summaryrefslogtreecommitdiff
path: root/www/wiki/includes
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2019-01-06 00:20:37 -0300
committerYaco <franco@reevo.org>2019-01-06 00:20:37 -0300
commitdab3fd4a501df5c3fc30b4c9fe79bfada4415958 (patch)
tree3d1971414457ff62418a69b6a95bc4b4e93ab5e9 /www/wiki/includes
parent71ddfdcf197d529e0964059ad7b796913908f2b3 (diff)
grandes avances previos al primer deployment en reevo.wiki
Diffstat (limited to 'www/wiki/includes')
-rw-r--r--www/wiki/includes/.htaccess1
-rw-r--r--www/wiki/includes/AjaxDispatcher.php163
-rw-r--r--www/wiki/includes/AjaxResponse.php315
-rw-r--r--www/wiki/includes/AuthPlugin.php368
-rw-r--r--www/wiki/includes/AutoLoader.php93
-rw-r--r--www/wiki/includes/Autopromote.php215
-rw-r--r--www/wiki/includes/Block.php1579
-rw-r--r--www/wiki/includes/CategoriesRdf.php95
-rw-r--r--www/wiki/includes/Category.php409
-rw-r--r--www/wiki/includes/CategoryFinder.php243
-rw-r--r--www/wiki/includes/CategoryViewer.php752
-rw-r--r--www/wiki/includes/CommentStore.php589
-rw-r--r--www/wiki/includes/CommentStoreComment.php92
-rw-r--r--www/wiki/includes/ConfiguredReadOnlyMode.php73
-rw-r--r--www/wiki/includes/DefaultSettings.php8772
-rw-r--r--www/wiki/includes/Defines.php297
-rw-r--r--www/wiki/includes/DeprecatedGlobal.php60
-rw-r--r--www/wiki/includes/DerivativeRequest.php87
-rw-r--r--www/wiki/includes/DummyLinker.php489
-rw-r--r--www/wiki/includes/EditPage.php4725
-rw-r--r--www/wiki/includes/EventRelayerGroup.php72
-rw-r--r--www/wiki/includes/FauxRequest.php246
-rw-r--r--www/wiki/includes/Feed.php429
-rw-r--r--www/wiki/includes/FeedUtils.php262
-rw-r--r--www/wiki/includes/FileDeleteForm.php436
-rw-r--r--www/wiki/includes/ForkController.php204
-rw-r--r--www/wiki/includes/FormOptions.php422
-rw-r--r--www/wiki/includes/GitInfo.php402
-rw-r--r--www/wiki/includes/GlobalFunctions.php3495
-rw-r--r--www/wiki/includes/HeaderCallback.php69
-rw-r--r--www/wiki/includes/HistoryBlob.php701
-rw-r--r--www/wiki/includes/Hooks.php244
-rw-r--r--www/wiki/includes/Html.php1010
-rw-r--r--www/wiki/includes/HtmlFormatter.php25
-rw-r--r--www/wiki/includes/HttpFunctions.php1122
-rw-r--r--www/wiki/includes/Licenses.php210
-rw-r--r--www/wiki/includes/LinkFilter.php191
-rw-r--r--www/wiki/includes/Linker.php2142
-rw-r--r--www/wiki/includes/ListToggle.php69
-rw-r--r--www/wiki/includes/MWGrants.php216
-rw-r--r--www/wiki/includes/MWNamespace.php525
-rw-r--r--www/wiki/includes/MWTimestamp.php210
-rw-r--r--www/wiki/includes/MagicWord.php676
-rw-r--r--www/wiki/includes/MagicWordArray.php338
-rw-r--r--www/wiki/includes/MediaWiki.php1043
-rw-r--r--www/wiki/includes/MediaWikiServices.php699
-rw-r--r--www/wiki/includes/MediaWikiVersionFetcher.php30
-rw-r--r--www/wiki/includes/MergeHistory.php354
-rw-r--r--www/wiki/includes/Message.php1364
-rw-r--r--www/wiki/includes/MimeMagic.php42
-rw-r--r--www/wiki/includes/MovePage.php614
-rw-r--r--www/wiki/includes/NoLocalSettings.php65
-rw-r--r--www/wiki/includes/OrderedStreamingForkController.php216
-rw-r--r--www/wiki/includes/OutputHandler.php238
-rw-r--r--www/wiki/includes/OutputPage.php4082
-rw-r--r--www/wiki/includes/PHPVersionCheck.php349
-rw-r--r--www/wiki/includes/PHPVersionError.php26
-rw-r--r--www/wiki/includes/PageProps.php316
-rw-r--r--www/wiki/includes/PathRouter.php399
-rw-r--r--www/wiki/includes/Pingback.php262
-rw-r--r--www/wiki/includes/PreConfigSetup.php54
-rw-r--r--www/wiki/includes/Preferences.php1649
-rw-r--r--www/wiki/includes/PrefixSearch.php406
-rw-r--r--www/wiki/includes/ProtectionForm.php630
-rw-r--r--www/wiki/includes/ProxyLookup.php85
-rw-r--r--www/wiki/includes/RawMessage.php72
-rw-r--r--www/wiki/includes/ReadOnlyMode.php68
-rw-r--r--www/wiki/includes/Revision.php1982
-rw-r--r--www/wiki/includes/RevisionList.php428
-rw-r--r--www/wiki/includes/Sanitizer.php2112
-rw-r--r--www/wiki/includes/ServiceWiring.php455
-rw-r--r--www/wiki/includes/Services/ServiceContainer.php222
-rw-r--r--www/wiki/includes/Setup.php887
-rw-r--r--www/wiki/includes/SiteConfiguration.php609
-rw-r--r--www/wiki/includes/SiteStats.php423
-rw-r--r--www/wiki/includes/Status.php401
-rw-r--r--www/wiki/includes/StreamFile.php144
-rw-r--r--www/wiki/includes/StubObject.php207
-rw-r--r--www/wiki/includes/TemplateParser.php220
-rw-r--r--www/wiki/includes/TemplatesOnThisPageFormatter.php183
-rw-r--r--www/wiki/includes/Title.php5027
-rw-r--r--www/wiki/includes/TitleArray.php59
-rw-r--r--www/wiki/includes/TitleArrayFromResult.php88
-rw-r--r--www/wiki/includes/TrackingCategories.php132
-rw-r--r--www/wiki/includes/WatchedItem.php200
-rw-r--r--www/wiki/includes/WatchedItemQueryService.php684
-rw-r--r--www/wiki/includes/WatchedItemQueryServiceExtension.php57
-rw-r--r--www/wiki/includes/WatchedItemStore.php986
-rw-r--r--www/wiki/includes/WebRequest.php1329
-rw-r--r--www/wiki/includes/WebRequestUpload.php142
-rw-r--r--www/wiki/includes/WebResponse.php318
-rw-r--r--www/wiki/includes/WebStart.php141
-rw-r--r--www/wiki/includes/WikiMap.php261
-rw-r--r--www/wiki/includes/WikiReference.php124
-rw-r--r--www/wiki/includes/Xml.php860
-rw-r--r--www/wiki/includes/XmlJsCode.php45
-rw-r--r--www/wiki/includes/XmlSelect.php140
-rw-r--r--www/wiki/includes/actions/Action.php430
-rw-r--r--www/wiki/includes/actions/CachedAction.php189
-rw-r--r--www/wiki/includes/actions/CreditsAction.php243
-rw-r--r--www/wiki/includes/actions/DeleteAction.php52
-rw-r--r--www/wiki/includes/actions/EditAction.php67
-rw-r--r--www/wiki/includes/actions/FormAction.php146
-rw-r--r--www/wiki/includes/actions/FormlessAction.php45
-rw-r--r--www/wiki/includes/actions/HistoryAction.php958
-rw-r--r--www/wiki/includes/actions/InfoAction.php924
-rw-r--r--www/wiki/includes/actions/MarkpatrolledAction.php139
-rw-r--r--www/wiki/includes/actions/ProtectAction.php58
-rw-r--r--www/wiki/includes/actions/PurgeAction.php109
-rw-r--r--www/wiki/includes/actions/RawAction.php246
-rw-r--r--www/wiki/includes/actions/RenderAction.php46
-rw-r--r--www/wiki/includes/actions/RevertAction.php168
-rw-r--r--www/wiki/includes/actions/RevisiondeleteAction.php65
-rw-r--r--www/wiki/includes/actions/RollbackAction.php164
-rw-r--r--www/wiki/includes/actions/SpecialPageAction.php97
-rw-r--r--www/wiki/includes/actions/SubmitAction.php40
-rw-r--r--www/wiki/includes/actions/UnprotectAction.php46
-rw-r--r--www/wiki/includes/actions/UnwatchAction.php65
-rw-r--r--www/wiki/includes/actions/ViewAction.php70
-rw-r--r--www/wiki/includes/actions/WatchAction.php196
-rw-r--r--www/wiki/includes/api/ApiAMCreateAccount.php137
-rw-r--r--www/wiki/includes/api/ApiAuthManagerHelper.php396
-rw-r--r--www/wiki/includes/api/ApiBase.php2959
-rw-r--r--www/wiki/includes/api/ApiBlock.php198
-rw-r--r--www/wiki/includes/api/ApiCSPReport.php242
-rw-r--r--www/wiki/includes/api/ApiChangeAuthenticationData.php98
-rw-r--r--www/wiki/includes/api/ApiCheckToken.php90
-rw-r--r--www/wiki/includes/api/ApiClearHasMsg.php55
-rw-r--r--www/wiki/includes/api/ApiClientLogin.php137
-rw-r--r--www/wiki/includes/api/ApiComparePages.php490
-rw-r--r--www/wiki/includes/api/ApiContinuationManager.php271
-rw-r--r--www/wiki/includes/api/ApiDelete.php233
-rw-r--r--www/wiki/includes/api/ApiDisabled.php58
-rw-r--r--www/wiki/includes/api/ApiEditPage.php616
-rw-r--r--www/wiki/includes/api/ApiEmailUser.php119
-rw-r--r--www/wiki/includes/api/ApiErrorFormatter.php458
-rw-r--r--www/wiki/includes/api/ApiExpandTemplates.php233
-rw-r--r--www/wiki/includes/api/ApiFeedContributions.php236
-rw-r--r--www/wiki/includes/api/ApiFeedRecentChanges.php193
-rw-r--r--www/wiki/includes/api/ApiFeedWatchlist.php312
-rw-r--r--www/wiki/includes/api/ApiFileRevert.php136
-rw-r--r--www/wiki/includes/api/ApiFormatBase.php376
-rw-r--r--www/wiki/includes/api/ApiFormatFeedWrapper.php117
-rw-r--r--www/wiki/includes/api/ApiFormatJson.php138
-rw-r--r--www/wiki/includes/api/ApiFormatNone.php39
-rw-r--r--www/wiki/includes/api/ApiFormatPhp.php87
-rw-r--r--www/wiki/includes/api/ApiFormatRaw.php120
-rw-r--r--www/wiki/includes/api/ApiFormatXml.php301
-rw-r--r--www/wiki/includes/api/ApiHelp.php898
-rw-r--r--www/wiki/includes/api/ApiHelpParamValueMessage.php95
-rw-r--r--www/wiki/includes/api/ApiImageRotate.php201
-rw-r--r--www/wiki/includes/api/ApiImport.php215
-rw-r--r--www/wiki/includes/api/ApiLinkAccount.php129
-rw-r--r--www/wiki/includes/api/ApiLogin.php312
-rw-r--r--www/wiki/includes/api/ApiLogout.php80
-rw-r--r--www/wiki/includes/api/ApiMain.php2032
-rw-r--r--www/wiki/includes/api/ApiManageTags.php130
-rw-r--r--www/wiki/includes/api/ApiMergeHistory.php142
-rw-r--r--www/wiki/includes/api/ApiMessage.php294
-rw-r--r--www/wiki/includes/api/ApiModuleManager.php294
-rw-r--r--www/wiki/includes/api/ApiMove.php297
-rw-r--r--www/wiki/includes/api/ApiOpenSearch.php418
-rw-r--r--www/wiki/includes/api/ApiOptions.php186
-rw-r--r--www/wiki/includes/api/ApiPageSet.php1545
-rw-r--r--www/wiki/includes/api/ApiParamInfo.php581
-rw-r--r--www/wiki/includes/api/ApiParse.php918
-rw-r--r--www/wiki/includes/api/ApiPatrol.php117
-rw-r--r--www/wiki/includes/api/ApiProtect.php204
-rw-r--r--www/wiki/includes/api/ApiPurge.php182
-rw-r--r--www/wiki/includes/api/ApiQuery.php548
-rw-r--r--www/wiki/includes/api/ApiQueryAllCategories.php205
-rw-r--r--www/wiki/includes/api/ApiQueryAllDeletedRevisions.php460
-rw-r--r--www/wiki/includes/api/ApiQueryAllImages.php431
-rw-r--r--www/wiki/includes/api/ApiQueryAllLinks.php313
-rw-r--r--www/wiki/includes/api/ApiQueryAllMessages.php261
-rw-r--r--www/wiki/includes/api/ApiQueryAllPages.php360
-rw-r--r--www/wiki/includes/api/ApiQueryAllRevisions.php295
-rw-r--r--www/wiki/includes/api/ApiQueryAllUsers.php395
-rw-r--r--www/wiki/includes/api/ApiQueryAuthManagerInfo.php132
-rw-r--r--www/wiki/includes/api/ApiQueryBacklinks.php580
-rw-r--r--www/wiki/includes/api/ApiQueryBacklinksprop.php437
-rw-r--r--www/wiki/includes/api/ApiQueryBase.php616
-rw-r--r--www/wiki/includes/api/ApiQueryBlocks.php347
-rw-r--r--www/wiki/includes/api/ApiQueryCategories.php232
-rw-r--r--www/wiki/includes/api/ApiQueryCategoryInfo.php120
-rw-r--r--www/wiki/includes/api/ApiQueryCategoryMembers.php396
-rw-r--r--www/wiki/includes/api/ApiQueryContributors.php259
-rw-r--r--www/wiki/includes/api/ApiQueryDeletedRevisions.php293
-rw-r--r--www/wiki/includes/api/ApiQueryDeletedrevs.php518
-rw-r--r--www/wiki/includes/api/ApiQueryDisabled.php58
-rw-r--r--www/wiki/includes/api/ApiQueryDuplicateFiles.php194
-rw-r--r--www/wiki/includes/api/ApiQueryExtLinksUsage.php235
-rw-r--r--www/wiki/includes/api/ApiQueryExternalLinks.php139
-rw-r--r--www/wiki/includes/api/ApiQueryFileRepoInfo.php116
-rw-r--r--www/wiki/includes/api/ApiQueryFilearchive.php304
-rw-r--r--www/wiki/includes/api/ApiQueryGeneratorBase.php109
-rw-r--r--www/wiki/includes/api/ApiQueryIWBacklinks.php220
-rw-r--r--www/wiki/includes/api/ApiQueryIWLinks.php199
-rw-r--r--www/wiki/includes/api/ApiQueryImageInfo.php826
-rw-r--r--www/wiki/includes/api/ApiQueryImages.php177
-rw-r--r--www/wiki/includes/api/ApiQueryInfo.php951
-rw-r--r--www/wiki/includes/api/ApiQueryLangBacklinks.php219
-rw-r--r--www/wiki/includes/api/ApiQueryLangLinks.php195
-rw-r--r--www/wiki/includes/api/ApiQueryLinks.php227
-rw-r--r--www/wiki/includes/api/ApiQueryLogEvents.php482
-rw-r--r--www/wiki/includes/api/ApiQueryMyStashedFiles.php150
-rw-r--r--www/wiki/includes/api/ApiQueryPagePropNames.php112
-rw-r--r--www/wiki/includes/api/ApiQueryPageProps.php125
-rw-r--r--www/wiki/includes/api/ApiQueryPagesWithProp.php177
-rw-r--r--www/wiki/includes/api/ApiQueryPrefixSearch.php132
-rw-r--r--www/wiki/includes/api/ApiQueryProtectedTitles.php248
-rw-r--r--www/wiki/includes/api/ApiQueryQueryPage.php171
-rw-r--r--www/wiki/includes/api/ApiQueryRandom.php223
-rw-r--r--www/wiki/includes/api/ApiQueryRecentChanges.php715
-rw-r--r--www/wiki/includes/api/ApiQueryRevisions.php517
-rw-r--r--www/wiki/includes/api/ApiQueryRevisionsBase.php526
-rw-r--r--www/wiki/includes/api/ApiQuerySearch.php413
-rw-r--r--www/wiki/includes/api/ApiQuerySiteinfo.php938
-rw-r--r--www/wiki/includes/api/ApiQueryStashImageInfo.php128
-rw-r--r--www/wiki/includes/api/ApiQueryTags.php170
-rw-r--r--www/wiki/includes/api/ApiQueryTokens.php136
-rw-r--r--www/wiki/includes/api/ApiQueryUserContributions.php596
-rw-r--r--www/wiki/includes/api/ApiQueryUserInfo.php353
-rw-r--r--www/wiki/includes/api/ApiQueryUsers.php409
-rw-r--r--www/wiki/includes/api/ApiQueryWatchlist.php512
-rw-r--r--www/wiki/includes/api/ApiQueryWatchlistRaw.php204
-rw-r--r--www/wiki/includes/api/ApiRemoveAuthenticationData.php111
-rw-r--r--www/wiki/includes/api/ApiResetPassword.php139
-rw-r--r--www/wiki/includes/api/ApiResult.php1229
-rw-r--r--www/wiki/includes/api/ApiRevisionDelete.php204
-rw-r--r--www/wiki/includes/api/ApiRollback.php207
-rw-r--r--www/wiki/includes/api/ApiRsd.php169
-rw-r--r--www/wiki/includes/api/ApiSerializable.php47
-rw-r--r--www/wiki/includes/api/ApiSetNotificationTimestamp.php253
-rw-r--r--www/wiki/includes/api/ApiSetPageLanguage.php149
-rw-r--r--www/wiki/includes/api/ApiStashEdit.php472
-rw-r--r--www/wiki/includes/api/ApiTag.php192
-rw-r--r--www/wiki/includes/api/ApiTokens.php116
-rw-r--r--www/wiki/includes/api/ApiUnblock.php137
-rw-r--r--www/wiki/includes/api/ApiUndelete.php153
-rw-r--r--www/wiki/includes/api/ApiUpload.php933
-rw-r--r--www/wiki/includes/api/ApiUsageException.php232
-rw-r--r--www/wiki/includes/api/ApiUserrights.php219
-rw-r--r--www/wiki/includes/api/ApiValidatePassword.php81
-rw-r--r--www/wiki/includes/api/ApiWatch.php188
-rw-r--r--www/wiki/includes/api/SearchApi.php197
-rw-r--r--www/wiki/includes/api/i18n/ar.json423
-rw-r--r--www/wiki/includes/api/i18n/ast.json37
-rw-r--r--www/wiki/includes/api/i18n/av.json8
-rw-r--r--www/wiki/includes/api/i18n/awa.json11
-rw-r--r--www/wiki/includes/api/i18n/azb.json15
-rw-r--r--www/wiki/includes/api/i18n/ba.json442
-rw-r--r--www/wiki/includes/api/i18n/bcl.json10
-rw-r--r--www/wiki/includes/api/i18n/be-tarask.json62
-rw-r--r--www/wiki/includes/api/i18n/bg.json66
-rw-r--r--www/wiki/includes/api/i18n/bgn.json11
-rw-r--r--www/wiki/includes/api/i18n/bn.json28
-rw-r--r--www/wiki/includes/api/i18n/br.json35
-rw-r--r--www/wiki/includes/api/i18n/bs.json17
-rw-r--r--www/wiki/includes/api/i18n/ca.json61
-rw-r--r--www/wiki/includes/api/i18n/ce.json29
-rw-r--r--www/wiki/includes/api/i18n/ckb.json10
-rw-r--r--www/wiki/includes/api/i18n/cs.json295
-rw-r--r--www/wiki/includes/api/i18n/cv.json8
-rw-r--r--www/wiki/includes/api/i18n/da.json8
-rw-r--r--www/wiki/includes/api/i18n/de.json1089
-rw-r--r--www/wiki/includes/api/i18n/diq.json66
-rw-r--r--www/wiki/includes/api/i18n/el.json109
-rw-r--r--www/wiki/includes/api/i18n/en-gb.json154
-rw-r--r--www/wiki/includes/api/i18n/en.json1875
-rw-r--r--www/wiki/includes/api/i18n/eo.json30
-rw-r--r--www/wiki/includes/api/i18n/es.json1633
-rw-r--r--www/wiki/includes/api/i18n/et.json42
-rw-r--r--www/wiki/includes/api/i18n/eu.json246
-rw-r--r--www/wiki/includes/api/i18n/fa.json338
-rw-r--r--www/wiki/includes/api/i18n/fi.json99
-rw-r--r--www/wiki/includes/api/i18n/fo.json39
-rw-r--r--www/wiki/includes/api/i18n/fr.json1766
-rw-r--r--www/wiki/includes/api/i18n/frc.json20
-rw-r--r--www/wiki/includes/api/i18n/fy.json15
-rw-r--r--www/wiki/includes/api/i18n/gl.json1711
-rw-r--r--www/wiki/includes/api/i18n/he.json1753
-rw-r--r--www/wiki/includes/api/i18n/hr.json9
-rw-r--r--www/wiki/includes/api/i18n/hsb.json8
-rw-r--r--www/wiki/includes/api/i18n/hsn.json9
-rw-r--r--www/wiki/includes/api/i18n/ht.json8
-rw-r--r--www/wiki/includes/api/i18n/hu.json1168
-rw-r--r--www/wiki/includes/api/i18n/ia.json63
-rw-r--r--www/wiki/includes/api/i18n/id.json105
-rw-r--r--www/wiki/includes/api/i18n/is.json10
-rw-r--r--www/wiki/includes/api/i18n/it.json693
-rw-r--r--www/wiki/includes/api/i18n/ja.json968
-rw-r--r--www/wiki/includes/api/i18n/jam.json8
-rw-r--r--www/wiki/includes/api/i18n/jv.json11
-rw-r--r--www/wiki/includes/api/i18n/ka.json11
-rw-r--r--www/wiki/includes/api/i18n/kn.json8
-rw-r--r--www/wiki/includes/api/i18n/ko.json830
-rw-r--r--www/wiki/includes/api/i18n/ksh.json1048
-rw-r--r--www/wiki/includes/api/i18n/ku-latn.json44
-rw-r--r--www/wiki/includes/api/i18n/ky.json20
-rw-r--r--www/wiki/includes/api/i18n/lb.json253
-rw-r--r--www/wiki/includes/api/i18n/lij.json221
-rw-r--r--www/wiki/includes/api/i18n/lki.json28
-rw-r--r--www/wiki/includes/api/i18n/ln.json8
-rw-r--r--www/wiki/includes/api/i18n/lt.json422
-rw-r--r--www/wiki/includes/api/i18n/lv.json14
-rw-r--r--www/wiki/includes/api/i18n/lzh.json8
-rw-r--r--www/wiki/includes/api/i18n/mg.json37
-rw-r--r--www/wiki/includes/api/i18n/mk.json433
-rw-r--r--www/wiki/includes/api/i18n/mr.json107
-rw-r--r--www/wiki/includes/api/i18n/ms.json58
-rw-r--r--www/wiki/includes/api/i18n/my.json10
-rw-r--r--www/wiki/includes/api/i18n/nap.json148
-rw-r--r--www/wiki/includes/api/i18n/nb.json263
-rw-r--r--www/wiki/includes/api/i18n/nds.json8
-rw-r--r--www/wiki/includes/api/i18n/ne.json13
-rw-r--r--www/wiki/includes/api/i18n/nl.json392
-rw-r--r--www/wiki/includes/api/i18n/nso.json9
-rw-r--r--www/wiki/includes/api/i18n/oc.json135
-rw-r--r--www/wiki/includes/api/i18n/olo.json13
-rw-r--r--www/wiki/includes/api/i18n/or.json14
-rw-r--r--www/wiki/includes/api/i18n/pa.json8
-rw-r--r--www/wiki/includes/api/i18n/pam.json32
-rw-r--r--www/wiki/includes/api/i18n/pl.json712
-rw-r--r--www/wiki/includes/api/i18n/ps.json73
-rw-r--r--www/wiki/includes/api/i18n/pt-br.json1751
-rw-r--r--www/wiki/includes/api/i18n/pt.json1745
-rw-r--r--www/wiki/includes/api/i18n/qqq.json1762
-rw-r--r--www/wiki/includes/api/i18n/ro.json19
-rw-r--r--www/wiki/includes/api/i18n/roa-tara.json11
-rw-r--r--www/wiki/includes/api/i18n/ru.json1764
-rw-r--r--www/wiki/includes/api/i18n/sah.json9
-rw-r--r--www/wiki/includes/api/i18n/sd.json15
-rw-r--r--www/wiki/includes/api/i18n/sh.json8
-rw-r--r--www/wiki/includes/api/i18n/shn.json8
-rw-r--r--www/wiki/includes/api/i18n/si.json65
-rw-r--r--www/wiki/includes/api/i18n/sk.json8
-rw-r--r--www/wiki/includes/api/i18n/sq.json15
-rw-r--r--www/wiki/includes/api/i18n/sr-ec.json28
-rw-r--r--www/wiki/includes/api/i18n/sr-el.json12
-rw-r--r--www/wiki/includes/api/i18n/sv.json535
-rw-r--r--www/wiki/includes/api/i18n/ta.json28
-rw-r--r--www/wiki/includes/api/i18n/tcy.json22
-rw-r--r--www/wiki/includes/api/i18n/te.json21
-rw-r--r--www/wiki/includes/api/i18n/th.json9
-rw-r--r--www/wiki/includes/api/i18n/tl.json38
-rw-r--r--www/wiki/includes/api/i18n/tr.json58
-rw-r--r--www/wiki/includes/api/i18n/tt-cyrl.json8
-rw-r--r--www/wiki/includes/api/i18n/udm.json11
-rw-r--r--www/wiki/includes/api/i18n/uk.json1750
-rw-r--r--www/wiki/includes/api/i18n/ur.json8
-rw-r--r--www/wiki/includes/api/i18n/vi.json225
-rw-r--r--www/wiki/includes/api/i18n/wuu.json8
-rw-r--r--www/wiki/includes/api/i18n/yi.json10
-rw-r--r--www/wiki/includes/api/i18n/zh-hans.json1760
-rw-r--r--www/wiki/includes/api/i18n/zh-hant.json315
-rw-r--r--www/wiki/includes/api/i18n/zu.json10
-rw-r--r--www/wiki/includes/auth/AbstractAuthenticationProvider.php59
-rw-r--r--www/wiki/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php171
-rw-r--r--www/wiki/includes/auth/AbstractPreAuthenticationProvider.php62
-rw-r--r--www/wiki/includes/auth/AbstractPrimaryAuthenticationProvider.php118
-rw-r--r--www/wiki/includes/auth/AbstractSecondaryAuthenticationProvider.php86
-rw-r--r--www/wiki/includes/auth/AuthManager.php2450
-rw-r--r--www/wiki/includes/auth/AuthManagerAuthPlugin.php229
-rw-r--r--www/wiki/includes/auth/AuthPluginPrimaryAuthenticationProvider.php429
-rw-r--r--www/wiki/includes/auth/AuthenticationProvider.php98
-rw-r--r--www/wiki/includes/auth/AuthenticationRequest.php379
-rw-r--r--www/wiki/includes/auth/AuthenticationResponse.php219
-rw-r--r--www/wiki/includes/auth/ButtonAuthenticationRequest.php108
-rw-r--r--www/wiki/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php101
-rw-r--r--www/wiki/includes/auth/ConfirmLinkAuthenticationRequest.php80
-rw-r--r--www/wiki/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php158
-rw-r--r--www/wiki/includes/auth/CreateFromLoginAuthenticationRequest.php96
-rw-r--r--www/wiki/includes/auth/CreatedAccountAuthenticationRequest.php48
-rw-r--r--www/wiki/includes/auth/CreationReasonAuthenticationRequest.php24
-rw-r--r--www/wiki/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php70
-rw-r--r--www/wiki/includes/auth/LegacyHookPreAuthenticationProvider.php181
-rw-r--r--www/wiki/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php324
-rw-r--r--www/wiki/includes/auth/PasswordAuthenticationRequest.php85
-rw-r--r--www/wiki/includes/auth/PasswordDomainAuthenticationRequest.php85
-rw-r--r--www/wiki/includes/auth/PreAuthenticationProvider.php148
-rw-r--r--www/wiki/includes/auth/PrimaryAuthenticationProvider.php400
-rw-r--r--www/wiki/includes/auth/RememberMeAuthenticationRequest.php65
-rw-r--r--www/wiki/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php133
-rw-r--r--www/wiki/includes/auth/SecondaryAuthenticationProvider.php258
-rw-r--r--www/wiki/includes/auth/TemporaryPasswordAuthenticationRequest.php101
-rw-r--r--www/wiki/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php477
-rw-r--r--www/wiki/includes/auth/ThrottlePreAuthenticationProvider.php180
-rw-r--r--www/wiki/includes/auth/Throttler.php208
-rw-r--r--www/wiki/includes/auth/UserDataAuthenticationRequest.php89
-rw-r--r--www/wiki/includes/auth/UsernameAuthenticationRequest.php39
-rw-r--r--www/wiki/includes/cache/BacklinkCache.php546
-rw-r--r--www/wiki/includes/cache/CacheDependency.php294
-rw-r--r--www/wiki/includes/cache/CacheHelper.php388
-rw-r--r--www/wiki/includes/cache/FileCacheBase.php278
-rw-r--r--www/wiki/includes/cache/GenderCache.php188
-rw-r--r--www/wiki/includes/cache/HTMLFileCache.php246
-rw-r--r--www/wiki/includes/cache/LinkBatch.php246
-rw-r--r--www/wiki/includes/cache/LinkCache.php338
-rw-r--r--www/wiki/includes/cache/MessageBlobStore.php253
-rw-r--r--www/wiki/includes/cache/MessageCache.php1331
-rw-r--r--www/wiki/includes/cache/ObjectFileCache.php52
-rw-r--r--www/wiki/includes/cache/ResourceFileCache.php117
-rw-r--r--www/wiki/includes/cache/UserCache.php147
-rw-r--r--www/wiki/includes/cache/localisation/LCStore.php66
-rw-r--r--www/wiki/includes/cache/localisation/LCStoreCDB.php144
-rw-r--r--www/wiki/includes/cache/localisation/LCStoreDB.php117
-rw-r--r--www/wiki/includes/cache/localisation/LCStoreNull.php39
-rw-r--r--www/wiki/includes/cache/localisation/LCStoreStaticArray.php140
-rw-r--r--www/wiki/includes/cache/localisation/LocalisationCache.php1096
-rw-r--r--www/wiki/includes/cache/localisation/LocalisationCacheBulkLoad.php126
-rw-r--r--www/wiki/includes/changes/CategoryMembershipChange.php286
-rw-r--r--www/wiki/includes/changes/ChangesFeed.php241
-rw-r--r--www/wiki/includes/changes/ChangesList.php785
-rw-r--r--www/wiki/includes/changes/ChangesListBooleanFilter.php261
-rw-r--r--www/wiki/includes/changes/ChangesListBooleanFilterGroup.php67
-rw-r--r--www/wiki/includes/changes/ChangesListFilter.php497
-rw-r--r--www/wiki/includes/changes/ChangesListFilterGroup.php452
-rw-r--r--www/wiki/includes/changes/ChangesListStringOptionsFilter.php30
-rw-r--r--www/wiki/includes/changes/ChangesListStringOptionsFilterGroup.php245
-rw-r--r--www/wiki/includes/changes/EnhancedChangesList.php806
-rw-r--r--www/wiki/includes/changes/OldChangesList.php156
-rw-r--r--www/wiki/includes/changes/RCCacheEntry.php45
-rw-r--r--www/wiki/includes/changes/RCCacheEntryFactory.php298
-rw-r--r--www/wiki/includes/changes/RecentChange.php1088
-rw-r--r--www/wiki/includes/changetags/ChangeTags.php1424
-rw-r--r--www/wiki/includes/changetags/ChangeTagsList.php78
-rw-r--r--www/wiki/includes/changetags/ChangeTagsLogItem.php106
-rw-r--r--www/wiki/includes/changetags/ChangeTagsLogList.php90
-rw-r--r--www/wiki/includes/changetags/ChangeTagsRevisionItem.php62
-rw-r--r--www/wiki/includes/changetags/ChangeTagsRevisionList.php100
-rw-r--r--www/wiki/includes/clientpool/RedisConnectionPool.php581
-rw-r--r--www/wiki/includes/clientpool/SquidPurgeClient.php396
-rw-r--r--www/wiki/includes/clientpool/SquidPurgeClientPool.php108
-rw-r--r--www/wiki/includes/collation/BashkirUppercaseCollation.php71
-rw-r--r--www/wiki/includes/collation/Collation.php131
-rw-r--r--www/wiki/includes/collation/CollationCkb.php35
-rw-r--r--www/wiki/includes/collation/CollationEt.php60
-rw-r--r--www/wiki/includes/collation/CollationFa.php60
-rw-r--r--www/wiki/includes/collation/CustomUppercaseCollation.php87
-rw-r--r--www/wiki/includes/collation/IcuCollation.php579
-rw-r--r--www/wiki/includes/collation/IdentityCollation.php44
-rw-r--r--www/wiki/includes/collation/NumericUppercaseCollation.php105
-rw-r--r--www/wiki/includes/collation/UppercaseCollation.php44
-rw-r--r--www/wiki/includes/compat/CdbCompat.php45
-rw-r--r--www/wiki/includes/compat/IPSetCompat.php28
-rw-r--r--www/wiki/includes/compat/MemcachedClientCompat.php34
-rw-r--r--www/wiki/includes/compat/RunningStatCompat.php28
-rw-r--r--www/wiki/includes/compat/ScopedCallback.php29
-rw-r--r--www/wiki/includes/compat/Timestamp.php18
-rw-r--r--www/wiki/includes/compat/normal/UtfNormal.php129
-rw-r--r--www/wiki/includes/compat/normal/UtfNormalDefines.php186
-rw-r--r--www/wiki/includes/compat/normal/UtfNormalUtil.php99
-rw-r--r--www/wiki/includes/composer/ComposerHookHandler.php37
-rw-r--r--www/wiki/includes/composer/ComposerPackageModifier.php62
-rw-r--r--www/wiki/includes/composer/ComposerVendorHtaccessCreator.php43
-rw-r--r--www/wiki/includes/composer/ComposerVersionNormalizer.php66
-rw-r--r--www/wiki/includes/config/Config.php47
-rw-r--r--www/wiki/includes/config/ConfigException.php29
-rw-r--r--www/wiki/includes/config/ConfigFactory.php150
-rw-r--r--www/wiki/includes/config/EtcdConfig.php315
-rw-r--r--www/wiki/includes/config/EtcdConfigParseError.php4
-rw-r--r--www/wiki/includes/config/GlobalVarConfig.php87
-rw-r--r--www/wiki/includes/config/HashConfig.php78
-rw-r--r--www/wiki/includes/config/MultiConfig.php72
-rw-r--r--www/wiki/includes/config/MutableConfig.php38
-rw-r--r--www/wiki/includes/content/AbstractContent.php551
-rw-r--r--www/wiki/includes/content/CodeContentHandler.php67
-rw-r--r--www/wiki/includes/content/Content.php526
-rw-r--r--www/wiki/includes/content/ContentHandler.php1213
-rw-r--r--www/wiki/includes/content/CssContent.php121
-rw-r--r--www/wiki/includes/content/CssContentHandler.php61
-rw-r--r--www/wiki/includes/content/FileContentHandler.php65
-rw-r--r--www/wiki/includes/content/JavaScriptContent.php123
-rw-r--r--www/wiki/includes/content/JavaScriptContentHandler.php62
-rw-r--r--www/wiki/includes/content/JsonContent.php251
-rw-r--r--www/wiki/includes/content/JsonContentHandler.php47
-rw-r--r--www/wiki/includes/content/MessageContent.php174
-rw-r--r--www/wiki/includes/content/TextContent.php325
-rw-r--r--www/wiki/includes/content/TextContentHandler.php164
-rw-r--r--www/wiki/includes/content/WikiTextStructure.php251
-rw-r--r--www/wiki/includes/content/WikitextContent.php369
-rw-r--r--www/wiki/includes/content/WikitextContentHandler.php163
-rw-r--r--www/wiki/includes/context/ContextSource.php205
-rw-r--r--www/wiki/includes/context/DerivativeContext.php336
-rw-r--r--www/wiki/includes/context/IContextSource.php154
-rw-r--r--www/wiki/includes/context/MutableContext.php82
-rw-r--r--www/wiki/includes/context/RequestContext.php652
-rw-r--r--www/wiki/includes/dao/DBAccessBase.php95
-rw-r--r--www/wiki/includes/dao/DBAccessObjectUtils.php81
-rw-r--r--www/wiki/includes/dao/IDBAccessObject.php71
-rw-r--r--www/wiki/includes/db/ChronologyProtector.php209
-rw-r--r--www/wiki/includes/db/CloneDatabase.php143
-rw-r--r--www/wiki/includes/db/DBConnRef.php544
-rw-r--r--www/wiki/includes/db/Database.php3325
-rw-r--r--www/wiki/includes/db/DatabaseError.php472
-rw-r--r--www/wiki/includes/db/DatabaseMssql.php1558
-rw-r--r--www/wiki/includes/db/DatabaseMysql.php210
-rw-r--r--www/wiki/includes/db/DatabaseMysqlBase.php1512
-rw-r--r--www/wiki/includes/db/DatabaseMysqli.php332
-rw-r--r--www/wiki/includes/db/DatabaseOracle.php1370
-rw-r--r--www/wiki/includes/db/DatabasePostgres.php1626
-rw-r--r--www/wiki/includes/db/DatabaseSqlite.php1085
-rw-r--r--www/wiki/includes/db/DatabaseUtility.php347
-rw-r--r--www/wiki/includes/db/IDatabase.php1596
-rw-r--r--www/wiki/includes/db/MWLBFactory.php202
-rw-r--r--www/wiki/includes/db/ORAField.php53
-rw-r--r--www/wiki/includes/db/ORAResult.php110
-rw-r--r--www/wiki/includes/db/loadbalancer/LBFactory.php481
-rw-r--r--www/wiki/includes/db/loadbalancer/LBFactoryFake.php49
-rw-r--r--www/wiki/includes/db/loadbalancer/LBFactoryMulti.php424
-rw-r--r--www/wiki/includes/db/loadbalancer/LBFactorySimple.php167
-rw-r--r--www/wiki/includes/db/loadbalancer/LBFactorySingle.php127
-rw-r--r--www/wiki/includes/db/loadbalancer/LoadBalancer.php1407
-rw-r--r--www/wiki/includes/db/loadbalancer/LoadMonitor.php78
-rw-r--r--www/wiki/includes/db/loadbalancer/LoadMonitorMySQL.php148
-rw-r--r--www/wiki/includes/debug/MWDebug.php558
-rw-r--r--www/wiki/includes/debug/logger/ConsoleLogger.php21
-rw-r--r--www/wiki/includes/debug/logger/ConsoleSpi.php11
-rw-r--r--www/wiki/includes/debug/logger/LegacyLogger.php482
-rw-r--r--www/wiki/includes/debug/logger/LegacySpi.php57
-rw-r--r--www/wiki/includes/debug/logger/LoggerFactory.php102
-rw-r--r--www/wiki/includes/debug/logger/MonologSpi.php271
-rw-r--r--www/wiki/includes/debug/logger/NullSpi.php60
-rw-r--r--www/wiki/includes/debug/logger/Spi.php46
-rw-r--r--www/wiki/includes/debug/logger/monolog/AvroFormatter.php171
-rw-r--r--www/wiki/includes/debug/logger/monolog/BufferHandler.php46
-rw-r--r--www/wiki/includes/debug/logger/monolog/KafkaHandler.php279
-rw-r--r--www/wiki/includes/debug/logger/monolog/LegacyFormatter.php47
-rw-r--r--www/wiki/includes/debug/logger/monolog/LegacyHandler.php236
-rw-r--r--www/wiki/includes/debug/logger/monolog/LineFormatter.php172
-rw-r--r--www/wiki/includes/debug/logger/monolog/LogstashFormatter.php111
-rw-r--r--www/wiki/includes/debug/logger/monolog/SyslogHandler.php94
-rw-r--r--www/wiki/includes/debug/logger/monolog/WikiProcessor.php48
-rw-r--r--www/wiki/includes/deferred/AtomicSectionUpdate.php48
-rw-r--r--www/wiki/includes/deferred/AutoCommitUpdate.php62
-rw-r--r--www/wiki/includes/deferred/CallableUpdate.php24
-rw-r--r--www/wiki/includes/deferred/CdnCacheUpdate.php296
-rw-r--r--www/wiki/includes/deferred/DataUpdate.php56
-rw-r--r--www/wiki/includes/deferred/DeferrableCallback.php13
-rw-r--r--www/wiki/includes/deferred/DeferrableUpdate.php14
-rw-r--r--www/wiki/includes/deferred/DeferredUpdates.php377
-rw-r--r--www/wiki/includes/deferred/EnqueueableDataUpdate.php15
-rw-r--r--www/wiki/includes/deferred/HTMLCacheUpdate.php50
-rw-r--r--www/wiki/includes/deferred/LinksDeletionUpdate.php242
-rw-r--r--www/wiki/includes/deferred/LinksUpdate.php1165
-rw-r--r--www/wiki/includes/deferred/MWCallableUpdate.php43
-rw-r--r--www/wiki/includes/deferred/MergeableUpdate.php16
-rw-r--r--www/wiki/includes/deferred/SearchUpdate.php225
-rw-r--r--www/wiki/includes/deferred/SiteStatsUpdate.php271
-rw-r--r--www/wiki/includes/deferred/SqlDataUpdate.php40
-rw-r--r--www/wiki/includes/deferred/WANCacheReapUpdate.php133
-rw-r--r--www/wiki/includes/diff/ArrayDiffFormatter.php82
-rw-r--r--www/wiki/includes/diff/ComplexityException.php30
-rw-r--r--www/wiki/includes/diff/DairikiDiff.php334
-rw-r--r--www/wiki/includes/diff/DiffEngine.php841
-rw-r--r--www/wiki/includes/diff/DiffFormatter.php254
-rw-r--r--www/wiki/includes/diff/DifferenceEngine.php1442
-rw-r--r--www/wiki/includes/diff/TableDiffFormatter.php216
-rw-r--r--www/wiki/includes/diff/UnifiedDiffFormatter.php84
-rw-r--r--www/wiki/includes/diff/WikiDiff3.php621
-rw-r--r--www/wiki/includes/diff/WordAccumulator.php105
-rw-r--r--www/wiki/includes/diff/WordLevelDiff.php139
-rw-r--r--www/wiki/includes/edit/PreparedEdit.php113
-rw-r--r--www/wiki/includes/exception/BadRequestError.php34
-rw-r--r--www/wiki/includes/exception/BadTitleError.php49
-rw-r--r--www/wiki/includes/exception/ErrorPageError.php72
-rw-r--r--www/wiki/includes/exception/FatalError.php43
-rw-r--r--www/wiki/includes/exception/HttpError.php129
-rw-r--r--www/wiki/includes/exception/LocalizedException.php66
-rw-r--r--www/wiki/includes/exception/MWContentSerializationException.php8
-rw-r--r--www/wiki/includes/exception/MWException.php230
-rw-r--r--www/wiki/includes/exception/MWExceptionHandler.php665
-rw-r--r--www/wiki/includes/exception/MWExceptionRenderer.php372
-rw-r--r--www/wiki/includes/exception/MWUnknownContentModelException.php25
-rw-r--r--www/wiki/includes/exception/PermissionsError.php72
-rw-r--r--www/wiki/includes/exception/ProcOpenError.php29
-rw-r--r--www/wiki/includes/exception/ReadOnlyError.php36
-rw-r--r--www/wiki/includes/exception/ShellDisabledError.php32
-rw-r--r--www/wiki/includes/exception/ThrottledError.php40
-rw-r--r--www/wiki/includes/exception/TimestampException.php7
-rw-r--r--www/wiki/includes/exception/UserBlockedError.php33
-rw-r--r--www/wiki/includes/exception/UserNotLoggedIn.php104
-rw-r--r--www/wiki/includes/export/Dump7ZipOutput.php76
-rw-r--r--www/wiki/includes/export/DumpBZip2Output.php36
-rw-r--r--www/wiki/includes/export/DumpDBZip2Output.php36
-rw-r--r--www/wiki/includes/export/DumpFileOutput.php115
-rw-r--r--www/wiki/includes/export/DumpFilter.php134
-rw-r--r--www/wiki/includes/export/DumpGZipOutput.php36
-rw-r--r--www/wiki/includes/export/DumpLatestFilter.php72
-rw-r--r--www/wiki/includes/export/DumpMultiWriter.php113
-rw-r--r--www/wiki/includes/export/DumpNamespaceFilter.php91
-rw-r--r--www/wiki/includes/export/DumpNotalkFilter.php37
-rw-r--r--www/wiki/includes/export/DumpOutput.php114
-rw-r--r--www/wiki/includes/export/DumpPipeOutput.php102
-rw-r--r--www/wiki/includes/export/DumpStringOutput.php45
-rw-r--r--www/wiki/includes/export/WikiExporter.php502
-rw-r--r--www/wiki/includes/export/XmlDumpWriter.php449
-rw-r--r--www/wiki/includes/externalstore/ExternalStore.php229
-rw-r--r--www/wiki/includes/externalstore/ExternalStoreDB.php301
-rw-r--r--www/wiki/includes/externalstore/ExternalStoreHttp.php50
-rw-r--r--www/wiki/includes/externalstore/ExternalStoreMedium.php79
-rw-r--r--www/wiki/includes/externalstore/ExternalStoreMwstore.php106
-rw-r--r--www/wiki/includes/filebackend/FSFile.php280
-rw-r--r--www/wiki/includes/filebackend/FSFileBackend.php975
-rw-r--r--www/wiki/includes/filebackend/FileBackend.php1545
-rw-r--r--www/wiki/includes/filebackend/FileBackendGroup.php246
-rw-r--r--www/wiki/includes/filebackend/FileBackendMultiWrite.php761
-rw-r--r--www/wiki/includes/filebackend/FileBackendStore.php1971
-rw-r--r--www/wiki/includes/filebackend/FileOp.php848
-rw-r--r--www/wiki/includes/filebackend/FileOpBatch.php202
-rw-r--r--www/wiki/includes/filebackend/MemoryFileBackend.php278
-rw-r--r--www/wiki/includes/filebackend/README208
-rw-r--r--www/wiki/includes/filebackend/SwiftFileBackend.php1910
-rw-r--r--www/wiki/includes/filebackend/TempFSFile.php157
-rw-r--r--www/wiki/includes/filebackend/filejournal/DBFileJournal.php193
-rw-r--r--www/wiki/includes/filebackend/filejournal/FileJournal.php251
-rw-r--r--www/wiki/includes/filebackend/lockmanager/DBLockManager.php433
-rw-r--r--www/wiki/includes/filebackend/lockmanager/FSLockManager.php248
-rw-r--r--www/wiki/includes/filebackend/lockmanager/LockManager.php258
-rw-r--r--www/wiki/includes/filebackend/lockmanager/LockManagerGroup.php176
-rw-r--r--www/wiki/includes/filebackend/lockmanager/MemcLockManager.php384
-rw-r--r--www/wiki/includes/filebackend/lockmanager/MySqlLockManager.php141
-rw-r--r--www/wiki/includes/filebackend/lockmanager/QuorumLockManager.php248
-rw-r--r--www/wiki/includes/filebackend/lockmanager/RedisLockManager.php272
-rw-r--r--www/wiki/includes/filebackend/lockmanager/ScopedLock.php105
-rw-r--r--www/wiki/includes/filerepo/FSRepo.php80
-rw-r--r--www/wiki/includes/filerepo/FileBackendDBRepoWrapper.php360
-rw-r--r--www/wiki/includes/filerepo/FileRepo.php1936
-rw-r--r--www/wiki/includes/filerepo/FileRepoStatus.php30
-rw-r--r--www/wiki/includes/filerepo/ForeignAPIRepo.php605
-rw-r--r--www/wiki/includes/filerepo/ForeignDBRepo.php156
-rw-r--r--www/wiki/includes/filerepo/ForeignDBViaLBRepo.php109
-rw-r--r--www/wiki/includes/filerepo/LocalRepo.php586
-rw-r--r--www/wiki/includes/filerepo/NullRepo.php38
-rw-r--r--www/wiki/includes/filerepo/README41
-rw-r--r--www/wiki/includes/filerepo/RepoGroup.php472
-rw-r--r--www/wiki/includes/filerepo/TempFileRepo.php9
-rw-r--r--www/wiki/includes/filerepo/file/ArchivedFile.php580
-rw-r--r--www/wiki/includes/filerepo/file/File.php2299
-rw-r--r--www/wiki/includes/filerepo/file/ForeignAPIFile.php398
-rw-r--r--www/wiki/includes/filerepo/file/ForeignDBFile.php203
-rw-r--r--www/wiki/includes/filerepo/file/LocalFile.php3296
-rw-r--r--www/wiki/includes/filerepo/file/OldLocalFile.php408
-rw-r--r--www/wiki/includes/filerepo/file/UnregisteredLocalFile.php232
-rw-r--r--www/wiki/includes/gallery/ImageGalleryBase.php378
-rw-r--r--www/wiki/includes/gallery/NolinesImageGallery.php37
-rw-r--r--www/wiki/includes/gallery/PackedImageGallery.php112
-rw-r--r--www/wiki/includes/gallery/PackedOverlayImageGallery.php63
-rw-r--r--www/wiki/includes/gallery/SlideshowImageGallery.php41
-rw-r--r--www/wiki/includes/gallery/TraditionalImageGallery.php355
-rw-r--r--www/wiki/includes/htmlform/HTMLApiField.php23
-rw-r--r--www/wiki/includes/htmlform/HTMLAutoCompleteSelectField.php177
-rw-r--r--www/wiki/includes/htmlform/HTMLButtonField.php132
-rw-r--r--www/wiki/includes/htmlform/HTMLCheckField.php131
-rw-r--r--www/wiki/includes/htmlform/HTMLCheckMatrix.php275
-rw-r--r--www/wiki/includes/htmlform/HTMLComboboxField.php59
-rw-r--r--www/wiki/includes/htmlform/HTMLEditTools.php51
-rw-r--r--www/wiki/includes/htmlform/HTMLFloatField.php46
-rw-r--r--www/wiki/includes/htmlform/HTMLForm.php1907
-rw-r--r--www/wiki/includes/htmlform/HTMLFormElement.php65
-rw-r--r--www/wiki/includes/htmlform/HTMLFormField.php1199
-rw-r--r--www/wiki/includes/htmlform/HTMLFormFieldCloner.php380
-rw-r--r--www/wiki/includes/htmlform/HTMLFormFieldRequiredOptionsException.php9
-rw-r--r--www/wiki/includes/htmlform/HTMLFormFieldWithButton.php75
-rw-r--r--www/wiki/includes/htmlform/HTMLHiddenField.php66
-rw-r--r--www/wiki/includes/htmlform/HTMLInfoField.php64
-rw-r--r--www/wiki/includes/htmlform/HTMLIntField.php26
-rw-r--r--www/wiki/includes/htmlform/HTMLMultiSelectField.php151
-rw-r--r--www/wiki/includes/htmlform/HTMLNestedFilterable.php11
-rw-r--r--www/wiki/includes/htmlform/HTMLRadioField.php91
-rw-r--r--www/wiki/includes/htmlform/HTMLSelectAndOtherField.php134
-rw-r--r--www/wiki/includes/htmlform/HTMLSelectField.php68
-rw-r--r--www/wiki/includes/htmlform/HTMLSelectLimitField.php35
-rw-r--r--www/wiki/includes/htmlform/HTMLSelectNamespace.php36
-rw-r--r--www/wiki/includes/htmlform/HTMLSelectNamespaceWithButton.php17
-rw-r--r--www/wiki/includes/htmlform/HTMLSelectOrOtherField.php90
-rw-r--r--www/wiki/includes/htmlform/HTMLSubmitField.php19
-rw-r--r--www/wiki/includes/htmlform/HTMLTagFilter.php31
-rw-r--r--www/wiki/includes/htmlform/HTMLTextAreaField.php103
-rw-r--r--www/wiki/includes/htmlform/HTMLTextField.php177
-rw-r--r--www/wiki/includes/htmlform/HTMLTextFieldWithButton.php17
-rw-r--r--www/wiki/includes/htmlform/HTMLTitleTextField.php99
-rw-r--r--www/wiki/includes/htmlform/HTMLUserTextField.php56
-rw-r--r--www/wiki/includes/htmlform/OOUIHTMLForm.php296
-rw-r--r--www/wiki/includes/htmlform/VFormHTMLForm.php162
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLApiField.php23
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLAutoCompleteSelectField.php197
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLButtonField.php142
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLCheckField.php131
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLCheckMatrix.php266
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLComboboxField.php63
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLDateTimeField.php185
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLEditTools.php51
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLFloatField.php46
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLFormFieldCloner.php397
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLFormFieldWithButton.php75
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLHiddenField.php66
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLInfoField.php81
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLIntField.php26
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLMultiSelectField.php208
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLRadioField.php115
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLRestrictionsField.php121
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLSelectAndOtherField.php195
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLSelectField.php72
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLSelectLimitField.php35
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLSelectNamespace.php44
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLSelectNamespaceWithButton.php17
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLSelectOrOtherField.php161
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLSizeFilterField.php72
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLSubmitField.php19
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLTagFilter.php50
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLTextAreaField.php103
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLTextField.php206
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLTextFieldWithButton.php17
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLTitleTextField.php107
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLUserTextField.php62
-rw-r--r--www/wiki/includes/htmlform/fields/HTMLUsersMultiselectField.php91
-rw-r--r--www/wiki/includes/http/CurlHttpRequest.php170
-rw-r--r--www/wiki/includes/http/Http.php184
-rw-r--r--www/wiki/includes/http/MWHttpRequest.php670
-rw-r--r--www/wiki/includes/http/PhpHttpRequest.php265
-rw-r--r--www/wiki/includes/import/ImportSource.php51
-rw-r--r--www/wiki/includes/import/ImportStreamSource.php183
-rw-r--r--www/wiki/includes/import/ImportStringSource.php57
-rw-r--r--www/wiki/includes/import/UploadSourceAdapter.php149
-rw-r--r--www/wiki/includes/import/WikiImporter.php1099
-rw-r--r--www/wiki/includes/import/WikiRevision.php842
-rw-r--r--www/wiki/includes/installer/CliInstaller.php228
-rw-r--r--www/wiki/includes/installer/DatabaseInstaller.php760
-rw-r--r--www/wiki/includes/installer/DatabaseUpdater.php1215
-rw-r--r--www/wiki/includes/installer/InstallDocFormatter.php74
-rw-r--r--www/wiki/includes/installer/Installer.php1788
-rw-r--r--www/wiki/includes/installer/InstallerOverrides.php76
-rw-r--r--www/wiki/includes/installer/InstallerSessionProvider.php64
-rw-r--r--www/wiki/includes/installer/LocalSettingsGenerator.php417
-rw-r--r--www/wiki/includes/installer/MssqlInstaller.php737
-rw-r--r--www/wiki/includes/installer/MssqlUpdater.php146
-rw-r--r--www/wiki/includes/installer/MysqlInstaller.php687
-rw-r--r--www/wiki/includes/installer/MysqlUpdater.php1213
-rw-r--r--www/wiki/includes/installer/OracleInstaller.php340
-rw-r--r--www/wiki/includes/installer/OracleUpdater.php332
-rw-r--r--www/wiki/includes/installer/PhpBugTests.php47
-rw-r--r--www/wiki/includes/installer/PostgresInstaller.php682
-rw-r--r--www/wiki/includes/installer/PostgresUpdater.php1066
-rw-r--r--www/wiki/includes/installer/SqliteInstaller.php335
-rw-r--r--www/wiki/includes/installer/SqliteUpdater.php227
-rw-r--r--www/wiki/includes/installer/WebInstaller.php1241
-rw-r--r--www/wiki/includes/installer/WebInstallerComplete.php64
-rw-r--r--www/wiki/includes/installer/WebInstallerCopying.php31
-rw-r--r--www/wiki/includes/installer/WebInstallerDBConnect.php121
-rw-r--r--www/wiki/includes/installer/WebInstallerDBSettings.php54
-rw-r--r--www/wiki/includes/installer/WebInstallerDocument.php49
-rw-r--r--www/wiki/includes/installer/WebInstallerExistingWiki.php191
-rw-r--r--www/wiki/includes/installer/WebInstallerInstall.php95
-rw-r--r--www/wiki/includes/installer/WebInstallerLanguage.php123
-rw-r--r--www/wiki/includes/installer/WebInstallerName.php263
-rw-r--r--www/wiki/includes/installer/WebInstallerOptions.php490
-rw-r--r--www/wiki/includes/installer/WebInstallerOutput.php348
-rw-r--r--www/wiki/includes/installer/WebInstallerPage.php207
-rw-r--r--www/wiki/includes/installer/WebInstallerReadme.php31
-rw-r--r--www/wiki/includes/installer/WebInstallerReleaseNotes.php38
-rw-r--r--www/wiki/includes/installer/WebInstallerRestart.php46
-rw-r--r--www/wiki/includes/installer/WebInstallerUpgrade.php110
-rw-r--r--www/wiki/includes/installer/WebInstallerUpgradeDoc.php31
-rw-r--r--www/wiki/includes/installer/WebInstallerWelcome.php49
-rw-r--r--www/wiki/includes/installer/i18n/af.json151
-rw-r--r--www/wiki/includes/installer/i18n/aln.json9
-rw-r--r--www/wiki/includes/installer/i18n/am.json5
-rw-r--r--www/wiki/includes/installer/i18n/an.json9
-rw-r--r--www/wiki/includes/installer/i18n/ang.json9
-rw-r--r--www/wiki/includes/installer/i18n/anp.json13
-rw-r--r--www/wiki/includes/installer/i18n/ar.json255
-rw-r--r--www/wiki/includes/installer/i18n/arc.json22
-rw-r--r--www/wiki/includes/installer/i18n/ary.json10
-rw-r--r--www/wiki/includes/installer/i18n/arz.json18
-rw-r--r--www/wiki/includes/installer/i18n/as.json10
-rw-r--r--www/wiki/includes/installer/i18n/ast.json156
-rw-r--r--www/wiki/includes/installer/i18n/av.json11
-rw-r--r--www/wiki/includes/installer/i18n/avk.json4
-rw-r--r--www/wiki/includes/installer/i18n/az.json32
-rw-r--r--www/wiki/includes/installer/i18n/azb.json40
-rw-r--r--www/wiki/includes/installer/i18n/ba.json317
-rw-r--r--www/wiki/includes/installer/i18n/bar.json13
-rw-r--r--www/wiki/includes/installer/i18n/bcc.json5
-rw-r--r--www/wiki/includes/installer/i18n/bcl.json36
-rw-r--r--www/wiki/includes/installer/i18n/be-tarask.json324
-rw-r--r--www/wiki/includes/installer/i18n/be.json21
-rw-r--r--www/wiki/includes/installer/i18n/bg.json316
-rw-r--r--www/wiki/includes/installer/i18n/bgn.json33
-rw-r--r--www/wiki/includes/installer/i18n/bjn.json10
-rw-r--r--www/wiki/includes/installer/i18n/bn.json148
-rw-r--r--www/wiki/includes/installer/i18n/bpy.json5
-rw-r--r--www/wiki/includes/installer/i18n/br.json301
-rw-r--r--www/wiki/includes/installer/i18n/bs.json185
-rw-r--r--www/wiki/includes/installer/i18n/bto.json65
-rw-r--r--www/wiki/includes/installer/i18n/ca.json262
-rw-r--r--www/wiki/includes/installer/i18n/ce.json93
-rw-r--r--www/wiki/includes/installer/i18n/ceb.json5
-rw-r--r--www/wiki/includes/installer/i18n/ckb.json59
-rw-r--r--www/wiki/includes/installer/i18n/cps.json10
-rw-r--r--www/wiki/includes/installer/i18n/crh-cyrl.json5
-rw-r--r--www/wiki/includes/installer/i18n/crh-latn.json5
-rw-r--r--www/wiki/includes/installer/i18n/cs.json326
-rw-r--r--www/wiki/includes/installer/i18n/csb.json64
-rw-r--r--www/wiki/includes/installer/i18n/cu.json14
-rw-r--r--www/wiki/includes/installer/i18n/cv.json17
-rw-r--r--www/wiki/includes/installer/i18n/cy.json15
-rw-r--r--www/wiki/includes/installer/i18n/da.json83
-rw-r--r--www/wiki/includes/installer/i18n/de-ch.json12
-rw-r--r--www/wiki/includes/installer/i18n/de-formal.json12
-rw-r--r--www/wiki/includes/installer/i18n/de.json333
-rw-r--r--www/wiki/includes/installer/i18n/diq.json96
-rw-r--r--www/wiki/includes/installer/i18n/dsb.json9
-rw-r--r--www/wiki/includes/installer/i18n/dtp.json9
-rw-r--r--www/wiki/includes/installer/i18n/dty.json52
-rw-r--r--www/wiki/includes/installer/i18n/el.json237
-rw-r--r--www/wiki/includes/installer/i18n/eml.json16
-rw-r--r--www/wiki/includes/installer/i18n/en-gb.json25
-rw-r--r--www/wiki/includes/installer/i18n/en.json317
-rw-r--r--www/wiki/includes/installer/i18n/eo.json65
-rw-r--r--www/wiki/includes/installer/i18n/es-formal.json9
-rw-r--r--www/wiki/includes/installer/i18n/es.json350
-rw-r--r--www/wiki/includes/installer/i18n/et.json83
-rw-r--r--www/wiki/includes/installer/i18n/eu.json131
-rw-r--r--www/wiki/includes/installer/i18n/ext.json5
-rw-r--r--www/wiki/includes/installer/i18n/fa.json329
-rw-r--r--www/wiki/includes/installer/i18n/fi.json310
-rw-r--r--www/wiki/includes/installer/i18n/fo.json53
-rw-r--r--www/wiki/includes/installer/i18n/fr.json345
-rw-r--r--www/wiki/includes/installer/i18n/frc.json79
-rw-r--r--www/wiki/includes/installer/i18n/frp.json148
-rw-r--r--www/wiki/includes/installer/i18n/frr.json10
-rw-r--r--www/wiki/includes/installer/i18n/fur.json15
-rw-r--r--www/wiki/includes/installer/i18n/fy.json21
-rw-r--r--www/wiki/includes/installer/i18n/ga.json13
-rw-r--r--www/wiki/includes/installer/i18n/gag.json5
-rw-r--r--www/wiki/includes/installer/i18n/gan-hans.json5
-rw-r--r--www/wiki/includes/installer/i18n/gan-hant.json9
-rw-r--r--www/wiki/includes/installer/i18n/gd.json9
-rw-r--r--www/wiki/includes/installer/i18n/gl.json324
-rw-r--r--www/wiki/includes/installer/i18n/gom-latn.json9
-rw-r--r--www/wiki/includes/installer/i18n/gor.json48
-rw-r--r--www/wiki/includes/installer/i18n/grc.json11
-rw-r--r--www/wiki/includes/installer/i18n/gsw.json60
-rw-r--r--www/wiki/includes/installer/i18n/gu.json45
-rw-r--r--www/wiki/includes/installer/i18n/gv.json4
-rw-r--r--www/wiki/includes/installer/i18n/hak.json5
-rw-r--r--www/wiki/includes/installer/i18n/haw.json64
-rw-r--r--www/wiki/includes/installer/i18n/he.json324
-rw-r--r--www/wiki/includes/installer/i18n/hi.json113
-rw-r--r--www/wiki/includes/installer/i18n/hif-latn.json9
-rw-r--r--www/wiki/includes/installer/i18n/hil.json9
-rw-r--r--www/wiki/includes/installer/i18n/hr.json26
-rw-r--r--www/wiki/includes/installer/i18n/hrx.json297
-rw-r--r--www/wiki/includes/installer/i18n/hsb.json246
-rw-r--r--www/wiki/includes/installer/i18n/hsn.json35
-rw-r--r--www/wiki/includes/installer/i18n/ht.json10
-rw-r--r--www/wiki/includes/installer/i18n/hu-formal.json31
-rw-r--r--www/wiki/includes/installer/i18n/hu.json309
-rw-r--r--www/wiki/includes/installer/i18n/hy.json43
-rw-r--r--www/wiki/includes/installer/i18n/ia.json317
-rw-r--r--www/wiki/includes/installer/i18n/id.json324
-rw-r--r--www/wiki/includes/installer/i18n/ie.json4
-rw-r--r--www/wiki/includes/installer/i18n/ig.json17
-rw-r--r--www/wiki/includes/installer/i18n/ilo.json4
-rw-r--r--www/wiki/includes/installer/i18n/inh.json12
-rw-r--r--www/wiki/includes/installer/i18n/io.json9
-rw-r--r--www/wiki/includes/installer/i18n/is.json103
-rw-r--r--www/wiki/includes/installer/i18n/it.json331
-rw-r--r--www/wiki/includes/installer/i18n/ja.json335
-rw-r--r--www/wiki/includes/installer/i18n/jam.json9
-rw-r--r--www/wiki/includes/installer/i18n/jbo.json12
-rw-r--r--www/wiki/includes/installer/i18n/jut.json10
-rw-r--r--www/wiki/includes/installer/i18n/jv.json11
-rw-r--r--www/wiki/includes/installer/i18n/ka.json102
-rw-r--r--www/wiki/includes/installer/i18n/kaa.json5
-rw-r--r--www/wiki/includes/installer/i18n/kbd-cyrl.json10
-rw-r--r--www/wiki/includes/installer/i18n/khw.json8
-rw-r--r--www/wiki/includes/installer/i18n/kiu.json9
-rw-r--r--www/wiki/includes/installer/i18n/kk-arab.json5
-rw-r--r--www/wiki/includes/installer/i18n/kk-cyrl.json5
-rw-r--r--www/wiki/includes/installer/i18n/kk-latn.json5
-rw-r--r--www/wiki/includes/installer/i18n/km.json35
-rw-r--r--www/wiki/includes/installer/i18n/kn.json48
-rw-r--r--www/wiki/includes/installer/i18n/ko.json327
-rw-r--r--www/wiki/includes/installer/i18n/krc.json30
-rw-r--r--www/wiki/includes/installer/i18n/ksh.json315
-rw-r--r--www/wiki/includes/installer/i18n/ku-latn.json69
-rw-r--r--www/wiki/includes/installer/i18n/lad.json10
-rw-r--r--www/wiki/includes/installer/i18n/lb.json212
-rw-r--r--www/wiki/includes/installer/i18n/lez.json27
-rw-r--r--www/wiki/includes/installer/i18n/lfn.json5
-rw-r--r--www/wiki/includes/installer/i18n/lg.json9
-rw-r--r--www/wiki/includes/installer/i18n/li.json9
-rw-r--r--www/wiki/includes/installer/i18n/lij.json312
-rw-r--r--www/wiki/includes/installer/i18n/lki.json81
-rw-r--r--www/wiki/includes/installer/i18n/lo.json4
-rw-r--r--www/wiki/includes/installer/i18n/lrc.json28
-rw-r--r--www/wiki/includes/installer/i18n/lt.json176
-rw-r--r--www/wiki/includes/installer/i18n/lv.json70
-rw-r--r--www/wiki/includes/installer/i18n/lzh.json11
-rw-r--r--www/wiki/includes/installer/i18n/lzz.json9
-rw-r--r--www/wiki/includes/installer/i18n/mai.json39
-rw-r--r--www/wiki/includes/installer/i18n/mdf.json5
-rw-r--r--www/wiki/includes/installer/i18n/mfe.json45
-rw-r--r--www/wiki/includes/installer/i18n/mg.json88
-rw-r--r--www/wiki/includes/installer/i18n/mhr.json4
-rw-r--r--www/wiki/includes/installer/i18n/min.json11
-rw-r--r--www/wiki/includes/installer/i18n/mk.json321
-rw-r--r--www/wiki/includes/installer/i18n/ml.json120
-rw-r--r--www/wiki/includes/installer/i18n/mn.json10
-rw-r--r--www/wiki/includes/installer/i18n/mr.json110
-rw-r--r--www/wiki/includes/installer/i18n/ms.json153
-rw-r--r--www/wiki/includes/installer/i18n/mt.json90
-rw-r--r--www/wiki/includes/installer/i18n/my.json10
-rw-r--r--www/wiki/includes/installer/i18n/myv.json16
-rw-r--r--www/wiki/includes/installer/i18n/mzn.json59
-rw-r--r--www/wiki/includes/installer/i18n/nah.json9
-rw-r--r--www/wiki/includes/installer/i18n/nan.json58
-rw-r--r--www/wiki/includes/installer/i18n/nap.json309
-rw-r--r--www/wiki/includes/installer/i18n/nb.json326
-rw-r--r--www/wiki/includes/installer/i18n/nds-nl.json10
-rw-r--r--www/wiki/includes/installer/i18n/nds.json14
-rw-r--r--www/wiki/includes/installer/i18n/ne.json86
-rw-r--r--www/wiki/includes/installer/i18n/nl-informal.json74
-rw-r--r--www/wiki/includes/installer/i18n/nl.json338
-rw-r--r--www/wiki/includes/installer/i18n/nn.json40
-rw-r--r--www/wiki/includes/installer/i18n/oc.json181
-rw-r--r--www/wiki/includes/installer/i18n/olo.json60
-rw-r--r--www/wiki/includes/installer/i18n/or.json48
-rw-r--r--www/wiki/includes/installer/i18n/os.json9
-rw-r--r--www/wiki/includes/installer/i18n/pa.json40
-rw-r--r--www/wiki/includes/installer/i18n/pam.json5
-rw-r--r--www/wiki/includes/installer/i18n/pcd.json4
-rw-r--r--www/wiki/includes/installer/i18n/pdc.json13
-rw-r--r--www/wiki/includes/installer/i18n/pl.json333
-rw-r--r--www/wiki/includes/installer/i18n/pms.json288
-rw-r--r--www/wiki/includes/installer/i18n/pnt.json8
-rw-r--r--www/wiki/includes/installer/i18n/prg.json9
-rw-r--r--www/wiki/includes/installer/i18n/ps.json93
-rw-r--r--www/wiki/includes/installer/i18n/pt-br.json339
-rw-r--r--www/wiki/includes/installer/i18n/pt.json335
-rw-r--r--www/wiki/includes/installer/i18n/qqq.json337
-rw-r--r--www/wiki/includes/installer/i18n/qu.json20
-rw-r--r--www/wiki/includes/installer/i18n/rgn.json4
-rw-r--r--www/wiki/includes/installer/i18n/rm.json9
-rw-r--r--www/wiki/includes/installer/i18n/ro.json161
-rw-r--r--www/wiki/includes/installer/i18n/roa-tara.json70
-rw-r--r--www/wiki/includes/installer/i18n/ru.json341
-rw-r--r--www/wiki/includes/installer/i18n/rue.json9
-rw-r--r--www/wiki/includes/installer/i18n/sa.json8
-rw-r--r--www/wiki/includes/installer/i18n/sah.json39
-rw-r--r--www/wiki/includes/installer/i18n/sc.json14
-rw-r--r--www/wiki/includes/installer/i18n/scn.json5
-rw-r--r--www/wiki/includes/installer/i18n/sco.json311
-rw-r--r--www/wiki/includes/installer/i18n/sd.json38
-rw-r--r--www/wiki/includes/installer/i18n/sdc.json19
-rw-r--r--www/wiki/includes/installer/i18n/sei.json4
-rw-r--r--www/wiki/includes/installer/i18n/sh.json12
-rw-r--r--www/wiki/includes/installer/i18n/shi.json10
-rw-r--r--www/wiki/includes/installer/i18n/si.json140
-rw-r--r--www/wiki/includes/installer/i18n/sk.json84
-rw-r--r--www/wiki/includes/installer/i18n/sl.json183
-rw-r--r--www/wiki/includes/installer/i18n/sli.json9
-rw-r--r--www/wiki/includes/installer/i18n/so.json9
-rw-r--r--www/wiki/includes/installer/i18n/sq.json45
-rw-r--r--www/wiki/includes/installer/i18n/sr-ec.json91
-rw-r--r--www/wiki/includes/installer/i18n/sr-el.json44
-rw-r--r--www/wiki/includes/installer/i18n/srn.json9
-rw-r--r--www/wiki/includes/installer/i18n/ss.json4
-rw-r--r--www/wiki/includes/installer/i18n/stq.json9
-rw-r--r--www/wiki/includes/installer/i18n/su.json16
-rw-r--r--www/wiki/includes/installer/i18n/sv.json323
-rw-r--r--www/wiki/includes/installer/i18n/sw.json9
-rw-r--r--www/wiki/includes/installer/i18n/szl.json9
-rw-r--r--www/wiki/includes/installer/i18n/ta.json89
-rw-r--r--www/wiki/includes/installer/i18n/tcy.json51
-rw-r--r--www/wiki/includes/installer/i18n/te.json234
-rw-r--r--www/wiki/includes/installer/i18n/tet.json9
-rw-r--r--www/wiki/includes/installer/i18n/tg-cyrl.json9
-rw-r--r--www/wiki/includes/installer/i18n/tg-latn.json9
-rw-r--r--www/wiki/includes/installer/i18n/th.json240
-rw-r--r--www/wiki/includes/installer/i18n/tk.json9
-rw-r--r--www/wiki/includes/installer/i18n/tl.json292
-rw-r--r--www/wiki/includes/installer/i18n/tly.json8
-rw-r--r--www/wiki/includes/installer/i18n/tokipona.json8
-rw-r--r--www/wiki/includes/installer/i18n/tr.json242
-rw-r--r--www/wiki/includes/installer/i18n/tt-cyrl.json72
-rw-r--r--www/wiki/includes/installer/i18n/tt-latn.json10
-rw-r--r--www/wiki/includes/installer/i18n/tyv.json8
-rw-r--r--www/wiki/includes/installer/i18n/udm.json23
-rw-r--r--www/wiki/includes/installer/i18n/ug-arab.json9
-rw-r--r--www/wiki/includes/installer/i18n/uk.json326
-rw-r--r--www/wiki/includes/installer/i18n/ur.json34
-rw-r--r--www/wiki/includes/installer/i18n/uz.json10
-rw-r--r--www/wiki/includes/installer/i18n/vec.json10
-rw-r--r--www/wiki/includes/installer/i18n/vep.json10
-rw-r--r--www/wiki/includes/installer/i18n/vi.json320
-rw-r--r--www/wiki/includes/installer/i18n/vo.json5
-rw-r--r--www/wiki/includes/installer/i18n/vro.json5
-rw-r--r--www/wiki/includes/installer/i18n/wa.json8
-rw-r--r--www/wiki/includes/installer/i18n/war.json114
-rw-r--r--www/wiki/includes/installer/i18n/wo.json9
-rw-r--r--www/wiki/includes/installer/i18n/wuu.json14
-rw-r--r--www/wiki/includes/installer/i18n/xal.json9
-rw-r--r--www/wiki/includes/installer/i18n/xmf.json33
-rw-r--r--www/wiki/includes/installer/i18n/yi.json75
-rw-r--r--www/wiki/includes/installer/i18n/yo.json19
-rw-r--r--www/wiki/includes/installer/i18n/yue.json5
-rw-r--r--www/wiki/includes/installer/i18n/zea.json9
-rw-r--r--www/wiki/includes/installer/i18n/zh-hans.json336
-rw-r--r--www/wiki/includes/installer/i18n/zh-hant.json330
-rw-r--r--www/wiki/includes/installer/i18n/zh-hk.json8
-rw-r--r--www/wiki/includes/installer/i18n/zh-tw.json4
-rw-r--r--www/wiki/includes/interwiki/ClassicInterwikiLookup.php451
-rw-r--r--www/wiki/includes/interwiki/Interwiki.php185
-rw-r--r--www/wiki/includes/interwiki/InterwikiLookup.php74
-rw-r--r--www/wiki/includes/interwiki/InterwikiLookupAdapter.php178
-rw-r--r--www/wiki/includes/jobqueue/Job.php410
-rw-r--r--www/wiki/includes/jobqueue/JobQueue.php731
-rw-r--r--www/wiki/includes/jobqueue/JobQueueDB.php851
-rw-r--r--www/wiki/includes/jobqueue/JobQueueFederated.php497
-rw-r--r--www/wiki/includes/jobqueue/JobQueueGroup.php475
-rw-r--r--www/wiki/includes/jobqueue/JobQueueMemory.php230
-rw-r--r--www/wiki/includes/jobqueue/JobQueueRedis.php820
-rw-r--r--www/wiki/includes/jobqueue/JobQueueSecondTestQueue.php282
-rw-r--r--www/wiki/includes/jobqueue/JobRunner.php606
-rw-r--r--www/wiki/includes/jobqueue/JobSpecification.php234
-rw-r--r--www/wiki/includes/jobqueue/README80
-rw-r--r--www/wiki/includes/jobqueue/aggregator/JobQueueAggregator.php177
-rw-r--r--www/wiki/includes/jobqueue/aggregator/JobQueueAggregatorRedis.php135
-rw-r--r--www/wiki/includes/jobqueue/jobs/ActivityUpdateJob.php75
-rw-r--r--www/wiki/includes/jobqueue/jobs/AssembleUploadChunksJob.php138
-rw-r--r--www/wiki/includes/jobqueue/jobs/CategoryMembershipChangeJob.php243
-rw-r--r--www/wiki/includes/jobqueue/jobs/CdnPurgeJob.php46
-rw-r--r--www/wiki/includes/jobqueue/jobs/DeleteLinksJob.php66
-rw-r--r--www/wiki/includes/jobqueue/jobs/DoubleRedirectJob.php252
-rw-r--r--www/wiki/includes/jobqueue/jobs/DuplicateJob.php59
-rw-r--r--www/wiki/includes/jobqueue/jobs/EmaillingJob.php46
-rw-r--r--www/wiki/includes/jobqueue/jobs/EnotifNotifyJob.php57
-rw-r--r--www/wiki/includes/jobqueue/jobs/EnqueueJob.php99
-rw-r--r--www/wiki/includes/jobqueue/jobs/HTMLCacheUpdateJob.php178
-rw-r--r--www/wiki/includes/jobqueue/jobs/NullJob.php76
-rw-r--r--www/wiki/includes/jobqueue/jobs/PublishStashedFileJob.php152
-rw-r--r--www/wiki/includes/jobqueue/jobs/RecentChangesUpdateJob.php244
-rw-r--r--www/wiki/includes/jobqueue/jobs/RefreshLinksJob.php312
-rw-r--r--www/wiki/includes/jobqueue/jobs/ThumbnailRenderJob.php111
-rw-r--r--www/wiki/includes/jobqueue/utils/BacklinkJobUtils.php149
-rw-r--r--www/wiki/includes/jobqueue/utils/PurgeJobUtils.php81
-rw-r--r--www/wiki/includes/json/FormatJson.php338
-rw-r--r--www/wiki/includes/libs/APACHE-LICENSE-2.0.txt202
-rw-r--r--www/wiki/includes/libs/ArrayUtils.php187
-rw-r--r--www/wiki/includes/libs/CSSMin.php539
-rw-r--r--www/wiki/includes/libs/Cookie.php208
-rw-r--r--www/wiki/includes/libs/CookieJar.php107
-rw-r--r--www/wiki/includes/libs/CryptHKDF.php282
-rw-r--r--www/wiki/includes/libs/CryptRand.php404
-rw-r--r--www/wiki/includes/libs/DeferredStringifier.php57
-rw-r--r--www/wiki/includes/libs/DnsSrvDiscoverer.php108
-rw-r--r--www/wiki/includes/libs/ExplodeIterator.php116
-rw-r--r--www/wiki/includes/libs/GenericArrayObject.php239
-rw-r--r--www/wiki/includes/libs/HashRing.php238
-rw-r--r--www/wiki/includes/libs/HtmlArmor.php57
-rw-r--r--www/wiki/includes/libs/HttpStatus.php115
-rw-r--r--www/wiki/includes/libs/IEContentAnalyzer.php851
-rw-r--r--www/wiki/includes/libs/IEUrlExtension.php269
-rw-r--r--www/wiki/includes/libs/IP.php756
-rw-r--r--www/wiki/includes/libs/JavaScriptMinifier.php615
-rw-r--r--www/wiki/includes/libs/MWCryptHash.php114
-rw-r--r--www/wiki/includes/libs/MWMessagePack.php189
-rw-r--r--www/wiki/includes/libs/MapCacheLRU.php159
-rw-r--r--www/wiki/includes/libs/MappedIterator.php116
-rw-r--r--www/wiki/includes/libs/MemoizedCallable.php158
-rw-r--r--www/wiki/includes/libs/MessageSpecifier.php39
-rw-r--r--www/wiki/includes/libs/MultiHttpClient.php449
-rw-r--r--www/wiki/includes/libs/ObjectFactory.php198
-rw-r--r--www/wiki/includes/libs/ProcessCacheLRU.php160
-rw-r--r--www/wiki/includes/libs/README4
-rw-r--r--www/wiki/includes/libs/ReplacementArray.php104
-rw-r--r--www/wiki/includes/libs/ReverseArrayIterator.php76
-rw-r--r--www/wiki/includes/libs/RiffExtractor.php99
-rw-r--r--www/wiki/includes/libs/SamplingStatsdClient.php139
-rw-r--r--www/wiki/includes/libs/ScopedCallback.php77
-rw-r--r--www/wiki/includes/libs/StatusValue.php351
-rw-r--r--www/wiki/includes/libs/StringUtils.php342
-rw-r--r--www/wiki/includes/libs/Timing.php195
-rw-r--r--www/wiki/includes/libs/UDPTransport.php102
-rw-r--r--www/wiki/includes/libs/Xhprof.php82
-rw-r--r--www/wiki/includes/libs/XhprofData.php384
-rw-r--r--www/wiki/includes/libs/XmlTypeCheck.php508
-rw-r--r--www/wiki/includes/libs/composer/ComposerInstalled.php38
-rw-r--r--www/wiki/includes/libs/composer/ComposerJson.php51
-rw-r--r--www/wiki/includes/libs/composer/ComposerLock.php37
-rw-r--r--www/wiki/includes/libs/eventrelayer/EventRelayer.php66
-rw-r--r--www/wiki/includes/libs/eventrelayer/EventRelayerKafka.php62
-rw-r--r--www/wiki/includes/libs/eventrelayer/EventRelayerNull.php28
-rw-r--r--www/wiki/includes/libs/filebackend/FSFileBackend.php984
-rw-r--r--www/wiki/includes/libs/filebackend/FileBackend.php1638
-rw-r--r--www/wiki/includes/libs/filebackend/FileBackendError.php9
-rw-r--r--www/wiki/includes/libs/filebackend/FileBackendMultiWrite.php755
-rw-r--r--www/wiki/includes/libs/filebackend/FileBackendStore.php1976
-rw-r--r--www/wiki/includes/libs/filebackend/FileOpBatch.php204
-rw-r--r--www/wiki/includes/libs/filebackend/HTTPFileStreamer.php269
-rw-r--r--www/wiki/includes/libs/filebackend/MemoryFileBackend.php262
-rw-r--r--www/wiki/includes/libs/filebackend/SwiftFileBackend.php1938
-rw-r--r--www/wiki/includes/libs/filebackend/filejournal/FileJournal.php199
-rw-r--r--www/wiki/includes/libs/filebackend/filejournal/NullFileJournal.php51
-rw-r--r--www/wiki/includes/libs/filebackend/fileop/CopyFileOp.php96
-rw-r--r--www/wiki/includes/libs/filebackend/fileop/CreateFileOp.php79
-rw-r--r--www/wiki/includes/libs/filebackend/fileop/DeleteFileOp.php71
-rw-r--r--www/wiki/includes/libs/filebackend/fileop/DescribeFileOp.php64
-rw-r--r--www/wiki/includes/libs/filebackend/fileop/FileOp.php469
-rw-r--r--www/wiki/includes/libs/filebackend/fileop/MoveFileOp.php106
-rw-r--r--www/wiki/includes/libs/filebackend/fileop/NullFileOp.php28
-rw-r--r--www/wiki/includes/libs/filebackend/fileop/StoreFileOp.php93
-rw-r--r--www/wiki/includes/libs/filebackend/fsfile/FSFile.php223
-rw-r--r--www/wiki/includes/libs/filebackend/fsfile/TempFSFile.php196
-rw-r--r--www/wiki/includes/libs/http/HttpAcceptNegotiator.php139
-rw-r--r--www/wiki/includes/libs/http/HttpAcceptParser.php78
-rw-r--r--www/wiki/includes/libs/iterators/IteratorDecorator.php50
-rw-r--r--www/wiki/includes/libs/iterators/NotRecursiveIterator.php35
-rw-r--r--www/wiki/includes/libs/jsminplus.php2132
-rw-r--r--www/wiki/includes/libs/lockmanager/DBLockManager.php231
-rw-r--r--www/wiki/includes/libs/lockmanager/FSLockManager.php253
-rw-r--r--www/wiki/includes/libs/lockmanager/LockManager.php267
-rw-r--r--www/wiki/includes/libs/lockmanager/MemcLockManager.php356
-rw-r--r--www/wiki/includes/libs/lockmanager/NullLockManager.php36
-rw-r--r--www/wiki/includes/libs/lockmanager/PostgreSqlLockManager.php82
-rw-r--r--www/wiki/includes/libs/lockmanager/QuorumLockManager.php281
-rw-r--r--www/wiki/includes/libs/lockmanager/RedisLockManager.php276
-rw-r--r--www/wiki/includes/libs/lockmanager/ScopedLock.php106
-rw-r--r--www/wiki/includes/libs/mime/IEContentAnalyzer.php851
-rw-r--r--www/wiki/includes/libs/mime/MimeAnalyzer.php1200
-rw-r--r--www/wiki/includes/libs/mime/XmlTypeCheck.php503
-rw-r--r--www/wiki/includes/libs/mime/defines.php48
-rw-r--r--www/wiki/includes/libs/mime/mime.info122
-rw-r--r--www/wiki/includes/libs/mime/mime.types189
-rw-r--r--www/wiki/includes/libs/objectcache/APCBagOStuff.php120
-rw-r--r--www/wiki/includes/libs/objectcache/APCUBagOStuff.php89
-rw-r--r--www/wiki/includes/libs/objectcache/BagOStuff.php797
-rw-r--r--www/wiki/includes/libs/objectcache/CachedBagOStuff.php121
-rw-r--r--www/wiki/includes/libs/objectcache/EmptyBagOStuff.php49
-rw-r--r--www/wiki/includes/libs/objectcache/HashBagOStuff.php118
-rw-r--r--www/wiki/includes/libs/objectcache/IExpiringStore.php58
-rw-r--r--www/wiki/includes/libs/objectcache/MemcachedBagOStuff.php192
-rw-r--r--www/wiki/includes/libs/objectcache/MemcachedClient.php1314
-rw-r--r--www/wiki/includes/libs/objectcache/MemcachedPeclBagOStuff.php270
-rw-r--r--www/wiki/includes/libs/objectcache/MemcachedPhpBagOStuff.php75
-rw-r--r--www/wiki/includes/libs/objectcache/MultiWriteBagOStuff.php243
-rw-r--r--www/wiki/includes/libs/objectcache/RESTBagOStuff.php137
-rw-r--r--www/wiki/includes/libs/objectcache/RedisBagOStuff.php433
-rw-r--r--www/wiki/includes/libs/objectcache/ReplicatedBagOStuff.php130
-rw-r--r--www/wiki/includes/libs/objectcache/WANObjectCache.php1761
-rw-r--r--www/wiki/includes/libs/objectcache/WANObjectCacheReaper.php204
-rw-r--r--www/wiki/includes/libs/objectcache/WinCacheBagOStuff.php64
-rw-r--r--www/wiki/includes/libs/objectcache/XCacheBagOStuff.php68
-rw-r--r--www/wiki/includes/libs/rdbms/ChronologyProtector.php337
-rw-r--r--www/wiki/includes/libs/rdbms/TransactionProfiler.php351
-rw-r--r--www/wiki/includes/libs/rdbms/connectionmanager/ConnectionManager.php137
-rw-r--r--www/wiki/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php94
-rw-r--r--www/wiki/includes/libs/rdbms/database/DBConnRef.php623
-rw-r--r--www/wiki/includes/libs/rdbms/database/Database.php3696
-rw-r--r--www/wiki/includes/libs/rdbms/database/DatabaseDomain.php209
-rw-r--r--www/wiki/includes/libs/rdbms/database/DatabaseMssql.php1351
-rw-r--r--www/wiki/includes/libs/rdbms/database/DatabaseMysql.php210
-rw-r--r--www/wiki/includes/libs/rdbms/database/DatabaseMysqlBase.php1387
-rw-r--r--www/wiki/includes/libs/rdbms/database/DatabaseMysqli.php338
-rw-r--r--www/wiki/includes/libs/rdbms/database/DatabasePostgres.php1400
-rw-r--r--www/wiki/includes/libs/rdbms/database/DatabaseSqlite.php1044
-rw-r--r--www/wiki/includes/libs/rdbms/database/IDatabase.php1868
-rw-r--r--www/wiki/includes/libs/rdbms/database/IMaintainableDatabase.php281
-rw-r--r--www/wiki/includes/libs/rdbms/database/MaintainableDBConnRef.php85
-rw-r--r--www/wiki/includes/libs/rdbms/database/position/DBMasterPos.php36
-rw-r--r--www/wiki/includes/libs/rdbms/database/position/MySQLMasterPos.php137
-rw-r--r--www/wiki/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php65
-rw-r--r--www/wiki/includes/libs/rdbms/database/resultwrapper/IResultWrapper.php81
-rw-r--r--www/wiki/includes/libs/rdbms/database/resultwrapper/MssqlResultWrapper.php76
-rw-r--r--www/wiki/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php122
-rw-r--r--www/wiki/includes/libs/rdbms/database/utils/NextSequenceValue.php12
-rw-r--r--www/wiki/includes/libs/rdbms/database/utils/SavepointPostgres.php104
-rw-r--r--www/wiki/includes/libs/rdbms/defines.php27
-rw-r--r--www/wiki/includes/libs/rdbms/encasing/Blob.php21
-rw-r--r--www/wiki/includes/libs/rdbms/encasing/IBlob.php14
-rw-r--r--www/wiki/includes/libs/rdbms/encasing/LikeMatch.php31
-rw-r--r--www/wiki/includes/libs/rdbms/encasing/MssqlBlob.php41
-rw-r--r--www/wiki/includes/libs/rdbms/encasing/PostgresBlob.php7
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBAccessError.php34
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBConnectionError.php41
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBError.php45
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBExpectedError.php61
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBQueryError.php67
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBReadOnlyError.php30
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBReplicationWaitError.php31
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBTransactionError.php30
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBTransactionSizeError.php33
-rw-r--r--www/wiki/includes/libs/rdbms/exception/DBUnexpectedError.php30
-rw-r--r--www/wiki/includes/libs/rdbms/field/Field.php35
-rw-r--r--www/wiki/includes/libs/rdbms/field/MssqlField.php40
-rw-r--r--www/wiki/includes/libs/rdbms/field/MySQLField.php108
-rw-r--r--www/wiki/includes/libs/rdbms/field/PostgresField.php110
-rw-r--r--www/wiki/includes/libs/rdbms/field/SQLiteField.php42
-rw-r--r--www/wiki/includes/libs/rdbms/lbfactory/ILBFactory.php324
-rw-r--r--www/wiki/includes/libs/rdbms/lbfactory/LBFactory.php585
-rw-r--r--www/wiki/includes/libs/rdbms/lbfactory/LBFactoryMulti.php423
-rw-r--r--www/wiki/includes/libs/rdbms/lbfactory/LBFactorySimple.php155
-rw-r--r--www/wiki/includes/libs/rdbms/lbfactory/LBFactorySingle.php107
-rw-r--r--www/wiki/includes/libs/rdbms/loadbalancer/ILoadBalancer.php609
-rw-r--r--www/wiki/includes/libs/rdbms/loadbalancer/LoadBalancer.php1723
-rw-r--r--www/wiki/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php80
-rw-r--r--www/wiki/includes/libs/rdbms/loadmonitor/ILoadMonitor.php66
-rw-r--r--www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitor.php226
-rw-r--r--www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php73
-rw-r--r--www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitorNull.php46
-rw-r--r--www/wiki/includes/libs/redis/RedisConnRef.php181
-rw-r--r--www/wiki/includes/libs/redis/RedisConnectionPool.php410
-rw-r--r--www/wiki/includes/libs/replacers/DoubleReplacer.php43
-rw-r--r--www/wiki/includes/libs/replacers/HashtableReplacer.php43
-rw-r--r--www/wiki/includes/libs/replacers/RegexlikeReplacer.php46
-rw-r--r--www/wiki/includes/libs/replacers/Replacer.php38
-rw-r--r--www/wiki/includes/libs/stats/BufferingStatsdDataFactory.php126
-rw-r--r--www/wiki/includes/libs/stats/IBufferingStatsdDataFactory.php30
-rw-r--r--www/wiki/includes/libs/stats/NullStatsdDataFactory.php133
-rw-r--r--www/wiki/includes/libs/stats/SamplingStatsdClient.php157
-rw-r--r--www/wiki/includes/libs/stats/StatsdAwareInterface.php21
-rw-r--r--www/wiki/includes/libs/virtualrest/ParsoidVirtualRESTService.php227
-rw-r--r--www/wiki/includes/libs/virtualrest/RestbaseVirtualRESTService.php282
-rw-r--r--www/wiki/includes/libs/virtualrest/SwiftVirtualRESTService.php179
-rw-r--r--www/wiki/includes/libs/virtualrest/VirtualRESTService.php117
-rw-r--r--www/wiki/includes/libs/virtualrest/VirtualRESTServiceClient.php321
-rw-r--r--www/wiki/includes/libs/xmp/XMP.php1372
-rw-r--r--www/wiki/includes/libs/xmp/XMPInfo.php1168
-rw-r--r--www/wiki/includes/libs/xmp/XMPValidate.php401
-rwxr-xr-xwww/wiki/includes/limit.sh122
-rw-r--r--www/wiki/includes/linkeddata/PageDataRequestHandler.php172
-rw-r--r--www/wiki/includes/linker/LinkRenderer.php481
-rw-r--r--www/wiki/includes/linker/LinkRendererFactory.php96
-rw-r--r--www/wiki/includes/linker/LinkTarget.php106
-rw-r--r--www/wiki/includes/logging/BlockLogFormatter.php234
-rw-r--r--www/wiki/includes/logging/ContentModelLogFormatter.php36
-rw-r--r--www/wiki/includes/logging/DeleteLogFormatter.php315
-rw-r--r--www/wiki/includes/logging/ImportLogFormatter.php42
-rw-r--r--www/wiki/includes/logging/LogEntry.php813
-rw-r--r--www/wiki/includes/logging/LogEventsList.php775
-rw-r--r--www/wiki/includes/logging/LogFormatter.php989
-rw-r--r--www/wiki/includes/logging/LogPage.php489
-rw-r--r--www/wiki/includes/logging/LogPager.php438
-rw-r--r--www/wiki/includes/logging/MergeLogFormatter.php91
-rw-r--r--www/wiki/includes/logging/MoveLogFormatter.php113
-rw-r--r--www/wiki/includes/logging/NewUsersLogFormatter.php68
-rw-r--r--www/wiki/includes/logging/PageLangLogFormatter.php61
-rw-r--r--www/wiki/includes/logging/PatrolLog.php90
-rw-r--r--www/wiki/includes/logging/PatrolLogFormatter.php90
-rw-r--r--www/wiki/includes/logging/ProtectLogFormatter.php215
-rw-r--r--www/wiki/includes/logging/RightsLogFormatter.php240
-rw-r--r--www/wiki/includes/logging/TagLogFormatter.php53
-rw-r--r--www/wiki/includes/logging/UploadLogFormatter.php49
-rw-r--r--www/wiki/includes/mail/EmailNotification.php513
-rw-r--r--www/wiki/includes/mail/MailAddress.php107
-rw-r--r--www/wiki/includes/mail/UserMailer.php541
-rw-r--r--www/wiki/includes/media/BMP.php80
-rw-r--r--www/wiki/includes/media/Bitmap.php588
-rw-r--r--www/wiki/includes/media/BitmapMetadataHandler.php316
-rw-r--r--www/wiki/includes/media/Bitmap_ClientOnly.php60
-rw-r--r--www/wiki/includes/media/DjVu.php464
-rw-r--r--www/wiki/includes/media/DjVuImage.php408
-rw-r--r--www/wiki/includes/media/Exif.php854
-rw-r--r--www/wiki/includes/media/ExifBitmap.php245
-rw-r--r--www/wiki/includes/media/FormatMetadata.php1890
-rw-r--r--www/wiki/includes/media/GIF.php211
-rw-r--r--www/wiki/includes/media/GIFMetadataExtractor.php347
-rw-r--r--www/wiki/includes/media/IPTC.php601
-rw-r--r--www/wiki/includes/media/ImageHandler.php288
-rw-r--r--www/wiki/includes/media/Jpeg.php290
-rw-r--r--www/wiki/includes/media/JpegMetadataExtractor.php293
-rw-r--r--www/wiki/includes/media/MediaHandler.php926
-rw-r--r--www/wiki/includes/media/MediaHandlerFactory.php101
-rw-r--r--www/wiki/includes/media/MediaTransformInvalidParametersException.php27
-rw-r--r--www/wiki/includes/media/MediaTransformOutput.php524
-rw-r--r--www/wiki/includes/media/PNG.php203
-rw-r--r--www/wiki/includes/media/PNGMetadataExtractor.php428
-rw-r--r--www/wiki/includes/media/SVG.php565
-rw-r--r--www/wiki/includes/media/SVGMetadataExtractor.php396
-rw-r--r--www/wiki/includes/media/Tiff.php107
-rw-r--r--www/wiki/includes/media/TransformationalImageHandler.php623
-rw-r--r--www/wiki/includes/media/WebP.php309
-rw-r--r--www/wiki/includes/media/XCF.php229
-rw-r--r--www/wiki/includes/media/XMP.php1383
-rw-r--r--www/wiki/includes/media/XMPInfo.php1168
-rw-r--r--www/wiki/includes/media/XMPValidate.php398
-rw-r--r--www/wiki/includes/media/tinyrgb.iccbin0 -> 524 bytes
-rw-r--r--www/wiki/includes/mime.info119
-rw-r--r--www/wiki/includes/mime.types186
-rw-r--r--www/wiki/includes/objectcache/MemcachedPeclBagOStuff.php241
-rw-r--r--www/wiki/includes/objectcache/ObjectCache.php404
-rw-r--r--www/wiki/includes/objectcache/RedisBagOStuff.php412
-rw-r--r--www/wiki/includes/objectcache/SqlBagOStuff.php822
-rw-r--r--www/wiki/includes/page/Article.php2700
-rw-r--r--www/wiki/includes/page/CategoryPage.php128
-rw-r--r--www/wiki/includes/page/ImageHistoryList.php326
-rw-r--r--www/wiki/includes/page/ImageHistoryPseudoPager.php228
-rw-r--r--www/wiki/includes/page/ImagePage.php1230
-rw-r--r--www/wiki/includes/page/Page.php25
-rw-r--r--www/wiki/includes/page/PageArchive.php810
-rw-r--r--www/wiki/includes/page/WikiCategoryPage.php64
-rw-r--r--www/wiki/includes/page/WikiFilePage.php257
-rw-r--r--www/wiki/includes/page/WikiPage.php3667
-rw-r--r--www/wiki/includes/pager/AlphabeticPager.php108
-rw-r--r--www/wiki/includes/pager/IndexPager.php742
-rw-r--r--www/wiki/includes/pager/Pager.php35
-rw-r--r--www/wiki/includes/pager/RangeChronologicalPager.php114
-rw-r--r--www/wiki/includes/pager/ReverseChronologicalPager.php178
-rw-r--r--www/wiki/includes/pager/TablePager.php474
-rw-r--r--www/wiki/includes/parser/BlockLevelPass.php555
-rw-r--r--www/wiki/includes/parser/CacheTime.php175
-rw-r--r--www/wiki/includes/parser/CoreParserFunctions.php1351
-rw-r--r--www/wiki/includes/parser/CoreTagHooks.php176
-rw-r--r--www/wiki/includes/parser/DateFormatter.php388
-rw-r--r--www/wiki/includes/parser/LinkHolderArray.php644
-rw-r--r--www/wiki/includes/parser/MWTidy.php166
-rw-r--r--www/wiki/includes/parser/Parser.php6100
-rw-r--r--www/wiki/includes/parser/ParserCache.php359
-rw-r--r--www/wiki/includes/parser/ParserDiffTest.php121
-rw-r--r--www/wiki/includes/parser/ParserOptions.php1409
-rw-r--r--www/wiki/includes/parser/ParserOutput.php1129
-rw-r--r--www/wiki/includes/parser/Preprocessor.php436
-rw-r--r--www/wiki/includes/parser/Preprocessor_DOM.php2009
-rw-r--r--www/wiki/includes/parser/Preprocessor_Hash.php2223
-rw-r--r--www/wiki/includes/parser/StripState.php242
-rw-r--r--www/wiki/includes/password/BcryptPassword.php88
-rw-r--r--www/wiki/includes/password/EncryptedPassword.php121
-rw-r--r--www/wiki/includes/password/InvalidPassword.php47
-rw-r--r--www/wiki/includes/password/LayeredParameterizedPassword.php140
-rw-r--r--www/wiki/includes/password/MWOldPassword.php54
-rw-r--r--www/wiki/includes/password/MWSaltedPassword.php50
-rw-r--r--www/wiki/includes/password/ParameterizedPassword.php121
-rw-r--r--www/wiki/includes/password/Password.php209
-rw-r--r--www/wiki/includes/password/PasswordError.php28
-rw-r--r--www/wiki/includes/password/PasswordFactory.php224
-rw-r--r--www/wiki/includes/password/PasswordPolicyChecks.php167
-rw-r--r--www/wiki/includes/password/Pbkdf2Password.php97
-rw-r--r--www/wiki/includes/password/UserPasswordPolicy.php196
-rw-r--r--www/wiki/includes/poolcounter/PoolCounter.php231
-rw-r--r--www/wiki/includes/poolcounter/PoolCounterRedis.php434
-rw-r--r--www/wiki/includes/poolcounter/PoolCounterWork.php160
-rw-r--r--www/wiki/includes/poolcounter/PoolCounterWorkViaCallback.php92
-rw-r--r--www/wiki/includes/poolcounter/PoolWorkArticleView.php220
-rw-r--r--www/wiki/includes/profiler/ProfileSection.php45
-rw-r--r--www/wiki/includes/profiler/Profiler.php320
-rw-r--r--www/wiki/includes/profiler/ProfilerFunctions.php56
-rw-r--r--www/wiki/includes/profiler/ProfilerSectionOnly.php103
-rw-r--r--www/wiki/includes/profiler/ProfilerStub.php48
-rw-r--r--www/wiki/includes/profiler/ProfilerXhprof.php239
-rw-r--r--www/wiki/includes/profiler/SectionProfiler.php524
-rw-r--r--www/wiki/includes/profiler/TransactionProfiler.php314
-rw-r--r--www/wiki/includes/profiler/output/ProfilerOutput.php56
-rw-r--r--www/wiki/includes/profiler/output/ProfilerOutputDb.php93
-rw-r--r--www/wiki/includes/profiler/output/ProfilerOutputDump.php55
-rw-r--r--www/wiki/includes/profiler/output/ProfilerOutputStats.php56
-rw-r--r--www/wiki/includes/profiler/output/ProfilerOutputText.php77
-rw-r--r--www/wiki/includes/rcfeed/FormattedRCFeed.php68
-rw-r--r--www/wiki/includes/rcfeed/IRCColourfulRCFeedFormatter.php143
-rw-r--r--www/wiki/includes/rcfeed/JSONRCFeedFormatter.php32
-rw-r--r--www/wiki/includes/rcfeed/MachineReadableRCFeedFormatter.php132
-rw-r--r--www/wiki/includes/rcfeed/RCFeed.php59
-rw-r--r--www/wiki/includes/rcfeed/RCFeedEngine.php27
-rw-r--r--www/wiki/includes/rcfeed/RCFeedFormatter.php39
-rw-r--r--www/wiki/includes/rcfeed/RedisPubSubFeedEngine.php75
-rw-r--r--www/wiki/includes/rcfeed/UDPRCFeedEngine.php36
-rw-r--r--www/wiki/includes/rcfeed/XMLRCFeedFormatter.php29
-rw-r--r--www/wiki/includes/registration/CoreVersionChecker.php68
-rw-r--r--www/wiki/includes/registration/ExtensionJsonValidationError.php22
-rw-r--r--www/wiki/includes/registration/ExtensionJsonValidator.php130
-rw-r--r--www/wiki/includes/registration/ExtensionProcessor.php530
-rw-r--r--www/wiki/includes/registration/ExtensionRegistry.php417
-rw-r--r--www/wiki/includes/registration/Processor.php53
-rw-r--r--www/wiki/includes/registration/VersionChecker.php211
-rw-r--r--www/wiki/includes/resourceloader/DerivativeResourceLoaderContext.php199
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoader.php1720
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderClientHtml.php467
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderContext.php397
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderEditToolbarModule.php44
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderFileModule.php1041
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderFilePath.php74
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderForeignApiModule.php33
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderImage.php399
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderImageModule.php473
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderJqueryMsgModule.php82
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderLanguageDataModule.php80
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderLanguageNamesModule.php77
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderMediaWikiUtilModule.php53
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderModule.php1092
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderOOUIFileModule.php98
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderOOUIImageModule.php113
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderOOUIModule.php146
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderRawFileModule.php52
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderSiteModule.php52
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderSiteStylesModule.php60
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderSkinModule.php141
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php102
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderStartUpModule.php443
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderUploadDialogModule.php49
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php86
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderUserDefaultsModule.php49
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderUserGroupsModule.php70
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderUserModule.php87
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderUserOptionsModule.php73
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderUserStylesModule.php86
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderUserTokensModule.php83
-rw-r--r--www/wiki/includes/resourceloader/ResourceLoaderWikiModule.php475
-rw-r--r--www/wiki/includes/revisiondelete/RevDelArchiveItem.php105
-rw-r--r--www/wiki/includes/revisiondelete/RevDelArchiveList.php85
-rw-r--r--www/wiki/includes/revisiondelete/RevDelArchivedFileItem.php142
-rw-r--r--www/wiki/includes/revisiondelete/RevDelArchivedFileList.php58
-rw-r--r--www/wiki/includes/revisiondelete/RevDelArchivedRevisionItem.php53
-rw-r--r--www/wiki/includes/revisiondelete/RevDelFileItem.php246
-rw-r--r--www/wiki/includes/revisiondelete/RevDelFileList.php132
-rw-r--r--www/wiki/includes/revisiondelete/RevDelItem.php82
-rw-r--r--www/wiki/includes/revisiondelete/RevDelList.php416
-rw-r--r--www/wiki/includes/revisiondelete/RevDelLogItem.php145
-rw-r--r--www/wiki/includes/revisiondelete/RevDelLogList.php109
-rw-r--r--www/wiki/includes/revisiondelete/RevDelRevisionItem.php209
-rw-r--r--www/wiki/includes/revisiondelete/RevDelRevisionList.php185
-rw-r--r--www/wiki/includes/revisiondelete/RevisionDeleteUser.php144
-rw-r--r--www/wiki/includes/revisiondelete/RevisionDeleter.php251
-rw-r--r--www/wiki/includes/search/AugmentPageProps.php20
-rw-r--r--www/wiki/includes/search/DummySearchIndexFieldDefinition.php30
-rw-r--r--www/wiki/includes/search/NullIndexField.php52
-rw-r--r--www/wiki/includes/search/ParserOutputSearchDataExtractor.php96
-rw-r--r--www/wiki/includes/search/PerRowAugmentor.php37
-rw-r--r--www/wiki/includes/search/ResultAugmentor.php13
-rw-r--r--www/wiki/includes/search/ResultSetAugmentor.php13
-rw-r--r--www/wiki/includes/search/SearchDatabase.php61
-rw-r--r--www/wiki/includes/search/SearchEngine.php821
-rw-r--r--www/wiki/includes/search/SearchEngineConfig.php117
-rw-r--r--www/wiki/includes/search/SearchEngineFactory.php65
-rw-r--r--www/wiki/includes/search/SearchExactMatchRescorer.php144
-rw-r--r--www/wiki/includes/search/SearchHighlighter.php566
-rw-r--r--www/wiki/includes/search/SearchIndexField.php98
-rw-r--r--www/wiki/includes/search/SearchIndexFieldDefinition.php153
-rw-r--r--www/wiki/includes/search/SearchMssql.php210
-rw-r--r--www/wiki/includes/search/SearchMySQL.php458
-rw-r--r--www/wiki/includes/search/SearchNearMatchResultSet.php30
-rw-r--r--www/wiki/includes/search/SearchNearMatcher.php167
-rw-r--r--www/wiki/includes/search/SearchOracle.php276
-rw-r--r--www/wiki/includes/search/SearchPostgres.php192
-rw-r--r--www/wiki/includes/search/SearchResult.php283
-rw-r--r--www/wiki/includes/search/SearchResultSet.php279
-rw-r--r--www/wiki/includes/search/SearchSqlite.php312
-rw-r--r--www/wiki/includes/search/SearchSuggestion.php185
-rw-r--r--www/wiki/includes/search/SearchSuggestionSet.php212
-rw-r--r--www/wiki/includes/search/SqlSearchResultSet.php69
-rw-r--r--www/wiki/includes/services/CannotReplaceActiveServiceException.php43
-rw-r--r--www/wiki/includes/services/ContainerDisabledException.php42
-rw-r--r--www/wiki/includes/services/DestructibleService.php45
-rw-r--r--www/wiki/includes/services/NoSuchServiceException.php43
-rw-r--r--www/wiki/includes/services/SalvageableService.php58
-rw-r--r--www/wiki/includes/services/ServiceAlreadyDefinedException.php45
-rw-r--r--www/wiki/includes/services/ServiceContainer.php378
-rw-r--r--www/wiki/includes/services/ServiceDisabledException.php43
-rw-r--r--www/wiki/includes/session/BotPasswordSessionProvider.php189
-rw-r--r--www/wiki/includes/session/CookieSessionProvider.php439
-rw-r--r--www/wiki/includes/session/ImmutableSessionProviderWithCookie.php153
-rw-r--r--www/wiki/includes/session/MetadataMergeException.php70
-rw-r--r--www/wiki/includes/session/PHPSessionHandler.php391
-rw-r--r--www/wiki/includes/session/Session.php691
-rw-r--r--www/wiki/includes/session/SessionBackend.php772
-rw-r--r--www/wiki/includes/session/SessionId.php70
-rw-r--r--www/wiki/includes/session/SessionInfo.php288
-rw-r--r--www/wiki/includes/session/SessionManager.php968
-rw-r--r--www/wiki/includes/session/SessionManagerInterface.php109
-rw-r--r--www/wiki/includes/session/SessionProvider.php533
-rw-r--r--www/wiki/includes/session/SessionProviderInterface.php54
-rw-r--r--www/wiki/includes/session/Token.php125
-rw-r--r--www/wiki/includes/session/UserInfo.php187
-rw-r--r--www/wiki/includes/shell/Command.php413
-rw-r--r--www/wiki/includes/shell/CommandFactory.php65
-rw-r--r--www/wiki/includes/shell/Result.php61
-rw-r--r--www/wiki/includes/shell/Shell.php157
-rwxr-xr-xwww/wiki/includes/shell/limit.sh122
-rw-r--r--www/wiki/includes/site/CachingSiteStore.php199
-rw-r--r--www/wiki/includes/site/DBSiteStore.php284
-rw-r--r--www/wiki/includes/site/FileBasedSiteLookup.php139
-rw-r--r--www/wiki/includes/site/HashSiteStore.php124
-rw-r--r--www/wiki/includes/site/MediaWikiPageNameNormalizer.php211
-rw-r--r--www/wiki/includes/site/MediaWikiSite.php213
-rw-r--r--www/wiki/includes/site/Site.php701
-rw-r--r--www/wiki/includes/site/SiteExporter.php114
-rw-r--r--www/wiki/includes/site/SiteImporter.php263
-rw-r--r--www/wiki/includes/site/SiteList.php352
-rw-r--r--www/wiki/includes/site/SiteLookup.php50
-rw-r--r--www/wiki/includes/site/SiteSQLStore.php60
-rw-r--r--www/wiki/includes/site/SiteStore.php58
-rw-r--r--www/wiki/includes/site/SitesCacheFileBuilder.php113
-rw-r--r--www/wiki/includes/skins/BaseTemplate.php769
-rw-r--r--www/wiki/includes/skins/MediaWikiI18N.php51
-rw-r--r--www/wiki/includes/skins/QuickTemplate.php200
-rw-r--r--www/wiki/includes/skins/Skin.php1627
-rw-r--r--www/wiki/includes/skins/SkinApi.php71
-rw-r--r--www/wiki/includes/skins/SkinApiTemplate.php63
-rw-r--r--www/wiki/includes/skins/SkinException.php29
-rw-r--r--www/wiki/includes/skins/SkinFactory.php103
-rw-r--r--www/wiki/includes/skins/SkinFallback.php36
-rw-r--r--www/wiki/includes/skins/SkinFallbackTemplate.php126
-rw-r--r--www/wiki/includes/skins/SkinTemplate.php1359
-rw-r--r--www/wiki/includes/specialpage/AuthManagerSpecialPage.php766
-rw-r--r--www/wiki/includes/specialpage/ChangesListSpecialPage.php1613
-rw-r--r--www/wiki/includes/specialpage/FormSpecialPage.php202
-rw-r--r--www/wiki/includes/specialpage/ImageQueryPage.php82
-rw-r--r--www/wiki/includes/specialpage/IncludableSpecialPage.php39
-rw-r--r--www/wiki/includes/specialpage/LoginSignupSpecialPage.php1599
-rw-r--r--www/wiki/includes/specialpage/PageQueryPage.php65
-rw-r--r--www/wiki/includes/specialpage/QueryPage.php875
-rw-r--r--www/wiki/includes/specialpage/RedirectSpecialPage.php235
-rw-r--r--www/wiki/includes/specialpage/SpecialPage.php875
-rw-r--r--www/wiki/includes/specialpage/SpecialPageFactory.php719
-rw-r--r--www/wiki/includes/specialpage/UnlistedSpecialPage.php37
-rw-r--r--www/wiki/includes/specialpage/WantedQueryPage.php157
-rw-r--r--www/wiki/includes/specials/SpecialActiveusers.php172
-rw-r--r--www/wiki/includes/specials/SpecialAllMessages.php74
-rw-r--r--www/wiki/includes/specials/SpecialAllPages.php384
-rw-r--r--www/wiki/includes/specials/SpecialAncientpages.php93
-rw-r--r--www/wiki/includes/specials/SpecialApiHelp.php98
-rw-r--r--www/wiki/includes/specials/SpecialApiSandbox.php58
-rw-r--r--www/wiki/includes/specials/SpecialAutoblockList.php167
-rw-r--r--www/wiki/includes/specials/SpecialBlankpage.php39
-rw-r--r--www/wiki/includes/specials/SpecialBlock.php1026
-rw-r--r--www/wiki/includes/specials/SpecialBlockList.php225
-rw-r--r--www/wiki/includes/specials/SpecialBooksources.php214
-rw-r--r--www/wiki/includes/specials/SpecialBotPasswords.php367
-rw-r--r--www/wiki/includes/specials/SpecialBrokenRedirects.php179
-rw-r--r--www/wiki/includes/specials/SpecialCachedPage.php201
-rw-r--r--www/wiki/includes/specials/SpecialCategories.php65
-rw-r--r--www/wiki/includes/specials/SpecialChangeContentModel.php296
-rw-r--r--www/wiki/includes/specials/SpecialChangeCredentials.php267
-rw-r--r--www/wiki/includes/specials/SpecialChangeEmail.php194
-rw-r--r--www/wiki/includes/specials/SpecialChangePassword.php36
-rw-r--r--www/wiki/includes/specials/SpecialComparePages.php174
-rw-r--r--www/wiki/includes/specials/SpecialConfirmemail.php168
-rw-r--r--www/wiki/includes/specials/SpecialContributions.php762
-rw-r--r--www/wiki/includes/specials/SpecialCreateAccount.php173
-rw-r--r--www/wiki/includes/specials/SpecialDeadendpages.php94
-rw-r--r--www/wiki/includes/specials/SpecialDeletedContributions.php240
-rw-r--r--www/wiki/includes/specials/SpecialDiff.php119
-rw-r--r--www/wiki/includes/specials/SpecialDoubleRedirects.php233
-rw-r--r--www/wiki/includes/specials/SpecialEditTags.php468
-rw-r--r--www/wiki/includes/specials/SpecialEditWatchlist.php772
-rw-r--r--www/wiki/includes/specials/SpecialEmailInvalidate.php75
-rw-r--r--www/wiki/includes/specials/SpecialEmailuser.php504
-rw-r--r--www/wiki/includes/specials/SpecialExpandTemplates.php296
-rw-r--r--www/wiki/includes/specials/SpecialExport.php595
-rw-r--r--www/wiki/includes/specials/SpecialFewestrevisions.php105
-rw-r--r--www/wiki/includes/specials/SpecialFileDuplicateSearch.php265
-rw-r--r--www/wiki/includes/specials/SpecialFilepath.php55
-rw-r--r--www/wiki/includes/specials/SpecialGoToInterwiki.php79
-rw-r--r--www/wiki/includes/specials/SpecialImport.php524
-rw-r--r--www/wiki/includes/specials/SpecialJavaScriptTest.php214
-rw-r--r--www/wiki/includes/specials/SpecialLinkAccounts.php111
-rw-r--r--www/wiki/includes/specials/SpecialLinkSearch.php274
-rw-r--r--www/wiki/includes/specials/SpecialListDuplicatedFiles.php106
-rw-r--r--www/wiki/includes/specials/SpecialListfiles.php83
-rw-r--r--www/wiki/includes/specials/SpecialListgrants.php91
-rw-r--r--www/wiki/includes/specials/SpecialListgrouprights.php298
-rw-r--r--www/wiki/includes/specials/SpecialListredirects.php151
-rw-r--r--www/wiki/includes/specials/SpecialListusers.php101
-rw-r--r--www/wiki/includes/specials/SpecialLockdb.php118
-rw-r--r--www/wiki/includes/specials/SpecialLog.php302
-rw-r--r--www/wiki/includes/specials/SpecialLonelypages.php102
-rw-r--r--www/wiki/includes/specials/SpecialLongpages.php40
-rw-r--r--www/wiki/includes/specials/SpecialMIMEsearch.php239
-rw-r--r--www/wiki/includes/specials/SpecialMediaStatistics.php370
-rw-r--r--www/wiki/includes/specials/SpecialMergeHistory.php385
-rw-r--r--www/wiki/includes/specials/SpecialMostcategories.php112
-rw-r--r--www/wiki/includes/specials/SpecialMostimages.php67
-rw-r--r--www/wiki/includes/specials/SpecialMostinterwikis.php115
-rw-r--r--www/wiki/includes/specials/SpecialMostlinked.php135
-rw-r--r--www/wiki/includes/specials/SpecialMostlinkedcategories.php98
-rw-r--r--www/wiki/includes/specials/SpecialMostlinkedtemplates.php132
-rw-r--r--www/wiki/includes/specials/SpecialMostrevisions.php39
-rw-r--r--www/wiki/includes/specials/SpecialMovepage.php867
-rw-r--r--www/wiki/includes/specials/SpecialMyLanguage.php113
-rw-r--r--www/wiki/includes/specials/SpecialMyRedirectPages.php185
-rw-r--r--www/wiki/includes/specials/SpecialNewimages.php230
-rw-r--r--www/wiki/includes/specials/SpecialNewpages.php517
-rw-r--r--www/wiki/includes/specials/SpecialPageData.php86
-rw-r--r--www/wiki/includes/specials/SpecialPageLanguage.php299
-rw-r--r--www/wiki/includes/specials/SpecialPagesWithProp.php240
-rw-r--r--www/wiki/includes/specials/SpecialPasswordReset.php178
-rw-r--r--www/wiki/includes/specials/SpecialPermanentLink.php82
-rw-r--r--www/wiki/includes/specials/SpecialPreferences.php170
-rw-r--r--www/wiki/includes/specials/SpecialPrefixindex.php319
-rw-r--r--www/wiki/includes/specials/SpecialProtectedpages.php273
-rw-r--r--www/wiki/includes/specials/SpecialProtectedtitles.php192
-rw-r--r--www/wiki/includes/specials/SpecialRandomInCategory.php315
-rw-r--r--www/wiki/includes/specials/SpecialRandompage.php180
-rw-r--r--www/wiki/includes/specials/SpecialRandomredirect.php35
-rw-r--r--www/wiki/includes/specials/SpecialRandomrootpage.php39
-rw-r--r--www/wiki/includes/specials/SpecialRecentchanges.php1012
-rw-r--r--www/wiki/includes/specials/SpecialRecentchangeslinked.php293
-rw-r--r--www/wiki/includes/specials/SpecialRedirect.php327
-rw-r--r--www/wiki/includes/specials/SpecialRemoveCredentials.php26
-rw-r--r--www/wiki/includes/specials/SpecialResetTokens.php156
-rw-r--r--www/wiki/includes/specials/SpecialRevisiondelete.php683
-rw-r--r--www/wiki/includes/specials/SpecialRunJobs.php122
-rw-r--r--www/wiki/includes/specials/SpecialSearch.php721
-rw-r--r--www/wiki/includes/specials/SpecialShortpages.php178
-rw-r--r--www/wiki/includes/specials/SpecialSpecialpages.php158
-rw-r--r--www/wiki/includes/specials/SpecialStatistics.php307
-rw-r--r--www/wiki/includes/specials/SpecialTags.php482
-rw-r--r--www/wiki/includes/specials/SpecialTrackingCategories.php130
-rw-r--r--www/wiki/includes/specials/SpecialUnblock.php278
-rw-r--r--www/wiki/includes/specials/SpecialUncategorizedcategories.php93
-rw-r--r--www/wiki/includes/specials/SpecialUncategorizedimages.php65
-rw-r--r--www/wiki/includes/specials/SpecialUncategorizedpages.php85
-rw-r--r--www/wiki/includes/specials/SpecialUncategorizedtemplates.php36
-rw-r--r--www/wiki/includes/specials/SpecialUndelete.php1188
-rw-r--r--www/wiki/includes/specials/SpecialUnlinkAccounts.php79
-rw-r--r--www/wiki/includes/specials/SpecialUnlockdb.php96
-rw-r--r--www/wiki/includes/specials/SpecialUnusedcategories.php83
-rw-r--r--www/wiki/includes/specials/SpecialUnusedimages.php85
-rw-r--r--www/wiki/includes/specials/SpecialUnusedtemplates.php97
-rw-r--r--www/wiki/includes/specials/SpecialUnwatchedpages.php138
-rw-r--r--www/wiki/includes/specials/SpecialUpload.php849
-rw-r--r--www/wiki/includes/specials/SpecialUploadStash.php431
-rw-r--r--www/wiki/includes/specials/SpecialUserLogin.php162
-rw-r--r--www/wiki/includes/specials/SpecialUserLogout.php85
-rw-r--r--www/wiki/includes/specials/SpecialUserrights.php1025
-rw-r--r--www/wiki/includes/specials/SpecialVersion.php1198
-rw-r--r--www/wiki/includes/specials/SpecialWantedcategories.php131
-rw-r--r--www/wiki/includes/specials/SpecialWantedfiles.php153
-rw-r--r--www/wiki/includes/specials/SpecialWantedpages.php98
-rw-r--r--www/wiki/includes/specials/SpecialWantedtemplates.php61
-rw-r--r--www/wiki/includes/specials/SpecialWatchlist.php922
-rw-r--r--www/wiki/includes/specials/SpecialWhatlinkshere.php573
-rw-r--r--www/wiki/includes/specials/SpecialWithoutinterwiki.php110
-rw-r--r--www/wiki/includes/specials/formfields/EditWatchlistCheckboxSeriesField.php37
-rw-r--r--www/wiki/includes/specials/formfields/Licenses.php189
-rw-r--r--www/wiki/includes/specials/formfields/UploadSourceField.php68
-rw-r--r--www/wiki/includes/specials/forms/EditWatchlistNormalHTMLForm.php36
-rw-r--r--www/wiki/includes/specials/forms/PreferencesForm.php143
-rw-r--r--www/wiki/includes/specials/forms/UploadForm.php446
-rw-r--r--www/wiki/includes/specials/helpers/ImportReporter.php190
-rw-r--r--www/wiki/includes/specials/helpers/License.php46
-rw-r--r--www/wiki/includes/specials/helpers/LoginHelper.php98
-rw-r--r--www/wiki/includes/specials/pagers/ActiveUsersPager.php190
-rw-r--r--www/wiki/includes/specials/pagers/AllMessagesTablePager.php424
-rw-r--r--www/wiki/includes/specials/pagers/BlockListPager.php309
-rw-r--r--www/wiki/includes/specials/pagers/CategoryPager.php115
-rw-r--r--www/wiki/includes/specials/pagers/ContribsPager.php693
-rw-r--r--www/wiki/includes/specials/pagers/DeletedContribsPager.php367
-rw-r--r--www/wiki/includes/specials/pagers/ImageListPager.php617
-rw-r--r--www/wiki/includes/specials/pagers/MergeHistoryPager.php101
-rw-r--r--www/wiki/includes/specials/pagers/NewFilesPager.php199
-rw-r--r--www/wiki/includes/specials/pagers/NewPagesPager.php161
-rw-r--r--www/wiki/includes/specials/pagers/ProtectedPagesPager.php337
-rw-r--r--www/wiki/includes/specials/pagers/ProtectedTitlesPager.php91
-rw-r--r--www/wiki/includes/specials/pagers/UsersPager.php416
-rw-r--r--www/wiki/includes/templates/EnhancedChangesListGroup.mustache30
-rw-r--r--www/wiki/includes/templates/NoLocalSettings.mustache39
-rw-r--r--www/wiki/includes/templates/SpecialContributionsLine.mustache6
-rw-r--r--www/wiki/includes/tidy/Balancer.php3582
-rw-r--r--www/wiki/includes/tidy/Html5Depurate.php47
-rw-r--r--www/wiki/includes/tidy/Html5Internal.php18
-rw-r--r--www/wiki/includes/tidy/RaggettBase.php47
-rw-r--r--www/wiki/includes/tidy/RaggettExternal.php73
-rw-r--r--www/wiki/includes/tidy/RaggettInternalHHVM.php29
-rw-r--r--www/wiki/includes/tidy/RaggettInternalPHP.php52
-rw-r--r--www/wiki/includes/tidy/RaggettWrapper.php100
-rw-r--r--www/wiki/includes/tidy/RemexCompatFormatter.php70
-rw-r--r--www/wiki/includes/tidy/RemexCompatMunger.php472
-rw-r--r--www/wiki/includes/tidy/RemexDriver.php57
-rw-r--r--www/wiki/includes/tidy/RemexMungerData.php78
-rw-r--r--www/wiki/includes/tidy/TidyDriverBase.php42
-rw-r--r--www/wiki/includes/tidy/tidy.conf24
-rw-r--r--www/wiki/includes/title/ForeignTitle.php117
-rw-r--r--www/wiki/includes/title/ForeignTitleFactory.php36
-rw-r--r--www/wiki/includes/title/ImportTitleFactory.php36
-rw-r--r--www/wiki/includes/title/MalformedTitleException.php83
-rw-r--r--www/wiki/includes/title/MediaWikiPageLinkRenderer.php134
-rw-r--r--www/wiki/includes/title/MediaWikiTitleCodec.php493
-rw-r--r--www/wiki/includes/title/NaiveForeignTitleFactory.php73
-rw-r--r--www/wiki/includes/title/NaiveImportTitleFactory.php65
-rw-r--r--www/wiki/includes/title/NamespaceAwareForeignTitleFactory.php142
-rw-r--r--www/wiki/includes/title/NamespaceImportTitleFactory.php52
-rw-r--r--www/wiki/includes/title/PageLinkRenderer.php69
-rw-r--r--www/wiki/includes/title/SubpageImportTitleFactory.php55
-rw-r--r--www/wiki/includes/title/TitleFormatter.php105
-rw-r--r--www/wiki/includes/title/TitleParser.php48
-rw-r--r--www/wiki/includes/title/TitleValue.php204
-rw-r--r--www/wiki/includes/upload/UploadBase.php2237
-rw-r--r--www/wiki/includes/upload/UploadFromChunks.php430
-rw-r--r--www/wiki/includes/upload/UploadFromFile.php97
-rw-r--r--www/wiki/includes/upload/UploadFromStash.php161
-rw-r--r--www/wiki/includes/upload/UploadFromUrl.php300
-rw-r--r--www/wiki/includes/upload/UploadStash.php759
-rw-r--r--www/wiki/includes/user/BotPassword.php519
-rw-r--r--www/wiki/includes/user/CentralIdLookup.php262
-rw-r--r--www/wiki/includes/user/LocalIdLookup.php116
-rw-r--r--www/wiki/includes/user/LoggedOutEditToken.php47
-rw-r--r--www/wiki/includes/user/PasswordReset.php309
-rw-r--r--www/wiki/includes/user/User.php5571
-rw-r--r--www/wiki/includes/user/UserArray.php89
-rw-r--r--www/wiki/includes/user/UserArrayFromResult.php92
-rw-r--r--www/wiki/includes/user/UserGroupMembership.php440
-rw-r--r--www/wiki/includes/user/UserNamePrefixSearch.php70
-rw-r--r--www/wiki/includes/user/UserRightsProxy.php289
-rw-r--r--www/wiki/includes/utils/AutoloadGenerator.php471
-rw-r--r--www/wiki/includes/utils/AvroValidator.php181
-rw-r--r--www/wiki/includes/utils/BatchRowIterator.php296
-rw-r--r--www/wiki/includes/utils/BatchRowUpdate.php128
-rw-r--r--www/wiki/includes/utils/BatchRowWriter.php74
-rw-r--r--www/wiki/includes/utils/FileContentsHasher.php114
-rw-r--r--www/wiki/includes/utils/IP.php791
-rw-r--r--www/wiki/includes/utils/MWCryptHKDF.php103
-rw-r--r--www/wiki/includes/utils/MWCryptHash.php115
-rw-r--r--www/wiki/includes/utils/MWCryptRand.php79
-rw-r--r--www/wiki/includes/utils/MWFileProps.php145
-rw-r--r--www/wiki/includes/utils/MWGrants.php214
-rw-r--r--www/wiki/includes/utils/MWRestrictions.php147
-rw-r--r--www/wiki/includes/utils/README9
-rw-r--r--www/wiki/includes/utils/RowUpdateGenerator.php39
-rw-r--r--www/wiki/includes/utils/UIDGenerator.php629
-rw-r--r--www/wiki/includes/utils/ZipDirectoryReader.php717
-rw-r--r--www/wiki/includes/utils/ZipDirectoryReaderError.php38
-rw-r--r--www/wiki/includes/utils/iterators/IteratorDecorator.php50
-rw-r--r--www/wiki/includes/utils/iterators/NotRecursiveIterator.php35
-rw-r--r--www/wiki/includes/widget/AUTHORS.txt13
-rw-r--r--www/wiki/includes/widget/ComplexNamespaceInputWidget.php119
-rw-r--r--www/wiki/includes/widget/ComplexTitleInputWidget.php67
-rw-r--r--www/wiki/includes/widget/DateInputWidget.php176
-rw-r--r--www/wiki/includes/widget/DateTimeInputWidget.php76
-rw-r--r--www/wiki/includes/widget/LICENSE.txt25
-rw-r--r--www/wiki/includes/widget/NamespaceInputWidget.php66
-rw-r--r--www/wiki/includes/widget/SearchInputWidget.php74
-rw-r--r--www/wiki/includes/widget/SelectWithInputWidget.php65
-rw-r--r--www/wiki/includes/widget/TitleInputWidget.php81
-rw-r--r--www/wiki/includes/widget/UserInputWidget.php29
-rw-r--r--www/wiki/includes/widget/UsersMultiselectWidget.php68
-rw-r--r--www/wiki/includes/widget/search/BasicSearchResultSetWidget.php135
-rw-r--r--www/wiki/includes/widget/search/DidYouMeanWidget.php105
-rw-r--r--www/wiki/includes/widget/search/FullSearchResultWidget.php284
-rw-r--r--www/wiki/includes/widget/search/InterwikiSearchResultSetWidget.php190
-rw-r--r--www/wiki/includes/widget/search/InterwikiSearchResultWidget.php66
-rw-r--r--www/wiki/includes/widget/search/SearchFormWidget.php316
-rw-r--r--www/wiki/includes/widget/search/SearchResultSetWidget.php18
-rw-r--r--www/wiki/includes/widget/search/SearchResultWidget.php18
-rw-r--r--www/wiki/includes/widget/search/SimpleSearchResultSetWidget.php132
-rw-r--r--www/wiki/includes/widget/search/SimpleSearchResultWidget.php60
1748 files changed, 505987 insertions, 0 deletions
diff --git a/www/wiki/includes/.htaccess b/www/wiki/includes/.htaccess
new file mode 100644
index 00000000..3a428827
--- /dev/null
+++ b/www/wiki/includes/.htaccess
@@ -0,0 +1 @@
+Deny from all
diff --git a/www/wiki/includes/AjaxDispatcher.php b/www/wiki/includes/AjaxDispatcher.php
new file mode 100644
index 00000000..75fcff36
--- /dev/null
+++ b/www/wiki/includes/AjaxDispatcher.php
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Handle ajax requests and send them to the proper handler.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Ajax
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @defgroup Ajax Ajax
+ */
+
+/**
+ * Object-Oriented Ajax functions.
+ * @ingroup Ajax
+ */
+class AjaxDispatcher {
+ /**
+ * The way the request was made, either a 'get' or a 'post'
+ * @var string $mode
+ */
+ private $mode;
+
+ /**
+ * Name of the requested handler
+ * @var string $func_name
+ */
+ private $func_name;
+
+ /** Arguments passed
+ * @var array $args
+ */
+ private $args;
+
+ /**
+ * @var Config
+ */
+ private $config;
+
+ /**
+ * Load up our object with user supplied data
+ * @param Config $config
+ */
+ function __construct( Config $config ) {
+ $this->config = $config;
+
+ $this->mode = "";
+
+ if ( !empty( $_GET["rs"] ) ) {
+ $this->mode = "get";
+ }
+
+ if ( !empty( $_POST["rs"] ) ) {
+ $this->mode = "post";
+ }
+
+ switch ( $this->mode ) {
+ case 'get':
+ $this->func_name = isset( $_GET["rs"] ) ? $_GET["rs"] : '';
+ if ( !empty( $_GET["rsargs"] ) ) {
+ $this->args = $_GET["rsargs"];
+ } else {
+ $this->args = [];
+ }
+ break;
+ case 'post':
+ $this->func_name = isset( $_POST["rs"] ) ? $_POST["rs"] : '';
+ if ( !empty( $_POST["rsargs"] ) ) {
+ $this->args = $_POST["rsargs"];
+ } else {
+ $this->args = [];
+ }
+ break;
+ default:
+ return;
+ # Or we could throw an exception:
+ # throw new MWException( __METHOD__ . ' called without any data (mode empty).' );
+ }
+ }
+
+ /**
+ * Pass the request to our internal function.
+ * BEWARE! Data are passed as they have been supplied by the user,
+ * they should be carefully handled in the function processing the
+ * request.
+ *
+ * @param User $user
+ */
+ function performAction( User $user ) {
+ if ( empty( $this->mode ) ) {
+ return;
+ }
+
+ if ( !in_array( $this->func_name, $this->config->get( 'AjaxExportList' ) ) ) {
+ wfDebug( __METHOD__ . ' Bad Request for unknown function ' . $this->func_name . "\n" );
+ wfHttpError(
+ 400,
+ 'Bad Request',
+ "unknown function " . $this->func_name
+ );
+ } elseif ( !User::isEveryoneAllowed( 'read' ) && !$user->isAllowed( 'read' ) ) {
+ wfHttpError(
+ 403,
+ 'Forbidden',
+ 'You are not allowed to view pages.' );
+ } else {
+ wfDebug( __METHOD__ . ' dispatching ' . $this->func_name . "\n" );
+ try {
+ $result = call_user_func_array( $this->func_name, $this->args );
+
+ if ( $result === false || $result === null ) {
+ wfDebug( __METHOD__ . ' ERROR while dispatching ' .
+ $this->func_name . "(" . var_export( $this->args, true ) . "): " .
+ "no data returned\n" );
+
+ wfHttpError( 500, 'Internal Error',
+ "{$this->func_name} returned no data" );
+ } else {
+ if ( is_string( $result ) ) {
+ $result = new AjaxResponse( $result );
+ }
+
+ // Make sure DB commit succeeds before sending a response
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->commitMasterChanges( __METHOD__ );
+
+ $result->sendHeaders();
+ $result->printText();
+
+ wfDebug( __METHOD__ . ' dispatch complete for ' . $this->func_name . "\n" );
+ }
+ } catch ( Exception $e ) {
+ wfDebug( __METHOD__ . ' ERROR while dispatching ' .
+ $this->func_name . "(" . var_export( $this->args, true ) . "): " .
+ get_class( $e ) . ": " . $e->getMessage() . "\n" );
+
+ if ( !headers_sent() ) {
+ wfHttpError( 500, 'Internal Error',
+ $e->getMessage() );
+ } else {
+ print $e->getMessage();
+ }
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/AjaxResponse.php b/www/wiki/includes/AjaxResponse.php
new file mode 100644
index 00000000..3e42c086
--- /dev/null
+++ b/www/wiki/includes/AjaxResponse.php
@@ -0,0 +1,315 @@
+<?php
+/**
+ * Response handler for Ajax requests.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Ajax
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Handle responses for Ajax requests (send headers, print
+ * content, that sort of thing)
+ *
+ * @ingroup Ajax
+ */
+class AjaxResponse {
+ /**
+ * Number of seconds to get the response cached by a proxy
+ * @var int $mCacheDuration
+ */
+ private $mCacheDuration;
+
+ /**
+ * HTTP header Content-Type
+ * @var string $mContentType
+ */
+ private $mContentType;
+
+ /**
+ * Disables output. Can be set by calling $AjaxResponse->disable()
+ * @var bool $mDisabled
+ */
+ private $mDisabled;
+
+ /**
+ * Date for the HTTP header Last-modified
+ * @var string|bool $mLastModified
+ */
+ private $mLastModified;
+
+ /**
+ * HTTP response code
+ * @var string $mResponseCode
+ */
+ private $mResponseCode;
+
+ /**
+ * HTTP Vary header
+ * @var string $mVary
+ */
+ private $mVary;
+
+ /**
+ * Content of our HTTP response
+ * @var string $mText
+ */
+ private $mText;
+
+ /**
+ * @var Config
+ */
+ private $mConfig;
+
+ /**
+ * @param string|null $text
+ * @param Config|null $config
+ */
+ function __construct( $text = null, Config $config = null ) {
+ $this->mCacheDuration = null;
+ $this->mVary = null;
+ $this->mConfig = $config ?: MediaWikiServices::getInstance()->getMainConfig();
+
+ $this->mDisabled = false;
+ $this->mText = '';
+ $this->mResponseCode = 200;
+ $this->mLastModified = false;
+ $this->mContentType = 'application/x-wiki';
+
+ if ( $text ) {
+ $this->addText( $text );
+ }
+ }
+
+ /**
+ * Set the number of seconds to get the response cached by a proxy
+ * @param int $duration
+ */
+ function setCacheDuration( $duration ) {
+ $this->mCacheDuration = $duration;
+ }
+
+ /**
+ * Set the HTTP Vary header
+ * @param string $vary
+ */
+ function setVary( $vary ) {
+ $this->mVary = $vary;
+ }
+
+ /**
+ * Set the HTTP response code
+ * @param string $code
+ */
+ function setResponseCode( $code ) {
+ $this->mResponseCode = $code;
+ }
+
+ /**
+ * Set the HTTP header Content-Type
+ * @param string $type
+ */
+ function setContentType( $type ) {
+ $this->mContentType = $type;
+ }
+
+ /**
+ * Disable output.
+ */
+ function disable() {
+ $this->mDisabled = true;
+ }
+
+ /**
+ * Add content to the response
+ * @param string $text
+ */
+ function addText( $text ) {
+ if ( !$this->mDisabled && $text ) {
+ $this->mText .= $text;
+ }
+ }
+
+ /**
+ * Output text
+ */
+ function printText() {
+ if ( !$this->mDisabled ) {
+ print $this->mText;
+ }
+ }
+
+ /**
+ * Construct the header and output it
+ */
+ function sendHeaders() {
+ if ( $this->mResponseCode ) {
+ // For back-compat, it is supported that mResponseCode be a string like " 200 OK"
+ // (with leading space and the status message after). Cast response code to an integer
+ // to take advantage of PHP's conversion rules which will turn " 200 OK" into 200.
+ // https://secure.php.net/manual/en/language.types.string.php#language.types.string.conversion
+ $n = intval( trim( $this->mResponseCode ) );
+ HttpStatus::header( $n );
+ }
+
+ header( "Content-Type: " . $this->mContentType );
+
+ if ( $this->mLastModified ) {
+ header( "Last-Modified: " . $this->mLastModified );
+ } else {
+ header( "Last-Modified: " . gmdate( "D, d M Y H:i:s" ) . " GMT" );
+ }
+
+ if ( $this->mCacheDuration ) {
+ # If CDN caches are configured, tell them to cache the response,
+ # and tell the client to always check with the CDN. Otherwise,
+ # tell the client to use a cached copy, without a way to purge it.
+
+ if ( $this->mConfig->get( 'UseSquid' ) ) {
+ # Expect explicit purge of the proxy cache, but require end user agents
+ # to revalidate against the proxy on each visit.
+ # Surrogate-Control controls our CDN, Cache-Control downstream caches
+
+ if ( $this->mConfig->get( 'UseESI' ) ) {
+ header( 'Surrogate-Control: max-age=' . $this->mCacheDuration . ', content="ESI/1.0"' );
+ header( 'Cache-Control: s-maxage=0, must-revalidate, max-age=0' );
+ } else {
+ header( 'Cache-Control: s-maxage=' . $this->mCacheDuration . ', must-revalidate, max-age=0' );
+ }
+
+ } else {
+ # Let the client do the caching. Cache is not purged.
+ header( "Expires: " . gmdate( "D, d M Y H:i:s", time() + $this->mCacheDuration ) . " GMT" );
+ header( "Cache-Control: s-maxage={$this->mCacheDuration}," .
+ "public,max-age={$this->mCacheDuration}" );
+ }
+
+ } else {
+ # always expired, always modified
+ header( "Expires: Mon, 26 Jul 1997 05:00:00 GMT" ); // Date in the past
+ header( "Cache-Control: no-cache, must-revalidate" ); // HTTP/1.1
+ header( "Pragma: no-cache" ); // HTTP/1.0
+ }
+
+ if ( $this->mVary ) {
+ header( "Vary: " . $this->mVary );
+ }
+ }
+
+ /**
+ * checkLastModified tells the client to use the client-cached response if
+ * possible. If successful, the AjaxResponse is disabled so that
+ * any future call to AjaxResponse::printText() have no effect.
+ *
+ * @param string $timestamp
+ * @return bool Returns true if the response code was set to 304 Not Modified.
+ */
+ function checkLastModified( $timestamp ) {
+ global $wgCachePages, $wgCacheEpoch, $wgUser;
+ $fname = 'AjaxResponse::checkLastModified';
+
+ if ( !$timestamp || $timestamp == '19700101000000' ) {
+ wfDebug( "$fname: CACHE DISABLED, NO TIMESTAMP", 'private' );
+ return false;
+ }
+
+ if ( !$wgCachePages ) {
+ wfDebug( "$fname: CACHE DISABLED", 'private' );
+ return false;
+ }
+
+ $timestamp = wfTimestamp( TS_MW, $timestamp );
+ $lastmod = wfTimestamp( TS_RFC2822, max( $timestamp, $wgUser->getTouched(), $wgCacheEpoch ) );
+
+ if ( !empty( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) {
+ # IE sends sizes after the date like this:
+ # Wed, 20 Aug 2003 06:51:19 GMT; length=5202
+ # this breaks strtotime().
+ $modsince = preg_replace( '/;.*$/', '', $_SERVER["HTTP_IF_MODIFIED_SINCE"] );
+ $modsinceTime = strtotime( $modsince );
+ $ismodsince = wfTimestamp( TS_MW, $modsinceTime ? $modsinceTime : 1 );
+ wfDebug( "$fname: -- client send If-Modified-Since: $modsince", 'private' );
+ wfDebug( "$fname: -- we might send Last-Modified : $lastmod", 'private' );
+
+ if ( ( $ismodsince >= $timestamp )
+ && $wgUser->validateCache( $ismodsince ) &&
+ $ismodsince >= $wgCacheEpoch
+ ) {
+ ini_set( 'zlib.output_compression', 0 );
+ $this->setResponseCode( 304 );
+ $this->disable();
+ $this->mLastModified = $lastmod;
+
+ wfDebug( "$fname: CACHED client: $ismodsince ; user: {$wgUser->getTouched()} ; " .
+ "page: $timestamp ; site $wgCacheEpoch", 'private' );
+
+ return true;
+ } else {
+ wfDebug( "$fname: READY client: $ismodsince ; user: {$wgUser->getTouched()} ; " .
+ "page: $timestamp ; site $wgCacheEpoch", 'private' );
+ $this->mLastModified = $lastmod;
+ }
+ } else {
+ wfDebug( "$fname: client did not send If-Modified-Since header", 'private' );
+ $this->mLastModified = $lastmod;
+ }
+ return false;
+ }
+
+ /**
+ * @param string $mckey
+ * @param int $touched
+ * @return bool
+ */
+ function loadFromMemcached( $mckey, $touched ) {
+ if ( !$touched ) {
+ return false;
+ }
+
+ $mcvalue = ObjectCache::getMainWANInstance()->get( $mckey );
+ if ( $mcvalue ) {
+ # Check to see if the value has been invalidated
+ if ( $touched <= $mcvalue['timestamp'] ) {
+ wfDebug( "Got $mckey from cache" );
+ $this->mText = $mcvalue['value'];
+
+ return true;
+ } else {
+ wfDebug( "$mckey has expired" );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $mckey
+ * @param int $expiry
+ * @return bool
+ */
+ function storeInMemcached( $mckey, $expiry = 86400 ) {
+ ObjectCache::getMainWANInstance()->set( $mckey,
+ [
+ 'timestamp' => wfTimestampNow(),
+ 'value' => $this->mText
+ ], $expiry
+ );
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/AuthPlugin.php b/www/wiki/includes/AuthPlugin.php
new file mode 100644
index 00000000..b73ecbd8
--- /dev/null
+++ b/www/wiki/includes/AuthPlugin.php
@@ -0,0 +1,368 @@
+<?php
+/**
+ * Authentication plugin interface
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Authentication plugin interface. Instantiate a subclass of AuthPlugin
+ * and set $wgAuth to it to authenticate against some external tool.
+ *
+ * The default behavior is not to do anything, and use the local user
+ * database for all authentication. A subclass can require that all
+ * accounts authenticate externally, or use it only as a fallback; also
+ * you can transparently create internal wiki accounts the first time
+ * someone logs in who can be authenticated externally.
+ *
+ * @deprecated since 1.27
+ */
+class AuthPlugin {
+ /**
+ * @var string
+ */
+ protected $domain;
+
+ /**
+ * Check whether there exists a user account with the given name.
+ * The name will be normalized to MediaWiki's requirements, so
+ * you might need to munge it (for instance, for lowercase initial
+ * letters).
+ *
+ * @param string $username Username.
+ * @return bool
+ */
+ public function userExists( $username ) {
+ # Override this!
+ return false;
+ }
+
+ /**
+ * Check if a username+password pair is a valid login.
+ * The name will be normalized to MediaWiki's requirements, so
+ * you might need to munge it (for instance, for lowercase initial
+ * letters).
+ *
+ * @param string $username Username.
+ * @param string $password User password.
+ * @return bool
+ */
+ public function authenticate( $username, $password ) {
+ # Override this!
+ return false;
+ }
+
+ /**
+ * Modify options in the login template.
+ *
+ * @param BaseTemplate &$template
+ * @param string &$type 'signup' or 'login'. Added in 1.16.
+ */
+ public function modifyUITemplate( &$template, &$type ) {
+ # Override this!
+ $template->set( 'usedomain', false );
+ }
+
+ /**
+ * Set the domain this plugin is supposed to use when authenticating.
+ *
+ * @param string $domain Authentication domain.
+ */
+ public function setDomain( $domain ) {
+ $this->domain = $domain;
+ }
+
+ /**
+ * Get the user's domain
+ *
+ * @return string
+ */
+ public function getDomain() {
+ if ( isset( $this->domain ) ) {
+ return $this->domain;
+ } else {
+ return 'invaliddomain';
+ }
+ }
+
+ /**
+ * Check to see if the specific domain is a valid domain.
+ *
+ * @param string $domain Authentication domain.
+ * @return bool
+ */
+ public function validDomain( $domain ) {
+ # Override this!
+ return true;
+ }
+
+ /**
+ * When a user logs in, optionally fill in preferences and such.
+ * For instance, you might pull the email address or real name from the
+ * external user database.
+ *
+ * The User object is passed by reference so it can be modified; don't
+ * forget the & on your function declaration.
+ *
+ * @deprecated since 1.26, use the UserLoggedIn hook instead. And assigning
+ * a different User object to $user is no longer supported.
+ * @param User &$user
+ * @return bool
+ */
+ public function updateUser( &$user ) {
+ # Override this and do something
+ return true;
+ }
+
+ /**
+ * Return true if the wiki should create a new local account automatically
+ * when asked to login a user who doesn't exist locally but does in the
+ * external auth database.
+ *
+ * If you don't automatically create accounts, you must still create
+ * accounts in some way. It's not possible to authenticate without
+ * a local account.
+ *
+ * This is just a question, and shouldn't perform any actions.
+ *
+ * @return bool
+ */
+ public function autoCreate() {
+ return false;
+ }
+
+ /**
+ * Allow a property change? Properties are the same as preferences
+ * and use the same keys. 'Realname' 'Emailaddress' and 'Nickname'
+ * all reference this.
+ *
+ * @param string $prop
+ *
+ * @return bool
+ */
+ public function allowPropChange( $prop = '' ) {
+ if ( $prop == 'realname' && is_callable( [ $this, 'allowRealNameChange' ] ) ) {
+ return $this->allowRealNameChange();
+ } elseif ( $prop == 'emailaddress' && is_callable( [ $this, 'allowEmailChange' ] ) ) {
+ return $this->allowEmailChange();
+ } elseif ( $prop == 'nickname' && is_callable( [ $this, 'allowNickChange' ] ) ) {
+ return $this->allowNickChange();
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Can users change their passwords?
+ *
+ * @return bool
+ */
+ public function allowPasswordChange() {
+ return true;
+ }
+
+ /**
+ * Should MediaWiki store passwords in its local database?
+ *
+ * @return bool
+ */
+ public function allowSetLocalPassword() {
+ return true;
+ }
+
+ /**
+ * Set the given password in the authentication database.
+ * As a special case, the password may be set to null to request
+ * locking the password to an unusable value, with the expectation
+ * that it will be set later through a mail reset or other method.
+ *
+ * Return true if successful.
+ *
+ * @param User $user
+ * @param string $password Password.
+ * @return bool
+ */
+ public function setPassword( $user, $password ) {
+ return true;
+ }
+
+ /**
+ * Update user information in the external authentication database.
+ * Return true if successful.
+ *
+ * @deprecated since 1.26, use the UserSaveSettings hook instead.
+ * @param User $user
+ * @return bool
+ */
+ public function updateExternalDB( $user ) {
+ return true;
+ }
+
+ /**
+ * Update user groups in the external authentication database.
+ * Return true if successful.
+ *
+ * @deprecated since 1.26, use the UserGroupsChanged hook instead.
+ * @param User $user
+ * @param array $addgroups Groups to add.
+ * @param array $delgroups Groups to remove.
+ * @return bool
+ */
+ public function updateExternalDBGroups( $user, $addgroups, $delgroups = [] ) {
+ return true;
+ }
+
+ /**
+ * Check to see if external accounts can be created.
+ * Return true if external accounts can be created.
+ * @return bool
+ */
+ public function canCreateAccounts() {
+ return false;
+ }
+
+ /**
+ * Add a user to the external authentication database.
+ * Return true if successful.
+ *
+ * @param User $user Only the name should be assumed valid at this point
+ * @param string $password
+ * @param string $email
+ * @param string $realname
+ * @return bool
+ */
+ public function addUser( $user, $password, $email = '', $realname = '' ) {
+ return true;
+ }
+
+ /**
+ * Return true to prevent logins that don't authenticate here from being
+ * checked against the local database's password fields.
+ *
+ * This is just a question, and shouldn't perform any actions.
+ *
+ * @return bool
+ */
+ public function strict() {
+ return false;
+ }
+
+ /**
+ * Check if a user should authenticate locally if the global authentication fails.
+ * If either this or strict() returns true, local authentication is not used.
+ *
+ * @param string $username Username.
+ * @return bool
+ */
+ public function strictUserAuth( $username ) {
+ return false;
+ }
+
+ /**
+ * When creating a user account, optionally fill in preferences and such.
+ * For instance, you might pull the email address or real name from the
+ * external user database.
+ *
+ * The User object is passed by reference so it can be modified; don't
+ * forget the & on your function declaration.
+ *
+ * @deprecated since 1.26, use the UserLoggedIn hook instead. And assigning
+ * a different User object to $user is no longer supported.
+ * @param User &$user
+ * @param bool $autocreate True if user is being autocreated on login
+ */
+ public function initUser( &$user, $autocreate = false ) {
+ # Override this to do something.
+ }
+
+ /**
+ * If you want to munge the case of an account name before the final
+ * check, now is your chance.
+ * @param string $username
+ * @return string
+ */
+ public function getCanonicalName( $username ) {
+ return $username;
+ }
+
+ /**
+ * Get an instance of a User object
+ *
+ * @param User &$user
+ *
+ * @return AuthPluginUser
+ */
+ public function getUserInstance( User &$user ) {
+ return new AuthPluginUser( $user );
+ }
+
+ /**
+ * Get a list of domains (in HTMLForm options format) used.
+ *
+ * @return array
+ */
+ public function domainList() {
+ return [];
+ }
+}
+
+/**
+ * @deprecated since 1.27
+ */
+class AuthPluginUser {
+ function __construct( $user ) {
+ # Override this!
+ }
+
+ public function getId() {
+ # Override this!
+ return -1;
+ }
+
+ /**
+ * Indicate whether the user is locked
+ * @deprecated since 1.26, use the UserIsLocked hook instead.
+ * @return bool
+ */
+ public function isLocked() {
+ # Override this!
+ return false;
+ }
+
+ /**
+ * Indicate whether the user is hidden
+ * @deprecated since 1.26, use the UserIsHidden hook instead.
+ * @return bool
+ */
+ public function isHidden() {
+ # Override this!
+ return false;
+ }
+
+ /**
+ * @deprecated since 1.28, use SessionManager::invalidateSessionForUser() instead.
+ * @return bool
+ */
+ public function resetAuthToken() {
+ # Override this!
+ return true;
+ }
+}
diff --git a/www/wiki/includes/AutoLoader.php b/www/wiki/includes/AutoLoader.php
new file mode 100644
index 00000000..8dc7d409
--- /dev/null
+++ b/www/wiki/includes/AutoLoader.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * This defines autoloading handler for whole MediaWiki framework
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Locations of core classes
+ * Extension classes are specified with $wgAutoloadClasses
+ * This array is a global instead of a static member of AutoLoader to work around a bug in APC
+ */
+require_once __DIR__ . '/../autoload.php';
+
+class AutoLoader {
+ static protected $autoloadLocalClassesLower = null;
+
+ /**
+ * autoload - take a class name and attempt to load it
+ *
+ * @param string $className Name of class we're looking for.
+ */
+ static function autoload( $className ) {
+ global $wgAutoloadClasses, $wgAutoloadLocalClasses,
+ $wgAutoloadAttemptLowercase;
+
+ $filename = false;
+
+ if ( isset( $wgAutoloadLocalClasses[$className] ) ) {
+ $filename = $wgAutoloadLocalClasses[$className];
+ } elseif ( isset( $wgAutoloadClasses[$className] ) ) {
+ $filename = $wgAutoloadClasses[$className];
+ } elseif ( $wgAutoloadAttemptLowercase ) {
+ /*
+ * Try a different capitalisation.
+ *
+ * PHP 4 objects are always serialized with the classname coerced to lowercase,
+ * and we are plagued with several legacy uses created by MediaWiki < 1.5, see
+ * https://wikitech.wikimedia.org/wiki/Text_storage_data
+ */
+ $lowerClass = strtolower( $className );
+
+ if ( self::$autoloadLocalClassesLower === null ) {
+ self::$autoloadLocalClassesLower = array_change_key_case( $wgAutoloadLocalClasses, CASE_LOWER );
+ }
+
+ if ( isset( self::$autoloadLocalClassesLower[$lowerClass] ) ) {
+ if ( function_exists( 'wfDebugLog' ) ) {
+ wfDebugLog( 'autoloader', "Class {$className} was loaded using incorrect case" );
+ }
+ $filename = self::$autoloadLocalClassesLower[$lowerClass];
+ }
+ }
+
+ if ( !$filename ) {
+ // Class not found; let the next autoloader try to find it
+ return;
+ }
+
+ // Make an absolute path, this improves performance by avoiding some stat calls
+ if ( substr( $filename, 0, 1 ) != '/' && substr( $filename, 1, 1 ) != ':' ) {
+ global $IP;
+ $filename = "$IP/$filename";
+ }
+
+ require $filename;
+ }
+
+ /**
+ * Method to clear the protected class property $autoloadLocalClassesLower.
+ * Used in tests.
+ */
+ static function resetAutoloadLocalClassesLower() {
+ self::$autoloadLocalClassesLower = null;
+ }
+}
+
+spl_autoload_register( [ 'AutoLoader', 'autoload' ] );
diff --git a/www/wiki/includes/Autopromote.php b/www/wiki/includes/Autopromote.php
new file mode 100644
index 00000000..a01465e9
--- /dev/null
+++ b/www/wiki/includes/Autopromote.php
@@ -0,0 +1,215 @@
+<?php
+/**
+ * Automatic user rights promotion based on conditions specified
+ * in $wgAutopromote.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This class checks if user can get extra rights
+ * because of conditions specified in $wgAutopromote
+ */
+class Autopromote {
+ /**
+ * Get the groups for the given user based on $wgAutopromote.
+ *
+ * @param User $user The user to get the groups for
+ * @return array Array of groups to promote to.
+ */
+ public static function getAutopromoteGroups( User $user ) {
+ global $wgAutopromote;
+
+ $promote = [];
+
+ foreach ( $wgAutopromote as $group => $cond ) {
+ if ( self::recCheckCondition( $cond, $user ) ) {
+ $promote[] = $group;
+ }
+ }
+
+ Hooks::run( 'GetAutoPromoteGroups', [ $user, &$promote ] );
+
+ return $promote;
+ }
+
+ /**
+ * Get the groups for the given user based on the given criteria.
+ *
+ * Does not return groups the user already belongs to or has once belonged.
+ *
+ * @param User $user The user to get the groups for
+ * @param string $event Key in $wgAutopromoteOnce (each one has groups/criteria)
+ *
+ * @return array Groups the user should be promoted to.
+ *
+ * @see $wgAutopromoteOnce
+ */
+ public static function getAutopromoteOnceGroups( User $user, $event ) {
+ global $wgAutopromoteOnce;
+
+ $promote = [];
+
+ if ( isset( $wgAutopromoteOnce[$event] ) && count( $wgAutopromoteOnce[$event] ) ) {
+ $currentGroups = $user->getGroups();
+ $formerGroups = $user->getFormerGroups();
+ foreach ( $wgAutopromoteOnce[$event] as $group => $cond ) {
+ // Do not check if the user's already a member
+ if ( in_array( $group, $currentGroups ) ) {
+ continue;
+ }
+ // Do not autopromote if the user has belonged to the group
+ if ( in_array( $group, $formerGroups ) ) {
+ continue;
+ }
+ // Finally - check the conditions
+ if ( self::recCheckCondition( $cond, $user ) ) {
+ $promote[] = $group;
+ }
+ }
+ }
+
+ return $promote;
+ }
+
+ /**
+ * Recursively check a condition. Conditions are in the form
+ * array( '&' or '|' or '^' or '!', cond1, cond2, ... )
+ * where cond1, cond2, ... are themselves conditions; *OR*
+ * APCOND_EMAILCONFIRMED, *OR*
+ * array( APCOND_EMAILCONFIRMED ), *OR*
+ * array( APCOND_EDITCOUNT, number of edits ), *OR*
+ * array( APCOND_AGE, seconds since registration ), *OR*
+ * similar constructs defined by extensions.
+ * This function evaluates the former type recursively, and passes off to
+ * self::checkCondition for evaluation of the latter type.
+ *
+ * @param mixed $cond A condition, possibly containing other conditions
+ * @param User $user The user to check the conditions against
+ * @return bool Whether the condition is true
+ */
+ private static function recCheckCondition( $cond, User $user ) {
+ $validOps = [ '&', '|', '^', '!' ];
+
+ if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], $validOps ) ) {
+ # Recursive condition
+ if ( $cond[0] == '&' ) { // AND (all conds pass)
+ foreach ( array_slice( $cond, 1 ) as $subcond ) {
+ if ( !self::recCheckCondition( $subcond, $user ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ } elseif ( $cond[0] == '|' ) { // OR (at least one cond passes)
+ foreach ( array_slice( $cond, 1 ) as $subcond ) {
+ if ( self::recCheckCondition( $subcond, $user ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ } elseif ( $cond[0] == '^' ) { // XOR (exactly one cond passes)
+ if ( count( $cond ) > 3 ) {
+ wfWarn( 'recCheckCondition() given XOR ("^") condition on three or more conditions.' .
+ ' Check your $wgAutopromote and $wgAutopromoteOnce settings.' );
+ }
+ return self::recCheckCondition( $cond[1], $user )
+ xor self::recCheckCondition( $cond[2], $user );
+ } elseif ( $cond[0] == '!' ) { // NOT (no conds pass)
+ foreach ( array_slice( $cond, 1 ) as $subcond ) {
+ if ( self::recCheckCondition( $subcond, $user ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+ // If we got here, the array presumably does not contain other conditions;
+ // it's not recursive. Pass it off to self::checkCondition.
+ if ( !is_array( $cond ) ) {
+ $cond = [ $cond ];
+ }
+
+ return self::checkCondition( $cond, $user );
+ }
+
+ /**
+ * As recCheckCondition, but *not* recursive. The only valid conditions
+ * are those whose first element is APCOND_EMAILCONFIRMED/APCOND_EDITCOUNT/
+ * APCOND_AGE. Other types will throw an exception if no extension evaluates them.
+ *
+ * @param array $cond A condition, which must not contain other conditions
+ * @param User $user The user to check the condition against
+ * @throws MWException
+ * @return bool Whether the condition is true for the user
+ */
+ private static function checkCondition( $cond, User $user ) {
+ global $wgEmailAuthentication;
+ if ( count( $cond ) < 1 ) {
+ return false;
+ }
+
+ switch ( $cond[0] ) {
+ case APCOND_EMAILCONFIRMED:
+ if ( Sanitizer::validateEmail( $user->getEmail() ) ) {
+ if ( $wgEmailAuthentication ) {
+ return (bool)$user->getEmailAuthenticationTimestamp();
+ } else {
+ return true;
+ }
+ }
+ return false;
+ case APCOND_EDITCOUNT:
+ $reqEditCount = $cond[1];
+
+ // T157718: Avoid edit count lookup if specified edit count is 0 or invalid
+ if ( $reqEditCount <= 0 ) {
+ return true;
+ }
+ return $user->getEditCount() >= $reqEditCount;
+ case APCOND_AGE:
+ $age = time() - wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
+ return $age >= $cond[1];
+ case APCOND_AGE_FROM_EDIT:
+ $age = time() - wfTimestampOrNull( TS_UNIX, $user->getFirstEditTimestamp() );
+ return $age >= $cond[1];
+ case APCOND_INGROUPS:
+ $groups = array_slice( $cond, 1 );
+ return count( array_intersect( $groups, $user->getGroups() ) ) == count( $groups );
+ case APCOND_ISIP:
+ return $cond[1] == $user->getRequest()->getIP();
+ case APCOND_IPINRANGE:
+ return IP::isInRange( $user->getRequest()->getIP(), $cond[1] );
+ case APCOND_BLOCKED:
+ return $user->isBlocked();
+ case APCOND_ISBOT:
+ return in_array( 'bot', User::getGroupPermissions( $user->getGroups() ) );
+ default:
+ $result = null;
+ Hooks::run( 'AutopromoteCondition', [ $cond[0],
+ array_slice( $cond, 1 ), $user, &$result ] );
+ if ( $result === null ) {
+ throw new MWException( "Unrecognized condition {$cond[0]} for autopromotion!" );
+ }
+
+ return (bool)$result;
+ }
+ }
+}
diff --git a/www/wiki/includes/Block.php b/www/wiki/includes/Block.php
new file mode 100644
index 00000000..5a4c43e6
--- /dev/null
+++ b/www/wiki/includes/Block.php
@@ -0,0 +1,1579 @@
+<?php
+/**
+ * Blocks and bans object
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\MediaWikiServices;
+
+class Block {
+ /** @var string */
+ public $mReason;
+
+ /** @var string */
+ public $mTimestamp;
+
+ /** @var bool */
+ public $mAuto;
+
+ /** @var string */
+ public $mExpiry;
+
+ /** @var bool */
+ public $mHideName;
+
+ /** @var int */
+ public $mParentBlockId;
+
+ /** @var int */
+ private $mId;
+
+ /** @var bool */
+ private $mFromMaster;
+
+ /** @var bool */
+ private $mBlockEmail;
+
+ /** @var bool */
+ private $mDisableUsertalk;
+
+ /** @var bool */
+ private $mCreateAccount;
+
+ /** @var User|string */
+ private $target;
+
+ /** @var int Hack for foreign blocking (CentralAuth) */
+ private $forcedTargetID;
+
+ /** @var int Block::TYPE_ constant. Can only be USER, IP or RANGE internally */
+ private $type;
+
+ /** @var User */
+ private $blocker;
+
+ /** @var bool */
+ private $isHardblock;
+
+ /** @var bool */
+ private $isAutoblocking;
+
+ /** @var string|null */
+ private $systemBlockType;
+
+ # TYPE constants
+ const TYPE_USER = 1;
+ const TYPE_IP = 2;
+ const TYPE_RANGE = 3;
+ const TYPE_AUTO = 4;
+ const TYPE_ID = 5;
+
+ /**
+ * Create a new block with specified parameters on a user, IP or IP range.
+ *
+ * @param array $options Parameters of the block:
+ * address string|User Target user name, User object, IP address or IP range
+ * user int Override target user ID (for foreign users)
+ * by int User ID of the blocker
+ * reason string Reason of the block
+ * timestamp string The time at which the block comes into effect
+ * auto bool Is this an automatic block?
+ * expiry string Timestamp of expiration of the block or 'infinity'
+ * anonOnly bool Only disallow anonymous actions
+ * createAccount bool Disallow creation of new accounts
+ * enableAutoblock bool Enable automatic blocking
+ * hideName bool Hide the target user name
+ * blockEmail bool Disallow sending emails
+ * allowUsertalk bool Allow the target to edit its own talk page
+ * byText string Username of the blocker (for foreign users)
+ * systemBlock string Indicate that this block is automatically
+ * created by MediaWiki rather than being stored
+ * in the database. Value is a string to return
+ * from self::getSystemBlockType().
+ *
+ * @since 1.26 accepts $options array instead of individual parameters; order
+ * of parameters above reflects the original order
+ */
+ function __construct( $options = [] ) {
+ $defaults = [
+ 'address' => '',
+ 'user' => null,
+ 'by' => null,
+ 'reason' => '',
+ 'timestamp' => '',
+ 'auto' => false,
+ 'expiry' => '',
+ 'anonOnly' => false,
+ 'createAccount' => false,
+ 'enableAutoblock' => false,
+ 'hideName' => false,
+ 'blockEmail' => false,
+ 'allowUsertalk' => false,
+ 'byText' => '',
+ 'systemBlock' => null,
+ ];
+
+ if ( func_num_args() > 1 || !is_array( $options ) ) {
+ $options = array_combine(
+ array_slice( array_keys( $defaults ), 0, func_num_args() ),
+ func_get_args()
+ );
+ wfDeprecated( __METHOD__ . ' with multiple arguments', '1.26' );
+ }
+
+ $options += $defaults;
+
+ $this->setTarget( $options['address'] );
+
+ if ( $this->target instanceof User && $options['user'] ) {
+ # Needed for foreign users
+ $this->forcedTargetID = $options['user'];
+ }
+
+ if ( $options['by'] ) {
+ # Local user
+ $this->setBlocker( User::newFromId( $options['by'] ) );
+ } else {
+ # Foreign user
+ $this->setBlocker( $options['byText'] );
+ }
+
+ $this->mReason = $options['reason'];
+ $this->mTimestamp = wfTimestamp( TS_MW, $options['timestamp'] );
+ $this->mExpiry = wfGetDB( DB_REPLICA )->decodeExpiry( $options['expiry'] );
+
+ # Boolean settings
+ $this->mAuto = (bool)$options['auto'];
+ $this->mHideName = (bool)$options['hideName'];
+ $this->isHardblock( !$options['anonOnly'] );
+ $this->isAutoblocking( (bool)$options['enableAutoblock'] );
+
+ # Prevention measures
+ $this->prevents( 'sendemail', (bool)$options['blockEmail'] );
+ $this->prevents( 'editownusertalk', !$options['allowUsertalk'] );
+ $this->prevents( 'createaccount', (bool)$options['createAccount'] );
+
+ $this->mFromMaster = false;
+ $this->systemBlockType = $options['systemBlock'];
+ }
+
+ /**
+ * Load a blocked user from their block id.
+ *
+ * @param int $id Block id to search for
+ * @return Block|null
+ */
+ public static function newFromID( $id ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->selectRow(
+ 'ipblocks',
+ self::selectFields(),
+ [ 'ipb_id' => $id ],
+ __METHOD__
+ );
+ if ( $res ) {
+ return self::newFromRow( $res );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Return the list of ipblocks fields that should be selected to create
+ * a new block.
+ * @todo Deprecate this in favor of a method that returns tables and joins
+ * as well, and use CommentStore::getJoin().
+ * @return array
+ */
+ public static function selectFields() {
+ return [
+ 'ipb_id',
+ 'ipb_address',
+ 'ipb_by',
+ 'ipb_by_text',
+ 'ipb_timestamp',
+ 'ipb_auto',
+ 'ipb_anon_only',
+ 'ipb_create_account',
+ 'ipb_enable_autoblock',
+ 'ipb_expiry',
+ 'ipb_deleted',
+ 'ipb_block_email',
+ 'ipb_allow_usertalk',
+ 'ipb_parent_block_id',
+ ] + CommentStore::newKey( 'ipb_reason' )->getFields();
+ }
+
+ /**
+ * Check if two blocks are effectively equal. Doesn't check irrelevant things like
+ * the blocking user or the block timestamp, only things which affect the blocked user
+ *
+ * @param Block $block
+ *
+ * @return bool
+ */
+ public function equals( Block $block ) {
+ return (
+ (string)$this->target == (string)$block->target
+ && $this->type == $block->type
+ && $this->mAuto == $block->mAuto
+ && $this->isHardblock() == $block->isHardblock()
+ && $this->prevents( 'createaccount' ) == $block->prevents( 'createaccount' )
+ && $this->mExpiry == $block->mExpiry
+ && $this->isAutoblocking() == $block->isAutoblocking()
+ && $this->mHideName == $block->mHideName
+ && $this->prevents( 'sendemail' ) == $block->prevents( 'sendemail' )
+ && $this->prevents( 'editownusertalk' ) == $block->prevents( 'editownusertalk' )
+ && $this->mReason == $block->mReason
+ );
+ }
+
+ /**
+ * Load a block from the database which affects the already-set $this->target:
+ * 1) A block directly on the given user or IP
+ * 2) A rangeblock encompassing the given IP (smallest first)
+ * 3) An autoblock on the given IP
+ * @param User|string $vagueTarget Also search for blocks affecting this target. Doesn't
+ * make any sense to use TYPE_AUTO / TYPE_ID here. Leave blank to skip IP lookups.
+ * @throws MWException
+ * @return bool Whether a relevant block was found
+ */
+ protected function newLoad( $vagueTarget = null ) {
+ $db = wfGetDB( $this->mFromMaster ? DB_MASTER : DB_REPLICA );
+
+ if ( $this->type !== null ) {
+ $conds = [
+ 'ipb_address' => [ (string)$this->target ],
+ ];
+ } else {
+ $conds = [ 'ipb_address' => [] ];
+ }
+
+ # Be aware that the != '' check is explicit, since empty values will be
+ # passed by some callers (T31116)
+ if ( $vagueTarget != '' ) {
+ list( $target, $type ) = self::parseTarget( $vagueTarget );
+ switch ( $type ) {
+ case self::TYPE_USER:
+ # Slightly weird, but who are we to argue?
+ $conds['ipb_address'][] = (string)$target;
+ break;
+
+ case self::TYPE_IP:
+ $conds['ipb_address'][] = (string)$target;
+ $conds[] = self::getRangeCond( IP::toHex( $target ) );
+ $conds = $db->makeList( $conds, LIST_OR );
+ break;
+
+ case self::TYPE_RANGE:
+ list( $start, $end ) = IP::parseRange( $target );
+ $conds['ipb_address'][] = (string)$target;
+ $conds[] = self::getRangeCond( $start, $end );
+ $conds = $db->makeList( $conds, LIST_OR );
+ break;
+
+ default:
+ throw new MWException( "Tried to load block with invalid type" );
+ }
+ }
+
+ $res = $db->select( 'ipblocks', self::selectFields(), $conds, __METHOD__ );
+
+ # This result could contain a block on the user, a block on the IP, and a russian-doll
+ # set of rangeblocks. We want to choose the most specific one, so keep a leader board.
+ $bestRow = null;
+
+ # Lower will be better
+ $bestBlockScore = 100;
+
+ # This is begging for $this = $bestBlock, but that's not allowed in PHP :(
+ $bestBlockPreventsEdit = null;
+
+ foreach ( $res as $row ) {
+ $block = self::newFromRow( $row );
+
+ # Don't use expired blocks
+ if ( $block->isExpired() ) {
+ continue;
+ }
+
+ # Don't use anon only blocks on users
+ if ( $this->type == self::TYPE_USER && !$block->isHardblock() ) {
+ continue;
+ }
+
+ if ( $block->getType() == self::TYPE_RANGE ) {
+ # This is the number of bits that are allowed to vary in the block, give
+ # or take some floating point errors
+ $end = Wikimedia\base_convert( $block->getRangeEnd(), 16, 10 );
+ $start = Wikimedia\base_convert( $block->getRangeStart(), 16, 10 );
+ $size = log( $end - $start + 1, 2 );
+
+ # This has the nice property that a /32 block is ranked equally with a
+ # single-IP block, which is exactly what it is...
+ $score = self::TYPE_RANGE - 1 + ( $size / 128 );
+
+ } else {
+ $score = $block->getType();
+ }
+
+ if ( $score < $bestBlockScore ) {
+ $bestBlockScore = $score;
+ $bestRow = $row;
+ $bestBlockPreventsEdit = $block->prevents( 'edit' );
+ }
+ }
+
+ if ( $bestRow !== null ) {
+ $this->initFromRow( $bestRow );
+ $this->prevents( 'edit', $bestBlockPreventsEdit );
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get a set of SQL conditions which will select rangeblocks encompassing a given range
+ * @param string $start Hexadecimal IP representation
+ * @param string $end Hexadecimal IP representation, or null to use $start = $end
+ * @return string
+ */
+ public static function getRangeCond( $start, $end = null ) {
+ if ( $end === null ) {
+ $end = $start;
+ }
+ # Per T16634, we want to include relevant active rangeblocks; for
+ # rangeblocks, we want to include larger ranges which enclose the given
+ # range. We know that all blocks must be smaller than $wgBlockCIDRLimit,
+ # so we can improve performance by filtering on a LIKE clause
+ $chunk = self::getIpFragment( $start );
+ $dbr = wfGetDB( DB_REPLICA );
+ $like = $dbr->buildLike( $chunk, $dbr->anyString() );
+
+ # Fairly hard to make a malicious SQL statement out of hex characters,
+ # but stranger things have happened...
+ $safeStart = $dbr->addQuotes( $start );
+ $safeEnd = $dbr->addQuotes( $end );
+
+ return $dbr->makeList(
+ [
+ "ipb_range_start $like",
+ "ipb_range_start <= $safeStart",
+ "ipb_range_end >= $safeEnd",
+ ],
+ LIST_AND
+ );
+ }
+
+ /**
+ * Get the component of an IP address which is certain to be the same between an IP
+ * address and a rangeblock containing that IP address.
+ * @param string $hex Hexadecimal IP representation
+ * @return string
+ */
+ protected static function getIpFragment( $hex ) {
+ global $wgBlockCIDRLimit;
+ if ( substr( $hex, 0, 3 ) == 'v6-' ) {
+ return 'v6-' . substr( substr( $hex, 3 ), 0, floor( $wgBlockCIDRLimit['IPv6'] / 4 ) );
+ } else {
+ return substr( $hex, 0, floor( $wgBlockCIDRLimit['IPv4'] / 4 ) );
+ }
+ }
+
+ /**
+ * Given a database row from the ipblocks table, initialize
+ * member variables
+ * @param stdClass $row A row from the ipblocks table
+ */
+ protected function initFromRow( $row ) {
+ $this->setTarget( $row->ipb_address );
+ if ( $row->ipb_by ) { // local user
+ $this->setBlocker( User::newFromId( $row->ipb_by ) );
+ } else { // foreign user
+ $this->setBlocker( $row->ipb_by_text );
+ }
+
+ $this->mTimestamp = wfTimestamp( TS_MW, $row->ipb_timestamp );
+ $this->mAuto = $row->ipb_auto;
+ $this->mHideName = $row->ipb_deleted;
+ $this->mId = (int)$row->ipb_id;
+ $this->mParentBlockId = $row->ipb_parent_block_id;
+
+ // I wish I didn't have to do this
+ $db = wfGetDB( DB_REPLICA );
+ $this->mExpiry = $db->decodeExpiry( $row->ipb_expiry );
+ $this->mReason = CommentStore::newKey( 'ipb_reason' )
+ // Legacy because $row probably came from self::selectFields()
+ ->getCommentLegacy( $db, $row )->text;
+
+ $this->isHardblock( !$row->ipb_anon_only );
+ $this->isAutoblocking( $row->ipb_enable_autoblock );
+
+ $this->prevents( 'createaccount', $row->ipb_create_account );
+ $this->prevents( 'sendemail', $row->ipb_block_email );
+ $this->prevents( 'editownusertalk', !$row->ipb_allow_usertalk );
+ }
+
+ /**
+ * Create a new Block object from a database row
+ * @param stdClass $row Row from the ipblocks table
+ * @return Block
+ */
+ public static function newFromRow( $row ) {
+ $block = new Block;
+ $block->initFromRow( $row );
+ return $block;
+ }
+
+ /**
+ * Delete the row from the IP blocks table.
+ *
+ * @throws MWException
+ * @return bool
+ */
+ public function delete() {
+ if ( wfReadOnly() ) {
+ return false;
+ }
+
+ if ( !$this->getId() ) {
+ throw new MWException( "Block::delete() requires that the mId member be filled\n" );
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete( 'ipblocks', [ 'ipb_parent_block_id' => $this->getId() ], __METHOD__ );
+ $dbw->delete( 'ipblocks', [ 'ipb_id' => $this->getId() ], __METHOD__ );
+
+ return $dbw->affectedRows() > 0;
+ }
+
+ /**
+ * Insert a block into the block table. Will fail if there is a conflicting
+ * block (same name and options) already in the database.
+ *
+ * @param IDatabase $dbw If you have one available
+ * @return bool|array False on failure, assoc array on success:
+ * ('id' => block ID, 'autoIds' => array of autoblock IDs)
+ */
+ public function insert( $dbw = null ) {
+ global $wgBlockDisablesLogin;
+
+ if ( $this->getSystemBlockType() !== null ) {
+ throw new MWException( 'Cannot insert a system block into the database' );
+ }
+
+ wfDebug( "Block::insert; timestamp {$this->mTimestamp}\n" );
+
+ if ( $dbw === null ) {
+ $dbw = wfGetDB( DB_MASTER );
+ }
+
+ # Periodic purge via commit hooks
+ if ( mt_rand( 0, 9 ) == 0 ) {
+ self::purgeExpired();
+ }
+
+ $row = $this->getDatabaseArray( $dbw );
+
+ $dbw->insert( 'ipblocks', $row, __METHOD__, [ 'IGNORE' ] );
+ $affected = $dbw->affectedRows();
+ $this->mId = $dbw->insertId();
+
+ # Don't collide with expired blocks.
+ # Do this after trying to insert to avoid locking.
+ if ( !$affected ) {
+ # T96428: The ipb_address index uses a prefix on a field, so
+ # use a standard SELECT + DELETE to avoid annoying gap locks.
+ $ids = $dbw->selectFieldValues( 'ipblocks',
+ 'ipb_id',
+ [
+ 'ipb_address' => $row['ipb_address'],
+ 'ipb_user' => $row['ipb_user'],
+ 'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() )
+ ],
+ __METHOD__
+ );
+ if ( $ids ) {
+ $dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], __METHOD__ );
+ $dbw->insert( 'ipblocks', $row, __METHOD__, [ 'IGNORE' ] );
+ $affected = $dbw->affectedRows();
+ $this->mId = $dbw->insertId();
+ }
+ }
+
+ if ( $affected ) {
+ $auto_ipd_ids = $this->doRetroactiveAutoblock();
+
+ if ( $wgBlockDisablesLogin && $this->target instanceof User ) {
+ // Change user login token to force them to be logged out.
+ $this->target->setToken();
+ $this->target->saveSettings();
+ }
+
+ return [ 'id' => $this->mId, 'autoIds' => $auto_ipd_ids ];
+ }
+
+ return false;
+ }
+
+ /**
+ * Update a block in the DB with new parameters.
+ * The ID field needs to be loaded first.
+ *
+ * @return bool|array False on failure, array on success:
+ * ('id' => block ID, 'autoIds' => array of autoblock IDs)
+ */
+ public function update() {
+ wfDebug( "Block::update; timestamp {$this->mTimestamp}\n" );
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->startAtomic( __METHOD__ );
+
+ $dbw->update(
+ 'ipblocks',
+ $this->getDatabaseArray( $dbw ),
+ [ 'ipb_id' => $this->getId() ],
+ __METHOD__
+ );
+
+ $affected = $dbw->affectedRows();
+
+ if ( $this->isAutoblocking() ) {
+ // update corresponding autoblock(s) (T50813)
+ $dbw->update(
+ 'ipblocks',
+ $this->getAutoblockUpdateArray( $dbw ),
+ [ 'ipb_parent_block_id' => $this->getId() ],
+ __METHOD__
+ );
+ } else {
+ // autoblock no longer required, delete corresponding autoblock(s)
+ $dbw->delete(
+ 'ipblocks',
+ [ 'ipb_parent_block_id' => $this->getId() ],
+ __METHOD__
+ );
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+
+ if ( $affected ) {
+ $auto_ipd_ids = $this->doRetroactiveAutoblock();
+ return [ 'id' => $this->mId, 'autoIds' => $auto_ipd_ids ];
+ }
+
+ return false;
+ }
+
+ /**
+ * Get an array suitable for passing to $dbw->insert() or $dbw->update()
+ * @param IDatabase $dbw
+ * @return array
+ */
+ protected function getDatabaseArray( IDatabase $dbw ) {
+ $expiry = $dbw->encodeExpiry( $this->mExpiry );
+
+ if ( $this->forcedTargetID ) {
+ $uid = $this->forcedTargetID;
+ } else {
+ $uid = $this->target instanceof User ? $this->target->getId() : 0;
+ }
+
+ $a = [
+ 'ipb_address' => (string)$this->target,
+ 'ipb_user' => $uid,
+ 'ipb_by' => $this->getBy(),
+ 'ipb_by_text' => $this->getByName(),
+ 'ipb_timestamp' => $dbw->timestamp( $this->mTimestamp ),
+ 'ipb_auto' => $this->mAuto,
+ 'ipb_anon_only' => !$this->isHardblock(),
+ 'ipb_create_account' => $this->prevents( 'createaccount' ),
+ 'ipb_enable_autoblock' => $this->isAutoblocking(),
+ 'ipb_expiry' => $expiry,
+ 'ipb_range_start' => $this->getRangeStart(),
+ 'ipb_range_end' => $this->getRangeEnd(),
+ 'ipb_deleted' => intval( $this->mHideName ), // typecast required for SQLite
+ 'ipb_block_email' => $this->prevents( 'sendemail' ),
+ 'ipb_allow_usertalk' => !$this->prevents( 'editownusertalk' ),
+ 'ipb_parent_block_id' => $this->mParentBlockId
+ ] + CommentStore::newKey( 'ipb_reason' )->insert( $dbw, $this->mReason );
+
+ return $a;
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @return array
+ */
+ protected function getAutoblockUpdateArray( IDatabase $dbw ) {
+ return [
+ 'ipb_by' => $this->getBy(),
+ 'ipb_by_text' => $this->getByName(),
+ 'ipb_create_account' => $this->prevents( 'createaccount' ),
+ 'ipb_deleted' => (int)$this->mHideName, // typecast required for SQLite
+ 'ipb_allow_usertalk' => !$this->prevents( 'editownusertalk' ),
+ ] + CommentStore::newKey( 'ipb_reason' )->insert( $dbw, $this->mReason );
+ }
+
+ /**
+ * Retroactively autoblocks the last IP used by the user (if it is a user)
+ * blocked by this Block.
+ *
+ * @return array Block IDs of retroactive autoblocks made
+ */
+ protected function doRetroactiveAutoblock() {
+ $blockIds = [];
+ # If autoblock is enabled, autoblock the LAST IP(s) used
+ if ( $this->isAutoblocking() && $this->getType() == self::TYPE_USER ) {
+ wfDebug( "Doing retroactive autoblocks for " . $this->getTarget() . "\n" );
+
+ $continue = Hooks::run(
+ 'PerformRetroactiveAutoblock', [ $this, &$blockIds ] );
+
+ if ( $continue ) {
+ self::defaultRetroactiveAutoblock( $this, $blockIds );
+ }
+ }
+ return $blockIds;
+ }
+
+ /**
+ * Retroactively autoblocks the last IP used by the user (if it is a user)
+ * blocked by this Block. This will use the recentchanges table.
+ *
+ * @param Block $block
+ * @param array &$blockIds
+ */
+ protected static function defaultRetroactiveAutoblock( Block $block, array &$blockIds ) {
+ global $wgPutIPinRC;
+
+ // No IPs are in recentchanges table, so nothing to select
+ if ( !$wgPutIPinRC ) {
+ return;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $options = [ 'ORDER BY' => 'rc_timestamp DESC' ];
+ $conds = [ 'rc_user_text' => (string)$block->getTarget() ];
+
+ // Just the last IP used.
+ $options['LIMIT'] = 1;
+
+ $res = $dbr->select( 'recentchanges', [ 'rc_ip' ], $conds,
+ __METHOD__, $options );
+
+ if ( !$res->numRows() ) {
+ # No results, don't autoblock anything
+ wfDebug( "No IP found to retroactively autoblock\n" );
+ } else {
+ foreach ( $res as $row ) {
+ if ( $row->rc_ip ) {
+ $id = $block->doAutoblock( $row->rc_ip );
+ if ( $id ) {
+ $blockIds[] = $id;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks whether a given IP is on the autoblock whitelist.
+ * TODO: this probably belongs somewhere else, but not sure where...
+ *
+ * @param string $ip The IP to check
+ * @return bool
+ */
+ public static function isWhitelistedFromAutoblocks( $ip ) {
+ // Try to get the autoblock_whitelist from the cache, as it's faster
+ // than getting the msg raw and explode()'ing it.
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $lines = $cache->getWithSetCallback(
+ $cache->makeKey( 'ipb', 'autoblock', 'whitelist' ),
+ $cache::TTL_DAY,
+ function ( $curValue, &$ttl, array &$setOpts ) {
+ $setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
+
+ return explode( "\n",
+ wfMessage( 'autoblock_whitelist' )->inContentLanguage()->plain() );
+ }
+ );
+
+ wfDebug( "Checking the autoblock whitelist..\n" );
+
+ foreach ( $lines as $line ) {
+ # List items only
+ if ( substr( $line, 0, 1 ) !== '*' ) {
+ continue;
+ }
+
+ $wlEntry = substr( $line, 1 );
+ $wlEntry = trim( $wlEntry );
+
+ wfDebug( "Checking $ip against $wlEntry..." );
+
+ # Is the IP in this range?
+ if ( IP::isInRange( $ip, $wlEntry ) ) {
+ wfDebug( " IP $ip matches $wlEntry, not autoblocking\n" );
+ return true;
+ } else {
+ wfDebug( " No match\n" );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Autoblocks the given IP, referring to this Block.
+ *
+ * @param string $autoblockIP The IP to autoblock.
+ * @return int|bool Block ID if an autoblock was inserted, false if not.
+ */
+ public function doAutoblock( $autoblockIP ) {
+ # If autoblocks are disabled, go away.
+ if ( !$this->isAutoblocking() ) {
+ return false;
+ }
+
+ # Don't autoblock for system blocks
+ if ( $this->getSystemBlockType() !== null ) {
+ throw new MWException( 'Cannot autoblock from a system block' );
+ }
+
+ # Check for presence on the autoblock whitelist.
+ if ( self::isWhitelistedFromAutoblocks( $autoblockIP ) ) {
+ return false;
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $block = $this;
+ # Allow hooks to cancel the autoblock.
+ if ( !Hooks::run( 'AbortAutoblock', [ $autoblockIP, &$block ] ) ) {
+ wfDebug( "Autoblock aborted by hook.\n" );
+ return false;
+ }
+
+ # It's okay to autoblock. Go ahead and insert/update the block...
+
+ # Do not add a *new* block if the IP is already blocked.
+ $ipblock = self::newFromTarget( $autoblockIP );
+ if ( $ipblock ) {
+ # Check if the block is an autoblock and would exceed the user block
+ # if renewed. If so, do nothing, otherwise prolong the block time...
+ if ( $ipblock->mAuto && // @todo Why not compare $ipblock->mExpiry?
+ $this->mExpiry > self::getAutoblockExpiry( $ipblock->mTimestamp )
+ ) {
+ # Reset block timestamp to now and its expiry to
+ # $wgAutoblockExpiry in the future
+ $ipblock->updateTimestamp();
+ }
+ return false;
+ }
+
+ # Make a new block object with the desired properties.
+ $autoblock = new Block;
+ wfDebug( "Autoblocking {$this->getTarget()}@" . $autoblockIP . "\n" );
+ $autoblock->setTarget( $autoblockIP );
+ $autoblock->setBlocker( $this->getBlocker() );
+ $autoblock->mReason = wfMessage( 'autoblocker', $this->getTarget(), $this->mReason )
+ ->inContentLanguage()->plain();
+ $timestamp = wfTimestampNow();
+ $autoblock->mTimestamp = $timestamp;
+ $autoblock->mAuto = 1;
+ $autoblock->prevents( 'createaccount', $this->prevents( 'createaccount' ) );
+ # Continue suppressing the name if needed
+ $autoblock->mHideName = $this->mHideName;
+ $autoblock->prevents( 'editownusertalk', $this->prevents( 'editownusertalk' ) );
+ $autoblock->mParentBlockId = $this->mId;
+
+ if ( $this->mExpiry == 'infinity' ) {
+ # Original block was indefinite, start an autoblock now
+ $autoblock->mExpiry = self::getAutoblockExpiry( $timestamp );
+ } else {
+ # If the user is already blocked with an expiry date, we don't
+ # want to pile on top of that.
+ $autoblock->mExpiry = min( $this->mExpiry, self::getAutoblockExpiry( $timestamp ) );
+ }
+
+ # Insert the block...
+ $status = $autoblock->insert();
+ return $status
+ ? $status['id']
+ : false;
+ }
+
+ /**
+ * Check if a block has expired. Delete it if it is.
+ * @return bool
+ */
+ public function deleteIfExpired() {
+ if ( $this->isExpired() ) {
+ wfDebug( "Block::deleteIfExpired() -- deleting\n" );
+ $this->delete();
+ $retVal = true;
+ } else {
+ wfDebug( "Block::deleteIfExpired() -- not expired\n" );
+ $retVal = false;
+ }
+
+ return $retVal;
+ }
+
+ /**
+ * Has the block expired?
+ * @return bool
+ */
+ public function isExpired() {
+ $timestamp = wfTimestampNow();
+ wfDebug( "Block::isExpired() checking current " . $timestamp . " vs $this->mExpiry\n" );
+
+ if ( !$this->mExpiry ) {
+ return false;
+ } else {
+ return $timestamp > $this->mExpiry;
+ }
+ }
+
+ /**
+ * Is the block address valid (i.e. not a null string?)
+ * @return bool
+ */
+ public function isValid() {
+ return $this->getTarget() != null;
+ }
+
+ /**
+ * Update the timestamp on autoblocks.
+ */
+ public function updateTimestamp() {
+ if ( $this->mAuto ) {
+ $this->mTimestamp = wfTimestamp();
+ $this->mExpiry = self::getAutoblockExpiry( $this->mTimestamp );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update( 'ipblocks',
+ [ /* SET */
+ 'ipb_timestamp' => $dbw->timestamp( $this->mTimestamp ),
+ 'ipb_expiry' => $dbw->timestamp( $this->mExpiry ),
+ ],
+ [ /* WHERE */
+ 'ipb_id' => $this->getId(),
+ ],
+ __METHOD__
+ );
+ }
+ }
+
+ /**
+ * Get the IP address at the start of the range in Hex form
+ * @throws MWException
+ * @return string IP in Hex form
+ */
+ public function getRangeStart() {
+ switch ( $this->type ) {
+ case self::TYPE_USER:
+ return '';
+ case self::TYPE_IP:
+ return IP::toHex( $this->target );
+ case self::TYPE_RANGE:
+ list( $start, /*...*/ ) = IP::parseRange( $this->target );
+ return $start;
+ default:
+ throw new MWException( "Block with invalid type" );
+ }
+ }
+
+ /**
+ * Get the IP address at the end of the range in Hex form
+ * @throws MWException
+ * @return string IP in Hex form
+ */
+ public function getRangeEnd() {
+ switch ( $this->type ) {
+ case self::TYPE_USER:
+ return '';
+ case self::TYPE_IP:
+ return IP::toHex( $this->target );
+ case self::TYPE_RANGE:
+ list( /*...*/, $end ) = IP::parseRange( $this->target );
+ return $end;
+ default:
+ throw new MWException( "Block with invalid type" );
+ }
+ }
+
+ /**
+ * Get the user id of the blocking sysop
+ *
+ * @return int (0 for foreign users)
+ */
+ public function getBy() {
+ $blocker = $this->getBlocker();
+ return ( $blocker instanceof User )
+ ? $blocker->getId()
+ : 0;
+ }
+
+ /**
+ * Get the username of the blocking sysop
+ *
+ * @return string
+ */
+ public function getByName() {
+ $blocker = $this->getBlocker();
+ return ( $blocker instanceof User )
+ ? $blocker->getName()
+ : (string)$blocker; // username
+ }
+
+ /**
+ * Get the block ID
+ * @return int
+ */
+ public function getId() {
+ return $this->mId;
+ }
+
+ /**
+ * Get the system block type, if any
+ * @since 1.29
+ * @return string|null
+ */
+ public function getSystemBlockType() {
+ return $this->systemBlockType;
+ }
+
+ /**
+ * Get/set a flag determining whether the master is used for reads
+ *
+ * @param bool|null $x
+ * @return bool
+ */
+ public function fromMaster( $x = null ) {
+ return wfSetVar( $this->mFromMaster, $x );
+ }
+
+ /**
+ * Get/set whether the Block is a hardblock (affects logged-in users on a given IP/range)
+ * @param bool|null $x
+ * @return bool
+ */
+ public function isHardblock( $x = null ) {
+ wfSetVar( $this->isHardblock, $x );
+
+ # You can't *not* hardblock a user
+ return $this->getType() == self::TYPE_USER
+ ? true
+ : $this->isHardblock;
+ }
+
+ /**
+ * @param null|bool $x
+ * @return bool
+ */
+ public function isAutoblocking( $x = null ) {
+ wfSetVar( $this->isAutoblocking, $x );
+
+ # You can't put an autoblock on an IP or range as we don't have any history to
+ # look over to get more IPs from
+ return $this->getType() == self::TYPE_USER
+ ? $this->isAutoblocking
+ : false;
+ }
+
+ /**
+ * Get/set whether the Block prevents a given action
+ *
+ * @param string $action Action to check
+ * @param bool|null $x Value for set, or null to just get value
+ * @return bool|null Null for unrecognized rights.
+ */
+ public function prevents( $action, $x = null ) {
+ global $wgBlockDisablesLogin;
+ $res = null;
+ switch ( $action ) {
+ case 'edit':
+ # For now... <evil laugh>
+ $res = true;
+ break;
+ case 'createaccount':
+ $res = wfSetVar( $this->mCreateAccount, $x );
+ break;
+ case 'sendemail':
+ $res = wfSetVar( $this->mBlockEmail, $x );
+ break;
+ case 'editownusertalk':
+ $res = wfSetVar( $this->mDisableUsertalk, $x );
+ break;
+ case 'read':
+ $res = false;
+ break;
+ }
+ if ( !$res && $wgBlockDisablesLogin ) {
+ // If a block would disable login, then it should
+ // prevent any action that all users cannot do
+ $anon = new User;
+ $res = $anon->isAllowed( $action ) ? $res : true;
+ }
+
+ return $res;
+ }
+
+ /**
+ * Get the block name, but with autoblocked IPs hidden as per standard privacy policy
+ * @return string Text is escaped
+ */
+ public function getRedactedName() {
+ if ( $this->mAuto ) {
+ return Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-autoblockid' ],
+ wfMessage( 'autoblockid', $this->mId )
+ );
+ } else {
+ return htmlspecialchars( $this->getTarget() );
+ }
+ }
+
+ /**
+ * Get a timestamp of the expiry for autoblocks
+ *
+ * @param string|int $timestamp
+ * @return string
+ */
+ public static function getAutoblockExpiry( $timestamp ) {
+ global $wgAutoblockExpiry;
+
+ return wfTimestamp( TS_MW, wfTimestamp( TS_UNIX, $timestamp ) + $wgAutoblockExpiry );
+ }
+
+ /**
+ * Purge expired blocks from the ipblocks table
+ */
+ public static function purgeExpired() {
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ DeferredUpdates::addUpdate( new AtomicSectionUpdate(
+ wfGetDB( DB_MASTER ),
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) {
+ $dbw->delete(
+ 'ipblocks',
+ [ 'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
+ $fname
+ );
+ }
+ ) );
+ }
+
+ /**
+ * Given a target and the target's type, get an existing Block object if possible.
+ * @param string|User|int $specificTarget A block target, which may be one of several types:
+ * * A user to block, in which case $target will be a User
+ * * An IP to block, in which case $target will be a User generated by using
+ * User::newFromName( $ip, false ) to turn off name validation
+ * * An IP range, in which case $target will be a String "123.123.123.123/18" etc
+ * * The ID of an existing block, in the format "#12345" (since pure numbers are valid
+ * usernames
+ * Calling this with a user, IP address or range will not select autoblocks, and will
+ * only select a block where the targets match exactly (so looking for blocks on
+ * 1.2.3.4 will not select 1.2.0.0/16 or even 1.2.3.4/32)
+ * @param string|User|int $vagueTarget As above, but we will search for *any* block which
+ * affects that target (so for an IP address, get ranges containing that IP; and also
+ * get any relevant autoblocks). Leave empty or blank to skip IP-based lookups.
+ * @param bool $fromMaster Whether to use the DB_MASTER database
+ * @return Block|null (null if no relevant block could be found). The target and type
+ * of the returned Block will refer to the actual block which was found, which might
+ * not be the same as the target you gave if you used $vagueTarget!
+ */
+ public static function newFromTarget( $specificTarget, $vagueTarget = null, $fromMaster = false ) {
+ list( $target, $type ) = self::parseTarget( $specificTarget );
+ if ( $type == self::TYPE_ID || $type == self::TYPE_AUTO ) {
+ return self::newFromID( $target );
+
+ } elseif ( $target === null && $vagueTarget == '' ) {
+ # We're not going to find anything useful here
+ # Be aware that the == '' check is explicit, since empty values will be
+ # passed by some callers (T31116)
+ return null;
+
+ } elseif ( in_array(
+ $type,
+ [ self::TYPE_USER, self::TYPE_IP, self::TYPE_RANGE, null ] )
+ ) {
+ $block = new Block();
+ $block->fromMaster( $fromMaster );
+
+ if ( $type !== null ) {
+ $block->setTarget( $target );
+ }
+
+ if ( $block->newLoad( $vagueTarget ) ) {
+ return $block;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get all blocks that match any IP from an array of IP addresses
+ *
+ * @param array $ipChain List of IPs (strings), usually retrieved from the
+ * X-Forwarded-For header of the request
+ * @param bool $isAnon Exclude anonymous-only blocks if false
+ * @param bool $fromMaster Whether to query the master or replica DB
+ * @return array Array of Blocks
+ * @since 1.22
+ */
+ public static function getBlocksForIPList( array $ipChain, $isAnon, $fromMaster = false ) {
+ if ( !count( $ipChain ) ) {
+ return [];
+ }
+
+ $conds = [];
+ $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
+ foreach ( array_unique( $ipChain ) as $ipaddr ) {
+ # Discard invalid IP addresses. Since XFF can be spoofed and we do not
+ # necessarily trust the header given to us, make sure that we are only
+ # checking for blocks on well-formatted IP addresses (IPv4 and IPv6).
+ # Do not treat private IP spaces as special as it may be desirable for wikis
+ # to block those IP ranges in order to stop misbehaving proxies that spoof XFF.
+ if ( !IP::isValid( $ipaddr ) ) {
+ continue;
+ }
+ # Don't check trusted IPs (includes local squids which will be in every request)
+ if ( $proxyLookup->isTrustedProxy( $ipaddr ) ) {
+ continue;
+ }
+ # Check both the original IP (to check against single blocks), as well as build
+ # the clause to check for rangeblocks for the given IP.
+ $conds['ipb_address'][] = $ipaddr;
+ $conds[] = self::getRangeCond( IP::toHex( $ipaddr ) );
+ }
+
+ if ( !count( $conds ) ) {
+ return [];
+ }
+
+ if ( $fromMaster ) {
+ $db = wfGetDB( DB_MASTER );
+ } else {
+ $db = wfGetDB( DB_REPLICA );
+ }
+ $conds = $db->makeList( $conds, LIST_OR );
+ if ( !$isAnon ) {
+ $conds = [ $conds, 'ipb_anon_only' => 0 ];
+ }
+ $selectFields = array_merge(
+ [ 'ipb_range_start', 'ipb_range_end' ],
+ self::selectFields()
+ );
+ $rows = $db->select( 'ipblocks',
+ $selectFields,
+ $conds,
+ __METHOD__
+ );
+
+ $blocks = [];
+ foreach ( $rows as $row ) {
+ $block = self::newFromRow( $row );
+ if ( !$block->isExpired() ) {
+ $blocks[] = $block;
+ }
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * From a list of multiple blocks, find the most exact and strongest Block.
+ *
+ * The logic for finding the "best" block is:
+ * - Blocks that match the block's target IP are preferred over ones in a range
+ * - Hardblocks are chosen over softblocks that prevent account creation
+ * - Softblocks that prevent account creation are chosen over other softblocks
+ * - Other softblocks are chosen over autoblocks
+ * - If there are multiple exact or range blocks at the same level, the one chosen
+ * is random
+ * This should be used when $blocks where retrieved from the user's IP address
+ * and $ipChain is populated from the same IP address information.
+ *
+ * @param array $blocks Array of Block objects
+ * @param array $ipChain List of IPs (strings). This is used to determine how "close"
+ * a block is to the server, and if a block matches exactly, or is in a range.
+ * The order is furthest from the server to nearest e.g., (Browser, proxy1, proxy2,
+ * local-squid, ...)
+ * @throws MWException
+ * @return Block|null The "best" block from the list
+ */
+ public static function chooseBlock( array $blocks, array $ipChain ) {
+ if ( !count( $blocks ) ) {
+ return null;
+ } elseif ( count( $blocks ) == 1 ) {
+ return $blocks[0];
+ }
+
+ // Sort hard blocks before soft ones and secondarily sort blocks
+ // that disable account creation before those that don't.
+ usort( $blocks, function ( Block $a, Block $b ) {
+ $aWeight = (int)$a->isHardblock() . (int)$a->prevents( 'createaccount' );
+ $bWeight = (int)$b->isHardblock() . (int)$b->prevents( 'createaccount' );
+ return strcmp( $bWeight, $aWeight ); // highest weight first
+ } );
+
+ $blocksListExact = [
+ 'hard' => false,
+ 'disable_create' => false,
+ 'other' => false,
+ 'auto' => false
+ ];
+ $blocksListRange = [
+ 'hard' => false,
+ 'disable_create' => false,
+ 'other' => false,
+ 'auto' => false
+ ];
+ $ipChain = array_reverse( $ipChain );
+
+ /** @var Block $block */
+ foreach ( $blocks as $block ) {
+ // Stop searching if we have already have a "better" block. This
+ // is why the order of the blocks matters
+ if ( !$block->isHardblock() && $blocksListExact['hard'] ) {
+ break;
+ } elseif ( !$block->prevents( 'createaccount' ) && $blocksListExact['disable_create'] ) {
+ break;
+ }
+
+ foreach ( $ipChain as $checkip ) {
+ $checkipHex = IP::toHex( $checkip );
+ if ( (string)$block->getTarget() === $checkip ) {
+ if ( $block->isHardblock() ) {
+ $blocksListExact['hard'] = $blocksListExact['hard'] ?: $block;
+ } elseif ( $block->prevents( 'createaccount' ) ) {
+ $blocksListExact['disable_create'] = $blocksListExact['disable_create'] ?: $block;
+ } elseif ( $block->mAuto ) {
+ $blocksListExact['auto'] = $blocksListExact['auto'] ?: $block;
+ } else {
+ $blocksListExact['other'] = $blocksListExact['other'] ?: $block;
+ }
+ // We found closest exact match in the ip list, so go to the next Block
+ break;
+ } elseif ( array_filter( $blocksListExact ) == []
+ && $block->getRangeStart() <= $checkipHex
+ && $block->getRangeEnd() >= $checkipHex
+ ) {
+ if ( $block->isHardblock() ) {
+ $blocksListRange['hard'] = $blocksListRange['hard'] ?: $block;
+ } elseif ( $block->prevents( 'createaccount' ) ) {
+ $blocksListRange['disable_create'] = $blocksListRange['disable_create'] ?: $block;
+ } elseif ( $block->mAuto ) {
+ $blocksListRange['auto'] = $blocksListRange['auto'] ?: $block;
+ } else {
+ $blocksListRange['other'] = $blocksListRange['other'] ?: $block;
+ }
+ break;
+ }
+ }
+ }
+
+ if ( array_filter( $blocksListExact ) == [] ) {
+ $blocksList = &$blocksListRange;
+ } else {
+ $blocksList = &$blocksListExact;
+ }
+
+ $chosenBlock = null;
+ if ( $blocksList['hard'] ) {
+ $chosenBlock = $blocksList['hard'];
+ } elseif ( $blocksList['disable_create'] ) {
+ $chosenBlock = $blocksList['disable_create'];
+ } elseif ( $blocksList['other'] ) {
+ $chosenBlock = $blocksList['other'];
+ } elseif ( $blocksList['auto'] ) {
+ $chosenBlock = $blocksList['auto'];
+ } else {
+ throw new MWException( "Proxy block found, but couldn't be classified." );
+ }
+
+ return $chosenBlock;
+ }
+
+ /**
+ * From an existing Block, get the target and the type of target.
+ * Note that, except for null, it is always safe to treat the target
+ * as a string; for User objects this will return User::__toString()
+ * which in turn gives User::getName().
+ *
+ * @param string|int|User|null $target
+ * @return array [ User|String|null, Block::TYPE_ constant|null ]
+ */
+ public static function parseTarget( $target ) {
+ # We may have been through this before
+ if ( $target instanceof User ) {
+ if ( IP::isValid( $target->getName() ) ) {
+ return [ $target, self::TYPE_IP ];
+ } else {
+ return [ $target, self::TYPE_USER ];
+ }
+ } elseif ( $target === null ) {
+ return [ null, null ];
+ }
+
+ $target = trim( $target );
+
+ if ( IP::isValid( $target ) ) {
+ # We can still create a User if it's an IP address, but we need to turn
+ # off validation checking (which would exclude IP addresses)
+ return [
+ User::newFromName( IP::sanitizeIP( $target ), false ),
+ self::TYPE_IP
+ ];
+
+ } elseif ( IP::isValidRange( $target ) ) {
+ # Can't create a User from an IP range
+ return [ IP::sanitizeRange( $target ), self::TYPE_RANGE ];
+ }
+
+ # Consider the possibility that this is not a username at all
+ # but actually an old subpage (bug #29797)
+ if ( strpos( $target, '/' ) !== false ) {
+ # An old subpage, drill down to the user behind it
+ $target = explode( '/', $target )[0];
+ }
+
+ $userObj = User::newFromName( $target );
+ if ( $userObj instanceof User ) {
+ # Note that since numbers are valid usernames, a $target of "12345" will be
+ # considered a User. If you want to pass a block ID, prepend a hash "#12345",
+ # since hash characters are not valid in usernames or titles generally.
+ return [ $userObj, self::TYPE_USER ];
+
+ } elseif ( preg_match( '/^#\d+$/', $target ) ) {
+ # Autoblock reference in the form "#12345"
+ return [ substr( $target, 1 ), self::TYPE_AUTO ];
+
+ } else {
+ # WTF?
+ return [ null, null ];
+ }
+ }
+
+ /**
+ * Get the type of target for this particular block
+ * @return int Block::TYPE_ constant, will never be TYPE_ID
+ */
+ public function getType() {
+ return $this->mAuto
+ ? self::TYPE_AUTO
+ : $this->type;
+ }
+
+ /**
+ * Get the target and target type for this particular Block. Note that for autoblocks,
+ * this returns the unredacted name; frontend functions need to call $block->getRedactedName()
+ * in this situation.
+ * @return array [ User|String, Block::TYPE_ constant ]
+ * @todo FIXME: This should be an integral part of the Block member variables
+ */
+ public function getTargetAndType() {
+ return [ $this->getTarget(), $this->getType() ];
+ }
+
+ /**
+ * Get the target for this particular Block. Note that for autoblocks,
+ * this returns the unredacted name; frontend functions need to call $block->getRedactedName()
+ * in this situation.
+ * @return User|string
+ */
+ public function getTarget() {
+ return $this->target;
+ }
+
+ /**
+ * @since 1.19
+ *
+ * @return mixed|string
+ */
+ public function getExpiry() {
+ return $this->mExpiry;
+ }
+
+ /**
+ * Set the target for this block, and update $this->type accordingly
+ * @param mixed $target
+ */
+ public function setTarget( $target ) {
+ list( $this->target, $this->type ) = self::parseTarget( $target );
+ }
+
+ /**
+ * Get the user who implemented this block
+ * @return User|string Local User object or string for a foreign user
+ */
+ public function getBlocker() {
+ return $this->blocker;
+ }
+
+ /**
+ * Set the user who implemented (or will implement) this block
+ * @param User|string $user Local User object or username string for foreign users
+ */
+ public function setBlocker( $user ) {
+ $this->blocker = $user;
+ }
+
+ /**
+ * Set the 'BlockID' cookie to this block's ID and expiry time. The cookie's expiry will be
+ * the same as the block's, to a maximum of 24 hours.
+ *
+ * @since 1.29
+ *
+ * @param WebResponse $response The response on which to set the cookie.
+ */
+ public function setCookie( WebResponse $response ) {
+ // Calculate the default expiry time.
+ $maxExpiryTime = wfTimestamp( TS_MW, wfTimestamp() + ( 24 * 60 * 60 ) );
+
+ // Use the Block's expiry time only if it's less than the default.
+ $expiryTime = $this->getExpiry();
+ if ( $expiryTime === 'infinity' || $expiryTime > $maxExpiryTime ) {
+ $expiryTime = $maxExpiryTime;
+ }
+
+ // Set the cookie. Reformat the MediaWiki datetime as a Unix timestamp for the cookie.
+ $expiryValue = DateTime::createFromFormat( 'YmdHis', $expiryTime )->format( 'U' );
+ $cookieOptions = [ 'httpOnly' => false ];
+ $cookieValue = $this->getCookieValue();
+ $response->setCookie( 'BlockID', $cookieValue, $expiryValue, $cookieOptions );
+ }
+
+ /**
+ * Unset the 'BlockID' cookie.
+ *
+ * @since 1.29
+ *
+ * @param WebResponse $response The response on which to unset the cookie.
+ */
+ public static function clearCookie( WebResponse $response ) {
+ $response->clearCookie( 'BlockID', [ 'httpOnly' => false ] );
+ }
+
+ /**
+ * Get the BlockID cookie's value for this block. This is usually the block ID concatenated
+ * with an HMAC in order to avoid spoofing (T152951), but if wgSecretKey is not set will just
+ * be the block ID.
+ *
+ * @since 1.29
+ *
+ * @return string The block ID, probably concatenated with "!" and the HMAC.
+ */
+ public function getCookieValue() {
+ $config = RequestContext::getMain()->getConfig();
+ $id = $this->getId();
+ $secretKey = $config->get( 'SecretKey' );
+ if ( !$secretKey ) {
+ // If there's no secret key, don't append a HMAC.
+ return $id;
+ }
+ $hmac = MWCryptHash::hmac( $id, $secretKey, false );
+ $cookieValue = $id . '!' . $hmac;
+ return $cookieValue;
+ }
+
+ /**
+ * Get the stored ID from the 'BlockID' cookie. The cookie's value is usually a combination of
+ * the ID and a HMAC (see Block::setCookie), but will sometimes only be the ID.
+ *
+ * @since 1.29
+ *
+ * @param string $cookieValue The string in which to find the ID.
+ *
+ * @return int|null The block ID, or null if the HMAC is present and invalid.
+ */
+ public static function getIdFromCookieValue( $cookieValue ) {
+ // Extract the ID prefix from the cookie value (may be the whole value, if no bang found).
+ $bangPos = strpos( $cookieValue, '!' );
+ $id = ( $bangPos === false ) ? $cookieValue : substr( $cookieValue, 0, $bangPos );
+ // Get the site-wide secret key.
+ $config = RequestContext::getMain()->getConfig();
+ $secretKey = $config->get( 'SecretKey' );
+ if ( !$secretKey ) {
+ // If there's no secret key, just use the ID as given.
+ return $id;
+ }
+ $storedHmac = substr( $cookieValue, $bangPos + 1 );
+ $calculatedHmac = MWCryptHash::hmac( $id, $secretKey, false );
+ if ( $calculatedHmac === $storedHmac ) {
+ return $id;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Get the key and parameters for the corresponding error message.
+ *
+ * @since 1.22
+ * @param IContextSource $context
+ * @return array
+ */
+ public function getPermissionsError( IContextSource $context ) {
+ $blocker = $this->getBlocker();
+ if ( $blocker instanceof User ) { // local user
+ $blockerUserpage = $blocker->getUserPage();
+ $link = "[[{$blockerUserpage->getPrefixedText()}|{$blockerUserpage->getText()}]]";
+ } else { // foreign user
+ $link = $blocker;
+ }
+
+ $reason = $this->mReason;
+ if ( $reason == '' ) {
+ $reason = $context->msg( 'blockednoreason' )->text();
+ }
+
+ /* $ip returns who *is* being blocked, $intended contains who was meant to be blocked.
+ * This could be a username, an IP range, or a single IP. */
+ $intended = $this->getTarget();
+
+ $systemBlockType = $this->getSystemBlockType();
+
+ $lang = $context->getLanguage();
+ return [
+ $systemBlockType !== null
+ ? 'systemblockedtext'
+ : ( $this->mAuto ? 'autoblockedtext' : 'blockedtext' ),
+ $link,
+ $reason,
+ $context->getRequest()->getIP(),
+ $this->getByName(),
+ $systemBlockType !== null ? $systemBlockType : $this->getId(),
+ $lang->formatExpiry( $this->mExpiry ),
+ (string)$intended,
+ $lang->userTimeAndDate( $this->mTimestamp, $context->getUser() ),
+ ];
+ }
+}
diff --git a/www/wiki/includes/CategoriesRdf.php b/www/wiki/includes/CategoriesRdf.php
new file mode 100644
index 00000000..e19dc2aa
--- /dev/null
+++ b/www/wiki/includes/CategoriesRdf.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ */
+use Wikimedia\Purtle\RdfWriter;
+
+/**
+ * Helper class to produce RDF representation of categories.
+ */
+class CategoriesRdf {
+ /**
+ * Prefix used for Mediawiki ontology in the dump.
+ */
+ const ONTOLOGY_PREFIX = 'mediawiki';
+ /**
+ * Base URL for Mediawiki ontology.
+ */
+ const ONTOLOGY_URL = 'https://www.mediawiki.org/ontology#';
+ /**
+ * OWL description of the ontology.
+ */
+ const OWL_URL = 'https://www.mediawiki.org/ontology/ontology.owl';
+ /**
+ * Current version of the dump format.
+ */
+ const FORMAT_VERSION = "1.0";
+ /**
+ * @var RdfWriter
+ */
+ private $rdfWriter;
+
+ public function __construct( RdfWriter $writer ) {
+ $this->rdfWriter = $writer;
+ }
+
+ /**
+ * Setup prefixes relevant for the dump
+ */
+ public function setupPrefixes() {
+ $this->rdfWriter->prefix( self::ONTOLOGY_PREFIX, self::ONTOLOGY_URL );
+ $this->rdfWriter->prefix( 'rdfs', 'http://www.w3.org/2000/01/rdf-schema#' );
+ $this->rdfWriter->prefix( 'owl', 'http://www.w3.org/2002/07/owl#' );
+ $this->rdfWriter->prefix( 'schema', 'http://schema.org/' );
+ $this->rdfWriter->prefix( 'cc', 'http://creativecommons.org/ns#' );
+ }
+
+ /**
+ * Write RDF data for link between categories.
+ * @param string $fromName Child category name
+ * @param string $toName Parent category name
+ */
+ public function writeCategoryLinkData( $fromName, $toName ) {
+ $titleFrom = Title::makeTitle( NS_CATEGORY, $fromName );
+ $titleTo = Title::makeTitle( NS_CATEGORY, $toName );
+ $this->rdfWriter->about( $this->titleToUrl( $titleFrom ) )
+ ->say( self::ONTOLOGY_PREFIX, 'isInCategory' )
+ ->is( $this->titleToUrl( $titleTo ) );
+ }
+
+ /**
+ * Write out the data for single category.
+ * @param string $categoryName Category name
+ */
+ public function writeCategoryData( $categoryName ) {
+ $title = Title::makeTitle( NS_CATEGORY, $categoryName );
+ $this->rdfWriter->about( $this->titleToUrl( $title ) )
+ ->say( 'a' )
+ ->is( self::ONTOLOGY_PREFIX, 'Category' );
+ $titletext = $title->getText();
+ $this->rdfWriter->say( 'rdfs', 'label' )->value( $titletext );
+ }
+
+ /**
+ * Convert Title to link to target page.
+ * @param Title $title
+ * @return string
+ */
+ private function titleToUrl( Title $title ) {
+ return $title->getFullURL( '', false, PROTO_CANONICAL );
+ }
+}
diff --git a/www/wiki/includes/Category.php b/www/wiki/includes/Category.php
new file mode 100644
index 00000000..629962d2
--- /dev/null
+++ b/www/wiki/includes/Category.php
@@ -0,0 +1,409 @@
+<?php
+/**
+ * Representation for a category.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Simetrical
+ */
+
+/**
+ * Category objects are immutable, strictly speaking. If you call methods that change the database,
+ * like to refresh link counts, the objects will be appropriately reinitialized.
+ * Member variables are lazy-initialized.
+ *
+ * @todo Move some stuff from CategoryPage.php to here, and use that.
+ */
+class Category {
+ /** Name of the category, normalized to DB-key form */
+ private $mName = null;
+ private $mID = null;
+ /**
+ * Category page title
+ * @var Title
+ */
+ private $mTitle = null;
+ /** Counts of membership (cat_pages, cat_subcats, cat_files) */
+ private $mPages = null, $mSubcats = null, $mFiles = null;
+
+ const LOAD_ONLY = 0;
+ const LAZY_INIT_ROW = 1;
+
+ private function __construct() {
+ }
+
+ /**
+ * Set up all member variables using a database query.
+ * @param int $mode
+ * @throws MWException
+ * @return bool True on success, false on failure.
+ */
+ protected function initialize( $mode = self::LOAD_ONLY ) {
+ if ( $this->mName === null && $this->mID === null ) {
+ throw new MWException( __METHOD__ . ' has both names and IDs null' );
+ } elseif ( $this->mID === null ) {
+ $where = [ 'cat_title' => $this->mName ];
+ } elseif ( $this->mName === null ) {
+ $where = [ 'cat_id' => $this->mID ];
+ } else {
+ # Already initialized
+ return true;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'category',
+ [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
+ $where,
+ __METHOD__
+ );
+
+ if ( !$row ) {
+ # Okay, there were no contents. Nothing to initialize.
+ if ( $this->mTitle ) {
+ # If there is a title object but no record in the category table,
+ # treat this as an empty category.
+ $this->mID = false;
+ $this->mName = $this->mTitle->getDBkey();
+ $this->mPages = 0;
+ $this->mSubcats = 0;
+ $this->mFiles = 0;
+
+ # If the title exists, call refreshCounts to add a row for it.
+ if ( $mode === self::LAZY_INIT_ROW && $this->mTitle->exists() ) {
+ DeferredUpdates::addCallableUpdate( [ $this, 'refreshCounts' ] );
+ }
+
+ return true;
+ } else {
+ return false; # Fail
+ }
+ }
+
+ $this->mID = $row->cat_id;
+ $this->mName = $row->cat_title;
+ $this->mPages = $row->cat_pages;
+ $this->mSubcats = $row->cat_subcats;
+ $this->mFiles = $row->cat_files;
+
+ # (T15683) If the count is negative, then 1) it's obviously wrong
+ # and should not be kept, and 2) we *probably* don't have to scan many
+ # rows to obtain the correct figure, so let's risk a one-time recount.
+ if ( $this->mPages < 0 || $this->mSubcats < 0 || $this->mFiles < 0 ) {
+ $this->mPages = max( $this->mPages, 0 );
+ $this->mSubcats = max( $this->mSubcats, 0 );
+ $this->mFiles = max( $this->mFiles, 0 );
+
+ if ( $mode === self::LAZY_INIT_ROW ) {
+ DeferredUpdates::addCallableUpdate( [ $this, 'refreshCounts' ] );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Factory function.
+ *
+ * @param array $name A category name (no "Category:" prefix). It need
+ * not be normalized, with spaces replaced by underscores.
+ * @return mixed Category, or false on a totally invalid name
+ */
+ public static function newFromName( $name ) {
+ $cat = new self();
+ $title = Title::makeTitleSafe( NS_CATEGORY, $name );
+
+ if ( !is_object( $title ) ) {
+ return false;
+ }
+
+ $cat->mTitle = $title;
+ $cat->mName = $title->getDBkey();
+
+ return $cat;
+ }
+
+ /**
+ * Factory function.
+ *
+ * @param Title $title Title for the category page
+ * @return Category|bool On a totally invalid name
+ */
+ public static function newFromTitle( $title ) {
+ $cat = new self();
+
+ $cat->mTitle = $title;
+ $cat->mName = $title->getDBkey();
+
+ return $cat;
+ }
+
+ /**
+ * Factory function.
+ *
+ * @param int $id A category id
+ * @return Category
+ */
+ public static function newFromID( $id ) {
+ $cat = new self();
+ $cat->mID = intval( $id );
+ return $cat;
+ }
+
+ /**
+ * Factory function, for constructing a Category object from a result set
+ *
+ * @param object $row Result set row, must contain the cat_xxx fields. If the
+ * fields are null, the resulting Category object will represent an empty
+ * category if a title object was given. If the fields are null and no
+ * title was given, this method fails and returns false.
+ * @param Title $title Optional title object for the category represented by
+ * the given row. May be provided if it is already known, to avoid having
+ * to re-create a title object later.
+ * @return Category|false
+ */
+ public static function newFromRow( $row, $title = null ) {
+ $cat = new self();
+ $cat->mTitle = $title;
+
+ # NOTE: the row often results from a LEFT JOIN on categorylinks. This may result in
+ # all the cat_xxx fields being null, if the category page exists, but nothing
+ # was ever added to the category. This case should be treated link an empty
+ # category, if possible.
+
+ if ( $row->cat_title === null ) {
+ if ( $title === null ) {
+ # the name is probably somewhere in the row, for example as page_title,
+ # but we can't know that here...
+ return false;
+ } else {
+ # if we have a title object, fetch the category name from there
+ $cat->mName = $title->getDBkey();
+ }
+
+ $cat->mID = false;
+ $cat->mSubcats = 0;
+ $cat->mPages = 0;
+ $cat->mFiles = 0;
+ } else {
+ $cat->mName = $row->cat_title;
+ $cat->mID = $row->cat_id;
+ $cat->mSubcats = $row->cat_subcats;
+ $cat->mPages = $row->cat_pages;
+ $cat->mFiles = $row->cat_files;
+ }
+
+ return $cat;
+ }
+
+ /**
+ * @return mixed DB key name, or false on failure
+ */
+ public function getName() {
+ return $this->getX( 'mName' );
+ }
+
+ /**
+ * @return mixed Category ID, or false on failure
+ */
+ public function getID() {
+ return $this->getX( 'mID' );
+ }
+
+ /**
+ * @return mixed Total number of member pages, or false on failure
+ */
+ public function getPageCount() {
+ return $this->getX( 'mPages' );
+ }
+
+ /**
+ * @return mixed Number of subcategories, or false on failure
+ */
+ public function getSubcatCount() {
+ return $this->getX( 'mSubcats' );
+ }
+
+ /**
+ * @return mixed Number of member files, or false on failure
+ */
+ public function getFileCount() {
+ return $this->getX( 'mFiles' );
+ }
+
+ /**
+ * @return Title|bool Title for this category, or false on failure.
+ */
+ public function getTitle() {
+ if ( $this->mTitle ) {
+ return $this->mTitle;
+ }
+
+ if ( !$this->initialize( self::LAZY_INIT_ROW ) ) {
+ return false;
+ }
+
+ $this->mTitle = Title::makeTitleSafe( NS_CATEGORY, $this->mName );
+ return $this->mTitle;
+ }
+
+ /**
+ * Fetch a TitleArray of up to $limit category members, beginning after the
+ * category sort key $offset.
+ * @param int|bool $limit
+ * @param string $offset
+ * @return TitleArray TitleArray object for category members.
+ */
+ public function getMembers( $limit = false, $offset = '' ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $conds = [ 'cl_to' => $this->getName(), 'cl_from = page_id' ];
+ $options = [ 'ORDER BY' => 'cl_sortkey' ];
+
+ if ( $limit ) {
+ $options['LIMIT'] = $limit;
+ }
+
+ if ( $offset !== '' ) {
+ $conds[] = 'cl_sortkey > ' . $dbr->addQuotes( $offset );
+ }
+
+ $result = TitleArray::newFromResult(
+ $dbr->select(
+ [ 'page', 'categorylinks' ],
+ [ 'page_id', 'page_namespace', 'page_title', 'page_len',
+ 'page_is_redirect', 'page_latest' ],
+ $conds,
+ __METHOD__,
+ $options
+ )
+ );
+
+ return $result;
+ }
+
+ /**
+ * Generic accessor
+ * @param string $key
+ * @return bool
+ */
+ private function getX( $key ) {
+ if ( !$this->initialize( self::LAZY_INIT_ROW ) ) {
+ return false;
+ }
+ return $this->{$key};
+ }
+
+ /**
+ * Refresh the counts for this category.
+ *
+ * @return bool True on success, false on failure
+ */
+ public function refreshCounts() {
+ if ( wfReadOnly() ) {
+ return false;
+ }
+
+ # If we have just a category name, find out whether there is an
+ # existing row. Or if we have just an ID, get the name, because
+ # that's what categorylinks uses.
+ if ( !$this->initialize( self::LOAD_ONLY ) ) {
+ return false;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ # Avoid excess contention on the same category (T162121)
+ $name = __METHOD__ . ':' . md5( $this->mName );
+ $scopedLock = $dbw->getScopedLockAndFlush( $name, __METHOD__, 1 );
+ if ( !$scopedLock ) {
+ return false;
+ }
+
+ $dbw->startAtomic( __METHOD__ );
+
+ $cond1 = $dbw->conditional( [ 'page_namespace' => NS_CATEGORY ], 1, 'NULL' );
+ $cond2 = $dbw->conditional( [ 'page_namespace' => NS_FILE ], 1, 'NULL' );
+ $result = $dbw->selectRow(
+ [ 'categorylinks', 'page' ],
+ [ 'pages' => 'COUNT(*)',
+ 'subcats' => "COUNT($cond1)",
+ 'files' => "COUNT($cond2)"
+ ],
+ [ 'cl_to' => $this->mName, 'page_id = cl_from' ],
+ __METHOD__,
+ [ 'LOCK IN SHARE MODE' ]
+ );
+
+ $shouldExist = $result->pages > 0 || $this->getTitle()->exists();
+
+ if ( $this->mID ) {
+ if ( $shouldExist ) {
+ # The category row already exists, so do a plain UPDATE instead
+ # of INSERT...ON DUPLICATE KEY UPDATE to avoid creating a gap
+ # in the cat_id sequence. The row may or may not be "affected".
+ $dbw->update(
+ 'category',
+ [
+ 'cat_pages' => $result->pages,
+ 'cat_subcats' => $result->subcats,
+ 'cat_files' => $result->files
+ ],
+ [ 'cat_title' => $this->mName ],
+ __METHOD__
+ );
+ } else {
+ # The category is empty and has no description page, delete it
+ $dbw->delete(
+ 'category',
+ [ 'cat_title' => $this->mName ],
+ __METHOD__
+ );
+ $this->mID = false;
+ }
+ } elseif ( $shouldExist ) {
+ # The category row doesn't exist but should, so create it. Use
+ # upsert in case of races.
+ $dbw->upsert(
+ 'category',
+ [
+ 'cat_title' => $this->mName,
+ 'cat_pages' => $result->pages,
+ 'cat_subcats' => $result->subcats,
+ 'cat_files' => $result->files
+ ],
+ [ 'cat_title' ],
+ [
+ 'cat_pages' => $result->pages,
+ 'cat_subcats' => $result->subcats,
+ 'cat_files' => $result->files
+ ],
+ __METHOD__
+ );
+ // @todo: Should we update $this->mID here? Or not since Category
+ // objects tend to be short lived enough to not matter?
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+
+ # Now we should update our local counts.
+ $this->mPages = $result->pages;
+ $this->mSubcats = $result->subcats;
+ $this->mFiles = $result->files;
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/CategoryFinder.php b/www/wiki/includes/CategoryFinder.php
new file mode 100644
index 00000000..89bf5c73
--- /dev/null
+++ b/www/wiki/includes/CategoryFinder.php
@@ -0,0 +1,243 @@
+<?php
+/**
+ * Recent changes filtering by category.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * The "CategoryFinder" class takes a list of articles, creates an internal
+ * representation of all their parent categories (as well as parents of
+ * parents etc.). From this representation, it determines which of these
+ * articles are in one or all of a given subset of categories.
+ *
+ * Example use :
+ * @code
+ * # Determines whether the article with the page_id 12345 is in both
+ * # "Category 1" and "Category 2" or their subcategories, respectively
+ *
+ * $cf = new CategoryFinder;
+ * $cf->seed(
+ * [ 12345 ],
+ * [ 'Category 1', 'Category 2' ],
+ * 'AND'
+ * );
+ * $a = $cf->run();
+ * print implode( ',' , $a );
+ * @endcode
+ */
+class CategoryFinder {
+ /** @var int[] The original article IDs passed to the seed function */
+ protected $articles = [];
+
+ /** @var array Array of DBKEY category names for categories that don't have a page */
+ protected $deadend = [];
+
+ /** @var array Array of [ ID => [] ] */
+ protected $parents = [];
+
+ /** @var array Array of article/category IDs */
+ protected $next = [];
+
+ /** @var array Array of DBKEY category names */
+ protected $targets = [];
+
+ /** @var array */
+ protected $name2id = [];
+
+ /** @var string "AND" or "OR" */
+ protected $mode;
+
+ /** @var IDatabase Read-DB replica DB */
+ protected $dbr;
+
+ /**
+ * Initializes the instance. Do this prior to calling run().
+ * @param array $articleIds Array of article IDs
+ * @param array $categories FIXME
+ * @param string $mode FIXME, default 'AND'.
+ * @todo FIXME: $categories/$mode
+ */
+ public function seed( $articleIds, $categories, $mode = 'AND' ) {
+ $this->articles = $articleIds;
+ $this->next = $articleIds;
+ $this->mode = $mode;
+
+ # Set the list of target categories; convert them to DBKEY form first
+ $this->targets = [];
+ foreach ( $categories as $c ) {
+ $ct = Title::makeTitleSafe( NS_CATEGORY, $c );
+ if ( $ct ) {
+ $c = $ct->getDBkey();
+ $this->targets[$c] = $c;
+ }
+ }
+ }
+
+ /**
+ * Iterates through the parent tree starting with the seed values,
+ * then checks the articles if they match the conditions
+ * @return array Array of page_ids (those given to seed() that match the conditions)
+ */
+ public function run() {
+ $this->dbr = wfGetDB( DB_REPLICA );
+ while ( count( $this->next ) > 0 ) {
+ $this->scanNextLayer();
+ }
+
+ # Now check if this applies to the individual articles
+ $ret = [];
+
+ foreach ( $this->articles as $article ) {
+ $conds = $this->targets;
+ if ( $this->check( $article, $conds ) ) {
+ # Matches the conditions
+ $ret[] = $article;
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Get the parents. Only really useful if run() has been called already
+ * @return array
+ */
+ public function getParents() {
+ return $this->parents;
+ }
+
+ /**
+ * This functions recurses through the parent representation, trying to match the conditions
+ * @param int $id The article/category to check
+ * @param array $conds The array of categories to match
+ * @param array $path Used to check for recursion loops
+ * @return bool Does this match the conditions?
+ */
+ private function check( $id, &$conds, $path = [] ) {
+ // Check for loops and stop!
+ if ( in_array( $id, $path ) ) {
+ return false;
+ }
+
+ $path[] = $id;
+
+ # Shortcut (runtime paranoia): No conditions=all matched
+ if ( count( $conds ) == 0 ) {
+ return true;
+ }
+
+ if ( !isset( $this->parents[$id] ) ) {
+ return false;
+ }
+
+ # iterate through the parents
+ foreach ( $this->parents[$id] as $p ) {
+ $pname = $p->cl_to;
+
+ # Is this a condition?
+ if ( isset( $conds[$pname] ) ) {
+ # This key is in the category list!
+ if ( $this->mode == 'OR' ) {
+ # One found, that's enough!
+ $conds = [];
+ return true;
+ } else {
+ # Assuming "AND" as default
+ unset( $conds[$pname] );
+ if ( count( $conds ) == 0 ) {
+ # All conditions met, done
+ return true;
+ }
+ }
+ }
+
+ # Not done yet, try sub-parents
+ if ( !isset( $this->name2id[$pname] ) ) {
+ # No sub-parent
+ continue;
+ }
+ $done = $this->check( $this->name2id[$pname], $conds, $path );
+ if ( $done || count( $conds ) == 0 ) {
+ # Subparents have done it!
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Scans a "parent layer" of the articles/categories in $this->next
+ */
+ private function scanNextLayer() {
+ # Find all parents of the article currently in $this->next
+ $layer = [];
+ $res = $this->dbr->select(
+ /* FROM */ 'categorylinks',
+ /* SELECT */ '*',
+ /* WHERE */ [ 'cl_from' => $this->next ],
+ __METHOD__ . '-1'
+ );
+ foreach ( $res as $o ) {
+ $k = $o->cl_to;
+
+ # Update parent tree
+ if ( !isset( $this->parents[$o->cl_from] ) ) {
+ $this->parents[$o->cl_from] = [];
+ }
+ $this->parents[$o->cl_from][$k] = $o;
+
+ # Ignore those we already have
+ if ( in_array( $k, $this->deadend ) ) {
+ continue;
+ }
+
+ if ( isset( $this->name2id[$k] ) ) {
+ continue;
+ }
+
+ # Hey, new category!
+ $layer[$k] = $k;
+ }
+
+ $this->next = [];
+
+ # Find the IDs of all category pages in $layer, if they exist
+ if ( count( $layer ) > 0 ) {
+ $res = $this->dbr->select(
+ /* FROM */ 'page',
+ /* SELECT */ [ 'page_id', 'page_title' ],
+ /* WHERE */ [ 'page_namespace' => NS_CATEGORY, 'page_title' => $layer ],
+ __METHOD__ . '-2'
+ );
+ foreach ( $res as $o ) {
+ $id = $o->page_id;
+ $name = $o->page_title;
+ $this->name2id[$name] = $id;
+ $this->next[] = $id;
+ unset( $layer[$name] );
+ }
+ }
+
+ # Mark dead ends
+ foreach ( $layer as $v ) {
+ $this->deadend[$v] = $v;
+ }
+ }
+}
diff --git a/www/wiki/includes/CategoryViewer.php b/www/wiki/includes/CategoryViewer.php
new file mode 100644
index 00000000..9d692d71
--- /dev/null
+++ b/www/wiki/includes/CategoryViewer.php
@@ -0,0 +1,752 @@
+<?php
+/**
+ * List and paging of category members.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+class CategoryViewer extends ContextSource {
+ /** @var int */
+ public $limit;
+
+ /** @var array */
+ public $from;
+
+ /** @var array */
+ public $until;
+
+ /** @var string[] */
+ public $articles;
+
+ /** @var array */
+ public $articles_start_char;
+
+ /** @var array */
+ public $children;
+
+ /** @var array */
+ public $children_start_char;
+
+ /** @var bool */
+ public $showGallery;
+
+ /** @var array */
+ public $imgsNoGallery_start_char;
+
+ /** @var array */
+ public $imgsNoGallery;
+
+ /** @var array */
+ public $nextPage;
+
+ /** @var array */
+ protected $prevPage;
+
+ /** @var array */
+ public $flip;
+
+ /** @var Title */
+ public $title;
+
+ /** @var Collation */
+ public $collation;
+
+ /** @var ImageGalleryBase */
+ public $gallery;
+
+ /** @var Category Category object for this page. */
+ private $cat;
+
+ /** @var array The original query array, to be used in generating paging links. */
+ private $query;
+
+ /**
+ * @since 1.19 $context is a second, required parameter
+ * @param Title $title
+ * @param IContextSource $context
+ * @param array $from An array with keys page, subcat,
+ * and file for offset of results of each section (since 1.17)
+ * @param array $until An array with 3 keys for until of each section (since 1.17)
+ * @param array $query
+ */
+ function __construct( $title, IContextSource $context, $from = [],
+ $until = [], $query = []
+ ) {
+ $this->title = $title;
+ $this->setContext( $context );
+ $this->getOutput()->addModuleStyles( [
+ 'mediawiki.action.view.categoryPage.styles'
+ ] );
+ $this->from = $from;
+ $this->until = $until;
+ $this->limit = $context->getConfig()->get( 'CategoryPagingLimit' );
+ $this->cat = Category::newFromTitle( $title );
+ $this->query = $query;
+ $this->collation = Collation::singleton();
+ unset( $this->query['title'] );
+ }
+
+ /**
+ * Format the category data list.
+ *
+ * @return string HTML output
+ */
+ public function getHTML() {
+ $this->showGallery = $this->getConfig()->get( 'CategoryMagicGallery' )
+ && !$this->getOutput()->mNoGallery;
+
+ $this->clearCategoryState();
+ $this->doCategoryQuery();
+ $this->finaliseCategoryState();
+
+ $r = $this->getSubcategorySection() .
+ $this->getPagesSection() .
+ $this->getImageSection();
+
+ if ( $r == '' ) {
+ // If there is no category content to display, only
+ // show the top part of the navigation links.
+ // @todo FIXME: Cannot be completely suppressed because it
+ // is unknown if 'until' or 'from' makes this
+ // give 0 results.
+ $r = $r . $this->getCategoryTop();
+ } else {
+ $r = $this->getCategoryTop() .
+ $r .
+ $this->getCategoryBottom();
+ }
+
+ // Give a proper message if category is empty
+ if ( $r == '' ) {
+ $r = $this->msg( 'category-empty' )->parseAsBlock();
+ }
+
+ $lang = $this->getLanguage();
+ $attribs = [
+ 'class' => 'mw-category-generated',
+ 'lang' => $lang->getHtmlCode(),
+ 'dir' => $lang->getDir()
+ ];
+ # put a div around the headings which are in the user language
+ $r = Html::openElement( 'div', $attribs ) . $r . '</div>';
+
+ return $r;
+ }
+
+ function clearCategoryState() {
+ $this->articles = [];
+ $this->articles_start_char = [];
+ $this->children = [];
+ $this->children_start_char = [];
+ if ( $this->showGallery ) {
+ // Note that null for mode is taken to mean use default.
+ $mode = $this->getRequest()->getVal( 'gallerymode', null );
+ try {
+ $this->gallery = ImageGalleryBase::factory( $mode, $this->getContext() );
+ } catch ( Exception $e ) {
+ // User specified something invalid, fallback to default.
+ $this->gallery = ImageGalleryBase::factory( false, $this->getContext() );
+ }
+
+ $this->gallery->setHideBadImages();
+ } else {
+ $this->imgsNoGallery = [];
+ $this->imgsNoGallery_start_char = [];
+ }
+ }
+
+ /**
+ * Add a subcategory to the internal lists, using a Category object
+ * @param Category $cat
+ * @param string $sortkey
+ * @param int $pageLength
+ */
+ function addSubcategoryObject( Category $cat, $sortkey, $pageLength ) {
+ // Subcategory; strip the 'Category' namespace from the link text.
+ $title = $cat->getTitle();
+
+ $this->children[] = $this->generateLink(
+ 'subcat',
+ $title,
+ $title->isRedirect(),
+ htmlspecialchars( $title->getText() )
+ );
+
+ $this->children_start_char[] =
+ $this->getSubcategorySortChar( $cat->getTitle(), $sortkey );
+ }
+
+ function generateLink( $type, Title $title, $isRedirect, $html = null ) {
+ $link = null;
+ Hooks::run( 'CategoryViewer::generateLink', [ $type, $title, $html, &$link ] );
+ if ( $link === null ) {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ if ( $html !== null ) {
+ $html = new HtmlArmor( $html );
+ }
+ $link = $linkRenderer->makeLink( $title, $html );
+ }
+ if ( $isRedirect ) {
+ $link = '<span class="redirect-in-category">' . $link . '</span>';
+ }
+
+ return $link;
+ }
+
+ /**
+ * Get the character to be used for sorting subcategories.
+ * If there's a link from Category:A to Category:B, the sortkey of the resulting
+ * entry in the categorylinks table is Category:A, not A, which it SHOULD be.
+ * Workaround: If sortkey == "Category:".$title, than use $title for sorting,
+ * else use sortkey...
+ *
+ * @param Title $title
+ * @param string $sortkey The human-readable sortkey (before transforming to icu or whatever).
+ * @return string
+ */
+ function getSubcategorySortChar( $title, $sortkey ) {
+ global $wgContLang;
+
+ if ( $title->getPrefixedText() == $sortkey ) {
+ $word = $title->getDBkey();
+ } else {
+ $word = $sortkey;
+ }
+
+ $firstChar = $this->collation->getFirstLetter( $word );
+
+ return $wgContLang->convert( $firstChar );
+ }
+
+ /**
+ * Add a page in the image namespace
+ * @param Title $title
+ * @param string $sortkey
+ * @param int $pageLength
+ * @param bool $isRedirect
+ */
+ function addImage( Title $title, $sortkey, $pageLength, $isRedirect = false ) {
+ global $wgContLang;
+ if ( $this->showGallery ) {
+ $flip = $this->flip['file'];
+ if ( $flip ) {
+ $this->gallery->insert( $title );
+ } else {
+ $this->gallery->add( $title );
+ }
+ } else {
+ $this->imgsNoGallery[] = $this->generateLink( 'image', $title, $isRedirect );
+
+ $this->imgsNoGallery_start_char[] = $wgContLang->convert(
+ $this->collation->getFirstLetter( $sortkey ) );
+ }
+ }
+
+ /**
+ * Add a miscellaneous page
+ * @param Title $title
+ * @param string $sortkey
+ * @param int $pageLength
+ * @param bool $isRedirect
+ */
+ function addPage( $title, $sortkey, $pageLength, $isRedirect = false ) {
+ global $wgContLang;
+
+ $this->articles[] = $this->generateLink( 'page', $title, $isRedirect );
+
+ $this->articles_start_char[] = $wgContLang->convert(
+ $this->collation->getFirstLetter( $sortkey ) );
+ }
+
+ function finaliseCategoryState() {
+ if ( $this->flip['subcat'] ) {
+ $this->children = array_reverse( $this->children );
+ $this->children_start_char = array_reverse( $this->children_start_char );
+ }
+ if ( $this->flip['page'] ) {
+ $this->articles = array_reverse( $this->articles );
+ $this->articles_start_char = array_reverse( $this->articles_start_char );
+ }
+ if ( !$this->showGallery && $this->flip['file'] ) {
+ $this->imgsNoGallery = array_reverse( $this->imgsNoGallery );
+ $this->imgsNoGallery_start_char = array_reverse( $this->imgsNoGallery_start_char );
+ }
+ }
+
+ function doCategoryQuery() {
+ $dbr = wfGetDB( DB_REPLICA, 'category' );
+
+ $this->nextPage = [
+ 'page' => null,
+ 'subcat' => null,
+ 'file' => null,
+ ];
+ $this->prevPage = [
+ 'page' => null,
+ 'subcat' => null,
+ 'file' => null,
+ ];
+
+ $this->flip = [ 'page' => false, 'subcat' => false, 'file' => false ];
+
+ foreach ( [ 'page', 'subcat', 'file' ] as $type ) {
+ # Get the sortkeys for start/end, if applicable. Note that if
+ # the collation in the database differs from the one
+ # set in $wgCategoryCollation, pagination might go totally haywire.
+ $extraConds = [ 'cl_type' => $type ];
+ if ( isset( $this->from[$type] ) && $this->from[$type] !== null ) {
+ $extraConds[] = 'cl_sortkey >= '
+ . $dbr->addQuotes( $this->collation->getSortKey( $this->from[$type] ) );
+ } elseif ( isset( $this->until[$type] ) && $this->until[$type] !== null ) {
+ $extraConds[] = 'cl_sortkey < '
+ . $dbr->addQuotes( $this->collation->getSortKey( $this->until[$type] ) );
+ $this->flip[$type] = true;
+ }
+
+ $res = $dbr->select(
+ [ 'page', 'categorylinks', 'category' ],
+ array_merge(
+ LinkCache::getSelectFields(),
+ [
+ 'page_namespace',
+ 'page_title',
+ 'cl_sortkey',
+ 'cat_id',
+ 'cat_title',
+ 'cat_subcats',
+ 'cat_pages',
+ 'cat_files',
+ 'cl_sortkey_prefix',
+ 'cl_collation'
+ ]
+ ),
+ array_merge( [ 'cl_to' => $this->title->getDBkey() ], $extraConds ),
+ __METHOD__,
+ [
+ 'USE INDEX' => [ 'categorylinks' => 'cl_sortkey' ],
+ 'LIMIT' => $this->limit + 1,
+ 'ORDER BY' => $this->flip[$type] ? 'cl_sortkey DESC' : 'cl_sortkey',
+ ],
+ [
+ 'categorylinks' => [ 'INNER JOIN', 'cl_from = page_id' ],
+ 'category' => [ 'LEFT JOIN', [
+ 'cat_title = page_title',
+ 'page_namespace' => NS_CATEGORY
+ ] ]
+ ]
+ );
+
+ Hooks::run( 'CategoryViewer::doCategoryQuery', [ $type, $res ] );
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+
+ $count = 0;
+ foreach ( $res as $row ) {
+ $title = Title::newFromRow( $row );
+ $linkCache->addGoodLinkObjFromRow( $title, $row );
+
+ if ( $row->cl_collation === '' ) {
+ // Hack to make sure that while updating from 1.16 schema
+ // and db is inconsistent, that the sky doesn't fall.
+ // See r83544. Could perhaps be removed in a couple decades...
+ $humanSortkey = $row->cl_sortkey;
+ } else {
+ $humanSortkey = $title->getCategorySortkey( $row->cl_sortkey_prefix );
+ }
+
+ if ( ++$count > $this->limit ) {
+ # We've reached the one extra which shows that there
+ # are additional pages to be had. Stop here...
+ $this->nextPage[$type] = $humanSortkey;
+ break;
+ }
+ if ( $count == $this->limit ) {
+ $this->prevPage[$type] = $humanSortkey;
+ }
+
+ if ( $title->getNamespace() == NS_CATEGORY ) {
+ $cat = Category::newFromRow( $row, $title );
+ $this->addSubcategoryObject( $cat, $humanSortkey, $row->page_len );
+ } elseif ( $title->getNamespace() == NS_FILE ) {
+ $this->addImage( $title, $humanSortkey, $row->page_len, $row->page_is_redirect );
+ } else {
+ $this->addPage( $title, $humanSortkey, $row->page_len, $row->page_is_redirect );
+ }
+ }
+ }
+ }
+
+ /**
+ * @return string
+ */
+ function getCategoryTop() {
+ $r = $this->getCategoryBottom();
+ return $r === ''
+ ? $r
+ : "<br style=\"clear:both;\"/>\n" . $r;
+ }
+
+ /**
+ * @return string
+ */
+ function getSubcategorySection() {
+ # Don't show subcategories section if there are none.
+ $r = '';
+ $rescnt = count( $this->children );
+ $dbcnt = $this->cat->getSubcatCount();
+ // This function should be called even if the result isn't used, it has side-effects
+ $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'subcat' );
+
+ if ( $rescnt > 0 ) {
+ # Showing subcategories
+ $r .= "<div id=\"mw-subcategories\">\n";
+ $r .= '<h2>' . $this->msg( 'subcategories' )->parse() . "</h2>\n";
+ $r .= $countmsg;
+ $r .= $this->getSectionPagingLinks( 'subcat' );
+ $r .= $this->formatList( $this->children, $this->children_start_char );
+ $r .= $this->getSectionPagingLinks( 'subcat' );
+ $r .= "\n</div>";
+ }
+ return $r;
+ }
+
+ /**
+ * @return string
+ */
+ function getPagesSection() {
+ $ti = wfEscapeWikiText( $this->title->getText() );
+ # Don't show articles section if there are none.
+ $r = '';
+
+ # @todo FIXME: Here and in the other two sections: we don't need to bother
+ # with this rigmarole if the entire category contents fit on one page
+ # and have already been retrieved. We can just use $rescnt in that
+ # case and save a query and some logic.
+ $dbcnt = $this->cat->getPageCount() - $this->cat->getSubcatCount()
+ - $this->cat->getFileCount();
+ $rescnt = count( $this->articles );
+ // This function should be called even if the result isn't used, it has side-effects
+ $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'article' );
+
+ if ( $rescnt > 0 ) {
+ $r = "<div id=\"mw-pages\">\n";
+ $r .= '<h2>' . $this->msg( 'category_header', $ti )->parse() . "</h2>\n";
+ $r .= $countmsg;
+ $r .= $this->getSectionPagingLinks( 'page' );
+ $r .= $this->formatList( $this->articles, $this->articles_start_char );
+ $r .= $this->getSectionPagingLinks( 'page' );
+ $r .= "\n</div>";
+ }
+ return $r;
+ }
+
+ /**
+ * @return string
+ */
+ function getImageSection() {
+ $r = '';
+ $rescnt = $this->showGallery ? $this->gallery->count() : count( $this->imgsNoGallery );
+ $dbcnt = $this->cat->getFileCount();
+ // This function should be called even if the result isn't used, it has side-effects
+ $countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'file' );
+
+ if ( $rescnt > 0 ) {
+ $r .= "<div id=\"mw-category-media\">\n";
+ $r .= '<h2>' .
+ $this->msg(
+ 'category-media-header',
+ wfEscapeWikiText( $this->title->getText() )
+ )->text() .
+ "</h2>\n";
+ $r .= $countmsg;
+ $r .= $this->getSectionPagingLinks( 'file' );
+ if ( $this->showGallery ) {
+ $r .= $this->gallery->toHTML();
+ } else {
+ $r .= $this->formatList( $this->imgsNoGallery, $this->imgsNoGallery_start_char );
+ }
+ $r .= $this->getSectionPagingLinks( 'file' );
+ $r .= "\n</div>";
+ }
+ return $r;
+ }
+
+ /**
+ * Get the paging links for a section (subcats/pages/files), to go at the top and bottom
+ * of the output.
+ *
+ * @param string $type 'page', 'subcat', or 'file'
+ * @return string HTML output, possibly empty if there are no other pages
+ */
+ private function getSectionPagingLinks( $type ) {
+ if ( isset( $this->until[$type] ) && $this->until[$type] !== null ) {
+ // The new value for the until parameter should be pointing to the first
+ // result displayed on the page which is the second last result retrieved
+ // from the database.The next link should have a from parameter pointing
+ // to the until parameter of the current page.
+ if ( $this->nextPage[$type] !== null ) {
+ return $this->pagingLinks( $this->prevPage[$type], $this->until[$type], $type );
+ } else {
+ // If the nextPage variable is null, it means that we have reached the first page
+ // and therefore the previous link should be disabled.
+ return $this->pagingLinks( null, $this->until[$type], $type );
+ }
+ } elseif ( $this->nextPage[$type] !== null
+ || ( isset( $this->from[$type] ) && $this->from[$type] !== null )
+ ) {
+ return $this->pagingLinks( $this->from[$type], $this->nextPage[$type], $type );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * @return string
+ */
+ function getCategoryBottom() {
+ return '';
+ }
+
+ /**
+ * Format a list of articles chunked by letter, either as a
+ * bullet list or a columnar format, depending on the length.
+ *
+ * @param array $articles
+ * @param array $articles_start_char
+ * @param int $cutoff
+ * @return string
+ * @private
+ */
+ function formatList( $articles, $articles_start_char, $cutoff = 6 ) {
+ $list = '';
+ if ( count( $articles ) > $cutoff ) {
+ $list = self::columnList( $articles, $articles_start_char );
+ } elseif ( count( $articles ) > 0 ) {
+ // for short lists of articles in categories.
+ $list = self::shortList( $articles, $articles_start_char );
+ }
+
+ $pageLang = $this->title->getPageLanguage();
+ $attribs = [ 'lang' => $pageLang->getHtmlCode(), 'dir' => $pageLang->getDir(),
+ 'class' => 'mw-content-' . $pageLang->getDir() ];
+ $list = Html::rawElement( 'div', $attribs, $list );
+
+ return $list;
+ }
+
+ /**
+ * Format a list of articles chunked by letter in a three-column list, ordered
+ * vertically. This is used for categories with a significant number of pages.
+ *
+ * TODO: Take the headers into account when creating columns, so they're
+ * more visually equal.
+ *
+ * TODO: shortList and columnList are similar, need merging
+ *
+ * @param string[] $articles HTML links to each article
+ * @param string[] $articles_start_char The header characters for each article
+ * @return string HTML to output
+ * @private
+ */
+ static function columnList( $articles, $articles_start_char ) {
+ $columns = array_combine( $articles, $articles_start_char );
+
+ $ret = Html::openElement( 'div', [ 'class' => 'mw-category' ] );
+
+ $colContents = [];
+
+ # Kind of like array_flip() here, but we keep duplicates in an
+ # array instead of dropping them.
+ foreach ( $columns as $article => $char ) {
+ if ( !isset( $colContents[$char] ) ) {
+ $colContents[$char] = [];
+ }
+ $colContents[$char][] = $article;
+ }
+
+ foreach ( $colContents as $char => $articles ) {
+ # Change space to non-breaking space to keep headers aligned
+ $h3char = $char === ' ' ? '&#160;' : htmlspecialchars( $char );
+
+ $ret .= '<div class="mw-category-group"><h3>' . $h3char;
+ $ret .= "</h3>\n";
+
+ $ret .= '<ul><li>';
+ $ret .= implode( "</li>\n<li>", $articles );
+ $ret .= '</li></ul></div>';
+
+ }
+
+ $ret .= Html::closeElement( 'div' );
+ return $ret;
+ }
+
+ /**
+ * Format a list of articles chunked by letter in a bullet list. This is used
+ * for categories with a small number of pages (when columns aren't needed).
+ * @param string[] $articles HTML links to each article
+ * @param string[] $articles_start_char The header characters for each article
+ * @return string HTML to output
+ * @private
+ */
+ static function shortList( $articles, $articles_start_char ) {
+ $r = '<h3>' . htmlspecialchars( $articles_start_char[0] ) . "</h3>\n";
+ $r .= '<ul><li>' . $articles[0] . '</li>';
+ $articleCount = count( $articles );
+ for ( $index = 1; $index < $articleCount; $index++ ) {
+ if ( $articles_start_char[$index] != $articles_start_char[$index - 1] ) {
+ $r .= "</ul><h3>" . htmlspecialchars( $articles_start_char[$index] ) . "</h3>\n<ul>";
+ }
+
+ $r .= "<li>{$articles[$index]}</li>";
+ }
+ $r .= '</ul>';
+ return $r;
+ }
+
+ /**
+ * Create paging links, as a helper method to getSectionPagingLinks().
+ *
+ * @param string $first The 'until' parameter for the generated URL
+ * @param string $last The 'from' parameter for the generated URL
+ * @param string $type A prefix for parameters, 'page' or 'subcat' or
+ * 'file'
+ * @return string HTML
+ */
+ private function pagingLinks( $first, $last, $type = '' ) {
+ $prevLink = $this->msg( 'prev-page' )->text();
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ if ( $first != '' ) {
+ $prevQuery = $this->query;
+ $prevQuery["{$type}until"] = $first;
+ unset( $prevQuery["{$type}from"] );
+ $prevLink = $linkRenderer->makeKnownLink(
+ $this->addFragmentToTitle( $this->title, $type ),
+ $prevLink,
+ [],
+ $prevQuery
+ );
+ }
+
+ $nextLink = $this->msg( 'next-page' )->text();
+
+ if ( $last != '' ) {
+ $lastQuery = $this->query;
+ $lastQuery["{$type}from"] = $last;
+ unset( $lastQuery["{$type}until"] );
+ $nextLink = $linkRenderer->makeKnownLink(
+ $this->addFragmentToTitle( $this->title, $type ),
+ $nextLink,
+ [],
+ $lastQuery
+ );
+ }
+
+ return $this->msg( 'categoryviewer-pagedlinks' )->rawParams( $prevLink, $nextLink )->escaped();
+ }
+
+ /**
+ * Takes a title, and adds the fragment identifier that
+ * corresponds to the correct segment of the category.
+ *
+ * @param Title $title The title (usually $this->title)
+ * @param string $section Which section
+ * @throws MWException
+ * @return Title
+ */
+ private function addFragmentToTitle( $title, $section ) {
+ switch ( $section ) {
+ case 'page':
+ $fragment = 'mw-pages';
+ break;
+ case 'subcat':
+ $fragment = 'mw-subcategories';
+ break;
+ case 'file':
+ $fragment = 'mw-category-media';
+ break;
+ default:
+ throw new MWException( __METHOD__ .
+ " Invalid section $section." );
+ }
+
+ return Title::makeTitle( $title->getNamespace(),
+ $title->getDBkey(), $fragment );
+ }
+
+ /**
+ * What to do if the category table conflicts with the number of results
+ * returned? This function says what. Each type is considered independently
+ * of the other types.
+ *
+ * @param int $rescnt The number of items returned by our database query.
+ * @param int $dbcnt The number of items according to the category table.
+ * @param string $type 'subcat', 'article', or 'file'
+ * @return string A message giving the number of items, to output to HTML.
+ */
+ private function getCountMessage( $rescnt, $dbcnt, $type ) {
+ // There are three cases:
+ // 1) The category table figure seems sane. It might be wrong, but
+ // we can't do anything about it if we don't recalculate it on ev-
+ // ery category view.
+ // 2) The category table figure isn't sane, like it's smaller than the
+ // number of actual results, *but* the number of results is less
+ // than $this->limit and there's no offset. In this case we still
+ // know the right figure.
+ // 3) We have no idea.
+
+ // Check if there's a "from" or "until" for anything
+
+ // This is a little ugly, but we seem to use different names
+ // for the paging types then for the messages.
+ if ( $type === 'article' ) {
+ $pagingType = 'page';
+ } else {
+ $pagingType = $type;
+ }
+
+ $fromOrUntil = false;
+ if ( ( isset( $this->from[$pagingType] ) && $this->from[$pagingType] !== null ) ||
+ ( isset( $this->until[$pagingType] ) && $this->until[$pagingType] !== null )
+ ) {
+ $fromOrUntil = true;
+ }
+
+ if ( $dbcnt == $rescnt ||
+ ( ( $rescnt == $this->limit || $fromOrUntil ) && $dbcnt > $rescnt )
+ ) {
+ // Case 1: seems sane.
+ $totalcnt = $dbcnt;
+ } elseif ( $rescnt < $this->limit && !$fromOrUntil ) {
+ // Case 2: not sane, but salvageable. Use the number of results.
+ // Since there are fewer than 200, we can also take this opportunity
+ // to refresh the incorrect category table entry -- which should be
+ // quick due to the small number of entries.
+ $totalcnt = $rescnt;
+ DeferredUpdates::addCallableUpdate( [ $this->cat, 'refreshCounts' ] );
+ } else {
+ // Case 3: hopeless. Don't give a total count at all.
+ // Messages: category-subcat-count-limited, category-article-count-limited,
+ // category-file-count-limited
+ return $this->msg( "category-$type-count-limited" )->numParams( $rescnt )->parseAsBlock();
+ }
+ // Messages: category-subcat-count, category-article-count, category-file-count
+ return $this->msg( "category-$type-count" )->numParams( $rescnt, $totalcnt )->parseAsBlock();
+ }
+}
diff --git a/www/wiki/includes/CommentStore.php b/www/wiki/includes/CommentStore.php
new file mode 100644
index 00000000..0d679d37
--- /dev/null
+++ b/www/wiki/includes/CommentStore.php
@@ -0,0 +1,589 @@
+<?php
+/**
+ * Manage storage of comments in the database
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * CommentStore handles storage of comments (edit summaries, log reasons, etc)
+ * in the database.
+ * @since 1.30
+ */
+class CommentStore {
+
+ /**
+ * Maximum length of a comment in UTF-8 characters. Longer comments will be truncated.
+ * @note This must be at least 255 and not greater than floor( MAX_COMMENT_LENGTH / 4 ).
+ */
+ const COMMENT_CHARACTER_LIMIT = 1000;
+
+ /**
+ * Maximum length of a comment in bytes. Longer comments will be truncated.
+ * @note This value is determined by the size of the underlying database field,
+ * currently BLOB in MySQL/MariaDB.
+ */
+ const MAX_COMMENT_LENGTH = 65535;
+
+ /**
+ * Maximum length of serialized data in bytes. Longer data will result in an exception.
+ * @note This value is determined by the size of the underlying database field,
+ * currently BLOB in MySQL/MariaDB.
+ */
+ const MAX_DATA_LENGTH = 65535;
+
+ /**
+ * Define fields that use temporary tables for transitional purposes
+ * @var array Keys are '$key', values are arrays with four fields:
+ * - table: Temporary table name
+ * - pk: Temporary table column referring to the main table's primary key
+ * - field: Temporary table column referring comment.comment_id
+ * - joinPK: Main table's primary key
+ */
+ protected static $tempTables = [
+ 'rev_comment' => [
+ 'table' => 'revision_comment_temp',
+ 'pk' => 'revcomment_rev',
+ 'field' => 'revcomment_comment_id',
+ 'joinPK' => 'rev_id',
+ ],
+ 'img_description' => [
+ 'table' => 'image_comment_temp',
+ 'pk' => 'imgcomment_name',
+ 'field' => 'imgcomment_description_id',
+ 'joinPK' => 'img_name',
+ ],
+ ];
+
+ /**
+ * Fields that formerly used $tempTables
+ * @var array Key is '$key', value is the MediaWiki version in which it was
+ * removed from $tempTables.
+ */
+ protected static $formerTempTables = [];
+
+ /** @var string */
+ protected $key;
+
+ /** @var int One of the MIGRATION_* constants */
+ protected $stage;
+
+ /** @var array|null Cache for `self::getJoin()` */
+ protected $joinCache = null;
+
+ /** @var Language Language to use for comment truncation */
+ protected $lang;
+
+ /**
+ * @param string $key A key such as "rev_comment" identifying the comment
+ * field being fetched.
+ * @param Language $lang Language to use for comment truncation. Defaults
+ * to $wgContLang.
+ */
+ public function __construct( $key, Language $lang = null ) {
+ global $wgCommentTableSchemaMigrationStage, $wgContLang;
+
+ $this->key = $key;
+ $this->stage = $wgCommentTableSchemaMigrationStage;
+ $this->lang = $lang ?: $wgContLang;
+ }
+
+ /**
+ * Static constructor for easier chaining
+ * @param string $key A key such as "rev_comment" identifying the comment
+ * field being fetched.
+ * @return CommentStore
+ */
+ public static function newKey( $key ) {
+ return new CommentStore( $key );
+ }
+
+ /**
+ * Get SELECT fields for the comment key
+ *
+ * Each resulting row should be passed to `self::getCommentLegacy()` to get the
+ * actual comment.
+ *
+ * @note Use of this method may require a subsequent database query to
+ * actually fetch the comment. If possible, use `self::getJoin()` instead.
+ * @return string[] to include in the `$vars` to `IDatabase->select()`. All
+ * fields are aliased, so `+` is safe to use.
+ */
+ public function getFields() {
+ $fields = [];
+ if ( $this->stage === MIGRATION_OLD ) {
+ $fields["{$this->key}_text"] = $this->key;
+ $fields["{$this->key}_data"] = 'NULL';
+ $fields["{$this->key}_cid"] = 'NULL';
+ } else {
+ if ( $this->stage < MIGRATION_NEW ) {
+ $fields["{$this->key}_old"] = $this->key;
+ }
+ if ( isset( self::$tempTables[$this->key] ) ) {
+ $fields["{$this->key}_pk"] = self::$tempTables[$this->key]['joinPK'];
+ } else {
+ $fields["{$this->key}_id"] = "{$this->key}_id";
+ }
+ }
+ return $fields;
+ }
+
+ /**
+ * Get SELECT fields and joins for the comment key
+ *
+ * Each resulting row should be passed to `self::getComment()` to get the
+ * actual comment.
+ *
+ * @return array With three keys:
+ * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
+ * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
+ * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
+ * All tables, fields, and joins are aliased, so `+` is safe to use.
+ */
+ public function getJoin() {
+ if ( $this->joinCache === null ) {
+ $tables = [];
+ $fields = [];
+ $joins = [];
+
+ if ( $this->stage === MIGRATION_OLD ) {
+ $fields["{$this->key}_text"] = $this->key;
+ $fields["{$this->key}_data"] = 'NULL';
+ $fields["{$this->key}_cid"] = 'NULL';
+ } else {
+ $join = $this->stage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN';
+
+ if ( isset( self::$tempTables[$this->key] ) ) {
+ $t = self::$tempTables[$this->key];
+ $alias = "temp_$this->key";
+ $tables[$alias] = $t['table'];
+ $joins[$alias] = [ $join, "{$alias}.{$t['pk']} = {$t['joinPK']}" ];
+ $joinField = "{$alias}.{$t['field']}";
+ } else {
+ $joinField = "{$this->key}_id";
+ }
+
+ $alias = "comment_$this->key";
+ $tables[$alias] = 'comment';
+ $joins[$alias] = [ $join, "{$alias}.comment_id = {$joinField}" ];
+
+ if ( $this->stage === MIGRATION_NEW ) {
+ $fields["{$this->key}_text"] = "{$alias}.comment_text";
+ } else {
+ $fields["{$this->key}_text"] = "COALESCE( {$alias}.comment_text, $this->key )";
+ }
+ $fields["{$this->key}_data"] = "{$alias}.comment_data";
+ $fields["{$this->key}_cid"] = "{$alias}.comment_id";
+ }
+
+ $this->joinCache = [
+ 'tables' => $tables,
+ 'fields' => $fields,
+ 'joins' => $joins,
+ ];
+ }
+
+ return $this->joinCache;
+ }
+
+ /**
+ * Extract the comment from a row
+ *
+ * Shared implementation for getComment() and getCommentLegacy()
+ *
+ * @param IDatabase|null $db Database handle for getCommentLegacy(), or null for getComment()
+ * @param object|array $row
+ * @param bool $fallback
+ * @return CommentStoreComment
+ */
+ private function getCommentInternal( IDatabase $db = null, $row, $fallback = false ) {
+ $key = $this->key;
+ $row = (array)$row;
+ if ( array_key_exists( "{$key}_text", $row ) && array_key_exists( "{$key}_data", $row ) ) {
+ $cid = isset( $row["{$key}_cid"] ) ? $row["{$key}_cid"] : null;
+ $text = $row["{$key}_text"];
+ $data = $row["{$key}_data"];
+ } elseif ( $this->stage === MIGRATION_OLD ) {
+ $cid = null;
+ if ( $fallback && isset( $row[$key] ) ) {
+ wfLogWarning( "Using deprecated fallback handling for comment $key" );
+ $text = $row[$key];
+ } else {
+ wfLogWarning( "Missing {$key}_text and {$key}_data fields in row with MIGRATION_OLD" );
+ $text = '';
+ }
+ $data = null;
+ } else {
+ if ( isset( self::$tempTables[$key] ) ) {
+ if ( array_key_exists( "{$key}_pk", $row ) ) {
+ if ( !$db ) {
+ throw new InvalidArgumentException(
+ "\$row does not contain fields needed for comment $key and getComment(), but "
+ . "does have fields for getCommentLegacy()"
+ );
+ }
+ $t = self::$tempTables[$key];
+ $id = $row["{$key}_pk"];
+ $row2 = $db->selectRow(
+ [ $t['table'], 'comment' ],
+ [ 'comment_id', 'comment_text', 'comment_data' ],
+ [ $t['pk'] => $id ],
+ __METHOD__,
+ [],
+ [ 'comment' => [ 'JOIN', [ "comment_id = {$t['field']}" ] ] ]
+ );
+ } elseif ( $fallback && isset( $row[$key] ) ) {
+ wfLogWarning( "Using deprecated fallback handling for comment $key" );
+ $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ];
+ } else {
+ throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" );
+ }
+ } else {
+ if ( array_key_exists( "{$key}_id", $row ) ) {
+ if ( !$db ) {
+ throw new InvalidArgumentException(
+ "\$row does not contain fields needed for comment $key and getComment(), but "
+ . "does have fields for getCommentLegacy()"
+ );
+ }
+ $id = $row["{$key}_id"];
+ $row2 = $db->selectRow(
+ 'comment',
+ [ 'comment_id', 'comment_text', 'comment_data' ],
+ [ 'comment_id' => $id ],
+ __METHOD__
+ );
+ } elseif ( $fallback && isset( $row[$key] ) ) {
+ wfLogWarning( "Using deprecated fallback handling for comment $key" );
+ $row2 = (object)[ 'comment_text' => $row[$key], 'comment_data' => null ];
+ } else {
+ throw new InvalidArgumentException( "\$row does not contain fields needed for comment $key" );
+ }
+ }
+
+ if ( $row2 ) {
+ $cid = $row2->comment_id;
+ $text = $row2->comment_text;
+ $data = $row2->comment_data;
+ } elseif ( $this->stage < MIGRATION_NEW && array_key_exists( "{$key}_old", $row ) ) {
+ $cid = null;
+ $text = $row["{$key}_old"];
+ $data = null;
+ } else {
+ // @codeCoverageIgnoreStart
+ wfLogWarning( "Missing comment row for $key, id=$id" );
+ $cid = null;
+ $text = '';
+ $data = null;
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ $msg = null;
+ if ( $data !== null ) {
+ $data = FormatJson::decode( $data );
+ if ( !is_object( $data ) ) {
+ // @codeCoverageIgnoreStart
+ wfLogWarning( "Invalid JSON object in comment: $data" );
+ $data = null;
+ // @codeCoverageIgnoreEnd
+ } else {
+ $data = (array)$data;
+ if ( isset( $data['_message'] ) ) {
+ $msg = self::decodeMessage( $data['_message'] )
+ ->setInterfaceMessageFlag( true );
+ }
+ if ( !empty( $data['_null'] ) ) {
+ $data = null;
+ } else {
+ foreach ( $data as $k => $v ) {
+ if ( substr( $k, 0, 1 ) === '_' ) {
+ unset( $data[$k] );
+ }
+ }
+ }
+ }
+ }
+
+ return new CommentStoreComment( $cid, $text, $msg, $data );
+ }
+
+ /**
+ * Extract the comment from a row
+ *
+ * Use `self::getJoin()` to ensure the row contains the needed data.
+ *
+ * If you need to fake a comment in a row for some reason, set fields
+ * `{$key}_text` (string) and `{$key}_data` (JSON string or null).
+ *
+ * @param object|array $row Result row.
+ * @param bool $fallback If true, fall back as well as possible instead of throwing an exception.
+ * @return CommentStoreComment
+ */
+ public function getComment( $row, $fallback = false ) {
+ return $this->getCommentInternal( null, $row, $fallback );
+ }
+
+ /**
+ * Extract the comment from a row, with legacy lookups.
+ *
+ * If `$row` might have been generated using `self::getFields()` rather
+ * than `self::getJoin()`, use this. Prefer `self::getComment()` if you
+ * know callers used `self::getJoin()` for the row fetch.
+ *
+ * If you need to fake a comment in a row for some reason, set fields
+ * `{$key}_text` (string) and `{$key}_data` (JSON string or null).
+ *
+ * @param IDatabase $db Database handle to use for lookup
+ * @param object|array $row Result row.
+ * @param bool $fallback If true, fall back as well as possible instead of throwing an exception.
+ * @return CommentStoreComment
+ */
+ public function getCommentLegacy( IDatabase $db, $row, $fallback = false ) {
+ return $this->getCommentInternal( $db, $row, $fallback );
+ }
+
+ /**
+ * Create a new CommentStoreComment, inserting it into the database if necessary
+ *
+ * If a comment is going to be passed to `self::insert()` or the like
+ * multiple times, it will be more efficient to pass a CommentStoreComment
+ * once rather than making `self::insert()` do it every time through.
+ *
+ * @note When passing a CommentStoreComment, this may set `$comment->id` if
+ * it's not already set. If `$comment->id` is already set, it will not be
+ * verified that the specified comment actually exists or that it
+ * corresponds to the comment text, message, and/or data in the
+ * CommentStoreComment.
+ * @param IDatabase $dbw Database handle to insert on. Unused if `$comment`
+ * is a CommentStoreComment and `$comment->id` is set.
+ * @param string|Message|CommentStoreComment $comment Comment text or Message object, or
+ * a CommentStoreComment.
+ * @param array|null $data Structured data to store. Keys beginning with '_' are reserved.
+ * Ignored if $comment is a CommentStoreComment.
+ * @return CommentStoreComment
+ */
+ public function createComment( IDatabase $dbw, $comment, array $data = null ) {
+ $comment = CommentStoreComment::newUnsavedComment( $comment, $data );
+
+ # Truncate comment in a Unicode-sensitive manner
+ $comment->text = $this->lang->truncate( $comment->text, self::MAX_COMMENT_LENGTH );
+ if ( mb_strlen( $comment->text, 'UTF-8' ) > self::COMMENT_CHARACTER_LIMIT ) {
+ $ellipsis = wfMessage( 'ellipsis' )->inLanguage( $this->lang )->escaped();
+ if ( mb_strlen( $ellipsis ) >= self::COMMENT_CHARACTER_LIMIT ) {
+ // WTF?
+ $ellipsis = '...';
+ }
+ $maxLength = self::COMMENT_CHARACTER_LIMIT - mb_strlen( $ellipsis, 'UTF-8' );
+ $comment->text = mb_substr( $comment->text, 0, $maxLength, 'UTF-8' ) . $ellipsis;
+ }
+
+ if ( $this->stage > MIGRATION_OLD && !$comment->id ) {
+ $dbData = $comment->data;
+ if ( !$comment->message instanceof RawMessage ) {
+ if ( $dbData === null ) {
+ $dbData = [ '_null' => true ];
+ }
+ $dbData['_message'] = self::encodeMessage( $comment->message );
+ }
+ if ( $dbData !== null ) {
+ $dbData = FormatJson::encode( (object)$dbData, false, FormatJson::ALL_OK );
+ $len = strlen( $dbData );
+ if ( $len > self::MAX_DATA_LENGTH ) {
+ $max = self::MAX_DATA_LENGTH;
+ throw new OverflowException( "Comment data is too long ($len bytes, maximum is $max)" );
+ }
+ }
+
+ $hash = self::hash( $comment->text, $dbData );
+ $comment->id = $dbw->selectField(
+ 'comment',
+ 'comment_id',
+ [
+ 'comment_hash' => $hash,
+ 'comment_text' => $comment->text,
+ 'comment_data' => $dbData,
+ ],
+ __METHOD__
+ );
+ if ( !$comment->id ) {
+ $dbw->insert(
+ 'comment',
+ [
+ 'comment_hash' => $hash,
+ 'comment_text' => $comment->text,
+ 'comment_data' => $dbData,
+ ],
+ __METHOD__
+ );
+ $comment->id = $dbw->insertId();
+ }
+ }
+
+ return $comment;
+ }
+
+ /**
+ * Implementation for `self::insert()` and `self::insertWithTempTable()`
+ * @param IDatabase $dbw
+ * @param string|Message|CommentStoreComment $comment
+ * @param array|null $data
+ * @return array [ array $fields, callable $callback ]
+ */
+ private function insertInternal( IDatabase $dbw, $comment, $data ) {
+ $fields = [];
+ $callback = null;
+
+ $comment = $this->createComment( $dbw, $comment, $data );
+
+ if ( $this->stage <= MIGRATION_WRITE_BOTH ) {
+ $fields[$this->key] = $this->lang->truncate( $comment->text, 255 );
+ }
+
+ if ( $this->stage >= MIGRATION_WRITE_BOTH ) {
+ if ( isset( self::$tempTables[$this->key] ) ) {
+ $t = self::$tempTables[$this->key];
+ $func = __METHOD__;
+ $commentId = $comment->id;
+ $callback = function ( $id ) use ( $dbw, $commentId, $t, $func ) {
+ $dbw->insert(
+ $t['table'],
+ [
+ $t['pk'] => $id,
+ $t['field'] => $commentId,
+ ],
+ $func
+ );
+ };
+ } else {
+ $fields["{$this->key}_id"] = $comment->id;
+ }
+ }
+
+ return [ $fields, $callback ];
+ }
+
+ /**
+ * Insert a comment in preparation for a row that references it
+ *
+ * @note It's recommended to include both the call to this method and the
+ * row insert in the same transaction.
+ * @param IDatabase $dbw Database handle to insert on
+ * @param string|Message|CommentStoreComment $comment As for `self::createComment()`
+ * @param array|null $data As for `self::createComment()`
+ * @return array Fields for the insert or update
+ */
+ public function insert( IDatabase $dbw, $comment, $data = null ) {
+ if ( isset( self::$tempTables[$this->key] ) ) {
+ throw new InvalidArgumentException( "Must use insertWithTempTable() for $this->key" );
+ }
+
+ list( $fields ) = $this->insertInternal( $dbw, $comment, $data );
+ return $fields;
+ }
+
+ /**
+ * Insert a comment in a temporary table in preparation for a row that references it
+ *
+ * This is currently needed for "rev_comment" and "img_description". In the
+ * future that requirement will be removed.
+ *
+ * @note It's recommended to include both the call to this method and the
+ * row insert in the same transaction.
+ * @param IDatabase $dbw Database handle to insert on
+ * @param string|Message|CommentStoreComment $comment As for `self::createComment()`
+ * @param array|null $data As for `self::createComment()`
+ * @return array Two values:
+ * - array Fields for the insert or update
+ * - callable Function to call when the primary key of the row being
+ * inserted/updated is known. Pass it that primary key.
+ */
+ public function insertWithTempTable( IDatabase $dbw, $comment, $data = null ) {
+ if ( isset( self::$formerTempTables[$this->key] ) ) {
+ wfDeprecated( __METHOD__ . " for $this->key", self::$formerTempTables[$this->key] );
+ } elseif ( !isset( self::$tempTables[$this->key] ) ) {
+ throw new InvalidArgumentException( "Must use insert() for $this->key" );
+ }
+
+ list( $fields, $callback ) = $this->insertInternal( $dbw, $comment, $data );
+ if ( !$callback ) {
+ $callback = function () {
+ // Do nothing.
+ };
+ }
+ return [ $fields, $callback ];
+ }
+
+ /**
+ * Encode a Message as a PHP data structure
+ * @param Message $msg
+ * @return array
+ */
+ protected static function encodeMessage( Message $msg ) {
+ $key = count( $msg->getKeysToTry() ) > 1 ? $msg->getKeysToTry() : $msg->getKey();
+ $params = $msg->getParams();
+ foreach ( $params as &$param ) {
+ if ( $param instanceof Message ) {
+ $param = [
+ 'message' => self::encodeMessage( $param )
+ ];
+ }
+ }
+ array_unshift( $params, $key );
+ return $params;
+ }
+
+ /**
+ * Decode a message that was encoded by self::encodeMessage()
+ * @param array $data
+ * @return Message
+ */
+ protected static function decodeMessage( $data ) {
+ $key = array_shift( $data );
+ foreach ( $data as &$param ) {
+ if ( is_object( $param ) ) {
+ $param = (array)$param;
+ }
+ if ( is_array( $param ) && count( $param ) === 1 && isset( $param['message'] ) ) {
+ $param = self::decodeMessage( $param['message'] );
+ }
+ }
+ return new Message( $key, $data );
+ }
+
+ /**
+ * Hashing function for comment storage
+ * @param string $text Comment text
+ * @param string|null $data Comment data
+ * @return int 32-bit signed integer
+ */
+ public static function hash( $text, $data ) {
+ $hash = crc32( $text ) ^ crc32( (string)$data );
+
+ // 64-bit PHP returns an unsigned CRC, change it to signed for
+ // insertion into the database.
+ if ( $hash >= 0x80000000 ) {
+ $hash |= -1 << 32;
+ }
+
+ return $hash;
+ }
+
+}
diff --git a/www/wiki/includes/CommentStoreComment.php b/www/wiki/includes/CommentStoreComment.php
new file mode 100644
index 00000000..3920ba08
--- /dev/null
+++ b/www/wiki/includes/CommentStoreComment.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * Value object for CommentStore
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * CommentStoreComment represents a comment stored by CommentStore. The fields
+ * should be considered read-only.
+ * @since 1.30
+ */
+class CommentStoreComment {
+
+ /** @var int|null Comment ID, if any */
+ public $id;
+
+ /** @var string Text version of the comment */
+ public $text;
+
+ /** @var Message Message version of the comment. Might be a RawMessage */
+ public $message;
+
+ /** @var array|null Structured data of the comment */
+ public $data;
+
+ /**
+ * @private For use by CommentStore only. Use self::newUnsavedComment() instead.
+ * @param int|null $id
+ * @param string $text
+ * @param Message|null $message
+ * @param array|null $data
+ */
+ public function __construct( $id, $text, Message $message = null, array $data = null ) {
+ $this->id = $id;
+ $this->text = $text;
+ $this->message = $message ?: new RawMessage( '$1', [ $text ] );
+ $this->data = $data;
+ }
+
+ /**
+ * Create a new, unsaved CommentStoreComment
+ *
+ * @param string|Message|CommentStoreComment $comment Comment text or Message object.
+ * A CommentStoreComment is also accepted here, in which case it is returned unchanged.
+ * @param array|null $data Structured data to store. Keys beginning with '_' are reserved.
+ * Ignored if $comment is a CommentStoreComment.
+ * @return CommentStoreComment
+ */
+ public static function newUnsavedComment( $comment, array $data = null ) {
+ global $wgContLang;
+
+ if ( $comment instanceof CommentStoreComment ) {
+ return $comment;
+ }
+
+ if ( $data !== null ) {
+ foreach ( $data as $k => $v ) {
+ if ( substr( $k, 0, 1 ) === '_' ) {
+ throw new InvalidArgumentException( 'Keys in $data beginning with "_" are reserved' );
+ }
+ }
+ }
+
+ if ( $comment instanceof Message ) {
+ $message = clone $comment;
+ $text = $message->inLanguage( $wgContLang ) // Avoid $wgForceUIMsgAsContentMsg
+ ->setInterfaceMessageFlag( true )
+ ->text();
+ return new CommentStoreComment( null, $text, $message, $data );
+ } else {
+ return new CommentStoreComment( null, $comment, null, $data );
+ }
+ }
+}
diff --git a/www/wiki/includes/ConfiguredReadOnlyMode.php b/www/wiki/includes/ConfiguredReadOnlyMode.php
new file mode 100644
index 00000000..af7c7cbd
--- /dev/null
+++ b/www/wiki/includes/ConfiguredReadOnlyMode.php
@@ -0,0 +1,73 @@
+<?php
+
+/**
+ * A read-only mode service which does not depend on LoadBalancer.
+ * To obtain an instance, use MediaWikiServices::getConfiguredReadOnlyMode().
+ *
+ * @since 1.29
+ */
+class ConfiguredReadOnlyMode {
+ /** @var Config */
+ private $config;
+
+ /** @var string|bool|null */
+ private $fileReason;
+
+ /** @var string|null */
+ private $overrideReason;
+
+ public function __construct( Config $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * Check whether the wiki is in read-only mode.
+ *
+ * @return bool
+ */
+ public function isReadOnly() {
+ return $this->getReason() !== false;
+ }
+
+ /**
+ * Get the value of $wgReadOnly or the contents of $wgReadOnlyFile.
+ *
+ * @return string|bool String when in read-only mode; false otherwise
+ */
+ public function getReason() {
+ if ( $this->overrideReason !== null ) {
+ return $this->overrideReason;
+ }
+ $confReason = $this->config->get( 'ReadOnly' );
+ if ( $confReason !== null ) {
+ return $confReason;
+ }
+ if ( $this->fileReason === null ) {
+ // Cache for faster access next time
+ $readOnlyFile = $this->config->get( 'ReadOnlyFile' );
+ if ( is_file( $readOnlyFile ) && filesize( $readOnlyFile ) > 0 ) {
+ $this->fileReason = file_get_contents( $readOnlyFile );
+ } else {
+ $this->fileReason = false;
+ }
+ }
+ return $this->fileReason;
+ }
+
+ /**
+ * Set the read-only mode, which will apply for the remainder of the
+ * request or until a service reset.
+ *
+ * @param string|null $msg
+ */
+ public function setReason( $msg ) {
+ $this->overrideReason = $msg;
+ }
+
+ /**
+ * Clear the cache of the read only file
+ */
+ public function clearCache() {
+ $this->fileReason = null;
+ }
+}
diff --git a/www/wiki/includes/DefaultSettings.php b/www/wiki/includes/DefaultSettings.php
new file mode 100644
index 00000000..4f29dcf6
--- /dev/null
+++ b/www/wiki/includes/DefaultSettings.php
@@ -0,0 +1,8772 @@
+<?php
+/**
+ * Default values for MediaWiki configuration settings.
+ *
+ *
+ * NEVER EDIT THIS FILE
+ *
+ *
+ * To customize your installation, edit "LocalSettings.php". If you make
+ * changes here, they will be lost on next upgrade of MediaWiki!
+ *
+ * In this file, variables whose default values depend on other
+ * variables are set to false. The actual default value of these variables
+ * will only be set in Setup.php, taking into account any custom settings
+ * performed in LocalSettings.php.
+ *
+ * Documentation is in the source and on:
+ * https://www.mediawiki.org/wiki/Manual:Configuration_settings
+ *
+ * @warning Note: this (and other things) will break if the autoloader is not
+ * enabled. Please include includes/AutoLoader.php before including this file.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @defgroup Globalsettings Global settings
+ */
+
+/**
+ * @cond file_level_code
+ * This is not a valid entry point, perform no further processing unless
+ * MEDIAWIKI is defined
+ */
+if ( !defined( 'MEDIAWIKI' ) ) {
+ echo "This file is part of MediaWiki and is not a valid entry point\n";
+ die( 1 );
+}
+
+/** @endcond */
+
+/**
+ * wgConf hold the site configuration.
+ * Not used for much in a default install.
+ * @since 1.5
+ */
+$wgConf = new SiteConfiguration;
+
+/**
+ * Registry of factory functions to create config objects:
+ * The 'main' key must be set, and the value should be a valid
+ * callable.
+ * @since 1.23
+ */
+$wgConfigRegistry = [
+ 'main' => 'GlobalVarConfig::newInstance'
+];
+
+/**
+ * MediaWiki version number
+ * @since 1.2
+ */
+$wgVersion = '1.30.1';
+
+/**
+ * Name of the site. It must be changed in LocalSettings.php
+ */
+$wgSitename = 'MediaWiki';
+
+/**
+ * When the wiki is running behind a proxy and this is set to true, assumes that the proxy exposes
+ * the wiki on the standard ports (443 for https and 80 for http).
+ * @var bool
+ * @since 1.26
+ */
+$wgAssumeProxiesUseDefaultProtocolPorts = true;
+
+/**
+ * URL of the server.
+ *
+ * @par Example:
+ * @code
+ * $wgServer = 'http://example.com';
+ * @endcode
+ *
+ * This is usually detected correctly by MediaWiki. If MediaWiki detects the
+ * wrong server, it will redirect incorrectly after you save a page. In that
+ * case, set this variable to fix it.
+ *
+ * If you want to use protocol-relative URLs on your wiki, set this to a
+ * protocol-relative URL like '//example.com' and set $wgCanonicalServer
+ * to a fully qualified URL.
+ */
+$wgServer = WebRequest::detectServer();
+
+/**
+ * Canonical URL of the server, to use in IRC feeds and notification e-mails.
+ * Must be fully qualified, even if $wgServer is protocol-relative.
+ *
+ * Defaults to $wgServer, expanded to a fully qualified http:// URL if needed.
+ * @since 1.18
+ */
+$wgCanonicalServer = false;
+
+/**
+ * Server name. This is automatically computed by parsing the bare
+ * hostname out of $wgCanonicalServer. It should not be customized.
+ * @since 1.24
+ */
+$wgServerName = false;
+
+/************************************************************************//**
+ * @name Script path settings
+ * @{
+ */
+
+/**
+ * The path we should point to.
+ * It might be a virtual path in case with use apache mod_rewrite for example.
+ *
+ * This *needs* to be set correctly.
+ *
+ * Other paths will be set to defaults based on it unless they are directly
+ * set in LocalSettings.php
+ */
+$wgScriptPath = '/wiki';
+
+/**
+ * Whether to support URLs like index.php/Page_title These often break when PHP
+ * is set up in CGI mode. PATH_INFO *may* be correct if cgi.fix_pathinfo is set,
+ * but then again it may not; lighttpd converts incoming path data to lowercase
+ * on systems with case-insensitive filesystems, and there have been reports of
+ * problems on Apache as well.
+ *
+ * To be safe we'll continue to keep it off by default.
+ *
+ * Override this to false if $_SERVER['PATH_INFO'] contains unexpectedly
+ * incorrect garbage, or to true if it is really correct.
+ *
+ * The default $wgArticlePath will be set based on this value at runtime, but if
+ * you have customized it, having this incorrectly set to true can cause
+ * redirect loops when "pretty URLs" are used.
+ * @since 1.2.1
+ */
+$wgUsePathInfo = ( strpos( PHP_SAPI, 'cgi' ) === false ) &&
+ ( strpos( PHP_SAPI, 'apache2filter' ) === false ) &&
+ ( strpos( PHP_SAPI, 'isapi' ) === false );
+
+/**
+ * The extension to append to script names by default.
+ *
+ * Some hosting providers used PHP 4 for *.php files, and PHP 5 for *.php5.
+ * This variable was provided to support those providers.
+ *
+ * @since 1.11
+ * @deprecated since 1.25; support for '.php5' has been phased out of MediaWiki
+ * proper. Backward-compatibility can be maintained by configuring your web
+ * server to rewrite URLs. See RELEASE-NOTES for details.
+ */
+$wgScriptExtension = '.php';
+
+/**@}*/
+
+/************************************************************************//**
+ * @name URLs and file paths
+ *
+ * These various web and file path variables are set to their defaults
+ * in Setup.php if they are not explicitly set from LocalSettings.php.
+ *
+ * These will relatively rarely need to be set manually, unless you are
+ * splitting style sheets or images outside the main document root.
+ *
+ * In this section, a "path" is usually a host-relative URL, i.e. a URL without
+ * the host part, that starts with a slash. In most cases a full URL is also
+ * acceptable. A "directory" is a local file path.
+ *
+ * In both paths and directories, trailing slashes should not be included.
+ *
+ * @{
+ */
+
+/**
+ * The URL path to index.php.
+ *
+ * Defaults to "{$wgScriptPath}/index.php".
+ */
+$wgScript = false;
+
+/**
+ * The URL path to load.php.
+ *
+ * Defaults to "{$wgScriptPath}/load.php".
+ * @since 1.17
+ */
+$wgLoadScript = false;
+
+/**
+ * The URL path of the skins directory.
+ * Defaults to "{$wgResourceBasePath}/skins".
+ * @since 1.3
+ */
+$wgStylePath = false;
+$wgStyleSheetPath = &$wgStylePath;
+
+/**
+ * The URL path of the skins directory. Should not point to an external domain.
+ * Defaults to "{$wgScriptPath}/skins".
+ * @since 1.17
+ */
+$wgLocalStylePath = false;
+
+/**
+ * The URL path of the extensions directory.
+ * Defaults to "{$wgResourceBasePath}/extensions".
+ * @since 1.16
+ */
+$wgExtensionAssetsPath = false;
+
+/**
+ * Filesystem extensions directory.
+ * Defaults to "{$IP}/extensions".
+ * @since 1.25
+ */
+$wgExtensionDirectory = "{$IP}/extensions";
+
+/**
+ * Filesystem stylesheets directory.
+ * Defaults to "{$IP}/skins".
+ * @since 1.3
+ */
+$wgStyleDirectory = "{$IP}/skins";
+
+/**
+ * The URL path for primary article page views. This path should contain $1,
+ * which is replaced by the article title.
+ *
+ * Defaults to "{$wgScript}/$1" or "{$wgScript}?title=$1",
+ * depending on $wgUsePathInfo.
+ */
+$wgArticlePath = false;
+
+/**
+ * The URL path for the images directory.
+ * Defaults to "{$wgScriptPath}/images".
+ */
+$wgUploadPath = false;
+
+/**
+ * The filesystem path of the images directory. Defaults to "{$IP}/images".
+ */
+$wgUploadDirectory = false;
+
+/**
+ * Directory where the cached page will be saved.
+ * Defaults to "{$wgUploadDirectory}/cache".
+ */
+$wgFileCacheDirectory = false;
+
+/**
+ * The URL path of the wiki logo. The logo size should be 135x135 pixels.
+ * Defaults to "$wgResourceBasePath/resources/assets/wiki.png".
+ */
+$wgLogo = false;
+
+/**
+ * Array with URL paths to HD versions of the wiki logo. The scaled logo size
+ * should be under 135x155 pixels.
+ * Only 1.5x and 2x versions are supported.
+ *
+ * @par Example:
+ * @code
+ * $wgLogoHD = [
+ * "1.5x" => "path/to/1.5x_version.png",
+ * "2x" => "path/to/2x_version.png"
+ * ];
+ * @endcode
+ *
+ * @since 1.25
+ */
+$wgLogoHD = false;
+
+/**
+ * The URL path of the shortcut icon.
+ * @since 1.6
+ */
+$wgFavicon = '/favicon.ico';
+
+/**
+ * The URL path of the icon for iPhone and iPod Touch web app bookmarks.
+ * Defaults to no icon.
+ * @since 1.12
+ */
+$wgAppleTouchIcon = false;
+
+/**
+ * Value for the referrer policy meta tag.
+ * One of 'never', 'default', 'origin', 'always'. Setting it to false just
+ * prevents the meta tag from being output.
+ * See https://www.w3.org/TR/referrer-policy/ for details.
+ *
+ * @since 1.25
+ */
+$wgReferrerPolicy = false;
+
+/**
+ * The local filesystem path to a temporary directory. This is not required to
+ * be web accessible.
+ *
+ * When this setting is set to false, its value will be set through a call
+ * to wfTempDir(). See that methods implementation for the actual detection
+ * logic.
+ *
+ * Developers should use the global function wfTempDir() instead of this
+ * variable.
+ *
+ * @see wfTempDir()
+ * @note Default changed to false in MediaWiki 1.20.
+ */
+$wgTmpDirectory = false;
+
+/**
+ * If set, this URL is added to the start of $wgUploadPath to form a complete
+ * upload URL.
+ * @since 1.4
+ */
+$wgUploadBaseUrl = '';
+
+/**
+ * To enable remote on-demand scaling, set this to the thumbnail base URL.
+ * Full thumbnail URL will be like $wgUploadStashScalerBaseUrl/e/e6/Foo.jpg/123px-Foo.jpg
+ * where 'e6' are the first two characters of the MD5 hash of the file name.
+ * If $wgUploadStashScalerBaseUrl is set to false, thumbs are rendered locally as needed.
+ * @since 1.17
+ */
+$wgUploadStashScalerBaseUrl = false;
+
+/**
+ * To set 'pretty' URL paths for actions other than
+ * plain page views, add to this array.
+ *
+ * @par Example:
+ * Set pretty URL for the edit action:
+ * @code
+ * 'edit' => "$wgScriptPath/edit/$1"
+ * @endcode
+ *
+ * There must be an appropriate script or rewrite rule in place to handle these
+ * URLs.
+ * @since 1.5
+ */
+$wgActionPaths = [];
+
+/**@}*/
+
+/************************************************************************//**
+ * @name Files and file uploads
+ * @{
+ */
+
+/**
+ * Uploads have to be specially set up to be secure
+ */
+$wgEnableUploads = false;
+
+/**
+ * The maximum age of temporary (incomplete) uploaded files
+ */
+$wgUploadStashMaxAge = 6 * 3600; // 6 hours
+
+/**
+ * Allows to move images and other media files
+ */
+$wgAllowImageMoving = true;
+
+/**
+ * Enable deferred upload tasks that use the job queue.
+ * Only enable this if job runners are set up for both the
+ * 'AssembleUploadChunks' and 'PublishStashedFile' job types.
+ *
+ * @note If you use suhosin, this setting is incompatible with
+ * suhosin.session.encrypt.
+ */
+$wgEnableAsyncUploads = false;
+
+/**
+ * Additional characters that are not allowed in filenames. They are replaced with '-' when
+ * uploading. Like $wgLegalTitleChars, this is a regexp character class.
+ *
+ * Slashes and backslashes are disallowed regardless of this setting, but included here for
+ * completeness.
+ */
+$wgIllegalFileChars = ":\\/\\\\";
+
+/**
+ * What directory to place deleted uploads in.
+ * Defaults to "{$wgUploadDirectory}/deleted".
+ */
+$wgDeletedDirectory = false;
+
+/**
+ * Set this to true if you use img_auth and want the user to see details on why access failed.
+ */
+$wgImgAuthDetails = false;
+
+/**
+ * Map of relative URL directories to match to internal mwstore:// base storage paths.
+ * For img_auth.php requests, everything after "img_auth.php/" is checked to see
+ * if starts with any of the prefixes defined here. The prefixes should not overlap.
+ * The prefix that matches has a corresponding storage path, which the rest of the URL
+ * is assumed to be relative to. The file at that path (or a 404) is send to the client.
+ *
+ * Example:
+ * $wgImgAuthUrlPathMap['/timeline/'] = 'mwstore://local-fs/timeline-render/';
+ * The above maps ".../img_auth.php/timeline/X" to "mwstore://local-fs/timeline-render/".
+ * The name "local-fs" should correspond by name to an entry in $wgFileBackends.
+ *
+ * @see $wgFileBackends
+ */
+$wgImgAuthUrlPathMap = [];
+
+/**
+ * File repository structures
+ *
+ * $wgLocalFileRepo is a single repository structure, and $wgForeignFileRepos is
+ * an array of such structures. Each repository structure is an associative
+ * array of properties configuring the repository.
+ *
+ * Properties required for all repos:
+ * - class The class name for the repository. May come from the core or an extension.
+ * The core repository classes are FileRepo, LocalRepo, ForeignDBRepo.
+ *
+ * - name A unique name for the repository (but $wgLocalFileRepo should be 'local').
+ * The name should consist of alpha-numeric characters.
+ * - backend A file backend name (see $wgFileBackends).
+ *
+ * For most core repos:
+ * - zones Associative array of zone names that each map to an array with:
+ * container : backend container name the zone is in
+ * directory : root path within container for the zone
+ * url : base URL to the root of the zone
+ * urlsByExt : map of file extension types to base URLs
+ * (useful for using a different cache for videos)
+ * Zones default to using "<repo name>-<zone name>" as the container name
+ * and default to using the container root as the zone's root directory.
+ * Nesting of zone locations within other zones should be avoided.
+ * - url Public zone URL. The 'zones' settings take precedence.
+ * - hashLevels The number of directory levels for hash-based division of files
+ * - thumbScriptUrl The URL for thumb.php (optional, not recommended)
+ * - transformVia404 Whether to skip media file transformation on parse and rely on a 404
+ * handler instead.
+ * - initialCapital Equivalent to $wgCapitalLinks (or $wgCapitalLinkOverrides[NS_FILE],
+ * determines whether filenames implicitly start with a capital letter.
+ * The current implementation may give incorrect description page links
+ * when the local $wgCapitalLinks and initialCapital are mismatched.
+ * - pathDisclosureProtection
+ * May be 'paranoid' to remove all parameters from error messages, 'none' to
+ * leave the paths in unchanged, or 'simple' to replace paths with
+ * placeholders. Default for LocalRepo is 'simple'.
+ * - fileMode This allows wikis to set the file mode when uploading/moving files. Default
+ * is 0644.
+ * - directory The local filesystem directory where public files are stored. Not used for
+ * some remote repos.
+ * - thumbDir The base thumbnail directory. Defaults to "<directory>/thumb".
+ * - thumbUrl The base thumbnail URL. Defaults to "<url>/thumb".
+ * - isPrivate Set this if measures should always be taken to keep the files private.
+ * One should not trust this to assure that the files are not web readable;
+ * the server configuration should be done manually depending on the backend.
+ *
+ * These settings describe a foreign MediaWiki installation. They are optional, and will be ignored
+ * for local repositories:
+ * - descBaseUrl URL of image description pages, e.g. https://en.wikipedia.org/wiki/File:
+ * - scriptDirUrl URL of the MediaWiki installation, equivalent to $wgScriptPath, e.g.
+ * https://en.wikipedia.org/w
+ * - scriptExtension Script extension of the MediaWiki installation, equivalent to
+ * $wgScriptExtension, e.g. ".php5". Defaults to ".php".
+ *
+ * - articleUrl Equivalent to $wgArticlePath, e.g. https://en.wikipedia.org/wiki/$1
+ * - fetchDescription Fetch the text of the remote file description page. Equivalent to
+ * $wgFetchCommonsDescriptions.
+ * - abbrvThreshold File names over this size will use the short form of thumbnail names.
+ * Short thumbnail names only have the width, parameters, and the extension.
+ *
+ * ForeignDBRepo:
+ * - dbType, dbServer, dbUser, dbPassword, dbName, dbFlags
+ * equivalent to the corresponding member of $wgDBservers
+ * - tablePrefix Table prefix, the foreign wiki's $wgDBprefix
+ * - hasSharedCache True if the wiki's shared cache is accessible via the local $wgMemc
+ *
+ * ForeignAPIRepo:
+ * - apibase Use for the foreign API's URL
+ * - apiThumbCacheExpiry How long to locally cache thumbs for
+ *
+ * If you leave $wgLocalFileRepo set to false, Setup will fill in appropriate values.
+ * Otherwise, set $wgLocalFileRepo to a repository structure as described above.
+ * If you set $wgUseInstantCommons to true, it will add an entry for Commons.
+ * If you set $wgForeignFileRepos to an array of repository structures, those will
+ * be searched after the local file repo.
+ * Otherwise, you will only have access to local media files.
+ *
+ * @see Setup.php for an example usage and default initialization.
+ */
+$wgLocalFileRepo = false;
+
+/**
+ * @see $wgLocalFileRepo
+ */
+$wgForeignFileRepos = [];
+
+/**
+ * Use Commons as a remote file repository. Essentially a wrapper, when this
+ * is enabled $wgForeignFileRepos will point at Commons with a set of default
+ * settings
+ */
+$wgUseInstantCommons = false;
+
+/**
+ * Array of foreign file repo names (set in $wgForeignFileRepos above) that
+ * are allowable upload targets. These wikis must have some method of
+ * authentication (i.e. CentralAuth), and be CORS-enabled for this wiki.
+ * The string 'local' signifies the default local file repository.
+ *
+ * Example:
+ * $wgForeignUploadTargets = [ 'shared' ];
+ */
+$wgForeignUploadTargets = [ 'local' ];
+
+/**
+ * Configuration for file uploads using the embeddable upload dialog
+ * (https://www.mediawiki.org/wiki/Upload_dialog).
+ *
+ * This applies also to foreign uploads to this wiki (the configuration is loaded by remote wikis
+ * using the action=query&meta=siteinfo API).
+ *
+ * See below for documentation of each property. None of the properties may be omitted.
+ */
+$wgUploadDialog = [
+ // Fields to make available in the dialog. `true` means that this field is visible, `false` means
+ // that it is hidden. The "Name" field can't be hidden. Note that you also have to add the
+ // matching replacement to the 'filepage' format key below to make use of these.
+ 'fields' => [
+ 'description' => true,
+ 'date' => false,
+ 'categories' => false,
+ ],
+ // Suffix of localisation messages used to describe the license under which the uploaded file will
+ // be released. The same value may be set for both 'local' and 'foreign' uploads.
+ 'licensemessages' => [
+ // The 'local' messages are used for local uploads on this wiki:
+ // * upload-form-label-own-work-message-generic-local
+ // * upload-form-label-not-own-work-message-generic-local
+ // * upload-form-label-not-own-work-local-generic-local
+ 'local' => 'generic-local',
+ // The 'foreign' messages are used for cross-wiki uploads from other wikis to this wiki:
+ // * upload-form-label-own-work-message-generic-foreign
+ // * upload-form-label-not-own-work-message-generic-foreign
+ // * upload-form-label-not-own-work-local-generic-foreign
+ 'foreign' => 'generic-foreign',
+ ],
+ // Upload comments to use for 'local' and 'foreign' uploads. This can also be set to a single
+ // string value, in which case it is used for both kinds of uploads. Available replacements:
+ // * $HOST - domain name from which a cross-wiki upload originates
+ // * $PAGENAME - wiki page name from which an upload originates
+ 'comment' => [
+ 'local' => '',
+ 'foreign' => '',
+ ],
+ // Format of the file page wikitext to be generated from the fields input by the user.
+ 'format' => [
+ // Wrapper for the whole page. Available replacements:
+ // * $DESCRIPTION - file description, as input by the user (only if the 'description' field is
+ // enabled), wrapped as defined below in the 'description' key
+ // * $DATE - file creation date, as input by the user (only if the 'date' field is enabled)
+ // * $SOURCE - as defined below in the 'ownwork' key, may be extended in the future
+ // * $AUTHOR - linked user name, may be extended in the future
+ // * $LICENSE - as defined below in the 'license' key, may be extended in the future
+ // * $CATEGORIES - file categories wikitext, as input by the user (only if the 'categories'
+ // field is enabled), or if no input, as defined below in the 'uncategorized' key
+ 'filepage' => '$DESCRIPTION',
+ // Wrapped for file description. Available replacements:
+ // * $LANGUAGE - source wiki's content language
+ // * $TEXT - input by the user
+ 'description' => '$TEXT',
+ 'ownwork' => '',
+ 'license' => '',
+ 'uncategorized' => '',
+ ],
+];
+
+/**
+ * File backend structure configuration.
+ *
+ * This is an array of file backend configuration arrays.
+ * Each backend configuration has the following parameters:
+ * - 'name' : A unique name for the backend
+ * - 'class' : The file backend class to use
+ * - 'wikiId' : A unique string that identifies the wiki (container prefix)
+ * - 'lockManager' : The name of a lock manager (see $wgLockManagers)
+ *
+ * See FileBackend::__construct() for more details.
+ * Additional parameters are specific to the file backend class used.
+ * These settings should be global to all wikis when possible.
+ *
+ * FileBackendMultiWrite::__construct() is augmented with a 'template' option that
+ * can be used in any of the values of the 'backends' array. Its value is the name of
+ * another backend in $wgFileBackends. When set, it pre-fills the array with all of the
+ * configuration of the named backend. Explicitly set values in the array take precedence.
+ *
+ * There are two particularly important aspects about each backend:
+ * - a) Whether it is fully qualified or wiki-relative.
+ * By default, the paths of files are relative to the current wiki,
+ * which works via prefixing them with the current wiki ID when accessed.
+ * Setting 'wikiId' forces the backend to be fully qualified by prefixing
+ * all paths with the specified value instead. This can be useful if
+ * multiple wikis need to share the same data. Note that 'name' is *not*
+ * part of any prefix and thus should not be relied upon for namespacing.
+ * - b) Whether it is only defined for some wikis or is defined on all
+ * wikis in the wiki farm. Defining a backend globally is useful
+ * if multiple wikis need to share the same data.
+ * One should be aware of these aspects when configuring a backend for use with
+ * any basic feature or plugin. For example, suppose an extension stores data for
+ * different wikis in different directories and sometimes needs to access data from
+ * a foreign wiki's directory in order to render a page on given wiki. The extension
+ * would need a fully qualified backend that is defined on all wikis in the wiki farm.
+ */
+$wgFileBackends = [];
+
+/**
+ * Array of configuration arrays for each lock manager.
+ * Each backend configuration has the following parameters:
+ * - 'name' : A unique name for the lock manager
+ * - 'class' : The lock manger class to use
+ *
+ * See LockManager::__construct() for more details.
+ * Additional parameters are specific to the lock manager class used.
+ * These settings should be global to all wikis.
+ *
+ * When using DBLockManager, the 'dbsByBucket' map can reference 'localDBMaster' as
+ * a peer database in each bucket. This will result in an extra connection to the domain
+ * that the LockManager services, which must also be a valid wiki ID.
+ */
+$wgLockManagers = [];
+
+/**
+ * Show Exif data, on by default if available.
+ * Requires PHP's Exif extension: https://secure.php.net/manual/en/ref.exif.php
+ *
+ * @note FOR WINDOWS USERS:
+ * To enable Exif functions, add the following line to the "Windows
+ * extensions" section of php.ini:
+ * @code{.ini}
+ * extension=extensions/php_exif.dll
+ * @endcode
+ */
+$wgShowEXIF = function_exists( 'exif_read_data' );
+
+/**
+ * If to automatically update the img_metadata field
+ * if the metadata field is outdated but compatible with the current version.
+ * Defaults to false.
+ */
+$wgUpdateCompatibleMetadata = false;
+
+/**
+ * If you operate multiple wikis, you can define a shared upload path here.
+ * Uploads to this wiki will NOT be put there - they will be put into
+ * $wgUploadDirectory.
+ * If $wgUseSharedUploads is set, the wiki will look in the shared repository if
+ * no file of the given name is found in the local repository (for [[File:..]],
+ * [[Media:..]] links). Thumbnails will also be looked for and generated in this
+ * directory.
+ *
+ * Note that these configuration settings can now be defined on a per-
+ * repository basis for an arbitrary number of file repositories, using the
+ * $wgForeignFileRepos variable.
+ */
+$wgUseSharedUploads = false;
+
+/**
+ * Full path on the web server where shared uploads can be found
+ */
+$wgSharedUploadPath = null;
+
+/**
+ * Fetch commons image description pages and display them on the local wiki?
+ */
+$wgFetchCommonsDescriptions = false;
+
+/**
+ * Path on the file system where shared uploads can be found.
+ */
+$wgSharedUploadDirectory = null;
+
+/**
+ * DB name with metadata about shared directory.
+ * Set this to false if the uploads do not come from a wiki.
+ */
+$wgSharedUploadDBname = false;
+
+/**
+ * Optional table prefix used in database.
+ */
+$wgSharedUploadDBprefix = '';
+
+/**
+ * Cache shared metadata in memcached.
+ * Don't do this if the commons wiki is in a different memcached domain
+ */
+$wgCacheSharedUploads = true;
+
+/**
+ * Allow for upload to be copied from an URL.
+ * The timeout for copy uploads is set by $wgCopyUploadTimeout.
+ * You have to assign the user right 'upload_by_url' to a user group, to use this.
+ */
+$wgAllowCopyUploads = false;
+
+/**
+ * A list of domains copy uploads can come from
+ *
+ * @since 1.20
+ */
+$wgCopyUploadsDomains = [];
+
+/**
+ * Enable copy uploads from Special:Upload. $wgAllowCopyUploads must also be
+ * true. If $wgAllowCopyUploads is true, but this is false, you will only be
+ * able to perform copy uploads from the API or extensions (e.g. UploadWizard).
+ */
+$wgCopyUploadsFromSpecialUpload = false;
+
+/**
+ * Proxy to use for copy upload requests.
+ * @since 1.20
+ */
+$wgCopyUploadProxy = false;
+
+/**
+ * Different timeout for upload by url
+ * This could be useful since when fetching large files, you may want a
+ * timeout longer than the default $wgHTTPTimeout. False means fallback
+ * to default.
+ *
+ * @var int|bool
+ *
+ * @since 1.22
+ */
+$wgCopyUploadTimeout = false;
+
+/**
+ * Max size for uploads, in bytes. If not set to an array, applies to all
+ * uploads. If set to an array, per upload type maximums can be set, using the
+ * file and url keys. If the * key is set this value will be used as maximum
+ * for non-specified types.
+ *
+ * @par Example:
+ * @code
+ * $wgMaxUploadSize = [
+ * '*' => 250 * 1024,
+ * 'url' => 500 * 1024,
+ * ];
+ * @endcode
+ * Sets the maximum for all uploads to 250 kB except for upload-by-url, which
+ * will have a maximum of 500 kB.
+ */
+$wgMaxUploadSize = 1024 * 1024 * 100; # 100MB
+
+/**
+ * Minimum upload chunk size, in bytes. When using chunked upload, non-final
+ * chunks smaller than this will be rejected. May be reduced based on the
+ * 'upload_max_filesize' or 'post_max_size' PHP settings.
+ * @since 1.26
+ */
+$wgMinUploadChunkSize = 1024; # 1KB
+
+/**
+ * Point the upload navigation link to an external URL
+ * Useful if you want to use a shared repository by default
+ * without disabling local uploads (use $wgEnableUploads = false for that).
+ *
+ * @par Example:
+ * @code
+ * $wgUploadNavigationUrl = 'https://commons.wikimedia.org/wiki/Special:Upload';
+ * @endcode
+ */
+$wgUploadNavigationUrl = false;
+
+/**
+ * Point the upload link for missing files to an external URL, as with
+ * $wgUploadNavigationUrl. The URL will get "(?|&)wpDestFile=<filename>"
+ * appended to it as appropriate.
+ */
+$wgUploadMissingFileUrl = false;
+
+/**
+ * Give a path here to use thumb.php for thumbnail generation on client
+ * request, instead of generating them on render and outputting a static URL.
+ * This is necessary if some of your apache servers don't have read/write
+ * access to the thumbnail path.
+ *
+ * @par Example:
+ * @code
+ * $wgThumbnailScriptPath = "{$wgScriptPath}/thumb.php";
+ * @endcode
+ */
+$wgThumbnailScriptPath = false;
+
+/**
+ * @see $wgThumbnailScriptPath
+ */
+$wgSharedThumbnailScriptPath = false;
+
+/**
+ * Set this to false if you do not want MediaWiki to divide your images
+ * directory into many subdirectories, for improved performance.
+ *
+ * It's almost always good to leave this enabled. In previous versions of
+ * MediaWiki, some users set this to false to allow images to be added to the
+ * wiki by simply copying them into $wgUploadDirectory and then running
+ * maintenance/rebuildImages.php to register them in the database. This is no
+ * longer recommended, use maintenance/importImages.php instead.
+ *
+ * @note That this variable may be ignored if $wgLocalFileRepo is set.
+ * @todo Deprecate the setting and ultimately remove it from Core.
+ */
+$wgHashedUploadDirectory = true;
+
+/**
+ * Set the following to false especially if you have a set of files that need to
+ * be accessible by all wikis, and you do not want to use the hash (path/a/aa/)
+ * directory layout.
+ */
+$wgHashedSharedUploadDirectory = true;
+
+/**
+ * Base URL for a repository wiki. Leave this blank if uploads are just stored
+ * in a shared directory and not meant to be accessible through a separate wiki.
+ * Otherwise the image description pages on the local wiki will link to the
+ * image description page on this wiki.
+ *
+ * Please specify the namespace, as in the example below.
+ */
+$wgRepositoryBaseUrl = "https://commons.wikimedia.org/wiki/File:";
+
+/**
+ * This is the list of preferred extensions for uploading files. Uploading files
+ * with extensions not in this list will trigger a warning.
+ *
+ * @warning If you add any OpenOffice or Microsoft Office file formats here,
+ * such as odt or doc, and untrusted users are allowed to upload files, then
+ * your wiki will be vulnerable to cross-site request forgery (CSRF).
+ */
+$wgFileExtensions = [ 'png', 'gif', 'jpg', 'jpeg', 'webp' ];
+
+/**
+ * Files with these extensions will never be allowed as uploads.
+ * An array of file extensions to blacklist. You should append to this array
+ * if you want to blacklist additional files.
+ */
+$wgFileBlacklist = [
+ # HTML may contain cookie-stealing JavaScript and web bugs
+ 'html', 'htm', 'js', 'jsb', 'mhtml', 'mht', 'xhtml', 'xht',
+ # PHP scripts may execute arbitrary code on the server
+ 'php', 'phtml', 'php3', 'php4', 'php5', 'phps',
+ # Other types that may be interpreted by some servers
+ 'shtml', 'jhtml', 'pl', 'py', 'cgi',
+ # May contain harmful executables for Windows victims
+ 'exe', 'scr', 'dll', 'msi', 'vbs', 'bat', 'com', 'pif', 'cmd', 'vxd', 'cpl' ];
+
+/**
+ * Files with these MIME types will never be allowed as uploads
+ * if $wgVerifyMimeType is enabled.
+ */
+$wgMimeTypeBlacklist = [
+ # HTML may contain cookie-stealing JavaScript and web bugs
+ 'text/html', 'text/javascript', 'text/x-javascript', 'application/x-shellscript',
+ # PHP scripts may execute arbitrary code on the server
+ 'application/x-php', 'text/x-php',
+ # Other types that may be interpreted by some servers
+ 'text/x-python', 'text/x-perl', 'text/x-bash', 'text/x-sh', 'text/x-csh',
+ # Client-side hazards on Internet Explorer
+ 'text/scriptlet', 'application/x-msdownload',
+ # Windows metafile, client-side vulnerability on some systems
+ 'application/x-msmetafile',
+];
+
+/**
+ * Allow Java archive uploads.
+ * This is not recommended for public wikis since a maliciously-constructed
+ * applet running on the same domain as the wiki can steal the user's cookies.
+ */
+$wgAllowJavaUploads = false;
+
+/**
+ * This is a flag to determine whether or not to check file extensions on upload.
+ *
+ * @warning Setting this to false is insecure for public wikis.
+ */
+$wgCheckFileExtensions = true;
+
+/**
+ * If this is turned off, users may override the warning for files not covered
+ * by $wgFileExtensions.
+ *
+ * @warning Setting this to false is insecure for public wikis.
+ */
+$wgStrictFileExtensions = true;
+
+/**
+ * Setting this to true will disable the upload system's checks for HTML/JavaScript.
+ *
+ * @warning THIS IS VERY DANGEROUS on a publicly editable site, so USE
+ * $wgGroupPermissions TO RESTRICT UPLOADING to only those that you trust
+ */
+$wgDisableUploadScriptChecks = false;
+
+/**
+ * Warn if uploaded files are larger than this (in bytes), or false to disable
+ */
+$wgUploadSizeWarning = false;
+
+/**
+ * list of trusted media-types and MIME types.
+ * Use the MEDIATYPE_xxx constants to represent media types.
+ * This list is used by File::isSafeFile
+ *
+ * Types not listed here will have a warning about unsafe content
+ * displayed on the images description page. It would also be possible
+ * to use this for further restrictions, like disabling direct
+ * [[media:...]] links for non-trusted formats.
+ */
+$wgTrustedMediaFormats = [
+ MEDIATYPE_BITMAP, // all bitmap formats
+ MEDIATYPE_AUDIO, // all audio formats
+ MEDIATYPE_VIDEO, // all plain video formats
+ "image/svg+xml", // svg (only needed if inline rendering of svg is not supported)
+ "application/pdf", // PDF files
+ # "application/x-shockwave-flash", //flash/shockwave movie
+];
+
+/**
+ * Plugins for media file type handling.
+ * Each entry in the array maps a MIME type to a class name
+ *
+ * Core media handlers are listed in MediaHandlerFactory,
+ * and extensions should use extension.json.
+ */
+$wgMediaHandlers = [];
+
+/**
+ * Media handler overrides for parser tests (they don't need to generate actual
+ * thumbnails, so a mock will do)
+ */
+$wgParserTestMediaHandlers = [
+ 'image/jpeg' => 'MockBitmapHandler',
+ 'image/png' => 'MockBitmapHandler',
+ 'image/gif' => 'MockBitmapHandler',
+ 'image/tiff' => 'MockBitmapHandler',
+ 'image/webp' => 'MockBitmapHandler',
+ 'image/x-ms-bmp' => 'MockBitmapHandler',
+ 'image/x-bmp' => 'MockBitmapHandler',
+ 'image/x-xcf' => 'MockBitmapHandler',
+ 'image/svg+xml' => 'MockSvgHandler',
+ 'image/vnd.djvu' => 'MockDjVuHandler',
+];
+
+/**
+ * Plugins for page content model handling.
+ * Each entry in the array maps a model id to a class name or callback
+ * that creates an instance of the appropriate ContentHandler subclass.
+ *
+ * @since 1.21
+ */
+$wgContentHandlers = [
+ // the usual case
+ CONTENT_MODEL_WIKITEXT => 'WikitextContentHandler',
+ // dumb version, no syntax highlighting
+ CONTENT_MODEL_JAVASCRIPT => 'JavaScriptContentHandler',
+ // simple implementation, for use by extensions, etc.
+ CONTENT_MODEL_JSON => 'JsonContentHandler',
+ // dumb version, no syntax highlighting
+ CONTENT_MODEL_CSS => 'CssContentHandler',
+ // plain text, for use by extensions, etc.
+ CONTENT_MODEL_TEXT => 'TextContentHandler',
+];
+
+/**
+ * Whether to enable server-side image thumbnailing. If false, images will
+ * always be sent to the client in full resolution, with appropriate width= and
+ * height= attributes on the <img> tag for the client to do its own scaling.
+ */
+$wgUseImageResize = true;
+
+/**
+ * Resizing can be done using PHP's internal image libraries or using
+ * ImageMagick or another third-party converter, e.g. GraphicMagick.
+ * These support more file formats than PHP, which only supports PNG,
+ * GIF, JPG, XBM and WBMP.
+ *
+ * Use Image Magick instead of PHP builtin functions.
+ */
+$wgUseImageMagick = false;
+
+/**
+ * The convert command shipped with ImageMagick
+ */
+$wgImageMagickConvertCommand = '/usr/bin/convert';
+
+/**
+ * Array of max pixel areas for interlacing per MIME type
+ * @since 1.27
+ */
+$wgMaxInterlacingAreas = [];
+
+/**
+ * Sharpening parameter to ImageMagick
+ */
+$wgSharpenParameter = '0x0.4';
+
+/**
+ * Reduction in linear dimensions below which sharpening will be enabled
+ */
+$wgSharpenReductionThreshold = 0.85;
+
+/**
+ * Temporary directory used for ImageMagick. The directory must exist. Leave
+ * this set to false to let ImageMagick decide for itself.
+ */
+$wgImageMagickTempDir = false;
+
+/**
+ * Use another resizing converter, e.g. GraphicMagick
+ * %s will be replaced with the source path, %d with the destination
+ * %w and %h will be replaced with the width and height.
+ *
+ * @par Example for GraphicMagick:
+ * @code
+ * $wgCustomConvertCommand = "gm convert %s -resize %wx%h %d"
+ * @endcode
+ *
+ * Leave as false to skip this.
+ */
+$wgCustomConvertCommand = false;
+
+/**
+ * used for lossless jpeg rotation
+ *
+ * @since 1.21
+ */
+$wgJpegTran = '/usr/bin/jpegtran';
+
+/**
+ * At default setting of 'yuv420', JPEG thumbnails will use 4:2:0 chroma
+ * subsampling to reduce file size, at the cost of possible color fringing
+ * at sharp edges.
+ *
+ * See https://en.wikipedia.org/wiki/Chroma_subsampling
+ *
+ * Supported values:
+ * false - use scaling system's default (same as pre-1.27 behavior)
+ * 'yuv444' - luma and chroma at same resolution
+ * 'yuv422' - chroma at 1/2 resolution horizontally, full vertically
+ * 'yuv420' - chroma at 1/2 resolution in both dimensions
+ *
+ * This setting is currently supported only for the ImageMagick backend;
+ * others may default to 4:2:0 or 4:4:4 or maintaining the source file's
+ * sampling in the thumbnail.
+ *
+ * @since 1.27
+ */
+$wgJpegPixelFormat = 'yuv420';
+
+/**
+ * Some tests and extensions use exiv2 to manipulate the Exif metadata in some
+ * image formats.
+ */
+$wgExiv2Command = '/usr/bin/exiv2';
+
+/**
+ * Path to exiftool binary. Used for lossless ICC profile swapping.
+ *
+ * @since 1.26
+ */
+$wgExiftool = '/usr/bin/exiftool';
+
+/**
+ * Scalable Vector Graphics (SVG) may be uploaded as images.
+ * Since SVG support is not yet standard in browsers, it is
+ * necessary to rasterize SVGs to PNG as a fallback format.
+ *
+ * An external program is required to perform this conversion.
+ * If set to an array, the first item is a PHP callable and any further items
+ * are passed as parameters after $srcPath, $dstPath, $width, $height
+ */
+$wgSVGConverters = [
+ 'ImageMagick' =>
+ '$path/convert -background "#ffffff00" -thumbnail $widthx$height\! $input PNG:$output',
+ 'sodipodi' => '$path/sodipodi -z -w $width -f $input -e $output',
+ 'inkscape' => '$path/inkscape -z -w $width -f $input -e $output',
+ 'batik' => 'java -Djava.awt.headless=true -jar $path/batik-rasterizer.jar -w $width -d '
+ . '$output $input',
+ 'rsvg' => '$path/rsvg-convert -w $width -h $height -o $output $input',
+ 'imgserv' => '$path/imgserv-wrapper -i svg -o png -w$width $input $output',
+ 'ImagickExt' => [ 'SvgHandler::rasterizeImagickExt' ],
+];
+
+/**
+ * Pick a converter defined in $wgSVGConverters
+ */
+$wgSVGConverter = 'ImageMagick';
+
+/**
+ * If not in the executable PATH, specify the SVG converter path.
+ */
+$wgSVGConverterPath = '';
+
+/**
+ * Don't scale a SVG larger than this
+ */
+$wgSVGMaxSize = 5120;
+
+/**
+ * Don't read SVG metadata beyond this point.
+ * Default is 1024*256 bytes
+ */
+$wgSVGMetadataCutoff = 262144;
+
+/**
+ * Disallow <title> element in SVG files.
+ *
+ * MediaWiki will reject HTMLesque tags in uploaded files due to idiotic
+ * browsers which can not perform basic stuff like MIME detection and which are
+ * vulnerable to further idiots uploading crap files as images.
+ *
+ * When this directive is on, "<title>" will be allowed in files with an
+ * "image/svg+xml" MIME type. You should leave this disabled if your web server
+ * is misconfigured and doesn't send appropriate MIME types for SVG images.
+ */
+$wgAllowTitlesInSVG = false;
+
+/**
+ * The maximum number of pixels a source image can have if it is to be scaled
+ * down by a scaler that requires the full source image to be decompressed
+ * and stored in decompressed form, before the thumbnail is generated.
+ *
+ * This provides a limit on memory usage for the decompression side of the
+ * image scaler. The limit is used when scaling PNGs with any of the
+ * built-in image scalers, such as ImageMagick or GD. It is ignored for
+ * JPEGs with ImageMagick, and when using the VipsScaler extension.
+ *
+ * The default is 50 MB if decompressed to RGBA form, which corresponds to
+ * 12.5 million pixels or 3500x3500.
+ */
+$wgMaxImageArea = 1.25e7;
+
+/**
+ * Force thumbnailing of animated GIFs above this size to a single
+ * frame instead of an animated thumbnail. As of MW 1.17 this limit
+ * is checked against the total size of all frames in the animation.
+ * It probably makes sense to keep this equal to $wgMaxImageArea.
+ */
+$wgMaxAnimatedGifArea = 1.25e7;
+
+/**
+ * Browsers don't support TIFF inline generally...
+ * For inline display, we need to convert to PNG or JPEG.
+ * Note scaling should work with ImageMagick, but may not with GD scaling.
+ *
+ * @par Example:
+ * @code
+ * // PNG is lossless, but inefficient for photos
+ * $wgTiffThumbnailType = [ 'png', 'image/png' ];
+ * // JPEG is good for photos, but has no transparency support. Bad for diagrams.
+ * $wgTiffThumbnailType = [ 'jpg', 'image/jpeg' ];
+ * @endcode
+ */
+$wgTiffThumbnailType = false;
+
+/**
+ * If rendered thumbnail files are older than this timestamp, they
+ * will be rerendered on demand as if the file didn't already exist.
+ * Update if there is some need to force thumbs and SVG rasterizations
+ * to rerender, such as fixes to rendering bugs.
+ */
+$wgThumbnailEpoch = '20030516000000';
+
+/**
+ * Certain operations are avoided if there were too many recent failures,
+ * for example, thumbnail generation. Bump this value to invalidate all
+ * memory of failed operations and thus allow further attempts to resume.
+ * This is useful when a cause for the failures has been found and fixed.
+ */
+$wgAttemptFailureEpoch = 1;
+
+/**
+ * If set, inline scaled images will still produce "<img>" tags ready for
+ * output instead of showing an error message.
+ *
+ * This may be useful if errors are transitory, especially if the site
+ * is configured to automatically render thumbnails on request.
+ *
+ * On the other hand, it may obscure error conditions from debugging.
+ * Enable the debug log or the 'thumbnail' log group to make sure errors
+ * are logged to a file for review.
+ */
+$wgIgnoreImageErrors = false;
+
+/**
+ * Allow thumbnail rendering on page view. If this is false, a valid
+ * thumbnail URL is still output, but no file will be created at
+ * the target location. This may save some time if you have a
+ * thumb.php or 404 handler set up which is faster than the regular
+ * webserver(s).
+ */
+$wgGenerateThumbnailOnParse = true;
+
+/**
+ * Show thumbnails for old images on the image description page
+ */
+$wgShowArchiveThumbnails = true;
+
+/**
+ * If set to true, images that contain certain the exif orientation tag will
+ * be rotated accordingly. If set to null, try to auto-detect whether a scaler
+ * is available that can rotate.
+ */
+$wgEnableAutoRotation = null;
+
+/**
+ * Internal name of virus scanner. This serves as a key to the
+ * $wgAntivirusSetup array. Set this to NULL to disable virus scanning. If not
+ * null, every file uploaded will be scanned for viruses.
+ */
+$wgAntivirus = null;
+
+/**
+ * Configuration for different virus scanners. This an associative array of
+ * associative arrays. It contains one setup array per known scanner type.
+ * The entry is selected by $wgAntivirus, i.e.
+ * valid values for $wgAntivirus are the keys defined in this array.
+ *
+ * The configuration array for each scanner contains the following keys:
+ * "command", "codemap", "messagepattern":
+ *
+ * "command" is the full command to call the virus scanner - %f will be
+ * replaced with the name of the file to scan. If not present, the filename
+ * will be appended to the command. Note that this must be overwritten if the
+ * scanner is not in the system path; in that case, please set
+ * $wgAntivirusSetup[$wgAntivirus]['command'] to the desired command with full
+ * path.
+ *
+ * "codemap" is a mapping of exit code to return codes of the detectVirus
+ * function in SpecialUpload.
+ * - An exit code mapped to AV_SCAN_FAILED causes the function to consider
+ * the scan to be failed. This will pass the file if $wgAntivirusRequired
+ * is not set.
+ * - An exit code mapped to AV_SCAN_ABORTED causes the function to consider
+ * the file to have an unsupported format, which is probably immune to
+ * viruses. This causes the file to pass.
+ * - An exit code mapped to AV_NO_VIRUS will cause the file to pass, meaning
+ * no virus was found.
+ * - All other codes (like AV_VIRUS_FOUND) will cause the function to report
+ * a virus.
+ * - You may use "*" as a key in the array to catch all exit codes not mapped otherwise.
+ *
+ * "messagepattern" is a perl regular expression to extract the meaningful part of the scanners
+ * output. The relevant part should be matched as group one (\1).
+ * If not defined or the pattern does not match, the full message is shown to the user.
+ */
+$wgAntivirusSetup = [
+
+ # setup for clamav
+ 'clamav' => [
+ 'command' => 'clamscan --no-summary ',
+ 'codemap' => [
+ "0" => AV_NO_VIRUS, # no virus
+ "1" => AV_VIRUS_FOUND, # virus found
+ "52" => AV_SCAN_ABORTED, # unsupported file format (probably immune)
+ "*" => AV_SCAN_FAILED, # else scan failed
+ ],
+ 'messagepattern' => '/.*?:(.*)/sim',
+ ],
+];
+
+/**
+ * Determines if a failed virus scan (AV_SCAN_FAILED) will cause the file to be rejected.
+ */
+$wgAntivirusRequired = true;
+
+/**
+ * Determines if the MIME type of uploaded files should be checked
+ */
+$wgVerifyMimeType = true;
+
+/**
+ * Sets the MIME type definition file to use by MimeMagic.php.
+ * Set to null, to use built-in defaults only.
+ * example: $wgMimeTypeFile = '/etc/mime.types';
+ */
+$wgMimeTypeFile = 'includes/mime.types';
+
+/**
+ * Sets the MIME type info file to use by MimeMagic.php.
+ * Set to null, to use built-in defaults only.
+ */
+$wgMimeInfoFile = 'includes/mime.info';
+
+/**
+ * Sets an external MIME detector program. The command must print only
+ * the MIME type to standard output.
+ * The name of the file to process will be appended to the command given here.
+ * If not set or NULL, PHP's mime_content_type function will be used.
+ *
+ * @par Example:
+ * @code
+ * #$wgMimeDetectorCommand = "file -bi"; # use external MIME detector (Linux)
+ * @endcode
+ */
+$wgMimeDetectorCommand = null;
+
+/**
+ * Switch for trivial MIME detection. Used by thumb.php to disable all fancy
+ * things, because only a few types of images are needed and file extensions
+ * can be trusted.
+ */
+$wgTrivialMimeDetection = false;
+
+/**
+ * Additional XML types we can allow via MIME-detection.
+ * array = [ 'rootElement' => 'associatedMimeType' ]
+ */
+$wgXMLMimeTypes = [
+ 'http://www.w3.org/2000/svg:svg' => 'image/svg+xml',
+ 'svg' => 'image/svg+xml',
+ 'http://www.lysator.liu.se/~alla/dia/:diagram' => 'application/x-dia-diagram',
+ 'http://www.w3.org/1999/xhtml:html' => 'text/html', // application/xhtml+xml?
+ 'html' => 'text/html', // application/xhtml+xml?
+];
+
+/**
+ * Limit images on image description pages to a user-selectable limit. In order
+ * to reduce disk usage, limits can only be selected from a list.
+ * The user preference is saved as an array offset in the database, by default
+ * the offset is set with $wgDefaultUserOptions['imagesize']. Make sure you
+ * change it if you alter the array (see T10858).
+ * This is the list of settings the user can choose from:
+ */
+$wgImageLimits = [
+ [ 320, 240 ],
+ [ 640, 480 ],
+ [ 800, 600 ],
+ [ 1024, 768 ],
+ [ 1280, 1024 ]
+];
+
+/**
+ * Adjust thumbnails on image pages according to a user setting. In order to
+ * reduce disk usage, the values can only be selected from a list. This is the
+ * list of settings the user can choose from:
+ */
+$wgThumbLimits = [
+ 120,
+ 150,
+ 180,
+ 200,
+ 250,
+ 300
+];
+
+/**
+ * When defined, is an array of image widths used as buckets for thumbnail generation.
+ * The goal is to save resources by generating thumbnails based on reference buckets instead of
+ * always using the original. This will incur a speed gain but cause a quality loss.
+ *
+ * The buckets generation is chained, with each bucket generated based on the above bucket
+ * when possible. File handlers have to opt into using that feature. For now only BitmapHandler
+ * supports it.
+ */
+$wgThumbnailBuckets = null;
+
+/**
+ * When using thumbnail buckets as defined above, this sets the minimum distance to the bucket
+ * above the requested size. The distance represents how many extra pixels of width the bucket
+ * needs in order to be used as the reference for a given thumbnail. For example, with the
+ * following buckets:
+ *
+ * $wgThumbnailBuckets = [ 128, 256, 512 ];
+ *
+ * and a distance of 50:
+ *
+ * $wgThumbnailMinimumBucketDistance = 50;
+ *
+ * If we want to render a thumbnail of width 220px, the 512px bucket will be used,
+ * because 220 + 50 = 270 and the closest bucket bigger than 270px is 512.
+ */
+$wgThumbnailMinimumBucketDistance = 50;
+
+/**
+ * When defined, is an array of thumbnail widths to be rendered at upload time. The idea is to
+ * prerender common thumbnail sizes, in order to avoid the necessity to render them on demand, which
+ * has a performance impact for the first client to view a certain size.
+ *
+ * This obviously means that more disk space is needed per upload upfront.
+ *
+ * @since 1.25
+ */
+
+$wgUploadThumbnailRenderMap = [];
+
+/**
+ * The method through which the thumbnails will be prerendered for the entries in
+ * $wgUploadThumbnailRenderMap
+ *
+ * The method can be either "http" or "jobqueue". The former uses an http request to hit the
+ * thumbnail's URL.
+ * This method only works if thumbnails are configured to be rendered by a 404 handler. The latter
+ * option uses the job queue to render the thumbnail.
+ *
+ * @since 1.25
+ */
+$wgUploadThumbnailRenderMethod = 'jobqueue';
+
+/**
+ * When using the "http" wgUploadThumbnailRenderMethod, lets one specify a custom Host HTTP header.
+ *
+ * @since 1.25
+ */
+$wgUploadThumbnailRenderHttpCustomHost = false;
+
+/**
+ * When using the "http" wgUploadThumbnailRenderMethod, lets one specify a custom domain to send the
+ * HTTP request to.
+ *
+ * @since 1.25
+ */
+$wgUploadThumbnailRenderHttpCustomDomain = false;
+
+/**
+ * When this variable is true and JPGs use the sRGB ICC profile, swaps it for the more lightweight
+ * (and free) TinyRGB profile when generating thumbnails.
+ *
+ * @since 1.26
+ */
+$wgUseTinyRGBForJPGThumbnails = false;
+
+/**
+ * Parameters for the "<gallery>" tag.
+ * Fields are:
+ * - imagesPerRow: Default number of images per-row in the gallery. 0 -> Adapt to screensize
+ * - imageWidth: Width of the cells containing images in galleries (in "px")
+ * - imageHeight: Height of the cells containing images in galleries (in "px")
+ * - captionLength: Length to truncate filename to in caption when using "showfilename".
+ * A value of 'true' will truncate the filename to one line using CSS
+ * and will be the behaviour after deprecation.
+ * @deprecated since 1.28
+ * - showBytes: Show the filesize in bytes in categories
+ * - showDimensions: Show the dimensions (width x height) in categories
+ * - mode: Gallery mode
+ */
+$wgGalleryOptions = [];
+
+/**
+ * Adjust width of upright images when parameter 'upright' is used
+ * This allows a nicer look for upright images without the need to fix the width
+ * by hardcoded px in wiki sourcecode.
+ */
+$wgThumbUpright = 0.75;
+
+/**
+ * Default value for chmoding of new directories.
+ */
+$wgDirectoryMode = 0777;
+
+/**
+ * Generate and use thumbnails suitable for screens with 1.5 and 2.0 pixel densities.
+ *
+ * This means a 320x240 use of an image on the wiki will also generate 480x360 and 640x480
+ * thumbnails, output via the srcset attribute.
+ *
+ * On older browsers, a JavaScript polyfill switches the appropriate images in after loading
+ * the original low-resolution versions depending on the reported window.devicePixelRatio.
+ * The polyfill can be found in the jquery.hidpi module.
+ */
+$wgResponsiveImages = true;
+
+/**
+ * @name DJVU settings
+ * @{
+ */
+
+/**
+ * Path of the djvudump executable
+ * Enable this and $wgDjvuRenderer to enable djvu rendering
+ * example: $wgDjvuDump = 'djvudump';
+ */
+$wgDjvuDump = null;
+
+/**
+ * Path of the ddjvu DJVU renderer
+ * Enable this and $wgDjvuDump to enable djvu rendering
+ * example: $wgDjvuRenderer = 'ddjvu';
+ */
+$wgDjvuRenderer = null;
+
+/**
+ * Path of the djvutxt DJVU text extraction utility
+ * Enable this and $wgDjvuDump to enable text layer extraction from djvu files
+ * example: $wgDjvuTxt = 'djvutxt';
+ */
+$wgDjvuTxt = null;
+
+/**
+ * Path of the djvutoxml executable
+ * This works like djvudump except much, much slower as of version 3.5.
+ *
+ * For now we recommend you use djvudump instead. The djvuxml output is
+ * probably more stable, so we'll switch back to it as soon as they fix
+ * the efficiency problem.
+ * https://sourceforge.net/tracker/index.php?func=detail&aid=1704049&group_id=32953&atid=406583
+ *
+ * @par Example:
+ * @code
+ * $wgDjvuToXML = 'djvutoxml';
+ * @endcode
+ */
+$wgDjvuToXML = null;
+
+/**
+ * Shell command for the DJVU post processor
+ * Default: pnmtojpeg, since ddjvu generates ppm output
+ * Set this to false to output the ppm file directly.
+ */
+$wgDjvuPostProcessor = 'pnmtojpeg';
+
+/**
+ * File extension for the DJVU post processor output
+ */
+$wgDjvuOutputExtension = 'jpg';
+
+/** @} */ # end of DJvu }
+
+/** @} */ # end of file uploads }
+
+/************************************************************************//**
+ * @name Email settings
+ * @{
+ */
+
+/**
+ * Site admin email address.
+ *
+ * Defaults to "wikiadmin@$wgServerName".
+ */
+$wgEmergencyContact = false;
+
+/**
+ * Sender email address for e-mail notifications.
+ *
+ * The address we use as sender when a user requests a password reminder.
+ *
+ * Defaults to "apache@$wgServerName".
+ */
+$wgPasswordSender = false;
+
+/**
+ * Sender name for e-mail notifications.
+ *
+ * @deprecated since 1.23; use the system message 'emailsender' instead.
+ */
+$wgPasswordSenderName = 'MediaWiki Mail';
+
+/**
+ * Reply-To address for e-mail notifications.
+ *
+ * Defaults to $wgPasswordSender.
+ */
+$wgNoReplyAddress = false;
+
+/**
+ * Set to true to enable the e-mail basic features:
+ * Password reminders, etc. If sending e-mail on your
+ * server doesn't work, you might want to disable this.
+ */
+$wgEnableEmail = true;
+
+/**
+ * Set to true to enable user-to-user e-mail.
+ * This can potentially be abused, as it's hard to track.
+ */
+$wgEnableUserEmail = true;
+
+/**
+ * Set to true to enable user-to-user e-mail blacklist.
+ *
+ * @since 1.30
+ */
+$wgEnableUserEmailBlacklist = false;
+
+/**
+ * If true put the sending user's email in a Reply-To header
+ * instead of From (false). ($wgPasswordSender will be used as From.)
+ *
+ * Some mailers (eg SMTP) set the SMTP envelope sender to the From value,
+ * which can cause problems with SPF validation and leak recipient addresses
+ * when bounces are sent to the sender. In addition, DMARC restrictions
+ * can cause emails to fail to be received when false.
+ */
+$wgUserEmailUseReplyTo = true;
+
+/**
+ * Minimum time, in hours, which must elapse between password reminder
+ * emails for a given account. This is to prevent abuse by mail flooding.
+ */
+$wgPasswordReminderResendTime = 24;
+
+/**
+ * The time, in seconds, when an emailed temporary password expires.
+ */
+$wgNewPasswordExpiry = 3600 * 24 * 7;
+
+/**
+ * The time, in seconds, when an email confirmation email expires
+ */
+$wgUserEmailConfirmationTokenExpiry = 7 * 24 * 60 * 60;
+
+/**
+ * The number of days that a user's password is good for. After this number of days, the
+ * user will be asked to reset their password. Set to false to disable password expiration.
+ */
+$wgPasswordExpirationDays = false;
+
+/**
+ * If a user's password is expired, the number of seconds when they can still login,
+ * and cancel their password change, but are sent to the password change form on each login.
+ */
+$wgPasswordExpireGrace = 3600 * 24 * 7; // 7 days
+
+/**
+ * SMTP Mode.
+ *
+ * For using a direct (authenticated) SMTP server connection.
+ * Default to false or fill an array :
+ *
+ * @code
+ * $wgSMTP = [
+ * 'host' => 'SMTP domain',
+ * 'IDHost' => 'domain for MessageID',
+ * 'port' => '25',
+ * 'auth' => [true|false],
+ * 'username' => [SMTP username],
+ * 'password' => [SMTP password],
+ * ];
+ * @endcode
+ */
+$wgSMTP = false;
+
+/**
+ * Additional email parameters, will be passed as the last argument to mail() call.
+ */
+$wgAdditionalMailParams = null;
+
+/**
+ * For parts of the system that have been updated to provide HTML email content, send
+ * both text and HTML parts as the body of the email
+ */
+$wgAllowHTMLEmail = false;
+
+/**
+ * True: from page editor if s/he opted-in. False: Enotif mails appear to come
+ * from $wgEmergencyContact
+ */
+$wgEnotifFromEditor = false;
+
+// TODO move UPO to preferences probably ?
+# If set to true, users get a corresponding option in their preferences and can choose to
+# enable or disable at their discretion
+# If set to false, the corresponding input form on the user preference page is suppressed
+# It call this to be a "user-preferences-option (UPO)"
+
+/**
+ * Require email authentication before sending mail to an email address.
+ * This is highly recommended. It prevents MediaWiki from being used as an open
+ * spam relay.
+ */
+$wgEmailAuthentication = true;
+
+/**
+ * Allow users to enable email notification ("enotif") on watchlist changes.
+ */
+$wgEnotifWatchlist = false;
+
+/**
+ * Allow users to enable email notification ("enotif") when someone edits their
+ * user talk page.
+ *
+ * The owner of the user talk page must also have the 'enotifusertalkpages' user
+ * preference set to true.
+ */
+$wgEnotifUserTalk = false;
+
+/**
+ * Set the Reply-to address in notifications to the editor's address, if user
+ * allowed this in the preferences.
+ */
+$wgEnotifRevealEditorAddress = false;
+
+/**
+ * Potentially send notification mails on minor edits to pages. This is enabled
+ * by default. If this is false, users will never be notified on minor edits.
+ *
+ * If it is true, editors with the 'nominornewtalk' right (typically bots) will still not
+ * trigger notifications for minor edits they make (to any page, not just user talk).
+ *
+ * Finally, if the watcher/recipient has the 'enotifminoredits' user preference set to
+ * false, they will not receive notifications for minor edits.
+ *
+ * User talk notifications are also affected by $wgEnotifMinorEdits, the above settings,
+ * $wgEnotifUserTalk, and the preference described there.
+ */
+$wgEnotifMinorEdits = true;
+
+/**
+ * Send a generic mail instead of a personalised mail for each user. This
+ * always uses UTC as the time zone, and doesn't include the username.
+ *
+ * For pages with many users watching, this can significantly reduce mail load.
+ * Has no effect when using sendmail rather than SMTP.
+ */
+$wgEnotifImpersonal = false;
+
+/**
+ * Maximum number of users to mail at once when using impersonal mail. Should
+ * match the limit on your mail server.
+ */
+$wgEnotifMaxRecips = 500;
+
+/**
+ * Use real name instead of username in e-mail "from" field.
+ */
+$wgEnotifUseRealName = false;
+
+/**
+ * Array of usernames who will be sent a notification email for every change
+ * which occurs on a wiki. Users will not be notified of their own changes.
+ */
+$wgUsersNotifiedOnAllChanges = [];
+
+/** @} */ # end of email settings
+
+/************************************************************************//**
+ * @name Database settings
+ * @{
+ */
+
+/**
+ * Database host name or IP address
+ */
+$wgDBserver = 'localhost';
+
+/**
+ * Database port number (for PostgreSQL and Microsoft SQL Server).
+ */
+$wgDBport = 5432;
+
+/**
+ * Name of the database
+ */
+$wgDBname = 'my_wiki';
+
+/**
+ * Database username
+ */
+$wgDBuser = 'wikiuser';
+
+/**
+ * Database user's password
+ */
+$wgDBpassword = '';
+
+/**
+ * Database type
+ */
+$wgDBtype = 'mysql';
+
+/**
+ * Whether to use SSL in DB connection.
+ *
+ * This setting is only used $wgLBFactoryConf['class'] is set to
+ * 'LBFactorySimple' and $wgDBservers is an empty array; otherwise
+ * the DBO_SSL flag must be set in the 'flags' option of the database
+ * connection to achieve the same functionality.
+ */
+$wgDBssl = false;
+
+/**
+ * Whether to use compression in DB connection.
+ *
+ * This setting is only used $wgLBFactoryConf['class'] is set to
+ * 'LBFactorySimple' and $wgDBservers is an empty array; otherwise
+ * the DBO_COMPRESS flag must be set in the 'flags' option of the database
+ * connection to achieve the same functionality.
+ */
+$wgDBcompress = false;
+
+/**
+ * Separate username for maintenance tasks. Leave as null to use the default.
+ */
+$wgDBadminuser = null;
+
+/**
+ * Separate password for maintenance tasks. Leave as null to use the default.
+ */
+$wgDBadminpassword = null;
+
+/**
+ * Search type.
+ * Leave as null to select the default search engine for the
+ * selected database type (eg SearchMySQL), or set to a class
+ * name to override to a custom search engine.
+ */
+$wgSearchType = null;
+
+/**
+ * Alternative search types
+ * Sometimes you want to support multiple search engines for testing. This
+ * allows users to select their search engine of choice via url parameters
+ * to Special:Search and the action=search API. If using this, there's no
+ * need to add $wgSearchType to it, that is handled automatically.
+ */
+$wgSearchTypeAlternatives = null;
+
+/**
+ * Table name prefix
+ */
+$wgDBprefix = '';
+
+/**
+ * MySQL table options to use during installation or update
+ */
+$wgDBTableOptions = 'ENGINE=InnoDB';
+
+/**
+ * SQL Mode - default is turning off all modes, including strict, if set.
+ * null can be used to skip the setting for performance reasons and assume
+ * DBA has done his best job.
+ * String override can be used for some additional fun :-)
+ */
+$wgSQLMode = '';
+
+/**
+ * Mediawiki schema
+ */
+$wgDBmwschema = null;
+
+/**
+ * To override default SQLite data directory ($docroot/../data)
+ */
+$wgSQLiteDataDir = '';
+
+/**
+ * Shared database for multiple wikis. Commonly used for storing a user table
+ * for single sign-on. The server for this database must be the same as for the
+ * main database.
+ *
+ * For backwards compatibility the shared prefix is set to the same as the local
+ * prefix, and the user table is listed in the default list of shared tables.
+ * The user_properties table is also added so that users will continue to have their
+ * preferences shared (preferences were stored in the user table prior to 1.16)
+ *
+ * $wgSharedTables may be customized with a list of tables to share in the shared
+ * database. However it is advised to limit what tables you do share as many of
+ * MediaWiki's tables may have side effects if you try to share them.
+ *
+ * $wgSharedPrefix is the table prefix for the shared database. It defaults to
+ * $wgDBprefix.
+ *
+ * $wgSharedSchema is the table schema for the shared database. It defaults to
+ * $wgDBmwschema.
+ *
+ * @deprecated since 1.21 In new code, use the $wiki parameter to wfGetLB() to
+ * access remote databases. Using wfGetLB() allows the shared database to
+ * reside on separate servers to the wiki's own database, with suitable
+ * configuration of $wgLBFactoryConf.
+ */
+$wgSharedDB = null;
+
+/**
+ * @see $wgSharedDB
+ */
+$wgSharedPrefix = false;
+
+/**
+ * @see $wgSharedDB
+ */
+$wgSharedTables = [ 'user', 'user_properties' ];
+
+/**
+ * @see $wgSharedDB
+ * @since 1.23
+ */
+$wgSharedSchema = false;
+
+/**
+ * Database load balancer
+ * This is a two-dimensional array, an array of server info structures
+ * Fields are:
+ * - host: Host name
+ * - dbname: Default database name
+ * - user: DB user
+ * - password: DB password
+ * - type: DB type
+ *
+ * - load: Ratio of DB_REPLICA load, must be >=0, the sum of all loads must be >0.
+ * If this is zero for any given server, no normal query traffic will be
+ * sent to it. It will be excluded from lag checks in maintenance scripts.
+ * The only way it can receive traffic is if groupLoads is used.
+ *
+ * - groupLoads: array of load ratios, the key is the query group name. A query may belong
+ * to several groups, the most specific group defined here is used.
+ *
+ * - flags: bit field
+ * - DBO_DEFAULT -- turns on DBO_TRX only if "cliMode" is off (recommended)
+ * - DBO_DEBUG -- equivalent of $wgDebugDumpSql
+ * - DBO_TRX -- wrap entire request in a transaction
+ * - DBO_NOBUFFER -- turn off buffering (not useful in LocalSettings.php)
+ * - DBO_PERSISTENT -- enables persistent database connections
+ * - DBO_SSL -- uses SSL/TLS encryption in database connections, if available
+ * - DBO_COMPRESS -- uses internal compression in database connections,
+ * if available
+ *
+ * - max lag: (optional) Maximum replication lag before a replica DB goes out of rotation
+ * - is static: (optional) Set to true if the dataset is static and no replication is used.
+ * - cliMode: (optional) Connection handles will not assume that requests are short-lived
+ * nor that INSERT..SELECT can be rewritten into a buffered SELECT and INSERT.
+ * [Default: uses value of $wgCommandLineMode]
+ *
+ * These and any other user-defined properties will be assigned to the mLBInfo member
+ * variable of the Database object.
+ *
+ * Leave at false to use the single-server variables above. If you set this
+ * variable, the single-server variables will generally be ignored (except
+ * perhaps in some command-line scripts).
+ *
+ * The first server listed in this array (with key 0) will be the master. The
+ * rest of the servers will be replica DBs. To prevent writes to your replica DBs due to
+ * accidental misconfiguration or MediaWiki bugs, set read_only=1 on all your
+ * replica DBs in my.cnf. You can set read_only mode at runtime using:
+ *
+ * @code
+ * SET @@read_only=1;
+ * @endcode
+ *
+ * Since the effect of writing to a replica DB is so damaging and difficult to clean
+ * up, we at Wikimedia set read_only=1 in my.cnf on all our DB servers, even
+ * our masters, and then set read_only=0 on masters at runtime.
+ */
+$wgDBservers = false;
+
+/**
+ * Load balancer factory configuration
+ * To set up a multi-master wiki farm, set the class here to something that
+ * can return a LoadBalancer with an appropriate master on a call to getMainLB().
+ * The class identified here is responsible for reading $wgDBservers,
+ * $wgDBserver, etc., so overriding it may cause those globals to be ignored.
+ *
+ * The LBFactoryMulti class is provided for this purpose, please see
+ * includes/db/LBFactoryMulti.php for configuration information.
+ */
+$wgLBFactoryConf = [ 'class' => 'LBFactorySimple' ];
+
+/**
+ * After a state-changing request is done by a client, this determines
+ * how many seconds that client should keep using the master datacenter.
+ * This avoids unexpected stale or 404 responses due to replication lag.
+ * @since 1.27
+ */
+$wgDataCenterUpdateStickTTL = 10;
+
+/**
+ * File to log database errors to
+ */
+$wgDBerrorLog = false;
+
+/**
+ * Timezone to use in the error log.
+ * Defaults to the wiki timezone ($wgLocaltimezone).
+ *
+ * A list of usable timezones can found at:
+ * https://secure.php.net/manual/en/timezones.php
+ *
+ * @par Examples:
+ * @code
+ * $wgDBerrorLogTZ = 'UTC';
+ * $wgDBerrorLogTZ = 'GMT';
+ * $wgDBerrorLogTZ = 'PST8PDT';
+ * $wgDBerrorLogTZ = 'Europe/Sweden';
+ * $wgDBerrorLogTZ = 'CET';
+ * @endcode
+ *
+ * @since 1.20
+ */
+$wgDBerrorLogTZ = false;
+
+/**
+ * Set to true to engage MySQL 4.1/5.0 charset-related features;
+ * for now will just cause sending of 'SET NAMES=utf8' on connect.
+ *
+ * @warning THIS IS EXPERIMENTAL!
+ *
+ * May break if you're not using the table defs from mysql5/tables.sql.
+ * May break if you're upgrading an existing wiki if set differently.
+ * Broken symptoms likely to include incorrect behavior with page titles,
+ * usernames, comments etc containing non-ASCII characters.
+ * Might also cause failures on the object cache and other things.
+ *
+ * Even correct usage may cause failures with Unicode supplementary
+ * characters (those not in the Basic Multilingual Plane) unless MySQL
+ * has enhanced their Unicode support.
+ */
+$wgDBmysql5 = false;
+
+/**
+ * Set true to enable Oracle DCRP (supported from 11gR1 onward)
+ *
+ * To use this feature set to true and use a datasource defined as
+ * POOLED (i.e. in tnsnames definition set server=pooled in connect_data
+ * block).
+ *
+ * Starting from 11gR1 you can use DCRP (Database Resident Connection
+ * Pool) that maintains established sessions and reuses them on new
+ * connections.
+ *
+ * Not completely tested, but it should fall back on normal connection
+ * in case the pool is full or the datasource is not configured as
+ * pooled.
+ * And the other way around; using oci_pconnect on a non pooled
+ * datasource should produce a normal connection.
+ *
+ * When it comes to frequent shortlived DB connections like with MW
+ * Oracle tends to s***. The problem is the driver connects to the
+ * database reasonably fast, but establishing a session takes time and
+ * resources. MW does not rely on session state (as it does not use
+ * features such as package variables) so establishing a valid session
+ * is in this case an unwanted overhead that just slows things down.
+ *
+ * @warning EXPERIMENTAL!
+ */
+$wgDBOracleDRCP = false;
+
+/**
+ * Other wikis on this site, can be administered from a single developer account.
+ *
+ * Array numeric key => database name
+ */
+$wgLocalDatabases = [];
+
+/**
+ * If lag is higher than $wgSlaveLagWarning, show a warning in some special
+ * pages (like watchlist). If the lag is higher than $wgSlaveLagCritical,
+ * show a more obvious warning.
+ */
+$wgSlaveLagWarning = 10;
+
+/**
+ * @see $wgSlaveLagWarning
+ */
+$wgSlaveLagCritical = 30;
+
+/**
+ * Use Windows Authentication instead of $wgDBuser / $wgDBpassword for MS SQL Server
+ */
+$wgDBWindowsAuthentication = false;
+
+/**@}*/ # End of DB settings }
+
+/************************************************************************//**
+ * @name Text storage
+ * @{
+ */
+
+/**
+ * We can also compress text stored in the 'text' table. If this is set on, new
+ * revisions will be compressed on page save if zlib support is available. Any
+ * compressed revisions will be decompressed on load regardless of this setting,
+ * but will not be readable at all* if zlib support is not available.
+ */
+$wgCompressRevisions = false;
+
+/**
+ * External stores allow including content
+ * from non database sources following URL links.
+ *
+ * Short names of ExternalStore classes may be specified in an array here:
+ * @code
+ * $wgExternalStores = [ "http","file","custom" ]...
+ * @endcode
+ *
+ * CAUTION: Access to database might lead to code execution
+ */
+$wgExternalStores = [];
+
+/**
+ * An array of external MySQL servers.
+ *
+ * @par Example:
+ * Create a cluster named 'cluster1' containing three servers:
+ * @code
+ * $wgExternalServers = [
+ * 'cluster1' => <array in the same format as $wgDBservers>
+ * ];
+ * @endcode
+ *
+ * Used by LBFactorySimple, may be ignored if $wgLBFactoryConf is set to
+ * another class.
+ */
+$wgExternalServers = [];
+
+/**
+ * The place to put new revisions, false to put them in the local text table.
+ * Part of a URL, e.g. DB://cluster1
+ *
+ * Can be an array instead of a single string, to enable data distribution. Keys
+ * must be consecutive integers, starting at zero.
+ *
+ * @par Example:
+ * @code
+ * $wgDefaultExternalStore = [ 'DB://cluster1', 'DB://cluster2' ];
+ * @endcode
+ *
+ * @var array
+ */
+$wgDefaultExternalStore = false;
+
+/**
+ * Revision text may be cached in $wgMemc to reduce load on external storage
+ * servers and object extraction overhead for frequently-loaded revisions.
+ *
+ * Set to 0 to disable, or number of seconds before cache expiry.
+ */
+$wgRevisionCacheExpiry = 86400 * 7;
+
+/** @} */ # end text storage }
+
+/************************************************************************//**
+ * @name Performance hacks and limits
+ * @{
+ */
+
+/**
+ * Disable database-intensive features
+ */
+$wgMiserMode = false;
+
+/**
+ * Disable all query pages if miser mode is on, not just some
+ */
+$wgDisableQueryPages = false;
+
+/**
+ * Number of rows to cache in 'querycache' table when miser mode is on
+ */
+$wgQueryCacheLimit = 1000;
+
+/**
+ * Number of links to a page required before it is deemed "wanted"
+ */
+$wgWantedPagesThreshold = 1;
+
+/**
+ * Enable slow parser functions
+ */
+$wgAllowSlowParserFunctions = false;
+
+/**
+ * Allow schema updates
+ */
+$wgAllowSchemaUpdates = true;
+
+/**
+ * Maximum article size in kilobytes
+ */
+$wgMaxArticleSize = 2048;
+
+/**
+ * The minimum amount of memory that MediaWiki "needs"; MediaWiki will try to
+ * raise PHP's memory limit if it's below this amount.
+ */
+$wgMemoryLimit = "50M";
+
+/**
+ * The minimum amount of time that MediaWiki needs for "slow" write request,
+ * particularly ones with multiple non-atomic writes that *should* be as
+ * transactional as possible; MediaWiki will call set_time_limit() if needed.
+ * @since 1.26
+ */
+$wgTransactionalTimeLimit = 120;
+
+/** @} */ # end performance hacks }
+
+/************************************************************************//**
+ * @name Cache settings
+ * @{
+ */
+
+/**
+ * Directory for caching data in the local filesystem. Should not be accessible
+ * from the web.
+ *
+ * Note: if multiple wikis share the same localisation cache directory, they
+ * must all have the same set of extensions. You can set a directory just for
+ * the localisation cache using $wgLocalisationCacheConf['storeDirectory'].
+ */
+$wgCacheDirectory = false;
+
+/**
+ * Main cache type. This should be a cache with fast access, but it may have
+ * limited space. By default, it is disabled, since the stock database cache
+ * is not fast enough to make it worthwhile.
+ *
+ * The options are:
+ *
+ * - CACHE_ANYTHING: Use anything, as long as it works
+ * - CACHE_NONE: Do not cache
+ * - CACHE_DB: Store cache objects in the DB
+ * - CACHE_MEMCACHED: MemCached, must specify servers in $wgMemCachedServers
+ * - CACHE_ACCEL: APC, APCU, XCache or WinCache
+ * - (other): A string may be used which identifies a cache
+ * configuration in $wgObjectCaches.
+ *
+ * @see $wgMessageCacheType, $wgParserCacheType
+ */
+$wgMainCacheType = CACHE_NONE;
+
+/**
+ * The cache type for storing the contents of the MediaWiki namespace. This
+ * cache is used for a small amount of data which is expensive to regenerate.
+ *
+ * For available types see $wgMainCacheType.
+ */
+$wgMessageCacheType = CACHE_ANYTHING;
+
+/**
+ * The cache type for storing article HTML. This is used to store data which
+ * is expensive to regenerate, and benefits from having plenty of storage space.
+ *
+ * For available types see $wgMainCacheType.
+ */
+$wgParserCacheType = CACHE_ANYTHING;
+
+/**
+ * The cache type for storing session data.
+ *
+ * For available types see $wgMainCacheType.
+ */
+$wgSessionCacheType = CACHE_ANYTHING;
+
+/**
+ * The cache type for storing language conversion tables,
+ * which are used when parsing certain text and interface messages.
+ *
+ * For available types see $wgMainCacheType.
+ *
+ * @since 1.20
+ */
+$wgLanguageConverterCacheType = CACHE_ANYTHING;
+
+/**
+ * Advanced object cache configuration.
+ *
+ * Use this to define the class names and constructor parameters which are used
+ * for the various cache types. Custom cache types may be defined here and
+ * referenced from $wgMainCacheType, $wgMessageCacheType, $wgParserCacheType,
+ * or $wgLanguageConverterCacheType.
+ *
+ * The format is an associative array where the key is a cache identifier, and
+ * the value is an associative array of parameters. The "class" parameter is the
+ * class name which will be used. Alternatively, a "factory" parameter may be
+ * given, giving a callable function which will generate a suitable cache object.
+ */
+$wgObjectCaches = [
+ CACHE_NONE => [ 'class' => 'EmptyBagOStuff', 'reportDupes' => false ],
+ CACHE_DB => [ 'class' => 'SqlBagOStuff', 'loggroup' => 'SQLBagOStuff' ],
+
+ CACHE_ANYTHING => [ 'factory' => 'ObjectCache::newAnything' ],
+ CACHE_ACCEL => [ 'factory' => 'ObjectCache::getLocalServerInstance' ],
+ CACHE_MEMCACHED => [ 'class' => 'MemcachedPhpBagOStuff', 'loggroup' => 'memcached' ],
+
+ 'db-replicated' => [
+ 'class' => 'ReplicatedBagOStuff',
+ 'readFactory' => [
+ 'class' => 'SqlBagOStuff',
+ 'args' => [ [ 'slaveOnly' => true ] ]
+ ],
+ 'writeFactory' => [
+ 'class' => 'SqlBagOStuff',
+ 'args' => [ [ 'slaveOnly' => false ] ]
+ ],
+ 'loggroup' => 'SQLBagOStuff',
+ 'reportDupes' => false
+ ],
+
+ 'apc' => [ 'class' => 'APCBagOStuff', 'reportDupes' => false ],
+ 'apcu' => [ 'class' => 'APCUBagOStuff', 'reportDupes' => false ],
+ 'xcache' => [ 'class' => 'XCacheBagOStuff', 'reportDupes' => false ],
+ 'wincache' => [ 'class' => 'WinCacheBagOStuff', 'reportDupes' => false ],
+ 'memcached-php' => [ 'class' => 'MemcachedPhpBagOStuff', 'loggroup' => 'memcached' ],
+ 'memcached-pecl' => [ 'class' => 'MemcachedPeclBagOStuff', 'loggroup' => 'memcached' ],
+ 'hash' => [ 'class' => 'HashBagOStuff', 'reportDupes' => false ],
+];
+
+/**
+ * Main Wide-Area-Network cache type. This should be a cache with fast access,
+ * but it may have limited space. By default, it is disabled, since the basic stock
+ * cache is not fast enough to make it worthwhile. For single data-center setups, this can
+ * simply be pointed to a cache in $wgWANObjectCaches that uses a local $wgObjectCaches
+ * cache with a relayer of type EventRelayerNull.
+ *
+ * The options are:
+ * - false: Configure the cache using $wgMainCacheType, without using
+ * a relayer (only matters if there are multiple data-centers)
+ * - CACHE_NONE: Do not cache
+ * - (other): A string may be used which identifies a cache
+ * configuration in $wgWANObjectCaches
+ * @since 1.26
+ */
+$wgMainWANCache = false;
+
+/**
+ * Advanced WAN object cache configuration.
+ *
+ * Each WAN cache wraps a registered object cache (for the local cluster)
+ * and it must also be configured to point to a PubSub instance. Subscribers
+ * must be configured to relay purges to the actual cache servers.
+ *
+ * The format is an associative array where the key is a cache identifier, and
+ * the value is an associative array of parameters. The "cacheId" parameter is
+ * a cache identifier from $wgObjectCaches. The "channels" parameter is a map of
+ * actions ('purge') to PubSub channels defined in $wgEventRelayerConfig.
+ * The "loggroup" parameter controls where log events are sent.
+ *
+ * @since 1.26
+ */
+$wgWANObjectCaches = [
+ CACHE_NONE => [
+ 'class' => 'WANObjectCache',
+ 'cacheId' => CACHE_NONE,
+ 'channels' => []
+ ]
+ /* Example of a simple single data-center cache:
+ 'memcached-php' => [
+ 'class' => 'WANObjectCache',
+ 'cacheId' => 'memcached-php',
+ 'channels' => [ 'purge' => 'wancache-main-memcached-purge' ]
+ ]
+ */
+];
+
+/**
+ * Verify and enforce WAN cache purges using reliable DB sources as streams.
+ *
+ * These secondary cache purges are de-duplicated via simple cache mutexes.
+ * This improves consistency when cache purges are lost, which becomes more likely
+ * as more cache servers are added or if there are multiple datacenters. Only keys
+ * related to important mutable content will be checked.
+ *
+ * @var bool
+ * @since 1.29
+ */
+$wgEnableWANCacheReaper = false;
+
+/**
+ * Main object stash type. This should be a fast storage system for storing
+ * lightweight data like hit counters and user activity. Sites with multiple
+ * data-centers should have this use a store that replicates all writes. The
+ * store should have enough consistency for CAS operations to be usable.
+ * Reads outside of those needed for merge() may be eventually consistent.
+ *
+ * The options are:
+ * - db: Store cache objects in the DB
+ * - (other): A string may be used which identifies a cache
+ * configuration in $wgObjectCaches
+ *
+ * @since 1.26
+ */
+$wgMainStash = 'db-replicated';
+
+/**
+ * The expiry time for the parser cache, in seconds.
+ * The default is 86400 (one day).
+ */
+$wgParserCacheExpireTime = 86400;
+
+/**
+ * @deprecated since 1.27, session data is always stored in object cache.
+ */
+$wgSessionsInObjectCache = true;
+
+/**
+ * The expiry time to use for session storage, in seconds.
+ */
+$wgObjectCacheSessionExpiry = 3600;
+
+/**
+ * @deprecated since 1.27, MediaWiki\Session\SessionManager doesn't use PHP session storage.
+ */
+$wgSessionHandler = null;
+
+/**
+ * Whether to use PHP session handling ($_SESSION and session_*() functions)
+ *
+ * If the constant MW_NO_SESSION is defined, this is forced to 'disable'.
+ *
+ * If the constant MW_NO_SESSION_HANDLER is defined, this is ignored and PHP
+ * session handling will function independently of SessionHandler.
+ * SessionHandler and PHP's session handling may attempt to override each
+ * others' cookies.
+ *
+ * @since 1.27
+ * @var string
+ * - 'enable': Integrate with PHP's session handling as much as possible.
+ * - 'warn': Integrate but log warnings if anything changes $_SESSION.
+ * - 'disable': Throw exceptions if PHP session handling is used.
+ */
+$wgPHPSessionHandling = 'enable';
+
+/**
+ * Number of internal PBKDF2 iterations to use when deriving session secrets.
+ *
+ * @since 1.28
+ */
+$wgSessionPbkdf2Iterations = 10001;
+
+/**
+ * If enabled, will send MemCached debugging information to $wgDebugLogFile
+ */
+$wgMemCachedDebug = false;
+
+/**
+ * The list of MemCached servers and port numbers
+ */
+$wgMemCachedServers = [ '127.0.0.1:11211' ];
+
+/**
+ * Use persistent connections to MemCached, which are shared across multiple
+ * requests.
+ */
+$wgMemCachedPersistent = false;
+
+/**
+ * Read/write timeout for MemCached server communication, in microseconds.
+ */
+$wgMemCachedTimeout = 500000;
+
+/**
+ * Set this to true to maintain a copy of the message cache on the local server.
+ *
+ * This layer of message cache is in addition to the one configured by $wgMessageCacheType.
+ *
+ * The local copy is put in APC. If APC is not installed, this setting does nothing.
+ *
+ * Note that this is about the message cache, which stores interface messages
+ * maintained as wiki pages. This is separate from the localisation cache for interface
+ * messages provided by the software, which is configured by $wgLocalisationCacheConf.
+ */
+$wgUseLocalMessageCache = false;
+
+/**
+ * Instead of caching everything, only cache those messages which have
+ * been customised in the site content language. This means that
+ * MediaWiki:Foo/ja is ignored if MediaWiki:Foo doesn't exist.
+ * This option is probably only useful for translatewiki.net.
+ */
+$wgAdaptiveMessageCache = false;
+
+/**
+ * Localisation cache configuration. Associative array with keys:
+ * class: The class to use. May be overridden by extensions.
+ *
+ * store: The location to store cache data. May be 'files', 'array', 'db' or
+ * 'detect'. If set to "files", data will be in CDB files. If set
+ * to "db", data will be stored to the database. If set to
+ * "detect", files will be used if $wgCacheDirectory is set,
+ * otherwise the database will be used.
+ * "array" is an experimental option that uses PHP files that
+ * store static arrays.
+ *
+ * storeClass: The class name for the underlying storage. If set to a class
+ * name, it overrides the "store" setting.
+ *
+ * storeDirectory: If the store class puts its data in files, this is the
+ * directory it will use. If this is false, $wgCacheDirectory
+ * will be used.
+ *
+ * manualRecache: Set this to true to disable cache updates on web requests.
+ * Use maintenance/rebuildLocalisationCache.php instead.
+ */
+$wgLocalisationCacheConf = [
+ 'class' => 'LocalisationCache',
+ 'store' => 'detect',
+ 'storeClass' => false,
+ 'storeDirectory' => false,
+ 'manualRecache' => false,
+];
+
+/**
+ * Allow client-side caching of pages
+ */
+$wgCachePages = true;
+
+/**
+ * Set this to current time to invalidate all prior cached pages. Affects both
+ * client-side and server-side caching.
+ * You can get the current date on your server by using the command:
+ * @verbatim
+ * date +%Y%m%d%H%M%S
+ * @endverbatim
+ */
+$wgCacheEpoch = '20030516000000';
+
+/**
+ * Directory where GitInfo will look for pre-computed cache files. If false,
+ * $wgCacheDirectory/gitinfo will be used.
+ */
+$wgGitInfoCacheDirectory = false;
+
+/**
+ * Bump this number when changing the global style sheets and JavaScript.
+ *
+ * It should be appended in the query string of static CSS and JS includes,
+ * to ensure that client-side caches do not keep obsolete copies of global
+ * styles.
+ */
+$wgStyleVersion = '303';
+
+/**
+ * This will cache static pages for non-logged-in users to reduce
+ * database traffic on public sites. ResourceLoader requests to default
+ * language and skins are cached as well as single module requests.
+ */
+$wgUseFileCache = false;
+
+/**
+ * Depth of the subdirectory hierarchy to be created under
+ * $wgFileCacheDirectory. The subdirectories will be named based on
+ * the MD5 hash of the title. A value of 0 means all cache files will
+ * be put directly into the main file cache directory.
+ */
+$wgFileCacheDepth = 2;
+
+/**
+ * Kept for extension compatibility; see $wgParserCacheType
+ * @deprecated since 1.26
+ */
+$wgEnableParserCache = true;
+
+/**
+ * Append a configured value to the parser cache and the sitenotice key so
+ * that they can be kept separate for some class of activity.
+ */
+$wgRenderHashAppend = '';
+
+/**
+ * If on, the sidebar navigation links are cached for users with the
+ * current language set. This can save a touch of load on a busy site
+ * by shaving off extra message lookups.
+ *
+ * However it is also fragile: changing the site configuration, or
+ * having a variable $wgArticlePath, can produce broken links that
+ * don't update as expected.
+ */
+$wgEnableSidebarCache = false;
+
+/**
+ * Expiry time for the sidebar cache, in seconds
+ */
+$wgSidebarCacheExpiry = 86400;
+
+/**
+ * When using the file cache, we can store the cached HTML gzipped to save disk
+ * space. Pages will then also be served compressed to clients that support it.
+ *
+ * Requires zlib support enabled in PHP.
+ */
+$wgUseGzip = false;
+
+/**
+ * Clock skew or the one-second resolution of time() can occasionally cause cache
+ * problems when the user requests two pages within a short period of time. This
+ * variable adds a given number of seconds to vulnerable timestamps, thereby giving
+ * a grace period.
+ */
+$wgClockSkewFudge = 5;
+
+/**
+ * Invalidate various caches when LocalSettings.php changes. This is equivalent
+ * to setting $wgCacheEpoch to the modification time of LocalSettings.php, as
+ * was previously done in the default LocalSettings.php file.
+ *
+ * On high-traffic wikis, this should be set to false, to avoid the need to
+ * check the file modification time, and to avoid the performance impact of
+ * unnecessary cache invalidations.
+ */
+$wgInvalidateCacheOnLocalSettingsChange = true;
+
+/**
+ * When loading extensions through the extension registration system, this
+ * can be used to invalidate the cache. A good idea would be to set this to
+ * one file, you can just `touch` that one to invalidate the cache
+ *
+ * @par Example:
+ * @code
+ * $wgExtensionInfoMtime = filemtime( "$IP/LocalSettings.php" );
+ * @endcode
+ *
+ * If set to false, the mtime for each individual JSON file will be checked,
+ * which can be slow if a large number of extensions are being loaded.
+ *
+ * @var int|bool
+ */
+$wgExtensionInfoMTime = false;
+
+/** @} */ # end of cache settings
+
+/************************************************************************//**
+ * @name HTTP proxy (CDN) settings
+ *
+ * Many of these settings apply to any HTTP proxy used in front of MediaWiki,
+ * although they are referred to as Squid settings for historical reasons.
+ *
+ * Achieving a high hit ratio with an HTTP proxy requires special
+ * configuration. See https://www.mediawiki.org/wiki/Manual:Squid_caching for
+ * more details.
+ *
+ * @{
+ */
+
+/**
+ * Enable/disable CDN.
+ * See https://www.mediawiki.org/wiki/Manual:Squid_caching
+ */
+$wgUseSquid = false;
+
+/**
+ * If you run Squid3 with ESI support, enable this (default:false):
+ */
+$wgUseESI = false;
+
+/**
+ * Send the Key HTTP header for better caching.
+ * See https://datatracker.ietf.org/doc/draft-fielding-http-key/ for details.
+ * @since 1.27
+ */
+$wgUseKeyHeader = false;
+
+/**
+ * Add X-Forwarded-Proto to the Vary and Key headers for API requests and
+ * RSS/Atom feeds. Use this if you have an SSL termination setup
+ * and need to split the cache between HTTP and HTTPS for API requests,
+ * feed requests and HTTP redirect responses in order to prevent cache
+ * pollution. This does not affect 'normal' requests to index.php other than
+ * HTTP redirects.
+ */
+$wgVaryOnXFP = false;
+
+/**
+ * Internal server name as known to CDN, if different.
+ *
+ * @par Example:
+ * @code
+ * $wgInternalServer = 'http://yourinternal.tld:8000';
+ * @endcode
+ */
+$wgInternalServer = false;
+
+/**
+ * Cache TTL for the CDN sent as s-maxage (without ESI) or
+ * Surrogate-Control (with ESI). Without ESI, you should strip
+ * out s-maxage in the CDN config.
+ *
+ * 18000 seconds = 5 hours, more cache hits with 2678400 = 31 days.
+ */
+$wgSquidMaxage = 18000;
+
+/**
+ * Cache timeout for the CDN when DB replica DB lag is high
+ * @see $wgSquidMaxage
+ * @since 1.27
+ */
+$wgCdnMaxageLagged = 30;
+
+/**
+ * If set, any SquidPurge call on a URL or URLs will send a second purge no less than
+ * this many seconds later via the job queue. This requires delayed job support.
+ * This should be safely higher than the 'max lag' value in $wgLBFactoryConf, so that
+ * replica DB lag does not cause page to be stuck in stales states in CDN.
+ *
+ * This also fixes race conditions in two-tiered CDN setups (e.g. cdn2 => cdn1 => MediaWiki).
+ * If a purge for a URL reaches cdn2 before cdn1 and a request reaches cdn2 for that URL,
+ * it will populate the response from the stale cdn1 value. When cdn1 gets the purge, cdn2
+ * will still be stale. If the rebound purge delay is safely higher than the time to relay
+ * a purge to all nodes, then the rebound puge will clear cdn2 after cdn1 was cleared.
+ *
+ * @since 1.27
+ */
+$wgCdnReboundPurgeDelay = 0;
+
+/**
+ * Cache timeout for the CDN when a response is known to be wrong or incomplete (due to load)
+ * @see $wgSquidMaxage
+ * @since 1.27
+ */
+$wgCdnMaxageSubstitute = 60;
+
+/**
+ * Default maximum age for raw CSS/JS accesses
+ *
+ * 300 seconds = 5 minutes.
+ */
+$wgForcedRawSMaxage = 300;
+
+/**
+ * List of proxy servers to purge on changes; default port is 80. Use IP addresses.
+ *
+ * When MediaWiki is running behind a proxy, it will trust X-Forwarded-For
+ * headers sent/modified from these proxies when obtaining the remote IP address
+ *
+ * For a list of trusted servers which *aren't* purged, see $wgSquidServersNoPurge.
+ */
+$wgSquidServers = [];
+
+/**
+ * As above, except these servers aren't purged on page changes; use to set a
+ * list of trusted proxies, etc. Supports both individual IP addresses and
+ * CIDR blocks.
+ * @since 1.23 Supports CIDR ranges
+ */
+$wgSquidServersNoPurge = [];
+
+/**
+ * Whether to use a Host header in purge requests sent to the proxy servers
+ * configured in $wgSquidServers. Set this to false to support Squid
+ * configured in forward-proxy mode.
+ *
+ * If this is set to true, a Host header will be sent, and only the path
+ * component of the URL will appear on the request line, as if the request
+ * were a non-proxy HTTP 1.1 request. Varnish only supports this style of
+ * request. Squid supports this style of request only if reverse-proxy mode
+ * (http_port ... accel) is enabled.
+ *
+ * If this is set to false, no Host header will be sent, and the absolute URL
+ * will be sent in the request line, as is the standard for an HTTP proxy
+ * request in both HTTP 1.0 and 1.1. This style of request is not supported
+ * by Varnish, but is supported by Squid in either configuration (forward or
+ * reverse).
+ *
+ * @since 1.21
+ */
+$wgSquidPurgeUseHostHeader = true;
+
+/**
+ * Routing configuration for HTCP multicast purging. Add elements here to
+ * enable HTCP and determine which purges are sent where. If set to an empty
+ * array, HTCP is disabled.
+ *
+ * Each key in this array is a regular expression to match against the purged
+ * URL, or an empty string to match all URLs. The purged URL is matched against
+ * the regexes in the order specified, and the first rule whose regex matches
+ * is used, all remaining rules will thus be ignored.
+ *
+ * @par Example configuration to send purges for upload.wikimedia.org to one
+ * multicast group and all other purges to another:
+ * @code
+ * $wgHTCPRouting = [
+ * '|^https?://upload\.wikimedia\.org|' => [
+ * 'host' => '239.128.0.113',
+ * 'port' => 4827,
+ * ],
+ * '' => [
+ * 'host' => '239.128.0.112',
+ * 'port' => 4827,
+ * ],
+ * ];
+ * @endcode
+ *
+ * You can also pass an array of hosts to send purges too. This is useful when
+ * you have several multicast groups or unicast address that should receive a
+ * given purge. Multiple hosts support was introduced in MediaWiki 1.22.
+ *
+ * @par Example of sending purges to multiple hosts:
+ * @code
+ * $wgHTCPRouting = [
+ * '' => [
+ * // Purges to text caches using multicast
+ * [ 'host' => '239.128.0.114', 'port' => '4827' ],
+ * // Purges to a hardcoded list of caches
+ * [ 'host' => '10.88.66.1', 'port' => '4827' ],
+ * [ 'host' => '10.88.66.2', 'port' => '4827' ],
+ * [ 'host' => '10.88.66.3', 'port' => '4827' ],
+ * ],
+ * ];
+ * @endcode
+ *
+ * @since 1.22
+ *
+ * $wgHTCPRouting replaces $wgHTCPMulticastRouting that was introduced in 1.20.
+ * For back compatibility purposes, whenever its array is empty
+ * $wgHTCPMutlicastRouting will be used as a fallback if it not null.
+ *
+ * @see $wgHTCPMulticastTTL
+ */
+$wgHTCPRouting = [];
+
+/**
+ * HTCP multicast TTL.
+ * @see $wgHTCPRouting
+ */
+$wgHTCPMulticastTTL = 1;
+
+/**
+ * Should forwarded Private IPs be accepted?
+ */
+$wgUsePrivateIPs = false;
+
+/** @} */ # end of HTTP proxy settings
+
+/************************************************************************//**
+ * @name Language, regional and character encoding settings
+ * @{
+ */
+
+/**
+ * Site language code. See languages/data/Names.php for languages supported by
+ * MediaWiki out of the box. Not all languages listed there have translations,
+ * see languages/messages/ for the list of languages with some localisation.
+ *
+ * Warning: Don't use any of MediaWiki's deprecated language codes listed in
+ * LanguageCode::getDeprecatedCodeMapping or $wgDummyLanguageCodes, like "no"
+ * for Norwegian (use "nb" instead). If you do, things will break unexpectedly.
+ *
+ * This defines the default interface language for all users, but users can
+ * change it in their preferences.
+ *
+ * This also defines the language of pages in the wiki. The content is wrapped
+ * in a html element with lang=XX attribute. This behavior can be overridden
+ * via hooks, see Title::getPageLanguage.
+ */
+$wgLanguageCode = 'en';
+
+/**
+ * Language cache size, or really how many languages can we handle
+ * simultaneously without degrading to crawl speed.
+ */
+$wgLangObjCacheSize = 10;
+
+/**
+ * Some languages need different word forms, usually for different cases.
+ * Used in Language::convertGrammar().
+ *
+ * @par Example:
+ * @code
+ * $wgGrammarForms['en']['genitive']['car'] = 'car\'s';
+ * @endcode
+ */
+$wgGrammarForms = [];
+
+/**
+ * Treat language links as magic connectors, not inline links
+ */
+$wgInterwikiMagic = true;
+
+/**
+ * Hide interlanguage links from the sidebar
+ */
+$wgHideInterlanguageLinks = false;
+
+/**
+ * List of additional interwiki prefixes that should be treated as
+ * interlanguage links (i.e. placed in the sidebar).
+ * Notes:
+ * - This will not do anything unless the prefixes are defined in the interwiki
+ * map.
+ * - The display text for these custom interlanguage links will be fetched from
+ * the system message "interlanguage-link-xyz" where xyz is the prefix in
+ * this array.
+ * - A friendly name for each site, used for tooltip text, may optionally be
+ * placed in the system message "interlanguage-link-sitename-xyz" where xyz is
+ * the prefix in this array.
+ */
+$wgExtraInterlanguageLinkPrefixes = [];
+
+/**
+ * List of language names or overrides for default names in Names.php
+ */
+$wgExtraLanguageNames = [];
+
+/**
+ * List of mappings from one language code to another.
+ * This array makes the codes not appear as a selectable language on the
+ * installer, and excludes them when running the transstat.php script.
+ *
+ * In Setup.php, the variable $wgDummyLanguageCodes is created by combining
+ * these codes with a list of "deprecated" codes, which are mostly leftovers
+ * from renames or other legacy things, and the internal codes 'qqq' and 'qqx'.
+ * If a mapping in $wgExtraLanguageCodes collide with a built-in mapping, the
+ * value in $wgExtraLanguageCodes will be used.
+ *
+ * @since 1.29
+ */
+$wgExtraLanguageCodes = [
+ 'bh' => 'bho', // Bihari language family
+ 'no' => 'nb', // Norwegian language family
+ 'simple' => 'en', // Simple English
+];
+
+/**
+ * Functionally the same as $wgExtraLanguageCodes, but deprecated. Instead of
+ * appending values to this array, append them to $wgExtraLanguageCodes.
+ *
+ * @deprecated since 1.29
+ */
+$wgDummyLanguageCodes = [];
+
+/**
+ * Set this to true to replace Arabic presentation forms with their standard
+ * forms in the U+0600-U+06FF block. This only works if $wgLanguageCode is
+ * set to "ar".
+ *
+ * Note that pages with titles containing presentation forms will become
+ * inaccessible, run maintenance/cleanupTitles.php to fix this.
+ */
+$wgFixArabicUnicode = true;
+
+/**
+ * Set this to true to replace ZWJ-based chillu sequences in Malayalam text
+ * with their Unicode 5.1 equivalents. This only works if $wgLanguageCode is
+ * set to "ml". Note that some clients (even new clients as of 2010) do not
+ * support these characters.
+ *
+ * If you enable this on an existing wiki, run maintenance/cleanupTitles.php to
+ * fix any ZWJ sequences in existing page titles.
+ */
+$wgFixMalayalamUnicode = true;
+
+/**
+ * Set this to always convert certain Unicode sequences to modern ones
+ * regardless of the content language. This has a small performance
+ * impact.
+ *
+ * See $wgFixArabicUnicode and $wgFixMalayalamUnicode for conversion
+ * details.
+ *
+ * @since 1.17
+ */
+$wgAllUnicodeFixes = false;
+
+/**
+ * Set this to eg 'ISO-8859-1' to perform character set conversion when
+ * loading old revisions not marked with "utf-8" flag. Use this when
+ * converting a wiki from MediaWiki 1.4 or earlier to UTF-8 without the
+ * burdensome mass conversion of old text data.
+ *
+ * @note This DOES NOT touch any fields other than old_text. Titles, comments,
+ * user names, etc still must be converted en masse in the database before
+ * continuing as a UTF-8 wiki.
+ */
+$wgLegacyEncoding = false;
+
+/**
+ * @deprecated since 1.30, does nothing
+ */
+$wgBrowserBlackList = [];
+
+/**
+ * If set to true, the MediaWiki 1.4 to 1.5 schema conversion will
+ * create stub reference rows in the text table instead of copying
+ * the full text of all current entries from 'cur' to 'text'.
+ *
+ * This will speed up the conversion step for large sites, but
+ * requires that the cur table be kept around for those revisions
+ * to remain viewable.
+ *
+ * This option affects the updaters *only*. Any present cur stub
+ * revisions will be readable at runtime regardless of this setting.
+ */
+$wgLegacySchemaConversion = false;
+
+/**
+ * Enable dates like 'May 12' instead of '12 May', if the default date format
+ * is 'dmy or mdy'.
+ */
+$wgAmericanDates = false;
+
+/**
+ * For Hindi and Arabic use local numerals instead of Western style (0-9)
+ * numerals in interface.
+ */
+$wgTranslateNumerals = true;
+
+/**
+ * Translation using MediaWiki: namespace.
+ * Interface messages will be loaded from the database.
+ */
+$wgUseDatabaseMessages = true;
+
+/**
+ * Expiry time for the message cache key
+ */
+$wgMsgCacheExpiry = 86400;
+
+/**
+ * Maximum entry size in the message cache, in bytes
+ */
+$wgMaxMsgCacheEntrySize = 10000;
+
+/**
+ * Whether to enable language variant conversion.
+ */
+$wgDisableLangConversion = false;
+
+/**
+ * Whether to enable language variant conversion for links.
+ */
+$wgDisableTitleConversion = false;
+
+/**
+ * Default variant code, if false, the default will be the language code
+ */
+$wgDefaultLanguageVariant = false;
+
+/**
+ * Whether to enable the pig latin variant of English (en-x-piglatin),
+ * used to ease variant development work.
+ */
+$wgUsePigLatinVariant = false;
+
+/**
+ * Disabled variants array of language variant conversion.
+ *
+ * @par Example:
+ * @code
+ * $wgDisabledVariants[] = 'zh-mo';
+ * $wgDisabledVariants[] = 'zh-my';
+ * @endcode
+ */
+$wgDisabledVariants = [];
+
+/**
+ * Like $wgArticlePath, but on multi-variant wikis, this provides a
+ * path format that describes which parts of the URL contain the
+ * language variant.
+ *
+ * @par Example:
+ * @code
+ * $wgLanguageCode = 'sr';
+ * $wgVariantArticlePath = '/$2/$1';
+ * $wgArticlePath = '/wiki/$1';
+ * @endcode
+ *
+ * A link to /wiki/ would be redirected to /sr/Главна_страна
+ *
+ * It is important that $wgArticlePath not overlap with possible values
+ * of $wgVariantArticlePath.
+ */
+$wgVariantArticlePath = false;
+
+/**
+ * Show a bar of language selection links in the user login and user
+ * registration forms; edit the "loginlanguagelinks" message to
+ * customise these.
+ */
+$wgLoginLanguageSelector = false;
+
+/**
+ * When translating messages with wfMessage(), it is not always clear what
+ * should be considered UI messages and what should be content messages.
+ *
+ * For example, for the English Wikipedia, there should be only one 'mainpage',
+ * so when getting the link for 'mainpage', we should treat it as site content
+ * and call ->inContentLanguage()->text(), but for rendering the text of the
+ * link, we call ->text(). The code behaves this way by default. However,
+ * sites like the Wikimedia Commons do offer different versions of 'mainpage'
+ * and the like for different languages. This array provides a way to override
+ * the default behavior.
+ *
+ * @par Example:
+ * To allow language-specific main page and community
+ * portal:
+ * @code
+ * $wgForceUIMsgAsContentMsg = [ 'mainpage', 'portal-url' ];
+ * @endcode
+ */
+$wgForceUIMsgAsContentMsg = [];
+
+/**
+ * Fake out the timezone that the server thinks it's in. This will be used for
+ * date display and not for what's stored in the DB. Leave to null to retain
+ * your server's OS-based timezone value.
+ *
+ * This variable is currently used only for signature formatting and for local
+ * time/date parser variables ({{LOCALTIME}} etc.)
+ *
+ * Timezones can be translated by editing MediaWiki messages of type
+ * timezone-nameinlowercase like timezone-utc.
+ *
+ * A list of usable timezones can found at:
+ * https://secure.php.net/manual/en/timezones.php
+ *
+ * @par Examples:
+ * @code
+ * $wgLocaltimezone = 'UTC';
+ * $wgLocaltimezone = 'GMT';
+ * $wgLocaltimezone = 'PST8PDT';
+ * $wgLocaltimezone = 'Europe/Sweden';
+ * $wgLocaltimezone = 'CET';
+ * @endcode
+ */
+$wgLocaltimezone = null;
+
+/**
+ * Set an offset from UTC in minutes to use for the default timezone setting
+ * for anonymous users and new user accounts.
+ *
+ * This setting is used for most date/time displays in the software, and is
+ * overridable in user preferences. It is *not* used for signature timestamps.
+ *
+ * By default, this will be set to match $wgLocaltimezone.
+ */
+$wgLocalTZoffset = null;
+
+/** @} */ # End of language/charset settings
+
+/*************************************************************************//**
+ * @name Output format and skin settings
+ * @{
+ */
+
+/**
+ * The default Content-Type header.
+ */
+$wgMimeType = 'text/html';
+
+/**
+ * Previously used as content type in HTML script tags. This is now ignored since
+ * HTML5 doesn't require a MIME type for script tags (javascript is the default).
+ * It was also previously used by RawAction to determine the ctype query parameter
+ * value that will result in a javascript response.
+ * @deprecated since 1.22
+ */
+$wgJsMimeType = null;
+
+/**
+ * The default xmlns attribute. The option to define this has been removed.
+ * The value of this variable is no longer used by core and is set to a fixed
+ * value in Setup.php for compatibility with extensions that depend on the value
+ * of this variable being set. Such a dependency however is deprecated.
+ * @deprecated since 1.22
+ */
+$wgXhtmlDefaultNamespace = null;
+
+/**
+ * Previously used to determine if we should output an HTML5 doctype.
+ * This is no longer used as we always output HTML5 now. For compatibility with
+ * extensions that still check the value of this config it's value is now forced
+ * to true by Setup.php.
+ * @deprecated since 1.22
+ */
+$wgHtml5 = true;
+
+/**
+ * Defines the value of the version attribute in the &lt;html&gt; tag, if any.
+ *
+ * If your wiki uses RDFa, set it to the correct value for RDFa+HTML5.
+ * Correct current values are 'HTML+RDFa 1.0' or 'XHTML+RDFa 1.0'.
+ * See also https://www.w3.org/TR/rdfa-in-html/#document-conformance
+ * @since 1.16
+ */
+$wgHtml5Version = null;
+
+/**
+ * Temporary variable that allows HTMLForms to be rendered as tables.
+ * Table based layouts cause various issues when designing for mobile.
+ * This global allows skins or extensions a means to force non-table based rendering.
+ * Setting to false forces form components to always render as div elements.
+ * @since 1.24
+ */
+$wgHTMLFormAllowTableFormat = true;
+
+/**
+ * Temporary variable that applies MediaWiki UI wherever it can be supported.
+ * Temporary variable that should be removed when mediawiki ui is more
+ * stable and change has been communicated.
+ * @since 1.24
+ */
+$wgUseMediaWikiUIEverywhere = false;
+
+/**
+ * Whether to label the store-to-database-and-show-to-others button in the editor
+ * as "Save page"/"Save changes" if false (the default) or, if true, instead as
+ * "Publish page"/"Publish changes".
+ *
+ * @since 1.28
+ */
+$wgEditSubmitButtonLabelPublish = false;
+
+/**
+ * Permit other namespaces in addition to the w3.org default.
+ *
+ * Use the prefix for the key and the namespace for the value.
+ *
+ * @par Example:
+ * @code
+ * $wgXhtmlNamespaces['svg'] = 'http://www.w3.org/2000/svg';
+ * @endcode
+ * Normally we wouldn't have to define this in the root "<html>"
+ * element, but IE needs it there in some circumstances.
+ *
+ * This is ignored if $wgMimeType is set to a non-XML MIME type.
+ */
+$wgXhtmlNamespaces = [];
+
+/**
+ * Site notice shown at the top of each page
+ *
+ * MediaWiki:Sitenotice page, which will override this. You can also
+ * provide a separate message for logged-out users using the
+ * MediaWiki:Anonnotice page.
+ */
+$wgSiteNotice = '';
+
+/**
+ * If this is set, a "donate" link will appear in the sidebar. Set it to a URL.
+ */
+$wgSiteSupportPage = '';
+
+/**
+ * Validate the overall output using tidy and refuse
+ * to display the page if it's not valid.
+ */
+$wgValidateAllHtml = false;
+
+/**
+ * Default skin, for new users and anonymous visitors. Registered users may
+ * change this to any one of the other available skins in their preferences.
+ */
+$wgDefaultSkin = 'vector';
+
+/**
+ * Fallback skin used when the skin defined by $wgDefaultSkin can't be found.
+ *
+ * @since 1.24
+ */
+$wgFallbackSkin = 'fallback';
+
+/**
+ * Specify the names of skins that should not be presented in the list of
+ * available skins in user preferences. If you want to remove a skin entirely,
+ * remove it from the skins/ directory and its entry from LocalSettings.php.
+ */
+$wgSkipSkins = [];
+
+/**
+ * @deprecated since 1.23; use $wgSkipSkins instead
+ */
+$wgSkipSkin = '';
+
+/**
+ * Allow user Javascript page?
+ * This enables a lot of neat customizations, but may
+ * increase security risk to users and server load.
+ */
+$wgAllowUserJs = false;
+
+/**
+ * Allow user Cascading Style Sheets (CSS)?
+ * This enables a lot of neat customizations, but may
+ * increase security risk to users and server load.
+ */
+$wgAllowUserCss = false;
+
+/**
+ * Allow user-preferences implemented in CSS?
+ * This allows users to customise the site appearance to a greater
+ * degree; disabling it will improve page load times.
+ */
+$wgAllowUserCssPrefs = true;
+
+/**
+ * Use the site's Javascript page?
+ */
+$wgUseSiteJs = true;
+
+/**
+ * Use the site's Cascading Style Sheets (CSS)?
+ */
+$wgUseSiteCss = true;
+
+/**
+ * Break out of framesets. This can be used to prevent clickjacking attacks,
+ * or to prevent external sites from framing your site with ads.
+ */
+$wgBreakFrames = false;
+
+/**
+ * The X-Frame-Options header to send on pages sensitive to clickjacking
+ * attacks, such as edit pages. This prevents those pages from being displayed
+ * in a frame or iframe. The options are:
+ *
+ * - 'DENY': Do not allow framing. This is recommended for most wikis.
+ *
+ * - 'SAMEORIGIN': Allow framing by pages on the same domain. This can be used
+ * to allow framing within a trusted domain. This is insecure if there
+ * is a page on the same domain which allows framing of arbitrary URLs.
+ *
+ * - false: Allow all framing. This opens up the wiki to XSS attacks and thus
+ * full compromise of local user accounts. Private wikis behind a
+ * corporate firewall are especially vulnerable. This is not
+ * recommended.
+ *
+ * For extra safety, set $wgBreakFrames = true, to prevent framing on all pages,
+ * not just edit pages.
+ */
+$wgEditPageFrameOptions = 'DENY';
+
+/**
+ * Disallow framing of API pages directly, by setting the X-Frame-Options
+ * header. Since the API returns CSRF tokens, allowing the results to be
+ * framed can compromise your user's account security.
+ * Options are:
+ * - 'DENY': Do not allow framing. This is recommended for most wikis.
+ * - 'SAMEORIGIN': Allow framing by pages on the same domain.
+ * - false: Allow all framing.
+ * Note: $wgBreakFrames will override this for human formatted API output.
+ */
+$wgApiFrameOptions = 'DENY';
+
+/**
+ * Disable output compression (enabled by default if zlib is available)
+ */
+$wgDisableOutputCompression = false;
+
+/**
+ * Abandoned experiment with HTML5-style ID escaping. Normalized IDs a bit
+ * too aggressively, breaking preexisting content (particularly Cite).
+ * See T29733, T29694, T29474.
+ *
+ * @deprecated since 1.30, use $wgFragmentMode
+ */
+$wgExperimentalHtmlIds = false;
+
+/**
+ * How should section IDs be encoded?
+ * This array can contain 1 or 2 elements, each of them can be one of:
+ * - 'html5' is modern HTML5 style encoding with minimal escaping. Displays Unicode
+ * characters in most browsers' address bars.
+ * - 'legacy' is old MediaWiki-style encoding, e.g. 啤酒 turns into .E5.95.A4.E9.85.92
+ * - 'html5-legacy' corresponds to DEPRECATED $wgExperimentalHtmlIds mode. DO NOT use
+ * it for anything but migration off that mode (see below).
+ *
+ * The first element of this array specifies the primary mode of escaping IDs. This
+ * is what users will see when they e.g. follow an [[#internal link]] to a section of
+ * a page.
+ *
+ * The optional second element defines a fallback mode, useful for migrations.
+ * If present, it will direct MediaWiki to add empty <span>s to every section with its
+ * id attribute set to fallback encoded title so that links using the previous encoding
+ * would still work.
+ *
+ * Example: you want to migrate your wiki from 'legacy' to 'html5'
+ *
+ * On the first step, set this variable to [ 'legacy', 'html5' ]. After a while, when
+ * all caches (parser, HTTP, etc.) contain only pages generated with this setting,
+ * flip the value to [ 'html5', 'legacy' ]. This will result in all internal links being
+ * generated in the new encoding while old links (both external and cached internal) will
+ * still work. After a long time, you might want to ditch backwards compatibility and
+ * set it to [ 'html5' ]. After all, pages get edited, breaking incoming links no matter which
+ * fragment mode is used.
+ *
+ * @since 1.30
+ */
+$wgFragmentMode = [ 'legacy' ];
+
+/**
+ * Which ID escaping mode should be used for external interwiki links? See documentation
+ * for $wgFragmentMode above for details of each mode. Because you can't control external sites,
+ * this setting should probably always be 'legacy', unless every wiki you link to has converted
+ * to 'html5'.
+ *
+ * @since 1.30
+ */
+$wgExternalInterwikiFragmentMode = 'legacy';
+
+/**
+ * Abstract list of footer icons for skins in place of old copyrightico and poweredbyico code
+ * You can add new icons to the built in copyright or poweredby, or you can create
+ * a new block. Though note that you may need to add some custom css to get good styling
+ * of new blocks in monobook. vector and modern should work without any special css.
+ *
+ * $wgFooterIcons itself is a key/value array.
+ * The key is the name of a block that the icons will be wrapped in. The final id varies
+ * by skin; Monobook and Vector will turn poweredby into f-poweredbyico while Modern
+ * turns it into mw_poweredby.
+ * The value is either key/value array of icons or a string.
+ * In the key/value array the key may or may not be used by the skin but it can
+ * be used to find the icon and unset it or change the icon if needed.
+ * This is useful for disabling icons that are set by extensions.
+ * The value should be either a string or an array. If it is a string it will be output
+ * directly as html, however some skins may choose to ignore it. An array is the preferred format
+ * for the icon, the following keys are used:
+ * - src: An absolute url to the image to use for the icon, this is recommended
+ * but not required, however some skins will ignore icons without an image
+ * - srcset: optional additional-resolution images; see HTML5 specs
+ * - url: The url to use in the a element around the text or icon, if not set an a element will
+ * not be outputted
+ * - alt: This is the text form of the icon, it will be displayed without an image in
+ * skins like Modern or if src is not set, and will otherwise be used as
+ * the alt="" for the image. This key is required.
+ * - width and height: If the icon specified by src is not of the standard size
+ * you can specify the size of image to use with these keys.
+ * Otherwise they will default to the standard 88x31.
+ * @todo Reformat documentation.
+ */
+$wgFooterIcons = [
+ "copyright" => [
+ "copyright" => [], // placeholder for the built in copyright icon
+ ],
+ "poweredby" => [
+ "mediawiki" => [
+ // Defaults to point at
+ // "$wgResourceBasePath/resources/assets/poweredby_mediawiki_88x31.png"
+ // plus srcset for 1.5x, 2x resolution variants.
+ "src" => null,
+ "url" => "//www.mediawiki.org/",
+ "alt" => "Powered by MediaWiki",
+ ]
+ ],
+];
+
+/**
+ * Login / create account link behavior when it's possible for anonymous users
+ * to create an account.
+ * - true = use a combined login / create account link
+ * - false = split login and create account into two separate links
+ */
+$wgUseCombinedLoginLink = false;
+
+/**
+ * Display user edit counts in various prominent places.
+ */
+$wgEdititis = false;
+
+/**
+ * Some web hosts attempt to rewrite all responses with a 404 (not found)
+ * status code, mangling or hiding MediaWiki's output. If you are using such a
+ * host, you should start looking for a better one. While you're doing that,
+ * set this to false to convert some of MediaWiki's 404 responses to 200 so
+ * that the generated error pages can be seen.
+ *
+ * In cases where for technical reasons it is more important for MediaWiki to
+ * send the correct status code than for the body to be transmitted intact,
+ * this configuration variable is ignored.
+ */
+$wgSend404Code = true;
+
+/**
+ * The $wgShowRollbackEditCount variable is used to show how many edits can be rolled back.
+ * The numeric value of the variable controls how many edits MediaWiki will look back to
+ * determine whether a rollback is allowed (by checking that they are all from the same author).
+ * If the value is false or 0, the edits are not counted. Disabling this will prevent MediaWiki
+ * from hiding some useless rollback links.
+ *
+ * @since 1.20
+ */
+$wgShowRollbackEditCount = 10;
+
+/**
+ * Output a <link rel="canonical"> tag on every page indicating the canonical
+ * server which should be used, i.e. $wgServer or $wgCanonicalServer. Since
+ * detection of the current server is unreliable, the link is sent
+ * unconditionally.
+ */
+$wgEnableCanonicalServerLink = false;
+
+/**
+ * When OutputHandler is used, mangle any output that contains
+ * <cross-domain-policy>. Without this, an attacker can send their own
+ * cross-domain policy unless it is prevented by the crossdomain.xml file at
+ * the domain root.
+ *
+ * @since 1.25
+ */
+$wgMangleFlashPolicy = true;
+
+/** @} */ # End of output format settings }
+
+/*************************************************************************//**
+ * @name ResourceLoader settings
+ * @{
+ */
+
+/**
+ * Client-side resource modules.
+ *
+ * Extensions should add their ResourceLoader module definitions
+ * to the $wgResourceModules variable.
+ *
+ * @par Example:
+ * @code
+ * $wgResourceModules['ext.myExtension'] = [
+ * 'scripts' => 'myExtension.js',
+ * 'styles' => 'myExtension.css',
+ * 'dependencies' => [ 'jquery.cookie', 'jquery.tabIndex' ],
+ * 'localBasePath' => __DIR__,
+ * 'remoteExtPath' => 'MyExtension',
+ * ];
+ * @endcode
+ */
+$wgResourceModules = [];
+
+/**
+ * Skin-specific styles for resource modules.
+ *
+ * These are later added to the 'skinStyles' list of the existing module. The 'styles' list can
+ * not be modified or disabled.
+ *
+ * For example, here is a module "bar" and how skin Foo would provide additional styles for it.
+ *
+ * @par Example:
+ * @code
+ * $wgResourceModules['bar'] = [
+ * 'scripts' => 'resources/bar/bar.js',
+ * 'styles' => 'resources/bar/main.css',
+ * ];
+ *
+ * $wgResourceModuleSkinStyles['foo'] = [
+ * 'bar' => 'skins/Foo/bar.css',
+ * ];
+ * @endcode
+ *
+ * This is mostly equivalent to:
+ *
+ * @par Equivalent:
+ * @code
+ * $wgResourceModules['bar'] = [
+ * 'scripts' => 'resources/bar/bar.js',
+ * 'styles' => 'resources/bar/main.css',
+ * 'skinStyles' => [
+ * 'foo' => skins/Foo/bar.css',
+ * ],
+ * ];
+ * @endcode
+ *
+ * If the module already defines its own entry in `skinStyles` for a given skin, then
+ * $wgResourceModuleSkinStyles is ignored.
+ *
+ * If a module defines a `skinStyles['default']` the skin may want to extend that instead
+ * of replacing them. This can be done using the `+` prefix.
+ *
+ * @par Example:
+ * @code
+ * $wgResourceModules['bar'] = [
+ * 'scripts' => 'resources/bar/bar.js',
+ * 'styles' => 'resources/bar/basic.css',
+ * 'skinStyles' => [
+ * 'default' => 'resources/bar/additional.css',
+ * ],
+ * ];
+ * // Note the '+' character:
+ * $wgResourceModuleSkinStyles['foo'] = [
+ * '+bar' => 'skins/Foo/bar.css',
+ * ];
+ * @endcode
+ *
+ * This is mostly equivalent to:
+ *
+ * @par Equivalent:
+ * @code
+ * $wgResourceModules['bar'] = [
+ * 'scripts' => 'resources/bar/bar.js',
+ * 'styles' => 'resources/bar/basic.css',
+ * 'skinStyles' => [
+ * 'default' => 'resources/bar/additional.css',
+ * 'foo' => [
+ * 'resources/bar/additional.css',
+ * 'skins/Foo/bar.css',
+ * ],
+ * ],
+ * ];
+ * @endcode
+ *
+ * In other words, as a module author, use the `styles` list for stylesheets that may not be
+ * disabled by a skin. To provide default styles that may be extended or replaced,
+ * use `skinStyles['default']`.
+ *
+ * As with $wgResourceModules, paths default to being relative to the MediaWiki root.
+ * You should always provide a localBasePath and remoteBasePath (or remoteExtPath/remoteSkinPath).
+ *
+ * @par Example:
+ * @code
+ * $wgResourceModuleSkinStyles['foo'] = [
+ * 'bar' => 'bar.css',
+ * 'quux' => 'quux.css',
+ * 'remoteSkinPath' => 'Foo',
+ * 'localBasePath' => __DIR__,
+ * ];
+ * @endcode
+ */
+$wgResourceModuleSkinStyles = [];
+
+/**
+ * Extensions should register foreign module sources here. 'local' is a
+ * built-in source that is not in this array, but defined by
+ * ResourceLoader::__construct() so that it cannot be unset.
+ *
+ * @par Example:
+ * @code
+ * $wgResourceLoaderSources['foo'] = 'http://example.org/w/load.php';
+ * @endcode
+ */
+$wgResourceLoaderSources = [];
+
+/**
+ * The default 'remoteBasePath' value for instances of ResourceLoaderFileModule.
+ * Defaults to $wgScriptPath.
+ */
+$wgResourceBasePath = null;
+
+/**
+ * Maximum time in seconds to cache resources served by ResourceLoader.
+ * Used to set last modified headers (max-age/s-maxage).
+ *
+ * Following options to distinguish:
+ * - versioned: Used for modules with a version, because changing version
+ * numbers causes cache misses. This normally has a long expiry time.
+ * - unversioned: Used for modules without a version to propagate changes
+ * quickly to clients. Also used for modules with errors to recover quickly.
+ * This normally has a short expiry time.
+ *
+ * Expiry time for the options to distinguish:
+ * - server: Squid/Varnish but also any other public proxy cache between the
+ * client and MediaWiki.
+ * - client: On the client side (e.g. in the browser cache).
+ */
+$wgResourceLoaderMaxage = [
+ 'versioned' => [
+ 'server' => 30 * 24 * 60 * 60, // 30 days
+ 'client' => 30 * 24 * 60 * 60, // 30 days
+ ],
+ 'unversioned' => [
+ 'server' => 5 * 60, // 5 minutes
+ 'client' => 5 * 60, // 5 minutes
+ ],
+];
+
+/**
+ * The default debug mode (on/off) for of ResourceLoader requests.
+ *
+ * This will still be overridden when the debug URL parameter is used.
+ */
+$wgResourceLoaderDebug = false;
+
+/**
+ * Put each statement on its own line when minifying JavaScript. This makes
+ * debugging in non-debug mode a bit easier.
+ *
+ * @deprecated since 1.27: Always false; no longer configurable.
+ */
+$wgResourceLoaderMinifierStatementsOnOwnLine = false;
+
+/**
+ * Maximum line length when minifying JavaScript. This is not a hard maximum:
+ * the minifier will try not to produce lines longer than this, but may be
+ * forced to do so in certain cases.
+ *
+ * @deprecated since 1.27: Always 1,000; no longer configurable.
+ */
+$wgResourceLoaderMinifierMaxLineLength = 1000;
+
+/**
+ * Whether to ensure the mediawiki.legacy library is loaded before other modules.
+ *
+ * @deprecated since 1.26: Always declare dependencies.
+ */
+$wgIncludeLegacyJavaScript = false;
+
+/**
+ * Use jQuery 3 (with jQuery Migrate) instead of jQuery 1.
+ *
+ * This is a temporary feature flag for the MediaWiki 1.29 development cycle while
+ * instabilities with jQuery 3 are being addressed. See T124742.
+ *
+ * @deprecated since 1.29
+ */
+$wgUsejQueryThree = true;
+
+/**
+ * Whether or not to assign configuration variables to the global window object.
+ *
+ * If this is set to false, old code using deprecated variables will no longer
+ * work.
+ *
+ * @par Example of legacy code:
+ * @code{,js}
+ * if ( window.wgRestrictionEdit ) { ... }
+ * @endcode
+ * or:
+ * @code{,js}
+ * if ( wgIsArticle ) { ... }
+ * @endcode
+ *
+ * Instead, one needs to use mw.config.
+ * @par Example using mw.config global configuration:
+ * @code{,js}
+ * if ( mw.config.exists('wgRestrictionEdit') ) { ... }
+ * @endcode
+ * or:
+ * @code{,js}
+ * if ( mw.config.get('wgIsArticle') ) { ... }
+ * @endcode
+ */
+$wgLegacyJavaScriptGlobals = true;
+
+/**
+ * If set to a positive number, ResourceLoader will not generate URLs whose
+ * query string is more than this many characters long, and will instead use
+ * multiple requests with shorter query strings. This degrades performance,
+ * but may be needed if your web server has a low (less than, say 1024)
+ * query string length limit or a low value for suhosin.get.max_value_length
+ * that you can't increase.
+ *
+ * If set to a negative number, ResourceLoader will assume there is no query
+ * string length limit.
+ *
+ * Defaults to a value based on php configuration.
+ */
+$wgResourceLoaderMaxQueryLength = false;
+
+/**
+ * If set to true, JavaScript modules loaded from wiki pages will be parsed
+ * prior to minification to validate it.
+ *
+ * Parse errors will result in a JS exception being thrown during module load,
+ * which avoids breaking other modules loaded in the same request.
+ */
+$wgResourceLoaderValidateJS = true;
+
+/**
+ * If set to true, statically-sourced (file-backed) JavaScript resources will
+ * be parsed for validity before being bundled up into ResourceLoader modules.
+ *
+ * This can be helpful for development by providing better error messages in
+ * default (non-debug) mode, but JavaScript parsing is slow and memory hungry
+ * and may fail on large pre-bundled frameworks.
+ */
+$wgResourceLoaderValidateStaticJS = false;
+
+/**
+ * Global LESS variables. An associative array binding variable names to
+ * LESS code snippets representing their values.
+ *
+ * Adding an item here is equivalent to writing `@variable: value;`
+ * at the beginning of all your .less files, with all the consequences.
+ * In particular, string values must be escaped and quoted.
+ *
+ * Changes to this configuration do NOT trigger cache invalidation.
+ *
+ * @par Example:
+ * @code
+ * $wgResourceLoaderLESSVars = [
+ * 'exampleFontSize' => '1em',
+ * 'exampleBlue' => '#eee',
+ * ];
+ * @endcode
+ * @since 1.22
+ * @deprecated since 1.30 Use ResourceLoaderModule::getLessVars() instead to
+ * add variables to individual modules that need them.
+ */
+$wgResourceLoaderLESSVars = [
+ /**
+ * Minimum available screen width at which a device can be considered a tablet/desktop
+ * The number is currently based on the device width of a Samsung Galaxy S5 mini and is low
+ * enough to cover iPad (768px). Number is prone to change with new information.
+ * @since 1.27
+ */
+ 'deviceWidthTablet' => '720px',
+];
+
+/**
+ * Default import paths for LESS modules. LESS files referenced in @import
+ * statements will be looked up here first, and relative to the importing file
+ * second. To avoid collisions, it's important for the LESS files in these
+ * directories to have a common, predictable file name prefix.
+ *
+ * Extensions need not (and should not) register paths in
+ * $wgResourceLoaderLESSImportPaths. The import path includes the path of the
+ * currently compiling LESS file, which allows each extension to freely import
+ * files from its own tree.
+ *
+ * @since 1.22
+ */
+$wgResourceLoaderLESSImportPaths = [
+ "$IP/resources/src/mediawiki.less/",
+];
+
+/**
+ * Whether ResourceLoader should attempt to persist modules in localStorage on
+ * browsers that support the Web Storage API.
+ */
+$wgResourceLoaderStorageEnabled = true;
+
+/**
+ * Cache version for client-side ResourceLoader module storage. You can trigger
+ * invalidation of the contents of the module store by incrementing this value.
+ *
+ * @since 1.23
+ */
+$wgResourceLoaderStorageVersion = 1;
+
+/**
+ * Whether to allow site-wide CSS (MediaWiki:Common.css and friends) on
+ * restricted pages like Special:UserLogin or Special:Preferences where
+ * JavaScript is disabled for security reasons. As it is possible to
+ * execute JavaScript through CSS, setting this to true opens up a
+ * potential security hole. Some sites may "skin" their wiki by using
+ * site-wide CSS, causing restricted pages to look unstyled and different
+ * from the rest of the site.
+ *
+ * @since 1.25
+ */
+$wgAllowSiteCSSOnRestrictedPages = false;
+
+/** @} */ # End of ResourceLoader settings }
+
+/*************************************************************************//**
+ * @name Page title and interwiki link settings
+ * @{
+ */
+
+/**
+ * Name of the project namespace. If left set to false, $wgSitename will be
+ * used instead.
+ */
+$wgMetaNamespace = false;
+
+/**
+ * Name of the project talk namespace.
+ *
+ * Normally you can ignore this and it will be something like
+ * $wgMetaNamespace . "_talk". In some languages, you may want to set this
+ * manually for grammatical reasons.
+ */
+$wgMetaNamespaceTalk = false;
+
+/**
+ * Additional namespaces. If the namespaces defined in Language.php and
+ * Namespace.php are insufficient, you can create new ones here, for example,
+ * to import Help files in other languages. You can also override the namespace
+ * names of existing namespaces. Extensions should use the CanonicalNamespaces
+ * hook or extension.json.
+ *
+ * @warning Once you delete a namespace, the pages in that namespace will
+ * no longer be accessible. If you rename it, then you can access them through
+ * the new namespace name.
+ *
+ * Custom namespaces should start at 100 to avoid conflicting with standard
+ * namespaces, and should always follow the even/odd main/talk pattern.
+ *
+ * @par Example:
+ * @code
+ * $wgExtraNamespaces = [
+ * 100 => "Hilfe",
+ * 101 => "Hilfe_Diskussion",
+ * 102 => "Aide",
+ * 103 => "Discussion_Aide"
+ * ];
+ * @endcode
+ *
+ * @todo Add a note about maintenance/namespaceDupes.php
+ */
+$wgExtraNamespaces = [];
+
+/**
+ * Same as above, but for namespaces with gender distinction.
+ * Note: the default form for the namespace should also be set
+ * using $wgExtraNamespaces for the same index.
+ * @since 1.18
+ */
+$wgExtraGenderNamespaces = [];
+
+/**
+ * Namespace aliases.
+ *
+ * These are alternate names for the primary localised namespace names, which
+ * are defined by $wgExtraNamespaces and the language file. If a page is
+ * requested with such a prefix, the request will be redirected to the primary
+ * name.
+ *
+ * Set this to a map from namespace names to IDs.
+ *
+ * @par Example:
+ * @code
+ * $wgNamespaceAliases = [
+ * 'Wikipedian' => NS_USER,
+ * 'Help' => 100,
+ * ];
+ * @endcode
+ */
+$wgNamespaceAliases = [];
+
+/**
+ * Allowed title characters -- regex character class
+ * Don't change this unless you know what you're doing
+ *
+ * Problematic punctuation:
+ * - []{}|# Are needed for link syntax, never enable these
+ * - <> Causes problems with HTML escaping, don't use
+ * - % Enabled by default, minor problems with path to query rewrite rules, see below
+ * - + Enabled by default, but doesn't work with path to query rewrite rules,
+ * corrupted by apache
+ * - ? Enabled by default, but doesn't work with path to PATH_INFO rewrites
+ *
+ * All three of these punctuation problems can be avoided by using an alias,
+ * instead of a rewrite rule of either variety.
+ *
+ * The problem with % is that when using a path to query rewrite rule, URLs are
+ * double-unescaped: once by Apache's path conversion code, and again by PHP. So
+ * %253F, for example, becomes "?". Our code does not double-escape to compensate
+ * for this, indeed double escaping would break if the double-escaped title was
+ * passed in the query string rather than the path. This is a minor security issue
+ * because articles can be created such that they are hard to view or edit.
+ *
+ * In some rare cases you may wish to remove + for compatibility with old links.
+ *
+ * Theoretically 0x80-0x9F of ISO 8859-1 should be disallowed, but
+ * this breaks interlanguage links
+ */
+$wgLegalTitleChars = " %!\"$&'()*,\\-.\\/0-9:;=?@A-Z\\\\^_`a-z~\\x80-\\xFF+";
+
+/**
+ * The interwiki prefix of the current wiki, or false if it doesn't have one.
+ *
+ * @deprecated since 1.23; use $wgLocalInterwikis instead
+ */
+$wgLocalInterwiki = false;
+
+/**
+ * Array for multiple $wgLocalInterwiki values, in case there are several
+ * interwiki prefixes that point to the current wiki. If $wgLocalInterwiki is
+ * set, its value is prepended to this array, for backwards compatibility.
+ *
+ * Note, recent changes feeds use only the first entry in this array (or
+ * $wgLocalInterwiki, if it is set). See $wgRCFeeds
+ */
+$wgLocalInterwikis = [];
+
+/**
+ * Expiry time for cache of interwiki table
+ */
+$wgInterwikiExpiry = 10800;
+
+/**
+ * @name Interwiki caching settings.
+ * @{
+ */
+
+/**
+ * Interwiki cache, either as an associative array or a path to a constant
+ * database (.cdb) file.
+ *
+ * This data structure database is generated by the `dumpInterwiki` maintenance
+ * script (which lives in the WikimediaMaintenance repository) and has key
+ * formats such as the following:
+ *
+ * - dbname:key - a simple key (e.g. enwiki:meta)
+ * - _sitename:key - site-scope key (e.g. wiktionary:meta)
+ * - __global:key - global-scope key (e.g. __global:meta)
+ * - __sites:dbname - site mapping (e.g. __sites:enwiki)
+ *
+ * Sites mapping just specifies site name, other keys provide "local url"
+ * data layout.
+ *
+ * @var bool|array|string
+ */
+$wgInterwikiCache = false;
+
+/**
+ * Specify number of domains to check for messages.
+ * - 1: Just wiki(db)-level
+ * - 2: wiki and global levels
+ * - 3: site levels
+ */
+$wgInterwikiScopes = 3;
+
+/**
+ * Fallback site, if unable to resolve from cache
+ */
+$wgInterwikiFallbackSite = 'wiki';
+
+/** @} */ # end of Interwiki caching settings.
+
+/**
+ * @name SiteStore caching settings.
+ * @{
+ */
+
+/**
+ * Specify the file location for the Sites json cache file.
+ */
+$wgSitesCacheFile = false;
+
+/** @} */ # end of SiteStore caching settings.
+
+/**
+ * If local interwikis are set up which allow redirects,
+ * set this regexp to restrict URLs which will be displayed
+ * as 'redirected from' links.
+ *
+ * @par Example:
+ * It might look something like this:
+ * @code
+ * $wgRedirectSources = '!^https?://[a-z-]+\.wikipedia\.org/!';
+ * @endcode
+ *
+ * Leave at false to avoid displaying any incoming redirect markers.
+ * This does not affect intra-wiki redirects, which don't change
+ * the URL.
+ */
+$wgRedirectSources = false;
+
+/**
+ * Set this to false to avoid forcing the first letter of links to capitals.
+ *
+ * @warning may break links! This makes links COMPLETELY case-sensitive. Links
+ * appearing with a capital at the beginning of a sentence will *not* go to the
+ * same place as links in the middle of a sentence using a lowercase initial.
+ */
+$wgCapitalLinks = true;
+
+/**
+ * @since 1.16 - This can now be set per-namespace. Some special namespaces (such
+ * as Special, see MWNamespace::$alwaysCapitalizedNamespaces for the full list) must be
+ * true by default (and setting them has no effect), due to various things that
+ * require them to be so. Also, since Talk namespaces need to directly mirror their
+ * associated content namespaces, the values for those are ignored in favor of the
+ * subject namespace's setting. Setting for NS_MEDIA is taken automatically from
+ * NS_FILE.
+ *
+ * @par Example:
+ * @code
+ * $wgCapitalLinkOverrides[ NS_FILE ] = false;
+ * @endcode
+ */
+$wgCapitalLinkOverrides = [];
+
+/**
+ * Which namespaces should support subpages?
+ * See Language.php for a list of namespaces.
+ */
+$wgNamespacesWithSubpages = [
+ NS_TALK => true,
+ NS_USER => true,
+ NS_USER_TALK => true,
+ NS_PROJECT => true,
+ NS_PROJECT_TALK => true,
+ NS_FILE_TALK => true,
+ NS_MEDIAWIKI => true,
+ NS_MEDIAWIKI_TALK => true,
+ NS_TEMPLATE => true,
+ NS_TEMPLATE_TALK => true,
+ NS_HELP => true,
+ NS_HELP_TALK => true,
+ NS_CATEGORY_TALK => true
+];
+
+/**
+ * Array holding default tracking category names.
+ *
+ * Array contains the system messages for each tracking category.
+ * Tracking categories allow pages with certain characteristics to be tracked.
+ * It works by adding any such page to a category automatically.
+ *
+ * A message with the suffix '-desc' should be added as a description message
+ * to have extra information on Special:TrackingCategories.
+ *
+ * @deprecated since 1.25 Extensions should now register tracking categories using
+ * the new extension registration system.
+ *
+ * @since 1.23
+ */
+$wgTrackingCategories = [];
+
+/**
+ * Array of namespaces which can be deemed to contain valid "content", as far
+ * as the site statistics are concerned. Useful if additional namespaces also
+ * contain "content" which should be considered when generating a count of the
+ * number of articles in the wiki.
+ */
+$wgContentNamespaces = [ NS_MAIN ];
+
+/**
+ * Optional array of namespaces which should be blacklisted from Special:ShortPages
+ * Only pages inside $wgContentNamespaces but not $wgShortPagesNamespaceBlacklist will
+ * be shown on that page.
+ * @since 1.30
+ */
+$wgShortPagesNamespaceBlacklist = [];
+
+/**
+ * Array of namespaces, in addition to the talk namespaces, where signatures
+ * (~~~~) are likely to be used. This determines whether to display the
+ * Signature button on the edit toolbar, and may also be used by extensions.
+ * For example, "traditional" style wikis, where content and discussion are
+ * intermixed, could place NS_MAIN and NS_PROJECT namespaces in this array.
+ */
+$wgExtraSignatureNamespaces = [];
+
+/**
+ * Max number of redirects to follow when resolving redirects.
+ * 1 means only the first redirect is followed (default behavior).
+ * 0 or less means no redirects are followed.
+ */
+$wgMaxRedirects = 1;
+
+/**
+ * Array of invalid page redirect targets.
+ * Attempting to create a redirect to any of the pages in this array
+ * will make the redirect fail.
+ * Userlogout is hard-coded, so it does not need to be listed here.
+ * (T12569) Disallow Mypage and Mytalk as well.
+ *
+ * As of now, this only checks special pages. Redirects to pages in
+ * other namespaces cannot be invalidated by this variable.
+ */
+$wgInvalidRedirectTargets = [ 'Filepath', 'Mypage', 'Mytalk', 'Redirect' ];
+
+/** @} */ # End of title and interwiki settings }
+
+/************************************************************************//**
+ * @name Parser settings
+ * These settings configure the transformation from wikitext to HTML.
+ * @{
+ */
+
+/**
+ * Parser configuration. Associative array with the following members:
+ *
+ * class The class name
+ *
+ * preprocessorClass The preprocessor class. Two classes are currently available:
+ * Preprocessor_Hash, which uses plain PHP arrays for temporary
+ * storage, and Preprocessor_DOM, which uses the DOM module for
+ * temporary storage. Preprocessor_DOM generally uses less memory;
+ * the speed of the two is roughly the same.
+ *
+ * If this parameter is not given, it uses Preprocessor_DOM if the
+ * DOM module is available, otherwise it uses Preprocessor_Hash.
+ *
+ * The entire associative array will be passed through to the constructor as
+ * the first parameter. Note that only Setup.php can use this variable --
+ * the configuration will change at runtime via $wgParser member functions, so
+ * the contents of this variable will be out-of-date. The variable can only be
+ * changed during LocalSettings.php, in particular, it can't be changed during
+ * an extension setup function.
+ */
+$wgParserConf = [
+ 'class' => 'Parser',
+ # 'preprocessorClass' => 'Preprocessor_Hash',
+];
+
+/**
+ * Maximum indent level of toc.
+ */
+$wgMaxTocLevel = 999;
+
+/**
+ * A complexity limit on template expansion: the maximum number of nodes visited
+ * by PPFrame::expand()
+ */
+$wgMaxPPNodeCount = 1000000;
+
+/**
+ * A complexity limit on template expansion: the maximum number of elements
+ * generated by Preprocessor::preprocessToObj(). This allows you to limit the
+ * amount of memory used by the Preprocessor_DOM node cache: testing indicates
+ * that each element uses about 160 bytes of memory on a 64-bit processor, so
+ * this default corresponds to about 155 MB.
+ *
+ * When the limit is exceeded, an exception is thrown.
+ */
+$wgMaxGeneratedPPNodeCount = 1000000;
+
+/**
+ * Maximum recursion depth for templates within templates.
+ * The current parser adds two levels to the PHP call stack for each template,
+ * and xdebug limits the call stack to 100 by default. So this should hopefully
+ * stop the parser before it hits the xdebug limit.
+ */
+$wgMaxTemplateDepth = 40;
+
+/**
+ * @see $wgMaxTemplateDepth
+ */
+$wgMaxPPExpandDepth = 40;
+
+/**
+ * URL schemes that should be recognized as valid by wfParseUrl().
+ *
+ * WARNING: Do not add 'file:' to this or internal file links will be broken.
+ * Instead, if you want to support file links, add 'file://'. The same applies
+ * to any other protocols with the same name as a namespace. See task T46011 for
+ * more information.
+ *
+ * @see wfParseUrl
+ */
+$wgUrlProtocols = [
+ 'bitcoin:', 'ftp://', 'ftps://', 'geo:', 'git://', 'gopher://', 'http://',
+ 'https://', 'irc://', 'ircs://', 'magnet:', 'mailto:', 'mms://', 'news:',
+ 'nntp://', 'redis://', 'sftp://', 'sip:', 'sips:', 'sms:', 'ssh://',
+ 'svn://', 'tel:', 'telnet://', 'urn:', 'worldwind://', 'xmpp:', '//'
+];
+
+/**
+ * If true, removes (by substituting) templates in signatures.
+ */
+$wgCleanSignatures = true;
+
+/**
+ * Whether to allow inline image pointing to other websites
+ */
+$wgAllowExternalImages = false;
+
+/**
+ * If the above is false, you can specify an exception here. Image URLs
+ * that start with this string are then rendered, while all others are not.
+ * You can use this to set up a trusted, simple repository of images.
+ * You may also specify an array of strings to allow multiple sites
+ *
+ * @par Examples:
+ * @code
+ * $wgAllowExternalImagesFrom = 'http://127.0.0.1/';
+ * $wgAllowExternalImagesFrom = [ 'http://127.0.0.1/', 'http://example.com' ];
+ * @endcode
+ */
+$wgAllowExternalImagesFrom = '';
+
+/**
+ * If $wgAllowExternalImages is false, you can allow an on-wiki
+ * whitelist of regular expression fragments to match the image URL
+ * against. If the image matches one of the regular expression fragments,
+ * The image will be displayed.
+ *
+ * Set this to true to enable the on-wiki whitelist (MediaWiki:External image whitelist)
+ * Or false to disable it
+ */
+$wgEnableImageWhitelist = true;
+
+/**
+ * A different approach to the above: simply allow the "<img>" tag to be used.
+ * This allows you to specify alt text and other attributes, copy-paste HTML to
+ * your wiki more easily, etc. However, allowing external images in any manner
+ * will allow anyone with editing rights to snoop on your visitors' IP
+ * addresses and so forth, if they wanted to, by inserting links to images on
+ * sites they control.
+ */
+$wgAllowImageTag = false;
+
+/**
+ * Configuration for HTML postprocessing tool. Set this to a configuration
+ * array to enable an external tool. Dave Raggett's "HTML Tidy" is typically
+ * used. See https://www.w3.org/People/Raggett/tidy/
+ *
+ * If this is null and $wgUseTidy is true, the deprecated configuration
+ * parameters will be used instead.
+ *
+ * If this is null and $wgUseTidy is false, a pure PHP fallback will be used.
+ *
+ * Keys are:
+ * - driver: May be:
+ * - RaggettInternalHHVM: Use the limited-functionality HHVM extension
+ * - RaggettInternalPHP: Use the PECL extension
+ * - RaggettExternal: Shell out to an external binary (tidyBin)
+ * - Html5Depurate: Use external Depurate service
+ * - Html5Internal: Use the Balancer library in PHP
+ * - RemexHtml: Use the RemexHtml library in PHP
+ *
+ * - tidyConfigFile: Path to configuration file for any of the Raggett drivers
+ * - debugComment: True to add a comment to the output with warning messages
+ * - tidyBin: For RaggettExternal, the path to the tidy binary.
+ * - tidyCommandLine: For RaggettExternal, additional command line options.
+ */
+$wgTidyConfig = null;
+
+/**
+ * Set this to true to use the deprecated tidy configuration parameters.
+ * @deprecated use $wgTidyConfig
+ */
+$wgUseTidy = false;
+
+/**
+ * The path to the tidy binary.
+ * @deprecated Use $wgTidyConfig['tidyBin']
+ */
+$wgTidyBin = 'tidy';
+
+/**
+ * The path to the tidy config file
+ * @deprecated Use $wgTidyConfig['tidyConfigFile']
+ */
+$wgTidyConf = $IP . '/includes/tidy/tidy.conf';
+
+/**
+ * The command line options to the tidy binary
+ * @deprecated Use $wgTidyConfig['tidyCommandLine']
+ */
+$wgTidyOpts = '';
+
+/**
+ * Set this to true to use the tidy extension
+ * @deprecated Use $wgTidyConfig['driver']
+ */
+$wgTidyInternal = extension_loaded( 'tidy' );
+
+/**
+ * Put tidy warnings in HTML comments
+ * Only works for internal tidy.
+ */
+$wgDebugTidy = false;
+
+/**
+ * Allow raw, unchecked HTML in "<html>...</html>" sections.
+ * THIS IS VERY DANGEROUS on a publicly editable site, so USE wgGroupPermissions
+ * TO RESTRICT EDITING to only those that you trust
+ */
+$wgRawHtml = false;
+
+/**
+ * Set a default target for external links, e.g. _blank to pop up a new window.
+ *
+ * This will also set the "noreferrer" and "noopener" link rel to prevent the
+ * attack described at https://mathiasbynens.github.io/rel-noopener/ .
+ * Some older browsers may not support these link attributes, hence
+ * setting $wgExternalLinkTarget to _blank may represent a security risk
+ * to some of your users.
+ */
+$wgExternalLinkTarget = false;
+
+/**
+ * If true, external URL links in wiki text will be given the
+ * rel="nofollow" attribute as a hint to search engines that
+ * they should not be followed for ranking purposes as they
+ * are user-supplied and thus subject to spamming.
+ */
+$wgNoFollowLinks = true;
+
+/**
+ * Namespaces in which $wgNoFollowLinks doesn't apply.
+ * See Language.php for a list of namespaces.
+ */
+$wgNoFollowNsExceptions = [];
+
+/**
+ * If this is set to an array of domains, external links to these domain names
+ * (or any subdomains) will not be set to rel="nofollow" regardless of the
+ * value of $wgNoFollowLinks. For instance:
+ *
+ * $wgNoFollowDomainExceptions = [ 'en.wikipedia.org', 'wiktionary.org', 'mediawiki.org' ];
+ *
+ * This would add rel="nofollow" to links to de.wikipedia.org, but not
+ * en.wikipedia.org, wiktionary.org, en.wiktionary.org, us.en.wikipedia.org,
+ * etc.
+ *
+ * Defaults to mediawiki.org for the links included in the software by default.
+ */
+$wgNoFollowDomainExceptions = [ 'mediawiki.org' ];
+
+/**
+ * Allow DISPLAYTITLE to change title display
+ */
+$wgAllowDisplayTitle = true;
+
+/**
+ * For consistency, restrict DISPLAYTITLE to text that normalizes to the same
+ * canonical DB key. Also disallow some inline CSS rules like display: none;
+ * which can cause the text to be hidden or unselectable.
+ */
+$wgRestrictDisplayTitle = true;
+
+/**
+ * Maximum number of calls per parse to expensive parser functions such as
+ * PAGESINCATEGORY.
+ */
+$wgExpensiveParserFunctionLimit = 100;
+
+/**
+ * Preprocessor caching threshold
+ * Setting it to 'false' will disable the preprocessor cache.
+ */
+$wgPreprocessorCacheThreshold = 1000;
+
+/**
+ * Enable interwiki transcluding. Only when iw_trans=1 in the interwiki table.
+ */
+$wgEnableScaryTranscluding = false;
+
+/**
+ * Expiry time for transcluded templates cached in transcache database table.
+ * Only used $wgEnableInterwikiTranscluding is set to true.
+ */
+$wgTranscludeCacheExpiry = 3600;
+
+/**
+ * Enable the magic links feature of automatically turning ISBN xxx,
+ * PMID xxx, RFC xxx into links
+ *
+ * @since 1.28
+ */
+$wgEnableMagicLinks = [
+ 'ISBN' => false,
+ 'PMID' => false,
+ 'RFC' => false
+];
+
+/** @} */ # end of parser settings }
+
+/************************************************************************//**
+ * @name Statistics
+ * @{
+ */
+
+/**
+ * Method used to determine if a page in a content namespace should be counted
+ * as a valid article.
+ *
+ * Redirect pages will never be counted as valid articles.
+ *
+ * This variable can have the following values:
+ * - 'any': all pages as considered as valid articles
+ * - 'comma': the page must contain a comma to be considered valid
+ * - 'link': the page must contain a [[wiki link]] to be considered valid
+ *
+ * See also See https://www.mediawiki.org/wiki/Manual:Article_count
+ *
+ * Retroactively changing this variable will not affect the existing count,
+ * to update it, you will need to run the maintenance/updateArticleCount.php
+ * script.
+ */
+$wgArticleCountMethod = 'link';
+
+/**
+ * How many days user must be idle before he is considered inactive. Will affect
+ * the number shown on Special:Statistics, Special:ActiveUsers, and the
+ * {{NUMBEROFACTIVEUSERS}} magic word in wikitext.
+ * You might want to leave this as the default value, to provide comparable
+ * numbers between different wikis.
+ */
+$wgActiveUserDays = 30;
+
+/** @} */ # End of statistics }
+
+/************************************************************************//**
+ * @name User accounts, authentication
+ * @{
+ */
+
+/**
+ * Central ID lookup providers
+ * Key is the provider ID, value is a specification for ObjectFactory
+ * @since 1.27
+ */
+$wgCentralIdLookupProviders = [
+ 'local' => [ 'class' => 'LocalIdLookup' ],
+];
+
+/**
+ * Central ID lookup provider to use by default
+ * @var string
+ */
+$wgCentralIdLookupProvider = 'local';
+
+/**
+ * Password policy for local wiki users. A user's effective policy
+ * is the superset of all policy statements from the policies for the
+ * groups where the user is a member. If more than one group policy
+ * include the same policy statement, the value is the max() of the
+ * values. Note true > false. The 'default' policy group is required,
+ * and serves as the minimum policy for all users. New statements can
+ * be added by appending to $wgPasswordPolicy['checks'].
+ * Statements:
+ * - MinimalPasswordLength - minimum length a user can set
+ * - MinimumPasswordLengthToLogin - passwords shorter than this will
+ * not be allowed to login, regardless if it is correct.
+ * - MaximalPasswordLength - maximum length password a user is allowed
+ * to attempt. Prevents DoS attacks with pbkdf2.
+ * - PasswordCannotMatchUsername - Password cannot match username to
+ * - PasswordCannotMatchBlacklist - Username/password combination cannot
+ * match a specific, hardcoded blacklist.
+ * - PasswordCannotBePopular - Blacklist passwords which are known to be
+ * commonly chosen. Set to integer n to ban the top n passwords.
+ * If you want to ban all common passwords on file, use the
+ * PHP_INT_MAX constant.
+ * @since 1.26
+ */
+$wgPasswordPolicy = [
+ 'policies' => [
+ 'bureaucrat' => [
+ 'MinimalPasswordLength' => 8,
+ 'MinimumPasswordLengthToLogin' => 1,
+ 'PasswordCannotMatchUsername' => true,
+ 'PasswordCannotBePopular' => 25,
+ ],
+ 'sysop' => [
+ 'MinimalPasswordLength' => 8,
+ 'MinimumPasswordLengthToLogin' => 1,
+ 'PasswordCannotMatchUsername' => true,
+ 'PasswordCannotBePopular' => 25,
+ ],
+ 'bot' => [
+ 'MinimalPasswordLength' => 8,
+ 'MinimumPasswordLengthToLogin' => 1,
+ 'PasswordCannotMatchUsername' => true,
+ ],
+ 'default' => [
+ 'MinimalPasswordLength' => 1,
+ 'PasswordCannotMatchUsername' => true,
+ 'PasswordCannotMatchBlacklist' => true,
+ 'MaximalPasswordLength' => 4096,
+ ],
+ ],
+ 'checks' => [
+ 'MinimalPasswordLength' => 'PasswordPolicyChecks::checkMinimalPasswordLength',
+ 'MinimumPasswordLengthToLogin' => 'PasswordPolicyChecks::checkMinimumPasswordLengthToLogin',
+ 'PasswordCannotMatchUsername' => 'PasswordPolicyChecks::checkPasswordCannotMatchUsername',
+ 'PasswordCannotMatchBlacklist' => 'PasswordPolicyChecks::checkPasswordCannotMatchBlacklist',
+ 'MaximalPasswordLength' => 'PasswordPolicyChecks::checkMaximalPasswordLength',
+ 'PasswordCannotBePopular' => 'PasswordPolicyChecks::checkPopularPasswordBlacklist'
+ ],
+];
+
+/**
+ * Configure AuthManager
+ *
+ * All providers are constructed using ObjectFactory, see that for the general
+ * structure. The array may also contain a key "sort" used to order providers:
+ * providers are stably sorted by this value, which should be an integer
+ * (default is 0).
+ *
+ * Elements are:
+ * - preauth: Array (keys ignored) of specifications for PreAuthenticationProviders
+ * - primaryauth: Array (keys ignored) of specifications for PrimaryAuthenticationProviders
+ * - secondaryauth: Array (keys ignored) of specifications for SecondaryAuthenticationProviders
+ *
+ * @since 1.27
+ * @note If this is null or empty, the value from $wgAuthManagerAutoConfig is
+ * used instead. Local customization should generally set this variable from
+ * scratch to the desired configuration. Extensions that want to
+ * auto-configure themselves should use $wgAuthManagerAutoConfig instead.
+ */
+$wgAuthManagerConfig = null;
+
+/**
+ * @see $wgAuthManagerConfig
+ * @since 1.27
+ */
+$wgAuthManagerAutoConfig = [
+ 'preauth' => [
+ MediaWiki\Auth\LegacyHookPreAuthenticationProvider::class => [
+ 'class' => MediaWiki\Auth\LegacyHookPreAuthenticationProvider::class,
+ 'sort' => 0,
+ ],
+ MediaWiki\Auth\ThrottlePreAuthenticationProvider::class => [
+ 'class' => MediaWiki\Auth\ThrottlePreAuthenticationProvider::class,
+ 'sort' => 0,
+ ],
+ ],
+ 'primaryauth' => [
+ // TemporaryPasswordPrimaryAuthenticationProvider should come before
+ // any other PasswordAuthenticationRequest-based
+ // PrimaryAuthenticationProvider (or at least any that might return
+ // FAIL rather than ABSTAIN for a wrong password), or password reset
+ // won't work right. Do not remove this (or change the key) or
+ // auto-configuration of other such providers in extensions will
+ // probably auto-insert themselves in the wrong place.
+ MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider::class => [
+ 'class' => MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider::class,
+ 'args' => [ [
+ // Fall through to LocalPasswordPrimaryAuthenticationProvider
+ 'authoritative' => false,
+ ] ],
+ 'sort' => 0,
+ ],
+ MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider::class => [
+ 'class' => MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider::class,
+ 'args' => [ [
+ // Last one should be authoritative, or else the user will get
+ // a less-than-helpful error message (something like "supplied
+ // authentication info not supported" rather than "wrong
+ // password") if it too fails.
+ 'authoritative' => true,
+ ] ],
+ 'sort' => 100,
+ ],
+ ],
+ 'secondaryauth' => [
+ MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider::class => [
+ 'class' => MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider::class,
+ 'sort' => 0,
+ ],
+ MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider::class => [
+ 'class' => MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider::class,
+ 'sort' => 100,
+ ],
+ // Linking during login is experimental, enable at your own risk - T134952
+ // MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider::class => [
+ // 'class' => MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider::class,
+ // 'sort' => 100,
+ // ],
+ MediaWiki\Auth\EmailNotificationSecondaryAuthenticationProvider::class => [
+ 'class' => MediaWiki\Auth\EmailNotificationSecondaryAuthenticationProvider::class,
+ 'sort' => 200,
+ ],
+ ],
+];
+
+/**
+ * Time frame for re-authentication.
+ *
+ * With only password-based authentication, you'd just ask the user to re-enter
+ * their password to verify certain operations like changing the password or
+ * changing the account's email address. But under AuthManager, the user might
+ * not have a password (you might even have to redirect the browser to a
+ * third-party service or something complex like that), you might want to have
+ * both factors of a two-factor authentication, and so on. So, the options are:
+ * - Incorporate the whole multi-step authentication flow within everything
+ * that needs to do this.
+ * - Consider it good if they used Special:UserLogin during this session within
+ * the last X seconds.
+ * - Come up with a third option.
+ *
+ * MediaWiki currently takes the second option. This setting configures the
+ * "X seconds".
+ *
+ * This allows for configuring different time frames for different
+ * "operations". The operations used in MediaWiki core include:
+ * - LinkAccounts
+ * - UnlinkAccount
+ * - ChangeCredentials
+ * - RemoveCredentials
+ * - ChangeEmail
+ *
+ * Additional operations may be used by extensions, either explicitly by
+ * calling AuthManager::securitySensitiveOperationStatus(),
+ * ApiAuthManagerHelper::securitySensitiveOperation() or
+ * SpecialPage::checkLoginSecurityLevel(), or implicitly by overriding
+ * SpecialPage::getLoginSecurityLevel() or by subclassing
+ * AuthManagerSpecialPage.
+ *
+ * The key 'default' is used if a requested operation isn't defined in the array.
+ *
+ * @since 1.27
+ * @var int[] operation => time in seconds. A 'default' key must always be provided.
+ */
+$wgReauthenticateTime = [
+ 'default' => 300,
+];
+
+/**
+ * Whether to allow security-sensitive operations when re-authentication is not possible.
+ *
+ * If AuthManager::canAuthenticateNow() is false (e.g. the current
+ * SessionProvider is not able to change users, such as when OAuth is in use),
+ * AuthManager::securitySensitiveOperationStatus() cannot sensibly return
+ * SEC_REAUTH. Setting an operation true here will have it return SEC_OK in
+ * that case, while setting it false will have it return SEC_FAIL.
+ *
+ * The key 'default' is used if a requested operation isn't defined in the array.
+ *
+ * @since 1.27
+ * @see $wgReauthenticateTime
+ * @var bool[] operation => boolean. A 'default' key must always be provided.
+ */
+$wgAllowSecuritySensitiveOperationIfCannotReauthenticate = [
+ 'default' => true,
+];
+
+/**
+ * List of AuthenticationRequest class names which are not changeable through
+ * Special:ChangeCredentials and the changeauthenticationdata API.
+ * This is only enforced on the client level; AuthManager itself (e.g.
+ * AuthManager::allowsAuthenticationDataChange calls) is not affected.
+ * Class names are checked for exact match (not for subclasses).
+ * @since 1.27
+ * @var string[]
+ */
+$wgChangeCredentialsBlacklist = [
+ \MediaWiki\Auth\TemporaryPasswordAuthenticationRequest::class
+];
+
+/**
+ * List of AuthenticationRequest class names which are not removable through
+ * Special:RemoveCredentials and the removeauthenticationdata API.
+ * This is only enforced on the client level; AuthManager itself (e.g.
+ * AuthManager::allowsAuthenticationDataChange calls) is not affected.
+ * Class names are checked for exact match (not for subclasses).
+ * @since 1.27
+ * @var string[]
+ */
+$wgRemoveCredentialsBlacklist = [
+ \MediaWiki\Auth\PasswordAuthenticationRequest::class,
+];
+
+/**
+ * For compatibility with old installations set to false
+ * @deprecated since 1.24 will be removed in future
+ */
+$wgPasswordSalt = true;
+
+/**
+ * Specifies the minimal length of a user password. If set to 0, empty pass-
+ * words are allowed.
+ * @deprecated since 1.26, use $wgPasswordPolicy's MinimalPasswordLength.
+ */
+$wgMinimalPasswordLength = false;
+
+/**
+ * Specifies the maximal length of a user password (T64685).
+ *
+ * It is not recommended to make this greater than the default, as it can
+ * allow DoS attacks by users setting really long passwords. In addition,
+ * this should not be lowered too much, as it enforces weak passwords.
+ *
+ * @warning Unlike other password settings, user with passwords greater than
+ * the maximum will not be able to log in.
+ * @deprecated since 1.26, use $wgPasswordPolicy's MaximalPasswordLength.
+ */
+$wgMaximalPasswordLength = false;
+
+/**
+ * Specifies if users should be sent to a password-reset form on login, if their
+ * password doesn't meet the requirements of User::isValidPassword().
+ * @since 1.23
+ */
+$wgInvalidPasswordReset = true;
+
+/**
+ * Default password type to use when hashing user passwords
+ *
+ * @since 1.24
+ */
+$wgPasswordDefault = 'pbkdf2';
+
+/**
+ * Configuration for built-in password types. Maps the password type
+ * to an array of options. The 'class' option is the Password class to
+ * use. All other options are class-dependent.
+ *
+ * An advanced example:
+ * @code
+ * $wgPasswordConfig['bcrypt-peppered'] = [
+ * 'class' => 'EncryptedPassword',
+ * 'underlying' => 'bcrypt',
+ * 'secrets' => [],
+ * 'cipher' => MCRYPT_RIJNDAEL_256,
+ * 'mode' => MCRYPT_MODE_CBC,
+ * 'cost' => 5,
+ * ];
+ * @endcode
+ *
+ * @since 1.24
+ */
+$wgPasswordConfig = [
+ 'A' => [
+ 'class' => 'MWOldPassword',
+ ],
+ 'B' => [
+ 'class' => 'MWSaltedPassword',
+ ],
+ 'pbkdf2-legacyA' => [
+ 'class' => 'LayeredParameterizedPassword',
+ 'types' => [
+ 'A',
+ 'pbkdf2',
+ ],
+ ],
+ 'pbkdf2-legacyB' => [
+ 'class' => 'LayeredParameterizedPassword',
+ 'types' => [
+ 'B',
+ 'pbkdf2',
+ ],
+ ],
+ 'bcrypt' => [
+ 'class' => 'BcryptPassword',
+ 'cost' => 9,
+ ],
+ 'pbkdf2' => [
+ 'class' => 'Pbkdf2Password',
+ 'algo' => 'sha512',
+ 'cost' => '30000',
+ 'length' => '64',
+ ],
+];
+
+/**
+ * Whether to allow password resets ("enter some identifying data, and we'll send an email
+ * with a temporary password you can use to get back into the account") identified by
+ * various bits of data. Setting all of these to false (or the whole variable to false)
+ * has the effect of disabling password resets entirely
+ */
+$wgPasswordResetRoutes = [
+ 'username' => true,
+ 'email' => true,
+];
+
+/**
+ * Maximum number of Unicode characters in signature
+ */
+$wgMaxSigChars = 255;
+
+/**
+ * Maximum number of bytes in username. You want to run the maintenance
+ * script ./maintenance/checkUsernames.php once you have changed this value.
+ */
+$wgMaxNameChars = 255;
+
+/**
+ * Array of usernames which may not be registered or logged in from
+ * Maintenance scripts can still use these
+ */
+$wgReservedUsernames = [
+ 'MediaWiki default', // Default 'Main Page' and MediaWiki: message pages
+ 'Conversion script', // Used for the old Wikipedia software upgrade
+ 'Maintenance script', // Maintenance scripts which perform editing, image import script
+ 'Template namespace initialisation script', // Used in 1.2->1.3 upgrade
+ 'ScriptImporter', // Default user name used by maintenance/importSiteScripts.php
+ 'Unknown user', // Used in WikiImporter when importing revisions with no author
+ 'msg:double-redirect-fixer', // Automatic double redirect fix
+ 'msg:usermessage-editor', // Default user for leaving user messages
+ 'msg:proxyblocker', // For $wgProxyList and Special:Blockme (removed in 1.22)
+ 'msg:spambot_username', // Used by cleanupSpam.php
+ 'msg:autochange-username', // Used by anon category RC entries (parser functions, Lua & purges)
+];
+
+/**
+ * Settings added to this array will override the default globals for the user
+ * preferences used by anonymous visitors and newly created accounts.
+ * For instance, to disable editing on double clicks:
+ * $wgDefaultUserOptions ['editondblclick'] = 0;
+ */
+$wgDefaultUserOptions = [
+ 'ccmeonemails' => 0,
+ 'cols' => 80, // @deprecated since 1.29 No longer used in core
+ 'date' => 'default',
+ 'diffonly' => 0,
+ 'disablemail' => 0,
+ 'editfont' => 'monospace',
+ 'editondblclick' => 0,
+ 'editsectiononrightclick' => 0,
+ 'enotifminoredits' => 0,
+ 'enotifrevealaddr' => 0,
+ 'enotifusertalkpages' => 1,
+ 'enotifwatchlistpages' => 1,
+ 'extendwatchlist' => 1,
+ 'fancysig' => 0,
+ 'forceeditsummary' => 0,
+ 'gender' => 'unknown',
+ 'hideminor' => 0,
+ 'hidepatrolled' => 0,
+ 'hidecategorization' => 1,
+ 'imagesize' => 2,
+ 'math' => 1,
+ 'minordefault' => 0,
+ 'newpageshidepatrolled' => 0,
+ 'nickname' => '',
+ 'norollbackdiff' => 0,
+ 'numberheadings' => 0,
+ 'previewonfirst' => 0,
+ 'previewontop' => 1,
+ 'rcdays' => 7,
+ 'rcenhancedfilters' => 0,
+ 'rcenhancedfilters-disable' => 0,
+ 'rclimit' => 50,
+ 'rows' => 25, // @deprecated since 1.29 No longer used in core
+ 'showhiddencats' => 0,
+ 'shownumberswatching' => 1,
+ 'showtoolbar' => 1,
+ 'skin' => false,
+ 'stubthreshold' => 0,
+ 'thumbsize' => 5,
+ 'underline' => 2,
+ 'uselivepreview' => 0,
+ 'usenewrc' => 1,
+ 'watchcreations' => 1,
+ 'watchdefault' => 1,
+ 'watchdeletion' => 0,
+ 'watchuploads' => 1,
+ 'watchlistdays' => 3.0,
+ 'watchlisthideanons' => 0,
+ 'watchlisthidebots' => 0,
+ 'watchlisthideliu' => 0,
+ 'watchlisthideminor' => 0,
+ 'watchlisthideown' => 0,
+ 'watchlisthidepatrolled' => 0,
+ 'watchlisthidecategorization' => 1,
+ 'watchlistreloadautomatically' => 0,
+ 'watchlistunwatchlinks' => 0,
+ 'watchmoves' => 0,
+ 'watchrollback' => 0,
+ 'wllimit' => 250,
+ 'useeditwarning' => 1,
+ 'prefershttps' => 1,
+];
+
+/**
+ * An array of preferences to not show for the user
+ */
+$wgHiddenPrefs = [];
+
+/**
+ * Characters to prevent during new account creations.
+ * This is used in a regular expression character class during
+ * registration (regex metacharacters like / are escaped).
+ */
+$wgInvalidUsernameCharacters = '@:';
+
+/**
+ * Character used as a delimiter when testing for interwiki userrights
+ * (In Special:UserRights, it is possible to modify users on different
+ * databases if the delimiter is used, e.g. "Someuser@enwiki").
+ *
+ * It is recommended that you have this delimiter in
+ * $wgInvalidUsernameCharacters above, or you will not be able to
+ * modify the user rights of those users via Special:UserRights
+ */
+$wgUserrightsInterwikiDelimiter = '@';
+
+/**
+ * This is to let user authenticate using https when they come from http.
+ * Based on an idea by George Herbert on wikitech-l:
+ * https://lists.wikimedia.org/pipermail/wikitech-l/2010-October/050039.html
+ * @since 1.17
+ */
+$wgSecureLogin = false;
+
+/**
+ * Versioning for authentication tokens.
+ *
+ * If non-null, this is combined with the user's secret (the user_token field
+ * in the DB) to generate the token cookie. Changing this will invalidate all
+ * active sessions (i.e. it will log everyone out).
+ *
+ * @since 1.27
+ * @var string|null
+ */
+$wgAuthenticationTokenVersion = null;
+
+/**
+ * MediaWiki\Session\SessionProvider configuration.
+ *
+ * Value is an array of ObjectFactory specifications for the SessionProviders
+ * to be used. Keys in the array are ignored. Order is not significant.
+ *
+ * @since 1.27
+ */
+$wgSessionProviders = [
+ MediaWiki\Session\CookieSessionProvider::class => [
+ 'class' => MediaWiki\Session\CookieSessionProvider::class,
+ 'args' => [ [
+ 'priority' => 30,
+ 'callUserSetCookiesHook' => true,
+ ] ],
+ ],
+ MediaWiki\Session\BotPasswordSessionProvider::class => [
+ 'class' => MediaWiki\Session\BotPasswordSessionProvider::class,
+ 'args' => [ [
+ 'priority' => 75,
+ ] ],
+ ],
+];
+
+/** @} */ # end user accounts }
+
+/************************************************************************//**
+ * @name User rights, access control and monitoring
+ * @{
+ */
+
+/**
+ * Number of seconds before autoblock entries expire. Default 86400 = 1 day.
+ */
+$wgAutoblockExpiry = 86400;
+
+/**
+ * Set this to true to allow blocked users to edit their own user talk page.
+ */
+$wgBlockAllowsUTEdit = true;
+
+/**
+ * Allow sysops to ban users from accessing Emailuser
+ */
+$wgSysopEmailBans = true;
+
+/**
+ * Limits on the possible sizes of range blocks.
+ *
+ * CIDR notation is hard to understand, it's easy to mistakenly assume that a
+ * /1 is a small range and a /31 is a large range. For IPv4, setting a limit of
+ * half the number of bits avoids such errors, and allows entire ISPs to be
+ * blocked using a small number of range blocks.
+ *
+ * For IPv6, RFC 3177 recommends that a /48 be allocated to every residential
+ * customer, so range blocks larger than /64 (half the number of bits) will
+ * plainly be required. RFC 4692 implies that a very large ISP may be
+ * allocated a /19 if a generous HD-Ratio of 0.8 is used, so we will use that
+ * as our limit. As of 2012, blocking the whole world would require a /4 range.
+ */
+$wgBlockCIDRLimit = [
+ 'IPv4' => 16, # Blocks larger than a /16 (64k addresses) will not be allowed
+ 'IPv6' => 19,
+];
+
+/**
+ * If true, blocked users will not be allowed to login. When using this with
+ * a public wiki, the effect of logging out blocked users may actually be
+ * avers: unless the user's address is also blocked (e.g. auto-block),
+ * logging the user out will again allow reading and editing, just as for
+ * anonymous visitors.
+ */
+$wgBlockDisablesLogin = false;
+
+/**
+ * Pages anonymous user may see, set as an array of pages titles.
+ *
+ * @par Example:
+ * @code
+ * $wgWhitelistRead = array ( "Main Page", "Wikipedia:Help");
+ * @endcode
+ *
+ * Special:Userlogin and Special:ChangePassword are always whitelisted.
+ *
+ * @note This will only work if $wgGroupPermissions['*']['read'] is false --
+ * see below. Otherwise, ALL pages are accessible, regardless of this setting.
+ *
+ * @note Also that this will only protect _pages in the wiki_. Uploaded files
+ * will remain readable. You can use img_auth.php to protect uploaded files,
+ * see https://www.mediawiki.org/wiki/Manual:Image_Authorization
+ *
+ * @note Extensions should not modify this, but use the TitleReadWhitelist
+ * hook instead.
+ */
+$wgWhitelistRead = false;
+
+/**
+ * Pages anonymous user may see, set as an array of regular expressions.
+ *
+ * This function will match the regexp against the title name, which
+ * is without underscore.
+ *
+ * @par Example:
+ * To whitelist [[Main Page]]:
+ * @code
+ * $wgWhitelistReadRegexp = [ "/Main Page/" ];
+ * @endcode
+ *
+ * @note Unless ^ and/or $ is specified, a regular expression might match
+ * pages not intended to be whitelisted. The above example will also
+ * whitelist a page named 'Security Main Page'.
+ *
+ * @par Example:
+ * To allow reading any page starting with 'User' regardless of the case:
+ * @code
+ * $wgWhitelistReadRegexp = [ "@^UsEr.*@i" ];
+ * @endcode
+ * Will allow both [[User is banned]] and [[User:JohnDoe]]
+ *
+ * @note This will only work if $wgGroupPermissions['*']['read'] is false --
+ * see below. Otherwise, ALL pages are accessible, regardless of this setting.
+ */
+$wgWhitelistReadRegexp = false;
+
+/**
+ * Should editors be required to have a validated e-mail
+ * address before being allowed to edit?
+ */
+$wgEmailConfirmToEdit = false;
+
+/**
+ * Should MediaWiki attempt to protect user's privacy when doing redirects?
+ * Keep this true if access counts to articles are made public.
+ */
+$wgHideIdentifiableRedirects = true;
+
+/**
+ * Permission keys given to users in each group.
+ *
+ * This is an array where the keys are all groups and each value is an
+ * array of the format (right => boolean).
+ *
+ * The second format is used to support per-namespace permissions.
+ * Note that this feature does not fully work for all permission types.
+ *
+ * All users are implicitly in the '*' group including anonymous visitors;
+ * logged-in users are all implicitly in the 'user' group. These will be
+ * combined with the permissions of all groups that a given user is listed
+ * in in the user_groups table.
+ *
+ * Note: Don't set $wgGroupPermissions = []; unless you know what you're
+ * doing! This will wipe all permissions, and may mean that your users are
+ * unable to perform certain essential tasks or access new functionality
+ * when new permissions are introduced and default grants established.
+ *
+ * Functionality to make pages inaccessible has not been extensively tested
+ * for security. Use at your own risk!
+ *
+ * This replaces $wgWhitelistAccount and $wgWhitelistEdit
+ */
+$wgGroupPermissions = [];
+
+/** @cond file_level_code */
+// Implicit group for all visitors
+$wgGroupPermissions['*']['createaccount'] = true;
+$wgGroupPermissions['*']['read'] = true;
+$wgGroupPermissions['*']['edit'] = true;
+$wgGroupPermissions['*']['createpage'] = true;
+$wgGroupPermissions['*']['createtalk'] = true;
+$wgGroupPermissions['*']['writeapi'] = true;
+$wgGroupPermissions['*']['editmyusercss'] = true;
+$wgGroupPermissions['*']['editmyuserjs'] = true;
+$wgGroupPermissions['*']['viewmywatchlist'] = true;
+$wgGroupPermissions['*']['editmywatchlist'] = true;
+$wgGroupPermissions['*']['viewmyprivateinfo'] = true;
+$wgGroupPermissions['*']['editmyprivateinfo'] = true;
+$wgGroupPermissions['*']['editmyoptions'] = true;
+# $wgGroupPermissions['*']['patrolmarks'] = false; // let anons see what was patrolled
+
+// Implicit group for all logged-in accounts
+$wgGroupPermissions['user']['move'] = true;
+$wgGroupPermissions['user']['move-subpages'] = true;
+$wgGroupPermissions['user']['move-rootuserpages'] = true; // can move root userpages
+$wgGroupPermissions['user']['move-categorypages'] = true;
+$wgGroupPermissions['user']['movefile'] = true;
+$wgGroupPermissions['user']['read'] = true;
+$wgGroupPermissions['user']['edit'] = true;
+$wgGroupPermissions['user']['createpage'] = true;
+$wgGroupPermissions['user']['createtalk'] = true;
+$wgGroupPermissions['user']['writeapi'] = true;
+$wgGroupPermissions['user']['upload'] = true;
+$wgGroupPermissions['user']['reupload'] = true;
+$wgGroupPermissions['user']['reupload-shared'] = true;
+$wgGroupPermissions['user']['minoredit'] = true;
+$wgGroupPermissions['user']['purge'] = true;
+$wgGroupPermissions['user']['sendemail'] = true;
+$wgGroupPermissions['user']['applychangetags'] = true;
+$wgGroupPermissions['user']['changetags'] = true;
+$wgGroupPermissions['user']['editcontentmodel'] = true;
+
+// Implicit group for accounts that pass $wgAutoConfirmAge
+$wgGroupPermissions['autoconfirmed']['autoconfirmed'] = true;
+$wgGroupPermissions['autoconfirmed']['editsemiprotected'] = true;
+
+// Users with bot privilege can have their edits hidden
+// from various log pages by default
+$wgGroupPermissions['bot']['bot'] = true;
+$wgGroupPermissions['bot']['autoconfirmed'] = true;
+$wgGroupPermissions['bot']['editsemiprotected'] = true;
+$wgGroupPermissions['bot']['nominornewtalk'] = true;
+$wgGroupPermissions['bot']['autopatrol'] = true;
+$wgGroupPermissions['bot']['suppressredirect'] = true;
+$wgGroupPermissions['bot']['apihighlimits'] = true;
+$wgGroupPermissions['bot']['writeapi'] = true;
+
+// Most extra permission abilities go to this group
+$wgGroupPermissions['sysop']['block'] = true;
+$wgGroupPermissions['sysop']['createaccount'] = true;
+$wgGroupPermissions['sysop']['delete'] = true;
+// can be separately configured for pages with > $wgDeleteRevisionsLimit revs
+$wgGroupPermissions['sysop']['bigdelete'] = true;
+// can view deleted history entries, but not see or restore the text
+$wgGroupPermissions['sysop']['deletedhistory'] = true;
+// can view deleted revision text
+$wgGroupPermissions['sysop']['deletedtext'] = true;
+$wgGroupPermissions['sysop']['undelete'] = true;
+$wgGroupPermissions['sysop']['editinterface'] = true;
+$wgGroupPermissions['sysop']['editusercss'] = true;
+$wgGroupPermissions['sysop']['edituserjs'] = true;
+$wgGroupPermissions['sysop']['import'] = true;
+$wgGroupPermissions['sysop']['importupload'] = true;
+$wgGroupPermissions['sysop']['move'] = true;
+$wgGroupPermissions['sysop']['move-subpages'] = true;
+$wgGroupPermissions['sysop']['move-rootuserpages'] = true;
+$wgGroupPermissions['sysop']['move-categorypages'] = true;
+$wgGroupPermissions['sysop']['patrol'] = true;
+$wgGroupPermissions['sysop']['autopatrol'] = true;
+$wgGroupPermissions['sysop']['protect'] = true;
+$wgGroupPermissions['sysop']['editprotected'] = true;
+$wgGroupPermissions['sysop']['rollback'] = true;
+$wgGroupPermissions['sysop']['upload'] = true;
+$wgGroupPermissions['sysop']['reupload'] = true;
+$wgGroupPermissions['sysop']['reupload-shared'] = true;
+$wgGroupPermissions['sysop']['unwatchedpages'] = true;
+$wgGroupPermissions['sysop']['autoconfirmed'] = true;
+$wgGroupPermissions['sysop']['editsemiprotected'] = true;
+$wgGroupPermissions['sysop']['ipblock-exempt'] = true;
+$wgGroupPermissions['sysop']['blockemail'] = true;
+$wgGroupPermissions['sysop']['markbotedits'] = true;
+$wgGroupPermissions['sysop']['apihighlimits'] = true;
+$wgGroupPermissions['sysop']['browsearchive'] = true;
+$wgGroupPermissions['sysop']['noratelimit'] = true;
+$wgGroupPermissions['sysop']['movefile'] = true;
+$wgGroupPermissions['sysop']['unblockself'] = true;
+$wgGroupPermissions['sysop']['suppressredirect'] = true;
+# $wgGroupPermissions['sysop']['pagelang'] = true;
+# $wgGroupPermissions['sysop']['upload_by_url'] = true;
+$wgGroupPermissions['sysop']['mergehistory'] = true;
+$wgGroupPermissions['sysop']['managechangetags'] = true;
+$wgGroupPermissions['sysop']['deletechangetags'] = true;
+
+// Permission to change users' group assignments
+$wgGroupPermissions['bureaucrat']['userrights'] = true;
+$wgGroupPermissions['bureaucrat']['noratelimit'] = true;
+// Permission to change users' groups assignments across wikis
+# $wgGroupPermissions['bureaucrat']['userrights-interwiki'] = true;
+// Permission to export pages including linked pages regardless of $wgExportMaxLinkDepth
+# $wgGroupPermissions['bureaucrat']['override-export-depth'] = true;
+
+# $wgGroupPermissions['sysop']['deletelogentry'] = true;
+# $wgGroupPermissions['sysop']['deleterevision'] = true;
+// To hide usernames from users and Sysops
+# $wgGroupPermissions['suppress']['hideuser'] = true;
+// To hide revisions/log items from users and Sysops
+# $wgGroupPermissions['suppress']['suppressrevision'] = true;
+// To view revisions/log items hidden from users and Sysops
+# $wgGroupPermissions['suppress']['viewsuppressed'] = true;
+// For private suppression log access
+# $wgGroupPermissions['suppress']['suppressionlog'] = true;
+
+/**
+ * The developer group is deprecated, but can be activated if need be
+ * to use the 'lockdb' and 'unlockdb' special pages. Those require
+ * that a lock file be defined and creatable/removable by the web
+ * server.
+ */
+# $wgGroupPermissions['developer']['siteadmin'] = true;
+
+/** @endcond */
+
+/**
+ * Permission keys revoked from users in each group.
+ *
+ * This acts the same way as wgGroupPermissions above, except that
+ * if the user is in a group here, the permission will be removed from them.
+ *
+ * Improperly setting this could mean that your users will be unable to perform
+ * certain essential tasks, so use at your own risk!
+ */
+$wgRevokePermissions = [];
+
+/**
+ * Implicit groups, aren't shown on Special:Listusers or somewhere else
+ */
+$wgImplicitGroups = [ '*', 'user', 'autoconfirmed' ];
+
+/**
+ * A map of group names that the user is in, to group names that those users
+ * are allowed to add or revoke.
+ *
+ * Setting the list of groups to add or revoke to true is equivalent to "any
+ * group".
+ *
+ * @par Example:
+ * To allow sysops to add themselves to the "bot" group:
+ * @code
+ * $wgGroupsAddToSelf = [ 'sysop' => [ 'bot' ] ];
+ * @endcode
+ *
+ * @par Example:
+ * Implicit groups may be used for the source group, for instance:
+ * @code
+ * $wgGroupsRemoveFromSelf = [ '*' => true ];
+ * @endcode
+ * This allows users in the '*' group (i.e. any user) to remove themselves from
+ * any group that they happen to be in.
+ */
+$wgGroupsAddToSelf = [];
+
+/**
+ * @see $wgGroupsAddToSelf
+ */
+$wgGroupsRemoveFromSelf = [];
+
+/**
+ * Set of available actions that can be restricted via action=protect
+ * You probably shouldn't change this.
+ * Translated through restriction-* messages.
+ * Title::getRestrictionTypes() will remove restrictions that are not
+ * applicable to a specific title (create and upload)
+ */
+$wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ];
+
+/**
+ * Rights which can be required for each protection level (via action=protect)
+ *
+ * You can add a new protection level that requires a specific
+ * permission by manipulating this array. The ordering of elements
+ * dictates the order on the protection form's lists.
+ *
+ * - '' will be ignored (i.e. unprotected)
+ * - 'autoconfirmed' is quietly rewritten to 'editsemiprotected' for backwards compatibility
+ * - 'sysop' is quietly rewritten to 'editprotected' for backwards compatibility
+ */
+$wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ];
+
+/**
+ * Restriction levels that can be used with cascading protection
+ *
+ * A page can only be protected with cascading protection if the
+ * requested restriction level is included in this array.
+ *
+ * 'autoconfirmed' is quietly rewritten to 'editsemiprotected' for backwards compatibility.
+ * 'sysop' is quietly rewritten to 'editprotected' for backwards compatibility.
+ */
+$wgCascadingRestrictionLevels = [ 'sysop' ];
+
+/**
+ * Restriction levels that should be considered "semiprotected"
+ *
+ * Certain places in the interface recognize a dichotomy between "protected"
+ * and "semiprotected", without further distinguishing the specific levels. In
+ * general, if anyone can be eligible to edit a protection level merely by
+ * reaching some condition in $wgAutopromote, it should probably be considered
+ * "semiprotected".
+ *
+ * 'autoconfirmed' is quietly rewritten to 'editsemiprotected' for backwards compatibility.
+ * 'sysop' is not changed, since it really shouldn't be here.
+ */
+$wgSemiprotectedRestrictionLevels = [ 'autoconfirmed' ];
+
+/**
+ * Set the minimum permissions required to edit pages in each
+ * namespace. If you list more than one permission, a user must
+ * have all of them to edit pages in that namespace.
+ *
+ * @note NS_MEDIAWIKI is implicitly restricted to 'editinterface'.
+ */
+$wgNamespaceProtection = [];
+
+/**
+ * Pages in namespaces in this array can not be used as templates.
+ *
+ * Elements MUST be numeric namespace ids, you can safely use the MediaWiki
+ * namespaces constants (NS_USER, NS_MAIN...).
+ *
+ * Among other things, this may be useful to enforce read-restrictions
+ * which may otherwise be bypassed by using the template mechanism.
+ */
+$wgNonincludableNamespaces = [];
+
+/**
+ * Number of seconds an account is required to age before it's given the
+ * implicit 'autoconfirm' group membership. This can be used to limit
+ * privileges of new accounts.
+ *
+ * Accounts created by earlier versions of the software may not have a
+ * recorded creation date, and will always be considered to pass the age test.
+ *
+ * When left at 0, all registered accounts will pass.
+ *
+ * @par Example:
+ * Set automatic confirmation to 10 minutes (which is 600 seconds):
+ * @code
+ * $wgAutoConfirmAge = 600; // ten minutes
+ * @endcode
+ * Set age to one day:
+ * @code
+ * $wgAutoConfirmAge = 3600*24; // one day
+ * @endcode
+ */
+$wgAutoConfirmAge = 0;
+
+/**
+ * Number of edits an account requires before it is autoconfirmed.
+ * Passing both this AND the time requirement is needed. Example:
+ *
+ * @par Example:
+ * @code
+ * $wgAutoConfirmCount = 50;
+ * @endcode
+ */
+$wgAutoConfirmCount = 0;
+
+/**
+ * Array containing the conditions of automatic promotion of a user to specific groups.
+ *
+ * The basic syntax for `$wgAutopromote` is:
+ *
+ * $wgAutopromote = array(
+ * 'groupname' => cond,
+ * 'group2' => cond2,
+ * );
+ *
+ * A `cond` may be:
+ * - a single condition without arguments:
+ * Note that Autopromote wraps a single non-array value into an array
+ * e.g. `APCOND_EMAILCONFIRMED` OR
+ * array( `APCOND_EMAILCONFIRMED` )
+ * - a single condition with arguments:
+ * e.g. `array( APCOND_EDITCOUNT, 100 )`
+ * - a set of conditions:
+ * e.g. `array( 'operand', cond1, cond2, ... )`
+ *
+ * When constructing a set of conditions, the following conditions are available:
+ * - `&` (**AND**):
+ * promote if user matches **ALL** conditions
+ * - `|` (**OR**):
+ * promote if user matches **ANY** condition
+ * - `^` (**XOR**):
+ * promote if user matches **ONLY ONE OF THE CONDITIONS**
+ * - `!` (**NOT**):
+ * promote if user matces **NO** condition
+ * - array( APCOND_EMAILCONFIRMED ):
+ * true if user has a confirmed e-mail
+ * - array( APCOND_EDITCOUNT, number of edits ):
+ * true if user has the at least the number of edits as the passed parameter
+ * - array( APCOND_AGE, seconds since registration ):
+ * true if the length of time since the user created his/her account
+ * is at least the same length of time as the passed parameter
+ * - array( APCOND_AGE_FROM_EDIT, seconds since first edit ):
+ * true if the length of time since the user made his/her first edit
+ * is at least the same length of time as the passed parameter
+ * - array( APCOND_INGROUPS, group1, group2, ... ):
+ * true if the user is a member of each of the passed groups
+ * - array( APCOND_ISIP, ip ):
+ * true if the user has the passed IP address
+ * - array( APCOND_IPINRANGE, range ):
+ * true if the user has an IP address in the range of the passed parameter
+ * - array( APCOND_BLOCKED ):
+ * true if the user is blocked
+ * - array( APCOND_ISBOT ):
+ * true if the user is a bot
+ * - similar constructs can be defined by extensions
+ *
+ * The sets of conditions are evaluated recursively, so you can use nested sets of conditions
+ * linked by operands.
+ *
+ * Note that if $wgEmailAuthentication is disabled, APCOND_EMAILCONFIRMED will be true for any
+ * user who has provided an e-mail address.
+ */
+$wgAutopromote = [
+ 'autoconfirmed' => [ '&',
+ [ APCOND_EDITCOUNT, &$wgAutoConfirmCount ],
+ [ APCOND_AGE, &$wgAutoConfirmAge ],
+ ],
+];
+
+/**
+ * Automatically add a usergroup to any user who matches certain conditions.
+ *
+ * Does not add the user to the group again if it has been removed.
+ * Also, does not remove the group if the user no longer meets the criteria.
+ *
+ * The format is:
+ * @code
+ * [ event => criteria, ... ]
+ * @endcode
+ * Where event is either:
+ * - 'onEdit' (when user edits)
+ *
+ * Criteria has the same format as $wgAutopromote
+ *
+ * @see $wgAutopromote
+ * @since 1.18
+ */
+$wgAutopromoteOnce = [
+ 'onEdit' => [],
+];
+
+/**
+ * Put user rights log entries for autopromotion in recent changes?
+ * @since 1.18
+ */
+$wgAutopromoteOnceLogInRC = true;
+
+/**
+ * $wgAddGroups and $wgRemoveGroups can be used to give finer control over who
+ * can assign which groups at Special:Userrights.
+ *
+ * @par Example:
+ * Bureaucrats can add any group:
+ * @code
+ * $wgAddGroups['bureaucrat'] = true;
+ * @endcode
+ * Bureaucrats can only remove bots and sysops:
+ * @code
+ * $wgRemoveGroups['bureaucrat'] = [ 'bot', 'sysop' ];
+ * @endcode
+ * Sysops can make bots:
+ * @code
+ * $wgAddGroups['sysop'] = [ 'bot' ];
+ * @endcode
+ * Sysops can disable other sysops in an emergency, and disable bots:
+ * @code
+ * $wgRemoveGroups['sysop'] = [ 'sysop', 'bot' ];
+ * @endcode
+ */
+$wgAddGroups = [];
+
+/**
+ * @see $wgAddGroups
+ */
+$wgRemoveGroups = [];
+
+/**
+ * A list of available rights, in addition to the ones defined by the core.
+ * For extensions only.
+ */
+$wgAvailableRights = [];
+
+/**
+ * Optional to restrict deletion of pages with higher revision counts
+ * to users with the 'bigdelete' permission. (Default given to sysops.)
+ */
+$wgDeleteRevisionsLimit = 0;
+
+/**
+ * The maximum number of edits a user can have and
+ * can still be hidden by users with the hideuser permission.
+ * This is limited for performance reason.
+ * Set to false to disable the limit.
+ * @since 1.23
+ */
+$wgHideUserContribLimit = 1000;
+
+/**
+ * Number of accounts each IP address may create per specified period(s).
+ *
+ * @par Example:
+ * @code
+ * $wgAccountCreationThrottle = [
+ * // no more than 100 per month
+ * [
+ * 'count' => 100,
+ * 'seconds' => 30*86400,
+ * ],
+ * // no more than 10 per day
+ * [
+ * 'count' => 10,
+ * 'seconds' => 86400,
+ * ],
+ * ];
+ * @endcode
+ *
+ * @warning Requires $wgMainCacheType to be enabled
+ */
+$wgAccountCreationThrottle = [ [
+ 'count' => 0,
+ 'seconds' => 86400,
+] ];
+
+/**
+ * Edits matching these regular expressions in body text
+ * will be recognised as spam and rejected automatically.
+ *
+ * There's no administrator override on-wiki, so be careful what you set. :)
+ * May be an array of regexes or a single string for backwards compatibility.
+ *
+ * @see https://en.wikipedia.org/wiki/Regular_expression
+ *
+ * @note Each regex needs a beginning/end delimiter, eg: # or /
+ */
+$wgSpamRegex = [];
+
+/**
+ * Same as the above except for edit summaries
+ */
+$wgSummarySpamRegex = [];
+
+/**
+ * Whether to use DNS blacklists in $wgDnsBlacklistUrls to check for open
+ * proxies
+ * @since 1.16
+ */
+$wgEnableDnsBlacklist = false;
+
+/**
+ * List of DNS blacklists to use, if $wgEnableDnsBlacklist is true.
+ *
+ * This is an array of either a URL or an array with the URL and a key (should
+ * the blacklist require a key).
+ *
+ * @par Example:
+ * @code
+ * $wgDnsBlacklistUrls = [
+ * // String containing URL
+ * 'http.dnsbl.sorbs.net.',
+ * // Array with URL and key, for services that require a key
+ * [ 'dnsbl.httpbl.net.', 'mykey' ],
+ * // Array with just the URL. While this works, it is recommended that you
+ * // just use a string as shown above
+ * [ 'opm.tornevall.org.' ]
+ * ];
+ * @endcode
+ *
+ * @note You should end the domain name with a . to avoid searching your
+ * eventual domain search suffixes.
+ * @since 1.16
+ */
+$wgDnsBlacklistUrls = [ 'http.dnsbl.sorbs.net.' ];
+
+/**
+ * Proxy whitelist, list of addresses that are assumed to be non-proxy despite
+ * what the other methods might say.
+ */
+$wgProxyWhitelist = [];
+
+/**
+ * IP ranges that should be considered soft-blocked (anon-only, account
+ * creation allowed). The intent is to use this to prevent anonymous edits from
+ * shared resources such as Wikimedia Labs.
+ * @since 1.29
+ * @var string[]
+ */
+$wgSoftBlockRanges = [];
+
+/**
+ * Whether to look at the X-Forwarded-For header's list of (potentially spoofed)
+ * IPs and apply IP blocks to them. This allows for IP blocks to work with correctly-configured
+ * (transparent) proxies without needing to block the proxies themselves.
+ */
+$wgApplyIpBlocksToXff = false;
+
+/**
+ * Simple rate limiter options to brake edit floods.
+ *
+ * Maximum number actions allowed in the given number of seconds; after that
+ * the violating client receives HTTP 500 error pages until the period
+ * elapses.
+ *
+ * @par Example:
+ * Limits per configured per action and then type of users.
+ * @code
+ * $wgRateLimits = [
+ * 'edit' => [
+ * 'anon' => [ x, y ], // any and all anonymous edits (aggregate)
+ * 'user' => [ x, y ], // each logged-in user
+ * 'newbie' => [ x, y ], // each new autoconfirmed accounts; overrides 'user'
+ * 'ip' => [ x, y ], // each anon and recent account
+ * 'subnet' => [ x, y ], // ... within a /24 subnet in IPv4 or /64 in IPv6
+ * 'groupName' => [ x, y ], // by group membership
+ * ]
+ * ];
+ * @endcode
+ *
+ * @par Normally, the 'noratelimit' right allows a user to bypass any rate
+ * limit checks. This can be disabled on a per-action basis by setting the
+ * special '&can-bypass' key to false in that action's configuration.
+ * @code
+ * $wgRateLimits = [
+ * 'some-action' => [
+ * '&can-bypass' => false,
+ * 'user' => [ x, y ],
+ * ];
+ * @endcode
+ *
+ * @warning Requires that $wgMainCacheType is set to something persistent
+ */
+$wgRateLimits = [
+ // Page edits
+ 'edit' => [
+ 'ip' => [ 8, 60 ],
+ 'newbie' => [ 8, 60 ],
+ 'user' => [ 90, 60 ],
+ ],
+ // Page moves
+ 'move' => [
+ 'newbie' => [ 2, 120 ],
+ 'user' => [ 8, 60 ],
+ ],
+ // File uploads
+ 'upload' => [
+ 'ip' => [ 8, 60 ],
+ 'newbie' => [ 8, 60 ],
+ ],
+ // Page rollbacks
+ 'rollback' => [
+ 'user' => [ 10, 60 ],
+ 'newbie' => [ 5, 120 ]
+ ],
+ // Triggering password resets emails
+ 'mailpassword' => [
+ 'ip' => [ 5, 3600 ],
+ ],
+ // Emailing other users using MediaWiki
+ 'emailuser' => [
+ 'ip' => [ 5, 86400 ],
+ 'newbie' => [ 5, 86400 ],
+ 'user' => [ 20, 86400 ],
+ ],
+ // Purging pages
+ 'purge' => [
+ 'ip' => [ 30, 60 ],
+ 'user' => [ 30, 60 ],
+ ],
+ // Purges of link tables
+ 'linkpurge' => [
+ 'ip' => [ 30, 60 ],
+ 'user' => [ 30, 60 ],
+ ],
+ // Files rendered via thumb.php or thumb_handler.php
+ 'renderfile' => [
+ 'ip' => [ 700, 30 ],
+ 'user' => [ 700, 30 ],
+ ],
+ // Same as above but for non-standard thumbnails
+ 'renderfile-nonstandard' => [
+ 'ip' => [ 70, 30 ],
+ 'user' => [ 70, 30 ],
+ ],
+ // Stashing edits into cache before save
+ 'stashedit' => [
+ 'ip' => [ 30, 60 ],
+ 'newbie' => [ 30, 60 ],
+ ],
+ // Adding or removing change tags
+ 'changetag' => [
+ 'ip' => [ 8, 60 ],
+ 'newbie' => [ 8, 60 ],
+ ],
+ // Changing the content model of a page
+ 'editcontentmodel' => [
+ 'newbie' => [ 2, 120 ],
+ 'user' => [ 8, 60 ],
+ ],
+];
+
+/**
+ * Array of IPs / CIDR ranges which should be excluded from rate limits.
+ * This may be useful for whitelisting NAT gateways for conferences, etc.
+ */
+$wgRateLimitsExcludedIPs = [];
+
+/**
+ * Log IP addresses in the recentchanges table; can be accessed only by
+ * extensions (e.g. CheckUser) or a DB admin
+ * Used for retroactive autoblocks
+ */
+$wgPutIPinRC = true;
+
+/**
+ * Integer defining default number of entries to show on
+ * special pages which are query-pages such as Special:Whatlinkshere.
+ */
+$wgQueryPageDefaultLimit = 50;
+
+/**
+ * Limit password attempts to X attempts per Y seconds per IP per account.
+ *
+ * Value is an array of arrays. Each sub-array must have a key for count
+ * (ie count of how many attempts before throttle) and a key for seconds.
+ * If the key 'allIPs' (case sensitive) is present, then the limit is
+ * just per account instead of per IP per account.
+ *
+ * @since 1.27 allIps support and multiple limits added in 1.27. Prior
+ * to 1.27 this only supported having a single throttle.
+ * @warning Requires $wgMainCacheType to be enabled
+ */
+$wgPasswordAttemptThrottle = [
+ // Short term limit
+ [ 'count' => 5, 'seconds' => 300 ],
+ // Long term limit. We need to balance the risk
+ // of somebody using this as a DoS attack to lock someone
+ // out of their account, and someone doing a brute force attack.
+ [ 'count' => 150, 'seconds' => 60 * 60 * 48 ],
+];
+
+/**
+ * @var Array Map of (grant => right => boolean)
+ * Users authorize consumers (like Apps) to act on their behalf but only with
+ * a subset of the user's normal account rights (signed off on by the user).
+ * The possible rights to grant to a consumer are bundled into groups called
+ * "grants". Each grant defines some rights it lets consumers inherit from the
+ * account they may act on behalf of. Note that a user granting a right does
+ * nothing if that user does not actually have that right to begin with.
+ * @since 1.27
+ */
+$wgGrantPermissions = [];
+
+// @TODO: clean up grants
+// @TODO: auto-include read/editsemiprotected rights?
+
+$wgGrantPermissions['basic']['autoconfirmed'] = true;
+$wgGrantPermissions['basic']['autopatrol'] = true;
+$wgGrantPermissions['basic']['editsemiprotected'] = true;
+$wgGrantPermissions['basic']['ipblock-exempt'] = true;
+$wgGrantPermissions['basic']['nominornewtalk'] = true;
+$wgGrantPermissions['basic']['patrolmarks'] = true;
+$wgGrantPermissions['basic']['purge'] = true;
+$wgGrantPermissions['basic']['read'] = true;
+$wgGrantPermissions['basic']['skipcaptcha'] = true;
+$wgGrantPermissions['basic']['writeapi'] = true;
+
+$wgGrantPermissions['highvolume']['bot'] = true;
+$wgGrantPermissions['highvolume']['apihighlimits'] = true;
+$wgGrantPermissions['highvolume']['noratelimit'] = true;
+$wgGrantPermissions['highvolume']['markbotedits'] = true;
+
+$wgGrantPermissions['editpage']['edit'] = true;
+$wgGrantPermissions['editpage']['minoredit'] = true;
+$wgGrantPermissions['editpage']['applychangetags'] = true;
+$wgGrantPermissions['editpage']['changetags'] = true;
+
+$wgGrantPermissions['editprotected'] = $wgGrantPermissions['editpage'];
+$wgGrantPermissions['editprotected']['editprotected'] = true;
+
+$wgGrantPermissions['editmycssjs'] = $wgGrantPermissions['editpage'];
+$wgGrantPermissions['editmycssjs']['editmyusercss'] = true;
+$wgGrantPermissions['editmycssjs']['editmyuserjs'] = true;
+
+$wgGrantPermissions['editmyoptions']['editmyoptions'] = true;
+
+$wgGrantPermissions['editinterface'] = $wgGrantPermissions['editpage'];
+$wgGrantPermissions['editinterface']['editinterface'] = true;
+$wgGrantPermissions['editinterface']['editusercss'] = true;
+$wgGrantPermissions['editinterface']['edituserjs'] = true;
+
+$wgGrantPermissions['createeditmovepage'] = $wgGrantPermissions['editpage'];
+$wgGrantPermissions['createeditmovepage']['createpage'] = true;
+$wgGrantPermissions['createeditmovepage']['createtalk'] = true;
+$wgGrantPermissions['createeditmovepage']['move'] = true;
+$wgGrantPermissions['createeditmovepage']['move-rootuserpages'] = true;
+$wgGrantPermissions['createeditmovepage']['move-subpages'] = true;
+$wgGrantPermissions['createeditmovepage']['move-categorypages'] = true;
+
+$wgGrantPermissions['uploadfile']['upload'] = true;
+$wgGrantPermissions['uploadfile']['reupload-own'] = true;
+
+$wgGrantPermissions['uploadeditmovefile'] = $wgGrantPermissions['uploadfile'];
+$wgGrantPermissions['uploadeditmovefile']['reupload'] = true;
+$wgGrantPermissions['uploadeditmovefile']['reupload-shared'] = true;
+$wgGrantPermissions['uploadeditmovefile']['upload_by_url'] = true;
+$wgGrantPermissions['uploadeditmovefile']['movefile'] = true;
+$wgGrantPermissions['uploadeditmovefile']['suppressredirect'] = true;
+
+$wgGrantPermissions['patrol']['patrol'] = true;
+
+$wgGrantPermissions['rollback']['rollback'] = true;
+
+$wgGrantPermissions['blockusers']['block'] = true;
+$wgGrantPermissions['blockusers']['blockemail'] = true;
+
+$wgGrantPermissions['viewdeleted']['browsearchive'] = true;
+$wgGrantPermissions['viewdeleted']['deletedhistory'] = true;
+$wgGrantPermissions['viewdeleted']['deletedtext'] = true;
+
+$wgGrantPermissions['viewrestrictedlogs']['suppressionlog'] = true;
+
+$wgGrantPermissions['delete'] = $wgGrantPermissions['editpage'] +
+ $wgGrantPermissions['viewdeleted'];
+$wgGrantPermissions['delete']['delete'] = true;
+$wgGrantPermissions['delete']['bigdelete'] = true;
+$wgGrantPermissions['delete']['deletelogentry'] = true;
+$wgGrantPermissions['delete']['deleterevision'] = true;
+$wgGrantPermissions['delete']['undelete'] = true;
+
+$wgGrantPermissions['protect'] = $wgGrantPermissions['editprotected'];
+$wgGrantPermissions['protect']['protect'] = true;
+
+$wgGrantPermissions['viewmywatchlist']['viewmywatchlist'] = true;
+
+$wgGrantPermissions['editmywatchlist']['editmywatchlist'] = true;
+
+$wgGrantPermissions['sendemail']['sendemail'] = true;
+
+$wgGrantPermissions['createaccount']['createaccount'] = true;
+
+$wgGrantPermissions['privateinfo']['viewmyprivateinfo'] = true;
+
+/**
+ * @var Array Map of grants to their UI grouping
+ * @since 1.27
+ */
+$wgGrantPermissionGroups = [
+ // Hidden grants are implicitly present
+ 'basic' => 'hidden',
+
+ 'editpage' => 'page-interaction',
+ 'createeditmovepage' => 'page-interaction',
+ 'editprotected' => 'page-interaction',
+ 'patrol' => 'page-interaction',
+
+ 'uploadfile' => 'file-interaction',
+ 'uploadeditmovefile' => 'file-interaction',
+
+ 'sendemail' => 'email',
+
+ 'viewmywatchlist' => 'watchlist-interaction',
+ 'editviewmywatchlist' => 'watchlist-interaction',
+
+ 'editmycssjs' => 'customization',
+ 'editmyoptions' => 'customization',
+
+ 'editinterface' => 'administration',
+ 'rollback' => 'administration',
+ 'blockusers' => 'administration',
+ 'delete' => 'administration',
+ 'viewdeleted' => 'administration',
+ 'viewrestrictedlogs' => 'administration',
+ 'protect' => 'administration',
+ 'createaccount' => 'administration',
+
+ 'highvolume' => 'high-volume',
+
+ 'privateinfo' => 'private-information',
+];
+
+/**
+ * @var bool Whether to enable bot passwords
+ * @since 1.27
+ */
+$wgEnableBotPasswords = true;
+
+/**
+ * Cluster for the bot_passwords table
+ * @var string|bool If false, the normal cluster will be used
+ * @since 1.27
+ */
+$wgBotPasswordsCluster = false;
+
+/**
+ * Database name for the bot_passwords table
+ *
+ * To use a database with a table prefix, set this variable to
+ * "{$database}-{$prefix}".
+ * @var string|bool If false, the normal database will be used
+ * @since 1.27
+ */
+$wgBotPasswordsDatabase = false;
+
+/** @} */ # end of user rights settings
+
+/************************************************************************//**
+ * @name Proxy scanner settings
+ * @{
+ */
+
+/**
+ * This should always be customised in LocalSettings.php
+ */
+$wgSecretKey = false;
+
+/**
+ * Big list of banned IP addresses.
+ *
+ * This can have the following formats:
+ * - An array of addresses, either in the values
+ * or the keys (for backward compatibility, deprecated since 1.30)
+ * - A string, in that case this is the path to a file
+ * containing the list of IP addresses, one per line
+ */
+$wgProxyList = [];
+
+/** @} */ # end of proxy scanner settings
+
+/************************************************************************//**
+ * @name Cookie settings
+ * @{
+ */
+
+/**
+ * Default cookie lifetime, in seconds. Setting to 0 makes all cookies session-only.
+ */
+$wgCookieExpiration = 30 * 86400;
+
+/**
+ * Default login cookie lifetime, in seconds. Setting
+ * $wgExtendLoginCookieExpiration to null will use $wgCookieExpiration to
+ * calculate the cookie lifetime. As with $wgCookieExpiration, 0 will make
+ * login cookies session-only.
+ */
+$wgExtendedLoginCookieExpiration = 180 * 86400;
+
+/**
+ * Set to set an explicit domain on the login cookies eg, "justthis.domain.org"
+ * or ".any.subdomain.net"
+ */
+$wgCookieDomain = '';
+
+/**
+ * Set this variable if you want to restrict cookies to a certain path within
+ * the domain specified by $wgCookieDomain.
+ */
+$wgCookiePath = '/';
+
+/**
+ * Whether the "secure" flag should be set on the cookie. This can be:
+ * - true: Set secure flag
+ * - false: Don't set secure flag
+ * - "detect": Set the secure flag if $wgServer is set to an HTTPS URL
+ */
+$wgCookieSecure = 'detect';
+
+/**
+ * By default, MediaWiki checks if the client supports cookies during the
+ * login process, so that it can display an informative error message if
+ * cookies are disabled. Set this to true if you want to disable this cookie
+ * check.
+ */
+$wgDisableCookieCheck = false;
+
+/**
+ * Cookies generated by MediaWiki have names starting with this prefix. Set it
+ * to a string to use a custom prefix. Setting it to false causes the database
+ * name to be used as a prefix.
+ */
+$wgCookiePrefix = false;
+
+/**
+ * Set authentication cookies to HttpOnly to prevent access by JavaScript,
+ * in browsers that support this feature. This can mitigates some classes of
+ * XSS attack.
+ */
+$wgCookieHttpOnly = true;
+
+/**
+ * A list of cookies that vary the cache (for use by extensions)
+ */
+$wgCacheVaryCookies = [];
+
+/**
+ * Override to customise the session name
+ */
+$wgSessionName = false;
+
+/**
+ * Whether to set a cookie when a user is autoblocked. Doing so means that a blocked user, even
+ * after logging out and moving to a new IP address, will still be blocked. This cookie will contain
+ * an authentication code if $wgSecretKey is set, or otherwise will just be the block ID (in
+ * which case there is a possibility of an attacker discovering the names of revdeleted users, so
+ * it is best to use this in conjunction with $wgSecretKey being set).
+ */
+$wgCookieSetOnAutoblock = false;
+
+/** @} */ # end of cookie settings }
+
+/************************************************************************//**
+ * @name LaTeX (mathematical formulas)
+ * @{
+ */
+
+/**
+ * To use inline TeX, you need to compile 'texvc' (in the 'math' subdirectory of
+ * the MediaWiki package and have latex, dvips, gs (ghostscript), andconvert
+ * (ImageMagick) installed and available in the PATH.
+ * Please see math/README for more information.
+ */
+$wgUseTeX = false;
+
+/** @} */ # end LaTeX }
+
+/************************************************************************//**
+ * @name Profiling, testing and debugging
+ *
+ * To enable profiling, edit StartProfiler.php
+ *
+ * @{
+ */
+
+/**
+ * Filename for debug logging. See https://www.mediawiki.org/wiki/How_to_debug
+ * The debug log file should be not be publicly accessible if it is used, as it
+ * may contain private data.
+ */
+$wgDebugLogFile = '';
+
+/**
+ * Prefix for debug log lines
+ */
+$wgDebugLogPrefix = '';
+
+/**
+ * If true, instead of redirecting, show a page with a link to the redirect
+ * destination. This allows for the inspection of PHP error messages, and easy
+ * resubmission of form data. For developer use only.
+ */
+$wgDebugRedirects = false;
+
+/**
+ * If true, log debugging data from action=raw and load.php.
+ * This is normally false to avoid overlapping debug entries due to gen=css
+ * and gen=js requests.
+ */
+$wgDebugRawPage = false;
+
+/**
+ * Send debug data to an HTML comment in the output.
+ *
+ * This may occasionally be useful when supporting a non-technical end-user.
+ * It's more secure than exposing the debug log file to the web, since the
+ * output only contains private data for the current user. But it's not ideal
+ * for development use since data is lost on fatal errors and redirects.
+ */
+$wgDebugComments = false;
+
+/**
+ * Write SQL queries to the debug log.
+ *
+ * This setting is only used $wgLBFactoryConf['class'] is set to
+ * 'LBFactorySimple' and $wgDBservers is an empty array; otherwise
+ * the DBO_DEBUG flag must be set in the 'flags' option of the database
+ * connection to achieve the same functionality.
+ */
+$wgDebugDumpSql = false;
+
+/**
+ * Performance expectations for DB usage
+ *
+ * @since 1.26
+ */
+$wgTrxProfilerLimits = [
+ // HTTP GET/HEAD requests.
+ // Master queries should not happen on GET requests
+ 'GET' => [
+ 'masterConns' => 0,
+ 'writes' => 0,
+ 'readQueryTime' => 5
+ ],
+ // HTTP POST requests.
+ // Master reads and writes will happen for a subset of these.
+ 'POST' => [
+ 'readQueryTime' => 5,
+ 'writeQueryTime' => 1,
+ 'maxAffected' => 1000
+ ],
+ 'POST-nonwrite' => [
+ 'masterConns' => 0,
+ 'writes' => 0,
+ 'readQueryTime' => 5
+ ],
+ // Deferred updates that run after HTTP response is sent
+ 'PostSend' => [
+ 'readQueryTime' => 5,
+ 'writeQueryTime' => 1,
+ 'maxAffected' => 1000,
+ // Log master queries under the post-send entry point as they are discouraged
+ 'masterConns' => 0,
+ 'writes' => 0,
+ ],
+ // Background job runner
+ 'JobRunner' => [
+ 'readQueryTime' => 30,
+ 'writeQueryTime' => 5,
+ 'maxAffected' => 500 // ballpark of $wgUpdateRowsPerQuery
+ ],
+ // Command-line scripts
+ 'Maintenance' => [
+ 'writeQueryTime' => 5,
+ 'maxAffected' => 1000
+ ]
+];
+
+/**
+ * Map of string log group names to log destinations.
+ *
+ * If set, wfDebugLog() output for that group will go to that file instead
+ * of the regular $wgDebugLogFile. Useful for enabling selective logging
+ * in production.
+ *
+ * Log destinations may be one of the following:
+ * - false to completely remove from the output, including from $wgDebugLogFile.
+ * - string values specifying a filename or URI.
+ * - associative array with keys:
+ * - 'destination' desired filename or URI.
+ * - 'sample' an integer value, specifying a sampling factor (optional)
+ * - 'level' A \Psr\Log\LogLevel constant, indicating the minimum level
+ * to log (optional, since 1.25)
+ *
+ * @par Example:
+ * @code
+ * $wgDebugLogGroups['redis'] = '/var/log/mediawiki/redis.log';
+ * @endcode
+ *
+ * @par Advanced example:
+ * @code
+ * $wgDebugLogGroups['memcached'] = [
+ * 'destination' => '/var/log/mediawiki/memcached.log',
+ * 'sample' => 1000, // log 1 message out of every 1,000.
+ * 'level' => \Psr\Log\LogLevel::WARNING
+ * ];
+ * @endcode
+ */
+$wgDebugLogGroups = [];
+
+/**
+ * Default service provider for creating Psr\Log\LoggerInterface instances.
+ *
+ * The value should be an array suitable for use with
+ * ObjectFactory::getObjectFromSpec(). The created object is expected to
+ * implement the MediaWiki\Logger\Spi interface. See ObjectFactory for additional
+ * details.
+ *
+ * Alternately the MediaWiki\Logger\LoggerFactory::registerProvider method can
+ * be called to inject an MediaWiki\Logger\Spi instance into the LoggerFactory
+ * and bypass the use of this configuration variable entirely.
+ *
+ * @par To completely disable logging:
+ * @code
+ * $wgMWLoggerDefaultSpi = [ 'class' => '\\MediaWiki\\Logger\\NullSpi' ];
+ * @endcode
+ *
+ * @since 1.25
+ * @var array $wgMWLoggerDefaultSpi
+ * @see MwLogger
+ */
+$wgMWLoggerDefaultSpi = [
+ 'class' => '\\MediaWiki\\Logger\\LegacySpi',
+];
+
+/**
+ * Display debug data at the bottom of the main content area.
+ *
+ * Useful for developers and technical users trying to working on a closed wiki.
+ */
+$wgShowDebug = false;
+
+/**
+ * Prefix debug messages with relative timestamp. Very-poor man's profiler.
+ * Since 1.19 also includes memory usage.
+ */
+$wgDebugTimestamps = false;
+
+/**
+ * Print HTTP headers for every request in the debug information.
+ */
+$wgDebugPrintHttpHeaders = true;
+
+/**
+ * Show the contents of $wgHooks in Special:Version
+ */
+$wgSpecialVersionShowHooks = false;
+
+/**
+ * Whether to show "we're sorry, but there has been a database error" pages.
+ * Displaying errors aids in debugging, but may display information useful
+ * to an attacker.
+ */
+$wgShowSQLErrors = false;
+
+/**
+ * If set to true, uncaught exceptions will print a complete stack trace
+ * to output. This should only be used for debugging, as it may reveal
+ * private information in function parameters due to PHP's backtrace
+ * formatting.
+ */
+$wgShowExceptionDetails = false;
+
+/**
+ * If true, show a backtrace for database errors
+ *
+ * @note This setting only applies when connection errors and query errors are
+ * reported in the normal manner. $wgShowExceptionDetails applies in other cases,
+ * including those in which an uncaught exception is thrown from within the
+ * exception handler.
+ */
+$wgShowDBErrorBacktrace = false;
+
+/**
+ * If true, send the exception backtrace to the error log
+ */
+$wgLogExceptionBacktrace = true;
+
+/**
+ * Expose backend server host names through the API and various HTML comments
+ */
+$wgShowHostnames = false;
+
+/**
+ * Override server hostname detection with a hardcoded value.
+ * Should be a string, default false.
+ * @since 1.20
+ */
+$wgOverrideHostname = false;
+
+/**
+ * If set to true MediaWiki will throw notices for some possible error
+ * conditions and for deprecated functions.
+ */
+$wgDevelopmentWarnings = false;
+
+/**
+ * Release limitation to wfDeprecated warnings, if set to a release number
+ * development warnings will not be generated for deprecations added in releases
+ * after the limit.
+ */
+$wgDeprecationReleaseLimit = false;
+
+/**
+ * Only record profiling info for pages that took longer than this
+ * @deprecated since 1.25: set $wgProfiler['threshold'] instead.
+ */
+$wgProfileLimit = 0.0;
+
+/**
+ * Don't put non-profiling info into log file
+ *
+ * @deprecated since 1.23, set the log file in
+ * $wgDebugLogGroups['profileoutput'] instead.
+ */
+$wgProfileOnly = false;
+
+/**
+ * Destination of statsd metrics.
+ *
+ * A host or host:port of a statsd server. Port defaults to 8125.
+ *
+ * If not set, statsd metrics will not be collected.
+ *
+ * @see wfLogProfilingData
+ * @since 1.25
+ */
+$wgStatsdServer = false;
+
+/**
+ * Prefix for metric names sent to $wgStatsdServer.
+ *
+ * @see MediaWikiServices::getStatsdDataFactory
+ * @see BufferingStatsdDataFactory
+ * @since 1.25
+ */
+$wgStatsdMetricPrefix = 'MediaWiki';
+
+/**
+ * Sampling rate for statsd metrics as an associative array of patterns and rates.
+ * Patterns are Unix shell patterns (e.g. 'MediaWiki.api.*').
+ * Rates are sampling probabilities (e.g. 0.1 means 1 in 10 events are sampled).
+ * @since 1.28
+ */
+$wgStatsdSamplingRates = [];
+
+/**
+ * InfoAction retrieves a list of transclusion links (both to and from).
+ * This number puts a limit on that query in the case of highly transcluded
+ * templates.
+ */
+$wgPageInfoTransclusionLimit = 50;
+
+/**
+ * Set this to an integer to only do synchronous site_stats updates
+ * one every *this many* updates. The other requests go into pending
+ * delta values in $wgMemc. Make sure that $wgMemc is a global cache.
+ * If set to -1, updates *only* go to $wgMemc (useful for daemons).
+ */
+$wgSiteStatsAsyncFactor = false;
+
+/**
+ * Parser test suite files to be run by parserTests.php when no specific
+ * filename is passed to it.
+ *
+ * Extensions using extension.json will have any *.txt file in a
+ * tests/parser/ directory automatically run.
+ *
+ * Core tests can be added to ParserTestRunner::$coreTestFiles.
+ *
+ * Use full paths.
+ *
+ * @deprecated since 1.30
+ */
+$wgParserTestFiles = [];
+
+/**
+ * Allow running of javascript test suites via [[Special:JavaScriptTest]] (such as QUnit).
+ */
+$wgEnableJavaScriptTest = false;
+
+/**
+ * Overwrite the caching key prefix with custom value.
+ * @since 1.19
+ */
+$wgCachePrefix = false;
+
+/**
+ * Display the new debugging toolbar. This also enables profiling on database
+ * queries and other useful output.
+ * Will be ignored if $wgUseFileCache or $wgUseSquid is enabled.
+ *
+ * @since 1.19
+ */
+$wgDebugToolbar = false;
+
+/** @} */ # end of profiling, testing and debugging }
+
+/************************************************************************//**
+ * @name Search
+ * @{
+ */
+
+/**
+ * Set this to true to disable the full text search feature.
+ */
+$wgDisableTextSearch = false;
+
+/**
+ * Set to true to have nicer highlighted text in search results,
+ * by default off due to execution overhead
+ */
+$wgAdvancedSearchHighlighting = false;
+
+/**
+ * Regexp to match word boundaries, defaults for non-CJK languages
+ * should be empty for CJK since the words are not separate
+ */
+$wgSearchHighlightBoundaries = '[\p{Z}\p{P}\p{C}]';
+
+/**
+ * Template for OpenSearch suggestions, defaults to API action=opensearch
+ *
+ * Sites with heavy load would typically have these point to a custom
+ * PHP wrapper to avoid firing up mediawiki for every keystroke
+ *
+ * Placeholders: {searchTerms}
+ *
+ * @deprecated since 1.25 Use $wgOpenSearchTemplates['application/x-suggestions+json'] instead
+ */
+$wgOpenSearchTemplate = false;
+
+/**
+ * Templates for OpenSearch suggestions, defaults to API action=opensearch
+ *
+ * Sites with heavy load would typically have these point to a custom
+ * PHP wrapper to avoid firing up mediawiki for every keystroke
+ *
+ * Placeholders: {searchTerms}
+ */
+$wgOpenSearchTemplates = [
+ 'application/x-suggestions+json' => false,
+ 'application/x-suggestions+xml' => false,
+];
+
+/**
+ * Enable OpenSearch suggestions requested by MediaWiki. Set this to
+ * false if you've disabled scripts that use api?action=opensearch and
+ * want reduce load caused by cached scripts still pulling suggestions.
+ * It will let the API fallback by responding with an empty array.
+ */
+$wgEnableOpenSearchSuggest = true;
+
+/**
+ * Integer defining default number of entries to show on
+ * OpenSearch call.
+ */
+$wgOpenSearchDefaultLimit = 10;
+
+/**
+ * Minimum length of extract in <Description>. Actual extracts will last until the end of sentence.
+ */
+$wgOpenSearchDescriptionLength = 100;
+
+/**
+ * Expiry time for search suggestion responses
+ */
+$wgSearchSuggestCacheExpiry = 1200;
+
+/**
+ * If you've disabled search semi-permanently, this also disables updates to the
+ * table. If you ever re-enable, be sure to rebuild the search table.
+ */
+$wgDisableSearchUpdate = false;
+
+/**
+ * List of namespaces which are searched by default.
+ *
+ * @par Example:
+ * @code
+ * $wgNamespacesToBeSearchedDefault[NS_MAIN] = true;
+ * $wgNamespacesToBeSearchedDefault[NS_PROJECT] = true;
+ * @endcode
+ */
+$wgNamespacesToBeSearchedDefault = [
+ NS_MAIN => true,
+];
+
+/**
+ * Disable the internal MySQL-based search, to allow it to be
+ * implemented by an extension instead.
+ */
+$wgDisableInternalSearch = false;
+
+/**
+ * Set this to a URL to forward search requests to some external location.
+ * If the URL includes '$1', this will be replaced with the URL-encoded
+ * search term.
+ *
+ * @par Example:
+ * To forward to Google you'd have something like:
+ * @code
+ * $wgSearchForwardUrl =
+ * 'https://www.google.com/search?q=$1' .
+ * '&domains=https://example.com' .
+ * '&sitesearch=https://example.com' .
+ * '&ie=utf-8&oe=utf-8';
+ * @endcode
+ */
+$wgSearchForwardUrl = null;
+
+/**
+ * Search form behavior.
+ * - true = use Go & Search buttons
+ * - false = use Go button & Advanced search link
+ */
+$wgUseTwoButtonsSearchForm = true;
+
+/**
+ * Array of namespaces to generate a Google sitemap for when the
+ * maintenance/generateSitemap.php script is run, or false if one is to be
+ * generated for all namespaces.
+ */
+$wgSitemapNamespaces = false;
+
+/**
+ * Custom namespace priorities for sitemaps. Setting this will allow you to
+ * set custom priorities to namespaces when sitemaps are generated using the
+ * maintenance/generateSitemap.php script.
+ *
+ * This should be a map of namespace IDs to priority
+ * @par Example:
+ * @code
+ * $wgSitemapNamespacesPriorities = [
+ * NS_USER => '0.9',
+ * NS_HELP => '0.0',
+ * ];
+ * @endcode
+ */
+$wgSitemapNamespacesPriorities = false;
+
+/**
+ * If true, searches for IP addresses will be redirected to that IP's
+ * contributions page. E.g. searching for "1.2.3.4" will redirect to
+ * [[Special:Contributions/1.2.3.4]]
+ */
+$wgEnableSearchContributorsByIP = true;
+
+/** @} */ # end of search settings
+
+/************************************************************************//**
+ * @name Edit user interface
+ * @{
+ */
+
+/**
+ * Path to the GNU diff3 utility. If the file doesn't exist, edit conflicts will
+ * fall back to the old behavior (no merging).
+ */
+$wgDiff3 = '/usr/bin/diff3';
+
+/**
+ * Path to the GNU diff utility.
+ */
+$wgDiff = '/usr/bin/diff';
+
+/**
+ * Which namespaces have special treatment where they should be preview-on-open
+ * Internally only Category: pages apply, but using this extensions (e.g. Semantic MediaWiki)
+ * can specify namespaces of pages they have special treatment for
+ */
+$wgPreviewOnOpenNamespaces = [
+ NS_CATEGORY => true
+];
+
+/**
+ * Enable the UniversalEditButton for browsers that support it
+ * (currently only Firefox with an extension)
+ * See http://universaleditbutton.org for more background information
+ */
+$wgUniversalEditButton = true;
+
+/**
+ * If user doesn't specify any edit summary when making a an edit, MediaWiki
+ * will try to automatically create one. This feature can be disabled by set-
+ * ting this variable false.
+ */
+$wgUseAutomaticEditSummaries = true;
+
+/** @} */ # end edit UI }
+
+/************************************************************************//**
+ * @name Maintenance
+ * See also $wgSiteNotice
+ * @{
+ */
+
+/**
+ * @cond file_level_code
+ * Set $wgCommandLineMode if it's not set already, to avoid notices
+ */
+if ( !isset( $wgCommandLineMode ) ) {
+ $wgCommandLineMode = false;
+}
+/** @endcond */
+
+/**
+ * For colorized maintenance script output, is your terminal background dark ?
+ */
+$wgCommandLineDarkBg = false;
+
+/**
+ * Set this to a string to put the wiki into read-only mode. The text will be
+ * used as an explanation to users.
+ *
+ * This prevents most write operations via the web interface. Cache updates may
+ * still be possible. To prevent database writes completely, use the read_only
+ * option in MySQL.
+ */
+$wgReadOnly = null;
+
+/**
+ * If this lock file exists (size > 0), the wiki will be forced into read-only mode.
+ * Its contents will be shown to users as part of the read-only warning
+ * message.
+ *
+ * Will default to "{$wgUploadDirectory}/lock_yBgMBwiR" in Setup.php
+ */
+$wgReadOnlyFile = false;
+
+/**
+ * When you run the web-based upgrade utility, it will tell you what to set
+ * this to in order to authorize the upgrade process. It will subsequently be
+ * used as a password, to authorize further upgrades.
+ *
+ * For security, do not set this to a guessable string. Use the value supplied
+ * by the install/upgrade process. To cause the upgrader to generate a new key,
+ * delete the old key from LocalSettings.php.
+ */
+$wgUpgradeKey = false;
+
+/**
+ * Fully specified path to git binary
+ */
+$wgGitBin = '/usr/bin/git';
+
+/**
+ * Map GIT repository URLs to viewer URLs to provide links in Special:Version
+ *
+ * Key is a pattern passed to preg_match() and preg_replace(),
+ * without the delimiters (which are #) and must match the whole URL.
+ * The value is the replacement for the key (it can contain $1, etc.)
+ * %h will be replaced by the short SHA-1 (7 first chars) and %H by the
+ * full SHA-1 of the HEAD revision.
+ * %r will be replaced with a URL-encoded version of $1.
+ * %R will be replaced with $1 and no URL-encoding
+ *
+ * @since 1.20
+ */
+$wgGitRepositoryViewers = [
+ 'https://(?:[a-z0-9_]+@)?gerrit.wikimedia.org/r/(?:p/)?(.*)' =>
+ 'https://phabricator.wikimedia.org/r/revision/%R;%H',
+ 'ssh://(?:[a-z0-9_]+@)?gerrit.wikimedia.org:29418/(.*)' =>
+ 'https://phabricator.wikimedia.org/r/revision/%R;%H',
+];
+
+/** @} */ # End of maintenance }
+
+/************************************************************************//**
+ * @name Recent changes, new pages, watchlist and history
+ * @{
+ */
+
+/**
+ * Recentchanges items are periodically purged; entries older than this many
+ * seconds will go.
+ * Default: 90 days = about three months
+ */
+$wgRCMaxAge = 90 * 24 * 3600;
+
+/**
+ * Page watchers inactive for more than this many seconds are considered inactive.
+ * Used mainly by action=info. Default: 180 days = about six months.
+ * @since 1.26
+ */
+$wgWatchersMaxAge = 180 * 24 * 3600;
+
+/**
+ * If active watchers (per above) are this number or less, do not disclose it.
+ * Left to 1, prevents unprivileged users from knowing for sure that there are 0.
+ * Set to -1 if you want to always complement watchers count with this info.
+ * @since 1.26
+ */
+$wgUnwatchedPageSecret = 1;
+
+/**
+ * Filter $wgRCLinkDays by $wgRCMaxAge to avoid showing links for numbers
+ * higher than what will be stored. Note that this is disabled by default
+ * because we sometimes do have RC data which is beyond the limit for some
+ * reason, and some users may use the high numbers to display that data which
+ * is still there.
+ */
+$wgRCFilterByAge = false;
+
+/**
+ * List of Limits options to list in the Special:Recentchanges and
+ * Special:Recentchangeslinked pages.
+ */
+$wgRCLinkLimits = [ 50, 100, 250, 500 ];
+
+/**
+ * List of Days options to list in the Special:Recentchanges and
+ * Special:Recentchangeslinked pages.
+ */
+$wgRCLinkDays = [ 1, 3, 7, 14, 30 ];
+
+/**
+ * Configuration for feeds to which notifications about recent changes will be sent.
+ *
+ * The following feed classes are available by default:
+ * - 'UDPRCFeedEngine' - sends recent changes over UDP to the specified server.
+ * - 'RedisPubSubFeedEngine' - send recent changes to Redis.
+ *
+ * Only 'class' or 'uri' is required. If 'uri' is set instead of 'class', then
+ * RecentChange::getEngine() is used to determine the class. All options are
+ * passed to the constructor.
+ *
+ * Common options:
+ * - 'class' -- The class to use for this feed (must implement RCFeed).
+ * - 'omit_bots' -- Exclude bot edits from the feed. (default: false)
+ * - 'omit_anon' -- Exclude anonymous edits from the feed. (default: false)
+ * - 'omit_user' -- Exclude edits by registered users from the feed. (default: false)
+ * - 'omit_minor' -- Exclude minor edits from the feed. (default: false)
+ * - 'omit_patrolled' -- Exclude patrolled edits from the feed. (default: false)
+ *
+ * FormattedRCFeed-specific options:
+ * - 'uri' -- [required] The address to which the messages are sent.
+ * The uri scheme of this string will be looked up in $wgRCEngines
+ * to determine which RCFeedEngine class to use.
+ * - 'formatter' -- [required] The class (implementing RCFeedFormatter) which will
+ * produce the text to send. This can also be an object of the class.
+ * Formatters available by default: JSONRCFeedFormatter, XMLRCFeedFormatter,
+ * IRCColourfulRCFeedFormatter.
+ *
+ * IRCColourfulRCFeedFormatter-specific options:
+ * - 'add_interwiki_prefix' -- whether the titles should be prefixed with
+ * the first entry in the $wgLocalInterwikis array (or the value of
+ * $wgLocalInterwiki, if set)
+ *
+ * JSONRCFeedFormatter-specific options:
+ * - 'channel' -- if set, the 'channel' parameter is also set in JSON values.
+ *
+ * @example $wgRCFeeds['example'] = [
+ * 'uri' => 'udp://localhost:1336',
+ * 'formatter' => 'JSONRCFeedFormatter',
+ * 'add_interwiki_prefix' => false,
+ * 'omit_bots' => true,
+ * ];
+ * @example $wgRCFeeds['example'] = [
+ * 'uri' => 'udp://localhost:1338',
+ * 'formatter' => 'IRCColourfulRCFeedFormatter',
+ * 'add_interwiki_prefix' => false,
+ * 'omit_bots' => true,
+ * ];
+ * @example $wgRCFeeds['example'] = [
+ * 'class' => 'ExampleRCFeed',
+ * ];
+ * @since 1.22
+ */
+$wgRCFeeds = [];
+
+/**
+ * Used by RecentChange::getEngine to find the correct engine for a given URI scheme.
+ * Keys are scheme names, values are names of FormattedRCFeed sub classes.
+ * @since 1.22
+ */
+$wgRCEngines = [
+ 'redis' => 'RedisPubSubFeedEngine',
+ 'udp' => 'UDPRCFeedEngine',
+];
+
+/**
+ * Treat category membership changes as a RecentChange.
+ * Changes are mentioned in RC for page actions as follows:
+ * - creation: pages created with categories are mentioned
+ * - edit: category additions/removals to existing pages are mentioned
+ * - move: nothing is mentioned (unless templates used depend on the title)
+ * - deletion: nothing is mentioned
+ * - undeletion: nothing is mentioned
+ *
+ * @since 1.27
+ */
+$wgRCWatchCategoryMembership = false;
+
+/**
+ * Use RC Patrolling to check for vandalism (from recent changes and watchlists)
+ * New pages and new files are included.
+ */
+$wgUseRCPatrol = true;
+
+/**
+ * Whether a preference is displayed for structured change filters.
+ * If false, no preference is displayed and structured change filters are disabled.
+ * If true, structured change filters are *enabled* by default, and a preference is displayed
+ * that lets users disable them.
+ *
+ * Temporary variable during development and will be removed.
+ *
+ * @since 1.30
+ */
+$wgStructuredChangeFiltersShowPreference = false;
+
+/**
+ * Whether to show the new experimental views (like namespaces, tags, and users) in
+ * RecentChanges filters
+ *
+ * Temporary variable during development and will be removed.
+ */
+$wgStructuredChangeFiltersEnableExperimentalViews = false;
+
+/**
+ * Whether to enable RCFilters app on Special:Watchlist
+ *
+ * Temporary variable during development and will be removed.
+ */
+$wgStructuredChangeFiltersOnWatchlist = false;
+
+/**
+ * Use new page patrolling to check new pages on Special:Newpages
+ */
+$wgUseNPPatrol = true;
+
+/**
+ * Use file patrolling to check new files on Special:Newfiles
+ *
+ * @since 1.27
+ */
+$wgUseFilePatrol = true;
+
+/**
+ * Log autopatrol actions to the log table
+ */
+$wgLogAutopatrol = true;
+
+/**
+ * Provide syndication feeds (RSS, Atom) for, e.g., Recentchanges, Newpages
+ */
+$wgFeed = true;
+
+/**
+ * Set maximum number of results to return in syndication feeds (RSS, Atom) for
+ * eg Recentchanges, Newpages.
+ */
+$wgFeedLimit = 50;
+
+/**
+ * _Minimum_ timeout for cached Recentchanges feed, in seconds.
+ * A cached version will continue to be served out even if changes
+ * are made, until this many seconds runs out since the last render.
+ *
+ * If set to 0, feed caching is disabled. Use this for debugging only;
+ * feed generation can be pretty slow with diffs.
+ */
+$wgFeedCacheTimeout = 60;
+
+/**
+ * When generating Recentchanges RSS/Atom feed, diffs will not be generated for
+ * pages larger than this size.
+ */
+$wgFeedDiffCutoff = 32768;
+
+/**
+ * Override the site's default RSS/ATOM feed for recentchanges that appears on
+ * every page. Some sites might have a different feed they'd like to promote
+ * instead of the RC feed (maybe like a "Recent New Articles" or "Breaking news" one).
+ * Should be a format as key (either 'rss' or 'atom') and an URL to the feed
+ * as value.
+ * @par Example:
+ * Configure the 'atom' feed to https://example.com/somefeed.xml
+ * @code
+ * $wgSiteFeed['atom'] = "https://example.com/somefeed.xml";
+ * @endcode
+ */
+$wgOverrideSiteFeed = [];
+
+/**
+ * Available feeds objects.
+ * Should probably only be defined when a page is syndicated ie when
+ * $wgOut->isSyndicated() is true.
+ */
+$wgFeedClasses = [
+ 'rss' => 'RSSFeed',
+ 'atom' => 'AtomFeed',
+];
+
+/**
+ * Which feed types should we provide by default? This can include 'rss',
+ * 'atom', neither, or both.
+ */
+$wgAdvertisedFeedTypes = [ 'atom' ];
+
+/**
+ * Show watching users in recent changes, watchlist and page history views
+ */
+$wgRCShowWatchingUsers = false; # UPO
+
+/**
+ * Show the amount of changed characters in recent changes
+ */
+$wgRCShowChangedSize = true;
+
+/**
+ * If the difference between the character counts of the text
+ * before and after the edit is below that value, the value will be
+ * highlighted on the RC page.
+ */
+$wgRCChangedSizeThreshold = 500;
+
+/**
+ * Show "Updated (since my last visit)" marker in RC view, watchlist and history
+ * view for watched pages with new changes
+ */
+$wgShowUpdatedMarker = true;
+
+/**
+ * Disable links to talk pages of anonymous users (IPs) in listings on special
+ * pages like page history, Special:Recentchanges, etc.
+ */
+$wgDisableAnonTalk = false;
+
+/**
+ * Enable filtering of categories in Recentchanges
+ */
+$wgAllowCategorizedRecentChanges = false;
+
+/**
+ * Allow filtering by change tag in recentchanges, history, etc
+ * Has no effect if no tags are defined in valid_tag.
+ */
+$wgUseTagFilter = true;
+
+/**
+ * If set to an integer, pages that are watched by this many users or more
+ * will not require the unwatchedpages permission to view the number of
+ * watchers.
+ *
+ * @since 1.21
+ */
+$wgUnwatchedPageThreshold = false;
+
+/**
+ * Flags (letter symbols) shown in recent changes and watchlist to indicate
+ * certain types of edits.
+ *
+ * To register a new one:
+ * @code
+ * $wgRecentChangesFlags['flag'] => [
+ * // message for the letter displayed next to rows on changes lists
+ * 'letter' => 'letter-msg',
+ * // message for the tooltip of the letter
+ * 'title' => 'tooltip-msg',
+ * // optional (defaults to 'tooltip-msg'), message to use in the legend box
+ * 'legend' => 'legend-msg',
+ * // optional (defaults to 'flag'), CSS class to put on changes lists rows
+ * 'class' => 'css-class',
+ * // optional (defaults to 'any'), how top-level flag is determined. 'any'
+ * // will set the top-level flag if any line contains the flag, 'all' will
+ * // only be set if all lines contain the flag.
+ * 'grouping' => 'any',
+ * ];
+ * @endcode
+ *
+ * @since 1.22
+ */
+$wgRecentChangesFlags = [
+ 'newpage' => [
+ 'letter' => 'newpageletter',
+ 'title' => 'recentchanges-label-newpage',
+ 'legend' => 'recentchanges-legend-newpage',
+ 'grouping' => 'any',
+ ],
+ 'minor' => [
+ 'letter' => 'minoreditletter',
+ 'title' => 'recentchanges-label-minor',
+ 'legend' => 'recentchanges-legend-minor',
+ 'class' => 'minoredit',
+ 'grouping' => 'all',
+ ],
+ 'bot' => [
+ 'letter' => 'boteditletter',
+ 'title' => 'recentchanges-label-bot',
+ 'legend' => 'recentchanges-legend-bot',
+ 'class' => 'botedit',
+ 'grouping' => 'all',
+ ],
+ 'unpatrolled' => [
+ 'letter' => 'unpatrolledletter',
+ 'title' => 'recentchanges-label-unpatrolled',
+ 'legend' => 'recentchanges-legend-unpatrolled',
+ 'grouping' => 'any',
+ ],
+];
+
+/** @} */ # end RC/watchlist }
+
+/************************************************************************//**
+ * @name Copyright and credits settings
+ * @{
+ */
+
+/**
+ * Override for copyright metadata.
+ *
+ * This is the name of the page containing information about the wiki's copyright status,
+ * which will be added as a link in the footer if it is specified. It overrides
+ * $wgRightsUrl if both are specified.
+ */
+$wgRightsPage = null;
+
+/**
+ * Set this to specify an external URL containing details about the content license used on your
+ * wiki.
+ * If $wgRightsPage is set then this setting is ignored.
+ */
+$wgRightsUrl = null;
+
+/**
+ * If either $wgRightsUrl or $wgRightsPage is specified then this variable gives the text for the
+ * link.
+ * If using $wgRightsUrl then this value must be specified. If using $wgRightsPage then the name
+ * of the page will also be used as the link if this variable is not set.
+ */
+$wgRightsText = null;
+
+/**
+ * Override for copyright metadata.
+ */
+$wgRightsIcon = null;
+
+/**
+ * Set this to true if you want detailed copyright information forms on Upload.
+ */
+$wgUseCopyrightUpload = false;
+
+/**
+ * Set this to the number of authors that you want to be credited below an
+ * article text. Set it to zero to hide the attribution block, and a negative
+ * number (like -1) to show all authors. Note that this will require 2-3 extra
+ * database hits, which can have a not insignificant impact on performance for
+ * large wikis.
+ */
+$wgMaxCredits = 0;
+
+/**
+ * If there are more than $wgMaxCredits authors, show $wgMaxCredits of them.
+ * Otherwise, link to a separate credits page.
+ */
+$wgShowCreditsIfMax = true;
+
+/** @} */ # end of copyright and credits settings }
+
+/************************************************************************//**
+ * @name Import / Export
+ * @{
+ */
+
+/**
+ * List of interwiki prefixes for wikis we'll accept as sources for
+ * Special:Import and API action=import. Since complete page history can be
+ * imported, these should be 'trusted'.
+ *
+ * This can either be a regular array, or an associative map specifying
+ * subprojects on the interwiki map of the target wiki, or a mix of the two,
+ * e.g.
+ * @code
+ * $wgImportSources = [
+ * 'wikipedia' => [ 'cs', 'en', 'fr', 'zh' ],
+ * 'wikispecies',
+ * 'wikia' => [ 'animanga', 'brickipedia', 'desserts' ],
+ * ];
+ * @endcode
+ *
+ * If you have a very complex import sources setup, you can lazy-load it using
+ * the ImportSources hook.
+ *
+ * If a user has the 'import' permission but not the 'importupload' permission,
+ * they will only be able to run imports through this transwiki interface.
+ */
+$wgImportSources = [];
+
+/**
+ * Optional default target namespace for interwiki imports.
+ * Can use this to create an incoming "transwiki"-style queue.
+ * Set to numeric key, not the name.
+ *
+ * Users may override this in the Special:Import dialog.
+ */
+$wgImportTargetNamespace = null;
+
+/**
+ * If set to false, disables the full-history option on Special:Export.
+ * This is currently poorly optimized for long edit histories, so is
+ * disabled on Wikimedia's sites.
+ */
+$wgExportAllowHistory = true;
+
+/**
+ * If set nonzero, Special:Export requests for history of pages with
+ * more revisions than this will be rejected. On some big sites things
+ * could get bogged down by very very long pages.
+ */
+$wgExportMaxHistory = 0;
+
+/**
+ * Return distinct author list (when not returning full history)
+ */
+$wgExportAllowListContributors = false;
+
+/**
+ * If non-zero, Special:Export accepts a "pagelink-depth" parameter
+ * up to this specified level, which will cause it to include all
+ * pages linked to from the pages you specify. Since this number
+ * can become *insanely large* and could easily break your wiki,
+ * it's disabled by default for now.
+ *
+ * @warning There's a HARD CODED limit of 5 levels of recursion to prevent a
+ * crazy-big export from being done by someone setting the depth number too
+ * high. In other words, last resort safety net.
+ */
+$wgExportMaxLinkDepth = 0;
+
+/**
+ * Whether to allow the "export all pages in namespace" option
+ */
+$wgExportFromNamespaces = false;
+
+/**
+ * Whether to allow exporting the entire wiki into a single file
+ */
+$wgExportAllowAll = false;
+
+/**
+ * Maximum number of pages returned by the GetPagesFromCategory and
+ * GetPagesFromNamespace functions.
+ *
+ * @since 1.27
+ */
+$wgExportPagelistLimit = 5000;
+
+/** @} */ # end of import/export }
+
+/*************************************************************************//**
+ * @name Extensions
+ * @{
+ */
+
+/**
+ * A list of callback functions which are called once MediaWiki is fully
+ * initialised
+ */
+$wgExtensionFunctions = [];
+
+/**
+ * Extension messages files.
+ *
+ * Associative array mapping extension name to the filename where messages can be
+ * found. The file should contain variable assignments. Any of the variables
+ * present in languages/messages/MessagesEn.php may be defined, but $messages
+ * is the most common.
+ *
+ * Variables defined in extensions will override conflicting variables defined
+ * in the core.
+ *
+ * Since MediaWiki 1.23, use of this variable to define messages is discouraged; instead, store
+ * messages in JSON format and use $wgMessagesDirs. For setting other variables than
+ * $messages, $wgExtensionMessagesFiles should still be used. Use a DIFFERENT key because
+ * any entry having a key that also exists in $wgMessagesDirs will be ignored.
+ *
+ * Extensions using the JSON message format can preserve backward compatibility with
+ * earlier versions of MediaWiki by using a compatibility shim, such as one generated
+ * by the generateJsonI18n.php maintenance script, listing it under the SAME key
+ * as for the $wgMessagesDirs entry.
+ *
+ * @par Example:
+ * @code
+ * $wgExtensionMessagesFiles['ConfirmEdit'] = __DIR__.'/ConfirmEdit.i18n.php';
+ * @endcode
+ */
+$wgExtensionMessagesFiles = [];
+
+/**
+ * Extension messages directories.
+ *
+ * Associative array mapping extension name to the path of the directory where message files can
+ * be found. The message files are expected to be JSON files named for their language code, e.g.
+ * en.json, de.json, etc. Extensions with messages in multiple places may specify an array of
+ * message directories.
+ *
+ * Message directories in core should be added to LocalisationCache::getMessagesDirs()
+ *
+ * @par Simple example:
+ * @code
+ * $wgMessagesDirs['Example'] = __DIR__ . '/i18n';
+ * @endcode
+ *
+ * @par Complex example:
+ * @code
+ * $wgMessagesDirs['Example'] = [
+ * __DIR__ . '/lib/ve/i18n',
+ * __DIR__ . '/lib/oojs-ui/i18n',
+ * __DIR__ . '/i18n',
+ * ]
+ * @endcode
+ * @since 1.23
+ */
+$wgMessagesDirs = [];
+
+/**
+ * Array of files with list(s) of extension entry points to be used in
+ * maintenance/mergeMessageFileList.php
+ * @since 1.22
+ */
+$wgExtensionEntryPointListFiles = [];
+
+/**
+ * Parser output hooks.
+ * This is an associative array where the key is an extension-defined tag
+ * (typically the extension name), and the value is a PHP callback.
+ * These will be called as an OutputPageParserOutput hook, if the relevant
+ * tag has been registered with the parser output object.
+ *
+ * Registration is done with $pout->addOutputHook( $tag, $data ).
+ *
+ * The callback has the form:
+ * @code
+ * function outputHook( $outputPage, $parserOutput, $data ) { ... }
+ * @endcode
+ */
+$wgParserOutputHooks = [];
+
+/**
+ * Whether to include the NewPP limit report as a HTML comment
+ */
+$wgEnableParserLimitReporting = true;
+
+/**
+ * List of valid skin names
+ *
+ * The key should be the name in all lower case, the value should be a properly
+ * cased name for the skin. This value will be prefixed with "Skin" to create
+ * the class name of the skin to load. Use Skin::getSkinNames() as an accessor
+ * if you wish to have access to the full list.
+ */
+$wgValidSkinNames = [];
+
+/**
+ * Special page list. This is an associative array mapping the (canonical) names of
+ * special pages to either a class name to be instantiated, or a callback to use for
+ * creating the special page object. In both cases, the result must be an instance of
+ * SpecialPage.
+ */
+$wgSpecialPages = [];
+
+/**
+ * Array mapping class names to filenames, for autoloading.
+ */
+$wgAutoloadClasses = [];
+
+/**
+ * Switch controlling legacy case-insensitive classloading.
+ * Do not disable if your wiki must support data created by PHP4, or by
+ * MediaWiki 1.4 or earlier.
+ */
+$wgAutoloadAttemptLowercase = true;
+
+/**
+ * An array of information about installed extensions keyed by their type.
+ *
+ * All but 'name', 'path' and 'author' can be omitted.
+ *
+ * @code
+ * $wgExtensionCredits[$type][] = [
+ * 'path' => __FILE__,
+ * 'name' => 'Example extension',
+ * 'namemsg' => 'exampleextension-name',
+ * 'author' => [
+ * 'Foo Barstein',
+ * ],
+ * 'version' => '1.9.0',
+ * 'url' => 'https://example.org/example-extension/',
+ * 'descriptionmsg' => 'exampleextension-desc',
+ * 'license-name' => 'GPL-2.0+',
+ * ];
+ * @endcode
+ *
+ * The extensions are listed on Special:Version. This page also looks for a file
+ * named COPYING or LICENSE (optional .txt extension) and provides a link to
+ * view said file. When the 'license-name' key is specified, this file is
+ * interpreted as wikitext.
+ *
+ * - $type: One of 'specialpage', 'parserhook', 'variable', 'media', 'antispam',
+ * 'skin', 'api', or 'other', or any additional types as specified through the
+ * ExtensionTypes hook as used in SpecialVersion::getExtensionTypes().
+ *
+ * - name: Name of extension as an inline string instead of localizable message.
+ * Do not omit this even if 'namemsg' is provided, as it is used to override
+ * the path Special:Version uses to find extension's license info, and is
+ * required for backwards-compatibility with MediaWiki 1.23 and older.
+ *
+ * - namemsg (since MW 1.24): A message key for a message containing the
+ * extension's name, if the name is localizable. (For example, skin names
+ * usually are.)
+ *
+ * - author: A string or an array of strings. Authors can be linked using
+ * the regular wikitext link syntax. To have an internationalized version of
+ * "and others" show, add an element "...". This element can also be linked,
+ * for instance "[https://example ...]".
+ *
+ * - descriptionmsg: A message key or an an array with message key and parameters:
+ * `'descriptionmsg' => 'exampleextension-desc',`
+ *
+ * - description: Description of extension as an inline string instead of
+ * localizable message (omit in favour of 'descriptionmsg').
+ *
+ * - license-name: Short name of the license (used as label for the link), such
+ * as "GPL-2.0+" or "MIT" (https://spdx.org/licenses/ for a list of identifiers).
+ */
+$wgExtensionCredits = [];
+
+/**
+ * Authentication plugin.
+ * @var $wgAuth AuthPlugin
+ * @deprecated since 1.27 use $wgAuthManagerConfig instead
+ */
+$wgAuth = null;
+
+/**
+ * Global list of hooks.
+ *
+ * The key is one of the events made available by MediaWiki, you can find
+ * a description for most of them in docs/hooks.txt. The array is used
+ * internally by Hook:run().
+ *
+ * The value can be one of:
+ *
+ * - A function name:
+ * @code
+ * $wgHooks['event_name'][] = $function;
+ * @endcode
+ * - A function with some data:
+ * @code
+ * $wgHooks['event_name'][] = [ $function, $data ];
+ * @endcode
+ * - A an object method:
+ * @code
+ * $wgHooks['event_name'][] = [ $object, 'method' ];
+ * @endcode
+ * - A closure:
+ * @code
+ * $wgHooks['event_name'][] = function ( $hookParam ) {
+ * // Handler code goes here.
+ * };
+ * @endcode
+ *
+ * @warning You should always append to an event array or you will end up
+ * deleting a previous registered hook.
+ *
+ * @warning Hook handlers should be registered at file scope. Registering
+ * handlers after file scope can lead to unexpected results due to caching.
+ */
+$wgHooks = [];
+
+/**
+ * List of service wiring files to be loaded by the default instance of MediaWikiServices.
+ * Each file listed here is expected to return an associative array mapping service names
+ * to instantiator functions. Extensions may add wiring files to define their own services.
+ * However, this cannot be used to replace existing services - use the MediaWikiServices
+ * hook for that.
+ *
+ * @see MediaWikiServices
+ * @see ServiceContainer::loadWiringFiles() for details on loading service instantiator functions.
+ * @see docs/injection.txt for an overview of dependency injection in MediaWiki.
+ */
+$wgServiceWiringFiles = [
+ __DIR__ . '/ServiceWiring.php'
+];
+
+/**
+ * Maps jobs to their handlers; extensions
+ * can add to this to provide custom jobs.
+ * A job handler should either be a class name to be instantiated,
+ * or (since 1.30) a callback to use for creating the job object.
+ */
+$wgJobClasses = [
+ 'refreshLinks' => 'RefreshLinksJob',
+ 'deleteLinks' => 'DeleteLinksJob',
+ 'htmlCacheUpdate' => 'HTMLCacheUpdateJob',
+ 'sendMail' => 'EmaillingJob',
+ 'enotifNotify' => 'EnotifNotifyJob',
+ 'fixDoubleRedirect' => 'DoubleRedirectJob',
+ 'AssembleUploadChunks' => 'AssembleUploadChunksJob',
+ 'PublishStashedFile' => 'PublishStashedFileJob',
+ 'ThumbnailRender' => 'ThumbnailRenderJob',
+ 'recentChangesUpdate' => 'RecentChangesUpdateJob',
+ 'refreshLinksPrioritized' => 'RefreshLinksJob',
+ 'refreshLinksDynamic' => 'RefreshLinksJob',
+ 'activityUpdateJob' => 'ActivityUpdateJob',
+ 'categoryMembershipChange' => 'CategoryMembershipChangeJob',
+ 'cdnPurge' => 'CdnPurgeJob',
+ 'enqueue' => 'EnqueueJob', // local queue for multi-DC setups
+ 'null' => 'NullJob'
+];
+
+/**
+ * Jobs that must be explicitly requested, i.e. aren't run by job runners unless
+ * special flags are set. The values here are keys of $wgJobClasses.
+ *
+ * These can be:
+ * - Very long-running jobs.
+ * - Jobs that you would never want to run as part of a page rendering request.
+ * - Jobs that you want to run on specialized machines ( like transcoding, or a particular
+ * machine on your cluster has 'outside' web access you could restrict uploadFromUrl )
+ * These settings should be global to all wikis.
+ */
+$wgJobTypesExcludedFromDefaultQueue = [ 'AssembleUploadChunks', 'PublishStashedFile' ];
+
+/**
+ * Map of job types to how many job "work items" should be run per second
+ * on each job runner process. The meaning of "work items" varies per job,
+ * but typically would be something like "pages to update". A single job
+ * may have a variable number of work items, as is the case with batch jobs.
+ * This is used by runJobs.php and not jobs run via $wgJobRunRate.
+ * These settings should be global to all wikis.
+ * @var float[]
+ */
+$wgJobBackoffThrottling = [];
+
+/**
+ * Make job runners commit changes for replica DB-lag prone jobs one job at a time.
+ * This is useful if there are many job workers that race on replica DB lag checks.
+ * If set, jobs taking this many seconds of DB write time have serialized commits.
+ *
+ * Note that affected jobs may have worse lock contention. Also, if they affect
+ * several DBs at once they may have a smaller chance of being atomic due to the
+ * possibility of connection loss while queueing up to commit. Affected jobs may
+ * also fail due to the commit lock acquisition timeout.
+ *
+ * @var float|bool
+ * @since 1.26
+ */
+$wgJobSerialCommitThreshold = false;
+
+/**
+ * Map of job types to configuration arrays.
+ * This determines which queue class and storage system is used for each job type.
+ * Job types that do not have explicit configuration will use the 'default' config.
+ * These settings should be global to all wikis.
+ */
+$wgJobTypeConf = [
+ 'default' => [ 'class' => 'JobQueueDB', 'order' => 'random', 'claimTTL' => 3600 ],
+];
+
+/**
+ * Which aggregator to use for tracking which queues have jobs.
+ * These settings should be global to all wikis.
+ */
+$wgJobQueueAggregator = [
+ 'class' => 'JobQueueAggregatorNull'
+];
+
+/**
+ * Whether to include the number of jobs that are queued
+ * for the API's maxlag parameter.
+ * The total number of jobs will be divided by this to get an
+ * estimated second of maxlag. Typically bots backoff at maxlag=5,
+ * so setting this to the max number of jobs that should be in your
+ * queue divided by 5 should have the effect of stopping bots once
+ * that limit is hit.
+ *
+ * @since 1.29
+ */
+$wgJobQueueIncludeInMaxLagFactor = false;
+
+/**
+ * Additional functions to be performed with updateSpecialPages.
+ * Expensive Querypages are already updated.
+ */
+$wgSpecialPageCacheUpdates = [
+ 'Statistics' => [ 'SiteStatsUpdate', 'cacheUpdate' ]
+];
+
+/**
+ * Page property link table invalidation lists. When a page property
+ * changes, this may require other link tables to be updated (eg
+ * adding __HIDDENCAT__ means the hiddencat tracking category will
+ * have been added, so the categorylinks table needs to be rebuilt).
+ * This array can be added to by extensions.
+ */
+$wgPagePropLinkInvalidations = [
+ 'hiddencat' => 'categorylinks',
+];
+
+/** @} */ # End extensions }
+
+/*************************************************************************//**
+ * @name Categories
+ * @{
+ */
+
+/**
+ * Use experimental, DMOZ-like category browser
+ */
+$wgUseCategoryBrowser = false;
+
+/**
+ * On category pages, show thumbnail gallery for images belonging to that
+ * category instead of listing them as articles.
+ */
+$wgCategoryMagicGallery = true;
+
+/**
+ * Paging limit for categories
+ */
+$wgCategoryPagingLimit = 200;
+
+/**
+ * Specify how category names should be sorted, when listed on a category page.
+ * A sorting scheme is also known as a collation.
+ *
+ * Available values are:
+ *
+ * - uppercase: Converts the category name to upper case, and sorts by that.
+ *
+ * - identity: Does no conversion. Sorts by binary value of the string.
+ *
+ * - uca-default: Provides access to the Unicode Collation Algorithm with
+ * the default element table. This is a compromise collation which sorts
+ * all languages in a mediocre way. However, it is better than "uppercase".
+ *
+ * To use the uca-default collation, you must have PHP's intl extension
+ * installed. See https://secure.php.net/manual/en/intl.setup.php . The details of the
+ * resulting collation will depend on the version of ICU installed on the
+ * server.
+ *
+ * After you change this, you must run maintenance/updateCollation.php to fix
+ * the sort keys in the database.
+ *
+ * Extensions can define there own collations by subclassing Collation
+ * and using the Collation::factory hook.
+ */
+$wgCategoryCollation = 'uppercase';
+
+/** @} */ # End categories }
+
+/*************************************************************************//**
+ * @name Logging
+ * @{
+ */
+
+/**
+ * The logging system has two levels: an event type, which describes the
+ * general category and can be viewed as a named subset of all logs; and
+ * an action, which is a specific kind of event that can exist in that
+ * log type.
+ */
+$wgLogTypes = [
+ '',
+ 'block',
+ 'protect',
+ 'rights',
+ 'delete',
+ 'upload',
+ 'move',
+ 'import',
+ 'patrol',
+ 'merge',
+ 'suppress',
+ 'tag',
+ 'managetags',
+ 'contentmodel',
+];
+
+/**
+ * This restricts log access to those who have a certain right
+ * Users without this will not see it in the option menu and can not view it
+ * Restricted logs are not added to recent changes
+ * Logs should remain non-transcludable
+ * Format: logtype => permissiontype
+ */
+$wgLogRestrictions = [
+ 'suppress' => 'suppressionlog'
+];
+
+/**
+ * Show/hide links on Special:Log will be shown for these log types.
+ *
+ * This is associative array of log type => boolean "hide by default"
+ *
+ * See $wgLogTypes for a list of available log types.
+ *
+ * @par Example:
+ * @code
+ * $wgFilterLogTypes = [ 'move' => true, 'import' => false ];
+ * @endcode
+ *
+ * Will display show/hide links for the move and import logs. Move logs will be
+ * hidden by default unless the link is clicked. Import logs will be shown by
+ * default, and hidden when the link is clicked.
+ *
+ * A message of the form log-show-hide-[type] should be added, and will be used
+ * for the link text.
+ */
+$wgFilterLogTypes = [
+ 'patrol' => true,
+ 'tag' => true,
+];
+
+/**
+ * Lists the message key string for each log type. The localized messages
+ * will be listed in the user interface.
+ *
+ * Extensions with custom log types may add to this array.
+ *
+ * @since 1.19, if you follow the naming convention log-name-TYPE,
+ * where TYPE is your log type, yoy don't need to use this array.
+ */
+$wgLogNames = [
+ '' => 'all-logs-page',
+ 'block' => 'blocklogpage',
+ 'protect' => 'protectlogpage',
+ 'rights' => 'rightslog',
+ 'delete' => 'dellogpage',
+ 'upload' => 'uploadlogpage',
+ 'move' => 'movelogpage',
+ 'import' => 'importlogpage',
+ 'patrol' => 'patrol-log-page',
+ 'merge' => 'mergelog',
+ 'suppress' => 'suppressionlog',
+];
+
+/**
+ * Lists the message key string for descriptive text to be shown at the
+ * top of each log type.
+ *
+ * Extensions with custom log types may add to this array.
+ *
+ * @since 1.19, if you follow the naming convention log-description-TYPE,
+ * where TYPE is your log type, yoy don't need to use this array.
+ */
+$wgLogHeaders = [
+ '' => 'alllogstext',
+ 'block' => 'blocklogtext',
+ 'delete' => 'dellogpagetext',
+ 'import' => 'importlogpagetext',
+ 'merge' => 'mergelogpagetext',
+ 'move' => 'movelogpagetext',
+ 'patrol' => 'patrol-log-header',
+ 'protect' => 'protectlogtext',
+ 'rights' => 'rightslogtext',
+ 'suppress' => 'suppressionlogtext',
+ 'upload' => 'uploadlogpagetext',
+];
+
+/**
+ * Lists the message key string for formatting individual events of each
+ * type and action when listed in the logs.
+ *
+ * Extensions with custom log types may add to this array.
+ */
+$wgLogActions = [];
+
+/**
+ * The same as above, but here values are names of classes,
+ * not messages.
+ * @see LogPage::actionText
+ * @see LogFormatter
+ */
+$wgLogActionsHandlers = [
+ 'block/block' => 'BlockLogFormatter',
+ 'block/reblock' => 'BlockLogFormatter',
+ 'block/unblock' => 'BlockLogFormatter',
+ 'contentmodel/change' => 'ContentModelLogFormatter',
+ 'contentmodel/new' => 'ContentModelLogFormatter',
+ 'delete/delete' => 'DeleteLogFormatter',
+ 'delete/delete_redir' => 'DeleteLogFormatter',
+ 'delete/event' => 'DeleteLogFormatter',
+ 'delete/restore' => 'DeleteLogFormatter',
+ 'delete/revision' => 'DeleteLogFormatter',
+ 'import/interwiki' => 'ImportLogFormatter',
+ 'import/upload' => 'ImportLogFormatter',
+ 'managetags/activate' => 'LogFormatter',
+ 'managetags/create' => 'LogFormatter',
+ 'managetags/deactivate' => 'LogFormatter',
+ 'managetags/delete' => 'LogFormatter',
+ 'merge/merge' => 'MergeLogFormatter',
+ 'move/move' => 'MoveLogFormatter',
+ 'move/move_redir' => 'MoveLogFormatter',
+ 'patrol/patrol' => 'PatrolLogFormatter',
+ 'patrol/autopatrol' => 'PatrolLogFormatter',
+ 'protect/modify' => 'ProtectLogFormatter',
+ 'protect/move_prot' => 'ProtectLogFormatter',
+ 'protect/protect' => 'ProtectLogFormatter',
+ 'protect/unprotect' => 'ProtectLogFormatter',
+ 'rights/autopromote' => 'RightsLogFormatter',
+ 'rights/rights' => 'RightsLogFormatter',
+ 'suppress/block' => 'BlockLogFormatter',
+ 'suppress/delete' => 'DeleteLogFormatter',
+ 'suppress/event' => 'DeleteLogFormatter',
+ 'suppress/reblock' => 'BlockLogFormatter',
+ 'suppress/revision' => 'DeleteLogFormatter',
+ 'tag/update' => 'TagLogFormatter',
+ 'upload/overwrite' => 'UploadLogFormatter',
+ 'upload/revert' => 'UploadLogFormatter',
+ 'upload/upload' => 'UploadLogFormatter',
+];
+
+/**
+ * List of log types that can be filtered by action types
+ *
+ * To each action is associated the list of log_action
+ * subtypes to search for, usually one, but not necessarily so
+ * Extensions may append to this array
+ * @since 1.27
+ */
+$wgActionFilteredLogs = [
+ 'block' => [
+ 'block' => [ 'block' ],
+ 'reblock' => [ 'reblock' ],
+ 'unblock' => [ 'unblock' ],
+ ],
+ 'contentmodel' => [
+ 'change' => [ 'change' ],
+ 'new' => [ 'new' ],
+ ],
+ 'delete' => [
+ 'delete' => [ 'delete' ],
+ 'delete_redir' => [ 'delete_redir' ],
+ 'restore' => [ 'restore' ],
+ 'event' => [ 'event' ],
+ 'revision' => [ 'revision' ],
+ ],
+ 'import' => [
+ 'interwiki' => [ 'interwiki' ],
+ 'upload' => [ 'upload' ],
+ ],
+ 'managetags' => [
+ 'create' => [ 'create' ],
+ 'delete' => [ 'delete' ],
+ 'activate' => [ 'activate' ],
+ 'deactivate' => [ 'deactivate' ],
+ ],
+ 'move' => [
+ 'move' => [ 'move' ],
+ 'move_redir' => [ 'move_redir' ],
+ ],
+ 'newusers' => [
+ 'create' => [ 'create', 'newusers' ],
+ 'create2' => [ 'create2' ],
+ 'autocreate' => [ 'autocreate' ],
+ 'byemail' => [ 'byemail' ],
+ ],
+ 'patrol' => [
+ 'patrol' => [ 'patrol' ],
+ 'autopatrol' => [ 'autopatrol' ],
+ ],
+ 'protect' => [
+ 'protect' => [ 'protect' ],
+ 'modify' => [ 'modify' ],
+ 'unprotect' => [ 'unprotect' ],
+ 'move_prot' => [ 'move_prot' ],
+ ],
+ 'rights' => [
+ 'rights' => [ 'rights' ],
+ 'autopromote' => [ 'autopromote' ],
+ ],
+ 'suppress' => [
+ 'event' => [ 'event' ],
+ 'revision' => [ 'revision' ],
+ 'delete' => [ 'delete' ],
+ 'block' => [ 'block' ],
+ 'reblock' => [ 'reblock' ],
+ ],
+ 'upload' => [
+ 'upload' => [ 'upload' ],
+ 'overwrite' => [ 'overwrite' ],
+ ],
+];
+
+/**
+ * Maintain a log of newusers at Log/newusers?
+ */
+$wgNewUserLog = true;
+
+/** @} */ # end logging }
+
+/*************************************************************************//**
+ * @name Special pages (general and miscellaneous)
+ * @{
+ */
+
+/**
+ * Allow special page inclusions such as {{Special:Allpages}}
+ */
+$wgAllowSpecialInclusion = true;
+
+/**
+ * Set this to an array of special page names to prevent
+ * maintenance/updateSpecialPages.php from updating those pages.
+ */
+$wgDisableQueryPageUpdate = false;
+
+/**
+ * On Special:Unusedimages, consider images "used", if they are put
+ * into a category. Default (false) is not to count those as used.
+ */
+$wgCountCategorizedImagesAsUsed = false;
+
+/**
+ * Maximum number of links to a redirect page listed on
+ * Special:Whatlinkshere/RedirectDestination
+ */
+$wgMaxRedirectLinksRetrieved = 500;
+
+/** @} */ # end special pages }
+
+/*************************************************************************//**
+ * @name Actions
+ * @{
+ */
+
+/**
+ * Array of allowed values for the "title=foo&action=<action>" parameter. Syntax is:
+ * 'foo' => 'ClassName' Load the specified class which subclasses Action
+ * 'foo' => true Load the class FooAction which subclasses Action
+ * If something is specified in the getActionOverrides()
+ * of the relevant Page object it will be used
+ * instead of the default class.
+ * 'foo' => false The action is disabled; show an error message
+ * Unsetting core actions will probably cause things to complain loudly.
+ */
+$wgActions = [
+ 'credits' => true,
+ 'delete' => true,
+ 'edit' => true,
+ 'editchangetags' => 'SpecialPageAction',
+ 'history' => true,
+ 'info' => true,
+ 'markpatrolled' => true,
+ 'protect' => true,
+ 'purge' => true,
+ 'raw' => true,
+ 'render' => true,
+ 'revert' => true,
+ 'revisiondelete' => 'SpecialPageAction',
+ 'rollback' => true,
+ 'submit' => true,
+ 'unprotect' => true,
+ 'unwatch' => true,
+ 'view' => true,
+ 'watch' => true,
+];
+
+/** @} */ # end actions }
+
+/*************************************************************************//**
+ * @name Robot (search engine crawler) policy
+ * See also $wgNoFollowLinks.
+ * @{
+ */
+
+/**
+ * Default robot policy. The default policy is to encourage indexing and fol-
+ * lowing of links. It may be overridden on a per-namespace and/or per-page
+ * basis.
+ */
+$wgDefaultRobotPolicy = 'index,follow';
+
+/**
+ * Robot policies per namespaces. The default policy is given above, the array
+ * is made of namespace constants as defined in includes/Defines.php. You can-
+ * not specify a different default policy for NS_SPECIAL: it is always noindex,
+ * nofollow. This is because a number of special pages (e.g., ListPages) have
+ * many permutations of options that display the same data under redundant
+ * URLs, so search engine spiders risk getting lost in a maze of twisty special
+ * pages, all alike, and never reaching your actual content.
+ *
+ * @par Example:
+ * @code
+ * $wgNamespaceRobotPolicies = [ NS_TALK => 'noindex' ];
+ * @endcode
+ */
+$wgNamespaceRobotPolicies = [];
+
+/**
+ * Robot policies per article. These override the per-namespace robot policies.
+ * Must be in the form of an array where the key part is a properly canonicalised
+ * text form title and the value is a robot policy.
+ *
+ * @par Example:
+ * @code
+ * $wgArticleRobotPolicies = [
+ * 'Main Page' => 'noindex,follow',
+ * 'User:Bob' => 'index,follow',
+ * ];
+ * @endcode
+ *
+ * @par Example that DOES NOT WORK because the names are not canonical text
+ * forms:
+ * @code
+ * $wgArticleRobotPolicies = [
+ * # Underscore, not space!
+ * 'Main_Page' => 'noindex,follow',
+ * # "Project", not the actual project name!
+ * 'Project:X' => 'index,follow',
+ * # Needs to be "Abc", not "abc" (unless $wgCapitalLinks is false for that namespace)!
+ * 'abc' => 'noindex,nofollow'
+ * ];
+ * @endcode
+ */
+$wgArticleRobotPolicies = [];
+
+/**
+ * An array of namespace keys in which the __INDEX__/__NOINDEX__ magic words
+ * will not function, so users can't decide whether pages in that namespace are
+ * indexed by search engines. If set to null, default to $wgContentNamespaces.
+ *
+ * @par Example:
+ * @code
+ * $wgExemptFromUserRobotsControl = [ NS_MAIN, NS_TALK, NS_PROJECT ];
+ * @endcode
+ */
+$wgExemptFromUserRobotsControl = null;
+
+/** @} */ # End robot policy }
+
+/************************************************************************//**
+ * @name AJAX and API
+ * Note: The AJAX entry point which this section refers to is gradually being
+ * replaced by the API entry point, api.php. They are essentially equivalent.
+ * Both of them are used for dynamic client-side features, via XHR.
+ * @{
+ */
+
+/**
+ * Enable the MediaWiki API for convenient access to
+ * machine-readable data via api.php
+ *
+ * See https://www.mediawiki.org/wiki/API
+ */
+$wgEnableAPI = true;
+
+/**
+ * Allow the API to be used to perform write operations
+ * (page edits, rollback, etc.) when an authorised user
+ * accesses it
+ */
+$wgEnableWriteAPI = true;
+
+/**
+ *
+ * WARNING: SECURITY THREAT - debug use only
+ *
+ * Disables many security checks in the API for debugging purposes.
+ * This flag should never be used on the production servers, as it introduces
+ * a number of potential security holes. Even when enabled, the validation
+ * will still be performed, but instead of failing, API will return a warning.
+ * Also, there will always be a warning notifying that this flag is set.
+ * At this point, the flag allows GET requests to go through for modules
+ * requiring POST.
+ *
+ * @since 1.21
+ */
+$wgDebugAPI = false;
+
+/**
+ * API module extensions.
+ *
+ * Associative array mapping module name to modules specs;
+ * Each module spec is an associative array containing at least
+ * the 'class' key for the module's class, and optionally a
+ * 'factory' key for the factory function to use for the module.
+ *
+ * That factory function will be called with two parameters,
+ * the parent module (an instance of ApiBase, usually ApiMain)
+ * and the name the module was registered under. The return
+ * value must be an instance of the class given in the 'class'
+ * field.
+ *
+ * For backward compatibility, the module spec may also be a
+ * simple string containing the module's class name. In that
+ * case, the class' constructor will be called with the parent
+ * module and module name as parameters, as described above.
+ *
+ * Examples for registering API modules:
+ *
+ * @code
+ * $wgAPIModules['foo'] = 'ApiFoo';
+ * $wgAPIModules['bar'] = [
+ * 'class' => 'ApiBar',
+ * 'factory' => function( $main, $name ) { ... }
+ * ];
+ * $wgAPIModules['xyzzy'] = [
+ * 'class' => 'ApiXyzzy',
+ * 'factory' => [ 'XyzzyFactory', 'newApiModule' ]
+ * ];
+ * @endcode
+ *
+ * Extension modules may override the core modules.
+ * See ApiMain::$Modules for a list of the core modules.
+ */
+$wgAPIModules = [];
+
+/**
+ * API format module extensions.
+ * Associative array mapping format module name to module specs (see $wgAPIModules).
+ * Extension modules may override the core modules.
+ *
+ * See ApiMain::$Formats for a list of the core format modules.
+ */
+$wgAPIFormatModules = [];
+
+/**
+ * API Query meta module extensions.
+ * Associative array mapping meta module name to module specs (see $wgAPIModules).
+ * Extension modules may override the core modules.
+ *
+ * See ApiQuery::$QueryMetaModules for a list of the core meta modules.
+ */
+$wgAPIMetaModules = [];
+
+/**
+ * API Query prop module extensions.
+ * Associative array mapping prop module name to module specs (see $wgAPIModules).
+ * Extension modules may override the core modules.
+ *
+ * See ApiQuery::$QueryPropModules for a list of the core prop modules.
+ */
+$wgAPIPropModules = [];
+
+/**
+ * API Query list module extensions.
+ * Associative array mapping list module name to module specs (see $wgAPIModules).
+ * Extension modules may override the core modules.
+ *
+ * See ApiQuery::$QueryListModules for a list of the core list modules.
+ */
+$wgAPIListModules = [];
+
+/**
+ * Maximum amount of rows to scan in a DB query in the API
+ * The default value is generally fine
+ */
+$wgAPIMaxDBRows = 5000;
+
+/**
+ * The maximum size (in bytes) of an API result.
+ * @warning Do not set this lower than $wgMaxArticleSize*1024
+ */
+$wgAPIMaxResultSize = 8388608;
+
+/**
+ * The maximum number of uncached diffs that can be retrieved in one API
+ * request. Set this to 0 to disable API diffs altogether
+ */
+$wgAPIMaxUncachedDiffs = 1;
+
+/**
+ * Maximum amount of DB lag on a majority of DB replica DBs to tolerate
+ * before forcing bots to retry any write requests via API errors.
+ * This should be lower than the 'max lag' value in $wgLBFactoryConf.
+ */
+$wgAPIMaxLagThreshold = 7;
+
+/**
+ * Log file or URL (TCP or UDP) to log API requests to, or false to disable
+ * API request logging
+ */
+$wgAPIRequestLog = false;
+
+/**
+ * Set the timeout for the API help text cache. If set to 0, caching disabled
+ */
+$wgAPICacheHelpTimeout = 60 * 60;
+
+/**
+ * The ApiQueryQueryPages module should skip pages that are redundant to true
+ * API queries.
+ */
+$wgAPIUselessQueryPages = [
+ 'MIMEsearch', // aiprop=mime
+ 'LinkSearch', // list=exturlusage
+ 'FileDuplicateSearch', // prop=duplicatefiles
+];
+
+/**
+ * Enable AJAX framework
+ */
+$wgUseAjax = true;
+
+/**
+ * List of Ajax-callable functions.
+ * Extensions acting as Ajax callbacks must register here
+ * @deprecated (officially) since 1.27; use the API instead
+ */
+$wgAjaxExportList = [];
+
+/**
+ * Enable AJAX check for file overwrite, pre-upload
+ */
+$wgAjaxUploadDestCheck = true;
+
+/**
+ * Enable previewing licences via AJAX. Also requires $wgEnableAPI to be true.
+ */
+$wgAjaxLicensePreview = true;
+
+/**
+ * Have clients send edits to be prepared when filling in edit summaries.
+ * This gives the server a head start on the expensive parsing operation.
+ */
+$wgAjaxEditStash = true;
+
+/**
+ * Settings for incoming cross-site AJAX requests:
+ * Newer browsers support cross-site AJAX when the target resource allows requests
+ * from the origin domain by the Access-Control-Allow-Origin header.
+ * This is currently only used by the API (requests to api.php)
+ * $wgCrossSiteAJAXdomains can be set using a wildcard syntax:
+ *
+ * - '*' matches any number of characters
+ * - '?' matches any 1 character
+ *
+ * @par Example:
+ * @code
+ * $wgCrossSiteAJAXdomains = [
+ * 'www.mediawiki.org',
+ * '*.wikipedia.org',
+ * '*.wikimedia.org',
+ * '*.wiktionary.org',
+ * ];
+ * @endcode
+ */
+$wgCrossSiteAJAXdomains = [];
+
+/**
+ * Domains that should not be allowed to make AJAX requests,
+ * even if they match one of the domains allowed by $wgCrossSiteAJAXdomains
+ * Uses the same syntax as $wgCrossSiteAJAXdomains
+ */
+$wgCrossSiteAJAXdomainExceptions = [];
+
+/** @} */ # End AJAX and API }
+
+/************************************************************************//**
+ * @name Shell and process control
+ * @{
+ */
+
+/**
+ * Maximum amount of virtual memory available to shell processes under linux, in KB.
+ */
+$wgMaxShellMemory = 307200;
+
+/**
+ * Maximum file size created by shell processes under linux, in KB
+ * ImageMagick convert for example can be fairly hungry for scratch space
+ */
+$wgMaxShellFileSize = 102400;
+
+/**
+ * Maximum CPU time in seconds for shell processes under Linux
+ */
+$wgMaxShellTime = 180;
+
+/**
+ * Maximum wall clock time (i.e. real time, of the kind the clock on the wall
+ * would measure) in seconds for shell processes under Linux
+ */
+$wgMaxShellWallClockTime = 180;
+
+/**
+ * Under Linux: a cgroup directory used to constrain memory usage of shell
+ * commands. The directory must be writable by the user which runs MediaWiki.
+ *
+ * If specified, this is used instead of ulimit, which is inaccurate, and
+ * causes malloc() to return NULL, which exposes bugs in C applications, making
+ * them segfault or deadlock.
+ *
+ * A wrapper script will create a cgroup for each shell command that runs, as
+ * a subgroup of the specified cgroup. If the memory limit is exceeded, the
+ * kernel will send a SIGKILL signal to a process in the subgroup.
+ *
+ * @par Example:
+ * @code
+ * mkdir -p /sys/fs/cgroup/memory/mediawiki
+ * mkdir -m 0777 /sys/fs/cgroup/memory/mediawiki/job
+ * echo '$wgShellCgroup = "/sys/fs/cgroup/memory/mediawiki/job";' >> LocalSettings.php
+ * @endcode
+ *
+ * The reliability of cgroup cleanup can be improved by installing a
+ * notify_on_release script in the root cgroup, see e.g.
+ * https://gerrit.wikimedia.org/r/#/c/40784
+ */
+$wgShellCgroup = false;
+
+/**
+ * Executable path of the PHP cli binary (php/php5). Should be set up on install.
+ */
+$wgPhpCli = '/usr/bin/php';
+
+/**
+ * Locale for LC_ALL, to provide a known environment for locale-sensitive operations
+ *
+ * For Unix-like operating systems, this should be set to C.UTF-8 or an
+ * equivalent to provide the most consistent behavior for locale-sensitive
+ * C library operations across different-language wikis. If that locale is not
+ * available, use another locale that has a UTF-8 character set.
+ *
+ * This setting mainly affects the behavior of C library functions, including:
+ * - String collation (order when sorting using locale-sensitive comparison)
+ * - For example, whether "Å" and "A" are considered to be the same letter or
+ * different letters and if different whether it comes after "A" or after
+ * "Z", and whether sorting is case sensitive.
+ * - String character set (how characters beyond basic ASCII are represented)
+ * - We need this to be a UTF-8 character set to work around
+ * https://bugs.php.net/bug.php?id=45132
+ * - Language used for low-level error messages.
+ * - Formatting of date/time and numeric values (e.g. '.' versus ',' as the
+ * decimal separator)
+ *
+ * MediaWiki provides its own methods and classes to perform many
+ * locale-sensitive operations, which are designed to be able to vary locale
+ * based on wiki language or user preference:
+ * - MediaWiki's Collation class should generally be used instead of the C
+ * library collation functions when locale-sensitive sorting is needed.
+ * - MediaWiki's Message class should be used for localization of messages
+ * displayed to the user.
+ * - MediaWiki's Language class should be used for formatting numeric and
+ * date/time values.
+ *
+ * @note If multiple wikis are being served from the same process (e.g. the
+ * same fastCGI or Apache server), this setting must be the same on all those
+ * wikis.
+ */
+$wgShellLocale = 'C.UTF-8';
+
+/** @} */ # End shell }
+
+/************************************************************************//**
+ * @name HTTP client
+ * @{
+ */
+
+/**
+ * Timeout for HTTP requests done internally, in seconds.
+ * @var int
+ */
+$wgHTTPTimeout = 25;
+
+/**
+ * Timeout for HTTP requests done internally for transwiki imports, in seconds.
+ * @since 1.29
+ */
+$wgHTTPImportTimeout = 25;
+
+/**
+ * Timeout for Asynchronous (background) HTTP requests, in seconds.
+ */
+$wgAsyncHTTPTimeout = 25;
+
+/**
+ * Proxy to use for CURL requests.
+ */
+$wgHTTPProxy = false;
+
+/**
+ * Local virtual hosts.
+ *
+ * This lists domains that are configured as virtual hosts on the same machine.
+ *
+ * This affects the following:
+ * - MWHttpRequest: If a request is to be made to a domain listed here, or any
+ * subdomain thereof, then no proxy will be used.
+ * Command-line scripts are not affected by this setting and will always use
+ * the proxy if it is configured.
+ *
+ * @since 1.25
+ */
+$wgLocalVirtualHosts = [];
+
+/**
+ * Timeout for connections done internally (in seconds)
+ * Only works for curl
+ */
+$wgHTTPConnectTimeout = 5e0;
+
+/** @} */ # End HTTP client }
+
+/************************************************************************//**
+ * @name Job queue
+ * @{
+ */
+
+/**
+ * Number of jobs to perform per request. May be less than one in which case
+ * jobs are performed probabalistically. If this is zero, jobs will not be done
+ * during ordinary apache requests. In this case, maintenance/runJobs.php should
+ * be run periodically.
+ */
+$wgJobRunRate = 1;
+
+/**
+ * When $wgJobRunRate > 0, try to run jobs asynchronously, spawning a new process
+ * to handle the job execution, instead of blocking the request until the job
+ * execution finishes.
+ *
+ * @since 1.23
+ */
+$wgRunJobsAsync = false;
+
+/**
+ * Number of rows to update per job
+ */
+$wgUpdateRowsPerJob = 300;
+
+/**
+ * Number of rows to update per query
+ */
+$wgUpdateRowsPerQuery = 100;
+
+/** @} */ # End job queue }
+
+/************************************************************************//**
+ * @name Miscellaneous
+ * @{
+ */
+
+/**
+ * Name of the external diff engine to use. Supported values:
+ * * string: path to an external diff executable
+ * * false: wikidiff2 PHP/HHVM module if installed, otherwise the default PHP implementation
+ * * 'wikidiff', 'wikidiff2', and 'wikidiff3' are treated as false for backwards compatibility
+ */
+$wgExternalDiffEngine = false;
+
+/**
+ * wikidiff2 supports detection of changes in moved paragraphs.
+ * This setting controls the maximum number of paragraphs to compare before it bails out.
+ * Supported values:
+ * * 0: detection of moved paragraphs is disabled
+ * * int > 0: maximum number of paragraphs to compare
+ * Note: number of paragraph comparisons is in O(n^2).
+ * This setting is only effective if the wikidiff2 PHP/HHVM module is used as diffengine.
+ * See $wgExternalDiffEngine.
+ *
+ * @since 1.30
+ */
+$wgWikiDiff2MovedParagraphDetectionCutoff = 0;
+
+/**
+ * Disable redirects to special pages and interwiki redirects, which use a 302
+ * and have no "redirected from" link.
+ *
+ * @note This is only for articles with #REDIRECT in them. URL's containing a
+ * local interwiki prefix (or a non-canonical special page name) are still hard
+ * redirected regardless of this setting.
+ */
+$wgDisableHardRedirects = false;
+
+/**
+ * LinkHolderArray batch size
+ * For debugging
+ */
+$wgLinkHolderBatchSize = 1000;
+
+/**
+ * By default MediaWiki does not register links pointing to same server in
+ * externallinks dataset, use this value to override:
+ */
+$wgRegisterInternalExternals = false;
+
+/**
+ * Maximum number of pages to move at once when moving subpages with a page.
+ */
+$wgMaximumMovedPages = 100;
+
+/**
+ * Fix double redirects after a page move.
+ * Tends to conflict with page move vandalism, use only on a private wiki.
+ */
+$wgFixDoubleRedirects = false;
+
+/**
+ * Allow redirection to another page when a user logs in.
+ * To enable, set to a string like 'Main Page'
+ */
+$wgRedirectOnLogin = null;
+
+/**
+ * Configuration for processing pool control, for use in high-traffic wikis.
+ * An implementation is provided in the PoolCounter extension.
+ *
+ * This configuration array maps pool types to an associative array. The only
+ * defined key in the associative array is "class", which gives the class name.
+ * The remaining elements are passed through to the class as constructor
+ * parameters.
+ *
+ * @par Example using local redis instance:
+ * @code
+ * $wgPoolCounterConf = [ 'ArticleView' => [
+ * 'class' => 'PoolCounterRedis',
+ * 'timeout' => 15, // wait timeout in seconds
+ * 'workers' => 1, // maximum number of active threads in each pool
+ * 'maxqueue' => 5, // maximum number of total threads in each pool
+ * 'servers' => [ '127.0.0.1' ],
+ * 'redisConfig' => []
+ * ] ];
+ * @endcode
+ *
+ * @par Example using C daemon from https://www.mediawiki.org/wiki/Extension:PoolCounter:
+ * @code
+ * $wgPoolCounterConf = [ 'ArticleView' => [
+ * 'class' => 'PoolCounter_Client',
+ * 'timeout' => 15, // wait timeout in seconds
+ * 'workers' => 5, // maximum number of active threads in each pool
+ * 'maxqueue' => 50, // maximum number of total threads in each pool
+ * ... any extension-specific options...
+ * ] ];
+ * @endcode
+ */
+$wgPoolCounterConf = null;
+
+/**
+ * To disable file delete/restore temporarily
+ */
+$wgUploadMaintenance = false;
+
+/**
+ * Associative array mapping namespace IDs to the name of the content model pages in that namespace
+ * should have by default (use the CONTENT_MODEL_XXX constants). If no special content type is
+ * defined for a given namespace, pages in that namespace will use the CONTENT_MODEL_WIKITEXT
+ * (except for the special case of JS and CS pages).
+ *
+ * @since 1.21
+ */
+$wgNamespaceContentModels = [];
+
+/**
+ * How to react if a plain text version of a non-text Content object is requested using
+ * ContentHandler::getContentText():
+ *
+ * * 'ignore': return null
+ * * 'fail': throw an MWException
+ * * 'serialize': serialize to default format
+ *
+ * @since 1.21
+ */
+$wgContentHandlerTextFallback = 'ignore';
+
+/**
+ * Set to false to disable use of the database fields introduced by the ContentHandler facility.
+ * This way, the ContentHandler facility can be used without any additional information in the
+ * database. A page's content model is then derived solely from the page's title. This however
+ * means that changing a page's default model (e.g. using $wgNamespaceContentModels) will break
+ * the page and/or make the content inaccessible. This also means that pages can not be moved to
+ * a title that would default to a different content model.
+ *
+ * Overall, with $wgContentHandlerUseDB = false, no database updates are needed, but content
+ * handling is less robust and less flexible.
+ *
+ * @since 1.21
+ */
+$wgContentHandlerUseDB = true;
+
+/**
+ * Determines which types of text are parsed as wikitext. This does not imply that these kinds
+ * of texts are also rendered as wikitext, it only means that links, magic words, etc will have
+ * the effect on the database they would have on a wikitext page.
+ *
+ * @todo On the long run, it would be nice to put categories etc into a separate structure,
+ * or at least parse only the contents of comments in the scripts.
+ *
+ * @since 1.21
+ */
+$wgTextModelsToParse = [
+ CONTENT_MODEL_WIKITEXT, // Just for completeness, wikitext will always be parsed.
+ CONTENT_MODEL_JAVASCRIPT, // Make categories etc work, people put them into comments.
+ CONTENT_MODEL_CSS, // Make categories etc work, people put them into comments.
+];
+
+/**
+ * Register handlers for specific types of sites.
+ *
+ * @since 1.20
+ */
+$wgSiteTypes = [
+ 'mediawiki' => 'MediaWikiSite',
+];
+
+/**
+ * Whether the page_props table has a pp_sortkey column. Set to false in case
+ * the respective database schema change was not applied.
+ * @since 1.23
+ */
+$wgPagePropsHaveSortkey = true;
+
+/**
+ * Port where you have HTTPS running
+ * Supports HTTPS on non-standard ports
+ * @see T67184
+ * @since 1.24
+ */
+$wgHttpsPort = 443;
+
+/**
+ * Secret for session storage.
+ * This should be set in LocalSettings.php, otherwise wgSecretKey will
+ * be used.
+ * @since 1.27
+ */
+$wgSessionSecret = false;
+
+/**
+ * If for some reason you can't install the PHP OpenSSL or mcrypt extensions,
+ * you can set this to true to make MediaWiki work again at the cost of storing
+ * sensitive session data insecurely. But it would be much more secure to just
+ * install the OpenSSL extension.
+ * @since 1.27
+ */
+$wgSessionInsecureSecrets = false;
+
+/**
+ * Secret for hmac-based key derivation function (fast,
+ * cryptographically secure random numbers).
+ * This should be set in LocalSettings.php, otherwise wgSecretKey will
+ * be used.
+ * See also: $wgHKDFAlgorithm
+ * @since 1.24
+ */
+$wgHKDFSecret = false;
+
+/**
+ * Algorithm for hmac-based key derivation function (fast,
+ * cryptographically secure random numbers).
+ * See also: $wgHKDFSecret
+ * @since 1.24
+ */
+$wgHKDFAlgorithm = 'sha256';
+
+/**
+ * Enable page language feature
+ * Allows setting page language in database
+ * @var bool
+ * @since 1.24
+ */
+$wgPageLanguageUseDB = false;
+
+/**
+ * Global configuration variable for Virtual REST Services.
+ *
+ * Use the 'path' key to define automatically mounted services. The value for this
+ * key is a map of path prefixes to service configuration. The latter is an array of:
+ * - class : the fully qualified class name
+ * - options : map of arguments to the class constructor
+ * Such services will be available to handle queries under their path from the VRS
+ * singleton, e.g. MediaWikiServices::getInstance()->getVirtualRESTServiceClient();
+ *
+ * Auto-mounting example for Parsoid:
+ *
+ * $wgVirtualRestConfig['paths']['/parsoid/'] = [
+ * 'class' => 'ParsoidVirtualRESTService',
+ * 'options' => [
+ * 'url' => 'http://localhost:8000',
+ * 'prefix' => 'enwiki',
+ * 'domain' => 'en.wikipedia.org'
+ * ]
+ * ];
+ *
+ * Parameters for different services can also be declared inside the 'modules' value,
+ * which is to be treated as an associative array. The parameters in 'global' will be
+ * merged with service-specific ones. The result will then be passed to
+ * VirtualRESTService::__construct() in the module.
+ *
+ * Example config for Parsoid:
+ *
+ * $wgVirtualRestConfig['modules']['parsoid'] = [
+ * 'url' => 'http://localhost:8000',
+ * 'prefix' => 'enwiki',
+ * 'domain' => 'en.wikipedia.org',
+ * ];
+ *
+ * @var array
+ * @since 1.25
+ */
+$wgVirtualRestConfig = [
+ 'paths' => [],
+ 'modules' => [],
+ 'global' => [
+ # Timeout in seconds
+ 'timeout' => 360,
+ # 'domain' is set to $wgCanonicalServer in Setup.php
+ 'forwardCookies' => false,
+ 'HTTPProxy' => null
+ ]
+];
+
+/**
+ * Controls whether zero-result search queries with suggestions should display results for
+ * these suggestions.
+ *
+ * @var bool
+ * @since 1.26
+ */
+$wgSearchRunSuggestedQuery = true;
+
+/**
+ * Where popular password file is located.
+ *
+ * Default in core contains 10,000 most popular. This config
+ * allows you to change which file, in case you want to generate
+ * a password file with > 10000 entries in it.
+ *
+ * @see maintenance/createCommonPasswordCdb.php
+ * @since 1.27
+ * @var string path to file
+ */
+$wgPopularPasswordFile = __DIR__ . '/../serialized/commonpasswords.cdb';
+
+/*
+ * Max time (in seconds) a user-generated transaction can spend in writes.
+ * If exceeded, the transaction is rolled back with an error instead of being committed.
+ *
+ * @var int|bool Disabled if false
+ * @since 1.27
+ */
+$wgMaxUserDBWriteDuration = false;
+
+/*
+ * Max time (in seconds) a job-generated transaction can spend in writes.
+ * If exceeded, the transaction is rolled back with an error instead of being committed.
+ *
+ * @var int|bool Disabled if false
+ * @since 1.30
+ */
+$wgMaxJobDBWriteDuration = false;
+
+/**
+ * Mapping of event channels (or channel categories) to EventRelayer configuration.
+ *
+ * By setting up a PubSub system (like Kafka) and enabling a corresponding EventRelayer class
+ * that uses it, MediaWiki can broadcast events to all subscribers. Certain features like WAN
+ * cache purging and CDN cache purging will emit events to this system. Appropriate listers can
+ * subscribe to the channel and take actions based on the events. For example, a local daemon
+ * can run on each CDN cache node and perfom local purges based on the URL purge channel events.
+ *
+ * Some extensions may want to use "channel categories" so that different channels can also share
+ * the same custom relayer instance (e.g. when it's likely to be overriden). They can use
+ * EventRelayerGroup::getRelayer() based on the category but call notify() on various different
+ * actual channels. One reason for this would be that some system have very different performance
+ * vs durability needs, so one system (e.g. Kafka) may not be suitable for all uses.
+ *
+ * The 'default' key is for all channels (or channel categories) without an explicit entry here.
+ *
+ * @since 1.27
+ */
+$wgEventRelayerConfig = [
+ 'default' => [
+ 'class' => 'EventRelayerNull',
+ ]
+];
+
+/**
+ * Share data about this installation with MediaWiki developers
+ *
+ * When set to true, MediaWiki will periodically ping https://www.mediawiki.org/ with basic
+ * data about this MediaWiki instance. This data includes, for example, the type of system,
+ * PHP version, and chosen database backend. The Wikimedia Foundation shares this data with
+ * MediaWiki developers to help guide future development efforts.
+ *
+ * For details about what data is sent, see: https://www.mediawiki.org/wiki/Manual:$wgPingback
+ *
+ * @var bool
+ * @since 1.28
+ */
+$wgPingback = false;
+
+/**
+ * List of urls which appear often to be triggering CSP reports
+ * but do not appear to be caused by actual content, but by client
+ * software inserting scripts (i.e. Ad-Ware).
+ * List based on results from Wikimedia logs.
+ *
+ * @since 1.28
+ */
+$wgCSPFalsePositiveUrls = [
+ 'https://3hub.co' => true,
+ 'https://morepro.info' => true,
+ 'https://p.ato.mx' => true,
+ 'https://s.ato.mx' => true,
+ 'https://adserver.adtech.de' => true,
+ 'https://ums.adtechus.com' => true,
+ 'https://cas.criteo.com' => true,
+ 'https://cat.nl.eu.criteo.com' => true,
+ 'https://atpixel.alephd.com' => true,
+ 'https://rtb.metrigo.com' => true,
+ 'https://d5p.de17a.com' => true,
+ 'https://ad.lkqd.net/vpaid/vpaid.js' => true,
+];
+
+/**
+ * Shortest CIDR limits that can be checked in any individual range check
+ * at Special:Contributions.
+ *
+ * @var array
+ * @since 1.30
+ */
+$wgRangeContributionsCIDRLimit = [
+ 'IPv4' => 16,
+ 'IPv6' => 32,
+];
+
+/**
+ * The following variables define 3 user experience levels:
+ *
+ * - newcomer: has not yet reached the 'learner' level
+ *
+ * - learner: has at least $wgLearnerEdits and has been
+ * a member for $wgLearnerMemberSince days
+ * but has not yet reached the 'experienced' level.
+ *
+ * - experienced: has at least $wgExperiencedUserEdits edits and
+ * has been a member for $wgExperiencedUserMemberSince days.
+ */
+$wgLearnerEdits = 10;
+$wgLearnerMemberSince = 4; # days
+$wgExperiencedUserEdits = 500;
+$wgExperiencedUserMemberSince = 30; # days
+
+/**
+ * Mapping of interwiki index prefixes to descriptors that
+ * can be used to change the display of interwiki search results.
+ *
+ * Descriptors are appended to CSS classes of interwiki results
+ * which using InterwikiSearchResultWidget.
+ *
+ * Predefined descriptors include the following words:
+ * definition, textbook, news, quotation, book, travel, course
+ *
+ * @par Example:
+ * @code
+ * $wgInterwikiPrefixDisplayTypes = [
+ * 'iwprefix' => 'definition'
+ *];
+ * @endcode
+ */
+$wgInterwikiPrefixDisplayTypes = [];
+
+/**
+ * Comment table schema migration stage.
+ * @since 1.30
+ * @var int One of the MIGRATION_* constants
+ */
+$wgCommentTableSchemaMigrationStage = MIGRATION_OLD;
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ * @}
+ */
diff --git a/www/wiki/includes/Defines.php b/www/wiki/includes/Defines.php
new file mode 100644
index 00000000..ca603e76
--- /dev/null
+++ b/www/wiki/includes/Defines.php
@@ -0,0 +1,297 @@
+<?php
+/**
+ * A few constants that might be needed during LocalSettings.php.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+require_once __DIR__ . '/libs/mime/defines.php';
+require_once __DIR__ . '/libs/rdbms/defines.php';
+require_once __DIR__ . '/compat/normal/UtfNormalDefines.php';
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * @defgroup Constants MediaWiki constants
+ */
+
+# Obsolete aliases
+/**
+ * @deprecated since 1.28
+ */
+define( 'DB_SLAVE', -1 );
+
+/**@{
+ * Obsolete IDatabase::makeList() constants
+ * These are also available as Database class constants
+ */
+define( 'LIST_COMMA', IDatabase::LIST_COMMA );
+define( 'LIST_AND', IDatabase::LIST_AND );
+define( 'LIST_SET', IDatabase::LIST_SET );
+define( 'LIST_NAMES', IDatabase::LIST_NAMES );
+define( 'LIST_OR', IDatabase::LIST_OR );
+/**@}*/
+
+/**@{
+ * Virtual namespaces; don't appear in the page database
+ */
+define( 'NS_MEDIA', -2 );
+define( 'NS_SPECIAL', -1 );
+/**@}*/
+
+/**@{
+ * Real namespaces
+ *
+ * Number 100 and beyond are reserved for custom namespaces;
+ * DO NOT assign standard namespaces at 100 or beyond.
+ * DO NOT Change integer values as they are most probably hardcoded everywhere
+ * see bug #696 which talked about that.
+ */
+define( 'NS_MAIN', 0 );
+define( 'NS_TALK', 1 );
+define( 'NS_USER', 2 );
+define( 'NS_USER_TALK', 3 );
+define( 'NS_PROJECT', 4 );
+define( 'NS_PROJECT_TALK', 5 );
+define( 'NS_FILE', 6 );
+define( 'NS_FILE_TALK', 7 );
+define( 'NS_MEDIAWIKI', 8 );
+define( 'NS_MEDIAWIKI_TALK', 9 );
+define( 'NS_TEMPLATE', 10 );
+define( 'NS_TEMPLATE_TALK', 11 );
+define( 'NS_HELP', 12 );
+define( 'NS_HELP_TALK', 13 );
+define( 'NS_CATEGORY', 14 );
+define( 'NS_CATEGORY_TALK', 15 );
+
+/**
+ * NS_IMAGE and NS_IMAGE_TALK are the pre-v1.14 names for NS_FILE and
+ * NS_FILE_TALK respectively, and are kept for compatibility.
+ *
+ * When writing code that should be compatible with older MediaWiki
+ * versions, either stick to the old names or define the new constants
+ * yourself, if they're not defined already.
+ *
+ * @deprecated since 1.14
+ */
+define( 'NS_IMAGE', NS_FILE );
+/**
+ * @deprecated since 1.14
+ */
+define( 'NS_IMAGE_TALK', NS_FILE_TALK );
+/**@}*/
+
+/**@{
+ * Cache type
+ */
+define( 'CACHE_ANYTHING', -1 ); // Use anything, as long as it works
+define( 'CACHE_NONE', 0 ); // Do not cache
+define( 'CACHE_DB', 1 ); // Store cache objects in the DB
+define( 'CACHE_MEMCACHED', 2 ); // MemCached, must specify servers in $wgMemCacheServers
+define( 'CACHE_ACCEL', 3 ); // APC, XCache or WinCache
+/**@}*/
+
+/**@{
+ * Antivirus result codes, for use in $wgAntivirusSetup.
+ */
+define( 'AV_NO_VIRUS', 0 ); # scan ok, no virus found
+define( 'AV_VIRUS_FOUND', 1 ); # virus found!
+define( 'AV_SCAN_ABORTED', -1 ); # scan aborted, the file is probably immune
+define( 'AV_SCAN_FAILED', false ); # scan failed (scanner not found or error in scanner)
+/**@}*/
+
+/**@{
+ * Anti-lock flags
+ * Was used by $wgAntiLockFlags, which was removed with 1.25
+ * Constants kept to not have warnings when used in LocalSettings
+ */
+define( 'ALF_PRELOAD_LINKS', 1 ); // unused
+define( 'ALF_PRELOAD_EXISTENCE', 2 ); // unused
+define( 'ALF_NO_LINK_LOCK', 4 ); // unused
+define( 'ALF_NO_BLOCK_LOCK', 8 ); // unused
+/**@}*/
+
+/**@{
+ * Date format selectors; used in user preference storage and by
+ * Language::date() and co.
+ */
+define( 'MW_DATE_DEFAULT', 'default' );
+define( 'MW_DATE_MDY', 'mdy' );
+define( 'MW_DATE_DMY', 'dmy' );
+define( 'MW_DATE_YMD', 'ymd' );
+define( 'MW_DATE_ISO', 'ISO 8601' );
+/**@}*/
+
+/**@{
+ * RecentChange type identifiers
+ */
+define( 'RC_EDIT', 0 );
+define( 'RC_NEW', 1 );
+define( 'RC_LOG', 3 );
+define( 'RC_EXTERNAL', 5 );
+define( 'RC_CATEGORIZE', 6 );
+/**@}*/
+
+/**@{
+ * Article edit flags
+ */
+define( 'EDIT_NEW', 1 );
+define( 'EDIT_UPDATE', 2 );
+define( 'EDIT_MINOR', 4 );
+define( 'EDIT_SUPPRESS_RC', 8 );
+define( 'EDIT_FORCE_BOT', 16 );
+define( 'EDIT_DEFER_UPDATES', 32 ); // Unused since 1.27
+define( 'EDIT_AUTOSUMMARY', 64 );
+define( 'EDIT_INTERNAL', 128 );
+/**@}*/
+
+/**@{
+ * Hook support constants
+ */
+define( 'MW_SUPPORTS_PARSERFIRSTCALLINIT', 1 );
+define( 'MW_SUPPORTS_LOCALISATIONCACHE', 1 );
+define( 'MW_SUPPORTS_CONTENTHANDLER', 1 );
+define( 'MW_EDITFILTERMERGED_SUPPORTS_API', 1 );
+/**@}*/
+
+/** Support for $wgResourceModules */
+define( 'MW_SUPPORTS_RESOURCE_MODULES', 1 );
+
+/**@{
+ * Allowed values for Parser::$mOutputType
+ * Parameter to Parser::startExternalParse().
+ * Use of Parser consts is preferred:
+ * - Parser::OT_HTML
+ * - Parser::OT_WIKI
+ * - Parser::OT_PREPROCESS
+ * - Parser::OT_MSG
+ * - Parser::OT_PLAIN
+ */
+define( 'OT_HTML', 1 );
+define( 'OT_WIKI', 2 );
+define( 'OT_PREPROCESS', 3 );
+define( 'OT_MSG', 3 ); // b/c alias for OT_PREPROCESS
+define( 'OT_PLAIN', 4 );
+/**@}*/
+
+/**@{
+ * Flags for Parser::setFunctionHook
+ * Use of Parser consts is preferred:
+ * - Parser::SFH_NO_HASH
+ * - Parser::SFH_OBJECT_ARGS
+ */
+define( 'SFH_NO_HASH', 1 );
+define( 'SFH_OBJECT_ARGS', 2 );
+/**@}*/
+
+/**@{
+ * Autopromote conditions (must be here and not in Autopromote.php, so that
+ * they're loaded for DefaultSettings.php before AutoLoader.php)
+ */
+define( 'APCOND_EDITCOUNT', 1 );
+define( 'APCOND_AGE', 2 );
+define( 'APCOND_EMAILCONFIRMED', 3 );
+define( 'APCOND_INGROUPS', 4 );
+define( 'APCOND_ISIP', 5 );
+define( 'APCOND_IPINRANGE', 6 );
+define( 'APCOND_AGE_FROM_EDIT', 7 );
+define( 'APCOND_BLOCKED', 8 );
+define( 'APCOND_ISBOT', 9 );
+/**@}*/
+
+/** @{
+ * Protocol constants for wfExpandUrl()
+ */
+define( 'PROTO_HTTP', 'http://' );
+define( 'PROTO_HTTPS', 'https://' );
+define( 'PROTO_RELATIVE', '//' );
+define( 'PROTO_CURRENT', null );
+define( 'PROTO_CANONICAL', 1 );
+define( 'PROTO_INTERNAL', 2 );
+/**@}*/
+
+/**@{
+ * Content model ids, used by Content and ContentHandler.
+ * These IDs will be exposed in the API and XML dumps.
+ *
+ * Extensions that define their own content model IDs should take
+ * care to avoid conflicts. Using the extension name as a prefix is recommended,
+ * for example 'myextension-somecontent'.
+ */
+define( 'CONTENT_MODEL_WIKITEXT', 'wikitext' );
+define( 'CONTENT_MODEL_JAVASCRIPT', 'javascript' );
+define( 'CONTENT_MODEL_CSS', 'css' );
+define( 'CONTENT_MODEL_TEXT', 'text' );
+define( 'CONTENT_MODEL_JSON', 'json' );
+/**@}*/
+
+/**@{
+ * Content formats, used by Content and ContentHandler.
+ * These should be MIME types, and will be exposed in the API and XML dumps.
+ *
+ * Extensions are free to use the below formats, or define their own.
+ * It is recommended to stick with the conventions for MIME types.
+ */
+// wikitext
+define( 'CONTENT_FORMAT_WIKITEXT', 'text/x-wiki' );
+// for js pages
+define( 'CONTENT_FORMAT_JAVASCRIPT', 'text/javascript' );
+// for css pages
+define( 'CONTENT_FORMAT_CSS', 'text/css' );
+// for future use, e.g. with some plain-html messages.
+define( 'CONTENT_FORMAT_TEXT', 'text/plain' );
+// for future use, e.g. with some plain-html messages.
+define( 'CONTENT_FORMAT_HTML', 'text/html' );
+// for future use with the api and for extensions
+define( 'CONTENT_FORMAT_SERIALIZED', 'application/vnd.php.serialized' );
+// for future use with the api, and for use by extensions
+define( 'CONTENT_FORMAT_JSON', 'application/json' );
+// for future use with the api, and for use by extensions
+define( 'CONTENT_FORMAT_XML', 'application/xml' );
+/**@}*/
+
+/**@{
+ * Max string length for shell invocations; based on binfmts.h
+ */
+define( 'SHELL_MAX_ARG_STRLEN', '100000' );
+/**@}*/
+
+/**@{
+ * Schema change migration flags.
+ *
+ * Used as values of a feature flag for an orderly transition from an old
+ * schema to a new schema.
+ *
+ * - MIGRATION_OLD: Only read and write the old schema. The new schema need not
+ * even exist. This is used from when the patch is merged until the schema
+ * change is actually applied to the database.
+ * - MIGRATION_WRITE_BOTH: Write both the old and new schema. Read the new
+ * schema preferentially, falling back to the old. This is used while the
+ * change is being tested, allowing easy roll-back to the old schema.
+ * - MIGRATION_WRITE_NEW: Write only the new schema. Read the new schema
+ * preferentially, falling back to the old. This is used while running the
+ * maintenance script to migrate existing entries in the old schema to the
+ * new schema.
+ * - MIGRATION_NEW: Only read and write the new schema. The old schema (and the
+ * feature flag) may now be removed.
+ */
+define( 'MIGRATION_OLD', 0 );
+define( 'MIGRATION_WRITE_BOTH', 1 );
+define( 'MIGRATION_WRITE_NEW', 2 );
+define( 'MIGRATION_NEW', 3 );
+/**@}*/
diff --git a/www/wiki/includes/DeprecatedGlobal.php b/www/wiki/includes/DeprecatedGlobal.php
new file mode 100644
index 00000000..60dde401
--- /dev/null
+++ b/www/wiki/includes/DeprecatedGlobal.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * Delayed loading of deprecated global objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to allow throwing wfDeprecated warnings
+ * when people use globals that we do not want them to.
+ */
+class DeprecatedGlobal extends StubObject {
+ protected $version;
+
+ /**
+ * @param string $name Global name
+ * @param callable|string $callback Factory function or class name to construct
+ * @param bool|string $version Version global was deprecated in
+ */
+ function __construct( $name, $callback, $version = false ) {
+ parent::__construct( $name, $callback );
+ $this->version = $version;
+ }
+
+ // @codingStandardsIgnoreStart
+ // PSR2.Methods.MethodDeclaration.Underscore
+ // PSR2.Classes.PropertyDeclaration.ScopeMissing
+ function _newObject() {
+ /* Put the caller offset for wfDeprecated as 6, as
+ * that gives the function that uses this object, since:
+ * 1 = this function ( _newObject )
+ * 2 = StubObject::_unstub
+ * 3 = StubObject::_call
+ * 4 = StubObject::__call
+ * 5 = DeprecatedGlobal::<method of global called>
+ * 6 = Actual function using the global.
+ * Of course its theoretically possible to have other call
+ * sequences for this method, but that seems to be
+ * rather unlikely.
+ */
+ wfDeprecated( '$' . $this->global, $this->version, false, 6 );
+ return parent::_newObject();
+ }
+ // @codingStandardsIgnoreEnd
+}
diff --git a/www/wiki/includes/DerivativeRequest.php b/www/wiki/includes/DerivativeRequest.php
new file mode 100644
index 00000000..487e86c8
--- /dev/null
+++ b/www/wiki/includes/DerivativeRequest.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * Deal with importing all those nasty globals and things
+ *
+ * Copyright © 2003 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Similar to FauxRequest, but only fakes URL parameters and method
+ * (POST or GET) and use the base request for the remaining stuff
+ * (cookies, session and headers).
+ *
+ * @ingroup HTTP
+ * @since 1.19
+ */
+class DerivativeRequest extends FauxRequest {
+ private $base;
+
+ /**
+ * @param WebRequest $base
+ * @param array $data Array of *non*-urlencoded key => value pairs, the
+ * fake GET/POST values
+ * @param bool $wasPosted Whether to treat the data as POST
+ */
+ public function __construct( WebRequest $base, $data, $wasPosted = false ) {
+ $this->base = $base;
+ parent::__construct( $data, $wasPosted );
+ }
+
+ public function getCookie( $key, $prefix = null, $default = null ) {
+ return $this->base->getCookie( $key, $prefix, $default );
+ }
+
+ public function getHeader( $name, $flags = 0 ) {
+ return $this->base->getHeader( $name, $flags );
+ }
+
+ public function getAllHeaders() {
+ return $this->base->getAllHeaders();
+ }
+
+ public function getSession() {
+ return $this->base->getSession();
+ }
+
+ public function getSessionData( $key ) {
+ return $this->base->getSessionData( $key );
+ }
+
+ public function setSessionData( $key, $data ) {
+ $this->base->setSessionData( $key, $data );
+ }
+
+ public function getAcceptLang() {
+ return $this->base->getAcceptLang();
+ }
+
+ public function getIP() {
+ return $this->base->getIP();
+ }
+
+ public function getProtocol() {
+ return $this->base->getProtocol();
+ }
+
+ public function getElapsedTime() {
+ return $this->base->getElapsedTime();
+ }
+}
diff --git a/www/wiki/includes/DummyLinker.php b/www/wiki/includes/DummyLinker.php
new file mode 100644
index 00000000..9aa6aeb0
--- /dev/null
+++ b/www/wiki/includes/DummyLinker.php
@@ -0,0 +1,489 @@
+<?php
+
+/**
+ * @since 1.18
+ */
+class DummyLinker {
+
+ /**
+ * @deprecated since 1.28, use LinkRenderer::getLinkClasses() instead
+ */
+ public function getLinkColour( $t, $threshold ) {
+ wfDeprecated( __METHOD__, '1.28' );
+ return Linker::getLinkColour( $t, $threshold );
+ }
+
+ public function link(
+ $target,
+ $html = null,
+ $customAttribs = [],
+ $query = [],
+ $options = []
+ ) {
+ return Linker::link(
+ $target,
+ $html,
+ $customAttribs,
+ $query,
+ $options
+ );
+ }
+
+ public function linkKnown(
+ $target,
+ $html = null,
+ $customAttribs = [],
+ $query = [],
+ $options = [ 'known' ]
+ ) {
+ return Linker::linkKnown(
+ $target,
+ $html,
+ $customAttribs,
+ $query,
+ $options
+ );
+ }
+
+ public function makeSelfLinkObj(
+ $nt,
+ $html = '',
+ $query = '',
+ $trail = '',
+ $prefix = ''
+ ) {
+ return Linker::makeSelfLinkObj(
+ $nt,
+ $html,
+ $query,
+ $trail,
+ $prefix
+ );
+ }
+
+ public function getInvalidTitleDescription(
+ IContextSource $context,
+ $namespace,
+ $title
+ ) {
+ return Linker::getInvalidTitleDescription(
+ $context,
+ $namespace,
+ $title
+ );
+ }
+
+ public function normaliseSpecialPage( Title $title ) {
+ return Linker::normaliseSpecialPage( $title );
+ }
+
+ public function makeExternalImage( $url, $alt = '' ) {
+ return Linker::makeExternalImage( $url, $alt );
+ }
+
+ public function makeImageLink(
+ Parser $parser,
+ Title $title,
+ $file,
+ $frameParams = [],
+ $handlerParams = [],
+ $time = false,
+ $query = "",
+ $widthOption = null
+ ) {
+ return Linker::makeImageLink(
+ $parser,
+ $title,
+ $file,
+ $frameParams,
+ $handlerParams,
+ $time,
+ $query,
+ $widthOption
+ );
+ }
+
+ public function makeThumbLinkObj(
+ Title $title,
+ $file,
+ $label = '',
+ $alt,
+ $align = 'right',
+ $params = [],
+ $framed = false,
+ $manualthumb = ""
+ ) {
+ return Linker::makeThumbLinkObj(
+ $title,
+ $file,
+ $label,
+ $alt,
+ $align,
+ $params,
+ $framed,
+ $manualthumb
+ );
+ }
+
+ public function makeThumbLink2(
+ Title $title,
+ $file,
+ $frameParams = [],
+ $handlerParams = [],
+ $time = false,
+ $query = ""
+ ) {
+ return Linker::makeThumbLink2(
+ $title,
+ $file,
+ $frameParams,
+ $handlerParams,
+ $time,
+ $query
+ );
+ }
+
+ public function processResponsiveImages( $file, $thumb, $hp ) {
+ Linker::processResponsiveImages(
+ $file,
+ $thumb,
+ $hp
+ );
+ }
+
+ public function makeBrokenImageLinkObj(
+ $title,
+ $label = '',
+ $query = '',
+ $unused1 = '',
+ $unused2 = '',
+ $time = false
+ ) {
+ return Linker::makeBrokenImageLinkObj(
+ $title,
+ $label,
+ $query,
+ $unused1,
+ $unused2,
+ $time
+ );
+ }
+
+ public function makeMediaLinkObj( $title, $html = '', $time = false ) {
+ return Linker::makeMediaLinkObj(
+ $title,
+ $html,
+ $time
+ );
+ }
+
+ public function makeMediaLinkFile( Title $title, $file, $html = '' ) {
+ return Linker::makeMediaLinkFile(
+ $title,
+ $file,
+ $html
+ );
+ }
+
+ public function specialLink( $name, $key = '' ) {
+ return Linker::specialLink( $name, $key );
+ }
+
+ public function makeExternalLink(
+ $url,
+ $text,
+ $escape = true,
+ $linktype = '',
+ $attribs = [],
+ $title = null
+ ) {
+ return Linker::makeExternalLink(
+ $url,
+ $text,
+ $escape,
+ $linktype,
+ $attribs,
+ $title
+ );
+ }
+
+ public function userLink( $userId, $userName, $altUserName = false ) {
+ return Linker::userLink(
+ $userId,
+ $userName,
+ $altUserName
+ );
+ }
+
+ public function userToolLinks(
+ $userId,
+ $userText,
+ $redContribsWhenNoEdits = false,
+ $flags = 0,
+ $edits = null
+ ) {
+ return Linker::userToolLinks(
+ $userId,
+ $userText,
+ $redContribsWhenNoEdits,
+ $flags,
+ $edits
+ );
+ }
+
+ public function userToolLinksRedContribs( $userId, $userText, $edits = null ) {
+ return Linker::userToolLinksRedContribs(
+ $userId,
+ $userText,
+ $edits
+ );
+ }
+
+ public function userTalkLink( $userId, $userText ) {
+ return Linker::userTalkLink( $userId, $userText );
+ }
+
+ public function blockLink( $userId, $userText ) {
+ return Linker::blockLink( $userId, $userText );
+ }
+
+ public function emailLink( $userId, $userText ) {
+ return Linker::emailLink( $userId, $userText );
+ }
+
+ public function revUserLink( $rev, $isPublic = false ) {
+ return Linker::revUserLink( $rev, $isPublic );
+ }
+
+ public function revUserTools( $rev, $isPublic = false ) {
+ return Linker::revUserTools( $rev, $isPublic );
+ }
+
+ public function formatComment(
+ $comment,
+ $title = null,
+ $local = false,
+ $wikiId = null
+ ) {
+ return Linker::formatComment(
+ $comment,
+ $title,
+ $local,
+ $wikiId
+ );
+ }
+
+ public function formatLinksInComment(
+ $comment,
+ $title = null,
+ $local = false,
+ $wikiId = null
+ ) {
+ return Linker::formatLinksInComment(
+ $comment,
+ $title,
+ $local,
+ $wikiId
+ );
+ }
+
+ public function makeCommentLink(
+ Title $title,
+ $text,
+ $wikiId = null,
+ $options = []
+ ) {
+ return Linker::makeCommentLink(
+ $title,
+ $text,
+ $wikiId,
+ $options
+ );
+ }
+
+ public function normalizeSubpageLink( $contextTitle, $target, &$text ) {
+ return Linker::normalizeSubpageLink(
+ $contextTitle,
+ $target,
+ $text
+ );
+ }
+
+ public function commentBlock(
+ $comment,
+ $title = null,
+ $local = false,
+ $wikiId = null
+ ) {
+ return Linker::commentBlock(
+ $comment,
+ $title,
+ $local,
+ $wikiId
+ );
+ }
+
+ public function revComment( Revision $rev, $local = false, $isPublic = false ) {
+ return Linker::revComment( $rev, $local, $isPublic );
+ }
+
+ public function formatRevisionSize( $size ) {
+ return Linker::formatRevisionSize( $size );
+ }
+
+ public function tocIndent() {
+ return Linker::tocIndent();
+ }
+
+ public function tocUnindent( $level ) {
+ return Linker::tocUnindent( $level );
+ }
+
+ public function tocLine( $anchor, $tocline, $tocnumber, $level, $sectionIndex = false ) {
+ return Linker::tocLine(
+ $anchor,
+ $tocline,
+ $tocnumber,
+ $level,
+ $sectionIndex
+ );
+ }
+
+ public function tocLineEnd() {
+ return Linker::tocLineEnd();
+ }
+
+ public function tocList( $toc, $lang = false ) {
+ return Linker::tocList( $toc, $lang );
+ }
+
+ public function generateTOC( $tree, $lang = false ) {
+ return Linker::generateTOC( $tree, $lang );
+ }
+
+ public function makeHeadline(
+ $level,
+ $attribs,
+ $anchor,
+ $html,
+ $link,
+ $legacyAnchor = false
+ ) {
+ return Linker::makeHeadline(
+ $level,
+ $attribs,
+ $anchor,
+ $html,
+ $link,
+ $legacyAnchor
+ );
+ }
+
+ public function splitTrail( $trail ) {
+ return Linker::splitTrail( $trail );
+ }
+
+ public function generateRollback(
+ $rev,
+ IContextSource $context = null,
+ $options = [ 'verify' ]
+ ) {
+ return Linker::generateRollback(
+ $rev,
+ $context,
+ $options
+ );
+ }
+
+ public function getRollbackEditCount( $rev, $verify ) {
+ return Linker::getRollbackEditCount( $rev, $verify );
+ }
+
+ public function buildRollbackLink(
+ $rev,
+ IContextSource $context = null,
+ $editCount = false
+ ) {
+ return Linker::buildRollbackLink(
+ $rev,
+ $context,
+ $editCount
+ );
+ }
+
+ /**
+ * @deprecated since 1.28, use TemplatesOnThisPageFormatter directly
+ */
+ public function formatTemplates(
+ $templates,
+ $preview = false,
+ $section = false,
+ $more = null
+ ) {
+ wfDeprecated( __METHOD__, '1.28' );
+
+ return Linker::formatTemplates(
+ $templates,
+ $preview,
+ $section,
+ $more
+ );
+ }
+
+ public function formatHiddenCategories( $hiddencats ) {
+ return Linker::formatHiddenCategories( $hiddencats );
+ }
+
+ /**
+ * @deprecated since 1.28, use Language::formatSize() directly
+ */
+ public function formatSize( $size ) {
+ wfDeprecated( __METHOD__, '1.28' );
+
+ return Linker::formatSize( $size );
+ }
+
+ public function titleAttrib( $name, $options = null, array $msgParams = [] ) {
+ return Linker::titleAttrib(
+ $name,
+ $options,
+ $msgParams
+ );
+ }
+
+ public function accesskey( $name ) {
+ return Linker::accesskey( $name );
+ }
+
+ public function getRevDeleteLink( User $user, Revision $rev, Title $title ) {
+ return Linker::getRevDeleteLink(
+ $user,
+ $rev,
+ $title
+ );
+ }
+
+ public function revDeleteLink( $query = [], $restricted = false, $delete = true ) {
+ return Linker::revDeleteLink(
+ $query,
+ $restricted,
+ $delete
+ );
+ }
+
+ public function revDeleteLinkDisabled( $delete = true ) {
+ return Linker::revDeleteLinkDisabled( $delete );
+ }
+
+ public function tooltipAndAccesskeyAttribs( $name, array $msgParams = [] ) {
+ return Linker::tooltipAndAccesskeyAttribs(
+ $name,
+ $msgParams
+ );
+ }
+
+ public function tooltip( $name, $options = null ) {
+ return Linker::tooltip( $name, $options );
+ }
+
+}
diff --git a/www/wiki/includes/EditPage.php b/www/wiki/includes/EditPage.php
new file mode 100644
index 00000000..eeae7b9d
--- /dev/null
+++ b/www/wiki/includes/EditPage.php
@@ -0,0 +1,4725 @@
+<?php
+/**
+ * User interface for page editing.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+
+/**
+ * The edit page/HTML interface (split from Article)
+ * The actual database and text munging is still in Article,
+ * but it should get easier to call those from alternate
+ * interfaces.
+ *
+ * EditPage cares about two distinct titles:
+ * $this->mContextTitle is the page that forms submit to, links point to,
+ * redirects go to, etc. $this->mTitle (as well as $mArticle) is the
+ * page in the database that is actually being edited. These are
+ * usually the same, but they are now allowed to be different.
+ *
+ * Surgeon General's Warning: prolonged exposure to this class is known to cause
+ * headaches, which may be fatal.
+ */
+class EditPage {
+ /**
+ * Used for Unicode support checks
+ */
+ const UNICODE_CHECK = 'ℳ𝒲♥𝓊𝓃𝒾𝒸ℴ𝒹ℯ';
+
+ /**
+ * Status: Article successfully updated
+ */
+ const AS_SUCCESS_UPDATE = 200;
+
+ /**
+ * Status: Article successfully created
+ */
+ const AS_SUCCESS_NEW_ARTICLE = 201;
+
+ /**
+ * Status: Article update aborted by a hook function
+ */
+ const AS_HOOK_ERROR = 210;
+
+ /**
+ * Status: A hook function returned an error
+ */
+ const AS_HOOK_ERROR_EXPECTED = 212;
+
+ /**
+ * Status: User is blocked from editing this page
+ */
+ const AS_BLOCKED_PAGE_FOR_USER = 215;
+
+ /**
+ * Status: Content too big (> $wgMaxArticleSize)
+ */
+ const AS_CONTENT_TOO_BIG = 216;
+
+ /**
+ * Status: this anonymous user is not allowed to edit this page
+ */
+ const AS_READ_ONLY_PAGE_ANON = 218;
+
+ /**
+ * Status: this logged in user is not allowed to edit this page
+ */
+ const AS_READ_ONLY_PAGE_LOGGED = 219;
+
+ /**
+ * Status: wiki is in readonly mode (wfReadOnly() == true)
+ */
+ const AS_READ_ONLY_PAGE = 220;
+
+ /**
+ * Status: rate limiter for action 'edit' was tripped
+ */
+ const AS_RATE_LIMITED = 221;
+
+ /**
+ * Status: article was deleted while editing and param wpRecreate == false or form
+ * was not posted
+ */
+ const AS_ARTICLE_WAS_DELETED = 222;
+
+ /**
+ * Status: user tried to create this page, but is not allowed to do that
+ * ( Title->userCan('create') == false )
+ */
+ const AS_NO_CREATE_PERMISSION = 223;
+
+ /**
+ * Status: user tried to create a blank page and wpIgnoreBlankArticle == false
+ */
+ const AS_BLANK_ARTICLE = 224;
+
+ /**
+ * Status: (non-resolvable) edit conflict
+ */
+ const AS_CONFLICT_DETECTED = 225;
+
+ /**
+ * Status: no edit summary given and the user has forceeditsummary set and the user is not
+ * editing in his own userspace or talkspace and wpIgnoreBlankSummary == false
+ */
+ const AS_SUMMARY_NEEDED = 226;
+
+ /**
+ * Status: user tried to create a new section without content
+ */
+ const AS_TEXTBOX_EMPTY = 228;
+
+ /**
+ * Status: article is too big (> $wgMaxArticleSize), after merging in the new section
+ */
+ const AS_MAX_ARTICLE_SIZE_EXCEEDED = 229;
+
+ /**
+ * Status: WikiPage::doEdit() was unsuccessful
+ */
+ const AS_END = 231;
+
+ /**
+ * Status: summary contained spam according to one of the regexes in $wgSummarySpamRegex
+ */
+ const AS_SPAM_ERROR = 232;
+
+ /**
+ * Status: anonymous user is not allowed to upload (User::isAllowed('upload') == false)
+ */
+ const AS_IMAGE_REDIRECT_ANON = 233;
+
+ /**
+ * Status: logged in user is not allowed to upload (User::isAllowed('upload') == false)
+ */
+ const AS_IMAGE_REDIRECT_LOGGED = 234;
+
+ /**
+ * Status: user tried to modify the content model, but is not allowed to do that
+ * ( User::isAllowed('editcontentmodel') == false )
+ */
+ const AS_NO_CHANGE_CONTENT_MODEL = 235;
+
+ /**
+ * Status: user tried to create self-redirect (redirect to the same article) and
+ * wpIgnoreSelfRedirect == false
+ */
+ const AS_SELF_REDIRECT = 236;
+
+ /**
+ * Status: an error relating to change tagging. Look at the message key for
+ * more details
+ */
+ const AS_CHANGE_TAG_ERROR = 237;
+
+ /**
+ * Status: can't parse content
+ */
+ const AS_PARSE_ERROR = 240;
+
+ /**
+ * Status: when changing the content model is disallowed due to
+ * $wgContentHandlerUseDB being false
+ */
+ const AS_CANNOT_USE_CUSTOM_MODEL = 241;
+
+ /**
+ * Status: edit rejected because browser doesn't support Unicode.
+ */
+ const AS_UNICODE_NOT_SUPPORTED = 242;
+
+ /**
+ * HTML id and name for the beginning of the edit form.
+ */
+ const EDITFORM_ID = 'editform';
+
+ /**
+ * Prefix of key for cookie used to pass post-edit state.
+ * The revision id edited is added after this
+ */
+ const POST_EDIT_COOKIE_KEY_PREFIX = 'PostEditRevision';
+
+ /**
+ * Duration of PostEdit cookie, in seconds.
+ * The cookie will be removed instantly if the JavaScript runs.
+ *
+ * Otherwise, though, we don't want the cookies to accumulate.
+ * RFC 2109 ( https://www.ietf.org/rfc/rfc2109.txt ) specifies a possible
+ * limit of only 20 cookies per domain. This still applies at least to some
+ * versions of IE without full updates:
+ * https://blogs.msdn.com/b/ieinternals/archive/2009/08/20/wininet-ie-cookie-internals-faq.aspx
+ *
+ * A value of 20 minutes should be enough to take into account slow loads and minor
+ * clock skew while still avoiding cookie accumulation when JavaScript is turned off.
+ */
+ const POST_EDIT_COOKIE_DURATION = 1200;
+
+ /**
+ * @deprecated for public usage since 1.30 use EditPage::getArticle()
+ * @var Article
+ */
+ public $mArticle;
+ /** @var WikiPage */
+ private $page;
+
+ /**
+ * @deprecated for public usage since 1.30 use EditPage::getTitle()
+ * @var Title
+ */
+ public $mTitle;
+
+ /** @var null|Title */
+ private $mContextTitle = null;
+
+ /** @var string */
+ public $action = 'submit';
+
+ /** @var bool */
+ public $isConflict = false;
+
+ /**
+ * @deprecated since 1.30 use Title::isCssJsSubpage()
+ * @var bool
+ */
+ public $isCssJsSubpage = false;
+
+ /**
+ * @deprecated since 1.30 use Title::isCssSubpage()
+ * @var bool
+ */
+ public $isCssSubpage = false;
+
+ /**
+ * @deprecated since 1.30 use Title::isJsSubpage()
+ * @var bool
+ */
+ public $isJsSubpage = false;
+
+ /**
+ * @deprecated since 1.30
+ * @var bool
+ */
+ public $isWrongCaseCssJsPage = false;
+
+ /** @var bool New page or new section */
+ public $isNew = false;
+
+ /** @var bool */
+ public $deletedSinceEdit;
+
+ /** @var string */
+ public $formtype;
+
+ /** @var bool */
+ public $firsttime;
+
+ /** @var bool|stdClass */
+ public $lastDelete;
+
+ /** @var bool */
+ public $mTokenOk = false;
+
+ /** @var bool */
+ public $mTokenOkExceptSuffix = false;
+
+ /** @var bool */
+ public $mTriedSave = false;
+
+ /** @var bool */
+ public $incompleteForm = false;
+
+ /** @var bool */
+ public $tooBig = false;
+
+ /** @var bool */
+ public $missingComment = false;
+
+ /** @var bool */
+ public $missingSummary = false;
+
+ /** @var bool */
+ public $allowBlankSummary = false;
+
+ /** @var bool */
+ protected $blankArticle = false;
+
+ /** @var bool */
+ protected $allowBlankArticle = false;
+
+ /** @var bool */
+ protected $selfRedirect = false;
+
+ /** @var bool */
+ protected $allowSelfRedirect = false;
+
+ /** @var string */
+ public $autoSumm = '';
+
+ /** @var string */
+ public $hookError = '';
+
+ /** @var ParserOutput */
+ public $mParserOutput;
+
+ /** @var bool Has a summary been preset using GET parameter &summary= ? */
+ public $hasPresetSummary = false;
+
+ /** @var Revision|bool */
+ public $mBaseRevision = false;
+
+ /** @var bool */
+ public $mShowSummaryField = true;
+
+ # Form values
+
+ /** @var bool */
+ public $save = false;
+
+ /** @var bool */
+ public $preview = false;
+
+ /** @var bool */
+ public $diff = false;
+
+ /** @var bool */
+ public $minoredit = false;
+
+ /** @var bool */
+ public $watchthis = false;
+
+ /** @var bool */
+ public $recreate = false;
+
+ /** @var string */
+ public $textbox1 = '';
+
+ /** @var string */
+ public $textbox2 = '';
+
+ /** @var string */
+ public $summary = '';
+
+ /** @var bool */
+ public $nosummary = false;
+
+ /** @var string */
+ public $edittime = '';
+
+ /** @var int */
+ private $editRevId = null;
+
+ /** @var string */
+ public $section = '';
+
+ /** @var string */
+ public $sectiontitle = '';
+
+ /** @var string */
+ public $starttime = '';
+
+ /** @var int */
+ public $oldid = 0;
+
+ /** @var int */
+ public $parentRevId = 0;
+
+ /** @var string */
+ public $editintro = '';
+
+ /** @var null */
+ public $scrolltop = null;
+
+ /** @var bool */
+ public $bot = true;
+
+ /** @var string */
+ public $contentModel;
+
+ /** @var null|string */
+ public $contentFormat = null;
+
+ /** @var null|array */
+ private $changeTags = null;
+
+ # Placeholders for text injection by hooks (must be HTML)
+ # extensions should take care to _append_ to the present value
+
+ /** @var string Before even the preview */
+ public $editFormPageTop = '';
+ public $editFormTextTop = '';
+ public $editFormTextBeforeContent = '';
+ public $editFormTextAfterWarn = '';
+ public $editFormTextAfterTools = '';
+ public $editFormTextBottom = '';
+ public $editFormTextAfterContent = '';
+ public $previewTextAfterContent = '';
+ public $mPreloadContent = null;
+
+ /* $didSave should be set to true whenever an article was successfully altered. */
+ public $didSave = false;
+ public $undidRev = 0;
+
+ public $suppressIntro = false;
+
+ /** @var bool */
+ protected $edit;
+
+ /** @var bool|int */
+ protected $contentLength = false;
+
+ /**
+ * @var bool Set in ApiEditPage, based on ContentHandler::allowsDirectApiEditing
+ */
+ private $enableApiEditOverride = false;
+
+ /**
+ * @var IContextSource
+ */
+ protected $context;
+
+ /**
+ * @var bool Whether an old revision is edited
+ */
+ private $isOldRev = false;
+
+ /**
+ * @var string|null What the user submitted in the 'wpUnicodeCheck' field
+ */
+ private $unicodeCheck;
+
+ /**
+ * @param Article $article
+ */
+ public function __construct( Article $article ) {
+ $this->mArticle = $article;
+ $this->page = $article->getPage(); // model object
+ $this->mTitle = $article->getTitle();
+ $this->context = $article->getContext();
+
+ $this->contentModel = $this->mTitle->getContentModel();
+
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+ $this->contentFormat = $handler->getDefaultFormat();
+ }
+
+ /**
+ * @return Article
+ */
+ public function getArticle() {
+ return $this->mArticle;
+ }
+
+ /**
+ * @since 1.28
+ * @return IContextSource
+ */
+ public function getContext() {
+ return $this->context;
+ }
+
+ /**
+ * @since 1.19
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->mTitle;
+ }
+
+ /**
+ * Set the context Title object
+ *
+ * @param Title|null $title Title object or null
+ */
+ public function setContextTitle( $title ) {
+ $this->mContextTitle = $title;
+ }
+
+ /**
+ * Get the context title object.
+ * If not set, $wgTitle will be returned. This behavior might change in
+ * the future to return $this->mTitle instead.
+ *
+ * @return Title
+ */
+ public function getContextTitle() {
+ if ( is_null( $this->mContextTitle ) ) {
+ wfDebugLog(
+ 'GlobalTitleFail',
+ __METHOD__ . ' called by ' . wfGetAllCallers( 5 ) . ' with no title set.'
+ );
+ global $wgTitle;
+ return $wgTitle;
+ } else {
+ return $this->mContextTitle;
+ }
+ }
+
+ /**
+ * Check if the edit page is using OOUI controls
+ * @return bool Always true
+ * @deprecated since 1.30
+ */
+ public function isOouiEnabled() {
+ return true;
+ }
+
+ /**
+ * Returns if the given content model is editable.
+ *
+ * @param string $modelId The ID of the content model to test. Use CONTENT_MODEL_XXX constants.
+ * @return bool
+ * @throws MWException If $modelId has no known handler
+ */
+ public function isSupportedContentModel( $modelId ) {
+ return $this->enableApiEditOverride === true ||
+ ContentHandler::getForModelID( $modelId )->supportsDirectEditing();
+ }
+
+ /**
+ * Allow editing of content that supports API direct editing, but not general
+ * direct editing. Set to false by default.
+ *
+ * @param bool $enableOverride
+ */
+ public function setApiEditOverride( $enableOverride ) {
+ $this->enableApiEditOverride = $enableOverride;
+ }
+
+ /**
+ * @deprecated since 1.29, call edit directly
+ */
+ public function submit() {
+ wfDeprecated( __METHOD__, '1.29' );
+ $this->edit();
+ }
+
+ /**
+ * This is the function that gets called for "action=edit". It
+ * sets up various member variables, then passes execution to
+ * another function, usually showEditForm()
+ *
+ * The edit form is self-submitting, so that when things like
+ * preview and edit conflicts occur, we get the same form back
+ * with the extra stuff added. Only when the final submission
+ * is made and all is well do we actually save and redirect to
+ * the newly-edited page.
+ */
+ public function edit() {
+ // Allow extensions to modify/prevent this form or submission
+ if ( !Hooks::run( 'AlternateEdit', [ $this ] ) ) {
+ return;
+ }
+
+ wfDebug( __METHOD__ . ": enter\n" );
+
+ $request = $this->context->getRequest();
+ // If they used redlink=1 and the page exists, redirect to the main article
+ if ( $request->getBool( 'redlink' ) && $this->mTitle->exists() ) {
+ $this->context->getOutput()->redirect( $this->mTitle->getFullURL() );
+ return;
+ }
+
+ $this->importFormData( $request );
+ $this->firsttime = false;
+
+ if ( wfReadOnly() && $this->save ) {
+ // Force preview
+ $this->save = false;
+ $this->preview = true;
+ }
+
+ if ( $this->save ) {
+ $this->formtype = 'save';
+ } elseif ( $this->preview ) {
+ $this->formtype = 'preview';
+ } elseif ( $this->diff ) {
+ $this->formtype = 'diff';
+ } else { # First time through
+ $this->firsttime = true;
+ if ( $this->previewOnOpen() ) {
+ $this->formtype = 'preview';
+ } else {
+ $this->formtype = 'initial';
+ }
+ }
+
+ $permErrors = $this->getEditPermissionErrors( $this->save ? 'secure' : 'full' );
+ if ( $permErrors ) {
+ wfDebug( __METHOD__ . ": User can't edit\n" );
+ // Auto-block user's IP if the account was "hard" blocked
+ if ( !wfReadOnly() ) {
+ DeferredUpdates::addCallableUpdate( function () {
+ $this->context->getUser()->spreadAnyEditBlock();
+ } );
+ }
+ $this->displayPermissionsError( $permErrors );
+
+ return;
+ }
+
+ $revision = $this->mArticle->getRevisionFetched();
+ // Disallow editing revisions with content models different from the current one
+ // Undo edits being an exception in order to allow reverting content model changes.
+ if ( $revision
+ && $revision->getContentModel() !== $this->contentModel
+ ) {
+ $prevRev = null;
+ if ( $this->undidRev ) {
+ $undidRevObj = Revision::newFromId( $this->undidRev );
+ $prevRev = $undidRevObj ? $undidRevObj->getPrevious() : null;
+ }
+ if ( !$this->undidRev
+ || !$prevRev
+ || $prevRev->getContentModel() !== $this->contentModel
+ ) {
+ $this->displayViewSourcePage(
+ $this->getContentObject(),
+ $this->context->msg(
+ 'contentmodelediterror',
+ $revision->getContentModel(),
+ $this->contentModel
+ )->plain()
+ );
+ return;
+ }
+ }
+
+ $this->isConflict = false;
+ // css / js subpages of user pages get a special treatment
+ // The following member variables are deprecated since 1.30,
+ // the functions should be used instead.
+ $this->isCssJsSubpage = $this->mTitle->isCssJsSubpage();
+ $this->isCssSubpage = $this->mTitle->isCssSubpage();
+ $this->isJsSubpage = $this->mTitle->isJsSubpage();
+ $this->isWrongCaseCssJsPage = $this->isWrongCaseCssJsPage();
+
+ # Show applicable editing introductions
+ if ( $this->formtype == 'initial' || $this->firsttime ) {
+ $this->showIntro();
+ }
+
+ # Attempt submission here. This will check for edit conflicts,
+ # and redundantly check for locked database, blocked IPs, etc.
+ # that edit() already checked just in case someone tries to sneak
+ # in the back door with a hand-edited submission URL.
+
+ if ( 'save' == $this->formtype ) {
+ $resultDetails = null;
+ $status = $this->attemptSave( $resultDetails );
+ if ( !$this->handleStatus( $status, $resultDetails ) ) {
+ return;
+ }
+ }
+
+ # First time through: get contents, set time for conflict
+ # checking, etc.
+ if ( 'initial' == $this->formtype || $this->firsttime ) {
+ if ( $this->initialiseForm() === false ) {
+ $this->noSuchSectionPage();
+ return;
+ }
+
+ if ( !$this->mTitle->getArticleID() ) {
+ Hooks::run( 'EditFormPreloadText', [ &$this->textbox1, &$this->mTitle ] );
+ } else {
+ Hooks::run( 'EditFormInitialText', [ $this ] );
+ }
+
+ }
+
+ $this->showEditForm();
+ }
+
+ /**
+ * @param string $rigor Same format as Title::getUserPermissionErrors()
+ * @return array
+ */
+ protected function getEditPermissionErrors( $rigor = 'secure' ) {
+ $user = $this->context->getUser();
+ $permErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user, $rigor );
+ # Can this title be created?
+ if ( !$this->mTitle->exists() ) {
+ $permErrors = array_merge(
+ $permErrors,
+ wfArrayDiff2(
+ $this->mTitle->getUserPermissionsErrors( 'create', $user, $rigor ),
+ $permErrors
+ )
+ );
+ }
+ # Ignore some permissions errors when a user is just previewing/viewing diffs
+ $remove = [];
+ foreach ( $permErrors as $error ) {
+ if ( ( $this->preview || $this->diff )
+ && (
+ $error[0] == 'blockedtext' ||
+ $error[0] == 'autoblockedtext' ||
+ $error[0] == 'systemblockedtext'
+ )
+ ) {
+ $remove[] = $error;
+ }
+ }
+ $permErrors = wfArrayDiff2( $permErrors, $remove );
+
+ return $permErrors;
+ }
+
+ /**
+ * Display a permissions error page, like OutputPage::showPermissionsErrorPage(),
+ * but with the following differences:
+ * - If redlink=1, the user will be redirected to the page
+ * - If there is content to display or the error occurs while either saving,
+ * previewing or showing the difference, it will be a
+ * "View source for ..." page displaying the source code after the error message.
+ *
+ * @since 1.19
+ * @param array $permErrors Array of permissions errors, as returned by
+ * Title::getUserPermissionsErrors().
+ * @throws PermissionsError
+ */
+ protected function displayPermissionsError( array $permErrors ) {
+ $out = $this->context->getOutput();
+ if ( $this->context->getRequest()->getBool( 'redlink' ) ) {
+ // The edit page was reached via a red link.
+ // Redirect to the article page and let them click the edit tab if
+ // they really want a permission error.
+ $out->redirect( $this->mTitle->getFullURL() );
+ return;
+ }
+
+ $content = $this->getContentObject();
+
+ # Use the normal message if there's nothing to display
+ if ( $this->firsttime && ( !$content || $content->isEmpty() ) ) {
+ $action = $this->mTitle->exists() ? 'edit' :
+ ( $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage' );
+ throw new PermissionsError( $action, $permErrors );
+ }
+
+ $this->displayViewSourcePage(
+ $content,
+ $out->formatPermissionsErrorMessage( $permErrors, 'edit' )
+ );
+ }
+
+ /**
+ * Display a read-only View Source page
+ * @param Content $content content object
+ * @param string $errorMessage additional wikitext error message to display
+ */
+ protected function displayViewSourcePage( Content $content, $errorMessage = '' ) {
+ $out = $this->context->getOutput();
+ Hooks::run( 'EditPage::showReadOnlyForm:initial', [ $this, &$out ] );
+
+ $out->setRobotPolicy( 'noindex,nofollow' );
+ $out->setPageTitle( $this->context->msg(
+ 'viewsource-title',
+ $this->getContextTitle()->getPrefixedText()
+ ) );
+ $out->addBacklinkSubtitle( $this->getContextTitle() );
+ $out->addHTML( $this->editFormPageTop );
+ $out->addHTML( $this->editFormTextTop );
+
+ if ( $errorMessage !== '' ) {
+ $out->addWikiText( $errorMessage );
+ $out->addHTML( "<hr />\n" );
+ }
+
+ # If the user made changes, preserve them when showing the markup
+ # (This happens when a user is blocked during edit, for instance)
+ if ( !$this->firsttime ) {
+ $text = $this->textbox1;
+ $out->addWikiMsg( 'viewyourtext' );
+ } else {
+ try {
+ $text = $this->toEditText( $content );
+ } catch ( MWException $e ) {
+ # Serialize using the default format if the content model is not supported
+ # (e.g. for an old revision with a different model)
+ $text = $content->serialize();
+ }
+ $out->addWikiMsg( 'viewsourcetext' );
+ }
+
+ $out->addHTML( $this->editFormTextBeforeContent );
+ $this->showTextbox( $text, 'wpTextbox1', [ 'readonly' ] );
+ $out->addHTML( $this->editFormTextAfterContent );
+
+ $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) );
+
+ $out->addModules( 'mediawiki.action.edit.collapsibleFooter' );
+
+ $out->addHTML( $this->editFormTextBottom );
+ if ( $this->mTitle->exists() ) {
+ $out->returnToMain( null, $this->mTitle );
+ }
+ }
+
+ /**
+ * Should we show a preview when the edit form is first shown?
+ *
+ * @return bool
+ */
+ protected function previewOnOpen() {
+ $previewOnOpenNamespaces = $this->context->getConfig()->get( 'PreviewOnOpenNamespaces' );
+ $request = $this->context->getRequest();
+ if ( $request->getVal( 'preview' ) == 'yes' ) {
+ // Explicit override from request
+ return true;
+ } elseif ( $request->getVal( 'preview' ) == 'no' ) {
+ // Explicit override from request
+ return false;
+ } elseif ( $this->section == 'new' ) {
+ // Nothing *to* preview for new sections
+ return false;
+ } elseif ( ( $request->getVal( 'preload' ) !== null || $this->mTitle->exists() )
+ && $this->context->getUser()->getOption( 'previewonfirst' )
+ ) {
+ // Standard preference behavior
+ return true;
+ } elseif ( !$this->mTitle->exists()
+ && isset( $previewOnOpenNamespaces[$this->mTitle->getNamespace()] )
+ && $previewOnOpenNamespaces[$this->mTitle->getNamespace()]
+ ) {
+ // Categories are special
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Checks whether the user entered a skin name in uppercase,
+ * e.g. "User:Example/Monobook.css" instead of "monobook.css"
+ *
+ * @return bool
+ */
+ protected function isWrongCaseCssJsPage() {
+ if ( $this->mTitle->isCssJsSubpage() ) {
+ $name = $this->mTitle->getSkinFromCssJsSubpage();
+ $skins = array_merge(
+ array_keys( Skin::getSkinNames() ),
+ [ 'common' ]
+ );
+ return !in_array( $name, $skins )
+ && in_array( strtolower( $name ), $skins );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Returns whether section editing is supported for the current page.
+ * Subclasses may override this to replace the default behavior, which is
+ * to check ContentHandler::supportsSections.
+ *
+ * @return bool True if this edit page supports sections, false otherwise.
+ */
+ protected function isSectionEditSupported() {
+ $contentHandler = ContentHandler::getForTitle( $this->mTitle );
+ return $contentHandler->supportsSections();
+ }
+
+ /**
+ * This function collects the form data and uses it to populate various member variables.
+ * @param WebRequest &$request
+ * @throws ErrorPageError
+ */
+ public function importFormData( &$request ) {
+ # Section edit can come from either the form or a link
+ $this->section = $request->getVal( 'wpSection', $request->getVal( 'section' ) );
+
+ if ( $this->section !== null && $this->section !== '' && !$this->isSectionEditSupported() ) {
+ throw new ErrorPageError( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
+ }
+
+ $this->isNew = !$this->mTitle->exists() || $this->section == 'new';
+
+ if ( $request->wasPosted() ) {
+ # These fields need to be checked for encoding.
+ # Also remove trailing whitespace, but don't remove _initial_
+ # whitespace from the text boxes. This may be significant formatting.
+ $this->textbox1 = rtrim( $request->getText( 'wpTextbox1' ) );
+ if ( !$request->getCheck( 'wpTextbox2' ) ) {
+ // Skip this if wpTextbox2 has input, it indicates that we came
+ // from a conflict page with raw page text, not a custom form
+ // modified by subclasses
+ $textbox1 = $this->importContentFormData( $request );
+ if ( $textbox1 !== null ) {
+ $this->textbox1 = $textbox1;
+ }
+ }
+
+ $this->unicodeCheck = $request->getText( 'wpUnicodeCheck' );
+
+ $this->summary = $request->getText( 'wpSummary' );
+
+ # If the summary consists of a heading, e.g. '==Foobar==', extract the title from the
+ # header syntax, e.g. 'Foobar'. This is mainly an issue when we are using wpSummary for
+ # section titles.
+ $this->summary = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->summary );
+
+ # Treat sectiontitle the same way as summary.
+ # Note that wpSectionTitle is not yet a part of the actual edit form, as wpSummary is
+ # currently doing double duty as both edit summary and section title. Right now this
+ # is just to allow API edits to work around this limitation, but this should be
+ # incorporated into the actual edit form when EditPage is rewritten (Bugs 18654, 26312).
+ $this->sectiontitle = $request->getText( 'wpSectionTitle' );
+ $this->sectiontitle = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->sectiontitle );
+
+ $this->edittime = $request->getVal( 'wpEdittime' );
+ $this->editRevId = $request->getIntOrNull( 'editRevId' );
+ $this->starttime = $request->getVal( 'wpStarttime' );
+
+ $undidRev = $request->getInt( 'wpUndidRevision' );
+ if ( $undidRev ) {
+ $this->undidRev = $undidRev;
+ }
+
+ $this->scrolltop = $request->getIntOrNull( 'wpScrolltop' );
+
+ if ( $this->textbox1 === '' && $request->getVal( 'wpTextbox1' ) === null ) {
+ // wpTextbox1 field is missing, possibly due to being "too big"
+ // according to some filter rules such as Suhosin's setting for
+ // suhosin.request.max_value_length (d'oh)
+ $this->incompleteForm = true;
+ } else {
+ // If we receive the last parameter of the request, we can fairly
+ // claim the POST request has not been truncated.
+
+ // TODO: softened the check for cutover. Once we determine
+ // that it is safe, we should complete the transition by
+ // removing the "edittime" clause.
+ $this->incompleteForm = ( !$request->getVal( 'wpUltimateParam' )
+ && is_null( $this->edittime ) );
+ }
+ if ( $this->incompleteForm ) {
+ # If the form is incomplete, force to preview.
+ wfDebug( __METHOD__ . ": Form data appears to be incomplete\n" );
+ wfDebug( "POST DATA: " . var_export( $_POST, true ) . "\n" );
+ $this->preview = true;
+ } else {
+ $this->preview = $request->getCheck( 'wpPreview' );
+ $this->diff = $request->getCheck( 'wpDiff' );
+
+ // Remember whether a save was requested, so we can indicate
+ // if we forced preview due to session failure.
+ $this->mTriedSave = !$this->preview;
+
+ if ( $this->tokenOk( $request ) ) {
+ # Some browsers will not report any submit button
+ # if the user hits enter in the comment box.
+ # The unmarked state will be assumed to be a save,
+ # if the form seems otherwise complete.
+ wfDebug( __METHOD__ . ": Passed token check.\n" );
+ } elseif ( $this->diff ) {
+ # Failed token check, but only requested "Show Changes".
+ wfDebug( __METHOD__ . ": Failed token check; Show Changes requested.\n" );
+ } else {
+ # Page might be a hack attempt posted from
+ # an external site. Preview instead of saving.
+ wfDebug( __METHOD__ . ": Failed token check; forcing preview\n" );
+ $this->preview = true;
+ }
+ }
+ $this->save = !$this->preview && !$this->diff;
+ if ( !preg_match( '/^\d{14}$/', $this->edittime ) ) {
+ $this->edittime = null;
+ }
+
+ if ( !preg_match( '/^\d{14}$/', $this->starttime ) ) {
+ $this->starttime = null;
+ }
+
+ $this->recreate = $request->getCheck( 'wpRecreate' );
+
+ $this->minoredit = $request->getCheck( 'wpMinoredit' );
+ $this->watchthis = $request->getCheck( 'wpWatchthis' );
+
+ $user = $this->context->getUser();
+ # Don't force edit summaries when a user is editing their own user or talk page
+ if ( ( $this->mTitle->mNamespace == NS_USER || $this->mTitle->mNamespace == NS_USER_TALK )
+ && $this->mTitle->getText() == $user->getName()
+ ) {
+ $this->allowBlankSummary = true;
+ } else {
+ $this->allowBlankSummary = $request->getBool( 'wpIgnoreBlankSummary' )
+ || !$user->getOption( 'forceeditsummary' );
+ }
+
+ $this->autoSumm = $request->getText( 'wpAutoSummary' );
+
+ $this->allowBlankArticle = $request->getBool( 'wpIgnoreBlankArticle' );
+ $this->allowSelfRedirect = $request->getBool( 'wpIgnoreSelfRedirect' );
+
+ $changeTags = $request->getVal( 'wpChangeTags' );
+ if ( is_null( $changeTags ) || $changeTags === '' ) {
+ $this->changeTags = [];
+ } else {
+ $this->changeTags = array_filter( array_map( 'trim', explode( ',',
+ $changeTags ) ) );
+ }
+ } else {
+ # Not a posted form? Start with nothing.
+ wfDebug( __METHOD__ . ": Not a posted form.\n" );
+ $this->textbox1 = '';
+ $this->summary = '';
+ $this->sectiontitle = '';
+ $this->edittime = '';
+ $this->editRevId = null;
+ $this->starttime = wfTimestampNow();
+ $this->edit = false;
+ $this->preview = false;
+ $this->save = false;
+ $this->diff = false;
+ $this->minoredit = false;
+ // Watch may be overridden by request parameters
+ $this->watchthis = $request->getBool( 'watchthis', false );
+ $this->recreate = false;
+
+ // When creating a new section, we can preload a section title by passing it as the
+ // preloadtitle parameter in the URL (T15100)
+ if ( $this->section == 'new' && $request->getVal( 'preloadtitle' ) ) {
+ $this->sectiontitle = $request->getVal( 'preloadtitle' );
+ // Once wpSummary isn't being use for setting section titles, we should delete this.
+ $this->summary = $request->getVal( 'preloadtitle' );
+ } elseif ( $this->section != 'new' && $request->getVal( 'summary' ) ) {
+ $this->summary = $request->getText( 'summary' );
+ if ( $this->summary !== '' ) {
+ $this->hasPresetSummary = true;
+ }
+ }
+
+ if ( $request->getVal( 'minor' ) ) {
+ $this->minoredit = true;
+ }
+ }
+
+ $this->oldid = $request->getInt( 'oldid' );
+ $this->parentRevId = $request->getInt( 'parentRevId' );
+
+ $this->bot = $request->getBool( 'bot', true );
+ $this->nosummary = $request->getBool( 'nosummary' );
+
+ // May be overridden by revision.
+ $this->contentModel = $request->getText( 'model', $this->contentModel );
+ // May be overridden by revision.
+ $this->contentFormat = $request->getText( 'format', $this->contentFormat );
+
+ try {
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+ } catch ( MWUnknownContentModelException $e ) {
+ throw new ErrorPageError(
+ 'editpage-invalidcontentmodel-title',
+ 'editpage-invalidcontentmodel-text',
+ [ wfEscapeWikiText( $this->contentModel ) ]
+ );
+ }
+
+ if ( !$handler->isSupportedFormat( $this->contentFormat ) ) {
+ throw new ErrorPageError(
+ 'editpage-notsupportedcontentformat-title',
+ 'editpage-notsupportedcontentformat-text',
+ [
+ wfEscapeWikiText( $this->contentFormat ),
+ wfEscapeWikiText( ContentHandler::getLocalizedName( $this->contentModel ) )
+ ]
+ );
+ }
+
+ /**
+ * @todo Check if the desired model is allowed in this namespace, and if
+ * a transition from the page's current model to the new model is
+ * allowed.
+ */
+
+ $this->editintro = $request->getText( 'editintro',
+ // Custom edit intro for new sections
+ $this->section === 'new' ? 'MediaWiki:addsection-editintro' : '' );
+
+ // Allow extensions to modify form data
+ Hooks::run( 'EditPage::importFormData', [ $this, $request ] );
+ }
+
+ /**
+ * Subpage overridable method for extracting the page content data from the
+ * posted form to be placed in $this->textbox1, if using customized input
+ * this method should be overridden and return the page text that will be used
+ * for saving, preview parsing and so on...
+ *
+ * @param WebRequest &$request
+ * @return string|null
+ */
+ protected function importContentFormData( &$request ) {
+ return; // Don't do anything, EditPage already extracted wpTextbox1
+ }
+
+ /**
+ * Initialise form fields in the object
+ * Called on the first invocation, e.g. when a user clicks an edit link
+ * @return bool If the requested section is valid
+ */
+ public function initialiseForm() {
+ $this->edittime = $this->page->getTimestamp();
+ $this->editRevId = $this->page->getLatest();
+
+ $content = $this->getContentObject( false ); # TODO: track content object?!
+ if ( $content === false ) {
+ return false;
+ }
+ $this->textbox1 = $this->toEditText( $content );
+
+ $user = $this->context->getUser();
+ // activate checkboxes if user wants them to be always active
+ # Sort out the "watch" checkbox
+ if ( $user->getOption( 'watchdefault' ) ) {
+ # Watch all edits
+ $this->watchthis = true;
+ } elseif ( $user->getOption( 'watchcreations' ) && !$this->mTitle->exists() ) {
+ # Watch creations
+ $this->watchthis = true;
+ } elseif ( $user->isWatched( $this->mTitle ) ) {
+ # Already watched
+ $this->watchthis = true;
+ }
+ if ( $user->getOption( 'minordefault' ) && !$this->isNew ) {
+ $this->minoredit = true;
+ }
+ if ( $this->textbox1 === false ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @param Content|null $def_content The default value to return
+ *
+ * @return Content|null Content on success, $def_content for invalid sections
+ *
+ * @since 1.21
+ */
+ protected function getContentObject( $def_content = null ) {
+ global $wgContLang;
+
+ $content = false;
+
+ $user = $this->context->getUser();
+ $request = $this->context->getRequest();
+ // For message page not locally set, use the i18n message.
+ // For other non-existent articles, use preload text if any.
+ if ( !$this->mTitle->exists() || $this->section == 'new' ) {
+ if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI && $this->section != 'new' ) {
+ # If this is a system message, get the default text.
+ $msg = $this->mTitle->getDefaultMessageText();
+
+ $content = $this->toEditContent( $msg );
+ }
+ if ( $content === false ) {
+ # If requested, preload some text.
+ $preload = $request->getVal( 'preload',
+ // Custom preload text for new sections
+ $this->section === 'new' ? 'MediaWiki:addsection-preload' : '' );
+ $params = $request->getArray( 'preloadparams', [] );
+
+ $content = $this->getPreloadedContent( $preload, $params );
+ }
+ // For existing pages, get text based on "undo" or section parameters.
+ } else {
+ if ( $this->section != '' ) {
+ // Get section edit text (returns $def_text for invalid sections)
+ $orig = $this->getOriginalContent( $user );
+ $content = $orig ? $orig->getSection( $this->section ) : null;
+
+ if ( !$content ) {
+ $content = $def_content;
+ }
+ } else {
+ $undoafter = $request->getInt( 'undoafter' );
+ $undo = $request->getInt( 'undo' );
+
+ if ( $undo > 0 && $undoafter > 0 ) {
+ $undorev = Revision::newFromId( $undo );
+ $oldrev = Revision::newFromId( $undoafter );
+
+ # Sanity check, make sure it's the right page,
+ # the revisions exist and they were not deleted.
+ # Otherwise, $content will be left as-is.
+ if ( !is_null( $undorev ) && !is_null( $oldrev ) &&
+ !$undorev->isDeleted( Revision::DELETED_TEXT ) &&
+ !$oldrev->isDeleted( Revision::DELETED_TEXT )
+ ) {
+ $content = $this->page->getUndoContent( $undorev, $oldrev );
+
+ if ( $content === false ) {
+ # Warn the user that something went wrong
+ $undoMsg = 'failure';
+ } else {
+ $oldContent = $this->page->getContent( Revision::RAW );
+ $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
+ $newContent = $content->preSaveTransform( $this->mTitle, $user, $popts );
+ if ( $newContent->getModel() !== $oldContent->getModel() ) {
+ // The undo may change content
+ // model if its reverting the top
+ // edit. This can result in
+ // mismatched content model/format.
+ $this->contentModel = $newContent->getModel();
+ $this->contentFormat = $oldrev->getContentFormat();
+ }
+
+ if ( $newContent->equals( $oldContent ) ) {
+ # Tell the user that the undo results in no change,
+ # i.e. the revisions were already undone.
+ $undoMsg = 'nochange';
+ $content = false;
+ } else {
+ # Inform the user of our success and set an automatic edit summary
+ $undoMsg = 'success';
+
+ # If we just undid one rev, use an autosummary
+ $firstrev = $oldrev->getNext();
+ if ( $firstrev && $firstrev->getId() == $undo ) {
+ $userText = $undorev->getUserText();
+ if ( $userText === '' ) {
+ $undoSummary = $this->context->msg(
+ 'undo-summary-username-hidden',
+ $undo
+ )->inContentLanguage()->text();
+ } else {
+ $undoSummary = $this->context->msg(
+ 'undo-summary',
+ $undo,
+ $userText
+ )->inContentLanguage()->text();
+ }
+ if ( $this->summary === '' ) {
+ $this->summary = $undoSummary;
+ } else {
+ $this->summary = $undoSummary . $this->context->msg( 'colon-separator' )
+ ->inContentLanguage()->text() . $this->summary;
+ }
+ $this->undidRev = $undo;
+ }
+ $this->formtype = 'diff';
+ }
+ }
+ } else {
+ // Failed basic sanity checks.
+ // Older revisions may have been removed since the link
+ // was created, or we may simply have got bogus input.
+ $undoMsg = 'norev';
+ }
+
+ $out = $this->context->getOutput();
+ // Messages: undo-success, undo-failure, undo-norev, undo-nochange
+ $class = ( $undoMsg == 'success' ? '' : 'error ' ) . "mw-undo-{$undoMsg}";
+ $this->editFormPageTop .= $out->parse( "<div class=\"{$class}\">" .
+ $this->context->msg( 'undo-' . $undoMsg )->plain() . '</div>', true, /* interface */true );
+ }
+
+ if ( $content === false ) {
+ $content = $this->getOriginalContent( $user );
+ }
+ }
+ }
+
+ return $content;
+ }
+
+ /**
+ * Get the content of the wanted revision, without section extraction.
+ *
+ * The result of this function can be used to compare user's input with
+ * section replaced in its context (using WikiPage::replaceSectionAtRev())
+ * to the original text of the edit.
+ *
+ * This differs from Article::getContent() that when a missing revision is
+ * encountered the result will be null and not the
+ * 'missing-revision' message.
+ *
+ * @since 1.19
+ * @param User $user The user to get the revision for
+ * @return Content|null
+ */
+ private function getOriginalContent( User $user ) {
+ if ( $this->section == 'new' ) {
+ return $this->getCurrentContent();
+ }
+ $revision = $this->mArticle->getRevisionFetched();
+ if ( $revision === null ) {
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+ return $handler->makeEmptyContent();
+ }
+ $content = $revision->getContent( Revision::FOR_THIS_USER, $user );
+ return $content;
+ }
+
+ /**
+ * Get the edit's parent revision ID
+ *
+ * The "parent" revision is the ancestor that should be recorded in this
+ * page's revision history. It is either the revision ID of the in-memory
+ * article content, or in the case of a 3-way merge in order to rebase
+ * across a recoverable edit conflict, the ID of the newer revision to
+ * which we have rebased this page.
+ *
+ * @since 1.27
+ * @return int Revision ID
+ */
+ public function getParentRevId() {
+ if ( $this->parentRevId ) {
+ return $this->parentRevId;
+ } else {
+ return $this->mArticle->getRevIdFetched();
+ }
+ }
+
+ /**
+ * Get the current content of the page. This is basically similar to
+ * WikiPage::getContent( Revision::RAW ) except that when the page doesn't exist an empty
+ * content object is returned instead of null.
+ *
+ * @since 1.21
+ * @return Content
+ */
+ protected function getCurrentContent() {
+ $rev = $this->page->getRevision();
+ $content = $rev ? $rev->getContent( Revision::RAW ) : null;
+
+ if ( $content === false || $content === null ) {
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+ return $handler->makeEmptyContent();
+ } elseif ( !$this->undidRev ) {
+ // Content models should always be the same since we error
+ // out if they are different before this point (in ->edit()).
+ // The exception being, during an undo, the current revision might
+ // differ from the prior revision.
+ $logger = LoggerFactory::getInstance( 'editpage' );
+ if ( $this->contentModel !== $rev->getContentModel() ) {
+ $logger->warning( "Overriding content model from current edit {prev} to {new}", [
+ 'prev' => $this->contentModel,
+ 'new' => $rev->getContentModel(),
+ 'title' => $this->getTitle()->getPrefixedDBkey(),
+ 'method' => __METHOD__
+ ] );
+ $this->contentModel = $rev->getContentModel();
+ }
+
+ // Given that the content models should match, the current selected
+ // format should be supported.
+ if ( !$content->isSupportedFormat( $this->contentFormat ) ) {
+ $logger->warning( "Current revision content format unsupported. Overriding {prev} to {new}", [
+
+ 'prev' => $this->contentFormat,
+ 'new' => $rev->getContentFormat(),
+ 'title' => $this->getTitle()->getPrefixedDBkey(),
+ 'method' => __METHOD__
+ ] );
+ $this->contentFormat = $rev->getContentFormat();
+ }
+ }
+ return $content;
+ }
+
+ /**
+ * Use this method before edit() to preload some content into the edit box
+ *
+ * @param Content $content
+ *
+ * @since 1.21
+ */
+ public function setPreloadedContent( Content $content ) {
+ $this->mPreloadContent = $content;
+ }
+
+ /**
+ * Get the contents to be preloaded into the box, either set by
+ * an earlier setPreloadText() or by loading the given page.
+ *
+ * @param string $preload Representing the title to preload from.
+ * @param array $params Parameters to use (interface-message style) in the preloaded text
+ *
+ * @return Content
+ *
+ * @since 1.21
+ */
+ protected function getPreloadedContent( $preload, $params = [] ) {
+ if ( !empty( $this->mPreloadContent ) ) {
+ return $this->mPreloadContent;
+ }
+
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+
+ if ( $preload === '' ) {
+ return $handler->makeEmptyContent();
+ }
+
+ $user = $this->context->getUser();
+ $title = Title::newFromText( $preload );
+ # Check for existence to avoid getting MediaWiki:Noarticletext
+ if ( $title === null || !$title->exists() || !$title->userCan( 'read', $user ) ) {
+ // TODO: somehow show a warning to the user!
+ return $handler->makeEmptyContent();
+ }
+
+ $page = WikiPage::factory( $title );
+ if ( $page->isRedirect() ) {
+ $title = $page->getRedirectTarget();
+ # Same as before
+ if ( $title === null || !$title->exists() || !$title->userCan( 'read', $user ) ) {
+ // TODO: somehow show a warning to the user!
+ return $handler->makeEmptyContent();
+ }
+ $page = WikiPage::factory( $title );
+ }
+
+ $parserOptions = ParserOptions::newFromUser( $user );
+ $content = $page->getContent( Revision::RAW );
+
+ if ( !$content ) {
+ // TODO: somehow show a warning to the user!
+ return $handler->makeEmptyContent();
+ }
+
+ if ( $content->getModel() !== $handler->getModelID() ) {
+ $converted = $content->convert( $handler->getModelID() );
+
+ if ( !$converted ) {
+ // TODO: somehow show a warning to the user!
+ wfDebug( "Attempt to preload incompatible content: " .
+ "can't convert " . $content->getModel() .
+ " to " . $handler->getModelID() );
+
+ return $handler->makeEmptyContent();
+ }
+
+ $content = $converted;
+ }
+
+ return $content->preloadTransform( $title, $parserOptions, $params );
+ }
+
+ /**
+ * Make sure the form isn't faking a user's credentials.
+ *
+ * @param WebRequest &$request
+ * @return bool
+ * @private
+ */
+ public function tokenOk( &$request ) {
+ $token = $request->getVal( 'wpEditToken' );
+ $user = $this->context->getUser();
+ $this->mTokenOk = $user->matchEditToken( $token );
+ $this->mTokenOkExceptSuffix = $user->matchEditTokenNoSuffix( $token );
+ return $this->mTokenOk;
+ }
+
+ /**
+ * Sets post-edit cookie indicating the user just saved a particular revision.
+ *
+ * This uses a temporary cookie for each revision ID so separate saves will never
+ * interfere with each other.
+ *
+ * Article::view deletes the cookie on server-side after the redirect and
+ * converts the value to the global JavaScript variable wgPostEdit.
+ *
+ * If the variable were set on the server, it would be cached, which is unwanted
+ * since the post-edit state should only apply to the load right after the save.
+ *
+ * @param int $statusValue The status value (to check for new article status)
+ */
+ protected function setPostEditCookie( $statusValue ) {
+ $revisionId = $this->page->getLatest();
+ $postEditKey = self::POST_EDIT_COOKIE_KEY_PREFIX . $revisionId;
+
+ $val = 'saved';
+ if ( $statusValue == self::AS_SUCCESS_NEW_ARTICLE ) {
+ $val = 'created';
+ } elseif ( $this->oldid ) {
+ $val = 'restored';
+ }
+
+ $response = $this->context->getRequest()->response();
+ $response->setCookie( $postEditKey, $val, time() + self::POST_EDIT_COOKIE_DURATION );
+ }
+
+ /**
+ * Attempt submission
+ * @param array|bool &$resultDetails See docs for $result in internalAttemptSave
+ * @throws UserBlockedError|ReadOnlyError|ThrottledError|PermissionsError
+ * @return Status The resulting status object.
+ */
+ public function attemptSave( &$resultDetails = false ) {
+ # Allow bots to exempt some edits from bot flagging
+ $bot = $this->context->getUser()->isAllowed( 'bot' ) && $this->bot;
+ $status = $this->internalAttemptSave( $resultDetails, $bot );
+
+ Hooks::run( 'EditPage::attemptSave:after', [ $this, $status, $resultDetails ] );
+
+ return $status;
+ }
+
+ /**
+ * Log when a page was successfully saved after the edit conflict view
+ */
+ private function incrementResolvedConflicts() {
+ if ( $this->context->getRequest()->getText( 'mode' ) !== 'conflict' ) {
+ return;
+ }
+
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $stats->increment( 'edit.failures.conflict.resolved' );
+ }
+
+ /**
+ * Handle status, such as after attempt save
+ *
+ * @param Status $status
+ * @param array|bool $resultDetails
+ *
+ * @throws ErrorPageError
+ * @return bool False, if output is done, true if rest of the form should be displayed
+ */
+ private function handleStatus( Status $status, $resultDetails ) {
+ /**
+ * @todo FIXME: once the interface for internalAttemptSave() is made
+ * nicer, this should use the message in $status
+ */
+ if ( $status->value == self::AS_SUCCESS_UPDATE
+ || $status->value == self::AS_SUCCESS_NEW_ARTICLE
+ ) {
+ $this->incrementResolvedConflicts();
+
+ $this->didSave = true;
+ if ( !$resultDetails['nullEdit'] ) {
+ $this->setPostEditCookie( $status->value );
+ }
+ }
+
+ $out = $this->context->getOutput();
+
+ // "wpExtraQueryRedirect" is a hidden input to modify
+ // after save URL and is not used by actual edit form
+ $request = $this->context->getRequest();
+ $extraQueryRedirect = $request->getVal( 'wpExtraQueryRedirect' );
+
+ switch ( $status->value ) {
+ case self::AS_HOOK_ERROR_EXPECTED:
+ case self::AS_CONTENT_TOO_BIG:
+ case self::AS_ARTICLE_WAS_DELETED:
+ case self::AS_CONFLICT_DETECTED:
+ case self::AS_SUMMARY_NEEDED:
+ case self::AS_TEXTBOX_EMPTY:
+ case self::AS_MAX_ARTICLE_SIZE_EXCEEDED:
+ case self::AS_END:
+ case self::AS_BLANK_ARTICLE:
+ case self::AS_SELF_REDIRECT:
+ return true;
+
+ case self::AS_HOOK_ERROR:
+ return false;
+
+ case self::AS_CANNOT_USE_CUSTOM_MODEL:
+ case self::AS_PARSE_ERROR:
+ case self::AS_UNICODE_NOT_SUPPORTED:
+ $out->addWikiText( '<div class="error">' . "\n" . $status->getWikiText() . '</div>' );
+ return true;
+
+ case self::AS_SUCCESS_NEW_ARTICLE:
+ $query = $resultDetails['redirect'] ? 'redirect=no' : '';
+ if ( $extraQueryRedirect ) {
+ if ( $query === '' ) {
+ $query = $extraQueryRedirect;
+ } else {
+ $query = $query . '&' . $extraQueryRedirect;
+ }
+ }
+ $anchor = isset( $resultDetails['sectionanchor'] ) ? $resultDetails['sectionanchor'] : '';
+ $out->redirect( $this->mTitle->getFullURL( $query ) . $anchor );
+ return false;
+
+ case self::AS_SUCCESS_UPDATE:
+ $extraQuery = '';
+ $sectionanchor = $resultDetails['sectionanchor'];
+
+ // Give extensions a chance to modify URL query on update
+ Hooks::run(
+ 'ArticleUpdateBeforeRedirect',
+ [ $this->mArticle, &$sectionanchor, &$extraQuery ]
+ );
+
+ if ( $resultDetails['redirect'] ) {
+ if ( $extraQuery == '' ) {
+ $extraQuery = 'redirect=no';
+ } else {
+ $extraQuery = 'redirect=no&' . $extraQuery;
+ }
+ }
+ if ( $extraQueryRedirect ) {
+ if ( $extraQuery === '' ) {
+ $extraQuery = $extraQueryRedirect;
+ } else {
+ $extraQuery = $extraQuery . '&' . $extraQueryRedirect;
+ }
+ }
+
+ $out->redirect( $this->mTitle->getFullURL( $extraQuery ) . $sectionanchor );
+ return false;
+
+ case self::AS_SPAM_ERROR:
+ $this->spamPageWithContent( $resultDetails['spam'] );
+ return false;
+
+ case self::AS_BLOCKED_PAGE_FOR_USER:
+ throw new UserBlockedError( $this->context->getUser()->getBlock() );
+
+ case self::AS_IMAGE_REDIRECT_ANON:
+ case self::AS_IMAGE_REDIRECT_LOGGED:
+ throw new PermissionsError( 'upload' );
+
+ case self::AS_READ_ONLY_PAGE_ANON:
+ case self::AS_READ_ONLY_PAGE_LOGGED:
+ throw new PermissionsError( 'edit' );
+
+ case self::AS_READ_ONLY_PAGE:
+ throw new ReadOnlyError;
+
+ case self::AS_RATE_LIMITED:
+ throw new ThrottledError();
+
+ case self::AS_NO_CREATE_PERMISSION:
+ $permission = $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage';
+ throw new PermissionsError( $permission );
+
+ case self::AS_NO_CHANGE_CONTENT_MODEL:
+ throw new PermissionsError( 'editcontentmodel' );
+
+ default:
+ // We don't recognize $status->value. The only way that can happen
+ // is if an extension hook aborted from inside ArticleSave.
+ // Render the status object into $this->hookError
+ // FIXME this sucks, we should just use the Status object throughout
+ $this->hookError = '<div class="error">' ."\n" . $status->getWikiText() .
+ '</div>';
+ return true;
+ }
+ }
+
+ /**
+ * Run hooks that can filter edits just before they get saved.
+ *
+ * @param Content $content The Content to filter.
+ * @param Status $status For reporting the outcome to the caller
+ * @param User $user The user performing the edit
+ *
+ * @return bool
+ */
+ protected function runPostMergeFilters( Content $content, Status $status, User $user ) {
+ // Run old style post-section-merge edit filter
+ if ( $this->hookError != '' ) {
+ # ...or the hook could be expecting us to produce an error
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR_EXPECTED;
+ return false;
+ }
+
+ // Run new style post-section-merge edit filter
+ if ( !Hooks::run( 'EditFilterMergedContent',
+ [ $this->context, $content, $status, $this->summary,
+ $user, $this->minoredit ] )
+ ) {
+ # Error messages etc. could be handled within the hook...
+ if ( $status->isGood() ) {
+ $status->fatal( 'hookaborted' );
+ // Not setting $this->hookError here is a hack to allow the hook
+ // to cause a return to the edit page without $this->hookError
+ // being set. This is used by ConfirmEdit to display a captcha
+ // without any error message cruft.
+ } else {
+ $this->hookError = $status->getWikiText();
+ }
+ // Use the existing $status->value if the hook set it
+ if ( !$status->value ) {
+ $status->value = self::AS_HOOK_ERROR;
+ }
+ return false;
+ } elseif ( !$status->isOK() ) {
+ # ...or the hook could be expecting us to produce an error
+ // FIXME this sucks, we should just use the Status object throughout
+ $this->hookError = $status->getWikiText();
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR_EXPECTED;
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Return the summary to be used for a new section.
+ *
+ * @param string $sectionanchor Set to the section anchor text
+ * @return string
+ */
+ private function newSectionSummary( &$sectionanchor = null ) {
+ global $wgParser;
+
+ if ( $this->sectiontitle !== '' ) {
+ $sectionanchor = $this->guessSectionName( $this->sectiontitle );
+ // If no edit summary was specified, create one automatically from the section
+ // title and have it link to the new section. Otherwise, respect the summary as
+ // passed.
+ if ( $this->summary === '' ) {
+ $cleanSectionTitle = $wgParser->stripSectionName( $this->sectiontitle );
+ return $this->context->msg( 'newsectionsummary' )
+ ->rawParams( $cleanSectionTitle )->inContentLanguage()->text();
+ }
+ } elseif ( $this->summary !== '' ) {
+ $sectionanchor = $this->guessSectionName( $this->summary );
+ # This is a new section, so create a link to the new section
+ # in the revision summary.
+ $cleanSummary = $wgParser->stripSectionName( $this->summary );
+ return $this->context->msg( 'newsectionsummary' )
+ ->rawParams( $cleanSummary )->inContentLanguage()->text();
+ }
+ return $this->summary;
+ }
+
+ /**
+ * Attempt submission (no UI)
+ *
+ * @param array &$result Array to add statuses to, currently with the
+ * possible keys:
+ * - spam (string): Spam string from content if any spam is detected by
+ * matchSpamRegex.
+ * - sectionanchor (string): Section anchor for a section save.
+ * - nullEdit (bool): Set if doEditContent is OK. True if null edit,
+ * false otherwise.
+ * - redirect (bool): Set if doEditContent is OK. True if resulting
+ * revision is a redirect.
+ * @param bool $bot True if edit is being made under the bot right.
+ *
+ * @return Status Status object, possibly with a message, but always with
+ * one of the AS_* constants in $status->value,
+ *
+ * @todo FIXME: This interface is TERRIBLE, but hard to get rid of due to
+ * various error display idiosyncrasies. There are also lots of cases
+ * where error metadata is set in the object and retrieved later instead
+ * of being returned, e.g. AS_CONTENT_TOO_BIG and
+ * AS_BLOCKED_PAGE_FOR_USER. All that stuff needs to be cleaned up some
+ * time.
+ */
+ public function internalAttemptSave( &$result, $bot = false ) {
+ $status = Status::newGood();
+ $user = $this->context->getUser();
+
+ if ( !Hooks::run( 'EditPage::attemptSave', [ $this ] ) ) {
+ wfDebug( "Hook 'EditPage::attemptSave' aborted article saving\n" );
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR;
+ return $status;
+ }
+
+ if ( $this->unicodeCheck !== self::UNICODE_CHECK ) {
+ $status->fatal( 'unicode-support-fail' );
+ $status->value = self::AS_UNICODE_NOT_SUPPORTED;
+ return $status;
+ }
+
+ $request = $this->context->getRequest();
+ $spam = $request->getText( 'wpAntispam' );
+ if ( $spam !== '' ) {
+ wfDebugLog(
+ 'SimpleAntiSpam',
+ $user->getName() .
+ ' editing "' .
+ $this->mTitle->getPrefixedText() .
+ '" submitted bogus field "' .
+ $spam .
+ '"'
+ );
+ $status->fatal( 'spamprotectionmatch', false );
+ $status->value = self::AS_SPAM_ERROR;
+ return $status;
+ }
+
+ try {
+ # Construct Content object
+ $textbox_content = $this->toEditContent( $this->textbox1 );
+ } catch ( MWContentSerializationException $ex ) {
+ $status->fatal(
+ 'content-failed-to-parse',
+ $this->contentModel,
+ $this->contentFormat,
+ $ex->getMessage()
+ );
+ $status->value = self::AS_PARSE_ERROR;
+ return $status;
+ }
+
+ # Check image redirect
+ if ( $this->mTitle->getNamespace() == NS_FILE &&
+ $textbox_content->isRedirect() &&
+ !$user->isAllowed( 'upload' )
+ ) {
+ $code = $user->isAnon() ? self::AS_IMAGE_REDIRECT_ANON : self::AS_IMAGE_REDIRECT_LOGGED;
+ $status->setResult( false, $code );
+
+ return $status;
+ }
+
+ # Check for spam
+ $match = self::matchSummarySpamRegex( $this->summary );
+ if ( $match === false && $this->section == 'new' ) {
+ # $wgSpamRegex is enforced on this new heading/summary because, unlike
+ # regular summaries, it is added to the actual wikitext.
+ if ( $this->sectiontitle !== '' ) {
+ # This branch is taken when the API is used with the 'sectiontitle' parameter.
+ $match = self::matchSpamRegex( $this->sectiontitle );
+ } else {
+ # This branch is taken when the "Add Topic" user interface is used, or the API
+ # is used with the 'summary' parameter.
+ $match = self::matchSpamRegex( $this->summary );
+ }
+ }
+ if ( $match === false ) {
+ $match = self::matchSpamRegex( $this->textbox1 );
+ }
+ if ( $match !== false ) {
+ $result['spam'] = $match;
+ $ip = $request->getIP();
+ $pdbk = $this->mTitle->getPrefixedDBkey();
+ $match = str_replace( "\n", '', $match );
+ wfDebugLog( 'SpamRegex', "$ip spam regex hit [[$pdbk]]: \"$match\"" );
+ $status->fatal( 'spamprotectionmatch', $match );
+ $status->value = self::AS_SPAM_ERROR;
+ return $status;
+ }
+ if ( !Hooks::run(
+ 'EditFilter',
+ [ $this, $this->textbox1, $this->section, &$this->hookError, $this->summary ] )
+ ) {
+ # Error messages etc. could be handled within the hook...
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR;
+ return $status;
+ } elseif ( $this->hookError != '' ) {
+ # ...or the hook could be expecting us to produce an error
+ $status->fatal( 'hookaborted' );
+ $status->value = self::AS_HOOK_ERROR_EXPECTED;
+ return $status;
+ }
+
+ if ( $user->isBlockedFrom( $this->mTitle, false ) ) {
+ // Auto-block user's IP if the account was "hard" blocked
+ if ( !wfReadOnly() ) {
+ $user->spreadAnyEditBlock();
+ }
+ # Check block state against master, thus 'false'.
+ $status->setResult( false, self::AS_BLOCKED_PAGE_FOR_USER );
+ return $status;
+ }
+
+ $this->contentLength = strlen( $this->textbox1 );
+ $config = $this->context->getConfig();
+ $maxArticleSize = $config->get( 'MaxArticleSize' );
+ if ( $this->contentLength > $maxArticleSize * 1024 ) {
+ // Error will be displayed by showEditForm()
+ $this->tooBig = true;
+ $status->setResult( false, self::AS_CONTENT_TOO_BIG );
+ return $status;
+ }
+
+ if ( !$user->isAllowed( 'edit' ) ) {
+ if ( $user->isAnon() ) {
+ $status->setResult( false, self::AS_READ_ONLY_PAGE_ANON );
+ return $status;
+ } else {
+ $status->fatal( 'readonlytext' );
+ $status->value = self::AS_READ_ONLY_PAGE_LOGGED;
+ return $status;
+ }
+ }
+
+ $changingContentModel = false;
+ if ( $this->contentModel !== $this->mTitle->getContentModel() ) {
+ if ( !$config->get( 'ContentHandlerUseDB' ) ) {
+ $status->fatal( 'editpage-cannot-use-custom-model' );
+ $status->value = self::AS_CANNOT_USE_CUSTOM_MODEL;
+ return $status;
+ } elseif ( !$user->isAllowed( 'editcontentmodel' ) ) {
+ $status->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL );
+ return $status;
+ }
+ // Make sure the user can edit the page under the new content model too
+ $titleWithNewContentModel = clone $this->mTitle;
+ $titleWithNewContentModel->setContentModel( $this->contentModel );
+ if ( !$titleWithNewContentModel->userCan( 'editcontentmodel', $user )
+ || !$titleWithNewContentModel->userCan( 'edit', $user )
+ ) {
+ $status->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL );
+ return $status;
+ }
+
+ $changingContentModel = true;
+ $oldContentModel = $this->mTitle->getContentModel();
+ }
+
+ if ( $this->changeTags ) {
+ $changeTagsStatus = ChangeTags::canAddTagsAccompanyingChange(
+ $this->changeTags, $user );
+ if ( !$changeTagsStatus->isOK() ) {
+ $changeTagsStatus->value = self::AS_CHANGE_TAG_ERROR;
+ return $changeTagsStatus;
+ }
+ }
+
+ if ( wfReadOnly() ) {
+ $status->fatal( 'readonlytext' );
+ $status->value = self::AS_READ_ONLY_PAGE;
+ return $status;
+ }
+ if ( $user->pingLimiter() || $user->pingLimiter( 'linkpurge', 0 )
+ || ( $changingContentModel && $user->pingLimiter( 'editcontentmodel' ) )
+ ) {
+ $status->fatal( 'actionthrottledtext' );
+ $status->value = self::AS_RATE_LIMITED;
+ return $status;
+ }
+
+ # If the article has been deleted while editing, don't save it without
+ # confirmation
+ if ( $this->wasDeletedSinceLastEdit() && !$this->recreate ) {
+ $status->setResult( false, self::AS_ARTICLE_WAS_DELETED );
+ return $status;
+ }
+
+ # Load the page data from the master. If anything changes in the meantime,
+ # we detect it by using page_latest like a token in a 1 try compare-and-swap.
+ $this->page->loadPageData( 'fromdbmaster' );
+ $new = !$this->page->exists();
+
+ if ( $new ) {
+ // Late check for create permission, just in case *PARANOIA*
+ if ( !$this->mTitle->userCan( 'create', $user ) ) {
+ $status->fatal( 'nocreatetext' );
+ $status->value = self::AS_NO_CREATE_PERMISSION;
+ wfDebug( __METHOD__ . ": no create permission\n" );
+ return $status;
+ }
+
+ // Don't save a new page if it's blank or if it's a MediaWiki:
+ // message with content equivalent to default (allow empty pages
+ // in this case to disable messages, see T52124)
+ $defaultMessageText = $this->mTitle->getDefaultMessageText();
+ if ( $this->mTitle->getNamespace() === NS_MEDIAWIKI && $defaultMessageText !== false ) {
+ $defaultText = $defaultMessageText;
+ } else {
+ $defaultText = '';
+ }
+
+ if ( !$this->allowBlankArticle && $this->textbox1 === $defaultText ) {
+ $this->blankArticle = true;
+ $status->fatal( 'blankarticle' );
+ $status->setResult( false, self::AS_BLANK_ARTICLE );
+ return $status;
+ }
+
+ if ( !$this->runPostMergeFilters( $textbox_content, $status, $user ) ) {
+ return $status;
+ }
+
+ $content = $textbox_content;
+
+ $result['sectionanchor'] = '';
+ if ( $this->section == 'new' ) {
+ if ( $this->sectiontitle !== '' ) {
+ // Insert the section title above the content.
+ $content = $content->addSectionHeader( $this->sectiontitle );
+ } elseif ( $this->summary !== '' ) {
+ // Insert the section title above the content.
+ $content = $content->addSectionHeader( $this->summary );
+ }
+ $this->summary = $this->newSectionSummary( $result['sectionanchor'] );
+ }
+
+ $status->value = self::AS_SUCCESS_NEW_ARTICLE;
+
+ } else { # not $new
+
+ # Article exists. Check for edit conflict.
+
+ $this->page->clear(); # Force reload of dates, etc.
+ $timestamp = $this->page->getTimestamp();
+ $latest = $this->page->getLatest();
+
+ wfDebug( "timestamp: {$timestamp}, edittime: {$this->edittime}\n" );
+
+ // Check editRevId if set, which handles same-second timestamp collisions
+ if ( $timestamp != $this->edittime
+ || ( $this->editRevId !== null && $this->editRevId != $latest )
+ ) {
+ $this->isConflict = true;
+ if ( $this->section == 'new' ) {
+ if ( $this->page->getUserText() == $user->getName() &&
+ $this->page->getComment() == $this->newSectionSummary()
+ ) {
+ // Probably a duplicate submission of a new comment.
+ // This can happen when CDN resends a request after
+ // a timeout but the first one actually went through.
+ wfDebug( __METHOD__
+ . ": duplicate new section submission; trigger edit conflict!\n" );
+ } else {
+ // New comment; suppress conflict.
+ $this->isConflict = false;
+ wfDebug( __METHOD__ . ": conflict suppressed; new section\n" );
+ }
+ } elseif ( $this->section == ''
+ && Revision::userWasLastToEdit(
+ DB_MASTER, $this->mTitle->getArticleID(),
+ $user->getId(), $this->edittime
+ )
+ ) {
+ # Suppress edit conflict with self, except for section edits where merging is required.
+ wfDebug( __METHOD__ . ": Suppressing edit conflict, same user.\n" );
+ $this->isConflict = false;
+ }
+ }
+
+ // If sectiontitle is set, use it, otherwise use the summary as the section title.
+ if ( $this->sectiontitle !== '' ) {
+ $sectionTitle = $this->sectiontitle;
+ } else {
+ $sectionTitle = $this->summary;
+ }
+
+ $content = null;
+
+ if ( $this->isConflict ) {
+ wfDebug( __METHOD__
+ . ": conflict! getting section '{$this->section}' for time '{$this->edittime}'"
+ . " (id '{$this->editRevId}') (article time '{$timestamp}')\n" );
+ // @TODO: replaceSectionAtRev() with base ID (not prior current) for ?oldid=X case
+ // ...or disable section editing for non-current revisions (not exposed anyway).
+ if ( $this->editRevId !== null ) {
+ $content = $this->page->replaceSectionAtRev(
+ $this->section,
+ $textbox_content,
+ $sectionTitle,
+ $this->editRevId
+ );
+ } else {
+ $content = $this->page->replaceSectionContent(
+ $this->section,
+ $textbox_content,
+ $sectionTitle,
+ $this->edittime
+ );
+ }
+ } else {
+ wfDebug( __METHOD__ . ": getting section '{$this->section}'\n" );
+ $content = $this->page->replaceSectionContent(
+ $this->section,
+ $textbox_content,
+ $sectionTitle
+ );
+ }
+
+ if ( is_null( $content ) ) {
+ wfDebug( __METHOD__ . ": activating conflict; section replace failed.\n" );
+ $this->isConflict = true;
+ $content = $textbox_content; // do not try to merge here!
+ } elseif ( $this->isConflict ) {
+ # Attempt merge
+ if ( $this->mergeChangesIntoContent( $content ) ) {
+ // Successful merge! Maybe we should tell the user the good news?
+ $this->isConflict = false;
+ wfDebug( __METHOD__ . ": Suppressing edit conflict, successful merge.\n" );
+ } else {
+ $this->section = '';
+ $this->textbox1 = ContentHandler::getContentText( $content );
+ wfDebug( __METHOD__ . ": Keeping edit conflict, failed merge.\n" );
+ }
+ }
+
+ if ( $this->isConflict ) {
+ $status->setResult( false, self::AS_CONFLICT_DETECTED );
+ return $status;
+ }
+
+ if ( !$this->runPostMergeFilters( $content, $status, $user ) ) {
+ return $status;
+ }
+
+ if ( $this->section == 'new' ) {
+ // Handle the user preference to force summaries here
+ if ( !$this->allowBlankSummary && trim( $this->summary ) == '' ) {
+ $this->missingSummary = true;
+ $status->fatal( 'missingsummary' ); // or 'missingcommentheader' if $section == 'new'. Blegh
+ $status->value = self::AS_SUMMARY_NEEDED;
+ return $status;
+ }
+
+ // Do not allow the user to post an empty comment
+ if ( $this->textbox1 == '' ) {
+ $this->missingComment = true;
+ $status->fatal( 'missingcommenttext' );
+ $status->value = self::AS_TEXTBOX_EMPTY;
+ return $status;
+ }
+ } elseif ( !$this->allowBlankSummary
+ && !$content->equals( $this->getOriginalContent( $user ) )
+ && !$content->isRedirect()
+ && md5( $this->summary ) == $this->autoSumm
+ ) {
+ $this->missingSummary = true;
+ $status->fatal( 'missingsummary' );
+ $status->value = self::AS_SUMMARY_NEEDED;
+ return $status;
+ }
+
+ # All's well
+ $sectionanchor = '';
+ if ( $this->section == 'new' ) {
+ $this->summary = $this->newSectionSummary( $sectionanchor );
+ } elseif ( $this->section != '' ) {
+ # Try to get a section anchor from the section source, redirect
+ # to edited section if header found.
+ # XXX: Might be better to integrate this into Article::replaceSectionAtRev
+ # for duplicate heading checking and maybe parsing.
+ $hasmatch = preg_match( "/^ *([=]{1,6})(.*?)(\\1) *\\n/i", $this->textbox1, $matches );
+ # We can't deal with anchors, includes, html etc in the header for now,
+ # headline would need to be parsed to improve this.
+ if ( $hasmatch && strlen( $matches[2] ) > 0 ) {
+ $sectionanchor = $this->guessSectionName( $matches[2] );
+ }
+ }
+ $result['sectionanchor'] = $sectionanchor;
+
+ // Save errors may fall down to the edit form, but we've now
+ // merged the section into full text. Clear the section field
+ // so that later submission of conflict forms won't try to
+ // replace that into a duplicated mess.
+ $this->textbox1 = $this->toEditText( $content );
+ $this->section = '';
+
+ $status->value = self::AS_SUCCESS_UPDATE;
+ }
+
+ if ( !$this->allowSelfRedirect
+ && $content->isRedirect()
+ && $content->getRedirectTarget()->equals( $this->getTitle() )
+ ) {
+ // If the page already redirects to itself, don't warn.
+ $currentTarget = $this->getCurrentContent()->getRedirectTarget();
+ if ( !$currentTarget || !$currentTarget->equals( $this->getTitle() ) ) {
+ $this->selfRedirect = true;
+ $status->fatal( 'selfredirect' );
+ $status->value = self::AS_SELF_REDIRECT;
+ return $status;
+ }
+ }
+
+ // Check for length errors again now that the section is merged in
+ $this->contentLength = strlen( $this->toEditText( $content ) );
+ if ( $this->contentLength > $maxArticleSize * 1024 ) {
+ $this->tooBig = true;
+ $status->setResult( false, self::AS_MAX_ARTICLE_SIZE_EXCEEDED );
+ return $status;
+ }
+
+ $flags = EDIT_AUTOSUMMARY |
+ ( $new ? EDIT_NEW : EDIT_UPDATE ) |
+ ( ( $this->minoredit && !$this->isNew ) ? EDIT_MINOR : 0 ) |
+ ( $bot ? EDIT_FORCE_BOT : 0 );
+
+ $doEditStatus = $this->page->doEditContent(
+ $content,
+ $this->summary,
+ $flags,
+ false,
+ $user,
+ $content->getDefaultFormat(),
+ $this->changeTags,
+ $this->undidRev
+ );
+
+ if ( !$doEditStatus->isOK() ) {
+ // Failure from doEdit()
+ // Show the edit conflict page for certain recognized errors from doEdit(),
+ // but don't show it for errors from extension hooks
+ $errors = $doEditStatus->getErrorsArray();
+ if ( in_array( $errors[0][0],
+ [ 'edit-gone-missing', 'edit-conflict', 'edit-already-exists' ] )
+ ) {
+ $this->isConflict = true;
+ // Destroys data doEdit() put in $status->value but who cares
+ $doEditStatus->value = self::AS_END;
+ }
+ return $doEditStatus;
+ }
+
+ $result['nullEdit'] = $doEditStatus->hasMessage( 'edit-no-change' );
+ if ( $result['nullEdit'] ) {
+ // We don't know if it was a null edit until now, so increment here
+ $user->pingLimiter( 'linkpurge' );
+ }
+ $result['redirect'] = $content->isRedirect();
+
+ $this->updateWatchlist();
+
+ // If the content model changed, add a log entry
+ if ( $changingContentModel ) {
+ $this->addContentModelChangeLogEntry(
+ $user,
+ $new ? false : $oldContentModel,
+ $this->contentModel,
+ $this->summary
+ );
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param User $user
+ * @param string|false $oldModel false if the page is being newly created
+ * @param string $newModel
+ * @param string $reason
+ */
+ protected function addContentModelChangeLogEntry( User $user, $oldModel, $newModel, $reason ) {
+ $new = $oldModel === false;
+ $log = new ManualLogEntry( 'contentmodel', $new ? 'new' : 'change' );
+ $log->setPerformer( $user );
+ $log->setTarget( $this->mTitle );
+ $log->setComment( $reason );
+ $log->setParameters( [
+ '4::oldmodel' => $oldModel,
+ '5::newmodel' => $newModel
+ ] );
+ $logid = $log->insert();
+ $log->publish( $logid );
+ }
+
+ /**
+ * Register the change of watch status
+ */
+ protected function updateWatchlist() {
+ $user = $this->context->getUser();
+ if ( !$user->isLoggedIn() ) {
+ return;
+ }
+
+ $title = $this->mTitle;
+ $watch = $this->watchthis;
+ // Do this in its own transaction to reduce contention...
+ DeferredUpdates::addCallableUpdate( function () use ( $user, $title, $watch ) {
+ if ( $watch == $user->isWatched( $title, User::IGNORE_USER_RIGHTS ) ) {
+ return; // nothing to change
+ }
+ WatchAction::doWatchOrUnwatch( $watch, $title, $user );
+ } );
+ }
+
+ /**
+ * Attempts to do 3-way merge of edit content with a base revision
+ * and current content, in case of edit conflict, in whichever way appropriate
+ * for the content type.
+ *
+ * @since 1.21
+ *
+ * @param Content $editContent
+ *
+ * @return bool
+ */
+ private function mergeChangesIntoContent( &$editContent ) {
+ $db = wfGetDB( DB_MASTER );
+
+ // This is the revision the editor started from
+ $baseRevision = $this->getBaseRevision();
+ $baseContent = $baseRevision ? $baseRevision->getContent() : null;
+
+ if ( is_null( $baseContent ) ) {
+ return false;
+ }
+
+ // The current state, we want to merge updates into it
+ $currentRevision = Revision::loadFromTitle( $db, $this->mTitle );
+ $currentContent = $currentRevision ? $currentRevision->getContent() : null;
+
+ if ( is_null( $currentContent ) ) {
+ return false;
+ }
+
+ $handler = ContentHandler::getForModelID( $baseContent->getModel() );
+
+ $result = $handler->merge3( $baseContent, $editContent, $currentContent );
+
+ if ( $result ) {
+ $editContent = $result;
+ // Update parentRevId to what we just merged.
+ $this->parentRevId = $currentRevision->getId();
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @note: this method is very poorly named. If the user opened the form with ?oldid=X,
+ * one might think of X as the "base revision", which is NOT what this returns.
+ * @return Revision Current version when the edit was started
+ */
+ public function getBaseRevision() {
+ if ( !$this->mBaseRevision ) {
+ $db = wfGetDB( DB_MASTER );
+ $this->mBaseRevision = $this->editRevId
+ ? Revision::newFromId( $this->editRevId, Revision::READ_LATEST )
+ : Revision::loadFromTimestamp( $db, $this->mTitle, $this->edittime );
+ }
+ return $this->mBaseRevision;
+ }
+
+ /**
+ * Check given input text against $wgSpamRegex, and return the text of the first match.
+ *
+ * @param string $text
+ *
+ * @return string|bool Matching string or false
+ */
+ public static function matchSpamRegex( $text ) {
+ global $wgSpamRegex;
+ // For back compatibility, $wgSpamRegex may be a single string or an array of regexes.
+ $regexes = (array)$wgSpamRegex;
+ return self::matchSpamRegexInternal( $text, $regexes );
+ }
+
+ /**
+ * Check given input text against $wgSummarySpamRegex, and return the text of the first match.
+ *
+ * @param string $text
+ *
+ * @return string|bool Matching string or false
+ */
+ public static function matchSummarySpamRegex( $text ) {
+ global $wgSummarySpamRegex;
+ $regexes = (array)$wgSummarySpamRegex;
+ return self::matchSpamRegexInternal( $text, $regexes );
+ }
+
+ /**
+ * @param string $text
+ * @param array $regexes
+ * @return bool|string
+ */
+ protected static function matchSpamRegexInternal( $text, $regexes ) {
+ foreach ( $regexes as $regex ) {
+ $matches = [];
+ if ( preg_match( $regex, $text, $matches ) ) {
+ return $matches[0];
+ }
+ }
+ return false;
+ }
+
+ public function setHeaders() {
+ $out = $this->context->getOutput();
+
+ $out->addModules( 'mediawiki.action.edit' );
+ $out->addModuleStyles( 'mediawiki.action.edit.styles' );
+
+ $user = $this->context->getUser();
+ if ( $user->getOption( 'showtoolbar' ) ) {
+ // The addition of default buttons is handled by getEditToolbar() which
+ // has its own dependency on this module. The call here ensures the module
+ // is loaded in time (it has position "top") for other modules to register
+ // buttons (e.g. extensions, gadgets, user scripts).
+ $out->addModules( 'mediawiki.toolbar' );
+ }
+
+ if ( $user->getOption( 'uselivepreview' ) ) {
+ $out->addModules( 'mediawiki.action.edit.preview' );
+ }
+
+ if ( $user->getOption( 'useeditwarning' ) ) {
+ $out->addModules( 'mediawiki.action.edit.editWarning' );
+ }
+
+ # Enabled article-related sidebar, toplinks, etc.
+ $out->setArticleRelated( true );
+
+ $contextTitle = $this->getContextTitle();
+ if ( $this->isConflict ) {
+ $msg = 'editconflict';
+ } elseif ( $contextTitle->exists() && $this->section != '' ) {
+ $msg = $this->section == 'new' ? 'editingcomment' : 'editingsection';
+ } else {
+ $msg = $contextTitle->exists()
+ || ( $contextTitle->getNamespace() == NS_MEDIAWIKI
+ && $contextTitle->getDefaultMessageText() !== false
+ )
+ ? 'editing'
+ : 'creating';
+ }
+
+ # Use the title defined by DISPLAYTITLE magic word when present
+ # NOTE: getDisplayTitle() returns HTML while getPrefixedText() returns plain text.
+ # setPageTitle() treats the input as wikitext, which should be safe in either case.
+ $displayTitle = isset( $this->mParserOutput ) ? $this->mParserOutput->getDisplayTitle() : false;
+ if ( $displayTitle === false ) {
+ $displayTitle = $contextTitle->getPrefixedText();
+ }
+ $out->setPageTitle( $this->context->msg( $msg, $displayTitle ) );
+ # Transmit the name of the message to JavaScript for live preview
+ # Keep Resources.php/mediawiki.action.edit.preview in sync with the possible keys
+ $out->addJsConfigVars( [
+ 'wgEditMessage' => $msg,
+ 'wgAjaxEditStash' => $this->context->getConfig()->get( 'AjaxEditStash' ),
+ ] );
+ }
+
+ /**
+ * Show all applicable editing introductions
+ */
+ protected function showIntro() {
+ if ( $this->suppressIntro ) {
+ return;
+ }
+
+ $out = $this->context->getOutput();
+ $namespace = $this->mTitle->getNamespace();
+
+ if ( $namespace == NS_MEDIAWIKI ) {
+ # Show a warning if editing an interface message
+ $out->wrapWikiMsg( "<div class='mw-editinginterface'>\n$1\n</div>", 'editinginterface' );
+ # If this is a default message (but not css or js),
+ # show a hint that it is translatable on translatewiki.net
+ if ( !$this->mTitle->hasContentModel( CONTENT_MODEL_CSS )
+ && !$this->mTitle->hasContentModel( CONTENT_MODEL_JAVASCRIPT )
+ ) {
+ $defaultMessageText = $this->mTitle->getDefaultMessageText();
+ if ( $defaultMessageText !== false ) {
+ $out->wrapWikiMsg( "<div class='mw-translateinterface'>\n$1\n</div>",
+ 'translateinterface' );
+ }
+ }
+ } elseif ( $namespace == NS_FILE ) {
+ # Show a hint to shared repo
+ $file = wfFindFile( $this->mTitle );
+ if ( $file && !$file->isLocal() ) {
+ $descUrl = $file->getDescriptionUrl();
+ # there must be a description url to show a hint to shared repo
+ if ( $descUrl ) {
+ if ( !$this->mTitle->exists() ) {
+ $out->wrapWikiMsg( "<div class=\"mw-sharedupload-desc-create\">\n$1\n</div>", [
+ 'sharedupload-desc-create', $file->getRepo()->getDisplayName(), $descUrl
+ ] );
+ } else {
+ $out->wrapWikiMsg( "<div class=\"mw-sharedupload-desc-edit\">\n$1\n</div>", [
+ 'sharedupload-desc-edit', $file->getRepo()->getDisplayName(), $descUrl
+ ] );
+ }
+ }
+ }
+ }
+
+ # Show a warning message when someone creates/edits a user (talk) page but the user does not exist
+ # Show log extract when the user is currently blocked
+ if ( $namespace == NS_USER || $namespace == NS_USER_TALK ) {
+ $username = explode( '/', $this->mTitle->getText(), 2 )[0];
+ $user = User::newFromName( $username, false /* allow IP users */ );
+ $ip = User::isIP( $username );
+ $block = Block::newFromTarget( $user, $user );
+ if ( !( $user && $user->isLoggedIn() ) && !$ip ) { # User does not exist
+ $out->wrapWikiMsg( "<div class=\"mw-userpage-userdoesnotexist error\">\n$1\n</div>",
+ [ 'userpage-userdoesnotexist', wfEscapeWikiText( $username ) ] );
+ } elseif ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) {
+ # Show log extract if the user is currently blocked
+ LogEventsList::showLogExtract(
+ $out,
+ 'block',
+ MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget(),
+ '',
+ [
+ 'lim' => 1,
+ 'showIfEmpty' => false,
+ 'msgKey' => [
+ 'blocked-notice-logextract',
+ $user->getName() # Support GENDER in notice
+ ]
+ ]
+ );
+ }
+ }
+ # Try to add a custom edit intro, or use the standard one if this is not possible.
+ if ( !$this->showCustomIntro() && !$this->mTitle->exists() ) {
+ $helpLink = wfExpandUrl( Skin::makeInternalOrExternalUrl(
+ $this->context->msg( 'helppage' )->inContentLanguage()->text()
+ ) );
+ if ( $this->context->getUser()->isLoggedIn() ) {
+ $out->wrapWikiMsg(
+ // Suppress the external link icon, consider the help url an internal one
+ "<div class=\"mw-newarticletext plainlinks\">\n$1\n</div>",
+ [
+ 'newarticletext',
+ $helpLink
+ ]
+ );
+ } else {
+ $out->wrapWikiMsg(
+ // Suppress the external link icon, consider the help url an internal one
+ "<div class=\"mw-newarticletextanon plainlinks\">\n$1\n</div>",
+ [
+ 'newarticletextanon',
+ $helpLink
+ ]
+ );
+ }
+ }
+ # Give a notice if the user is editing a deleted/moved page...
+ if ( !$this->mTitle->exists() ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ LogEventsList::showLogExtract( $out, [ 'delete', 'move' ], $this->mTitle,
+ '',
+ [
+ 'lim' => 10,
+ 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ],
+ 'showIfEmpty' => false,
+ 'msgKey' => [ 'recreate-moveddeleted-warn' ]
+ ]
+ );
+ }
+ }
+
+ /**
+ * Attempt to show a custom editing introduction, if supplied
+ *
+ * @return bool
+ */
+ protected function showCustomIntro() {
+ if ( $this->editintro ) {
+ $title = Title::newFromText( $this->editintro );
+ if ( $title instanceof Title && $title->exists() && $title->userCan( 'read' ) ) {
+ // Added using template syntax, to take <noinclude>'s into account.
+ $this->context->getOutput()->addWikiTextTitleTidy(
+ '<div class="mw-editintro">{{:' . $title->getFullText() . '}}</div>',
+ $this->mTitle
+ );
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Gets an editable textual representation of $content.
+ * The textual representation can be turned by into a Content object by the
+ * toEditContent() method.
+ *
+ * If $content is null or false or a string, $content is returned unchanged.
+ *
+ * If the given Content object is not of a type that can be edited using
+ * the text base EditPage, an exception will be raised. Set
+ * $this->allowNonTextContent to true to allow editing of non-textual
+ * content.
+ *
+ * @param Content|null|bool|string $content
+ * @return string The editable text form of the content.
+ *
+ * @throws MWException If $content is not an instance of TextContent and
+ * $this->allowNonTextContent is not true.
+ */
+ protected function toEditText( $content ) {
+ if ( $content === null || $content === false || is_string( $content ) ) {
+ return $content;
+ }
+
+ if ( !$this->isSupportedContentModel( $content->getModel() ) ) {
+ throw new MWException( 'This content model is not supported: ' . $content->getModel() );
+ }
+
+ return $content->serialize( $this->contentFormat );
+ }
+
+ /**
+ * Turns the given text into a Content object by unserializing it.
+ *
+ * If the resulting Content object is not of a type that can be edited using
+ * the text base EditPage, an exception will be raised. Set
+ * $this->allowNonTextContent to true to allow editing of non-textual
+ * content.
+ *
+ * @param string|null|bool $text Text to unserialize
+ * @return Content|bool|null The content object created from $text. If $text was false
+ * or null, false resp. null will be returned instead.
+ *
+ * @throws MWException If unserializing the text results in a Content
+ * object that is not an instance of TextContent and
+ * $this->allowNonTextContent is not true.
+ */
+ protected function toEditContent( $text ) {
+ if ( $text === false || $text === null ) {
+ return $text;
+ }
+
+ $content = ContentHandler::makeContent( $text, $this->getTitle(),
+ $this->contentModel, $this->contentFormat );
+
+ if ( !$this->isSupportedContentModel( $content->getModel() ) ) {
+ throw new MWException( 'This content model is not supported: ' . $content->getModel() );
+ }
+
+ return $content;
+ }
+
+ /**
+ * Send the edit form and related headers to OutputPage
+ * @param callable|null $formCallback That takes an OutputPage parameter; will be called
+ * during form output near the top, for captchas and the like.
+ *
+ * The $formCallback parameter is deprecated since MediaWiki 1.25. Please
+ * use the EditPage::showEditForm:fields hook instead.
+ */
+ public function showEditForm( $formCallback = null ) {
+ # need to parse the preview early so that we know which templates are used,
+ # otherwise users with "show preview after edit box" will get a blank list
+ # we parse this near the beginning so that setHeaders can do the title
+ # setting work instead of leaving it in getPreviewText
+ $previewOutput = '';
+ if ( $this->formtype == 'preview' ) {
+ $previewOutput = $this->getPreviewText();
+ }
+
+ $out = $this->context->getOutput();
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ Hooks::run( 'EditPage::showEditForm:initial', [ &$editPage, &$out ] );
+
+ $this->setHeaders();
+
+ $this->addTalkPageText();
+ $this->addEditNotices();
+
+ if ( !$this->isConflict &&
+ $this->section != '' &&
+ !$this->isSectionEditSupported() ) {
+ // We use $this->section to much before this and getVal('wgSection') directly in other places
+ // at this point we can't reset $this->section to '' to fallback to non-section editing.
+ // Someone is welcome to try refactoring though
+ $out->showErrorPage( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
+ return;
+ }
+
+ $this->showHeader();
+
+ $out->addHTML( $this->editFormPageTop );
+
+ $user = $this->context->getUser();
+ if ( $user->getOption( 'previewontop' ) ) {
+ $this->displayPreviewArea( $previewOutput, true );
+ }
+
+ $out->addHTML( $this->editFormTextTop );
+
+ $showToolbar = true;
+ if ( $this->wasDeletedSinceLastEdit() ) {
+ if ( $this->formtype == 'save' ) {
+ // Hide the toolbar and edit area, user can click preview to get it back
+ // Add an confirmation checkbox and explanation.
+ $showToolbar = false;
+ } else {
+ $out->wrapWikiMsg( "<div class='error mw-deleted-while-editing'>\n$1\n</div>",
+ 'deletedwhileediting' );
+ }
+ }
+
+ // @todo add EditForm plugin interface and use it here!
+ // search for textarea1 and textarea2, and allow EditForm to override all uses.
+ $out->addHTML( Html::openElement(
+ 'form',
+ [
+ 'class' => 'mw-editform',
+ 'id' => self::EDITFORM_ID,
+ 'name' => self::EDITFORM_ID,
+ 'method' => 'post',
+ 'action' => $this->getActionURL( $this->getContextTitle() ),
+ 'enctype' => 'multipart/form-data'
+ ]
+ ) );
+
+ if ( is_callable( $formCallback ) ) {
+ wfWarn( 'The $formCallback parameter to ' . __METHOD__ . 'is deprecated' );
+ call_user_func_array( $formCallback, [ &$out ] );
+ }
+
+ // Add a check for Unicode support
+ $out->addHTML( Html::hidden( 'wpUnicodeCheck', self::UNICODE_CHECK ) );
+
+ // Add an empty field to trip up spambots
+ $out->addHTML(
+ Xml::openElement( 'div', [ 'id' => 'antispam-container', 'style' => 'display: none;' ] )
+ . Html::rawElement(
+ 'label',
+ [ 'for' => 'wpAntispam' ],
+ $this->context->msg( 'simpleantispam-label' )->parse()
+ )
+ . Xml::element(
+ 'input',
+ [
+ 'type' => 'text',
+ 'name' => 'wpAntispam',
+ 'id' => 'wpAntispam',
+ 'value' => ''
+ ]
+ )
+ . Xml::closeElement( 'div' )
+ );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ Hooks::run( 'EditPage::showEditForm:fields', [ &$editPage, &$out ] );
+
+ // Put these up at the top to ensure they aren't lost on early form submission
+ $this->showFormBeforeText();
+
+ if ( $this->wasDeletedSinceLastEdit() && 'save' == $this->formtype ) {
+ $username = $this->lastDelete->user_name;
+ $comment = CommentStore::newKey( 'log_comment' )->getComment( $this->lastDelete )->text;
+
+ // It is better to not parse the comment at all than to have templates expanded in the middle
+ // TODO: can the checkLabel be moved outside of the div so that wrapWikiMsg could be used?
+ $key = $comment === ''
+ ? 'confirmrecreate-noreason'
+ : 'confirmrecreate';
+ $out->addHTML(
+ '<div class="mw-confirm-recreate">' .
+ $this->context->msg( $key, $username, "<nowiki>$comment</nowiki>" )->parse() .
+ Xml::checkLabel( $this->context->msg( 'recreate' )->text(), 'wpRecreate', 'wpRecreate', false,
+ [ 'title' => Linker::titleAttrib( 'recreate' ), 'tabindex' => 1, 'id' => 'wpRecreate' ]
+ ) .
+ '</div>'
+ );
+ }
+
+ # When the summary is hidden, also hide them on preview/show changes
+ if ( $this->nosummary ) {
+ $out->addHTML( Html::hidden( 'nosummary', true ) );
+ }
+
+ # If a blank edit summary was previously provided, and the appropriate
+ # user preference is active, pass a hidden tag as wpIgnoreBlankSummary. This will stop the
+ # user being bounced back more than once in the event that a summary
+ # is not required.
+ # ####
+ # For a bit more sophisticated detection of blank summaries, hash the
+ # automatic one and pass that in the hidden field wpAutoSummary.
+ if ( $this->missingSummary || ( $this->section == 'new' && $this->nosummary ) ) {
+ $out->addHTML( Html::hidden( 'wpIgnoreBlankSummary', true ) );
+ }
+
+ if ( $this->undidRev ) {
+ $out->addHTML( Html::hidden( 'wpUndidRevision', $this->undidRev ) );
+ }
+
+ if ( $this->selfRedirect ) {
+ $out->addHTML( Html::hidden( 'wpIgnoreSelfRedirect', true ) );
+ }
+
+ if ( $this->hasPresetSummary ) {
+ // If a summary has been preset using &summary= we don't want to prompt for
+ // a different summary. Only prompt for a summary if the summary is blanked.
+ // (T19416)
+ $this->autoSumm = md5( '' );
+ }
+
+ $autosumm = $this->autoSumm ? $this->autoSumm : md5( $this->summary );
+ $out->addHTML( Html::hidden( 'wpAutoSummary', $autosumm ) );
+
+ $out->addHTML( Html::hidden( 'oldid', $this->oldid ) );
+ $out->addHTML( Html::hidden( 'parentRevId', $this->getParentRevId() ) );
+
+ $out->addHTML( Html::hidden( 'format', $this->contentFormat ) );
+ $out->addHTML( Html::hidden( 'model', $this->contentModel ) );
+
+ $out->enableOOUI();
+
+ if ( $this->section == 'new' ) {
+ $this->showSummaryInput( true, $this->summary );
+ $out->addHTML( $this->getSummaryPreview( true, $this->summary ) );
+ }
+
+ $out->addHTML( $this->editFormTextBeforeContent );
+
+ if ( !$this->mTitle->isCssJsSubpage() && $showToolbar && $user->getOption( 'showtoolbar' ) ) {
+ $out->addHTML( self::getEditToolbar( $this->mTitle ) );
+ }
+
+ if ( $this->blankArticle ) {
+ $out->addHTML( Html::hidden( 'wpIgnoreBlankArticle', true ) );
+ }
+
+ if ( $this->isConflict ) {
+ // In an edit conflict bypass the overridable content form method
+ // and fallback to the raw wpTextbox1 since editconflicts can't be
+ // resolved between page source edits and custom ui edits using the
+ // custom edit ui.
+ $this->textbox2 = $this->textbox1;
+
+ $content = $this->getCurrentContent();
+ $this->textbox1 = $this->toEditText( $content );
+
+ $this->showTextbox1();
+ } else {
+ $this->showContentForm();
+ }
+
+ $out->addHTML( $this->editFormTextAfterContent );
+
+ $this->showStandardInputs();
+
+ $this->showFormAfterText();
+
+ $this->showTosSummary();
+
+ $this->showEditTools();
+
+ $out->addHTML( $this->editFormTextAfterTools . "\n" );
+
+ $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) );
+
+ $out->addHTML( Html::rawElement( 'div', [ 'class' => 'hiddencats' ],
+ Linker::formatHiddenCategories( $this->page->getHiddenCategories() ) ) );
+
+ $out->addHTML( Html::rawElement( 'div', [ 'class' => 'limitreport' ],
+ self::getPreviewLimitReport( $this->mParserOutput ) ) );
+
+ $out->addModules( 'mediawiki.action.edit.collapsibleFooter' );
+
+ if ( $this->isConflict ) {
+ try {
+ $this->showConflict();
+ } catch ( MWContentSerializationException $ex ) {
+ // this can't really happen, but be nice if it does.
+ $msg = $this->context->msg(
+ 'content-failed-to-parse',
+ $this->contentModel,
+ $this->contentFormat,
+ $ex->getMessage()
+ );
+ $out->addWikiText( '<div class="error">' . $msg->text() . '</div>' );
+ }
+ }
+
+ // Set a hidden field so JS knows what edit form mode we are in
+ if ( $this->isConflict ) {
+ $mode = 'conflict';
+ } elseif ( $this->preview ) {
+ $mode = 'preview';
+ } elseif ( $this->diff ) {
+ $mode = 'diff';
+ } else {
+ $mode = 'text';
+ }
+ $out->addHTML( Html::hidden( 'mode', $mode, [ 'id' => 'mw-edit-mode' ] ) );
+
+ // Marker for detecting truncated form data. This must be the last
+ // parameter sent in order to be of use, so do not move me.
+ $out->addHTML( Html::hidden( 'wpUltimateParam', true ) );
+ $out->addHTML( $this->editFormTextBottom . "\n</form>\n" );
+
+ if ( !$user->getOption( 'previewontop' ) ) {
+ $this->displayPreviewArea( $previewOutput, false );
+ }
+ }
+
+ /**
+ * Wrapper around TemplatesOnThisPageFormatter to make
+ * a "templates on this page" list.
+ *
+ * @param Title[] $templates
+ * @return string HTML
+ */
+ public function makeTemplatesOnThisPageList( array $templates ) {
+ $templateListFormatter = new TemplatesOnThisPageFormatter(
+ $this->context, MediaWikiServices::getInstance()->getLinkRenderer()
+ );
+
+ // preview if preview, else section if section, else false
+ $type = false;
+ if ( $this->preview ) {
+ $type = 'preview';
+ } elseif ( $this->section != '' ) {
+ $type = 'section';
+ }
+
+ return Html::rawElement( 'div', [ 'class' => 'templatesUsed' ],
+ $templateListFormatter->format( $templates, $type )
+ );
+ }
+
+ /**
+ * Extract the section title from current section text, if any.
+ *
+ * @param string $text
+ * @return string|bool String or false
+ */
+ public static function extractSectionTitle( $text ) {
+ preg_match( "/^(=+)(.+)\\1\\s*(\n|$)/i", $text, $matches );
+ if ( !empty( $matches[2] ) ) {
+ global $wgParser;
+ return $wgParser->stripSectionName( trim( $matches[2] ) );
+ } else {
+ return false;
+ }
+ }
+
+ protected function showHeader() {
+ $out = $this->context->getOutput();
+ $user = $this->context->getUser();
+ if ( $this->isConflict ) {
+ $this->addExplainConflictHeader( $out );
+ $this->editRevId = $this->page->getLatest();
+ } else {
+ if ( $this->section != '' && $this->section != 'new' ) {
+ if ( !$this->summary && !$this->preview && !$this->diff ) {
+ $sectionTitle = self::extractSectionTitle( $this->textbox1 ); // FIXME: use Content object
+ if ( $sectionTitle !== false ) {
+ $this->summary = "/* $sectionTitle */ ";
+ }
+ }
+ }
+
+ $buttonLabel = $this->context->msg( $this->getSubmitButtonLabel() )->text();
+
+ if ( $this->missingComment ) {
+ $out->wrapWikiMsg( "<div id='mw-missingcommenttext'>\n$1\n</div>", 'missingcommenttext' );
+ }
+
+ if ( $this->missingSummary && $this->section != 'new' ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-missingsummary'>\n$1\n</div>",
+ [ 'missingsummary', $buttonLabel ]
+ );
+ }
+
+ if ( $this->missingSummary && $this->section == 'new' ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-missingcommentheader'>\n$1\n</div>",
+ [ 'missingcommentheader', $buttonLabel ]
+ );
+ }
+
+ if ( $this->blankArticle ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-blankarticle'>\n$1\n</div>",
+ [ 'blankarticle', $buttonLabel ]
+ );
+ }
+
+ if ( $this->selfRedirect ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-selfredirect'>\n$1\n</div>",
+ [ 'selfredirect', $buttonLabel ]
+ );
+ }
+
+ if ( $this->hookError !== '' ) {
+ $out->addWikiText( $this->hookError );
+ }
+
+ if ( $this->section != 'new' ) {
+ $revision = $this->mArticle->getRevisionFetched();
+ if ( $revision ) {
+ // Let sysop know that this will make private content public if saved
+
+ if ( !$revision->userCan( Revision::DELETED_TEXT, $user ) ) {
+ $out->wrapWikiMsg(
+ "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ 'rev-deleted-text-permission'
+ );
+ } elseif ( $revision->isDeleted( Revision::DELETED_TEXT ) ) {
+ $out->wrapWikiMsg(
+ "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ 'rev-deleted-text-view'
+ );
+ }
+
+ if ( !$revision->isCurrent() ) {
+ $this->mArticle->setOldSubtitle( $revision->getId() );
+ $out->addWikiMsg( 'editingold' );
+ $this->isOldRev = true;
+ }
+ } elseif ( $this->mTitle->exists() ) {
+ // Something went wrong
+
+ $out->wrapWikiMsg( "<div class='errorbox'>\n$1\n</div>\n",
+ [ 'missing-revision', $this->oldid ] );
+ }
+ }
+ }
+
+ if ( wfReadOnly() ) {
+ $out->wrapWikiMsg(
+ "<div id=\"mw-read-only-warning\">\n$1\n</div>",
+ [ 'readonlywarning', wfReadOnlyReason() ]
+ );
+ } elseif ( $user->isAnon() ) {
+ if ( $this->formtype != 'preview' ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-anon-edit-warning' class='warningbox'>\n$1\n</div>",
+ [ 'anoneditwarning',
+ // Log-in link
+ SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
+ 'returnto' => $this->getTitle()->getPrefixedDBkey()
+ ] ),
+ // Sign-up link
+ SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
+ 'returnto' => $this->getTitle()->getPrefixedDBkey()
+ ] )
+ ]
+ );
+ } else {
+ $out->wrapWikiMsg( "<div id=\"mw-anon-preview-warning\" class=\"warningbox\">\n$1</div>",
+ 'anonpreviewwarning'
+ );
+ }
+ } else {
+ if ( $this->mTitle->isCssJsSubpage() ) {
+ # Check the skin exists
+ if ( $this->isWrongCaseCssJsPage() ) {
+ $out->wrapWikiMsg(
+ "<div class='error' id='mw-userinvalidcssjstitle'>\n$1\n</div>",
+ [ 'userinvalidcssjstitle', $this->mTitle->getSkinFromCssJsSubpage() ]
+ );
+ }
+ if ( $this->getTitle()->isSubpageOf( $user->getUserPage() ) ) {
+ $isCssSubpage = $this->mTitle->isCssSubpage();
+ $out->wrapWikiMsg( '<div class="mw-usercssjspublic">$1</div>',
+ $isCssSubpage ? 'usercssispublic' : 'userjsispublic'
+ );
+ if ( $this->formtype !== 'preview' ) {
+ $config = $this->context->getConfig();
+ if ( $isCssSubpage && $config->get( 'AllowUserCss' ) ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-usercssyoucanpreview'>\n$1\n</div>",
+ [ 'usercssyoucanpreview' ]
+ );
+ }
+
+ if ( $this->mTitle->isJsSubpage() && $config->get( 'AllowUserJs' ) ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-userjsyoucanpreview'>\n$1\n</div>",
+ [ 'userjsyoucanpreview' ]
+ );
+ }
+ }
+ }
+ }
+ }
+
+ $this->addPageProtectionWarningHeaders();
+
+ $this->addLongPageWarningHeader();
+
+ # Add header copyright warning
+ $this->showHeaderCopyrightWarning();
+ }
+
+ /**
+ * Helper function for summary input functions, which returns the neccessary
+ * attributes for the input.
+ *
+ * @param array|null $inputAttrs Array of attrs to use on the input
+ * @return array
+ */
+ private function getSummaryInputAttributes( array $inputAttrs = null ) {
+ // Note: the maxlength is overridden in JS to 255 and to make it use UTF-8 bytes, not characters.
+ return ( is_array( $inputAttrs ) ? $inputAttrs : [] ) + [
+ 'id' => 'wpSummary',
+ 'name' => 'wpSummary',
+ 'maxlength' => '200',
+ 'tabindex' => 1,
+ 'size' => 60,
+ 'spellcheck' => 'true',
+ ];
+ }
+
+ /**
+ * Standard summary input and label (wgSummary), abstracted so EditPage
+ * subclasses may reorganize the form.
+ * Note that you do not need to worry about the label's for=, it will be
+ * inferred by the id given to the input. You can remove them both by
+ * passing [ 'id' => false ] to $userInputAttrs.
+ *
+ * @deprecated since 1.30 Use getSummaryInputWidget() instead
+ * @param string $summary The value of the summary input
+ * @param string $labelText The html to place inside the label
+ * @param array $inputAttrs Array of attrs to use on the input
+ * @param array $spanLabelAttrs Array of attrs to use on the span inside the label
+ * @return array An array in the format [ $label, $input ]
+ */
+ public function getSummaryInput( $summary = "", $labelText = null,
+ $inputAttrs = null, $spanLabelAttrs = null
+ ) {
+ wfDeprecated( __METHOD__, '1.30' );
+ $inputAttrs = $this->getSummaryInputAttributes( $inputAttrs );
+ $inputAttrs += Linker::tooltipAndAccesskeyAttribs( 'summary' );
+
+ $spanLabelAttrs = ( is_array( $spanLabelAttrs ) ? $spanLabelAttrs : [] ) + [
+ 'class' => $this->missingSummary ? 'mw-summarymissed' : 'mw-summary',
+ 'id' => "wpSummaryLabel"
+ ];
+
+ $label = null;
+ if ( $labelText ) {
+ $label = Xml::tags(
+ 'label',
+ $inputAttrs['id'] ? [ 'for' => $inputAttrs['id'] ] : null,
+ $labelText
+ );
+ $label = Xml::tags( 'span', $spanLabelAttrs, $label );
+ }
+
+ $input = Html::input( 'wpSummary', $summary, 'text', $inputAttrs );
+
+ return [ $label, $input ];
+ }
+
+ /**
+ * Builds a standard summary input with a label.
+ *
+ * @deprecated since 1.30 Use getSummaryInputWidget() instead
+ * @param string $summary The value of the summary input
+ * @param string $labelText The html to place inside the label
+ * @param array $inputAttrs Array of attrs to use on the input
+ *
+ * @return OOUI\FieldLayout OOUI FieldLayout with Label and Input
+ */
+ function getSummaryInputOOUI( $summary = "", $labelText = null, $inputAttrs = null ) {
+ wfDeprecated( __METHOD__, '1.30' );
+ $this->getSummaryInputWidget( $summary, $labelText, $inputAttrs );
+ }
+
+ /**
+ * Builds a standard summary input with a label.
+ *
+ * @param string $summary The value of the summary input
+ * @param string $labelText The html to place inside the label
+ * @param array $inputAttrs Array of attrs to use on the input
+ *
+ * @return OOUI\FieldLayout OOUI FieldLayout with Label and Input
+ */
+ function getSummaryInputWidget( $summary = "", $labelText = null, $inputAttrs = null ) {
+ $inputAttrs = OOUI\Element::configFromHtmlAttributes(
+ $this->getSummaryInputAttributes( $inputAttrs )
+ );
+ $inputAttrs += [
+ 'title' => Linker::titleAttrib( 'summary' ),
+ 'accessKey' => Linker::accesskey( 'summary' ),
+ ];
+
+ // For compatibility with old scripts and extensions, we want the legacy 'id' on the `<input>`
+ $inputAttrs['inputId'] = $inputAttrs['id'];
+ $inputAttrs['id'] = 'wpSummaryWidget';
+
+ return new OOUI\FieldLayout(
+ new OOUI\TextInputWidget( [
+ 'value' => $summary,
+ 'infusable' => true,
+ ] + $inputAttrs ),
+ [
+ 'label' => new OOUI\HtmlSnippet( $labelText ),
+ 'align' => 'top',
+ 'id' => 'wpSummaryLabel',
+ 'classes' => [ $this->missingSummary ? 'mw-summarymissed' : 'mw-summary' ],
+ ]
+ );
+ }
+
+ /**
+ * @param bool $isSubjectPreview True if this is the section subject/title
+ * up top, or false if this is the comment summary
+ * down below the textarea
+ * @param string $summary The text of the summary to display
+ */
+ protected function showSummaryInput( $isSubjectPreview, $summary = "" ) {
+ # Add a class if 'missingsummary' is triggered to allow styling of the summary line
+ $summaryClass = $this->missingSummary ? 'mw-summarymissed' : 'mw-summary';
+ if ( $isSubjectPreview ) {
+ if ( $this->nosummary ) {
+ return;
+ }
+ } else {
+ if ( !$this->mShowSummaryField ) {
+ return;
+ }
+ }
+
+ $labelText = $this->context->msg( $isSubjectPreview ? 'subject' : 'summary' )->parse();
+ $this->context->getOutput()->addHTML( $this->getSummaryInputWidget(
+ $summary,
+ $labelText,
+ [ 'class' => $summaryClass ]
+ ) );
+ }
+
+ /**
+ * @param bool $isSubjectPreview True if this is the section subject/title
+ * up top, or false if this is the comment summary
+ * down below the textarea
+ * @param string $summary The text of the summary to display
+ * @return string
+ */
+ protected function getSummaryPreview( $isSubjectPreview, $summary = "" ) {
+ // avoid spaces in preview, gets always trimmed on save
+ $summary = trim( $summary );
+ if ( !$summary || ( !$this->preview && !$this->diff ) ) {
+ return "";
+ }
+
+ global $wgParser;
+
+ if ( $isSubjectPreview ) {
+ $summary = $this->context->msg( 'newsectionsummary' )
+ ->rawParams( $wgParser->stripSectionName( $summary ) )
+ ->inContentLanguage()->text();
+ }
+
+ $message = $isSubjectPreview ? 'subject-preview' : 'summary-preview';
+
+ $summary = $this->context->msg( $message )->parse()
+ . Linker::commentBlock( $summary, $this->mTitle, $isSubjectPreview );
+ return Xml::tags( 'div', [ 'class' => 'mw-summary-preview' ], $summary );
+ }
+
+ protected function showFormBeforeText() {
+ $out = $this->context->getOutput();
+ $out->addHTML( Html::hidden( 'wpSection', $this->section ) );
+ $out->addHTML( Html::hidden( 'wpStarttime', $this->starttime ) );
+ $out->addHTML( Html::hidden( 'wpEdittime', $this->edittime ) );
+ $out->addHTML( Html::hidden( 'editRevId', $this->editRevId ) );
+ $out->addHTML( Html::hidden( 'wpScrolltop', $this->scrolltop, [ 'id' => 'wpScrolltop' ] ) );
+ }
+
+ protected function showFormAfterText() {
+ /**
+ * To make it harder for someone to slip a user a page
+ * which submits an edit form to the wiki without their
+ * knowledge, a random token is associated with the login
+ * session. If it's not passed back with the submission,
+ * we won't save the page, or render user JavaScript and
+ * CSS previews.
+ *
+ * For anon editors, who may not have a session, we just
+ * include the constant suffix to prevent editing from
+ * broken text-mangling proxies.
+ */
+ $this->context->getOutput()->addHTML(
+ "\n" .
+ Html::hidden( "wpEditToken", $this->context->getUser()->getEditToken() ) .
+ "\n"
+ );
+ }
+
+ /**
+ * Subpage overridable method for printing the form for page content editing
+ * By default this simply outputs wpTextbox1
+ * Subclasses can override this to provide a custom UI for editing;
+ * be it a form, or simply wpTextbox1 with a modified content that will be
+ * reverse modified when extracted from the post data.
+ * Note that this is basically the inverse for importContentFormData
+ */
+ protected function showContentForm() {
+ $this->showTextbox1();
+ }
+
+ /**
+ * Method to output wpTextbox1
+ * The $textoverride method can be used by subclasses overriding showContentForm
+ * to pass back to this method.
+ *
+ * @param array $customAttribs Array of html attributes to use in the textarea
+ * @param string $textoverride Optional text to override $this->textarea1 with
+ */
+ protected function showTextbox1( $customAttribs = null, $textoverride = null ) {
+ if ( $this->wasDeletedSinceLastEdit() && $this->formtype == 'save' ) {
+ $attribs = [ 'style' => 'display:none;' ];
+ } else {
+ $classes = []; // Textarea CSS
+ if ( $this->mTitle->isProtected( 'edit' ) &&
+ MWNamespace::getRestrictionLevels( $this->mTitle->getNamespace() ) !== [ '' ]
+ ) {
+ # Is the title semi-protected?
+ if ( $this->mTitle->isSemiProtected() ) {
+ $classes[] = 'mw-textarea-sprotected';
+ } else {
+ # Then it must be protected based on static groups (regular)
+ $classes[] = 'mw-textarea-protected';
+ }
+ # Is the title cascade-protected?
+ if ( $this->mTitle->isCascadeProtected() ) {
+ $classes[] = 'mw-textarea-cprotected';
+ }
+ }
+ # Is an old revision being edited?
+ if ( $this->isOldRev ) {
+ $classes[] = 'mw-textarea-oldrev';
+ }
+
+ $attribs = [ 'tabindex' => 1 ];
+
+ if ( is_array( $customAttribs ) ) {
+ $attribs += $customAttribs;
+ }
+
+ if ( count( $classes ) ) {
+ if ( isset( $attribs['class'] ) ) {
+ $classes[] = $attribs['class'];
+ }
+ $attribs['class'] = implode( ' ', $classes );
+ }
+ }
+
+ $this->showTextbox(
+ $textoverride !== null ? $textoverride : $this->textbox1,
+ 'wpTextbox1',
+ $attribs
+ );
+ }
+
+ protected function showTextbox2() {
+ $this->showTextbox( $this->textbox2, 'wpTextbox2', [ 'tabindex' => 6, 'readonly' ] );
+ }
+
+ protected function showTextbox( $text, $name, $customAttribs = [] ) {
+ $wikitext = $this->addNewLineAtEnd( $text );
+
+ $attribs = $this->buildTextboxAttribs( $name, $customAttribs, $this->context->getUser() );
+
+ $this->context->getOutput()->addHTML( Html::textarea( $name, $wikitext, $attribs ) );
+ }
+
+ protected function displayPreviewArea( $previewOutput, $isOnTop = false ) {
+ $classes = [];
+ if ( $isOnTop ) {
+ $classes[] = 'ontop';
+ }
+
+ $attribs = [ 'id' => 'wikiPreview', 'class' => implode( ' ', $classes ) ];
+
+ if ( $this->formtype != 'preview' ) {
+ $attribs['style'] = 'display: none;';
+ }
+
+ $out = $this->context->getOutput();
+ $out->addHTML( Xml::openElement( 'div', $attribs ) );
+
+ if ( $this->formtype == 'preview' ) {
+ $this->showPreview( $previewOutput );
+ } else {
+ // Empty content container for LivePreview
+ $pageViewLang = $this->mTitle->getPageViewLanguage();
+ $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(),
+ 'class' => 'mw-content-' . $pageViewLang->getDir() ];
+ $out->addHTML( Html::rawElement( 'div', $attribs ) );
+ }
+
+ $out->addHTML( '</div>' );
+
+ if ( $this->formtype == 'diff' ) {
+ try {
+ $this->showDiff();
+ } catch ( MWContentSerializationException $ex ) {
+ $msg = $this->context->msg(
+ 'content-failed-to-parse',
+ $this->contentModel,
+ $this->contentFormat,
+ $ex->getMessage()
+ );
+ $out->addWikiText( '<div class="error">' . $msg->text() . '</div>' );
+ }
+ }
+ }
+
+ /**
+ * Append preview output to OutputPage.
+ * Includes category rendering if this is a category page.
+ *
+ * @param string $text The HTML to be output for the preview.
+ */
+ protected function showPreview( $text ) {
+ if ( $this->mArticle instanceof CategoryPage ) {
+ $this->mArticle->openShowCategory();
+ }
+ # This hook seems slightly odd here, but makes things more
+ # consistent for extensions.
+ $out = $this->context->getOutput();
+ Hooks::run( 'OutputPageBeforeHTML', [ &$out, &$text ] );
+ $out->addHTML( $text );
+ if ( $this->mArticle instanceof CategoryPage ) {
+ $this->mArticle->closeShowCategory();
+ }
+ }
+
+ /**
+ * Get a diff between the current contents of the edit box and the
+ * version of the page we're editing from.
+ *
+ * If this is a section edit, we'll replace the section as for final
+ * save and then make a comparison.
+ */
+ public function showDiff() {
+ global $wgContLang;
+
+ $oldtitlemsg = 'currentrev';
+ # if message does not exist, show diff against the preloaded default
+ if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI && !$this->mTitle->exists() ) {
+ $oldtext = $this->mTitle->getDefaultMessageText();
+ if ( $oldtext !== false ) {
+ $oldtitlemsg = 'defaultmessagetext';
+ $oldContent = $this->toEditContent( $oldtext );
+ } else {
+ $oldContent = null;
+ }
+ } else {
+ $oldContent = $this->getCurrentContent();
+ }
+
+ $textboxContent = $this->toEditContent( $this->textbox1 );
+ if ( $this->editRevId !== null ) {
+ $newContent = $this->page->replaceSectionAtRev(
+ $this->section, $textboxContent, $this->summary, $this->editRevId
+ );
+ } else {
+ $newContent = $this->page->replaceSectionContent(
+ $this->section, $textboxContent, $this->summary, $this->edittime
+ );
+ }
+
+ if ( $newContent ) {
+ Hooks::run( 'EditPageGetDiffContent', [ $this, &$newContent ] );
+
+ $user = $this->context->getUser();
+ $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
+ $newContent = $newContent->preSaveTransform( $this->mTitle, $user, $popts );
+ }
+
+ if ( ( $oldContent && !$oldContent->isEmpty() ) || ( $newContent && !$newContent->isEmpty() ) ) {
+ $oldtitle = $this->context->msg( $oldtitlemsg )->parse();
+ $newtitle = $this->context->msg( 'yourtext' )->parse();
+
+ if ( !$oldContent ) {
+ $oldContent = $newContent->getContentHandler()->makeEmptyContent();
+ }
+
+ if ( !$newContent ) {
+ $newContent = $oldContent->getContentHandler()->makeEmptyContent();
+ }
+
+ $de = $oldContent->getContentHandler()->createDifferenceEngine( $this->context );
+ $de->setContent( $oldContent, $newContent );
+
+ $difftext = $de->getDiff( $oldtitle, $newtitle );
+ $de->showDiffStyle();
+ } else {
+ $difftext = '';
+ }
+
+ $this->context->getOutput()->addHTML( '<div id="wikiDiff">' . $difftext . '</div>' );
+ }
+
+ /**
+ * Show the header copyright warning.
+ */
+ protected function showHeaderCopyrightWarning() {
+ $msg = 'editpage-head-copy-warn';
+ if ( !$this->context->msg( $msg )->isDisabled() ) {
+ $this->context->getOutput()->wrapWikiMsg( "<div class='editpage-head-copywarn'>\n$1\n</div>",
+ 'editpage-head-copy-warn' );
+ }
+ }
+
+ /**
+ * Give a chance for site and per-namespace customizations of
+ * terms of service summary link that might exist separately
+ * from the copyright notice.
+ *
+ * This will display between the save button and the edit tools,
+ * so should remain short!
+ */
+ protected function showTosSummary() {
+ $msg = 'editpage-tos-summary';
+ Hooks::run( 'EditPageTosSummary', [ $this->mTitle, &$msg ] );
+ if ( !$this->context->msg( $msg )->isDisabled() ) {
+ $out = $this->context->getOutput();
+ $out->addHTML( '<div class="mw-tos-summary">' );
+ $out->addWikiMsg( $msg );
+ $out->addHTML( '</div>' );
+ }
+ }
+
+ /**
+ * Inserts optional text shown below edit and upload forms. Can be used to offer special
+ * characters not present on most keyboards for copying/pasting.
+ */
+ protected function showEditTools() {
+ $this->context->getOutput()->addHTML( '<div class="mw-editTools">' .
+ $this->context->msg( 'edittools' )->inContentLanguage()->parse() .
+ '</div>' );
+ }
+
+ /**
+ * Get the copyright warning
+ *
+ * Renamed to getCopyrightWarning(), old name kept around for backwards compatibility
+ * @return string
+ */
+ protected function getCopywarn() {
+ return self::getCopyrightWarning( $this->mTitle );
+ }
+
+ /**
+ * Get the copyright warning, by default returns wikitext
+ *
+ * @param Title $title
+ * @param string $format Output format, valid values are any function of a Message object
+ * @param Language|string|null $langcode Language code or Language object.
+ * @return string
+ */
+ public static function getCopyrightWarning( $title, $format = 'plain', $langcode = null ) {
+ global $wgRightsText;
+ if ( $wgRightsText ) {
+ $copywarnMsg = [ 'copyrightwarning',
+ '[[' . wfMessage( 'copyrightpage' )->inContentLanguage()->text() . ']]',
+ $wgRightsText ];
+ } else {
+ $copywarnMsg = [ 'copyrightwarning2',
+ '[[' . wfMessage( 'copyrightpage' )->inContentLanguage()->text() . ']]' ];
+ }
+ // Allow for site and per-namespace customization of contribution/copyright notice.
+ Hooks::run( 'EditPageCopyrightWarning', [ $title, &$copywarnMsg ] );
+
+ $msg = call_user_func_array( 'wfMessage', $copywarnMsg )->title( $title );
+ if ( $langcode ) {
+ $msg->inLanguage( $langcode );
+ }
+ return "<div id=\"editpage-copywarn\">\n" .
+ $msg->$format() . "\n</div>";
+ }
+
+ /**
+ * Get the Limit report for page previews
+ *
+ * @since 1.22
+ * @param ParserOutput $output ParserOutput object from the parse
+ * @return string HTML
+ */
+ public static function getPreviewLimitReport( $output ) {
+ if ( !$output || !$output->getLimitReportData() ) {
+ return '';
+ }
+
+ $limitReport = Html::rawElement( 'div', [ 'class' => 'mw-limitReportExplanation' ],
+ wfMessage( 'limitreport-title' )->parseAsBlock()
+ );
+
+ // Show/hide animation doesn't work correctly on a table, so wrap it in a div.
+ $limitReport .= Html::openElement( 'div', [ 'class' => 'preview-limit-report-wrapper' ] );
+
+ $limitReport .= Html::openElement( 'table', [
+ 'class' => 'preview-limit-report wikitable'
+ ] ) .
+ Html::openElement( 'tbody' );
+
+ foreach ( $output->getLimitReportData() as $key => $value ) {
+ if ( Hooks::run( 'ParserLimitReportFormat',
+ [ $key, &$value, &$limitReport, true, true ]
+ ) ) {
+ $keyMsg = wfMessage( $key );
+ $valueMsg = wfMessage( [ "$key-value-html", "$key-value" ] );
+ if ( !$valueMsg->exists() ) {
+ $valueMsg = new RawMessage( '$1' );
+ }
+ if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) {
+ $limitReport .= Html::openElement( 'tr' ) .
+ Html::rawElement( 'th', null, $keyMsg->parse() ) .
+ Html::rawElement( 'td', null, $valueMsg->params( $value )->parse() ) .
+ Html::closeElement( 'tr' );
+ }
+ }
+ }
+
+ $limitReport .= Html::closeElement( 'tbody' ) .
+ Html::closeElement( 'table' ) .
+ Html::closeElement( 'div' );
+
+ return $limitReport;
+ }
+
+ protected function showStandardInputs( &$tabindex = 2 ) {
+ $out = $this->context->getOutput();
+ $out->addHTML( "<div class='editOptions'>\n" );
+
+ if ( $this->section != 'new' ) {
+ $this->showSummaryInput( false, $this->summary );
+ $out->addHTML( $this->getSummaryPreview( false, $this->summary ) );
+ }
+
+ $checkboxes = $this->getCheckboxesWidget(
+ $tabindex,
+ [ 'minor' => $this->minoredit, 'watch' => $this->watchthis ]
+ );
+ $checkboxesHTML = new OOUI\HorizontalLayout( [ 'items' => $checkboxes ] );
+
+ $out->addHTML( "<div class='editCheckboxes'>" . $checkboxesHTML . "</div>\n" );
+
+ // Show copyright warning.
+ $out->addWikiText( $this->getCopywarn() );
+ $out->addHTML( $this->editFormTextAfterWarn );
+
+ $out->addHTML( "<div class='editButtons'>\n" );
+ $out->addHTML( implode( $this->getEditButtons( $tabindex ), "\n" ) . "\n" );
+
+ $cancel = $this->getCancelLink();
+ if ( $cancel !== '' ) {
+ $cancel .= Html::element( 'span',
+ [ 'class' => 'mw-editButtons-pipe-separator' ],
+ $this->context->msg( 'pipe-separator' )->text() );
+ }
+
+ $message = $this->context->msg( 'edithelppage' )->inContentLanguage()->text();
+ $edithelpurl = Skin::makeInternalOrExternalUrl( $message );
+ $edithelp =
+ Html::linkButton(
+ $this->context->msg( 'edithelp' )->text(),
+ [ 'target' => 'helpwindow', 'href' => $edithelpurl ],
+ [ 'mw-ui-quiet' ]
+ ) .
+ $this->context->msg( 'word-separator' )->escaped() .
+ $this->context->msg( 'newwindow' )->parse();
+
+ $out->addHTML( " <span class='cancelLink'>{$cancel}</span>\n" );
+ $out->addHTML( " <span class='editHelp'>{$edithelp}</span>\n" );
+ $out->addHTML( "</div><!-- editButtons -->\n" );
+
+ Hooks::run( 'EditPage::showStandardInputs:options', [ $this, $out, &$tabindex ] );
+
+ $out->addHTML( "</div><!-- editOptions -->\n" );
+ }
+
+ /**
+ * Show an edit conflict. textbox1 is already shown in showEditForm().
+ * If you want to use another entry point to this function, be careful.
+ */
+ protected function showConflict() {
+ $out = $this->context->getOutput();
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ if ( Hooks::run( 'EditPageBeforeConflictDiff', [ &$editPage, &$out ] ) ) {
+ $this->incrementConflictStats();
+
+ $out->wrapWikiMsg( '<h2>$1</h2>', "yourdiff" );
+
+ $content1 = $this->toEditContent( $this->textbox1 );
+ $content2 = $this->toEditContent( $this->textbox2 );
+
+ $handler = ContentHandler::getForModelID( $this->contentModel );
+ $de = $handler->createDifferenceEngine( $this->context );
+ $de->setContent( $content2, $content1 );
+ $de->showDiff(
+ $this->context->msg( 'yourtext' )->parse(),
+ $this->context->msg( 'storedversion' )->text()
+ );
+
+ $out->wrapWikiMsg( '<h2>$1</h2>', "yourtext" );
+ $this->showTextbox2();
+ }
+ }
+
+ protected function incrementConflictStats() {
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $stats->increment( 'edit.failures.conflict' );
+ // Only include 'standard' namespaces to avoid creating unknown numbers of statsd metrics
+ if (
+ $this->mTitle->getNamespace() >= NS_MAIN &&
+ $this->mTitle->getNamespace() <= NS_CATEGORY_TALK
+ ) {
+ $stats->increment( 'edit.failures.conflict.byNamespaceId.' . $this->mTitle->getNamespace() );
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getCancelLink() {
+ $cancelParams = [];
+ if ( !$this->isConflict && $this->oldid > 0 ) {
+ $cancelParams['oldid'] = $this->oldid;
+ } elseif ( $this->getContextTitle()->isRedirect() ) {
+ $cancelParams['redirect'] = 'no';
+ }
+
+ return new OOUI\ButtonWidget( [
+ 'id' => 'mw-editform-cancel',
+ 'href' => $this->getContextTitle()->getLinkUrl( $cancelParams ),
+ 'label' => new OOUI\HtmlSnippet( $this->context->msg( 'cancel' )->parse() ),
+ 'framed' => false,
+ 'infusable' => true,
+ 'flags' => 'destructive',
+ ] );
+ }
+
+ /**
+ * Returns the URL to use in the form's action attribute.
+ * This is used by EditPage subclasses when simply customizing the action
+ * variable in the constructor is not enough. This can be used when the
+ * EditPage lives inside of a Special page rather than a custom page action.
+ *
+ * @param Title $title Title object for which is being edited (where we go to for &action= links)
+ * @return string
+ */
+ protected function getActionURL( Title $title ) {
+ return $title->getLocalURL( [ 'action' => $this->action ] );
+ }
+
+ /**
+ * Check if a page was deleted while the user was editing it, before submit.
+ * Note that we rely on the logging table, which hasn't been always there,
+ * but that doesn't matter, because this only applies to brand new
+ * deletes.
+ * @return bool
+ */
+ protected function wasDeletedSinceLastEdit() {
+ if ( $this->deletedSinceEdit !== null ) {
+ return $this->deletedSinceEdit;
+ }
+
+ $this->deletedSinceEdit = false;
+
+ if ( !$this->mTitle->exists() && $this->mTitle->isDeletedQuick() ) {
+ $this->lastDelete = $this->getLastDelete();
+ if ( $this->lastDelete ) {
+ $deleteTime = wfTimestamp( TS_MW, $this->lastDelete->log_timestamp );
+ if ( $deleteTime > $this->starttime ) {
+ $this->deletedSinceEdit = true;
+ }
+ }
+ }
+
+ return $this->deletedSinceEdit;
+ }
+
+ /**
+ * @return bool|stdClass
+ */
+ protected function getLastDelete() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $commentQuery = CommentStore::newKey( 'log_comment' )->getJoin();
+ $data = $dbr->selectRow(
+ [ 'logging', 'user' ] + $commentQuery['tables'],
+ [
+ 'log_type',
+ 'log_action',
+ 'log_timestamp',
+ 'log_user',
+ 'log_namespace',
+ 'log_title',
+ 'log_params',
+ 'log_deleted',
+ 'user_name'
+ ] + $commentQuery['fields'], [
+ 'log_namespace' => $this->mTitle->getNamespace(),
+ 'log_title' => $this->mTitle->getDBkey(),
+ 'log_type' => 'delete',
+ 'log_action' => 'delete',
+ 'user_id=log_user'
+ ],
+ __METHOD__,
+ [ 'LIMIT' => 1, 'ORDER BY' => 'log_timestamp DESC' ],
+ [
+ 'user' => [ 'JOIN', 'user_id=log_user' ],
+ ] + $commentQuery['joins']
+ );
+ // Quick paranoid permission checks...
+ if ( is_object( $data ) ) {
+ if ( $data->log_deleted & LogPage::DELETED_USER ) {
+ $data->user_name = $this->context->msg( 'rev-deleted-user' )->escaped();
+ }
+
+ if ( $data->log_deleted & LogPage::DELETED_COMMENT ) {
+ $data->log_comment_text = $this->context->msg( 'rev-deleted-comment' )->escaped();
+ $data->log_comment_data = null;
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the rendered text for previewing.
+ * @throws MWException
+ * @return string
+ */
+ public function getPreviewText() {
+ $out = $this->context->getOutput();
+ $config = $this->context->getConfig();
+
+ if ( $config->get( 'RawHtml' ) && !$this->mTokenOk ) {
+ // Could be an offsite preview attempt. This is very unsafe if
+ // HTML is enabled, as it could be an attack.
+ $parsedNote = '';
+ if ( $this->textbox1 !== '' ) {
+ // Do not put big scary notice, if previewing the empty
+ // string, which happens when you initially edit
+ // a category page, due to automatic preview-on-open.
+ $parsedNote = $out->parse( "<div class='previewnote'>" .
+ $this->context->msg( 'session_fail_preview_html' )->text() . "</div>",
+ true, /* interface */true );
+ }
+ $this->incrementEditFailureStats( 'session_loss' );
+ return $parsedNote;
+ }
+
+ $note = '';
+
+ try {
+ $content = $this->toEditContent( $this->textbox1 );
+
+ $previewHTML = '';
+ if ( !Hooks::run(
+ 'AlternateEditPreview',
+ [ $this, &$content, &$previewHTML, &$this->mParserOutput ] )
+ ) {
+ return $previewHTML;
+ }
+
+ # provide a anchor link to the editform
+ $continueEditing = '<span class="mw-continue-editing">' .
+ '[[#' . self::EDITFORM_ID . '|' .
+ $this->context->getLanguage()->getArrow() . ' ' .
+ $this->context->msg( 'continue-editing' )->text() . ']]</span>';
+ if ( $this->mTriedSave && !$this->mTokenOk ) {
+ if ( $this->mTokenOkExceptSuffix ) {
+ $note = $this->context->msg( 'token_suffix_mismatch' )->plain();
+ $this->incrementEditFailureStats( 'bad_token' );
+ } else {
+ $note = $this->context->msg( 'session_fail_preview' )->plain();
+ $this->incrementEditFailureStats( 'session_loss' );
+ }
+ } elseif ( $this->incompleteForm ) {
+ $note = $this->context->msg( 'edit_form_incomplete' )->plain();
+ if ( $this->mTriedSave ) {
+ $this->incrementEditFailureStats( 'incomplete_form' );
+ }
+ } else {
+ $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
+ }
+
+ # don't parse non-wikitext pages, show message about preview
+ if ( $this->mTitle->isCssJsSubpage() || $this->mTitle->isCssOrJsPage() ) {
+ if ( $this->mTitle->isCssJsSubpage() ) {
+ $level = 'user';
+ } elseif ( $this->mTitle->isCssOrJsPage() ) {
+ $level = 'site';
+ } else {
+ $level = false;
+ }
+
+ if ( $content->getModel() == CONTENT_MODEL_CSS ) {
+ $format = 'css';
+ if ( $level === 'user' && !$config->get( 'AllowUserCss' ) ) {
+ $format = false;
+ }
+ } elseif ( $content->getModel() == CONTENT_MODEL_JAVASCRIPT ) {
+ $format = 'js';
+ if ( $level === 'user' && !$config->get( 'AllowUserJs' ) ) {
+ $format = false;
+ }
+ } else {
+ $format = false;
+ }
+
+ # Used messages to make sure grep find them:
+ # Messages: usercsspreview, userjspreview, sitecsspreview, sitejspreview
+ if ( $level && $format ) {
+ $note = "<div id='mw-{$level}{$format}preview'>" .
+ $this->context->msg( "{$level}{$format}preview" )->text() .
+ ' ' . $continueEditing . "</div>";
+ }
+ }
+
+ # If we're adding a comment, we need to show the
+ # summary as the headline
+ if ( $this->section === "new" && $this->summary !== "" ) {
+ $content = $content->addSectionHeader( $this->summary );
+ }
+
+ $hook_args = [ $this, &$content ];
+ Hooks::run( 'EditPageGetPreviewContent', $hook_args );
+
+ $parserResult = $this->doPreviewParse( $content );
+ $parserOutput = $parserResult['parserOutput'];
+ $previewHTML = $parserResult['html'];
+ $this->mParserOutput = $parserOutput;
+ $out->addParserOutputMetadata( $parserOutput );
+
+ if ( count( $parserOutput->getWarnings() ) ) {
+ $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
+ }
+
+ } catch ( MWContentSerializationException $ex ) {
+ $m = $this->context->msg(
+ 'content-failed-to-parse',
+ $this->contentModel,
+ $this->contentFormat,
+ $ex->getMessage()
+ );
+ $note .= "\n\n" . $m->parse();
+ $previewHTML = '';
+ }
+
+ if ( $this->isConflict ) {
+ $conflict = '<h2 id="mw-previewconflict">'
+ . $this->context->msg( 'previewconflict' )->escaped() . "</h2>\n";
+ } else {
+ $conflict = '<hr />';
+ }
+
+ $previewhead = "<div class='previewnote'>\n" .
+ '<h2 id="mw-previewheader">' . $this->context->msg( 'preview' )->escaped() . "</h2>" .
+ $out->parse( $note, true, /* interface */true ) . $conflict . "</div>\n";
+
+ $pageViewLang = $this->mTitle->getPageViewLanguage();
+ $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(),
+ 'class' => 'mw-content-' . $pageViewLang->getDir() ];
+ $previewHTML = Html::rawElement( 'div', $attribs, $previewHTML );
+
+ return $previewhead . $previewHTML . $this->previewTextAfterContent;
+ }
+
+ private function incrementEditFailureStats( $failureType ) {
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $stats->increment( 'edit.failures.' . $failureType );
+ }
+
+ /**
+ * Get parser options for a preview
+ * @return ParserOptions
+ */
+ protected function getPreviewParserOptions() {
+ $parserOptions = $this->page->makeParserOptions( $this->context );
+ $parserOptions->setIsPreview( true );
+ $parserOptions->setIsSectionPreview( !is_null( $this->section ) && $this->section !== '' );
+ $parserOptions->enableLimitReport();
+ return $parserOptions;
+ }
+
+ /**
+ * Parse the page for a preview. Subclasses may override this class, in order
+ * to parse with different options, or to otherwise modify the preview HTML.
+ *
+ * @param Content $content The page content
+ * @return array with keys:
+ * - parserOutput: The ParserOutput object
+ * - html: The HTML to be displayed
+ */
+ protected function doPreviewParse( Content $content ) {
+ $user = $this->context->getUser();
+ $parserOptions = $this->getPreviewParserOptions();
+ $pstContent = $content->preSaveTransform( $this->mTitle, $user, $parserOptions );
+ $scopedCallback = $parserOptions->setupFakeRevision(
+ $this->mTitle, $pstContent, $user );
+ $parserOutput = $pstContent->getParserOutput( $this->mTitle, null, $parserOptions );
+ ScopedCallback::consume( $scopedCallback );
+ $parserOutput->setEditSectionTokens( false ); // no section edit links
+ return [
+ 'parserOutput' => $parserOutput,
+ 'html' => $parserOutput->getText() ];
+ }
+
+ /**
+ * @return array
+ */
+ public function getTemplates() {
+ if ( $this->preview || $this->section != '' ) {
+ $templates = [];
+ if ( !isset( $this->mParserOutput ) ) {
+ return $templates;
+ }
+ foreach ( $this->mParserOutput->getTemplates() as $ns => $template ) {
+ foreach ( array_keys( $template ) as $dbk ) {
+ $templates[] = Title::makeTitle( $ns, $dbk );
+ }
+ }
+ return $templates;
+ } else {
+ return $this->mTitle->getTemplateLinksFrom();
+ }
+ }
+
+ /**
+ * Shows a bulletin board style toolbar for common editing functions.
+ * It can be disabled in the user preferences.
+ *
+ * @param Title $title Title object for the page being edited (optional)
+ * @return string
+ */
+ public static function getEditToolbar( $title = null ) {
+ global $wgContLang, $wgOut;
+ global $wgEnableUploads, $wgForeignFileRepos;
+
+ $imagesAvailable = $wgEnableUploads || count( $wgForeignFileRepos );
+ $showSignature = true;
+ if ( $title ) {
+ $showSignature = MWNamespace::wantSignatures( $title->getNamespace() );
+ }
+
+ /**
+ * $toolarray is an array of arrays each of which includes the
+ * opening tag, the closing tag, optionally a sample text that is
+ * inserted between the two when no selection is highlighted
+ * and. The tip text is shown when the user moves the mouse
+ * over the button.
+ *
+ * Images are defined in ResourceLoaderEditToolbarModule.
+ */
+ $toolarray = [
+ [
+ 'id' => 'mw-editbutton-bold',
+ 'open' => '\'\'\'',
+ 'close' => '\'\'\'',
+ 'sample' => wfMessage( 'bold_sample' )->text(),
+ 'tip' => wfMessage( 'bold_tip' )->text(),
+ ],
+ [
+ 'id' => 'mw-editbutton-italic',
+ 'open' => '\'\'',
+ 'close' => '\'\'',
+ 'sample' => wfMessage( 'italic_sample' )->text(),
+ 'tip' => wfMessage( 'italic_tip' )->text(),
+ ],
+ [
+ 'id' => 'mw-editbutton-link',
+ 'open' => '[[',
+ 'close' => ']]',
+ 'sample' => wfMessage( 'link_sample' )->text(),
+ 'tip' => wfMessage( 'link_tip' )->text(),
+ ],
+ [
+ 'id' => 'mw-editbutton-extlink',
+ 'open' => '[',
+ 'close' => ']',
+ 'sample' => wfMessage( 'extlink_sample' )->text(),
+ 'tip' => wfMessage( 'extlink_tip' )->text(),
+ ],
+ [
+ 'id' => 'mw-editbutton-headline',
+ 'open' => "\n== ",
+ 'close' => " ==\n",
+ 'sample' => wfMessage( 'headline_sample' )->text(),
+ 'tip' => wfMessage( 'headline_tip' )->text(),
+ ],
+ $imagesAvailable ? [
+ 'id' => 'mw-editbutton-image',
+ 'open' => '[[' . $wgContLang->getNsText( NS_FILE ) . ':',
+ 'close' => ']]',
+ 'sample' => wfMessage( 'image_sample' )->text(),
+ 'tip' => wfMessage( 'image_tip' )->text(),
+ ] : false,
+ $imagesAvailable ? [
+ 'id' => 'mw-editbutton-media',
+ 'open' => '[[' . $wgContLang->getNsText( NS_MEDIA ) . ':',
+ 'close' => ']]',
+ 'sample' => wfMessage( 'media_sample' )->text(),
+ 'tip' => wfMessage( 'media_tip' )->text(),
+ ] : false,
+ [
+ 'id' => 'mw-editbutton-nowiki',
+ 'open' => "<nowiki>",
+ 'close' => "</nowiki>",
+ 'sample' => wfMessage( 'nowiki_sample' )->text(),
+ 'tip' => wfMessage( 'nowiki_tip' )->text(),
+ ],
+ $showSignature ? [
+ 'id' => 'mw-editbutton-signature',
+ 'open' => wfMessage( 'sig-text', '~~~~' )->inContentLanguage()->text(),
+ 'close' => '',
+ 'sample' => '',
+ 'tip' => wfMessage( 'sig_tip' )->text(),
+ ] : false,
+ [
+ 'id' => 'mw-editbutton-hr',
+ 'open' => "\n----\n",
+ 'close' => '',
+ 'sample' => '',
+ 'tip' => wfMessage( 'hr_tip' )->text(),
+ ]
+ ];
+
+ $script = 'mw.loader.using("mediawiki.toolbar", function () {';
+ foreach ( $toolarray as $tool ) {
+ if ( !$tool ) {
+ continue;
+ }
+
+ $params = [
+ // Images are defined in ResourceLoaderEditToolbarModule
+ false,
+ // Note that we use the tip both for the ALT tag and the TITLE tag of the image.
+ // Older browsers show a "speedtip" type message only for ALT.
+ // Ideally these should be different, realistically they
+ // probably don't need to be.
+ $tool['tip'],
+ $tool['open'],
+ $tool['close'],
+ $tool['sample'],
+ $tool['id'],
+ ];
+
+ $script .= Xml::encodeJsCall(
+ 'mw.toolbar.addButton',
+ $params,
+ ResourceLoader::inDebugMode()
+ );
+ }
+
+ $script .= '});';
+
+ $toolbar = '<div id="toolbar"></div>';
+
+ if ( Hooks::run( 'EditPageBeforeEditToolbar', [ &$toolbar ] ) ) {
+ // Only add the old toolbar cruft to the page payload if the toolbar has not
+ // been over-written by a hook caller
+ $wgOut->addScript( ResourceLoader::makeInlineScript( $script ) );
+ };
+
+ return $toolbar;
+ }
+
+ /**
+ * Return an array of checkbox definitions.
+ *
+ * Array keys correspond to the `<input>` 'name' attribute to use for each checkbox.
+ *
+ * Array values are associative arrays with the following keys:
+ * - 'label-message' (required): message for label text
+ * - 'id' (required): 'id' attribute for the `<input>`
+ * - 'default' (required): default checkedness (true or false)
+ * - 'title-message' (optional): used to generate 'title' attribute for the `<label>`
+ * - 'tooltip' (optional): used to generate 'title' and 'accesskey' attributes
+ * from messages like 'tooltip-foo', 'accesskey-foo'
+ * - 'label-id' (optional): 'id' attribute for the `<label>`
+ * - 'legacy-name' (optional): short name for backwards-compatibility
+ * @param array $checked Array of checkbox name (matching the 'legacy-name') => bool,
+ * where bool indicates the checked status of the checkbox
+ * @return array
+ */
+ public function getCheckboxesDefinition( $checked ) {
+ $checkboxes = [];
+
+ $user = $this->context->getUser();
+ // don't show the minor edit checkbox if it's a new page or section
+ if ( !$this->isNew && $user->isAllowed( 'minoredit' ) ) {
+ $checkboxes['wpMinoredit'] = [
+ 'id' => 'wpMinoredit',
+ 'label-message' => 'minoredit',
+ // Uses messages: tooltip-minoredit, accesskey-minoredit
+ 'tooltip' => 'minoredit',
+ 'label-id' => 'mw-editpage-minoredit',
+ 'legacy-name' => 'minor',
+ 'default' => $checked['minor'],
+ ];
+ }
+
+ if ( $user->isLoggedIn() ) {
+ $checkboxes['wpWatchthis'] = [
+ 'id' => 'wpWatchthis',
+ 'label-message' => 'watchthis',
+ // Uses messages: tooltip-watch, accesskey-watch
+ 'tooltip' => 'watch',
+ 'label-id' => 'mw-editpage-watch',
+ 'legacy-name' => 'watch',
+ 'default' => $checked['watch'],
+ ];
+ }
+
+ $editPage = $this;
+ Hooks::run( 'EditPageGetCheckboxesDefinition', [ $editPage, &$checkboxes ] );
+
+ return $checkboxes;
+ }
+
+ /**
+ * Returns an array of html code of the following checkboxes old style:
+ * minor and watch
+ *
+ * @deprecated since 1.30 Use getCheckboxesWidget() or getCheckboxesDefinition() instead
+ * @param int &$tabindex Current tabindex
+ * @param array $checked See getCheckboxesDefinition()
+ * @return array
+ */
+ public function getCheckboxes( &$tabindex, $checked ) {
+ global $wgUseMediaWikiUIEverywhere;
+
+ $checkboxes = [];
+ $checkboxesDef = $this->getCheckboxesDefinition( $checked );
+
+ // Backwards-compatibility for the EditPageBeforeEditChecks hook
+ if ( !$this->isNew ) {
+ $checkboxes['minor'] = '';
+ }
+ $checkboxes['watch'] = '';
+
+ foreach ( $checkboxesDef as $name => $options ) {
+ $legacyName = isset( $options['legacy-name'] ) ? $options['legacy-name'] : $name;
+ $label = $this->context->msg( $options['label-message'] )->parse();
+ $attribs = [
+ 'tabindex' => ++$tabindex,
+ 'id' => $options['id'],
+ ];
+ $labelAttribs = [
+ 'for' => $options['id'],
+ ];
+ if ( isset( $options['tooltip'] ) ) {
+ $attribs['accesskey'] = $this->context->msg( "accesskey-{$options['tooltip']}" )->text();
+ $labelAttribs['title'] = Linker::titleAttrib( $options['tooltip'], 'withaccess' );
+ }
+ if ( isset( $options['title-message'] ) ) {
+ $labelAttribs['title'] = $this->context->msg( $options['title-message'] )->text();
+ }
+ if ( isset( $options['label-id'] ) ) {
+ $labelAttribs['id'] = $options['label-id'];
+ }
+ $checkboxHtml =
+ Xml::check( $name, $options['default'], $attribs ) .
+ '&#160;' .
+ Xml::tags( 'label', $labelAttribs, $label );
+
+ if ( $wgUseMediaWikiUIEverywhere ) {
+ $checkboxHtml = Html::rawElement( 'div', [ 'class' => 'mw-ui-checkbox' ], $checkboxHtml );
+ }
+
+ $checkboxes[ $legacyName ] = $checkboxHtml;
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ Hooks::run( 'EditPageBeforeEditChecks', [ &$editPage, &$checkboxes, &$tabindex ], '1.29' );
+ return $checkboxes;
+ }
+
+ /**
+ * Returns an array of checkboxes for the edit form, including 'minor' and 'watch' checkboxes and
+ * any other added by extensions.
+ *
+ * @deprecated since 1.30 Use getCheckboxesWidget() or getCheckboxesDefinition() instead
+ * @param int &$tabindex Current tabindex
+ * @param array $checked Array of checkbox => bool, where bool indicates the checked
+ * status of the checkbox
+ *
+ * @return array Associative array of string keys to OOUI\FieldLayout instances
+ */
+ public function getCheckboxesOOUI( &$tabindex, $checked ) {
+ return $this->getCheckboxesWidget( $tabindex, $checked );
+ }
+
+ /**
+ * Returns an array of checkboxes for the edit form, including 'minor' and 'watch' checkboxes and
+ * any other added by extensions.
+ *
+ * @param int &$tabindex Current tabindex
+ * @param array $checked Array of checkbox => bool, where bool indicates the checked
+ * status of the checkbox
+ *
+ * @return array Associative array of string keys to OOUI\FieldLayout instances
+ */
+ public function getCheckboxesWidget( &$tabindex, $checked ) {
+ $checkboxes = [];
+ $checkboxesDef = $this->getCheckboxesDefinition( $checked );
+
+ foreach ( $checkboxesDef as $name => $options ) {
+ $legacyName = isset( $options['legacy-name'] ) ? $options['legacy-name'] : $name;
+
+ $title = null;
+ $accesskey = null;
+ if ( isset( $options['tooltip'] ) ) {
+ $accesskey = $this->context->msg( "accesskey-{$options['tooltip']}" )->text();
+ $title = Linker::titleAttrib( $options['tooltip'] );
+ }
+ if ( isset( $options['title-message'] ) ) {
+ $title = $this->context->msg( $options['title-message'] )->text();
+ }
+
+ $checkboxes[ $legacyName ] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'tabIndex' => ++$tabindex,
+ 'accessKey' => $accesskey,
+ 'id' => $options['id'] . 'Widget',
+ 'inputId' => $options['id'],
+ 'name' => $name,
+ 'selected' => $options['default'],
+ 'infusable' => true,
+ ] ),
+ [
+ 'align' => 'inline',
+ 'label' => new OOUI\HtmlSnippet( $this->context->msg( $options['label-message'] )->parse() ),
+ 'title' => $title,
+ 'id' => isset( $options['label-id'] ) ? $options['label-id'] : null,
+ ]
+ );
+ }
+
+ // Backwards-compatibility hack to run the EditPageBeforeEditChecks hook. It's important,
+ // people have used it for the weirdest things completely unrelated to checkboxes...
+ // And if we're gonna run it, might as well allow its legacy checkboxes to be shown.
+ $legacyCheckboxes = [];
+ if ( !$this->isNew ) {
+ $legacyCheckboxes['minor'] = '';
+ }
+ $legacyCheckboxes['watch'] = '';
+ // Copy new-style checkboxes into an old-style structure
+ foreach ( $checkboxes as $name => $oouiLayout ) {
+ $legacyCheckboxes[$name] = (string)$oouiLayout;
+ }
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $ep = $this;
+ Hooks::run( 'EditPageBeforeEditChecks', [ &$ep, &$legacyCheckboxes, &$tabindex ], '1.29' );
+ // Copy back any additional old-style checkboxes into the new-style structure
+ foreach ( $legacyCheckboxes as $name => $html ) {
+ if ( $html && !isset( $checkboxes[$name] ) ) {
+ $checkboxes[$name] = new OOUI\Widget( [ 'content' => new OOUI\HtmlSnippet( $html ) ] );
+ }
+ }
+
+ return $checkboxes;
+ }
+
+ /**
+ * Get the message key of the label for the button to save the page
+ *
+ * @since 1.30
+ * @return string
+ */
+ protected function getSubmitButtonLabel() {
+ $labelAsPublish =
+ $this->context->getConfig()->get( 'EditSubmitButtonLabelPublish' );
+
+ // Can't use $this->isNew as that's also true if we're adding a new section to an extant page
+ $newPage = !$this->mTitle->exists();
+
+ if ( $labelAsPublish ) {
+ $buttonLabelKey = $newPage ? 'publishpage' : 'publishchanges';
+ } else {
+ $buttonLabelKey = $newPage ? 'savearticle' : 'savechanges';
+ }
+
+ return $buttonLabelKey;
+ }
+
+ /**
+ * Returns an array of html code of the following buttons:
+ * save, diff and preview
+ *
+ * @param int &$tabindex Current tabindex
+ *
+ * @return array
+ */
+ public function getEditButtons( &$tabindex ) {
+ $buttons = [];
+
+ $buttonLabel = $this->context->msg( $this->getSubmitButtonLabel() )->text();
+
+ $attribs = [
+ 'name' => 'wpSave',
+ 'tabindex' => ++$tabindex,
+ ];
+
+ $saveConfig = OOUI\Element::configFromHtmlAttributes( $attribs );
+ $buttons['save'] = new OOUI\ButtonInputWidget( [
+ 'id' => 'wpSaveWidget',
+ 'inputId' => 'wpSave',
+ // Support: IE 6 – Use <input>, otherwise it can't distinguish which button was clicked
+ 'useInputTag' => true,
+ 'flags' => [ 'constructive', 'primary' ],
+ 'label' => $buttonLabel,
+ 'infusable' => true,
+ 'type' => 'submit',
+ 'title' => Linker::titleAttrib( 'save' ),
+ 'accessKey' => Linker::accesskey( 'save' ),
+ ] + $saveConfig );
+
+ $attribs = [
+ 'name' => 'wpPreview',
+ 'tabindex' => ++$tabindex,
+ ];
+
+ $previewConfig = OOUI\Element::configFromHtmlAttributes( $attribs );
+ $buttons['preview'] = new OOUI\ButtonInputWidget( [
+ 'id' => 'wpPreviewWidget',
+ 'inputId' => 'wpPreview',
+ // Support: IE 6 – Use <input>, otherwise it can't distinguish which button was clicked
+ 'useInputTag' => true,
+ 'label' => $this->context->msg( 'showpreview' )->text(),
+ 'infusable' => true,
+ 'type' => 'submit',
+ 'title' => Linker::titleAttrib( 'preview' ),
+ 'accessKey' => Linker::accesskey( 'preview' ),
+ ] + $previewConfig );
+
+ $attribs = [
+ 'name' => 'wpDiff',
+ 'tabindex' => ++$tabindex,
+ ];
+
+ $diffConfig = OOUI\Element::configFromHtmlAttributes( $attribs );
+ $buttons['diff'] = new OOUI\ButtonInputWidget( [
+ 'id' => 'wpDiffWidget',
+ 'inputId' => 'wpDiff',
+ // Support: IE 6 – Use <input>, otherwise it can't distinguish which button was clicked
+ 'useInputTag' => true,
+ 'label' => $this->context->msg( 'showdiff' )->text(),
+ 'infusable' => true,
+ 'type' => 'submit',
+ 'title' => Linker::titleAttrib( 'diff' ),
+ 'accessKey' => Linker::accesskey( 'diff' ),
+ ] + $diffConfig );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ Hooks::run( 'EditPageBeforeEditButtons', [ &$editPage, &$buttons, &$tabindex ] );
+
+ return $buttons;
+ }
+
+ /**
+ * Creates a basic error page which informs the user that
+ * they have attempted to edit a nonexistent section.
+ */
+ public function noSuchSectionPage() {
+ $out = $this->context->getOutput();
+ $out->prepareErrorPage( $this->context->msg( 'nosuchsectiontitle' ) );
+
+ $res = $this->context->msg( 'nosuchsectiontext', $this->section )->parseAsBlock();
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $editPage = $this;
+ Hooks::run( 'EditPageNoSuchSection', [ &$editPage, &$res ] );
+ $out->addHTML( $res );
+
+ $out->returnToMain( false, $this->mTitle );
+ }
+
+ /**
+ * Show "your edit contains spam" page with your diff and text
+ *
+ * @param string|array|bool $match Text (or array of texts) which triggered one or more filters
+ */
+ public function spamPageWithContent( $match = false ) {
+ $this->textbox2 = $this->textbox1;
+
+ if ( is_array( $match ) ) {
+ $match = $this->context->getLanguage()->listToText( $match );
+ }
+ $out = $this->context->getOutput();
+ $out->prepareErrorPage( $this->context->msg( 'spamprotectiontitle' ) );
+
+ $out->addHTML( '<div id="spamprotected">' );
+ $out->addWikiMsg( 'spamprotectiontext' );
+ if ( $match ) {
+ $out->addWikiMsg( 'spamprotectionmatch', wfEscapeWikiText( $match ) );
+ }
+ $out->addHTML( '</div>' );
+
+ $out->wrapWikiMsg( '<h2>$1</h2>', "yourdiff" );
+ $this->showDiff();
+
+ $out->wrapWikiMsg( '<h2>$1</h2>', "yourtext" );
+ $this->showTextbox2();
+
+ $out->addReturnTo( $this->getContextTitle(), [ 'action' => 'edit' ] );
+ }
+
+ /**
+ * Filter an input field through a Unicode de-armoring process if it
+ * came from an old browser with known broken Unicode editing issues.
+ *
+ * @deprecated since 1.30, does nothing
+ *
+ * @param WebRequest $request
+ * @param string $field
+ * @return string
+ */
+ protected function safeUnicodeInput( $request, $field ) {
+ return rtrim( $request->getText( $field ) );
+ }
+
+ /**
+ * Filter an output field through a Unicode armoring process if it is
+ * going to an old browser with known broken Unicode editing issues.
+ *
+ * @deprecated since 1.30, does nothing
+ *
+ * @param string $text
+ * @return string
+ */
+ protected function safeUnicodeOutput( $text ) {
+ return $text;
+ }
+
+ /**
+ * @since 1.29
+ */
+ protected function addEditNotices() {
+ $out = $this->context->getOutput();
+ $editNotices = $this->mTitle->getEditNotices( $this->oldid );
+ if ( count( $editNotices ) ) {
+ $out->addHTML( implode( "\n", $editNotices ) );
+ } else {
+ $msg = $this->context->msg( 'editnotice-notext' );
+ if ( !$msg->isDisabled() ) {
+ $out->addHTML(
+ '<div class="mw-editnotice-notext">'
+ . $msg->parseAsBlock()
+ . '</div>'
+ );
+ }
+ }
+ }
+
+ /**
+ * @since 1.29
+ */
+ protected function addTalkPageText() {
+ if ( $this->mTitle->isTalkPage() ) {
+ $this->context->getOutput()->addWikiMsg( 'talkpagetext' );
+ }
+ }
+
+ /**
+ * @since 1.29
+ */
+ protected function addLongPageWarningHeader() {
+ if ( $this->contentLength === false ) {
+ $this->contentLength = strlen( $this->textbox1 );
+ }
+
+ $out = $this->context->getOutput();
+ $lang = $this->context->getLanguage();
+ $maxArticleSize = $this->context->getConfig()->get( 'MaxArticleSize' );
+ if ( $this->tooBig || $this->contentLength > $maxArticleSize * 1024 ) {
+ $out->wrapWikiMsg( "<div class='error' id='mw-edit-longpageerror'>\n$1\n</div>",
+ [
+ 'longpageerror',
+ $lang->formatNum( round( $this->contentLength / 1024, 3 ) ),
+ $lang->formatNum( $maxArticleSize )
+ ]
+ );
+ } else {
+ if ( !$this->context->msg( 'longpage-hint' )->isDisabled() ) {
+ $out->wrapWikiMsg( "<div id='mw-edit-longpage-hint'>\n$1\n</div>",
+ [
+ 'longpage-hint',
+ $lang->formatSize( strlen( $this->textbox1 ) ),
+ strlen( $this->textbox1 )
+ ]
+ );
+ }
+ }
+ }
+
+ /**
+ * @since 1.29
+ */
+ protected function addPageProtectionWarningHeaders() {
+ $out = $this->context->getOutput();
+ if ( $this->mTitle->isProtected( 'edit' ) &&
+ MWNamespace::getRestrictionLevels( $this->mTitle->getNamespace() ) !== [ '' ]
+ ) {
+ # Is the title semi-protected?
+ if ( $this->mTitle->isSemiProtected() ) {
+ $noticeMsg = 'semiprotectedpagewarning';
+ } else {
+ # Then it must be protected based on static groups (regular)
+ $noticeMsg = 'protectedpagewarning';
+ }
+ LogEventsList::showLogExtract( $out, 'protect', $this->mTitle, '',
+ [ 'lim' => 1, 'msgKey' => [ $noticeMsg ] ] );
+ }
+ if ( $this->mTitle->isCascadeProtected() ) {
+ # Is this page under cascading protection from some source pages?
+ /** @var Title[] $cascadeSources */
+ list( $cascadeSources, /* $restrictions */ ) = $this->mTitle->getCascadeProtectionSources();
+ $notice = "<div class='mw-cascadeprotectedwarning'>\n$1\n";
+ $cascadeSourcesCount = count( $cascadeSources );
+ if ( $cascadeSourcesCount > 0 ) {
+ # Explain, and list the titles responsible
+ foreach ( $cascadeSources as $page ) {
+ $notice .= '* [[:' . $page->getPrefixedText() . "]]\n";
+ }
+ }
+ $notice .= '</div>';
+ $out->wrapWikiMsg( $notice, [ 'cascadeprotectedwarning', $cascadeSourcesCount ] );
+ }
+ if ( !$this->mTitle->exists() && $this->mTitle->getRestrictions( 'create' ) ) {
+ LogEventsList::showLogExtract( $out, 'protect', $this->mTitle, '',
+ [ 'lim' => 1,
+ 'showIfEmpty' => false,
+ 'msgKey' => [ 'titleprotectedwarning' ],
+ 'wrap' => "<div class=\"mw-titleprotectedwarning\">\n$1</div>" ] );
+ }
+ }
+
+ /**
+ * @param OutputPage $out
+ * @since 1.29
+ */
+ protected function addExplainConflictHeader( OutputPage $out ) {
+ $out->wrapWikiMsg(
+ "<div class='mw-explainconflict'>\n$1\n</div>",
+ [ 'explainconflict', $this->context->msg( $this->getSubmitButtonLabel() )->text() ]
+ );
+ }
+
+ /**
+ * @param string $name
+ * @param mixed[] $customAttribs
+ * @param User $user
+ * @return mixed[]
+ * @since 1.29
+ */
+ protected function buildTextboxAttribs( $name, array $customAttribs, User $user ) {
+ $attribs = $customAttribs + [
+ 'accesskey' => ',',
+ 'id' => $name,
+ 'cols' => 80,
+ 'rows' => 25,
+ // Avoid PHP notices when appending preferences
+ // (appending allows customAttribs['style'] to still work).
+ 'style' => ''
+ ];
+
+ // The following classes can be used here:
+ // * mw-editfont-default
+ // * mw-editfont-monospace
+ // * mw-editfont-sans-serif
+ // * mw-editfont-serif
+ $class = 'mw-editfont-' . $user->getOption( 'editfont' );
+
+ if ( isset( $attribs['class'] ) ) {
+ if ( is_string( $attribs['class'] ) ) {
+ $attribs['class'] .= ' ' . $class;
+ } elseif ( is_array( $attribs['class'] ) ) {
+ $attribs['class'][] = $class;
+ }
+ } else {
+ $attribs['class'] = $class;
+ }
+
+ $pageLang = $this->mTitle->getPageLanguage();
+ $attribs['lang'] = $pageLang->getHtmlCode();
+ $attribs['dir'] = $pageLang->getDir();
+
+ return $attribs;
+ }
+
+ /**
+ * @param string $wikitext
+ * @return string
+ * @since 1.29
+ */
+ protected function addNewLineAtEnd( $wikitext ) {
+ if ( strval( $wikitext ) !== '' ) {
+ // Ensure there's a newline at the end, otherwise adding lines
+ // is awkward.
+ // But don't add a newline if the text is empty, or Firefox in XHTML
+ // mode will show an extra newline. A bit annoying.
+ $wikitext .= "\n";
+ return $wikitext;
+ }
+ return $wikitext;
+ }
+
+ /**
+ * Turns section name wikitext into anchors for use in HTTP redirects. Various
+ * versions of Microsoft browsers misinterpret fragment encoding of Location: headers
+ * resulting in mojibake in address bar. Redirect them to legacy section IDs,
+ * if possible. All the other browsers get HTML5 if the wiki is configured for it, to
+ * spread the new style links more efficiently.
+ *
+ * @param string $text
+ * @return string
+ */
+ private function guessSectionName( $text ) {
+ global $wgParser;
+
+ // Detect Microsoft browsers
+ $userAgent = $this->context->getRequest()->getHeader( 'User-Agent' );
+ if ( $userAgent && preg_match( '/MSIE|Edge/', $userAgent ) ) {
+ // ...and redirect them to legacy encoding, if available
+ return $wgParser->guessLegacySectionNameFromWikiText( $text );
+ }
+ // Meanwhile, real browsers get real anchors
+ return $wgParser->guessSectionNameFromWikiText( $text );
+ }
+}
diff --git a/www/wiki/includes/EventRelayerGroup.php b/www/wiki/includes/EventRelayerGroup.php
new file mode 100644
index 00000000..18b1cd3f
--- /dev/null
+++ b/www/wiki/includes/EventRelayerGroup.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Factory class for spawning EventRelayer objects using configuration
+ *
+ * @since 1.27
+ */
+class EventRelayerGroup {
+ /** @var array[] */
+ protected $configByChannel = [];
+
+ /** @var EventRelayer[] */
+ protected $relayers = [];
+
+ /**
+ * @param array[] $config Channel configuration
+ */
+ public function __construct( array $config ) {
+ $this->configByChannel = $config;
+ }
+
+ /**
+ * @deprecated since 1.27 Use MediaWikiServices::getInstance()->getEventRelayerGroup()
+ * @return EventRelayerGroup
+ */
+ public static function singleton() {
+ return MediaWikiServices::getInstance()->getEventRelayerGroup();
+ }
+
+ /**
+ * @param string $channel
+ * @return EventRelayer Relayer instance that handles the given channel
+ */
+ public function getRelayer( $channel ) {
+ $channelKey = isset( $this->configByChannel[$channel] )
+ ? $channel
+ : 'default';
+
+ if ( !isset( $this->relayers[$channelKey] ) ) {
+ if ( !isset( $this->configByChannel[$channelKey] ) ) {
+ throw new UnexpectedValueException( "No config for '$channelKey'" );
+ }
+
+ $config = $this->configByChannel[$channelKey];
+ $class = $config['class'];
+
+ $this->relayers[$channelKey] = new $class( $config );
+ }
+
+ return $this->relayers[$channelKey];
+ }
+}
diff --git a/www/wiki/includes/FauxRequest.php b/www/wiki/includes/FauxRequest.php
new file mode 100644
index 00000000..2f7f75b4
--- /dev/null
+++ b/www/wiki/includes/FauxRequest.php
@@ -0,0 +1,246 @@
+<?php
+/**
+ * Deal with importing all those nasty globals and things
+ *
+ * Copyright © 2003 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Session\SessionManager;
+
+/**
+ * WebRequest clone which takes values from a provided array.
+ *
+ * @ingroup HTTP
+ */
+class FauxRequest extends WebRequest {
+ private $wasPosted = false;
+ private $requestUrl;
+ protected $cookies = [];
+
+ /**
+ * @param array $data Array of *non*-urlencoded key => value pairs, the
+ * fake GET/POST values
+ * @param bool $wasPosted Whether to treat the data as POST
+ * @param MediaWiki\Session\Session|array|null $session Session, session
+ * data array, or null
+ * @param string $protocol 'http' or 'https'
+ * @throws MWException
+ */
+ public function __construct( $data = [], $wasPosted = false,
+ $session = null, $protocol = 'http'
+ ) {
+ $this->requestTime = microtime( true );
+
+ if ( is_array( $data ) ) {
+ $this->data = $data;
+ } else {
+ throw new MWException( "FauxRequest() got bogus data" );
+ }
+ $this->wasPosted = $wasPosted;
+ if ( $session instanceof MediaWiki\Session\Session ) {
+ $this->sessionId = $session->getSessionId();
+ } elseif ( is_array( $session ) ) {
+ $mwsession = SessionManager::singleton()->getEmptySession( $this );
+ $this->sessionId = $mwsession->getSessionId();
+ foreach ( $session as $key => $value ) {
+ $mwsession->set( $key, $value );
+ }
+ } elseif ( $session !== null ) {
+ throw new MWException( "FauxRequest() got bogus session" );
+ }
+ $this->protocol = $protocol;
+ }
+
+ /**
+ * Initialise the header list
+ */
+ protected function initHeaders() {
+ // Nothing to init
+ }
+
+ /**
+ * @param string $name
+ * @param string $default
+ * @return string
+ */
+ public function getText( $name, $default = '' ) {
+ # Override; don't recode since we're using internal data
+ return (string)$this->getVal( $name, $default );
+ }
+
+ /**
+ * @return array
+ */
+ public function getValues() {
+ return $this->data;
+ }
+
+ /**
+ * @return array
+ */
+ public function getQueryValues() {
+ if ( $this->wasPosted ) {
+ return [];
+ } else {
+ return $this->data;
+ }
+ }
+
+ public function getMethod() {
+ return $this->wasPosted ? 'POST' : 'GET';
+ }
+
+ /**
+ * @return bool
+ */
+ public function wasPosted() {
+ return $this->wasPosted;
+ }
+
+ public function getCookie( $key, $prefix = null, $default = null ) {
+ if ( $prefix === null ) {
+ global $wgCookiePrefix;
+ $prefix = $wgCookiePrefix;
+ }
+ $name = $prefix . $key;
+ return isset( $this->cookies[$name] ) ? $this->cookies[$name] : $default;
+ }
+
+ /**
+ * @since 1.26
+ * @param string $key Unprefixed name of the cookie to set
+ * @param string|null $value Value of the cookie to set
+ * @param string|null $prefix Cookie prefix. Defaults to $wgCookiePrefix
+ */
+ public function setCookie( $key, $value, $prefix = null ) {
+ $this->setCookies( [ $key => $value ], $prefix );
+ }
+
+ /**
+ * @since 1.26
+ * @param array $cookies
+ * @param string|null $prefix Cookie prefix. Defaults to $wgCookiePrefix
+ */
+ public function setCookies( $cookies, $prefix = null ) {
+ if ( $prefix === null ) {
+ global $wgCookiePrefix;
+ $prefix = $wgCookiePrefix;
+ }
+ foreach ( $cookies as $key => $value ) {
+ $name = $prefix . $key;
+ $this->cookies[$name] = $value;
+ }
+ }
+
+ /**
+ * @since 1.25
+ * @param string $url
+ */
+ public function setRequestURL( $url ) {
+ $this->requestUrl = $url;
+ }
+
+ /**
+ * @since 1.25 MWException( "getRequestURL not implemented" )
+ * no longer thrown.
+ * @return string
+ */
+ public function getRequestURL() {
+ if ( $this->requestUrl === null ) {
+ throw new MWException( 'Request URL not set' );
+ }
+ return $this->requestUrl;
+ }
+
+ public function getProtocol() {
+ return $this->protocol;
+ }
+
+ /**
+ * @param string $name
+ * @param string $val
+ */
+ public function setHeader( $name, $val ) {
+ $this->setHeaders( [ $name => $val ] );
+ }
+
+ /**
+ * @since 1.26
+ * @param array $headers
+ */
+ public function setHeaders( $headers ) {
+ foreach ( $headers as $name => $val ) {
+ $name = strtoupper( $name );
+ $this->headers[$name] = $val;
+ }
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getSessionArray() {
+ if ( $this->sessionId !== null ) {
+ return iterator_to_array( $this->getSession() );
+ }
+ return null;
+ }
+
+ /**
+ * FauxRequests shouldn't depend on raw request data (but that could be implemented here)
+ * @return string
+ */
+ public function getRawQueryString() {
+ return '';
+ }
+
+ /**
+ * FauxRequests shouldn't depend on raw request data (but that could be implemented here)
+ * @return string
+ */
+ public function getRawPostString() {
+ return '';
+ }
+
+ /**
+ * FauxRequests shouldn't depend on raw request data (but that could be implemented here)
+ * @return string
+ */
+ public function getRawInput() {
+ return '';
+ }
+
+ /**
+ * @codeCoverageIgnore
+ * @param array $extWhitelist
+ * @return bool
+ */
+ public function checkUrlExtension( $extWhitelist = [] ) {
+ return true;
+ }
+
+ /**
+ * @codeCoverageIgnore
+ * @return string
+ */
+ protected function getRawIP() {
+ return '127.0.0.1';
+ }
+}
diff --git a/www/wiki/includes/Feed.php b/www/wiki/includes/Feed.php
new file mode 100644
index 00000000..d05fc2d8
--- /dev/null
+++ b/www/wiki/includes/Feed.php
@@ -0,0 +1,429 @@
+<?php
+/**
+ * Basic support for outputting syndication feeds in RSS, other formats.
+ *
+ * Contain a feed class as well as classes to build rss / atom ... feeds
+ * Available feeds are defined in Defines.php
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @defgroup Feed Feed
+ */
+
+/**
+ * A base class for basic support for outputting syndication feeds in RSS and other formats.
+ *
+ * @ingroup Feed
+ */
+class FeedItem {
+ /** @var Title */
+ public $title;
+
+ public $description;
+
+ public $url;
+
+ public $date;
+
+ public $author;
+
+ public $uniqueId;
+
+ public $comments;
+
+ public $rssIsPermalink = false;
+
+ /**
+ * @param string|Title $title Item's title
+ * @param string $description
+ * @param string $url URL uniquely designating the item.
+ * @param string $date Item's date
+ * @param string $author Author's user name
+ * @param string $comments
+ */
+ function __construct( $title, $description, $url, $date = '', $author = '', $comments = '' ) {
+ $this->title = $title;
+ $this->description = $description;
+ $this->url = $url;
+ $this->uniqueId = $url;
+ $this->date = $date;
+ $this->author = $author;
+ $this->comments = $comments;
+ }
+
+ /**
+ * Encode $string so that it can be safely embedded in a XML document
+ *
+ * @param string $string String to encode
+ * @return string
+ */
+ public function xmlEncode( $string ) {
+ $string = str_replace( "\r\n", "\n", $string );
+ $string = preg_replace( '/[\x00-\x08\x0b\x0c\x0e-\x1f]/', '', $string );
+ return htmlspecialchars( $string );
+ }
+
+ /**
+ * Get the unique id of this item
+ *
+ * @return string
+ */
+ public function getUniqueId() {
+ if ( $this->uniqueId ) {
+ return $this->xmlEncode( wfExpandUrl( $this->uniqueId, PROTO_CURRENT ) );
+ }
+ }
+
+ /**
+ * Set the unique id of an item
+ *
+ * @param string $uniqueId Unique id for the item
+ * @param bool $rssIsPermalink Set to true if the guid (unique id) is a permalink (RSS feeds only)
+ */
+ public function setUniqueId( $uniqueId, $rssIsPermalink = false ) {
+ $this->uniqueId = $uniqueId;
+ $this->rssIsPermalink = $rssIsPermalink;
+ }
+
+ /**
+ * Get the title of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getTitle() {
+ return $this->xmlEncode( $this->title );
+ }
+
+ /**
+ * Get the URL of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getUrl() {
+ return $this->xmlEncode( $this->url );
+ }
+
+ /**
+ * Get the description of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getDescription() {
+ return $this->xmlEncode( $this->description );
+ }
+
+ /**
+ * Get the language of this item
+ *
+ * @return string
+ */
+ public function getLanguage() {
+ global $wgLanguageCode;
+ return wfBCP47( $wgLanguageCode );
+ }
+
+ /**
+ * Get the date of this item
+ *
+ * @return string
+ */
+ public function getDate() {
+ return $this->date;
+ }
+
+ /**
+ * Get the author of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getAuthor() {
+ return $this->xmlEncode( $this->author );
+ }
+
+ /**
+ * Get the comment of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getComments() {
+ return $this->xmlEncode( $this->comments );
+ }
+
+ /**
+ * Quickie hack... strip out wikilinks to more legible form from the comment.
+ *
+ * @param string $text Wikitext
+ * @return string
+ */
+ public static function stripComment( $text ) {
+ return preg_replace( '/\[\[([^]]*\|)?([^]]+)\]\]/', '\2', $text );
+ }
+ /**#@-*/
+}
+
+/**
+ * Class to support the outputting of syndication feeds in Atom and RSS format.
+ *
+ * @ingroup Feed
+ */
+abstract class ChannelFeed extends FeedItem {
+ /**
+ * Generate Header of the feed
+ * @par Example:
+ * @code
+ * print "<feed>";
+ * @endcode
+ */
+ abstract public function outHeader();
+
+ /**
+ * Generate an item
+ * @par Example:
+ * @code
+ * print "<item>...</item>";
+ * @endcode
+ * @param FeedItem $item
+ */
+ abstract public function outItem( $item );
+
+ /**
+ * Generate Footer of the feed
+ * @par Example:
+ * @code
+ * print "</feed>";
+ * @endcode
+ */
+ abstract public function outFooter();
+
+ /**
+ * Setup and send HTTP headers. Don't send any content;
+ * content might end up being cached and re-sent with
+ * these same headers later.
+ *
+ * This should be called from the outHeader() method,
+ * but can also be called separately.
+ */
+ public function httpHeaders() {
+ global $wgOut, $wgVaryOnXFP;
+
+ # We take over from $wgOut, excepting its cache header info
+ $wgOut->disable();
+ $mimetype = $this->contentType();
+ header( "Content-type: $mimetype; charset=UTF-8" );
+
+ // Set a sane filename
+ $exts = MimeMagic::singleton()->getExtensionsForType( $mimetype );
+ $ext = $exts ? strtok( $exts, ' ' ) : 'xml';
+ header( "Content-Disposition: inline; filename=\"feed.{$ext}\"" );
+
+ if ( $wgVaryOnXFP ) {
+ $wgOut->addVaryHeader( 'X-Forwarded-Proto' );
+ }
+ $wgOut->sendCacheControl();
+ }
+
+ /**
+ * Return an internet media type to be sent in the headers.
+ *
+ * @return string
+ */
+ private function contentType() {
+ global $wgRequest;
+
+ $ctype = $wgRequest->getVal( 'ctype', 'application/xml' );
+ $allowedctypes = [
+ 'application/xml',
+ 'text/xml',
+ 'application/rss+xml',
+ 'application/atom+xml'
+ ];
+
+ return ( in_array( $ctype, $allowedctypes ) ? $ctype : 'application/xml' );
+ }
+
+ /**
+ * Output the initial XML headers.
+ */
+ protected function outXmlHeader() {
+ $this->httpHeaders();
+ echo '<?xml version="1.0"?>' . "\n";
+ }
+}
+
+/**
+ * Generate a RSS feed
+ *
+ * @ingroup Feed
+ */
+class RSSFeed extends ChannelFeed {
+
+ /**
+ * Format a date given a timestamp
+ *
+ * @param int $ts Timestamp
+ * @return string Date string
+ */
+ function formatTime( $ts ) {
+ return gmdate( 'D, d M Y H:i:s \G\M\T', wfTimestamp( TS_UNIX, $ts ) );
+ }
+
+ /**
+ * Output an RSS 2.0 header
+ */
+ function outHeader() {
+ global $wgVersion;
+
+ $this->outXmlHeader();
+ ?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <channel>
+ <title><?php print $this->getTitle() ?></title>
+ <link><?php print wfExpandUrl( $this->getUrl(), PROTO_CURRENT ) ?></link>
+ <description><?php print $this->getDescription() ?></description>
+ <language><?php print $this->getLanguage() ?></language>
+ <generator>MediaWiki <?php print $wgVersion ?></generator>
+ <lastBuildDate><?php print $this->formatTime( wfTimestampNow() ) ?></lastBuildDate>
+<?php
+ }
+
+ /**
+ * Output an RSS 2.0 item
+ * @param FeedItem $item Item to be output
+ */
+ function outItem( $item ) {
+ // @codingStandardsIgnoreStart Ignore long lines and formatting issues.
+ ?>
+ <item>
+ <title><?php print $item->getTitle(); ?></title>
+ <link><?php print wfExpandUrl( $item->getUrl(), PROTO_CURRENT ); ?></link>
+ <guid<?php if ( !$item->rssIsPermalink ) { print ' isPermaLink="false"'; } ?>><?php print $item->getUniqueId(); ?></guid>
+ <description><?php print $item->getDescription() ?></description>
+ <?php if ( $item->getDate() ) { ?><pubDate><?php print $this->formatTime( $item->getDate() ); ?></pubDate><?php } ?>
+ <?php if ( $item->getAuthor() ) { ?><dc:creator><?php print $item->getAuthor(); ?></dc:creator><?php }?>
+ <?php if ( $item->getComments() ) { ?><comments><?php print wfExpandUrl( $item->getComments(), PROTO_CURRENT ); ?></comments><?php }?>
+ </item>
+<?php
+ // @codingStandardsIgnoreEnd
+ }
+
+ /**
+ * Output an RSS 2.0 footer
+ */
+ function outFooter() {
+ ?>
+ </channel>
+</rss><?php
+ }
+}
+
+/**
+ * Generate an Atom feed
+ *
+ * @ingroup Feed
+ */
+class AtomFeed extends ChannelFeed {
+ /**
+ * Format a date given timestamp.
+ *
+ * @param string|int $timestamp
+ * @return string
+ */
+ function formatTime( $timestamp ) {
+ // need to use RFC 822 time format at least for rss2.0
+ return gmdate( 'Y-m-d\TH:i:s', wfTimestamp( TS_UNIX, $timestamp ) );
+ }
+
+ /**
+ * Outputs a basic header for Atom 1.0 feeds.
+ */
+ function outHeader() {
+ global $wgVersion;
+
+ $this->outXmlHeader();
+ // @codingStandardsIgnoreStart Ignore long lines and formatting issues.
+ ?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="<?php print $this->getLanguage() ?>">
+ <id><?php print $this->getFeedId() ?></id>
+ <title><?php print $this->getTitle() ?></title>
+ <link rel="self" type="application/atom+xml" href="<?php print wfExpandUrl( $this->getSelfUrl(), PROTO_CURRENT ) ?>"/>
+ <link rel="alternate" type="text/html" href="<?php print wfExpandUrl( $this->getUrl(), PROTO_CURRENT ) ?>"/>
+ <updated><?php print $this->formatTime( wfTimestampNow() ) ?>Z</updated>
+ <subtitle><?php print $this->getDescription() ?></subtitle>
+ <generator>MediaWiki <?php print $wgVersion ?></generator>
+
+<?php
+ // @codingStandardsIgnoreEnd
+ }
+
+ /**
+ * Atom 1.0 requires a unique, opaque IRI as a unique identifier
+ * for every feed we create. For now just use the URL, but who
+ * can tell if that's right? If we put options on the feed, do we
+ * have to change the id? Maybe? Maybe not.
+ *
+ * @return string
+ */
+ private function getFeedId() {
+ return $this->getSelfUrl();
+ }
+
+ /**
+ * Atom 1.0 requests a self-reference to the feed.
+ * @return string
+ */
+ private function getSelfUrl() {
+ global $wgRequest;
+ return htmlspecialchars( $wgRequest->getFullRequestURL() );
+ }
+
+ /**
+ * Output a given item.
+ * @param FeedItem $item
+ */
+ function outItem( $item ) {
+ global $wgMimeType;
+ // @codingStandardsIgnoreStart Ignore long lines and formatting issues.
+ ?>
+ <entry>
+ <id><?php print $item->getUniqueId(); ?></id>
+ <title><?php print $item->getTitle(); ?></title>
+ <link rel="alternate" type="<?php print $wgMimeType ?>" href="<?php print wfExpandUrl( $item->getUrl(), PROTO_CURRENT ); ?>"/>
+ <?php if ( $item->getDate() ) { ?>
+ <updated><?php print $this->formatTime( $item->getDate() ); ?>Z</updated>
+ <?php } ?>
+
+ <summary type="html"><?php print $item->getDescription() ?></summary>
+ <?php if ( $item->getAuthor() ) { ?><author><name><?php print $item->getAuthor(); ?></name></author><?php }?>
+ </entry>
+
+<?php /* @todo FIXME: Need to add comments
+ <?php if( $item->getComments() ) { ?><dc:comment><?php print $item->getComments() ?></dc:comment><?php }?>
+ */
+ }
+
+ /**
+ * Outputs the footer for Atom 1.0 feed (basically '\</feed\>').
+ */
+ function outFooter() {?>
+ </feed><?php
+ // @codingStandardsIgnoreEnd
+ }
+}
diff --git a/www/wiki/includes/FeedUtils.php b/www/wiki/includes/FeedUtils.php
new file mode 100644
index 00000000..0def6a04
--- /dev/null
+++ b/www/wiki/includes/FeedUtils.php
@@ -0,0 +1,262 @@
+<?php
+/**
+ * Helper functions for feeds.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Feed
+ */
+
+/**
+ * Helper functions for feeds
+ *
+ * @ingroup Feed
+ */
+class FeedUtils {
+
+ /**
+ * Check whether feed's cache should be cleared; for changes feeds
+ * If the feed should be purged; $timekey and $key will be removed from cache
+ *
+ * @param string $timekey Cache key of the timestamp of the last item
+ * @param string $key Cache key of feed's content
+ */
+ public static function checkPurge( $timekey, $key ) {
+ global $wgRequest, $wgUser;
+
+ $purge = $wgRequest->getVal( 'action' ) === 'purge';
+ // Allow users with 'purge' right to clear feed caches
+ if ( $purge && $wgUser->isAllowed( 'purge' ) ) {
+ $cache = ObjectCache::getMainWANInstance();
+ $cache->delete( $timekey, 1 );
+ $cache->delete( $key, 1 );
+ }
+ }
+
+ /**
+ * Check whether feeds can be used and that $type is a valid feed type
+ *
+ * @param string $type Feed type, as requested by the user
+ * @return bool
+ */
+ public static function checkFeedOutput( $type ) {
+ global $wgOut, $wgFeed, $wgFeedClasses;
+
+ if ( !$wgFeed ) {
+ $wgOut->addWikiMsg( 'feed-unavailable' );
+ return false;
+ }
+
+ if ( !isset( $wgFeedClasses[$type] ) ) {
+ $wgOut->addWikiMsg( 'feed-invalid' );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Format a diff for the newsfeed
+ *
+ * @param object $row Row from the recentchanges table, including fields as
+ * appropriate for CommentStore
+ * @return string
+ */
+ public static function formatDiff( $row ) {
+ $titleObj = Title::makeTitle( $row->rc_namespace, $row->rc_title );
+ $timestamp = wfTimestamp( TS_MW, $row->rc_timestamp );
+ $actiontext = '';
+ if ( $row->rc_type == RC_LOG ) {
+ $rcRow = (array)$row; // newFromRow() only accepts arrays for RC rows
+ $actiontext = LogFormatter::newFromRow( $rcRow )->getActionText();
+ }
+ return self::formatDiffRow( $titleObj,
+ $row->rc_last_oldid, $row->rc_this_oldid,
+ $timestamp,
+ $row->rc_deleted & Revision::DELETED_COMMENT
+ ? wfMessage( 'rev-deleted-comment' )->escaped()
+ : CommentStore::newKey( 'rc_comment' )
+ // Legacy from RecentChange::selectFields() via ChangesListSpecialPage::doMainQuery()
+ ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row )->text,
+ $actiontext
+ );
+ }
+
+ /**
+ * Really format a diff for the newsfeed
+ *
+ * @param Title $title Title object
+ * @param int $oldid Old revision's id
+ * @param int $newid New revision's id
+ * @param int $timestamp New revision's timestamp
+ * @param string $comment New revision's comment
+ * @param string $actiontext Text of the action; in case of log event
+ * @return string
+ */
+ public static function formatDiffRow( $title, $oldid, $newid, $timestamp,
+ $comment, $actiontext = ''
+ ) {
+ global $wgFeedDiffCutoff, $wgLang;
+
+ // log entries
+ $completeText = '<p>' . implode( ' ',
+ array_filter(
+ [
+ $actiontext,
+ Linker::formatComment( $comment ) ] ) ) . "</p>\n";
+
+ // NOTE: Check permissions for anonymous users, not current user.
+ // No "privileged" version should end up in the cache.
+ // Most feed readers will not log in anyway.
+ $anon = new User();
+ $accErrors = $title->getUserPermissionsErrors( 'read', $anon, true );
+
+ // Can't diff special pages, unreadable pages or pages with no new revision
+ // to compare against: just return the text.
+ if ( $title->getNamespace() < 0 || $accErrors || !$newid ) {
+ return $completeText;
+ }
+
+ if ( $oldid ) {
+ $diffText = '';
+ // Don't bother generating the diff if we won't be able to show it
+ if ( $wgFeedDiffCutoff > 0 ) {
+ $rev = Revision::newFromId( $oldid );
+
+ if ( !$rev ) {
+ $diffText = false;
+ } else {
+ $context = clone RequestContext::getMain();
+ $context->setTitle( $title );
+
+ $contentHandler = $rev->getContentHandler();
+ $de = $contentHandler->createDifferenceEngine( $context, $oldid, $newid );
+ $diffText = $de->getDiff(
+ wfMessage( 'previousrevision' )->text(), // hack
+ wfMessage( 'revisionasof',
+ $wgLang->timeanddate( $timestamp ),
+ $wgLang->date( $timestamp ),
+ $wgLang->time( $timestamp ) )->text() );
+ }
+ }
+
+ if ( $wgFeedDiffCutoff <= 0 || ( strlen( $diffText ) > $wgFeedDiffCutoff ) ) {
+ // Omit large diffs
+ $diffText = self::getDiffLink( $title, $newid, $oldid );
+ } elseif ( $diffText === false ) {
+ // Error in diff engine, probably a missing revision
+ $diffText = "<p>Can't load revision $newid</p>";
+ } else {
+ // Diff output fine, clean up any illegal UTF-8
+ $diffText = UtfNormal\Validator::cleanUp( $diffText );
+ $diffText = self::applyDiffStyle( $diffText );
+ }
+ } else {
+ $rev = Revision::newFromId( $newid );
+ if ( $wgFeedDiffCutoff <= 0 || is_null( $rev ) ) {
+ $newContent = ContentHandler::getForTitle( $title )->makeEmptyContent();
+ } else {
+ $newContent = $rev->getContent();
+ }
+
+ if ( $newContent instanceof TextContent ) {
+ // only textual content has a "source view".
+ $text = $newContent->getNativeData();
+
+ if ( $wgFeedDiffCutoff <= 0 || strlen( $text ) > $wgFeedDiffCutoff ) {
+ $html = null;
+ } else {
+ $html = nl2br( htmlspecialchars( $text ) );
+ }
+ } else {
+ // XXX: we could get an HTML representation of the content via getParserOutput, but that may
+ // contain JS magic and generally may not be suitable for inclusion in a feed.
+ // Perhaps Content should have a getDescriptiveHtml method and/or a getSourceText method.
+ // Compare also ApiFeedContributions::feedItemDesc
+ $html = null;
+ }
+
+ if ( $html === null ) {
+ // Omit large new page diffs, T31110
+ // Also use diff link for non-textual content
+ $diffText = self::getDiffLink( $title, $newid );
+ } else {
+ $diffText = '<p><b>' . wfMessage( 'newpage' )->text() . '</b></p>' .
+ '<div>' . $html . '</div>';
+ }
+ }
+ $completeText .= $diffText;
+
+ return $completeText;
+ }
+
+ /**
+ * Generates a diff link. Used when the full diff is not wanted for example
+ * when $wgFeedDiffCutoff is 0.
+ *
+ * @param Title $title Title object: used to generate the diff URL
+ * @param int $newid Newid for this diff
+ * @param int|null $oldid Oldid for the diff. Null means it is a new article
+ * @return string
+ */
+ protected static function getDiffLink( Title $title, $newid, $oldid = null ) {
+ $queryParameters = [ 'diff' => $newid ];
+ if ( $oldid != null ) {
+ $queryParameters['oldid'] = $oldid;
+ }
+ $diffUrl = $title->getFullURL( $queryParameters );
+
+ $diffLink = Html::element( 'a', [ 'href' => $diffUrl ],
+ wfMessage( 'showdiff' )->inContentLanguage()->text() );
+
+ return $diffLink;
+ }
+
+ /**
+ * Hacky application of diff styles for the feeds.
+ * Might be 'cleaner' to use DOM or XSLT or something,
+ * but *gack* it's a pain in the ass.
+ *
+ * @param string $text Diff's HTML output
+ * @return string Modified HTML
+ */
+ public static function applyDiffStyle( $text ) {
+ $styles = [
+ 'diff' => 'background-color: white; color:black;',
+ 'diff-otitle' => 'background-color: white; color:black; text-align: center;',
+ 'diff-ntitle' => 'background-color: white; color:black; text-align: center;',
+ 'diff-addedline' => 'color:black; font-size: 88%; border-style: solid; '
+ . 'border-width: 1px 1px 1px 4px; border-radius: 0.33em; border-color: #a3d3ff; '
+ . 'vertical-align: top; white-space: pre-wrap;',
+ 'diff-deletedline' => 'color:black; font-size: 88%; border-style: solid; '
+ . 'border-width: 1px 1px 1px 4px; border-radius: 0.33em; border-color: #ffe49c; '
+ . 'vertical-align: top; white-space: pre-wrap;',
+ 'diff-context' => 'background-color: #f9f9f9; color: #333333; font-size: 88%; '
+ . 'border-style: solid; border-width: 1px 1px 1px 4px; border-radius: 0.33em; '
+ . 'border-color: #e6e6e6; vertical-align: top; white-space: pre-wrap;',
+ 'diffchange' => 'font-weight: bold; text-decoration: none;',
+ ];
+
+ foreach ( $styles as $class => $style ) {
+ $text = preg_replace( "/(<[^>]+)class=(['\"])$class\\2([^>]*>)/",
+ "\\1style=\"$style\"\\3", $text );
+ }
+
+ return $text;
+ }
+
+}
diff --git a/www/wiki/includes/FileDeleteForm.php b/www/wiki/includes/FileDeleteForm.php
new file mode 100644
index 00000000..8c843c44
--- /dev/null
+++ b/www/wiki/includes/FileDeleteForm.php
@@ -0,0 +1,436 @@
+<?php
+/**
+ * File deletion user interface.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Rob Church <robchur@gmail.com>
+ * @ingroup Media
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * File deletion user interface
+ *
+ * @ingroup Media
+ */
+class FileDeleteForm {
+
+ /**
+ * @var Title
+ */
+ private $title = null;
+
+ /**
+ * @var File
+ */
+ private $file = null;
+
+ /**
+ * @var File
+ */
+ private $oldfile = null;
+ private $oldimage = '';
+
+ /**
+ * @param File $file File object we're deleting
+ */
+ public function __construct( $file ) {
+ $this->title = $file->getTitle();
+ $this->file = $file;
+ }
+
+ /**
+ * Fulfil the request; shows the form or deletes the file,
+ * pending authentication, confirmation, etc.
+ */
+ public function execute() {
+ global $wgOut, $wgRequest, $wgUser, $wgUploadMaintenance;
+
+ $permissionErrors = $this->title->getUserPermissionsErrors( 'delete', $wgUser );
+ if ( count( $permissionErrors ) ) {
+ throw new PermissionsError( 'delete', $permissionErrors );
+ }
+
+ if ( wfReadOnly() ) {
+ throw new ReadOnlyError;
+ }
+
+ if ( $wgUploadMaintenance ) {
+ throw new ErrorPageError( 'filedelete-maintenance-title', 'filedelete-maintenance' );
+ }
+
+ $this->setHeaders();
+
+ $this->oldimage = $wgRequest->getText( 'oldimage', false );
+ $token = $wgRequest->getText( 'wpEditToken' );
+ # Flag to hide all contents of the archived revisions
+ $suppress = $wgRequest->getVal( 'wpSuppress' ) && $wgUser->isAllowed( 'suppressrevision' );
+
+ if ( $this->oldimage ) {
+ $this->oldfile = RepoGroup::singleton()->getLocalRepo()->newFromArchiveName(
+ $this->title,
+ $this->oldimage
+ );
+ }
+
+ if ( !self::haveDeletableFile( $this->file, $this->oldfile, $this->oldimage ) ) {
+ $wgOut->addHTML( $this->prepareMessage( 'filedelete-nofile' ) );
+ $wgOut->addReturnTo( $this->title );
+ return;
+ }
+
+ // Perform the deletion if appropriate
+ if ( $wgRequest->wasPosted() && $wgUser->matchEditToken( $token, $this->oldimage ) ) {
+ $deleteReasonList = $wgRequest->getText( 'wpDeleteReasonList' );
+ $deleteReason = $wgRequest->getText( 'wpReason' );
+
+ if ( $deleteReasonList == 'other' ) {
+ $reason = $deleteReason;
+ } elseif ( $deleteReason != '' ) {
+ // Entry from drop down menu + additional comment
+ $reason = $deleteReasonList . wfMessage( 'colon-separator' )
+ ->inContentLanguage()->text() . $deleteReason;
+ } else {
+ $reason = $deleteReasonList;
+ }
+
+ $status = self::doDelete(
+ $this->title,
+ $this->file,
+ $this->oldimage,
+ $reason,
+ $suppress,
+ $wgUser
+ );
+
+ if ( !$status->isGood() ) {
+ $wgOut->addHTML( '<h2>' . $this->prepareMessage( 'filedeleteerror-short' ) . "</h2>\n" );
+ $wgOut->addWikiText( '<div class="error">' .
+ $status->getWikiText( 'filedeleteerror-short', 'filedeleteerror-long' )
+ . '</div>' );
+ }
+ if ( $status->isOK() ) {
+ $wgOut->setPageTitle( wfMessage( 'actioncomplete' ) );
+ $wgOut->addHTML( $this->prepareMessage( 'filedelete-success' ) );
+ // Return to the main page if we just deleted all versions of the
+ // file, otherwise go back to the description page
+ $wgOut->addReturnTo( $this->oldimage ? $this->title : Title::newMainPage() );
+
+ WatchAction::doWatchOrUnwatch( $wgRequest->getCheck( 'wpWatch' ), $this->title, $wgUser );
+ }
+ return;
+ }
+
+ $this->showForm();
+ $this->showLogEntries();
+ }
+
+ /**
+ * Really delete the file
+ *
+ * @param Title &$title
+ * @param File &$file
+ * @param string &$oldimage Archive name
+ * @param string $reason Reason of the deletion
+ * @param bool $suppress Whether to mark all deleted versions as restricted
+ * @param User $user User object performing the request
+ * @param array $tags Tags to apply to the deletion action
+ * @throws MWException
+ * @return Status
+ */
+ public static function doDelete( &$title, &$file, &$oldimage, $reason,
+ $suppress, User $user = null, $tags = []
+ ) {
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ if ( $oldimage ) {
+ $page = null;
+ $status = $file->deleteOld( $oldimage, $reason, $suppress, $user );
+ if ( $status->ok ) {
+ // Need to do a log item
+ $logComment = wfMessage( 'deletedrevision', $oldimage )->inContentLanguage()->text();
+ if ( trim( $reason ) != '' ) {
+ $logComment .= wfMessage( 'colon-separator' )
+ ->inContentLanguage()->text() . $reason;
+ }
+
+ $logtype = $suppress ? 'suppress' : 'delete';
+
+ $logEntry = new ManualLogEntry( $logtype, 'delete' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( $title );
+ $logEntry->setComment( $logComment );
+ $logEntry->setTags( $tags );
+ $logid = $logEntry->insert();
+ $logEntry->publish( $logid );
+
+ $status->value = $logid;
+ }
+ } else {
+ $status = Status::newFatal( 'cannotdelete',
+ wfEscapeWikiText( $title->getPrefixedText() )
+ );
+ $page = WikiPage::factory( $title );
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+ // delete the associated article first
+ $error = '';
+ $deleteStatus = $page->doDeleteArticleReal( $reason, $suppress, 0, false, $error,
+ $user, $tags );
+ // doDeleteArticleReal() returns a non-fatal error status if the page
+ // or revision is missing, so check for isOK() rather than isGood()
+ if ( $deleteStatus->isOK() ) {
+ $status = $file->delete( $reason, $suppress, $user );
+ if ( $status->isOK() ) {
+ if ( $deleteStatus->value === null ) {
+ // No log ID from doDeleteArticleReal(), probably
+ // because the page/revision didn't exist, so create
+ // one here.
+ $logtype = $suppress ? 'suppress' : 'delete';
+ $logEntry = new ManualLogEntry( $logtype, 'delete' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( clone $title );
+ $logEntry->setComment( $reason );
+ $logEntry->setTags( $tags );
+ $logid = $logEntry->insert();
+ $dbw->onTransactionPreCommitOrIdle(
+ function () use ( $dbw, $logEntry, $logid ) {
+ $logEntry->publish( $logid );
+ },
+ __METHOD__
+ );
+ $status->value = $logid;
+ } else {
+ $status->value = $deleteStatus->value; // log id
+ }
+ $dbw->endAtomic( __METHOD__ );
+ } else {
+ // Page deleted but file still there? rollback page delete
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->rollbackMasterChanges( __METHOD__ );
+ }
+ } else {
+ // Done; nothing changed
+ $dbw->endAtomic( __METHOD__ );
+ }
+ }
+
+ if ( $status->isOK() ) {
+ Hooks::run( 'FileDeleteComplete', [ &$file, &$oldimage, &$page, &$user, &$reason ] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Show the confirmation form
+ */
+ private function showForm() {
+ global $wgOut, $wgUser, $wgRequest;
+
+ if ( $wgUser->isAllowed( 'suppressrevision' ) ) {
+ $suppress = "<tr id=\"wpDeleteSuppressRow\">
+ <td></td>
+ <td class='mw-input'><strong>" .
+ Xml::checkLabel( wfMessage( 'revdelete-suppress' )->text(),
+ 'wpSuppress', 'wpSuppress', false, [ 'tabindex' => '3' ] ) .
+ "</strong></td>
+ </tr>";
+ } else {
+ $suppress = '';
+ }
+
+ $checkWatch = $wgUser->getBoolOption( 'watchdeletion' ) || $wgUser->isWatched( $this->title );
+ $form = Xml::openElement( 'form', [ 'method' => 'post', 'action' => $this->getAction(),
+ 'id' => 'mw-img-deleteconfirm' ] ) .
+ Xml::openElement( 'fieldset' ) .
+ Xml::element( 'legend', null, wfMessage( 'filedelete-legend' )->text() ) .
+ Html::hidden( 'wpEditToken', $wgUser->getEditToken( $this->oldimage ) ) .
+ $this->prepareMessage( 'filedelete-intro' ) .
+ Xml::openElement( 'table', [ 'id' => 'mw-img-deleteconfirm-table' ] ) .
+ "<tr>
+ <td class='mw-label'>" .
+ Xml::label( wfMessage( 'filedelete-comment' )->text(), 'wpDeleteReasonList' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::listDropDown(
+ 'wpDeleteReasonList',
+ wfMessage( 'filedelete-reason-dropdown' )->inContentLanguage()->text(),
+ wfMessage( 'filedelete-reason-otherlist' )->inContentLanguage()->text(),
+ '',
+ 'wpReasonDropDown',
+ 1
+ ) .
+ "</td>
+ </tr>
+ <tr>
+ <td class='mw-label'>" .
+ Xml::label( wfMessage( 'filedelete-otherreason' )->text(), 'wpReason' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::input( 'wpReason', 60, $wgRequest->getText( 'wpReason' ),
+ [ 'type' => 'text', 'maxlength' => '255', 'tabindex' => '2', 'id' => 'wpReason' ] ) .
+ "</td>
+ </tr>
+ {$suppress}";
+ if ( $wgUser->isLoggedIn() ) {
+ $form .= "
+ <tr>
+ <td></td>
+ <td class='mw-input'>" .
+ Xml::checkLabel( wfMessage( 'watchthis' )->text(),
+ 'wpWatch', 'wpWatch', $checkWatch, [ 'tabindex' => '3' ] ) .
+ "</td>
+ </tr>";
+ }
+ $form .= "
+ <tr>
+ <td></td>
+ <td class='mw-submit'>" .
+ Xml::submitButton(
+ wfMessage( 'filedelete-submit' )->text(),
+ [
+ 'name' => 'mw-filedelete-submit',
+ 'id' => 'mw-filedelete-submit',
+ 'tabindex' => '4'
+ ]
+ ) .
+ "</td>
+ </tr>" .
+ Xml::closeElement( 'table' ) .
+ Xml::closeElement( 'fieldset' ) .
+ Xml::closeElement( 'form' );
+
+ if ( $wgUser->isAllowed( 'editinterface' ) ) {
+ $title = wfMessage( 'filedelete-reason-dropdown' )->inContentLanguage()->getTitle();
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ $link = $linkRenderer->makeKnownLink(
+ $title,
+ wfMessage( 'filedelete-edit-reasonlist' )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ $form .= '<p class="mw-filedelete-editreasons">' . $link . '</p>';
+ }
+
+ $wgOut->addHTML( $form );
+ }
+
+ /**
+ * Show deletion log fragments pertaining to the current file
+ */
+ private function showLogEntries() {
+ global $wgOut;
+ $deleteLogPage = new LogPage( 'delete' );
+ $wgOut->addHTML( '<h2>' . $deleteLogPage->getName()->escaped() . "</h2>\n" );
+ LogEventsList::showLogExtract( $wgOut, 'delete', $this->title );
+ }
+
+ /**
+ * Prepare a message referring to the file being deleted,
+ * showing an appropriate message depending upon whether
+ * it's a current file or an old version
+ *
+ * @param string $message Message base
+ * @return string
+ */
+ private function prepareMessage( $message ) {
+ global $wgLang;
+ if ( $this->oldimage ) {
+ # Message keys used:
+ # 'filedelete-intro-old', 'filedelete-nofile-old', 'filedelete-success-old'
+ return wfMessage(
+ "{$message}-old",
+ wfEscapeWikiText( $this->title->getText() ),
+ $wgLang->date( $this->getTimestamp(), true ),
+ $wgLang->time( $this->getTimestamp(), true ),
+ wfExpandUrl( $this->file->getArchiveUrl( $this->oldimage ), PROTO_CURRENT ) )->parseAsBlock();
+ } else {
+ return wfMessage(
+ $message,
+ wfEscapeWikiText( $this->title->getText() )
+ )->parseAsBlock();
+ }
+ }
+
+ /**
+ * Set headers, titles and other bits
+ */
+ private function setHeaders() {
+ global $wgOut;
+ $wgOut->setPageTitle( wfMessage( 'filedelete', $this->title->getText() ) );
+ $wgOut->setRobotPolicy( 'noindex,nofollow' );
+ $wgOut->addBacklinkSubtitle( $this->title );
+ }
+
+ /**
+ * Is the provided `oldimage` value valid?
+ *
+ * @param string $oldimage
+ * @return bool
+ */
+ public static function isValidOldSpec( $oldimage ) {
+ return strlen( $oldimage ) >= 16
+ && strpos( $oldimage, '/' ) === false
+ && strpos( $oldimage, '\\' ) === false;
+ }
+
+ /**
+ * Could we delete the file specified? If an `oldimage`
+ * value was provided, does it correspond to an
+ * existing, local, old version of this file?
+ *
+ * @param File &$file
+ * @param File &$oldfile
+ * @param File $oldimage
+ * @return bool
+ */
+ public static function haveDeletableFile( &$file, &$oldfile, $oldimage ) {
+ return $oldimage
+ ? $oldfile && $oldfile->exists() && $oldfile->isLocal()
+ : $file && $file->exists() && $file->isLocal();
+ }
+
+ /**
+ * Prepare the form action
+ *
+ * @return string
+ */
+ private function getAction() {
+ $q = [];
+ $q['action'] = 'delete';
+
+ if ( $this->oldimage ) {
+ $q['oldimage'] = $this->oldimage;
+ }
+
+ return $this->title->getLocalURL( $q );
+ }
+
+ /**
+ * Extract the timestamp of the old version
+ *
+ * @return string
+ */
+ private function getTimestamp() {
+ return $this->oldfile->getTimestamp();
+ }
+}
diff --git a/www/wiki/includes/ForkController.php b/www/wiki/includes/ForkController.php
new file mode 100644
index 00000000..2dde17be
--- /dev/null
+++ b/www/wiki/includes/ForkController.php
@@ -0,0 +1,204 @@
+<?php
+/**
+ * Class for managing forking command line scripts.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Class for managing forking command line scripts.
+ * Currently just does forking and process control, but it could easily be extended
+ * to provide IPC and job dispatch.
+ *
+ * This class requires the posix and pcntl extensions.
+ *
+ * @ingroup Maintenance
+ */
+class ForkController {
+ protected $children = [], $childNumber = 0;
+ protected $termReceived = false;
+ protected $flags = 0, $procsToStart = 0;
+
+ protected static $restartableSignals = [
+ SIGFPE,
+ SIGILL,
+ SIGSEGV,
+ SIGBUS,
+ SIGABRT,
+ SIGSYS,
+ SIGPIPE,
+ SIGXCPU,
+ SIGXFSZ,
+ ];
+
+ /**
+ * Pass this flag to __construct() to cause the class to automatically restart
+ * workers that exit with non-zero exit status or a signal such as SIGSEGV.
+ */
+ const RESTART_ON_ERROR = 1;
+
+ public function __construct( $numProcs, $flags = 0 ) {
+ if ( PHP_SAPI != 'cli' ) {
+ throw new MWException( "ForkController cannot be used from the web." );
+ }
+ $this->procsToStart = $numProcs;
+ $this->flags = $flags;
+ }
+
+ /**
+ * Start the child processes.
+ *
+ * This should only be called from the command line. It should be called
+ * as early as possible during execution.
+ *
+ * This will return 'child' in the child processes. In the parent process,
+ * it will run until all the child processes exit or a TERM signal is
+ * received. It will then return 'done'.
+ * @return string
+ */
+ public function start() {
+ // Trap SIGTERM
+ pcntl_signal( SIGTERM, [ $this, 'handleTermSignal' ], false );
+
+ do {
+ // Start child processes
+ if ( $this->procsToStart ) {
+ if ( $this->forkWorkers( $this->procsToStart ) == 'child' ) {
+ return 'child';
+ }
+ $this->procsToStart = 0;
+ }
+
+ // Check child status
+ $status = false;
+ $deadPid = pcntl_wait( $status );
+
+ if ( $deadPid > 0 ) {
+ // Respond to child process termination
+ unset( $this->children[$deadPid] );
+ if ( $this->flags & self::RESTART_ON_ERROR ) {
+ if ( pcntl_wifsignaled( $status ) ) {
+ // Restart if the signal was abnormal termination
+ // Don't restart if it was deliberately killed
+ $signal = pcntl_wtermsig( $status );
+ if ( in_array( $signal, self::$restartableSignals ) ) {
+ echo "Worker exited with signal $signal, restarting\n";
+ $this->procsToStart++;
+ }
+ } elseif ( pcntl_wifexited( $status ) ) {
+ // Restart on non-zero exit status
+ $exitStatus = pcntl_wexitstatus( $status );
+ if ( $exitStatus != 0 ) {
+ echo "Worker exited with status $exitStatus, restarting\n";
+ $this->procsToStart++;
+ } else {
+ echo "Worker exited normally\n";
+ }
+ }
+ }
+ // Throttle restarts
+ if ( $this->procsToStart ) {
+ usleep( 500000 );
+ }
+ }
+
+ // Run signal handlers
+ if ( function_exists( 'pcntl_signal_dispatch' ) ) {
+ pcntl_signal_dispatch();
+ } else {
+ declare( ticks = 1 ) {
+ $status = $status;
+ }
+ }
+ // Respond to TERM signal
+ if ( $this->termReceived ) {
+ foreach ( $this->children as $childPid => $unused ) {
+ posix_kill( $childPid, SIGTERM );
+ }
+ $this->termReceived = false;
+ }
+ } while ( count( $this->children ) );
+ pcntl_signal( SIGTERM, SIG_DFL );
+ return 'done';
+ }
+
+ /**
+ * Get the number of the child currently running. Note, this
+ * is not the pid, but rather which of the total number of children
+ * we are
+ * @return int
+ */
+ public function getChildNumber() {
+ return $this->childNumber;
+ }
+
+ protected function prepareEnvironment() {
+ global $wgMemc;
+ // Don't share DB, storage, or memcached connections
+ MediaWikiServices::resetChildProcessServices();
+ FileBackendGroup::destroySingleton();
+ LockManagerGroup::destroySingletons();
+ JobQueueGroup::destroySingletons();
+ ObjectCache::clear();
+ RedisConnectionPool::destroySingletons();
+ $wgMemc = null;
+ }
+
+ /**
+ * Fork a number of worker processes.
+ *
+ * @param int $numProcs
+ * @return string
+ */
+ protected function forkWorkers( $numProcs ) {
+ $this->prepareEnvironment();
+
+ // Create the child processes
+ for ( $i = 0; $i < $numProcs; $i++ ) {
+ // Do the fork
+ $pid = pcntl_fork();
+ if ( $pid === -1 || $pid === false ) {
+ echo "Error creating child processes\n";
+ exit( 1 );
+ }
+
+ if ( !$pid ) {
+ $this->initChild();
+ $this->childNumber = $i;
+ return 'child';
+ } else {
+ // This is the parent process
+ $this->children[$pid] = true;
+ }
+ }
+
+ return 'parent';
+ }
+
+ protected function initChild() {
+ global $wgMemc, $wgMainCacheType;
+ $wgMemc = wfGetCache( $wgMainCacheType );
+ $this->children = null;
+ pcntl_signal( SIGTERM, SIG_DFL );
+ }
+
+ protected function handleTermSignal( $signal ) {
+ $this->termReceived = true;
+ }
+}
diff --git a/www/wiki/includes/FormOptions.php b/www/wiki/includes/FormOptions.php
new file mode 100644
index 00000000..53c8d3bf
--- /dev/null
+++ b/www/wiki/includes/FormOptions.php
@@ -0,0 +1,422 @@
+<?php
+/**
+ * Helper class to keep track of options when mixing links and form elements.
+ *
+ * Copyright © 2008, Niklas Laxström
+ * Copyright © 2011, Antoine Musso
+ * Copyright © 2013, Bartosz Dziewoński
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @author Antoine Musso
+ */
+
+/**
+ * Helper class to keep track of options when mixing links and form elements.
+ *
+ * @todo This badly needs some examples and tests :) The usage in SpecialRecentchanges class is a
+ * good ersatz in the meantime.
+ */
+class FormOptions implements ArrayAccess {
+ /** @name Type constants
+ * Used internally to map an option value to a WebRequest accessor
+ */
+ /* @{ */
+ /** Mark value for automatic detection (for simple data types only) */
+ const AUTO = -1;
+ /** String type, maps guessType() to WebRequest::getText() */
+ const STRING = 0;
+ /** Integer type, maps guessType() to WebRequest::getInt() */
+ const INT = 1;
+ /** Float type, maps guessType() to WebRequest::getFloat()
+ * @since 1.23 */
+ const FLOAT = 4;
+ /** Boolean type, maps guessType() to WebRequest::getBool() */
+ const BOOL = 2;
+ /** Integer type or null, maps to WebRequest::getIntOrNull()
+ * This is useful for the namespace selector.
+ */
+ const INTNULL = 3;
+ /** Array type, maps guessType() to WebRequest::getArray()
+ * @since 1.29 */
+ const ARR = 5;
+ /* @} */
+
+ /**
+ * Map of known option names to information about them.
+ *
+ * Each value is an array with the following keys:
+ * - 'default' - the default value as passed to add()
+ * - 'value' - current value, start with null, can be set by various functions
+ * - 'consumed' - true/false, whether the option was consumed using
+ * consumeValue() or consumeValues()
+ * - 'type' - one of the type constants (but never AUTO)
+ */
+ protected $options = [];
+
+ # Setting up
+
+ /**
+ * Add an option to be handled by this FormOptions instance.
+ *
+ * @param string $name Request parameter name
+ * @param mixed $default Default value when the request parameter is not present
+ * @param int $type One of the type constants (optional, defaults to AUTO)
+ */
+ public function add( $name, $default, $type = self::AUTO ) {
+ $option = [];
+ $option['default'] = $default;
+ $option['value'] = null;
+ $option['consumed'] = false;
+
+ if ( $type !== self::AUTO ) {
+ $option['type'] = $type;
+ } else {
+ $option['type'] = self::guessType( $default );
+ }
+
+ $this->options[$name] = $option;
+ }
+
+ /**
+ * Remove an option being handled by this FormOptions instance. This is the inverse of add().
+ *
+ * @param string $name Request parameter name
+ */
+ public function delete( $name ) {
+ $this->validateName( $name, true );
+ unset( $this->options[$name] );
+ }
+
+ /**
+ * Used to find out which type the data is. All types are defined in the 'Type constants' section
+ * of this class.
+ *
+ * Detection of the INTNULL type is not supported; INT will be assumed if the data is an integer,
+ * MWException will be thrown if it's null.
+ *
+ * @param mixed $data Value to guess the type for
+ * @throws MWException If unable to guess the type
+ * @return int Type constant
+ */
+ public static function guessType( $data ) {
+ if ( is_bool( $data ) ) {
+ return self::BOOL;
+ } elseif ( is_int( $data ) ) {
+ return self::INT;
+ } elseif ( is_float( $data ) ) {
+ return self::FLOAT;
+ } elseif ( is_string( $data ) ) {
+ return self::STRING;
+ } elseif ( is_array( $data ) ) {
+ return self::ARR;
+ } else {
+ throw new MWException( 'Unsupported datatype' );
+ }
+ }
+
+ # Handling values
+
+ /**
+ * Verify that the given option name exists.
+ *
+ * @param string $name Option name
+ * @param bool $strict Throw an exception when the option doesn't exist instead of returning false
+ * @throws MWException
+ * @return bool True if the option exists, false otherwise
+ */
+ public function validateName( $name, $strict = false ) {
+ if ( !isset( $this->options[$name] ) ) {
+ if ( $strict ) {
+ throw new MWException( "Invalid option $name" );
+ } else {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Use to set the value of an option.
+ *
+ * @param string $name Option name
+ * @param mixed $value Value for the option
+ * @param bool $force Whether to set the value when it is equivalent to the default value for this
+ * option (default false).
+ */
+ public function setValue( $name, $value, $force = false ) {
+ $this->validateName( $name, true );
+
+ if ( !$force && $value === $this->options[$name]['default'] ) {
+ // null default values as unchanged
+ $this->options[$name]['value'] = null;
+ } else {
+ $this->options[$name]['value'] = $value;
+ }
+ }
+
+ /**
+ * Get the value for the given option name. Uses getValueReal() internally.
+ *
+ * @param string $name Option name
+ * @return mixed
+ */
+ public function getValue( $name ) {
+ $this->validateName( $name, true );
+
+ return $this->getValueReal( $this->options[$name] );
+ }
+
+ /**
+ * Return current option value, based on a structure taken from $options.
+ *
+ * @param array $option Array structure describing the option
+ * @return mixed Value, or the default value if it is null
+ */
+ protected function getValueReal( $option ) {
+ if ( $option['value'] !== null ) {
+ return $option['value'];
+ } else {
+ return $option['default'];
+ }
+ }
+
+ /**
+ * Delete the option value.
+ * This will make future calls to getValue() return the default value.
+ * @param string $name Option name
+ */
+ public function reset( $name ) {
+ $this->validateName( $name, true );
+ $this->options[$name]['value'] = null;
+ }
+
+ /**
+ * Get the value of given option and mark it as 'consumed'. Consumed options are not returned
+ * by getUnconsumedValues().
+ *
+ * @see consumeValues()
+ * @throws MWException If the option does not exist
+ * @param string $name Option name
+ * @return mixed Value, or the default value if it is null
+ */
+ public function consumeValue( $name ) {
+ $this->validateName( $name, true );
+ $this->options[$name]['consumed'] = true;
+
+ return $this->getValueReal( $this->options[$name] );
+ }
+
+ /**
+ * Get the values of given options and mark them as 'consumed'. Consumed options are not returned
+ * by getUnconsumedValues().
+ *
+ * @see consumeValue()
+ * @throws MWException If any option does not exist
+ * @param array $names Array of option names as strings
+ * @return array Array of option values, or the default values if they are null
+ */
+ public function consumeValues( $names ) {
+ $out = [];
+
+ foreach ( $names as $name ) {
+ $this->validateName( $name, true );
+ $this->options[$name]['consumed'] = true;
+ $out[] = $this->getValueReal( $this->options[$name] );
+ }
+
+ return $out;
+ }
+
+ /**
+ * @see validateBounds()
+ * @param string $name
+ * @param int $min
+ * @param int $max
+ */
+ public function validateIntBounds( $name, $min, $max ) {
+ $this->validateBounds( $name, $min, $max );
+ }
+
+ /**
+ * Constrain a numeric value for a given option to a given range. The value will be altered to fit
+ * in the range.
+ *
+ * @since 1.23
+ *
+ * @param string $name Option name
+ * @param int|float $min Minimum value
+ * @param int|float $max Maximum value
+ * @throws MWException If option is not of type INT
+ */
+ public function validateBounds( $name, $min, $max ) {
+ $this->validateName( $name, true );
+ $type = $this->options[$name]['type'];
+
+ if ( $type !== self::INT && $type !== self::FLOAT ) {
+ throw new MWException( "Option $name is not of type INT or FLOAT" );
+ }
+
+ $value = $this->getValueReal( $this->options[$name] );
+ $value = max( $min, min( $max, $value ) );
+
+ $this->setValue( $name, $value );
+ }
+
+ /**
+ * Get all remaining values which have not been consumed by consumeValue() or consumeValues().
+ *
+ * @param bool $all Whether to include unchanged options (default: false)
+ * @return array
+ */
+ public function getUnconsumedValues( $all = false ) {
+ $values = [];
+
+ foreach ( $this->options as $name => $data ) {
+ if ( !$data['consumed'] ) {
+ if ( $all || $data['value'] !== null ) {
+ $values[$name] = $this->getValueReal( $data );
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Return options modified as an array ( name => value )
+ * @return array
+ */
+ public function getChangedValues() {
+ $values = [];
+
+ foreach ( $this->options as $name => $data ) {
+ if ( $data['value'] !== null ) {
+ $values[$name] = $data['value'];
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Format options to an array ( name => value )
+ * @return array
+ */
+ public function getAllValues() {
+ $values = [];
+
+ foreach ( $this->options as $name => $data ) {
+ $values[$name] = $this->getValueReal( $data );
+ }
+
+ return $values;
+ }
+
+ # Reading values
+
+ /**
+ * Fetch values for all options (or selected options) from the given WebRequest, making them
+ * available for accessing with getValue() or consumeValue() etc.
+ *
+ * @param WebRequest $r The request to fetch values from
+ * @param array $optionKeys Which options to fetch the values for (default:
+ * all of them). Note that passing an empty array will also result in
+ * values for all keys being fetched.
+ * @throws MWException If the type of any option is invalid
+ */
+ public function fetchValuesFromRequest( WebRequest $r, $optionKeys = null ) {
+ if ( !$optionKeys ) {
+ $optionKeys = array_keys( $this->options );
+ }
+
+ foreach ( $optionKeys as $name ) {
+ $default = $this->options[$name]['default'];
+ $type = $this->options[$name]['type'];
+
+ switch ( $type ) {
+ case self::BOOL:
+ $value = $r->getBool( $name, $default );
+ break;
+ case self::INT:
+ $value = $r->getInt( $name, $default );
+ break;
+ case self::FLOAT:
+ $value = $r->getFloat( $name, $default );
+ break;
+ case self::STRING:
+ $value = $r->getText( $name, $default );
+ break;
+ case self::INTNULL:
+ $value = $r->getIntOrNull( $name );
+ break;
+ case self::ARR:
+ $value = $r->getArray( $name );
+ break;
+ default:
+ throw new MWException( 'Unsupported datatype' );
+ }
+
+ if ( $value !== null ) {
+ $this->options[$name]['value'] = $value === $default ? null : $value;
+ }
+ }
+ }
+
+ /** @name ArrayAccess functions
+ * These functions implement the ArrayAccess PHP interface.
+ * @see https://secure.php.net/manual/en/class.arrayaccess.php
+ */
+ /* @{ */
+ /**
+ * Whether the option exists.
+ * @param string $name
+ * @return bool
+ */
+ public function offsetExists( $name ) {
+ return isset( $this->options[$name] );
+ }
+
+ /**
+ * Retrieve an option value.
+ * @param string $name
+ * @return mixed
+ */
+ public function offsetGet( $name ) {
+ return $this->getValue( $name );
+ }
+
+ /**
+ * Set an option to given value.
+ * @param string $name
+ * @param mixed $value
+ */
+ public function offsetSet( $name, $value ) {
+ $this->setValue( $name, $value );
+ }
+
+ /**
+ * Delete the option.
+ * @param string $name
+ */
+ public function offsetUnset( $name ) {
+ $this->delete( $name );
+ }
+ /* @} */
+}
diff --git a/www/wiki/includes/GitInfo.php b/www/wiki/includes/GitInfo.php
new file mode 100644
index 00000000..4351acc0
--- /dev/null
+++ b/www/wiki/includes/GitInfo.php
@@ -0,0 +1,402 @@
+<?php
+/**
+ * A class to help return information about a git repo MediaWiki may be inside
+ * This is used by Special:Version and is also useful for the LocalSettings.php
+ * of anyone working on large branches in git to setup config that show up only
+ * when specific branches are currently checked out.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class GitInfo {
+
+ /**
+ * Singleton for the repo at $IP
+ */
+ protected static $repo = null;
+
+ /**
+ * Location of the .git directory
+ */
+ protected $basedir;
+
+ /**
+ * Path to JSON cache file for pre-computed git information.
+ */
+ protected $cacheFile;
+
+ /**
+ * Cached git information.
+ */
+ protected $cache = [];
+
+ /**
+ * @var array|false Map of repo URLs to viewer URLs. Access via static method getViewers().
+ */
+ private static $viewers = false;
+
+ /**
+ * @param string $repoDir The root directory of the repo where .git can be found
+ * @param bool $usePrecomputed Use precomputed information if available
+ * @see precomputeValues
+ */
+ public function __construct( $repoDir, $usePrecomputed = true ) {
+ $this->cacheFile = self::getCacheFilePath( $repoDir );
+ wfDebugLog( 'gitinfo',
+ "Computed cacheFile={$this->cacheFile} for {$repoDir}"
+ );
+ if ( $usePrecomputed &&
+ $this->cacheFile !== null &&
+ is_readable( $this->cacheFile )
+ ) {
+ $this->cache = FormatJson::decode(
+ file_get_contents( $this->cacheFile ),
+ true
+ );
+ wfDebugLog( 'gitinfo', "Loaded git data from cache for {$repoDir}" );
+ }
+
+ if ( !$this->cacheIsComplete() ) {
+ wfDebugLog( 'gitinfo', "Cache incomplete for {$repoDir}" );
+ $this->basedir = $repoDir . DIRECTORY_SEPARATOR . '.git';
+ if ( is_readable( $this->basedir ) && !is_dir( $this->basedir ) ) {
+ $GITfile = file_get_contents( $this->basedir );
+ if ( strlen( $GITfile ) > 8 &&
+ substr( $GITfile, 0, 8 ) === 'gitdir: '
+ ) {
+ $path = rtrim( substr( $GITfile, 8 ), "\r\n" );
+ if ( $path[0] === '/' || substr( $path, 1, 1 ) === ':' ) {
+ // Path from GITfile is absolute
+ $this->basedir = $path;
+ } else {
+ $this->basedir = $repoDir . DIRECTORY_SEPARATOR . $path;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Compute the path to the cache file for a given directory.
+ *
+ * @param string $repoDir The root directory of the repo where .git can be found
+ * @return string Path to GitInfo cache file in $wgGitInfoCacheDirectory or
+ * fallback in the extension directory itself
+ * @since 1.24
+ */
+ protected static function getCacheFilePath( $repoDir ) {
+ global $IP, $wgGitInfoCacheDirectory;
+
+ if ( $wgGitInfoCacheDirectory ) {
+ // Convert both $IP and $repoDir to canonical paths to protect against
+ // $IP having changed between the settings files and runtime.
+ $realIP = realpath( $IP );
+ $repoName = realpath( $repoDir );
+ if ( $repoName === false ) {
+ // Unit tests use fake path names
+ $repoName = $repoDir;
+ }
+ if ( strpos( $repoName, $realIP ) === 0 ) {
+ // Strip $IP from path
+ $repoName = substr( $repoName, strlen( $realIP ) );
+ }
+ // Transform path to git repo to something we can safely embed in
+ // a filename
+ $repoName = strtr( $repoName, DIRECTORY_SEPARATOR, '-' );
+ $fileName = 'info' . $repoName . '.json';
+ $cachePath = "{$wgGitInfoCacheDirectory}/{$fileName}";
+ if ( is_readable( $cachePath ) ) {
+ return $cachePath;
+ }
+ }
+
+ return "$repoDir/gitinfo.json";
+ }
+
+ /**
+ * Get the singleton for the repo at $IP
+ *
+ * @return GitInfo
+ */
+ public static function repo() {
+ if ( is_null( self::$repo ) ) {
+ global $IP;
+ self::$repo = new self( $IP );
+ }
+ return self::$repo;
+ }
+
+ /**
+ * Check if a string looks like a hex encoded SHA1 hash
+ *
+ * @param string $str The string to check
+ * @return bool Whether or not the string looks like a SHA1
+ */
+ public static function isSHA1( $str ) {
+ return !!preg_match( '/^[0-9A-F]{40}$/i', $str );
+ }
+
+ /**
+ * Get the HEAD of the repo (without any opening "ref: ")
+ *
+ * @return string|bool The HEAD (git reference or SHA1) or false
+ */
+ public function getHead() {
+ if ( !isset( $this->cache['head'] ) ) {
+ $headFile = "{$this->basedir}/HEAD";
+ $head = false;
+
+ if ( is_readable( $headFile ) ) {
+ $head = file_get_contents( $headFile );
+
+ if ( preg_match( "/ref: (.*)/", $head, $m ) ) {
+ $head = rtrim( $m[1] );
+ } else {
+ $head = rtrim( $head );
+ }
+ }
+ $this->cache['head'] = $head;
+ }
+ return $this->cache['head'];
+ }
+
+ /**
+ * Get the SHA1 for the current HEAD of the repo
+ *
+ * @return string|bool A SHA1 or false
+ */
+ public function getHeadSHA1() {
+ if ( !isset( $this->cache['headSHA1'] ) ) {
+ $head = $this->getHead();
+ $sha1 = false;
+
+ // If detached HEAD may be a SHA1
+ if ( self::isSHA1( $head ) ) {
+ $sha1 = $head;
+ } else {
+ // If not a SHA1 it may be a ref:
+ $refFile = "{$this->basedir}/{$head}";
+ if ( is_readable( $refFile ) ) {
+ $sha1 = rtrim( file_get_contents( $refFile ) );
+ }
+ }
+ $this->cache['headSHA1'] = $sha1;
+ }
+ return $this->cache['headSHA1'];
+ }
+
+ /**
+ * Get the commit date of HEAD entry of the git code repository
+ *
+ * @since 1.22
+ * @return int|bool Commit date (UNIX timestamp) or false
+ */
+ public function getHeadCommitDate() {
+ global $wgGitBin;
+
+ if ( !isset( $this->cache['headCommitDate'] ) ) {
+ $date = false;
+ if ( is_file( $wgGitBin ) &&
+ is_executable( $wgGitBin ) &&
+ $this->getHead() !== false
+ ) {
+ $environment = [ "GIT_DIR" => $this->basedir ];
+ $cmd = wfEscapeShellArg( $wgGitBin ) .
+ " show -s --format=format:%ct HEAD";
+ $retc = false;
+ $commitDate = wfShellExec( $cmd, $retc, $environment );
+ if ( $retc === 0 ) {
+ $date = (int)$commitDate;
+ }
+ }
+ $this->cache['headCommitDate'] = $date;
+ }
+ return $this->cache['headCommitDate'];
+ }
+
+ /**
+ * Get the name of the current branch, or HEAD if not found
+ *
+ * @return string|bool The branch name, HEAD, or false
+ */
+ public function getCurrentBranch() {
+ if ( !isset( $this->cache['branch'] ) ) {
+ $branch = $this->getHead();
+ if ( $branch &&
+ preg_match( "#^refs/heads/(.*)$#", $branch, $m )
+ ) {
+ $branch = $m[1];
+ }
+ $this->cache['branch'] = $branch;
+ }
+ return $this->cache['branch'];
+ }
+
+ /**
+ * Get an URL to a web viewer link to the HEAD revision.
+ *
+ * @return string|bool String if a URL is available or false otherwise
+ */
+ public function getHeadViewUrl() {
+ $url = $this->getRemoteUrl();
+ if ( $url === false ) {
+ return false;
+ }
+ foreach ( self::getViewers() as $repo => $viewer ) {
+ $pattern = '#^' . $repo . '$#';
+ if ( preg_match( $pattern, $url, $matches ) ) {
+ $viewerUrl = preg_replace( $pattern, $viewer, $url );
+ $headSHA1 = $this->getHeadSHA1();
+ $replacements = [
+ '%h' => substr( $headSHA1, 0, 7 ),
+ '%H' => $headSHA1,
+ '%r' => urlencode( $matches[1] ),
+ '%R' => $matches[1],
+ ];
+ return strtr( $viewerUrl, $replacements );
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get the URL of the remote origin.
+ * @return string|bool String if a URL is available or false otherwise.
+ */
+ protected function getRemoteUrl() {
+ if ( !isset( $this->cache['remoteURL'] ) ) {
+ $config = "{$this->basedir}/config";
+ $url = false;
+ if ( is_readable( $config ) ) {
+ MediaWiki\suppressWarnings();
+ $configArray = parse_ini_file( $config, true );
+ MediaWiki\restoreWarnings();
+ $remote = false;
+
+ // Use the "origin" remote repo if available or any other repo if not.
+ if ( isset( $configArray['remote origin'] ) ) {
+ $remote = $configArray['remote origin'];
+ } elseif ( is_array( $configArray ) ) {
+ foreach ( $configArray as $sectionName => $sectionConf ) {
+ if ( substr( $sectionName, 0, 6 ) == 'remote' ) {
+ $remote = $sectionConf;
+ }
+ }
+ }
+
+ if ( $remote !== false && isset( $remote['url'] ) ) {
+ $url = $remote['url'];
+ }
+ }
+ $this->cache['remoteURL'] = $url;
+ }
+ return $this->cache['remoteURL'];
+ }
+
+ /**
+ * Check to see if the current cache is fully populated.
+ *
+ * Note: This method is public only to make unit testing easier. There's
+ * really no strong reason that anything other than a test should want to
+ * call this method.
+ *
+ * @return bool True if all expected cache keys exist, false otherwise
+ */
+ public function cacheIsComplete() {
+ return isset( $this->cache['head'] ) &&
+ isset( $this->cache['headSHA1'] ) &&
+ isset( $this->cache['headCommitDate'] ) &&
+ isset( $this->cache['branch'] ) &&
+ isset( $this->cache['remoteURL'] );
+ }
+
+ /**
+ * Precompute and cache git information.
+ *
+ * Creates a JSON file in the cache directory associated with this
+ * GitInfo instance. This cache file will be used by subsequent GitInfo objects referencing
+ * the same directory to avoid needing to examine the .git directory again.
+ *
+ * @since 1.24
+ */
+ public function precomputeValues() {
+ if ( $this->cacheFile !== null ) {
+ // Try to completely populate the cache
+ $this->getHead();
+ $this->getHeadSHA1();
+ $this->getHeadCommitDate();
+ $this->getCurrentBranch();
+ $this->getRemoteUrl();
+
+ if ( !$this->cacheIsComplete() ) {
+ wfDebugLog( 'gitinfo',
+ "Failed to compute GitInfo for \"{$this->basedir}\""
+ );
+ return;
+ }
+
+ $cacheDir = dirname( $this->cacheFile );
+ if ( !file_exists( $cacheDir ) &&
+ !wfMkdirParents( $cacheDir, null, __METHOD__ )
+ ) {
+ throw new MWException( "Unable to create GitInfo cache \"{$cacheDir}\"" );
+ }
+
+ file_put_contents( $this->cacheFile, FormatJson::encode( $this->cache ) );
+ }
+ }
+
+ /**
+ * @see self::getHeadSHA1
+ * @return string
+ */
+ public static function headSHA1() {
+ return self::repo()->getHeadSHA1();
+ }
+
+ /**
+ * @see self::getCurrentBranch
+ * @return string
+ */
+ public static function currentBranch() {
+ return self::repo()->getCurrentBranch();
+ }
+
+ /**
+ * @see self::getHeadViewUrl()
+ * @return bool|string
+ */
+ public static function headViewUrl() {
+ return self::repo()->getHeadViewUrl();
+ }
+
+ /**
+ * Gets the list of repository viewers
+ * @return array
+ */
+ protected static function getViewers() {
+ global $wgGitRepositoryViewers;
+
+ if ( self::$viewers === false ) {
+ self::$viewers = $wgGitRepositoryViewers;
+ Hooks::run( 'GitViewers', [ &self::$viewers ] );
+ }
+
+ return self::$viewers;
+ }
+}
diff --git a/www/wiki/includes/GlobalFunctions.php b/www/wiki/includes/GlobalFunctions.php
new file mode 100644
index 00000000..e80ecf1c
--- /dev/null
+++ b/www/wiki/includes/GlobalFunctions.php
@@ -0,0 +1,3495 @@
+<?php
+/**
+ * Global functions used everywhere.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+if ( !defined( 'MEDIAWIKI' ) ) {
+ die( "This file is part of MediaWiki, it is not a valid entry point" );
+}
+
+use Liuggio\StatsdClient\Sender\SocketSender;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\ProcOpenError;
+use MediaWiki\Session\SessionManager;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\Shell;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Rdbms\DBReplicationWaitError;
+
+// Hide compatibility functions from Doxygen
+/// @cond
+/**
+ * Compatibility functions
+ *
+ * We support PHP 5.5.9 and up.
+ * Re-implementations of newer functions or functions in non-standard
+ * PHP extensions may be included here.
+ */
+
+// hash_equals function only exists in PHP >= 5.6.0
+// https://secure.php.net/hash_equals
+if ( !function_exists( 'hash_equals' ) ) {
+ /**
+ * Check whether a user-provided string is equal to a fixed-length secret string
+ * without revealing bytes of the secret string through timing differences.
+ *
+ * The usual way to compare strings (PHP's === operator or the underlying memcmp()
+ * function in C) is to compare corresponding bytes and stop at the first difference,
+ * which would take longer for a partial match than for a complete mismatch. This
+ * is not secure when one of the strings (e.g. an HMAC or token) must remain secret
+ * and the other may come from an attacker. Statistical analysis of timing measurements
+ * over many requests may allow the attacker to guess the string's bytes one at a time
+ * (and check his guesses) even if the timing differences are extremely small.
+ *
+ * When making such a security-sensitive comparison, it is essential that the sequence
+ * in which instructions are executed and memory locations are accessed not depend on
+ * the secret string's value. HOWEVER, for simplicity, we do not attempt to minimize
+ * the inevitable leakage of the string's length. That is generally known anyway as
+ * a chararacteristic of the hash function used to compute the secret value.
+ *
+ * Longer explanation: http://www.emerose.com/timing-attacks-explained
+ *
+ * @codeCoverageIgnore
+ * @param string $known_string Fixed-length secret string to compare against
+ * @param string $user_string User-provided string
+ * @return bool True if the strings are the same, false otherwise
+ */
+ function hash_equals( $known_string, $user_string ) {
+ // Strict type checking as in PHP's native implementation
+ if ( !is_string( $known_string ) ) {
+ trigger_error( 'hash_equals(): Expected known_string to be a string, ' .
+ gettype( $known_string ) . ' given', E_USER_WARNING );
+
+ return false;
+ }
+
+ if ( !is_string( $user_string ) ) {
+ trigger_error( 'hash_equals(): Expected user_string to be a string, ' .
+ gettype( $user_string ) . ' given', E_USER_WARNING );
+
+ return false;
+ }
+
+ $known_string_len = strlen( $known_string );
+ if ( $known_string_len !== strlen( $user_string ) ) {
+ return false;
+ }
+
+ $result = 0;
+ for ( $i = 0; $i < $known_string_len; $i++ ) {
+ $result |= ord( $known_string[$i] ) ^ ord( $user_string[$i] );
+ }
+
+ return ( $result === 0 );
+ }
+}
+/// @endcond
+
+/**
+ * Load an extension
+ *
+ * This queues an extension to be loaded through
+ * the ExtensionRegistry system.
+ *
+ * @param string $ext Name of the extension to load
+ * @param string|null $path Absolute path of where to find the extension.json file
+ * @since 1.25
+ */
+function wfLoadExtension( $ext, $path = null ) {
+ if ( !$path ) {
+ global $wgExtensionDirectory;
+ $path = "$wgExtensionDirectory/$ext/extension.json";
+ }
+ ExtensionRegistry::getInstance()->queue( $path );
+}
+
+/**
+ * Load multiple extensions at once
+ *
+ * Same as wfLoadExtension, but more efficient if you
+ * are loading multiple extensions.
+ *
+ * If you want to specify custom paths, you should interact with
+ * ExtensionRegistry directly.
+ *
+ * @see wfLoadExtension
+ * @param string[] $exts Array of extension names to load
+ * @since 1.25
+ */
+function wfLoadExtensions( array $exts ) {
+ global $wgExtensionDirectory;
+ $registry = ExtensionRegistry::getInstance();
+ foreach ( $exts as $ext ) {
+ $registry->queue( "$wgExtensionDirectory/$ext/extension.json" );
+ }
+}
+
+/**
+ * Load a skin
+ *
+ * @see wfLoadExtension
+ * @param string $skin Name of the extension to load
+ * @param string|null $path Absolute path of where to find the skin.json file
+ * @since 1.25
+ */
+function wfLoadSkin( $skin, $path = null ) {
+ if ( !$path ) {
+ global $wgStyleDirectory;
+ $path = "$wgStyleDirectory/$skin/skin.json";
+ }
+ ExtensionRegistry::getInstance()->queue( $path );
+}
+
+/**
+ * Load multiple skins at once
+ *
+ * @see wfLoadExtensions
+ * @param string[] $skins Array of extension names to load
+ * @since 1.25
+ */
+function wfLoadSkins( array $skins ) {
+ global $wgStyleDirectory;
+ $registry = ExtensionRegistry::getInstance();
+ foreach ( $skins as $skin ) {
+ $registry->queue( "$wgStyleDirectory/$skin/skin.json" );
+ }
+}
+
+/**
+ * Like array_diff( $a, $b ) except that it works with two-dimensional arrays.
+ * @param array $a
+ * @param array $b
+ * @return array
+ */
+function wfArrayDiff2( $a, $b ) {
+ return array_udiff( $a, $b, 'wfArrayDiff2_cmp' );
+}
+
+/**
+ * @param array|string $a
+ * @param array|string $b
+ * @return int
+ */
+function wfArrayDiff2_cmp( $a, $b ) {
+ if ( is_string( $a ) && is_string( $b ) ) {
+ return strcmp( $a, $b );
+ } elseif ( count( $a ) !== count( $b ) ) {
+ return count( $a ) < count( $b ) ? -1 : 1;
+ } else {
+ reset( $a );
+ reset( $b );
+ while ( ( list( , $valueA ) = each( $a ) ) && ( list( , $valueB ) = each( $b ) ) ) {
+ $cmp = strcmp( $valueA, $valueB );
+ if ( $cmp !== 0 ) {
+ return $cmp;
+ }
+ }
+ return 0;
+ }
+}
+
+/**
+ * Like array_filter with ARRAY_FILTER_USE_BOTH, but works pre-5.6.
+ *
+ * @param array $arr
+ * @param callable $callback Will be called with the array value and key (in that order) and
+ * should return a bool which will determine whether the array element is kept.
+ * @return array
+ */
+function wfArrayFilter( array $arr, callable $callback ) {
+ if ( defined( 'ARRAY_FILTER_USE_BOTH' ) ) {
+ return array_filter( $arr, $callback, ARRAY_FILTER_USE_BOTH );
+ }
+ $filteredKeys = array_filter( array_keys( $arr ), function ( $key ) use ( $arr, $callback ) {
+ return call_user_func( $callback, $arr[$key], $key );
+ } );
+ return array_intersect_key( $arr, array_fill_keys( $filteredKeys, true ) );
+}
+
+/**
+ * Like array_filter with ARRAY_FILTER_USE_KEY, but works pre-5.6.
+ *
+ * @param array $arr
+ * @param callable $callback Will be called with the array key and should return a bool which
+ * will determine whether the array element is kept.
+ * @return array
+ */
+function wfArrayFilterByKey( array $arr, callable $callback ) {
+ return wfArrayFilter( $arr, function ( $val, $key ) use ( $callback ) {
+ return call_user_func( $callback, $key );
+ } );
+}
+
+/**
+ * Appends to second array if $value differs from that in $default
+ *
+ * @param string|int $key
+ * @param mixed $value
+ * @param mixed $default
+ * @param array &$changed Array to alter
+ * @throws MWException
+ */
+function wfAppendToArrayIfNotDefault( $key, $value, $default, &$changed ) {
+ if ( is_null( $changed ) ) {
+ throw new MWException( 'GlobalFunctions::wfAppendToArrayIfNotDefault got null' );
+ }
+ if ( $default[$key] !== $value ) {
+ $changed[$key] = $value;
+ }
+}
+
+/**
+ * Merge arrays in the style of getUserPermissionsErrors, with duplicate removal
+ * e.g.
+ * wfMergeErrorArrays(
+ * [ [ 'x' ] ],
+ * [ [ 'x', '2' ] ],
+ * [ [ 'x' ] ],
+ * [ [ 'y' ] ]
+ * );
+ * returns:
+ * [
+ * [ 'x', '2' ],
+ * [ 'x' ],
+ * [ 'y' ]
+ * ]
+ *
+ * @param array $array1,...
+ * @return array
+ */
+function wfMergeErrorArrays( /*...*/ ) {
+ $args = func_get_args();
+ $out = [];
+ foreach ( $args as $errors ) {
+ foreach ( $errors as $params ) {
+ $originalParams = $params;
+ if ( $params[0] instanceof MessageSpecifier ) {
+ $msg = $params[0];
+ $params = array_merge( [ $msg->getKey() ], $msg->getParams() );
+ }
+ # @todo FIXME: Sometimes get nested arrays for $params,
+ # which leads to E_NOTICEs
+ $spec = implode( "\t", $params );
+ $out[$spec] = $originalParams;
+ }
+ }
+ return array_values( $out );
+}
+
+/**
+ * Insert array into another array after the specified *KEY*
+ *
+ * @param array $array The array.
+ * @param array $insert The array to insert.
+ * @param mixed $after The key to insert after
+ * @return array
+ */
+function wfArrayInsertAfter( array $array, array $insert, $after ) {
+ // Find the offset of the element to insert after.
+ $keys = array_keys( $array );
+ $offsetByKey = array_flip( $keys );
+
+ $offset = $offsetByKey[$after];
+
+ // Insert at the specified offset
+ $before = array_slice( $array, 0, $offset + 1, true );
+ $after = array_slice( $array, $offset + 1, count( $array ) - $offset, true );
+
+ $output = $before + $insert + $after;
+
+ return $output;
+}
+
+/**
+ * Recursively converts the parameter (an object) to an array with the same data
+ *
+ * @param object|array $objOrArray
+ * @param bool $recursive
+ * @return array
+ */
+function wfObjectToArray( $objOrArray, $recursive = true ) {
+ $array = [];
+ if ( is_object( $objOrArray ) ) {
+ $objOrArray = get_object_vars( $objOrArray );
+ }
+ foreach ( $objOrArray as $key => $value ) {
+ if ( $recursive && ( is_object( $value ) || is_array( $value ) ) ) {
+ $value = wfObjectToArray( $value );
+ }
+
+ $array[$key] = $value;
+ }
+
+ return $array;
+}
+
+/**
+ * Get a random decimal value between 0 and 1, in a way
+ * not likely to give duplicate values for any realistic
+ * number of articles.
+ *
+ * @note This is designed for use in relation to Special:RandomPage
+ * and the page_random database field.
+ *
+ * @return string
+ */
+function wfRandom() {
+ // The maximum random value is "only" 2^31-1, so get two random
+ // values to reduce the chance of dupes
+ $max = mt_getrandmax() + 1;
+ $rand = number_format( ( mt_rand() * $max + mt_rand() ) / $max / $max, 12, '.', '' );
+ return $rand;
+}
+
+/**
+ * Get a random string containing a number of pseudo-random hex characters.
+ *
+ * @note This is not secure, if you are trying to generate some sort
+ * of token please use MWCryptRand instead.
+ *
+ * @param int $length The length of the string to generate
+ * @return string
+ * @since 1.20
+ */
+function wfRandomString( $length = 32 ) {
+ $str = '';
+ for ( $n = 0; $n < $length; $n += 7 ) {
+ $str .= sprintf( '%07x', mt_rand() & 0xfffffff );
+ }
+ return substr( $str, 0, $length );
+}
+
+/**
+ * We want some things to be included as literal characters in our title URLs
+ * for prettiness, which urlencode encodes by default. According to RFC 1738,
+ * all of the following should be safe:
+ *
+ * ;:@&=$-_.+!*'(),
+ *
+ * RFC 1738 says ~ is unsafe, however RFC 3986 considers it an unreserved
+ * character which should not be encoded. More importantly, google chrome
+ * always converts %7E back to ~, and converting it in this function can
+ * cause a redirect loop (T105265).
+ *
+ * But + is not safe because it's used to indicate a space; &= are only safe in
+ * paths and not in queries (and we don't distinguish here); ' seems kind of
+ * scary; and urlencode() doesn't touch -_. to begin with. Plus, although /
+ * is reserved, we don't care. So the list we unescape is:
+ *
+ * ;:@$!*(),/~
+ *
+ * However, IIS7 redirects fail when the url contains a colon (see T24709),
+ * so no fancy : for IIS7.
+ *
+ * %2F in the page titles seems to fatally break for some reason.
+ *
+ * @param string $s
+ * @return string
+ */
+function wfUrlencode( $s ) {
+ static $needle;
+
+ if ( is_null( $s ) ) {
+ $needle = null;
+ return '';
+ }
+
+ if ( is_null( $needle ) ) {
+ $needle = [ '%3B', '%40', '%24', '%21', '%2A', '%28', '%29', '%2C', '%2F', '%7E' ];
+ if ( !isset( $_SERVER['SERVER_SOFTWARE'] ) ||
+ ( strpos( $_SERVER['SERVER_SOFTWARE'], 'Microsoft-IIS/7' ) === false )
+ ) {
+ $needle[] = '%3A';
+ }
+ }
+
+ $s = urlencode( $s );
+ $s = str_ireplace(
+ $needle,
+ [ ';', '@', '$', '!', '*', '(', ')', ',', '/', '~', ':' ],
+ $s
+ );
+
+ return $s;
+}
+
+/**
+ * This function takes one or two arrays as input, and returns a CGI-style string, e.g.
+ * "days=7&limit=100". Options in the first array override options in the second.
+ * Options set to null or false will not be output.
+ *
+ * @param array $array1 ( String|Array )
+ * @param array|null $array2 ( String|Array )
+ * @param string $prefix
+ * @return string
+ */
+function wfArrayToCgi( $array1, $array2 = null, $prefix = '' ) {
+ if ( !is_null( $array2 ) ) {
+ $array1 = $array1 + $array2;
+ }
+
+ $cgi = '';
+ foreach ( $array1 as $key => $value ) {
+ if ( !is_null( $value ) && $value !== false ) {
+ if ( $cgi != '' ) {
+ $cgi .= '&';
+ }
+ if ( $prefix !== '' ) {
+ $key = $prefix . "[$key]";
+ }
+ if ( is_array( $value ) ) {
+ $firstTime = true;
+ foreach ( $value as $k => $v ) {
+ $cgi .= $firstTime ? '' : '&';
+ if ( is_array( $v ) ) {
+ $cgi .= wfArrayToCgi( $v, null, $key . "[$k]" );
+ } else {
+ $cgi .= urlencode( $key . "[$k]" ) . '=' . urlencode( $v );
+ }
+ $firstTime = false;
+ }
+ } else {
+ if ( is_object( $value ) ) {
+ $value = $value->__toString();
+ }
+ $cgi .= urlencode( $key ) . '=' . urlencode( $value );
+ }
+ }
+ }
+ return $cgi;
+}
+
+/**
+ * This is the logical opposite of wfArrayToCgi(): it accepts a query string as
+ * its argument and returns the same string in array form. This allows compatibility
+ * with legacy functions that accept raw query strings instead of nice
+ * arrays. Of course, keys and values are urldecode()d.
+ *
+ * @param string $query Query string
+ * @return string[] Array version of input
+ */
+function wfCgiToArray( $query ) {
+ if ( isset( $query[0] ) && $query[0] == '?' ) {
+ $query = substr( $query, 1 );
+ }
+ $bits = explode( '&', $query );
+ $ret = [];
+ foreach ( $bits as $bit ) {
+ if ( $bit === '' ) {
+ continue;
+ }
+ if ( strpos( $bit, '=' ) === false ) {
+ // Pieces like &qwerty become 'qwerty' => '' (at least this is what php does)
+ $key = $bit;
+ $value = '';
+ } else {
+ list( $key, $value ) = explode( '=', $bit );
+ }
+ $key = urldecode( $key );
+ $value = urldecode( $value );
+ if ( strpos( $key, '[' ) !== false ) {
+ $keys = array_reverse( explode( '[', $key ) );
+ $key = array_pop( $keys );
+ $temp = $value;
+ foreach ( $keys as $k ) {
+ $k = substr( $k, 0, -1 );
+ $temp = [ $k => $temp ];
+ }
+ if ( isset( $ret[$key] ) ) {
+ $ret[$key] = array_merge( $ret[$key], $temp );
+ } else {
+ $ret[$key] = $temp;
+ }
+ } else {
+ $ret[$key] = $value;
+ }
+ }
+ return $ret;
+}
+
+/**
+ * Append a query string to an existing URL, which may or may not already
+ * have query string parameters already. If so, they will be combined.
+ *
+ * @param string $url
+ * @param string|string[] $query String or associative array
+ * @return string
+ */
+function wfAppendQuery( $url, $query ) {
+ if ( is_array( $query ) ) {
+ $query = wfArrayToCgi( $query );
+ }
+ if ( $query != '' ) {
+ // Remove the fragment, if there is one
+ $fragment = false;
+ $hashPos = strpos( $url, '#' );
+ if ( $hashPos !== false ) {
+ $fragment = substr( $url, $hashPos );
+ $url = substr( $url, 0, $hashPos );
+ }
+
+ // Add parameter
+ if ( false === strpos( $url, '?' ) ) {
+ $url .= '?';
+ } else {
+ $url .= '&';
+ }
+ $url .= $query;
+
+ // Put the fragment back
+ if ( $fragment !== false ) {
+ $url .= $fragment;
+ }
+ }
+ return $url;
+}
+
+/**
+ * Expand a potentially local URL to a fully-qualified URL. Assumes $wgServer
+ * is correct.
+ *
+ * The meaning of the PROTO_* constants is as follows:
+ * PROTO_HTTP: Output a URL starting with http://
+ * PROTO_HTTPS: Output a URL starting with https://
+ * PROTO_RELATIVE: Output a URL starting with // (protocol-relative URL)
+ * PROTO_CURRENT: Output a URL starting with either http:// or https:// , depending
+ * on which protocol was used for the current incoming request
+ * PROTO_CANONICAL: For URLs without a domain, like /w/index.php , use $wgCanonicalServer.
+ * For protocol-relative URLs, use the protocol of $wgCanonicalServer
+ * PROTO_INTERNAL: Like PROTO_CANONICAL, but uses $wgInternalServer instead of $wgCanonicalServer
+ *
+ * @todo this won't work with current-path-relative URLs
+ * like "subdir/foo.html", etc.
+ *
+ * @param string $url Either fully-qualified or a local path + query
+ * @param string $defaultProto One of the PROTO_* constants. Determines the
+ * protocol to use if $url or $wgServer is protocol-relative
+ * @return string|false Fully-qualified URL, current-path-relative URL or false if
+ * no valid URL can be constructed
+ */
+function wfExpandUrl( $url, $defaultProto = PROTO_CURRENT ) {
+ global $wgServer, $wgCanonicalServer, $wgInternalServer, $wgRequest,
+ $wgHttpsPort;
+ if ( $defaultProto === PROTO_CANONICAL ) {
+ $serverUrl = $wgCanonicalServer;
+ } elseif ( $defaultProto === PROTO_INTERNAL && $wgInternalServer !== false ) {
+ // Make $wgInternalServer fall back to $wgServer if not set
+ $serverUrl = $wgInternalServer;
+ } else {
+ $serverUrl = $wgServer;
+ if ( $defaultProto === PROTO_CURRENT ) {
+ $defaultProto = $wgRequest->getProtocol() . '://';
+ }
+ }
+
+ // Analyze $serverUrl to obtain its protocol
+ $bits = wfParseUrl( $serverUrl );
+ $serverHasProto = $bits && $bits['scheme'] != '';
+
+ if ( $defaultProto === PROTO_CANONICAL || $defaultProto === PROTO_INTERNAL ) {
+ if ( $serverHasProto ) {
+ $defaultProto = $bits['scheme'] . '://';
+ } else {
+ // $wgCanonicalServer or $wgInternalServer doesn't have a protocol.
+ // This really isn't supposed to happen. Fall back to HTTP in this
+ // ridiculous case.
+ $defaultProto = PROTO_HTTP;
+ }
+ }
+
+ $defaultProtoWithoutSlashes = substr( $defaultProto, 0, -2 );
+
+ if ( substr( $url, 0, 2 ) == '//' ) {
+ $url = $defaultProtoWithoutSlashes . $url;
+ } elseif ( substr( $url, 0, 1 ) == '/' ) {
+ // If $serverUrl is protocol-relative, prepend $defaultProtoWithoutSlashes,
+ // otherwise leave it alone.
+ $url = ( $serverHasProto ? '' : $defaultProtoWithoutSlashes ) . $serverUrl . $url;
+ }
+
+ $bits = wfParseUrl( $url );
+
+ // ensure proper port for HTTPS arrives in URL
+ // https://phabricator.wikimedia.org/T67184
+ if ( $defaultProto === PROTO_HTTPS && $wgHttpsPort != 443 ) {
+ $bits['port'] = $wgHttpsPort;
+ }
+
+ if ( $bits && isset( $bits['path'] ) ) {
+ $bits['path'] = wfRemoveDotSegments( $bits['path'] );
+ return wfAssembleUrl( $bits );
+ } elseif ( $bits ) {
+ # No path to expand
+ return $url;
+ } elseif ( substr( $url, 0, 1 ) != '/' ) {
+ # URL is a relative path
+ return wfRemoveDotSegments( $url );
+ }
+
+ # Expanded URL is not valid.
+ return false;
+}
+
+/**
+ * This function will reassemble a URL parsed with wfParseURL. This is useful
+ * if you need to edit part of a URL and put it back together.
+ *
+ * This is the basic structure used (brackets contain keys for $urlParts):
+ * [scheme][delimiter][user]:[pass]@[host]:[port][path]?[query]#[fragment]
+ *
+ * @todo Need to integrate this into wfExpandUrl (see T34168)
+ *
+ * @since 1.19
+ * @param array $urlParts URL parts, as output from wfParseUrl
+ * @return string URL assembled from its component parts
+ */
+function wfAssembleUrl( $urlParts ) {
+ $result = '';
+
+ if ( isset( $urlParts['delimiter'] ) ) {
+ if ( isset( $urlParts['scheme'] ) ) {
+ $result .= $urlParts['scheme'];
+ }
+
+ $result .= $urlParts['delimiter'];
+ }
+
+ if ( isset( $urlParts['host'] ) ) {
+ if ( isset( $urlParts['user'] ) ) {
+ $result .= $urlParts['user'];
+ if ( isset( $urlParts['pass'] ) ) {
+ $result .= ':' . $urlParts['pass'];
+ }
+ $result .= '@';
+ }
+
+ $result .= $urlParts['host'];
+
+ if ( isset( $urlParts['port'] ) ) {
+ $result .= ':' . $urlParts['port'];
+ }
+ }
+
+ if ( isset( $urlParts['path'] ) ) {
+ $result .= $urlParts['path'];
+ }
+
+ if ( isset( $urlParts['query'] ) ) {
+ $result .= '?' . $urlParts['query'];
+ }
+
+ if ( isset( $urlParts['fragment'] ) ) {
+ $result .= '#' . $urlParts['fragment'];
+ }
+
+ return $result;
+}
+
+/**
+ * Remove all dot-segments in the provided URL path. For example,
+ * '/a/./b/../c/' becomes '/a/c/'. For details on the algorithm, please see
+ * RFC3986 section 5.2.4.
+ *
+ * @todo Need to integrate this into wfExpandUrl (see T34168)
+ *
+ * @param string $urlPath URL path, potentially containing dot-segments
+ * @return string URL path with all dot-segments removed
+ */
+function wfRemoveDotSegments( $urlPath ) {
+ $output = '';
+ $inputOffset = 0;
+ $inputLength = strlen( $urlPath );
+
+ while ( $inputOffset < $inputLength ) {
+ $prefixLengthOne = substr( $urlPath, $inputOffset, 1 );
+ $prefixLengthTwo = substr( $urlPath, $inputOffset, 2 );
+ $prefixLengthThree = substr( $urlPath, $inputOffset, 3 );
+ $prefixLengthFour = substr( $urlPath, $inputOffset, 4 );
+ $trimOutput = false;
+
+ if ( $prefixLengthTwo == './' ) {
+ # Step A, remove leading "./"
+ $inputOffset += 2;
+ } elseif ( $prefixLengthThree == '../' ) {
+ # Step A, remove leading "../"
+ $inputOffset += 3;
+ } elseif ( ( $prefixLengthTwo == '/.' ) && ( $inputOffset + 2 == $inputLength ) ) {
+ # Step B, replace leading "/.$" with "/"
+ $inputOffset += 1;
+ $urlPath[$inputOffset] = '/';
+ } elseif ( $prefixLengthThree == '/./' ) {
+ # Step B, replace leading "/./" with "/"
+ $inputOffset += 2;
+ } elseif ( $prefixLengthThree == '/..' && ( $inputOffset + 3 == $inputLength ) ) {
+ # Step C, replace leading "/..$" with "/" and
+ # remove last path component in output
+ $inputOffset += 2;
+ $urlPath[$inputOffset] = '/';
+ $trimOutput = true;
+ } elseif ( $prefixLengthFour == '/../' ) {
+ # Step C, replace leading "/../" with "/" and
+ # remove last path component in output
+ $inputOffset += 3;
+ $trimOutput = true;
+ } elseif ( ( $prefixLengthOne == '.' ) && ( $inputOffset + 1 == $inputLength ) ) {
+ # Step D, remove "^.$"
+ $inputOffset += 1;
+ } elseif ( ( $prefixLengthTwo == '..' ) && ( $inputOffset + 2 == $inputLength ) ) {
+ # Step D, remove "^..$"
+ $inputOffset += 2;
+ } else {
+ # Step E, move leading path segment to output
+ if ( $prefixLengthOne == '/' ) {
+ $slashPos = strpos( $urlPath, '/', $inputOffset + 1 );
+ } else {
+ $slashPos = strpos( $urlPath, '/', $inputOffset );
+ }
+ if ( $slashPos === false ) {
+ $output .= substr( $urlPath, $inputOffset );
+ $inputOffset = $inputLength;
+ } else {
+ $output .= substr( $urlPath, $inputOffset, $slashPos - $inputOffset );
+ $inputOffset += $slashPos - $inputOffset;
+ }
+ }
+
+ if ( $trimOutput ) {
+ $slashPos = strrpos( $output, '/' );
+ if ( $slashPos === false ) {
+ $output = '';
+ } else {
+ $output = substr( $output, 0, $slashPos );
+ }
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Returns a regular expression of url protocols
+ *
+ * @param bool $includeProtocolRelative If false, remove '//' from the returned protocol list.
+ * DO NOT USE this directly, use wfUrlProtocolsWithoutProtRel() instead
+ * @return string
+ */
+function wfUrlProtocols( $includeProtocolRelative = true ) {
+ global $wgUrlProtocols;
+
+ // Cache return values separately based on $includeProtocolRelative
+ static $withProtRel = null, $withoutProtRel = null;
+ $cachedValue = $includeProtocolRelative ? $withProtRel : $withoutProtRel;
+ if ( !is_null( $cachedValue ) ) {
+ return $cachedValue;
+ }
+
+ // Support old-style $wgUrlProtocols strings, for backwards compatibility
+ // with LocalSettings files from 1.5
+ if ( is_array( $wgUrlProtocols ) ) {
+ $protocols = [];
+ foreach ( $wgUrlProtocols as $protocol ) {
+ // Filter out '//' if !$includeProtocolRelative
+ if ( $includeProtocolRelative || $protocol !== '//' ) {
+ $protocols[] = preg_quote( $protocol, '/' );
+ }
+ }
+
+ $retval = implode( '|', $protocols );
+ } else {
+ // Ignore $includeProtocolRelative in this case
+ // This case exists for pre-1.6 compatibility, and we can safely assume
+ // that '//' won't appear in a pre-1.6 config because protocol-relative
+ // URLs weren't supported until 1.18
+ $retval = $wgUrlProtocols;
+ }
+
+ // Cache return value
+ if ( $includeProtocolRelative ) {
+ $withProtRel = $retval;
+ } else {
+ $withoutProtRel = $retval;
+ }
+ return $retval;
+}
+
+/**
+ * Like wfUrlProtocols(), but excludes '//' from the protocol list. Use this if
+ * you need a regex that matches all URL protocols but does not match protocol-
+ * relative URLs
+ * @return string
+ */
+function wfUrlProtocolsWithoutProtRel() {
+ return wfUrlProtocols( false );
+}
+
+/**
+ * parse_url() work-alike, but non-broken. Differences:
+ *
+ * 1) Does not raise warnings on bad URLs (just returns false).
+ * 2) Handles protocols that don't use :// (e.g., mailto: and news:, as well as
+ * protocol-relative URLs) correctly.
+ * 3) Adds a "delimiter" element to the array (see (2)).
+ * 4) Verifies that the protocol is on the $wgUrlProtocols whitelist.
+ * 5) Rejects some invalid URLs that parse_url doesn't, e.g. the empty string or URLs starting with
+ * a line feed character.
+ *
+ * @param string $url A URL to parse
+ * @return string[]|bool Bits of the URL in an associative array, or false on failure.
+ * Possible fields:
+ * - scheme: URI scheme (protocol), e.g. 'http', 'mailto'. Lowercase, always present, but can
+ * be an empty string for protocol-relative URLs.
+ * - delimiter: either '://', ':' or '//'. Always present.
+ * - host: domain name / IP. Always present, but could be an empty string, e.g. for file: URLs.
+ * - user: user name, e.g. for HTTP Basic auth URLs such as http://user:pass@example.com/
+ * Missing when there is no username.
+ * - pass: password, same as above.
+ * - path: path including the leading /. Will be missing when empty (e.g. 'http://example.com')
+ * - query: query string (as a string; see wfCgiToArray() for parsing it), can be missing.
+ * - fragment: the part after #, can be missing.
+ */
+function wfParseUrl( $url ) {
+ global $wgUrlProtocols; // Allow all protocols defined in DefaultSettings/LocalSettings.php
+
+ // Protocol-relative URLs are handled really badly by parse_url(). It's so
+ // bad that the easiest way to handle them is to just prepend 'http:' and
+ // strip the protocol out later.
+ $wasRelative = substr( $url, 0, 2 ) == '//';
+ if ( $wasRelative ) {
+ $url = "http:$url";
+ }
+ MediaWiki\suppressWarnings();
+ $bits = parse_url( $url );
+ MediaWiki\restoreWarnings();
+ // parse_url() returns an array without scheme for some invalid URLs, e.g.
+ // parse_url("%0Ahttp://example.com") == [ 'host' => '%0Ahttp', 'path' => 'example.com' ]
+ if ( !$bits || !isset( $bits['scheme'] ) ) {
+ return false;
+ }
+
+ // parse_url() incorrectly handles schemes case-sensitively. Convert it to lowercase.
+ $bits['scheme'] = strtolower( $bits['scheme'] );
+
+ // most of the protocols are followed by ://, but mailto: and sometimes news: not, check for it
+ if ( in_array( $bits['scheme'] . '://', $wgUrlProtocols ) ) {
+ $bits['delimiter'] = '://';
+ } elseif ( in_array( $bits['scheme'] . ':', $wgUrlProtocols ) ) {
+ $bits['delimiter'] = ':';
+ // parse_url detects for news: and mailto: the host part of an url as path
+ // We have to correct this wrong detection
+ if ( isset( $bits['path'] ) ) {
+ $bits['host'] = $bits['path'];
+ $bits['path'] = '';
+ }
+ } else {
+ return false;
+ }
+
+ /* Provide an empty host for eg. file:/// urls (see T30627) */
+ if ( !isset( $bits['host'] ) ) {
+ $bits['host'] = '';
+
+ // See T47069
+ if ( isset( $bits['path'] ) ) {
+ /* parse_url loses the third / for file:///c:/ urls (but not on variants) */
+ if ( substr( $bits['path'], 0, 1 ) !== '/' ) {
+ $bits['path'] = '/' . $bits['path'];
+ }
+ } else {
+ $bits['path'] = '';
+ }
+ }
+
+ // If the URL was protocol-relative, fix scheme and delimiter
+ if ( $wasRelative ) {
+ $bits['scheme'] = '';
+ $bits['delimiter'] = '//';
+ }
+ return $bits;
+}
+
+/**
+ * Take a URL, make sure it's expanded to fully qualified, and replace any
+ * encoded non-ASCII Unicode characters with their UTF-8 original forms
+ * for more compact display and legibility for local audiences.
+ *
+ * @todo handle punycode domains too
+ *
+ * @param string $url
+ * @return string
+ */
+function wfExpandIRI( $url ) {
+ return preg_replace_callback(
+ '/((?:%[89A-F][0-9A-F])+)/i',
+ 'wfExpandIRI_callback',
+ wfExpandUrl( $url )
+ );
+}
+
+/**
+ * Private callback for wfExpandIRI
+ * @param array $matches
+ * @return string
+ */
+function wfExpandIRI_callback( $matches ) {
+ return urldecode( $matches[1] );
+}
+
+/**
+ * Make URL indexes, appropriate for the el_index field of externallinks.
+ *
+ * @param string $url
+ * @return array
+ */
+function wfMakeUrlIndexes( $url ) {
+ $bits = wfParseUrl( $url );
+
+ // Reverse the labels in the hostname, convert to lower case
+ // For emails reverse domainpart only
+ if ( $bits['scheme'] == 'mailto' ) {
+ $mailparts = explode( '@', $bits['host'], 2 );
+ if ( count( $mailparts ) === 2 ) {
+ $domainpart = strtolower( implode( '.', array_reverse( explode( '.', $mailparts[1] ) ) ) );
+ } else {
+ // No domain specified, don't mangle it
+ $domainpart = '';
+ }
+ $reversedHost = $domainpart . '@' . $mailparts[0];
+ } else {
+ $reversedHost = strtolower( implode( '.', array_reverse( explode( '.', $bits['host'] ) ) ) );
+ }
+ // Add an extra dot to the end
+ // Why? Is it in wrong place in mailto links?
+ if ( substr( $reversedHost, -1, 1 ) !== '.' ) {
+ $reversedHost .= '.';
+ }
+ // Reconstruct the pseudo-URL
+ $prot = $bits['scheme'];
+ $index = $prot . $bits['delimiter'] . $reversedHost;
+ // Leave out user and password. Add the port, path, query and fragment
+ if ( isset( $bits['port'] ) ) {
+ $index .= ':' . $bits['port'];
+ }
+ if ( isset( $bits['path'] ) ) {
+ $index .= $bits['path'];
+ } else {
+ $index .= '/';
+ }
+ if ( isset( $bits['query'] ) ) {
+ $index .= '?' . $bits['query'];
+ }
+ if ( isset( $bits['fragment'] ) ) {
+ $index .= '#' . $bits['fragment'];
+ }
+
+ if ( $prot == '' ) {
+ return [ "http:$index", "https:$index" ];
+ } else {
+ return [ $index ];
+ }
+}
+
+/**
+ * Check whether a given URL has a domain that occurs in a given set of domains
+ * @param string $url URL
+ * @param array $domains Array of domains (strings)
+ * @return bool True if the host part of $url ends in one of the strings in $domains
+ */
+function wfMatchesDomainList( $url, $domains ) {
+ $bits = wfParseUrl( $url );
+ if ( is_array( $bits ) && isset( $bits['host'] ) ) {
+ $host = '.' . $bits['host'];
+ foreach ( (array)$domains as $domain ) {
+ $domain = '.' . $domain;
+ if ( substr( $host, -strlen( $domain ) ) === $domain ) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+/**
+ * Sends a line to the debug log if enabled or, optionally, to a comment in output.
+ * In normal operation this is a NOP.
+ *
+ * Controlling globals:
+ * $wgDebugLogFile - points to the log file
+ * $wgDebugRawPage - if false, 'action=raw' hits will not result in debug output.
+ * $wgDebugComments - if on, some debug items may appear in comments in the HTML output.
+ *
+ * @since 1.25 support for additional context data
+ *
+ * @param string $text
+ * @param string|bool $dest Destination of the message:
+ * - 'all': both to the log and HTML (debug toolbar or HTML comments)
+ * - 'private': excluded from HTML output
+ * For backward compatibility, it can also take a boolean:
+ * - true: same as 'all'
+ * - false: same as 'private'
+ * @param array $context Additional logging context data
+ */
+function wfDebug( $text, $dest = 'all', array $context = [] ) {
+ global $wgDebugRawPage, $wgDebugLogPrefix;
+ global $wgDebugTimestamps, $wgRequestTime;
+
+ if ( !$wgDebugRawPage && wfIsDebugRawPage() ) {
+ return;
+ }
+
+ $text = trim( $text );
+
+ if ( $wgDebugTimestamps ) {
+ $context['seconds_elapsed'] = sprintf(
+ '%6.4f',
+ microtime( true ) - $wgRequestTime
+ );
+ $context['memory_used'] = sprintf(
+ '%5.1fM',
+ ( memory_get_usage( true ) / ( 1024 * 1024 ) )
+ );
+ }
+
+ if ( $wgDebugLogPrefix !== '' ) {
+ $context['prefix'] = $wgDebugLogPrefix;
+ }
+ $context['private'] = ( $dest === false || $dest === 'private' );
+
+ $logger = LoggerFactory::getInstance( 'wfDebug' );
+ $logger->debug( $text, $context );
+}
+
+/**
+ * Returns true if debug logging should be suppressed if $wgDebugRawPage = false
+ * @return bool
+ */
+function wfIsDebugRawPage() {
+ static $cache;
+ if ( $cache !== null ) {
+ return $cache;
+ }
+ # Check for raw action using $_GET not $wgRequest, since the latter might not be initialised yet
+ if ( ( isset( $_GET['action'] ) && $_GET['action'] == 'raw' )
+ || (
+ isset( $_SERVER['SCRIPT_NAME'] )
+ && substr( $_SERVER['SCRIPT_NAME'], -8 ) == 'load.php'
+ )
+ ) {
+ $cache = true;
+ } else {
+ $cache = false;
+ }
+ return $cache;
+}
+
+/**
+ * Send a line giving PHP memory usage.
+ *
+ * @param bool $exact Print exact byte values instead of kibibytes (default: false)
+ */
+function wfDebugMem( $exact = false ) {
+ $mem = memory_get_usage();
+ if ( !$exact ) {
+ $mem = floor( $mem / 1024 ) . ' KiB';
+ } else {
+ $mem .= ' B';
+ }
+ wfDebug( "Memory usage: $mem\n" );
+}
+
+/**
+ * Send a line to a supplementary debug log file, if configured, or main debug
+ * log if not.
+ *
+ * To configure a supplementary log file, set $wgDebugLogGroups[$logGroup] to
+ * a string filename or an associative array mapping 'destination' to the
+ * desired filename. The associative array may also contain a 'sample' key
+ * with an integer value, specifying a sampling factor. Sampled log events
+ * will be emitted with a 1 in N random chance.
+ *
+ * @since 1.23 support for sampling log messages via $wgDebugLogGroups.
+ * @since 1.25 support for additional context data
+ * @since 1.25 sample behavior dependent on configured $wgMWLoggerDefaultSpi
+ *
+ * @param string $logGroup
+ * @param string $text
+ * @param string|bool $dest Destination of the message:
+ * - 'all': both to the log and HTML (debug toolbar or HTML comments)
+ * - 'private': only to the specific log if set in $wgDebugLogGroups and
+ * discarded otherwise
+ * For backward compatibility, it can also take a boolean:
+ * - true: same as 'all'
+ * - false: same as 'private'
+ * @param array $context Additional logging context data
+ */
+function wfDebugLog(
+ $logGroup, $text, $dest = 'all', array $context = []
+) {
+ $text = trim( $text );
+
+ $logger = LoggerFactory::getInstance( $logGroup );
+ $context['private'] = ( $dest === false || $dest === 'private' );
+ $logger->info( $text, $context );
+}
+
+/**
+ * Log for database errors
+ *
+ * @since 1.25 support for additional context data
+ *
+ * @param string $text Database error message.
+ * @param array $context Additional logging context data
+ */
+function wfLogDBError( $text, array $context = [] ) {
+ $logger = LoggerFactory::getInstance( 'wfLogDBError' );
+ $logger->error( trim( $text ), $context );
+}
+
+/**
+ * Throws a warning that $function is deprecated
+ *
+ * @param string $function
+ * @param string|bool $version Version of MediaWiki that the function
+ * was deprecated in (Added in 1.19).
+ * @param string|bool $component Added in 1.19.
+ * @param int $callerOffset How far up the call stack is the original
+ * caller. 2 = function that called the function that called
+ * wfDeprecated (Added in 1.20)
+ *
+ * @return null
+ */
+function wfDeprecated( $function, $version = false, $component = false, $callerOffset = 2 ) {
+ MWDebug::deprecated( $function, $version, $component, $callerOffset + 1 );
+}
+
+/**
+ * Send a warning either to the debug log or in a PHP error depending on
+ * $wgDevelopmentWarnings. To log warnings in production, use wfLogWarning() instead.
+ *
+ * @param string $msg Message to send
+ * @param int $callerOffset Number of items to go back in the backtrace to
+ * find the correct caller (1 = function calling wfWarn, ...)
+ * @param int $level PHP error level; defaults to E_USER_NOTICE;
+ * only used when $wgDevelopmentWarnings is true
+ */
+function wfWarn( $msg, $callerOffset = 1, $level = E_USER_NOTICE ) {
+ MWDebug::warning( $msg, $callerOffset + 1, $level, 'auto' );
+}
+
+/**
+ * Send a warning as a PHP error and the debug log. This is intended for logging
+ * warnings in production. For logging development warnings, use WfWarn instead.
+ *
+ * @param string $msg Message to send
+ * @param int $callerOffset Number of items to go back in the backtrace to
+ * find the correct caller (1 = function calling wfLogWarning, ...)
+ * @param int $level PHP error level; defaults to E_USER_WARNING
+ */
+function wfLogWarning( $msg, $callerOffset = 1, $level = E_USER_WARNING ) {
+ MWDebug::warning( $msg, $callerOffset + 1, $level, 'production' );
+}
+
+/**
+ * Log to a file without getting "file size exceeded" signals.
+ *
+ * Can also log to TCP or UDP with the syntax udp://host:port/prefix. This will
+ * send lines to the specified port, prefixed by the specified prefix and a space.
+ * @since 1.25 support for additional context data
+ *
+ * @param string $text
+ * @param string $file Filename
+ * @param array $context Additional logging context data
+ * @throws MWException
+ * @deprecated since 1.25 Use \MediaWiki\Logger\LegacyLogger::emit or UDPTransport
+ */
+function wfErrorLog( $text, $file, array $context = [] ) {
+ wfDeprecated( __METHOD__, '1.25' );
+ $logger = LoggerFactory::getInstance( 'wfErrorLog' );
+ $context['destination'] = $file;
+ $logger->info( trim( $text ), $context );
+}
+
+/**
+ * @todo document
+ */
+function wfLogProfilingData() {
+ global $wgDebugLogGroups, $wgDebugRawPage;
+
+ $context = RequestContext::getMain();
+ $request = $context->getRequest();
+
+ $profiler = Profiler::instance();
+ $profiler->setContext( $context );
+ $profiler->logData();
+
+ $config = $context->getConfig();
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ if ( $config->get( 'StatsdServer' ) && $stats->hasData() ) {
+ try {
+ $statsdServer = explode( ':', $config->get( 'StatsdServer' ) );
+ $statsdHost = $statsdServer[0];
+ $statsdPort = isset( $statsdServer[1] ) ? $statsdServer[1] : 8125;
+ $statsdSender = new SocketSender( $statsdHost, $statsdPort );
+ $statsdClient = new SamplingStatsdClient( $statsdSender, true, false );
+ $statsdClient->setSamplingRates( $config->get( 'StatsdSamplingRates' ) );
+ $statsdClient->send( $stats->getData() );
+ } catch ( Exception $ex ) {
+ MWExceptionHandler::logException( $ex );
+ }
+ }
+
+ # Profiling must actually be enabled...
+ if ( $profiler instanceof ProfilerStub ) {
+ return;
+ }
+
+ if ( isset( $wgDebugLogGroups['profileoutput'] )
+ && $wgDebugLogGroups['profileoutput'] === false
+ ) {
+ // Explicitly disabled
+ return;
+ }
+ if ( !$wgDebugRawPage && wfIsDebugRawPage() ) {
+ return;
+ }
+
+ $ctx = [ 'elapsed' => $request->getElapsedTime() ];
+ if ( !empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
+ $ctx['forwarded_for'] = $_SERVER['HTTP_X_FORWARDED_FOR'];
+ }
+ if ( !empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
+ $ctx['client_ip'] = $_SERVER['HTTP_CLIENT_IP'];
+ }
+ if ( !empty( $_SERVER['HTTP_FROM'] ) ) {
+ $ctx['from'] = $_SERVER['HTTP_FROM'];
+ }
+ if ( isset( $ctx['forwarded_for'] ) ||
+ isset( $ctx['client_ip'] ) ||
+ isset( $ctx['from'] ) ) {
+ $ctx['proxy'] = $_SERVER['REMOTE_ADDR'];
+ }
+
+ // Don't load $wgUser at this late stage just for statistics purposes
+ // @todo FIXME: We can detect some anons even if it is not loaded.
+ // See User::getId()
+ $user = $context->getUser();
+ $ctx['anon'] = $user->isItemLoaded( 'id' ) && $user->isAnon();
+
+ // Command line script uses a FauxRequest object which does not have
+ // any knowledge about an URL and throw an exception instead.
+ try {
+ $ctx['url'] = urldecode( $request->getRequestURL() );
+ } catch ( Exception $ignored ) {
+ // no-op
+ }
+
+ $ctx['output'] = $profiler->getOutput();
+
+ $log = LoggerFactory::getInstance( 'profileoutput' );
+ $log->info( "Elapsed: {elapsed}; URL: <{url}>\n{output}", $ctx );
+}
+
+/**
+ * Increment a statistics counter
+ *
+ * @param string $key
+ * @param int $count
+ * @return void
+ */
+function wfIncrStats( $key, $count = 1 ) {
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $stats->updateCount( $key, $count );
+}
+
+/**
+ * Check whether the wiki is in read-only mode.
+ *
+ * @return bool
+ */
+function wfReadOnly() {
+ return MediaWikiServices::getInstance()->getReadOnlyMode()
+ ->isReadOnly();
+}
+
+/**
+ * Check if the site is in read-only mode and return the message if so
+ *
+ * This checks wfConfiguredReadOnlyReason() and the main load balancer
+ * for replica DB lag. This may result in DB connection being made.
+ *
+ * @return string|bool String when in read-only mode; false otherwise
+ */
+function wfReadOnlyReason() {
+ return MediaWikiServices::getInstance()->getReadOnlyMode()
+ ->getReason();
+}
+
+/**
+ * Get the value of $wgReadOnly or the contents of $wgReadOnlyFile.
+ *
+ * @return string|bool String when in read-only mode; false otherwise
+ * @since 1.27
+ */
+function wfConfiguredReadOnlyReason() {
+ return MediaWikiServices::getInstance()->getConfiguredReadOnlyMode()
+ ->getReason();
+}
+
+/**
+ * Return a Language object from $langcode
+ *
+ * @param Language|string|bool $langcode Either:
+ * - a Language object
+ * - code of the language to get the message for, if it is
+ * a valid code create a language for that language, if
+ * it is a string but not a valid code then make a basic
+ * language object
+ * - a boolean: if it's false then use the global object for
+ * the current user's language (as a fallback for the old parameter
+ * functionality), or if it is true then use global object
+ * for the wiki's content language.
+ * @return Language
+ */
+function wfGetLangObj( $langcode = false ) {
+ # Identify which language to get or create a language object for.
+ # Using is_object here due to Stub objects.
+ if ( is_object( $langcode ) ) {
+ # Great, we already have the object (hopefully)!
+ return $langcode;
+ }
+
+ global $wgContLang, $wgLanguageCode;
+ if ( $langcode === true || $langcode === $wgLanguageCode ) {
+ # $langcode is the language code of the wikis content language object.
+ # or it is a boolean and value is true
+ return $wgContLang;
+ }
+
+ global $wgLang;
+ if ( $langcode === false || $langcode === $wgLang->getCode() ) {
+ # $langcode is the language code of user language object.
+ # or it was a boolean and value is false
+ return $wgLang;
+ }
+
+ $validCodes = array_keys( Language::fetchLanguageNames() );
+ if ( in_array( $langcode, $validCodes ) ) {
+ # $langcode corresponds to a valid language.
+ return Language::factory( $langcode );
+ }
+
+ # $langcode is a string, but not a valid language code; use content language.
+ wfDebug( "Invalid language code passed to wfGetLangObj, falling back to content language.\n" );
+ return $wgContLang;
+}
+
+/**
+ * This is the function for getting translated interface messages.
+ *
+ * @see Message class for documentation how to use them.
+ * @see https://www.mediawiki.org/wiki/Manual:Messages_API
+ *
+ * This function replaces all old wfMsg* functions.
+ *
+ * @param string|string[]|MessageSpecifier $key Message key, or array of keys, or a MessageSpecifier
+ * @param mixed $params,... Normal message parameters
+ * @return Message
+ *
+ * @since 1.17
+ *
+ * @see Message::__construct
+ */
+function wfMessage( $key /*...*/ ) {
+ $message = new Message( $key );
+
+ // We call Message::params() to reduce code duplication
+ $params = func_get_args();
+ array_shift( $params );
+ if ( $params ) {
+ call_user_func_array( [ $message, 'params' ], $params );
+ }
+
+ return $message;
+}
+
+/**
+ * This function accepts multiple message keys and returns a message instance
+ * for the first message which is non-empty. If all messages are empty then an
+ * instance of the first message key is returned.
+ *
+ * @param string|string[] $keys,... Message keys
+ * @return Message
+ *
+ * @since 1.18
+ *
+ * @see Message::newFallbackSequence
+ */
+function wfMessageFallback( /*...*/ ) {
+ $args = func_get_args();
+ return call_user_func_array( 'Message::newFallbackSequence', $args );
+}
+
+/**
+ * Replace message parameter keys on the given formatted output.
+ *
+ * @param string $message
+ * @param array $args
+ * @return string
+ * @private
+ */
+function wfMsgReplaceArgs( $message, $args ) {
+ # Fix windows line-endings
+ # Some messages are split with explode("\n", $msg)
+ $message = str_replace( "\r", '', $message );
+
+ // Replace arguments
+ if ( is_array( $args ) && $args ) {
+ if ( is_array( $args[0] ) ) {
+ $args = array_values( $args[0] );
+ }
+ $replacementKeys = [];
+ foreach ( $args as $n => $param ) {
+ $replacementKeys['$' . ( $n + 1 )] = $param;
+ }
+ $message = strtr( $message, $replacementKeys );
+ }
+
+ return $message;
+}
+
+/**
+ * Fetch server name for use in error reporting etc.
+ * Use real server name if available, so we know which machine
+ * in a server farm generated the current page.
+ *
+ * @return string
+ */
+function wfHostname() {
+ static $host;
+ if ( is_null( $host ) ) {
+ # Hostname overriding
+ global $wgOverrideHostname;
+ if ( $wgOverrideHostname !== false ) {
+ # Set static and skip any detection
+ $host = $wgOverrideHostname;
+ return $host;
+ }
+
+ if ( function_exists( 'posix_uname' ) ) {
+ // This function not present on Windows
+ $uname = posix_uname();
+ } else {
+ $uname = false;
+ }
+ if ( is_array( $uname ) && isset( $uname['nodename'] ) ) {
+ $host = $uname['nodename'];
+ } elseif ( getenv( 'COMPUTERNAME' ) ) {
+ # Windows computer name
+ $host = getenv( 'COMPUTERNAME' );
+ } else {
+ # This may be a virtual server.
+ $host = $_SERVER['SERVER_NAME'];
+ }
+ }
+ return $host;
+}
+
+/**
+ * Returns a script tag that stores the amount of time it took MediaWiki to
+ * handle the request in milliseconds as 'wgBackendResponseTime'.
+ *
+ * If $wgShowHostnames is true, the script will also set 'wgHostname' to the
+ * hostname of the server handling the request.
+ *
+ * @return string
+ */
+function wfReportTime() {
+ global $wgRequestTime, $wgShowHostnames;
+
+ $responseTime = round( ( microtime( true ) - $wgRequestTime ) * 1000 );
+ $reportVars = [ 'wgBackendResponseTime' => $responseTime ];
+ if ( $wgShowHostnames ) {
+ $reportVars['wgHostname'] = wfHostname();
+ }
+ return Skin::makeVariablesScript( $reportVars );
+}
+
+/**
+ * Safety wrapper for debug_backtrace().
+ *
+ * Will return an empty array if debug_backtrace is disabled, otherwise
+ * the output from debug_backtrace() (trimmed).
+ *
+ * @param int $limit This parameter can be used to limit the number of stack frames returned
+ *
+ * @return array Array of backtrace information
+ */
+function wfDebugBacktrace( $limit = 0 ) {
+ static $disabled = null;
+
+ if ( is_null( $disabled ) ) {
+ $disabled = !function_exists( 'debug_backtrace' );
+ if ( $disabled ) {
+ wfDebug( "debug_backtrace() is disabled\n" );
+ }
+ }
+ if ( $disabled ) {
+ return [];
+ }
+
+ if ( $limit ) {
+ return array_slice( debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, $limit + 1 ), 1 );
+ } else {
+ return array_slice( debug_backtrace(), 1 );
+ }
+}
+
+/**
+ * Get a debug backtrace as a string
+ *
+ * @param bool|null $raw If true, the return value is plain text. If false, HTML.
+ * Defaults to $wgCommandLineMode if unset.
+ * @return string
+ * @since 1.25 Supports $raw parameter.
+ */
+function wfBacktrace( $raw = null ) {
+ global $wgCommandLineMode;
+
+ if ( $raw === null ) {
+ $raw = $wgCommandLineMode;
+ }
+
+ if ( $raw ) {
+ $frameFormat = "%s line %s calls %s()\n";
+ $traceFormat = "%s";
+ } else {
+ $frameFormat = "<li>%s line %s calls %s()</li>\n";
+ $traceFormat = "<ul>\n%s</ul>\n";
+ }
+
+ $frames = array_map( function ( $frame ) use ( $frameFormat ) {
+ $file = !empty( $frame['file'] ) ? basename( $frame['file'] ) : '-';
+ $line = isset( $frame['line'] ) ? $frame['line'] : '-';
+ $call = $frame['function'];
+ if ( !empty( $frame['class'] ) ) {
+ $call = $frame['class'] . $frame['type'] . $call;
+ }
+ return sprintf( $frameFormat, $file, $line, $call );
+ }, wfDebugBacktrace() );
+
+ return sprintf( $traceFormat, implode( '', $frames ) );
+}
+
+/**
+ * Get the name of the function which called this function
+ * wfGetCaller( 1 ) is the function with the wfGetCaller() call (ie. __FUNCTION__)
+ * wfGetCaller( 2 ) [default] is the caller of the function running wfGetCaller()
+ * wfGetCaller( 3 ) is the parent of that.
+ *
+ * @param int $level
+ * @return string
+ */
+function wfGetCaller( $level = 2 ) {
+ $backtrace = wfDebugBacktrace( $level + 1 );
+ if ( isset( $backtrace[$level] ) ) {
+ return wfFormatStackFrame( $backtrace[$level] );
+ } else {
+ return 'unknown';
+ }
+}
+
+/**
+ * Return a string consisting of callers in the stack. Useful sometimes
+ * for profiling specific points.
+ *
+ * @param int $limit The maximum depth of the stack frame to return, or false for the entire stack.
+ * @return string
+ */
+function wfGetAllCallers( $limit = 3 ) {
+ $trace = array_reverse( wfDebugBacktrace() );
+ if ( !$limit || $limit > count( $trace ) - 1 ) {
+ $limit = count( $trace ) - 1;
+ }
+ $trace = array_slice( $trace, -$limit - 1, $limit );
+ return implode( '/', array_map( 'wfFormatStackFrame', $trace ) );
+}
+
+/**
+ * Return a string representation of frame
+ *
+ * @param array $frame
+ * @return string
+ */
+function wfFormatStackFrame( $frame ) {
+ if ( !isset( $frame['function'] ) ) {
+ return 'NO_FUNCTION_GIVEN';
+ }
+ return isset( $frame['class'] ) && isset( $frame['type'] ) ?
+ $frame['class'] . $frame['type'] . $frame['function'] :
+ $frame['function'];
+}
+
+/* Some generic result counters, pulled out of SearchEngine */
+
+/**
+ * @todo document
+ *
+ * @param int $offset
+ * @param int $limit
+ * @return string
+ */
+function wfShowingResults( $offset, $limit ) {
+ return wfMessage( 'showingresults' )->numParams( $limit, $offset + 1 )->parse();
+}
+
+/**
+ * Whether the client accept gzip encoding
+ *
+ * Uses the Accept-Encoding header to check if the client supports gzip encoding.
+ * Use this when considering to send a gzip-encoded response to the client.
+ *
+ * @param bool $force Forces another check even if we already have a cached result.
+ * @return bool
+ */
+function wfClientAcceptsGzip( $force = false ) {
+ static $result = null;
+ if ( $result === null || $force ) {
+ $result = false;
+ if ( isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) {
+ # @todo FIXME: We may want to blacklist some broken browsers
+ $m = [];
+ if ( preg_match(
+ '/\bgzip(?:;(q)=([0-9]+(?:\.[0-9]+)))?\b/',
+ $_SERVER['HTTP_ACCEPT_ENCODING'],
+ $m
+ )
+ ) {
+ if ( isset( $m[2] ) && ( $m[1] == 'q' ) && ( $m[2] == 0 ) ) {
+ $result = false;
+ return $result;
+ }
+ wfDebug( "wfClientAcceptsGzip: client accepts gzip.\n" );
+ $result = true;
+ }
+ }
+ }
+ return $result;
+}
+
+/**
+ * Escapes the given text so that it may be output using addWikiText()
+ * without any linking, formatting, etc. making its way through. This
+ * is achieved by substituting certain characters with HTML entities.
+ * As required by the callers, "<nowiki>" is not used.
+ *
+ * @param string $text Text to be escaped
+ * @return string
+ */
+function wfEscapeWikiText( $text ) {
+ global $wgEnableMagicLinks;
+ static $repl = null, $repl2 = null;
+ if ( $repl === null || defined( 'MW_PARSER_TEST' ) || defined( 'MW_PHPUNIT_TEST' ) ) {
+ // Tests depend upon being able to change $wgEnableMagicLinks, so don't cache
+ // in those situations
+ $repl = [
+ '"' => '&#34;', '&' => '&#38;', "'" => '&#39;', '<' => '&#60;',
+ '=' => '&#61;', '>' => '&#62;', '[' => '&#91;', ']' => '&#93;',
+ '{' => '&#123;', '|' => '&#124;', '}' => '&#125;', ';' => '&#59;',
+ "\n#" => "\n&#35;", "\r#" => "\r&#35;",
+ "\n*" => "\n&#42;", "\r*" => "\r&#42;",
+ "\n:" => "\n&#58;", "\r:" => "\r&#58;",
+ "\n " => "\n&#32;", "\r " => "\r&#32;",
+ "\n\n" => "\n&#10;", "\r\n" => "&#13;\n",
+ "\n\r" => "\n&#13;", "\r\r" => "\r&#13;",
+ "\n\t" => "\n&#9;", "\r\t" => "\r&#9;", // "\n\t\n" is treated like "\n\n"
+ "\n----" => "\n&#45;---", "\r----" => "\r&#45;---",
+ '__' => '_&#95;', '://' => '&#58;//',
+ ];
+
+ $magicLinks = array_keys( array_filter( $wgEnableMagicLinks ) );
+ // We have to catch everything "\s" matches in PCRE
+ foreach ( $magicLinks as $magic ) {
+ $repl["$magic "] = "$magic&#32;";
+ $repl["$magic\t"] = "$magic&#9;";
+ $repl["$magic\r"] = "$magic&#13;";
+ $repl["$magic\n"] = "$magic&#10;";
+ $repl["$magic\f"] = "$magic&#12;";
+ }
+
+ // And handle protocols that don't use "://"
+ global $wgUrlProtocols;
+ $repl2 = [];
+ foreach ( $wgUrlProtocols as $prot ) {
+ if ( substr( $prot, -1 ) === ':' ) {
+ $repl2[] = preg_quote( substr( $prot, 0, -1 ), '/' );
+ }
+ }
+ $repl2 = $repl2 ? '/\b(' . implode( '|', $repl2 ) . '):/i' : '/^(?!)/';
+ }
+ $text = substr( strtr( "\n$text", $repl ), 1 );
+ $text = preg_replace( $repl2, '$1&#58;', $text );
+ return $text;
+}
+
+/**
+ * Sets dest to source and returns the original value of dest
+ * If source is NULL, it just returns the value, it doesn't set the variable
+ * If force is true, it will set the value even if source is NULL
+ *
+ * @param mixed &$dest
+ * @param mixed $source
+ * @param bool $force
+ * @return mixed
+ */
+function wfSetVar( &$dest, $source, $force = false ) {
+ $temp = $dest;
+ if ( !is_null( $source ) || $force ) {
+ $dest = $source;
+ }
+ return $temp;
+}
+
+/**
+ * As for wfSetVar except setting a bit
+ *
+ * @param int &$dest
+ * @param int $bit
+ * @param bool $state
+ *
+ * @return bool
+ */
+function wfSetBit( &$dest, $bit, $state = true ) {
+ $temp = (bool)( $dest & $bit );
+ if ( !is_null( $state ) ) {
+ if ( $state ) {
+ $dest |= $bit;
+ } else {
+ $dest &= ~$bit;
+ }
+ }
+ return $temp;
+}
+
+/**
+ * A wrapper around the PHP function var_export().
+ * Either print it or add it to the regular output ($wgOut).
+ *
+ * @param mixed $var A PHP variable to dump.
+ */
+function wfVarDump( $var ) {
+ global $wgOut;
+ $s = str_replace( "\n", "<br />\n", var_export( $var, true ) . "\n" );
+ if ( headers_sent() || !isset( $wgOut ) || !is_object( $wgOut ) ) {
+ print $s;
+ } else {
+ $wgOut->addHTML( $s );
+ }
+}
+
+/**
+ * Provide a simple HTTP error.
+ *
+ * @param int|string $code
+ * @param string $label
+ * @param string $desc
+ */
+function wfHttpError( $code, $label, $desc ) {
+ global $wgOut;
+ HttpStatus::header( $code );
+ if ( $wgOut ) {
+ $wgOut->disable();
+ $wgOut->sendCacheControl();
+ }
+
+ MediaWiki\HeaderCallback::warnIfHeadersSent();
+ header( 'Content-type: text/html; charset=utf-8' );
+ print '<!DOCTYPE html>' .
+ '<html><head><title>' .
+ htmlspecialchars( $label ) .
+ '</title></head><body><h1>' .
+ htmlspecialchars( $label ) .
+ '</h1><p>' .
+ nl2br( htmlspecialchars( $desc ) ) .
+ "</p></body></html>\n";
+}
+
+/**
+ * Clear away any user-level output buffers, discarding contents.
+ *
+ * Suitable for 'starting afresh', for instance when streaming
+ * relatively large amounts of data without buffering, or wanting to
+ * output image files without ob_gzhandler's compression.
+ *
+ * The optional $resetGzipEncoding parameter controls suppression of
+ * the Content-Encoding header sent by ob_gzhandler; by default it
+ * is left. See comments for wfClearOutputBuffers() for why it would
+ * be used.
+ *
+ * Note that some PHP configuration options may add output buffer
+ * layers which cannot be removed; these are left in place.
+ *
+ * @param bool $resetGzipEncoding
+ */
+function wfResetOutputBuffers( $resetGzipEncoding = true ) {
+ if ( $resetGzipEncoding ) {
+ // Suppress Content-Encoding and Content-Length
+ // headers from 1.10+s wfOutputHandler
+ global $wgDisableOutputCompression;
+ $wgDisableOutputCompression = true;
+ }
+ while ( $status = ob_get_status() ) {
+ if ( isset( $status['flags'] ) ) {
+ $flags = PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_REMOVABLE;
+ $deleteable = ( $status['flags'] & $flags ) === $flags;
+ } elseif ( isset( $status['del'] ) ) {
+ $deleteable = $status['del'];
+ } else {
+ // Guess that any PHP-internal setting can't be removed.
+ $deleteable = $status['type'] !== 0; /* PHP_OUTPUT_HANDLER_INTERNAL */
+ }
+ if ( !$deleteable ) {
+ // Give up, and hope the result doesn't break
+ // output behavior.
+ break;
+ }
+ if ( $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier' ) {
+ // Unit testing barrier to prevent this function from breaking PHPUnit.
+ break;
+ }
+ if ( !ob_end_clean() ) {
+ // Could not remove output buffer handler; abort now
+ // to avoid getting in some kind of infinite loop.
+ break;
+ }
+ if ( $resetGzipEncoding ) {
+ if ( $status['name'] == 'ob_gzhandler' ) {
+ // Reset the 'Content-Encoding' field set by this handler
+ // so we can start fresh.
+ header_remove( 'Content-Encoding' );
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * More legible than passing a 'false' parameter to wfResetOutputBuffers():
+ *
+ * Clear away output buffers, but keep the Content-Encoding header
+ * produced by ob_gzhandler, if any.
+ *
+ * This should be used for HTTP 304 responses, where you need to
+ * preserve the Content-Encoding header of the real result, but
+ * also need to suppress the output of ob_gzhandler to keep to spec
+ * and avoid breaking Firefox in rare cases where the headers and
+ * body are broken over two packets.
+ */
+function wfClearOutputBuffers() {
+ wfResetOutputBuffers( false );
+}
+
+/**
+ * Converts an Accept-* header into an array mapping string values to quality
+ * factors
+ *
+ * @param string $accept
+ * @param string $def Default
+ * @return float[] Associative array of string => float pairs
+ */
+function wfAcceptToPrefs( $accept, $def = '*/*' ) {
+ # No arg means accept anything (per HTTP spec)
+ if ( !$accept ) {
+ return [ $def => 1.0 ];
+ }
+
+ $prefs = [];
+
+ $parts = explode( ',', $accept );
+
+ foreach ( $parts as $part ) {
+ # @todo FIXME: Doesn't deal with params like 'text/html; level=1'
+ $values = explode( ';', trim( $part ) );
+ $match = [];
+ if ( count( $values ) == 1 ) {
+ $prefs[$values[0]] = 1.0;
+ } elseif ( preg_match( '/q\s*=\s*(\d*\.\d+)/', $values[1], $match ) ) {
+ $prefs[$values[0]] = floatval( $match[1] );
+ }
+ }
+
+ return $prefs;
+}
+
+/**
+ * Checks if a given MIME type matches any of the keys in the given
+ * array. Basic wildcards are accepted in the array keys.
+ *
+ * Returns the matching MIME type (or wildcard) if a match, otherwise
+ * NULL if no match.
+ *
+ * @param string $type
+ * @param array $avail
+ * @return string
+ * @private
+ */
+function mimeTypeMatch( $type, $avail ) {
+ if ( array_key_exists( $type, $avail ) ) {
+ return $type;
+ } else {
+ $mainType = explode( '/', $type )[0];
+ if ( array_key_exists( "$mainType/*", $avail ) ) {
+ return "$mainType/*";
+ } elseif ( array_key_exists( '*/*', $avail ) ) {
+ return '*/*';
+ } else {
+ return null;
+ }
+ }
+}
+
+/**
+ * Returns the 'best' match between a client's requested internet media types
+ * and the server's list of available types. Each list should be an associative
+ * array of type to preference (preference is a float between 0.0 and 1.0).
+ * Wildcards in the types are acceptable.
+ *
+ * @param array $cprefs Client's acceptable type list
+ * @param array $sprefs Server's offered types
+ * @return string
+ *
+ * @todo FIXME: Doesn't handle params like 'text/plain; charset=UTF-8'
+ * XXX: generalize to negotiate other stuff
+ */
+function wfNegotiateType( $cprefs, $sprefs ) {
+ $combine = [];
+
+ foreach ( array_keys( $sprefs ) as $type ) {
+ $subType = explode( '/', $type )[1];
+ if ( $subType != '*' ) {
+ $ckey = mimeTypeMatch( $type, $cprefs );
+ if ( $ckey ) {
+ $combine[$type] = $sprefs[$type] * $cprefs[$ckey];
+ }
+ }
+ }
+
+ foreach ( array_keys( $cprefs ) as $type ) {
+ $subType = explode( '/', $type )[1];
+ if ( $subType != '*' && !array_key_exists( $type, $sprefs ) ) {
+ $skey = mimeTypeMatch( $type, $sprefs );
+ if ( $skey ) {
+ $combine[$type] = $sprefs[$skey] * $cprefs[$type];
+ }
+ }
+ }
+
+ $bestq = 0;
+ $besttype = null;
+
+ foreach ( array_keys( $combine ) as $type ) {
+ if ( $combine[$type] > $bestq ) {
+ $besttype = $type;
+ $bestq = $combine[$type];
+ }
+ }
+
+ return $besttype;
+}
+
+/**
+ * Reference-counted warning suppression
+ *
+ * @deprecated since 1.26, use MediaWiki\suppressWarnings() directly
+ * @param bool $end
+ */
+function wfSuppressWarnings( $end = false ) {
+ MediaWiki\suppressWarnings( $end );
+}
+
+/**
+ * @deprecated since 1.26, use MediaWiki\restoreWarnings() directly
+ * Restore error level to previous value
+ */
+function wfRestoreWarnings() {
+ MediaWiki\suppressWarnings( true );
+}
+
+/**
+ * Get a timestamp string in one of various formats
+ *
+ * @param mixed $outputtype A timestamp in one of the supported formats, the
+ * function will autodetect which format is supplied and act accordingly.
+ * @param mixed $ts Optional timestamp to convert, default 0 for the current time
+ * @return string|bool String / false The same date in the format specified in $outputtype or false
+ */
+function wfTimestamp( $outputtype = TS_UNIX, $ts = 0 ) {
+ $ret = MWTimestamp::convert( $outputtype, $ts );
+ if ( $ret === false ) {
+ wfDebug( "wfTimestamp() fed bogus time value: TYPE=$outputtype; VALUE=$ts\n" );
+ }
+ return $ret;
+}
+
+/**
+ * Return a formatted timestamp, or null if input is null.
+ * For dealing with nullable timestamp columns in the database.
+ *
+ * @param int $outputtype
+ * @param string $ts
+ * @return string
+ */
+function wfTimestampOrNull( $outputtype = TS_UNIX, $ts = null ) {
+ if ( is_null( $ts ) ) {
+ return null;
+ } else {
+ return wfTimestamp( $outputtype, $ts );
+ }
+}
+
+/**
+ * Convenience function; returns MediaWiki timestamp for the present time.
+ *
+ * @return string
+ */
+function wfTimestampNow() {
+ # return NOW
+ return MWTimestamp::now( TS_MW );
+}
+
+/**
+ * Check if the operating system is Windows
+ *
+ * @return bool True if it's Windows, false otherwise.
+ */
+function wfIsWindows() {
+ static $isWindows = null;
+ if ( $isWindows === null ) {
+ $isWindows = strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN';
+ }
+ return $isWindows;
+}
+
+/**
+ * Check if we are running under HHVM
+ *
+ * @return bool
+ */
+function wfIsHHVM() {
+ return defined( 'HHVM_VERSION' );
+}
+
+/**
+ * Tries to get the system directory for temporary files. First
+ * $wgTmpDirectory is checked, and then the TMPDIR, TMP, and TEMP
+ * environment variables are then checked in sequence, then
+ * sys_get_temp_dir(), then upload_tmp_dir from php.ini.
+ *
+ * NOTE: When possible, use instead the tmpfile() function to create
+ * temporary files to avoid race conditions on file creation, etc.
+ *
+ * @return string
+ */
+function wfTempDir() {
+ global $wgTmpDirectory;
+
+ if ( $wgTmpDirectory !== false ) {
+ return $wgTmpDirectory;
+ }
+
+ return TempFSFile::getUsableTempDirectory();
+}
+
+/**
+ * Make directory, and make all parent directories if they don't exist
+ *
+ * @param string $dir Full path to directory to create
+ * @param int $mode Chmod value to use, default is $wgDirectoryMode
+ * @param string $caller Optional caller param for debugging.
+ * @throws MWException
+ * @return bool
+ */
+function wfMkdirParents( $dir, $mode = null, $caller = null ) {
+ global $wgDirectoryMode;
+
+ if ( FileBackend::isStoragePath( $dir ) ) { // sanity
+ throw new MWException( __FUNCTION__ . " given storage path '$dir'." );
+ }
+
+ if ( !is_null( $caller ) ) {
+ wfDebug( "$caller: called wfMkdirParents($dir)\n" );
+ }
+
+ if ( strval( $dir ) === '' || is_dir( $dir ) ) {
+ return true;
+ }
+
+ $dir = str_replace( [ '\\', '/' ], DIRECTORY_SEPARATOR, $dir );
+
+ if ( is_null( $mode ) ) {
+ $mode = $wgDirectoryMode;
+ }
+
+ // Turn off the normal warning, we're doing our own below
+ MediaWiki\suppressWarnings();
+ $ok = mkdir( $dir, $mode, true ); // PHP5 <3
+ MediaWiki\restoreWarnings();
+
+ if ( !$ok ) {
+ // directory may have been created on another request since we last checked
+ if ( is_dir( $dir ) ) {
+ return true;
+ }
+
+ // PHP doesn't report the path in its warning message, so add our own to aid in diagnosis.
+ wfLogWarning( sprintf( "failed to mkdir \"%s\" mode 0%o", $dir, $mode ) );
+ }
+ return $ok;
+}
+
+/**
+ * Remove a directory and all its content.
+ * Does not hide error.
+ * @param string $dir
+ */
+function wfRecursiveRemoveDir( $dir ) {
+ wfDebug( __FUNCTION__ . "( $dir )\n" );
+ // taken from https://secure.php.net/manual/en/function.rmdir.php#98622
+ if ( is_dir( $dir ) ) {
+ $objects = scandir( $dir );
+ foreach ( $objects as $object ) {
+ if ( $object != "." && $object != ".." ) {
+ if ( filetype( $dir . '/' . $object ) == "dir" ) {
+ wfRecursiveRemoveDir( $dir . '/' . $object );
+ } else {
+ unlink( $dir . '/' . $object );
+ }
+ }
+ }
+ reset( $objects );
+ rmdir( $dir );
+ }
+}
+
+/**
+ * @param int $nr The number to format
+ * @param int $acc The number of digits after the decimal point, default 2
+ * @param bool $round Whether or not to round the value, default true
+ * @return string
+ */
+function wfPercent( $nr, $acc = 2, $round = true ) {
+ $ret = sprintf( "%.${acc}f", $nr );
+ return $round ? round( $ret, $acc ) . '%' : "$ret%";
+}
+
+/**
+ * Safety wrapper around ini_get() for boolean settings.
+ * The values returned from ini_get() are pre-normalized for settings
+ * set via php.ini or php_flag/php_admin_flag... but *not*
+ * for those set via php_value/php_admin_value.
+ *
+ * It's fairly common for people to use php_value instead of php_flag,
+ * which can leave you with an 'off' setting giving a false positive
+ * for code that just takes the ini_get() return value as a boolean.
+ *
+ * To make things extra interesting, setting via php_value accepts
+ * "true" and "yes" as true, but php.ini and php_flag consider them false. :)
+ * Unrecognized values go false... again opposite PHP's own coercion
+ * from string to bool.
+ *
+ * Luckily, 'properly' set settings will always come back as '0' or '1',
+ * so we only have to worry about them and the 'improper' settings.
+ *
+ * I frickin' hate PHP... :P
+ *
+ * @param string $setting
+ * @return bool
+ */
+function wfIniGetBool( $setting ) {
+ $val = strtolower( ini_get( $setting ) );
+ // 'on' and 'true' can't have whitespace around them, but '1' can.
+ return $val == 'on'
+ || $val == 'true'
+ || $val == 'yes'
+ || preg_match( "/^\s*[+-]?0*[1-9]/", $val ); // approx C atoi() function
+}
+
+/**
+ * Version of escapeshellarg() that works better on Windows.
+ *
+ * Originally, this fixed the incorrect use of single quotes on Windows
+ * (https://bugs.php.net/bug.php?id=26285) and the locale problems on Linux in
+ * PHP 5.2.6+ (bug backported to earlier distro releases of PHP).
+ *
+ * @param string $args,... strings to escape and glue together,
+ * or a single array of strings parameter
+ * @return string
+ * @deprecated since 1.30 use MediaWiki\Shell::escape()
+ */
+function wfEscapeShellArg( /*...*/ ) {
+ $args = func_get_args();
+
+ return call_user_func_array( Shell::class . '::escape', $args );
+}
+
+/**
+ * Check if wfShellExec() is effectively disabled via php.ini config
+ *
+ * @return bool|string False or 'disabled'
+ * @since 1.22
+ * @deprecated since 1.30 use MediaWiki\Shell::isDisabled()
+ */
+function wfShellExecDisabled() {
+ return Shell::isDisabled() ? 'disabled' : false;
+}
+
+/**
+ * Execute a shell command, with time and memory limits mirrored from the PHP
+ * configuration if supported.
+ *
+ * @param string|string[] $cmd If string, a properly shell-escaped command line,
+ * or an array of unescaped arguments, in which case each value will be escaped
+ * Example: [ 'convert', '-font', 'font name' ] would produce "'convert' '-font' 'font name'"
+ * @param null|mixed &$retval Optional, will receive the program's exit code.
+ * (non-zero is usually failure). If there is an error from
+ * read, select, or proc_open(), this will be set to -1.
+ * @param array $environ Optional environment variables which should be
+ * added to the executed command environment.
+ * @param array $limits Optional array with limits(filesize, memory, time, walltime)
+ * this overwrites the global wgMaxShell* limits.
+ * @param array $options Array of options:
+ * - duplicateStderr: Set this to true to duplicate stderr to stdout,
+ * including errors from limit.sh
+ * - profileMethod: By default this function will profile based on the calling
+ * method. Set this to a string for an alternative method to profile from
+ *
+ * @return string Collected stdout as a string
+ * @deprecated since 1.30 use class MediaWiki\Shell\Shell
+ */
+function wfShellExec( $cmd, &$retval = null, $environ = [],
+ $limits = [], $options = []
+) {
+ if ( Shell::isDisabled() ) {
+ $retval = 1;
+ // Backwards compatibility be upon us...
+ return 'Unable to run external programs, proc_open() is disabled.';
+ }
+
+ if ( is_array( $cmd ) ) {
+ $cmd = Shell::escape( $cmd );
+ }
+
+ $includeStderr = isset( $options['duplicateStderr'] ) && $options['duplicateStderr'];
+ $profileMethod = isset( $options['profileMethod'] ) ? $options['profileMethod'] : wfGetCaller();
+
+ try {
+ $result = Shell::command( [] )
+ ->unsafeParams( (array)$cmd )
+ ->environment( $environ )
+ ->limits( $limits )
+ ->includeStderr( $includeStderr )
+ ->profileMethod( $profileMethod )
+ ->execute();
+ } catch ( ProcOpenError $ex ) {
+ $retval = -1;
+ return '';
+ }
+
+ $retval = $result->getExitCode();
+
+ return $result->getStdout();
+}
+
+/**
+ * Execute a shell command, returning both stdout and stderr. Convenience
+ * function, as all the arguments to wfShellExec can become unwieldy.
+ *
+ * @note This also includes errors from limit.sh, e.g. if $wgMaxShellFileSize is exceeded.
+ * @param string|string[] $cmd If string, a properly shell-escaped command line,
+ * or an array of unescaped arguments, in which case each value will be escaped
+ * Example: [ 'convert', '-font', 'font name' ] would produce "'convert' '-font' 'font name'"
+ * @param null|mixed &$retval Optional, will receive the program's exit code.
+ * (non-zero is usually failure)
+ * @param array $environ Optional environment variables which should be
+ * added to the executed command environment.
+ * @param array $limits Optional array with limits(filesize, memory, time, walltime)
+ * this overwrites the global wgMaxShell* limits.
+ * @return string Collected stdout and stderr as a string
+ * @deprecated since 1.30 use class MediaWiki\Shell\Shell
+ */
+function wfShellExecWithStderr( $cmd, &$retval = null, $environ = [], $limits = [] ) {
+ return wfShellExec( $cmd, $retval, $environ, $limits,
+ [ 'duplicateStderr' => true, 'profileMethod' => wfGetCaller() ] );
+}
+
+/**
+ * Formerly set the locale for locale-sensitive operations
+ *
+ * This is now done in Setup.php.
+ *
+ * @deprecated since 1.30, no longer needed
+ * @see $wgShellLocale
+ */
+function wfInitShellLocale() {
+}
+
+/**
+ * Generate a shell-escaped command line string to run a MediaWiki cli script.
+ * Note that $parameters should be a flat array and an option with an argument
+ * should consist of two consecutive items in the array (do not use "--option value").
+ *
+ * @param string $script MediaWiki cli script path
+ * @param array $parameters Arguments and options to the script
+ * @param array $options Associative array of options:
+ * 'php': The path to the php executable
+ * 'wrapper': Path to a PHP wrapper to handle the maintenance script
+ * @return string
+ */
+function wfShellWikiCmd( $script, array $parameters = [], array $options = [] ) {
+ global $wgPhpCli;
+ // Give site config file a chance to run the script in a wrapper.
+ // The caller may likely want to call wfBasename() on $script.
+ Hooks::run( 'wfShellWikiCmd', [ &$script, &$parameters, &$options ] );
+ $cmd = isset( $options['php'] ) ? [ $options['php'] ] : [ $wgPhpCli ];
+ if ( isset( $options['wrapper'] ) ) {
+ $cmd[] = $options['wrapper'];
+ }
+ $cmd[] = $script;
+ // Escape each parameter for shell
+ return Shell::escape( array_merge( $cmd, $parameters ) );
+}
+
+/**
+ * wfMerge attempts to merge differences between three texts.
+ * Returns true for a clean merge and false for failure or a conflict.
+ *
+ * @param string $old
+ * @param string $mine
+ * @param string $yours
+ * @param string &$result
+ * @return bool
+ */
+function wfMerge( $old, $mine, $yours, &$result ) {
+ global $wgDiff3;
+
+ # This check may also protect against code injection in
+ # case of broken installations.
+ MediaWiki\suppressWarnings();
+ $haveDiff3 = $wgDiff3 && file_exists( $wgDiff3 );
+ MediaWiki\restoreWarnings();
+
+ if ( !$haveDiff3 ) {
+ wfDebug( "diff3 not found\n" );
+ return false;
+ }
+
+ # Make temporary files
+ $td = wfTempDir();
+ $oldtextFile = fopen( $oldtextName = tempnam( $td, 'merge-old-' ), 'w' );
+ $mytextFile = fopen( $mytextName = tempnam( $td, 'merge-mine-' ), 'w' );
+ $yourtextFile = fopen( $yourtextName = tempnam( $td, 'merge-your-' ), 'w' );
+
+ # NOTE: diff3 issues a warning to stderr if any of the files does not end with
+ # a newline character. To avoid this, we normalize the trailing whitespace before
+ # creating the diff.
+
+ fwrite( $oldtextFile, rtrim( $old ) . "\n" );
+ fclose( $oldtextFile );
+ fwrite( $mytextFile, rtrim( $mine ) . "\n" );
+ fclose( $mytextFile );
+ fwrite( $yourtextFile, rtrim( $yours ) . "\n" );
+ fclose( $yourtextFile );
+
+ # Check for a conflict
+ $cmd = Shell::escape( $wgDiff3, '-a', '--overlap-only', $mytextName,
+ $oldtextName, $yourtextName );
+ $handle = popen( $cmd, 'r' );
+
+ if ( fgets( $handle, 1024 ) ) {
+ $conflict = true;
+ } else {
+ $conflict = false;
+ }
+ pclose( $handle );
+
+ # Merge differences
+ $cmd = Shell::escape( $wgDiff3, '-a', '-e', '--merge', $mytextName,
+ $oldtextName, $yourtextName );
+ $handle = popen( $cmd, 'r' );
+ $result = '';
+ do {
+ $data = fread( $handle, 8192 );
+ if ( strlen( $data ) == 0 ) {
+ break;
+ }
+ $result .= $data;
+ } while ( true );
+ pclose( $handle );
+ unlink( $mytextName );
+ unlink( $oldtextName );
+ unlink( $yourtextName );
+
+ if ( $result === '' && $old !== '' && !$conflict ) {
+ wfDebug( "Unexpected null result from diff3. Command: $cmd\n" );
+ $conflict = true;
+ }
+ return !$conflict;
+}
+
+/**
+ * Returns unified plain-text diff of two texts.
+ * "Useful" for machine processing of diffs.
+ *
+ * @deprecated since 1.25, use DiffEngine/UnifiedDiffFormatter directly
+ *
+ * @param string $before The text before the changes.
+ * @param string $after The text after the changes.
+ * @param string $params Command-line options for the diff command.
+ * @return string Unified diff of $before and $after
+ */
+function wfDiff( $before, $after, $params = '-u' ) {
+ if ( $before == $after ) {
+ return '';
+ }
+
+ global $wgDiff;
+ MediaWiki\suppressWarnings();
+ $haveDiff = $wgDiff && file_exists( $wgDiff );
+ MediaWiki\restoreWarnings();
+
+ # This check may also protect against code injection in
+ # case of broken installations.
+ if ( !$haveDiff ) {
+ wfDebug( "diff executable not found\n" );
+ $diffs = new Diff( explode( "\n", $before ), explode( "\n", $after ) );
+ $format = new UnifiedDiffFormatter();
+ return $format->format( $diffs );
+ }
+
+ # Make temporary files
+ $td = wfTempDir();
+ $oldtextFile = fopen( $oldtextName = tempnam( $td, 'merge-old-' ), 'w' );
+ $newtextFile = fopen( $newtextName = tempnam( $td, 'merge-your-' ), 'w' );
+
+ fwrite( $oldtextFile, $before );
+ fclose( $oldtextFile );
+ fwrite( $newtextFile, $after );
+ fclose( $newtextFile );
+
+ // Get the diff of the two files
+ $cmd = "$wgDiff " . $params . ' ' . Shell::escape( $oldtextName, $newtextName );
+
+ $h = popen( $cmd, 'r' );
+ if ( !$h ) {
+ unlink( $oldtextName );
+ unlink( $newtextName );
+ throw new Exception( __METHOD__ . '(): popen() failed' );
+ }
+
+ $diff = '';
+
+ do {
+ $data = fread( $h, 8192 );
+ if ( strlen( $data ) == 0 ) {
+ break;
+ }
+ $diff .= $data;
+ } while ( true );
+
+ // Clean up
+ pclose( $h );
+ unlink( $oldtextName );
+ unlink( $newtextName );
+
+ // Kill the --- and +++ lines. They're not useful.
+ $diff_lines = explode( "\n", $diff );
+ if ( isset( $diff_lines[0] ) && strpos( $diff_lines[0], '---' ) === 0 ) {
+ unset( $diff_lines[0] );
+ }
+ if ( isset( $diff_lines[1] ) && strpos( $diff_lines[1], '+++' ) === 0 ) {
+ unset( $diff_lines[1] );
+ }
+
+ $diff = implode( "\n", $diff_lines );
+
+ return $diff;
+}
+
+/**
+ * This function works like "use VERSION" in Perl, the program will die with a
+ * backtrace if the current version of PHP is less than the version provided
+ *
+ * This is useful for extensions which due to their nature are not kept in sync
+ * with releases, and might depend on other versions of PHP than the main code
+ *
+ * Note: PHP might die due to parsing errors in some cases before it ever
+ * manages to call this function, such is life
+ *
+ * @see perldoc -f use
+ *
+ * @param string|int|float $req_ver The version to check, can be a string, an integer, or a float
+ *
+ * @deprecated since 1.30
+ *
+ * @throws MWException
+ */
+function wfUsePHP( $req_ver ) {
+ $php_ver = PHP_VERSION;
+
+ if ( version_compare( $php_ver, (string)$req_ver, '<' ) ) {
+ throw new MWException( "PHP $req_ver required--this is only $php_ver" );
+ }
+}
+
+/**
+ * This function works like "use VERSION" in Perl except it checks the version
+ * of MediaWiki, the program will die with a backtrace if the current version
+ * of MediaWiki is less than the version provided.
+ *
+ * This is useful for extensions which due to their nature are not kept in sync
+ * with releases
+ *
+ * Note: Due to the behavior of PHP's version_compare() which is used in this
+ * function, if you want to allow the 'wmf' development versions add a 'c' (or
+ * any single letter other than 'a', 'b' or 'p') as a post-fix to your
+ * targeted version number. For example if you wanted to allow any variation
+ * of 1.22 use `wfUseMW( '1.22c' )`. Using an 'a' or 'b' instead of 'c' will
+ * not result in the same comparison due to the internal logic of
+ * version_compare().
+ *
+ * @see perldoc -f use
+ *
+ * @deprecated since 1.26, use the "requires" property of extension.json
+ * @param string|int|float $req_ver The version to check, can be a string, an integer, or a float
+ * @throws MWException
+ */
+function wfUseMW( $req_ver ) {
+ global $wgVersion;
+
+ if ( version_compare( $wgVersion, (string)$req_ver, '<' ) ) {
+ throw new MWException( "MediaWiki $req_ver required--this is only $wgVersion" );
+ }
+}
+
+/**
+ * Return the final portion of a pathname.
+ * Reimplemented because PHP5's "basename()" is buggy with multibyte text.
+ * https://bugs.php.net/bug.php?id=33898
+ *
+ * PHP's basename() only considers '\' a pathchar on Windows and Netware.
+ * We'll consider it so always, as we don't want '\s' in our Unix paths either.
+ *
+ * @param string $path
+ * @param string $suffix String to remove if present
+ * @return string
+ */
+function wfBaseName( $path, $suffix = '' ) {
+ if ( $suffix == '' ) {
+ $encSuffix = '';
+ } else {
+ $encSuffix = '(?:' . preg_quote( $suffix, '#' ) . ')?';
+ }
+
+ $matches = [];
+ if ( preg_match( "#([^/\\\\]*?){$encSuffix}[/\\\\]*$#", $path, $matches ) ) {
+ return $matches[1];
+ } else {
+ return '';
+ }
+}
+
+/**
+ * Generate a relative path name to the given file.
+ * May explode on non-matching case-insensitive paths,
+ * funky symlinks, etc.
+ *
+ * @param string $path Absolute destination path including target filename
+ * @param string $from Absolute source path, directory only
+ * @return string
+ */
+function wfRelativePath( $path, $from ) {
+ // Normalize mixed input on Windows...
+ $path = str_replace( '/', DIRECTORY_SEPARATOR, $path );
+ $from = str_replace( '/', DIRECTORY_SEPARATOR, $from );
+
+ // Trim trailing slashes -- fix for drive root
+ $path = rtrim( $path, DIRECTORY_SEPARATOR );
+ $from = rtrim( $from, DIRECTORY_SEPARATOR );
+
+ $pieces = explode( DIRECTORY_SEPARATOR, dirname( $path ) );
+ $against = explode( DIRECTORY_SEPARATOR, $from );
+
+ if ( $pieces[0] !== $against[0] ) {
+ // Non-matching Windows drive letters?
+ // Return a full path.
+ return $path;
+ }
+
+ // Trim off common prefix
+ while ( count( $pieces ) && count( $against )
+ && $pieces[0] == $against[0] ) {
+ array_shift( $pieces );
+ array_shift( $against );
+ }
+
+ // relative dots to bump us to the parent
+ while ( count( $against ) ) {
+ array_unshift( $pieces, '..' );
+ array_shift( $against );
+ }
+
+ array_push( $pieces, wfBaseName( $path ) );
+
+ return implode( DIRECTORY_SEPARATOR, $pieces );
+}
+
+/**
+ * Convert an arbitrarily-long digit string from one numeric base
+ * to another, optionally zero-padding to a minimum column width.
+ *
+ * Supports base 2 through 36; digit values 10-36 are represented
+ * as lowercase letters a-z. Input is case-insensitive.
+ *
+ * @deprecated since 1.27 Use Wikimedia\base_convert() directly
+ *
+ * @param string $input Input number
+ * @param int $sourceBase Base of the input number
+ * @param int $destBase Desired base of the output
+ * @param int $pad Minimum number of digits in the output (pad with zeroes)
+ * @param bool $lowercase Whether to output in lowercase or uppercase
+ * @param string $engine Either "gmp", "bcmath", or "php"
+ * @return string|bool The output number as a string, or false on error
+ */
+function wfBaseConvert( $input, $sourceBase, $destBase, $pad = 1,
+ $lowercase = true, $engine = 'auto'
+) {
+ return Wikimedia\base_convert( $input, $sourceBase, $destBase, $pad, $lowercase, $engine );
+}
+
+/**
+ * Reset the session id
+ *
+ * @deprecated since 1.27, use MediaWiki\Session\SessionManager instead
+ * @since 1.22
+ */
+function wfResetSessionID() {
+ wfDeprecated( __FUNCTION__, '1.27' );
+ $session = SessionManager::getGlobalSession();
+ $delay = $session->delaySave();
+
+ $session->resetId();
+
+ // Make sure a session is started, since that's what the old
+ // wfResetSessionID() did.
+ if ( session_id() !== $session->getId() ) {
+ wfSetupSession( $session->getId() );
+ }
+
+ ScopedCallback::consume( $delay );
+}
+
+/**
+ * Initialise php session
+ *
+ * @deprecated since 1.27, use MediaWiki\Session\SessionManager instead.
+ * Generally, "using" SessionManager will be calling ->getSessionById() or
+ * ::getGlobalSession() (depending on whether you were passing $sessionId
+ * here), then calling $session->persist().
+ * @param bool|string $sessionId
+ */
+function wfSetupSession( $sessionId = false ) {
+ wfDeprecated( __FUNCTION__, '1.27' );
+
+ if ( $sessionId ) {
+ session_id( $sessionId );
+ }
+
+ $session = SessionManager::getGlobalSession();
+ $session->persist();
+
+ if ( session_id() !== $session->getId() ) {
+ session_id( $session->getId() );
+ }
+ MediaWiki\quietCall( 'session_start' );
+}
+
+/**
+ * Get an object from the precompiled serialized directory
+ *
+ * @param string $name
+ * @return mixed The variable on success, false on failure
+ */
+function wfGetPrecompiledData( $name ) {
+ global $IP;
+
+ $file = "$IP/serialized/$name";
+ if ( file_exists( $file ) ) {
+ $blob = file_get_contents( $file );
+ if ( $blob ) {
+ return unserialize( $blob );
+ }
+ }
+ return false;
+}
+
+/**
+ * Make a cache key for the local wiki.
+ *
+ * @deprecated since 1.30 Call makeKey on a BagOStuff instance
+ * @param string $args,...
+ * @return string
+ */
+function wfMemcKey( /*...*/ ) {
+ return call_user_func_array(
+ [ ObjectCache::getLocalClusterInstance(), 'makeKey' ],
+ func_get_args()
+ );
+}
+
+/**
+ * Make a cache key for a foreign DB.
+ *
+ * Must match what wfMemcKey() would produce in context of the foreign wiki.
+ *
+ * @param string $db
+ * @param string $prefix
+ * @param string $args,...
+ * @return string
+ */
+function wfForeignMemcKey( $db, $prefix /*...*/ ) {
+ $args = array_slice( func_get_args(), 2 );
+ $keyspace = $prefix ? "$db-$prefix" : $db;
+ return call_user_func_array(
+ [ ObjectCache::getLocalClusterInstance(), 'makeKeyInternal' ],
+ [ $keyspace, $args ]
+ );
+}
+
+/**
+ * Make a cache key with database-agnostic prefix.
+ *
+ * Doesn't have a wiki-specific namespace. Uses a generic 'global' prefix
+ * instead. Must have a prefix as otherwise keys that use a database name
+ * in the first segment will clash with wfMemcKey/wfForeignMemcKey.
+ *
+ * @deprecated since 1.30 Call makeGlobalKey on a BagOStuff instance
+ * @since 1.26
+ * @param string $args,...
+ * @return string
+ */
+function wfGlobalCacheKey( /*...*/ ) {
+ return call_user_func_array(
+ [ ObjectCache::getLocalClusterInstance(), 'makeGlobalKey' ],
+ func_get_args()
+ );
+}
+
+/**
+ * Get an ASCII string identifying this wiki
+ * This is used as a prefix in memcached keys
+ *
+ * @return string
+ */
+function wfWikiID() {
+ global $wgDBprefix, $wgDBname;
+ if ( $wgDBprefix ) {
+ return "$wgDBname-$wgDBprefix";
+ } else {
+ return $wgDBname;
+ }
+}
+
+/**
+ * Split a wiki ID into DB name and table prefix
+ *
+ * @param string $wiki
+ *
+ * @return array
+ */
+function wfSplitWikiID( $wiki ) {
+ $bits = explode( '-', $wiki, 2 );
+ if ( count( $bits ) < 2 ) {
+ $bits[] = '';
+ }
+ return $bits;
+}
+
+/**
+ * Get a Database object.
+ *
+ * @param int $db Index of the connection to get. May be DB_MASTER for the
+ * master (for write queries), DB_REPLICA for potentially lagged read
+ * queries, or an integer >= 0 for a particular server.
+ *
+ * @param string|string[] $groups Query groups. An array of group names that this query
+ * belongs to. May contain a single string if the query is only
+ * in one group.
+ *
+ * @param string|bool $wiki The wiki ID, or false for the current wiki
+ *
+ * Note: multiple calls to wfGetDB(DB_REPLICA) during the course of one request
+ * will always return the same object, unless the underlying connection or load
+ * balancer is manually destroyed.
+ *
+ * Note 2: use $this->getDB() in maintenance scripts that may be invoked by
+ * updater to ensure that a proper database is being updated.
+ *
+ * @todo Replace calls to wfGetDB with calls to LoadBalancer::getConnection()
+ * on an injected instance of LoadBalancer.
+ *
+ * @return \Wikimedia\Rdbms\Database
+ */
+function wfGetDB( $db, $groups = [], $wiki = false ) {
+ return wfGetLB( $wiki )->getConnection( $db, $groups, $wiki );
+}
+
+/**
+ * Get a load balancer object.
+ *
+ * @deprecated since 1.27, use MediaWikiServices::getDBLoadBalancer()
+ * or MediaWikiServices::getDBLoadBalancerFactory() instead.
+ *
+ * @param string|bool $wiki Wiki ID, or false for the current wiki
+ * @return \Wikimedia\Rdbms\LoadBalancer
+ */
+function wfGetLB( $wiki = false ) {
+ if ( $wiki === false ) {
+ return MediaWikiServices::getInstance()->getDBLoadBalancer();
+ } else {
+ $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ return $factory->getMainLB( $wiki );
+ }
+}
+
+/**
+ * Get the load balancer factory object
+ *
+ * @deprecated since 1.27, use MediaWikiServices::getDBLoadBalancerFactory() instead.
+ *
+ * @return \Wikimedia\Rdbms\LBFactory
+ */
+function wfGetLBFactory() {
+ return MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+}
+
+/**
+ * Find a file.
+ * Shortcut for RepoGroup::singleton()->findFile()
+ *
+ * @param string $title String or Title object
+ * @param array $options Associative array of options (see RepoGroup::findFile)
+ * @return File|bool File, or false if the file does not exist
+ */
+function wfFindFile( $title, $options = [] ) {
+ return RepoGroup::singleton()->findFile( $title, $options );
+}
+
+/**
+ * Get an object referring to a locally registered file.
+ * Returns a valid placeholder object if the file does not exist.
+ *
+ * @param Title|string $title
+ * @return LocalFile|null A File, or null if passed an invalid Title
+ */
+function wfLocalFile( $title ) {
+ return RepoGroup::singleton()->getLocalRepo()->newFile( $title );
+}
+
+/**
+ * Should low-performance queries be disabled?
+ *
+ * @return bool
+ * @codeCoverageIgnore
+ */
+function wfQueriesMustScale() {
+ global $wgMiserMode;
+ return $wgMiserMode
+ || ( SiteStats::pages() > 100000
+ && SiteStats::edits() > 1000000
+ && SiteStats::users() > 10000 );
+}
+
+/**
+ * Get the path to a specified script file, respecting file
+ * extensions; this is a wrapper around $wgScriptPath etc.
+ * except for 'index' and 'load' which use $wgScript/$wgLoadScript
+ *
+ * @param string $script Script filename, sans extension
+ * @return string
+ */
+function wfScript( $script = 'index' ) {
+ global $wgScriptPath, $wgScript, $wgLoadScript;
+ if ( $script === 'index' ) {
+ return $wgScript;
+ } elseif ( $script === 'load' ) {
+ return $wgLoadScript;
+ } else {
+ return "{$wgScriptPath}/{$script}.php";
+ }
+}
+
+/**
+ * Get the script URL.
+ *
+ * @return string Script URL
+ */
+function wfGetScriptUrl() {
+ if ( isset( $_SERVER['SCRIPT_NAME'] ) ) {
+ /* as it was called, minus the query string.
+ *
+ * Some sites use Apache rewrite rules to handle subdomains,
+ * and have PHP set up in a weird way that causes PHP_SELF
+ * to contain the rewritten URL instead of the one that the
+ * outside world sees.
+ *
+ * If in this mode, use SCRIPT_URL instead, which mod_rewrite
+ * provides containing the "before" URL.
+ */
+ return $_SERVER['SCRIPT_NAME'];
+ } else {
+ return $_SERVER['URL'];
+ }
+}
+
+/**
+ * Convenience function converts boolean values into "true"
+ * or "false" (string) values
+ *
+ * @param bool $value
+ * @return string
+ */
+function wfBoolToStr( $value ) {
+ return $value ? 'true' : 'false';
+}
+
+/**
+ * Get a platform-independent path to the null file, e.g. /dev/null
+ *
+ * @return string
+ */
+function wfGetNull() {
+ return wfIsWindows() ? 'NUL' : '/dev/null';
+}
+
+/**
+ * Waits for the replica DBs to catch up to the master position
+ *
+ * Use this when updating very large numbers of rows, as in maintenance scripts,
+ * to avoid causing too much lag. Of course, this is a no-op if there are no replica DBs.
+ *
+ * By default this waits on the main DB cluster of the current wiki.
+ * If $cluster is set to "*" it will wait on all DB clusters, including
+ * external ones. If the lag being waiting on is caused by the code that
+ * does this check, it makes since to use $ifWritesSince, particularly if
+ * cluster is "*", to avoid excess overhead.
+ *
+ * Never call this function after a big DB write that is still in a transaction.
+ * This only makes sense after the possible lag inducing changes were committed.
+ *
+ * @param float|null $ifWritesSince Only wait if writes were done since this UNIX timestamp
+ * @param string|bool $wiki Wiki identifier accepted by wfGetLB
+ * @param string|bool $cluster Cluster name accepted by LBFactory. Default: false.
+ * @param int|null $timeout Max wait time. Default: 1 day (cli), ~10 seconds (web)
+ * @return bool Success (able to connect and no timeouts reached)
+ * @deprecated since 1.27 Use LBFactory::waitForReplication
+ */
+function wfWaitForSlaves(
+ $ifWritesSince = null, $wiki = false, $cluster = false, $timeout = null
+) {
+ if ( $timeout === null ) {
+ $timeout = ( PHP_SAPI === 'cli' ) ? 86400 : 10;
+ }
+
+ if ( $cluster === '*' ) {
+ $cluster = false;
+ $wiki = false;
+ } elseif ( $wiki === false ) {
+ $wiki = wfWikiID();
+ }
+
+ try {
+ wfGetLBFactory()->waitForReplication( [
+ 'wiki' => $wiki,
+ 'cluster' => $cluster,
+ 'timeout' => $timeout,
+ // B/C: first argument used to be "max seconds of lag"; ignore such values
+ 'ifWritesSince' => ( $ifWritesSince > 1e9 ) ? $ifWritesSince : null
+ ] );
+ } catch ( DBReplicationWaitError $e ) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Count down from $seconds to zero on the terminal, with a one-second pause
+ * between showing each number. For use in command-line scripts.
+ *
+ * @codeCoverageIgnore
+ * @param int $seconds
+ */
+function wfCountDown( $seconds ) {
+ for ( $i = $seconds; $i >= 0; $i-- ) {
+ if ( $i != $seconds ) {
+ echo str_repeat( "\x08", strlen( $i + 1 ) );
+ }
+ echo $i;
+ flush();
+ if ( $i ) {
+ sleep( 1 );
+ }
+ }
+ echo "\n";
+}
+
+/**
+ * Replace all invalid characters with '-'.
+ * Additional characters can be defined in $wgIllegalFileChars (see T22489).
+ * By default, $wgIllegalFileChars includes ':', '/', '\'.
+ *
+ * @param string $name Filename to process
+ * @return string
+ */
+function wfStripIllegalFilenameChars( $name ) {
+ global $wgIllegalFileChars;
+ $illegalFileChars = $wgIllegalFileChars ? "|[" . $wgIllegalFileChars . "]" : '';
+ $name = preg_replace(
+ "/[^" . Title::legalChars() . "]" . $illegalFileChars . "/",
+ '-',
+ $name
+ );
+ // $wgIllegalFileChars may not include '/' and '\', so we still need to do this
+ $name = wfBaseName( $name );
+ return $name;
+}
+
+/**
+ * Set PHP's memory limit to the larger of php.ini or $wgMemoryLimit
+ *
+ * @return int Resulting value of the memory limit.
+ */
+function wfMemoryLimit() {
+ global $wgMemoryLimit;
+ $memlimit = wfShorthandToInteger( ini_get( 'memory_limit' ) );
+ if ( $memlimit != -1 ) {
+ $conflimit = wfShorthandToInteger( $wgMemoryLimit );
+ if ( $conflimit == -1 ) {
+ wfDebug( "Removing PHP's memory limit\n" );
+ MediaWiki\suppressWarnings();
+ ini_set( 'memory_limit', $conflimit );
+ MediaWiki\restoreWarnings();
+ return $conflimit;
+ } elseif ( $conflimit > $memlimit ) {
+ wfDebug( "Raising PHP's memory limit to $conflimit bytes\n" );
+ MediaWiki\suppressWarnings();
+ ini_set( 'memory_limit', $conflimit );
+ MediaWiki\restoreWarnings();
+ return $conflimit;
+ }
+ }
+ return $memlimit;
+}
+
+/**
+ * Set PHP's time limit to the larger of php.ini or $wgTransactionalTimeLimit
+ *
+ * @return int Prior time limit
+ * @since 1.26
+ */
+function wfTransactionalTimeLimit() {
+ global $wgTransactionalTimeLimit;
+
+ $timeLimit = ini_get( 'max_execution_time' );
+ // Note that CLI scripts use 0
+ if ( $timeLimit > 0 && $wgTransactionalTimeLimit > $timeLimit ) {
+ set_time_limit( $wgTransactionalTimeLimit );
+ }
+
+ ignore_user_abort( true ); // ignore client disconnects
+
+ return $timeLimit;
+}
+
+/**
+ * Converts shorthand byte notation to integer form
+ *
+ * @param string $string
+ * @param int $default Returned if $string is empty
+ * @return int
+ */
+function wfShorthandToInteger( $string = '', $default = -1 ) {
+ $string = trim( $string );
+ if ( $string === '' ) {
+ return $default;
+ }
+ $last = $string[strlen( $string ) - 1];
+ $val = intval( $string );
+ switch ( $last ) {
+ case 'g':
+ case 'G':
+ $val *= 1024;
+ // break intentionally missing
+ case 'm':
+ case 'M':
+ $val *= 1024;
+ // break intentionally missing
+ case 'k':
+ case 'K':
+ $val *= 1024;
+ }
+
+ return $val;
+}
+
+/**
+ * Get the normalised IETF language tag
+ * See unit test for examples.
+ * See mediawiki.language.bcp47 for the JavaScript implementation.
+ *
+ * @param string $code The language code.
+ * @return string The language code which complying with BCP 47 standards.
+ */
+function wfBCP47( $code ) {
+ $codeSegment = explode( '-', $code );
+ $codeBCP = [];
+ foreach ( $codeSegment as $segNo => $seg ) {
+ // when previous segment is x, it is a private segment and should be lc
+ if ( $segNo > 0 && strtolower( $codeSegment[( $segNo - 1 )] ) == 'x' ) {
+ $codeBCP[$segNo] = strtolower( $seg );
+ // ISO 3166 country code
+ } elseif ( ( strlen( $seg ) == 2 ) && ( $segNo > 0 ) ) {
+ $codeBCP[$segNo] = strtoupper( $seg );
+ // ISO 15924 script code
+ } elseif ( ( strlen( $seg ) == 4 ) && ( $segNo > 0 ) ) {
+ $codeBCP[$segNo] = ucfirst( strtolower( $seg ) );
+ // Use lowercase for other cases
+ } else {
+ $codeBCP[$segNo] = strtolower( $seg );
+ }
+ }
+ $langCode = implode( '-', $codeBCP );
+ return $langCode;
+}
+
+/**
+ * Get a specific cache object.
+ *
+ * @param int|string $cacheType A CACHE_* constants, or other key in $wgObjectCaches
+ * @return BagOStuff
+ */
+function wfGetCache( $cacheType ) {
+ return ObjectCache::getInstance( $cacheType );
+}
+
+/**
+ * Get the main cache object
+ *
+ * @return BagOStuff
+ */
+function wfGetMainCache() {
+ global $wgMainCacheType;
+ return ObjectCache::getInstance( $wgMainCacheType );
+}
+
+/**
+ * Get the cache object used by the message cache
+ *
+ * @return BagOStuff
+ */
+function wfGetMessageCacheStorage() {
+ global $wgMessageCacheType;
+ return ObjectCache::getInstance( $wgMessageCacheType );
+}
+
+/**
+ * Get the cache object used by the parser cache
+ *
+ * @deprecated since 1.30, use MediaWikiServices::getParserCache()->getCacheStorage()
+ * @return BagOStuff
+ */
+function wfGetParserCacheStorage() {
+ global $wgParserCacheType;
+ return ObjectCache::getInstance( $wgParserCacheType );
+}
+
+/**
+ * Call hook functions defined in $wgHooks
+ *
+ * @param string $event Event name
+ * @param array $args Parameters passed to hook functions
+ * @param string|null $deprecatedVersion Optionally mark hook as deprecated with version number
+ *
+ * @return bool True if no handler aborted the hook
+ * @deprecated since 1.25 - use Hooks::run
+ */
+function wfRunHooks( $event, array $args = [], $deprecatedVersion = null ) {
+ return Hooks::run( $event, $args, $deprecatedVersion );
+}
+
+/**
+ * Wrapper around php's unpack.
+ *
+ * @param string $format The format string (See php's docs)
+ * @param string $data A binary string of binary data
+ * @param int|bool $length The minimum length of $data or false. This is to
+ * prevent reading beyond the end of $data. false to disable the check.
+ *
+ * Also be careful when using this function to read unsigned 32 bit integer
+ * because php might make it negative.
+ *
+ * @throws MWException If $data not long enough, or if unpack fails
+ * @return array Associative array of the extracted data
+ */
+function wfUnpack( $format, $data, $length = false ) {
+ if ( $length !== false ) {
+ $realLen = strlen( $data );
+ if ( $realLen < $length ) {
+ throw new MWException( "Tried to use wfUnpack on a "
+ . "string of length $realLen, but needed one "
+ . "of at least length $length."
+ );
+ }
+ }
+
+ MediaWiki\suppressWarnings();
+ $result = unpack( $format, $data );
+ MediaWiki\restoreWarnings();
+
+ if ( $result === false ) {
+ // If it cannot extract the packed data.
+ throw new MWException( "unpack could not unpack binary data" );
+ }
+ return $result;
+}
+
+/**
+ * Determine if an image exists on the 'bad image list'.
+ *
+ * The format of MediaWiki:Bad_image_list is as follows:
+ * * Only list items (lines starting with "*") are considered
+ * * The first link on a line must be a link to a bad image
+ * * Any subsequent links on the same line are considered to be exceptions,
+ * i.e. articles where the image may occur inline.
+ *
+ * @param string $name The image name to check
+ * @param Title|bool $contextTitle The page on which the image occurs, if known
+ * @param string $blacklist Wikitext of a file blacklist
+ * @return bool
+ */
+function wfIsBadImage( $name, $contextTitle = false, $blacklist = null ) {
+ # Handle redirects; callers almost always hit wfFindFile() anyway,
+ # so just use that method because it has a fast process cache.
+ $file = wfFindFile( $name ); // get the final name
+ $name = $file ? $file->getTitle()->getDBkey() : $name;
+
+ # Run the extension hook
+ $bad = false;
+ if ( !Hooks::run( 'BadImage', [ $name, &$bad ] ) ) {
+ return (bool)$bad;
+ }
+
+ $cache = ObjectCache::getLocalServerInstance( 'hash' );
+ $key = $cache->makeKey(
+ 'bad-image-list', ( $blacklist === null ) ? 'default' : md5( $blacklist )
+ );
+ $badImages = $cache->get( $key );
+
+ if ( $badImages === false ) { // cache miss
+ if ( $blacklist === null ) {
+ $blacklist = wfMessage( 'bad_image_list' )->inContentLanguage()->plain(); // site list
+ }
+ # Build the list now
+ $badImages = [];
+ $lines = explode( "\n", $blacklist );
+ foreach ( $lines as $line ) {
+ # List items only
+ if ( substr( $line, 0, 1 ) !== '*' ) {
+ continue;
+ }
+
+ # Find all links
+ $m = [];
+ if ( !preg_match_all( '/\[\[:?(.*?)\]\]/', $line, $m ) ) {
+ continue;
+ }
+
+ $exceptions = [];
+ $imageDBkey = false;
+ foreach ( $m[1] as $i => $titleText ) {
+ $title = Title::newFromText( $titleText );
+ if ( !is_null( $title ) ) {
+ if ( $i == 0 ) {
+ $imageDBkey = $title->getDBkey();
+ } else {
+ $exceptions[$title->getPrefixedDBkey()] = true;
+ }
+ }
+ }
+
+ if ( $imageDBkey !== false ) {
+ $badImages[$imageDBkey] = $exceptions;
+ }
+ }
+ $cache->set( $key, $badImages, 60 );
+ }
+
+ $contextKey = $contextTitle ? $contextTitle->getPrefixedDBkey() : false;
+ $bad = isset( $badImages[$name] ) && !isset( $badImages[$name][$contextKey] );
+
+ return $bad;
+}
+
+/**
+ * Determine whether the client at a given source IP is likely to be able to
+ * access the wiki via HTTPS.
+ *
+ * @param string $ip The IPv4/6 address in the normal human-readable form
+ * @return bool
+ */
+function wfCanIPUseHTTPS( $ip ) {
+ $canDo = true;
+ Hooks::run( 'CanIPUseHTTPS', [ $ip, &$canDo ] );
+ return !!$canDo;
+}
+
+/**
+ * Determine input string is represents as infinity
+ *
+ * @param string $str The string to determine
+ * @return bool
+ * @since 1.25
+ */
+function wfIsInfinity( $str ) {
+ // These are hardcoded elsewhere in MediaWiki (e.g. mediawiki.special.block.js).
+ $infinityValues = [ 'infinite', 'indefinite', 'infinity', 'never' ];
+ return in_array( $str, $infinityValues );
+}
+
+/**
+ * Returns true if these thumbnail parameters match one that MediaWiki
+ * requests from file description pages and/or parser output.
+ *
+ * $params is considered non-standard if they involve a non-standard
+ * width or any non-default parameters aside from width and page number.
+ * The number of possible files with standard parameters is far less than
+ * that of all combinations; rate-limiting for them can thus be more generious.
+ *
+ * @param File $file
+ * @param array $params
+ * @return bool
+ * @since 1.24 Moved from thumb.php to GlobalFunctions in 1.25
+ */
+function wfThumbIsStandard( File $file, array $params ) {
+ global $wgThumbLimits, $wgImageLimits, $wgResponsiveImages;
+
+ $multipliers = [ 1 ];
+ if ( $wgResponsiveImages ) {
+ // These available sizes are hardcoded currently elsewhere in MediaWiki.
+ // @see Linker::processResponsiveImages
+ $multipliers[] = 1.5;
+ $multipliers[] = 2;
+ }
+
+ $handler = $file->getHandler();
+ if ( !$handler || !isset( $params['width'] ) ) {
+ return false;
+ }
+
+ $basicParams = [];
+ if ( isset( $params['page'] ) ) {
+ $basicParams['page'] = $params['page'];
+ }
+
+ $thumbLimits = [];
+ $imageLimits = [];
+ // Expand limits to account for multipliers
+ foreach ( $multipliers as $multiplier ) {
+ $thumbLimits = array_merge( $thumbLimits, array_map(
+ function ( $width ) use ( $multiplier ) {
+ return round( $width * $multiplier );
+ }, $wgThumbLimits )
+ );
+ $imageLimits = array_merge( $imageLimits, array_map(
+ function ( $pair ) use ( $multiplier ) {
+ return [
+ round( $pair[0] * $multiplier ),
+ round( $pair[1] * $multiplier ),
+ ];
+ }, $wgImageLimits )
+ );
+ }
+
+ // Check if the width matches one of $wgThumbLimits
+ if ( in_array( $params['width'], $thumbLimits ) ) {
+ $normalParams = $basicParams + [ 'width' => $params['width'] ];
+ // Append any default values to the map (e.g. "lossy", "lossless", ...)
+ $handler->normaliseParams( $file, $normalParams );
+ } else {
+ // If not, then check if the width matchs one of $wgImageLimits
+ $match = false;
+ foreach ( $imageLimits as $pair ) {
+ $normalParams = $basicParams + [ 'width' => $pair[0], 'height' => $pair[1] ];
+ // Decide whether the thumbnail should be scaled on width or height.
+ // Also append any default values to the map (e.g. "lossy", "lossless", ...)
+ $handler->normaliseParams( $file, $normalParams );
+ // Check if this standard thumbnail size maps to the given width
+ if ( $normalParams['width'] == $params['width'] ) {
+ $match = true;
+ break;
+ }
+ }
+ if ( !$match ) {
+ return false; // not standard for description pages
+ }
+ }
+
+ // Check that the given values for non-page, non-width, params are just defaults
+ foreach ( $params as $key => $value ) {
+ if ( !isset( $normalParams[$key] ) || $normalParams[$key] != $value ) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Merges two (possibly) 2 dimensional arrays into the target array ($baseArray).
+ *
+ * Values that exist in both values will be combined with += (all values of the array
+ * of $newValues will be added to the values of the array of $baseArray, while values,
+ * that exists in both, the value of $baseArray will be used).
+ *
+ * @param array $baseArray The array where you want to add the values of $newValues to
+ * @param array $newValues An array with new values
+ * @return array The combined array
+ * @since 1.26
+ */
+function wfArrayPlus2d( array $baseArray, array $newValues ) {
+ // First merge items that are in both arrays
+ foreach ( $baseArray as $name => &$groupVal ) {
+ if ( isset( $newValues[$name] ) ) {
+ $groupVal += $newValues[$name];
+ }
+ }
+ // Now add items that didn't exist yet
+ $baseArray += $newValues;
+
+ return $baseArray;
+}
diff --git a/www/wiki/includes/HeaderCallback.php b/www/wiki/includes/HeaderCallback.php
new file mode 100644
index 00000000..b2ca6733
--- /dev/null
+++ b/www/wiki/includes/HeaderCallback.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace MediaWiki;
+
+class HeaderCallback {
+ private static $headersSentException;
+ private static $messageSent = false;
+
+ /**
+ * Register a callback to be called when headers are sent. There can only
+ * be one of these handlers active, so all relevant actions have to be in
+ * here.
+ */
+ public static function register() {
+ header_register_callback( [ __CLASS__, 'callback' ] );
+ }
+
+ /**
+ * The callback, which is called by the transport
+ */
+ public static function callback() {
+ // Prevent caching of responses with cookies (T127993)
+ $headers = [];
+ foreach ( headers_list() as $header ) {
+ list( $name, $value ) = explode( ':', $header, 2 );
+ $headers[strtolower( trim( $name ) )][] = trim( $value );
+ }
+
+ if ( isset( $headers['set-cookie'] ) ) {
+ $cacheControl = isset( $headers['cache-control'] )
+ ? implode( ', ', $headers['cache-control'] )
+ : '';
+
+ if ( !preg_match( '/(?:^|,)\s*(?:private|no-cache|no-store)\s*(?:$|,)/i',
+ $cacheControl )
+ ) {
+ header( 'Expires: Thu, 01 Jan 1970 00:00:00 GMT' );
+ header( 'Cache-Control: private, max-age=0, s-maxage=0' );
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'cache-cookies' )->warning(
+ 'Cookies set on {url} with Cache-Control "{cache-control}"', [
+ 'url' => \WebRequest::getGlobalRequestURL(),
+ 'cookies' => $headers['set-cookie'],
+ 'cache-control' => $cacheControl ?: '<not set>',
+ ]
+ );
+ }
+ }
+
+ // Save a backtrace for logging in case it turns out that headers were sent prematurely
+ self::$headersSentException = new \Exception( 'Headers already sent from this point' );
+ }
+
+ /**
+ * Log a warning message if headers have already been sent. This can be
+ * called before flushing the output.
+ */
+ public static function warnIfHeadersSent() {
+ if ( headers_sent() && !self::$messageSent ) {
+ self::$messageSent = true;
+ \MWDebug::warning( 'Headers already sent, should send headers earlier than ' .
+ wfGetCaller( 3 ) );
+ $logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'headers-sent' );
+ $logger->error( 'Warning: headers were already sent from the location below', [
+ 'exception' => self::$headersSentException,
+ 'detection-trace' => new \Exception( 'Detected here' ),
+ ] );
+ }
+ }
+}
diff --git a/www/wiki/includes/HistoryBlob.php b/www/wiki/includes/HistoryBlob.php
new file mode 100644
index 00000000..51bd7a9e
--- /dev/null
+++ b/www/wiki/includes/HistoryBlob.php
@@ -0,0 +1,701 @@
+<?php
+/**
+ * Efficient concatenated text storage.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Base class for general text storage via the "object" flag in old_flags, or
+ * two-part external storage URLs. Used for represent efficient concatenated
+ * storage, and migration-related pointer objects.
+ */
+interface HistoryBlob {
+ /**
+ * Adds an item of text, returns a stub object which points to the item.
+ * You must call setLocation() on the stub object before storing it to the
+ * database
+ *
+ * @param string $text
+ *
+ * @return string The key for getItem()
+ */
+ function addItem( $text );
+
+ /**
+ * Get item by key, or false if the key is not present
+ *
+ * @param string $key
+ *
+ * @return string|bool
+ */
+ function getItem( $key );
+
+ /**
+ * Set the "default text"
+ * This concept is an odd property of the current DB schema, whereby each text item has a revision
+ * associated with it. The default text is the text of the associated revision. There may, however,
+ * be other revisions in the same object.
+ *
+ * Default text is not required for two-part external storage URLs.
+ *
+ * @param string $text
+ */
+ function setText( $text );
+
+ /**
+ * Get default text. This is called from Revision::getRevisionText()
+ *
+ * @return string
+ */
+ function getText();
+}
+
+/**
+ * Concatenated gzip (CGZ) storage
+ * Improves compression ratio by concatenating like objects before gzipping
+ */
+class ConcatenatedGzipHistoryBlob implements HistoryBlob {
+ public $mVersion = 0, $mCompressed = false, $mItems = [], $mDefaultHash = '';
+ public $mSize = 0;
+ public $mMaxSize = 10000000;
+ public $mMaxCount = 100;
+
+ public function __construct() {
+ if ( !function_exists( 'gzdeflate' ) ) {
+ throw new MWException( "Need zlib support to read or write this "
+ . "kind of history object (ConcatenatedGzipHistoryBlob)\n" );
+ }
+ }
+
+ /**
+ * @param string $text
+ * @return string
+ */
+ public function addItem( $text ) {
+ $this->uncompress();
+ $hash = md5( $text );
+ if ( !isset( $this->mItems[$hash] ) ) {
+ $this->mItems[$hash] = $text;
+ $this->mSize += strlen( $text );
+ }
+ return $hash;
+ }
+
+ /**
+ * @param string $hash
+ * @return array|bool
+ */
+ public function getItem( $hash ) {
+ $this->uncompress();
+ if ( array_key_exists( $hash, $this->mItems ) ) {
+ return $this->mItems[$hash];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param string $text
+ * @return void
+ */
+ public function setText( $text ) {
+ $this->uncompress();
+ $this->mDefaultHash = $this->addItem( $text );
+ }
+
+ /**
+ * @return array|bool
+ */
+ public function getText() {
+ $this->uncompress();
+ return $this->getItem( $this->mDefaultHash );
+ }
+
+ /**
+ * Remove an item
+ *
+ * @param string $hash
+ */
+ public function removeItem( $hash ) {
+ $this->mSize -= strlen( $this->mItems[$hash] );
+ unset( $this->mItems[$hash] );
+ }
+
+ /**
+ * Compress the bulk data in the object
+ */
+ public function compress() {
+ if ( !$this->mCompressed ) {
+ $this->mItems = gzdeflate( serialize( $this->mItems ) );
+ $this->mCompressed = true;
+ }
+ }
+
+ /**
+ * Uncompress bulk data
+ */
+ public function uncompress() {
+ if ( $this->mCompressed ) {
+ $this->mItems = unserialize( gzinflate( $this->mItems ) );
+ $this->mCompressed = false;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ function __sleep() {
+ $this->compress();
+ return [ 'mVersion', 'mCompressed', 'mItems', 'mDefaultHash' ];
+ }
+
+ function __wakeup() {
+ $this->uncompress();
+ }
+
+ /**
+ * Helper function for compression jobs
+ * Returns true until the object is "full" and ready to be committed
+ *
+ * @return bool
+ */
+ public function isHappy() {
+ return $this->mSize < $this->mMaxSize
+ && count( $this->mItems ) < $this->mMaxCount;
+ }
+}
+
+/**
+ * Pointer object for an item within a CGZ blob stored in the text table.
+ */
+class HistoryBlobStub {
+ /**
+ * @var array One-step cache variable to hold base blobs; operations that
+ * pull multiple revisions may often pull multiple times from the same
+ * blob. By keeping the last-used one open, we avoid redundant
+ * unserialization and decompression overhead.
+ */
+ protected static $blobCache = [];
+
+ /** @var int */
+ public $mOldId;
+
+ /** @var string */
+ public $mHash;
+
+ /** @var string */
+ public $mRef;
+
+ /**
+ * @param string $hash The content hash of the text
+ * @param int $oldid The old_id for the CGZ object
+ */
+ function __construct( $hash = '', $oldid = 0 ) {
+ $this->mHash = $hash;
+ }
+
+ /**
+ * Sets the location (old_id) of the main object to which this object
+ * points
+ * @param int $id
+ */
+ function setLocation( $id ) {
+ $this->mOldId = $id;
+ }
+
+ /**
+ * Sets the location (old_id) of the referring object
+ * @param string $id
+ */
+ function setReferrer( $id ) {
+ $this->mRef = $id;
+ }
+
+ /**
+ * Gets the location of the referring object
+ * @return string
+ */
+ function getReferrer() {
+ return $this->mRef;
+ }
+
+ /**
+ * @return string|false
+ */
+ function getText() {
+ if ( isset( self::$blobCache[$this->mOldId] ) ) {
+ $obj = self::$blobCache[$this->mOldId];
+ } else {
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'text',
+ [ 'old_flags', 'old_text' ],
+ [ 'old_id' => $this->mOldId ]
+ );
+
+ if ( !$row ) {
+ return false;
+ }
+
+ $flags = explode( ',', $row->old_flags );
+ if ( in_array( 'external', $flags ) ) {
+ $url = $row->old_text;
+ $parts = explode( '://', $url, 2 );
+ if ( !isset( $parts[1] ) || $parts[1] == '' ) {
+ return false;
+ }
+ $row->old_text = ExternalStore::fetchFromURL( $url );
+
+ }
+
+ if ( !in_array( 'object', $flags ) ) {
+ return false;
+ }
+
+ if ( in_array( 'gzip', $flags ) ) {
+ // This shouldn't happen, but a bug in the compress script
+ // may at times gzip-compress a HistoryBlob object row.
+ $obj = unserialize( gzinflate( $row->old_text ) );
+ } else {
+ $obj = unserialize( $row->old_text );
+ }
+
+ if ( !is_object( $obj ) ) {
+ // Correct for old double-serialization bug.
+ $obj = unserialize( $obj );
+ }
+
+ // Save this item for reference; if pulling many
+ // items in a row we'll likely use it again.
+ $obj->uncompress();
+ self::$blobCache = [ $this->mOldId => $obj ];
+ }
+
+ return $obj->getItem( $this->mHash );
+ }
+
+ /**
+ * Get the content hash
+ *
+ * @return string
+ */
+ function getHash() {
+ return $this->mHash;
+ }
+}
+
+/**
+ * To speed up conversion from 1.4 to 1.5 schema, text rows can refer to the
+ * leftover cur table as the backend. This avoids expensively copying hundreds
+ * of megabytes of data during the conversion downtime.
+ *
+ * Serialized HistoryBlobCurStub objects will be inserted into the text table
+ * on conversion if $wgLegacySchemaConversion is set to true.
+ */
+class HistoryBlobCurStub {
+ /** @var int */
+ public $mCurId;
+
+ /**
+ * @param int $curid The cur_id pointed to
+ */
+ function __construct( $curid = 0 ) {
+ $this->mCurId = $curid;
+ }
+
+ /**
+ * Sets the location (cur_id) of the main object to which this object
+ * points
+ *
+ * @param int $id
+ */
+ function setLocation( $id ) {
+ $this->mCurId = $id;
+ }
+
+ /**
+ * @return string|bool
+ */
+ function getText() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow( 'cur', [ 'cur_text' ], [ 'cur_id' => $this->mCurId ] );
+ if ( !$row ) {
+ return false;
+ }
+ return $row->cur_text;
+ }
+}
+
+/**
+ * Diff-based history compression
+ * Requires xdiff 1.5+ and zlib
+ */
+class DiffHistoryBlob implements HistoryBlob {
+ /** @var array Uncompressed item cache */
+ public $mItems = [];
+
+ /** @var int Total uncompressed size */
+ public $mSize = 0;
+
+ /**
+ * @var array Array of diffs. If a diff D from A to B is notated D = B - A,
+ * and Z is an empty string:
+ *
+ * { item[map[i]] - item[map[i-1]] where i > 0
+ * diff[i] = {
+ * { item[map[i]] - Z where i = 0
+ */
+ public $mDiffs;
+
+ /** @var array The diff map, see above */
+ public $mDiffMap;
+
+ /** @var int The key for getText()
+ */
+ public $mDefaultKey;
+
+ /** @var string Compressed storage */
+ public $mCompressed;
+
+ /** @var bool True if the object is locked against further writes */
+ public $mFrozen = false;
+
+ /**
+ * @var int The maximum uncompressed size before the object becomes sad
+ * Should be less than max_allowed_packet
+ */
+ public $mMaxSize = 10000000;
+
+ /** @var int The maximum number of text items before the object becomes sad */
+ public $mMaxCount = 100;
+
+ /** Constants from xdiff.h */
+ const XDL_BDOP_INS = 1;
+ const XDL_BDOP_CPY = 2;
+ const XDL_BDOP_INSB = 3;
+
+ function __construct() {
+ if ( !function_exists( 'gzdeflate' ) ) {
+ throw new MWException( "Need zlib support to read or write DiffHistoryBlob\n" );
+ }
+ }
+
+ /**
+ * @throws MWException
+ * @param string $text
+ * @return int
+ */
+ function addItem( $text ) {
+ if ( $this->mFrozen ) {
+ throw new MWException( __METHOD__ . ": Cannot add more items after sleep/wakeup" );
+ }
+
+ $this->mItems[] = $text;
+ $this->mSize += strlen( $text );
+ $this->mDiffs = null; // later
+ return count( $this->mItems ) - 1;
+ }
+
+ /**
+ * @param string $key
+ * @return string
+ */
+ function getItem( $key ) {
+ return $this->mItems[$key];
+ }
+
+ /**
+ * @param string $text
+ */
+ function setText( $text ) {
+ $this->mDefaultKey = $this->addItem( $text );
+ }
+
+ /**
+ * @return string
+ */
+ function getText() {
+ return $this->getItem( $this->mDefaultKey );
+ }
+
+ /**
+ * @throws MWException
+ */
+ function compress() {
+ if ( !function_exists( 'xdiff_string_rabdiff' ) ) {
+ throw new MWException( "Need xdiff 1.5+ support to write DiffHistoryBlob\n" );
+ }
+ if ( isset( $this->mDiffs ) ) {
+ // Already compressed
+ return;
+ }
+ if ( !count( $this->mItems ) ) {
+ // Empty
+ return;
+ }
+
+ // Create two diff sequences: one for main text and one for small text
+ $sequences = [
+ 'small' => [
+ 'tail' => '',
+ 'diffs' => [],
+ 'map' => [],
+ ],
+ 'main' => [
+ 'tail' => '',
+ 'diffs' => [],
+ 'map' => [],
+ ],
+ ];
+ $smallFactor = 0.5;
+
+ $mItemsCount = count( $this->mItems );
+ for ( $i = 0; $i < $mItemsCount; $i++ ) {
+ $text = $this->mItems[$i];
+ if ( $i == 0 ) {
+ $seqName = 'main';
+ } else {
+ $mainTail = $sequences['main']['tail'];
+ if ( strlen( $text ) < strlen( $mainTail ) * $smallFactor ) {
+ $seqName = 'small';
+ } else {
+ $seqName = 'main';
+ }
+ }
+ $seq =& $sequences[$seqName];
+ $tail = $seq['tail'];
+ $diff = $this->diff( $tail, $text );
+ $seq['diffs'][] = $diff;
+ $seq['map'][] = $i;
+ $seq['tail'] = $text;
+ }
+ unset( $seq ); // unlink dangerous alias
+
+ // Knit the sequences together
+ $tail = '';
+ $this->mDiffs = [];
+ $this->mDiffMap = [];
+ foreach ( $sequences as $seq ) {
+ if ( !count( $seq['diffs'] ) ) {
+ continue;
+ }
+ if ( $tail === '' ) {
+ $this->mDiffs[] = $seq['diffs'][0];
+ } else {
+ $head = $this->patch( '', $seq['diffs'][0] );
+ $this->mDiffs[] = $this->diff( $tail, $head );
+ }
+ $this->mDiffMap[] = $seq['map'][0];
+ $diffsCount = count( $seq['diffs'] );
+ for ( $i = 1; $i < $diffsCount; $i++ ) {
+ $this->mDiffs[] = $seq['diffs'][$i];
+ $this->mDiffMap[] = $seq['map'][$i];
+ }
+ $tail = $seq['tail'];
+ }
+ }
+
+ /**
+ * @param string $t1
+ * @param string $t2
+ * @return string
+ */
+ function diff( $t1, $t2 ) {
+ # Need to do a null concatenation with warnings off, due to bugs in the current version of xdiff
+ # "String is not zero-terminated"
+ MediaWiki\suppressWarnings();
+ $diff = xdiff_string_rabdiff( $t1, $t2 ) . '';
+ MediaWiki\restoreWarnings();
+ return $diff;
+ }
+
+ /**
+ * @param string $base
+ * @param string $diff
+ * @return bool|string
+ */
+ function patch( $base, $diff ) {
+ if ( function_exists( 'xdiff_string_bpatch' ) ) {
+ MediaWiki\suppressWarnings();
+ $text = xdiff_string_bpatch( $base, $diff ) . '';
+ MediaWiki\restoreWarnings();
+ return $text;
+ }
+
+ # Pure PHP implementation
+
+ $header = unpack( 'Vofp/Vcsize', substr( $diff, 0, 8 ) );
+
+ # Check the checksum if hash extension is available
+ $ofp = $this->xdiffAdler32( $base );
+ if ( $ofp !== false && $ofp !== substr( $diff, 0, 4 ) ) {
+ wfDebug( __METHOD__ . ": incorrect base checksum\n" );
+ return false;
+ }
+ if ( $header['csize'] != strlen( $base ) ) {
+ wfDebug( __METHOD__ . ": incorrect base length\n" );
+ return false;
+ }
+
+ $p = 8;
+ $out = '';
+ while ( $p < strlen( $diff ) ) {
+ $x = unpack( 'Cop', substr( $diff, $p, 1 ) );
+ $op = $x['op'];
+ ++$p;
+ switch ( $op ) {
+ case self::XDL_BDOP_INS:
+ $x = unpack( 'Csize', substr( $diff, $p, 1 ) );
+ $p++;
+ $out .= substr( $diff, $p, $x['size'] );
+ $p += $x['size'];
+ break;
+ case self::XDL_BDOP_INSB:
+ $x = unpack( 'Vcsize', substr( $diff, $p, 4 ) );
+ $p += 4;
+ $out .= substr( $diff, $p, $x['csize'] );
+ $p += $x['csize'];
+ break;
+ case self::XDL_BDOP_CPY:
+ $x = unpack( 'Voff/Vcsize', substr( $diff, $p, 8 ) );
+ $p += 8;
+ $out .= substr( $base, $x['off'], $x['csize'] );
+ break;
+ default:
+ wfDebug( __METHOD__ . ": invalid op\n" );
+ return false;
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Compute a binary "Adler-32" checksum as defined by LibXDiff, i.e. with
+ * the bytes backwards and initialised with 0 instead of 1. See T36428.
+ *
+ * @param string $s
+ * @return string|bool False if the hash extension is not available
+ */
+ function xdiffAdler32( $s ) {
+ if ( !function_exists( 'hash' ) ) {
+ return false;
+ }
+
+ static $init;
+ if ( $init === null ) {
+ $init = str_repeat( "\xf0", 205 ) . "\xee" . str_repeat( "\xf0", 67 ) . "\x02";
+ }
+
+ // The real Adler-32 checksum of $init is zero, so it initialises the
+ // state to zero, as it is at the start of LibXDiff's checksum
+ // algorithm. Appending the subject string then simulates LibXDiff.
+ return strrev( hash( 'adler32', $init . $s, true ) );
+ }
+
+ function uncompress() {
+ if ( !$this->mDiffs ) {
+ return;
+ }
+ $tail = '';
+ $mDiffsCount = count( $this->mDiffs );
+ for ( $diffKey = 0; $diffKey < $mDiffsCount; $diffKey++ ) {
+ $textKey = $this->mDiffMap[$diffKey];
+ $text = $this->patch( $tail, $this->mDiffs[$diffKey] );
+ $this->mItems[$textKey] = $text;
+ $tail = $text;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ function __sleep() {
+ $this->compress();
+ if ( !count( $this->mItems ) ) {
+ // Empty object
+ $info = false;
+ } else {
+ // Take forward differences to improve the compression ratio for sequences
+ $map = '';
+ $prev = 0;
+ foreach ( $this->mDiffMap as $i ) {
+ if ( $map !== '' ) {
+ $map .= ',';
+ }
+ $map .= $i - $prev;
+ $prev = $i;
+ }
+ $info = [
+ 'diffs' => $this->mDiffs,
+ 'map' => $map
+ ];
+ }
+ if ( isset( $this->mDefaultKey ) ) {
+ $info['default'] = $this->mDefaultKey;
+ }
+ $this->mCompressed = gzdeflate( serialize( $info ) );
+ return [ 'mCompressed' ];
+ }
+
+ function __wakeup() {
+ // addItem() doesn't work if mItems is partially filled from mDiffs
+ $this->mFrozen = true;
+ $info = unserialize( gzinflate( $this->mCompressed ) );
+ unset( $this->mCompressed );
+
+ if ( !$info ) {
+ // Empty object
+ return;
+ }
+
+ if ( isset( $info['default'] ) ) {
+ $this->mDefaultKey = $info['default'];
+ }
+ $this->mDiffs = $info['diffs'];
+ if ( isset( $info['base'] ) ) {
+ // Old format
+ $this->mDiffMap = range( 0, count( $this->mDiffs ) - 1 );
+ array_unshift( $this->mDiffs,
+ pack( 'VVCV', 0, 0, self::XDL_BDOP_INSB, strlen( $info['base'] ) ) .
+ $info['base'] );
+ } else {
+ // New format
+ $map = explode( ',', $info['map'] );
+ $cur = 0;
+ $this->mDiffMap = [];
+ foreach ( $map as $i ) {
+ $cur += $i;
+ $this->mDiffMap[] = $cur;
+ }
+ }
+ $this->uncompress();
+ }
+
+ /**
+ * Helper function for compression jobs
+ * Returns true until the object is "full" and ready to be committed
+ *
+ * @return bool
+ */
+ function isHappy() {
+ return $this->mSize < $this->mMaxSize
+ && count( $this->mItems ) < $this->mMaxCount;
+ }
+
+}
diff --git a/www/wiki/includes/Hooks.php b/www/wiki/includes/Hooks.php
new file mode 100644
index 00000000..c22dc97f
--- /dev/null
+++ b/www/wiki/includes/Hooks.php
@@ -0,0 +1,244 @@
+<?php
+
+/**
+ * A tool for running hook functions.
+ *
+ * Copyright 2004, 2005 Evan Prodromou <evan@wikitravel.org>.
+ *
+ * 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
+ *
+ * @author Evan Prodromou <evan@wikitravel.org>
+ * @see hooks.txt
+ * @file
+ */
+
+/**
+ * Hooks class.
+ *
+ * Used to supersede $wgHooks, because globals are EVIL.
+ *
+ * @since 1.18
+ */
+class Hooks {
+ /**
+ * Array of events mapped to an array of callbacks to be run
+ * when that event is triggered.
+ */
+ protected static $handlers = [];
+
+ /**
+ * Attach an event handler to a given hook.
+ *
+ * @param string $name Name of hook
+ * @param callable $callback Callback function to attach
+ *
+ * @since 1.18
+ */
+ public static function register( $name, $callback ) {
+ if ( !isset( self::$handlers[$name] ) ) {
+ self::$handlers[$name] = [];
+ }
+
+ self::$handlers[$name][] = $callback;
+ }
+
+ /**
+ * Clears hooks registered via Hooks::register(). Does not touch $wgHooks.
+ * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+ *
+ * @param string $name The name of the hook to clear.
+ *
+ * @since 1.21
+ * @throws MWException If not in testing mode.
+ */
+ public static function clear( $name ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
+ throw new MWException( 'Cannot reset hooks in operation.' );
+ }
+
+ unset( self::$handlers[$name] );
+ }
+
+ /**
+ * Returns true if a hook has a function registered to it.
+ * The function may have been registered either via Hooks::register or in $wgHooks.
+ *
+ * @since 1.18
+ *
+ * @param string $name Name of hook
+ * @return bool True if the hook has a function registered to it
+ */
+ public static function isRegistered( $name ) {
+ global $wgHooks;
+ return !empty( $wgHooks[$name] ) || !empty( self::$handlers[$name] );
+ }
+
+ /**
+ * Returns an array of all the event functions attached to a hook
+ * This combines functions registered via Hooks::register and with $wgHooks.
+ *
+ * @since 1.18
+ *
+ * @param string $name Name of the hook
+ * @return array
+ */
+ public static function getHandlers( $name ) {
+ global $wgHooks;
+
+ if ( !self::isRegistered( $name ) ) {
+ return [];
+ } elseif ( !isset( self::$handlers[$name] ) ) {
+ return $wgHooks[$name];
+ } elseif ( !isset( $wgHooks[$name] ) ) {
+ return self::$handlers[$name];
+ } else {
+ return array_merge( self::$handlers[$name], $wgHooks[$name] );
+ }
+ }
+
+ /**
+ * @param string $event Event name
+ * @param array|callable $hook
+ * @param array $args Array of parameters passed to hook functions
+ * @param string|null $deprecatedVersion [optional]
+ * @param string &$fname [optional] Readable name of hook [returned]
+ * @return null|string|bool
+ */
+ private static function callHook( $event, $hook, array $args, $deprecatedVersion = null,
+ &$fname = null
+ ) {
+ // Turn non-array values into an array. (Can't use casting because of objects.)
+ if ( !is_array( $hook ) ) {
+ $hook = [ $hook ];
+ }
+
+ if ( !array_filter( $hook ) ) {
+ // Either array is empty or it's an array filled with null/false/empty.
+ return null;
+ }
+
+ if ( is_array( $hook[0] ) ) {
+ // First element is an array, meaning the developer intended
+ // the first element to be a callback. Merge it in so that
+ // processing can be uniform.
+ $hook = array_merge( $hook[0], array_slice( $hook, 1 ) );
+ }
+
+ /**
+ * $hook can be: a function, an object, an array of $function and
+ * $data, an array of just a function, an array of object and
+ * method, or an array of object, method, and data.
+ */
+ if ( $hook[0] instanceof Closure ) {
+ $fname = "hook-$event-closure";
+ $callback = array_shift( $hook );
+ } elseif ( is_object( $hook[0] ) ) {
+ $object = array_shift( $hook );
+ $method = array_shift( $hook );
+
+ // If no method was specified, default to on$event.
+ if ( $method === null ) {
+ $method = "on$event";
+ }
+
+ $fname = get_class( $object ) . '::' . $method;
+ $callback = [ $object, $method ];
+ } elseif ( is_string( $hook[0] ) ) {
+ $fname = $callback = array_shift( $hook );
+ } else {
+ throw new MWException( 'Unknown datatype in hooks for ' . $event . "\n" );
+ }
+
+ // Run autoloader (workaround for call_user_func_array bug)
+ // and throw error if not callable.
+ if ( !is_callable( $callback ) ) {
+ throw new MWException( 'Invalid callback ' . $fname . ' in hooks for ' . $event . "\n" );
+ }
+
+ // mark hook as deprecated, if deprecation version is specified
+ if ( $deprecatedVersion !== null ) {
+ wfDeprecated( "$event hook (used in $fname)", $deprecatedVersion );
+ }
+
+ // Call the hook.
+ $hook_args = array_merge( $hook, $args );
+ return call_user_func_array( $callback, $hook_args );
+ }
+
+ /**
+ * Call hook functions defined in Hooks::register and $wgHooks.
+ *
+ * For the given hook event, fetch the array of hook events and
+ * process them. Determine the proper callback for each hook and
+ * then call the actual hook using the appropriate arguments.
+ * Finally, process the return value and return/throw accordingly.
+ *
+ * For hook event that are not abortable through a handler's return value,
+ * use runWithoutAbort() instead.
+ *
+ * @param string $event Event name
+ * @param array $args Array of parameters passed to hook functions
+ * @param string|null $deprecatedVersion [optional] Mark hook as deprecated with version number
+ * @return bool True if no handler aborted the hook
+ *
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ * @since 1.22 A hook function is not required to return a value for
+ * processing to continue. Not returning a value (or explicitly
+ * returning null) is equivalent to returning true.
+ */
+ public static function run( $event, array $args = [], $deprecatedVersion = null ) {
+ foreach ( self::getHandlers( $event ) as $hook ) {
+ $retval = self::callHook( $event, $hook, $args, $deprecatedVersion );
+ if ( $retval === null ) {
+ continue;
+ }
+
+ // Process the return value.
+ if ( is_string( $retval ) ) {
+ // String returned means error.
+ throw new FatalError( $retval );
+ } elseif ( $retval === false ) {
+ // False was returned. Stop processing, but no error.
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Call hook functions defined in Hooks::register and $wgHooks.
+ *
+ * @param string $event Event name
+ * @param array $args Array of parameters passed to hook functions
+ * @param string|null $deprecatedVersion [optional] Mark hook as deprecated with version number
+ * @return bool Always true
+ * @throws MWException If a callback is invalid, unknown
+ * @throws UnexpectedValueException If a callback returns an abort value.
+ * @since 1.30
+ */
+ public static function runWithoutAbort( $event, array $args = [], $deprecatedVersion = null ) {
+ foreach ( self::getHandlers( $event ) as $hook ) {
+ $fname = null;
+ $retval = self::callHook( $event, $hook, $args, $deprecatedVersion, $fname );
+ if ( $retval !== null && $retval !== true ) {
+ throw new UnexpectedValueException( "Invalid return from $fname for unabortable $event." );
+ }
+ }
+ return true;
+ }
+}
diff --git a/www/wiki/includes/Html.php b/www/wiki/includes/Html.php
new file mode 100644
index 00000000..0988b054
--- /dev/null
+++ b/www/wiki/includes/Html.php
@@ -0,0 +1,1010 @@
+<?php
+/**
+ * Collection of methods to generate HTML content
+ *
+ * Copyright © 2009 Aryeh Gregor
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This class is a collection of static functions that serve two purposes:
+ *
+ * 1) Implement any algorithms specified by HTML5, or other HTML
+ * specifications, in a convenient and self-contained way.
+ *
+ * 2) Allow HTML elements to be conveniently and safely generated, like the
+ * current Xml class but a) less confused (Xml supports HTML-specific things,
+ * but only sometimes!) and b) not necessarily confined to XML-compatible
+ * output.
+ *
+ * There are two important configuration options this class uses:
+ *
+ * $wgMimeType: If this is set to an xml MIME type then output should be
+ * valid XHTML5.
+ *
+ * This class is meant to be confined to utility functions that are called from
+ * trusted code paths. It does not do enforcement of policy like not allowing
+ * <a> elements.
+ *
+ * @since 1.16
+ */
+class Html {
+ // List of void elements from HTML5, section 8.1.2 as of 2016-09-19
+ private static $voidElements = [
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'keygen',
+ 'link',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+ ];
+
+ // Boolean attributes, which may have the value omitted entirely. Manually
+ // collected from the HTML5 spec as of 2011-08-12.
+ private static $boolAttribs = [
+ 'async',
+ 'autofocus',
+ 'autoplay',
+ 'checked',
+ 'controls',
+ 'default',
+ 'defer',
+ 'disabled',
+ 'formnovalidate',
+ 'hidden',
+ 'ismap',
+ 'itemscope',
+ 'loop',
+ 'multiple',
+ 'muted',
+ 'novalidate',
+ 'open',
+ 'pubdate',
+ 'readonly',
+ 'required',
+ 'reversed',
+ 'scoped',
+ 'seamless',
+ 'selected',
+ 'truespeed',
+ 'typemustmatch',
+ // HTML5 Microdata
+ 'itemscope',
+ ];
+
+ /**
+ * Modifies a set of attributes meant for button elements
+ * and apply a set of default attributes when $wgUseMediaWikiUIEverywhere enabled.
+ * @param array $attrs HTML attributes in an associative array
+ * @param string[] $modifiers classes to add to the button
+ * @see https://tools.wmflabs.org/styleguide/desktop/index.html for guidance on available modifiers
+ * @return array $attrs A modified attribute array
+ */
+ public static function buttonAttributes( array $attrs, array $modifiers = [] ) {
+ global $wgUseMediaWikiUIEverywhere;
+ if ( $wgUseMediaWikiUIEverywhere ) {
+ if ( isset( $attrs['class'] ) ) {
+ if ( is_array( $attrs['class'] ) ) {
+ $attrs['class'][] = 'mw-ui-button';
+ $attrs['class'] = array_merge( $attrs['class'], $modifiers );
+ // ensure compatibility with Xml
+ $attrs['class'] = implode( ' ', $attrs['class'] );
+ } else {
+ $attrs['class'] .= ' mw-ui-button ' . implode( ' ', $modifiers );
+ }
+ } else {
+ // ensure compatibility with Xml
+ $attrs['class'] = 'mw-ui-button ' . implode( ' ', $modifiers );
+ }
+ }
+ return $attrs;
+ }
+
+ /**
+ * Modifies a set of attributes meant for text input elements
+ * and apply a set of default attributes.
+ * Removes size attribute when $wgUseMediaWikiUIEverywhere enabled.
+ * @param array $attrs An attribute array.
+ * @return array $attrs A modified attribute array
+ */
+ public static function getTextInputAttributes( array $attrs ) {
+ global $wgUseMediaWikiUIEverywhere;
+ if ( $wgUseMediaWikiUIEverywhere ) {
+ if ( isset( $attrs['class'] ) ) {
+ if ( is_array( $attrs['class'] ) ) {
+ $attrs['class'][] = 'mw-ui-input';
+ } else {
+ $attrs['class'] .= ' mw-ui-input';
+ }
+ } else {
+ $attrs['class'] = 'mw-ui-input';
+ }
+ }
+ return $attrs;
+ }
+
+ /**
+ * Returns an HTML link element in a string styled as a button
+ * (when $wgUseMediaWikiUIEverywhere is enabled).
+ *
+ * @param string $contents The raw HTML contents of the element: *not*
+ * escaped!
+ * @param array $attrs Associative array of attributes, e.g., [
+ * 'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
+ * further documentation.
+ * @param string[] $modifiers classes to add to the button
+ * @see https://tools.wmflabs.org/styleguide/desktop/index.html for guidance on available modifiers
+ * @return string Raw HTML
+ */
+ public static function linkButton( $contents, array $attrs, array $modifiers = [] ) {
+ return self::element( 'a',
+ self::buttonAttributes( $attrs, $modifiers ),
+ $contents
+ );
+ }
+
+ /**
+ * Returns an HTML link element in a string styled as a button
+ * (when $wgUseMediaWikiUIEverywhere is enabled).
+ *
+ * @param string $contents The raw HTML contents of the element: *not*
+ * escaped!
+ * @param array $attrs Associative array of attributes, e.g., [
+ * 'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
+ * further documentation.
+ * @param string[] $modifiers classes to add to the button
+ * @see https://tools.wmflabs.org/styleguide/desktop/index.html for guidance on available modifiers
+ * @return string Raw HTML
+ */
+ public static function submitButton( $contents, array $attrs, array $modifiers = [] ) {
+ $attrs['type'] = 'submit';
+ $attrs['value'] = $contents;
+ return self::element( 'input', self::buttonAttributes( $attrs, $modifiers ) );
+ }
+
+ /**
+ * Returns an HTML element in a string. The major advantage here over
+ * manually typing out the HTML is that it will escape all attribute
+ * values.
+ *
+ * This is quite similar to Xml::tags(), but it implements some useful
+ * HTML-specific logic. For instance, there is no $allowShortTag
+ * parameter: the closing tag is magically omitted if $element has an empty
+ * content model.
+ *
+ * @param string $element The element's name, e.g., 'a'
+ * @param array $attribs Associative array of attributes, e.g., [
+ * 'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
+ * further documentation.
+ * @param string $contents The raw HTML contents of the element: *not*
+ * escaped!
+ * @return string Raw HTML
+ */
+ public static function rawElement( $element, $attribs = [], $contents = '' ) {
+ $start = self::openElement( $element, $attribs );
+ if ( in_array( $element, self::$voidElements ) ) {
+ // Silly XML.
+ return substr( $start, 0, -1 ) . '/>';
+ } else {
+ return "$start$contents" . self::closeElement( $element );
+ }
+ }
+
+ /**
+ * Identical to rawElement(), but HTML-escapes $contents (like
+ * Xml::element()).
+ *
+ * @param string $element Name of the element, e.g., 'a'
+ * @param array $attribs Associative array of attributes, e.g., [
+ * 'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
+ * further documentation.
+ * @param string $contents
+ *
+ * @return string
+ */
+ public static function element( $element, $attribs = [], $contents = '' ) {
+ return self::rawElement( $element, $attribs, strtr( $contents, [
+ // There's no point in escaping quotes, >, etc. in the contents of
+ // elements.
+ '&' => '&amp;',
+ '<' => '&lt;'
+ ] ) );
+ }
+
+ /**
+ * Identical to rawElement(), but has no third parameter and omits the end
+ * tag (and the self-closing '/' in XML mode for empty elements).
+ *
+ * @param string $element Name of the element, e.g., 'a'
+ * @param array $attribs Associative array of attributes, e.g., [
+ * 'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
+ * further documentation.
+ *
+ * @return string
+ */
+ public static function openElement( $element, $attribs = [] ) {
+ $attribs = (array)$attribs;
+ // This is not required in HTML5, but let's do it anyway, for
+ // consistency and better compression.
+ $element = strtolower( $element );
+
+ // Remove invalid input types
+ if ( $element == 'input' ) {
+ $validTypes = [
+ 'hidden',
+ 'text',
+ 'password',
+ 'checkbox',
+ 'radio',
+ 'file',
+ 'submit',
+ 'image',
+ 'reset',
+ 'button',
+
+ // HTML input types
+ 'datetime',
+ 'datetime-local',
+ 'date',
+ 'month',
+ 'time',
+ 'week',
+ 'number',
+ 'range',
+ 'email',
+ 'url',
+ 'search',
+ 'tel',
+ 'color',
+ ];
+ if ( isset( $attribs['type'] ) && !in_array( $attribs['type'], $validTypes ) ) {
+ unset( $attribs['type'] );
+ }
+ }
+
+ // According to standard the default type for <button> elements is "submit".
+ // Depending on compatibility mode IE might use "button", instead.
+ // We enforce the standard "submit".
+ if ( $element == 'button' && !isset( $attribs['type'] ) ) {
+ $attribs['type'] = 'submit';
+ }
+
+ return "<$element" . self::expandAttributes(
+ self::dropDefaults( $element, $attribs ) ) . '>';
+ }
+
+ /**
+ * Returns "</$element>"
+ *
+ * @since 1.17
+ * @param string $element Name of the element, e.g., 'a'
+ * @return string A closing tag
+ */
+ public static function closeElement( $element ) {
+ $element = strtolower( $element );
+
+ return "</$element>";
+ }
+
+ /**
+ * Given an element name and an associative array of element attributes,
+ * return an array that is functionally identical to the input array, but
+ * possibly smaller. In particular, attributes might be stripped if they
+ * are given their default values.
+ *
+ * This method is not guaranteed to remove all redundant attributes, only
+ * some common ones and some others selected arbitrarily at random. It
+ * only guarantees that the output array should be functionally identical
+ * to the input array (currently per the HTML 5 draft as of 2009-09-06).
+ *
+ * @param string $element Name of the element, e.g., 'a'
+ * @param array $attribs Associative array of attributes, e.g., [
+ * 'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
+ * further documentation.
+ * @return array An array of attributes functionally identical to $attribs
+ */
+ private static function dropDefaults( $element, array $attribs ) {
+ // Whenever altering this array, please provide a covering test case
+ // in HtmlTest::provideElementsWithAttributesHavingDefaultValues
+ static $attribDefaults = [
+ 'area' => [ 'shape' => 'rect' ],
+ 'button' => [
+ 'formaction' => 'GET',
+ 'formenctype' => 'application/x-www-form-urlencoded',
+ ],
+ 'canvas' => [
+ 'height' => '150',
+ 'width' => '300',
+ ],
+ 'form' => [
+ 'action' => 'GET',
+ 'autocomplete' => 'on',
+ 'enctype' => 'application/x-www-form-urlencoded',
+ ],
+ 'input' => [
+ 'formaction' => 'GET',
+ 'type' => 'text',
+ ],
+ 'keygen' => [ 'keytype' => 'rsa' ],
+ 'link' => [ 'media' => 'all' ],
+ 'menu' => [ 'type' => 'list' ],
+ 'script' => [ 'type' => 'text/javascript' ],
+ 'style' => [
+ 'media' => 'all',
+ 'type' => 'text/css',
+ ],
+ 'textarea' => [ 'wrap' => 'soft' ],
+ ];
+
+ $element = strtolower( $element );
+
+ foreach ( $attribs as $attrib => $value ) {
+ $lcattrib = strtolower( $attrib );
+ if ( is_array( $value ) ) {
+ $value = implode( ' ', $value );
+ } else {
+ $value = strval( $value );
+ }
+
+ // Simple checks using $attribDefaults
+ if ( isset( $attribDefaults[$element][$lcattrib] )
+ && $attribDefaults[$element][$lcattrib] == $value
+ ) {
+ unset( $attribs[$attrib] );
+ }
+
+ if ( $lcattrib == 'class' && $value == '' ) {
+ unset( $attribs[$attrib] );
+ }
+ }
+
+ // More subtle checks
+ if ( $element === 'link'
+ && isset( $attribs['type'] ) && strval( $attribs['type'] ) == 'text/css'
+ ) {
+ unset( $attribs['type'] );
+ }
+ if ( $element === 'input' ) {
+ $type = isset( $attribs['type'] ) ? $attribs['type'] : null;
+ $value = isset( $attribs['value'] ) ? $attribs['value'] : null;
+ if ( $type === 'checkbox' || $type === 'radio' ) {
+ // The default value for checkboxes and radio buttons is 'on'
+ // not ''. By stripping value="" we break radio boxes that
+ // actually wants empty values.
+ if ( $value === 'on' ) {
+ unset( $attribs['value'] );
+ }
+ } elseif ( $type === 'submit' ) {
+ // The default value for submit appears to be "Submit" but
+ // let's not bother stripping out localized text that matches
+ // that.
+ } else {
+ // The default value for nearly every other field type is ''
+ // The 'range' and 'color' types use different defaults but
+ // stripping a value="" does not hurt them.
+ if ( $value === '' ) {
+ unset( $attribs['value'] );
+ }
+ }
+ }
+ if ( $element === 'select' && isset( $attribs['size'] ) ) {
+ if ( in_array( 'multiple', $attribs )
+ || ( isset( $attribs['multiple'] ) && $attribs['multiple'] !== false )
+ ) {
+ // A multi-select
+ if ( strval( $attribs['size'] ) == '4' ) {
+ unset( $attribs['size'] );
+ }
+ } else {
+ // Single select
+ if ( strval( $attribs['size'] ) == '1' ) {
+ unset( $attribs['size'] );
+ }
+ }
+ }
+
+ return $attribs;
+ }
+
+ /**
+ * Given an associative array of element attributes, generate a string
+ * to stick after the element name in HTML output. Like [ 'href' =>
+ * 'https://www.mediawiki.org/' ] becomes something like
+ * ' href="https://www.mediawiki.org"'. Again, this is like
+ * Xml::expandAttributes(), but it implements some HTML-specific logic.
+ *
+ * Attributes that can contain space-separated lists ('class', 'accesskey' and 'rel') array
+ * values are allowed as well, which will automagically be normalized
+ * and converted to a space-separated string. In addition to a numerical
+ * array, the attribute value may also be an associative array. See the
+ * example below for how that works.
+ *
+ * @par Numerical array
+ * @code
+ * Html::element( 'em', [
+ * 'class' => [ 'foo', 'bar' ]
+ * ] );
+ * // gives '<em class="foo bar"></em>'
+ * @endcode
+ *
+ * @par Associative array
+ * @code
+ * Html::element( 'em', [
+ * 'class' => [ 'foo', 'bar', 'foo' => false, 'quux' => true ]
+ * ] );
+ * // gives '<em class="bar quux"></em>'
+ * @endcode
+ *
+ * @param array $attribs Associative array of attributes, e.g., [
+ * 'href' => 'https://www.mediawiki.org/' ]. Values will be HTML-escaped.
+ * A value of false or null means to omit the attribute. For boolean attributes,
+ * you can omit the key, e.g., [ 'checked' ] instead of
+ * [ 'checked' => 'checked' ] or such.
+ *
+ * @throws MWException If an attribute that doesn't allow lists is set to an array
+ * @return string HTML fragment that goes between element name and '>'
+ * (starting with a space if at least one attribute is output)
+ */
+ public static function expandAttributes( array $attribs ) {
+ $ret = '';
+ foreach ( $attribs as $key => $value ) {
+ // Support intuitive [ 'checked' => true/false ] form
+ if ( $value === false || is_null( $value ) ) {
+ continue;
+ }
+
+ // For boolean attributes, support [ 'foo' ] instead of
+ // requiring [ 'foo' => 'meaningless' ].
+ if ( is_int( $key ) && in_array( strtolower( $value ), self::$boolAttribs ) ) {
+ $key = $value;
+ }
+
+ // Not technically required in HTML5 but we'd like consistency
+ // and better compression anyway.
+ $key = strtolower( $key );
+
+ // https://www.w3.org/TR/html401/index/attributes.html ("space-separated")
+ // https://www.w3.org/TR/html5/index.html#attributes-1 ("space-separated")
+ $spaceSeparatedListAttributes = [
+ 'class', // html4, html5
+ 'accesskey', // as of html5, multiple space-separated values allowed
+ // html4-spec doesn't document rel= as space-separated
+ // but has been used like that and is now documented as such
+ // in the html5-spec.
+ 'rel',
+ ];
+
+ // Specific features for attributes that allow a list of space-separated values
+ if ( in_array( $key, $spaceSeparatedListAttributes ) ) {
+ // Apply some normalization and remove duplicates
+
+ // Convert into correct array. Array can contain space-separated
+ // values. Implode/explode to get those into the main array as well.
+ if ( is_array( $value ) ) {
+ // If input wasn't an array, we can skip this step
+ $newValue = [];
+ foreach ( $value as $k => $v ) {
+ if ( is_string( $v ) ) {
+ // String values should be normal `array( 'foo' )`
+ // Just append them
+ if ( !isset( $value[$v] ) ) {
+ // As a special case don't set 'foo' if a
+ // separate 'foo' => true/false exists in the array
+ // keys should be authoritative
+ $newValue[] = $v;
+ }
+ } elseif ( $v ) {
+ // If the value is truthy but not a string this is likely
+ // an [ 'foo' => true ], falsy values don't add strings
+ $newValue[] = $k;
+ }
+ }
+ $value = implode( ' ', $newValue );
+ }
+ $value = explode( ' ', $value );
+
+ // Normalize spacing by fixing up cases where people used
+ // more than 1 space and/or a trailing/leading space
+ $value = array_diff( $value, [ '', ' ' ] );
+
+ // Remove duplicates and create the string
+ $value = implode( ' ', array_unique( $value ) );
+ } elseif ( is_array( $value ) ) {
+ throw new MWException( "HTML attribute $key can not contain a list of values" );
+ }
+
+ $quote = '"';
+
+ if ( in_array( $key, self::$boolAttribs ) ) {
+ $ret .= " $key=\"\"";
+ } else {
+ $ret .= " $key=$quote" . Sanitizer::encodeAttribute( $value ) . $quote;
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Output a "<script>" tag with the given contents.
+ *
+ * @todo do some useful escaping as well, like if $contents contains
+ * literal "</script>" or (for XML) literal "]]>".
+ *
+ * @param string $contents JavaScript
+ * @return string Raw HTML
+ */
+ public static function inlineScript( $contents ) {
+ $attrs = [];
+
+ if ( preg_match( '/[<&]/', $contents ) ) {
+ $contents = "/*<![CDATA[*/$contents/*]]>*/";
+ }
+
+ return self::rawElement( 'script', $attrs, $contents );
+ }
+
+ /**
+ * Output a "<script>" tag linking to the given URL, e.g.,
+ * "<script src=foo.js></script>".
+ *
+ * @param string $url
+ * @return string Raw HTML
+ */
+ public static function linkedScript( $url ) {
+ $attrs = [ 'src' => $url ];
+
+ return self::element( 'script', $attrs );
+ }
+
+ /**
+ * Output a "<style>" tag with the given contents for the given media type
+ * (if any). TODO: do some useful escaping as well, like if $contents
+ * contains literal "</style>" (admittedly unlikely).
+ *
+ * @param string $contents CSS
+ * @param string $media A media type string, like 'screen'
+ * @return string Raw HTML
+ */
+ public static function inlineStyle( $contents, $media = 'all' ) {
+ // Don't escape '>' since that is used
+ // as direct child selector.
+ // Remember, in css, there is no "x" for hexadecimal escapes, and
+ // the space immediately after an escape sequence is swallowed.
+ $contents = strtr( $contents, [
+ '<' => '\3C ',
+ // CDATA end tag for good measure, but the main security
+ // is from escaping the '<'.
+ ']]>' => '\5D\5D\3E '
+ ] );
+
+ if ( preg_match( '/[<&]/', $contents ) ) {
+ $contents = "/*<![CDATA[*/$contents/*]]>*/";
+ }
+
+ return self::rawElement( 'style', [
+ 'media' => $media,
+ ], $contents );
+ }
+
+ /**
+ * Output a "<link rel=stylesheet>" linking to the given URL for the given
+ * media type (if any).
+ *
+ * @param string $url
+ * @param string $media A media type string, like 'screen'
+ * @return string Raw HTML
+ */
+ public static function linkedStyle( $url, $media = 'all' ) {
+ return self::element( 'link', [
+ 'rel' => 'stylesheet',
+ 'href' => $url,
+ 'media' => $media,
+ ] );
+ }
+
+ /**
+ * Convenience function to produce an "<input>" element. This supports the
+ * new HTML5 input types and attributes.
+ *
+ * @param string $name Name attribute
+ * @param string $value Value attribute
+ * @param string $type Type attribute
+ * @param array $attribs Associative array of miscellaneous extra
+ * attributes, passed to Html::element()
+ * @return string Raw HTML
+ */
+ public static function input( $name, $value = '', $type = 'text', array $attribs = [] ) {
+ $attribs['type'] = $type;
+ $attribs['value'] = $value;
+ $attribs['name'] = $name;
+ if ( in_array( $type, [ 'text', 'search', 'email', 'password', 'number' ] ) ) {
+ $attribs = self::getTextInputAttributes( $attribs );
+ }
+ if ( in_array( $type, [ 'button', 'reset', 'submit' ] ) ) {
+ $attribs = self::buttonAttributes( $attribs );
+ }
+ return self::element( 'input', $attribs );
+ }
+
+ /**
+ * Convenience function to produce a checkbox (input element with type=checkbox)
+ *
+ * @param string $name Name attribute
+ * @param bool $checked Whether the checkbox is checked or not
+ * @param array $attribs Array of additional attributes
+ * @return string Raw HTML
+ */
+ public static function check( $name, $checked = false, array $attribs = [] ) {
+ if ( isset( $attribs['value'] ) ) {
+ $value = $attribs['value'];
+ unset( $attribs['value'] );
+ } else {
+ $value = 1;
+ }
+
+ if ( $checked ) {
+ $attribs[] = 'checked';
+ }
+
+ return self::input( $name, $value, 'checkbox', $attribs );
+ }
+
+ /**
+ * Convenience function to produce a radio button (input element with type=radio)
+ *
+ * @param string $name Name attribute
+ * @param bool $checked Whether the radio button is checked or not
+ * @param array $attribs Array of additional attributes
+ * @return string Raw HTML
+ */
+ public static function radio( $name, $checked = false, array $attribs = [] ) {
+ if ( isset( $attribs['value'] ) ) {
+ $value = $attribs['value'];
+ unset( $attribs['value'] );
+ } else {
+ $value = 1;
+ }
+
+ if ( $checked ) {
+ $attribs[] = 'checked';
+ }
+
+ return self::input( $name, $value, 'radio', $attribs );
+ }
+
+ /**
+ * Convenience function for generating a label for inputs.
+ *
+ * @param string $label Contents of the label
+ * @param string $id ID of the element being labeled
+ * @param array $attribs Additional attributes
+ * @return string Raw HTML
+ */
+ public static function label( $label, $id, array $attribs = [] ) {
+ $attribs += [
+ 'for' => $id
+ ];
+ return self::element( 'label', $attribs, $label );
+ }
+
+ /**
+ * Convenience function to produce an input element with type=hidden
+ *
+ * @param string $name Name attribute
+ * @param string $value Value attribute
+ * @param array $attribs Associative array of miscellaneous extra
+ * attributes, passed to Html::element()
+ * @return string Raw HTML
+ */
+ public static function hidden( $name, $value, array $attribs = [] ) {
+ return self::input( $name, $value, 'hidden', $attribs );
+ }
+
+ /**
+ * Convenience function to produce a <textarea> element.
+ *
+ * This supports leaving out the cols= and rows= which Xml requires and are
+ * required by HTML4/XHTML but not required by HTML5.
+ *
+ * @param string $name Name attribute
+ * @param string $value Value attribute
+ * @param array $attribs Associative array of miscellaneous extra
+ * attributes, passed to Html::element()
+ * @return string Raw HTML
+ */
+ public static function textarea( $name, $value = '', array $attribs = [] ) {
+ $attribs['name'] = $name;
+
+ if ( substr( $value, 0, 1 ) == "\n" ) {
+ // Workaround for T14130: browsers eat the initial newline
+ // assuming that it's just for show, but they do keep the later
+ // newlines, which we may want to preserve during editing.
+ // Prepending a single newline
+ $spacedValue = "\n" . $value;
+ } else {
+ $spacedValue = $value;
+ }
+ return self::element( 'textarea', self::getTextInputAttributes( $attribs ), $spacedValue );
+ }
+
+ /**
+ * Helper for Html::namespaceSelector().
+ * @param array $params See Html::namespaceSelector()
+ * @return array
+ */
+ public static function namespaceSelectorOptions( array $params = [] ) {
+ global $wgContLang;
+
+ $options = [];
+
+ if ( !isset( $params['exclude'] ) || !is_array( $params['exclude'] ) ) {
+ $params['exclude'] = [];
+ }
+
+ if ( isset( $params['all'] ) ) {
+ // add an option that would let the user select all namespaces.
+ // Value is provided by user, the name shown is localized for the user.
+ $options[$params['all']] = wfMessage( 'namespacesall' )->text();
+ }
+ // Add all namespaces as options (in the content language)
+ $options += $wgContLang->getFormattedNamespaces();
+
+ $optionsOut = [];
+ // Filter out namespaces below 0 and massage labels
+ foreach ( $options as $nsId => $nsName ) {
+ if ( $nsId < NS_MAIN || in_array( $nsId, $params['exclude'] ) ) {
+ continue;
+ }
+ if ( $nsId === NS_MAIN ) {
+ // For other namespaces use the namespace prefix as label, but for
+ // main we don't use "" but the user message describing it (e.g. "(Main)" or "(Article)")
+ $nsName = wfMessage( 'blanknamespace' )->text();
+ } elseif ( is_int( $nsId ) ) {
+ $nsName = $wgContLang->convertNamespace( $nsId );
+ }
+ $optionsOut[$nsId] = $nsName;
+ }
+
+ return $optionsOut;
+ }
+
+ /**
+ * Build a drop-down box for selecting a namespace
+ *
+ * @param array $params Params to set.
+ * - selected: [optional] Id of namespace which should be pre-selected
+ * - all: [optional] Value of item for "all namespaces". If null or unset,
+ * no "<option>" is generated to select all namespaces.
+ * - label: text for label to add before the field.
+ * - exclude: [optional] Array of namespace ids to exclude.
+ * - disable: [optional] Array of namespace ids for which the option should
+ * be disabled in the selector.
+ * @param array $selectAttribs HTML attributes for the generated select element.
+ * - id: [optional], default: 'namespace'.
+ * - name: [optional], default: 'namespace'.
+ * @return string HTML code to select a namespace.
+ */
+ public static function namespaceSelector( array $params = [],
+ array $selectAttribs = []
+ ) {
+ ksort( $selectAttribs );
+
+ // Is a namespace selected?
+ if ( isset( $params['selected'] ) ) {
+ // If string only contains digits, convert to clean int. Selected could also
+ // be "all" or "" etc. which needs to be left untouched.
+ // PHP is_numeric() has issues with large strings, PHP ctype_digit has other issues
+ // and returns false for already clean ints. Use regex instead..
+ if ( preg_match( '/^\d+$/', $params['selected'] ) ) {
+ $params['selected'] = intval( $params['selected'] );
+ }
+ // else: leaves it untouched for later processing
+ } else {
+ $params['selected'] = '';
+ }
+
+ if ( !isset( $params['disable'] ) || !is_array( $params['disable'] ) ) {
+ $params['disable'] = [];
+ }
+
+ // Associative array between option-values and option-labels
+ $options = self::namespaceSelectorOptions( $params );
+
+ // Convert $options to HTML
+ $optionsHtml = [];
+ foreach ( $options as $nsId => $nsName ) {
+ $optionsHtml[] = self::element(
+ 'option', [
+ 'disabled' => in_array( $nsId, $params['disable'] ),
+ 'value' => $nsId,
+ 'selected' => $nsId === $params['selected'],
+ ], $nsName
+ );
+ }
+
+ if ( !array_key_exists( 'id', $selectAttribs ) ) {
+ $selectAttribs['id'] = 'namespace';
+ }
+
+ if ( !array_key_exists( 'name', $selectAttribs ) ) {
+ $selectAttribs['name'] = 'namespace';
+ }
+
+ $ret = '';
+ if ( isset( $params['label'] ) ) {
+ $ret .= self::element(
+ 'label', [
+ 'for' => isset( $selectAttribs['id'] ) ? $selectAttribs['id'] : null,
+ ], $params['label']
+ ) . '&#160;';
+ }
+
+ // Wrap options in a <select>
+ $ret .= self::openElement( 'select', $selectAttribs )
+ . "\n"
+ . implode( "\n", $optionsHtml )
+ . "\n"
+ . self::closeElement( 'select' );
+
+ return $ret;
+ }
+
+ /**
+ * Constructs the opening html-tag with necessary doctypes depending on
+ * global variables.
+ *
+ * @param array $attribs Associative array of miscellaneous extra
+ * attributes, passed to Html::element() of html tag.
+ * @return string Raw HTML
+ */
+ public static function htmlHeader( array $attribs = [] ) {
+ $ret = '';
+
+ global $wgHtml5Version, $wgMimeType, $wgXhtmlNamespaces;
+
+ $isXHTML = self::isXmlMimeType( $wgMimeType );
+
+ if ( $isXHTML ) { // XHTML5
+ // XML MIME-typed markup should have an xml header.
+ // However a DOCTYPE is not needed.
+ $ret .= "<?xml version=\"1.0\" encoding=\"UTF-8\" ?" . ">\n";
+
+ // Add the standard xmlns
+ $attribs['xmlns'] = 'http://www.w3.org/1999/xhtml';
+
+ // And support custom namespaces
+ foreach ( $wgXhtmlNamespaces as $tag => $ns ) {
+ $attribs["xmlns:$tag"] = $ns;
+ }
+ } else { // HTML5
+ // DOCTYPE
+ $ret .= "<!DOCTYPE html>\n";
+ }
+
+ if ( $wgHtml5Version ) {
+ $attribs['version'] = $wgHtml5Version;
+ }
+
+ $ret .= self::openElement( 'html', $attribs );
+
+ return $ret;
+ }
+
+ /**
+ * Determines if the given MIME type is xml.
+ *
+ * @param string $mimetype MIME type
+ * @return bool
+ */
+ public static function isXmlMimeType( $mimetype ) {
+ # https://html.spec.whatwg.org/multipage/infrastructure.html#xml-mime-type
+ # * text/xml
+ # * application/xml
+ # * Any MIME type with a subtype ending in +xml (this implicitly includes application/xhtml+xml)
+ return (bool)preg_match( '!^(text|application)/xml$|^.+/.+\+xml$!', $mimetype );
+ }
+
+ /**
+ * Get HTML for an info box with an icon.
+ *
+ * @param string $text Wikitext, get this with wfMessage()->plain()
+ * @param string $icon Path to icon file (used as 'src' attribute)
+ * @param string $alt Alternate text for the icon
+ * @param string $class Additional class name to add to the wrapper div
+ *
+ * @return string
+ */
+ static function infoBox( $text, $icon, $alt, $class = '' ) {
+ $s = self::openElement( 'div', [ 'class' => "mw-infobox $class" ] );
+
+ $s .= self::openElement( 'div', [ 'class' => 'mw-infobox-left' ] ) .
+ self::element( 'img',
+ [
+ 'src' => $icon,
+ 'alt' => $alt,
+ ]
+ ) .
+ self::closeElement( 'div' );
+
+ $s .= self::openElement( 'div', [ 'class' => 'mw-infobox-right' ] ) .
+ $text .
+ self::closeElement( 'div' );
+ $s .= self::element( 'div', [ 'style' => 'clear: left;' ], ' ' );
+
+ $s .= self::closeElement( 'div' );
+
+ $s .= self::element( 'div', [ 'style' => 'clear: left;' ], ' ' );
+
+ return $s;
+ }
+
+ /**
+ * Generate a srcset attribute value.
+ *
+ * Generates a srcset attribute value from an array mapping pixel densities
+ * to URLs. A trailing 'x' in pixel density values is optional.
+ *
+ * @note srcset width and height values are not supported.
+ *
+ * @see https://html.spec.whatwg.org/#attr-img-srcset
+ *
+ * @par Example:
+ * @code
+ * Html::srcSet( [
+ * '1x' => 'standard.jpeg',
+ * '1.5x' => 'large.jpeg',
+ * '3x' => 'extra-large.jpeg',
+ * ] );
+ * // gives 'standard.jpeg 1x, large.jpeg 1.5x, extra-large.jpeg 2x'
+ * @endcode
+ *
+ * @param string[] $urls
+ * @return string
+ */
+ static function srcSet( array $urls ) {
+ $candidates = [];
+ foreach ( $urls as $density => $url ) {
+ // Cast density to float to strip 'x', then back to string to serve
+ // as array index.
+ $density = (string)(float)$density;
+ $candidates[$density] = $url;
+ }
+
+ // Remove duplicates that are the same as a smaller value
+ ksort( $candidates, SORT_NUMERIC );
+ $candidates = array_unique( $candidates );
+
+ // Append density info to the url
+ foreach ( $candidates as $density => $url ) {
+ $candidates[$density] = $url . ' ' . $density . 'x';
+ }
+
+ return implode( ", ", $candidates );
+ }
+}
diff --git a/www/wiki/includes/HtmlFormatter.php b/www/wiki/includes/HtmlFormatter.php
new file mode 100644
index 00000000..9bae8b5f
--- /dev/null
+++ b/www/wiki/includes/HtmlFormatter.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * Stub for extensions that haven't switched to Composer-based version of this class
+ * @todo: remove in 1.28
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @deprecated since 1.27, use HtmlFormatter\HtmlFormatter
+ */
+class HtmlFormatter extends HtmlFormatter\HtmlFormatter {
+}
diff --git a/www/wiki/includes/HttpFunctions.php b/www/wiki/includes/HttpFunctions.php
new file mode 100644
index 00000000..694bbb5f
--- /dev/null
+++ b/www/wiki/includes/HttpFunctions.php
@@ -0,0 +1,1122 @@
+<?php
+/**
+ * Various HTTP related functions.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup HTTP
+ */
+
+/**
+ * @defgroup HTTP HTTP
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Various HTTP related functions
+ * @ingroup HTTP
+ */
+class Http {
+ static public $httpEngine = false;
+
+ /**
+ * Perform an HTTP request
+ *
+ * @param string $method HTTP method. Usually GET/POST
+ * @param string $url Full URL to act on. If protocol-relative, will be expanded to an http:// URL
+ * @param array $options Options to pass to MWHttpRequest object.
+ * Possible keys for the array:
+ * - timeout Timeout length in seconds
+ * - connectTimeout Timeout for connection, in seconds (curl only)
+ * - postData An array of key-value pairs or a url-encoded form data
+ * - proxy The proxy to use.
+ * Otherwise it will use $wgHTTPProxy (if set)
+ * Otherwise it will use the environment variable "http_proxy" (if set)
+ * - noProxy Don't use any proxy at all. Takes precedence over proxy value(s).
+ * - sslVerifyHost Verify hostname against certificate
+ * - sslVerifyCert Verify SSL certificate
+ * - caInfo Provide CA information
+ * - maxRedirects Maximum number of redirects to follow (defaults to 5)
+ * - followRedirects Whether to follow redirects (defaults to false).
+ * Note: this should only be used when the target URL is trusted,
+ * to avoid attacks on intranet services accessible by HTTP.
+ * - userAgent A user agent, if you want to override the default
+ * MediaWiki/$wgVersion
+ * @param string $caller The method making this request, for profiling
+ * @return string|bool (bool)false on failure or a string on success
+ */
+ public static function request( $method, $url, $options = [], $caller = __METHOD__ ) {
+ wfDebug( "HTTP: $method: $url\n" );
+
+ $options['method'] = strtoupper( $method );
+
+ if ( !isset( $options['timeout'] ) ) {
+ $options['timeout'] = 'default';
+ }
+ if ( !isset( $options['connectTimeout'] ) ) {
+ $options['connectTimeout'] = 'default';
+ }
+
+ $req = MWHttpRequest::factory( $url, $options, $caller );
+ $status = $req->execute();
+
+ if ( $status->isOK() ) {
+ return $req->getContent();
+ } else {
+ $errors = $status->getErrorsByType( 'error' );
+ $logger = LoggerFactory::getInstance( 'http' );
+ $logger->warning( $status->getWikiText( false, false, 'en' ),
+ [ 'error' => $errors, 'caller' => $caller, 'content' => $req->getContent() ] );
+ return false;
+ }
+ }
+
+ /**
+ * Simple wrapper for Http::request( 'GET' )
+ * @see Http::request()
+ * @since 1.25 Second parameter $timeout removed. Second parameter
+ * is now $options which can be given a 'timeout'
+ *
+ * @param string $url
+ * @param array $options
+ * @param string $caller The method making this request, for profiling
+ * @return string|bool false on error
+ */
+ public static function get( $url, $options = [], $caller = __METHOD__ ) {
+ $args = func_get_args();
+ if ( isset( $args[1] ) && ( is_string( $args[1] ) || is_numeric( $args[1] ) ) ) {
+ // Second was used to be the timeout
+ // And third parameter used to be $options
+ wfWarn( "Second parameter should not be a timeout.", 2 );
+ $options = isset( $args[2] ) && is_array( $args[2] ) ?
+ $args[2] : [];
+ $options['timeout'] = $args[1];
+ $caller = __METHOD__;
+ }
+ return Http::request( 'GET', $url, $options, $caller );
+ }
+
+ /**
+ * Simple wrapper for Http::request( 'POST' )
+ * @see Http::request()
+ *
+ * @param string $url
+ * @param array $options
+ * @param string $caller The method making this request, for profiling
+ * @return string|bool false on error
+ */
+ public static function post( $url, $options = [], $caller = __METHOD__ ) {
+ return Http::request( 'POST', $url, $options, $caller );
+ }
+
+ /**
+ * Check if the URL can be served by localhost
+ *
+ * @param string $url Full url to check
+ * @return bool
+ */
+ public static function isLocalURL( $url ) {
+ global $wgCommandLineMode, $wgLocalVirtualHosts;
+
+ if ( $wgCommandLineMode ) {
+ return false;
+ }
+
+ // Extract host part
+ $matches = [];
+ if ( preg_match( '!^http://([\w.-]+)[/:].*$!', $url, $matches ) ) {
+ $host = $matches[1];
+ // Split up dotwise
+ $domainParts = explode( '.', $host );
+ // Check if this domain or any superdomain is listed as a local virtual host
+ $domainParts = array_reverse( $domainParts );
+
+ $domain = '';
+ $countParts = count( $domainParts );
+ for ( $i = 0; $i < $countParts; $i++ ) {
+ $domainPart = $domainParts[$i];
+ if ( $i == 0 ) {
+ $domain = $domainPart;
+ } else {
+ $domain = $domainPart . '.' . $domain;
+ }
+
+ if ( in_array( $domain, $wgLocalVirtualHosts ) ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * A standard user-agent we can use for external requests.
+ * @return string
+ */
+ public static function userAgent() {
+ global $wgVersion;
+ return "MediaWiki/$wgVersion";
+ }
+
+ /**
+ * Checks that the given URI is a valid one. Hardcoding the
+ * protocols, because we only want protocols that both cURL
+ * and php support.
+ *
+ * file:// should not be allowed here for security purpose (r67684)
+ *
+ * @todo FIXME this is wildly inaccurate and fails to actually check most stuff
+ *
+ * @param string $uri URI to check for validity
+ * @return bool
+ */
+ public static function isValidURI( $uri ) {
+ return preg_match(
+ '/^https?:\/\/[^\/\s]\S*$/D',
+ $uri
+ );
+ }
+
+ /**
+ * Gets the relevant proxy from $wgHTTPProxy
+ *
+ * @return mixed The proxy address or an empty string if not set.
+ */
+ public static function getProxy() {
+ global $wgHTTPProxy;
+
+ if ( $wgHTTPProxy ) {
+ return $wgHTTPProxy;
+ }
+
+ return "";
+ }
+}
+
+/**
+ * This wrapper class will call out to curl (if available) or fallback
+ * to regular PHP if necessary for handling internal HTTP requests.
+ *
+ * Renamed from HttpRequest to MWHttpRequest to avoid conflict with
+ * PHP's HTTP extension.
+ */
+class MWHttpRequest {
+ const SUPPORTS_FILE_POSTS = false;
+
+ protected $content;
+ protected $timeout = 'default';
+ protected $headersOnly = null;
+ protected $postData = null;
+ protected $proxy = null;
+ protected $noProxy = false;
+ protected $sslVerifyHost = true;
+ protected $sslVerifyCert = true;
+ protected $caInfo = null;
+ protected $method = "GET";
+ protected $reqHeaders = [];
+ protected $url;
+ protected $parsedUrl;
+ protected $callback;
+ protected $maxRedirects = 5;
+ protected $followRedirects = false;
+
+ /**
+ * @var CookieJar
+ */
+ protected $cookieJar;
+
+ protected $headerList = [];
+ protected $respVersion = "0.9";
+ protected $respStatus = "200 Ok";
+ protected $respHeaders = [];
+
+ public $status;
+
+ /**
+ * @var Profiler
+ */
+ protected $profiler;
+
+ /**
+ * @var string
+ */
+ protected $profileName;
+
+ /**
+ * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL
+ * @param array $options (optional) extra params to pass (see Http::request())
+ * @param string $caller The method making this request, for profiling
+ * @param Profiler $profiler An instance of the profiler for profiling, or null
+ */
+ protected function __construct(
+ $url, $options = [], $caller = __METHOD__, $profiler = null
+ ) {
+ global $wgHTTPTimeout, $wgHTTPConnectTimeout;
+
+ $this->url = wfExpandUrl( $url, PROTO_HTTP );
+ $this->parsedUrl = wfParseUrl( $this->url );
+
+ if ( !$this->parsedUrl || !Http::isValidURI( $this->url ) ) {
+ $this->status = Status::newFatal( 'http-invalid-url', $url );
+ } else {
+ $this->status = Status::newGood( 100 ); // continue
+ }
+
+ if ( isset( $options['timeout'] ) && $options['timeout'] != 'default' ) {
+ $this->timeout = $options['timeout'];
+ } else {
+ $this->timeout = $wgHTTPTimeout;
+ }
+ if ( isset( $options['connectTimeout'] ) && $options['connectTimeout'] != 'default' ) {
+ $this->connectTimeout = $options['connectTimeout'];
+ } else {
+ $this->connectTimeout = $wgHTTPConnectTimeout;
+ }
+ if ( isset( $options['userAgent'] ) ) {
+ $this->setUserAgent( $options['userAgent'] );
+ }
+
+ $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
+ "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
+
+ foreach ( $members as $o ) {
+ if ( isset( $options[$o] ) ) {
+ // ensure that MWHttpRequest::method is always
+ // uppercased. Bug 36137
+ if ( $o == 'method' ) {
+ $options[$o] = strtoupper( $options[$o] );
+ }
+ $this->$o = $options[$o];
+ }
+ }
+
+ if ( $this->noProxy ) {
+ $this->proxy = ''; // noProxy takes precedence
+ }
+
+ // Profile based on what's calling us
+ $this->profiler = $profiler;
+ $this->profileName = $caller;
+ }
+
+ /**
+ * Simple function to test if we can make any sort of requests at all, using
+ * cURL or fopen()
+ * @return bool
+ */
+ public static function canMakeRequests() {
+ return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
+ }
+
+ /**
+ * Generate a new request object
+ * @param string $url Url to use
+ * @param array $options (optional) extra params to pass (see Http::request())
+ * @param string $caller The method making this request, for profiling
+ * @throws MWException
+ * @return CurlHttpRequest|PhpHttpRequest
+ * @see MWHttpRequest::__construct
+ */
+ public static function factory( $url, $options = null, $caller = __METHOD__ ) {
+ if ( !Http::$httpEngine ) {
+ Http::$httpEngine = function_exists( 'curl_init' ) ? 'curl' : 'php';
+ } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) {
+ throw new MWException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' .
+ ' Http::$httpEngine is set to "curl"' );
+ }
+
+ switch ( Http::$httpEngine ) {
+ case 'curl':
+ return new CurlHttpRequest( $url, $options, $caller, Profiler::instance() );
+ case 'php':
+ if ( !wfIniGetBool( 'allow_url_fopen' ) ) {
+ throw new MWException( __METHOD__ . ': allow_url_fopen ' .
+ 'needs to be enabled for pure PHP http requests to ' .
+ 'work. If possible, curl should be used instead. See ' .
+ 'http://php.net/curl.'
+ );
+ }
+ return new PhpHttpRequest( $url, $options, $caller, Profiler::instance() );
+ default:
+ throw new MWException( __METHOD__ . ': The setting of Http::$httpEngine is not valid.' );
+ }
+ }
+
+ /**
+ * Get the body, or content, of the response to the request
+ *
+ * @return string
+ */
+ public function getContent() {
+ return $this->content;
+ }
+
+ /**
+ * Set the parameters of the request
+ *
+ * @param array $args
+ * @todo overload the args param
+ */
+ public function setData( $args ) {
+ $this->postData = $args;
+ }
+
+ /**
+ * Take care of setting up the proxy (do nothing if "noProxy" is set)
+ *
+ * @return void
+ */
+ public function proxySetup() {
+ // If there is an explicit proxy set and proxies are not disabled, then use it
+ if ( $this->proxy && !$this->noProxy ) {
+ return;
+ }
+
+ // Otherwise, fallback to $wgHTTPProxy if this is not a machine
+ // local URL and proxies are not disabled
+ if ( Http::isLocalURL( $this->url ) || $this->noProxy ) {
+ $this->proxy = '';
+ } else {
+ $this->proxy = Http::getProxy();
+ }
+ }
+
+ /**
+ * Set the user agent
+ * @param string $UA
+ */
+ public function setUserAgent( $UA ) {
+ $this->setHeader( 'User-Agent', $UA );
+ }
+
+ /**
+ * Set an arbitrary header
+ * @param string $name
+ * @param string $value
+ */
+ public function setHeader( $name, $value ) {
+ // I feel like I should normalize the case here...
+ $this->reqHeaders[$name] = $value;
+ }
+
+ /**
+ * Get an array of the headers
+ * @return array
+ */
+ public function getHeaderList() {
+ $list = [];
+
+ if ( $this->cookieJar ) {
+ $this->reqHeaders['Cookie'] =
+ $this->cookieJar->serializeToHttpRequest(
+ $this->parsedUrl['path'],
+ $this->parsedUrl['host']
+ );
+ }
+
+ foreach ( $this->reqHeaders as $name => $value ) {
+ $list[] = "$name: $value";
+ }
+
+ return $list;
+ }
+
+ /**
+ * Set a read callback to accept data read from the HTTP request.
+ * By default, data is appended to an internal buffer which can be
+ * retrieved through $req->getContent().
+ *
+ * To handle data as it comes in -- especially for large files that
+ * would not fit in memory -- you can instead set your own callback,
+ * in the form function($resource, $buffer) where the first parameter
+ * is the low-level resource being read (implementation specific),
+ * and the second parameter is the data buffer.
+ *
+ * You MUST return the number of bytes handled in the buffer; if fewer
+ * bytes are reported handled than were passed to you, the HTTP fetch
+ * will be aborted.
+ *
+ * @param callable $callback
+ * @throws MWException
+ */
+ public function setCallback( $callback ) {
+ if ( !is_callable( $callback ) ) {
+ throw new MWException( 'Invalid MwHttpRequest callback' );
+ }
+ $this->callback = $callback;
+ }
+
+ /**
+ * A generic callback to read the body of the response from a remote
+ * server.
+ *
+ * @param resource $fh
+ * @param string $content
+ * @return int
+ */
+ public function read( $fh, $content ) {
+ $this->content .= $content;
+ return strlen( $content );
+ }
+
+ /**
+ * Take care of whatever is necessary to perform the URI request.
+ *
+ * @return Status
+ */
+ public function execute() {
+
+ $this->content = "";
+
+ if ( strtoupper( $this->method ) == "HEAD" ) {
+ $this->headersOnly = true;
+ }
+
+ $this->proxySetup(); // set up any proxy as needed
+
+ if ( !$this->callback ) {
+ $this->setCallback( [ $this, 'read' ] );
+ }
+
+ if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
+ $this->setUserAgent( Http::userAgent() );
+ }
+
+ }
+
+ /**
+ * Parses the headers, including the HTTP status code and any
+ * Set-Cookie headers. This function expects the headers to be
+ * found in an array in the member variable headerList.
+ */
+ protected function parseHeader() {
+
+ $lastname = "";
+
+ foreach ( $this->headerList as $header ) {
+ if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
+ $this->respVersion = $match[1];
+ $this->respStatus = $match[2];
+ } elseif ( preg_match( "#^[ \t]#", $header ) ) {
+ $last = count( $this->respHeaders[$lastname] ) - 1;
+ $this->respHeaders[$lastname][$last] .= "\r\n$header";
+ } elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
+ $this->respHeaders[strtolower( $match[1] )][] = $match[2];
+ $lastname = strtolower( $match[1] );
+ }
+ }
+
+ $this->parseCookies();
+
+ }
+
+ /**
+ * Sets HTTPRequest status member to a fatal value with the error
+ * message if the returned integer value of the status code was
+ * not successful (< 300) or a redirect (>=300 and < 400). (see
+ * RFC2616, section 10,
+ * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html for a
+ * list of status codes.)
+ */
+ protected function setStatus() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ if ( (int)$this->respStatus > 399 ) {
+ list( $code, $message ) = explode( " ", $this->respStatus, 2 );
+ $this->status->fatal( "http-bad-status", $code, $message );
+ }
+ }
+
+ /**
+ * Get the integer value of the HTTP status code (e.g. 200 for "200 Ok")
+ * (see RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
+ * for a list of status codes.)
+ *
+ * @return int
+ */
+ public function getStatus() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ return (int)$this->respStatus;
+ }
+
+ /**
+ * Returns true if the last status code was a redirect.
+ *
+ * @return bool
+ */
+ public function isRedirect() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ $status = (int)$this->respStatus;
+
+ if ( $status >= 300 && $status <= 303 ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns an associative array of response headers after the
+ * request has been executed. Because some headers
+ * (e.g. Set-Cookie) can appear more than once the, each value of
+ * the associative array is an array of the values given.
+ *
+ * @return array
+ */
+ public function getResponseHeaders() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ return $this->respHeaders;
+ }
+
+ /**
+ * Returns the value of the given response header.
+ *
+ * @param string $header
+ * @return string
+ */
+ public function getResponseHeader( $header ) {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
+ $v = $this->respHeaders[strtolower( $header )];
+ return $v[count( $v ) - 1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Tells the MWHttpRequest object to use this pre-loaded CookieJar.
+ *
+ * @param CookieJar $jar
+ */
+ public function setCookieJar( $jar ) {
+ $this->cookieJar = $jar;
+ }
+
+ /**
+ * Returns the cookie jar in use.
+ *
+ * @return CookieJar
+ */
+ public function getCookieJar() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ return $this->cookieJar;
+ }
+
+ /**
+ * Sets a cookie. Used before a request to set up any individual
+ * cookies. Used internally after a request to parse the
+ * Set-Cookie headers.
+ * @see Cookie::set
+ * @param string $name
+ * @param mixed $value
+ * @param array $attr
+ */
+ public function setCookie( $name, $value = null, $attr = null ) {
+ if ( !$this->cookieJar ) {
+ $this->cookieJar = new CookieJar;
+ }
+
+ $this->cookieJar->setCookie( $name, $value, $attr );
+ }
+
+ /**
+ * Parse the cookies in the response headers and store them in the cookie jar.
+ */
+ protected function parseCookies() {
+
+ if ( !$this->cookieJar ) {
+ $this->cookieJar = new CookieJar;
+ }
+
+ if ( isset( $this->respHeaders['set-cookie'] ) ) {
+ $url = parse_url( $this->getFinalUrl() );
+ foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
+ $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
+ }
+ }
+
+ }
+
+ /**
+ * Returns the final URL after all redirections.
+ *
+ * Relative values of the "Location" header are incorrect as
+ * stated in RFC, however they do happen and modern browsers
+ * support them. This function loops backwards through all
+ * locations in order to build the proper absolute URI - Marooned
+ * at wikia-inc.com
+ *
+ * Note that the multiple Location: headers are an artifact of
+ * CURL -- they shouldn't actually get returned this way. Rewrite
+ * this when bug 29232 is taken care of (high-level redirect
+ * handling rewrite).
+ *
+ * @return string
+ */
+ public function getFinalUrl() {
+ $headers = $this->getResponseHeaders();
+
+ // return full url (fix for incorrect but handled relative location)
+ if ( isset( $headers['location'] ) ) {
+ $locations = $headers['location'];
+ $domain = '';
+ $foundRelativeURI = false;
+ $countLocations = count( $locations );
+
+ for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
+ $url = parse_url( $locations[$i] );
+
+ if ( isset( $url['host'] ) ) {
+ $domain = $url['scheme'] . '://' . $url['host'];
+ break; // found correct URI (with host)
+ } else {
+ $foundRelativeURI = true;
+ }
+ }
+
+ if ( $foundRelativeURI ) {
+ if ( $domain ) {
+ return $domain . $locations[$countLocations - 1];
+ } else {
+ $url = parse_url( $this->url );
+ if ( isset( $url['host'] ) ) {
+ return $url['scheme'] . '://' . $url['host'] .
+ $locations[$countLocations - 1];
+ }
+ }
+ } else {
+ return $locations[$countLocations - 1];
+ }
+ }
+
+ return $this->url;
+ }
+
+ /**
+ * Returns true if the backend can follow redirects. Overridden by the
+ * child classes.
+ * @return bool
+ */
+ public function canFollowRedirects() {
+ return true;
+ }
+}
+
+/**
+ * MWHttpRequest implemented using internal curl compiled into PHP
+ */
+class CurlHttpRequest extends MWHttpRequest {
+ const SUPPORTS_FILE_POSTS = true;
+
+ protected $curlOptions = [];
+ protected $headerText = "";
+
+ /**
+ * @param resource $fh
+ * @param string $content
+ * @return int
+ */
+ protected function readHeader( $fh, $content ) {
+ $this->headerText .= $content;
+ return strlen( $content );
+ }
+
+ public function execute() {
+
+ parent::execute();
+
+ if ( !$this->status->isOK() ) {
+ return $this->status;
+ }
+
+ $this->curlOptions[CURLOPT_PROXY] = $this->proxy;
+ $this->curlOptions[CURLOPT_TIMEOUT] = $this->timeout;
+
+ // Only supported in curl >= 7.16.2
+ if ( defined( 'CURLOPT_CONNECTTIMEOUT_MS' ) ) {
+ $this->curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = $this->connectTimeout * 1000;
+ }
+
+ $this->curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
+ $this->curlOptions[CURLOPT_WRITEFUNCTION] = $this->callback;
+ $this->curlOptions[CURLOPT_HEADERFUNCTION] = [ $this, "readHeader" ];
+ $this->curlOptions[CURLOPT_MAXREDIRS] = $this->maxRedirects;
+ $this->curlOptions[CURLOPT_ENCODING] = ""; # Enable compression
+
+ $this->curlOptions[CURLOPT_USERAGENT] = $this->reqHeaders['User-Agent'];
+
+ $this->curlOptions[CURLOPT_SSL_VERIFYHOST] = $this->sslVerifyHost ? 2 : 0;
+ $this->curlOptions[CURLOPT_SSL_VERIFYPEER] = $this->sslVerifyCert;
+
+ if ( $this->caInfo ) {
+ $this->curlOptions[CURLOPT_CAINFO] = $this->caInfo;
+ }
+
+ if ( $this->headersOnly ) {
+ $this->curlOptions[CURLOPT_NOBODY] = true;
+ $this->curlOptions[CURLOPT_HEADER] = true;
+ } elseif ( $this->method == 'POST' ) {
+ $this->curlOptions[CURLOPT_POST] = true;
+ $postData = $this->postData;
+ // Don't interpret POST parameters starting with '@' as file uploads, because this
+ // makes it impossible to POST plain values starting with '@' (and causes security
+ // issues potentially exposing the contents of local files).
+ // The PHP manual says this option was introduced in PHP 5.5 defaults to true in PHP 5.6,
+ // but we support lower versions, and the option doesn't exist in HHVM 5.6.99.
+ if ( defined( 'CURLOPT_SAFE_UPLOAD' ) ) {
+ $this->curlOptions[CURLOPT_SAFE_UPLOAD] = true;
+ } elseif ( is_array( $postData ) ) {
+ // In PHP 5.2 and later, '@' is interpreted as a file upload if POSTFIELDS
+ // is an array, but not if it's a string. So convert $req['body'] to a string
+ // for safety.
+ $postData = wfArrayToCgi( $postData );
+ }
+ $this->curlOptions[CURLOPT_POSTFIELDS] = $postData;
+
+ // Suppress 'Expect: 100-continue' header, as some servers
+ // will reject it with a 417 and Curl won't auto retry
+ // with HTTP 1.0 fallback
+ $this->reqHeaders['Expect'] = '';
+ } else {
+ $this->curlOptions[CURLOPT_CUSTOMREQUEST] = $this->method;
+ }
+
+ $this->curlOptions[CURLOPT_HTTPHEADER] = $this->getHeaderList();
+
+ $curlHandle = curl_init( $this->url );
+
+ if ( !curl_setopt_array( $curlHandle, $this->curlOptions ) ) {
+ throw new MWException( "Error setting curl options." );
+ }
+
+ if ( $this->followRedirects && $this->canFollowRedirects() ) {
+ MediaWiki\suppressWarnings();
+ if ( !curl_setopt( $curlHandle, CURLOPT_FOLLOWLOCATION, true ) ) {
+ wfDebug( __METHOD__ . ": Couldn't set CURLOPT_FOLLOWLOCATION. " .
+ "Probably open_basedir is set.\n" );
+ // Continue the processing. If it were in curl_setopt_array,
+ // processing would have halted on its entry
+ }
+ MediaWiki\restoreWarnings();
+ }
+
+ if ( $this->profiler ) {
+ $profileSection = $this->profiler->scopedProfileIn(
+ __METHOD__ . '-' . $this->profileName
+ );
+ }
+
+ $curlRes = curl_exec( $curlHandle );
+ if ( curl_errno( $curlHandle ) == CURLE_OPERATION_TIMEOUTED ) {
+ $this->status->fatal( 'http-timed-out', $this->url );
+ } elseif ( $curlRes === false ) {
+ $this->status->fatal( 'http-curl-error', curl_error( $curlHandle ) );
+ } else {
+ $this->headerList = explode( "\r\n", $this->headerText );
+ }
+
+ curl_close( $curlHandle );
+
+ if ( $this->profiler ) {
+ $this->profiler->scopedProfileOut( $profileSection );
+ }
+
+ $this->parseHeader();
+ $this->setStatus();
+
+ return $this->status;
+ }
+
+ /**
+ * @return bool
+ */
+ public function canFollowRedirects() {
+ $curlVersionInfo = curl_version();
+ if ( $curlVersionInfo['version_number'] < 0x071304 ) {
+ wfDebug( "Cannot follow redirects with libcurl < 7.19.4 due to CVE-2009-0037\n" );
+ return false;
+ }
+
+ if ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) {
+ if ( strval( ini_get( 'open_basedir' ) ) !== '' ) {
+ wfDebug( "Cannot follow redirects when open_basedir is set\n" );
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+class PhpHttpRequest extends MWHttpRequest {
+
+ private $fopenErrors = [];
+
+ /**
+ * @param string $url
+ * @return string
+ */
+ protected function urlToTcp( $url ) {
+ $parsedUrl = parse_url( $url );
+
+ return 'tcp://' . $parsedUrl['host'] . ':' . $parsedUrl['port'];
+ }
+
+ /**
+ * Returns an array with a 'capath' or 'cafile' key
+ * that is suitable to be merged into the 'ssl' sub-array of
+ * a stream context options array.
+ * Uses the 'caInfo' option of the class if it is provided, otherwise uses the system
+ * default CA bundle if PHP supports that, or searches a few standard locations.
+ * @return array
+ * @throws DomainException
+ */
+ protected function getCertOptions() {
+ $certOptions = [];
+ $certLocations = [];
+ if ( $this->caInfo ) {
+ $certLocations = [ 'manual' => $this->caInfo ];
+ } elseif ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) {
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ // Default locations, based on
+ // https://www.happyassassin.net/2015/01/12/a-note-about-ssltls-trusted-certificate-stores-and-platforms/
+ // PHP 5.5 and older doesn't have any defaults, so we try to guess ourselves.
+ // PHP 5.6+ gets the CA location from OpenSSL as long as it is not set manually,
+ // so we should leave capath/cafile empty there.
+ // @codingStandardsIgnoreEnd
+ $certLocations = array_filter( [
+ getenv( 'SSL_CERT_DIR' ),
+ getenv( 'SSL_CERT_PATH' ),
+ '/etc/pki/tls/certs/ca-bundle.crt', # Fedora et al
+ '/etc/ssl/certs', # Debian et al
+ '/etc/pki/tls/certs/ca-bundle.trust.crt',
+ '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem',
+ '/System/Library/OpenSSL', # OSX
+ ] );
+ }
+
+ foreach ( $certLocations as $key => $cert ) {
+ if ( is_dir( $cert ) ) {
+ $certOptions['capath'] = $cert;
+ break;
+ } elseif ( is_file( $cert ) ) {
+ $certOptions['cafile'] = $cert;
+ break;
+ } elseif ( $key === 'manual' ) {
+ // fail more loudly if a cert path was manually configured and it is not valid
+ throw new DomainException( "Invalid CA info passed: $cert" );
+ }
+ }
+
+ return $certOptions;
+ }
+
+ /**
+ * Custom error handler for dealing with fopen() errors.
+ * fopen() tends to fire multiple errors in succession, and the last one
+ * is completely useless (something like "fopen: failed to open stream")
+ * so normal methods of handling errors programmatically
+ * like get_last_error() don't work.
+ */
+ public function errorHandler( $errno, $errstr ) {
+ $n = count( $this->fopenErrors ) + 1;
+ $this->fopenErrors += [ "errno$n" => $errno, "errstr$n" => $errstr ];
+ }
+
+ public function execute() {
+
+ parent::execute();
+
+ if ( is_array( $this->postData ) ) {
+ $this->postData = wfArrayToCgi( $this->postData );
+ }
+
+ if ( $this->parsedUrl['scheme'] != 'http'
+ && $this->parsedUrl['scheme'] != 'https' ) {
+ $this->status->fatal( 'http-invalid-scheme', $this->parsedUrl['scheme'] );
+ }
+
+ $this->reqHeaders['Accept'] = "*/*";
+ $this->reqHeaders['Connection'] = 'Close';
+ if ( $this->method == 'POST' ) {
+ // Required for HTTP 1.0 POSTs
+ $this->reqHeaders['Content-Length'] = strlen( $this->postData );
+ if ( !isset( $this->reqHeaders['Content-Type'] ) ) {
+ $this->reqHeaders['Content-Type'] = "application/x-www-form-urlencoded";
+ }
+ }
+
+ // Set up PHP stream context
+ $options = [
+ 'http' => [
+ 'method' => $this->method,
+ 'header' => implode( "\r\n", $this->getHeaderList() ),
+ 'protocol_version' => '1.1',
+ 'max_redirects' => $this->followRedirects ? $this->maxRedirects : 0,
+ 'ignore_errors' => true,
+ 'timeout' => $this->timeout,
+ // Curl options in case curlwrappers are installed
+ 'curl_verify_ssl_host' => $this->sslVerifyHost ? 2 : 0,
+ 'curl_verify_ssl_peer' => $this->sslVerifyCert,
+ ],
+ 'ssl' => [
+ 'verify_peer' => $this->sslVerifyCert,
+ 'SNI_enabled' => true,
+ 'ciphers' => 'HIGH:!SSLv2:!SSLv3:-ADH:-kDH:-kECDH:-DSS',
+ 'disable_compression' => true,
+ ],
+ ];
+
+ if ( $this->proxy ) {
+ $options['http']['proxy'] = $this->urlToTcp( $this->proxy );
+ $options['http']['request_fulluri'] = true;
+ }
+
+ if ( $this->postData ) {
+ $options['http']['content'] = $this->postData;
+ }
+
+ if ( $this->sslVerifyHost ) {
+ // PHP 5.6.0 deprecates CN_match, in favour of peer_name which
+ // actually checks SubjectAltName properly.
+ if ( version_compare( PHP_VERSION, '5.6.0', '>=' ) ) {
+ $options['ssl']['peer_name'] = $this->parsedUrl['host'];
+ } else {
+ $options['ssl']['CN_match'] = $this->parsedUrl['host'];
+ }
+ }
+
+ $options['ssl'] += $this->getCertOptions();
+
+ $context = stream_context_create( $options );
+
+ $this->headerList = [];
+ $reqCount = 0;
+ $url = $this->url;
+
+ $result = [];
+
+ if ( $this->profiler ) {
+ $profileSection = $this->profiler->scopedProfileIn(
+ __METHOD__ . '-' . $this->profileName
+ );
+ }
+ do {
+ $reqCount++;
+ $this->fopenErrors = [];
+ set_error_handler( [ $this, 'errorHandler' ] );
+ $fh = fopen( $url, "r", false, $context );
+ restore_error_handler();
+
+ if ( !$fh ) {
+ // HACK for instant commons.
+ // If we are contacting (commons|upload).wikimedia.org
+ // try again with CN_match for en.wikipedia.org
+ // as php does not handle SubjectAltName properly
+ // prior to "peer_name" option in php 5.6
+ if ( isset( $options['ssl']['CN_match'] )
+ && ( $options['ssl']['CN_match'] === 'commons.wikimedia.org'
+ || $options['ssl']['CN_match'] === 'upload.wikimedia.org' )
+ ) {
+ $options['ssl']['CN_match'] = 'en.wikipedia.org';
+ $context = stream_context_create( $options );
+ continue;
+ }
+ break;
+ }
+
+ $result = stream_get_meta_data( $fh );
+ $this->headerList = $result['wrapper_data'];
+ $this->parseHeader();
+
+ if ( !$this->followRedirects ) {
+ break;
+ }
+
+ # Handle manual redirection
+ if ( !$this->isRedirect() || $reqCount > $this->maxRedirects ) {
+ break;
+ }
+ # Check security of URL
+ $url = $this->getResponseHeader( "Location" );
+
+ if ( !Http::isValidURI( $url ) ) {
+ wfDebug( __METHOD__ . ": insecure redirection\n" );
+ break;
+ }
+ } while ( true );
+ if ( $this->profiler ) {
+ $this->profiler->scopedProfileOut( $profileSection );
+ }
+
+ $this->setStatus();
+
+ if ( $fh === false ) {
+ if ( $this->fopenErrors ) {
+ LoggerFactory::getInstance( 'http' )->warning( __CLASS__
+ . ': error opening connection: {errstr1}', $this->fopenErrors );
+ }
+ $this->status->fatal( 'http-request-error' );
+ return $this->status;
+ }
+
+ if ( $result['timed_out'] ) {
+ $this->status->fatal( 'http-timed-out', $this->url );
+ return $this->status;
+ }
+
+ // If everything went OK, or we received some error code
+ // get the response body content.
+ if ( $this->status->isOK() || (int)$this->respStatus >= 300 ) {
+ while ( !feof( $fh ) ) {
+ $buf = fread( $fh, 8192 );
+
+ if ( $buf === false ) {
+ $this->status->fatal( 'http-read-error' );
+ break;
+ }
+
+ if ( strlen( $buf ) ) {
+ call_user_func( $this->callback, $fh, $buf );
+ }
+ }
+ }
+ fclose( $fh );
+
+ return $this->status;
+ }
+}
diff --git a/www/wiki/includes/Licenses.php b/www/wiki/includes/Licenses.php
new file mode 100644
index 00000000..da1a8da6
--- /dev/null
+++ b/www/wiki/includes/Licenses.php
@@ -0,0 +1,210 @@
+<?php
+/**
+ * License selector for use on Special:Upload.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * A License class for use on Special:Upload
+ */
+class Licenses extends HTMLFormField {
+ /** @var string */
+ protected $msg;
+
+ /** @var array */
+ protected $licenses = [];
+
+ /** @var string */
+ protected $html;
+ /**#@-*/
+
+ /**
+ * @param array $params
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ $this->msg = empty( $params['licenses'] )
+ ? wfMessage( 'licenses' )->inContentLanguage()->plain()
+ : $params['licenses'];
+ $this->selected = null;
+
+ $this->makeLicenses();
+ }
+
+ /**
+ * @private
+ */
+ protected function makeLicenses() {
+ $levels = [];
+ $lines = explode( "\n", $this->msg );
+
+ foreach ( $lines as $line ) {
+ if ( strpos( $line, '*' ) !== 0 ) {
+ continue;
+ } else {
+ list( $level, $line ) = $this->trimStars( $line );
+
+ if ( strpos( $line, '|' ) !== false ) {
+ $obj = new License( $line );
+ $this->stackItem( $this->licenses, $levels, $obj );
+ } else {
+ if ( $level < count( $levels ) ) {
+ $levels = array_slice( $levels, 0, $level );
+ }
+ if ( $level == count( $levels ) ) {
+ $levels[$level - 1] = $line;
+ } elseif ( $level > count( $levels ) ) {
+ $levels[] = $line;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @param string $str
+ * @return array
+ */
+ protected function trimStars( $str ) {
+ $numStars = strspn( $str, '*' );
+ return [ $numStars, ltrim( substr( $str, $numStars ), ' ' ) ];
+ }
+
+ /**
+ * @param array $list
+ * @param array $path
+ * @param mixed $item
+ */
+ protected function stackItem( &$list, $path, $item ) {
+ $position =& $list;
+ if ( $path ) {
+ foreach ( $path as $key ) {
+ $position =& $position[$key];
+ }
+ }
+ $position[] = $item;
+ }
+
+ /**
+ * @param array $tagset
+ * @param int $depth
+ */
+ protected function makeHtml( $tagset, $depth = 0 ) {
+ foreach ( $tagset as $key => $val ) {
+ if ( is_array( $val ) ) {
+ $this->html .= $this->outputOption(
+ $key, '',
+ [
+ 'disabled' => 'disabled',
+ 'style' => 'color: GrayText', // for MSIE
+ ],
+ $depth
+ );
+ $this->makeHtml( $val, $depth + 1 );
+ } else {
+ $this->html .= $this->outputOption(
+ $val->text, $val->template,
+ [ 'title' => '{{' . $val->template . '}}' ],
+ $depth
+ );
+ }
+ }
+ }
+
+ /**
+ * @param string $message
+ * @param string $value
+ * @param null|array $attribs
+ * @param int $depth
+ * @return string
+ */
+ protected function outputOption( $message, $value, $attribs = null, $depth = 0 ) {
+ $msgObj = $this->msg( $message );
+ $text = $msgObj->exists() ? $msgObj->text() : $message;
+ $attribs['value'] = $value;
+ if ( $value === $this->selected ) {
+ $attribs['selected'] = 'selected';
+ }
+
+ $val = str_repeat( /* &nbsp */ "\xc2\xa0", $depth * 2 ) . $text;
+ return str_repeat( "\t", $depth ) . Xml::element( 'option', $attribs, $val ) . "\n";
+ }
+
+ /**#@-*/
+
+ /**
+ * Accessor for $this->licenses
+ *
+ * @return array
+ */
+ public function getLicenses() {
+ return $this->licenses;
+ }
+
+ /**
+ * Accessor for $this->html
+ *
+ * @param bool $value
+ *
+ * @return string
+ */
+ public function getInputHTML( $value ) {
+ $this->selected = $value;
+
+ $this->html = $this->outputOption( wfMessage( 'nolicense' )->text(), '',
+ (bool)$this->selected ? null : [ 'selected' => 'selected' ] );
+ $this->makeHtml( $this->getLicenses() );
+
+ $attribs = [
+ 'name' => $this->mName,
+ 'id' => $this->mID
+ ];
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $attibs['disabled'] = 'disabled';
+ }
+
+ return Html::rawElement( 'select', $attribs, $this->html );
+ }
+}
+
+/**
+ * A License class for use on Special:Upload (represents a single type of license).
+ */
+class License {
+ /** @var string */
+ public $template;
+
+ /** @var string */
+ public $text;
+
+ /**
+ * @param string $str License name??
+ */
+ function __construct( $str ) {
+ list( $text, $template ) = explode( '|', strrev( $str ), 2 );
+
+ $this->template = strrev( $template );
+ $this->text = strrev( $text );
+ }
+}
diff --git a/www/wiki/includes/LinkFilter.php b/www/wiki/includes/LinkFilter.php
new file mode 100644
index 00000000..790e2be4
--- /dev/null
+++ b/www/wiki/includes/LinkFilter.php
@@ -0,0 +1,191 @@
+<?php
+/**
+ * Functions to help implement an external link filter for spam control.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Wikimedia\Rdbms\LikeMatch;
+
+/**
+ * Some functions to help implement an external link filter for spam control.
+ *
+ * @todo implement the filter. Currently these are just some functions to help
+ * maintenance/cleanupSpam.php remove links to a single specified domain. The
+ * next thing is to implement functions for checking a given page against a big
+ * list of domains.
+ *
+ * Another cool thing to do would be a web interface for fast spam removal.
+ */
+class LinkFilter {
+
+ /**
+ * Check whether $content contains a link to $filterEntry
+ *
+ * @param Content $content Content to check
+ * @param string $filterEntry Domainparts, see makeRegex() for more details
+ * @return int 0 if no match or 1 if there's at least one match
+ */
+ static function matchEntry( Content $content, $filterEntry ) {
+ if ( !( $content instanceof TextContent ) ) {
+ // TODO: handle other types of content too.
+ // Maybe create ContentHandler::matchFilter( LinkFilter ).
+ // Think about a common base class for LinkFilter and MagicWord.
+ return 0;
+ }
+
+ $text = $content->getNativeData();
+
+ $regex = self::makeRegex( $filterEntry );
+ return preg_match( $regex, $text );
+ }
+
+ /**
+ * Builds a regex pattern for $filterEntry.
+ *
+ * @param string $filterEntry URL, if it begins with "*.", it'll be
+ * replaced to match any subdomain
+ * @return string Regex pattern, for preg_match()
+ */
+ private static function makeRegex( $filterEntry ) {
+ $regex = '!http://';
+ if ( substr( $filterEntry, 0, 2 ) == '*.' ) {
+ $regex .= '(?:[A-Za-z0-9.-]+\.|)';
+ $filterEntry = substr( $filterEntry, 2 );
+ }
+ $regex .= preg_quote( $filterEntry, '!' ) . '!Si';
+ return $regex;
+ }
+
+ /**
+ * Make an array to be used for calls to Database::buildLike(), which
+ * will match the specified string. There are several kinds of filter entry:
+ * *.domain.com - Produces http://com.domain.%, matches domain.com
+ * and www.domain.com
+ * domain.com - Produces http://com.domain./%, matches domain.com
+ * or domain.com/ but not www.domain.com
+ * *.domain.com/x - Produces http://com.domain.%/x%, matches
+ * www.domain.com/xy
+ * domain.com/x - Produces http://com.domain./x%, matches
+ * domain.com/xy but not www.domain.com/xy
+ *
+ * Asterisks in any other location are considered invalid.
+ *
+ * This function does the same as wfMakeUrlIndexes(), except it also takes care
+ * of adding wildcards
+ *
+ * @param string $filterEntry Domainparts
+ * @param string $protocol Protocol (default http://)
+ * @return array|bool Array to be passed to Database::buildLike() or false on error
+ */
+ public static function makeLikeArray( $filterEntry, $protocol = 'http://' ) {
+ $db = wfGetDB( DB_REPLICA );
+
+ $target = $protocol . $filterEntry;
+ $bits = wfParseUrl( $target );
+
+ if ( $bits == false ) {
+ // Unknown protocol?
+ return false;
+ }
+
+ if ( substr( $bits['host'], 0, 2 ) == '*.' ) {
+ $subdomains = true;
+ $bits['host'] = substr( $bits['host'], 2 );
+ if ( $bits['host'] == '' ) {
+ // We don't want to make a clause that will match everything,
+ // that could be dangerous
+ return false;
+ }
+ } else {
+ $subdomains = false;
+ }
+
+ // Reverse the labels in the hostname, convert to lower case
+ // For emails reverse domainpart only
+ if ( $bits['scheme'] === 'mailto' && strpos( $bits['host'], '@' ) ) {
+ // complete email address
+ $mailparts = explode( '@', $bits['host'] );
+ $domainpart = strtolower( implode( '.', array_reverse( explode( '.', $mailparts[1] ) ) ) );
+ $bits['host'] = $domainpart . '@' . $mailparts[0];
+ } elseif ( $bits['scheme'] === 'mailto' ) {
+ // domainpart of email address only, do not add '.'
+ $bits['host'] = strtolower( implode( '.', array_reverse( explode( '.', $bits['host'] ) ) ) );
+ } else {
+ $bits['host'] = strtolower( implode( '.', array_reverse( explode( '.', $bits['host'] ) ) ) );
+ if ( substr( $bits['host'], -1, 1 ) !== '.' ) {
+ $bits['host'] .= '.';
+ }
+ }
+
+ $like[] = $bits['scheme'] . $bits['delimiter'] . $bits['host'];
+
+ if ( $subdomains ) {
+ $like[] = $db->anyString();
+ }
+
+ if ( isset( $bits['port'] ) ) {
+ $like[] = ':' . $bits['port'];
+ }
+ if ( isset( $bits['path'] ) ) {
+ $like[] = $bits['path'];
+ } elseif ( !$subdomains ) {
+ $like[] = '/';
+ }
+ if ( isset( $bits['query'] ) ) {
+ $like[] = '?' . $bits['query'];
+ }
+ if ( isset( $bits['fragment'] ) ) {
+ $like[] = '#' . $bits['fragment'];
+ }
+
+ // Check for stray asterisks: asterisk only allowed at the start of the domain
+ foreach ( $like as $likepart ) {
+ if ( !( $likepart instanceof LikeMatch ) && strpos( $likepart, '*' ) !== false ) {
+ return false;
+ }
+ }
+
+ if ( !( $like[count( $like ) - 1] instanceof LikeMatch ) ) {
+ // Add wildcard at the end if there isn't one already
+ $like[] = $db->anyString();
+ }
+
+ return $like;
+ }
+
+ /**
+ * Filters an array returned by makeLikeArray(), removing everything past first
+ * pattern placeholder.
+ *
+ * @param array $arr Array to filter
+ * @return array Filtered array
+ */
+ public static function keepOneWildcard( $arr ) {
+ if ( !is_array( $arr ) ) {
+ return $arr;
+ }
+
+ foreach ( $arr as $key => $value ) {
+ if ( $value instanceof LikeMatch ) {
+ return array_slice( $arr, 0, $key + 1 );
+ }
+ }
+
+ return $arr;
+ }
+}
diff --git a/www/wiki/includes/Linker.php b/www/wiki/includes/Linker.php
new file mode 100644
index 00000000..403b10a1
--- /dev/null
+++ b/www/wiki/includes/Linker.php
@@ -0,0 +1,2142 @@
+<?php
+/**
+ * Methods to make links and related items.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Some internal bits split of from Skin.php. These functions are used
+ * for primarily page content: links, embedded images, table of contents. Links
+ * are also used in the skin.
+ *
+ * @todo turn this into a legacy interface for HtmlPageLinkRenderer and similar services.
+ *
+ * @ingroup Skins
+ */
+class Linker {
+ /**
+ * Flags for userToolLinks()
+ */
+ const TOOL_LINKS_NOBLOCK = 1;
+ const TOOL_LINKS_EMAIL = 2;
+
+ /**
+ * Return the CSS colour of a known link
+ *
+ * @deprecated since 1.28, use LinkRenderer::getLinkClasses() instead
+ *
+ * @since 1.16.3
+ * @param LinkTarget $t
+ * @param int $threshold User defined threshold
+ * @return string CSS class
+ */
+ public static function getLinkColour( LinkTarget $t, $threshold ) {
+ wfDeprecated( __METHOD__, '1.28' );
+ $services = MediaWikiServices::getInstance();
+ $linkRenderer = $services->getLinkRenderer();
+ if ( $threshold !== $linkRenderer->getStubThreshold() ) {
+ // Need to create a new instance with the right stub threshold...
+ $linkRenderer = $services->getLinkRendererFactory()->create();
+ $linkRenderer->setStubThreshold( $threshold );
+ }
+
+ return $linkRenderer->getLinkClasses( $t );
+ }
+
+ /**
+ * This function returns an HTML link to the given target. It serves a few
+ * purposes:
+ * 1) If $target is a Title, the correct URL to link to will be figured
+ * out automatically.
+ * 2) It automatically adds the usual classes for various types of link
+ * targets: "new" for red links, "stub" for short articles, etc.
+ * 3) It escapes all attribute values safely so there's no risk of XSS.
+ * 4) It provides a default tooltip if the target is a Title (the page
+ * name of the target).
+ * link() replaces the old functions in the makeLink() family.
+ *
+ * @since 1.18 Method exists since 1.16 as non-static, made static in 1.18.
+ * @deprecated since 1.28, use MediaWiki\Linker\LinkRenderer instead
+ *
+ * @param LinkTarget $target Can currently only be a LinkTarget, but this may
+ * change to support Images, literal URLs, etc.
+ * @param string $html The HTML contents of the <a> element, i.e.,
+ * the link text. This is raw HTML and will not be escaped. If null,
+ * defaults to the prefixed text of the Title; or if the Title is just a
+ * fragment, the contents of the fragment.
+ * @param array $customAttribs A key => value array of extra HTML attributes,
+ * such as title and class. (href is ignored.) Classes will be
+ * merged with the default classes, while other attributes will replace
+ * default attributes. All passed attribute values will be HTML-escaped.
+ * A false attribute value means to suppress that attribute.
+ * @param array $query The query string to append to the URL
+ * you're linking to, in key => value array form. Query keys and values
+ * will be URL-encoded.
+ * @param string|array $options String or array of strings:
+ * 'known': Page is known to exist, so don't check if it does.
+ * 'broken': Page is known not to exist, so don't check if it does.
+ * 'noclasses': Don't add any classes automatically (includes "new",
+ * "stub", "mw-redirect", "extiw"). Only use the class attribute
+ * provided, if any, so you get a simple blue link with no funny i-
+ * cons.
+ * 'forcearticlepath': Use the article path always, even with a querystring.
+ * Has compatibility issues on some setups, so avoid wherever possible.
+ * 'http': Force a full URL with http:// as the scheme.
+ * 'https': Force a full URL with https:// as the scheme.
+ * 'stubThreshold' => (int): Stub threshold to use when determining link classes.
+ * @return string HTML <a> attribute
+ */
+ public static function link(
+ $target, $html = null, $customAttribs = [], $query = [], $options = []
+ ) {
+ if ( !$target instanceof LinkTarget ) {
+ wfWarn( __METHOD__ . ': Requires $target to be a LinkTarget object.', 2 );
+ return "<!-- ERROR -->$html";
+ }
+
+ if ( is_string( $query ) ) {
+ // some functions withing core using this still hand over query strings
+ wfDeprecated( __METHOD__ . ' with parameter $query as string (should be array)', '1.20' );
+ $query = wfCgiToArray( $query );
+ }
+
+ $services = MediaWikiServices::getInstance();
+ $options = (array)$options;
+ if ( $options ) {
+ // Custom options, create new LinkRenderer
+ if ( !isset( $options['stubThreshold'] ) ) {
+ $defaultLinkRenderer = $services->getLinkRenderer();
+ $options['stubThreshold'] = $defaultLinkRenderer->getStubThreshold();
+ }
+ $linkRenderer = $services->getLinkRendererFactory()
+ ->createFromLegacyOptions( $options );
+ } else {
+ $linkRenderer = $services->getLinkRenderer();
+ }
+
+ if ( $html !== null ) {
+ $text = new HtmlArmor( $html );
+ } else {
+ $text = $html; // null
+ }
+ if ( in_array( 'known', $options, true ) ) {
+ return $linkRenderer->makeKnownLink( $target, $text, $customAttribs, $query );
+ } elseif ( in_array( 'broken', $options, true ) ) {
+ return $linkRenderer->makeBrokenLink( $target, $text, $customAttribs, $query );
+ } elseif ( in_array( 'noclasses', $options, true ) ) {
+ return $linkRenderer->makePreloadedLink( $target, $text, '', $customAttribs, $query );
+ } else {
+ return $linkRenderer->makeLink( $target, $text, $customAttribs, $query );
+ }
+ }
+
+ /**
+ * Identical to link(), except $options defaults to 'known'.
+ *
+ * @since 1.16.3
+ * @deprecated since 1.28, use MediaWiki\Linker\LinkRenderer instead
+ * @see Linker::link
+ * @param Title $target
+ * @param string $html
+ * @param array $customAttribs
+ * @param array $query
+ * @param string|array $options
+ * @return string
+ */
+ public static function linkKnown(
+ $target, $html = null, $customAttribs = [],
+ $query = [], $options = [ 'known' ]
+ ) {
+ return self::link( $target, $html, $customAttribs, $query, $options );
+ }
+
+ /**
+ * Make appropriate markup for a link to the current article. This is since
+ * MediaWiki 1.29.0 rendered as an <a> tag without an href and with a class
+ * showing the link text. The calling sequence is the same as for the other
+ * make*LinkObj static functions, but $query is not used.
+ *
+ * @since 1.16.3
+ * @param Title $nt
+ * @param string $html [optional]
+ * @param string $query [optional]
+ * @param string $trail [optional]
+ * @param string $prefix [optional]
+ *
+ * @return string
+ */
+ public static function makeSelfLinkObj( $nt, $html = '', $query = '', $trail = '', $prefix = '' ) {
+ $ret = "<a class=\"mw-selflink selflink\">{$prefix}{$html}</a>{$trail}";
+ if ( !Hooks::run( 'SelfLinkBegin', [ $nt, &$html, &$trail, &$prefix, &$ret ] ) ) {
+ return $ret;
+ }
+
+ if ( $html == '' ) {
+ $html = htmlspecialchars( $nt->getPrefixedText() );
+ }
+ list( $inside, $trail ) = self::splitTrail( $trail );
+ return "<a class=\"mw-selflink selflink\">{$prefix}{$html}{$inside}</a>{$trail}";
+ }
+
+ /**
+ * Get a message saying that an invalid title was encountered.
+ * This should be called after a method like Title::makeTitleSafe() returned
+ * a value indicating that the title object is invalid.
+ *
+ * @param IContextSource $context Context to use to get the messages
+ * @param int $namespace Namespace number
+ * @param string $title Text of the title, without the namespace part
+ * @return string
+ */
+ public static function getInvalidTitleDescription( IContextSource $context, $namespace, $title ) {
+ global $wgContLang;
+
+ // First we check whether the namespace exists or not.
+ if ( MWNamespace::exists( $namespace ) ) {
+ if ( $namespace == NS_MAIN ) {
+ $name = $context->msg( 'blanknamespace' )->text();
+ } else {
+ $name = $wgContLang->getFormattedNsText( $namespace );
+ }
+ return $context->msg( 'invalidtitle-knownnamespace', $namespace, $name, $title )->text();
+ } else {
+ return $context->msg( 'invalidtitle-unknownnamespace', $namespace, $title )->text();
+ }
+ }
+
+ /**
+ * @since 1.16.3
+ * @param LinkTarget $target
+ * @return LinkTarget
+ */
+ public static function normaliseSpecialPage( LinkTarget $target ) {
+ if ( $target->getNamespace() == NS_SPECIAL && !$target->isExternal() ) {
+ list( $name, $subpage ) = SpecialPageFactory::resolveAlias( $target->getDBkey() );
+ if ( !$name ) {
+ return $target;
+ }
+ $ret = SpecialPage::getTitleValueFor( $name, $subpage, $target->getFragment() );
+ return $ret;
+ } else {
+ return $target;
+ }
+ }
+
+ /**
+ * Returns the filename part of an url.
+ * Used as alternative text for external images.
+ *
+ * @param string $url
+ *
+ * @return string
+ */
+ private static function fnamePart( $url ) {
+ $basename = strrchr( $url, '/' );
+ if ( false === $basename ) {
+ $basename = $url;
+ } else {
+ $basename = substr( $basename, 1 );
+ }
+ return $basename;
+ }
+
+ /**
+ * Return the code for images which were added via external links,
+ * via Parser::maybeMakeExternalImage().
+ *
+ * @since 1.16.3
+ * @param string $url
+ * @param string $alt
+ *
+ * @return string
+ */
+ public static function makeExternalImage( $url, $alt = '' ) {
+ if ( $alt == '' ) {
+ $alt = self::fnamePart( $url );
+ }
+ $img = '';
+ $success = Hooks::run( 'LinkerMakeExternalImage', [ &$url, &$alt, &$img ] );
+ if ( !$success ) {
+ wfDebug( "Hook LinkerMakeExternalImage changed the output of external image "
+ . "with url {$url} and alt text {$alt} to {$img}\n", true );
+ return $img;
+ }
+ return Html::element( 'img',
+ [
+ 'src' => $url,
+ 'alt' => $alt ] );
+ }
+
+ /**
+ * Given parameters derived from [[Image:Foo|options...]], generate the
+ * HTML that that syntax inserts in the page.
+ *
+ * @param Parser $parser
+ * @param Title $title Title object of the file (not the currently viewed page)
+ * @param File $file File object, or false if it doesn't exist
+ * @param array $frameParams Associative array of parameters external to the media handler.
+ * Boolean parameters are indicated by presence or absence, the value is arbitrary and
+ * will often be false.
+ * thumbnail If present, downscale and frame
+ * manualthumb Image name to use as a thumbnail, instead of automatic scaling
+ * framed Shows image in original size in a frame
+ * frameless Downscale but don't frame
+ * upright If present, tweak default sizes for portrait orientation
+ * upright_factor Fudge factor for "upright" tweak (default 0.75)
+ * border If present, show a border around the image
+ * align Horizontal alignment (left, right, center, none)
+ * valign Vertical alignment (baseline, sub, super, top, text-top, middle,
+ * bottom, text-bottom)
+ * alt Alternate text for image (i.e. alt attribute). Plain text.
+ * class HTML for image classes. Plain text.
+ * caption HTML for image caption.
+ * link-url URL to link to
+ * link-title Title object to link to
+ * link-target Value for the target attribute, only with link-url
+ * no-link Boolean, suppress description link
+ *
+ * @param array $handlerParams Associative array of media handler parameters, to be passed
+ * to transform(). Typical keys are "width" and "page".
+ * @param string|bool $time Timestamp of the file, set as false for current
+ * @param string $query Query params for desc url
+ * @param int|null $widthOption Used by the parser to remember the user preference thumbnailsize
+ * @since 1.20
+ * @return string HTML for an image, with links, wrappers, etc.
+ */
+ public static function makeImageLink( Parser $parser, Title $title,
+ $file, $frameParams = [], $handlerParams = [], $time = false,
+ $query = "", $widthOption = null
+ ) {
+ $res = null;
+ $dummy = new DummyLinker;
+ if ( !Hooks::run( 'ImageBeforeProduceHTML', [ &$dummy, &$title,
+ &$file, &$frameParams, &$handlerParams, &$time, &$res ] ) ) {
+ return $res;
+ }
+
+ if ( $file && !$file->allowInlineDisplay() ) {
+ wfDebug( __METHOD__ . ': ' . $title->getPrefixedDBkey() . " does not allow inline display\n" );
+ return self::link( $title );
+ }
+
+ // Clean up parameters
+ $page = isset( $handlerParams['page'] ) ? $handlerParams['page'] : false;
+ if ( !isset( $frameParams['align'] ) ) {
+ $frameParams['align'] = '';
+ }
+ if ( !isset( $frameParams['alt'] ) ) {
+ $frameParams['alt'] = '';
+ }
+ if ( !isset( $frameParams['title'] ) ) {
+ $frameParams['title'] = '';
+ }
+ if ( !isset( $frameParams['class'] ) ) {
+ $frameParams['class'] = '';
+ }
+
+ $prefix = $postfix = '';
+
+ if ( 'center' == $frameParams['align'] ) {
+ $prefix = '<div class="center">';
+ $postfix = '</div>';
+ $frameParams['align'] = 'none';
+ }
+ if ( $file && !isset( $handlerParams['width'] ) ) {
+ if ( isset( $handlerParams['height'] ) && $file->isVectorized() ) {
+ // If its a vector image, and user only specifies height
+ // we don't want it to be limited by its "normal" width.
+ global $wgSVGMaxSize;
+ $handlerParams['width'] = $wgSVGMaxSize;
+ } else {
+ $handlerParams['width'] = $file->getWidth( $page );
+ }
+
+ if ( isset( $frameParams['thumbnail'] )
+ || isset( $frameParams['manualthumb'] )
+ || isset( $frameParams['framed'] )
+ || isset( $frameParams['frameless'] )
+ || !$handlerParams['width']
+ ) {
+ global $wgThumbLimits, $wgThumbUpright;
+
+ if ( $widthOption === null || !isset( $wgThumbLimits[$widthOption] ) ) {
+ $widthOption = User::getDefaultOption( 'thumbsize' );
+ }
+
+ // Reduce width for upright images when parameter 'upright' is used
+ if ( isset( $frameParams['upright'] ) && $frameParams['upright'] == 0 ) {
+ $frameParams['upright'] = $wgThumbUpright;
+ }
+
+ // For caching health: If width scaled down due to upright
+ // parameter, round to full __0 pixel to avoid the creation of a
+ // lot of odd thumbs.
+ $prefWidth = isset( $frameParams['upright'] ) ?
+ round( $wgThumbLimits[$widthOption] * $frameParams['upright'], -1 ) :
+ $wgThumbLimits[$widthOption];
+
+ // Use width which is smaller: real image width or user preference width
+ // Unless image is scalable vector.
+ if ( !isset( $handlerParams['height'] ) && ( $handlerParams['width'] <= 0 ||
+ $prefWidth < $handlerParams['width'] || $file->isVectorized() ) ) {
+ $handlerParams['width'] = $prefWidth;
+ }
+ }
+ }
+
+ if ( isset( $frameParams['thumbnail'] ) || isset( $frameParams['manualthumb'] )
+ || isset( $frameParams['framed'] )
+ ) {
+ # Create a thumbnail. Alignment depends on the writing direction of
+ # the page content language (right-aligned for LTR languages,
+ # left-aligned for RTL languages)
+ # If a thumbnail width has not been provided, it is set
+ # to the default user option as specified in Language*.php
+ if ( $frameParams['align'] == '' ) {
+ $frameParams['align'] = $parser->getTargetLanguage()->alignEnd();
+ }
+ return $prefix .
+ self::makeThumbLink2( $title, $file, $frameParams, $handlerParams, $time, $query ) .
+ $postfix;
+ }
+
+ if ( $file && isset( $frameParams['frameless'] ) ) {
+ $srcWidth = $file->getWidth( $page );
+ # For "frameless" option: do not present an image bigger than the
+ # source (for bitmap-style images). This is the same behavior as the
+ # "thumb" option does it already.
+ if ( $srcWidth && !$file->mustRender() && $handlerParams['width'] > $srcWidth ) {
+ $handlerParams['width'] = $srcWidth;
+ }
+ }
+
+ if ( $file && isset( $handlerParams['width'] ) ) {
+ # Create a resized image, without the additional thumbnail features
+ $thumb = $file->transform( $handlerParams );
+ } else {
+ $thumb = false;
+ }
+
+ if ( !$thumb ) {
+ $s = self::makeBrokenImageLinkObj( $title, $frameParams['title'], '', '', '', $time == true );
+ } else {
+ self::processResponsiveImages( $file, $thumb, $handlerParams );
+ $params = [
+ 'alt' => $frameParams['alt'],
+ 'title' => $frameParams['title'],
+ 'valign' => isset( $frameParams['valign'] ) ? $frameParams['valign'] : false,
+ 'img-class' => $frameParams['class'] ];
+ if ( isset( $frameParams['border'] ) ) {
+ $params['img-class'] .= ( $params['img-class'] !== '' ? ' ' : '' ) . 'thumbborder';
+ }
+ $params = self::getImageLinkMTOParams( $frameParams, $query, $parser ) + $params;
+
+ $s = $thumb->toHtml( $params );
+ }
+ if ( $frameParams['align'] != '' ) {
+ $s = "<div class=\"float{$frameParams['align']}\">{$s}</div>";
+ }
+ return str_replace( "\n", ' ', $prefix . $s . $postfix );
+ }
+
+ /**
+ * Get the link parameters for MediaTransformOutput::toHtml() from given
+ * frame parameters supplied by the Parser.
+ * @param array $frameParams The frame parameters
+ * @param string $query An optional query string to add to description page links
+ * @param Parser|null $parser
+ * @return array
+ */
+ private static function getImageLinkMTOParams( $frameParams, $query = '', $parser = null ) {
+ $mtoParams = [];
+ if ( isset( $frameParams['link-url'] ) && $frameParams['link-url'] !== '' ) {
+ $mtoParams['custom-url-link'] = $frameParams['link-url'];
+ if ( isset( $frameParams['link-target'] ) ) {
+ $mtoParams['custom-target-link'] = $frameParams['link-target'];
+ }
+ if ( $parser ) {
+ $extLinkAttrs = $parser->getExternalLinkAttribs( $frameParams['link-url'] );
+ foreach ( $extLinkAttrs as $name => $val ) {
+ // Currently could include 'rel' and 'target'
+ $mtoParams['parser-extlink-' . $name] = $val;
+ }
+ }
+ } elseif ( isset( $frameParams['link-title'] ) && $frameParams['link-title'] !== '' ) {
+ $mtoParams['custom-title-link'] = Title::newFromLinkTarget(
+ self::normaliseSpecialPage( $frameParams['link-title'] )
+ );
+ } elseif ( !empty( $frameParams['no-link'] ) ) {
+ // No link
+ } else {
+ $mtoParams['desc-link'] = true;
+ $mtoParams['desc-query'] = $query;
+ }
+ return $mtoParams;
+ }
+
+ /**
+ * Make HTML for a thumbnail including image, border and caption
+ * @param Title $title
+ * @param File|bool $file File object or false if it doesn't exist
+ * @param string $label
+ * @param string $alt
+ * @param string $align
+ * @param array $params
+ * @param bool $framed
+ * @param string $manualthumb
+ * @return string
+ */
+ public static function makeThumbLinkObj( Title $title, $file, $label = '', $alt,
+ $align = 'right', $params = [], $framed = false, $manualthumb = ""
+ ) {
+ $frameParams = [
+ 'alt' => $alt,
+ 'caption' => $label,
+ 'align' => $align
+ ];
+ if ( $framed ) {
+ $frameParams['framed'] = true;
+ }
+ if ( $manualthumb ) {
+ $frameParams['manualthumb'] = $manualthumb;
+ }
+ return self::makeThumbLink2( $title, $file, $frameParams, $params );
+ }
+
+ /**
+ * @param Title $title
+ * @param File $file
+ * @param array $frameParams
+ * @param array $handlerParams
+ * @param bool $time
+ * @param string $query
+ * @return string
+ */
+ public static function makeThumbLink2( Title $title, $file, $frameParams = [],
+ $handlerParams = [], $time = false, $query = ""
+ ) {
+ $exists = $file && $file->exists();
+
+ $page = isset( $handlerParams['page'] ) ? $handlerParams['page'] : false;
+ if ( !isset( $frameParams['align'] ) ) {
+ $frameParams['align'] = 'right';
+ }
+ if ( !isset( $frameParams['alt'] ) ) {
+ $frameParams['alt'] = '';
+ }
+ if ( !isset( $frameParams['title'] ) ) {
+ $frameParams['title'] = '';
+ }
+ if ( !isset( $frameParams['caption'] ) ) {
+ $frameParams['caption'] = '';
+ }
+
+ if ( empty( $handlerParams['width'] ) ) {
+ // Reduce width for upright images when parameter 'upright' is used
+ $handlerParams['width'] = isset( $frameParams['upright'] ) ? 130 : 180;
+ }
+ $thumb = false;
+ $noscale = false;
+ $manualthumb = false;
+
+ if ( !$exists ) {
+ $outerWidth = $handlerParams['width'] + 2;
+ } else {
+ if ( isset( $frameParams['manualthumb'] ) ) {
+ # Use manually specified thumbnail
+ $manual_title = Title::makeTitleSafe( NS_FILE, $frameParams['manualthumb'] );
+ if ( $manual_title ) {
+ $manual_img = wfFindFile( $manual_title );
+ if ( $manual_img ) {
+ $thumb = $manual_img->getUnscaledThumb( $handlerParams );
+ $manualthumb = true;
+ } else {
+ $exists = false;
+ }
+ }
+ } elseif ( isset( $frameParams['framed'] ) ) {
+ // Use image dimensions, don't scale
+ $thumb = $file->getUnscaledThumb( $handlerParams );
+ $noscale = true;
+ } else {
+ # Do not present an image bigger than the source, for bitmap-style images
+ # This is a hack to maintain compatibility with arbitrary pre-1.10 behavior
+ $srcWidth = $file->getWidth( $page );
+ if ( $srcWidth && !$file->mustRender() && $handlerParams['width'] > $srcWidth ) {
+ $handlerParams['width'] = $srcWidth;
+ }
+ $thumb = $file->transform( $handlerParams );
+ }
+
+ if ( $thumb ) {
+ $outerWidth = $thumb->getWidth() + 2;
+ } else {
+ $outerWidth = $handlerParams['width'] + 2;
+ }
+ }
+
+ # ThumbnailImage::toHtml() already adds page= onto the end of DjVu URLs
+ # So we don't need to pass it here in $query. However, the URL for the
+ # zoom icon still needs it, so we make a unique query for it. See T16771
+ $url = $title->getLocalURL( $query );
+ if ( $page ) {
+ $url = wfAppendQuery( $url, [ 'page' => $page ] );
+ }
+ if ( $manualthumb
+ && !isset( $frameParams['link-title'] )
+ && !isset( $frameParams['link-url'] )
+ && !isset( $frameParams['no-link'] ) ) {
+ $frameParams['link-url'] = $url;
+ }
+
+ $s = "<div class=\"thumb t{$frameParams['align']}\">"
+ . "<div class=\"thumbinner\" style=\"width:{$outerWidth}px;\">";
+
+ if ( !$exists ) {
+ $s .= self::makeBrokenImageLinkObj( $title, $frameParams['title'], '', '', '', $time == true );
+ $zoomIcon = '';
+ } elseif ( !$thumb ) {
+ $s .= wfMessage( 'thumbnail_error', '' )->escaped();
+ $zoomIcon = '';
+ } else {
+ if ( !$noscale && !$manualthumb ) {
+ self::processResponsiveImages( $file, $thumb, $handlerParams );
+ }
+ $params = [
+ 'alt' => $frameParams['alt'],
+ 'title' => $frameParams['title'],
+ 'img-class' => ( isset( $frameParams['class'] ) && $frameParams['class'] !== ''
+ ? $frameParams['class'] . ' '
+ : '' ) . 'thumbimage'
+ ];
+ $params = self::getImageLinkMTOParams( $frameParams, $query ) + $params;
+ $s .= $thumb->toHtml( $params );
+ if ( isset( $frameParams['framed'] ) ) {
+ $zoomIcon = "";
+ } else {
+ $zoomIcon = Html::rawElement( 'div', [ 'class' => 'magnify' ],
+ Html::rawElement( 'a', [
+ 'href' => $url,
+ 'class' => 'internal',
+ 'title' => wfMessage( 'thumbnail-more' )->text() ],
+ "" ) );
+ }
+ }
+ $s .= ' <div class="thumbcaption">' . $zoomIcon . $frameParams['caption'] . "</div></div></div>";
+ return str_replace( "\n", ' ', $s );
+ }
+
+ /**
+ * Process responsive images: add 1.5x and 2x subimages to the thumbnail, where
+ * applicable.
+ *
+ * @param File $file
+ * @param MediaTransformOutput $thumb
+ * @param array $hp Image parameters
+ */
+ public static function processResponsiveImages( $file, $thumb, $hp ) {
+ global $wgResponsiveImages;
+ if ( $wgResponsiveImages && $thumb && !$thumb->isError() ) {
+ $hp15 = $hp;
+ $hp15['width'] = round( $hp['width'] * 1.5 );
+ $hp20 = $hp;
+ $hp20['width'] = $hp['width'] * 2;
+ if ( isset( $hp['height'] ) ) {
+ $hp15['height'] = round( $hp['height'] * 1.5 );
+ $hp20['height'] = $hp['height'] * 2;
+ }
+
+ $thumb15 = $file->transform( $hp15 );
+ $thumb20 = $file->transform( $hp20 );
+ if ( $thumb15 && !$thumb15->isError() && $thumb15->getUrl() !== $thumb->getUrl() ) {
+ $thumb->responsiveUrls['1.5'] = $thumb15->getUrl();
+ }
+ if ( $thumb20 && !$thumb20->isError() && $thumb20->getUrl() !== $thumb->getUrl() ) {
+ $thumb->responsiveUrls['2'] = $thumb20->getUrl();
+ }
+ }
+ }
+
+ /**
+ * Make a "broken" link to an image
+ *
+ * @since 1.16.3
+ * @param Title $title
+ * @param string $label Link label (plain text)
+ * @param string $query Query string
+ * @param string $unused1 Unused parameter kept for b/c
+ * @param string $unused2 Unused parameter kept for b/c
+ * @param bool $time A file of a certain timestamp was requested
+ * @return string
+ */
+ public static function makeBrokenImageLinkObj( $title, $label = '',
+ $query = '', $unused1 = '', $unused2 = '', $time = false
+ ) {
+ if ( !$title instanceof Title ) {
+ wfWarn( __METHOD__ . ': Requires $title to be a Title object.' );
+ return "<!-- ERROR -->" . htmlspecialchars( $label );
+ }
+
+ global $wgEnableUploads, $wgUploadMissingFileUrl, $wgUploadNavigationUrl;
+ if ( $label == '' ) {
+ $label = $title->getPrefixedText();
+ }
+ $encLabel = htmlspecialchars( $label );
+ $currentExists = $time ? ( wfFindFile( $title ) != false ) : false;
+
+ if ( ( $wgUploadMissingFileUrl || $wgUploadNavigationUrl || $wgEnableUploads )
+ && !$currentExists
+ ) {
+ $redir = RepoGroup::singleton()->getLocalRepo()->checkRedirect( $title );
+
+ if ( $redir ) {
+ // We already know it's a redirect, so mark it
+ // accordingly
+ return self::link(
+ $title,
+ $encLabel,
+ [ 'class' => 'mw-redirect' ],
+ wfCgiToArray( $query ),
+ [ 'known', 'noclasses' ]
+ );
+ }
+
+ $href = self::getUploadUrl( $title, $query );
+
+ return '<a href="' . htmlspecialchars( $href ) . '" class="new" title="' .
+ htmlspecialchars( $title->getPrefixedText(), ENT_QUOTES ) . '">' .
+ $encLabel . '</a>';
+ }
+
+ return self::link( $title, $encLabel, [], wfCgiToArray( $query ), [ 'known', 'noclasses' ] );
+ }
+
+ /**
+ * Get the URL to upload a certain file
+ *
+ * @since 1.16.3
+ * @param Title $destFile Title object of the file to upload
+ * @param string $query Urlencoded query string to prepend
+ * @return string Urlencoded URL
+ */
+ protected static function getUploadUrl( $destFile, $query = '' ) {
+ global $wgUploadMissingFileUrl, $wgUploadNavigationUrl;
+ $q = 'wpDestFile=' . $destFile->getPartialURL();
+ if ( $query != '' ) {
+ $q .= '&' . $query;
+ }
+
+ if ( $wgUploadMissingFileUrl ) {
+ return wfAppendQuery( $wgUploadMissingFileUrl, $q );
+ } elseif ( $wgUploadNavigationUrl ) {
+ return wfAppendQuery( $wgUploadNavigationUrl, $q );
+ } else {
+ $upload = SpecialPage::getTitleFor( 'Upload' );
+ return $upload->getLocalURL( $q );
+ }
+ }
+
+ /**
+ * Create a direct link to a given uploaded file.
+ *
+ * @since 1.16.3
+ * @param Title $title
+ * @param string $html Pre-sanitized HTML
+ * @param string $time MW timestamp of file creation time
+ * @return string HTML
+ */
+ public static function makeMediaLinkObj( $title, $html = '', $time = false ) {
+ $img = wfFindFile( $title, [ 'time' => $time ] );
+ return self::makeMediaLinkFile( $title, $img, $html );
+ }
+
+ /**
+ * Create a direct link to a given uploaded file.
+ * This will make a broken link if $file is false.
+ *
+ * @since 1.16.3
+ * @param Title $title
+ * @param File|bool $file File object or false
+ * @param string $html Pre-sanitized HTML
+ * @return string HTML
+ *
+ * @todo Handle invalid or missing images better.
+ */
+ public static function makeMediaLinkFile( Title $title, $file, $html = '' ) {
+ if ( $file && $file->exists() ) {
+ $url = $file->getUrl();
+ $class = 'internal';
+ } else {
+ $url = self::getUploadUrl( $title );
+ $class = 'new';
+ }
+
+ $alt = $title->getText();
+ if ( $html == '' ) {
+ $html = $alt;
+ }
+
+ $ret = '';
+ $attribs = [
+ 'href' => $url,
+ 'class' => $class,
+ 'title' => $alt
+ ];
+
+ if ( !Hooks::run( 'LinkerMakeMediaLinkFile',
+ [ $title, $file, &$html, &$attribs, &$ret ] ) ) {
+ wfDebug( "Hook LinkerMakeMediaLinkFile changed the output of link "
+ . "with url {$url} and text {$html} to {$ret}\n", true );
+ return $ret;
+ }
+
+ return Html::rawElement( 'a', $attribs, $html );
+ }
+
+ /**
+ * Make a link to a special page given its name and, optionally,
+ * a message key from the link text.
+ * Usage example: Linker::specialLink( 'Recentchanges' )
+ *
+ * @since 1.16.3
+ * @param string $name
+ * @param string $key
+ * @return string
+ */
+ public static function specialLink( $name, $key = '' ) {
+ if ( $key == '' ) {
+ $key = strtolower( $name );
+ }
+
+ return self::linkKnown( SpecialPage::getTitleFor( $name ), wfMessage( $key )->text() );
+ }
+
+ /**
+ * Make an external link
+ * @since 1.16.3. $title added in 1.21
+ * @param string $url URL to link to
+ * @param string $text Text of link
+ * @param bool $escape Do we escape the link text?
+ * @param string $linktype Type of external link. Gets added to the classes
+ * @param array $attribs Array of extra attributes to <a>
+ * @param Title|null $title Title object used for title specific link attributes
+ * @return string
+ */
+ public static function makeExternalLink( $url, $text, $escape = true,
+ $linktype = '', $attribs = [], $title = null
+ ) {
+ global $wgTitle;
+ $class = "external";
+ if ( $linktype ) {
+ $class .= " $linktype";
+ }
+ if ( isset( $attribs['class'] ) && $attribs['class'] ) {
+ $class .= " {$attribs['class']}";
+ }
+ $attribs['class'] = $class;
+
+ if ( $escape ) {
+ $text = htmlspecialchars( $text );
+ }
+
+ if ( !$title ) {
+ $title = $wgTitle;
+ }
+ $newRel = Parser::getExternalLinkRel( $url, $title );
+ if ( !isset( $attribs['rel'] ) || $attribs['rel'] === '' ) {
+ $attribs['rel'] = $newRel;
+ } elseif ( $newRel !== '' ) {
+ // Merge the rel attributes.
+ $newRels = explode( ' ', $newRel );
+ $oldRels = explode( ' ', $attribs['rel'] );
+ $combined = array_unique( array_merge( $newRels, $oldRels ) );
+ $attribs['rel'] = implode( ' ', $combined );
+ }
+ $link = '';
+ $success = Hooks::run( 'LinkerMakeExternalLink',
+ [ &$url, &$text, &$link, &$attribs, $linktype ] );
+ if ( !$success ) {
+ wfDebug( "Hook LinkerMakeExternalLink changed the output of link "
+ . "with url {$url} and text {$text} to {$link}\n", true );
+ return $link;
+ }
+ $attribs['href'] = $url;
+ return Html::rawElement( 'a', $attribs, $text );
+ }
+
+ /**
+ * Make user link (or user contributions for unregistered users)
+ * @param int $userId User id in database.
+ * @param string $userName User name in database.
+ * @param string $altUserName Text to display instead of the user name (optional)
+ * @return string HTML fragment
+ * @since 1.16.3. $altUserName was added in 1.19.
+ */
+ public static function userLink( $userId, $userName, $altUserName = false ) {
+ $classes = 'mw-userlink';
+ if ( $userId == 0 ) {
+ $page = SpecialPage::getTitleFor( 'Contributions', $userName );
+ if ( $altUserName === false ) {
+ $altUserName = IP::prettifyIP( $userName );
+ }
+ $classes .= ' mw-anonuserlink'; // Separate link class for anons (T45179)
+ } else {
+ $page = Title::makeTitle( NS_USER, $userName );
+ }
+
+ // Wrap the output with <bdi> tags for directionality isolation
+ return self::link(
+ $page,
+ '<bdi>' . htmlspecialchars( $altUserName !== false ? $altUserName : $userName ) . '</bdi>',
+ [ 'class' => $classes ]
+ );
+ }
+
+ /**
+ * Generate standard user tool links (talk, contributions, block link, etc.)
+ *
+ * @since 1.16.3
+ * @param int $userId User identifier
+ * @param string $userText User name or IP address
+ * @param bool $redContribsWhenNoEdits Should the contributions link be
+ * red if the user has no edits?
+ * @param int $flags Customisation flags (e.g. Linker::TOOL_LINKS_NOBLOCK
+ * and Linker::TOOL_LINKS_EMAIL).
+ * @param int $edits User edit count (optional, for performance)
+ * @return string HTML fragment
+ */
+ public static function userToolLinks(
+ $userId, $userText, $redContribsWhenNoEdits = false, $flags = 0, $edits = null
+ ) {
+ global $wgUser, $wgDisableAnonTalk, $wgLang;
+ $talkable = !( $wgDisableAnonTalk && 0 == $userId );
+ $blockable = !( $flags & self::TOOL_LINKS_NOBLOCK );
+ $addEmailLink = $flags & self::TOOL_LINKS_EMAIL && $userId;
+
+ $items = [];
+ if ( $talkable ) {
+ $items[] = self::userTalkLink( $userId, $userText );
+ }
+ if ( $userId ) {
+ // check if the user has an edit
+ $attribs = [];
+ $attribs['class'] = 'mw-usertoollinks-contribs';
+ if ( $redContribsWhenNoEdits ) {
+ if ( intval( $edits ) === 0 && $edits !== 0 ) {
+ $user = User::newFromId( $userId );
+ $edits = $user->getEditCount();
+ }
+ if ( $edits === 0 ) {
+ $attribs['class'] .= ' new';
+ }
+ }
+ $contribsPage = SpecialPage::getTitleFor( 'Contributions', $userText );
+
+ $items[] = self::link( $contribsPage, wfMessage( 'contribslink' )->escaped(), $attribs );
+ }
+ if ( $blockable && $wgUser->isAllowed( 'block' ) ) {
+ $items[] = self::blockLink( $userId, $userText );
+ }
+
+ if ( $addEmailLink && $wgUser->canSendEmail() ) {
+ $items[] = self::emailLink( $userId, $userText );
+ }
+
+ Hooks::run( 'UserToolLinksEdit', [ $userId, $userText, &$items ] );
+
+ if ( $items ) {
+ return wfMessage( 'word-separator' )->escaped()
+ . '<span class="mw-usertoollinks">'
+ . wfMessage( 'parentheses' )->rawParams( $wgLang->pipeList( $items ) )->escaped()
+ . '</span>';
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Alias for userToolLinks( $userId, $userText, true );
+ * @since 1.16.3
+ * @param int $userId User identifier
+ * @param string $userText User name or IP address
+ * @param int $edits User edit count (optional, for performance)
+ * @return string
+ */
+ public static function userToolLinksRedContribs( $userId, $userText, $edits = null ) {
+ return self::userToolLinks( $userId, $userText, true, 0, $edits );
+ }
+
+ /**
+ * @since 1.16.3
+ * @param int $userId User id in database.
+ * @param string $userText User name in database.
+ * @return string HTML fragment with user talk link
+ */
+ public static function userTalkLink( $userId, $userText ) {
+ $userTalkPage = Title::makeTitle( NS_USER_TALK, $userText );
+ $moreLinkAttribs['class'] = 'mw-usertoollinks-talk';
+ $userTalkLink = self::link( $userTalkPage,
+ wfMessage( 'talkpagelinktext' )->escaped(),
+ $moreLinkAttribs );
+ return $userTalkLink;
+ }
+
+ /**
+ * @since 1.16.3
+ * @param int $userId Userid
+ * @param string $userText User name in database.
+ * @return string HTML fragment with block link
+ */
+ public static function blockLink( $userId, $userText ) {
+ $blockPage = SpecialPage::getTitleFor( 'Block', $userText );
+ $moreLinkAttribs['class'] = 'mw-usertoollinks-block';
+ $blockLink = self::link( $blockPage,
+ wfMessage( 'blocklink' )->escaped(),
+ $moreLinkAttribs );
+ return $blockLink;
+ }
+
+ /**
+ * @param int $userId Userid
+ * @param string $userText User name in database.
+ * @return string HTML fragment with e-mail user link
+ */
+ public static function emailLink( $userId, $userText ) {
+ $emailPage = SpecialPage::getTitleFor( 'Emailuser', $userText );
+ $moreLinkAttribs['class'] = 'mw-usertoollinks-mail';
+ $emailLink = self::link( $emailPage,
+ wfMessage( 'emaillink' )->escaped(),
+ $moreLinkAttribs );
+ return $emailLink;
+ }
+
+ /**
+ * Generate a user link if the current user is allowed to view it
+ * @since 1.16.3
+ * @param Revision $rev
+ * @param bool $isPublic Show only if all users can see it
+ * @return string HTML fragment
+ */
+ public static function revUserLink( $rev, $isPublic = false ) {
+ if ( $rev->isDeleted( Revision::DELETED_USER ) && $isPublic ) {
+ $link = wfMessage( 'rev-deleted-user' )->escaped();
+ } elseif ( $rev->userCan( Revision::DELETED_USER ) ) {
+ $link = self::userLink( $rev->getUser( Revision::FOR_THIS_USER ),
+ $rev->getUserText( Revision::FOR_THIS_USER ) );
+ } else {
+ $link = wfMessage( 'rev-deleted-user' )->escaped();
+ }
+ if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
+ return '<span class="history-deleted">' . $link . '</span>';
+ }
+ return $link;
+ }
+
+ /**
+ * Generate a user tool link cluster if the current user is allowed to view it
+ * @since 1.16.3
+ * @param Revision $rev
+ * @param bool $isPublic Show only if all users can see it
+ * @return string HTML
+ */
+ public static function revUserTools( $rev, $isPublic = false ) {
+ if ( $rev->isDeleted( Revision::DELETED_USER ) && $isPublic ) {
+ $link = wfMessage( 'rev-deleted-user' )->escaped();
+ } elseif ( $rev->userCan( Revision::DELETED_USER ) ) {
+ $userId = $rev->getUser( Revision::FOR_THIS_USER );
+ $userText = $rev->getUserText( Revision::FOR_THIS_USER );
+ $link = self::userLink( $userId, $userText )
+ . self::userToolLinks( $userId, $userText );
+ } else {
+ $link = wfMessage( 'rev-deleted-user' )->escaped();
+ }
+ if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
+ return ' <span class="history-deleted">' . $link . '</span>';
+ }
+ return $link;
+ }
+
+ /**
+ * This function is called by all recent changes variants, by the page history,
+ * and by the user contributions list. It is responsible for formatting edit
+ * summaries. It escapes any HTML in the summary, but adds some CSS to format
+ * auto-generated comments (from section editing) and formats [[wikilinks]].
+ *
+ * @author Erik Moeller <moeller@scireview.de>
+ * @since 1.16.3. $wikiId added in 1.26
+ *
+ * Note: there's not always a title to pass to this function.
+ * Since you can't set a default parameter for a reference, I've turned it
+ * temporarily to a value pass. Should be adjusted further. --brion
+ *
+ * @param string $comment
+ * @param Title|null $title Title object (to generate link to the section in autocomment)
+ * or null
+ * @param bool $local Whether section links should refer to local page
+ * @param string|null $wikiId Id (as used by WikiMap) of the wiki to generate links to.
+ * For use with external changes.
+ *
+ * @return mixed|string
+ */
+ public static function formatComment(
+ $comment, $title = null, $local = false, $wikiId = null
+ ) {
+ # Sanitize text a bit:
+ $comment = str_replace( "\n", " ", $comment );
+ # Allow HTML entities (for T15815)
+ $comment = Sanitizer::escapeHtmlAllowEntities( $comment );
+
+ # Render autocomments and make links:
+ $comment = self::formatAutocomments( $comment, $title, $local, $wikiId );
+ $comment = self::formatLinksInComment( $comment, $title, $local, $wikiId );
+
+ return $comment;
+ }
+
+ /**
+ * Converts autogenerated comments in edit summaries into section links.
+ *
+ * The pattern for autogen comments is / * foo * /, which makes for
+ * some nasty regex.
+ * We look for all comments, match any text before and after the comment,
+ * add a separator where needed and format the comment itself with CSS
+ * Called by Linker::formatComment.
+ *
+ * @param string $comment Comment text
+ * @param Title|null $title An optional title object used to links to sections
+ * @param bool $local Whether section links should refer to local page
+ * @param string|null $wikiId Id of the wiki to link to (if not the local wiki),
+ * as used by WikiMap.
+ *
+ * @return string Formatted comment (wikitext)
+ */
+ private static function formatAutocomments(
+ $comment, $title = null, $local = false, $wikiId = null
+ ) {
+ // @todo $append here is something of a hack to preserve the status
+ // quo. Someone who knows more about bidi and such should decide
+ // (1) what sane rendering even *is* for an LTR edit summary on an RTL
+ // wiki, both when autocomments exist and when they don't, and
+ // (2) what markup will make that actually happen.
+ $append = '';
+ $comment = preg_replace_callback(
+ // To detect the presence of content before or after the
+ // auto-comment, we use capturing groups inside optional zero-width
+ // assertions. But older versions of PCRE can't directly make
+ // zero-width assertions optional, so wrap them in a non-capturing
+ // group.
+ '!(?:(?<=(.)))?/\*\s*(.*?)\s*\*/(?:(?=(.)))?!',
+ function ( $match ) use ( $title, $local, $wikiId, &$append ) {
+ global $wgLang;
+
+ // Ensure all match positions are defined
+ $match += [ '', '', '', '' ];
+
+ $pre = $match[1] !== '';
+ $auto = $match[2];
+ $post = $match[3] !== '';
+ $comment = null;
+
+ Hooks::run(
+ 'FormatAutocomments',
+ [ &$comment, $pre, $auto, $post, $title, $local, $wikiId ]
+ );
+
+ if ( $comment === null ) {
+ $link = '';
+ if ( $title ) {
+ $section = $auto;
+ # Remove links that a user may have manually put in the autosummary
+ # This could be improved by copying as much of Parser::stripSectionName as desired.
+ $section = str_replace( '[[:', '', $section );
+ $section = str_replace( '[[', '', $section );
+ $section = str_replace( ']]', '', $section );
+
+ $section = Sanitizer::normalizeSectionNameWhitespace( $section ); # T24784
+ if ( $local ) {
+ $sectionTitle = Title::newFromText( '#' . $section );
+ } else {
+ $sectionTitle = Title::makeTitleSafe( $title->getNamespace(),
+ $title->getDBkey(), Sanitizer::decodeCharReferences( $section ) );
+ }
+ if ( $sectionTitle ) {
+ $link = Linker::makeCommentLink( $sectionTitle, $wgLang->getArrow(), $wikiId, 'noclasses' );
+ } else {
+ $link = '';
+ }
+ }
+ if ( $pre ) {
+ # written summary $presep autocomment (summary /* section */)
+ $pre = wfMessage( 'autocomment-prefix' )->inContentLanguage()->escaped();
+ }
+ if ( $post ) {
+ # autocomment $postsep written summary (/* section */ summary)
+ $auto .= wfMessage( 'colon-separator' )->inContentLanguage()->escaped();
+ }
+ $auto = '<span class="autocomment">' . $auto . '</span>';
+ $comment = $pre . $link . $wgLang->getDirMark()
+ . '<span dir="auto">' . $auto;
+ $append .= '</span>';
+ }
+ return $comment;
+ },
+ $comment
+ );
+ return $comment . $append;
+ }
+
+ /**
+ * Formats wiki links and media links in text; all other wiki formatting
+ * is ignored
+ *
+ * @since 1.16.3. $wikiId added in 1.26
+ * @todo FIXME: Doesn't handle sub-links as in image thumb texts like the main parser
+ *
+ * @param string $comment Text to format links in. WARNING! Since the output of this
+ * function is html, $comment must be sanitized for use as html. You probably want
+ * to pass $comment through Sanitizer::escapeHtmlAllowEntities() before calling
+ * this function.
+ * @param Title|null $title An optional title object used to links to sections
+ * @param bool $local Whether section links should refer to local page
+ * @param string|null $wikiId Id of the wiki to link to (if not the local wiki),
+ * as used by WikiMap.
+ *
+ * @return string
+ */
+ public static function formatLinksInComment(
+ $comment, $title = null, $local = false, $wikiId = null
+ ) {
+ return preg_replace_callback(
+ '/
+ \[\[
+ :? # ignore optional leading colon
+ ([^\]|]+) # 1. link target; page names cannot include ] or |
+ (?:\|
+ # 2. link text
+ # Stop matching at ]] without relying on backtracking.
+ ((?:]?[^\]])*+)
+ )?
+ \]\]
+ ([^[]*) # 3. link trail (the text up until the next link)
+ /x',
+ function ( $match ) use ( $title, $local, $wikiId ) {
+ global $wgContLang;
+
+ $medians = '(?:' . preg_quote( MWNamespace::getCanonicalName( NS_MEDIA ), '/' ) . '|';
+ $medians .= preg_quote( $wgContLang->getNsText( NS_MEDIA ), '/' ) . '):';
+
+ $comment = $match[0];
+
+ # fix up urlencoded title texts (copied from Parser::replaceInternalLinks)
+ if ( strpos( $match[1], '%' ) !== false ) {
+ $match[1] = strtr(
+ rawurldecode( $match[1] ),
+ [ '<' => '&lt;', '>' => '&gt;' ]
+ );
+ }
+
+ # Handle link renaming [[foo|text]] will show link as "text"
+ if ( $match[2] != "" ) {
+ $text = $match[2];
+ } else {
+ $text = $match[1];
+ }
+ $submatch = [];
+ $thelink = null;
+ if ( preg_match( '/^' . $medians . '(.*)$/i', $match[1], $submatch ) ) {
+ # Media link; trail not supported.
+ $linkRegexp = '/\[\[(.*?)\]\]/';
+ $title = Title::makeTitleSafe( NS_FILE, $submatch[1] );
+ if ( $title ) {
+ $thelink = Linker::makeMediaLinkObj( $title, $text );
+ }
+ } else {
+ # Other kind of link
+ # Make sure its target is non-empty
+ if ( isset( $match[1][0] ) && $match[1][0] == ':' ) {
+ $match[1] = substr( $match[1], 1 );
+ }
+ if ( $match[1] !== false && $match[1] !== '' ) {
+ if ( preg_match( $wgContLang->linkTrail(), $match[3], $submatch ) ) {
+ $trail = $submatch[1];
+ } else {
+ $trail = "";
+ }
+ $linkRegexp = '/\[\[(.*?)\]\]' . preg_quote( $trail, '/' ) . '/';
+ list( $inside, $trail ) = Linker::splitTrail( $trail );
+
+ $linkText = $text;
+ $linkTarget = Linker::normalizeSubpageLink( $title, $match[1], $linkText );
+
+ $target = Title::newFromText( $linkTarget );
+ if ( $target ) {
+ if ( $target->getText() == '' && !$target->isExternal()
+ && !$local && $title
+ ) {
+ $target = $title->createFragmentTarget( $target->getFragment() );
+ }
+
+ $thelink = Linker::makeCommentLink( $target, $linkText . $inside, $wikiId ) . $trail;
+ }
+ }
+ }
+ if ( $thelink ) {
+ // If the link is still valid, go ahead and replace it in!
+ $comment = preg_replace(
+ $linkRegexp,
+ StringUtils::escapeRegexReplacement( $thelink ),
+ $comment,
+ 1
+ );
+ }
+
+ return $comment;
+ },
+ $comment
+ );
+ }
+
+ /**
+ * Generates a link to the given Title
+ *
+ * @note This is only public for technical reasons. It's not intended for use outside Linker.
+ *
+ * @param LinkTarget $linkTarget
+ * @param string $text
+ * @param string|null $wikiId Id of the wiki to link to (if not the local wiki),
+ * as used by WikiMap.
+ * @param string|string[] $options See the $options parameter in Linker::link.
+ *
+ * @return string HTML link
+ */
+ public static function makeCommentLink(
+ LinkTarget $linkTarget, $text, $wikiId = null, $options = []
+ ) {
+ if ( $wikiId !== null && !$linkTarget->isExternal() ) {
+ $link = self::makeExternalLink(
+ WikiMap::getForeignURL(
+ $wikiId,
+ $linkTarget->getNamespace() === 0
+ ? $linkTarget->getDBkey()
+ : MWNamespace::getCanonicalName( $linkTarget->getNamespace() ) . ':'
+ . $linkTarget->getDBkey(),
+ $linkTarget->getFragment()
+ ),
+ $text,
+ /* escape = */ false // Already escaped
+ );
+ } else {
+ $link = self::link( $linkTarget, $text, [], [], $options );
+ }
+
+ return $link;
+ }
+
+ /**
+ * @param Title $contextTitle
+ * @param string $target
+ * @param string &$text
+ * @return string
+ */
+ public static function normalizeSubpageLink( $contextTitle, $target, &$text ) {
+ # Valid link forms:
+ # Foobar -- normal
+ # :Foobar -- override special treatment of prefix (images, language links)
+ # /Foobar -- convert to CurrentPage/Foobar
+ # /Foobar/ -- convert to CurrentPage/Foobar, strip the initial and final / from text
+ # ../ -- convert to CurrentPage, from CurrentPage/CurrentSubPage
+ # ../Foobar -- convert to CurrentPage/Foobar,
+ # (from CurrentPage/CurrentSubPage)
+ # ../Foobar/ -- convert to CurrentPage/Foobar, use 'Foobar' as text
+ # (from CurrentPage/CurrentSubPage)
+
+ $ret = $target; # default return value is no change
+
+ # Some namespaces don't allow subpages,
+ # so only perform processing if subpages are allowed
+ if ( $contextTitle && MWNamespace::hasSubpages( $contextTitle->getNamespace() ) ) {
+ $hash = strpos( $target, '#' );
+ if ( $hash !== false ) {
+ $suffix = substr( $target, $hash );
+ $target = substr( $target, 0, $hash );
+ } else {
+ $suffix = '';
+ }
+ # T9425
+ $target = trim( $target );
+ # Look at the first character
+ if ( $target != '' && $target[0] === '/' ) {
+ # / at end means we don't want the slash to be shown
+ $m = [];
+ $trailingSlashes = preg_match_all( '%(/+)$%', $target, $m );
+ if ( $trailingSlashes ) {
+ $noslash = $target = substr( $target, 1, -strlen( $m[0][0] ) );
+ } else {
+ $noslash = substr( $target, 1 );
+ }
+
+ $ret = $contextTitle->getPrefixedText() . '/' . trim( $noslash ) . $suffix;
+ if ( $text === '' ) {
+ $text = $target . $suffix;
+ } # this might be changed for ugliness reasons
+ } else {
+ # check for .. subpage backlinks
+ $dotdotcount = 0;
+ $nodotdot = $target;
+ while ( strncmp( $nodotdot, "../", 3 ) == 0 ) {
+ ++$dotdotcount;
+ $nodotdot = substr( $nodotdot, 3 );
+ }
+ if ( $dotdotcount > 0 ) {
+ $exploded = explode( '/', $contextTitle->getPrefixedText() );
+ if ( count( $exploded ) > $dotdotcount ) { # not allowed to go below top level page
+ $ret = implode( '/', array_slice( $exploded, 0, -$dotdotcount ) );
+ # / at the end means don't show full path
+ if ( substr( $nodotdot, -1, 1 ) === '/' ) {
+ $nodotdot = rtrim( $nodotdot, '/' );
+ if ( $text === '' ) {
+ $text = $nodotdot . $suffix;
+ }
+ }
+ $nodotdot = trim( $nodotdot );
+ if ( $nodotdot != '' ) {
+ $ret .= '/' . $nodotdot;
+ }
+ $ret .= $suffix;
+ }
+ }
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Wrap a comment in standard punctuation and formatting if
+ * it's non-empty, otherwise return empty string.
+ *
+ * @since 1.16.3. $wikiId added in 1.26
+ * @param string $comment
+ * @param Title|null $title Title object (to generate link to section in autocomment) or null
+ * @param bool $local Whether section links should refer to local page
+ * @param string|null $wikiId Id (as used by WikiMap) of the wiki to generate links to.
+ * For use with external changes.
+ *
+ * @return string
+ */
+ public static function commentBlock(
+ $comment, $title = null, $local = false, $wikiId = null
+ ) {
+ // '*' used to be the comment inserted by the software way back
+ // in antiquity in case none was provided, here for backwards
+ // compatibility, acc. to brion -ævar
+ if ( $comment == '' || $comment == '*' ) {
+ return '';
+ } else {
+ $formatted = self::formatComment( $comment, $title, $local, $wikiId );
+ $formatted = wfMessage( 'parentheses' )->rawParams( $formatted )->escaped();
+ return " <span class=\"comment\">$formatted</span>";
+ }
+ }
+
+ /**
+ * Wrap and format the given revision's comment block, if the current
+ * user is allowed to view it.
+ *
+ * @since 1.16.3
+ * @param Revision $rev
+ * @param bool $local Whether section links should refer to local page
+ * @param bool $isPublic Show only if all users can see it
+ * @return string HTML fragment
+ */
+ public static function revComment( Revision $rev, $local = false, $isPublic = false ) {
+ if ( $rev->getComment( Revision::RAW ) == "" ) {
+ return "";
+ }
+ if ( $rev->isDeleted( Revision::DELETED_COMMENT ) && $isPublic ) {
+ $block = " <span class=\"comment\">" . wfMessage( 'rev-deleted-comment' )->escaped() . "</span>";
+ } elseif ( $rev->userCan( Revision::DELETED_COMMENT ) ) {
+ $block = self::commentBlock( $rev->getComment( Revision::FOR_THIS_USER ),
+ $rev->getTitle(), $local );
+ } else {
+ $block = " <span class=\"comment\">" . wfMessage( 'rev-deleted-comment' )->escaped() . "</span>";
+ }
+ if ( $rev->isDeleted( Revision::DELETED_COMMENT ) ) {
+ return " <span class=\"history-deleted\">$block</span>";
+ }
+ return $block;
+ }
+
+ /**
+ * @since 1.16.3
+ * @param int $size
+ * @return string
+ */
+ public static function formatRevisionSize( $size ) {
+ if ( $size == 0 ) {
+ $stxt = wfMessage( 'historyempty' )->escaped();
+ } else {
+ $stxt = wfMessage( 'nbytes' )->numParams( $size )->escaped();
+ $stxt = wfMessage( 'parentheses' )->rawParams( $stxt )->escaped();
+ }
+ return "<span class=\"history-size\">$stxt</span>";
+ }
+
+ /**
+ * Add another level to the Table of Contents
+ *
+ * @since 1.16.3
+ * @return string
+ */
+ public static function tocIndent() {
+ return "\n<ul>";
+ }
+
+ /**
+ * Finish one or more sublevels on the Table of Contents
+ *
+ * @since 1.16.3
+ * @param int $level
+ * @return string
+ */
+ public static function tocUnindent( $level ) {
+ return "</li>\n" . str_repeat( "</ul>\n</li>\n", $level > 0 ? $level : 0 );
+ }
+
+ /**
+ * parameter level defines if we are on an indentation level
+ *
+ * @since 1.16.3
+ * @param string $anchor
+ * @param string $tocline
+ * @param string $tocnumber
+ * @param string $level
+ * @param string|bool $sectionIndex
+ * @return string
+ */
+ public static function tocLine( $anchor, $tocline, $tocnumber, $level, $sectionIndex = false ) {
+ $classes = "toclevel-$level";
+ if ( $sectionIndex !== false ) {
+ $classes .= " tocsection-$sectionIndex";
+ }
+
+ // \n<li class="$classes"><a href="#$anchor"><span class="tocnumber">
+ // $tocnumber</span> <span class="toctext">$tocline</span></a>
+ return "\n" . Html::openElement( 'li', [ 'class' => $classes ] )
+ . Html::rawElement( 'a',
+ [ 'href' => "#$anchor" ],
+ Html::element( 'span', [ 'class' => 'tocnumber' ], $tocnumber )
+ . ' '
+ . Html::rawElement( 'span', [ 'class' => 'toctext' ], $tocline )
+ );
+ }
+
+ /**
+ * End a Table Of Contents line.
+ * tocUnindent() will be used instead if we're ending a line below
+ * the new level.
+ * @since 1.16.3
+ * @return string
+ */
+ public static function tocLineEnd() {
+ return "</li>\n";
+ }
+
+ /**
+ * Wraps the TOC in a table and provides the hide/collapse javascript.
+ *
+ * @since 1.16.3
+ * @param string $toc Html of the Table Of Contents
+ * @param string|Language|bool $lang Language for the toc title, defaults to user language
+ * @return string Full html of the TOC
+ */
+ public static function tocList( $toc, $lang = false ) {
+ $lang = wfGetLangObj( $lang );
+ $title = wfMessage( 'toc' )->inLanguage( $lang )->escaped();
+
+ return '<div id="toc" class="toc">'
+ . '<div class="toctitle"><h2>' . $title . "</h2></div>\n"
+ . $toc
+ . "</ul>\n</div>\n";
+ }
+
+ /**
+ * Generate a table of contents from a section tree.
+ *
+ * @since 1.16.3. $lang added in 1.17
+ * @param array $tree Return value of ParserOutput::getSections()
+ * @param string|Language|bool $lang Language for the toc title, defaults to user language
+ * @return string HTML fragment
+ */
+ public static function generateTOC( $tree, $lang = false ) {
+ $toc = '';
+ $lastLevel = 0;
+ foreach ( $tree as $section ) {
+ if ( $section['toclevel'] > $lastLevel ) {
+ $toc .= self::tocIndent();
+ } elseif ( $section['toclevel'] < $lastLevel ) {
+ $toc .= self::tocUnindent(
+ $lastLevel - $section['toclevel'] );
+ } else {
+ $toc .= self::tocLineEnd();
+ }
+
+ $toc .= self::tocLine( $section['anchor'],
+ $section['line'], $section['number'],
+ $section['toclevel'], $section['index'] );
+ $lastLevel = $section['toclevel'];
+ }
+ $toc .= self::tocLineEnd();
+ return self::tocList( $toc, $lang );
+ }
+
+ /**
+ * Create a headline for content
+ *
+ * @since 1.16.3
+ * @param int $level The level of the headline (1-6)
+ * @param string $attribs Any attributes for the headline, starting with
+ * a space and ending with '>'
+ * This *must* be at least '>' for no attribs
+ * @param string $anchor The anchor to give the headline (the bit after the #)
+ * @param string $html HTML for the text of the header
+ * @param string $link HTML to add for the section edit link
+ * @param string|bool $fallbackAnchor A second, optional anchor to give for
+ * backward compatibility (false to omit)
+ *
+ * @return string HTML headline
+ */
+ public static function makeHeadline( $level, $attribs, $anchor, $html,
+ $link, $fallbackAnchor = false
+ ) {
+ $anchorEscaped = htmlspecialchars( $anchor );
+ $fallback = '';
+ if ( $fallbackAnchor !== false && $fallbackAnchor !== $anchor ) {
+ $fallbackAnchor = htmlspecialchars( $fallbackAnchor );
+ $fallback = "<span id=\"$fallbackAnchor\"></span>";
+ }
+ $ret = "<h$level$attribs"
+ . "$fallback<span class=\"mw-headline\" id=\"$anchorEscaped\">$html</span>"
+ . $link
+ . "</h$level>";
+
+ return $ret;
+ }
+
+ /**
+ * Split a link trail, return the "inside" portion and the remainder of the trail
+ * as a two-element array
+ * @param string $trail
+ * @return array
+ */
+ static function splitTrail( $trail ) {
+ global $wgContLang;
+ $regex = $wgContLang->linkTrail();
+ $inside = '';
+ if ( $trail !== '' ) {
+ $m = [];
+ if ( preg_match( $regex, $trail, $m ) ) {
+ $inside = $m[1];
+ $trail = $m[2];
+ }
+ }
+ return [ $inside, $trail ];
+ }
+
+ /**
+ * Generate a rollback link for a given revision. Currently it's the
+ * caller's responsibility to ensure that the revision is the top one. If
+ * it's not, of course, the user will get an error message.
+ *
+ * If the calling page is called with the parameter &bot=1, all rollback
+ * links also get that parameter. It causes the edit itself and the rollback
+ * to be marked as "bot" edits. Bot edits are hidden by default from recent
+ * changes, so this allows sysops to combat a busy vandal without bothering
+ * other users.
+ *
+ * If the option verify is set this function will return the link only in case the
+ * revision can be reverted. Please note that due to performance limitations
+ * it might be assumed that a user isn't the only contributor of a page while
+ * (s)he is, which will lead to useless rollback links. Furthermore this wont
+ * work if $wgShowRollbackEditCount is disabled, so this can only function
+ * as an additional check.
+ *
+ * If the option noBrackets is set the rollback link wont be enclosed in "[]".
+ *
+ * @since 1.16.3. $context added in 1.20. $options added in 1.21
+ *
+ * @param Revision $rev
+ * @param IContextSource $context Context to use or null for the main context.
+ * @param array $options
+ * @return string
+ */
+ public static function generateRollback( $rev, IContextSource $context = null,
+ $options = [ 'verify' ]
+ ) {
+ if ( $context === null ) {
+ $context = RequestContext::getMain();
+ }
+
+ $editCount = false;
+ if ( in_array( 'verify', $options, true ) ) {
+ $editCount = self::getRollbackEditCount( $rev, true );
+ if ( $editCount === false ) {
+ return '';
+ }
+ }
+
+ $inner = self::buildRollbackLink( $rev, $context, $editCount );
+
+ if ( !in_array( 'noBrackets', $options, true ) ) {
+ $inner = $context->msg( 'brackets' )->rawParams( $inner )->escaped();
+ }
+
+ return '<span class="mw-rollback-link">' . $inner . '</span>';
+ }
+
+ /**
+ * This function will return the number of revisions which a rollback
+ * would revert and, if $verify is set it will verify that a revision
+ * can be reverted (that the user isn't the only contributor and the
+ * revision we might rollback to isn't deleted). These checks can only
+ * function as an additional check as this function only checks against
+ * the last $wgShowRollbackEditCount edits.
+ *
+ * Returns null if $wgShowRollbackEditCount is disabled or false if $verify
+ * is set and the user is the only contributor of the page.
+ *
+ * @param Revision $rev
+ * @param bool $verify Try to verify that this revision can really be rolled back
+ * @return int|bool|null
+ */
+ public static function getRollbackEditCount( $rev, $verify ) {
+ global $wgShowRollbackEditCount;
+ if ( !is_int( $wgShowRollbackEditCount ) || !$wgShowRollbackEditCount > 0 ) {
+ // Nothing has happened, indicate this by returning 'null'
+ return null;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ // Up to the value of $wgShowRollbackEditCount revisions are counted
+ $res = $dbr->select(
+ 'revision',
+ [ 'rev_user_text', 'rev_deleted' ],
+ // $rev->getPage() returns null sometimes
+ [ 'rev_page' => $rev->getTitle()->getArticleID() ],
+ __METHOD__,
+ [
+ 'USE INDEX' => [ 'revision' => 'page_timestamp' ],
+ 'ORDER BY' => 'rev_timestamp DESC',
+ 'LIMIT' => $wgShowRollbackEditCount + 1
+ ]
+ );
+
+ $editCount = 0;
+ $moreRevs = false;
+ foreach ( $res as $row ) {
+ if ( $rev->getUserText( Revision::RAW ) != $row->rev_user_text ) {
+ if ( $verify &&
+ ( $row->rev_deleted & Revision::DELETED_TEXT
+ || $row->rev_deleted & Revision::DELETED_USER
+ ) ) {
+ // If the user or the text of the revision we might rollback
+ // to is deleted in some way we can't rollback. Similar to
+ // the sanity checks in WikiPage::commitRollback.
+ return false;
+ }
+ $moreRevs = true;
+ break;
+ }
+ $editCount++;
+ }
+
+ if ( $verify && $editCount <= $wgShowRollbackEditCount && !$moreRevs ) {
+ // We didn't find at least $wgShowRollbackEditCount revisions made by the current user
+ // and there weren't any other revisions. That means that the current user is the only
+ // editor, so we can't rollback
+ return false;
+ }
+ return $editCount;
+ }
+
+ /**
+ * Build a raw rollback link, useful for collections of "tool" links
+ *
+ * @since 1.16.3. $context added in 1.20. $editCount added in 1.21
+ * @param Revision $rev
+ * @param IContextSource|null $context Context to use or null for the main context.
+ * @param int $editCount Number of edits that would be reverted
+ * @return string HTML fragment
+ */
+ public static function buildRollbackLink( $rev, IContextSource $context = null,
+ $editCount = false
+ ) {
+ global $wgShowRollbackEditCount, $wgMiserMode;
+
+ // To config which pages are affected by miser mode
+ $disableRollbackEditCountSpecialPage = [ 'Recentchanges', 'Watchlist' ];
+
+ if ( $context === null ) {
+ $context = RequestContext::getMain();
+ }
+
+ $title = $rev->getTitle();
+ $query = [
+ 'action' => 'rollback',
+ 'from' => $rev->getUserText(),
+ 'token' => $context->getUser()->getEditToken( 'rollback' ),
+ ];
+ $attrs = [
+ 'data-mw' => 'interface',
+ 'title' => $context->msg( 'tooltip-rollback' )->text(),
+ ];
+ $options = [ 'known', 'noclasses' ];
+
+ if ( $context->getRequest()->getBool( 'bot' ) ) {
+ $query['bot'] = '1';
+ $query['hidediff'] = '1'; // T17999
+ }
+
+ $disableRollbackEditCount = false;
+ if ( $wgMiserMode ) {
+ foreach ( $disableRollbackEditCountSpecialPage as $specialPage ) {
+ if ( $context->getTitle()->isSpecial( $specialPage ) ) {
+ $disableRollbackEditCount = true;
+ break;
+ }
+ }
+ }
+
+ if ( !$disableRollbackEditCount
+ && is_int( $wgShowRollbackEditCount )
+ && $wgShowRollbackEditCount > 0
+ ) {
+ if ( !is_numeric( $editCount ) ) {
+ $editCount = self::getRollbackEditCount( $rev, false );
+ }
+
+ if ( $editCount > $wgShowRollbackEditCount ) {
+ $html = $context->msg( 'rollbacklinkcount-morethan' )
+ ->numParams( $wgShowRollbackEditCount )->parse();
+ } else {
+ $html = $context->msg( 'rollbacklinkcount' )->numParams( $editCount )->parse();
+ }
+
+ return self::link( $title, $html, $attrs, $query, $options );
+ } else {
+ $html = $context->msg( 'rollbacklink' )->escaped();
+ return self::link( $title, $html, $attrs, $query, $options );
+ }
+ }
+
+ /**
+ * @deprecated since 1.28, use TemplatesOnThisPageFormatter directly
+ *
+ * Returns HTML for the "templates used on this page" list.
+ *
+ * Make an HTML list of templates, and then add a "More..." link at
+ * the bottom. If $more is null, do not add a "More..." link. If $more
+ * is a Title, make a link to that title and use it. If $more is a string,
+ * directly paste it in as the link (escaping needs to be done manually).
+ * Finally, if $more is a Message, call toString().
+ *
+ * @since 1.16.3. $more added in 1.21
+ * @param Title[] $templates Array of templates
+ * @param bool $preview Whether this is for a preview
+ * @param bool $section Whether this is for a section edit
+ * @param Title|Message|string|null $more An escaped link for "More..." of the templates
+ * @return string HTML output
+ */
+ public static function formatTemplates( $templates, $preview = false,
+ $section = false, $more = null
+ ) {
+ wfDeprecated( __METHOD__, '1.28' );
+
+ $type = false;
+ if ( $preview ) {
+ $type = 'preview';
+ } elseif ( $section ) {
+ $type = 'section';
+ }
+
+ if ( $more instanceof Message ) {
+ $more = $more->toString();
+ }
+
+ $formatter = new TemplatesOnThisPageFormatter(
+ RequestContext::getMain(),
+ MediaWikiServices::getInstance()->getLinkRenderer()
+ );
+ return $formatter->format( $templates, $type, $more );
+ }
+
+ /**
+ * Returns HTML for the "hidden categories on this page" list.
+ *
+ * @since 1.16.3
+ * @param array $hiddencats Array of hidden categories from Article::getHiddenCategories
+ * or similar
+ * @return string HTML output
+ */
+ public static function formatHiddenCategories( $hiddencats ) {
+ $outText = '';
+ if ( count( $hiddencats ) > 0 ) {
+ # Construct the HTML
+ $outText = '<div class="mw-hiddenCategoriesExplanation">';
+ $outText .= wfMessage( 'hiddencategories' )->numParams( count( $hiddencats ) )->parseAsBlock();
+ $outText .= "</div><ul>\n";
+
+ foreach ( $hiddencats as $titleObj ) {
+ # If it's hidden, it must exist - no need to check with a LinkBatch
+ $outText .= '<li>'
+ . self::link( $titleObj, null, [], [], 'known' )
+ . "</li>\n";
+ }
+ $outText .= '</ul>';
+ }
+ return $outText;
+ }
+
+ /**
+ * @deprecated since 1.28, use Language::formatSize() directly
+ *
+ * Format a size in bytes for output, using an appropriate
+ * unit (B, KB, MB or GB) according to the magnitude in question
+ *
+ * @since 1.16.3
+ * @param int $size Size to format
+ * @return string
+ */
+ public static function formatSize( $size ) {
+ wfDeprecated( __METHOD__, '1.28' );
+
+ global $wgLang;
+ return htmlspecialchars( $wgLang->formatSize( $size ) );
+ }
+
+ /**
+ * Given the id of an interface element, constructs the appropriate title
+ * attribute from the system messages. (Note, this is usually the id but
+ * isn't always, because sometimes the accesskey needs to go on a different
+ * element than the id, for reverse-compatibility, etc.)
+ *
+ * @since 1.16.3 $msgParams added in 1.27
+ * @param string $name Id of the element, minus prefixes.
+ * @param string|null $options Null or the string 'withaccess' to add an access-
+ * key hint
+ * @param array $msgParams Parameters to pass to the message
+ *
+ * @return string Contents of the title attribute (which you must HTML-
+ * escape), or false for no title attribute
+ */
+ public static function titleAttrib( $name, $options = null, array $msgParams = [] ) {
+ $message = wfMessage( "tooltip-$name", $msgParams );
+ if ( !$message->exists() ) {
+ $tooltip = false;
+ } else {
+ $tooltip = $message->text();
+ # Compatibility: formerly some tooltips had [alt-.] hardcoded
+ $tooltip = preg_replace( "/ ?\[alt-.\]$/", '', $tooltip );
+ # Message equal to '-' means suppress it.
+ if ( $tooltip == '-' ) {
+ $tooltip = false;
+ }
+ }
+
+ if ( $options == 'withaccess' ) {
+ $accesskey = self::accesskey( $name );
+ if ( $accesskey !== false ) {
+ // Should be build the same as in jquery.accessKeyLabel.js
+ if ( $tooltip === false || $tooltip === '' ) {
+ $tooltip = wfMessage( 'brackets', $accesskey )->text();
+ } else {
+ $tooltip .= wfMessage( 'word-separator' )->text();
+ $tooltip .= wfMessage( 'brackets', $accesskey )->text();
+ }
+ }
+ }
+
+ return $tooltip;
+ }
+
+ public static $accesskeycache;
+
+ /**
+ * Given the id of an interface element, constructs the appropriate
+ * accesskey attribute from the system messages. (Note, this is usually
+ * the id but isn't always, because sometimes the accesskey needs to go on
+ * a different element than the id, for reverse-compatibility, etc.)
+ *
+ * @since 1.16.3
+ * @param string $name Id of the element, minus prefixes.
+ * @return string Contents of the accesskey attribute (which you must HTML-
+ * escape), or false for no accesskey attribute
+ */
+ public static function accesskey( $name ) {
+ if ( isset( self::$accesskeycache[$name] ) ) {
+ return self::$accesskeycache[$name];
+ }
+
+ $message = wfMessage( "accesskey-$name" );
+
+ if ( !$message->exists() ) {
+ $accesskey = false;
+ } else {
+ $accesskey = $message->plain();
+ if ( $accesskey === '' || $accesskey === '-' ) {
+ # @todo FIXME: Per standard MW behavior, a value of '-' means to suppress the
+ # attribute, but this is broken for accesskey: that might be a useful
+ # value.
+ $accesskey = false;
+ }
+ }
+
+ self::$accesskeycache[$name] = $accesskey;
+ return self::$accesskeycache[$name];
+ }
+
+ /**
+ * Get a revision-deletion link, or disabled link, or nothing, depending
+ * on user permissions & the settings on the revision.
+ *
+ * Will use forward-compatible revision ID in the Special:RevDelete link
+ * if possible, otherwise the timestamp-based ID which may break after
+ * undeletion.
+ *
+ * @param User $user
+ * @param Revision $rev
+ * @param Title $title
+ * @return string HTML fragment
+ */
+ public static function getRevDeleteLink( User $user, Revision $rev, Title $title ) {
+ $canHide = $user->isAllowed( 'deleterevision' );
+ if ( !$canHide && !( $rev->getVisibility() && $user->isAllowed( 'deletedhistory' ) ) ) {
+ return '';
+ }
+
+ if ( !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) {
+ return self::revDeleteLinkDisabled( $canHide ); // revision was hidden from sysops
+ } else {
+ if ( $rev->getId() ) {
+ // RevDelete links using revision ID are stable across
+ // page deletion and undeletion; use when possible.
+ $query = [
+ 'type' => 'revision',
+ 'target' => $title->getPrefixedDBkey(),
+ 'ids' => $rev->getId()
+ ];
+ } else {
+ // Older deleted entries didn't save a revision ID.
+ // We have to refer to these by timestamp, ick!
+ $query = [
+ 'type' => 'archive',
+ 'target' => $title->getPrefixedDBkey(),
+ 'ids' => $rev->getTimestamp()
+ ];
+ }
+ return self::revDeleteLink( $query,
+ $rev->isDeleted( Revision::DELETED_RESTRICTED ), $canHide );
+ }
+ }
+
+ /**
+ * Creates a (show/hide) link for deleting revisions/log entries
+ *
+ * @param array $query Query parameters to be passed to link()
+ * @param bool $restricted Set to true to use a "<strong>" instead of a "<span>"
+ * @param bool $delete Set to true to use (show/hide) rather than (show)
+ *
+ * @return string HTML "<a>" link to Special:Revisiondelete, wrapped in a
+ * span to allow for customization of appearance with CSS
+ */
+ public static function revDeleteLink( $query = [], $restricted = false, $delete = true ) {
+ $sp = SpecialPage::getTitleFor( 'Revisiondelete' );
+ $msgKey = $delete ? 'rev-delundel' : 'rev-showdeleted';
+ $html = wfMessage( $msgKey )->escaped();
+ $tag = $restricted ? 'strong' : 'span';
+ $link = self::link( $sp, $html, [], $query, [ 'known', 'noclasses' ] );
+ return Xml::tags(
+ $tag,
+ [ 'class' => 'mw-revdelundel-link' ],
+ wfMessage( 'parentheses' )->rawParams( $link )->escaped()
+ );
+ }
+
+ /**
+ * Creates a dead (show/hide) link for deleting revisions/log entries
+ *
+ * @since 1.16.3
+ * @param bool $delete Set to true to use (show/hide) rather than (show)
+ *
+ * @return string HTML text wrapped in a span to allow for customization
+ * of appearance with CSS
+ */
+ public static function revDeleteLinkDisabled( $delete = true ) {
+ $msgKey = $delete ? 'rev-delundel' : 'rev-showdeleted';
+ $html = wfMessage( $msgKey )->escaped();
+ $htmlParentheses = wfMessage( 'parentheses' )->rawParams( $html )->escaped();
+ return Xml::tags( 'span', [ 'class' => 'mw-revdelundel-link' ], $htmlParentheses );
+ }
+
+ /* Deprecated methods */
+
+ /**
+ * Returns the attributes for the tooltip and access key.
+ *
+ * @since 1.16.3. $msgParams introduced in 1.27
+ * @param string $name
+ * @param array $msgParams Params for constructing the message
+ *
+ * @return array
+ */
+ public static function tooltipAndAccesskeyAttribs( $name, array $msgParams = [] ) {
+ $attribs = [
+ 'title' => self::titleAttrib( $name, 'withaccess', $msgParams ),
+ 'accesskey' => self::accesskey( $name )
+ ];
+ if ( $attribs['title'] === false ) {
+ unset( $attribs['title'] );
+ }
+ if ( $attribs['accesskey'] === false ) {
+ unset( $attribs['accesskey'] );
+ }
+ return $attribs;
+ }
+
+ /**
+ * Returns raw bits of HTML, use titleAttrib()
+ * @since 1.16.3
+ * @param string $name
+ * @param array|null $options
+ * @return null|string
+ */
+ public static function tooltip( $name, $options = null ) {
+ $tooltip = self::titleAttrib( $name, $options );
+ if ( $tooltip === false ) {
+ return '';
+ }
+ return Xml::expandAttributes( [
+ 'title' => $tooltip
+ ] );
+ }
+
+}
diff --git a/www/wiki/includes/ListToggle.php b/www/wiki/includes/ListToggle.php
new file mode 100644
index 00000000..7a5fd9a1
--- /dev/null
+++ b/www/wiki/includes/ListToggle.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Class for generating clickable toggle links for a list of checkboxes.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class for generating clickable toggle links for a list of checkboxes.
+ *
+ * This is only supported on clients that have JavaScript enabled; it is hidden
+ * for clients that have it disabled.
+ *
+ * @since 1.27
+ */
+class ListToggle {
+ /** @var OutputPage */
+ private $output;
+
+ public function __construct( OutputPage $output ) {
+ $this->output = $output;
+
+ $output->addModules( 'mediawiki.checkboxtoggle' );
+ $output->addModuleStyles( 'mediawiki.checkboxtoggle.styles' );
+ }
+
+ private function checkboxLink( $checkboxType ) {
+ return Html::element(
+ // CSS classes: mw-checkbox-all, mw-checkbox-none, mw-checkbox-invert
+ 'a', [ 'class' => 'mw-checkbox-' . $checkboxType, 'role' => 'button', 'tabindex' => 0 ],
+ $this->output->msg( 'checkbox-' . $checkboxType )->text()
+ );
+ }
+
+ /**
+ * @return string
+ */
+ public function getHTML() {
+ // Select: All, None, Invert
+ $links = [
+ $this->checkboxLink( 'all' ),
+ $this->checkboxLink( 'none' ),
+ $this->checkboxLink( 'invert' ),
+ ];
+
+ return Html::rawElement( 'div',
+ [
+ 'class' => 'mw-checkbox-toggle-controls'
+ ],
+ $this->output->msg( 'checkbox-select' )
+ ->rawParams( $this->output->getLanguage()->commaList( $links ) )->escaped()
+ );
+ }
+}
diff --git a/www/wiki/includes/MWGrants.php b/www/wiki/includes/MWGrants.php
new file mode 100644
index 00000000..c7c54fd5
--- /dev/null
+++ b/www/wiki/includes/MWGrants.php
@@ -0,0 +1,216 @@
+<?php
+/**
+ * Functions and constants to deal with grants
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * A collection of public static functions to deal with grants.
+ */
+class MWGrants {
+
+ /**
+ * List all known grants.
+ * @return array
+ */
+ public static function getValidGrants() {
+ global $wgGrantPermissions;
+
+ return array_keys( $wgGrantPermissions );
+ }
+
+ /**
+ * Map all grants to corresponding user rights.
+ * @return array grant => array of rights
+ */
+ public static function getRightsByGrant() {
+ global $wgGrantPermissions;
+
+ $res = [];
+ foreach ( $wgGrantPermissions as $grant => $rights ) {
+ $res[$grant] = array_keys( array_filter( $rights ) );
+ }
+ return $res;
+ }
+
+ /**
+ * Fetch the display name of the grant
+ * @param string $grant
+ * @param Language|string|null $lang
+ * @return string Grant description
+ */
+ public static function grantName( $grant, $lang = null ) {
+ // Give grep a chance to find the usages:
+ // grant-blockusers, grant-createeditmovepage, grant-delete,
+ // grant-editinterface, grant-editmycssjs, grant-editmywatchlist,
+ // grant-editpage, grant-editprotected, grant-highvolume,
+ // grant-oversight, grant-patrol, grant-protect, grant-rollback,
+ // grant-sendemail, grant-uploadeditmovefile, grant-uploadfile,
+ // grant-basic, grant-viewdeleted, grant-viewmywatchlist,
+ // grant-createaccount
+ $msg = wfMessage( "grant-$grant" );
+ if ( $lang !== null ) {
+ if ( is_string( $lang ) ) {
+ $lang = Language::factory( $lang );
+ }
+ $msg->inLanguage( $lang );
+ }
+ if ( !$msg->exists() ) {
+ $msg = wfMessage( 'grant-generic', $grant );
+ if ( $lang ) {
+ $msg->inLanguage( $lang );
+ }
+ }
+ return $msg->text();
+ }
+
+ /**
+ * Fetch the display names for the grants.
+ * @param string[] $grants
+ * @param Language|string|null $lang
+ * @return string[] Corresponding grant descriptions
+ */
+ public static function grantNames( array $grants, $lang = null ) {
+ if ( $lang !== null ) {
+ if ( is_string( $lang ) ) {
+ $lang = Language::factory( $lang );
+ }
+ }
+
+ $ret = [];
+ foreach ( $grants as $grant ) {
+ $ret[] = self::grantName( $grant, $lang );
+ }
+ return $ret;
+ }
+
+ /**
+ * Fetch the rights allowed by a set of grants.
+ * @param string[]|string $grants
+ * @return string[]
+ */
+ public static function getGrantRights( $grants ) {
+ global $wgGrantPermissions;
+
+ $rights = [];
+ foreach ( (array)$grants as $grant ) {
+ if ( isset( $wgGrantPermissions[$grant] ) ) {
+ $rights = array_merge( $rights, array_keys( array_filter( $wgGrantPermissions[$grant] ) ) );
+ }
+ }
+ return array_unique( $rights );
+ }
+
+ /**
+ * Test that all grants in the list are known.
+ * @param string[] $grants
+ * @return bool
+ */
+ public static function grantsAreValid( array $grants ) {
+ return array_diff( $grants, self::getValidGrants() ) === [];
+ }
+
+ /**
+ * Divide the grants into groups.
+ * @param string[]|null $grantsFilter
+ * @return array Map of (group => (grant list))
+ */
+ public static function getGrantGroups( $grantsFilter = null ) {
+ global $wgGrantPermissions, $wgGrantPermissionGroups;
+
+ if ( is_array( $grantsFilter ) ) {
+ $grantsFilter = array_flip( $grantsFilter );
+ }
+
+ $groups = [];
+ foreach ( $wgGrantPermissions as $grant => $rights ) {
+ if ( $grantsFilter !== null && !isset( $grantsFilter[$grant] ) ) {
+ continue;
+ }
+ if ( isset( $wgGrantPermissionGroups[$grant] ) ) {
+ $groups[$wgGrantPermissionGroups[$grant]][] = $grant;
+ } else {
+ $groups['other'][] = $grant;
+ }
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Get the list of grants that are hidden and should always be granted
+ * @return string[]
+ */
+ public static function getHiddenGrants() {
+ global $wgGrantPermissionGroups;
+
+ $grants = [];
+ foreach ( $wgGrantPermissionGroups as $grant => $group ) {
+ if ( $group === 'hidden' ) {
+ $grants[] = $grant;
+ }
+ }
+ return $grants;
+ }
+
+ /**
+ * Generate a link to Special:ListGrants for a particular grant name.
+ *
+ * This should be used to link end users to a full description of what
+ * rights they are giving when they authorize a grant.
+ *
+ * @param string $grant the grant name
+ * @param Language|string|null $lang
+ * @return string (proto-relative) HTML link
+ */
+ public static function getGrantsLink( $grant, $lang = null ) {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ return $linkRenderer->makeKnownLink(
+ \SpecialPage::getTitleFor( 'Listgrants', false, $grant ),
+ self::grantName( $grant, $lang )
+ );
+ }
+
+ /**
+ * Generate wikitext to display a list of grants
+ * @param string[]|null $grantsFilter If non-null, only display these grants.
+ * @param Language|string|null $lang
+ * @return string Wikitext
+ */
+ public static function getGrantsWikiText( $grantsFilter, $lang = null ) {
+ global $wgContLang;
+
+ if ( is_string( $lang ) ) {
+ $lang = Language::factory( $lang );
+ } elseif ( $lang === null ) {
+ $lang = $wgContLang;
+ }
+
+ $s = '';
+ foreach ( self::getGrantGroups( $grantsFilter ) as $group => $grants ) {
+ if ( $group === 'hidden' ) {
+ continue; // implicitly granted
+ }
+ $s .= "*<span class=\"mw-grantgroup\">" .
+ wfMessage( "grant-group-$group" )->inLanguage( $lang )->text() . "</span>\n";
+ $s .= ":" . $lang->semicolonList( self::grantNames( $grants, $lang ) ) . "\n";
+ }
+ return "$s\n";
+ }
+
+}
diff --git a/www/wiki/includes/MWNamespace.php b/www/wiki/includes/MWNamespace.php
new file mode 100644
index 00000000..97dba26b
--- /dev/null
+++ b/www/wiki/includes/MWNamespace.php
@@ -0,0 +1,525 @@
+<?php
+/**
+ * Provide things related to namespaces.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This is a utility class with only static functions
+ * for dealing with namespaces that encodes all the
+ * "magic" behaviors of them based on index. The textual
+ * names of the namespaces are handled by Language.php.
+ *
+ * These are synonyms for the names given in the language file
+ * Users and translators should not change them
+ */
+class MWNamespace {
+
+ /**
+ * These namespaces should always be first-letter capitalized, now and
+ * forevermore. Historically, they could've probably been lowercased too,
+ * but some things are just too ingrained now. :)
+ */
+ private static $alwaysCapitalizedNamespaces = [ NS_SPECIAL, NS_USER, NS_MEDIAWIKI ];
+
+ /**
+ * Throw an exception when trying to get the subject or talk page
+ * for a given namespace where it does not make sense.
+ * Special namespaces are defined in includes/Defines.php and have
+ * a value below 0 (ex: NS_SPECIAL = -1 , NS_MEDIA = -2)
+ *
+ * @param int $index
+ * @param string $method
+ *
+ * @throws MWException
+ * @return bool
+ */
+ private static function isMethodValidFor( $index, $method ) {
+ if ( $index < NS_MAIN ) {
+ throw new MWException( "$method does not make any sense for given namespace $index" );
+ }
+ return true;
+ }
+
+ /**
+ * Can pages in the given namespace be moved?
+ *
+ * @param int $index Namespace index
+ * @return bool
+ */
+ public static function isMovable( $index ) {
+ global $wgAllowImageMoving;
+
+ $result = !( $index < NS_MAIN || ( $index == NS_FILE && !$wgAllowImageMoving ) );
+
+ /**
+ * @since 1.20
+ */
+ Hooks::run( 'NamespaceIsMovable', [ $index, &$result ] );
+
+ return $result;
+ }
+
+ /**
+ * Is the given namespace is a subject (non-talk) namespace?
+ *
+ * @param int $index Namespace index
+ * @return bool
+ * @since 1.19
+ */
+ public static function isSubject( $index ) {
+ return !self::isTalk( $index );
+ }
+
+ /**
+ * Is the given namespace a talk namespace?
+ *
+ * @param int $index Namespace index
+ * @return bool
+ */
+ public static function isTalk( $index ) {
+ return $index > NS_MAIN
+ && $index % 2;
+ }
+
+ /**
+ * Get the talk namespace index for a given namespace
+ *
+ * @param int $index Namespace index
+ * @return int
+ */
+ public static function getTalk( $index ) {
+ self::isMethodValidFor( $index, __METHOD__ );
+ return self::isTalk( $index )
+ ? $index
+ : $index + 1;
+ }
+
+ /**
+ * Get the subject namespace index for a given namespace
+ * Special namespaces (NS_MEDIA, NS_SPECIAL) are always the subject.
+ *
+ * @param int $index Namespace index
+ * @return int
+ */
+ public static function getSubject( $index ) {
+ # Handle special namespaces
+ if ( $index < NS_MAIN ) {
+ return $index;
+ }
+
+ return self::isTalk( $index )
+ ? $index - 1
+ : $index;
+ }
+
+ /**
+ * Get the associated namespace.
+ * For talk namespaces, returns the subject (non-talk) namespace
+ * For subject (non-talk) namespaces, returns the talk namespace
+ *
+ * @param int $index Namespace index
+ * @return int|null If no associated namespace could be found
+ */
+ public static function getAssociated( $index ) {
+ self::isMethodValidFor( $index, __METHOD__ );
+
+ if ( self::isSubject( $index ) ) {
+ return self::getTalk( $index );
+ } elseif ( self::isTalk( $index ) ) {
+ return self::getSubject( $index );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns whether the specified namespace exists
+ *
+ * @param int $index
+ *
+ * @return bool
+ * @since 1.19
+ */
+ public static function exists( $index ) {
+ $nslist = self::getCanonicalNamespaces();
+ return isset( $nslist[$index] );
+ }
+
+ /**
+ * Returns whether the specified namespaces are the same namespace
+ *
+ * @note It's possible that in the future we may start using something
+ * other than just namespace indexes. Under that circumstance making use
+ * of this function rather than directly doing comparison will make
+ * sure that code will not potentially break.
+ *
+ * @param int $ns1 The first namespace index
+ * @param int $ns2 The second namespace index
+ *
+ * @return bool
+ * @since 1.19
+ */
+ public static function equals( $ns1, $ns2 ) {
+ return $ns1 == $ns2;
+ }
+
+ /**
+ * Returns whether the specified namespaces share the same subject.
+ * eg: NS_USER and NS_USER wil return true, as well
+ * NS_USER and NS_USER_TALK will return true.
+ *
+ * @param int $ns1 The first namespace index
+ * @param int $ns2 The second namespace index
+ *
+ * @return bool
+ * @since 1.19
+ */
+ public static function subjectEquals( $ns1, $ns2 ) {
+ return self::getSubject( $ns1 ) == self::getSubject( $ns2 );
+ }
+
+ /**
+ * Returns array of all defined namespaces with their canonical
+ * (English) names.
+ *
+ * @param bool $rebuild Rebuild namespace list (default = false). Used for testing.
+ *
+ * @return array
+ * @since 1.17
+ */
+ public static function getCanonicalNamespaces( $rebuild = false ) {
+ static $namespaces = null;
+ if ( $namespaces === null || $rebuild ) {
+ global $wgExtraNamespaces, $wgCanonicalNamespaceNames;
+ $namespaces = [ NS_MAIN => '' ] + $wgCanonicalNamespaceNames;
+ // Add extension namespaces
+ $namespaces += ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' );
+ if ( is_array( $wgExtraNamespaces ) ) {
+ $namespaces += $wgExtraNamespaces;
+ }
+ Hooks::run( 'CanonicalNamespaces', [ &$namespaces ] );
+ }
+ return $namespaces;
+ }
+
+ /**
+ * Returns the canonical (English) name for a given index
+ *
+ * @param int $index Namespace index
+ * @return string|bool If no canonical definition.
+ */
+ public static function getCanonicalName( $index ) {
+ $nslist = self::getCanonicalNamespaces();
+ if ( isset( $nslist[$index] ) ) {
+ return $nslist[$index];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Returns the index for a given canonical name, or NULL
+ * The input *must* be converted to lower case first
+ *
+ * @param string $name Namespace name
+ * @return int
+ */
+ public static function getCanonicalIndex( $name ) {
+ static $xNamespaces = false;
+ if ( $xNamespaces === false ) {
+ $xNamespaces = [];
+ foreach ( self::getCanonicalNamespaces() as $i => $text ) {
+ $xNamespaces[strtolower( $text )] = $i;
+ }
+ }
+ if ( array_key_exists( $name, $xNamespaces ) ) {
+ return $xNamespaces[$name];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns an array of the namespaces (by integer id) that exist on the
+ * wiki. Used primarily by the api in help documentation.
+ * @return array
+ */
+ public static function getValidNamespaces() {
+ static $mValidNamespaces = null;
+
+ if ( is_null( $mValidNamespaces ) ) {
+ foreach ( array_keys( self::getCanonicalNamespaces() ) as $ns ) {
+ if ( $ns >= 0 ) {
+ $mValidNamespaces[] = $ns;
+ }
+ }
+ // T109137: sort numerically
+ sort( $mValidNamespaces, SORT_NUMERIC );
+ }
+
+ return $mValidNamespaces;
+ }
+
+ /**
+ * Does this namespace ever have a talk namespace?
+ *
+ * @deprecated since 1.30, use hasTalkNamespace() instead.
+ *
+ * @param int $index Namespace index
+ * @return bool True if this namespace either is or has a corresponding talk namespace.
+ */
+ public static function canTalk( $index ) {
+ return self::hasTalkNamespace( $index );
+ }
+
+ /**
+ * Does this namespace ever have a talk namespace?
+ *
+ * @since 1.30
+ *
+ * @param int $index Namespace ID
+ * @return bool True if this namespace either is or has a corresponding talk namespace.
+ */
+ public static function hasTalkNamespace( $index ) {
+ return $index >= NS_MAIN;
+ }
+
+ /**
+ * Does this namespace contain content, for the purposes of calculating
+ * statistics, etc?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public static function isContent( $index ) {
+ global $wgContentNamespaces;
+ return $index == NS_MAIN || in_array( $index, $wgContentNamespaces );
+ }
+
+ /**
+ * Might pages in this namespace require the use of the Signature button on
+ * the edit toolbar?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public static function wantSignatures( $index ) {
+ global $wgExtraSignatureNamespaces;
+ return self::isTalk( $index ) || in_array( $index, $wgExtraSignatureNamespaces );
+ }
+
+ /**
+ * Can pages in a namespace be watched?
+ *
+ * @param int $index
+ * @return bool
+ */
+ public static function isWatchable( $index ) {
+ return $index >= NS_MAIN;
+ }
+
+ /**
+ * Does the namespace allow subpages?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public static function hasSubpages( $index ) {
+ global $wgNamespacesWithSubpages;
+ return !empty( $wgNamespacesWithSubpages[$index] );
+ }
+
+ /**
+ * Get a list of all namespace indices which are considered to contain content
+ * @return array Array of namespace indices
+ */
+ public static function getContentNamespaces() {
+ global $wgContentNamespaces;
+ if ( !is_array( $wgContentNamespaces ) || $wgContentNamespaces === [] ) {
+ return [ NS_MAIN ];
+ } elseif ( !in_array( NS_MAIN, $wgContentNamespaces ) ) {
+ // always force NS_MAIN to be part of array (to match the algorithm used by isContent)
+ return array_merge( [ NS_MAIN ], $wgContentNamespaces );
+ } else {
+ return $wgContentNamespaces;
+ }
+ }
+
+ /**
+ * List all namespace indices which are considered subject, aka not a talk
+ * or special namespace. See also MWNamespace::isSubject
+ *
+ * @return array Array of namespace indices
+ */
+ public static function getSubjectNamespaces() {
+ return array_filter(
+ self::getValidNamespaces(),
+ 'MWNamespace::isSubject'
+ );
+ }
+
+ /**
+ * List all namespace indices which are considered talks, aka not a subject
+ * or special namespace. See also MWNamespace::isTalk
+ *
+ * @return array Array of namespace indices
+ */
+ public static function getTalkNamespaces() {
+ return array_filter(
+ self::getValidNamespaces(),
+ 'MWNamespace::isTalk'
+ );
+ }
+
+ /**
+ * Is the namespace first-letter capitalized?
+ *
+ * @param int $index Index to check
+ * @return bool
+ */
+ public static function isCapitalized( $index ) {
+ global $wgCapitalLinks, $wgCapitalLinkOverrides;
+ // Turn NS_MEDIA into NS_FILE
+ $index = $index === NS_MEDIA ? NS_FILE : $index;
+
+ // Make sure to get the subject of our namespace
+ $index = self::getSubject( $index );
+
+ // Some namespaces are special and should always be upper case
+ if ( in_array( $index, self::$alwaysCapitalizedNamespaces ) ) {
+ return true;
+ }
+ if ( isset( $wgCapitalLinkOverrides[$index] ) ) {
+ // $wgCapitalLinkOverrides is explicitly set
+ return $wgCapitalLinkOverrides[$index];
+ }
+ // Default to the global setting
+ return $wgCapitalLinks;
+ }
+
+ /**
+ * Does the namespace (potentially) have different aliases for different
+ * genders. Not all languages make a distinction here.
+ *
+ * @since 1.18
+ * @param int $index Index to check
+ * @return bool
+ */
+ public static function hasGenderDistinction( $index ) {
+ return $index == NS_USER || $index == NS_USER_TALK;
+ }
+
+ /**
+ * It is not possible to use pages from this namespace as template?
+ *
+ * @since 1.20
+ * @param int $index Index to check
+ * @return bool
+ */
+ public static function isNonincludable( $index ) {
+ global $wgNonincludableNamespaces;
+ return $wgNonincludableNamespaces && in_array( $index, $wgNonincludableNamespaces );
+ }
+
+ /**
+ * Get the default content model for a namespace
+ * This does not mean that all pages in that namespace have the model
+ *
+ * @since 1.21
+ * @param int $index Index to check
+ * @return null|string Default model name for the given namespace, if set
+ */
+ public static function getNamespaceContentModel( $index ) {
+ global $wgNamespaceContentModels;
+ return isset( $wgNamespaceContentModels[$index] )
+ ? $wgNamespaceContentModels[$index]
+ : null;
+ }
+
+ /**
+ * Determine which restriction levels it makes sense to use in a namespace,
+ * optionally filtered by a user's rights.
+ *
+ * @since 1.23
+ * @param int $index Index to check
+ * @param User $user User to check
+ * @return array
+ */
+ public static function getRestrictionLevels( $index, User $user = null ) {
+ global $wgNamespaceProtection, $wgRestrictionLevels;
+
+ if ( !isset( $wgNamespaceProtection[$index] ) ) {
+ // All levels are valid if there's no namespace restriction.
+ // But still filter by user, if necessary
+ $levels = $wgRestrictionLevels;
+ if ( $user ) {
+ $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
+ $right = $level;
+ if ( $right == 'sysop' ) {
+ $right = 'editprotected'; // BC
+ }
+ if ( $right == 'autoconfirmed' ) {
+ $right = 'editsemiprotected'; // BC
+ }
+ return ( $right == '' || $user->isAllowed( $right ) );
+ } ) );
+ }
+ return $levels;
+ }
+
+ // First, get the list of groups that can edit this namespace.
+ $namespaceGroups = [];
+ $combine = 'array_merge';
+ foreach ( (array)$wgNamespaceProtection[$index] as $right ) {
+ if ( $right == 'sysop' ) {
+ $right = 'editprotected'; // BC
+ }
+ if ( $right == 'autoconfirmed' ) {
+ $right = 'editsemiprotected'; // BC
+ }
+ if ( $right != '' ) {
+ $namespaceGroups = call_user_func( $combine, $namespaceGroups,
+ User::getGroupsWithPermission( $right ) );
+ $combine = 'array_intersect';
+ }
+ }
+
+ // Now, keep only those restriction levels where there is at least one
+ // group that can edit the namespace but would be blocked by the
+ // restriction.
+ $usableLevels = [ '' ];
+ foreach ( $wgRestrictionLevels as $level ) {
+ $right = $level;
+ if ( $right == 'sysop' ) {
+ $right = 'editprotected'; // BC
+ }
+ if ( $right == 'autoconfirmed' ) {
+ $right = 'editsemiprotected'; // BC
+ }
+ if ( $right != '' && ( !$user || $user->isAllowed( $right ) ) &&
+ array_diff( $namespaceGroups, User::getGroupsWithPermission( $right ) )
+ ) {
+ $usableLevels[] = $level;
+ }
+ }
+
+ return $usableLevels;
+ }
+}
diff --git a/www/wiki/includes/MWTimestamp.php b/www/wiki/includes/MWTimestamp.php
new file mode 100644
index 00000000..7f3649e3
--- /dev/null
+++ b/www/wiki/includes/MWTimestamp.php
@@ -0,0 +1,210 @@
+<?php
+/**
+ * Creation and parsing of MW-style timestamps.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.20
+ * @author Tyler Romeo, 2012
+ */
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+
+/**
+ * Library for creating and parsing MW-style timestamps. Based on the JS
+ * library that does the same thing.
+ *
+ * @since 1.20
+ */
+class MWTimestamp extends ConvertibleTimestamp {
+ /**
+ * Get a timestamp instance in GMT
+ *
+ * @param bool|string $ts Timestamp to set, or false for current time
+ * @return MWTimestamp The instance
+ */
+ public static function getInstance( $ts = false ) {
+ return new static( $ts );
+ }
+
+ /**
+ * Get the timestamp in a human-friendly relative format, e.g., "3 days ago".
+ *
+ * Determine the difference between the timestamp and the current time, and
+ * generate a readable timestamp by returning "<N> <units> ago", where the
+ * largest possible unit is used.
+ *
+ * @since 1.20
+ * @since 1.22 Uses Language::getHumanTimestamp to produce the timestamp
+ * @deprecated since 1.26 Use Language::getHumanTimestamp directly
+ *
+ * @param MWTimestamp|null $relativeTo The base timestamp to compare to (defaults to now)
+ * @param User|null $user User the timestamp is being generated for
+ * (or null to use main context's user)
+ * @param Language|null $lang Language to use to make the human timestamp
+ * (or null to use main context's language)
+ * @return string Formatted timestamp
+ */
+ public function getHumanTimestamp(
+ MWTimestamp $relativeTo = null, User $user = null, Language $lang = null
+ ) {
+ if ( $lang === null ) {
+ $lang = RequestContext::getMain()->getLanguage();
+ }
+
+ return $lang->getHumanTimestamp( $this, $relativeTo, $user );
+ }
+
+ /**
+ * Adjust the timestamp depending on the given user's preferences.
+ *
+ * @since 1.22
+ *
+ * @param User $user User to take preferences from
+ * @return DateInterval Offset that was applied to the timestamp
+ */
+ public function offsetForUser( User $user ) {
+ global $wgLocalTZoffset;
+
+ $option = $user->getOption( 'timecorrection' );
+ $data = explode( '|', $option, 3 );
+
+ // First handle the case of an actual timezone being specified.
+ if ( $data[0] == 'ZoneInfo' ) {
+ try {
+ $tz = new DateTimeZone( $data[2] );
+ } catch ( Exception $e ) {
+ $tz = false;
+ }
+
+ if ( $tz ) {
+ $this->timestamp->setTimezone( $tz );
+ return new DateInterval( 'P0Y' );
+ } else {
+ $data[0] = 'Offset';
+ }
+ }
+
+ $diff = 0;
+ // If $option is in fact a pipe-separated value, check the
+ // first value.
+ if ( $data[0] == 'System' ) {
+ // First value is System, so use the system offset.
+ if ( $wgLocalTZoffset !== null ) {
+ $diff = $wgLocalTZoffset;
+ }
+ } elseif ( $data[0] == 'Offset' ) {
+ // First value is Offset, so use the specified offset
+ $diff = (int)$data[1];
+ } else {
+ // $option actually isn't a pipe separated value, but instead
+ // a comma separated value. Isn't MediaWiki fun?
+ $data = explode( ':', $option );
+ if ( count( $data ) >= 2 ) {
+ // Combination hours and minutes.
+ $diff = abs( (int)$data[0] ) * 60 + (int)$data[1];
+ if ( (int)$data[0] < 0 ) {
+ $diff *= -1;
+ }
+ } else {
+ // Just hours.
+ $diff = (int)$data[0] * 60;
+ }
+ }
+
+ $interval = new DateInterval( 'PT' . abs( $diff ) . 'M' );
+ if ( $diff < 1 ) {
+ $interval->invert = 1;
+ }
+
+ $this->timestamp->add( $interval );
+ return $interval;
+ }
+
+ /**
+ * Generate a purely relative timestamp, i.e., represent the time elapsed between
+ * the given base timestamp and this object.
+ *
+ * @param MWTimestamp $relativeTo Relative base timestamp (defaults to now)
+ * @param User $user Use to use offset for
+ * @param Language $lang Language to use
+ * @param array $chosenIntervals Intervals to use to represent it
+ * @return string Relative timestamp
+ */
+ public function getRelativeTimestamp(
+ MWTimestamp $relativeTo = null,
+ User $user = null,
+ Language $lang = null,
+ array $chosenIntervals = []
+ ) {
+ if ( $relativeTo === null ) {
+ $relativeTo = new self;
+ }
+ if ( $user === null ) {
+ $user = RequestContext::getMain()->getUser();
+ }
+ if ( $lang === null ) {
+ $lang = RequestContext::getMain()->getLanguage();
+ }
+
+ $ts = '';
+ $diff = $this->diff( $relativeTo );
+ if ( Hooks::run(
+ 'GetRelativeTimestamp',
+ [ &$ts, &$diff, $this, $relativeTo, $user, $lang ]
+ ) ) {
+ $seconds = ( ( ( $diff->days * 24 + $diff->h ) * 60 + $diff->i ) * 60 + $diff->s );
+ $ts = wfMessage( 'ago', $lang->formatDuration( $seconds, $chosenIntervals ) )
+ ->inLanguage( $lang )->text();
+ }
+
+ return $ts;
+ }
+
+ /**
+ * Get the localized timezone message, if available.
+ *
+ * Premade translations are not shipped as format() may return whatever the
+ * system uses, localized or not, so translation must be done through wiki.
+ *
+ * @since 1.27
+ * @return Message The localized timezone message
+ */
+ public function getTimezoneMessage() {
+ $tzMsg = $this->format( 'T' ); // might vary on DST changeover!
+ $key = 'timezone-' . strtolower( trim( $tzMsg ) );
+ $msg = wfMessage( $key );
+ if ( $msg->exists() ) {
+ return $msg;
+ } else {
+ return new RawMessage( $tzMsg );
+ }
+ }
+
+ /**
+ * Get a timestamp instance in the server local timezone ($wgLocaltimezone)
+ *
+ * @since 1.22
+ * @param bool|string $ts Timestamp to set, or false for current time
+ * @return MWTimestamp The local instance
+ */
+ public static function getLocalInstance( $ts = false ) {
+ global $wgLocaltimezone;
+ $timestamp = new self( $ts );
+ $timestamp->setTimezone( $wgLocaltimezone );
+ return $timestamp;
+ }
+}
diff --git a/www/wiki/includes/MagicWord.php b/www/wiki/includes/MagicWord.php
new file mode 100644
index 00000000..6e7799a3
--- /dev/null
+++ b/www/wiki/includes/MagicWord.php
@@ -0,0 +1,676 @@
+<?php
+/**
+ * See docs/magicword.txt.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * This class encapsulates "magic words" such as "#redirect", __NOTOC__, etc.
+ *
+ * @par Usage:
+ * @code
+ * if (MagicWord::get( 'redirect' )->match( $text ) ) {
+ * // some code
+ * }
+ * @endcode
+ *
+ * Possible future improvements:
+ * * Simultaneous searching for a number of magic words
+ * * MagicWord::$mObjects in shared memory
+ *
+ * Please avoid reading the data out of one of these objects and then writing
+ * special case code. If possible, add another match()-like function here.
+ *
+ * To add magic words in an extension, use $magicWords in a file listed in
+ * $wgExtensionMessagesFiles[].
+ *
+ * @par Example:
+ * @code
+ * $magicWords = [];
+ *
+ * $magicWords['en'] = [
+ * 'magicwordkey' => [ 0, 'case_insensitive_magic_word' ],
+ * 'magicwordkey2' => [ 1, 'CASE_sensitive_magic_word2' ],
+ * ];
+ * @endcode
+ *
+ * For magic words which are also Parser variables, add a MagicWordwgVariableIDs
+ * hook. Use string keys.
+ *
+ * @ingroup Parser
+ */
+class MagicWord {
+ /**#@-*/
+
+ /** @var int */
+ public $mId;
+
+ /** @var array */
+ public $mSynonyms;
+
+ /** @var bool */
+ public $mCaseSensitive;
+
+ /** @var string */
+ private $mRegex = '';
+
+ /** @var string */
+ private $mRegexStart = '';
+
+ /** @var string */
+ private $mRegexStartToEnd = '';
+
+ /** @var string */
+ private $mBaseRegex = '';
+
+ /** @var string */
+ private $mVariableRegex = '';
+
+ /** @var string */
+ private $mVariableStartToEndRegex = '';
+
+ /** @var bool */
+ private $mModified = false;
+
+ /** @var bool */
+ private $mFound = false;
+
+ public static $mVariableIDsInitialised = false;
+ public static $mVariableIDs = [
+ '!',
+ 'currentmonth',
+ 'currentmonth1',
+ 'currentmonthname',
+ 'currentmonthnamegen',
+ 'currentmonthabbrev',
+ 'currentday',
+ 'currentday2',
+ 'currentdayname',
+ 'currentyear',
+ 'currenttime',
+ 'currenthour',
+ 'localmonth',
+ 'localmonth1',
+ 'localmonthname',
+ 'localmonthnamegen',
+ 'localmonthabbrev',
+ 'localday',
+ 'localday2',
+ 'localdayname',
+ 'localyear',
+ 'localtime',
+ 'localhour',
+ 'numberofarticles',
+ 'numberoffiles',
+ 'numberofedits',
+ 'articlepath',
+ 'pageid',
+ 'sitename',
+ 'server',
+ 'servername',
+ 'scriptpath',
+ 'stylepath',
+ 'pagename',
+ 'pagenamee',
+ 'fullpagename',
+ 'fullpagenamee',
+ 'namespace',
+ 'namespacee',
+ 'namespacenumber',
+ 'currentweek',
+ 'currentdow',
+ 'localweek',
+ 'localdow',
+ 'revisionid',
+ 'revisionday',
+ 'revisionday2',
+ 'revisionmonth',
+ 'revisionmonth1',
+ 'revisionyear',
+ 'revisiontimestamp',
+ 'revisionuser',
+ 'revisionsize',
+ 'subpagename',
+ 'subpagenamee',
+ 'talkspace',
+ 'talkspacee',
+ 'subjectspace',
+ 'subjectspacee',
+ 'talkpagename',
+ 'talkpagenamee',
+ 'subjectpagename',
+ 'subjectpagenamee',
+ 'numberofusers',
+ 'numberofactiveusers',
+ 'numberofpages',
+ 'currentversion',
+ 'rootpagename',
+ 'rootpagenamee',
+ 'basepagename',
+ 'basepagenamee',
+ 'currenttimestamp',
+ 'localtimestamp',
+ 'directionmark',
+ 'contentlanguage',
+ 'pagelanguage',
+ 'numberofadmins',
+ 'cascadingsources',
+ ];
+
+ /* Array of caching hints for ParserCache */
+ public static $mCacheTTLs = [
+ 'currentmonth' => 86400,
+ 'currentmonth1' => 86400,
+ 'currentmonthname' => 86400,
+ 'currentmonthnamegen' => 86400,
+ 'currentmonthabbrev' => 86400,
+ 'currentday' => 3600,
+ 'currentday2' => 3600,
+ 'currentdayname' => 3600,
+ 'currentyear' => 86400,
+ 'currenttime' => 3600,
+ 'currenthour' => 3600,
+ 'localmonth' => 86400,
+ 'localmonth1' => 86400,
+ 'localmonthname' => 86400,
+ 'localmonthnamegen' => 86400,
+ 'localmonthabbrev' => 86400,
+ 'localday' => 3600,
+ 'localday2' => 3600,
+ 'localdayname' => 3600,
+ 'localyear' => 86400,
+ 'localtime' => 3600,
+ 'localhour' => 3600,
+ 'numberofarticles' => 3600,
+ 'numberoffiles' => 3600,
+ 'numberofedits' => 3600,
+ 'currentweek' => 3600,
+ 'currentdow' => 3600,
+ 'localweek' => 3600,
+ 'localdow' => 3600,
+ 'numberofusers' => 3600,
+ 'numberofactiveusers' => 3600,
+ 'numberofpages' => 3600,
+ 'currentversion' => 86400,
+ 'currenttimestamp' => 3600,
+ 'localtimestamp' => 3600,
+ 'pagesinnamespace' => 3600,
+ 'numberofadmins' => 3600,
+ 'numberingroup' => 3600,
+ ];
+
+ public static $mDoubleUnderscoreIDs = [
+ 'notoc',
+ 'nogallery',
+ 'forcetoc',
+ 'toc',
+ 'noeditsection',
+ 'newsectionlink',
+ 'nonewsectionlink',
+ 'hiddencat',
+ 'index',
+ 'noindex',
+ 'staticredirect',
+ 'notitleconvert',
+ 'nocontentconvert',
+ ];
+
+ public static $mSubstIDs = [
+ 'subst',
+ 'safesubst',
+ ];
+
+ public static $mObjects = [];
+ public static $mDoubleUnderscoreArray = null;
+
+ /**#@-*/
+
+ public function __construct( $id = 0, $syn = [], $cs = false ) {
+ $this->mId = $id;
+ $this->mSynonyms = (array)$syn;
+ $this->mCaseSensitive = $cs;
+ }
+
+ /**
+ * Factory: creates an object representing an ID
+ *
+ * @param int $id
+ *
+ * @return MagicWord
+ */
+ public static function &get( $id ) {
+ if ( !isset( self::$mObjects[$id] ) ) {
+ $mw = new MagicWord();
+ $mw->load( $id );
+ self::$mObjects[$id] = $mw;
+ }
+ return self::$mObjects[$id];
+ }
+
+ /**
+ * Get an array of parser variable IDs
+ *
+ * @return array
+ */
+ public static function getVariableIDs() {
+ if ( !self::$mVariableIDsInitialised ) {
+ # Get variable IDs
+ Hooks::run( 'MagicWordwgVariableIDs', [ &self::$mVariableIDs ] );
+ self::$mVariableIDsInitialised = true;
+ }
+ return self::$mVariableIDs;
+ }
+
+ /**
+ * Get an array of parser substitution modifier IDs
+ * @return array
+ */
+ public static function getSubstIDs() {
+ return self::$mSubstIDs;
+ }
+
+ /**
+ * Allow external reads of TTL array
+ *
+ * @param int $id
+ * @return int
+ */
+ public static function getCacheTTL( $id ) {
+ if ( array_key_exists( $id, self::$mCacheTTLs ) ) {
+ return self::$mCacheTTLs[$id];
+ } else {
+ return -1;
+ }
+ }
+
+ /**
+ * Get a MagicWordArray of double-underscore entities
+ *
+ * @return MagicWordArray
+ */
+ public static function getDoubleUnderscoreArray() {
+ if ( is_null( self::$mDoubleUnderscoreArray ) ) {
+ Hooks::run( 'GetDoubleUnderscoreIDs', [ &self::$mDoubleUnderscoreIDs ] );
+ self::$mDoubleUnderscoreArray = new MagicWordArray( self::$mDoubleUnderscoreIDs );
+ }
+ return self::$mDoubleUnderscoreArray;
+ }
+
+ /**
+ * Clear the self::$mObjects variable
+ * For use in parser tests
+ */
+ public static function clearCache() {
+ self::$mObjects = [];
+ }
+
+ /**
+ * Initialises this object with an ID
+ *
+ * @param int $id
+ * @throws MWException
+ */
+ public function load( $id ) {
+ global $wgContLang;
+ $this->mId = $id;
+ $wgContLang->getMagic( $this );
+ if ( !$this->mSynonyms ) {
+ $this->mSynonyms = [ 'brionmademeputthishere' ];
+ throw new MWException( "Error: invalid magic word '$id'" );
+ }
+ }
+
+ /**
+ * Preliminary initialisation
+ * @private
+ */
+ public function initRegex() {
+ // Sort the synonyms by length, descending, so that the longest synonym
+ // matches in precedence to the shortest
+ $synonyms = $this->mSynonyms;
+ usort( $synonyms, [ $this, 'compareStringLength' ] );
+
+ $escSyn = [];
+ foreach ( $synonyms as $synonym ) {
+ // In case a magic word contains /, like that's going to happen;)
+ $escSyn[] = preg_quote( $synonym, '/' );
+ }
+ $this->mBaseRegex = implode( '|', $escSyn );
+
+ $case = $this->mCaseSensitive ? '' : 'iu';
+ $this->mRegex = "/{$this->mBaseRegex}/{$case}";
+ $this->mRegexStart = "/^(?:{$this->mBaseRegex})/{$case}";
+ $this->mRegexStartToEnd = "/^(?:{$this->mBaseRegex})$/{$case}";
+ $this->mVariableRegex = str_replace( "\\$1", "(.*?)", $this->mRegex );
+ $this->mVariableStartToEndRegex = str_replace( "\\$1", "(.*?)",
+ "/^(?:{$this->mBaseRegex})$/{$case}" );
+ }
+
+ /**
+ * A comparison function that returns -1, 0 or 1 depending on whether the
+ * first string is longer, the same length or shorter than the second
+ * string.
+ *
+ * @param string $s1
+ * @param string $s2
+ *
+ * @return int
+ */
+ public function compareStringLength( $s1, $s2 ) {
+ $l1 = strlen( $s1 );
+ $l2 = strlen( $s2 );
+ if ( $l1 < $l2 ) {
+ return 1;
+ } elseif ( $l1 > $l2 ) {
+ return -1;
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * Gets a regex representing matching the word
+ *
+ * @return string
+ */
+ public function getRegex() {
+ if ( $this->mRegex == '' ) {
+ $this->initRegex();
+ }
+ return $this->mRegex;
+ }
+
+ /**
+ * Gets the regexp case modifier to use, i.e. i or nothing, to be used if
+ * one is using MagicWord::getBaseRegex(), otherwise it'll be included in
+ * the complete expression
+ *
+ * @return string
+ */
+ public function getRegexCase() {
+ if ( $this->mRegex === '' ) {
+ $this->initRegex();
+ }
+
+ return $this->mCaseSensitive ? '' : 'iu';
+ }
+
+ /**
+ * Gets a regex matching the word, if it is at the string start
+ *
+ * @return string
+ */
+ public function getRegexStart() {
+ if ( $this->mRegex == '' ) {
+ $this->initRegex();
+ }
+ return $this->mRegexStart;
+ }
+
+ /**
+ * Gets a regex matching the word from start to end of a string
+ *
+ * @return string
+ * @since 1.23
+ */
+ public function getRegexStartToEnd() {
+ if ( $this->mRegexStartToEnd == '' ) {
+ $this->initRegex();
+ }
+ return $this->mRegexStartToEnd;
+ }
+
+ /**
+ * regex without the slashes and what not
+ *
+ * @return string
+ */
+ public function getBaseRegex() {
+ if ( $this->mRegex == '' ) {
+ $this->initRegex();
+ }
+ return $this->mBaseRegex;
+ }
+
+ /**
+ * Returns true if the text contains the word
+ *
+ * @param string $text
+ *
+ * @return bool
+ */
+ public function match( $text ) {
+ return (bool)preg_match( $this->getRegex(), $text );
+ }
+
+ /**
+ * Returns true if the text starts with the word
+ *
+ * @param string $text
+ *
+ * @return bool
+ */
+ public function matchStart( $text ) {
+ return (bool)preg_match( $this->getRegexStart(), $text );
+ }
+
+ /**
+ * Returns true if the text matched the word
+ *
+ * @param string $text
+ *
+ * @return bool
+ * @since 1.23
+ */
+ public function matchStartToEnd( $text ) {
+ return (bool)preg_match( $this->getRegexStartToEnd(), $text );
+ }
+
+ /**
+ * Returns NULL if there's no match, the value of $1 otherwise
+ * The return code is the matched string, if there's no variable
+ * part in the regex and the matched variable part ($1) if there
+ * is one.
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ public function matchVariableStartToEnd( $text ) {
+ $matches = [];
+ $matchcount = preg_match( $this->getVariableStartToEndRegex(), $text, $matches );
+ if ( $matchcount == 0 ) {
+ return null;
+ } else {
+ # multiple matched parts (variable match); some will be empty because of
+ # synonyms. The variable will be the second non-empty one so remove any
+ # blank elements and re-sort the indices.
+ # See also T8526
+
+ $matches = array_values( array_filter( $matches ) );
+
+ if ( count( $matches ) == 1 ) {
+ return $matches[0];
+ } else {
+ return $matches[1];
+ }
+ }
+ }
+
+ /**
+ * Returns true if the text matches the word, and alters the
+ * input string, removing all instances of the word
+ *
+ * @param string &$text
+ *
+ * @return bool
+ */
+ public function matchAndRemove( &$text ) {
+ $this->mFound = false;
+ $text = preg_replace_callback(
+ $this->getRegex(),
+ [ $this, 'pregRemoveAndRecord' ],
+ $text
+ );
+
+ return $this->mFound;
+ }
+
+ /**
+ * @param string &$text
+ * @return bool
+ */
+ public function matchStartAndRemove( &$text ) {
+ $this->mFound = false;
+ $text = preg_replace_callback(
+ $this->getRegexStart(),
+ [ $this, 'pregRemoveAndRecord' ],
+ $text
+ );
+
+ return $this->mFound;
+ }
+
+ /**
+ * Used in matchAndRemove()
+ *
+ * @return string
+ */
+ public function pregRemoveAndRecord() {
+ $this->mFound = true;
+ return '';
+ }
+
+ /**
+ * Replaces the word with something else
+ *
+ * @param string $replacement
+ * @param string $subject
+ * @param int $limit
+ *
+ * @return string
+ */
+ public function replace( $replacement, $subject, $limit = -1 ) {
+ $res = preg_replace(
+ $this->getRegex(),
+ StringUtils::escapeRegexReplacement( $replacement ),
+ $subject,
+ $limit
+ );
+ $this->mModified = $res !== $subject;
+ return $res;
+ }
+
+ /**
+ * Variable handling: {{SUBST:xxx}} style words
+ * Calls back a function to determine what to replace xxx with
+ * Input word must contain $1
+ *
+ * @param string $text
+ * @param callable $callback
+ *
+ * @return string
+ */
+ public function substituteCallback( $text, $callback ) {
+ $res = preg_replace_callback( $this->getVariableRegex(), $callback, $text );
+ $this->mModified = $res !== $text;
+ return $res;
+ }
+
+ /**
+ * Matches the word, where $1 is a wildcard
+ *
+ * @return string
+ */
+ public function getVariableRegex() {
+ if ( $this->mVariableRegex == '' ) {
+ $this->initRegex();
+ }
+ return $this->mVariableRegex;
+ }
+
+ /**
+ * Matches the entire string, where $1 is a wildcard
+ *
+ * @return string
+ */
+ public function getVariableStartToEndRegex() {
+ if ( $this->mVariableStartToEndRegex == '' ) {
+ $this->initRegex();
+ }
+ return $this->mVariableStartToEndRegex;
+ }
+
+ /**
+ * Accesses the synonym list directly
+ *
+ * @param int $i
+ *
+ * @return string
+ */
+ public function getSynonym( $i ) {
+ return $this->mSynonyms[$i];
+ }
+
+ /**
+ * @return array
+ */
+ public function getSynonyms() {
+ return $this->mSynonyms;
+ }
+
+ /**
+ * Returns true if the last call to replace() or substituteCallback()
+ * returned a modified text, otherwise false.
+ *
+ * @return bool
+ */
+ public function getWasModified() {
+ return $this->mModified;
+ }
+
+ /**
+ * Adds all the synonyms of this MagicWord to an array, to allow quick
+ * lookup in a list of magic words
+ *
+ * @param array &$array
+ * @param string $value
+ */
+ public function addToArray( &$array, $value ) {
+ global $wgContLang;
+ foreach ( $this->mSynonyms as $syn ) {
+ $array[$wgContLang->lc( $syn )] = $value;
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function isCaseSensitive() {
+ return $this->mCaseSensitive;
+ }
+
+ /**
+ * @return int
+ */
+ public function getId() {
+ return $this->mId;
+ }
+}
diff --git a/www/wiki/includes/MagicWordArray.php b/www/wiki/includes/MagicWordArray.php
new file mode 100644
index 00000000..5856e21b
--- /dev/null
+++ b/www/wiki/includes/MagicWordArray.php
@@ -0,0 +1,338 @@
+<?php
+
+/**
+ * See docs/magicword.txt.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Class for handling an array of magic words
+ * @ingroup Parser
+ */
+class MagicWordArray {
+ /** @var array */
+ public $names = [];
+
+ /** @var array */
+ private $hash;
+
+ private $baseRegex;
+
+ private $regex;
+
+ /**
+ * @param array $names
+ */
+ public function __construct( $names = [] ) {
+ $this->names = $names;
+ }
+
+ /**
+ * Add a magic word by name
+ *
+ * @param string $name
+ */
+ public function add( $name ) {
+ $this->names[] = $name;
+ $this->hash = $this->baseRegex = $this->regex = null;
+ }
+
+ /**
+ * Add a number of magic words by name
+ *
+ * @param array $names
+ */
+ public function addArray( $names ) {
+ $this->names = array_merge( $this->names, array_values( $names ) );
+ $this->hash = $this->baseRegex = $this->regex = null;
+ }
+
+ /**
+ * Get a 2-d hashtable for this array
+ * @return array
+ */
+ public function getHash() {
+ if ( is_null( $this->hash ) ) {
+ global $wgContLang;
+ $this->hash = [ 0 => [], 1 => [] ];
+ foreach ( $this->names as $name ) {
+ $magic = MagicWord::get( $name );
+ $case = intval( $magic->isCaseSensitive() );
+ foreach ( $magic->getSynonyms() as $syn ) {
+ if ( !$case ) {
+ $syn = $wgContLang->lc( $syn );
+ }
+ $this->hash[$case][$syn] = $name;
+ }
+ }
+ }
+ return $this->hash;
+ }
+
+ /**
+ * Get the base regex
+ * @return array
+ */
+ public function getBaseRegex() {
+ if ( is_null( $this->baseRegex ) ) {
+ $this->baseRegex = [ 0 => '', 1 => '' ];
+ $allGroups = [];
+ foreach ( $this->names as $name ) {
+ $magic = MagicWord::get( $name );
+ $case = intval( $magic->isCaseSensitive() );
+ foreach ( $magic->getSynonyms() as $i => $syn ) {
+ // Group name must start with a non-digit in PCRE 8.34+
+ $it = strtr( $i, '0123456789', 'abcdefghij' );
+ $groupName = $it . '_' . $name;
+ $group = '(?P<' . $groupName . '>' . preg_quote( $syn, '/' ) . ')';
+ // look for same group names to avoid same named subpatterns in the regex
+ if ( isset( $allGroups[$groupName] ) ) {
+ throw new MWException(
+ __METHOD__ . ': duplicate internal name in magic word array: ' . $name
+ );
+ }
+ $allGroups[$groupName] = true;
+ if ( $this->baseRegex[$case] === '' ) {
+ $this->baseRegex[$case] = $group;
+ } else {
+ $this->baseRegex[$case] .= '|' . $group;
+ }
+ }
+ }
+ }
+ return $this->baseRegex;
+ }
+
+ /**
+ * Get an unanchored regex that does not match parameters
+ * @return array
+ */
+ public function getRegex() {
+ if ( is_null( $this->regex ) ) {
+ $base = $this->getBaseRegex();
+ $this->regex = [ '', '' ];
+ if ( $this->baseRegex[0] !== '' ) {
+ $this->regex[0] = "/{$base[0]}/iuS";
+ }
+ if ( $this->baseRegex[1] !== '' ) {
+ $this->regex[1] = "/{$base[1]}/S";
+ }
+ }
+ return $this->regex;
+ }
+
+ /**
+ * Get a regex for matching variables with parameters
+ *
+ * @return string
+ */
+ public function getVariableRegex() {
+ return str_replace( "\\$1", "(.*?)", $this->getRegex() );
+ }
+
+ /**
+ * Get a regex anchored to the start of the string that does not match parameters
+ *
+ * @return array
+ */
+ public function getRegexStart() {
+ $base = $this->getBaseRegex();
+ $newRegex = [ '', '' ];
+ if ( $base[0] !== '' ) {
+ $newRegex[0] = "/^(?:{$base[0]})/iuS";
+ }
+ if ( $base[1] !== '' ) {
+ $newRegex[1] = "/^(?:{$base[1]})/S";
+ }
+ return $newRegex;
+ }
+
+ /**
+ * Get an anchored regex for matching variables with parameters
+ *
+ * @return array
+ */
+ public function getVariableStartToEndRegex() {
+ $base = $this->getBaseRegex();
+ $newRegex = [ '', '' ];
+ if ( $base[0] !== '' ) {
+ $newRegex[0] = str_replace( "\\$1", "(.*?)", "/^(?:{$base[0]})$/iuS" );
+ }
+ if ( $base[1] !== '' ) {
+ $newRegex[1] = str_replace( "\\$1", "(.*?)", "/^(?:{$base[1]})$/S" );
+ }
+ return $newRegex;
+ }
+
+ /**
+ * @since 1.20
+ * @return array
+ */
+ public function getNames() {
+ return $this->names;
+ }
+
+ /**
+ * Parse a match array from preg_match
+ * Returns array(magic word ID, parameter value)
+ * If there is no parameter value, that element will be false.
+ *
+ * @param array $m
+ *
+ * @throws MWException
+ * @return array
+ */
+ public function parseMatch( $m ) {
+ reset( $m );
+ while ( list( $key, $value ) = each( $m ) ) {
+ if ( $key === 0 || $value === '' ) {
+ continue;
+ }
+ $parts = explode( '_', $key, 2 );
+ if ( count( $parts ) != 2 ) {
+ // This shouldn't happen
+ // continue;
+ throw new MWException( __METHOD__ . ': bad parameter name' );
+ }
+ list( /* $synIndex */, $magicName ) = $parts;
+ $paramValue = next( $m );
+ return [ $magicName, $paramValue ];
+ }
+ // This shouldn't happen either
+ throw new MWException( __METHOD__ . ': parameter not found' );
+ }
+
+ /**
+ * Match some text, with parameter capture
+ * Returns an array with the magic word name in the first element and the
+ * parameter in the second element.
+ * Both elements are false if there was no match.
+ *
+ * @param string $text
+ *
+ * @return array
+ */
+ public function matchVariableStartToEnd( $text ) {
+ $regexes = $this->getVariableStartToEndRegex();
+ foreach ( $regexes as $regex ) {
+ if ( $regex !== '' ) {
+ $m = [];
+ if ( preg_match( $regex, $text, $m ) ) {
+ return $this->parseMatch( $m );
+ }
+ }
+ }
+ return [ false, false ];
+ }
+
+ /**
+ * Match some text, without parameter capture
+ * Returns the magic word name, or false if there was no capture
+ *
+ * @param string $text
+ *
+ * @return string|bool False on failure
+ */
+ public function matchStartToEnd( $text ) {
+ $hash = $this->getHash();
+ if ( isset( $hash[1][$text] ) ) {
+ return $hash[1][$text];
+ }
+ global $wgContLang;
+ $lc = $wgContLang->lc( $text );
+ if ( isset( $hash[0][$lc] ) ) {
+ return $hash[0][$lc];
+ }
+ return false;
+ }
+
+ /**
+ * Returns an associative array, ID => param value, for all items that match
+ * Removes the matched items from the input string (passed by reference)
+ *
+ * @param string &$text
+ *
+ * @return array
+ */
+ public function matchAndRemove( &$text ) {
+ $found = [];
+ $regexes = $this->getRegex();
+ foreach ( $regexes as $regex ) {
+ if ( $regex === '' ) {
+ continue;
+ }
+ $matches = [];
+ $res = preg_match_all( $regex, $text, $matches, PREG_SET_ORDER );
+ if ( $res === false ) {
+ LoggerFactory::getInstance( 'parser' )->warning( 'preg_match_all returned false', [
+ 'code' => preg_last_error(),
+ 'regex' => $regex,
+ 'text' => $text,
+ ] );
+ } elseif ( $res ) {
+ foreach ( $matches as $m ) {
+ list( $name, $param ) = $this->parseMatch( $m );
+ $found[$name] = $param;
+ }
+ }
+ $res = preg_replace( $regex, '', $text );
+ if ( $res === null ) {
+ LoggerFactory::getInstance( 'parser' )->warning( 'preg_replace returned null', [
+ 'code' => preg_last_error(),
+ 'regex' => $regex,
+ 'text' => $text,
+ ] );
+ }
+ $text = $res;
+ }
+ return $found;
+ }
+
+ /**
+ * Return the ID of the magic word at the start of $text, and remove
+ * the prefix from $text.
+ * Return false if no match found and $text is not modified.
+ * Does not match parameters.
+ *
+ * @param string &$text
+ *
+ * @return int|bool False on failure
+ */
+ public function matchStartAndRemove( &$text ) {
+ $regexes = $this->getRegexStart();
+ foreach ( $regexes as $regex ) {
+ if ( $regex === '' ) {
+ continue;
+ }
+ if ( preg_match( $regex, $text, $m ) ) {
+ list( $id, ) = $this->parseMatch( $m );
+ if ( strlen( $m[0] ) >= strlen( $text ) ) {
+ $text = '';
+ } else {
+ $text = substr( $text, strlen( $m[0] ) );
+ }
+ return $id;
+ }
+ }
+ return false;
+ }
+}
diff --git a/www/wiki/includes/MediaWiki.php b/www/wiki/includes/MediaWiki.php
new file mode 100644
index 00000000..0f40c192
--- /dev/null
+++ b/www/wiki/includes/MediaWiki.php
@@ -0,0 +1,1043 @@
+<?php
+/**
+ * Helper class for the index.php entry point.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use Psr\Log\LoggerInterface;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ChronologyProtector;
+use Wikimedia\Rdbms\LBFactory;
+use Wikimedia\Rdbms\DBConnectionError;
+
+/**
+ * The MediaWiki class is the helper class for the index.php entry point.
+ */
+class MediaWiki {
+ /**
+ * @var IContextSource
+ */
+ private $context;
+
+ /**
+ * @var Config
+ */
+ private $config;
+
+ /**
+ * @var String Cache what action this request is
+ */
+ private $action;
+
+ /**
+ * @param IContextSource|null $context
+ */
+ public function __construct( IContextSource $context = null ) {
+ if ( !$context ) {
+ $context = RequestContext::getMain();
+ }
+
+ $this->context = $context;
+ $this->config = $context->getConfig();
+ }
+
+ /**
+ * Parse the request to get the Title object
+ *
+ * @throws MalformedTitleException If a title has been provided by the user, but is invalid.
+ * @return Title Title object to be $wgTitle
+ */
+ private function parseTitle() {
+ global $wgContLang;
+
+ $request = $this->context->getRequest();
+ $curid = $request->getInt( 'curid' );
+ $title = $request->getVal( 'title' );
+ $action = $request->getVal( 'action' );
+
+ if ( $request->getCheck( 'search' ) ) {
+ // Compatibility with old search URLs which didn't use Special:Search
+ // Just check for presence here, so blank requests still
+ // show the search page when using ugly URLs (T10054).
+ $ret = SpecialPage::getTitleFor( 'Search' );
+ } elseif ( $curid ) {
+ // URLs like this are generated by RC, because rc_title isn't always accurate
+ $ret = Title::newFromID( $curid );
+ } else {
+ $ret = Title::newFromURL( $title );
+ // Alias NS_MEDIA page URLs to NS_FILE...we only use NS_MEDIA
+ // in wikitext links to tell Parser to make a direct file link
+ if ( !is_null( $ret ) && $ret->getNamespace() == NS_MEDIA ) {
+ $ret = Title::makeTitle( NS_FILE, $ret->getDBkey() );
+ }
+ // Check variant links so that interwiki links don't have to worry
+ // about the possible different language variants
+ if ( count( $wgContLang->getVariants() ) > 1
+ && !is_null( $ret ) && $ret->getArticleID() == 0
+ ) {
+ $wgContLang->findVariantLink( $title, $ret );
+ }
+ }
+
+ // If title is not provided, always allow oldid and diff to set the title.
+ // If title is provided, allow oldid and diff to override the title, unless
+ // we are talking about a special page which might use these parameters for
+ // other purposes.
+ if ( $ret === null || !$ret->isSpecialPage() ) {
+ // We can have urls with just ?diff=,?oldid= or even just ?diff=
+ $oldid = $request->getInt( 'oldid' );
+ $oldid = $oldid ? $oldid : $request->getInt( 'diff' );
+ // Allow oldid to override a changed or missing title
+ if ( $oldid ) {
+ $rev = Revision::newFromId( $oldid );
+ $ret = $rev ? $rev->getTitle() : $ret;
+ }
+ }
+
+ // Use the main page as default title if nothing else has been provided
+ if ( $ret === null
+ && strval( $title ) === ''
+ && !$request->getCheck( 'curid' )
+ && $action !== 'delete'
+ ) {
+ $ret = Title::newMainPage();
+ }
+
+ if ( $ret === null || ( $ret->getDBkey() == '' && !$ret->isExternal() ) ) {
+ // If we get here, we definitely don't have a valid title; throw an exception.
+ // Try to get detailed invalid title exception first, fall back to MalformedTitleException.
+ Title::newFromTextThrow( $title );
+ throw new MalformedTitleException( 'badtitletext', $title );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Get the Title object that we'll be acting on, as specified in the WebRequest
+ * @return Title
+ */
+ public function getTitle() {
+ if ( !$this->context->hasTitle() ) {
+ try {
+ $this->context->setTitle( $this->parseTitle() );
+ } catch ( MalformedTitleException $ex ) {
+ $this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
+ }
+ }
+ return $this->context->getTitle();
+ }
+
+ /**
+ * Returns the name of the action that will be executed.
+ *
+ * @return string Action
+ */
+ public function getAction() {
+ if ( $this->action === null ) {
+ $this->action = Action::getActionName( $this->context );
+ }
+
+ return $this->action;
+ }
+
+ /**
+ * Performs the request.
+ * - bad titles
+ * - read restriction
+ * - local interwiki redirects
+ * - redirect loop
+ * - special pages
+ * - normal pages
+ *
+ * @throws MWException|PermissionsError|BadTitleError|HttpError
+ * @return void
+ */
+ private function performRequest() {
+ global $wgTitle;
+
+ $request = $this->context->getRequest();
+ $requestTitle = $title = $this->context->getTitle();
+ $output = $this->context->getOutput();
+ $user = $this->context->getUser();
+
+ if ( $request->getVal( 'printable' ) === 'yes' ) {
+ $output->setPrintable();
+ }
+
+ $unused = null; // To pass it by reference
+ Hooks::run( 'BeforeInitialize', [ &$title, &$unused, &$output, &$user, $request, $this ] );
+
+ // Invalid titles. T23776: The interwikis must redirect even if the page name is empty.
+ if ( is_null( $title ) || ( $title->getDBkey() == '' && !$title->isExternal() )
+ || $title->isSpecial( 'Badtitle' )
+ ) {
+ $this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
+ try {
+ $this->parseTitle();
+ } catch ( MalformedTitleException $ex ) {
+ throw new BadTitleError( $ex );
+ }
+ throw new BadTitleError();
+ }
+
+ // Check user's permissions to read this page.
+ // We have to check here to catch special pages etc.
+ // We will check again in Article::view().
+ $permErrors = $title->isSpecial( 'RunJobs' )
+ ? [] // relies on HMAC key signature alone
+ : $title->getUserPermissionsErrors( 'read', $user );
+ if ( count( $permErrors ) ) {
+ // T34276: allowing the skin to generate output with $wgTitle or
+ // $this->context->title set to the input title would allow anonymous users to
+ // determine whether a page exists, potentially leaking private data. In fact, the
+ // curid and oldid request parameters would allow page titles to be enumerated even
+ // when they are not guessable. So we reset the title to Special:Badtitle before the
+ // permissions error is displayed.
+
+ // The skin mostly uses $this->context->getTitle() these days, but some extensions
+ // still use $wgTitle.
+ $badTitle = SpecialPage::getTitleFor( 'Badtitle' );
+ $this->context->setTitle( $badTitle );
+ $wgTitle = $badTitle;
+
+ throw new PermissionsError( 'read', $permErrors );
+ }
+
+ // Interwiki redirects
+ if ( $title->isExternal() ) {
+ $rdfrom = $request->getVal( 'rdfrom' );
+ if ( $rdfrom ) {
+ $url = $title->getFullURL( [ 'rdfrom' => $rdfrom ] );
+ } else {
+ $query = $request->getValues();
+ unset( $query['title'] );
+ $url = $title->getFullURL( $query );
+ }
+ // Check for a redirect loop
+ if ( !preg_match( '/^' . preg_quote( $this->config->get( 'Server' ), '/' ) . '/', $url )
+ && $title->isLocal()
+ ) {
+ // 301 so google et al report the target as the actual url.
+ $output->redirect( $url, 301 );
+ } else {
+ $this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
+ try {
+ $this->parseTitle();
+ } catch ( MalformedTitleException $ex ) {
+ throw new BadTitleError( $ex );
+ }
+ throw new BadTitleError();
+ }
+ // Handle any other redirects.
+ // Redirect loops, titleless URL, $wgUsePathInfo URLs, and URLs with a variant
+ } elseif ( !$this->tryNormaliseRedirect( $title ) ) {
+ // Prevent information leak via Special:MyPage et al (T109724)
+ if ( $title->isSpecialPage() ) {
+ $specialPage = SpecialPageFactory::getPage( $title->getDBkey() );
+ if ( $specialPage instanceof RedirectSpecialPage ) {
+ $specialPage->setContext( $this->context );
+ if ( $this->config->get( 'HideIdentifiableRedirects' )
+ && $specialPage->personallyIdentifiableTarget()
+ ) {
+ list( , $subpage ) = SpecialPageFactory::resolveAlias( $title->getDBkey() );
+ $target = $specialPage->getRedirect( $subpage );
+ // target can also be true. We let that case fall through to normal processing.
+ if ( $target instanceof Title ) {
+ $query = $specialPage->getRedirectQuery() ?: [];
+ $request = new DerivativeRequest( $this->context->getRequest(), $query );
+ $request->setRequestURL( $this->context->getRequest()->getRequestURL() );
+ $this->context->setRequest( $request );
+ // Do not varnish cache these. May vary even for anons
+ $this->context->getOutput()->lowerCdnMaxage( 0 );
+ $this->context->setTitle( $target );
+ $wgTitle = $target;
+ // Reset action type cache. (Special pages have only view)
+ $this->action = null;
+ $title = $target;
+ $output->addJsConfigVars( [
+ 'wgInternalRedirectTargetUrl' => $target->getFullURL( $query ),
+ ] );
+ $output->addModules( 'mediawiki.action.view.redirect' );
+ }
+ }
+ }
+ }
+
+ // Special pages ($title may have changed since if statement above)
+ if ( $title->isSpecialPage() ) {
+ // Actions that need to be made when we have a special pages
+ SpecialPageFactory::executePath( $title, $this->context );
+ } else {
+ // ...otherwise treat it as an article view. The article
+ // may still be a wikipage redirect to another article or URL.
+ $article = $this->initializeArticle();
+ if ( is_object( $article ) ) {
+ $this->performAction( $article, $requestTitle );
+ } elseif ( is_string( $article ) ) {
+ $output->redirect( $article );
+ } else {
+ throw new MWException( "Shouldn't happen: MediaWiki::initializeArticle()"
+ . " returned neither an object nor a URL" );
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle redirects for uncanonical title requests.
+ *
+ * Handles:
+ * - Redirect loops.
+ * - No title in URL.
+ * - $wgUsePathInfo URLs.
+ * - URLs with a variant.
+ * - Other non-standard URLs (as long as they have no extra query parameters).
+ *
+ * Behaviour:
+ * - Normalise title values:
+ * /wiki/Foo%20Bar -> /wiki/Foo_Bar
+ * - Normalise empty title:
+ * /wiki/ -> /wiki/Main
+ * /w/index.php?title= -> /wiki/Main
+ * - Don't redirect anything with query parameters other than 'title' or 'action=view'.
+ *
+ * @param Title $title
+ * @return bool True if a redirect was set.
+ * @throws HttpError
+ */
+ private function tryNormaliseRedirect( Title $title ) {
+ $request = $this->context->getRequest();
+ $output = $this->context->getOutput();
+
+ if ( $request->getVal( 'action', 'view' ) != 'view'
+ || $request->wasPosted()
+ || ( $request->getVal( 'title' ) !== null
+ && $title->getPrefixedDBkey() == $request->getVal( 'title' ) )
+ || count( $request->getValueNames( [ 'action', 'title' ] ) )
+ || !Hooks::run( 'TestCanonicalRedirect', [ $request, $title, $output ] )
+ ) {
+ return false;
+ }
+
+ if ( $title->isSpecialPage() ) {
+ list( $name, $subpage ) = SpecialPageFactory::resolveAlias( $title->getDBkey() );
+ if ( $name ) {
+ $title = SpecialPage::getTitleFor( $name, $subpage );
+ }
+ }
+ // Redirect to canonical url, make it a 301 to allow caching
+ $targetUrl = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+ if ( $targetUrl == $request->getFullRequestURL() ) {
+ $message = "Redirect loop detected!\n\n" .
+ "This means the wiki got confused about what page was " .
+ "requested; this sometimes happens when moving a wiki " .
+ "to a new server or changing the server configuration.\n\n";
+
+ if ( $this->config->get( 'UsePathInfo' ) ) {
+ $message .= "The wiki is trying to interpret the page " .
+ "title from the URL path portion (PATH_INFO), which " .
+ "sometimes fails depending on the web server. Try " .
+ "setting \"\$wgUsePathInfo = false;\" in your " .
+ "LocalSettings.php, or check that \$wgArticlePath " .
+ "is correct.";
+ } else {
+ $message .= "Your web server was detected as possibly not " .
+ "supporting URL path components (PATH_INFO) correctly; " .
+ "check your LocalSettings.php for a customized " .
+ "\$wgArticlePath setting and/or toggle \$wgUsePathInfo " .
+ "to true.";
+ }
+ throw new HttpError( 500, $message );
+ }
+ $output->setSquidMaxage( 1200 );
+ $output->redirect( $targetUrl, '301' );
+ return true;
+ }
+
+ /**
+ * Initialize the main Article object for "standard" actions (view, etc)
+ * Create an Article object for the page, following redirects if needed.
+ *
+ * @return Article|string An Article, or a string to redirect to another URL
+ */
+ private function initializeArticle() {
+ $title = $this->context->getTitle();
+ if ( $this->context->canUseWikiPage() ) {
+ // Try to use request context wiki page, as there
+ // is already data from db saved in per process
+ // cache there from this->getAction() call.
+ $page = $this->context->getWikiPage();
+ } else {
+ // This case should not happen, but just in case.
+ // @TODO: remove this or use an exception
+ $page = WikiPage::factory( $title );
+ $this->context->setWikiPage( $page );
+ wfWarn( "RequestContext::canUseWikiPage() returned false" );
+ }
+
+ // Make GUI wrapper for the WikiPage
+ $article = Article::newFromWikiPage( $page, $this->context );
+
+ // Skip some unnecessary code if the content model doesn't support redirects
+ if ( !ContentHandler::getForTitle( $title )->supportsRedirects() ) {
+ return $article;
+ }
+
+ $request = $this->context->getRequest();
+
+ // Namespace might change when using redirects
+ // Check for redirects ...
+ $action = $request->getVal( 'action', 'view' );
+ $file = ( $page instanceof WikiFilePage ) ? $page->getFile() : null;
+ if ( ( $action == 'view' || $action == 'render' ) // ... for actions that show content
+ && !$request->getVal( 'oldid' ) // ... and are not old revisions
+ && !$request->getVal( 'diff' ) // ... and not when showing diff
+ && $request->getVal( 'redirect' ) != 'no' // ... unless explicitly told not to
+ // ... and the article is not a non-redirect image page with associated file
+ && !( is_object( $file ) && $file->exists() && !$file->getRedirected() )
+ ) {
+ // Give extensions a change to ignore/handle redirects as needed
+ $ignoreRedirect = $target = false;
+
+ Hooks::run( 'InitializeArticleMaybeRedirect',
+ [ &$title, &$request, &$ignoreRedirect, &$target, &$article ] );
+ $page = $article->getPage(); // reflect any hook changes
+
+ // Follow redirects only for... redirects.
+ // If $target is set, then a hook wanted to redirect.
+ if ( !$ignoreRedirect && ( $target || $page->isRedirect() ) ) {
+ // Is the target already set by an extension?
+ $target = $target ? $target : $page->followRedirect();
+ if ( is_string( $target ) ) {
+ if ( !$this->config->get( 'DisableHardRedirects' ) ) {
+ // we'll need to redirect
+ return $target;
+ }
+ }
+ if ( is_object( $target ) ) {
+ // Rewrite environment to redirected article
+ $rpage = WikiPage::factory( $target );
+ $rpage->loadPageData();
+ if ( $rpage->exists() || ( is_object( $file ) && !$file->isLocal() ) ) {
+ $rarticle = Article::newFromWikiPage( $rpage, $this->context );
+ $rarticle->setRedirectedFrom( $title );
+
+ $article = $rarticle;
+ $this->context->setTitle( $target );
+ $this->context->setWikiPage( $article->getPage() );
+ }
+ }
+ } else {
+ // Article may have been changed by hook
+ $this->context->setTitle( $article->getTitle() );
+ $this->context->setWikiPage( $article->getPage() );
+ }
+ }
+
+ return $article;
+ }
+
+ /**
+ * Perform one of the "standard" actions
+ *
+ * @param Page $page
+ * @param Title $requestTitle The original title, before any redirects were applied
+ */
+ private function performAction( Page $page, Title $requestTitle ) {
+ $request = $this->context->getRequest();
+ $output = $this->context->getOutput();
+ $title = $this->context->getTitle();
+ $user = $this->context->getUser();
+
+ if ( !Hooks::run( 'MediaWikiPerformAction',
+ [ $output, $page, $title, $user, $request, $this ] )
+ ) {
+ return;
+ }
+
+ $act = $this->getAction();
+ $action = Action::factory( $act, $page, $this->context );
+
+ if ( $action instanceof Action ) {
+ // Narrow DB query expectations for this HTTP request
+ $trxLimits = $this->config->get( 'TrxProfilerLimits' );
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+ if ( $request->wasPosted() && !$action->doesWrites() ) {
+ $trxProfiler->setExpectations( $trxLimits['POST-nonwrite'], __METHOD__ );
+ $request->markAsSafeRequest();
+ }
+
+ # Let CDN cache things if we can purge them.
+ if ( $this->config->get( 'UseSquid' ) &&
+ in_array(
+ // Use PROTO_INTERNAL because that's what getCdnUrls() uses
+ wfExpandUrl( $request->getRequestURL(), PROTO_INTERNAL ),
+ $requestTitle->getCdnUrls()
+ )
+ ) {
+ $output->setCdnMaxage( $this->config->get( 'SquidMaxage' ) );
+ }
+
+ $action->show();
+ return;
+ }
+ // NOTE: deprecated hook. Add to $wgActions instead
+ if ( Hooks::run(
+ 'UnknownAction',
+ [
+ $request->getVal( 'action', 'view' ),
+ $page
+ ],
+ '1.19'
+ ) ) {
+ $output->setStatusCode( 404 );
+ $output->showErrorPage( 'nosuchaction', 'nosuchactiontext' );
+ }
+ }
+
+ /**
+ * Run the current MediaWiki instance; index.php just calls this
+ */
+ public function run() {
+ try {
+ $this->setDBProfilingAgent();
+ try {
+ $this->main();
+ } catch ( ErrorPageError $e ) {
+ // T64091: while exceptions are convenient to bubble up GUI errors,
+ // they are not internal application faults. As with normal requests, this
+ // should commit, print the output, do deferred updates, jobs, and profiling.
+ $this->doPreOutputCommit();
+ $e->report(); // display the GUI error
+ }
+ } catch ( Exception $e ) {
+ $context = $this->context;
+ $action = $context->getRequest()->getVal( 'action', 'view' );
+ if (
+ $e instanceof DBConnectionError &&
+ $context->hasTitle() &&
+ $context->getTitle()->canExist() &&
+ in_array( $action, [ 'view', 'history' ], true ) &&
+ HTMLFileCache::useFileCache( $this->context, HTMLFileCache::MODE_OUTAGE )
+ ) {
+ // Try to use any (even stale) file during outages...
+ $cache = new HTMLFileCache( $context->getTitle(), $action );
+ if ( $cache->isCached() ) {
+ $cache->loadFromFileCache( $context, HTMLFileCache::MODE_OUTAGE );
+ print MWExceptionRenderer::getHTML( $e );
+ exit;
+ }
+ }
+
+ MWExceptionHandler::handleException( $e );
+ }
+
+ $this->doPostOutputShutdown( 'normal' );
+ }
+
+ private function setDBProfilingAgent() {
+ $services = MediaWikiServices::getInstance();
+ // Add a comment for easy SHOW PROCESSLIST interpretation
+ $name = $this->context->getUser()->getName();
+ $services->getDBLoadBalancerFactory()->setAgentName(
+ mb_strlen( $name ) > 15 ? mb_substr( $name, 0, 15 ) . '...' : $name
+ );
+ }
+
+ /**
+ * @see MediaWiki::preOutputCommit()
+ * @param callable $postCommitWork [default: null]
+ * @since 1.26
+ */
+ public function doPreOutputCommit( callable $postCommitWork = null ) {
+ self::preOutputCommit( $this->context, $postCommitWork );
+ }
+
+ /**
+ * This function commits all DB changes as needed before
+ * the user can receive a response (in case commit fails)
+ *
+ * @param IContextSource $context
+ * @param callable $postCommitWork [default: null]
+ * @since 1.27
+ */
+ public static function preOutputCommit(
+ IContextSource $context, callable $postCommitWork = null
+ ) {
+ // Either all DBs should commit or none
+ ignore_user_abort( true );
+
+ $config = $context->getConfig();
+ $request = $context->getRequest();
+ $output = $context->getOutput();
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+
+ // Commit all changes
+ $lbFactory->commitMasterChanges(
+ __METHOD__,
+ // Abort if any transaction was too big
+ [ 'maxWriteDuration' => $config->get( 'MaxUserDBWriteDuration' ) ]
+ );
+ wfDebug( __METHOD__ . ': primary transaction round committed' );
+
+ // Run updates that need to block the user or affect output (this is the last chance)
+ DeferredUpdates::doUpdates( 'enqueue', DeferredUpdates::PRESEND );
+ wfDebug( __METHOD__ . ': pre-send deferred updates completed' );
+
+ // Decide when clients block on ChronologyProtector DB position writes
+ $urlDomainDistance = (
+ $request->wasPosted() &&
+ $output->getRedirect() &&
+ $lbFactory->hasOrMadeRecentMasterChanges( INF )
+ ) ? self::getUrlDomainDistance( $output->getRedirect() ) : false;
+
+ $allowHeaders = !( $output->isDisabled() || headers_sent() );
+ if ( $urlDomainDistance === 'local' || $urlDomainDistance === 'remote' ) {
+ // OutputPage::output() will be fast; $postCommitWork will not be useful for
+ // masking the latency of syncing DB positions accross all datacenters synchronously.
+ // Instead, make use of the RTT time of the client follow redirects.
+ $flags = $lbFactory::SHUTDOWN_CHRONPROT_ASYNC;
+ $cpPosTime = microtime( true );
+ // Client's next request should see 1+ positions with this DBMasterPos::asOf() time
+ if ( $urlDomainDistance === 'local' && $allowHeaders ) {
+ // Client will stay on this domain, so set an unobtrusive cookie
+ $expires = time() + ChronologyProtector::POSITION_TTL;
+ $options = [ 'prefix' => '' ];
+ $request->response()->setCookie( 'cpPosTime', $cpPosTime, $expires, $options );
+ } else {
+ // Cookies may not work across wiki domains, so use a URL parameter
+ $safeUrl = $lbFactory->appendPreShutdownTimeAsQuery(
+ $output->getRedirect(),
+ $cpPosTime
+ );
+ $output->redirect( $safeUrl );
+ }
+ } else {
+ // OutputPage::output() is fairly slow; run it in $postCommitWork to mask
+ // the latency of syncing DB positions accross all datacenters synchronously
+ $flags = $lbFactory::SHUTDOWN_CHRONPROT_SYNC;
+ if ( $lbFactory->hasOrMadeRecentMasterChanges( INF ) && $allowHeaders ) {
+ $cpPosTime = microtime( true );
+ // Set a cookie in case the DB position store cannot sync accross datacenters.
+ // This will at least cover the common case of the user staying on the domain.
+ $expires = time() + ChronologyProtector::POSITION_TTL;
+ $options = [ 'prefix' => '' ];
+ $request->response()->setCookie( 'cpPosTime', $cpPosTime, $expires, $options );
+ }
+ }
+ // Record ChronologyProtector positions for DBs affected in this request at this point
+ $lbFactory->shutdown( $flags, $postCommitWork );
+ wfDebug( __METHOD__ . ': LBFactory shutdown completed' );
+
+ // Set a cookie to tell all CDN edge nodes to "stick" the user to the DC that handles this
+ // POST request (e.g. the "master" data center). Also have the user briefly bypass CDN so
+ // ChronologyProtector works for cacheable URLs.
+ if ( $request->wasPosted() && $lbFactory->hasOrMadeRecentMasterChanges() ) {
+ $expires = time() + $config->get( 'DataCenterUpdateStickTTL' );
+ $options = [ 'prefix' => '' ];
+ $request->response()->setCookie( 'UseDC', 'master', $expires, $options );
+ $request->response()->setCookie( 'UseCDNCache', 'false', $expires, $options );
+ }
+
+ // Avoid letting a few seconds of replica DB lag cause a month of stale data. This logic is
+ // also intimately related to the value of $wgCdnReboundPurgeDelay.
+ if ( $lbFactory->laggedReplicaUsed() ) {
+ $maxAge = $config->get( 'CdnMaxageLagged' );
+ $output->lowerCdnMaxage( $maxAge );
+ $request->response()->header( "X-Database-Lagged: true" );
+ wfDebugLog( 'replication', "Lagged DB used; CDN cache TTL limited to $maxAge seconds" );
+ }
+
+ // Avoid long-term cache pollution due to message cache rebuild timeouts (T133069)
+ if ( MessageCache::singleton()->isDisabled() ) {
+ $maxAge = $config->get( 'CdnMaxageSubstitute' );
+ $output->lowerCdnMaxage( $maxAge );
+ $request->response()->header( "X-Response-Substitute: true" );
+ }
+ }
+
+ /**
+ * @param string $url
+ * @return string Either "local", "remote" if in the farm, "external" otherwise
+ */
+ private static function getUrlDomainDistance( $url ) {
+ $clusterWiki = WikiMap::getWikiFromUrl( $url );
+ if ( $clusterWiki === wfWikiID() ) {
+ return 'local'; // the current wiki
+ } elseif ( $clusterWiki !== false ) {
+ return 'remote'; // another wiki in this cluster/farm
+ }
+
+ return 'external';
+ }
+
+ /**
+ * This function does work that can be done *after* the
+ * user gets the HTTP response so they don't block on it
+ *
+ * This manages deferred updates, job insertion,
+ * final commit, and the logging of profiling data
+ *
+ * @param string $mode Use 'fast' to always skip job running
+ * @since 1.26
+ */
+ public function doPostOutputShutdown( $mode = 'normal' ) {
+ // Perform the last synchronous operations...
+ try {
+ // Record backend request timing
+ $timing = $this->context->getTiming();
+ $timing->mark( 'requestShutdown' );
+ // Show visible profiling data if enabled (which cannot be post-send)
+ Profiler::instance()->logDataPageOutputOnly();
+ } catch ( Exception $e ) {
+ // An error may already have been shown in run(), so just log it to be safe
+ MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+ }
+
+ $blocksHttpClient = true;
+ // Defer everything else if possible...
+ $callback = function () use ( $mode, &$blocksHttpClient ) {
+ try {
+ $this->restInPeace( $mode, $blocksHttpClient );
+ } catch ( Exception $e ) {
+ // If this is post-send, then displaying errors can cause broken HTML
+ MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+ }
+ };
+
+ if ( function_exists( 'register_postsend_function' ) ) {
+ // https://github.com/facebook/hhvm/issues/1230
+ register_postsend_function( $callback );
+ $blocksHttpClient = false;
+ } else {
+ if ( function_exists( 'fastcgi_finish_request' ) ) {
+ fastcgi_finish_request();
+ $blocksHttpClient = false;
+ } else {
+ // Either all DB and deferred updates should happen or none.
+ // The latter should not be cancelled due to client disconnect.
+ ignore_user_abort( true );
+ }
+
+ $callback();
+ }
+ }
+
+ private function main() {
+ global $wgTitle;
+
+ $output = $this->context->getOutput();
+ $request = $this->context->getRequest();
+
+ // Send Ajax requests to the Ajax dispatcher.
+ if ( $this->config->get( 'UseAjax' ) && $request->getVal( 'action' ) === 'ajax' ) {
+ // Set a dummy title, because $wgTitle == null might break things
+ $title = Title::makeTitle( NS_SPECIAL, 'Badtitle/performing an AJAX call in '
+ . __METHOD__
+ );
+ $this->context->setTitle( $title );
+ $wgTitle = $title;
+
+ $dispatcher = new AjaxDispatcher( $this->config );
+ $dispatcher->performAction( $this->context->getUser() );
+
+ return;
+ }
+
+ // Get title from request parameters,
+ // is set on the fly by parseTitle the first time.
+ $title = $this->getTitle();
+ $action = $this->getAction();
+ $wgTitle = $title;
+
+ // Set DB query expectations for this HTTP request
+ $trxLimits = $this->config->get( 'TrxProfilerLimits' );
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+ $trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) );
+ if ( $request->hasSafeMethod() ) {
+ $trxProfiler->setExpectations( $trxLimits['GET'], __METHOD__ );
+ } else {
+ $trxProfiler->setExpectations( $trxLimits['POST'], __METHOD__ );
+ }
+
+ // If the user has forceHTTPS set to true, or if the user
+ // is in a group requiring HTTPS, or if they have the HTTPS
+ // preference set, redirect them to HTTPS.
+ // Note: Do this after $wgTitle is setup, otherwise the hooks run from
+ // isLoggedIn() will do all sorts of weird stuff.
+ if (
+ $request->getProtocol() == 'http' &&
+ // switch to HTTPS only when supported by the server
+ preg_match( '#^https://#', wfExpandUrl( $request->getRequestURL(), PROTO_HTTPS ) ) &&
+ (
+ $request->getSession()->shouldForceHTTPS() ||
+ // Check the cookie manually, for paranoia
+ $request->getCookie( 'forceHTTPS', '' ) ||
+ // check for prefixed version that was used for a time in older MW versions
+ $request->getCookie( 'forceHTTPS' ) ||
+ // Avoid checking the user and groups unless it's enabled.
+ (
+ $this->context->getUser()->isLoggedIn()
+ && $this->context->getUser()->requiresHTTPS()
+ )
+ )
+ ) {
+ $oldUrl = $request->getFullRequestURL();
+ $redirUrl = preg_replace( '#^http://#', 'https://', $oldUrl );
+
+ // ATTENTION: This hook is likely to be removed soon due to overall design of the system.
+ if ( Hooks::run( 'BeforeHttpsRedirect', [ $this->context, &$redirUrl ] ) ) {
+ if ( $request->wasPosted() ) {
+ // This is weird and we'd hope it almost never happens. This
+ // means that a POST came in via HTTP and policy requires us
+ // redirecting to HTTPS. It's likely such a request is going
+ // to fail due to post data being lost, but let's try anyway
+ // and just log the instance.
+
+ // @todo FIXME: See if we could issue a 307 or 308 here, need
+ // to see how clients (automated & browser) behave when we do
+ wfDebugLog( 'RedirectedPosts', "Redirected from HTTP to HTTPS: $oldUrl" );
+ }
+ // Setup dummy Title, otherwise OutputPage::redirect will fail
+ $title = Title::newFromText( 'REDIR', NS_MAIN );
+ $this->context->setTitle( $title );
+ // Since we only do this redir to change proto, always send a vary header
+ $output->addVaryHeader( 'X-Forwarded-Proto' );
+ $output->redirect( $redirUrl );
+ $output->output();
+
+ return;
+ }
+ }
+
+ if ( $title->canExist() && HTMLFileCache::useFileCache( $this->context ) ) {
+ // Try low-level file cache hit
+ $cache = new HTMLFileCache( $title, $action );
+ if ( $cache->isCacheGood( /* Assume up to date */ ) ) {
+ // Check incoming headers to see if client has this cached
+ $timestamp = $cache->cacheTimestamp();
+ if ( !$output->checkLastModified( $timestamp ) ) {
+ $cache->loadFromFileCache( $this->context );
+ }
+ // Do any stats increment/watchlist stuff, assuming user is viewing the
+ // latest revision (which should always be the case for file cache)
+ $this->context->getWikiPage()->doViewUpdates( $this->context->getUser() );
+ // Tell OutputPage that output is taken care of
+ $output->disable();
+
+ return;
+ }
+ }
+
+ // Actually do the work of the request and build up any output
+ $this->performRequest();
+
+ // GUI-ify and stash the page output in MediaWiki::doPreOutputCommit() while
+ // ChronologyProtector synchronizes DB positions or slaves accross all datacenters.
+ $buffer = null;
+ $outputWork = function () use ( $output, &$buffer ) {
+ if ( $buffer === null ) {
+ $buffer = $output->output( true );
+ }
+
+ return $buffer;
+ };
+
+ // Now commit any transactions, so that unreported errors after
+ // output() don't roll back the whole DB transaction and so that
+ // we avoid having both success and error text in the response
+ $this->doPreOutputCommit( $outputWork );
+
+ // Now send the actual output
+ print $outputWork();
+ }
+
+ /**
+ * Ends this task peacefully
+ * @param string $mode Use 'fast' to always skip job running
+ * @param bool $blocksHttpClient Whether this blocks an HTTP response to a client
+ */
+ public function restInPeace( $mode = 'fast', $blocksHttpClient = true ) {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ // Assure deferred updates are not in the main transaction
+ $lbFactory->commitMasterChanges( __METHOD__ );
+
+ // Loosen DB query expectations since the HTTP client is unblocked
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+ $trxProfiler->resetExpectations();
+ $trxProfiler->setExpectations(
+ $this->config->get( 'TrxProfilerLimits' )['PostSend'],
+ __METHOD__
+ );
+
+ // Important: this must be the last deferred update added (T100085, T154425)
+ DeferredUpdates::addCallableUpdate( [ JobQueueGroup::class, 'pushLazyJobs' ] );
+
+ // Do any deferred jobs; preferring to run them now if a client will not wait on them
+ DeferredUpdates::doUpdates( $blocksHttpClient ? 'enqueue' : 'run' );
+
+ // Now that everything specific to this request is done,
+ // try to occasionally run jobs (if enabled) from the queues
+ if ( $mode === 'normal' ) {
+ $this->triggerJobs();
+ }
+
+ // Log profiling data, e.g. in the database or UDP
+ wfLogProfilingData();
+
+ // Commit and close up!
+ $lbFactory->commitMasterChanges( __METHOD__ );
+ $lbFactory->shutdown( LBFactory::SHUTDOWN_NO_CHRONPROT );
+
+ wfDebug( "Request ended normally\n" );
+ }
+
+ /**
+ * Potentially open a socket and sent an HTTP request back to the server
+ * to run a specified number of jobs. This registers a callback to cleanup
+ * the socket once it's done.
+ */
+ public function triggerJobs() {
+ $jobRunRate = $this->config->get( 'JobRunRate' );
+ if ( $this->getTitle()->isSpecial( 'RunJobs' ) ) {
+ return; // recursion guard
+ } elseif ( $jobRunRate <= 0 || wfReadOnly() ) {
+ return;
+ }
+
+ if ( $jobRunRate < 1 ) {
+ $max = mt_getrandmax();
+ if ( mt_rand( 0, $max ) > $max * $jobRunRate ) {
+ return; // the higher the job run rate, the less likely we return here
+ }
+ $n = 1;
+ } else {
+ $n = intval( $jobRunRate );
+ }
+
+ $logger = LoggerFactory::getInstance( 'runJobs' );
+
+ try {
+ if ( $this->config->get( 'RunJobsAsync' ) ) {
+ // Send an HTTP request to the job RPC entry point if possible
+ $invokedWithSuccess = $this->triggerAsyncJobs( $n, $logger );
+ if ( !$invokedWithSuccess ) {
+ // Fall back to blocking on running the job(s)
+ $logger->warning( "Jobs switched to blocking; Special:RunJobs disabled" );
+ $this->triggerSyncJobs( $n, $logger );
+ }
+ } else {
+ $this->triggerSyncJobs( $n, $logger );
+ }
+ } catch ( JobQueueError $e ) {
+ // Do not make the site unavailable (T88312)
+ MWExceptionHandler::logException( $e );
+ }
+ }
+
+ /**
+ * @param int $n Number of jobs to try to run
+ * @param LoggerInterface $runJobsLogger
+ */
+ private function triggerSyncJobs( $n, LoggerInterface $runJobsLogger ) {
+ $runner = new JobRunner( $runJobsLogger );
+ $runner->run( [ 'maxJobs' => $n ] );
+ }
+
+ /**
+ * @param int $n Number of jobs to try to run
+ * @param LoggerInterface $runJobsLogger
+ * @return bool Success
+ */
+ private function triggerAsyncJobs( $n, LoggerInterface $runJobsLogger ) {
+ // Do not send request if there are probably no jobs
+ $group = JobQueueGroup::singleton();
+ if ( !$group->queuesHaveJobs( JobQueueGroup::TYPE_DEFAULT ) ) {
+ return true;
+ }
+
+ $query = [ 'title' => 'Special:RunJobs',
+ 'tasks' => 'jobs', 'maxjobs' => $n, 'sigexpiry' => time() + 5 ];
+ $query['signature'] = SpecialRunJobs::getQuerySignature(
+ $query, $this->config->get( 'SecretKey' ) );
+
+ $errno = $errstr = null;
+ $info = wfParseUrl( $this->config->get( 'CanonicalServer' ) );
+ $host = $info ? $info['host'] : null;
+ $port = 80;
+ if ( isset( $info['scheme'] ) && $info['scheme'] == 'https' ) {
+ $host = "tls://" . $host;
+ $port = 443;
+ }
+ if ( isset( $info['port'] ) ) {
+ $port = $info['port'];
+ }
+
+ MediaWiki\suppressWarnings();
+ $sock = $host ? fsockopen(
+ $host,
+ $port,
+ $errno,
+ $errstr,
+ // If it takes more than 100ms to connect to ourselves there is a problem...
+ 0.100
+ ) : false;
+ MediaWiki\restoreWarnings();
+
+ $invokedWithSuccess = true;
+ if ( $sock ) {
+ $special = SpecialPageFactory::getPage( 'RunJobs' );
+ $url = $special->getPageTitle()->getCanonicalURL( $query );
+ $req = (
+ "POST $url HTTP/1.1\r\n" .
+ "Host: {$info['host']}\r\n" .
+ "Connection: Close\r\n" .
+ "Content-Length: 0\r\n\r\n"
+ );
+
+ $runJobsLogger->info( "Running $n job(s) via '$url'" );
+ // Send a cron API request to be performed in the background.
+ // Give up if this takes too long to send (which should be rare).
+ stream_set_timeout( $sock, 2 );
+ $bytes = fwrite( $sock, $req );
+ if ( $bytes !== strlen( $req ) ) {
+ $invokedWithSuccess = false;
+ $runJobsLogger->error( "Failed to start cron API (socket write error)" );
+ } else {
+ // Do not wait for the response (the script should handle client aborts).
+ // Make sure that we don't close before that script reaches ignore_user_abort().
+ $start = microtime( true );
+ $status = fgets( $sock );
+ $sec = microtime( true ) - $start;
+ if ( !preg_match( '#^HTTP/\d\.\d 202 #', $status ) ) {
+ $invokedWithSuccess = false;
+ $runJobsLogger->error( "Failed to start cron API: received '$status' ($sec)" );
+ }
+ }
+ fclose( $sock );
+ } else {
+ $invokedWithSuccess = false;
+ $runJobsLogger->error( "Failed to start cron API (socket error $errno): $errstr" );
+ }
+
+ return $invokedWithSuccess;
+ }
+}
diff --git a/www/wiki/includes/MediaWikiServices.php b/www/wiki/includes/MediaWikiServices.php
new file mode 100644
index 00000000..0d010b49
--- /dev/null
+++ b/www/wiki/includes/MediaWikiServices.php
@@ -0,0 +1,699 @@
+<?php
+namespace MediaWiki;
+
+use Config;
+use ConfigFactory;
+use CryptHKDF;
+use CryptRand;
+use EventRelayerGroup;
+use GenderCache;
+use GlobalVarConfig;
+use Hooks;
+use IBufferingStatsdDataFactory;
+use MediaWiki\Shell\CommandFactory;
+use Wikimedia\Rdbms\LBFactory;
+use LinkCache;
+use Wikimedia\Rdbms\LoadBalancer;
+use MediaHandlerFactory;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
+use MediaWiki\Services\SalvageableService;
+use MediaWiki\Services\ServiceContainer;
+use MediaWiki\Services\NoSuchServiceException;
+use MWException;
+use MimeAnalyzer;
+use ObjectCache;
+use Parser;
+use ParserCache;
+use ProxyLookup;
+use SearchEngine;
+use SearchEngineConfig;
+use SearchEngineFactory;
+use SiteLookup;
+use SiteStore;
+use WatchedItemStore;
+use WatchedItemQueryService;
+use SkinFactory;
+use TitleFormatter;
+use TitleParser;
+use VirtualRESTServiceClient;
+use MediaWiki\Interwiki\InterwikiLookup;
+
+/**
+ * Service locator for MediaWiki core services.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * MediaWikiServices is the service locator for the application scope of MediaWiki.
+ * Its implemented as a simple configurable DI container.
+ * MediaWikiServices acts as a top level factory/registry for top level services, and builds
+ * the network of service objects that defines MediaWiki's application logic.
+ * It acts as an entry point to MediaWiki's dependency injection mechanism.
+ *
+ * Services are defined in the "wiring" array passed to the constructor,
+ * or by calling defineService().
+ *
+ * @see docs/injection.txt for an overview of using dependency injection in the
+ * MediaWiki code base.
+ */
+class MediaWikiServices extends ServiceContainer {
+
+ /**
+ * @var MediaWikiServices|null
+ */
+ private static $instance = null;
+
+ /**
+ * Returns the global default instance of the top level service locator.
+ *
+ * @since 1.27
+ *
+ * The default instance is initialized using the service instantiator functions
+ * defined in ServiceWiring.php.
+ *
+ * @note This should only be called by static functions! The instance returned here
+ * should not be passed around! Objects that need access to a service should have
+ * that service injected into the constructor, never a service locator!
+ *
+ * @return MediaWikiServices
+ */
+ public static function getInstance() {
+ if ( self::$instance === null ) {
+ // NOTE: constructing GlobalVarConfig here is not particularly pretty,
+ // but some information from the global scope has to be injected here,
+ // even if it's just a file name or database credentials to load
+ // configuration from.
+ $bootstrapConfig = new GlobalVarConfig();
+ self::$instance = self::newInstance( $bootstrapConfig, 'load' );
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Replaces the global MediaWikiServices instance.
+ *
+ * @since 1.28
+ *
+ * @note This is for use in PHPUnit tests only!
+ *
+ * @throws MWException if called outside of PHPUnit tests.
+ *
+ * @param MediaWikiServices $services The new MediaWikiServices object.
+ *
+ * @return MediaWikiServices The old MediaWikiServices object, so it can be restored later.
+ */
+ public static function forceGlobalInstance( MediaWikiServices $services ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException( __METHOD__ . ' must not be used outside unit tests.' );
+ }
+
+ $old = self::getInstance();
+ self::$instance = $services;
+
+ return $old;
+ }
+
+ /**
+ * Creates a new instance of MediaWikiServices and sets it as the global default
+ * instance. getInstance() will return a different MediaWikiServices object
+ * after every call to resetGlobalInstance().
+ *
+ * @since 1.28
+ *
+ * @warning This should not be used during normal operation. It is intended for use
+ * when the configuration has changed significantly since bootstrap time, e.g.
+ * during the installation process or during testing.
+ *
+ * @warning Calling resetGlobalInstance() may leave the application in an inconsistent
+ * state. Calling this is only safe under the ASSUMPTION that NO REFERENCE to
+ * any of the services managed by MediaWikiServices exist. If any service objects
+ * managed by the old MediaWikiServices instance remain in use, they may INTERFERE
+ * with the operation of the services managed by the new MediaWikiServices.
+ * Operating with a mix of services created by the old and the new
+ * MediaWikiServices instance may lead to INCONSISTENCIES and even DATA LOSS!
+ * Any class implementing LAZY LOADING is especially prone to this problem,
+ * since instances would typically retain a reference to a storage layer service.
+ *
+ * @see forceGlobalInstance()
+ * @see resetGlobalInstance()
+ * @see resetBetweenTest()
+ *
+ * @param Config|null $bootstrapConfig The Config object to be registered as the
+ * 'BootstrapConfig' service. This has to contain at least the information
+ * needed to set up the 'ConfigFactory' service. If not given, the bootstrap
+ * config of the old instance of MediaWikiServices will be re-used. If there
+ * was no previous instance, a new GlobalVarConfig object will be used to
+ * bootstrap the services.
+ *
+ * @param string $quick Set this to "quick" to allow expensive resources to be re-used.
+ * See SalvageableService for details.
+ *
+ * @throws MWException If called after MW_SERVICE_BOOTSTRAP_COMPLETE has been defined in
+ * Setup.php (unless MW_PHPUNIT_TEST or MEDIAWIKI_INSTALL or RUN_MAINTENANCE_IF_MAIN
+ * is defined).
+ */
+ public static function resetGlobalInstance( Config $bootstrapConfig = null, $quick = '' ) {
+ if ( self::$instance === null ) {
+ // no global instance yet, nothing to reset
+ return;
+ }
+
+ self::failIfResetNotAllowed( __METHOD__ );
+
+ if ( $bootstrapConfig === null ) {
+ $bootstrapConfig = self::$instance->getBootstrapConfig();
+ }
+
+ $oldInstance = self::$instance;
+
+ self::$instance = self::newInstance( $bootstrapConfig, 'load' );
+ self::$instance->importWiring( $oldInstance, [ 'BootstrapConfig' ] );
+
+ if ( $quick === 'quick' ) {
+ self::$instance->salvage( $oldInstance );
+ } else {
+ $oldInstance->destroy();
+ }
+ }
+
+ /**
+ * Salvages the state of any salvageable service instances in $other.
+ *
+ * @note $other will have been destroyed when salvage() returns.
+ *
+ * @param MediaWikiServices $other
+ */
+ private function salvage( self $other ) {
+ foreach ( $this->getServiceNames() as $name ) {
+ // The service could be new in the new instance and not registered in the
+ // other instance (e.g. an extension that was loaded after the instantiation of
+ // the other instance. Skip this service in this case. See T143974
+ try {
+ $oldService = $other->peekService( $name );
+ } catch ( NoSuchServiceException $e ) {
+ continue;
+ }
+
+ if ( $oldService instanceof SalvageableService ) {
+ /** @var SalvageableService $newService */
+ $newService = $this->getService( $name );
+ $newService->salvage( $oldService );
+ }
+ }
+
+ $other->destroy();
+ }
+
+ /**
+ * Creates a new MediaWikiServices instance and initializes it according to the
+ * given $bootstrapConfig. In particular, all wiring files defined in the
+ * ServiceWiringFiles setting are loaded, and the MediaWikiServices hook is called.
+ *
+ * @param Config|null $bootstrapConfig The Config object to be registered as the
+ * 'BootstrapConfig' service.
+ *
+ * @param string $loadWiring set this to 'load' to load the wiring files specified
+ * in the 'ServiceWiringFiles' setting in $bootstrapConfig.
+ *
+ * @return MediaWikiServices
+ * @throws MWException
+ * @throws \FatalError
+ */
+ private static function newInstance( Config $bootstrapConfig, $loadWiring = '' ) {
+ $instance = new self( $bootstrapConfig );
+
+ // Load the default wiring from the specified files.
+ if ( $loadWiring === 'load' ) {
+ $wiringFiles = $bootstrapConfig->get( 'ServiceWiringFiles' );
+ $instance->loadWiringFiles( $wiringFiles );
+ }
+
+ // Provide a traditional hook point to allow extensions to configure services.
+ Hooks::run( 'MediaWikiServices', [ $instance ] );
+
+ return $instance;
+ }
+
+ /**
+ * Disables all storage layer services. After calling this, any attempt to access the
+ * storage layer will result in an error. Use resetGlobalInstance() to restore normal
+ * operation.
+ *
+ * @since 1.28
+ *
+ * @warning This is intended for extreme situations only and should never be used
+ * while serving normal web requests. Legitimate use cases for this method include
+ * the installation process. Test fixtures may also use this, if the fixture relies
+ * on globalState.
+ *
+ * @see resetGlobalInstance()
+ * @see resetChildProcessServices()
+ */
+ public static function disableStorageBackend() {
+ // TODO: also disable some Caches, JobQueues, etc
+ $destroy = [ 'DBLoadBalancer', 'DBLoadBalancerFactory' ];
+ $services = self::getInstance();
+
+ foreach ( $destroy as $name ) {
+ $services->disableService( $name );
+ }
+
+ ObjectCache::clear();
+ }
+
+ /**
+ * Resets any services that may have become stale after a child process
+ * returns from after pcntl_fork(). It's also safe, but generally unnecessary,
+ * to call this method from the parent process.
+ *
+ * @since 1.28
+ *
+ * @note This is intended for use in the context of process forking only!
+ *
+ * @see resetGlobalInstance()
+ * @see disableStorageBackend()
+ */
+ public static function resetChildProcessServices() {
+ // NOTE: for now, just reset everything. Since we don't know the interdependencies
+ // between services, we can't do this more selectively at this time.
+ self::resetGlobalInstance();
+
+ // Child, reseed because there is no bug in PHP:
+ // https://bugs.php.net/bug.php?id=42465
+ mt_srand( getmypid() );
+ }
+
+ /**
+ * Resets the given service for testing purposes.
+ *
+ * @since 1.28
+ *
+ * @warning This is generally unsafe! Other services may still retain references
+ * to the stale service instance, leading to failures and inconsistencies. Subclasses
+ * may use this method to reset specific services under specific instances, but
+ * it should not be exposed to application logic.
+ *
+ * @note With proper dependency injection used throughout the codebase, this method
+ * should not be needed. It is provided to allow tests that pollute global service
+ * instances to clean up.
+ *
+ * @param string $name
+ * @param bool $destroy Whether the service instance should be destroyed if it exists.
+ * When set to false, any existing service instance will effectively be detached
+ * from the container.
+ *
+ * @throws MWException if called outside of PHPUnit tests.
+ */
+ public function resetServiceForTesting( $name, $destroy = true ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
+ throw new MWException( 'resetServiceForTesting() must not be used outside unit tests.' );
+ }
+
+ $this->resetService( $name, $destroy );
+ }
+
+ /**
+ * Convenience method that throws an exception unless it is called during a phase in which
+ * resetting of global services is allowed. In general, services should not be reset
+ * individually, since that may introduce inconsistencies.
+ *
+ * @since 1.28
+ *
+ * This method will throw an exception if:
+ *
+ * - self::$resetInProgress is false (to allow all services to be reset together
+ * via resetGlobalInstance)
+ * - and MEDIAWIKI_INSTALL is not defined (to allow services to be reset during installation)
+ * - and MW_PHPUNIT_TEST is not defined (to allow services to be reset during testing)
+ *
+ * This method is intended to be used to safeguard against accidentally resetting
+ * global service instances that are not yet managed by MediaWikiServices. It is
+ * defined here in the MediaWikiServices services class to have a central place
+ * for managing service bootstrapping and resetting.
+ *
+ * @param string $method the name of the caller method, as given by __METHOD__.
+ *
+ * @throws MWException if called outside bootstrap mode.
+ *
+ * @see resetGlobalInstance()
+ * @see forceGlobalInstance()
+ * @see disableStorageBackend()
+ */
+ public static function failIfResetNotAllowed( $method ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' )
+ && !defined( 'MW_PARSER_TEST' )
+ && !defined( 'MEDIAWIKI_INSTALL' )
+ && !defined( 'RUN_MAINTENANCE_IF_MAIN' )
+ && defined( 'MW_SERVICE_BOOTSTRAP_COMPLETE' )
+ ) {
+ throw new MWException( $method . ' may only be called during bootstrapping and unit tests!' );
+ }
+ }
+
+ /**
+ * @param Config $config The Config object to be registered as the 'BootstrapConfig' service.
+ * This has to contain at least the information needed to set up the 'ConfigFactory'
+ * service.
+ */
+ public function __construct( Config $config ) {
+ parent::__construct();
+
+ // Register the given Config object as the bootstrap config service.
+ $this->defineService( 'BootstrapConfig', function () use ( $config ) {
+ return $config;
+ } );
+ }
+
+ // CONVENIENCE GETTERS ////////////////////////////////////////////////////
+
+ /**
+ * Returns the Config object containing the bootstrap configuration.
+ * Bootstrap configuration would typically include database credentials
+ * and other information that may be needed before the ConfigFactory
+ * service can be instantiated.
+ *
+ * @note This should only be used during bootstrapping, in particular
+ * when creating the MainConfig service. Application logic should
+ * use getMainConfig() to get a Config instances.
+ *
+ * @since 1.27
+ * @return Config
+ */
+ public function getBootstrapConfig() {
+ return $this->getService( 'BootstrapConfig' );
+ }
+
+ /**
+ * @since 1.27
+ * @return ConfigFactory
+ */
+ public function getConfigFactory() {
+ return $this->getService( 'ConfigFactory' );
+ }
+
+ /**
+ * Returns the Config object that provides configuration for MediaWiki core.
+ * This may or may not be the same object that is returned by getBootstrapConfig().
+ *
+ * @since 1.27
+ * @return Config
+ */
+ public function getMainConfig() {
+ return $this->getService( 'MainConfig' );
+ }
+
+ /**
+ * @since 1.27
+ * @return SiteLookup
+ */
+ public function getSiteLookup() {
+ return $this->getService( 'SiteLookup' );
+ }
+
+ /**
+ * @since 1.27
+ * @return SiteStore
+ */
+ public function getSiteStore() {
+ return $this->getService( 'SiteStore' );
+ }
+
+ /**
+ * @since 1.28
+ * @return InterwikiLookup
+ */
+ public function getInterwikiLookup() {
+ return $this->getService( 'InterwikiLookup' );
+ }
+
+ /**
+ * @since 1.27
+ * @return IBufferingStatsdDataFactory
+ */
+ public function getStatsdDataFactory() {
+ return $this->getService( 'StatsdDataFactory' );
+ }
+
+ /**
+ * @since 1.27
+ * @return EventRelayerGroup
+ */
+ public function getEventRelayerGroup() {
+ return $this->getService( 'EventRelayerGroup' );
+ }
+
+ /**
+ * @since 1.27
+ * @return SearchEngine
+ */
+ public function newSearchEngine() {
+ // New engine object every time, since they keep state
+ return $this->getService( 'SearchEngineFactory' )->create();
+ }
+
+ /**
+ * @since 1.27
+ * @return SearchEngineFactory
+ */
+ public function getSearchEngineFactory() {
+ return $this->getService( 'SearchEngineFactory' );
+ }
+
+ /**
+ * @since 1.27
+ * @return SearchEngineConfig
+ */
+ public function getSearchEngineConfig() {
+ return $this->getService( 'SearchEngineConfig' );
+ }
+
+ /**
+ * @since 1.27
+ * @return SkinFactory
+ */
+ public function getSkinFactory() {
+ return $this->getService( 'SkinFactory' );
+ }
+
+ /**
+ * @since 1.28
+ * @return LBFactory
+ */
+ public function getDBLoadBalancerFactory() {
+ return $this->getService( 'DBLoadBalancerFactory' );
+ }
+
+ /**
+ * @since 1.28
+ * @return LoadBalancer The main DB load balancer for the local wiki.
+ */
+ public function getDBLoadBalancer() {
+ return $this->getService( 'DBLoadBalancer' );
+ }
+
+ /**
+ * @since 1.28
+ * @return WatchedItemStore
+ */
+ public function getWatchedItemStore() {
+ return $this->getService( 'WatchedItemStore' );
+ }
+
+ /**
+ * @since 1.28
+ * @return WatchedItemQueryService
+ */
+ public function getWatchedItemQueryService() {
+ return $this->getService( 'WatchedItemQueryService' );
+ }
+
+ /**
+ * @since 1.28
+ * @return CryptRand
+ */
+ public function getCryptRand() {
+ return $this->getService( 'CryptRand' );
+ }
+
+ /**
+ * @since 1.28
+ * @return CryptHKDF
+ */
+ public function getCryptHKDF() {
+ return $this->getService( 'CryptHKDF' );
+ }
+
+ /**
+ * @since 1.28
+ * @return MediaHandlerFactory
+ */
+ public function getMediaHandlerFactory() {
+ return $this->getService( 'MediaHandlerFactory' );
+ }
+
+ /**
+ * @since 1.28
+ * @return MimeAnalyzer
+ */
+ public function getMimeAnalyzer() {
+ return $this->getService( 'MimeAnalyzer' );
+ }
+
+ /**
+ * @since 1.28
+ * @return ProxyLookup
+ */
+ public function getProxyLookup() {
+ return $this->getService( 'ProxyLookup' );
+ }
+
+ /**
+ * @since 1.29
+ * @return Parser
+ */
+ public function getParser() {
+ return $this->getService( 'Parser' );
+ }
+
+ /**
+ * @since 1.30
+ * @return ParserCache
+ */
+ public function getParserCache() {
+ return $this->getService( 'ParserCache' );
+ }
+
+ /**
+ * @since 1.28
+ * @return GenderCache
+ */
+ public function getGenderCache() {
+ return $this->getService( 'GenderCache' );
+ }
+
+ /**
+ * @since 1.28
+ * @return LinkCache
+ */
+ public function getLinkCache() {
+ return $this->getService( 'LinkCache' );
+ }
+
+ /**
+ * @since 1.28
+ * @return LinkRendererFactory
+ */
+ public function getLinkRendererFactory() {
+ return $this->getService( 'LinkRendererFactory' );
+ }
+
+ /**
+ * LinkRenderer instance that can be used
+ * if no custom options are needed
+ *
+ * @since 1.28
+ * @return LinkRenderer
+ */
+ public function getLinkRenderer() {
+ return $this->getService( 'LinkRenderer' );
+ }
+
+ /**
+ * @since 1.28
+ * @return TitleFormatter
+ */
+ public function getTitleFormatter() {
+ return $this->getService( 'TitleFormatter' );
+ }
+
+ /**
+ * @since 1.28
+ * @return TitleParser
+ */
+ public function getTitleParser() {
+ return $this->getService( 'TitleParser' );
+ }
+
+ /**
+ * @since 1.28
+ * @return \BagOStuff
+ */
+ public function getMainObjectStash() {
+ return $this->getService( 'MainObjectStash' );
+ }
+
+ /**
+ * @since 1.28
+ * @return \WANObjectCache
+ */
+ public function getMainWANObjectCache() {
+ return $this->getService( 'MainWANObjectCache' );
+ }
+
+ /**
+ * @since 1.28
+ * @return \BagOStuff
+ */
+ public function getLocalServerObjectCache() {
+ return $this->getService( 'LocalServerObjectCache' );
+ }
+
+ /**
+ * @since 1.28
+ * @return VirtualRESTServiceClient
+ */
+ public function getVirtualRESTServiceClient() {
+ return $this->getService( 'VirtualRESTServiceClient' );
+ }
+
+ /**
+ * @since 1.29
+ * @return \ConfiguredReadOnlyMode
+ */
+ public function getConfiguredReadOnlyMode() {
+ return $this->getService( 'ConfiguredReadOnlyMode' );
+ }
+
+ /**
+ * @since 1.29
+ * @return \ReadOnlyMode
+ */
+ public function getReadOnlyMode() {
+ return $this->getService( 'ReadOnlyMode' );
+ }
+
+ /**
+ * @since 1.30
+ * @return CommandFactory
+ */
+ public function getShellCommandFactory() {
+ return $this->getService( 'ShellCommandFactory' );
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // NOTE: When adding a service getter here, don't forget to add a test
+ // case for it in MediaWikiServicesTest::provideGetters() and in
+ // MediaWikiServicesTest::provideGetService()!
+ ///////////////////////////////////////////////////////////////////////////
+
+}
diff --git a/www/wiki/includes/MediaWikiVersionFetcher.php b/www/wiki/includes/MediaWikiVersionFetcher.php
new file mode 100644
index 00000000..913ae9a5
--- /dev/null
+++ b/www/wiki/includes/MediaWikiVersionFetcher.php
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * Provides access to MediaWiki's version without requiring MediaWiki (or anything else)
+ * being loaded first.
+ *
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class MediaWikiVersionFetcher {
+
+ /**
+ * Returns the MediaWiki version, in the format used by MediaWiki's wgVersion global.
+ *
+ * @return string
+ * @throws RuntimeException
+ */
+ public function fetchVersion() {
+ $defaultSettings = file_get_contents( __DIR__ . '/DefaultSettings.php' );
+
+ $matches = [];
+ preg_match( "/wgVersion = '([0-9a-zA-Z\.\-]+)';/", $defaultSettings, $matches );
+
+ if ( count( $matches ) !== 2 ) {
+ throw new RuntimeException( 'Could not extract the MediaWiki version from DefaultSettings.php' );
+ }
+
+ return $matches[1];
+ }
+
+}
diff --git a/www/wiki/includes/MergeHistory.php b/www/wiki/includes/MergeHistory.php
new file mode 100644
index 00000000..9d638696
--- /dev/null
+++ b/www/wiki/includes/MergeHistory.php
@@ -0,0 +1,354 @@
+<?php
+
+/**
+ *
+ *
+ * Created on Dec 29, 2015
+ *
+ * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Wikimedia\Timestamp\TimestampException;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Handles the backend logic of merging the histories of two
+ * pages.
+ *
+ * @since 1.27
+ */
+class MergeHistory {
+
+ /** @const int Maximum number of revisions that can be merged at once */
+ const REVISION_LIMIT = 5000;
+
+ /** @var Title Page from which history will be merged */
+ protected $source;
+
+ /** @var Title Page to which history will be merged */
+ protected $dest;
+
+ /** @var IDatabase Database that we are using */
+ protected $dbw;
+
+ /** @var MWTimestamp Maximum timestamp that we can use (oldest timestamp of dest) */
+ protected $maxTimestamp;
+
+ /** @var string SQL WHERE condition that selects source revisions to insert into destination */
+ protected $timeWhere;
+
+ /** @var MWTimestamp|bool Timestamp upto which history from the source will be merged */
+ protected $timestampLimit;
+
+ /** @var int Number of revisions merged (for Special:MergeHistory success message) */
+ protected $revisionsMerged;
+
+ /**
+ * @param Title $source Page from which history will be merged
+ * @param Title $dest Page to which history will be merged
+ * @param string|bool $timestamp Timestamp up to which history from the source will be merged
+ */
+ public function __construct( Title $source, Title $dest, $timestamp = false ) {
+ // Save the parameters
+ $this->source = $source;
+ $this->dest = $dest;
+
+ // Get the database
+ $this->dbw = wfGetDB( DB_MASTER );
+
+ // Max timestamp should be min of destination page
+ $firstDestTimestamp = $this->dbw->selectField(
+ 'revision',
+ 'MIN(rev_timestamp)',
+ [ 'rev_page' => $this->dest->getArticleID() ],
+ __METHOD__
+ );
+ $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp );
+
+ // Get the timestamp pivot condition
+ try {
+ if ( $timestamp ) {
+ // If we have a requested timestamp, use the
+ // latest revision up to that point as the insertion point
+ $mwTimestamp = new MWTimestamp( $timestamp );
+ $lastWorkingTimestamp = $this->dbw->selectField(
+ 'revision',
+ 'MAX(rev_timestamp)',
+ [
+ 'rev_timestamp <= ' .
+ $this->dbw->addQuotes( $this->dbw->timestamp( $mwTimestamp ) ),
+ 'rev_page' => $this->source->getArticleID()
+ ],
+ __METHOD__
+ );
+ $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
+
+ $timeInsert = $mwLastWorkingTimestamp;
+ $this->timestampLimit = $mwLastWorkingTimestamp;
+ } else {
+ // If we don't, merge entire source page history into the
+ // beginning of destination page history
+
+ // Get the latest timestamp of the source
+ $lastSourceTimestamp = $this->dbw->selectField(
+ [ 'page', 'revision' ],
+ 'rev_timestamp',
+ [ 'page_id' => $this->source->getArticleID(),
+ 'page_latest = rev_id'
+ ],
+ __METHOD__
+ );
+ $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
+
+ $timeInsert = $this->maxTimestamp;
+ $this->timestampLimit = $lasttimestamp;
+ }
+
+ $this->timeWhere = "rev_timestamp <= " .
+ $this->dbw->addQuotes( $this->dbw->timestamp( $timeInsert ) );
+ } catch ( TimestampException $ex ) {
+ // The timestamp we got is screwed up and merge cannot continue
+ // This should be detected by $this->isValidMerge()
+ $this->timestampLimit = false;
+ }
+ }
+
+ /**
+ * Get the number of revisions that will be moved
+ * @return int
+ */
+ public function getRevisionCount() {
+ $count = $this->dbw->selectRowCount( 'revision', '1',
+ [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
+ __METHOD__,
+ [ 'LIMIT' => self::REVISION_LIMIT + 1 ]
+ );
+
+ return $count;
+ }
+
+ /**
+ * Get the number of revisions that were moved
+ * Used in the SpecialMergeHistory success message
+ * @return int
+ */
+ public function getMergedRevisionCount() {
+ return $this->revisionsMerged;
+ }
+
+ /**
+ * Check if the merge is possible
+ * @param User $user
+ * @param string $reason
+ * @return Status
+ */
+ public function checkPermissions( User $user, $reason ) {
+ $status = new Status();
+
+ // Check if user can edit both pages
+ $errors = wfMergeErrorArrays(
+ $this->source->getUserPermissionsErrors( 'edit', $user ),
+ $this->dest->getUserPermissionsErrors( 'edit', $user )
+ );
+
+ // Convert into a Status object
+ if ( $errors ) {
+ foreach ( $errors as $error ) {
+ call_user_func_array( [ $status, 'fatal' ], $error );
+ }
+ }
+
+ // Anti-spam
+ if ( EditPage::matchSummarySpamRegex( $reason ) !== false ) {
+ // This is kind of lame, won't display nice
+ $status->fatal( 'spamprotectiontext' );
+ }
+
+ // Check mergehistory permission
+ if ( !$user->isAllowed( 'mergehistory' ) ) {
+ // User doesn't have the right to merge histories
+ $status->fatal( 'mergehistory-fail-permission' );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Does various sanity checks that the merge is
+ * valid. Only things based on the two pages
+ * should be checked here.
+ *
+ * @return Status
+ */
+ public function isValidMerge() {
+ $status = new Status();
+
+ // If either article ID is 0, then revisions cannot be reliably selected
+ if ( $this->source->getArticleID() === 0 ) {
+ $status->fatal( 'mergehistory-fail-invalid-source' );
+ }
+ if ( $this->dest->getArticleID() === 0 ) {
+ $status->fatal( 'mergehistory-fail-invalid-dest' );
+ }
+
+ // Make sure page aren't the same
+ if ( $this->source->equals( $this->dest ) ) {
+ $status->fatal( 'mergehistory-fail-self-merge' );
+ }
+
+ // Make sure the timestamp is valid
+ if ( !$this->timestampLimit ) {
+ $status->fatal( 'mergehistory-fail-bad-timestamp' );
+ }
+
+ // $this->timestampLimit must be older than $this->maxTimestamp
+ if ( $this->timestampLimit > $this->maxTimestamp ) {
+ $status->fatal( 'mergehistory-fail-timestamps-overlap' );
+ }
+
+ // Check that there are not too many revisions to move
+ if ( $this->timestampLimit && $this->getRevisionCount() > self::REVISION_LIMIT ) {
+ $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Actually attempt the history move
+ *
+ * @todo if all versions of page A are moved to B and then a user
+ * tries to do a reverse-merge via the "unmerge" log link, then page
+ * A will still be a redirect (as it was after the original merge),
+ * though it will have the old revisions back from before (as expected).
+ * The user may have to "undo" the redirect manually to finish the "unmerge".
+ * Maybe this should delete redirects at the source page of merges?
+ *
+ * @param User $user
+ * @param string $reason
+ * @return Status status of the history merge
+ */
+ public function merge( User $user, $reason = '' ) {
+ $status = new Status();
+
+ // Check validity and permissions required for merge
+ $validCheck = $this->isValidMerge(); // Check this first to check for null pages
+ if ( !$validCheck->isOK() ) {
+ return $validCheck;
+ }
+ $permCheck = $this->checkPermissions( $user, $reason );
+ if ( !$permCheck->isOK() ) {
+ return $permCheck;
+ }
+
+ $this->dbw->update(
+ 'revision',
+ [ 'rev_page' => $this->dest->getArticleID() ],
+ [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
+ __METHOD__
+ );
+
+ // Check if this did anything
+ $this->revisionsMerged = $this->dbw->affectedRows();
+ if ( $this->revisionsMerged < 1 ) {
+ $status->fatal( 'mergehistory-fail-no-change' );
+ return $status;
+ }
+
+ // Make the source page a redirect if no revisions are left
+ $haveRevisions = $this->dbw->selectField(
+ 'revision',
+ 'rev_timestamp',
+ [ 'rev_page' => $this->source->getArticleID() ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ if ( !$haveRevisions ) {
+ if ( $reason ) {
+ $reason = wfMessage(
+ 'mergehistory-comment',
+ $this->source->getPrefixedText(),
+ $this->dest->getPrefixedText(),
+ $reason
+ )->inContentLanguage()->text();
+ } else {
+ $reason = wfMessage(
+ 'mergehistory-autocomment',
+ $this->source->getPrefixedText(),
+ $this->dest->getPrefixedText()
+ )->inContentLanguage()->text();
+ }
+
+ $contentHandler = ContentHandler::getForTitle( $this->source );
+ $redirectContent = $contentHandler->makeRedirectContent(
+ $this->dest,
+ wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain()
+ );
+
+ if ( $redirectContent ) {
+ $redirectPage = WikiPage::factory( $this->source );
+ $redirectRevision = new Revision( [
+ 'title' => $this->source,
+ 'page' => $this->source->getArticleID(),
+ 'comment' => $reason,
+ 'content' => $redirectContent ] );
+ $redirectRevision->insertOn( $this->dbw );
+ $redirectPage->updateRevisionOn( $this->dbw, $redirectRevision );
+
+ // Now, we record the link from the redirect to the new title.
+ // It should have no other outgoing links...
+ $this->dbw->delete(
+ 'pagelinks',
+ [ 'pl_from' => $this->dest->getArticleID() ],
+ __METHOD__
+ );
+ $this->dbw->insert( 'pagelinks',
+ [
+ 'pl_from' => $this->dest->getArticleID(),
+ 'pl_from_namespace' => $this->dest->getNamespace(),
+ 'pl_namespace' => $this->dest->getNamespace(),
+ 'pl_title' => $this->dest->getDBkey() ],
+ __METHOD__
+ );
+ } else {
+ // Warning if we couldn't create the redirect
+ $status->warning( 'mergehistory-warning-redirect-not-created' );
+ }
+ } else {
+ $this->source->invalidateCache(); // update histories
+ }
+ $this->dest->invalidateCache(); // update histories
+
+ // Update our logs
+ $logEntry = new ManualLogEntry( 'merge', 'merge' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setComment( $reason );
+ $logEntry->setTarget( $this->source );
+ $logEntry->setParameters( [
+ '4::dest' => $this->dest->getPrefixedText(),
+ '5::mergepoint' => $this->timestampLimit->getTimestamp( TS_MW )
+ ] );
+ $logId = $logEntry->insert();
+ $logEntry->publish( $logId );
+
+ Hooks::run( 'ArticleMergeComplete', [ $this->source, $this->dest ] );
+
+ return $status;
+ }
+}
diff --git a/www/wiki/includes/Message.php b/www/wiki/includes/Message.php
new file mode 100644
index 00000000..677efb5d
--- /dev/null
+++ b/www/wiki/includes/Message.php
@@ -0,0 +1,1364 @@
+<?php
+/**
+ * Fetching and processing of interface messages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ */
+
+/**
+ * The Message class provides methods which fulfil two basic services:
+ * - fetching interface messages
+ * - processing messages into a variety of formats
+ *
+ * First implemented with MediaWiki 1.17, the Message class is intended to
+ * replace the old wfMsg* functions that over time grew unusable.
+ * @see https://www.mediawiki.org/wiki/Manual:Messages_API for equivalences
+ * between old and new functions.
+ *
+ * You should use the wfMessage() global function which acts as a wrapper for
+ * the Message class. The wrapper let you pass parameters as arguments.
+ *
+ * The most basic usage cases would be:
+ *
+ * @code
+ * // Initialize a Message object using the 'some_key' message key
+ * $message = wfMessage( 'some_key' );
+ *
+ * // Using two parameters those values are strings 'value1' and 'value2':
+ * $message = wfMessage( 'some_key',
+ * 'value1', 'value2'
+ * );
+ * @endcode
+ *
+ * @section message_global_fn Global function wrapper:
+ *
+ * Since wfMessage() returns a Message instance, you can chain its call with
+ * a method. Some of them return a Message instance too so you can chain them.
+ * You will find below several examples of wfMessage() usage.
+ *
+ * Fetching a message text for interface message:
+ *
+ * @code
+ * $button = Xml::button(
+ * wfMessage( 'submit' )->text()
+ * );
+ * @endcode
+ *
+ * A Message instance can be passed parameters after it has been constructed,
+ * use the params() method to do so:
+ *
+ * @code
+ * wfMessage( 'welcome-to' )
+ * ->params( $wgSitename )
+ * ->text();
+ * @endcode
+ *
+ * {{GRAMMAR}} and friends work correctly:
+ *
+ * @code
+ * wfMessage( 'are-friends',
+ * $user, $friend
+ * );
+ * wfMessage( 'bad-message' )
+ * ->rawParams( '<script>...</script>' )
+ * ->escaped();
+ * @endcode
+ *
+ * @section message_language Changing language:
+ *
+ * Messages can be requested in a different language or in whatever current
+ * content language is being used. The methods are:
+ * - Message->inContentLanguage()
+ * - Message->inLanguage()
+ *
+ * Sometimes the message text ends up in the database, so content language is
+ * needed:
+ *
+ * @code
+ * wfMessage( 'file-log',
+ * $user, $filename
+ * )->inContentLanguage()->text();
+ * @endcode
+ *
+ * Checking whether a message exists:
+ *
+ * @code
+ * wfMessage( 'mysterious-message' )->exists()
+ * // returns a boolean whether the 'mysterious-message' key exist.
+ * @endcode
+ *
+ * If you want to use a different language:
+ *
+ * @code
+ * $userLanguage = $user->getOption( 'language' );
+ * wfMessage( 'email-header' )
+ * ->inLanguage( $userLanguage )
+ * ->plain();
+ * @endcode
+ *
+ * @note You can parse the text only in the content or interface languages
+ *
+ * @section message_compare_old Comparison with old wfMsg* functions:
+ *
+ * Use full parsing:
+ *
+ * @code
+ * // old style:
+ * wfMsgExt( 'key', [ 'parseinline' ], 'apple' );
+ * // new style:
+ * wfMessage( 'key', 'apple' )->parse();
+ * @endcode
+ *
+ * Parseinline is used because it is more useful when pre-building HTML.
+ * In normal use it is better to use OutputPage::(add|wrap)WikiMsg.
+ *
+ * Places where HTML cannot be used. {{-transformation is done.
+ * @code
+ * // old style:
+ * wfMsgExt( 'key', [ 'parsemag' ], 'apple', 'pear' );
+ * // new style:
+ * wfMessage( 'key', 'apple', 'pear' )->text();
+ * @endcode
+ *
+ * Shortcut for escaping the message too, similar to wfMsgHTML(), but
+ * parameters are not replaced after escaping by default.
+ * @code
+ * $escaped = wfMessage( 'key' )
+ * ->rawParams( 'apple' )
+ * ->escaped();
+ * @endcode
+ *
+ * @section message_appendix Appendix:
+ *
+ * @todo
+ * - test, can we have tests?
+ * - this documentation needs to be extended
+ *
+ * @see https://www.mediawiki.org/wiki/WfMessage()
+ * @see https://www.mediawiki.org/wiki/New_messages_API
+ * @see https://www.mediawiki.org/wiki/Localisation
+ *
+ * @since 1.17
+ */
+class Message implements MessageSpecifier, Serializable {
+ /** Use message text as-is */
+ const FORMAT_PLAIN = 'plain';
+ /** Use normal wikitext -> HTML parsing (the result will be wrapped in a block-level HTML tag) */
+ const FORMAT_BLOCK_PARSE = 'block-parse';
+ /** Use normal wikitext -> HTML parsing but strip the block-level wrapper */
+ const FORMAT_PARSE = 'parse';
+ /** Transform {{..}} constructs but don't transform to HTML */
+ const FORMAT_TEXT = 'text';
+ /** Transform {{..}} constructs, HTML-escape the result */
+ const FORMAT_ESCAPED = 'escaped';
+
+ /**
+ * Mapping from Message::listParam() types to Language methods.
+ * @var array
+ */
+ protected static $listTypeMap = [
+ 'comma' => 'commaList',
+ 'semicolon' => 'semicolonList',
+ 'pipe' => 'pipeList',
+ 'text' => 'listToText',
+ ];
+
+ /**
+ * In which language to get this message. True, which is the default,
+ * means the current user language, false content language.
+ *
+ * @var bool
+ */
+ protected $interface = true;
+
+ /**
+ * In which language to get this message. Overrides the $interface setting.
+ *
+ * @var Language|bool Explicit language object, or false for user language
+ */
+ protected $language = false;
+
+ /**
+ * @var string The message key. If $keysToTry has more than one element,
+ * this may change to one of the keys to try when fetching the message text.
+ */
+ protected $key;
+
+ /**
+ * @var string[] List of keys to try when fetching the message.
+ */
+ protected $keysToTry;
+
+ /**
+ * @var array List of parameters which will be substituted into the message.
+ */
+ protected $parameters = [];
+
+ /**
+ * @var string
+ * @deprecated
+ */
+ protected $format = 'parse';
+
+ /**
+ * @var bool Whether database can be used.
+ */
+ protected $useDatabase = true;
+
+ /**
+ * @var Title Title object to use as context.
+ */
+ protected $title = null;
+
+ /**
+ * @var Content Content object representing the message.
+ */
+ protected $content = null;
+
+ /**
+ * @var string
+ */
+ protected $message;
+
+ /**
+ * @since 1.17
+ * @param string|string[]|MessageSpecifier $key Message key, or array of
+ * message keys to try and use the first non-empty message for, or a
+ * MessageSpecifier to copy from.
+ * @param array $params Message parameters.
+ * @param Language $language [optional] Language to use (defaults to current user language).
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $key, $params = [], Language $language = null ) {
+ if ( $key instanceof MessageSpecifier ) {
+ if ( $params ) {
+ throw new InvalidArgumentException(
+ '$params must be empty if $key is a MessageSpecifier'
+ );
+ }
+ $params = $key->getParams();
+ $key = $key->getKey();
+ }
+
+ if ( !is_string( $key ) && !is_array( $key ) ) {
+ throw new InvalidArgumentException( '$key must be a string or an array' );
+ }
+
+ $this->keysToTry = (array)$key;
+
+ if ( empty( $this->keysToTry ) ) {
+ throw new InvalidArgumentException( '$key must not be an empty list' );
+ }
+
+ $this->key = reset( $this->keysToTry );
+
+ $this->parameters = array_values( $params );
+ // User language is only resolved in getLanguage(). This helps preserve the
+ // semantic intent of "user language" across serialize() and unserialize().
+ $this->language = $language ?: false;
+ }
+
+ /**
+ * @see Serializable::serialize()
+ * @since 1.26
+ * @return string
+ */
+ public function serialize() {
+ return serialize( [
+ 'interface' => $this->interface,
+ 'language' => $this->language ? $this->language->getCode() : false,
+ 'key' => $this->key,
+ 'keysToTry' => $this->keysToTry,
+ 'parameters' => $this->parameters,
+ 'format' => $this->format,
+ 'useDatabase' => $this->useDatabase,
+ 'title' => $this->title,
+ ] );
+ }
+
+ /**
+ * @see Serializable::unserialize()
+ * @since 1.26
+ * @param string $serialized
+ */
+ public function unserialize( $serialized ) {
+ $data = unserialize( $serialized );
+ $this->interface = $data['interface'];
+ $this->key = $data['key'];
+ $this->keysToTry = $data['keysToTry'];
+ $this->parameters = $data['parameters'];
+ $this->format = $data['format'];
+ $this->useDatabase = $data['useDatabase'];
+ $this->language = $data['language'] ? Language::factory( $data['language'] ) : false;
+ $this->title = $data['title'];
+ }
+
+ /**
+ * @since 1.24
+ *
+ * @return bool True if this is a multi-key message, that is, if the key provided to the
+ * constructor was a fallback list of keys to try.
+ */
+ public function isMultiKey() {
+ return count( $this->keysToTry ) > 1;
+ }
+
+ /**
+ * @since 1.24
+ *
+ * @return string[] The list of keys to try when fetching the message text,
+ * in order of preference.
+ */
+ public function getKeysToTry() {
+ return $this->keysToTry;
+ }
+
+ /**
+ * Returns the message key.
+ *
+ * If a list of multiple possible keys was supplied to the constructor, this method may
+ * return any of these keys. After the message has been fetched, this method will return
+ * the key that was actually used to fetch the message.
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getKey() {
+ return $this->key;
+ }
+
+ /**
+ * Returns the message parameters.
+ *
+ * @since 1.21
+ *
+ * @return array
+ */
+ public function getParams() {
+ return $this->parameters;
+ }
+
+ /**
+ * Returns the message format.
+ *
+ * @since 1.21
+ *
+ * @return string
+ * @deprecated since 1.29 formatting is not stateful
+ */
+ public function getFormat() {
+ wfDeprecated( __METHOD__, '1.29' );
+ return $this->format;
+ }
+
+ /**
+ * Returns the Language of the Message.
+ *
+ * @since 1.23
+ *
+ * @return Language
+ */
+ public function getLanguage() {
+ // Defaults to false which means current user language
+ return $this->language ?: RequestContext::getMain()->getLanguage();
+ }
+
+ /**
+ * Factory function that is just wrapper for the real constructor. It is
+ * intended to be used instead of the real constructor, because it allows
+ * chaining method calls, while new objects don't.
+ *
+ * @since 1.17
+ *
+ * @param string|string[]|MessageSpecifier $key
+ * @param mixed $param,... Parameters as strings.
+ *
+ * @return Message
+ */
+ public static function newFromKey( $key /*...*/ ) {
+ $params = func_get_args();
+ array_shift( $params );
+ return new self( $key, $params );
+ }
+
+ /**
+ * Transform a MessageSpecifier or a primitive value used interchangeably with
+ * specifiers (a message key string, or a key + params array) into a proper Message.
+ *
+ * Also accepts a MessageSpecifier inside an array: that's not considered a valid format
+ * but is an easy error to make due to how StatusValue stores messages internally.
+ * Further array elements are ignored in that case.
+ *
+ * @param string|array|MessageSpecifier $value
+ * @return Message
+ * @throws InvalidArgumentException
+ * @since 1.27
+ */
+ public static function newFromSpecifier( $value ) {
+ $params = [];
+ if ( is_array( $value ) ) {
+ $params = $value;
+ $value = array_shift( $params );
+ }
+
+ if ( $value instanceof Message ) { // Message, RawMessage, ApiMessage, etc
+ $message = clone $value;
+ } elseif ( $value instanceof MessageSpecifier ) {
+ $message = new Message( $value );
+ } elseif ( is_string( $value ) ) {
+ $message = new Message( $value, $params );
+ } else {
+ throw new InvalidArgumentException( __METHOD__ . ': invalid argument type '
+ . gettype( $value ) );
+ }
+
+ return $message;
+ }
+
+ /**
+ * Factory function accepting multiple message keys and returning a message instance
+ * for the first message which is non-empty. If all messages are empty then an
+ * instance of the first message key is returned.
+ *
+ * @since 1.18
+ *
+ * @param string|string[] $keys,... Message keys, or first argument as an array of all the
+ * message keys.
+ *
+ * @return Message
+ */
+ public static function newFallbackSequence( /*...*/ ) {
+ $keys = func_get_args();
+ if ( func_num_args() == 1 ) {
+ if ( is_array( $keys[0] ) ) {
+ // Allow an array to be passed as the first argument instead
+ $keys = array_values( $keys[0] );
+ } else {
+ // Optimize a single string to not need special fallback handling
+ $keys = $keys[0];
+ }
+ }
+ return new self( $keys );
+ }
+
+ /**
+ * Get a title object for a mediawiki message, where it can be found in the mediawiki namespace.
+ * The title will be for the current language, if the message key is in
+ * $wgForceUIMsgAsContentMsg it will be append with the language code (except content
+ * language), because Message::inContentLanguage will also return in user language.
+ *
+ * @see $wgForceUIMsgAsContentMsg
+ * @return Title
+ * @since 1.26
+ */
+ public function getTitle() {
+ global $wgContLang, $wgForceUIMsgAsContentMsg;
+
+ $title = $this->key;
+ if (
+ !$this->language->equals( $wgContLang )
+ && in_array( $this->key, (array)$wgForceUIMsgAsContentMsg )
+ ) {
+ $code = $this->language->getCode();
+ $title .= '/' . $code;
+ }
+
+ return Title::makeTitle( NS_MEDIAWIKI, $wgContLang->ucfirst( strtr( $title, ' ', '_' ) ) );
+ }
+
+ /**
+ * Adds parameters to the parameter list of this message.
+ *
+ * @since 1.17
+ *
+ * @param mixed $args,... Parameters as strings or arrays from
+ * Message::numParam() and the like, or a single array of parameters.
+ *
+ * @return Message $this
+ */
+ public function params( /*...*/ ) {
+ $args = func_get_args();
+
+ // If $args has only one entry and it's an array, then it's either a
+ // non-varargs call or it happens to be a call with just a single
+ // "special" parameter. Since the "special" parameters don't have any
+ // numeric keys, we'll test that to differentiate the cases.
+ if ( count( $args ) === 1 && isset( $args[0] ) && is_array( $args[0] ) ) {
+ if ( $args[0] === [] ) {
+ $args = [];
+ } else {
+ foreach ( $args[0] as $key => $value ) {
+ if ( is_int( $key ) ) {
+ $args = $args[0];
+ break;
+ }
+ }
+ }
+ }
+
+ $this->parameters = array_merge( $this->parameters, array_values( $args ) );
+ return $this;
+ }
+
+ /**
+ * Add parameters that are substituted after parsing or escaping.
+ * In other words the parsing process cannot access the contents
+ * of this type of parameter, and you need to make sure it is
+ * sanitized beforehand. The parser will see "$n", instead.
+ *
+ * @since 1.17
+ *
+ * @param mixed $params,... Raw parameters as strings, or a single argument that is
+ * an array of raw parameters.
+ *
+ * @return Message $this
+ */
+ public function rawParams( /*...*/ ) {
+ $params = func_get_args();
+ if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+ foreach ( $params as $param ) {
+ $this->parameters[] = self::rawParam( $param );
+ }
+ return $this;
+ }
+
+ /**
+ * Add parameters that are numeric and will be passed through
+ * Language::formatNum before substitution
+ *
+ * @since 1.18
+ *
+ * @param mixed $param,... Numeric parameters, or a single argument that is
+ * an array of numeric parameters.
+ *
+ * @return Message $this
+ */
+ public function numParams( /*...*/ ) {
+ $params = func_get_args();
+ if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+ foreach ( $params as $param ) {
+ $this->parameters[] = self::numParam( $param );
+ }
+ return $this;
+ }
+
+ /**
+ * Add parameters that are durations of time and will be passed through
+ * Language::formatDuration before substitution
+ *
+ * @since 1.22
+ *
+ * @param int|int[] $param,... Duration parameters, or a single argument that is
+ * an array of duration parameters.
+ *
+ * @return Message $this
+ */
+ public function durationParams( /*...*/ ) {
+ $params = func_get_args();
+ if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+ foreach ( $params as $param ) {
+ $this->parameters[] = self::durationParam( $param );
+ }
+ return $this;
+ }
+
+ /**
+ * Add parameters that are expiration times and will be passed through
+ * Language::formatExpiry before substitution
+ *
+ * @since 1.22
+ *
+ * @param string|string[] $param,... Expiry parameters, or a single argument that is
+ * an array of expiry parameters.
+ *
+ * @return Message $this
+ */
+ public function expiryParams( /*...*/ ) {
+ $params = func_get_args();
+ if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+ foreach ( $params as $param ) {
+ $this->parameters[] = self::expiryParam( $param );
+ }
+ return $this;
+ }
+
+ /**
+ * Add parameters that are time periods and will be passed through
+ * Language::formatTimePeriod before substitution
+ *
+ * @since 1.22
+ *
+ * @param int|int[] $param,... Time period parameters, or a single argument that is
+ * an array of time period parameters.
+ *
+ * @return Message $this
+ */
+ public function timeperiodParams( /*...*/ ) {
+ $params = func_get_args();
+ if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+ foreach ( $params as $param ) {
+ $this->parameters[] = self::timeperiodParam( $param );
+ }
+ return $this;
+ }
+
+ /**
+ * Add parameters that are file sizes and will be passed through
+ * Language::formatSize before substitution
+ *
+ * @since 1.22
+ *
+ * @param int|int[] $param,... Size parameters, or a single argument that is
+ * an array of size parameters.
+ *
+ * @return Message $this
+ */
+ public function sizeParams( /*...*/ ) {
+ $params = func_get_args();
+ if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+ foreach ( $params as $param ) {
+ $this->parameters[] = self::sizeParam( $param );
+ }
+ return $this;
+ }
+
+ /**
+ * Add parameters that are bitrates and will be passed through
+ * Language::formatBitrate before substitution
+ *
+ * @since 1.22
+ *
+ * @param int|int[] $param,... Bit rate parameters, or a single argument that is
+ * an array of bit rate parameters.
+ *
+ * @return Message $this
+ */
+ public function bitrateParams( /*...*/ ) {
+ $params = func_get_args();
+ if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+ foreach ( $params as $param ) {
+ $this->parameters[] = self::bitrateParam( $param );
+ }
+ return $this;
+ }
+
+ /**
+ * Add parameters that are plaintext and will be passed through without
+ * the content being evaluated. Plaintext parameters are not valid as
+ * arguments to parser functions. This differs from self::rawParams in
+ * that the Message class handles escaping to match the output format.
+ *
+ * @since 1.25
+ *
+ * @param string|string[] $param,... plaintext parameters, or a single argument that is
+ * an array of plaintext parameters.
+ *
+ * @return Message $this
+ */
+ public function plaintextParams( /*...*/ ) {
+ $params = func_get_args();
+ if ( isset( $params[0] ) && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+ foreach ( $params as $param ) {
+ $this->parameters[] = self::plaintextParam( $param );
+ }
+ return $this;
+ }
+
+ /**
+ * Set the language and the title from a context object
+ *
+ * @since 1.19
+ *
+ * @param IContextSource $context
+ *
+ * @return Message $this
+ */
+ public function setContext( IContextSource $context ) {
+ $this->inLanguage( $context->getLanguage() );
+ $this->title( $context->getTitle() );
+ $this->interface = true;
+
+ return $this;
+ }
+
+ /**
+ * Request the message in any language that is supported.
+ *
+ * As a side effect interface message status is unconditionally
+ * turned off.
+ *
+ * @since 1.17
+ * @param Language|string $lang Language code or Language object.
+ * @return Message $this
+ * @throws MWException
+ */
+ public function inLanguage( $lang ) {
+ if ( $lang instanceof Language ) {
+ $this->language = $lang;
+ } elseif ( is_string( $lang ) ) {
+ if ( !$this->language instanceof Language || $this->language->getCode() != $lang ) {
+ $this->language = Language::factory( $lang );
+ }
+ } elseif ( $lang instanceof StubUserLang ) {
+ $this->language = false;
+ } else {
+ $type = gettype( $lang );
+ throw new MWException( __METHOD__ . " must be "
+ . "passed a String or Language object; $type given"
+ );
+ }
+ $this->message = null;
+ $this->interface = false;
+ return $this;
+ }
+
+ /**
+ * Request the message in the wiki's content language,
+ * unless it is disabled for this message.
+ *
+ * @since 1.17
+ * @see $wgForceUIMsgAsContentMsg
+ *
+ * @return Message $this
+ */
+ public function inContentLanguage() {
+ global $wgForceUIMsgAsContentMsg;
+ if ( in_array( $this->key, (array)$wgForceUIMsgAsContentMsg ) ) {
+ return $this;
+ }
+
+ global $wgContLang;
+ $this->inLanguage( $wgContLang );
+ return $this;
+ }
+
+ /**
+ * Allows manipulating the interface message flag directly.
+ * Can be used to restore the flag after setting a language.
+ *
+ * @since 1.20
+ *
+ * @param bool $interface
+ *
+ * @return Message $this
+ */
+ public function setInterfaceMessageFlag( $interface ) {
+ $this->interface = (bool)$interface;
+ return $this;
+ }
+
+ /**
+ * Enable or disable database use.
+ *
+ * @since 1.17
+ *
+ * @param bool $useDatabase
+ *
+ * @return Message $this
+ */
+ public function useDatabase( $useDatabase ) {
+ $this->useDatabase = (bool)$useDatabase;
+ $this->message = null;
+ return $this;
+ }
+
+ /**
+ * Set the Title object to use as context when transforming the message
+ *
+ * @since 1.18
+ *
+ * @param Title $title
+ *
+ * @return Message $this
+ */
+ public function title( $title ) {
+ $this->title = $title;
+ return $this;
+ }
+
+ /**
+ * Returns the message as a Content object.
+ *
+ * @return Content
+ */
+ public function content() {
+ if ( !$this->content ) {
+ $this->content = new MessageContent( $this );
+ }
+
+ return $this->content;
+ }
+
+ /**
+ * Returns the message parsed from wikitext to HTML.
+ *
+ * @since 1.17
+ *
+ * @param string|null $format One of the FORMAT_* constants. Null means use whatever was used
+ * the last time (this is for B/C and should be avoided).
+ *
+ * @return string HTML
+ */
+ public function toString( $format = null ) {
+ if ( $format === null ) {
+ $ex = new LogicException( __METHOD__ . ' using implicit format: ' . $this->format );
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'message-format' )->warning(
+ $ex->getMessage(), [ 'exception' => $ex, 'format' => $this->format, 'key' => $this->key ] );
+ $format = $this->format;
+ }
+ $string = $this->fetchMessage();
+
+ if ( $string === false ) {
+ // Err on the side of safety, ensure that the output
+ // is always html safe in the event the message key is
+ // missing, since in that case its highly likely the
+ // message key is user-controlled.
+ // '⧼' is used instead of '<' to side-step any
+ // double-escaping issues.
+ // (Keep synchronised with mw.Message#toString in JS.)
+ return '⧼' . htmlspecialchars( $this->key ) . '⧽';
+ }
+
+ # Replace $* with a list of parameters for &uselang=qqx.
+ if ( strpos( $string, '$*' ) !== false ) {
+ $paramlist = '';
+ if ( $this->parameters !== [] ) {
+ $paramlist = ': $' . implode( ', $', range( 1, count( $this->parameters ) ) );
+ }
+ $string = str_replace( '$*', $paramlist, $string );
+ }
+
+ # Replace parameters before text parsing
+ $string = $this->replaceParameters( $string, 'before', $format );
+
+ # Maybe transform using the full parser
+ if ( $format === self::FORMAT_PARSE ) {
+ $string = $this->parseText( $string );
+ $string = Parser::stripOuterParagraph( $string );
+ } elseif ( $format === self::FORMAT_BLOCK_PARSE ) {
+ $string = $this->parseText( $string );
+ } elseif ( $format === self::FORMAT_TEXT ) {
+ $string = $this->transformText( $string );
+ } elseif ( $format === self::FORMAT_ESCAPED ) {
+ $string = $this->transformText( $string );
+ $string = htmlspecialchars( $string, ENT_QUOTES, 'UTF-8', false );
+ }
+
+ # Raw parameter replacement
+ $string = $this->replaceParameters( $string, 'after', $format );
+
+ return $string;
+ }
+
+ /**
+ * Magic method implementation of the above (for PHP >= 5.2.0), so we can do, eg:
+ * $foo = new Message( $key );
+ * $string = "<abbr>$foo</abbr>";
+ *
+ * @since 1.18
+ *
+ * @return string
+ */
+ public function __toString() {
+ // PHP doesn't allow __toString to throw exceptions and will
+ // trigger a fatal error if it does. So, catch any exceptions.
+
+ try {
+ return $this->toString( self::FORMAT_PARSE );
+ } catch ( Exception $ex ) {
+ try {
+ trigger_error( "Exception caught in " . __METHOD__ . " (message " . $this->key . "): "
+ . $ex, E_USER_WARNING );
+ } catch ( Exception $ex ) {
+ // Doh! Cause a fatal error after all?
+ }
+
+ return '⧼' . htmlspecialchars( $this->key ) . '⧽';
+ }
+ }
+
+ /**
+ * Fully parse the text from wikitext to HTML.
+ *
+ * @since 1.17
+ *
+ * @return string Parsed HTML.
+ */
+ public function parse() {
+ $this->format = self::FORMAT_PARSE;
+ return $this->toString( self::FORMAT_PARSE );
+ }
+
+ /**
+ * Returns the message text. {{-transformation is done.
+ *
+ * @since 1.17
+ *
+ * @return string Unescaped message text.
+ */
+ public function text() {
+ $this->format = self::FORMAT_TEXT;
+ return $this->toString( self::FORMAT_TEXT );
+ }
+
+ /**
+ * Returns the message text as-is, only parameters are substituted.
+ *
+ * @since 1.17
+ *
+ * @return string Unescaped untransformed message text.
+ */
+ public function plain() {
+ $this->format = self::FORMAT_PLAIN;
+ return $this->toString( self::FORMAT_PLAIN );
+ }
+
+ /**
+ * Returns the parsed message text which is always surrounded by a block element.
+ *
+ * @since 1.17
+ *
+ * @return string HTML
+ */
+ public function parseAsBlock() {
+ $this->format = self::FORMAT_BLOCK_PARSE;
+ return $this->toString( self::FORMAT_BLOCK_PARSE );
+ }
+
+ /**
+ * Returns the message text. {{-transformation is done and the result
+ * is escaped excluding any raw parameters.
+ *
+ * @since 1.17
+ *
+ * @return string Escaped message text.
+ */
+ public function escaped() {
+ $this->format = self::FORMAT_ESCAPED;
+ return $this->toString( self::FORMAT_ESCAPED );
+ }
+
+ /**
+ * Check whether a message key has been defined currently.
+ *
+ * @since 1.17
+ *
+ * @return bool
+ */
+ public function exists() {
+ return $this->fetchMessage() !== false;
+ }
+
+ /**
+ * Check whether a message does not exist, or is an empty string
+ *
+ * @since 1.18
+ * @todo FIXME: Merge with isDisabled()?
+ *
+ * @return bool
+ */
+ public function isBlank() {
+ $message = $this->fetchMessage();
+ return $message === false || $message === '';
+ }
+
+ /**
+ * Check whether a message does not exist, is an empty string, or is "-".
+ *
+ * @since 1.18
+ *
+ * @return bool
+ */
+ public function isDisabled() {
+ $message = $this->fetchMessage();
+ return $message === false || $message === '' || $message === '-';
+ }
+
+ /**
+ * @since 1.17
+ *
+ * @param mixed $raw
+ *
+ * @return array Array with a single "raw" key.
+ */
+ public static function rawParam( $raw ) {
+ return [ 'raw' => $raw ];
+ }
+
+ /**
+ * @since 1.18
+ *
+ * @param mixed $num
+ *
+ * @return array Array with a single "num" key.
+ */
+ public static function numParam( $num ) {
+ return [ 'num' => $num ];
+ }
+
+ /**
+ * @since 1.22
+ *
+ * @param int $duration
+ *
+ * @return int[] Array with a single "duration" key.
+ */
+ public static function durationParam( $duration ) {
+ return [ 'duration' => $duration ];
+ }
+
+ /**
+ * @since 1.22
+ *
+ * @param string $expiry
+ *
+ * @return string[] Array with a single "expiry" key.
+ */
+ public static function expiryParam( $expiry ) {
+ return [ 'expiry' => $expiry ];
+ }
+
+ /**
+ * @since 1.22
+ *
+ * @param int $period
+ *
+ * @return int[] Array with a single "period" key.
+ */
+ public static function timeperiodParam( $period ) {
+ return [ 'period' => $period ];
+ }
+
+ /**
+ * @since 1.22
+ *
+ * @param int $size
+ *
+ * @return int[] Array with a single "size" key.
+ */
+ public static function sizeParam( $size ) {
+ return [ 'size' => $size ];
+ }
+
+ /**
+ * @since 1.22
+ *
+ * @param int $bitrate
+ *
+ * @return int[] Array with a single "bitrate" key.
+ */
+ public static function bitrateParam( $bitrate ) {
+ return [ 'bitrate' => $bitrate ];
+ }
+
+ /**
+ * @since 1.25
+ *
+ * @param string $plaintext
+ *
+ * @return string[] Array with a single "plaintext" key.
+ */
+ public static function plaintextParam( $plaintext ) {
+ return [ 'plaintext' => $plaintext ];
+ }
+
+ /**
+ * @since 1.29
+ *
+ * @param array $list
+ * @param string $type 'comma', 'semicolon', 'pipe', 'text'
+ * @return array Array with "list" and "type" keys.
+ */
+ public static function listParam( array $list, $type = 'text' ) {
+ if ( !isset( self::$listTypeMap[$type] ) ) {
+ throw new InvalidArgumentException(
+ "Invalid type '$type'. Known types are: " . join( ', ', array_keys( self::$listTypeMap ) )
+ );
+ }
+ return [ 'list' => $list, 'type' => $type ];
+ }
+
+ /**
+ * Substitutes any parameters into the message text.
+ *
+ * @since 1.17
+ *
+ * @param string $message The message text.
+ * @param string $type Either "before" or "after".
+ * @param string $format One of the FORMAT_* constants.
+ *
+ * @return string
+ */
+ protected function replaceParameters( $message, $type = 'before', $format ) {
+ // A temporary marker for $1 parameters that is only valid
+ // in non-attribute contexts. However if the entire message is escaped
+ // then we don't want to use it because it will be mangled in all contexts
+ // and its unnessary as ->escaped() messages aren't html.
+ $marker = $format === self::FORMAT_ESCAPED ? '$' : '$\'"';
+ $replacementKeys = [];
+ foreach ( $this->parameters as $n => $param ) {
+ list( $paramType, $value ) = $this->extractParam( $param, $format );
+ if ( $type === 'before' ) {
+ if ( $paramType === 'before' ) {
+ $replacementKeys['$' . ( $n + 1 )] = $value;
+ } else /* $paramType === 'after' */ {
+ // To protect against XSS from replacing parameters
+ // inside html attributes, we convert $1 to $'"1.
+ // In the event that one of the parameters ends up
+ // in an attribute, either the ' or the " will be
+ // escaped, breaking the replacement and avoiding XSS.
+ $replacementKeys['$' . ( $n + 1 )] = $marker . ( $n + 1 );
+ }
+ } else {
+ if ( $paramType === 'after' ) {
+ $replacementKeys[$marker . ( $n + 1 )] = $value;
+ }
+ }
+ }
+ $message = strtr( $message, $replacementKeys );
+ return $message;
+ }
+
+ /**
+ * Extracts the parameter type and preprocessed the value if needed.
+ *
+ * @since 1.18
+ *
+ * @param mixed $param Parameter as defined in this class.
+ * @param string $format One of the FORMAT_* constants.
+ *
+ * @return array Array with the parameter type (either "before" or "after") and the value.
+ */
+ protected function extractParam( $param, $format ) {
+ if ( is_array( $param ) ) {
+ if ( isset( $param['raw'] ) ) {
+ return [ 'after', $param['raw'] ];
+ } elseif ( isset( $param['num'] ) ) {
+ // Replace number params always in before step for now.
+ // No support for combined raw and num params
+ return [ 'before', $this->getLanguage()->formatNum( $param['num'] ) ];
+ } elseif ( isset( $param['duration'] ) ) {
+ return [ 'before', $this->getLanguage()->formatDuration( $param['duration'] ) ];
+ } elseif ( isset( $param['expiry'] ) ) {
+ return [ 'before', $this->getLanguage()->formatExpiry( $param['expiry'] ) ];
+ } elseif ( isset( $param['period'] ) ) {
+ return [ 'before', $this->getLanguage()->formatTimePeriod( $param['period'] ) ];
+ } elseif ( isset( $param['size'] ) ) {
+ return [ 'before', $this->getLanguage()->formatSize( $param['size'] ) ];
+ } elseif ( isset( $param['bitrate'] ) ) {
+ return [ 'before', $this->getLanguage()->formatBitrate( $param['bitrate'] ) ];
+ } elseif ( isset( $param['plaintext'] ) ) {
+ return [ 'after', $this->formatPlaintext( $param['plaintext'], $format ) ];
+ } elseif ( isset( $param['list'] ) ) {
+ return $this->formatListParam( $param['list'], $param['type'], $format );
+ } else {
+ $warning = 'Invalid parameter for message "' . $this->getKey() . '": ' .
+ htmlspecialchars( serialize( $param ) );
+ trigger_error( $warning, E_USER_WARNING );
+ $e = new Exception;
+ wfDebugLog( 'Bug58676', $warning . "\n" . $e->getTraceAsString() );
+
+ return [ 'before', '[INVALID]' ];
+ }
+ } elseif ( $param instanceof Message ) {
+ // Match language, flags, etc. to the current message.
+ $msg = clone $param;
+ if ( $msg->language !== $this->language || $msg->useDatabase !== $this->useDatabase ) {
+ // Cache depends on these parameters
+ $msg->message = null;
+ }
+ $msg->interface = $this->interface;
+ $msg->language = $this->language;
+ $msg->useDatabase = $this->useDatabase;
+ $msg->title = $this->title;
+
+ // DWIM
+ if ( $format === 'block-parse' ) {
+ $format = 'parse';
+ }
+ $msg->format = $format;
+
+ // Message objects should not be before parameters because
+ // then they'll get double escaped. If the message needs to be
+ // escaped, it'll happen right here when we call toString().
+ return [ 'after', $msg->toString( $format ) ];
+ } else {
+ return [ 'before', $param ];
+ }
+ }
+
+ /**
+ * Wrapper for what ever method we use to parse wikitext.
+ *
+ * @since 1.17
+ *
+ * @param string $string Wikitext message contents.
+ *
+ * @return string Wikitext parsed into HTML.
+ */
+ protected function parseText( $string ) {
+ $out = MessageCache::singleton()->parse(
+ $string,
+ $this->title,
+ /*linestart*/true,
+ $this->interface,
+ $this->getLanguage()
+ );
+
+ return $out instanceof ParserOutput ? $out->getText() : $out;
+ }
+
+ /**
+ * Wrapper for what ever method we use to {{-transform wikitext.
+ *
+ * @since 1.17
+ *
+ * @param string $string Wikitext message contents.
+ *
+ * @return string Wikitext with {{-constructs replaced with their values.
+ */
+ protected function transformText( $string ) {
+ return MessageCache::singleton()->transform(
+ $string,
+ $this->interface,
+ $this->getLanguage(),
+ $this->title
+ );
+ }
+
+ /**
+ * Wrapper for what ever method we use to get message contents.
+ *
+ * @since 1.17
+ *
+ * @return string
+ * @throws MWException If message key array is empty.
+ */
+ protected function fetchMessage() {
+ if ( $this->message === null ) {
+ $cache = MessageCache::singleton();
+
+ foreach ( $this->keysToTry as $key ) {
+ $message = $cache->get( $key, $this->useDatabase, $this->getLanguage() );
+ if ( $message !== false && $message !== '' ) {
+ break;
+ }
+ }
+
+ // NOTE: The constructor makes sure keysToTry isn't empty,
+ // so we know that $key and $message are initialized.
+ $this->key = $key;
+ $this->message = $message;
+ }
+ return $this->message;
+ }
+
+ /**
+ * Formats a message parameter wrapped with 'plaintext'. Ensures that
+ * the entire string is displayed unchanged when displayed in the output
+ * format.
+ *
+ * @since 1.25
+ *
+ * @param string $plaintext String to ensure plaintext output of
+ * @param string $format One of the FORMAT_* constants.
+ *
+ * @return string Input plaintext encoded for output to $format
+ */
+ protected function formatPlaintext( $plaintext, $format ) {
+ switch ( $format ) {
+ case self::FORMAT_TEXT:
+ case self::FORMAT_PLAIN:
+ return $plaintext;
+
+ case self::FORMAT_PARSE:
+ case self::FORMAT_BLOCK_PARSE:
+ case self::FORMAT_ESCAPED:
+ default:
+ return htmlspecialchars( $plaintext, ENT_QUOTES );
+
+ }
+ }
+
+ /**
+ * Formats a list of parameters as a concatenated string.
+ * @since 1.29
+ * @param array $params
+ * @param string $listType
+ * @param string $format One of the FORMAT_* constants.
+ * @return array Array with the parameter type (either "before" or "after") and the value.
+ */
+ protected function formatListParam( array $params, $listType, $format ) {
+ if ( !isset( self::$listTypeMap[$listType] ) ) {
+ $warning = 'Invalid list type for message "' . $this->getKey() . '": '
+ . htmlspecialchars( $listType )
+ . ' (params are ' . htmlspecialchars( serialize( $params ) ) . ')';
+ trigger_error( $warning, E_USER_WARNING );
+ $e = new Exception;
+ wfDebugLog( 'Bug58676', $warning . "\n" . $e->getTraceAsString() );
+ return [ 'before', '[INVALID]' ];
+ }
+ $func = self::$listTypeMap[$listType];
+
+ // Handle an empty list sensibly
+ if ( !$params ) {
+ return [ 'before', $this->getLanguage()->$func( [] ) ];
+ }
+
+ // First, determine what kinds of list items we have
+ $types = [];
+ $vars = [];
+ $list = [];
+ foreach ( $params as $n => $p ) {
+ list( $type, $value ) = $this->extractParam( $p, $format );
+ $types[$type] = true;
+ $list[] = $value;
+ $vars[] = '$' . ( $n + 1 );
+ }
+
+ // Easy case: all are 'before' or 'after', so just join the
+ // values and use the same type.
+ if ( count( $types ) === 1 ) {
+ return [ key( $types ), $this->getLanguage()->$func( $list ) ];
+ }
+
+ // Hard case: We need to process each value per its type, then
+ // return the concatenated values as 'after'. We handle this by turning
+ // the list into a RawMessage and processing that as a parameter.
+ $vars = $this->getLanguage()->$func( $vars );
+ return $this->extractParam( new RawMessage( $vars, $params ), $format );
+ }
+}
diff --git a/www/wiki/includes/MimeMagic.php b/www/wiki/includes/MimeMagic.php
new file mode 100644
index 00000000..a2a44bb8
--- /dev/null
+++ b/www/wiki/includes/MimeMagic.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Assert\Assert;
+
+/**
+ * @deprecated since 1.29
+ * MimeAnalyzer should be used instead of MimeMagic
+ */
+class MimeMagic extends MimeAnalyzer {
+ /**
+ * Get an instance of this class
+ * @return MimeMagic
+ * @deprecated since 1.28 get a MimeAnalyzer instance from MediaWikiServices
+ */
+ public static function singleton() {
+ // XXX: We know that the MimeAnalyzer is currently an instance of MimeMagic
+ $instance = MediaWikiServices::getInstance()->getMimeAnalyzer();
+ Assert::postcondition(
+ $instance instanceof MimeMagic,
+ __METHOD__ . ' should return an instance of ' . self::class
+ );
+ return $instance;
+ }
+}
diff --git a/www/wiki/includes/MovePage.php b/www/wiki/includes/MovePage.php
new file mode 100644
index 00000000..2f6bca75
--- /dev/null
+++ b/www/wiki/includes/MovePage.php
@@ -0,0 +1,614 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Handles the backend logic of moving a page from one title
+ * to another.
+ *
+ * @since 1.24
+ */
+class MovePage {
+
+ /**
+ * @var Title
+ */
+ protected $oldTitle;
+
+ /**
+ * @var Title
+ */
+ protected $newTitle;
+
+ public function __construct( Title $oldTitle, Title $newTitle ) {
+ $this->oldTitle = $oldTitle;
+ $this->newTitle = $newTitle;
+ }
+
+ public function checkPermissions( User $user, $reason ) {
+ $status = new Status();
+
+ $errors = wfMergeErrorArrays(
+ $this->oldTitle->getUserPermissionsErrors( 'move', $user ),
+ $this->oldTitle->getUserPermissionsErrors( 'edit', $user ),
+ $this->newTitle->getUserPermissionsErrors( 'move-target', $user ),
+ $this->newTitle->getUserPermissionsErrors( 'edit', $user )
+ );
+
+ // Convert into a Status object
+ if ( $errors ) {
+ foreach ( $errors as $error ) {
+ call_user_func_array( [ $status, 'fatal' ], $error );
+ }
+ }
+
+ if ( EditPage::matchSummarySpamRegex( $reason ) !== false ) {
+ // This is kind of lame, won't display nice
+ $status->fatal( 'spamprotectiontext' );
+ }
+
+ $tp = $this->newTitle->getTitleProtection();
+ if ( $tp !== false && !$user->isAllowed( $tp['permission'] ) ) {
+ $status->fatal( 'cantmove-titleprotected' );
+ }
+
+ Hooks::run( 'MovePageCheckPermissions',
+ [ $this->oldTitle, $this->newTitle, $user, $reason, $status ]
+ );
+
+ return $status;
+ }
+
+ /**
+ * Does various sanity checks that the move is
+ * valid. Only things based on the two titles
+ * should be checked here.
+ *
+ * @return Status
+ */
+ public function isValidMove() {
+ global $wgContentHandlerUseDB;
+ $status = new Status();
+
+ if ( $this->oldTitle->equals( $this->newTitle ) ) {
+ $status->fatal( 'selfmove' );
+ }
+ if ( !$this->oldTitle->isMovable() ) {
+ $status->fatal( 'immobile-source-namespace', $this->oldTitle->getNsText() );
+ }
+ if ( $this->newTitle->isExternal() ) {
+ $status->fatal( 'immobile-target-namespace-iw' );
+ }
+ if ( !$this->newTitle->isMovable() ) {
+ $status->fatal( 'immobile-target-namespace', $this->newTitle->getNsText() );
+ }
+
+ $oldid = $this->oldTitle->getArticleID();
+
+ if ( strlen( $this->newTitle->getDBkey() ) < 1 ) {
+ $status->fatal( 'articleexists' );
+ }
+ if (
+ ( $this->oldTitle->getDBkey() == '' ) ||
+ ( !$oldid ) ||
+ ( $this->newTitle->getDBkey() == '' )
+ ) {
+ $status->fatal( 'badarticleerror' );
+ }
+
+ # The move is allowed only if (1) the target doesn't exist, or
+ # (2) the target is a redirect to the source, and has no history
+ # (so we can undo bad moves right after they're done).
+ if ( $this->newTitle->getArticleID() && !$this->isValidMoveTarget() ) {
+ $status->fatal( 'articleexists' );
+ }
+
+ // Content model checks
+ if ( !$wgContentHandlerUseDB &&
+ $this->oldTitle->getContentModel() !== $this->newTitle->getContentModel() ) {
+ // can't move a page if that would change the page's content model
+ $status->fatal(
+ 'bad-target-model',
+ ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ),
+ ContentHandler::getLocalizedName( $this->newTitle->getContentModel() )
+ );
+ } elseif (
+ !ContentHandler::getForTitle( $this->oldTitle )->canBeUsedOn( $this->newTitle )
+ ) {
+ $status->fatal(
+ 'content-not-allowed-here',
+ ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ),
+ $this->newTitle->getPrefixedText()
+ );
+ }
+
+ // Image-specific checks
+ if ( $this->oldTitle->inNamespace( NS_FILE ) ) {
+ $status->merge( $this->isValidFileMove() );
+ }
+
+ if ( $this->newTitle->inNamespace( NS_FILE ) && !$this->oldTitle->inNamespace( NS_FILE ) ) {
+ $status->fatal( 'nonfile-cannot-move-to-file' );
+ }
+
+ // Hook for extensions to say a title can't be moved for technical reasons
+ Hooks::run( 'MovePageIsValidMove', [ $this->oldTitle, $this->newTitle, $status ] );
+
+ return $status;
+ }
+
+ /**
+ * Sanity checks for when a file is being moved
+ *
+ * @return Status
+ */
+ protected function isValidFileMove() {
+ $status = new Status();
+ $file = wfLocalFile( $this->oldTitle );
+ $file->load( File::READ_LATEST );
+ if ( $file->exists() ) {
+ if ( $this->newTitle->getText() != wfStripIllegalFilenameChars( $this->newTitle->getText() ) ) {
+ $status->fatal( 'imageinvalidfilename' );
+ }
+ if ( !File::checkExtensionCompatibility( $file, $this->newTitle->getDBkey() ) ) {
+ $status->fatal( 'imagetypemismatch' );
+ }
+ }
+
+ if ( !$this->newTitle->inNamespace( NS_FILE ) ) {
+ $status->fatal( 'imagenocrossnamespace' );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Checks if $this can be moved to a given Title
+ * - Selects for update, so don't call it unless you mean business
+ *
+ * @since 1.25
+ * @return bool
+ */
+ protected function isValidMoveTarget() {
+ # Is it an existing file?
+ if ( $this->newTitle->inNamespace( NS_FILE ) ) {
+ $file = wfLocalFile( $this->newTitle );
+ $file->load( File::READ_LATEST );
+ if ( $file->exists() ) {
+ wfDebug( __METHOD__ . ": file exists\n" );
+ return false;
+ }
+ }
+ # Is it a redirect with no history?
+ if ( !$this->newTitle->isSingleRevRedirect() ) {
+ wfDebug( __METHOD__ . ": not a one-rev redirect\n" );
+ return false;
+ }
+ # Get the article text
+ $rev = Revision::newFromTitle( $this->newTitle, false, Revision::READ_LATEST );
+ if ( !is_object( $rev ) ) {
+ return false;
+ }
+ $content = $rev->getContent();
+ # Does the redirect point to the source?
+ # Or is it a broken self-redirect, usually caused by namespace collisions?
+ $redirTitle = $content ? $content->getRedirectTarget() : null;
+
+ if ( $redirTitle ) {
+ if ( $redirTitle->getPrefixedDBkey() !== $this->oldTitle->getPrefixedDBkey() &&
+ $redirTitle->getPrefixedDBkey() !== $this->newTitle->getPrefixedDBkey() ) {
+ wfDebug( __METHOD__ . ": redirect points to other page\n" );
+ return false;
+ } else {
+ return true;
+ }
+ } else {
+ # Fail safe (not a redirect after all. strange.)
+ wfDebug( __METHOD__ . ": failsafe: database says " . $this->newTitle->getPrefixedDBkey() .
+ " is a redirect, but it doesn't contain a valid redirect.\n" );
+ return false;
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param string $reason
+ * @param bool $createRedirect
+ * @param string[] $changeTags Change tags to apply to the entry in the move log. Caller
+ * should perform permission checks with ChangeTags::canAddTagsAccompanyingChange
+ * @return Status
+ */
+ public function move( User $user, $reason, $createRedirect, array $changeTags = [] ) {
+ global $wgCategoryCollation;
+
+ Hooks::run( 'TitleMove', [ $this->oldTitle, $this->newTitle, $user ] );
+
+ // If it is a file, move it first.
+ // It is done before all other moving stuff is done because it's hard to revert.
+ $dbw = wfGetDB( DB_MASTER );
+ if ( $this->oldTitle->getNamespace() == NS_FILE ) {
+ $file = wfLocalFile( $this->oldTitle );
+ $file->load( File::READ_LATEST );
+ if ( $file->exists() ) {
+ $status = $file->move( $this->newTitle );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ }
+ // Clear RepoGroup process cache
+ RepoGroup::singleton()->clearCache( $this->oldTitle );
+ RepoGroup::singleton()->clearCache( $this->newTitle ); # clear false negative cache
+ }
+
+ $dbw->startAtomic( __METHOD__ );
+
+ Hooks::run( 'TitleMoveStarting', [ $this->oldTitle, $this->newTitle, $user ] );
+
+ $pageid = $this->oldTitle->getArticleID( Title::GAID_FOR_UPDATE );
+ $protected = $this->oldTitle->isProtected();
+
+ // Do the actual move; if this fails, it will throw an MWException(!)
+ $nullRevision = $this->moveToInternal( $user, $this->newTitle, $reason, $createRedirect,
+ $changeTags );
+
+ // Refresh the sortkey for this row. Be careful to avoid resetting
+ // cl_timestamp, which may disturb time-based lists on some sites.
+ // @todo This block should be killed, it's duplicating code
+ // from LinksUpdate::getCategoryInsertions() and friends.
+ $prefixes = $dbw->select(
+ 'categorylinks',
+ [ 'cl_sortkey_prefix', 'cl_to' ],
+ [ 'cl_from' => $pageid ],
+ __METHOD__
+ );
+ if ( $this->newTitle->getNamespace() == NS_CATEGORY ) {
+ $type = 'subcat';
+ } elseif ( $this->newTitle->getNamespace() == NS_FILE ) {
+ $type = 'file';
+ } else {
+ $type = 'page';
+ }
+ foreach ( $prefixes as $prefixRow ) {
+ $prefix = $prefixRow->cl_sortkey_prefix;
+ $catTo = $prefixRow->cl_to;
+ $dbw->update( 'categorylinks',
+ [
+ 'cl_sortkey' => Collation::singleton()->getSortKey(
+ $this->newTitle->getCategorySortkey( $prefix ) ),
+ 'cl_collation' => $wgCategoryCollation,
+ 'cl_type' => $type,
+ 'cl_timestamp=cl_timestamp' ],
+ [
+ 'cl_from' => $pageid,
+ 'cl_to' => $catTo ],
+ __METHOD__
+ );
+ }
+
+ $redirid = $this->oldTitle->getArticleID();
+
+ if ( $protected ) {
+ # Protect the redirect title as the title used to be...
+ $res = $dbw->select(
+ 'page_restrictions',
+ '*',
+ [ 'pr_page' => $pageid ],
+ __METHOD__,
+ 'FOR UPDATE'
+ );
+ $rowsInsert = [];
+ foreach ( $res as $row ) {
+ $rowsInsert[] = [
+ 'pr_page' => $redirid,
+ 'pr_type' => $row->pr_type,
+ 'pr_level' => $row->pr_level,
+ 'pr_cascade' => $row->pr_cascade,
+ 'pr_user' => $row->pr_user,
+ 'pr_expiry' => $row->pr_expiry
+ ];
+ }
+ $dbw->insert( 'page_restrictions', $rowsInsert, __METHOD__, [ 'IGNORE' ] );
+
+ // Build comment for log
+ $comment = wfMessage(
+ 'prot_1movedto2',
+ $this->oldTitle->getPrefixedText(),
+ $this->newTitle->getPrefixedText()
+ )->inContentLanguage()->text();
+ if ( $reason ) {
+ $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
+ }
+
+ // reread inserted pr_ids for log relation
+ $insertedPrIds = $dbw->select(
+ 'page_restrictions',
+ 'pr_id',
+ [ 'pr_page' => $redirid ],
+ __METHOD__
+ );
+ $logRelationsValues = [];
+ foreach ( $insertedPrIds as $prid ) {
+ $logRelationsValues[] = $prid->pr_id;
+ }
+
+ // Update the protection log
+ $logEntry = new ManualLogEntry( 'protect', 'move_prot' );
+ $logEntry->setTarget( $this->newTitle );
+ $logEntry->setComment( $comment );
+ $logEntry->setPerformer( $user );
+ $logEntry->setParameters( [
+ '4::oldtitle' => $this->oldTitle->getPrefixedText(),
+ ] );
+ $logEntry->setRelations( [ 'pr_id' => $logRelationsValues ] );
+ $logEntry->setTags( $changeTags );
+ $logId = $logEntry->insert();
+ $logEntry->publish( $logId );
+ }
+
+ // Update *_from_namespace fields as needed
+ if ( $this->oldTitle->getNamespace() != $this->newTitle->getNamespace() ) {
+ $dbw->update( 'pagelinks',
+ [ 'pl_from_namespace' => $this->newTitle->getNamespace() ],
+ [ 'pl_from' => $pageid ],
+ __METHOD__
+ );
+ $dbw->update( 'templatelinks',
+ [ 'tl_from_namespace' => $this->newTitle->getNamespace() ],
+ [ 'tl_from' => $pageid ],
+ __METHOD__
+ );
+ $dbw->update( 'imagelinks',
+ [ 'il_from_namespace' => $this->newTitle->getNamespace() ],
+ [ 'il_from' => $pageid ],
+ __METHOD__
+ );
+ }
+
+ # Update watchlists
+ $oldtitle = $this->oldTitle->getDBkey();
+ $newtitle = $this->newTitle->getDBkey();
+ $oldsnamespace = MWNamespace::getSubject( $this->oldTitle->getNamespace() );
+ $newsnamespace = MWNamespace::getSubject( $this->newTitle->getNamespace() );
+ if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) {
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle );
+ }
+
+ Hooks::run(
+ 'TitleMoveCompleting',
+ [ $this->oldTitle, $this->newTitle,
+ $user, $pageid, $redirid, $reason, $nullRevision ]
+ );
+
+ $dbw->endAtomic( __METHOD__ );
+
+ $params = [
+ &$this->oldTitle,
+ &$this->newTitle,
+ &$user,
+ $pageid,
+ $redirid,
+ $reason,
+ $nullRevision
+ ];
+ // Keep each single hook handler atomic
+ DeferredUpdates::addUpdate(
+ new AtomicSectionUpdate(
+ $dbw,
+ __METHOD__,
+ // Hold onto $user to avoid HHVM bug where it no longer
+ // becomes a reference (T118683)
+ function () use ( $params, &$user ) {
+ Hooks::run( 'TitleMoveComplete', $params );
+ }
+ )
+ );
+
+ return Status::newGood();
+ }
+
+ /**
+ * Move page to a title which is either a redirect to the
+ * source page or nonexistent
+ *
+ * @todo This was basically directly moved from Title, it should be split into
+ * smaller functions
+ * @param User $user the User doing the move
+ * @param Title $nt The page to move to, which should be a redirect or non-existent
+ * @param string $reason The reason for the move
+ * @param bool $createRedirect Whether to leave a redirect at the old title. Does not check
+ * if the user has the suppressredirect right
+ * @param string[] $changeTags Change tags to apply to the entry in the move log
+ * @return Revision the revision created by the move
+ * @throws MWException
+ */
+ private function moveToInternal( User $user, &$nt, $reason = '', $createRedirect = true,
+ array $changeTags = []
+ ) {
+ if ( $nt->exists() ) {
+ $moveOverRedirect = true;
+ $logType = 'move_redir';
+ } else {
+ $moveOverRedirect = false;
+ $logType = 'move';
+ }
+
+ if ( $moveOverRedirect ) {
+ $overwriteMessage = wfMessage(
+ 'delete_and_move_reason',
+ $this->oldTitle->getPrefixedText()
+ )->inContentLanguage()->text();
+ $newpage = WikiPage::factory( $nt );
+ $errs = [];
+ $status = $newpage->doDeleteArticleReal(
+ $overwriteMessage,
+ /* $suppress */ false,
+ $nt->getArticleID(),
+ /* $commit */ false,
+ $errs,
+ $user,
+ $changeTags,
+ 'delete_redir'
+ );
+
+ if ( !$status->isGood() ) {
+ throw new MWException( 'Failed to delete page-move revision: ' . $status );
+ }
+
+ $nt->resetArticleID( false );
+ }
+
+ if ( $createRedirect ) {
+ if ( $this->oldTitle->getNamespace() == NS_CATEGORY
+ && !wfMessage( 'category-move-redirect-override' )->inContentLanguage()->isDisabled()
+ ) {
+ $redirectContent = new WikitextContent(
+ wfMessage( 'category-move-redirect-override' )
+ ->params( $nt->getPrefixedText() )->inContentLanguage()->plain() );
+ } else {
+ $contentHandler = ContentHandler::getForTitle( $this->oldTitle );
+ $redirectContent = $contentHandler->makeRedirectContent( $nt,
+ wfMessage( 'move-redirect-text' )->inContentLanguage()->plain() );
+ }
+
+ // NOTE: If this page's content model does not support redirects, $redirectContent will be null.
+ } else {
+ $redirectContent = null;
+ }
+
+ // Figure out whether the content model is no longer the default
+ $oldDefault = ContentHandler::getDefaultModelFor( $this->oldTitle );
+ $contentModel = $this->oldTitle->getContentModel();
+ $newDefault = ContentHandler::getDefaultModelFor( $nt );
+ $defaultContentModelChanging = ( $oldDefault !== $newDefault
+ && $oldDefault === $contentModel );
+
+ // T59084: log_page should be the ID of the *moved* page
+ $oldid = $this->oldTitle->getArticleID();
+ $logTitle = clone $this->oldTitle;
+
+ $logEntry = new ManualLogEntry( 'move', $logType );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( $logTitle );
+ $logEntry->setComment( $reason );
+ $logEntry->setParameters( [
+ '4::target' => $nt->getPrefixedText(),
+ '5::noredir' => $redirectContent ? '0' : '1',
+ ] );
+
+ $formatter = LogFormatter::newFromEntry( $logEntry );
+ $formatter->setContext( RequestContext::newExtraneousContext( $this->oldTitle ) );
+ $comment = $formatter->getPlainActionText();
+ if ( $reason ) {
+ $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ $oldpage = WikiPage::factory( $this->oldTitle );
+ $oldcountable = $oldpage->isCountable();
+
+ $newpage = WikiPage::factory( $nt );
+
+ # Save a null revision in the page's history notifying of the move
+ $nullRevision = Revision::newNullRevision( $dbw, $oldid, $comment, true, $user );
+ if ( !is_object( $nullRevision ) ) {
+ throw new MWException( 'No valid null revision produced in ' . __METHOD__ );
+ }
+
+ $nullRevId = $nullRevision->insertOn( $dbw );
+ $logEntry->setAssociatedRevId( $nullRevId );
+
+ # Change the name of the target page:
+ $dbw->update( 'page',
+ /* SET */ [
+ 'page_namespace' => $nt->getNamespace(),
+ 'page_title' => $nt->getDBkey(),
+ ],
+ /* WHERE */ [ 'page_id' => $oldid ],
+ __METHOD__
+ );
+
+ if ( !$redirectContent ) {
+ // Clean up the old title *before* reset article id - T47348
+ WikiPage::onArticleDelete( $this->oldTitle );
+ }
+
+ $this->oldTitle->resetArticleID( 0 ); // 0 == non existing
+ $nt->resetArticleID( $oldid );
+ $newpage->loadPageData( WikiPage::READ_LOCKING ); // T48397
+
+ $newpage->updateRevisionOn( $dbw, $nullRevision );
+
+ Hooks::run( 'NewRevisionFromEditComplete',
+ [ $newpage, $nullRevision, $nullRevision->getParentId(), $user ] );
+
+ $newpage->doEditUpdates( $nullRevision, $user,
+ [ 'changed' => false, 'moved' => true, 'oldcountable' => $oldcountable ] );
+
+ // If the default content model changes, we need to populate rev_content_model
+ if ( $defaultContentModelChanging ) {
+ $dbw->update(
+ 'revision',
+ [ 'rev_content_model' => $contentModel ],
+ [ 'rev_page' => $nt->getArticleID(), 'rev_content_model IS NULL' ],
+ __METHOD__
+ );
+ }
+
+ WikiPage::onArticleCreate( $nt );
+
+ # Recreate the redirect, this time in the other direction.
+ if ( $redirectContent ) {
+ $redirectArticle = WikiPage::factory( $this->oldTitle );
+ $redirectArticle->loadFromRow( false, WikiPage::READ_LOCKING ); // T48397
+ $newid = $redirectArticle->insertOn( $dbw );
+ if ( $newid ) { // sanity
+ $this->oldTitle->resetArticleID( $newid );
+ $redirectRevision = new Revision( [
+ 'title' => $this->oldTitle, // for determining the default content model
+ 'page' => $newid,
+ 'user_text' => $user->getName(),
+ 'user' => $user->getId(),
+ 'comment' => $comment,
+ 'content' => $redirectContent ] );
+ $redirectRevId = $redirectRevision->insertOn( $dbw );
+ $redirectArticle->updateRevisionOn( $dbw, $redirectRevision, 0 );
+
+ Hooks::run( 'NewRevisionFromEditComplete',
+ [ $redirectArticle, $redirectRevision, false, $user ] );
+
+ $redirectArticle->doEditUpdates( $redirectRevision, $user, [ 'created' => true ] );
+
+ ChangeTags::addTags( $changeTags, null, $redirectRevId, null );
+ }
+ }
+
+ # Log the move
+ $logid = $logEntry->insert();
+
+ $logEntry->setTags( $changeTags );
+ $logEntry->publish( $logid );
+
+ return $nullRevision;
+ }
+}
diff --git a/www/wiki/includes/NoLocalSettings.php b/www/wiki/includes/NoLocalSettings.php
new file mode 100644
index 00000000..50950ef3
--- /dev/null
+++ b/www/wiki/includes/NoLocalSettings.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Display an error page when there is no LocalSettings.php file.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+# T32219 : can not use pathinfo() on URLs since slashes do not match
+$matches = [];
+$ext = 'php';
+$path = '/';
+foreach ( array_filter( explode( '/', $_SERVER['PHP_SELF'] ) ) as $part ) {
+ if ( !preg_match( '/\.(php5?)$/', $part, $matches ) ) {
+ $path .= "$part/";
+ } else {
+ $ext = $matches[1] == 'php5' ? 'php5' : 'php';
+ break;
+ }
+}
+
+# Check to see if the installer is running
+if ( !function_exists( 'session_name' ) ) {
+ $installerStarted = false;
+} else {
+ if ( !wfIniGetBool( 'session.auto_start' ) ) {
+ session_name( 'mw_installer_session' );
+ }
+ $oldReporting = error_reporting( E_ALL & ~E_NOTICE );
+ $success = session_start();
+ error_reporting( $oldReporting );
+ $installerStarted = ( $success && isset( $_SESSION['installData'] ) );
+}
+
+$templateParser = new TemplateParser();
+
+# Render error page if no LocalSettings file can be found
+try {
+ echo $templateParser->processTemplate(
+ 'NoLocalSettings',
+ [
+ 'wgVersion' => ( isset( $wgVersion ) ? $wgVersion : 'VERSION' ),
+ 'path' => $path,
+ 'ext' => $ext,
+ 'localSettingsExists' => file_exists( MW_CONFIG_FILE ),
+ 'installerStarted' => $installerStarted
+ ]
+ );
+} catch ( Exception $e ) {
+ echo 'Error: ' . htmlspecialchars( $e->getMessage() );
+}
diff --git a/www/wiki/includes/OrderedStreamingForkController.php b/www/wiki/includes/OrderedStreamingForkController.php
new file mode 100644
index 00000000..ff29cb51
--- /dev/null
+++ b/www/wiki/includes/OrderedStreamingForkController.php
@@ -0,0 +1,216 @@
+<?php
+
+/**
+ * Reads lines of work from an input stream and farms them out to multiple
+ * child streams. Each child has exactly one piece of work in flight at a given
+ * moment. Writes the result of child's work to an output stream. If numProcs
+ * <= zero the work will be performed in process.
+ *
+ * This class amends ForkController with the requirement that the output is
+ * produced in the same exact order as input values were.
+ *
+ * Currently used by CirrusSearch extension to implement CLI search script.
+ *
+ * @ingroup Maintenance
+ * @since 1.30
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+class OrderedStreamingForkController extends ForkController {
+ /** @var callable */
+ protected $workCallback;
+ /** @var resource */
+ protected $input;
+ /** @var resource */
+ protected $output;
+ /** @var int */
+ protected $nextOutputId;
+ /** @var string[] Int key indicates order, value is data */
+ protected $delayedOutputData = [];
+
+ /**
+ * @param int $numProcs The number of worker processes to fork
+ * @param callable $workCallback A callback to call in the child process
+ * once for each line of work to process.
+ * @param resource $input A socket to read work lines from
+ * @param resource $output A socket to write the result of work to.
+ */
+ public function __construct( $numProcs, $workCallback, $input, $output ) {
+ parent::__construct( $numProcs );
+ $this->workCallback = $workCallback;
+ $this->input = $input;
+ $this->output = $output;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function start() {
+ if ( $this->procsToStart > 0 ) {
+ $status = parent::start();
+ if ( $status === 'child' ) {
+ $this->consume();
+ }
+ } else {
+ $status = 'parent';
+ $this->consumeNoFork();
+ }
+ return $status;
+ }
+
+ /**
+ * @param int $numProcs
+ * @return string
+ */
+ protected function forkWorkers( $numProcs ) {
+ $this->prepareEnvironment();
+
+ $childSockets = [];
+ // Create the child processes
+ for ( $i = 0; $i < $numProcs; $i++ ) {
+ $sockets = stream_socket_pair( STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP );
+ // Do the fork
+ $pid = pcntl_fork();
+ if ( $pid === -1 || $pid === false ) {
+ echo "Error creating child processes\n";
+ exit( 1 );
+ }
+
+ if ( !$pid ) {
+ $this->initChild();
+ $this->childNumber = $i;
+ $this->input = $sockets[0];
+ $this->output = $sockets[0];
+ fclose( $sockets[1] );
+ return 'child';
+ } else {
+ // This is the parent process
+ $this->children[$pid] = true;
+ fclose( $sockets[0] );
+ $childSockets[] = $sockets[1];
+ }
+ }
+ $this->feedChildren( $childSockets );
+ foreach ( $childSockets as $socket ) {
+ // if a child has already shutdown the sockets will be closed,
+ // closing a second time would raise a warning.
+ if ( is_resource( $socket ) ) {
+ fclose( $socket );
+ }
+ }
+ return 'parent';
+ }
+
+ /**
+ * Child worker process. Reads work from $this->input and writes the
+ * result of that work to $this->output when completed.
+ */
+ protected function consume() {
+ while ( !feof( $this->input ) ) {
+ $line = trim( fgets( $this->input ) );
+ if ( $line ) {
+ list( $id, $data ) = json_decode( $line );
+ $result = call_user_func( $this->workCallback, $data );
+ fwrite( $this->output, json_encode( [ $id, $result ] ) . "\n" );
+ }
+ }
+ }
+
+ /**
+ * Special cased version of self::consume() when no forking occurs
+ */
+ protected function consumeNoFork() {
+ while ( !feof( $this->input ) ) {
+ $line = trim( fgets( $this->input ) );
+ if ( $line ) {
+ $result = call_user_func( $this->workCallback, $line );
+ fwrite( $this->output, "$result\n" );
+ }
+ }
+ }
+
+ /**
+ * Reads lines of work from $this->input and farms them out to
+ * the provided socket.
+ *
+ * @param resource[] $sockets
+ */
+ protected function feedChildren( array $sockets ) {
+ $used = [];
+ $id = 0;
+ $this->nextOutputId = 0;
+
+ while ( !feof( $this->input ) ) {
+ $data = fgets( $this->input );
+ if ( $used ) {
+ do {
+ $this->updateAvailableSockets( $sockets, $used, $sockets ? 0 : 5 );
+ } while ( !$sockets );
+ }
+ $data = trim( $data );
+ if ( !$data ) {
+ continue;
+ }
+ $socket = array_pop( $sockets );
+ fwrite( $socket, json_encode( [ $id++, $data ] ) . "\n" );
+ $used[] = $socket;
+ }
+ while ( $used ) {
+ $this->updateAvailableSockets( $sockets, $used, 5 );
+ }
+ }
+
+ /**
+ * Moves sockets from $used to $sockets when they are available
+ * for more work
+ *
+ * @param resource[] &$sockets List of sockets that are waiting for work
+ * @param resource[] &$used List of sockets currently performing work
+ * @param int $timeout The number of seconds to block waiting. 0 for
+ * non-blocking operation.
+ */
+ protected function updateAvailableSockets( &$sockets, &$used, $timeout ) {
+ $read = $used;
+ $write = $except = [];
+ stream_select( $read, $write, $except, $timeout );
+ foreach ( $read as $socket ) {
+ $line = fgets( $socket );
+ list( $id, $data ) = json_decode( trim( $line ) );
+ $this->receive( (int)$id, $data );
+ $sockets[] = $socket;
+ $idx = array_search( $socket, $used );
+ unset( $used[$idx] );
+ }
+ }
+
+ /**
+ * @param int $id
+ * @param string $data
+ */
+ protected function receive( $id, $data ) {
+ if ( $id !== $this->nextOutputId ) {
+ $this->delayedOutputData[$id] = $data;
+ return;
+ }
+ fwrite( $this->output, $data . "\n" );
+ $this->nextOutputId = $id + 1;
+ while ( isset( $this->delayedOutputData[$this->nextOutputId] ) ) {
+ fwrite( $this->output, $this->delayedOutputData[$this->nextOutputId] . "\n" );
+ unset( $this->delayedOutputData[$this->nextOutputId] );
+ $this->nextOutputId++;
+ }
+ }
+}
diff --git a/www/wiki/includes/OutputHandler.php b/www/wiki/includes/OutputHandler.php
new file mode 100644
index 00000000..2dc37320
--- /dev/null
+++ b/www/wiki/includes/OutputHandler.php
@@ -0,0 +1,238 @@
+<?php
+/**
+ * Functions to be used with PHP's output buffer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Standard output handler for use with ob_start
+ *
+ * @param string $s
+ *
+ * @return string
+ */
+function wfOutputHandler( $s ) {
+ global $wgDisableOutputCompression, $wgValidateAllHtml, $wgMangleFlashPolicy;
+ if ( $wgMangleFlashPolicy ) {
+ $s = wfMangleFlashPolicy( $s );
+ }
+ if ( $wgValidateAllHtml ) {
+ $headers = headers_list();
+ $isHTML = false;
+ foreach ( $headers as $header ) {
+ $parts = explode( ':', $header, 2 );
+ if ( count( $parts ) !== 2 ) {
+ continue;
+ }
+ $name = strtolower( trim( $parts[0] ) );
+ $value = trim( $parts[1] );
+ if ( $name == 'content-type' && ( strpos( $value, 'text/html' ) === 0
+ || strpos( $value, 'application/xhtml+xml' ) === 0 )
+ ) {
+ $isHTML = true;
+ break;
+ }
+ }
+ if ( $isHTML ) {
+ $s = wfHtmlValidationHandler( $s );
+ }
+ }
+ if ( !$wgDisableOutputCompression && !ini_get( 'zlib.output_compression' ) ) {
+ if ( !defined( 'MW_NO_OUTPUT_COMPRESSION' ) ) {
+ $s = wfGzipHandler( $s );
+ }
+ if ( !ini_get( 'output_handler' ) ) {
+ wfDoContentLength( strlen( $s ) );
+ }
+ }
+ return $s;
+}
+
+/**
+ * Get the "file extension" that some client apps will estimate from
+ * the currently-requested URL.
+ * This isn't on WebRequest because we need it when things aren't initialized
+ * @private
+ *
+ * @return string
+ */
+function wfRequestExtension() {
+ /// @todo FIXME: this sort of dupes some code in WebRequest::getRequestUrl()
+ if ( isset( $_SERVER['REQUEST_URI'] ) ) {
+ // Strip the query string...
+ list( $path ) = explode( '?', $_SERVER['REQUEST_URI'], 2 );
+ } elseif ( isset( $_SERVER['SCRIPT_NAME'] ) ) {
+ // Probably IIS. QUERY_STRING appears separately.
+ $path = $_SERVER['SCRIPT_NAME'];
+ } else {
+ // Can't get the path from the server? :(
+ return '';
+ }
+
+ $period = strrpos( $path, '.' );
+ if ( $period !== false ) {
+ return strtolower( substr( $path, $period ) );
+ }
+ return '';
+}
+
+/**
+ * Handler that compresses data with gzip if allowed by the Accept header.
+ * Unlike ob_gzhandler, it works for HEAD requests too.
+ *
+ * @param string $s
+ *
+ * @return string
+ */
+function wfGzipHandler( $s ) {
+ if ( !function_exists( 'gzencode' ) ) {
+ wfDebug( __FUNCTION__ . "() skipping compression (gzencode unavailable)\n" );
+ return $s;
+ }
+ if ( headers_sent() ) {
+ wfDebug( __FUNCTION__ . "() skipping compression (headers already sent)\n" );
+ return $s;
+ }
+
+ $ext = wfRequestExtension();
+ if ( $ext == '.gz' || $ext == '.tgz' ) {
+ // Don't do gzip compression if the URL path ends in .gz or .tgz
+ // This confuses Safari and triggers a download of the page,
+ // even though it's pretty clearly labeled as viewable HTML.
+ // Bad Safari! Bad!
+ return $s;
+ }
+
+ if ( wfClientAcceptsGzip() ) {
+ wfDebug( __FUNCTION__ . "() is compressing output\n" );
+ header( 'Content-Encoding: gzip' );
+ $s = gzencode( $s, 6 );
+ }
+
+ // Set vary header if it hasn't been set already
+ $headers = headers_list();
+ $foundVary = false;
+ foreach ( $headers as $header ) {
+ $headerName = strtolower( substr( $header, 0, 5 ) );
+ if ( $headerName == 'vary:' ) {
+ $foundVary = true;
+ break;
+ }
+ }
+ if ( !$foundVary ) {
+ header( 'Vary: Accept-Encoding' );
+ global $wgUseKeyHeader;
+ if ( $wgUseKeyHeader ) {
+ header( 'Key: Accept-Encoding;match=gzip' );
+ }
+ }
+ return $s;
+}
+
+/**
+ * Mangle flash policy tags which open up the site to XSS attacks.
+ *
+ * @param string $s
+ *
+ * @return string
+ */
+function wfMangleFlashPolicy( $s ) {
+ # Avoid weird excessive memory usage in PCRE on big articles
+ if ( preg_match( '/\<\s*cross-domain-policy(?=\s|\>)/i', $s ) ) {
+ return preg_replace( '/\<(\s*)(cross-domain-policy(?=\s|\>))/i', '<$1NOT-$2', $s );
+ } else {
+ return $s;
+ }
+}
+
+/**
+ * Add a Content-Length header if possible. This makes it cooperate with CDN better.
+ *
+ * @param int $length
+ */
+function wfDoContentLength( $length ) {
+ if ( !headers_sent()
+ && isset( $_SERVER['SERVER_PROTOCOL'] )
+ && $_SERVER['SERVER_PROTOCOL'] == 'HTTP/1.0'
+ ) {
+ header( "Content-Length: $length" );
+ }
+}
+
+/**
+ * Replace the output with an error if the HTML is not valid
+ *
+ * @param string $s
+ *
+ * @return string
+ */
+function wfHtmlValidationHandler( $s ) {
+ $errors = '';
+ if ( MWTidy::checkErrors( $s, $errors ) ) {
+ return $s;
+ }
+
+ header( 'Cache-Control: no-cache' );
+
+ $out = Html::element( 'h1', null, 'HTML validation error' );
+ $out .= Html::openElement( 'ul' );
+
+ $error = strtok( $errors, "\n" );
+ $badLines = [];
+ while ( $error !== false ) {
+ if ( preg_match( '/^line (\d+)/', $error, $m ) ) {
+ $lineNum = intval( $m[1] );
+ $badLines[$lineNum] = true;
+ $out .= Html::rawElement( 'li', null,
+ Html::element( 'a', [ 'href' => "#line-{$lineNum}" ], $error ) ) . "\n";
+ }
+ $error = strtok( "\n" );
+ }
+
+ $out .= Html::closeElement( 'ul' );
+ $out .= Html::element( 'pre', null, $errors );
+ $out .= Html::openElement( 'ol' ) . "\n";
+ $line = strtok( $s, "\n" );
+ $i = 1;
+ while ( $line !== false ) {
+ $attrs = [];
+ if ( isset( $badLines[$i] ) ) {
+ $attrs['class'] = 'highlight';
+ $attrs['id'] = "line-$i";
+ }
+ $out .= Html::element( 'li', $attrs, $line ) . "\n";
+ $line = strtok( "\n" );
+ $i++;
+ }
+ $out .= Html::closeElement( 'ol' );
+
+ $style = <<<CSS
+.highlight { background-color: #ffc }
+li { white-space: pre }
+CSS;
+
+ $out = Html::htmlHeader( [ 'lang' => 'en', 'dir' => 'ltr' ] ) .
+ Html::rawElement( 'head', null,
+ Html::element( 'title', null, 'HTML validation error' ) .
+ Html::inlineStyle( $style ) ) .
+ Html::rawElement( 'body', null, $out ) .
+ Html::closeElement( 'html' );
+
+ return $out;
+}
diff --git a/www/wiki/includes/OutputPage.php b/www/wiki/includes/OutputPage.php
new file mode 100644
index 00000000..52161466
--- /dev/null
+++ b/www/wiki/includes/OutputPage.php
@@ -0,0 +1,4082 @@
+<?php
+/**
+ * Preparation for the final page rendering.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Session\SessionManager;
+use WrappedString\WrappedString;
+use WrappedString\WrappedStringList;
+
+/**
+ * This class should be covered by a general architecture document which does
+ * not exist as of January 2011. This is one of the Core classes and should
+ * be read at least once by any new developers.
+ *
+ * This class is used to prepare the final rendering. A skin is then
+ * applied to the output parameters (links, javascript, html, categories ...).
+ *
+ * @todo FIXME: Another class handles sending the whole page to the client.
+ *
+ * Some comments comes from a pairing session between Zak Greant and Antoine Musso
+ * in November 2010.
+ *
+ * @todo document
+ */
+class OutputPage extends ContextSource {
+ /** @var array Should be private. Used with addMeta() which adds "<meta>" */
+ protected $mMetatags = [];
+
+ /** @var array */
+ protected $mLinktags = [];
+
+ /** @var bool */
+ protected $mCanonicalUrl = false;
+
+ /**
+ * @var array Additional stylesheets. Looks like this is for extensions.
+ * Might be replaced by ResourceLoader.
+ */
+ protected $mExtStyles = [];
+
+ /**
+ * @var string Should be private - has getter and setter. Contains
+ * the HTML title */
+ public $mPagetitle = '';
+
+ /**
+ * @var string Contains all of the "<body>" content. Should be private we
+ * got set/get accessors and the append() method.
+ */
+ public $mBodytext = '';
+
+ /** @var string Stores contents of "<title>" tag */
+ private $mHTMLtitle = '';
+
+ /**
+ * @var bool Is the displayed content related to the source of the
+ * corresponding wiki article.
+ */
+ private $mIsarticle = false;
+
+ /** @var bool Stores "article flag" toggle. */
+ private $mIsArticleRelated = true;
+
+ /**
+ * @var bool We have to set isPrintable(). Some pages should
+ * never be printed (ex: redirections).
+ */
+ private $mPrintable = false;
+
+ /**
+ * @var array Contains the page subtitle. Special pages usually have some
+ * links here. Don't confuse with site subtitle added by skins.
+ */
+ private $mSubtitle = [];
+
+ /** @var string */
+ public $mRedirect = '';
+
+ /** @var int */
+ protected $mStatusCode;
+
+ /**
+ * @var string Used for sending cache control.
+ * The whole caching system should probably be moved into its own class.
+ */
+ protected $mLastModified = '';
+
+ /** @var array */
+ protected $mCategoryLinks = [];
+
+ /** @var array */
+ protected $mCategories = [
+ 'hidden' => [],
+ 'normal' => [],
+ ];
+
+ /** @var array */
+ protected $mIndicators = [];
+
+ /** @var array Array of Interwiki Prefixed (non DB key) Titles (e.g. 'fr:Test page') */
+ private $mLanguageLinks = [];
+
+ /**
+ * Used for JavaScript (predates ResourceLoader)
+ * @todo We should split JS / CSS.
+ * mScripts content is inserted as is in "<head>" by Skin. This might
+ * contain either a link to a stylesheet or inline CSS.
+ */
+ private $mScripts = '';
+
+ /** @var string Inline CSS styles. Use addInlineStyle() sparingly */
+ protected $mInlineStyles = '';
+
+ /**
+ * @var string Used by skin template.
+ * Example: $tpl->set( 'displaytitle', $out->mPageLinkTitle );
+ */
+ public $mPageLinkTitle = '';
+
+ /** @var array Array of elements in "<head>". Parser might add its own headers! */
+ protected $mHeadItems = [];
+
+ /** @var array Additional <body> classes; there are also <body> classes from other sources */
+ protected $mAdditionalBodyClasses = [];
+
+ /** @var array */
+ protected $mModules = [];
+
+ /** @var array */
+ protected $mModuleScripts = [];
+
+ /** @var array */
+ protected $mModuleStyles = [];
+
+ /** @var ResourceLoader */
+ protected $mResourceLoader;
+
+ /** @var ResourceLoaderClientHtml */
+ private $rlClient;
+
+ /** @var ResourceLoaderContext */
+ private $rlClientContext;
+
+ /** @var string */
+ private $rlUserModuleState;
+
+ /** @var array */
+ private $rlExemptStyleModules;
+
+ /** @var array */
+ protected $mJsConfigVars = [];
+
+ /** @var array */
+ protected $mTemplateIds = [];
+
+ /** @var array */
+ protected $mImageTimeKeys = [];
+
+ /** @var string */
+ public $mRedirectCode = '';
+
+ protected $mFeedLinksAppendQuery = null;
+
+ /** @var array
+ * What level of 'untrustworthiness' is allowed in CSS/JS modules loaded on this page?
+ * @see ResourceLoaderModule::$origin
+ * ResourceLoaderModule::ORIGIN_ALL is assumed unless overridden;
+ */
+ protected $mAllowedModules = [
+ ResourceLoaderModule::TYPE_COMBINED => ResourceLoaderModule::ORIGIN_ALL,
+ ];
+
+ /** @var bool Whether output is disabled. If this is true, the 'output' method will do nothing. */
+ protected $mDoNothing = false;
+
+ // Parser related.
+
+ /** @var int */
+ protected $mContainsNewMagic = 0;
+
+ /**
+ * lazy initialised, use parserOptions()
+ * @var ParserOptions
+ */
+ protected $mParserOptions = null;
+
+ /**
+ * Handles the Atom / RSS links.
+ * We probably only support Atom in 2011.
+ * @see $wgAdvertisedFeedTypes
+ */
+ private $mFeedLinks = [];
+
+ // Gwicke work on squid caching? Roughly from 2003.
+ protected $mEnableClientCache = true;
+
+ /** @var bool Flag if output should only contain the body of the article. */
+ private $mArticleBodyOnly = false;
+
+ /** @var bool */
+ protected $mNewSectionLink = false;
+
+ /** @var bool */
+ protected $mHideNewSectionLink = false;
+
+ /**
+ * @var bool Comes from the parser. This was probably made to load CSS/JS
+ * only if we had "<gallery>". Used directly in CategoryPage.php.
+ * Looks like ResourceLoader can replace this.
+ */
+ public $mNoGallery = false;
+
+ /** @var string */
+ private $mPageTitleActionText = '';
+
+ /** @var int Cache stuff. Looks like mEnableClientCache */
+ protected $mCdnMaxage = 0;
+ /** @var int Upper limit on mCdnMaxage */
+ protected $mCdnMaxageLimit = INF;
+
+ /**
+ * @var bool Controls if anti-clickjacking / frame-breaking headers will
+ * be sent. This should be done for pages where edit actions are possible.
+ * Setters: $this->preventClickjacking() and $this->allowClickjacking().
+ */
+ protected $mPreventClickjacking = true;
+
+ /** @var int To include the variable {{REVISIONID}} */
+ private $mRevisionId = null;
+
+ /** @var string */
+ private $mRevisionTimestamp = null;
+
+ /** @var array */
+ protected $mFileVersion = null;
+
+ /**
+ * @var array An array of stylesheet filenames (relative from skins path),
+ * with options for CSS media, IE conditions, and RTL/LTR direction.
+ * For internal use; add settings in the skin via $this->addStyle()
+ *
+ * Style again! This seems like a code duplication since we already have
+ * mStyles. This is what makes Open Source amazing.
+ */
+ protected $styles = [];
+
+ private $mIndexPolicy = 'index';
+ private $mFollowPolicy = 'follow';
+ private $mVaryHeader = [
+ 'Accept-Encoding' => [ 'match=gzip' ],
+ ];
+
+ /**
+ * If the current page was reached through a redirect, $mRedirectedFrom contains the Title
+ * of the redirect.
+ *
+ * @var Title
+ */
+ private $mRedirectedFrom = null;
+
+ /**
+ * Additional key => value data
+ */
+ private $mProperties = [];
+
+ /**
+ * @var string|null ResourceLoader target for load.php links. If null, will be omitted
+ */
+ private $mTarget = null;
+
+ /**
+ * @var bool Whether parser output contains a table of contents
+ */
+ private $mEnableTOC = false;
+
+ /**
+ * @var bool Whether parser output should contain section edit links
+ */
+ private $mEnableSectionEditLinks = true;
+
+ /**
+ * @var string|null The URL to send in a <link> element with rel=license
+ */
+ private $copyrightUrl;
+
+ /** @var array Profiling data */
+ private $limitReportJSData = [];
+
+ /**
+ * Link: header contents
+ */
+ private $mLinkHeader = [];
+
+ /**
+ * Constructor for OutputPage. This should not be called directly.
+ * Instead a new RequestContext should be created and it will implicitly create
+ * a OutputPage tied to that context.
+ * @param IContextSource|null $context
+ */
+ function __construct( IContextSource $context = null ) {
+ if ( $context === null ) {
+ # Extensions should use `new RequestContext` instead of `new OutputPage` now.
+ wfDeprecated( __METHOD__, '1.18' );
+ } else {
+ $this->setContext( $context );
+ }
+ }
+
+ /**
+ * Redirect to $url rather than displaying the normal page
+ *
+ * @param string $url URL
+ * @param string $responsecode HTTP status code
+ */
+ public function redirect( $url, $responsecode = '302' ) {
+ # Strip newlines as a paranoia check for header injection in PHP<5.1.2
+ $this->mRedirect = str_replace( "\n", '', $url );
+ $this->mRedirectCode = $responsecode;
+ }
+
+ /**
+ * Get the URL to redirect to, or an empty string if not redirect URL set
+ *
+ * @return string
+ */
+ public function getRedirect() {
+ return $this->mRedirect;
+ }
+
+ /**
+ * Set the copyright URL to send with the output.
+ * Empty string to omit, null to reset.
+ *
+ * @since 1.26
+ *
+ * @param string|null $url
+ */
+ public function setCopyrightUrl( $url ) {
+ $this->copyrightUrl = $url;
+ }
+
+ /**
+ * Set the HTTP status code to send with the output.
+ *
+ * @param int $statusCode
+ */
+ public function setStatusCode( $statusCode ) {
+ $this->mStatusCode = $statusCode;
+ }
+
+ /**
+ * Add a new "<meta>" tag
+ * To add an http-equiv meta tag, precede the name with "http:"
+ *
+ * @param string $name Tag name
+ * @param string $val Tag value
+ */
+ function addMeta( $name, $val ) {
+ array_push( $this->mMetatags, [ $name, $val ] );
+ }
+
+ /**
+ * Returns the current <meta> tags
+ *
+ * @since 1.25
+ * @return array
+ */
+ public function getMetaTags() {
+ return $this->mMetatags;
+ }
+
+ /**
+ * Add a new \<link\> tag to the page header.
+ *
+ * Note: use setCanonicalUrl() for rel=canonical.
+ *
+ * @param array $linkarr Associative array of attributes.
+ */
+ function addLink( array $linkarr ) {
+ array_push( $this->mLinktags, $linkarr );
+ }
+
+ /**
+ * Returns the current <link> tags
+ *
+ * @since 1.25
+ * @return array
+ */
+ public function getLinkTags() {
+ return $this->mLinktags;
+ }
+
+ /**
+ * Add a new \<link\> with "rel" attribute set to "meta"
+ *
+ * @param array $linkarr Associative array mapping attribute names to their
+ * values, both keys and values will be escaped, and the
+ * "rel" attribute will be automatically added
+ */
+ function addMetadataLink( array $linkarr ) {
+ $linkarr['rel'] = $this->getMetadataAttribute();
+ $this->addLink( $linkarr );
+ }
+
+ /**
+ * Set the URL to be used for the <link rel=canonical>. This should be used
+ * in preference to addLink(), to avoid duplicate link tags.
+ * @param string $url
+ */
+ function setCanonicalUrl( $url ) {
+ $this->mCanonicalUrl = $url;
+ }
+
+ /**
+ * Returns the URL to be used for the <link rel=canonical> if
+ * one is set.
+ *
+ * @since 1.25
+ * @return bool|string
+ */
+ public function getCanonicalUrl() {
+ return $this->mCanonicalUrl;
+ }
+
+ /**
+ * Get the value of the "rel" attribute for metadata links
+ *
+ * @return string
+ */
+ public function getMetadataAttribute() {
+ # note: buggy CC software only reads first "meta" link
+ static $haveMeta = false;
+ if ( $haveMeta ) {
+ return 'alternate meta';
+ } else {
+ $haveMeta = true;
+ return 'meta';
+ }
+ }
+
+ /**
+ * Add raw HTML to the list of scripts (including \<script\> tag, etc.)
+ * Internal use only. Use OutputPage::addModules() or OutputPage::addJsConfigVars()
+ * if possible.
+ *
+ * @param string $script Raw HTML
+ */
+ function addScript( $script ) {
+ $this->mScripts .= $script;
+ }
+
+ /**
+ * Register and add a stylesheet from an extension directory.
+ *
+ * @deprecated since 1.27 use addModuleStyles() or addStyle() instead
+ * @param string $url Path to sheet. Provide either a full url (beginning
+ * with 'http', etc) or a relative path from the document root
+ * (beginning with '/'). Otherwise it behaves identically to
+ * addStyle() and draws from the /skins folder.
+ */
+ public function addExtensionStyle( $url ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ array_push( $this->mExtStyles, $url );
+ }
+
+ /**
+ * Get all styles added by extensions
+ *
+ * @deprecated since 1.27
+ * @return array
+ */
+ function getExtStyle() {
+ wfDeprecated( __METHOD__, '1.27' );
+ return $this->mExtStyles;
+ }
+
+ /**
+ * Add a JavaScript file out of skins/common, or a given relative path.
+ * Internal use only. Use OutputPage::addModules() if possible.
+ *
+ * @param string $file Filename in skins/common or complete on-server path
+ * (/foo/bar.js)
+ * @param string $version Style version of the file. Defaults to $wgStyleVersion
+ */
+ public function addScriptFile( $file, $version = null ) {
+ // See if $file parameter is an absolute URL or begins with a slash
+ if ( substr( $file, 0, 1 ) == '/' || preg_match( '#^[a-z]*://#i', $file ) ) {
+ $path = $file;
+ } else {
+ $path = $this->getConfig()->get( 'StylePath' ) . "/common/{$file}";
+ }
+ if ( is_null( $version ) ) {
+ $version = $this->getConfig()->get( 'StyleVersion' );
+ }
+ $this->addScript( Html::linkedScript( wfAppendQuery( $path, $version ) ) );
+ }
+
+ /**
+ * Add a self-contained script tag with the given contents
+ * Internal use only. Use OutputPage::addModules() if possible.
+ *
+ * @param string $script JavaScript text, no script tags
+ */
+ public function addInlineScript( $script ) {
+ $this->mScripts .= Html::inlineScript( $script );
+ }
+
+ /**
+ * Filter an array of modules to remove insufficiently trustworthy members, and modules
+ * which are no longer registered (eg a page is cached before an extension is disabled)
+ * @param array $modules
+ * @param string|null $position If not null, only return modules with this position
+ * @param string $type
+ * @return array
+ */
+ protected function filterModules( array $modules, $position = null,
+ $type = ResourceLoaderModule::TYPE_COMBINED
+ ) {
+ $resourceLoader = $this->getResourceLoader();
+ $filteredModules = [];
+ foreach ( $modules as $val ) {
+ $module = $resourceLoader->getModule( $val );
+ if ( $module instanceof ResourceLoaderModule
+ && $module->getOrigin() <= $this->getAllowedModules( $type )
+ && ( is_null( $position ) || $module->getPosition() == $position )
+ ) {
+ if ( $this->mTarget && !in_array( $this->mTarget, $module->getTargets() ) ) {
+ $this->warnModuleTargetFilter( $module->getName() );
+ continue;
+ }
+ $filteredModules[] = $val;
+ }
+ }
+ return $filteredModules;
+ }
+
+ private function warnModuleTargetFilter( $moduleName ) {
+ static $warnings = [];
+ if ( isset( $warnings[$this->mTarget][$moduleName] ) ) {
+ return;
+ }
+ $warnings[$this->mTarget][$moduleName] = true;
+ $this->getResourceLoader()->getLogger()->debug(
+ 'Module "{module}" not loadable on target "{target}".',
+ [
+ 'module' => $moduleName,
+ 'target' => $this->mTarget,
+ ]
+ );
+ }
+
+ /**
+ * Get the list of modules to include on this page
+ *
+ * @param bool $filter Whether to filter out insufficiently trustworthy modules
+ * @param string|null $position If not null, only return modules with this position
+ * @param string $param
+ * @param string $type
+ * @return array Array of module names
+ */
+ public function getModules( $filter = false, $position = null, $param = 'mModules',
+ $type = ResourceLoaderModule::TYPE_COMBINED
+ ) {
+ $modules = array_values( array_unique( $this->$param ) );
+ return $filter
+ ? $this->filterModules( $modules, $position, $type )
+ : $modules;
+ }
+
+ /**
+ * Add one or more modules recognized by ResourceLoader. Modules added
+ * through this function will be loaded by ResourceLoader when the
+ * page loads.
+ *
+ * @param string|array $modules Module name (string) or array of module names
+ */
+ public function addModules( $modules ) {
+ $this->mModules = array_merge( $this->mModules, (array)$modules );
+ }
+
+ /**
+ * Get the list of module JS to include on this page
+ *
+ * @param bool $filter
+ * @param string|null $position
+ * @return array Array of module names
+ */
+ public function getModuleScripts( $filter = false, $position = null ) {
+ return $this->getModules( $filter, $position, 'mModuleScripts',
+ ResourceLoaderModule::TYPE_SCRIPTS
+ );
+ }
+
+ /**
+ * Add only JS of one or more modules recognized by ResourceLoader. Module
+ * scripts added through this function will be loaded by ResourceLoader when
+ * the page loads.
+ *
+ * @param string|array $modules Module name (string) or array of module names
+ */
+ public function addModuleScripts( $modules ) {
+ $this->mModuleScripts = array_merge( $this->mModuleScripts, (array)$modules );
+ }
+
+ /**
+ * Get the list of module CSS to include on this page
+ *
+ * @param bool $filter
+ * @param string|null $position
+ * @return array Array of module names
+ */
+ public function getModuleStyles( $filter = false, $position = null ) {
+ return $this->getModules( $filter, $position, 'mModuleStyles',
+ ResourceLoaderModule::TYPE_STYLES
+ );
+ }
+
+ /**
+ * Add only CSS of one or more modules recognized by ResourceLoader.
+ *
+ * Module styles added through this function will be added using standard link CSS
+ * tags, rather than as a combined Javascript and CSS package. Thus, they will
+ * load when JavaScript is disabled (unless CSS also happens to be disabled).
+ *
+ * @param string|array $modules Module name (string) or array of module names
+ */
+ public function addModuleStyles( $modules ) {
+ $this->mModuleStyles = array_merge( $this->mModuleStyles, (array)$modules );
+ }
+
+ /**
+ * @return null|string ResourceLoader target
+ */
+ public function getTarget() {
+ return $this->mTarget;
+ }
+
+ /**
+ * Sets ResourceLoader target for load.php links. If null, will be omitted
+ *
+ * @param string|null $target
+ */
+ public function setTarget( $target ) {
+ $this->mTarget = $target;
+ }
+
+ /**
+ * Get an array of head items
+ *
+ * @return array
+ */
+ function getHeadItemsArray() {
+ return $this->mHeadItems;
+ }
+
+ /**
+ * Add or replace a head item to the output
+ *
+ * Whenever possible, use more specific options like ResourceLoader modules,
+ * OutputPage::addLink(), OutputPage::addMetaLink() and OutputPage::addFeedLink()
+ * Fallback options for those are: OutputPage::addStyle, OutputPage::addScript(),
+ * OutputPage::addInlineScript() and OutputPage::addInlineStyle()
+ * This would be your very LAST fallback.
+ *
+ * @param string $name Item name
+ * @param string $value Raw HTML
+ */
+ public function addHeadItem( $name, $value ) {
+ $this->mHeadItems[$name] = $value;
+ }
+
+ /**
+ * Add one or more head items to the output
+ *
+ * @since 1.28
+ * @param string|string[] $values Raw HTML
+ */
+ public function addHeadItems( $values ) {
+ $this->mHeadItems = array_merge( $this->mHeadItems, (array)$values );
+ }
+
+ /**
+ * Check if the header item $name is already set
+ *
+ * @param string $name Item name
+ * @return bool
+ */
+ public function hasHeadItem( $name ) {
+ return isset( $this->mHeadItems[$name] );
+ }
+
+ /**
+ * Add a class to the <body> element
+ *
+ * @since 1.30
+ * @param string|string[] $classes One or more classes to add
+ */
+ public function addBodyClasses( $classes ) {
+ $this->mAdditionalBodyClasses = array_merge( $this->mAdditionalBodyClasses, (array)$classes );
+ }
+
+ /**
+ * @deprecated since 1.28 Obsolete - wgUseETag experiment was removed.
+ * @param string $tag
+ */
+ public function setETag( $tag ) {
+ }
+
+ /**
+ * Set whether the output should only contain the body of the article,
+ * without any skin, sidebar, etc.
+ * Used e.g. when calling with "action=render".
+ *
+ * @param bool $only Whether to output only the body of the article
+ */
+ public function setArticleBodyOnly( $only ) {
+ $this->mArticleBodyOnly = $only;
+ }
+
+ /**
+ * Return whether the output will contain only the body of the article
+ *
+ * @return bool
+ */
+ public function getArticleBodyOnly() {
+ return $this->mArticleBodyOnly;
+ }
+
+ /**
+ * Set an additional output property
+ * @since 1.21
+ *
+ * @param string $name
+ * @param mixed $value
+ */
+ public function setProperty( $name, $value ) {
+ $this->mProperties[$name] = $value;
+ }
+
+ /**
+ * Get an additional output property
+ * @since 1.21
+ *
+ * @param string $name
+ * @return mixed Property value or null if not found
+ */
+ public function getProperty( $name ) {
+ if ( isset( $this->mProperties[$name] ) ) {
+ return $this->mProperties[$name];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * checkLastModified tells the client to use the client-cached page if
+ * possible. If successful, the OutputPage is disabled so that
+ * any future call to OutputPage->output() have no effect.
+ *
+ * Side effect: sets mLastModified for Last-Modified header
+ *
+ * @param string $timestamp
+ *
+ * @return bool True if cache-ok headers was sent.
+ */
+ public function checkLastModified( $timestamp ) {
+ if ( !$timestamp || $timestamp == '19700101000000' ) {
+ wfDebug( __METHOD__ . ": CACHE DISABLED, NO TIMESTAMP\n" );
+ return false;
+ }
+ $config = $this->getConfig();
+ if ( !$config->get( 'CachePages' ) ) {
+ wfDebug( __METHOD__ . ": CACHE DISABLED\n" );
+ return false;
+ }
+
+ $timestamp = wfTimestamp( TS_MW, $timestamp );
+ $modifiedTimes = [
+ 'page' => $timestamp,
+ 'user' => $this->getUser()->getTouched(),
+ 'epoch' => $config->get( 'CacheEpoch' )
+ ];
+ if ( $config->get( 'UseSquid' ) ) {
+ // T46570: the core page itself may not change, but resources might
+ $modifiedTimes['sepoch'] = wfTimestamp( TS_MW, time() - $config->get( 'SquidMaxage' ) );
+ }
+ Hooks::run( 'OutputPageCheckLastModified', [ &$modifiedTimes, $this ] );
+
+ $maxModified = max( $modifiedTimes );
+ $this->mLastModified = wfTimestamp( TS_RFC2822, $maxModified );
+
+ $clientHeader = $this->getRequest()->getHeader( 'If-Modified-Since' );
+ if ( $clientHeader === false ) {
+ wfDebug( __METHOD__ . ": client did not send If-Modified-Since header", 'private' );
+ return false;
+ }
+
+ # IE sends sizes after the date like this:
+ # Wed, 20 Aug 2003 06:51:19 GMT; length=5202
+ # this breaks strtotime().
+ $clientHeader = preg_replace( '/;.*$/', '', $clientHeader );
+
+ MediaWiki\suppressWarnings(); // E_STRICT system time bitching
+ $clientHeaderTime = strtotime( $clientHeader );
+ MediaWiki\restoreWarnings();
+ if ( !$clientHeaderTime ) {
+ wfDebug( __METHOD__
+ . ": unable to parse the client's If-Modified-Since header: $clientHeader\n" );
+ return false;
+ }
+ $clientHeaderTime = wfTimestamp( TS_MW, $clientHeaderTime );
+
+ # Make debug info
+ $info = '';
+ foreach ( $modifiedTimes as $name => $value ) {
+ if ( $info !== '' ) {
+ $info .= ', ';
+ }
+ $info .= "$name=" . wfTimestamp( TS_ISO_8601, $value );
+ }
+
+ wfDebug( __METHOD__ . ": client sent If-Modified-Since: " .
+ wfTimestamp( TS_ISO_8601, $clientHeaderTime ), 'private' );
+ wfDebug( __METHOD__ . ": effective Last-Modified: " .
+ wfTimestamp( TS_ISO_8601, $maxModified ), 'private' );
+ if ( $clientHeaderTime < $maxModified ) {
+ wfDebug( __METHOD__ . ": STALE, $info", 'private' );
+ return false;
+ }
+
+ # Not modified
+ # Give a 304 Not Modified response code and disable body output
+ wfDebug( __METHOD__ . ": NOT MODIFIED, $info", 'private' );
+ ini_set( 'zlib.output_compression', 0 );
+ $this->getRequest()->response()->statusHeader( 304 );
+ $this->sendCacheControl();
+ $this->disable();
+
+ // Don't output a compressed blob when using ob_gzhandler;
+ // it's technically against HTTP spec and seems to confuse
+ // Firefox when the response gets split over two packets.
+ wfClearOutputBuffers();
+
+ return true;
+ }
+
+ /**
+ * Override the last modified timestamp
+ *
+ * @param string $timestamp New timestamp, in a format readable by
+ * wfTimestamp()
+ */
+ public function setLastModified( $timestamp ) {
+ $this->mLastModified = wfTimestamp( TS_RFC2822, $timestamp );
+ }
+
+ /**
+ * Set the robot policy for the page: <http://www.robotstxt.org/meta.html>
+ *
+ * @param string $policy The literal string to output as the contents of
+ * the meta tag. Will be parsed according to the spec and output in
+ * standardized form.
+ * @return null
+ */
+ public function setRobotPolicy( $policy ) {
+ $policy = Article::formatRobotPolicy( $policy );
+
+ if ( isset( $policy['index'] ) ) {
+ $this->setIndexPolicy( $policy['index'] );
+ }
+ if ( isset( $policy['follow'] ) ) {
+ $this->setFollowPolicy( $policy['follow'] );
+ }
+ }
+
+ /**
+ * Set the index policy for the page, but leave the follow policy un-
+ * touched.
+ *
+ * @param string $policy Either 'index' or 'noindex'.
+ * @return null
+ */
+ public function setIndexPolicy( $policy ) {
+ $policy = trim( $policy );
+ if ( in_array( $policy, [ 'index', 'noindex' ] ) ) {
+ $this->mIndexPolicy = $policy;
+ }
+ }
+
+ /**
+ * Set the follow policy for the page, but leave the index policy un-
+ * touched.
+ *
+ * @param string $policy Either 'follow' or 'nofollow'.
+ * @return null
+ */
+ public function setFollowPolicy( $policy ) {
+ $policy = trim( $policy );
+ if ( in_array( $policy, [ 'follow', 'nofollow' ] ) ) {
+ $this->mFollowPolicy = $policy;
+ }
+ }
+
+ /**
+ * Set the new value of the "action text", this will be added to the
+ * "HTML title", separated from it with " - ".
+ *
+ * @param string $text New value of the "action text"
+ */
+ public function setPageTitleActionText( $text ) {
+ $this->mPageTitleActionText = $text;
+ }
+
+ /**
+ * Get the value of the "action text"
+ *
+ * @return string
+ */
+ public function getPageTitleActionText() {
+ return $this->mPageTitleActionText;
+ }
+
+ /**
+ * "HTML title" means the contents of "<title>".
+ * It is stored as plain, unescaped text and will be run through htmlspecialchars in the skin file.
+ *
+ * @param string|Message $name
+ */
+ public function setHTMLTitle( $name ) {
+ if ( $name instanceof Message ) {
+ $this->mHTMLtitle = $name->setContext( $this->getContext() )->text();
+ } else {
+ $this->mHTMLtitle = $name;
+ }
+ }
+
+ /**
+ * Return the "HTML title", i.e. the content of the "<title>" tag.
+ *
+ * @return string
+ */
+ public function getHTMLTitle() {
+ return $this->mHTMLtitle;
+ }
+
+ /**
+ * Set $mRedirectedFrom, the Title of the page which redirected us to the current page.
+ *
+ * @param Title $t
+ */
+ public function setRedirectedFrom( $t ) {
+ $this->mRedirectedFrom = $t;
+ }
+
+ /**
+ * "Page title" means the contents of \<h1\>. It is stored as a valid HTML
+ * fragment. This function allows good tags like \<sup\> in the \<h1\> tag,
+ * but not bad tags like \<script\>. This function automatically sets
+ * \<title\> to the same content as \<h1\> but with all tags removed. Bad
+ * tags that were escaped in \<h1\> will still be escaped in \<title\>, and
+ * good tags like \<i\> will be dropped entirely.
+ *
+ * @param string|Message $name
+ */
+ public function setPageTitle( $name ) {
+ if ( $name instanceof Message ) {
+ $name = $name->setContext( $this->getContext() )->text();
+ }
+
+ # change "<script>foo&bar</script>" to "&lt;script&gt;foo&amp;bar&lt;/script&gt;"
+ # but leave "<i>foobar</i>" alone
+ $nameWithTags = Sanitizer::normalizeCharReferences( Sanitizer::removeHTMLtags( $name ) );
+ $this->mPagetitle = $nameWithTags;
+
+ # change "<i>foo&amp;bar</i>" to "foo&bar"
+ $this->setHTMLTitle(
+ $this->msg( 'pagetitle' )->rawParams( Sanitizer::stripAllTags( $nameWithTags ) )
+ ->inContentLanguage()
+ );
+ }
+
+ /**
+ * Return the "page title", i.e. the content of the \<h1\> tag.
+ *
+ * @return string
+ */
+ public function getPageTitle() {
+ return $this->mPagetitle;
+ }
+
+ /**
+ * Set the Title object to use
+ *
+ * @param Title $t
+ */
+ public function setTitle( Title $t ) {
+ $this->getContext()->setTitle( $t );
+ }
+
+ /**
+ * Replace the subtitle with $str
+ *
+ * @param string|Message $str New value of the subtitle. String should be safe HTML.
+ */
+ public function setSubtitle( $str ) {
+ $this->clearSubtitle();
+ $this->addSubtitle( $str );
+ }
+
+ /**
+ * Add $str to the subtitle
+ *
+ * @param string|Message $str String or Message to add to the subtitle. String should be safe HTML.
+ */
+ public function addSubtitle( $str ) {
+ if ( $str instanceof Message ) {
+ $this->mSubtitle[] = $str->setContext( $this->getContext() )->parse();
+ } else {
+ $this->mSubtitle[] = $str;
+ }
+ }
+
+ /**
+ * Build message object for a subtitle containing a backlink to a page
+ *
+ * @param Title $title Title to link to
+ * @param array $query Array of additional parameters to include in the link
+ * @return Message
+ * @since 1.25
+ */
+ public static function buildBacklinkSubtitle( Title $title, $query = [] ) {
+ if ( $title->isRedirect() ) {
+ $query['redirect'] = 'no';
+ }
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ return wfMessage( 'backlinksubtitle' )
+ ->rawParams( $linkRenderer->makeLink( $title, null, [], $query ) );
+ }
+
+ /**
+ * Add a subtitle containing a backlink to a page
+ *
+ * @param Title $title Title to link to
+ * @param array $query Array of additional parameters to include in the link
+ */
+ public function addBacklinkSubtitle( Title $title, $query = [] ) {
+ $this->addSubtitle( self::buildBacklinkSubtitle( $title, $query ) );
+ }
+
+ /**
+ * Clear the subtitles
+ */
+ public function clearSubtitle() {
+ $this->mSubtitle = [];
+ }
+
+ /**
+ * Get the subtitle
+ *
+ * @return string
+ */
+ public function getSubtitle() {
+ return implode( "<br />\n\t\t\t\t", $this->mSubtitle );
+ }
+
+ /**
+ * Set the page as printable, i.e. it'll be displayed with all
+ * print styles included
+ */
+ public function setPrintable() {
+ $this->mPrintable = true;
+ }
+
+ /**
+ * Return whether the page is "printable"
+ *
+ * @return bool
+ */
+ public function isPrintable() {
+ return $this->mPrintable;
+ }
+
+ /**
+ * Disable output completely, i.e. calling output() will have no effect
+ */
+ public function disable() {
+ $this->mDoNothing = true;
+ }
+
+ /**
+ * Return whether the output will be completely disabled
+ *
+ * @return bool
+ */
+ public function isDisabled() {
+ return $this->mDoNothing;
+ }
+
+ /**
+ * Show an "add new section" link?
+ *
+ * @return bool
+ */
+ public function showNewSectionLink() {
+ return $this->mNewSectionLink;
+ }
+
+ /**
+ * Forcibly hide the new section link?
+ *
+ * @return bool
+ */
+ public function forceHideNewSectionLink() {
+ return $this->mHideNewSectionLink;
+ }
+
+ /**
+ * Add or remove feed links in the page header
+ * This is mainly kept for backward compatibility, see OutputPage::addFeedLink()
+ * for the new version
+ * @see addFeedLink()
+ *
+ * @param bool $show True: add default feeds, false: remove all feeds
+ */
+ public function setSyndicated( $show = true ) {
+ if ( $show ) {
+ $this->setFeedAppendQuery( false );
+ } else {
+ $this->mFeedLinks = [];
+ }
+ }
+
+ /**
+ * Add default feeds to the page header
+ * This is mainly kept for backward compatibility, see OutputPage::addFeedLink()
+ * for the new version
+ * @see addFeedLink()
+ *
+ * @param string $val Query to append to feed links or false to output
+ * default links
+ */
+ public function setFeedAppendQuery( $val ) {
+ $this->mFeedLinks = [];
+
+ foreach ( $this->getConfig()->get( 'AdvertisedFeedTypes' ) as $type ) {
+ $query = "feed=$type";
+ if ( is_string( $val ) ) {
+ $query .= '&' . $val;
+ }
+ $this->mFeedLinks[$type] = $this->getTitle()->getLocalURL( $query );
+ }
+ }
+
+ /**
+ * Add a feed link to the page header
+ *
+ * @param string $format Feed type, should be a key of $wgFeedClasses
+ * @param string $href URL
+ */
+ public function addFeedLink( $format, $href ) {
+ if ( in_array( $format, $this->getConfig()->get( 'AdvertisedFeedTypes' ) ) ) {
+ $this->mFeedLinks[$format] = $href;
+ }
+ }
+
+ /**
+ * Should we output feed links for this page?
+ * @return bool
+ */
+ public function isSyndicated() {
+ return count( $this->mFeedLinks ) > 0;
+ }
+
+ /**
+ * Return URLs for each supported syndication format for this page.
+ * @return array Associating format keys with URLs
+ */
+ public function getSyndicationLinks() {
+ return $this->mFeedLinks;
+ }
+
+ /**
+ * Will currently always return null
+ *
+ * @return null
+ */
+ public function getFeedAppendQuery() {
+ return $this->mFeedLinksAppendQuery;
+ }
+
+ /**
+ * Set whether the displayed content is related to the source of the
+ * corresponding article on the wiki
+ * Setting true will cause the change "article related" toggle to true
+ *
+ * @param bool $v
+ */
+ public function setArticleFlag( $v ) {
+ $this->mIsarticle = $v;
+ if ( $v ) {
+ $this->mIsArticleRelated = $v;
+ }
+ }
+
+ /**
+ * Return whether the content displayed page is related to the source of
+ * the corresponding article on the wiki
+ *
+ * @return bool
+ */
+ public function isArticle() {
+ return $this->mIsarticle;
+ }
+
+ /**
+ * Set whether this page is related an article on the wiki
+ * Setting false will cause the change of "article flag" toggle to false
+ *
+ * @param bool $v
+ */
+ public function setArticleRelated( $v ) {
+ $this->mIsArticleRelated = $v;
+ if ( !$v ) {
+ $this->mIsarticle = false;
+ }
+ }
+
+ /**
+ * Return whether this page is related an article on the wiki
+ *
+ * @return bool
+ */
+ public function isArticleRelated() {
+ return $this->mIsArticleRelated;
+ }
+
+ /**
+ * Add new language links
+ *
+ * @param string[] $newLinkArray Array of interwiki-prefixed (non DB key) titles
+ * (e.g. 'fr:Test page')
+ */
+ public function addLanguageLinks( array $newLinkArray ) {
+ $this->mLanguageLinks += $newLinkArray;
+ }
+
+ /**
+ * Reset the language links and add new language links
+ *
+ * @param string[] $newLinkArray Array of interwiki-prefixed (non DB key) titles
+ * (e.g. 'fr:Test page')
+ */
+ public function setLanguageLinks( array $newLinkArray ) {
+ $this->mLanguageLinks = $newLinkArray;
+ }
+
+ /**
+ * Get the list of language links
+ *
+ * @return string[] Array of interwiki-prefixed (non DB key) titles (e.g. 'fr:Test page')
+ */
+ public function getLanguageLinks() {
+ return $this->mLanguageLinks;
+ }
+
+ /**
+ * Add an array of categories, with names in the keys
+ *
+ * @param array $categories Mapping category name => sort key
+ */
+ public function addCategoryLinks( array $categories ) {
+ global $wgContLang;
+
+ if ( !is_array( $categories ) || count( $categories ) == 0 ) {
+ return;
+ }
+
+ $res = $this->addCategoryLinksToLBAndGetResult( $categories );
+
+ # Set all the values to 'normal'.
+ $categories = array_fill_keys( array_keys( $categories ), 'normal' );
+
+ # Mark hidden categories
+ foreach ( $res as $row ) {
+ if ( isset( $row->pp_value ) ) {
+ $categories[$row->page_title] = 'hidden';
+ }
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $outputPage = $this;
+ # Add the remaining categories to the skin
+ if ( Hooks::run(
+ 'OutputPageMakeCategoryLinks',
+ [ &$outputPage, $categories, &$this->mCategoryLinks ] )
+ ) {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ foreach ( $categories as $category => $type ) {
+ // array keys will cast numeric category names to ints, so cast back to string
+ $category = (string)$category;
+ $origcategory = $category;
+ $title = Title::makeTitleSafe( NS_CATEGORY, $category );
+ if ( !$title ) {
+ continue;
+ }
+ $wgContLang->findVariantLink( $category, $title, true );
+ if ( $category != $origcategory && array_key_exists( $category, $categories ) ) {
+ continue;
+ }
+ $text = $wgContLang->convertHtml( $title->getText() );
+ $this->mCategories[$type][] = $title->getText();
+ $this->mCategoryLinks[$type][] = $linkRenderer->makeLink( $title, new HtmlArmor( $text ) );
+ }
+ }
+ }
+
+ /**
+ * @param array $categories
+ * @return bool|ResultWrapper
+ */
+ protected function addCategoryLinksToLBAndGetResult( array $categories ) {
+ # Add the links to a LinkBatch
+ $arr = [ NS_CATEGORY => $categories ];
+ $lb = new LinkBatch;
+ $lb->setArray( $arr );
+
+ # Fetch existence plus the hiddencat property
+ $dbr = wfGetDB( DB_REPLICA );
+ $fields = array_merge(
+ LinkCache::getSelectFields(),
+ [ 'page_namespace', 'page_title', 'pp_value' ]
+ );
+
+ $res = $dbr->select( [ 'page', 'page_props' ],
+ $fields,
+ $lb->constructSet( 'page', $dbr ),
+ __METHOD__,
+ [],
+ [ 'page_props' => [ 'LEFT JOIN', [
+ 'pp_propname' => 'hiddencat',
+ 'pp_page = page_id'
+ ] ] ]
+ );
+
+ # Add the results to the link cache
+ $lb->addResultToCache( LinkCache::singleton(), $res );
+
+ return $res;
+ }
+
+ /**
+ * Reset the category links (but not the category list) and add $categories
+ *
+ * @param array $categories Mapping category name => sort key
+ */
+ public function setCategoryLinks( array $categories ) {
+ $this->mCategoryLinks = [];
+ $this->addCategoryLinks( $categories );
+ }
+
+ /**
+ * Get the list of category links, in a 2-D array with the following format:
+ * $arr[$type][] = $link, where $type is either "normal" or "hidden" (for
+ * hidden categories) and $link a HTML fragment with a link to the category
+ * page
+ *
+ * @return array
+ */
+ public function getCategoryLinks() {
+ return $this->mCategoryLinks;
+ }
+
+ /**
+ * Get the list of category names this page belongs to.
+ *
+ * @param string $type The type of categories which should be returned. Possible values:
+ * * all: all categories of all types
+ * * hidden: only the hidden categories
+ * * normal: all categories, except hidden categories
+ * @return array Array of strings
+ */
+ public function getCategories( $type = 'all' ) {
+ if ( $type === 'all' ) {
+ $allCategories = [];
+ foreach ( $this->mCategories as $categories ) {
+ $allCategories = array_merge( $allCategories, $categories );
+ }
+ return $allCategories;
+ }
+ if ( !isset( $this->mCategories[$type] ) ) {
+ throw new InvalidArgumentException( 'Invalid category type given: ' . $type );
+ }
+ return $this->mCategories[$type];
+ }
+
+ /**
+ * Add an array of indicators, with their identifiers as array
+ * keys and HTML contents as values.
+ *
+ * In case of duplicate keys, existing values are overwritten.
+ *
+ * @param array $indicators
+ * @since 1.25
+ */
+ public function setIndicators( array $indicators ) {
+ $this->mIndicators = $indicators + $this->mIndicators;
+ // Keep ordered by key
+ ksort( $this->mIndicators );
+ }
+
+ /**
+ * Get the indicators associated with this page.
+ *
+ * The array will be internally ordered by item keys.
+ *
+ * @return array Keys: identifiers, values: HTML contents
+ * @since 1.25
+ */
+ public function getIndicators() {
+ return $this->mIndicators;
+ }
+
+ /**
+ * Adds help link with an icon via page indicators.
+ * Link target can be overridden by a local message containing a wikilink:
+ * the message key is: lowercase action or special page name + '-helppage'.
+ * @param string $to Target MediaWiki.org page title or encoded URL.
+ * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o.
+ * @since 1.25
+ */
+ public function addHelpLink( $to, $overrideBaseUrl = false ) {
+ $this->addModuleStyles( 'mediawiki.helplink' );
+ $text = $this->msg( 'helppage-top-gethelp' )->escaped();
+
+ if ( $overrideBaseUrl ) {
+ $helpUrl = $to;
+ } else {
+ $toUrlencoded = wfUrlencode( str_replace( ' ', '_', $to ) );
+ $helpUrl = "//www.mediawiki.org/wiki/Special:MyLanguage/$toUrlencoded";
+ }
+
+ $link = Html::rawElement(
+ 'a',
+ [
+ 'href' => $helpUrl,
+ 'target' => '_blank',
+ 'class' => 'mw-helplink',
+ ],
+ $text
+ );
+
+ $this->setIndicators( [ 'mw-helplink' => $link ] );
+ }
+
+ /**
+ * Do not allow scripts which can be modified by wiki users to load on this page;
+ * only allow scripts bundled with, or generated by, the software.
+ * Site-wide styles are controlled by a config setting, since they can be
+ * used to create a custom skin/theme, but not user-specific ones.
+ *
+ * @todo this should be given a more accurate name
+ */
+ public function disallowUserJs() {
+ $this->reduceAllowedModules(
+ ResourceLoaderModule::TYPE_SCRIPTS,
+ ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL
+ );
+
+ // Site-wide styles are controlled by a config setting, see T73621
+ // for background on why. User styles are never allowed.
+ if ( $this->getConfig()->get( 'AllowSiteCSSOnRestrictedPages' ) ) {
+ $styleOrigin = ResourceLoaderModule::ORIGIN_USER_SITEWIDE;
+ } else {
+ $styleOrigin = ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL;
+ }
+ $this->reduceAllowedModules(
+ ResourceLoaderModule::TYPE_STYLES,
+ $styleOrigin
+ );
+ }
+
+ /**
+ * Show what level of JavaScript / CSS untrustworthiness is allowed on this page
+ * @see ResourceLoaderModule::$origin
+ * @param string $type ResourceLoaderModule TYPE_ constant
+ * @return int ResourceLoaderModule ORIGIN_ class constant
+ */
+ public function getAllowedModules( $type ) {
+ if ( $type == ResourceLoaderModule::TYPE_COMBINED ) {
+ return min( array_values( $this->mAllowedModules ) );
+ } else {
+ return isset( $this->mAllowedModules[$type] )
+ ? $this->mAllowedModules[$type]
+ : ResourceLoaderModule::ORIGIN_ALL;
+ }
+ }
+
+ /**
+ * Limit the highest level of CSS/JS untrustworthiness allowed.
+ *
+ * If passed the same or a higher level than the current level of untrustworthiness set, the
+ * level will remain unchanged.
+ *
+ * @param string $type
+ * @param int $level ResourceLoaderModule class constant
+ */
+ public function reduceAllowedModules( $type, $level ) {
+ $this->mAllowedModules[$type] = min( $this->getAllowedModules( $type ), $level );
+ }
+
+ /**
+ * Prepend $text to the body HTML
+ *
+ * @param string $text HTML
+ */
+ public function prependHTML( $text ) {
+ $this->mBodytext = $text . $this->mBodytext;
+ }
+
+ /**
+ * Append $text to the body HTML
+ *
+ * @param string $text HTML
+ */
+ public function addHTML( $text ) {
+ $this->mBodytext .= $text;
+ }
+
+ /**
+ * Shortcut for adding an Html::element via addHTML.
+ *
+ * @since 1.19
+ *
+ * @param string $element
+ * @param array $attribs
+ * @param string $contents
+ */
+ public function addElement( $element, array $attribs = [], $contents = '' ) {
+ $this->addHTML( Html::element( $element, $attribs, $contents ) );
+ }
+
+ /**
+ * Clear the body HTML
+ */
+ public function clearHTML() {
+ $this->mBodytext = '';
+ }
+
+ /**
+ * Get the body HTML
+ *
+ * @return string HTML
+ */
+ public function getHTML() {
+ return $this->mBodytext;
+ }
+
+ /**
+ * Get/set the ParserOptions object to use for wikitext parsing
+ *
+ * @param ParserOptions|null $options Either the ParserOption to use or null to only get the
+ * current ParserOption object
+ * @return ParserOptions
+ */
+ public function parserOptions( $options = null ) {
+ if ( $options !== null && !empty( $options->isBogus ) ) {
+ // Someone is trying to set a bogus pre-$wgUser PO. Check if it has
+ // been changed somehow, and keep it if so.
+ $anonPO = ParserOptions::newFromAnon();
+ $anonPO->setEditSection( false );
+ $anonPO->setAllowUnsafeRawHtml( false );
+ if ( !$options->matches( $anonPO ) ) {
+ wfLogWarning( __METHOD__ . ': Setting a changed bogus ParserOptions: ' . wfGetAllCallers( 5 ) );
+ $options->isBogus = false;
+ }
+ }
+
+ if ( !$this->mParserOptions ) {
+ if ( !$this->getContext()->getUser()->isSafeToLoad() ) {
+ // $wgUser isn't unstubbable yet, so don't try to get a
+ // ParserOptions for it. And don't cache this ParserOptions
+ // either.
+ $po = ParserOptions::newFromAnon();
+ $po->setEditSection( false );
+ $po->setAllowUnsafeRawHtml( false );
+ $po->isBogus = true;
+ if ( $options !== null ) {
+ $this->mParserOptions = empty( $options->isBogus ) ? $options : null;
+ }
+ return $po;
+ }
+
+ $this->mParserOptions = ParserOptions::newFromContext( $this->getContext() );
+ $this->mParserOptions->setEditSection( false );
+ $this->mParserOptions->setAllowUnsafeRawHtml( false );
+ }
+
+ if ( $options !== null && !empty( $options->isBogus ) ) {
+ // They're trying to restore the bogus pre-$wgUser PO. Do the right
+ // thing.
+ return wfSetVar( $this->mParserOptions, null, true );
+ } else {
+ return wfSetVar( $this->mParserOptions, $options );
+ }
+ }
+
+ /**
+ * Set the revision ID which will be seen by the wiki text parser
+ * for things such as embedded {{REVISIONID}} variable use.
+ *
+ * @param int|null $revid An positive integer, or null
+ * @return mixed Previous value
+ */
+ public function setRevisionId( $revid ) {
+ $val = is_null( $revid ) ? null : intval( $revid );
+ return wfSetVar( $this->mRevisionId, $val );
+ }
+
+ /**
+ * Get the displayed revision ID
+ *
+ * @return int
+ */
+ public function getRevisionId() {
+ return $this->mRevisionId;
+ }
+
+ /**
+ * Set the timestamp of the revision which will be displayed. This is used
+ * to avoid a extra DB call in Skin::lastModified().
+ *
+ * @param string|null $timestamp
+ * @return mixed Previous value
+ */
+ public function setRevisionTimestamp( $timestamp ) {
+ return wfSetVar( $this->mRevisionTimestamp, $timestamp );
+ }
+
+ /**
+ * Get the timestamp of displayed revision.
+ * This will be null if not filled by setRevisionTimestamp().
+ *
+ * @return string|null
+ */
+ public function getRevisionTimestamp() {
+ return $this->mRevisionTimestamp;
+ }
+
+ /**
+ * Set the displayed file version
+ *
+ * @param File|bool $file
+ * @return mixed Previous value
+ */
+ public function setFileVersion( $file ) {
+ $val = null;
+ if ( $file instanceof File && $file->exists() ) {
+ $val = [ 'time' => $file->getTimestamp(), 'sha1' => $file->getSha1() ];
+ }
+ return wfSetVar( $this->mFileVersion, $val, true );
+ }
+
+ /**
+ * Get the displayed file version
+ *
+ * @return array|null ('time' => MW timestamp, 'sha1' => sha1)
+ */
+ public function getFileVersion() {
+ return $this->mFileVersion;
+ }
+
+ /**
+ * Get the templates used on this page
+ *
+ * @return array (namespace => dbKey => revId)
+ * @since 1.18
+ */
+ public function getTemplateIds() {
+ return $this->mTemplateIds;
+ }
+
+ /**
+ * Get the files used on this page
+ *
+ * @return array (dbKey => array('time' => MW timestamp or null, 'sha1' => sha1 or ''))
+ * @since 1.18
+ */
+ public function getFileSearchOptions() {
+ return $this->mImageTimeKeys;
+ }
+
+ /**
+ * Convert wikitext to HTML and add it to the buffer
+ * Default assumes that the current page title will be used.
+ *
+ * @param string $text
+ * @param bool $linestart Is this the start of a line?
+ * @param bool $interface Is this text in the user interface language?
+ * @throws MWException
+ */
+ public function addWikiText( $text, $linestart = true, $interface = true ) {
+ $title = $this->getTitle(); // Work around E_STRICT
+ if ( !$title ) {
+ throw new MWException( 'Title is null' );
+ }
+ $this->addWikiTextTitle( $text, $title, $linestart, /*tidy*/false, $interface );
+ }
+
+ /**
+ * Add wikitext with a custom Title object
+ *
+ * @param string $text Wikitext
+ * @param Title &$title
+ * @param bool $linestart Is this the start of a line?
+ */
+ public function addWikiTextWithTitle( $text, &$title, $linestart = true ) {
+ $this->addWikiTextTitle( $text, $title, $linestart );
+ }
+
+ /**
+ * Add wikitext with a custom Title object and tidy enabled.
+ *
+ * @param string $text Wikitext
+ * @param Title &$title
+ * @param bool $linestart Is this the start of a line?
+ */
+ function addWikiTextTitleTidy( $text, &$title, $linestart = true ) {
+ $this->addWikiTextTitle( $text, $title, $linestart, true );
+ }
+
+ /**
+ * Add wikitext with tidy enabled
+ *
+ * @param string $text Wikitext
+ * @param bool $linestart Is this the start of a line?
+ */
+ public function addWikiTextTidy( $text, $linestart = true ) {
+ $title = $this->getTitle();
+ $this->addWikiTextTitleTidy( $text, $title, $linestart );
+ }
+
+ /**
+ * Add wikitext with a custom Title object
+ *
+ * @param string $text Wikitext
+ * @param Title $title
+ * @param bool $linestart Is this the start of a line?
+ * @param bool $tidy Whether to use tidy
+ * @param bool $interface Whether it is an interface message
+ * (for example disables conversion)
+ */
+ public function addWikiTextTitle( $text, Title $title, $linestart,
+ $tidy = false, $interface = false
+ ) {
+ global $wgParser;
+
+ $popts = $this->parserOptions();
+ $oldTidy = $popts->setTidy( $tidy );
+ $popts->setInterfaceMessage( (bool)$interface );
+
+ $parserOutput = $wgParser->getFreshParser()->parse(
+ $text, $title, $popts,
+ $linestart, true, $this->mRevisionId
+ );
+
+ $popts->setTidy( $oldTidy );
+
+ $this->addParserOutput( $parserOutput );
+ }
+
+ /**
+ * Add all metadata associated with a ParserOutput object, but without the actual HTML. This
+ * includes categories, language links, ResourceLoader modules, effects of certain magic words,
+ * and so on.
+ *
+ * @since 1.24
+ * @param ParserOutput $parserOutput
+ */
+ public function addParserOutputMetadata( $parserOutput ) {
+ $this->mLanguageLinks += $parserOutput->getLanguageLinks();
+ $this->addCategoryLinks( $parserOutput->getCategories() );
+ $this->setIndicators( $parserOutput->getIndicators() );
+ $this->mNewSectionLink = $parserOutput->getNewSection();
+ $this->mHideNewSectionLink = $parserOutput->getHideNewSection();
+
+ if ( !$parserOutput->isCacheable() ) {
+ $this->enableClientCache( false );
+ }
+ $this->mNoGallery = $parserOutput->getNoGallery();
+ $this->mHeadItems = array_merge( $this->mHeadItems, $parserOutput->getHeadItems() );
+ $this->addModules( $parserOutput->getModules() );
+ $this->addModuleScripts( $parserOutput->getModuleScripts() );
+ $this->addModuleStyles( $parserOutput->getModuleStyles() );
+ $this->addJsConfigVars( $parserOutput->getJsConfigVars() );
+ $this->mPreventClickjacking = $this->mPreventClickjacking
+ || $parserOutput->preventClickjacking();
+
+ // Template versioning...
+ foreach ( (array)$parserOutput->getTemplateIds() as $ns => $dbks ) {
+ if ( isset( $this->mTemplateIds[$ns] ) ) {
+ $this->mTemplateIds[$ns] = $dbks + $this->mTemplateIds[$ns];
+ } else {
+ $this->mTemplateIds[$ns] = $dbks;
+ }
+ }
+ // File versioning...
+ foreach ( (array)$parserOutput->getFileSearchOptions() as $dbk => $data ) {
+ $this->mImageTimeKeys[$dbk] = $data;
+ }
+
+ // Hooks registered in the object
+ $parserOutputHooks = $this->getConfig()->get( 'ParserOutputHooks' );
+ foreach ( $parserOutput->getOutputHooks() as $hookInfo ) {
+ list( $hookName, $data ) = $hookInfo;
+ if ( isset( $parserOutputHooks[$hookName] ) ) {
+ call_user_func( $parserOutputHooks[$hookName], $this, $parserOutput, $data );
+ }
+ }
+
+ // Enable OOUI if requested via ParserOutput
+ if ( $parserOutput->getEnableOOUI() ) {
+ $this->enableOOUI();
+ }
+
+ // Include parser limit report
+ if ( !$this->limitReportJSData ) {
+ $this->limitReportJSData = $parserOutput->getLimitReportJSData();
+ }
+
+ // Link flags are ignored for now, but may in the future be
+ // used to mark individual language links.
+ $linkFlags = [];
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $outputPage = $this;
+ Hooks::run( 'LanguageLinks', [ $this->getTitle(), &$this->mLanguageLinks, &$linkFlags ] );
+ Hooks::run( 'OutputPageParserOutput', [ &$outputPage, $parserOutput ] );
+
+ // This check must be after 'OutputPageParserOutput' runs in addParserOutputMetadata
+ // so that extensions may modify ParserOutput to toggle TOC.
+ // This cannot be moved to addParserOutputText because that is not
+ // called by EditPage for Preview.
+ if ( $parserOutput->getTOCEnabled() && $parserOutput->getTOCHTML() ) {
+ $this->mEnableTOC = true;
+ }
+ }
+
+ /**
+ * Add the HTML and enhancements for it (like ResourceLoader modules) associated with a
+ * ParserOutput object, without any other metadata.
+ *
+ * @since 1.24
+ * @param ParserOutput $parserOutput
+ */
+ public function addParserOutputContent( $parserOutput ) {
+ $this->addParserOutputText( $parserOutput );
+
+ $this->addModules( $parserOutput->getModules() );
+ $this->addModuleScripts( $parserOutput->getModuleScripts() );
+ $this->addModuleStyles( $parserOutput->getModuleStyles() );
+
+ $this->addJsConfigVars( $parserOutput->getJsConfigVars() );
+ }
+
+ /**
+ * Add the HTML associated with a ParserOutput object, without any metadata.
+ *
+ * @since 1.24
+ * @param ParserOutput $parserOutput
+ */
+ public function addParserOutputText( $parserOutput ) {
+ $text = $parserOutput->getText();
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $outputPage = $this;
+ Hooks::run( 'OutputPageBeforeHTML', [ &$outputPage, &$text ] );
+ $this->addHTML( $text );
+ }
+
+ /**
+ * Add everything from a ParserOutput object.
+ *
+ * @param ParserOutput $parserOutput
+ */
+ function addParserOutput( $parserOutput ) {
+ $this->addParserOutputMetadata( $parserOutput );
+
+ // Touch section edit links only if not previously disabled
+ if ( $parserOutput->getEditSectionTokens() ) {
+ $parserOutput->setEditSectionTokens( $this->mEnableSectionEditLinks );
+ }
+
+ $this->addParserOutputText( $parserOutput );
+ }
+
+ /**
+ * Add the output of a QuickTemplate to the output buffer
+ *
+ * @param QuickTemplate &$template
+ */
+ public function addTemplate( &$template ) {
+ $this->addHTML( $template->getHTML() );
+ }
+
+ /**
+ * Parse wikitext and return the HTML.
+ *
+ * @param string $text
+ * @param bool $linestart Is this the start of a line?
+ * @param bool $interface Use interface language ($wgLang instead of
+ * $wgContLang) while parsing language sensitive magic words like GRAMMAR and PLURAL.
+ * This also disables LanguageConverter.
+ * @param Language $language Target language object, will override $interface
+ * @throws MWException
+ * @return string HTML
+ */
+ public function parse( $text, $linestart = true, $interface = false, $language = null ) {
+ global $wgParser;
+
+ if ( is_null( $this->getTitle() ) ) {
+ throw new MWException( 'Empty $mTitle in ' . __METHOD__ );
+ }
+
+ $popts = $this->parserOptions();
+ if ( $interface ) {
+ $popts->setInterfaceMessage( true );
+ }
+ if ( $language !== null ) {
+ $oldLang = $popts->setTargetLanguage( $language );
+ }
+
+ $parserOutput = $wgParser->getFreshParser()->parse(
+ $text, $this->getTitle(), $popts,
+ $linestart, true, $this->mRevisionId
+ );
+
+ if ( $interface ) {
+ $popts->setInterfaceMessage( false );
+ }
+ if ( $language !== null ) {
+ $popts->setTargetLanguage( $oldLang );
+ }
+
+ return $parserOutput->getText();
+ }
+
+ /**
+ * Parse wikitext, strip paragraphs, and return the HTML.
+ *
+ * @param string $text
+ * @param bool $linestart Is this the start of a line?
+ * @param bool $interface Use interface language ($wgLang instead of
+ * $wgContLang) while parsing language sensitive magic
+ * words like GRAMMAR and PLURAL
+ * @return string HTML
+ */
+ public function parseInline( $text, $linestart = true, $interface = false ) {
+ $parsed = $this->parse( $text, $linestart, $interface );
+ return Parser::stripOuterParagraph( $parsed );
+ }
+
+ /**
+ * @param int $maxage
+ * @deprecated since 1.27 Use setCdnMaxage() instead
+ */
+ public function setSquidMaxage( $maxage ) {
+ $this->setCdnMaxage( $maxage );
+ }
+
+ /**
+ * Set the value of the "s-maxage" part of the "Cache-control" HTTP header
+ *
+ * @param int $maxage Maximum cache time on the CDN, in seconds.
+ */
+ public function setCdnMaxage( $maxage ) {
+ $this->mCdnMaxage = min( $maxage, $this->mCdnMaxageLimit );
+ }
+
+ /**
+ * Lower the value of the "s-maxage" part of the "Cache-control" HTTP header
+ *
+ * @param int $maxage Maximum cache time on the CDN, in seconds
+ * @since 1.27
+ */
+ public function lowerCdnMaxage( $maxage ) {
+ $this->mCdnMaxageLimit = min( $maxage, $this->mCdnMaxageLimit );
+ $this->setCdnMaxage( $this->mCdnMaxage );
+ }
+
+ /**
+ * Get TTL in [$minTTL,$maxTTL] in pass it to lowerCdnMaxage()
+ *
+ * This sets and returns $minTTL if $mtime is false or null. Otherwise,
+ * the TTL is higher the older the $mtime timestamp is. Essentially, the
+ * TTL is 90% of the age of the object, subject to the min and max.
+ *
+ * @param string|int|float|bool|null $mtime Last-Modified timestamp
+ * @param int $minTTL Mimimum TTL in seconds [default: 1 minute]
+ * @param int $maxTTL Maximum TTL in seconds [default: $wgSquidMaxage]
+ * @return int TTL in seconds
+ * @since 1.28
+ */
+ public function adaptCdnTTL( $mtime, $minTTL = 0, $maxTTL = 0 ) {
+ $minTTL = $minTTL ?: IExpiringStore::TTL_MINUTE;
+ $maxTTL = $maxTTL ?: $this->getConfig()->get( 'SquidMaxage' );
+
+ if ( $mtime === null || $mtime === false ) {
+ return $minTTL; // entity does not exist
+ }
+
+ $age = time() - wfTimestamp( TS_UNIX, $mtime );
+ $adaptiveTTL = max( 0.9 * $age, $minTTL );
+ $adaptiveTTL = min( $adaptiveTTL, $maxTTL );
+
+ $this->lowerCdnMaxage( (int)$adaptiveTTL );
+
+ return $adaptiveTTL;
+ }
+
+ /**
+ * Use enableClientCache(false) to force it to send nocache headers
+ *
+ * @param bool $state
+ *
+ * @return bool
+ */
+ public function enableClientCache( $state ) {
+ return wfSetVar( $this->mEnableClientCache, $state );
+ }
+
+ /**
+ * Get the list of cookies that will influence on the cache
+ *
+ * @return array
+ */
+ function getCacheVaryCookies() {
+ static $cookies;
+ if ( $cookies === null ) {
+ $config = $this->getConfig();
+ $cookies = array_merge(
+ SessionManager::singleton()->getVaryCookies(),
+ [
+ 'forceHTTPS',
+ ],
+ $config->get( 'CacheVaryCookies' )
+ );
+ Hooks::run( 'GetCacheVaryCookies', [ $this, &$cookies ] );
+ }
+ return $cookies;
+ }
+
+ /**
+ * Check if the request has a cache-varying cookie header
+ * If it does, it's very important that we don't allow public caching
+ *
+ * @return bool
+ */
+ function haveCacheVaryCookies() {
+ $request = $this->getRequest();
+ foreach ( $this->getCacheVaryCookies() as $cookieName ) {
+ if ( $request->getCookie( $cookieName, '', '' ) !== '' ) {
+ wfDebug( __METHOD__ . ": found $cookieName\n" );
+ return true;
+ }
+ }
+ wfDebug( __METHOD__ . ": no cache-varying cookies found\n" );
+ return false;
+ }
+
+ /**
+ * Add an HTTP header that will influence on the cache
+ *
+ * @param string $header Header name
+ * @param string[]|null $option Options for the Key header. See
+ * https://datatracker.ietf.org/doc/draft-fielding-http-key/
+ * for the list of valid options.
+ */
+ public function addVaryHeader( $header, array $option = null ) {
+ if ( !array_key_exists( $header, $this->mVaryHeader ) ) {
+ $this->mVaryHeader[$header] = [];
+ }
+ if ( !is_array( $option ) ) {
+ $option = [];
+ }
+ $this->mVaryHeader[$header] = array_unique( array_merge( $this->mVaryHeader[$header], $option ) );
+ }
+
+ /**
+ * Return a Vary: header on which to vary caches. Based on the keys of $mVaryHeader,
+ * such as Accept-Encoding or Cookie
+ *
+ * @return string
+ */
+ public function getVaryHeader() {
+ // If we vary on cookies, let's make sure it's always included here too.
+ if ( $this->getCacheVaryCookies() ) {
+ $this->addVaryHeader( 'Cookie' );
+ }
+
+ foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) {
+ $this->addVaryHeader( $header, $options );
+ }
+ return 'Vary: ' . implode( ', ', array_keys( $this->mVaryHeader ) );
+ }
+
+ /**
+ * Add an HTTP Link: header
+ *
+ * @param string $header Header value
+ */
+ public function addLinkHeader( $header ) {
+ $this->mLinkHeader[] = $header;
+ }
+
+ /**
+ * Return a Link: header. Based on the values of $mLinkHeader.
+ *
+ * @return string
+ */
+ public function getLinkHeader() {
+ if ( !$this->mLinkHeader ) {
+ return false;
+ }
+
+ return 'Link: ' . implode( ',', $this->mLinkHeader );
+ }
+
+ /**
+ * Get a complete Key header
+ *
+ * @return string
+ */
+ public function getKeyHeader() {
+ $cvCookies = $this->getCacheVaryCookies();
+
+ $cookiesOption = [];
+ foreach ( $cvCookies as $cookieName ) {
+ $cookiesOption[] = 'param=' . $cookieName;
+ }
+ $this->addVaryHeader( 'Cookie', $cookiesOption );
+
+ foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) {
+ $this->addVaryHeader( $header, $options );
+ }
+
+ $headers = [];
+ foreach ( $this->mVaryHeader as $header => $option ) {
+ $newheader = $header;
+ if ( is_array( $option ) && count( $option ) > 0 ) {
+ $newheader .= ';' . implode( ';', $option );
+ }
+ $headers[] = $newheader;
+ }
+ $key = 'Key: ' . implode( ',', $headers );
+
+ return $key;
+ }
+
+ /**
+ * T23672: Add Accept-Language to Vary and Key headers
+ * if there's no 'variant' parameter existed in GET.
+ *
+ * For example:
+ * /w/index.php?title=Main_page should always be served; but
+ * /w/index.php?title=Main_page&variant=zh-cn should never be served.
+ */
+ function addAcceptLanguage() {
+ $title = $this->getTitle();
+ if ( !$title instanceof Title ) {
+ return;
+ }
+
+ $lang = $title->getPageLanguage();
+ if ( !$this->getRequest()->getCheck( 'variant' ) && $lang->hasVariants() ) {
+ $variants = $lang->getVariants();
+ $aloption = [];
+ foreach ( $variants as $variant ) {
+ if ( $variant === $lang->getCode() ) {
+ continue;
+ } else {
+ $aloption[] = 'substr=' . $variant;
+
+ // IE and some other browsers use BCP 47 standards in
+ // their Accept-Language header, like "zh-CN" or "zh-Hant".
+ // We should handle these too.
+ $variantBCP47 = wfBCP47( $variant );
+ if ( $variantBCP47 !== $variant ) {
+ $aloption[] = 'substr=' . $variantBCP47;
+ }
+ }
+ }
+ $this->addVaryHeader( 'Accept-Language', $aloption );
+ }
+ }
+
+ /**
+ * Set a flag which will cause an X-Frame-Options header appropriate for
+ * edit pages to be sent. The header value is controlled by
+ * $wgEditPageFrameOptions.
+ *
+ * This is the default for special pages. If you display a CSRF-protected
+ * form on an ordinary view page, then you need to call this function.
+ *
+ * @param bool $enable
+ */
+ public function preventClickjacking( $enable = true ) {
+ $this->mPreventClickjacking = $enable;
+ }
+
+ /**
+ * Turn off frame-breaking. Alias for $this->preventClickjacking(false).
+ * This can be called from pages which do not contain any CSRF-protected
+ * HTML form.
+ */
+ public function allowClickjacking() {
+ $this->mPreventClickjacking = false;
+ }
+
+ /**
+ * Get the prevent-clickjacking flag
+ *
+ * @since 1.24
+ * @return bool
+ */
+ public function getPreventClickjacking() {
+ return $this->mPreventClickjacking;
+ }
+
+ /**
+ * Get the X-Frame-Options header value (without the name part), or false
+ * if there isn't one. This is used by Skin to determine whether to enable
+ * JavaScript frame-breaking, for clients that don't support X-Frame-Options.
+ *
+ * @return string|false
+ */
+ public function getFrameOptions() {
+ $config = $this->getConfig();
+ if ( $config->get( 'BreakFrames' ) ) {
+ return 'DENY';
+ } elseif ( $this->mPreventClickjacking && $config->get( 'EditPageFrameOptions' ) ) {
+ return $config->get( 'EditPageFrameOptions' );
+ }
+ return false;
+ }
+
+ /**
+ * Send cache control HTTP headers
+ */
+ public function sendCacheControl() {
+ $response = $this->getRequest()->response();
+ $config = $this->getConfig();
+
+ $this->addVaryHeader( 'Cookie' );
+ $this->addAcceptLanguage();
+
+ # don't serve compressed data to clients who can't handle it
+ # maintain different caches for logged-in users and non-logged in ones
+ $response->header( $this->getVaryHeader() );
+
+ if ( $config->get( 'UseKeyHeader' ) ) {
+ $response->header( $this->getKeyHeader() );
+ }
+
+ if ( $this->mEnableClientCache ) {
+ if (
+ $config->get( 'UseSquid' ) &&
+ !$response->hasCookies() &&
+ !SessionManager::getGlobalSession()->isPersistent() &&
+ !$this->isPrintable() &&
+ $this->mCdnMaxage != 0 &&
+ !$this->haveCacheVaryCookies()
+ ) {
+ if ( $config->get( 'UseESI' ) ) {
+ # We'll purge the proxy cache explicitly, but require end user agents
+ # to revalidate against the proxy on each visit.
+ # Surrogate-Control controls our CDN, Cache-Control downstream caches
+ wfDebug( __METHOD__ .
+ ": proxy caching with ESI; {$this->mLastModified} **", 'private' );
+ # start with a shorter timeout for initial testing
+ # header( 'Surrogate-Control: max-age=2678400+2678400, content="ESI/1.0"');
+ $response->header(
+ "Surrogate-Control: max-age={$config->get( 'SquidMaxage' )}" .
+ "+{$this->mCdnMaxage}, content=\"ESI/1.0\""
+ );
+ $response->header( 'Cache-Control: s-maxage=0, must-revalidate, max-age=0' );
+ } else {
+ # We'll purge the proxy cache for anons explicitly, but require end user agents
+ # to revalidate against the proxy on each visit.
+ # IMPORTANT! The CDN needs to replace the Cache-Control header with
+ # Cache-Control: s-maxage=0, must-revalidate, max-age=0
+ wfDebug( __METHOD__ .
+ ": local proxy caching; {$this->mLastModified} **", 'private' );
+ # start with a shorter timeout for initial testing
+ # header( "Cache-Control: s-maxage=2678400, must-revalidate, max-age=0" );
+ $response->header( "Cache-Control: " .
+ "s-maxage={$this->mCdnMaxage}, must-revalidate, max-age=0" );
+ }
+ } else {
+ # We do want clients to cache if they can, but they *must* check for updates
+ # on revisiting the page.
+ wfDebug( __METHOD__ . ": private caching; {$this->mLastModified} **", 'private' );
+ $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
+ $response->header( "Cache-Control: private, must-revalidate, max-age=0" );
+ }
+ if ( $this->mLastModified ) {
+ $response->header( "Last-Modified: {$this->mLastModified}" );
+ }
+ } else {
+ wfDebug( __METHOD__ . ": no caching **", 'private' );
+
+ # In general, the absence of a last modified header should be enough to prevent
+ # the client from using its cache. We send a few other things just to make sure.
+ $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
+ $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
+ $response->header( 'Pragma: no-cache' );
+ }
+ }
+
+ /**
+ * Finally, all the text has been munged and accumulated into
+ * the object, let's actually output it:
+ *
+ * @param bool $return Set to true to get the result as a string rather than sending it
+ * @return string|null
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ */
+ public function output( $return = false ) {
+ global $wgContLang;
+
+ if ( $this->mDoNothing ) {
+ return $return ? '' : null;
+ }
+
+ $response = $this->getRequest()->response();
+ $config = $this->getConfig();
+
+ if ( $this->mRedirect != '' ) {
+ # Standards require redirect URLs to be absolute
+ $this->mRedirect = wfExpandUrl( $this->mRedirect, PROTO_CURRENT );
+
+ $redirect = $this->mRedirect;
+ $code = $this->mRedirectCode;
+
+ if ( Hooks::run( "BeforePageRedirect", [ $this, &$redirect, &$code ] ) ) {
+ if ( $code == '301' || $code == '303' ) {
+ if ( !$config->get( 'DebugRedirects' ) ) {
+ $response->statusHeader( $code );
+ }
+ $this->mLastModified = wfTimestamp( TS_RFC2822 );
+ }
+ if ( $config->get( 'VaryOnXFP' ) ) {
+ $this->addVaryHeader( 'X-Forwarded-Proto' );
+ }
+ $this->sendCacheControl();
+
+ $response->header( "Content-Type: text/html; charset=utf-8" );
+ if ( $config->get( 'DebugRedirects' ) ) {
+ $url = htmlspecialchars( $redirect );
+ print "<!DOCTYPE html>\n<html>\n<head>\n<title>Redirect</title>\n</head>\n<body>\n";
+ print "<p>Location: <a href=\"$url\">$url</a></p>\n";
+ print "</body>\n</html>\n";
+ } else {
+ $response->header( 'Location: ' . $redirect );
+ }
+ }
+
+ return $return ? '' : null;
+ } elseif ( $this->mStatusCode ) {
+ $response->statusHeader( $this->mStatusCode );
+ }
+
+ # Buffer output; final headers may depend on later processing
+ ob_start();
+
+ $response->header( 'Content-type: ' . $config->get( 'MimeType' ) . '; charset=UTF-8' );
+ $response->header( 'Content-language: ' . $wgContLang->getHtmlCode() );
+
+ // Avoid Internet Explorer "compatibility view" in IE 8-10, so that
+ // jQuery etc. can work correctly.
+ $response->header( 'X-UA-Compatible: IE=Edge' );
+
+ if ( !$this->mArticleBodyOnly ) {
+ $sk = $this->getSkin();
+
+ if ( $sk->shouldPreloadLogo() ) {
+ $this->addLogoPreloadLinkHeaders();
+ }
+ }
+
+ $linkHeader = $this->getLinkHeader();
+ if ( $linkHeader ) {
+ $response->header( $linkHeader );
+ }
+
+ // Prevent framing, if requested
+ $frameOptions = $this->getFrameOptions();
+ if ( $frameOptions ) {
+ $response->header( "X-Frame-Options: $frameOptions" );
+ }
+
+ if ( $this->mArticleBodyOnly ) {
+ echo $this->mBodytext;
+ } else {
+ // Enable safe mode if requested
+ if ( $this->getRequest()->getBool( 'safemode' ) ) {
+ $this->disallowUserJs();
+ }
+
+ $sk = $this->getSkin();
+ foreach ( $sk->getDefaultModules() as $group ) {
+ $this->addModules( $group );
+ }
+
+ MWDebug::addModules( $this );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $outputPage = $this;
+ // Hook that allows last minute changes to the output page, e.g.
+ // adding of CSS or Javascript by extensions.
+ Hooks::run( 'BeforePageDisplay', [ &$outputPage, &$sk ] );
+
+ try {
+ $sk->outputPage();
+ } catch ( Exception $e ) {
+ ob_end_clean(); // bug T129657
+ throw $e;
+ }
+ }
+
+ try {
+ // This hook allows last minute changes to final overall output by modifying output buffer
+ Hooks::run( 'AfterFinalPageOutput', [ $this ] );
+ } catch ( Exception $e ) {
+ ob_end_clean(); // bug T129657
+ throw $e;
+ }
+
+ $this->sendCacheControl();
+
+ if ( $return ) {
+ return ob_get_clean();
+ } else {
+ ob_end_flush();
+ return null;
+ }
+ }
+
+ /**
+ * Prepare this object to display an error page; disable caching and
+ * indexing, clear the current text and redirect, set the page's title
+ * and optionally an custom HTML title (content of the "<title>" tag).
+ *
+ * @param string|Message $pageTitle Will be passed directly to setPageTitle()
+ * @param string|Message $htmlTitle Will be passed directly to setHTMLTitle();
+ * optional, if not passed the "<title>" attribute will be
+ * based on $pageTitle
+ */
+ public function prepareErrorPage( $pageTitle, $htmlTitle = false ) {
+ $this->setPageTitle( $pageTitle );
+ if ( $htmlTitle !== false ) {
+ $this->setHTMLTitle( $htmlTitle );
+ }
+ $this->setRobotPolicy( 'noindex,nofollow' );
+ $this->setArticleRelated( false );
+ $this->enableClientCache( false );
+ $this->mRedirect = '';
+ $this->clearSubtitle();
+ $this->clearHTML();
+ }
+
+ /**
+ * Output a standard error page
+ *
+ * showErrorPage( 'titlemsg', 'pagetextmsg' );
+ * showErrorPage( 'titlemsg', 'pagetextmsg', [ 'param1', 'param2' ] );
+ * showErrorPage( 'titlemsg', $messageObject );
+ * showErrorPage( $titleMessageObject, $messageObject );
+ *
+ * @param string|Message $title Message key (string) for page title, or a Message object
+ * @param string|Message $msg Message key (string) for page text, or a Message object
+ * @param array $params Message parameters; ignored if $msg is a Message object
+ */
+ public function showErrorPage( $title, $msg, $params = [] ) {
+ if ( !$title instanceof Message ) {
+ $title = $this->msg( $title );
+ }
+
+ $this->prepareErrorPage( $title );
+
+ if ( $msg instanceof Message ) {
+ if ( $params !== [] ) {
+ trigger_error( 'Argument ignored: $params. The message parameters argument '
+ . 'is discarded when the $msg argument is a Message object instead of '
+ . 'a string.', E_USER_NOTICE );
+ }
+ $this->addHTML( $msg->parseAsBlock() );
+ } else {
+ $this->addWikiMsgArray( $msg, $params );
+ }
+
+ $this->returnToMain();
+ }
+
+ /**
+ * Output a standard permission error page
+ *
+ * @param array $errors Error message keys or [key, param...] arrays
+ * @param string $action Action that was denied or null if unknown
+ */
+ public function showPermissionsErrorPage( array $errors, $action = null ) {
+ foreach ( $errors as $key => $error ) {
+ $errors[$key] = (array)$error;
+ }
+
+ // For some action (read, edit, create and upload), display a "login to do this action"
+ // error if all of the following conditions are met:
+ // 1. the user is not logged in
+ // 2. the only error is insufficient permissions (i.e. no block or something else)
+ // 3. the error can be avoided simply by logging in
+ if ( in_array( $action, [ 'read', 'edit', 'createpage', 'createtalk', 'upload' ] )
+ && $this->getUser()->isAnon() && count( $errors ) == 1 && isset( $errors[0][0] )
+ && ( $errors[0][0] == 'badaccess-groups' || $errors[0][0] == 'badaccess-group0' )
+ && ( User::groupHasPermission( 'user', $action )
+ || User::groupHasPermission( 'autoconfirmed', $action ) )
+ ) {
+ $displayReturnto = null;
+
+ # Due to T34276, if a user does not have read permissions,
+ # $this->getTitle() will just give Special:Badtitle, which is
+ # not especially useful as a returnto parameter. Use the title
+ # from the request instead, if there was one.
+ $request = $this->getRequest();
+ $returnto = Title::newFromText( $request->getVal( 'title', '' ) );
+ if ( $action == 'edit' ) {
+ $msg = 'whitelistedittext';
+ $displayReturnto = $returnto;
+ } elseif ( $action == 'createpage' || $action == 'createtalk' ) {
+ $msg = 'nocreatetext';
+ } elseif ( $action == 'upload' ) {
+ $msg = 'uploadnologintext';
+ } else { # Read
+ $msg = 'loginreqpagetext';
+ $displayReturnto = Title::newMainPage();
+ }
+
+ $query = [];
+
+ if ( $returnto ) {
+ $query['returnto'] = $returnto->getPrefixedText();
+
+ if ( !$request->wasPosted() ) {
+ $returntoquery = $request->getValues();
+ unset( $returntoquery['title'] );
+ unset( $returntoquery['returnto'] );
+ unset( $returntoquery['returntoquery'] );
+ $query['returntoquery'] = wfArrayToCgi( $returntoquery );
+ }
+ }
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ $loginLink = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Userlogin' ),
+ $this->msg( 'loginreqlink' )->text(),
+ [],
+ $query
+ );
+
+ $this->prepareErrorPage( $this->msg( 'loginreqtitle' ) );
+ $this->addHTML( $this->msg( $msg )->rawParams( $loginLink )->parse() );
+
+ # Don't return to a page the user can't read otherwise
+ # we'll end up in a pointless loop
+ if ( $displayReturnto && $displayReturnto->userCan( 'read', $this->getUser() ) ) {
+ $this->returnToMain( null, $displayReturnto );
+ }
+ } else {
+ $this->prepareErrorPage( $this->msg( 'permissionserrors' ) );
+ $this->addWikiText( $this->formatPermissionsErrorMessage( $errors, $action ) );
+ }
+ }
+
+ /**
+ * Display an error page indicating that a given version of MediaWiki is
+ * required to use it
+ *
+ * @param mixed $version The version of MediaWiki needed to use the page
+ */
+ public function versionRequired( $version ) {
+ $this->prepareErrorPage( $this->msg( 'versionrequired', $version ) );
+
+ $this->addWikiMsg( 'versionrequiredtext', $version );
+ $this->returnToMain();
+ }
+
+ /**
+ * Format a list of error messages
+ *
+ * @param array $errors Array of arrays returned by Title::getUserPermissionsErrors
+ * @param string $action Action that was denied or null if unknown
+ * @return string The wikitext error-messages, formatted into a list.
+ */
+ public function formatPermissionsErrorMessage( array $errors, $action = null ) {
+ if ( $action == null ) {
+ $text = $this->msg( 'permissionserrorstext', count( $errors ) )->plain() . "\n\n";
+ } else {
+ $action_desc = $this->msg( "action-$action" )->plain();
+ $text = $this->msg(
+ 'permissionserrorstext-withaction',
+ count( $errors ),
+ $action_desc
+ )->plain() . "\n\n";
+ }
+
+ if ( count( $errors ) > 1 ) {
+ $text .= '<ul class="permissions-errors">' . "\n";
+
+ foreach ( $errors as $error ) {
+ $text .= '<li>';
+ $text .= call_user_func_array( [ $this, 'msg' ], $error )->plain();
+ $text .= "</li>\n";
+ }
+ $text .= '</ul>';
+ } else {
+ $text .= "<div class=\"permissions-errors\">\n" .
+ call_user_func_array( [ $this, 'msg' ], reset( $errors ) )->plain() .
+ "\n</div>";
+ }
+
+ return $text;
+ }
+
+ /**
+ * Display a page stating that the Wiki is in read-only mode.
+ * Should only be called after wfReadOnly() has returned true.
+ *
+ * Historically, this function was used to show the source of the page that the user
+ * was trying to edit and _also_ permissions error messages. The relevant code was
+ * moved into EditPage in 1.19 (r102024 / d83c2a431c2a) and removed here in 1.25.
+ *
+ * @deprecated since 1.25; throw the exception directly
+ * @throws ReadOnlyError
+ */
+ public function readOnlyPage() {
+ if ( func_num_args() > 0 ) {
+ throw new MWException( __METHOD__ . ' no longer accepts arguments since 1.25.' );
+ }
+
+ throw new ReadOnlyError;
+ }
+
+ /**
+ * Turn off regular page output and return an error response
+ * for when rate limiting has triggered.
+ *
+ * @deprecated since 1.25; throw the exception directly
+ */
+ public function rateLimited() {
+ wfDeprecated( __METHOD__, '1.25' );
+ throw new ThrottledError;
+ }
+
+ /**
+ * Show a warning about replica DB lag
+ *
+ * If the lag is higher than $wgSlaveLagCritical seconds,
+ * then the warning is a bit more obvious. If the lag is
+ * lower than $wgSlaveLagWarning, then no warning is shown.
+ *
+ * @param int $lag Slave lag
+ */
+ public function showLagWarning( $lag ) {
+ $config = $this->getConfig();
+ if ( $lag >= $config->get( 'SlaveLagWarning' ) ) {
+ $lag = floor( $lag ); // floor to avoid nano seconds to display
+ $message = $lag < $config->get( 'SlaveLagCritical' )
+ ? 'lag-warn-normal'
+ : 'lag-warn-high';
+ $wrap = Html::rawElement( 'div', [ 'class' => "mw-{$message}" ], "\n$1\n" );
+ $this->wrapWikiMsg( "$wrap\n", [ $message, $this->getLanguage()->formatNum( $lag ) ] );
+ }
+ }
+
+ public function showFatalError( $message ) {
+ $this->prepareErrorPage( $this->msg( 'internalerror' ) );
+
+ $this->addHTML( $message );
+ }
+
+ public function showUnexpectedValueError( $name, $val ) {
+ $this->showFatalError( $this->msg( 'unexpected', $name, $val )->text() );
+ }
+
+ public function showFileCopyError( $old, $new ) {
+ $this->showFatalError( $this->msg( 'filecopyerror', $old, $new )->text() );
+ }
+
+ public function showFileRenameError( $old, $new ) {
+ $this->showFatalError( $this->msg( 'filerenameerror', $old, $new )->text() );
+ }
+
+ public function showFileDeleteError( $name ) {
+ $this->showFatalError( $this->msg( 'filedeleteerror', $name )->text() );
+ }
+
+ public function showFileNotFoundError( $name ) {
+ $this->showFatalError( $this->msg( 'filenotfound', $name )->text() );
+ }
+
+ /**
+ * Add a "return to" link pointing to a specified title
+ *
+ * @param Title $title Title to link
+ * @param array $query Query string parameters
+ * @param string $text Text of the link (input is not escaped)
+ * @param array $options Options array to pass to Linker
+ */
+ public function addReturnTo( $title, array $query = [], $text = null, $options = [] ) {
+ $linkRenderer = MediaWikiServices::getInstance()
+ ->getLinkRendererFactory()->createFromLegacyOptions( $options );
+ $link = $this->msg( 'returnto' )->rawParams(
+ $linkRenderer->makeLink( $title, $text, [], $query ) )->escaped();
+ $this->addHTML( "<p id=\"mw-returnto\">{$link}</p>\n" );
+ }
+
+ /**
+ * Add a "return to" link pointing to a specified title,
+ * or the title indicated in the request, or else the main page
+ *
+ * @param mixed $unused
+ * @param Title|string $returnto Title or String to return to
+ * @param string $returntoquery Query string for the return to link
+ */
+ public function returnToMain( $unused = null, $returnto = null, $returntoquery = null ) {
+ if ( $returnto == null ) {
+ $returnto = $this->getRequest()->getText( 'returnto' );
+ }
+
+ if ( $returntoquery == null ) {
+ $returntoquery = $this->getRequest()->getText( 'returntoquery' );
+ }
+
+ if ( $returnto === '' ) {
+ $returnto = Title::newMainPage();
+ }
+
+ if ( is_object( $returnto ) ) {
+ $titleObj = $returnto;
+ } else {
+ $titleObj = Title::newFromText( $returnto );
+ }
+ // We don't want people to return to external interwiki. That
+ // might potentially be used as part of a phishing scheme
+ if ( !is_object( $titleObj ) || $titleObj->isExternal() ) {
+ $titleObj = Title::newMainPage();
+ }
+
+ $this->addReturnTo( $titleObj, wfCgiToArray( $returntoquery ) );
+ }
+
+ private function getRlClientContext() {
+ if ( !$this->rlClientContext ) {
+ $query = ResourceLoader::makeLoaderQuery(
+ [], // modules; not relevant
+ $this->getLanguage()->getCode(),
+ $this->getSkin()->getSkinName(),
+ $this->getUser()->isLoggedIn() ? $this->getUser()->getName() : null,
+ null, // version; not relevant
+ ResourceLoader::inDebugMode(),
+ null, // only; not relevant
+ $this->isPrintable(),
+ $this->getRequest()->getBool( 'handheld' )
+ );
+ $this->rlClientContext = new ResourceLoaderContext(
+ $this->getResourceLoader(),
+ new FauxRequest( $query )
+ );
+ }
+ return $this->rlClientContext;
+ }
+
+ /**
+ * Call this to freeze the module queue and JS config and create a formatter.
+ *
+ * Depending on the Skin, this may get lazy-initialised in either headElement() or
+ * getBottomScripts(). See SkinTemplate::prepareQuickTemplate(). Calling this too early may
+ * cause unexpected side-effects since disallowUserJs() may be called at any time to change
+ * the module filters retroactively. Skins and extension hooks may also add modules until very
+ * late in the request lifecycle.
+ *
+ * @return ResourceLoaderClientHtml
+ */
+ public function getRlClient() {
+ if ( !$this->rlClient ) {
+ $context = $this->getRlClientContext();
+ $rl = $this->getResourceLoader();
+ $this->addModules( [
+ 'user.options',
+ 'user.tokens',
+ ] );
+ $this->addModuleStyles( [
+ 'site.styles',
+ 'noscript',
+ 'user.styles',
+ ] );
+ $this->getSkin()->setupSkinUserCss( $this );
+
+ // Prepare exempt modules for buildExemptModules()
+ $exemptGroups = [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ];
+ $exemptStates = [];
+ $moduleStyles = $this->getModuleStyles( /*filter*/ true );
+
+ // Preload getTitleInfo for isKnownEmpty calls below and in ResourceLoaderClientHtml
+ // Separate user-specific batch for improved cache-hit ratio.
+ $userBatch = [ 'user.styles', 'user' ];
+ $siteBatch = array_diff( $moduleStyles, $userBatch );
+ $dbr = wfGetDB( DB_REPLICA );
+ ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $siteBatch );
+ ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $userBatch );
+
+ // Filter out modules handled by buildExemptModules()
+ $moduleStyles = array_filter( $moduleStyles,
+ function ( $name ) use ( $rl, $context, &$exemptGroups, &$exemptStates ) {
+ $module = $rl->getModule( $name );
+ if ( $module ) {
+ if ( $name === 'user.styles' && $this->isUserCssPreview() ) {
+ $exemptStates[$name] = 'ready';
+ // Special case in buildExemptModules()
+ return false;
+ }
+ $group = $module->getGroup();
+ if ( isset( $exemptGroups[$group] ) ) {
+ $exemptStates[$name] = 'ready';
+ if ( !$module->isKnownEmpty( $context ) ) {
+ // E.g. Don't output empty <styles>
+ $exemptGroups[$group][] = $name;
+ }
+ return false;
+ }
+ }
+ return true;
+ }
+ );
+ $this->rlExemptStyleModules = $exemptGroups;
+
+ $isUserModuleFiltered = !$this->filterModules( [ 'user' ] );
+ // If this page filters out 'user', makeResourceLoaderLink will drop it.
+ // Avoid indefinite "loading" state or untrue "ready" state (T145368).
+ if ( !$isUserModuleFiltered ) {
+ // Manually handled by getBottomScripts()
+ $userModule = $rl->getModule( 'user' );
+ $userState = $userModule->isKnownEmpty( $context ) && !$this->isUserJsPreview()
+ ? 'ready'
+ : 'loading';
+ $this->rlUserModuleState = $exemptStates['user'] = $userState;
+ }
+
+ $rlClient = new ResourceLoaderClientHtml( $context, $this->getTarget() );
+ $rlClient->setConfig( $this->getJSVars() );
+ $rlClient->setModules( $this->getModules( /*filter*/ true ) );
+ $rlClient->setModuleStyles( $moduleStyles );
+ $rlClient->setModuleScripts( $this->getModuleScripts( /*filter*/ true ) );
+ $rlClient->setExemptStates( $exemptStates );
+ $this->rlClient = $rlClient;
+ }
+ return $this->rlClient;
+ }
+
+ /**
+ * @param Skin $sk The given Skin
+ * @param bool $includeStyle Unused
+ * @return string The doctype, opening "<html>", and head element.
+ */
+ public function headElement( Skin $sk, $includeStyle = true ) {
+ global $wgContLang;
+
+ $userdir = $this->getLanguage()->getDir();
+ $sitedir = $wgContLang->getDir();
+
+ $pieces = [];
+ $pieces[] = Html::htmlHeader( Sanitizer::mergeAttributes(
+ $this->getRlClient()->getDocumentAttributes(),
+ $sk->getHtmlElementAttributes()
+ ) );
+ $pieces[] = Html::openElement( 'head' );
+
+ if ( $this->getHTMLTitle() == '' ) {
+ $this->setHTMLTitle( $this->msg( 'pagetitle', $this->getPageTitle() )->inContentLanguage() );
+ }
+
+ if ( !Html::isXmlMimeType( $this->getConfig()->get( 'MimeType' ) ) ) {
+ // Add <meta charset="UTF-8">
+ // This should be before <title> since it defines the charset used by
+ // text including the text inside <title>.
+ // The spec recommends defining XHTML5's charset using the XML declaration
+ // instead of meta.
+ // Our XML declaration is output by Html::htmlHeader.
+ // https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-content-type
+ // https://html.spec.whatwg.org/multipage/semantics.html#charset
+ $pieces[] = Html::element( 'meta', [ 'charset' => 'UTF-8' ] );
+ }
+
+ $pieces[] = Html::element( 'title', null, $this->getHTMLTitle() );
+ $pieces[] = $this->getRlClient()->getHeadHtml();
+ $pieces[] = $this->buildExemptModules();
+ $pieces = array_merge( $pieces, array_values( $this->getHeadLinksArray() ) );
+ $pieces = array_merge( $pieces, array_values( $this->mHeadItems ) );
+
+ $min = ResourceLoader::inDebugMode() ? '' : '.min';
+ // Use an IE conditional comment to serve the script only to old IE
+ $pieces[] = '<!--[if lt IE 9]>' .
+ Html::element( 'script', [
+ 'src' => self::transformResourcePath(
+ $this->getConfig(),
+ "/resources/lib/html5shiv/html5shiv{$min}.js"
+ ),
+ ] ) .
+ '<![endif]-->';
+
+ $pieces[] = Html::closeElement( 'head' );
+
+ $bodyClasses = $this->mAdditionalBodyClasses;
+ $bodyClasses[] = 'mediawiki';
+
+ # Classes for LTR/RTL directionality support
+ $bodyClasses[] = $userdir;
+ $bodyClasses[] = "sitedir-$sitedir";
+
+ $underline = $this->getUser()->getOption( 'underline' );
+ if ( $underline < 2 ) {
+ // The following classes can be used here:
+ // * mw-underline-always
+ // * mw-underline-never
+ $bodyClasses[] = 'mw-underline-' . ( $underline ? 'always' : 'never' );
+ }
+
+ if ( $this->getLanguage()->capitalizeAllNouns() ) {
+ # A <body> class is probably not the best way to do this . . .
+ $bodyClasses[] = 'capitalize-all-nouns';
+ }
+
+ // Parser feature migration class
+ // The idea is that this will eventually be removed, after the wikitext
+ // which requires it is cleaned up.
+ $bodyClasses[] = 'mw-hide-empty-elt';
+
+ $bodyClasses[] = $sk->getPageClasses( $this->getTitle() );
+ $bodyClasses[] = 'skin-' . Sanitizer::escapeClass( $sk->getSkinName() );
+ $bodyClasses[] =
+ 'action-' . Sanitizer::escapeClass( Action::getActionName( $this->getContext() ) );
+
+ $bodyAttrs = [];
+ // While the implode() is not strictly needed, it's used for backwards compatibility
+ // (this used to be built as a string and hooks likely still expect that).
+ $bodyAttrs['class'] = implode( ' ', $bodyClasses );
+
+ // Allow skins and extensions to add body attributes they need
+ $sk->addToBodyAttributes( $this, $bodyAttrs );
+ Hooks::run( 'OutputPageBodyAttributes', [ $this, $sk, &$bodyAttrs ] );
+
+ $pieces[] = Html::openElement( 'body', $bodyAttrs );
+
+ return self::combineWrappedStrings( $pieces );
+ }
+
+ /**
+ * Get a ResourceLoader object associated with this OutputPage
+ *
+ * @return ResourceLoader
+ */
+ public function getResourceLoader() {
+ if ( is_null( $this->mResourceLoader ) ) {
+ $this->mResourceLoader = new ResourceLoader(
+ $this->getConfig(),
+ LoggerFactory::getInstance( 'resourceloader' )
+ );
+ }
+ return $this->mResourceLoader;
+ }
+
+ /**
+ * Explicily load or embed modules on a page.
+ *
+ * @param array|string $modules One or more module names
+ * @param string $only ResourceLoaderModule TYPE_ class constant
+ * @param array $extraQuery [optional] Array with extra query parameters for the request
+ * @return string|WrappedStringList HTML
+ */
+ public function makeResourceLoaderLink( $modules, $only, array $extraQuery = [] ) {
+ // Apply 'target' and 'origin' filters
+ $modules = $this->filterModules( (array)$modules, null, $only );
+
+ return ResourceLoaderClientHtml::makeLoad(
+ $this->getRlClientContext(),
+ $modules,
+ $only,
+ $extraQuery
+ );
+ }
+
+ /**
+ * Combine WrappedString chunks and filter out empty ones
+ *
+ * @param array $chunks
+ * @return string|WrappedStringList HTML
+ */
+ protected static function combineWrappedStrings( array $chunks ) {
+ // Filter out empty values
+ $chunks = array_filter( $chunks, 'strlen' );
+ return WrappedString::join( "\n", $chunks );
+ }
+
+ private function isUserJsPreview() {
+ return $this->getConfig()->get( 'AllowUserJs' )
+ && $this->getTitle()
+ && $this->getTitle()->isJsSubpage()
+ && $this->userCanPreview();
+ }
+
+ protected function isUserCssPreview() {
+ return $this->getConfig()->get( 'AllowUserCss' )
+ && $this->getTitle()
+ && $this->getTitle()->isCssSubpage()
+ && $this->userCanPreview();
+ }
+
+ /**
+ * JS stuff to put at the bottom of the `<body>`. These are modules with position 'bottom',
+ * legacy scripts ($this->mScripts), and user JS.
+ *
+ * @return string|WrappedStringList HTML
+ */
+ public function getBottomScripts() {
+ $chunks = [];
+ $chunks[] = $this->getRlClient()->getBodyHtml();
+
+ // Legacy non-ResourceLoader scripts
+ $chunks[] = $this->mScripts;
+
+ // Exempt 'user' module
+ // - May need excludepages for live preview. (T28283)
+ // - Must use TYPE_COMBINED so its response is handled by mw.loader.implement() which
+ // ensures execution is scheduled after the "site" module.
+ // - Don't load if module state is already resolved as "ready".
+ if ( $this->rlUserModuleState === 'loading' ) {
+ if ( $this->isUserJsPreview() ) {
+ $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED,
+ [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
+ );
+ $chunks[] = ResourceLoader::makeInlineScript(
+ Xml::encodeJsCall( 'mw.loader.using', [
+ [ 'user', 'site' ],
+ new XmlJsCode(
+ 'function () {'
+ . Xml::encodeJsCall( '$.globalEval', [
+ $this->getRequest()->getText( 'wpTextbox1' )
+ ] )
+ . '}'
+ )
+ ] )
+ );
+ // FIXME: If the user is previewing, say, ./vector.js, his ./common.js will be loaded
+ // asynchronously and may arrive *after* the inline script here. So the previewed code
+ // may execute before ./common.js runs. Normally, ./common.js runs before ./vector.js.
+ // Similarly, when previewing ./common.js and the user module does arrive first,
+ // it will arrive without common.js and the inline script runs after.
+ // Thus running common after the excluded subpage.
+ } else {
+ // Load normally
+ $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED );
+ }
+ }
+
+ if ( $this->limitReportJSData ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ ResourceLoader::makeConfigSetScript(
+ [ 'wgPageParseReport' => $this->limitReportJSData ]
+ )
+ );
+ }
+
+ return self::combineWrappedStrings( $chunks );
+ }
+
+ /**
+ * Get the javascript config vars to include on this page
+ *
+ * @return array Array of javascript config vars
+ * @since 1.23
+ */
+ public function getJsConfigVars() {
+ return $this->mJsConfigVars;
+ }
+
+ /**
+ * Add one or more variables to be set in mw.config in JavaScript
+ *
+ * @param string|array $keys Key or array of key/value pairs
+ * @param mixed $value [optional] Value of the configuration variable
+ */
+ public function addJsConfigVars( $keys, $value = null ) {
+ if ( is_array( $keys ) ) {
+ foreach ( $keys as $key => $value ) {
+ $this->mJsConfigVars[$key] = $value;
+ }
+ return;
+ }
+
+ $this->mJsConfigVars[$keys] = $value;
+ }
+
+ /**
+ * Get an array containing the variables to be set in mw.config in JavaScript.
+ *
+ * Do not add things here which can be evaluated in ResourceLoaderStartUpModule
+ * - in other words, page-independent/site-wide variables (without state).
+ * You will only be adding bloat to the html page and causing page caches to
+ * have to be purged on configuration changes.
+ * @return array
+ */
+ public function getJSVars() {
+ global $wgContLang;
+
+ $curRevisionId = 0;
+ $articleId = 0;
+ $canonicalSpecialPageName = false; # T23115
+
+ $title = $this->getTitle();
+ $ns = $title->getNamespace();
+ $canonicalNamespace = MWNamespace::exists( $ns )
+ ? MWNamespace::getCanonicalName( $ns )
+ : $title->getNsText();
+
+ $sk = $this->getSkin();
+ // Get the relevant title so that AJAX features can use the correct page name
+ // when making API requests from certain special pages (T36972).
+ $relevantTitle = $sk->getRelevantTitle();
+ $relevantUser = $sk->getRelevantUser();
+
+ if ( $ns == NS_SPECIAL ) {
+ list( $canonicalSpecialPageName, /*...*/ ) =
+ SpecialPageFactory::resolveAlias( $title->getDBkey() );
+ } elseif ( $this->canUseWikiPage() ) {
+ $wikiPage = $this->getWikiPage();
+ $curRevisionId = $wikiPage->getLatest();
+ $articleId = $wikiPage->getId();
+ }
+
+ $lang = $title->getPageViewLanguage();
+
+ // Pre-process information
+ $separatorTransTable = $lang->separatorTransformTable();
+ $separatorTransTable = $separatorTransTable ? $separatorTransTable : [];
+ $compactSeparatorTransTable = [
+ implode( "\t", array_keys( $separatorTransTable ) ),
+ implode( "\t", $separatorTransTable ),
+ ];
+ $digitTransTable = $lang->digitTransformTable();
+ $digitTransTable = $digitTransTable ? $digitTransTable : [];
+ $compactDigitTransTable = [
+ implode( "\t", array_keys( $digitTransTable ) ),
+ implode( "\t", $digitTransTable ),
+ ];
+
+ $user = $this->getUser();
+
+ $vars = [
+ 'wgCanonicalNamespace' => $canonicalNamespace,
+ 'wgCanonicalSpecialPageName' => $canonicalSpecialPageName,
+ 'wgNamespaceNumber' => $title->getNamespace(),
+ 'wgPageName' => $title->getPrefixedDBkey(),
+ 'wgTitle' => $title->getText(),
+ 'wgCurRevisionId' => $curRevisionId,
+ 'wgRevisionId' => (int)$this->getRevisionId(),
+ 'wgArticleId' => $articleId,
+ 'wgIsArticle' => $this->isArticle(),
+ 'wgIsRedirect' => $title->isRedirect(),
+ 'wgAction' => Action::getActionName( $this->getContext() ),
+ 'wgUserName' => $user->isAnon() ? null : $user->getName(),
+ 'wgUserGroups' => $user->getEffectiveGroups(),
+ 'wgCategories' => $this->getCategories(),
+ 'wgBreakFrames' => $this->getFrameOptions() == 'DENY',
+ 'wgPageContentLanguage' => $lang->getCode(),
+ 'wgPageContentModel' => $title->getContentModel(),
+ 'wgSeparatorTransformTable' => $compactSeparatorTransTable,
+ 'wgDigitTransformTable' => $compactDigitTransTable,
+ 'wgDefaultDateFormat' => $lang->getDefaultDateFormat(),
+ 'wgMonthNames' => $lang->getMonthNamesArray(),
+ 'wgMonthNamesShort' => $lang->getMonthAbbreviationsArray(),
+ 'wgRelevantPageName' => $relevantTitle->getPrefixedDBkey(),
+ 'wgRelevantArticleId' => $relevantTitle->getArticleID(),
+ 'wgRequestId' => WebRequest::getRequestId(),
+ ];
+
+ if ( $user->isLoggedIn() ) {
+ $vars['wgUserId'] = $user->getId();
+ $vars['wgUserEditCount'] = $user->getEditCount();
+ $userReg = $user->getRegistration();
+ $vars['wgUserRegistration'] = $userReg ? wfTimestamp( TS_UNIX, $userReg ) * 1000 : null;
+ // Get the revision ID of the oldest new message on the user's talk
+ // page. This can be used for constructing new message alerts on
+ // the client side.
+ $vars['wgUserNewMsgRevisionId'] = $user->getNewMessageRevisionId();
+ }
+
+ if ( $wgContLang->hasVariants() ) {
+ $vars['wgUserVariant'] = $wgContLang->getPreferredVariant();
+ }
+ // Same test as SkinTemplate
+ $vars['wgIsProbablyEditable'] = $title->quickUserCan( 'edit', $user )
+ && ( $title->exists() || $title->quickUserCan( 'create', $user ) );
+
+ $vars['wgRelevantPageIsProbablyEditable'] = $relevantTitle
+ && $relevantTitle->quickUserCan( 'edit', $user )
+ && ( $relevantTitle->exists() || $relevantTitle->quickUserCan( 'create', $user ) );
+
+ foreach ( $title->getRestrictionTypes() as $type ) {
+ $vars['wgRestriction' . ucfirst( $type )] = $title->getRestrictions( $type );
+ }
+
+ if ( $title->isMainPage() ) {
+ $vars['wgIsMainPage'] = true;
+ }
+
+ if ( $this->mRedirectedFrom ) {
+ $vars['wgRedirectedFrom'] = $this->mRedirectedFrom->getPrefixedDBkey();
+ }
+
+ if ( $relevantUser ) {
+ $vars['wgRelevantUserName'] = $relevantUser->getName();
+ }
+
+ // Allow extensions to add their custom variables to the mw.config map.
+ // Use the 'ResourceLoaderGetConfigVars' hook if the variable is not
+ // page-dependant but site-wide (without state).
+ // Alternatively, you may want to use OutputPage->addJsConfigVars() instead.
+ Hooks::run( 'MakeGlobalVariablesScript', [ &$vars, $this ] );
+
+ // Merge in variables from addJsConfigVars last
+ return array_merge( $vars, $this->getJsConfigVars() );
+ }
+
+ /**
+ * To make it harder for someone to slip a user a fake
+ * user-JavaScript or user-CSS preview, a random token
+ * is associated with the login session. If it's not
+ * passed back with the preview request, we won't render
+ * the code.
+ *
+ * @return bool
+ */
+ public function userCanPreview() {
+ $request = $this->getRequest();
+ if (
+ $request->getVal( 'action' ) !== 'submit' ||
+ !$request->getCheck( 'wpPreview' ) ||
+ !$request->wasPosted()
+ ) {
+ return false;
+ }
+
+ $user = $this->getUser();
+
+ if ( !$user->isLoggedIn() ) {
+ // Anons have predictable edit tokens
+ return false;
+ }
+ if ( !$user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
+ return false;
+ }
+
+ $title = $this->getTitle();
+ if ( !$title->isJsSubpage() && !$title->isCssSubpage() ) {
+ return false;
+ }
+ if ( !$title->isSubpageOf( $user->getUserPage() ) ) {
+ // Don't execute another user's CSS or JS on preview (T85855)
+ return false;
+ }
+
+ $errors = $title->getUserPermissionsErrors( 'edit', $user );
+ if ( count( $errors ) !== 0 ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @return array Array in format "link name or number => 'link html'".
+ */
+ public function getHeadLinksArray() {
+ global $wgVersion;
+
+ $tags = [];
+ $config = $this->getConfig();
+
+ $canonicalUrl = $this->mCanonicalUrl;
+
+ $tags['meta-generator'] = Html::element( 'meta', [
+ 'name' => 'generator',
+ 'content' => "MediaWiki $wgVersion",
+ ] );
+
+ if ( $config->get( 'ReferrerPolicy' ) !== false ) {
+ $tags['meta-referrer'] = Html::element( 'meta', [
+ 'name' => 'referrer',
+ 'content' => $config->get( 'ReferrerPolicy' )
+ ] );
+ }
+
+ $p = "{$this->mIndexPolicy},{$this->mFollowPolicy}";
+ if ( $p !== 'index,follow' ) {
+ // http://www.robotstxt.org/wc/meta-user.html
+ // Only show if it's different from the default robots policy
+ $tags['meta-robots'] = Html::element( 'meta', [
+ 'name' => 'robots',
+ 'content' => $p,
+ ] );
+ }
+
+ foreach ( $this->mMetatags as $tag ) {
+ if ( strncasecmp( $tag[0], 'http:', 5 ) === 0 ) {
+ $a = 'http-equiv';
+ $tag[0] = substr( $tag[0], 5 );
+ } elseif ( strncasecmp( $tag[0], 'og:', 3 ) === 0 ) {
+ $a = 'property';
+ } else {
+ $a = 'name';
+ }
+ $tagName = "meta-{$tag[0]}";
+ if ( isset( $tags[$tagName] ) ) {
+ $tagName .= $tag[1];
+ }
+ $tags[$tagName] = Html::element( 'meta',
+ [
+ $a => $tag[0],
+ 'content' => $tag[1]
+ ]
+ );
+ }
+
+ foreach ( $this->mLinktags as $tag ) {
+ $tags[] = Html::element( 'link', $tag );
+ }
+
+ # Universal edit button
+ if ( $config->get( 'UniversalEditButton' ) && $this->isArticleRelated() ) {
+ $user = $this->getUser();
+ if ( $this->getTitle()->quickUserCan( 'edit', $user )
+ && ( $this->getTitle()->exists() ||
+ $this->getTitle()->quickUserCan( 'create', $user ) )
+ ) {
+ // Original UniversalEditButton
+ $msg = $this->msg( 'edit' )->text();
+ $tags['universal-edit-button'] = Html::element( 'link', [
+ 'rel' => 'alternate',
+ 'type' => 'application/x-wiki',
+ 'title' => $msg,
+ 'href' => $this->getTitle()->getEditURL(),
+ ] );
+ // Alternate edit link
+ $tags['alternative-edit'] = Html::element( 'link', [
+ 'rel' => 'edit',
+ 'title' => $msg,
+ 'href' => $this->getTitle()->getEditURL(),
+ ] );
+ }
+ }
+
+ # Generally the order of the favicon and apple-touch-icon links
+ # should not matter, but Konqueror (3.5.9 at least) incorrectly
+ # uses whichever one appears later in the HTML source. Make sure
+ # apple-touch-icon is specified first to avoid this.
+ if ( $config->get( 'AppleTouchIcon' ) !== false ) {
+ $tags['apple-touch-icon'] = Html::element( 'link', [
+ 'rel' => 'apple-touch-icon',
+ 'href' => $config->get( 'AppleTouchIcon' )
+ ] );
+ }
+
+ if ( $config->get( 'Favicon' ) !== false ) {
+ $tags['favicon'] = Html::element( 'link', [
+ 'rel' => 'shortcut icon',
+ 'href' => $config->get( 'Favicon' )
+ ] );
+ }
+
+ # OpenSearch description link
+ $tags['opensearch'] = Html::element( 'link', [
+ 'rel' => 'search',
+ 'type' => 'application/opensearchdescription+xml',
+ 'href' => wfScript( 'opensearch_desc' ),
+ 'title' => $this->msg( 'opensearch-desc' )->inContentLanguage()->text(),
+ ] );
+
+ if ( $config->get( 'EnableAPI' ) ) {
+ # Real Simple Discovery link, provides auto-discovery information
+ # for the MediaWiki API (and potentially additional custom API
+ # support such as WordPress or Twitter-compatible APIs for a
+ # blogging extension, etc)
+ $tags['rsd'] = Html::element( 'link', [
+ 'rel' => 'EditURI',
+ 'type' => 'application/rsd+xml',
+ // Output a protocol-relative URL here if $wgServer is protocol-relative.
+ // Whether RSD accepts relative or protocol-relative URLs is completely
+ // undocumented, though.
+ 'href' => wfExpandUrl( wfAppendQuery(
+ wfScript( 'api' ),
+ [ 'action' => 'rsd' ] ),
+ PROTO_RELATIVE
+ ),
+ ] );
+ }
+
+ # Language variants
+ if ( !$config->get( 'DisableLangConversion' ) ) {
+ $lang = $this->getTitle()->getPageLanguage();
+ if ( $lang->hasVariants() ) {
+ $variants = $lang->getVariants();
+ foreach ( $variants as $variant ) {
+ $tags["variant-$variant"] = Html::element( 'link', [
+ 'rel' => 'alternate',
+ 'hreflang' => wfBCP47( $variant ),
+ 'href' => $this->getTitle()->getLocalURL(
+ [ 'variant' => $variant ] )
+ ]
+ );
+ }
+ # x-default link per https://support.google.com/webmasters/answer/189077?hl=en
+ $tags["variant-x-default"] = Html::element( 'link', [
+ 'rel' => 'alternate',
+ 'hreflang' => 'x-default',
+ 'href' => $this->getTitle()->getLocalURL() ] );
+ }
+ }
+
+ # Copyright
+ if ( $this->copyrightUrl !== null ) {
+ $copyright = $this->copyrightUrl;
+ } else {
+ $copyright = '';
+ if ( $config->get( 'RightsPage' ) ) {
+ $copy = Title::newFromText( $config->get( 'RightsPage' ) );
+
+ if ( $copy ) {
+ $copyright = $copy->getLocalURL();
+ }
+ }
+
+ if ( !$copyright && $config->get( 'RightsUrl' ) ) {
+ $copyright = $config->get( 'RightsUrl' );
+ }
+ }
+
+ if ( $copyright ) {
+ $tags['copyright'] = Html::element( 'link', [
+ 'rel' => 'license',
+ 'href' => $copyright ]
+ );
+ }
+
+ # Feeds
+ if ( $config->get( 'Feed' ) ) {
+ $feedLinks = [];
+
+ foreach ( $this->getSyndicationLinks() as $format => $link ) {
+ # Use the page name for the title. In principle, this could
+ # lead to issues with having the same name for different feeds
+ # corresponding to the same page, but we can't avoid that at
+ # this low a level.
+
+ $feedLinks[] = $this->feedLink(
+ $format,
+ $link,
+ # Used messages: 'page-rss-feed' and 'page-atom-feed' (for an easier grep)
+ $this->msg(
+ "page-{$format}-feed", $this->getTitle()->getPrefixedText()
+ )->text()
+ );
+ }
+
+ # Recent changes feed should appear on every page (except recentchanges,
+ # that would be redundant). Put it after the per-page feed to avoid
+ # changing existing behavior. It's still available, probably via a
+ # menu in your browser. Some sites might have a different feed they'd
+ # like to promote instead of the RC feed (maybe like a "Recent New Articles"
+ # or "Breaking news" one). For this, we see if $wgOverrideSiteFeed is defined.
+ # If so, use it instead.
+ $sitename = $config->get( 'Sitename' );
+ if ( $config->get( 'OverrideSiteFeed' ) ) {
+ foreach ( $config->get( 'OverrideSiteFeed' ) as $type => $feedUrl ) {
+ // Note, this->feedLink escapes the url.
+ $feedLinks[] = $this->feedLink(
+ $type,
+ $feedUrl,
+ $this->msg( "site-{$type}-feed", $sitename )->text()
+ );
+ }
+ } elseif ( !$this->getTitle()->isSpecial( 'Recentchanges' ) ) {
+ $rctitle = SpecialPage::getTitleFor( 'Recentchanges' );
+ foreach ( $config->get( 'AdvertisedFeedTypes' ) as $format ) {
+ $feedLinks[] = $this->feedLink(
+ $format,
+ $rctitle->getLocalURL( [ 'feed' => $format ] ),
+ # For grep: 'site-rss-feed', 'site-atom-feed'
+ $this->msg( "site-{$format}-feed", $sitename )->text()
+ );
+ }
+ }
+
+ # Allow extensions to change the list pf feeds. This hook is primarily for changing,
+ # manipulating or removing existing feed tags. If you want to add new feeds, you should
+ # use OutputPage::addFeedLink() instead.
+ Hooks::run( 'AfterBuildFeedLinks', [ &$feedLinks ] );
+
+ $tags += $feedLinks;
+ }
+
+ # Canonical URL
+ if ( $config->get( 'EnableCanonicalServerLink' ) ) {
+ if ( $canonicalUrl !== false ) {
+ $canonicalUrl = wfExpandUrl( $canonicalUrl, PROTO_CANONICAL );
+ } else {
+ if ( $this->isArticleRelated() ) {
+ // This affects all requests where "setArticleRelated" is true. This is
+ // typically all requests that show content (query title, curid, oldid, diff),
+ // and all wikipage actions (edit, delete, purge, info, history etc.).
+ // It does not apply to File pages and Special pages.
+ // 'history' and 'info' actions address page metadata rather than the page
+ // content itself, so they may not be canonicalized to the view page url.
+ // TODO: this ought to be better encapsulated in the Action class.
+ $action = Action::getActionName( $this->getContext() );
+ if ( in_array( $action, [ 'history', 'info' ] ) ) {
+ $query = "action={$action}";
+ } else {
+ $query = '';
+ }
+ $canonicalUrl = $this->getTitle()->getCanonicalURL( $query );
+ } else {
+ $reqUrl = $this->getRequest()->getRequestURL();
+ $canonicalUrl = wfExpandUrl( $reqUrl, PROTO_CANONICAL );
+ }
+ }
+ }
+ if ( $canonicalUrl !== false ) {
+ $tags[] = Html::element( 'link', [
+ 'rel' => 'canonical',
+ 'href' => $canonicalUrl
+ ] );
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Generate a "<link rel/>" for a feed.
+ *
+ * @param string $type Feed type
+ * @param string $url URL to the feed
+ * @param string $text Value of the "title" attribute
+ * @return string HTML fragment
+ */
+ private function feedLink( $type, $url, $text ) {
+ return Html::element( 'link', [
+ 'rel' => 'alternate',
+ 'type' => "application/$type+xml",
+ 'title' => $text,
+ 'href' => $url ]
+ );
+ }
+
+ /**
+ * Add a local or specified stylesheet, with the given media options.
+ * Internal use only. Use OutputPage::addModuleStyles() if possible.
+ *
+ * @param string $style URL to the file
+ * @param string $media To specify a media type, 'screen', 'printable', 'handheld' or any.
+ * @param string $condition For IE conditional comments, specifying an IE version
+ * @param string $dir Set to 'rtl' or 'ltr' for direction-specific sheets
+ */
+ public function addStyle( $style, $media = '', $condition = '', $dir = '' ) {
+ $options = [];
+ if ( $media ) {
+ $options['media'] = $media;
+ }
+ if ( $condition ) {
+ $options['condition'] = $condition;
+ }
+ if ( $dir ) {
+ $options['dir'] = $dir;
+ }
+ $this->styles[$style] = $options;
+ }
+
+ /**
+ * Adds inline CSS styles
+ * Internal use only. Use OutputPage::addModuleStyles() if possible.
+ *
+ * @param mixed $style_css Inline CSS
+ * @param string $flip Set to 'flip' to flip the CSS if needed
+ */
+ public function addInlineStyle( $style_css, $flip = 'noflip' ) {
+ if ( $flip === 'flip' && $this->getLanguage()->isRTL() ) {
+ # If wanted, and the interface is right-to-left, flip the CSS
+ $style_css = CSSJanus::transform( $style_css, true, false );
+ }
+ $this->mInlineStyles .= Html::inlineStyle( $style_css );
+ }
+
+ /**
+ * Build exempt modules and legacy non-ResourceLoader styles.
+ *
+ * @return string|WrappedStringList HTML
+ */
+ protected function buildExemptModules() {
+ global $wgContLang;
+
+ $chunks = [];
+ // Things that go after the ResourceLoaderDynamicStyles marker
+ $append = [];
+
+ // Exempt 'user' styles module (may need 'excludepages' for live preview)
+ if ( $this->isUserCssPreview() ) {
+ $append[] = $this->makeResourceLoaderLink(
+ 'user.styles',
+ ResourceLoaderModule::TYPE_STYLES,
+ [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
+ );
+
+ // Load the previewed CSS. Janus it if needed.
+ // User-supplied CSS is assumed to in the wiki's content language.
+ $previewedCSS = $this->getRequest()->getText( 'wpTextbox1' );
+ if ( $this->getLanguage()->getDir() !== $wgContLang->getDir() ) {
+ $previewedCSS = CSSJanus::transform( $previewedCSS, true, false );
+ }
+ $append[] = Html::inlineStyle( $previewedCSS );
+ }
+
+ // We want site, private and user styles to override dynamically added styles from
+ // general modules, but we want dynamically added styles to override statically added
+ // style modules. So the order has to be:
+ // - page style modules (formatted by ResourceLoaderClientHtml::getHeadHtml())
+ // - dynamically loaded styles (added by mw.loader before ResourceLoaderDynamicStyles)
+ // - ResourceLoaderDynamicStyles marker
+ // - site/private/user styles
+
+ // Add legacy styles added through addStyle()/addInlineStyle() here
+ $chunks[] = implode( '', $this->buildCssLinksArray() ) . $this->mInlineStyles;
+
+ $chunks[] = Html::element(
+ 'meta',
+ [ 'name' => 'ResourceLoaderDynamicStyles', 'content' => '' ]
+ );
+
+ $separateReq = [ 'site.styles', 'user.styles' ];
+ foreach ( $this->rlExemptStyleModules as $group => $moduleNames ) {
+ // Combinable modules
+ $chunks[] = $this->makeResourceLoaderLink(
+ array_diff( $moduleNames, $separateReq ),
+ ResourceLoaderModule::TYPE_STYLES
+ );
+
+ foreach ( array_intersect( $moduleNames, $separateReq ) as $name ) {
+ // These require their own dedicated request in order to support "@import"
+ // syntax, which is incompatible with concatenation. (T147667, T37562)
+ $chunks[] = $this->makeResourceLoaderLink( $name,
+ ResourceLoaderModule::TYPE_STYLES
+ );
+ }
+ }
+
+ return self::combineWrappedStrings( array_merge( $chunks, $append ) );
+ }
+
+ /**
+ * @return array
+ */
+ public function buildCssLinksArray() {
+ $links = [];
+
+ // Add any extension CSS
+ foreach ( $this->mExtStyles as $url ) {
+ $this->addStyle( $url );
+ }
+ $this->mExtStyles = [];
+
+ foreach ( $this->styles as $file => $options ) {
+ $link = $this->styleLink( $file, $options );
+ if ( $link ) {
+ $links[$file] = $link;
+ }
+ }
+ return $links;
+ }
+
+ /**
+ * Generate \<link\> tags for stylesheets
+ *
+ * @param string $style URL to the file
+ * @param array $options Option, can contain 'condition', 'dir', 'media' keys
+ * @return string HTML fragment
+ */
+ protected function styleLink( $style, array $options ) {
+ if ( isset( $options['dir'] ) ) {
+ if ( $this->getLanguage()->getDir() != $options['dir'] ) {
+ return '';
+ }
+ }
+
+ if ( isset( $options['media'] ) ) {
+ $media = self::transformCssMedia( $options['media'] );
+ if ( is_null( $media ) ) {
+ return '';
+ }
+ } else {
+ $media = 'all';
+ }
+
+ if ( substr( $style, 0, 1 ) == '/' ||
+ substr( $style, 0, 5 ) == 'http:' ||
+ substr( $style, 0, 6 ) == 'https:' ) {
+ $url = $style;
+ } else {
+ $config = $this->getConfig();
+ $url = $config->get( 'StylePath' ) . '/' . $style . '?' .
+ $config->get( 'StyleVersion' );
+ }
+
+ $link = Html::linkedStyle( $url, $media );
+
+ if ( isset( $options['condition'] ) ) {
+ $condition = htmlspecialchars( $options['condition'] );
+ $link = "<!--[if $condition]>$link<![endif]-->";
+ }
+ return $link;
+ }
+
+ /**
+ * Transform path to web-accessible static resource.
+ *
+ * This is used to add a validation hash as query string.
+ * This aids various behaviors:
+ *
+ * - Put long Cache-Control max-age headers on responses for improved
+ * cache performance.
+ * - Get the correct version of a file as expected by the current page.
+ * - Instantly get the updated version of a file after deployment.
+ *
+ * Avoid using this for urls included in HTML as otherwise clients may get different
+ * versions of a resource when navigating the site depending on when the page was cached.
+ * If changes to the url propagate, this is not a problem (e.g. if the url is in
+ * an external stylesheet).
+ *
+ * @since 1.27
+ * @param Config $config
+ * @param string $path Path-absolute URL to file (from document root, must start with "/")
+ * @return string URL
+ */
+ public static function transformResourcePath( Config $config, $path ) {
+ global $IP;
+
+ $localDir = $IP;
+ $remotePathPrefix = $config->get( 'ResourceBasePath' );
+ if ( $remotePathPrefix === '' ) {
+ // The configured base path is required to be empty string for
+ // wikis in the domain root
+ $remotePath = '/';
+ } else {
+ $remotePath = $remotePathPrefix;
+ }
+ if ( strpos( $path, $remotePath ) !== 0 || substr( $path, 0, 2 ) === '//' ) {
+ // - Path is outside wgResourceBasePath, ignore.
+ // - Path is protocol-relative. Fixes T155310. Not supported by RelPath lib.
+ return $path;
+ }
+ // For files in resources, extensions/ or skins/, ResourceBasePath is preferred here.
+ // For other misc files in $IP, we'll fallback to that as well. There is, however, a fourth
+ // supported dir/path pair in the configuration (wgUploadDirectory, wgUploadPath)
+ // which is not expected to be in wgResourceBasePath on CDNs. (T155146)
+ $uploadPath = $config->get( 'UploadPath' );
+ if ( strpos( $path, $uploadPath ) === 0 ) {
+ $localDir = $config->get( 'UploadDirectory' );
+ $remotePathPrefix = $remotePath = $uploadPath;
+ }
+
+ $path = RelPath\getRelativePath( $path, $remotePath );
+ return self::transformFilePath( $remotePathPrefix, $localDir, $path );
+ }
+
+ /**
+ * Utility method for transformResourceFilePath().
+ *
+ * Caller is responsible for ensuring the file exists. Emits a PHP warning otherwise.
+ *
+ * @since 1.27
+ * @param string $remotePathPrefix URL path prefix that points to $localPath
+ * @param string $localPath File directory exposed at $remotePath
+ * @param string $file Path to target file relative to $localPath
+ * @return string URL
+ */
+ public static function transformFilePath( $remotePathPrefix, $localPath, $file ) {
+ $hash = md5_file( "$localPath/$file" );
+ if ( $hash === false ) {
+ wfLogWarning( __METHOD__ . ": Failed to hash $localPath/$file" );
+ $hash = '';
+ }
+ return "$remotePathPrefix/$file?" . substr( $hash, 0, 5 );
+ }
+
+ /**
+ * Transform "media" attribute based on request parameters
+ *
+ * @param string $media Current value of the "media" attribute
+ * @return string Modified value of the "media" attribute, or null to skip
+ * this stylesheet
+ */
+ public static function transformCssMedia( $media ) {
+ global $wgRequest;
+
+ // https://www.w3.org/TR/css3-mediaqueries/#syntax
+ $screenMediaQueryRegex = '/^(?:only\s+)?screen\b/i';
+
+ // Switch in on-screen display for media testing
+ $switches = [
+ 'printable' => 'print',
+ 'handheld' => 'handheld',
+ ];
+ foreach ( $switches as $switch => $targetMedia ) {
+ if ( $wgRequest->getBool( $switch ) ) {
+ if ( $media == $targetMedia ) {
+ $media = '';
+ } elseif ( preg_match( $screenMediaQueryRegex, $media ) === 1 ) {
+ /* This regex will not attempt to understand a comma-separated media_query_list
+ *
+ * Example supported values for $media:
+ * 'screen', 'only screen', 'screen and (min-width: 982px)' ),
+ * Example NOT supported value for $media:
+ * '3d-glasses, screen, print and resolution > 90dpi'
+ *
+ * If it's a print request, we never want any kind of screen stylesheets
+ * If it's a handheld request (currently the only other choice with a switch),
+ * we don't want simple 'screen' but we might want screen queries that
+ * have a max-width or something, so we'll pass all others on and let the
+ * client do the query.
+ */
+ if ( $targetMedia == 'print' || $media == 'screen' ) {
+ return null;
+ }
+ }
+ }
+ }
+
+ return $media;
+ }
+
+ /**
+ * Add a wikitext-formatted message to the output.
+ * This is equivalent to:
+ *
+ * $wgOut->addWikiText( wfMessage( ... )->plain() )
+ */
+ public function addWikiMsg( /*...*/ ) {
+ $args = func_get_args();
+ $name = array_shift( $args );
+ $this->addWikiMsgArray( $name, $args );
+ }
+
+ /**
+ * Add a wikitext-formatted message to the output.
+ * Like addWikiMsg() except the parameters are taken as an array
+ * instead of a variable argument list.
+ *
+ * @param string $name
+ * @param array $args
+ */
+ public function addWikiMsgArray( $name, $args ) {
+ $this->addHTML( $this->msg( $name, $args )->parseAsBlock() );
+ }
+
+ /**
+ * This function takes a number of message/argument specifications, wraps them in
+ * some overall structure, and then parses the result and adds it to the output.
+ *
+ * In the $wrap, $1 is replaced with the first message, $2 with the second,
+ * and so on. The subsequent arguments may be either
+ * 1) strings, in which case they are message names, or
+ * 2) arrays, in which case, within each array, the first element is the message
+ * name, and subsequent elements are the parameters to that message.
+ *
+ * Don't use this for messages that are not in the user's interface language.
+ *
+ * For example:
+ *
+ * $wgOut->wrapWikiMsg( "<div class='error'>\n$1\n</div>", 'some-error' );
+ *
+ * Is equivalent to:
+ *
+ * $wgOut->addWikiText( "<div class='error'>\n"
+ * . wfMessage( 'some-error' )->plain() . "\n</div>" );
+ *
+ * The newline after the opening div is needed in some wikitext. See T21226.
+ *
+ * @param string $wrap
+ */
+ public function wrapWikiMsg( $wrap /*, ...*/ ) {
+ $msgSpecs = func_get_args();
+ array_shift( $msgSpecs );
+ $msgSpecs = array_values( $msgSpecs );
+ $s = $wrap;
+ foreach ( $msgSpecs as $n => $spec ) {
+ if ( is_array( $spec ) ) {
+ $args = $spec;
+ $name = array_shift( $args );
+ if ( isset( $args['options'] ) ) {
+ unset( $args['options'] );
+ wfDeprecated(
+ 'Adding "options" to ' . __METHOD__ . ' is no longer supported',
+ '1.20'
+ );
+ }
+ } else {
+ $args = [];
+ $name = $spec;
+ }
+ $s = str_replace( '$' . ( $n + 1 ), $this->msg( $name, $args )->plain(), $s );
+ }
+ $this->addWikiText( $s );
+ }
+
+ /**
+ * Whether the output has a table of contents
+ * @return bool
+ * @since 1.22
+ */
+ public function isTOCEnabled() {
+ return $this->mEnableTOC;
+ }
+
+ /**
+ * Enables/disables section edit links, doesn't override __NOEDITSECTION__
+ * @param bool $flag
+ * @since 1.23
+ */
+ public function enableSectionEditLinks( $flag = true ) {
+ $this->mEnableSectionEditLinks = $flag;
+ }
+
+ /**
+ * @return bool
+ * @since 1.23
+ */
+ public function sectionEditLinksEnabled() {
+ return $this->mEnableSectionEditLinks;
+ }
+
+ /**
+ * Helper function to setup the PHP implementation of OOUI to use in this request.
+ *
+ * @since 1.26
+ * @param String $skinName The Skin name to determine the correct OOUI theme
+ * @param String $dir Language direction
+ */
+ public static function setupOOUI( $skinName = 'default', $dir = 'ltr' ) {
+ $themes = ResourceLoaderOOUIModule::getSkinThemeMap();
+ $theme = isset( $themes[$skinName] ) ? $themes[$skinName] : $themes['default'];
+ // For example, 'OOUI\WikimediaUITheme'.
+ $themeClass = "OOUI\\{$theme}Theme";
+ OOUI\Theme::setSingleton( new $themeClass() );
+ OOUI\Element::setDefaultDir( $dir );
+ }
+
+ /**
+ * Add ResourceLoader module styles for OOUI and set up the PHP implementation of it for use with
+ * MediaWiki and this OutputPage instance.
+ *
+ * @since 1.25
+ */
+ public function enableOOUI() {
+ self::setupOOUI(
+ strtolower( $this->getSkin()->getSkinName() ),
+ $this->getLanguage()->getDir()
+ );
+ $this->addModuleStyles( [
+ 'oojs-ui-core.styles',
+ 'oojs-ui.styles.indicators',
+ 'oojs-ui.styles.textures',
+ 'mediawiki.widgets.styles',
+ 'oojs-ui.styles.icons-content',
+ 'oojs-ui.styles.icons-alerts',
+ 'oojs-ui.styles.icons-interactions',
+ ] );
+ }
+
+ /**
+ * Add Link headers for preloading the wiki's logo.
+ *
+ * @since 1.26
+ */
+ protected function addLogoPreloadLinkHeaders() {
+ $logo = ResourceLoaderSkinModule::getLogo( $this->getConfig() );
+
+ $tags = [];
+ $logosPerDppx = [];
+ $logos = [];
+
+ if ( !is_array( $logo ) ) {
+ // No media queries required if we only have one variant
+ $this->addLinkHeader( '<' . $logo . '>;rel=preload;as=image' );
+ return;
+ }
+
+ foreach ( $logo as $dppx => $src ) {
+ // Keys are in this format: "1.5x"
+ $dppx = substr( $dppx, 0, -1 );
+ $logosPerDppx[$dppx] = $src;
+ }
+
+ // Because PHP can't have floats as array keys
+ uksort( $logosPerDppx, function ( $a , $b ) {
+ $a = floatval( $a );
+ $b = floatval( $b );
+
+ if ( $a == $b ) {
+ return 0;
+ }
+ // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
+ return ( $a < $b ) ? -1 : 1;
+ } );
+
+ foreach ( $logosPerDppx as $dppx => $src ) {
+ $logos[] = [ 'dppx' => $dppx, 'src' => $src ];
+ }
+
+ $logosCount = count( $logos );
+ // Logic must match ResourceLoaderSkinModule:
+ // - 1x applies to resolution < 1.5dppx
+ // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
+ // - 2x applies to resolution >= 2dppx
+ // Note that min-resolution and max-resolution are both inclusive.
+ for ( $i = 0; $i < $logosCount; $i++ ) {
+ if ( $i === 0 ) {
+ // Smallest dppx
+ // min-resolution is ">=" (larger than or equal to)
+ // "not min-resolution" is essentially "<"
+ $media_query = 'not all and (min-resolution: ' . $logos[ 1 ]['dppx'] . 'dppx)';
+ } elseif ( $i !== $logosCount - 1 ) {
+ // In between
+ // Media query expressions can only apply "not" to the entire expression
+ // (e.g. can't express ">= 1.5 and not >= 2).
+ // Workaround: Use <= 1.9999 in place of < 2.
+ $upper_bound = floatval( $logos[ $i + 1 ]['dppx'] ) - 0.000001;
+ $media_query = '(min-resolution: ' . $logos[ $i ]['dppx'] .
+ 'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
+ } else {
+ // Largest dppx
+ $media_query = '(min-resolution: ' . $logos[ $i ]['dppx'] . 'dppx)';
+ }
+
+ $this->addLinkHeader(
+ '<' . $logos[$i]['src'] . '>;rel=preload;as=image;media=' . $media_query
+ );
+ }
+ }
+}
diff --git a/www/wiki/includes/PHPVersionCheck.php b/www/wiki/includes/PHPVersionCheck.php
new file mode 100644
index 00000000..66b7158b
--- /dev/null
+++ b/www/wiki/includes/PHPVersionCheck.php
@@ -0,0 +1,349 @@
+<?php
+// @codingStandardsIgnoreFile Generic.Arrays.DisallowLongArraySyntax
+// @codingStandardsIgnoreFile Generic.Files.LineLength
+// @codingStandardsIgnoreFile MediaWiki.Usage.DirUsage.FunctionFound
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Check PHP Version, as well as for composer dependencies in entry points,
+ * and display something vaguely comprehensible in the event of a totally
+ * unrecoverable error.
+ * @class
+ */
+class PHPVersionCheck {
+ /* @var string The number of the MediaWiki version used */
+ var $mwVersion = '1.30';
+ var $functionsExtensionsMapping = array(
+ 'mb_substr' => 'mbstring',
+ 'utf8_encode' => 'xml',
+ 'ctype_digit' => 'ctype',
+ 'json_decode' => 'json',
+ 'iconv' => 'iconv',
+ 'mime_content_type' => 'fileinfo',
+ );
+
+ /**
+ * @var string Which entry point we are protecting. One of:
+ * - index.php
+ * - load.php
+ * - api.php
+ * - mw-config/index.php
+ * - cli
+ */
+ var $entryPoint = null;
+
+ /**
+ * @param string $entryPoint Which entry point we are protecting. One of:
+ * - index.php
+ * - load.php
+ * - api.php
+ * - mw-config/index.php
+ * - cli
+ * @return $this
+ */
+ function setEntryPoint( $entryPoint ) {
+ $this->entryPoint = $entryPoint;
+ }
+
+ /**
+ * Returns the version of the installed php implementation.
+ *
+ * @param string $impl By default, the function returns the info of the currently installed PHP
+ * implementation. Using this parameter the caller can decide, what version info will be
+ * returned. Valid values: HHVM, PHP
+ * @return array An array of information about the php implementation, containing:
+ * - 'version': The version of the php implementation (specific to the implementation, not
+ * the version of the implemented php version)
+ * - 'implementation': The name of the implementation used
+ * - 'vendor': The development group, vendor or developer of the implementation.
+ * - 'upstreamSupported': The minimum version of the implementation supported by the named vendor.
+ * - 'minSupported': The minimum version supported by MediWiki
+ * - 'upgradeURL': The URL to the website of the implementation that contains
+ * upgrade/installation instructions.
+ */
+ function getPHPInfo( $impl = false ) {
+ if (
+ ( defined( 'HHVM_VERSION' ) && $impl !== 'PHP' ) ||
+ $impl === 'HHVM'
+ ) {
+ return array(
+ 'implementation' => 'HHVM',
+ 'version' => defined( 'HHVM_VERSION' ) ? HHVM_VERSION : 'undefined',
+ 'vendor' => 'Facebook',
+ 'upstreamSupported' => '3.6.5',
+ 'minSupported' => '3.6.5',
+ 'upgradeURL' => 'https://docs.hhvm.com/hhvm/installation/introduction',
+ );
+ }
+ return array(
+ 'implementation' => 'PHP',
+ 'version' => PHP_VERSION,
+ 'vendor' => 'the PHP Group',
+ 'upstreamSupported' => '5.5.0',
+ 'minSupported' => '5.5.9',
+ 'upgradeURL' => 'https://secure.php.net/downloads.php',
+ );
+ }
+
+ /**
+ * Displays an error, if the installed php version does not meet the minimum requirement.
+ *
+ * @return $this
+ */
+ function checkRequiredPHPVersion() {
+ $phpInfo = $this->getPHPInfo();
+ $minimumVersion = $phpInfo['minSupported'];
+ $otherInfo = $this->getPHPInfo( $phpInfo['implementation'] === 'HHVM' ? 'PHP' : 'HHVM' );
+ if (
+ !function_exists( 'version_compare' )
+ || version_compare( $phpInfo['version'], $minimumVersion ) < 0
+ ) {
+ $shortText = "MediaWiki $this->mwVersion requires at least {$phpInfo['implementation']}"
+ . " version $minimumVersion or {$otherInfo['implementation']} version "
+ . "{$otherInfo['minSupported']}, you are using {$phpInfo['implementation']} "
+ . "{$phpInfo['version']}.";
+
+ $longText = "Error: You might be using an older {$phpInfo['implementation']} version. \n"
+ . "MediaWiki $this->mwVersion needs {$phpInfo['implementation']}"
+ . " $minimumVersion or higher or {$otherInfo['implementation']} version "
+ . "{$otherInfo['minSupported']}.\n\nCheck if you have a"
+ . " newer php executable with a different name, such as php5.\n\n";
+
+ $longHtml = <<<HTML
+ Please consider <a href="{$phpInfo['upgradeURL']}">upgrading your copy of
+ {$phpInfo['implementation']}</a>.
+ {$phpInfo['implementation']} versions less than {$phpInfo['upstreamSupported']} are no
+ longer supported by {$phpInfo['vendor']} and will not receive
+ security or bugfix updates.
+ </p>
+ <p>
+ If for some reason you are unable to upgrade your {$phpInfo['implementation']} version,
+ you will need to <a href="https://www.mediawiki.org/wiki/Download">download</a> an
+ older version of MediaWiki from our website.
+ See our <a href="https://www.mediawiki.org/wiki/Compatibility#PHP">compatibility page</a>
+ for details of which versions are compatible with prior versions of {$phpInfo['implementation']}.
+HTML;
+ $this->triggerError(
+ "Supported {$phpInfo['implementation']} versions",
+ $shortText,
+ $longText,
+ $longHtml
+ );
+ }
+ }
+
+ /**
+ * Displays an error, if the vendor/autoload.php file could not be found.
+ *
+ * @return $this
+ */
+ function checkVendorExistence() {
+ if ( !file_exists( dirname( __FILE__ ) . '/../vendor/autoload.php' ) ) {
+ $shortText = "Installing some external dependencies (e.g. via composer) is required.";
+
+ $longText = "Error: You are missing some external dependencies. \n"
+ . "MediaWiki now also has some external dependencies that need to be installed\n"
+ . "via composer or from a separate git repo. Please see\n"
+ . "https://www.mediawiki.org/wiki/Download_from_Git#Fetch_external_libraries\n"
+ . "for help on installing the required components.";
+
+ $longHtml = <<<HTML
+ MediaWiki now also has some external dependencies that need to be installed via
+ composer or from a separate git repo. Please see
+ <a href="https://www.mediawiki.org/wiki/Download_from_Git#Fetch_external_libraries">mediawiki.org</a>
+ for help on installing the required components.
+HTML;
+
+ $this->triggerError( 'External dependencies', $shortText, $longText, $longHtml );
+ }
+ }
+
+ /**
+ * Displays an error, if a PHP extension does not exist.
+ *
+ * @return $this
+ */
+ function checkExtensionExistence() {
+ $missingExtensions = array();
+ foreach ( $this->functionsExtensionsMapping as $function => $extension ) {
+ if ( !function_exists( $function ) ) {
+ $missingExtensions[] = $extension;
+ }
+ }
+
+ if ( $missingExtensions ) {
+ $shortText = "Installing some PHP extensions is required.";
+
+ $missingExtText = '';
+ $missingExtHtml = '';
+ $baseUrl = 'https://secure.php.net';
+ foreach ( $missingExtensions as $ext ) {
+ $missingExtText .= " * $ext <$baseUrl/$ext>\n";
+ $missingExtHtml .= "<li><b>$ext</b> "
+ . "(<a href=\"$baseUrl/$ext\">more information</a>)</li>";
+ }
+
+ $cliText = "Error: Missing one or more required components of PHP.\n"
+ . "You are missing a required extension to PHP that MediaWiki needs.\n"
+ . "Please install:\n" . $missingExtText;
+
+ $longHtml = <<<HTML
+ You are missing a required extension to PHP that MediaWiki
+ requires to run. Please install:
+ <ul>
+ $missingExtHtml
+ </ul>
+HTML;
+
+ $this->triggerError( 'Required components', $shortText, $cliText, $longHtml );
+ }
+ }
+
+ /**
+ * Output headers that prevents error pages to be cached.
+ */
+ function outputHTMLHeader() {
+ $protocol = isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0';
+
+ header( "$protocol 500 MediaWiki configuration Error" );
+ // Don't cache error pages! They cause no end of trouble...
+ header( 'Cache-control: none' );
+ header( 'Pragma: no-cache' );
+ }
+
+ /**
+ * Returns an error page, which is suitable for output to the end user via a web browser.
+ *
+ * @param string $title
+ * @param string $longHtml
+ * @param string $shortText
+ * @return string
+ */
+ function getIndexErrorOutput( $title, $longHtml, $shortText ) {
+ $pathinfo = pathinfo( $_SERVER['SCRIPT_NAME'] );
+ if ( $this->entryPoint == 'mw-config/index.php' ) {
+ $dirname = dirname( $pathinfo['dirname'] );
+ } else {
+ $dirname = $pathinfo['dirname'];
+ }
+ $encLogo =
+ htmlspecialchars( str_replace( '//', '/', $dirname . '/' ) .
+ 'resources/assets/mediawiki.png' );
+ $shortHtml = htmlspecialchars( $shortText );
+
+ header( 'Content-type: text/html; charset=UTF-8' );
+
+ $finalOutput = <<<HTML
+<!DOCTYPE html>
+<html lang="en" dir="ltr">
+ <head>
+ <meta charset="UTF-8" />
+ <title>MediaWiki {$this->mwVersion}</title>
+ <style media='screen'>
+ body {
+ color: #000;
+ background-color: #fff;
+ font-family: sans-serif;
+ padding: 2em;
+ text-align: center;
+ }
+ p, img, h1, h2, ul {
+ text-align: left;
+ margin: 0.5em 0 1em;
+ }
+ h1 {
+ font-size: 120%;
+ }
+ h2 {
+ font-size: 110%;
+ }
+ </style>
+ </head>
+ <body>
+ <img src="{$encLogo}" alt='The MediaWiki logo' />
+ <h1>MediaWiki {$this->mwVersion} internal error</h1>
+ <div class='error'>
+ <p>
+ {$shortHtml}
+ </p>
+ <h2>{$title}</h2>
+ <p>
+ {$longHtml}
+ </p>
+ </div>
+ </body>
+</html>
+HTML;
+
+ return $finalOutput;
+ }
+
+ /**
+ * Display something vaguely comprehensible in the event of a totally unrecoverable error.
+ * Does not assume access to *anything*; no globals, no autoloader, no database, no localisation.
+ * Safe for PHP4 (and putting this here means that WebStart.php and GlobalSettings.php
+ * no longer need to be).
+ *
+ * Calling this function kills execution immediately.
+ *
+ * @param string $title HTML code to be put within an <h2> tag
+ * @param string $shortText
+ * @param string $longText
+ * @param string $longHtml
+ */
+ function triggerError( $title, $shortText, $longText, $longHtml ) {
+ switch ( $this->entryPoint ) {
+ case 'cli':
+ $finalOutput = $longText;
+ break;
+ case 'index.php':
+ case 'mw-config/index.php':
+ $this->outputHTMLHeader();
+ $finalOutput = $this->getIndexErrorOutput( $title, $longHtml, $shortText );
+ break;
+ case 'load.php':
+ $this->outputHTMLHeader();
+ $finalOutput = "/* $shortText */";
+ break;
+ default:
+ $this->outputHTMLHeader();
+ // Handle everything that's not index.php
+ $finalOutput = $shortText;
+ }
+
+ echo "$finalOutput\n";
+ die( 1 );
+ }
+}
+
+/**
+ * Check php version and that external dependencies are installed, and
+ * display an informative error if either condition is not satisfied.
+ *
+ * @note Since we can't rely on anything, the minimum PHP versions and MW current
+ * version are hardcoded here
+ */
+function wfEntryPointCheck( $entryPoint ) {
+ $phpVersionCheck = new PHPVersionCheck();
+ $phpVersionCheck->setEntryPoint( $entryPoint );
+ $phpVersionCheck->checkRequiredPHPVersion();
+ $phpVersionCheck->checkVendorExistence();
+ $phpVersionCheck->checkExtensionExistence();
+}
diff --git a/www/wiki/includes/PHPVersionError.php b/www/wiki/includes/PHPVersionError.php
new file mode 100644
index 00000000..9fbcf895
--- /dev/null
+++ b/www/wiki/includes/PHPVersionError.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Backwards compatibility. The PHP version error function is now
+ * included in PHPVersionCheck.php.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @deprecated 1.25
+ * @file
+ */
+// @codingStandardsIgnoreStart MediaWiki.Usage.DirUsage.FunctionFound
+require_once dirname( __FILE__ ) . '/PHPVersionCheck.php';
+// @codingStandardsIgnoreEnd
diff --git a/www/wiki/includes/PageProps.php b/www/wiki/includes/PageProps.php
new file mode 100644
index 00000000..ff8deee3
--- /dev/null
+++ b/www/wiki/includes/PageProps.php
@@ -0,0 +1,316 @@
+<?php
+/**
+ * Access to properties of a page.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Wikimedia\ScopedCallback;
+
+/**
+ * Gives access to properties of a page.
+ *
+ * @since 1.27
+ */
+class PageProps {
+
+ /**
+ * @var PageProps
+ */
+ private static $instance;
+
+ /**
+ * Overrides the default instance of this class
+ * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+ *
+ * If this method is used it MUST also be called with null after a test to ensure a new
+ * default instance is created next time getInstance is called.
+ *
+ * @since 1.27
+ *
+ * @param PageProps|null $store
+ *
+ * @return ScopedCallback to reset the overridden value
+ * @throws MWException
+ */
+ public static function overrideInstance( PageProps $store = null ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException(
+ 'Cannot override ' . __CLASS__ . 'default instance in operation.'
+ );
+ }
+ $previousValue = self::$instance;
+ self::$instance = $store;
+ return new ScopedCallback( function () use ( $previousValue ) {
+ self::$instance = $previousValue;
+ } );
+ }
+
+ /**
+ * @return PageProps
+ */
+ public static function getInstance() {
+ if ( self::$instance === null ) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /** Cache parameters */
+ const CACHE_TTL = 10; // integer; TTL in seconds
+ const CACHE_SIZE = 100; // integer; max cached pages
+
+ /** Property cache */
+ private $cache = null;
+
+ /**
+ * Create a PageProps object
+ */
+ private function __construct() {
+ $this->cache = new ProcessCacheLRU( self::CACHE_SIZE );
+ }
+
+ /**
+ * Ensure that cache has at least this size
+ * @param int $size
+ */
+ public function ensureCacheSize( $size ) {
+ if ( $this->cache->getSize() < $size ) {
+ $this->cache->resize( $size );
+ }
+ }
+
+ /**
+ * Given one or more Titles and one or more names of properties,
+ * returns an associative array mapping page ID to property value.
+ * Pages in the provided set of Titles that do not have a value for
+ * the given properties will not appear in the returned array. If a
+ * single Title is provided, it does not need to be passed in an array,
+ * but an array will always be returned. If a single property name is
+ * provided, it does not need to be passed in an array. In that case,
+ * an associative array mapping page ID to property value will be
+ * returned; otherwise, an associative array mapping page ID to
+ * an associative array mapping property name to property value will be
+ * returned. An empty array will be returned if no matching properties
+ * were found.
+ *
+ * @param Title[]|Title $titles
+ * @param string[]|string $propertyNames
+ * @return array associative array mapping page ID to property value
+ */
+ public function getProperties( $titles, $propertyNames ) {
+ if ( is_array( $propertyNames ) ) {
+ $gotArray = true;
+ } else {
+ $propertyNames = [ $propertyNames ];
+ $gotArray = false;
+ }
+
+ $values = [];
+ $goodIDs = $this->getGoodIDs( $titles );
+ $queryIDs = [];
+ foreach ( $goodIDs as $pageID ) {
+ foreach ( $propertyNames as $propertyName ) {
+ $propertyValue = $this->getCachedProperty( $pageID, $propertyName );
+ if ( $propertyValue === false ) {
+ $queryIDs[] = $pageID;
+ break;
+ } else {
+ if ( $gotArray ) {
+ $values[$pageID][$propertyName] = $propertyValue;
+ } else {
+ $values[$pageID] = $propertyValue;
+ }
+ }
+ }
+ }
+
+ if ( $queryIDs ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $result = $dbr->select(
+ 'page_props',
+ [
+ 'pp_page',
+ 'pp_propname',
+ 'pp_value'
+ ],
+ [
+ 'pp_page' => $queryIDs,
+ 'pp_propname' => $propertyNames
+ ],
+ __METHOD__
+ );
+
+ foreach ( $result as $row ) {
+ $pageID = $row->pp_page;
+ $propertyName = $row->pp_propname;
+ $propertyValue = $row->pp_value;
+ $this->cacheProperty( $pageID, $propertyName, $propertyValue );
+ if ( $gotArray ) {
+ $values[$pageID][$propertyName] = $propertyValue;
+ } else {
+ $values[$pageID] = $propertyValue;
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Get all page property values.
+ * Given one or more Titles, returns an associative array mapping page
+ * ID to an associative array mapping property names to property
+ * values. Pages in the provided set of Titles that do not have any
+ * properties will not appear in the returned array. If a single Title
+ * is provided, it does not need to be passed in an array, but an array
+ * will always be returned. An empty array will be returned if no
+ * matching properties were found.
+ *
+ * @param Title[]|Title $titles
+ * @return array associative array mapping page ID to property value array
+ */
+ public function getAllProperties( $titles ) {
+ $values = [];
+ $goodIDs = $this->getGoodIDs( $titles );
+ $queryIDs = [];
+ foreach ( $goodIDs as $pageID ) {
+ $pageProperties = $this->getCachedProperties( $pageID );
+ if ( $pageProperties === false ) {
+ $queryIDs[] = $pageID;
+ } else {
+ $values[$pageID] = $pageProperties;
+ }
+ }
+
+ if ( $queryIDs != [] ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $result = $dbr->select(
+ 'page_props',
+ [
+ 'pp_page',
+ 'pp_propname',
+ 'pp_value'
+ ],
+ [
+ 'pp_page' => $queryIDs,
+ ],
+ __METHOD__
+ );
+
+ $currentPageID = 0;
+ $pageProperties = [];
+ foreach ( $result as $row ) {
+ $pageID = $row->pp_page;
+ if ( $currentPageID != $pageID ) {
+ if ( $pageProperties != [] ) {
+ $this->cacheProperties( $currentPageID, $pageProperties );
+ $values[$currentPageID] = $pageProperties;
+ }
+ $currentPageID = $pageID;
+ $pageProperties = [];
+ }
+ $pageProperties[$row->pp_propname] = $row->pp_value;
+ }
+ if ( $pageProperties != [] ) {
+ $this->cacheProperties( $pageID, $pageProperties );
+ $values[$pageID] = $pageProperties;
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * @param Title[]|Title $titles
+ * @return array array of good page IDs
+ */
+ private function getGoodIDs( $titles ) {
+ $result = [];
+ if ( is_array( $titles ) ) {
+ ( new LinkBatch( $titles ) )->execute();
+
+ foreach ( $titles as $title ) {
+ $pageID = $title->getArticleID();
+ if ( $pageID > 0 ) {
+ $result[] = $pageID;
+ }
+ }
+ } else {
+ $pageID = $titles->getArticleID();
+ if ( $pageID > 0 ) {
+ $result[] = $pageID;
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * Get a property from the cache.
+ *
+ * @param int $pageID page ID of page being queried
+ * @param string $propertyName name of property being queried
+ * @return string|bool property value array or false if not found
+ */
+ private function getCachedProperty( $pageID, $propertyName ) {
+ if ( $this->cache->has( $pageID, $propertyName, self::CACHE_TTL ) ) {
+ return $this->cache->get( $pageID, $propertyName );
+ }
+ if ( $this->cache->has( 0, $pageID, self::CACHE_TTL ) ) {
+ $pageProperties = $this->cache->get( 0, $pageID );
+ if ( isset( $pageProperties[$propertyName] ) ) {
+ return $pageProperties[$propertyName];
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get properties from the cache.
+ *
+ * @param int $pageID page ID of page being queried
+ * @return string|bool property value array or false if not found
+ */
+ private function getCachedProperties( $pageID ) {
+ if ( $this->cache->has( 0, $pageID, self::CACHE_TTL ) ) {
+ return $this->cache->get( 0, $pageID );
+ }
+ return false;
+ }
+
+ /**
+ * Save a property to the cache.
+ *
+ * @param int $pageID page ID of page being cached
+ * @param string $propertyName name of property being cached
+ * @param mixed $propertyValue value of property
+ */
+ private function cacheProperty( $pageID, $propertyName, $propertyValue ) {
+ $this->cache->set( $pageID, $propertyName, $propertyValue );
+ }
+
+ /**
+ * Save properties to the cache.
+ *
+ * @param int $pageID page ID of page being cached
+ * @param string[] $pageProperties associative array of page properties to be cached
+ */
+ private function cacheProperties( $pageID, $pageProperties ) {
+ $this->cache->clear( $pageID );
+ $this->cache->set( 0, $pageID, $pageProperties );
+ }
+}
diff --git a/www/wiki/includes/PathRouter.php b/www/wiki/includes/PathRouter.php
new file mode 100644
index 00000000..cc6fc4a3
--- /dev/null
+++ b/www/wiki/includes/PathRouter.php
@@ -0,0 +1,399 @@
+<?php
+/**
+ * Parser to extract query parameters out of REQUEST_URI paths.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * PathRouter class.
+ * This class can take patterns such as /wiki/$1 and use them to
+ * parse query parameters out of REQUEST_URI paths.
+ *
+ * $router->add( "/wiki/$1" );
+ * - Matches /wiki/Foo style urls and extracts the title
+ * $router->add( [ 'edit' => "/edit/$key" ], [ 'action' => '$key' ] );
+ * - Matches /edit/Foo style urls and sets action=edit
+ * $router->add( '/$2/$1',
+ * [ 'variant' => '$2' ],
+ * [ '$2' => [ 'zh-hant', 'zh-hans' ] ]
+ * );
+ * - Matches /zh-hant/Foo or /zh-hans/Foo
+ * $router->addStrict( "/foo/Bar", [ 'title' => 'Baz' ] );
+ * - Matches /foo/Bar explicitly and uses "Baz" as the title
+ * $router->add( '/help/$1', [ 'title' => 'Help:$1' ] );
+ * - Matches /help/Foo with "Help:Foo" as the title
+ * $router->add( '/$1', [ 'foo' => [ 'value' => 'bar$2' ] ] );
+ * - Matches /Foo and sets 'foo' to 'bar$2' without $2 being replaced
+ * $router->add( '/$1', [ 'data:foo' => 'bar' ], [ 'callback' => 'functionname' ] );
+ * - Matches /Foo, adds the key 'foo' with the value 'bar' to the data array
+ * and calls functionname( &$matches, $data );
+ *
+ * Path patterns:
+ * - Paths may contain $# patterns such as $1, $2, etc...
+ * - $1 will match 0 or more while the rest will match 1 or more
+ * - Unless you use addStrict "/wiki" and "/wiki/" will be expanded to "/wiki/$1"
+ *
+ * Params:
+ * - In a pattern $1, $2, etc... will be replaced with the relevant contents
+ * - If you used a keyed array as a path pattern, $key will be replaced with
+ * the relevant contents
+ * - The default behavior is equivalent to `array( 'title' => '$1' )`,
+ * if you don't want the title parameter you can explicitly use `array( 'title' => false )`
+ * - You can specify a value that won't have replacements in it
+ * using `'foo' => [ 'value' => 'bar' ];`
+ *
+ * Options:
+ * - The option keys $1, $2, etc... can be specified to restrict the possible values
+ * of that variable. A string can be used for a single value, or an array for multiple.
+ * - When the option key 'strict' is set (Using addStrict is simpler than doing this directly)
+ * the path won't have $1 implicitly added to it.
+ * - The option key 'callback' can specify a callback that will be run when a path is matched.
+ * The callback will have the arguments ( &$matches, $data ) and the matches array can
+ * be modified.
+ *
+ * @since 1.19
+ * @author Daniel Friesen
+ */
+class PathRouter {
+
+ /**
+ * @var array
+ */
+ private $patterns = [];
+
+ /**
+ * Protected helper to do the actual bulk work of adding a single pattern.
+ * This is in a separate method so that add() can handle the difference between
+ * a single string $path and an array() $path that contains multiple path
+ * patterns each with an associated $key to pass on.
+ * @param string $path
+ * @param array $params
+ * @param array $options
+ * @param null|string $key
+ */
+ protected function doAdd( $path, $params, $options, $key = null ) {
+ // Make sure all paths start with a /
+ if ( $path[0] !== '/' ) {
+ $path = '/' . $path;
+ }
+
+ if ( !isset( $options['strict'] ) || !$options['strict'] ) {
+ // Unless this is a strict path make sure that the path has a $1
+ if ( strpos( $path, '$1' ) === false ) {
+ if ( substr( $path, -1 ) !== '/' ) {
+ $path .= '/';
+ }
+ $path .= '$1';
+ }
+ }
+
+ // If 'title' is not specified and our path pattern contains a $1
+ // Add a default 'title' => '$1' rule to the parameters.
+ if ( !isset( $params['title'] ) && strpos( $path, '$1' ) !== false ) {
+ $params['title'] = '$1';
+ }
+ // If the user explicitly marked 'title' as false then omit it from the matches
+ if ( isset( $params['title'] ) && $params['title'] === false ) {
+ unset( $params['title'] );
+ }
+
+ // Loop over our parameters and convert basic key => string
+ // patterns into fully descriptive array form
+ foreach ( $params as $paramName => $paramData ) {
+ if ( is_string( $paramData ) ) {
+ if ( preg_match( '/\$(\d+|key)/u', $paramData ) ) {
+ $paramArrKey = 'pattern';
+ } else {
+ // If there's no replacement use a value instead
+ // of a pattern for a little more efficiency
+ $paramArrKey = 'value';
+ }
+ $params[$paramName] = [
+ $paramArrKey => $paramData
+ ];
+ }
+ }
+
+ // Loop over our options and convert any single value $# restrictions
+ // into an array so we only have to do in_array tests.
+ foreach ( $options as $optionName => $optionData ) {
+ if ( preg_match( '/^\$\d+$/u', $optionName ) ) {
+ if ( !is_array( $optionData ) ) {
+ $options[$optionName] = [ $optionData ];
+ }
+ }
+ }
+
+ $pattern = (object)[
+ 'path' => $path,
+ 'params' => $params,
+ 'options' => $options,
+ 'key' => $key,
+ ];
+ $pattern->weight = self::makeWeight( $pattern );
+ $this->patterns[] = $pattern;
+ }
+
+ /**
+ * Add a new path pattern to the path router
+ *
+ * @param string|array $path The path pattern to add
+ * @param array $params The params for this path pattern
+ * @param array $options The options for this path pattern
+ */
+ public function add( $path, $params = [], $options = [] ) {
+ if ( is_array( $path ) ) {
+ foreach ( $path as $key => $onePath ) {
+ $this->doAdd( $onePath, $params, $options, $key );
+ }
+ } else {
+ $this->doAdd( $path, $params, $options );
+ }
+ }
+
+ /**
+ * Add a new path pattern to the path router with the strict option on
+ * @see self::add
+ * @param string|array $path
+ * @param array $params
+ * @param array $options
+ */
+ public function addStrict( $path, $params = [], $options = [] ) {
+ $options['strict'] = true;
+ $this->add( $path, $params, $options );
+ }
+
+ /**
+ * Protected helper to re-sort our patterns so that the most specific
+ * (most heavily weighted) patterns are at the start of the array.
+ */
+ protected function sortByWeight() {
+ $weights = [];
+ foreach ( $this->patterns as $key => $pattern ) {
+ $weights[$key] = $pattern->weight;
+ }
+ array_multisort( $weights, SORT_DESC, SORT_NUMERIC, $this->patterns );
+ }
+
+ /**
+ * @param object $pattern
+ * @return float|int
+ */
+ protected static function makeWeight( $pattern ) {
+ # Start with a weight of 0
+ $weight = 0;
+
+ // Explode the path to work with
+ $path = explode( '/', $pattern->path );
+
+ # For each level of the path
+ foreach ( $path as $piece ) {
+ if ( preg_match( '/^\$(\d+|key)$/u', $piece ) ) {
+ # For a piece that is only a $1 variable add 1 points of weight
+ $weight += 1;
+ } elseif ( preg_match( '/\$(\d+|key)/u', $piece ) ) {
+ # For a piece that simply contains a $1 variable add 2 points of weight
+ $weight += 2;
+ } else {
+ # For a solid piece add a full 3 points of weight
+ $weight += 3;
+ }
+ }
+
+ foreach ( $pattern->options as $key => $option ) {
+ if ( preg_match( '/^\$\d+$/u', $key ) ) {
+ # Add 0.5 for restrictions to values
+ # This way given two separate "/$2/$1" patterns the
+ # one with a limited set of $2 values will dominate
+ # the one that'll match more loosely
+ $weight += 0.5;
+ }
+ }
+
+ return $weight;
+ }
+
+ /**
+ * Parse a path and return the query matches for the path
+ *
+ * @param string $path The path to parse
+ * @return array The array of matches for the path
+ */
+ public function parse( $path ) {
+ // Make sure our patterns are sorted by weight so the most specific
+ // matches are tested first
+ $this->sortByWeight();
+
+ $matches = null;
+
+ foreach ( $this->patterns as $pattern ) {
+ $matches = self::extractTitle( $path, $pattern );
+ if ( !is_null( $matches ) ) {
+ break;
+ }
+ }
+
+ // We know the difference between null (no matches) and
+ // array() (a match with no data) but our WebRequest caller
+ // expects array() even when we have no matches so return
+ // a array() when we have null
+ return is_null( $matches ) ? [] : $matches;
+ }
+
+ /**
+ * @param string $path
+ * @param string $pattern
+ * @return array|null
+ */
+ protected static function extractTitle( $path, $pattern ) {
+ // Convert the path pattern into a regexp we can match with
+ $regexp = preg_quote( $pattern->path, '#' );
+ // .* for the $1
+ $regexp = preg_replace( '#\\\\\$1#u', '(?P<par1>.*)', $regexp );
+ // .+ for the rest of the parameter numbers
+ $regexp = preg_replace( '#\\\\\$(\d+)#u', '(?P<par$1>.+?)', $regexp );
+ $regexp = "#^{$regexp}$#";
+
+ $matches = [];
+ $data = [];
+
+ // Try to match the path we were asked to parse with our regexp
+ if ( preg_match( $regexp, $path, $m ) ) {
+ // Ensure that any $# restriction we have set in our {$option}s
+ // matches properly here.
+ foreach ( $pattern->options as $key => $option ) {
+ if ( preg_match( '/^\$\d+$/u', $key ) ) {
+ $n = intval( substr( $key, 1 ) );
+ $value = rawurldecode( $m["par{$n}"] );
+ if ( !in_array( $value, $option ) ) {
+ // If any restriction does not match return null
+ // to signify that this rule did not match.
+ return null;
+ }
+ }
+ }
+
+ // Give our $data array a copy of every $# that was matched
+ foreach ( $m as $matchKey => $matchValue ) {
+ if ( preg_match( '/^par\d+$/u', $matchKey ) ) {
+ $n = intval( substr( $matchKey, 3 ) );
+ $data['$' . $n] = rawurldecode( $matchValue );
+ }
+ }
+ // If present give our $data array a $key as well
+ if ( isset( $pattern->key ) ) {
+ $data['$key'] = $pattern->key;
+ }
+
+ // Go through our parameters for this match and add data to our matches and data arrays
+ foreach ( $pattern->params as $paramName => $paramData ) {
+ $value = null;
+ // Differentiate data: from normal parameters and keep the correct
+ // array key around (ie: foo for data:foo)
+ if ( preg_match( '/^data:/u', $paramName ) ) {
+ $isData = true;
+ $key = substr( $paramName, 5 );
+ } else {
+ $isData = false;
+ $key = $paramName;
+ }
+
+ if ( isset( $paramData['value'] ) ) {
+ // For basic values just set the raw data as the value
+ $value = $paramData['value'];
+ } elseif ( isset( $paramData['pattern'] ) ) {
+ // For patterns we have to make value replacements on the string
+ $value = $paramData['pattern'];
+ $replacer = new PathRouterPatternReplacer;
+ $replacer->params = $m;
+ if ( isset( $pattern->key ) ) {
+ $replacer->key = $pattern->key;
+ }
+ $value = $replacer->replace( $value );
+ if ( $value === false ) {
+ // Pattern required data that wasn't available, abort
+ return null;
+ }
+ }
+
+ // Send things that start with data: to $data, the rest to $matches
+ if ( $isData ) {
+ $data[$key] = $value;
+ } else {
+ $matches[$key] = $value;
+ }
+ }
+
+ // If this match includes a callback, execute it
+ if ( isset( $pattern->options['callback'] ) ) {
+ call_user_func_array( $pattern->options['callback'], [ &$matches, $data ] );
+ }
+ } else {
+ // Our regexp didn't match, return null to signify no match.
+ return null;
+ }
+ // Fall through, everything went ok, return our matches array
+ return $matches;
+ }
+
+}
+
+class PathRouterPatternReplacer {
+
+ public $key, $params, $error;
+
+ /**
+ * Replace keys inside path router patterns with text.
+ * We do this inside of a replacement callback because after replacement we can't tell the
+ * difference between a $1 that was not replaced and a $1 that was part of
+ * the content a $1 was replaced with.
+ * @param string $value
+ * @return string|false
+ */
+ public function replace( $value ) {
+ $this->error = false;
+ $value = preg_replace_callback( '/\$(\d+|key)/u', [ $this, 'callback' ], $value );
+ if ( $this->error ) {
+ return false;
+ }
+ return $value;
+ }
+
+ /**
+ * @param array $m
+ * @return string
+ */
+ protected function callback( $m ) {
+ if ( $m[1] == "key" ) {
+ if ( is_null( $this->key ) ) {
+ $this->error = true;
+ return '';
+ }
+ return $this->key;
+ } else {
+ $d = $m[1];
+ if ( !isset( $this->params["par$d"] ) ) {
+ $this->error = true;
+ return '';
+ }
+ return rawurldecode( $this->params["par$d"] );
+ }
+ }
+
+}
diff --git a/www/wiki/includes/Pingback.php b/www/wiki/includes/Pingback.php
new file mode 100644
index 00000000..c3393bcc
--- /dev/null
+++ b/www/wiki/includes/Pingback.php
@@ -0,0 +1,262 @@
+<?php
+/**
+ * Send information about this MediaWiki instance to MediaWiki.org.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Psr\Log\LoggerInterface;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Send information about this MediaWiki instance to MediaWiki.org.
+ *
+ * @since 1.28
+ */
+class Pingback {
+
+ /**
+ * @var int Revision ID of the JSON schema that describes the pingback
+ * payload. The schema lives on MetaWiki, at
+ * <https://meta.wikimedia.org/wiki/Schema:MediaWikiPingback>.
+ */
+ const SCHEMA_REV = 15781718;
+
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var Config */
+ protected $config;
+
+ /** @var string updatelog key (also used as cache/db lock key) */
+ protected $key;
+
+ /** @var string Randomly-generated identifier for this wiki */
+ protected $id;
+
+ /**
+ * @param Config $config
+ * @param LoggerInterface $logger
+ */
+ public function __construct( Config $config = null, LoggerInterface $logger = null ) {
+ $this->config = $config ?: RequestContext::getMain()->getConfig();
+ $this->logger = $logger ?: LoggerFactory::getInstance( __CLASS__ );
+ $this->key = 'Pingback-' . $this->config->get( 'Version' );
+ }
+
+ /**
+ * Should a pingback be sent?
+ * @return bool
+ */
+ private function shouldSend() {
+ return $this->config->get( 'Pingback' ) && !$this->checkIfSent();
+ }
+
+ /**
+ * Has a pingback already been sent for this MediaWiki version?
+ * @return bool
+ */
+ private function checkIfSent() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $sent = $dbr->selectField(
+ 'updatelog', '1', [ 'ul_key' => $this->key ], __METHOD__ );
+ return $sent !== false;
+ }
+
+ /**
+ * Record the fact that we have sent a pingback for this MediaWiki version,
+ * to ensure we don't submit data multiple times.
+ */
+ private function markSent() {
+ $dbw = wfGetDB( DB_MASTER );
+ return $dbw->insert(
+ 'updatelog', [ 'ul_key' => $this->key ], __METHOD__, 'IGNORE' );
+ }
+
+ /**
+ * Acquire lock for sending a pingback
+ *
+ * This ensures only one thread can attempt to send a pingback at any given
+ * time and that we wait an hour before retrying failed attempts.
+ *
+ * @return bool Whether lock was acquired
+ */
+ private function acquireLock() {
+ $cache = ObjectCache::getLocalClusterInstance();
+ if ( !$cache->add( $this->key, 1, 60 * 60 ) ) {
+ return false; // throttled
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ if ( !$dbw->lock( $this->key, __METHOD__, 0 ) ) {
+ return false; // already in progress
+ }
+
+ return true;
+ }
+
+ /**
+ * Collect basic data about this MediaWiki installation and return it
+ * as an associative array conforming to the Pingback schema on MetaWiki
+ * (<https://meta.wikimedia.org/wiki/Schema:MediaWikiPingback>).
+ *
+ * This is public so we can display it in the installer
+ *
+ * Developers: If you're adding a new piece of data to this, please ensure
+ * that you update https://www.mediawiki.org/wiki/Manual:$wgPingback
+ *
+ * @return array
+ */
+ public function getSystemInfo() {
+ $event = [
+ 'database' => $this->config->get( 'DBtype' ),
+ 'MediaWiki' => $this->config->get( 'Version' ),
+ 'PHP' => PHP_VERSION,
+ 'OS' => PHP_OS . ' ' . php_uname( 'r' ),
+ 'arch' => PHP_INT_SIZE === 8 ? 64 : 32,
+ 'machine' => php_uname( 'm' ),
+ ];
+
+ if ( isset( $_SERVER['SERVER_SOFTWARE'] ) ) {
+ $event['serverSoftware'] = $_SERVER['SERVER_SOFTWARE'];
+ }
+
+ $limit = ini_get( 'memory_limit' );
+ if ( $limit && $limit != -1 ) {
+ $event['memoryLimit'] = $limit;
+ }
+
+ return $event;
+ }
+
+ /**
+ * Get the EventLogging packet to be sent to the server
+ *
+ * @return array
+ */
+ private function getData() {
+ return [
+ 'schema' => 'MediaWikiPingback',
+ 'revision' => self::SCHEMA_REV,
+ 'wiki' => $this->getOrCreatePingbackId(),
+ 'event' => $this->getSystemInfo(),
+ ];
+ }
+
+ /**
+ * Get a unique, stable identifier for this wiki
+ *
+ * If the identifier does not already exist, create it and save it in the
+ * database. The identifier is randomly-generated.
+ *
+ * @return string 32-character hex string
+ */
+ private function getOrCreatePingbackId() {
+ if ( !$this->id ) {
+ $id = wfGetDB( DB_REPLICA )->selectField(
+ 'updatelog', 'ul_value', [ 'ul_key' => 'PingBack' ] );
+
+ if ( $id == false ) {
+ $id = MWCryptRand::generateHex( 32 );
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert(
+ 'updatelog',
+ [ 'ul_key' => 'PingBack', 'ul_value' => $id ],
+ __METHOD__,
+ 'IGNORE'
+ );
+
+ if ( !$dbw->affectedRows() ) {
+ $id = $dbw->selectField(
+ 'updatelog', 'ul_value', [ 'ul_key' => 'PingBack' ] );
+ }
+ }
+
+ $this->id = $id;
+ }
+
+ return $this->id;
+ }
+
+ /**
+ * Serialize pingback data and send it to MediaWiki.org via a POST
+ * to its event beacon endpoint.
+ *
+ * The data encoding conforms to the expectations of EventLogging,
+ * a software suite used by the Wikimedia Foundation for logging and
+ * processing analytic data.
+ *
+ * Compare:
+ * <https://github.com/wikimedia/mediawiki-extensions-EventLogging/
+ * blob/7e5fe4f1ef/includes/EventLogging.php#L32-L74>
+ *
+ * @param array $data Pingback data as an associative array
+ * @return bool true on success, false on failure
+ */
+ private function postPingback( array $data ) {
+ $json = FormatJson::encode( $data );
+ $queryString = rawurlencode( str_replace( ' ', '\u0020', $json ) ) . ';';
+ $url = 'https://www.mediawiki.org/beacon/event?' . $queryString;
+ return Http::post( $url ) !== false;
+ }
+
+ /**
+ * Send information about this MediaWiki instance to MediaWiki.org.
+ *
+ * The data is structured and serialized to match the expectations of
+ * EventLogging, a software suite used by the Wikimedia Foundation for
+ * logging and processing analytic data.
+ *
+ * Compare:
+ * <https://github.com/wikimedia/mediawiki-extensions-EventLogging/
+ * blob/7e5fe4f1ef/includes/EventLogging.php#L32-L74>
+ *
+ * The schema for the data is located at:
+ * <https://meta.wikimedia.org/wiki/Schema:MediaWikiPingback>
+ * @return bool
+ */
+ public function sendPingback() {
+ if ( !$this->acquireLock() ) {
+ $this->logger->debug( __METHOD__ . ": couldn't acquire lock" );
+ return false;
+ }
+
+ $data = $this->getData();
+ if ( !$this->postPingback( $data ) ) {
+ $this->logger->warning( __METHOD__ . ": failed to send pingback; check 'http' log" );
+ return false;
+ }
+
+ $this->markSent();
+ $this->logger->debug( __METHOD__ . ": pingback sent OK ({$this->key})" );
+ return true;
+ }
+
+ /**
+ * Schedule a deferred callable that will check if a pingback should be
+ * sent and (if so) proceed to send it.
+ */
+ public static function schedulePingback() {
+ DeferredUpdates::addCallableUpdate( function () {
+ $instance = new Pingback;
+ if ( $instance->shouldSend() ) {
+ $instance->sendPingback();
+ }
+ } );
+ }
+}
diff --git a/www/wiki/includes/PreConfigSetup.php b/www/wiki/includes/PreConfigSetup.php
new file mode 100644
index 00000000..bda78865
--- /dev/null
+++ b/www/wiki/includes/PreConfigSetup.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * File-scope setup actions, loaded before LocalSettings.php, shared by
+ * WebStart.php and doMaintenance.php
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ *
+ * @file
+ */
+
+if ( !defined( 'MEDIAWIKI' ) ) {
+ // Not an entry point
+ exit( 1 );
+}
+
+// Grab profiling functions
+require_once "$IP/includes/profiler/ProfilerFunctions.php";
+
+// Start the autoloader, so that extensions can derive classes from core files
+require_once "$IP/includes/AutoLoader.php";
+
+// Load up some global defines.
+require_once "$IP/includes/Defines.php";
+
+// Start the profiler
+$wgProfiler = [];
+if ( file_exists( "$IP/StartProfiler.php" ) ) {
+ require "$IP/StartProfiler.php";
+}
+
+// Load default settings
+require_once "$IP/includes/DefaultSettings.php";
+
+// Load global functions
+require_once "$IP/includes/GlobalFunctions.php";
+
+// Load composer's autoloader if present
+if ( is_readable( "$IP/vendor/autoload.php" ) ) {
+ require_once "$IP/vendor/autoload.php";
+}
diff --git a/www/wiki/includes/Preferences.php b/www/wiki/includes/Preferences.php
new file mode 100644
index 00000000..a7e6684b
--- /dev/null
+++ b/www/wiki/includes/Preferences.php
@@ -0,0 +1,1649 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\PasswordAuthenticationRequest;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * We're now using the HTMLForm object with some customisation to generate the
+ * Preferences form. This object handles generic submission, CSRF protection,
+ * layout and other logic in a reusable manner. We subclass it as a PreferencesForm
+ * to make some minor customisations.
+ *
+ * In order to generate the form, the HTMLForm object needs an array structure
+ * detailing the form fields available, and that's what this class is for. Each
+ * element of the array is a basic property-list, including the type of field,
+ * the label it is to be given in the form, callbacks for validation and
+ * 'filtering', and other pertinent information. Note that the 'default' field
+ * is named for generic forms, and does not represent the preference's default
+ * (which is stored in $wgDefaultUserOptions), but the default for the form
+ * field, which should be whatever the user has set for that preference. There
+ * is no need to override it unless you have some special storage logic (for
+ * instance, those not presently stored as options, but which are best set from
+ * the user preferences view).
+ *
+ * Field types are implemented as subclasses of the generic HTMLFormField
+ * object, and typically implement at least getInputHTML, which generates the
+ * HTML for the input field to be placed in the table.
+ *
+ * Once fields have been retrieved and validated, submission logic is handed
+ * over to the tryUISubmit static method of this class.
+ */
+class Preferences {
+ /** @var array */
+ protected static $defaultPreferences = null;
+
+ /** @var array */
+ protected static $saveFilters = [
+ 'timecorrection' => [ 'Preferences', 'filterTimezoneInput' ],
+ 'rclimit' => [ 'Preferences', 'filterIntval' ],
+ 'wllimit' => [ 'Preferences', 'filterIntval' ],
+ 'searchlimit' => [ 'Preferences', 'filterIntval' ],
+ ];
+
+ // Stuff that shouldn't be saved as a preference.
+ private static $saveBlacklist = [
+ 'realname',
+ 'emailaddress',
+ ];
+
+ /**
+ * @return array
+ */
+ static function getSaveBlacklist() {
+ return self::$saveBlacklist;
+ }
+
+ /**
+ * @throws MWException
+ * @param User $user
+ * @param IContextSource $context
+ * @return array|null
+ */
+ static function getPreferences( $user, IContextSource $context ) {
+ if ( self::$defaultPreferences ) {
+ return self::$defaultPreferences;
+ }
+
+ $defaultPreferences = [];
+
+ self::profilePreferences( $user, $context, $defaultPreferences );
+ self::skinPreferences( $user, $context, $defaultPreferences );
+ self::datetimePreferences( $user, $context, $defaultPreferences );
+ self::filesPreferences( $user, $context, $defaultPreferences );
+ self::renderingPreferences( $user, $context, $defaultPreferences );
+ self::editingPreferences( $user, $context, $defaultPreferences );
+ self::rcPreferences( $user, $context, $defaultPreferences );
+ self::watchlistPreferences( $user, $context, $defaultPreferences );
+ self::searchPreferences( $user, $context, $defaultPreferences );
+ self::miscPreferences( $user, $context, $defaultPreferences );
+
+ Hooks::run( 'GetPreferences', [ $user, &$defaultPreferences ] );
+
+ self::loadPreferenceValues( $user, $context, $defaultPreferences );
+ self::$defaultPreferences = $defaultPreferences;
+ return $defaultPreferences;
+ }
+
+ /**
+ * Loads existing values for a given array of preferences
+ * @throws MWException
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences Array to load values for
+ * @return array|null
+ */
+ static function loadPreferenceValues( $user, $context, &$defaultPreferences ) {
+ # # Remove preferences that wikis don't want to use
+ foreach ( $context->getConfig()->get( 'HiddenPrefs' ) as $pref ) {
+ if ( isset( $defaultPreferences[$pref] ) ) {
+ unset( $defaultPreferences[$pref] );
+ }
+ }
+
+ # # Make sure that form fields have their parent set. See T43337.
+ $dummyForm = new HTMLForm( [], $context );
+
+ $disable = !$user->isAllowed( 'editmyoptions' );
+
+ $defaultOptions = User::getDefaultOptions();
+ # # Prod in defaults from the user
+ foreach ( $defaultPreferences as $name => &$info ) {
+ $prefFromUser = self::getOptionFromUser( $name, $info, $user );
+ if ( $disable && !in_array( $name, self::$saveBlacklist ) ) {
+ $info['disabled'] = 'disabled';
+ }
+ $field = HTMLForm::loadInputFromParameters( $name, $info, $dummyForm ); // For validation
+ $globalDefault = isset( $defaultOptions[$name] )
+ ? $defaultOptions[$name]
+ : null;
+
+ // If it validates, set it as the default
+ if ( isset( $info['default'] ) ) {
+ // Already set, no problem
+ continue;
+ } elseif ( !is_null( $prefFromUser ) && // Make sure we're not just pulling nothing
+ $field->validate( $prefFromUser, $user->getOptions() ) === true ) {
+ $info['default'] = $prefFromUser;
+ } elseif ( $field->validate( $globalDefault, $user->getOptions() ) === true ) {
+ $info['default'] = $globalDefault;
+ } else {
+ throw new MWException( "Global default '$globalDefault' is invalid for field $name" );
+ }
+ }
+
+ return $defaultPreferences;
+ }
+
+ /**
+ * Pull option from a user account. Handles stuff like array-type preferences.
+ *
+ * @param string $name
+ * @param array $info
+ * @param User $user
+ * @return array|string
+ */
+ static function getOptionFromUser( $name, $info, $user ) {
+ $val = $user->getOption( $name );
+
+ // Handling for multiselect preferences
+ if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
+ ( isset( $info['class'] ) && $info['class'] == 'HTMLMultiSelectField' ) ) {
+ $options = HTMLFormField::flattenOptions( $info['options'] );
+ $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $name;
+ $val = [];
+
+ foreach ( $options as $value ) {
+ if ( $user->getOption( "$prefix$value" ) ) {
+ $val[] = $value;
+ }
+ }
+ }
+
+ // Handling for checkmatrix preferences
+ if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
+ ( isset( $info['class'] ) && $info['class'] == 'HTMLCheckMatrix' ) ) {
+ $columns = HTMLFormField::flattenOptions( $info['columns'] );
+ $rows = HTMLFormField::flattenOptions( $info['rows'] );
+ $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $name;
+ $val = [];
+
+ foreach ( $columns as $column ) {
+ foreach ( $rows as $row ) {
+ if ( $user->getOption( "$prefix$column-$row" ) ) {
+ $val[] = "$column-$row";
+ }
+ }
+ }
+ }
+
+ return $val;
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ * @return void
+ */
+ static function profilePreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ global $wgContLang, $wgParser;
+
+ $authManager = AuthManager::singleton();
+ $config = $context->getConfig();
+ // retrieving user name for GENDER and misc.
+ $userName = $user->getName();
+
+ # # User info #####################################
+ // Information panel
+ $defaultPreferences['username'] = [
+ 'type' => 'info',
+ 'label-message' => [ 'username', $userName ],
+ 'default' => $userName,
+ 'section' => 'personal/info',
+ ];
+
+ $lang = $context->getLanguage();
+
+ # Get groups to which the user belongs
+ $userEffectiveGroups = $user->getEffectiveGroups();
+ $userGroupMemberships = $user->getGroupMemberships();
+ $userGroups = $userMembers = $userTempGroups = $userTempMembers = [];
+ foreach ( $userEffectiveGroups as $ueg ) {
+ if ( $ueg == '*' ) {
+ // Skip the default * group, seems useless here
+ continue;
+ }
+
+ if ( isset( $userGroupMemberships[$ueg] ) ) {
+ $groupStringOrObject = $userGroupMemberships[$ueg];
+ } else {
+ $groupStringOrObject = $ueg;
+ }
+
+ $userG = UserGroupMembership::getLink( $groupStringOrObject, $context, 'html' );
+ $userM = UserGroupMembership::getLink( $groupStringOrObject, $context, 'html',
+ $userName );
+
+ // Store expiring groups separately, so we can place them before non-expiring
+ // groups in the list. This is to avoid the ambiguity of something like
+ // "administrator, bureaucrat (until X date)" -- users might wonder whether the
+ // expiry date applies to both groups, or just the last one
+ if ( $groupStringOrObject instanceof UserGroupMembership &&
+ $groupStringOrObject->getExpiry()
+ ) {
+ $userTempGroups[] = $userG;
+ $userTempMembers[] = $userM;
+ } else {
+ $userGroups[] = $userG;
+ $userMembers[] = $userM;
+ }
+ }
+ sort( $userGroups );
+ sort( $userMembers );
+ sort( $userTempGroups );
+ sort( $userTempMembers );
+ $userGroups = array_merge( $userTempGroups, $userGroups );
+ $userMembers = array_merge( $userTempMembers, $userMembers );
+
+ $defaultPreferences['usergroups'] = [
+ 'type' => 'info',
+ 'label' => $context->msg( 'prefs-memberingroups' )->numParams(
+ count( $userGroups ) )->params( $userName )->parse(),
+ 'default' => $context->msg( 'prefs-memberingroups-type' )
+ ->rawParams( $lang->commaList( $userGroups ), $lang->commaList( $userMembers ) )
+ ->escaped(),
+ 'raw' => true,
+ 'section' => 'personal/info',
+ ];
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ $editCount = $linkRenderer->makeLink( SpecialPage::getTitleFor( "Contributions", $userName ),
+ $lang->formatNum( $user->getEditCount() ) );
+
+ $defaultPreferences['editcount'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'label-message' => 'prefs-edits',
+ 'default' => $editCount,
+ 'section' => 'personal/info',
+ ];
+
+ if ( $user->getRegistration() ) {
+ $displayUser = $context->getUser();
+ $userRegistration = $user->getRegistration();
+ $defaultPreferences['registrationdate'] = [
+ 'type' => 'info',
+ 'label-message' => 'prefs-registration',
+ 'default' => $context->msg(
+ 'prefs-registration-date-time',
+ $lang->userTimeAndDate( $userRegistration, $displayUser ),
+ $lang->userDate( $userRegistration, $displayUser ),
+ $lang->userTime( $userRegistration, $displayUser )
+ )->parse(),
+ 'section' => 'personal/info',
+ ];
+ }
+
+ $canViewPrivateInfo = $user->isAllowed( 'viewmyprivateinfo' );
+ $canEditPrivateInfo = $user->isAllowed( 'editmyprivateinfo' );
+
+ // Actually changeable stuff
+ $defaultPreferences['realname'] = [
+ // (not really "private", but still shouldn't be edited without permission)
+ 'type' => $canEditPrivateInfo && $authManager->allowsPropertyChange( 'realname' )
+ ? 'text' : 'info',
+ 'default' => $user->getRealName(),
+ 'section' => 'personal/info',
+ 'label-message' => 'yourrealname',
+ 'help-message' => 'prefs-help-realname',
+ ];
+
+ if ( $canEditPrivateInfo && $authManager->allowsAuthenticationDataChange(
+ new PasswordAuthenticationRequest(), false )->isGood()
+ ) {
+ $link = $linkRenderer->makeLink( SpecialPage::getTitleFor( 'ChangePassword' ),
+ $context->msg( 'prefs-resetpass' )->text(), [],
+ [ 'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText() ] );
+
+ $defaultPreferences['password'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $link,
+ 'label-message' => 'yourpassword',
+ 'section' => 'personal/info',
+ ];
+ }
+ // Only show prefershttps if secure login is turned on
+ if ( $config->get( 'SecureLogin' ) && wfCanIPUseHTTPS( $context->getRequest()->getIP() ) ) {
+ $defaultPreferences['prefershttps'] = [
+ 'type' => 'toggle',
+ 'label-message' => 'tog-prefershttps',
+ 'help-message' => 'prefs-help-prefershttps',
+ 'section' => 'personal/info'
+ ];
+ }
+
+ // Language
+ $languages = Language::fetchLanguageNames( null, 'mw' );
+ $languageCode = $config->get( 'LanguageCode' );
+ if ( !array_key_exists( $languageCode, $languages ) ) {
+ $languages[$languageCode] = $languageCode;
+ }
+ ksort( $languages );
+
+ $options = [];
+ foreach ( $languages as $code => $name ) {
+ $display = wfBCP47( $code ) . ' - ' . $name;
+ $options[$display] = $code;
+ }
+ $defaultPreferences['language'] = [
+ 'type' => 'select',
+ 'section' => 'personal/i18n',
+ 'options' => $options,
+ 'label-message' => 'yourlanguage',
+ ];
+
+ $defaultPreferences['gender'] = [
+ 'type' => 'radio',
+ 'section' => 'personal/i18n',
+ 'options' => [
+ $context->msg( 'parentheses' )
+ ->params( $context->msg( 'gender-unknown' )->plain() )
+ ->escaped() => 'unknown',
+ $context->msg( 'gender-female' )->escaped() => 'female',
+ $context->msg( 'gender-male' )->escaped() => 'male',
+ ],
+ 'label-message' => 'yourgender',
+ 'help-message' => 'prefs-help-gender',
+ ];
+
+ // see if there are multiple language variants to choose from
+ if ( !$config->get( 'DisableLangConversion' ) ) {
+ foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
+ if ( $langCode == $wgContLang->getCode() ) {
+ $variants = $wgContLang->getVariants();
+
+ if ( count( $variants ) <= 1 ) {
+ continue;
+ }
+
+ $variantArray = [];
+ foreach ( $variants as $v ) {
+ $v = str_replace( '_', '-', strtolower( $v ) );
+ $variantArray[$v] = $lang->getVariantname( $v, false );
+ }
+
+ $options = [];
+ foreach ( $variantArray as $code => $name ) {
+ $display = wfBCP47( $code ) . ' - ' . $name;
+ $options[$display] = $code;
+ }
+
+ $defaultPreferences['variant'] = [
+ 'label-message' => 'yourvariant',
+ 'type' => 'select',
+ 'options' => $options,
+ 'section' => 'personal/i18n',
+ 'help-message' => 'prefs-help-variant',
+ ];
+ } else {
+ $defaultPreferences["variant-$langCode"] = [
+ 'type' => 'api',
+ ];
+ }
+ }
+ }
+
+ // Stuff from Language::getExtraUserToggles()
+ // FIXME is this dead code? $extraUserToggles doesn't seem to be defined for any language
+ $toggles = $wgContLang->getExtraUserToggles();
+
+ foreach ( $toggles as $toggle ) {
+ $defaultPreferences[$toggle] = [
+ 'type' => 'toggle',
+ 'section' => 'personal/i18n',
+ 'label-message' => "tog-$toggle",
+ ];
+ }
+
+ // show a preview of the old signature first
+ $oldsigWikiText = $wgParser->preSaveTransform(
+ '~~~',
+ $context->getTitle(),
+ $user,
+ ParserOptions::newFromContext( $context )
+ );
+ $oldsigHTML = $context->getOutput()->parseInline( $oldsigWikiText, true, true );
+ $defaultPreferences['oldsig'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'label-message' => 'tog-oldsig',
+ 'default' => $oldsigHTML,
+ 'section' => 'personal/signature',
+ ];
+ $defaultPreferences['nickname'] = [
+ 'type' => $authManager->allowsPropertyChange( 'nickname' ) ? 'text' : 'info',
+ 'maxlength' => $config->get( 'MaxSigChars' ),
+ 'label-message' => 'yournick',
+ 'validation-callback' => [ 'Preferences', 'validateSignature' ],
+ 'section' => 'personal/signature',
+ 'filter-callback' => [ 'Preferences', 'cleanSignature' ],
+ ];
+ $defaultPreferences['fancysig'] = [
+ 'type' => 'toggle',
+ 'label-message' => 'tog-fancysig',
+ // show general help about signature at the bottom of the section
+ 'help-message' => 'prefs-help-signature',
+ 'section' => 'personal/signature'
+ ];
+
+ # # Email stuff
+
+ if ( $config->get( 'EnableEmail' ) ) {
+ if ( $canViewPrivateInfo ) {
+ $helpMessages[] = $config->get( 'EmailConfirmToEdit' )
+ ? 'prefs-help-email-required'
+ : 'prefs-help-email';
+
+ if ( $config->get( 'EnableUserEmail' ) ) {
+ // additional messages when users can send email to each other
+ $helpMessages[] = 'prefs-help-email-others';
+ }
+
+ $emailAddress = $user->getEmail() ? htmlspecialchars( $user->getEmail() ) : '';
+ if ( $canEditPrivateInfo && $authManager->allowsPropertyChange( 'emailaddress' ) ) {
+ $link = $linkRenderer->makeLink(
+ SpecialPage::getTitleFor( 'ChangeEmail' ),
+ $context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(),
+ [],
+ [ 'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText() ] );
+
+ $emailAddress .= $emailAddress == '' ? $link : (
+ $context->msg( 'word-separator' )->escaped()
+ . $context->msg( 'parentheses' )->rawParams( $link )->escaped()
+ );
+ }
+
+ $defaultPreferences['emailaddress'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $emailAddress,
+ 'label-message' => 'youremail',
+ 'section' => 'personal/email',
+ 'help-messages' => $helpMessages,
+ # 'cssclass' chosen below
+ ];
+ }
+
+ $disableEmailPrefs = false;
+
+ if ( $config->get( 'EmailAuthentication' ) ) {
+ $emailauthenticationclass = 'mw-email-not-authenticated';
+ if ( $user->getEmail() ) {
+ if ( $user->getEmailAuthenticationTimestamp() ) {
+ // date and time are separate parameters to facilitate localisation.
+ // $time is kept for backward compat reasons.
+ // 'emailauthenticated' is also used in SpecialConfirmemail.php
+ $displayUser = $context->getUser();
+ $emailTimestamp = $user->getEmailAuthenticationTimestamp();
+ $time = $lang->userTimeAndDate( $emailTimestamp, $displayUser );
+ $d = $lang->userDate( $emailTimestamp, $displayUser );
+ $t = $lang->userTime( $emailTimestamp, $displayUser );
+ $emailauthenticated = $context->msg( 'emailauthenticated',
+ $time, $d, $t )->parse() . '<br />';
+ $disableEmailPrefs = false;
+ $emailauthenticationclass = 'mw-email-authenticated';
+ } else {
+ $disableEmailPrefs = true;
+ $emailauthenticated = $context->msg( 'emailnotauthenticated' )->parse() . '<br />' .
+ $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Confirmemail' ),
+ $context->msg( 'emailconfirmlink' )->text()
+ ) . '<br />';
+ $emailauthenticationclass = "mw-email-not-authenticated";
+ }
+ } else {
+ $disableEmailPrefs = true;
+ $emailauthenticated = $context->msg( 'noemailprefs' )->escaped();
+ $emailauthenticationclass = 'mw-email-none';
+ }
+
+ if ( $canViewPrivateInfo ) {
+ $defaultPreferences['emailauthentication'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'section' => 'personal/email',
+ 'label-message' => 'prefs-emailconfirm-label',
+ 'default' => $emailauthenticated,
+ # Apply the same CSS class used on the input to the message:
+ 'cssclass' => $emailauthenticationclass,
+ ];
+ }
+ }
+
+ if ( $config->get( 'EnableUserEmail' ) && $user->isAllowed( 'sendemail' ) ) {
+ $defaultPreferences['disablemail'] = [
+ 'type' => 'toggle',
+ 'invert' => true,
+ 'section' => 'personal/email',
+ 'label-message' => 'allowemail',
+ 'disabled' => $disableEmailPrefs,
+ ];
+ $defaultPreferences['ccmeonemails'] = [
+ 'type' => 'toggle',
+ 'section' => 'personal/email',
+ 'label-message' => 'tog-ccmeonemails',
+ 'disabled' => $disableEmailPrefs,
+ ];
+
+ if ( $config->get( 'EnableUserEmailBlacklist' )
+ && !$disableEmailPrefs
+ && !(bool)$user->getOption( 'disablemail' )
+ ) {
+ $lookup = CentralIdLookup::factory();
+ $ids = $user->getOption( 'email-blacklist', [] );
+ $names = $ids ? $lookup->namesFromCentralIds( $ids, $user ) : [];
+
+ $defaultPreferences['email-blacklist'] = [
+ 'type' => 'usersmultiselect',
+ 'label-message' => 'email-blacklist-label',
+ 'section' => 'personal/email',
+ 'default' => implode( "\n", $names ),
+ ];
+ }
+ }
+
+ if ( $config->get( 'EnotifWatchlist' ) ) {
+ $defaultPreferences['enotifwatchlistpages'] = [
+ 'type' => 'toggle',
+ 'section' => 'personal/email',
+ 'label-message' => 'tog-enotifwatchlistpages',
+ 'disabled' => $disableEmailPrefs,
+ ];
+ }
+ if ( $config->get( 'EnotifUserTalk' ) ) {
+ $defaultPreferences['enotifusertalkpages'] = [
+ 'type' => 'toggle',
+ 'section' => 'personal/email',
+ 'label-message' => 'tog-enotifusertalkpages',
+ 'disabled' => $disableEmailPrefs,
+ ];
+ }
+ if ( $config->get( 'EnotifUserTalk' ) || $config->get( 'EnotifWatchlist' ) ) {
+ if ( $config->get( 'EnotifMinorEdits' ) ) {
+ $defaultPreferences['enotifminoredits'] = [
+ 'type' => 'toggle',
+ 'section' => 'personal/email',
+ 'label-message' => 'tog-enotifminoredits',
+ 'disabled' => $disableEmailPrefs,
+ ];
+ }
+
+ if ( $config->get( 'EnotifRevealEditorAddress' ) ) {
+ $defaultPreferences['enotifrevealaddr'] = [
+ 'type' => 'toggle',
+ 'section' => 'personal/email',
+ 'label-message' => 'tog-enotifrevealaddr',
+ 'disabled' => $disableEmailPrefs,
+ ];
+ }
+ }
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ * @return void
+ */
+ static function skinPreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ # # Skin #####################################
+
+ // Skin selector, if there is at least one valid skin
+ $skinOptions = self::generateSkinOptions( $user, $context );
+ if ( $skinOptions ) {
+ $defaultPreferences['skin'] = [
+ 'type' => 'radio',
+ 'options' => $skinOptions,
+ 'section' => 'rendering/skin',
+ ];
+ }
+
+ $config = $context->getConfig();
+ $allowUserCss = $config->get( 'AllowUserCss' );
+ $allowUserJs = $config->get( 'AllowUserJs' );
+ # Create links to user CSS/JS pages for all skins
+ # This code is basically copied from generateSkinOptions(). It'd
+ # be nice to somehow merge this back in there to avoid redundancy.
+ if ( $allowUserCss || $allowUserJs ) {
+ $linkTools = [];
+ $userName = $user->getName();
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ if ( $allowUserCss ) {
+ $cssPage = Title::makeTitleSafe( NS_USER, $userName . '/common.css' );
+ $linkTools[] = $linkRenderer->makeLink( $cssPage, $context->msg( 'prefs-custom-css' )->text() );
+ }
+
+ if ( $allowUserJs ) {
+ $jsPage = Title::makeTitleSafe( NS_USER, $userName . '/common.js' );
+ $linkTools[] = $linkRenderer->makeLink( $jsPage, $context->msg( 'prefs-custom-js' )->text() );
+ }
+
+ $defaultPreferences['commoncssjs'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $context->getLanguage()->pipeList( $linkTools ),
+ 'label-message' => 'prefs-common-css-js',
+ 'section' => 'rendering/skin',
+ ];
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ */
+ static function filesPreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ # # Files #####################################
+ $defaultPreferences['imagesize'] = [
+ 'type' => 'select',
+ 'options' => self::getImageSizes( $context ),
+ 'label-message' => 'imagemaxsize',
+ 'section' => 'rendering/files',
+ ];
+ $defaultPreferences['thumbsize'] = [
+ 'type' => 'select',
+ 'options' => self::getThumbSizes( $context ),
+ 'label-message' => 'thumbsize',
+ 'section' => 'rendering/files',
+ ];
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ * @return void
+ */
+ static function datetimePreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ # # Date and time #####################################
+ $dateOptions = self::getDateOptions( $context );
+ if ( $dateOptions ) {
+ $defaultPreferences['date'] = [
+ 'type' => 'radio',
+ 'options' => $dateOptions,
+ 'section' => 'rendering/dateformat',
+ ];
+ }
+
+ // Info
+ $now = wfTimestampNow();
+ $lang = $context->getLanguage();
+ $nowlocal = Xml::element( 'span', [ 'id' => 'wpLocalTime' ],
+ $lang->userTime( $now, $user ) );
+ $nowserver = $lang->userTime( $now, $user,
+ [ 'format' => false, 'timecorrection' => false ] ) .
+ Html::hidden( 'wpServerTime', (int)substr( $now, 8, 2 ) * 60 + (int)substr( $now, 10, 2 ) );
+
+ $defaultPreferences['nowserver'] = [
+ 'type' => 'info',
+ 'raw' => 1,
+ 'label-message' => 'servertime',
+ 'default' => $nowserver,
+ 'section' => 'rendering/timeoffset',
+ ];
+
+ $defaultPreferences['nowlocal'] = [
+ 'type' => 'info',
+ 'raw' => 1,
+ 'label-message' => 'localtime',
+ 'default' => $nowlocal,
+ 'section' => 'rendering/timeoffset',
+ ];
+
+ // Grab existing pref.
+ $tzOffset = $user->getOption( 'timecorrection' );
+ $tz = explode( '|', $tzOffset, 3 );
+
+ $tzOptions = self::getTimezoneOptions( $context );
+
+ $tzSetting = $tzOffset;
+ if ( count( $tz ) > 1 && $tz[0] == 'ZoneInfo' &&
+ !in_array( $tzOffset, HTMLFormField::flattenOptions( $tzOptions ) )
+ ) {
+ // Timezone offset can vary with DST
+ try {
+ $userTZ = new DateTimeZone( $tz[2] );
+ $minDiff = floor( $userTZ->getOffset( new DateTime( 'now' ) ) / 60 );
+ $tzSetting = "ZoneInfo|$minDiff|{$tz[2]}";
+ } catch ( Exception $e ) {
+ // User has an invalid time zone set. Fall back to just using the offset
+ $tz[0] = 'Offset';
+ }
+ }
+ if ( count( $tz ) > 1 && $tz[0] == 'Offset' ) {
+ $minDiff = $tz[1];
+ $tzSetting = sprintf( '%+03d:%02d', floor( $minDiff / 60 ), abs( $minDiff ) % 60 );
+ }
+
+ $defaultPreferences['timecorrection'] = [
+ 'class' => 'HTMLSelectOrOtherField',
+ 'label-message' => 'timezonelegend',
+ 'options' => $tzOptions,
+ 'default' => $tzSetting,
+ 'size' => 20,
+ 'section' => 'rendering/timeoffset',
+ ];
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ */
+ static function renderingPreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ # # Diffs ####################################
+ $defaultPreferences['diffonly'] = [
+ 'type' => 'toggle',
+ 'section' => 'rendering/diffs',
+ 'label-message' => 'tog-diffonly',
+ ];
+ $defaultPreferences['norollbackdiff'] = [
+ 'type' => 'toggle',
+ 'section' => 'rendering/diffs',
+ 'label-message' => 'tog-norollbackdiff',
+ ];
+
+ # # Page Rendering ##############################
+ if ( $context->getConfig()->get( 'AllowUserCssPrefs' ) ) {
+ $defaultPreferences['underline'] = [
+ 'type' => 'select',
+ 'options' => [
+ $context->msg( 'underline-never' )->text() => 0,
+ $context->msg( 'underline-always' )->text() => 1,
+ $context->msg( 'underline-default' )->text() => 2,
+ ],
+ 'label-message' => 'tog-underline',
+ 'section' => 'rendering/advancedrendering',
+ ];
+ }
+
+ $stubThresholdValues = [ 50, 100, 500, 1000, 2000, 5000, 10000 ];
+ $stubThresholdOptions = [ $context->msg( 'stub-threshold-disabled' )->text() => 0 ];
+ foreach ( $stubThresholdValues as $value ) {
+ $stubThresholdOptions[$context->msg( 'size-bytes', $value )->text()] = $value;
+ }
+
+ $defaultPreferences['stubthreshold'] = [
+ 'type' => 'select',
+ 'section' => 'rendering/advancedrendering',
+ 'options' => $stubThresholdOptions,
+ // This is not a raw HTML message; label-raw is needed for the manual <a></a>
+ 'label-raw' => $context->msg( 'stub-threshold' )->rawParams(
+ '<a href="#" class="stub">' .
+ $context->msg( 'stub-threshold-sample-link' )->parse() .
+ '</a>' )->parse(),
+ ];
+
+ $defaultPreferences['showhiddencats'] = [
+ 'type' => 'toggle',
+ 'section' => 'rendering/advancedrendering',
+ 'label-message' => 'tog-showhiddencats'
+ ];
+
+ $defaultPreferences['numberheadings'] = [
+ 'type' => 'toggle',
+ 'section' => 'rendering/advancedrendering',
+ 'label-message' => 'tog-numberheadings',
+ ];
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ */
+ static function editingPreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ # # Editing #####################################
+ $defaultPreferences['editsectiononrightclick'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/advancedediting',
+ 'label-message' => 'tog-editsectiononrightclick',
+ ];
+ $defaultPreferences['editondblclick'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/advancedediting',
+ 'label-message' => 'tog-editondblclick',
+ ];
+
+ if ( $context->getConfig()->get( 'AllowUserCssPrefs' ) ) {
+ $defaultPreferences['editfont'] = [
+ 'type' => 'select',
+ 'section' => 'editing/editor',
+ 'label-message' => 'editfont-style',
+ 'options' => [
+ $context->msg( 'editfont-monospace' )->text() => 'monospace',
+ $context->msg( 'editfont-sansserif' )->text() => 'sans-serif',
+ $context->msg( 'editfont-serif' )->text() => 'serif',
+ $context->msg( 'editfont-default' )->text() => 'default',
+ ]
+ ];
+ }
+
+ if ( $user->isAllowed( 'minoredit' ) ) {
+ $defaultPreferences['minordefault'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/editor',
+ 'label-message' => 'tog-minordefault',
+ ];
+ }
+
+ $defaultPreferences['forceeditsummary'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/editor',
+ 'label-message' => 'tog-forceeditsummary',
+ ];
+ $defaultPreferences['useeditwarning'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/editor',
+ 'label-message' => 'tog-useeditwarning',
+ ];
+ $defaultPreferences['showtoolbar'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/editor',
+ 'label-message' => 'tog-showtoolbar',
+ ];
+
+ $defaultPreferences['previewonfirst'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/preview',
+ 'label-message' => 'tog-previewonfirst',
+ ];
+ $defaultPreferences['previewontop'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/preview',
+ 'label-message' => 'tog-previewontop',
+ ];
+ $defaultPreferences['uselivepreview'] = [
+ 'type' => 'toggle',
+ 'section' => 'editing/preview',
+ 'label-message' => 'tog-uselivepreview',
+ ];
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ */
+ static function rcPreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ $config = $context->getConfig();
+ $rcMaxAge = $config->get( 'RCMaxAge' );
+ # # RecentChanges #####################################
+ $defaultPreferences['rcdays'] = [
+ 'type' => 'float',
+ 'label-message' => 'recentchangesdays',
+ 'section' => 'rc/displayrc',
+ 'min' => 1,
+ 'max' => ceil( $rcMaxAge / ( 3600 * 24 ) ),
+ 'help' => $context->msg( 'recentchangesdays-max' )->numParams(
+ ceil( $rcMaxAge / ( 3600 * 24 ) ) )->escaped()
+ ];
+ $defaultPreferences['rclimit'] = [
+ 'type' => 'int',
+ 'min' => 0,
+ 'max' => 1000,
+ 'label-message' => 'recentchangescount',
+ 'help-message' => 'prefs-help-recentchangescount',
+ 'section' => 'rc/displayrc',
+ ];
+ $defaultPreferences['usenewrc'] = [
+ 'type' => 'toggle',
+ 'label-message' => 'tog-usenewrc',
+ 'section' => 'rc/advancedrc',
+ ];
+ $defaultPreferences['hideminor'] = [
+ 'type' => 'toggle',
+ 'label-message' => 'tog-hideminor',
+ 'section' => 'rc/advancedrc',
+ ];
+ $defaultPreferences['rcfilters-saved-queries'] = [
+ 'type' => 'api',
+ ];
+ $defaultPreferences['rcfilters-wl-saved-queries'] = [
+ 'type' => 'api',
+ ];
+ $defaultPreferences['rcfilters-rclimit'] = [
+ 'type' => 'api',
+ ];
+
+ if ( $config->get( 'RCWatchCategoryMembership' ) ) {
+ $defaultPreferences['hidecategorization'] = [
+ 'type' => 'toggle',
+ 'label-message' => 'tog-hidecategorization',
+ 'section' => 'rc/advancedrc',
+ ];
+ }
+
+ if ( $user->useRCPatrol() ) {
+ $defaultPreferences['hidepatrolled'] = [
+ 'type' => 'toggle',
+ 'section' => 'rc/advancedrc',
+ 'label-message' => 'tog-hidepatrolled',
+ ];
+ }
+
+ if ( $user->useNPPatrol() ) {
+ $defaultPreferences['newpageshidepatrolled'] = [
+ 'type' => 'toggle',
+ 'section' => 'rc/advancedrc',
+ 'label-message' => 'tog-newpageshidepatrolled',
+ ];
+ }
+
+ if ( $config->get( 'RCShowWatchingUsers' ) ) {
+ $defaultPreferences['shownumberswatching'] = [
+ 'type' => 'toggle',
+ 'section' => 'rc/advancedrc',
+ 'label-message' => 'tog-shownumberswatching',
+ ];
+ }
+
+ if ( $config->get( 'StructuredChangeFiltersShowPreference' ) ) {
+ $defaultPreferences['rcenhancedfilters-disable'] = [
+ 'type' => 'toggle',
+ 'section' => 'rc/opt-out',
+ 'label-message' => 'rcfilters-preference-label',
+ 'help-message' => 'rcfilters-preference-help',
+ ];
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ */
+ static function watchlistPreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ $config = $context->getConfig();
+ $watchlistdaysMax = ceil( $config->get( 'RCMaxAge' ) / ( 3600 * 24 ) );
+
+ # # Watchlist #####################################
+ if ( $user->isAllowed( 'editmywatchlist' ) ) {
+ $editWatchlistLinks = [];
+ $editWatchlistModes = [
+ 'edit' => [ 'EditWatchlist', false ],
+ 'raw' => [ 'EditWatchlist', 'raw' ],
+ 'clear' => [ 'EditWatchlist', 'clear' ],
+ ];
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ foreach ( $editWatchlistModes as $editWatchlistMode => $mode ) {
+ // Messages: prefs-editwatchlist-edit, prefs-editwatchlist-raw, prefs-editwatchlist-clear
+ $editWatchlistLinks[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( $mode[0], $mode[1] ),
+ new HtmlArmor( $context->msg( "prefs-editwatchlist-{$editWatchlistMode}" )->parse() )
+ );
+ }
+
+ $defaultPreferences['editwatchlist'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $context->getLanguage()->pipeList( $editWatchlistLinks ),
+ 'label-message' => 'prefs-editwatchlist-label',
+ 'section' => 'watchlist/editwatchlist',
+ ];
+ }
+
+ $defaultPreferences['watchlistdays'] = [
+ 'type' => 'float',
+ 'min' => 0,
+ 'max' => $watchlistdaysMax,
+ 'section' => 'watchlist/displaywatchlist',
+ 'help' => $context->msg( 'prefs-watchlist-days-max' )->numParams(
+ $watchlistdaysMax )->escaped(),
+ 'label-message' => 'prefs-watchlist-days',
+ ];
+ $defaultPreferences['wllimit'] = [
+ 'type' => 'int',
+ 'min' => 0,
+ 'max' => 1000,
+ 'label-message' => 'prefs-watchlist-edits',
+ 'help' => $context->msg( 'prefs-watchlist-edits-max' )->escaped(),
+ 'section' => 'watchlist/displaywatchlist',
+ ];
+ $defaultPreferences['extendwatchlist'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-extendwatchlist',
+ ];
+ $defaultPreferences['watchlisthideminor'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlisthideminor',
+ ];
+ $defaultPreferences['watchlisthidebots'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlisthidebots',
+ ];
+ $defaultPreferences['watchlisthideown'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlisthideown',
+ ];
+ $defaultPreferences['watchlisthideanons'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlisthideanons',
+ ];
+ $defaultPreferences['watchlisthideliu'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlisthideliu',
+ ];
+ $defaultPreferences['watchlistreloadautomatically'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlistreloadautomatically',
+ ];
+ $defaultPreferences['watchlistunwatchlinks'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlistunwatchlinks',
+ ];
+
+ if ( $config->get( 'RCWatchCategoryMembership' ) ) {
+ $defaultPreferences['watchlisthidecategorization'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlisthidecategorization',
+ ];
+ }
+
+ if ( $user->useRCPatrol() ) {
+ $defaultPreferences['watchlisthidepatrolled'] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => 'tog-watchlisthidepatrolled',
+ ];
+ }
+
+ $watchTypes = [
+ 'edit' => 'watchdefault',
+ 'move' => 'watchmoves',
+ 'delete' => 'watchdeletion'
+ ];
+
+ // Kinda hacky
+ if ( $user->isAllowed( 'createpage' ) || $user->isAllowed( 'createtalk' ) ) {
+ $watchTypes['read'] = 'watchcreations';
+ }
+
+ if ( $user->isAllowed( 'rollback' ) ) {
+ $watchTypes['rollback'] = 'watchrollback';
+ }
+
+ if ( $user->isAllowed( 'upload' ) ) {
+ $watchTypes['upload'] = 'watchuploads';
+ }
+
+ foreach ( $watchTypes as $action => $pref ) {
+ if ( $user->isAllowed( $action ) ) {
+ // Messages:
+ // tog-watchdefault, tog-watchmoves, tog-watchdeletion, tog-watchcreations, tog-watchuploads
+ // tog-watchrollback
+ $defaultPreferences[$pref] = [
+ 'type' => 'toggle',
+ 'section' => 'watchlist/advancedwatchlist',
+ 'label-message' => "tog-$pref",
+ ];
+ }
+ }
+
+ if ( $config->get( 'EnableAPI' ) ) {
+ $defaultPreferences['watchlisttoken'] = [
+ 'type' => 'api',
+ ];
+ $defaultPreferences['watchlisttoken-info'] = [
+ 'type' => 'info',
+ 'section' => 'watchlist/tokenwatchlist',
+ 'label-message' => 'prefs-watchlist-token',
+ 'default' => $user->getTokenFromOption( 'watchlisttoken' ),
+ 'help-message' => 'prefs-help-watchlist-token2',
+ ];
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ */
+ static function searchPreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ foreach ( MWNamespace::getValidNamespaces() as $n ) {
+ $defaultPreferences['searchNs' . $n] = [
+ 'type' => 'api',
+ ];
+ }
+ }
+
+ /**
+ * Dummy, kept for backwards-compatibility.
+ * @param User $user
+ * @param IContextSource $context
+ * @param array &$defaultPreferences
+ */
+ static function miscPreferences( $user, IContextSource $context, &$defaultPreferences ) {
+ }
+
+ /**
+ * @param User $user The User object
+ * @param IContextSource $context
+ * @return array Text/links to display as key; $skinkey as value
+ */
+ static function generateSkinOptions( $user, IContextSource $context ) {
+ $ret = [];
+
+ $mptitle = Title::newMainPage();
+ $previewtext = $context->msg( 'skin-preview' )->escaped();
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ # Only show skins that aren't disabled in $wgSkipSkins
+ $validSkinNames = Skin::getAllowedSkins();
+
+ # Sort by UI skin name. First though need to update validSkinNames as sometimes
+ # the skinkey & UI skinname differ (e.g. "standard" skinkey is "Classic" in the UI).
+ foreach ( $validSkinNames as $skinkey => &$skinname ) {
+ $msg = $context->msg( "skinname-{$skinkey}" );
+ if ( $msg->exists() ) {
+ $skinname = htmlspecialchars( $msg->text() );
+ }
+ }
+ asort( $validSkinNames );
+
+ $config = $context->getConfig();
+ $defaultSkin = $config->get( 'DefaultSkin' );
+ $allowUserCss = $config->get( 'AllowUserCss' );
+ $allowUserJs = $config->get( 'AllowUserJs' );
+
+ $foundDefault = false;
+ foreach ( $validSkinNames as $skinkey => $sn ) {
+ $linkTools = [];
+
+ # Mark the default skin
+ if ( strcasecmp( $skinkey, $defaultSkin ) === 0 ) {
+ $linkTools[] = $context->msg( 'default' )->escaped();
+ $foundDefault = true;
+ }
+
+ # Create preview link
+ $mplink = htmlspecialchars( $mptitle->getLocalURL( [ 'useskin' => $skinkey ] ) );
+ $linkTools[] = "<a target='_blank' href=\"$mplink\">$previewtext</a>";
+
+ # Create links to user CSS/JS pages
+ if ( $allowUserCss ) {
+ $cssPage = Title::makeTitleSafe( NS_USER, $user->getName() . '/' . $skinkey . '.css' );
+ $linkTools[] = $linkRenderer->makeLink( $cssPage, $context->msg( 'prefs-custom-css' )->text() );
+ }
+
+ if ( $allowUserJs ) {
+ $jsPage = Title::makeTitleSafe( NS_USER, $user->getName() . '/' . $skinkey . '.js' );
+ $linkTools[] = $linkRenderer->makeLink( $jsPage, $context->msg( 'prefs-custom-js' )->text() );
+ }
+
+ $display = $sn . ' ' . $context->msg( 'parentheses' )
+ ->rawParams( $context->getLanguage()->pipeList( $linkTools ) )
+ ->escaped();
+ $ret[$display] = $skinkey;
+ }
+
+ if ( !$foundDefault ) {
+ // If the default skin is not available, things are going to break horribly because the
+ // default value for skin selector will not be a valid value. Let's just not show it then.
+ return [];
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @return array
+ */
+ static function getDateOptions( IContextSource $context ) {
+ $lang = $context->getLanguage();
+ $dateopts = $lang->getDatePreferences();
+
+ $ret = [];
+
+ if ( $dateopts ) {
+ if ( !in_array( 'default', $dateopts ) ) {
+ $dateopts[] = 'default'; // Make sure default is always valid T21237
+ }
+
+ // FIXME KLUGE: site default might not be valid for user language
+ global $wgDefaultUserOptions;
+ if ( !in_array( $wgDefaultUserOptions['date'], $dateopts ) ) {
+ $wgDefaultUserOptions['date'] = 'default';
+ }
+
+ $epoch = wfTimestampNow();
+ foreach ( $dateopts as $key ) {
+ if ( $key == 'default' ) {
+ $formatted = $context->msg( 'datedefault' )->escaped();
+ } else {
+ $formatted = htmlspecialchars( $lang->timeanddate( $epoch, false, $key ) );
+ }
+ $ret[$formatted] = $key;
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @return array
+ */
+ static function getImageSizes( IContextSource $context ) {
+ $ret = [];
+ $pixels = $context->msg( 'unit-pixel' )->text();
+
+ foreach ( $context->getConfig()->get( 'ImageLimits' ) as $index => $limits ) {
+ // Note: A left-to-right marker (\u200e) is inserted, see T144386
+ $display = "{$limits[0]}" . json_decode( '"\u200e"' ) . "×{$limits[1]}" . $pixels;
+ $ret[$display] = $index;
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @return array
+ */
+ static function getThumbSizes( IContextSource $context ) {
+ $ret = [];
+ $pixels = $context->msg( 'unit-pixel' )->text();
+
+ foreach ( $context->getConfig()->get( 'ThumbLimits' ) as $index => $size ) {
+ $display = $size . $pixels;
+ $ret[$display] = $index;
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $signature
+ * @param array $alldata
+ * @param HTMLForm $form
+ * @return bool|string
+ */
+ static function validateSignature( $signature, $alldata, $form ) {
+ global $wgParser;
+ $maxSigChars = $form->getConfig()->get( 'MaxSigChars' );
+ if ( mb_strlen( $signature ) > $maxSigChars ) {
+ return Xml::element( 'span', [ 'class' => 'error' ],
+ $form->msg( 'badsiglength' )->numParams( $maxSigChars )->text() );
+ } elseif ( isset( $alldata['fancysig'] ) &&
+ $alldata['fancysig'] &&
+ $wgParser->validateSig( $signature ) === false
+ ) {
+ return Xml::element(
+ 'span',
+ [ 'class' => 'error' ],
+ $form->msg( 'badsig' )->text()
+ );
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * @param string $signature
+ * @param array $alldata
+ * @param HTMLForm $form
+ * @return string
+ */
+ static function cleanSignature( $signature, $alldata, $form ) {
+ if ( isset( $alldata['fancysig'] ) && $alldata['fancysig'] ) {
+ global $wgParser;
+ $signature = $wgParser->cleanSig( $signature );
+ } else {
+ // When no fancy sig used, make sure ~{3,5} get removed.
+ $signature = Parser::cleanSigInSig( $signature );
+ }
+
+ return $signature;
+ }
+
+ /**
+ * @param User $user
+ * @param IContextSource $context
+ * @param string $formClass
+ * @param array $remove Array of items to remove
+ * @return PreferencesForm|HtmlForm
+ */
+ static function getFormObject(
+ $user,
+ IContextSource $context,
+ $formClass = 'PreferencesForm',
+ array $remove = []
+ ) {
+ $formDescriptor = self::getPreferences( $user, $context );
+ if ( count( $remove ) ) {
+ $removeKeys = array_flip( $remove );
+ $formDescriptor = array_diff_key( $formDescriptor, $removeKeys );
+ }
+
+ // Remove type=api preferences. They are not intended for rendering in the form.
+ foreach ( $formDescriptor as $name => $info ) {
+ if ( isset( $info['type'] ) && $info['type'] === 'api' ) {
+ unset( $formDescriptor[$name] );
+ }
+ }
+
+ /**
+ * @var $htmlForm PreferencesForm
+ */
+ $htmlForm = new $formClass( $formDescriptor, $context, 'prefs' );
+
+ $htmlForm->setModifiedUser( $user );
+ $htmlForm->setId( 'mw-prefs-form' );
+ $htmlForm->setAutocomplete( 'off' );
+ $htmlForm->setSubmitText( $context->msg( 'saveprefs' )->text() );
+ # Used message keys: 'accesskey-preferences-save', 'tooltip-preferences-save'
+ $htmlForm->setSubmitTooltip( 'preferences-save' );
+ $htmlForm->setSubmitID( 'prefcontrol' );
+ $htmlForm->setSubmitCallback( [ 'Preferences', 'tryFormSubmit' ] );
+
+ return $htmlForm;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @return array
+ */
+ static function getTimezoneOptions( IContextSource $context ) {
+ $opt = [];
+
+ $localTZoffset = $context->getConfig()->get( 'LocalTZoffset' );
+ $timeZoneList = self::getTimeZoneList( $context->getLanguage() );
+
+ $timestamp = MWTimestamp::getLocalInstance();
+ // Check that the LocalTZoffset is the same as the local time zone offset
+ if ( $localTZoffset == $timestamp->format( 'Z' ) / 60 ) {
+ $timezoneName = $timestamp->getTimezone()->getName();
+ // Localize timezone
+ if ( isset( $timeZoneList[$timezoneName] ) ) {
+ $timezoneName = $timeZoneList[$timezoneName]['name'];
+ }
+ $server_tz_msg = $context->msg(
+ 'timezoneuseserverdefault',
+ $timezoneName
+ )->text();
+ } else {
+ $tzstring = sprintf(
+ '%+03d:%02d',
+ floor( $localTZoffset / 60 ),
+ abs( $localTZoffset ) % 60
+ );
+ $server_tz_msg = $context->msg( 'timezoneuseserverdefault', $tzstring )->text();
+ }
+ $opt[$server_tz_msg] = "System|$localTZoffset";
+ $opt[$context->msg( 'timezoneuseoffset' )->text()] = 'other';
+ $opt[$context->msg( 'guesstimezone' )->text()] = 'guess';
+
+ foreach ( $timeZoneList as $timeZoneInfo ) {
+ $region = $timeZoneInfo['region'];
+ if ( !isset( $opt[$region] ) ) {
+ $opt[$region] = [];
+ }
+ $opt[$region][$timeZoneInfo['name']] = $timeZoneInfo['timecorrection'];
+ }
+ return $opt;
+ }
+
+ /**
+ * @param string $value
+ * @param array $alldata
+ * @return int
+ */
+ static function filterIntval( $value, $alldata ) {
+ return intval( $value );
+ }
+
+ /**
+ * @param string $tz
+ * @param array $alldata
+ * @return string
+ */
+ static function filterTimezoneInput( $tz, $alldata ) {
+ $data = explode( '|', $tz, 3 );
+ switch ( $data[0] ) {
+ case 'ZoneInfo':
+ $valid = false;
+
+ if ( count( $data ) === 3 ) {
+ // Make sure this timezone exists
+ try {
+ new DateTimeZone( $data[2] );
+ // If the constructor didn't throw, we know it's valid
+ $valid = true;
+ } catch ( Exception $e ) {
+ // Not a valid timezone
+ }
+ }
+
+ if ( !$valid ) {
+ // If the supplied timezone doesn't exist, fall back to the encoded offset
+ return 'Offset|' . intval( $tz[1] );
+ }
+ return $tz;
+ case 'System':
+ return $tz;
+ default:
+ $data = explode( ':', $tz, 2 );
+ if ( count( $data ) == 2 ) {
+ $data[0] = intval( $data[0] );
+ $data[1] = intval( $data[1] );
+ $minDiff = abs( $data[0] ) * 60 + $data[1];
+ if ( $data[0] < 0 ) {
+ $minDiff = - $minDiff;
+ }
+ } else {
+ $minDiff = intval( $data[0] ) * 60;
+ }
+
+ # Max is +14:00 and min is -12:00, see:
+ # https://en.wikipedia.org/wiki/Timezone
+ $minDiff = min( $minDiff, 840 ); # 14:00
+ $minDiff = max( $minDiff, -720 ); # -12:00
+ return 'Offset|' . $minDiff;
+ }
+ }
+
+ /**
+ * Handle the form submission if everything validated properly
+ *
+ * @param array $formData
+ * @param PreferencesForm $form
+ * @return bool|Status|string
+ */
+ static function tryFormSubmit( $formData, $form ) {
+ $user = $form->getModifiedUser();
+ $hiddenPrefs = $form->getConfig()->get( 'HiddenPrefs' );
+ $result = true;
+
+ if ( !$user->isAllowedAny( 'editmyprivateinfo', 'editmyoptions' ) ) {
+ return Status::newFatal( 'mypreferencesprotected' );
+ }
+
+ // Filter input
+ foreach ( array_keys( $formData ) as $name ) {
+ if ( isset( self::$saveFilters[$name] ) ) {
+ $formData[$name] =
+ call_user_func( self::$saveFilters[$name], $formData[$name], $formData );
+ }
+ }
+
+ // Fortunately, the realname field is MUCH simpler
+ // (not really "private", but still shouldn't be edited without permission)
+
+ if ( !in_array( 'realname', $hiddenPrefs )
+ && $user->isAllowed( 'editmyprivateinfo' )
+ && array_key_exists( 'realname', $formData )
+ ) {
+ $realName = $formData['realname'];
+ $user->setRealName( $realName );
+ }
+
+ if ( $user->isAllowed( 'editmyoptions' ) ) {
+ $oldUserOptions = $user->getOptions();
+
+ foreach ( self::$saveBlacklist as $b ) {
+ unset( $formData[$b] );
+ }
+
+ # If users have saved a value for a preference which has subsequently been disabled
+ # via $wgHiddenPrefs, we don't want to destroy that setting in case the preference
+ # is subsequently re-enabled
+ foreach ( $hiddenPrefs as $pref ) {
+ # If the user has not set a non-default value here, the default will be returned
+ # and subsequently discarded
+ $formData[$pref] = $user->getOption( $pref, null, true );
+ }
+
+ // Keep old preferences from interfering due to back-compat code, etc.
+ $user->resetOptions( 'unused', $form->getContext() );
+
+ foreach ( $formData as $key => $value ) {
+ $user->setOption( $key, $value );
+ }
+
+ Hooks::run(
+ 'PreferencesFormPreSave',
+ [ $formData, $form, $user, &$result, $oldUserOptions ]
+ );
+ }
+
+ MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'updateExternalDB', [ $user ] );
+ $user->saveSettings();
+
+ return $result;
+ }
+
+ /**
+ * @param array $formData
+ * @param PreferencesForm $form
+ * @return Status
+ */
+ public static function tryUISubmit( $formData, $form ) {
+ $res = self::tryFormSubmit( $formData, $form );
+
+ if ( $res ) {
+ $urlOptions = [];
+
+ if ( $res === 'eauth' ) {
+ $urlOptions['eauth'] = 1;
+ }
+
+ $urlOptions += $form->getExtraSuccessRedirectParameters();
+
+ $url = $form->getTitle()->getFullURL( $urlOptions );
+
+ $context = $form->getContext();
+ // Set session data for the success message
+ $context->getRequest()->getSession()->set( 'specialPreferencesSaveSuccess', 1 );
+
+ $context->getOutput()->redirect( $url );
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Get a list of all time zones
+ * @param Language $language Language used for the localized names
+ * @return array A list of all time zones. The system name of the time zone is used as key and
+ * the value is an array which contains localized name, the timecorrection value used for
+ * preferences and the region
+ * @since 1.26
+ */
+ public static function getTimeZoneList( Language $language ) {
+ $identifiers = DateTimeZone::listIdentifiers();
+ if ( $identifiers === false ) {
+ return [];
+ }
+ sort( $identifiers );
+
+ $tzRegions = [
+ 'Africa' => wfMessage( 'timezoneregion-africa' )->inLanguage( $language )->text(),
+ 'America' => wfMessage( 'timezoneregion-america' )->inLanguage( $language )->text(),
+ 'Antarctica' => wfMessage( 'timezoneregion-antarctica' )->inLanguage( $language )->text(),
+ 'Arctic' => wfMessage( 'timezoneregion-arctic' )->inLanguage( $language )->text(),
+ 'Asia' => wfMessage( 'timezoneregion-asia' )->inLanguage( $language )->text(),
+ 'Atlantic' => wfMessage( 'timezoneregion-atlantic' )->inLanguage( $language )->text(),
+ 'Australia' => wfMessage( 'timezoneregion-australia' )->inLanguage( $language )->text(),
+ 'Europe' => wfMessage( 'timezoneregion-europe' )->inLanguage( $language )->text(),
+ 'Indian' => wfMessage( 'timezoneregion-indian' )->inLanguage( $language )->text(),
+ 'Pacific' => wfMessage( 'timezoneregion-pacific' )->inLanguage( $language )->text(),
+ ];
+ asort( $tzRegions );
+
+ $timeZoneList = [];
+
+ $now = new DateTime();
+
+ foreach ( $identifiers as $identifier ) {
+ $parts = explode( '/', $identifier, 2 );
+
+ // DateTimeZone::listIdentifiers() returns a number of
+ // backwards-compatibility entries. This filters them out of the
+ // list presented to the user.
+ if ( count( $parts ) !== 2 || !array_key_exists( $parts[0], $tzRegions ) ) {
+ continue;
+ }
+
+ // Localize region
+ $parts[0] = $tzRegions[$parts[0]];
+
+ $dateTimeZone = new DateTimeZone( $identifier );
+ $minDiff = floor( $dateTimeZone->getOffset( $now ) / 60 );
+
+ $display = str_replace( '_', ' ', $parts[0] . '/' . $parts[1] );
+ $value = "ZoneInfo|$minDiff|$identifier";
+
+ $timeZoneList[$identifier] = [
+ 'name' => $display,
+ 'timecorrection' => $value,
+ 'region' => $parts[0],
+ ];
+ }
+
+ return $timeZoneList;
+ }
+}
diff --git a/www/wiki/includes/PrefixSearch.php b/www/wiki/includes/PrefixSearch.php
new file mode 100644
index 00000000..62ee5c65
--- /dev/null
+++ b/www/wiki/includes/PrefixSearch.php
@@ -0,0 +1,406 @@
+<?php
+/**
+ * Prefix search of page names.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Handles searching prefixes of titles and finding any page
+ * names that match. Used largely by the OpenSearch implementation.
+ * @deprecated Since 1.27, Use SearchEngine::defaultPrefixSearch or SearchEngine::completionSearch
+ *
+ * @ingroup Search
+ */
+abstract class PrefixSearch {
+ /**
+ * Do a prefix search of titles and return a list of matching page names.
+ * @deprecated Since 1.23, use TitlePrefixSearch or StringPrefixSearch classes
+ *
+ * @param string $search
+ * @param int $limit
+ * @param array $namespaces Used if query is not explicitly prefixed
+ * @param int $offset How many results to offset from the beginning
+ * @return array Array of strings
+ */
+ public static function titleSearch( $search, $limit, $namespaces = [], $offset = 0 ) {
+ $prefixSearch = new StringPrefixSearch;
+ return $prefixSearch->search( $search, $limit, $namespaces, $offset );
+ }
+
+ /**
+ * Do a prefix search of titles and return a list of matching page names.
+ *
+ * @param string $search
+ * @param int $limit
+ * @param array $namespaces Used if query is not explicitly prefixed
+ * @param int $offset How many results to offset from the beginning
+ * @return array Array of strings or Title objects
+ */
+ public function search( $search, $limit, $namespaces = [], $offset = 0 ) {
+ $search = trim( $search );
+ if ( $search == '' ) {
+ return []; // Return empty result
+ }
+
+ $hasNamespace = $this->extractNamespace( $search );
+ if ( $hasNamespace ) {
+ list( $namespace, $search ) = $hasNamespace;
+ $namespaces = [ $namespace ];
+ } else {
+ $namespaces = $this->validateNamespaces( $namespaces );
+ Hooks::run( 'PrefixSearchExtractNamespace', [ &$namespaces, &$search ] );
+ }
+
+ return $this->searchBackend( $namespaces, $search, $limit, $offset );
+ }
+
+ /**
+ * Figure out if given input contains an explicit namespace.
+ *
+ * @param string $input
+ * @return false|array Array of namespace and remaining text, or false if no namespace given.
+ */
+ protected function extractNamespace( $input ) {
+ if ( strpos( $input, ':' ) === false ) {
+ return false;
+ }
+
+ // Namespace prefix only
+ $title = Title::newFromText( $input . 'Dummy' );
+ if (
+ $title &&
+ $title->getText() === 'Dummy' &&
+ !$title->inNamespace( NS_MAIN ) &&
+ !$title->isExternal()
+ ) {
+ return [ $title->getNamespace(), '' ];
+ }
+
+ // Namespace prefix with additional input
+ $title = Title::newFromText( $input );
+ if (
+ $title &&
+ !$title->inNamespace( NS_MAIN ) &&
+ !$title->isExternal()
+ ) {
+ // getText provides correct capitalization
+ return [ $title->getNamespace(), $title->getText() ];
+ }
+
+ return false;
+ }
+
+ /**
+ * Do a prefix search for all possible variants of the prefix
+ * @param string $search
+ * @param int $limit
+ * @param array $namespaces
+ * @param int $offset How many results to offset from the beginning
+ *
+ * @return array
+ */
+ public function searchWithVariants( $search, $limit, array $namespaces, $offset = 0 ) {
+ $searches = $this->search( $search, $limit, $namespaces, $offset );
+
+ // if the content language has variants, try to retrieve fallback results
+ $fallbackLimit = $limit - count( $searches );
+ if ( $fallbackLimit > 0 ) {
+ global $wgContLang;
+
+ $fallbackSearches = $wgContLang->autoConvertToAllVariants( $search );
+ $fallbackSearches = array_diff( array_unique( $fallbackSearches ), [ $search ] );
+
+ foreach ( $fallbackSearches as $fbs ) {
+ $fallbackSearchResult = $this->search( $fbs, $fallbackLimit, $namespaces );
+ $searches = array_merge( $searches, $fallbackSearchResult );
+ $fallbackLimit -= count( $fallbackSearchResult );
+
+ if ( $fallbackLimit == 0 ) {
+ break;
+ }
+ }
+ }
+ return $searches;
+ }
+
+ /**
+ * When implemented in a descendant class, receives an array of Title objects and returns
+ * either an unmodified array or an array of strings corresponding to titles passed to it.
+ *
+ * @param array $titles
+ * @return array
+ */
+ abstract protected function titles( array $titles );
+
+ /**
+ * When implemented in a descendant class, receives an array of titles as strings and returns
+ * either an unmodified array or an array of Title objects corresponding to strings received.
+ *
+ * @param array $strings
+ *
+ * @return array
+ */
+ abstract protected function strings( array $strings );
+
+ /**
+ * Do a prefix search of titles and return a list of matching page names.
+ * @param array $namespaces
+ * @param string $search
+ * @param int $limit
+ * @param int $offset How many results to offset from the beginning
+ * @return array Array of strings
+ */
+ protected function searchBackend( $namespaces, $search, $limit, $offset ) {
+ if ( count( $namespaces ) == 1 ) {
+ $ns = $namespaces[0];
+ if ( $ns == NS_MEDIA ) {
+ $namespaces = [ NS_FILE ];
+ } elseif ( $ns == NS_SPECIAL ) {
+ return $this->titles( $this->specialSearch( $search, $limit, $offset ) );
+ }
+ }
+ $srchres = [];
+ if ( Hooks::run(
+ 'PrefixSearchBackend',
+ [ $namespaces, $search, $limit, &$srchres, $offset ]
+ ) ) {
+ return $this->titles( $this->defaultSearchBackend( $namespaces, $search, $limit, $offset ) );
+ }
+ return $this->strings(
+ $this->handleResultFromHook( $srchres, $namespaces, $search, $limit, $offset ) );
+ }
+
+ private function handleResultFromHook( $srchres, $namespaces, $search, $limit, $offset ) {
+ if ( $offset === 0 ) {
+ // Only perform exact db match if offset === 0
+ // This is still far from perfect but at least we avoid returning the
+ // same title afain and again when the user is scrolling with a query
+ // that matches a title in the db.
+ $rescorer = new SearchExactMatchRescorer();
+ $srchres = $rescorer->rescore( $search, $namespaces, $srchres, $limit );
+ }
+ return $srchres;
+ }
+
+ /**
+ * Prefix search special-case for Special: namespace.
+ *
+ * @param string $search Term
+ * @param int $limit Max number of items to return
+ * @param int $offset Number of items to offset
+ * @return array
+ */
+ protected function specialSearch( $search, $limit, $offset ) {
+ global $wgContLang;
+
+ $searchParts = explode( '/', $search, 2 );
+ $searchKey = $searchParts[0];
+ $subpageSearch = isset( $searchParts[1] ) ? $searchParts[1] : null;
+
+ // Handle subpage search separately.
+ if ( $subpageSearch !== null ) {
+ // Try matching the full search string as a page name
+ $specialTitle = Title::makeTitleSafe( NS_SPECIAL, $searchKey );
+ if ( !$specialTitle ) {
+ return [];
+ }
+ $special = SpecialPageFactory::getPage( $specialTitle->getText() );
+ if ( $special ) {
+ $subpages = $special->prefixSearchSubpages( $subpageSearch, $limit, $offset );
+ return array_map( function ( $sub ) use ( $specialTitle ) {
+ return $specialTitle->getSubpage( $sub );
+ }, $subpages );
+ } else {
+ return [];
+ }
+ }
+
+ # normalize searchKey, so aliases with spaces can be found - T27675
+ $searchKey = str_replace( ' ', '_', $searchKey );
+ $searchKey = $wgContLang->caseFold( $searchKey );
+
+ // Unlike SpecialPage itself, we want the canonical forms of both
+ // canonical and alias title forms...
+ $keys = [];
+ foreach ( SpecialPageFactory::getNames() as $page ) {
+ $keys[$wgContLang->caseFold( $page )] = [ 'page' => $page, 'rank' => 0 ];
+ }
+
+ foreach ( $wgContLang->getSpecialPageAliases() as $page => $aliases ) {
+ if ( !in_array( $page, SpecialPageFactory::getNames() ) ) {# T22885
+ continue;
+ }
+
+ foreach ( $aliases as $key => $alias ) {
+ $keys[$wgContLang->caseFold( $alias )] = [ 'page' => $alias, 'rank' => $key ];
+ }
+ }
+ ksort( $keys );
+
+ $matches = [];
+ foreach ( $keys as $pageKey => $page ) {
+ if ( $searchKey === '' || strpos( $pageKey, $searchKey ) === 0 ) {
+ // T29671: Don't use SpecialPage::getTitleFor() here because it
+ // localizes its input leading to searches for e.g. Special:All
+ // returning Spezial:MediaWiki-Systemnachrichten and returning
+ // Spezial:Alle_Seiten twice when $wgLanguageCode == 'de'
+ $matches[$page['rank']][] = Title::makeTitleSafe( NS_SPECIAL, $page['page'] );
+
+ if ( isset( $matches[0] ) && count( $matches[0] ) >= $limit + $offset ) {
+ // We have enough items in primary rank, no use to continue
+ break;
+ }
+ }
+
+ }
+
+ // Ensure keys are in order
+ ksort( $matches );
+ // Flatten the array
+ $matches = array_reduce( $matches, 'array_merge', [] );
+
+ return array_slice( $matches, $offset, $limit );
+ }
+
+ /**
+ * Unless overridden by PrefixSearchBackend hook...
+ * This is case-sensitive (First character may
+ * be automatically capitalized by Title::secureAndSpit()
+ * later on depending on $wgCapitalLinks)
+ *
+ * @param array|null $namespaces Namespaces to search in
+ * @param string $search Term
+ * @param int $limit Max number of items to return
+ * @param int $offset Number of items to skip
+ * @return Title[] Array of Title objects
+ */
+ public function defaultSearchBackend( $namespaces, $search, $limit, $offset ) {
+ // Backwards compatability with old code. Default to NS_MAIN if no namespaces provided.
+ if ( $namespaces === null ) {
+ $namespaces = [];
+ }
+ if ( !$namespaces ) {
+ $namespaces[] = NS_MAIN;
+ }
+
+ // Construct suitable prefix for each namespace. They differ in cases where
+ // some namespaces always capitalize and some don't.
+ $prefixes = [];
+ foreach ( $namespaces as $namespace ) {
+ // For now, if special is included, ignore the other namespaces
+ if ( $namespace == NS_SPECIAL ) {
+ return $this->specialSearch( $search, $limit, $offset );
+ }
+
+ $title = Title::makeTitleSafe( $namespace, $search );
+ // Why does the prefix default to empty?
+ $prefix = $title ? $title->getDBkey() : '';
+ $prefixes[$prefix][] = $namespace;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ // Often there is only one prefix that applies to all requested namespaces,
+ // but sometimes there are two if some namespaces do not always capitalize.
+ $conds = [];
+ foreach ( $prefixes as $prefix => $namespaces ) {
+ $condition = [
+ 'page_namespace' => $namespaces,
+ 'page_title' . $dbr->buildLike( $prefix, $dbr->anyString() ),
+ ];
+ $conds[] = $dbr->makeList( $condition, LIST_AND );
+ }
+
+ $table = 'page';
+ $fields = [ 'page_id', 'page_namespace', 'page_title' ];
+ $conds = $dbr->makeList( $conds, LIST_OR );
+ $options = [
+ 'LIMIT' => $limit,
+ 'ORDER BY' => [ 'page_title', 'page_namespace' ],
+ 'OFFSET' => $offset
+ ];
+
+ $res = $dbr->select( $table, $fields, $conds, __METHOD__, $options );
+
+ return iterator_to_array( TitleArray::newFromResult( $res ) );
+ }
+
+ /**
+ * Validate an array of numerical namespace indexes
+ *
+ * @param array $namespaces
+ * @return array (default: contains only NS_MAIN)
+ */
+ protected function validateNamespaces( $namespaces ) {
+ global $wgContLang;
+
+ // We will look at each given namespace against wgContLang namespaces
+ $validNamespaces = $wgContLang->getNamespaces();
+ if ( is_array( $namespaces ) && count( $namespaces ) > 0 ) {
+ $valid = [];
+ foreach ( $namespaces as $ns ) {
+ if ( is_numeric( $ns ) && array_key_exists( $ns, $validNamespaces ) ) {
+ $valid[] = $ns;
+ }
+ }
+ if ( count( $valid ) > 0 ) {
+ return $valid;
+ }
+ }
+
+ return [ NS_MAIN ];
+ }
+}
+
+/**
+ * Performs prefix search, returning Title objects
+ * @deprecated Since 1.27, Use SearchEngine::defaultPrefixSearch or SearchEngine::completionSearch
+ * @ingroup Search
+ */
+class TitlePrefixSearch extends PrefixSearch {
+
+ protected function titles( array $titles ) {
+ return $titles;
+ }
+
+ protected function strings( array $strings ) {
+ $titles = array_map( 'Title::newFromText', $strings );
+ $lb = new LinkBatch( $titles );
+ $lb->setCaller( __METHOD__ );
+ $lb->execute();
+ return $titles;
+ }
+}
+
+/**
+ * Performs prefix search, returning strings
+ * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
+ * @ingroup Search
+ */
+class StringPrefixSearch extends PrefixSearch {
+
+ protected function titles( array $titles ) {
+ return array_map( function ( Title $t ) {
+ return $t->getPrefixedText();
+ }, $titles );
+ }
+
+ protected function strings( array $strings ) {
+ return $strings;
+ }
+}
diff --git a/www/wiki/includes/ProtectionForm.php b/www/wiki/includes/ProtectionForm.php
new file mode 100644
index 00000000..53608e84
--- /dev/null
+++ b/www/wiki/includes/ProtectionForm.php
@@ -0,0 +1,630 @@
+<?php
+/**
+ * Page protection
+ *
+ * Copyright © 2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Handles the page protection UI and backend
+ */
+class ProtectionForm {
+ /** @var array A map of action to restriction level, from request or default */
+ protected $mRestrictions = [];
+
+ /** @var string The custom/additional protection reason */
+ protected $mReason = '';
+
+ /** @var string The reason selected from the list, blank for other/additional */
+ protected $mReasonSelection = '';
+
+ /** @var bool True if the restrictions are cascading, from request or existing protection */
+ protected $mCascade = false;
+
+ /** @var array Map of action to "other" expiry time. Used in preference to mExpirySelection. */
+ protected $mExpiry = [];
+
+ /**
+ * @var array Map of action to value selected in expiry drop-down list.
+ * Will be set to 'othertime' whenever mExpiry is set.
+ */
+ protected $mExpirySelection = [];
+
+ /** @var array Permissions errors for the protect action */
+ protected $mPermErrors = [];
+
+ /** @var array Types (i.e. actions) for which levels can be selected */
+ protected $mApplicableTypes = [];
+
+ /** @var array Map of action to the expiry time of the existing protection */
+ protected $mExistingExpiry = [];
+
+ /** @var IContextSource */
+ private $mContext;
+
+ function __construct( Article $article ) {
+ // Set instance variables.
+ $this->mArticle = $article;
+ $this->mTitle = $article->getTitle();
+ $this->mApplicableTypes = $this->mTitle->getRestrictionTypes();
+ $this->mContext = $article->getContext();
+
+ // Check if the form should be disabled.
+ // If it is, the form will be available in read-only to show levels.
+ $this->mPermErrors = $this->mTitle->getUserPermissionsErrors(
+ 'protect',
+ $this->mContext->getUser(),
+ $this->mContext->getRequest()->wasPosted() ? 'secure' : 'full' // T92357
+ );
+ if ( wfReadOnly() ) {
+ $this->mPermErrors[] = [ 'readonlytext', wfReadOnlyReason() ];
+ }
+ $this->disabled = $this->mPermErrors != [];
+ $this->disabledAttrib = $this->disabled
+ ? [ 'disabled' => 'disabled' ]
+ : [];
+
+ $this->loadData();
+ }
+
+ /**
+ * Loads the current state of protection into the object.
+ */
+ function loadData() {
+ $levels = MWNamespace::getRestrictionLevels(
+ $this->mTitle->getNamespace(), $this->mContext->getUser()
+ );
+ $this->mCascade = $this->mTitle->areRestrictionsCascading();
+
+ $request = $this->mContext->getRequest();
+ $this->mReason = $request->getText( 'mwProtect-reason' );
+ $this->mReasonSelection = $request->getText( 'wpProtectReasonSelection' );
+ $this->mCascade = $request->getBool( 'mwProtect-cascade', $this->mCascade );
+
+ foreach ( $this->mApplicableTypes as $action ) {
+ // @todo FIXME: This form currently requires individual selections,
+ // but the db allows multiples separated by commas.
+
+ // Pull the actual restriction from the DB
+ $this->mRestrictions[$action] = implode( '', $this->mTitle->getRestrictions( $action ) );
+
+ if ( !$this->mRestrictions[$action] ) {
+ // No existing expiry
+ $existingExpiry = '';
+ } else {
+ $existingExpiry = $this->mTitle->getRestrictionExpiry( $action );
+ }
+ $this->mExistingExpiry[$action] = $existingExpiry;
+
+ $requestExpiry = $request->getText( "mwProtect-expiry-$action" );
+ $requestExpirySelection = $request->getVal( "wpProtectExpirySelection-$action" );
+
+ if ( $requestExpiry ) {
+ // Custom expiry takes precedence
+ $this->mExpiry[$action] = $requestExpiry;
+ $this->mExpirySelection[$action] = 'othertime';
+ } elseif ( $requestExpirySelection ) {
+ // Expiry selected from list
+ $this->mExpiry[$action] = '';
+ $this->mExpirySelection[$action] = $requestExpirySelection;
+ } elseif ( $existingExpiry ) {
+ // Use existing expiry in its own list item
+ $this->mExpiry[$action] = '';
+ $this->mExpirySelection[$action] = $existingExpiry;
+ } else {
+ // Catches 'infinity' - Existing expiry is infinite, use "infinite" in drop-down
+ // Final default: infinite
+ $this->mExpiry[$action] = '';
+ $this->mExpirySelection[$action] = 'infinite';
+ }
+
+ $val = $request->getVal( "mwProtect-level-$action" );
+ if ( isset( $val ) && in_array( $val, $levels ) ) {
+ $this->mRestrictions[$action] = $val;
+ }
+ }
+ }
+
+ /**
+ * Get the expiry time for a given action, by combining the relevant inputs.
+ *
+ * @param string $action
+ *
+ * @return string|false 14-char timestamp or "infinity", or false if the input was invalid
+ */
+ function getExpiry( $action ) {
+ if ( $this->mExpirySelection[$action] == 'existing' ) {
+ return $this->mExistingExpiry[$action];
+ } elseif ( $this->mExpirySelection[$action] == 'othertime' ) {
+ $value = $this->mExpiry[$action];
+ } else {
+ $value = $this->mExpirySelection[$action];
+ }
+ if ( wfIsInfinity( $value ) ) {
+ $time = 'infinity';
+ } else {
+ $unix = strtotime( $value );
+
+ if ( !$unix || $unix === -1 ) {
+ return false;
+ }
+
+ // @todo FIXME: Non-qualified absolute times are not in users specified timezone
+ // and there isn't notice about it in the ui
+ $time = wfTimestamp( TS_MW, $unix );
+ }
+ return $time;
+ }
+
+ /**
+ * Main entry point for action=protect and action=unprotect
+ */
+ function execute() {
+ if ( MWNamespace::getRestrictionLevels( $this->mTitle->getNamespace() ) === [ '' ] ) {
+ throw new ErrorPageError( 'protect-badnamespace-title', 'protect-badnamespace-text' );
+ }
+
+ if ( $this->mContext->getRequest()->wasPosted() ) {
+ if ( $this->save() ) {
+ $q = $this->mArticle->isRedirect() ? 'redirect=no' : '';
+ $this->mContext->getOutput()->redirect( $this->mTitle->getFullURL( $q ) );
+ }
+ } else {
+ $this->show();
+ }
+ }
+
+ /**
+ * Show the input form with optional error message
+ *
+ * @param string $err Error message or null if there's no error
+ */
+ function show( $err = null ) {
+ $out = $this->mContext->getOutput();
+ $out->setRobotPolicy( 'noindex,nofollow' );
+ $out->addBacklinkSubtitle( $this->mTitle );
+
+ if ( is_array( $err ) ) {
+ $out->wrapWikiMsg( "<p class='error'>\n$1\n</p>\n", $err );
+ } elseif ( is_string( $err ) ) {
+ $out->addHTML( "<p class='error'>{$err}</p>\n" );
+ }
+
+ if ( $this->mTitle->getRestrictionTypes() === [] ) {
+ // No restriction types available for the current title
+ // this might happen if an extension alters the available types
+ $out->setPageTitle( $this->mContext->msg(
+ 'protect-norestrictiontypes-title',
+ $this->mTitle->getPrefixedText()
+ ) );
+ $out->addWikiText( $this->mContext->msg( 'protect-norestrictiontypes-text' )->plain() );
+
+ // Show the log in case protection was possible once
+ $this->showLogExtract( $out );
+ // return as there isn't anything else we can do
+ return;
+ }
+
+ list( $cascadeSources, /* $restrictions */ ) = $this->mTitle->getCascadeProtectionSources();
+ if ( $cascadeSources && count( $cascadeSources ) > 0 ) {
+ $titles = '';
+
+ foreach ( $cascadeSources as $title ) {
+ $titles .= '* [[:' . $title->getPrefixedText() . "]]\n";
+ }
+
+ /** @todo FIXME: i18n issue, should use formatted number. */
+ $out->wrapWikiMsg(
+ "<div id=\"mw-protect-cascadeon\">\n$1\n" . $titles . "</div>",
+ [ 'protect-cascadeon', count( $cascadeSources ) ]
+ );
+ }
+
+ # Show an appropriate message if the user isn't allowed or able to change
+ # the protection settings at this time
+ if ( $this->disabled ) {
+ $out->setPageTitle(
+ $this->mContext->msg( 'protect-title-notallowed',
+ $this->mTitle->getPrefixedText() )
+ );
+ $out->addWikiText( $out->formatPermissionsErrorMessage( $this->mPermErrors, 'protect' ) );
+ } else {
+ $out->setPageTitle( $this->mContext->msg( 'protect-title', $this->mTitle->getPrefixedText() ) );
+ $out->addWikiMsg( 'protect-text',
+ wfEscapeWikiText( $this->mTitle->getPrefixedText() ) );
+ }
+
+ $out->addHTML( $this->buildForm() );
+ $this->showLogExtract( $out );
+ }
+
+ /**
+ * Save submitted protection form
+ *
+ * @return bool Success
+ */
+ function save() {
+ # Permission check!
+ if ( $this->disabled ) {
+ $this->show();
+ return false;
+ }
+
+ $request = $this->mContext->getRequest();
+ $user = $this->mContext->getUser();
+ $out = $this->mContext->getOutput();
+ $token = $request->getVal( 'wpEditToken' );
+ if ( !$user->matchEditToken( $token, [ 'protect', $this->mTitle->getPrefixedDBkey() ] ) ) {
+ $this->show( [ 'sessionfailure' ] );
+ return false;
+ }
+
+ # Create reason string. Use list and/or custom string.
+ $reasonstr = $this->mReasonSelection;
+ if ( $reasonstr != 'other' && $this->mReason != '' ) {
+ // Entry from drop down menu + additional comment
+ $reasonstr .= $this->mContext->msg( 'colon-separator' )->text() . $this->mReason;
+ } elseif ( $reasonstr == 'other' ) {
+ $reasonstr = $this->mReason;
+ }
+ $expiry = [];
+ foreach ( $this->mApplicableTypes as $action ) {
+ $expiry[$action] = $this->getExpiry( $action );
+ if ( empty( $this->mRestrictions[$action] ) ) {
+ continue; // unprotected
+ }
+ if ( !$expiry[$action] ) {
+ $this->show( [ 'protect_expiry_invalid' ] );
+ return false;
+ }
+ if ( $expiry[$action] < wfTimestampNow() ) {
+ $this->show( [ 'protect_expiry_old' ] );
+ return false;
+ }
+ }
+
+ $this->mCascade = $request->getBool( 'mwProtect-cascade' );
+
+ $status = $this->mArticle->doUpdateRestrictions(
+ $this->mRestrictions,
+ $expiry,
+ $this->mCascade,
+ $reasonstr,
+ $user
+ );
+
+ if ( !$status->isOK() ) {
+ $this->show( $out->parseInline( $status->getWikiText() ) );
+ return false;
+ }
+
+ /**
+ * Give extensions a change to handle added form items
+ *
+ * @since 1.19 you can (and you should) return false to abort saving;
+ * you can also return an array of message name and its parameters
+ */
+ $errorMsg = '';
+ if ( !Hooks::run( 'ProtectionForm::save', [ $this->mArticle, &$errorMsg, $reasonstr ] ) ) {
+ if ( $errorMsg == '' ) {
+ $errorMsg = [ 'hookaborted' ];
+ }
+ }
+ if ( $errorMsg != '' ) {
+ $this->show( $errorMsg );
+ return false;
+ }
+
+ WatchAction::doWatchOrUnwatch( $request->getCheck( 'mwProtectWatch' ), $this->mTitle, $user );
+
+ return true;
+ }
+
+ /**
+ * Build the input form
+ *
+ * @return string HTML form
+ */
+ function buildForm() {
+ $context = $this->mContext;
+ $user = $context->getUser();
+ $output = $context->getOutput();
+ $lang = $context->getLanguage();
+ $cascadingRestrictionLevels = $context->getConfig()->get( 'CascadingRestrictionLevels' );
+ $out = '';
+ if ( !$this->disabled ) {
+ $output->addModules( 'mediawiki.legacy.protect' );
+ $output->addJsConfigVars( 'wgCascadeableLevels', $cascadingRestrictionLevels );
+ $out .= Xml::openElement( 'form', [ 'method' => 'post',
+ 'action' => $this->mTitle->getLocalURL( 'action=protect' ),
+ 'id' => 'mw-Protect-Form' ] );
+ }
+
+ $out .= Xml::openElement( 'fieldset' ) .
+ Xml::element( 'legend', null, $context->msg( 'protect-legend' )->text() ) .
+ Xml::openElement( 'table', [ 'id' => 'mwProtectSet' ] ) .
+ Xml::openElement( 'tbody' );
+
+ $scExpiryOptions = wfMessage( 'protect-expiry-options' )->inContentLanguage()->text();
+ $showProtectOptions = $scExpiryOptions !== '-' && !$this->disabled;
+
+ // Not all languages have V_x <-> N_x relation
+ foreach ( $this->mRestrictions as $action => $selected ) {
+ // Messages:
+ // restriction-edit, restriction-move, restriction-create, restriction-upload
+ $msg = $context->msg( 'restriction-' . $action );
+ $out .= "<tr><td>" .
+ Xml::openElement( 'fieldset' ) .
+ Xml::element( 'legend', null, $msg->exists() ? $msg->text() : $action ) .
+ Xml::openElement( 'table', [ 'id' => "mw-protect-table-$action" ] ) .
+ "<tr><td>" . $this->buildSelector( $action, $selected ) . "</td></tr><tr><td>";
+
+ $mProtectexpiry = Xml::label(
+ $context->msg( 'protectexpiry' )->text(),
+ "mwProtectExpirySelection-$action"
+ );
+ $mProtectother = Xml::label(
+ $context->msg( 'protect-othertime' )->text(),
+ "mwProtect-$action-expires"
+ );
+
+ $expiryFormOptions = new XmlSelect(
+ "wpProtectExpirySelection-$action",
+ "mwProtectExpirySelection-$action",
+ $this->mExpirySelection[$action]
+ );
+ $expiryFormOptions->setAttribute( 'tabindex', '2' );
+ if ( $this->disabled ) {
+ $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
+ }
+
+ if ( $this->mExistingExpiry[$action] ) {
+ if ( $this->mExistingExpiry[$action] == 'infinity' ) {
+ $existingExpiryMessage = $context->msg( 'protect-existing-expiry-infinity' );
+ } else {
+ $timestamp = $lang->userTimeAndDate( $this->mExistingExpiry[$action], $user );
+ $d = $lang->userDate( $this->mExistingExpiry[$action], $user );
+ $t = $lang->userTime( $this->mExistingExpiry[$action], $user );
+ $existingExpiryMessage = $context->msg(
+ 'protect-existing-expiry',
+ $timestamp,
+ $d,
+ $t
+ );
+ }
+ $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
+ }
+
+ $expiryFormOptions->addOption(
+ $context->msg( 'protect-othertime-op' )->text(),
+ 'othertime'
+ );
+ foreach ( explode( ',', $scExpiryOptions ) as $option ) {
+ if ( strpos( $option, ":" ) === false ) {
+ $show = $value = $option;
+ } else {
+ list( $show, $value ) = explode( ":", $option );
+ }
+ $expiryFormOptions->addOption( $show, htmlspecialchars( $value ) );
+ }
+ # Add expiry dropdown
+ if ( $showProtectOptions && !$this->disabled ) {
+ $out .= "
+ <table><tr>
+ <td class='mw-label'>
+ {$mProtectexpiry}
+ </td>
+ <td class='mw-input'>" .
+ $expiryFormOptions->getHTML() .
+ "</td>
+ </tr></table>";
+ }
+ # Add custom expiry field
+ $attribs = [ 'id' => "mwProtect-$action-expires" ] + $this->disabledAttrib;
+ $out .= "<table><tr>
+ <td class='mw-label'>" .
+ $mProtectother .
+ '</td>
+ <td class="mw-input">' .
+ Xml::input( "mwProtect-expiry-$action", 50, $this->mExpiry[$action], $attribs ) .
+ '</td>
+ </tr></table>';
+ $out .= "</td></tr>" .
+ Xml::closeElement( 'table' ) .
+ Xml::closeElement( 'fieldset' ) .
+ "</td></tr>";
+ }
+ # Give extensions a chance to add items to the form
+ Hooks::run( 'ProtectionForm::buildForm', [ $this->mArticle, &$out ] );
+
+ $out .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );
+
+ // JavaScript will add another row with a value-chaining checkbox
+ if ( $this->mTitle->exists() ) {
+ $out .= Xml::openElement( 'table', [ 'id' => 'mw-protect-table2' ] ) .
+ Xml::openElement( 'tbody' );
+ $out .= '<tr>
+ <td></td>
+ <td class="mw-input">' .
+ Xml::checkLabel(
+ $context->msg( 'protect-cascade' )->text(),
+ 'mwProtect-cascade',
+ 'mwProtect-cascade',
+ $this->mCascade, $this->disabledAttrib
+ ) .
+ "</td>
+ </tr>\n";
+ $out .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );
+ }
+
+ # Add manual and custom reason field/selects as well as submit
+ if ( !$this->disabled ) {
+ $mProtectreasonother = Xml::label(
+ $context->msg( 'protectcomment' )->text(),
+ 'wpProtectReasonSelection'
+ );
+
+ $mProtectreason = Xml::label(
+ $context->msg( 'protect-otherreason' )->text(),
+ 'mwProtect-reason'
+ );
+
+ $reasonDropDown = Xml::listDropDown( 'wpProtectReasonSelection',
+ wfMessage( 'protect-dropdown' )->inContentLanguage()->text(),
+ wfMessage( 'protect-otherreason-op' )->inContentLanguage()->text(),
+ $this->mReasonSelection,
+ 'mwProtect-reason', 4 );
+
+ $out .= Xml::openElement( 'table', [ 'id' => 'mw-protect-table3' ] ) .
+ Xml::openElement( 'tbody' );
+ $out .= "
+ <tr>
+ <td class='mw-label'>
+ {$mProtectreasonother}
+ </td>
+ <td class='mw-input'>
+ {$reasonDropDown}
+ </td>
+ </tr>
+ <tr>
+ <td class='mw-label'>
+ {$mProtectreason}
+ </td>
+ <td class='mw-input'>" .
+ Xml::input( 'mwProtect-reason', 60, $this->mReason, [ 'type' => 'text',
+ 'id' => 'mwProtect-reason', 'maxlength' => 180 ] ) .
+ // Limited maxlength as the database trims at 255 bytes and other texts
+ // chosen by dropdown menus on this page are also included in this database field.
+ // The byte limit of 180 bytes is enforced in javascript
+ "</td>
+ </tr>";
+ # Disallow watching is user is not logged in
+ if ( $user->isLoggedIn() ) {
+ $out .= "
+ <tr>
+ <td></td>
+ <td class='mw-input'>" .
+ Xml::checkLabel( $context->msg( 'watchthis' )->text(),
+ 'mwProtectWatch', 'mwProtectWatch',
+ $user->isWatched( $this->mTitle ) || $user->getOption( 'watchdefault' ) ) .
+ "</td>
+ </tr>";
+ }
+ $out .= "
+ <tr>
+ <td></td>
+ <td class='mw-submit'>" .
+ Xml::submitButton(
+ $context->msg( 'confirm' )->text(),
+ [ 'id' => 'mw-Protect-submit' ]
+ ) .
+ "</td>
+ </tr>\n";
+ $out .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );
+ }
+ $out .= Xml::closeElement( 'fieldset' );
+
+ if ( $user->isAllowed( 'editinterface' ) ) {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ $link = $linkRenderer->makeKnownLink(
+ $context->msg( 'protect-dropdown' )->inContentLanguage()->getTitle(),
+ $context->msg( 'protect-edit-reasonlist' )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ $out .= '<p class="mw-protect-editreasons">' . $link . '</p>';
+ }
+
+ if ( !$this->disabled ) {
+ $out .= Html::hidden(
+ 'wpEditToken',
+ $user->getEditToken( [ 'protect', $this->mTitle->getPrefixedDBkey() ] )
+ );
+ $out .= Xml::closeElement( 'form' );
+ }
+
+ return $out;
+ }
+
+ /**
+ * Build protection level selector
+ *
+ * @param string $action Action to protect
+ * @param string $selected Current protection level
+ * @return string HTML fragment
+ */
+ function buildSelector( $action, $selected ) {
+ // If the form is disabled, display all relevant levels. Otherwise,
+ // just show the ones this user can use.
+ $levels = MWNamespace::getRestrictionLevels( $this->mTitle->getNamespace(),
+ $this->disabled ? null : $this->mContext->getUser()
+ );
+
+ $id = 'mwProtect-level-' . $action;
+
+ $select = new XmlSelect( $id, $id, $selected );
+ $select->setAttribute( 'size', count( $levels ) );
+ if ( $this->disabled ) {
+ $select->setAttribute( 'disabled', 'disabled' );
+ }
+
+ foreach ( $levels as $key ) {
+ $select->addOption( $this->getOptionLabel( $key ), $key );
+ }
+
+ return $select->getHTML();
+ }
+
+ /**
+ * Prepare the label for a protection selector option
+ *
+ * @param string $permission Permission required
+ * @return string
+ */
+ private function getOptionLabel( $permission ) {
+ if ( $permission == '' ) {
+ return $this->mContext->msg( 'protect-default' )->text();
+ } else {
+ // Messages: protect-level-autoconfirmed, protect-level-sysop
+ $msg = $this->mContext->msg( "protect-level-{$permission}" );
+ if ( $msg->exists() ) {
+ return $msg->text();
+ }
+ return $this->mContext->msg( 'protect-fallback', $permission )->text();
+ }
+ }
+
+ /**
+ * Show protection long extracts for this page
+ *
+ * @param OutputPage &$out
+ * @access private
+ */
+ function showLogExtract( &$out ) {
+ # Show relevant lines from the protection log:
+ $protectLogPage = new LogPage( 'protect' );
+ $out->addHTML( Xml::element( 'h2', null, $protectLogPage->getName()->text() ) );
+ LogEventsList::showLogExtract( $out, 'protect', $this->mTitle );
+ # Let extensions add other relevant log extracts
+ Hooks::run( 'ProtectionForm::showLogExtract', [ $this->mArticle, $out ] );
+ }
+}
diff --git a/www/wiki/includes/ProxyLookup.php b/www/wiki/includes/ProxyLookup.php
new file mode 100644
index 00000000..3a3243a5
--- /dev/null
+++ b/www/wiki/includes/ProxyLookup.php
@@ -0,0 +1,85 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use IPSet\IPSet;
+
+/**
+ * @since 1.28
+ */
+class ProxyLookup {
+
+ /**
+ * @var string[]
+ */
+ private $proxyServers;
+
+ /**
+ * @var string[]
+ */
+ private $proxyServersComplex;
+
+ /**
+ * @var IPSet|null
+ */
+ private $proxyIPSet;
+
+ /**
+ * @param string[] $proxyServers Simple list of IPs
+ * @param string[] $proxyServersComplex Complex list of IPs/ranges
+ */
+ public function __construct( $proxyServers, $proxyServersComplex ) {
+ $this->proxyServers = $proxyServers;
+ $this->proxyServersComplex = $proxyServersComplex;
+ }
+
+ /**
+ * Checks if an IP matches a proxy we've configured
+ *
+ * @param string $ip
+ * @return bool
+ */
+ public function isConfiguredProxy( $ip ) {
+ // Quick check of known singular proxy servers
+ if ( in_array( $ip, $this->proxyServers ) ) {
+ return true;
+ }
+
+ // Check against addresses and CIDR nets in the complex list
+ if ( !$this->proxyIPSet ) {
+ $this->proxyIPSet = new IPSet( $this->proxyServersComplex );
+ }
+ return $this->proxyIPSet->match( $ip );
+ }
+
+ /**
+ * Checks if an IP is a trusted proxy provider.
+ * Useful to tell if X-Forwarded-For data is possibly bogus.
+ * CDN cache servers for the site are whitelisted.
+ *
+ * @param string $ip
+ * @return bool
+ */
+ public function isTrustedProxy( $ip ) {
+ $trusted = $this->isConfiguredProxy( $ip );
+ Hooks::run( 'IsTrustedProxy', [ &$ip, &$trusted ] );
+ return $trusted;
+ }
+}
diff --git a/www/wiki/includes/RawMessage.php b/www/wiki/includes/RawMessage.php
new file mode 100644
index 00000000..9a0d947d
--- /dev/null
+++ b/www/wiki/includes/RawMessage.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Variant of the Message class.
+ *
+ * Rather than treating the message key as a lookup
+ * value (which is passed to the MessageCache and
+ * translated as necessary), a RawMessage key is
+ * treated as the actual message.
+ *
+ * All other functionality (parsing, escaping, etc.)
+ * is preserved.
+ *
+ * @since 1.21
+ */
+class RawMessage extends Message {
+
+ /**
+ * Call the parent constructor, then store the key as
+ * the message.
+ *
+ * @see Message::__construct
+ *
+ * @param string $text Message to use.
+ * @param array $params Parameters for the message.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $text, $params = [] ) {
+ if ( !is_string( $text ) ) {
+ throw new InvalidArgumentException( '$text must be a string' );
+ }
+
+ parent::__construct( $text, $params );
+
+ // The key is the message.
+ $this->message = $text;
+ }
+
+ /**
+ * Fetch the message (in this case, the key).
+ *
+ * @return string
+ */
+ public function fetchMessage() {
+ // Just in case the message is unset somewhere.
+ if ( $this->message === null ) {
+ $this->message = $this->key;
+ }
+
+ return $this->message;
+ }
+
+}
diff --git a/www/wiki/includes/ReadOnlyMode.php b/www/wiki/includes/ReadOnlyMode.php
new file mode 100644
index 00000000..547c2d5e
--- /dev/null
+++ b/www/wiki/includes/ReadOnlyMode.php
@@ -0,0 +1,68 @@
+<?php
+
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * A service class for fetching the wiki's current read-only mode.
+ * To obtain an instance, use MediaWikiServices::getReadOnlyMode().
+ *
+ * @since 1.29
+ */
+class ReadOnlyMode {
+ /** @var ConfiguredReadOnlyMode */
+ private $configuredReadOnly;
+
+ /** @var LoadBalancer */
+ private $loadBalancer;
+
+ public function __construct( ConfiguredReadOnlyMode $cro, LoadBalancer $loadBalancer ) {
+ $this->configuredReadOnly = $cro;
+ $this->loadBalancer = $loadBalancer;
+ }
+
+ /**
+ * Check whether the wiki is in read-only mode.
+ *
+ * @return bool
+ */
+ public function isReadOnly() {
+ return $this->getReason() !== false;
+ }
+
+ /**
+ * Check if the site is in read-only mode and return the message if so
+ *
+ * This checks the configuration and registered DB load balancers for
+ * read-only mode. This may result in DB connection being made.
+ *
+ * @return string|bool String when in read-only mode; false otherwise
+ */
+ public function getReason() {
+ $reason = $this->configuredReadOnly->getReason();
+ if ( $reason !== false ) {
+ return $reason;
+ }
+ $reason = $this->loadBalancer->getReadOnlyReason();
+ if ( $reason !== false && $reason !== null ) {
+ return $reason;
+ }
+ return false;
+ }
+
+ /**
+ * Set the read-only mode, which will apply for the remainder of the
+ * request or until a service reset.
+ *
+ * @param string|null $msg
+ */
+ public function setReason( $msg ) {
+ $this->configuredReadOnly->setReason( $msg );
+ }
+
+ /**
+ * Clear the cache of the read only file
+ */
+ public function clearCache() {
+ $this->configuredReadOnly->clearCache();
+ }
+}
diff --git a/www/wiki/includes/Revision.php b/www/wiki/includes/Revision.php
new file mode 100644
index 00000000..bcfbe638
--- /dev/null
+++ b/www/wiki/includes/Revision.php
@@ -0,0 +1,1982 @@
+<?php
+/**
+ * Representation of a page version.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+/**
+ * @todo document
+ */
+class Revision implements IDBAccessObject {
+ /** @var int|null */
+ protected $mId;
+ /** @var int|null */
+ protected $mPage;
+ /** @var string */
+ protected $mUserText;
+ /** @var string */
+ protected $mOrigUserText;
+ /** @var int */
+ protected $mUser;
+ /** @var bool */
+ protected $mMinorEdit;
+ /** @var string */
+ protected $mTimestamp;
+ /** @var int */
+ protected $mDeleted;
+ /** @var int */
+ protected $mSize;
+ /** @var string */
+ protected $mSha1;
+ /** @var int */
+ protected $mParentId;
+ /** @var string */
+ protected $mComment;
+ /** @var string */
+ protected $mText;
+ /** @var int */
+ protected $mTextId;
+ /** @var int */
+ protected $mUnpatrolled;
+
+ /** @var stdClass|null */
+ protected $mTextRow;
+
+ /** @var null|Title */
+ protected $mTitle;
+ /** @var bool */
+ protected $mCurrent;
+ /** @var string */
+ protected $mContentModel;
+ /** @var string */
+ protected $mContentFormat;
+
+ /** @var Content|null|bool */
+ protected $mContent;
+ /** @var null|ContentHandler */
+ protected $mContentHandler;
+
+ /** @var int */
+ protected $mQueryFlags = 0;
+ /** @var bool Used for cached values to reload user text and rev_deleted */
+ protected $mRefreshMutableFields = false;
+ /** @var string Wiki ID; false means the current wiki */
+ protected $mWiki = false;
+
+ // Revision deletion constants
+ const DELETED_TEXT = 1;
+ const DELETED_COMMENT = 2;
+ const DELETED_USER = 4;
+ const DELETED_RESTRICTED = 8;
+ const SUPPRESSED_USER = 12; // convenience
+ const SUPPRESSED_ALL = 15; // convenience
+
+ // Audience options for accessors
+ const FOR_PUBLIC = 1;
+ const FOR_THIS_USER = 2;
+ const RAW = 3;
+
+ const TEXT_CACHE_GROUP = 'revisiontext:10'; // process cache name and max key count
+
+ /**
+ * Load a page revision from a given revision ID number.
+ * Returns null if no such revision can be found.
+ *
+ * $flags include:
+ * Revision::READ_LATEST : Select the data from the master
+ * Revision::READ_LOCKING : Select & lock the data from the master
+ *
+ * @param int $id
+ * @param int $flags (optional)
+ * @return Revision|null
+ */
+ public static function newFromId( $id, $flags = 0 ) {
+ return self::newFromConds( [ 'rev_id' => intval( $id ) ], $flags );
+ }
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given link target. If not attached
+ * to that link target, will return null.
+ *
+ * $flags include:
+ * Revision::READ_LATEST : Select the data from the master
+ * Revision::READ_LOCKING : Select & lock the data from the master
+ *
+ * @param LinkTarget $linkTarget
+ * @param int $id (optional)
+ * @param int $flags Bitfield (optional)
+ * @return Revision|null
+ */
+ public static function newFromTitle( LinkTarget $linkTarget, $id = 0, $flags = 0 ) {
+ $conds = [
+ 'page_namespace' => $linkTarget->getNamespace(),
+ 'page_title' => $linkTarget->getDBkey()
+ ];
+ if ( $id ) {
+ // Use the specified ID
+ $conds['rev_id'] = $id;
+ return self::newFromConds( $conds, $flags );
+ } else {
+ // Use a join to get the latest revision
+ $conds[] = 'rev_id=page_latest';
+ $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
+ return self::loadFromConds( $db, $conds, $flags );
+ }
+ }
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given page ID.
+ * Returns null if no such revision can be found.
+ *
+ * $flags include:
+ * Revision::READ_LATEST : Select the data from the master (since 1.20)
+ * Revision::READ_LOCKING : Select & lock the data from the master
+ *
+ * @param int $pageId
+ * @param int $revId (optional)
+ * @param int $flags Bitfield (optional)
+ * @return Revision|null
+ */
+ public static function newFromPageId( $pageId, $revId = 0, $flags = 0 ) {
+ $conds = [ 'page_id' => $pageId ];
+ if ( $revId ) {
+ $conds['rev_id'] = $revId;
+ return self::newFromConds( $conds, $flags );
+ } else {
+ // Use a join to get the latest revision
+ $conds[] = 'rev_id = page_latest';
+ $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
+ return self::loadFromConds( $db, $conds, $flags );
+ }
+ }
+
+ /**
+ * Make a fake revision object from an archive table row. This is queried
+ * for permissions or even inserted (as in Special:Undelete)
+ * @todo FIXME: Should be a subclass for RevisionDelete. [TS]
+ *
+ * @param object $row
+ * @param array $overrides
+ *
+ * @throws MWException
+ * @return Revision
+ */
+ public static function newFromArchiveRow( $row, $overrides = [] ) {
+ global $wgContentHandlerUseDB;
+
+ $attribs = $overrides + [
+ 'page' => isset( $row->ar_page_id ) ? $row->ar_page_id : null,
+ 'id' => isset( $row->ar_rev_id ) ? $row->ar_rev_id : null,
+ 'comment' => CommentStore::newKey( 'ar_comment' )
+ // Legacy because $row probably came from self::selectArchiveFields()
+ ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row, true )->text,
+ 'user' => $row->ar_user,
+ 'user_text' => $row->ar_user_text,
+ 'timestamp' => $row->ar_timestamp,
+ 'minor_edit' => $row->ar_minor_edit,
+ 'text_id' => isset( $row->ar_text_id ) ? $row->ar_text_id : null,
+ 'deleted' => $row->ar_deleted,
+ 'len' => $row->ar_len,
+ 'sha1' => isset( $row->ar_sha1 ) ? $row->ar_sha1 : null,
+ 'content_model' => isset( $row->ar_content_model ) ? $row->ar_content_model : null,
+ 'content_format' => isset( $row->ar_content_format ) ? $row->ar_content_format : null,
+ ];
+
+ if ( !$wgContentHandlerUseDB ) {
+ unset( $attribs['content_model'] );
+ unset( $attribs['content_format'] );
+ }
+
+ if ( !isset( $attribs['title'] )
+ && isset( $row->ar_namespace )
+ && isset( $row->ar_title )
+ ) {
+ $attribs['title'] = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+ }
+
+ if ( isset( $row->ar_text ) && !$row->ar_text_id ) {
+ // Pre-1.5 ar_text row
+ $attribs['text'] = self::getRevisionText( $row, 'ar_' );
+ if ( $attribs['text'] === false ) {
+ throw new MWException( 'Unable to load text from archive row (possibly T24624)' );
+ }
+ }
+ return new self( $attribs );
+ }
+
+ /**
+ * @since 1.19
+ *
+ * @param object $row
+ * @return Revision
+ */
+ public static function newFromRow( $row ) {
+ return new self( $row );
+ }
+
+ /**
+ * Load a page revision from a given revision ID number.
+ * Returns null if no such revision can be found.
+ *
+ * @param IDatabase $db
+ * @param int $id
+ * @return Revision|null
+ */
+ public static function loadFromId( $db, $id ) {
+ return self::loadFromConds( $db, [ 'rev_id' => intval( $id ) ] );
+ }
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given page. If not attached
+ * to that page, will return null.
+ *
+ * @param IDatabase $db
+ * @param int $pageid
+ * @param int $id
+ * @return Revision|null
+ */
+ public static function loadFromPageId( $db, $pageid, $id = 0 ) {
+ $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ];
+ if ( $id ) {
+ $conds['rev_id'] = intval( $id );
+ } else {
+ $conds[] = 'rev_id=page_latest';
+ }
+ return self::loadFromConds( $db, $conds );
+ }
+
+ /**
+ * Load either the current, or a specified, revision
+ * that's attached to a given page. If not attached
+ * to that page, will return null.
+ *
+ * @param IDatabase $db
+ * @param Title $title
+ * @param int $id
+ * @return Revision|null
+ */
+ public static function loadFromTitle( $db, $title, $id = 0 ) {
+ if ( $id ) {
+ $matchId = intval( $id );
+ } else {
+ $matchId = 'page_latest';
+ }
+ return self::loadFromConds( $db,
+ [
+ "rev_id=$matchId",
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ]
+ );
+ }
+
+ /**
+ * Load the revision for the given title with the given timestamp.
+ * WARNING: Timestamps may in some circumstances not be unique,
+ * so this isn't the best key to use.
+ *
+ * @param IDatabase $db
+ * @param Title $title
+ * @param string $timestamp
+ * @return Revision|null
+ */
+ public static function loadFromTimestamp( $db, $title, $timestamp ) {
+ return self::loadFromConds( $db,
+ [
+ 'rev_timestamp' => $db->timestamp( $timestamp ),
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ]
+ );
+ }
+
+ /**
+ * Given a set of conditions, fetch a revision
+ *
+ * This method is used then a revision ID is qualified and
+ * will incorporate some basic replica DB/master fallback logic
+ *
+ * @param array $conditions
+ * @param int $flags (optional)
+ * @return Revision|null
+ */
+ private static function newFromConds( $conditions, $flags = 0 ) {
+ $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
+
+ $rev = self::loadFromConds( $db, $conditions, $flags );
+ // Make sure new pending/committed revision are visibile later on
+ // within web requests to certain avoid bugs like T93866 and T94407.
+ if ( !$rev
+ && !( $flags & self::READ_LATEST )
+ && wfGetLB()->getServerCount() > 1
+ && wfGetLB()->hasOrMadeRecentMasterChanges()
+ ) {
+ $flags = self::READ_LATEST;
+ $db = wfGetDB( DB_MASTER );
+ $rev = self::loadFromConds( $db, $conditions, $flags );
+ }
+
+ if ( $rev ) {
+ $rev->mQueryFlags = $flags;
+ }
+
+ return $rev;
+ }
+
+ /**
+ * Given a set of conditions, fetch a revision from
+ * the given database connection.
+ *
+ * @param IDatabase $db
+ * @param array $conditions
+ * @param int $flags (optional)
+ * @return Revision|null
+ */
+ private static function loadFromConds( $db, $conditions, $flags = 0 ) {
+ $row = self::fetchFromConds( $db, $conditions, $flags );
+ if ( $row ) {
+ $rev = new Revision( $row );
+ $rev->mWiki = $db->getDomainID();
+
+ return $rev;
+ }
+
+ return null;
+ }
+
+ /**
+ * Return a wrapper for a series of database rows to
+ * fetch all of a given page's revisions in turn.
+ * Each row can be fed to the constructor to get objects.
+ *
+ * @param LinkTarget $title
+ * @return ResultWrapper
+ * @deprecated Since 1.28
+ */
+ public static function fetchRevision( LinkTarget $title ) {
+ $row = self::fetchFromConds(
+ wfGetDB( DB_REPLICA ),
+ [
+ 'rev_id=page_latest',
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ]
+ );
+
+ return new FakeResultWrapper( $row ? [ $row ] : [] );
+ }
+
+ /**
+ * Given a set of conditions, return a ResultWrapper
+ * which will return matching database rows with the
+ * fields necessary to build Revision objects.
+ *
+ * @param IDatabase $db
+ * @param array $conditions
+ * @param int $flags (optional)
+ * @return stdClass
+ */
+ private static function fetchFromConds( $db, $conditions, $flags = 0 ) {
+ $fields = array_merge(
+ self::selectFields(),
+ self::selectPageFields(),
+ self::selectUserFields()
+ );
+ $options = [];
+ if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
+ $options[] = 'FOR UPDATE';
+ }
+ return $db->selectRow(
+ [ 'revision', 'page', 'user' ],
+ $fields,
+ $conditions,
+ __METHOD__,
+ $options,
+ [ 'page' => self::pageJoinCond(), 'user' => self::userJoinCond() ]
+ );
+ }
+
+ /**
+ * Return the value of a select() JOIN conds array for the user table.
+ * This will get user table rows for logged-in users.
+ * @since 1.19
+ * @return array
+ */
+ public static function userJoinCond() {
+ return [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ];
+ }
+
+ /**
+ * Return the value of a select() page conds array for the page table.
+ * This will assure that the revision(s) are not orphaned from live pages.
+ * @since 1.19
+ * @return array
+ */
+ public static function pageJoinCond() {
+ return [ 'INNER JOIN', [ 'page_id = rev_page' ] ];
+ }
+
+ /**
+ * Return the list of revision fields that should be selected to create
+ * a new revision.
+ * @todo Deprecate this in favor of a method that returns tables and joins
+ * as well, and use CommentStore::getJoin().
+ * @return array
+ */
+ public static function selectFields() {
+ global $wgContentHandlerUseDB;
+
+ $fields = [
+ 'rev_id',
+ 'rev_page',
+ 'rev_text_id',
+ 'rev_timestamp',
+ 'rev_user_text',
+ 'rev_user',
+ 'rev_minor_edit',
+ 'rev_deleted',
+ 'rev_len',
+ 'rev_parent_id',
+ 'rev_sha1',
+ ];
+
+ $fields += CommentStore::newKey( 'rev_comment' )->getFields();
+
+ if ( $wgContentHandlerUseDB ) {
+ $fields[] = 'rev_content_format';
+ $fields[] = 'rev_content_model';
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Return the list of revision fields that should be selected to create
+ * a new revision from an archive row.
+ * @todo Deprecate this in favor of a method that returns tables and joins
+ * as well, and use CommentStore::getJoin().
+ * @return array
+ */
+ public static function selectArchiveFields() {
+ global $wgContentHandlerUseDB;
+ $fields = [
+ 'ar_id',
+ 'ar_page_id',
+ 'ar_rev_id',
+ 'ar_text',
+ 'ar_text_id',
+ 'ar_timestamp',
+ 'ar_user_text',
+ 'ar_user',
+ 'ar_minor_edit',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_parent_id',
+ 'ar_sha1',
+ ];
+
+ $fields += CommentStore::newKey( 'ar_comment' )->getFields();
+
+ if ( $wgContentHandlerUseDB ) {
+ $fields[] = 'ar_content_format';
+ $fields[] = 'ar_content_model';
+ }
+ return $fields;
+ }
+
+ /**
+ * Return the list of text fields that should be selected to read the
+ * revision text
+ * @return array
+ */
+ public static function selectTextFields() {
+ return [
+ 'old_text',
+ 'old_flags'
+ ];
+ }
+
+ /**
+ * Return the list of page fields that should be selected from page table
+ * @return array
+ */
+ public static function selectPageFields() {
+ return [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ ];
+ }
+
+ /**
+ * Return the list of user fields that should be selected from user table
+ * @return array
+ */
+ public static function selectUserFields() {
+ return [ 'user_name' ];
+ }
+
+ /**
+ * Do a batched query to get the parent revision lengths
+ * @param IDatabase $db
+ * @param array $revIds
+ * @return array
+ */
+ public static function getParentLengths( $db, array $revIds ) {
+ $revLens = [];
+ if ( !$revIds ) {
+ return $revLens; // empty
+ }
+ $res = $db->select( 'revision',
+ [ 'rev_id', 'rev_len' ],
+ [ 'rev_id' => $revIds ],
+ __METHOD__ );
+ foreach ( $res as $row ) {
+ $revLens[$row->rev_id] = $row->rev_len;
+ }
+ return $revLens;
+ }
+
+ /**
+ * @param object|array $row Either a database row or an array
+ * @throws MWException
+ * @access private
+ */
+ function __construct( $row ) {
+ if ( is_object( $row ) ) {
+ $this->mId = intval( $row->rev_id );
+ $this->mPage = intval( $row->rev_page );
+ $this->mTextId = intval( $row->rev_text_id );
+ $this->mComment = CommentStore::newKey( 'rev_comment' )
+ // Legacy because $row probably came from self::selectFields()
+ ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row, true )->text;
+ $this->mUser = intval( $row->rev_user );
+ $this->mMinorEdit = intval( $row->rev_minor_edit );
+ $this->mTimestamp = $row->rev_timestamp;
+ $this->mDeleted = intval( $row->rev_deleted );
+
+ if ( !isset( $row->rev_parent_id ) ) {
+ $this->mParentId = null;
+ } else {
+ $this->mParentId = intval( $row->rev_parent_id );
+ }
+
+ if ( !isset( $row->rev_len ) ) {
+ $this->mSize = null;
+ } else {
+ $this->mSize = intval( $row->rev_len );
+ }
+
+ if ( !isset( $row->rev_sha1 ) ) {
+ $this->mSha1 = null;
+ } else {
+ $this->mSha1 = $row->rev_sha1;
+ }
+
+ if ( isset( $row->page_latest ) ) {
+ $this->mCurrent = ( $row->rev_id == $row->page_latest );
+ $this->mTitle = Title::newFromRow( $row );
+ } else {
+ $this->mCurrent = false;
+ $this->mTitle = null;
+ }
+
+ if ( !isset( $row->rev_content_model ) ) {
+ $this->mContentModel = null; # determine on demand if needed
+ } else {
+ $this->mContentModel = strval( $row->rev_content_model );
+ }
+
+ if ( !isset( $row->rev_content_format ) ) {
+ $this->mContentFormat = null; # determine on demand if needed
+ } else {
+ $this->mContentFormat = strval( $row->rev_content_format );
+ }
+
+ // Lazy extraction...
+ $this->mText = null;
+ if ( isset( $row->old_text ) ) {
+ $this->mTextRow = $row;
+ } else {
+ // 'text' table row entry will be lazy-loaded
+ $this->mTextRow = null;
+ }
+
+ // Use user_name for users and rev_user_text for IPs...
+ $this->mUserText = null; // lazy load if left null
+ if ( $this->mUser == 0 ) {
+ $this->mUserText = $row->rev_user_text; // IP user
+ } elseif ( isset( $row->user_name ) ) {
+ $this->mUserText = $row->user_name; // logged-in user
+ }
+ $this->mOrigUserText = $row->rev_user_text;
+ } elseif ( is_array( $row ) ) {
+ // Build a new revision to be saved...
+ global $wgUser; // ugh
+
+ # if we have a content object, use it to set the model and type
+ if ( !empty( $row['content'] ) ) {
+ // @todo when is that set? test with external store setup! check out insertOn() [dk]
+ if ( !empty( $row['text_id'] ) ) {
+ throw new MWException( "Text already stored in external store (id {$row['text_id']}), " .
+ "can't serialize content object" );
+ }
+
+ $row['content_model'] = $row['content']->getModel();
+ # note: mContentFormat is initializes later accordingly
+ # note: content is serialized later in this method!
+ # also set text to null?
+ }
+
+ $this->mId = isset( $row['id'] ) ? intval( $row['id'] ) : null;
+ $this->mPage = isset( $row['page'] ) ? intval( $row['page'] ) : null;
+ $this->mTextId = isset( $row['text_id'] ) ? intval( $row['text_id'] ) : null;
+ $this->mUserText = isset( $row['user_text'] )
+ ? strval( $row['user_text'] ) : $wgUser->getName();
+ $this->mUser = isset( $row['user'] ) ? intval( $row['user'] ) : $wgUser->getId();
+ $this->mMinorEdit = isset( $row['minor_edit'] ) ? intval( $row['minor_edit'] ) : 0;
+ $this->mTimestamp = isset( $row['timestamp'] )
+ ? strval( $row['timestamp'] ) : wfTimestampNow();
+ $this->mDeleted = isset( $row['deleted'] ) ? intval( $row['deleted'] ) : 0;
+ $this->mSize = isset( $row['len'] ) ? intval( $row['len'] ) : null;
+ $this->mParentId = isset( $row['parent_id'] ) ? intval( $row['parent_id'] ) : null;
+ $this->mSha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null;
+
+ $this->mContentModel = isset( $row['content_model'] )
+ ? strval( $row['content_model'] ) : null;
+ $this->mContentFormat = isset( $row['content_format'] )
+ ? strval( $row['content_format'] ) : null;
+
+ // Enforce spacing trimming on supplied text
+ $this->mComment = isset( $row['comment'] ) ? trim( strval( $row['comment'] ) ) : null;
+ $this->mText = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null;
+ $this->mTextRow = null;
+
+ $this->mTitle = isset( $row['title'] ) ? $row['title'] : null;
+
+ // if we have a Content object, override mText and mContentModel
+ if ( !empty( $row['content'] ) ) {
+ if ( !( $row['content'] instanceof Content ) ) {
+ throw new MWException( '`content` field must contain a Content object.' );
+ }
+
+ $handler = $this->getContentHandler();
+ $this->mContent = $row['content'];
+
+ $this->mContentModel = $this->mContent->getModel();
+ $this->mContentHandler = null;
+
+ $this->mText = $handler->serializeContent( $row['content'], $this->getContentFormat() );
+ } elseif ( $this->mText !== null ) {
+ $handler = $this->getContentHandler();
+ $this->mContent = $handler->unserializeContent( $this->mText );
+ }
+
+ // If we have a Title object, make sure it is consistent with mPage.
+ if ( $this->mTitle && $this->mTitle->exists() ) {
+ if ( $this->mPage === null ) {
+ // if the page ID wasn't known, set it now
+ $this->mPage = $this->mTitle->getArticleID();
+ } elseif ( $this->mTitle->getArticleID() !== $this->mPage ) {
+ // Got different page IDs. This may be legit (e.g. during undeletion),
+ // but it seems worth mentioning it in the log.
+ wfDebug( "Page ID " . $this->mPage . " mismatches the ID " .
+ $this->mTitle->getArticleID() . " provided by the Title object." );
+ }
+ }
+
+ $this->mCurrent = false;
+
+ // If we still have no length, see it we have the text to figure it out
+ if ( !$this->mSize && $this->mContent !== null ) {
+ $this->mSize = $this->mContent->getSize();
+ }
+
+ // Same for sha1
+ if ( $this->mSha1 === null ) {
+ $this->mSha1 = $this->mText === null ? null : self::base36Sha1( $this->mText );
+ }
+
+ // force lazy init
+ $this->getContentModel();
+ $this->getContentFormat();
+ } else {
+ throw new MWException( 'Revision constructor passed invalid row format.' );
+ }
+ $this->mUnpatrolled = null;
+ }
+
+ /**
+ * Get revision ID
+ *
+ * @return int|null
+ */
+ public function getId() {
+ return $this->mId;
+ }
+
+ /**
+ * Set the revision ID
+ *
+ * This should only be used for proposed revisions that turn out to be null edits
+ *
+ * @since 1.19
+ * @param int $id
+ */
+ public function setId( $id ) {
+ $this->mId = (int)$id;
+ }
+
+ /**
+ * Set the user ID/name
+ *
+ * This should only be used for proposed revisions that turn out to be null edits
+ *
+ * @since 1.28
+ * @param int $id User ID
+ * @param string $name User name
+ */
+ public function setUserIdAndName( $id, $name ) {
+ $this->mUser = (int)$id;
+ $this->mUserText = $name;
+ $this->mOrigUserText = $name;
+ }
+
+ /**
+ * Get text row ID
+ *
+ * @return int|null
+ */
+ public function getTextId() {
+ return $this->mTextId;
+ }
+
+ /**
+ * Get parent revision ID (the original previous page revision)
+ *
+ * @return int|null
+ */
+ public function getParentId() {
+ return $this->mParentId;
+ }
+
+ /**
+ * Returns the length of the text in this revision, or null if unknown.
+ *
+ * @return int|null
+ */
+ public function getSize() {
+ return $this->mSize;
+ }
+
+ /**
+ * Returns the base36 sha1 of the text in this revision, or null if unknown.
+ *
+ * @return string|null
+ */
+ public function getSha1() {
+ return $this->mSha1;
+ }
+
+ /**
+ * Returns the title of the page associated with this entry or null.
+ *
+ * Will do a query, when title is not set and id is given.
+ *
+ * @return Title|null
+ */
+ public function getTitle() {
+ if ( $this->mTitle !== null ) {
+ return $this->mTitle;
+ }
+ // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
+ if ( $this->mId !== null ) {
+ $dbr = wfGetLB( $this->mWiki )->getConnectionRef( DB_REPLICA, [], $this->mWiki );
+ $row = $dbr->selectRow(
+ [ 'page', 'revision' ],
+ self::selectPageFields(),
+ [ 'page_id=rev_page', 'rev_id' => $this->mId ],
+ __METHOD__
+ );
+ if ( $row ) {
+ // @TODO: better foreign title handling
+ $this->mTitle = Title::newFromRow( $row );
+ }
+ }
+
+ if ( $this->mWiki === false || $this->mWiki === wfWikiID() ) {
+ // Loading by ID is best, though not possible for foreign titles
+ if ( !$this->mTitle && $this->mPage !== null && $this->mPage > 0 ) {
+ $this->mTitle = Title::newFromID( $this->mPage );
+ }
+ }
+
+ return $this->mTitle;
+ }
+
+ /**
+ * Set the title of the revision
+ *
+ * @param Title $title
+ */
+ public function setTitle( $title ) {
+ $this->mTitle = $title;
+ }
+
+ /**
+ * Get the page ID
+ *
+ * @return int|null
+ */
+ public function getPage() {
+ return $this->mPage;
+ }
+
+ /**
+ * Fetch revision's user id if it's available to the specified audience.
+ * If the specified audience does not have access to it, zero will be
+ * returned.
+ *
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to the given user
+ * Revision::RAW get the ID regardless of permissions
+ * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return int
+ */
+ public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
+ if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_USER ) ) {
+ return 0;
+ } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_USER, $user ) ) {
+ return 0;
+ } else {
+ return $this->mUser;
+ }
+ }
+
+ /**
+ * Fetch revision's user id without regard for the current user's permissions
+ *
+ * @return int
+ * @deprecated since 1.25, use getUser( Revision::RAW )
+ */
+ public function getRawUser() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return $this->getUser( self::RAW );
+ }
+
+ /**
+ * Fetch revision's username if it's available to the specified audience.
+ * If the specified audience does not have access to the username, an
+ * empty string will be returned.
+ *
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to the given user
+ * Revision::RAW get the text regardless of permissions
+ * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return string
+ */
+ public function getUserText( $audience = self::FOR_PUBLIC, User $user = null ) {
+ $this->loadMutableFields();
+
+ if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_USER ) ) {
+ return '';
+ } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_USER, $user ) ) {
+ return '';
+ } else {
+ if ( $this->mUserText === null ) {
+ $this->mUserText = User::whoIs( $this->mUser ); // load on demand
+ if ( $this->mUserText === false ) {
+ # This shouldn't happen, but it can if the wiki was recovered
+ # via importing revs and there is no user table entry yet.
+ $this->mUserText = $this->mOrigUserText;
+ }
+ }
+ return $this->mUserText;
+ }
+ }
+
+ /**
+ * Fetch revision's username without regard for view restrictions
+ *
+ * @return string
+ * @deprecated since 1.25, use getUserText( Revision::RAW )
+ */
+ public function getRawUserText() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return $this->getUserText( self::RAW );
+ }
+
+ /**
+ * Fetch revision comment if it's available to the specified audience.
+ * If the specified audience does not have access to the comment, an
+ * empty string will be returned.
+ *
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to the given user
+ * Revision::RAW get the text regardless of permissions
+ * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return string
+ */
+ function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
+ if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_COMMENT ) ) {
+ return '';
+ } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_COMMENT, $user ) ) {
+ return '';
+ } else {
+ return $this->mComment;
+ }
+ }
+
+ /**
+ * Fetch revision comment without regard for the current user's permissions
+ *
+ * @return string
+ * @deprecated since 1.25, use getComment( Revision::RAW )
+ */
+ public function getRawComment() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return $this->getComment( self::RAW );
+ }
+
+ /**
+ * @return bool
+ */
+ public function isMinor() {
+ return (bool)$this->mMinorEdit;
+ }
+
+ /**
+ * @return int Rcid of the unpatrolled row, zero if there isn't one
+ */
+ public function isUnpatrolled() {
+ if ( $this->mUnpatrolled !== null ) {
+ return $this->mUnpatrolled;
+ }
+ $rc = $this->getRecentChange();
+ if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == 0 ) {
+ $this->mUnpatrolled = $rc->getAttribute( 'rc_id' );
+ } else {
+ $this->mUnpatrolled = 0;
+ }
+ return $this->mUnpatrolled;
+ }
+
+ /**
+ * Get the RC object belonging to the current revision, if there's one
+ *
+ * @param int $flags (optional) $flags include:
+ * Revision::READ_LATEST : Select the data from the master
+ *
+ * @since 1.22
+ * @return RecentChange|null
+ */
+ public function getRecentChange( $flags = 0 ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
+
+ return RecentChange::newFromConds(
+ [
+ 'rc_user_text' => $this->getUserText( self::RAW ),
+ 'rc_timestamp' => $dbr->timestamp( $this->getTimestamp() ),
+ 'rc_this_oldid' => $this->getId()
+ ],
+ __METHOD__,
+ $dbType
+ );
+ }
+
+ /**
+ * @param int $field One of DELETED_* bitfield constants
+ *
+ * @return bool
+ */
+ public function isDeleted( $field ) {
+ if ( $this->isCurrent() && $field === self::DELETED_TEXT ) {
+ // Current revisions of pages cannot have the content hidden. Skipping this
+ // check is very useful for Parser as it fetches templates using newKnownCurrent().
+ // Calling getVisibility() in that case triggers a verification database query.
+ return false; // no need to check
+ }
+
+ return ( $this->getVisibility() & $field ) == $field;
+ }
+
+ /**
+ * Get the deletion bitfield of the revision
+ *
+ * @return int
+ */
+ public function getVisibility() {
+ $this->loadMutableFields();
+
+ return (int)$this->mDeleted;
+ }
+
+ /**
+ * Fetch revision content if it's available to the specified audience.
+ * If the specified audience does not have the ability to view this
+ * revision, null will be returned.
+ *
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to $wgUser
+ * Revision::RAW get the text regardless of permissions
+ * @param User $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @since 1.21
+ * @return Content|null
+ */
+ public function getContent( $audience = self::FOR_PUBLIC, User $user = null ) {
+ if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_TEXT ) ) {
+ return null;
+ } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_TEXT, $user ) ) {
+ return null;
+ } else {
+ return $this->getContentInternal();
+ }
+ }
+
+ /**
+ * Get original serialized data (without checking view restrictions)
+ *
+ * @since 1.21
+ * @return string
+ */
+ public function getSerializedData() {
+ if ( $this->mText === null ) {
+ // Revision is immutable. Load on demand.
+ $this->mText = $this->loadText();
+ }
+
+ return $this->mText;
+ }
+
+ /**
+ * Gets the content object for the revision (or null on failure).
+ *
+ * Note that for mutable Content objects, each call to this method will return a
+ * fresh clone.
+ *
+ * @since 1.21
+ * @return Content|null The Revision's content, or null on failure.
+ */
+ protected function getContentInternal() {
+ if ( $this->mContent === null ) {
+ $text = $this->getSerializedData();
+
+ if ( $text !== null && $text !== false ) {
+ // Unserialize content
+ $handler = $this->getContentHandler();
+ $format = $this->getContentFormat();
+
+ $this->mContent = $handler->unserializeContent( $text, $format );
+ }
+ }
+
+ // NOTE: copy() will return $this for immutable content objects
+ return $this->mContent ? $this->mContent->copy() : null;
+ }
+
+ /**
+ * Returns the content model for this revision.
+ *
+ * If no content model was stored in the database, the default content model for the title is
+ * used to determine the content model to use. If no title is know, CONTENT_MODEL_WIKITEXT
+ * is used as a last resort.
+ *
+ * @return string The content model id associated with this revision,
+ * see the CONTENT_MODEL_XXX constants.
+ */
+ public function getContentModel() {
+ if ( !$this->mContentModel ) {
+ $title = $this->getTitle();
+ if ( $title ) {
+ $this->mContentModel = ContentHandler::getDefaultModelFor( $title );
+ } else {
+ $this->mContentModel = CONTENT_MODEL_WIKITEXT;
+ }
+
+ assert( !empty( $this->mContentModel ) );
+ }
+
+ return $this->mContentModel;
+ }
+
+ /**
+ * Returns the content format for this revision.
+ *
+ * If no content format was stored in the database, the default format for this
+ * revision's content model is returned.
+ *
+ * @return string The content format id associated with this revision,
+ * see the CONTENT_FORMAT_XXX constants.
+ */
+ public function getContentFormat() {
+ if ( !$this->mContentFormat ) {
+ $handler = $this->getContentHandler();
+ $this->mContentFormat = $handler->getDefaultFormat();
+
+ assert( !empty( $this->mContentFormat ) );
+ }
+
+ return $this->mContentFormat;
+ }
+
+ /**
+ * Returns the content handler appropriate for this revision's content model.
+ *
+ * @throws MWException
+ * @return ContentHandler
+ */
+ public function getContentHandler() {
+ if ( !$this->mContentHandler ) {
+ $model = $this->getContentModel();
+ $this->mContentHandler = ContentHandler::getForModelID( $model );
+
+ $format = $this->getContentFormat();
+
+ if ( !$this->mContentHandler->isSupportedFormat( $format ) ) {
+ throw new MWException( "Oops, the content format $format is not supported for "
+ . "this content model, $model" );
+ }
+ }
+
+ return $this->mContentHandler;
+ }
+
+ /**
+ * @return string
+ */
+ public function getTimestamp() {
+ return wfTimestamp( TS_MW, $this->mTimestamp );
+ }
+
+ /**
+ * @return bool
+ */
+ public function isCurrent() {
+ return $this->mCurrent;
+ }
+
+ /**
+ * Get previous revision for this title
+ *
+ * @return Revision|null
+ */
+ public function getPrevious() {
+ if ( $this->getTitle() ) {
+ $prev = $this->getTitle()->getPreviousRevisionID( $this->getId() );
+ if ( $prev ) {
+ return self::newFromTitle( $this->getTitle(), $prev );
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get next revision for this title
+ *
+ * @return Revision|null
+ */
+ public function getNext() {
+ if ( $this->getTitle() ) {
+ $next = $this->getTitle()->getNextRevisionID( $this->getId() );
+ if ( $next ) {
+ return self::newFromTitle( $this->getTitle(), $next );
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get previous revision Id for this page_id
+ * This is used to populate rev_parent_id on save
+ *
+ * @param IDatabase $db
+ * @return int
+ */
+ private function getPreviousRevisionId( $db ) {
+ if ( $this->mPage === null ) {
+ return 0;
+ }
+ # Use page_latest if ID is not given
+ if ( !$this->mId ) {
+ $prevId = $db->selectField( 'page', 'page_latest',
+ [ 'page_id' => $this->mPage ],
+ __METHOD__ );
+ } else {
+ $prevId = $db->selectField( 'revision', 'rev_id',
+ [ 'rev_page' => $this->mPage, 'rev_id < ' . $this->mId ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_id DESC' ] );
+ }
+ return intval( $prevId );
+ }
+
+ /**
+ * Get revision text associated with an old or archive row
+ *
+ * Both the flags and the text field must be included. Including the old_id
+ * field will activate cache usage as long as the $wiki parameter is not set.
+ *
+ * @param stdClass $row The text data
+ * @param string $prefix Table prefix (default 'old_')
+ * @param string|bool $wiki The name of the wiki to load the revision text from
+ * (same as the the wiki $row was loaded from) or false to indicate the local
+ * wiki (this is the default). Otherwise, it must be a symbolic wiki database
+ * identifier as understood by the LoadBalancer class.
+ * @return string|false Text the text requested or false on failure
+ */
+ public static function getRevisionText( $row, $prefix = 'old_', $wiki = false ) {
+ $textField = $prefix . 'text';
+ $flagsField = $prefix . 'flags';
+
+ if ( isset( $row->$flagsField ) ) {
+ $flags = explode( ',', $row->$flagsField );
+ } else {
+ $flags = [];
+ }
+
+ if ( isset( $row->$textField ) ) {
+ $text = $row->$textField;
+ } else {
+ return false;
+ }
+
+ // Use external methods for external objects, text in table is URL-only then
+ if ( in_array( 'external', $flags ) ) {
+ $url = $text;
+ $parts = explode( '://', $url, 2 );
+ if ( count( $parts ) == 1 || $parts[1] == '' ) {
+ return false;
+ }
+
+ if ( isset( $row->old_id ) && $wiki === false ) {
+ // Make use of the wiki-local revision text cache
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ // The cached value should be decompressed, so handle that and return here
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'revisiontext', 'textid', $row->old_id ),
+ self::getCacheTTL( $cache ),
+ function () use ( $url, $wiki, $flags ) {
+ // No negative caching per Revision::loadText()
+ $text = ExternalStore::fetchFromURL( $url, [ 'wiki' => $wiki ] );
+
+ return self::decompressRevisionText( $text, $flags );
+ },
+ [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => $cache::TTL_PROC_LONG ]
+ );
+ } else {
+ $text = ExternalStore::fetchFromURL( $url, [ 'wiki' => $wiki ] );
+ }
+ }
+
+ return self::decompressRevisionText( $text, $flags );
+ }
+
+ /**
+ * If $wgCompressRevisions is enabled, we will compress data.
+ * The input string is modified in place.
+ * Return value is the flags field: contains 'gzip' if the
+ * data is compressed, and 'utf-8' if we're saving in UTF-8
+ * mode.
+ *
+ * @param mixed &$text Reference to a text
+ * @return string
+ */
+ public static function compressRevisionText( &$text ) {
+ global $wgCompressRevisions;
+ $flags = [];
+
+ # Revisions not marked this way will be converted
+ # on load if $wgLegacyCharset is set in the future.
+ $flags[] = 'utf-8';
+
+ if ( $wgCompressRevisions ) {
+ if ( function_exists( 'gzdeflate' ) ) {
+ $deflated = gzdeflate( $text );
+
+ if ( $deflated === false ) {
+ wfLogWarning( __METHOD__ . ': gzdeflate() failed' );
+ } else {
+ $text = $deflated;
+ $flags[] = 'gzip';
+ }
+ } else {
+ wfDebug( __METHOD__ . " -- no zlib support, not compressing\n" );
+ }
+ }
+ return implode( ',', $flags );
+ }
+
+ /**
+ * Re-converts revision text according to it's flags.
+ *
+ * @param mixed $text Reference to a text
+ * @param array $flags Compression flags
+ * @return string|bool Decompressed text, or false on failure
+ */
+ public static function decompressRevisionText( $text, $flags ) {
+ global $wgLegacyEncoding, $wgContLang;
+
+ if ( $text === false ) {
+ // Text failed to be fetched; nothing to do
+ return false;
+ }
+
+ if ( in_array( 'gzip', $flags ) ) {
+ # Deal with optional compression of archived pages.
+ # This can be done periodically via maintenance/compressOld.php, and
+ # as pages are saved if $wgCompressRevisions is set.
+ $text = gzinflate( $text );
+
+ if ( $text === false ) {
+ wfLogWarning( __METHOD__ . ': gzinflate() failed' );
+ return false;
+ }
+ }
+
+ if ( in_array( 'object', $flags ) ) {
+ # Generic compressed storage
+ $obj = unserialize( $text );
+ if ( !is_object( $obj ) ) {
+ // Invalid object
+ return false;
+ }
+ $text = $obj->getText();
+ }
+
+ if ( $text !== false && $wgLegacyEncoding
+ && !in_array( 'utf-8', $flags ) && !in_array( 'utf8', $flags )
+ ) {
+ # Old revisions kept around in a legacy encoding?
+ # Upconvert on demand.
+ # ("utf8" checked for compatibility with some broken
+ # conversion scripts 2008-12-30)
+ $text = $wgContLang->iconv( $wgLegacyEncoding, 'UTF-8', $text );
+ }
+
+ return $text;
+ }
+
+ /**
+ * Insert a new revision into the database, returning the new revision ID
+ * number on success and dies horribly on failure.
+ *
+ * @param IDatabase $dbw (master connection)
+ * @throws MWException
+ * @return int The revision ID
+ */
+ public function insertOn( $dbw ) {
+ global $wgDefaultExternalStore, $wgContentHandlerUseDB;
+
+ // We're inserting a new revision, so we have to use master anyway.
+ // If it's a null revision, it may have references to rows that
+ // are not in the replica yet (the text row).
+ $this->mQueryFlags |= self::READ_LATEST;
+
+ // Not allowed to have rev_page equal to 0, false, etc.
+ if ( !$this->mPage ) {
+ $title = $this->getTitle();
+ if ( $title instanceof Title ) {
+ $titleText = ' for page ' . $title->getPrefixedText();
+ } else {
+ $titleText = '';
+ }
+ throw new MWException( "Cannot insert revision$titleText: page ID must be nonzero" );
+ }
+
+ $this->checkContentModel();
+
+ $data = $this->mText;
+ $flags = self::compressRevisionText( $data );
+
+ # Write to external storage if required
+ if ( $wgDefaultExternalStore ) {
+ // Store and get the URL
+ $data = ExternalStore::insertToDefault( $data );
+ if ( !$data ) {
+ throw new MWException( "Unable to store text to external storage" );
+ }
+ if ( $flags ) {
+ $flags .= ',';
+ }
+ $flags .= 'external';
+ }
+
+ # Record the text (or external storage URL) to the text table
+ if ( $this->mTextId === null ) {
+ $dbw->insert( 'text',
+ [
+ 'old_text' => $data,
+ 'old_flags' => $flags,
+ ], __METHOD__
+ );
+ $this->mTextId = $dbw->insertId();
+ }
+
+ if ( $this->mComment === null ) {
+ $this->mComment = "";
+ }
+
+ # Record the edit in revisions
+ $row = [
+ 'rev_page' => $this->mPage,
+ 'rev_text_id' => $this->mTextId,
+ 'rev_minor_edit' => $this->mMinorEdit ? 1 : 0,
+ 'rev_user' => $this->mUser,
+ 'rev_user_text' => $this->mUserText,
+ 'rev_timestamp' => $dbw->timestamp( $this->mTimestamp ),
+ 'rev_deleted' => $this->mDeleted,
+ 'rev_len' => $this->mSize,
+ 'rev_parent_id' => $this->mParentId === null
+ ? $this->getPreviousRevisionId( $dbw )
+ : $this->mParentId,
+ 'rev_sha1' => $this->mSha1 === null
+ ? self::base36Sha1( $this->mText )
+ : $this->mSha1,
+ ];
+ if ( $this->mId !== null ) {
+ $row['rev_id'] = $this->mId;
+ }
+
+ list( $commentFields, $commentCallback ) =
+ CommentStore::newKey( 'rev_comment' )->insertWithTempTable( $dbw, $this->mComment );
+ $row += $commentFields;
+
+ if ( $wgContentHandlerUseDB ) {
+ // NOTE: Store null for the default model and format, to save space.
+ // XXX: Makes the DB sensitive to changed defaults.
+ // Make this behavior optional? Only in miser mode?
+
+ $model = $this->getContentModel();
+ $format = $this->getContentFormat();
+
+ $title = $this->getTitle();
+
+ if ( $title === null ) {
+ throw new MWException( "Insufficient information to determine the title of the "
+ . "revision's page!" );
+ }
+
+ $defaultModel = ContentHandler::getDefaultModelFor( $title );
+ $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
+
+ $row['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
+ $row['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
+ }
+
+ $dbw->insert( 'revision', $row, __METHOD__ );
+
+ if ( $this->mId === null ) {
+ // Only if auto-increment was used
+ $this->mId = $dbw->insertId();
+ }
+ $commentCallback( $this->mId );
+
+ // Assertion to try to catch T92046
+ if ( (int)$this->mId === 0 ) {
+ throw new UnexpectedValueException(
+ 'After insert, Revision mId is ' . var_export( $this->mId, 1 ) . ': ' .
+ var_export( $row, 1 )
+ );
+ }
+
+ // Insert IP revision into ip_changes for use when querying for a range.
+ if ( $this->mUser === 0 && IP::isValid( $this->mUserText ) ) {
+ $ipcRow = [
+ 'ipc_rev_id' => $this->mId,
+ 'ipc_rev_timestamp' => $row['rev_timestamp'],
+ 'ipc_hex' => IP::toHex( $row['rev_user_text'] ),
+ ];
+ $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $revision = $this;
+ Hooks::run( 'RevisionInsertComplete', [ &$revision, $data, $flags ] );
+
+ return $this->mId;
+ }
+
+ protected function checkContentModel() {
+ global $wgContentHandlerUseDB;
+
+ // Note: may return null for revisions that have not yet been inserted
+ $title = $this->getTitle();
+
+ $model = $this->getContentModel();
+ $format = $this->getContentFormat();
+ $handler = $this->getContentHandler();
+
+ if ( !$handler->isSupportedFormat( $format ) ) {
+ $t = $title->getPrefixedDBkey();
+
+ throw new MWException( "Can't use format $format with content model $model on $t" );
+ }
+
+ if ( !$wgContentHandlerUseDB && $title ) {
+ // if $wgContentHandlerUseDB is not set,
+ // all revisions must use the default content model and format.
+
+ $defaultModel = ContentHandler::getDefaultModelFor( $title );
+ $defaultHandler = ContentHandler::getForModelID( $defaultModel );
+ $defaultFormat = $defaultHandler->getDefaultFormat();
+
+ if ( $this->getContentModel() != $defaultModel ) {
+ $t = $title->getPrefixedDBkey();
+
+ throw new MWException( "Can't save non-default content model with "
+ . "\$wgContentHandlerUseDB disabled: model is $model, "
+ . "default for $t is $defaultModel" );
+ }
+
+ if ( $this->getContentFormat() != $defaultFormat ) {
+ $t = $title->getPrefixedDBkey();
+
+ throw new MWException( "Can't use non-default content format with "
+ . "\$wgContentHandlerUseDB disabled: format is $format, "
+ . "default for $t is $defaultFormat" );
+ }
+ }
+
+ $content = $this->getContent( self::RAW );
+ $prefixedDBkey = $title->getPrefixedDBkey();
+ $revId = $this->mId;
+
+ if ( !$content ) {
+ throw new MWException(
+ "Content of revision $revId ($prefixedDBkey) could not be loaded for validation!"
+ );
+ }
+ if ( !$content->isValid() ) {
+ throw new MWException(
+ "Content of revision $revId ($prefixedDBkey) is not valid! Content model is $model"
+ );
+ }
+ }
+
+ /**
+ * Get the base 36 SHA-1 value for a string of text
+ * @param string $text
+ * @return string
+ */
+ public static function base36Sha1( $text ) {
+ return Wikimedia\base_convert( sha1( $text ), 16, 36, 31 );
+ }
+
+ /**
+ * Get the text cache TTL
+ *
+ * @param WANObjectCache $cache
+ * @return int
+ */
+ private static function getCacheTTL( WANObjectCache $cache ) {
+ global $wgRevisionCacheExpiry;
+
+ if ( $cache->getQoS( $cache::ATTR_EMULATION ) <= $cache::QOS_EMULATION_SQL ) {
+ // Do not cache RDBMs blobs in...the RDBMs store
+ $ttl = $cache::TTL_UNCACHEABLE;
+ } else {
+ $ttl = $wgRevisionCacheExpiry ?: $cache::TTL_UNCACHEABLE;
+ }
+
+ return $ttl;
+ }
+
+ /**
+ * Lazy-load the revision's text.
+ * Currently hardcoded to the 'text' table storage engine.
+ *
+ * @return string|bool The revision's text, or false on failure
+ */
+ private function loadText() {
+ $cache = ObjectCache::getMainWANInstance();
+
+ // No negative caching; negative hits on text rows may be due to corrupted replica DBs
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'revisiontext', 'textid', $this->getTextId() ),
+ self::getCacheTTL( $cache ),
+ function () {
+ return $this->fetchText();
+ },
+ [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => $cache::TTL_PROC_LONG ]
+ );
+ }
+
+ private function fetchText() {
+ $textId = $this->getTextId();
+
+ // If we kept data for lazy extraction, use it now...
+ if ( $this->mTextRow !== null ) {
+ $row = $this->mTextRow;
+ $this->mTextRow = null;
+ } else {
+ $row = null;
+ }
+
+ // Callers doing updates will pass in READ_LATEST as usual. Since the text/blob tables
+ // do not normally get rows changed around, set READ_LATEST_IMMUTABLE in those cases.
+ $flags = $this->mQueryFlags;
+ $flags |= DBAccessObjectUtils::hasFlags( $flags, self::READ_LATEST )
+ ? self::READ_LATEST_IMMUTABLE
+ : 0;
+
+ list( $index, $options, $fallbackIndex, $fallbackOptions ) =
+ DBAccessObjectUtils::getDBOptions( $flags );
+
+ if ( !$row ) {
+ // Text data is immutable; check replica DBs first.
+ $row = wfGetDB( $index )->selectRow(
+ 'text',
+ [ 'old_text', 'old_flags' ],
+ [ 'old_id' => $textId ],
+ __METHOD__,
+ $options
+ );
+ }
+
+ // Fallback to DB_MASTER in some cases if the row was not found
+ if ( !$row && $fallbackIndex !== null ) {
+ // Use FOR UPDATE if it was used to fetch this revision. This avoids missing the row
+ // due to REPEATABLE-READ. Also fallback to the master if READ_LATEST is provided.
+ $row = wfGetDB( $fallbackIndex )->selectRow(
+ 'text',
+ [ 'old_text', 'old_flags' ],
+ [ 'old_id' => $textId ],
+ __METHOD__,
+ $fallbackOptions
+ );
+ }
+
+ if ( !$row ) {
+ wfDebugLog( 'Revision', "No text row with ID '$textId' (revision {$this->getId()})." );
+ }
+
+ $text = self::getRevisionText( $row );
+ if ( $row && $text === false ) {
+ wfDebugLog( 'Revision', "No blob for text row '$textId' (revision {$this->getId()})." );
+ }
+
+ return is_string( $text ) ? $text : false;
+ }
+
+ /**
+ * Create a new null-revision for insertion into a page's
+ * history. This will not re-save the text, but simply refer
+ * to the text from the previous version.
+ *
+ * Such revisions can for instance identify page rename
+ * operations and other such meta-modifications.
+ *
+ * @param IDatabase $dbw
+ * @param int $pageId ID number of the page to read from
+ * @param string $summary Revision's summary
+ * @param bool $minor Whether the revision should be considered as minor
+ * @param User|null $user User object to use or null for $wgUser
+ * @return Revision|null Revision or null on error
+ */
+ public static function newNullRevision( $dbw, $pageId, $summary, $minor, $user = null ) {
+ global $wgContentHandlerUseDB;
+
+ $fields = [ 'page_latest', 'page_namespace', 'page_title',
+ 'rev_text_id', 'rev_len', 'rev_sha1' ];
+
+ if ( $wgContentHandlerUseDB ) {
+ $fields[] = 'rev_content_model';
+ $fields[] = 'rev_content_format';
+ }
+
+ $current = $dbw->selectRow(
+ [ 'page', 'revision' ],
+ $fields,
+ [
+ 'page_id' => $pageId,
+ 'page_latest=rev_id',
+ ],
+ __METHOD__,
+ [ 'FOR UPDATE' ] // T51581
+ );
+
+ if ( $current ) {
+ if ( !$user ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ $row = [
+ 'page' => $pageId,
+ 'user_text' => $user->getName(),
+ 'user' => $user->getId(),
+ 'comment' => $summary,
+ 'minor_edit' => $minor,
+ 'text_id' => $current->rev_text_id,
+ 'parent_id' => $current->page_latest,
+ 'len' => $current->rev_len,
+ 'sha1' => $current->rev_sha1
+ ];
+
+ if ( $wgContentHandlerUseDB ) {
+ $row['content_model'] = $current->rev_content_model;
+ $row['content_format'] = $current->rev_content_format;
+ }
+
+ $row['title'] = Title::makeTitle( $current->page_namespace, $current->page_title );
+
+ $revision = new Revision( $row );
+ } else {
+ $revision = null;
+ }
+
+ return $revision;
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this revision, if it's marked as deleted.
+ *
+ * @param int $field One of self::DELETED_TEXT,
+ * self::DELETED_COMMENT,
+ * self::DELETED_USER
+ * @param User|null $user User object to check, or null to use $wgUser
+ * @return bool
+ */
+ public function userCan( $field, User $user = null ) {
+ return self::userCanBitfield( $this->getVisibility(), $field, $user );
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this revision, if it's marked as deleted. This is used
+ * by various classes to avoid duplication.
+ *
+ * @param int $bitfield Current field
+ * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE,
+ * self::DELETED_COMMENT = File::DELETED_COMMENT,
+ * self::DELETED_USER = File::DELETED_USER
+ * @param User|null $user User object to check, or null to use $wgUser
+ * @param Title|null $title A Title object to check for per-page restrictions on,
+ * instead of just plain userrights
+ * @return bool
+ */
+ public static function userCanBitfield( $bitfield, $field, User $user = null,
+ Title $title = null
+ ) {
+ if ( $bitfield & $field ) { // aspect is deleted
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+ if ( $bitfield & self::DELETED_RESTRICTED ) {
+ $permissions = [ 'suppressrevision', 'viewsuppressed' ];
+ } elseif ( $field & self::DELETED_TEXT ) {
+ $permissions = [ 'deletedtext' ];
+ } else {
+ $permissions = [ 'deletedhistory' ];
+ }
+ $permissionlist = implode( ', ', $permissions );
+ if ( $title === null ) {
+ wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" );
+ return call_user_func_array( [ $user, 'isAllowedAny' ], $permissions );
+ } else {
+ $text = $title->getPrefixedText();
+ wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" );
+ foreach ( $permissions as $perm ) {
+ if ( $title->userCan( $perm, $user ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Get rev_timestamp from rev_id, without loading the rest of the row
+ *
+ * @param Title $title
+ * @param int $id
+ * @param int $flags
+ * @return string|bool False if not found
+ */
+ static function getTimestampFromId( $title, $id, $flags = 0 ) {
+ $db = ( $flags & self::READ_LATEST )
+ ? wfGetDB( DB_MASTER )
+ : wfGetDB( DB_REPLICA );
+ // Casting fix for databases that can't take '' for rev_id
+ if ( $id == '' ) {
+ $id = 0;
+ }
+ $conds = [ 'rev_id' => $id ];
+ $conds['rev_page'] = $title->getArticleID();
+ $timestamp = $db->selectField( 'revision', 'rev_timestamp', $conds, __METHOD__ );
+
+ return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false;
+ }
+
+ /**
+ * Get count of revisions per page...not very efficient
+ *
+ * @param IDatabase $db
+ * @param int $id Page id
+ * @return int
+ */
+ static function countByPageId( $db, $id ) {
+ $row = $db->selectRow( 'revision', [ 'revCount' => 'COUNT(*)' ],
+ [ 'rev_page' => $id ], __METHOD__ );
+ if ( $row ) {
+ return $row->revCount;
+ }
+ return 0;
+ }
+
+ /**
+ * Get count of revisions per page...not very efficient
+ *
+ * @param IDatabase $db
+ * @param Title $title
+ * @return int
+ */
+ static function countByTitle( $db, $title ) {
+ $id = $title->getArticleID();
+ if ( $id ) {
+ return self::countByPageId( $db, $id );
+ }
+ return 0;
+ }
+
+ /**
+ * Check if no edits were made by other users since
+ * the time a user started editing the page. Limit to
+ * 50 revisions for the sake of performance.
+ *
+ * @since 1.20
+ * @deprecated since 1.24
+ *
+ * @param IDatabase|int $db The Database to perform the check on. May be given as a
+ * Database object or a database identifier usable with wfGetDB.
+ * @param int $pageId The ID of the page in question
+ * @param int $userId The ID of the user in question
+ * @param string $since Look at edits since this time
+ *
+ * @return bool True if the given user was the only one to edit since the given timestamp
+ */
+ public static function userWasLastToEdit( $db, $pageId, $userId, $since ) {
+ if ( !$userId ) {
+ return false;
+ }
+
+ if ( is_int( $db ) ) {
+ $db = wfGetDB( $db );
+ }
+
+ $res = $db->select( 'revision',
+ 'rev_user',
+ [
+ 'rev_page' => $pageId,
+ 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ] );
+ foreach ( $res as $row ) {
+ if ( $row->rev_user != $userId ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Load a revision based on a known page ID and current revision ID from the DB
+ *
+ * This method allows for the use of caching, though accessing anything that normally
+ * requires permission checks (aside from the text) will trigger a small DB lookup.
+ * The title will also be lazy loaded, though setTitle() can be used to preload it.
+ *
+ * @param IDatabase $db
+ * @param int $pageId Page ID
+ * @param int $revId Known current revision of this page
+ * @return Revision|bool Returns false if missing
+ * @since 1.28
+ */
+ public static function newKnownCurrent( IDatabase $db, $pageId, $revId ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->getWithSetCallback(
+ // Page/rev IDs passed in from DB to reflect history merges
+ $cache->makeGlobalKey( 'revision', $db->getDomainID(), $pageId, $revId ),
+ $cache::TTL_WEEK,
+ function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) {
+ $setOpts += Database::getCacheSetOptions( $db );
+
+ $rev = Revision::loadFromPageId( $db, $pageId, $revId );
+ // Reflect revision deletion and user renames
+ if ( $rev ) {
+ $rev->mTitle = null; // mutable; lazy-load
+ $rev->mRefreshMutableFields = true;
+ }
+
+ return $rev ?: false; // don't cache negatives
+ }
+ );
+ }
+
+ /**
+ * For cached revisions, make sure the user name and rev_deleted is up-to-date
+ */
+ private function loadMutableFields() {
+ if ( !$this->mRefreshMutableFields ) {
+ return; // not needed
+ }
+
+ $this->mRefreshMutableFields = false;
+ $dbr = wfGetLB( $this->mWiki )->getConnectionRef( DB_REPLICA, [], $this->mWiki );
+ $row = $dbr->selectRow(
+ [ 'revision', 'user' ],
+ [ 'rev_deleted', 'user_name' ],
+ [ 'rev_id' => $this->mId, 'user_id = rev_user' ],
+ __METHOD__
+ );
+ if ( $row ) { // update values
+ $this->mDeleted = (int)$row->rev_deleted;
+ $this->mUserText = $row->user_name;
+ }
+ }
+}
diff --git a/www/wiki/includes/RevisionList.php b/www/wiki/includes/RevisionList.php
new file mode 100644
index 00000000..b0bc60a1
--- /dev/null
+++ b/www/wiki/includes/RevisionList.php
@@ -0,0 +1,428 @@
+<?php
+/**
+ * Holders of revision list for a single page
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * List for revision table items for a single page
+ */
+abstract class RevisionListBase extends ContextSource implements Iterator {
+ /** @var Title */
+ public $title;
+
+ /** @var array */
+ protected $ids;
+
+ /** @var ResultWrapper|bool */
+ protected $res;
+
+ /** @var bool|Revision */
+ protected $current;
+
+ /**
+ * Construct a revision list for a given title
+ * @param IContextSource $context
+ * @param Title $title
+ */
+ function __construct( IContextSource $context, Title $title ) {
+ $this->setContext( $context );
+ $this->title = $title;
+ }
+
+ /**
+ * Select items only where the ID is any of the specified values
+ * @param array $ids
+ */
+ function filterByIds( array $ids ) {
+ $this->ids = $ids;
+ }
+
+ /**
+ * Get the internal type name of this list. Equal to the table name.
+ * Override this function.
+ * @return null
+ */
+ public function getType() {
+ return null;
+ }
+
+ /**
+ * Initialise the current iteration pointer
+ */
+ protected function initCurrent() {
+ $row = $this->res->current();
+ if ( $row ) {
+ $this->current = $this->newItem( $row );
+ } else {
+ $this->current = false;
+ }
+ }
+
+ /**
+ * Start iteration. This must be called before current() or next().
+ * @return Revision First list item
+ */
+ public function reset() {
+ if ( !$this->res ) {
+ $this->res = $this->doQuery( wfGetDB( DB_REPLICA ) );
+ } else {
+ $this->res->rewind();
+ }
+ $this->initCurrent();
+ return $this->current;
+ }
+
+ public function rewind() {
+ $this->reset();
+ }
+
+ /**
+ * Get the current list item, or false if we are at the end
+ * @return Revision
+ */
+ public function current() {
+ return $this->current;
+ }
+
+ /**
+ * Move the iteration pointer to the next list item, and return it.
+ * @return Revision
+ */
+ public function next() {
+ $this->res->next();
+ $this->initCurrent();
+ return $this->current;
+ }
+
+ public function key() {
+ return $this->res ? $this->res->key() : 0;
+ }
+
+ public function valid() {
+ return $this->res ? $this->res->valid() : false;
+ }
+
+ /**
+ * Get the number of items in the list.
+ * @return int
+ */
+ public function length() {
+ if ( !$this->res ) {
+ return 0;
+ } else {
+ return $this->res->numRows();
+ }
+ }
+
+ /**
+ * Do the DB query to iterate through the objects.
+ * @param IDatabase $db DB object to use for the query
+ */
+ abstract public function doQuery( $db );
+
+ /**
+ * Create an item object from a DB result row
+ * @param object $row
+ */
+ abstract public function newItem( $row );
+}
+
+/**
+ * Abstract base class for revision items
+ */
+abstract class RevisionItemBase {
+ /** @var RevisionListBase The parent */
+ protected $list;
+
+ /** The database result row */
+ protected $row;
+
+ /**
+ * @param RevisionListBase $list
+ * @param object $row DB result row
+ */
+ public function __construct( $list, $row ) {
+ $this->list = $list;
+ $this->row = $row;
+ }
+
+ /**
+ * Get the DB field name associated with the ID list.
+ * Override this function.
+ * @return null
+ */
+ public function getIdField() {
+ return null;
+ }
+
+ /**
+ * Get the DB field name storing timestamps.
+ * Override this function.
+ * @return bool
+ */
+ public function getTimestampField() {
+ return false;
+ }
+
+ /**
+ * Get the DB field name storing user ids.
+ * Override this function.
+ * @return bool
+ */
+ public function getAuthorIdField() {
+ return false;
+ }
+
+ /**
+ * Get the DB field name storing user names.
+ * Override this function.
+ * @return bool
+ */
+ public function getAuthorNameField() {
+ return false;
+ }
+
+ /**
+ * Get the ID, as it would appear in the ids URL parameter
+ * @return int
+ */
+ public function getId() {
+ $field = $this->getIdField();
+ return $this->row->$field;
+ }
+
+ /**
+ * Get the date, formatted in user's language
+ * @return string
+ */
+ public function formatDate() {
+ return $this->list->getLanguage()->userDate( $this->getTimestamp(),
+ $this->list->getUser() );
+ }
+
+ /**
+ * Get the time, formatted in user's language
+ * @return string
+ */
+ public function formatTime() {
+ return $this->list->getLanguage()->userTime( $this->getTimestamp(),
+ $this->list->getUser() );
+ }
+
+ /**
+ * Get the timestamp in MW 14-char form
+ * @return mixed
+ */
+ public function getTimestamp() {
+ $field = $this->getTimestampField();
+ return wfTimestamp( TS_MW, $this->row->$field );
+ }
+
+ /**
+ * Get the author user ID
+ * @return int
+ */
+ public function getAuthorId() {
+ $field = $this->getAuthorIdField();
+ return intval( $this->row->$field );
+ }
+
+ /**
+ * Get the author user name
+ * @return string
+ */
+ public function getAuthorName() {
+ $field = $this->getAuthorNameField();
+ return strval( $this->row->$field );
+ }
+
+ /**
+ * Returns true if the current user can view the item
+ */
+ abstract public function canView();
+
+ /**
+ * Returns true if the current user can view the item text/file
+ */
+ abstract public function canViewContent();
+
+ /**
+ * Get the HTML of the list item. Should be include "<li></li>" tags.
+ * This is used to show the list in HTML form, by the special page.
+ */
+ abstract public function getHTML();
+
+ /**
+ * Returns an instance of LinkRenderer
+ * @return \MediaWiki\Linker\LinkRenderer
+ */
+ protected function getLinkRenderer() {
+ return MediaWikiServices::getInstance()->getLinkRenderer();
+ }
+}
+
+class RevisionList extends RevisionListBase {
+ public function getType() {
+ return 'revision';
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return mixed
+ */
+ public function doQuery( $db ) {
+ $conds = [ 'rev_page' => $this->title->getArticleID() ];
+ if ( $this->ids !== null ) {
+ $conds['rev_id'] = array_map( 'intval', $this->ids );
+ }
+ return $db->select(
+ [ 'revision', 'page', 'user' ],
+ array_merge( Revision::selectFields(), Revision::selectUserFields() ),
+ $conds,
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_id DESC' ],
+ [
+ 'page' => Revision::pageJoinCond(),
+ 'user' => Revision::userJoinCond() ]
+ );
+ }
+
+ public function newItem( $row ) {
+ return new RevisionItem( $this, $row );
+ }
+}
+
+/**
+ * Item class for a live revision table row
+ */
+class RevisionItem extends RevisionItemBase {
+ /** @var Revision */
+ protected $revision;
+
+ /** @var RequestContext */
+ protected $context;
+
+ public function __construct( $list, $row ) {
+ parent::__construct( $list, $row );
+ $this->revision = new Revision( $row );
+ $this->context = $list->getContext();
+ }
+
+ public function getIdField() {
+ return 'rev_id';
+ }
+
+ public function getTimestampField() {
+ return 'rev_timestamp';
+ }
+
+ public function getAuthorIdField() {
+ return 'rev_user';
+ }
+
+ public function getAuthorNameField() {
+ return 'rev_user_text';
+ }
+
+ public function canView() {
+ return $this->revision->userCan( Revision::DELETED_RESTRICTED, $this->context->getUser() );
+ }
+
+ public function canViewContent() {
+ return $this->revision->userCan( Revision::DELETED_TEXT, $this->context->getUser() );
+ }
+
+ public function isDeleted() {
+ return $this->revision->isDeleted( Revision::DELETED_TEXT );
+ }
+
+ /**
+ * Get the HTML link to the revision text.
+ * @todo Essentially a copy of RevDelRevisionItem::getRevisionLink. That class
+ * should inherit from this one, and implement an appropriate interface instead
+ * of extending RevDelItem
+ * @return string
+ */
+ protected function getRevisionLink() {
+ $date = $this->list->getLanguage()->userTimeAndDate(
+ $this->revision->getTimestamp(), $this->list->getUser() );
+
+ if ( $this->isDeleted() && !$this->canViewContent() ) {
+ return htmlspecialchars( $date );
+ }
+ $linkRenderer = $this->getLinkRenderer();
+ return $linkRenderer->makeKnownLink(
+ $this->list->title,
+ $date,
+ [],
+ [
+ 'oldid' => $this->revision->getId(),
+ 'unhide' => 1
+ ]
+ );
+ }
+
+ /**
+ * Get the HTML link to the diff.
+ * @todo Essentially a copy of RevDelRevisionItem::getDiffLink. That class
+ * should inherit from this one, and implement an appropriate interface instead
+ * of extending RevDelItem
+ * @return string
+ */
+ protected function getDiffLink() {
+ if ( $this->isDeleted() && !$this->canViewContent() ) {
+ return $this->context->msg( 'diff' )->escaped();
+ } else {
+ $linkRenderer = $this->getLinkRenderer();
+ return $linkRenderer->makeKnownLink(
+ $this->list->title,
+ $this->list->msg( 'diff' )->text(),
+ [],
+ [
+ 'diff' => $this->revision->getId(),
+ 'oldid' => 'prev',
+ 'unhide' => 1
+ ]
+ );
+ }
+ }
+
+ /**
+ * @todo Essentially a copy of RevDelRevisionItem::getHTML. That class
+ * should inherit from this one, and implement an appropriate interface instead
+ * of extending RevDelItem
+ * @return string
+ */
+ public function getHTML() {
+ $difflink = $this->context->msg( 'parentheses' )
+ ->rawParams( $this->getDiffLink() )->escaped();
+ $revlink = $this->getRevisionLink();
+ $userlink = Linker::revUserLink( $this->revision );
+ $comment = Linker::revComment( $this->revision );
+ if ( $this->isDeleted() ) {
+ $revlink = "<span class=\"history-deleted\">$revlink</span>";
+ }
+ return "<li>$difflink $revlink $userlink $comment</li>";
+ }
+}
diff --git a/www/wiki/includes/Sanitizer.php b/www/wiki/includes/Sanitizer.php
new file mode 100644
index 00000000..a7f963a4
--- /dev/null
+++ b/www/wiki/includes/Sanitizer.php
@@ -0,0 +1,2112 @@
+<?php
+/**
+ * HTML sanitizer for %MediaWiki.
+ *
+ * Copyright © 2002-2005 Brion Vibber <brion@pobox.com> et al
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * HTML sanitizer for MediaWiki
+ * @ingroup Parser
+ */
+class Sanitizer {
+ /**
+ * Regular expression to match various types of character references in
+ * Sanitizer::normalizeCharReferences and Sanitizer::decodeCharReferences
+ */
+ const CHAR_REFS_REGEX =
+ '/&([A-Za-z0-9\x80-\xff]+);
+ |&\#([0-9]+);
+ |&\#[xX]([0-9A-Fa-f]+);
+ |(&)/x';
+
+ /**
+ * Acceptable tag name charset from HTML5 parsing spec
+ * https://www.w3.org/TR/html5/syntax.html#tag-open-state
+ */
+ const ELEMENT_BITS_REGEX = '!^(/?)([A-Za-z][^\t\n\v />\0]*+)([^>]*?)(/?>)([^<]*)$!';
+
+ /**
+ * Blacklist for evil uris like javascript:
+ * WARNING: DO NOT use this in any place that actually requires blacklisting
+ * for security reasons. There are NUMEROUS[1] ways to bypass blacklisting, the
+ * only way to be secure from javascript: uri based xss vectors is to whitelist
+ * things that you know are safe and deny everything else.
+ * [1]: http://ha.ckers.org/xss.html
+ */
+ const EVIL_URI_PATTERN = '!(^|\s|\*/\s*)(javascript|vbscript)([^\w]|$)!i';
+ const XMLNS_ATTRIBUTE_PATTERN = "/^xmlns:[:A-Z_a-z-.0-9]+$/";
+
+ /**
+ * Tells escapeUrlForHtml() to encode the ID using the wiki's primary encoding.
+ *
+ * @since 1.30
+ */
+ const ID_PRIMARY = 0;
+
+ /**
+ * Tells escapeUrlForHtml() to encode the ID using the fallback encoding, or return false
+ * if no fallback is configured.
+ *
+ * @since 1.30
+ */
+ const ID_FALLBACK = 1;
+
+ /**
+ * List of all named character entities defined in HTML 4.01
+ * https://www.w3.org/TR/html4/sgml/entities.html
+ * As well as &apos; which is only defined starting in XHTML1.
+ */
+ private static $htmlEntities = [
+ 'Aacute' => 193,
+ 'aacute' => 225,
+ 'Acirc' => 194,
+ 'acirc' => 226,
+ 'acute' => 180,
+ 'AElig' => 198,
+ 'aelig' => 230,
+ 'Agrave' => 192,
+ 'agrave' => 224,
+ 'alefsym' => 8501,
+ 'Alpha' => 913,
+ 'alpha' => 945,
+ 'amp' => 38,
+ 'and' => 8743,
+ 'ang' => 8736,
+ 'apos' => 39, // New in XHTML & HTML 5; avoid in output for compatibility with IE.
+ 'Aring' => 197,
+ 'aring' => 229,
+ 'asymp' => 8776,
+ 'Atilde' => 195,
+ 'atilde' => 227,
+ 'Auml' => 196,
+ 'auml' => 228,
+ 'bdquo' => 8222,
+ 'Beta' => 914,
+ 'beta' => 946,
+ 'brvbar' => 166,
+ 'bull' => 8226,
+ 'cap' => 8745,
+ 'Ccedil' => 199,
+ 'ccedil' => 231,
+ 'cedil' => 184,
+ 'cent' => 162,
+ 'Chi' => 935,
+ 'chi' => 967,
+ 'circ' => 710,
+ 'clubs' => 9827,
+ 'cong' => 8773,
+ 'copy' => 169,
+ 'crarr' => 8629,
+ 'cup' => 8746,
+ 'curren' => 164,
+ 'dagger' => 8224,
+ 'Dagger' => 8225,
+ 'darr' => 8595,
+ 'dArr' => 8659,
+ 'deg' => 176,
+ 'Delta' => 916,
+ 'delta' => 948,
+ 'diams' => 9830,
+ 'divide' => 247,
+ 'Eacute' => 201,
+ 'eacute' => 233,
+ 'Ecirc' => 202,
+ 'ecirc' => 234,
+ 'Egrave' => 200,
+ 'egrave' => 232,
+ 'empty' => 8709,
+ 'emsp' => 8195,
+ 'ensp' => 8194,
+ 'Epsilon' => 917,
+ 'epsilon' => 949,
+ 'equiv' => 8801,
+ 'Eta' => 919,
+ 'eta' => 951,
+ 'ETH' => 208,
+ 'eth' => 240,
+ 'Euml' => 203,
+ 'euml' => 235,
+ 'euro' => 8364,
+ 'exist' => 8707,
+ 'fnof' => 402,
+ 'forall' => 8704,
+ 'frac12' => 189,
+ 'frac14' => 188,
+ 'frac34' => 190,
+ 'frasl' => 8260,
+ 'Gamma' => 915,
+ 'gamma' => 947,
+ 'ge' => 8805,
+ 'gt' => 62,
+ 'harr' => 8596,
+ 'hArr' => 8660,
+ 'hearts' => 9829,
+ 'hellip' => 8230,
+ 'Iacute' => 205,
+ 'iacute' => 237,
+ 'Icirc' => 206,
+ 'icirc' => 238,
+ 'iexcl' => 161,
+ 'Igrave' => 204,
+ 'igrave' => 236,
+ 'image' => 8465,
+ 'infin' => 8734,
+ 'int' => 8747,
+ 'Iota' => 921,
+ 'iota' => 953,
+ 'iquest' => 191,
+ 'isin' => 8712,
+ 'Iuml' => 207,
+ 'iuml' => 239,
+ 'Kappa' => 922,
+ 'kappa' => 954,
+ 'Lambda' => 923,
+ 'lambda' => 955,
+ 'lang' => 9001,
+ 'laquo' => 171,
+ 'larr' => 8592,
+ 'lArr' => 8656,
+ 'lceil' => 8968,
+ 'ldquo' => 8220,
+ 'le' => 8804,
+ 'lfloor' => 8970,
+ 'lowast' => 8727,
+ 'loz' => 9674,
+ 'lrm' => 8206,
+ 'lsaquo' => 8249,
+ 'lsquo' => 8216,
+ 'lt' => 60,
+ 'macr' => 175,
+ 'mdash' => 8212,
+ 'micro' => 181,
+ 'middot' => 183,
+ 'minus' => 8722,
+ 'Mu' => 924,
+ 'mu' => 956,
+ 'nabla' => 8711,
+ 'nbsp' => 160,
+ 'ndash' => 8211,
+ 'ne' => 8800,
+ 'ni' => 8715,
+ 'not' => 172,
+ 'notin' => 8713,
+ 'nsub' => 8836,
+ 'Ntilde' => 209,
+ 'ntilde' => 241,
+ 'Nu' => 925,
+ 'nu' => 957,
+ 'Oacute' => 211,
+ 'oacute' => 243,
+ 'Ocirc' => 212,
+ 'ocirc' => 244,
+ 'OElig' => 338,
+ 'oelig' => 339,
+ 'Ograve' => 210,
+ 'ograve' => 242,
+ 'oline' => 8254,
+ 'Omega' => 937,
+ 'omega' => 969,
+ 'Omicron' => 927,
+ 'omicron' => 959,
+ 'oplus' => 8853,
+ 'or' => 8744,
+ 'ordf' => 170,
+ 'ordm' => 186,
+ 'Oslash' => 216,
+ 'oslash' => 248,
+ 'Otilde' => 213,
+ 'otilde' => 245,
+ 'otimes' => 8855,
+ 'Ouml' => 214,
+ 'ouml' => 246,
+ 'para' => 182,
+ 'part' => 8706,
+ 'permil' => 8240,
+ 'perp' => 8869,
+ 'Phi' => 934,
+ 'phi' => 966,
+ 'Pi' => 928,
+ 'pi' => 960,
+ 'piv' => 982,
+ 'plusmn' => 177,
+ 'pound' => 163,
+ 'prime' => 8242,
+ 'Prime' => 8243,
+ 'prod' => 8719,
+ 'prop' => 8733,
+ 'Psi' => 936,
+ 'psi' => 968,
+ 'quot' => 34,
+ 'radic' => 8730,
+ 'rang' => 9002,
+ 'raquo' => 187,
+ 'rarr' => 8594,
+ 'rArr' => 8658,
+ 'rceil' => 8969,
+ 'rdquo' => 8221,
+ 'real' => 8476,
+ 'reg' => 174,
+ 'rfloor' => 8971,
+ 'Rho' => 929,
+ 'rho' => 961,
+ 'rlm' => 8207,
+ 'rsaquo' => 8250,
+ 'rsquo' => 8217,
+ 'sbquo' => 8218,
+ 'Scaron' => 352,
+ 'scaron' => 353,
+ 'sdot' => 8901,
+ 'sect' => 167,
+ 'shy' => 173,
+ 'Sigma' => 931,
+ 'sigma' => 963,
+ 'sigmaf' => 962,
+ 'sim' => 8764,
+ 'spades' => 9824,
+ 'sub' => 8834,
+ 'sube' => 8838,
+ 'sum' => 8721,
+ 'sup' => 8835,
+ 'sup1' => 185,
+ 'sup2' => 178,
+ 'sup3' => 179,
+ 'supe' => 8839,
+ 'szlig' => 223,
+ 'Tau' => 932,
+ 'tau' => 964,
+ 'there4' => 8756,
+ 'Theta' => 920,
+ 'theta' => 952,
+ 'thetasym' => 977,
+ 'thinsp' => 8201,
+ 'THORN' => 222,
+ 'thorn' => 254,
+ 'tilde' => 732,
+ 'times' => 215,
+ 'trade' => 8482,
+ 'Uacute' => 218,
+ 'uacute' => 250,
+ 'uarr' => 8593,
+ 'uArr' => 8657,
+ 'Ucirc' => 219,
+ 'ucirc' => 251,
+ 'Ugrave' => 217,
+ 'ugrave' => 249,
+ 'uml' => 168,
+ 'upsih' => 978,
+ 'Upsilon' => 933,
+ 'upsilon' => 965,
+ 'Uuml' => 220,
+ 'uuml' => 252,
+ 'weierp' => 8472,
+ 'Xi' => 926,
+ 'xi' => 958,
+ 'Yacute' => 221,
+ 'yacute' => 253,
+ 'yen' => 165,
+ 'Yuml' => 376,
+ 'yuml' => 255,
+ 'Zeta' => 918,
+ 'zeta' => 950,
+ 'zwj' => 8205,
+ 'zwnj' => 8204
+ ];
+
+ /**
+ * Character entity aliases accepted by MediaWiki
+ */
+ private static $htmlEntityAliases = [
+ 'רלמ' => 'rlm',
+ 'رلم' => 'rlm',
+ ];
+
+ /**
+ * Lazy-initialised attributes regex, see getAttribsRegex()
+ */
+ private static $attribsRegex;
+
+ /**
+ * Regular expression to match HTML/XML attribute pairs within a tag.
+ * Allows some... latitude. Based on,
+ * https://www.w3.org/TR/html5/syntax.html#before-attribute-value-state
+ * Used in Sanitizer::fixTagAttributes and Sanitizer::decodeTagAttributes
+ * @return string
+ */
+ static function getAttribsRegex() {
+ if ( self::$attribsRegex === null ) {
+ $attribFirst = "[:_\p{L}\p{N}]";
+ $attrib = "[:_\.\-\p{L}\p{N}]";
+ $space = '[\x09\x0a\x0c\x0d\x20]';
+ self::$attribsRegex =
+ "/(?:^|$space)({$attribFirst}{$attrib}*)
+ ($space*=$space*
+ (?:
+ # The attribute value: quoted or alone
+ \"([^\"]*)(?:\"|\$)
+ | '([^']*)(?:'|\$)
+ | (((?!$space|>).)*)
+ )
+ )?(?=$space|\$)/sxu";
+ }
+ return self::$attribsRegex;
+ }
+
+ /**
+ * Return the various lists of recognized tags
+ * @param array $extratags For any extra tags to include
+ * @param array $removetags For any tags (default or extra) to exclude
+ * @return array
+ */
+ public static function getRecognizedTagData( $extratags = [], $removetags = [] ) {
+ global $wgAllowImageTag;
+
+ static $htmlpairsStatic, $htmlsingle, $htmlsingleonly, $htmlnest, $tabletags,
+ $htmllist, $listtags, $htmlsingleallowed, $htmlelementsStatic, $staticInitialised;
+
+ // Base our staticInitialised variable off of the global config state so that if the globals
+ // are changed (like in the screwed up test system) we will re-initialise the settings.
+ $globalContext = $wgAllowImageTag;
+ if ( !$staticInitialised || $staticInitialised != $globalContext ) {
+ $htmlpairsStatic = [ # Tags that must be closed
+ 'b', 'bdi', 'del', 'i', 'ins', 'u', 'font', 'big', 'small', 'sub', 'sup', 'h1',
+ 'h2', 'h3', 'h4', 'h5', 'h6', 'cite', 'code', 'em', 's',
+ 'strike', 'strong', 'tt', 'var', 'div', 'center',
+ 'blockquote', 'ol', 'ul', 'dl', 'table', 'caption', 'pre',
+ 'ruby', 'rb', 'rp', 'rt', 'rtc', 'p', 'span', 'abbr', 'dfn',
+ 'kbd', 'samp', 'data', 'time', 'mark'
+ ];
+ $htmlsingle = [
+ 'br', 'wbr', 'hr', 'li', 'dt', 'dd', 'meta', 'link'
+ ];
+
+ # Elements that cannot have close tags. This is (not coincidentally)
+ # also the list of tags for which the HTML 5 parsing algorithm
+ # requires you to "acknowledge the token's self-closing flag", i.e.
+ # a self-closing tag like <br/> is not an HTML 5 parse error only
+ # for this list.
+ $htmlsingleonly = [
+ 'br', 'wbr', 'hr', 'meta', 'link'
+ ];
+
+ $htmlnest = [ # Tags that can be nested--??
+ 'table', 'tr', 'td', 'th', 'div', 'blockquote', 'ol', 'ul',
+ 'li', 'dl', 'dt', 'dd', 'font', 'big', 'small', 'sub', 'sup', 'span',
+ 'var', 'kbd', 'samp', 'em', 'strong', 'q', 'ruby', 'bdo'
+ ];
+ $tabletags = [ # Can only appear inside table, we will close them
+ 'td', 'th', 'tr',
+ ];
+ $htmllist = [ # Tags used by list
+ 'ul', 'ol',
+ ];
+ $listtags = [ # Tags that can appear in a list
+ 'li',
+ ];
+
+ if ( $wgAllowImageTag ) {
+ $htmlsingle[] = 'img';
+ $htmlsingleonly[] = 'img';
+ }
+
+ $htmlsingleallowed = array_unique( array_merge( $htmlsingle, $tabletags ) );
+ $htmlelementsStatic = array_unique( array_merge( $htmlsingle, $htmlpairsStatic, $htmlnest ) );
+
+ # Convert them all to hashtables for faster lookup
+ $vars = [ 'htmlpairsStatic', 'htmlsingle', 'htmlsingleonly', 'htmlnest', 'tabletags',
+ 'htmllist', 'listtags', 'htmlsingleallowed', 'htmlelementsStatic' ];
+ foreach ( $vars as $var ) {
+ $$var = array_flip( $$var );
+ }
+ $staticInitialised = $globalContext;
+ }
+
+ # Populate $htmlpairs and $htmlelements with the $extratags and $removetags arrays
+ $extratags = array_flip( $extratags );
+ $removetags = array_flip( $removetags );
+ $htmlpairs = array_merge( $extratags, $htmlpairsStatic );
+ $htmlelements = array_diff_key( array_merge( $extratags, $htmlelementsStatic ), $removetags );
+
+ return [
+ 'htmlpairs' => $htmlpairs,
+ 'htmlsingle' => $htmlsingle,
+ 'htmlsingleonly' => $htmlsingleonly,
+ 'htmlnest' => $htmlnest,
+ 'tabletags' => $tabletags,
+ 'htmllist' => $htmllist,
+ 'listtags' => $listtags,
+ 'htmlsingleallowed' => $htmlsingleallowed,
+ 'htmlelements' => $htmlelements,
+ ];
+ }
+
+ /**
+ * Cleans up HTML, removes dangerous tags and attributes, and
+ * removes HTML comments
+ * @param string $text
+ * @param callable $processCallback Callback to do any variable or parameter
+ * replacements in HTML attribute values
+ * @param array|bool $args Arguments for the processing callback
+ * @param array $extratags For any extra tags to include
+ * @param array $removetags For any tags (default or extra) to exclude
+ * @param callable $warnCallback (Deprecated) Callback allowing the
+ * addition of a tracking category when bad input is encountered.
+ * DO NOT ADD NEW PARAMETERS AFTER $warnCallback, since it will be
+ * removed shortly.
+ * @return string
+ */
+ public static function removeHTMLtags( $text, $processCallback = null,
+ $args = [], $extratags = [], $removetags = [], $warnCallback = null
+ ) {
+ extract( self::getRecognizedTagData( $extratags, $removetags ) );
+
+ # Remove HTML comments
+ $text = self::removeHTMLcomments( $text );
+ $bits = explode( '<', $text );
+ $text = str_replace( '>', '&gt;', array_shift( $bits ) );
+ if ( !MWTidy::isEnabled() ) {
+ $tagstack = $tablestack = [];
+ foreach ( $bits as $x ) {
+ $regs = [];
+ # $slash: Does the current element start with a '/'?
+ # $t: Current element name
+ # $params: String between element name and >
+ # $brace: Ending '>' or '/>'
+ # $rest: Everything until the next element of $bits
+ if ( preg_match( self::ELEMENT_BITS_REGEX, $x, $regs ) ) {
+ list( /* $qbar */, $slash, $t, $params, $brace, $rest ) = $regs;
+ } else {
+ $slash = $t = $params = $brace = $rest = null;
+ }
+
+ $badtag = false;
+ $t = strtolower( $t );
+ if ( isset( $htmlelements[$t] ) ) {
+ # Check our stack
+ if ( $slash && isset( $htmlsingleonly[$t] ) ) {
+ $badtag = true;
+ } elseif ( $slash ) {
+ # Closing a tag... is it the one we just opened?
+ MediaWiki\suppressWarnings();
+ $ot = array_pop( $tagstack );
+ MediaWiki\restoreWarnings();
+
+ if ( $ot != $t ) {
+ if ( isset( $htmlsingleallowed[$ot] ) ) {
+ # Pop all elements with an optional close tag
+ # and see if we find a match below them
+ $optstack = [];
+ array_push( $optstack, $ot );
+ MediaWiki\suppressWarnings();
+ $ot = array_pop( $tagstack );
+ MediaWiki\restoreWarnings();
+ while ( $ot != $t && isset( $htmlsingleallowed[$ot] ) ) {
+ array_push( $optstack, $ot );
+ MediaWiki\suppressWarnings();
+ $ot = array_pop( $tagstack );
+ MediaWiki\restoreWarnings();
+ }
+ if ( $t != $ot ) {
+ # No match. Push the optional elements back again
+ $badtag = true;
+ MediaWiki\suppressWarnings();
+ $ot = array_pop( $optstack );
+ MediaWiki\restoreWarnings();
+ while ( $ot ) {
+ array_push( $tagstack, $ot );
+ MediaWiki\suppressWarnings();
+ $ot = array_pop( $optstack );
+ MediaWiki\restoreWarnings();
+ }
+ }
+ } else {
+ MediaWiki\suppressWarnings();
+ array_push( $tagstack, $ot );
+ MediaWiki\restoreWarnings();
+
+ # <li> can be nested in <ul> or <ol>, skip those cases:
+ if ( !isset( $htmllist[$ot] ) || !isset( $listtags[$t] ) ) {
+ $badtag = true;
+ }
+ }
+ } else {
+ if ( $t == 'table' ) {
+ $tagstack = array_pop( $tablestack );
+ }
+ }
+ $newparams = '';
+ } else {
+ # Keep track for later
+ if ( isset( $tabletags[$t] ) && !in_array( 'table', $tagstack ) ) {
+ $badtag = true;
+ } elseif ( in_array( $t, $tagstack ) && !isset( $htmlnest[$t] ) ) {
+ $badtag = true;
+ #  Is it a self closed htmlpair ? (T7487)
+ } elseif ( $brace == '/>' && isset( $htmlpairs[$t] ) ) {
+ // Eventually we'll just remove the self-closing
+ // slash, in order to be consistent with HTML5
+ // semantics.
+ // $brace = '>';
+ // For now, let's just warn authors to clean up.
+ if ( is_callable( $warnCallback ) ) {
+ call_user_func_array( $warnCallback, [ 'deprecated-self-close-category' ] );
+ }
+ $badtag = true;
+ } elseif ( isset( $htmlsingleonly[$t] ) ) {
+ # Hack to force empty tag for unclosable elements
+ $brace = '/>';
+ } elseif ( isset( $htmlsingle[$t] ) ) {
+ # Hack to not close $htmlsingle tags
+ $brace = null;
+ # Still need to push this optionally-closed tag to
+ # the tag stack so that we can match end tags
+ # instead of marking them as bad.
+ array_push( $tagstack, $t );
+ } elseif ( isset( $tabletags[$t] ) && in_array( $t, $tagstack ) ) {
+ // New table tag but forgot to close the previous one
+ $text .= "</$t>";
+ } else {
+ if ( $t == 'table' ) {
+ array_push( $tablestack, $tagstack );
+ $tagstack = [];
+ }
+ array_push( $tagstack, $t );
+ }
+
+ # Replace any variables or template parameters with
+ # plaintext results.
+ if ( is_callable( $processCallback ) ) {
+ call_user_func_array( $processCallback, [ &$params, $args ] );
+ }
+
+ if ( !self::validateTag( $params, $t ) ) {
+ $badtag = true;
+ }
+
+ # Strip non-approved attributes from the tag
+ $newparams = self::fixTagAttributes( $params, $t );
+ }
+ if ( !$badtag ) {
+ $rest = str_replace( '>', '&gt;', $rest );
+ $close = ( $brace == '/>' && !$slash ) ? ' /' : '';
+ $text .= "<$slash$t$newparams$close>$rest";
+ continue;
+ }
+ }
+ $text .= '&lt;' . str_replace( '>', '&gt;', $x );
+ }
+ # Close off any remaining tags
+ while ( is_array( $tagstack ) && ( $t = array_pop( $tagstack ) ) ) {
+ $text .= "</$t>\n";
+ if ( $t == 'table' ) {
+ $tagstack = array_pop( $tablestack );
+ }
+ }
+ } else {
+ # this might be possible using tidy itself
+ foreach ( $bits as $x ) {
+ if ( preg_match( self::ELEMENT_BITS_REGEX, $x, $regs ) ) {
+ list( /* $qbar */, $slash, $t, $params, $brace, $rest ) = $regs;
+
+ $badtag = false;
+ $t = strtolower( $t );
+ if ( isset( $htmlelements[$t] ) ) {
+ if ( is_callable( $processCallback ) ) {
+ call_user_func_array( $processCallback, [ &$params, $args ] );
+ }
+
+ if ( $brace == '/>' && !( isset( $htmlsingle[$t] ) || isset( $htmlsingleonly[$t] ) ) ) {
+ // Eventually we'll just remove the self-closing
+ // slash, in order to be consistent with HTML5
+ // semantics.
+ // $brace = '>';
+ // For now, let's just warn authors to clean up.
+ if ( is_callable( $warnCallback ) ) {
+ call_user_func_array( $warnCallback, [ 'deprecated-self-close-category' ] );
+ }
+ }
+ if ( !self::validateTag( $params, $t ) ) {
+ $badtag = true;
+ }
+
+ $newparams = self::fixTagAttributes( $params, $t );
+ if ( !$badtag ) {
+ if ( $brace === '/>' && !isset( $htmlsingleonly[$t] ) ) {
+ # Interpret self-closing tags as empty tags even when
+ # HTML 5 would interpret them as start tags. Such input
+ # is commonly seen on Wikimedia wikis with this intention.
+ $brace = "></$t>";
+ }
+
+ $rest = str_replace( '>', '&gt;', $rest );
+ $text .= "<$slash$t$newparams$brace$rest";
+ continue;
+ }
+ }
+ }
+ $text .= '&lt;' . str_replace( '>', '&gt;', $x );
+ }
+ }
+ return $text;
+ }
+
+ /**
+ * Remove '<!--', '-->', and everything between.
+ * To avoid leaving blank lines, when a comment is both preceded
+ * and followed by a newline (ignoring spaces), trim leading and
+ * trailing spaces and one of the newlines.
+ *
+ * @param string $text
+ * @return string
+ */
+ public static function removeHTMLcomments( $text ) {
+ while ( ( $start = strpos( $text, '<!--' ) ) !== false ) {
+ $end = strpos( $text, '-->', $start + 4 );
+ if ( $end === false ) {
+ # Unterminated comment; bail out
+ break;
+ }
+
+ $end += 3;
+
+ # Trim space and newline if the comment is both
+ # preceded and followed by a newline
+ $spaceStart = max( $start - 1, 0 );
+ $spaceLen = $end - $spaceStart;
+ while ( substr( $text, $spaceStart, 1 ) === ' ' && $spaceStart > 0 ) {
+ $spaceStart--;
+ $spaceLen++;
+ }
+ while ( substr( $text, $spaceStart + $spaceLen, 1 ) === ' ' ) {
+ $spaceLen++;
+ }
+ if ( substr( $text, $spaceStart, 1 ) === "\n"
+ && substr( $text, $spaceStart + $spaceLen, 1 ) === "\n" ) {
+ # Remove the comment, leading and trailing
+ # spaces, and leave only one newline.
+ $text = substr_replace( $text, "\n", $spaceStart, $spaceLen + 1 );
+ } else {
+ # Remove just the comment.
+ $text = substr_replace( $text, '', $start, $end - $start );
+ }
+ }
+ return $text;
+ }
+
+ /**
+ * Takes attribute names and values for a tag and the tag name and
+ * validates that the tag is allowed to be present.
+ * This DOES NOT validate the attributes, nor does it validate the
+ * tags themselves. This method only handles the special circumstances
+ * where we may want to allow a tag within content but ONLY when it has
+ * specific attributes set.
+ *
+ * @param string $params
+ * @param string $element
+ * @return bool
+ */
+ static function validateTag( $params, $element ) {
+ $params = self::decodeTagAttributes( $params );
+
+ if ( $element == 'meta' || $element == 'link' ) {
+ if ( !isset( $params['itemprop'] ) ) {
+ // <meta> and <link> must have an itemprop="" otherwise they are not valid or safe in content
+ return false;
+ }
+ if ( $element == 'meta' && !isset( $params['content'] ) ) {
+ // <meta> must have a content="" for the itemprop
+ return false;
+ }
+ if ( $element == 'link' && !isset( $params['href'] ) ) {
+ // <link> must have an associated href=""
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Take an array of attribute names and values and normalize or discard
+ * illegal values for the given element type.
+ *
+ * - Discards attributes not on a whitelist for the given element
+ * - Unsafe style attributes are discarded
+ * - Invalid id attributes are re-encoded
+ *
+ * @param array $attribs
+ * @param string $element
+ * @return array
+ *
+ * @todo Check for legal values where the DTD limits things.
+ * @todo Check for unique id attribute :P
+ */
+ static function validateTagAttributes( $attribs, $element ) {
+ return self::validateAttributes( $attribs,
+ self::attributeWhitelist( $element ) );
+ }
+
+ /**
+ * Take an array of attribute names and values and normalize or discard
+ * illegal values for the given whitelist.
+ *
+ * - Discards attributes not on the given whitelist
+ * - Unsafe style attributes are discarded
+ * - Invalid id attributes are re-encoded
+ *
+ * @param array $attribs
+ * @param array $whitelist List of allowed attribute names
+ * @return array
+ *
+ * @todo Check for legal values where the DTD limits things.
+ * @todo Check for unique id attribute :P
+ */
+ static function validateAttributes( $attribs, $whitelist ) {
+ $whitelist = array_flip( $whitelist );
+ $hrefExp = '/^(' . wfUrlProtocols() . ')[^\s]+$/';
+
+ $out = [];
+ foreach ( $attribs as $attribute => $value ) {
+ # Allow XML namespace declaration to allow RDFa
+ if ( preg_match( self::XMLNS_ATTRIBUTE_PATTERN, $attribute ) ) {
+ if ( !preg_match( self::EVIL_URI_PATTERN, $value ) ) {
+ $out[$attribute] = $value;
+ }
+
+ continue;
+ }
+
+ # Allow any attribute beginning with "data-"
+ # However:
+ # * Disallow data attributes used by MediaWiki code
+ # * Ensure that the attribute is not namespaced by banning
+ # colons.
+ if ( !preg_match( '/^data-[^:]*$/i', $attribute )
+ && !isset( $whitelist[$attribute] )
+ || self::isReservedDataAttribute( $attribute )
+ ) {
+ continue;
+ }
+
+ # Strip javascript "expression" from stylesheets.
+ # https://msdn.microsoft.com/en-us/library/ms537634.aspx
+ if ( $attribute == 'style' ) {
+ $value = self::checkCss( $value );
+ }
+
+ # Escape HTML id attributes
+ if ( $attribute === 'id' ) {
+ $value = self::escapeIdForAttribute( $value, self::ID_PRIMARY );
+ }
+
+ # Escape HTML id reference lists
+ if ( $attribute === 'aria-describedby'
+ || $attribute === 'aria-flowto'
+ || $attribute === 'aria-labelledby'
+ || $attribute === 'aria-owns'
+ ) {
+ $value = self::escapeIdReferenceList( $value, 'noninitial' );
+ }
+
+ // RDFa and microdata properties allow URLs, URIs and/or CURIs.
+ // Check them for sanity.
+ if ( $attribute === 'rel' || $attribute === 'rev'
+ # RDFa
+ || $attribute === 'about' || $attribute === 'property'
+ || $attribute === 'resource' || $attribute === 'datatype'
+ || $attribute === 'typeof'
+ # HTML5 microdata
+ || $attribute === 'itemid' || $attribute === 'itemprop'
+ || $attribute === 'itemref' || $attribute === 'itemscope'
+ || $attribute === 'itemtype'
+ ) {
+ // Paranoia. Allow "simple" values but suppress javascript
+ if ( preg_match( self::EVIL_URI_PATTERN, $value ) ) {
+ continue;
+ }
+ }
+
+ # NOTE: even though elements using href/src are not allowed directly, supply
+ # validation code that can be used by tag hook handlers, etc
+ if ( $attribute === 'href' || $attribute === 'src' || $attribute === 'poster' ) {
+ if ( !preg_match( $hrefExp, $value ) ) {
+ continue; // drop any href or src attributes not using an allowed protocol.
+ // NOTE: this also drops all relative URLs
+ }
+ }
+
+ // If this attribute was previously set, override it.
+ // Output should only have one attribute of each name.
+ $out[$attribute] = $value;
+ }
+
+ # itemtype, itemid, itemref don't make sense without itemscope
+ if ( !array_key_exists( 'itemscope', $out ) ) {
+ unset( $out['itemtype'] );
+ unset( $out['itemid'] );
+ unset( $out['itemref'] );
+ }
+ # TODO: Strip itemprop if we aren't descendants of an itemscope or pointed to by an itemref.
+
+ return $out;
+ }
+
+ /**
+ * Given an attribute name, checks whether it is a reserved data attribute
+ * (such as data-mw-foo) which is unavailable to user-generated HTML so MediaWiki
+ * core and extension code can safely use it to communicate with frontend code.
+ * @param string $attr Attribute name.
+ * @return bool
+ */
+ public static function isReservedDataAttribute( $attr ) {
+ // data-ooui is reserved for ooui.
+ // data-mw and data-parsoid are reserved for parsoid.
+ // data-mw-<name here> is reserved for extensions (or core) if
+ // they need to communicate some data to the client and want to be
+ // sure that it isn't coming from an untrusted user.
+ // We ignore the possibility of namespaces since user-generated HTML
+ // can't use them anymore.
+ return (bool)preg_match( '/^data-(ooui|mw|parsoid)/i', $attr );
+ }
+
+ /**
+ * Merge two sets of HTML attributes. Conflicting items in the second set
+ * will override those in the first, except for 'class' attributes which
+ * will be combined (if they're both strings).
+ *
+ * @todo implement merging for other attributes such as style
+ * @param array $a
+ * @param array $b
+ * @return array
+ */
+ static function mergeAttributes( $a, $b ) {
+ $out = array_merge( $a, $b );
+ if ( isset( $a['class'] ) && isset( $b['class'] )
+ && is_string( $a['class'] ) && is_string( $b['class'] )
+ && $a['class'] !== $b['class']
+ ) {
+ $classes = preg_split( '/\s+/', "{$a['class']} {$b['class']}",
+ -1, PREG_SPLIT_NO_EMPTY );
+ $out['class'] = implode( ' ', array_unique( $classes ) );
+ }
+ return $out;
+ }
+
+ /**
+ * Normalize CSS into a format we can easily search for hostile input
+ * - decode character references
+ * - decode escape sequences
+ * - convert characters that IE6 interprets into ascii
+ * - remove comments, unless the entire value is one single comment
+ * @param string $value the css string
+ * @return string normalized css
+ */
+ public static function normalizeCss( $value ) {
+ // Decode character references like &#123;
+ $value = self::decodeCharReferences( $value );
+
+ // Decode escape sequences and line continuation
+ // See the grammar in the CSS 2 spec, appendix D.
+ // This has to be done AFTER decoding character references.
+ // This means it isn't possible for this function to return
+ // unsanitized escape sequences. It is possible to manufacture
+ // input that contains character references that decode to
+ // escape sequences that decode to character references, but
+ // it's OK for the return value to contain character references
+ // because the caller is supposed to escape those anyway.
+ static $decodeRegex;
+ if ( !$decodeRegex ) {
+ $space = '[\\x20\\t\\r\\n\\f]';
+ $nl = '(?:\\n|\\r\\n|\\r|\\f)';
+ $backslash = '\\\\';
+ $decodeRegex = "/ $backslash
+ (?:
+ ($nl) | # 1. Line continuation
+ ([0-9A-Fa-f]{1,6})$space? | # 2. character number
+ (.) | # 3. backslash cancelling special meaning
+ () | # 4. backslash at end of string
+ )/xu";
+ }
+ $value = preg_replace_callback( $decodeRegex,
+ [ __CLASS__, 'cssDecodeCallback' ], $value );
+
+ // Normalize Halfwidth and Fullwidth Unicode block that IE6 might treat as ascii
+ $value = preg_replace_callback(
+ '/[!-[]-z]/u', // U+FF01 to U+FF5A, excluding U+FF3C (T60088)
+ function ( $matches ) {
+ $cp = UtfNormal\Utils::utf8ToCodepoint( $matches[0] );
+ if ( $cp === false ) {
+ return '';
+ }
+ return chr( $cp - 65248 ); // ASCII range \x21-\x7A
+ },
+ $value
+ );
+
+ // Convert more characters IE6 might treat as ascii
+ // U+0280, U+0274, U+207F, U+029F, U+026A, U+207D, U+208D
+ $value = str_replace(
+ [ 'ʀ', 'ɴ', 'ⁿ', 'ʟ', 'ɪ', '⁽', '₍' ],
+ [ 'r', 'n', 'n', 'l', 'i', '(', '(' ],
+ $value
+ );
+
+ // Let the value through if it's nothing but a single comment, to
+ // allow other functions which may reject it to pass some error
+ // message through.
+ if ( !preg_match( '! ^ \s* /\* [^*\\/]* \*/ \s* $ !x', $value ) ) {
+ // Remove any comments; IE gets token splitting wrong
+ // This must be done AFTER decoding character references and
+ // escape sequences, because those steps can introduce comments
+ // This step cannot introduce character references or escape
+ // sequences, because it replaces comments with spaces rather
+ // than removing them completely.
+ $value = StringUtils::delimiterReplace( '/*', '*/', ' ', $value );
+
+ // Remove anything after a comment-start token, to guard against
+ // incorrect client implementations.
+ $commentPos = strpos( $value, '/*' );
+ if ( $commentPos !== false ) {
+ $value = substr( $value, 0, $commentPos );
+ }
+ }
+
+ // S followed by repeat, iteration, or prolonged sound marks,
+ // which IE will treat as "ss"
+ $value = preg_replace(
+ '/s(?:
+ \xE3\x80\xB1 | # U+3031
+ \xE3\x82\x9D | # U+309D
+ \xE3\x83\xBC | # U+30FC
+ \xE3\x83\xBD | # U+30FD
+ \xEF\xB9\xBC | # U+FE7C
+ \xEF\xB9\xBD | # U+FE7D
+ \xEF\xBD\xB0 # U+FF70
+ )/ix',
+ 'ss',
+ $value
+ );
+
+ return $value;
+ }
+
+ /**
+ * Pick apart some CSS and check it for forbidden or unsafe structures.
+ * Returns a sanitized string. This sanitized string will have
+ * character references and escape sequences decoded and comments
+ * stripped (unless it is itself one valid comment, in which case the value
+ * will be passed through). If the input is just too evil, only a comment
+ * complaining about evilness will be returned.
+ *
+ * Currently URL references, 'expression', 'tps' are forbidden.
+ *
+ * NOTE: Despite the fact that character references are decoded, the
+ * returned string may contain character references given certain
+ * clever input strings. These character references must
+ * be escaped before the return value is embedded in HTML.
+ *
+ * @param string $value
+ * @return string
+ */
+ static function checkCss( $value ) {
+ $value = self::normalizeCss( $value );
+
+ // Reject problematic keywords and control characters
+ if ( preg_match( '/[\000-\010\013\016-\037\177]/', $value ) ||
+ strpos( $value, UtfNormal\Constants::UTF8_REPLACEMENT ) !== false ) {
+ return '/* invalid control char */';
+ } elseif ( preg_match(
+ '! expression
+ | filter\s*:
+ | accelerator\s*:
+ | -o-link\s*:
+ | -o-link-source\s*:
+ | -o-replace\s*:
+ | url\s*\(
+ | image\s*\(
+ | image-set\s*\(
+ | attr\s*\([^)]+[\s,]+url
+ !ix', $value ) ) {
+ return '/* insecure input */';
+ }
+ return $value;
+ }
+
+ /**
+ * @param array $matches
+ * @return string
+ */
+ static function cssDecodeCallback( $matches ) {
+ if ( $matches[1] !== '' ) {
+ // Line continuation
+ return '';
+ } elseif ( $matches[2] !== '' ) {
+ $char = UtfNormal\Utils::codepointToUtf8( hexdec( $matches[2] ) );
+ } elseif ( $matches[3] !== '' ) {
+ $char = $matches[3];
+ } else {
+ $char = '\\';
+ }
+ if ( $char == "\n" || $char == '"' || $char == "'" || $char == '\\' ) {
+ // These characters need to be escaped in strings
+ // Clean up the escape sequence to avoid parsing errors by clients
+ return '\\' . dechex( ord( $char ) ) . ' ';
+ } else {
+ // Decode unnecessary escape
+ return $char;
+ }
+ }
+
+ /**
+ * Take a tag soup fragment listing an HTML element's attributes
+ * and normalize it to well-formed XML, discarding unwanted attributes.
+ * Output is safe for further wikitext processing, with escaping of
+ * values that could trigger problems.
+ *
+ * - Normalizes attribute names to lowercase
+ * - Discards attributes not on a whitelist for the given element
+ * - Turns broken or invalid entities into plaintext
+ * - Double-quotes all attribute values
+ * - Attributes without values are given the name as attribute
+ * - Double attributes are discarded
+ * - Unsafe style attributes are discarded
+ * - Prepends space if there are attributes.
+ * - (Optionally) Sorts attributes by name.
+ *
+ * @param string $text
+ * @param string $element
+ * @param bool $sorted Whether to sort the attributes (default: false)
+ * @return string
+ */
+ static function fixTagAttributes( $text, $element, $sorted = false ) {
+ if ( trim( $text ) == '' ) {
+ return '';
+ }
+
+ $decoded = self::decodeTagAttributes( $text );
+ $stripped = self::validateTagAttributes( $decoded, $element );
+
+ if ( $sorted ) {
+ ksort( $stripped );
+ }
+
+ return self::safeEncodeTagAttributes( $stripped );
+ }
+
+ /**
+ * Encode an attribute value for HTML output.
+ * @param string $text
+ * @return string HTML-encoded text fragment
+ */
+ static function encodeAttribute( $text ) {
+ $encValue = htmlspecialchars( $text, ENT_QUOTES );
+
+ // Whitespace is normalized during attribute decoding,
+ // so if we've been passed non-spaces we must encode them
+ // ahead of time or they won't be preserved.
+ $encValue = strtr( $encValue, [
+ "\n" => '&#10;',
+ "\r" => '&#13;',
+ "\t" => '&#9;',
+ ] );
+
+ return $encValue;
+ }
+
+ /**
+ * Encode an attribute value for HTML tags, with extra armoring
+ * against further wiki processing.
+ * @param string $text
+ * @return string HTML-encoded text fragment
+ */
+ static function safeEncodeAttribute( $text ) {
+ $encValue = self::encodeAttribute( $text );
+
+ # Templates and links may be expanded in later parsing,
+ # creating invalid or dangerous output. Suppress this.
+ $encValue = strtr( $encValue, [
+ '<' => '&lt;', // This should never happen,
+ '>' => '&gt;', // we've received invalid input
+ '"' => '&quot;', // which should have been escaped.
+ '{' => '&#123;',
+ '}' => '&#125;', // prevent unpaired language conversion syntax
+ '[' => '&#91;',
+ "''" => '&#39;&#39;',
+ 'ISBN' => '&#73;SBN',
+ 'RFC' => '&#82;FC',
+ 'PMID' => '&#80;MID',
+ '|' => '&#124;',
+ '__' => '&#95;_',
+ ] );
+
+ # Stupid hack
+ $encValue = preg_replace_callback(
+ '/((?i)' . wfUrlProtocols() . ')/',
+ [ 'Sanitizer', 'armorLinksCallback' ],
+ $encValue );
+ return $encValue;
+ }
+
+ /**
+ * Given a value, escape it so that it can be used in an id attribute and
+ * return it. This will use HTML5 validation if $wgExperimentalHtmlIds is
+ * true, allowing anything but ASCII whitespace. Otherwise it will use
+ * HTML 4 rules, which means a narrow subset of ASCII, with bad characters
+ * escaped with lots of dots.
+ *
+ * To ensure we don't have to bother escaping anything, we also strip ', ",
+ * & even if $wgExperimentalIds is true. TODO: Is this the best tactic?
+ * We also strip # because it upsets IE, and % because it could be
+ * ambiguous if it's part of something that looks like a percent escape
+ * (which don't work reliably in fragments cross-browser).
+ *
+ * @deprecated since 1.30, use one of this class' escapeIdFor*() functions
+ *
+ * @see https://www.w3.org/TR/html401/types.html#type-name Valid characters
+ * in the id and name attributes
+ * @see https://www.w3.org/TR/html401/struct/links.html#h-12.2.3 Anchors with
+ * the id attribute
+ * @see https://www.w3.org/TR/html5/dom.html#the-id-attribute
+ * HTML5 definition of id attribute
+ *
+ * @param string $id Id to escape
+ * @param string|array $options String or array of strings (default is array()):
+ * 'noninitial': This is a non-initial fragment of an id, not a full id,
+ * so don't pay attention if the first character isn't valid at the
+ * beginning of an id. Only matters if $wgExperimentalHtmlIds is
+ * false.
+ * 'legacy': Behave the way the old HTML 4-based ID escaping worked even
+ * if $wgExperimentalHtmlIds is used, so we can generate extra
+ * anchors and links won't break.
+ * @return string
+ */
+ static function escapeId( $id, $options = [] ) {
+ global $wgExperimentalHtmlIds;
+ $options = (array)$options;
+
+ if ( $wgExperimentalHtmlIds && !in_array( 'legacy', $options ) ) {
+ $id = preg_replace( '/[ \t\n\r\f_\'"&#%]+/', '_', $id );
+ $id = trim( $id, '_' );
+ if ( $id === '' ) {
+ // Must have been all whitespace to start with.
+ return '_';
+ } else {
+ return $id;
+ }
+ }
+
+ // HTML4-style escaping
+ static $replace = [
+ '%3A' => ':',
+ '%' => '.'
+ ];
+
+ $id = urlencode( strtr( $id, ' ', '_' ) );
+ $id = strtr( $id, $replace );
+
+ if ( !preg_match( '/^[a-zA-Z]/', $id ) && !in_array( 'noninitial', $options ) ) {
+ // Initial character must be a letter!
+ $id = "x$id";
+ }
+ return $id;
+ }
+
+ /**
+ * Given a section name or other user-generated or otherwise unsafe string, escapes it to be
+ * a valid HTML id attribute.
+ *
+ * WARNING: unlike escapeId(), the output of this function is not guaranteed to be HTML safe,
+ * be sure to use proper escaping.
+ *
+ * @param string $id String to escape
+ * @param int $mode One of ID_* constants, specifying whether the primary or fallback encoding
+ * should be used.
+ * @return string|bool Escaped ID or false if fallback encoding is requested but it's not
+ * configured.
+ *
+ * @since 1.30
+ */
+ public static function escapeIdForAttribute( $id, $mode = self::ID_PRIMARY ) {
+ global $wgFragmentMode;
+
+ if ( !isset( $wgFragmentMode[$mode] ) ) {
+ if ( $mode === self::ID_PRIMARY ) {
+ throw new UnexpectedValueException( '$wgFragmentMode is configured with no primary mode' );
+ }
+ return false;
+ }
+
+ $internalMode = $wgFragmentMode[$mode];
+
+ return self::escapeIdInternal( $id, $internalMode );
+ }
+
+ /**
+ * Given a section name or other user-generated or otherwise unsafe string, escapes it to be
+ * a valid URL fragment.
+ *
+ * WARNING: unlike escapeId(), the output of this function is not guaranteed to be HTML safe,
+ * be sure to use proper escaping.
+ *
+ * @param string $id String to escape
+ * @return string Escaped ID
+ *
+ * @since 1.30
+ */
+ public static function escapeIdForLink( $id ) {
+ global $wgFragmentMode;
+
+ if ( !isset( $wgFragmentMode[self::ID_PRIMARY] ) ) {
+ throw new UnexpectedValueException( '$wgFragmentMode is configured with no primary mode' );
+ }
+
+ $mode = $wgFragmentMode[self::ID_PRIMARY];
+
+ $id = self::escapeIdInternal( $id, $mode );
+
+ return $id;
+ }
+
+ /**
+ * Given a section name or other user-generated or otherwise unsafe string, escapes it to be
+ * a valid URL fragment for external interwikis.
+ *
+ * @param string $id String to escape
+ * @return string Escaped ID
+ *
+ * @since 1.30
+ */
+ public static function escapeIdForExternalInterwiki( $id ) {
+ global $wgExternalInterwikiFragmentMode;
+
+ $id = self::escapeIdInternal( $id, $wgExternalInterwikiFragmentMode );
+
+ return $id;
+ }
+
+ /**
+ * Helper for escapeIdFor*() functions. Performs most of the actual escaping.
+ *
+ * @param string $id String to escape
+ * @param string $mode One of modes from $wgFragmentMode
+ * @return string
+ */
+ private static function escapeIdInternal( $id, $mode ) {
+ switch ( $mode ) {
+ case 'html5':
+ $id = str_replace( ' ', '_', $id );
+ break;
+ case 'legacy':
+ // This corresponds to 'noninitial' mode of the old escapeId()
+ static $replace = [
+ '%3A' => ':',
+ '%' => '.'
+ ];
+
+ $id = urlencode( str_replace( ' ', '_', $id ) );
+ $id = strtr( $id, $replace );
+ break;
+ case 'html5-legacy':
+ $id = preg_replace( '/[ \t\n\r\f_\'"&#%]+/', '_', $id );
+ $id = trim( $id, '_' );
+ if ( $id === '' ) {
+ // Must have been all whitespace to start with.
+ $id = '_';
+ }
+ break;
+ default:
+ throw new InvalidArgumentException( "Invalid mode '$mode' passed to '" . __METHOD__ );
+ }
+
+ return $id;
+ }
+
+ /**
+ * Given a string containing a space delimited list of ids, escape each id
+ * to match ids escaped by the escapeId() function.
+ *
+ * @todo wfDeprecated() uses of $options in 1.31, remove completely in 1.32
+ *
+ * @since 1.27
+ *
+ * @param string $referenceString Space delimited list of ids
+ * @param string|array $options Deprecated and does nothing.
+ * @return string
+ */
+ static function escapeIdReferenceList( $referenceString, $options = [] ) {
+ # Explode the space delimited list string into an array of tokens
+ $references = preg_split( '/\s+/', "{$referenceString}", -1, PREG_SPLIT_NO_EMPTY );
+
+ # Escape each token as an id
+ foreach ( $references as &$ref ) {
+ $ref = self::escapeIdForAttribute( $ref );
+ }
+
+ # Merge the array back to a space delimited list string
+ # If the array is empty, the result will be an empty string ('')
+ $referenceString = implode( ' ', $references );
+
+ return $referenceString;
+ }
+
+ /**
+ * Given a value, escape it so that it can be used as a CSS class and
+ * return it.
+ *
+ * @todo For extra validity, input should be validated UTF-8.
+ *
+ * @see https://www.w3.org/TR/CSS21/syndata.html Valid characters/format
+ *
+ * @param string $class
+ * @return string
+ */
+ static function escapeClass( $class ) {
+ // Convert ugly stuff to underscores and kill underscores in ugly places
+ return rtrim( preg_replace(
+ [ '/(^[0-9\\-])|[\\x00-\\x20!"#$%&\'()*+,.\\/:;<=>?@[\\]^`{|}~]|\\xC2\\xA0/', '/_+/' ],
+ '_',
+ $class ), '_' );
+ }
+
+ /**
+ * Given HTML input, escape with htmlspecialchars but un-escape entities.
+ * This allows (generally harmless) entities like &#160; to survive.
+ *
+ * @param string $html HTML to escape
+ * @return string Escaped input
+ */
+ static function escapeHtmlAllowEntities( $html ) {
+ $html = self::decodeCharReferences( $html );
+ # It seems wise to escape ' as well as ", as a matter of course. Can't
+ # hurt. Use ENT_SUBSTITUTE so that incorrectly truncated multibyte characters
+ # don't cause the entire string to disappear.
+ $html = htmlspecialchars( $html, ENT_QUOTES | ENT_SUBSTITUTE );
+ return $html;
+ }
+
+ /**
+ * Regex replace callback for armoring links against further processing.
+ * @param array $matches
+ * @return string
+ */
+ private static function armorLinksCallback( $matches ) {
+ return str_replace( ':', '&#58;', $matches[1] );
+ }
+
+ /**
+ * Return an associative array of attribute names and values from
+ * a partial tag string. Attribute names are forced to lowercase,
+ * character references are decoded to UTF-8 text.
+ *
+ * @param string $text
+ * @return array
+ */
+ public static function decodeTagAttributes( $text ) {
+ if ( trim( $text ) == '' ) {
+ return [];
+ }
+
+ $attribs = [];
+ $pairs = [];
+ if ( !preg_match_all(
+ self::getAttribsRegex(),
+ $text,
+ $pairs,
+ PREG_SET_ORDER ) ) {
+ return $attribs;
+ }
+
+ foreach ( $pairs as $set ) {
+ $attribute = strtolower( $set[1] );
+ $value = self::getTagAttributeCallback( $set );
+
+ // Normalize whitespace
+ $value = preg_replace( '/[\t\r\n ]+/', ' ', $value );
+ $value = trim( $value );
+
+ // Decode character references
+ $attribs[$attribute] = self::decodeCharReferences( $value );
+ }
+ return $attribs;
+ }
+
+ /**
+ * Build a partial tag string from an associative array of attribute
+ * names and values as returned by decodeTagAttributes.
+ *
+ * @param array $assoc_array
+ * @return string
+ */
+ public static function safeEncodeTagAttributes( $assoc_array ) {
+ $attribs = [];
+ foreach ( $assoc_array as $attribute => $value ) {
+ $encAttribute = htmlspecialchars( $attribute );
+ $encValue = self::safeEncodeAttribute( $value );
+
+ $attribs[] = "$encAttribute=\"$encValue\"";
+ }
+ return count( $attribs ) ? ' ' . implode( ' ', $attribs ) : '';
+ }
+
+ /**
+ * Pick the appropriate attribute value from a match set from the
+ * attribs regex matches.
+ *
+ * @param array $set
+ * @throws MWException When tag conditions are not met.
+ * @return string
+ */
+ private static function getTagAttributeCallback( $set ) {
+ if ( isset( $set[5] ) ) {
+ # No quotes.
+ return $set[5];
+ } elseif ( isset( $set[4] ) ) {
+ # Single-quoted
+ return $set[4];
+ } elseif ( isset( $set[3] ) ) {
+ # Double-quoted
+ return $set[3];
+ } elseif ( !isset( $set[2] ) ) {
+ # In XHTML, attributes must have a value so return an empty string.
+ # See "Empty attribute syntax",
+ # https://www.w3.org/TR/html5/syntax.html#syntax-attribute-name
+ return "";
+ } else {
+ throw new MWException( "Tag conditions not met. This should never happen and is a bug." );
+ }
+ }
+
+ /**
+ * @param string $text
+ * @return string
+ */
+ private static function normalizeWhitespace( $text ) {
+ return preg_replace(
+ '/\r\n|[\x20\x0d\x0a\x09]/',
+ ' ',
+ $text );
+ }
+
+ /**
+ * Normalizes whitespace in a section name, such as might be returned
+ * by Parser::stripSectionName(), for use in the id's that are used for
+ * section links.
+ *
+ * @param string $section
+ * @return string
+ */
+ static function normalizeSectionNameWhitespace( $section ) {
+ return trim( preg_replace( '/[ _]+/', ' ', $section ) );
+ }
+
+ /**
+ * Ensure that any entities and character references are legal
+ * for XML and XHTML specifically. Any stray bits will be
+ * &amp;-escaped to result in a valid text fragment.
+ *
+ * a. named char refs can only be &lt; &gt; &amp; &quot;, others are
+ * numericized (this way we're well-formed even without a DTD)
+ * b. any numeric char refs must be legal chars, not invalid or forbidden
+ * c. use lower cased "&#x", not "&#X"
+ * d. fix or reject non-valid attributes
+ *
+ * @param string $text
+ * @return string
+ * @private
+ */
+ static function normalizeCharReferences( $text ) {
+ return preg_replace_callback(
+ self::CHAR_REFS_REGEX,
+ [ 'Sanitizer', 'normalizeCharReferencesCallback' ],
+ $text );
+ }
+
+ /**
+ * @param string $matches
+ * @return string
+ */
+ static function normalizeCharReferencesCallback( $matches ) {
+ $ret = null;
+ if ( $matches[1] != '' ) {
+ $ret = self::normalizeEntity( $matches[1] );
+ } elseif ( $matches[2] != '' ) {
+ $ret = self::decCharReference( $matches[2] );
+ } elseif ( $matches[3] != '' ) {
+ $ret = self::hexCharReference( $matches[3] );
+ }
+ if ( is_null( $ret ) ) {
+ return htmlspecialchars( $matches[0] );
+ } else {
+ return $ret;
+ }
+ }
+
+ /**
+ * If the named entity is defined in the HTML 4.0/XHTML 1.0 DTD,
+ * return the equivalent numeric entity reference (except for the core &lt;
+ * &gt; &amp; &quot;). If the entity is a MediaWiki-specific alias, returns
+ * the HTML equivalent. Otherwise, returns HTML-escaped text of
+ * pseudo-entity source (eg &amp;foo;)
+ *
+ * @param string $name
+ * @return string
+ */
+ static function normalizeEntity( $name ) {
+ if ( isset( self::$htmlEntityAliases[$name] ) ) {
+ return '&' . self::$htmlEntityAliases[$name] . ';';
+ } elseif ( in_array( $name, [ 'lt', 'gt', 'amp', 'quot' ] ) ) {
+ return "&$name;";
+ } elseif ( isset( self::$htmlEntities[$name] ) ) {
+ return '&#' . self::$htmlEntities[$name] . ';';
+ } else {
+ return "&amp;$name;";
+ }
+ }
+
+ /**
+ * @param int $codepoint
+ * @return null|string
+ */
+ static function decCharReference( $codepoint ) {
+ $point = intval( $codepoint );
+ if ( self::validateCodepoint( $point ) ) {
+ return sprintf( '&#%d;', $point );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param int $codepoint
+ * @return null|string
+ */
+ static function hexCharReference( $codepoint ) {
+ $point = hexdec( $codepoint );
+ if ( self::validateCodepoint( $point ) ) {
+ return sprintf( '&#x%x;', $point );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns true if a given Unicode codepoint is a valid character in
+ * both HTML5 and XML.
+ * @param int $codepoint
+ * @return bool
+ */
+ private static function validateCodepoint( $codepoint ) {
+ # U+000C is valid in HTML5 but not allowed in XML.
+ # U+000D is valid in XML but not allowed in HTML5.
+ # U+007F - U+009F are disallowed in HTML5 (control characters).
+ return $codepoint == 0x09
+ || $codepoint == 0x0a
+ || ( $codepoint >= 0x20 && $codepoint <= 0x7e )
+ || ( $codepoint >= 0xa0 && $codepoint <= 0xd7ff )
+ || ( $codepoint >= 0xe000 && $codepoint <= 0xfffd )
+ || ( $codepoint >= 0x10000 && $codepoint <= 0x10ffff );
+ }
+
+ /**
+ * Decode any character references, numeric or named entities,
+ * in the text and return a UTF-8 string.
+ *
+ * @param string $text
+ * @return string
+ */
+ public static function decodeCharReferences( $text ) {
+ return preg_replace_callback(
+ self::CHAR_REFS_REGEX,
+ [ 'Sanitizer', 'decodeCharReferencesCallback' ],
+ $text );
+ }
+
+ /**
+ * Decode any character references, numeric or named entities,
+ * in the next and normalize the resulting string. (T16952)
+ *
+ * This is useful for page titles, not for text to be displayed,
+ * MediaWiki allows HTML entities to escape normalization as a feature.
+ *
+ * @param string $text Already normalized, containing entities
+ * @return string Still normalized, without entities
+ */
+ public static function decodeCharReferencesAndNormalize( $text ) {
+ global $wgContLang;
+ $text = preg_replace_callback(
+ self::CHAR_REFS_REGEX,
+ [ 'Sanitizer', 'decodeCharReferencesCallback' ],
+ $text,
+ -1, //limit
+ $count
+ );
+
+ if ( $count ) {
+ return $wgContLang->normalize( $text );
+ } else {
+ return $text;
+ }
+ }
+
+ /**
+ * @param string $matches
+ * @return string
+ */
+ static function decodeCharReferencesCallback( $matches ) {
+ if ( $matches[1] != '' ) {
+ return self::decodeEntity( $matches[1] );
+ } elseif ( $matches[2] != '' ) {
+ return self::decodeChar( intval( $matches[2] ) );
+ } elseif ( $matches[3] != '' ) {
+ return self::decodeChar( hexdec( $matches[3] ) );
+ }
+ # Last case should be an ampersand by itself
+ return $matches[0];
+ }
+
+ /**
+ * Return UTF-8 string for a codepoint if that is a valid
+ * character reference, otherwise U+FFFD REPLACEMENT CHARACTER.
+ * @param int $codepoint
+ * @return string
+ * @private
+ */
+ static function decodeChar( $codepoint ) {
+ if ( self::validateCodepoint( $codepoint ) ) {
+ return UtfNormal\Utils::codepointToUtf8( $codepoint );
+ } else {
+ return UtfNormal\Constants::UTF8_REPLACEMENT;
+ }
+ }
+
+ /**
+ * If the named entity is defined in the HTML 4.0/XHTML 1.0 DTD,
+ * return the UTF-8 encoding of that character. Otherwise, returns
+ * pseudo-entity source (eg "&foo;")
+ *
+ * @param string $name
+ * @return string
+ */
+ static function decodeEntity( $name ) {
+ if ( isset( self::$htmlEntityAliases[$name] ) ) {
+ $name = self::$htmlEntityAliases[$name];
+ }
+ if ( isset( self::$htmlEntities[$name] ) ) {
+ return UtfNormal\Utils::codepointToUtf8( self::$htmlEntities[$name] );
+ } else {
+ return "&$name;";
+ }
+ }
+
+ /**
+ * Fetch the whitelist of acceptable attributes for a given element name.
+ *
+ * @param string $element
+ * @return array
+ */
+ static function attributeWhitelist( $element ) {
+ $list = self::setupAttributeWhitelist();
+ return isset( $list[$element] )
+ ? $list[$element]
+ : [];
+ }
+
+ /**
+ * Foreach array key (an allowed HTML element), return an array
+ * of allowed attributes
+ * @return array
+ */
+ static function setupAttributeWhitelist() {
+ static $whitelist;
+
+ if ( $whitelist !== null ) {
+ return $whitelist;
+ }
+
+ $common = [
+ # HTML
+ 'id',
+ 'class',
+ 'style',
+ 'lang',
+ 'dir',
+ 'title',
+
+ # WAI-ARIA
+ 'aria-describedby',
+ 'aria-flowto',
+ 'aria-label',
+ 'aria-labelledby',
+ 'aria-owns',
+ 'role',
+
+ # RDFa
+ # These attributes are specified in section 9 of
+ # https://www.w3.org/TR/2008/REC-rdfa-syntax-20081014
+ 'about',
+ 'property',
+ 'resource',
+ 'datatype',
+ 'typeof',
+
+ # Microdata. These are specified by
+ # https://html.spec.whatwg.org/multipage/microdata.html#the-microdata-model
+ 'itemid',
+ 'itemprop',
+ 'itemref',
+ 'itemscope',
+ 'itemtype',
+ ];
+
+ $block = array_merge( $common, [ 'align' ] );
+ $tablealign = [ 'align', 'valign' ];
+ $tablecell = [
+ 'abbr',
+ 'axis',
+ 'headers',
+ 'scope',
+ 'rowspan',
+ 'colspan',
+ 'nowrap', # deprecated
+ 'width', # deprecated
+ 'height', # deprecated
+ 'bgcolor', # deprecated
+ ];
+
+ # Numbers refer to sections in HTML 4.01 standard describing the element.
+ # See: https://www.w3.org/TR/html4/
+ $whitelist = [
+ # 7.5.4
+ 'div' => $block,
+ 'center' => $common, # deprecated
+ 'span' => $common,
+
+ # 7.5.5
+ 'h1' => $block,
+ 'h2' => $block,
+ 'h3' => $block,
+ 'h4' => $block,
+ 'h5' => $block,
+ 'h6' => $block,
+
+ # 7.5.6
+ # address
+
+ # 8.2.4
+ 'bdo' => $common,
+
+ # 9.2.1
+ 'em' => $common,
+ 'strong' => $common,
+ 'cite' => $common,
+ 'dfn' => $common,
+ 'code' => $common,
+ 'samp' => $common,
+ 'kbd' => $common,
+ 'var' => $common,
+ 'abbr' => $common,
+ # acronym
+
+ # 9.2.2
+ 'blockquote' => array_merge( $common, [ 'cite' ] ),
+ 'q' => array_merge( $common, [ 'cite' ] ),
+
+ # 9.2.3
+ 'sub' => $common,
+ 'sup' => $common,
+
+ # 9.3.1
+ 'p' => $block,
+
+ # 9.3.2
+ 'br' => array_merge( $common, [ 'clear' ] ),
+
+ # https://www.w3.org/TR/html5/text-level-semantics.html#the-wbr-element
+ 'wbr' => $common,
+
+ # 9.3.4
+ 'pre' => array_merge( $common, [ 'width' ] ),
+
+ # 9.4
+ 'ins' => array_merge( $common, [ 'cite', 'datetime' ] ),
+ 'del' => array_merge( $common, [ 'cite', 'datetime' ] ),
+
+ # 10.2
+ 'ul' => array_merge( $common, [ 'type' ] ),
+ 'ol' => array_merge( $common, [ 'type', 'start', 'reversed' ] ),
+ 'li' => array_merge( $common, [ 'type', 'value' ] ),
+
+ # 10.3
+ 'dl' => $common,
+ 'dd' => $common,
+ 'dt' => $common,
+
+ # 11.2.1
+ 'table' => array_merge( $common,
+ [ 'summary', 'width', 'border', 'frame',
+ 'rules', 'cellspacing', 'cellpadding',
+ 'align', 'bgcolor',
+ ] ),
+
+ # 11.2.2
+ 'caption' => $block,
+
+ # 11.2.3
+ 'thead' => $common,
+ 'tfoot' => $common,
+ 'tbody' => $common,
+
+ # 11.2.4
+ 'colgroup' => array_merge( $common, [ 'span' ] ),
+ 'col' => array_merge( $common, [ 'span' ] ),
+
+ # 11.2.5
+ 'tr' => array_merge( $common, [ 'bgcolor' ], $tablealign ),
+
+ # 11.2.6
+ 'td' => array_merge( $common, $tablecell, $tablealign ),
+ 'th' => array_merge( $common, $tablecell, $tablealign ),
+
+ # 12.2
+ # NOTE: <a> is not allowed directly, but the attrib
+ # whitelist is used from the Parser object
+ 'a' => array_merge( $common, [ 'href', 'rel', 'rev' ] ), # rel/rev esp. for RDFa
+
+ # 13.2
+ # Not usually allowed, but may be used for extension-style hooks
+ # such as <math> when it is rasterized, or if $wgAllowImageTag is
+ # true
+ 'img' => array_merge( $common, [ 'alt', 'src', 'width', 'height', 'srcset' ] ),
+
+ 'video' => array_merge( $common, [ 'poster', 'controls', 'preload', 'width', 'height' ] ),
+ 'source' => array_merge( $common, [ 'type', 'src' ] ),
+ 'track' => array_merge( $common, [ 'type', 'src', 'srclang', 'kind', 'label' ] ),
+
+ # 15.2.1
+ 'tt' => $common,
+ 'b' => $common,
+ 'i' => $common,
+ 'big' => $common,
+ 'small' => $common,
+ 'strike' => $common,
+ 's' => $common,
+ 'u' => $common,
+
+ # 15.2.2
+ 'font' => array_merge( $common, [ 'size', 'color', 'face' ] ),
+ # basefont
+
+ # 15.3
+ 'hr' => array_merge( $common, [ 'width' ] ),
+
+ # HTML Ruby annotation text module, simple ruby only.
+ # https://www.w3.org/TR/html5/text-level-semantics.html#the-ruby-element
+ 'ruby' => $common,
+ # rbc
+ 'rb' => $common,
+ 'rp' => $common,
+ 'rt' => $common, # array_merge( $common, array( 'rbspan' ) ),
+ 'rtc' => $common,
+
+ # MathML root element, where used for extensions
+ # 'title' may not be 100% valid here; it's XHTML
+ # https://www.w3.org/TR/REC-MathML/
+ 'math' => [ 'class', 'style', 'id', 'title' ],
+
+ // HTML 5 section 4.5
+ 'figure' => $common,
+ 'figcaption' => $common,
+
+ # HTML 5 section 4.6
+ 'bdi' => $common,
+
+ # HTML5 elements, defined by:
+ # https://html.spec.whatwg.org/multipage/semantics.html#the-data-element
+ 'data' => array_merge( $common, [ 'value' ] ),
+ 'time' => array_merge( $common, [ 'datetime' ] ),
+ 'mark' => $common,
+
+ // meta and link are only permitted by removeHTMLtags when Microdata
+ // is enabled so we don't bother adding a conditional to hide these
+ // Also meta and link are only valid in WikiText as Microdata elements
+ // (ie: validateTag rejects tags missing the attributes needed for Microdata)
+ // So we don't bother including $common attributes that have no purpose.
+ 'meta' => [ 'itemprop', 'content' ],
+ 'link' => [ 'itemprop', 'href', 'title' ],
+ ];
+
+ return $whitelist;
+ }
+
+ /**
+ * Take a fragment of (potentially invalid) HTML and return
+ * a version with any tags removed, encoded as plain text.
+ *
+ * Warning: this return value must be further escaped for literal
+ * inclusion in HTML output as of 1.10!
+ *
+ * @param string $text HTML fragment
+ * @return string
+ */
+ static function stripAllTags( $text ) {
+ # Actual <tags>
+ $text = StringUtils::delimiterReplace( '<', '>', '', $text );
+
+ # Normalize &entities and whitespace
+ $text = self::decodeCharReferences( $text );
+ $text = self::normalizeWhitespace( $text );
+
+ return $text;
+ }
+
+ /**
+ * Hack up a private DOCTYPE with HTML's standard entity declarations.
+ * PHP 4 seemed to know these if you gave it an HTML doctype, but
+ * PHP 5.1 doesn't.
+ *
+ * Use for passing XHTML fragments to PHP's XML parsing functions
+ *
+ * @return string
+ */
+ static function hackDocType() {
+ $out = "<!DOCTYPE html [\n";
+ foreach ( self::$htmlEntities as $entity => $codepoint ) {
+ $out .= "<!ENTITY $entity \"&#$codepoint;\">";
+ }
+ $out .= "]>\n";
+ return $out;
+ }
+
+ /**
+ * @param string $url
+ * @return mixed|string
+ */
+ static function cleanUrl( $url ) {
+ # Normalize any HTML entities in input. They will be
+ # re-escaped by makeExternalLink().
+ $url = self::decodeCharReferences( $url );
+
+ # Escape any control characters introduced by the above step
+ $url = preg_replace_callback( '/[\][<>"\\x00-\\x20\\x7F\|]/',
+ [ __CLASS__, 'cleanUrlCallback' ], $url );
+
+ # Validate hostname portion
+ $matches = [];
+ if ( preg_match( '!^([^:]+:)(//[^/]+)?(.*)$!iD', $url, $matches ) ) {
+ list( /* $whole */, $protocol, $host, $rest ) = $matches;
+
+ // Characters that will be ignored in IDNs.
+ // https://tools.ietf.org/html/rfc3454#section-3.1
+ // Strip them before further processing so blacklists and such work.
+ $strip = "/
+ \\s| # general whitespace
+ \xc2\xad| # 00ad SOFT HYPHEN
+ \xe1\xa0\x86| # 1806 MONGOLIAN TODO SOFT HYPHEN
+ \xe2\x80\x8b| # 200b ZERO WIDTH SPACE
+ \xe2\x81\xa0| # 2060 WORD JOINER
+ \xef\xbb\xbf| # feff ZERO WIDTH NO-BREAK SPACE
+ \xcd\x8f| # 034f COMBINING GRAPHEME JOINER
+ \xe1\xa0\x8b| # 180b MONGOLIAN FREE VARIATION SELECTOR ONE
+ \xe1\xa0\x8c| # 180c MONGOLIAN FREE VARIATION SELECTOR TWO
+ \xe1\xa0\x8d| # 180d MONGOLIAN FREE VARIATION SELECTOR THREE
+ \xe2\x80\x8c| # 200c ZERO WIDTH NON-JOINER
+ \xe2\x80\x8d| # 200d ZERO WIDTH JOINER
+ [\xef\xb8\x80-\xef\xb8\x8f] # fe00-fe0f VARIATION SELECTOR-1-16
+ /xuD";
+
+ $host = preg_replace( $strip, '', $host );
+
+ // IPv6 host names are bracketed with []. Url-decode these.
+ if ( substr_compare( "//%5B", $host, 0, 5 ) === 0 &&
+ preg_match( '!^//%5B([0-9A-Fa-f:.]+)%5D((:\d+)?)$!', $host, $matches )
+ ) {
+ $host = '//[' . $matches[1] . ']' . $matches[2];
+ }
+
+ // @todo FIXME: Validate hostnames here
+
+ return $protocol . $host . $rest;
+ } else {
+ return $url;
+ }
+ }
+
+ /**
+ * @param array $matches
+ * @return string
+ */
+ static function cleanUrlCallback( $matches ) {
+ return urlencode( $matches[0] );
+ }
+
+ /**
+ * Does a string look like an e-mail address?
+ *
+ * This validates an email address using an HTML5 specification found at:
+ * http://www.whatwg.org/html/states-of-the-type-attribute.html#valid-e-mail-address
+ * Which as of 2011-01-24 says:
+ *
+ * A valid e-mail address is a string that matches the ABNF production
+ * 1*( atext / "." ) "@" ldh-str *( "." ldh-str ) where atext is defined
+ * in RFC 5322 section 3.2.3, and ldh-str is defined in RFC 1034 section
+ * 3.5.
+ *
+ * This function is an implementation of the specification as requested in
+ * T24449.
+ *
+ * Client-side forms will use the same standard validation rules via JS or
+ * HTML 5 validation; additional restrictions can be enforced server-side
+ * by extensions via the 'isValidEmailAddr' hook.
+ *
+ * Note that this validation doesn't 100% match RFC 2822, but is believed
+ * to be liberal enough for wide use. Some invalid addresses will still
+ * pass validation here.
+ *
+ * @since 1.18
+ *
+ * @param string $addr E-mail address
+ * @return bool
+ */
+ public static function validateEmail( $addr ) {
+ $result = null;
+ if ( !Hooks::run( 'isValidEmailAddr', [ $addr, &$result ] ) ) {
+ return $result;
+ }
+
+ // Please note strings below are enclosed in brackets [], this make the
+ // hyphen "-" a range indicator. Hence it is double backslashed below.
+ // See T28948
+ $rfc5322_atext = "a-z0-9!#$%&'*+\\-\/=?^_`{|}~";
+ $rfc1034_ldh_str = "a-z0-9\\-";
+
+ $html5_email_regexp = "/
+ ^ # start of string
+ [$rfc5322_atext\\.]+ # user part which is liberal :p
+ @ # 'apostrophe'
+ [$rfc1034_ldh_str]+ # First domain part
+ (\\.[$rfc1034_ldh_str]+)* # Following part prefixed with a dot
+ $ # End of string
+ /ix"; // case Insensitive, eXtended
+
+ return (bool)preg_match( $html5_email_regexp, $addr );
+ }
+}
diff --git a/www/wiki/includes/ServiceWiring.php b/www/wiki/includes/ServiceWiring.php
new file mode 100644
index 00000000..75ce8eca
--- /dev/null
+++ b/www/wiki/includes/ServiceWiring.php
@@ -0,0 +1,455 @@
+<?php
+/**
+ * Default wiring for MediaWiki services.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * This file is loaded by MediaWiki\MediaWikiServices::getInstance() during the
+ * bootstrapping of the dependency injection framework.
+ *
+ * This file returns an array that associates service name with instantiator functions
+ * that create the default instances for the services used by MediaWiki core.
+ * For every service that MediaWiki core requires, an instantiator must be defined in
+ * this file.
+ *
+ * @note As of version 1.27, MediaWiki is only beginning to use dependency injection.
+ * The services defined here do not yet fully represent all services used by core,
+ * much of the code still relies on global state for this accessing services.
+ *
+ * @since 1.27
+ *
+ * @see docs/injection.txt for an overview of using dependency injection in the
+ * MediaWiki code base.
+ */
+
+use MediaWiki\Interwiki\ClassicInterwikiLookup;
+use MediaWiki\Linker\LinkRendererFactory;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\CommandFactory;
+
+return [
+ 'DBLoadBalancerFactory' => function ( MediaWikiServices $services ) {
+ $mainConfig = $services->getMainConfig();
+
+ $lbConf = MWLBFactory::applyDefaultConfig(
+ $mainConfig->get( 'LBFactoryConf' ),
+ $mainConfig,
+ $services->getConfiguredReadOnlyMode()
+ );
+ $class = MWLBFactory::getLBFactoryClass( $lbConf );
+
+ return new $class( $lbConf );
+ },
+
+ 'DBLoadBalancer' => function ( MediaWikiServices $services ) {
+ // just return the default LB from the DBLoadBalancerFactory service
+ return $services->getDBLoadBalancerFactory()->getMainLB();
+ },
+
+ 'SiteStore' => function ( MediaWikiServices $services ) {
+ $rawSiteStore = new DBSiteStore( $services->getDBLoadBalancer() );
+
+ // TODO: replace wfGetCache with a CacheFactory service.
+ // TODO: replace wfIsHHVM with a capabilities service.
+ $cache = wfGetCache( wfIsHHVM() ? CACHE_ACCEL : CACHE_ANYTHING );
+
+ return new CachingSiteStore( $rawSiteStore, $cache );
+ },
+
+ 'SiteLookup' => function ( MediaWikiServices $services ) {
+ $cacheFile = $services->getMainConfig()->get( 'SitesCacheFile' );
+
+ if ( $cacheFile !== false ) {
+ return new FileBasedSiteLookup( $cacheFile );
+ } else {
+ // Use the default SiteStore as the SiteLookup implementation for now
+ return $services->getSiteStore();
+ }
+ },
+
+ 'ConfigFactory' => function ( MediaWikiServices $services ) {
+ // Use the bootstrap config to initialize the ConfigFactory.
+ $registry = $services->getBootstrapConfig()->get( 'ConfigRegistry' );
+ $factory = new ConfigFactory();
+
+ foreach ( $registry as $name => $callback ) {
+ $factory->register( $name, $callback );
+ }
+ return $factory;
+ },
+
+ 'MainConfig' => function ( MediaWikiServices $services ) {
+ // Use the 'main' config from the ConfigFactory service.
+ return $services->getConfigFactory()->makeConfig( 'main' );
+ },
+
+ 'InterwikiLookup' => function ( MediaWikiServices $services ) {
+ global $wgContLang; // TODO: manage $wgContLang as a service
+ $config = $services->getMainConfig();
+ return new ClassicInterwikiLookup(
+ $wgContLang,
+ $services->getMainWANObjectCache(),
+ $config->get( 'InterwikiExpiry' ),
+ $config->get( 'InterwikiCache' ),
+ $config->get( 'InterwikiScopes' ),
+ $config->get( 'InterwikiFallbackSite' )
+ );
+ },
+
+ 'StatsdDataFactory' => function ( MediaWikiServices $services ) {
+ return new BufferingStatsdDataFactory(
+ rtrim( $services->getMainConfig()->get( 'StatsdMetricPrefix' ), '.' )
+ );
+ },
+
+ 'EventRelayerGroup' => function ( MediaWikiServices $services ) {
+ return new EventRelayerGroup( $services->getMainConfig()->get( 'EventRelayerConfig' ) );
+ },
+
+ 'SearchEngineFactory' => function ( MediaWikiServices $services ) {
+ return new SearchEngineFactory( $services->getSearchEngineConfig() );
+ },
+
+ 'SearchEngineConfig' => function ( MediaWikiServices $services ) {
+ global $wgContLang;
+ return new SearchEngineConfig( $services->getMainConfig(), $wgContLang );
+ },
+
+ 'SkinFactory' => function ( MediaWikiServices $services ) {
+ $factory = new SkinFactory();
+
+ $names = $services->getMainConfig()->get( 'ValidSkinNames' );
+
+ foreach ( $names as $name => $skin ) {
+ $factory->register( $name, $skin, function () use ( $name, $skin ) {
+ $class = "Skin$skin";
+ return new $class( $name );
+ } );
+ }
+ // Register a hidden "fallback" skin
+ $factory->register( 'fallback', 'Fallback', function () {
+ return new SkinFallback;
+ } );
+ // Register a hidden skin for api output
+ $factory->register( 'apioutput', 'ApiOutput', function () {
+ return new SkinApi;
+ } );
+
+ return $factory;
+ },
+
+ 'WatchedItemStore' => function ( MediaWikiServices $services ) {
+ $store = new WatchedItemStore(
+ $services->getDBLoadBalancer(),
+ new HashBagOStuff( [ 'maxKeys' => 100 ] ),
+ $services->getReadOnlyMode()
+ );
+ $store->setStatsdDataFactory( $services->getStatsdDataFactory() );
+ return $store;
+ },
+
+ 'WatchedItemQueryService' => function ( MediaWikiServices $services ) {
+ return new WatchedItemQueryService( $services->getDBLoadBalancer() );
+ },
+
+ 'CryptRand' => function ( MediaWikiServices $services ) {
+ $secretKey = $services->getMainConfig()->get( 'SecretKey' );
+ return new CryptRand(
+ [
+ // To try vary the system information of the state a bit more
+ // by including the system's hostname into the state
+ 'wfHostname',
+ // It's mostly worthless but throw the wiki's id into the data
+ // for a little more variance
+ 'wfWikiID',
+ // If we have a secret key set then throw it into the state as well
+ function () use ( $secretKey ) {
+ return $secretKey ?: '';
+ }
+ ],
+ // The config file is likely the most often edited file we know should
+ // be around so include its stat info into the state.
+ // The constant with its location will almost always be defined, as
+ // WebStart.php defines MW_CONFIG_FILE to $IP/LocalSettings.php unless
+ // being configured with MW_CONFIG_CALLBACK (e.g. the installer).
+ defined( 'MW_CONFIG_FILE' ) ? [ MW_CONFIG_FILE ] : [],
+ LoggerFactory::getInstance( 'CryptRand' )
+ );
+ },
+
+ 'CryptHKDF' => function ( MediaWikiServices $services ) {
+ $config = $services->getMainConfig();
+
+ $secret = $config->get( 'HKDFSecret' ) ?: $config->get( 'SecretKey' );
+ if ( !$secret ) {
+ throw new RuntimeException( "Cannot use MWCryptHKDF without a secret." );
+ }
+
+ // In HKDF, the context can be known to the attacker, but this will
+ // keep simultaneous runs from producing the same output.
+ $context = [ microtime(), getmypid(), gethostname() ];
+
+ // Setup salt cache. Use APC, or fallback to the main cache if it isn't setup
+ $cache = $services->getLocalServerObjectCache();
+ if ( $cache instanceof EmptyBagOStuff ) {
+ $cache = ObjectCache::getLocalClusterInstance();
+ }
+
+ return new CryptHKDF( $secret, $config->get( 'HKDFAlgorithm' ),
+ $cache, $context, $services->getCryptRand()
+ );
+ },
+
+ 'MediaHandlerFactory' => function ( MediaWikiServices $services ) {
+ return new MediaHandlerFactory(
+ $services->getMainConfig()->get( 'MediaHandlers' )
+ );
+ },
+
+ 'MimeAnalyzer' => function ( MediaWikiServices $services ) {
+ $logger = LoggerFactory::getInstance( 'Mime' );
+ $mainConfig = $services->getMainConfig();
+ $params = [
+ 'typeFile' => $mainConfig->get( 'MimeTypeFile' ),
+ 'infoFile' => $mainConfig->get( 'MimeInfoFile' ),
+ 'xmlTypes' => $mainConfig->get( 'XMLMimeTypes' ),
+ 'guessCallback' =>
+ function ( $mimeAnalyzer, &$head, &$tail, $file, &$mime ) use ( $logger ) {
+ // Also test DjVu
+ $deja = new DjVuImage( $file );
+ if ( $deja->isValid() ) {
+ $logger->info( __METHOD__ . ": detected $file as image/vnd.djvu\n" );
+ $mime = 'image/vnd.djvu';
+
+ return;
+ }
+ // Some strings by reference for performance - assuming well-behaved hooks
+ Hooks::run(
+ 'MimeMagicGuessFromContent',
+ [ $mimeAnalyzer, &$head, &$tail, $file, &$mime ]
+ );
+ },
+ 'extCallback' => function ( $mimeAnalyzer, $ext, &$mime ) {
+ // Media handling extensions can improve the MIME detected
+ Hooks::run( 'MimeMagicImproveFromExtension', [ $mimeAnalyzer, $ext, &$mime ] );
+ },
+ 'initCallback' => function ( $mimeAnalyzer ) {
+ // Allow media handling extensions adding MIME-types and MIME-info
+ Hooks::run( 'MimeMagicInit', [ $mimeAnalyzer ] );
+ },
+ 'logger' => $logger
+ ];
+
+ if ( $params['infoFile'] === 'includes/mime.info' ) {
+ $params['infoFile'] = __DIR__ . "/libs/mime/mime.info";
+ }
+
+ if ( $params['typeFile'] === 'includes/mime.types' ) {
+ $params['typeFile'] = __DIR__ . "/libs/mime/mime.types";
+ }
+
+ $detectorCmd = $mainConfig->get( 'MimeDetectorCommand' );
+ if ( $detectorCmd ) {
+ $params['detectCallback'] = function ( $file ) use ( $detectorCmd ) {
+ return wfShellExec( "$detectorCmd " . wfEscapeShellArg( $file ) );
+ };
+ }
+
+ // XXX: MimeMagic::singleton currently requires this service to return an instance of MimeMagic
+ return new MimeMagic( $params );
+ },
+
+ 'ProxyLookup' => function ( MediaWikiServices $services ) {
+ $mainConfig = $services->getMainConfig();
+ return new ProxyLookup(
+ $mainConfig->get( 'SquidServers' ),
+ $mainConfig->get( 'SquidServersNoPurge' )
+ );
+ },
+
+ 'Parser' => function ( MediaWikiServices $services ) {
+ $conf = $services->getMainConfig()->get( 'ParserConf' );
+ return ObjectFactory::constructClassInstance( $conf['class'], [ $conf ] );
+ },
+
+ 'ParserCache' => function ( MediaWikiServices $services ) {
+ $config = $services->getMainConfig();
+ $cache = ObjectCache::getInstance( $config->get( 'ParserCacheType' ) );
+ wfDebugLog( 'caches', 'parser: ' . get_class( $cache ) );
+
+ return new ParserCache(
+ $cache,
+ $config->get( 'CacheEpoch' )
+ );
+ },
+
+ 'LinkCache' => function ( MediaWikiServices $services ) {
+ return new LinkCache(
+ $services->getTitleFormatter(),
+ $services->getMainWANObjectCache()
+ );
+ },
+
+ 'LinkRendererFactory' => function ( MediaWikiServices $services ) {
+ return new LinkRendererFactory(
+ $services->getTitleFormatter(),
+ $services->getLinkCache()
+ );
+ },
+
+ 'LinkRenderer' => function ( MediaWikiServices $services ) {
+ global $wgUser;
+
+ if ( defined( 'MW_NO_SESSION' ) ) {
+ return $services->getLinkRendererFactory()->create();
+ } else {
+ return $services->getLinkRendererFactory()->createForUser( $wgUser );
+ }
+ },
+
+ 'GenderCache' => function ( MediaWikiServices $services ) {
+ return new GenderCache();
+ },
+
+ '_MediaWikiTitleCodec' => function ( MediaWikiServices $services ) {
+ global $wgContLang;
+
+ return new MediaWikiTitleCodec(
+ $wgContLang,
+ $services->getGenderCache(),
+ $services->getMainConfig()->get( 'LocalInterwikis' )
+ );
+ },
+
+ 'TitleFormatter' => function ( MediaWikiServices $services ) {
+ return $services->getService( '_MediaWikiTitleCodec' );
+ },
+
+ 'TitleParser' => function ( MediaWikiServices $services ) {
+ return $services->getService( '_MediaWikiTitleCodec' );
+ },
+
+ 'MainObjectStash' => function ( MediaWikiServices $services ) {
+ $mainConfig = $services->getMainConfig();
+
+ $id = $mainConfig->get( 'MainStash' );
+ if ( !isset( $mainConfig->get( 'ObjectCaches' )[$id] ) ) {
+ throw new UnexpectedValueException(
+ "Cache type \"$id\" is not present in \$wgObjectCaches." );
+ }
+
+ return \ObjectCache::newFromParams( $mainConfig->get( 'ObjectCaches' )[$id] );
+ },
+
+ 'MainWANObjectCache' => function ( MediaWikiServices $services ) {
+ $mainConfig = $services->getMainConfig();
+
+ $id = $mainConfig->get( 'MainWANCache' );
+ if ( !isset( $mainConfig->get( 'WANObjectCaches' )[$id] ) ) {
+ throw new UnexpectedValueException(
+ "WAN cache type \"$id\" is not present in \$wgWANObjectCaches." );
+ }
+
+ $params = $mainConfig->get( 'WANObjectCaches' )[$id];
+ $objectCacheId = $params['cacheId'];
+ if ( !isset( $mainConfig->get( 'ObjectCaches' )[$objectCacheId] ) ) {
+ throw new UnexpectedValueException(
+ "Cache type \"$objectCacheId\" is not present in \$wgObjectCaches." );
+ }
+ $params['store'] = $mainConfig->get( 'ObjectCaches' )[$objectCacheId];
+
+ return \ObjectCache::newWANCacheFromParams( $params );
+ },
+
+ 'LocalServerObjectCache' => function ( MediaWikiServices $services ) {
+ $mainConfig = $services->getMainConfig();
+
+ if ( function_exists( 'apc_fetch' ) ) {
+ $id = 'apc';
+ } elseif ( function_exists( 'apcu_fetch' ) ) {
+ $id = 'apcu';
+ } elseif ( function_exists( 'xcache_get' ) && wfIniGetBool( 'xcache.var_size' ) ) {
+ $id = 'xcache';
+ } elseif ( function_exists( 'wincache_ucache_get' ) ) {
+ $id = 'wincache';
+ } else {
+ $id = CACHE_NONE;
+ }
+
+ if ( !isset( $mainConfig->get( 'ObjectCaches' )[$id] ) ) {
+ throw new UnexpectedValueException(
+ "Cache type \"$id\" is not present in \$wgObjectCaches." );
+ }
+
+ return \ObjectCache::newFromParams( $mainConfig->get( 'ObjectCaches' )[$id] );
+ },
+
+ 'VirtualRESTServiceClient' => function ( MediaWikiServices $services ) {
+ $config = $services->getMainConfig()->get( 'VirtualRestConfig' );
+
+ $vrsClient = new VirtualRESTServiceClient( new MultiHttpClient( [] ) );
+ foreach ( $config['paths'] as $prefix => $serviceConfig ) {
+ $class = $serviceConfig['class'];
+ // Merge in the global defaults
+ $constructArg = isset( $serviceConfig['options'] )
+ ? $serviceConfig['options']
+ : [];
+ $constructArg += $config['global'];
+ // Make the VRS service available at the mount point
+ $vrsClient->mount( $prefix, [ 'class' => $class, 'config' => $constructArg ] );
+ }
+
+ return $vrsClient;
+ },
+
+ 'ConfiguredReadOnlyMode' => function ( MediaWikiServices $services ) {
+ return new ConfiguredReadOnlyMode( $services->getMainConfig() );
+ },
+
+ 'ReadOnlyMode' => function ( MediaWikiServices $services ) {
+ return new ReadOnlyMode(
+ $services->getConfiguredReadOnlyMode(),
+ $services->getDBLoadBalancer()
+ );
+ },
+
+ 'ShellCommandFactory' => function ( MediaWikiServices $services ) {
+ $config = $services->getMainConfig();
+
+ $limits = [
+ 'time' => $config->get( 'MaxShellTime' ),
+ 'walltime' => $config->get( 'MaxShellWallClockTime' ),
+ 'memory' => $config->get( 'MaxShellMemory' ),
+ 'filesize' => $config->get( 'MaxShellFileSize' ),
+ ];
+ $cgroup = $config->get( 'ShellCgroup' );
+
+ $factory = new CommandFactory( $limits, $cgroup );
+ $factory->setLogger( LoggerFactory::getInstance( 'exec' ) );
+
+ return $factory;
+ },
+
+ ///////////////////////////////////////////////////////////////////////////
+ // NOTE: When adding a service here, don't forget to add a getter function
+ // in the MediaWikiServices class. The convenience getter should just call
+ // $this->getService( 'FooBarService' ).
+ ///////////////////////////////////////////////////////////////////////////
+
+];
diff --git a/www/wiki/includes/Services/ServiceContainer.php b/www/wiki/includes/Services/ServiceContainer.php
new file mode 100644
index 00000000..e3cda2ee
--- /dev/null
+++ b/www/wiki/includes/Services/ServiceContainer.php
@@ -0,0 +1,222 @@
+<?php
+namespace MediaWiki\Services;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Generic service container.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * ServiceContainer provides a generic service to manage named services using
+ * lazy instantiation based on instantiator callback functions.
+ *
+ * Services managed by an instance of ServiceContainer may or may not implement
+ * a common interface.
+ *
+ * @note When using ServiceContainer to manage a set of services, consider
+ * creating a wrapper or a subclass that provides access to the services via
+ * getter methods with more meaningful names and more specific return type
+ * declarations.
+ *
+ * @see docs/injection.txt for an overview of using dependency injection in the
+ * MediaWiki code base.
+ */
+class ServiceContainer {
+
+ /**
+ * @var object[]
+ */
+ private $services = [];
+
+ /**
+ * @var callable[]
+ */
+ private $serviceInstantiators = [];
+
+ /**
+ * @var array
+ */
+ private $extraInstantiationParams;
+
+ /**
+ * @param array $extraInstantiationParams Any additional parameters to be passed to the
+ * instantiator function when creating a service. This is typically used to provide
+ * access to additional ServiceContainers or Config objects.
+ */
+ public function __construct( array $extraInstantiationParams = [] ) {
+ $this->extraInstantiationParams = $extraInstantiationParams;
+ }
+
+ /**
+ * @param array $wiringFiles A list of PHP files to load wiring information from.
+ * Each file is loaded using PHP's include mechanism. Each file is expected to
+ * return an associative array that maps service names to instantiator functions.
+ */
+ public function loadWiringFiles( array $wiringFiles ) {
+ foreach ( $wiringFiles as $file ) {
+ // the wiring file is required to return an array of instantiators.
+ $wiring = require $file;
+
+ Assert::postcondition(
+ is_array( $wiring ),
+ "Wiring file $file is expected to return an array!"
+ );
+
+ $this->applyWiring( $wiring );
+ }
+ }
+
+ /**
+ * Registers multiple services (aka a "wiring").
+ *
+ * @param array $serviceInstantiators An associative array mapping service names to
+ * instantiator functions.
+ */
+ public function applyWiring( array $serviceInstantiators ) {
+ Assert::parameterElementType( 'callable', $serviceInstantiators, '$serviceInstantiators' );
+
+ foreach ( $serviceInstantiators as $name => $instantiator ) {
+ $this->defineService( $name, $instantiator );
+ }
+ }
+
+ /**
+ * Returns true if a service is defined for $name, that is, if a call to getService( $name )
+ * would return a service instance.
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function hasService( $name ) {
+ return isset( $this->serviceInstantiators[$name] );
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getServiceNames() {
+ return array_keys( $this->serviceInstantiators );
+ }
+
+ /**
+ * Define a new service. The service must not be known already.
+ *
+ * @see getService().
+ * @see replaceService().
+ *
+ * @param string $name The name of the service to register, for use with getService().
+ * @param callable $instantiator Callback that returns a service instance.
+ * Will be called with this MediaWikiServices instance as the only parameter.
+ * Any extra instantiation parameters provided to the constructor will be
+ * passed as subsequent parameters when invoking the instantiator.
+ *
+ * @throws RuntimeException if there is already a service registered as $name.
+ */
+ public function defineService( $name, callable $instantiator ) {
+ Assert::parameterType( 'string', $name, '$name' );
+
+ if ( $this->hasService( $name ) ) {
+ throw new RuntimeException( 'Service already defined: ' . $name );
+ }
+
+ $this->serviceInstantiators[$name] = $instantiator;
+ }
+
+ /**
+ * Replace an already defined service.
+ *
+ * @see defineService().
+ *
+ * @note This causes any previously instantiated instance of the service to be discarded.
+ *
+ * @param string $name The name of the service to register.
+ * @param callable $instantiator Callback function that returns a service instance.
+ * Will be called with this MediaWikiServices instance as the only parameter.
+ * The instantiator must return a service compatible with the originally defined service.
+ * Any extra instantiation parameters provided to the constructor will be
+ * passed as subsequent parameters when invoking the instantiator.
+ *
+ * @throws RuntimeException if $name is not a known service.
+ */
+ public function redefineService( $name, callable $instantiator ) {
+ Assert::parameterType( 'string', $name, '$name' );
+
+ if ( !$this->hasService( $name ) ) {
+ throw new RuntimeException( 'Service not defined: ' . $name );
+ }
+
+ if ( isset( $this->services[$name] ) ) {
+ throw new RuntimeException( 'Cannot redefine a service that is already in use: ' . $name );
+ }
+
+ $this->serviceInstantiators[$name] = $instantiator;
+ }
+
+ /**
+ * Returns a service object of the kind associated with $name.
+ * Services instances are instantiated lazily, on demand.
+ * This method may or may not return the same service instance
+ * when called multiple times with the same $name.
+ *
+ * @note Rather than calling this method directly, it is recommended to provide
+ * getters with more meaningful names and more specific return types, using
+ * a subclass or wrapper.
+ *
+ * @see redefineService().
+ *
+ * @param string $name The service name
+ *
+ * @throws InvalidArgumentException if $name is not a known service.
+ * @return object The service instance
+ */
+ public function getService( $name ) {
+ if ( !isset( $this->services[$name] ) ) {
+ $this->services[$name] = $this->createService( $name );
+ }
+
+ return $this->services[$name];
+ }
+
+ /**
+ * @param string $name
+ *
+ * @throws InvalidArgumentException if $name is not a known service.
+ * @return object
+ */
+ private function createService( $name ) {
+ if ( isset( $this->serviceInstantiators[$name] ) ) {
+ $service = call_user_func_array(
+ $this->serviceInstantiators[$name],
+ array_merge( [ $this ], $this->extraInstantiationParams )
+ );
+ } else {
+ throw new InvalidArgumentException( 'Unknown service: ' . $name );
+ }
+
+ return $service;
+ }
+
+}
diff --git a/www/wiki/includes/Setup.php b/www/wiki/includes/Setup.php
new file mode 100644
index 00000000..68e3d96a
--- /dev/null
+++ b/www/wiki/includes/Setup.php
@@ -0,0 +1,887 @@
+<?php
+/**
+ * Include most things that are needed to make MediaWiki work.
+ *
+ * This file is included by WebStart.php and doMaintenance.php so that both
+ * web and maintenance scripts share a final set up phase to include necessary
+ * files and create global object variables.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This file is not a valid entry point, perform no further processing unless
+ * MEDIAWIKI is defined
+ */
+if ( !defined( 'MEDIAWIKI' ) ) {
+ exit( 1 );
+}
+
+$fname = 'Setup.php';
+$ps_setup = Profiler::instance()->scopedProfileIn( $fname );
+
+// Load queued extensions
+ExtensionRegistry::getInstance()->loadFromQueue();
+// Don't let any other extensions load
+ExtensionRegistry::getInstance()->finish();
+
+// Check to see if we are at the file scope
+if ( !isset( $wgVersion ) ) {
+ echo "Error, Setup.php must be included from the file scope, after DefaultSettings.php\n";
+ die( 1 );
+}
+
+mb_internal_encoding( 'UTF-8' );
+
+// Set the configured locale on all requests for consisteny
+putenv( "LC_ALL=$wgShellLocale" );
+setlocale( LC_ALL, $wgShellLocale );
+
+// Set various default paths sensibly...
+$ps_default = Profiler::instance()->scopedProfileIn( $fname . '-defaults' );
+
+if ( $wgScript === false ) {
+ $wgScript = "$wgScriptPath/index.php";
+}
+if ( $wgLoadScript === false ) {
+ $wgLoadScript = "$wgScriptPath/load.php";
+}
+
+if ( $wgArticlePath === false ) {
+ if ( $wgUsePathInfo ) {
+ $wgArticlePath = "$wgScript/$1";
+ } else {
+ $wgArticlePath = "$wgScript?title=$1";
+ }
+}
+
+if ( !empty( $wgActionPaths ) && !isset( $wgActionPaths['view'] ) ) {
+ // 'view' is assumed the default action path everywhere in the code
+ // but is rarely filled in $wgActionPaths
+ $wgActionPaths['view'] = $wgArticlePath;
+}
+
+if ( $wgResourceBasePath === null ) {
+ $wgResourceBasePath = $wgScriptPath;
+}
+if ( $wgStylePath === false ) {
+ $wgStylePath = "$wgResourceBasePath/skins";
+}
+if ( $wgLocalStylePath === false ) {
+ // Avoid wgResourceBasePath here since that may point to a different domain (e.g. CDN)
+ $wgLocalStylePath = "$wgScriptPath/skins";
+}
+if ( $wgExtensionAssetsPath === false ) {
+ $wgExtensionAssetsPath = "$wgResourceBasePath/extensions";
+}
+
+if ( $wgLogo === false ) {
+ $wgLogo = "$wgResourceBasePath/resources/assets/wiki.png";
+}
+
+if ( $wgUploadPath === false ) {
+ $wgUploadPath = "$wgScriptPath/images";
+}
+if ( $wgUploadDirectory === false ) {
+ $wgUploadDirectory = "$IP/images";
+}
+if ( $wgReadOnlyFile === false ) {
+ $wgReadOnlyFile = "{$wgUploadDirectory}/lock_yBgMBwiR";
+}
+if ( $wgFileCacheDirectory === false ) {
+ $wgFileCacheDirectory = "{$wgUploadDirectory}/cache";
+}
+if ( $wgDeletedDirectory === false ) {
+ $wgDeletedDirectory = "{$wgUploadDirectory}/deleted";
+}
+
+if ( $wgGitInfoCacheDirectory === false && $wgCacheDirectory !== false ) {
+ $wgGitInfoCacheDirectory = "{$wgCacheDirectory}/gitinfo";
+}
+
+if ( $wgEnableParserCache === false ) {
+ $wgParserCacheType = CACHE_NONE;
+}
+
+// Fix path to icon images after they were moved in 1.24
+if ( $wgRightsIcon ) {
+ $wgRightsIcon = str_replace(
+ "{$wgStylePath}/common/images/",
+ "{$wgResourceBasePath}/resources/assets/licenses/",
+ $wgRightsIcon
+ );
+}
+
+if ( isset( $wgFooterIcons['copyright']['copyright'] )
+ && $wgFooterIcons['copyright']['copyright'] === []
+) {
+ if ( $wgRightsIcon || $wgRightsText ) {
+ $wgFooterIcons['copyright']['copyright'] = [
+ 'url' => $wgRightsUrl,
+ 'src' => $wgRightsIcon,
+ 'alt' => $wgRightsText,
+ ];
+ }
+}
+
+if ( isset( $wgFooterIcons['poweredby'] )
+ && isset( $wgFooterIcons['poweredby']['mediawiki'] )
+ && $wgFooterIcons['poweredby']['mediawiki']['src'] === null
+) {
+ $wgFooterIcons['poweredby']['mediawiki']['src'] =
+ "$wgResourceBasePath/resources/assets/poweredby_mediawiki_88x31.png";
+ $wgFooterIcons['poweredby']['mediawiki']['srcset'] =
+ "$wgResourceBasePath/resources/assets/poweredby_mediawiki_132x47.png 1.5x, " .
+ "$wgResourceBasePath/resources/assets/poweredby_mediawiki_176x62.png 2x";
+}
+
+/**
+ * Unconditional protection for NS_MEDIAWIKI since otherwise it's too easy for a
+ * sysadmin to set $wgNamespaceProtection incorrectly and leave the wiki insecure.
+ *
+ * Note that this is the definition of editinterface and it can be granted to
+ * all users if desired.
+ */
+$wgNamespaceProtection[NS_MEDIAWIKI] = 'editinterface';
+
+/**
+ * The canonical names of namespaces 6 and 7 are, as of v1.14, "File"
+ * and "File_talk". The old names "Image" and "Image_talk" are
+ * retained as aliases for backwards compatibility.
+ */
+$wgNamespaceAliases['Image'] = NS_FILE;
+$wgNamespaceAliases['Image_talk'] = NS_FILE_TALK;
+
+/**
+ * Initialise $wgLockManagers to include basic FS version
+ */
+$wgLockManagers[] = [
+ 'name' => 'fsLockManager',
+ 'class' => 'FSLockManager',
+ 'lockDirectory' => "{$wgUploadDirectory}/lockdir",
+];
+$wgLockManagers[] = [
+ 'name' => 'nullLockManager',
+ 'class' => 'NullLockManager',
+];
+
+/**
+ * Default parameters for the "<gallery>" tag.
+ * @see DefaultSettings.php for description of the fields.
+ */
+$wgGalleryOptions += [
+ 'imagesPerRow' => 0,
+ 'imageWidth' => 120,
+ 'imageHeight' => 120,
+ 'captionLength' => true,
+ 'showBytes' => true,
+ 'showDimensions' => true,
+ 'mode' => 'traditional',
+];
+
+/**
+ * Initialise $wgLocalFileRepo from backwards-compatible settings
+ */
+if ( !$wgLocalFileRepo ) {
+ $wgLocalFileRepo = [
+ 'class' => 'LocalRepo',
+ 'name' => 'local',
+ 'directory' => $wgUploadDirectory,
+ 'scriptDirUrl' => $wgScriptPath,
+ 'scriptExtension' => '.php',
+ 'url' => $wgUploadBaseUrl ? $wgUploadBaseUrl . $wgUploadPath : $wgUploadPath,
+ 'hashLevels' => $wgHashedUploadDirectory ? 2 : 0,
+ 'thumbScriptUrl' => $wgThumbnailScriptPath,
+ 'transformVia404' => !$wgGenerateThumbnailOnParse,
+ 'deletedDir' => $wgDeletedDirectory,
+ 'deletedHashLevels' => $wgHashedUploadDirectory ? 3 : 0
+ ];
+}
+/**
+ * Initialise shared repo from backwards-compatible settings
+ */
+if ( $wgUseSharedUploads ) {
+ if ( $wgSharedUploadDBname ) {
+ $wgForeignFileRepos[] = [
+ 'class' => 'ForeignDBRepo',
+ 'name' => 'shared',
+ 'directory' => $wgSharedUploadDirectory,
+ 'url' => $wgSharedUploadPath,
+ 'hashLevels' => $wgHashedSharedUploadDirectory ? 2 : 0,
+ 'thumbScriptUrl' => $wgSharedThumbnailScriptPath,
+ 'transformVia404' => !$wgGenerateThumbnailOnParse,
+ 'dbType' => $wgDBtype,
+ 'dbServer' => $wgDBserver,
+ 'dbUser' => $wgDBuser,
+ 'dbPassword' => $wgDBpassword,
+ 'dbName' => $wgSharedUploadDBname,
+ 'dbFlags' => ( $wgDebugDumpSql ? DBO_DEBUG : 0 ) | DBO_DEFAULT,
+ 'tablePrefix' => $wgSharedUploadDBprefix,
+ 'hasSharedCache' => $wgCacheSharedUploads,
+ 'descBaseUrl' => $wgRepositoryBaseUrl,
+ 'fetchDescription' => $wgFetchCommonsDescriptions,
+ ];
+ } else {
+ $wgForeignFileRepos[] = [
+ 'class' => 'FileRepo',
+ 'name' => 'shared',
+ 'directory' => $wgSharedUploadDirectory,
+ 'url' => $wgSharedUploadPath,
+ 'hashLevels' => $wgHashedSharedUploadDirectory ? 2 : 0,
+ 'thumbScriptUrl' => $wgSharedThumbnailScriptPath,
+ 'transformVia404' => !$wgGenerateThumbnailOnParse,
+ 'descBaseUrl' => $wgRepositoryBaseUrl,
+ 'fetchDescription' => $wgFetchCommonsDescriptions,
+ ];
+ }
+}
+if ( $wgUseInstantCommons ) {
+ $wgForeignFileRepos[] = [
+ 'class' => 'ForeignAPIRepo',
+ 'name' => 'wikimediacommons',
+ 'apibase' => 'https://commons.wikimedia.org/w/api.php',
+ 'url' => 'https://upload.wikimedia.org/wikipedia/commons',
+ 'thumbUrl' => 'https://upload.wikimedia.org/wikipedia/commons/thumb',
+ 'hashLevels' => 2,
+ 'transformVia404' => true,
+ 'fetchDescription' => true,
+ 'descriptionCacheExpiry' => 43200,
+ 'apiThumbCacheExpiry' => 0,
+ ];
+}
+/*
+ * Add on default file backend config for file repos.
+ * FileBackendGroup will handle initializing the backends.
+ */
+if ( !isset( $wgLocalFileRepo['backend'] ) ) {
+ $wgLocalFileRepo['backend'] = $wgLocalFileRepo['name'] . '-backend';
+}
+foreach ( $wgForeignFileRepos as &$repo ) {
+ if ( !isset( $repo['directory'] ) && $repo['class'] === 'ForeignAPIRepo' ) {
+ $repo['directory'] = $wgUploadDirectory; // b/c
+ }
+ if ( !isset( $repo['backend'] ) ) {
+ $repo['backend'] = $repo['name'] . '-backend';
+ }
+}
+unset( $repo ); // no global pollution; destroy reference
+
+// Convert this deprecated setting to modern system
+if ( $wgExperimentalHtmlIds ) {
+ $wgFragmentMode = [ 'html5-legacy', 'legacy' ];
+}
+
+$rcMaxAgeDays = $wgRCMaxAge / ( 3600 * 24 );
+if ( $wgRCFilterByAge ) {
+ // Trim down $wgRCLinkDays so that it only lists links which are valid
+ // as determined by $wgRCMaxAge.
+ // Note that we allow 1 link higher than the max for things like 56 days but a 60 day link.
+ sort( $wgRCLinkDays );
+
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $i = 0; $i < count( $wgRCLinkDays ); $i++ ) {
+ // @codingStandardsIgnoreEnd
+ if ( $wgRCLinkDays[$i] >= $rcMaxAgeDays ) {
+ $wgRCLinkDays = array_slice( $wgRCLinkDays, 0, $i + 1, false );
+ break;
+ }
+ }
+}
+// Ensure that default user options are not invalid, since that breaks Special:Preferences
+$wgDefaultUserOptions['rcdays'] = min(
+ $wgDefaultUserOptions['rcdays'],
+ ceil( $rcMaxAgeDays )
+);
+$wgDefaultUserOptions['watchlistdays'] = min(
+ $wgDefaultUserOptions['watchlistdays'],
+ ceil( $rcMaxAgeDays )
+);
+unset( $rcMaxAgeDays );
+
+if ( $wgSkipSkin ) {
+ $wgSkipSkins[] = $wgSkipSkin;
+}
+
+$wgSkipSkins[] = 'fallback';
+$wgSkipSkins[] = 'apioutput';
+
+if ( $wgLocalInterwiki ) {
+ array_unshift( $wgLocalInterwikis, $wgLocalInterwiki );
+}
+
+// Set default shared prefix
+if ( $wgSharedPrefix === false ) {
+ $wgSharedPrefix = $wgDBprefix;
+}
+
+// Set default shared schema
+if ( $wgSharedSchema === false ) {
+ $wgSharedSchema = $wgDBmwschema;
+}
+
+if ( !$wgCookiePrefix ) {
+ if ( $wgSharedDB && $wgSharedPrefix && in_array( 'user', $wgSharedTables ) ) {
+ $wgCookiePrefix = $wgSharedDB . '_' . $wgSharedPrefix;
+ } elseif ( $wgSharedDB && in_array( 'user', $wgSharedTables ) ) {
+ $wgCookiePrefix = $wgSharedDB;
+ } elseif ( $wgDBprefix ) {
+ $wgCookiePrefix = $wgDBname . '_' . $wgDBprefix;
+ } else {
+ $wgCookiePrefix = $wgDBname;
+ }
+}
+$wgCookiePrefix = strtr( $wgCookiePrefix, '=,; +."\'\\[', '__________' );
+
+if ( $wgEnableEmail ) {
+ $wgUseEnotif = $wgEnotifUserTalk || $wgEnotifWatchlist;
+} else {
+ // Disable all other email settings automatically if $wgEnableEmail
+ // is set to false. - T65678
+ $wgAllowHTMLEmail = false;
+ $wgEmailAuthentication = false; // do not require auth if you're not sending email anyway
+ $wgEnableUserEmail = false;
+ $wgEnotifFromEditor = false;
+ $wgEnotifImpersonal = false;
+ $wgEnotifMaxRecips = 0;
+ $wgEnotifMinorEdits = false;
+ $wgEnotifRevealEditorAddress = false;
+ $wgEnotifUseRealName = false;
+ $wgEnotifUserTalk = false;
+ $wgEnotifWatchlist = false;
+ unset( $wgGroupPermissions['user']['sendemail'] );
+ $wgUseEnotif = false;
+ $wgUserEmailUseReplyTo = false;
+ $wgUsersNotifiedOnAllChanges = [];
+}
+
+if ( $wgMetaNamespace === false ) {
+ $wgMetaNamespace = str_replace( ' ', '_', $wgSitename );
+}
+
+// Default value is 2000 or the suhosin limit if it is between 1 and 2000
+if ( $wgResourceLoaderMaxQueryLength === false ) {
+ $suhosinMaxValueLength = (int)ini_get( 'suhosin.get.max_value_length' );
+ if ( $suhosinMaxValueLength > 0 && $suhosinMaxValueLength < 2000 ) {
+ $wgResourceLoaderMaxQueryLength = $suhosinMaxValueLength;
+ } else {
+ $wgResourceLoaderMaxQueryLength = 2000;
+ }
+ unset( $suhosinMaxValueLength );
+}
+
+// Ensure the minimum chunk size is less than PHP upload limits or the maximum
+// upload size.
+$wgMinUploadChunkSize = min(
+ $wgMinUploadChunkSize,
+ UploadBase::getMaxUploadSize( 'file' ),
+ UploadBase::getMaxPhpUploadSize(),
+ ( wfShorthandToInteger(
+ ini_get( 'post_max_size' ) ?: ini_get( 'hhvm.server.max_post_size' ),
+ PHP_INT_MAX
+ ) ?: PHP_INT_MAX ) - 1024 // Leave some room for other POST parameters
+);
+
+/**
+ * Definitions of the NS_ constants are in Defines.php
+ * @private
+ */
+$wgCanonicalNamespaceNames = [
+ NS_MEDIA => 'Media',
+ NS_SPECIAL => 'Special',
+ NS_TALK => 'Talk',
+ NS_USER => 'User',
+ NS_USER_TALK => 'User_talk',
+ NS_PROJECT => 'Project',
+ NS_PROJECT_TALK => 'Project_talk',
+ NS_FILE => 'File',
+ NS_FILE_TALK => 'File_talk',
+ NS_MEDIAWIKI => 'MediaWiki',
+ NS_MEDIAWIKI_TALK => 'MediaWiki_talk',
+ NS_TEMPLATE => 'Template',
+ NS_TEMPLATE_TALK => 'Template_talk',
+ NS_HELP => 'Help',
+ NS_HELP_TALK => 'Help_talk',
+ NS_CATEGORY => 'Category',
+ NS_CATEGORY_TALK => 'Category_talk',
+];
+
+/// @todo UGLY UGLY
+if ( is_array( $wgExtraNamespaces ) ) {
+ $wgCanonicalNamespaceNames = $wgCanonicalNamespaceNames + $wgExtraNamespaces;
+}
+
+// Merge in the legacy language codes, incorporating overrides from the config
+$wgDummyLanguageCodes += [
+ 'qqq' => 'qqq', // Used for message documentation
+ 'qqx' => 'qqx', // Used for viewing message keys
+] + $wgExtraLanguageCodes + LanguageCode::getDeprecatedCodeMapping();
+
+// These are now the same, always
+// To determine the user language, use $wgLang->getCode()
+$wgContLanguageCode = $wgLanguageCode;
+
+// Easy to forget to falsify $wgDebugToolbar for static caches.
+// If file cache or CDN cache is on, just disable this (DWIMD).
+if ( $wgUseFileCache || $wgUseSquid ) {
+ $wgDebugToolbar = false;
+}
+
+// We always output HTML5 since 1.22, overriding these is no longer supported
+// we set them here for extensions that depend on its value.
+$wgHtml5 = true;
+$wgXhtmlDefaultNamespace = 'http://www.w3.org/1999/xhtml';
+$wgJsMimeType = 'text/javascript';
+
+// Blacklisted file extensions shouldn't appear on the "allowed" list
+$wgFileExtensions = array_values( array_diff( $wgFileExtensions, $wgFileBlacklist ) );
+
+if ( $wgInvalidateCacheOnLocalSettingsChange ) {
+ MediaWiki\suppressWarnings();
+ $wgCacheEpoch = max( $wgCacheEpoch, gmdate( 'YmdHis', filemtime( "$IP/LocalSettings.php" ) ) );
+ MediaWiki\restoreWarnings();
+}
+
+if ( $wgNewUserLog ) {
+ // Add a new log type
+ $wgLogTypes[] = 'newusers';
+ $wgLogNames['newusers'] = 'newuserlogpage';
+ $wgLogHeaders['newusers'] = 'newuserlogpagetext';
+ $wgLogActionsHandlers['newusers/newusers'] = 'NewUsersLogFormatter';
+ $wgLogActionsHandlers['newusers/create'] = 'NewUsersLogFormatter';
+ $wgLogActionsHandlers['newusers/create2'] = 'NewUsersLogFormatter';
+ $wgLogActionsHandlers['newusers/byemail'] = 'NewUsersLogFormatter';
+ $wgLogActionsHandlers['newusers/autocreate'] = 'NewUsersLogFormatter';
+}
+
+if ( $wgPageLanguageUseDB ) {
+ $wgLogTypes[] = 'pagelang';
+ $wgLogActionsHandlers['pagelang/pagelang'] = 'PageLangLogFormatter';
+}
+
+if ( $wgCookieSecure === 'detect' ) {
+ $wgCookieSecure = ( WebRequest::detectProtocol() === 'https' );
+}
+
+if ( $wgProfileOnly ) {
+ $wgDebugLogGroups['profileoutput'] = $wgDebugLogFile;
+ $wgDebugLogFile = '';
+}
+
+// Backwards compatibility with old password limits
+if ( $wgMinimalPasswordLength !== false ) {
+ $wgPasswordPolicy['policies']['default']['MinimalPasswordLength'] = $wgMinimalPasswordLength;
+}
+
+if ( $wgMaximalPasswordLength !== false ) {
+ $wgPasswordPolicy['policies']['default']['MaximalPasswordLength'] = $wgMaximalPasswordLength;
+}
+
+// Backwards compatibility warning
+if ( !$wgSessionsInObjectCache ) {
+ wfDeprecated( '$wgSessionsInObjectCache = false', '1.27' );
+ if ( $wgSessionHandler ) {
+ wfDeprecated( '$wgSessionsHandler', '1.27' );
+ }
+ $cacheType = get_class( ObjectCache::getInstance( $wgSessionCacheType ) );
+ wfDebugLog(
+ 'caches',
+ "Session data will be stored in \"$cacheType\" cache with " .
+ "expiry $wgObjectCacheSessionExpiry seconds"
+ );
+}
+$wgSessionsInObjectCache = true;
+
+if ( $wgPHPSessionHandling !== 'enable' &&
+ $wgPHPSessionHandling !== 'warn' &&
+ $wgPHPSessionHandling !== 'disable'
+) {
+ $wgPHPSessionHandling = 'warn';
+}
+if ( defined( 'MW_NO_SESSION' ) ) {
+ // If the entry point wants no session, force 'disable' here unless they
+ // specifically set it to the (undocumented) 'warn'.
+ $wgPHPSessionHandling = MW_NO_SESSION === 'warn' ? 'warn' : 'disable';
+}
+
+Profiler::instance()->scopedProfileOut( $ps_default );
+
+// Disable MWDebug for command line mode, this prevents MWDebug from eating up
+// all the memory from logging SQL queries on maintenance scripts
+global $wgCommandLineMode;
+if ( $wgDebugToolbar && !$wgCommandLineMode ) {
+ MWDebug::init();
+}
+
+// Reset the global service locator, so any services that have already been created will be
+// re-created while taking into account any custom settings and extensions.
+MediaWikiServices::resetGlobalInstance( new GlobalVarConfig(), 'quick' );
+
+if ( $wgSharedDB && $wgSharedTables ) {
+ // Apply $wgSharedDB table aliases for the local LB (all non-foreign DB connections)
+ MediaWikiServices::getInstance()->getDBLoadBalancer()->setTableAliases(
+ array_fill_keys(
+ $wgSharedTables,
+ [
+ 'dbname' => $wgSharedDB,
+ 'schema' => $wgSharedSchema,
+ 'prefix' => $wgSharedPrefix
+ ]
+ )
+ );
+}
+
+// Define a constant that indicates that the bootstrapping of the service locator
+// is complete.
+define( 'MW_SERVICE_BOOTSTRAP_COMPLETE', 1 );
+
+MWExceptionHandler::installHandler();
+
+require_once "$IP/includes/compat/normal/UtfNormalUtil.php";
+
+$ps_validation = Profiler::instance()->scopedProfileIn( $fname . '-validation' );
+
+// T48998: Bail out early if $wgArticlePath is non-absolute
+foreach ( [ 'wgArticlePath', 'wgVariantArticlePath' ] as $varName ) {
+ if ( $$varName && !preg_match( '/^(https?:\/\/|\/)/', $$varName ) ) {
+ throw new FatalError(
+ "If you use a relative URL for \$$varName, it must start " .
+ 'with a slash (<code>/</code>).<br><br>See ' .
+ "<a href=\"https://www.mediawiki.org/wiki/Manual:\$$varName\">" .
+ "https://www.mediawiki.org/wiki/Manual:\$$varName</a>."
+ );
+ }
+}
+
+Profiler::instance()->scopedProfileOut( $ps_validation );
+
+$ps_default2 = Profiler::instance()->scopedProfileIn( $fname . '-defaults2' );
+
+if ( $wgCanonicalServer === false ) {
+ $wgCanonicalServer = wfExpandUrl( $wgServer, PROTO_HTTP );
+}
+
+// Set server name
+$serverParts = wfParseUrl( $wgCanonicalServer );
+if ( $wgServerName !== false ) {
+ wfWarn( '$wgServerName should be derived from $wgCanonicalServer, '
+ . 'not customized. Overwriting $wgServerName.' );
+}
+$wgServerName = $serverParts['host'];
+unset( $serverParts );
+
+// Set defaults for configuration variables
+// that are derived from the server name by default
+// Note: $wgEmergencyContact and $wgPasswordSender may be false or empty string (T104142)
+if ( !$wgEmergencyContact ) {
+ $wgEmergencyContact = 'wikiadmin@' . $wgServerName;
+}
+if ( !$wgPasswordSender ) {
+ $wgPasswordSender = 'apache@' . $wgServerName;
+}
+if ( !$wgNoReplyAddress ) {
+ $wgNoReplyAddress = $wgPasswordSender;
+}
+
+if ( $wgSecureLogin && substr( $wgServer, 0, 2 ) !== '//' ) {
+ $wgSecureLogin = false;
+ wfWarn( 'Secure login was enabled on a server that only supports '
+ . 'HTTP or HTTPS. Disabling secure login.' );
+}
+
+$wgVirtualRestConfig['global']['domain'] = $wgCanonicalServer;
+
+// Now that GlobalFunctions is loaded, set defaults that depend on it.
+if ( $wgTmpDirectory === false ) {
+ $wgTmpDirectory = wfTempDir();
+}
+
+// We don't use counters anymore. Left here for extensions still
+// expecting this to exist. Should be removed sometime 1.26 or later.
+if ( !isset( $wgDisableCounters ) ) {
+ $wgDisableCounters = true;
+}
+
+if ( $wgMainWANCache === false ) {
+ // Setup a WAN cache from $wgMainCacheType with no relayer.
+ // Sites using multiple datacenters can configure a relayer.
+ $wgMainWANCache = 'mediawiki-main-default';
+ $wgWANObjectCaches[$wgMainWANCache] = [
+ 'class' => 'WANObjectCache',
+ 'cacheId' => $wgMainCacheType,
+ 'channels' => [ 'purge' => 'wancache-main-default-purge' ]
+ ];
+}
+
+Profiler::instance()->scopedProfileOut( $ps_default2 );
+
+$ps_misc = Profiler::instance()->scopedProfileIn( $fname . '-misc1' );
+
+// Raise the memory limit if it's too low
+wfMemoryLimit();
+
+/**
+ * Set up the timezone, suppressing the pseudo-security warning in PHP 5.1+
+ * that happens whenever you use a date function without the timezone being
+ * explicitly set. Inspired by phpMyAdmin's treatment of the problem.
+ */
+if ( is_null( $wgLocaltimezone ) ) {
+ MediaWiki\suppressWarnings();
+ $wgLocaltimezone = date_default_timezone_get();
+ MediaWiki\restoreWarnings();
+}
+
+date_default_timezone_set( $wgLocaltimezone );
+if ( is_null( $wgLocalTZoffset ) ) {
+ $wgLocalTZoffset = date( 'Z' ) / 60;
+}
+// The part after the System| is ignored, but rest of MW fills it
+// out as the local offset.
+$wgDefaultUserOptions['timecorrection'] = "System|$wgLocalTZoffset";
+
+if ( !$wgDBerrorLogTZ ) {
+ $wgDBerrorLogTZ = $wgLocaltimezone;
+}
+
+// initialize the request object in $wgRequest
+$wgRequest = RequestContext::getMain()->getRequest(); // BackCompat
+// Set user IP/agent information for causal consistency purposes
+MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->setRequestInfo( [
+ 'IPAddress' => $wgRequest->getIP(),
+ 'UserAgent' => $wgRequest->getHeader( 'User-Agent' ),
+ 'ChronologyProtection' => $wgRequest->getHeader( 'ChronologyProtection' )
+] );
+
+// Useful debug output
+if ( $wgCommandLineMode ) {
+ wfDebug( "\n\nStart command line script $self\n" );
+} else {
+ $debug = "\n\nStart request {$wgRequest->getMethod()} {$wgRequest->getRequestURL()}\n";
+
+ if ( $wgDebugPrintHttpHeaders ) {
+ $debug .= "HTTP HEADERS:\n";
+
+ foreach ( $wgRequest->getAllHeaders() as $name => $value ) {
+ $debug .= "$name: $value\n";
+ }
+ }
+ wfDebug( $debug );
+}
+
+Profiler::instance()->scopedProfileOut( $ps_misc );
+$ps_memcached = Profiler::instance()->scopedProfileIn( $fname . '-memcached' );
+
+$wgMemc = wfGetMainCache();
+$messageMemc = wfGetMessageCacheStorage();
+
+/**
+ * @deprecated since 1.30
+ */
+$parserMemc = new DeprecatedGlobal( 'parserMemc', function () {
+ return MediaWikiServices::getInstance()->getParserCache()->getCacheStorage();
+}, '1.30' );
+
+wfDebugLog( 'caches',
+ 'cluster: ' . get_class( $wgMemc ) .
+ ', WAN: ' . ( $wgMainWANCache === CACHE_NONE ? 'CACHE_NONE' : $wgMainWANCache ) .
+ ', stash: ' . $wgMainStash .
+ ', message: ' . get_class( $messageMemc ) .
+ ', session: ' . get_class( ObjectCache::getInstance( $wgSessionCacheType ) )
+);
+
+Profiler::instance()->scopedProfileOut( $ps_memcached );
+
+// Most of the config is out, some might want to run hooks here.
+Hooks::run( 'SetupAfterCache' );
+
+$ps_globals = Profiler::instance()->scopedProfileIn( $fname . '-globals' );
+
+/**
+ * @var Language $wgContLang
+ */
+$wgContLang = Language::factory( $wgLanguageCode );
+$wgContLang->initContLang();
+
+// Now that variant lists may be available...
+$wgRequest->interpolateTitle();
+
+if ( !is_object( $wgAuth ) ) {
+ $wgAuth = new MediaWiki\Auth\AuthManagerAuthPlugin;
+ Hooks::run( 'AuthPluginSetup', [ &$wgAuth ] );
+}
+if ( $wgAuth && !$wgAuth instanceof MediaWiki\Auth\AuthManagerAuthPlugin ) {
+ MediaWiki\Auth\AuthManager::singleton()->forcePrimaryAuthenticationProviders( [
+ new MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider( [
+ 'authoritative' => false,
+ ] ),
+ new MediaWiki\Auth\AuthPluginPrimaryAuthenticationProvider( $wgAuth ),
+ new MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider( [
+ 'authoritative' => true,
+ ] ),
+ ], '$wgAuth is ' . get_class( $wgAuth ) );
+}
+
+// Set up the session
+$ps_session = Profiler::instance()->scopedProfileIn( $fname . '-session' );
+/**
+ * @var MediaWiki\Session\SessionId|null $wgInitialSessionId The persistent
+ * session ID (if any) loaded at startup
+ */
+$wgInitialSessionId = null;
+if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) {
+ // If session.auto_start is there, we can't touch session name
+ if ( $wgPHPSessionHandling !== 'disable' && !wfIniGetBool( 'session.auto_start' ) ) {
+ session_name( $wgSessionName ? $wgSessionName : $wgCookiePrefix . '_session' );
+ }
+
+ // Create the SessionManager singleton and set up our session handler,
+ // unless we're specifically asked not to.
+ if ( !defined( 'MW_NO_SESSION_HANDLER' ) ) {
+ MediaWiki\Session\PHPSessionHandler::install(
+ MediaWiki\Session\SessionManager::singleton()
+ );
+ }
+
+ // Initialize the session
+ try {
+ $session = MediaWiki\Session\SessionManager::getGlobalSession();
+ } catch ( OverflowException $ex ) {
+ if ( isset( $ex->sessionInfos ) && count( $ex->sessionInfos ) >= 2 ) {
+ // The exception is because the request had multiple possible
+ // sessions tied for top priority. Report this to the user.
+ $list = [];
+ foreach ( $ex->sessionInfos as $info ) {
+ $list[] = $info->getProvider()->describe( $wgContLang );
+ }
+ $list = $wgContLang->listToText( $list );
+ throw new HttpError( 400,
+ Message::newFromKey( 'sessionmanager-tie', $list )->inLanguage( $wgContLang )->plain()
+ );
+ }
+
+ // Not the one we want, rethrow
+ throw $ex;
+ }
+
+ if ( $session->isPersistent() ) {
+ $wgInitialSessionId = $session->getSessionId();
+ }
+
+ $session->renew();
+ if ( MediaWiki\Session\PHPSessionHandler::isEnabled() &&
+ ( $session->isPersistent() || $session->shouldRememberUser() )
+ ) {
+ // Start the PHP-session for backwards compatibility
+ session_id( $session->getId() );
+ MediaWiki\quietCall( 'session_start' );
+ }
+
+ unset( $session );
+} else {
+ // Even if we didn't set up a global Session, still install our session
+ // handler unless specifically requested not to.
+ if ( !defined( 'MW_NO_SESSION_HANDLER' ) ) {
+ MediaWiki\Session\PHPSessionHandler::install(
+ MediaWiki\Session\SessionManager::singleton()
+ );
+ }
+}
+Profiler::instance()->scopedProfileOut( $ps_session );
+
+/**
+ * @var User $wgUser
+ */
+$wgUser = RequestContext::getMain()->getUser(); // BackCompat
+
+/**
+ * @var Language $wgLang
+ */
+$wgLang = new StubUserLang;
+
+/**
+ * @var OutputPage $wgOut
+ */
+$wgOut = RequestContext::getMain()->getOutput(); // BackCompat
+
+/**
+ * @var Parser $wgParser
+ */
+$wgParser = new StubObject( 'wgParser', function () {
+ return MediaWikiServices::getInstance()->getParser();
+} );
+
+/**
+ * @var Title $wgTitle
+ */
+$wgTitle = null;
+
+Profiler::instance()->scopedProfileOut( $ps_globals );
+$ps_extensions = Profiler::instance()->scopedProfileIn( $fname . '-extensions' );
+
+// Extension setup functions
+// Entries should be added to this variable during the inclusion
+// of the extension file. This allows the extension to perform
+// any necessary initialisation in the fully initialised environment
+foreach ( $wgExtensionFunctions as $func ) {
+ // Allow closures in PHP 5.3+
+ if ( is_object( $func ) && $func instanceof Closure ) {
+ $profName = $fname . '-extensions-closure';
+ } elseif ( is_array( $func ) ) {
+ if ( is_object( $func[0] ) ) {
+ $profName = $fname . '-extensions-' . get_class( $func[0] ) . '::' . $func[1];
+ } else {
+ $profName = $fname . '-extensions-' . implode( '::', $func );
+ }
+ } else {
+ $profName = $fname . '-extensions-' . strval( $func );
+ }
+
+ $ps_ext_func = Profiler::instance()->scopedProfileIn( $profName );
+ call_user_func( $func );
+ Profiler::instance()->scopedProfileOut( $ps_ext_func );
+}
+
+// If the session user has a 0 id but a valid name, that means we need to
+// autocreate it.
+if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) {
+ $sessionUser = MediaWiki\Session\SessionManager::getGlobalSession()->getUser();
+ if ( $sessionUser->getId() === 0 && User::isValidUserName( $sessionUser->getName() ) ) {
+ $ps_autocreate = Profiler::instance()->scopedProfileIn( $fname . '-autocreate' );
+ $res = MediaWiki\Auth\AuthManager::singleton()->autoCreateUser(
+ $sessionUser,
+ MediaWiki\Auth\AuthManager::AUTOCREATE_SOURCE_SESSION,
+ true
+ );
+ Profiler::instance()->scopedProfileOut( $ps_autocreate );
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'authevents' )->info( 'Autocreation attempt', [
+ 'event' => 'autocreate',
+ 'status' => $res,
+ ] );
+ unset( $res );
+ }
+ unset( $sessionUser );
+}
+
+if ( !$wgCommandLineMode ) {
+ Pingback::schedulePingback();
+}
+
+$wgFullyInitialised = true;
+
+Profiler::instance()->scopedProfileOut( $ps_extensions );
+Profiler::instance()->scopedProfileOut( $ps_setup );
diff --git a/www/wiki/includes/SiteConfiguration.php b/www/wiki/includes/SiteConfiguration.php
new file mode 100644
index 00000000..7a01a657
--- /dev/null
+++ b/www/wiki/includes/SiteConfiguration.php
@@ -0,0 +1,609 @@
+<?php
+/**
+ * Configuration holder, particularly for multi-wiki sites.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This is a class for holding configuration settings, particularly for
+ * multi-wiki sites.
+ *
+ * A basic synopsis:
+ *
+ * Consider a wikifarm having three sites: two production sites, one in English
+ * and one in German, and one testing site. You can assign them easy-to-remember
+ * identifiers - ISO 639 codes 'en' and 'de' for language wikis, and 'beta' for
+ * the testing wiki.
+ *
+ * You would thus initialize the site configuration by specifying the wiki
+ * identifiers:
+ *
+ * @code
+ * $conf = new SiteConfiguration;
+ * $conf->wikis = [ 'de', 'en', 'beta' ];
+ * @endcode
+ *
+ * When configuring the MediaWiki global settings (the $wg variables),
+ * the identifiers will be available to specify settings on a per wiki basis.
+ *
+ * @code
+ * $conf->settings = [
+ * 'wgSomeSetting' => [
+ *
+ * # production:
+ * 'de' => false,
+ * 'en' => false,
+ *
+ * # test:
+ * 'beta => true,
+ * ],
+ * ];
+ * @endcode
+ *
+ * With three wikis, that is easy to manage. But what about a farm with
+ * hundreds of wikis? Site configuration provides a special keyword named
+ * 'default' which is the value used when a wiki is not found. Hence
+ * the above code could be written:
+ *
+ * @code
+ * $conf->settings = [
+ * 'wgSomeSetting' => [
+ *
+ * 'default' => false,
+ *
+ * # Enable feature on test
+ * 'beta' => true,
+ * ],
+ * ];
+ * @endcode
+ *
+ *
+ * Since settings can contain arrays, site configuration provides a way
+ * to merge an array with the default. This is very useful to avoid
+ * repeating settings again and again while still maintaining specific changes
+ * on a per wiki basis.
+ *
+ * @code
+ * $conf->settings = [
+ * 'wgMergeSetting' = [
+ * # Value that will be shared among all wikis:
+ * 'default' => [ NS_USER => true ],
+ *
+ * # Leading '+' means merging the array of value with the defaults
+ * '+beta' => [ NS_HELP => true ],
+ * ],
+ * ];
+ *
+ * # Get configuration for the German site:
+ * $conf->get( 'wgMergeSetting', 'de' );
+ * // --> [ NS_USER => true ];
+ *
+ * # Get configuration for the testing site:
+ * $conf->get( 'wgMergeSetting', 'beta' );
+ * // --> [ NS_USER => true, NS_HELP => true ];
+ * @endcode
+ *
+ * Finally, to load all configuration settings, extract them in global context:
+ *
+ * @code
+ * # Name / identifier of the wiki as set in $conf->wikis
+ * $wikiID = 'beta';
+ * $globals = $conf->getAll( $wikiID );
+ * extract( $globals );
+ * @endcode
+ *
+ * @note For WikiMap to function, the configuration must define string values for
+ * $wgServer (or $wgCanonicalServer) and $wgArticlePath, even if these are the
+ * same for all wikis or can be correctly determined by the logic in
+ * Setup.php.
+ *
+ * @todo Give examples for,
+ * suffixes:
+ * $conf->suffixes = [ 'wiki' ];
+ * localVHosts
+ * callbacks!
+ */
+class SiteConfiguration {
+
+ /**
+ * Array of suffixes, for self::siteFromDB()
+ */
+ public $suffixes = [];
+
+ /**
+ * Array of wikis, should be the same as $wgLocalDatabases
+ */
+ public $wikis = [];
+
+ /**
+ * The whole array of settings
+ */
+ public $settings = [];
+
+ /**
+ * Array of domains that are local and can be handled by the same server
+ *
+ * @deprecated since 1.25; use $wgLocalVirtualHosts instead.
+ */
+ public $localVHosts = [];
+
+ /**
+ * Optional callback to load full configuration data.
+ * @var string|array
+ */
+ public $fullLoadCallback = null;
+
+ /** Whether or not all data has been loaded */
+ public $fullLoadDone = false;
+
+ /**
+ * A callback function that returns an array with the following keys (all
+ * optional):
+ * - suffix: site's suffix
+ * - lang: site's lang
+ * - tags: array of wiki tags
+ * - params: array of parameters to be replaced
+ * The function will receive the SiteConfiguration instance in the first
+ * argument and the wiki in the second one.
+ * if suffix and lang are passed they will be used for the return value of
+ * self::siteFromDB() and self::$suffixes will be ignored
+ *
+ * @var string|array
+ */
+ public $siteParamsCallback = null;
+
+ /**
+ * Configuration cache for getConfig()
+ * @var array
+ */
+ protected $cfgCache = [];
+
+ /**
+ * Retrieves a configuration setting for a given wiki.
+ * @param string $settingName ID of the setting name to retrieve
+ * @param string $wiki Wiki ID of the wiki in question.
+ * @param string $suffix The suffix of the wiki in question.
+ * @param array $params List of parameters. $.'key' is replaced by $value in all returned data.
+ * @param array $wikiTags The tags assigned to the wiki.
+ * @return mixed The value of the setting requested.
+ */
+ public function get( $settingName, $wiki, $suffix = null, $params = [],
+ $wikiTags = []
+ ) {
+ $params = $this->mergeParams( $wiki, $suffix, $params, $wikiTags );
+ return $this->getSetting( $settingName, $wiki, $params );
+ }
+
+ /**
+ * Really retrieves a configuration setting for a given wiki.
+ *
+ * @param string $settingName ID of the setting name to retrieve.
+ * @param string $wiki Wiki ID of the wiki in question.
+ * @param array $params Array of parameters.
+ * @return mixed The value of the setting requested.
+ */
+ protected function getSetting( $settingName, $wiki, array $params ) {
+ $retval = null;
+ if ( array_key_exists( $settingName, $this->settings ) ) {
+ $thisSetting =& $this->settings[$settingName];
+ do {
+ // Do individual wiki settings
+ if ( array_key_exists( $wiki, $thisSetting ) ) {
+ $retval = $thisSetting[$wiki];
+ break;
+ } elseif ( array_key_exists( "+$wiki", $thisSetting ) && is_array( $thisSetting["+$wiki"] ) ) {
+ $retval = $thisSetting["+$wiki"];
+ }
+
+ // Do tag settings
+ foreach ( $params['tags'] as $tag ) {
+ if ( array_key_exists( $tag, $thisSetting ) ) {
+ if ( is_array( $retval ) && is_array( $thisSetting[$tag] ) ) {
+ $retval = self::arrayMerge( $retval, $thisSetting[$tag] );
+ } else {
+ $retval = $thisSetting[$tag];
+ }
+ break 2;
+ } elseif ( array_key_exists( "+$tag", $thisSetting ) && is_array( $thisSetting["+$tag"] ) ) {
+ if ( $retval === null ) {
+ $retval = [];
+ }
+ $retval = self::arrayMerge( $retval, $thisSetting["+$tag"] );
+ }
+ }
+ // Do suffix settings
+ $suffix = $params['suffix'];
+ if ( !is_null( $suffix ) ) {
+ if ( array_key_exists( $suffix, $thisSetting ) ) {
+ if ( is_array( $retval ) && is_array( $thisSetting[$suffix] ) ) {
+ $retval = self::arrayMerge( $retval, $thisSetting[$suffix] );
+ } else {
+ $retval = $thisSetting[$suffix];
+ }
+ break;
+ } elseif ( array_key_exists( "+$suffix", $thisSetting )
+ && is_array( $thisSetting["+$suffix"] )
+ ) {
+ if ( $retval === null ) {
+ $retval = [];
+ }
+ $retval = self::arrayMerge( $retval, $thisSetting["+$suffix"] );
+ }
+ }
+
+ // Fall back to default.
+ if ( array_key_exists( 'default', $thisSetting ) ) {
+ if ( is_array( $retval ) && is_array( $thisSetting['default'] ) ) {
+ $retval = self::arrayMerge( $retval, $thisSetting['default'] );
+ } else {
+ $retval = $thisSetting['default'];
+ }
+ break;
+ }
+ } while ( false );
+ }
+
+ if ( !is_null( $retval ) && count( $params['params'] ) ) {
+ foreach ( $params['params'] as $key => $value ) {
+ $retval = $this->doReplace( '$' . $key, $value, $retval );
+ }
+ }
+ return $retval;
+ }
+
+ /**
+ * Type-safe string replace; won't do replacements on non-strings
+ * private?
+ *
+ * @param string $from
+ * @param string $to
+ * @param string|array $in
+ * @return string|array
+ */
+ function doReplace( $from, $to, $in ) {
+ if ( is_string( $in ) ) {
+ return str_replace( $from, $to, $in );
+ } elseif ( is_array( $in ) ) {
+ foreach ( $in as $key => $val ) {
+ $in[$key] = $this->doReplace( $from, $to, $val );
+ }
+ return $in;
+ } else {
+ return $in;
+ }
+ }
+
+ /**
+ * Gets all settings for a wiki
+ * @param string $wiki Wiki ID of the wiki in question.
+ * @param string $suffix The suffix of the wiki in question.
+ * @param array $params List of parameters. $.'key' is replaced by $value in all returned data.
+ * @param array $wikiTags The tags assigned to the wiki.
+ * @return array Array of settings requested.
+ */
+ public function getAll( $wiki, $suffix = null, $params = [], $wikiTags = [] ) {
+ $params = $this->mergeParams( $wiki, $suffix, $params, $wikiTags );
+ $localSettings = [];
+ foreach ( $this->settings as $varname => $stuff ) {
+ $append = false;
+ $var = $varname;
+ if ( substr( $varname, 0, 1 ) == '+' ) {
+ $append = true;
+ $var = substr( $varname, 1 );
+ }
+
+ $value = $this->getSetting( $varname, $wiki, $params );
+ if ( $append && is_array( $value ) && is_array( $GLOBALS[$var] ) ) {
+ $value = self::arrayMerge( $value, $GLOBALS[$var] );
+ }
+ if ( !is_null( $value ) ) {
+ $localSettings[$var] = $value;
+ }
+ }
+ return $localSettings;
+ }
+
+ /**
+ * Retrieves a configuration setting for a given wiki, forced to a boolean.
+ * @param string $setting ID of the setting name to retrieve
+ * @param string $wiki Wiki ID of the wiki in question.
+ * @param string $suffix The suffix of the wiki in question.
+ * @param array $wikiTags The tags assigned to the wiki.
+ * @return bool The value of the setting requested.
+ */
+ public function getBool( $setting, $wiki, $suffix = null, $wikiTags = [] ) {
+ return (bool)$this->get( $setting, $wiki, $suffix, [], $wikiTags );
+ }
+
+ /**
+ * Retrieves an array of local databases
+ *
+ * @return array
+ */
+ function &getLocalDatabases() {
+ return $this->wikis;
+ }
+
+ /**
+ * Retrieves the value of a given setting, and places it in a variable passed by reference.
+ * @param string $setting ID of the setting name to retrieve
+ * @param string $wiki Wiki ID of the wiki in question.
+ * @param string $suffix The suffix of the wiki in question.
+ * @param array &$var Reference The variable to insert the value into.
+ * @param array $params List of parameters. $.'key' is replaced by $value in all returned data.
+ * @param array $wikiTags The tags assigned to the wiki.
+ */
+ public function extractVar( $setting, $wiki, $suffix, &$var,
+ $params = [], $wikiTags = []
+ ) {
+ $value = $this->get( $setting, $wiki, $suffix, $params, $wikiTags );
+ if ( !is_null( $value ) ) {
+ $var = $value;
+ }
+ }
+
+ /**
+ * Retrieves the value of a given setting, and places it in its corresponding global variable.
+ * @param string $setting ID of the setting name to retrieve
+ * @param string $wiki Wiki ID of the wiki in question.
+ * @param string $suffix The suffix of the wiki in question.
+ * @param array $params List of parameters. $.'key' is replaced by $value in all returned data.
+ * @param array $wikiTags The tags assigned to the wiki.
+ */
+ public function extractGlobal( $setting, $wiki, $suffix = null,
+ $params = [], $wikiTags = []
+ ) {
+ $params = $this->mergeParams( $wiki, $suffix, $params, $wikiTags );
+ $this->extractGlobalSetting( $setting, $wiki, $params );
+ }
+
+ /**
+ * @param string $setting
+ * @param string $wiki
+ * @param array $params
+ */
+ public function extractGlobalSetting( $setting, $wiki, $params ) {
+ $value = $this->getSetting( $setting, $wiki, $params );
+ if ( !is_null( $value ) ) {
+ if ( substr( $setting, 0, 1 ) == '+' && is_array( $value ) ) {
+ $setting = substr( $setting, 1 );
+ if ( is_array( $GLOBALS[$setting] ) ) {
+ $GLOBALS[$setting] = self::arrayMerge( $GLOBALS[$setting], $value );
+ } else {
+ $GLOBALS[$setting] = $value;
+ }
+ } else {
+ $GLOBALS[$setting] = $value;
+ }
+ }
+ }
+
+ /**
+ * Retrieves the values of all settings, and places them in their corresponding global variables.
+ * @param string $wiki Wiki ID of the wiki in question.
+ * @param string $suffix The suffix of the wiki in question.
+ * @param array $params List of parameters. $.'key' is replaced by $value in all returned data.
+ * @param array $wikiTags The tags assigned to the wiki.
+ */
+ public function extractAllGlobals( $wiki, $suffix = null, $params = [],
+ $wikiTags = []
+ ) {
+ $params = $this->mergeParams( $wiki, $suffix, $params, $wikiTags );
+ foreach ( $this->settings as $varName => $setting ) {
+ $this->extractGlobalSetting( $varName, $wiki, $params );
+ }
+ }
+
+ /**
+ * Return specific settings for $wiki
+ * See the documentation of self::$siteParamsCallback for more in-depth
+ * documentation about this function
+ *
+ * @param string $wiki
+ * @return array
+ */
+ protected function getWikiParams( $wiki ) {
+ static $default = [
+ 'suffix' => null,
+ 'lang' => null,
+ 'tags' => [],
+ 'params' => [],
+ ];
+
+ if ( !is_callable( $this->siteParamsCallback ) ) {
+ return $default;
+ }
+
+ $ret = call_user_func_array( $this->siteParamsCallback, [ $this, $wiki ] );
+ # Validate the returned value
+ if ( !is_array( $ret ) ) {
+ return $default;
+ }
+
+ foreach ( $default as $name => $def ) {
+ if ( !isset( $ret[$name] ) || ( is_array( $default[$name] ) && !is_array( $ret[$name] ) ) ) {
+ $ret[$name] = $default[$name];
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Merge params between the ones passed to the function and the ones given
+ * by self::$siteParamsCallback for backward compatibility
+ * Values returned by self::getWikiParams() have the priority.
+ *
+ * @param string $wiki Wiki ID of the wiki in question.
+ * @param string $suffix The suffix of the wiki in question.
+ * @param array $params List of parameters. $.'key' is replaced by $value in
+ * all returned data.
+ * @param array $wikiTags The tags assigned to the wiki.
+ * @return array
+ */
+ protected function mergeParams( $wiki, $suffix, array $params, array $wikiTags ) {
+ $ret = $this->getWikiParams( $wiki );
+
+ if ( is_null( $ret['suffix'] ) ) {
+ $ret['suffix'] = $suffix;
+ }
+
+ $ret['tags'] = array_unique( array_merge( $ret['tags'], $wikiTags ) );
+
+ $ret['params'] += $params;
+
+ // Automatically fill that ones if needed
+ if ( !isset( $ret['params']['lang'] ) && !is_null( $ret['lang'] ) ) {
+ $ret['params']['lang'] = $ret['lang'];
+ }
+ if ( !isset( $ret['params']['site'] ) && !is_null( $ret['suffix'] ) ) {
+ $ret['params']['site'] = $ret['suffix'];
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Work out the site and language name from a database name
+ * @param string $db
+ *
+ * @return array
+ */
+ public function siteFromDB( $db ) {
+ // Allow override
+ $def = $this->getWikiParams( $db );
+ if ( !is_null( $def['suffix'] ) && !is_null( $def['lang'] ) ) {
+ return [ $def['suffix'], $def['lang'] ];
+ }
+
+ $site = null;
+ $lang = null;
+ foreach ( $this->suffixes as $altSite => $suffix ) {
+ if ( $suffix === '' ) {
+ $site = '';
+ $lang = $db;
+ break;
+ } elseif ( substr( $db, -strlen( $suffix ) ) == $suffix ) {
+ $site = is_numeric( $altSite ) ? $suffix : $altSite;
+ $lang = substr( $db, 0, strlen( $db ) - strlen( $suffix ) );
+ break;
+ }
+ }
+ $lang = str_replace( '_', '-', $lang );
+ return [ $site, $lang ];
+ }
+
+ /**
+ * Get the resolved (post-setup) configuration of a potentially foreign wiki.
+ * For foreign wikis, this is expensive, and only works if maintenance
+ * scripts are setup to handle the --wiki parameter such as in wiki farms.
+ *
+ * @param string $wiki
+ * @param array|string $settings A setting name or array of setting names
+ * @return mixed|mixed[] Array if $settings is an array, otherwise the value
+ * @throws MWException
+ * @since 1.21
+ */
+ public function getConfig( $wiki, $settings ) {
+ global $IP;
+
+ $multi = is_array( $settings );
+ $settings = (array)$settings;
+ if ( $wiki === wfWikiID() ) { // $wiki is this wiki
+ $res = [];
+ foreach ( $settings as $name ) {
+ if ( !preg_match( '/^wg[A-Z]/', $name ) ) {
+ throw new MWException( "Variable '$name' does start with 'wg'." );
+ } elseif ( !isset( $GLOBALS[$name] ) ) {
+ throw new MWException( "Variable '$name' is not set." );
+ }
+ $res[$name] = $GLOBALS[$name];
+ }
+ } else { // $wiki is a foreign wiki
+ if ( isset( $this->cfgCache[$wiki] ) ) {
+ $res = array_intersect_key( $this->cfgCache[$wiki], array_flip( $settings ) );
+ if ( count( $res ) == count( $settings ) ) {
+ return $multi ? $res : current( $res ); // cache hit
+ }
+ } elseif ( !in_array( $wiki, $this->wikis ) ) {
+ throw new MWException( "No such wiki '$wiki'." );
+ } else {
+ $this->cfgCache[$wiki] = [];
+ }
+ $retVal = 1;
+ $cmd = wfShellWikiCmd(
+ "$IP/maintenance/getConfiguration.php",
+ [
+ '--wiki', $wiki,
+ '--settings', implode( ' ', $settings ),
+ '--format', 'PHP'
+ ]
+ );
+ // ulimit5.sh breaks this call
+ $data = trim( wfShellExec( $cmd, $retVal, [], [ 'memory' => 0 ] ) );
+ if ( $retVal != 0 || !strlen( $data ) ) {
+ throw new MWException( "Failed to run getConfiguration.php." );
+ }
+ $res = unserialize( $data );
+ if ( !is_array( $res ) ) {
+ throw new MWException( "Failed to unserialize configuration array." );
+ }
+ $this->cfgCache[$wiki] = $this->cfgCache[$wiki] + $res;
+ }
+
+ return $multi ? $res : current( $res );
+ }
+
+ /**
+ * Merge multiple arrays together.
+ * On encountering duplicate keys, merge the two, but ONLY if they're arrays.
+ * PHP's array_merge_recursive() merges ANY duplicate values into arrays,
+ * which is not fun
+ *
+ * @param array $array1
+ *
+ * @return array
+ */
+ static function arrayMerge( $array1/* ... */ ) {
+ $out = $array1;
+ $argsCount = func_num_args();
+ for ( $i = 1; $i < $argsCount; $i++ ) {
+ foreach ( func_get_arg( $i ) as $key => $value ) {
+ if ( isset( $out[$key] ) && is_array( $out[$key] ) && is_array( $value ) ) {
+ $out[$key] = self::arrayMerge( $out[$key], $value );
+ } elseif ( !isset( $out[$key] ) || !$out[$key] && !is_numeric( $key ) ) {
+ // Values that evaluate to true given precedence, for the
+ // primary purpose of merging permissions arrays.
+ $out[$key] = $value;
+ } elseif ( is_numeric( $key ) ) {
+ $out[] = $value;
+ }
+ }
+ }
+
+ return $out;
+ }
+
+ public function loadFullData() {
+ if ( $this->fullLoadCallback && !$this->fullLoadDone ) {
+ call_user_func( $this->fullLoadCallback, $this );
+ $this->fullLoadDone = true;
+ }
+ }
+}
diff --git a/www/wiki/includes/SiteStats.php b/www/wiki/includes/SiteStats.php
new file mode 100644
index 00000000..ce87596a
--- /dev/null
+++ b/www/wiki/includes/SiteStats.php
@@ -0,0 +1,423 @@
+<?php
+/**
+ * Accessors and mutators for the site-wide statistics.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Static accessor class for site_stats and related things
+ */
+class SiteStats {
+ /** @var bool|stdClass */
+ private static $row;
+
+ /** @var bool */
+ private static $loaded = false;
+
+ /** @var int[] */
+ private static $pageCount = [];
+
+ static function unload() {
+ self::$loaded = false;
+ }
+
+ static function recache() {
+ self::load( true );
+ }
+
+ /**
+ * @param bool $recache
+ */
+ static function load( $recache = false ) {
+ if ( self::$loaded && !$recache ) {
+ return;
+ }
+
+ self::$row = self::loadAndLazyInit();
+
+ # This code is somewhat schema-agnostic, because I'm changing it in a minor release -- TS
+ if ( !isset( self::$row->ss_total_pages ) && self::$row->ss_total_pages == -1 ) {
+ # Update schema
+ $u = new SiteStatsUpdate( 0, 0, 0 );
+ $u->doUpdate();
+ self::$row = self::doLoad( wfGetDB( DB_REPLICA ) );
+ }
+
+ self::$loaded = true;
+ }
+
+ /**
+ * @return bool|stdClass
+ */
+ static function loadAndLazyInit() {
+ global $wgMiserMode;
+
+ wfDebug( __METHOD__ . ": reading site_stats from replica DB\n" );
+ $row = self::doLoad( wfGetDB( DB_REPLICA ) );
+
+ if ( !self::isSane( $row ) ) {
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ if ( $lb->hasOrMadeRecentMasterChanges() ) {
+ // Might have just been initialized during this request? Underflow?
+ wfDebug( __METHOD__ . ": site_stats damaged or missing on replica DB\n" );
+ $row = self::doLoad( wfGetDB( DB_MASTER ) );
+ }
+ }
+
+ if ( !$wgMiserMode && !self::isSane( $row ) ) {
+ // Normally the site_stats table is initialized at install time.
+ // Some manual construction scenarios may leave the table empty or
+ // broken, however, for instance when importing from a dump into a
+ // clean schema with mwdumper.
+ wfDebug( __METHOD__ . ": initializing damaged or missing site_stats\n" );
+
+ SiteStatsInit::doAllAndCommit( wfGetDB( DB_REPLICA ) );
+
+ $row = self::doLoad( wfGetDB( DB_MASTER ) );
+ }
+
+ if ( !self::isSane( $row ) ) {
+ wfDebug( __METHOD__ . ": site_stats persistently nonsensical o_O\n" );
+ }
+
+ return $row;
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return bool|stdClass
+ */
+ static function doLoad( $db ) {
+ return $db->selectRow( 'site_stats', [
+ 'ss_row_id',
+ 'ss_total_edits',
+ 'ss_good_articles',
+ 'ss_total_pages',
+ 'ss_users',
+ 'ss_active_users',
+ 'ss_images',
+ ], [], __METHOD__ );
+ }
+
+ /**
+ * Return the total number of page views. Except we don't track those anymore.
+ * Stop calling this function, it will be removed some time in the future. It's
+ * kept here simply to prevent fatal errors.
+ *
+ * @deprecated since 1.25
+ * @return int
+ */
+ static function views() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return 0;
+ }
+
+ /**
+ * @return int
+ */
+ static function edits() {
+ self::load();
+ return self::$row->ss_total_edits;
+ }
+
+ /**
+ * @return int
+ */
+ static function articles() {
+ self::load();
+ return self::$row->ss_good_articles;
+ }
+
+ /**
+ * @return int
+ */
+ static function pages() {
+ self::load();
+ return self::$row->ss_total_pages;
+ }
+
+ /**
+ * @return int
+ */
+ static function users() {
+ self::load();
+ return self::$row->ss_users;
+ }
+
+ /**
+ * @return int
+ */
+ static function activeUsers() {
+ self::load();
+ return self::$row->ss_active_users;
+ }
+
+ /**
+ * @return int
+ */
+ static function images() {
+ self::load();
+ return self::$row->ss_images;
+ }
+
+ /**
+ * Find the number of users in a given user group.
+ * @param string $group Name of group
+ * @return int
+ */
+ static function numberingroup( $group ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'SiteStats', 'groupcounts', $group ),
+ $cache::TTL_HOUR,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $group ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $setOpts += Database::getCacheSetOptions( $dbr );
+
+ return $dbr->selectField(
+ 'user_groups',
+ 'COUNT(*)',
+ [
+ 'ug_group' => $group,
+ 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
+ ],
+ __METHOD__
+ );
+ },
+ [ 'pcTTL' => $cache::TTL_PROC_LONG ]
+ );
+ }
+
+ /**
+ * Total number of jobs in the job queue.
+ * @return int
+ */
+ static function jobs() {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'SiteStats', 'jobscount' ),
+ $cache::TTL_MINUTE,
+ function ( $oldValue, &$ttl, array &$setOpts ) {
+ try{
+ $jobs = array_sum( JobQueueGroup::singleton()->getQueueSizes() );
+ } catch ( JobQueueError $e ) {
+ $jobs = 0;
+ }
+ return $jobs;
+ },
+ [ 'pcTTL' => $cache::TTL_PROC_LONG ]
+ );
+ }
+
+ /**
+ * @param int $ns
+ *
+ * @return int
+ */
+ static function pagesInNs( $ns ) {
+ if ( !isset( self::$pageCount[$ns] ) ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ self::$pageCount[$ns] = (int)$dbr->selectField(
+ 'page',
+ 'COUNT(*)',
+ [ 'page_namespace' => $ns ],
+ __METHOD__
+ );
+ }
+ return self::$pageCount[$ns];
+ }
+
+ /**
+ * Is the provided row of site stats sane, or should it be regenerated?
+ *
+ * Checks only fields which are filled by SiteStatsInit::refresh.
+ *
+ * @param bool|object $row
+ *
+ * @return bool
+ */
+ private static function isSane( $row ) {
+ if ( $row === false
+ || $row->ss_total_pages < $row->ss_good_articles
+ || $row->ss_total_edits < $row->ss_total_pages
+ ) {
+ return false;
+ }
+ // Now check for underflow/overflow
+ foreach ( [
+ 'ss_total_edits',
+ 'ss_good_articles',
+ 'ss_total_pages',
+ 'ss_users',
+ 'ss_images',
+ ] as $member ) {
+ if ( $row->$member > 2000000000 || $row->$member < 0 ) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
+
+/**
+ * Class designed for counting of stats.
+ */
+class SiteStatsInit {
+
+ // Database connection
+ private $db;
+
+ // Various stats
+ private $mEdits = null, $mArticles = null, $mPages = null;
+ private $mUsers = null, $mFiles = null;
+
+ /**
+ * @param bool|IDatabase $database
+ * - bool: Whether to use the master DB
+ * - IDatabase: Database connection to use
+ */
+ public function __construct( $database = false ) {
+ if ( $database instanceof IDatabase ) {
+ $this->db = $database;
+ } elseif ( $database ) {
+ $this->db = wfGetDB( DB_MASTER );
+ } else {
+ $this->db = wfGetDB( DB_REPLICA, 'vslow' );
+ }
+ }
+
+ /**
+ * Count the total number of edits
+ * @return int
+ */
+ public function edits() {
+ $this->mEdits = $this->db->selectField( 'revision', 'COUNT(*)', '', __METHOD__ );
+ $this->mEdits += $this->db->selectField( 'archive', 'COUNT(*)', '', __METHOD__ );
+ return $this->mEdits;
+ }
+
+ /**
+ * Count pages in article space(s)
+ * @return int
+ */
+ public function articles() {
+ global $wgArticleCountMethod;
+
+ $tables = [ 'page' ];
+ $conds = [
+ 'page_namespace' => MWNamespace::getContentNamespaces(),
+ 'page_is_redirect' => 0,
+ ];
+
+ if ( $wgArticleCountMethod == 'link' ) {
+ $tables[] = 'pagelinks';
+ $conds[] = 'pl_from=page_id';
+ } elseif ( $wgArticleCountMethod == 'comma' ) {
+ // To make a correct check for this, we would need, for each page,
+ // to load the text, maybe uncompress it, maybe decode it and then
+ // check if there's one comma.
+ // But one thing we are sure is that if the page is empty, it can't
+ // contain a comma :)
+ $conds[] = 'page_len > 0';
+ }
+
+ $this->mArticles = $this->db->selectField( $tables, 'COUNT(DISTINCT page_id)',
+ $conds, __METHOD__ );
+ return $this->mArticles;
+ }
+
+ /**
+ * Count total pages
+ * @return int
+ */
+ public function pages() {
+ $this->mPages = $this->db->selectField( 'page', 'COUNT(*)', '', __METHOD__ );
+ return $this->mPages;
+ }
+
+ /**
+ * Count total users
+ * @return int
+ */
+ public function users() {
+ $this->mUsers = $this->db->selectField( 'user', 'COUNT(*)', '', __METHOD__ );
+ return $this->mUsers;
+ }
+
+ /**
+ * Count total files
+ * @return int
+ */
+ public function files() {
+ $this->mFiles = $this->db->selectField( 'image', 'COUNT(*)', '', __METHOD__ );
+ return $this->mFiles;
+ }
+
+ /**
+ * Do all updates and commit them. More or less a replacement
+ * for the original initStats, but without output.
+ *
+ * @param IDatabase|bool $database
+ * - bool: Whether to use the master DB
+ * - IDatabase: Database connection to use
+ * @param array $options Array of options, may contain the following values
+ * - activeUsers bool: Whether to update the number of active users (default: false)
+ */
+ public static function doAllAndCommit( $database, array $options = [] ) {
+ $options += [ 'update' => false, 'activeUsers' => false ];
+
+ // Grab the object and count everything
+ $counter = new SiteStatsInit( $database );
+
+ $counter->edits();
+ $counter->articles();
+ $counter->pages();
+ $counter->users();
+ $counter->files();
+
+ $counter->refresh();
+
+ // Count active users if need be
+ if ( $options['activeUsers'] ) {
+ SiteStatsUpdate::cacheUpdate( wfGetDB( DB_MASTER ) );
+ }
+ }
+
+ /**
+ * Refresh site_stats
+ */
+ public function refresh() {
+ $values = [
+ 'ss_row_id' => 1,
+ 'ss_total_edits' => ( $this->mEdits === null ? $this->edits() : $this->mEdits ),
+ 'ss_good_articles' => ( $this->mArticles === null ? $this->articles() : $this->mArticles ),
+ 'ss_total_pages' => ( $this->mPages === null ? $this->pages() : $this->mPages ),
+ 'ss_users' => ( $this->mUsers === null ? $this->users() : $this->mUsers ),
+ 'ss_images' => ( $this->mFiles === null ? $this->files() : $this->mFiles ),
+ ];
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->upsert( 'site_stats', $values, [ 'ss_row_id' ], $values, __METHOD__ );
+ }
+}
diff --git a/www/wiki/includes/Status.php b/www/wiki/includes/Status.php
new file mode 100644
index 00000000..a35af6e8
--- /dev/null
+++ b/www/wiki/includes/Status.php
@@ -0,0 +1,401 @@
+<?php
+/**
+ * Generic operation result.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Generic operation result class
+ * Has warning/error list, boolean status and arbitrary value
+ *
+ * "Good" means the operation was completed with no warnings or errors.
+ *
+ * "OK" means the operation was partially or wholly completed.
+ *
+ * An operation which is not OK should have errors so that the user can be
+ * informed as to what went wrong. Calling the fatal() function sets an error
+ * message and simultaneously switches off the OK flag.
+ *
+ * The recommended pattern for Status objects is to return a Status object
+ * unconditionally, i.e. both on success and on failure -- so that the
+ * developer of the calling code is reminded that the function can fail, and
+ * so that a lack of error-handling will be explicit.
+ */
+class Status extends StatusValue {
+ /** @var callable */
+ public $cleanCallback = false;
+
+ /**
+ * Succinct helper method to wrap a StatusValue
+ *
+ * This is is useful when formatting StatusValue objects:
+ * @code
+ * $this->getOutput()->addHtml( Status::wrap( $sv )->getHTML() );
+ * @endcode
+ *
+ * @param StatusValue|Status $sv
+ * @return Status
+ */
+ public static function wrap( $sv ) {
+ if ( $sv instanceof static ) {
+ return $sv;
+ }
+
+ $result = new static();
+ $result->ok =& $sv->ok;
+ $result->errors =& $sv->errors;
+ $result->value =& $sv->value;
+ $result->successCount =& $sv->successCount;
+ $result->failCount =& $sv->failCount;
+ $result->success =& $sv->success;
+
+ return $result;
+ }
+
+ /**
+ * Backwards compatibility logic
+ *
+ * @param string $name
+ * @return mixed
+ * @throws RuntimeException
+ */
+ function __get( $name ) {
+ if ( $name === 'ok' ) {
+ return $this->isOK();
+ } elseif ( $name === 'errors' ) {
+ return $this->getErrors();
+ }
+
+ throw new RuntimeException( "Cannot get '$name' property." );
+ }
+
+ /**
+ * Change operation result
+ * Backwards compatibility logic
+ *
+ * @param string $name
+ * @param mixed $value
+ * @throws RuntimeException
+ */
+ function __set( $name, $value ) {
+ if ( $name === 'ok' ) {
+ $this->setOK( $value );
+ } elseif ( !property_exists( $this, $name ) ) {
+ // Caller is using undeclared ad-hoc properties
+ $this->$name = $value;
+ } else {
+ throw new RuntimeException( "Cannot set '$name' property." );
+ }
+ }
+
+ /**
+ * Splits this Status object into two new Status objects, one which contains only
+ * the error messages, and one that contains the warnings, only. The returned array is
+ * defined as:
+ * [
+ * 0 => object(Status) # the Status with error messages, only
+ * 1 => object(Status) # The Status with warning messages, only
+ * ]
+ *
+ * @return Status[]
+ */
+ public function splitByErrorType() {
+ list( $errorsOnlyStatus, $warningsOnlyStatus ) = parent::splitByErrorType();
+ $errorsOnlyStatus->cleanCallback =
+ $warningsOnlyStatus->cleanCallback = $this->cleanCallback;
+
+ return [ $errorsOnlyStatus, $warningsOnlyStatus ];
+ }
+
+ /**
+ * Returns the wrapped StatusValue object
+ * @return StatusValue
+ * @since 1.27
+ */
+ public function getStatusValue() {
+ return $this;
+ }
+
+ /**
+ * @param array $params
+ * @return array
+ */
+ protected function cleanParams( array $params ) {
+ if ( !$this->cleanCallback ) {
+ return $params;
+ }
+ $cleanParams = [];
+ foreach ( $params as $i => $param ) {
+ $cleanParams[$i] = call_user_func( $this->cleanCallback, $param );
+ }
+ return $cleanParams;
+ }
+
+ /**
+ * @param string|Language|null $lang Language to use for processing
+ * messages, or null to default to the user language.
+ * @return Language
+ */
+ protected function languageFromParam( $lang ) {
+ global $wgLang;
+
+ if ( $lang === null ) {
+ // @todo: Use RequestContext::getMain()->getLanguage() instead
+ return $wgLang;
+ } elseif ( $lang instanceof Language || $lang instanceof StubUserLang ) {
+ return $lang;
+ } else {
+ return Language::factory( $lang );
+ }
+ }
+
+ /**
+ * Get the error list as a wikitext formatted list
+ *
+ * @param string|bool $shortContext A short enclosing context message name, to
+ * be used when there is a single error
+ * @param string|bool $longContext A long enclosing context message name, for a list
+ * @param string|Language $lang Language to use for processing messages
+ * @return string
+ */
+ public function getWikiText( $shortContext = false, $longContext = false, $lang = null ) {
+ $lang = $this->languageFromParam( $lang );
+
+ $rawErrors = $this->getErrors();
+ if ( count( $rawErrors ) == 0 ) {
+ if ( $this->isOK() ) {
+ $this->fatal( 'internalerror_info',
+ __METHOD__ . " called for a good result, this is incorrect\n" );
+ } else {
+ $this->fatal( 'internalerror_info',
+ __METHOD__ . ": Invalid result object: no error text but not OK\n" );
+ }
+ $rawErrors = $this->getErrors(); // just added a fatal
+ }
+ if ( count( $rawErrors ) == 1 ) {
+ $s = $this->getErrorMessage( $rawErrors[0], $lang )->plain();
+ if ( $shortContext ) {
+ $s = wfMessage( $shortContext, $s )->inLanguage( $lang )->plain();
+ } elseif ( $longContext ) {
+ $s = wfMessage( $longContext, "* $s\n" )->inLanguage( $lang )->plain();
+ }
+ } else {
+ $errors = $this->getErrorMessageArray( $rawErrors, $lang );
+ foreach ( $errors as &$error ) {
+ $error = $error->plain();
+ }
+ $s = '* ' . implode( "\n* ", $errors ) . "\n";
+ if ( $longContext ) {
+ $s = wfMessage( $longContext, $s )->inLanguage( $lang )->plain();
+ } elseif ( $shortContext ) {
+ $s = wfMessage( $shortContext, "\n$s\n" )->inLanguage( $lang )->plain();
+ }
+ }
+ return $s;
+ }
+
+ /**
+ * Get a bullet list of the errors as a Message object.
+ *
+ * $shortContext and $longContext can be used to wrap the error list in some text.
+ * $shortContext will be preferred when there is a single error; $longContext will be
+ * preferred when there are multiple ones. In either case, $1 will be replaced with
+ * the list of errors.
+ *
+ * $shortContext is assumed to use $1 as an inline parameter: if there is a single item,
+ * it will not be made into a list; if there are multiple items, newlines will be inserted
+ * around the list.
+ * $longContext is assumed to use $1 as a standalone parameter; it will always receive a list.
+ *
+ * If both parameters are missing, and there is only one error, no bullet will be added.
+ *
+ * @param string|string[]|bool $shortContext A message name or an array of message names.
+ * @param string|string[]|bool $longContext A message name or an array of message names.
+ * @param string|Language $lang Language to use for processing messages
+ * @return Message
+ */
+ public function getMessage( $shortContext = false, $longContext = false, $lang = null ) {
+ $lang = $this->languageFromParam( $lang );
+
+ $rawErrors = $this->getErrors();
+ if ( count( $rawErrors ) == 0 ) {
+ if ( $this->isOK() ) {
+ $this->fatal( 'internalerror_info',
+ __METHOD__ . " called for a good result, this is incorrect\n" );
+ } else {
+ $this->fatal( 'internalerror_info',
+ __METHOD__ . ": Invalid result object: no error text but not OK\n" );
+ }
+ $rawErrors = $this->getErrors(); // just added a fatal
+ }
+ if ( count( $rawErrors ) == 1 ) {
+ $s = $this->getErrorMessage( $rawErrors[0], $lang );
+ if ( $shortContext ) {
+ $s = wfMessage( $shortContext, $s )->inLanguage( $lang );
+ } elseif ( $longContext ) {
+ $wrapper = new RawMessage( "* \$1\n" );
+ $wrapper->params( $s )->parse();
+ $s = wfMessage( $longContext, $wrapper )->inLanguage( $lang );
+ }
+ } else {
+ $msgs = $this->getErrorMessageArray( $rawErrors, $lang );
+ $msgCount = count( $msgs );
+
+ $s = new RawMessage( '* $' . implode( "\n* \$", range( 1, $msgCount ) ) );
+ $s->params( $msgs )->parse();
+
+ if ( $longContext ) {
+ $s = wfMessage( $longContext, $s )->inLanguage( $lang );
+ } elseif ( $shortContext ) {
+ $wrapper = new RawMessage( "\n\$1\n", [ $s ] );
+ $wrapper->parse();
+ $s = wfMessage( $shortContext, $wrapper )->inLanguage( $lang );
+ }
+ }
+
+ return $s;
+ }
+
+ /**
+ * Return the message for a single error
+ *
+ * The code string can be used a message key with per-language versions.
+ * If $error is an array, the "params" field is a list of parameters for the message.
+ *
+ * @param array|string $error Code string or (key: code string, params: string[]) map
+ * @param string|Language $lang Language to use for processing messages
+ * @return Message
+ */
+ protected function getErrorMessage( $error, $lang = null ) {
+ if ( is_array( $error ) ) {
+ if ( isset( $error['message'] ) && $error['message'] instanceof Message ) {
+ $msg = $error['message'];
+ } elseif ( isset( $error['message'] ) && isset( $error['params'] ) ) {
+ $msg = wfMessage( $error['message'],
+ array_map( 'wfEscapeWikiText', $this->cleanParams( $error['params'] ) ) );
+ } else {
+ $msgName = array_shift( $error );
+ $msg = wfMessage( $msgName,
+ array_map( 'wfEscapeWikiText', $this->cleanParams( $error ) ) );
+ }
+ } elseif ( is_string( $error ) ) {
+ $msg = wfMessage( $error );
+ } else {
+ throw new UnexpectedValueException( "Got " . get_class( $error ) . " for key." );
+ }
+
+ $msg->inLanguage( $this->languageFromParam( $lang ) );
+ return $msg;
+ }
+
+ /**
+ * Get the error message as HTML. This is done by parsing the wikitext error message
+ * @param string|bool $shortContext A short enclosing context message name, to
+ * be used when there is a single error
+ * @param string|bool $longContext A long enclosing context message name, for a list
+ * @param string|Language|null $lang Language to use for processing messages
+ * @return string
+ */
+ public function getHTML( $shortContext = false, $longContext = false, $lang = null ) {
+ $lang = $this->languageFromParam( $lang );
+ $text = $this->getWikiText( $shortContext, $longContext, $lang );
+ $out = MessageCache::singleton()->parse( $text, null, true, true, $lang );
+ return $out instanceof ParserOutput ? $out->getText() : $out;
+ }
+
+ /**
+ * Return an array with a Message object for each error.
+ * @param array $errors
+ * @param string|Language $lang Language to use for processing messages
+ * @return Message[]
+ */
+ protected function getErrorMessageArray( $errors, $lang = null ) {
+ $lang = $this->languageFromParam( $lang );
+ return array_map( function ( $e ) use ( $lang ) {
+ return $this->getErrorMessage( $e, $lang );
+ }, $errors );
+ }
+
+ /**
+ * Get the list of errors (but not warnings)
+ *
+ * @return array A list in which each entry is an array with a message key as its first element.
+ * The remaining array elements are the message parameters.
+ * @deprecated since 1.25
+ */
+ public function getErrorsArray() {
+ return $this->getStatusArray( 'error' );
+ }
+
+ /**
+ * Get the list of warnings (but not errors)
+ *
+ * @return array A list in which each entry is an array with a message key as its first element.
+ * The remaining array elements are the message parameters.
+ * @deprecated since 1.25
+ */
+ public function getWarningsArray() {
+ return $this->getStatusArray( 'warning' );
+ }
+
+ /**
+ * Returns a list of status messages of the given type (or all if false)
+ *
+ * @note: this handles RawMessage poorly
+ *
+ * @param string|bool $type
+ * @return array
+ */
+ protected function getStatusArray( $type = false ) {
+ $result = [];
+
+ foreach ( $this->getErrors() as $error ) {
+ if ( $type === false || $error['type'] === $type ) {
+ if ( $error['message'] instanceof MessageSpecifier ) {
+ $result[] = array_merge(
+ [ $error['message']->getKey() ],
+ $error['message']->getParams()
+ );
+ } elseif ( $error['params'] ) {
+ $result[] = array_merge( [ $error['message'] ], $error['params'] );
+ } else {
+ $result[] = [ $error['message'] ];
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Don't save the callback when serializing, because Closures can't be
+ * serialized and we're going to clear it in __wakeup anyway.
+ */
+ function __sleep() {
+ $keys = array_keys( get_object_vars( $this ) );
+ return array_diff( $keys, [ 'cleanCallback' ] );
+ }
+
+ /**
+ * Sanitize the callback parameter on wakeup, to avoid arbitrary execution.
+ */
+ function __wakeup() {
+ $this->cleanCallback = false;
+ }
+}
diff --git a/www/wiki/includes/StreamFile.php b/www/wiki/includes/StreamFile.php
new file mode 100644
index 00000000..71113a86
--- /dev/null
+++ b/www/wiki/includes/StreamFile.php
@@ -0,0 +1,144 @@
+<?php
+/**
+ * Functions related to the output of file content.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Functions related to the output of file content
+ */
+class StreamFile {
+ // Do not send any HTTP headers unless requested by caller (e.g. body only)
+ const STREAM_HEADLESS = HTTPFileStreamer::STREAM_HEADLESS;
+ // Do not try to tear down any PHP output buffers
+ const STREAM_ALLOW_OB = HTTPFileStreamer::STREAM_ALLOW_OB;
+
+ /**
+ * Stream a file to the browser, adding all the headings and fun stuff.
+ * Headers sent include: Content-type, Content-Length, Last-Modified,
+ * and Content-Disposition.
+ *
+ * @param string $fname Full name and path of the file to stream
+ * @param array $headers Any additional headers to send if the file exists
+ * @param bool $sendErrors Send error messages if errors occur (like 404)
+ * @param array $optHeaders HTTP request header map (e.g. "range") (use lowercase keys)
+ * @param int $flags Bitfield of STREAM_* constants
+ * @throws MWException
+ * @return bool Success
+ */
+ public static function stream(
+ $fname, $headers = [], $sendErrors = true, $optHeaders = [], $flags = 0
+ ) {
+ if ( FileBackend::isStoragePath( $fname ) ) { // sanity
+ throw new InvalidArgumentException( __FUNCTION__ . " given storage path '$fname'." );
+ }
+
+ $streamer = new HTTPFileStreamer(
+ $fname,
+ [
+ 'obResetFunc' => 'wfResetOutputBuffers',
+ 'streamMimeFunc' => [ __CLASS__, 'contentTypeFromPath' ]
+ ]
+ );
+
+ return $streamer->stream( $headers, $sendErrors, $optHeaders, $flags );
+ }
+
+ /**
+ * Send out a standard 404 message for a file
+ *
+ * @param string $fname Full name and path of the file to stream
+ * @param int $flags Bitfield of STREAM_* constants
+ * @since 1.24
+ */
+ public static function send404Message( $fname, $flags = 0 ) {
+ HTTPFileStreamer::send404Message( $fname, $flags );
+ }
+
+ /**
+ * Convert a Range header value to an absolute (start, end) range tuple
+ *
+ * @param string $range Range header value
+ * @param int $size File size
+ * @return array|string Returns error string on failure (start, end, length)
+ * @since 1.24
+ */
+ public static function parseRange( $range, $size ) {
+ return HTTPFileStreamer::parseRange( $range, $size );
+ }
+
+ /**
+ * Determine the file type of a file based on the path
+ *
+ * @param string $filename Storage path or file system path
+ * @param bool $safe Whether to do retroactive upload blacklist checks
+ * @return null|string
+ */
+ public static function contentTypeFromPath( $filename, $safe = true ) {
+ global $wgTrivialMimeDetection;
+
+ $ext = strrchr( $filename, '.' );
+ $ext = $ext === false ? '' : strtolower( substr( $ext, 1 ) );
+
+ # trivial detection by file extension,
+ # used for thumbnails (thumb.php)
+ if ( $wgTrivialMimeDetection ) {
+ switch ( $ext ) {
+ case 'gif':
+ return 'image/gif';
+ case 'png':
+ return 'image/png';
+ case 'jpg':
+ return 'image/jpeg';
+ case 'jpeg':
+ return 'image/jpeg';
+ }
+
+ return 'unknown/unknown';
+ }
+
+ $magic = MimeMagic::singleton();
+ // Use the extension only, rather than magic numbers, to avoid opening
+ // up vulnerabilities due to uploads of files with allowed extensions
+ // but disallowed types.
+ $type = $magic->guessTypesForExtension( $ext );
+
+ /**
+ * Double-check some security settings that were done on upload but might
+ * have changed since.
+ */
+ if ( $safe ) {
+ global $wgFileBlacklist, $wgCheckFileExtensions, $wgStrictFileExtensions,
+ $wgFileExtensions, $wgVerifyMimeType, $wgMimeTypeBlacklist;
+ list( , $extList ) = UploadBase::splitExtensions( $filename );
+ if ( UploadBase::checkFileExtensionList( $extList, $wgFileBlacklist ) ) {
+ return 'unknown/unknown';
+ }
+ if ( $wgCheckFileExtensions && $wgStrictFileExtensions
+ && !UploadBase::checkFileExtensionList( $extList, $wgFileExtensions )
+ ) {
+ return 'unknown/unknown';
+ }
+ if ( $wgVerifyMimeType && in_array( strtolower( $type ), $wgMimeTypeBlacklist ) ) {
+ return 'unknown/unknown';
+ }
+ }
+ return $type;
+ }
+}
diff --git a/www/wiki/includes/StubObject.php b/www/wiki/includes/StubObject.php
new file mode 100644
index 00000000..baf51099
--- /dev/null
+++ b/www/wiki/includes/StubObject.php
@@ -0,0 +1,207 @@
+<?php
+/**
+ * Delayed loading of global objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to implement stub globals, which are globals that delay loading the
+ * their associated module code by deferring initialisation until the first
+ * method call.
+ *
+ * Note on reference parameters:
+ *
+ * If the called method takes any parameters by reference, the __call magic
+ * here won't work correctly. The solution is to unstub the object before
+ * calling the method.
+ *
+ * Note on unstub loops:
+ *
+ * Unstub loops (infinite recursion) sometimes occur when a constructor calls
+ * another function, and the other function calls some method of the stub. The
+ * best way to avoid this is to make constructors as lightweight as possible,
+ * deferring any initialisation which depends on other modules. As a last
+ * resort, you can use StubObject::isRealObject() to break the loop, but as a
+ * general rule, the stub object mechanism should be transparent, and code
+ * which refers to it should be kept to a minimum.
+ */
+class StubObject {
+ /** @var null|string */
+ protected $global;
+
+ /** @var null|string */
+ protected $class;
+
+ /** @var null|callable */
+ protected $factory;
+
+ /** @var array */
+ protected $params;
+
+ /**
+ * @param string $global Name of the global variable.
+ * @param string|callable $class Name of the class of the real object
+ * or a factory function to call
+ * @param array $params Parameters to pass to constructor of the real object.
+ */
+ public function __construct( $global = null, $class = null, $params = [] ) {
+ $this->global = $global;
+ if ( is_callable( $class ) ) {
+ $this->factory = $class;
+ } else {
+ $this->class = $class;
+ }
+ $this->params = $params;
+ }
+
+ /**
+ * Returns a bool value whenever $obj is a stub object. Can be used to break
+ * a infinite loop when unstubbing an object.
+ *
+ * @param object $obj Object to check.
+ * @return bool True if $obj is not an instance of StubObject class.
+ */
+ public static function isRealObject( $obj ) {
+ return is_object( $obj ) && !$obj instanceof StubObject;
+ }
+
+ /**
+ * Unstubs an object, if it is a stub object. Can be used to break a
+ * infinite loop when unstubbing an object or to avoid reference parameter
+ * breakage.
+ *
+ * @param object &$obj Object to check.
+ * @return void
+ */
+ public static function unstub( &$obj ) {
+ if ( $obj instanceof StubObject ) {
+ $obj = $obj->_unstub( 'unstub', 3 );
+ }
+ }
+
+ /**
+ * Function called if any function exists with that name in this object.
+ * It is used to unstub the object. Only used internally, PHP will call
+ * self::__call() function and that function will call this function.
+ * This function will also call the function with the same name in the real
+ * object.
+ *
+ * @param string $name Name of the function called
+ * @param array $args Arguments
+ * @return mixed
+ */
+ public function _call( $name, $args ) {
+ $this->_unstub( $name, 5 );
+ return call_user_func_array( [ $GLOBALS[$this->global], $name ], $args );
+ }
+
+ /**
+ * Create a new object to replace this stub object.
+ * @return object
+ */
+ public function _newObject() {
+ $params = $this->factory
+ ? [ 'factory' => $this->factory ]
+ : [ 'class' => $this->class ];
+ return ObjectFactory::getObjectFromSpec( $params + [
+ 'args' => $this->params,
+ 'closure_expansion' => false,
+ ] );
+ }
+
+ /**
+ * Function called by PHP if no function with that name exists in this
+ * object.
+ *
+ * @param string $name Name of the function called
+ * @param array $args Arguments
+ * @return mixed
+ */
+ public function __call( $name, $args ) {
+ return $this->_call( $name, $args );
+ }
+
+ /**
+ * This function creates a new object of the real class and replace it in
+ * the global variable.
+ * This is public, for the convenience of external callers wishing to access
+ * properties, e.g. eval.php
+ *
+ * @param string $name Name of the method called in this object.
+ * @param int $level Level to go in the stack trace to get the function
+ * who called this function.
+ * @return object The unstubbed version of itself
+ * @throws MWException
+ */
+ public function _unstub( $name = '_unstub', $level = 2 ) {
+ static $recursionLevel = 0;
+
+ if ( !$GLOBALS[$this->global] instanceof StubObject ) {
+ return $GLOBALS[$this->global]; // already unstubbed.
+ }
+
+ if ( get_class( $GLOBALS[$this->global] ) != $this->class ) {
+ $caller = wfGetCaller( $level );
+ if ( ++$recursionLevel > 2 ) {
+ throw new MWException( "Unstub loop detected on call of "
+ . "\${$this->global}->$name from $caller\n" );
+ }
+ wfDebug( "Unstubbing \${$this->global} on call of "
+ . "\${$this->global}::$name from $caller\n" );
+ $GLOBALS[$this->global] = $this->_newObject();
+ --$recursionLevel;
+ return $GLOBALS[$this->global];
+ }
+ }
+}
+
+/**
+ * Stub object for the user language. Assigned to the $wgLang global.
+ */
+class StubUserLang extends StubObject {
+
+ public function __construct() {
+ parent::__construct( 'wgLang' );
+ }
+
+ /**
+ * Call Language::findVariantLink after unstubbing $wgLang.
+ *
+ * This method is implemented with a full signature rather than relying on
+ * __call so that the pass-by-reference signature of the proxied method is
+ * honored.
+ *
+ * @param string &$link The name of the link
+ * @param Title &$nt The title object of the link
+ * @param bool $ignoreOtherCond To disable other conditions when
+ * we need to transclude a template or update a category's link
+ */
+ public function findVariantLink( &$link, &$nt, $ignoreOtherCond = false ) {
+ global $wgLang;
+ $this->_unstub( 'findVariantLink', 3 );
+ $wgLang->findVariantLink( $link, $nt, $ignoreOtherCond );
+ }
+
+ /**
+ * @return Language
+ */
+ public function _newObject() {
+ return RequestContext::getMain()->getLanguage();
+ }
+}
diff --git a/www/wiki/includes/TemplateParser.php b/www/wiki/includes/TemplateParser.php
new file mode 100644
index 00000000..2293dabb
--- /dev/null
+++ b/www/wiki/includes/TemplateParser.php
@@ -0,0 +1,220 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Handles compiling Mustache templates into PHP rendering functions
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.25
+ */
+class TemplateParser {
+ /**
+ * @var string The path to the Mustache templates
+ */
+ protected $templateDir;
+
+ /**
+ * @var callable[] Array of cached rendering functions
+ */
+ protected $renderers;
+
+ /**
+ * @var bool Always compile template files
+ */
+ protected $forceRecompile = false;
+
+ /**
+ * @var int Compilation flags passed to LightnCandy
+ */
+ // Do not add more flags here without discussion.
+ // If you do add more flags, be sure to update unit tests as well.
+ protected $compileFlags = LightnCandy::FLAG_ERROR_EXCEPTION;
+
+ /**
+ * @param string $templateDir
+ * @param bool $forceRecompile
+ */
+ public function __construct( $templateDir = null, $forceRecompile = false ) {
+ $this->templateDir = $templateDir ?: __DIR__ . '/templates';
+ $this->forceRecompile = $forceRecompile;
+ }
+
+ /**
+ * Enable/disable the use of recursive partials.
+ * @param bool $enable
+ */
+ public function enableRecursivePartials( $enable ) {
+ if ( $enable ) {
+ $this->compileFlags = $this->compileFlags | LightnCandy::FLAG_RUNTIMEPARTIAL;
+ } else {
+ $this->compileFlags = $this->compileFlags & ~LightnCandy::FLAG_RUNTIMEPARTIAL;
+ }
+ }
+
+ /**
+ * Constructs the location of the the source Mustache template
+ * @param string $templateName The name of the template
+ * @return string
+ * @throws UnexpectedValueException If $templateName attempts upwards directory traversal
+ */
+ protected function getTemplateFilename( $templateName ) {
+ // Prevent path traversal. Based on Language::isValidCode().
+ // This is for paranoia. The $templateName should never come from
+ // untrusted input.
+ if (
+ strcspn( $templateName, ":/\\\000&<>'\"%" ) !== strlen( $templateName )
+ ) {
+ throw new UnexpectedValueException( "Malformed \$templateName: $templateName" );
+ }
+
+ return "{$this->templateDir}/{$templateName}.mustache";
+ }
+
+ /**
+ * Returns a given template function if found, otherwise throws an exception.
+ * @param string $templateName The name of the template (without file suffix)
+ * @return callable
+ * @throws RuntimeException
+ */
+ protected function getTemplate( $templateName ) {
+ $templateKey = $templateName . '|' . $this->compileFlags;
+
+ // If a renderer has already been defined for this template, reuse it
+ if ( isset( $this->renderers[$templateKey] ) &&
+ is_callable( $this->renderers[$templateKey] )
+ ) {
+ return $this->renderers[$templateKey];
+ }
+
+ $filename = $this->getTemplateFilename( $templateName );
+
+ if ( !file_exists( $filename ) ) {
+ throw new RuntimeException( "Could not locate template: {$filename}" );
+ }
+
+ // Read the template file
+ $fileContents = file_get_contents( $filename );
+
+ // Generate a quick hash for cache invalidation
+ $fastHash = md5( $this->compileFlags . '|' . $fileContents );
+
+ // Fetch a secret key for building a keyed hash of the PHP code
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ $secretKey = $config->get( 'SecretKey' );
+
+ if ( $secretKey ) {
+ // See if the compiled PHP code is stored in cache.
+ $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
+ $key = $cache->makeKey( 'template', $templateName, $fastHash );
+ $code = $this->forceRecompile ? null : $cache->get( $key );
+
+ if ( $code ) {
+ // Verify the integrity of the cached PHP code
+ $keyedHash = substr( $code, 0, 64 );
+ $code = substr( $code, 64 );
+ if ( $keyedHash !== hash_hmac( 'sha256', $code, $secretKey ) ) {
+ // If the integrity check fails, don't use the cached code
+ // We'll update the invalid cache below
+ $code = null;
+ }
+ }
+ if ( !$code ) {
+ $code = $this->compileForEval( $fileContents, $filename );
+
+ // Prefix the cached code with a keyed hash (64 hex chars) as an integrity check
+ $cache->set( $key, hash_hmac( 'sha256', $code, $secretKey ) . $code );
+ }
+ // If there is no secret key available, don't use cache
+ } else {
+ $code = $this->compileForEval( $fileContents, $filename );
+ }
+
+ $renderer = eval( $code );
+ if ( !is_callable( $renderer ) ) {
+ throw new RuntimeException( "Requested template, {$templateName}, is not callable" );
+ }
+ $this->renderers[$templateKey] = $renderer;
+ return $renderer;
+ }
+
+ /**
+ * Wrapper for compile() function that verifies successful compilation and strips
+ * out the '<?php' part so that the code is ready for eval()
+ * @param string $fileContents Mustache code
+ * @param string $filename Name of the template
+ * @return string PHP code (without '<?php')
+ * @throws RuntimeException
+ */
+ protected function compileForEval( $fileContents, $filename ) {
+ // Compile the template into PHP code
+ $code = $this->compile( $fileContents );
+
+ if ( !$code ) {
+ throw new RuntimeException( "Could not compile template: {$filename}" );
+ }
+
+ // Strip the "<?php" added by lightncandy so that it can be eval()ed
+ if ( substr( $code, 0, 5 ) === '<?php' ) {
+ $code = substr( $code, 5 );
+ }
+
+ return $code;
+ }
+
+ /**
+ * Compile the Mustache code into PHP code using LightnCandy
+ * @param string $code Mustache code
+ * @return string PHP code (with '<?php')
+ * @throws RuntimeException
+ */
+ protected function compile( $code ) {
+ if ( !class_exists( 'LightnCandy' ) ) {
+ throw new RuntimeException( 'LightnCandy class not defined' );
+ }
+ return LightnCandy::compile(
+ $code,
+ [
+ 'flags' => $this->compileFlags,
+ 'basedir' => $this->templateDir,
+ 'fileext' => '.mustache',
+ ]
+ );
+ }
+
+ /**
+ * Returns HTML for a given template by calling the template function with the given args
+ *
+ * @code
+ * echo $templateParser->processTemplate(
+ * 'ExampleTemplate',
+ * [
+ * 'username' => $user->getName(),
+ * 'message' => 'Hello!'
+ * ]
+ * );
+ * @endcode
+ * @param string $templateName The name of the template
+ * @param mixed $args
+ * @param array $scopes
+ * @return string
+ */
+ public function processTemplate( $templateName, $args, array $scopes = [] ) {
+ $template = $this->getTemplate( $templateName );
+ return call_user_func( $template, $args, $scopes );
+ }
+}
diff --git a/www/wiki/includes/TemplatesOnThisPageFormatter.php b/www/wiki/includes/TemplatesOnThisPageFormatter.php
new file mode 100644
index 00000000..494c7bfc
--- /dev/null
+++ b/www/wiki/includes/TemplatesOnThisPageFormatter.php
@@ -0,0 +1,183 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * Handles formatting for the "templates used on this page"
+ * lists. Formerly known as Linker::formatTemplates()
+ *
+ * @since 1.28
+ */
+class TemplatesOnThisPageFormatter {
+
+ /**
+ * @var IContextSource
+ */
+ private $context;
+
+ /**
+ * @var LinkRenderer
+ */
+ private $linkRenderer;
+
+ /**
+ * @param IContextSource $context
+ * @param LinkRenderer $linkRenderer
+ */
+ public function __construct( IContextSource $context, LinkRenderer $linkRenderer ) {
+ $this->context = $context;
+ $this->linkRenderer = $linkRenderer;
+ }
+
+ /**
+ * Make an HTML list of templates, and then add a "More..." link at
+ * the bottom. If $more is null, do not add a "More..." link. If $more
+ * is a LinkTarget, make a link to that title and use it. If $more is a string,
+ * directly paste it in as the link (escaping needs to be done manually).
+ *
+ * @param LinkTarget[] $templates
+ * @param string|bool $type 'preview' if a preview, 'section' if a section edit, false if neither
+ * @param LinkTarget|string|null $more An escaped link for "More..." of the templates
+ * @return string HTML output
+ */
+ public function format( array $templates, $type = false, $more = null ) {
+ if ( !$templates ) {
+ // No templates
+ return '';
+ }
+
+ # Do a batch existence check
+ $batch = new LinkBatch;
+ foreach ( $templates as $title ) {
+ $batch->addObj( $title );
+ }
+ $batch->execute();
+
+ # Construct the HTML
+ $outText = '<div class="mw-templatesUsedExplanation">';
+ $count = count( $templates );
+ if ( $type === 'preview' ) {
+ $outText .= $this->context->msg( 'templatesusedpreview' )->numParams( $count )
+ ->parseAsBlock();
+ } elseif ( $type === 'section' ) {
+ $outText .= $this->context->msg( 'templatesusedsection' )->numParams( $count )
+ ->parseAsBlock();
+ } else {
+ $outText .= $this->context->msg( 'templatesused' )->numParams( $count )
+ ->parseAsBlock();
+ }
+ $outText .= "</div><ul>\n";
+
+ usort( $templates, 'Title::compare' );
+ foreach ( $templates as $template ) {
+ $outText .= $this->formatTemplate( $template );
+ }
+
+ if ( $more instanceof LinkTarget ) {
+ $outText .= Html::rawElement( 'li', [], $this->linkRenderer->makeLink(
+ $more, $this->context->msg( 'moredotdotdot' )->text() ) );
+ } elseif ( $more ) {
+ // Documented as should already be escaped
+ $outText .= Html::rawElement( 'li', [], $more );
+ }
+
+ $outText .= '</ul>';
+ return $outText;
+ }
+
+ /**
+ * Builds an <li> item for an individual template
+ *
+ * @param LinkTarget $target
+ * @return string
+ */
+ private function formatTemplate( LinkTarget $target ) {
+ // TODO Would be nice if we didn't have to use Title here
+ $titleObj = Title::newFromLinkTarget( $target );
+ $protected = $this->getRestrictionsText( $titleObj->getRestrictions( 'edit' ) );
+ $editLink = $this->buildEditLink( $titleObj );
+ return '<li>' . $this->linkRenderer->makeLink( $target )
+ . $this->context->msg( 'word-separator' )->escaped()
+ . $this->context->msg( 'parentheses' )->rawParams( $editLink )->escaped()
+ . $this->context->msg( 'word-separator' )->escaped()
+ . $protected . '</li>';
+ }
+
+ /**
+ * If the page is protected, get the relevant text
+ * for those restrictions
+ *
+ * @param array $restrictions
+ * @return string
+ */
+ private function getRestrictionsText( array $restrictions ) {
+ $protected = '';
+ if ( !$restrictions ) {
+ return $protected;
+ }
+
+ // Check backwards-compatible messages
+ $msg = null;
+ if ( $restrictions === [ 'sysop' ] ) {
+ $msg = $this->context->msg( 'template-protected' );
+ } elseif ( $restrictions === [ 'autoconfirmed' ] ) {
+ $msg = $this->context->msg( 'template-semiprotected' );
+ }
+ if ( $msg && !$msg->isDisabled() ) {
+ $protected = $msg->parse();
+ } else {
+ // Construct the message from restriction-level-*
+ // e.g. restriction-level-sysop, restriction-level-autoconfirmed
+ $msgs = [];
+ foreach ( $restrictions as $r ) {
+ $msgs[] = $this->context->msg( "restriction-level-$r" )->parse();
+ }
+ $protected = $this->context->msg( 'parentheses' )
+ ->rawParams( $this->context->getLanguage()->commaList( $msgs ) )->escaped();
+ }
+
+ return $protected;
+ }
+
+ /**
+ * Return a link to the edit page, with the text
+ * saying "view source" if the user can't edit the page
+ *
+ * @param Title $titleObj
+ * @return string
+ */
+ private function buildEditLink( Title $titleObj ) {
+ if ( $titleObj->quickUserCan( 'edit', $this->context->getUser() ) ) {
+ $linkMsg = 'editlink';
+ } else {
+ $linkMsg = 'viewsourcelink';
+ }
+
+ return $this->linkRenderer->makeLink(
+ $titleObj,
+ $this->context->msg( $linkMsg )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ }
+
+}
diff --git a/www/wiki/includes/Title.php b/www/wiki/includes/Title.php
new file mode 100644
index 00000000..a4dc8c1f
--- /dev/null
+++ b/www/wiki/includes/Title.php
@@ -0,0 +1,5027 @@
+<?php
+/**
+ * Representation of a title within %MediaWiki.
+ *
+ * See title.txt
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\Interwiki\InterwikiLookup;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Represents a title within MediaWiki.
+ * Optionally may contain an interwiki designation or namespace.
+ * @note This class can fetch various kinds of data from the database;
+ * however, it does so inefficiently.
+ * @note Consider using a TitleValue object instead. TitleValue is more lightweight
+ * and does not rely on global state or the database.
+ */
+class Title implements LinkTarget {
+ /** @var HashBagOStuff */
+ static private $titleCache = null;
+
+ /**
+ * Title::newFromText maintains a cache to avoid expensive re-normalization of
+ * commonly used titles. On a batch operation this can become a memory leak
+ * if not bounded. After hitting this many titles reset the cache.
+ */
+ const CACHE_MAX = 1000;
+
+ /**
+ * Used to be GAID_FOR_UPDATE define. Used with getArticleID() and friends
+ * to use the master DB
+ */
+ const GAID_FOR_UPDATE = 1;
+
+ /**
+ * @name Private member variables
+ * Please use the accessor functions instead.
+ * @private
+ */
+ // @{
+
+ /** @var string Text form (spaces not underscores) of the main part */
+ public $mTextform = '';
+
+ /** @var string URL-encoded form of the main part */
+ public $mUrlform = '';
+
+ /** @var string Main part with underscores */
+ public $mDbkeyform = '';
+
+ /** @var string Database key with the initial letter in the case specified by the user */
+ protected $mUserCaseDBKey;
+
+ /** @var int Namespace index, i.e. one of the NS_xxxx constants */
+ public $mNamespace = NS_MAIN;
+
+ /** @var string Interwiki prefix */
+ public $mInterwiki = '';
+
+ /** @var bool Was this Title created from a string with a local interwiki prefix? */
+ private $mLocalInterwiki = false;
+
+ /** @var string Title fragment (i.e. the bit after the #) */
+ public $mFragment = '';
+
+ /** @var int Article ID, fetched from the link cache on demand */
+ public $mArticleID = -1;
+
+ /** @var bool|int ID of most recent revision */
+ protected $mLatestID = false;
+
+ /**
+ * @var bool|string ID of the page's content model, i.e. one of the
+ * CONTENT_MODEL_XXX constants
+ */
+ private $mContentModel = false;
+
+ /**
+ * @var bool If a content model was forced via setContentModel()
+ * this will be true to avoid having other code paths reset it
+ */
+ private $mForcedContentModel = false;
+
+ /** @var int Estimated number of revisions; null of not loaded */
+ private $mEstimateRevisions;
+
+ /** @var array Array of groups allowed to edit this article */
+ public $mRestrictions = [];
+
+ /** @var string|bool */
+ protected $mOldRestrictions = false;
+
+ /** @var bool Cascade restrictions on this page to included templates and images? */
+ public $mCascadeRestriction;
+
+ /** Caching the results of getCascadeProtectionSources */
+ public $mCascadingRestrictions;
+
+ /** @var array When do the restrictions on this page expire? */
+ protected $mRestrictionsExpiry = [];
+
+ /** @var bool Are cascading restrictions in effect on this page? */
+ protected $mHasCascadingRestrictions;
+
+ /** @var array Where are the cascading restrictions coming from on this page? */
+ public $mCascadeSources;
+
+ /** @var bool Boolean for initialisation on demand */
+ public $mRestrictionsLoaded = false;
+
+ /** @var string Text form including namespace/interwiki, initialised on demand */
+ protected $mPrefixedText = null;
+
+ /** @var mixed Cached value for getTitleProtection (create protection) */
+ public $mTitleProtection;
+
+ /**
+ * @var int Namespace index when there is no namespace. Don't change the
+ * following default, NS_MAIN is hardcoded in several places. See T2696.
+ * Zero except in {{transclusion}} tags.
+ */
+ public $mDefaultNamespace = NS_MAIN;
+
+ /** @var int The page length, 0 for special pages */
+ protected $mLength = -1;
+
+ /** @var null Is the article at this title a redirect? */
+ public $mRedirect = null;
+
+ /** @var array Associative array of user ID -> timestamp/false */
+ private $mNotificationTimestamp = [];
+
+ /** @var bool Whether a page has any subpages */
+ private $mHasSubpages;
+
+ /** @var bool The (string) language code of the page's language and content code. */
+ private $mPageLanguage = false;
+
+ /** @var string|bool|null The page language code from the database, null if not saved in
+ * the database or false if not loaded, yet. */
+ private $mDbPageLanguage = false;
+
+ /** @var TitleValue A corresponding TitleValue object */
+ private $mTitleValue = null;
+
+ /** @var bool Would deleting this page be a big deletion? */
+ private $mIsBigDeletion = null;
+ // @}
+
+ /**
+ * B/C kludge: provide a TitleParser for use by Title.
+ * Ideally, Title would have no methods that need this.
+ * Avoid usage of this singleton by using TitleValue
+ * and the associated services when possible.
+ *
+ * @return TitleFormatter
+ */
+ private static function getTitleFormatter() {
+ return MediaWikiServices::getInstance()->getTitleFormatter();
+ }
+
+ /**
+ * B/C kludge: provide an InterwikiLookup for use by Title.
+ * Ideally, Title would have no methods that need this.
+ * Avoid usage of this singleton by using TitleValue
+ * and the associated services when possible.
+ *
+ * @return InterwikiLookup
+ */
+ private static function getInterwikiLookup() {
+ return MediaWikiServices::getInstance()->getInterwikiLookup();
+ }
+
+ /**
+ * @access protected
+ */
+ function __construct() {
+ }
+
+ /**
+ * Create a new Title from a prefixed DB key
+ *
+ * @param string $key The database key, which has underscores
+ * instead of spaces, possibly including namespace and
+ * interwiki prefixes
+ * @return Title|null Title, or null on an error
+ */
+ public static function newFromDBkey( $key ) {
+ $t = new Title();
+ $t->mDbkeyform = $key;
+
+ try {
+ $t->secureAndSplit();
+ return $t;
+ } catch ( MalformedTitleException $ex ) {
+ return null;
+ }
+ }
+
+ /**
+ * Create a new Title from a TitleValue
+ *
+ * @param TitleValue $titleValue Assumed to be safe.
+ *
+ * @return Title
+ */
+ public static function newFromTitleValue( TitleValue $titleValue ) {
+ return self::newFromLinkTarget( $titleValue );
+ }
+
+ /**
+ * Create a new Title from a LinkTarget
+ *
+ * @param LinkTarget $linkTarget Assumed to be safe.
+ *
+ * @return Title
+ */
+ public static function newFromLinkTarget( LinkTarget $linkTarget ) {
+ if ( $linkTarget instanceof Title ) {
+ // Special case if it's already a Title object
+ return $linkTarget;
+ }
+ return self::makeTitle(
+ $linkTarget->getNamespace(),
+ $linkTarget->getText(),
+ $linkTarget->getFragment(),
+ $linkTarget->getInterwiki()
+ );
+ }
+
+ /**
+ * Create a new Title from text, such as what one would find in a link. De-
+ * codes any HTML entities in the text.
+ *
+ * Title objects returned by this method are guaranteed to be valid, and
+ * thus return true from the isValid() method.
+ *
+ * @param string|int|null $text The link text; spaces, prefixes, and an
+ * initial ':' indicating the main namespace are accepted.
+ * @param int $defaultNamespace The namespace to use if none is specified
+ * by a prefix. If you want to force a specific namespace even if
+ * $text might begin with a namespace prefix, use makeTitle() or
+ * makeTitleSafe().
+ * @throws InvalidArgumentException
+ * @return Title|null Title or null on an error.
+ */
+ public static function newFromText( $text, $defaultNamespace = NS_MAIN ) {
+ // DWIM: Integers can be passed in here when page titles are used as array keys.
+ if ( $text !== null && !is_string( $text ) && !is_int( $text ) ) {
+ throw new InvalidArgumentException( '$text must be a string.' );
+ }
+ if ( $text === null ) {
+ return null;
+ }
+
+ try {
+ return self::newFromTextThrow( strval( $text ), $defaultNamespace );
+ } catch ( MalformedTitleException $ex ) {
+ return null;
+ }
+ }
+
+ /**
+ * Like Title::newFromText(), but throws MalformedTitleException when the title is invalid,
+ * rather than returning null.
+ *
+ * The exception subclasses encode detailed information about why the title is invalid.
+ *
+ * Title objects returned by this method are guaranteed to be valid, and
+ * thus return true from the isValid() method.
+ *
+ * @see Title::newFromText
+ *
+ * @since 1.25
+ * @param string $text Title text to check
+ * @param int $defaultNamespace
+ * @throws MalformedTitleException If the title is invalid
+ * @return Title
+ */
+ public static function newFromTextThrow( $text, $defaultNamespace = NS_MAIN ) {
+ if ( is_object( $text ) ) {
+ throw new MWException( '$text must be a string, given an object' );
+ }
+
+ $titleCache = self::getTitleCache();
+
+ // Wiki pages often contain multiple links to the same page.
+ // Title normalization and parsing can become expensive on pages with many
+ // links, so we can save a little time by caching them.
+ // In theory these are value objects and won't get changed...
+ if ( $defaultNamespace == NS_MAIN ) {
+ $t = $titleCache->get( $text );
+ if ( $t ) {
+ return $t;
+ }
+ }
+
+ // Convert things like &eacute; &#257; or &#x3017; into normalized (T16952) text
+ $filteredText = Sanitizer::decodeCharReferencesAndNormalize( $text );
+
+ $t = new Title();
+ $t->mDbkeyform = strtr( $filteredText, ' ', '_' );
+ $t->mDefaultNamespace = intval( $defaultNamespace );
+
+ $t->secureAndSplit();
+ if ( $defaultNamespace == NS_MAIN ) {
+ $titleCache->set( $text, $t );
+ }
+ return $t;
+ }
+
+ /**
+ * THIS IS NOT THE FUNCTION YOU WANT. Use Title::newFromText().
+ *
+ * Example of wrong and broken code:
+ * $title = Title::newFromURL( $wgRequest->getVal( 'title' ) );
+ *
+ * Example of right code:
+ * $title = Title::newFromText( $wgRequest->getVal( 'title' ) );
+ *
+ * Create a new Title from URL-encoded text. Ensures that
+ * the given title's length does not exceed the maximum.
+ *
+ * @param string $url The title, as might be taken from a URL
+ * @return Title|null The new object, or null on an error
+ */
+ public static function newFromURL( $url ) {
+ $t = new Title();
+
+ # For compatibility with old buggy URLs. "+" is usually not valid in titles,
+ # but some URLs used it as a space replacement and they still come
+ # from some external search tools.
+ if ( strpos( self::legalChars(), '+' ) === false ) {
+ $url = strtr( $url, '+', ' ' );
+ }
+
+ $t->mDbkeyform = strtr( $url, ' ', '_' );
+
+ try {
+ $t->secureAndSplit();
+ return $t;
+ } catch ( MalformedTitleException $ex ) {
+ return null;
+ }
+ }
+
+ /**
+ * @return HashBagOStuff
+ */
+ private static function getTitleCache() {
+ if ( self::$titleCache == null ) {
+ self::$titleCache = new HashBagOStuff( [ 'maxKeys' => self::CACHE_MAX ] );
+ }
+ return self::$titleCache;
+ }
+
+ /**
+ * Returns a list of fields that are to be selected for initializing Title
+ * objects or LinkCache entries. Uses $wgContentHandlerUseDB to determine
+ * whether to include page_content_model.
+ *
+ * @return array
+ */
+ protected static function getSelectFields() {
+ global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
+
+ $fields = [
+ 'page_namespace', 'page_title', 'page_id',
+ 'page_len', 'page_is_redirect', 'page_latest',
+ ];
+
+ if ( $wgContentHandlerUseDB ) {
+ $fields[] = 'page_content_model';
+ }
+
+ if ( $wgPageLanguageUseDB ) {
+ $fields[] = 'page_lang';
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Create a new Title from an article ID
+ *
+ * @param int $id The page_id corresponding to the Title to create
+ * @param int $flags Use Title::GAID_FOR_UPDATE to use master
+ * @return Title|null The new object, or null on an error
+ */
+ public static function newFromID( $id, $flags = 0 ) {
+ $db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_REPLICA );
+ $row = $db->selectRow(
+ 'page',
+ self::getSelectFields(),
+ [ 'page_id' => $id ],
+ __METHOD__
+ );
+ if ( $row !== false ) {
+ $title = self::newFromRow( $row );
+ } else {
+ $title = null;
+ }
+ return $title;
+ }
+
+ /**
+ * Make an array of titles from an array of IDs
+ *
+ * @param int[] $ids Array of IDs
+ * @return Title[] Array of Titles
+ */
+ public static function newFromIDs( $ids ) {
+ if ( !count( $ids ) ) {
+ return [];
+ }
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $res = $dbr->select(
+ 'page',
+ self::getSelectFields(),
+ [ 'page_id' => $ids ],
+ __METHOD__
+ );
+
+ $titles = [];
+ foreach ( $res as $row ) {
+ $titles[] = self::newFromRow( $row );
+ }
+ return $titles;
+ }
+
+ /**
+ * Make a Title object from a DB row
+ *
+ * @param stdClass $row Object database row (needs at least page_title,page_namespace)
+ * @return Title Corresponding Title
+ */
+ public static function newFromRow( $row ) {
+ $t = self::makeTitle( $row->page_namespace, $row->page_title );
+ $t->loadFromRow( $row );
+ return $t;
+ }
+
+ /**
+ * Load Title object fields from a DB row.
+ * If false is given, the title will be treated as non-existing.
+ *
+ * @param stdClass|bool $row Database row
+ */
+ public function loadFromRow( $row ) {
+ if ( $row ) { // page found
+ if ( isset( $row->page_id ) ) {
+ $this->mArticleID = (int)$row->page_id;
+ }
+ if ( isset( $row->page_len ) ) {
+ $this->mLength = (int)$row->page_len;
+ }
+ if ( isset( $row->page_is_redirect ) ) {
+ $this->mRedirect = (bool)$row->page_is_redirect;
+ }
+ if ( isset( $row->page_latest ) ) {
+ $this->mLatestID = (int)$row->page_latest;
+ }
+ if ( !$this->mForcedContentModel && isset( $row->page_content_model ) ) {
+ $this->mContentModel = strval( $row->page_content_model );
+ } elseif ( !$this->mForcedContentModel ) {
+ $this->mContentModel = false; # initialized lazily in getContentModel()
+ }
+ if ( isset( $row->page_lang ) ) {
+ $this->mDbPageLanguage = (string)$row->page_lang;
+ }
+ if ( isset( $row->page_restrictions ) ) {
+ $this->mOldRestrictions = $row->page_restrictions;
+ }
+ } else { // page not found
+ $this->mArticleID = 0;
+ $this->mLength = 0;
+ $this->mRedirect = false;
+ $this->mLatestID = 0;
+ if ( !$this->mForcedContentModel ) {
+ $this->mContentModel = false; # initialized lazily in getContentModel()
+ }
+ }
+ }
+
+ /**
+ * Create a new Title from a namespace index and a DB key.
+ *
+ * It's assumed that $ns and $title are safe, for instance when
+ * they came directly from the database or a special page name,
+ * not from user input.
+ *
+ * No validation is applied. For convenience, spaces are normalized
+ * to underscores, so that e.g. user_text fields can be used directly.
+ *
+ * @note This method may return Title objects that are "invalid"
+ * according to the isValid() method. This is usually caused by
+ * configuration changes: e.g. a namespace that was once defined is
+ * no longer configured, or a character that was once allowed in
+ * titles is now forbidden.
+ *
+ * @param int $ns The namespace of the article
+ * @param string $title The unprefixed database key form
+ * @param string $fragment The link fragment (after the "#")
+ * @param string $interwiki The interwiki prefix
+ * @return Title The new object
+ */
+ public static function makeTitle( $ns, $title, $fragment = '', $interwiki = '' ) {
+ $t = new Title();
+ $t->mInterwiki = $interwiki;
+ $t->mFragment = $fragment;
+ $t->mNamespace = $ns = intval( $ns );
+ $t->mDbkeyform = strtr( $title, ' ', '_' );
+ $t->mArticleID = ( $ns >= 0 ) ? -1 : 0;
+ $t->mUrlform = wfUrlencode( $t->mDbkeyform );
+ $t->mTextform = strtr( $title, '_', ' ' );
+ $t->mContentModel = false; # initialized lazily in getContentModel()
+ return $t;
+ }
+
+ /**
+ * Create a new Title from a namespace index and a DB key.
+ * The parameters will be checked for validity, which is a bit slower
+ * than makeTitle() but safer for user-provided data.
+ *
+ * Title objects returned by makeTitleSafe() are guaranteed to be valid,
+ * that is, they return true from the isValid() method. If no valid Title
+ * can be constructed from the input, this method returns null.
+ *
+ * @param int $ns The namespace of the article
+ * @param string $title Database key form
+ * @param string $fragment The link fragment (after the "#")
+ * @param string $interwiki Interwiki prefix
+ * @return Title|null The new object, or null on an error
+ */
+ public static function makeTitleSafe( $ns, $title, $fragment = '', $interwiki = '' ) {
+ // NOTE: ideally, this would just call makeTitle() and then isValid(),
+ // but presently, that means more overhead on a potential performance hotspot.
+
+ if ( !MWNamespace::exists( $ns ) ) {
+ return null;
+ }
+
+ $t = new Title();
+ $t->mDbkeyform = self::makeName( $ns, $title, $fragment, $interwiki, true );
+
+ try {
+ $t->secureAndSplit();
+ return $t;
+ } catch ( MalformedTitleException $ex ) {
+ return null;
+ }
+ }
+
+ /**
+ * Create a new Title for the Main Page
+ *
+ * @return Title The new object
+ */
+ public static function newMainPage() {
+ $title = self::newFromText( wfMessage( 'mainpage' )->inContentLanguage()->text() );
+ // Don't give fatal errors if the message is broken
+ if ( !$title ) {
+ $title = self::newFromText( 'Main Page' );
+ }
+ return $title;
+ }
+
+ /**
+ * Get the prefixed DB key associated with an ID
+ *
+ * @param int $id The page_id of the article
+ * @return Title|null An object representing the article, or null if no such article was found
+ */
+ public static function nameOf( $id ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $s = $dbr->selectRow(
+ 'page',
+ [ 'page_namespace', 'page_title' ],
+ [ 'page_id' => $id ],
+ __METHOD__
+ );
+ if ( $s === false ) {
+ return null;
+ }
+
+ $n = self::makeName( $s->page_namespace, $s->page_title );
+ return $n;
+ }
+
+ /**
+ * Get a regex character class describing the legal characters in a link
+ *
+ * @return string The list of characters, not delimited
+ */
+ public static function legalChars() {
+ global $wgLegalTitleChars;
+ return $wgLegalTitleChars;
+ }
+
+ /**
+ * Returns a simple regex that will match on characters and sequences invalid in titles.
+ * Note that this doesn't pick up many things that could be wrong with titles, but that
+ * replacing this regex with something valid will make many titles valid.
+ *
+ * @deprecated since 1.25, use MediaWikiTitleCodec::getTitleInvalidRegex() instead
+ *
+ * @return string Regex string
+ */
+ static function getTitleInvalidRegex() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return MediaWikiTitleCodec::getTitleInvalidRegex();
+ }
+
+ /**
+ * Utility method for converting a character sequence from bytes to Unicode.
+ *
+ * Primary usecase being converting $wgLegalTitleChars to a sequence usable in
+ * javascript, as PHP uses UTF-8 bytes where javascript uses Unicode code units.
+ *
+ * @param string $byteClass
+ * @return string
+ */
+ public static function convertByteClassToUnicodeClass( $byteClass ) {
+ $length = strlen( $byteClass );
+ // Input token queue
+ $x0 = $x1 = $x2 = '';
+ // Decoded queue
+ $d0 = $d1 = $d2 = '';
+ // Decoded integer codepoints
+ $ord0 = $ord1 = $ord2 = 0;
+ // Re-encoded queue
+ $r0 = $r1 = $r2 = '';
+ // Output
+ $out = '';
+ // Flags
+ $allowUnicode = false;
+ for ( $pos = 0; $pos < $length; $pos++ ) {
+ // Shift the queues down
+ $x2 = $x1;
+ $x1 = $x0;
+ $d2 = $d1;
+ $d1 = $d0;
+ $ord2 = $ord1;
+ $ord1 = $ord0;
+ $r2 = $r1;
+ $r1 = $r0;
+ // Load the current input token and decoded values
+ $inChar = $byteClass[$pos];
+ if ( $inChar == '\\' ) {
+ if ( preg_match( '/x([0-9a-fA-F]{2})/A', $byteClass, $m, 0, $pos + 1 ) ) {
+ $x0 = $inChar . $m[0];
+ $d0 = chr( hexdec( $m[1] ) );
+ $pos += strlen( $m[0] );
+ } elseif ( preg_match( '/[0-7]{3}/A', $byteClass, $m, 0, $pos + 1 ) ) {
+ $x0 = $inChar . $m[0];
+ $d0 = chr( octdec( $m[0] ) );
+ $pos += strlen( $m[0] );
+ } elseif ( $pos + 1 >= $length ) {
+ $x0 = $d0 = '\\';
+ } else {
+ $d0 = $byteClass[$pos + 1];
+ $x0 = $inChar . $d0;
+ $pos += 1;
+ }
+ } else {
+ $x0 = $d0 = $inChar;
+ }
+ $ord0 = ord( $d0 );
+ // Load the current re-encoded value
+ if ( $ord0 < 32 || $ord0 == 0x7f ) {
+ $r0 = sprintf( '\x%02x', $ord0 );
+ } elseif ( $ord0 >= 0x80 ) {
+ // Allow unicode if a single high-bit character appears
+ $r0 = sprintf( '\x%02x', $ord0 );
+ $allowUnicode = true;
+ } elseif ( strpos( '-\\[]^', $d0 ) !== false ) {
+ $r0 = '\\' . $d0;
+ } else {
+ $r0 = $d0;
+ }
+ // Do the output
+ if ( $x0 !== '' && $x1 === '-' && $x2 !== '' ) {
+ // Range
+ if ( $ord2 > $ord0 ) {
+ // Empty range
+ } elseif ( $ord0 >= 0x80 ) {
+ // Unicode range
+ $allowUnicode = true;
+ if ( $ord2 < 0x80 ) {
+ // Keep the non-unicode section of the range
+ $out .= "$r2-\\x7F";
+ }
+ } else {
+ // Normal range
+ $out .= "$r2-$r0";
+ }
+ // Reset state to the initial value
+ $x0 = $x1 = $d0 = $d1 = $r0 = $r1 = '';
+ } elseif ( $ord2 < 0x80 ) {
+ // ASCII character
+ $out .= $r2;
+ }
+ }
+ if ( $ord1 < 0x80 ) {
+ $out .= $r1;
+ }
+ if ( $ord0 < 0x80 ) {
+ $out .= $r0;
+ }
+ if ( $allowUnicode ) {
+ $out .= '\u0080-\uFFFF';
+ }
+ return $out;
+ }
+
+ /**
+ * Make a prefixed DB key from a DB key and a namespace index
+ *
+ * @param int $ns Numerical representation of the namespace
+ * @param string $title The DB key form the title
+ * @param string $fragment The link fragment (after the "#")
+ * @param string $interwiki The interwiki prefix
+ * @param bool $canonicalNamespace If true, use the canonical name for
+ * $ns instead of the localized version.
+ * @return string The prefixed form of the title
+ */
+ public static function makeName( $ns, $title, $fragment = '', $interwiki = '',
+ $canonicalNamespace = false
+ ) {
+ global $wgContLang;
+
+ if ( $canonicalNamespace ) {
+ $namespace = MWNamespace::getCanonicalName( $ns );
+ } else {
+ $namespace = $wgContLang->getNsText( $ns );
+ }
+ $name = $namespace == '' ? $title : "$namespace:$title";
+ if ( strval( $interwiki ) != '' ) {
+ $name = "$interwiki:$name";
+ }
+ if ( strval( $fragment ) != '' ) {
+ $name .= '#' . $fragment;
+ }
+ return $name;
+ }
+
+ /**
+ * Escape a text fragment, say from a link, for a URL
+ *
+ * @deprecated since 1.30, use Sanitizer::escapeIdForLink() or escapeIdForExternalInterwiki()
+ *
+ * @param string $fragment Containing a URL or link fragment (after the "#")
+ * @return string Escaped string
+ */
+ static function escapeFragmentForURL( $fragment ) {
+ # Note that we don't urlencode the fragment. urlencoded Unicode
+ # fragments appear not to work in IE (at least up to 7) or in at least
+ # one version of Opera 9.x. The W3C validator, for one, doesn't seem
+ # to care if they aren't encoded.
+ return Sanitizer::escapeId( $fragment, 'noninitial' );
+ }
+
+ /**
+ * Callback for usort() to do title sorts by (namespace, title)
+ *
+ * @param LinkTarget $a
+ * @param LinkTarget $b
+ *
+ * @return int Result of string comparison, or namespace comparison
+ */
+ public static function compare( LinkTarget $a, LinkTarget $b ) {
+ if ( $a->getNamespace() == $b->getNamespace() ) {
+ return strcmp( $a->getText(), $b->getText() );
+ } else {
+ return $a->getNamespace() - $b->getNamespace();
+ }
+ }
+
+ /**
+ * Returns true if the title is valid, false if it is invalid.
+ *
+ * Valid titles can be round-tripped via makeTitleSafe() and newFromText().
+ * Invalid titles may get returned from makeTitle(), and it may be useful to
+ * allow them to exist, e.g. in order to process log entries about pages in
+ * namespaces that belong to extensions that are no longer installed.
+ *
+ * @note This method is relatively expensive. When constructing Title
+ * objects that need to be valid, use an instantiator method that is guaranteed
+ * to return valid titles, such as makeTitleSafe() or newFromText().
+ *
+ * @return bool
+ */
+ public function isValid() {
+ $ns = $this->getNamespace();
+
+ if ( !MWNamespace::exists( $ns ) ) {
+ return false;
+ }
+
+ try {
+ $parser = MediaWikiServices::getInstance()->getTitleParser();
+ $parser->parseTitle( $this->getDBkey(), $ns );
+ return true;
+ } catch ( MalformedTitleException $ex ) {
+ return false;
+ }
+ }
+
+ /**
+ * Determine whether the object refers to a page within
+ * this project (either this wiki or a wiki with a local
+ * interwiki, see https://www.mediawiki.org/wiki/Manual:Interwiki_table#iw_local )
+ *
+ * @return bool True if this is an in-project interwiki link or a wikilink, false otherwise
+ */
+ public function isLocal() {
+ if ( $this->isExternal() ) {
+ $iw = self::getInterwikiLookup()->fetch( $this->mInterwiki );
+ if ( $iw ) {
+ return $iw->isLocal();
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Is this Title interwiki?
+ *
+ * @return bool
+ */
+ public function isExternal() {
+ return $this->mInterwiki !== '';
+ }
+
+ /**
+ * Get the interwiki prefix
+ *
+ * Use Title::isExternal to check if a interwiki is set
+ *
+ * @return string Interwiki prefix
+ */
+ public function getInterwiki() {
+ return $this->mInterwiki;
+ }
+
+ /**
+ * Was this a local interwiki link?
+ *
+ * @return bool
+ */
+ public function wasLocalInterwiki() {
+ return $this->mLocalInterwiki;
+ }
+
+ /**
+ * Determine whether the object refers to a page within
+ * this project and is transcludable.
+ *
+ * @return bool True if this is transcludable
+ */
+ public function isTrans() {
+ if ( !$this->isExternal() ) {
+ return false;
+ }
+
+ return self::getInterwikiLookup()->fetch( $this->mInterwiki )->isTranscludable();
+ }
+
+ /**
+ * Returns the DB name of the distant wiki which owns the object.
+ *
+ * @return string|false The DB name
+ */
+ public function getTransWikiID() {
+ if ( !$this->isExternal() ) {
+ return false;
+ }
+
+ return self::getInterwikiLookup()->fetch( $this->mInterwiki )->getWikiID();
+ }
+
+ /**
+ * Get a TitleValue object representing this Title.
+ *
+ * @note Not all valid Titles have a corresponding valid TitleValue
+ * (e.g. TitleValues cannot represent page-local links that have a
+ * fragment but no title text).
+ *
+ * @return TitleValue|null
+ */
+ public function getTitleValue() {
+ if ( $this->mTitleValue === null ) {
+ try {
+ $this->mTitleValue = new TitleValue(
+ $this->getNamespace(),
+ $this->getDBkey(),
+ $this->getFragment(),
+ $this->getInterwiki()
+ );
+ } catch ( InvalidArgumentException $ex ) {
+ wfDebug( __METHOD__ . ': Can\'t create a TitleValue for [[' .
+ $this->getPrefixedText() . ']]: ' . $ex->getMessage() . "\n" );
+ }
+ }
+
+ return $this->mTitleValue;
+ }
+
+ /**
+ * Get the text form (spaces not underscores) of the main part
+ *
+ * @return string Main part of the title
+ */
+ public function getText() {
+ return $this->mTextform;
+ }
+
+ /**
+ * Get the URL-encoded form of the main part
+ *
+ * @return string Main part of the title, URL-encoded
+ */
+ public function getPartialURL() {
+ return $this->mUrlform;
+ }
+
+ /**
+ * Get the main part with underscores
+ *
+ * @return string Main part of the title, with underscores
+ */
+ public function getDBkey() {
+ return $this->mDbkeyform;
+ }
+
+ /**
+ * Get the DB key with the initial letter case as specified by the user
+ *
+ * @return string DB key
+ */
+ function getUserCaseDBKey() {
+ if ( !is_null( $this->mUserCaseDBKey ) ) {
+ return $this->mUserCaseDBKey;
+ } else {
+ // If created via makeTitle(), $this->mUserCaseDBKey is not set.
+ return $this->mDbkeyform;
+ }
+ }
+
+ /**
+ * Get the namespace index, i.e. one of the NS_xxxx constants.
+ *
+ * @return int Namespace index
+ */
+ public function getNamespace() {
+ return $this->mNamespace;
+ }
+
+ /**
+ * Get the page's content model id, see the CONTENT_MODEL_XXX constants.
+ *
+ * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
+ * @return string Content model id
+ */
+ public function getContentModel( $flags = 0 ) {
+ if ( !$this->mForcedContentModel
+ && ( !$this->mContentModel || $flags === self::GAID_FOR_UPDATE )
+ && $this->getArticleID( $flags )
+ ) {
+ $linkCache = LinkCache::singleton();
+ $linkCache->addLinkObj( $this ); # in case we already had an article ID
+ $this->mContentModel = $linkCache->getGoodLinkFieldObj( $this, 'model' );
+ }
+
+ if ( !$this->mContentModel ) {
+ $this->mContentModel = ContentHandler::getDefaultModelFor( $this );
+ }
+
+ return $this->mContentModel;
+ }
+
+ /**
+ * Convenience method for checking a title's content model name
+ *
+ * @param string $id The content model ID (use the CONTENT_MODEL_XXX constants).
+ * @return bool True if $this->getContentModel() == $id
+ */
+ public function hasContentModel( $id ) {
+ return $this->getContentModel() == $id;
+ }
+
+ /**
+ * Set a proposed content model for the page for permissions
+ * checking. This does not actually change the content model
+ * of a title!
+ *
+ * Additionally, you should make sure you've checked
+ * ContentHandler::canBeUsedOn() first.
+ *
+ * @since 1.28
+ * @param string $model CONTENT_MODEL_XXX constant
+ */
+ public function setContentModel( $model ) {
+ $this->mContentModel = $model;
+ $this->mForcedContentModel = true;
+ }
+
+ /**
+ * Get the namespace text
+ *
+ * @return string|false Namespace text
+ */
+ public function getNsText() {
+ if ( $this->isExternal() ) {
+ // This probably shouldn't even happen,
+ // but for interwiki transclusion it sometimes does.
+ // Use the canonical namespaces if possible to try to
+ // resolve a foreign namespace.
+ if ( MWNamespace::exists( $this->mNamespace ) ) {
+ return MWNamespace::getCanonicalName( $this->mNamespace );
+ }
+ }
+
+ try {
+ $formatter = self::getTitleFormatter();
+ return $formatter->getNamespaceName( $this->mNamespace, $this->mDbkeyform );
+ } catch ( InvalidArgumentException $ex ) {
+ wfDebug( __METHOD__ . ': ' . $ex->getMessage() . "\n" );
+ return false;
+ }
+ }
+
+ /**
+ * Get the namespace text of the subject (rather than talk) page
+ *
+ * @return string Namespace text
+ */
+ public function getSubjectNsText() {
+ global $wgContLang;
+ return $wgContLang->getNsText( MWNamespace::getSubject( $this->mNamespace ) );
+ }
+
+ /**
+ * Get the namespace text of the talk page
+ *
+ * @return string Namespace text
+ */
+ public function getTalkNsText() {
+ global $wgContLang;
+ return $wgContLang->getNsText( MWNamespace::getTalk( $this->mNamespace ) );
+ }
+
+ /**
+ * Can this title have a corresponding talk page?
+ *
+ * @deprecated since 1.30, use canHaveTalkPage() instead.
+ *
+ * @return bool True if this title either is a talk page or can have a talk page associated.
+ */
+ public function canTalk() {
+ return $this->canHaveTalkPage();
+ }
+
+ /**
+ * Can this title have a corresponding talk page?
+ *
+ * @see MWNamespace::hasTalkNamespace
+ * @since 1.30
+ *
+ * @return bool True if this title either is a talk page or can have a talk page associated.
+ */
+ public function canHaveTalkPage() {
+ return MWNamespace::hasTalkNamespace( $this->mNamespace );
+ }
+
+ /**
+ * Is this in a namespace that allows actual pages?
+ *
+ * @return bool
+ */
+ public function canExist() {
+ return $this->mNamespace >= NS_MAIN;
+ }
+
+ /**
+ * Can this title be added to a user's watchlist?
+ *
+ * @return bool
+ */
+ public function isWatchable() {
+ return !$this->isExternal() && MWNamespace::isWatchable( $this->getNamespace() );
+ }
+
+ /**
+ * Returns true if this is a special page.
+ *
+ * @return bool
+ */
+ public function isSpecialPage() {
+ return $this->getNamespace() == NS_SPECIAL;
+ }
+
+ /**
+ * Returns true if this title resolves to the named special page
+ *
+ * @param string $name The special page name
+ * @return bool
+ */
+ public function isSpecial( $name ) {
+ if ( $this->isSpecialPage() ) {
+ list( $thisName, /* $subpage */ ) = SpecialPageFactory::resolveAlias( $this->getDBkey() );
+ if ( $name == $thisName ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * If the Title refers to a special page alias which is not the local default, resolve
+ * the alias, and localise the name as necessary. Otherwise, return $this
+ *
+ * @return Title
+ */
+ public function fixSpecialName() {
+ if ( $this->isSpecialPage() ) {
+ list( $canonicalName, $par ) = SpecialPageFactory::resolveAlias( $this->mDbkeyform );
+ if ( $canonicalName ) {
+ $localName = SpecialPageFactory::getLocalNameFor( $canonicalName, $par );
+ if ( $localName != $this->mDbkeyform ) {
+ return self::makeTitle( NS_SPECIAL, $localName );
+ }
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Returns true if the title is inside the specified namespace.
+ *
+ * Please make use of this instead of comparing to getNamespace()
+ * This function is much more resistant to changes we may make
+ * to namespaces than code that makes direct comparisons.
+ * @param int $ns The namespace
+ * @return bool
+ * @since 1.19
+ */
+ public function inNamespace( $ns ) {
+ return MWNamespace::equals( $this->getNamespace(), $ns );
+ }
+
+ /**
+ * Returns true if the title is inside one of the specified namespaces.
+ *
+ * @param int|int[] $namespaces,... The namespaces to check for
+ * @return bool
+ * @since 1.19
+ */
+ public function inNamespaces( /* ... */ ) {
+ $namespaces = func_get_args();
+ if ( count( $namespaces ) > 0 && is_array( $namespaces[0] ) ) {
+ $namespaces = $namespaces[0];
+ }
+
+ foreach ( $namespaces as $ns ) {
+ if ( $this->inNamespace( $ns ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns true if the title has the same subject namespace as the
+ * namespace specified.
+ * For example this method will take NS_USER and return true if namespace
+ * is either NS_USER or NS_USER_TALK since both of them have NS_USER
+ * as their subject namespace.
+ *
+ * This is MUCH simpler than individually testing for equivalence
+ * against both NS_USER and NS_USER_TALK, and is also forward compatible.
+ * @since 1.19
+ * @param int $ns
+ * @return bool
+ */
+ public function hasSubjectNamespace( $ns ) {
+ return MWNamespace::subjectEquals( $this->getNamespace(), $ns );
+ }
+
+ /**
+ * Is this Title in a namespace which contains content?
+ * In other words, is this a content page, for the purposes of calculating
+ * statistics, etc?
+ *
+ * @return bool
+ */
+ public function isContentPage() {
+ return MWNamespace::isContent( $this->getNamespace() );
+ }
+
+ /**
+ * Would anybody with sufficient privileges be able to move this page?
+ * Some pages just aren't movable.
+ *
+ * @return bool
+ */
+ public function isMovable() {
+ if ( !MWNamespace::isMovable( $this->getNamespace() ) || $this->isExternal() ) {
+ // Interwiki title or immovable namespace. Hooks don't get to override here
+ return false;
+ }
+
+ $result = true;
+ Hooks::run( 'TitleIsMovable', [ $this, &$result ] );
+ return $result;
+ }
+
+ /**
+ * Is this the mainpage?
+ * @note Title::newFromText seems to be sufficiently optimized by the title
+ * cache that we don't need to over-optimize by doing direct comparisons and
+ * accidentally creating new bugs where $title->equals( Title::newFromText() )
+ * ends up reporting something differently than $title->isMainPage();
+ *
+ * @since 1.18
+ * @return bool
+ */
+ public function isMainPage() {
+ return $this->equals( self::newMainPage() );
+ }
+
+ /**
+ * Is this a subpage?
+ *
+ * @return bool
+ */
+ public function isSubpage() {
+ return MWNamespace::hasSubpages( $this->mNamespace )
+ ? strpos( $this->getText(), '/' ) !== false
+ : false;
+ }
+
+ /**
+ * Is this a conversion table for the LanguageConverter?
+ *
+ * @return bool
+ */
+ public function isConversionTable() {
+ // @todo ConversionTable should become a separate content model.
+
+ return $this->getNamespace() == NS_MEDIAWIKI &&
+ strpos( $this->getText(), 'Conversiontable/' ) === 0;
+ }
+
+ /**
+ * Does that page contain wikitext, or it is JS, CSS or whatever?
+ *
+ * @return bool
+ */
+ public function isWikitextPage() {
+ return $this->hasContentModel( CONTENT_MODEL_WIKITEXT );
+ }
+
+ /**
+ * Could this page contain custom CSS or JavaScript for the global UI.
+ * This is generally true for pages in the MediaWiki namespace having CONTENT_MODEL_CSS
+ * or CONTENT_MODEL_JAVASCRIPT.
+ *
+ * This method does *not* return true for per-user JS/CSS. Use isCssJsSubpage()
+ * for that!
+ *
+ * Note that this method should not return true for pages that contain and
+ * show "inactive" CSS or JS.
+ *
+ * @return bool
+ * @todo FIXME: Rename to isSiteConfigPage() and remove deprecated hook
+ */
+ public function isCssOrJsPage() {
+ $isCssOrJsPage = NS_MEDIAWIKI == $this->mNamespace
+ && ( $this->hasContentModel( CONTENT_MODEL_CSS )
+ || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) );
+
+ return $isCssOrJsPage;
+ }
+
+ /**
+ * Is this a .css or .js subpage of a user page?
+ * @return bool
+ * @todo FIXME: Rename to isUserConfigPage()
+ */
+ public function isCssJsSubpage() {
+ return ( NS_USER == $this->mNamespace && $this->isSubpage()
+ && ( $this->hasContentModel( CONTENT_MODEL_CSS )
+ || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ) );
+ }
+
+ /**
+ * Trim down a .css or .js subpage title to get the corresponding skin name
+ *
+ * @return string Containing skin name from .css or .js subpage title
+ */
+ public function getSkinFromCssJsSubpage() {
+ $subpage = explode( '/', $this->mTextform );
+ $subpage = $subpage[count( $subpage ) - 1];
+ $lastdot = strrpos( $subpage, '.' );
+ if ( $lastdot === false ) {
+ return $subpage; # Never happens: only called for names ending in '.css' or '.js'
+ }
+ return substr( $subpage, 0, $lastdot );
+ }
+
+ /**
+ * Is this a .css subpage of a user page?
+ *
+ * @return bool
+ */
+ public function isCssSubpage() {
+ return ( NS_USER == $this->mNamespace && $this->isSubpage()
+ && $this->hasContentModel( CONTENT_MODEL_CSS ) );
+ }
+
+ /**
+ * Is this a .js subpage of a user page?
+ *
+ * @return bool
+ */
+ public function isJsSubpage() {
+ return ( NS_USER == $this->mNamespace && $this->isSubpage()
+ && $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) );
+ }
+
+ /**
+ * Is this a talk page of some sort?
+ *
+ * @return bool
+ */
+ public function isTalkPage() {
+ return MWNamespace::isTalk( $this->getNamespace() );
+ }
+
+ /**
+ * Get a Title object associated with the talk page of this article
+ *
+ * @return Title The object for the talk page
+ */
+ public function getTalkPage() {
+ return self::makeTitle( MWNamespace::getTalk( $this->getNamespace() ), $this->getDBkey() );
+ }
+
+ /**
+ * Get a Title object associated with the talk page of this article,
+ * if such a talk page can exist.
+ *
+ * @since 1.30
+ *
+ * @return Title|null The object for the talk page,
+ * or null if no associated talk page can exist, according to canHaveTalkPage().
+ */
+ public function getTalkPageIfDefined() {
+ if ( !$this->canHaveTalkPage() ) {
+ return null;
+ }
+
+ return $this->getTalkPage();
+ }
+
+ /**
+ * Get a title object associated with the subject page of this
+ * talk page
+ *
+ * @return Title The object for the subject page
+ */
+ public function getSubjectPage() {
+ // Is this the same title?
+ $subjectNS = MWNamespace::getSubject( $this->getNamespace() );
+ if ( $this->getNamespace() == $subjectNS ) {
+ return $this;
+ }
+ return self::makeTitle( $subjectNS, $this->getDBkey() );
+ }
+
+ /**
+ * Get the other title for this page, if this is a subject page
+ * get the talk page, if it is a subject page get the talk page
+ *
+ * @since 1.25
+ * @throws MWException If the page doesn't have an other page
+ * @return Title
+ */
+ public function getOtherPage() {
+ if ( $this->isSpecialPage() ) {
+ throw new MWException( 'Special pages cannot have other pages' );
+ }
+ if ( $this->isTalkPage() ) {
+ return $this->getSubjectPage();
+ } else {
+ if ( !$this->canHaveTalkPage() ) {
+ throw new MWException( "{$this->getPrefixedText()} does not have an other page" );
+ }
+ return $this->getTalkPage();
+ }
+ }
+
+ /**
+ * Get the default namespace index, for when there is no namespace
+ *
+ * @return int Default namespace index
+ */
+ public function getDefaultNamespace() {
+ return $this->mDefaultNamespace;
+ }
+
+ /**
+ * Get the Title fragment (i.e.\ the bit after the #) in text form
+ *
+ * Use Title::hasFragment to check for a fragment
+ *
+ * @return string Title fragment
+ */
+ public function getFragment() {
+ return $this->mFragment;
+ }
+
+ /**
+ * Check if a Title fragment is set
+ *
+ * @return bool
+ * @since 1.23
+ */
+ public function hasFragment() {
+ return $this->mFragment !== '';
+ }
+
+ /**
+ * Get the fragment in URL form, including the "#" character if there is one
+ *
+ * @return string Fragment in URL form
+ */
+ public function getFragmentForURL() {
+ if ( !$this->hasFragment() ) {
+ return '';
+ } elseif ( $this->isExternal() && !$this->getTransWikiID() ) {
+ return '#' . Sanitizer::escapeIdForExternalInterwiki( $this->getFragment() );
+ }
+ return '#' . Sanitizer::escapeIdForLink( $this->getFragment() );
+ }
+
+ /**
+ * Set the fragment for this title. Removes the first character from the
+ * specified fragment before setting, so it assumes you're passing it with
+ * an initial "#".
+ *
+ * Deprecated for public use, use Title::makeTitle() with fragment parameter,
+ * or Title::createFragmentTarget().
+ * Still in active use privately.
+ *
+ * @private
+ * @param string $fragment Text
+ */
+ public function setFragment( $fragment ) {
+ $this->mFragment = strtr( substr( $fragment, 1 ), '_', ' ' );
+ }
+
+ /**
+ * Creates a new Title for a different fragment of the same page.
+ *
+ * @since 1.27
+ * @param string $fragment
+ * @return Title
+ */
+ public function createFragmentTarget( $fragment ) {
+ return self::makeTitle(
+ $this->getNamespace(),
+ $this->getText(),
+ $fragment,
+ $this->getInterwiki()
+ );
+ }
+
+ /**
+ * Prefix some arbitrary text with the namespace or interwiki prefix
+ * of this object
+ *
+ * @param string $name The text
+ * @return string The prefixed text
+ */
+ private function prefix( $name ) {
+ global $wgContLang;
+
+ $p = '';
+ if ( $this->isExternal() ) {
+ $p = $this->mInterwiki . ':';
+ }
+
+ if ( 0 != $this->mNamespace ) {
+ $nsText = $this->getNsText();
+
+ if ( $nsText === false ) {
+ // See T165149. Awkward, but better than erroneously linking to the main namespace.
+ $nsText = $wgContLang->getNsText( NS_SPECIAL ) . ":Badtitle/NS{$this->mNamespace}";
+ }
+
+ $p .= $nsText . ':';
+ }
+ return $p . $name;
+ }
+
+ /**
+ * Get the prefixed database key form
+ *
+ * @return string The prefixed title, with underscores and
+ * any interwiki and namespace prefixes
+ */
+ public function getPrefixedDBkey() {
+ $s = $this->prefix( $this->mDbkeyform );
+ $s = strtr( $s, ' ', '_' );
+ return $s;
+ }
+
+ /**
+ * Get the prefixed title with spaces.
+ * This is the form usually used for display
+ *
+ * @return string The prefixed title, with spaces
+ */
+ public function getPrefixedText() {
+ if ( $this->mPrefixedText === null ) {
+ $s = $this->prefix( $this->mTextform );
+ $s = strtr( $s, '_', ' ' );
+ $this->mPrefixedText = $s;
+ }
+ return $this->mPrefixedText;
+ }
+
+ /**
+ * Return a string representation of this title
+ *
+ * @return string Representation of this title
+ */
+ public function __toString() {
+ return $this->getPrefixedText();
+ }
+
+ /**
+ * Get the prefixed title with spaces, plus any fragment
+ * (part beginning with '#')
+ *
+ * @return string The prefixed title, with spaces and the fragment, including '#'
+ */
+ public function getFullText() {
+ $text = $this->getPrefixedText();
+ if ( $this->hasFragment() ) {
+ $text .= '#' . $this->getFragment();
+ }
+ return $text;
+ }
+
+ /**
+ * Get the root page name text without a namespace, i.e. the leftmost part before any slashes
+ *
+ * @par Example:
+ * @code
+ * Title::newFromText('User:Foo/Bar/Baz')->getRootText();
+ * # returns: 'Foo'
+ * @endcode
+ *
+ * @return string Root name
+ * @since 1.20
+ */
+ public function getRootText() {
+ if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) {
+ return $this->getText();
+ }
+
+ return strtok( $this->getText(), '/' );
+ }
+
+ /**
+ * Get the root page name title, i.e. the leftmost part before any slashes
+ *
+ * @par Example:
+ * @code
+ * Title::newFromText('User:Foo/Bar/Baz')->getRootTitle();
+ * # returns: Title{User:Foo}
+ * @endcode
+ *
+ * @return Title Root title
+ * @since 1.20
+ */
+ public function getRootTitle() {
+ return self::makeTitle( $this->getNamespace(), $this->getRootText() );
+ }
+
+ /**
+ * Get the base page name without a namespace, i.e. the part before the subpage name
+ *
+ * @par Example:
+ * @code
+ * Title::newFromText('User:Foo/Bar/Baz')->getBaseText();
+ * # returns: 'Foo/Bar'
+ * @endcode
+ *
+ * @return string Base name
+ */
+ public function getBaseText() {
+ if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) {
+ return $this->getText();
+ }
+
+ $parts = explode( '/', $this->getText() );
+ # Don't discard the real title if there's no subpage involved
+ if ( count( $parts ) > 1 ) {
+ unset( $parts[count( $parts ) - 1] );
+ }
+ return implode( '/', $parts );
+ }
+
+ /**
+ * Get the base page name title, i.e. the part before the subpage name
+ *
+ * @par Example:
+ * @code
+ * Title::newFromText('User:Foo/Bar/Baz')->getBaseTitle();
+ * # returns: Title{User:Foo/Bar}
+ * @endcode
+ *
+ * @return Title Base title
+ * @since 1.20
+ */
+ public function getBaseTitle() {
+ return self::makeTitle( $this->getNamespace(), $this->getBaseText() );
+ }
+
+ /**
+ * Get the lowest-level subpage name, i.e. the rightmost part after any slashes
+ *
+ * @par Example:
+ * @code
+ * Title::newFromText('User:Foo/Bar/Baz')->getSubpageText();
+ * # returns: "Baz"
+ * @endcode
+ *
+ * @return string Subpage name
+ */
+ public function getSubpageText() {
+ if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) {
+ return $this->mTextform;
+ }
+ $parts = explode( '/', $this->mTextform );
+ return $parts[count( $parts ) - 1];
+ }
+
+ /**
+ * Get the title for a subpage of the current page
+ *
+ * @par Example:
+ * @code
+ * Title::newFromText('User:Foo/Bar/Baz')->getSubpage("Asdf");
+ * # returns: Title{User:Foo/Bar/Baz/Asdf}
+ * @endcode
+ *
+ * @param string $text The subpage name to add to the title
+ * @return Title Subpage title
+ * @since 1.20
+ */
+ public function getSubpage( $text ) {
+ return self::makeTitleSafe( $this->getNamespace(), $this->getText() . '/' . $text );
+ }
+
+ /**
+ * Get a URL-encoded form of the subpage text
+ *
+ * @return string URL-encoded subpage name
+ */
+ public function getSubpageUrlForm() {
+ $text = $this->getSubpageText();
+ $text = wfUrlencode( strtr( $text, ' ', '_' ) );
+ return $text;
+ }
+
+ /**
+ * Get a URL-encoded title (not an actual URL) including interwiki
+ *
+ * @return string The URL-encoded form
+ */
+ public function getPrefixedURL() {
+ $s = $this->prefix( $this->mDbkeyform );
+ $s = wfUrlencode( strtr( $s, ' ', '_' ) );
+ return $s;
+ }
+
+ /**
+ * Helper to fix up the get{Canonical,Full,Link,Local,Internal}URL args
+ * get{Canonical,Full,Link,Local,Internal}URL methods accepted an optional
+ * second argument named variant. This was deprecated in favor
+ * of passing an array of option with a "variant" key
+ * Once $query2 is removed for good, this helper can be dropped
+ * and the wfArrayToCgi moved to getLocalURL();
+ *
+ * @since 1.19 (r105919)
+ * @param array|string $query
+ * @param string|string[]|bool $query2
+ * @return string
+ */
+ private static function fixUrlQueryArgs( $query, $query2 = false ) {
+ if ( $query2 !== false ) {
+ wfDeprecated( "Title::get{Canonical,Full,Link,Local,Internal}URL " .
+ "method called with a second parameter is deprecated. Add your " .
+ "parameter to an array passed as the first parameter.", "1.19" );
+ }
+ if ( is_array( $query ) ) {
+ $query = wfArrayToCgi( $query );
+ }
+ if ( $query2 ) {
+ if ( is_string( $query2 ) ) {
+ // $query2 is a string, we will consider this to be
+ // a deprecated $variant argument and add it to the query
+ $query2 = wfArrayToCgi( [ 'variant' => $query2 ] );
+ } else {
+ $query2 = wfArrayToCgi( $query2 );
+ }
+ // If we have $query content add a & to it first
+ if ( $query ) {
+ $query .= '&';
+ }
+ // Now append the queries together
+ $query .= $query2;
+ }
+ return $query;
+ }
+
+ /**
+ * Get a real URL referring to this title, with interwiki link and
+ * fragment
+ *
+ * @see self::getLocalURL for the arguments.
+ * @see wfExpandUrl
+ * @param string|string[] $query
+ * @param string|string[]|bool $query2
+ * @param string $proto Protocol type to use in URL
+ * @return string The URL
+ */
+ public function getFullURL( $query = '', $query2 = false, $proto = PROTO_RELATIVE ) {
+ $query = self::fixUrlQueryArgs( $query, $query2 );
+
+ # Hand off all the decisions on urls to getLocalURL
+ $url = $this->getLocalURL( $query );
+
+ # Expand the url to make it a full url. Note that getLocalURL has the
+ # potential to output full urls for a variety of reasons, so we use
+ # wfExpandUrl instead of simply prepending $wgServer
+ $url = wfExpandUrl( $url, $proto );
+
+ # Finally, add the fragment.
+ $url .= $this->getFragmentForURL();
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $titleRef = $this;
+ Hooks::run( 'GetFullURL', [ &$titleRef, &$url, $query ] );
+ return $url;
+ }
+
+ /**
+ * Get a url appropriate for making redirects based on an untrusted url arg
+ *
+ * This is basically the same as getFullUrl(), but in the case of external
+ * interwikis, we send the user to a landing page, to prevent possible
+ * phishing attacks and the like.
+ *
+ * @note Uses current protocol by default, since technically relative urls
+ * aren't allowed in redirects per HTTP spec, so this is not suitable for
+ * places where the url gets cached, as might pollute between
+ * https and non-https users.
+ * @see self::getLocalURL for the arguments.
+ * @param array|string $query
+ * @param string $proto Protocol type to use in URL
+ * @return string A url suitable to use in an HTTP location header.
+ */
+ public function getFullUrlForRedirect( $query = '', $proto = PROTO_CURRENT ) {
+ $target = $this;
+ if ( $this->isExternal() ) {
+ $target = SpecialPage::getTitleFor(
+ 'GoToInterwiki',
+ $this->getPrefixedDBKey()
+ );
+ }
+ return $target->getFullUrl( $query, false, $proto );
+ }
+
+ /**
+ * Get a URL with no fragment or server name (relative URL) from a Title object.
+ * If this page is generated with action=render, however,
+ * $wgServer is prepended to make an absolute URL.
+ *
+ * @see self::getFullURL to always get an absolute URL.
+ * @see self::getLinkURL to always get a URL that's the simplest URL that will be
+ * valid to link, locally, to the current Title.
+ * @see self::newFromText to produce a Title object.
+ *
+ * @param string|string[] $query An optional query string,
+ * not used for interwiki links. Can be specified as an associative array as well,
+ * e.g., array( 'action' => 'edit' ) (keys and values will be URL-escaped).
+ * Some query patterns will trigger various shorturl path replacements.
+ * @param string|string[]|bool $query2 An optional secondary query array. This one MUST
+ * be an array. If a string is passed it will be interpreted as a deprecated
+ * variant argument and urlencoded into a variant= argument.
+ * This second query argument will be added to the $query
+ * The second parameter is deprecated since 1.19. Pass it as a key,value
+ * pair in the first parameter array instead.
+ *
+ * @return string String of the URL.
+ */
+ public function getLocalURL( $query = '', $query2 = false ) {
+ global $wgArticlePath, $wgScript, $wgServer, $wgRequest;
+
+ $query = self::fixUrlQueryArgs( $query, $query2 );
+
+ $interwiki = self::getInterwikiLookup()->fetch( $this->mInterwiki );
+ if ( $interwiki ) {
+ $namespace = $this->getNsText();
+ if ( $namespace != '' ) {
+ # Can this actually happen? Interwikis shouldn't be parsed.
+ # Yes! It can in interwiki transclusion. But... it probably shouldn't.
+ $namespace .= ':';
+ }
+ $url = $interwiki->getURL( $namespace . $this->getDBkey() );
+ $url = wfAppendQuery( $url, $query );
+ } else {
+ $dbkey = wfUrlencode( $this->getPrefixedDBkey() );
+ if ( $query == '' ) {
+ $url = str_replace( '$1', $dbkey, $wgArticlePath );
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $titleRef = $this;
+ Hooks::run( 'GetLocalURL::Article', [ &$titleRef, &$url ] );
+ } else {
+ global $wgVariantArticlePath, $wgActionPaths, $wgContLang;
+ $url = false;
+ $matches = [];
+
+ if ( !empty( $wgActionPaths )
+ && preg_match( '/^(.*&|)action=([^&]*)(&(.*)|)$/', $query, $matches )
+ ) {
+ $action = urldecode( $matches[2] );
+ if ( isset( $wgActionPaths[$action] ) ) {
+ $query = $matches[1];
+ if ( isset( $matches[4] ) ) {
+ $query .= $matches[4];
+ }
+ $url = str_replace( '$1', $dbkey, $wgActionPaths[$action] );
+ if ( $query != '' ) {
+ $url = wfAppendQuery( $url, $query );
+ }
+ }
+ }
+
+ if ( $url === false
+ && $wgVariantArticlePath
+ && preg_match( '/^variant=([^&]*)$/', $query, $matches )
+ && $this->getPageLanguage()->equals( $wgContLang )
+ && $this->getPageLanguage()->hasVariants()
+ ) {
+ $variant = urldecode( $matches[1] );
+ if ( $this->getPageLanguage()->hasVariant( $variant ) ) {
+ // Only do the variant replacement if the given variant is a valid
+ // variant for the page's language.
+ $url = str_replace( '$2', urlencode( $variant ), $wgVariantArticlePath );
+ $url = str_replace( '$1', $dbkey, $url );
+ }
+ }
+
+ if ( $url === false ) {
+ if ( $query == '-' ) {
+ $query = '';
+ }
+ $url = "{$wgScript}?title={$dbkey}&{$query}";
+ }
+ }
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $titleRef = $this;
+ Hooks::run( 'GetLocalURL::Internal', [ &$titleRef, &$url, $query ] );
+
+ // @todo FIXME: This causes breakage in various places when we
+ // actually expected a local URL and end up with dupe prefixes.
+ if ( $wgRequest->getVal( 'action' ) == 'render' ) {
+ $url = $wgServer . $url;
+ }
+ }
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $titleRef = $this;
+ Hooks::run( 'GetLocalURL', [ &$titleRef, &$url, $query ] );
+ return $url;
+ }
+
+ /**
+ * Get a URL that's the simplest URL that will be valid to link, locally,
+ * to the current Title. It includes the fragment, but does not include
+ * the server unless action=render is used (or the link is external). If
+ * there's a fragment but the prefixed text is empty, we just return a link
+ * to the fragment.
+ *
+ * The result obviously should not be URL-escaped, but does need to be
+ * HTML-escaped if it's being output in HTML.
+ *
+ * @param string|string[] $query
+ * @param bool $query2
+ * @param string|int|bool $proto A PROTO_* constant on how the URL should be expanded,
+ * or false (default) for no expansion
+ * @see self::getLocalURL for the arguments.
+ * @return string The URL
+ */
+ public function getLinkURL( $query = '', $query2 = false, $proto = false ) {
+ if ( $this->isExternal() || $proto !== false ) {
+ $ret = $this->getFullURL( $query, $query2, $proto );
+ } elseif ( $this->getPrefixedText() === '' && $this->hasFragment() ) {
+ $ret = $this->getFragmentForURL();
+ } else {
+ $ret = $this->getLocalURL( $query, $query2 ) . $this->getFragmentForURL();
+ }
+ return $ret;
+ }
+
+ /**
+ * Get the URL form for an internal link.
+ * - Used in various CDN-related code, in case we have a different
+ * internal hostname for the server from the exposed one.
+ *
+ * This uses $wgInternalServer to qualify the path, or $wgServer
+ * if $wgInternalServer is not set. If the server variable used is
+ * protocol-relative, the URL will be expanded to http://
+ *
+ * @see self::getLocalURL for the arguments.
+ * @param string $query
+ * @param string|bool $query2
+ * @return string The URL
+ */
+ public function getInternalURL( $query = '', $query2 = false ) {
+ global $wgInternalServer, $wgServer;
+ $query = self::fixUrlQueryArgs( $query, $query2 );
+ $server = $wgInternalServer !== false ? $wgInternalServer : $wgServer;
+ $url = wfExpandUrl( $server . $this->getLocalURL( $query ), PROTO_HTTP );
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $titleRef = $this;
+ Hooks::run( 'GetInternalURL', [ &$titleRef, &$url, $query ] );
+ return $url;
+ }
+
+ /**
+ * Get the URL for a canonical link, for use in things like IRC and
+ * e-mail notifications. Uses $wgCanonicalServer and the
+ * GetCanonicalURL hook.
+ *
+ * NOTE: Unlike getInternalURL(), the canonical URL includes the fragment
+ *
+ * @see self::getLocalURL for the arguments.
+ * @param string $query
+ * @param string|bool $query2
+ * @return string The URL
+ * @since 1.18
+ */
+ public function getCanonicalURL( $query = '', $query2 = false ) {
+ $query = self::fixUrlQueryArgs( $query, $query2 );
+ $url = wfExpandUrl( $this->getLocalURL( $query ) . $this->getFragmentForURL(), PROTO_CANONICAL );
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $titleRef = $this;
+ Hooks::run( 'GetCanonicalURL', [ &$titleRef, &$url, $query ] );
+ return $url;
+ }
+
+ /**
+ * Get the edit URL for this Title
+ *
+ * @return string The URL, or a null string if this is an interwiki link
+ */
+ public function getEditURL() {
+ if ( $this->isExternal() ) {
+ return '';
+ }
+ $s = $this->getLocalURL( 'action=edit' );
+
+ return $s;
+ }
+
+ /**
+ * Can $user perform $action on this page?
+ * This skips potentially expensive cascading permission checks
+ * as well as avoids expensive error formatting
+ *
+ * Suitable for use for nonessential UI controls in common cases, but
+ * _not_ for functional access control.
+ *
+ * May provide false positives, but should never provide a false negative.
+ *
+ * @param string $action Action that permission needs to be checked for
+ * @param User $user User to check (since 1.19); $wgUser will be used if not provided.
+ * @return bool
+ */
+ public function quickUserCan( $action, $user = null ) {
+ return $this->userCan( $action, $user, false );
+ }
+
+ /**
+ * Can $user perform $action on this page?
+ *
+ * @param string $action Action that permission needs to be checked for
+ * @param User $user User to check (since 1.19); $wgUser will be used if not
+ * provided.
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @return bool
+ */
+ public function userCan( $action, $user = null, $rigor = 'secure' ) {
+ if ( !$user instanceof User ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ return !count( $this->getUserPermissionsErrorsInternal( $action, $user, $rigor, true ) );
+ }
+
+ /**
+ * Can $user perform $action on this page?
+ *
+ * @todo FIXME: This *does not* check throttles (User::pingLimiter()).
+ *
+ * @param string $action Action that permission needs to be checked for
+ * @param User $user User to check
+ * @param string $rigor One of (quick,full,secure)
+ * - quick : does cheap permission checks from replica DBs (usable for GUI creation)
+ * - full : does cheap and expensive checks possibly from a replica DB
+ * - secure : does cheap and expensive checks, using the master as needed
+ * @param array $ignoreErrors Array of Strings Set this to a list of message keys
+ * whose corresponding errors may be ignored.
+ * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
+ */
+ public function getUserPermissionsErrors(
+ $action, $user, $rigor = 'secure', $ignoreErrors = []
+ ) {
+ $errors = $this->getUserPermissionsErrorsInternal( $action, $user, $rigor );
+
+ // Remove the errors being ignored.
+ foreach ( $errors as $index => $error ) {
+ $errKey = is_array( $error ) ? $error[0] : $error;
+
+ if ( in_array( $errKey, $ignoreErrors ) ) {
+ unset( $errors[$index] );
+ }
+ if ( $errKey instanceof MessageSpecifier && in_array( $errKey->getKey(), $ignoreErrors ) ) {
+ unset( $errors[$index] );
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Permissions checks that fail most often, and which are easiest to test.
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkQuickPermissions( $action, $user, $errors, $rigor, $short ) {
+ if ( !Hooks::run( 'TitleQuickPermissions',
+ [ $this, $user, $action, &$errors, ( $rigor !== 'quick' ), $short ] )
+ ) {
+ return $errors;
+ }
+
+ if ( $action == 'create' ) {
+ if (
+ ( $this->isTalkPage() && !$user->isAllowed( 'createtalk' ) ) ||
+ ( !$this->isTalkPage() && !$user->isAllowed( 'createpage' ) )
+ ) {
+ $errors[] = $user->isAnon() ? [ 'nocreatetext' ] : [ 'nocreate-loggedin' ];
+ }
+ } elseif ( $action == 'move' ) {
+ if ( !$user->isAllowed( 'move-rootuserpages' )
+ && $this->mNamespace == NS_USER && !$this->isSubpage() ) {
+ // Show user page-specific message only if the user can move other pages
+ $errors[] = [ 'cant-move-user-page' ];
+ }
+
+ // Check if user is allowed to move files if it's a file
+ if ( $this->mNamespace == NS_FILE && !$user->isAllowed( 'movefile' ) ) {
+ $errors[] = [ 'movenotallowedfile' ];
+ }
+
+ // Check if user is allowed to move category pages if it's a category page
+ if ( $this->mNamespace == NS_CATEGORY && !$user->isAllowed( 'move-categorypages' ) ) {
+ $errors[] = [ 'cant-move-category-page' ];
+ }
+
+ if ( !$user->isAllowed( 'move' ) ) {
+ // User can't move anything
+ $userCanMove = User::groupHasPermission( 'user', 'move' );
+ $autoconfirmedCanMove = User::groupHasPermission( 'autoconfirmed', 'move' );
+ if ( $user->isAnon() && ( $userCanMove || $autoconfirmedCanMove ) ) {
+ // custom message if logged-in users without any special rights can move
+ $errors[] = [ 'movenologintext' ];
+ } else {
+ $errors[] = [ 'movenotallowed' ];
+ }
+ }
+ } elseif ( $action == 'move-target' ) {
+ if ( !$user->isAllowed( 'move' ) ) {
+ // User can't move anything
+ $errors[] = [ 'movenotallowed' ];
+ } elseif ( !$user->isAllowed( 'move-rootuserpages' )
+ && $this->mNamespace == NS_USER && !$this->isSubpage() ) {
+ // Show user page-specific message only if the user can move other pages
+ $errors[] = [ 'cant-move-to-user-page' ];
+ } elseif ( !$user->isAllowed( 'move-categorypages' )
+ && $this->mNamespace == NS_CATEGORY ) {
+ // Show category page-specific message only if the user can move other pages
+ $errors[] = [ 'cant-move-to-category-page' ];
+ }
+ } elseif ( !$user->isAllowed( $action ) ) {
+ $errors[] = $this->missingPermissionError( $action, $short );
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Add the resulting error code to the errors array
+ *
+ * @param array $errors List of current errors
+ * @param array $result Result of errors
+ *
+ * @return array List of errors
+ */
+ private function resultToError( $errors, $result ) {
+ if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
+ // A single array representing an error
+ $errors[] = $result;
+ } elseif ( is_array( $result ) && is_array( $result[0] ) ) {
+ // A nested array representing multiple errors
+ $errors = array_merge( $errors, $result );
+ } elseif ( $result !== '' && is_string( $result ) ) {
+ // A string representing a message-id
+ $errors[] = [ $result ];
+ } elseif ( $result instanceof MessageSpecifier ) {
+ // A message specifier representing an error
+ $errors[] = [ $result ];
+ } elseif ( $result === false ) {
+ // a generic "We don't want them to do that"
+ $errors[] = [ 'badaccess-group0' ];
+ }
+ return $errors;
+ }
+
+ /**
+ * Check various permission hooks
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkPermissionHooks( $action, $user, $errors, $rigor, $short ) {
+ // Use getUserPermissionsErrors instead
+ $result = '';
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $titleRef = $this;
+ if ( !Hooks::run( 'userCan', [ &$titleRef, &$user, $action, &$result ] ) ) {
+ return $result ? [] : [ [ 'badaccess-group0' ] ];
+ }
+ // Check getUserPermissionsErrors hook
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $titleRef = $this;
+ if ( !Hooks::run( 'getUserPermissionsErrors', [ &$titleRef, &$user, $action, &$result ] ) ) {
+ $errors = $this->resultToError( $errors, $result );
+ }
+ // Check getUserPermissionsErrorsExpensive hook
+ if (
+ $rigor !== 'quick'
+ && !( $short && count( $errors ) > 0 )
+ && !Hooks::run( 'getUserPermissionsErrorsExpensive', [ &$titleRef, &$user, $action, &$result ] )
+ ) {
+ $errors = $this->resultToError( $errors, $result );
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Check permissions on special pages & namespaces
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkSpecialsAndNSPermissions( $action, $user, $errors, $rigor, $short ) {
+ # Only 'createaccount' can be performed on special pages,
+ # which don't actually exist in the DB.
+ if ( $this->isSpecialPage() && $action !== 'createaccount' ) {
+ $errors[] = [ 'ns-specialprotected' ];
+ }
+
+ # Check $wgNamespaceProtection for restricted namespaces
+ if ( $this->isNamespaceProtected( $user ) ) {
+ $ns = $this->mNamespace == NS_MAIN ?
+ wfMessage( 'nstab-main' )->text() : $this->getNsText();
+ $errors[] = $this->mNamespace == NS_MEDIAWIKI ?
+ [ 'protectedinterface', $action ] : [ 'namespaceprotected', $ns, $action ];
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Check CSS/JS sub-page permissions
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkCSSandJSPermissions( $action, $user, $errors, $rigor, $short ) {
+ # Protect css/js subpages of user pages
+ # XXX: this might be better using restrictions
+ if ( $action != 'patrol' ) {
+ if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $this->mTextform ) ) {
+ if ( $this->isCssSubpage() && !$user->isAllowedAny( 'editmyusercss', 'editusercss' ) ) {
+ $errors[] = [ 'mycustomcssprotected', $action ];
+ } elseif ( $this->isJsSubpage() && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' ) ) {
+ $errors[] = [ 'mycustomjsprotected', $action ];
+ }
+ } else {
+ if ( $this->isCssSubpage() && !$user->isAllowed( 'editusercss' ) ) {
+ $errors[] = [ 'customcssprotected', $action ];
+ } elseif ( $this->isJsSubpage() && !$user->isAllowed( 'edituserjs' ) ) {
+ $errors[] = [ 'customjsprotected', $action ];
+ }
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Check against page_restrictions table requirements on this
+ * page. The user must possess all required rights for this
+ * action.
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkPageRestrictions( $action, $user, $errors, $rigor, $short ) {
+ foreach ( $this->getRestrictions( $action ) as $right ) {
+ // Backwards compatibility, rewrite sysop -> editprotected
+ if ( $right == 'sysop' ) {
+ $right = 'editprotected';
+ }
+ // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
+ if ( $right == 'autoconfirmed' ) {
+ $right = 'editsemiprotected';
+ }
+ if ( $right == '' ) {
+ continue;
+ }
+ if ( !$user->isAllowed( $right ) ) {
+ $errors[] = [ 'protectedpagetext', $right, $action ];
+ } elseif ( $this->mCascadeRestriction && !$user->isAllowed( 'protect' ) ) {
+ $errors[] = [ 'protectedpagetext', 'protect', $action ];
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Check restrictions on cascading pages.
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkCascadingSourcesRestrictions( $action, $user, $errors, $rigor, $short ) {
+ if ( $rigor !== 'quick' && !$this->isCssJsSubpage() ) {
+ # We /could/ use the protection level on the source page, but it's
+ # fairly ugly as we have to establish a precedence hierarchy for pages
+ # included by multiple cascade-protected pages. So just restrict
+ # it to people with 'protect' permission, as they could remove the
+ # protection anyway.
+ list( $cascadingSources, $restrictions ) = $this->getCascadeProtectionSources();
+ # Cascading protection depends on more than this page...
+ # Several cascading protected pages may include this page...
+ # Check each cascading level
+ # This is only for protection restrictions, not for all actions
+ if ( isset( $restrictions[$action] ) ) {
+ foreach ( $restrictions[$action] as $right ) {
+ // Backwards compatibility, rewrite sysop -> editprotected
+ if ( $right == 'sysop' ) {
+ $right = 'editprotected';
+ }
+ // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
+ if ( $right == 'autoconfirmed' ) {
+ $right = 'editsemiprotected';
+ }
+ if ( $right != '' && !$user->isAllowedAll( 'protect', $right ) ) {
+ $pages = '';
+ foreach ( $cascadingSources as $page ) {
+ $pages .= '* [[:' . $page->getPrefixedText() . "]]\n";
+ }
+ $errors[] = [ 'cascadeprotected', count( $cascadingSources ), $pages, $action ];
+ }
+ }
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Check action permissions not already checked in checkQuickPermissions
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkActionPermissions( $action, $user, $errors, $rigor, $short ) {
+ global $wgDeleteRevisionsLimit, $wgLang;
+
+ if ( $action == 'protect' ) {
+ if ( count( $this->getUserPermissionsErrorsInternal( 'edit', $user, $rigor, true ) ) ) {
+ // If they can't edit, they shouldn't protect.
+ $errors[] = [ 'protect-cantedit' ];
+ }
+ } elseif ( $action == 'create' ) {
+ $title_protection = $this->getTitleProtection();
+ if ( $title_protection ) {
+ if ( $title_protection['permission'] == ''
+ || !$user->isAllowed( $title_protection['permission'] )
+ ) {
+ $errors[] = [
+ 'titleprotected',
+ User::whoIs( $title_protection['user'] ),
+ $title_protection['reason']
+ ];
+ }
+ }
+ } elseif ( $action == 'move' ) {
+ // Check for immobile pages
+ if ( !MWNamespace::isMovable( $this->mNamespace ) ) {
+ // Specific message for this case
+ $errors[] = [ 'immobile-source-namespace', $this->getNsText() ];
+ } elseif ( !$this->isMovable() ) {
+ // Less specific message for rarer cases
+ $errors[] = [ 'immobile-source-page' ];
+ }
+ } elseif ( $action == 'move-target' ) {
+ if ( !MWNamespace::isMovable( $this->mNamespace ) ) {
+ $errors[] = [ 'immobile-target-namespace', $this->getNsText() ];
+ } elseif ( !$this->isMovable() ) {
+ $errors[] = [ 'immobile-target-page' ];
+ }
+ } elseif ( $action == 'delete' ) {
+ $tempErrors = $this->checkPageRestrictions( 'edit', $user, [], $rigor, true );
+ if ( !$tempErrors ) {
+ $tempErrors = $this->checkCascadingSourcesRestrictions( 'edit',
+ $user, $tempErrors, $rigor, true );
+ }
+ if ( $tempErrors ) {
+ // If protection keeps them from editing, they shouldn't be able to delete.
+ $errors[] = [ 'deleteprotected' ];
+ }
+ if ( $rigor !== 'quick' && $wgDeleteRevisionsLimit
+ && !$this->userCan( 'bigdelete', $user ) && $this->isBigDeletion()
+ ) {
+ $errors[] = [ 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ];
+ }
+ } elseif ( $action === 'undelete' ) {
+ if ( count( $this->getUserPermissionsErrorsInternal( 'edit', $user, $rigor, true ) ) ) {
+ // Undeleting implies editing
+ $errors[] = [ 'undelete-cantedit' ];
+ }
+ if ( !$this->exists()
+ && count( $this->getUserPermissionsErrorsInternal( 'create', $user, $rigor, true ) )
+ ) {
+ // Undeleting where nothing currently exists implies creating
+ $errors[] = [ 'undelete-cantcreate' ];
+ }
+ }
+ return $errors;
+ }
+
+ /**
+ * Check that the user isn't blocked from editing.
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkUserBlock( $action, $user, $errors, $rigor, $short ) {
+ global $wgEmailConfirmToEdit, $wgBlockDisablesLogin;
+ // Account creation blocks handled at userlogin.
+ // Unblocking handled in SpecialUnblock
+ if ( $rigor === 'quick' || in_array( $action, [ 'createaccount', 'unblock' ] ) ) {
+ return $errors;
+ }
+
+ // Optimize for a very common case
+ if ( $action === 'read' && !$wgBlockDisablesLogin ) {
+ return $errors;
+ }
+
+ if ( $wgEmailConfirmToEdit
+ && !$user->isEmailConfirmed()
+ && $action === 'edit'
+ ) {
+ $errors[] = [ 'confirmedittext' ];
+ }
+
+ $useSlave = ( $rigor !== 'secure' );
+ if ( ( $action == 'edit' || $action == 'create' )
+ && !$user->isBlockedFrom( $this, $useSlave )
+ ) {
+ // Don't block the user from editing their own talk page unless they've been
+ // explicitly blocked from that too.
+ } elseif ( $user->isBlocked() && $user->getBlock()->prevents( $action ) !== false ) {
+ // @todo FIXME: Pass the relevant context into this function.
+ $errors[] = $user->getBlock()->getPermissionsError( RequestContext::getMain() );
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Check that the user is allowed to read this page.
+ *
+ * @param string $action The action to check
+ * @param User $user User to check
+ * @param array $errors List of current errors
+ * @param string $rigor Same format as Title::getUserPermissionsErrors()
+ * @param bool $short Short circuit on first error
+ *
+ * @return array List of errors
+ */
+ private function checkReadPermissions( $action, $user, $errors, $rigor, $short ) {
+ global $wgWhitelistRead, $wgWhitelistReadRegexp;
+
+ $whitelisted = false;
+ if ( User::isEveryoneAllowed( 'read' ) ) {
+ # Shortcut for public wikis, allows skipping quite a bit of code
+ $whitelisted = true;
+ } elseif ( $user->isAllowed( 'read' ) ) {
+ # If the user is allowed to read pages, he is allowed to read all pages
+ $whitelisted = true;
+ } elseif ( $this->isSpecial( 'Userlogin' )
+ || $this->isSpecial( 'PasswordReset' )
+ || $this->isSpecial( 'Userlogout' )
+ ) {
+ # Always grant access to the login page.
+ # Even anons need to be able to log in.
+ $whitelisted = true;
+ } elseif ( is_array( $wgWhitelistRead ) && count( $wgWhitelistRead ) ) {
+ # Time to check the whitelist
+ # Only do these checks is there's something to check against
+ $name = $this->getPrefixedText();
+ $dbName = $this->getPrefixedDBkey();
+
+ // Check for explicit whitelisting with and without underscores
+ if ( in_array( $name, $wgWhitelistRead, true ) || in_array( $dbName, $wgWhitelistRead, true ) ) {
+ $whitelisted = true;
+ } elseif ( $this->getNamespace() == NS_MAIN ) {
+ # Old settings might have the title prefixed with
+ # a colon for main-namespace pages
+ if ( in_array( ':' . $name, $wgWhitelistRead ) ) {
+ $whitelisted = true;
+ }
+ } elseif ( $this->isSpecialPage() ) {
+ # If it's a special page, ditch the subpage bit and check again
+ $name = $this->getDBkey();
+ list( $name, /* $subpage */ ) = SpecialPageFactory::resolveAlias( $name );
+ if ( $name ) {
+ $pure = SpecialPage::getTitleFor( $name )->getPrefixedText();
+ if ( in_array( $pure, $wgWhitelistRead, true ) ) {
+ $whitelisted = true;
+ }
+ }
+ }
+ }
+
+ if ( !$whitelisted && is_array( $wgWhitelistReadRegexp ) && !empty( $wgWhitelistReadRegexp ) ) {
+ $name = $this->getPrefixedText();
+ // Check for regex whitelisting
+ foreach ( $wgWhitelistReadRegexp as $listItem ) {
+ if ( preg_match( $listItem, $name ) ) {
+ $whitelisted = true;
+ break;
+ }
+ }
+ }
+
+ if ( !$whitelisted ) {
+ # If the title is not whitelisted, give extensions a chance to do so...
+ Hooks::run( 'TitleReadWhitelist', [ $this, $user, &$whitelisted ] );
+ if ( !$whitelisted ) {
+ $errors[] = $this->missingPermissionError( $action, $short );
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Get a description array when the user doesn't have the right to perform
+ * $action (i.e. when User::isAllowed() returns false)
+ *
+ * @param string $action The action to check
+ * @param bool $short Short circuit on first error
+ * @return array Array containing an error message key and any parameters
+ */
+ private function missingPermissionError( $action, $short ) {
+ // We avoid expensive display logic for quickUserCan's and such
+ if ( $short ) {
+ return [ 'badaccess-group0' ];
+ }
+
+ return User::newFatalPermissionDeniedStatus( $action )->getErrorsArray()[0];
+ }
+
+ /**
+ * Can $user perform $action on this page? This is an internal function,
+ * with multiple levels of checks depending on performance needs; see $rigor below.
+ * It does not check wfReadOnly().
+ *
+ * @param string $action Action that permission needs to be checked for
+ * @param User $user User to check
+ * @param string $rigor One of (quick,full,secure)
+ * - quick : does cheap permission checks from replica DBs (usable for GUI creation)
+ * - full : does cheap and expensive checks possibly from a replica DB
+ * - secure : does cheap and expensive checks, using the master as needed
+ * @param bool $short Set this to true to stop after the first permission error.
+ * @return array Array of arrays of the arguments to wfMessage to explain permissions problems.
+ */
+ protected function getUserPermissionsErrorsInternal(
+ $action, $user, $rigor = 'secure', $short = false
+ ) {
+ if ( $rigor === true ) {
+ $rigor = 'secure'; // b/c
+ } elseif ( $rigor === false ) {
+ $rigor = 'quick'; // b/c
+ } elseif ( !in_array( $rigor, [ 'quick', 'full', 'secure' ] ) ) {
+ throw new Exception( "Invalid rigor parameter '$rigor'." );
+ }
+
+ # Read has special handling
+ if ( $action == 'read' ) {
+ $checks = [
+ 'checkPermissionHooks',
+ 'checkReadPermissions',
+ 'checkUserBlock', // for wgBlockDisablesLogin
+ ];
+ # Don't call checkSpecialsAndNSPermissions or checkCSSandJSPermissions
+ # here as it will lead to duplicate error messages. This is okay to do
+ # since anywhere that checks for create will also check for edit, and
+ # those checks are called for edit.
+ } elseif ( $action == 'create' ) {
+ $checks = [
+ 'checkQuickPermissions',
+ 'checkPermissionHooks',
+ 'checkPageRestrictions',
+ 'checkCascadingSourcesRestrictions',
+ 'checkActionPermissions',
+ 'checkUserBlock'
+ ];
+ } else {
+ $checks = [
+ 'checkQuickPermissions',
+ 'checkPermissionHooks',
+ 'checkSpecialsAndNSPermissions',
+ 'checkCSSandJSPermissions',
+ 'checkPageRestrictions',
+ 'checkCascadingSourcesRestrictions',
+ 'checkActionPermissions',
+ 'checkUserBlock'
+ ];
+ }
+
+ $errors = [];
+ while ( count( $checks ) > 0 &&
+ !( $short && count( $errors ) > 0 ) ) {
+ $method = array_shift( $checks );
+ $errors = $this->$method( $action, $user, $errors, $rigor, $short );
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Get a filtered list of all restriction types supported by this wiki.
+ * @param bool $exists True to get all restriction types that apply to
+ * titles that do exist, False for all restriction types that apply to
+ * titles that do not exist
+ * @return array
+ */
+ public static function getFilteredRestrictionTypes( $exists = true ) {
+ global $wgRestrictionTypes;
+ $types = $wgRestrictionTypes;
+ if ( $exists ) {
+ # Remove the create restriction for existing titles
+ $types = array_diff( $types, [ 'create' ] );
+ } else {
+ # Only the create and upload restrictions apply to non-existing titles
+ $types = array_intersect( $types, [ 'create', 'upload' ] );
+ }
+ return $types;
+ }
+
+ /**
+ * Returns restriction types for the current Title
+ *
+ * @return array Applicable restriction types
+ */
+ public function getRestrictionTypes() {
+ if ( $this->isSpecialPage() ) {
+ return [];
+ }
+
+ $types = self::getFilteredRestrictionTypes( $this->exists() );
+
+ if ( $this->getNamespace() != NS_FILE ) {
+ # Remove the upload restriction for non-file titles
+ $types = array_diff( $types, [ 'upload' ] );
+ }
+
+ Hooks::run( 'TitleGetRestrictionTypes', [ $this, &$types ] );
+
+ wfDebug( __METHOD__ . ': applicable restrictions to [[' .
+ $this->getPrefixedText() . ']] are {' . implode( ',', $types ) . "}\n" );
+
+ return $types;
+ }
+
+ /**
+ * Is this title subject to title protection?
+ * Title protection is the one applied against creation of such title.
+ *
+ * @return array|bool An associative array representing any existent title
+ * protection, or false if there's none.
+ */
+ public function getTitleProtection() {
+ $protection = $this->getTitleProtectionInternal();
+ if ( $protection ) {
+ if ( $protection['permission'] == 'sysop' ) {
+ $protection['permission'] = 'editprotected'; // B/C
+ }
+ if ( $protection['permission'] == 'autoconfirmed' ) {
+ $protection['permission'] = 'editsemiprotected'; // B/C
+ }
+ }
+ return $protection;
+ }
+
+ /**
+ * Fetch title protection settings
+ *
+ * To work correctly, $this->loadRestrictions() needs to have access to the
+ * actual protections in the database without munging 'sysop' =>
+ * 'editprotected' and 'autoconfirmed' => 'editsemiprotected'. Other
+ * callers probably want $this->getTitleProtection() instead.
+ *
+ * @return array|bool
+ */
+ protected function getTitleProtectionInternal() {
+ // Can't protect pages in special namespaces
+ if ( $this->getNamespace() < 0 ) {
+ return false;
+ }
+
+ // Can't protect pages that exist.
+ if ( $this->exists() ) {
+ return false;
+ }
+
+ if ( $this->mTitleProtection === null ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $commentStore = new CommentStore( 'pt_reason' );
+ $commentQuery = $commentStore->getJoin();
+ $res = $dbr->select(
+ [ 'protected_titles' ] + $commentQuery['tables'],
+ [
+ 'user' => 'pt_user',
+ 'expiry' => 'pt_expiry',
+ 'permission' => 'pt_create_perm'
+ ] + $commentQuery['fields'],
+ [ 'pt_namespace' => $this->getNamespace(), 'pt_title' => $this->getDBkey() ],
+ __METHOD__,
+ [],
+ $commentQuery['joins']
+ );
+
+ // fetchRow returns false if there are no rows.
+ $row = $dbr->fetchRow( $res );
+ if ( $row ) {
+ $this->mTitleProtection = [
+ 'user' => $row['user'],
+ 'expiry' => $dbr->decodeExpiry( $row['expiry'] ),
+ 'permission' => $row['permission'],
+ 'reason' => $commentStore->getComment( $row )->text,
+ ];
+ } else {
+ $this->mTitleProtection = false;
+ }
+ }
+ return $this->mTitleProtection;
+ }
+
+ /**
+ * Remove any title protection due to page existing
+ */
+ public function deleteTitleProtection() {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->delete(
+ 'protected_titles',
+ [ 'pt_namespace' => $this->getNamespace(), 'pt_title' => $this->getDBkey() ],
+ __METHOD__
+ );
+ $this->mTitleProtection = false;
+ }
+
+ /**
+ * Is this page "semi-protected" - the *only* protection levels are listed
+ * in $wgSemiprotectedRestrictionLevels?
+ *
+ * @param string $action Action to check (default: edit)
+ * @return bool
+ */
+ public function isSemiProtected( $action = 'edit' ) {
+ global $wgSemiprotectedRestrictionLevels;
+
+ $restrictions = $this->getRestrictions( $action );
+ $semi = $wgSemiprotectedRestrictionLevels;
+ if ( !$restrictions || !$semi ) {
+ // Not protected, or all protection is full protection
+ return false;
+ }
+
+ // Remap autoconfirmed to editsemiprotected for BC
+ foreach ( array_keys( $semi, 'autoconfirmed' ) as $key ) {
+ $semi[$key] = 'editsemiprotected';
+ }
+ foreach ( array_keys( $restrictions, 'autoconfirmed' ) as $key ) {
+ $restrictions[$key] = 'editsemiprotected';
+ }
+
+ return !array_diff( $restrictions, $semi );
+ }
+
+ /**
+ * Does the title correspond to a protected article?
+ *
+ * @param string $action The action the page is protected from,
+ * by default checks all actions.
+ * @return bool
+ */
+ public function isProtected( $action = '' ) {
+ global $wgRestrictionLevels;
+
+ $restrictionTypes = $this->getRestrictionTypes();
+
+ # Special pages have inherent protection
+ if ( $this->isSpecialPage() ) {
+ return true;
+ }
+
+ # Check regular protection levels
+ foreach ( $restrictionTypes as $type ) {
+ if ( $action == $type || $action == '' ) {
+ $r = $this->getRestrictions( $type );
+ foreach ( $wgRestrictionLevels as $level ) {
+ if ( in_array( $level, $r ) && $level != '' ) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines if $user is unable to edit this page because it has been protected
+ * by $wgNamespaceProtection.
+ *
+ * @param User $user User object to check permissions
+ * @return bool
+ */
+ public function isNamespaceProtected( User $user ) {
+ global $wgNamespaceProtection;
+
+ if ( isset( $wgNamespaceProtection[$this->mNamespace] ) ) {
+ foreach ( (array)$wgNamespaceProtection[$this->mNamespace] as $right ) {
+ if ( $right != '' && !$user->isAllowed( $right ) ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Cascading protection: Return true if cascading restrictions apply to this page, false if not.
+ *
+ * @return bool If the page is subject to cascading restrictions.
+ */
+ public function isCascadeProtected() {
+ list( $sources, /* $restrictions */ ) = $this->getCascadeProtectionSources( false );
+ return ( $sources > 0 );
+ }
+
+ /**
+ * Determines whether cascading protection sources have already been loaded from
+ * the database.
+ *
+ * @param bool $getPages True to check if the pages are loaded, or false to check
+ * if the status is loaded.
+ * @return bool Whether or not the specified information has been loaded
+ * @since 1.23
+ */
+ public function areCascadeProtectionSourcesLoaded( $getPages = true ) {
+ return $getPages ? $this->mCascadeSources !== null : $this->mHasCascadingRestrictions !== null;
+ }
+
+ /**
+ * Cascading protection: Get the source of any cascading restrictions on this page.
+ *
+ * @param bool $getPages Whether or not to retrieve the actual pages
+ * that the restrictions have come from and the actual restrictions
+ * themselves.
+ * @return array Two elements: First is an array of Title objects of the
+ * pages from which cascading restrictions have come, false for
+ * none, or true if such restrictions exist but $getPages was not
+ * set. Second is an array like that returned by
+ * Title::getAllRestrictions(), or an empty array if $getPages is
+ * false.
+ */
+ public function getCascadeProtectionSources( $getPages = true ) {
+ $pagerestrictions = [];
+
+ if ( $this->mCascadeSources !== null && $getPages ) {
+ return [ $this->mCascadeSources, $this->mCascadingRestrictions ];
+ } elseif ( $this->mHasCascadingRestrictions !== null && !$getPages ) {
+ return [ $this->mHasCascadingRestrictions, $pagerestrictions ];
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ if ( $this->getNamespace() == NS_FILE ) {
+ $tables = [ 'imagelinks', 'page_restrictions' ];
+ $where_clauses = [
+ 'il_to' => $this->getDBkey(),
+ 'il_from=pr_page',
+ 'pr_cascade' => 1
+ ];
+ } else {
+ $tables = [ 'templatelinks', 'page_restrictions' ];
+ $where_clauses = [
+ 'tl_namespace' => $this->getNamespace(),
+ 'tl_title' => $this->getDBkey(),
+ 'tl_from=pr_page',
+ 'pr_cascade' => 1
+ ];
+ }
+
+ if ( $getPages ) {
+ $cols = [ 'pr_page', 'page_namespace', 'page_title',
+ 'pr_expiry', 'pr_type', 'pr_level' ];
+ $where_clauses[] = 'page_id=pr_page';
+ $tables[] = 'page';
+ } else {
+ $cols = [ 'pr_expiry' ];
+ }
+
+ $res = $dbr->select( $tables, $cols, $where_clauses, __METHOD__ );
+
+ $sources = $getPages ? [] : false;
+ $now = wfTimestampNow();
+
+ foreach ( $res as $row ) {
+ $expiry = $dbr->decodeExpiry( $row->pr_expiry );
+ if ( $expiry > $now ) {
+ if ( $getPages ) {
+ $page_id = $row->pr_page;
+ $page_ns = $row->page_namespace;
+ $page_title = $row->page_title;
+ $sources[$page_id] = self::makeTitle( $page_ns, $page_title );
+ # Add groups needed for each restriction type if its not already there
+ # Make sure this restriction type still exists
+
+ if ( !isset( $pagerestrictions[$row->pr_type] ) ) {
+ $pagerestrictions[$row->pr_type] = [];
+ }
+
+ if (
+ isset( $pagerestrictions[$row->pr_type] )
+ && !in_array( $row->pr_level, $pagerestrictions[$row->pr_type] )
+ ) {
+ $pagerestrictions[$row->pr_type][] = $row->pr_level;
+ }
+ } else {
+ $sources = true;
+ }
+ }
+ }
+
+ if ( $getPages ) {
+ $this->mCascadeSources = $sources;
+ $this->mCascadingRestrictions = $pagerestrictions;
+ } else {
+ $this->mHasCascadingRestrictions = $sources;
+ }
+
+ return [ $sources, $pagerestrictions ];
+ }
+
+ /**
+ * Accessor for mRestrictionsLoaded
+ *
+ * @return bool Whether or not the page's restrictions have already been
+ * loaded from the database
+ * @since 1.23
+ */
+ public function areRestrictionsLoaded() {
+ return $this->mRestrictionsLoaded;
+ }
+
+ /**
+ * Accessor/initialisation for mRestrictions
+ *
+ * @param string $action Action that permission needs to be checked for
+ * @return array Restriction levels needed to take the action. All levels are
+ * required. Note that restriction levels are normally user rights, but 'sysop'
+ * and 'autoconfirmed' are also allowed for backwards compatibility. These should
+ * be mapped to 'editprotected' and 'editsemiprotected' respectively.
+ */
+ public function getRestrictions( $action ) {
+ if ( !$this->mRestrictionsLoaded ) {
+ $this->loadRestrictions();
+ }
+ return isset( $this->mRestrictions[$action] )
+ ? $this->mRestrictions[$action]
+ : [];
+ }
+
+ /**
+ * Accessor/initialisation for mRestrictions
+ *
+ * @return array Keys are actions, values are arrays as returned by
+ * Title::getRestrictions()
+ * @since 1.23
+ */
+ public function getAllRestrictions() {
+ if ( !$this->mRestrictionsLoaded ) {
+ $this->loadRestrictions();
+ }
+ return $this->mRestrictions;
+ }
+
+ /**
+ * Get the expiry time for the restriction against a given action
+ *
+ * @param string $action
+ * @return string|bool 14-char timestamp, or 'infinity' if the page is protected forever
+ * or not protected at all, or false if the action is not recognised.
+ */
+ public function getRestrictionExpiry( $action ) {
+ if ( !$this->mRestrictionsLoaded ) {
+ $this->loadRestrictions();
+ }
+ return isset( $this->mRestrictionsExpiry[$action] ) ? $this->mRestrictionsExpiry[$action] : false;
+ }
+
+ /**
+ * Returns cascading restrictions for the current article
+ *
+ * @return bool
+ */
+ function areRestrictionsCascading() {
+ if ( !$this->mRestrictionsLoaded ) {
+ $this->loadRestrictions();
+ }
+
+ return $this->mCascadeRestriction;
+ }
+
+ /**
+ * Compiles list of active page restrictions from both page table (pre 1.10)
+ * and page_restrictions table for this existing page.
+ * Public for usage by LiquidThreads.
+ *
+ * @param array $rows Array of db result objects
+ * @param string $oldFashionedRestrictions Comma-separated list of page
+ * restrictions from page table (pre 1.10)
+ */
+ public function loadRestrictionsFromRows( $rows, $oldFashionedRestrictions = null ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $restrictionTypes = $this->getRestrictionTypes();
+
+ foreach ( $restrictionTypes as $type ) {
+ $this->mRestrictions[$type] = [];
+ $this->mRestrictionsExpiry[$type] = 'infinity';
+ }
+
+ $this->mCascadeRestriction = false;
+
+ # Backwards-compatibility: also load the restrictions from the page record (old format).
+ if ( $oldFashionedRestrictions !== null ) {
+ $this->mOldRestrictions = $oldFashionedRestrictions;
+ }
+
+ if ( $this->mOldRestrictions === false ) {
+ $this->mOldRestrictions = $dbr->selectField( 'page', 'page_restrictions',
+ [ 'page_id' => $this->getArticleID() ], __METHOD__ );
+ }
+
+ if ( $this->mOldRestrictions != '' ) {
+ foreach ( explode( ':', trim( $this->mOldRestrictions ) ) as $restrict ) {
+ $temp = explode( '=', trim( $restrict ) );
+ if ( count( $temp ) == 1 ) {
+ // old old format should be treated as edit/move restriction
+ $this->mRestrictions['edit'] = explode( ',', trim( $temp[0] ) );
+ $this->mRestrictions['move'] = explode( ',', trim( $temp[0] ) );
+ } else {
+ $restriction = trim( $temp[1] );
+ if ( $restriction != '' ) { // some old entries are empty
+ $this->mRestrictions[$temp[0]] = explode( ',', $restriction );
+ }
+ }
+ }
+ }
+
+ if ( count( $rows ) ) {
+ # Current system - load second to make them override.
+ $now = wfTimestampNow();
+
+ # Cycle through all the restrictions.
+ foreach ( $rows as $row ) {
+ // Don't take care of restrictions types that aren't allowed
+ if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
+ continue;
+ }
+
+ $expiry = $dbr->decodeExpiry( $row->pr_expiry );
+
+ // Only apply the restrictions if they haven't expired!
+ if ( !$expiry || $expiry > $now ) {
+ $this->mRestrictionsExpiry[$row->pr_type] = $expiry;
+ $this->mRestrictions[$row->pr_type] = explode( ',', trim( $row->pr_level ) );
+
+ $this->mCascadeRestriction |= $row->pr_cascade;
+ }
+ }
+ }
+
+ $this->mRestrictionsLoaded = true;
+ }
+
+ /**
+ * Load restrictions from the page_restrictions table
+ *
+ * @param string $oldFashionedRestrictions Comma-separated list of page
+ * restrictions from page table (pre 1.10)
+ */
+ public function loadRestrictions( $oldFashionedRestrictions = null ) {
+ if ( $this->mRestrictionsLoaded ) {
+ return;
+ }
+
+ $id = $this->getArticleID();
+ if ( $id ) {
+ $cache = ObjectCache::getMainWANInstance();
+ $rows = $cache->getWithSetCallback(
+ // Page protections always leave a new null revision
+ $cache->makeKey( 'page-restrictions', $id, $this->getLatestRevID() ),
+ $cache::TTL_DAY,
+ function ( $curValue, &$ttl, array &$setOpts ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $setOpts += Database::getCacheSetOptions( $dbr );
+
+ return iterator_to_array(
+ $dbr->select(
+ 'page_restrictions',
+ [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ],
+ [ 'pr_page' => $this->getArticleID() ],
+ __METHOD__
+ )
+ );
+ }
+ );
+
+ $this->loadRestrictionsFromRows( $rows, $oldFashionedRestrictions );
+ } else {
+ $title_protection = $this->getTitleProtectionInternal();
+
+ if ( $title_protection ) {
+ $now = wfTimestampNow();
+ $expiry = wfGetDB( DB_REPLICA )->decodeExpiry( $title_protection['expiry'] );
+
+ if ( !$expiry || $expiry > $now ) {
+ // Apply the restrictions
+ $this->mRestrictionsExpiry['create'] = $expiry;
+ $this->mRestrictions['create'] =
+ explode( ',', trim( $title_protection['permission'] ) );
+ } else { // Get rid of the old restrictions
+ $this->mTitleProtection = false;
+ }
+ } else {
+ $this->mRestrictionsExpiry['create'] = 'infinity';
+ }
+ $this->mRestrictionsLoaded = true;
+ }
+ }
+
+ /**
+ * Flush the protection cache in this object and force reload from the database.
+ * This is used when updating protection from WikiPage::doUpdateRestrictions().
+ */
+ public function flushRestrictions() {
+ $this->mRestrictionsLoaded = false;
+ $this->mTitleProtection = null;
+ }
+
+ /**
+ * Purge expired restrictions from the page_restrictions table
+ *
+ * This will purge no more than $wgUpdateRowsPerQuery page_restrictions rows
+ */
+ static function purgeExpiredRestrictions() {
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ DeferredUpdates::addUpdate( new AtomicSectionUpdate(
+ wfGetDB( DB_MASTER ),
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) {
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ $ids = $dbw->selectFieldValues(
+ 'page_restrictions',
+ 'pr_id',
+ [ 'pr_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
+ $fname,
+ [ 'LIMIT' => $config->get( 'UpdateRowsPerQuery' ) ] // T135470
+ );
+ if ( $ids ) {
+ $dbw->delete( 'page_restrictions', [ 'pr_id' => $ids ], $fname );
+ }
+ }
+ ) );
+
+ DeferredUpdates::addUpdate( new AtomicSectionUpdate(
+ wfGetDB( DB_MASTER ),
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) {
+ $dbw->delete(
+ 'protected_titles',
+ [ 'pt_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
+ $fname
+ );
+ }
+ ) );
+ }
+
+ /**
+ * Does this have subpages? (Warning, usually requires an extra DB query.)
+ *
+ * @return bool
+ */
+ public function hasSubpages() {
+ if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) {
+ # Duh
+ return false;
+ }
+
+ # We dynamically add a member variable for the purpose of this method
+ # alone to cache the result. There's no point in having it hanging
+ # around uninitialized in every Title object; therefore we only add it
+ # if needed and don't declare it statically.
+ if ( $this->mHasSubpages === null ) {
+ $this->mHasSubpages = false;
+ $subpages = $this->getSubpages( 1 );
+ if ( $subpages instanceof TitleArray ) {
+ $this->mHasSubpages = (bool)$subpages->count();
+ }
+ }
+
+ return $this->mHasSubpages;
+ }
+
+ /**
+ * Get all subpages of this page.
+ *
+ * @param int $limit Maximum number of subpages to fetch; -1 for no limit
+ * @return TitleArray|array TitleArray, or empty array if this page's namespace
+ * doesn't allow subpages
+ */
+ public function getSubpages( $limit = -1 ) {
+ if ( !MWNamespace::hasSubpages( $this->getNamespace() ) ) {
+ return [];
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $conds['page_namespace'] = $this->getNamespace();
+ $conds[] = 'page_title ' . $dbr->buildLike( $this->getDBkey() . '/', $dbr->anyString() );
+ $options = [];
+ if ( $limit > -1 ) {
+ $options['LIMIT'] = $limit;
+ }
+ return TitleArray::newFromResult(
+ $dbr->select( 'page',
+ [ 'page_id', 'page_namespace', 'page_title', 'page_is_redirect' ],
+ $conds,
+ __METHOD__,
+ $options
+ )
+ );
+ }
+
+ /**
+ * Is there a version of this page in the deletion archive?
+ *
+ * @return int The number of archived revisions
+ */
+ public function isDeleted() {
+ if ( $this->getNamespace() < 0 ) {
+ $n = 0;
+ } else {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $n = $dbr->selectField( 'archive', 'COUNT(*)',
+ [ 'ar_namespace' => $this->getNamespace(), 'ar_title' => $this->getDBkey() ],
+ __METHOD__
+ );
+ if ( $this->getNamespace() == NS_FILE ) {
+ $n += $dbr->selectField( 'filearchive', 'COUNT(*)',
+ [ 'fa_name' => $this->getDBkey() ],
+ __METHOD__
+ );
+ }
+ }
+ return (int)$n;
+ }
+
+ /**
+ * Is there a version of this page in the deletion archive?
+ *
+ * @return bool
+ */
+ public function isDeletedQuick() {
+ if ( $this->getNamespace() < 0 ) {
+ return false;
+ }
+ $dbr = wfGetDB( DB_REPLICA );
+ $deleted = (bool)$dbr->selectField( 'archive', '1',
+ [ 'ar_namespace' => $this->getNamespace(), 'ar_title' => $this->getDBkey() ],
+ __METHOD__
+ );
+ if ( !$deleted && $this->getNamespace() == NS_FILE ) {
+ $deleted = (bool)$dbr->selectField( 'filearchive', '1',
+ [ 'fa_name' => $this->getDBkey() ],
+ __METHOD__
+ );
+ }
+ return $deleted;
+ }
+
+ /**
+ * Get the article ID for this Title from the link cache,
+ * adding it if necessary
+ *
+ * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select
+ * for update
+ * @return int The ID
+ */
+ public function getArticleID( $flags = 0 ) {
+ if ( $this->getNamespace() < 0 ) {
+ $this->mArticleID = 0;
+ return $this->mArticleID;
+ }
+ $linkCache = LinkCache::singleton();
+ if ( $flags & self::GAID_FOR_UPDATE ) {
+ $oldUpdate = $linkCache->forUpdate( true );
+ $linkCache->clearLink( $this );
+ $this->mArticleID = $linkCache->addLinkObj( $this );
+ $linkCache->forUpdate( $oldUpdate );
+ } else {
+ if ( -1 == $this->mArticleID ) {
+ $this->mArticleID = $linkCache->addLinkObj( $this );
+ }
+ }
+ return $this->mArticleID;
+ }
+
+ /**
+ * Is this an article that is a redirect page?
+ * Uses link cache, adding it if necessary
+ *
+ * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
+ * @return bool
+ */
+ public function isRedirect( $flags = 0 ) {
+ if ( !is_null( $this->mRedirect ) ) {
+ return $this->mRedirect;
+ }
+ if ( !$this->getArticleID( $flags ) ) {
+ $this->mRedirect = false;
+ return $this->mRedirect;
+ }
+
+ $linkCache = LinkCache::singleton();
+ $linkCache->addLinkObj( $this ); # in case we already had an article ID
+ $cached = $linkCache->getGoodLinkFieldObj( $this, 'redirect' );
+ if ( $cached === null ) {
+ # Trust LinkCache's state over our own
+ # LinkCache is telling us that the page doesn't exist, despite there being cached
+ # data relating to an existing page in $this->mArticleID. Updaters should clear
+ # LinkCache as appropriate, or use $flags = Title::GAID_FOR_UPDATE. If that flag is
+ # set, then LinkCache will definitely be up to date here, since getArticleID() forces
+ # LinkCache to refresh its data from the master.
+ $this->mRedirect = false;
+ return $this->mRedirect;
+ }
+
+ $this->mRedirect = (bool)$cached;
+
+ return $this->mRedirect;
+ }
+
+ /**
+ * What is the length of this page?
+ * Uses link cache, adding it if necessary
+ *
+ * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
+ * @return int
+ */
+ public function getLength( $flags = 0 ) {
+ if ( $this->mLength != -1 ) {
+ return $this->mLength;
+ }
+ if ( !$this->getArticleID( $flags ) ) {
+ $this->mLength = 0;
+ return $this->mLength;
+ }
+ $linkCache = LinkCache::singleton();
+ $linkCache->addLinkObj( $this ); # in case we already had an article ID
+ $cached = $linkCache->getGoodLinkFieldObj( $this, 'length' );
+ if ( $cached === null ) {
+ # Trust LinkCache's state over our own, as for isRedirect()
+ $this->mLength = 0;
+ return $this->mLength;
+ }
+
+ $this->mLength = intval( $cached );
+
+ return $this->mLength;
+ }
+
+ /**
+ * What is the page_latest field for this page?
+ *
+ * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
+ * @return int Int or 0 if the page doesn't exist
+ */
+ public function getLatestRevID( $flags = 0 ) {
+ if ( !( $flags & self::GAID_FOR_UPDATE ) && $this->mLatestID !== false ) {
+ return intval( $this->mLatestID );
+ }
+ if ( !$this->getArticleID( $flags ) ) {
+ $this->mLatestID = 0;
+ return $this->mLatestID;
+ }
+ $linkCache = LinkCache::singleton();
+ $linkCache->addLinkObj( $this ); # in case we already had an article ID
+ $cached = $linkCache->getGoodLinkFieldObj( $this, 'revision' );
+ if ( $cached === null ) {
+ # Trust LinkCache's state over our own, as for isRedirect()
+ $this->mLatestID = 0;
+ return $this->mLatestID;
+ }
+
+ $this->mLatestID = intval( $cached );
+
+ return $this->mLatestID;
+ }
+
+ /**
+ * This clears some fields in this object, and clears any associated
+ * keys in the "bad links" section of the link cache.
+ *
+ * - This is called from WikiPage::doEditContent() and WikiPage::insertOn() to allow
+ * loading of the new page_id. It's also called from
+ * WikiPage::doDeleteArticleReal()
+ *
+ * @param int $newid The new Article ID
+ */
+ public function resetArticleID( $newid ) {
+ $linkCache = LinkCache::singleton();
+ $linkCache->clearLink( $this );
+
+ if ( $newid === false ) {
+ $this->mArticleID = -1;
+ } else {
+ $this->mArticleID = intval( $newid );
+ }
+ $this->mRestrictionsLoaded = false;
+ $this->mRestrictions = [];
+ $this->mOldRestrictions = false;
+ $this->mRedirect = null;
+ $this->mLength = -1;
+ $this->mLatestID = false;
+ $this->mContentModel = false;
+ $this->mEstimateRevisions = null;
+ $this->mPageLanguage = false;
+ $this->mDbPageLanguage = false;
+ $this->mIsBigDeletion = null;
+ }
+
+ public static function clearCaches() {
+ $linkCache = LinkCache::singleton();
+ $linkCache->clear();
+
+ $titleCache = self::getTitleCache();
+ $titleCache->clear();
+ }
+
+ /**
+ * Capitalize a text string for a title if it belongs to a namespace that capitalizes
+ *
+ * @param string $text Containing title to capitalize
+ * @param int $ns Namespace index, defaults to NS_MAIN
+ * @return string Containing capitalized title
+ */
+ public static function capitalize( $text, $ns = NS_MAIN ) {
+ global $wgContLang;
+
+ if ( MWNamespace::isCapitalized( $ns ) ) {
+ return $wgContLang->ucfirst( $text );
+ } else {
+ return $text;
+ }
+ }
+
+ /**
+ * Secure and split - main initialisation function for this object
+ *
+ * Assumes that mDbkeyform has been set, and is urldecoded
+ * and uses underscores, but not otherwise munged. This function
+ * removes illegal characters, splits off the interwiki and
+ * namespace prefixes, sets the other forms, and canonicalizes
+ * everything.
+ *
+ * @throws MalformedTitleException On invalid titles
+ * @return bool True on success
+ */
+ private function secureAndSplit() {
+ # Initialisation
+ $this->mInterwiki = '';
+ $this->mFragment = '';
+ $this->mNamespace = $this->mDefaultNamespace; # Usually NS_MAIN
+
+ $dbkey = $this->mDbkeyform;
+
+ // @note: splitTitleString() is a temporary hack to allow MediaWikiTitleCodec to share
+ // the parsing code with Title, while avoiding massive refactoring.
+ // @todo: get rid of secureAndSplit, refactor parsing code.
+ // @note: getTitleParser() returns a TitleParser implementation which does not have a
+ // splitTitleString method, but the only implementation (MediaWikiTitleCodec) does
+ $titleCodec = MediaWikiServices::getInstance()->getTitleParser();
+ // MalformedTitleException can be thrown here
+ $parts = $titleCodec->splitTitleString( $dbkey, $this->getDefaultNamespace() );
+
+ # Fill fields
+ $this->setFragment( '#' . $parts['fragment'] );
+ $this->mInterwiki = $parts['interwiki'];
+ $this->mLocalInterwiki = $parts['local_interwiki'];
+ $this->mNamespace = $parts['namespace'];
+ $this->mUserCaseDBKey = $parts['user_case_dbkey'];
+
+ $this->mDbkeyform = $parts['dbkey'];
+ $this->mUrlform = wfUrlencode( $this->mDbkeyform );
+ $this->mTextform = strtr( $this->mDbkeyform, '_', ' ' );
+
+ # We already know that some pages won't be in the database!
+ if ( $this->isExternal() || $this->isSpecialPage() ) {
+ $this->mArticleID = 0;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get an array of Title objects linking to this Title
+ * Also stores the IDs in the link cache.
+ *
+ * WARNING: do not use this function on arbitrary user-supplied titles!
+ * On heavily-used templates it will max out the memory.
+ *
+ * @param array $options May be FOR UPDATE
+ * @param string $table Table name
+ * @param string $prefix Fields prefix
+ * @return Title[] Array of Title objects linking here
+ */
+ public function getLinksTo( $options = [], $table = 'pagelinks', $prefix = 'pl' ) {
+ if ( count( $options ) > 0 ) {
+ $db = wfGetDB( DB_MASTER );
+ } else {
+ $db = wfGetDB( DB_REPLICA );
+ }
+
+ $res = $db->select(
+ [ 'page', $table ],
+ self::getSelectFields(),
+ [
+ "{$prefix}_from=page_id",
+ "{$prefix}_namespace" => $this->getNamespace(),
+ "{$prefix}_title" => $this->getDBkey() ],
+ __METHOD__,
+ $options
+ );
+
+ $retVal = [];
+ if ( $res->numRows() ) {
+ $linkCache = LinkCache::singleton();
+ foreach ( $res as $row ) {
+ $titleObj = self::makeTitle( $row->page_namespace, $row->page_title );
+ if ( $titleObj ) {
+ $linkCache->addGoodLinkObjFromRow( $titleObj, $row );
+ $retVal[] = $titleObj;
+ }
+ }
+ }
+ return $retVal;
+ }
+
+ /**
+ * Get an array of Title objects using this Title as a template
+ * Also stores the IDs in the link cache.
+ *
+ * WARNING: do not use this function on arbitrary user-supplied titles!
+ * On heavily-used templates it will max out the memory.
+ *
+ * @param array $options Query option to Database::select()
+ * @return Title[] Array of Title the Title objects linking here
+ */
+ public function getTemplateLinksTo( $options = [] ) {
+ return $this->getLinksTo( $options, 'templatelinks', 'tl' );
+ }
+
+ /**
+ * Get an array of Title objects linked from this Title
+ * Also stores the IDs in the link cache.
+ *
+ * WARNING: do not use this function on arbitrary user-supplied titles!
+ * On heavily-used templates it will max out the memory.
+ *
+ * @param array $options Query option to Database::select()
+ * @param string $table Table name
+ * @param string $prefix Fields prefix
+ * @return array Array of Title objects linking here
+ */
+ public function getLinksFrom( $options = [], $table = 'pagelinks', $prefix = 'pl' ) {
+ $id = $this->getArticleID();
+
+ # If the page doesn't exist; there can't be any link from this page
+ if ( !$id ) {
+ return [];
+ }
+
+ $db = wfGetDB( DB_REPLICA );
+
+ $blNamespace = "{$prefix}_namespace";
+ $blTitle = "{$prefix}_title";
+
+ $res = $db->select(
+ [ $table, 'page' ],
+ array_merge(
+ [ $blNamespace, $blTitle ],
+ WikiPage::selectFields()
+ ),
+ [ "{$prefix}_from" => $id ],
+ __METHOD__,
+ $options,
+ [ 'page' => [
+ 'LEFT JOIN',
+ [ "page_namespace=$blNamespace", "page_title=$blTitle" ]
+ ] ]
+ );
+
+ $retVal = [];
+ $linkCache = LinkCache::singleton();
+ foreach ( $res as $row ) {
+ if ( $row->page_id ) {
+ $titleObj = self::newFromRow( $row );
+ } else {
+ $titleObj = self::makeTitle( $row->$blNamespace, $row->$blTitle );
+ $linkCache->addBadLinkObj( $titleObj );
+ }
+ $retVal[] = $titleObj;
+ }
+
+ return $retVal;
+ }
+
+ /**
+ * Get an array of Title objects used on this Title as a template
+ * Also stores the IDs in the link cache.
+ *
+ * WARNING: do not use this function on arbitrary user-supplied titles!
+ * On heavily-used templates it will max out the memory.
+ *
+ * @param array $options May be FOR UPDATE
+ * @return Title[] Array of Title the Title objects used here
+ */
+ public function getTemplateLinksFrom( $options = [] ) {
+ return $this->getLinksFrom( $options, 'templatelinks', 'tl' );
+ }
+
+ /**
+ * Get an array of Title objects referring to non-existent articles linked
+ * from this page.
+ *
+ * @todo check if needed (used only in SpecialBrokenRedirects.php, and
+ * should use redirect table in this case).
+ * @return Title[] Array of Title the Title objects
+ */
+ public function getBrokenLinksFrom() {
+ if ( $this->getArticleID() == 0 ) {
+ # All links from article ID 0 are false positives
+ return [];
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select(
+ [ 'page', 'pagelinks' ],
+ [ 'pl_namespace', 'pl_title' ],
+ [
+ 'pl_from' => $this->getArticleID(),
+ 'page_namespace IS NULL'
+ ],
+ __METHOD__, [],
+ [
+ 'page' => [
+ 'LEFT JOIN',
+ [ 'pl_namespace=page_namespace', 'pl_title=page_title' ]
+ ]
+ ]
+ );
+
+ $retVal = [];
+ foreach ( $res as $row ) {
+ $retVal[] = self::makeTitle( $row->pl_namespace, $row->pl_title );
+ }
+ return $retVal;
+ }
+
+ /**
+ * Get a list of URLs to purge from the CDN cache when this
+ * page changes
+ *
+ * @return string[] Array of String the URLs
+ */
+ public function getCdnUrls() {
+ $urls = [
+ $this->getInternalURL(),
+ $this->getInternalURL( 'action=history' )
+ ];
+
+ $pageLang = $this->getPageLanguage();
+ if ( $pageLang->hasVariants() ) {
+ $variants = $pageLang->getVariants();
+ foreach ( $variants as $vCode ) {
+ $urls[] = $this->getInternalURL( $vCode );
+ }
+ }
+
+ // If we are looking at a css/js user subpage, purge the action=raw.
+ if ( $this->isJsSubpage() ) {
+ $urls[] = $this->getInternalURL( 'action=raw&ctype=text/javascript' );
+ } elseif ( $this->isCssSubpage() ) {
+ $urls[] = $this->getInternalURL( 'action=raw&ctype=text/css' );
+ }
+
+ Hooks::run( 'TitleSquidURLs', [ $this, &$urls ] );
+ return $urls;
+ }
+
+ /**
+ * @deprecated since 1.27 use getCdnUrls()
+ */
+ public function getSquidURLs() {
+ return $this->getCdnUrls();
+ }
+
+ /**
+ * Purge all applicable CDN URLs
+ */
+ public function purgeSquid() {
+ DeferredUpdates::addUpdate(
+ new CdnCacheUpdate( $this->getCdnUrls() ),
+ DeferredUpdates::PRESEND
+ );
+ }
+
+ /**
+ * Check whether a given move operation would be valid.
+ * Returns true if ok, or a getUserPermissionsErrors()-like array otherwise
+ *
+ * @deprecated since 1.25, use MovePage's methods instead
+ * @param Title &$nt The new title
+ * @param bool $auth Whether to check user permissions (uses $wgUser)
+ * @param string $reason Is the log summary of the move, used for spam checking
+ * @return array|bool True on success, getUserPermissionsErrors()-like array on failure
+ */
+ public function isValidMoveOperation( &$nt, $auth = true, $reason = '' ) {
+ global $wgUser;
+
+ if ( !( $nt instanceof Title ) ) {
+ // Normally we'd add this to $errors, but we'll get
+ // lots of syntax errors if $nt is not an object
+ return [ [ 'badtitletext' ] ];
+ }
+
+ $mp = new MovePage( $this, $nt );
+ $errors = $mp->isValidMove()->getErrorsArray();
+ if ( $auth ) {
+ $errors = wfMergeErrorArrays(
+ $errors,
+ $mp->checkPermissions( $wgUser, $reason )->getErrorsArray()
+ );
+ }
+
+ return $errors ?: true;
+ }
+
+ /**
+ * Check if the requested move target is a valid file move target
+ * @todo move this to MovePage
+ * @param Title $nt Target title
+ * @return array List of errors
+ */
+ protected function validateFileMoveOperation( $nt ) {
+ global $wgUser;
+
+ $errors = [];
+
+ $destFile = wfLocalFile( $nt );
+ $destFile->load( File::READ_LATEST );
+ if ( !$wgUser->isAllowed( 'reupload-shared' )
+ && !$destFile->exists() && wfFindFile( $nt )
+ ) {
+ $errors[] = [ 'file-exists-sharedrepo' ];
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Move a title to a new location
+ *
+ * @deprecated since 1.25, use the MovePage class instead
+ * @param Title &$nt The new title
+ * @param bool $auth Indicates whether $wgUser's permissions
+ * should be checked
+ * @param string $reason The reason for the move
+ * @param bool $createRedirect Whether to create a redirect from the old title to the new title.
+ * Ignored if the user doesn't have the suppressredirect right.
+ * @param array $changeTags Applied to the entry in the move log and redirect page revision
+ * @return array|bool True on success, getUserPermissionsErrors()-like array on failure
+ */
+ public function moveTo( &$nt, $auth = true, $reason = '', $createRedirect = true,
+ array $changeTags = []
+ ) {
+ global $wgUser;
+ $err = $this->isValidMoveOperation( $nt, $auth, $reason );
+ if ( is_array( $err ) ) {
+ // Auto-block user's IP if the account was "hard" blocked
+ $wgUser->spreadAnyEditBlock();
+ return $err;
+ }
+ // Check suppressredirect permission
+ if ( $auth && !$wgUser->isAllowed( 'suppressredirect' ) ) {
+ $createRedirect = true;
+ }
+
+ $mp = new MovePage( $this, $nt );
+ $status = $mp->move( $wgUser, $reason, $createRedirect, $changeTags );
+ if ( $status->isOK() ) {
+ return true;
+ } else {
+ return $status->getErrorsArray();
+ }
+ }
+
+ /**
+ * Move this page's subpages to be subpages of $nt
+ *
+ * @param Title $nt Move target
+ * @param bool $auth Whether $wgUser's permissions should be checked
+ * @param string $reason The reason for the move
+ * @param bool $createRedirect Whether to create redirects from the old subpages to
+ * the new ones Ignored if the user doesn't have the 'suppressredirect' right
+ * @param array $changeTags Applied to the entry in the move log and redirect page revision
+ * @return array Array with old page titles as keys, and strings (new page titles) or
+ * getUserPermissionsErrors()-like arrays (errors) as values, or a
+ * getUserPermissionsErrors()-like error array with numeric indices if
+ * no pages were moved
+ */
+ public function moveSubpages( $nt, $auth = true, $reason = '', $createRedirect = true,
+ array $changeTags = []
+ ) {
+ global $wgMaximumMovedPages;
+ // Check permissions
+ if ( !$this->userCan( 'move-subpages' ) ) {
+ return [
+ [ 'cant-move-subpages' ],
+ ];
+ }
+ // Do the source and target namespaces support subpages?
+ if ( !MWNamespace::hasSubpages( $this->getNamespace() ) ) {
+ return [
+ [ 'namespace-nosubpages', MWNamespace::getCanonicalName( $this->getNamespace() ) ],
+ ];
+ }
+ if ( !MWNamespace::hasSubpages( $nt->getNamespace() ) ) {
+ return [
+ [ 'namespace-nosubpages', MWNamespace::getCanonicalName( $nt->getNamespace() ) ],
+ ];
+ }
+
+ $subpages = $this->getSubpages( $wgMaximumMovedPages + 1 );
+ $retval = [];
+ $count = 0;
+ foreach ( $subpages as $oldSubpage ) {
+ $count++;
+ if ( $count > $wgMaximumMovedPages ) {
+ $retval[$oldSubpage->getPrefixedText()] = [
+ [ 'movepage-max-pages', $wgMaximumMovedPages ],
+ ];
+ break;
+ }
+
+ // We don't know whether this function was called before
+ // or after moving the root page, so check both
+ // $this and $nt
+ if ( $oldSubpage->getArticleID() == $this->getArticleID()
+ || $oldSubpage->getArticleID() == $nt->getArticleID()
+ ) {
+ // When moving a page to a subpage of itself,
+ // don't move it twice
+ continue;
+ }
+ $newPageName = preg_replace(
+ '#^' . preg_quote( $this->getDBkey(), '#' ) . '#',
+ StringUtils::escapeRegexReplacement( $nt->getDBkey() ), # T23234
+ $oldSubpage->getDBkey() );
+ if ( $oldSubpage->isTalkPage() ) {
+ $newNs = $nt->getTalkPage()->getNamespace();
+ } else {
+ $newNs = $nt->getSubjectPage()->getNamespace();
+ }
+ # T16385: we need makeTitleSafe because the new page names may
+ # be longer than 255 characters.
+ $newSubpage = self::makeTitleSafe( $newNs, $newPageName );
+
+ $success = $oldSubpage->moveTo( $newSubpage, $auth, $reason, $createRedirect, $changeTags );
+ if ( $success === true ) {
+ $retval[$oldSubpage->getPrefixedText()] = $newSubpage->getPrefixedText();
+ } else {
+ $retval[$oldSubpage->getPrefixedText()] = $success;
+ }
+ }
+ return $retval;
+ }
+
+ /**
+ * Checks if this page is just a one-rev redirect.
+ * Adds lock, so don't use just for light purposes.
+ *
+ * @return bool
+ */
+ public function isSingleRevRedirect() {
+ global $wgContentHandlerUseDB;
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ # Is it a redirect?
+ $fields = [ 'page_is_redirect', 'page_latest', 'page_id' ];
+ if ( $wgContentHandlerUseDB ) {
+ $fields[] = 'page_content_model';
+ }
+
+ $row = $dbw->selectRow( 'page',
+ $fields,
+ $this->pageCond(),
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ # Cache some fields we may want
+ $this->mArticleID = $row ? intval( $row->page_id ) : 0;
+ $this->mRedirect = $row ? (bool)$row->page_is_redirect : false;
+ $this->mLatestID = $row ? intval( $row->page_latest ) : false;
+ $this->mContentModel = $row && isset( $row->page_content_model )
+ ? strval( $row->page_content_model )
+ : false;
+
+ if ( !$this->mRedirect ) {
+ return false;
+ }
+ # Does the article have a history?
+ $row = $dbw->selectField( [ 'page', 'revision' ],
+ 'rev_id',
+ [ 'page_namespace' => $this->getNamespace(),
+ 'page_title' => $this->getDBkey(),
+ 'page_id=rev_page',
+ 'page_latest != rev_id'
+ ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ # Return true if there was no history
+ return ( $row === false );
+ }
+
+ /**
+ * Checks if $this can be moved to a given Title
+ * - Selects for update, so don't call it unless you mean business
+ *
+ * @deprecated since 1.25, use MovePage's methods instead
+ * @param Title $nt The new title to check
+ * @return bool
+ */
+ public function isValidMoveTarget( $nt ) {
+ # Is it an existing file?
+ if ( $nt->getNamespace() == NS_FILE ) {
+ $file = wfLocalFile( $nt );
+ $file->load( File::READ_LATEST );
+ if ( $file->exists() ) {
+ wfDebug( __METHOD__ . ": file exists\n" );
+ return false;
+ }
+ }
+ # Is it a redirect with no history?
+ if ( !$nt->isSingleRevRedirect() ) {
+ wfDebug( __METHOD__ . ": not a one-rev redirect\n" );
+ return false;
+ }
+ # Get the article text
+ $rev = Revision::newFromTitle( $nt, false, Revision::READ_LATEST );
+ if ( !is_object( $rev ) ) {
+ return false;
+ }
+ $content = $rev->getContent();
+ # Does the redirect point to the source?
+ # Or is it a broken self-redirect, usually caused by namespace collisions?
+ $redirTitle = $content ? $content->getRedirectTarget() : null;
+
+ if ( $redirTitle ) {
+ if ( $redirTitle->getPrefixedDBkey() != $this->getPrefixedDBkey() &&
+ $redirTitle->getPrefixedDBkey() != $nt->getPrefixedDBkey() ) {
+ wfDebug( __METHOD__ . ": redirect points to other page\n" );
+ return false;
+ } else {
+ return true;
+ }
+ } else {
+ # Fail safe (not a redirect after all. strange.)
+ wfDebug( __METHOD__ . ": failsafe: database sais " . $nt->getPrefixedDBkey() .
+ " is a redirect, but it doesn't contain a valid redirect.\n" );
+ return false;
+ }
+ }
+
+ /**
+ * Get categories to which this Title belongs and return an array of
+ * categories' names.
+ *
+ * @return array Array of parents in the form:
+ * $parent => $currentarticle
+ */
+ public function getParentCategories() {
+ global $wgContLang;
+
+ $data = [];
+
+ $titleKey = $this->getArticleID();
+
+ if ( $titleKey === 0 ) {
+ return $data;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $res = $dbr->select(
+ 'categorylinks',
+ 'cl_to',
+ [ 'cl_from' => $titleKey ],
+ __METHOD__
+ );
+
+ if ( $res->numRows() > 0 ) {
+ foreach ( $res as $row ) {
+ // $data[] = Title::newFromText($wgContLang->getNsText ( NS_CATEGORY ).':'.$row->cl_to);
+ $data[$wgContLang->getNsText( NS_CATEGORY ) . ':' . $row->cl_to] = $this->getFullText();
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Get a tree of parent categories
+ *
+ * @param array $children Array with the children in the keys, to check for circular refs
+ * @return array Tree of parent categories
+ */
+ public function getParentCategoryTree( $children = [] ) {
+ $stack = [];
+ $parents = $this->getParentCategories();
+
+ if ( $parents ) {
+ foreach ( $parents as $parent => $current ) {
+ if ( array_key_exists( $parent, $children ) ) {
+ # Circular reference
+ $stack[$parent] = [];
+ } else {
+ $nt = self::newFromText( $parent );
+ if ( $nt ) {
+ $stack[$parent] = $nt->getParentCategoryTree( $children + [ $parent => 1 ] );
+ }
+ }
+ }
+ }
+
+ return $stack;
+ }
+
+ /**
+ * Get an associative array for selecting this title from
+ * the "page" table
+ *
+ * @return array Array suitable for the $where parameter of DB::select()
+ */
+ public function pageCond() {
+ if ( $this->mArticleID > 0 ) {
+ // PK avoids secondary lookups in InnoDB, shouldn't hurt other DBs
+ return [ 'page_id' => $this->mArticleID ];
+ } else {
+ return [ 'page_namespace' => $this->mNamespace, 'page_title' => $this->mDbkeyform ];
+ }
+ }
+
+ /**
+ * Get next/previous revision ID relative to another revision ID
+ * @param int $revId Revision ID. Get the revision that was before this one.
+ * @param int $flags Title::GAID_FOR_UPDATE
+ * @param string $dir 'next' or 'prev'
+ * @return int|bool New revision ID, or false if none exists
+ */
+ private function getRelativeRevisionID( $revId, $flags, $dir ) {
+ $revId = (int)$revId;
+ if ( $dir === 'next' ) {
+ $op = '>';
+ $sort = 'ASC';
+ } elseif ( $dir === 'prev' ) {
+ $op = '<';
+ $sort = 'DESC';
+ } else {
+ throw new InvalidArgumentException( '$dir must be "next" or "prev"' );
+ }
+
+ if ( $flags & self::GAID_FOR_UPDATE ) {
+ $db = wfGetDB( DB_MASTER );
+ } else {
+ $db = wfGetDB( DB_REPLICA, 'contributions' );
+ }
+
+ // Intentionally not caring if the specified revision belongs to this
+ // page. We only care about the timestamp.
+ $ts = $db->selectField( 'revision', 'rev_timestamp', [ 'rev_id' => $revId ], __METHOD__ );
+ if ( $ts === false ) {
+ $ts = $db->selectField( 'archive', 'ar_timestamp', [ 'ar_rev_id' => $revId ], __METHOD__ );
+ if ( $ts === false ) {
+ // Or should this throw an InvalidArgumentException or something?
+ return false;
+ }
+ }
+ $ts = $db->addQuotes( $ts );
+
+ $revId = $db->selectField( 'revision', 'rev_id',
+ [
+ 'rev_page' => $this->getArticleID( $flags ),
+ "rev_timestamp $op $ts OR (rev_timestamp = $ts AND rev_id $op $revId)"
+ ],
+ __METHOD__,
+ [
+ 'ORDER BY' => "rev_timestamp $sort, rev_id $sort",
+ 'IGNORE INDEX' => 'rev_timestamp', // Probably needed for T159319
+ ]
+ );
+
+ if ( $revId === false ) {
+ return false;
+ } else {
+ return intval( $revId );
+ }
+ }
+
+ /**
+ * Get the revision ID of the previous revision
+ *
+ * @param int $revId Revision ID. Get the revision that was before this one.
+ * @param int $flags Title::GAID_FOR_UPDATE
+ * @return int|bool Old revision ID, or false if none exists
+ */
+ public function getPreviousRevisionID( $revId, $flags = 0 ) {
+ return $this->getRelativeRevisionID( $revId, $flags, 'prev' );
+ }
+
+ /**
+ * Get the revision ID of the next revision
+ *
+ * @param int $revId Revision ID. Get the revision that was after this one.
+ * @param int $flags Title::GAID_FOR_UPDATE
+ * @return int|bool Next revision ID, or false if none exists
+ */
+ public function getNextRevisionID( $revId, $flags = 0 ) {
+ return $this->getRelativeRevisionID( $revId, $flags, 'next' );
+ }
+
+ /**
+ * Get the first revision of the page
+ *
+ * @param int $flags Title::GAID_FOR_UPDATE
+ * @return Revision|null If page doesn't exist
+ */
+ public function getFirstRevision( $flags = 0 ) {
+ $pageId = $this->getArticleID( $flags );
+ if ( $pageId ) {
+ $db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_REPLICA );
+ $row = $db->selectRow( 'revision', Revision::selectFields(),
+ [ 'rev_page' => $pageId ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'rev_timestamp ASC, rev_id ASC',
+ 'IGNORE INDEX' => 'rev_timestamp', // See T159319
+ ]
+ );
+ if ( $row ) {
+ return new Revision( $row );
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get the oldest revision timestamp of this page
+ *
+ * @param int $flags Title::GAID_FOR_UPDATE
+ * @return string MW timestamp
+ */
+ public function getEarliestRevTime( $flags = 0 ) {
+ $rev = $this->getFirstRevision( $flags );
+ return $rev ? $rev->getTimestamp() : null;
+ }
+
+ /**
+ * Check if this is a new page
+ *
+ * @return bool
+ */
+ public function isNewPage() {
+ $dbr = wfGetDB( DB_REPLICA );
+ return (bool)$dbr->selectField( 'page', 'page_is_new', $this->pageCond(), __METHOD__ );
+ }
+
+ /**
+ * Check whether the number of revisions of this page surpasses $wgDeleteRevisionsLimit
+ *
+ * @return bool
+ */
+ public function isBigDeletion() {
+ global $wgDeleteRevisionsLimit;
+
+ if ( !$wgDeleteRevisionsLimit ) {
+ return false;
+ }
+
+ if ( $this->mIsBigDeletion === null ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $revCount = $dbr->selectRowCount(
+ 'revision',
+ '1',
+ [ 'rev_page' => $this->getArticleID() ],
+ __METHOD__,
+ [ 'LIMIT' => $wgDeleteRevisionsLimit + 1 ]
+ );
+
+ $this->mIsBigDeletion = $revCount > $wgDeleteRevisionsLimit;
+ }
+
+ return $this->mIsBigDeletion;
+ }
+
+ /**
+ * Get the approximate revision count of this page.
+ *
+ * @return int
+ */
+ public function estimateRevisionCount() {
+ if ( !$this->exists() ) {
+ return 0;
+ }
+
+ if ( $this->mEstimateRevisions === null ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $this->mEstimateRevisions = $dbr->estimateRowCount( 'revision', '*',
+ [ 'rev_page' => $this->getArticleID() ], __METHOD__ );
+ }
+
+ return $this->mEstimateRevisions;
+ }
+
+ /**
+ * Get the number of revisions between the given revision.
+ * Used for diffs and other things that really need it.
+ *
+ * @param int|Revision $old Old revision or rev ID (first before range)
+ * @param int|Revision $new New revision or rev ID (first after range)
+ * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations
+ * @return int Number of revisions between these revisions.
+ */
+ public function countRevisionsBetween( $old, $new, $max = null ) {
+ if ( !( $old instanceof Revision ) ) {
+ $old = Revision::newFromTitle( $this, (int)$old );
+ }
+ if ( !( $new instanceof Revision ) ) {
+ $new = Revision::newFromTitle( $this, (int)$new );
+ }
+ if ( !$old || !$new ) {
+ return 0; // nothing to compare
+ }
+ $dbr = wfGetDB( DB_REPLICA );
+ $conds = [
+ 'rev_page' => $this->getArticleID(),
+ 'rev_timestamp > ' . $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) ),
+ 'rev_timestamp < ' . $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) )
+ ];
+ if ( $max !== null ) {
+ return $dbr->selectRowCount( 'revision', '1',
+ $conds,
+ __METHOD__,
+ [ 'LIMIT' => $max + 1 ] // extra to detect truncation
+ );
+ } else {
+ return (int)$dbr->selectField( 'revision', 'count(*)', $conds, __METHOD__ );
+ }
+ }
+
+ /**
+ * Get the authors between the given revisions or revision IDs.
+ * Used for diffs and other things that really need it.
+ *
+ * @since 1.23
+ *
+ * @param int|Revision $old Old revision or rev ID (first before range by default)
+ * @param int|Revision $new New revision or rev ID (first after range by default)
+ * @param int $limit Maximum number of authors
+ * @param string|array $options (Optional): Single option, or an array of options:
+ * 'include_old' Include $old in the range; $new is excluded.
+ * 'include_new' Include $new in the range; $old is excluded.
+ * 'include_both' Include both $old and $new in the range.
+ * Unknown option values are ignored.
+ * @return array|null Names of revision authors in the range; null if not both revisions exist
+ */
+ public function getAuthorsBetween( $old, $new, $limit, $options = [] ) {
+ if ( !( $old instanceof Revision ) ) {
+ $old = Revision::newFromTitle( $this, (int)$old );
+ }
+ if ( !( $new instanceof Revision ) ) {
+ $new = Revision::newFromTitle( $this, (int)$new );
+ }
+ // XXX: what if Revision objects are passed in, but they don't refer to this title?
+ // Add $old->getPage() != $new->getPage() || $old->getPage() != $this->getArticleID()
+ // in the sanity check below?
+ if ( !$old || !$new ) {
+ return null; // nothing to compare
+ }
+ $authors = [];
+ $old_cmp = '>';
+ $new_cmp = '<';
+ $options = (array)$options;
+ if ( in_array( 'include_old', $options ) ) {
+ $old_cmp = '>=';
+ }
+ if ( in_array( 'include_new', $options ) ) {
+ $new_cmp = '<=';
+ }
+ if ( in_array( 'include_both', $options ) ) {
+ $old_cmp = '>=';
+ $new_cmp = '<=';
+ }
+ // No DB query needed if $old and $new are the same or successive revisions:
+ if ( $old->getId() === $new->getId() ) {
+ return ( $old_cmp === '>' && $new_cmp === '<' ) ?
+ [] :
+ [ $old->getUserText( Revision::RAW ) ];
+ } elseif ( $old->getId() === $new->getParentId() ) {
+ if ( $old_cmp === '>=' && $new_cmp === '<=' ) {
+ $authors[] = $old->getUserText( Revision::RAW );
+ if ( $old->getUserText( Revision::RAW ) != $new->getUserText( Revision::RAW ) ) {
+ $authors[] = $new->getUserText( Revision::RAW );
+ }
+ } elseif ( $old_cmp === '>=' ) {
+ $authors[] = $old->getUserText( Revision::RAW );
+ } elseif ( $new_cmp === '<=' ) {
+ $authors[] = $new->getUserText( Revision::RAW );
+ }
+ return $authors;
+ }
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'revision', 'DISTINCT rev_user_text',
+ [
+ 'rev_page' => $this->getArticleID(),
+ "rev_timestamp $old_cmp " . $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) ),
+ "rev_timestamp $new_cmp " . $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) )
+ ], __METHOD__,
+ [ 'LIMIT' => $limit + 1 ] // add one so caller knows it was truncated
+ );
+ foreach ( $res as $row ) {
+ $authors[] = $row->rev_user_text;
+ }
+ return $authors;
+ }
+
+ /**
+ * Get the number of authors between the given revisions or revision IDs.
+ * Used for diffs and other things that really need it.
+ *
+ * @param int|Revision $old Old revision or rev ID (first before range by default)
+ * @param int|Revision $new New revision or rev ID (first after range by default)
+ * @param int $limit Maximum number of authors
+ * @param string|array $options (Optional): Single option, or an array of options:
+ * 'include_old' Include $old in the range; $new is excluded.
+ * 'include_new' Include $new in the range; $old is excluded.
+ * 'include_both' Include both $old and $new in the range.
+ * Unknown option values are ignored.
+ * @return int Number of revision authors in the range; zero if not both revisions exist
+ */
+ public function countAuthorsBetween( $old, $new, $limit, $options = [] ) {
+ $authors = $this->getAuthorsBetween( $old, $new, $limit, $options );
+ return $authors ? count( $authors ) : 0;
+ }
+
+ /**
+ * Compare with another title.
+ *
+ * @param Title $title
+ * @return bool
+ */
+ public function equals( Title $title ) {
+ // Note: === is necessary for proper matching of number-like titles.
+ return $this->getInterwiki() === $title->getInterwiki()
+ && $this->getNamespace() == $title->getNamespace()
+ && $this->getDBkey() === $title->getDBkey();
+ }
+
+ /**
+ * Check if this title is a subpage of another title
+ *
+ * @param Title $title
+ * @return bool
+ */
+ public function isSubpageOf( Title $title ) {
+ return $this->getInterwiki() === $title->getInterwiki()
+ && $this->getNamespace() == $title->getNamespace()
+ && strpos( $this->getDBkey(), $title->getDBkey() . '/' ) === 0;
+ }
+
+ /**
+ * Check if page exists. For historical reasons, this function simply
+ * checks for the existence of the title in the page table, and will
+ * thus return false for interwiki links, special pages and the like.
+ * If you want to know if a title can be meaningfully viewed, you should
+ * probably call the isKnown() method instead.
+ *
+ * @param int $flags An optional bit field; may be Title::GAID_FOR_UPDATE to check
+ * from master/for update
+ * @return bool
+ */
+ public function exists( $flags = 0 ) {
+ $exists = $this->getArticleID( $flags ) != 0;
+ Hooks::run( 'TitleExists', [ $this, &$exists ] );
+ return $exists;
+ }
+
+ /**
+ * Should links to this title be shown as potentially viewable (i.e. as
+ * "bluelinks"), even if there's no record by this title in the page
+ * table?
+ *
+ * This function is semi-deprecated for public use, as well as somewhat
+ * misleadingly named. You probably just want to call isKnown(), which
+ * calls this function internally.
+ *
+ * (ISSUE: Most of these checks are cheap, but the file existence check
+ * can potentially be quite expensive. Including it here fixes a lot of
+ * existing code, but we might want to add an optional parameter to skip
+ * it and any other expensive checks.)
+ *
+ * @return bool
+ */
+ public function isAlwaysKnown() {
+ $isKnown = null;
+
+ /**
+ * Allows overriding default behavior for determining if a page exists.
+ * If $isKnown is kept as null, regular checks happen. If it's
+ * a boolean, this value is returned by the isKnown method.
+ *
+ * @since 1.20
+ *
+ * @param Title $title
+ * @param bool|null $isKnown
+ */
+ Hooks::run( 'TitleIsAlwaysKnown', [ $this, &$isKnown ] );
+
+ if ( !is_null( $isKnown ) ) {
+ return $isKnown;
+ }
+
+ if ( $this->isExternal() ) {
+ return true; // any interwiki link might be viewable, for all we know
+ }
+
+ switch ( $this->mNamespace ) {
+ case NS_MEDIA:
+ case NS_FILE:
+ // file exists, possibly in a foreign repo
+ return (bool)wfFindFile( $this );
+ case NS_SPECIAL:
+ // valid special page
+ return SpecialPageFactory::exists( $this->getDBkey() );
+ case NS_MAIN:
+ // selflink, possibly with fragment
+ return $this->mDbkeyform == '';
+ case NS_MEDIAWIKI:
+ // known system message
+ return $this->hasSourceText() !== false;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Does this title refer to a page that can (or might) be meaningfully
+ * viewed? In particular, this function may be used to determine if
+ * links to the title should be rendered as "bluelinks" (as opposed to
+ * "redlinks" to non-existent pages).
+ * Adding something else to this function will cause inconsistency
+ * since LinkHolderArray calls isAlwaysKnown() and does its own
+ * page existence check.
+ *
+ * @return bool
+ */
+ public function isKnown() {
+ return $this->isAlwaysKnown() || $this->exists();
+ }
+
+ /**
+ * Does this page have source text?
+ *
+ * @return bool
+ */
+ public function hasSourceText() {
+ if ( $this->exists() ) {
+ return true;
+ }
+
+ if ( $this->mNamespace == NS_MEDIAWIKI ) {
+ // If the page doesn't exist but is a known system message, default
+ // message content will be displayed, same for language subpages-
+ // Use always content language to avoid loading hundreds of languages
+ // to get the link color.
+ global $wgContLang;
+ list( $name, ) = MessageCache::singleton()->figureMessage(
+ $wgContLang->lcfirst( $this->getText() )
+ );
+ $message = wfMessage( $name )->inLanguage( $wgContLang )->useDatabase( false );
+ return $message->exists();
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the default message text or false if the message doesn't exist
+ *
+ * @return string|bool
+ */
+ public function getDefaultMessageText() {
+ global $wgContLang;
+
+ if ( $this->getNamespace() != NS_MEDIAWIKI ) { // Just in case
+ return false;
+ }
+
+ list( $name, $lang ) = MessageCache::singleton()->figureMessage(
+ $wgContLang->lcfirst( $this->getText() )
+ );
+ $message = wfMessage( $name )->inLanguage( $lang )->useDatabase( false );
+
+ if ( $message->exists() ) {
+ return $message->plain();
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Updates page_touched for this page; called from LinksUpdate.php
+ *
+ * @param string $purgeTime [optional] TS_MW timestamp
+ * @return bool True if the update succeeded
+ */
+ public function invalidateCache( $purgeTime = null ) {
+ if ( wfReadOnly() ) {
+ return false;
+ } elseif ( $this->mArticleID === 0 ) {
+ return true; // avoid gap locking if we know it's not there
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->onTransactionPreCommitOrIdle( function () {
+ ResourceLoaderWikiModule::invalidateModuleCache( $this, null, null, wfWikiID() );
+ } );
+
+ $conds = $this->pageCond();
+ DeferredUpdates::addUpdate(
+ new AutoCommitUpdate(
+ $dbw,
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) use ( $conds, $purgeTime ) {
+ $dbTimestamp = $dbw->timestamp( $purgeTime ?: time() );
+ $dbw->update(
+ 'page',
+ [ 'page_touched' => $dbTimestamp ],
+ $conds + [ 'page_touched < ' . $dbw->addQuotes( $dbTimestamp ) ],
+ $fname
+ );
+ MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $this );
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ return true;
+ }
+
+ /**
+ * Update page_touched timestamps and send CDN purge messages for
+ * pages linking to this title. May be sent to the job queue depending
+ * on the number of links. Typically called on create and delete.
+ */
+ public function touchLinks() {
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this, 'pagelinks' ) );
+ if ( $this->getNamespace() == NS_CATEGORY ) {
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this, 'categorylinks' ) );
+ }
+ }
+
+ /**
+ * Get the last touched timestamp
+ *
+ * @param IDatabase $db Optional db
+ * @return string|false Last-touched timestamp
+ */
+ public function getTouched( $db = null ) {
+ if ( $db === null ) {
+ $db = wfGetDB( DB_REPLICA );
+ }
+ $touched = $db->selectField( 'page', 'page_touched', $this->pageCond(), __METHOD__ );
+ return $touched;
+ }
+
+ /**
+ * Get the timestamp when this page was updated since the user last saw it.
+ *
+ * @param User $user
+ * @return string|null
+ */
+ public function getNotificationTimestamp( $user = null ) {
+ global $wgUser;
+
+ // Assume current user if none given
+ if ( !$user ) {
+ $user = $wgUser;
+ }
+ // Check cache first
+ $uid = $user->getId();
+ if ( !$uid ) {
+ return false;
+ }
+ // avoid isset here, as it'll return false for null entries
+ if ( array_key_exists( $uid, $this->mNotificationTimestamp ) ) {
+ return $this->mNotificationTimestamp[$uid];
+ }
+ // Don't cache too much!
+ if ( count( $this->mNotificationTimestamp ) >= self::CACHE_MAX ) {
+ $this->mNotificationTimestamp = [];
+ }
+
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $watchedItem = $store->getWatchedItem( $user, $this );
+ if ( $watchedItem ) {
+ $this->mNotificationTimestamp[$uid] = $watchedItem->getNotificationTimestamp();
+ } else {
+ $this->mNotificationTimestamp[$uid] = false;
+ }
+
+ return $this->mNotificationTimestamp[$uid];
+ }
+
+ /**
+ * Generate strings used for xml 'id' names in monobook tabs
+ *
+ * @param string $prepend Defaults to 'nstab-'
+ * @return string XML 'id' name
+ */
+ public function getNamespaceKey( $prepend = 'nstab-' ) {
+ global $wgContLang;
+ // Gets the subject namespace if this title
+ $namespace = MWNamespace::getSubject( $this->getNamespace() );
+ // Checks if canonical namespace name exists for namespace
+ if ( MWNamespace::exists( $this->getNamespace() ) ) {
+ // Uses canonical namespace name
+ $namespaceKey = MWNamespace::getCanonicalName( $namespace );
+ } else {
+ // Uses text of namespace
+ $namespaceKey = $this->getSubjectNsText();
+ }
+ // Makes namespace key lowercase
+ $namespaceKey = $wgContLang->lc( $namespaceKey );
+ // Uses main
+ if ( $namespaceKey == '' ) {
+ $namespaceKey = 'main';
+ }
+ // Changes file to image for backwards compatibility
+ if ( $namespaceKey == 'file' ) {
+ $namespaceKey = 'image';
+ }
+ return $prepend . $namespaceKey;
+ }
+
+ /**
+ * Get all extant redirects to this Title
+ *
+ * @param int|null $ns Single namespace to consider; null to consider all namespaces
+ * @return Title[] Array of Title redirects to this title
+ */
+ public function getRedirectsHere( $ns = null ) {
+ $redirs = [];
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $where = [
+ 'rd_namespace' => $this->getNamespace(),
+ 'rd_title' => $this->getDBkey(),
+ 'rd_from = page_id'
+ ];
+ if ( $this->isExternal() ) {
+ $where['rd_interwiki'] = $this->getInterwiki();
+ } else {
+ $where[] = 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL';
+ }
+ if ( !is_null( $ns ) ) {
+ $where['page_namespace'] = $ns;
+ }
+
+ $res = $dbr->select(
+ [ 'redirect', 'page' ],
+ [ 'page_namespace', 'page_title' ],
+ $where,
+ __METHOD__
+ );
+
+ foreach ( $res as $row ) {
+ $redirs[] = self::newFromRow( $row );
+ }
+ return $redirs;
+ }
+
+ /**
+ * Check if this Title is a valid redirect target
+ *
+ * @return bool
+ */
+ public function isValidRedirectTarget() {
+ global $wgInvalidRedirectTargets;
+
+ if ( $this->isSpecialPage() ) {
+ // invalid redirect targets are stored in a global array, but explicitly disallow Userlogout here
+ if ( $this->isSpecial( 'Userlogout' ) ) {
+ return false;
+ }
+
+ foreach ( $wgInvalidRedirectTargets as $target ) {
+ if ( $this->isSpecial( $target ) ) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a backlink cache object
+ *
+ * @return BacklinkCache
+ */
+ public function getBacklinkCache() {
+ return BacklinkCache::get( $this );
+ }
+
+ /**
+ * Whether the magic words __INDEX__ and __NOINDEX__ function for this page.
+ *
+ * @return bool
+ */
+ public function canUseNoindex() {
+ global $wgExemptFromUserRobotsControl;
+
+ $bannedNamespaces = is_null( $wgExemptFromUserRobotsControl )
+ ? MWNamespace::getContentNamespaces()
+ : $wgExemptFromUserRobotsControl;
+
+ return !in_array( $this->mNamespace, $bannedNamespaces );
+ }
+
+ /**
+ * Returns the raw sort key to be used for categories, with the specified
+ * prefix. This will be fed to Collation::getSortKey() to get a
+ * binary sortkey that can be used for actual sorting.
+ *
+ * @param string $prefix The prefix to be used, specified using
+ * {{defaultsort:}} or like [[Category:Foo|prefix]]. Empty for no
+ * prefix.
+ * @return string
+ */
+ public function getCategorySortkey( $prefix = '' ) {
+ $unprefixed = $this->getText();
+
+ // Anything that uses this hook should only depend
+ // on the Title object passed in, and should probably
+ // tell the users to run updateCollations.php --force
+ // in order to re-sort existing category relations.
+ Hooks::run( 'GetDefaultSortkey', [ $this, &$unprefixed ] );
+ if ( $prefix !== '' ) {
+ # Separate with a line feed, so the unprefixed part is only used as
+ # a tiebreaker when two pages have the exact same prefix.
+ # In UCA, tab is the only character that can sort above LF
+ # so we strip both of them from the original prefix.
+ $prefix = strtr( $prefix, "\n\t", ' ' );
+ return "$prefix\n$unprefixed";
+ }
+ return $unprefixed;
+ }
+
+ /**
+ * Returns the page language code saved in the database, if $wgPageLanguageUseDB is set
+ * to true in LocalSettings.php, otherwise returns false. If there is no language saved in
+ * the db, it will return NULL.
+ *
+ * @return string|null|bool
+ */
+ private function getDbPageLanguageCode() {
+ global $wgPageLanguageUseDB;
+
+ // check, if the page language could be saved in the database, and if so and
+ // the value is not requested already, lookup the page language using LinkCache
+ if ( $wgPageLanguageUseDB && $this->mDbPageLanguage === false ) {
+ $linkCache = LinkCache::singleton();
+ $linkCache->addLinkObj( $this );
+ $this->mDbPageLanguage = $linkCache->getGoodLinkFieldObj( $this, 'lang' );
+ }
+
+ return $this->mDbPageLanguage;
+ }
+
+ /**
+ * Get the language in which the content of this page is written in
+ * wikitext. Defaults to $wgContLang, but in certain cases it can be
+ * e.g. $wgLang (such as special pages, which are in the user language).
+ *
+ * @since 1.18
+ * @return Language
+ */
+ public function getPageLanguage() {
+ global $wgLang, $wgLanguageCode;
+ if ( $this->isSpecialPage() ) {
+ // special pages are in the user language
+ return $wgLang;
+ }
+
+ // Checking if DB language is set
+ $dbPageLanguage = $this->getDbPageLanguageCode();
+ if ( $dbPageLanguage ) {
+ return wfGetLangObj( $dbPageLanguage );
+ }
+
+ if ( !$this->mPageLanguage || $this->mPageLanguage[1] !== $wgLanguageCode ) {
+ // Note that this may depend on user settings, so the cache should
+ // be only per-request.
+ // NOTE: ContentHandler::getPageLanguage() may need to load the
+ // content to determine the page language!
+ // Checking $wgLanguageCode hasn't changed for the benefit of unit
+ // tests.
+ $contentHandler = ContentHandler::getForTitle( $this );
+ $langObj = $contentHandler->getPageLanguage( $this );
+ $this->mPageLanguage = [ $langObj->getCode(), $wgLanguageCode ];
+ } else {
+ $langObj = wfGetLangObj( $this->mPageLanguage[0] );
+ }
+
+ return $langObj;
+ }
+
+ /**
+ * Get the language in which the content of this page is written when
+ * viewed by user. Defaults to $wgContLang, but in certain cases it can be
+ * e.g. $wgLang (such as special pages, which are in the user language).
+ *
+ * @since 1.20
+ * @return Language
+ */
+ public function getPageViewLanguage() {
+ global $wgLang;
+
+ if ( $this->isSpecialPage() ) {
+ // If the user chooses a variant, the content is actually
+ // in a language whose code is the variant code.
+ $variant = $wgLang->getPreferredVariant();
+ if ( $wgLang->getCode() !== $variant ) {
+ return Language::factory( $variant );
+ }
+
+ return $wgLang;
+ }
+
+ // Checking if DB language is set
+ $dbPageLanguage = $this->getDbPageLanguageCode();
+ if ( $dbPageLanguage ) {
+ $pageLang = wfGetLangObj( $dbPageLanguage );
+ $variant = $pageLang->getPreferredVariant();
+ if ( $pageLang->getCode() !== $variant ) {
+ $pageLang = Language::factory( $variant );
+ }
+
+ return $pageLang;
+ }
+
+ // @note Can't be cached persistently, depends on user settings.
+ // @note ContentHandler::getPageViewLanguage() may need to load the
+ // content to determine the page language!
+ $contentHandler = ContentHandler::getForTitle( $this );
+ $pageLang = $contentHandler->getPageViewLanguage( $this );
+ return $pageLang;
+ }
+
+ /**
+ * Get a list of rendered edit notices for this page.
+ *
+ * Array is keyed by the original message key, and values are rendered using parseAsBlock, so
+ * they will already be wrapped in paragraphs.
+ *
+ * @since 1.21
+ * @param int $oldid Revision ID that's being edited
+ * @return array
+ */
+ public function getEditNotices( $oldid = 0 ) {
+ $notices = [];
+
+ // Optional notice for the entire namespace
+ $editnotice_ns = 'editnotice-' . $this->getNamespace();
+ $msg = wfMessage( $editnotice_ns );
+ if ( $msg->exists() ) {
+ $html = $msg->parseAsBlock();
+ // Edit notices may have complex logic, but output nothing (T91715)
+ if ( trim( $html ) !== '' ) {
+ $notices[$editnotice_ns] = Html::rawElement(
+ 'div',
+ [ 'class' => [
+ 'mw-editnotice',
+ 'mw-editnotice-namespace',
+ Sanitizer::escapeClass( "mw-$editnotice_ns" )
+ ] ],
+ $html
+ );
+ }
+ }
+
+ if ( MWNamespace::hasSubpages( $this->getNamespace() ) ) {
+ // Optional notice for page itself and any parent page
+ $parts = explode( '/', $this->getDBkey() );
+ $editnotice_base = $editnotice_ns;
+ while ( count( $parts ) > 0 ) {
+ $editnotice_base .= '-' . array_shift( $parts );
+ $msg = wfMessage( $editnotice_base );
+ if ( $msg->exists() ) {
+ $html = $msg->parseAsBlock();
+ if ( trim( $html ) !== '' ) {
+ $notices[$editnotice_base] = Html::rawElement(
+ 'div',
+ [ 'class' => [
+ 'mw-editnotice',
+ 'mw-editnotice-base',
+ Sanitizer::escapeClass( "mw-$editnotice_base" )
+ ] ],
+ $html
+ );
+ }
+ }
+ }
+ } else {
+ // Even if there are no subpages in namespace, we still don't want "/" in MediaWiki message keys
+ $editnoticeText = $editnotice_ns . '-' . strtr( $this->getDBkey(), '/', '-' );
+ $msg = wfMessage( $editnoticeText );
+ if ( $msg->exists() ) {
+ $html = $msg->parseAsBlock();
+ if ( trim( $html ) !== '' ) {
+ $notices[$editnoticeText] = Html::rawElement(
+ 'div',
+ [ 'class' => [
+ 'mw-editnotice',
+ 'mw-editnotice-page',
+ Sanitizer::escapeClass( "mw-$editnoticeText" )
+ ] ],
+ $html
+ );
+ }
+ }
+ }
+
+ Hooks::run( 'TitleGetEditNotices', [ $this, $oldid, &$notices ] );
+ return $notices;
+ }
+
+ /**
+ * @return array
+ */
+ public function __sleep() {
+ return [
+ 'mNamespace',
+ 'mDbkeyform',
+ 'mFragment',
+ 'mInterwiki',
+ 'mLocalInterwiki',
+ 'mUserCaseDBKey',
+ 'mDefaultNamespace',
+ ];
+ }
+
+ public function __wakeup() {
+ $this->mArticleID = ( $this->mNamespace >= 0 ) ? -1 : 0;
+ $this->mUrlform = wfUrlencode( $this->mDbkeyform );
+ $this->mTextform = strtr( $this->mDbkeyform, '_', ' ' );
+ }
+
+}
diff --git a/www/wiki/includes/TitleArray.php b/www/wiki/includes/TitleArray.php
new file mode 100644
index 00000000..bf2344bb
--- /dev/null
+++ b/www/wiki/includes/TitleArray.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Class to walk into a list of Title objects.
+ *
+ * Note: this entire file is a byte-for-byte copy of UserArray.php with
+ * s/User/Title/. If anyone can figure out how to do this nicely with
+ * inheritance or something, please do so.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * The TitleArray class only exists to provide the newFromResult method at pre-
+ * sent.
+ */
+abstract class TitleArray implements Iterator {
+ /**
+ * @param ResultWrapper $res A SQL result including at least page_namespace and
+ * page_title -- also can have page_id, page_len, page_is_redirect,
+ * page_latest (if those will be used). See Title::newFromRow.
+ * @return TitleArrayFromResult
+ */
+ static function newFromResult( $res ) {
+ $array = null;
+ if ( !Hooks::run( 'TitleArrayFromResult', [ &$array, $res ] ) ) {
+ return null;
+ }
+ if ( $array === null ) {
+ $array = self::newFromResult_internal( $res );
+ }
+ return $array;
+ }
+
+ /**
+ * @param ResultWrapper $res
+ * @return TitleArrayFromResult
+ */
+ protected static function newFromResult_internal( $res ) {
+ $array = new TitleArrayFromResult( $res );
+ return $array;
+ }
+}
diff --git a/www/wiki/includes/TitleArrayFromResult.php b/www/wiki/includes/TitleArrayFromResult.php
new file mode 100644
index 00000000..189fb405
--- /dev/null
+++ b/www/wiki/includes/TitleArrayFromResult.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * Class to walk into a list of Title objects.
+ *
+ * Note: this entire file is a byte-for-byte copy of UserArrayFromResult.php
+ * with s/User/Title/. If anyone can figure out how to do this nicely
+ * with inheritance or something, please do so.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+class TitleArrayFromResult extends TitleArray implements Countable {
+ /** @var ResultWrapper */
+ public $res;
+
+ public $key;
+
+ public $current;
+
+ function __construct( $res ) {
+ $this->res = $res;
+ $this->key = 0;
+ $this->setCurrent( $this->res->current() );
+ }
+
+ /**
+ * @param bool|ResultWrapper $row
+ * @return void
+ */
+ protected function setCurrent( $row ) {
+ if ( $row === false ) {
+ $this->current = false;
+ } else {
+ $this->current = Title::newFromRow( $row );
+ }
+ }
+
+ /**
+ * @return int
+ */
+ public function count() {
+ return $this->res->numRows();
+ }
+
+ function current() {
+ return $this->current;
+ }
+
+ function key() {
+ return $this->key;
+ }
+
+ function next() {
+ $row = $this->res->next();
+ $this->setCurrent( $row );
+ $this->key++;
+ }
+
+ function rewind() {
+ $this->res->rewind();
+ $this->key = 0;
+ $this->setCurrent( $this->res->current() );
+ }
+
+ /**
+ * @return bool
+ */
+ function valid() {
+ return $this->current !== false;
+ }
+}
diff --git a/www/wiki/includes/TrackingCategories.php b/www/wiki/includes/TrackingCategories.php
new file mode 100644
index 00000000..b3a49c71
--- /dev/null
+++ b/www/wiki/includes/TrackingCategories.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Categories
+ */
+
+/**
+ * This class performs some operations related to tracking categories, such as creating
+ * a list of all such categories.
+ * @since 1.29
+ */
+class TrackingCategories {
+ /** @var Config */
+ private $config;
+
+ /**
+ * Tracking categories that exist in core
+ *
+ * @var array
+ */
+ private static $coreTrackingCategories = [
+ 'index-category',
+ 'noindex-category',
+ 'duplicate-args-category',
+ 'expensive-parserfunction-category',
+ 'post-expand-template-argument-category',
+ 'post-expand-template-inclusion-category',
+ 'hidden-category-category',
+ 'broken-file-category',
+ 'node-count-exceeded-category',
+ 'expansion-depth-exceeded-category',
+ 'restricted-displaytitle-ignored',
+ 'deprecated-self-close-category',
+ 'template-loop-category',
+ ];
+
+ /**
+ * @param Config $config
+ */
+ public function __construct( Config $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * Read the global and extract title objects from the corresponding messages
+ * @return array Array( 'msg' => Title, 'cats' => Title[] )
+ */
+ public function getTrackingCategories() {
+ $categories = array_merge(
+ self::$coreTrackingCategories,
+ ExtensionRegistry::getInstance()->getAttribute( 'TrackingCategories' ),
+ $this->config->get( 'TrackingCategories' ) // deprecated
+ );
+
+ // Only show magic link tracking categories if they are enabled
+ $enableMagicLinks = $this->config->get( 'EnableMagicLinks' );
+ if ( $enableMagicLinks['ISBN'] ) {
+ $categories[] = 'magiclink-tracking-isbn';
+ }
+ if ( $enableMagicLinks['RFC'] ) {
+ $categories[] = 'magiclink-tracking-rfc';
+ }
+ if ( $enableMagicLinks['PMID'] ) {
+ $categories[] = 'magiclink-tracking-pmid';
+ }
+
+ $trackingCategories = [];
+ foreach ( $categories as $catMsg ) {
+ /*
+ * Check if the tracking category varies by namespace
+ * Otherwise only pages in the current namespace will be displayed
+ * If it does vary, show pages considering all namespaces
+ */
+ $msgObj = wfMessage( $catMsg )->inContentLanguage();
+ $allCats = [];
+ $catMsgTitle = Title::makeTitleSafe( NS_MEDIAWIKI, $catMsg );
+ if ( !$catMsgTitle ) {
+ continue;
+ }
+
+ // Match things like {{NAMESPACE}} and {{NAMESPACENUMBER}}.
+ // False positives are ok, this is just an efficiency shortcut
+ if ( strpos( $msgObj->plain(), '{{' ) !== false ) {
+ $ns = MWNamespace::getValidNamespaces();
+ foreach ( $ns as $namesp ) {
+ $tempTitle = Title::makeTitleSafe( $namesp, $catMsg );
+ if ( !$tempTitle ) {
+ continue;
+ }
+ $catName = $msgObj->title( $tempTitle )->text();
+ # Allow tracking categories to be disabled by setting them to "-"
+ if ( $catName !== '-' ) {
+ $catTitle = Title::makeTitleSafe( NS_CATEGORY, $catName );
+ if ( $catTitle ) {
+ $allCats[] = $catTitle;
+ }
+ }
+ }
+ } else {
+ $catName = $msgObj->text();
+ # Allow tracking categories to be disabled by setting them to "-"
+ if ( $catName !== '-' ) {
+ $catTitle = Title::makeTitleSafe( NS_CATEGORY, $catName );
+ if ( $catTitle ) {
+ $allCats[] = $catTitle;
+ }
+ }
+ }
+ $trackingCategories[$catMsg] = [
+ 'cats' => $allCats,
+ 'msg' => $catMsgTitle,
+ ];
+ }
+
+ return $trackingCategories;
+ }
+}
diff --git a/www/wiki/includes/WatchedItem.php b/www/wiki/includes/WatchedItem.php
new file mode 100644
index 00000000..bfd1d613
--- /dev/null
+++ b/www/wiki/includes/WatchedItem.php
@@ -0,0 +1,200 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Watchlist
+ */
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * Representation of a pair of user and title for watchlist entries.
+ *
+ * @author Tim Starling
+ * @author Addshore
+ *
+ * @ingroup Watchlist
+ */
+class WatchedItem {
+
+ /**
+ * @deprecated since 1.27, see User::IGNORE_USER_RIGHTS
+ */
+ const IGNORE_USER_RIGHTS = User::IGNORE_USER_RIGHTS;
+
+ /**
+ * @deprecated since 1.27, see User::CHECK_USER_RIGHTS
+ */
+ const CHECK_USER_RIGHTS = User::CHECK_USER_RIGHTS;
+
+ /**
+ * @deprecated Internal class use only
+ */
+ const DEPRECATED_USAGE_TIMESTAMP = -100;
+
+ /**
+ * @var bool
+ * @deprecated Internal class use only
+ */
+ public $checkRights = User::CHECK_USER_RIGHTS;
+
+ /**
+ * @var Title
+ * @deprecated Internal class use only
+ */
+ private $title;
+
+ /**
+ * @var LinkTarget
+ */
+ private $linkTarget;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var null|string the value of the wl_notificationtimestamp field
+ */
+ private $notificationTimestamp;
+
+ /**
+ * @param User $user
+ * @param LinkTarget $linkTarget
+ * @param null|string $notificationTimestamp the value of the wl_notificationtimestamp field
+ * @param bool|null $checkRights DO NOT USE - used internally for backward compatibility
+ */
+ public function __construct(
+ User $user,
+ LinkTarget $linkTarget,
+ $notificationTimestamp,
+ $checkRights = null
+ ) {
+ $this->user = $user;
+ $this->linkTarget = $linkTarget;
+ $this->notificationTimestamp = $notificationTimestamp;
+ if ( $checkRights !== null ) {
+ $this->checkRights = $checkRights;
+ }
+ }
+
+ /**
+ * @return User
+ */
+ public function getUser() {
+ return $this->user;
+ }
+
+ /**
+ * @return LinkTarget
+ */
+ public function getLinkTarget() {
+ return $this->linkTarget;
+ }
+
+ /**
+ * Get the notification timestamp of this entry.
+ *
+ * @return bool|null|string
+ */
+ public function getNotificationTimestamp() {
+ // Back compat for objects constructed using self::fromUserTitle
+ if ( $this->notificationTimestamp === self::DEPRECATED_USAGE_TIMESTAMP ) {
+ // wfDeprecated( __METHOD__, '1.27' );
+ if ( $this->checkRights && !$this->user->isAllowed( 'viewmywatchlist' ) ) {
+ return false;
+ }
+ $item = MediaWikiServices::getInstance()->getWatchedItemStore()
+ ->loadWatchedItem( $this->user, $this->linkTarget );
+ if ( $item ) {
+ $this->notificationTimestamp = $item->getNotificationTimestamp();
+ } else {
+ $this->notificationTimestamp = false;
+ }
+ }
+ return $this->notificationTimestamp;
+ }
+
+ /**
+ * Back compat pre 1.27 with the WatchedItemStore introduction
+ * @todo remove in 1.28/9
+ * -------------------------------------------------
+ */
+
+ /**
+ * @return Title
+ * @deprecated Internal class use only
+ */
+ public function getTitle() {
+ if ( !$this->title ) {
+ $this->title = Title::newFromLinkTarget( $this->linkTarget );
+ }
+ return $this->title;
+ }
+
+ /**
+ * @deprecated since 1.27 Use the constructor, WatchedItemStore::getWatchedItem()
+ * or WatchedItemStore::loadWatchedItem()
+ */
+ public static function fromUserTitle( $user, $title, $checkRights = User::CHECK_USER_RIGHTS ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ return new self( $user, $title, self::DEPRECATED_USAGE_TIMESTAMP, (bool)$checkRights );
+ }
+
+ /**
+ * @deprecated since 1.27 Use User::addWatch()
+ * @return bool
+ */
+ public function addWatch() {
+ wfDeprecated( __METHOD__, '1.27' );
+ $this->user->addWatch( $this->getTitle(), $this->checkRights );
+ return true;
+ }
+
+ /**
+ * @deprecated since 1.27 Use User::removeWatch()
+ * @return bool
+ */
+ public function removeWatch() {
+ wfDeprecated( __METHOD__, '1.27' );
+ if ( $this->checkRights && !$this->user->isAllowed( 'editmywatchlist' ) ) {
+ return false;
+ }
+ $this->user->removeWatch( $this->getTitle(), $this->checkRights );
+ return true;
+ }
+
+ /**
+ * @deprecated since 1.27 Use User::isWatched()
+ * @return bool
+ */
+ public function isWatched() {
+ wfDeprecated( __METHOD__, '1.27' );
+ return $this->user->isWatched( $this->getTitle(), $this->checkRights );
+ }
+
+ /**
+ * @deprecated since 1.27 Use WatchedItemStore::duplicateAllAssociatedEntries()
+ */
+ public static function duplicateEntries( Title $oldTitle, Title $newTitle ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->duplicateAllAssociatedEntries( $oldTitle, $newTitle );
+ }
+
+}
diff --git a/www/wiki/includes/WatchedItemQueryService.php b/www/wiki/includes/WatchedItemQueryService.php
new file mode 100644
index 00000000..d0f45bec
--- /dev/null
+++ b/www/wiki/includes/WatchedItemQueryService.php
@@ -0,0 +1,684 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Linker\LinkTarget;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Class performing complex database queries related to WatchedItems.
+ *
+ * @since 1.28
+ *
+ * @file
+ * @ingroup Watchlist
+ *
+ * @license GNU GPL v2+
+ */
+class WatchedItemQueryService {
+
+ const DIR_OLDER = 'older';
+ const DIR_NEWER = 'newer';
+
+ const INCLUDE_FLAGS = 'flags';
+ const INCLUDE_USER = 'user';
+ const INCLUDE_USER_ID = 'userid';
+ const INCLUDE_COMMENT = 'comment';
+ const INCLUDE_PATROL_INFO = 'patrol';
+ const INCLUDE_SIZES = 'sizes';
+ const INCLUDE_LOG_INFO = 'loginfo';
+
+ // FILTER_* constants are part of public API (are used in ApiQueryWatchlist and
+ // ApiQueryWatchlistRaw classes) and should not be changed.
+ // Changing values of those constants will result in a breaking change in the API
+ const FILTER_MINOR = 'minor';
+ const FILTER_NOT_MINOR = '!minor';
+ const FILTER_BOT = 'bot';
+ const FILTER_NOT_BOT = '!bot';
+ const FILTER_ANON = 'anon';
+ const FILTER_NOT_ANON = '!anon';
+ const FILTER_PATROLLED = 'patrolled';
+ const FILTER_NOT_PATROLLED = '!patrolled';
+ const FILTER_UNREAD = 'unread';
+ const FILTER_NOT_UNREAD = '!unread';
+ const FILTER_CHANGED = 'changed';
+ const FILTER_NOT_CHANGED = '!changed';
+
+ const SORT_ASC = 'ASC';
+ const SORT_DESC = 'DESC';
+
+ /**
+ * @var LoadBalancer
+ */
+ private $loadBalancer;
+
+ /** @var WatchedItemQueryServiceExtension[]|null */
+ private $extensions = null;
+
+ /**
+ * @var CommentStore|null */
+ private $commentStore = null;
+
+ public function __construct( LoadBalancer $loadBalancer ) {
+ $this->loadBalancer = $loadBalancer;
+ }
+
+ /**
+ * @return WatchedItemQueryServiceExtension[]
+ */
+ private function getExtensions() {
+ if ( $this->extensions === null ) {
+ $this->extensions = [];
+ Hooks::run( 'WatchedItemQueryServiceExtensions', [ &$this->extensions, $this ] );
+ }
+ return $this->extensions;
+ }
+
+ /**
+ * @return IDatabase
+ * @throws MWException
+ */
+ private function getConnection() {
+ return $this->loadBalancer->getConnectionRef( DB_REPLICA, [ 'watchlist' ] );
+ }
+
+ private function getCommentStore() {
+ if ( !$this->commentStore ) {
+ $this->commentStore = new CommentStore( 'rc_comment' );
+ }
+ return $this->commentStore;
+ }
+
+ /**
+ * @param User $user
+ * @param array $options Allowed keys:
+ * 'includeFields' => string[] RecentChange fields to be included in the result,
+ * self::INCLUDE_* constants should be used
+ * 'filters' => string[] optional filters to narrow down resulted items
+ * 'namespaceIds' => int[] optional namespace IDs to filter by
+ * (defaults to all namespaces)
+ * 'allRevisions' => bool return multiple revisions of the same page if true,
+ * only the most recent if false (default)
+ * 'rcTypes' => int[] which types of RecentChanges to include
+ * (defaults to all types), allowed values: RC_EDIT, RC_NEW,
+ * RC_LOG, RC_EXTERNAL, RC_CATEGORIZE
+ * 'onlyByUser' => string only list changes by a specified user
+ * 'notByUser' => string do not incluide changes by a specified user
+ * 'dir' => string in which direction to enumerate, accepted values:
+ * - DIR_OLDER list newest first
+ * - DIR_NEWER list oldest first
+ * 'start' => string (format accepted by wfTimestamp) requires 'dir' option,
+ * timestamp to start enumerating from
+ * 'end' => string (format accepted by wfTimestamp) requires 'dir' option,
+ * timestamp to end enumerating
+ * 'watchlistOwner' => User user whose watchlist items should be listed if different
+ * than the one specified with $user param,
+ * requires 'watchlistOwnerToken' option
+ * 'watchlistOwnerToken' => string a watchlist token used to access another user's
+ * watchlist, used with 'watchlistOwnerToken' option
+ * 'limit' => int maximum numbers of items to return
+ * 'usedInGenerator' => bool include only RecentChange id field required by the
+ * generator ('rc_cur_id' or 'rc_this_oldid') if true, or all
+ * id fields ('rc_cur_id', 'rc_this_oldid', 'rc_last_oldid')
+ * if false (default)
+ * @param array|null &$startFrom Continuation value: [ string $rcTimestamp, int $rcId ]
+ * @return array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ),
+ * where $recentChangeInfo contains the following keys:
+ * - 'rc_id',
+ * - 'rc_namespace',
+ * - 'rc_title',
+ * - 'rc_timestamp',
+ * - 'rc_type',
+ * - 'rc_deleted',
+ * Additional keys could be added by specifying the 'includeFields' option
+ */
+ public function getWatchedItemsWithRecentChangeInfo(
+ User $user, array $options = [], &$startFrom = null
+ ) {
+ $options += [
+ 'includeFields' => [],
+ 'namespaceIds' => [],
+ 'filters' => [],
+ 'allRevisions' => false,
+ 'usedInGenerator' => false
+ ];
+
+ Assert::parameter(
+ !isset( $options['rcTypes'] )
+ || !array_diff( $options['rcTypes'], [ RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL, RC_CATEGORIZE ] ),
+ '$options[\'rcTypes\']',
+ 'must be an array containing only: RC_EDIT, RC_NEW, RC_LOG, RC_EXTERNAL and/or RC_CATEGORIZE'
+ );
+ Assert::parameter(
+ !isset( $options['dir'] ) || in_array( $options['dir'], [ self::DIR_OLDER, self::DIR_NEWER ] ),
+ '$options[\'dir\']',
+ 'must be DIR_OLDER or DIR_NEWER'
+ );
+ Assert::parameter(
+ !isset( $options['start'] ) && !isset( $options['end'] ) && $startFrom === null
+ || isset( $options['dir'] ),
+ '$options[\'dir\']',
+ 'must be provided when providing the "start" or "end" options or the $startFrom parameter'
+ );
+ Assert::parameter(
+ !isset( $options['startFrom'] ),
+ '$options[\'startFrom\']',
+ 'must not be provided, use $startFrom instead'
+ );
+ Assert::parameter(
+ !isset( $startFrom ) || ( is_array( $startFrom ) && count( $startFrom ) === 2 ),
+ '$startFrom',
+ 'must be a two-element array'
+ );
+ if ( array_key_exists( 'watchlistOwner', $options ) ) {
+ Assert::parameterType(
+ User::class,
+ $options['watchlistOwner'],
+ '$options[\'watchlistOwner\']'
+ );
+ Assert::parameter(
+ isset( $options['watchlistOwnerToken'] ),
+ '$options[\'watchlistOwnerToken\']',
+ 'must be provided when providing watchlistOwner option'
+ );
+ }
+
+ $db = $this->getConnection();
+
+ $tables = $this->getWatchedItemsWithRCInfoQueryTables( $options );
+ $fields = $this->getWatchedItemsWithRCInfoQueryFields( $options );
+ $conds = $this->getWatchedItemsWithRCInfoQueryConds( $db, $user, $options );
+ $dbOptions = $this->getWatchedItemsWithRCInfoQueryDbOptions( $options );
+ $joinConds = $this->getWatchedItemsWithRCInfoQueryJoinConds( $options );
+
+ if ( $startFrom !== null ) {
+ $conds[] = $this->getStartFromConds( $db, $options, $startFrom );
+ }
+
+ foreach ( $this->getExtensions() as $extension ) {
+ $extension->modifyWatchedItemsWithRCInfoQuery(
+ $user, $options, $db,
+ $tables,
+ $fields,
+ $conds,
+ $dbOptions,
+ $joinConds
+ );
+ }
+
+ $res = $db->select(
+ $tables,
+ $fields,
+ $conds,
+ __METHOD__,
+ $dbOptions,
+ $joinConds
+ );
+
+ $limit = isset( $dbOptions['LIMIT'] ) ? $dbOptions['LIMIT'] : INF;
+ $items = [];
+ $startFrom = null;
+ foreach ( $res as $row ) {
+ if ( --$limit <= 0 ) {
+ $startFrom = [ $row->rc_timestamp, $row->rc_id ];
+ break;
+ }
+
+ $items[] = [
+ new WatchedItem(
+ $user,
+ new TitleValue( (int)$row->rc_namespace, $row->rc_title ),
+ $row->wl_notificationtimestamp
+ ),
+ $this->getRecentChangeFieldsFromRow( $row )
+ ];
+ }
+
+ foreach ( $this->getExtensions() as $extension ) {
+ $extension->modifyWatchedItemsWithRCInfo( $user, $options, $db, $items, $res, $startFrom );
+ }
+
+ return $items;
+ }
+
+ /**
+ * For simple listing of user's watchlist items, see WatchedItemStore::getWatchedItemsForUser
+ *
+ * @param User $user
+ * @param array $options Allowed keys:
+ * 'sort' => string optional sorting by namespace ID and title
+ * one of the self::SORT_* constants
+ * 'namespaceIds' => int[] optional namespace IDs to filter by (defaults to all namespaces)
+ * 'limit' => int maximum number of items to return
+ * 'filter' => string optional filter, one of the self::FILTER_* contants
+ * 'from' => LinkTarget requires 'sort' key, only return items starting from
+ * those related to the link target
+ * 'until' => LinkTarget requires 'sort' key, only return items until
+ * those related to the link target
+ * 'startFrom' => LinkTarget requires 'sort' key, only return items starting from
+ * those related to the link target, allows to skip some link targets
+ * specified using the form option
+ * @return WatchedItem[]
+ */
+ public function getWatchedItemsForUser( User $user, array $options = [] ) {
+ if ( $user->isAnon() ) {
+ // TODO: should this just return an empty array or rather complain loud at this point
+ // as e.g. ApiBase::getWatchlistUser does?
+ return [];
+ }
+
+ $options += [ 'namespaceIds' => [] ];
+
+ Assert::parameter(
+ !isset( $options['sort'] ) || in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ),
+ '$options[\'sort\']',
+ 'must be SORT_ASC or SORT_DESC'
+ );
+ Assert::parameter(
+ !isset( $options['filter'] ) || in_array(
+ $options['filter'], [ self::FILTER_CHANGED, self::FILTER_NOT_CHANGED ]
+ ),
+ '$options[\'filter\']',
+ 'must be FILTER_CHANGED or FILTER_NOT_CHANGED'
+ );
+ Assert::parameter(
+ !isset( $options['from'] ) && !isset( $options['until'] ) && !isset( $options['startFrom'] )
+ || isset( $options['sort'] ),
+ '$options[\'sort\']',
+ 'must be provided if any of "from", "until", "startFrom" options is provided'
+ );
+
+ $db = $this->getConnection();
+
+ $conds = $this->getWatchedItemsForUserQueryConds( $db, $user, $options );
+ $dbOptions = $this->getWatchedItemsForUserQueryDbOptions( $options );
+
+ $res = $db->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ $conds,
+ __METHOD__,
+ $dbOptions
+ );
+
+ $watchedItems = [];
+ foreach ( $res as $row ) {
+ // todo these could all be cached at some point?
+ $watchedItems[] = new WatchedItem(
+ $user,
+ new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
+ $row->wl_notificationtimestamp
+ );
+ }
+
+ return $watchedItems;
+ }
+
+ private function getRecentChangeFieldsFromRow( stdClass $row ) {
+ // This can be simplified to single array_filter call filtering by key value,
+ // once we stop supporting PHP 5.5
+ $allFields = get_object_vars( $row );
+ $rcKeys = array_filter(
+ array_keys( $allFields ),
+ function ( $key ) {
+ return substr( $key, 0, 3 ) === 'rc_';
+ }
+ );
+ return array_intersect_key( $allFields, array_flip( $rcKeys ) );
+ }
+
+ private function getWatchedItemsWithRCInfoQueryTables( array $options ) {
+ $tables = [ 'recentchanges', 'watchlist' ];
+ if ( !$options['allRevisions'] ) {
+ $tables[] = 'page';
+ }
+ if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+ $tables += $this->getCommentStore()->getJoin()['tables'];
+ }
+ return $tables;
+ }
+
+ private function getWatchedItemsWithRCInfoQueryFields( array $options ) {
+ $fields = [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp'
+ ];
+
+ $rcIdFields = [
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ];
+ if ( $options['usedInGenerator'] ) {
+ if ( $options['allRevisions'] ) {
+ $rcIdFields = [ 'rc_this_oldid' ];
+ } else {
+ $rcIdFields = [ 'rc_cur_id' ];
+ }
+ }
+ $fields = array_merge( $fields, $rcIdFields );
+
+ if ( in_array( self::INCLUDE_FLAGS, $options['includeFields'] ) ) {
+ $fields = array_merge( $fields, [ 'rc_type', 'rc_minor', 'rc_bot' ] );
+ }
+ if ( in_array( self::INCLUDE_USER, $options['includeFields'] ) ) {
+ $fields[] = 'rc_user_text';
+ }
+ if ( in_array( self::INCLUDE_USER_ID, $options['includeFields'] ) ) {
+ $fields[] = 'rc_user';
+ }
+ if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+ $fields += $this->getCommentStore()->getJoin()['fields'];
+ }
+ if ( in_array( self::INCLUDE_PATROL_INFO, $options['includeFields'] ) ) {
+ $fields = array_merge( $fields, [ 'rc_patrolled', 'rc_log_type' ] );
+ }
+ if ( in_array( self::INCLUDE_SIZES, $options['includeFields'] ) ) {
+ $fields = array_merge( $fields, [ 'rc_old_len', 'rc_new_len' ] );
+ }
+ if ( in_array( self::INCLUDE_LOG_INFO, $options['includeFields'] ) ) {
+ $fields = array_merge( $fields, [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ] );
+ }
+
+ return $fields;
+ }
+
+ private function getWatchedItemsWithRCInfoQueryConds(
+ IDatabase $db,
+ User $user,
+ array $options
+ ) {
+ $watchlistOwnerId = $this->getWatchlistOwnerId( $user, $options );
+ $conds = [ 'wl_user' => $watchlistOwnerId ];
+
+ if ( !$options['allRevisions'] ) {
+ $conds[] = $db->makeList(
+ [ 'rc_this_oldid=page_latest', 'rc_type=' . RC_LOG ],
+ LIST_OR
+ );
+ }
+
+ if ( $options['namespaceIds'] ) {
+ $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
+ }
+
+ if ( array_key_exists( 'rcTypes', $options ) ) {
+ $conds['rc_type'] = array_map( 'intval', $options['rcTypes'] );
+ }
+
+ $conds = array_merge(
+ $conds,
+ $this->getWatchedItemsWithRCInfoQueryFilterConds( $user, $options )
+ );
+
+ $conds = array_merge( $conds, $this->getStartEndConds( $db, $options ) );
+
+ if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
+ if ( $db->getType() === 'mysql' ) {
+ // This is an index optimization for mysql
+ $conds[] = 'rc_timestamp > ' . $db->addQuotes( '' );
+ }
+ }
+
+ $conds = array_merge( $conds, $this->getUserRelatedConds( $db, $user, $options ) );
+
+ $deletedPageLogCond = $this->getExtraDeletedPageLogEntryRelatedCond( $db, $user );
+ if ( $deletedPageLogCond ) {
+ $conds[] = $deletedPageLogCond;
+ }
+
+ return $conds;
+ }
+
+ private function getWatchlistOwnerId( User $user, array $options ) {
+ if ( array_key_exists( 'watchlistOwner', $options ) ) {
+ /** @var User $watchlistOwner */
+ $watchlistOwner = $options['watchlistOwner'];
+ $ownersToken = $watchlistOwner->getOption( 'watchlisttoken' );
+ $token = $options['watchlistOwnerToken'];
+ if ( $ownersToken == '' || !hash_equals( $ownersToken, $token ) ) {
+ throw ApiUsageException::newWithMessage( null, 'apierror-bad-watchlist-token', 'bad_wltoken' );
+ }
+ return $watchlistOwner->getId();
+ }
+ return $user->getId();
+ }
+
+ private function getWatchedItemsWithRCInfoQueryFilterConds( User $user, array $options ) {
+ $conds = [];
+
+ if ( in_array( self::FILTER_MINOR, $options['filters'] ) ) {
+ $conds[] = 'rc_minor != 0';
+ } elseif ( in_array( self::FILTER_NOT_MINOR, $options['filters'] ) ) {
+ $conds[] = 'rc_minor = 0';
+ }
+
+ if ( in_array( self::FILTER_BOT, $options['filters'] ) ) {
+ $conds[] = 'rc_bot != 0';
+ } elseif ( in_array( self::FILTER_NOT_BOT, $options['filters'] ) ) {
+ $conds[] = 'rc_bot = 0';
+ }
+
+ if ( in_array( self::FILTER_ANON, $options['filters'] ) ) {
+ $conds[] = 'rc_user = 0';
+ } elseif ( in_array( self::FILTER_NOT_ANON, $options['filters'] ) ) {
+ $conds[] = 'rc_user != 0';
+ }
+
+ if ( $user->useRCPatrol() || $user->useNPPatrol() ) {
+ // TODO: not sure if this should simply ignore patrolled filters if user does not have the patrol
+ // right, or maybe rather fail loud at this point, same as e.g. ApiQueryWatchlist does?
+ if ( in_array( self::FILTER_PATROLLED, $options['filters'] ) ) {
+ $conds[] = 'rc_patrolled != 0';
+ } elseif ( in_array( self::FILTER_NOT_PATROLLED, $options['filters'] ) ) {
+ $conds[] = 'rc_patrolled = 0';
+ }
+ }
+
+ if ( in_array( self::FILTER_UNREAD, $options['filters'] ) ) {
+ $conds[] = 'rc_timestamp >= wl_notificationtimestamp';
+ } elseif ( in_array( self::FILTER_NOT_UNREAD, $options['filters'] ) ) {
+ // TODO: should this be changed to use Database::makeList?
+ $conds[] = 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp';
+ }
+
+ return $conds;
+ }
+
+ private function getStartEndConds( IDatabase $db, array $options ) {
+ if ( !isset( $options['start'] ) && !isset( $options['end'] ) ) {
+ return [];
+ }
+
+ $conds = [];
+
+ if ( isset( $options['start'] ) ) {
+ $after = $options['dir'] === self::DIR_OLDER ? '<=' : '>=';
+ $conds[] = 'rc_timestamp ' . $after . ' ' .
+ $db->addQuotes( $db->timestamp( $options['start'] ) );
+ }
+ if ( isset( $options['end'] ) ) {
+ $before = $options['dir'] === self::DIR_OLDER ? '>=' : '<=';
+ $conds[] = 'rc_timestamp ' . $before . ' ' .
+ $db->addQuotes( $db->timestamp( $options['end'] ) );
+ }
+
+ return $conds;
+ }
+
+ private function getUserRelatedConds( IDatabase $db, User $user, array $options ) {
+ if ( !array_key_exists( 'onlyByUser', $options ) && !array_key_exists( 'notByUser', $options ) ) {
+ return [];
+ }
+
+ $conds = [];
+
+ if ( array_key_exists( 'onlyByUser', $options ) ) {
+ $conds['rc_user_text'] = $options['onlyByUser'];
+ } elseif ( array_key_exists( 'notByUser', $options ) ) {
+ $conds[] = 'rc_user_text != ' . $db->addQuotes( $options['notByUser'] );
+ }
+
+ // Avoid brute force searches (T19342)
+ $bitmask = 0;
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ }
+ if ( $bitmask ) {
+ $conds[] = $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask";
+ }
+
+ return $conds;
+ }
+
+ private function getExtraDeletedPageLogEntryRelatedCond( IDatabase $db, User $user ) {
+ // LogPage::DELETED_ACTION hides the affected page, too. So hide those
+ // entirely from the watchlist, or someone could guess the title.
+ $bitmask = 0;
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = LogPage::DELETED_ACTION;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
+ }
+ if ( $bitmask ) {
+ return $db->makeList( [
+ 'rc_type != ' . RC_LOG,
+ $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
+ ], LIST_OR );
+ }
+ return '';
+ }
+
+ private function getStartFromConds( IDatabase $db, array $options, array $startFrom ) {
+ $op = $options['dir'] === self::DIR_OLDER ? '<' : '>';
+ list( $rcTimestamp, $rcId ) = $startFrom;
+ $rcTimestamp = $db->addQuotes( $db->timestamp( $rcTimestamp ) );
+ $rcId = (int)$rcId;
+ return $db->makeList(
+ [
+ "rc_timestamp $op $rcTimestamp",
+ $db->makeList(
+ [
+ "rc_timestamp = $rcTimestamp",
+ "rc_id $op= $rcId"
+ ],
+ LIST_AND
+ )
+ ],
+ LIST_OR
+ );
+ }
+
+ private function getWatchedItemsForUserQueryConds( IDatabase $db, User $user, array $options ) {
+ $conds = [ 'wl_user' => $user->getId() ];
+ if ( $options['namespaceIds'] ) {
+ $conds['wl_namespace'] = array_map( 'intval', $options['namespaceIds'] );
+ }
+ if ( isset( $options['filter'] ) ) {
+ $filter = $options['filter'];
+ if ( $filter === self::FILTER_CHANGED ) {
+ $conds[] = 'wl_notificationtimestamp IS NOT NULL';
+ } else {
+ $conds[] = 'wl_notificationtimestamp IS NULL';
+ }
+ }
+
+ if ( isset( $options['from'] ) ) {
+ $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
+ $conds[] = $this->getFromUntilTargetConds( $db, $options['from'], $op );
+ }
+ if ( isset( $options['until'] ) ) {
+ $op = $options['sort'] === self::SORT_ASC ? '<' : '>';
+ $conds[] = $this->getFromUntilTargetConds( $db, $options['until'], $op );
+ }
+ if ( isset( $options['startFrom'] ) ) {
+ $op = $options['sort'] === self::SORT_ASC ? '>' : '<';
+ $conds[] = $this->getFromUntilTargetConds( $db, $options['startFrom'], $op );
+ }
+
+ return $conds;
+ }
+
+ /**
+ * Creates a query condition part for getting only items before or after the given link target
+ * (while ordering using $sort mode)
+ *
+ * @param IDatabase $db
+ * @param LinkTarget $target
+ * @param string $op comparison operator to use in the conditions
+ * @return string
+ */
+ private function getFromUntilTargetConds( IDatabase $db, LinkTarget $target, $op ) {
+ return $db->makeList(
+ [
+ "wl_namespace $op " . $target->getNamespace(),
+ $db->makeList(
+ [
+ 'wl_namespace = ' . $target->getNamespace(),
+ "wl_title $op= " . $db->addQuotes( $target->getDBkey() )
+ ],
+ LIST_AND
+ )
+ ],
+ LIST_OR
+ );
+ }
+
+ private function getWatchedItemsWithRCInfoQueryDbOptions( array $options ) {
+ $dbOptions = [];
+
+ if ( array_key_exists( 'dir', $options ) ) {
+ $sort = $options['dir'] === self::DIR_OLDER ? ' DESC' : '';
+ $dbOptions['ORDER BY'] = [ 'rc_timestamp' . $sort, 'rc_id' . $sort ];
+ }
+
+ if ( array_key_exists( 'limit', $options ) ) {
+ $dbOptions['LIMIT'] = (int)$options['limit'] + 1;
+ }
+
+ return $dbOptions;
+ }
+
+ private function getWatchedItemsForUserQueryDbOptions( array $options ) {
+ $dbOptions = [];
+ if ( array_key_exists( 'sort', $options ) ) {
+ $dbOptions['ORDER BY'] = [
+ "wl_namespace {$options['sort']}",
+ "wl_title {$options['sort']}"
+ ];
+ if ( count( $options['namespaceIds'] ) === 1 ) {
+ $dbOptions['ORDER BY'] = "wl_title {$options['sort']}";
+ }
+ }
+ if ( array_key_exists( 'limit', $options ) ) {
+ $dbOptions['LIMIT'] = (int)$options['limit'];
+ }
+ return $dbOptions;
+ }
+
+ private function getWatchedItemsWithRCInfoQueryJoinConds( array $options ) {
+ $joinConds = [
+ 'watchlist' => [ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ]
+ ];
+ if ( !$options['allRevisions'] ) {
+ $joinConds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
+ }
+ if ( in_array( self::INCLUDE_COMMENT, $options['includeFields'] ) ) {
+ $joinConds += $this->getCommentStore()->getJoin()['joins'];
+ }
+ return $joinConds;
+ }
+
+}
diff --git a/www/wiki/includes/WatchedItemQueryServiceExtension.php b/www/wiki/includes/WatchedItemQueryServiceExtension.php
new file mode 100644
index 00000000..93d50330
--- /dev/null
+++ b/www/wiki/includes/WatchedItemQueryServiceExtension.php
@@ -0,0 +1,57 @@
+<?php
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Extension mechanism for WatchedItemQueryService
+ *
+ * @since 1.29
+ *
+ * @file
+ * @ingroup Watchlist
+ *
+ * @license GNU GPL v2+
+ */
+interface WatchedItemQueryServiceExtension {
+
+ /**
+ * Modify the WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+ * query before it's made.
+ *
+ * @warning Any joins added *must* join on a unique key of the target table
+ * unless you really know what you're doing.
+ * @param User $user
+ * @param array $options Options from
+ * WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+ * @param IDatabase $db Database connection being used for the query
+ * @param array &$tables Tables for Database::select()
+ * @param array &$fields Fields for Database::select()
+ * @param array &$conds Conditions for Database::select()
+ * @param array &$dbOptions Options for Database::select()
+ * @param array &$joinConds Join conditions for Database::select()
+ */
+ public function modifyWatchedItemsWithRCInfoQuery( User $user, array $options, IDatabase $db,
+ array &$tables, array &$fields, array &$conds, array &$dbOptions, array &$joinConds
+ );
+
+ /**
+ * Modify the results from WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+ * before they're returned.
+ *
+ * @param User $user
+ * @param array $options Options from
+ * WatchedItemQueryService::getWatchedItemsWithRecentChangeInfo()
+ * @param IDatabase $db Database connection being used for the query
+ * @param array &$items array of pairs ( WatchedItem $watchedItem, string[] $recentChangeInfo ).
+ * May be truncated if necessary, in which case $startFrom must be updated.
+ * @param ResultWrapper|bool $res Database query result
+ * @param array|null &$startFrom Continuation value. If you truncate $items, set this to
+ * [ $recentChangeInfo['rc_timestamp'], $recentChangeInfo['rc_id'] ] from the first item
+ * removed.
+ */
+ public function modifyWatchedItemsWithRCInfo( User $user, array $options, IDatabase $db,
+ array &$items, $res, &$startFrom
+ );
+
+}
diff --git a/www/wiki/includes/WatchedItemStore.php b/www/wiki/includes/WatchedItemStore.php
new file mode 100644
index 00000000..60d8b769
--- /dev/null
+++ b/www/wiki/includes/WatchedItemStore.php
@@ -0,0 +1,986 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Assert\Assert;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\DBUnexpectedError;
+
+/**
+ * Storage layer class for WatchedItems.
+ * Database interaction.
+ *
+ * Uses database because this uses User::isAnon
+ *
+ * @group Database
+ *
+ * @author Addshore
+ * @since 1.27
+ */
+class WatchedItemStore implements StatsdAwareInterface {
+
+ const SORT_DESC = 'DESC';
+ const SORT_ASC = 'ASC';
+
+ /**
+ * @var LoadBalancer
+ */
+ private $loadBalancer;
+
+ /**
+ * @var ReadOnlyMode
+ */
+ private $readOnlyMode;
+
+ /**
+ * @var HashBagOStuff
+ */
+ private $cache;
+
+ /**
+ * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
+ * The index is needed so that on mass changes all relevant items can be un-cached.
+ * For example: Clearing a users watchlist of all items or updating notification timestamps
+ * for all users watching a single target.
+ */
+ private $cacheIndex = [];
+
+ /**
+ * @var callable|null
+ */
+ private $deferredUpdatesAddCallableUpdateCallback;
+
+ /**
+ * @var callable|null
+ */
+ private $revisionGetTimestampFromIdCallback;
+
+ /**
+ * @var StatsdDataFactoryInterface
+ */
+ private $stats;
+
+ /**
+ * @param LoadBalancer $loadBalancer
+ * @param HashBagOStuff $cache
+ * @param ReadOnlyMode $readOnlyMode
+ */
+ public function __construct(
+ LoadBalancer $loadBalancer,
+ HashBagOStuff $cache,
+ ReadOnlyMode $readOnlyMode
+ ) {
+ $this->loadBalancer = $loadBalancer;
+ $this->cache = $cache;
+ $this->readOnlyMode = $readOnlyMode;
+ $this->stats = new NullStatsdDataFactory();
+ $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
+ $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
+ }
+
+ public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
+ $this->stats = $stats;
+ }
+
+ /**
+ * Overrides the DeferredUpdates::addCallableUpdate callback
+ * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+ *
+ * @param callable $callback
+ *
+ * @see DeferredUpdates::addCallableUpdate for callback signiture
+ *
+ * @return ScopedCallback to reset the overridden value
+ * @throws MWException
+ */
+ public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException(
+ 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
+ );
+ }
+ $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
+ $this->deferredUpdatesAddCallableUpdateCallback = $callback;
+ return new ScopedCallback( function () use ( $previousValue ) {
+ $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
+ } );
+ }
+
+ /**
+ * Overrides the Revision::getTimestampFromId callback
+ * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+ *
+ * @param callable $callback
+ * @see Revision::getTimestampFromId for callback signiture
+ *
+ * @return ScopedCallback to reset the overridden value
+ * @throws MWException
+ */
+ public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException(
+ 'Cannot override Revision::getTimestampFromId callback in operation.'
+ );
+ }
+ $previousValue = $this->revisionGetTimestampFromIdCallback;
+ $this->revisionGetTimestampFromIdCallback = $callback;
+ return new ScopedCallback( function () use ( $previousValue ) {
+ $this->revisionGetTimestampFromIdCallback = $previousValue;
+ } );
+ }
+
+ private function getCacheKey( User $user, LinkTarget $target ) {
+ return $this->cache->makeKey(
+ (string)$target->getNamespace(),
+ $target->getDBkey(),
+ (string)$user->getId()
+ );
+ }
+
+ private function cache( WatchedItem $item ) {
+ $user = $item->getUser();
+ $target = $item->getLinkTarget();
+ $key = $this->getCacheKey( $user, $target );
+ $this->cache->set( $key, $item );
+ $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
+ $this->stats->increment( 'WatchedItemStore.cache' );
+ }
+
+ private function uncache( User $user, LinkTarget $target ) {
+ $this->cache->delete( $this->getCacheKey( $user, $target ) );
+ unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
+ $this->stats->increment( 'WatchedItemStore.uncache' );
+ }
+
+ private function uncacheLinkTarget( LinkTarget $target ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
+ if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
+ return;
+ }
+ foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
+ $this->cache->delete( $key );
+ }
+ }
+
+ private function uncacheUser( User $user ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheUser' );
+ foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
+ foreach ( $dbKeyArray as $dbKey => $userArray ) {
+ if ( isset( $userArray[$user->getId()] ) ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
+ $this->cache->delete( $userArray[$user->getId()] );
+ }
+ }
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return WatchedItem|false
+ */
+ private function getCached( User $user, LinkTarget $target ) {
+ return $this->cache->get( $this->getCacheKey( $user, $target ) );
+ }
+
+ /**
+ * Return an array of conditions to select or update the appropriate database
+ * row.
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return array
+ */
+ private function dbCond( User $user, LinkTarget $target ) {
+ return [
+ 'wl_user' => $user->getId(),
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ ];
+ }
+
+ /**
+ * @param int $dbIndex DB_MASTER or DB_REPLICA
+ *
+ * @return IDatabase
+ * @throws MWException
+ */
+ private function getConnectionRef( $dbIndex ) {
+ return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
+ }
+
+ /**
+ * Count the number of individual items that are watched by the user.
+ * If a subject and corresponding talk page are watched this will return 2.
+ *
+ * @param User $user
+ *
+ * @return int
+ */
+ public function countWatchedItems( User $user ) {
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $return = (int)$dbr->selectField(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId()
+ ],
+ __METHOD__
+ );
+
+ return $return;
+ }
+
+ /**
+ * @param LinkTarget $target
+ *
+ * @return int
+ */
+ public function countWatchers( LinkTarget $target ) {
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $return = (int)$dbr->selectField(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ ],
+ __METHOD__
+ );
+
+ return $return;
+ }
+
+ /**
+ * Number of page watchers who also visited a "recent" edit
+ *
+ * @param LinkTarget $target
+ * @param mixed $threshold timestamp accepted by wfTimestamp
+ *
+ * @return int
+ * @throws DBUnexpectedError
+ * @throws MWException
+ */
+ public function countVisitingWatchers( LinkTarget $target, $threshold ) {
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $visitingWatchers = (int)$dbr->selectField(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ 'wl_notificationtimestamp >= ' .
+ $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
+ ' OR wl_notificationtimestamp IS NULL'
+ ],
+ __METHOD__
+ );
+
+ return $visitingWatchers;
+ }
+
+ /**
+ * @param LinkTarget[] $targets
+ * @param array $options Allowed keys:
+ * 'minimumWatchers' => int
+ *
+ * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
+ * All targets will be present in the result. 0 either means no watchers or the number
+ * of watchers was below the minimumWatchers option if passed.
+ */
+ public function countWatchersMultiple( array $targets, array $options = [] ) {
+ $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
+
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+
+ if ( array_key_exists( 'minimumWatchers', $options ) ) {
+ $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
+ }
+
+ $lb = new LinkBatch( $targets );
+ $res = $dbr->select(
+ 'watchlist',
+ [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+ [ $lb->constructSet( 'wl', $dbr ) ],
+ __METHOD__,
+ $dbOptions
+ );
+
+ $watchCounts = [];
+ foreach ( $targets as $linkTarget ) {
+ $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
+ }
+
+ foreach ( $res as $row ) {
+ $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
+ }
+
+ return $watchCounts;
+ }
+
+ /**
+ * Number of watchers of each page who have visited recent edits to that page
+ *
+ * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
+ * $threshold is:
+ * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
+ * - null if $target doesn't exist
+ * @param int|null $minimumWatchers
+ * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
+ * where $watchers is an int:
+ * - if the page exists, number of users watching who have visited the page recently
+ * - if the page doesn't exist, number of users that have the page on their watchlist
+ * - 0 means there are no visiting watchers or their number is below the minimumWatchers
+ * option (if passed).
+ */
+ public function countVisitingWatchersMultiple(
+ array $targetsWithVisitThresholds,
+ $minimumWatchers = null
+ ) {
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+
+ $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
+
+ $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
+ if ( $minimumWatchers !== null ) {
+ $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
+ }
+ $res = $dbr->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $conds,
+ __METHOD__,
+ $dbOptions
+ );
+
+ $watcherCounts = [];
+ foreach ( $targetsWithVisitThresholds as list( $target ) ) {
+ /* @var LinkTarget $target */
+ $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
+ }
+
+ foreach ( $res as $row ) {
+ $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
+ }
+
+ return $watcherCounts;
+ }
+
+ /**
+ * Generates condition for the query used in a batch count visiting watchers.
+ *
+ * @param IDatabase $db
+ * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
+ * @return string
+ */
+ private function getVisitingWatchersCondition(
+ IDatabase $db,
+ array $targetsWithVisitThresholds
+ ) {
+ $missingTargets = [];
+ $namespaceConds = [];
+ foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
+ if ( $threshold === null ) {
+ $missingTargets[] = $target;
+ continue;
+ }
+ /* @var LinkTarget $target */
+ $namespaceConds[$target->getNamespace()][] = $db->makeList( [
+ 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
+ $db->makeList( [
+ 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
+ 'wl_notificationtimestamp IS NULL'
+ ], LIST_OR )
+ ], LIST_AND );
+ }
+
+ $conds = [];
+ foreach ( $namespaceConds as $namespace => $pageConds ) {
+ $conds[] = $db->makeList( [
+ 'wl_namespace = ' . $namespace,
+ '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
+ ], LIST_AND );
+ }
+
+ if ( $missingTargets ) {
+ $lb = new LinkBatch( $missingTargets );
+ $conds[] = $lb->constructSet( 'wl', $db );
+ }
+
+ return $db->makeList( $conds, LIST_OR );
+ }
+
+ /**
+ * Get an item (may be cached)
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return WatchedItem|false
+ */
+ public function getWatchedItem( User $user, LinkTarget $target ) {
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ $cached = $this->getCached( $user, $target );
+ if ( $cached ) {
+ $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
+ return $cached;
+ }
+ $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
+ return $this->loadWatchedItem( $user, $target );
+ }
+
+ /**
+ * Loads an item from the db
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return WatchedItem|false
+ */
+ public function loadWatchedItem( User $user, LinkTarget $target ) {
+ // Only loggedin user can have a watchlist
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ $this->dbCond( $user, $target ),
+ __METHOD__
+ );
+
+ if ( !$row ) {
+ return false;
+ }
+
+ $item = new WatchedItem(
+ $user,
+ $target,
+ wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp )
+ );
+ $this->cache( $item );
+
+ return $item;
+ }
+
+ /**
+ * @param User $user
+ * @param array $options Allowed keys:
+ * 'forWrite' => bool defaults to false
+ * 'sort' => string optional sorting by namespace ID and title
+ * one of the self::SORT_* constants
+ *
+ * @return WatchedItem[]
+ */
+ public function getWatchedItemsForUser( User $user, array $options = [] ) {
+ $options += [ 'forWrite' => false ];
+
+ $dbOptions = [];
+ if ( array_key_exists( 'sort', $options ) ) {
+ Assert::parameter(
+ ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
+ '$options[\'sort\']',
+ 'must be SORT_ASC or SORT_DESC'
+ );
+ $dbOptions['ORDER BY'] = [
+ "wl_namespace {$options['sort']}",
+ "wl_title {$options['sort']}"
+ ];
+ }
+ $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
+
+ $res = $db->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => $user->getId() ],
+ __METHOD__,
+ $dbOptions
+ );
+
+ $watchedItems = [];
+ foreach ( $res as $row ) {
+ // @todo: Should we add these to the process cache?
+ $watchedItems[] = new WatchedItem(
+ $user,
+ new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
+ $row->wl_notificationtimestamp
+ );
+ }
+
+ return $watchedItems;
+ }
+
+ /**
+ * Must be called separately for Subject & Talk namespaces
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return bool
+ */
+ public function isWatched( User $user, LinkTarget $target ) {
+ return (bool)$this->getWatchedItem( $user, $target );
+ }
+
+ /**
+ * @param User $user
+ * @param LinkTarget[] $targets
+ *
+ * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
+ * where $timestamp is:
+ * - string|null value of wl_notificationtimestamp,
+ * - false if $target is not watched by $user.
+ */
+ public function getNotificationTimestampsBatch( User $user, array $targets ) {
+ $timestamps = [];
+ foreach ( $targets as $target ) {
+ $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
+ }
+
+ if ( $user->isAnon() ) {
+ return $timestamps;
+ }
+
+ $targetsToLoad = [];
+ foreach ( $targets as $target ) {
+ $cachedItem = $this->getCached( $user, $target );
+ if ( $cachedItem ) {
+ $timestamps[$target->getNamespace()][$target->getDBkey()] =
+ $cachedItem->getNotificationTimestamp();
+ } else {
+ $targetsToLoad[] = $target;
+ }
+ }
+
+ if ( !$targetsToLoad ) {
+ return $timestamps;
+ }
+
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+
+ $lb = new LinkBatch( $targetsToLoad );
+ $res = $dbr->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ $lb->constructSet( 'wl', $dbr ),
+ 'wl_user' => $user->getId(),
+ ],
+ __METHOD__
+ );
+
+ foreach ( $res as $row ) {
+ $timestamps[$row->wl_namespace][$row->wl_title] =
+ wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp );
+ }
+
+ return $timestamps;
+ }
+
+ /**
+ * Must be called separately for Subject & Talk namespaces
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ */
+ public function addWatch( User $user, LinkTarget $target ) {
+ $this->addWatchBatchForUser( $user, [ $target ] );
+ }
+
+ /**
+ * @param User $user
+ * @param LinkTarget[] $targets
+ *
+ * @return bool success
+ */
+ public function addWatchBatchForUser( User $user, array $targets ) {
+ if ( $this->readOnlyMode->isReadOnly() ) {
+ return false;
+ }
+ // Only loggedin user can have a watchlist
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ if ( !$targets ) {
+ return true;
+ }
+
+ $rows = [];
+ $items = [];
+ foreach ( $targets as $target ) {
+ $rows[] = [
+ 'wl_user' => $user->getId(),
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ 'wl_notificationtimestamp' => null,
+ ];
+ $items[] = new WatchedItem(
+ $user,
+ $target,
+ null
+ );
+ $this->uncache( $user, $target );
+ }
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
+ // Use INSERT IGNORE to avoid overwriting the notification timestamp
+ // if there's already an entry for this page
+ $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
+ }
+ // Update process cache to ensure skin doesn't claim that the current
+ // page is unwatched in the response of action=watch itself (T28292).
+ // This would otherwise be re-queried from a slave by isWatched().
+ foreach ( $items as $item ) {
+ $this->cache( $item );
+ }
+
+ return true;
+ }
+
+ /**
+ * Removes the an entry for the User watching the LinkTarget
+ * Must be called separately for Subject & Talk namespaces
+ *
+ * @param User $user
+ * @param LinkTarget $target
+ *
+ * @return bool success
+ * @throws DBUnexpectedError
+ * @throws MWException
+ */
+ public function removeWatch( User $user, LinkTarget $target ) {
+ // Only logged in user can have a watchlist
+ if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
+ return false;
+ }
+
+ $this->uncache( $user, $target );
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ $dbw->delete( 'watchlist',
+ [
+ 'wl_user' => $user->getId(),
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ ], __METHOD__
+ );
+ $success = (bool)$dbw->affectedRows();
+
+ return $success;
+ }
+
+ /**
+ * @param User $user The user to set the timestamp for
+ * @param string|null $timestamp Set the update timestamp to this value
+ * @param LinkTarget[] $targets List of targets to update. Default to all targets
+ *
+ * @return bool success
+ */
+ public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
+ // Only loggedin user can have a watchlist
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+
+ $conds = [ 'wl_user' => $user->getId() ];
+ if ( $targets ) {
+ $batch = new LinkBatch( $targets );
+ $conds[] = $batch->constructSet( 'wl', $dbw );
+ }
+
+ if ( $timestamp !== null ) {
+ $timestamp = $dbw->timestamp( $timestamp );
+ }
+
+ $success = $dbw->update(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => $timestamp ],
+ $conds,
+ __METHOD__
+ );
+
+ $this->uncacheUser( $user );
+
+ return $success;
+ }
+
+ /**
+ * @param User $editor The editor that triggered the update. Their notification
+ * timestamp will not be updated(they have already seen it)
+ * @param LinkTarget $target The target to update timestamps for
+ * @param string $timestamp Set the update timestamp to this value
+ *
+ * @return int[] Array of user IDs the timestamp has been updated for
+ */
+ public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ $uids = $dbw->selectFieldValues(
+ 'watchlist',
+ 'wl_user',
+ [
+ 'wl_user != ' . intval( $editor->getId() ),
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ 'wl_notificationtimestamp IS NULL',
+ ],
+ __METHOD__
+ );
+
+ $watchers = array_map( 'intval', $uids );
+ if ( $watchers ) {
+ // Update wl_notificationtimestamp for all watching users except the editor
+ $fname = __METHOD__;
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $timestamp, $watchers, $target, $fname ) {
+ global $wgUpdateRowsPerQuery;
+
+ $dbw = $this->getConnectionRef( DB_MASTER );
+ $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
+
+ $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
+ foreach ( $watchersChunks as $watchersChunk ) {
+ $dbw->update( 'watchlist',
+ [ /* SET */
+ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
+ ], [ /* WHERE - TODO Use wl_id T130067 */
+ 'wl_user' => $watchersChunk,
+ 'wl_namespace' => $target->getNamespace(),
+ 'wl_title' => $target->getDBkey(),
+ ], $fname
+ );
+ if ( count( $watchersChunks ) > 1 ) {
+ $factory->commitAndWaitForReplication(
+ __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ]
+ );
+ }
+ }
+ $this->uncacheLinkTarget( $target );
+ },
+ DeferredUpdates::POSTSEND,
+ $dbw
+ );
+ }
+
+ return $watchers;
+ }
+
+ /**
+ * Reset the notification timestamp of this entry
+ *
+ * @param User $user
+ * @param Title $title
+ * @param string $force Whether to force the write query to be executed even if the
+ * page is not watched or the notification timestamp is already NULL.
+ * 'force' in order to force
+ * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
+ *
+ * @return bool success
+ */
+ public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
+ // Only loggedin user can have a watchlist
+ if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
+ return false;
+ }
+
+ $item = null;
+ if ( $force != 'force' ) {
+ $item = $this->loadWatchedItem( $user, $title );
+ if ( !$item || $item->getNotificationTimestamp() === null ) {
+ return false;
+ }
+ }
+
+ // If the page is watched by the user (or may be watched), update the timestamp
+ $job = new ActivityUpdateJob(
+ $title,
+ [
+ 'type' => 'updateWatchlistNotification',
+ 'userid' => $user->getId(),
+ 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
+ 'curTime' => time()
+ ]
+ );
+
+ // Try to run this post-send
+ // Calls DeferredUpdates::addCallableUpdate in normal operation
+ call_user_func(
+ $this->deferredUpdatesAddCallableUpdateCallback,
+ function () use ( $job ) {
+ $job->run();
+ }
+ );
+
+ $this->uncache( $user, $title );
+
+ return true;
+ }
+
+ private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
+ if ( !$oldid ) {
+ // No oldid given, assuming latest revision; clear the timestamp.
+ return null;
+ }
+
+ if ( !$title->getNextRevisionID( $oldid ) ) {
+ // Oldid given and is the latest revision for this title; clear the timestamp.
+ return null;
+ }
+
+ if ( $item === null ) {
+ $item = $this->loadWatchedItem( $user, $title );
+ }
+
+ if ( !$item ) {
+ // This can only happen if $force is enabled.
+ return null;
+ }
+
+ // Oldid given and isn't the latest; update the timestamp.
+ // This will result in no further notification emails being sent!
+ // Calls Revision::getTimestampFromId in normal operation
+ $notificationTimestamp = call_user_func(
+ $this->revisionGetTimestampFromIdCallback,
+ $title,
+ $oldid
+ );
+
+ // We need to go one second to the future because of various strict comparisons
+ // throughout the codebase
+ $ts = new MWTimestamp( $notificationTimestamp );
+ $ts->timestamp->add( new DateInterval( 'PT1S' ) );
+ $notificationTimestamp = $ts->getTimestamp( TS_MW );
+
+ if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
+ if ( $force != 'force' ) {
+ return false;
+ } else {
+ // This is a little silly…
+ return $item->getNotificationTimestamp();
+ }
+ }
+
+ return $notificationTimestamp;
+ }
+
+ /**
+ * @param User $user
+ * @param int $unreadLimit
+ *
+ * @return int|bool The number of unread notifications
+ * true if greater than or equal to $unreadLimit
+ */
+ public function countUnreadNotifications( User $user, $unreadLimit = null ) {
+ $queryOptions = [];
+ if ( $unreadLimit !== null ) {
+ $unreadLimit = (int)$unreadLimit;
+ $queryOptions['LIMIT'] = $unreadLimit;
+ }
+
+ $dbr = $this->getConnectionRef( DB_REPLICA );
+ $rowCount = $dbr->selectRowCount(
+ 'watchlist',
+ '1',
+ [
+ 'wl_user' => $user->getId(),
+ 'wl_notificationtimestamp IS NOT NULL',
+ ],
+ __METHOD__,
+ $queryOptions
+ );
+
+ if ( !isset( $unreadLimit ) ) {
+ return $rowCount;
+ }
+
+ if ( $rowCount >= $unreadLimit ) {
+ return true;
+ }
+
+ return $rowCount;
+ }
+
+ /**
+ * Check if the given title already is watched by the user, and if so
+ * add a watch for the new title.
+ *
+ * To be used for page renames and such.
+ *
+ * @param LinkTarget $oldTarget
+ * @param LinkTarget $newTarget
+ */
+ public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
+ $oldTarget = Title::newFromLinkTarget( $oldTarget );
+ $newTarget = Title::newFromLinkTarget( $newTarget );
+
+ $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
+ $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
+ }
+
+ /**
+ * Check if the given title already is watched by the user, and if so
+ * add a watch for the new title.
+ *
+ * To be used for page renames and such.
+ * This must be called separately for Subject and Talk pages
+ *
+ * @param LinkTarget $oldTarget
+ * @param LinkTarget $newTarget
+ */
+ public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
+ $dbw = $this->getConnectionRef( DB_MASTER );
+
+ $result = $dbw->select(
+ 'watchlist',
+ [ 'wl_user', 'wl_notificationtimestamp' ],
+ [
+ 'wl_namespace' => $oldTarget->getNamespace(),
+ 'wl_title' => $oldTarget->getDBkey(),
+ ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+
+ $newNamespace = $newTarget->getNamespace();
+ $newDBkey = $newTarget->getDBkey();
+
+ # Construct array to replace into the watchlist
+ $values = [];
+ foreach ( $result as $row ) {
+ $values[] = [
+ 'wl_user' => $row->wl_user,
+ 'wl_namespace' => $newNamespace,
+ 'wl_title' => $newDBkey,
+ 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
+ ];
+ }
+
+ if ( !empty( $values ) ) {
+ # Perform replace
+ # Note that multi-row replace is very efficient for MySQL but may be inefficient for
+ # some other DBMSes, mostly due to poor simulation by us
+ $dbw->replace(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ $values,
+ __METHOD__
+ );
+ }
+ }
+
+}
diff --git a/www/wiki/includes/WebRequest.php b/www/wiki/includes/WebRequest.php
new file mode 100644
index 00000000..3d5e372c
--- /dev/null
+++ b/www/wiki/includes/WebRequest.php
@@ -0,0 +1,1329 @@
+<?php
+/**
+ * Deal with importing all those nasty globals and things
+ *
+ * Copyright © 2003 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Session\Session;
+use MediaWiki\Session\SessionId;
+use MediaWiki\Session\SessionManager;
+
+/**
+ * The WebRequest class encapsulates getting at data passed in the
+ * URL or via a POSTed form stripping illegal input characters and
+ * normalizing Unicode sequences.
+ *
+ * @ingroup HTTP
+ */
+class WebRequest {
+ protected $data, $headers = [];
+
+ /**
+ * Flag to make WebRequest::getHeader return an array of values.
+ * @since 1.26
+ */
+ const GETHEADER_LIST = 1;
+
+ /**
+ * The unique request ID.
+ * @var string
+ */
+ private static $reqId;
+
+ /**
+ * Lazy-init response object
+ * @var WebResponse
+ */
+ private $response;
+
+ /**
+ * Cached client IP address
+ * @var string
+ */
+ private $ip;
+
+ /**
+ * The timestamp of the start of the request, with microsecond precision.
+ * @var float
+ */
+ protected $requestTime;
+
+ /**
+ * Cached URL protocol
+ * @var string
+ */
+ protected $protocol;
+
+ /**
+ * @var SessionId|null Session ID to use for this
+ * request. We can't save the session directly due to reference cycles not
+ * working too well (slow GC in Zend and never collected in HHVM).
+ */
+ protected $sessionId = null;
+
+ /** @var bool Whether this HTTP request is "safe" (even if it is an HTTP post) */
+ protected $markedAsSafe = false;
+
+ /**
+ * @codeCoverageIgnore
+ */
+ public function __construct() {
+ $this->requestTime = isset( $_SERVER['REQUEST_TIME_FLOAT'] )
+ ? $_SERVER['REQUEST_TIME_FLOAT'] : microtime( true );
+
+ // POST overrides GET data
+ // We don't use $_REQUEST here to avoid interference from cookies...
+ $this->data = $_POST + $_GET;
+ }
+
+ /**
+ * Extract relevant query arguments from the http request uri's path
+ * to be merged with the normal php provided query arguments.
+ * Tries to use the REQUEST_URI data if available and parses it
+ * according to the wiki's configuration looking for any known pattern.
+ *
+ * If the REQUEST_URI is not provided we'll fall back on the PATH_INFO
+ * provided by the server if any and use that to set a 'title' parameter.
+ *
+ * @param string $want If this is not 'all', then the function
+ * will return an empty array if it determines that the URL is
+ * inside a rewrite path.
+ *
+ * @return array Any query arguments found in path matches.
+ */
+ public static function getPathInfo( $want = 'all' ) {
+ global $wgUsePathInfo;
+ // PATH_INFO is mangled due to https://bugs.php.net/bug.php?id=31892
+ // And also by Apache 2.x, double slashes are converted to single slashes.
+ // So we will use REQUEST_URI if possible.
+ $matches = [];
+ if ( !empty( $_SERVER['REQUEST_URI'] ) ) {
+ // Slurp out the path portion to examine...
+ $url = $_SERVER['REQUEST_URI'];
+ if ( !preg_match( '!^https?://!', $url ) ) {
+ $url = 'http://unused' . $url;
+ }
+ MediaWiki\suppressWarnings();
+ $a = parse_url( $url );
+ MediaWiki\restoreWarnings();
+ if ( $a ) {
+ $path = isset( $a['path'] ) ? $a['path'] : '';
+
+ global $wgScript;
+ if ( $path == $wgScript && $want !== 'all' ) {
+ // Script inside a rewrite path?
+ // Abort to keep from breaking...
+ return $matches;
+ }
+
+ $router = new PathRouter;
+
+ // Raw PATH_INFO style
+ $router->add( "$wgScript/$1" );
+
+ if ( isset( $_SERVER['SCRIPT_NAME'] )
+ && preg_match( '/\.php5?/', $_SERVER['SCRIPT_NAME'] )
+ ) {
+ # Check for SCRIPT_NAME, we handle index.php explicitly
+ # But we do have some other .php files such as img_auth.php
+ # Don't let root article paths clober the parsing for them
+ $router->add( $_SERVER['SCRIPT_NAME'] . "/$1" );
+ }
+
+ global $wgArticlePath;
+ if ( $wgArticlePath ) {
+ $router->add( $wgArticlePath );
+ }
+
+ global $wgActionPaths;
+ if ( $wgActionPaths ) {
+ $router->add( $wgActionPaths, [ 'action' => '$key' ] );
+ }
+
+ global $wgVariantArticlePath, $wgContLang;
+ if ( $wgVariantArticlePath ) {
+ $router->add( $wgVariantArticlePath,
+ [ 'variant' => '$2' ],
+ [ '$2' => $wgContLang->getVariants() ]
+ );
+ }
+
+ Hooks::run( 'WebRequestPathInfoRouter', [ $router ] );
+
+ $matches = $router->parse( $path );
+ }
+ } elseif ( $wgUsePathInfo ) {
+ if ( isset( $_SERVER['ORIG_PATH_INFO'] ) && $_SERVER['ORIG_PATH_INFO'] != '' ) {
+ // Mangled PATH_INFO
+ // https://bugs.php.net/bug.php?id=31892
+ // Also reported when ini_get('cgi.fix_pathinfo')==false
+ $matches['title'] = substr( $_SERVER['ORIG_PATH_INFO'], 1 );
+
+ } elseif ( isset( $_SERVER['PATH_INFO'] ) && $_SERVER['PATH_INFO'] != '' ) {
+ // Regular old PATH_INFO yay
+ $matches['title'] = substr( $_SERVER['PATH_INFO'], 1 );
+ }
+ }
+
+ return $matches;
+ }
+
+ /**
+ * Work out an appropriate URL prefix containing scheme and host, based on
+ * information detected from $_SERVER
+ *
+ * @return string
+ */
+ public static function detectServer() {
+ global $wgAssumeProxiesUseDefaultProtocolPorts;
+
+ $proto = self::detectProtocol();
+ $stdPort = $proto === 'https' ? 443 : 80;
+
+ $varNames = [ 'HTTP_HOST', 'SERVER_NAME', 'HOSTNAME', 'SERVER_ADDR' ];
+ $host = 'localhost';
+ $port = $stdPort;
+ foreach ( $varNames as $varName ) {
+ if ( !isset( $_SERVER[$varName] ) ) {
+ continue;
+ }
+
+ $parts = IP::splitHostAndPort( $_SERVER[$varName] );
+ if ( !$parts ) {
+ // Invalid, do not use
+ continue;
+ }
+
+ $host = $parts[0];
+ if ( $wgAssumeProxiesUseDefaultProtocolPorts && isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) {
+ // T72021: Assume that upstream proxy is running on the default
+ // port based on the protocol. We have no reliable way to determine
+ // the actual port in use upstream.
+ $port = $stdPort;
+ } elseif ( $parts[1] === false ) {
+ if ( isset( $_SERVER['SERVER_PORT'] ) ) {
+ $port = $_SERVER['SERVER_PORT'];
+ } // else leave it as $stdPort
+ } else {
+ $port = $parts[1];
+ }
+ break;
+ }
+
+ return $proto . '://' . IP::combineHostAndPort( $host, $port, $stdPort );
+ }
+
+ /**
+ * Detect the protocol from $_SERVER.
+ * This is for use prior to Setup.php, when no WebRequest object is available.
+ * At other times, use the non-static function getProtocol().
+ *
+ * @return string
+ */
+ public static function detectProtocol() {
+ if ( ( !empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ||
+ ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) &&
+ $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' ) ) {
+ return 'https';
+ } else {
+ return 'http';
+ }
+ }
+
+ /**
+ * Get the number of seconds to have elapsed since request start,
+ * in fractional seconds, with microsecond resolution.
+ *
+ * @return float
+ * @since 1.25
+ */
+ public function getElapsedTime() {
+ return microtime( true ) - $this->requestTime;
+ }
+
+ /**
+ * Get the unique request ID.
+ * This is either the value of the UNIQUE_ID envvar (if present) or a
+ * randomly-generated 24-character string.
+ *
+ * @return string
+ * @since 1.27
+ */
+ public static function getRequestId() {
+ if ( !self::$reqId ) {
+ self::$reqId = isset( $_SERVER['UNIQUE_ID'] )
+ ? $_SERVER['UNIQUE_ID'] : wfRandomString( 24 );
+ }
+
+ return self::$reqId;
+ }
+
+ /**
+ * Override the unique request ID. This is for sub-requests, such as jobs,
+ * that wish to use the same id but are not part of the same execution context.
+ *
+ * @param string $id
+ * @since 1.27
+ */
+ public static function overrideRequestId( $id ) {
+ self::$reqId = $id;
+ }
+
+ /**
+ * Get the current URL protocol (http or https)
+ * @return string
+ */
+ public function getProtocol() {
+ if ( $this->protocol === null ) {
+ $this->protocol = self::detectProtocol();
+ }
+ return $this->protocol;
+ }
+
+ /**
+ * Check for title, action, and/or variant data in the URL
+ * and interpolate it into the GET variables.
+ * This should only be run after $wgContLang is available,
+ * as we may need the list of language variants to determine
+ * available variant URLs.
+ */
+ public function interpolateTitle() {
+ // T18019: title interpolation on API queries is useless and sometimes harmful
+ if ( defined( 'MW_API' ) ) {
+ return;
+ }
+
+ $matches = self::getPathInfo( 'title' );
+ foreach ( $matches as $key => $val ) {
+ $this->data[$key] = $_GET[$key] = $_REQUEST[$key] = $val;
+ }
+ }
+
+ /**
+ * URL rewriting function; tries to extract page title and,
+ * optionally, one other fixed parameter value from a URL path.
+ *
+ * @param string $path The URL path given from the client
+ * @param array $bases One or more URLs, optionally with $1 at the end
+ * @param string|bool $key If provided, the matching key in $bases will be
+ * passed on as the value of this URL parameter
+ * @return array Array of URL variables to interpolate; empty if no match
+ */
+ static function extractTitle( $path, $bases, $key = false ) {
+ foreach ( (array)$bases as $keyValue => $base ) {
+ // Find the part after $wgArticlePath
+ $base = str_replace( '$1', '', $base );
+ $baseLen = strlen( $base );
+ if ( substr( $path, 0, $baseLen ) == $base ) {
+ $raw = substr( $path, $baseLen );
+ if ( $raw !== '' ) {
+ $matches = [ 'title' => rawurldecode( $raw ) ];
+ if ( $key ) {
+ $matches[$key] = $keyValue;
+ }
+ return $matches;
+ }
+ }
+ }
+ return [];
+ }
+
+ /**
+ * Recursively normalizes UTF-8 strings in the given array.
+ *
+ * @param string|array $data
+ * @return array|string Cleaned-up version of the given
+ * @private
+ */
+ public function normalizeUnicode( $data ) {
+ if ( is_array( $data ) ) {
+ foreach ( $data as $key => $val ) {
+ $data[$key] = $this->normalizeUnicode( $val );
+ }
+ } else {
+ global $wgContLang;
+ $data = isset( $wgContLang ) ?
+ $wgContLang->normalize( $data ) :
+ UtfNormal\Validator::cleanUp( $data );
+ }
+ return $data;
+ }
+
+ /**
+ * Fetch a value from the given array or return $default if it's not set.
+ *
+ * @param array $arr
+ * @param string $name
+ * @param mixed $default
+ * @return mixed
+ */
+ private function getGPCVal( $arr, $name, $default ) {
+ # PHP is so nice to not touch input data, except sometimes:
+ # https://secure.php.net/variables.external#language.variables.external.dot-in-names
+ # Work around PHP *feature* to avoid *bugs* elsewhere.
+ $name = strtr( $name, '.', '_' );
+ if ( isset( $arr[$name] ) ) {
+ global $wgContLang;
+ $data = $arr[$name];
+ if ( isset( $_GET[$name] ) && !is_array( $data ) ) {
+ # Check for alternate/legacy character encoding.
+ if ( isset( $wgContLang ) ) {
+ $data = $wgContLang->checkTitleEncoding( $data );
+ }
+ }
+ $data = $this->normalizeUnicode( $data );
+ return $data;
+ } else {
+ return $default;
+ }
+ }
+
+ /**
+ * Fetch a scalar from the input without normalization, or return $default
+ * if it's not set.
+ *
+ * Unlike self::getVal(), this does not perform any normalization on the
+ * input value.
+ *
+ * @since 1.28
+ * @param string $name
+ * @param string|null $default Optional default
+ * @return string|null
+ */
+ public function getRawVal( $name, $default = null ) {
+ $name = strtr( $name, '.', '_' ); // See comment in self::getGPCVal()
+ if ( isset( $this->data[$name] ) && !is_array( $this->data[$name] ) ) {
+ $val = $this->data[$name];
+ } else {
+ $val = $default;
+ }
+ if ( is_null( $val ) ) {
+ return $val;
+ } else {
+ return (string)$val;
+ }
+ }
+
+ /**
+ * Fetch a scalar from the input or return $default if it's not set.
+ * Returns a string. Arrays are discarded. Useful for
+ * non-freeform text inputs (e.g. predefined internal text keys
+ * selected by a drop-down menu). For freeform input, see getText().
+ *
+ * @param string $name
+ * @param string $default Optional default (or null)
+ * @return string|null
+ */
+ public function getVal( $name, $default = null ) {
+ $val = $this->getGPCVal( $this->data, $name, $default );
+ if ( is_array( $val ) ) {
+ $val = $default;
+ }
+ if ( is_null( $val ) ) {
+ return $val;
+ } else {
+ return (string)$val;
+ }
+ }
+
+ /**
+ * Set an arbitrary value into our get/post data.
+ *
+ * @param string $key Key name to use
+ * @param mixed $value Value to set
+ * @return mixed Old value if one was present, null otherwise
+ */
+ public function setVal( $key, $value ) {
+ $ret = isset( $this->data[$key] ) ? $this->data[$key] : null;
+ $this->data[$key] = $value;
+ return $ret;
+ }
+
+ /**
+ * Unset an arbitrary value from our get/post data.
+ *
+ * @param string $key Key name to use
+ * @return mixed Old value if one was present, null otherwise
+ */
+ public function unsetVal( $key ) {
+ if ( !isset( $this->data[$key] ) ) {
+ $ret = null;
+ } else {
+ $ret = $this->data[$key];
+ unset( $this->data[$key] );
+ }
+ return $ret;
+ }
+
+ /**
+ * Fetch an array from the input or return $default if it's not set.
+ * If source was scalar, will return an array with a single element.
+ * If no source and no default, returns null.
+ *
+ * @param string $name
+ * @param array $default Optional default (or null)
+ * @return array|null
+ */
+ public function getArray( $name, $default = null ) {
+ $val = $this->getGPCVal( $this->data, $name, $default );
+ if ( is_null( $val ) ) {
+ return null;
+ } else {
+ return (array)$val;
+ }
+ }
+
+ /**
+ * Fetch an array of integers, or return $default if it's not set.
+ * If source was scalar, will return an array with a single element.
+ * If no source and no default, returns null.
+ * If an array is returned, contents are guaranteed to be integers.
+ *
+ * @param string $name
+ * @param array $default Option default (or null)
+ * @return array Array of ints
+ */
+ public function getIntArray( $name, $default = null ) {
+ $val = $this->getArray( $name, $default );
+ if ( is_array( $val ) ) {
+ $val = array_map( 'intval', $val );
+ }
+ return $val;
+ }
+
+ /**
+ * Fetch an integer value from the input or return $default if not set.
+ * Guaranteed to return an integer; non-numeric input will typically
+ * return 0.
+ *
+ * @param string $name
+ * @param int $default
+ * @return int
+ */
+ public function getInt( $name, $default = 0 ) {
+ return intval( $this->getRawVal( $name, $default ) );
+ }
+
+ /**
+ * Fetch an integer value from the input or return null if empty.
+ * Guaranteed to return an integer or null; non-numeric input will
+ * typically return null.
+ *
+ * @param string $name
+ * @return int|null
+ */
+ public function getIntOrNull( $name ) {
+ $val = $this->getRawVal( $name );
+ return is_numeric( $val )
+ ? intval( $val )
+ : null;
+ }
+
+ /**
+ * Fetch a floating point value from the input or return $default if not set.
+ * Guaranteed to return a float; non-numeric input will typically
+ * return 0.
+ *
+ * @since 1.23
+ * @param string $name
+ * @param float $default
+ * @return float
+ */
+ public function getFloat( $name, $default = 0.0 ) {
+ return floatval( $this->getRawVal( $name, $default ) );
+ }
+
+ /**
+ * Fetch a boolean value from the input or return $default if not set.
+ * Guaranteed to return true or false, with normal PHP semantics for
+ * boolean interpretation of strings.
+ *
+ * @param string $name
+ * @param bool $default
+ * @return bool
+ */
+ public function getBool( $name, $default = false ) {
+ return (bool)$this->getRawVal( $name, $default );
+ }
+
+ /**
+ * Fetch a boolean value from the input or return $default if not set.
+ * Unlike getBool, the string "false" will result in boolean false, which is
+ * useful when interpreting information sent from JavaScript.
+ *
+ * @param string $name
+ * @param bool $default
+ * @return bool
+ */
+ public function getFuzzyBool( $name, $default = false ) {
+ return $this->getBool( $name, $default )
+ && strcasecmp( $this->getRawVal( $name ), 'false' ) !== 0;
+ }
+
+ /**
+ * Return true if the named value is set in the input, whatever that
+ * value is (even "0"). Return false if the named value is not set.
+ * Example use is checking for the presence of check boxes in forms.
+ *
+ * @param string $name
+ * @return bool
+ */
+ public function getCheck( $name ) {
+ # Checkboxes and buttons are only present when clicked
+ # Presence connotes truth, absence false
+ return $this->getRawVal( $name, null ) !== null;
+ }
+
+ /**
+ * Fetch a text string from the given array or return $default if it's not
+ * set. Carriage returns are stripped from the text. This should generally
+ * be used for form "<textarea>" and "<input>" fields, and for
+ * user-supplied freeform text input.
+ *
+ * @param string $name
+ * @param string $default Optional
+ * @return string
+ */
+ public function getText( $name, $default = '' ) {
+ $val = $this->getVal( $name, $default );
+ return str_replace( "\r\n", "\n", $val );
+ }
+
+ /**
+ * Extracts the given named values into an array.
+ * If no arguments are given, returns all input values.
+ * No transformation is performed on the values.
+ *
+ * @return array
+ */
+ public function getValues() {
+ $names = func_get_args();
+ if ( count( $names ) == 0 ) {
+ $names = array_keys( $this->data );
+ }
+
+ $retVal = [];
+ foreach ( $names as $name ) {
+ $value = $this->getGPCVal( $this->data, $name, null );
+ if ( !is_null( $value ) ) {
+ $retVal[$name] = $value;
+ }
+ }
+ return $retVal;
+ }
+
+ /**
+ * Returns the names of all input values excluding those in $exclude.
+ *
+ * @param array $exclude
+ * @return array
+ */
+ public function getValueNames( $exclude = [] ) {
+ return array_diff( array_keys( $this->getValues() ), $exclude );
+ }
+
+ /**
+ * Get the values passed in the query string.
+ * No transformation is performed on the values.
+ *
+ * @codeCoverageIgnore
+ * @return array
+ */
+ public function getQueryValues() {
+ return $_GET;
+ }
+
+ /**
+ * Return the contents of the Query with no decoding. Use when you need to
+ * know exactly what was sent, e.g. for an OAuth signature over the elements.
+ *
+ * @codeCoverageIgnore
+ * @return string
+ */
+ public function getRawQueryString() {
+ return $_SERVER['QUERY_STRING'];
+ }
+
+ /**
+ * Return the contents of the POST with no decoding. Use when you need to
+ * know exactly what was sent, e.g. for an OAuth signature over the elements.
+ *
+ * @return string
+ */
+ public function getRawPostString() {
+ if ( !$this->wasPosted() ) {
+ return '';
+ }
+ return $this->getRawInput();
+ }
+
+ /**
+ * Return the raw request body, with no processing. Cached since some methods
+ * disallow reading the stream more than once. As stated in the php docs, this
+ * does not work with enctype="multipart/form-data".
+ *
+ * @return string
+ */
+ public function getRawInput() {
+ static $input = null;
+ if ( $input === null ) {
+ $input = file_get_contents( 'php://input' );
+ }
+ return $input;
+ }
+
+ /**
+ * Get the HTTP method used for this request.
+ *
+ * @return string
+ */
+ public function getMethod() {
+ return isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : 'GET';
+ }
+
+ /**
+ * Returns true if the present request was reached by a POST operation,
+ * false otherwise (GET, HEAD, or command-line).
+ *
+ * Note that values retrieved by the object may come from the
+ * GET URL etc even on a POST request.
+ *
+ * @return bool
+ */
+ public function wasPosted() {
+ return $this->getMethod() == 'POST';
+ }
+
+ /**
+ * Return the session for this request
+ *
+ * This might unpersist an existing session if it was invalid.
+ *
+ * @since 1.27
+ * @note For performance, keep the session locally if you will be making
+ * much use of it instead of calling this method repeatedly.
+ * @return Session
+ */
+ public function getSession() {
+ if ( $this->sessionId !== null ) {
+ $session = SessionManager::singleton()->getSessionById( (string)$this->sessionId, true, $this );
+ if ( $session ) {
+ return $session;
+ }
+ }
+
+ $session = SessionManager::singleton()->getSessionForRequest( $this );
+ $this->sessionId = $session->getSessionId();
+ return $session;
+ }
+
+ /**
+ * Set the session for this request
+ * @since 1.27
+ * @private For use by MediaWiki\Session classes only
+ * @param SessionId $sessionId
+ */
+ public function setSessionId( SessionId $sessionId ) {
+ $this->sessionId = $sessionId;
+ }
+
+ /**
+ * Get the session id for this request, if any
+ * @since 1.27
+ * @private For use by MediaWiki\Session classes only
+ * @return SessionId|null
+ */
+ public function getSessionId() {
+ return $this->sessionId;
+ }
+
+ /**
+ * Get a cookie from the $_COOKIE jar
+ *
+ * @param string $key The name of the cookie
+ * @param string $prefix A prefix to use for the cookie name, if not $wgCookiePrefix
+ * @param mixed $default What to return if the value isn't found
+ * @return mixed Cookie value or $default if the cookie not set
+ */
+ public function getCookie( $key, $prefix = null, $default = null ) {
+ if ( $prefix === null ) {
+ global $wgCookiePrefix;
+ $prefix = $wgCookiePrefix;
+ }
+ return $this->getGPCVal( $_COOKIE, $prefix . $key, $default );
+ }
+
+ /**
+ * Return the path and query string portion of the main request URI.
+ * This will be suitable for use as a relative link in HTML output.
+ *
+ * @throws MWException
+ * @return string
+ */
+ public static function getGlobalRequestURL() {
+ if ( isset( $_SERVER['REQUEST_URI'] ) && strlen( $_SERVER['REQUEST_URI'] ) ) {
+ $base = $_SERVER['REQUEST_URI'];
+ } elseif ( isset( $_SERVER['HTTP_X_ORIGINAL_URL'] )
+ && strlen( $_SERVER['HTTP_X_ORIGINAL_URL'] )
+ ) {
+ // Probably IIS; doesn't set REQUEST_URI
+ $base = $_SERVER['HTTP_X_ORIGINAL_URL'];
+ } elseif ( isset( $_SERVER['SCRIPT_NAME'] ) ) {
+ $base = $_SERVER['SCRIPT_NAME'];
+ if ( isset( $_SERVER['QUERY_STRING'] ) && $_SERVER['QUERY_STRING'] != '' ) {
+ $base .= '?' . $_SERVER['QUERY_STRING'];
+ }
+ } else {
+ // This shouldn't happen!
+ throw new MWException( "Web server doesn't provide either " .
+ "REQUEST_URI, HTTP_X_ORIGINAL_URL or SCRIPT_NAME. Report details " .
+ "of your web server configuration to https://phabricator.wikimedia.org/" );
+ }
+ // User-agents should not send a fragment with the URI, but
+ // if they do, and the web server passes it on to us, we
+ // need to strip it or we get false-positive redirect loops
+ // or weird output URLs
+ $hash = strpos( $base, '#' );
+ if ( $hash !== false ) {
+ $base = substr( $base, 0, $hash );
+ }
+
+ if ( $base[0] == '/' ) {
+ // More than one slash will look like it is protocol relative
+ return preg_replace( '!^/+!', '/', $base );
+ } else {
+ // We may get paths with a host prepended; strip it.
+ return preg_replace( '!^[^:]+://[^/]+/+!', '/', $base );
+ }
+ }
+
+ /**
+ * Return the path and query string portion of the request URI.
+ * This will be suitable for use as a relative link in HTML output.
+ *
+ * @throws MWException
+ * @return string
+ */
+ public function getRequestURL() {
+ return self::getGlobalRequestURL();
+ }
+
+ /**
+ * Return the request URI with the canonical service and hostname, path,
+ * and query string. This will be suitable for use as an absolute link
+ * in HTML or other output.
+ *
+ * If $wgServer is protocol-relative, this will return a fully
+ * qualified URL with the protocol that was used for this request.
+ *
+ * @return string
+ */
+ public function getFullRequestURL() {
+ return wfExpandUrl( $this->getRequestURL(), PROTO_CURRENT );
+ }
+
+ /**
+ * @param string $key
+ * @param string $value
+ * @return string
+ */
+ public function appendQueryValue( $key, $value ) {
+ return $this->appendQueryArray( [ $key => $value ] );
+ }
+
+ /**
+ * Appends or replaces value of query variables.
+ *
+ * @param array $array Array of values to replace/add to query
+ * @return string
+ */
+ public function appendQueryArray( $array ) {
+ $newquery = $this->getQueryValues();
+ unset( $newquery['title'] );
+ $newquery = array_merge( $newquery, $array );
+
+ return wfArrayToCgi( $newquery );
+ }
+
+ /**
+ * Check for limit and offset parameters on the input, and return sensible
+ * defaults if not given. The limit must be positive and is capped at 5000.
+ * Offset must be positive but is not capped.
+ *
+ * @param int $deflimit Limit to use if no input and the user hasn't set the option.
+ * @param string $optionname To specify an option other than rclimit to pull from.
+ * @return int[] First element is limit, second is offset
+ */
+ public function getLimitOffset( $deflimit = 50, $optionname = 'rclimit' ) {
+ global $wgUser;
+
+ $limit = $this->getInt( 'limit', 0 );
+ if ( $limit < 0 ) {
+ $limit = 0;
+ }
+ if ( ( $limit == 0 ) && ( $optionname != '' ) ) {
+ $limit = $wgUser->getIntOption( $optionname );
+ }
+ if ( $limit <= 0 ) {
+ $limit = $deflimit;
+ }
+ if ( $limit > 5000 ) {
+ $limit = 5000; # We have *some* limits...
+ }
+
+ $offset = $this->getInt( 'offset', 0 );
+ if ( $offset < 0 ) {
+ $offset = 0;
+ }
+
+ return [ $limit, $offset ];
+ }
+
+ /**
+ * Return the path to the temporary file where PHP has stored the upload.
+ *
+ * @param string $key
+ * @return string|null String or null if no such file.
+ */
+ public function getFileTempname( $key ) {
+ $file = new WebRequestUpload( $this, $key );
+ return $file->getTempName();
+ }
+
+ /**
+ * Return the upload error or 0
+ *
+ * @param string $key
+ * @return int
+ */
+ public function getUploadError( $key ) {
+ $file = new WebRequestUpload( $this, $key );
+ return $file->getError();
+ }
+
+ /**
+ * Return the original filename of the uploaded file, as reported by
+ * the submitting user agent. HTML-style character entities are
+ * interpreted and normalized to Unicode normalization form C, in part
+ * to deal with weird input from Safari with non-ASCII filenames.
+ *
+ * Other than this the name is not verified for being a safe filename.
+ *
+ * @param string $key
+ * @return string|null String or null if no such file.
+ */
+ public function getFileName( $key ) {
+ $file = new WebRequestUpload( $this, $key );
+ return $file->getName();
+ }
+
+ /**
+ * Return a WebRequestUpload object corresponding to the key
+ *
+ * @param string $key
+ * @return WebRequestUpload
+ */
+ public function getUpload( $key ) {
+ return new WebRequestUpload( $this, $key );
+ }
+
+ /**
+ * Return a handle to WebResponse style object, for setting cookies,
+ * headers and other stuff, for Request being worked on.
+ *
+ * @return WebResponse
+ */
+ public function response() {
+ /* Lazy initialization of response object for this request */
+ if ( !is_object( $this->response ) ) {
+ $class = ( $this instanceof FauxRequest ) ? 'FauxResponse' : 'WebResponse';
+ $this->response = new $class();
+ }
+ return $this->response;
+ }
+
+ /**
+ * Initialise the header list
+ */
+ protected function initHeaders() {
+ if ( count( $this->headers ) ) {
+ return;
+ }
+
+ $apacheHeaders = function_exists( 'apache_request_headers' ) ? apache_request_headers() : false;
+ if ( $apacheHeaders ) {
+ foreach ( $apacheHeaders as $tempName => $tempValue ) {
+ $this->headers[strtoupper( $tempName )] = $tempValue;
+ }
+ } else {
+ foreach ( $_SERVER as $name => $value ) {
+ if ( substr( $name, 0, 5 ) === 'HTTP_' ) {
+ $name = str_replace( '_', '-', substr( $name, 5 ) );
+ $this->headers[$name] = $value;
+ } elseif ( $name === 'CONTENT_LENGTH' ) {
+ $this->headers['CONTENT-LENGTH'] = $value;
+ }
+ }
+ }
+ }
+
+ /**
+ * Get an array containing all request headers
+ *
+ * @return array Mapping header name to its value
+ */
+ public function getAllHeaders() {
+ $this->initHeaders();
+ return $this->headers;
+ }
+
+ /**
+ * Get a request header, or false if it isn't set.
+ *
+ * @param string $name Case-insensitive header name
+ * @param int $flags Bitwise combination of:
+ * WebRequest::GETHEADER_LIST Treat the header as a comma-separated list
+ * of values, as described in RFC 2616 § 4.2.
+ * (since 1.26).
+ * @return string|array|bool False if header is unset; otherwise the
+ * header value(s) as either a string (the default) or an array, if
+ * WebRequest::GETHEADER_LIST flag was set.
+ */
+ public function getHeader( $name, $flags = 0 ) {
+ $this->initHeaders();
+ $name = strtoupper( $name );
+ if ( !isset( $this->headers[$name] ) ) {
+ return false;
+ }
+ $value = $this->headers[$name];
+ if ( $flags & self::GETHEADER_LIST ) {
+ $value = array_map( 'trim', explode( ',', $value ) );
+ }
+ return $value;
+ }
+
+ /**
+ * Get data from the session
+ *
+ * @note Prefer $this->getSession() instead if making multiple calls.
+ * @param string $key Name of key in the session
+ * @return mixed
+ */
+ public function getSessionData( $key ) {
+ return $this->getSession()->get( $key );
+ }
+
+ /**
+ * Set session data
+ *
+ * @note Prefer $this->getSession() instead if making multiple calls.
+ * @param string $key Name of key in the session
+ * @param mixed $data
+ */
+ public function setSessionData( $key, $data ) {
+ $this->getSession()->set( $key, $data );
+ }
+
+ /**
+ * Check if Internet Explorer will detect an incorrect cache extension in
+ * PATH_INFO or QUERY_STRING. If the request can't be allowed, show an error
+ * message or redirect to a safer URL. Returns true if the URL is OK, and
+ * false if an error message has been shown and the request should be aborted.
+ *
+ * @param array $extWhitelist
+ * @throws HttpError
+ * @return bool
+ */
+ public function checkUrlExtension( $extWhitelist = [] ) {
+ $extWhitelist[] = 'php';
+ if ( IEUrlExtension::areServerVarsBad( $_SERVER, $extWhitelist ) ) {
+ if ( !$this->wasPosted() ) {
+ $newUrl = IEUrlExtension::fixUrlForIE6(
+ $this->getFullRequestURL(), $extWhitelist );
+ if ( $newUrl !== false ) {
+ $this->doSecurityRedirect( $newUrl );
+ return false;
+ }
+ }
+ throw new HttpError( 403,
+ 'Invalid file extension found in the path info or query string.' );
+ }
+ return true;
+ }
+
+ /**
+ * Attempt to redirect to a URL with a QUERY_STRING that's not dangerous in
+ * IE 6. Returns true if it was successful, false otherwise.
+ *
+ * @param string $url
+ * @return bool
+ */
+ protected function doSecurityRedirect( $url ) {
+ header( 'Location: ' . $url );
+ header( 'Content-Type: text/html' );
+ $encUrl = htmlspecialchars( $url );
+ echo <<<HTML
+<!DOCTYPE html>
+<html>
+<head>
+<title>Security redirect</title>
+</head>
+<body>
+<h1>Security redirect</h1>
+<p>
+We can't serve non-HTML content from the URL you have requested, because
+Internet Explorer would interpret it as an incorrect and potentially dangerous
+content type.</p>
+<p>Instead, please use <a href="$encUrl">this URL</a>, which is the same as the
+URL you have requested, except that "&amp;*" is appended. This prevents Internet
+Explorer from seeing a bogus file extension.
+</p>
+</body>
+</html>
+HTML;
+ echo "\n";
+ return true;
+ }
+
+ /**
+ * Parse the Accept-Language header sent by the client into an array
+ *
+ * @return array Array( languageCode => q-value ) sorted by q-value in
+ * descending order then appearing time in the header in ascending order.
+ * May contain the "language" '*', which applies to languages other than those explicitly listed.
+ * This is aligned with rfc2616 section 14.4
+ * Preference for earlier languages appears in rfc3282 as an extension to HTTP/1.1.
+ */
+ public function getAcceptLang() {
+ // Modified version of code found at
+ // http://www.thefutureoftheweb.com/blog/use-accept-language-header
+ $acceptLang = $this->getHeader( 'Accept-Language' );
+ if ( !$acceptLang ) {
+ return [];
+ }
+
+ // Return the language codes in lower case
+ $acceptLang = strtolower( $acceptLang );
+
+ // Break up string into pieces (languages and q factors)
+ $lang_parse = null;
+ preg_match_all(
+ '/([a-z]{1,8}(-[a-z]{1,8})*|\*)\s*(;\s*q\s*=\s*(1(\.0{0,3})?|0(\.[0-9]{0,3})?)?)?/',
+ $acceptLang,
+ $lang_parse
+ );
+
+ if ( !count( $lang_parse[1] ) ) {
+ return [];
+ }
+
+ $langcodes = $lang_parse[1];
+ $qvalues = $lang_parse[4];
+ $indices = range( 0, count( $lang_parse[1] ) - 1 );
+
+ // Set default q factor to 1
+ foreach ( $indices as $index ) {
+ if ( $qvalues[$index] === '' ) {
+ $qvalues[$index] = 1;
+ } elseif ( $qvalues[$index] == 0 ) {
+ unset( $langcodes[$index], $qvalues[$index], $indices[$index] );
+ }
+ }
+
+ // Sort list. First by $qvalues, then by order. Reorder $langcodes the same way
+ array_multisort( $qvalues, SORT_DESC, SORT_NUMERIC, $indices, $langcodes );
+
+ // Create a list like "en" => 0.8
+ $langs = array_combine( $langcodes, $qvalues );
+
+ return $langs;
+ }
+
+ /**
+ * Fetch the raw IP from the request
+ *
+ * @since 1.19
+ *
+ * @throws MWException
+ * @return string
+ */
+ protected function getRawIP() {
+ if ( !isset( $_SERVER['REMOTE_ADDR'] ) ) {
+ return null;
+ }
+
+ if ( is_array( $_SERVER['REMOTE_ADDR'] ) || strpos( $_SERVER['REMOTE_ADDR'], ',' ) !== false ) {
+ throw new MWException( __METHOD__
+ . " : Could not determine the remote IP address due to multiple values." );
+ } else {
+ $ipchain = $_SERVER['REMOTE_ADDR'];
+ }
+
+ return IP::canonicalize( $ipchain );
+ }
+
+ /**
+ * Work out the IP address based on various globals
+ * For trusted proxies, use the XFF client IP (first of the chain)
+ *
+ * @since 1.19
+ *
+ * @throws MWException
+ * @return string
+ */
+ public function getIP() {
+ global $wgUsePrivateIPs;
+
+ # Return cached result
+ if ( $this->ip !== null ) {
+ return $this->ip;
+ }
+
+ # collect the originating ips
+ $ip = $this->getRawIP();
+ if ( !$ip ) {
+ throw new MWException( 'Unable to determine IP.' );
+ }
+
+ # Append XFF
+ $forwardedFor = $this->getHeader( 'X-Forwarded-For' );
+ if ( $forwardedFor !== false ) {
+ $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
+ $isConfigured = $proxyLookup->isConfiguredProxy( $ip );
+ $ipchain = array_map( 'trim', explode( ',', $forwardedFor ) );
+ $ipchain = array_reverse( $ipchain );
+ array_unshift( $ipchain, $ip );
+
+ # Step through XFF list and find the last address in the list which is a
+ # trusted server. Set $ip to the IP address given by that trusted server,
+ # unless the address is not sensible (e.g. private). However, prefer private
+ # IP addresses over proxy servers controlled by this site (more sensible).
+ # Note that some XFF values might be "unknown" with Squid/Varnish.
+ foreach ( $ipchain as $i => $curIP ) {
+ $curIP = IP::sanitizeIP( IP::canonicalize( $curIP ) );
+ if ( !$curIP || !isset( $ipchain[$i + 1] ) || $ipchain[$i + 1] === 'unknown'
+ || !$proxyLookup->isTrustedProxy( $curIP )
+ ) {
+ break; // IP is not valid/trusted or does not point to anything
+ }
+ if (
+ IP::isPublic( $ipchain[$i + 1] ) ||
+ $wgUsePrivateIPs ||
+ $proxyLookup->isConfiguredProxy( $curIP ) // T50919; treat IP as sane
+ ) {
+ // Follow the next IP according to the proxy
+ $nextIP = IP::canonicalize( $ipchain[$i + 1] );
+ if ( !$nextIP && $isConfigured ) {
+ // We have not yet made it past CDN/proxy servers of this site,
+ // so either they are misconfigured or there is some IP spoofing.
+ throw new MWException( "Invalid IP given in XFF '$forwardedFor'." );
+ }
+ $ip = $nextIP;
+ // keep traversing the chain
+ continue;
+ }
+ break;
+ }
+ }
+
+ # Allow extensions to improve our guess
+ Hooks::run( 'GetIP', [ &$ip ] );
+
+ if ( !$ip ) {
+ throw new MWException( "Unable to determine IP." );
+ }
+
+ wfDebug( "IP: $ip\n" );
+ $this->ip = $ip;
+ return $ip;
+ }
+
+ /**
+ * @param string $ip
+ * @return void
+ * @since 1.21
+ */
+ public function setIP( $ip ) {
+ $this->ip = $ip;
+ }
+
+ /**
+ * Check if this request uses a "safe" HTTP method
+ *
+ * Safe methods are verbs (e.g. GET/HEAD/OPTIONS) used for obtaining content. Such requests
+ * are not expected to mutate content, especially in ways attributable to the client. Verbs
+ * like POST and PUT are typical of non-safe requests which often change content.
+ *
+ * @return bool
+ * @see https://tools.ietf.org/html/rfc7231#section-4.2.1
+ * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
+ * @since 1.28
+ */
+ public function hasSafeMethod() {
+ if ( !isset( $_SERVER['REQUEST_METHOD'] ) ) {
+ return false; // CLI mode
+ }
+
+ return in_array( $_SERVER['REQUEST_METHOD'], [ 'GET', 'HEAD', 'OPTIONS', 'TRACE' ] );
+ }
+
+ /**
+ * Whether this request should be identified as being "safe"
+ *
+ * This means that the client is not requesting any state changes and that database writes
+ * are not inherently required. Ideally, no visible updates would happen at all. If they
+ * must, then they should not be publically attributed to the end user.
+ *
+ * In more detail:
+ * - Cache populations and refreshes MAY occur.
+ * - Private user session updates and private server logging MAY occur.
+ * - Updates to private viewing activity data MAY occur via DeferredUpdates.
+ * - Other updates SHOULD NOT occur (e.g. modifying content assets).
+ *
+ * @return bool
+ * @see https://tools.ietf.org/html/rfc7231#section-4.2.1
+ * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
+ * @since 1.28
+ */
+ public function isSafeRequest() {
+ if ( $this->markedAsSafe && $this->wasPosted() ) {
+ return true; // marked as a "safe" POST
+ }
+
+ return $this->hasSafeMethod();
+ }
+
+ /**
+ * Mark this request as identified as being nullipotent even if it is a POST request
+ *
+ * POST requests are often used due to the need for a client payload, even if the request
+ * is otherwise equivalent to a "safe method" request.
+ *
+ * @see https://tools.ietf.org/html/rfc7231#section-4.2.1
+ * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
+ * @since 1.28
+ */
+ public function markAsSafeRequest() {
+ $this->markedAsSafe = true;
+ }
+}
diff --git a/www/wiki/includes/WebRequestUpload.php b/www/wiki/includes/WebRequestUpload.php
new file mode 100644
index 00000000..916a10c9
--- /dev/null
+++ b/www/wiki/includes/WebRequestUpload.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * Object to access the $_FILES array
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Object to access the $_FILES array
+ *
+ * @ingroup HTTP
+ */
+class WebRequestUpload {
+ protected $request;
+ protected $doesExist;
+ protected $fileInfo;
+
+ /**
+ * Constructor. Should only be called by WebRequest
+ *
+ * @param WebRequest $request The associated request
+ * @param string $key Key in $_FILES array (name of form field)
+ */
+ public function __construct( $request, $key ) {
+ $this->request = $request;
+ $this->doesExist = isset( $_FILES[$key] );
+ if ( $this->doesExist ) {
+ $this->fileInfo = $_FILES[$key];
+ }
+ }
+
+ /**
+ * Return whether a file with this name was uploaded.
+ *
+ * @return bool
+ */
+ public function exists() {
+ return $this->doesExist;
+ }
+
+ /**
+ * Return the original filename of the uploaded file
+ *
+ * @return string|null Filename or null if non-existent
+ */
+ public function getName() {
+ if ( !$this->exists() ) {
+ return null;
+ }
+
+ global $wgContLang;
+ $name = $this->fileInfo['name'];
+
+ # Safari sends filenames in HTML-encoded Unicode form D...
+ # Horrid and evil! Let's try to make some kind of sense of it.
+ $name = Sanitizer::decodeCharReferences( $name );
+ $name = $wgContLang->normalize( $name );
+ wfDebug( __METHOD__ . ": {$this->fileInfo['name']} normalized to '$name'\n" );
+ return $name;
+ }
+
+ /**
+ * Return the file size of the uploaded file
+ *
+ * @return int File size or zero if non-existent
+ */
+ public function getSize() {
+ if ( !$this->exists() ) {
+ return 0;
+ }
+
+ return $this->fileInfo['size'];
+ }
+
+ /**
+ * Return the path to the temporary file
+ *
+ * @return string|null Path or null if non-existent
+ */
+ public function getTempName() {
+ if ( !$this->exists() ) {
+ return null;
+ }
+
+ return $this->fileInfo['tmp_name'];
+ }
+
+ /**
+ * Return the upload error. See link for explanation
+ * https://secure.php.net/manual/en/features.file-upload.errors.php
+ *
+ * @return int One of the UPLOAD_ constants, 0 if non-existent
+ */
+ public function getError() {
+ if ( !$this->exists() ) {
+ return 0; # UPLOAD_ERR_OK
+ }
+
+ return $this->fileInfo['error'];
+ }
+
+ /**
+ * Returns whether this upload failed because of overflow of a maximum set
+ * in php.ini
+ *
+ * @return bool
+ */
+ public function isIniSizeOverflow() {
+ if ( $this->getError() == UPLOAD_ERR_INI_SIZE ) {
+ # PHP indicated that upload_max_filesize is exceeded
+ return true;
+ }
+
+ $contentLength = $this->request->getHeader( 'Content-Length' );
+ $maxPostSize = wfShorthandToInteger(
+ ini_get( 'post_max_size' ) ?: ini_get( 'hhvm.server.max_post_size' ),
+ 0
+ );
+
+ if ( $maxPostSize && $contentLength > $maxPostSize ) {
+ # post_max_size is exceeded
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/WebResponse.php b/www/wiki/includes/WebResponse.php
new file mode 100644
index 00000000..0208a72a
--- /dev/null
+++ b/www/wiki/includes/WebResponse.php
@@ -0,0 +1,318 @@
+<?php
+/**
+ * Classes used to send headers and cookies back to the user
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Allow programs to request this object from WebRequest::response()
+ * and handle all outputting (or lack of outputting) via it.
+ * @ingroup HTTP
+ */
+class WebResponse {
+
+ /** @var array Used to record set cookies, because PHP's setcookie() will
+ * happily send an identical Set-Cookie to the client.
+ */
+ protected static $setCookies = [];
+
+ /**
+ * Output an HTTP header, wrapper for PHP's header()
+ * @param string $string Header to output
+ * @param bool $replace Replace current similar header
+ * @param null|int $http_response_code Forces the HTTP response code to the specified value.
+ */
+ public function header( $string, $replace = true, $http_response_code = null ) {
+ \MediaWiki\HeaderCallback::warnIfHeadersSent();
+ if ( $http_response_code ) {
+ header( $string, $replace, $http_response_code );
+ } else {
+ header( $string, $replace );
+ }
+ }
+
+ /**
+ * Get a response header
+ * @param string $key The name of the header to get (case insensitive).
+ * @return string|null The header value (if set); null otherwise.
+ * @since 1.25
+ */
+ public function getHeader( $key ) {
+ foreach ( headers_list() as $header ) {
+ list( $name, $val ) = explode( ':', $header, 2 );
+ if ( !strcasecmp( $name, $key ) ) {
+ return trim( $val );
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Output an HTTP status code header
+ * @since 1.26
+ * @param int $code Status code
+ */
+ public function statusHeader( $code ) {
+ HttpStatus::header( $code );
+ }
+
+ /**
+ * Test if headers have been sent
+ * @since 1.27
+ * @return bool
+ */
+ public function headersSent() {
+ return headers_sent();
+ }
+
+ /**
+ * Set the browser cookie
+ * @param string $name The name of the cookie.
+ * @param string $value The value to be stored in the cookie.
+ * @param int|null $expire Unix timestamp (in seconds) when the cookie should expire.
+ * 0 (the default) causes it to expire $wgCookieExpiration seconds from now.
+ * null causes it to be a session cookie.
+ * @param array $options Assoc of additional cookie options:
+ * prefix: string, name prefix ($wgCookiePrefix)
+ * domain: string, cookie domain ($wgCookieDomain)
+ * path: string, cookie path ($wgCookiePath)
+ * secure: bool, secure attribute ($wgCookieSecure)
+ * httpOnly: bool, httpOnly attribute ($wgCookieHttpOnly)
+ * @since 1.22 Replaced $prefix, $domain, and $forceSecure with $options
+ */
+ public function setCookie( $name, $value, $expire = 0, $options = [] ) {
+ global $wgCookiePath, $wgCookiePrefix, $wgCookieDomain;
+ global $wgCookieSecure, $wgCookieExpiration, $wgCookieHttpOnly;
+
+ $options = array_filter( $options, function ( $a ) {
+ return $a !== null;
+ } ) + [
+ 'prefix' => $wgCookiePrefix,
+ 'domain' => $wgCookieDomain,
+ 'path' => $wgCookiePath,
+ 'secure' => $wgCookieSecure,
+ 'httpOnly' => $wgCookieHttpOnly,
+ 'raw' => false,
+ ];
+
+ if ( $expire === null ) {
+ $expire = 0; // Session cookie
+ } elseif ( $expire == 0 && $wgCookieExpiration != 0 ) {
+ $expire = time() + $wgCookieExpiration;
+ }
+
+ $func = $options['raw'] ? 'setrawcookie' : 'setcookie';
+
+ if ( Hooks::run( 'WebResponseSetCookie', [ &$name, &$value, &$expire, &$options ] ) ) {
+ $cookie = $options['prefix'] . $name;
+ $data = [
+ 'name' => (string)$cookie,
+ 'value' => (string)$value,
+ 'expire' => (int)$expire,
+ 'path' => (string)$options['path'],
+ 'domain' => (string)$options['domain'],
+ 'secure' => (bool)$options['secure'],
+ 'httpOnly' => (bool)$options['httpOnly'],
+ ];
+
+ // Per RFC 6265, key is name + domain + path
+ $key = "{$data['name']}\n{$data['domain']}\n{$data['path']}";
+
+ // If this cookie name was in the request, fake an entry in
+ // self::$setCookies for it so the deleting check works right.
+ if ( isset( $_COOKIE[$cookie] ) && !array_key_exists( $key, self::$setCookies ) ) {
+ self::$setCookies[$key] = [];
+ }
+
+ // PHP deletes if value is the empty string; also, a past expiry is deleting
+ $deleting = ( $data['value'] === '' || $data['expire'] > 0 && $data['expire'] <= time() );
+
+ if ( $deleting && !isset( self::$setCookies[$key] ) ) { // isset( null ) is false
+ wfDebugLog( 'cookie', 'already deleted ' . $func . ': "' . implode( '", "', $data ) . '"' );
+ } elseif ( !$deleting && isset( self::$setCookies[$key] ) &&
+ self::$setCookies[$key] === [ $func, $data ]
+ ) {
+ wfDebugLog( 'cookie', 'already set ' . $func . ': "' . implode( '", "', $data ) . '"' );
+ } else {
+ wfDebugLog( 'cookie', $func . ': "' . implode( '", "', $data ) . '"' );
+ if ( call_user_func_array( $func, array_values( $data ) ) ) {
+ self::$setCookies[$key] = $deleting ? null : [ $func, $data ];
+ }
+ }
+ }
+ }
+
+ /**
+ * Unset a browser cookie.
+ * This sets the cookie with an empty value and an expiry set to a time in the past,
+ * which will cause the browser to remove any cookie with the given name, domain and
+ * path from its cookie store. Options other than these (and prefix) have no effect.
+ * @param string $name Cookie name
+ * @param array $options Cookie options, see {@link setCookie()}
+ * @since 1.27
+ */
+ public function clearCookie( $name, $options = [] ) {
+ $this->setCookie( $name, '', time() - 31536000 /* 1 year */, $options );
+ }
+
+ /**
+ * Checks whether this request is performing cookie operations
+ *
+ * @return bool
+ * @since 1.27
+ */
+ public function hasCookies() {
+ return (bool)self::$setCookies;
+ }
+}
+
+/**
+ * @ingroup HTTP
+ */
+class FauxResponse extends WebResponse {
+ private $headers;
+ private $cookies = [];
+ private $code;
+
+ /**
+ * Stores a HTTP header
+ * @param string $string Header to output
+ * @param bool $replace Replace current similar header
+ * @param null|int $http_response_code Forces the HTTP response code to the specified value.
+ */
+ public function header( $string, $replace = true, $http_response_code = null ) {
+ if ( substr( $string, 0, 5 ) == 'HTTP/' ) {
+ $parts = explode( ' ', $string, 3 );
+ $this->code = intval( $parts[1] );
+ } else {
+ list( $key, $val ) = array_map( 'trim', explode( ":", $string, 2 ) );
+
+ $key = strtoupper( $key );
+
+ if ( $replace || !isset( $this->headers[$key] ) ) {
+ $this->headers[$key] = $val;
+ }
+ }
+
+ if ( $http_response_code !== null ) {
+ $this->code = intval( $http_response_code );
+ }
+ }
+
+ /**
+ * @since 1.26
+ * @param int $code Status code
+ */
+ public function statusHeader( $code ) {
+ $this->code = intval( $code );
+ }
+
+ public function headersSent() {
+ return false;
+ }
+
+ /**
+ * @param string $key The name of the header to get (case insensitive).
+ * @return string|null The header value (if set); null otherwise.
+ */
+ public function getHeader( $key ) {
+ $key = strtoupper( $key );
+
+ if ( isset( $this->headers[$key] ) ) {
+ return $this->headers[$key];
+ }
+ return null;
+ }
+
+ /**
+ * Get the HTTP response code, null if not set
+ *
+ * @return int|null
+ */
+ public function getStatusCode() {
+ return $this->code;
+ }
+
+ /**
+ * @param string $name The name of the cookie.
+ * @param string $value The value to be stored in the cookie.
+ * @param int|null $expire Ignored in this faux subclass.
+ * @param array $options Ignored in this faux subclass.
+ */
+ public function setCookie( $name, $value, $expire = 0, $options = [] ) {
+ global $wgCookiePath, $wgCookiePrefix, $wgCookieDomain;
+ global $wgCookieSecure, $wgCookieExpiration, $wgCookieHttpOnly;
+
+ $options = array_filter( $options, function ( $a ) {
+ return $a !== null;
+ } ) + [
+ 'prefix' => $wgCookiePrefix,
+ 'domain' => $wgCookieDomain,
+ 'path' => $wgCookiePath,
+ 'secure' => $wgCookieSecure,
+ 'httpOnly' => $wgCookieHttpOnly,
+ 'raw' => false,
+ ];
+
+ if ( $expire === null ) {
+ $expire = 0; // Session cookie
+ } elseif ( $expire == 0 && $wgCookieExpiration != 0 ) {
+ $expire = time() + $wgCookieExpiration;
+ }
+
+ $this->cookies[$options['prefix'] . $name] = [
+ 'value' => (string)$value,
+ 'expire' => (int)$expire,
+ 'path' => (string)$options['path'],
+ 'domain' => (string)$options['domain'],
+ 'secure' => (bool)$options['secure'],
+ 'httpOnly' => (bool)$options['httpOnly'],
+ 'raw' => (bool)$options['raw'],
+ ];
+ }
+
+ /**
+ * @param string $name
+ * @return string|null
+ */
+ public function getCookie( $name ) {
+ if ( isset( $this->cookies[$name] ) ) {
+ return $this->cookies[$name]['value'];
+ }
+ return null;
+ }
+
+ /**
+ * @param string $name
+ * @return array|null
+ */
+ public function getCookieData( $name ) {
+ if ( isset( $this->cookies[$name] ) ) {
+ return $this->cookies[$name];
+ }
+ return null;
+ }
+
+ /**
+ * @return array
+ */
+ public function getCookies() {
+ return $this->cookies;
+ }
+}
diff --git a/www/wiki/includes/WebStart.php b/www/wiki/includes/WebStart.php
new file mode 100644
index 00000000..8a58e6f0
--- /dev/null
+++ b/www/wiki/includes/WebStart.php
@@ -0,0 +1,141 @@
+<?php
+/**
+ * This does the initial set up for a web request.
+ *
+ * It does some security checks, loads autoloaders, constants, and
+ * global functions, starts the profiler, loads the configuration,
+ * and loads Setup.php, which loads extensions using the extension
+ * registration system and initializes the application's global state.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+if ( ini_get( 'mbstring.func_overload' ) ) {
+ die( 'MediaWiki does not support installations where mbstring.func_overload is non-zero.' );
+}
+
+# T17461: Make IE8 turn off content sniffing. Everybody else should ignore this
+# We're adding it here so that it's *always* set, even for alternate entry
+# points and when $wgOut gets disabled or overridden.
+header( 'X-Content-Type-Options: nosniff' );
+
+/**
+ * @var float Request start time as fractional seconds since epoch
+ * @deprecated since 1.25; use $_SERVER['REQUEST_TIME_FLOAT'] or
+ * WebRequest::getElapsedTime() instead.
+ */
+$wgRequestTime = $_SERVER['REQUEST_TIME_FLOAT'];
+
+unset( $IP );
+
+# Valid web server entry point, enable includes.
+# Please don't move this line to includes/Defines.php. This line essentially
+# defines a valid entry point. If you put it in includes/Defines.php, then
+# any script that includes it becomes an entry point, thereby defeating
+# its purpose.
+define( 'MEDIAWIKI', true );
+
+# Full path to working directory.
+# Makes it possible to for example to have effective exclude path in apc.
+# __DIR__ breaks symlinked includes, but realpath() returns false
+# if we don't have permissions on parent directories.
+$IP = getenv( 'MW_INSTALL_PATH' );
+if ( $IP === false ) {
+ $IP = realpath( '.' ) ?: dirname( __DIR__ );
+}
+
+require_once "$IP/includes/PreConfigSetup.php";
+
+# Assert that composer dependencies were successfully loaded
+# Purposely no leading \ due to it breaking HHVM RepoAuthorative mode
+# PHP works fine with both versions
+# See https://github.com/facebook/hhvm/issues/5833
+if ( !interface_exists( 'Psr\Log\LoggerInterface' ) ) {
+ $message = (
+ 'MediaWiki requires the <a href="https://github.com/php-fig/log">PSR-3 logging ' .
+ "library</a> to be present. This library is not embedded directly in MediaWiki's " .
+ "git repository and must be installed separately by the end user.\n\n" .
+ 'Please see <a href="https://www.mediawiki.org/wiki/Download_from_Git' .
+ '#Fetch_external_libraries">mediawiki.org</a> for help on installing ' .
+ 'the required components.'
+ );
+ echo $message;
+ trigger_error( $message, E_USER_ERROR );
+ die( 1 );
+}
+
+# Install a header callback
+MediaWiki\HeaderCallback::register();
+
+if ( defined( 'MW_CONFIG_CALLBACK' ) ) {
+ # Use a callback function to configure MediaWiki
+ call_user_func( MW_CONFIG_CALLBACK );
+} else {
+ if ( !defined( 'MW_CONFIG_FILE' ) ) {
+ define( 'MW_CONFIG_FILE', "$IP/LocalSettings.php" );
+ }
+
+ # LocalSettings.php is the per site customization file. If it does not exist
+ # the wiki installer needs to be launched or the generated file uploaded to
+ # the root wiki directory. Give a hint, if it is not readable by the server.
+ if ( !is_readable( MW_CONFIG_FILE ) ) {
+ require_once "$IP/includes/NoLocalSettings.php";
+ die();
+ }
+
+ # Include site settings. $IP may be changed (hopefully before the AutoLoader is invoked)
+ require_once MW_CONFIG_FILE;
+}
+
+# Initialise output buffering
+# Check that there is no previous output or previously set up buffers, because
+# that would cause us to potentially mix gzip and non-gzip output, creating a
+# big mess.
+if ( ob_get_level() == 0 ) {
+ require_once "$IP/includes/OutputHandler.php";
+ ob_start( 'wfOutputHandler' );
+}
+
+require_once "$IP/includes/Setup.php";
+
+# Multiple DBs or commits might be used; keep the request as transactional as possible
+if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] === 'POST' ) {
+ ignore_user_abort( true );
+}
+
+if ( !defined( 'MW_API' ) &&
+ RequestContext::getMain()->getRequest()->getHeader( 'Promise-Non-Write-API-Action' )
+) {
+ header( 'Cache-Control: no-cache' );
+ header( 'Content-Type: text/html; charset=utf-8' );
+ HttpStatus::header( 400 );
+ $error = wfMessage( 'nonwrite-api-promise-error' )->escaped();
+ $content = <<<EOT
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8" /></head>
+<body>
+$error
+</body>
+</html>
+
+EOT;
+ header( 'Content-Length: ' . strlen( $content ) );
+ echo $content;
+ die();
+}
diff --git a/www/wiki/includes/WikiMap.php b/www/wiki/includes/WikiMap.php
new file mode 100644
index 00000000..8bb37b5c
--- /dev/null
+++ b/www/wiki/includes/WikiMap.php
@@ -0,0 +1,261 @@
+<?php
+/**
+ * Tools for dealing with other locally-hosted wikis.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\DatabaseDomain;
+
+/**
+ * Helper tools for dealing with other locally-hosted wikis.
+ */
+class WikiMap {
+
+ /**
+ * Get a WikiReference object for $wikiID
+ *
+ * @param string $wikiID Wiki'd id (generally database name)
+ * @return WikiReference|null WikiReference object or null if the wiki was not found
+ */
+ public static function getWiki( $wikiID ) {
+ $wikiReference = self::getWikiReferenceFromWgConf( $wikiID );
+ if ( $wikiReference ) {
+ return $wikiReference;
+ }
+
+ // Try sites, if $wgConf failed
+ return self::getWikiWikiReferenceFromSites( $wikiID );
+ }
+
+ /**
+ * @param string $wikiID
+ * @return WikiReference|null WikiReference object or null if the wiki was not found
+ */
+ private static function getWikiReferenceFromWgConf( $wikiID ) {
+ global $wgConf;
+
+ $wgConf->loadFullData();
+
+ list( $major, $minor ) = $wgConf->siteFromDB( $wikiID );
+ if ( $major === null ) {
+ return null;
+ }
+ $server = $wgConf->get( 'wgServer', $wikiID, $major,
+ [ 'lang' => $minor, 'site' => $major ] );
+
+ $canonicalServer = $wgConf->get( 'wgCanonicalServer', $wikiID, $major,
+ [ 'lang' => $minor, 'site' => $major ] );
+ if ( $canonicalServer === false || $canonicalServer === null ) {
+ $canonicalServer = $server;
+ }
+
+ $path = $wgConf->get( 'wgArticlePath', $wikiID, $major,
+ [ 'lang' => $minor, 'site' => $major ] );
+
+ // If we don't have a canonical server or a path containing $1, the
+ // WikiReference isn't going to function properly. Just return null in
+ // that case.
+ if ( !is_string( $canonicalServer ) || !is_string( $path ) || strpos( $path, '$1' ) === false ) {
+ return null;
+ }
+
+ return new WikiReference( $canonicalServer, $path, $server );
+ }
+
+ /**
+ * @param string $wikiID
+ * @return WikiReference|null WikiReference object or null if the wiki was not found
+ */
+ private static function getWikiWikiReferenceFromSites( $wikiID ) {
+ $siteLookup = MediaWikiServices::getInstance()->getSiteLookup();
+ $site = $siteLookup->getSite( $wikiID );
+
+ if ( !$site instanceof MediaWikiSite ) {
+ // Abort if not a MediaWikiSite, as this is about Wikis
+ return null;
+ }
+
+ $urlParts = wfParseUrl( $site->getPageUrl() );
+ if ( $urlParts === false || !isset( $urlParts['path'] ) || !isset( $urlParts['host'] ) ) {
+ // We can't create a meaningful WikiReference without URLs
+ return null;
+ }
+
+ // XXX: Check whether path contains a $1?
+ $path = $urlParts['path'];
+ if ( isset( $urlParts['query'] ) ) {
+ $path .= '?' . $urlParts['query'];
+ }
+
+ $canonicalServer = isset( $urlParts['scheme'] ) ? $urlParts['scheme'] : 'http';
+ $canonicalServer .= '://' . $urlParts['host'];
+
+ return new WikiReference( $canonicalServer, $path );
+ }
+
+ /**
+ * Convenience to get the wiki's display name
+ *
+ * @todo We can give more info than just the wiki id!
+ * @param string $wikiID Wiki'd id (generally database name)
+ * @return string|int Wiki's name or $wiki_id if the wiki was not found
+ */
+ public static function getWikiName( $wikiID ) {
+ $wiki = self::getWiki( $wikiID );
+
+ if ( $wiki ) {
+ return $wiki->getDisplayName();
+ }
+ return $wikiID;
+ }
+
+ /**
+ * Convenience to get a link to a user page on a foreign wiki
+ *
+ * @param string $wikiID Wiki'd id (generally database name)
+ * @param string $user User name (must be normalised before calling this function!)
+ * @param string $text Link's text; optional, default to "User:$user"
+ * @return string HTML link or false if the wiki was not found
+ */
+ public static function foreignUserLink( $wikiID, $user, $text = null ) {
+ return self::makeForeignLink( $wikiID, "User:$user", $text );
+ }
+
+ /**
+ * Convenience to get a link to a page on a foreign wiki
+ *
+ * @param string $wikiID Wiki'd id (generally database name)
+ * @param string $page Page name (must be normalised before calling this function!)
+ * @param string $text Link's text; optional, default to $page
+ * @return string|false HTML link or false if the wiki was not found
+ */
+ public static function makeForeignLink( $wikiID, $page, $text = null ) {
+ if ( !$text ) {
+ $text = $page;
+ }
+
+ $url = self::getForeignURL( $wikiID, $page );
+ if ( $url === false ) {
+ return false;
+ }
+
+ return Linker::makeExternalLink( $url, $text );
+ }
+
+ /**
+ * Convenience to get a url to a page on a foreign wiki
+ *
+ * @param string $wikiID Wiki'd id (generally database name)
+ * @param string $page Page name (must be normalised before calling this function!)
+ * @param string|null $fragmentId
+ *
+ * @return string|bool URL or false if the wiki was not found
+ */
+ public static function getForeignURL( $wikiID, $page, $fragmentId = null ) {
+ $wiki = self::getWiki( $wikiID );
+
+ if ( $wiki ) {
+ return $wiki->getFullUrl( $page, $fragmentId );
+ }
+
+ return false;
+ }
+
+ /**
+ * Get canonical server info for all local wikis in the map that have one
+ *
+ * @return array Map of (local wiki ID => map of (url,parts))
+ * @since 1.30
+ */
+ public static function getCanonicalServerInfoForAllWikis() {
+ $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
+
+ return $cache->getWithSetCallback(
+ $cache->makeGlobalKey( 'wikimap', 'canonical-urls' ),
+ $cache::TTL_DAY,
+ function () {
+ global $wgLocalDatabases, $wgCanonicalServer;
+
+ $infoMap = [];
+ // Make sure at least the current wiki is set, for simple configurations.
+ // This also makes it the first in the map, which is useful for common cases.
+ $infoMap[wfWikiID()] = [
+ 'url' => $wgCanonicalServer,
+ 'parts' => wfParseUrl( $wgCanonicalServer )
+ ];
+
+ foreach ( $wgLocalDatabases as $wikiId ) {
+ $wikiReference = self::getWiki( $wikiId );
+ if ( $wikiReference ) {
+ $url = $wikiReference->getCanonicalServer();
+ $infoMap[$wikiId] = [ 'url' => $url, 'parts' => wfParseUrl( $url ) ];
+ }
+ }
+
+ return $infoMap;
+ }
+ );
+ }
+
+ /**
+ * @param string $url
+ * @return bool|string Wiki ID or false
+ * @since 1.30
+ */
+ public static function getWikiFromUrl( $url ) {
+ $urlPartsCheck = wfParseUrl( $url );
+ if ( $urlPartsCheck === false ) {
+ return false;
+ }
+
+ $urlPartsCheck = array_intersect_key( $urlPartsCheck, [ 'host' => 1, 'port' => 1 ] );
+ foreach ( self::getCanonicalServerInfoForAllWikis() as $wikiId => $info ) {
+ $urlParts = $info['parts'];
+ if ( $urlParts === false ) {
+ continue; // sanity
+ }
+
+ $urlParts = array_intersect_key( $urlParts, [ 'host' => 1, 'port' => 1 ] );
+ if ( $urlParts == $urlPartsCheck ) {
+ return $wikiId;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the wiki ID of a database domain
+ *
+ * This is like DatabaseDomain::getId() without encoding (for legacy reasons)
+ *
+ * @param string|DatabaseDomain $domain
+ * @return string
+ */
+ public static function getWikiIdFromDomain( $domain ) {
+ if ( !( $domain instanceof DatabaseDomain ) ) {
+ $domain = DatabaseDomain::newFromId( $domain );
+ }
+
+ return strlen( $domain->getTablePrefix() )
+ ? "{$domain->getDatabase()}-{$domain->getTablePrefix()}"
+ : $domain->getDatabase();
+ }
+}
diff --git a/www/wiki/includes/WikiReference.php b/www/wiki/includes/WikiReference.php
new file mode 100644
index 00000000..724ba980
--- /dev/null
+++ b/www/wiki/includes/WikiReference.php
@@ -0,0 +1,124 @@
+<?php
+/**
+ * Tools for dealing with other locally-hosted wikis.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Reference to a locally-hosted wiki
+ */
+class WikiReference {
+ private $mCanonicalServer; ///< canonical server URL, e.g. 'https://www.mediawiki.org'
+ private $mServer; ///< server URL, may be protocol-relative, e.g. '//www.mediawiki.org'
+ private $mPath; ///< path, '/wiki/$1'
+
+ /**
+ * @param string $canonicalServer
+ * @param string $path
+ * @param null|string $server
+ */
+ public function __construct( $canonicalServer, $path, $server = null ) {
+ $this->mCanonicalServer = $canonicalServer;
+ $this->mPath = $path;
+ $this->mServer = $server === null ? $canonicalServer : $server;
+ }
+
+ /**
+ * Get the URL in a way to be displayed to the user
+ * More or less Wikimedia specific
+ *
+ * @return string
+ */
+ public function getDisplayName() {
+ $parsed = wfParseUrl( $this->mCanonicalServer );
+ if ( $parsed ) {
+ return $parsed['host'];
+ } else {
+ // Invalid server spec.
+ // There's no sane thing to do here, so just return the canonical server name in full.
+ return $this->mCanonicalServer;
+ }
+ }
+
+ /**
+ * Helper function for getUrl()
+ *
+ * @todo FIXME: This may be generalized...
+ *
+ * @param string $page Page name (must be normalised before calling this function!
+ * May contain a section part.)
+ * @param string|null $fragmentId
+ *
+ * @return string relative URL, without the server part.
+ */
+ private function getLocalUrl( $page, $fragmentId = null ) {
+ $page = wfUrlencode( str_replace( ' ', '_', $page ) );
+
+ if ( is_string( $fragmentId ) && $fragmentId !== '' ) {
+ $page .= '#' . wfUrlencode( $fragmentId );
+ }
+
+ return str_replace( '$1', $page, $this->mPath );
+ }
+
+ /**
+ * Get a canonical (i.e. based on $wgCanonicalServer) URL to a page on this foreign wiki
+ *
+ * @param string $page Page name (must be normalised before calling this function!)
+ * @param string|null $fragmentId
+ *
+ * @return string Url
+ */
+ public function getCanonicalUrl( $page, $fragmentId = null ) {
+ return $this->mCanonicalServer . $this->getLocalUrl( $page, $fragmentId );
+ }
+
+ /**
+ * Get a canonical server URL
+ * @return string
+ */
+ public function getCanonicalServer() {
+ return $this->mCanonicalServer;
+ }
+
+ /**
+ * Alias for getCanonicalUrl(), for backwards compatibility.
+ * @param string $page
+ * @param string|null $fragmentId
+ *
+ * @return string
+ */
+ public function getUrl( $page, $fragmentId = null ) {
+ return $this->getCanonicalUrl( $page, $fragmentId );
+ }
+
+ /**
+ * Get a URL based on $wgServer, like Title::getFullURL() would produce
+ * when called locally on the wiki.
+ *
+ * @param string $page Page name (must be normalized before calling this function!)
+ * @param string|null $fragmentId
+ *
+ * @return string URL
+ */
+ public function getFullUrl( $page, $fragmentId = null ) {
+ return $this->mServer .
+ $this->getLocalUrl( $page, $fragmentId );
+ }
+}
diff --git a/www/wiki/includes/Xml.php b/www/wiki/includes/Xml.php
new file mode 100644
index 00000000..00915131
--- /dev/null
+++ b/www/wiki/includes/Xml.php
@@ -0,0 +1,860 @@
+<?php
+/**
+ * Methods to generate XML.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Module of static functions for generating XML
+ */
+class Xml {
+ /**
+ * Format an XML element with given attributes and, optionally, text content.
+ * Element and attribute names are assumed to be ready for literal inclusion.
+ * Strings are assumed to not contain XML-illegal characters; special
+ * characters (<, >, &) are escaped but illegals are not touched.
+ *
+ * @param string $element Element name
+ * @param array $attribs Name=>value pairs. Values will be escaped.
+ * @param string $contents Null to make an open tag only; '' for a contentless closed tag (default)
+ * @param bool $allowShortTag Whether '' in $contents will result in a contentless closed tag
+ * @return string
+ */
+ public static function element( $element, $attribs = null, $contents = '',
+ $allowShortTag = true
+ ) {
+ $out = '<' . $element;
+ if ( !is_null( $attribs ) ) {
+ $out .= self::expandAttributes( $attribs );
+ }
+ if ( is_null( $contents ) ) {
+ $out .= '>';
+ } else {
+ if ( $allowShortTag && $contents === '' ) {
+ $out .= ' />';
+ } else {
+ $out .= '>' . htmlspecialchars( $contents ) . "</$element>";
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Given an array of ('attributename' => 'value'), it generates the code
+ * to set the XML attributes : attributename="value".
+ * The values are passed to Sanitizer::encodeAttribute.
+ * Returns null or empty string if no attributes given.
+ * @param array|null $attribs Array of attributes for an XML element
+ * @throws MWException
+ * @return null|string
+ */
+ public static function expandAttributes( $attribs ) {
+ $out = '';
+ if ( is_null( $attribs ) ) {
+ return null;
+ } elseif ( is_array( $attribs ) ) {
+ foreach ( $attribs as $name => $val ) {
+ $out .= " {$name}=\"" . Sanitizer::encodeAttribute( $val ) . '"';
+ }
+ return $out;
+ } else {
+ throw new MWException( 'Expected attribute array, got something else in ' . __METHOD__ );
+ }
+ }
+
+ /**
+ * Format an XML element as with self::element(), but run text through the
+ * $wgContLang->normalize() validator first to ensure that no invalid UTF-8
+ * is passed.
+ *
+ * @param string $element
+ * @param array $attribs Name=>value pairs. Values will be escaped.
+ * @param string $contents Null to make an open tag only; '' for a contentless closed tag (default)
+ * @return string
+ */
+ public static function elementClean( $element, $attribs = [], $contents = '' ) {
+ global $wgContLang;
+ if ( $attribs ) {
+ $attribs = array_map( [ 'UtfNormal\Validator', 'cleanUp' ], $attribs );
+ }
+ if ( $contents ) {
+ $contents = $wgContLang->normalize( $contents );
+ }
+ return self::element( $element, $attribs, $contents );
+ }
+
+ /**
+ * This opens an XML element
+ *
+ * @param string $element Name of the element
+ * @param array $attribs Array of attributes, see Xml::expandAttributes()
+ * @return string
+ */
+ public static function openElement( $element, $attribs = null ) {
+ return '<' . $element . self::expandAttributes( $attribs ) . '>';
+ }
+
+ /**
+ * Shortcut to close an XML element
+ * @param string $element Element name
+ * @return string
+ */
+ public static function closeElement( $element ) {
+ return "</$element>";
+ }
+
+ /**
+ * Same as Xml::element(), but does not escape contents. Handy when the
+ * content you have is already valid xml.
+ *
+ * @param string $element Element name
+ * @param array $attribs Array of attributes
+ * @param string $contents Content of the element
+ * @return string
+ */
+ public static function tags( $element, $attribs = null, $contents ) {
+ return self::openElement( $element, $attribs ) . $contents . "</$element>";
+ }
+
+ /**
+ * Create a date selector
+ *
+ * @param string $selected The month which should be selected, default ''.
+ * @param string $allmonths Value of a special item denoting all month.
+ * Null to not include (default).
+ * @param string $id Element identifier
+ * @return string Html string containing the month selector
+ */
+ public static function monthSelector( $selected = '', $allmonths = null, $id = 'month' ) {
+ global $wgLang;
+ $options = [];
+ $data = new XmlSelect( 'month', $id, $selected );
+ if ( is_null( $selected ) ) {
+ $selected = '';
+ }
+ if ( !is_null( $allmonths ) ) {
+ $options[wfMessage( 'monthsall' )->text()] = $allmonths;
+ }
+ for ( $i = 1; $i < 13; $i++ ) {
+ $options[$wgLang->getMonthName( $i )] = $i;
+ }
+ $data->addOptions( $options );
+ $data->setAttribute( 'class', 'mw-month-selector' );
+ return $data->getHTML();
+ }
+
+ /**
+ * @param int $year
+ * @param int $month
+ * @return string Formatted HTML
+ */
+ public static function dateMenu( $year, $month ) {
+ # Offset overrides year/month selection
+ if ( $month && $month !== -1 ) {
+ $encMonth = intval( $month );
+ } else {
+ $encMonth = '';
+ }
+ if ( $year ) {
+ $encYear = intval( $year );
+ } elseif ( $encMonth ) {
+ $timestamp = MWTimestamp::getInstance();
+ $thisMonth = intval( $timestamp->format( 'n' ) );
+ $thisYear = intval( $timestamp->format( 'Y' ) );
+ if ( intval( $encMonth ) > $thisMonth ) {
+ $thisYear--;
+ }
+ $encYear = $thisYear;
+ } else {
+ $encYear = '';
+ }
+ $inputAttribs = [ 'id' => 'year', 'maxlength' => 4, 'size' => 7 ];
+ return self::label( wfMessage( 'year' )->text(), 'year' ) . ' ' .
+ Html::input( 'year', $encYear, 'number', $inputAttribs ) . ' ' .
+ self::label( wfMessage( 'month' )->text(), 'month' ) . ' ' .
+ self::monthSelector( $encMonth, -1 );
+ }
+
+ /**
+ * Construct a language selector appropriate for use in a form or preferences
+ *
+ * @param string $selected The language code of the selected language
+ * @param bool $customisedOnly If true only languages which have some content are listed
+ * @param string $inLanguage The ISO code of the language to display the select list in (optional)
+ * @param array $overrideAttrs Override the attributes of the select tag (since 1.20)
+ * @param Message|null $msg Label message key (since 1.20)
+ * @return array Array containing 2 items: label HTML and select list HTML
+ */
+ public static function languageSelector( $selected, $customisedOnly = true,
+ $inLanguage = null, $overrideAttrs = [], Message $msg = null
+ ) {
+ global $wgLanguageCode;
+
+ $include = $customisedOnly ? 'mwfile' : 'mw';
+ $languages = Language::fetchLanguageNames( $inLanguage, $include );
+
+ // Make sure the site language is in the list;
+ // a custom language code might not have a defined name...
+ if ( !array_key_exists( $wgLanguageCode, $languages ) ) {
+ $languages[$wgLanguageCode] = $wgLanguageCode;
+ }
+
+ ksort( $languages );
+
+ /**
+ * If a bogus value is set, default to the content language.
+ * Otherwise, no default is selected and the user ends up
+ * with Afrikaans since it's first in the list.
+ */
+ $selected = isset( $languages[$selected] ) ? $selected : $wgLanguageCode;
+ $options = "\n";
+ foreach ( $languages as $code => $name ) {
+ $options .= self::option( "$code - $name", $code, $code == $selected ) . "\n";
+ }
+
+ $attrs = [ 'id' => 'wpUserLanguage', 'name' => 'wpUserLanguage' ];
+ $attrs = array_merge( $attrs, $overrideAttrs );
+
+ if ( $msg === null ) {
+ $msg = wfMessage( 'yourlanguage' );
+ }
+ return [
+ self::label( $msg->text(), $attrs['id'] ),
+ self::tags( 'select', $attrs, $options )
+ ];
+ }
+
+ /**
+ * Shortcut to make a span element
+ * @param string $text Content of the element, will be escaped
+ * @param string $class Class name of the span element
+ * @param array $attribs Other attributes
+ * @return string
+ */
+ public static function span( $text, $class, $attribs = [] ) {
+ return self::element( 'span', [ 'class' => $class ] + $attribs, $text );
+ }
+
+ /**
+ * Shortcut to make a specific element with a class attribute
+ * @param string $text Content of the element, will be escaped
+ * @param string $class Class name of the span element
+ * @param string $tag Element name
+ * @param array $attribs Other attributes
+ * @return string
+ */
+ public static function wrapClass( $text, $class, $tag = 'span', $attribs = [] ) {
+ return self::tags( $tag, [ 'class' => $class ] + $attribs, $text );
+ }
+
+ /**
+ * Convenience function to build an HTML text input field
+ * @param string $name Value of the name attribute
+ * @param int $size Value of the size attribute
+ * @param mixed $value Value of the value attribute
+ * @param array $attribs Other attributes
+ * @return string HTML
+ */
+ public static function input( $name, $size = false, $value = false, $attribs = [] ) {
+ $attributes = [ 'name' => $name ];
+
+ if ( $size ) {
+ $attributes['size'] = $size;
+ }
+
+ if ( $value !== false ) { // maybe 0
+ $attributes['value'] = $value;
+ }
+
+ return self::element( 'input',
+ Html::getTextInputAttributes( $attributes + $attribs ) );
+ }
+
+ /**
+ * Convenience function to build an HTML password input field
+ * @param string $name Value of the name attribute
+ * @param int $size Value of the size attribute
+ * @param mixed $value Value of the value attribute
+ * @param array $attribs Other attributes
+ * @return string HTML
+ */
+ public static function password( $name, $size = false, $value = false,
+ $attribs = []
+ ) {
+ return self::input( $name, $size, $value,
+ array_merge( $attribs, [ 'type' => 'password' ] ) );
+ }
+
+ /**
+ * Internal function for use in checkboxes and radio buttons and such.
+ *
+ * @param string $name
+ * @param bool $present
+ *
+ * @return array
+ */
+ public static function attrib( $name, $present = true ) {
+ return $present ? [ $name => $name ] : [];
+ }
+
+ /**
+ * Convenience function to build an HTML checkbox
+ * @param string $name Value of the name attribute
+ * @param bool $checked Whether the checkbox is checked or not
+ * @param array $attribs Array other attributes
+ * @return string HTML
+ */
+ public static function check( $name, $checked = false, $attribs = [] ) {
+ return self::element( 'input', array_merge(
+ [
+ 'name' => $name,
+ 'type' => 'checkbox',
+ 'value' => 1 ],
+ self::attrib( 'checked', $checked ),
+ $attribs ) );
+ }
+
+ /**
+ * Convenience function to build an HTML radio button
+ * @param string $name Value of the name attribute
+ * @param string $value Value of the value attribute
+ * @param bool $checked Whether the checkbox is checked or not
+ * @param array $attribs Other attributes
+ * @return string HTML
+ */
+ public static function radio( $name, $value, $checked = false, $attribs = [] ) {
+ return self::element( 'input', [
+ 'name' => $name,
+ 'type' => 'radio',
+ 'value' => $value ] + self::attrib( 'checked', $checked ) + $attribs );
+ }
+
+ /**
+ * Convenience function to build an HTML form label
+ * @param string $label Text of the label
+ * @param string $id
+ * @param array $attribs An attribute array. This will usually be
+ * the same array as is passed to the corresponding input element,
+ * so this function will cherry-pick appropriate attributes to
+ * apply to the label as well; only class and title are applied.
+ * @return string HTML
+ */
+ public static function label( $label, $id, $attribs = [] ) {
+ $a = [ 'for' => $id ];
+
+ foreach ( [ 'class', 'title' ] as $attr ) {
+ if ( isset( $attribs[$attr] ) ) {
+ $a[$attr] = $attribs[$attr];
+ }
+ }
+
+ return self::element( 'label', $a, $label );
+ }
+
+ /**
+ * Convenience function to build an HTML text input field with a label
+ * @param string $label Text of the label
+ * @param string $name Value of the name attribute
+ * @param string $id Id of the input
+ * @param int|bool $size Value of the size attribute
+ * @param string|bool $value Value of the value attribute
+ * @param array $attribs Other attributes
+ * @return string HTML
+ */
+ public static function inputLabel( $label, $name, $id, $size = false,
+ $value = false, $attribs = []
+ ) {
+ list( $label, $input ) = self::inputLabelSep( $label, $name, $id, $size, $value, $attribs );
+ return $label . '&#160;' . $input;
+ }
+
+ /**
+ * Same as Xml::inputLabel() but return input and label in an array
+ *
+ * @param string $label
+ * @param string $name
+ * @param string $id
+ * @param int|bool $size
+ * @param string|bool $value
+ * @param array $attribs
+ *
+ * @return array
+ */
+ public static function inputLabelSep( $label, $name, $id, $size = false,
+ $value = false, $attribs = []
+ ) {
+ return [
+ self::label( $label, $id, $attribs ),
+ self::input( $name, $size, $value, [ 'id' => $id ] + $attribs )
+ ];
+ }
+
+ /**
+ * Convenience function to build an HTML checkbox with a label
+ *
+ * @param string $label
+ * @param string $name
+ * @param string $id
+ * @param bool $checked
+ * @param array $attribs
+ *
+ * @return string HTML
+ */
+ public static function checkLabel( $label, $name, $id, $checked = false, $attribs = [] ) {
+ global $wgUseMediaWikiUIEverywhere;
+ $chkLabel = self::check( $name, $checked, [ 'id' => $id ] + $attribs ) .
+ '&#160;' .
+ self::label( $label, $id, $attribs );
+
+ if ( $wgUseMediaWikiUIEverywhere ) {
+ $chkLabel = self::openElement( 'div', [ 'class' => 'mw-ui-checkbox' ] ) .
+ $chkLabel . self::closeElement( 'div' );
+ }
+ return $chkLabel;
+ }
+
+ /**
+ * Convenience function to build an HTML radio button with a label
+ *
+ * @param string $label
+ * @param string $name
+ * @param string $value
+ * @param string $id
+ * @param bool $checked
+ * @param array $attribs
+ *
+ * @return string HTML
+ */
+ public static function radioLabel( $label, $name, $value, $id,
+ $checked = false, $attribs = []
+ ) {
+ return self::radio( $name, $value, $checked, [ 'id' => $id ] + $attribs ) .
+ '&#160;' .
+ self::label( $label, $id, $attribs );
+ }
+
+ /**
+ * Convenience function to build an HTML submit button
+ * When $wgUseMediaWikiUIEverywhere is true it will default to a progressive button
+ * @param string $value Label text for the button
+ * @param array $attribs Optional custom attributes
+ * @return string HTML
+ */
+ public static function submitButton( $value, $attribs = [] ) {
+ global $wgUseMediaWikiUIEverywhere;
+ $baseAttrs = [
+ 'type' => 'submit',
+ 'value' => $value,
+ ];
+ // Done conditionally for time being as it is possible
+ // some submit forms
+ // might need to be mw-ui-destructive (e.g. delete a page)
+ if ( $wgUseMediaWikiUIEverywhere ) {
+ $baseAttrs['class'] = 'mw-ui-button mw-ui-progressive';
+ }
+ // Any custom attributes will take precendence of anything in baseAttrs e.g. override the class
+ $attribs = $attribs + $baseAttrs;
+ return Html::element( 'input', $attribs );
+ }
+
+ /**
+ * Convenience function to build an HTML drop-down list item.
+ * @param string $text Text for this item. Will be HTML escaped
+ * @param string $value Form submission value; if empty, use text
+ * @param bool $selected If true, will be the default selected item
+ * @param array $attribs Optional additional HTML attributes
+ * @return string HTML
+ */
+ public static function option( $text, $value = null, $selected = false,
+ $attribs = [] ) {
+ if ( !is_null( $value ) ) {
+ $attribs['value'] = $value;
+ }
+ if ( $selected ) {
+ $attribs['selected'] = 'selected';
+ }
+ return Html::element( 'option', $attribs, $text );
+ }
+
+ /**
+ * Build a drop-down box from a textual list. This is a wrapper
+ * for Xml::listDropDownOptions() plus the XmlSelect class.
+ *
+ * @param string $name Name and id for the drop-down
+ * @param string $list Correctly formatted text (newline delimited) to be
+ * used to generate the options.
+ * @param string $other Text for the "Other reasons" option
+ * @param string $selected Option which should be pre-selected
+ * @param string $class CSS classes for the drop-down
+ * @param int $tabindex Value of the tabindex attribute
+ * @return string
+ */
+ public static function listDropDown( $name = '', $list = '', $other = '',
+ $selected = '', $class = '', $tabindex = null
+ ) {
+ $options = self::listDropDownOptions( $list, [ 'other' => $other ] );
+
+ $xmlSelect = new XmlSelect( $name, $name, $selected );
+ $xmlSelect->addOptions( $options );
+
+ if ( $class ) {
+ $xmlSelect->setAttribute( 'class', $class );
+ }
+ if ( $tabindex ) {
+ $xmlSelect->setAttribute( 'tabindex', $tabindex );
+ }
+
+ return $xmlSelect->getHTML();
+ }
+
+ /**
+ * Build options for a drop-down box from a textual list.
+ *
+ * The result of this function can be passed to XmlSelect::addOptions()
+ * (to render a plain `<select>` dropdown box) or to Xml::listDropDownOptionsOoui()
+ * and then OOUI\DropdownInputWidget() (to render a pretty one).
+ *
+ * @param string $list Correctly formatted text (newline delimited) to be
+ * used to generate the options.
+ * @param array $params Extra parameters
+ * @param string $params['other'] If set, add an option with this as text and a value of 'other'
+ * @return array Array keys are textual labels, values are internal values
+ */
+ public static function listDropDownOptions( $list, $params = [] ) {
+ $options = [];
+
+ if ( isset( $params['other'] ) ) {
+ $options[ $params['other'] ] = 'other';
+ }
+
+ $optgroup = false;
+ foreach ( explode( "\n", $list ) as $option ) {
+ $value = trim( $option );
+ if ( $value == '' ) {
+ continue;
+ } elseif ( substr( $value, 0, 1 ) == '*' && substr( $value, 1, 1 ) != '*' ) {
+ # A new group is starting...
+ $value = trim( substr( $value, 1 ) );
+ $optgroup = $value;
+ } elseif ( substr( $value, 0, 2 ) == '**' ) {
+ # groupmember
+ $opt = trim( substr( $value, 2 ) );
+ if ( $optgroup === false ) {
+ $options[$opt] = $opt;
+ } else {
+ $options[$optgroup][$opt] = $opt;
+ }
+ } else {
+ # groupless reason list
+ $optgroup = false;
+ $options[$option] = $option;
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * Convert options for a drop-down box into a format accepted by OOUI\DropdownInputWidget etc.
+ *
+ * TODO Find a better home for this function.
+ *
+ * @param array $options Options, as returned e.g. by Xml::listDropDownOptions()
+ * @return array
+ */
+ public static function listDropDownOptionsOoui( $options ) {
+ $optionsOoui = [];
+
+ foreach ( $options as $text => $value ) {
+ if ( is_array( $value ) ) {
+ $optionsOoui[] = [ 'optgroup' => (string)$text ];
+ foreach ( $value as $text2 => $value2 ) {
+ $optionsOoui[] = [ 'data' => (string)$value2, 'label' => (string)$text2 ];
+ }
+ } else {
+ $optionsOoui[] = [ 'data' => (string)$value, 'label' => (string)$text ];
+ }
+ }
+
+ return $optionsOoui;
+ }
+
+ /**
+ * Shortcut for creating fieldsets.
+ *
+ * @param string|bool $legend Legend of the fieldset. If evaluates to false,
+ * legend is not added.
+ * @param string $content Pre-escaped content for the fieldset. If false,
+ * only open fieldset is returned.
+ * @param array $attribs Any attributes to fieldset-element.
+ *
+ * @return string
+ */
+ public static function fieldset( $legend = false, $content = false, $attribs = [] ) {
+ $s = self::openElement( 'fieldset', $attribs ) . "\n";
+
+ if ( $legend ) {
+ $s .= self::element( 'legend', null, $legend ) . "\n";
+ }
+
+ if ( $content !== false ) {
+ $s .= $content . "\n";
+ $s .= self::closeElement( 'fieldset' ) . "\n";
+ }
+
+ return $s;
+ }
+
+ /**
+ * Shortcut for creating textareas.
+ *
+ * @param string $name The 'name' for the textarea
+ * @param string $content Content for the textarea
+ * @param int $cols The number of columns for the textarea
+ * @param int $rows The number of rows for the textarea
+ * @param array $attribs Any other attributes for the textarea
+ *
+ * @return string
+ */
+ public static function textarea( $name, $content, $cols = 40, $rows = 5, $attribs = [] ) {
+ return self::element( 'textarea',
+ Html::getTextInputAttributes(
+ [
+ 'name' => $name,
+ 'id' => $name,
+ 'cols' => $cols,
+ 'rows' => $rows
+ ] + $attribs
+ ),
+ $content, false );
+ }
+
+ /**
+ * Encode a variable of arbitrary type to JavaScript.
+ * If the value is an XmlJsCode object, pass through the object's value verbatim.
+ *
+ * @note Only use this function for generating JavaScript code. If generating output
+ * for a proper JSON parser, just call FormatJson::encode() directly.
+ *
+ * @param mixed $value The value being encoded. Can be any type except a resource.
+ * @param bool $pretty If true, add non-significant whitespace to improve readability.
+ * @return string|bool String if successful; false upon failure
+ */
+ public static function encodeJsVar( $value, $pretty = false ) {
+ if ( $value instanceof XmlJsCode ) {
+ return $value->value;
+ }
+ return FormatJson::encode( $value, $pretty, FormatJson::UTF8_OK );
+ }
+
+ /**
+ * Create a call to a JavaScript function. The supplied arguments will be
+ * encoded using Xml::encodeJsVar().
+ *
+ * @since 1.17
+ * @param string $name The name of the function to call, or a JavaScript expression
+ * which evaluates to a function object which is called.
+ * @param array $args The arguments to pass to the function.
+ * @param bool $pretty If true, add non-significant whitespace to improve readability.
+ * @return string|bool String if successful; false upon failure
+ */
+ public static function encodeJsCall( $name, $args, $pretty = false ) {
+ foreach ( $args as &$arg ) {
+ $arg = self::encodeJsVar( $arg, $pretty );
+ if ( $arg === false ) {
+ return false;
+ }
+ }
+
+ return "$name(" . ( $pretty
+ ? ( ' ' . implode( ', ', $args ) . ' ' )
+ : implode( ',', $args )
+ ) . ");";
+ }
+
+ /**
+ * Check if a string is well-formed XML.
+ * Must include the surrounding tag.
+ * This function is a DoS vector if an attacker can define
+ * entities in $text.
+ *
+ * @param string $text String to test.
+ * @return bool
+ *
+ * @todo Error position reporting return
+ */
+ private static function isWellFormed( $text ) {
+ $parser = xml_parser_create( "UTF-8" );
+
+ # case folding violates XML standard, turn it off
+ xml_parser_set_option( $parser, XML_OPTION_CASE_FOLDING, false );
+
+ if ( !xml_parse( $parser, $text, true ) ) {
+ // $err = xml_error_string( xml_get_error_code( $parser ) );
+ // $position = xml_get_current_byte_index( $parser );
+ // $fragment = $this->extractFragment( $html, $position );
+ // $this->mXmlError = "$err at byte $position:\n$fragment";
+ xml_parser_free( $parser );
+ return false;
+ }
+
+ xml_parser_free( $parser );
+
+ return true;
+ }
+
+ /**
+ * Check if a string is a well-formed XML fragment.
+ * Wraps fragment in an \<html\> bit and doctype, so it can be a fragment
+ * and can use HTML named entities.
+ *
+ * @param string $text
+ * @return bool
+ */
+ public static function isWellFormedXmlFragment( $text ) {
+ $html =
+ Sanitizer::hackDocType() .
+ '<html>' .
+ $text .
+ '</html>';
+
+ return self::isWellFormed( $html );
+ }
+
+ /**
+ * Replace " > and < with their respective HTML entities ( &quot;,
+ * &gt;, &lt;)
+ *
+ * @param string $in Text that might contain HTML tags.
+ * @return string Escaped string
+ */
+ public static function escapeTagsOnly( $in ) {
+ return str_replace(
+ [ '"', '>', '<' ],
+ [ '&quot;', '&gt;', '&lt;' ],
+ $in );
+ }
+
+ /**
+ * Generate a form (without the opening form element).
+ * Output optionally includes a submit button.
+ * @param array $fields Associative array, key is the name of a message that
+ * contains a description for the field, value is an HTML string
+ * containing the appropriate input.
+ * @param string $submitLabel The name of a message containing a label for
+ * the submit button.
+ * @param array $submitAttribs The attributes to add to the submit button
+ * @return string HTML form.
+ */
+ public static function buildForm( $fields, $submitLabel = null, $submitAttribs = [] ) {
+ $form = '';
+ $form .= "<table><tbody>";
+
+ foreach ( $fields as $labelmsg => $input ) {
+ $id = "mw-$labelmsg";
+ $form .= self::openElement( 'tr', [ 'id' => $id ] );
+
+ // TODO use a <label> here for accessibility purposes - will need
+ // to either not use a table to build the form, or find the ID of
+ // the input somehow.
+
+ $form .= self::tags( 'td', [ 'class' => 'mw-label' ], wfMessage( $labelmsg )->parse() );
+ $form .= self::openElement( 'td', [ 'class' => 'mw-input' ] )
+ . $input . self::closeElement( 'td' );
+ $form .= self::closeElement( 'tr' );
+ }
+
+ if ( $submitLabel ) {
+ $form .= self::openElement( 'tr' );
+ $form .= self::tags( 'td', [], '' );
+ $form .= self::openElement( 'td', [ 'class' => 'mw-submit' ] )
+ . self::submitButton( wfMessage( $submitLabel )->text(), $submitAttribs )
+ . self::closeElement( 'td' );
+ $form .= self::closeElement( 'tr' );
+ }
+
+ $form .= "</tbody></table>";
+
+ return $form;
+ }
+
+ /**
+ * Build a table of data
+ * @param array $rows An array of arrays of strings, each to be a row in a table
+ * @param array $attribs An array of attributes to apply to the table tag [optional]
+ * @param array $headers An array of strings to use as table headers [optional]
+ * @return string
+ */
+ public static function buildTable( $rows, $attribs = [], $headers = null ) {
+ $s = self::openElement( 'table', $attribs );
+
+ if ( is_array( $headers ) ) {
+ $s .= self::openElement( 'thead', $attribs );
+
+ foreach ( $headers as $id => $header ) {
+ $attribs = [];
+
+ if ( is_string( $id ) ) {
+ $attribs['id'] = $id;
+ }
+
+ $s .= self::element( 'th', $attribs, $header );
+ }
+ $s .= self::closeElement( 'thead' );
+ }
+
+ foreach ( $rows as $id => $row ) {
+ $attribs = [];
+
+ if ( is_string( $id ) ) {
+ $attribs['id'] = $id;
+ }
+
+ $s .= self::buildTableRow( $attribs, $row );
+ }
+
+ $s .= self::closeElement( 'table' );
+
+ return $s;
+ }
+
+ /**
+ * Build a row for a table
+ * @param array $attribs An array of attributes to apply to the tr tag
+ * @param array $cells An array of strings to put in <td>
+ * @return string
+ */
+ public static function buildTableRow( $attribs, $cells ) {
+ $s = self::openElement( 'tr', $attribs );
+
+ foreach ( $cells as $id => $cell ) {
+ $attribs = [];
+
+ if ( is_string( $id ) ) {
+ $attribs['id'] = $id;
+ }
+
+ $s .= self::element( 'td', $attribs, $cell );
+ }
+
+ $s .= self::closeElement( 'tr' );
+
+ return $s;
+ }
+}
diff --git a/www/wiki/includes/XmlJsCode.php b/www/wiki/includes/XmlJsCode.php
new file mode 100644
index 00000000..1b90a1f2
--- /dev/null
+++ b/www/wiki/includes/XmlJsCode.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A wrapper class which causes Xml::encodeJsVar() and Xml::encodeJsCall() to
+ * interpret a given string as being a JavaScript expression, instead of string
+ * data.
+ *
+ * @par Example:
+ * @code
+ * Xml::encodeJsVar( new XmlJsCode( 'a + b' ) );
+ * @endcode
+ *
+ * This returns "a + b".
+ *
+ * @note As of 1.21, XmlJsCode objects cannot be nested inside objects or arrays. The sole
+ * exception is the $args argument to Xml::encodeJsCall() because Xml::encodeJsVar() is
+ * called for each individual element in that array.
+ *
+ * @since 1.17
+ */
+class XmlJsCode {
+ public $value;
+
+ function __construct( $value ) {
+ $this->value = $value;
+ }
+}
diff --git a/www/wiki/includes/XmlSelect.php b/www/wiki/includes/XmlSelect.php
new file mode 100644
index 00000000..89f2f41c
--- /dev/null
+++ b/www/wiki/includes/XmlSelect.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Class for generating HTML <select> elements.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class for generating HTML <select> or <datalist> elements.
+ */
+class XmlSelect {
+ protected $options = [];
+ protected $default = false;
+ protected $tagName = 'select';
+ protected $attributes = [];
+
+ public function __construct( $name = false, $id = false, $default = false ) {
+ if ( $name ) {
+ $this->setAttribute( 'name', $name );
+ }
+
+ if ( $id ) {
+ $this->setAttribute( 'id', $id );
+ }
+
+ if ( $default !== false ) {
+ $this->default = $default;
+ }
+ }
+
+ /**
+ * @param string|array $default
+ */
+ public function setDefault( $default ) {
+ $this->default = $default;
+ }
+
+ /**
+ * @param string|array $tagName
+ */
+ public function setTagName( $tagName ) {
+ $this->tagName = $tagName;
+ }
+
+ /**
+ * @param string $name
+ * @param string $value
+ */
+ public function setAttribute( $name, $value ) {
+ $this->attributes[$name] = $value;
+ }
+
+ /**
+ * @param string $name
+ * @return string|null
+ */
+ public function getAttribute( $name ) {
+ if ( isset( $this->attributes[$name] ) ) {
+ return $this->attributes[$name];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param string $label
+ * @param string $value If not given, assumed equal to $label
+ */
+ public function addOption( $label, $value = false ) {
+ $value = $value !== false ? $value : $label;
+ $this->options[] = [ $label => $value ];
+ }
+
+ /**
+ * This accepts an array of form
+ * label => value
+ * label => ( label => value, label => value )
+ *
+ * @param array $options
+ */
+ public function addOptions( $options ) {
+ $this->options[] = $options;
+ }
+
+ /**
+ * This accepts an array of form:
+ * label => value
+ * label => ( label => value, label => value )
+ *
+ * @param array $options
+ * @param string|array $default
+ * @return string
+ */
+ static function formatOptions( $options, $default = false ) {
+ $data = '';
+
+ foreach ( $options as $label => $value ) {
+ if ( is_array( $value ) ) {
+ $contents = self::formatOptions( $value, $default );
+ $data .= Html::rawElement( 'optgroup', [ 'label' => $label ], $contents ) . "\n";
+ } else {
+ // If $default is an array, then the <select> probably has the multiple attribute,
+ // so we should check if each $value is in $default, rather than checking if
+ // $value is equal to $default.
+ $selected = is_array( $default ) ? in_array( $value, $default ) : $value === $default;
+ $data .= Xml::option( $label, $value, $selected ) . "\n";
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @return string
+ */
+ public function getHTML() {
+ $contents = '';
+
+ foreach ( $this->options as $options ) {
+ $contents .= self::formatOptions( $options, $this->default );
+ }
+
+ return Html::rawElement( $this->tagName, $this->attributes, rtrim( $contents ) );
+ }
+}
diff --git a/www/wiki/includes/actions/Action.php b/www/wiki/includes/actions/Action.php
new file mode 100644
index 00000000..e8d9a3e4
--- /dev/null
+++ b/www/wiki/includes/actions/Action.php
@@ -0,0 +1,430 @@
+<?php
+/**
+ * Base classes for actions done on pages.
+ *
+ * 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
+ *
+ * @file
+ */
+
+/**
+ * @defgroup Actions Action done on pages
+ */
+
+/**
+ * Actions are things which can be done to pages (edit, delete, rollback, etc). They
+ * are distinct from Special Pages because an action must apply to exactly one page.
+ *
+ * To add an action in an extension, create a subclass of Action, and add the key to
+ * $wgActions. There is also the deprecated UnknownAction hook
+ *
+ * Actions generally fall into two groups: the show-a-form-then-do-something-with-the-input
+ * format (protect, delete, move, etc), and the just-do-something format (watch, rollback,
+ * patrol, etc). The FormAction and FormlessAction classes represent these two groups.
+ */
+abstract class Action implements MessageLocalizer {
+
+ /**
+ * Page on which we're performing the action
+ * @since 1.17
+ * @var WikiPage|Article|ImagePage|CategoryPage|Page $page
+ */
+ protected $page;
+
+ /**
+ * IContextSource if specified; otherwise we'll use the Context from the Page
+ * @since 1.17
+ * @var IContextSource $context
+ */
+ protected $context;
+
+ /**
+ * The fields used to create the HTMLForm
+ * @since 1.17
+ * @var array $fields
+ */
+ protected $fields;
+
+ /**
+ * Get the Action subclass which should be used to handle this action, false if
+ * the action is disabled, or null if it's not recognised
+ * @param string $action
+ * @param array $overrides
+ * @return bool|null|string|callable|Action
+ */
+ final private static function getClass( $action, array $overrides ) {
+ global $wgActions;
+ $action = strtolower( $action );
+
+ if ( !isset( $wgActions[$action] ) ) {
+ return null;
+ }
+
+ if ( $wgActions[$action] === false ) {
+ return false;
+ } elseif ( $wgActions[$action] === true && isset( $overrides[$action] ) ) {
+ return $overrides[$action];
+ } elseif ( $wgActions[$action] === true ) {
+ return ucfirst( $action ) . 'Action';
+ } else {
+ return $wgActions[$action];
+ }
+ }
+
+ /**
+ * Get an appropriate Action subclass for the given action
+ * @since 1.17
+ * @param string $action
+ * @param Page $page
+ * @param IContextSource|null $context
+ * @return Action|bool|null False if the action is disabled, null
+ * if it is not recognised
+ */
+ final public static function factory( $action, Page $page, IContextSource $context = null ) {
+ $classOrCallable = self::getClass( $action, $page->getActionOverrides() );
+
+ if ( is_string( $classOrCallable ) ) {
+ if ( !class_exists( $classOrCallable ) ) {
+ return false;
+ }
+ $obj = new $classOrCallable( $page, $context );
+ return $obj;
+ }
+
+ if ( is_callable( $classOrCallable ) ) {
+ return call_user_func_array( $classOrCallable, [ $page, $context ] );
+ }
+
+ return $classOrCallable;
+ }
+
+ /**
+ * Get the action that will be executed, not necessarily the one passed
+ * passed through the "action" request parameter. Actions disabled in
+ * $wgActions will be replaced by "nosuchaction".
+ *
+ * @since 1.19
+ * @param IContextSource $context
+ * @return string Action name
+ */
+ final public static function getActionName( IContextSource $context ) {
+ global $wgActions;
+
+ $request = $context->getRequest();
+ $actionName = $request->getVal( 'action', 'view' );
+
+ // Check for disabled actions
+ if ( isset( $wgActions[$actionName] ) && $wgActions[$actionName] === false ) {
+ $actionName = 'nosuchaction';
+ }
+
+ // Workaround for bug #20966: inability of IE to provide an action dependent
+ // on which submit button is clicked.
+ if ( $actionName === 'historysubmit' ) {
+ if ( $request->getBool( 'revisiondelete' ) ) {
+ $actionName = 'revisiondelete';
+ } elseif ( $request->getBool( 'editchangetags' ) ) {
+ $actionName = 'editchangetags';
+ } else {
+ $actionName = 'view';
+ }
+ } elseif ( $actionName == 'editredlink' ) {
+ $actionName = 'edit';
+ }
+
+ // Trying to get a WikiPage for NS_SPECIAL etc. will result
+ // in WikiPage::factory throwing "Invalid or virtual namespace -1 given."
+ // For SpecialPages et al, default to action=view.
+ if ( !$context->canUseWikiPage() ) {
+ return 'view';
+ }
+
+ $action = self::factory( $actionName, $context->getWikiPage(), $context );
+ if ( $action instanceof Action ) {
+ return $action->getName();
+ }
+
+ return 'nosuchaction';
+ }
+
+ /**
+ * Check if a given action is recognised, even if it's disabled
+ * @since 1.17
+ *
+ * @param string $name Name of an action
+ * @return bool
+ */
+ final public static function exists( $name ) {
+ return self::getClass( $name, [] ) !== null;
+ }
+
+ /**
+ * Get the IContextSource in use here
+ * @since 1.17
+ * @return IContextSource
+ */
+ final public function getContext() {
+ if ( $this->context instanceof IContextSource ) {
+ return $this->context;
+ } elseif ( $this->page instanceof Article ) {
+ // NOTE: $this->page can be a WikiPage, which does not have a context.
+ wfDebug( __METHOD__ . ": no context known, falling back to Article's context.\n" );
+ return $this->page->getContext();
+ }
+
+ wfWarn( __METHOD__ . ': no context known, falling back to RequestContext::getMain().' );
+ return RequestContext::getMain();
+ }
+
+ /**
+ * Get the WebRequest being used for this instance
+ * @since 1.17
+ *
+ * @return WebRequest
+ */
+ final public function getRequest() {
+ return $this->getContext()->getRequest();
+ }
+
+ /**
+ * Get the OutputPage being used for this instance
+ * @since 1.17
+ *
+ * @return OutputPage
+ */
+ final public function getOutput() {
+ return $this->getContext()->getOutput();
+ }
+
+ /**
+ * Shortcut to get the User being used for this instance
+ * @since 1.17
+ *
+ * @return User
+ */
+ final public function getUser() {
+ return $this->getContext()->getUser();
+ }
+
+ /**
+ * Shortcut to get the Skin being used for this instance
+ * @since 1.17
+ *
+ * @return Skin
+ */
+ final public function getSkin() {
+ return $this->getContext()->getSkin();
+ }
+
+ /**
+ * Shortcut to get the user Language being used for this instance
+ *
+ * @return Language
+ */
+ final public function getLanguage() {
+ return $this->getContext()->getLanguage();
+ }
+
+ /**
+ * Shortcut to get the Title object from the page
+ * @since 1.17
+ *
+ * @return Title
+ */
+ final public function getTitle() {
+ return $this->page->getTitle();
+ }
+
+ /**
+ * Get a Message object with context set
+ * Parameters are the same as wfMessage()
+ *
+ * @return Message
+ */
+ final public function msg( $key ) {
+ $params = func_get_args();
+ return call_user_func_array( [ $this->getContext(), 'msg' ], $params );
+ }
+
+ /**
+ * Only public since 1.21
+ *
+ * @param Page $page
+ * @param IContextSource|null $context
+ */
+ public function __construct( Page $page, IContextSource $context = null ) {
+ if ( $context === null ) {
+ wfWarn( __METHOD__ . ' called without providing a Context object.' );
+ // NOTE: We could try to initialize $context using $page->getContext(),
+ // if $page is an Article. That however seems to not work seamlessly.
+ }
+
+ $this->page = $page;
+ $this->context = $context;
+ }
+
+ /**
+ * Return the name of the action this object responds to
+ * @since 1.17
+ *
+ * @return string Lowercase name
+ */
+ abstract public function getName();
+
+ /**
+ * Get the permission required to perform this action. Often, but not always,
+ * the same as the action name
+ * @since 1.17
+ *
+ * @return string|null
+ */
+ public function getRestriction() {
+ return null;
+ }
+
+ /**
+ * Checks if the given user (identified by an object) can perform this action. Can be
+ * overridden by sub-classes with more complicated permissions schemes. Failures here
+ * must throw subclasses of ErrorPageError
+ * @since 1.17
+ *
+ * @param User $user The user to check, or null to use the context user
+ * @throws UserBlockedError|ReadOnlyError|PermissionsError
+ */
+ protected function checkCanExecute( User $user ) {
+ $right = $this->getRestriction();
+ if ( $right !== null ) {
+ $errors = $this->getTitle()->getUserPermissionsErrors( $right, $user );
+ if ( count( $errors ) ) {
+ throw new PermissionsError( $right, $errors );
+ }
+ }
+
+ if ( $this->requiresUnblock() && $user->isBlocked() ) {
+ $block = $user->getBlock();
+ throw new UserBlockedError( $block );
+ }
+
+ // This should be checked at the end so that the user won't think the
+ // error is only temporary when he also don't have the rights to execute
+ // this action
+ if ( $this->requiresWrite() && wfReadOnly() ) {
+ throw new ReadOnlyError();
+ }
+ }
+
+ /**
+ * Whether this action requires the wiki not to be locked
+ * @since 1.17
+ *
+ * @return bool
+ */
+ public function requiresWrite() {
+ return true;
+ }
+
+ /**
+ * Whether this action can still be executed by a blocked user
+ * @since 1.17
+ *
+ * @return bool
+ */
+ public function requiresUnblock() {
+ return true;
+ }
+
+ /**
+ * Set output headers for noindexing etc. This function will not be called through
+ * the execute() entry point, so only put UI-related stuff in here.
+ * @since 1.17
+ */
+ protected function setHeaders() {
+ $out = $this->getOutput();
+ $out->setRobotPolicy( "noindex,nofollow" );
+ $out->setPageTitle( $this->getPageTitle() );
+ $out->setSubtitle( $this->getDescription() );
+ $out->setArticleRelated( true );
+ }
+
+ /**
+ * Returns the name that goes in the \<h1\> page title
+ *
+ * @return string
+ */
+ protected function getPageTitle() {
+ return $this->getTitle()->getPrefixedText();
+ }
+
+ /**
+ * Returns the description that goes below the \<h1\> tag
+ * @since 1.17
+ *
+ * @return string HTML
+ */
+ protected function getDescription() {
+ return $this->msg( strtolower( $this->getName() ) )->escaped();
+ }
+
+ /**
+ * Adds help link with an icon via page indicators.
+ * Link target can be overridden by a local message containing a wikilink:
+ * the message key is: lowercase action name + '-helppage'.
+ * @param string $to Target MediaWiki.org page title or encoded URL.
+ * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o.
+ * @since 1.25
+ */
+ public function addHelpLink( $to, $overrideBaseUrl = false ) {
+ global $wgContLang;
+ $msg = wfMessage( $wgContLang->lc(
+ self::getActionName( $this->getContext() )
+ ) . '-helppage' );
+
+ if ( !$msg->isDisabled() ) {
+ $helpUrl = Skin::makeUrl( $msg->plain() );
+ $this->getOutput()->addHelpLink( $helpUrl, true );
+ } else {
+ $this->getOutput()->addHelpLink( $to, $overrideBaseUrl );
+ }
+ }
+
+ /**
+ * The main action entry point. Do all output for display and send it to the context
+ * output. Do not use globals $wgOut, $wgRequest, etc, in implementations; use
+ * $this->getOutput(), etc.
+ * @since 1.17
+ *
+ * @throws ErrorPageError
+ */
+ abstract public function show();
+
+ /**
+ * Call wfTransactionalTimeLimit() if this request was POSTed
+ * @since 1.26
+ */
+ protected function useTransactionalTimeLimit() {
+ if ( $this->getRequest()->wasPosted() ) {
+ wfTransactionalTimeLimit();
+ }
+ }
+
+ /**
+ * Indicates whether this action may perform database writes
+ * @return bool
+ * @since 1.27
+ */
+ public function doesWrites() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/actions/CachedAction.php b/www/wiki/includes/actions/CachedAction.php
new file mode 100644
index 00000000..864094de
--- /dev/null
+++ b/www/wiki/includes/actions/CachedAction.php
@@ -0,0 +1,189 @@
+<?php
+/**
+ * Abstract action class with scaffolding for caching HTML and other values
+ * in a single blob.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Actions
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ * @since 1.20
+ */
+
+/**
+ * Abstract action class with scaffolding for caching HTML and other values
+ * in a single blob.
+ *
+ * Before using any of the caching functionality, call startCache.
+ * After the last call to either getCachedValue or addCachedHTML, call saveCache.
+ *
+ * To get a cached value or compute it, use getCachedValue like this:
+ * $this->getCachedValue( $callback );
+ *
+ * To add HTML that should be cached, use addCachedHTML like this:
+ * $this->addCachedHTML( $callback );
+ *
+ * The callback function is only called when needed, so do all your expensive
+ * computations here. This function should returns the HTML to be cached.
+ * It should not add anything to the PageOutput object!
+ *
+ * @ingroup Actions
+ */
+abstract class CachedAction extends FormlessAction implements ICacheHelper {
+
+ /**
+ * CacheHelper object to which we forward the non-SpecialPage specific caching work.
+ * Initialized in startCache.
+ *
+ * @since 1.20
+ * @var CacheHelper
+ */
+ protected $cacheHelper;
+
+ /**
+ * If the cache is enabled or not.
+ *
+ * @since 1.20
+ * @var bool
+ */
+ protected $cacheEnabled = true;
+
+ /**
+ * Sets if the cache should be enabled or not.
+ *
+ * @since 1.20
+ * @param bool $cacheEnabled
+ */
+ public function setCacheEnabled( $cacheEnabled ) {
+ $this->cacheHelper->setCacheEnabled( $cacheEnabled );
+ }
+
+ /**
+ * Initializes the caching.
+ * Should be called before the first time anything is added via addCachedHTML.
+ *
+ * @since 1.20
+ *
+ * @param int|null $cacheExpiry Sets the cache expiry, either ttl in seconds or unix timestamp.
+ * @param bool|null $cacheEnabled Sets if the cache should be enabled or not.
+ */
+ public function startCache( $cacheExpiry = null, $cacheEnabled = null ) {
+ $this->cacheHelper = new CacheHelper();
+
+ $this->cacheHelper->setCacheEnabled( $this->cacheEnabled );
+ $this->cacheHelper->setOnInitializedHandler( [ $this, 'onCacheInitialized' ] );
+
+ $keyArgs = $this->getCacheKey();
+
+ if ( array_key_exists( 'action', $keyArgs ) && $keyArgs['action'] === 'purge' ) {
+ unset( $keyArgs['action'] );
+ }
+
+ $this->cacheHelper->setCacheKey( $keyArgs );
+
+ if ( $this->getRequest()->getText( 'action' ) === 'purge' ) {
+ $this->cacheHelper->rebuildOnDemand();
+ }
+
+ $this->cacheHelper->startCache( $cacheExpiry, $cacheEnabled );
+ }
+
+ /**
+ * Get a cached value if available or compute it if not and then cache it if possible.
+ * The provided $computeFunction is only called when the computation needs to happen
+ * and should return a result value. $args are arguments that will be passed to the
+ * compute function when called.
+ *
+ * @since 1.20
+ *
+ * @param callable $computeFunction
+ * @param array|mixed $args
+ * @param string|null $key
+ *
+ * @return mixed
+ */
+ public function getCachedValue( $computeFunction, $args = [], $key = null ) {
+ return $this->cacheHelper->getCachedValue( $computeFunction, $args, $key );
+ }
+
+ /**
+ * Add some HTML to be cached.
+ * This is done by providing a callback function that should
+ * return the HTML to be added. It will only be called if the
+ * item is not in the cache yet or when the cache has been invalidated.
+ *
+ * @since 1.20
+ *
+ * @param callable $computeFunction
+ * @param array $args
+ * @param string|null $key
+ */
+ public function addCachedHTML( $computeFunction, $args = [], $key = null ) {
+ $html = $this->cacheHelper->getCachedValue( $computeFunction, $args, $key );
+ $this->getOutput()->addHTML( $html );
+ }
+
+ /**
+ * Saves the HTML to the cache in case it got recomputed.
+ * Should be called after the last time anything is added via addCachedHTML.
+ *
+ * @since 1.20
+ */
+ public function saveCache() {
+ $this->cacheHelper->saveCache();
+ }
+
+ /**
+ * Sets the time to live for the cache, in seconds or a unix timestamp
+ * indicating the point of expiry.
+ *
+ * @since 1.20
+ *
+ * @param int $cacheExpiry
+ */
+ public function setExpiry( $cacheExpiry ) {
+ $this->cacheHelper->setExpiry( $cacheExpiry );
+ }
+
+ /**
+ * Returns the variables used to constructed the cache key in an array.
+ *
+ * @since 1.20
+ *
+ * @return array
+ */
+ protected function getCacheKey() {
+ return [
+ get_class( $this->page ),
+ $this->getName(),
+ $this->getLanguage()->getCode()
+ ];
+ }
+
+ /**
+ * Gets called after the cache got initialized.
+ *
+ * @since 1.20
+ *
+ * @param bool $hasCached
+ */
+ public function onCacheInitialized( $hasCached ) {
+ if ( $hasCached ) {
+ $this->getOutput()->setSubtitle( $this->cacheHelper->getCachedNotice( $this->getContext() ) );
+ }
+ }
+}
diff --git a/www/wiki/includes/actions/CreditsAction.php b/www/wiki/includes/actions/CreditsAction.php
new file mode 100644
index 00000000..70254778
--- /dev/null
+++ b/www/wiki/includes/actions/CreditsAction.php
@@ -0,0 +1,243 @@
+<?php
+/**
+ * Formats credits for articles
+ *
+ * Copyright 2004, Evan Prodromou <evan@wikitravel.org>.
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @author <evan@wikitravel.org>
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @ingroup Actions
+ */
+class CreditsAction extends FormlessAction {
+
+ public function getName() {
+ return 'credits';
+ }
+
+ protected function getDescription() {
+ return $this->msg( 'creditspage' )->escaped();
+ }
+
+ /**
+ * This is largely cadged from PageHistory::history
+ *
+ * @return string HTML
+ */
+ public function onView() {
+ if ( $this->page->getID() == 0 ) {
+ $s = $this->msg( 'nocredits' )->parse();
+ } else {
+ $s = $this->getCredits( -1 );
+ }
+
+ return Html::rawElement( 'div', [ 'id' => 'mw-credits' ], $s );
+ }
+
+ /**
+ * Get a list of contributors
+ *
+ * @param int $cnt Maximum list of contributors to show
+ * @param bool $showIfMax Whether to contributors if there more than $cnt
+ * @return string Html
+ */
+ public function getCredits( $cnt, $showIfMax = true ) {
+ $s = '';
+
+ if ( $cnt != 0 ) {
+ $s = $this->getAuthor( $this->page );
+ if ( $cnt > 1 || $cnt < 0 ) {
+ $s .= ' ' . $this->getContributors( $cnt - 1, $showIfMax );
+ }
+ }
+
+ return $s;
+ }
+
+ /**
+ * Get the last author with the last modification time
+ * @param Page $page
+ * @return string HTML
+ */
+ protected function getAuthor( Page $page ) {
+ $user = User::newFromName( $page->getUserText(), false );
+
+ $timestamp = $page->getTimestamp();
+ if ( $timestamp ) {
+ $lang = $this->getLanguage();
+ $d = $lang->date( $page->getTimestamp(), true );
+ $t = $lang->time( $page->getTimestamp(), true );
+ } else {
+ $d = '';
+ $t = '';
+ }
+
+ return $this->msg( 'lastmodifiedatby', $d, $t )->rawParams(
+ $this->userLink( $user ) )->params( $user->getName() )->escaped();
+ }
+
+ /**
+ * Whether we can display the user's real name (not a hidden pref)
+ *
+ * @since 1.24
+ * @return bool
+ */
+ protected function canShowRealUserName() {
+ $hiddenPrefs = $this->context->getConfig()->get( 'HiddenPrefs' );
+ return !in_array( 'realname', $hiddenPrefs );
+ }
+
+ /**
+ * Get a list of contributors of $article
+ * @param int $cnt Maximum list of contributors to show
+ * @param bool $showIfMax Whether to contributors if there more than $cnt
+ * @return string Html
+ */
+ protected function getContributors( $cnt, $showIfMax ) {
+ $contributors = $this->page->getContributors();
+
+ $others_link = false;
+
+ # Hmm... too many to fit!
+ if ( $cnt > 0 && $contributors->count() > $cnt ) {
+ $others_link = $this->othersLink();
+ if ( !$showIfMax ) {
+ return $this->msg( 'othercontribs' )->rawParams(
+ $others_link )->params( $contributors->count() )->escaped();
+ }
+ }
+
+ $real_names = [];
+ $user_names = [];
+ $anon_ips = [];
+
+ # Sift for real versus user names
+ /** @var User $user */
+ foreach ( $contributors as $user ) {
+ $cnt--;
+ if ( $user->isLoggedIn() ) {
+ $link = $this->link( $user );
+ if ( $this->canShowRealUserName() && $user->getRealName() ) {
+ $real_names[] = $link;
+ } else {
+ $user_names[] = $link;
+ }
+ } else {
+ $anon_ips[] = $this->link( $user );
+ }
+
+ if ( $cnt == 0 ) {
+ break;
+ }
+ }
+
+ $lang = $this->getLanguage();
+
+ if ( count( $real_names ) ) {
+ $real = $lang->listToText( $real_names );
+ } else {
+ $real = false;
+ }
+
+ # "ThisSite user(s) A, B and C"
+ if ( count( $user_names ) ) {
+ $user = $this->msg( 'siteusers' )->rawParams( $lang->listToText( $user_names ) )->params(
+ count( $user_names ) )->escaped();
+ } else {
+ $user = false;
+ }
+
+ if ( count( $anon_ips ) ) {
+ $anon = $this->msg( 'anonusers' )->rawParams( $lang->listToText( $anon_ips ) )->params(
+ count( $anon_ips ) )->escaped();
+ } else {
+ $anon = false;
+ }
+
+ # This is the big list, all mooshed together. We sift for blank strings
+ $fulllist = [];
+ foreach ( [ $real, $user, $anon, $others_link ] as $s ) {
+ if ( $s !== false ) {
+ array_push( $fulllist, $s );
+ }
+ }
+
+ $count = count( $fulllist );
+
+ # "Based on work by ..."
+ return $count
+ ? $this->msg( 'othercontribs' )->rawParams(
+ $lang->listToText( $fulllist ) )->params( $count )->escaped()
+ : '';
+ }
+
+ /**
+ * Get a link to $user's user page
+ * @param User $user
+ * @return string Html
+ */
+ protected function link( User $user ) {
+ if ( $this->canShowRealUserName() && !$user->isAnon() ) {
+ $real = $user->getRealName();
+ } else {
+ $real = $user->getName();
+ }
+
+ $page = $user->isAnon()
+ ? SpecialPage::getTitleFor( 'Contributions', $user->getName() )
+ : $user->getUserPage();
+
+ return MediaWikiServices::getInstance()
+ ->getLinkRenderer()->makeLink( $page, $real );
+ }
+
+ /**
+ * Get a link to $user's user page
+ * @param User $user
+ * @return string Html
+ */
+ protected function userLink( User $user ) {
+ $link = $this->link( $user );
+ if ( $user->isAnon() ) {
+ return $this->msg( 'anonuser' )->rawParams( $link )->parse();
+ } else {
+ if ( $this->canShowRealUserName() && $user->getRealName() ) {
+ return $link;
+ } else {
+ return $this->msg( 'siteuser' )->rawParams( $link )->params( $user->getName() )->escaped();
+ }
+ }
+ }
+
+ /**
+ * Get a link to action=credits of $article page
+ * @return string HTML link
+ */
+ protected function othersLink() {
+ return MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink(
+ $this->getTitle(),
+ $this->msg( 'others' )->text(),
+ [],
+ [ 'action' => 'credits' ]
+ );
+ }
+}
diff --git a/www/wiki/includes/actions/DeleteAction.php b/www/wiki/includes/actions/DeleteAction.php
new file mode 100644
index 00000000..6bed59a2
--- /dev/null
+++ b/www/wiki/includes/actions/DeleteAction.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * Handle page deletion
+ *
+ * Copyright © 2012 Timo Tijhof
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @author Timo Tijhof
+ */
+
+/**
+ * Handle page deletion
+ *
+ * This is a wrapper that will call Article::delete().
+ *
+ * @ingroup Actions
+ */
+class DeleteAction extends FormlessAction {
+
+ public function getName() {
+ return 'delete';
+ }
+
+ public function onView() {
+ return null;
+ }
+
+ public function show() {
+ $this->useTransactionalTimeLimit();
+ $this->addHelpLink( 'Help:Sysop deleting and undeleting' );
+ $this->page->delete();
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/EditAction.php b/www/wiki/includes/actions/EditAction.php
new file mode 100644
index 00000000..f0bc8bff
--- /dev/null
+++ b/www/wiki/includes/actions/EditAction.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * action=edit handler
+ *
+ * Copyright © 2012 Timo Tijhof
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @author Timo Tijhof
+ */
+
+/**
+ * Page edition handler
+ *
+ * This is a wrapper that will call the EditPage class or a custom editor from an extension.
+ *
+ * @ingroup Actions
+ */
+class EditAction extends FormlessAction {
+
+ public function getName() {
+ return 'edit';
+ }
+
+ public function onView() {
+ return null;
+ }
+
+ public function show() {
+ $this->useTransactionalTimeLimit();
+
+ $out = $this->getOutput();
+ $out->setRobotPolicy( 'noindex,nofollow' );
+ if ( $this->getContext()->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $out->addModuleStyles( [
+ 'mediawiki.ui.input',
+ 'mediawiki.ui.checkbox',
+ ] );
+ }
+ $page = $this->page;
+ $user = $this->getUser();
+
+ if ( Hooks::run( 'CustomEditor', [ $page, $user ] ) ) {
+ $editor = new EditPage( $page );
+ $editor->setContextTitle( $this->getTitle() );
+ $editor->edit();
+ }
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/FormAction.php b/www/wiki/includes/actions/FormAction.php
new file mode 100644
index 00000000..0141b9ec
--- /dev/null
+++ b/www/wiki/includes/actions/FormAction.php
@@ -0,0 +1,146 @@
+<?php
+/**
+ * Base classes for actions done on pages.
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * An action which shows a form and does something based on the input from the form
+ *
+ * @ingroup Actions
+ */
+abstract class FormAction extends Action {
+
+ /**
+ * Get an HTMLForm descriptor array
+ * @return array
+ */
+ protected function getFormFields() {
+ // Default to an empty form with just a submit button
+ return [];
+ }
+
+ /**
+ * Add pre- or post-text to the form
+ * @return string HTML which will be sent to $form->addPreText()
+ */
+ protected function preText() {
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ protected function postText() {
+ return '';
+ }
+
+ /**
+ * Play with the HTMLForm if you need to more substantially
+ * @param HTMLForm $form
+ */
+ protected function alterForm( HTMLForm $form ) {
+ }
+
+ /**
+ * Whether the form should use OOUI
+ * @return bool
+ */
+ protected function usesOOUI() {
+ return false;
+ }
+
+ /**
+ * Get the HTMLForm to control behavior
+ * @return HTMLForm|null
+ */
+ protected function getForm() {
+ $this->fields = $this->getFormFields();
+
+ // Give hooks a chance to alter the form, adding extra fields or text etc
+ Hooks::run( 'ActionModifyFormFields', [ $this->getName(), &$this->fields, $this->page ] );
+
+ if ( $this->usesOOUI() ) {
+ $form = HTMLForm::factory( 'ooui', $this->fields, $this->getContext(), $this->getName() );
+ } else {
+ $form = new HTMLForm( $this->fields, $this->getContext(), $this->getName() );
+ }
+ $form->setSubmitCallback( [ $this, 'onSubmit' ] );
+
+ $title = $this->getTitle();
+ $form->setAction( $title->getLocalURL( [ 'action' => $this->getName() ] ) );
+ // Retain query parameters (uselang etc)
+ $params = array_diff_key(
+ $this->getRequest()->getQueryValues(),
+ [ 'action' => null, 'title' => null ]
+ );
+ if ( $params ) {
+ $form->addHiddenField( 'redirectparams', wfArrayToCgi( $params ) );
+ }
+
+ $form->addPreText( $this->preText() );
+ $form->addPostText( $this->postText() );
+ $this->alterForm( $form );
+
+ // Give hooks a chance to alter the form, adding extra fields or text etc
+ Hooks::run( 'ActionBeforeFormDisplay', [ $this->getName(), &$form, $this->page ] );
+
+ return $form;
+ }
+
+ /**
+ * Process the form on POST submission.
+ *
+ * If you don't want to do anything with the form, just return false here.
+ *
+ * @param array $data
+ * @return bool|array True for success, false for didn't-try, array of errors on failure
+ */
+ abstract public function onSubmit( $data );
+
+ /**
+ * Do something exciting on successful processing of the form. This might be to show
+ * a confirmation message (watch, rollback, etc) or to redirect somewhere else (edit,
+ * protect, etc).
+ */
+ abstract public function onSuccess();
+
+ /**
+ * The basic pattern for actions is to display some sort of HTMLForm UI, maybe with
+ * some stuff underneath (history etc); to do some processing on submission of that
+ * form (delete, protect, etc) and to do something exciting on 'success', be that
+ * display something new or redirect to somewhere. Some actions have more exotic
+ * behavior, but that's what subclassing is for :D
+ */
+ public function show() {
+ $this->setHeaders();
+
+ // This will throw exceptions if there's a problem
+ $this->checkCanExecute( $this->getUser() );
+
+ $form = $this->getForm();
+ if ( $form->show() ) {
+ $this->onSuccess();
+ }
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/FormlessAction.php b/www/wiki/includes/actions/FormlessAction.php
new file mode 100644
index 00000000..a6f1e295
--- /dev/null
+++ b/www/wiki/includes/actions/FormlessAction.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Base classes for actions done on pages.
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * An action which just does something, without showing a form first.
+ *
+ * @ingroup Actions
+ */
+abstract class FormlessAction extends Action {
+
+ /**
+ * Show something on GET request.
+ * @return string|null Will be added to the HTMLForm if present, or just added to the
+ * output if not. Return null to not add anything
+ */
+ abstract public function onView();
+
+ public function show() {
+ $this->setHeaders();
+
+ // This will throw exceptions if there's a problem
+ $this->checkCanExecute( $this->getUser() );
+
+ $this->getOutput()->addHTML( $this->onView() );
+ }
+}
diff --git a/www/wiki/includes/actions/HistoryAction.php b/www/wiki/includes/actions/HistoryAction.php
new file mode 100644
index 00000000..fe848524
--- /dev/null
+++ b/www/wiki/includes/actions/HistoryAction.php
@@ -0,0 +1,958 @@
+<?php
+/**
+ * Page history
+ *
+ * Split off from Article.php and Skin.php, 2003-12-22
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+/**
+ * This class handles printing the history page for an article. In order to
+ * be efficient, it uses timestamps rather than offsets for paging, to avoid
+ * costly LIMIT,offset queries.
+ *
+ * Construct it by passing in an Article, and call $h->history() to print the
+ * history.
+ *
+ * @ingroup Actions
+ */
+class HistoryAction extends FormlessAction {
+ const DIR_PREV = 0;
+ const DIR_NEXT = 1;
+
+ /** @var array Array of message keys and strings */
+ public $message;
+
+ public function getName() {
+ return 'history';
+ }
+
+ public function requiresWrite() {
+ return false;
+ }
+
+ public function requiresUnblock() {
+ return false;
+ }
+
+ protected function getPageTitle() {
+ return $this->msg( 'history-title', $this->getTitle()->getPrefixedText() )->text();
+ }
+
+ protected function getDescription() {
+ // Creation of a subtitle link pointing to [[Special:Log]]
+ return MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink(
+ SpecialPage::getTitleFor( 'Log' ),
+ $this->msg( 'viewpagelogs' )->text(),
+ [],
+ [ 'page' => $this->getTitle()->getPrefixedText() ]
+ );
+ }
+
+ /**
+ * @return WikiPage|Article|ImagePage|CategoryPage|Page The Article object we are working on.
+ */
+ public function getArticle() {
+ return $this->page;
+ }
+
+ /**
+ * As we use the same small set of messages in various methods and that
+ * they are called often, we call them once and save them in $this->message
+ */
+ private function preCacheMessages() {
+ // Precache various messages
+ if ( !isset( $this->message ) ) {
+ $msgs = [ 'cur', 'last', 'pipe-separator' ];
+ foreach ( $msgs as $msg ) {
+ $this->message[$msg] = $this->msg( $msg )->escaped();
+ }
+ }
+ }
+
+ /**
+ * Print the history page for an article.
+ */
+ function onView() {
+ $out = $this->getOutput();
+ $request = $this->getRequest();
+
+ /**
+ * Allow client caching.
+ */
+ if ( $out->checkLastModified( $this->page->getTouched() ) ) {
+ return; // Client cache fresh and headers sent, nothing more to do.
+ }
+
+ $this->preCacheMessages();
+ $config = $this->context->getConfig();
+
+ # Fill in the file cache if not set already
+ if ( HTMLFileCache::useFileCache( $this->getContext() ) ) {
+ $cache = new HTMLFileCache( $this->getTitle(), 'history' );
+ if ( !$cache->isCacheGood( /* Assume up to date */ ) ) {
+ ob_start( [ &$cache, 'saveToFileCache' ] );
+ }
+ }
+
+ // Setup page variables.
+ $out->setFeedAppendQuery( 'action=history' );
+ $out->addModules( 'mediawiki.action.history' );
+ $out->addModuleStyles( [
+ 'mediawiki.action.history.styles',
+ 'mediawiki.special.changeslist',
+ ] );
+ if ( $config->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $out = $this->getOutput();
+ $out->addModuleStyles( [
+ 'mediawiki.ui.input',
+ 'mediawiki.ui.checkbox',
+ ] );
+ }
+
+ // Handle atom/RSS feeds.
+ $feedType = $request->getVal( 'feed' );
+ if ( $feedType ) {
+ $this->feed( $feedType );
+
+ return;
+ }
+
+ $this->addHelpLink( '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Page_history', true );
+
+ // Fail nicely if article doesn't exist.
+ if ( !$this->page->exists() ) {
+ global $wgSend404Code;
+ if ( $wgSend404Code ) {
+ $out->setStatusCode( 404 );
+ }
+ $out->addWikiMsg( 'nohistory' );
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ # show deletion/move log if there is an entry
+ LogEventsList::showLogExtract(
+ $out,
+ [ 'delete', 'move' ],
+ $this->getTitle(),
+ '',
+ [ 'lim' => 10,
+ 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ],
+ 'showIfEmpty' => false,
+ 'msgKey' => [ 'moveddeleted-notice' ]
+ ]
+ );
+
+ return;
+ }
+
+ /**
+ * Add date selector to quickly get to a certain time
+ */
+ $year = $request->getInt( 'year' );
+ $month = $request->getInt( 'month' );
+ $tagFilter = $request->getVal( 'tagfilter' );
+ $tagSelector = ChangeTags::buildTagFilterSelector( $tagFilter, false, $this->getContext() );
+
+ /**
+ * Option to show only revisions that have been (partially) hidden via RevisionDelete
+ */
+ if ( $request->getBool( 'deleted' ) ) {
+ $conds = [ 'rev_deleted != 0' ];
+ } else {
+ $conds = [];
+ }
+ if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
+ $checkDeleted = Xml::checkLabel( $this->msg( 'history-show-deleted' )->text(),
+ 'deleted', 'mw-show-deleted-only', $request->getBool( 'deleted' ) ) . "\n";
+ } else {
+ $checkDeleted = '';
+ }
+
+ // Add the general form
+ $action = htmlspecialchars( wfScript() );
+ $content = Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n";
+ $content .= Html::hidden( 'action', 'history' ) . "\n";
+ $content .= Xml::dateMenu(
+ ( $year == null ? MWTimestamp::getLocalInstance()->format( 'Y' ) : $year ),
+ $month
+ ) . '&#160;';
+ $content .= $tagSelector ? ( implode( '&#160;', $tagSelector ) . '&#160;' ) : '';
+ $content .= $checkDeleted . Html::submitButton(
+ $this->msg( 'historyaction-submit' )->text(),
+ [],
+ [ 'mw-ui-progressive' ]
+ );
+ $out->addHTML(
+ "<form action=\"$action\" method=\"get\" id=\"mw-history-searchform\">" .
+ Xml::fieldset(
+ $this->msg( 'history-fieldset-title' )->text(),
+ $content,
+ [ 'id' => 'mw-history-search' ]
+ ) .
+ '</form>'
+ );
+
+ Hooks::run( 'PageHistoryBeforeList', [ &$this->page, $this->getContext() ] );
+
+ // Create and output the list.
+ $pager = new HistoryPager( $this, $year, $month, $tagFilter, $conds );
+ $out->addHTML(
+ $pager->getNavigationBar() .
+ $pager->getBody() .
+ $pager->getNavigationBar()
+ );
+ $out->preventClickjacking( $pager->getPreventClickjacking() );
+ }
+
+ /**
+ * Fetch an array of revisions, specified by a given limit, offset and
+ * direction. This is now only used by the feeds. It was previously
+ * used by the main UI but that's now handled by the pager.
+ *
+ * @param int $limit The limit number of revisions to get
+ * @param int $offset
+ * @param int $direction Either self::DIR_PREV or self::DIR_NEXT
+ * @return ResultWrapper
+ */
+ function fetchRevisions( $limit, $offset, $direction ) {
+ // Fail if article doesn't exist.
+ if ( !$this->getTitle()->exists() ) {
+ return new FakeResultWrapper( [] );
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ if ( $direction === self::DIR_PREV ) {
+ list( $dirs, $oper ) = [ "ASC", ">=" ];
+ } else { /* $direction === self::DIR_NEXT */
+ list( $dirs, $oper ) = [ "DESC", "<=" ];
+ }
+
+ if ( $offset ) {
+ $offsets = [ "rev_timestamp $oper " . $dbr->addQuotes( $dbr->timestamp( $offset ) ) ];
+ } else {
+ $offsets = [];
+ }
+
+ $page_id = $this->page->getId();
+
+ return $dbr->select( 'revision',
+ Revision::selectFields(),
+ array_merge( [ 'rev_page' => $page_id ], $offsets ),
+ __METHOD__,
+ [ 'ORDER BY' => "rev_timestamp $dirs",
+ 'USE INDEX' => 'page_timestamp', 'LIMIT' => $limit ]
+ );
+ }
+
+ /**
+ * Output a subscription feed listing recent edits to this page.
+ *
+ * @param string $type Feed type
+ */
+ function feed( $type ) {
+ if ( !FeedUtils::checkFeedOutput( $type ) ) {
+ return;
+ }
+ $request = $this->getRequest();
+
+ $feedClasses = $this->context->getConfig()->get( 'FeedClasses' );
+ /** @var RSSFeed|AtomFeed $feed */
+ $feed = new $feedClasses[$type](
+ $this->getTitle()->getPrefixedText() . ' - ' .
+ $this->msg( 'history-feed-title' )->inContentLanguage()->text(),
+ $this->msg( 'history-feed-description' )->inContentLanguage()->text(),
+ $this->getTitle()->getFullURL( 'action=history' )
+ );
+
+ // Get a limit on number of feed entries. Provide a sane default
+ // of 10 if none is defined (but limit to $wgFeedLimit max)
+ $limit = $request->getInt( 'limit', 10 );
+ $limit = min(
+ max( $limit, 1 ),
+ $this->context->getConfig()->get( 'FeedLimit' )
+ );
+
+ $items = $this->fetchRevisions( $limit, 0, self::DIR_NEXT );
+
+ // Generate feed elements enclosed between header and footer.
+ $feed->outHeader();
+ if ( $items->numRows() ) {
+ foreach ( $items as $row ) {
+ $feed->outItem( $this->feedItem( $row ) );
+ }
+ } else {
+ $feed->outItem( $this->feedEmpty() );
+ }
+ $feed->outFooter();
+ }
+
+ function feedEmpty() {
+ return new FeedItem(
+ $this->msg( 'nohistory' )->inContentLanguage()->text(),
+ $this->msg( 'history-feed-empty' )->inContentLanguage()->parseAsBlock(),
+ $this->getTitle()->getFullURL(),
+ wfTimestamp( TS_MW ),
+ '',
+ $this->getTitle()->getTalkPage()->getFullURL()
+ );
+ }
+
+ /**
+ * Generate a FeedItem object from a given revision table row
+ * Borrows Recent Changes' feed generation functions for formatting;
+ * includes a diff to the previous revision (if any).
+ *
+ * @param stdClass|array $row Database row
+ * @return FeedItem
+ */
+ function feedItem( $row ) {
+ $rev = new Revision( $row );
+ $rev->setTitle( $this->getTitle() );
+ $text = FeedUtils::formatDiffRow(
+ $this->getTitle(),
+ $this->getTitle()->getPreviousRevisionID( $rev->getId() ),
+ $rev->getId(),
+ $rev->getTimestamp(),
+ $rev->getComment()
+ );
+ if ( $rev->getComment() == '' ) {
+ global $wgContLang;
+ $title = $this->msg( 'history-feed-item-nocomment',
+ $rev->getUserText(),
+ $wgContLang->timeanddate( $rev->getTimestamp() ),
+ $wgContLang->date( $rev->getTimestamp() ),
+ $wgContLang->time( $rev->getTimestamp() ) )->inContentLanguage()->text();
+ } else {
+ $title = $rev->getUserText() .
+ $this->msg( 'colon-separator' )->inContentLanguage()->text() .
+ FeedItem::stripComment( $rev->getComment() );
+ }
+
+ return new FeedItem(
+ $title,
+ $text,
+ $this->getTitle()->getFullURL( 'diff=' . $rev->getId() . '&oldid=prev' ),
+ $rev->getTimestamp(),
+ $rev->getUserText(),
+ $this->getTitle()->getTalkPage()->getFullURL()
+ );
+ }
+}
+
+/**
+ * @ingroup Pager
+ * @ingroup Actions
+ */
+class HistoryPager extends ReverseChronologicalPager {
+ /**
+ * @var bool|stdClass
+ */
+ public $lastRow = false;
+
+ public $counter, $historyPage, $buttons, $conds;
+
+ protected $oldIdChecked;
+
+ protected $preventClickjacking = false;
+ /**
+ * @var array
+ */
+ protected $parentLens;
+
+ /** @var bool Whether to show the tag editing UI */
+ protected $showTagEditUI;
+
+ /**
+ * @param HistoryAction $historyPage
+ * @param string $year
+ * @param string $month
+ * @param string $tagFilter
+ * @param array $conds
+ */
+ function __construct( $historyPage, $year = '', $month = '', $tagFilter = '', $conds = [] ) {
+ parent::__construct( $historyPage->getContext() );
+ $this->historyPage = $historyPage;
+ $this->tagFilter = $tagFilter;
+ $this->getDateCond( $year, $month );
+ $this->conds = $conds;
+ $this->showTagEditUI = ChangeTags::showTagEditingUI( $this->getUser() );
+ }
+
+ // For hook compatibility...
+ function getArticle() {
+ return $this->historyPage->getArticle();
+ }
+
+ function getSqlComment() {
+ if ( $this->conds ) {
+ return 'history page filtered'; // potentially slow, see CR r58153
+ } else {
+ return 'history page unfiltered';
+ }
+ }
+
+ function getQueryInfo() {
+ $queryInfo = [
+ 'tables' => [ 'revision', 'user' ],
+ 'fields' => array_merge( Revision::selectFields(), Revision::selectUserFields() ),
+ 'conds' => array_merge(
+ [ 'rev_page' => $this->getWikiPage()->getId() ],
+ $this->conds ),
+ 'options' => [ 'USE INDEX' => [ 'revision' => 'page_timestamp' ] ],
+ 'join_conds' => [ 'user' => Revision::userJoinCond() ],
+ ];
+ ChangeTags::modifyDisplayQuery(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ $queryInfo['join_conds'],
+ $queryInfo['options'],
+ $this->tagFilter
+ );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $historyPager = $this;
+ Hooks::run( 'PageHistoryPager::getQueryInfo', [ &$historyPager, &$queryInfo ] );
+
+ return $queryInfo;
+ }
+
+ function getIndexField() {
+ return 'rev_timestamp';
+ }
+
+ /**
+ * @param stdClass $row
+ * @return string
+ */
+ function formatRow( $row ) {
+ if ( $this->lastRow ) {
+ $latest = ( $this->counter == 1 && $this->mIsFirst );
+ $firstInList = $this->counter == 1;
+ $this->counter++;
+
+ $notifTimestamp = $this->getConfig()->get( 'ShowUpdatedMarker' )
+ ? $this->getTitle()->getNotificationTimestamp( $this->getUser() )
+ : false;
+
+ $s = $this->historyLine(
+ $this->lastRow, $row, $notifTimestamp, $latest, $firstInList );
+ } else {
+ $s = '';
+ }
+ $this->lastRow = $row;
+
+ return $s;
+ }
+
+ function doBatchLookups() {
+ if ( !Hooks::run( 'PageHistoryPager::doBatchLookups', [ $this, $this->mResult ] ) ) {
+ return;
+ }
+
+ # Do a link batch query
+ $this->mResult->seek( 0 );
+ $batch = new LinkBatch();
+ $revIds = [];
+ foreach ( $this->mResult as $row ) {
+ if ( $row->rev_parent_id ) {
+ $revIds[] = $row->rev_parent_id;
+ }
+ if ( !is_null( $row->user_name ) ) {
+ $batch->add( NS_USER, $row->user_name );
+ $batch->add( NS_USER_TALK, $row->user_name );
+ } else { # for anons or usernames of imported revisions
+ $batch->add( NS_USER, $row->rev_user_text );
+ $batch->add( NS_USER_TALK, $row->rev_user_text );
+ }
+ }
+ $this->parentLens = Revision::getParentLengths( $this->mDb, $revIds );
+ $batch->execute();
+ $this->mResult->seek( 0 );
+ }
+
+ /**
+ * Creates begin of history list with a submit button
+ *
+ * @return string HTML output
+ */
+ function getStartBody() {
+ $this->lastRow = false;
+ $this->counter = 1;
+ $this->oldIdChecked = 0;
+
+ $this->getOutput()->wrapWikiMsg( "<div class='mw-history-legend'>\n$1\n</div>", 'histlegend' );
+ $s = Html::openElement( 'form', [ 'action' => wfScript(),
+ 'id' => 'mw-history-compare' ] ) . "\n";
+ $s .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n";
+ $s .= Html::hidden( 'action', 'historysubmit' ) . "\n";
+ $s .= Html::hidden( 'type', 'revision' ) . "\n";
+
+ // Button container stored in $this->buttons for re-use in getEndBody()
+ $this->buttons = '<div>';
+ $className = 'historysubmit mw-history-compareselectedversions-button';
+ $attrs = [ 'class' => $className ]
+ + Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' );
+ $this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(),
+ $attrs
+ ) . "\n";
+
+ $user = $this->getUser();
+ $actionButtons = '';
+ if ( $user->isAllowed( 'deleterevision' ) ) {
+ $actionButtons .= $this->getRevisionButton( 'revisiondelete', 'showhideselectedversions' );
+ }
+ if ( $this->showTagEditUI ) {
+ $actionButtons .= $this->getRevisionButton( 'editchangetags', 'history-edit-tags' );
+ }
+ if ( $actionButtons ) {
+ $this->buttons .= Xml::tags( 'div', [ 'class' =>
+ 'mw-history-revisionactions' ], $actionButtons );
+ }
+
+ if ( $user->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) {
+ $this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
+ }
+
+ $this->buttons .= '</div>';
+
+ $s .= $this->buttons;
+ $s .= '<ul id="pagehistory">' . "\n";
+
+ return $s;
+ }
+
+ private function getRevisionButton( $name, $msg ) {
+ $this->preventClickjacking();
+ # Note bug #20966, <button> is non-standard in IE<8
+ $element = Html::element(
+ 'button',
+ [
+ 'type' => 'submit',
+ 'name' => $name,
+ 'value' => '1',
+ 'class' => "historysubmit mw-history-$name-button",
+ ],
+ $this->msg( $msg )->text()
+ ) . "\n";
+ return $element;
+ }
+
+ function getEndBody() {
+ if ( $this->lastRow ) {
+ $latest = $this->counter == 1 && $this->mIsFirst;
+ $firstInList = $this->counter == 1;
+ if ( $this->mIsBackwards ) {
+ # Next row is unknown, but for UI reasons, probably exists if an offset has been specified
+ if ( $this->mOffset == '' ) {
+ $next = null;
+ } else {
+ $next = 'unknown';
+ }
+ } else {
+ # The next row is the past-the-end row
+ $next = $this->mPastTheEndRow;
+ }
+ $this->counter++;
+
+ $notifTimestamp = $this->getConfig()->get( 'ShowUpdatedMarker' )
+ ? $this->getTitle()->getNotificationTimestamp( $this->getUser() )
+ : false;
+
+ $s = $this->historyLine(
+ $this->lastRow, $next, $notifTimestamp, $latest, $firstInList );
+ } else {
+ $s = '';
+ }
+ $s .= "</ul>\n";
+ # Add second buttons only if there is more than one rev
+ if ( $this->getNumRows() > 2 ) {
+ $s .= $this->buttons;
+ }
+ $s .= '</form>';
+
+ return $s;
+ }
+
+ /**
+ * Creates a submit button
+ *
+ * @param string $message Text of the submit button, will be escaped
+ * @param array $attributes Attributes
+ * @return string HTML output for the submit button
+ */
+ function submitButton( $message, $attributes = [] ) {
+ # Disable submit button if history has 1 revision only
+ if ( $this->getNumRows() > 1 ) {
+ return Html::submitButton( $message, $attributes );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Returns a row from the history printout.
+ *
+ * @todo document some more, and maybe clean up the code (some params redundant?)
+ *
+ * @param stdClass $row The database row corresponding to the previous line.
+ * @param mixed $next The database row corresponding to the next line
+ * (chronologically previous)
+ * @param bool|string $notificationtimestamp
+ * @param bool $latest Whether this row corresponds to the page's latest revision.
+ * @param bool $firstInList Whether this row corresponds to the first
+ * displayed on this history page.
+ * @return string HTML output for the row
+ */
+ function historyLine( $row, $next, $notificationtimestamp = false,
+ $latest = false, $firstInList = false ) {
+ $rev = new Revision( $row );
+ $rev->setTitle( $this->getTitle() );
+
+ if ( is_object( $next ) ) {
+ $prevRev = new Revision( $next );
+ $prevRev->setTitle( $this->getTitle() );
+ } else {
+ $prevRev = null;
+ }
+
+ $curlink = $this->curLink( $rev, $latest );
+ $lastlink = $this->lastLink( $rev, $next );
+ $curLastlinks = $curlink . $this->historyPage->message['pipe-separator'] . $lastlink;
+ $histLinks = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-history-histlinks' ],
+ $this->msg( 'parentheses' )->rawParams( $curLastlinks )->escaped()
+ );
+
+ $diffButtons = $this->diffButtons( $rev, $firstInList );
+ $s = $histLinks . $diffButtons;
+
+ $link = $this->revLink( $rev );
+ $classes = [];
+
+ $del = '';
+ $user = $this->getUser();
+ $canRevDelete = $user->isAllowed( 'deleterevision' );
+ // Show checkboxes for each revision, to allow for revision deletion and
+ // change tags
+ if ( $canRevDelete || $this->showTagEditUI ) {
+ $this->preventClickjacking();
+ // If revision was hidden from sysops and we don't need the checkbox
+ // for anything else, disable it
+ if ( !$this->showTagEditUI && !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) {
+ $del = Xml::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] );
+ // Otherwise, enable the checkbox...
+ } else {
+ $del = Xml::check( 'showhiderevisions', false,
+ [ 'name' => 'ids[' . $rev->getId() . ']' ] );
+ }
+ // User can only view deleted revisions...
+ } elseif ( $rev->getVisibility() && $user->isAllowed( 'deletedhistory' ) ) {
+ // If revision was hidden from sysops, disable the link
+ if ( !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) {
+ $del = Linker::revDeleteLinkDisabled( false );
+ // Otherwise, show the link...
+ } else {
+ $query = [ 'type' => 'revision',
+ 'target' => $this->getTitle()->getPrefixedDBkey(), 'ids' => $rev->getId() ];
+ $del .= Linker::revDeleteLink( $query,
+ $rev->isDeleted( Revision::DELETED_RESTRICTED ), false );
+ }
+ }
+ if ( $del ) {
+ $s .= " $del ";
+ }
+
+ $lang = $this->getLanguage();
+ $dirmark = $lang->getDirMark();
+
+ $s .= " $link";
+ $s .= $dirmark;
+ $s .= " <span class='history-user'>" .
+ Linker::revUserTools( $rev, true ) . "</span>";
+ $s .= $dirmark;
+
+ if ( $rev->isMinor() ) {
+ $s .= ' ' . ChangesList::flag( 'minor', $this->getContext() );
+ }
+
+ # Sometimes rev_len isn't populated
+ if ( $rev->getSize() !== null ) {
+ # Size is always public data
+ $prevSize = isset( $this->parentLens[$row->rev_parent_id] )
+ ? $this->parentLens[$row->rev_parent_id]
+ : 0;
+ $sDiff = ChangesList::showCharacterDifference( $prevSize, $rev->getSize() );
+ $fSize = Linker::formatRevisionSize( $rev->getSize() );
+ $s .= ' <span class="mw-changeslist-separator">. .</span> ' . "$fSize $sDiff";
+ }
+
+ # Text following the character difference is added just before running hooks
+ $s2 = Linker::revComment( $rev, false, true );
+
+ if ( $notificationtimestamp && ( $row->rev_timestamp >= $notificationtimestamp ) ) {
+ $s2 .= ' <span class="updatedmarker">' . $this->msg( 'updatedmarker' )->escaped() . '</span>';
+ $classes[] = 'mw-history-line-updated';
+ }
+
+ $tools = [];
+
+ # Rollback and undo links
+ if ( $prevRev && $this->getTitle()->quickUserCan( 'edit', $user ) ) {
+ if ( $latest && $this->getTitle()->quickUserCan( 'rollback', $user ) ) {
+ // Get a rollback link without the brackets
+ $rollbackLink = Linker::generateRollback(
+ $rev,
+ $this->getContext(),
+ [ 'verify', 'noBrackets' ]
+ );
+ if ( $rollbackLink ) {
+ $this->preventClickjacking();
+ $tools[] = $rollbackLink;
+ }
+ }
+
+ if ( !$rev->isDeleted( Revision::DELETED_TEXT )
+ && !$prevRev->isDeleted( Revision::DELETED_TEXT )
+ ) {
+ # Create undo tooltip for the first (=latest) line only
+ $undoTooltip = $latest
+ ? [ 'title' => $this->msg( 'tooltip-undo' )->text() ]
+ : [];
+ $undolink = MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink(
+ $this->getTitle(),
+ $this->msg( 'editundo' )->text(),
+ $undoTooltip,
+ [
+ 'action' => 'edit',
+ 'undoafter' => $prevRev->getId(),
+ 'undo' => $rev->getId()
+ ]
+ );
+ $tools[] = "<span class=\"mw-history-undo\">{$undolink}</span>";
+ }
+ }
+ // Allow extension to add their own links here
+ Hooks::run( 'HistoryRevisionTools', [ $rev, &$tools, $prevRev, $user ] );
+
+ if ( $tools ) {
+ $s2 .= ' ' . $this->msg( 'parentheses' )->rawParams( $lang->pipeList( $tools ) )->escaped();
+ }
+
+ # Tags
+ list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow(
+ $row->ts_tags,
+ 'history',
+ $this->getContext()
+ );
+ $classes = array_merge( $classes, $newClasses );
+ if ( $tagSummary !== '' ) {
+ $s2 .= " $tagSummary";
+ }
+
+ # Include separator between character difference and following text
+ if ( $s2 !== '' ) {
+ $s .= ' <span class="mw-changeslist-separator">. .</span> ' . $s2;
+ }
+
+ $attribs = [ 'data-mw-revid' => $rev->getId() ];
+
+ Hooks::run( 'PageHistoryLineEnding', [ $this, &$row, &$s, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+
+ if ( $classes ) {
+ $attribs['class'] = implode( ' ', $classes );
+ }
+
+ return Xml::tags( 'li', $attribs, $s ) . "\n";
+ }
+
+ /**
+ * Create a link to view this revision of the page
+ *
+ * @param Revision $rev
+ * @return string
+ */
+ function revLink( $rev ) {
+ $date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $this->getUser() );
+ if ( $rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
+ $link = MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink(
+ $this->getTitle(),
+ $date,
+ [ 'class' => 'mw-changeslist-date' ],
+ [ 'oldid' => $rev->getId() ]
+ );
+ } else {
+ $link = htmlspecialchars( $date );
+ }
+ if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $link = "<span class=\"history-deleted\">$link</span>";
+ }
+
+ return $link;
+ }
+
+ /**
+ * Create a diff-to-current link for this revision for this page
+ *
+ * @param Revision $rev
+ * @param bool $latest This is the latest revision of the page?
+ * @return string
+ */
+ function curLink( $rev, $latest ) {
+ $cur = $this->historyPage->message['cur'];
+ if ( $latest || !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
+ return $cur;
+ } else {
+ return MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink(
+ $this->getTitle(),
+ $cur,
+ [],
+ [
+ 'diff' => $this->getWikiPage()->getLatest(),
+ 'oldid' => $rev->getId()
+ ]
+ );
+ }
+ }
+
+ /**
+ * Create a diff-to-previous link for this revision for this page.
+ *
+ * @param Revision $prevRev The revision being displayed
+ * @param stdClass|string|null $next The next revision in list (that is
+ * the previous one in chronological order).
+ * May either be a row, "unknown" or null.
+ * @return string
+ */
+ function lastLink( $prevRev, $next ) {
+ $last = $this->historyPage->message['last'];
+
+ if ( $next === null ) {
+ # Probably no next row
+ return $last;
+ }
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ if ( $next === 'unknown' ) {
+ # Next row probably exists but is unknown, use an oldid=prev link
+ return $linkRenderer->makeKnownLink(
+ $this->getTitle(),
+ $last,
+ [],
+ [
+ 'diff' => $prevRev->getId(),
+ 'oldid' => 'prev'
+ ]
+ );
+ }
+
+ $nextRev = new Revision( $next );
+
+ if ( !$prevRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
+ || !$nextRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
+ ) {
+ return $last;
+ }
+
+ return $linkRenderer->makeKnownLink(
+ $this->getTitle(),
+ $last,
+ [],
+ [
+ 'diff' => $prevRev->getId(),
+ 'oldid' => $next->rev_id
+ ]
+ );
+ }
+
+ /**
+ * Create radio buttons for page history
+ *
+ * @param Revision $rev
+ * @param bool $firstInList Is this version the first one?
+ *
+ * @return string HTML output for the radio buttons
+ */
+ function diffButtons( $rev, $firstInList ) {
+ if ( $this->getNumRows() > 1 ) {
+ $id = $rev->getId();
+ $radio = [ 'type' => 'radio', 'value' => $id ];
+ /** @todo Move title texts to javascript */
+ if ( $firstInList ) {
+ $first = Xml::element( 'input',
+ array_merge( $radio, [
+ 'style' => 'visibility:hidden',
+ 'name' => 'oldid',
+ 'id' => 'mw-oldid-null' ] )
+ );
+ $checkmark = [ 'checked' => 'checked' ];
+ } else {
+ # Check visibility of old revisions
+ if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
+ $radio['disabled'] = 'disabled';
+ $checkmark = []; // We will check the next possible one
+ } elseif ( !$this->oldIdChecked ) {
+ $checkmark = [ 'checked' => 'checked' ];
+ $this->oldIdChecked = $id;
+ } else {
+ $checkmark = [];
+ }
+ $first = Xml::element( 'input',
+ array_merge( $radio, $checkmark, [
+ 'name' => 'oldid',
+ 'id' => "mw-oldid-$id" ] ) );
+ $checkmark = [];
+ }
+ $second = Xml::element( 'input',
+ array_merge( $radio, $checkmark, [
+ 'name' => 'diff',
+ 'id' => "mw-diff-$id" ] ) );
+
+ return $first . $second;
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * This is called if a write operation is possible from the generated HTML
+ * @param bool $enable
+ */
+ function preventClickjacking( $enable = true ) {
+ $this->preventClickjacking = $enable;
+ }
+
+ /**
+ * Get the "prevent clickjacking" flag
+ * @return bool
+ */
+ function getPreventClickjacking() {
+ return $this->preventClickjacking;
+ }
+
+}
diff --git a/www/wiki/includes/actions/InfoAction.php b/www/wiki/includes/actions/InfoAction.php
new file mode 100644
index 00000000..c5cd89f1
--- /dev/null
+++ b/www/wiki/includes/actions/InfoAction.php
@@ -0,0 +1,924 @@
+<?php
+/**
+ * Displays information about a page.
+ *
+ * Copyright © 2011 Alexandre Emsenhuber
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\Database;
+
+/**
+ * Displays information about a page.
+ *
+ * @ingroup Actions
+ */
+class InfoAction extends FormlessAction {
+ const VERSION = 1;
+
+ /**
+ * Returns the name of the action this object responds to.
+ *
+ * @return string Lowercase name
+ */
+ public function getName() {
+ return 'info';
+ }
+
+ /**
+ * Whether this action can still be executed by a blocked user.
+ *
+ * @return bool
+ */
+ public function requiresUnblock() {
+ return false;
+ }
+
+ /**
+ * Whether this action requires the wiki not to be locked.
+ *
+ * @return bool
+ */
+ public function requiresWrite() {
+ return false;
+ }
+
+ /**
+ * Clear the info cache for a given Title.
+ *
+ * @since 1.22
+ * @param Title $title Title to clear cache for
+ * @param int|null $revid Revision id to clear
+ */
+ public static function invalidateCache( Title $title, $revid = null ) {
+ if ( !$revid ) {
+ $revision = Revision::newFromTitle( $title, 0, Revision::READ_LATEST );
+ $revid = $revision ? $revision->getId() : null;
+ }
+ if ( $revid !== null ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $key = self::getCacheKey( $cache, $title, $revid );
+ $cache->delete( $key );
+ }
+ }
+
+ /**
+ * Shows page information on GET request.
+ *
+ * @return string Page information that will be added to the output
+ */
+ public function onView() {
+ $content = '';
+
+ // Validate revision
+ $oldid = $this->page->getOldID();
+ if ( $oldid ) {
+ $revision = $this->page->getRevisionFetched();
+
+ // Revision is missing
+ if ( $revision === null ) {
+ return $this->msg( 'missing-revision', $oldid )->parse();
+ }
+
+ // Revision is not current
+ if ( !$revision->isCurrent() ) {
+ return $this->msg( 'pageinfo-not-current' )->plain();
+ }
+ }
+
+ // Page header
+ if ( !$this->msg( 'pageinfo-header' )->isDisabled() ) {
+ $content .= $this->msg( 'pageinfo-header' )->parse();
+ }
+
+ // Hide "This page is a member of # hidden categories" explanation
+ $content .= Html::element( 'style', [],
+ '.mw-hiddenCategoriesExplanation { display: none; }' ) . "\n";
+
+ // Hide "Templates used on this page" explanation
+ $content .= Html::element( 'style', [],
+ '.mw-templatesUsedExplanation { display: none; }' ) . "\n";
+
+ // Get page information
+ $pageInfo = $this->pageInfo();
+
+ // Allow extensions to add additional information
+ Hooks::run( 'InfoAction', [ $this->getContext(), &$pageInfo ] );
+
+ // Render page information
+ foreach ( $pageInfo as $header => $infoTable ) {
+ // Messages:
+ // pageinfo-header-basic, pageinfo-header-edits, pageinfo-header-restrictions,
+ // pageinfo-header-properties, pageinfo-category-info
+ $content .= $this->makeHeader(
+ $this->msg( "pageinfo-${header}" )->escaped(),
+ "mw-pageinfo-${header}"
+ ) . "\n";
+ $table = "\n";
+ foreach ( $infoTable as $infoRow ) {
+ $name = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->escaped() : $infoRow[0];
+ $value = ( $infoRow[1] instanceof Message ) ? $infoRow[1]->escaped() : $infoRow[1];
+ $id = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->getKey() : null;
+ $table = $this->addRow( $table, $name, $value, $id ) . "\n";
+ }
+ $content = $this->addTable( $content, $table ) . "\n";
+ }
+
+ // Page footer
+ if ( !$this->msg( 'pageinfo-footer' )->isDisabled() ) {
+ $content .= $this->msg( 'pageinfo-footer' )->parse();
+ }
+
+ return $content;
+ }
+
+ /**
+ * Creates a header that can be added to the output.
+ *
+ * @param string $header The header text.
+ * @param string $canonicalId
+ * @return string The HTML.
+ */
+ protected function makeHeader( $header, $canonicalId ) {
+ $spanAttribs = [ 'class' => 'mw-headline', 'id' => Sanitizer::escapeIdForAttribute( $header ) ];
+ $h2Attribs = [ 'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ];
+
+ return Html::rawElement( 'h2', $h2Attribs, Html::element( 'span', $spanAttribs, $header ) );
+ }
+
+ /**
+ * Adds a row to a table that will be added to the content.
+ *
+ * @param string $table The table that will be added to the content
+ * @param string $name The name of the row
+ * @param string $value The value of the row
+ * @param string $id The ID to use for the 'tr' element
+ * @return string The table with the row added
+ */
+ protected function addRow( $table, $name, $value, $id ) {
+ return $table .
+ Html::rawElement(
+ 'tr',
+ $id === null ? [] : [ 'id' => 'mw-' . $id ],
+ Html::rawElement( 'td', [ 'style' => 'vertical-align: top;' ], $name ) .
+ Html::rawElement( 'td', [], $value )
+ );
+ }
+
+ /**
+ * Adds a table to the content that will be added to the output.
+ *
+ * @param string $content The content that will be added to the output
+ * @param string $table The table
+ * @return string The content with the table added
+ */
+ protected function addTable( $content, $table ) {
+ return $content . Html::rawElement( 'table', [ 'class' => 'wikitable mw-page-info' ],
+ $table );
+ }
+
+ /**
+ * Returns page information in an easily-manipulated format. Array keys are used so extensions
+ * may add additional information in arbitrary positions. Array values are arrays with one
+ * element to be rendered as a header, arrays with two elements to be rendered as a table row.
+ *
+ * @return array
+ */
+ protected function pageInfo() {
+ global $wgContLang;
+
+ $user = $this->getUser();
+ $lang = $this->getLanguage();
+ $title = $this->getTitle();
+ $id = $title->getArticleID();
+ $config = $this->context->getConfig();
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ $pageCounts = $this->pageCounts( $this->page );
+
+ $pageProperties = [];
+ $props = PageProps::getInstance()->getAllProperties( $title );
+ if ( isset( $props[$id] ) ) {
+ $pageProperties = $props[$id];
+ }
+
+ // Basic information
+ $pageInfo = [];
+ $pageInfo['header-basic'] = [];
+
+ // Display title
+ $displayTitle = $title->getPrefixedText();
+ if ( isset( $pageProperties['displaytitle'] ) ) {
+ $displayTitle = $pageProperties['displaytitle'];
+ }
+
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-display-title' ), $displayTitle
+ ];
+
+ // Is it a redirect? If so, where to?
+ if ( $title->isRedirect() ) {
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-redirectsto' ),
+ $linkRenderer->makeLink( $this->page->getRedirectTarget() ) .
+ $this->msg( 'word-separator' )->escaped() .
+ $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
+ $this->page->getRedirectTarget(),
+ $this->msg( 'pageinfo-redirectsto-info' )->text(),
+ [],
+ [ 'action' => 'info' ]
+ ) )->escaped()
+ ];
+ }
+
+ // Default sort key
+ $sortKey = $title->getCategorySortkey();
+ if ( isset( $pageProperties['defaultsort'] ) ) {
+ $sortKey = $pageProperties['defaultsort'];
+ }
+
+ $sortKey = htmlspecialchars( $sortKey );
+ $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-default-sort' ), $sortKey ];
+
+ // Page length (in bytes)
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-length' ), $lang->formatNum( $title->getLength() )
+ ];
+
+ // Page ID (number not localised, as it's a database ID)
+ $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-article-id' ), $id ];
+
+ // Language in which the page content is (supposed to be) written
+ $pageLang = $title->getPageLanguage()->getCode();
+
+ $pageLangHtml = $pageLang . ' - ' .
+ Language::fetchLanguageName( $pageLang, $lang->getCode() );
+ // Link to Special:PageLanguage with pre-filled page title if user has permissions
+ if ( $config->get( 'PageLanguageUseDB' )
+ && $title->userCan( 'pagelang', $user )
+ ) {
+ $pageLangHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
+ SpecialPage::getTitleValueFor( 'PageLanguage', $title->getPrefixedText() ),
+ $this->msg( 'pageinfo-language-change' )->text()
+ ) )->escaped();
+ }
+
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-language' )->escaped(),
+ $pageLangHtml
+ ];
+
+ // Content model of the page
+ $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->getContentModel() ) );
+ // If the user can change it, add a link to Special:ChangeContentModel
+ if ( $config->get( 'ContentHandlerUseDB' )
+ && $title->userCan( 'editcontentmodel', $user )
+ ) {
+ $modelHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
+ SpecialPage::getTitleValueFor( 'ChangeContentModel', $title->getPrefixedText() ),
+ $this->msg( 'pageinfo-content-model-change' )->text()
+ ) )->escaped();
+ }
+
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-content-model' ),
+ $modelHtml
+ ];
+
+ if ( $title->inNamespace( NS_USER ) ) {
+ $pageUser = User::newFromName( $title->getRootText() );
+ if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) {
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-user-id' ),
+ $pageUser->getId()
+ ];
+ }
+ }
+
+ // Search engine status
+ $pOutput = new ParserOutput();
+ if ( isset( $pageProperties['noindex'] ) ) {
+ $pOutput->setIndexPolicy( 'noindex' );
+ }
+ if ( isset( $pageProperties['index'] ) ) {
+ $pOutput->setIndexPolicy( 'index' );
+ }
+
+ // Use robot policy logic
+ $policy = $this->page->getRobotPolicy( 'view', $pOutput );
+ $pageInfo['header-basic'][] = [
+ // Messages: pageinfo-robot-index, pageinfo-robot-noindex
+ $this->msg( 'pageinfo-robot-policy' ),
+ $this->msg( "pageinfo-robot-${policy['index']}" )
+ ];
+
+ $unwatchedPageThreshold = $config->get( 'UnwatchedPageThreshold' );
+ if (
+ $user->isAllowed( 'unwatchedpages' ) ||
+ ( $unwatchedPageThreshold !== false &&
+ $pageCounts['watchers'] >= $unwatchedPageThreshold )
+ ) {
+ // Number of page watchers
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-watchers' ),
+ $lang->formatNum( $pageCounts['watchers'] )
+ ];
+ if (
+ $config->get( 'ShowUpdatedMarker' ) &&
+ isset( $pageCounts['visitingWatchers'] )
+ ) {
+ $minToDisclose = $config->get( 'UnwatchedPageSecret' );
+ if ( $pageCounts['visitingWatchers'] > $minToDisclose ||
+ $user->isAllowed( 'unwatchedpages' ) ) {
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-visiting-watchers' ),
+ $lang->formatNum( $pageCounts['visitingWatchers'] )
+ ];
+ } else {
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-visiting-watchers' ),
+ $this->msg( 'pageinfo-few-visiting-watchers' )
+ ];
+ }
+ }
+ } elseif ( $unwatchedPageThreshold !== false ) {
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-watchers' ),
+ $this->msg( 'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold )
+ ];
+ }
+
+ // Redirects to this page
+ $whatLinksHere = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() );
+ $pageInfo['header-basic'][] = [
+ $linkRenderer->makeLink(
+ $whatLinksHere,
+ $this->msg( 'pageinfo-redirects-name' )->text(),
+ [],
+ [
+ 'hidelinks' => 1,
+ 'hidetrans' => 1,
+ 'hideimages' => $title->getNamespace() == NS_FILE
+ ]
+ ),
+ $this->msg( 'pageinfo-redirects-value' )
+ ->numParams( count( $title->getRedirectsHere() ) )
+ ];
+
+ // Is it counted as a content page?
+ if ( $this->page->isCountable() ) {
+ $pageInfo['header-basic'][] = [
+ $this->msg( 'pageinfo-contentpage' ),
+ $this->msg( 'pageinfo-contentpage-yes' )
+ ];
+ }
+
+ // Subpages of this page, if subpages are enabled for the current NS
+ if ( MWNamespace::hasSubpages( $title->getNamespace() ) ) {
+ $prefixIndex = SpecialPage::getTitleFor(
+ 'Prefixindex', $title->getPrefixedText() . '/' );
+ $pageInfo['header-basic'][] = [
+ $linkRenderer->makeLink(
+ $prefixIndex,
+ $this->msg( 'pageinfo-subpages-name' )->text()
+ ),
+ $this->msg( 'pageinfo-subpages-value' )
+ ->numParams(
+ $pageCounts['subpages']['total'],
+ $pageCounts['subpages']['redirects'],
+ $pageCounts['subpages']['nonredirects'] )
+ ];
+ }
+
+ if ( $title->inNamespace( NS_CATEGORY ) ) {
+ $category = Category::newFromTitle( $title );
+
+ // $allCount is the total number of cat members,
+ // not the count of how many members are normal pages.
+ $allCount = (int)$category->getPageCount();
+ $subcatCount = (int)$category->getSubcatCount();
+ $fileCount = (int)$category->getFileCount();
+ $pagesCount = $allCount - $subcatCount - $fileCount;
+
+ $pageInfo['category-info'] = [
+ [
+ $this->msg( 'pageinfo-category-total' ),
+ $lang->formatNum( $allCount )
+ ],
+ [
+ $this->msg( 'pageinfo-category-pages' ),
+ $lang->formatNum( $pagesCount )
+ ],
+ [
+ $this->msg( 'pageinfo-category-subcats' ),
+ $lang->formatNum( $subcatCount )
+ ],
+ [
+ $this->msg( 'pageinfo-category-files' ),
+ $lang->formatNum( $fileCount )
+ ]
+ ];
+ }
+
+ // Page protection
+ $pageInfo['header-restrictions'] = [];
+
+ // Is this page affected by the cascading protection of something which includes it?
+ if ( $title->isCascadeProtected() ) {
+ $cascadingFrom = '';
+ $sources = $title->getCascadeProtectionSources()[0];
+
+ foreach ( $sources as $sourceTitle ) {
+ $cascadingFrom .= Html::rawElement(
+ 'li', [], $linkRenderer->makeKnownLink( $sourceTitle ) );
+ }
+
+ $cascadingFrom = Html::rawElement( 'ul', [], $cascadingFrom );
+ $pageInfo['header-restrictions'][] = [
+ $this->msg( 'pageinfo-protect-cascading-from' ),
+ $cascadingFrom
+ ];
+ }
+
+ // Is out protection set to cascade to other pages?
+ if ( $title->areRestrictionsCascading() ) {
+ $pageInfo['header-restrictions'][] = [
+ $this->msg( 'pageinfo-protect-cascading' ),
+ $this->msg( 'pageinfo-protect-cascading-yes' )
+ ];
+ }
+
+ // Page protection
+ foreach ( $title->getRestrictionTypes() as $restrictionType ) {
+ $protectionLevel = implode( ', ', $title->getRestrictions( $restrictionType ) );
+
+ if ( $protectionLevel == '' ) {
+ // Allow all users
+ $message = $this->msg( 'protect-default' )->escaped();
+ } else {
+ // Administrators only
+ // Messages: protect-level-autoconfirmed, protect-level-sysop
+ $message = $this->msg( "protect-level-$protectionLevel" );
+ if ( $message->isDisabled() ) {
+ // Require "$1" permission
+ $message = $this->msg( "protect-fallback", $protectionLevel )->parse();
+ } else {
+ $message = $message->escaped();
+ }
+ }
+ $expiry = $title->getRestrictionExpiry( $restrictionType );
+ $formattedexpiry = $this->msg( 'parentheses',
+ $lang->formatExpiry( $expiry ) )->escaped();
+ $message .= $this->msg( 'word-separator' )->escaped() . $formattedexpiry;
+
+ // Messages: restriction-edit, restriction-move, restriction-create,
+ // restriction-upload
+ $pageInfo['header-restrictions'][] = [
+ $this->msg( "restriction-$restrictionType" ), $message
+ ];
+ }
+
+ if ( !$this->page->exists() ) {
+ return $pageInfo;
+ }
+
+ // Edit history
+ $pageInfo['header-edits'] = [];
+
+ $firstRev = $this->page->getOldestRevision();
+ $lastRev = $this->page->getRevision();
+ $batch = new LinkBatch;
+
+ if ( $firstRev ) {
+ $firstRevUser = $firstRev->getUserText( Revision::FOR_THIS_USER );
+ if ( $firstRevUser !== '' ) {
+ $firstRevUserTitle = Title::makeTitle( NS_USER, $firstRevUser );
+ $batch->addObj( $firstRevUserTitle );
+ $batch->addObj( $firstRevUserTitle->getTalkPage() );
+ }
+ }
+
+ if ( $lastRev ) {
+ $lastRevUser = $lastRev->getUserText( Revision::FOR_THIS_USER );
+ if ( $lastRevUser !== '' ) {
+ $lastRevUserTitle = Title::makeTitle( NS_USER, $lastRevUser );
+ $batch->addObj( $lastRevUserTitle );
+ $batch->addObj( $lastRevUserTitle->getTalkPage() );
+ }
+ }
+
+ $batch->execute();
+
+ if ( $firstRev ) {
+ // Page creator
+ $pageInfo['header-edits'][] = [
+ $this->msg( 'pageinfo-firstuser' ),
+ Linker::revUserTools( $firstRev )
+ ];
+
+ // Date of page creation
+ $pageInfo['header-edits'][] = [
+ $this->msg( 'pageinfo-firsttime' ),
+ $linkRenderer->makeKnownLink(
+ $title,
+ $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ),
+ [],
+ [ 'oldid' => $firstRev->getId() ]
+ )
+ ];
+ }
+
+ if ( $lastRev ) {
+ // Latest editor
+ $pageInfo['header-edits'][] = [
+ $this->msg( 'pageinfo-lastuser' ),
+ Linker::revUserTools( $lastRev )
+ ];
+
+ // Date of latest edit
+ $pageInfo['header-edits'][] = [
+ $this->msg( 'pageinfo-lasttime' ),
+ $linkRenderer->makeKnownLink(
+ $title,
+ $lang->userTimeAndDate( $this->page->getTimestamp(), $user ),
+ [],
+ [ 'oldid' => $this->page->getLatest() ]
+ )
+ ];
+ }
+
+ // Total number of edits
+ $pageInfo['header-edits'][] = [
+ $this->msg( 'pageinfo-edits' ), $lang->formatNum( $pageCounts['edits'] )
+ ];
+
+ // Total number of distinct authors
+ if ( $pageCounts['authors'] > 0 ) {
+ $pageInfo['header-edits'][] = [
+ $this->msg( 'pageinfo-authors' ), $lang->formatNum( $pageCounts['authors'] )
+ ];
+ }
+
+ // Recent number of edits (within past 30 days)
+ $pageInfo['header-edits'][] = [
+ $this->msg( 'pageinfo-recent-edits',
+ $lang->formatDuration( $config->get( 'RCMaxAge' ) ) ),
+ $lang->formatNum( $pageCounts['recent_edits'] )
+ ];
+
+ // Recent number of distinct authors
+ $pageInfo['header-edits'][] = [
+ $this->msg( 'pageinfo-recent-authors' ),
+ $lang->formatNum( $pageCounts['recent_authors'] )
+ ];
+
+ // Array of MagicWord objects
+ $magicWords = MagicWord::getDoubleUnderscoreArray();
+
+ // Array of magic word IDs
+ $wordIDs = $magicWords->names;
+
+ // Array of IDs => localized magic words
+ $localizedWords = $wgContLang->getMagicWords();
+
+ $listItems = [];
+ foreach ( $pageProperties as $property => $value ) {
+ if ( in_array( $property, $wordIDs ) ) {
+ $listItems[] = Html::element( 'li', [], $localizedWords[$property][1] );
+ }
+ }
+
+ $localizedList = Html::rawElement( 'ul', [], implode( '', $listItems ) );
+ $hiddenCategories = $this->page->getHiddenCategories();
+
+ if (
+ count( $listItems ) > 0 ||
+ count( $hiddenCategories ) > 0 ||
+ $pageCounts['transclusion']['from'] > 0 ||
+ $pageCounts['transclusion']['to'] > 0
+ ) {
+ $options = [ 'LIMIT' => $config->get( 'PageInfoTransclusionLimit' ) ];
+ $transcludedTemplates = $title->getTemplateLinksFrom( $options );
+ if ( $config->get( 'MiserMode' ) ) {
+ $transcludedTargets = [];
+ } else {
+ $transcludedTargets = $title->getTemplateLinksTo( $options );
+ }
+
+ // Page properties
+ $pageInfo['header-properties'] = [];
+
+ // Magic words
+ if ( count( $listItems ) > 0 ) {
+ $pageInfo['header-properties'][] = [
+ $this->msg( 'pageinfo-magic-words' )->numParams( count( $listItems ) ),
+ $localizedList
+ ];
+ }
+
+ // Hidden categories
+ if ( count( $hiddenCategories ) > 0 ) {
+ $pageInfo['header-properties'][] = [
+ $this->msg( 'pageinfo-hidden-categories' )
+ ->numParams( count( $hiddenCategories ) ),
+ Linker::formatHiddenCategories( $hiddenCategories )
+ ];
+ }
+
+ // Transcluded templates
+ if ( $pageCounts['transclusion']['from'] > 0 ) {
+ if ( $pageCounts['transclusion']['from'] > count( $transcludedTemplates ) ) {
+ $more = $this->msg( 'morenotlisted' )->escaped();
+ } else {
+ $more = null;
+ }
+
+ $templateListFormatter = new TemplatesOnThisPageFormatter(
+ $this->getContext(),
+ $linkRenderer
+ );
+
+ $pageInfo['header-properties'][] = [
+ $this->msg( 'pageinfo-templates' )
+ ->numParams( $pageCounts['transclusion']['from'] ),
+ $templateListFormatter->format( $transcludedTemplates, false, $more )
+ ];
+ }
+
+ if ( !$config->get( 'MiserMode' ) && $pageCounts['transclusion']['to'] > 0 ) {
+ if ( $pageCounts['transclusion']['to'] > count( $transcludedTargets ) ) {
+ $more = $linkRenderer->makeLink(
+ $whatLinksHere,
+ $this->msg( 'moredotdotdot' )->text(),
+ [],
+ [ 'hidelinks' => 1, 'hideredirs' => 1 ]
+ );
+ } else {
+ $more = null;
+ }
+
+ $templateListFormatter = new TemplatesOnThisPageFormatter(
+ $this->getContext(),
+ $linkRenderer
+ );
+
+ $pageInfo['header-properties'][] = [
+ $this->msg( 'pageinfo-transclusions' )
+ ->numParams( $pageCounts['transclusion']['to'] ),
+ $templateListFormatter->format( $transcludedTargets, false, $more )
+ ];
+ }
+ }
+
+ return $pageInfo;
+ }
+
+ /**
+ * Returns page counts that would be too "expensive" to retrieve by normal means.
+ *
+ * @param WikiPage|Article|Page $page
+ * @return array
+ */
+ protected function pageCounts( Page $page ) {
+ $fname = __METHOD__;
+ $config = $this->context->getConfig();
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+
+ return $cache->getWithSetCallback(
+ self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ),
+ WANObjectCache::TTL_WEEK,
+ function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname ) {
+ $title = $page->getTitle();
+ $id = $title->getArticleID();
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $dbrWatchlist = wfGetDB( DB_REPLICA, 'watchlist' );
+ $setOpts += Database::getCacheSetOptions( $dbr, $dbrWatchlist );
+
+ $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore();
+
+ $result = [];
+ $result['watchers'] = $watchedItemStore->countWatchers( $title );
+
+ if ( $config->get( 'ShowUpdatedMarker' ) ) {
+ $updated = wfTimestamp( TS_UNIX, $page->getTimestamp() );
+ $result['visitingWatchers'] = $watchedItemStore->countVisitingWatchers(
+ $title,
+ $updated - $config->get( 'WatchersMaxAge' )
+ );
+ }
+
+ // Total number of edits
+ $edits = (int)$dbr->selectField(
+ 'revision',
+ 'COUNT(*)',
+ [ 'rev_page' => $id ],
+ $fname
+ );
+ $result['edits'] = $edits;
+
+ // Total number of distinct authors
+ if ( $config->get( 'MiserMode' ) ) {
+ $result['authors'] = 0;
+ } else {
+ $result['authors'] = (int)$dbr->selectField(
+ 'revision',
+ 'COUNT(DISTINCT rev_user_text)',
+ [ 'rev_page' => $id ],
+ $fname
+ );
+ }
+
+ // "Recent" threshold defined by RCMaxAge setting
+ $threshold = $dbr->timestamp( time() - $config->get( 'RCMaxAge' ) );
+
+ // Recent number of edits
+ $edits = (int)$dbr->selectField(
+ 'revision',
+ 'COUNT(rev_page)',
+ [
+ 'rev_page' => $id,
+ "rev_timestamp >= " . $dbr->addQuotes( $threshold )
+ ],
+ $fname
+ );
+ $result['recent_edits'] = $edits;
+
+ // Recent number of distinct authors
+ $result['recent_authors'] = (int)$dbr->selectField(
+ 'revision',
+ 'COUNT(DISTINCT rev_user_text)',
+ [
+ 'rev_page' => $id,
+ "rev_timestamp >= " . $dbr->addQuotes( $threshold )
+ ],
+ $fname
+ );
+
+ // Subpages (if enabled)
+ if ( MWNamespace::hasSubpages( $title->getNamespace() ) ) {
+ $conds = [ 'page_namespace' => $title->getNamespace() ];
+ $conds[] = 'page_title ' .
+ $dbr->buildLike( $title->getDBkey() . '/', $dbr->anyString() );
+
+ // Subpages of this page (redirects)
+ $conds['page_is_redirect'] = 1;
+ $result['subpages']['redirects'] = (int)$dbr->selectField(
+ 'page',
+ 'COUNT(page_id)',
+ $conds,
+ $fname
+ );
+
+ // Subpages of this page (non-redirects)
+ $conds['page_is_redirect'] = 0;
+ $result['subpages']['nonredirects'] = (int)$dbr->selectField(
+ 'page',
+ 'COUNT(page_id)',
+ $conds,
+ $fname
+ );
+
+ // Subpages of this page (total)
+ $result['subpages']['total'] = $result['subpages']['redirects']
+ + $result['subpages']['nonredirects'];
+ }
+
+ // Counts for the number of transclusion links (to/from)
+ if ( $config->get( 'MiserMode' ) ) {
+ $result['transclusion']['to'] = 0;
+ } else {
+ $result['transclusion']['to'] = (int)$dbr->selectField(
+ 'templatelinks',
+ 'COUNT(tl_from)',
+ [
+ 'tl_namespace' => $title->getNamespace(),
+ 'tl_title' => $title->getDBkey()
+ ],
+ $fname
+ );
+ }
+
+ $result['transclusion']['from'] = (int)$dbr->selectField(
+ 'templatelinks',
+ 'COUNT(*)',
+ [ 'tl_from' => $title->getArticleID() ],
+ $fname
+ );
+
+ return $result;
+ }
+ );
+ }
+
+ /**
+ * Returns the name that goes in the "<h1>" page title.
+ *
+ * @return string
+ */
+ protected function getPageTitle() {
+ return $this->msg( 'pageinfo-title', $this->getTitle()->getPrefixedText() )->text();
+ }
+
+ /**
+ * Get a list of contributors of $article
+ * @return string Html
+ */
+ protected function getContributors() {
+ $contributors = $this->page->getContributors();
+ $real_names = [];
+ $user_names = [];
+ $anon_ips = [];
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ # Sift for real versus user names
+ /** @var User $user */
+ foreach ( $contributors as $user ) {
+ $page = $user->isAnon()
+ ? SpecialPage::getTitleFor( 'Contributions', $user->getName() )
+ : $user->getUserPage();
+
+ $hiddenPrefs = $this->context->getConfig()->get( 'HiddenPrefs' );
+ if ( $user->getId() == 0 ) {
+ $anon_ips[] = $linkRenderer->makeLink( $page, $user->getName() );
+ } elseif ( !in_array( 'realname', $hiddenPrefs ) && $user->getRealName() ) {
+ $real_names[] = $linkRenderer->makeLink( $page, $user->getRealName() );
+ } else {
+ $user_names[] = $linkRenderer->makeLink( $page, $user->getName() );
+ }
+ }
+
+ $lang = $this->getLanguage();
+
+ $real = $lang->listToText( $real_names );
+
+ # "ThisSite user(s) A, B and C"
+ if ( count( $user_names ) ) {
+ $user = $this->msg( 'siteusers' )
+ ->rawParams( $lang->listToText( $user_names ) )
+ ->params( count( $user_names ) )->escaped();
+ } else {
+ $user = false;
+ }
+
+ if ( count( $anon_ips ) ) {
+ $anon = $this->msg( 'anonusers' )
+ ->rawParams( $lang->listToText( $anon_ips ) )
+ ->params( count( $anon_ips ) )->escaped();
+ } else {
+ $anon = false;
+ }
+
+ # This is the big list, all mooshed together. We sift for blank strings
+ $fulllist = [];
+ foreach ( [ $real, $user, $anon ] as $s ) {
+ if ( $s !== '' ) {
+ array_push( $fulllist, $s );
+ }
+ }
+
+ $count = count( $fulllist );
+
+ # "Based on work by ..."
+ return $count
+ ? $this->msg( 'othercontribs' )->rawParams(
+ $lang->listToText( $fulllist ) )->params( $count )->escaped()
+ : '';
+ }
+
+ /**
+ * Returns the description that goes below the "<h1>" tag.
+ *
+ * @return string
+ */
+ protected function getDescription() {
+ return '';
+ }
+
+ /**
+ * @param WANObjectCache $cache
+ * @param Title $title
+ * @param int $revId
+ * @return string
+ */
+ protected static function getCacheKey( WANObjectCache $cache, Title $title, $revId ) {
+ return $cache->makeKey( 'infoaction', md5( $title->getPrefixedText() ), $revId, self::VERSION );
+ }
+}
diff --git a/www/wiki/includes/actions/MarkpatrolledAction.php b/www/wiki/includes/actions/MarkpatrolledAction.php
new file mode 100644
index 00000000..66bedb2b
--- /dev/null
+++ b/www/wiki/includes/actions/MarkpatrolledAction.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Copyright © 2011 Alexandre Emsenhuber
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Mark a revision as patrolled on a page
+ *
+ * @ingroup Actions
+ */
+class MarkpatrolledAction extends FormAction {
+
+ public function getName() {
+ return 'markpatrolled';
+ }
+
+ protected function getDescription() {
+ // Disable default header "subtitle"
+ return '';
+ }
+
+ public function getRestriction() {
+ return 'patrol';
+ }
+
+ protected function getRecentChange( $data = null ) {
+ $rc = null;
+ // Note: This works both on initial GET url and after submitting the form
+ $rcId = $data ? intval( $data['rcid'] ) : $this->getRequest()->getInt( 'rcid' );
+ if ( $rcId ) {
+ $rc = RecentChange::newFromId( $rcId );
+ }
+ if ( !$rc ) {
+ throw new ErrorPageError( 'markedaspatrollederror', 'markedaspatrollederrortext' );
+ }
+ return $rc;
+ }
+
+ protected function preText() {
+ $rc = $this->getRecentChange();
+ $title = $rc->getTitle();
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ // Based on logentry-patrol-patrol (see PatrolLogFormatter)
+ $revId = $rc->getAttribute( 'rc_this_oldid' );
+ $query = [
+ 'curid' => $rc->getAttribute( 'rc_cur_id' ),
+ 'diff' => $revId,
+ 'oldid' => $rc->getAttribute( 'rc_last_oldid' )
+ ];
+ $revlink = $linkRenderer->makeLink( $title, $revId, [], $query );
+ $pagelink = $linkRenderer->makeLink( $title, $title->getPrefixedText() );
+
+ return $this->msg( 'confirm-markpatrolled-top' )->params(
+ $title->getPrefixedText(),
+ // Provide pre-rendered link as parser would render [[:$1]] as bold non-link
+ Message::rawParam( $pagelink ),
+ Message::rawParam( $revlink )
+ )->parse();
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->addHiddenField( 'rcid', $this->getRequest()->getInt( 'rcid' ) );
+ $form->setTokenSalt( 'patrol' );
+ $form->setSubmitTextMsg( 'confirm-markpatrolled-button' );
+ }
+
+ /**
+ * @param array $data
+ * @return bool|array True for success, false for didn't-try, array of errors on failure
+ */
+ public function onSubmit( $data ) {
+ $user = $this->getUser();
+ $rc = $this->getRecentChange( $data );
+ $errors = $rc->doMarkPatrolled( $user );
+
+ if ( in_array( [ 'rcpatroldisabled' ], $errors ) ) {
+ throw new ErrorPageError( 'rcpatroldisabled', 'rcpatroldisabledtext' );
+ }
+
+ // Guess where the user came from
+ // TODO: Would be nice to see where the user actually came from
+ if ( $rc->getAttribute( 'rc_type' ) == RC_NEW ) {
+ $returnTo = 'Newpages';
+ } elseif ( $rc->getAttribute( 'rc_log_type' ) == 'upload' ) {
+ $returnTo = 'Newfiles';
+ } else {
+ $returnTo = 'Recentchanges';
+ }
+ $return = SpecialPage::getTitleFor( $returnTo );
+
+ if ( in_array( [ 'markedaspatrollederror-noautopatrol' ], $errors ) ) {
+ $this->getOutput()->setPageTitle( $this->msg( 'markedaspatrollederror' ) );
+ $this->getOutput()->addWikiMsg( 'markedaspatrollederror-noautopatrol' );
+ $this->getOutput()->returnToMain( null, $return );
+ return true;
+ }
+
+ if ( $errors ) {
+ if ( !in_array( [ 'hookaborted' ], $errors ) ) {
+ throw new PermissionsError( 'patrol', $errors );
+ }
+ // The hook itself has handled any output
+ return $errors;
+ }
+
+ $this->getOutput()->setPageTitle( $this->msg( 'markedaspatrolled' ) );
+ $this->getOutput()->addWikiMsg( 'markedaspatrolledtext', $rc->getTitle()->getPrefixedText() );
+ $this->getOutput()->returnToMain( null, $return );
+ return true;
+ }
+
+ public function onSuccess() {
+ // Required by parent class. Redundant as our onSubmit handles output already.
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/ProtectAction.php b/www/wiki/includes/actions/ProtectAction.php
new file mode 100644
index 00000000..2e9e0934
--- /dev/null
+++ b/www/wiki/includes/actions/ProtectAction.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * action=protect handler
+ *
+ * Copyright © 2012 Timo Tijhof
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @author Timo Tijhof
+ */
+
+/**
+ * Handle page protection
+ *
+ * This is a wrapper that will call Article::protect().
+ *
+ * @ingroup Actions
+ */
+class ProtectAction extends FormlessAction {
+
+ public function getName() {
+ return 'protect';
+ }
+
+ public function onView() {
+ return null;
+ }
+
+ public function show() {
+ if ( $this->getContext()->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $out = $this->getOutput();
+ $out->addModuleStyles( [
+ 'mediawiki.ui.input',
+ 'mediawiki.ui.checkbox',
+ ] );
+ }
+
+ $this->page->protect();
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/PurgeAction.php b/www/wiki/includes/actions/PurgeAction.php
new file mode 100644
index 00000000..904c6e27
--- /dev/null
+++ b/www/wiki/includes/actions/PurgeAction.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * User-requested page cache purging.
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * User-requested page cache purging
+ *
+ * @ingroup Actions
+ */
+class PurgeAction extends FormAction {
+
+ private $redirectParams;
+
+ public function getName() {
+ return 'purge';
+ }
+
+ public function requiresUnblock() {
+ return false;
+ }
+
+ public function getDescription() {
+ return '';
+ }
+
+ public function onSubmit( $data ) {
+ return $this->page->doPurge();
+ }
+
+ public function show() {
+ $this->setHeaders();
+
+ // This will throw exceptions if there's a problem
+ $this->checkCanExecute( $this->getUser() );
+
+ $user = $this->getUser();
+
+ if ( $user->pingLimiter( 'purge' ) ) {
+ // TODO: Display actionthrottledtext
+ return;
+ }
+
+ if ( $this->getRequest()->wasPosted() ) {
+ $this->redirectParams = wfArrayToCgi( array_diff_key(
+ $this->getRequest()->getQueryValues(),
+ [ 'title' => null, 'action' => null ]
+ ) );
+ if ( $this->onSubmit( [] ) ) {
+ $this->onSuccess();
+ }
+ } else {
+ $this->redirectParams = $this->getRequest()->getVal( 'redirectparams', '' );
+ $form = $this->getForm();
+ if ( $form->show() ) {
+ $this->onSuccess();
+ }
+ }
+ }
+
+ protected function usesOOUI() {
+ return true;
+ }
+
+ protected function getFormFields() {
+ return [
+ 'intro' => [
+ 'type' => 'info',
+ 'vertical-label' => true,
+ 'raw' => true,
+ 'default' => $this->msg( 'confirm-purge-top' )->parse()
+ ]
+ ];
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setWrapperLegendMsg( 'confirm-purge-title' );
+ $form->setSubmitTextMsg( 'confirm_purge_button' );
+ }
+
+ protected function postText() {
+ return $this->msg( 'confirm-purge-bottom' )->parse();
+ }
+
+ public function onSuccess() {
+ $this->getOutput()->redirect( $this->getTitle()->getFullURL( $this->redirectParams ) );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/RawAction.php b/www/wiki/includes/actions/RawAction.php
new file mode 100644
index 00000000..d8c8bc32
--- /dev/null
+++ b/www/wiki/includes/actions/RawAction.php
@@ -0,0 +1,246 @@
+<?php
+/**
+ * Raw page text accessor
+ *
+ * Copyright © 2004 Gabriel Wicke <wicke@wikidev.net>
+ * http://wikidev.net/
+ *
+ * Based on HistoryAction and SpecialExport
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @author Gabriel Wicke <wicke@wikidev.net>
+ * @file
+ */
+
+/**
+ * A simple method to retrieve the plain source of an article,
+ * using "action=raw" in the GET request string.
+ *
+ * @ingroup Actions
+ */
+class RawAction extends FormlessAction {
+ public function getName() {
+ return 'raw';
+ }
+
+ public function requiresWrite() {
+ return false;
+ }
+
+ public function requiresUnblock() {
+ return false;
+ }
+
+ function onView() {
+ $this->getOutput()->disable();
+ $request = $this->getRequest();
+ $response = $request->response();
+ $config = $this->context->getConfig();
+
+ if ( !$request->checkUrlExtension() ) {
+ return;
+ }
+
+ if ( $this->getOutput()->checkLastModified( $this->page->getTouched() ) ) {
+ return; // Client cache fresh and headers sent, nothing more to do.
+ }
+
+ $gen = $request->getVal( 'gen' );
+ if ( $gen == 'css' || $gen == 'js' ) {
+ $this->gen = true;
+ }
+
+ $contentType = $this->getContentType();
+
+ $maxage = $request->getInt( 'maxage', $config->get( 'SquidMaxage' ) );
+ $smaxage = $request->getIntOrNull( 'smaxage' );
+ if ( $smaxage === null ) {
+ if ( $contentType == 'text/css' || $contentType == 'text/javascript' ) {
+ // CSS/JS raw content has its own CDN max age configuration.
+ // Note: Title::getCdnUrls() includes action=raw for css/js pages,
+ // so if using the canonical url, this will get HTCP purges.
+ $smaxage = intval( $config->get( 'ForcedRawSMaxage' ) );
+ } else {
+ // No CDN cache for anything else
+ $smaxage = 0;
+ }
+ }
+
+ // Set standard Vary headers so cache varies on cookies and such (T125283)
+ $response->header( $this->getOutput()->getVaryHeader() );
+ if ( $config->get( 'UseKeyHeader' ) ) {
+ $response->header( $this->getOutput()->getKeyHeader() );
+ }
+
+ $response->header( 'Content-type: ' . $contentType . '; charset=UTF-8' );
+ // Output may contain user-specific data;
+ // vary generated content for open sessions on private wikis
+ $privateCache = !User::isEveryoneAllowed( 'read' ) &&
+ ( $smaxage == 0 || MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() );
+ // Don't accidentally cache cookies if user is logged in (T55032)
+ $privateCache = $privateCache || $this->getUser()->isLoggedIn();
+ $mode = $privateCache ? 'private' : 'public';
+ $response->header(
+ 'Cache-Control: ' . $mode . ', s-maxage=' . $smaxage . ', max-age=' . $maxage
+ );
+
+ $text = $this->getRawText();
+
+ // Don't return a 404 response for CSS or JavaScript;
+ // 404s aren't generally cached and it would create
+ // extra hits when user CSS/JS are on and the user doesn't
+ // have the pages.
+ if ( $text === false && $contentType == 'text/x-wiki' ) {
+ $response->statusHeader( 404 );
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $rawAction = $this;
+ if ( !Hooks::run( 'RawPageViewBeforeOutput', [ &$rawAction, &$text ] ) ) {
+ wfDebug( __METHOD__ . ": RawPageViewBeforeOutput hook broke raw page output.\n" );
+ }
+
+ echo $text;
+ }
+
+ /**
+ * Get the text that should be returned, or false if the page or revision
+ * was not found.
+ *
+ * @return string|bool
+ */
+ public function getRawText() {
+ global $wgParser;
+
+ $text = false;
+ $title = $this->getTitle();
+ $request = $this->getRequest();
+
+ // If it's a MediaWiki message we can just hit the message cache
+ if ( $request->getBool( 'usemsgcache' ) && $title->getNamespace() == NS_MEDIAWIKI ) {
+ // The first "true" is to use the database, the second is to use
+ // the content langue and the last one is to specify the message
+ // key already contains the language in it ("/de", etc.).
+ $text = MessageCache::singleton()->get( $title->getDBkey(), true, true, true );
+ // If the message doesn't exist, return a blank
+ if ( $text === false ) {
+ $text = '';
+ }
+ } else {
+ // Get it from the DB
+ $rev = Revision::newFromTitle( $title, $this->getOldId() );
+ if ( $rev ) {
+ $lastmod = wfTimestamp( TS_RFC2822, $rev->getTimestamp() );
+ $request->response()->header( "Last-modified: $lastmod" );
+
+ // Public-only due to cache headers
+ $content = $rev->getContent();
+
+ if ( $content === null ) {
+ // revision not found (or suppressed)
+ $text = false;
+ } elseif ( !$content instanceof TextContent ) {
+ // non-text content
+ wfHttpError( 415, "Unsupported Media Type", "The requested page uses the content model `"
+ . $content->getModel() . "` which is not supported via this interface." );
+ die();
+ } else {
+ // want a section?
+ $section = $request->getIntOrNull( 'section' );
+ if ( $section !== null ) {
+ $content = $content->getSection( $section );
+ }
+
+ if ( $content === null || $content === false ) {
+ // section not found (or section not supported, e.g. for JS and CSS)
+ $text = false;
+ } else {
+ $text = $content->getNativeData();
+ }
+ }
+ }
+ }
+
+ if ( $text !== false && $text !== '' && $request->getVal( 'templates' ) === 'expand' ) {
+ $text = $wgParser->preprocess(
+ $text,
+ $title,
+ ParserOptions::newFromContext( $this->getContext() )
+ );
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get the ID of the revision that should used to get the text.
+ *
+ * @return int
+ */
+ public function getOldId() {
+ $oldid = $this->getRequest()->getInt( 'oldid' );
+ switch ( $this->getRequest()->getText( 'direction' ) ) {
+ case 'next':
+ # output next revision, or nothing if there isn't one
+ $nextid = 0;
+ if ( $oldid ) {
+ $nextid = $this->getTitle()->getNextRevisionID( $oldid );
+ }
+ $oldid = $nextid ?: -1;
+ break;
+ case 'prev':
+ # output previous revision, or nothing if there isn't one
+ if ( !$oldid ) {
+ # get the current revision so we can get the penultimate one
+ $oldid = $this->page->getLatest();
+ }
+ $previd = $this->getTitle()->getPreviousRevisionID( $oldid );
+ $oldid = $previd ?: -1;
+ break;
+ case 'cur':
+ $oldid = 0;
+ break;
+ }
+
+ return $oldid;
+ }
+
+ /**
+ * Get the content type to use for the response
+ *
+ * @return string
+ */
+ public function getContentType() {
+ $ctype = $this->getRequest()->getVal( 'ctype' );
+
+ if ( $ctype == '' ) {
+ $gen = $this->getRequest()->getVal( 'gen' );
+ if ( $gen == 'js' ) {
+ $ctype = 'text/javascript';
+ } elseif ( $gen == 'css' ) {
+ $ctype = 'text/css';
+ }
+ }
+
+ $allowedCTypes = [ 'text/x-wiki', 'text/javascript', 'text/css', 'application/x-zope-edit' ];
+ if ( $ctype == '' || !in_array( $ctype, $allowedCTypes ) ) {
+ $ctype = 'text/x-wiki';
+ }
+
+ return $ctype;
+ }
+}
diff --git a/www/wiki/includes/actions/RenderAction.php b/www/wiki/includes/actions/RenderAction.php
new file mode 100644
index 00000000..16e407f4
--- /dev/null
+++ b/www/wiki/includes/actions/RenderAction.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Handle action=render
+ *
+ * Copyright © 2012 Timo Tijhof
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @author Timo Tijhof
+ */
+
+/**
+ * Handle action=render
+ *
+ * This is a wrapper that will call Article::render().
+ *
+ * @ingroup Actions
+ */
+class RenderAction extends FormlessAction {
+
+ public function getName() {
+ return 'render';
+ }
+
+ public function onView() {
+ return null;
+ }
+
+ public function show() {
+ $this->page->render();
+ }
+}
diff --git a/www/wiki/includes/actions/RevertAction.php b/www/wiki/includes/actions/RevertAction.php
new file mode 100644
index 00000000..a914c9b2
--- /dev/null
+++ b/www/wiki/includes/actions/RevertAction.php
@@ -0,0 +1,168 @@
+<?php
+/**
+ * File reversion user interface
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @ingroup Media
+ * @author Alexandre Emsenhuber
+ * @author Rob Church <robchur@gmail.com>
+ */
+
+/**
+ * File reversion user interface
+ *
+ * @ingroup Actions
+ */
+class RevertAction extends FormAction {
+ /**
+ * @var OldLocalFile
+ */
+ protected $oldFile;
+
+ public function getName() {
+ return 'revert';
+ }
+
+ public function getRestriction() {
+ return 'upload';
+ }
+
+ protected function checkCanExecute( User $user ) {
+ if ( $this->getTitle()->getNamespace() !== NS_FILE ) {
+ throw new ErrorPageError( $this->msg( 'nosuchaction' ), $this->msg( 'nosuchactiontext' ) );
+ }
+ parent::checkCanExecute( $user );
+
+ $oldimage = $this->getRequest()->getText( 'oldimage' );
+ if ( strlen( $oldimage ) < 16
+ || strpos( $oldimage, '/' ) !== false
+ || strpos( $oldimage, '\\' ) !== false
+ ) {
+ throw new ErrorPageError( 'internalerror', 'unexpected', [ 'oldimage', $oldimage ] );
+ }
+
+ $this->oldFile = RepoGroup::singleton()->getLocalRepo()->newFromArchiveName(
+ $this->getTitle(),
+ $oldimage
+ );
+
+ if ( !$this->oldFile->exists() ) {
+ throw new ErrorPageError( '', 'filerevert-badversion' );
+ }
+ }
+
+ protected function usesOOUI() {
+ return true;
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setWrapperLegendMsg( 'filerevert-legend' );
+ $form->setSubmitTextMsg( 'filerevert-submit' );
+ $form->addHiddenField( 'oldimage', $this->getRequest()->getText( 'oldimage' ) );
+ $form->setTokenSalt( [ 'revert', $this->getTitle()->getPrefixedDBkey() ] );
+ }
+
+ protected function getFormFields() {
+ global $wgContLang;
+
+ $timestamp = $this->oldFile->getTimestamp();
+
+ $user = $this->getUser();
+ $lang = $this->getLanguage();
+ $userDate = $lang->userDate( $timestamp, $user );
+ $userTime = $lang->userTime( $timestamp, $user );
+ $siteTs = MWTimestamp::getLocalInstance( $timestamp );
+ $ts = $siteTs->format( 'YmdHis' );
+ $siteDate = $wgContLang->date( $ts, false, false );
+ $siteTime = $wgContLang->time( $ts, false, false );
+ $tzMsg = $siteTs->getTimezoneMessage()->inContentLanguage()->text();
+
+ return [
+ 'intro' => [
+ 'type' => 'info',
+ 'vertical-label' => true,
+ 'raw' => true,
+ 'default' => $this->msg( 'filerevert-intro',
+ $this->getTitle()->getText(), $userDate, $userTime,
+ wfExpandUrl(
+ $this->page->getFile()->getArchiveUrl( $this->getRequest()->getText( 'oldimage' ) ),
+ PROTO_CURRENT
+ ) )->parseAsBlock()
+ ],
+ 'comment' => [
+ 'type' => 'text',
+ 'label-message' => 'filerevert-comment',
+ 'default' => $this->msg( 'filerevert-defaultcomment', $siteDate, $siteTime,
+ $tzMsg )->inContentLanguage()->text()
+ ]
+ ];
+ }
+
+ public function onSubmit( $data ) {
+ $this->useTransactionalTimeLimit();
+
+ $old = $this->getRequest()->getText( 'oldimage' );
+ $localFile = $this->page->getFile();
+ $oldFile = OldLocalFile::newFromArchiveName( $this->getTitle(), $localFile->getRepo(), $old );
+
+ $source = $localFile->getArchiveVirtualUrl( $old );
+ $comment = $data['comment'];
+
+ if ( $localFile->getSha1() === $oldFile->getSha1() ) {
+ return Status::newFatal( 'filerevert-identical' );
+ }
+
+ // TODO: Preserve file properties from database instead of reloading from file
+ return $localFile->upload(
+ $source,
+ $comment,
+ $comment,
+ 0,
+ false,
+ false,
+ $this->getUser()
+ );
+ }
+
+ public function onSuccess() {
+ $timestamp = $this->oldFile->getTimestamp();
+ $user = $this->getUser();
+ $lang = $this->getLanguage();
+ $userDate = $lang->userDate( $timestamp, $user );
+ $userTime = $lang->userTime( $timestamp, $user );
+
+ $this->getOutput()->addWikiMsg( 'filerevert-success', $this->getTitle()->getText(),
+ $userDate, $userTime,
+ wfExpandUrl( $this->page->getFile()->getArchiveUrl( $this->getRequest()->getText( 'oldimage' ) ),
+ PROTO_CURRENT
+ ) );
+ $this->getOutput()->returnToMain( false, $this->getTitle() );
+ }
+
+ protected function getPageTitle() {
+ return $this->msg( 'filerevert', $this->getTitle()->getText() );
+ }
+
+ protected function getDescription() {
+ return OutputPage::buildBacklinkSubtitle( $this->getTitle() );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/RevisiondeleteAction.php b/www/wiki/includes/actions/RevisiondeleteAction.php
new file mode 100644
index 00000000..7df42b33
--- /dev/null
+++ b/www/wiki/includes/actions/RevisiondeleteAction.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * An action that just pass the request to Special:RevisionDelete
+ *
+ * Copyright © 2011 Alexandre Emsenhuber
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @author Alexandre Emsenhuber
+ */
+
+/**
+ * An action that just pass the request to Special:RevisionDelete
+ *
+ * @ingroup Actions
+ * @deprecated since 1.25 This class has been replaced by SpecialPageAction, but
+ * you really shouldn't have been using it outside core in the first place
+ */
+class RevisiondeleteAction extends FormlessAction {
+ public function __construct( Page $page, IContextSource $context = null ) {
+ wfDeprecated( 'RevisiondeleteAction class', '1.25' );
+ parent::__construct( $page, $context );
+ }
+
+ public function getName() {
+ return 'revisiondelete';
+ }
+
+ public function requiresUnblock() {
+ return false;
+ }
+
+ public function getDescription() {
+ return '';
+ }
+
+ public function onView() {
+ return '';
+ }
+
+ public function show() {
+ $special = SpecialPageFactory::getPage( 'Revisiondelete' );
+ $special->setContext( $this->getContext() );
+ $special->getContext()->setTitle( $special->getPageTitle() );
+ $special->run( '' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/RollbackAction.php b/www/wiki/includes/actions/RollbackAction.php
new file mode 100644
index 00000000..9d336e46
--- /dev/null
+++ b/www/wiki/includes/actions/RollbackAction.php
@@ -0,0 +1,164 @@
+<?php
+/**
+ * Edit rollback user interface
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * User interface for the rollback action
+ *
+ * @ingroup Actions
+ */
+class RollbackAction extends FormlessAction {
+
+ public function getName() {
+ return 'rollback';
+ }
+
+ public function getRestriction() {
+ return 'rollback';
+ }
+
+ /**
+ * Temporarily unused message keys due to T88044/T136375:
+ * - confirm-rollback-top
+ * - confirm-rollback-button
+ * - rollbackfailed
+ * - rollback-missingparam
+ * - rollback-success-notify
+ */
+
+ /**
+ * @throws ErrorPageError
+ */
+ public function onView() {
+ // TODO: use $this->useTransactionalTimeLimit(); when POST only
+ wfTransactionalTimeLimit();
+
+ $request = $this->getRequest();
+ $user = $this->getUser();
+ $from = $request->getVal( 'from' );
+ $rev = $this->page->getRevision();
+ if ( $from === null ) {
+ throw new ErrorPageError( 'rollbackfailed', 'rollback-missingparam' );
+ }
+ if ( !$rev ) {
+ throw new ErrorPageError( 'rollbackfailed', 'rollback-missingrevision' );
+ }
+ if ( $from !== $rev->getUserText() ) {
+ throw new ErrorPageError( 'rollbackfailed', 'alreadyrolled', [
+ $this->getTitle()->getPrefixedText(),
+ $from,
+ $rev->getUserText()
+ ] );
+ }
+
+ $data = null;
+ $errors = $this->page->doRollback(
+ $from,
+ $request->getText( 'summary' ),
+ $request->getVal( 'token' ),
+ $request->getBool( 'bot' ),
+ $data,
+ $this->getUser()
+ );
+
+ if ( in_array( [ 'actionthrottledtext' ], $errors ) ) {
+ throw new ThrottledError;
+ }
+
+ if ( isset( $errors[0][0] ) &&
+ ( $errors[0][0] == 'alreadyrolled' || $errors[0][0] == 'cantrollback' )
+ ) {
+ $this->getOutput()->setPageTitle( $this->msg( 'rollbackfailed' ) );
+ $errArray = $errors[0];
+ $errMsg = array_shift( $errArray );
+ $this->getOutput()->addWikiMsgArray( $errMsg, $errArray );
+
+ if ( isset( $data['current'] ) ) {
+ /** @var Revision $current */
+ $current = $data['current'];
+
+ if ( $current->getComment() != '' ) {
+ $this->getOutput()->addHTML( $this->msg( 'editcomment' )->rawParams(
+ Linker::formatComment( $current->getComment() ) )->parse() );
+ }
+ }
+
+ return;
+ }
+
+ # NOTE: Permission errors already handled by Action::checkExecute.
+ if ( $errors == [ [ 'readonlytext' ] ] ) {
+ throw new ReadOnlyError;
+ }
+
+ # XXX: Would be nice if ErrorPageError could take multiple errors, and/or a status object.
+ # Right now, we only show the first error
+ foreach ( $errors as $error ) {
+ throw new ErrorPageError( 'rollbackfailed', $error[0], array_slice( $error, 1 ) );
+ }
+
+ /** @var Revision $current */
+ $current = $data['current'];
+ $target = $data['target'];
+ $newId = $data['newid'];
+ $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) );
+ $this->getOutput()->setRobotPolicy( 'noindex,nofollow' );
+
+ $old = Linker::revUserTools( $current );
+ $new = Linker::revUserTools( $target );
+ $this->getOutput()->addHTML(
+ $this->msg( 'rollback-success' )
+ ->rawParams( $old, $new )
+ ->params( $current->getUserText( Revision::FOR_THIS_USER, $user ) )
+ ->params( $target->getUserText( Revision::FOR_THIS_USER, $user ) )
+ ->parseAsBlock()
+ );
+
+ if ( $user->getBoolOption( 'watchrollback' ) ) {
+ $user->addWatch( $this->page->getTitle(), User::IGNORE_USER_RIGHTS );
+ }
+
+ $this->getOutput()->returnToMain( false, $this->getTitle() );
+
+ if ( !$request->getBool( 'hidediff', false ) &&
+ !$this->getUser()->getBoolOption( 'norollbackdiff' )
+ ) {
+ $contentHandler = $current->getContentHandler();
+ $de = $contentHandler->createDifferenceEngine(
+ $this->getContext(),
+ $current->getId(),
+ $newId,
+ false,
+ true
+ );
+ $de->showDiff( '', '' );
+ }
+ return;
+ }
+
+ protected function getDescription() {
+ return '';
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/SpecialPageAction.php b/www/wiki/includes/actions/SpecialPageAction.php
new file mode 100644
index 00000000..e59b6d61
--- /dev/null
+++ b/www/wiki/includes/actions/SpecialPageAction.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * An action that just passes the request to the relevant special page
+ *
+ * @ingroup Actions
+ * @since 1.25
+ */
+class SpecialPageAction extends FormlessAction {
+ /**
+ * @var array A mapping of action names to special page names.
+ */
+ public static $actionToSpecialPageMapping = [
+ 'revisiondelete' => 'Revisiondelete',
+ 'editchangetags' => 'EditTags',
+ ];
+
+ public function getName() {
+ $request = $this->getRequest();
+ $actionName = $request->getVal( 'action', 'view' );
+ // TODO: Shouldn't need to copy-paste this code from Action::getActionName!
+ if ( $actionName === 'historysubmit' ) {
+ if ( $request->getBool( 'revisiondelete' ) ) {
+ $actionName = 'revisiondelete';
+ } elseif ( $request->getBool( 'editchangetags' ) ) {
+ $actionName = 'editchangetags';
+ }
+ }
+
+ if ( isset( self::$actionToSpecialPageMapping[$actionName] ) ) {
+ return $actionName;
+ }
+
+ return 'nosuchaction';
+ }
+
+ public function requiresUnblock() {
+ return false;
+ }
+
+ public function getDescription() {
+ return '';
+ }
+
+ public function onView() {
+ return '';
+ }
+
+ public function show() {
+ $special = $this->getSpecialPage();
+ if ( !$special ) {
+ throw new ErrorPageError(
+ $this->msg( 'nosuchaction' ), $this->msg( 'nosuchactiontext' ) );
+ }
+
+ $special->setContext( $this->getContext() );
+ $special->getContext()->setTitle( $special->getPageTitle() );
+ $special->run( '' );
+ }
+
+ public function doesWrites() {
+ $special = $this->getSpecialPage();
+
+ return $special ? $special->doesWrites() : false;
+ }
+
+ /**
+ * @return SpecialPage|null
+ */
+ protected function getSpecialPage() {
+ $action = $this->getName();
+ if ( $action === 'nosuchaction' ) {
+ return null;
+ }
+
+ // map actions to (whitelisted) special pages
+ return SpecialPageFactory::getPage( self::$actionToSpecialPageMapping[$action] );
+ }
+}
diff --git a/www/wiki/includes/actions/SubmitAction.php b/www/wiki/includes/actions/SubmitAction.php
new file mode 100644
index 00000000..8990b75f
--- /dev/null
+++ b/www/wiki/includes/actions/SubmitAction.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Wrapper for EditAction; sets the session cookie.
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * This is the same as EditAction; except that it sets the session cookie.
+ *
+ * @ingroup Actions
+ */
+class SubmitAction extends EditAction {
+
+ public function getName() {
+ return 'submit';
+ }
+
+ public function show() {
+ // Send a cookie so anons get talk message notifications
+ MediaWiki\Session\SessionManager::getGlobalSession()->persist();
+
+ parent::show();
+ }
+}
diff --git a/www/wiki/includes/actions/UnprotectAction.php b/www/wiki/includes/actions/UnprotectAction.php
new file mode 100644
index 00000000..0757e88c
--- /dev/null
+++ b/www/wiki/includes/actions/UnprotectAction.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * action=unprotect handler
+ *
+ * Copyright © 2012 Timo Tijhof
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @author Timo Tijhof
+ */
+
+/**
+ * Handle page unprotection
+ *
+ * This is a wrapper that will call Article::unprotect().
+ *
+ * @ingroup Actions
+ */
+class UnprotectAction extends ProtectAction {
+
+ public function getName() {
+ return 'unprotect';
+ }
+
+ public function show() {
+ $this->page->unprotect();
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/UnwatchAction.php b/www/wiki/includes/actions/UnwatchAction.php
new file mode 100644
index 00000000..aa17b89c
--- /dev/null
+++ b/www/wiki/includes/actions/UnwatchAction.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Performs the unwatch actions on a page
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * Page removal from a user's watchlist
+ *
+ * @ingroup Actions
+ */
+class UnwatchAction extends WatchAction {
+
+ public function getName() {
+ return 'unwatch';
+ }
+
+ public function onSubmit( $data ) {
+ self::doUnwatch( $this->getTitle(), $this->getUser() );
+
+ return true;
+ }
+
+ protected function getFormFields() {
+ return [
+ 'intro' => [
+ 'type' => 'info',
+ 'vertical-label' => true,
+ 'raw' => true,
+ 'default' => $this->msg( 'confirm-unwatch-top' )->parse()
+ ]
+ ];
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ parent::alterForm( $form );
+ $form->setWrapperLegendMsg( 'removewatch' );
+ $form->setSubmitTextMsg( 'confirm-unwatch-button' );
+ }
+
+ public function onSuccess() {
+ $msgKey = $this->getTitle()->isTalkPage() ? 'removedwatchtext-talk' : 'removedwatchtext';
+ $this->getOutput()->addWikiMsg( $msgKey, $this->getTitle()->getPrefixedText() );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/actions/ViewAction.php b/www/wiki/includes/actions/ViewAction.php
new file mode 100644
index 00000000..134b8a45
--- /dev/null
+++ b/www/wiki/includes/actions/ViewAction.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * An action that views article content
+ *
+ * Copyright © 2012 Timo Tijhof
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ * @author Timo Tijhof
+ */
+
+/**
+ * An action that views article content
+ *
+ * This is a wrapper that will call Article::view().
+ *
+ * @ingroup Actions
+ */
+class ViewAction extends FormlessAction {
+
+ public function getName() {
+ return 'view';
+ }
+
+ public function onView() {
+ return null;
+ }
+
+ public function show() {
+ $config = $this->context->getConfig();
+
+ if (
+ $config->get( 'DebugToolbar' ) == false && // don't let this get stuck on pages
+ $this->page->checkTouched() // page exists and is not a redirect
+ ) {
+ // Include any redirect in the last-modified calculation
+ $redirFromTitle = $this->page->getRedirectedFrom();
+ if ( !$redirFromTitle ) {
+ $touched = $this->page->getTouched();
+ } elseif ( $config->get( 'MaxRedirects' ) <= 1 ) {
+ $touched = max( $this->page->getTouched(), $redirFromTitle->getTouched() );
+ } else {
+ // Don't bother following the chain and getting the max mtime
+ $touched = null;
+ }
+
+ // Send HTTP 304 if the IMS matches or otherwise set expiry/last-modified headers
+ if ( $touched && $this->getOutput()->checkLastModified( $touched ) ) {
+ wfDebug( __METHOD__ . ": done 304\n" );
+ return;
+ }
+ }
+
+ $this->page->view();
+ }
+}
diff --git a/www/wiki/includes/actions/WatchAction.php b/www/wiki/includes/actions/WatchAction.php
new file mode 100644
index 00000000..e12a7276
--- /dev/null
+++ b/www/wiki/includes/actions/WatchAction.php
@@ -0,0 +1,196 @@
+<?php
+/**
+ * Performs the watch actions on a page
+ *
+ * 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
+ *
+ * @file
+ * @ingroup Actions
+ */
+
+/**
+ * Page addition to a user's watchlist
+ *
+ * @ingroup Actions
+ */
+class WatchAction extends FormAction {
+
+ public function getName() {
+ return 'watch';
+ }
+
+ public function requiresUnblock() {
+ return false;
+ }
+
+ protected function getDescription() {
+ return '';
+ }
+
+ public function onSubmit( $data ) {
+ self::doWatch( $this->getTitle(), $this->getUser() );
+
+ return true;
+ }
+
+ protected function checkCanExecute( User $user ) {
+ // Must be logged in
+ if ( $user->isAnon() ) {
+ throw new UserNotLoggedIn( 'watchlistanontext', 'watchnologin' );
+ }
+
+ parent::checkCanExecute( $user );
+ }
+
+ protected function usesOOUI() {
+ return true;
+ }
+
+ protected function getFormFields() {
+ return [
+ 'intro' => [
+ 'type' => 'info',
+ 'vertical-label' => true,
+ 'raw' => true,
+ 'default' => $this->msg( 'confirm-watch-top' )->parse()
+ ]
+ ];
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setWrapperLegendMsg( 'addwatch' );
+ $form->setSubmitTextMsg( 'confirm-watch-button' );
+ $form->setTokenSalt( 'watch' );
+ }
+
+ public function onSuccess() {
+ $msgKey = $this->getTitle()->isTalkPage() ? 'addedwatchtext-talk' : 'addedwatchtext';
+ $this->getOutput()->addWikiMsg( $msgKey, $this->getTitle()->getPrefixedText() );
+ }
+
+ /* Static utility methods */
+
+ /**
+ * Watch or unwatch a page
+ * @since 1.22
+ * @param bool $watch Whether to watch or unwatch the page
+ * @param Title $title Page to watch/unwatch
+ * @param User $user User who is watching/unwatching
+ * @return Status
+ */
+ public static function doWatchOrUnwatch( $watch, Title $title, User $user ) {
+ if ( $user->isLoggedIn() &&
+ $user->isWatched( $title, User::IGNORE_USER_RIGHTS ) != $watch
+ ) {
+ // If the user doesn't have 'editmywatchlist', we still want to
+ // allow them to add but not remove items via edits and such.
+ if ( $watch ) {
+ return self::doWatch( $title, $user, User::IGNORE_USER_RIGHTS );
+ } else {
+ return self::doUnwatch( $title, $user );
+ }
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Watch a page
+ * @since 1.22 Returns Status, $checkRights parameter added
+ * @param Title $title Page to watch/unwatch
+ * @param User $user User who is watching/unwatching
+ * @param bool $checkRights Passed through to $user->addWatch()
+ * Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS.
+ * @return Status
+ */
+ public static function doWatch(
+ Title $title,
+ User $user,
+ $checkRights = User::CHECK_USER_RIGHTS
+ ) {
+ if ( $checkRights && !$user->isAllowed( 'editmywatchlist' ) ) {
+ return User::newFatalPermissionDeniedStatus( 'editmywatchlist' );
+ }
+
+ $page = WikiPage::factory( $title );
+
+ $status = Status::newFatal( 'hookaborted' );
+ if ( Hooks::run( 'WatchArticle', [ &$user, &$page, &$status ] ) ) {
+ $status = Status::newGood();
+ $user->addWatch( $title, $checkRights );
+ Hooks::run( 'WatchArticleComplete', [ &$user, &$page ] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Unwatch a page
+ * @since 1.22 Returns Status
+ * @param Title $title Page to watch/unwatch
+ * @param User $user User who is watching/unwatching
+ * @return Status
+ */
+ public static function doUnwatch( Title $title, User $user ) {
+ if ( !$user->isAllowed( 'editmywatchlist' ) ) {
+ return User::newFatalPermissionDeniedStatus( 'editmywatchlist' );
+ }
+
+ $page = WikiPage::factory( $title );
+
+ $status = Status::newFatal( 'hookaborted' );
+ if ( Hooks::run( 'UnwatchArticle', [ &$user, &$page, &$status ] ) ) {
+ $status = Status::newGood();
+ $user->removeWatch( $title );
+ Hooks::run( 'UnwatchArticleComplete', [ &$user, &$page ] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get token to watch (or unwatch) a page for a user
+ *
+ * @param Title $title Title object of page to watch
+ * @param User $user User for whom the action is going to be performed
+ * @param string $action Optionally override the action to 'unwatch'
+ * @return string Token
+ * @since 1.18
+ */
+ public static function getWatchToken( Title $title, User $user, $action = 'watch' ) {
+ if ( $action != 'unwatch' ) {
+ $action = 'watch';
+ }
+ // Match ApiWatch and ResourceLoaderUserTokensModule
+ return $user->getEditToken( $action );
+ }
+
+ /**
+ * Get token to unwatch (or watch) a page for a user
+ *
+ * @param Title $title Title object of page to unwatch
+ * @param User $user User for whom the action is going to be performed
+ * @param string $action Optionally override the action to 'watch'
+ * @return string Token
+ * @since 1.18
+ */
+ public static function getUnwatchToken( Title $title, User $user, $action = 'unwatch' ) {
+ return self::getWatchToken( $title, $user, $action );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/api/ApiAMCreateAccount.php b/www/wiki/includes/api/ApiAMCreateAccount.php
new file mode 100644
index 00000000..72a36d71
--- /dev/null
+++ b/www/wiki/includes/api/ApiAMCreateAccount.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Copyright © 2016 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationResponse;
+
+/**
+ * Create an account with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiAMCreateAccount extends ApiBase {
+
+ public function __construct( ApiMain $main, $action ) {
+ parent::__construct( $main, $action, 'create' );
+ }
+
+ public function getFinalDescription() {
+ // A bit of a hack to append 'api-help-authmanager-general-usage'
+ $msgs = parent::getFinalDescription();
+ $msgs[] = ApiBase::makeMessage( 'api-help-authmanager-general-usage', $this->getContext(), [
+ $this->getModulePrefix(),
+ $this->getModuleName(),
+ $this->getModulePath(),
+ AuthManager::ACTION_CREATE,
+ self::needsToken(),
+ ] );
+ return $msgs;
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $this->requireAtLeastOneParameter( $params, 'continue', 'returnurl' );
+
+ if ( $params['returnurl'] !== null ) {
+ $bits = wfParseUrl( $params['returnurl'] );
+ if ( !$bits || $bits['scheme'] === '' ) {
+ $encParamName = $this->encodeParamName( 'returnurl' );
+ $this->dieWithError(
+ [ 'apierror-badurl', $encParamName, wfEscapeWikiText( $params['returnurl'] ) ],
+ "badurl_{$encParamName}"
+ );
+ }
+ }
+
+ $helper = new ApiAuthManagerHelper( $this );
+ $manager = AuthManager::singleton();
+
+ // Make sure it's possible to create accounts
+ if ( !$manager->canCreateAccounts() ) {
+ $this->getResult()->addValue( null, 'createaccount', $helper->formatAuthenticationResponse(
+ AuthenticationResponse::newFail(
+ $this->msg( 'userlogin-cannot-' . AuthManager::ACTION_CREATE )
+ )
+ ) );
+ $helper->logAuthenticationResult( 'accountcreation',
+ 'userlogin-cannot-' . AuthManager::ACTION_CREATE );
+ return;
+ }
+
+ // Perform the create step
+ if ( $params['continue'] ) {
+ $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_CREATE_CONTINUE );
+ $res = $manager->continueAccountCreation( $reqs );
+ } else {
+ $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_CREATE );
+ if ( $params['preservestate'] ) {
+ $req = $helper->getPreservedRequest();
+ if ( $req ) {
+ $reqs[] = $req;
+ }
+ }
+ $res = $manager->beginAccountCreation( $this->getUser(), $reqs, $params['returnurl'] );
+ }
+
+ $this->getResult()->addValue( null, 'createaccount',
+ $helper->formatAuthenticationResponse( $res ) );
+ $helper->logAuthenticationResult( 'accountcreation', $res );
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function needsToken() {
+ return 'createaccount';
+ }
+
+ public function getAllowedParams() {
+ $ret = ApiAuthManagerHelper::getStandardParams( AuthManager::ACTION_CREATE,
+ 'requests', 'messageformat', 'mergerequestfields', 'preservestate', 'returnurl', 'continue'
+ );
+ $ret['preservestate'][ApiBase::PARAM_HELP_MSG_APPEND][] =
+ 'apihelp-createaccount-param-preservestate';
+ return $ret;
+ }
+
+ public function dynamicParameterDocumentation() {
+ return [ 'api-help-authmanagerhelper-additional-params', AuthManager::ACTION_CREATE ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=createaccount&username=Example&password=ExamplePassword&retype=ExamplePassword'
+ . '&createreturnurl=http://example.org/&createtoken=123ABC'
+ => 'apihelp-createaccount-example-create',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Account_creation';
+ }
+}
diff --git a/www/wiki/includes/api/ApiAuthManagerHelper.php b/www/wiki/includes/api/ApiAuthManagerHelper.php
new file mode 100644
index 00000000..d6b9f761
--- /dev/null
+++ b/www/wiki/includes/api/ApiAuthManagerHelper.php
@@ -0,0 +1,396 @@
+<?php
+/**
+ * Copyright © 2016 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.27
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\CreateFromLoginAuthenticationRequest;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Helper class for AuthManager-using API modules. Intended for use via
+ * composition.
+ *
+ * @ingroup API
+ */
+class ApiAuthManagerHelper {
+
+ /** @var ApiBase API module, for context and parameters */
+ private $module;
+
+ /** @var string Message output format */
+ private $messageFormat;
+
+ /**
+ * @param ApiBase $module API module, for context and parameters
+ */
+ public function __construct( ApiBase $module ) {
+ $this->module = $module;
+
+ $params = $module->extractRequestParams();
+ $this->messageFormat = isset( $params['messageformat'] ) ? $params['messageformat'] : 'wikitext';
+ }
+
+ /**
+ * Static version of the constructor, for chaining
+ * @param ApiBase $module API module, for context and parameters
+ * @return ApiAuthManagerHelper
+ */
+ public static function newForModule( ApiBase $module ) {
+ return new self( $module );
+ }
+
+ /**
+ * Format a message for output
+ * @param array &$res Result array
+ * @param string $key Result key
+ * @param Message $message
+ */
+ private function formatMessage( array &$res, $key, Message $message ) {
+ switch ( $this->messageFormat ) {
+ case 'none':
+ break;
+
+ case 'wikitext':
+ $res[$key] = $message->setContext( $this->module )->text();
+ break;
+
+ case 'html':
+ $res[$key] = $message->setContext( $this->module )->parseAsBlock();
+ $res[$key] = Parser::stripOuterParagraph( $res[$key] );
+ break;
+
+ case 'raw':
+ $res[$key] = [
+ 'key' => $message->getKey(),
+ 'params' => $message->getParams(),
+ ];
+ ApiResult::setIndexedTagName( $res[$key]['params'], 'param' );
+ break;
+ }
+ }
+
+ /**
+ * Call $manager->securitySensitiveOperationStatus()
+ * @param string $operation Operation being checked.
+ * @throws ApiUsageException
+ */
+ public function securitySensitiveOperation( $operation ) {
+ $status = AuthManager::singleton()->securitySensitiveOperationStatus( $operation );
+ switch ( $status ) {
+ case AuthManager::SEC_OK:
+ return;
+
+ case AuthManager::SEC_REAUTH:
+ $this->module->dieWithError( 'apierror-reauthenticate' );
+
+ case AuthManager::SEC_FAIL:
+ $this->module->dieWithError( 'apierror-cannotreauthenticate' );
+
+ default:
+ throw new UnexpectedValueException( "Unknown status \"$status\"" );
+ }
+ }
+
+ /**
+ * Filter out authentication requests by class name
+ * @param AuthenticationRequest[] $reqs Requests to filter
+ * @param string[] $blacklist Class names to remove
+ * @return AuthenticationRequest[]
+ */
+ public static function blacklistAuthenticationRequests( array $reqs, array $blacklist ) {
+ if ( $blacklist ) {
+ $blacklist = array_flip( $blacklist );
+ $reqs = array_filter( $reqs, function ( $req ) use ( $blacklist ) {
+ return !isset( $blacklist[get_class( $req )] );
+ } );
+ }
+ return $reqs;
+ }
+
+ /**
+ * Fetch and load the AuthenticationRequests for an action
+ * @param string $action One of the AuthManager::ACTION_* constants
+ * @return AuthenticationRequest[]
+ */
+ public function loadAuthenticationRequests( $action ) {
+ $params = $this->module->extractRequestParams();
+
+ $manager = AuthManager::singleton();
+ $reqs = $manager->getAuthenticationRequests( $action, $this->module->getUser() );
+
+ // Filter requests, if requested to do so
+ $wantedRequests = null;
+ if ( isset( $params['requests'] ) ) {
+ $wantedRequests = array_flip( $params['requests'] );
+ } elseif ( isset( $params['request'] ) ) {
+ $wantedRequests = [ $params['request'] => true ];
+ }
+ if ( $wantedRequests !== null ) {
+ $reqs = array_filter( $reqs, function ( $req ) use ( $wantedRequests ) {
+ return isset( $wantedRequests[$req->getUniqueId()] );
+ } );
+ }
+
+ // Collect the fields for all the requests
+ $fields = [];
+ $sensitive = [];
+ foreach ( $reqs as $req ) {
+ $info = (array)$req->getFieldInfo();
+ $fields += $info;
+ $sensitive += array_filter( $info, function ( $opts ) {
+ return !empty( $opts['sensitive'] );
+ } );
+ }
+
+ // Extract the request data for the fields and mark those request
+ // parameters as used
+ $data = array_intersect_key( $this->module->getRequest()->getValues(), $fields );
+ $this->module->getMain()->markParamsUsed( array_keys( $data ) );
+
+ if ( $sensitive ) {
+ $this->module->getMain()->markParamsSensitive( array_keys( $sensitive ) );
+ $this->module->requirePostedParameters( array_keys( $sensitive ), 'noprefix' );
+ }
+
+ return AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
+ }
+
+ /**
+ * Format an AuthenticationResponse for return
+ * @param AuthenticationResponse $res
+ * @return array
+ */
+ public function formatAuthenticationResponse( AuthenticationResponse $res ) {
+ $ret = [
+ 'status' => $res->status,
+ ];
+
+ if ( $res->status === AuthenticationResponse::PASS && $res->username !== null ) {
+ $ret['username'] = $res->username;
+ }
+
+ if ( $res->status === AuthenticationResponse::REDIRECT ) {
+ $ret['redirecttarget'] = $res->redirectTarget;
+ if ( $res->redirectApiData !== null ) {
+ $ret['redirectdata'] = $res->redirectApiData;
+ }
+ }
+
+ if ( $res->status === AuthenticationResponse::REDIRECT ||
+ $res->status === AuthenticationResponse::UI ||
+ $res->status === AuthenticationResponse::RESTART
+ ) {
+ $ret += $this->formatRequests( $res->neededRequests );
+ }
+
+ if ( $res->status === AuthenticationResponse::FAIL ||
+ $res->status === AuthenticationResponse::UI ||
+ $res->status === AuthenticationResponse::RESTART
+ ) {
+ $this->formatMessage( $ret, 'message', $res->message );
+ $ret['messagecode'] = ApiMessage::create( $res->message )->getApiCode();
+ }
+
+ if ( $res->status === AuthenticationResponse::FAIL ||
+ $res->status === AuthenticationResponse::RESTART
+ ) {
+ $this->module->getRequest()->getSession()->set(
+ 'ApiAuthManagerHelper::createRequest',
+ $res->createRequest
+ );
+ $ret['canpreservestate'] = $res->createRequest !== null;
+ } else {
+ $this->module->getRequest()->getSession()->remove( 'ApiAuthManagerHelper::createRequest' );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Logs successful or failed authentication.
+ * @param string $event Event type (e.g. 'accountcreation')
+ * @param string|AuthenticationResponse $result Response or error message
+ */
+ public function logAuthenticationResult( $event, $result ) {
+ if ( is_string( $result ) ) {
+ $status = Status::newFatal( $result );
+ } elseif ( $result->status === AuthenticationResponse::PASS ) {
+ $status = Status::newGood();
+ } elseif ( $result->status === AuthenticationResponse::FAIL ) {
+ $status = Status::newFatal( $result->message );
+ } else {
+ return;
+ }
+
+ $module = $this->module->getModuleName();
+ LoggerFactory::getInstance( 'authevents' )->info( "$module API attempt", [
+ 'event' => $event,
+ 'status' => $status,
+ 'module' => $module,
+ ] );
+ }
+
+ /**
+ * Fetch the preserved CreateFromLoginAuthenticationRequest, if any
+ * @return CreateFromLoginAuthenticationRequest|null
+ */
+ public function getPreservedRequest() {
+ $ret = $this->module->getRequest()->getSession()->get( 'ApiAuthManagerHelper::createRequest' );
+ return $ret instanceof CreateFromLoginAuthenticationRequest ? $ret : null;
+ }
+
+ /**
+ * Format an array of AuthenticationRequests for return
+ * @param AuthenticationRequest[] $reqs
+ * @return array Will have a 'requests' key, and also 'fields' if $module's
+ * params include 'mergerequestfields'.
+ */
+ public function formatRequests( array $reqs ) {
+ $params = $this->module->extractRequestParams();
+ $mergeFields = !empty( $params['mergerequestfields'] );
+
+ $ret = [ 'requests' => [] ];
+ foreach ( $reqs as $req ) {
+ $describe = $req->describeCredentials();
+ $reqInfo = [
+ 'id' => $req->getUniqueId(),
+ 'metadata' => $req->getMetadata() + [ ApiResult::META_TYPE => 'assoc' ],
+ ];
+ switch ( $req->required ) {
+ case AuthenticationRequest::OPTIONAL:
+ $reqInfo['required'] = 'optional';
+ break;
+ case AuthenticationRequest::REQUIRED:
+ $reqInfo['required'] = 'required';
+ break;
+ case AuthenticationRequest::PRIMARY_REQUIRED:
+ $reqInfo['required'] = 'primary-required';
+ break;
+ }
+ $this->formatMessage( $reqInfo, 'provider', $describe['provider'] );
+ $this->formatMessage( $reqInfo, 'account', $describe['account'] );
+ if ( !$mergeFields ) {
+ $reqInfo['fields'] = $this->formatFields( (array)$req->getFieldInfo() );
+ }
+ $ret['requests'][] = $reqInfo;
+ }
+
+ if ( $mergeFields ) {
+ $fields = AuthenticationRequest::mergeFieldInfo( $reqs );
+ $ret['fields'] = $this->formatFields( $fields );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Clean up a field array for output
+ * @param ApiBase $module For context and parameters 'mergerequestfields'
+ * and 'messageformat'
+ * @param array $fields
+ * @return array
+ */
+ private function formatFields( array $fields ) {
+ static $copy = [
+ 'type' => true,
+ 'value' => true,
+ ];
+
+ $module = $this->module;
+ $retFields = [];
+
+ foreach ( $fields as $name => $field ) {
+ $ret = array_intersect_key( $field, $copy );
+
+ if ( isset( $field['options'] ) ) {
+ $ret['options'] = array_map( function ( $msg ) use ( $module ) {
+ return $msg->setContext( $module )->plain();
+ }, $field['options'] );
+ ApiResult::setArrayType( $ret['options'], 'assoc' );
+ }
+ $this->formatMessage( $ret, 'label', $field['label'] );
+ $this->formatMessage( $ret, 'help', $field['help'] );
+ $ret['optional'] = !empty( $field['optional'] );
+ $ret['sensitive'] = !empty( $field['sensitive'] );
+
+ $retFields[$name] = $ret;
+ }
+
+ ApiResult::setArrayType( $retFields, 'assoc' );
+
+ return $retFields;
+ }
+
+ /**
+ * Fetch the standard parameters this helper recognizes
+ * @param string $action AuthManager action
+ * @param string $param,... Parameters to use
+ * @return array
+ */
+ public static function getStandardParams( $action, $param /* ... */ ) {
+ $params = [
+ 'requests' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG => [ 'api-help-authmanagerhelper-requests', $action ],
+ ],
+ 'request' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_HELP_MSG => [ 'api-help-authmanagerhelper-request', $action ],
+ ],
+ 'messageformat' => [
+ ApiBase::PARAM_DFLT => 'wikitext',
+ ApiBase::PARAM_TYPE => [ 'html', 'wikitext', 'raw', 'none' ],
+ ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-messageformat',
+ ],
+ 'mergerequestfields' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-mergerequestfields',
+ ],
+ 'preservestate' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-preservestate',
+ ],
+ 'returnurl' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-returnurl',
+ ],
+ 'continue' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'api-help-authmanagerhelper-continue',
+ ],
+ ];
+
+ $ret = [];
+ $wantedParams = func_get_args();
+ array_shift( $wantedParams );
+ foreach ( $wantedParams as $name ) {
+ if ( isset( $params[$name] ) ) {
+ $ret[$name] = $params[$name];
+ }
+ }
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/api/ApiBase.php b/www/wiki/includes/api/ApiBase.php
new file mode 100644
index 00000000..bf2b9779
--- /dev/null
+++ b/www/wiki/includes/api/ApiBase.php
@@ -0,0 +1,2959 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 5, 2006
+ *
+ * Copyright © 2006, 2010 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * This abstract class implements many basic API functions, and is the base of
+ * all API classes.
+ * The class functions are divided into several areas of functionality:
+ *
+ * Module parameters: Derived classes can define getAllowedParams() to specify
+ * which parameters to expect, how to parse and validate them.
+ *
+ * Self-documentation: code to allow the API to document its own state
+ *
+ * @ingroup API
+ */
+abstract class ApiBase extends ContextSource {
+
+ /**
+ * @name Constants for ::getAllowedParams() arrays
+ * These constants are keys in the arrays returned by ::getAllowedParams()
+ * and accepted by ::getParameterFromSettings() that define how the
+ * parameters coming in from the request are to be interpreted.
+ * @{
+ */
+
+ /** (null|boolean|integer|string) Default value of the parameter. */
+ const PARAM_DFLT = 0;
+
+ /** (boolean) Accept multiple pipe-separated values for this parameter (e.g. titles)? */
+ const PARAM_ISMULTI = 1;
+
+ /**
+ * (string|string[]) Either an array of allowed value strings, or a string
+ * type as described below. If not specified, will be determined from the
+ * type of PARAM_DFLT.
+ *
+ * Supported string types are:
+ * - boolean: A boolean parameter, returned as false if the parameter is
+ * omitted and true if present (even with a falsey value, i.e. it works
+ * like HTML checkboxes). PARAM_DFLT must be boolean false, if specified.
+ * Cannot be used with PARAM_ISMULTI.
+ * - integer: An integer value. See also PARAM_MIN, PARAM_MAX, and
+ * PARAM_RANGE_ENFORCE.
+ * - limit: An integer or the string 'max'. Default lower limit is 0 (but
+ * see PARAM_MIN), and requires that PARAM_MAX and PARAM_MAX2 be
+ * specified. Cannot be used with PARAM_ISMULTI.
+ * - namespace: An integer representing a MediaWiki namespace. Forces PARAM_ALL = true to
+ * support easily specifying all namespaces.
+ * - NULL: Any string.
+ * - password: Any non-empty string. Input value is private or sensitive.
+ * <input type="password"> would be an appropriate HTML form field.
+ * - string: Any non-empty string, not expected to be very long or contain newlines.
+ * <input type="text"> would be an appropriate HTML form field.
+ * - submodule: The name of a submodule of this module, see PARAM_SUBMODULE_MAP.
+ * - tags: A string naming an existing, explicitly-defined tag. Should usually be
+ * used with PARAM_ISMULTI.
+ * - text: Any non-empty string, expected to be very long or contain newlines.
+ * <textarea> would be an appropriate HTML form field.
+ * - timestamp: A timestamp in any format recognized by MWTimestamp, or the
+ * string 'now' representing the current timestamp. Will be returned in
+ * TS_MW format.
+ * - user: A MediaWiki username or IP. Will be returned normalized but not canonicalized.
+ * - upload: An uploaded file. Will be returned as a WebRequestUpload object.
+ * Cannot be used with PARAM_ISMULTI.
+ */
+ const PARAM_TYPE = 2;
+
+ /** (integer) Max value allowed for the parameter, for PARAM_TYPE 'integer' and 'limit'. */
+ const PARAM_MAX = 3;
+
+ /**
+ * (integer) Max value allowed for the parameter for users with the
+ * apihighlimits right, for PARAM_TYPE 'limit'.
+ */
+ const PARAM_MAX2 = 4;
+
+ /** (integer) Lowest value allowed for the parameter, for PARAM_TYPE 'integer' and 'limit'. */
+ const PARAM_MIN = 5;
+
+ /** (boolean) Allow the same value to be set more than once when PARAM_ISMULTI is true? */
+ const PARAM_ALLOW_DUPLICATES = 6;
+
+ /** (boolean) Is the parameter deprecated (will show a warning)? */
+ const PARAM_DEPRECATED = 7;
+
+ /**
+ * (boolean) Is the parameter required?
+ * @since 1.17
+ */
+ const PARAM_REQUIRED = 8;
+
+ /**
+ * (boolean) For PARAM_TYPE 'integer', enforce PARAM_MIN and PARAM_MAX?
+ * @since 1.17
+ */
+ const PARAM_RANGE_ENFORCE = 9;
+
+ /**
+ * (string|array|Message) Specify an alternative i18n documentation message
+ * for this parameter. Default is apihelp-{$path}-param-{$param}.
+ * @since 1.25
+ */
+ const PARAM_HELP_MSG = 10;
+
+ /**
+ * ((string|array|Message)[]) Specify additional i18n messages to append to
+ * the normal message for this parameter.
+ * @since 1.25
+ */
+ const PARAM_HELP_MSG_APPEND = 11;
+
+ /**
+ * (array) Specify additional information tags for the parameter. Value is
+ * an array of arrays, with the first member being the 'tag' for the info
+ * and the remaining members being the values. In the help, this is
+ * formatted using apihelp-{$path}-paraminfo-{$tag}, which is passed
+ * $1 = count, $2 = comma-joined list of values, $3 = module prefix.
+ * @since 1.25
+ */
+ const PARAM_HELP_MSG_INFO = 12;
+
+ /**
+ * (string[]) When PARAM_TYPE is an array, this may be an array mapping
+ * those values to page titles which will be linked in the help.
+ * @since 1.25
+ */
+ const PARAM_VALUE_LINKS = 13;
+
+ /**
+ * ((string|array|Message)[]) When PARAM_TYPE is an array, this is an array
+ * mapping those values to $msg for ApiBase::makeMessage(). Any value not
+ * having a mapping will use apihelp-{$path}-paramvalue-{$param}-{$value}.
+ * @since 1.25
+ */
+ const PARAM_HELP_MSG_PER_VALUE = 14;
+
+ /**
+ * (string[]) When PARAM_TYPE is 'submodule', map parameter values to
+ * submodule paths. Default is to use all modules in
+ * $this->getModuleManager() in the group matching the parameter name.
+ * @since 1.26
+ */
+ const PARAM_SUBMODULE_MAP = 15;
+
+ /**
+ * (string) When PARAM_TYPE is 'submodule', used to indicate the 'g' prefix
+ * added by ApiQueryGeneratorBase (and similar if anything else ever does that).
+ * @since 1.26
+ */
+ const PARAM_SUBMODULE_PARAM_PREFIX = 16;
+
+ /**
+ * (boolean|string) When PARAM_TYPE has a defined set of values and PARAM_ISMULTI is true,
+ * this allows for an asterisk ('*') to be passed in place of a pipe-separated list of
+ * every possible value. If a string is set, it will be used in place of the asterisk.
+ * @since 1.29
+ */
+ const PARAM_ALL = 17;
+
+ /**
+ * (int[]) When PARAM_TYPE is 'namespace', include these as additional possible values.
+ * @since 1.29
+ */
+ const PARAM_EXTRA_NAMESPACES = 18;
+
+ /**
+ * (boolean) Is the parameter sensitive? Note 'password'-type fields are
+ * always sensitive regardless of the value of this field.
+ * @since 1.29
+ */
+ const PARAM_SENSITIVE = 19;
+
+ /**
+ * (array) When PARAM_TYPE is an array, this indicates which of the values are deprecated.
+ * Keys are the deprecated parameter values, values define the warning
+ * message to emit: either boolean true (to use a default message) or a
+ * $msg for ApiBase::makeMessage().
+ * @since 1.30
+ */
+ const PARAM_DEPRECATED_VALUES = 20;
+
+ /**
+ * (integer) Maximum number of values, for normal users. Must be used with PARAM_ISMULTI.
+ * @since 1.30
+ */
+ const PARAM_ISMULTI_LIMIT1 = 21;
+
+ /**
+ * (integer) Maximum number of values, for users with the apihighimits right.
+ * Must be used with PARAM_ISMULTI.
+ * @since 1.30
+ */
+ const PARAM_ISMULTI_LIMIT2 = 22;
+
+ /**@}*/
+
+ const ALL_DEFAULT_STRING = '*';
+
+ /** Fast query, standard limit. */
+ const LIMIT_BIG1 = 500;
+ /** Fast query, apihighlimits limit. */
+ const LIMIT_BIG2 = 5000;
+ /** Slow query, standard limit. */
+ const LIMIT_SML1 = 50;
+ /** Slow query, apihighlimits limit. */
+ const LIMIT_SML2 = 500;
+
+ /**
+ * getAllowedParams() flag: When set, the result could take longer to generate,
+ * but should be more thorough. E.g. get the list of generators for ApiSandBox extension
+ * @since 1.21
+ */
+ const GET_VALUES_FOR_HELP = 1;
+
+ /** @var array Maps extension paths to info arrays */
+ private static $extensionInfo = null;
+
+ /** @var ApiMain */
+ private $mMainModule;
+ /** @var string */
+ private $mModuleName, $mModulePrefix;
+ private $mSlaveDB = null;
+ private $mParamCache = [];
+ /** @var array|null|bool */
+ private $mModuleSource = false;
+
+ /**
+ * @param ApiMain $mainModule
+ * @param string $moduleName Name of this module
+ * @param string $modulePrefix Prefix to use for parameter names
+ */
+ public function __construct( ApiMain $mainModule, $moduleName, $modulePrefix = '' ) {
+ $this->mMainModule = $mainModule;
+ $this->mModuleName = $moduleName;
+ $this->mModulePrefix = $modulePrefix;
+
+ if ( !$this->isMain() ) {
+ $this->setContext( $mainModule->getContext() );
+ }
+ }
+
+ /************************************************************************//**
+ * @name Methods to implement
+ * @{
+ */
+
+ /**
+ * Evaluates the parameters, performs the requested query, and sets up
+ * the result. Concrete implementations of ApiBase must override this
+ * method to provide whatever functionality their module offers.
+ * Implementations must not produce any output on their own and are not
+ * expected to handle any errors.
+ *
+ * The execute() method will be invoked directly by ApiMain immediately
+ * before the result of the module is output. Aside from the
+ * constructor, implementations should assume that no other methods
+ * will be called externally on the module before the result is
+ * processed.
+ *
+ * The result data should be stored in the ApiResult object available
+ * through getResult().
+ */
+ abstract public function execute();
+
+ /**
+ * Get the module manager, or null if this module has no sub-modules
+ * @since 1.21
+ * @return ApiModuleManager
+ */
+ public function getModuleManager() {
+ return null;
+ }
+
+ /**
+ * If the module may only be used with a certain format module,
+ * it should override this method to return an instance of that formatter.
+ * A value of null means the default format will be used.
+ * @note Do not use this just because you don't want to support non-json
+ * formats. This should be used only when there is a fundamental
+ * requirement for a specific format.
+ * @return mixed Instance of a derived class of ApiFormatBase, or null
+ */
+ public function getCustomPrinter() {
+ return null;
+ }
+
+ /**
+ * Returns usage examples for this module.
+ *
+ * Return value has query strings as keys, with values being either strings
+ * (message key), arrays (message key + parameter), or Message objects.
+ *
+ * Do not call this base class implementation when overriding this method.
+ *
+ * @since 1.25
+ * @return array
+ */
+ protected function getExamplesMessages() {
+ // Fall back to old non-localised method
+ $ret = [];
+
+ $examples = $this->getExamples();
+ if ( $examples ) {
+ if ( !is_array( $examples ) ) {
+ $examples = [ $examples ];
+ } elseif ( $examples && ( count( $examples ) & 1 ) == 0 &&
+ array_keys( $examples ) === range( 0, count( $examples ) - 1 ) &&
+ !preg_match( '/^\s*api\.php\?/', $examples[0] )
+ ) {
+ // Fix up the ugly "even numbered elements are description, odd
+ // numbered elemts are the link" format (see doc for self::getExamples)
+ $tmp = [];
+ $examplesCount = count( $examples );
+ for ( $i = 0; $i < $examplesCount; $i += 2 ) {
+ $tmp[$examples[$i + 1]] = $examples[$i];
+ }
+ $examples = $tmp;
+ }
+
+ foreach ( $examples as $k => $v ) {
+ if ( is_numeric( $k ) ) {
+ $qs = $v;
+ $msg = '';
+ } else {
+ $qs = $k;
+ $msg = self::escapeWikiText( $v );
+ if ( is_array( $msg ) ) {
+ $msg = implode( ' ', $msg );
+ }
+ }
+
+ $qs = preg_replace( '/^\s*api\.php\?/', '', $qs );
+ $ret[$qs] = $this->msg( 'api-help-fallback-example', [ $msg ] );
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Return links to more detailed help pages about the module.
+ * @since 1.25, returning boolean false is deprecated
+ * @return string|array
+ */
+ public function getHelpUrls() {
+ return [];
+ }
+
+ /**
+ * Returns an array of allowed parameters (parameter name) => (default
+ * value) or (parameter name) => (array with PARAM_* constants as keys)
+ * Don't call this function directly: use getFinalParams() to allow
+ * hooks to modify parameters as needed.
+ *
+ * Some derived classes may choose to handle an integer $flags parameter
+ * in the overriding methods. Callers of this method can pass zero or
+ * more OR-ed flags like GET_VALUES_FOR_HELP.
+ *
+ * @return array
+ */
+ protected function getAllowedParams( /* $flags = 0 */ ) {
+ // int $flags is not declared because it causes "Strict standards"
+ // warning. Most derived classes do not implement it.
+ return [];
+ }
+
+ /**
+ * Indicates if this module needs maxlag to be checked
+ * @return bool
+ */
+ public function shouldCheckMaxlag() {
+ return true;
+ }
+
+ /**
+ * Indicates whether this module requires read rights
+ * @return bool
+ */
+ public function isReadMode() {
+ return true;
+ }
+
+ /**
+ * Indicates whether this module requires write mode
+ *
+ * This should return true for modules that may require synchronous database writes.
+ * Modules that do not need such writes should also not rely on master database access,
+ * since only read queries are needed and each master DB is a single point of failure.
+ * Additionally, requests that only need replica DBs can be efficiently routed to any
+ * datacenter via the Promise-Non-Write-API-Action header.
+ *
+ * @return bool
+ */
+ public function isWriteMode() {
+ return false;
+ }
+
+ /**
+ * Indicates whether this module must be called with a POST request
+ * @return bool
+ */
+ public function mustBePosted() {
+ return $this->needsToken() !== false;
+ }
+
+ /**
+ * Indicates whether this module is deprecated
+ * @since 1.25
+ * @return bool
+ */
+ public function isDeprecated() {
+ return false;
+ }
+
+ /**
+ * Indicates whether this module is "internal"
+ * Internal API modules are not (yet) intended for 3rd party use and may be unstable.
+ * @since 1.25
+ * @return bool
+ */
+ public function isInternal() {
+ return false;
+ }
+
+ /**
+ * Returns the token type this module requires in order to execute.
+ *
+ * Modules are strongly encouraged to use the core 'csrf' type unless they
+ * have specialized security needs. If the token type is not one of the
+ * core types, you must use the ApiQueryTokensRegisterTypes hook to
+ * register it.
+ *
+ * Returning a non-falsey value here will force the addition of an
+ * appropriate 'token' parameter in self::getFinalParams(). Also,
+ * self::mustBePosted() must return true when tokens are used.
+ *
+ * In previous versions of MediaWiki, true was a valid return value.
+ * Returning true will generate errors indicating that the API module needs
+ * updating.
+ *
+ * @return string|false
+ */
+ public function needsToken() {
+ return false;
+ }
+
+ /**
+ * Fetch the salt used in the Web UI corresponding to this module.
+ *
+ * Only override this if the Web UI uses a token with a non-constant salt.
+ *
+ * @since 1.24
+ * @param array $params All supplied parameters for the module
+ * @return string|array|null
+ */
+ protected function getWebUITokenSalt( array $params ) {
+ return null;
+ }
+
+ /**
+ * Returns data for HTTP conditional request mechanisms.
+ *
+ * @since 1.26
+ * @param string $condition Condition being queried:
+ * - last-modified: Return a timestamp representing the maximum of the
+ * last-modified dates for all resources involved in the request. See
+ * RFC 7232 § 2.2 for semantics.
+ * - etag: Return an entity-tag representing the state of all resources involved
+ * in the request. Quotes must be included. See RFC 7232 § 2.3 for semantics.
+ * @return string|bool|null As described above, or null if no value is available.
+ */
+ public function getConditionalRequestData( $condition ) {
+ return null;
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Data access methods
+ * @{
+ */
+
+ /**
+ * Get the name of the module being executed by this instance
+ * @return string
+ */
+ public function getModuleName() {
+ return $this->mModuleName;
+ }
+
+ /**
+ * Get parameter prefix (usually two letters or an empty string).
+ * @return string
+ */
+ public function getModulePrefix() {
+ return $this->mModulePrefix;
+ }
+
+ /**
+ * Get the main module
+ * @return ApiMain
+ */
+ public function getMain() {
+ return $this->mMainModule;
+ }
+
+ /**
+ * Returns true if this module is the main module ($this === $this->mMainModule),
+ * false otherwise.
+ * @return bool
+ */
+ public function isMain() {
+ return $this === $this->mMainModule;
+ }
+
+ /**
+ * Get the parent of this module
+ * @since 1.25
+ * @return ApiBase|null
+ */
+ public function getParent() {
+ return $this->isMain() ? null : $this->getMain();
+ }
+
+ /**
+ * Returns true if the current request breaks the same-origin policy.
+ *
+ * For example, json with callbacks.
+ *
+ * https://en.wikipedia.org/wiki/Same-origin_policy
+ *
+ * @since 1.25
+ * @return bool
+ */
+ public function lacksSameOriginSecurity() {
+ // Main module has this method overridden
+ // Safety - avoid infinite loop:
+ if ( $this->isMain() ) {
+ self::dieDebug( __METHOD__, 'base method was called on main module.' );
+ }
+
+ return $this->getMain()->lacksSameOriginSecurity();
+ }
+
+ /**
+ * Get the path to this module
+ *
+ * @since 1.25
+ * @return string
+ */
+ public function getModulePath() {
+ if ( $this->isMain() ) {
+ return 'main';
+ } elseif ( $this->getParent()->isMain() ) {
+ return $this->getModuleName();
+ } else {
+ return $this->getParent()->getModulePath() . '+' . $this->getModuleName();
+ }
+ }
+
+ /**
+ * Get a module from its module path
+ *
+ * @since 1.25
+ * @param string $path
+ * @return ApiBase|null
+ * @throws ApiUsageException
+ */
+ public function getModuleFromPath( $path ) {
+ $module = $this->getMain();
+ if ( $path === 'main' ) {
+ return $module;
+ }
+
+ $parts = explode( '+', $path );
+ if ( count( $parts ) === 1 ) {
+ // In case the '+' was typed into URL, it resolves as a space
+ $parts = explode( ' ', $path );
+ }
+
+ $count = count( $parts );
+ for ( $i = 0; $i < $count; $i++ ) {
+ $parent = $module;
+ $manager = $parent->getModuleManager();
+ if ( $manager === null ) {
+ $errorPath = implode( '+', array_slice( $parts, 0, $i ) );
+ $this->dieWithError( [ 'apierror-badmodule-nosubmodules', $errorPath ], 'badmodule' );
+ }
+ $module = $manager->getModule( $parts[$i] );
+
+ if ( $module === null ) {
+ $errorPath = $i ? implode( '+', array_slice( $parts, 0, $i ) ) : $parent->getModuleName();
+ $this->dieWithError(
+ [ 'apierror-badmodule-badsubmodule', $errorPath, wfEscapeWikiText( $parts[$i] ) ],
+ 'badmodule'
+ );
+ }
+ }
+
+ return $module;
+ }
+
+ /**
+ * Get the result object
+ * @return ApiResult
+ */
+ public function getResult() {
+ // Main module has getResult() method overridden
+ // Safety - avoid infinite loop:
+ if ( $this->isMain() ) {
+ self::dieDebug( __METHOD__, 'base method was called on main module. ' );
+ }
+
+ return $this->getMain()->getResult();
+ }
+
+ /**
+ * Get the error formatter
+ * @return ApiErrorFormatter
+ */
+ public function getErrorFormatter() {
+ // Main module has getErrorFormatter() method overridden
+ // Safety - avoid infinite loop:
+ if ( $this->isMain() ) {
+ self::dieDebug( __METHOD__, 'base method was called on main module. ' );
+ }
+
+ return $this->getMain()->getErrorFormatter();
+ }
+
+ /**
+ * Gets a default replica DB connection object
+ * @return IDatabase
+ */
+ protected function getDB() {
+ if ( !isset( $this->mSlaveDB ) ) {
+ $this->mSlaveDB = wfGetDB( DB_REPLICA, 'api' );
+ }
+
+ return $this->mSlaveDB;
+ }
+
+ /**
+ * Get the continuation manager
+ * @return ApiContinuationManager|null
+ */
+ public function getContinuationManager() {
+ // Main module has getContinuationManager() method overridden
+ // Safety - avoid infinite loop:
+ if ( $this->isMain() ) {
+ self::dieDebug( __METHOD__, 'base method was called on main module. ' );
+ }
+
+ return $this->getMain()->getContinuationManager();
+ }
+
+ /**
+ * Set the continuation manager
+ * @param ApiContinuationManager|null $manager
+ */
+ public function setContinuationManager( $manager ) {
+ // Main module has setContinuationManager() method overridden
+ // Safety - avoid infinite loop:
+ if ( $this->isMain() ) {
+ self::dieDebug( __METHOD__, 'base method was called on main module. ' );
+ }
+
+ $this->getMain()->setContinuationManager( $manager );
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Parameter handling
+ * @{
+ */
+
+ /**
+ * Indicate if the module supports dynamically-determined parameters that
+ * cannot be included in self::getAllowedParams().
+ * @return string|array|Message|null Return null if the module does not
+ * support additional dynamic parameters, otherwise return a message
+ * describing them.
+ */
+ public function dynamicParameterDocumentation() {
+ return null;
+ }
+
+ /**
+ * This method mangles parameter name based on the prefix supplied to the constructor.
+ * Override this method to change parameter name during runtime
+ * @param string|string[] $paramName Parameter name
+ * @return string|string[] Prefixed parameter name
+ * @since 1.29 accepts an array of strings
+ */
+ public function encodeParamName( $paramName ) {
+ if ( is_array( $paramName ) ) {
+ return array_map( function ( $name ) {
+ return $this->mModulePrefix . $name;
+ }, $paramName );
+ } else {
+ return $this->mModulePrefix . $paramName;
+ }
+ }
+
+ /**
+ * Using getAllowedParams(), this function makes an array of the values
+ * provided by the user, with key being the name of the variable, and
+ * value - validated value from user or default. limits will not be
+ * parsed if $parseLimit is set to false; use this when the max
+ * limit is not definitive yet, e.g. when getting revisions.
+ * @param bool $parseLimit True by default
+ * @return array
+ */
+ public function extractRequestParams( $parseLimit = true ) {
+ // Cache parameters, for performance and to avoid T26564.
+ if ( !isset( $this->mParamCache[$parseLimit] ) ) {
+ $params = $this->getFinalParams();
+ $results = [];
+
+ if ( $params ) { // getFinalParams() can return false
+ foreach ( $params as $paramName => $paramSettings ) {
+ $results[$paramName] = $this->getParameterFromSettings(
+ $paramName, $paramSettings, $parseLimit );
+ }
+ }
+ $this->mParamCache[$parseLimit] = $results;
+ }
+
+ return $this->mParamCache[$parseLimit];
+ }
+
+ /**
+ * Get a value for the given parameter
+ * @param string $paramName Parameter name
+ * @param bool $parseLimit See extractRequestParams()
+ * @return mixed Parameter value
+ */
+ protected function getParameter( $paramName, $parseLimit = true ) {
+ $paramSettings = $this->getFinalParams()[$paramName];
+
+ return $this->getParameterFromSettings( $paramName, $paramSettings, $parseLimit );
+ }
+
+ /**
+ * Die if none or more than one of a certain set of parameters is set and not false.
+ *
+ * @param array $params User provided set of parameters, as from $this->extractRequestParams()
+ * @param string $required,... Names of parameters of which exactly one must be set
+ */
+ public function requireOnlyOneParameter( $params, $required /*...*/ ) {
+ $required = func_get_args();
+ array_shift( $required );
+
+ $intersection = array_intersect( array_keys( array_filter( $params,
+ [ $this, 'parameterNotEmpty' ] ) ), $required );
+
+ if ( count( $intersection ) > 1 ) {
+ $this->dieWithError( [
+ 'apierror-invalidparammix',
+ Message::listParam( array_map(
+ function ( $p ) {
+ return '<var>' . $this->encodeParamName( $p ) . '</var>';
+ },
+ array_values( $intersection )
+ ) ),
+ count( $intersection ),
+ ] );
+ } elseif ( count( $intersection ) == 0 ) {
+ $this->dieWithError( [
+ 'apierror-missingparam-one-of',
+ Message::listParam( array_map(
+ function ( $p ) {
+ return '<var>' . $this->encodeParamName( $p ) . '</var>';
+ },
+ array_values( $required )
+ ) ),
+ count( $required ),
+ ], 'missingparam' );
+ }
+ }
+
+ /**
+ * Die if more than one of a certain set of parameters is set and not false.
+ *
+ * @param array $params User provided set of parameters, as from $this->extractRequestParams()
+ * @param string $required,... Names of parameters of which at most one must be set
+ */
+ public function requireMaxOneParameter( $params, $required /*...*/ ) {
+ $required = func_get_args();
+ array_shift( $required );
+
+ $intersection = array_intersect( array_keys( array_filter( $params,
+ [ $this, 'parameterNotEmpty' ] ) ), $required );
+
+ if ( count( $intersection ) > 1 ) {
+ $this->dieWithError( [
+ 'apierror-invalidparammix',
+ Message::listParam( array_map(
+ function ( $p ) {
+ return '<var>' . $this->encodeParamName( $p ) . '</var>';
+ },
+ array_values( $intersection )
+ ) ),
+ count( $intersection ),
+ ] );
+ }
+ }
+
+ /**
+ * Die if none of a certain set of parameters is set and not false.
+ *
+ * @since 1.23
+ * @param array $params User provided set of parameters, as from $this->extractRequestParams()
+ * @param string $required,... Names of parameters of which at least one must be set
+ */
+ public function requireAtLeastOneParameter( $params, $required /*...*/ ) {
+ $required = func_get_args();
+ array_shift( $required );
+
+ $intersection = array_intersect(
+ array_keys( array_filter( $params, [ $this, 'parameterNotEmpty' ] ) ),
+ $required
+ );
+
+ if ( count( $intersection ) == 0 ) {
+ $this->dieWithError( [
+ 'apierror-missingparam-at-least-one-of',
+ Message::listParam( array_map(
+ function ( $p ) {
+ return '<var>' . $this->encodeParamName( $p ) . '</var>';
+ },
+ array_values( $required )
+ ) ),
+ count( $required ),
+ ], 'missingparam' );
+ }
+ }
+
+ /**
+ * Die if any of the specified parameters were found in the query part of
+ * the URL rather than the post body.
+ * @since 1.28
+ * @param string[] $params Parameters to check
+ * @param string $prefix Set to 'noprefix' to skip calling $this->encodeParamName()
+ */
+ public function requirePostedParameters( $params, $prefix = 'prefix' ) {
+ // Skip if $wgDebugAPI is set or we're in internal mode
+ if ( $this->getConfig()->get( 'DebugAPI' ) || $this->getMain()->isInternalMode() ) {
+ return;
+ }
+
+ $queryValues = $this->getRequest()->getQueryValues();
+ $badParams = [];
+ foreach ( $params as $param ) {
+ if ( $prefix !== 'noprefix' ) {
+ $param = $this->encodeParamName( $param );
+ }
+ if ( array_key_exists( $param, $queryValues ) ) {
+ $badParams[] = $param;
+ }
+ }
+
+ if ( $badParams ) {
+ $this->dieWithError(
+ [ 'apierror-mustpostparams', join( ', ', $badParams ), count( $badParams ) ]
+ );
+ }
+ }
+
+ /**
+ * Callback function used in requireOnlyOneParameter to check whether required parameters are set
+ *
+ * @param object $x Parameter to check is not null/false
+ * @return bool
+ */
+ private function parameterNotEmpty( $x ) {
+ return !is_null( $x ) && $x !== false;
+ }
+
+ /**
+ * Get a WikiPage object from a title or pageid param, if possible.
+ * Can die, if no param is set or if the title or page id is not valid.
+ *
+ * @param array $params User provided set of parameters, as from $this->extractRequestParams()
+ * @param bool|string $load Whether load the object's state from the database:
+ * - false: don't load (if the pageid is given, it will still be loaded)
+ * - 'fromdb': load from a replica DB
+ * - 'fromdbmaster': load from the master database
+ * @return WikiPage
+ */
+ public function getTitleOrPageId( $params, $load = false ) {
+ $this->requireOnlyOneParameter( $params, 'title', 'pageid' );
+
+ $pageObj = null;
+ if ( isset( $params['title'] ) ) {
+ $titleObj = Title::newFromText( $params['title'] );
+ if ( !$titleObj || $titleObj->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
+ }
+ if ( !$titleObj->canExist() ) {
+ $this->dieWithError( 'apierror-pagecannotexist' );
+ }
+ $pageObj = WikiPage::factory( $titleObj );
+ if ( $load !== false ) {
+ $pageObj->loadPageData( $load );
+ }
+ } elseif ( isset( $params['pageid'] ) ) {
+ if ( $load === false ) {
+ $load = 'fromdb';
+ }
+ $pageObj = WikiPage::newFromID( $params['pageid'], $load );
+ if ( !$pageObj ) {
+ $this->dieWithError( [ 'apierror-nosuchpageid', $params['pageid'] ] );
+ }
+ }
+
+ return $pageObj;
+ }
+
+ /**
+ * Get a Title object from a title or pageid param, if possible.
+ * Can die, if no param is set or if the title or page id is not valid.
+ *
+ * @since 1.29
+ * @param array $params User provided set of parameters, as from $this->extractRequestParams()
+ * @return Title
+ */
+ public function getTitleFromTitleOrPageId( $params ) {
+ $this->requireOnlyOneParameter( $params, 'title', 'pageid' );
+
+ $titleObj = null;
+ if ( isset( $params['title'] ) ) {
+ $titleObj = Title::newFromText( $params['title'] );
+ if ( !$titleObj || $titleObj->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
+ }
+ return $titleObj;
+ } elseif ( isset( $params['pageid'] ) ) {
+ $titleObj = Title::newFromID( $params['pageid'] );
+ if ( !$titleObj ) {
+ $this->dieWithError( [ 'apierror-nosuchpageid', $params['pageid'] ] );
+ }
+ }
+
+ return $titleObj;
+ }
+
+ /**
+ * Return true if we're to watch the page, false if not, null if no change.
+ * @param string $watchlist Valid values: 'watch', 'unwatch', 'preferences', 'nochange'
+ * @param Title $titleObj The page under consideration
+ * @param string $userOption The user option to consider when $watchlist=preferences.
+ * If not set will use watchdefault always and watchcreations if $titleObj doesn't exist.
+ * @return bool
+ */
+ protected function getWatchlistValue( $watchlist, $titleObj, $userOption = null ) {
+ $userWatching = $this->getUser()->isWatched( $titleObj, User::IGNORE_USER_RIGHTS );
+
+ switch ( $watchlist ) {
+ case 'watch':
+ return true;
+
+ case 'unwatch':
+ return false;
+
+ case 'preferences':
+ # If the user is already watching, don't bother checking
+ if ( $userWatching ) {
+ return true;
+ }
+ # If no user option was passed, use watchdefault and watchcreations
+ if ( is_null( $userOption ) ) {
+ return $this->getUser()->getBoolOption( 'watchdefault' ) ||
+ $this->getUser()->getBoolOption( 'watchcreations' ) && !$titleObj->exists();
+ }
+
+ # Watch the article based on the user preference
+ return $this->getUser()->getBoolOption( $userOption );
+
+ case 'nochange':
+ return $userWatching;
+
+ default:
+ return $userWatching;
+ }
+ }
+
+ /**
+ * Using the settings determine the value for the given parameter
+ *
+ * @param string $paramName Parameter name
+ * @param array|mixed $paramSettings Default value or an array of settings
+ * using PARAM_* constants.
+ * @param bool $parseLimit Parse limit?
+ * @return mixed Parameter value
+ */
+ protected function getParameterFromSettings( $paramName, $paramSettings, $parseLimit ) {
+ // Some classes may decide to change parameter names
+ $encParamName = $this->encodeParamName( $paramName );
+
+ // Shorthand
+ if ( !is_array( $paramSettings ) ) {
+ $paramSettings = [
+ self::PARAM_DFLT => $paramSettings,
+ ];
+ }
+
+ $default = isset( $paramSettings[self::PARAM_DFLT] )
+ ? $paramSettings[self::PARAM_DFLT]
+ : null;
+ $multi = isset( $paramSettings[self::PARAM_ISMULTI] )
+ ? $paramSettings[self::PARAM_ISMULTI]
+ : false;
+ $multiLimit1 = isset( $paramSettings[self::PARAM_ISMULTI_LIMIT1] )
+ ? $paramSettings[self::PARAM_ISMULTI_LIMIT1]
+ : null;
+ $multiLimit2 = isset( $paramSettings[self::PARAM_ISMULTI_LIMIT2] )
+ ? $paramSettings[self::PARAM_ISMULTI_LIMIT2]
+ : null;
+ $type = isset( $paramSettings[self::PARAM_TYPE] )
+ ? $paramSettings[self::PARAM_TYPE]
+ : null;
+ $dupes = isset( $paramSettings[self::PARAM_ALLOW_DUPLICATES] )
+ ? $paramSettings[self::PARAM_ALLOW_DUPLICATES]
+ : false;
+ $deprecated = isset( $paramSettings[self::PARAM_DEPRECATED] )
+ ? $paramSettings[self::PARAM_DEPRECATED]
+ : false;
+ $deprecatedValues = isset( $paramSettings[self::PARAM_DEPRECATED_VALUES] )
+ ? $paramSettings[self::PARAM_DEPRECATED_VALUES]
+ : [];
+ $required = isset( $paramSettings[self::PARAM_REQUIRED] )
+ ? $paramSettings[self::PARAM_REQUIRED]
+ : false;
+ $allowAll = isset( $paramSettings[self::PARAM_ALL] )
+ ? $paramSettings[self::PARAM_ALL]
+ : false;
+
+ // When type is not given, and no choices, the type is the same as $default
+ if ( !isset( $type ) ) {
+ if ( isset( $default ) ) {
+ $type = gettype( $default );
+ } else {
+ $type = 'NULL'; // allow everything
+ }
+ }
+
+ if ( $type == 'password' || !empty( $paramSettings[self::PARAM_SENSITIVE] ) ) {
+ $this->getMain()->markParamsSensitive( $encParamName );
+ }
+
+ if ( $type == 'boolean' ) {
+ if ( isset( $default ) && $default !== false ) {
+ // Having a default value of anything other than 'false' is not allowed
+ self::dieDebug(
+ __METHOD__,
+ "Boolean param $encParamName's default is set to '$default'. " .
+ 'Boolean parameters must default to false.'
+ );
+ }
+
+ $value = $this->getMain()->getCheck( $encParamName );
+ } elseif ( $type == 'upload' ) {
+ if ( isset( $default ) ) {
+ // Having a default value is not allowed
+ self::dieDebug(
+ __METHOD__,
+ "File upload param $encParamName's default is set to " .
+ "'$default'. File upload parameters may not have a default." );
+ }
+ if ( $multi ) {
+ self::dieDebug( __METHOD__, "Multi-values not supported for $encParamName" );
+ }
+ $value = $this->getMain()->getUpload( $encParamName );
+ if ( !$value->exists() ) {
+ // This will get the value without trying to normalize it
+ // (because trying to normalize a large binary file
+ // accidentally uploaded as a field fails spectacularly)
+ $value = $this->getMain()->getRequest()->unsetVal( $encParamName );
+ if ( $value !== null ) {
+ $this->dieWithError(
+ [ 'apierror-badupload', $encParamName ],
+ "badupload_{$encParamName}"
+ );
+ }
+ }
+ } else {
+ $value = $this->getMain()->getVal( $encParamName, $default );
+
+ if ( isset( $value ) && $type == 'namespace' ) {
+ $type = MWNamespace::getValidNamespaces();
+ if ( isset( $paramSettings[self::PARAM_EXTRA_NAMESPACES] ) &&
+ is_array( $paramSettings[self::PARAM_EXTRA_NAMESPACES] )
+ ) {
+ $type = array_merge( $type, $paramSettings[self::PARAM_EXTRA_NAMESPACES] );
+ }
+ // By default, namespace parameters allow ALL_DEFAULT_STRING to be used to specify
+ // all namespaces.
+ $allowAll = true;
+ }
+ if ( isset( $value ) && $type == 'submodule' ) {
+ if ( isset( $paramSettings[self::PARAM_SUBMODULE_MAP] ) ) {
+ $type = array_keys( $paramSettings[self::PARAM_SUBMODULE_MAP] );
+ } else {
+ $type = $this->getModuleManager()->getNames( $paramName );
+ }
+ }
+
+ $request = $this->getMain()->getRequest();
+ $rawValue = $request->getRawVal( $encParamName );
+ if ( $rawValue === null ) {
+ $rawValue = $default;
+ }
+
+ // Preserve U+001F for self::parseMultiValue(), or error out if that won't be called
+ if ( isset( $value ) && substr( $rawValue, 0, 1 ) === "\x1f" ) {
+ if ( $multi ) {
+ // This loses the potential $wgContLang->checkTitleEncoding() transformation
+ // done by WebRequest for $_GET. Let's call that a feature.
+ $value = join( "\x1f", $request->normalizeUnicode( explode( "\x1f", $rawValue ) ) );
+ } else {
+ $this->dieWithError( 'apierror-badvalue-notmultivalue', 'badvalue_notmultivalue' );
+ }
+ }
+
+ // Check for NFC normalization, and warn
+ if ( $rawValue !== $value ) {
+ $this->handleParamNormalization( $paramName, $value, $rawValue );
+ }
+ }
+
+ $allSpecifier = ( is_string( $allowAll ) ? $allowAll : self::ALL_DEFAULT_STRING );
+ if ( $allowAll && $multi && is_array( $type ) && in_array( $allSpecifier, $type, true ) ) {
+ self::dieDebug(
+ __METHOD__,
+ "For param $encParamName, PARAM_ALL collides with a possible value" );
+ }
+ if ( isset( $value ) && ( $multi || is_array( $type ) ) ) {
+ $value = $this->parseMultiValue(
+ $encParamName,
+ $value,
+ $multi,
+ is_array( $type ) ? $type : null,
+ $allowAll ? $allSpecifier : null,
+ $multiLimit1,
+ $multiLimit2
+ );
+ }
+
+ // More validation only when choices were not given
+ // choices were validated in parseMultiValue()
+ if ( isset( $value ) ) {
+ if ( !is_array( $type ) ) {
+ switch ( $type ) {
+ case 'NULL': // nothing to do
+ break;
+ case 'string':
+ case 'text':
+ case 'password':
+ if ( $required && $value === '' ) {
+ $this->dieWithError( [ 'apierror-missingparam', $paramName ] );
+ }
+ break;
+ case 'integer': // Force everything using intval() and optionally validate limits
+ $min = isset( $paramSettings[self::PARAM_MIN] ) ? $paramSettings[self::PARAM_MIN] : null;
+ $max = isset( $paramSettings[self::PARAM_MAX] ) ? $paramSettings[self::PARAM_MAX] : null;
+ $enforceLimits = isset( $paramSettings[self::PARAM_RANGE_ENFORCE] )
+ ? $paramSettings[self::PARAM_RANGE_ENFORCE] : false;
+
+ if ( is_array( $value ) ) {
+ $value = array_map( 'intval', $value );
+ if ( !is_null( $min ) || !is_null( $max ) ) {
+ foreach ( $value as &$v ) {
+ $this->validateLimit( $paramName, $v, $min, $max, null, $enforceLimits );
+ }
+ }
+ } else {
+ $value = intval( $value );
+ if ( !is_null( $min ) || !is_null( $max ) ) {
+ $this->validateLimit( $paramName, $value, $min, $max, null, $enforceLimits );
+ }
+ }
+ break;
+ case 'limit':
+ if ( !$parseLimit ) {
+ // Don't do any validation whatsoever
+ break;
+ }
+ if ( !isset( $paramSettings[self::PARAM_MAX] )
+ || !isset( $paramSettings[self::PARAM_MAX2] )
+ ) {
+ self::dieDebug(
+ __METHOD__,
+ "MAX1 or MAX2 are not defined for the limit $encParamName"
+ );
+ }
+ if ( $multi ) {
+ self::dieDebug( __METHOD__, "Multi-values not supported for $encParamName" );
+ }
+ $min = isset( $paramSettings[self::PARAM_MIN] ) ? $paramSettings[self::PARAM_MIN] : 0;
+ if ( $value == 'max' ) {
+ $value = $this->getMain()->canApiHighLimits()
+ ? $paramSettings[self::PARAM_MAX2]
+ : $paramSettings[self::PARAM_MAX];
+ $this->getResult()->addParsedLimit( $this->getModuleName(), $value );
+ } else {
+ $value = intval( $value );
+ $this->validateLimit(
+ $paramName,
+ $value,
+ $min,
+ $paramSettings[self::PARAM_MAX],
+ $paramSettings[self::PARAM_MAX2]
+ );
+ }
+ break;
+ case 'boolean':
+ if ( $multi ) {
+ self::dieDebug( __METHOD__, "Multi-values not supported for $encParamName" );
+ }
+ break;
+ case 'timestamp':
+ if ( is_array( $value ) ) {
+ foreach ( $value as $key => $val ) {
+ $value[$key] = $this->validateTimestamp( $val, $encParamName );
+ }
+ } else {
+ $value = $this->validateTimestamp( $value, $encParamName );
+ }
+ break;
+ case 'user':
+ if ( is_array( $value ) ) {
+ foreach ( $value as $key => $val ) {
+ $value[$key] = $this->validateUser( $val, $encParamName );
+ }
+ } else {
+ $value = $this->validateUser( $value, $encParamName );
+ }
+ break;
+ case 'upload': // nothing to do
+ break;
+ case 'tags':
+ // If change tagging was requested, check that the tags are valid.
+ if ( !is_array( $value ) && !$multi ) {
+ $value = [ $value ];
+ }
+ $tagsStatus = ChangeTags::canAddTagsAccompanyingChange( $value );
+ if ( !$tagsStatus->isGood() ) {
+ $this->dieStatus( $tagsStatus );
+ }
+ break;
+ default:
+ self::dieDebug( __METHOD__, "Param $encParamName's type is unknown - $type" );
+ }
+ }
+
+ // Throw out duplicates if requested
+ if ( !$dupes && is_array( $value ) ) {
+ $value = array_unique( $value );
+ }
+
+ // Set a warning if a deprecated parameter has been passed
+ if ( $deprecated && $value !== false ) {
+ $feature = $encParamName;
+ $m = $this;
+ while ( !$m->isMain() ) {
+ $p = $m->getParent();
+ $name = $m->getModuleName();
+ $param = $p->encodeParamName( $p->getModuleManager()->getModuleGroup( $name ) );
+ $feature = "{$param}={$name}&{$feature}";
+ $m = $p;
+ }
+ $this->addDeprecation( [ 'apiwarn-deprecation-parameter', $encParamName ], $feature );
+ }
+
+ // Set a warning if a deprecated parameter value has been passed
+ $usedDeprecatedValues = $deprecatedValues && $value !== false
+ ? array_intersect( array_keys( $deprecatedValues ), (array)$value )
+ : [];
+ if ( $usedDeprecatedValues ) {
+ $feature = "$encParamName=";
+ $m = $this;
+ while ( !$m->isMain() ) {
+ $p = $m->getParent();
+ $name = $m->getModuleName();
+ $param = $p->encodeParamName( $p->getModuleManager()->getModuleGroup( $name ) );
+ $feature = "{$param}={$name}&{$feature}";
+ $m = $p;
+ }
+ foreach ( $usedDeprecatedValues as $v ) {
+ $msg = $deprecatedValues[$v];
+ if ( $msg === true ) {
+ $msg = [ 'apiwarn-deprecation-parameter', "$encParamName=$v" ];
+ }
+ $this->addDeprecation( $msg, "$feature$v" );
+ }
+ }
+ } elseif ( $required ) {
+ $this->dieWithError( [ 'apierror-missingparam', $paramName ] );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Handle when a parameter was Unicode-normalized
+ * @since 1.28
+ * @param string $paramName Unprefixed parameter name
+ * @param string $value Input that will be used.
+ * @param string $rawValue Input before normalization.
+ */
+ protected function handleParamNormalization( $paramName, $value, $rawValue ) {
+ $encParamName = $this->encodeParamName( $paramName );
+ $this->addWarning( [ 'apiwarn-badutf8', $encParamName ] );
+ }
+
+ /**
+ * Split a multi-valued parameter string, like explode()
+ * @since 1.28
+ * @param string $value
+ * @param int $limit
+ * @return string[]
+ */
+ protected function explodeMultiValue( $value, $limit ) {
+ if ( substr( $value, 0, 1 ) === "\x1f" ) {
+ $sep = "\x1f";
+ $value = substr( $value, 1 );
+ } else {
+ $sep = '|';
+ }
+
+ return explode( $sep, $value, $limit );
+ }
+
+ /**
+ * Return an array of values that were given in a 'a|b|c' notation,
+ * after it optionally validates them against the list allowed values.
+ *
+ * @param string $valueName The name of the parameter (for error
+ * reporting)
+ * @param mixed $value The value being parsed
+ * @param bool $allowMultiple Can $value contain more than one value
+ * separated by '|'?
+ * @param string[]|null $allowedValues An array of values to check against. If
+ * null, all values are accepted.
+ * @param string|null $allSpecifier String to use to specify all allowed values, or null
+ * if this behavior should not be allowed
+ * @param int|null $limit1 Maximum number of values, for normal users.
+ * @param int|null $limit2 Maximum number of values, for users with the apihighlimits right.
+ * @return string|string[] (allowMultiple ? an_array_of_values : a_single_value)
+ */
+ protected function parseMultiValue( $valueName, $value, $allowMultiple, $allowedValues,
+ $allSpecifier = null, $limit1 = null, $limit2 = null
+ ) {
+ if ( ( trim( $value ) === '' || trim( $value ) === "\x1f" ) && $allowMultiple ) {
+ return [];
+ }
+ $limit1 = $limit1 ?: self::LIMIT_SML1;
+ $limit2 = $limit2 ?: self::LIMIT_SML2;
+
+ // This is a bit awkward, but we want to avoid calling canApiHighLimits()
+ // because it unstubs $wgUser
+ $valuesList = $this->explodeMultiValue( $value, $limit2 + 1 );
+ $sizeLimit = count( $valuesList ) > $limit1 && $this->mMainModule->canApiHighLimits()
+ ? $limit2
+ : $limit1;
+
+ if ( $allowMultiple && is_array( $allowedValues ) && $allSpecifier &&
+ count( $valuesList ) === 1 && $valuesList[0] === $allSpecifier
+ ) {
+ return $allowedValues;
+ }
+
+ if ( self::truncateArray( $valuesList, $sizeLimit ) ) {
+ $this->addDeprecation(
+ [ 'apiwarn-toomanyvalues', $valueName, $sizeLimit ],
+ "too-many-$valueName-for-{$this->getModulePath()}"
+ );
+ }
+
+ if ( !$allowMultiple && count( $valuesList ) != 1 ) {
+ // T35482 - Allow entries with | in them for non-multiple values
+ if ( in_array( $value, $allowedValues, true ) ) {
+ return $value;
+ }
+
+ if ( is_array( $allowedValues ) ) {
+ $values = array_map( function ( $v ) {
+ return '<kbd>' . wfEscapeWikiText( $v ) . '</kbd>';
+ }, $allowedValues );
+ $this->dieWithError( [
+ 'apierror-multival-only-one-of',
+ $valueName,
+ Message::listParam( $values ),
+ count( $values ),
+ ], "multival_$valueName" );
+ } else {
+ $this->dieWithError( [
+ 'apierror-multival-only-one',
+ $valueName,
+ ], "multival_$valueName" );
+ }
+ }
+
+ if ( is_array( $allowedValues ) ) {
+ // Check for unknown values
+ $unknown = array_map( 'wfEscapeWikiText', array_diff( $valuesList, $allowedValues ) );
+ if ( count( $unknown ) ) {
+ if ( $allowMultiple ) {
+ $this->addWarning( [
+ 'apiwarn-unrecognizedvalues',
+ $valueName,
+ Message::listParam( $unknown, 'comma' ),
+ count( $unknown ),
+ ] );
+ } else {
+ $this->dieWithError(
+ [ 'apierror-unrecognizedvalue', $valueName, wfEscapeWikiText( $valuesList[0] ) ],
+ "unknown_$valueName"
+ );
+ }
+ }
+ // Now throw them out
+ $valuesList = array_intersect( $valuesList, $allowedValues );
+ }
+
+ return $allowMultiple ? $valuesList : $valuesList[0];
+ }
+
+ /**
+ * Validate the value against the minimum and user/bot maximum limits.
+ * Prints usage info on failure.
+ * @param string $paramName Parameter name
+ * @param int &$value Parameter value
+ * @param int|null $min Minimum value
+ * @param int|null $max Maximum value for users
+ * @param int $botMax Maximum value for sysops/bots
+ * @param bool $enforceLimits Whether to enforce (die) if value is outside limits
+ */
+ protected function validateLimit( $paramName, &$value, $min, $max, $botMax = null,
+ $enforceLimits = false
+ ) {
+ if ( !is_null( $min ) && $value < $min ) {
+ $msg = ApiMessage::create(
+ [ 'apierror-integeroutofrange-belowminimum',
+ $this->encodeParamName( $paramName ), $min, $value ],
+ 'integeroutofrange',
+ [ 'min' => $min, 'max' => $max, 'botMax' => $botMax ?: $max ]
+ );
+ $this->warnOrDie( $msg, $enforceLimits );
+ $value = $min;
+ }
+
+ // Minimum is always validated, whereas maximum is checked only if not
+ // running in internal call mode
+ if ( $this->getMain()->isInternalMode() ) {
+ return;
+ }
+
+ // Optimization: do not check user's bot status unless really needed -- skips db query
+ // assumes $botMax >= $max
+ if ( !is_null( $max ) && $value > $max ) {
+ if ( !is_null( $botMax ) && $this->getMain()->canApiHighLimits() ) {
+ if ( $value > $botMax ) {
+ $msg = ApiMessage::create(
+ [ 'apierror-integeroutofrange-abovebotmax',
+ $this->encodeParamName( $paramName ), $botMax, $value ],
+ 'integeroutofrange',
+ [ 'min' => $min, 'max' => $max, 'botMax' => $botMax ?: $max ]
+ );
+ $this->warnOrDie( $msg, $enforceLimits );
+ $value = $botMax;
+ }
+ } else {
+ $msg = ApiMessage::create(
+ [ 'apierror-integeroutofrange-abovemax',
+ $this->encodeParamName( $paramName ), $max, $value ],
+ 'integeroutofrange',
+ [ 'min' => $min, 'max' => $max, 'botMax' => $botMax ?: $max ]
+ );
+ $this->warnOrDie( $msg, $enforceLimits );
+ $value = $max;
+ }
+ }
+ }
+
+ /**
+ * Validate and normalize of parameters of type 'timestamp'
+ * @param string $value Parameter value
+ * @param string $encParamName Parameter name
+ * @return string Validated and normalized parameter
+ */
+ protected function validateTimestamp( $value, $encParamName ) {
+ // Confusing synonyms for the current time accepted by wfTimestamp()
+ // (wfTimestamp() also accepts various non-strings and the string of 14
+ // ASCII NUL bytes, but those can't get here)
+ if ( !$value ) {
+ $this->addDeprecation(
+ [ 'apiwarn-unclearnowtimestamp', $encParamName, wfEscapeWikiText( $value ) ],
+ 'unclear-"now"-timestamp'
+ );
+ return wfTimestamp( TS_MW );
+ }
+
+ // Explicit synonym for the current time
+ if ( $value === 'now' ) {
+ return wfTimestamp( TS_MW );
+ }
+
+ $unixTimestamp = wfTimestamp( TS_UNIX, $value );
+ if ( $unixTimestamp === false ) {
+ $this->dieWithError(
+ [ 'apierror-badtimestamp', $encParamName, wfEscapeWikiText( $value ) ],
+ "badtimestamp_{$encParamName}"
+ );
+ }
+
+ return wfTimestamp( TS_MW, $unixTimestamp );
+ }
+
+ /**
+ * Validate the supplied token.
+ *
+ * @since 1.24
+ * @param string $token Supplied token
+ * @param array $params All supplied parameters for the module
+ * @return bool
+ * @throws MWException
+ */
+ final public function validateToken( $token, array $params ) {
+ $tokenType = $this->needsToken();
+ $salts = ApiQueryTokens::getTokenTypeSalts();
+ if ( !isset( $salts[$tokenType] ) ) {
+ throw new MWException(
+ "Module '{$this->getModuleName()}' tried to use token type '$tokenType' " .
+ 'without registering it'
+ );
+ }
+
+ $tokenObj = ApiQueryTokens::getToken(
+ $this->getUser(), $this->getRequest()->getSession(), $salts[$tokenType]
+ );
+ if ( $tokenObj->match( $token ) ) {
+ return true;
+ }
+
+ $webUiSalt = $this->getWebUITokenSalt( $params );
+ if ( $webUiSalt !== null && $this->getUser()->matchEditToken(
+ $token,
+ $webUiSalt,
+ $this->getRequest()
+ ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Validate and normalize of parameters of type 'user'
+ * @param string $value Parameter value
+ * @param string $encParamName Parameter name
+ * @return string Validated and normalized parameter
+ */
+ private function validateUser( $value, $encParamName ) {
+ $title = Title::makeTitleSafe( NS_USER, $value );
+ if ( $title === null || $title->hasFragment() ) {
+ $this->dieWithError(
+ [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $value ) ],
+ "baduser_{$encParamName}"
+ );
+ }
+
+ return $title->getText();
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Utility methods
+ * @{
+ */
+
+ /**
+ * Set a watch (or unwatch) based the based on a watchlist parameter.
+ * @param string $watch Valid values: 'watch', 'unwatch', 'preferences', 'nochange'
+ * @param Title $titleObj The article's title to change
+ * @param string $userOption The user option to consider when $watch=preferences
+ */
+ protected function setWatch( $watch, $titleObj, $userOption = null ) {
+ $value = $this->getWatchlistValue( $watch, $titleObj, $userOption );
+ if ( $value === null ) {
+ return;
+ }
+
+ WatchAction::doWatchOrUnwatch( $value, $titleObj, $this->getUser() );
+ }
+
+ /**
+ * Truncate an array to a certain length.
+ * @param array &$arr Array to truncate
+ * @param int $limit Maximum length
+ * @return bool True if the array was truncated, false otherwise
+ */
+ public static function truncateArray( &$arr, $limit ) {
+ $modified = false;
+ while ( count( $arr ) > $limit ) {
+ array_pop( $arr );
+ $modified = true;
+ }
+
+ return $modified;
+ }
+
+ /**
+ * Gets the user for whom to get the watchlist
+ *
+ * @param array $params
+ * @return User
+ */
+ public function getWatchlistUser( $params ) {
+ if ( !is_null( $params['owner'] ) && !is_null( $params['token'] ) ) {
+ $user = User::newFromName( $params['owner'], false );
+ if ( !( $user && $user->getId() ) ) {
+ $this->dieWithError(
+ [ 'nosuchusershort', wfEscapeWikiText( $params['owner'] ) ], 'bad_wlowner'
+ );
+ }
+ $token = $user->getOption( 'watchlisttoken' );
+ if ( $token == '' || !hash_equals( $token, $params['token'] ) ) {
+ $this->dieWithError( 'apierror-bad-watchlist-token', 'bad_wltoken' );
+ }
+ } else {
+ if ( !$this->getUser()->isLoggedIn() ) {
+ $this->dieWithError( 'watchlistanontext', 'notloggedin' );
+ }
+ $this->checkUserRightsAny( 'viewmywatchlist' );
+ $user = $this->getUser();
+ }
+
+ return $user;
+ }
+
+ /**
+ * A subset of wfEscapeWikiText for BC texts
+ *
+ * @since 1.25
+ * @param string|array $v
+ * @return string|array
+ */
+ private static function escapeWikiText( $v ) {
+ if ( is_array( $v ) ) {
+ return array_map( 'self::escapeWikiText', $v );
+ } else {
+ return strtr( $v, [
+ '__' => '_&#95;', '{' => '&#123;', '}' => '&#125;',
+ '[[Category:' => '[[:Category:',
+ '[[File:' => '[[:File:', '[[Image:' => '[[:Image:',
+ ] );
+ }
+ }
+
+ /**
+ * Create a Message from a string or array
+ *
+ * A string is used as a message key. An array has the message key as the
+ * first value and message parameters as subsequent values.
+ *
+ * @since 1.25
+ * @param string|array|Message $msg
+ * @param IContextSource $context
+ * @param array $params
+ * @return Message|null
+ */
+ public static function makeMessage( $msg, IContextSource $context, array $params = null ) {
+ if ( is_string( $msg ) ) {
+ $msg = wfMessage( $msg );
+ } elseif ( is_array( $msg ) ) {
+ $msg = call_user_func_array( 'wfMessage', $msg );
+ }
+ if ( !$msg instanceof Message ) {
+ return null;
+ }
+
+ $msg->setContext( $context );
+ if ( $params ) {
+ $msg->params( $params );
+ }
+
+ return $msg;
+ }
+
+ /**
+ * Turn an array of message keys or key+param arrays into a Status
+ * @since 1.29
+ * @param array $errors
+ * @param User|null $user
+ * @return Status
+ */
+ public function errorArrayToStatus( array $errors, User $user = null ) {
+ if ( $user === null ) {
+ $user = $this->getUser();
+ }
+
+ $status = Status::newGood();
+ foreach ( $errors as $error ) {
+ if ( is_array( $error ) && $error[0] === 'blockedtext' && $user->getBlock() ) {
+ $status->fatal( ApiMessage::create(
+ 'apierror-blocked',
+ 'blocked',
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
+ ) );
+ } elseif ( is_array( $error ) && $error[0] === 'autoblockedtext' && $user->getBlock() ) {
+ $status->fatal( ApiMessage::create(
+ 'apierror-autoblocked',
+ 'autoblocked',
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
+ ) );
+ } elseif ( is_array( $error ) && $error[0] === 'systemblockedtext' && $user->getBlock() ) {
+ $status->fatal( ApiMessage::create(
+ 'apierror-systemblocked',
+ 'blocked',
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
+ ) );
+ } else {
+ call_user_func_array( [ $status, 'fatal' ], (array)$error );
+ }
+ }
+ return $status;
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Warning and error reporting
+ * @{
+ */
+
+ /**
+ * Add a warning for this module.
+ *
+ * Users should monitor this section to notice any changes in API. Multiple
+ * calls to this function will result in multiple warning messages.
+ *
+ * If $msg is not an ApiMessage, the message code will be derived from the
+ * message key by stripping any "apiwarn-" or "apierror-" prefix.
+ *
+ * @since 1.29
+ * @param string|array|Message $msg See ApiErrorFormatter::addWarning()
+ * @param string|null $code See ApiErrorFormatter::addWarning()
+ * @param array|null $data See ApiErrorFormatter::addWarning()
+ */
+ public function addWarning( $msg, $code = null, $data = null ) {
+ $this->getErrorFormatter()->addWarning( $this->getModulePath(), $msg, $code, $data );
+ }
+
+ /**
+ * Add a deprecation warning for this module.
+ *
+ * A combination of $this->addWarning() and $this->logFeatureUsage()
+ *
+ * @since 1.29
+ * @param string|array|Message $msg See ApiErrorFormatter::addWarning()
+ * @param string|null $feature See ApiBase::logFeatureUsage()
+ * @param array|null $data See ApiErrorFormatter::addWarning()
+ */
+ public function addDeprecation( $msg, $feature, $data = [] ) {
+ $data = (array)$data;
+ if ( $feature !== null ) {
+ $data['feature'] = $feature;
+ $this->logFeatureUsage( $feature );
+ }
+ $this->addWarning( $msg, 'deprecation', $data );
+
+ // No real need to deduplicate here, ApiErrorFormatter does that for
+ // us (assuming the hook is deterministic).
+ $msgs = [ $this->msg( 'api-usage-mailinglist-ref' ) ];
+ Hooks::run( 'ApiDeprecationHelp', [ &$msgs ] );
+ if ( count( $msgs ) > 1 ) {
+ $key = '$' . join( ' $', range( 1, count( $msgs ) ) );
+ $msg = ( new RawMessage( $key ) )->params( $msgs );
+ } else {
+ $msg = reset( $msgs );
+ }
+ $this->getMain()->addWarning( $msg, 'deprecation-help' );
+ }
+
+ /**
+ * Add an error for this module without aborting
+ *
+ * If $msg is not an ApiMessage, the message code will be derived from the
+ * message key by stripping any "apiwarn-" or "apierror-" prefix.
+ *
+ * @note If you want to abort processing, use self::dieWithError() instead.
+ * @since 1.29
+ * @param string|array|Message $msg See ApiErrorFormatter::addError()
+ * @param string|null $code See ApiErrorFormatter::addError()
+ * @param array|null $data See ApiErrorFormatter::addError()
+ */
+ public function addError( $msg, $code = null, $data = null ) {
+ $this->getErrorFormatter()->addError( $this->getModulePath(), $msg, $code, $data );
+ }
+
+ /**
+ * Add warnings and/or errors from a Status
+ *
+ * @note If you want to abort processing, use self::dieStatus() instead.
+ * @since 1.29
+ * @param StatusValue $status
+ * @param string[] $types 'warning' and/or 'error'
+ */
+ public function addMessagesFromStatus( StatusValue $status, $types = [ 'warning', 'error' ] ) {
+ $this->getErrorFormatter()->addMessagesFromStatus( $this->getModulePath(), $status, $types );
+ }
+
+ /**
+ * Abort execution with an error
+ *
+ * If $msg is not an ApiMessage, the message code will be derived from the
+ * message key by stripping any "apiwarn-" or "apierror-" prefix.
+ *
+ * @since 1.29
+ * @param string|array|Message $msg See ApiErrorFormatter::addError()
+ * @param string|null $code See ApiErrorFormatter::addError()
+ * @param array|null $data See ApiErrorFormatter::addError()
+ * @param int|null $httpCode HTTP error code to use
+ * @throws ApiUsageException always
+ */
+ public function dieWithError( $msg, $code = null, $data = null, $httpCode = null ) {
+ throw ApiUsageException::newWithMessage( $this, $msg, $code, $data, $httpCode );
+ }
+
+ /**
+ * Abort execution with an error derived from an exception
+ *
+ * @since 1.29
+ * @param Exception|Throwable $exception See ApiErrorFormatter::getMessageFromException()
+ * @param array $options See ApiErrorFormatter::getMessageFromException()
+ * @throws ApiUsageException always
+ */
+ public function dieWithException( $exception, array $options = [] ) {
+ $this->dieWithError(
+ $this->getErrorFormatter()->getMessageFromException( $exception, $options )
+ );
+ }
+
+ /**
+ * Adds a warning to the output, else dies
+ *
+ * @param ApiMessage $msg Message to show as a warning, or error message if dying
+ * @param bool $enforceLimits Whether this is an enforce (die)
+ */
+ private function warnOrDie( ApiMessage $msg, $enforceLimits = false ) {
+ if ( $enforceLimits ) {
+ $this->dieWithError( $msg );
+ } else {
+ $this->addWarning( $msg );
+ }
+ }
+
+ /**
+ * Throw an ApiUsageException, which will (if uncaught) call the main module's
+ * error handler and die with an error message including block info.
+ *
+ * @since 1.27
+ * @param Block $block The block used to generate the ApiUsageException
+ * @throws ApiUsageException always
+ */
+ public function dieBlocked( Block $block ) {
+ // Die using the appropriate message depending on block type
+ if ( $block->getType() == Block::TYPE_AUTO ) {
+ $this->dieWithError(
+ 'apierror-autoblocked',
+ 'autoblocked',
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $block ) ]
+ );
+ } else {
+ $this->dieWithError(
+ 'apierror-blocked',
+ 'blocked',
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $block ) ]
+ );
+ }
+ }
+
+ /**
+ * Throw an ApiUsageException based on the Status object.
+ *
+ * @since 1.22
+ * @since 1.29 Accepts a StatusValue
+ * @param StatusValue $status
+ * @throws ApiUsageException always
+ */
+ public function dieStatus( StatusValue $status ) {
+ if ( $status->isGood() ) {
+ throw new MWException( 'Successful status passed to ApiBase::dieStatus' );
+ }
+
+ // ApiUsageException needs a fatal status, but this method has
+ // historically accepted any non-good status. Convert it if necessary.
+ $status->setOK( false );
+ if ( !$status->getErrorsByType( 'error' ) ) {
+ $newStatus = Status::newGood();
+ foreach ( $status->getErrorsByType( 'warning' ) as $err ) {
+ call_user_func_array(
+ [ $newStatus, 'fatal' ],
+ array_merge( [ $err['message'] ], $err['params'] )
+ );
+ }
+ if ( !$newStatus->getErrorsByType( 'error' ) ) {
+ $newStatus->fatal( 'unknownerror-nocode' );
+ }
+ $status = $newStatus;
+ }
+
+ throw new ApiUsageException( $this, $status );
+ }
+
+ /**
+ * Helper function for readonly errors
+ *
+ * @throws ApiUsageException always
+ */
+ public function dieReadOnly() {
+ $this->dieWithError(
+ 'apierror-readonly',
+ 'readonly',
+ [ 'readonlyreason' => wfReadOnlyReason() ]
+ );
+ }
+
+ /**
+ * Helper function for permission-denied errors
+ * @since 1.29
+ * @param string|string[] $rights
+ * @param User|null $user
+ * @throws ApiUsageException if the user doesn't have any of the rights.
+ * The error message is based on $rights[0].
+ */
+ public function checkUserRightsAny( $rights, $user = null ) {
+ if ( !$user ) {
+ $user = $this->getUser();
+ }
+ $rights = (array)$rights;
+ if ( !call_user_func_array( [ $user, 'isAllowedAny' ], $rights ) ) {
+ $this->dieWithError( [ 'apierror-permissiondenied', $this->msg( "action-{$rights[0]}" ) ] );
+ }
+ }
+
+ /**
+ * Helper function for permission-denied errors
+ * @since 1.29
+ * @param Title $title
+ * @param string|string[] $actions
+ * @param User|null $user
+ * @throws ApiUsageException if the user doesn't have all of the rights.
+ */
+ public function checkTitleUserPermissions( Title $title, $actions, $user = null ) {
+ if ( !$user ) {
+ $user = $this->getUser();
+ }
+
+ $errors = [];
+ foreach ( (array)$actions as $action ) {
+ $errors = array_merge( $errors, $title->getUserPermissionsErrors( $action, $user ) );
+ }
+ if ( $errors ) {
+ $this->dieStatus( $this->errorArrayToStatus( $errors, $user ) );
+ }
+ }
+
+ /**
+ * Will only set a warning instead of failing if the global $wgDebugAPI
+ * is set to true. Otherwise behaves exactly as self::dieWithError().
+ *
+ * @since 1.29
+ * @param string|array|Message $msg
+ * @param string|null $code
+ * @param array|null $data
+ * @param int|null $httpCode
+ * @throws ApiUsageException
+ */
+ public function dieWithErrorOrDebug( $msg, $code = null, $data = null, $httpCode = null ) {
+ if ( $this->getConfig()->get( 'DebugAPI' ) !== true ) {
+ $this->dieWithError( $msg, $code, $data, $httpCode );
+ } else {
+ $this->addWarning( $msg, $code, $data );
+ }
+ }
+
+ /**
+ * Die with the 'badcontinue' error.
+ *
+ * This call is common enough to make it into the base method.
+ *
+ * @param bool $condition Will only die if this value is true
+ * @throws ApiUsageException
+ * @since 1.21
+ */
+ protected function dieContinueUsageIf( $condition ) {
+ if ( $condition ) {
+ $this->dieWithError( 'apierror-badcontinue' );
+ }
+ }
+
+ /**
+ * Internal code errors should be reported with this method
+ * @param string $method Method or function name
+ * @param string $message Error message
+ * @throws MWException always
+ */
+ protected static function dieDebug( $method, $message ) {
+ throw new MWException( "Internal error in $method: $message" );
+ }
+
+ /**
+ * Write logging information for API features to a debug log, for usage
+ * analysis.
+ * @note Consider using $this->addDeprecation() instead to both warn and log.
+ * @param string $feature Feature being used.
+ */
+ public function logFeatureUsage( $feature ) {
+ $request = $this->getRequest();
+ $s = '"' . addslashes( $feature ) . '"' .
+ ' "' . wfUrlencode( str_replace( ' ', '_', $this->getUser()->getName() ) ) . '"' .
+ ' "' . $request->getIP() . '"' .
+ ' "' . addslashes( $request->getHeader( 'Referer' ) ) . '"' .
+ ' "' . addslashes( $this->getMain()->getUserAgent() ) . '"';
+ wfDebugLog( 'api-feature-usage', $s, 'private' );
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Help message generation
+ * @{
+ */
+
+ /**
+ * Return the summary message.
+ *
+ * This is a one-line description of the module, suitable for display in a
+ * list of modules.
+ *
+ * @since 1.30
+ * @return string|array|Message
+ */
+ protected function getSummaryMessage() {
+ return "apihelp-{$this->getModulePath()}-summary";
+ }
+
+ /**
+ * Return the extended help text message.
+ *
+ * This is additional text to display at the top of the help section, below
+ * the summary.
+ *
+ * @since 1.30
+ * @return string|array|Message
+ */
+ protected function getExtendedDescription() {
+ return [ [
+ "apihelp-{$this->getModulePath()}-extended-description",
+ 'api-help-no-extended-description',
+ ] ];
+ }
+
+ /**
+ * Get final module summary
+ *
+ * Ideally this will just be the getSummaryMessage(). However, for
+ * backwards compatibility, if that message does not exist then the first
+ * line of wikitext from the description message will be used instead.
+ *
+ * @since 1.30
+ * @return Message
+ */
+ public function getFinalSummary() {
+ $msg = self::makeMessage( $this->getSummaryMessage(), $this->getContext(), [
+ $this->getModulePrefix(),
+ $this->getModuleName(),
+ $this->getModulePath(),
+ ] );
+ if ( !$msg->exists() ) {
+ wfDeprecated( 'API help "description" messages', '1.30' );
+ $msg = self::makeMessage( $this->getDescriptionMessage(), $this->getContext(), [
+ $this->getModulePrefix(),
+ $this->getModuleName(),
+ $this->getModulePath(),
+ ] );
+ $msg = self::makeMessage( 'rawmessage', $this->getContext(), [
+ preg_replace( '/\n.*/s', '', $msg->text() )
+ ] );
+ }
+ return $msg;
+ }
+
+ /**
+ * Get final module description, after hooks have had a chance to tweak it as
+ * needed.
+ *
+ * @since 1.25, returns Message[] rather than string[]
+ * @return Message[]
+ */
+ public function getFinalDescription() {
+ $desc = $this->getDescription();
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $apiModule = $this;
+ Hooks::run( 'APIGetDescription', [ &$apiModule, &$desc ] );
+ $desc = self::escapeWikiText( $desc );
+ if ( is_array( $desc ) ) {
+ $desc = implode( "\n", $desc );
+ } else {
+ $desc = (string)$desc;
+ }
+
+ $summary = self::makeMessage( $this->getSummaryMessage(), $this->getContext(), [
+ $this->getModulePrefix(),
+ $this->getModuleName(),
+ $this->getModulePath(),
+ ] );
+ $extendedDescription = self::makeMessage(
+ $this->getExtendedDescription(), $this->getContext(), [
+ $this->getModulePrefix(),
+ $this->getModuleName(),
+ $this->getModulePath(),
+ ]
+ );
+
+ if ( $summary->exists() ) {
+ $msgs = [ $summary, $extendedDescription ];
+ } else {
+ wfDeprecated( 'API help "description" messages', '1.30' );
+ $description = self::makeMessage( $this->getDescriptionMessage(), $this->getContext(), [
+ $this->getModulePrefix(),
+ $this->getModuleName(),
+ $this->getModulePath(),
+ ] );
+ if ( !$description->exists() ) {
+ $description = $this->msg( 'api-help-fallback-description', $desc );
+ }
+ $msgs = [ $description ];
+ }
+
+ Hooks::run( 'APIGetDescriptionMessages', [ $this, &$msgs ] );
+
+ return $msgs;
+ }
+
+ /**
+ * Get final list of parameters, after hooks have had a chance to
+ * tweak it as needed.
+ *
+ * @param int $flags Zero or more flags like GET_VALUES_FOR_HELP
+ * @return array|bool False on no parameters
+ * @since 1.21 $flags param added
+ */
+ public function getFinalParams( $flags = 0 ) {
+ $params = $this->getAllowedParams( $flags );
+ if ( !$params ) {
+ $params = [];
+ }
+
+ if ( $this->needsToken() ) {
+ $params['token'] = [
+ self::PARAM_TYPE => 'string',
+ self::PARAM_REQUIRED => true,
+ self::PARAM_SENSITIVE => true,
+ self::PARAM_HELP_MSG => [
+ 'api-help-param-token',
+ $this->needsToken(),
+ ],
+ ] + ( isset( $params['token'] ) ? $params['token'] : [] );
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $apiModule = $this;
+ Hooks::run( 'APIGetAllowedParams', [ &$apiModule, &$params, $flags ] );
+
+ return $params;
+ }
+
+ /**
+ * Get final parameter descriptions, after hooks have had a chance to tweak it as
+ * needed.
+ *
+ * @since 1.25, returns array of Message[] rather than array of string[]
+ * @return array Keys are parameter names, values are arrays of Message objects
+ */
+ public function getFinalParamDescription() {
+ $prefix = $this->getModulePrefix();
+ $name = $this->getModuleName();
+ $path = $this->getModulePath();
+
+ $desc = $this->getParamDescription();
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $apiModule = $this;
+ Hooks::run( 'APIGetParamDescription', [ &$apiModule, &$desc ] );
+
+ if ( !$desc ) {
+ $desc = [];
+ }
+ $desc = self::escapeWikiText( $desc );
+
+ $params = $this->getFinalParams( self::GET_VALUES_FOR_HELP );
+ $msgs = [];
+ foreach ( $params as $param => $settings ) {
+ if ( !is_array( $settings ) ) {
+ $settings = [];
+ }
+
+ $d = isset( $desc[$param] ) ? $desc[$param] : '';
+ if ( is_array( $d ) ) {
+ // Special handling for prop parameters
+ $d = array_map( function ( $line ) {
+ if ( preg_match( '/^\s+(\S+)\s+-\s+(.+)$/', $line, $m ) ) {
+ $line = "\n;{$m[1]}:{$m[2]}";
+ }
+ return $line;
+ }, $d );
+ $d = implode( ' ', $d );
+ }
+
+ if ( isset( $settings[self::PARAM_HELP_MSG] ) ) {
+ $msg = $settings[self::PARAM_HELP_MSG];
+ } else {
+ $msg = $this->msg( "apihelp-{$path}-param-{$param}" );
+ if ( !$msg->exists() ) {
+ $msg = $this->msg( 'api-help-fallback-parameter', $d );
+ }
+ }
+ $msg = self::makeMessage( $msg, $this->getContext(),
+ [ $prefix, $param, $name, $path ] );
+ if ( !$msg ) {
+ self::dieDebug( __METHOD__,
+ 'Value in ApiBase::PARAM_HELP_MSG is not valid' );
+ }
+ $msgs[$param] = [ $msg ];
+
+ if ( isset( $settings[self::PARAM_TYPE] ) &&
+ $settings[self::PARAM_TYPE] === 'submodule'
+ ) {
+ if ( isset( $settings[self::PARAM_SUBMODULE_MAP] ) ) {
+ $map = $settings[self::PARAM_SUBMODULE_MAP];
+ } else {
+ $prefix = $this->isMain() ? '' : ( $this->getModulePath() . '+' );
+ $map = [];
+ foreach ( $this->getModuleManager()->getNames( $param ) as $submoduleName ) {
+ $map[$submoduleName] = $prefix . $submoduleName;
+ }
+ }
+ ksort( $map );
+ $submodules = [];
+ $deprecatedSubmodules = [];
+ foreach ( $map as $v => $m ) {
+ $arr = &$submodules;
+ $isDeprecated = false;
+ $summary = null;
+ try {
+ $submod = $this->getModuleFromPath( $m );
+ if ( $submod ) {
+ $summary = $submod->getFinalSummary();
+ $isDeprecated = $submod->isDeprecated();
+ if ( $isDeprecated ) {
+ $arr = &$deprecatedSubmodules;
+ }
+ }
+ } catch ( ApiUsageException $ex ) {
+ // Ignore
+ }
+ if ( $summary ) {
+ $key = $summary->getKey();
+ $params = $summary->getParams();
+ } else {
+ $key = 'api-help-undocumented-module';
+ $params = [ $m ];
+ }
+ $m = new ApiHelpParamValueMessage( "[[Special:ApiHelp/$m|$v]]", $key, $params, $isDeprecated );
+ $arr[] = $m->setContext( $this->getContext() );
+ }
+ $msgs[$param] = array_merge( $msgs[$param], $submodules, $deprecatedSubmodules );
+ } elseif ( isset( $settings[self::PARAM_HELP_MSG_PER_VALUE] ) ) {
+ if ( !is_array( $settings[self::PARAM_HELP_MSG_PER_VALUE] ) ) {
+ self::dieDebug( __METHOD__,
+ 'ApiBase::PARAM_HELP_MSG_PER_VALUE is not valid' );
+ }
+ if ( !is_array( $settings[self::PARAM_TYPE] ) ) {
+ self::dieDebug( __METHOD__,
+ 'ApiBase::PARAM_HELP_MSG_PER_VALUE may only be used when ' .
+ 'ApiBase::PARAM_TYPE is an array' );
+ }
+
+ $valueMsgs = $settings[self::PARAM_HELP_MSG_PER_VALUE];
+ $deprecatedValues = isset( $settings[self::PARAM_DEPRECATED_VALUES] )
+ ? $settings[self::PARAM_DEPRECATED_VALUES]
+ : [];
+
+ foreach ( $settings[self::PARAM_TYPE] as $value ) {
+ if ( isset( $valueMsgs[$value] ) ) {
+ $msg = $valueMsgs[$value];
+ } else {
+ $msg = "apihelp-{$path}-paramvalue-{$param}-{$value}";
+ }
+ $m = self::makeMessage( $msg, $this->getContext(),
+ [ $prefix, $param, $name, $path, $value ] );
+ if ( $m ) {
+ $m = new ApiHelpParamValueMessage(
+ $value,
+ [ $m->getKey(), 'api-help-param-no-description' ],
+ $m->getParams(),
+ isset( $deprecatedValues[$value] )
+ );
+ $msgs[$param][] = $m->setContext( $this->getContext() );
+ } else {
+ self::dieDebug( __METHOD__,
+ "Value in ApiBase::PARAM_HELP_MSG_PER_VALUE for $value is not valid" );
+ }
+ }
+ }
+
+ if ( isset( $settings[self::PARAM_HELP_MSG_APPEND] ) ) {
+ if ( !is_array( $settings[self::PARAM_HELP_MSG_APPEND] ) ) {
+ self::dieDebug( __METHOD__,
+ 'Value for ApiBase::PARAM_HELP_MSG_APPEND is not an array' );
+ }
+ foreach ( $settings[self::PARAM_HELP_MSG_APPEND] as $m ) {
+ $m = self::makeMessage( $m, $this->getContext(),
+ [ $prefix, $param, $name, $path ] );
+ if ( $m ) {
+ $msgs[$param][] = $m;
+ } else {
+ self::dieDebug( __METHOD__,
+ 'Value in ApiBase::PARAM_HELP_MSG_APPEND is not valid' );
+ }
+ }
+ }
+ }
+
+ Hooks::run( 'APIGetParamDescriptionMessages', [ $this, &$msgs ] );
+
+ return $msgs;
+ }
+
+ /**
+ * Generates the list of flags for the help screen and for action=paraminfo
+ *
+ * Corresponding messages: api-help-flag-deprecated,
+ * api-help-flag-internal, api-help-flag-readrights,
+ * api-help-flag-writerights, api-help-flag-mustbeposted
+ *
+ * @return string[]
+ */
+ protected function getHelpFlags() {
+ $flags = [];
+
+ if ( $this->isDeprecated() ) {
+ $flags[] = 'deprecated';
+ }
+ if ( $this->isInternal() ) {
+ $flags[] = 'internal';
+ }
+ if ( $this->isReadMode() ) {
+ $flags[] = 'readrights';
+ }
+ if ( $this->isWriteMode() ) {
+ $flags[] = 'writerights';
+ }
+ if ( $this->mustBePosted() ) {
+ $flags[] = 'mustbeposted';
+ }
+
+ return $flags;
+ }
+
+ /**
+ * Returns information about the source of this module, if known
+ *
+ * Returned array is an array with the following keys:
+ * - path: Install path
+ * - name: Extension name, or "MediaWiki" for core
+ * - namemsg: (optional) i18n message key for a display name
+ * - license-name: (optional) Name of license
+ *
+ * @return array|null
+ */
+ protected function getModuleSourceInfo() {
+ global $IP;
+
+ if ( $this->mModuleSource !== false ) {
+ return $this->mModuleSource;
+ }
+
+ // First, try to find where the module comes from...
+ $rClass = new ReflectionClass( $this );
+ $path = $rClass->getFileName();
+ if ( !$path ) {
+ // No path known?
+ $this->mModuleSource = null;
+ return null;
+ }
+ $path = realpath( $path ) ?: $path;
+
+ // Build map of extension directories to extension info
+ if ( self::$extensionInfo === null ) {
+ $extDir = $this->getConfig()->get( 'ExtensionDirectory' );
+ self::$extensionInfo = [
+ realpath( __DIR__ ) ?: __DIR__ => [
+ 'path' => $IP,
+ 'name' => 'MediaWiki',
+ 'license-name' => 'GPL-2.0+',
+ ],
+ realpath( "$IP/extensions" ) ?: "$IP/extensions" => null,
+ realpath( $extDir ) ?: $extDir => null,
+ ];
+ $keep = [
+ 'path' => null,
+ 'name' => null,
+ 'namemsg' => null,
+ 'license-name' => null,
+ ];
+ foreach ( $this->getConfig()->get( 'ExtensionCredits' ) as $group ) {
+ foreach ( $group as $ext ) {
+ if ( !isset( $ext['path'] ) || !isset( $ext['name'] ) ) {
+ // This shouldn't happen, but does anyway.
+ continue;
+ }
+
+ $extpath = $ext['path'];
+ if ( !is_dir( $extpath ) ) {
+ $extpath = dirname( $extpath );
+ }
+ self::$extensionInfo[realpath( $extpath ) ?: $extpath] =
+ array_intersect_key( $ext, $keep );
+ }
+ }
+ foreach ( ExtensionRegistry::getInstance()->getAllThings() as $ext ) {
+ $extpath = $ext['path'];
+ if ( !is_dir( $extpath ) ) {
+ $extpath = dirname( $extpath );
+ }
+ self::$extensionInfo[realpath( $extpath ) ?: $extpath] =
+ array_intersect_key( $ext, $keep );
+ }
+ }
+
+ // Now traverse parent directories until we find a match or run out of
+ // parents.
+ do {
+ if ( array_key_exists( $path, self::$extensionInfo ) ) {
+ // Found it!
+ $this->mModuleSource = self::$extensionInfo[$path];
+ return $this->mModuleSource;
+ }
+
+ $oldpath = $path;
+ $path = dirname( $path );
+ } while ( $path !== $oldpath );
+
+ // No idea what extension this might be.
+ $this->mModuleSource = null;
+ return null;
+ }
+
+ /**
+ * Called from ApiHelp before the pieces are joined together and returned.
+ *
+ * This exists mainly for ApiMain to add the Permissions and Credits
+ * sections. Other modules probably don't need it.
+ *
+ * @param string[] &$help Array of help data
+ * @param array $options Options passed to ApiHelp::getHelp
+ * @param array &$tocData If a TOC is being generated, this array has keys
+ * as anchors in the page and values as for Linker::generateTOC().
+ */
+ public function modifyHelp( array &$help, array $options, array &$tocData ) {
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Deprecated
+ * @{
+ */
+
+ /**
+ * Returns the description string for this module
+ *
+ * Ignored if an i18n message exists for
+ * "apihelp-{$this->getModulePath()}-description".
+ *
+ * @deprecated since 1.25
+ * @return Message|string|array|false
+ */
+ protected function getDescription() {
+ return false;
+ }
+
+ /**
+ * Returns an array of parameter descriptions.
+ *
+ * For each parameter, ignored if an i18n message exists for the parameter.
+ * By default that message is
+ * "apihelp-{$this->getModulePath()}-param-{$param}", but it may be
+ * overridden using ApiBase::PARAM_HELP_MSG in the data returned by
+ * self::getFinalParams().
+ *
+ * @deprecated since 1.25
+ * @return array|bool False on no parameter descriptions
+ */
+ protected function getParamDescription() {
+ return [];
+ }
+
+ /**
+ * Returns usage examples for this module.
+ *
+ * Return value as an array is either:
+ * - numeric keys with partial URLs ("api.php?" plus a query string) as
+ * values
+ * - sequential numeric keys with even-numbered keys being display-text
+ * and odd-numbered keys being partial urls
+ * - partial URLs as keys with display-text (string or array-to-be-joined)
+ * as values
+ * Return value as a string is the same as an array with a numeric key and
+ * that value, and boolean false means "no examples".
+ *
+ * @deprecated since 1.25, use getExamplesMessages() instead
+ * @return bool|string|array
+ */
+ protected function getExamples() {
+ return false;
+ }
+
+ /**
+ * @deprecated since 1.25, always returns empty string
+ * @param IDatabase|bool $db
+ * @return string
+ */
+ public function getModuleProfileName( $db = false ) {
+ wfDeprecated( __METHOD__, '1.25' );
+ return '';
+ }
+
+ /**
+ * @deprecated since 1.25
+ */
+ public function profileIn() {
+ // No wfDeprecated() yet because extensions call this and might need to
+ // keep doing so for BC.
+ }
+
+ /**
+ * @deprecated since 1.25
+ */
+ public function profileOut() {
+ // No wfDeprecated() yet because extensions call this and might need to
+ // keep doing so for BC.
+ }
+
+ /**
+ * @deprecated since 1.25
+ */
+ public function safeProfileOut() {
+ wfDeprecated( __METHOD__, '1.25' );
+ }
+
+ /**
+ * @deprecated since 1.25, always returns 0
+ * @return float
+ */
+ public function getProfileTime() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return 0;
+ }
+
+ /**
+ * @deprecated since 1.25
+ */
+ public function profileDBIn() {
+ wfDeprecated( __METHOD__, '1.25' );
+ }
+
+ /**
+ * @deprecated since 1.25
+ */
+ public function profileDBOut() {
+ wfDeprecated( __METHOD__, '1.25' );
+ }
+
+ /**
+ * @deprecated since 1.25, always returns 0
+ * @return float
+ */
+ public function getProfileDBTime() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return 0;
+ }
+
+ /**
+ * Call wfTransactionalTimeLimit() if this request was POSTed
+ * @since 1.26
+ */
+ protected function useTransactionalTimeLimit() {
+ if ( $this->getRequest()->wasPosted() ) {
+ wfTransactionalTimeLimit();
+ }
+ }
+
+ /**
+ * @deprecated since 1.29, use ApiBase::addWarning() instead
+ * @param string $warning Warning message
+ */
+ public function setWarning( $warning ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ $msg = new ApiRawMessage( $warning, 'warning' );
+ $this->getErrorFormatter()->addWarning( $this->getModulePath(), $msg );
+ }
+
+ /**
+ * Throw an ApiUsageException, which will (if uncaught) call the main module's
+ * error handler and die with an error message.
+ *
+ * @deprecated since 1.29, use self::dieWithError() instead
+ * @param string $description One-line human-readable description of the
+ * error condition, e.g., "The API requires a valid action parameter"
+ * @param string $errorCode Brief, arbitrary, stable string to allow easy
+ * automated identification of the error, e.g., 'unknown_action'
+ * @param int $httpRespCode HTTP response code
+ * @param array|null $extradata Data to add to the "<error>" element; array in ApiResult format
+ * @throws ApiUsageException always
+ */
+ public function dieUsage( $description, $errorCode, $httpRespCode = 0, $extradata = null ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ $this->dieWithError(
+ new RawMessage( '$1', [ $description ] ),
+ $errorCode,
+ $extradata,
+ $httpRespCode
+ );
+ }
+
+ /**
+ * Get error (as code, string) from a Status object.
+ *
+ * @since 1.23
+ * @deprecated since 1.29, use ApiErrorFormatter::arrayFromStatus instead
+ * @param Status $status
+ * @param array|null &$extraData Set if extra data from IApiMessage is available (since 1.27)
+ * @return array Array of code and error string
+ * @throws MWException
+ */
+ public function getErrorFromStatus( $status, &$extraData = null ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ if ( $status->isGood() ) {
+ throw new MWException( 'Successful status passed to ApiBase::dieStatus' );
+ }
+
+ $errors = $status->getErrorsByType( 'error' );
+ if ( !$errors ) {
+ // No errors? Assume the warnings should be treated as errors
+ $errors = $status->getErrorsByType( 'warning' );
+ }
+ if ( !$errors ) {
+ // Still no errors? Punt
+ $errors = [ [ 'message' => 'unknownerror-nocode', 'params' => [] ] ];
+ }
+
+ if ( $errors[0]['message'] instanceof MessageSpecifier ) {
+ $msg = $errors[0]['message'];
+ } else {
+ $msg = new Message( $errors[0]['message'], $errors[0]['params'] );
+ }
+ if ( !$msg instanceof IApiMessage ) {
+ $key = $msg->getKey();
+ $params = $msg->getParams();
+ array_unshift( $params, isset( self::$messageMap[$key] ) ? self::$messageMap[$key] : $key );
+ $msg = ApiMessage::create( $params );
+ }
+
+ return [
+ $msg->getApiCode(),
+ ApiErrorFormatter::stripMarkup( $msg->inLanguage( 'en' )->useDatabase( false )->text() )
+ ];
+ }
+
+ /**
+ * @deprecated since 1.29. Prior to 1.29, this was a public mapping from
+ * arbitrary strings (often message keys used elsewhere in MediaWiki) to
+ * API codes and message texts, and a few interfaces required poking
+ * something in here. Now we're repurposing it to map those same strings
+ * to i18n messages, and declaring that any interface that requires poking
+ * at this is broken and needs replacing ASAP.
+ */
+ private static $messageMap = [
+ 'unknownerror' => 'apierror-unknownerror',
+ 'unknownerror-nocode' => 'apierror-unknownerror-nocode',
+ 'ns-specialprotected' => 'ns-specialprotected',
+ 'protectedinterface' => 'protectedinterface',
+ 'namespaceprotected' => 'namespaceprotected',
+ 'customcssprotected' => 'customcssprotected',
+ 'customjsprotected' => 'customjsprotected',
+ 'cascadeprotected' => 'cascadeprotected',
+ 'protectedpagetext' => 'protectedpagetext',
+ 'protect-cantedit' => 'protect-cantedit',
+ 'deleteprotected' => 'deleteprotected',
+ 'badaccess-group0' => 'badaccess-group0',
+ 'badaccess-groups' => 'badaccess-groups',
+ 'titleprotected' => 'titleprotected',
+ 'nocreate-loggedin' => 'nocreate-loggedin',
+ 'nocreatetext' => 'nocreatetext',
+ 'movenologintext' => 'movenologintext',
+ 'movenotallowed' => 'movenotallowed',
+ 'confirmedittext' => 'confirmedittext',
+ 'blockedtext' => 'apierror-blocked',
+ 'autoblockedtext' => 'apierror-autoblocked',
+ 'systemblockedtext' => 'apierror-systemblocked',
+ 'actionthrottledtext' => 'apierror-ratelimited',
+ 'alreadyrolled' => 'alreadyrolled',
+ 'cantrollback' => 'cantrollback',
+ 'readonlytext' => 'readonlytext',
+ 'sessionfailure' => 'sessionfailure',
+ 'cannotdelete' => 'cannotdelete',
+ 'notanarticle' => 'apierror-missingtitle',
+ 'selfmove' => 'selfmove',
+ 'immobile_namespace' => 'apierror-immobilenamespace',
+ 'articleexists' => 'articleexists',
+ 'hookaborted' => 'hookaborted',
+ 'cantmove-titleprotected' => 'cantmove-titleprotected',
+ 'imagenocrossnamespace' => 'imagenocrossnamespace',
+ 'imagetypemismatch' => 'imagetypemismatch',
+ 'ip_range_invalid' => 'ip_range_invalid',
+ 'range_block_disabled' => 'range_block_disabled',
+ 'nosuchusershort' => 'nosuchusershort',
+ 'badipaddress' => 'badipaddress',
+ 'ipb_expiry_invalid' => 'ipb_expiry_invalid',
+ 'ipb_already_blocked' => 'ipb_already_blocked',
+ 'ipb_blocked_as_range' => 'ipb_blocked_as_range',
+ 'ipb_cant_unblock' => 'ipb_cant_unblock',
+ 'mailnologin' => 'apierror-cantsend',
+ 'ipbblocked' => 'ipbblocked',
+ 'ipbnounblockself' => 'ipbnounblockself',
+ 'usermaildisabled' => 'usermaildisabled',
+ 'blockedemailuser' => 'apierror-blockedfrommail',
+ 'notarget' => 'apierror-notarget',
+ 'noemail' => 'noemail',
+ 'rcpatroldisabled' => 'rcpatroldisabled',
+ 'markedaspatrollederror-noautopatrol' => 'markedaspatrollederror-noautopatrol',
+ 'delete-toobig' => 'delete-toobig',
+ 'movenotallowedfile' => 'movenotallowedfile',
+ 'userrights-no-interwiki' => 'userrights-no-interwiki',
+ 'userrights-nodatabase' => 'userrights-nodatabase',
+ 'nouserspecified' => 'nouserspecified',
+ 'noname' => 'noname',
+ 'summaryrequired' => 'apierror-summaryrequired',
+ 'import-rootpage-invalid' => 'import-rootpage-invalid',
+ 'import-rootpage-nosubpage' => 'import-rootpage-nosubpage',
+ 'readrequired' => 'apierror-readapidenied',
+ 'writedisabled' => 'apierror-noapiwrite',
+ 'writerequired' => 'apierror-writeapidenied',
+ 'missingparam' => 'apierror-missingparam',
+ 'invalidtitle' => 'apierror-invalidtitle',
+ 'nosuchpageid' => 'apierror-nosuchpageid',
+ 'nosuchrevid' => 'apierror-nosuchrevid',
+ 'nosuchuser' => 'nosuchusershort',
+ 'invaliduser' => 'apierror-invaliduser',
+ 'invalidexpiry' => 'apierror-invalidexpiry',
+ 'pastexpiry' => 'apierror-pastexpiry',
+ 'create-titleexists' => 'apierror-create-titleexists',
+ 'missingtitle-createonly' => 'apierror-missingtitle-createonly',
+ 'cantblock' => 'apierror-cantblock',
+ 'canthide' => 'apierror-canthide',
+ 'cantblock-email' => 'apierror-cantblock-email',
+ 'cantunblock' => 'apierror-permissiondenied-generic',
+ 'cannotundelete' => 'cannotundelete',
+ 'permdenied-undelete' => 'apierror-permissiondenied-generic',
+ 'createonly-exists' => 'apierror-articleexists',
+ 'nocreate-missing' => 'apierror-missingtitle',
+ 'cantchangecontentmodel' => 'apierror-cantchangecontentmodel',
+ 'nosuchrcid' => 'apierror-nosuchrcid',
+ 'nosuchlogid' => 'apierror-nosuchlogid',
+ 'protect-invalidaction' => 'apierror-protect-invalidaction',
+ 'protect-invalidlevel' => 'apierror-protect-invalidlevel',
+ 'toofewexpiries' => 'apierror-toofewexpiries',
+ 'cantimport' => 'apierror-cantimport',
+ 'cantimport-upload' => 'apierror-cantimport-upload',
+ 'importnofile' => 'importnofile',
+ 'importuploaderrorsize' => 'importuploaderrorsize',
+ 'importuploaderrorpartial' => 'importuploaderrorpartial',
+ 'importuploaderrortemp' => 'importuploaderrortemp',
+ 'importcantopen' => 'importcantopen',
+ 'import-noarticle' => 'import-noarticle',
+ 'importbadinterwiki' => 'importbadinterwiki',
+ 'import-unknownerror' => 'apierror-import-unknownerror',
+ 'cantoverwrite-sharedfile' => 'apierror-cantoverwrite-sharedfile',
+ 'sharedfile-exists' => 'apierror-fileexists-sharedrepo-perm',
+ 'mustbeposted' => 'apierror-mustbeposted',
+ 'show' => 'apierror-show',
+ 'specialpage-cantexecute' => 'apierror-specialpage-cantexecute',
+ 'invalidoldimage' => 'apierror-invalidoldimage',
+ 'nodeleteablefile' => 'apierror-nodeleteablefile',
+ 'fileexists-forbidden' => 'fileexists-forbidden',
+ 'fileexists-shared-forbidden' => 'fileexists-shared-forbidden',
+ 'filerevert-badversion' => 'filerevert-badversion',
+ 'noimageredirect-anon' => 'apierror-noimageredirect-anon',
+ 'noimageredirect-logged' => 'apierror-noimageredirect',
+ 'spamdetected' => 'apierror-spamdetected',
+ 'contenttoobig' => 'apierror-contenttoobig',
+ 'noedit-anon' => 'apierror-noedit-anon',
+ 'noedit' => 'apierror-noedit',
+ 'wasdeleted' => 'apierror-pagedeleted',
+ 'blankpage' => 'apierror-emptypage',
+ 'editconflict' => 'editconflict',
+ 'hashcheckfailed' => 'apierror-badmd5',
+ 'missingtext' => 'apierror-notext',
+ 'emptynewsection' => 'apierror-emptynewsection',
+ 'revwrongpage' => 'apierror-revwrongpage',
+ 'undo-failure' => 'undo-failure',
+ 'content-not-allowed-here' => 'content-not-allowed-here',
+ 'edit-hook-aborted' => 'edit-hook-aborted',
+ 'edit-gone-missing' => 'edit-gone-missing',
+ 'edit-conflict' => 'edit-conflict',
+ 'edit-already-exists' => 'edit-already-exists',
+ 'invalid-file-key' => 'apierror-invalid-file-key',
+ 'nouploadmodule' => 'apierror-nouploadmodule',
+ 'uploaddisabled' => 'uploaddisabled',
+ 'copyuploaddisabled' => 'copyuploaddisabled',
+ 'copyuploadbaddomain' => 'apierror-copyuploadbaddomain',
+ 'copyuploadbadurl' => 'apierror-copyuploadbadurl',
+ 'filename-tooshort' => 'filename-tooshort',
+ 'filename-toolong' => 'filename-toolong',
+ 'illegal-filename' => 'illegal-filename',
+ 'filetype-missing' => 'filetype-missing',
+ 'mustbeloggedin' => 'apierror-mustbeloggedin',
+ ];
+
+ /**
+ * @deprecated do not use
+ * @param array|string|MessageSpecifier $error Element of a getUserPermissionsErrors()-style array
+ * @return ApiMessage
+ */
+ private function parseMsgInternal( $error ) {
+ $msg = Message::newFromSpecifier( $error );
+ if ( !$msg instanceof IApiMessage ) {
+ $key = $msg->getKey();
+ if ( isset( self::$messageMap[$key] ) ) {
+ $params = $msg->getParams();
+ array_unshift( $params, self::$messageMap[$key] );
+ } else {
+ $params = [ 'apierror-unknownerror', wfEscapeWikiText( $key ) ];
+ }
+ $msg = ApiMessage::create( $params );
+ }
+ return $msg;
+ }
+
+ /**
+ * Return the error message related to a certain array
+ * @deprecated since 1.29
+ * @param array|string|MessageSpecifier $error Element of a getUserPermissionsErrors()-style array
+ * @return array [ 'code' => code, 'info' => info ]
+ */
+ public function parseMsg( $error ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ // Check whether someone passed the whole array, instead of one element as
+ // documented. This breaks if it's actually an array of fallback keys, but
+ // that's long-standing misbehavior introduced in r87627 to incorrectly
+ // fix T30797.
+ if ( is_array( $error ) ) {
+ $first = reset( $error );
+ if ( is_array( $first ) ) {
+ wfDebug( __METHOD__ . ' was passed an array of arrays. ' . wfGetAllCallers( 5 ) );
+ $error = $first;
+ }
+ }
+
+ $msg = $this->parseMsgInternal( $error );
+ return [
+ 'code' => $msg->getApiCode(),
+ 'info' => ApiErrorFormatter::stripMarkup(
+ $msg->inLanguage( 'en' )->useDatabase( false )->text()
+ ),
+ 'data' => $msg->getApiData()
+ ];
+ }
+
+ /**
+ * Output the error message related to a certain array
+ * @deprecated since 1.29, use ApiBase::dieWithError() instead
+ * @param array|string|MessageSpecifier $error Element of a getUserPermissionsErrors()-style array
+ * @throws ApiUsageException always
+ */
+ public function dieUsageMsg( $error ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ $this->dieWithError( $this->parseMsgInternal( $error ) );
+ }
+
+ /**
+ * Will only set a warning instead of failing if the global $wgDebugAPI
+ * is set to true. Otherwise behaves exactly as dieUsageMsg().
+ * @deprecated since 1.29, use ApiBase::dieWithErrorOrDebug() instead
+ * @param array|string|MessageSpecifier $error Element of a getUserPermissionsErrors()-style array
+ * @throws ApiUsageException
+ * @since 1.21
+ */
+ public function dieUsageMsgOrDebug( $error ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ $this->dieWithErrorOrDebug( $this->parseMsgInternal( $error ) );
+ }
+
+ /**
+ * Return the description message.
+ *
+ * This is additional text to display on the help page after the summary.
+ *
+ * @deprecated since 1.30
+ * @return string|array|Message
+ */
+ protected function getDescriptionMessage() {
+ return [ [
+ "apihelp-{$this->getModulePath()}-description",
+ "apihelp-{$this->getModulePath()}-summary",
+ ] ];
+ }
+
+ /**@}*/
+}
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ */
diff --git a/www/wiki/includes/api/ApiBlock.php b/www/wiki/includes/api/ApiBlock.php
new file mode 100644
index 00000000..4d37af31
--- /dev/null
+++ b/www/wiki/includes/api/ApiBlock.php
@@ -0,0 +1,198 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 4, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that facilitates the blocking of users. Requires API write mode
+ * to be enabled.
+ *
+ * @ingroup API
+ */
+class ApiBlock extends ApiBase {
+
+ /**
+ * Blocks the user specified in the parameters for the given expiry, with the
+ * given reason, and with all other settings provided in the params. If the block
+ * succeeds, produces a result containing the details of the block and notice
+ * of success. If it fails, the result will specify the nature of the error.
+ */
+ public function execute() {
+ $this->checkUserRightsAny( 'block' );
+
+ $user = $this->getUser();
+ $params = $this->extractRequestParams();
+
+ $this->requireOnlyOneParameter( $params, 'user', 'userid' );
+
+ # T17810: blocked admins should have limited access here
+ if ( $user->isBlocked() ) {
+ $status = SpecialBlock::checkUnblockSelf( $params['user'], $user );
+ if ( $status !== true ) {
+ $this->dieWithError(
+ $status,
+ null,
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
+ );
+ }
+ }
+
+ if ( $params['userid'] !== null ) {
+ $username = User::whoIs( $params['userid'] );
+
+ if ( $username === false ) {
+ $this->dieWithError( [ 'apierror-nosuchuserid', $params['userid'] ], 'nosuchuserid' );
+ } else {
+ $params['user'] = $username;
+ }
+ } else {
+ $target = User::newFromName( $params['user'] );
+
+ // T40633 - if the target is a user (not an IP address), but it
+ // doesn't exist or is unusable, error.
+ if ( $target instanceof User &&
+ ( $target->isAnon() /* doesn't exist */ || !User::isUsableName( $target->getName() ) )
+ ) {
+ $this->dieWithError( [ 'nosuchusershort', $params['user'] ], 'nosuchuser' );
+ }
+ }
+
+ if ( $params['tags'] ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ if ( $params['hidename'] && !$user->isAllowed( 'hideuser' ) ) {
+ $this->dieWithError( 'apierror-canthide' );
+ }
+ if ( $params['noemail'] && !SpecialBlock::canBlockEmail( $user ) ) {
+ $this->dieWithError( 'apierror-cantblock-email' );
+ }
+
+ $data = [
+ 'PreviousTarget' => $params['user'],
+ 'Target' => $params['user'],
+ 'Reason' => [
+ $params['reason'],
+ 'other',
+ $params['reason']
+ ],
+ 'Expiry' => $params['expiry'],
+ 'HardBlock' => !$params['anononly'],
+ 'CreateAccount' => $params['nocreate'],
+ 'AutoBlock' => $params['autoblock'],
+ 'DisableEmail' => $params['noemail'],
+ 'HideUser' => $params['hidename'],
+ 'DisableUTEdit' => !$params['allowusertalk'],
+ 'Reblock' => $params['reblock'],
+ 'Watch' => $params['watchuser'],
+ 'Confirm' => true,
+ 'Tags' => $params['tags'],
+ ];
+
+ $retval = SpecialBlock::processForm( $data, $this->getContext() );
+ if ( $retval !== true ) {
+ $this->dieStatus( $this->errorArrayToStatus( $retval ) );
+ }
+
+ list( $target, /*...*/ ) = SpecialBlock::getTargetAndType( $params['user'] );
+ $res['user'] = $params['user'];
+ $res['userID'] = $target instanceof User ? $target->getId() : 0;
+
+ $block = Block::newFromTarget( $target, null, true );
+ if ( $block instanceof Block ) {
+ $res['expiry'] = ApiResult::formatExpiry( $block->mExpiry, 'infinite' );
+ $res['id'] = $block->getId();
+ } else {
+ # should be unreachable
+ $res['expiry'] = '';
+ $res['id'] = '';
+ }
+
+ $res['reason'] = $params['reason'];
+ $res['anononly'] = $params['anononly'];
+ $res['nocreate'] = $params['nocreate'];
+ $res['autoblock'] = $params['autoblock'];
+ $res['noemail'] = $params['noemail'];
+ $res['hidename'] = $params['hidename'];
+ $res['allowusertalk'] = $params['allowusertalk'];
+ $res['watchuser'] = $params['watchuser'];
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $res );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'userid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'expiry' => 'never',
+ 'reason' => '',
+ 'anononly' => false,
+ 'nocreate' => false,
+ 'autoblock' => false,
+ 'noemail' => false,
+ 'hidename' => false,
+ 'allowusertalk' => false,
+ 'reblock' => false,
+ 'watchuser' => false,
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ return [
+ 'action=block&user=192.0.2.5&expiry=3%20days&reason=First%20strike&token=123ABC'
+ => 'apihelp-block-example-ip-simple',
+ 'action=block&user=Vandal&expiry=never&reason=Vandalism&nocreate=&autoblock=&noemail=&token=123ABC'
+ => 'apihelp-block-example-user-complex',
+ ];
+ // @codingStandardsIgnoreEnd
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Block';
+ }
+}
diff --git a/www/wiki/includes/api/ApiCSPReport.php b/www/wiki/includes/api/ApiCSPReport.php
new file mode 100644
index 00000000..0df0ca97
--- /dev/null
+++ b/www/wiki/includes/api/ApiCSPReport.php
@@ -0,0 +1,242 @@
+<?php
+/**
+ * Copyright © 2015 Brian Wolff
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Api module to receive and log CSP violation reports
+ *
+ * @ingroup API
+ */
+class ApiCSPReport extends ApiBase {
+
+ private $log;
+
+ /**
+ * These reports should be small. Ignore super big reports out of paranoia
+ */
+ const MAX_POST_SIZE = 8192;
+
+ /**
+ * Logs a content-security-policy violation report from web browser.
+ */
+ public function execute() {
+ $reportOnly = $this->getParameter( 'reportonly' );
+ $logname = $reportOnly ? 'csp-report-only' : 'csp';
+ $this->log = LoggerFactory::getInstance( $logname );
+ $userAgent = $this->getRequest()->getHeader( 'user-agent' );
+
+ $this->verifyPostBodyOk();
+ $report = $this->getReport();
+ $flags = $this->getFlags( $report );
+
+ $warningText = $this->generateLogLine( $flags, $report );
+ $this->logReport( $flags, $warningText, [
+ // XXX Is it ok to put untrusted data into log??
+ 'csp-report' => $report,
+ 'method' => __METHOD__,
+ 'user' => $this->getUser()->getName(),
+ 'user-agent' => $userAgent,
+ 'source' => $this->getParameter( 'source' ),
+ ] );
+ $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
+ }
+
+ /**
+ * Log CSP report, with a different severity depending on $flags
+ * @param array $flags Flags for this report
+ * @param string $logLine text of log entry
+ * @param array $context logging context
+ */
+ private function logReport( $flags, $logLine, $context ) {
+ if ( in_array( 'false-positive', $flags ) ) {
+ // These reports probably don't matter much
+ $this->log->debug( $logLine, $context );
+ } else {
+ // Normal report.
+ $this->log->warning( $logLine, $context );
+ }
+ }
+
+ /**
+ * Get extra notes about the report.
+ *
+ * @param array $report The CSP report
+ * @return array
+ */
+ private function getFlags( $report ) {
+ $reportOnly = $this->getParameter( 'reportonly' );
+ $source = $this->getParameter( 'source' );
+ $falsePositives = $this->getConfig()->get( 'CSPFalsePositiveUrls' );
+
+ $flags = [];
+ if ( $source !== 'internal' ) {
+ $flags[] = 'source=' . $source;
+ }
+ if ( $reportOnly ) {
+ $flags[] = 'report-only';
+ }
+
+ if (
+ ( isset( $report['blocked-uri'] ) &&
+ isset( $falsePositives[$report['blocked-uri']] ) )
+ || ( isset( $report['source-file'] ) &&
+ isset( $falsePositives[$report['source-file']] ) )
+ ) {
+ // Report caused by Ad-Ware
+ $flags[] = 'false-positive';
+ }
+ return $flags;
+ }
+
+ /**
+ * Output an api error if post body is obviously not OK.
+ */
+ private function verifyPostBodyOk() {
+ $req = $this->getRequest();
+ $contentType = $req->getHeader( 'content-type' );
+ if ( $contentType !== 'application/json'
+ && $contentType !== 'application/csp-report'
+ ) {
+ $this->error( 'wrongformat', __METHOD__ );
+ }
+ if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
+ $this->error( 'toobig', __METHOD__ );
+ }
+ }
+
+ /**
+ * Get the report from post body and turn into associative array.
+ *
+ * @return Array
+ */
+ private function getReport() {
+ $postBody = $this->getRequest()->getRawInput();
+ if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
+ // paranoia, already checked content-length earlier.
+ $this->error( 'toobig', __METHOD__ );
+ }
+ $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
+ if ( !$status->isGood() ) {
+ $msg = $status->getErrors()[0]['message'];
+ if ( $msg instanceof Message ) {
+ $msg = $msg->getKey();
+ }
+ $this->error( $msg, __METHOD__ );
+ }
+
+ $report = $status->getValue();
+
+ if ( !isset( $report['csp-report'] ) ) {
+ $this->error( 'missingkey', __METHOD__ );
+ }
+ return $report['csp-report'];
+ }
+
+ /**
+ * Get text of log line.
+ *
+ * @param array $flags of additional markers for this report
+ * @param array $report the csp report
+ * @return string Text to put in log
+ */
+ private function generateLogLine( $flags, $report ) {
+ $flagText = '';
+ if ( $flags ) {
+ $flagText = '[' . implode( $flags, ', ' ) . ']';
+ }
+
+ $blockedFile = isset( $report['blocked-uri'] ) ? $report['blocked-uri'] : 'n/a';
+ $page = isset( $report['document-uri'] ) ? $report['document-uri'] : 'n/a';
+ $line = isset( $report['line-number'] ) ? ':' . $report['line-number'] : '';
+ $warningText = $flagText .
+ ' Received CSP report: <' . $blockedFile .
+ '> blocked from being loaded on <' . $page . '>' . $line;
+ return $warningText;
+ }
+
+ /**
+ * Stop processing the request, and output/log an error
+ *
+ * @param string $code error code
+ * @param string $method method that made error
+ * @throws ApiUsageException Always
+ */
+ private function error( $code, $method ) {
+ $this->log->info( 'Error reading CSP report: ' . $code, [
+ 'method' => $method,
+ 'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
+ ] );
+ // Return 400 on error for user agents to display, e.g. to the console.
+ $this->dieWithError(
+ [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
+ );
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'reportonly' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false
+ ],
+ 'source' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_DFLT => 'internal',
+ ApiBase::PARAM_REQUIRED => false
+ ]
+ ];
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return false;
+ }
+
+ /**
+ * Mark as internal. This isn't meant to be used by normal api users
+ * @return bool
+ */
+ public function isInternal() {
+ return true;
+ }
+
+ /**
+ * Even if you don't have read rights, we still want your report.
+ * @return bool
+ */
+ public function isReadMode() {
+ return false;
+ }
+
+ /**
+ * Doesn't touch db, so max lag should be rather irrelavent.
+ *
+ * Also, this makes sure that reports aren't lost during lag events.
+ * @return bool
+ */
+ public function shouldCheckMaxLag() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/api/ApiChangeAuthenticationData.php b/www/wiki/includes/api/ApiChangeAuthenticationData.php
new file mode 100644
index 00000000..d4a26ad9
--- /dev/null
+++ b/www/wiki/includes/api/ApiChangeAuthenticationData.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Copyright © 2016 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Change authentication data with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiChangeAuthenticationData extends ApiBase {
+
+ public function __construct( ApiMain $main, $action ) {
+ parent::__construct( $main, $action, 'changeauth' );
+ }
+
+ public function execute() {
+ if ( !$this->getUser()->isLoggedIn() ) {
+ $this->dieWithError( 'apierror-mustbeloggedin-changeauthenticationdata', 'notloggedin' );
+ }
+
+ $helper = new ApiAuthManagerHelper( $this );
+ $manager = AuthManager::singleton();
+
+ // Check security-sensitive operation status
+ $helper->securitySensitiveOperation( 'ChangeCredentials' );
+
+ // Fetch the request
+ $reqs = ApiAuthManagerHelper::blacklistAuthenticationRequests(
+ $helper->loadAuthenticationRequests( AuthManager::ACTION_CHANGE ),
+ $this->getConfig()->get( 'ChangeCredentialsBlacklist' )
+ );
+ if ( count( $reqs ) !== 1 ) {
+ $this->dieWithError( 'apierror-changeauth-norequest', 'badrequest' );
+ }
+ $req = reset( $reqs );
+
+ // Make the change
+ $status = $manager->allowsAuthenticationDataChange( $req, true );
+ Hooks::run( 'ChangeAuthenticationDataAudit', [ $req, $status ] );
+ if ( !$status->isGood() ) {
+ $this->dieStatus( $status );
+ }
+ $manager->changeAuthenticationData( $req );
+
+ $this->getResult()->addValue( null, 'changeauthenticationdata', [ 'status' => 'success' ] );
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ public function getAllowedParams() {
+ return ApiAuthManagerHelper::getStandardParams( AuthManager::ACTION_CHANGE,
+ 'request'
+ );
+ }
+
+ public function dynamicParameterDocumentation() {
+ return [ 'api-help-authmanagerhelper-additional-params', AuthManager::ACTION_CHANGE ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=changeauthenticationdata' .
+ '&changeauthrequest=MediaWiki%5CAuth%5CPasswordAuthenticationRequest' .
+ '&password=ExamplePassword&retype=ExamplePassword&changeauthtoken=123ABC'
+ => 'apihelp-changeauthenticationdata-example-password',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Manage_authentication_data';
+ }
+}
diff --git a/www/wiki/includes/api/ApiCheckToken.php b/www/wiki/includes/api/ApiCheckToken.php
new file mode 100644
index 00000000..e1be8efa
--- /dev/null
+++ b/www/wiki/includes/api/ApiCheckToken.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Created on Jan 29, 2015
+ *
+ * Copyright © 2015 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Session\Token;
+
+/**
+ * @since 1.25
+ * @ingroup API
+ */
+class ApiCheckToken extends ApiBase {
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $token = $params['token'];
+ $maxage = $params['maxtokenage'];
+ $salts = ApiQueryTokens::getTokenTypeSalts();
+
+ $res = [];
+
+ $tokenObj = ApiQueryTokens::getToken(
+ $this->getUser(), $this->getRequest()->getSession(), $salts[$params['type']]
+ );
+
+ if ( substr( $token, -strlen( urldecode( Token::SUFFIX ) ) ) === urldecode( Token::SUFFIX ) ) {
+ $this->addWarning( 'apiwarn-checktoken-percentencoding' );
+ }
+
+ if ( $tokenObj->match( $token, $maxage ) ) {
+ $res['result'] = 'valid';
+ } elseif ( $maxage !== null && $tokenObj->match( $token ) ) {
+ $res['result'] = 'expired';
+ } else {
+ $res['result'] = 'invalid';
+ }
+
+ $ts = Token::getTimestamp( $token );
+ if ( $ts !== null ) {
+ $mwts = new MWTimestamp();
+ $mwts->timestamp->setTimestamp( $ts );
+ $res['generated'] = $mwts->getTimestamp( TS_ISO_8601 );
+ }
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $res );
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'type' => [
+ ApiBase::PARAM_TYPE => array_keys( ApiQueryTokens::getTokenTypeSalts() ),
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'token' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_SENSITIVE => true,
+ ],
+ 'maxtokenage' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=checktoken&type=csrf&token=123ABC'
+ => 'apihelp-checktoken-example-simple',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiClearHasMsg.php b/www/wiki/includes/api/ApiClearHasMsg.php
new file mode 100644
index 00000000..3b246309
--- /dev/null
+++ b/www/wiki/includes/api/ApiClearHasMsg.php
@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * Created on August 26, 2014
+ *
+ * Copyright © 2014 Petr Bena (benapetr@gmail.com)
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that clears the hasmsg flag for current user
+ * @ingroup API
+ */
+class ApiClearHasMsg extends ApiBase {
+ public function execute() {
+ $user = $this->getUser();
+ $user->setNewtalk( false );
+ $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=clearhasmsg'
+ => 'apihelp-clearhasmsg-example-1',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:ClearHasMsg';
+ }
+}
diff --git a/www/wiki/includes/api/ApiClientLogin.php b/www/wiki/includes/api/ApiClientLogin.php
new file mode 100644
index 00000000..65dea93b
--- /dev/null
+++ b/www/wiki/includes/api/ApiClientLogin.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Copyright © 2016 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\CreateFromLoginAuthenticationRequest;
+
+/**
+ * Log in to the wiki with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiClientLogin extends ApiBase {
+
+ public function __construct( ApiMain $main, $action ) {
+ parent::__construct( $main, $action, 'login' );
+ }
+
+ public function getFinalDescription() {
+ // A bit of a hack to append 'api-help-authmanager-general-usage'
+ $msgs = parent::getFinalDescription();
+ $msgs[] = ApiBase::makeMessage( 'api-help-authmanager-general-usage', $this->getContext(), [
+ $this->getModulePrefix(),
+ $this->getModuleName(),
+ $this->getModulePath(),
+ AuthManager::ACTION_LOGIN,
+ self::needsToken(),
+ ] );
+ return $msgs;
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $this->requireAtLeastOneParameter( $params, 'continue', 'returnurl' );
+
+ if ( $params['returnurl'] !== null ) {
+ $bits = wfParseUrl( $params['returnurl'] );
+ if ( !$bits || $bits['scheme'] === '' ) {
+ $encParamName = $this->encodeParamName( 'returnurl' );
+ $this->dieWithError(
+ [ 'apierror-badurl', $encParamName, wfEscapeWikiText( $params['returnurl'] ) ],
+ "badurl_{$encParamName}"
+ );
+ }
+ }
+
+ $helper = new ApiAuthManagerHelper( $this );
+ $manager = AuthManager::singleton();
+
+ // Make sure it's possible to log in
+ if ( !$manager->canAuthenticateNow() ) {
+ $this->getResult()->addValue( null, 'clientlogin', $helper->formatAuthenticationResponse(
+ AuthenticationResponse::newFail( $this->msg( 'userlogin-cannot-' . AuthManager::ACTION_LOGIN ) )
+ ) );
+ $helper->logAuthenticationResult( 'login', 'userlogin-cannot-' . AuthManager::ACTION_LOGIN );
+ return;
+ }
+
+ // Perform the login step
+ if ( $params['continue'] ) {
+ $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_LOGIN_CONTINUE );
+ $res = $manager->continueAuthentication( $reqs );
+ } else {
+ $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_LOGIN );
+ if ( $params['preservestate'] ) {
+ $req = $helper->getPreservedRequest();
+ if ( $req ) {
+ $reqs[] = $req;
+ }
+ }
+ $res = $manager->beginAuthentication( $reqs, $params['returnurl'] );
+ }
+
+ // Remove CreateFromLoginAuthenticationRequest from $res->neededRequests.
+ // It's there so a RESTART treated as UI will work right, but showing
+ // it to the API client is just confusing.
+ $res->neededRequests = ApiAuthManagerHelper::blacklistAuthenticationRequests(
+ $res->neededRequests, [ CreateFromLoginAuthenticationRequest::class ]
+ );
+
+ $this->getResult()->addValue( null, 'clientlogin',
+ $helper->formatAuthenticationResponse( $res ) );
+ $helper->logAuthenticationResult( 'login', $res );
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ public function needsToken() {
+ return 'login';
+ }
+
+ public function getAllowedParams() {
+ return ApiAuthManagerHelper::getStandardParams( AuthManager::ACTION_LOGIN,
+ 'requests', 'messageformat', 'mergerequestfields', 'preservestate', 'returnurl', 'continue'
+ );
+ }
+
+ public function dynamicParameterDocumentation() {
+ return [ 'api-help-authmanagerhelper-additional-params', AuthManager::ACTION_LOGIN ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=clientlogin&username=Example&password=ExamplePassword&'
+ . 'loginreturnurl=http://example.org/&logintoken=123ABC'
+ => 'apihelp-clientlogin-example-login',
+ 'action=clientlogin&logincontinue=1&OATHToken=987654&logintoken=123ABC'
+ => 'apihelp-clientlogin-example-login2',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Login';
+ }
+}
diff --git a/www/wiki/includes/api/ApiComparePages.php b/www/wiki/includes/api/ApiComparePages.php
new file mode 100644
index 00000000..953bc10c
--- /dev/null
+++ b/www/wiki/includes/api/ApiComparePages.php
@@ -0,0 +1,490 @@
+<?php
+/**
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class ApiComparePages extends ApiBase {
+
+ private $guessed = false, $guessedTitle, $guessedModel, $props;
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ // Parameter validation
+ $this->requireAtLeastOneParameter( $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext' );
+ $this->requireAtLeastOneParameter( $params, 'totitle', 'toid', 'torev', 'totext', 'torelative' );
+
+ $this->props = array_flip( $params['prop'] );
+
+ // Cache responses publicly by default. This may be overridden later.
+ $this->getMain()->setCacheMode( 'public' );
+
+ // Get the 'from' Revision and Content
+ list( $fromRev, $fromContent, $relRev ) = $this->getDiffContent( 'from', $params );
+
+ // Get the 'to' Revision and Content
+ if ( $params['torelative'] !== null ) {
+ if ( !$relRev ) {
+ $this->dieWithError( 'apierror-compare-relative-to-nothing' );
+ }
+ switch ( $params['torelative'] ) {
+ case 'prev':
+ // Swap 'from' and 'to'
+ $toRev = $fromRev;
+ $toContent = $fromContent;
+ $fromRev = $relRev->getPrevious();
+ $fromContent = $fromRev
+ ? $fromRev->getContent( Revision::FOR_THIS_USER, $this->getUser() )
+ : $toContent->getContentHandler()->makeEmptyContent();
+ if ( !$fromContent ) {
+ $this->dieWithError(
+ [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent'
+ );
+ }
+ break;
+
+ case 'next':
+ $toRev = $relRev->getNext();
+ $toContent = $toRev
+ ? $toRev->getContent( Revision::FOR_THIS_USER, $this->getUser() )
+ : $fromContent;
+ if ( !$toContent ) {
+ $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
+ }
+ break;
+
+ case 'cur':
+ $title = $relRev->getTitle();
+ $id = $title->getLatestRevID();
+ $toRev = $id ? Revision::newFromId( $id ) : null;
+ if ( !$toRev ) {
+ $this->dieWithError(
+ [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
+ );
+ }
+ $toContent = $toRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+ if ( !$toContent ) {
+ $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
+ }
+ break;
+ }
+ $relRev2 = null;
+ } else {
+ list( $toRev, $toContent, $relRev2 ) = $this->getDiffContent( 'to', $params );
+ }
+
+ // Should never happen, but just in case...
+ if ( !$fromContent || !$toContent ) {
+ $this->dieWithError( 'apierror-baddiff' );
+ }
+
+ // Get the diff
+ $context = new DerivativeContext( $this->getContext() );
+ if ( $relRev && $relRev->getTitle() ) {
+ $context->setTitle( $relRev->getTitle() );
+ } elseif ( $relRev2 && $relRev2->getTitle() ) {
+ $context->setTitle( $relRev2->getTitle() );
+ } else {
+ $this->guessTitleAndModel();
+ if ( $this->guessedTitle ) {
+ $context->setTitle( $this->guessedTitle );
+ }
+ }
+ $de = $fromContent->getContentHandler()->createDifferenceEngine(
+ $context,
+ $fromRev ? $fromRev->getId() : 0,
+ $toRev ? $toRev->getId() : 0,
+ /* $rcid = */ null,
+ /* $refreshCache = */ false,
+ /* $unhide = */ true
+ );
+ $de->setContent( $fromContent, $toContent );
+ $difftext = $de->getDiffBody();
+ if ( $difftext === false ) {
+ $this->dieWithError( 'apierror-baddiff' );
+ }
+
+ // Fill in the response
+ $vals = [];
+ $this->setVals( $vals, 'from', $fromRev );
+ $this->setVals( $vals, 'to', $toRev );
+
+ if ( isset( $this->props['rel'] ) ) {
+ if ( $fromRev ) {
+ $rev = $fromRev->getPrevious();
+ if ( $rev ) {
+ $vals['prev'] = $rev->getId();
+ }
+ }
+ if ( $toRev ) {
+ $rev = $toRev->getNext();
+ if ( $rev ) {
+ $vals['next'] = $rev->getId();
+ }
+ }
+ }
+
+ if ( isset( $this->props['diffsize'] ) ) {
+ $vals['diffsize'] = strlen( $difftext );
+ }
+ if ( isset( $this->props['diff'] ) ) {
+ ApiResult::setContentValue( $vals, 'body', $difftext );
+ }
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $vals );
+ }
+
+ /**
+ * Guess an appropriate default Title and content model for this request
+ *
+ * Fills in $this->guessedTitle based on the first of 'fromrev',
+ * 'fromtitle', 'fromid', 'torev', 'totitle', and 'toid' that's present and
+ * valid.
+ *
+ * Fills in $this->guessedModel based on the Revision or Title used to
+ * determine $this->guessedTitle, or the 'fromcontentmodel' or
+ * 'tocontentmodel' parameters if no title was guessed.
+ */
+ private function guessTitleAndModel() {
+ if ( $this->guessed ) {
+ return;
+ }
+
+ $this->guessed = true;
+ $params = $this->extractRequestParams();
+
+ foreach ( [ 'from', 'to' ] as $prefix ) {
+ if ( $params["{$prefix}rev"] !== null ) {
+ $revId = $params["{$prefix}rev"];
+ $rev = Revision::newFromId( $revId );
+ if ( !$rev ) {
+ // Titles of deleted revisions aren't secret, per T51088
+ $row = $this->getDB()->selectRow(
+ 'archive',
+ array_merge(
+ Revision::selectArchiveFields(),
+ [ 'ar_namespace', 'ar_title' ]
+ ),
+ [ 'ar_rev_id' => $revId ],
+ __METHOD__
+ );
+ if ( $row ) {
+ $rev = Revision::newFromArchiveRow( $row );
+ }
+ }
+ if ( $rev ) {
+ $this->guessedTitle = $rev->getTitle();
+ $this->guessedModel = $rev->getContentModel();
+ break;
+ }
+ }
+
+ if ( $params["{$prefix}title"] !== null ) {
+ $title = Title::newFromText( $params["{$prefix}title"] );
+ if ( $title && !$title->isExternal() ) {
+ $this->guessedTitle = $title;
+ break;
+ }
+ }
+
+ if ( $params["{$prefix}id"] !== null ) {
+ $title = Title::newFromID( $params["{$prefix}id"] );
+ if ( $title ) {
+ $this->guessedTitle = $title;
+ break;
+ }
+ }
+ }
+
+ if ( !$this->guessedModel ) {
+ if ( $this->guessedTitle ) {
+ $this->guessedModel = $this->guessedTitle->getContentModel();
+ } elseif ( $params['fromcontentmodel'] !== null ) {
+ $this->guessedModel = $params['fromcontentmodel'];
+ } elseif ( $params['tocontentmodel'] !== null ) {
+ $this->guessedModel = $params['tocontentmodel'];
+ }
+ }
+ }
+
+ /**
+ * Get the Revision and Content for one side of the diff
+ *
+ * This uses the appropriate set of 'rev', 'id', 'title', 'text', 'pst',
+ * 'contentmodel', and 'contentformat' parameters to determine what content
+ * should be diffed.
+ *
+ * Returns three values:
+ * - The revision used to retrieve the content, if any
+ * - The content to be diffed
+ * - The revision specified, if any, even if not used to retrieve the
+ * Content
+ *
+ * @param string $prefix 'from' or 'to'
+ * @param array $params
+ * @return array [ Revision|null, Content, Revision|null ]
+ */
+ private function getDiffContent( $prefix, array $params ) {
+ $title = null;
+ $rev = null;
+ $suppliedContent = $params["{$prefix}text"] !== null;
+
+ // Get the revision and title, if applicable
+ $revId = null;
+ if ( $params["{$prefix}rev"] !== null ) {
+ $revId = $params["{$prefix}rev"];
+ } elseif ( $params["{$prefix}title"] !== null || $params["{$prefix}id"] !== null ) {
+ if ( $params["{$prefix}title"] !== null ) {
+ $title = Title::newFromText( $params["{$prefix}title"] );
+ if ( !$title || $title->isExternal() ) {
+ $this->dieWithError(
+ [ 'apierror-invalidtitle', wfEscapeWikiText( $params["{$prefix}title"] ) ]
+ );
+ }
+ } else {
+ $title = Title::newFromID( $params["{$prefix}id"] );
+ if ( !$title ) {
+ $this->dieWithError( [ 'apierror-nosuchpageid', $params["{$prefix}id"] ] );
+ }
+ }
+ $revId = $title->getLatestRevID();
+ if ( !$revId ) {
+ $revId = null;
+ // Only die here if we're not using supplied text
+ if ( !$suppliedContent ) {
+ if ( $title->exists() ) {
+ $this->dieWithError(
+ [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
+ );
+ } else {
+ $this->dieWithError(
+ [ 'apierror-missingtitle-byname', wfEscapeWikiText( $title->getPrefixedText() ) ],
+ 'missingtitle'
+ );
+ }
+ }
+ }
+ }
+ if ( $revId !== null ) {
+ $rev = Revision::newFromId( $revId );
+ if ( !$rev && $this->getUser()->isAllowedAny( 'deletedtext', 'undelete' ) ) {
+ // Try the 'archive' table
+ $row = $this->getDB()->selectRow(
+ 'archive',
+ array_merge(
+ Revision::selectArchiveFields(),
+ [ 'ar_namespace', 'ar_title' ]
+ ),
+ [ 'ar_rev_id' => $revId ],
+ __METHOD__
+ );
+ if ( $row ) {
+ $rev = Revision::newFromArchiveRow( $row );
+ $rev->isArchive = true;
+ }
+ }
+ if ( !$rev ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $revId ] );
+ }
+ $title = $rev->getTitle();
+
+ // If we don't have supplied content, return here. Otherwise,
+ // continue on below with the supplied content.
+ if ( !$suppliedContent ) {
+ $content = $rev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+ if ( !$content ) {
+ $this->dieWithError( [ 'apierror-missingcontent-revid', $revId ], 'missingcontent' );
+ }
+ return [ $rev, $content, $rev ];
+ }
+ }
+
+ // Override $content based on supplied text
+ $model = $params["{$prefix}contentmodel"];
+ $format = $params["{$prefix}contentformat"];
+
+ if ( !$model && $rev ) {
+ $model = $rev->getContentModel();
+ }
+ if ( !$model && $title ) {
+ $model = $title->getContentModel();
+ }
+ if ( !$model ) {
+ $this->guessTitleAndModel();
+ $model = $this->guessedModel;
+ }
+ if ( !$model ) {
+ $model = CONTENT_MODEL_WIKITEXT;
+ $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] );
+ }
+
+ if ( !$title ) {
+ $this->guessTitleAndModel();
+ $title = $this->guessedTitle;
+ }
+
+ try {
+ $content = ContentHandler::makeContent( $params["{$prefix}text"], $title, $model, $format );
+ } catch ( MWContentSerializationException $ex ) {
+ $this->dieWithException( $ex, [
+ 'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
+ ] );
+ }
+
+ if ( $params["{$prefix}pst"] ) {
+ if ( !$title ) {
+ $this->dieWithError( 'apierror-compare-no-title' );
+ }
+ $popts = ParserOptions::newFromContext( $this->getContext() );
+ $content = $content->preSaveTransform( $title, $this->getUser(), $popts );
+ }
+
+ return [ null, $content, $rev ];
+ }
+
+ /**
+ * Set value fields from a Revision object
+ * @param array &$vals Result array to set data into
+ * @param string $prefix 'from' or 'to'
+ * @param Revision|null $rev
+ */
+ private function setVals( &$vals, $prefix, $rev ) {
+ if ( $rev ) {
+ $title = $rev->getTitle();
+ if ( isset( $this->props['ids'] ) ) {
+ $vals["{$prefix}id"] = $title->getArticleId();
+ $vals["{$prefix}revid"] = $rev->getId();
+ }
+ if ( isset( $this->props['title'] ) ) {
+ ApiQueryBase::addTitleInfo( $vals, $title, $prefix );
+ }
+ if ( isset( $this->props['size'] ) ) {
+ $vals["{$prefix}size"] = $rev->getSize();
+ }
+
+ $anyHidden = false;
+ if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $vals["{$prefix}texthidden"] = true;
+ $anyHidden = true;
+ }
+
+ if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
+ $vals["{$prefix}userhidden"] = true;
+ $anyHidden = true;
+ }
+ if ( isset( $this->props['user'] ) &&
+ $rev->userCan( Revision::DELETED_USER, $this->getUser() )
+ ) {
+ $vals["{$prefix}user"] = $rev->getUserText( Revision::RAW );
+ $vals["{$prefix}userid"] = $rev->getUser( Revision::RAW );
+ }
+
+ if ( $rev->isDeleted( Revision::DELETED_COMMENT ) ) {
+ $vals["{$prefix}commenthidden"] = true;
+ $anyHidden = true;
+ }
+ if ( $rev->userCan( Revision::DELETED_COMMENT, $this->getUser() ) ) {
+ if ( isset( $this->props['comment'] ) ) {
+ $vals["{$prefix}comment"] = $rev->getComment( Revision::RAW );
+ }
+ if ( isset( $this->props['parsedcomment'] ) ) {
+ $vals["{$prefix}parsedcomment"] = Linker::formatComment(
+ $rev->getComment( Revision::RAW ),
+ $rev->getTitle()
+ );
+ }
+ }
+
+ if ( $anyHidden ) {
+ $this->getMain()->setCacheMode( 'private' );
+ if ( $rev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
+ $vals["{$prefix}suppressed"] = true;
+ }
+ }
+
+ if ( !empty( $rev->isArchive ) ) {
+ $this->getMain()->setCacheMode( 'private' );
+ $vals["{$prefix}archive"] = true;
+ }
+ }
+ }
+
+ public function getAllowedParams() {
+ // Parameters for the 'from' and 'to' content
+ $fromToParams = [
+ 'title' => null,
+ 'id' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'rev' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'text' => [
+ ApiBase::PARAM_TYPE => 'text'
+ ],
+ 'pst' => false,
+ 'contentformat' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
+ ],
+ 'contentmodel' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
+ ]
+ ];
+
+ $ret = [];
+ foreach ( $fromToParams as $k => $v ) {
+ $ret["from$k"] = $v;
+ }
+ foreach ( $fromToParams as $k => $v ) {
+ $ret["to$k"] = $v;
+ }
+
+ $ret = wfArrayInsertAfter(
+ $ret,
+ [ 'torelative' => [ ApiBase::PARAM_TYPE => [ 'prev', 'next', 'cur' ], ] ],
+ 'torev'
+ );
+
+ $ret['prop'] = [
+ ApiBase::PARAM_DFLT => 'diff|ids|title',
+ ApiBase::PARAM_TYPE => [
+ 'diff',
+ 'diffsize',
+ 'rel',
+ 'ids',
+ 'title',
+ 'user',
+ 'comment',
+ 'parsedcomment',
+ 'size',
+ ],
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ];
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=compare&fromrev=1&torev=2'
+ => 'apihelp-compare-example-1',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiContinuationManager.php b/www/wiki/includes/api/ApiContinuationManager.php
new file mode 100644
index 00000000..7da8ed9a
--- /dev/null
+++ b/www/wiki/includes/api/ApiContinuationManager.php
@@ -0,0 +1,271 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This manages continuation state.
+ * @since 1.25 this is no longer a subclass of ApiBase
+ * @ingroup API
+ */
+class ApiContinuationManager {
+ private $source;
+
+ private $allModules = [];
+ private $generatedModules = [];
+
+ private $continuationData = [];
+ private $generatorContinuationData = [];
+ private $generatorNonContinuationData = [];
+
+ private $generatorParams = [];
+ private $generatorDone = false;
+
+ /**
+ * @param ApiBase $module Module starting the continuation
+ * @param ApiBase[] $allModules Contains ApiBase instances that will be executed
+ * @param array $generatedModules Names of modules that depend on the generator
+ * @throws ApiUsageException
+ */
+ public function __construct(
+ ApiBase $module, array $allModules = [], array $generatedModules = []
+ ) {
+ $this->source = get_class( $module );
+ $request = $module->getRequest();
+
+ $this->generatedModules = $generatedModules
+ ? array_combine( $generatedModules, $generatedModules )
+ : [];
+
+ $skip = [];
+ $continue = $request->getVal( 'continue', '' );
+ if ( $continue !== '' ) {
+ $continue = explode( '||', $continue );
+ if ( count( $continue ) !== 2 ) {
+ throw ApiUsageException::newWithMessage( $module->getMain(), 'apierror-badcontinue' );
+ }
+ $this->generatorDone = ( $continue[0] === '-' );
+ $skip = explode( '|', $continue[1] );
+ if ( !$this->generatorDone ) {
+ $params = explode( '|', $continue[0] );
+ if ( $params ) {
+ $this->generatorParams = array_intersect_key(
+ $request->getValues(),
+ array_flip( $params )
+ );
+ }
+ } else {
+ // When the generator is complete, don't run any modules that
+ // depend on it.
+ $skip += $this->generatedModules;
+ }
+ }
+
+ foreach ( $allModules as $module ) {
+ $name = $module->getModuleName();
+ if ( in_array( $name, $skip, true ) ) {
+ $this->allModules[$name] = false;
+ // Prevent spurious "unused parameter" warnings
+ $module->extractRequestParams();
+ } else {
+ $this->allModules[$name] = $module;
+ }
+ }
+ }
+
+ /**
+ * Get the class that created this manager
+ * @return string
+ */
+ public function getSource() {
+ return $this->source;
+ }
+
+ /**
+ * Is the generator done?
+ * @return bool
+ */
+ public function isGeneratorDone() {
+ return $this->generatorDone;
+ }
+
+ /**
+ * Get the list of modules that should actually be run
+ * @return ApiBase[]
+ */
+ public function getRunModules() {
+ return array_values( array_filter( $this->allModules ) );
+ }
+
+ /**
+ * Set the continuation parameter for a module
+ * @param ApiBase $module
+ * @param string $paramName
+ * @param string|array $paramValue
+ * @throws UnexpectedValueException
+ */
+ public function addContinueParam( ApiBase $module, $paramName, $paramValue ) {
+ $name = $module->getModuleName();
+ if ( !isset( $this->allModules[$name] ) ) {
+ throw new UnexpectedValueException(
+ "Module '$name' called " . __METHOD__ .
+ ' but was not passed to ' . __CLASS__ . '::__construct'
+ );
+ }
+ if ( !$this->allModules[$name] ) {
+ throw new UnexpectedValueException(
+ "Module '$name' was not supposed to have been executed, but " .
+ 'it was executed anyway'
+ );
+ }
+ $paramName = $module->encodeParamName( $paramName );
+ if ( is_array( $paramValue ) ) {
+ $paramValue = implode( '|', $paramValue );
+ }
+ $this->continuationData[$name][$paramName] = $paramValue;
+ }
+
+ /**
+ * Set the non-continuation parameter for the generator module
+ *
+ * In case the generator isn't going to be continued, this sets the fields
+ * to return.
+ *
+ * @since 1.28
+ * @param ApiBase $module
+ * @param string $paramName
+ * @param string|array $paramValue
+ */
+ public function addGeneratorNonContinueParam( ApiBase $module, $paramName, $paramValue ) {
+ $name = $module->getModuleName();
+ $paramName = $module->encodeParamName( $paramName );
+ if ( is_array( $paramValue ) ) {
+ $paramValue = implode( '|', $paramValue );
+ }
+ $this->generatorNonContinuationData[$name][$paramName] = $paramValue;
+ }
+
+ /**
+ * Set the continuation parameter for the generator module
+ * @param ApiBase $module
+ * @param string $paramName
+ * @param string|array $paramValue
+ */
+ public function addGeneratorContinueParam( ApiBase $module, $paramName, $paramValue ) {
+ $name = $module->getModuleName();
+ $paramName = $module->encodeParamName( $paramName );
+ if ( is_array( $paramValue ) ) {
+ $paramValue = implode( '|', $paramValue );
+ }
+ $this->generatorContinuationData[$name][$paramName] = $paramValue;
+ }
+
+ /**
+ * Fetch raw continuation data
+ * @return array
+ */
+ public function getRawContinuation() {
+ return array_merge_recursive( $this->continuationData, $this->generatorContinuationData );
+ }
+
+ /**
+ * Fetch raw non-continuation data
+ * @since 1.28
+ * @return array
+ */
+ public function getRawNonContinuation() {
+ return $this->generatorNonContinuationData;
+ }
+
+ /**
+ * Fetch continuation result data
+ * @return array [ (array)$data, (bool)$batchcomplete ]
+ */
+ public function getContinuation() {
+ $data = [];
+ $batchcomplete = false;
+
+ $finishedModules = array_diff(
+ array_keys( $this->allModules ),
+ array_keys( $this->continuationData )
+ );
+
+ // First, grab the non-generator-using continuation data
+ $continuationData = array_diff_key( $this->continuationData, $this->generatedModules );
+ foreach ( $continuationData as $module => $kvp ) {
+ $data += $kvp;
+ }
+
+ // Next, handle the generator-using continuation data
+ $continuationData = array_intersect_key( $this->continuationData, $this->generatedModules );
+ if ( $continuationData ) {
+ // Some modules are unfinished: include those params, and copy
+ // the generator params.
+ foreach ( $continuationData as $module => $kvp ) {
+ $data += $kvp;
+ }
+ $generatorParams = [];
+ foreach ( $this->generatorNonContinuationData as $kvp ) {
+ $generatorParams += $kvp;
+ }
+ $generatorParams += $this->generatorParams;
+ $data += $generatorParams;
+ $generatorKeys = implode( '|', array_keys( $generatorParams ) );
+ } elseif ( $this->generatorContinuationData ) {
+ // All the generator-using modules are complete, but the
+ // generator isn't. Continue the generator and restart the
+ // generator-using modules
+ $generatorParams = [];
+ foreach ( $this->generatorContinuationData as $kvp ) {
+ $generatorParams += $kvp;
+ }
+ $data += $generatorParams;
+ $finishedModules = array_diff( $finishedModules, $this->generatedModules );
+ $generatorKeys = implode( '|', array_keys( $generatorParams ) );
+ $batchcomplete = true;
+ } else {
+ // Generator and prop modules are all done. Mark it so.
+ $generatorKeys = '-';
+ $batchcomplete = true;
+ }
+
+ // Set 'continue' if any continuation data is set or if the generator
+ // still needs to run
+ if ( $data || $generatorKeys !== '-' ) {
+ $data['continue'] = $generatorKeys . '||' . implode( '|', $finishedModules );
+ }
+
+ return [ $data, $batchcomplete ];
+ }
+
+ /**
+ * Store the continuation data into the result
+ * @param ApiResult $result
+ */
+ public function setContinuationIntoResult( ApiResult $result ) {
+ list( $data, $batchcomplete ) = $this->getContinuation();
+ if ( $data ) {
+ $result->addValue( null, 'continue', $data,
+ ApiResult::ADD_ON_TOP | ApiResult::NO_SIZE_CHECK );
+ }
+ if ( $batchcomplete ) {
+ $result->addValue( null, 'batchcomplete', true,
+ ApiResult::ADD_ON_TOP | ApiResult::NO_SIZE_CHECK );
+ }
+ }
+}
diff --git a/www/wiki/includes/api/ApiDelete.php b/www/wiki/includes/api/ApiDelete.php
new file mode 100644
index 00000000..7766acd3
--- /dev/null
+++ b/www/wiki/includes/api/ApiDelete.php
@@ -0,0 +1,233 @@
+<?php
+/**
+ *
+ *
+ * Created on Jun 30, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that facilitates deleting pages. The API equivalent of action=delete.
+ * Requires API write mode to be enabled.
+ *
+ * @ingroup API
+ */
+class ApiDelete extends ApiBase {
+ /**
+ * Extracts the title and reason from the request parameters and invokes
+ * the local delete() function with these as arguments. It does not make use of
+ * the delete function specified by Article.php. If the deletion succeeds, the
+ * details of the article deleted and the reason for deletion are added to the
+ * result object.
+ */
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $params = $this->extractRequestParams();
+
+ $pageObj = $this->getTitleOrPageId( $params, 'fromdbmaster' );
+ $titleObj = $pageObj->getTitle();
+ if ( !$pageObj->exists() &&
+ !( $titleObj->getNamespace() == NS_FILE && self::canDeleteFile( $pageObj->getFile() ) )
+ ) {
+ $this->dieWithError( 'apierror-missingtitle' );
+ }
+
+ $reason = $params['reason'];
+ $user = $this->getUser();
+
+ // Check that the user is allowed to carry out the deletion
+ $this->checkTitleUserPermissions( $titleObj, 'delete' );
+
+ // If change tagging was requested, check that the user is allowed to tag,
+ // and the tags are valid
+ if ( count( $params['tags'] ) ) {
+ $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$tagStatus->isOK() ) {
+ $this->dieStatus( $tagStatus );
+ }
+ }
+
+ if ( $titleObj->getNamespace() == NS_FILE ) {
+ $status = self::deleteFile(
+ $pageObj,
+ $user,
+ $params['oldimage'],
+ $reason,
+ false,
+ $params['tags']
+ );
+ } else {
+ $status = self::delete( $pageObj, $user, $reason, $params['tags'] );
+ }
+
+ if ( !$status->isGood() ) {
+ $this->dieStatus( $status );
+ }
+
+ // Deprecated parameters
+ if ( $params['watch'] ) {
+ $watch = 'watch';
+ } elseif ( $params['unwatch'] ) {
+ $watch = 'unwatch';
+ } else {
+ $watch = $params['watchlist'];
+ }
+ $this->setWatch( $watch, $titleObj, 'watchdeletion' );
+
+ $r = [
+ 'title' => $titleObj->getPrefixedText(),
+ 'reason' => $reason,
+ 'logid' => $status->value
+ ];
+ $this->getResult()->addValue( null, $this->getModuleName(), $r );
+ }
+
+ /**
+ * We have our own delete() function, since Article.php's implementation is split in two phases
+ *
+ * @param Page|WikiPage $page Page or WikiPage object to work on
+ * @param User $user User doing the action
+ * @param string|null &$reason Reason for the deletion. Autogenerated if null
+ * @param array $tags Tags to tag the deletion with
+ * @return Status
+ */
+ protected static function delete( Page $page, User $user, &$reason = null, $tags = [] ) {
+ $title = $page->getTitle();
+
+ // Auto-generate a summary, if necessary
+ if ( is_null( $reason ) ) {
+ // Need to pass a throwaway variable because generateReason expects
+ // a reference
+ $hasHistory = false;
+ $reason = $page->getAutoDeleteReason( $hasHistory );
+ if ( $reason === false ) {
+ return Status::newFatal( 'cannotdelete', $title->getPrefixedText() );
+ }
+ }
+
+ $error = '';
+
+ // Luckily, Article.php provides a reusable delete function that does the hard work for us
+ return $page->doDeleteArticleReal( $reason, false, 0, true, $error, $user, $tags );
+ }
+
+ /**
+ * @param File $file
+ * @return bool
+ */
+ protected static function canDeleteFile( File $file ) {
+ return $file->exists() && $file->isLocal() && !$file->getRedirected();
+ }
+
+ /**
+ * @param Page $page Object to work on
+ * @param User $user User doing the action
+ * @param string $oldimage Archive name
+ * @param string &$reason Reason for the deletion. Autogenerated if null.
+ * @param bool $suppress Whether to mark all deleted versions as restricted
+ * @param array $tags Tags to tag the deletion with
+ * @return Status
+ */
+ protected static function deleteFile( Page $page, User $user, $oldimage,
+ &$reason = null, $suppress = false, $tags = []
+ ) {
+ $title = $page->getTitle();
+
+ $file = $page->getFile();
+ if ( !self::canDeleteFile( $file ) ) {
+ return self::delete( $page, $user, $reason, $tags );
+ }
+
+ if ( $oldimage ) {
+ if ( !FileDeleteForm::isValidOldSpec( $oldimage ) ) {
+ return Status::newFatal( 'invalidoldimage' );
+ }
+ $oldfile = RepoGroup::singleton()->getLocalRepo()->newFromArchiveName( $title, $oldimage );
+ if ( !$oldfile->exists() || !$oldfile->isLocal() || $oldfile->getRedirected() ) {
+ return Status::newFatal( 'nodeleteablefile' );
+ }
+ }
+
+ if ( is_null( $reason ) ) { // Log and RC don't like null reasons
+ $reason = '';
+ }
+
+ return FileDeleteForm::doDelete( $title, $file, $oldimage, $reason, $suppress, $user, $tags );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => null,
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'reason' => null,
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'watch' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'watchlist' => [
+ ApiBase::PARAM_DFLT => 'preferences',
+ ApiBase::PARAM_TYPE => [
+ 'watch',
+ 'unwatch',
+ 'preferences',
+ 'nochange'
+ ],
+ ],
+ 'unwatch' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'oldimage' => null,
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=delete&title=Main%20Page&token=123ABC'
+ => 'apihelp-delete-example-simple',
+ 'action=delete&title=Main%20Page&token=123ABC&reason=Preparing%20for%20move'
+ => 'apihelp-delete-example-reason',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Delete';
+ }
+}
diff --git a/www/wiki/includes/api/ApiDisabled.php b/www/wiki/includes/api/ApiDisabled.php
new file mode 100644
index 00000000..684c4254
--- /dev/null
+++ b/www/wiki/includes/api/ApiDisabled.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 25, 2008
+ *
+ * Copyright © 2008 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that dies with an error immediately.
+ *
+ * Use this to disable core modules with
+ * $wgAPIModules['modulename'] = 'ApiDisabled';
+ *
+ * To disable submodules of action=query, use ApiQueryDisabled instead
+ *
+ * @ingroup API
+ */
+class ApiDisabled extends ApiBase {
+
+ public function execute() {
+ $this->dieWithError( [ 'apierror-moduledisabled', $this->getModuleName() ] );
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ protected function getDescriptionMessage() {
+ return 'apihelp-disabled-summary';
+ }
+
+ protected function getSummaryMessage() {
+ return 'apihelp-disabled-summary';
+ }
+
+ protected function getExtendedDescription() {
+ return 'apihelp-disabled-extended-description';
+ }
+}
diff --git a/www/wiki/includes/api/ApiEditPage.php b/www/wiki/includes/api/ApiEditPage.php
new file mode 100644
index 00000000..94d6e97b
--- /dev/null
+++ b/www/wiki/includes/api/ApiEditPage.php
@@ -0,0 +1,616 @@
+<?php
+/**
+ *
+ *
+ * Created on August 16, 2007
+ *
+ * Copyright © 2007 Iker Labarga "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A module that allows for editing and creating pages.
+ *
+ * Currently, this wraps around the EditPage class in an ugly way,
+ * EditPage.php should be rewritten to provide a cleaner interface,
+ * see T20654 if you're inspired to fix this.
+ *
+ * @ingroup API
+ */
+class ApiEditPage extends ApiBase {
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $user = $this->getUser();
+ $params = $this->extractRequestParams();
+
+ $this->requireAtLeastOneParameter( $params, 'text', 'appendtext', 'prependtext', 'undo' );
+
+ $pageObj = $this->getTitleOrPageId( $params );
+ $titleObj = $pageObj->getTitle();
+ $apiResult = $this->getResult();
+
+ if ( $params['redirect'] ) {
+ if ( $params['prependtext'] === null && $params['appendtext'] === null
+ && $params['section'] !== 'new'
+ ) {
+ $this->dieWithError( 'apierror-redirect-appendonly' );
+ }
+ if ( $titleObj->isRedirect() ) {
+ $oldTitle = $titleObj;
+
+ $titles = Revision::newFromTitle( $oldTitle, false, Revision::READ_LATEST )
+ ->getContent( Revision::FOR_THIS_USER, $user )
+ ->getRedirectChain();
+ // array_shift( $titles );
+
+ $redirValues = [];
+
+ /** @var Title $newTitle */
+ foreach ( $titles as $id => $newTitle ) {
+ if ( !isset( $titles[$id - 1] ) ) {
+ $titles[$id - 1] = $oldTitle;
+ }
+
+ $redirValues[] = [
+ 'from' => $titles[$id - 1]->getPrefixedText(),
+ 'to' => $newTitle->getPrefixedText()
+ ];
+
+ $titleObj = $newTitle;
+ }
+
+ ApiResult::setIndexedTagName( $redirValues, 'r' );
+ $apiResult->addValue( null, 'redirects', $redirValues );
+
+ // Since the page changed, update $pageObj
+ $pageObj = WikiPage::factory( $titleObj );
+ }
+ }
+
+ if ( !isset( $params['contentmodel'] ) || $params['contentmodel'] == '' ) {
+ $contentHandler = $pageObj->getContentHandler();
+ } else {
+ $contentHandler = ContentHandler::getForModelID( $params['contentmodel'] );
+ }
+ $contentModel = $contentHandler->getModelID();
+
+ $name = $titleObj->getPrefixedDBkey();
+ $model = $contentHandler->getModelID();
+
+ if ( $params['undo'] > 0 ) {
+ // allow undo via api
+ } elseif ( $contentHandler->supportsDirectApiEditing() === false ) {
+ $this->dieWithError( [ 'apierror-no-direct-editing', $model, $name ] );
+ }
+
+ if ( !isset( $params['contentformat'] ) || $params['contentformat'] == '' ) {
+ $contentFormat = $contentHandler->getDefaultFormat();
+ } else {
+ $contentFormat = $params['contentformat'];
+ }
+
+ if ( !$contentHandler->isSupportedFormat( $contentFormat ) ) {
+ $this->dieWithError( [ 'apierror-badformat', $contentFormat, $model, $name ] );
+ }
+
+ if ( $params['createonly'] && $titleObj->exists() ) {
+ $this->dieWithError( 'apierror-articleexists' );
+ }
+ if ( $params['nocreate'] && !$titleObj->exists() ) {
+ $this->dieWithError( 'apierror-missingtitle' );
+ }
+
+ // Now let's check whether we're even allowed to do this
+ $this->checkTitleUserPermissions(
+ $titleObj,
+ $titleObj->exists() ? 'edit' : [ 'edit', 'create' ]
+ );
+
+ $toMD5 = $params['text'];
+ if ( !is_null( $params['appendtext'] ) || !is_null( $params['prependtext'] ) ) {
+ $content = $pageObj->getContent();
+
+ if ( !$content ) {
+ if ( $titleObj->getNamespace() == NS_MEDIAWIKI ) {
+ # If this is a MediaWiki:x message, then load the messages
+ # and return the message value for x.
+ $text = $titleObj->getDefaultMessageText();
+ if ( $text === false ) {
+ $text = '';
+ }
+
+ try {
+ $content = ContentHandler::makeContent( $text, $this->getTitle() );
+ } catch ( MWContentSerializationException $ex ) {
+ $this->dieWithException( $ex, [
+ 'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
+ ] );
+ return;
+ }
+ } else {
+ # Otherwise, make a new empty content.
+ $content = $contentHandler->makeEmptyContent();
+ }
+ }
+
+ // @todo Add support for appending/prepending to the Content interface
+
+ if ( !( $content instanceof TextContent ) ) {
+ $modelName = $contentHandler->getModelID();
+ $this->dieWithError( [ 'apierror-appendnotsupported', $modelName ] );
+ }
+
+ if ( !is_null( $params['section'] ) ) {
+ if ( !$contentHandler->supportsSections() ) {
+ $modelName = $contentHandler->getModelID();
+ $this->dieWithError( [ 'apierror-sectionsnotsupported', $modelName ] );
+ }
+
+ if ( $params['section'] == 'new' ) {
+ // DWIM if they're trying to prepend/append to a new section.
+ $content = null;
+ } else {
+ // Process the content for section edits
+ $section = $params['section'];
+ $content = $content->getSection( $section );
+
+ if ( !$content ) {
+ $this->dieWithError( [ 'apierror-nosuchsection', wfEscapeWikiText( $section ) ] );
+ }
+ }
+ }
+
+ if ( !$content ) {
+ $text = '';
+ } else {
+ $text = $content->serialize( $contentFormat );
+ }
+
+ $params['text'] = $params['prependtext'] . $text . $params['appendtext'];
+ $toMD5 = $params['prependtext'] . $params['appendtext'];
+ }
+
+ if ( $params['undo'] > 0 ) {
+ if ( $params['undoafter'] > 0 ) {
+ if ( $params['undo'] < $params['undoafter'] ) {
+ list( $params['undo'], $params['undoafter'] ) =
+ [ $params['undoafter'], $params['undo'] ];
+ }
+ $undoafterRev = Revision::newFromId( $params['undoafter'] );
+ }
+ $undoRev = Revision::newFromId( $params['undo'] );
+ if ( is_null( $undoRev ) || $undoRev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $params['undo'] ] );
+ }
+
+ if ( $params['undoafter'] == 0 ) {
+ $undoafterRev = $undoRev->getPrevious();
+ }
+ if ( is_null( $undoafterRev ) || $undoafterRev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $params['undoafter'] ] );
+ }
+
+ if ( $undoRev->getPage() != $pageObj->getId() ) {
+ $this->dieWithError( [ 'apierror-revwrongpage', $undoRev->getId(),
+ $titleObj->getPrefixedText() ] );
+ }
+ if ( $undoafterRev->getPage() != $pageObj->getId() ) {
+ $this->dieWithError( [ 'apierror-revwrongpage', $undoafterRev->getId(),
+ $titleObj->getPrefixedText() ] );
+ }
+
+ $newContent = $contentHandler->getUndoContent(
+ $pageObj->getRevision(),
+ $undoRev,
+ $undoafterRev
+ );
+
+ if ( !$newContent ) {
+ $this->dieWithError( 'undo-failure', 'undofailure' );
+ }
+ if ( empty( $params['contentmodel'] )
+ && empty( $params['contentformat'] )
+ ) {
+ // If we are reverting content model, the new content model
+ // might not support the current serialization format, in
+ // which case go back to the old serialization format,
+ // but only if the user hasn't specified a format/model
+ // parameter.
+ if ( !$newContent->isSupportedFormat( $contentFormat ) ) {
+ $contentFormat = $undoafterRev->getContentFormat();
+ }
+ // Override content model with model of undid revision.
+ $contentModel = $newContent->getModel();
+ }
+ $params['text'] = $newContent->serialize( $contentFormat );
+ // If no summary was given and we only undid one rev,
+ // use an autosummary
+ if ( is_null( $params['summary'] ) &&
+ $titleObj->getNextRevisionID( $undoafterRev->getId() ) == $params['undo']
+ ) {
+ $params['summary'] = wfMessage( 'undo-summary' )
+ ->params( $params['undo'], $undoRev->getUserText() )->inContentLanguage()->text();
+ }
+ }
+
+ // See if the MD5 hash checks out
+ if ( !is_null( $params['md5'] ) && md5( $toMD5 ) !== $params['md5'] ) {
+ $this->dieWithError( 'apierror-badmd5' );
+ }
+
+ // EditPage wants to parse its stuff from a WebRequest
+ // That interface kind of sucks, but it's workable
+ $requestArray = [
+ 'wpTextbox1' => $params['text'],
+ 'format' => $contentFormat,
+ 'model' => $contentModel,
+ 'wpEditToken' => $params['token'],
+ 'wpIgnoreBlankSummary' => true,
+ 'wpIgnoreBlankArticle' => true,
+ 'wpIgnoreSelfRedirect' => true,
+ 'bot' => $params['bot'],
+ 'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
+ ];
+
+ if ( !is_null( $params['summary'] ) ) {
+ $requestArray['wpSummary'] = $params['summary'];
+ }
+
+ if ( !is_null( $params['sectiontitle'] ) ) {
+ $requestArray['wpSectionTitle'] = $params['sectiontitle'];
+ }
+
+ // TODO: Pass along information from 'undoafter' as well
+ if ( $params['undo'] > 0 ) {
+ $requestArray['wpUndidRevision'] = $params['undo'];
+ }
+
+ // Watch out for basetimestamp == '' or '0'
+ // It gets treated as NOW, almost certainly causing an edit conflict
+ if ( $params['basetimestamp'] !== null && (bool)$this->getMain()->getVal( 'basetimestamp' ) ) {
+ $requestArray['wpEdittime'] = $params['basetimestamp'];
+ } else {
+ $requestArray['wpEdittime'] = $pageObj->getTimestamp();
+ }
+
+ if ( $params['starttimestamp'] !== null ) {
+ $requestArray['wpStarttime'] = $params['starttimestamp'];
+ } else {
+ $requestArray['wpStarttime'] = wfTimestampNow(); // Fake wpStartime
+ }
+
+ if ( $params['minor'] || ( !$params['notminor'] && $user->getOption( 'minordefault' ) ) ) {
+ $requestArray['wpMinoredit'] = '';
+ }
+
+ if ( $params['recreate'] ) {
+ $requestArray['wpRecreate'] = '';
+ }
+
+ if ( !is_null( $params['section'] ) ) {
+ $section = $params['section'];
+ if ( !preg_match( '/^((T-)?\d+|new)$/', $section ) ) {
+ $this->dieWithError( 'apierror-invalidsection' );
+ }
+ $content = $pageObj->getContent();
+ if ( $section !== '0' && $section != 'new'
+ && ( !$content || !$content->getSection( $section ) )
+ ) {
+ $this->dieWithError( [ 'apierror-nosuchsection', $section ] );
+ }
+ $requestArray['wpSection'] = $params['section'];
+ } else {
+ $requestArray['wpSection'] = '';
+ }
+
+ $watch = $this->getWatchlistValue( $params['watchlist'], $titleObj );
+
+ // Deprecated parameters
+ if ( $params['watch'] ) {
+ $watch = true;
+ } elseif ( $params['unwatch'] ) {
+ $watch = false;
+ }
+
+ if ( $watch ) {
+ $requestArray['wpWatchthis'] = '';
+ }
+
+ // Apply change tags
+ if ( count( $params['tags'] ) ) {
+ $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( $tagStatus->isOK() ) {
+ $requestArray['wpChangeTags'] = implode( ',', $params['tags'] );
+ } else {
+ $this->dieStatus( $tagStatus );
+ }
+ }
+
+ // Pass through anything else we might have been given, to support extensions
+ // This is kind of a hack but it's the best we can do to make extensions work
+ $requestArray += $this->getRequest()->getValues();
+
+ global $wgTitle, $wgRequest;
+
+ $req = new DerivativeRequest( $this->getRequest(), $requestArray, true );
+
+ // Some functions depend on $wgTitle == $ep->mTitle
+ // TODO: Make them not or check if they still do
+ $wgTitle = $titleObj;
+
+ $articleContext = new RequestContext;
+ $articleContext->setRequest( $req );
+ $articleContext->setWikiPage( $pageObj );
+ $articleContext->setUser( $this->getUser() );
+
+ /** @var Article $articleObject */
+ $articleObject = Article::newFromWikiPage( $pageObj, $articleContext );
+
+ $ep = new EditPage( $articleObject );
+
+ $ep->setApiEditOverride( true );
+ $ep->setContextTitle( $titleObj );
+ $ep->importFormData( $req );
+ $content = $ep->textbox1;
+
+ // Run hooks
+ // Handle APIEditBeforeSave parameters
+ $r = [];
+ // Deprecated in favour of EditFilterMergedContent
+ if ( !Hooks::run( 'APIEditBeforeSave', [ $ep, $content, &$r ], '1.28' ) ) {
+ if ( count( $r ) ) {
+ $r['result'] = 'Failure';
+ $apiResult->addValue( null, $this->getModuleName(), $r );
+
+ return;
+ }
+
+ $this->dieWithError( 'hookaborted' );
+ }
+
+ // Do the actual save
+ $oldRevId = $articleObject->getRevIdFetched();
+ $result = null;
+ // Fake $wgRequest for some hooks inside EditPage
+ // @todo FIXME: This interface SUCKS
+ $oldRequest = $wgRequest;
+ $wgRequest = $req;
+
+ $status = $ep->attemptSave( $result );
+ $wgRequest = $oldRequest;
+
+ switch ( $status->value ) {
+ case EditPage::AS_HOOK_ERROR:
+ case EditPage::AS_HOOK_ERROR_EXPECTED:
+ if ( isset( $status->apiHookResult ) ) {
+ $r = $status->apiHookResult;
+ $r['result'] = 'Failure';
+ $apiResult->addValue( null, $this->getModuleName(), $r );
+ return;
+ }
+ if ( !$status->getErrors() ) {
+ $status->fatal( 'hookaborted' );
+ }
+ $this->dieStatus( $status );
+
+ case EditPage::AS_BLOCKED_PAGE_FOR_USER:
+ $this->dieWithError(
+ 'apierror-blocked',
+ 'blocked',
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
+ );
+
+ case EditPage::AS_READ_ONLY_PAGE:
+ $this->dieReadOnly();
+
+ case EditPage::AS_SUCCESS_NEW_ARTICLE:
+ $r['new'] = true;
+ // fall-through
+
+ case EditPage::AS_SUCCESS_UPDATE:
+ $r['result'] = 'Success';
+ $r['pageid'] = intval( $titleObj->getArticleID() );
+ $r['title'] = $titleObj->getPrefixedText();
+ $r['contentmodel'] = $articleObject->getContentModel();
+ $newRevId = $articleObject->getLatest();
+ if ( $newRevId == $oldRevId ) {
+ $r['nochange'] = true;
+ } else {
+ $r['oldrevid'] = intval( $oldRevId );
+ $r['newrevid'] = intval( $newRevId );
+ $r['newtimestamp'] = wfTimestamp( TS_ISO_8601,
+ $pageObj->getTimestamp() );
+ }
+ break;
+
+ default:
+ if ( !$status->getErrors() ) {
+ // EditPage sometimes only sets the status code without setting
+ // any actual error messages. Supply defaults for those cases.
+ switch ( $status->value ) {
+ // Currently needed
+ case EditPage::AS_IMAGE_REDIRECT_ANON:
+ $status->fatal( 'apierror-noimageredirect-anon' );
+ break;
+ case EditPage::AS_IMAGE_REDIRECT_LOGGED:
+ $status->fatal( 'apierror-noimageredirect-logged' );
+ break;
+ case EditPage::AS_CONTENT_TOO_BIG:
+ case EditPage::AS_MAX_ARTICLE_SIZE_EXCEEDED:
+ $status->fatal( 'apierror-contenttoobig', $this->getConfig()->get( 'MaxArticleSize' ) );
+ break;
+ case EditPage::AS_READ_ONLY_PAGE_ANON:
+ $status->fatal( 'apierror-noedit-anon' );
+ break;
+ case EditPage::AS_NO_CHANGE_CONTENT_MODEL:
+ $status->fatal( 'apierror-cantchangecontentmodel' );
+ break;
+ case EditPage::AS_ARTICLE_WAS_DELETED:
+ $status->fatal( 'apierror-pagedeleted' );
+ break;
+ case EditPage::AS_CONFLICT_DETECTED:
+ $status->fatal( 'editconflict' );
+ break;
+
+ // Currently shouldn't be needed, but here in case
+ // hooks use them without setting appropriate
+ // errors on the status.
+ case EditPage::AS_SPAM_ERROR:
+ $status->fatal( 'apierror-spamdetected', $result['spam'] );
+ break;
+ case EditPage::AS_READ_ONLY_PAGE_LOGGED:
+ $status->fatal( 'apierror-noedit' );
+ break;
+ case EditPage::AS_RATE_LIMITED:
+ $status->fatal( 'apierror-ratelimited' );
+ break;
+ case EditPage::AS_NO_CREATE_PERMISSION:
+ $status->fatal( 'nocreate-loggedin' );
+ break;
+ case EditPage::AS_BLANK_ARTICLE:
+ $status->fatal( 'apierror-emptypage' );
+ break;
+ case EditPage::AS_TEXTBOX_EMPTY:
+ $status->fatal( 'apierror-emptynewsection' );
+ break;
+ case EditPage::AS_SUMMARY_NEEDED:
+ $status->fatal( 'apierror-summaryrequired' );
+ break;
+ default:
+ wfWarn( __METHOD__ . ": Unknown EditPage code {$status->value} with no message" );
+ $status->fatal( 'apierror-unknownerror-editpage', $status->value );
+ break;
+ }
+ }
+ $this->dieStatus( $status );
+ break;
+ }
+ $apiResult->addValue( null, $this->getModuleName(), $r );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'section' => null,
+ 'sectiontitle' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'text' => [
+ ApiBase::PARAM_TYPE => 'text',
+ ],
+ 'summary' => null,
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'minor' => false,
+ 'notminor' => false,
+ 'bot' => false,
+ 'basetimestamp' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+ 'starttimestamp' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+ 'recreate' => false,
+ 'createonly' => false,
+ 'nocreate' => false,
+ 'watch' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'unwatch' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'watchlist' => [
+ ApiBase::PARAM_DFLT => 'preferences',
+ ApiBase::PARAM_TYPE => [
+ 'watch',
+ 'unwatch',
+ 'preferences',
+ 'nochange'
+ ],
+ ],
+ 'md5' => null,
+ 'prependtext' => [
+ ApiBase::PARAM_TYPE => 'text',
+ ],
+ 'appendtext' => [
+ ApiBase::PARAM_TYPE => 'text',
+ ],
+ 'undo' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'undoafter' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'redirect' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ],
+ 'contentformat' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
+ ],
+ 'contentmodel' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
+ ],
+ 'token' => [
+ // Standard definition automatically inserted
+ ApiBase::PARAM_HELP_MSG_APPEND => [ 'apihelp-edit-param-token' ],
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=edit&title=Test&summary=test%20summary&' .
+ 'text=article%20content&basetimestamp=2007-08-24T12:34:54Z&token=123ABC'
+ => 'apihelp-edit-example-edit',
+ 'action=edit&title=Test&summary=NOTOC&minor=&' .
+ 'prependtext=__NOTOC__%0A&basetimestamp=2007-08-24T12:34:54Z&token=123ABC'
+ => 'apihelp-edit-example-prepend',
+ 'action=edit&title=Test&undo=13585&undoafter=13579&' .
+ 'basetimestamp=2007-08-24T12:34:54Z&token=123ABC'
+ => 'apihelp-edit-example-undo',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Edit';
+ }
+}
diff --git a/www/wiki/includes/api/ApiEmailUser.php b/www/wiki/includes/api/ApiEmailUser.php
new file mode 100644
index 00000000..edea2661
--- /dev/null
+++ b/www/wiki/includes/api/ApiEmailUser.php
@@ -0,0 +1,119 @@
+<?php
+/**
+ *
+ *
+ * Created on June 1, 2008
+ *
+ * Copyright © 2008 Bryan Tong Minh <Bryan.TongMinh@Gmail.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API Module to facilitate sending of emails to users
+ * @ingroup API
+ */
+class ApiEmailUser extends ApiBase {
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ // Validate target
+ $targetUser = SpecialEmailUser::getTarget( $params['target'], $this->getUser() );
+ if ( !( $targetUser instanceof User ) ) {
+ switch ( $targetUser ) {
+ case 'notarget':
+ $this->dieWithError( 'apierror-notarget' );
+ case 'noemail':
+ $this->dieWithError( [ 'noemail', $params['target'] ] );
+ case 'nowikiemail':
+ $this->dieWithError( 'nowikiemailtext', 'nowikiemail' );
+ default:
+ $this->dieWithError( [ 'apierror-unknownerror', $targetUser ] );
+ }
+ }
+
+ // Check permissions and errors
+ $error = SpecialEmailUser::getPermissionsError(
+ $this->getUser(),
+ $params['token'],
+ $this->getConfig()
+ );
+ if ( $error ) {
+ $this->dieWithError( $error );
+ }
+
+ $data = [
+ 'Target' => $targetUser->getName(),
+ 'Text' => $params['text'],
+ 'Subject' => $params['subject'],
+ 'CCMe' => $params['ccme'],
+ ];
+ $retval = SpecialEmailUser::submit( $data, $this->getContext() );
+ if ( !$retval instanceof Status ) {
+ // This is probably the reason
+ $retval = Status::newFatal( 'hookaborted' );
+ }
+
+ $result = array_filter( [
+ 'result' => $retval->isGood() ? 'Success' : ( $retval->isOk() ? 'Warnings' : 'Failure' ),
+ 'warnings' => $this->getErrorFormatter()->arrayFromStatus( $retval, 'warning' ),
+ 'errors' => $this->getErrorFormatter()->arrayFromStatus( $retval, 'error' ),
+ ] );
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $result );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'target' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'subject' => null,
+ 'text' => [
+ ApiBase::PARAM_TYPE => 'text',
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'ccme' => false,
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=emailuser&target=WikiSysop&text=Content&token=123ABC'
+ => 'apihelp-emailuser-example-email',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Email';
+ }
+}
diff --git a/www/wiki/includes/api/ApiErrorFormatter.php b/www/wiki/includes/api/ApiErrorFormatter.php
new file mode 100644
index 00000000..7fb13525
--- /dev/null
+++ b/www/wiki/includes/api/ApiErrorFormatter.php
@@ -0,0 +1,458 @@
+<?php
+/**
+ * This file contains the ApiErrorFormatter definition, plus implementations of
+ * specific formatters.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Formats errors and warnings for the API, and add them to the associated
+ * ApiResult.
+ * @since 1.25
+ * @ingroup API
+ */
+class ApiErrorFormatter {
+ /** @var Title Dummy title to silence warnings from MessageCache::parse() */
+ private static $dummyTitle = null;
+
+ /** @var ApiResult */
+ protected $result;
+
+ /** @var Language */
+ protected $lang;
+ protected $useDB = false;
+ protected $format = 'none';
+
+ /**
+ * @param ApiResult $result Into which data will be added
+ * @param Language $lang Used for i18n
+ * @param string $format
+ * - plaintext: Error message as something vaguely like plaintext
+ * (it's basically wikitext with HTML tags stripped and entities decoded)
+ * - wikitext: Error message as wikitext
+ * - html: Error message as HTML
+ * - raw: Raw message key and parameters, no human-readable text
+ * - none: Code and data only, no human-readable text
+ * @param bool $useDB Whether to use local translations for errors and warnings.
+ */
+ public function __construct( ApiResult $result, Language $lang, $format, $useDB = false ) {
+ $this->result = $result;
+ $this->lang = $lang;
+ $this->useDB = $useDB;
+ $this->format = $format;
+ }
+
+ /**
+ * Fetch the Language for this formatter
+ * @since 1.29
+ * @return Language
+ */
+ public function getLanguage() {
+ return $this->lang;
+ }
+
+ /**
+ * Fetch a dummy title to set on Messages
+ * @return Title
+ */
+ protected function getDummyTitle() {
+ if ( self::$dummyTitle === null ) {
+ self::$dummyTitle = Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __METHOD__ );
+ }
+ return self::$dummyTitle;
+ }
+
+ /**
+ * Add a warning to the result
+ * @param string|null $modulePath
+ * @param Message|array|string $msg Warning message. See ApiMessage::create().
+ * @param string|null $code See ApiMessage::create().
+ * @param array|null $data See ApiMessage::create().
+ */
+ public function addWarning( $modulePath, $msg, $code = null, $data = null ) {
+ $msg = ApiMessage::create( $msg, $code, $data )
+ ->inLanguage( $this->lang )
+ ->title( $this->getDummyTitle() )
+ ->useDatabase( $this->useDB );
+ $this->addWarningOrError( 'warning', $modulePath, $msg );
+ }
+
+ /**
+ * Add an error to the result
+ * @param string|null $modulePath
+ * @param Message|array|string $msg Warning message. See ApiMessage::create().
+ * @param string|null $code See ApiMessage::create().
+ * @param array|null $data See ApiMessage::create().
+ */
+ public function addError( $modulePath, $msg, $code = null, $data = null ) {
+ $msg = ApiMessage::create( $msg, $code, $data )
+ ->inLanguage( $this->lang )
+ ->title( $this->getDummyTitle() )
+ ->useDatabase( $this->useDB );
+ $this->addWarningOrError( 'error', $modulePath, $msg );
+ }
+
+ /**
+ * Add warnings and errors from a StatusValue object to the result
+ * @param string|null $modulePath
+ * @param StatusValue $status
+ * @param string[] $types 'warning' and/or 'error'
+ */
+ public function addMessagesFromStatus(
+ $modulePath, StatusValue $status, $types = [ 'warning', 'error' ]
+ ) {
+ if ( $status->isGood() || !$status->getErrors() ) {
+ return;
+ }
+
+ $types = (array)$types;
+ foreach ( $status->getErrors() as $error ) {
+ if ( !in_array( $error['type'], $types, true ) ) {
+ continue;
+ }
+
+ if ( $error['type'] === 'error' ) {
+ $tag = 'error';
+ } else {
+ // Assume any unknown type is a warning
+ $tag = 'warning';
+ }
+
+ $msg = ApiMessage::create( $error )
+ ->inLanguage( $this->lang )
+ ->title( $this->getDummyTitle() )
+ ->useDatabase( $this->useDB );
+ $this->addWarningOrError( $tag, $modulePath, $msg );
+ }
+ }
+
+ /**
+ * Get an ApiMessage from an exception
+ * @since 1.29
+ * @param Exception|Throwable $exception
+ * @param array $options
+ * - wrap: (string|array|MessageSpecifier) Used to wrap the exception's
+ * message if it's not an ILocalizedException. The exception's message
+ * will be added as the final parameter.
+ * - code: (string) Default code
+ * - data: (array) Default extra data
+ * @return IApiMessage
+ */
+ public function getMessageFromException( $exception, array $options = [] ) {
+ $options += [ 'code' => null, 'data' => [] ];
+
+ if ( $exception instanceof ILocalizedException ) {
+ $msg = $exception->getMessageObject();
+ $params = [];
+ } else {
+ // Extract code and data from the exception, if applicable
+ if ( $exception instanceof UsageException ) {
+ $data = $exception->getMessageArray();
+ if ( !$options['code'] ) {
+ $options['code'] = $data['code'];
+ }
+ unset( $data['code'], $data['info'] );
+ $options['data'] = array_merge( $data, $options['data'] );
+ }
+
+ if ( isset( $options['wrap'] ) ) {
+ $msg = $options['wrap'];
+ } else {
+ $msg = new RawMessage( '$1' );
+ if ( !isset( $options['code'] ) ) {
+ $class = preg_replace( '#^Wikimedia\\\Rdbms\\\#', '', get_class( $exception ) );
+ $options['code'] = 'internal_api_error_' . $class;
+ }
+ }
+ $params = [ wfEscapeWikiText( $exception->getMessage() ) ];
+ }
+ return ApiMessage::create( $msg, $options['code'], $options['data'] )
+ ->params( $params )
+ ->inLanguage( $this->lang )
+ ->title( $this->getDummyTitle() )
+ ->useDatabase( $this->useDB );
+ }
+
+ /**
+ * Format an exception as an array
+ * @since 1.29
+ * @param Exception|Throwable $exception
+ * @param array $options See self::getMessageFromException(), plus
+ * - format: (string) Format override
+ * @return array
+ */
+ public function formatException( $exception, array $options = [] ) {
+ return $this->formatMessage(
+ $this->getMessageFromException( $exception, $options ),
+ isset( $options['format'] ) ? $options['format'] : null
+ );
+ }
+
+ /**
+ * Format a message as an array
+ * @param Message|array|string $msg Message. See ApiMessage::create().
+ * @param string|null $format
+ * @return array
+ */
+ public function formatMessage( $msg, $format = null ) {
+ $msg = ApiMessage::create( $msg )
+ ->inLanguage( $this->lang )
+ ->title( $this->getDummyTitle() )
+ ->useDatabase( $this->useDB );
+ return $this->formatMessageInternal( $msg, $format ?: $this->format );
+ }
+
+ /**
+ * Format messages from a StatusValue as an array
+ * @param StatusValue $status
+ * @param string $type 'warning' or 'error'
+ * @param string|null $format
+ * @return array
+ */
+ public function arrayFromStatus( StatusValue $status, $type = 'error', $format = null ) {
+ if ( $status->isGood() || !$status->getErrors() ) {
+ return [];
+ }
+
+ $result = new ApiResult( 1e6 );
+ $formatter = new ApiErrorFormatter(
+ $result, $this->lang, $format ?: $this->format, $this->useDB
+ );
+ $formatter->addMessagesFromStatus( null, $status, [ $type ] );
+ switch ( $type ) {
+ case 'error':
+ return (array)$result->getResultData( [ 'errors' ] );
+ case 'warning':
+ return (array)$result->getResultData( [ 'warnings' ] );
+ }
+ }
+
+ /**
+ * Turn wikitext into something resembling plaintext
+ * @since 1.29
+ * @param string $text
+ * @return string
+ */
+ public static function stripMarkup( $text ) {
+ // Turn semantic quoting tags to quotes
+ $ret = preg_replace( '!</?(var|kbd|samp|code)>!', '"', $text );
+
+ // Strip tags and decode.
+ $ret = Sanitizer::stripAllTags( $ret );
+
+ return $ret;
+ }
+
+ /**
+ * Format a Message object for raw format
+ * @param MessageSpecifier $msg
+ * @return array
+ */
+ private function formatRawMessage( MessageSpecifier $msg ) {
+ $ret = [
+ 'key' => $msg->getKey(),
+ 'params' => $msg->getParams(),
+ ];
+ ApiResult::setIndexedTagName( $ret['params'], 'param' );
+
+ // Transform Messages as parameters in the style of Message::fooParam().
+ foreach ( $ret['params'] as $i => $param ) {
+ if ( $param instanceof MessageSpecifier ) {
+ $ret['params'][$i] = [ 'message' => $this->formatRawMessage( $param ) ];
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Format a message as an array
+ * @since 1.29
+ * @param ApiMessage|ApiRawMessage $msg
+ * @param string|null $format
+ * @return array
+ */
+ protected function formatMessageInternal( $msg, $format ) {
+ $value = [ 'code' => $msg->getApiCode() ];
+ switch ( $format ) {
+ case 'plaintext':
+ $value += [
+ 'text' => self::stripMarkup( $msg->text() ),
+ ApiResult::META_CONTENT => 'text',
+ ];
+ break;
+
+ case 'wikitext':
+ $value += [
+ 'text' => $msg->text(),
+ ApiResult::META_CONTENT => 'text',
+ ];
+ break;
+
+ case 'html':
+ $value += [
+ 'html' => $msg->parse(),
+ ApiResult::META_CONTENT => 'html',
+ ];
+ break;
+
+ case 'raw':
+ $value += $this->formatRawMessage( $msg );
+ break;
+
+ case 'none':
+ break;
+ }
+ $data = $msg->getApiData();
+ if ( $data ) {
+ $value['data'] = $msg->getApiData() + [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ }
+ return $value;
+ }
+
+ /**
+ * Actually add the warning or error to the result
+ * @param string $tag 'warning' or 'error'
+ * @param string|null $modulePath
+ * @param ApiMessage|ApiRawMessage $msg
+ */
+ protected function addWarningOrError( $tag, $modulePath, $msg ) {
+ $value = $this->formatMessageInternal( $msg, $this->format );
+ if ( $modulePath !== null ) {
+ $value += [ 'module' => $modulePath ];
+ }
+
+ $path = [ $tag . 's' ];
+ $existing = $this->result->getResultData( $path );
+ if ( $existing === null || !in_array( $value, $existing ) ) {
+ $flags = ApiResult::NO_SIZE_CHECK;
+ if ( $existing === null ) {
+ $flags |= ApiResult::ADD_ON_TOP;
+ }
+ $this->result->addValue( $path, null, $value, $flags );
+ $this->result->addIndexedTagName( $path, $tag );
+ }
+ }
+}
+
+/**
+ * Format errors and warnings in the old style, for backwards compatibility.
+ * @since 1.25
+ * @deprecated Only for backwards compatibility, do not use
+ * @ingroup API
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class ApiErrorFormatter_BackCompat extends ApiErrorFormatter {
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * @param ApiResult $result Into which data will be added
+ */
+ public function __construct( ApiResult $result ) {
+ parent::__construct( $result, Language::factory( 'en' ), 'none', false );
+ }
+
+ public function arrayFromStatus( StatusValue $status, $type = 'error', $format = null ) {
+ if ( $status->isGood() || !$status->getErrors() ) {
+ return [];
+ }
+
+ $result = [];
+ foreach ( $status->getErrorsByType( $type ) as $error ) {
+ $msg = ApiMessage::create( $error );
+ $error = [
+ 'message' => $msg->getKey(),
+ 'params' => $msg->getParams(),
+ 'code' => $msg->getApiCode(),
+ ] + $error;
+ ApiResult::setIndexedTagName( $error['params'], 'param' );
+ $result[] = $error;
+ }
+ ApiResult::setIndexedTagName( $result, $type );
+
+ return $result;
+ }
+
+ protected function formatMessageInternal( $msg, $format ) {
+ return [
+ 'code' => $msg->getApiCode(),
+ 'info' => $msg->text(),
+ ] + $msg->getApiData();
+ }
+
+ /**
+ * Format an exception as an array
+ * @since 1.29
+ * @param Exception|Throwable $exception
+ * @param array $options See parent::formatException(), plus
+ * - bc: (bool) Return only the string, not an array
+ * @return array|string
+ */
+ public function formatException( $exception, array $options = [] ) {
+ $ret = parent::formatException( $exception, $options );
+ return empty( $options['bc'] ) ? $ret : $ret['info'];
+ }
+
+ protected function addWarningOrError( $tag, $modulePath, $msg ) {
+ $value = self::stripMarkup( $msg->text() );
+
+ if ( $tag === 'error' ) {
+ // In BC mode, only one error
+ $existingError = $this->result->getResultData( [ 'error' ] );
+ if ( !is_array( $existingError ) ||
+ !isset( $existingError['code'] ) || !isset( $existingError['info'] )
+ ) {
+ $value = [
+ 'code' => $msg->getApiCode(),
+ 'info' => $value,
+ ] + $msg->getApiData();
+ $this->result->addValue( null, 'error', $value,
+ ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP | ApiResult::NO_SIZE_CHECK );
+ }
+ } else {
+ if ( $modulePath === null ) {
+ $moduleName = 'unknown';
+ } else {
+ $i = strrpos( $modulePath, '+' );
+ $moduleName = $i === false ? $modulePath : substr( $modulePath, $i + 1 );
+ }
+
+ // Don't add duplicate warnings
+ $tag .= 's';
+ $path = [ $tag, $moduleName ];
+ $oldWarning = $this->result->getResultData( [ $tag, $moduleName, $tag ] );
+ if ( $oldWarning !== null ) {
+ $warnPos = strpos( $oldWarning, $value );
+ // If $value was found in $oldWarning, check if it starts at 0 or after "\n"
+ if ( $warnPos !== false && ( $warnPos === 0 || $oldWarning[$warnPos - 1] === "\n" ) ) {
+ // Check if $value is followed by "\n" or the end of the $oldWarning
+ $warnPos += strlen( $value );
+ if ( strlen( $oldWarning ) <= $warnPos || $oldWarning[$warnPos] === "\n" ) {
+ return;
+ }
+ }
+ // If there is a warning already, append it to the existing one
+ $value = "$oldWarning\n$value";
+ }
+ $this->result->addContentValue( $path, $tag, $value,
+ ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP | ApiResult::NO_SIZE_CHECK );
+ }
+ }
+}
diff --git a/www/wiki/includes/api/ApiExpandTemplates.php b/www/wiki/includes/api/ApiExpandTemplates.php
new file mode 100644
index 00000000..7c86e09d
--- /dev/null
+++ b/www/wiki/includes/api/ApiExpandTemplates.php
@@ -0,0 +1,233 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 05, 2007
+ *
+ * Copyright © 2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that functions as a shortcut to the wikitext preprocessor. Expands
+ * any templates in a provided string, and returns the result of this expansion
+ * to the caller.
+ *
+ * @ingroup API
+ */
+class ApiExpandTemplates extends ApiBase {
+
+ public function execute() {
+ // Cache may vary on the user because ParserOptions gets data from it
+ $this->getMain()->setCacheMode( 'anon-public-user-private' );
+
+ // Get parameters
+ $params = $this->extractRequestParams();
+ $this->requireMaxOneParameter( $params, 'prop', 'generatexml' );
+
+ $title = $params['title'];
+ if ( $title === null ) {
+ $titleProvided = false;
+ // A title is needed for parsing, so arbitrarily choose one
+ $title = 'API';
+ } else {
+ $titleProvided = true;
+ }
+
+ if ( $params['prop'] === null ) {
+ $this->addDeprecation(
+ 'apiwarn-deprecation-expandtemplates-prop', 'action=expandtemplates&!prop'
+ );
+ $prop = [];
+ } else {
+ $prop = array_flip( $params['prop'] );
+ }
+
+ $titleObj = Title::newFromText( $title );
+ if ( !$titleObj || $titleObj->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
+ }
+
+ // Get title and revision ID for parser
+ $revid = $params['revid'];
+ if ( $revid !== null ) {
+ $rev = Revision::newFromId( $revid );
+ if ( !$rev ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $revid ] );
+ }
+ $pTitleObj = $titleObj;
+ $titleObj = $rev->getTitle();
+ if ( $titleProvided ) {
+ if ( !$titleObj->equals( $pTitleObj ) ) {
+ $this->addWarning( [ 'apierror-revwrongpage', $rev->getId(),
+ wfEscapeWikiText( $pTitleObj->getPrefixedText() ) ] );
+ }
+ } else {
+ // Consider the title derived from the revid as having
+ // been provided.
+ $titleProvided = true;
+ }
+ }
+
+ $result = $this->getResult();
+
+ // Parse text
+ global $wgParser;
+ $options = ParserOptions::newFromContext( $this->getContext() );
+
+ if ( $params['includecomments'] ) {
+ $options->setRemoveComments( false );
+ }
+
+ $reset = null;
+ $suppressCache = false;
+ Hooks::run( 'ApiMakeParserOptions',
+ [ $options, $titleObj, $params, $this, &$reset, &$suppressCache ] );
+
+ $retval = [];
+
+ if ( isset( $prop['parsetree'] ) || $params['generatexml'] ) {
+ $wgParser->startExternalParse( $titleObj, $options, Parser::OT_PREPROCESS );
+ $dom = $wgParser->preprocessToDom( $params['text'] );
+ if ( is_callable( [ $dom, 'saveXML' ] ) ) {
+ $xml = $dom->saveXML();
+ } else {
+ $xml = $dom->__toString();
+ }
+ if ( isset( $prop['parsetree'] ) ) {
+ unset( $prop['parsetree'] );
+ $retval['parsetree'] = $xml;
+ } else {
+ // the old way
+ $result->addValue( null, 'parsetree', $xml );
+ $result->addValue( null, ApiResult::META_BC_SUBELEMENTS, [ 'parsetree' ] );
+ }
+ }
+
+ // if they didn't want any output except (probably) the parse tree,
+ // then don't bother actually fully expanding it
+ if ( $prop || $params['prop'] === null ) {
+ $wgParser->startExternalParse( $titleObj, $options, Parser::OT_PREPROCESS );
+ $frame = $wgParser->getPreprocessor()->newFrame();
+ $wikitext = $wgParser->preprocess( $params['text'], $titleObj, $options, $revid, $frame );
+ if ( $params['prop'] === null ) {
+ // the old way
+ ApiResult::setContentValue( $retval, 'wikitext', $wikitext );
+ } else {
+ $p_output = $wgParser->getOutput();
+ if ( isset( $prop['categories'] ) ) {
+ $categories = $p_output->getCategories();
+ if ( $categories ) {
+ $categories_result = [];
+ foreach ( $categories as $category => $sortkey ) {
+ $entry = [];
+ $entry['sortkey'] = $sortkey;
+ ApiResult::setContentValue( $entry, 'category', (string)$category );
+ $categories_result[] = $entry;
+ }
+ ApiResult::setIndexedTagName( $categories_result, 'category' );
+ $retval['categories'] = $categories_result;
+ }
+ }
+ if ( isset( $prop['properties'] ) ) {
+ $properties = $p_output->getProperties();
+ if ( $properties ) {
+ ApiResult::setArrayType( $properties, 'BCkvp', 'name' );
+ ApiResult::setIndexedTagName( $properties, 'property' );
+ $retval['properties'] = $properties;
+ }
+ }
+ if ( isset( $prop['volatile'] ) ) {
+ $retval['volatile'] = $frame->isVolatile();
+ }
+ if ( isset( $prop['ttl'] ) && $frame->getTTL() !== null ) {
+ $retval['ttl'] = $frame->getTTL();
+ }
+ if ( isset( $prop['wikitext'] ) ) {
+ $retval['wikitext'] = $wikitext;
+ }
+ if ( isset( $prop['modules'] ) ) {
+ $retval['modules'] = array_values( array_unique( $p_output->getModules() ) );
+ $retval['modulescripts'] = array_values( array_unique( $p_output->getModuleScripts() ) );
+ $retval['modulestyles'] = array_values( array_unique( $p_output->getModuleStyles() ) );
+ }
+ if ( isset( $prop['jsconfigvars'] ) ) {
+ $retval['jsconfigvars'] =
+ ApiResult::addMetadataToResultVars( $p_output->getJsConfigVars() );
+ }
+ if ( isset( $prop['encodedjsconfigvars'] ) ) {
+ $retval['encodedjsconfigvars'] = FormatJson::encode(
+ $p_output->getJsConfigVars(), false, FormatJson::ALL_OK
+ );
+ $retval[ApiResult::META_SUBELEMENTS][] = 'encodedjsconfigvars';
+ }
+ if ( isset( $prop['modules'] ) &&
+ !isset( $prop['jsconfigvars'] ) && !isset( $prop['encodedjsconfigvars'] ) ) {
+ $this->addWarning( 'apiwarn-moduleswithoutvars' );
+ }
+ }
+ }
+ ApiResult::setSubelementsList( $retval, [ 'wikitext', 'parsetree' ] );
+ $result->addValue( null, $this->getModuleName(), $retval );
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => null,
+ 'text' => [
+ ApiBase::PARAM_TYPE => 'text',
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'revid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'prop' => [
+ ApiBase::PARAM_TYPE => [
+ 'wikitext',
+ 'categories',
+ 'properties',
+ 'volatile',
+ 'ttl',
+ 'modules',
+ 'jsconfigvars',
+ 'encodedjsconfigvars',
+ 'parsetree',
+ ],
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'includecomments' => false,
+ 'generatexml' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=expandtemplates&text={{Project:Sandbox}}'
+ => 'apihelp-expandtemplates-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parsing_wikitext#expandtemplates';
+ }
+}
diff --git a/www/wiki/includes/api/ApiFeedContributions.php b/www/wiki/includes/api/ApiFeedContributions.php
new file mode 100644
index 00000000..cae1e150
--- /dev/null
+++ b/www/wiki/includes/api/ApiFeedContributions.php
@@ -0,0 +1,236 @@
+<?php
+/**
+ *
+ *
+ * Created on June 06, 2011
+ *
+ * Copyright © 2011 Sam Reed
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiFeedContributions extends ApiBase {
+
+ /**
+ * This module uses a custom feed wrapper printer.
+ *
+ * @return ApiFormatFeedWrapper
+ */
+ public function getCustomPrinter() {
+ return new ApiFormatFeedWrapper( $this->getMain() );
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $config = $this->getConfig();
+ if ( !$config->get( 'Feed' ) ) {
+ $this->dieWithError( 'feed-unavailable' );
+ }
+
+ $feedClasses = $config->get( 'FeedClasses' );
+ if ( !isset( $feedClasses[$params['feedformat']] ) ) {
+ $this->dieWithError( 'feed-invalid' );
+ }
+
+ if ( $params['showsizediff'] && $this->getConfig()->get( 'MiserMode' ) ) {
+ $this->dieWithError( 'apierror-sizediffdisabled' );
+ }
+
+ $msg = wfMessage( 'Contributions' )->inContentLanguage()->text();
+ $feedTitle = $config->get( 'Sitename' ) . ' - ' . $msg .
+ ' [' . $config->get( 'LanguageCode' ) . ']';
+ $feedUrl = SpecialPage::getTitleFor( 'Contributions', $params['user'] )->getFullURL();
+
+ $target = $params['user'] == 'newbies'
+ ? 'newbies'
+ : Title::makeTitleSafe( NS_USER, $params['user'] )->getText();
+
+ $feed = new $feedClasses[$params['feedformat']] (
+ $feedTitle,
+ htmlspecialchars( $msg ),
+ $feedUrl
+ );
+
+ // Convert year/month parameters to end parameter
+ $params['start'] = '';
+ $params['end'] = '';
+ $params = ContribsPager::processDateFilter( $params );
+
+ $pager = new ContribsPager( $this->getContext(), [
+ 'target' => $target,
+ 'namespace' => $params['namespace'],
+ 'start' => $params['start'],
+ 'end' => $params['end'],
+ 'tagFilter' => $params['tagfilter'],
+ 'deletedOnly' => $params['deletedonly'],
+ 'topOnly' => $params['toponly'],
+ 'newOnly' => $params['newonly'],
+ 'hideMinor' => $params['hideminor'],
+ 'showSizeDiff' => $params['showsizediff'],
+ ] );
+
+ $feedLimit = $this->getConfig()->get( 'FeedLimit' );
+ if ( $pager->getLimit() > $feedLimit ) {
+ $pager->setLimit( $feedLimit );
+ }
+
+ $feedItems = [];
+ if ( $pager->getNumRows() > 0 ) {
+ $count = 0;
+ $limit = $pager->getLimit();
+ foreach ( $pager->mResult as $row ) {
+ // ContribsPager selects one more row for navigation, skip that row
+ if ( ++$count > $limit ) {
+ break;
+ }
+ $item = $this->feedItem( $row );
+ if ( $item !== null ) {
+ $feedItems[] = $item;
+ }
+ }
+ }
+
+ ApiFormatFeedWrapper::setResult( $this->getResult(), $feed, $feedItems );
+ }
+
+ protected function feedItem( $row ) {
+ // This hook is the api contributions equivalent to the
+ // ContributionsLineEnding hook. Hook implementers may cancel
+ // the hook to signal the user is not allowed to read this item.
+ $feedItem = null;
+ $hookResult = Hooks::run(
+ 'ApiFeedContributions::feedItem',
+ [ $row, $this->getContext(), &$feedItem ]
+ );
+ // Hook returned a valid feed item
+ if ( $feedItem instanceof FeedItem ) {
+ return $feedItem;
+ // Hook was canceled and did not return a valid feed item
+ } elseif ( !$hookResult ) {
+ return null;
+ }
+
+ // Hook completed and did not return a valid feed item
+ $title = Title::makeTitle( intval( $row->page_namespace ), $row->page_title );
+ if ( $title && $title->userCan( 'read', $this->getUser() ) ) {
+ $date = $row->rev_timestamp;
+ $comments = $title->getTalkPage()->getFullURL();
+ $revision = Revision::newFromRow( $row );
+
+ return new FeedItem(
+ $title->getPrefixedText(),
+ $this->feedItemDesc( $revision ),
+ $title->getFullURL( [ 'diff' => $revision->getId() ] ),
+ $date,
+ $this->feedItemAuthor( $revision ),
+ $comments
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * @param Revision $revision
+ * @return string
+ */
+ protected function feedItemAuthor( $revision ) {
+ return $revision->getUserText();
+ }
+
+ /**
+ * @param Revision $revision
+ * @return string
+ */
+ protected function feedItemDesc( $revision ) {
+ if ( $revision ) {
+ $msg = wfMessage( 'colon-separator' )->inContentLanguage()->text();
+ $content = $revision->getContent();
+
+ if ( $content instanceof TextContent ) {
+ // only textual content has a "source view".
+ $html = nl2br( htmlspecialchars( $content->getNativeData() ) );
+ } else {
+ // XXX: we could get an HTML representation of the content via getParserOutput, but that may
+ // contain JS magic and generally may not be suitable for inclusion in a feed.
+ // Perhaps Content should have a getDescriptiveHtml method and/or a getSourceText method.
+ // Compare also FeedUtils::formatDiffRow.
+ $html = '';
+ }
+
+ return '<p>' . htmlspecialchars( $revision->getUserText() ) . $msg .
+ htmlspecialchars( FeedItem::stripComment( $revision->getComment() ) ) .
+ "</p>\n<hr />\n<div>" . $html . '</div>';
+ }
+
+ return '';
+ }
+
+ public function getAllowedParams() {
+ $feedFormatNames = array_keys( $this->getConfig()->get( 'FeedClasses' ) );
+
+ $ret = [
+ 'feedformat' => [
+ ApiBase::PARAM_DFLT => 'rss',
+ ApiBase::PARAM_TYPE => $feedFormatNames
+ ],
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_TYPE => 'namespace'
+ ],
+ 'year' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'month' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'tagfilter' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => array_values( ChangeTags::listDefinedTags() ),
+ ApiBase::PARAM_DFLT => '',
+ ],
+ 'deletedonly' => false,
+ 'toponly' => false,
+ 'newonly' => false,
+ 'hideminor' => false,
+ 'showsizediff' => [
+ ApiBase::PARAM_DFLT => false,
+ ],
+ ];
+
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $ret['showsizediff'][ApiBase::PARAM_HELP_MSG] = 'api-help-param-disabled-in-miser-mode';
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=feedcontributions&user=Example'
+ => 'apihelp-feedcontributions-example-simple',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiFeedRecentChanges.php b/www/wiki/includes/api/ApiFeedRecentChanges.php
new file mode 100644
index 00000000..2a80dd53
--- /dev/null
+++ b/www/wiki/includes/api/ApiFeedRecentChanges.php
@@ -0,0 +1,193 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.23
+ */
+
+/**
+ * Recent changes feed.
+ *
+ * @ingroup API
+ */
+class ApiFeedRecentChanges extends ApiBase {
+
+ private $params;
+
+ /**
+ * This module uses a custom feed wrapper printer.
+ *
+ * @return ApiFormatFeedWrapper
+ */
+ public function getCustomPrinter() {
+ return new ApiFormatFeedWrapper( $this->getMain() );
+ }
+
+ /**
+ * Format the rows (generated by SpecialRecentchanges or SpecialRecentchangeslinked)
+ * as an RSS/Atom feed.
+ */
+ public function execute() {
+ $config = $this->getConfig();
+
+ $this->params = $this->extractRequestParams();
+
+ if ( !$config->get( 'Feed' ) ) {
+ $this->dieWithError( 'feed-unavailable' );
+ }
+
+ $feedClasses = $config->get( 'FeedClasses' );
+ if ( !isset( $feedClasses[$this->params['feedformat']] ) ) {
+ $this->dieWithError( 'feed-invalid' );
+ }
+
+ $this->getMain()->setCacheMode( 'public' );
+ if ( !$this->getMain()->getParameter( 'smaxage' ) ) {
+ // T65249: This page gets hit a lot, cache at least 15 seconds.
+ $this->getMain()->setCacheMaxAge( 15 );
+ }
+
+ $feedFormat = $this->params['feedformat'];
+ $specialClass = $this->params['target'] !== null
+ ? SpecialRecentChangesLinked::class
+ : SpecialRecentChanges::class;
+
+ $formatter = $this->getFeedObject( $feedFormat, $specialClass );
+
+ // Parameters are passed via the request in the context… :(
+ $context = new DerivativeContext( $this );
+ $context->setRequest( new DerivativeRequest(
+ $this->getRequest(),
+ $this->params,
+ $this->getRequest()->wasPosted()
+ ) );
+
+ // The row-getting functionality should be factored out of ChangesListSpecialPage too…
+ $rc = new $specialClass();
+ $rc->setContext( $context );
+ $rows = $rc->getRows();
+
+ $feedItems = $rows ? ChangesFeed::buildItems( $rows ) : [];
+
+ ApiFormatFeedWrapper::setResult( $this->getResult(), $formatter, $feedItems );
+ }
+
+ /**
+ * Return a ChannelFeed object.
+ *
+ * @param string $feedFormat Feed's format (either 'rss' or 'atom')
+ * @param string $specialClass Relevant special page name (either 'SpecialRecentChanges' or
+ * 'SpecialRecentChangesLinked')
+ * @return ChannelFeed
+ */
+ public function getFeedObject( $feedFormat, $specialClass ) {
+ if ( $specialClass === SpecialRecentChangesLinked::class ) {
+ $title = Title::newFromText( $this->params['target'] );
+ if ( !$title ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $this->params['target'] ) ] );
+ }
+
+ $feed = new ChangesFeed( $feedFormat, false );
+ $feedObj = $feed->getFeedObject(
+ $this->msg( 'recentchangeslinked-title', $title->getPrefixedText() )
+ ->inContentLanguage()->text(),
+ $this->msg( 'recentchangeslinked-feed' )->inContentLanguage()->text(),
+ SpecialPage::getTitleFor( 'Recentchangeslinked' )->getFullURL()
+ );
+ } else {
+ $feed = new ChangesFeed( $feedFormat, 'rcfeed' );
+ $feedObj = $feed->getFeedObject(
+ $this->msg( 'recentchanges' )->inContentLanguage()->text(),
+ $this->msg( 'recentchanges-feed-description' )->inContentLanguage()->text(),
+ SpecialPage::getTitleFor( 'Recentchanges' )->getFullURL()
+ );
+ }
+
+ return $feedObj;
+ }
+
+ public function getAllowedParams() {
+ $config = $this->getConfig();
+ $feedFormatNames = array_keys( $config->get( 'FeedClasses' ) );
+
+ $ret = [
+ 'feedformat' => [
+ ApiBase::PARAM_DFLT => 'rss',
+ ApiBase::PARAM_TYPE => $feedFormatNames,
+ ],
+
+ 'namespace' => [
+ ApiBase::PARAM_TYPE => 'namespace',
+ ],
+ 'invert' => false,
+ 'associated' => false,
+
+ 'days' => [
+ ApiBase::PARAM_DFLT => 7,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 50,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => $config->get( 'FeedLimit' ),
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'from' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+
+ 'hideminor' => false,
+ 'hidebots' => false,
+ 'hideanons' => false,
+ 'hideliu' => false,
+ 'hidepatrolled' => false,
+ 'hidemyself' => false,
+ 'hidecategorization' => false,
+
+ 'tagfilter' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+
+ 'target' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'showlinkedto' => false,
+ ];
+
+ if ( $config->get( 'AllowCategorizedRecentChanges' ) ) {
+ $ret += [
+ 'categories' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'categories_any' => false,
+ ];
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=feedrecentchanges'
+ => 'apihelp-feedrecentchanges-example-simple',
+ 'action=feedrecentchanges&days=30'
+ => 'apihelp-feedrecentchanges-example-30days',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiFeedWatchlist.php b/www/wiki/includes/api/ApiFeedWatchlist.php
new file mode 100644
index 00000000..e3a757f7
--- /dev/null
+++ b/www/wiki/includes/api/ApiFeedWatchlist.php
@@ -0,0 +1,312 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 13, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This action allows users to get their watchlist items in RSS/Atom formats.
+ * When executed, it performs a nested call to the API to get the needed data,
+ * and formats it in a proper format.
+ *
+ * @ingroup API
+ */
+class ApiFeedWatchlist extends ApiBase {
+
+ private $watchlistModule = null;
+ private $linkToSections = false;
+
+ /**
+ * This module uses a custom feed wrapper printer.
+ *
+ * @return ApiFormatFeedWrapper
+ */
+ public function getCustomPrinter() {
+ return new ApiFormatFeedWrapper( $this->getMain() );
+ }
+
+ /**
+ * Make a nested call to the API to request watchlist items in the last $hours.
+ * Wrap the result as an RSS/Atom feed.
+ */
+ public function execute() {
+ $config = $this->getConfig();
+ $feedClasses = $config->get( 'FeedClasses' );
+ $params = [];
+ try {
+ $params = $this->extractRequestParams();
+
+ if ( !$config->get( 'Feed' ) ) {
+ $this->dieWithError( 'feed-unavailable' );
+ }
+
+ if ( !isset( $feedClasses[$params['feedformat']] ) ) {
+ $this->dieWithError( 'feed-invalid' );
+ }
+
+ // limit to the number of hours going from now back
+ $endTime = wfTimestamp( TS_MW, time() - intval( $params['hours'] * 60 * 60 ) );
+
+ // Prepare parameters for nested request
+ $fauxReqArr = [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'siprop' => 'general',
+ 'list' => 'watchlist',
+ 'wlprop' => 'title|user|comment|timestamp|ids',
+ 'wldir' => 'older', // reverse order - from newest to oldest
+ 'wlend' => $endTime, // stop at this time
+ 'wllimit' => min( 50, $this->getConfig()->get( 'FeedLimit' ) )
+ ];
+
+ if ( $params['wlowner'] !== null ) {
+ $fauxReqArr['wlowner'] = $params['wlowner'];
+ }
+ if ( $params['wltoken'] !== null ) {
+ $fauxReqArr['wltoken'] = $params['wltoken'];
+ }
+ if ( $params['wlexcludeuser'] !== null ) {
+ $fauxReqArr['wlexcludeuser'] = $params['wlexcludeuser'];
+ }
+ if ( $params['wlshow'] !== null ) {
+ $fauxReqArr['wlshow'] = $params['wlshow'];
+ }
+ if ( $params['wltype'] !== null ) {
+ $fauxReqArr['wltype'] = $params['wltype'];
+ }
+
+ // Support linking directly to sections when possible
+ // (possible only if section name is present in comment)
+ if ( $params['linktosections'] ) {
+ $this->linkToSections = true;
+ }
+
+ // Check for 'allrev' parameter, and if found, show all revisions to each page on wl.
+ if ( $params['allrev'] ) {
+ $fauxReqArr['wlallrev'] = '';
+ }
+
+ // Create the request
+ $fauxReq = new FauxRequest( $fauxReqArr );
+
+ // Execute
+ $module = new ApiMain( $fauxReq );
+ $module->execute();
+
+ $data = $module->getResult()->getResultData( [ 'query', 'watchlist' ] );
+ $feedItems = [];
+ foreach ( (array)$data as $key => $info ) {
+ if ( ApiResult::isMetadataKey( $key ) ) {
+ continue;
+ }
+ $feedItem = $this->createFeedItem( $info );
+ if ( $feedItem ) {
+ $feedItems[] = $feedItem;
+ }
+ }
+
+ $msg = wfMessage( 'watchlist' )->inContentLanguage()->text();
+
+ $feedTitle = $this->getConfig()->get( 'Sitename' ) . ' - ' . $msg .
+ ' [' . $this->getConfig()->get( 'LanguageCode' ) . ']';
+ $feedUrl = SpecialPage::getTitleFor( 'Watchlist' )->getFullURL();
+
+ $feed = new $feedClasses[$params['feedformat']] (
+ $feedTitle,
+ htmlspecialchars( $msg ),
+ $feedUrl
+ );
+
+ ApiFormatFeedWrapper::setResult( $this->getResult(), $feed, $feedItems );
+ } catch ( Exception $e ) {
+ // Error results should not be cached
+ $this->getMain()->setCacheMaxAge( 0 );
+
+ // @todo FIXME: Localise brackets
+ $feedTitle = $this->getConfig()->get( 'Sitename' ) . ' - Error - ' .
+ wfMessage( 'watchlist' )->inContentLanguage()->text() .
+ ' [' . $this->getConfig()->get( 'LanguageCode' ) . ']';
+ $feedUrl = SpecialPage::getTitleFor( 'Watchlist' )->getFullURL();
+
+ $feedFormat = isset( $params['feedformat'] ) ? $params['feedformat'] : 'rss';
+ $msg = wfMessage( 'watchlist' )->inContentLanguage()->escaped();
+ $feed = new $feedClasses[$feedFormat] ( $feedTitle, $msg, $feedUrl );
+
+ if ( $e instanceof ApiUsageException ) {
+ foreach ( $e->getStatusValue()->getErrors() as $error ) {
+ $msg = ApiMessage::create( $error )
+ ->inLanguage( $this->getLanguage() );
+ $errorTitle = $this->msg( 'api-feed-error-title', $msg->getApiCode() );
+ $errorText = $msg->text();
+ $feedItems[] = new FeedItem( $errorTitle, $errorText, '', '', '' );
+ }
+ } else {
+ if ( $e instanceof UsageException ) {
+ $errorCode = $e->getCodeString();
+ } else {
+ // Something is seriously wrong
+ $errorCode = 'internal_api_error';
+ }
+ $errorTitle = $this->msg( 'api-feed-error-title', $errorCode );
+ $errorText = $e->getMessage();
+ $feedItems[] = new FeedItem( $errorTitle, $errorText, '', '', '' );
+ }
+
+ ApiFormatFeedWrapper::setResult( $this->getResult(), $feed, $feedItems );
+ }
+ }
+
+ /**
+ * @param array $info
+ * @return FeedItem
+ */
+ private function createFeedItem( $info ) {
+ if ( !isset( $info['title'] ) ) {
+ // Probably a revdeled log entry, skip it.
+ return null;
+ }
+
+ $titleStr = $info['title'];
+ $title = Title::newFromText( $titleStr );
+ $curidParam = [];
+ if ( !$title || $title->isExternal() ) {
+ // Probably a formerly-valid title that's now conflicting with an
+ // interwiki prefix or the like.
+ if ( isset( $info['pageid'] ) ) {
+ $title = Title::newFromID( $info['pageid'] );
+ $curidParam = [ 'curid' => $info['pageid'] ];
+ }
+ if ( !$title || $title->isExternal() ) {
+ return null;
+ }
+ }
+ if ( isset( $info['revid'] ) ) {
+ $titleUrl = $title->getFullURL( [ 'diff' => $info['revid'] ] );
+ } else {
+ $titleUrl = $title->getFullURL( $curidParam );
+ }
+ $comment = isset( $info['comment'] ) ? $info['comment'] : null;
+
+ // Create an anchor to section.
+ // The anchor won't work for sections that have dupes on page
+ // as there's no way to strip that info from ApiWatchlist (apparently?).
+ // RegExp in the line below is equal to Linker::formatAutocomments().
+ if ( $this->linkToSections && $comment !== null &&
+ preg_match( '!(.*)/\*\s*(.*?)\s*\*/(.*)!', $comment, $matches )
+ ) {
+ global $wgParser;
+
+ $sectionTitle = $wgParser->stripSectionName( $matches[2] );
+ $sectionTitle = Sanitizer::normalizeSectionNameWhitespace( $sectionTitle );
+ $titleUrl .= Title::newFromText( '#' . $sectionTitle )->getFragmentForURL();
+ }
+
+ $timestamp = $info['timestamp'];
+
+ if ( isset( $info['user'] ) ) {
+ $user = $info['user'];
+ $completeText = "$comment ($user)";
+ } else {
+ $user = '';
+ $completeText = (string)$comment;
+ }
+
+ return new FeedItem( $titleStr, $completeText, $titleUrl, $timestamp, $user );
+ }
+
+ private function getWatchlistModule() {
+ if ( $this->watchlistModule === null ) {
+ $this->watchlistModule = $this->getMain()->getModuleManager()->getModule( 'query' )
+ ->getModuleManager()->getModule( 'watchlist' );
+ }
+
+ return $this->watchlistModule;
+ }
+
+ public function getAllowedParams( $flags = 0 ) {
+ $feedFormatNames = array_keys( $this->getConfig()->get( 'FeedClasses' ) );
+ $ret = [
+ 'feedformat' => [
+ ApiBase::PARAM_DFLT => 'rss',
+ ApiBase::PARAM_TYPE => $feedFormatNames
+ ],
+ 'hours' => [
+ ApiBase::PARAM_DFLT => 24,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => 72,
+ ],
+ 'linktosections' => false,
+ ];
+
+ $copyParams = [
+ 'allrev' => 'allrev',
+ 'owner' => 'wlowner',
+ 'token' => 'wltoken',
+ 'show' => 'wlshow',
+ 'type' => 'wltype',
+ 'excludeuser' => 'wlexcludeuser',
+ ];
+ if ( $flags ) {
+ $wlparams = $this->getWatchlistModule()->getAllowedParams( $flags );
+ foreach ( $copyParams as $from => $to ) {
+ $p = $wlparams[$from];
+ if ( !is_array( $p ) ) {
+ $p = [ ApiBase::PARAM_DFLT => $p ];
+ }
+ if ( !isset( $p[ApiBase::PARAM_HELP_MSG] ) ) {
+ $p[ApiBase::PARAM_HELP_MSG] = "apihelp-query+watchlist-param-$from";
+ }
+ if ( isset( $p[ApiBase::PARAM_TYPE] ) && is_array( $p[ApiBase::PARAM_TYPE] ) &&
+ isset( $p[ApiBase::PARAM_HELP_MSG_PER_VALUE] )
+ ) {
+ foreach ( $p[ApiBase::PARAM_TYPE] as $v ) {
+ if ( !isset( $p[ApiBase::PARAM_HELP_MSG_PER_VALUE][$v] ) ) {
+ $p[ApiBase::PARAM_HELP_MSG_PER_VALUE][$v] = "apihelp-query+watchlist-paramvalue-$from-$v";
+ }
+ }
+ }
+ $ret[$to] = $p;
+ }
+ } else {
+ foreach ( $copyParams as $from => $to ) {
+ $ret[$to] = null;
+ }
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=feedwatchlist'
+ => 'apihelp-feedwatchlist-example-default',
+ 'action=feedwatchlist&allrev=&hours=6'
+ => 'apihelp-feedwatchlist-example-all6hrs',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Watchlist_feed';
+ }
+}
diff --git a/www/wiki/includes/api/ApiFileRevert.php b/www/wiki/includes/api/ApiFileRevert.php
new file mode 100644
index 00000000..736898ed
--- /dev/null
+++ b/www/wiki/includes/api/ApiFileRevert.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ *
+ *
+ * Created on March 5, 2011
+ *
+ * Copyright © 2011 Bryan Tong Minh <Bryan.TongMinh@Gmail.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiFileRevert extends ApiBase {
+ /** @var LocalFile */
+ protected $file;
+
+ /** @var string */
+ protected $archiveName;
+
+ /** @var array */
+ protected $params;
+
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $this->params = $this->extractRequestParams();
+ // Extract the file and archiveName from the request parameters
+ $this->validateParameters();
+
+ // Check whether we're allowed to revert this file
+ $this->checkTitleUserPermissions( $this->file->getTitle(), [ 'edit', 'upload' ] );
+
+ $sourceUrl = $this->file->getArchiveVirtualUrl( $this->archiveName );
+ $status = $this->file->upload(
+ $sourceUrl,
+ $this->params['comment'],
+ $this->params['comment'],
+ 0,
+ false,
+ false,
+ $this->getUser()
+ );
+
+ if ( $status->isGood() ) {
+ $result = [ 'result' => 'Success' ];
+ } else {
+ $result = [
+ 'result' => 'Failure',
+ 'errors' => $this->getErrorFormatter()->arrayFromStatus( $status ),
+ ];
+ }
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $result );
+ }
+
+ /**
+ * Validate the user parameters and set $this->archiveName and $this->file.
+ * Throws an error if validation fails
+ */
+ protected function validateParameters() {
+ // Validate the input title
+ $title = Title::makeTitleSafe( NS_FILE, $this->params['filename'] );
+ if ( is_null( $title ) ) {
+ $this->dieWithError(
+ [ 'apierror-invalidtitle', wfEscapeWikiText( $this->params['filename'] ) ]
+ );
+ }
+ $localRepo = RepoGroup::singleton()->getLocalRepo();
+
+ // Check if the file really exists
+ $this->file = $localRepo->newFile( $title );
+ if ( !$this->file->exists() ) {
+ $this->dieWithError( 'apierror-missingtitle' );
+ }
+
+ // Check if the archivename is valid for this file
+ $this->archiveName = $this->params['archivename'];
+ $oldFile = $localRepo->newFromArchiveName( $title, $this->archiveName );
+ if ( !$oldFile->exists() ) {
+ $this->dieWithError( 'filerevert-badversion' );
+ }
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'filename' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'comment' => [
+ ApiBase::PARAM_DFLT => '',
+ ],
+ 'archivename' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=filerevert&filename=Wiki.png&comment=Revert&' .
+ 'archivename=20110305152740!Wiki.png&token=123ABC'
+ => 'apihelp-filerevert-example-revert',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiFormatBase.php b/www/wiki/includes/api/ApiFormatBase.php
new file mode 100644
index 00000000..c5f2fcfa
--- /dev/null
+++ b/www/wiki/includes/api/ApiFormatBase.php
@@ -0,0 +1,376 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 19, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This is the abstract base class for API formatters.
+ *
+ * @ingroup API
+ */
+abstract class ApiFormatBase extends ApiBase {
+ private $mIsHtml, $mFormat, $mUnescapeAmps, $mHelp;
+ private $mBuffer, $mDisabled = false;
+ private $mIsWrappedHtml = false;
+ private $mHttpStatus = false;
+ protected $mForceDefaultParams = false;
+
+ /**
+ * If $format ends with 'fm', pretty-print the output in HTML.
+ * @param ApiMain $main
+ * @param string $format Format name
+ */
+ public function __construct( ApiMain $main, $format ) {
+ parent::__construct( $main, $format );
+
+ $this->mIsHtml = ( substr( $format, -2, 2 ) === 'fm' ); // ends with 'fm'
+ if ( $this->mIsHtml ) {
+ $this->mFormat = substr( $format, 0, -2 ); // remove ending 'fm'
+ $this->mIsWrappedHtml = $this->getMain()->getCheck( 'wrappedhtml' );
+ } else {
+ $this->mFormat = $format;
+ }
+ $this->mFormat = strtoupper( $this->mFormat );
+ }
+
+ /**
+ * Overriding class returns the MIME type that should be sent to the client.
+ *
+ * When getIsHtml() returns true, the return value here is used for syntax
+ * highlighting but the client sees text/html.
+ *
+ * @return string
+ */
+ abstract public function getMimeType();
+
+ /**
+ * Return a filename for this module's output.
+ * @note If $this->getIsWrappedHtml() || $this->getIsHtml(), you'll very
+ * likely want to fall back to this class's version.
+ * @since 1.27
+ * @return string Generally this should be "api-result.$ext", and must be
+ * encoded for inclusion in a Content-Disposition header's filename parameter.
+ */
+ public function getFilename() {
+ if ( $this->getIsWrappedHtml() ) {
+ return 'api-result-wrapped.json';
+ } elseif ( $this->getIsHtml() ) {
+ return 'api-result.html';
+ } else {
+ $exts = MimeMagic::singleton()->getExtensionsForType( $this->getMimeType() );
+ $ext = $exts ? strtok( $exts, ' ' ) : strtolower( $this->mFormat );
+ return "api-result.$ext";
+ }
+ }
+
+ /**
+ * Get the internal format name
+ * @return string
+ */
+ public function getFormat() {
+ return $this->mFormat;
+ }
+
+ /**
+ * Returns true when the HTML pretty-printer should be used.
+ * The default implementation assumes that formats ending with 'fm'
+ * should be formatted in HTML.
+ * @return bool
+ */
+ public function getIsHtml() {
+ return $this->mIsHtml;
+ }
+
+ /**
+ * Returns true when the special wrapped mode is enabled.
+ * @since 1.27
+ * @return bool
+ */
+ protected function getIsWrappedHtml() {
+ return $this->mIsWrappedHtml;
+ }
+
+ /**
+ * Disable the formatter.
+ *
+ * This causes calls to initPrinter() and closePrinter() to be ignored.
+ */
+ public function disable() {
+ $this->mDisabled = true;
+ }
+
+ /**
+ * Whether the printer is disabled
+ * @return bool
+ */
+ public function isDisabled() {
+ return $this->mDisabled;
+ }
+
+ /**
+ * Whether this formatter can handle printing API errors.
+ *
+ * If this returns false, then on API errors the default printer will be
+ * instantiated.
+ * @since 1.23
+ * @return bool
+ */
+ public function canPrintErrors() {
+ return true;
+ }
+
+ /**
+ * Ignore request parameters, force a default.
+ *
+ * Used as a fallback if errors are being thrown.
+ * @since 1.26
+ */
+ public function forceDefaultParams() {
+ $this->mForceDefaultParams = true;
+ }
+
+ /**
+ * Overridden to honor $this->forceDefaultParams(), if applicable
+ * @inheritDoc
+ * @since 1.26
+ */
+ protected function getParameterFromSettings( $paramName, $paramSettings, $parseLimit ) {
+ if ( !$this->mForceDefaultParams ) {
+ return parent::getParameterFromSettings( $paramName, $paramSettings, $parseLimit );
+ }
+
+ if ( !is_array( $paramSettings ) ) {
+ return $paramSettings;
+ } elseif ( isset( $paramSettings[self::PARAM_DFLT] ) ) {
+ return $paramSettings[self::PARAM_DFLT];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Set the HTTP status code to be used for the response
+ * @since 1.29
+ * @param int $code
+ */
+ public function setHttpStatus( $code ) {
+ if ( $this->mDisabled ) {
+ return;
+ }
+
+ if ( $this->getIsHtml() ) {
+ $this->mHttpStatus = $code;
+ } else {
+ $this->getMain()->getRequest()->response()->statusHeader( $code );
+ }
+ }
+
+ /**
+ * Initialize the printer function and prepare the output headers.
+ * @param bool $unused Always false since 1.25
+ */
+ public function initPrinter( $unused = false ) {
+ if ( $this->mDisabled ) {
+ return;
+ }
+
+ $mime = $this->getIsWrappedHtml()
+ ? 'text/mediawiki-api-prettyprint-wrapped'
+ : ( $this->getIsHtml() ? 'text/html' : $this->getMimeType() );
+
+ // Some printers (ex. Feed) do their own header settings,
+ // in which case $mime will be set to null
+ if ( $mime === null ) {
+ return; // skip any initialization
+ }
+
+ $this->getMain()->getRequest()->response()->header( "Content-Type: $mime; charset=utf-8" );
+
+ // Set X-Frame-Options API results (T41180)
+ $apiFrameOptions = $this->getConfig()->get( 'ApiFrameOptions' );
+ if ( $apiFrameOptions ) {
+ $this->getMain()->getRequest()->response()->header( "X-Frame-Options: $apiFrameOptions" );
+ }
+
+ // Set a Content-Disposition header so something downloading an API
+ // response uses a halfway-sensible filename (T128209).
+ $filename = $this->getFilename();
+ $this->getMain()->getRequest()->response()->header(
+ "Content-Disposition: inline; filename=\"{$filename}\""
+ );
+ }
+
+ /**
+ * Finish printing and output buffered data.
+ */
+ public function closePrinter() {
+ if ( $this->mDisabled ) {
+ return;
+ }
+
+ $mime = $this->getMimeType();
+ if ( $this->getIsHtml() && $mime !== null ) {
+ $format = $this->getFormat();
+ $lcformat = strtolower( $format );
+ $result = $this->getBuffer();
+
+ $context = new DerivativeContext( $this->getMain() );
+ $context->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'apioutput' ) );
+ $context->setTitle( SpecialPage::getTitleFor( 'ApiHelp' ) );
+ $out = new OutputPage( $context );
+ $context->setOutput( $out );
+
+ $out->addModuleStyles( 'mediawiki.apipretty' );
+ $out->setPageTitle( $context->msg( 'api-format-title' ) );
+
+ if ( !$this->getIsWrappedHtml() ) {
+ // When the format without suffix 'fm' is defined, there is a non-html version
+ if ( $this->getMain()->getModuleManager()->isDefined( $lcformat, 'format' ) ) {
+ if ( !$this->getRequest()->wasPosted() ) {
+ $nonHtmlUrl = strtok( $this->getRequest()->getFullRequestURL(), '?' )
+ . '?' . $this->getRequest()->appendQueryValue( 'format', $lcformat );
+ $msg = $context->msg( 'api-format-prettyprint-header-hyperlinked' )
+ ->params( $format, $lcformat, $nonHtmlUrl );
+ } else {
+ $msg = $context->msg( 'api-format-prettyprint-header' )->params( $format, $lcformat );
+ }
+ } else {
+ $msg = $context->msg( 'api-format-prettyprint-header-only-html' )->params( $format );
+ }
+
+ $header = $msg->parseAsBlock();
+ $out->addHTML(
+ Html::rawElement( 'div', [ 'class' => 'api-pretty-header' ],
+ ApiHelp::fixHelpLinks( $header )
+ )
+ );
+
+ if ( $this->mHttpStatus && $this->mHttpStatus !== 200 ) {
+ $out->addHTML(
+ Html::rawElement( 'div', [ 'class' => 'api-pretty-header api-pretty-status' ],
+ $this->msg(
+ 'api-format-prettyprint-status',
+ $this->mHttpStatus,
+ HttpStatus::getMessage( $this->mHttpStatus )
+ )->parse()
+ )
+ );
+ }
+ }
+
+ if ( Hooks::run( 'ApiFormatHighlight', [ $context, $result, $mime, $format ] ) ) {
+ $out->addHTML(
+ Html::element( 'pre', [ 'class' => 'api-pretty-content' ], $result )
+ );
+ }
+
+ if ( $this->getIsWrappedHtml() ) {
+ // This is a special output mode mainly intended for ApiSandbox use
+ $time = microtime( true ) - $this->getConfig()->get( 'RequestTime' );
+ $json = FormatJson::encode(
+ [
+ 'status' => (int)( $this->mHttpStatus ?: 200 ),
+ 'statustext' => HttpStatus::getMessage( $this->mHttpStatus ?: 200 ),
+ 'html' => $out->getHTML(),
+ 'modules' => array_values( array_unique( array_merge(
+ $out->getModules(),
+ $out->getModuleScripts(),
+ $out->getModuleStyles()
+ ) ) ),
+ 'continue' => $this->getResult()->getResultData( 'continue' ),
+ 'time' => round( $time * 1000 ),
+ ],
+ false, FormatJson::ALL_OK
+ );
+
+ // T68776: wfMangleFlashPolicy() is needed to avoid a nasty bug in
+ // Flash, but what it does isn't friendly for the API, so we need to
+ // work around it.
+ if ( preg_match( '/\<\s*cross-domain-policy\s*\>/i', $json ) ) {
+ $json = preg_replace(
+ '/\<(\s*cross-domain-policy\s*)\>/i', '\\u003C$1\\u003E', $json
+ );
+ }
+
+ echo $json;
+ } else {
+ // API handles its own clickjacking protection.
+ // Note, that $wgBreakFrames will still override $wgApiFrameOptions for format mode.
+ $out->allowClickjacking();
+ $out->output();
+ }
+ } else {
+ // For non-HTML output, clear all errors that might have been
+ // displayed if display_errors=On
+ ob_clean();
+
+ echo $this->getBuffer();
+ }
+ }
+
+ /**
+ * Append text to the output buffer.
+ * @param string $text
+ */
+ public function printText( $text ) {
+ $this->mBuffer .= $text;
+ }
+
+ /**
+ * Get the contents of the buffer.
+ * @return string
+ */
+ public function getBuffer() {
+ return $this->mBuffer;
+ }
+
+ public function getAllowedParams() {
+ $ret = [];
+ if ( $this->getIsHtml() ) {
+ $ret['wrappedhtml'] = [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-format-param-wrappedhtml',
+
+ ];
+ }
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&meta=siteinfo&siprop=namespaces&format=' . $this->getModuleName()
+ => [ 'apihelp-format-example-generic', $this->getFormat() ]
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Data_formats';
+ }
+
+}
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ */
diff --git a/www/wiki/includes/api/ApiFormatFeedWrapper.php b/www/wiki/includes/api/ApiFormatFeedWrapper.php
new file mode 100644
index 00000000..3ab5ab9e
--- /dev/null
+++ b/www/wiki/includes/api/ApiFormatFeedWrapper.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 19, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This printer is used to wrap an instance of the Feed class
+ * @ingroup API
+ */
+class ApiFormatFeedWrapper extends ApiFormatBase {
+
+ public function __construct( ApiMain $main ) {
+ parent::__construct( $main, 'feed' );
+ }
+
+ /**
+ * Call this method to initialize output data. See execute()
+ * @param ApiResult $result
+ * @param object $feed An instance of one of the $wgFeedClasses classes
+ * @param array $feedItems Array of FeedItem objects
+ */
+ public static function setResult( $result, $feed, $feedItems ) {
+ // Store output in the Result data.
+ // This way we can check during execution if any error has occurred
+ // Disable size checking for this because we can't continue
+ // cleanly; size checking would cause more problems than it'd
+ // solve
+ $result->addValue( null, '_feed', $feed, ApiResult::NO_VALIDATE );
+ $result->addValue( null, '_feeditems', $feedItems, ApiResult::NO_VALIDATE );
+ }
+
+ /**
+ * Feed does its own headers
+ *
+ * @return null
+ */
+ public function getMimeType() {
+ return null;
+ }
+
+ /**
+ * ChannelFeed doesn't give us a method to print errors in a friendly
+ * manner, so just punt errors to the default printer.
+ * @return bool
+ */
+ public function canPrintErrors() {
+ return false;
+ }
+
+ /**
+ * This class expects the result data to be in a custom format set by self::setResult()
+ * $result['_feed'] - an instance of one of the $wgFeedClasses classes
+ * $result['_feeditems'] - an array of FeedItem instances
+ * @param bool $unused
+ */
+ public function initPrinter( $unused = false ) {
+ parent::initPrinter( $unused );
+
+ if ( $this->isDisabled() ) {
+ return;
+ }
+
+ $data = $this->getResult()->getResultData();
+ if ( isset( $data['_feed'] ) && isset( $data['_feeditems'] ) ) {
+ $data['_feed']->httpHeaders();
+ } else {
+ // Error has occurred, print something useful
+ ApiBase::dieDebug( __METHOD__, 'Invalid feed class/item' );
+ }
+ }
+
+ /**
+ * This class expects the result data to be in a custom format set by self::setResult()
+ * $result['_feed'] - an instance of one of the $wgFeedClasses classes
+ * $result['_feeditems'] - an array of FeedItem instances
+ */
+ public function execute() {
+ $data = $this->getResult()->getResultData();
+ if ( isset( $data['_feed'] ) && isset( $data['_feeditems'] ) ) {
+ $feed = $data['_feed'];
+ $items = $data['_feeditems'];
+
+ // execute() needs to pass strings to $this->printText, not produce output itself.
+ ob_start();
+ $feed->outHeader();
+ foreach ( $items as & $item ) {
+ $feed->outItem( $item );
+ }
+ $feed->outFooter();
+ $this->printText( ob_get_clean() );
+ } else {
+ // Error has occurred, print something useful
+ ApiBase::dieDebug( __METHOD__, 'Invalid feed class/item' );
+ }
+ }
+}
diff --git a/www/wiki/includes/api/ApiFormatJson.php b/www/wiki/includes/api/ApiFormatJson.php
new file mode 100644
index 00000000..e5dafae6
--- /dev/null
+++ b/www/wiki/includes/api/ApiFormatJson.php
@@ -0,0 +1,138 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 19, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API JSON output formatter
+ * @ingroup API
+ */
+class ApiFormatJson extends ApiFormatBase {
+
+ private $isRaw;
+
+ public function __construct( ApiMain $main, $format ) {
+ parent::__construct( $main, $format );
+ $this->isRaw = ( $format === 'rawfm' );
+
+ if ( $this->getMain()->getCheck( 'callback' ) ) {
+ # T94015: jQuery appends a useless '_' parameter in jsonp mode.
+ # Mark the parameter as used in that case to avoid a warning that's
+ # outside the control of the end user.
+ # (and do it here because ApiMain::reportUnusedParams() gets called
+ # before our ::execute())
+ $this->getMain()->markParamsUsed( '_' );
+ }
+ }
+
+ public function getMimeType() {
+ $params = $this->extractRequestParams();
+ // callback:
+ if ( isset( $params['callback'] ) ) {
+ return 'text/javascript';
+ }
+
+ return 'application/json';
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $opt = 0;
+ if ( $this->isRaw ) {
+ $opt |= FormatJson::ALL_OK;
+ $transform = [];
+ } else {
+ switch ( $params['formatversion'] ) {
+ case 1:
+ $opt |= $params['utf8'] ? FormatJson::ALL_OK : FormatJson::XMLMETA_OK;
+ $transform = [
+ 'BC' => [],
+ 'Types' => [ 'AssocAsObject' => true ],
+ 'Strip' => 'all',
+ ];
+ break;
+
+ case 2:
+ case 'latest':
+ $opt |= $params['ascii'] ? FormatJson::XMLMETA_OK : FormatJson::ALL_OK;
+ $transform = [
+ 'Types' => [ 'AssocAsObject' => true ],
+ 'Strip' => 'all',
+ ];
+ break;
+
+ default:
+ // Should have been caught during parameter validation
+ $this->dieDebug( __METHOD__, 'Unknown value for \'formatversion\'' );
+ }
+ }
+ $data = $this->getResult()->getResultData( null, $transform );
+ $json = FormatJson::encode( $data, $this->getIsHtml(), $opt );
+
+ // T68776: wfMangleFlashPolicy() is needed to avoid a nasty bug in
+ // Flash, but what it does isn't friendly for the API, so we need to
+ // work around it.
+ if ( preg_match( '/\<\s*cross-domain-policy(?=\s|\>)/i', $json ) ) {
+ $json = preg_replace(
+ '/\<(\s*cross-domain-policy(?=\s|\>))/i', '\\u003C$1', $json
+ );
+ }
+
+ if ( isset( $params['callback'] ) ) {
+ $callback = preg_replace( "/[^][.\\'\\\"_A-Za-z0-9]/", '', $params['callback'] );
+ # Prepend a comment to try to avoid attacks against content
+ # sniffers, such as T70187.
+ $this->printText( "/**/$callback($json)" );
+ } else {
+ $this->printText( $json );
+ }
+ }
+
+ public function getAllowedParams() {
+ if ( $this->isRaw ) {
+ return parent::getAllowedParams();
+ }
+
+ $ret = parent::getAllowedParams() + [
+ 'callback' => [
+ ApiBase::PARAM_HELP_MSG => 'apihelp-json-param-callback',
+ ],
+ 'utf8' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-json-param-utf8',
+ ],
+ 'ascii' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-json-param-ascii',
+ ],
+ 'formatversion' => [
+ ApiBase::PARAM_TYPE => [ 1, 2, 'latest' ],
+ ApiBase::PARAM_DFLT => 1,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-json-param-formatversion',
+ ],
+ ];
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/api/ApiFormatNone.php b/www/wiki/includes/api/ApiFormatNone.php
new file mode 100644
index 00000000..dc623ac1
--- /dev/null
+++ b/www/wiki/includes/api/ApiFormatNone.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 22, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API Serialized PHP output formatter
+ * @ingroup API
+ */
+class ApiFormatNone extends ApiFormatBase {
+
+ public function getMimeType() {
+ return 'text/plain';
+ }
+
+ public function execute() {
+ }
+}
diff --git a/www/wiki/includes/api/ApiFormatPhp.php b/www/wiki/includes/api/ApiFormatPhp.php
new file mode 100644
index 00000000..671f3561
--- /dev/null
+++ b/www/wiki/includes/api/ApiFormatPhp.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 22, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API Serialized PHP output formatter
+ * @ingroup API
+ */
+class ApiFormatPhp extends ApiFormatBase {
+
+ public function getMimeType() {
+ return 'application/vnd.php.serialized';
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ switch ( $params['formatversion'] ) {
+ case 1:
+ $transforms = [
+ 'BC' => [],
+ 'Types' => [],
+ 'Strip' => 'all',
+ ];
+ break;
+
+ case 2:
+ case 'latest':
+ $transforms = [
+ 'Types' => [],
+ 'Strip' => 'all',
+ ];
+ break;
+
+ default:
+ // Should have been caught during parameter validation
+ $this->dieDebug( __METHOD__, 'Unknown value for \'formatversion\'' );
+ }
+ $text = serialize( $this->getResult()->getResultData( null, $transforms ) );
+
+ // T68776: wfMangleFlashPolicy() is needed to avoid a nasty bug in
+ // Flash, but what it does isn't friendly for the API. There's nothing
+ // we can do here that isn't actively broken in some manner, so let's
+ // just be broken in a useful manner.
+ if ( $this->getConfig()->get( 'MangleFlashPolicy' ) &&
+ in_array( 'wfOutputHandler', ob_list_handlers(), true ) &&
+ preg_match( '/\<\s*cross-domain-policy(?=\s|\>)/i', $text )
+ ) {
+ $this->dieWithError( 'apierror-formatphp', 'internalerror' );
+ }
+
+ $this->printText( $text );
+ }
+
+ public function getAllowedParams() {
+ $ret = parent::getAllowedParams() + [
+ 'formatversion' => [
+ ApiBase::PARAM_TYPE => [ 1, 2, 'latest' ],
+ ApiBase::PARAM_DFLT => 1,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-php-param-formatversion',
+ ],
+ ];
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/api/ApiFormatRaw.php b/www/wiki/includes/api/ApiFormatRaw.php
new file mode 100644
index 00000000..ebaeb2ce
--- /dev/null
+++ b/www/wiki/includes/api/ApiFormatRaw.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ *
+ *
+ * Created on Feb 2, 2009
+ *
+ * Copyright © 2009 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Formatter that spits out anything you like with any desired MIME type
+ * @ingroup API
+ */
+class ApiFormatRaw extends ApiFormatBase {
+
+ private $errorFallback;
+ private $mFailWithHTTPError = false;
+
+ /**
+ * @param ApiMain $main
+ * @param ApiFormatBase|null $errorFallback Object to fall back on for errors
+ */
+ public function __construct( ApiMain $main, ApiFormatBase $errorFallback = null ) {
+ parent::__construct( $main, 'raw' );
+ if ( $errorFallback === null ) {
+ $this->errorFallback = $main->createPrinterByName( $main->getParameter( 'format' ) );
+ } else {
+ $this->errorFallback = $errorFallback;
+ }
+ }
+
+ public function getMimeType() {
+ $data = $this->getResult()->getResultData();
+
+ if ( isset( $data['error'] ) || isset( $data['errors'] ) ) {
+ return $this->errorFallback->getMimeType();
+ }
+
+ if ( !isset( $data['mime'] ) ) {
+ ApiBase::dieDebug( __METHOD__, 'No MIME type set for raw formatter' );
+ }
+
+ return $data['mime'];
+ }
+
+ public function getFilename() {
+ $data = $this->getResult()->getResultData();
+ if ( isset( $data['error'] ) ) {
+ return $this->errorFallback->getFilename();
+ } elseif ( !isset( $data['filename'] ) || $this->getIsWrappedHtml() || $this->getIsHtml() ) {
+ return parent::getFilename();
+ } else {
+ return $data['filename'];
+ }
+ }
+
+ public function initPrinter( $unused = false ) {
+ $data = $this->getResult()->getResultData();
+ if ( isset( $data['error'] ) || isset( $data['errors'] ) ) {
+ $this->errorFallback->initPrinter( $unused );
+ if ( $this->mFailWithHTTPError ) {
+ $this->getMain()->getRequest()->response()->statusHeader( 400 );
+ }
+ } else {
+ parent::initPrinter( $unused );
+ }
+ }
+
+ public function closePrinter() {
+ $data = $this->getResult()->getResultData();
+ if ( isset( $data['error'] ) || isset( $data['errors'] ) ) {
+ $this->errorFallback->closePrinter();
+ } else {
+ parent::closePrinter();
+ }
+ }
+
+ public function execute() {
+ $data = $this->getResult()->getResultData();
+ if ( isset( $data['error'] ) || isset( $data['errors'] ) ) {
+ $this->errorFallback->execute();
+ return;
+ }
+
+ if ( !isset( $data['text'] ) ) {
+ ApiBase::dieDebug( __METHOD__, 'No text given for raw formatter' );
+ }
+ $this->printText( $data['text'] );
+ }
+
+ /**
+ * Output HTTP error code 400 when if an error is encountered
+ *
+ * The purpose is for output formats where the user-agent will
+ * not be able to interpret the validity of the content in any
+ * other way. For example subtitle files read by browser video players.
+ *
+ * @param bool $fail
+ */
+ public function setFailWithHTTPError( $fail ) {
+ $this->mFailWithHTTPError = $fail;
+ }
+}
diff --git a/www/wiki/includes/api/ApiFormatXml.php b/www/wiki/includes/api/ApiFormatXml.php
new file mode 100644
index 00000000..e4dfda0f
--- /dev/null
+++ b/www/wiki/includes/api/ApiFormatXml.php
@@ -0,0 +1,301 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 19, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API XML output formatter
+ * @ingroup API
+ */
+class ApiFormatXml extends ApiFormatBase {
+
+ private $mRootElemName = 'api';
+ public static $namespace = 'http://www.mediawiki.org/xml/api/';
+ private $mIncludeNamespace = false;
+ private $mXslt = null;
+
+ public function getMimeType() {
+ return 'text/xml';
+ }
+
+ public function setRootElement( $rootElemName ) {
+ $this->mRootElemName = $rootElemName;
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $this->mIncludeNamespace = $params['includexmlnamespace'];
+ $this->mXslt = $params['xslt'];
+
+ $this->printText( '<?xml version="1.0"?>' );
+ if ( !is_null( $this->mXslt ) ) {
+ $this->addXslt();
+ }
+
+ $result = $this->getResult();
+ if ( $this->mIncludeNamespace && $result->getResultData( 'xmlns' ) === null ) {
+ // If the result data already contains an 'xmlns' namespace added
+ // for custom XML output types, it will override the one for the
+ // generic API results.
+ // This allows API output of other XML types like Atom, RSS, RSD.
+ $result->addValue( null, 'xmlns', self::$namespace, ApiResult::NO_SIZE_CHECK );
+ }
+ $data = $result->getResultData( null, [
+ 'Custom' => function ( &$data, &$metadata ) {
+ if ( isset( $metadata[ApiResult::META_TYPE] ) ) {
+ // We want to use non-BC for BCassoc to force outputting of _idx.
+ switch ( $metadata[ApiResult::META_TYPE] ) {
+ case 'BCassoc':
+ $metadata[ApiResult::META_TYPE] = 'assoc';
+ break;
+ }
+ }
+ },
+ 'BC' => [ 'nobool', 'no*', 'nosub' ],
+ 'Types' => [ 'ArmorKVP' => '_name' ],
+ ] );
+
+ $this->printText(
+ static::recXmlPrint( $this->mRootElemName,
+ $data,
+ $this->getIsHtml() ? -2 : null
+ )
+ );
+ }
+
+ /**
+ * This method takes an array and converts it to XML.
+ *
+ * @param string|null $name Tag name
+ * @param mixed $value Tag value (attributes/content/subelements)
+ * @param int|null $indent Indentation
+ * @param array $attributes Additional attributes
+ * @return string
+ */
+ public static function recXmlPrint( $name, $value, $indent, $attributes = [] ) {
+ $retval = '';
+ if ( $indent !== null ) {
+ if ( $name !== null ) {
+ $indent += 2;
+ }
+ $indstr = "\n" . str_repeat( ' ', $indent );
+ } else {
+ $indstr = '';
+ }
+
+ if ( is_object( $value ) ) {
+ $value = (array)$value;
+ }
+ if ( is_array( $value ) ) {
+ $contentKey = isset( $value[ApiResult::META_CONTENT] )
+ ? $value[ApiResult::META_CONTENT]
+ : '*';
+ $subelementKeys = isset( $value[ApiResult::META_SUBELEMENTS] )
+ ? $value[ApiResult::META_SUBELEMENTS]
+ : [];
+ if ( isset( $value[ApiResult::META_BC_SUBELEMENTS] ) ) {
+ $subelementKeys = array_merge(
+ $subelementKeys, $value[ApiResult::META_BC_SUBELEMENTS]
+ );
+ }
+ $preserveKeys = isset( $value[ApiResult::META_PRESERVE_KEYS] )
+ ? $value[ApiResult::META_PRESERVE_KEYS]
+ : [];
+ $indexedTagName = isset( $value[ApiResult::META_INDEXED_TAG_NAME] )
+ ? self::mangleName( $value[ApiResult::META_INDEXED_TAG_NAME], $preserveKeys )
+ : '_v';
+ $bcBools = isset( $value[ApiResult::META_BC_BOOLS] )
+ ? $value[ApiResult::META_BC_BOOLS]
+ : [];
+ $indexSubelements = isset( $value[ApiResult::META_TYPE] )
+ ? $value[ApiResult::META_TYPE] !== 'array'
+ : false;
+
+ $content = null;
+ $subelements = [];
+ $indexedSubelements = [];
+ foreach ( $value as $k => $v ) {
+ if ( ApiResult::isMetadataKey( $k ) && !in_array( $k, $preserveKeys, true ) ) {
+ continue;
+ }
+
+ $oldv = $v;
+ if ( is_bool( $v ) && !in_array( $k, $bcBools, true ) ) {
+ $v = $v ? 'true' : 'false';
+ }
+
+ if ( $name !== null && $k === $contentKey ) {
+ $content = $v;
+ } elseif ( is_int( $k ) ) {
+ $indexedSubelements[$k] = $v;
+ } elseif ( is_array( $v ) || is_object( $v ) ) {
+ $subelements[self::mangleName( $k, $preserveKeys )] = $v;
+ } elseif ( in_array( $k, $subelementKeys, true ) || $name === null ) {
+ $subelements[self::mangleName( $k, $preserveKeys )] = [
+ 'content' => $v,
+ ApiResult::META_CONTENT => 'content',
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ } elseif ( is_bool( $oldv ) ) {
+ if ( $oldv ) {
+ $attributes[self::mangleName( $k, $preserveKeys )] = '';
+ }
+ } elseif ( $v !== null ) {
+ $attributes[self::mangleName( $k, $preserveKeys )] = $v;
+ }
+ }
+
+ if ( $content !== null ) {
+ if ( $subelements || $indexedSubelements ) {
+ $subelements[self::mangleName( $contentKey, $preserveKeys )] = [
+ 'content' => $content,
+ ApiResult::META_CONTENT => 'content',
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ $content = null;
+ } elseif ( is_scalar( $content ) ) {
+ // Add xml:space="preserve" to the element so XML parsers
+ // will leave whitespace in the content alone
+ $attributes += [ 'xml:space' => 'preserve' ];
+ }
+ }
+
+ if ( $content !== null ) {
+ if ( is_scalar( $content ) ) {
+ $retval .= $indstr . Xml::element( $name, $attributes, $content );
+ } else {
+ if ( $name !== null ) {
+ $retval .= $indstr . Xml::element( $name, $attributes, null );
+ }
+ $retval .= static::recXmlPrint( null, $content, $indent );
+ if ( $name !== null ) {
+ $retval .= $indstr . Xml::closeElement( $name );
+ }
+ }
+ } elseif ( !$indexedSubelements && !$subelements ) {
+ if ( $name !== null ) {
+ $retval .= $indstr . Xml::element( $name, $attributes );
+ }
+ } else {
+ if ( $name !== null ) {
+ $retval .= $indstr . Xml::element( $name, $attributes, null );
+ }
+ foreach ( $subelements as $k => $v ) {
+ $retval .= static::recXmlPrint( $k, $v, $indent );
+ }
+ foreach ( $indexedSubelements as $k => $v ) {
+ $retval .= static::recXmlPrint( $indexedTagName, $v, $indent,
+ $indexSubelements ? [ '_idx' => $k ] : []
+ );
+ }
+ if ( $name !== null ) {
+ $retval .= $indstr . Xml::closeElement( $name );
+ }
+ }
+ } else {
+ // to make sure null value doesn't produce unclosed element,
+ // which is what Xml::element( $name, null, null ) returns
+ if ( $value === null ) {
+ $retval .= $indstr . Xml::element( $name, $attributes );
+ } else {
+ $retval .= $indstr . Xml::element( $name, $attributes, $value );
+ }
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Mangle XML-invalid names to be valid in XML
+ * @param string $name
+ * @param array $preserveKeys Names to not mangle
+ * @return string Mangled name
+ */
+ private static function mangleName( $name, $preserveKeys = [] ) {
+ static $nsc = null, $nc = null;
+
+ if ( in_array( $name, $preserveKeys, true ) ) {
+ return $name;
+ }
+
+ if ( $name === '' ) {
+ return '_';
+ }
+
+ if ( $nsc === null ) {
+ // Note we omit ':' from $nsc and $nc because it's reserved for XML
+ // namespacing, and we omit '_' from $nsc (but not $nc) because we
+ // reserve it.
+ $nsc = 'A-Za-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}' .
+ '\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}' .
+ '\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}';
+ $nc = $nsc . '_\-.0-9\x{B7}\x{300}-\x{36F}\x{203F}-\x{2040}';
+ }
+
+ if ( preg_match( "/^[$nsc][$nc]*$/uS", $name ) ) {
+ return $name;
+ }
+
+ return '_' . preg_replace_callback(
+ "/[^$nc]/uS",
+ function ( $m ) {
+ return sprintf( '.%X.', UtfNormal\Utils::utf8ToCodepoint( $m[0] ) );
+ },
+ str_replace( '.', '.2E.', $name )
+ );
+ }
+
+ protected function addXslt() {
+ $nt = Title::newFromText( $this->mXslt );
+ if ( is_null( $nt ) || !$nt->exists() ) {
+ $this->addWarning( 'apiwarn-invalidxmlstylesheet' );
+
+ return;
+ }
+ if ( $nt->getNamespace() != NS_MEDIAWIKI ) {
+ $this->addWarning( 'apiwarn-invalidxmlstylesheetns' );
+
+ return;
+ }
+ if ( substr( $nt->getText(), -4 ) !== '.xsl' ) {
+ $this->addWarning( 'apiwarn-invalidxmlstylesheetext' );
+
+ return;
+ }
+ $this->printText( '<?xml-stylesheet href="' .
+ htmlspecialchars( $nt->getLocalURL( 'action=raw' ) ) . '" type="text/xsl" ?>' );
+ }
+
+ public function getAllowedParams() {
+ return parent::getAllowedParams() + [
+ 'xslt' => [
+ ApiBase::PARAM_HELP_MSG => 'apihelp-xml-param-xslt',
+ ],
+ 'includexmlnamespace' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-xml-param-includexmlnamespace',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiHelp.php b/www/wiki/includes/api/ApiHelp.php
new file mode 100644
index 00000000..ea4f724a
--- /dev/null
+++ b/www/wiki/includes/api/ApiHelp.php
@@ -0,0 +1,898 @@
+<?php
+/**
+ *
+ *
+ * Created on Aug 29, 2014
+ *
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use HtmlFormatter\HtmlFormatter;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Class to output help for an API module
+ *
+ * @since 1.25 completely rewritten
+ * @ingroup API
+ */
+class ApiHelp extends ApiBase {
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $modules = [];
+
+ foreach ( $params['modules'] as $path ) {
+ $modules[] = $this->getModuleFromPath( $path );
+ }
+
+ // Get the help
+ $context = new DerivativeContext( $this->getMain()->getContext() );
+ $context->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'apioutput' ) );
+ $context->setLanguage( $this->getMain()->getLanguage() );
+ $context->setTitle( SpecialPage::getTitleFor( 'ApiHelp' ) );
+ $out = new OutputPage( $context );
+ $out->setCopyrightUrl( 'https://www.mediawiki.org/wiki/Special:MyLanguage/Copyright' );
+ $context->setOutput( $out );
+
+ self::getHelp( $context, $modules, $params );
+
+ // Grab the output from the skin
+ ob_start();
+ $context->getOutput()->output();
+ $html = ob_get_clean();
+
+ $result = $this->getResult();
+ if ( $params['wrap'] ) {
+ $data = [
+ 'mime' => 'text/html',
+ 'filename' => 'api-help.html',
+ 'help' => $html,
+ ];
+ ApiResult::setSubelementsList( $data, 'help' );
+ $result->addValue( null, $this->getModuleName(), $data );
+ } else {
+ $result->reset();
+ $result->addValue( null, 'text', $html, ApiResult::NO_SIZE_CHECK );
+ $result->addValue( null, 'mime', 'text/html', ApiResult::NO_SIZE_CHECK );
+ $result->addValue( null, 'filename', 'api-help.html', ApiResult::NO_SIZE_CHECK );
+ }
+ }
+
+ /**
+ * Generate help for the specified modules
+ *
+ * Help is placed into the OutputPage object returned by
+ * $context->getOutput().
+ *
+ * Recognized options include:
+ * - headerlevel: (int) Header tag level
+ * - nolead: (bool) Skip the inclusion of api-help-lead
+ * - noheader: (bool) Skip the inclusion of the top-level section headers
+ * - submodules: (bool) Include help for submodules of the current module
+ * - recursivesubmodules: (bool) Include help for submodules recursively
+ * - helptitle: (string) Title to link for additional modules' help. Should contain $1.
+ * - toc: (bool) Include a table of contents
+ *
+ * @param IContextSource $context
+ * @param ApiBase[]|ApiBase $modules
+ * @param array $options Formatting options (described above)
+ */
+ public static function getHelp( IContextSource $context, $modules, array $options ) {
+ global $wgContLang;
+
+ if ( !is_array( $modules ) ) {
+ $modules = [ $modules ];
+ }
+
+ $out = $context->getOutput();
+ $out->addModuleStyles( [
+ 'mediawiki.hlist',
+ 'mediawiki.apihelp',
+ ] );
+ if ( !empty( $options['toc'] ) ) {
+ $out->addModules( 'mediawiki.toc' );
+ }
+ $out->setPageTitle( $context->msg( 'api-help-title' ) );
+
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $cacheKey = null;
+ if ( count( $modules ) == 1 && $modules[0] instanceof ApiMain &&
+ $options['recursivesubmodules'] && $context->getLanguage() === $wgContLang
+ ) {
+ $cacheHelpTimeout = $context->getConfig()->get( 'APICacheHelpTimeout' );
+ if ( $cacheHelpTimeout > 0 ) {
+ // Get help text from cache if present
+ $cacheKey = $cache->makeKey( 'apihelp', $modules[0]->getModulePath(),
+ (int)!empty( $options['toc'] ),
+ str_replace( ' ', '_', SpecialVersion::getVersion( 'nodb' ) ) );
+ $cached = $cache->get( $cacheKey );
+ if ( $cached ) {
+ $out->addHTML( $cached );
+ return;
+ }
+ }
+ }
+ if ( $out->getHTML() !== '' ) {
+ // Don't save to cache, there's someone else's content in the page
+ // already
+ $cacheKey = null;
+ }
+
+ $options['recursivesubmodules'] = !empty( $options['recursivesubmodules'] );
+ $options['submodules'] = $options['recursivesubmodules'] || !empty( $options['submodules'] );
+
+ // Prepend lead
+ if ( empty( $options['nolead'] ) ) {
+ $msg = $context->msg( 'api-help-lead' );
+ if ( !$msg->isDisabled() ) {
+ $out->addHTML( $msg->parseAsBlock() );
+ }
+ }
+
+ $haveModules = [];
+ $html = self::getHelpInternal( $context, $modules, $options, $haveModules );
+ if ( !empty( $options['toc'] ) && $haveModules ) {
+ $out->addHTML( Linker::generateTOC( $haveModules, $context->getLanguage() ) );
+ }
+ $out->addHTML( $html );
+
+ $helptitle = isset( $options['helptitle'] ) ? $options['helptitle'] : null;
+ $html = self::fixHelpLinks( $out->getHTML(), $helptitle, $haveModules );
+ $out->clearHTML();
+ $out->addHTML( $html );
+
+ if ( $cacheKey !== null ) {
+ $cache->set( $cacheKey, $out->getHTML(), $cacheHelpTimeout );
+ }
+ }
+
+ /**
+ * Replace Special:ApiHelp links with links to api.php
+ *
+ * @param string $html
+ * @param string|null $helptitle Title to link to rather than api.php, must contain '$1'
+ * @param array $localModules Keys are modules to link within the current page, values are ignored
+ * @return string
+ */
+ public static function fixHelpLinks( $html, $helptitle = null, $localModules = [] ) {
+ $formatter = new HtmlFormatter( $html );
+ $doc = $formatter->getDoc();
+ $xpath = new DOMXPath( $doc );
+ $nodes = $xpath->query( '//a[@href][not(contains(@class,\'apihelp-linktrail\'))]' );
+ foreach ( $nodes as $node ) {
+ $href = $node->getAttribute( 'href' );
+ do {
+ $old = $href;
+ $href = rawurldecode( $href );
+ } while ( $old !== $href );
+ if ( preg_match( '!Special:ApiHelp/([^&/|#]+)((?:#.*)?)!', $href, $m ) ) {
+ if ( isset( $localModules[$m[1]] ) ) {
+ $href = $m[2] === '' ? '#' . $m[1] : $m[2];
+ } elseif ( $helptitle !== null ) {
+ $href = Title::newFromText( str_replace( '$1', $m[1], $helptitle ) . $m[2] )
+ ->getFullURL();
+ } else {
+ $href = wfAppendQuery( wfScript( 'api' ), [
+ 'action' => 'help',
+ 'modules' => $m[1],
+ ] ) . $m[2];
+ }
+ $node->setAttribute( 'href', $href );
+ $node->removeAttribute( 'title' );
+ }
+ }
+
+ return $formatter->getText();
+ }
+
+ /**
+ * Wrap a message in HTML with a class.
+ *
+ * @param Message $msg
+ * @param string $class
+ * @param string $tag
+ * @return string
+ */
+ private static function wrap( Message $msg, $class, $tag = 'span' ) {
+ return Html::rawElement( $tag, [ 'class' => $class ],
+ $msg->parse()
+ );
+ }
+
+ /**
+ * Recursively-called function to actually construct the help
+ *
+ * @param IContextSource $context
+ * @param ApiBase[] $modules
+ * @param array $options
+ * @param array &$haveModules
+ * @return string
+ */
+ private static function getHelpInternal( IContextSource $context, array $modules,
+ array $options, &$haveModules
+ ) {
+ $out = '';
+
+ $level = empty( $options['headerlevel'] ) ? 2 : $options['headerlevel'];
+ if ( empty( $options['tocnumber'] ) ) {
+ $tocnumber = [ 2 => 0 ];
+ } else {
+ $tocnumber = &$options['tocnumber'];
+ }
+
+ foreach ( $modules as $module ) {
+ $tocnumber[$level]++;
+ $path = $module->getModulePath();
+ $module->setContext( $context );
+ $help = [
+ 'header' => '',
+ 'flags' => '',
+ 'description' => '',
+ 'help-urls' => '',
+ 'parameters' => '',
+ 'examples' => '',
+ 'submodules' => '',
+ ];
+
+ if ( empty( $options['noheader'] ) || !empty( $options['toc'] ) ) {
+ $anchor = $path;
+ $i = 1;
+ while ( isset( $haveModules[$anchor] ) ) {
+ $anchor = $path . '|' . ++$i;
+ }
+
+ if ( $module->isMain() ) {
+ $headerContent = $context->msg( 'api-help-main-header' )->parse();
+ $headerAttr = [
+ 'class' => 'apihelp-header',
+ ];
+ } else {
+ $name = $module->getModuleName();
+ $headerContent = $module->getParent()->getModuleManager()->getModuleGroup( $name ) .
+ "=$name";
+ if ( $module->getModulePrefix() !== '' ) {
+ $headerContent .= ' ' .
+ $context->msg( 'parentheses', $module->getModulePrefix() )->parse();
+ }
+ // Module names are always in English and not localized,
+ // so English language and direction must be set explicitly,
+ // otherwise parentheses will get broken in RTL wikis
+ $headerAttr = [
+ 'class' => 'apihelp-header apihelp-module-name',
+ 'dir' => 'ltr',
+ 'lang' => 'en',
+ ];
+ }
+
+ $headerAttr['id'] = $anchor;
+
+ $haveModules[$anchor] = [
+ 'toclevel' => count( $tocnumber ),
+ 'level' => $level,
+ 'anchor' => $anchor,
+ 'line' => $headerContent,
+ 'number' => implode( '.', $tocnumber ),
+ 'index' => false,
+ ];
+ if ( empty( $options['noheader'] ) ) {
+ $help['header'] .= Html::element(
+ 'h' . min( 6, $level ),
+ $headerAttr,
+ $headerContent
+ );
+ }
+ } else {
+ $haveModules[$path] = true;
+ }
+
+ $links = [];
+ $any = false;
+ for ( $m = $module; $m !== null; $m = $m->getParent() ) {
+ $name = $m->getModuleName();
+ if ( $name === 'main_int' ) {
+ $name = 'main';
+ }
+
+ if ( count( $modules ) === 1 && $m === $modules[0] &&
+ !( !empty( $options['submodules'] ) && $m->getModuleManager() )
+ ) {
+ $link = Html::element( 'b', [ 'dir' => 'ltr', 'lang' => 'en' ], $name );
+ } else {
+ $link = SpecialPage::getTitleFor( 'ApiHelp', $m->getModulePath() )->getLocalURL();
+ $link = Html::element( 'a',
+ [ 'href' => $link, 'class' => 'apihelp-linktrail', 'dir' => 'ltr', 'lang' => 'en' ],
+ $name
+ );
+ $any = true;
+ }
+ array_unshift( $links, $link );
+ }
+ if ( $any ) {
+ $help['header'] .= self::wrap(
+ $context->msg( 'parentheses' )
+ ->rawParams( $context->getLanguage()->pipeList( $links ) ),
+ 'apihelp-linktrail', 'div'
+ );
+ }
+
+ $flags = $module->getHelpFlags();
+ $help['flags'] .= Html::openElement( 'div',
+ [ 'class' => 'apihelp-block apihelp-flags' ] );
+ $msg = $context->msg( 'api-help-flags' );
+ if ( !$msg->isDisabled() ) {
+ $help['flags'] .= self::wrap(
+ $msg->numParams( count( $flags ) ), 'apihelp-block-head', 'div'
+ );
+ }
+ $help['flags'] .= Html::openElement( 'ul' );
+ foreach ( $flags as $flag ) {
+ $help['flags'] .= Html::rawElement( 'li', null,
+ self::wrap( $context->msg( "api-help-flag-$flag" ), "apihelp-flag-$flag" )
+ );
+ }
+ $sourceInfo = $module->getModuleSourceInfo();
+ if ( $sourceInfo ) {
+ if ( isset( $sourceInfo['namemsg'] ) ) {
+ $extname = $context->msg( $sourceInfo['namemsg'] )->text();
+ } else {
+ // Probably English, so wrap it.
+ $extname = Html::element( 'span', [ 'dir' => 'ltr', 'lang' => 'en' ], $sourceInfo['name'] );
+ }
+ $help['flags'] .= Html::rawElement( 'li', null,
+ self::wrap(
+ $context->msg( 'api-help-source', $extname, $sourceInfo['name'] ),
+ 'apihelp-source'
+ )
+ );
+
+ $link = SpecialPage::getTitleFor( 'Version', 'License/' . $sourceInfo['name'] );
+ if ( isset( $sourceInfo['license-name'] ) ) {
+ $msg = $context->msg( 'api-help-license', $link,
+ Html::element( 'span', [ 'dir' => 'ltr', 'lang' => 'en' ], $sourceInfo['license-name'] )
+ );
+ } elseif ( SpecialVersion::getExtLicenseFileName( dirname( $sourceInfo['path'] ) ) ) {
+ $msg = $context->msg( 'api-help-license-noname', $link );
+ } else {
+ $msg = $context->msg( 'api-help-license-unknown' );
+ }
+ $help['flags'] .= Html::rawElement( 'li', null,
+ self::wrap( $msg, 'apihelp-license' )
+ );
+ } else {
+ $help['flags'] .= Html::rawElement( 'li', null,
+ self::wrap( $context->msg( 'api-help-source-unknown' ), 'apihelp-source' )
+ );
+ $help['flags'] .= Html::rawElement( 'li', null,
+ self::wrap( $context->msg( 'api-help-license-unknown' ), 'apihelp-license' )
+ );
+ }
+ $help['flags'] .= Html::closeElement( 'ul' );
+ $help['flags'] .= Html::closeElement( 'div' );
+
+ foreach ( $module->getFinalDescription() as $msg ) {
+ $msg->setContext( $context );
+ $help['description'] .= $msg->parseAsBlock();
+ }
+
+ $urls = $module->getHelpUrls();
+ if ( $urls ) {
+ $help['help-urls'] .= Html::openElement( 'div',
+ [ 'class' => 'apihelp-block apihelp-help-urls' ]
+ );
+ $msg = $context->msg( 'api-help-help-urls' );
+ if ( !$msg->isDisabled() ) {
+ $help['help-urls'] .= self::wrap(
+ $msg->numParams( count( $urls ) ), 'apihelp-block-head', 'div'
+ );
+ }
+ if ( !is_array( $urls ) ) {
+ $urls = [ $urls ];
+ }
+ $help['help-urls'] .= Html::openElement( 'ul' );
+ foreach ( $urls as $url ) {
+ $help['help-urls'] .= Html::rawElement( 'li', null,
+ Html::element( 'a', [ 'href' => $url, 'dir' => 'ltr' ], $url )
+ );
+ }
+ $help['help-urls'] .= Html::closeElement( 'ul' );
+ $help['help-urls'] .= Html::closeElement( 'div' );
+ }
+
+ $params = $module->getFinalParams( ApiBase::GET_VALUES_FOR_HELP );
+ $dynamicParams = $module->dynamicParameterDocumentation();
+ $groups = [];
+ if ( $params || $dynamicParams !== null ) {
+ $help['parameters'] .= Html::openElement( 'div',
+ [ 'class' => 'apihelp-block apihelp-parameters' ]
+ );
+ $msg = $context->msg( 'api-help-parameters' );
+ if ( !$msg->isDisabled() ) {
+ $help['parameters'] .= self::wrap(
+ $msg->numParams( count( $params ) ), 'apihelp-block-head', 'div'
+ );
+ }
+ $help['parameters'] .= Html::openElement( 'dl' );
+
+ $descriptions = $module->getFinalParamDescription();
+
+ foreach ( $params as $name => $settings ) {
+ if ( !is_array( $settings ) ) {
+ $settings = [ ApiBase::PARAM_DFLT => $settings ];
+ }
+
+ $help['parameters'] .= Html::rawElement( 'dt', null,
+ Html::element( 'span', [ 'dir' => 'ltr', 'lang' => 'en' ], $module->encodeParamName( $name ) )
+ );
+
+ // Add description
+ $description = [];
+ if ( isset( $descriptions[$name] ) ) {
+ foreach ( $descriptions[$name] as $msg ) {
+ $msg->setContext( $context );
+ $description[] = $msg->parseAsBlock();
+ }
+ }
+
+ // Add usage info
+ $info = [];
+
+ // Required?
+ if ( !empty( $settings[ApiBase::PARAM_REQUIRED] ) ) {
+ $info[] = $context->msg( 'api-help-param-required' )->parse();
+ }
+
+ // Custom info?
+ if ( !empty( $settings[ApiBase::PARAM_HELP_MSG_INFO] ) ) {
+ foreach ( $settings[ApiBase::PARAM_HELP_MSG_INFO] as $i ) {
+ $tag = array_shift( $i );
+ $info[] = $context->msg( "apihelp-{$path}-paraminfo-{$tag}" )
+ ->numParams( count( $i ) )
+ ->params( $context->getLanguage()->commaList( $i ) )
+ ->params( $module->getModulePrefix() )
+ ->parse();
+ }
+ }
+
+ // Type documentation
+ if ( !isset( $settings[ApiBase::PARAM_TYPE] ) ) {
+ $dflt = isset( $settings[ApiBase::PARAM_DFLT] )
+ ? $settings[ApiBase::PARAM_DFLT]
+ : null;
+ if ( is_bool( $dflt ) ) {
+ $settings[ApiBase::PARAM_TYPE] = 'boolean';
+ } elseif ( is_string( $dflt ) || is_null( $dflt ) ) {
+ $settings[ApiBase::PARAM_TYPE] = 'string';
+ } elseif ( is_int( $dflt ) ) {
+ $settings[ApiBase::PARAM_TYPE] = 'integer';
+ }
+ }
+ if ( isset( $settings[ApiBase::PARAM_TYPE] ) ) {
+ $type = $settings[ApiBase::PARAM_TYPE];
+ $multi = !empty( $settings[ApiBase::PARAM_ISMULTI] );
+ $hintPipeSeparated = true;
+ $count = !empty( $settings[ApiBase::PARAM_ISMULTI_LIMIT2] )
+ ? $settings[ApiBase::PARAM_ISMULTI_LIMIT2] + 1
+ : ApiBase::LIMIT_SML2 + 1;
+
+ if ( is_array( $type ) ) {
+ $count = count( $type );
+ $deprecatedValues = isset( $settings[ApiBase::PARAM_DEPRECATED_VALUES] )
+ ? $settings[ApiBase::PARAM_DEPRECATED_VALUES]
+ : [];
+ $links = isset( $settings[ApiBase::PARAM_VALUE_LINKS] )
+ ? $settings[ApiBase::PARAM_VALUE_LINKS]
+ : [];
+ $values = array_map( function ( $v ) use ( $links, $deprecatedValues ) {
+ $attr = [];
+ if ( $v !== '' ) {
+ // We can't know whether this contains LTR or RTL text.
+ $attr['dir'] = 'auto';
+ }
+ if ( isset( $deprecatedValues[$v] ) ) {
+ $attr['class'] = 'apihelp-deprecated-value';
+ }
+ $ret = $attr ? Html::element( 'span', $attr, $v ) : $v;
+ if ( isset( $links[$v] ) ) {
+ $ret = "[[{$links[$v]}|$ret]]";
+ }
+ return $ret;
+ }, $type );
+ $i = array_search( '', $type, true );
+ if ( $i === false ) {
+ $values = $context->getLanguage()->commaList( $values );
+ } else {
+ unset( $values[$i] );
+ $values = $context->msg( 'api-help-param-list-can-be-empty' )
+ ->numParams( count( $values ) )
+ ->params( $context->getLanguage()->commaList( $values ) )
+ ->parse();
+ }
+ $info[] = $context->msg( 'api-help-param-list' )
+ ->params( $multi ? 2 : 1 )
+ ->params( $values )
+ ->parse();
+ $hintPipeSeparated = false;
+ } else {
+ switch ( $type ) {
+ case 'submodule':
+ $groups[] = $name;
+
+ if ( isset( $settings[ApiBase::PARAM_SUBMODULE_MAP] ) ) {
+ $map = $settings[ApiBase::PARAM_SUBMODULE_MAP];
+ $defaultAttrs = [];
+ } else {
+ $prefix = $module->isMain() ? '' : ( $module->getModulePath() . '+' );
+ $map = [];
+ foreach ( $module->getModuleManager()->getNames( $name ) as $submoduleName ) {
+ $map[$submoduleName] = $prefix . $submoduleName;
+ }
+ $defaultAttrs = [ 'dir' => 'ltr', 'lang' => 'en' ];
+ }
+ ksort( $map );
+
+ $submodules = [];
+ $deprecatedSubmodules = [];
+ foreach ( $map as $v => $m ) {
+ $attrs = $defaultAttrs;
+ $arr = &$submodules;
+ try {
+ $submod = $module->getModuleFromPath( $m );
+ if ( $submod ) {
+ if ( $submod->isDeprecated() ) {
+ $arr = &$deprecatedSubmodules;
+ $attrs['class'] = 'apihelp-deprecated-value';
+ }
+ }
+ } catch ( ApiUsageException $ex ) {
+ // Ignore
+ }
+ if ( $attrs ) {
+ $v = Html::element( 'span', $attrs, $v );
+ }
+ $arr[] = "[[Special:ApiHelp/{$m}|{$v}]]";
+ }
+ $submodules = array_merge( $submodules, $deprecatedSubmodules );
+ $count = count( $submodules );
+ $info[] = $context->msg( 'api-help-param-list' )
+ ->params( $multi ? 2 : 1 )
+ ->params( $context->getLanguage()->commaList( $submodules ) )
+ ->parse();
+ $hintPipeSeparated = false;
+ // No type message necessary, we have a list of values.
+ $type = null;
+ break;
+
+ case 'namespace':
+ $namespaces = MWNamespace::getValidNamespaces();
+ if ( isset( $settings[ApiBase::PARAM_EXTRA_NAMESPACES] ) &&
+ is_array( $settings[ApiBase::PARAM_EXTRA_NAMESPACES] )
+ ) {
+ $namespaces = array_merge( $namespaces, $settings[ApiBase::PARAM_EXTRA_NAMESPACES] );
+ }
+ sort( $namespaces );
+ $count = count( $namespaces );
+ $info[] = $context->msg( 'api-help-param-list' )
+ ->params( $multi ? 2 : 1 )
+ ->params( $context->getLanguage()->commaList( $namespaces ) )
+ ->parse();
+ $hintPipeSeparated = false;
+ // No type message necessary, we have a list of values.
+ $type = null;
+ break;
+
+ case 'tags':
+ $tags = ChangeTags::listExplicitlyDefinedTags();
+ $count = count( $tags );
+ $info[] = $context->msg( 'api-help-param-list' )
+ ->params( $multi ? 2 : 1 )
+ ->params( $context->getLanguage()->commaList( $tags ) )
+ ->parse();
+ $hintPipeSeparated = false;
+ $type = null;
+ break;
+
+ case 'limit':
+ if ( isset( $settings[ApiBase::PARAM_MAX2] ) ) {
+ $info[] = $context->msg( 'api-help-param-limit2' )
+ ->numParams( $settings[ApiBase::PARAM_MAX] )
+ ->numParams( $settings[ApiBase::PARAM_MAX2] )
+ ->parse();
+ } else {
+ $info[] = $context->msg( 'api-help-param-limit' )
+ ->numParams( $settings[ApiBase::PARAM_MAX] )
+ ->parse();
+ }
+ break;
+
+ case 'integer':
+ // Possible messages:
+ // api-help-param-integer-min,
+ // api-help-param-integer-max,
+ // api-help-param-integer-minmax
+ $suffix = '';
+ $min = $max = 0;
+ if ( isset( $settings[ApiBase::PARAM_MIN] ) ) {
+ $suffix .= 'min';
+ $min = $settings[ApiBase::PARAM_MIN];
+ }
+ if ( isset( $settings[ApiBase::PARAM_MAX] ) ) {
+ $suffix .= 'max';
+ $max = $settings[ApiBase::PARAM_MAX];
+ }
+ if ( $suffix !== '' ) {
+ $info[] =
+ $context->msg( "api-help-param-integer-$suffix" )
+ ->params( $multi ? 2 : 1 )
+ ->numParams( $min, $max )
+ ->parse();
+ }
+ break;
+
+ case 'upload':
+ $info[] = $context->msg( 'api-help-param-upload' )
+ ->parse();
+ // No type message necessary, api-help-param-upload should handle it.
+ $type = null;
+ break;
+
+ case 'string':
+ case 'text':
+ // Displaying a type message here would be useless.
+ $type = null;
+ break;
+ }
+ }
+
+ // Add type. Messages for grep: api-help-param-type-limit
+ // api-help-param-type-integer api-help-param-type-boolean
+ // api-help-param-type-timestamp api-help-param-type-user
+ // api-help-param-type-password
+ if ( is_string( $type ) ) {
+ $msg = $context->msg( "api-help-param-type-$type" );
+ if ( !$msg->isDisabled() ) {
+ $info[] = $msg->params( $multi ? 2 : 1 )->parse();
+ }
+ }
+
+ if ( $multi ) {
+ $extra = [];
+ $lowcount = !empty( $settings[ApiBase::PARAM_ISMULTI_LIMIT1] )
+ ? $settings[ApiBase::PARAM_ISMULTI_LIMIT1]
+ : ApiBase::LIMIT_SML1;
+ $highcount = !empty( $settings[ApiBase::PARAM_ISMULTI_LIMIT2] )
+ ? $settings[ApiBase::PARAM_ISMULTI_LIMIT2]
+ : ApiBase::LIMIT_SML2;
+
+ if ( $hintPipeSeparated ) {
+ $extra[] = $context->msg( 'api-help-param-multi-separate' )->parse();
+ }
+ if ( $count > $lowcount ) {
+ if ( $lowcount === $highcount ) {
+ $msg = $context->msg( 'api-help-param-multi-max-simple' )
+ ->numParams( $lowcount );
+ } else {
+ $msg = $context->msg( 'api-help-param-multi-max' )
+ ->numParams( $lowcount, $highcount );
+ }
+ $extra[] = $msg->parse();
+ }
+ if ( $extra ) {
+ $info[] = implode( ' ', $extra );
+ }
+
+ $allowAll = isset( $settings[ApiBase::PARAM_ALL] )
+ ? $settings[ApiBase::PARAM_ALL]
+ : false;
+ if ( $allowAll || $settings[ApiBase::PARAM_TYPE] === 'namespace' ) {
+ if ( $settings[ApiBase::PARAM_TYPE] === 'namespace' ) {
+ $allSpecifier = ApiBase::ALL_DEFAULT_STRING;
+ } else {
+ $allSpecifier = ( is_string( $allowAll ) ? $allowAll : ApiBase::ALL_DEFAULT_STRING );
+ }
+ $info[] = $context->msg( 'api-help-param-multi-all' )
+ ->params( $allSpecifier )
+ ->parse();
+ }
+ }
+ }
+
+ // Add default
+ $default = isset( $settings[ApiBase::PARAM_DFLT] )
+ ? $settings[ApiBase::PARAM_DFLT]
+ : null;
+ if ( $default === '' ) {
+ $info[] = $context->msg( 'api-help-param-default-empty' )
+ ->parse();
+ } elseif ( $default !== null && $default !== false ) {
+ // We can't know whether this contains LTR or RTL text.
+ $info[] = $context->msg( 'api-help-param-default' )
+ ->params( Html::element( 'span', [ 'dir' => 'auto' ], $default ) )
+ ->parse();
+ }
+
+ if ( !array_filter( $description ) ) {
+ $description = [ self::wrap(
+ $context->msg( 'api-help-param-no-description' ),
+ 'apihelp-empty'
+ ) ];
+ }
+
+ // Add "deprecated" flag
+ if ( !empty( $settings[ApiBase::PARAM_DEPRECATED] ) ) {
+ $help['parameters'] .= Html::openElement( 'dd',
+ [ 'class' => 'info' ] );
+ $help['parameters'] .= self::wrap(
+ $context->msg( 'api-help-param-deprecated' ),
+ 'apihelp-deprecated', 'strong'
+ );
+ $help['parameters'] .= Html::closeElement( 'dd' );
+ }
+
+ if ( $description ) {
+ $description = implode( '', $description );
+ $description = preg_replace( '!\s*</([oud]l)>\s*<\1>\s*!', "\n", $description );
+ $help['parameters'] .= Html::rawElement( 'dd',
+ [ 'class' => 'description' ], $description );
+ }
+
+ foreach ( $info as $i ) {
+ $help['parameters'] .= Html::rawElement( 'dd', [ 'class' => 'info' ], $i );
+ }
+ }
+
+ if ( $dynamicParams !== null ) {
+ $dynamicParams = ApiBase::makeMessage( $dynamicParams, $context, [
+ $module->getModulePrefix(),
+ $module->getModuleName(),
+ $module->getModulePath()
+ ] );
+ $help['parameters'] .= Html::element( 'dt', null, '*' );
+ $help['parameters'] .= Html::rawElement( 'dd',
+ [ 'class' => 'description' ], $dynamicParams->parse() );
+ }
+
+ $help['parameters'] .= Html::closeElement( 'dl' );
+ $help['parameters'] .= Html::closeElement( 'div' );
+ }
+
+ $examples = $module->getExamplesMessages();
+ if ( $examples ) {
+ $help['examples'] .= Html::openElement( 'div',
+ [ 'class' => 'apihelp-block apihelp-examples' ] );
+ $msg = $context->msg( 'api-help-examples' );
+ if ( !$msg->isDisabled() ) {
+ $help['examples'] .= self::wrap(
+ $msg->numParams( count( $examples ) ), 'apihelp-block-head', 'div'
+ );
+ }
+
+ $help['examples'] .= Html::openElement( 'dl' );
+ foreach ( $examples as $qs => $msg ) {
+ $msg = ApiBase::makeMessage( $msg, $context, [
+ $module->getModulePrefix(),
+ $module->getModuleName(),
+ $module->getModulePath()
+ ] );
+
+ $link = wfAppendQuery( wfScript( 'api' ), $qs );
+ $sandbox = SpecialPage::getTitleFor( 'ApiSandbox' )->getLocalURL() . '#' . $qs;
+ $help['examples'] .= Html::rawElement( 'dt', null, $msg->parse() );
+ $help['examples'] .= Html::rawElement( 'dd', null,
+ Html::element( 'a', [ 'href' => $link, 'dir' => 'ltr' ], "api.php?$qs" ) . ' ' .
+ Html::rawElement( 'a', [ 'href' => $sandbox ],
+ $context->msg( 'api-help-open-in-apisandbox' )->parse() )
+ );
+ }
+ $help['examples'] .= Html::closeElement( 'dl' );
+ $help['examples'] .= Html::closeElement( 'div' );
+ }
+
+ $subtocnumber = $tocnumber;
+ $subtocnumber[$level + 1] = 0;
+ $suboptions = [
+ 'submodules' => $options['recursivesubmodules'],
+ 'headerlevel' => $level + 1,
+ 'tocnumber' => &$subtocnumber,
+ 'noheader' => false,
+ ] + $options;
+
+ if ( $options['submodules'] && $module->getModuleManager() ) {
+ $manager = $module->getModuleManager();
+ $submodules = [];
+ foreach ( $groups as $group ) {
+ $names = $manager->getNames( $group );
+ sort( $names );
+ foreach ( $names as $name ) {
+ $submodules[] = $manager->getModule( $name );
+ }
+ }
+ $help['submodules'] .= self::getHelpInternal(
+ $context,
+ $submodules,
+ $suboptions,
+ $haveModules
+ );
+ }
+
+ $module->modifyHelp( $help, $suboptions, $haveModules );
+
+ Hooks::run( 'APIHelpModifyOutput', [ $module, &$help, $suboptions, &$haveModules ] );
+
+ $out .= implode( "\n", $help );
+ }
+
+ return $out;
+ }
+
+ public function shouldCheckMaxlag() {
+ return false;
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ public function getCustomPrinter() {
+ $params = $this->extractRequestParams();
+ if ( $params['wrap'] ) {
+ return null;
+ }
+
+ $main = $this->getMain();
+ $errorPrinter = $main->createPrinterByName( $main->getParameter( 'format' ) );
+ return new ApiFormatRaw( $main, $errorPrinter );
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'modules' => [
+ ApiBase::PARAM_DFLT => 'main',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'submodules' => false,
+ 'recursivesubmodules' => false,
+ 'wrap' => false,
+ 'toc' => false,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=help'
+ => 'apihelp-help-example-main',
+ 'action=help&modules=query&submodules=1'
+ => 'apihelp-help-example-submodules',
+ 'action=help&recursivesubmodules=1'
+ => 'apihelp-help-example-recursive',
+ 'action=help&modules=help'
+ => 'apihelp-help-example-help',
+ 'action=help&modules=query+info|query+categorymembers'
+ => 'apihelp-help-example-query',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return [
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Main_page',
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:FAQ',
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Quick_start_guide',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiHelpParamValueMessage.php b/www/wiki/includes/api/ApiHelpParamValueMessage.php
new file mode 100644
index 00000000..162b7cd6
--- /dev/null
+++ b/www/wiki/includes/api/ApiHelpParamValueMessage.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ *
+ *
+ * Created on Dec 22, 2014
+ *
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Message subclass that prepends wikitext for API help.
+ *
+ * This exists so the apihelp-*-paramvalue-*-* messages don't all have to
+ * include markup wikitext while still keeping the
+ * 'APIGetParamDescriptionMessages' hook simple.
+ *
+ * @since 1.25
+ */
+class ApiHelpParamValueMessage extends Message {
+
+ protected $paramValue;
+ protected $deprecated;
+
+ /**
+ * @see Message::__construct
+ *
+ * @param string $paramValue Parameter value being documented
+ * @param string $text Message to use.
+ * @param array $params Parameters for the message.
+ * @param bool $deprecated Whether the value is deprecated
+ * @throws InvalidArgumentException
+ * @since 1.30 Added the `$deprecated` parameter
+ */
+ public function __construct( $paramValue, $text, $params = [], $deprecated = false ) {
+ parent::__construct( $text, $params );
+ $this->paramValue = $paramValue;
+ $this->deprecated = (bool)$deprecated;
+ }
+
+ /**
+ * Fetch the parameter value
+ * @return string
+ */
+ public function getParamValue() {
+ return $this->paramValue;
+ }
+
+ /**
+ * Fetch the 'deprecated' flag
+ * @since 1.30
+ * @return bool
+ */
+ public function isDeprecated() {
+ return $this->deprecated;
+ }
+
+ /**
+ * Fetch the message.
+ * @return string
+ */
+ public function fetchMessage() {
+ if ( $this->message === null ) {
+ $dep = '';
+ if ( $this->isDeprecated() ) {
+ $msg = new Message( 'api-help-param-deprecated' );
+ $msg->interface = $this->interface;
+ $msg->language = $this->language;
+ $msg->useDatabase = $this->useDatabase;
+ $msg->title = $this->title;
+ $dep = '<span class="apihelp-deprecated">' . $msg->fetchMessage() . '</span> ';
+ }
+ $this->message = ";<span dir=\"ltr\" lang=\"en\">{$this->paramValue}</span>:"
+ . $dep . parent::fetchMessage();
+ }
+ return $this->message;
+ }
+
+}
diff --git a/www/wiki/includes/api/ApiImageRotate.php b/www/wiki/includes/api/ApiImageRotate.php
new file mode 100644
index 00000000..71bda6d7
--- /dev/null
+++ b/www/wiki/includes/api/ApiImageRotate.php
@@ -0,0 +1,201 @@
+<?php
+/**
+ *
+ * Created on January 3rd, 2013
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class ApiImageRotate extends ApiBase {
+ private $mPageSet = null;
+
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $params = $this->extractRequestParams();
+ $rotation = $params['rotation'];
+
+ $continuationManager = new ApiContinuationManager( $this, [], [] );
+ $this->setContinuationManager( $continuationManager );
+
+ $pageSet = $this->getPageSet();
+ $pageSet->execute();
+
+ $result = [];
+
+ $result = $pageSet->getInvalidTitlesAndRevisions( [
+ 'invalidTitles', 'special', 'missingIds', 'missingRevIds', 'interwikiTitles',
+ ] );
+
+ // Check if user can add tags
+ if ( count( $params['tags'] ) ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $this->getUser() );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ foreach ( $pageSet->getTitles() as $title ) {
+ $r = [];
+ $r['id'] = $title->getArticleID();
+ ApiQueryBase::addTitleInfo( $r, $title );
+ if ( !$title->exists() ) {
+ $r['missing'] = true;
+ if ( $title->isKnown() ) {
+ $r['known'] = true;
+ }
+ }
+
+ $file = wfFindFile( $title, [ 'latest' => true ] );
+ if ( !$file ) {
+ $r['result'] = 'Failure';
+ $r['errors'] = $this->getErrorFormatter()->arrayFromStatus(
+ Status::newFatal( 'apierror-filedoesnotexist' )
+ );
+ $result[] = $r;
+ continue;
+ }
+ $handler = $file->getHandler();
+ if ( !$handler || !$handler->canRotate() ) {
+ $r['result'] = 'Failure';
+ $r['errors'] = $this->getErrorFormatter()->arrayFromStatus(
+ Status::newFatal( 'apierror-filetypecannotberotated' )
+ );
+ $result[] = $r;
+ continue;
+ }
+
+ // Check whether we're allowed to rotate this file
+ $permError = $this->checkTitleUserPermissions( $file->getTitle(), [ 'edit', 'upload' ] );
+ if ( $permError ) {
+ $r['result'] = 'Failure';
+ $r['errors'] = $this->getErrorFormatter()->arrayFromStatus(
+ $this->errorArrayToStatus( $permError )
+ );
+ $result[] = $r;
+ continue;
+ }
+
+ $srcPath = $file->getLocalRefPath();
+ if ( $srcPath === false ) {
+ $r['result'] = 'Failure';
+ $r['errors'] = $this->getErrorFormatter()->arrayFromStatus(
+ Status::newFatal( 'apierror-filenopath' )
+ );
+ $result[] = $r;
+ continue;
+ }
+ $ext = strtolower( pathinfo( "$srcPath", PATHINFO_EXTENSION ) );
+ $tmpFile = TempFSFile::factory( 'rotate_', $ext, wfTempDir() );
+ $dstPath = $tmpFile->getPath();
+ $err = $handler->rotate( $file, [
+ 'srcPath' => $srcPath,
+ 'dstPath' => $dstPath,
+ 'rotation' => $rotation
+ ] );
+ if ( !$err ) {
+ $comment = wfMessage(
+ 'rotate-comment'
+ )->numParams( $rotation )->inContentLanguage()->text();
+ $status = $file->upload(
+ $dstPath,
+ $comment,
+ $comment,
+ 0,
+ false,
+ false,
+ $this->getUser(),
+ $params['tags'] ?: []
+ );
+ if ( $status->isGood() ) {
+ $r['result'] = 'Success';
+ } else {
+ $r['result'] = 'Failure';
+ $r['errors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
+ }
+ } else {
+ $r['result'] = 'Failure';
+ $r['errors'] = $this->getErrorFormatter()->arrayFromStatus(
+ Status::newFatal( ApiMessage::create( $err->getMsg() ) )
+ );
+ }
+ $result[] = $r;
+ }
+ $apiResult = $this->getResult();
+ ApiResult::setIndexedTagName( $result, 'page' );
+ $apiResult->addValue( null, $this->getModuleName(), $result );
+
+ $this->setContinuationManager( null );
+ $continuationManager->setContinuationIntoResult( $apiResult );
+ }
+
+ /**
+ * Get a cached instance of an ApiPageSet object
+ * @return ApiPageSet
+ */
+ private function getPageSet() {
+ if ( $this->mPageSet === null ) {
+ $this->mPageSet = new ApiPageSet( $this, 0, NS_FILE );
+ }
+
+ return $this->mPageSet;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams( $flags = 0 ) {
+ $result = [
+ 'rotation' => [
+ ApiBase::PARAM_TYPE => [ '90', '180', '270' ],
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ if ( $flags ) {
+ $result += $this->getPageSet()->getFinalParams( $flags );
+ }
+
+ return $result;
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=imagerotate&titles=File:Example.jpg&rotation=90&token=123ABC'
+ => 'apihelp-imagerotate-example-simple',
+ 'action=imagerotate&generator=categorymembers&gcmtitle=Category:Flip&gcmtype=file&' .
+ 'rotation=180&token=123ABC'
+ => 'apihelp-imagerotate-example-generator',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiImport.php b/www/wiki/includes/api/ApiImport.php
new file mode 100644
index 00000000..b46f0b1e
--- /dev/null
+++ b/www/wiki/includes/api/ApiImport.php
@@ -0,0 +1,215 @@
+<?php
+/**
+ *
+ *
+ * Created on Feb 4, 2009
+ *
+ * Copyright © 2009 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that imports an XML file like Special:Import does
+ *
+ * @ingroup API
+ */
+class ApiImport extends ApiBase {
+
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $user = $this->getUser();
+ $params = $this->extractRequestParams();
+
+ $this->requireMaxOneParameter( $params, 'namespace', 'rootpage' );
+
+ $isUpload = false;
+ if ( isset( $params['interwikisource'] ) ) {
+ if ( !$user->isAllowed( 'import' ) ) {
+ $this->dieWithError( 'apierror-cantimport' );
+ }
+ if ( !isset( $params['interwikipage'] ) ) {
+ $this->dieWithError( [ 'apierror-missingparam', 'interwikipage' ] );
+ }
+ $source = ImportStreamSource::newFromInterwiki(
+ $params['interwikisource'],
+ $params['interwikipage'],
+ $params['fullhistory'],
+ $params['templates']
+ );
+ } else {
+ $isUpload = true;
+ if ( !$user->isAllowed( 'importupload' ) ) {
+ $this->dieWithError( 'apierror-cantimport-upload' );
+ }
+ $source = ImportStreamSource::newFromUpload( 'xml' );
+ }
+ if ( !$source->isOK() ) {
+ $this->dieStatus( $source );
+ }
+
+ // Check if user can add the log entry tags which were requested
+ if ( $params['tags'] ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ $importer = new WikiImporter( $source->value, $this->getConfig() );
+ if ( isset( $params['namespace'] ) ) {
+ $importer->setTargetNamespace( $params['namespace'] );
+ } elseif ( isset( $params['rootpage'] ) ) {
+ $statusRootPage = $importer->setTargetRootPage( $params['rootpage'] );
+ if ( !$statusRootPage->isGood() ) {
+ $this->dieStatus( $statusRootPage );
+ }
+ }
+ $reporter = new ApiImportReporter(
+ $importer,
+ $isUpload,
+ $params['interwikisource'],
+ $params['summary']
+ );
+ if ( $params['tags'] ) {
+ $reporter->setChangeTags( $params['tags'] );
+ }
+
+ try {
+ $importer->doImport();
+ } catch ( Exception $e ) {
+ $this->dieWithException( $e, [ 'wrap' => 'apierror-import-unknownerror' ] );
+ }
+
+ $resultData = $reporter->getData();
+ $result = $this->getResult();
+ ApiResult::setIndexedTagName( $resultData, 'page' );
+ $result->addValue( null, $this->getModuleName(), $resultData );
+ }
+
+ /**
+ * Returns a list of interwiki prefixes corresponding to each defined import
+ * source.
+ *
+ * @return array
+ * @since 1.27
+ */
+ public function getAllowedImportSources() {
+ $importSources = $this->getConfig()->get( 'ImportSources' );
+ Hooks::run( 'ImportSources', [ &$importSources ] );
+
+ $result = [];
+ foreach ( $importSources as $key => $value ) {
+ if ( is_int( $key ) ) {
+ $result[] = $value;
+ } else {
+ foreach ( $value as $subproject ) {
+ $result[] = "$key:$subproject";
+ }
+ }
+ }
+ return $result;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'summary' => null,
+ 'xml' => [
+ ApiBase::PARAM_TYPE => 'upload',
+ ],
+ 'interwikisource' => [
+ ApiBase::PARAM_TYPE => $this->getAllowedImportSources(),
+ ],
+ 'interwikipage' => null,
+ 'fullhistory' => false,
+ 'templates' => false,
+ 'namespace' => [
+ ApiBase::PARAM_TYPE => 'namespace'
+ ],
+ 'rootpage' => null,
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=import&interwikisource=meta&interwikipage=Help:ParserFunctions&' .
+ 'namespace=100&fullhistory=&token=123ABC'
+ => 'apihelp-import-example-import',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Import';
+ }
+}
+
+/**
+ * Import reporter for the API
+ * @ingroup API
+ */
+class ApiImportReporter extends ImportReporter {
+ private $mResultArr = [];
+
+ /**
+ * @param Title $title
+ * @param Title $origTitle
+ * @param int $revisionCount
+ * @param int $successCount
+ * @param array $pageInfo
+ * @return void
+ */
+ public function reportPage( $title, $origTitle, $revisionCount, $successCount, $pageInfo ) {
+ // Add a result entry
+ $r = [];
+
+ if ( $title === null ) {
+ # Invalid or non-importable title
+ $r['title'] = $pageInfo['title'];
+ $r['invalid'] = true;
+ } else {
+ ApiQueryBase::addTitleInfo( $r, $title );
+ $r['revisions'] = intval( $successCount );
+ }
+
+ $this->mResultArr[] = $r;
+
+ // Piggyback on the parent to do the logging
+ parent::reportPage( $title, $origTitle, $revisionCount, $successCount, $pageInfo );
+ }
+
+ public function getData() {
+ return $this->mResultArr;
+ }
+}
diff --git a/www/wiki/includes/api/ApiLinkAccount.php b/www/wiki/includes/api/ApiLinkAccount.php
new file mode 100644
index 00000000..9553f297
--- /dev/null
+++ b/www/wiki/includes/api/ApiLinkAccount.php
@@ -0,0 +1,129 @@
+<?php
+/**
+ * Copyright © 2016 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationResponse;
+
+/**
+ * Link an account with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiLinkAccount extends ApiBase {
+
+ public function __construct( ApiMain $main, $action ) {
+ parent::__construct( $main, $action, 'link' );
+ }
+
+ public function getFinalDescription() {
+ // A bit of a hack to append 'api-help-authmanager-general-usage'
+ $msgs = parent::getFinalDescription();
+ $msgs[] = ApiBase::makeMessage( 'api-help-authmanager-general-usage', $this->getContext(), [
+ $this->getModulePrefix(),
+ $this->getModuleName(),
+ $this->getModulePath(),
+ AuthManager::ACTION_LINK,
+ self::needsToken(),
+ ] );
+ return $msgs;
+ }
+
+ public function execute() {
+ if ( !$this->getUser()->isLoggedIn() ) {
+ $this->dieWithError( 'apierror-mustbeloggedin-linkaccounts', 'notloggedin' );
+ }
+
+ $params = $this->extractRequestParams();
+
+ $this->requireAtLeastOneParameter( $params, 'continue', 'returnurl' );
+
+ if ( $params['returnurl'] !== null ) {
+ $bits = wfParseUrl( $params['returnurl'] );
+ if ( !$bits || $bits['scheme'] === '' ) {
+ $encParamName = $this->encodeParamName( 'returnurl' );
+ $this->dieWithError(
+ [ 'apierror-badurl', $encParamName, wfEscapeWikiText( $params['returnurl'] ) ],
+ "badurl_{$encParamName}"
+ );
+ }
+ }
+
+ $helper = new ApiAuthManagerHelper( $this );
+ $manager = AuthManager::singleton();
+
+ // Check security-sensitive operation status
+ $helper->securitySensitiveOperation( 'LinkAccounts' );
+
+ // Make sure it's possible to link accounts
+ if ( !$manager->canLinkAccounts() ) {
+ $this->getResult()->addValue( null, 'linkaccount', $helper->formatAuthenticationResponse(
+ AuthenticationResponse::newFail( $this->msg( 'userlogin-cannot-' . AuthManager::ACTION_LINK ) )
+ ) );
+ return;
+ }
+
+ // Perform the link step
+ if ( $params['continue'] ) {
+ $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_LINK_CONTINUE );
+ $res = $manager->continueAccountLink( $reqs );
+ } else {
+ $reqs = $helper->loadAuthenticationRequests( AuthManager::ACTION_LINK );
+ $res = $manager->beginAccountLink( $this->getUser(), $reqs, $params['returnurl'] );
+ }
+
+ $this->getResult()->addValue( null, 'linkaccount',
+ $helper->formatAuthenticationResponse( $res ) );
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ public function getAllowedParams() {
+ return ApiAuthManagerHelper::getStandardParams( AuthManager::ACTION_LINK,
+ 'requests', 'messageformat', 'mergerequestfields', 'returnurl', 'continue'
+ );
+ }
+
+ public function dynamicParameterDocumentation() {
+ return [ 'api-help-authmanagerhelper-additional-params', AuthManager::ACTION_LINK ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=linkaccount&provider=Example&linkreturnurl=http://example.org/&linktoken=123ABC'
+ => 'apihelp-linkaccount-example-link',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Linkaccount';
+ }
+}
diff --git a/www/wiki/includes/api/ApiLogin.php b/www/wiki/includes/api/ApiLogin.php
new file mode 100644
index 00000000..1d62f845
--- /dev/null
+++ b/www/wiki/includes/api/ApiLogin.php
@@ -0,0 +1,312 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 19, 2006
+ *
+ * Copyright © 2006-2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com",
+ * Daniel Cannon (cannon dot danielc at gmail dot com)
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Unit to authenticate log-in attempts to the current wiki.
+ *
+ * @ingroup API
+ */
+class ApiLogin extends ApiBase {
+
+ public function __construct( ApiMain $main, $action ) {
+ parent::__construct( $main, $action, 'lg' );
+ }
+
+ protected function getExtendedDescription() {
+ if ( $this->getConfig()->get( 'EnableBotPasswords' ) ) {
+ return 'apihelp-login-extended-description';
+ } else {
+ return 'apihelp-login-extended-description-nobotpasswords';
+ }
+ }
+
+ /**
+ * Format a message for the response
+ * @param Message|string|array $message
+ * @return string|array
+ */
+ private function formatMessage( $message ) {
+ $message = Message::newFromSpecifier( $message );
+ $errorFormatter = $this->getErrorFormatter();
+ if ( $errorFormatter instanceof ApiErrorFormatter_BackCompat ) {
+ return ApiErrorFormatter::stripMarkup(
+ $message->useDatabase( false )->inLanguage( 'en' )->text()
+ );
+ } else {
+ return $errorFormatter->formatMessage( $message );
+ }
+ }
+
+ /**
+ * Executes the log-in attempt using the parameters passed. If
+ * the log-in succeeds, it attaches a cookie to the session
+ * and outputs the user id, username, and session token. If a
+ * log-in fails, as the result of a bad password, a nonexistent
+ * user, or any other reason, the host is cached with an expiry
+ * and no log-in attempts will be accepted until that expiry
+ * is reached. The expiry is $this->mLoginThrottle.
+ */
+ public function execute() {
+ // If we're in a mode that breaks the same-origin policy, no tokens can
+ // be obtained
+ if ( $this->lacksSameOriginSecurity() ) {
+ $this->getResult()->addValue( null, 'login', [
+ 'result' => 'Aborted',
+ 'reason' => $this->formatMessage( 'api-login-fail-sameorigin' ),
+ ] );
+
+ return;
+ }
+
+ $this->requirePostedParameters( [ 'password', 'token' ] );
+
+ $params = $this->extractRequestParams();
+
+ $result = [];
+
+ // Make sure session is persisted
+ $session = MediaWiki\Session\SessionManager::getGlobalSession();
+ $session->persist();
+
+ // Make sure it's possible to log in
+ if ( !$session->canSetUser() ) {
+ $this->getResult()->addValue( null, 'login', [
+ 'result' => 'Aborted',
+ 'reason' => $this->formatMessage( [
+ 'api-login-fail-badsessionprovider',
+ $session->getProvider()->describe( $this->getErrorFormatter()->getLanguage() ),
+ ] )
+ ] );
+
+ return;
+ }
+
+ $authRes = false;
+ $context = new DerivativeContext( $this->getContext() );
+ $loginType = 'N/A';
+
+ // Check login token
+ $token = $session->getToken( '', 'login' );
+ if ( $token->wasNew() || !$params['token'] ) {
+ $authRes = 'NeedToken';
+ } elseif ( !$token->match( $params['token'] ) ) {
+ $authRes = 'WrongToken';
+ }
+
+ // Try bot passwords
+ if (
+ $authRes === false && $this->getConfig()->get( 'EnableBotPasswords' ) &&
+ ( $botLoginData = BotPassword::canonicalizeLoginData( $params['name'], $params['password'] ) )
+ ) {
+ $status = BotPassword::login(
+ $botLoginData[0], $botLoginData[1], $this->getRequest()
+ );
+ if ( $status->isOK() ) {
+ $session = $status->getValue();
+ $authRes = 'Success';
+ $loginType = 'BotPassword';
+ } elseif ( !$botLoginData[2] ||
+ $status->hasMessage( 'login-throttled' ) ||
+ $status->hasMessage( 'botpasswords-needs-reset' ) ||
+ $status->hasMessage( 'botpasswords-locked' )
+ ) {
+ $authRes = 'Failed';
+ $message = $status->getMessage();
+ LoggerFactory::getInstance( 'authentication' )->info(
+ 'BotPassword login failed: ' . $status->getWikiText( false, false, 'en' )
+ );
+ }
+ }
+
+ if ( $authRes === false ) {
+ // Simplified AuthManager login, for backwards compatibility
+ $manager = AuthManager::singleton();
+ $reqs = AuthenticationRequest::loadRequestsFromSubmission(
+ $manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN, $this->getUser() ),
+ [
+ 'username' => $params['name'],
+ 'password' => $params['password'],
+ 'domain' => $params['domain'],
+ 'rememberMe' => true,
+ ]
+ );
+ $res = AuthManager::singleton()->beginAuthentication( $reqs, 'null:' );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS:
+ if ( $this->getConfig()->get( 'EnableBotPasswords' ) ) {
+ $this->addDeprecation( 'apiwarn-deprecation-login-botpw', 'main-account-login' );
+ } else {
+ $this->addDeprecation( 'apiwarn-deprecation-login-nobotpw', 'main-account-login' );
+ }
+ $authRes = 'Success';
+ $loginType = 'AuthManager';
+ break;
+
+ case AuthenticationResponse::FAIL:
+ // Hope it's not a PreAuthenticationProvider that failed...
+ $authRes = 'Failed';
+ $message = $res->message;
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' )
+ ->info( __METHOD__ . ': Authentication failed: '
+ . $message->inLanguage( 'en' )->plain() );
+ break;
+
+ default:
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' )
+ ->info( __METHOD__ . ': Authentication failed due to unsupported response type: '
+ . $res->status, $this->getAuthenticationResponseLogData( $res ) );
+ $authRes = 'Aborted';
+ break;
+ }
+ }
+
+ $result['result'] = $authRes;
+ switch ( $authRes ) {
+ case 'Success':
+ $user = $session->getUser();
+
+ ApiQueryInfo::resetTokenCache();
+
+ // Deprecated hook
+ $injected_html = '';
+ Hooks::run( 'UserLoginComplete', [ &$user, &$injected_html, true ] );
+
+ $result['lguserid'] = intval( $user->getId() );
+ $result['lgusername'] = $user->getName();
+ break;
+
+ case 'NeedToken':
+ $result['token'] = $token->toString();
+ $this->addDeprecation( 'apiwarn-deprecation-login-token', 'action=login&!lgtoken' );
+ break;
+
+ case 'WrongToken':
+ break;
+
+ case 'Failed':
+ $result['reason'] = $this->formatMessage( $message );
+ break;
+
+ case 'Aborted':
+ $result['reason'] = $this->formatMessage(
+ $this->getConfig()->get( 'EnableBotPasswords' )
+ ? 'api-login-fail-aborted'
+ : 'api-login-fail-aborted-nobotpw'
+ );
+ break;
+
+ default:
+ ApiBase::dieDebug( __METHOD__, "Unhandled case value: {$authRes}" );
+ }
+
+ $this->getResult()->addValue( null, 'login', $result );
+
+ if ( $loginType === 'LoginForm' && isset( LoginForm::$statusCodes[$authRes] ) ) {
+ $authRes = LoginForm::$statusCodes[$authRes];
+ }
+ LoggerFactory::getInstance( 'authevents' )->info( 'Login attempt', [
+ 'event' => 'login',
+ 'successful' => $authRes === 'Success',
+ 'loginType' => $loginType,
+ 'status' => $authRes,
+ ] );
+ }
+
+ public function isDeprecated() {
+ return !$this->getConfig()->get( 'EnableBotPasswords' );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'name' => null,
+ 'password' => [
+ ApiBase::PARAM_TYPE => 'password',
+ ],
+ 'domain' => null,
+ 'token' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => false, // for BC
+ ApiBase::PARAM_SENSITIVE => true,
+ ApiBase::PARAM_HELP_MSG => [ 'api-help-param-token', 'login' ],
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=login&lgname=user&lgpassword=password'
+ => 'apihelp-login-example-gettoken',
+ 'action=login&lgname=user&lgpassword=password&lgtoken=123ABC'
+ => 'apihelp-login-example-login',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Login';
+ }
+
+ /**
+ * Turns an AuthenticationResponse into a hash suitable for passing to Logger
+ * @param AuthenticationResponse $response
+ * @return array
+ */
+ protected function getAuthenticationResponseLogData( AuthenticationResponse $response ) {
+ $ret = [
+ 'status' => $response->status,
+ ];
+ if ( $response->message ) {
+ $ret['message'] = $response->message->inLanguage( 'en' )->plain();
+ };
+ $reqs = [
+ 'neededRequests' => $response->neededRequests,
+ 'createRequest' => $response->createRequest,
+ 'linkRequest' => $response->linkRequest,
+ ];
+ foreach ( $reqs as $k => $v ) {
+ if ( $v ) {
+ $v = is_array( $v ) ? $v : [ $v ];
+ $reqClasses = array_unique( array_map( 'get_class', $v ) );
+ sort( $reqClasses );
+ $ret[$k] = implode( ', ', $reqClasses );
+ }
+ }
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/api/ApiLogout.php b/www/wiki/includes/api/ApiLogout.php
new file mode 100644
index 00000000..d56c096c
--- /dev/null
+++ b/www/wiki/includes/api/ApiLogout.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ *
+ *
+ * Created on Jan 4, 2008
+ *
+ * Copyright © 2008 Yuri Astrakhan "<Firstname><Lastname>@gmail.com",
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Session\BotPasswordSessionProvider;
+
+/**
+ * API module to allow users to log out of the wiki. API equivalent of
+ * Special:Userlogout.
+ *
+ * @ingroup API
+ */
+class ApiLogout extends ApiBase {
+
+ public function execute() {
+ $session = MediaWiki\Session\SessionManager::getGlobalSession();
+
+ // Handle bot password logout specially
+ if ( $session->getProvider() instanceof BotPasswordSessionProvider ) {
+ $session->unpersist();
+ return;
+ }
+
+ // Make sure it's possible to log out
+ if ( !$session->canSetUser() ) {
+ $this->dieWithError(
+ [
+ 'cannotlogoutnow-text',
+ $session->getProvider()->describe( $this->getErrorFormatter()->getLanguage() )
+ ],
+ 'cannotlogout'
+ );
+ }
+
+ $user = $this->getUser();
+ $oldName = $user->getName();
+ $user->logout();
+
+ // Give extensions to do something after user logout
+ $injected_html = '';
+ Hooks::run( 'UserLogoutComplete', [ &$user, &$injected_html, $oldName ] );
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=logout'
+ => 'apihelp-logout-example-logout',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Logout';
+ }
+}
diff --git a/www/wiki/includes/api/ApiMain.php b/www/wiki/includes/api/ApiMain.php
new file mode 100644
index 00000000..c76c2b20
--- /dev/null
+++ b/www/wiki/includes/api/ApiMain.php
@@ -0,0 +1,2032 @@
+<?php
+/**
+ * Created on Sep 4, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @defgroup API API
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Timestamp\TimestampException;
+use Wikimedia\Rdbms\DBQueryError;
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * This is the main API class, used for both external and internal processing.
+ * When executed, it will create the requested formatter object,
+ * instantiate and execute an object associated with the needed action,
+ * and use formatter to print results.
+ * In case of an exception, an error message will be printed using the same formatter.
+ *
+ * To use API from another application, run it using FauxRequest object, in which
+ * case any internal exceptions will not be handled but passed up to the caller.
+ * After successful execution, use getResult() for the resulting data.
+ *
+ * @ingroup API
+ */
+class ApiMain extends ApiBase {
+ /**
+ * When no format parameter is given, this format will be used
+ */
+ const API_DEFAULT_FORMAT = 'jsonfm';
+
+ /**
+ * When no uselang parameter is given, this language will be used
+ */
+ const API_DEFAULT_USELANG = 'user';
+
+ /**
+ * List of available modules: action name => module class
+ */
+ private static $Modules = [
+ 'login' => 'ApiLogin',
+ 'clientlogin' => 'ApiClientLogin',
+ 'logout' => 'ApiLogout',
+ 'createaccount' => 'ApiAMCreateAccount',
+ 'linkaccount' => 'ApiLinkAccount',
+ 'unlinkaccount' => 'ApiRemoveAuthenticationData',
+ 'changeauthenticationdata' => 'ApiChangeAuthenticationData',
+ 'removeauthenticationdata' => 'ApiRemoveAuthenticationData',
+ 'resetpassword' => 'ApiResetPassword',
+ 'query' => 'ApiQuery',
+ 'expandtemplates' => 'ApiExpandTemplates',
+ 'parse' => 'ApiParse',
+ 'stashedit' => 'ApiStashEdit',
+ 'opensearch' => 'ApiOpenSearch',
+ 'feedcontributions' => 'ApiFeedContributions',
+ 'feedrecentchanges' => 'ApiFeedRecentChanges',
+ 'feedwatchlist' => 'ApiFeedWatchlist',
+ 'help' => 'ApiHelp',
+ 'paraminfo' => 'ApiParamInfo',
+ 'rsd' => 'ApiRsd',
+ 'compare' => 'ApiComparePages',
+ 'tokens' => 'ApiTokens',
+ 'checktoken' => 'ApiCheckToken',
+ 'cspreport' => 'ApiCSPReport',
+ 'validatepassword' => 'ApiValidatePassword',
+
+ // Write modules
+ 'purge' => 'ApiPurge',
+ 'setnotificationtimestamp' => 'ApiSetNotificationTimestamp',
+ 'rollback' => 'ApiRollback',
+ 'delete' => 'ApiDelete',
+ 'undelete' => 'ApiUndelete',
+ 'protect' => 'ApiProtect',
+ 'block' => 'ApiBlock',
+ 'unblock' => 'ApiUnblock',
+ 'move' => 'ApiMove',
+ 'edit' => 'ApiEditPage',
+ 'upload' => 'ApiUpload',
+ 'filerevert' => 'ApiFileRevert',
+ 'emailuser' => 'ApiEmailUser',
+ 'watch' => 'ApiWatch',
+ 'patrol' => 'ApiPatrol',
+ 'import' => 'ApiImport',
+ 'clearhasmsg' => 'ApiClearHasMsg',
+ 'userrights' => 'ApiUserrights',
+ 'options' => 'ApiOptions',
+ 'imagerotate' => 'ApiImageRotate',
+ 'revisiondelete' => 'ApiRevisionDelete',
+ 'managetags' => 'ApiManageTags',
+ 'tag' => 'ApiTag',
+ 'mergehistory' => 'ApiMergeHistory',
+ 'setpagelanguage' => 'ApiSetPageLanguage',
+ ];
+
+ /**
+ * List of available formats: format name => format class
+ */
+ private static $Formats = [
+ 'json' => 'ApiFormatJson',
+ 'jsonfm' => 'ApiFormatJson',
+ 'php' => 'ApiFormatPhp',
+ 'phpfm' => 'ApiFormatPhp',
+ 'xml' => 'ApiFormatXml',
+ 'xmlfm' => 'ApiFormatXml',
+ 'rawfm' => 'ApiFormatJson',
+ 'none' => 'ApiFormatNone',
+ ];
+
+ // @codingStandardsIgnoreStart String contenation on "msg" not allowed to break long line
+ /**
+ * List of user roles that are specifically relevant to the API.
+ * [ 'right' => [ 'msg' => 'Some message with a $1',
+ * 'params' => [ $someVarToSubst ] ],
+ * ];
+ */
+ private static $mRights = [
+ 'writeapi' => [
+ 'msg' => 'right-writeapi',
+ 'params' => []
+ ],
+ 'apihighlimits' => [
+ 'msg' => 'api-help-right-apihighlimits',
+ 'params' => [ ApiBase::LIMIT_SML2, ApiBase::LIMIT_BIG2 ]
+ ]
+ ];
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * @var ApiFormatBase
+ */
+ private $mPrinter;
+
+ private $mModuleMgr, $mResult, $mErrorFormatter = null;
+ /** @var ApiContinuationManager|null */
+ private $mContinuationManager;
+ private $mAction;
+ private $mEnableWrite;
+ private $mInternalMode, $mSquidMaxage;
+ /** @var ApiBase */
+ private $mModule;
+
+ private $mCacheMode = 'private';
+ private $mCacheControl = [];
+ private $mParamsUsed = [];
+ private $mParamsSensitive = [];
+
+ /** @var bool|null Cached return value from self::lacksSameOriginSecurity() */
+ private $lacksSameOriginSecurity = null;
+
+ /**
+ * Constructs an instance of ApiMain that utilizes the module and format specified by $request.
+ *
+ * @param IContextSource|WebRequest $context If this is an instance of
+ * FauxRequest, errors are thrown and no printing occurs
+ * @param bool $enableWrite Should be set to true if the api may modify data
+ */
+ public function __construct( $context = null, $enableWrite = false ) {
+ if ( $context === null ) {
+ $context = RequestContext::getMain();
+ } elseif ( $context instanceof WebRequest ) {
+ // BC for pre-1.19
+ $request = $context;
+ $context = RequestContext::getMain();
+ }
+ // We set a derivative context so we can change stuff later
+ $this->setContext( new DerivativeContext( $context ) );
+
+ if ( isset( $request ) ) {
+ $this->getContext()->setRequest( $request );
+ } else {
+ $request = $this->getRequest();
+ }
+
+ $this->mInternalMode = ( $request instanceof FauxRequest );
+
+ // Special handling for the main module: $parent === $this
+ parent::__construct( $this, $this->mInternalMode ? 'main_int' : 'main' );
+
+ $config = $this->getConfig();
+
+ if ( !$this->mInternalMode ) {
+ // Log if a request with a non-whitelisted Origin header is seen
+ // with session cookies.
+ $originHeader = $request->getHeader( 'Origin' );
+ if ( $originHeader === false ) {
+ $origins = [];
+ } else {
+ $originHeader = trim( $originHeader );
+ $origins = preg_split( '/\s+/', $originHeader );
+ }
+ $sessionCookies = array_intersect(
+ array_keys( $_COOKIE ),
+ MediaWiki\Session\SessionManager::singleton()->getVaryCookies()
+ );
+ if ( $origins && $sessionCookies && (
+ count( $origins ) !== 1 || !self::matchOrigin(
+ $origins[0],
+ $config->get( 'CrossSiteAJAXdomains' ),
+ $config->get( 'CrossSiteAJAXdomainExceptions' )
+ )
+ ) ) {
+ LoggerFactory::getInstance( 'cors' )->warning(
+ 'Non-whitelisted CORS request with session cookies', [
+ 'origin' => $originHeader,
+ 'cookies' => $sessionCookies,
+ 'ip' => $request->getIP(),
+ 'userAgent' => $this->getUserAgent(),
+ 'wiki' => wfWikiID(),
+ ]
+ );
+ }
+
+ // If we're in a mode that breaks the same-origin policy, strip
+ // user credentials for security.
+ if ( $this->lacksSameOriginSecurity() ) {
+ global $wgUser;
+ wfDebug( "API: stripping user credentials when the same-origin policy is not applied\n" );
+ $wgUser = new User();
+ $this->getContext()->setUser( $wgUser );
+ $request->response()->header( 'MediaWiki-Login-Suppressed: true' );
+ }
+ }
+
+ $this->mResult = new ApiResult( $this->getConfig()->get( 'APIMaxResultSize' ) );
+
+ // Setup uselang. This doesn't use $this->getParameter()
+ // because we're not ready to handle errors yet.
+ $uselang = $request->getVal( 'uselang', self::API_DEFAULT_USELANG );
+ if ( $uselang === 'user' ) {
+ // Assume the parent context is going to return the user language
+ // for uselang=user (see T85635).
+ } else {
+ if ( $uselang === 'content' ) {
+ global $wgContLang;
+ $uselang = $wgContLang->getCode();
+ }
+ $code = RequestContext::sanitizeLangCode( $uselang );
+ $this->getContext()->setLanguage( $code );
+ if ( !$this->mInternalMode ) {
+ global $wgLang;
+ $wgLang = $this->getContext()->getLanguage();
+ RequestContext::getMain()->setLanguage( $wgLang );
+ }
+ }
+
+ // Set up the error formatter. This doesn't use $this->getParameter()
+ // because we're not ready to handle errors yet.
+ $errorFormat = $request->getVal( 'errorformat', 'bc' );
+ $errorLangCode = $request->getVal( 'errorlang', 'uselang' );
+ $errorsUseDB = $request->getCheck( 'errorsuselocal' );
+ if ( in_array( $errorFormat, [ 'plaintext', 'wikitext', 'html', 'raw', 'none' ], true ) ) {
+ if ( $errorLangCode === 'uselang' ) {
+ $errorLang = $this->getLanguage();
+ } elseif ( $errorLangCode === 'content' ) {
+ global $wgContLang;
+ $errorLang = $wgContLang;
+ } else {
+ $errorLangCode = RequestContext::sanitizeLangCode( $errorLangCode );
+ $errorLang = Language::factory( $errorLangCode );
+ }
+ $this->mErrorFormatter = new ApiErrorFormatter(
+ $this->mResult, $errorLang, $errorFormat, $errorsUseDB
+ );
+ } else {
+ $this->mErrorFormatter = new ApiErrorFormatter_BackCompat( $this->mResult );
+ }
+ $this->mResult->setErrorFormatter( $this->getErrorFormatter() );
+
+ $this->mModuleMgr = new ApiModuleManager( $this );
+ $this->mModuleMgr->addModules( self::$Modules, 'action' );
+ $this->mModuleMgr->addModules( $config->get( 'APIModules' ), 'action' );
+ $this->mModuleMgr->addModules( self::$Formats, 'format' );
+ $this->mModuleMgr->addModules( $config->get( 'APIFormatModules' ), 'format' );
+
+ Hooks::run( 'ApiMain::moduleManager', [ $this->mModuleMgr ] );
+
+ $this->mContinuationManager = null;
+ $this->mEnableWrite = $enableWrite;
+
+ $this->mSquidMaxage = -1; // flag for executeActionWithErrorHandling()
+ $this->mCommit = false;
+ }
+
+ /**
+ * Return true if the API was started by other PHP code using FauxRequest
+ * @return bool
+ */
+ public function isInternalMode() {
+ return $this->mInternalMode;
+ }
+
+ /**
+ * Get the ApiResult object associated with current request
+ *
+ * @return ApiResult
+ */
+ public function getResult() {
+ return $this->mResult;
+ }
+
+ /**
+ * Get the security flag for the current request
+ * @return bool
+ */
+ public function lacksSameOriginSecurity() {
+ if ( $this->lacksSameOriginSecurity !== null ) {
+ return $this->lacksSameOriginSecurity;
+ }
+
+ $request = $this->getRequest();
+
+ // JSONP mode
+ if ( $request->getVal( 'callback' ) !== null ) {
+ $this->lacksSameOriginSecurity = true;
+ return true;
+ }
+
+ // Anonymous CORS
+ if ( $request->getVal( 'origin' ) === '*' ) {
+ $this->lacksSameOriginSecurity = true;
+ return true;
+ }
+
+ // Header to be used from XMLHTTPRequest when the request might
+ // otherwise be used for XSS.
+ if ( $request->getHeader( 'Treat-as-Untrusted' ) !== false ) {
+ $this->lacksSameOriginSecurity = true;
+ return true;
+ }
+
+ // Allow extensions to override.
+ $this->lacksSameOriginSecurity = !Hooks::run( 'RequestHasSameOriginSecurity', [ $request ] );
+ return $this->lacksSameOriginSecurity;
+ }
+
+ /**
+ * Get the ApiErrorFormatter object associated with current request
+ * @return ApiErrorFormatter
+ */
+ public function getErrorFormatter() {
+ return $this->mErrorFormatter;
+ }
+
+ /**
+ * Get the continuation manager
+ * @return ApiContinuationManager|null
+ */
+ public function getContinuationManager() {
+ return $this->mContinuationManager;
+ }
+
+ /**
+ * Set the continuation manager
+ * @param ApiContinuationManager|null $manager
+ */
+ public function setContinuationManager( $manager ) {
+ if ( $manager !== null ) {
+ if ( !$manager instanceof ApiContinuationManager ) {
+ throw new InvalidArgumentException( __METHOD__ . ': Was passed ' .
+ is_object( $manager ) ? get_class( $manager ) : gettype( $manager )
+ );
+ }
+ if ( $this->mContinuationManager !== null ) {
+ throw new UnexpectedValueException(
+ __METHOD__ . ': tried to set manager from ' . $manager->getSource() .
+ ' when a manager is already set from ' . $this->mContinuationManager->getSource()
+ );
+ }
+ }
+ $this->mContinuationManager = $manager;
+ }
+
+ /**
+ * Get the API module object. Only works after executeAction()
+ *
+ * @return ApiBase
+ */
+ public function getModule() {
+ return $this->mModule;
+ }
+
+ /**
+ * Get the result formatter object. Only works after setupExecuteAction()
+ *
+ * @return ApiFormatBase
+ */
+ public function getPrinter() {
+ return $this->mPrinter;
+ }
+
+ /**
+ * Set how long the response should be cached.
+ *
+ * @param int $maxage
+ */
+ public function setCacheMaxAge( $maxage ) {
+ $this->setCacheControl( [
+ 'max-age' => $maxage,
+ 's-maxage' => $maxage
+ ] );
+ }
+
+ /**
+ * Set the type of caching headers which will be sent.
+ *
+ * @param string $mode One of:
+ * - 'public': Cache this object in public caches, if the maxage or smaxage
+ * parameter is set, or if setCacheMaxAge() was called. If a maximum age is
+ * not provided by any of these means, the object will be private.
+ * - 'private': Cache this object only in private client-side caches.
+ * - 'anon-public-user-private': Make this object cacheable for logged-out
+ * users, but private for logged-in users. IMPORTANT: If this is set, it must be
+ * set consistently for a given URL, it cannot be set differently depending on
+ * things like the contents of the database, or whether the user is logged in.
+ *
+ * If the wiki does not allow anonymous users to read it, the mode set here
+ * will be ignored, and private caching headers will always be sent. In other words,
+ * the "public" mode is equivalent to saying that the data sent is as public as a page
+ * view.
+ *
+ * For user-dependent data, the private mode should generally be used. The
+ * anon-public-user-private mode should only be used where there is a particularly
+ * good performance reason for caching the anonymous response, but where the
+ * response to logged-in users may differ, or may contain private data.
+ *
+ * If this function is never called, then the default will be the private mode.
+ */
+ public function setCacheMode( $mode ) {
+ if ( !in_array( $mode, [ 'private', 'public', 'anon-public-user-private' ] ) ) {
+ wfDebug( __METHOD__ . ": unrecognised cache mode \"$mode\"\n" );
+
+ // Ignore for forwards-compatibility
+ return;
+ }
+
+ if ( !User::isEveryoneAllowed( 'read' ) ) {
+ // Private wiki, only private headers
+ if ( $mode !== 'private' ) {
+ wfDebug( __METHOD__ . ": ignoring request for $mode cache mode, private wiki\n" );
+
+ return;
+ }
+ }
+
+ if ( $mode === 'public' && $this->getParameter( 'uselang' ) === 'user' ) {
+ // User language is used for i18n, so we don't want to publicly
+ // cache. Anons are ok, because if they have non-default language
+ // then there's an appropriate Vary header set by whatever set
+ // their non-default language.
+ wfDebug( __METHOD__ . ": downgrading cache mode 'public' to " .
+ "'anon-public-user-private' due to uselang=user\n" );
+ $mode = 'anon-public-user-private';
+ }
+
+ wfDebug( __METHOD__ . ": setting cache mode $mode\n" );
+ $this->mCacheMode = $mode;
+ }
+
+ /**
+ * Set directives (key/value pairs) for the Cache-Control header.
+ * Boolean values will be formatted as such, by including or omitting
+ * without an equals sign.
+ *
+ * Cache control values set here will only be used if the cache mode is not
+ * private, see setCacheMode().
+ *
+ * @param array $directives
+ */
+ public function setCacheControl( $directives ) {
+ $this->mCacheControl = $directives + $this->mCacheControl;
+ }
+
+ /**
+ * Create an instance of an output formatter by its name
+ *
+ * @param string $format
+ *
+ * @return ApiFormatBase
+ */
+ public function createPrinterByName( $format ) {
+ $printer = $this->mModuleMgr->getModule( $format, 'format' );
+ if ( $printer === null ) {
+ $this->dieWithError(
+ [ 'apierror-unknownformat', wfEscapeWikiText( $format ) ], 'unknown_format'
+ );
+ }
+
+ return $printer;
+ }
+
+ /**
+ * Execute api request. Any errors will be handled if the API was called by the remote client.
+ */
+ public function execute() {
+ if ( $this->mInternalMode ) {
+ $this->executeAction();
+ } else {
+ $this->executeActionWithErrorHandling();
+ }
+ }
+
+ /**
+ * Execute an action, and in case of an error, erase whatever partial results
+ * have been accumulated, and replace it with an error message and a help screen.
+ */
+ protected function executeActionWithErrorHandling() {
+ // Verify the CORS header before executing the action
+ if ( !$this->handleCORS() ) {
+ // handleCORS() has sent a 403, abort
+ return;
+ }
+
+ // Exit here if the request method was OPTIONS
+ // (assume there will be a followup GET or POST)
+ if ( $this->getRequest()->getMethod() === 'OPTIONS' ) {
+ return;
+ }
+
+ // In case an error occurs during data output,
+ // clear the output buffer and print just the error information
+ $obLevel = ob_get_level();
+ ob_start();
+
+ $t = microtime( true );
+ $isError = false;
+ try {
+ $this->executeAction();
+ $runTime = microtime( true ) - $t;
+ $this->logRequest( $runTime );
+ if ( $this->mModule->isWriteMode() && $this->getRequest()->wasPosted() ) {
+ MediaWikiServices::getInstance()->getStatsdDataFactory()->timing(
+ 'api.' . $this->mModule->getModuleName() . '.executeTiming', 1000 * $runTime
+ );
+ }
+ } catch ( Exception $e ) {
+ $this->handleException( $e );
+ $this->logRequest( microtime( true ) - $t, $e );
+ $isError = true;
+ }
+
+ // Commit DBs and send any related cookies and headers
+ MediaWiki::preOutputCommit( $this->getContext() );
+
+ // Send cache headers after any code which might generate an error, to
+ // avoid sending public cache headers for errors.
+ $this->sendCacheHeaders( $isError );
+
+ // Executing the action might have already messed with the output
+ // buffers.
+ while ( ob_get_level() > $obLevel ) {
+ ob_end_flush();
+ }
+ }
+
+ /**
+ * Handle an exception as an API response
+ *
+ * @since 1.23
+ * @param Exception $e
+ */
+ protected function handleException( Exception $e ) {
+ // T65145: Rollback any open database transactions
+ if ( !( $e instanceof ApiUsageException || $e instanceof UsageException ) ) {
+ // UsageExceptions are intentional, so don't rollback if that's the case
+ MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+ }
+
+ // Allow extra cleanup and logging
+ Hooks::run( 'ApiMain::onException', [ $this, $e ] );
+
+ // Handle any kind of exception by outputting properly formatted error message.
+ // If this fails, an unhandled exception should be thrown so that global error
+ // handler will process and log it.
+
+ $errCodes = $this->substituteResultWithError( $e );
+
+ // Error results should not be cached
+ $this->setCacheMode( 'private' );
+
+ $response = $this->getRequest()->response();
+ $headerStr = 'MediaWiki-API-Error: ' . join( ', ', $errCodes );
+ $response->header( $headerStr );
+
+ // Reset and print just the error message
+ ob_clean();
+
+ // Printer may not be initialized if the extractRequestParams() fails for the main module
+ $this->createErrorPrinter();
+
+ $failed = false;
+ try {
+ $this->printResult( $e->getCode() );
+ } catch ( ApiUsageException $ex ) {
+ // The error printer itself is failing. Try suppressing its request
+ // parameters and redo.
+ $failed = true;
+ $this->addWarning( 'apiwarn-errorprinterfailed' );
+ foreach ( $ex->getStatusValue()->getErrors() as $error ) {
+ try {
+ $this->mPrinter->addWarning( $error );
+ } catch ( Exception $ex2 ) {
+ // WTF?
+ $this->addWarning( $error );
+ }
+ }
+ } catch ( UsageException $ex ) {
+ // The error printer itself is failing. Try suppressing its request
+ // parameters and redo.
+ $failed = true;
+ $this->addWarning(
+ [ 'apiwarn-errorprinterfailed-ex', $ex->getMessage() ], 'errorprinterfailed'
+ );
+ }
+ if ( $failed ) {
+ $this->mPrinter = null;
+ $this->createErrorPrinter();
+ $this->mPrinter->forceDefaultParams();
+ if ( $e->getCode() ) {
+ $response->statusHeader( 200 ); // Reset in case the fallback doesn't want a non-200
+ }
+ $this->printResult( $e->getCode() );
+ }
+ }
+
+ /**
+ * Handle an exception from the ApiBeforeMain hook.
+ *
+ * This tries to print the exception as an API response, to be more
+ * friendly to clients. If it fails, it will rethrow the exception.
+ *
+ * @since 1.23
+ * @param Exception $e
+ * @throws Exception
+ */
+ public static function handleApiBeforeMainException( Exception $e ) {
+ ob_start();
+
+ try {
+ $main = new self( RequestContext::getMain(), false );
+ $main->handleException( $e );
+ $main->logRequest( 0, $e );
+ } catch ( Exception $e2 ) {
+ // Nope, even that didn't work. Punt.
+ throw $e;
+ }
+
+ // Reset cache headers
+ $main->sendCacheHeaders( true );
+
+ ob_end_flush();
+ }
+
+ /**
+ * Check the &origin= query parameter against the Origin: HTTP header and respond appropriately.
+ *
+ * If no origin parameter is present, nothing happens.
+ * If an origin parameter is present but doesn't match the Origin header, a 403 status code
+ * is set and false is returned.
+ * If the parameter and the header do match, the header is checked against $wgCrossSiteAJAXdomains
+ * and $wgCrossSiteAJAXdomainExceptions, and if the origin qualifies, the appropriate CORS
+ * headers are set.
+ * https://www.w3.org/TR/cors/#resource-requests
+ * https://www.w3.org/TR/cors/#resource-preflight-requests
+ *
+ * @return bool False if the caller should abort (403 case), true otherwise (all other cases)
+ */
+ protected function handleCORS() {
+ $originParam = $this->getParameter( 'origin' ); // defaults to null
+ if ( $originParam === null ) {
+ // No origin parameter, nothing to do
+ return true;
+ }
+
+ $request = $this->getRequest();
+ $response = $request->response();
+
+ $matchedOrigin = false;
+ $allowTiming = false;
+ $varyOrigin = true;
+
+ if ( $originParam === '*' ) {
+ // Request for anonymous CORS
+ // Technically we should check for the presence of an Origin header
+ // and not process it as CORS if it's not set, but that would
+ // require us to vary on Origin for all 'origin=*' requests which
+ // we don't want to do.
+ $matchedOrigin = true;
+ $allowOrigin = '*';
+ $allowCredentials = 'false';
+ $varyOrigin = false; // No need to vary
+ } else {
+ // Non-anonymous CORS, check we allow the domain
+
+ // Origin: header is a space-separated list of origins, check all of them
+ $originHeader = $request->getHeader( 'Origin' );
+ if ( $originHeader === false ) {
+ $origins = [];
+ } else {
+ $originHeader = trim( $originHeader );
+ $origins = preg_split( '/\s+/', $originHeader );
+ }
+
+ if ( !in_array( $originParam, $origins ) ) {
+ // origin parameter set but incorrect
+ // Send a 403 response
+ $response->statusHeader( 403 );
+ $response->header( 'Cache-Control: no-cache' );
+ echo "'origin' parameter does not match Origin header\n";
+
+ return false;
+ }
+
+ $config = $this->getConfig();
+ $matchedOrigin = count( $origins ) === 1 && self::matchOrigin(
+ $originParam,
+ $config->get( 'CrossSiteAJAXdomains' ),
+ $config->get( 'CrossSiteAJAXdomainExceptions' )
+ );
+
+ $allowOrigin = $originHeader;
+ $allowCredentials = 'true';
+ $allowTiming = $originHeader;
+ }
+
+ if ( $matchedOrigin ) {
+ $requestedMethod = $request->getHeader( 'Access-Control-Request-Method' );
+ $preflight = $request->getMethod() === 'OPTIONS' && $requestedMethod !== false;
+ if ( $preflight ) {
+ // This is a CORS preflight request
+ if ( $requestedMethod !== 'POST' && $requestedMethod !== 'GET' ) {
+ // If method is not a case-sensitive match, do not set any additional headers and terminate.
+ $response->header( 'MediaWiki-CORS-Rejection: Unsupported method requested in preflight' );
+ return true;
+ }
+ // We allow the actual request to send the following headers
+ $requestedHeaders = $request->getHeader( 'Access-Control-Request-Headers' );
+ if ( $requestedHeaders !== false ) {
+ if ( !self::matchRequestedHeaders( $requestedHeaders ) ) {
+ $response->header( 'MediaWiki-CORS-Rejection: Unsupported header requested in preflight' );
+ return true;
+ }
+ $response->header( 'Access-Control-Allow-Headers: ' . $requestedHeaders );
+ }
+
+ // We only allow the actual request to be GET or POST
+ $response->header( 'Access-Control-Allow-Methods: POST, GET' );
+ } elseif ( $request->getMethod() !== 'POST' && $request->getMethod() !== 'GET' ) {
+ // Unsupported non-preflight method, don't handle it as CORS
+ $response->header(
+ 'MediaWiki-CORS-Rejection: Unsupported method for simple request or actual request'
+ );
+ return true;
+ }
+
+ $response->header( "Access-Control-Allow-Origin: $allowOrigin" );
+ $response->header( "Access-Control-Allow-Credentials: $allowCredentials" );
+ // https://www.w3.org/TR/resource-timing/#timing-allow-origin
+ if ( $allowTiming !== false ) {
+ $response->header( "Timing-Allow-Origin: $allowTiming" );
+ }
+
+ if ( !$preflight ) {
+ $response->header(
+ 'Access-Control-Expose-Headers: MediaWiki-API-Error, Retry-After, X-Database-Lag, '
+ . 'MediaWiki-Login-Suppressed'
+ );
+ }
+ } else {
+ $response->header( 'MediaWiki-CORS-Rejection: Origin mismatch' );
+ }
+
+ if ( $varyOrigin ) {
+ $this->getOutput()->addVaryHeader( 'Origin' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Attempt to match an Origin header against a set of rules and a set of exceptions
+ * @param string $value Origin header
+ * @param array $rules Set of wildcard rules
+ * @param array $exceptions Set of wildcard rules
+ * @return bool True if $value matches a rule in $rules and doesn't match
+ * any rules in $exceptions, false otherwise
+ */
+ protected static function matchOrigin( $value, $rules, $exceptions ) {
+ foreach ( $rules as $rule ) {
+ if ( preg_match( self::wildcardToRegex( $rule ), $value ) ) {
+ // Rule matches, check exceptions
+ foreach ( $exceptions as $exc ) {
+ if ( preg_match( self::wildcardToRegex( $exc ), $value ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Attempt to validate the value of Access-Control-Request-Headers against a list
+ * of headers that we allow the follow up request to send.
+ *
+ * @param string $requestedHeaders Comma seperated list of HTTP headers
+ * @return bool True if all requested headers are in the list of allowed headers
+ */
+ protected static function matchRequestedHeaders( $requestedHeaders ) {
+ if ( trim( $requestedHeaders ) === '' ) {
+ return true;
+ }
+ $requestedHeaders = explode( ',', $requestedHeaders );
+ $allowedAuthorHeaders = array_flip( [
+ /* simple headers (see spec) */
+ 'accept',
+ 'accept-language',
+ 'content-language',
+ 'content-type',
+ /* non-authorable headers in XHR, which are however requested by some UAs */
+ 'accept-encoding',
+ 'dnt',
+ 'origin',
+ /* MediaWiki whitelist */
+ 'api-user-agent',
+ ] );
+ foreach ( $requestedHeaders as $rHeader ) {
+ $rHeader = strtolower( trim( $rHeader ) );
+ if ( !isset( $allowedAuthorHeaders[$rHeader] ) ) {
+ wfDebugLog( 'api', 'CORS preflight failed on requested header: ' . $rHeader );
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Helper function to convert wildcard string into a regex
+ * '*' => '.*?'
+ * '?' => '.'
+ *
+ * @param string $wildcard String with wildcards
+ * @return string Regular expression
+ */
+ protected static function wildcardToRegex( $wildcard ) {
+ $wildcard = preg_quote( $wildcard, '/' );
+ $wildcard = str_replace(
+ [ '\*', '\?' ],
+ [ '.*?', '.' ],
+ $wildcard
+ );
+
+ return "/^https?:\/\/$wildcard$/";
+ }
+
+ /**
+ * Send caching headers
+ * @param bool $isError Whether an error response is being output
+ * @since 1.26 added $isError parameter
+ */
+ protected function sendCacheHeaders( $isError ) {
+ $response = $this->getRequest()->response();
+ $out = $this->getOutput();
+
+ $out->addVaryHeader( 'Treat-as-Untrusted' );
+
+ $config = $this->getConfig();
+
+ if ( $config->get( 'VaryOnXFP' ) ) {
+ $out->addVaryHeader( 'X-Forwarded-Proto' );
+ }
+
+ if ( !$isError && $this->mModule &&
+ ( $this->getRequest()->getMethod() === 'GET' || $this->getRequest()->getMethod() === 'HEAD' )
+ ) {
+ $etag = $this->mModule->getConditionalRequestData( 'etag' );
+ if ( $etag !== null ) {
+ $response->header( "ETag: $etag" );
+ }
+ $lastMod = $this->mModule->getConditionalRequestData( 'last-modified' );
+ if ( $lastMod !== null ) {
+ $response->header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $lastMod ) );
+ }
+ }
+
+ // The logic should be:
+ // $this->mCacheControl['max-age'] is set?
+ // Use it, the module knows better than our guess.
+ // !$this->mModule || $this->mModule->isWriteMode(), and mCacheMode is private?
+ // Use 0 because we can guess caching is probably the wrong thing to do.
+ // Use $this->getParameter( 'maxage' ), which already defaults to 0.
+ $maxage = 0;
+ if ( isset( $this->mCacheControl['max-age'] ) ) {
+ $maxage = $this->mCacheControl['max-age'];
+ } elseif ( ( $this->mModule && !$this->mModule->isWriteMode() ) ||
+ $this->mCacheMode !== 'private'
+ ) {
+ $maxage = $this->getParameter( 'maxage' );
+ }
+ $privateCache = 'private, must-revalidate, max-age=' . $maxage;
+
+ if ( $this->mCacheMode == 'private' ) {
+ $response->header( "Cache-Control: $privateCache" );
+ return;
+ }
+
+ $useKeyHeader = $config->get( 'UseKeyHeader' );
+ if ( $this->mCacheMode == 'anon-public-user-private' ) {
+ $out->addVaryHeader( 'Cookie' );
+ $response->header( $out->getVaryHeader() );
+ if ( $useKeyHeader ) {
+ $response->header( $out->getKeyHeader() );
+ if ( $out->haveCacheVaryCookies() ) {
+ // Logged in, mark this request private
+ $response->header( "Cache-Control: $privateCache" );
+ return;
+ }
+ // Logged out, send normal public headers below
+ } elseif ( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() ) {
+ // Logged in or otherwise has session (e.g. anonymous users who have edited)
+ // Mark request private
+ $response->header( "Cache-Control: $privateCache" );
+
+ return;
+ } // else no Key and anonymous, send public headers below
+ }
+
+ // Send public headers
+ $response->header( $out->getVaryHeader() );
+ if ( $useKeyHeader ) {
+ $response->header( $out->getKeyHeader() );
+ }
+
+ // If nobody called setCacheMaxAge(), use the (s)maxage parameters
+ if ( !isset( $this->mCacheControl['s-maxage'] ) ) {
+ $this->mCacheControl['s-maxage'] = $this->getParameter( 'smaxage' );
+ }
+ if ( !isset( $this->mCacheControl['max-age'] ) ) {
+ $this->mCacheControl['max-age'] = $this->getParameter( 'maxage' );
+ }
+
+ if ( !$this->mCacheControl['s-maxage'] && !$this->mCacheControl['max-age'] ) {
+ // Public cache not requested
+ // Sending a Vary header in this case is harmless, and protects us
+ // against conditional calls of setCacheMaxAge().
+ $response->header( "Cache-Control: $privateCache" );
+
+ return;
+ }
+
+ $this->mCacheControl['public'] = true;
+
+ // Send an Expires header
+ $maxAge = min( $this->mCacheControl['s-maxage'], $this->mCacheControl['max-age'] );
+ $expiryUnixTime = ( $maxAge == 0 ? 1 : time() + $maxAge );
+ $response->header( 'Expires: ' . wfTimestamp( TS_RFC2822, $expiryUnixTime ) );
+
+ // Construct the Cache-Control header
+ $ccHeader = '';
+ $separator = '';
+ foreach ( $this->mCacheControl as $name => $value ) {
+ if ( is_bool( $value ) ) {
+ if ( $value ) {
+ $ccHeader .= $separator . $name;
+ $separator = ', ';
+ }
+ } else {
+ $ccHeader .= $separator . "$name=$value";
+ $separator = ', ';
+ }
+ }
+
+ $response->header( "Cache-Control: $ccHeader" );
+ }
+
+ /**
+ * Create the printer for error output
+ */
+ private function createErrorPrinter() {
+ if ( !isset( $this->mPrinter ) ) {
+ $value = $this->getRequest()->getVal( 'format', self::API_DEFAULT_FORMAT );
+ if ( !$this->mModuleMgr->isDefined( $value, 'format' ) ) {
+ $value = self::API_DEFAULT_FORMAT;
+ }
+ $this->mPrinter = $this->createPrinterByName( $value );
+ }
+
+ // Printer may not be able to handle errors. This is particularly
+ // likely if the module returns something for getCustomPrinter().
+ if ( !$this->mPrinter->canPrintErrors() ) {
+ $this->mPrinter = $this->createPrinterByName( self::API_DEFAULT_FORMAT );
+ }
+ }
+
+ /**
+ * Create an error message for the given exception.
+ *
+ * If an ApiUsageException, errors/warnings will be extracted from the
+ * embedded StatusValue.
+ *
+ * If a base UsageException, the getMessageArray() method will be used to
+ * extract the code and English message for a single error (no warnings).
+ *
+ * Any other exception will be returned with a generic code and wrapper
+ * text around the exception's (presumably English) message as a single
+ * error (no warnings).
+ *
+ * @param Exception $e
+ * @param string $type 'error' or 'warning'
+ * @return ApiMessage[]
+ * @since 1.27
+ */
+ protected function errorMessagesFromException( $e, $type = 'error' ) {
+ $messages = [];
+ if ( $e instanceof ApiUsageException ) {
+ foreach ( $e->getStatusValue()->getErrorsByType( $type ) as $error ) {
+ $messages[] = ApiMessage::create( $error );
+ }
+ } elseif ( $type !== 'error' ) {
+ // None of the rest have any messages for non-error types
+ } elseif ( $e instanceof UsageException ) {
+ // User entered incorrect parameters - generate error response
+ $data = MediaWiki\quietCall( [ $e, 'getMessageArray' ] );
+ $code = $data['code'];
+ $info = $data['info'];
+ unset( $data['code'], $data['info'] );
+ $messages[] = new ApiRawMessage( [ '$1', $info ], $code, $data );
+ } else {
+ // Something is seriously wrong
+ $config = $this->getConfig();
+ $class = preg_replace( '#^Wikimedia\\\Rdbms\\\#', '', get_class( $e ) );
+ $code = 'internal_api_error_' . $class;
+ if ( ( $e instanceof DBQueryError ) && !$config->get( 'ShowSQLErrors' ) ) {
+ $params = [ 'apierror-databaseerror', WebRequest::getRequestId() ];
+ } else {
+ $params = [
+ 'apierror-exceptioncaught',
+ WebRequest::getRequestId(),
+ $e instanceof ILocalizedException
+ ? $e->getMessageObject()
+ : wfEscapeWikiText( $e->getMessage() )
+ ];
+ }
+ $messages[] = ApiMessage::create( $params, $code );
+ }
+ return $messages;
+ }
+
+ /**
+ * Replace the result data with the information about an exception.
+ * @param Exception $e
+ * @return string[] Error codes
+ */
+ protected function substituteResultWithError( $e ) {
+ $result = $this->getResult();
+ $formatter = $this->getErrorFormatter();
+ $config = $this->getConfig();
+ $errorCodes = [];
+
+ // Remember existing warnings and errors across the reset
+ $errors = $result->getResultData( [ 'errors' ] );
+ $warnings = $result->getResultData( [ 'warnings' ] );
+ $result->reset();
+ if ( $warnings !== null ) {
+ $result->addValue( null, 'warnings', $warnings, ApiResult::NO_SIZE_CHECK );
+ }
+ if ( $errors !== null ) {
+ $result->addValue( null, 'errors', $errors, ApiResult::NO_SIZE_CHECK );
+
+ // Collect the copied error codes for the return value
+ foreach ( $errors as $error ) {
+ if ( isset( $error['code'] ) ) {
+ $errorCodes[$error['code']] = true;
+ }
+ }
+ }
+
+ // Add errors from the exception
+ $modulePath = $e instanceof ApiUsageException ? $e->getModulePath() : null;
+ foreach ( $this->errorMessagesFromException( $e, 'error' ) as $msg ) {
+ $errorCodes[$msg->getApiCode()] = true;
+ $formatter->addError( $modulePath, $msg );
+ }
+ foreach ( $this->errorMessagesFromException( $e, 'warning' ) as $msg ) {
+ $formatter->addWarning( $modulePath, $msg );
+ }
+
+ // Add additional data. Path depends on whether we're in BC mode or not.
+ // Data depends on the type of exception.
+ if ( $formatter instanceof ApiErrorFormatter_BackCompat ) {
+ $path = [ 'error' ];
+ } else {
+ $path = null;
+ }
+ if ( $e instanceof ApiUsageException || $e instanceof UsageException ) {
+ $link = wfExpandUrl( wfScript( 'api' ) );
+ $result->addContentValue(
+ $path,
+ 'docref',
+ trim(
+ $this->msg( 'api-usage-docref', $link )->inLanguage( $formatter->getLanguage() )->text()
+ . ' '
+ . $this->msg( 'api-usage-mailinglist-ref' )->inLanguage( $formatter->getLanguage() )->text()
+ )
+ );
+ } else {
+ if ( $config->get( 'ShowExceptionDetails' ) &&
+ ( !$e instanceof DBError || $config->get( 'ShowDBErrorBacktrace' ) )
+ ) {
+ $result->addContentValue(
+ $path,
+ 'trace',
+ $this->msg( 'api-exception-trace',
+ get_class( $e ),
+ $e->getFile(),
+ $e->getLine(),
+ MWExceptionHandler::getRedactedTraceAsString( $e )
+ )->inLanguage( $formatter->getLanguage() )->text()
+ );
+ }
+ }
+
+ // Add the id and such
+ $this->addRequestedFields( [ 'servedby' ] );
+
+ return array_keys( $errorCodes );
+ }
+
+ /**
+ * Add requested fields to the result
+ * @param string[] $force Which fields to force even if not requested. Accepted values are:
+ * - servedby
+ */
+ protected function addRequestedFields( $force = [] ) {
+ $result = $this->getResult();
+
+ $requestid = $this->getParameter( 'requestid' );
+ if ( $requestid !== null ) {
+ $result->addValue( null, 'requestid', $requestid, ApiResult::NO_SIZE_CHECK );
+ }
+
+ if ( $this->getConfig()->get( 'ShowHostnames' ) && (
+ in_array( 'servedby', $force, true ) || $this->getParameter( 'servedby' )
+ ) ) {
+ $result->addValue( null, 'servedby', wfHostname(), ApiResult::NO_SIZE_CHECK );
+ }
+
+ if ( $this->getParameter( 'curtimestamp' ) ) {
+ $result->addValue( null, 'curtimestamp', wfTimestamp( TS_ISO_8601, time() ),
+ ApiResult::NO_SIZE_CHECK );
+ }
+
+ if ( $this->getParameter( 'responselanginfo' ) ) {
+ $result->addValue( null, 'uselang', $this->getLanguage()->getCode(),
+ ApiResult::NO_SIZE_CHECK );
+ $result->addValue( null, 'errorlang', $this->getErrorFormatter()->getLanguage()->getCode(),
+ ApiResult::NO_SIZE_CHECK );
+ }
+ }
+
+ /**
+ * Set up for the execution.
+ * @return array
+ */
+ protected function setupExecuteAction() {
+ $this->addRequestedFields();
+
+ $params = $this->extractRequestParams();
+ $this->mAction = $params['action'];
+
+ return $params;
+ }
+
+ /**
+ * Set up the module for response
+ * @return ApiBase The module that will handle this action
+ * @throws MWException
+ * @throws ApiUsageException
+ */
+ protected function setupModule() {
+ // Instantiate the module requested by the user
+ $module = $this->mModuleMgr->getModule( $this->mAction, 'action' );
+ if ( $module === null ) {
+ $this->dieWithError(
+ [ 'apierror-unknownaction', wfEscapeWikiText( $this->mAction ) ], 'unknown_action'
+ );
+ }
+ $moduleParams = $module->extractRequestParams();
+
+ // Check token, if necessary
+ if ( $module->needsToken() === true ) {
+ throw new MWException(
+ "Module '{$module->getModuleName()}' must be updated for the new token handling. " .
+ 'See documentation for ApiBase::needsToken for details.'
+ );
+ }
+ if ( $module->needsToken() ) {
+ if ( !$module->mustBePosted() ) {
+ throw new MWException(
+ "Module '{$module->getModuleName()}' must require POST to use tokens."
+ );
+ }
+
+ if ( !isset( $moduleParams['token'] ) ) {
+ $module->dieWithError( [ 'apierror-missingparam', 'token' ] );
+ }
+
+ $module->requirePostedParameters( [ 'token' ] );
+
+ if ( !$module->validateToken( $moduleParams['token'], $moduleParams ) ) {
+ $module->dieWithError( 'apierror-badtoken' );
+ }
+ }
+
+ return $module;
+ }
+
+ /**
+ * @return array
+ */
+ private function getMaxLag() {
+ $dbLag = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaxLag();
+ $lagInfo = [
+ 'host' => $dbLag[0],
+ 'lag' => $dbLag[1],
+ 'type' => 'db'
+ ];
+
+ $jobQueueLagFactor = $this->getConfig()->get( 'JobQueueIncludeInMaxLagFactor' );
+ if ( $jobQueueLagFactor ) {
+ // Turn total number of jobs into seconds by using the configured value
+ $totalJobs = array_sum( JobQueueGroup::singleton()->getQueueSizes() );
+ $jobQueueLag = $totalJobs / (float)$jobQueueLagFactor;
+ if ( $jobQueueLag > $lagInfo['lag'] ) {
+ $lagInfo = [
+ 'host' => wfHostname(), // XXX: Is there a better value that could be used?
+ 'lag' => $jobQueueLag,
+ 'type' => 'jobqueue',
+ 'jobs' => $totalJobs,
+ ];
+ }
+ }
+
+ return $lagInfo;
+ }
+
+ /**
+ * Check the max lag if necessary
+ * @param ApiBase $module Api module being used
+ * @param array $params Array an array containing the request parameters.
+ * @return bool True on success, false should exit immediately
+ */
+ protected function checkMaxLag( $module, $params ) {
+ if ( $module->shouldCheckMaxlag() && isset( $params['maxlag'] ) ) {
+ $maxLag = $params['maxlag'];
+ $lagInfo = $this->getMaxLag();
+ if ( $lagInfo['lag'] > $maxLag ) {
+ $response = $this->getRequest()->response();
+
+ $response->header( 'Retry-After: ' . max( intval( $maxLag ), 5 ) );
+ $response->header( 'X-Database-Lag: ' . intval( $lagInfo['lag'] ) );
+
+ if ( $this->getConfig()->get( 'ShowHostnames' ) ) {
+ $this->dieWithError(
+ [ 'apierror-maxlag', $lagInfo['lag'], $lagInfo['host'] ],
+ 'maxlag',
+ $lagInfo
+ );
+ }
+
+ $this->dieWithError( [ 'apierror-maxlag-generic', $lagInfo['lag'] ], 'maxlag', $lagInfo );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Check selected RFC 7232 precondition headers
+ *
+ * RFC 7232 envisions a particular model where you send your request to "a
+ * resource", and for write requests that you can read "the resource" by
+ * changing the method to GET. When the API receives a GET request, it
+ * works out even though "the resource" from RFC 7232's perspective might
+ * be many resources from MediaWiki's perspective. But it totally fails for
+ * a POST, since what HTTP sees as "the resource" is probably just
+ * "/api.php" with all the interesting bits in the body.
+ *
+ * Therefore, we only support RFC 7232 precondition headers for GET (and
+ * HEAD). That means we don't need to bother with If-Match and
+ * If-Unmodified-Since since they only apply to modification requests.
+ *
+ * And since we don't support Range, If-Range is ignored too.
+ *
+ * @since 1.26
+ * @param ApiBase $module Api module being used
+ * @return bool True on success, false should exit immediately
+ */
+ protected function checkConditionalRequestHeaders( $module ) {
+ if ( $this->mInternalMode ) {
+ // No headers to check in internal mode
+ return true;
+ }
+
+ if ( $this->getRequest()->getMethod() !== 'GET' && $this->getRequest()->getMethod() !== 'HEAD' ) {
+ // Don't check POSTs
+ return true;
+ }
+
+ $return304 = false;
+
+ $ifNoneMatch = array_diff(
+ $this->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST ) ?: [],
+ [ '' ]
+ );
+ if ( $ifNoneMatch ) {
+ if ( $ifNoneMatch === [ '*' ] ) {
+ // API responses always "exist"
+ $etag = '*';
+ } else {
+ $etag = $module->getConditionalRequestData( 'etag' );
+ }
+ }
+ if ( $ifNoneMatch && $etag !== null ) {
+ $test = substr( $etag, 0, 2 ) === 'W/' ? substr( $etag, 2 ) : $etag;
+ $match = array_map( function ( $s ) {
+ return substr( $s, 0, 2 ) === 'W/' ? substr( $s, 2 ) : $s;
+ }, $ifNoneMatch );
+ $return304 = in_array( $test, $match, true );
+ } else {
+ $value = trim( $this->getRequest()->getHeader( 'If-Modified-Since' ) );
+
+ // Some old browsers sends sizes after the date, like this:
+ // Wed, 20 Aug 2003 06:51:19 GMT; length=5202
+ // Ignore that.
+ $i = strpos( $value, ';' );
+ if ( $i !== false ) {
+ $value = trim( substr( $value, 0, $i ) );
+ }
+
+ if ( $value !== '' ) {
+ try {
+ $ts = new MWTimestamp( $value );
+ if (
+ // RFC 7231 IMF-fixdate
+ $ts->getTimestamp( TS_RFC2822 ) === $value ||
+ // RFC 850
+ $ts->format( 'l, d-M-y H:i:s' ) . ' GMT' === $value ||
+ // asctime (with and without space-padded day)
+ $ts->format( 'D M j H:i:s Y' ) === $value ||
+ $ts->format( 'D M j H:i:s Y' ) === $value
+ ) {
+ $lastMod = $module->getConditionalRequestData( 'last-modified' );
+ if ( $lastMod !== null ) {
+ // Mix in some MediaWiki modification times
+ $modifiedTimes = [
+ 'page' => $lastMod,
+ 'user' => $this->getUser()->getTouched(),
+ 'epoch' => $this->getConfig()->get( 'CacheEpoch' ),
+ ];
+ if ( $this->getConfig()->get( 'UseSquid' ) ) {
+ // T46570: the core page itself may not change, but resources might
+ $modifiedTimes['sepoch'] = wfTimestamp(
+ TS_MW, time() - $this->getConfig()->get( 'SquidMaxage' )
+ );
+ }
+ Hooks::run( 'OutputPageCheckLastModified', [ &$modifiedTimes, $this->getOutput() ] );
+ $lastMod = max( $modifiedTimes );
+ $return304 = wfTimestamp( TS_MW, $lastMod ) <= $ts->getTimestamp( TS_MW );
+ }
+ }
+ } catch ( TimestampException $e ) {
+ // Invalid timestamp, ignore it
+ }
+ }
+ }
+
+ if ( $return304 ) {
+ $this->getRequest()->response()->statusHeader( 304 );
+
+ // Avoid outputting the compressed representation of a zero-length body
+ MediaWiki\suppressWarnings();
+ ini_set( 'zlib.output_compression', 0 );
+ MediaWiki\restoreWarnings();
+ wfClearOutputBuffers();
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Check for sufficient permissions to execute
+ * @param ApiBase $module An Api module
+ */
+ protected function checkExecutePermissions( $module ) {
+ $user = $this->getUser();
+ if ( $module->isReadMode() && !User::isEveryoneAllowed( 'read' ) &&
+ !$user->isAllowed( 'read' )
+ ) {
+ $this->dieWithError( 'apierror-readapidenied' );
+ }
+
+ if ( $module->isWriteMode() ) {
+ if ( !$this->mEnableWrite ) {
+ $this->dieWithError( 'apierror-noapiwrite' );
+ } elseif ( !$user->isAllowed( 'writeapi' ) ) {
+ $this->dieWithError( 'apierror-writeapidenied' );
+ } elseif ( $this->getRequest()->getHeader( 'Promise-Non-Write-API-Action' ) ) {
+ $this->dieWithError( 'apierror-promised-nonwrite-api' );
+ }
+
+ $this->checkReadOnly( $module );
+ }
+
+ // Allow extensions to stop execution for arbitrary reasons.
+ $message = false;
+ if ( !Hooks::run( 'ApiCheckCanExecute', [ $module, $user, &$message ] ) ) {
+ $this->dieWithError( $message );
+ }
+ }
+
+ /**
+ * Check if the DB is read-only for this user
+ * @param ApiBase $module An Api module
+ */
+ protected function checkReadOnly( $module ) {
+ if ( wfReadOnly() ) {
+ $this->dieReadOnly();
+ }
+
+ if ( $module->isWriteMode()
+ && $this->getUser()->isBot()
+ && wfGetLB()->getServerCount() > 1
+ ) {
+ $this->checkBotReadOnly();
+ }
+ }
+
+ /**
+ * Check whether we are readonly for bots
+ */
+ private function checkBotReadOnly() {
+ // Figure out how many servers have passed the lag threshold
+ $numLagged = 0;
+ $lagLimit = $this->getConfig()->get( 'APIMaxLagThreshold' );
+ $laggedServers = [];
+ $loadBalancer = wfGetLB();
+ foreach ( $loadBalancer->getLagTimes() as $serverIndex => $lag ) {
+ if ( $lag > $lagLimit ) {
+ ++$numLagged;
+ $laggedServers[] = $loadBalancer->getServerName( $serverIndex ) . " ({$lag}s)";
+ }
+ }
+
+ // If a majority of replica DBs are too lagged then disallow writes
+ $replicaCount = wfGetLB()->getServerCount() - 1;
+ if ( $numLagged >= ceil( $replicaCount / 2 ) ) {
+ $laggedServers = implode( ', ', $laggedServers );
+ wfDebugLog(
+ 'api-readonly',
+ "Api request failed as read only because the following DBs are lagged: $laggedServers"
+ );
+
+ $this->dieWithError(
+ 'readonly_lag',
+ 'readonly',
+ [ 'readonlyreason' => "Waiting for $numLagged lagged database(s)" ]
+ );
+ }
+ }
+
+ /**
+ * Check asserts of the user's rights
+ * @param array $params
+ */
+ protected function checkAsserts( $params ) {
+ if ( isset( $params['assert'] ) ) {
+ $user = $this->getUser();
+ switch ( $params['assert'] ) {
+ case 'user':
+ if ( $user->isAnon() ) {
+ $this->dieWithError( 'apierror-assertuserfailed' );
+ }
+ break;
+ case 'bot':
+ if ( !$user->isAllowed( 'bot' ) ) {
+ $this->dieWithError( 'apierror-assertbotfailed' );
+ }
+ break;
+ }
+ }
+ if ( isset( $params['assertuser'] ) ) {
+ $assertUser = User::newFromName( $params['assertuser'], false );
+ if ( !$assertUser || !$this->getUser()->equals( $assertUser ) ) {
+ $this->dieWithError(
+ [ 'apierror-assertnameduserfailed', wfEscapeWikiText( $params['assertuser'] ) ]
+ );
+ }
+ }
+ }
+
+ /**
+ * Check POST for external response and setup result printer
+ * @param ApiBase $module An Api module
+ * @param array $params An array with the request parameters
+ */
+ protected function setupExternalResponse( $module, $params ) {
+ $request = $this->getRequest();
+ if ( !$request->wasPosted() && $module->mustBePosted() ) {
+ // Module requires POST. GET request might still be allowed
+ // if $wgDebugApi is true, otherwise fail.
+ $this->dieWithErrorOrDebug( [ 'apierror-mustbeposted', $this->mAction ] );
+ }
+
+ // See if custom printer is used
+ $this->mPrinter = $module->getCustomPrinter();
+ if ( is_null( $this->mPrinter ) ) {
+ // Create an appropriate printer
+ $this->mPrinter = $this->createPrinterByName( $params['format'] );
+ }
+
+ if ( $request->getProtocol() === 'http' && (
+ $request->getSession()->shouldForceHTTPS() ||
+ ( $this->getUser()->isLoggedIn() &&
+ $this->getUser()->requiresHTTPS() )
+ ) ) {
+ $this->addDeprecation( 'apiwarn-deprecation-httpsexpected', 'https-expected' );
+ }
+ }
+
+ /**
+ * Execute the actual module, without any error handling
+ */
+ protected function executeAction() {
+ $params = $this->setupExecuteAction();
+ $module = $this->setupModule();
+ $this->mModule = $module;
+
+ if ( !$this->mInternalMode ) {
+ $this->setRequestExpectations( $module );
+ }
+
+ $this->checkExecutePermissions( $module );
+
+ if ( !$this->checkMaxLag( $module, $params ) ) {
+ return;
+ }
+
+ if ( !$this->checkConditionalRequestHeaders( $module ) ) {
+ return;
+ }
+
+ if ( !$this->mInternalMode ) {
+ $this->setupExternalResponse( $module, $params );
+ }
+
+ $this->checkAsserts( $params );
+
+ // Execute
+ $module->execute();
+ Hooks::run( 'APIAfterExecute', [ &$module ] );
+
+ $this->reportUnusedParams();
+
+ if ( !$this->mInternalMode ) {
+ // append Debug information
+ MWDebug::appendDebugInfoToApiResult( $this->getContext(), $this->getResult() );
+
+ // Print result data
+ $this->printResult();
+ }
+ }
+
+ /**
+ * Set database connection, query, and write expectations given this module request
+ * @param ApiBase $module
+ */
+ protected function setRequestExpectations( ApiBase $module ) {
+ $limits = $this->getConfig()->get( 'TrxProfilerLimits' );
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+ $trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) );
+ if ( $this->getRequest()->hasSafeMethod() ) {
+ $trxProfiler->setExpectations( $limits['GET'], __METHOD__ );
+ } elseif ( $this->getRequest()->wasPosted() && !$module->isWriteMode() ) {
+ $trxProfiler->setExpectations( $limits['POST-nonwrite'], __METHOD__ );
+ $this->getRequest()->markAsSafeRequest();
+ } else {
+ $trxProfiler->setExpectations( $limits['POST'], __METHOD__ );
+ }
+ }
+
+ /**
+ * Log the preceding request
+ * @param float $time Time in seconds
+ * @param Exception $e Exception caught while processing the request
+ */
+ protected function logRequest( $time, $e = null ) {
+ $request = $this->getRequest();
+ $logCtx = [
+ 'ts' => time(),
+ 'ip' => $request->getIP(),
+ 'userAgent' => $this->getUserAgent(),
+ 'wiki' => wfWikiID(),
+ 'timeSpentBackend' => (int)round( $time * 1000 ),
+ 'hadError' => $e !== null,
+ 'errorCodes' => [],
+ 'params' => [],
+ ];
+
+ if ( $e ) {
+ foreach ( $this->errorMessagesFromException( $e ) as $msg ) {
+ $logCtx['errorCodes'][] = $msg->getApiCode();
+ }
+ }
+
+ // Construct space separated message for 'api' log channel
+ $msg = "API {$request->getMethod()} " .
+ wfUrlencode( str_replace( ' ', '_', $this->getUser()->getName() ) ) .
+ " {$logCtx['ip']} " .
+ "T={$logCtx['timeSpentBackend']}ms";
+
+ $sensitive = array_flip( $this->getSensitiveParams() );
+ foreach ( $this->getParamsUsed() as $name ) {
+ $value = $request->getVal( $name );
+ if ( $value === null ) {
+ continue;
+ }
+
+ if ( isset( $sensitive[$name] ) ) {
+ $value = '[redacted]';
+ $encValue = '[redacted]';
+ } elseif ( strlen( $value ) > 256 ) {
+ $value = substr( $value, 0, 256 );
+ $encValue = $this->encodeRequestLogValue( $value ) . '[...]';
+ } else {
+ $encValue = $this->encodeRequestLogValue( $value );
+ }
+
+ $logCtx['params'][$name] = $value;
+ $msg .= " {$name}={$encValue}";
+ }
+
+ wfDebugLog( 'api', $msg, 'private' );
+ // ApiAction channel is for structured data consumers
+ wfDebugLog( 'ApiAction', '', 'private', $logCtx );
+ }
+
+ /**
+ * Encode a value in a format suitable for a space-separated log line.
+ * @param string $s
+ * @return string
+ */
+ protected function encodeRequestLogValue( $s ) {
+ static $table;
+ if ( !$table ) {
+ $chars = ';@$!*(),/:';
+ $numChars = strlen( $chars );
+ for ( $i = 0; $i < $numChars; $i++ ) {
+ $table[rawurlencode( $chars[$i] )] = $chars[$i];
+ }
+ }
+
+ return strtr( rawurlencode( $s ), $table );
+ }
+
+ /**
+ * Get the request parameters used in the course of the preceding execute() request
+ * @return array
+ */
+ protected function getParamsUsed() {
+ return array_keys( $this->mParamsUsed );
+ }
+
+ /**
+ * Mark parameters as used
+ * @param string|string[] $params
+ */
+ public function markParamsUsed( $params ) {
+ $this->mParamsUsed += array_fill_keys( (array)$params, true );
+ }
+
+ /**
+ * Get the request parameters that should be considered sensitive
+ * @since 1.29
+ * @return array
+ */
+ protected function getSensitiveParams() {
+ return array_keys( $this->mParamsSensitive );
+ }
+
+ /**
+ * Mark parameters as sensitive
+ * @since 1.29
+ * @param string|string[] $params
+ */
+ public function markParamsSensitive( $params ) {
+ $this->mParamsSensitive += array_fill_keys( (array)$params, true );
+ }
+
+ /**
+ * Get a request value, and register the fact that it was used, for logging.
+ * @param string $name
+ * @param mixed $default
+ * @return mixed
+ */
+ public function getVal( $name, $default = null ) {
+ $this->mParamsUsed[$name] = true;
+
+ $ret = $this->getRequest()->getVal( $name );
+ if ( $ret === null ) {
+ if ( $this->getRequest()->getArray( $name ) !== null ) {
+ // See T12262 for why we don't just implode( '|', ... ) the
+ // array.
+ $this->addWarning( [ 'apiwarn-unsupportedarray', $name ] );
+ }
+ $ret = $default;
+ }
+ return $ret;
+ }
+
+ /**
+ * Get a boolean request value, and register the fact that the parameter
+ * was used, for logging.
+ * @param string $name
+ * @return bool
+ */
+ public function getCheck( $name ) {
+ return $this->getVal( $name, null ) !== null;
+ }
+
+ /**
+ * Get a request upload, and register the fact that it was used, for logging.
+ *
+ * @since 1.21
+ * @param string $name Parameter name
+ * @return WebRequestUpload
+ */
+ public function getUpload( $name ) {
+ $this->mParamsUsed[$name] = true;
+
+ return $this->getRequest()->getUpload( $name );
+ }
+
+ /**
+ * Report unused parameters, so the client gets a hint in case it gave us parameters we don't know,
+ * for example in case of spelling mistakes or a missing 'g' prefix for generators.
+ */
+ protected function reportUnusedParams() {
+ $paramsUsed = $this->getParamsUsed();
+ $allParams = $this->getRequest()->getValueNames();
+
+ if ( !$this->mInternalMode ) {
+ // Printer has not yet executed; don't warn that its parameters are unused
+ $printerParams = $this->mPrinter->encodeParamName(
+ array_keys( $this->mPrinter->getFinalParams() ?: [] )
+ );
+ $unusedParams = array_diff( $allParams, $paramsUsed, $printerParams );
+ } else {
+ $unusedParams = array_diff( $allParams, $paramsUsed );
+ }
+
+ if ( count( $unusedParams ) ) {
+ $this->addWarning( [
+ 'apierror-unrecognizedparams',
+ Message::listParam( array_map( 'wfEscapeWikiText', $unusedParams ), 'comma' ),
+ count( $unusedParams )
+ ] );
+ }
+ }
+
+ /**
+ * Print results using the current printer
+ *
+ * @param int $httpCode HTTP status code, or 0 to not change
+ */
+ protected function printResult( $httpCode = 0 ) {
+ if ( $this->getConfig()->get( 'DebugAPI' ) !== false ) {
+ $this->addWarning( 'apiwarn-wgDebugAPI' );
+ }
+
+ $printer = $this->mPrinter;
+ $printer->initPrinter( false );
+ if ( $httpCode ) {
+ $printer->setHttpStatus( $httpCode );
+ }
+ $printer->execute();
+ $printer->closePrinter();
+ }
+
+ /**
+ * @return bool
+ */
+ public function isReadMode() {
+ return false;
+ }
+
+ /**
+ * See ApiBase for description.
+ *
+ * @return array
+ */
+ public function getAllowedParams() {
+ return [
+ 'action' => [
+ ApiBase::PARAM_DFLT => 'help',
+ ApiBase::PARAM_TYPE => 'submodule',
+ ],
+ 'format' => [
+ ApiBase::PARAM_DFLT => self::API_DEFAULT_FORMAT,
+ ApiBase::PARAM_TYPE => 'submodule',
+ ],
+ 'maxlag' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'smaxage' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_DFLT => 0
+ ],
+ 'maxage' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_DFLT => 0
+ ],
+ 'assert' => [
+ ApiBase::PARAM_TYPE => [ 'user', 'bot' ]
+ ],
+ 'assertuser' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'requestid' => null,
+ 'servedby' => false,
+ 'curtimestamp' => false,
+ 'responselanginfo' => false,
+ 'origin' => null,
+ 'uselang' => [
+ ApiBase::PARAM_DFLT => self::API_DEFAULT_USELANG,
+ ],
+ 'errorformat' => [
+ ApiBase::PARAM_TYPE => [ 'plaintext', 'wikitext', 'html', 'raw', 'none', 'bc' ],
+ ApiBase::PARAM_DFLT => 'bc',
+ ],
+ 'errorlang' => [
+ ApiBase::PARAM_DFLT => 'uselang',
+ ],
+ 'errorsuselocal' => [
+ ApiBase::PARAM_DFLT => false,
+ ],
+ ];
+ }
+
+ /** @inheritDoc */
+ protected function getExamplesMessages() {
+ return [
+ 'action=help'
+ => 'apihelp-help-example-main',
+ 'action=help&recursivesubmodules=1'
+ => 'apihelp-help-example-recursive',
+ ];
+ }
+
+ public function modifyHelp( array &$help, array $options, array &$tocData ) {
+ // Wish PHP had an "array_insert_before". Instead, we have to manually
+ // reindex the array to get 'permissions' in the right place.
+ $oldHelp = $help;
+ $help = [];
+ foreach ( $oldHelp as $k => $v ) {
+ if ( $k === 'submodules' ) {
+ $help['permissions'] = '';
+ }
+ $help[$k] = $v;
+ }
+ $help['datatypes'] = '';
+ $help['credits'] = '';
+
+ // Fill 'permissions'
+ $help['permissions'] .= Html::openElement( 'div',
+ [ 'class' => 'apihelp-block apihelp-permissions' ] );
+ $m = $this->msg( 'api-help-permissions' );
+ if ( !$m->isDisabled() ) {
+ $help['permissions'] .= Html::rawElement( 'div', [ 'class' => 'apihelp-block-head' ],
+ $m->numParams( count( self::$mRights ) )->parse()
+ );
+ }
+ $help['permissions'] .= Html::openElement( 'dl' );
+ foreach ( self::$mRights as $right => $rightMsg ) {
+ $help['permissions'] .= Html::element( 'dt', null, $right );
+
+ $rightMsg = $this->msg( $rightMsg['msg'], $rightMsg['params'] )->parse();
+ $help['permissions'] .= Html::rawElement( 'dd', null, $rightMsg );
+
+ $groups = array_map( function ( $group ) {
+ return $group == '*' ? 'all' : $group;
+ }, User::getGroupsWithPermission( $right ) );
+
+ $help['permissions'] .= Html::rawElement( 'dd', null,
+ $this->msg( 'api-help-permissions-granted-to' )
+ ->numParams( count( $groups ) )
+ ->params( Message::listParam( $groups ) )
+ ->parse()
+ );
+ }
+ $help['permissions'] .= Html::closeElement( 'dl' );
+ $help['permissions'] .= Html::closeElement( 'div' );
+
+ // Fill 'datatypes' and 'credits', if applicable
+ if ( empty( $options['nolead'] ) ) {
+ $level = $options['headerlevel'];
+ $tocnumber = &$options['tocnumber'];
+
+ $header = $this->msg( 'api-help-datatypes-header' )->parse();
+
+ $id = Sanitizer::escapeIdForAttribute( 'main/datatypes', Sanitizer::ID_PRIMARY );
+ $idFallback = Sanitizer::escapeIdForAttribute( 'main/datatypes', Sanitizer::ID_FALLBACK );
+ $headline = Linker::makeHeadline( min( 6, $level ),
+ ' class="apihelp-header">',
+ $id,
+ $header,
+ '',
+ $idFallback
+ );
+ // Ensure we have a sane anchor
+ if ( $id !== 'main/datatypes' && $idFallback !== 'main/datatypes' ) {
+ $headline = '<div id="main/datatypes"></div>' . $headline;
+ }
+ $help['datatypes'] .= $headline;
+ $help['datatypes'] .= $this->msg( 'api-help-datatypes' )->parseAsBlock();
+ if ( !isset( $tocData['main/datatypes'] ) ) {
+ $tocnumber[$level]++;
+ $tocData['main/datatypes'] = [
+ 'toclevel' => count( $tocnumber ),
+ 'level' => $level,
+ 'anchor' => 'main/datatypes',
+ 'line' => $header,
+ 'number' => implode( '.', $tocnumber ),
+ 'index' => false,
+ ];
+ }
+
+ $header = $this->msg( 'api-credits-header' )->parse();
+ $id = Sanitizer::escapeIdForAttribute( 'main/credits', Sanitizer::ID_PRIMARY );
+ $idFallback = Sanitizer::escapeIdForAttribute( 'main/credits', Sanitizer::ID_FALLBACK );
+ $headline = Linker::makeHeadline( min( 6, $level ),
+ ' class="apihelp-header">',
+ $id,
+ $header,
+ '',
+ $idFallback
+ );
+ // Ensure we have a sane anchor
+ if ( $id !== 'main/credits' && $idFallback !== 'main/credits' ) {
+ $headline = '<div id="main/credits"></div>' . $headline;
+ }
+ $help['credits'] .= $headline;
+ $help['credits'] .= $this->msg( 'api-credits' )->useDatabase( false )->parseAsBlock();
+ if ( !isset( $tocData['main/credits'] ) ) {
+ $tocnumber[$level]++;
+ $tocData['main/credits'] = [
+ 'toclevel' => count( $tocnumber ),
+ 'level' => $level,
+ 'anchor' => 'main/credits',
+ 'line' => $header,
+ 'number' => implode( '.', $tocnumber ),
+ 'index' => false,
+ ];
+ }
+ }
+ }
+
+ private $mCanApiHighLimits = null;
+
+ /**
+ * Check whether the current user is allowed to use high limits
+ * @return bool
+ */
+ public function canApiHighLimits() {
+ if ( !isset( $this->mCanApiHighLimits ) ) {
+ $this->mCanApiHighLimits = $this->getUser()->isAllowed( 'apihighlimits' );
+ }
+
+ return $this->mCanApiHighLimits;
+ }
+
+ /**
+ * Overrides to return this instance's module manager.
+ * @return ApiModuleManager
+ */
+ public function getModuleManager() {
+ return $this->mModuleMgr;
+ }
+
+ /**
+ * Fetches the user agent used for this request
+ *
+ * The value will be the combination of the 'Api-User-Agent' header (if
+ * any) and the standard User-Agent header (if any).
+ *
+ * @return string
+ */
+ public function getUserAgent() {
+ return trim(
+ $this->getRequest()->getHeader( 'Api-user-agent' ) . ' ' .
+ $this->getRequest()->getHeader( 'User-agent' )
+ );
+ }
+}
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ */
diff --git a/www/wiki/includes/api/ApiManageTags.php b/www/wiki/includes/api/ApiManageTags.php
new file mode 100644
index 00000000..42de1610
--- /dev/null
+++ b/www/wiki/includes/api/ApiManageTags.php
@@ -0,0 +1,130 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ * @since 1.25
+ */
+class ApiManageTags extends ApiBase {
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $user = $this->getUser();
+
+ // make sure the user is allowed
+ if ( $params['operation'] !== 'delete'
+ && !$this->getUser()->isAllowed( 'managechangetags' )
+ ) {
+ $this->dieWithError( 'tags-manage-no-permission', 'permissiondenied' );
+ } elseif ( !$this->getUser()->isAllowed( 'deletechangetags' ) ) {
+ $this->dieWithError( 'tags-delete-no-permission', 'permissiondenied' );
+ }
+
+ // Check if user can add the log entry tags which were requested
+ if ( $params['tags'] ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ $result = $this->getResult();
+ $funcName = "{$params['operation']}TagWithChecks";
+ $status = ChangeTags::$funcName(
+ $params['tag'],
+ $params['reason'],
+ $user,
+ $params['ignorewarnings'],
+ $params['tags'] ?: []
+ );
+
+ if ( !$status->isOK() ) {
+ $this->dieStatus( $status );
+ }
+
+ $ret = [
+ 'operation' => $params['operation'],
+ 'tag' => $params['tag'],
+ ];
+ if ( !$status->isGood() ) {
+ $ret['warnings'] = $this->getErrorFormatter()->arrayFromStatus( $status, 'warning' );
+ }
+ $ret['success'] = $status->value !== null;
+ if ( $ret['success'] ) {
+ $ret['logid'] = $status->value;
+ }
+
+ $result->addValue( null, $this->getModuleName(), $ret );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'operation' => [
+ ApiBase::PARAM_TYPE => [ 'create', 'delete', 'activate', 'deactivate' ],
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'tag' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'reason' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'ignorewarnings' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ],
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=managetags&operation=create&tag=spam&reason=For+use+in+edit+patrolling&token=123ABC'
+ => 'apihelp-managetags-example-create',
+ 'action=managetags&operation=delete&tag=vandlaism&reason=Misspelt&token=123ABC'
+ => 'apihelp-managetags-example-delete',
+ 'action=managetags&operation=activate&tag=spam&reason=For+use+in+edit+patrolling&token=123ABC'
+ => 'apihelp-managetags-example-activate',
+ 'action=managetags&operation=deactivate&tag=spam&reason=No+longer+required&token=123ABC'
+ => 'apihelp-managetags-example-deactivate',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Tag_management';
+ }
+}
diff --git a/www/wiki/includes/api/ApiMergeHistory.php b/www/wiki/includes/api/ApiMergeHistory.php
new file mode 100644
index 00000000..79e99095
--- /dev/null
+++ b/www/wiki/includes/api/ApiMergeHistory.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ *
+ *
+ * Created on Dec 29, 2015
+ *
+ * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API Module to merge page histories
+ * @ingroup API
+ */
+class ApiMergeHistory extends ApiBase {
+
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $params = $this->extractRequestParams();
+
+ $this->requireOnlyOneParameter( $params, 'from', 'fromid' );
+ $this->requireOnlyOneParameter( $params, 'to', 'toid' );
+
+ // Get page objects (nonexistant pages get caught in MergeHistory::isValidMerge())
+ if ( isset( $params['from'] ) ) {
+ $fromTitle = Title::newFromText( $params['from'] );
+ if ( !$fromTitle || $fromTitle->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['from'] ) ] );
+ }
+ } elseif ( isset( $params['fromid'] ) ) {
+ $fromTitle = Title::newFromID( $params['fromid'] );
+ if ( !$fromTitle ) {
+ $this->dieWithError( [ 'apierror-nosuchpageid', $params['fromid'] ] );
+ }
+ }
+
+ if ( isset( $params['to'] ) ) {
+ $toTitle = Title::newFromText( $params['to'] );
+ if ( !$toTitle || $toTitle->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['to'] ) ] );
+ }
+ } elseif ( isset( $params['toid'] ) ) {
+ $toTitle = Title::newFromID( $params['toid'] );
+ if ( !$toTitle ) {
+ $this->dieWithError( [ 'apierror-nosuchpageid', $params['toid'] ] );
+ }
+ }
+
+ $reason = $params['reason'];
+ $timestamp = $params['timestamp'];
+
+ // Merge!
+ $status = $this->merge( $fromTitle, $toTitle, $timestamp, $reason );
+ if ( !$status->isOK() ) {
+ $this->dieStatus( $status );
+ }
+
+ $r = [
+ 'from' => $fromTitle->getPrefixedText(),
+ 'to' => $toTitle->getPrefixedText(),
+ 'timestamp' => wfTimestamp( TS_ISO_8601, $params['timestamp'] ),
+ 'reason' => $params['reason']
+ ];
+ $result = $this->getResult();
+
+ $result->addValue( null, $this->getModuleName(), $r );
+ }
+
+ /**
+ * @param Title $from
+ * @param Title $to
+ * @param string $timestamp
+ * @param string $reason
+ * @return Status
+ */
+ protected function merge( Title $from, Title $to, $timestamp, $reason ) {
+ $mh = new MergeHistory( $from, $to, $timestamp );
+
+ return $mh->merge( $this->getUser(), $reason );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'from' => null,
+ 'fromid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'to' => null,
+ 'toid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'timestamp' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'reason' => '',
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=mergehistory&from=Oldpage&to=Newpage&token=123ABC&' .
+ 'reason=Reason'
+ => 'apihelp-mergehistory-example-merge',
+ 'action=mergehistory&from=Oldpage&to=Newpage&token=123ABC&' .
+ 'reason=Reason&timestamp=2015-12-31T04%3A37%3A41Z' // TODO
+ => 'apihelp-mergehistory-example-merge-timestamp',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Mergehistory';
+ }
+}
diff --git a/www/wiki/includes/api/ApiMessage.php b/www/wiki/includes/api/ApiMessage.php
new file mode 100644
index 00000000..9e42d5fd
--- /dev/null
+++ b/www/wiki/includes/api/ApiMessage.php
@@ -0,0 +1,294 @@
+<?php
+/**
+ * Defines an interface for messages with additional machine-readable data for
+ * use by the API, and provides concrete implementations of that interface.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Interface for messages with machine-readable data for use by the API
+ *
+ * The idea is that it's a Message that has some extra data for the API to use when interpreting it
+ * as an error (or, in the future, as a warning). Internals of MediaWiki often use messages (or
+ * message keys, or Status objects containing messages) to pass information about errors to the user
+ * (see e.g. Title::getUserPermissionsErrors()) and the API has to make do with that.
+ *
+ * @since 1.25
+ * @ingroup API
+ */
+interface IApiMessage extends MessageSpecifier {
+ /**
+ * Returns a machine-readable code for use by the API
+ *
+ * If no code was specifically set, the message key is used as the code
+ * after removing "apiwarn-" or "apierror-" prefixes and applying
+ * backwards-compatibility mappings.
+ *
+ * @return string
+ */
+ public function getApiCode();
+
+ /**
+ * Returns additional machine-readable data about the error condition
+ * @return array
+ */
+ public function getApiData();
+
+ /**
+ * Sets the machine-readable code for use by the API
+ * @param string|null $code If null, uses the default (see self::getApiCode())
+ * @param array|null $data If non-null, passed to self::setApiData()
+ */
+ public function setApiCode( $code, array $data = null );
+
+ /**
+ * Sets additional machine-readable data about the error condition
+ * @param array $data
+ */
+ public function setApiData( array $data );
+}
+
+/**
+ * Trait to implement the IApiMessage interface for Message subclasses
+ * @since 1.27
+ * @ingroup API
+ */
+trait ApiMessageTrait {
+
+ /**
+ * Compatibility code mappings for various MW messages.
+ * @todo Ideally anything relying on this should be changed to use ApiMessage.
+ */
+ protected static $messageMap = [
+ 'actionthrottledtext' => 'ratelimited',
+ 'autoblockedtext' => 'autoblocked',
+ 'badaccess-group0' => 'permissiondenied',
+ 'badaccess-groups' => 'permissiondenied',
+ 'badipaddress' => 'invalidip',
+ 'blankpage' => 'emptypage',
+ 'blockedtext' => 'blocked',
+ 'cannotdelete' => 'cantdelete',
+ 'cannotundelete' => 'cantundelete',
+ 'cantmove-titleprotected' => 'protectedtitle',
+ 'cantrollback' => 'onlyauthor',
+ 'confirmedittext' => 'confirmemail',
+ 'content-not-allowed-here' => 'contentnotallowedhere',
+ 'deleteprotected' => 'cantedit',
+ 'delete-toobig' => 'bigdelete',
+ 'edit-conflict' => 'editconflict',
+ 'imagenocrossnamespace' => 'nonfilenamespace',
+ 'imagetypemismatch' => 'filetypemismatch',
+ 'importbadinterwiki' => 'badinterwiki',
+ 'importcantopen' => 'cantopenfile',
+ 'import-noarticle' => 'badinterwiki',
+ 'importnofile' => 'nofile',
+ 'importuploaderrorpartial' => 'partialupload',
+ 'importuploaderrorsize' => 'filetoobig',
+ 'importuploaderrortemp' => 'notempdir',
+ 'ipb_already_blocked' => 'alreadyblocked',
+ 'ipb_blocked_as_range' => 'blockedasrange',
+ 'ipb_cant_unblock' => 'cantunblock',
+ 'ipb_expiry_invalid' => 'invalidexpiry',
+ 'ip_range_invalid' => 'invalidrange',
+ 'mailnologin' => 'cantsend',
+ 'markedaspatrollederror-noautopatrol' => 'noautopatrol',
+ 'movenologintext' => 'cantmove-anon',
+ 'movenotallowed' => 'cantmove',
+ 'movenotallowedfile' => 'cantmovefile',
+ 'namespaceprotected' => 'protectednamespace',
+ 'nocreate-loggedin' => 'cantcreate',
+ 'nocreatetext' => 'cantcreate-anon',
+ 'noname' => 'invaliduser',
+ 'nosuchusershort' => 'nosuchuser',
+ 'notanarticle' => 'missingtitle',
+ 'nouserspecified' => 'invaliduser',
+ 'ns-specialprotected' => 'unsupportednamespace',
+ 'protect-cantedit' => 'cantedit',
+ 'protectedinterface' => 'protectednamespace-interface',
+ 'protectedpagetext' => 'protectedpage',
+ 'range_block_disabled' => 'rangedisabled',
+ 'rcpatroldisabled' => 'patroldisabled',
+ 'readonlytext' => 'readonly',
+ 'sessionfailure' => 'badtoken',
+ 'systemblockedtext' => 'blocked',
+ 'titleprotected' => 'protectedtitle',
+ 'undo-failure' => 'undofailure',
+ 'userrights-nodatabase' => 'nosuchdatabase',
+ 'userrights-no-interwiki' => 'nointerwikiuserrights',
+ ];
+
+ protected $apiCode = null;
+ protected $apiData = [];
+
+ public function getApiCode() {
+ if ( $this->apiCode === null ) {
+ $key = $this->getKey();
+ if ( isset( self::$messageMap[$key] ) ) {
+ $this->apiCode = self::$messageMap[$key];
+ } elseif ( $key === 'apierror-missingparam' ) {
+ /// @todo: Kill this case along with ApiBase::$messageMap
+ $this->apiCode = 'no' . $this->getParams()[0];
+ } elseif ( substr( $key, 0, 8 ) === 'apiwarn-' ) {
+ $this->apiCode = substr( $key, 8 );
+ } elseif ( substr( $key, 0, 9 ) === 'apierror-' ) {
+ $this->apiCode = substr( $key, 9 );
+ } else {
+ $this->apiCode = $key;
+ }
+ }
+ return $this->apiCode;
+ }
+
+ public function setApiCode( $code, array $data = null ) {
+ if ( $code !== null && !( is_string( $code ) && $code !== '' ) ) {
+ throw new InvalidArgumentException( "Invalid code \"$code\"" );
+ }
+
+ $this->apiCode = $code;
+ if ( $data !== null ) {
+ $this->setApiData( $data );
+ }
+ }
+
+ public function getApiData() {
+ return $this->apiData;
+ }
+
+ public function setApiData( array $data ) {
+ $this->apiData = $data;
+ }
+
+ public function serialize() {
+ return serialize( [
+ 'parent' => parent::serialize(),
+ 'apiCode' => $this->apiCode,
+ 'apiData' => $this->apiData,
+ ] );
+ }
+
+ public function unserialize( $serialized ) {
+ $data = unserialize( $serialized );
+ parent::unserialize( $data['parent'] );
+ $this->apiCode = $data['apiCode'];
+ $this->apiData = $data['apiData'];
+ }
+}
+
+/**
+ * Extension of Message implementing IApiMessage
+ * @since 1.25
+ * @ingroup API
+ */
+class ApiMessage extends Message implements IApiMessage {
+ use ApiMessageTrait;
+
+ /**
+ * Create an IApiMessage for the message
+ *
+ * This returns $msg if it's an IApiMessage, calls 'new ApiRawMessage' if
+ * $msg is a RawMessage, or calls 'new ApiMessage' in all other cases.
+ *
+ * @param Message|RawMessage|array|string $msg
+ * @param string|null $code
+ * @param array|null $data
+ * @return IApiMessage
+ */
+ public static function create( $msg, $code = null, array $data = null ) {
+ if ( is_array( $msg ) ) {
+ // From StatusValue
+ if ( isset( $msg['message'] ) ) {
+ if ( isset( $msg['params'] ) ) {
+ $msg = array_merge( [ $msg['message'] ], $msg['params'] );
+ } else {
+ $msg = [ $msg['message'] ];
+ }
+ }
+
+ // Weirdness that comes in sometimes, including the above
+ if ( $msg[0] instanceof MessageSpecifier ) {
+ $msg = $msg[0];
+ }
+ }
+
+ if ( $msg instanceof IApiMessage ) {
+ return $msg;
+ } elseif ( $msg instanceof RawMessage ) {
+ return new ApiRawMessage( $msg, $code, $data );
+ } else {
+ return new ApiMessage( $msg, $code, $data );
+ }
+ }
+
+ /**
+ * @param Message|string|array $msg
+ * - Message: is cloned
+ * - array: first element is $key, rest are $params to Message::__construct
+ * - string: passed to Message::__construct
+ * @param string|null $code
+ * @param array|null $data
+ */
+ public function __construct( $msg, $code = null, array $data = null ) {
+ if ( $msg instanceof Message ) {
+ foreach ( get_class_vars( get_class( $this ) ) as $key => $value ) {
+ if ( isset( $msg->$key ) ) {
+ $this->$key = $msg->$key;
+ }
+ }
+ } elseif ( is_array( $msg ) ) {
+ $key = array_shift( $msg );
+ parent::__construct( $key, $msg );
+ } else {
+ parent::__construct( $msg );
+ }
+ $this->setApiCode( $code, $data );
+ }
+}
+
+/**
+ * Extension of RawMessage implementing IApiMessage
+ * @since 1.25
+ * @ingroup API
+ */
+class ApiRawMessage extends RawMessage implements IApiMessage {
+ use ApiMessageTrait;
+
+ /**
+ * @param RawMessage|string|array $msg
+ * - RawMessage: is cloned
+ * - array: first element is $key, rest are $params to RawMessage::__construct
+ * - string: passed to RawMessage::__construct
+ * @param string|null $code
+ * @param array|null $data
+ */
+ public function __construct( $msg, $code = null, array $data = null ) {
+ if ( $msg instanceof RawMessage ) {
+ foreach ( get_class_vars( get_class( $this ) ) as $key => $value ) {
+ if ( isset( $msg->$key ) ) {
+ $this->$key = $msg->$key;
+ }
+ }
+ } elseif ( is_array( $msg ) ) {
+ $key = array_shift( $msg );
+ parent::__construct( $key, $msg );
+ } else {
+ parent::__construct( $msg );
+ }
+ $this->setApiCode( $code, $data );
+ }
+}
diff --git a/www/wiki/includes/api/ApiModuleManager.php b/www/wiki/includes/api/ApiModuleManager.php
new file mode 100644
index 00000000..b5e47ac9
--- /dev/null
+++ b/www/wiki/includes/api/ApiModuleManager.php
@@ -0,0 +1,294 @@
+<?php
+/**
+ *
+ *
+ * Created on Dec 27, 2012
+ *
+ * Copyright © 2012 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.21
+ */
+
+/**
+ * This class holds a list of modules and handles instantiation
+ *
+ * @since 1.21
+ * @ingroup API
+ */
+class ApiModuleManager extends ContextSource {
+
+ /**
+ * @var ApiBase
+ */
+ private $mParent;
+ /**
+ * @var ApiBase[]
+ */
+ private $mInstances = [];
+ /**
+ * @var null[]
+ */
+ private $mGroups = [];
+ /**
+ * @var array[]
+ */
+ private $mModules = [];
+
+ /**
+ * Construct new module manager
+ * @param ApiBase $parentModule Parent module instance will be used during instantiation
+ */
+ public function __construct( ApiBase $parentModule ) {
+ $this->mParent = $parentModule;
+ }
+
+ /**
+ * Add a list of modules to the manager. Each module is described
+ * by a module spec.
+ *
+ * Each module spec is an associative array containing at least
+ * the 'class' key for the module's class, and optionally a
+ * 'factory' key for the factory function to use for the module.
+ *
+ * That factory function will be called with two parameters,
+ * the parent module (an instance of ApiBase, usually ApiMain)
+ * and the name the module was registered under. The return
+ * value must be an instance of the class given in the 'class'
+ * field.
+ *
+ * For backward compatibility, the module spec may also be a
+ * simple string containing the module's class name. In that
+ * case, the class' constructor will be called with the parent
+ * module and module name as parameters, as described above.
+ *
+ * Examples for defining module specs:
+ *
+ * @code
+ * $modules['foo'] = 'ApiFoo';
+ * $modules['bar'] = [
+ * 'class' => 'ApiBar',
+ * 'factory' => function( $main, $name ) { ... }
+ * ];
+ * $modules['xyzzy'] = [
+ * 'class' => 'ApiXyzzy',
+ * 'factory' => [ 'XyzzyFactory', 'newApiModule' ]
+ * ];
+ * @endcode
+ *
+ * @param array $modules A map of ModuleName => ModuleSpec; The ModuleSpec
+ * is either a string containing the module's class name, or an associative
+ * array (see above for details).
+ * @param string $group Which group modules belong to (action,format,...)
+ */
+ public function addModules( array $modules, $group ) {
+ foreach ( $modules as $name => $moduleSpec ) {
+ if ( is_array( $moduleSpec ) ) {
+ $class = $moduleSpec['class'];
+ $factory = ( isset( $moduleSpec['factory'] ) ? $moduleSpec['factory'] : null );
+ } else {
+ $class = $moduleSpec;
+ $factory = null;
+ }
+
+ $this->addModule( $name, $group, $class, $factory );
+ }
+ }
+
+ /**
+ * Add or overwrite a module in this ApiMain instance. Intended for use by extending
+ * classes who wish to add their own modules to their lexicon or override the
+ * behavior of inherent ones.
+ *
+ * @param string $name The identifier for this module.
+ * @param string $group Name of the module group
+ * @param string $class The class where this module is implemented.
+ * @param callable|null $factory Callback for instantiating the module.
+ *
+ * @throws InvalidArgumentException
+ */
+ public function addModule( $name, $group, $class, $factory = null ) {
+ if ( !is_string( $name ) ) {
+ throw new InvalidArgumentException( '$name must be a string' );
+ }
+
+ if ( !is_string( $group ) ) {
+ throw new InvalidArgumentException( '$group must be a string' );
+ }
+
+ if ( !is_string( $class ) ) {
+ throw new InvalidArgumentException( '$class must be a string' );
+ }
+
+ if ( $factory !== null && !is_callable( $factory ) ) {
+ throw new InvalidArgumentException( '$factory must be a callable (or null)' );
+ }
+
+ $this->mGroups[$group] = null;
+ $this->mModules[$name] = [ $group, $class, $factory ];
+ }
+
+ /**
+ * Get module instance by name, or instantiate it if it does not exist
+ *
+ * @param string $moduleName Module name
+ * @param string $group Optionally validate that the module is in a specific group
+ * @param bool $ignoreCache If true, force-creates a new instance and does not cache it
+ *
+ * @return ApiBase|null The new module instance, or null if failed
+ */
+ public function getModule( $moduleName, $group = null, $ignoreCache = false ) {
+ if ( !isset( $this->mModules[$moduleName] ) ) {
+ return null;
+ }
+
+ list( $moduleGroup, $moduleClass, $moduleFactory ) = $this->mModules[$moduleName];
+
+ if ( $group !== null && $moduleGroup !== $group ) {
+ return null;
+ }
+
+ if ( !$ignoreCache && isset( $this->mInstances[$moduleName] ) ) {
+ // already exists
+ return $this->mInstances[$moduleName];
+ } else {
+ // new instance
+ $instance = $this->instantiateModule( $moduleName, $moduleClass, $moduleFactory );
+
+ if ( !$ignoreCache ) {
+ // cache this instance in case it is needed later
+ $this->mInstances[$moduleName] = $instance;
+ }
+
+ return $instance;
+ }
+ }
+
+ /**
+ * Instantiate the module using the given class or factory function.
+ *
+ * @param string $name The identifier for this module.
+ * @param string $class The class where this module is implemented.
+ * @param callable|null $factory Callback for instantiating the module.
+ *
+ * @throws MWException
+ * @return ApiBase
+ */
+ private function instantiateModule( $name, $class, $factory = null ) {
+ if ( $factory !== null ) {
+ // create instance from factory
+ $instance = call_user_func( $factory, $this->mParent, $name );
+
+ if ( !$instance instanceof $class ) {
+ throw new MWException(
+ "The factory function for module $name did not return an instance of $class!"
+ );
+ }
+ } else {
+ // create instance from class name
+ $instance = new $class( $this->mParent, $name );
+ }
+
+ return $instance;
+ }
+
+ /**
+ * Get an array of modules in a specific group or all if no group is set.
+ * @param string $group Optional group filter
+ * @return array List of module names
+ */
+ public function getNames( $group = null ) {
+ if ( $group === null ) {
+ return array_keys( $this->mModules );
+ }
+ $result = [];
+ foreach ( $this->mModules as $name => $grpCls ) {
+ if ( $grpCls[0] === $group ) {
+ $result[] = $name;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Create an array of (moduleName => moduleClass) for a specific group or for all.
+ * @param string $group Name of the group to get or null for all
+ * @return array Name=>class map
+ */
+ public function getNamesWithClasses( $group = null ) {
+ $result = [];
+ foreach ( $this->mModules as $name => $grpCls ) {
+ if ( $group === null || $grpCls[0] === $group ) {
+ $result[$name] = $grpCls[1];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns the class name of the given module
+ *
+ * @param string $module Module name
+ * @return string|bool class name or false if the module does not exist
+ * @since 1.24
+ */
+ public function getClassName( $module ) {
+ if ( isset( $this->mModules[$module] ) ) {
+ return $this->mModules[$module][1];
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns true if the specific module is defined at all or in a specific group.
+ * @param string $moduleName Module name
+ * @param string $group Group name to check against, or null to check all groups,
+ * @return bool True if defined
+ */
+ public function isDefined( $moduleName, $group = null ) {
+ if ( isset( $this->mModules[$moduleName] ) ) {
+ return $group === null || $this->mModules[$moduleName][0] === $group;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the group name for the given module
+ * @param string $moduleName
+ * @return string|null Group name or null if missing
+ */
+ public function getModuleGroup( $moduleName ) {
+ if ( isset( $this->mModules[$moduleName] ) ) {
+ return $this->mModules[$moduleName][0];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get a list of groups this manager contains.
+ * @return array
+ */
+ public function getGroups() {
+ return array_keys( $this->mGroups );
+ }
+}
diff --git a/www/wiki/includes/api/ApiMove.php b/www/wiki/includes/api/ApiMove.php
new file mode 100644
index 00000000..e7b28080
--- /dev/null
+++ b/www/wiki/includes/api/ApiMove.php
@@ -0,0 +1,297 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 31, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API Module to move pages
+ * @ingroup API
+ */
+class ApiMove extends ApiBase {
+
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $user = $this->getUser();
+ $params = $this->extractRequestParams();
+
+ $this->requireOnlyOneParameter( $params, 'from', 'fromid' );
+
+ if ( isset( $params['from'] ) ) {
+ $fromTitle = Title::newFromText( $params['from'] );
+ if ( !$fromTitle || $fromTitle->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['from'] ) ] );
+ }
+ } elseif ( isset( $params['fromid'] ) ) {
+ $fromTitle = Title::newFromID( $params['fromid'] );
+ if ( !$fromTitle ) {
+ $this->dieWithError( [ 'apierror-nosuchpageid', $params['fromid'] ] );
+ }
+ }
+
+ if ( !$fromTitle->exists() ) {
+ $this->dieWithError( 'apierror-missingtitle' );
+ }
+ $fromTalk = $fromTitle->getTalkPage();
+
+ $toTitle = Title::newFromText( $params['to'] );
+ if ( !$toTitle || $toTitle->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['to'] ) ] );
+ }
+ $toTalk = $toTitle->getTalkPageIfDefined();
+
+ if ( $toTitle->getNamespace() == NS_FILE
+ && !RepoGroup::singleton()->getLocalRepo()->findFile( $toTitle )
+ && wfFindFile( $toTitle )
+ ) {
+ if ( !$params['ignorewarnings'] && $user->isAllowed( 'reupload-shared' ) ) {
+ $this->dieWithError( 'apierror-fileexists-sharedrepo-perm' );
+ } elseif ( !$user->isAllowed( 'reupload-shared' ) ) {
+ $this->dieWithError( 'apierror-cantoverwrite-sharedfile' );
+ }
+ }
+
+ // Rate limit
+ if ( $user->pingLimiter( 'move' ) ) {
+ $this->dieWithError( 'apierror-ratelimited' );
+ }
+
+ // Check if the user is allowed to add the specified changetags
+ if ( $params['tags'] ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ // Move the page
+ $toTitleExists = $toTitle->exists();
+ $status = $this->movePage( $fromTitle, $toTitle, $params['reason'], !$params['noredirect'],
+ $params['tags'] ?: [] );
+ if ( !$status->isOK() ) {
+ $this->dieStatus( $status );
+ }
+
+ $r = [
+ 'from' => $fromTitle->getPrefixedText(),
+ 'to' => $toTitle->getPrefixedText(),
+ 'reason' => $params['reason']
+ ];
+
+ // NOTE: we assume that if the old title exists, it's because it was re-created as
+ // a redirect to the new title. This is not safe, but what we did before was
+ // even worse: we just determined whether a redirect should have been created,
+ // and reported that it was created if it should have, without any checks.
+ // Also note that isRedirect() is unreliable because of T39209.
+ $r['redirectcreated'] = $fromTitle->exists();
+
+ $r['moveoverredirect'] = $toTitleExists;
+
+ // Move the talk page
+ if ( $params['movetalk'] && $toTalk && $fromTalk->exists() && !$fromTitle->isTalkPage() ) {
+ $toTalkExists = $toTalk->exists();
+ $status = $this->movePage(
+ $fromTalk,
+ $toTalk,
+ $params['reason'],
+ !$params['noredirect'],
+ $params['tags'] ?: []
+ );
+ if ( $status->isOK() ) {
+ $r['talkfrom'] = $fromTalk->getPrefixedText();
+ $r['talkto'] = $toTalk->getPrefixedText();
+ $r['talkmoveoverredirect'] = $toTalkExists;
+ } else {
+ // We're not going to dieWithError() on failure, since we already changed something
+ $r['talkmove-errors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
+ }
+ }
+
+ $result = $this->getResult();
+
+ // Move subpages
+ if ( $params['movesubpages'] ) {
+ $r['subpages'] = $this->moveSubpages(
+ $fromTitle,
+ $toTitle,
+ $params['reason'],
+ $params['noredirect'],
+ $params['tags'] ?: []
+ );
+ ApiResult::setIndexedTagName( $r['subpages'], 'subpage' );
+
+ if ( $params['movetalk'] ) {
+ $r['subpages-talk'] = $this->moveSubpages(
+ $fromTalk,
+ $toTalk,
+ $params['reason'],
+ $params['noredirect'],
+ $params['tags'] ?: []
+ );
+ ApiResult::setIndexedTagName( $r['subpages-talk'], 'subpage' );
+ }
+ }
+
+ $watch = 'preferences';
+ if ( isset( $params['watchlist'] ) ) {
+ $watch = $params['watchlist'];
+ } elseif ( $params['watch'] ) {
+ $watch = 'watch';
+ } elseif ( $params['unwatch'] ) {
+ $watch = 'unwatch';
+ }
+
+ // Watch pages
+ $this->setWatch( $watch, $fromTitle, 'watchmoves' );
+ $this->setWatch( $watch, $toTitle, 'watchmoves' );
+
+ $result->addValue( null, $this->getModuleName(), $r );
+ }
+
+ /**
+ * @param Title $from
+ * @param Title $to
+ * @param string $reason
+ * @param bool $createRedirect
+ * @param array $changeTags Applied to the entry in the move log and redirect page revision
+ * @return Status
+ */
+ protected function movePage( Title $from, Title $to, $reason, $createRedirect, $changeTags ) {
+ $mp = new MovePage( $from, $to );
+ $valid = $mp->isValidMove();
+ if ( !$valid->isOK() ) {
+ return $valid;
+ }
+
+ $user = $this->getUser();
+ $permStatus = $mp->checkPermissions( $user, $reason );
+ if ( !$permStatus->isOK() ) {
+ return $permStatus;
+ }
+
+ // Check suppressredirect permission
+ if ( !$user->isAllowed( 'suppressredirect' ) ) {
+ $createRedirect = true;
+ }
+
+ return $mp->move( $user, $reason, $createRedirect, $changeTags );
+ }
+
+ /**
+ * @param Title $fromTitle
+ * @param Title $toTitle
+ * @param string $reason
+ * @param bool $noredirect
+ * @param array $changeTags Applied to the entry in the move log and redirect page revisions
+ * @return array
+ */
+ public function moveSubpages( $fromTitle, $toTitle, $reason, $noredirect, $changeTags = [] ) {
+ $retval = [];
+
+ $success = $fromTitle->moveSubpages( $toTitle, true, $reason, !$noredirect, $changeTags );
+ if ( isset( $success[0] ) ) {
+ $status = $this->errorArrayToStatus( $success );
+ return [ 'errors' => $this->getErrorFormatter()->arrayFromStatus( $status ) ];
+ }
+
+ // At least some pages could be moved
+ // Report each of them separately
+ foreach ( $success as $oldTitle => $newTitle ) {
+ $r = [ 'from' => $oldTitle ];
+ if ( is_array( $newTitle ) ) {
+ $status = $this->errorArrayToStatus( $newTitle );
+ $r['errors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
+ } else {
+ // Success
+ $r['to'] = $newTitle;
+ }
+ $retval[] = $r;
+ }
+
+ return $retval;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'from' => null,
+ 'fromid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'to' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'reason' => '',
+ 'movetalk' => false,
+ 'movesubpages' => false,
+ 'noredirect' => false,
+ 'watch' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'unwatch' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'watchlist' => [
+ ApiBase::PARAM_DFLT => 'preferences',
+ ApiBase::PARAM_TYPE => [
+ 'watch',
+ 'unwatch',
+ 'preferences',
+ 'nochange'
+ ],
+ ],
+ 'ignorewarnings' => false,
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=move&from=Badtitle&to=Goodtitle&token=123ABC&' .
+ 'reason=Misspelled%20title&movetalk=&noredirect='
+ => 'apihelp-move-example-move',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Move';
+ }
+}
diff --git a/www/wiki/includes/api/ApiOpenSearch.php b/www/wiki/includes/api/ApiOpenSearch.php
new file mode 100644
index 00000000..419fd140
--- /dev/null
+++ b/www/wiki/includes/api/ApiOpenSearch.php
@@ -0,0 +1,418 @@
+<?php
+/**
+ * Created on Oct 13, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ * Copyright © 2008 Brion Vibber <brion@wikimedia.org>
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @ingroup API
+ */
+class ApiOpenSearch extends ApiBase {
+ use SearchApi;
+
+ private $format = null;
+ private $fm = null;
+
+ /** @var array list of api allowed params */
+ private $allowedParams = null;
+
+ /**
+ * Get the output format
+ *
+ * @return string
+ */
+ protected function getFormat() {
+ if ( $this->format === null ) {
+ $params = $this->extractRequestParams();
+ $format = $params['format'];
+
+ $allowedParams = $this->getAllowedParams();
+ if ( !in_array( $format, $allowedParams['format'][ApiBase::PARAM_TYPE] ) ) {
+ $format = $allowedParams['format'][ApiBase::PARAM_DFLT];
+ }
+
+ if ( substr( $format, -2 ) === 'fm' ) {
+ $this->format = substr( $format, 0, -2 );
+ $this->fm = 'fm';
+ } else {
+ $this->format = $format;
+ $this->fm = '';
+ }
+ }
+ return $this->format;
+ }
+
+ public function getCustomPrinter() {
+ switch ( $this->getFormat() ) {
+ case 'json':
+ return new ApiOpenSearchFormatJson(
+ $this->getMain(), $this->fm, $this->getParameter( 'warningsaserror' )
+ );
+
+ case 'xml':
+ $printer = $this->getMain()->createPrinterByName( 'xml' . $this->fm );
+ $printer->setRootElement( 'SearchSuggestion' );
+ return $printer;
+
+ default:
+ ApiBase::dieDebug( __METHOD__, "Unsupported format '{$this->getFormat()}'" );
+ }
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $search = $params['search'];
+ $suggest = $params['suggest'];
+ $results = [];
+ if ( !$suggest || $this->getConfig()->get( 'EnableOpenSearchSuggest' ) ) {
+ // Open search results may be stored for a very long time
+ $this->getMain()->setCacheMaxAge( $this->getConfig()->get( 'SearchSuggestCacheExpiry' ) );
+ $this->getMain()->setCacheMode( 'public' );
+ $results = $this->search( $search, $params );
+
+ // Allow hooks to populate extracts and images
+ Hooks::run( 'ApiOpenSearchSuggest', [ &$results ] );
+
+ // Trim extracts, if necessary
+ $length = $this->getConfig()->get( 'OpenSearchDescriptionLength' );
+ foreach ( $results as &$r ) {
+ if ( is_string( $r['extract'] ) && !$r['extract trimmed'] ) {
+ $r['extract'] = self::trimExtract( $r['extract'], $length );
+ }
+ }
+ }
+
+ // Populate result object
+ $this->populateResult( $search, $results );
+ }
+
+ /**
+ * Perform the search
+ * @param string $search the search query
+ * @param array $params api request params
+ * @return array search results. Keys are integers.
+ */
+ private function search( $search, array $params ) {
+ $searchEngine = $this->buildSearchEngine( $params );
+ $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
+ $results = [];
+
+ if ( !$titles ) {
+ return $results;
+ }
+
+ // Special pages need unique integer ids in the return list, so we just
+ // assign them negative numbers because those won't clash with the
+ // always positive articleIds that non-special pages get.
+ $nextSpecialPageId = -1;
+
+ if ( $params['redirects'] === null ) {
+ // Backwards compatibility, don't resolve for JSON.
+ $resolveRedir = $this->getFormat() !== 'json';
+ } else {
+ $resolveRedir = $params['redirects'] === 'resolve';
+ }
+
+ if ( $resolveRedir ) {
+ // Query for redirects
+ $redirects = [];
+ $lb = new LinkBatch( $titles );
+ if ( !$lb->isEmpty() ) {
+ $db = $this->getDB();
+ $res = $db->select(
+ [ 'page', 'redirect' ],
+ [ 'page_namespace', 'page_title', 'rd_namespace', 'rd_title' ],
+ [
+ 'rd_from = page_id',
+ 'rd_interwiki IS NULL OR rd_interwiki = ' . $db->addQuotes( '' ),
+ $lb->constructSet( 'page', $db ),
+ ],
+ __METHOD__
+ );
+ foreach ( $res as $row ) {
+ $redirects[$row->page_namespace][$row->page_title] =
+ [ $row->rd_namespace, $row->rd_title ];
+ }
+ }
+
+ // Bypass any redirects
+ $seen = [];
+ foreach ( $titles as $title ) {
+ $ns = $title->getNamespace();
+ $dbkey = $title->getDBkey();
+ $from = null;
+ if ( isset( $redirects[$ns][$dbkey] ) ) {
+ list( $ns, $dbkey ) = $redirects[$ns][$dbkey];
+ $from = $title;
+ $title = Title::makeTitle( $ns, $dbkey );
+ }
+ if ( !isset( $seen[$ns][$dbkey] ) ) {
+ $seen[$ns][$dbkey] = true;
+ $resultId = $title->getArticleID();
+ if ( $resultId === 0 ) {
+ $resultId = $nextSpecialPageId;
+ $nextSpecialPageId -= 1;
+ }
+ $results[$resultId] = [
+ 'title' => $title,
+ 'redirect from' => $from,
+ 'extract' => false,
+ 'extract trimmed' => false,
+ 'image' => false,
+ 'url' => wfExpandUrl( $title->getFullURL(), PROTO_CURRENT ),
+ ];
+ }
+ }
+ } else {
+ foreach ( $titles as $title ) {
+ $resultId = $title->getArticleID();
+ if ( $resultId === 0 ) {
+ $resultId = $nextSpecialPageId;
+ $nextSpecialPageId -= 1;
+ }
+ $results[$resultId] = [
+ 'title' => $title,
+ 'redirect from' => null,
+ 'extract' => false,
+ 'extract trimmed' => false,
+ 'image' => false,
+ 'url' => wfExpandUrl( $title->getFullURL(), PROTO_CURRENT ),
+ ];
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * @param string $search
+ * @param array &$results
+ */
+ protected function populateResult( $search, &$results ) {
+ $result = $this->getResult();
+
+ switch ( $this->getFormat() ) {
+ case 'json':
+ // http://www.opensearch.org/Specifications/OpenSearch/Extensions/Suggestions/1.1
+ $result->addArrayType( null, 'array' );
+ $result->addValue( null, 0, strval( $search ) );
+ $terms = [];
+ $descriptions = [];
+ $urls = [];
+ foreach ( $results as $r ) {
+ $terms[] = $r['title']->getPrefixedText();
+ $descriptions[] = strval( $r['extract'] );
+ $urls[] = $r['url'];
+ }
+ $result->addValue( null, 1, $terms );
+ $result->addValue( null, 2, $descriptions );
+ $result->addValue( null, 3, $urls );
+ break;
+
+ case 'xml':
+ // https://msdn.microsoft.com/en-us/library/cc891508(v=vs.85).aspx
+ $imageKeys = [
+ 'source' => true,
+ 'alt' => true,
+ 'width' => true,
+ 'height' => true,
+ 'align' => true,
+ ];
+ $items = [];
+ foreach ( $results as $r ) {
+ $item = [
+ 'Text' => $r['title']->getPrefixedText(),
+ 'Url' => $r['url'],
+ ];
+ if ( is_string( $r['extract'] ) && $r['extract'] !== '' ) {
+ $item['Description'] = $r['extract'];
+ }
+ if ( is_array( $r['image'] ) && isset( $r['image']['source'] ) ) {
+ $item['Image'] = array_intersect_key( $r['image'], $imageKeys );
+ }
+ ApiResult::setSubelementsList( $item, array_keys( $item ) );
+ $items[] = $item;
+ }
+ ApiResult::setIndexedTagName( $items, 'Item' );
+ $result->addValue( null, 'version', '2.0' );
+ $result->addValue( null, 'xmlns', 'http://opensearch.org/searchsuggest2' );
+ $result->addValue( null, 'Query', strval( $search ) );
+ $result->addSubelementsList( null, 'Query' );
+ $result->addValue( null, 'Section', $items );
+ break;
+
+ default:
+ ApiBase::dieDebug( __METHOD__, "Unsupported format '{$this->getFormat()}'" );
+ }
+ }
+
+ public function getAllowedParams() {
+ if ( $this->allowedParams !== null ) {
+ return $this->allowedParams;
+ }
+ $this->allowedParams = $this->buildCommonApiParams( false ) + [
+ 'suggest' => false,
+ 'redirects' => [
+ ApiBase::PARAM_TYPE => [ 'return', 'resolve' ],
+ ],
+ 'format' => [
+ ApiBase::PARAM_DFLT => 'json',
+ ApiBase::PARAM_TYPE => [ 'json', 'jsonfm', 'xml', 'xmlfm' ],
+ ],
+ 'warningsaserror' => false,
+ ];
+
+ // Use open search specific default limit
+ $this->allowedParams['limit'][ApiBase::PARAM_DFLT] = $this->getConfig()->get(
+ 'OpenSearchDefaultLimit'
+ );
+
+ return $this->allowedParams;
+ }
+
+ public function getSearchProfileParams() {
+ return [
+ 'profile' => [
+ 'profile-type' => SearchEngine::COMPLETION_PROFILE_TYPE,
+ 'help-message' => 'apihelp-query+prefixsearch-param-profile'
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=opensearch&search=Te'
+ => 'apihelp-opensearch-example-te',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Opensearch';
+ }
+
+ /**
+ * Trim an extract to a sensible length.
+ *
+ * Adapted from Extension:OpenSearchXml, which adapted it from
+ * Extension:ActiveAbstract.
+ *
+ * @param string $text
+ * @param int $length Target length; actual result will continue to the end of a sentence.
+ * @return string
+ */
+ public static function trimExtract( $text, $length ) {
+ static $regex = null;
+
+ if ( $regex === null ) {
+ $endchars = [
+ '([^\d])\.\s', '\!\s', '\?\s', // regular ASCII
+ '。', // full-width ideographic full-stop
+ '.', '!', '?', // double-width roman forms
+ '。', // half-width ideographic full stop
+ ];
+ $endgroup = implode( '|', $endchars );
+ $end = "(?:$endgroup)";
+ $sentence = ".{{$length},}?$end+";
+ $regex = "/^($sentence)/u";
+ }
+
+ $matches = [];
+ if ( preg_match( $regex, $text, $matches ) ) {
+ return trim( $matches[1] );
+ } else {
+ // Just return the first line
+ return trim( explode( "\n", $text )[0] );
+ }
+ }
+
+ /**
+ * Fetch the template for a type.
+ *
+ * @param string $type MIME type
+ * @return string
+ * @throws MWException
+ */
+ public static function getOpenSearchTemplate( $type ) {
+ $config = MediaWikiServices::getInstance()->getSearchEngineConfig();
+ $template = $config->getConfig()->get( 'OpenSearchTemplate' );
+
+ if ( $template && $type === 'application/x-suggestions+json' ) {
+ return $template;
+ }
+
+ $ns = implode( '|', $config->defaultNamespaces() );
+ if ( !$ns ) {
+ $ns = '0';
+ }
+
+ switch ( $type ) {
+ case 'application/x-suggestions+json':
+ return $config->getConfig()->get( 'CanonicalServer' ) . wfScript( 'api' )
+ . '?action=opensearch&search={searchTerms}&namespace=' . $ns;
+
+ case 'application/x-suggestions+xml':
+ return $config->getConfig()->get( 'CanonicalServer' ) . wfScript( 'api' )
+ . '?action=opensearch&format=xml&search={searchTerms}&namespace=' . $ns;
+
+ default:
+ throw new MWException( __METHOD__ . ": Unknown type '$type'" );
+ }
+ }
+}
+
+class ApiOpenSearchFormatJson extends ApiFormatJson {
+ private $warningsAsError = false;
+
+ public function __construct( ApiMain $main, $fm, $warningsAsError ) {
+ parent::__construct( $main, "json$fm" );
+ $this->warningsAsError = $warningsAsError;
+ }
+
+ public function execute() {
+ $result = $this->getResult();
+ if ( !$result->getResultData( 'error' ) && !$result->getResultData( 'errors' ) ) {
+ // Ignore warnings or treat as errors, as requested
+ $warnings = $result->removeValue( 'warnings', null );
+ if ( $this->warningsAsError && $warnings ) {
+ $this->dieWithError(
+ 'apierror-opensearch-json-warnings',
+ 'warnings',
+ [ 'warnings' => $warnings ]
+ );
+ }
+
+ // Ignore any other unexpected keys (e.g. from $wgDebugToolbar)
+ $remove = array_keys( array_diff_key(
+ $result->getResultData(),
+ [ 0 => 'search', 1 => 'terms', 2 => 'descriptions', 3 => 'urls' ]
+ ) );
+ foreach ( $remove as $key ) {
+ $result->removeValue( $key, null );
+ }
+ }
+
+ parent::execute();
+ }
+}
diff --git a/www/wiki/includes/api/ApiOptions.php b/www/wiki/includes/api/ApiOptions.php
new file mode 100644
index 00000000..5b0d86a7
--- /dev/null
+++ b/www/wiki/includes/api/ApiOptions.php
@@ -0,0 +1,186 @@
+<?php
+/**
+ *
+ *
+ * Created on Apr 15, 2012
+ *
+ * Copyright © 2012 Szymon Świerkosz beau@adres.pl
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that facilitates the changing of user's preferences.
+ * Requires API write mode to be enabled.
+ *
+ * @ingroup API
+ */
+class ApiOptions extends ApiBase {
+ /**
+ * Changes preferences of the current user.
+ */
+ public function execute() {
+ if ( $this->getUser()->isAnon() ) {
+ $this->dieWithError(
+ [ 'apierror-mustbeloggedin', $this->msg( 'action-editmyoptions' ) ], 'notloggedin'
+ );
+ }
+
+ $this->checkUserRightsAny( 'editmyoptions' );
+
+ $params = $this->extractRequestParams();
+ $changed = false;
+
+ if ( isset( $params['optionvalue'] ) && !isset( $params['optionname'] ) ) {
+ $this->dieWithError( [ 'apierror-missingparam', 'optionname' ] );
+ }
+
+ // Load the user from the master to reduce CAS errors on double post (T95839)
+ $user = $this->getUser()->getInstanceForUpdate();
+ if ( !$user ) {
+ $this->dieWithError(
+ [ 'apierror-mustbeloggedin', $this->msg( 'action-editmyoptions' ) ], 'notloggedin'
+ );
+ }
+
+ if ( $params['reset'] ) {
+ $user->resetOptions( $params['resetkinds'], $this->getContext() );
+ $changed = true;
+ }
+
+ $changes = [];
+ if ( count( $params['change'] ) ) {
+ foreach ( $params['change'] as $entry ) {
+ $array = explode( '=', $entry, 2 );
+ $changes[$array[0]] = isset( $array[1] ) ? $array[1] : null;
+ }
+ }
+ if ( isset( $params['optionname'] ) ) {
+ $newValue = isset( $params['optionvalue'] ) ? $params['optionvalue'] : null;
+ $changes[$params['optionname']] = $newValue;
+ }
+ if ( !$changed && !count( $changes ) ) {
+ $this->dieWithError( 'apierror-nochanges' );
+ }
+
+ $prefs = Preferences::getPreferences( $user, $this->getContext() );
+ $prefsKinds = $user->getOptionKinds( $this->getContext(), $changes );
+
+ $htmlForm = null;
+ foreach ( $changes as $key => $value ) {
+ switch ( $prefsKinds[$key] ) {
+ case 'registered':
+ // Regular option.
+ if ( $htmlForm === null ) {
+ // We need a dummy HTMLForm for the validate callback...
+ $htmlForm = new HTMLForm( [], $this );
+ }
+ $field = HTMLForm::loadInputFromParameters( $key, $prefs[$key], $htmlForm );
+ $validation = $field->validate( $value, $user->getOptions() );
+ break;
+ case 'registered-multiselect':
+ case 'registered-checkmatrix':
+ // A key for a multiselect or checkmatrix option.
+ $validation = true;
+ $value = $value !== null ? (bool)$value : null;
+ break;
+ case 'userjs':
+ // Allow non-default preferences prefixed with 'userjs-', to be set by user scripts
+ if ( strlen( $key ) > 255 ) {
+ $validation = $this->msg( 'apiwarn-validationfailed-keytoolong', Message::numParam( 255 ) );
+ } elseif ( preg_match( '/[^a-zA-Z0-9_-]/', $key ) !== 0 ) {
+ $validation = $this->msg( 'apiwarn-validationfailed-badchars' );
+ } else {
+ $validation = true;
+ }
+ break;
+ case 'special':
+ $validation = $this->msg( 'apiwarn-validationfailed-cannotset' );
+ break;
+ case 'unused':
+ default:
+ $validation = $this->msg( 'apiwarn-validationfailed-badpref' );
+ break;
+ }
+ if ( $validation === true ) {
+ $user->setOption( $key, $value );
+ $changed = true;
+ } else {
+ $this->addWarning( [ 'apiwarn-validationfailed', wfEscapeWikitext( $key ), $validation ] );
+ }
+ }
+
+ if ( $changed ) {
+ // Commit changes
+ $user->saveSettings();
+ }
+
+ $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ $optionKinds = User::listOptionKinds();
+ $optionKinds[] = 'all';
+
+ return [
+ 'reset' => false,
+ 'resetkinds' => [
+ ApiBase::PARAM_TYPE => $optionKinds,
+ ApiBase::PARAM_DFLT => 'all',
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'change' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'optionname' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'optionvalue' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Options';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=options&reset=&token=123ABC'
+ => 'apihelp-options-example-reset',
+ 'action=options&change=skin=vector|hideminor=1&token=123ABC'
+ => 'apihelp-options-example-change',
+ 'action=options&reset=&change=skin=monobook&optionname=nickname&' .
+ 'optionvalue=[[User:Beau|Beau]]%20([[User_talk:Beau|talk]])&token=123ABC'
+ => 'apihelp-options-example-complex',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiPageSet.php b/www/wiki/includes/api/ApiPageSet.php
new file mode 100644
index 00000000..cfac761c
--- /dev/null
+++ b/www/wiki/includes/api/ApiPageSet.php
@@ -0,0 +1,1545 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 24, 2006
+ *
+ * Copyright © 2006, 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * This class contains a list of pages that the client has requested.
+ * Initially, when the client passes in titles=, pageids=, or revisions=
+ * parameter, an instance of the ApiPageSet class will normalize titles,
+ * determine if the pages/revisions exist, and prefetch any additional page
+ * data requested.
+ *
+ * When a generator is used, the result of the generator will become the input
+ * for the second instance of this class, and all subsequent actions will use
+ * the second instance for all their work.
+ *
+ * @ingroup API
+ * @since 1.21 derives from ApiBase instead of ApiQueryBase
+ */
+class ApiPageSet extends ApiBase {
+ /**
+ * Constructor flag: The new instance of ApiPageSet will ignore the 'generator=' parameter
+ * @since 1.21
+ */
+ const DISABLE_GENERATORS = 1;
+
+ private $mDbSource;
+ private $mParams;
+ private $mResolveRedirects;
+ private $mConvertTitles;
+ private $mAllowGenerator;
+
+ private $mAllPages = []; // [ns][dbkey] => page_id or negative when missing
+ private $mTitles = [];
+ private $mGoodAndMissingPages = []; // [ns][dbkey] => page_id or negative when missing
+ private $mGoodPages = []; // [ns][dbkey] => page_id
+ private $mGoodTitles = [];
+ private $mMissingPages = []; // [ns][dbkey] => fake page_id
+ private $mMissingTitles = [];
+ /** @var array [fake_page_id] => [ 'title' => $title, 'invalidreason' => $reason ] */
+ private $mInvalidTitles = [];
+ private $mMissingPageIDs = [];
+ private $mRedirectTitles = [];
+ private $mSpecialTitles = [];
+ private $mAllSpecials = []; // separate from mAllPages to avoid breaking getAllTitlesByNamespace()
+ private $mNormalizedTitles = [];
+ private $mInterwikiTitles = [];
+ /** @var Title[] */
+ private $mPendingRedirectIDs = [];
+ private $mPendingRedirectSpecialPages = []; // [dbkey] => [ Title $from, Title $to ]
+ private $mResolvedRedirectTitles = [];
+ private $mConvertedTitles = [];
+ private $mGoodRevIDs = [];
+ private $mLiveRevIDs = [];
+ private $mDeletedRevIDs = [];
+ private $mMissingRevIDs = [];
+ private $mGeneratorData = []; // [ns][dbkey] => data array
+ private $mFakePageId = -1;
+ private $mCacheMode = 'public';
+ private $mRequestedPageFields = [];
+ /** @var int */
+ private $mDefaultNamespace = NS_MAIN;
+ /** @var callable|null */
+ private $mRedirectMergePolicy;
+
+ /**
+ * Add all items from $values into the result
+ * @param array $result Output
+ * @param array $values Values to add
+ * @param string[] $flags The names of boolean flags to mark this element
+ * @param string $name If given, name of the value
+ */
+ private static function addValues( array &$result, $values, $flags = [], $name = null ) {
+ foreach ( $values as $val ) {
+ if ( $val instanceof Title ) {
+ $v = [];
+ ApiQueryBase::addTitleInfo( $v, $val );
+ } elseif ( $name !== null ) {
+ $v = [ $name => $val ];
+ } else {
+ $v = $val;
+ }
+ foreach ( $flags as $flag ) {
+ $v[$flag] = true;
+ }
+ $result[] = $v;
+ }
+ }
+
+ /**
+ * @param ApiBase $dbSource Module implementing getDB().
+ * Allows PageSet to reuse existing db connection from the shared state like ApiQuery.
+ * @param int $flags Zero or more flags like DISABLE_GENERATORS
+ * @param int $defaultNamespace The namespace to use if none is specified by a prefix.
+ * @since 1.21 accepts $flags instead of two boolean values
+ */
+ public function __construct( ApiBase $dbSource, $flags = 0, $defaultNamespace = NS_MAIN ) {
+ parent::__construct( $dbSource->getMain(), $dbSource->getModuleName() );
+ $this->mDbSource = $dbSource;
+ $this->mAllowGenerator = ( $flags & self::DISABLE_GENERATORS ) == 0;
+ $this->mDefaultNamespace = $defaultNamespace;
+
+ $this->mParams = $this->extractRequestParams();
+ $this->mResolveRedirects = $this->mParams['redirects'];
+ $this->mConvertTitles = $this->mParams['converttitles'];
+ }
+
+ /**
+ * In case execute() is not called, call this method to mark all relevant parameters as used
+ * This prevents unused parameters from being reported as warnings
+ */
+ public function executeDryRun() {
+ $this->executeInternal( true );
+ }
+
+ /**
+ * Populate the PageSet from the request parameters.
+ */
+ public function execute() {
+ $this->executeInternal( false );
+ }
+
+ /**
+ * Populate the PageSet from the request parameters.
+ * @param bool $isDryRun If true, instantiates generator, but only to mark
+ * relevant parameters as used
+ */
+ private function executeInternal( $isDryRun ) {
+ $generatorName = $this->mAllowGenerator ? $this->mParams['generator'] : null;
+ if ( isset( $generatorName ) ) {
+ $dbSource = $this->mDbSource;
+ if ( !$dbSource instanceof ApiQuery ) {
+ // If the parent container of this pageset is not ApiQuery, we must create it to run generator
+ $dbSource = $this->getMain()->getModuleManager()->getModule( 'query' );
+ }
+ $generator = $dbSource->getModuleManager()->getModule( $generatorName, null, true );
+ if ( $generator === null ) {
+ $this->dieWithError( [ 'apierror-badgenerator-unknown', $generatorName ], 'badgenerator' );
+ }
+ if ( !$generator instanceof ApiQueryGeneratorBase ) {
+ $this->dieWithError( [ 'apierror-badgenerator-notgenerator', $generatorName ], 'badgenerator' );
+ }
+ // Create a temporary pageset to store generator's output,
+ // add any additional fields generator may need, and execute pageset to populate titles/pageids
+ $tmpPageSet = new ApiPageSet( $dbSource, self::DISABLE_GENERATORS );
+ $generator->setGeneratorMode( $tmpPageSet );
+ $this->mCacheMode = $generator->getCacheMode( $generator->extractRequestParams() );
+
+ if ( !$isDryRun ) {
+ $generator->requestExtraData( $tmpPageSet );
+ }
+ $tmpPageSet->executeInternal( $isDryRun );
+
+ // populate this pageset with the generator output
+ if ( !$isDryRun ) {
+ $generator->executeGenerator( $this );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $apiModule = $this;
+ Hooks::run( 'APIQueryGeneratorAfterExecute', [ &$generator, &$apiModule ] );
+ } else {
+ // Prevent warnings from being reported on these parameters
+ $main = $this->getMain();
+ foreach ( $generator->extractRequestParams() as $paramName => $param ) {
+ $main->markParamsUsed( $generator->encodeParamName( $paramName ) );
+ }
+ }
+
+ if ( !$isDryRun ) {
+ $this->resolvePendingRedirects();
+ }
+ } else {
+ // Only one of the titles/pageids/revids is allowed at the same time
+ $dataSource = null;
+ if ( isset( $this->mParams['titles'] ) ) {
+ $dataSource = 'titles';
+ }
+ if ( isset( $this->mParams['pageids'] ) ) {
+ if ( isset( $dataSource ) ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-cannotusewith',
+ $this->encodeParamName( 'pageids' ),
+ $this->encodeParamName( $dataSource )
+ ],
+ 'multisource'
+ );
+ }
+ $dataSource = 'pageids';
+ }
+ if ( isset( $this->mParams['revids'] ) ) {
+ if ( isset( $dataSource ) ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-cannotusewith',
+ $this->encodeParamName( 'revids' ),
+ $this->encodeParamName( $dataSource )
+ ],
+ 'multisource'
+ );
+ }
+ $dataSource = 'revids';
+ }
+
+ if ( !$isDryRun ) {
+ // Populate page information with the original user input
+ switch ( $dataSource ) {
+ case 'titles':
+ $this->initFromTitles( $this->mParams['titles'] );
+ break;
+ case 'pageids':
+ $this->initFromPageIds( $this->mParams['pageids'] );
+ break;
+ case 'revids':
+ if ( $this->mResolveRedirects ) {
+ $this->addWarning( 'apiwarn-redirectsandrevids' );
+ }
+ $this->mResolveRedirects = false;
+ $this->initFromRevIDs( $this->mParams['revids'] );
+ break;
+ default:
+ // Do nothing - some queries do not need any of the data sources.
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Check whether this PageSet is resolving redirects
+ * @return bool
+ */
+ public function isResolvingRedirects() {
+ return $this->mResolveRedirects;
+ }
+
+ /**
+ * Return the parameter name that is the source of data for this PageSet
+ *
+ * If multiple source parameters are specified (e.g. titles and pageids),
+ * one will be named arbitrarily.
+ *
+ * @return string|null
+ */
+ public function getDataSource() {
+ if ( $this->mAllowGenerator && isset( $this->mParams['generator'] ) ) {
+ return 'generator';
+ }
+ if ( isset( $this->mParams['titles'] ) ) {
+ return 'titles';
+ }
+ if ( isset( $this->mParams['pageids'] ) ) {
+ return 'pageids';
+ }
+ if ( isset( $this->mParams['revids'] ) ) {
+ return 'revids';
+ }
+
+ return null;
+ }
+
+ /**
+ * Request an additional field from the page table.
+ * Must be called before execute()
+ * @param string $fieldName Field name
+ */
+ public function requestField( $fieldName ) {
+ $this->mRequestedPageFields[$fieldName] = null;
+ }
+
+ /**
+ * Get the value of a custom field previously requested through
+ * requestField()
+ * @param string $fieldName Field name
+ * @return mixed Field value
+ */
+ public function getCustomField( $fieldName ) {
+ return $this->mRequestedPageFields[$fieldName];
+ }
+
+ /**
+ * Get the fields that have to be queried from the page table:
+ * the ones requested through requestField() and a few basic ones
+ * we always need
+ * @return array Array of field names
+ */
+ public function getPageTableFields() {
+ // Ensure we get minimum required fields
+ // DON'T change this order
+ $pageFlds = [
+ 'page_namespace' => null,
+ 'page_title' => null,
+ 'page_id' => null,
+ ];
+
+ if ( $this->mResolveRedirects ) {
+ $pageFlds['page_is_redirect'] = null;
+ }
+
+ if ( $this->getConfig()->get( 'ContentHandlerUseDB' ) ) {
+ $pageFlds['page_content_model'] = null;
+ }
+
+ if ( $this->getConfig()->get( 'PageLanguageUseDB' ) ) {
+ $pageFlds['page_lang'] = null;
+ }
+
+ foreach ( LinkCache::getSelectFields() as $field ) {
+ $pageFlds[$field] = null;
+ }
+
+ $pageFlds = array_merge( $pageFlds, $this->mRequestedPageFields );
+
+ return array_keys( $pageFlds );
+ }
+
+ /**
+ * Returns an array [ns][dbkey] => page_id for all requested titles.
+ * page_id is a unique negative number in case title was not found.
+ * Invalid titles will also have negative page IDs and will be in namespace 0
+ * @return array
+ */
+ public function getAllTitlesByNamespace() {
+ return $this->mAllPages;
+ }
+
+ /**
+ * All Title objects provided.
+ * @return Title[]
+ */
+ public function getTitles() {
+ return $this->mTitles;
+ }
+
+ /**
+ * Returns the number of unique pages (not revisions) in the set.
+ * @return int
+ */
+ public function getTitleCount() {
+ return count( $this->mTitles );
+ }
+
+ /**
+ * Returns an array [ns][dbkey] => page_id for all good titles.
+ * @return array
+ */
+ public function getGoodTitlesByNamespace() {
+ return $this->mGoodPages;
+ }
+
+ /**
+ * Title objects that were found in the database.
+ * @return Title[] Array page_id (int) => Title (obj)
+ */
+ public function getGoodTitles() {
+ return $this->mGoodTitles;
+ }
+
+ /**
+ * Returns the number of found unique pages (not revisions) in the set.
+ * @return int
+ */
+ public function getGoodTitleCount() {
+ return count( $this->mGoodTitles );
+ }
+
+ /**
+ * Returns an array [ns][dbkey] => fake_page_id for all missing titles.
+ * fake_page_id is a unique negative number.
+ * @return array
+ */
+ public function getMissingTitlesByNamespace() {
+ return $this->mMissingPages;
+ }
+
+ /**
+ * Title objects that were NOT found in the database.
+ * The array's index will be negative for each item
+ * @return Title[]
+ */
+ public function getMissingTitles() {
+ return $this->mMissingTitles;
+ }
+
+ /**
+ * Returns an array [ns][dbkey] => page_id for all good and missing titles.
+ * @return array
+ */
+ public function getGoodAndMissingTitlesByNamespace() {
+ return $this->mGoodAndMissingPages;
+ }
+
+ /**
+ * Title objects for good and missing titles.
+ * @return array
+ */
+ public function getGoodAndMissingTitles() {
+ return $this->mGoodTitles + $this->mMissingTitles;
+ }
+
+ /**
+ * Titles that were deemed invalid by Title::newFromText()
+ * The array's index will be unique and negative for each item
+ * @deprecated since 1.26, use self::getInvalidTitlesAndReasons()
+ * @return string[] Array of strings (not Title objects)
+ */
+ public function getInvalidTitles() {
+ wfDeprecated( __METHOD__, '1.26' );
+ return array_map( function ( $t ) {
+ return $t['title'];
+ }, $this->mInvalidTitles );
+ }
+
+ /**
+ * Titles that were deemed invalid by Title::newFromText()
+ * The array's index will be unique and negative for each item
+ * @return array[] Array of arrays with 'title' and 'invalidreason' properties
+ */
+ public function getInvalidTitlesAndReasons() {
+ return $this->mInvalidTitles;
+ }
+
+ /**
+ * Page IDs that were not found in the database
+ * @return array Array of page IDs
+ */
+ public function getMissingPageIDs() {
+ return $this->mMissingPageIDs;
+ }
+
+ /**
+ * Get a list of redirect resolutions - maps a title to its redirect
+ * target, as an array of output-ready arrays
+ * @return Title[]
+ */
+ public function getRedirectTitles() {
+ return $this->mRedirectTitles;
+ }
+
+ /**
+ * Get a list of redirect resolutions - maps a title to its redirect
+ * target. Includes generator data for redirect source when available.
+ * @param ApiResult $result
+ * @return array Array of prefixed_title (string) => Title object
+ * @since 1.21
+ */
+ public function getRedirectTitlesAsResult( $result = null ) {
+ $values = [];
+ foreach ( $this->getRedirectTitles() as $titleStrFrom => $titleTo ) {
+ $r = [
+ 'from' => strval( $titleStrFrom ),
+ 'to' => $titleTo->getPrefixedText(),
+ ];
+ if ( $titleTo->hasFragment() ) {
+ $r['tofragment'] = $titleTo->getFragment();
+ }
+ if ( $titleTo->isExternal() ) {
+ $r['tointerwiki'] = $titleTo->getInterwiki();
+ }
+ if ( isset( $this->mResolvedRedirectTitles[$titleStrFrom] ) ) {
+ $titleFrom = $this->mResolvedRedirectTitles[$titleStrFrom];
+ $ns = $titleFrom->getNamespace();
+ $dbkey = $titleFrom->getDBkey();
+ if ( isset( $this->mGeneratorData[$ns][$dbkey] ) ) {
+ $r = array_merge( $this->mGeneratorData[$ns][$dbkey], $r );
+ }
+ }
+
+ $values[] = $r;
+ }
+ if ( !empty( $values ) && $result ) {
+ ApiResult::setIndexedTagName( $values, 'r' );
+ }
+
+ return $values;
+ }
+
+ /**
+ * Get a list of title normalizations - maps a title to its normalized
+ * version.
+ * @return array Array of raw_prefixed_title (string) => prefixed_title (string)
+ */
+ public function getNormalizedTitles() {
+ return $this->mNormalizedTitles;
+ }
+
+ /**
+ * Get a list of title normalizations - maps a title to its normalized
+ * version in the form of result array.
+ * @param ApiResult $result
+ * @return array Array of raw_prefixed_title (string) => prefixed_title (string)
+ * @since 1.21
+ */
+ public function getNormalizedTitlesAsResult( $result = null ) {
+ global $wgContLang;
+
+ $values = [];
+ foreach ( $this->getNormalizedTitles() as $rawTitleStr => $titleStr ) {
+ $encode = ( $wgContLang->normalize( $rawTitleStr ) !== $rawTitleStr );
+ $values[] = [
+ 'fromencoded' => $encode,
+ 'from' => $encode ? rawurlencode( $rawTitleStr ) : $rawTitleStr,
+ 'to' => $titleStr
+ ];
+ }
+ if ( !empty( $values ) && $result ) {
+ ApiResult::setIndexedTagName( $values, 'n' );
+ }
+
+ return $values;
+ }
+
+ /**
+ * Get a list of title conversions - maps a title to its converted
+ * version.
+ * @return array Array of raw_prefixed_title (string) => prefixed_title (string)
+ */
+ public function getConvertedTitles() {
+ return $this->mConvertedTitles;
+ }
+
+ /**
+ * Get a list of title conversions - maps a title to its converted
+ * version as a result array.
+ * @param ApiResult $result
+ * @return array Array of (from, to) strings
+ * @since 1.21
+ */
+ public function getConvertedTitlesAsResult( $result = null ) {
+ $values = [];
+ foreach ( $this->getConvertedTitles() as $rawTitleStr => $titleStr ) {
+ $values[] = [
+ 'from' => $rawTitleStr,
+ 'to' => $titleStr
+ ];
+ }
+ if ( !empty( $values ) && $result ) {
+ ApiResult::setIndexedTagName( $values, 'c' );
+ }
+
+ return $values;
+ }
+
+ /**
+ * Get a list of interwiki titles - maps a title to its interwiki
+ * prefix.
+ * @return array Array of raw_prefixed_title (string) => interwiki_prefix (string)
+ */
+ public function getInterwikiTitles() {
+ return $this->mInterwikiTitles;
+ }
+
+ /**
+ * Get a list of interwiki titles - maps a title to its interwiki
+ * prefix as result.
+ * @param ApiResult $result
+ * @param bool $iwUrl
+ * @return array Array of raw_prefixed_title (string) => interwiki_prefix (string)
+ * @since 1.21
+ */
+ public function getInterwikiTitlesAsResult( $result = null, $iwUrl = false ) {
+ $values = [];
+ foreach ( $this->getInterwikiTitles() as $rawTitleStr => $interwikiStr ) {
+ $item = [
+ 'title' => $rawTitleStr,
+ 'iw' => $interwikiStr,
+ ];
+ if ( $iwUrl ) {
+ $title = Title::newFromText( $rawTitleStr );
+ $item['url'] = $title->getFullURL( '', false, PROTO_CURRENT );
+ }
+ $values[] = $item;
+ }
+ if ( !empty( $values ) && $result ) {
+ ApiResult::setIndexedTagName( $values, 'i' );
+ }
+
+ return $values;
+ }
+
+ /**
+ * Get an array of invalid/special/missing titles.
+ *
+ * @param array $invalidChecks List of types of invalid titles to include.
+ * Recognized values are:
+ * - invalidTitles: Titles and reasons from $this->getInvalidTitlesAndReasons()
+ * - special: Titles from $this->getSpecialTitles()
+ * - missingIds: ids from $this->getMissingPageIDs()
+ * - missingRevIds: ids from $this->getMissingRevisionIDs()
+ * - missingTitles: Titles from $this->getMissingTitles()
+ * - interwikiTitles: Titles from $this->getInterwikiTitlesAsResult()
+ * @return array Array suitable for inclusion in the response
+ * @since 1.23
+ */
+ public function getInvalidTitlesAndRevisions( $invalidChecks = [ 'invalidTitles',
+ 'special', 'missingIds', 'missingRevIds', 'missingTitles', 'interwikiTitles' ]
+ ) {
+ $result = [];
+ if ( in_array( 'invalidTitles', $invalidChecks ) ) {
+ self::addValues( $result, $this->getInvalidTitlesAndReasons(), [ 'invalid' ] );
+ }
+ if ( in_array( 'special', $invalidChecks ) ) {
+ $known = [];
+ $unknown = [];
+ foreach ( $this->getSpecialTitles() as $title ) {
+ if ( $title->isKnown() ) {
+ $known[] = $title;
+ } else {
+ $unknown[] = $title;
+ }
+ }
+ self::addValues( $result, $unknown, [ 'special', 'missing' ] );
+ self::addValues( $result, $known, [ 'special' ] );
+ }
+ if ( in_array( 'missingIds', $invalidChecks ) ) {
+ self::addValues( $result, $this->getMissingPageIDs(), [ 'missing' ], 'pageid' );
+ }
+ if ( in_array( 'missingRevIds', $invalidChecks ) ) {
+ self::addValues( $result, $this->getMissingRevisionIDs(), [ 'missing' ], 'revid' );
+ }
+ if ( in_array( 'missingTitles', $invalidChecks ) ) {
+ $known = [];
+ $unknown = [];
+ foreach ( $this->getMissingTitles() as $title ) {
+ if ( $title->isKnown() ) {
+ $known[] = $title;
+ } else {
+ $unknown[] = $title;
+ }
+ }
+ self::addValues( $result, $unknown, [ 'missing' ] );
+ self::addValues( $result, $known, [ 'missing', 'known' ] );
+ }
+ if ( in_array( 'interwikiTitles', $invalidChecks ) ) {
+ self::addValues( $result, $this->getInterwikiTitlesAsResult() );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the list of valid revision IDs (requested with the revids= parameter)
+ * @return array Array of revID (int) => pageID (int)
+ */
+ public function getRevisionIDs() {
+ return $this->mGoodRevIDs;
+ }
+
+ /**
+ * Get the list of non-deleted revision IDs (requested with the revids= parameter)
+ * @return array Array of revID (int) => pageID (int)
+ */
+ public function getLiveRevisionIDs() {
+ return $this->mLiveRevIDs;
+ }
+
+ /**
+ * Get the list of revision IDs that were associated with deleted titles.
+ * @return array Array of revID (int) => pageID (int)
+ */
+ public function getDeletedRevisionIDs() {
+ return $this->mDeletedRevIDs;
+ }
+
+ /**
+ * Revision IDs that were not found in the database
+ * @return array Array of revision IDs
+ */
+ public function getMissingRevisionIDs() {
+ return $this->mMissingRevIDs;
+ }
+
+ /**
+ * Revision IDs that were not found in the database as result array.
+ * @param ApiResult $result
+ * @return array Array of revision IDs
+ * @since 1.21
+ */
+ public function getMissingRevisionIDsAsResult( $result = null ) {
+ $values = [];
+ foreach ( $this->getMissingRevisionIDs() as $revid ) {
+ $values[$revid] = [
+ 'revid' => $revid
+ ];
+ }
+ if ( !empty( $values ) && $result ) {
+ ApiResult::setIndexedTagName( $values, 'rev' );
+ }
+
+ return $values;
+ }
+
+ /**
+ * Get the list of titles with negative namespace
+ * @return Title[]
+ */
+ public function getSpecialTitles() {
+ return $this->mSpecialTitles;
+ }
+
+ /**
+ * Returns the number of revisions (requested with revids= parameter).
+ * @return int Number of revisions.
+ */
+ public function getRevisionCount() {
+ return count( $this->getRevisionIDs() );
+ }
+
+ /**
+ * Populate this PageSet from a list of Titles
+ * @param array $titles Array of Title objects
+ */
+ public function populateFromTitles( $titles ) {
+ $this->initFromTitles( $titles );
+ }
+
+ /**
+ * Populate this PageSet from a list of page IDs
+ * @param array $pageIDs Array of page IDs
+ */
+ public function populateFromPageIDs( $pageIDs ) {
+ $this->initFromPageIds( $pageIDs );
+ }
+
+ /**
+ * Populate this PageSet from a rowset returned from the database
+ *
+ * Note that the query result must include the columns returned by
+ * $this->getPageTableFields().
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $queryResult Query result object
+ */
+ public function populateFromQueryResult( $db, $queryResult ) {
+ $this->initFromQueryResult( $queryResult );
+ }
+
+ /**
+ * Populate this PageSet from a list of revision IDs
+ * @param array $revIDs Array of revision IDs
+ */
+ public function populateFromRevisionIDs( $revIDs ) {
+ $this->initFromRevIDs( $revIDs );
+ }
+
+ /**
+ * Extract all requested fields from the row received from the database
+ * @param stdClass $row Result row
+ */
+ public function processDbRow( $row ) {
+ // Store Title object in various data structures
+ $title = Title::newFromRow( $row );
+
+ LinkCache::singleton()->addGoodLinkObjFromRow( $title, $row );
+
+ $pageId = intval( $row->page_id );
+ $this->mAllPages[$row->page_namespace][$row->page_title] = $pageId;
+ $this->mTitles[] = $title;
+
+ if ( $this->mResolveRedirects && $row->page_is_redirect == '1' ) {
+ $this->mPendingRedirectIDs[$pageId] = $title;
+ } else {
+ $this->mGoodPages[$row->page_namespace][$row->page_title] = $pageId;
+ $this->mGoodAndMissingPages[$row->page_namespace][$row->page_title] = $pageId;
+ $this->mGoodTitles[$pageId] = $title;
+ }
+
+ foreach ( $this->mRequestedPageFields as $fieldName => &$fieldValues ) {
+ $fieldValues[$pageId] = $row->$fieldName;
+ }
+ }
+
+ /**
+ * This method populates internal variables with page information
+ * based on the given array of title strings.
+ *
+ * Steps:
+ * #1 For each title, get data from `page` table
+ * #2 If page was not found in the DB, store it as missing
+ *
+ * Additionally, when resolving redirects:
+ * #3 If no more redirects left, stop.
+ * #4 For each redirect, get its target from the `redirect` table.
+ * #5 Substitute the original LinkBatch object with the new list
+ * #6 Repeat from step #1
+ *
+ * @param array $titles Array of Title objects or strings
+ */
+ private function initFromTitles( $titles ) {
+ // Get validated and normalized title objects
+ $linkBatch = $this->processTitlesArray( $titles );
+ if ( $linkBatch->isEmpty() ) {
+ // There might be special-page redirects
+ $this->resolvePendingRedirects();
+ return;
+ }
+
+ $db = $this->getDB();
+ $set = $linkBatch->constructSet( 'page', $db );
+
+ // Get pageIDs data from the `page` table
+ $res = $db->select( 'page', $this->getPageTableFields(), $set,
+ __METHOD__ );
+
+ // Hack: get the ns:titles stored in [ ns => [ titles ] ] format
+ $this->initFromQueryResult( $res, $linkBatch->data, true ); // process Titles
+
+ // Resolve any found redirects
+ $this->resolvePendingRedirects();
+ }
+
+ /**
+ * Does the same as initFromTitles(), but is based on page IDs instead
+ * @param array $pageids Array of page IDs
+ */
+ private function initFromPageIds( $pageids ) {
+ if ( !$pageids ) {
+ return;
+ }
+
+ $pageids = array_map( 'intval', $pageids ); // paranoia
+ $remaining = array_flip( $pageids );
+
+ $pageids = self::getPositiveIntegers( $pageids );
+
+ $res = null;
+ if ( !empty( $pageids ) ) {
+ $set = [
+ 'page_id' => $pageids
+ ];
+ $db = $this->getDB();
+
+ // Get pageIDs data from the `page` table
+ $res = $db->select( 'page', $this->getPageTableFields(), $set,
+ __METHOD__ );
+ }
+
+ $this->initFromQueryResult( $res, $remaining, false ); // process PageIDs
+
+ // Resolve any found redirects
+ $this->resolvePendingRedirects();
+ }
+
+ /**
+ * Iterate through the result of the query on 'page' table,
+ * and for each row create and store title object and save any extra fields requested.
+ * @param ResultWrapper $res DB Query result
+ * @param array $remaining Array of either pageID or ns/title elements (optional).
+ * If given, any missing items will go to $mMissingPageIDs and $mMissingTitles
+ * @param bool $processTitles Must be provided together with $remaining.
+ * If true, treat $remaining as an array of [ns][title]
+ * If false, treat it as an array of [pageIDs]
+ */
+ private function initFromQueryResult( $res, &$remaining = null, $processTitles = null ) {
+ if ( !is_null( $remaining ) && is_null( $processTitles ) ) {
+ ApiBase::dieDebug( __METHOD__, 'Missing $processTitles parameter when $remaining is provided' );
+ }
+
+ $usernames = [];
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $pageId = intval( $row->page_id );
+
+ // Remove found page from the list of remaining items
+ if ( isset( $remaining ) ) {
+ if ( $processTitles ) {
+ unset( $remaining[$row->page_namespace][$row->page_title] );
+ } else {
+ unset( $remaining[$pageId] );
+ }
+ }
+
+ // Store any extra fields requested by modules
+ $this->processDbRow( $row );
+
+ // Need gender information
+ if ( MWNamespace::hasGenderDistinction( $row->page_namespace ) ) {
+ $usernames[] = $row->page_title;
+ }
+ }
+ }
+
+ if ( isset( $remaining ) ) {
+ // Any items left in the $remaining list are added as missing
+ if ( $processTitles ) {
+ // The remaining titles in $remaining are non-existent pages
+ $linkCache = LinkCache::singleton();
+ foreach ( $remaining as $ns => $dbkeys ) {
+ foreach ( array_keys( $dbkeys ) as $dbkey ) {
+ $title = Title::makeTitle( $ns, $dbkey );
+ $linkCache->addBadLinkObj( $title );
+ $this->mAllPages[$ns][$dbkey] = $this->mFakePageId;
+ $this->mMissingPages[$ns][$dbkey] = $this->mFakePageId;
+ $this->mGoodAndMissingPages[$ns][$dbkey] = $this->mFakePageId;
+ $this->mMissingTitles[$this->mFakePageId] = $title;
+ $this->mFakePageId--;
+ $this->mTitles[] = $title;
+
+ // need gender information
+ if ( MWNamespace::hasGenderDistinction( $ns ) ) {
+ $usernames[] = $dbkey;
+ }
+ }
+ }
+ } else {
+ // The remaining pageids do not exist
+ if ( !$this->mMissingPageIDs ) {
+ $this->mMissingPageIDs = array_keys( $remaining );
+ } else {
+ $this->mMissingPageIDs = array_merge( $this->mMissingPageIDs, array_keys( $remaining ) );
+ }
+ }
+ }
+
+ // Get gender information
+ $genderCache = MediaWikiServices::getInstance()->getGenderCache();
+ $genderCache->doQuery( $usernames, __METHOD__ );
+ }
+
+ /**
+ * Does the same as initFromTitles(), but is based on revision IDs
+ * instead
+ * @param array $revids Array of revision IDs
+ */
+ private function initFromRevIDs( $revids ) {
+ if ( !$revids ) {
+ return;
+ }
+
+ $revids = array_map( 'intval', $revids ); // paranoia
+ $db = $this->getDB();
+ $pageids = [];
+ $remaining = array_flip( $revids );
+
+ $revids = self::getPositiveIntegers( $revids );
+
+ if ( !empty( $revids ) ) {
+ $tables = [ 'revision', 'page' ];
+ $fields = [ 'rev_id', 'rev_page' ];
+ $where = [ 'rev_id' => $revids, 'rev_page = page_id' ];
+
+ // Get pageIDs data from the `page` table
+ $res = $db->select( $tables, $fields, $where, __METHOD__ );
+ foreach ( $res as $row ) {
+ $revid = intval( $row->rev_id );
+ $pageid = intval( $row->rev_page );
+ $this->mGoodRevIDs[$revid] = $pageid;
+ $this->mLiveRevIDs[$revid] = $pageid;
+ $pageids[$pageid] = '';
+ unset( $remaining[$revid] );
+ }
+ }
+
+ $this->mMissingRevIDs = array_keys( $remaining );
+
+ // Populate all the page information
+ $this->initFromPageIds( array_keys( $pageids ) );
+
+ // If the user can see deleted revisions, pull out the corresponding
+ // titles from the archive table and include them too. We ignore
+ // ar_page_id because deleted revisions are tied by title, not page_id.
+ if ( !empty( $this->mMissingRevIDs ) && $this->getUser()->isAllowed( 'deletedhistory' ) ) {
+ $remaining = array_flip( $this->mMissingRevIDs );
+ $tables = [ 'archive' ];
+ $fields = [ 'ar_rev_id', 'ar_namespace', 'ar_title' ];
+ $where = [ 'ar_rev_id' => $this->mMissingRevIDs ];
+
+ $res = $db->select( $tables, $fields, $where, __METHOD__ );
+ $titles = [];
+ foreach ( $res as $row ) {
+ $revid = intval( $row->ar_rev_id );
+ $titles[$revid] = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+ unset( $remaining[$revid] );
+ }
+
+ $this->initFromTitles( $titles );
+
+ foreach ( $titles as $revid => $title ) {
+ $ns = $title->getNamespace();
+ $dbkey = $title->getDBkey();
+
+ // Handle converted titles
+ if ( !isset( $this->mAllPages[$ns][$dbkey] ) &&
+ isset( $this->mConvertedTitles[$title->getPrefixedText()] )
+ ) {
+ $title = Title::newFromText( $this->mConvertedTitles[$title->getPrefixedText()] );
+ $ns = $title->getNamespace();
+ $dbkey = $title->getDBkey();
+ }
+
+ if ( isset( $this->mAllPages[$ns][$dbkey] ) ) {
+ $this->mGoodRevIDs[$revid] = $this->mAllPages[$ns][$dbkey];
+ $this->mDeletedRevIDs[$revid] = $this->mAllPages[$ns][$dbkey];
+ } else {
+ $remaining[$revid] = true;
+ }
+ }
+
+ $this->mMissingRevIDs = array_keys( $remaining );
+ }
+ }
+
+ /**
+ * Resolve any redirects in the result if redirect resolution was
+ * requested. This function is called repeatedly until all redirects
+ * have been resolved.
+ */
+ private function resolvePendingRedirects() {
+ if ( $this->mResolveRedirects ) {
+ $db = $this->getDB();
+ $pageFlds = $this->getPageTableFields();
+
+ // Repeat until all redirects have been resolved
+ // The infinite loop is prevented by keeping all known pages in $this->mAllPages
+ while ( $this->mPendingRedirectIDs || $this->mPendingRedirectSpecialPages ) {
+ // Resolve redirects by querying the pagelinks table, and repeat the process
+ // Create a new linkBatch object for the next pass
+ $linkBatch = $this->getRedirectTargets();
+
+ if ( $linkBatch->isEmpty() ) {
+ break;
+ }
+
+ $set = $linkBatch->constructSet( 'page', $db );
+ if ( $set === false ) {
+ break;
+ }
+
+ // Get pageIDs data from the `page` table
+ $res = $db->select( 'page', $pageFlds, $set, __METHOD__ );
+
+ // Hack: get the ns:titles stored in [ns => array(titles)] format
+ $this->initFromQueryResult( $res, $linkBatch->data, true );
+ }
+ }
+ }
+
+ /**
+ * Get the targets of the pending redirects from the database
+ *
+ * Also creates entries in the redirect table for redirects that don't
+ * have one.
+ * @return LinkBatch
+ */
+ private function getRedirectTargets() {
+ $titlesToResolve = [];
+ $db = $this->getDB();
+
+ if ( $this->mPendingRedirectIDs ) {
+ $res = $db->select(
+ 'redirect',
+ [
+ 'rd_from',
+ 'rd_namespace',
+ 'rd_fragment',
+ 'rd_interwiki',
+ 'rd_title'
+ ], [ 'rd_from' => array_keys( $this->mPendingRedirectIDs ) ],
+ __METHOD__
+ );
+ foreach ( $res as $row ) {
+ $rdfrom = intval( $row->rd_from );
+ $from = $this->mPendingRedirectIDs[$rdfrom]->getPrefixedText();
+ $to = Title::makeTitle(
+ $row->rd_namespace,
+ $row->rd_title,
+ $row->rd_fragment,
+ $row->rd_interwiki
+ );
+ $this->mResolvedRedirectTitles[$from] = $this->mPendingRedirectIDs[$rdfrom];
+ unset( $this->mPendingRedirectIDs[$rdfrom] );
+ if ( $to->isExternal() ) {
+ $this->mInterwikiTitles[$to->getPrefixedText()] = $to->getInterwiki();
+ } elseif ( !isset( $this->mAllPages[$to->getNamespace()][$to->getDBkey()] ) ) {
+ $titlesToResolve[] = $to;
+ }
+ $this->mRedirectTitles[$from] = $to;
+ }
+
+ if ( $this->mPendingRedirectIDs ) {
+ // We found pages that aren't in the redirect table
+ // Add them
+ foreach ( $this->mPendingRedirectIDs as $id => $title ) {
+ $page = WikiPage::factory( $title );
+ $rt = $page->insertRedirect();
+ if ( !$rt ) {
+ // What the hell. Let's just ignore this
+ continue;
+ }
+ if ( $rt->isExternal() ) {
+ $this->mInterwikiTitles[$rt->getPrefixedText()] = $rt->getInterwiki();
+ } elseif ( !isset( $this->mAllPages[$rt->getNamespace()][$rt->getDBkey()] ) ) {
+ $titlesToResolve[] = $rt;
+ }
+ $from = $title->getPrefixedText();
+ $this->mResolvedRedirectTitles[$from] = $title;
+ $this->mRedirectTitles[$from] = $rt;
+ unset( $this->mPendingRedirectIDs[$id] );
+ }
+ }
+ }
+
+ if ( $this->mPendingRedirectSpecialPages ) {
+ foreach ( $this->mPendingRedirectSpecialPages as $key => list( $from, $to ) ) {
+ $fromKey = $from->getPrefixedText();
+ $this->mResolvedRedirectTitles[$fromKey] = $from;
+ $this->mRedirectTitles[$fromKey] = $to;
+ if ( $to->isExternal() ) {
+ $this->mInterwikiTitles[$to->getPrefixedText()] = $to->getInterwiki();
+ } elseif ( !isset( $this->mAllPages[$to->getNamespace()][$to->getDBkey()] ) ) {
+ $titlesToResolve[] = $to;
+ }
+ }
+ $this->mPendingRedirectSpecialPages = [];
+
+ // Set private caching since we don't know what criteria the
+ // special pages used to decide on these redirects.
+ $this->mCacheMode = 'private';
+ }
+
+ return $this->processTitlesArray( $titlesToResolve );
+ }
+
+ /**
+ * Get the cache mode for the data generated by this module.
+ * All PageSet users should take into account whether this returns a more-restrictive
+ * cache mode than the using module itself. For possible return values and other
+ * details about cache modes, see ApiMain::setCacheMode()
+ *
+ * Public caching will only be allowed if *all* the modules that supply
+ * data for a given request return a cache mode of public.
+ *
+ * @param array|null $params
+ * @return string
+ * @since 1.21
+ */
+ public function getCacheMode( $params = null ) {
+ return $this->mCacheMode;
+ }
+
+ /**
+ * Given an array of title strings, convert them into Title objects.
+ * Alternatively, an array of Title objects may be given.
+ * This method validates access rights for the title,
+ * and appends normalization values to the output.
+ *
+ * @param array $titles Array of Title objects or strings
+ * @return LinkBatch
+ */
+ private function processTitlesArray( $titles ) {
+ $usernames = [];
+ $linkBatch = new LinkBatch();
+
+ foreach ( $titles as $title ) {
+ if ( is_string( $title ) ) {
+ try {
+ $titleObj = Title::newFromTextThrow( $title, $this->mDefaultNamespace );
+ } catch ( MalformedTitleException $ex ) {
+ // Handle invalid titles gracefully
+ if ( !isset( $this->mAllPages[0][$title] ) ) {
+ $this->mAllPages[0][$title] = $this->mFakePageId;
+ $this->mInvalidTitles[$this->mFakePageId] = [
+ 'title' => $title,
+ 'invalidreason' => $this->getErrorFormatter()->formatException( $ex, [ 'bc' => true ] ),
+ ];
+ $this->mFakePageId--;
+ }
+ continue; // There's nothing else we can do
+ }
+ } else {
+ $titleObj = $title;
+ }
+ $unconvertedTitle = $titleObj->getPrefixedText();
+ $titleWasConverted = false;
+ if ( $titleObj->isExternal() ) {
+ // This title is an interwiki link.
+ $this->mInterwikiTitles[$unconvertedTitle] = $titleObj->getInterwiki();
+ } else {
+ // Variants checking
+ global $wgContLang;
+ if ( $this->mConvertTitles &&
+ count( $wgContLang->getVariants() ) > 1 &&
+ !$titleObj->exists()
+ ) {
+ // Language::findVariantLink will modify titleText and titleObj into
+ // the canonical variant if possible
+ $titleText = is_string( $title ) ? $title : $titleObj->getPrefixedText();
+ $wgContLang->findVariantLink( $titleText, $titleObj );
+ $titleWasConverted = $unconvertedTitle !== $titleObj->getPrefixedText();
+ }
+
+ if ( $titleObj->getNamespace() < 0 ) {
+ // Handle Special and Media pages
+ $titleObj = $titleObj->fixSpecialName();
+ $ns = $titleObj->getNamespace();
+ $dbkey = $titleObj->getDBkey();
+ if ( !isset( $this->mAllSpecials[$ns][$dbkey] ) ) {
+ $this->mAllSpecials[$ns][$dbkey] = $this->mFakePageId;
+ $target = null;
+ if ( $ns === NS_SPECIAL && $this->mResolveRedirects ) {
+ $special = SpecialPageFactory::getPage( $dbkey );
+ if ( $special instanceof RedirectSpecialArticle ) {
+ // Only RedirectSpecialArticle is intended to redirect to an article, other kinds of
+ // RedirectSpecialPage are probably applying weird URL parameters we don't want to handle.
+ $context = new DerivativeContext( $this );
+ $context->setTitle( $titleObj );
+ $context->setRequest( new FauxRequest );
+ $special->setContext( $context );
+ list( /* $alias */, $subpage ) = SpecialPageFactory::resolveAlias( $dbkey );
+ $target = $special->getRedirect( $subpage );
+ }
+ }
+ if ( $target ) {
+ $this->mPendingRedirectSpecialPages[$dbkey] = [ $titleObj, $target ];
+ } else {
+ $this->mSpecialTitles[$this->mFakePageId] = $titleObj;
+ $this->mFakePageId--;
+ }
+ }
+ } else {
+ // Regular page
+ $linkBatch->addObj( $titleObj );
+ }
+ }
+
+ // Make sure we remember the original title that was
+ // given to us. This way the caller can correlate new
+ // titles with the originally requested when e.g. the
+ // namespace is localized or the capitalization is
+ // different
+ if ( $titleWasConverted ) {
+ $this->mConvertedTitles[$unconvertedTitle] = $titleObj->getPrefixedText();
+ // In this case the page can't be Special.
+ if ( is_string( $title ) && $title !== $unconvertedTitle ) {
+ $this->mNormalizedTitles[$title] = $unconvertedTitle;
+ }
+ } elseif ( is_string( $title ) && $title !== $titleObj->getPrefixedText() ) {
+ $this->mNormalizedTitles[$title] = $titleObj->getPrefixedText();
+ }
+
+ // Need gender information
+ if ( MWNamespace::hasGenderDistinction( $titleObj->getNamespace() ) ) {
+ $usernames[] = $titleObj->getText();
+ }
+ }
+ // Get gender information
+ $genderCache = MediaWikiServices::getInstance()->getGenderCache();
+ $genderCache->doQuery( $usernames, __METHOD__ );
+
+ return $linkBatch;
+ }
+
+ /**
+ * Set data for a title.
+ *
+ * This data may be extracted into an ApiResult using
+ * self::populateGeneratorData. This should generally be limited to
+ * data that is likely to be particularly useful to end users rather than
+ * just being a dump of everything returned in non-generator mode.
+ *
+ * Redirects here will *not* be followed, even if 'redirects' was
+ * specified, since in the case of multiple redirects we can't know which
+ * source's data to use on the target.
+ *
+ * @param Title $title
+ * @param array $data
+ */
+ public function setGeneratorData( Title $title, array $data ) {
+ $ns = $title->getNamespace();
+ $dbkey = $title->getDBkey();
+ $this->mGeneratorData[$ns][$dbkey] = $data;
+ }
+
+ /**
+ * Controls how generator data about a redirect source is merged into
+ * the generator data for the redirect target. When not set no data
+ * is merged. Note that if multiple titles redirect to the same target
+ * the order of operations is undefined.
+ *
+ * Example to include generated data from redirect in target, prefering
+ * the data generated for the destination when there is a collision:
+ * @code
+ * $pageSet->setRedirectMergePolicy( function( array $current, array $new ) {
+ * return $current + $new;
+ * } );
+ * @endcode
+ *
+ * @param callable|null $callable Recieves two array arguments, first the
+ * generator data for the redirect target and second the generator data
+ * for the redirect source. Returns the resulting generator data to use
+ * for the redirect target.
+ */
+ public function setRedirectMergePolicy( $callable ) {
+ $this->mRedirectMergePolicy = $callable;
+ }
+
+ /**
+ * Populate the generator data for all titles in the result
+ *
+ * The page data may be inserted into an ApiResult object or into an
+ * associative array. The $path parameter specifies the path within the
+ * ApiResult or array to find the "pages" node.
+ *
+ * The "pages" node itself must be an associative array mapping the page ID
+ * or fake page ID values returned by this pageset (see
+ * self::getAllTitlesByNamespace() and self::getSpecialTitles()) to
+ * associative arrays of page data. Each of those subarrays will have the
+ * data from self::setGeneratorData() merged in.
+ *
+ * Data that was set by self::setGeneratorData() for pages not in the
+ * "pages" node will be ignored.
+ *
+ * @param ApiResult|array &$result
+ * @param array $path
+ * @return bool Whether the data fit
+ */
+ public function populateGeneratorData( &$result, array $path = [] ) {
+ if ( $result instanceof ApiResult ) {
+ $data = $result->getResultData( $path );
+ if ( $data === null ) {
+ return true;
+ }
+ } else {
+ $data = &$result;
+ foreach ( $path as $key ) {
+ if ( !isset( $data[$key] ) ) {
+ // Path isn't in $result, so nothing to add, so everything
+ // "fits"
+ return true;
+ }
+ $data = &$data[$key];
+ }
+ }
+ foreach ( $this->mGeneratorData as $ns => $dbkeys ) {
+ if ( $ns === NS_SPECIAL ) {
+ $pages = [];
+ foreach ( $this->mSpecialTitles as $id => $title ) {
+ $pages[$title->getDBkey()] = $id;
+ }
+ } else {
+ if ( !isset( $this->mAllPages[$ns] ) ) {
+ // No known titles in the whole namespace. Skip it.
+ continue;
+ }
+ $pages = $this->mAllPages[$ns];
+ }
+ foreach ( $dbkeys as $dbkey => $genData ) {
+ if ( !isset( $pages[$dbkey] ) ) {
+ // Unknown title. Forget it.
+ continue;
+ }
+ $pageId = $pages[$dbkey];
+ if ( !isset( $data[$pageId] ) ) {
+ // $pageId didn't make it into the result. Ignore it.
+ continue;
+ }
+
+ if ( $result instanceof ApiResult ) {
+ $path2 = array_merge( $path, [ $pageId ] );
+ foreach ( $genData as $key => $value ) {
+ if ( !$result->addValue( $path2, $key, $value ) ) {
+ return false;
+ }
+ }
+ } else {
+ $data[$pageId] = array_merge( $data[$pageId], $genData );
+ }
+ }
+ }
+
+ // Merge data generated about redirect titles into the redirect destination
+ if ( $this->mRedirectMergePolicy ) {
+ foreach ( $this->mResolvedRedirectTitles as $titleFrom ) {
+ $dest = $titleFrom;
+ while ( isset( $this->mRedirectTitles[$dest->getPrefixedText()] ) ) {
+ $dest = $this->mRedirectTitles[$dest->getPrefixedText()];
+ }
+ $fromNs = $titleFrom->getNamespace();
+ $fromDBkey = $titleFrom->getDBkey();
+ $toPageId = $dest->getArticleID();
+ if ( isset( $data[$toPageId] ) &&
+ isset( $this->mGeneratorData[$fromNs][$fromDBkey] )
+ ) {
+ // It is necesary to set both $data and add to $result, if an ApiResult,
+ // to ensure multiple redirects to the same destination are all merged.
+ $data[$toPageId] = call_user_func(
+ $this->mRedirectMergePolicy,
+ $data[$toPageId],
+ $this->mGeneratorData[$fromNs][$fromDBkey]
+ );
+ if ( $result instanceof ApiResult ) {
+ if ( !$result->addValue( $path, $toPageId, $data[$toPageId], ApiResult::OVERRIDE ) ) {
+ return false;
+ }
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the database connection (read-only)
+ * @return IDatabase
+ */
+ protected function getDB() {
+ return $this->mDbSource->getDB();
+ }
+
+ /**
+ * Returns the input array of integers with all values < 0 removed
+ *
+ * @param array $array
+ * @return array
+ */
+ private static function getPositiveIntegers( $array ) {
+ // T27734 API: possible issue with revids validation
+ // It seems with a load of revision rows, MySQL gets upset
+ // Remove any < 0 integers, as they can't be valid
+ foreach ( $array as $i => $int ) {
+ if ( $int < 0 ) {
+ unset( $array[$i] );
+ }
+ }
+
+ return $array;
+ }
+
+ public function getAllowedParams( $flags = 0 ) {
+ $result = [
+ 'titles' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG => 'api-pageset-param-titles',
+ ],
+ 'pageids' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG => 'api-pageset-param-pageids',
+ ],
+ 'revids' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG => 'api-pageset-param-revids',
+ ],
+ 'generator' => [
+ ApiBase::PARAM_TYPE => null,
+ ApiBase::PARAM_HELP_MSG => 'api-pageset-param-generator',
+ ApiBase::PARAM_SUBMODULE_PARAM_PREFIX => 'g',
+ ],
+ 'redirects' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => $this->mAllowGenerator
+ ? 'api-pageset-param-redirects-generator'
+ : 'api-pageset-param-redirects-nogenerator',
+ ],
+ 'converttitles' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => [
+ 'api-pageset-param-converttitles',
+ [ Message::listParam( LanguageConverter::$languagesWithVariants, 'text' ) ],
+ ],
+ ],
+ ];
+
+ if ( !$this->mAllowGenerator ) {
+ unset( $result['generator'] );
+ } elseif ( $flags & ApiBase::GET_VALUES_FOR_HELP ) {
+ $result['generator'][ApiBase::PARAM_TYPE] = 'submodule';
+ $result['generator'][ApiBase::PARAM_SUBMODULE_MAP] = $this->getGenerators();
+ }
+
+ return $result;
+ }
+
+ protected function handleParamNormalization( $paramName, $value, $rawValue ) {
+ parent::handleParamNormalization( $paramName, $value, $rawValue );
+
+ if ( $paramName === 'titles' ) {
+ // For the 'titles' parameter, we want to split it like ApiBase would
+ // and add any changed titles to $this->mNormalizedTitles
+ $value = $this->explodeMultiValue( $value, self::LIMIT_SML2 + 1 );
+ $l = count( $value );
+ $rawValue = $this->explodeMultiValue( $rawValue, $l );
+ for ( $i = 0; $i < $l; $i++ ) {
+ if ( $value[$i] !== $rawValue[$i] ) {
+ $this->mNormalizedTitles[$rawValue[$i]] = $value[$i];
+ }
+ }
+ }
+ }
+
+ private static $generators = null;
+
+ /**
+ * Get an array of all available generators
+ * @return array
+ */
+ private function getGenerators() {
+ if ( self::$generators === null ) {
+ $query = $this->mDbSource;
+ if ( !( $query instanceof ApiQuery ) ) {
+ // If the parent container of this pageset is not ApiQuery,
+ // we must create it to get module manager
+ $query = $this->getMain()->getModuleManager()->getModule( 'query' );
+ }
+ $gens = [];
+ $prefix = $query->getModulePath() . '+';
+ $mgr = $query->getModuleManager();
+ foreach ( $mgr->getNamesWithClasses() as $name => $class ) {
+ if ( is_subclass_of( $class, 'ApiQueryGeneratorBase' ) ) {
+ $gens[$name] = $prefix . $name;
+ }
+ }
+ ksort( $gens );
+ self::$generators = $gens;
+ }
+
+ return self::$generators;
+ }
+}
diff --git a/www/wiki/includes/api/ApiParamInfo.php b/www/wiki/includes/api/ApiParamInfo.php
new file mode 100644
index 00000000..2fa20a96
--- /dev/null
+++ b/www/wiki/includes/api/ApiParamInfo.php
@@ -0,0 +1,581 @@
+<?php
+/**
+ *
+ *
+ * Created on Dec 01, 2007
+ *
+ * Copyright © 2008 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiParamInfo extends ApiBase {
+
+ private $helpFormat;
+ private $context;
+
+ public function __construct( ApiMain $main, $action ) {
+ parent::__construct( $main, $action );
+ }
+
+ public function execute() {
+ // Get parameters
+ $params = $this->extractRequestParams();
+
+ $this->helpFormat = $params['helpformat'];
+ $this->context = new RequestContext;
+ $this->context->setUser( new User ); // anon to avoid caching issues
+ $this->context->setLanguage( $this->getMain()->getLanguage() );
+
+ if ( is_array( $params['modules'] ) ) {
+ $modules = [];
+ foreach ( $params['modules'] as $path ) {
+ if ( $path === '*' || $path === '**' ) {
+ $path = "main+$path";
+ }
+ if ( substr( $path, -2 ) === '+*' || substr( $path, -2 ) === ' *' ) {
+ $submodules = true;
+ $path = substr( $path, 0, -2 );
+ $recursive = false;
+ } elseif ( substr( $path, -3 ) === '+**' || substr( $path, -3 ) === ' **' ) {
+ $submodules = true;
+ $path = substr( $path, 0, -3 );
+ $recursive = true;
+ } else {
+ $submodules = false;
+ }
+
+ if ( $submodules ) {
+ try {
+ $module = $this->getModuleFromPath( $path );
+ } catch ( ApiUsageException $ex ) {
+ foreach ( $ex->getStatusValue()->getErrors() as $error ) {
+ $this->addWarning( $error );
+ }
+ continue;
+ }
+ $submodules = $this->listAllSubmodules( $module, $recursive );
+ if ( $submodules ) {
+ $modules = array_merge( $modules, $submodules );
+ } else {
+ $this->addWarning( [ 'apierror-badmodule-nosubmodules', $path ], 'badmodule' );
+ }
+ } else {
+ $modules[] = $path;
+ }
+ }
+ } else {
+ $modules = [];
+ }
+
+ if ( is_array( $params['querymodules'] ) ) {
+ $queryModules = $params['querymodules'];
+ foreach ( $queryModules as $m ) {
+ $modules[] = 'query+' . $m;
+ }
+ } else {
+ $queryModules = [];
+ }
+
+ if ( is_array( $params['formatmodules'] ) ) {
+ $formatModules = $params['formatmodules'];
+ foreach ( $formatModules as $m ) {
+ $modules[] = $m;
+ }
+ } else {
+ $formatModules = [];
+ }
+
+ $modules = array_unique( $modules );
+
+ $res = [];
+
+ foreach ( $modules as $m ) {
+ try {
+ $module = $this->getModuleFromPath( $m );
+ } catch ( ApiUsageException $ex ) {
+ foreach ( $ex->getStatusValue()->getErrors() as $error ) {
+ $this->addWarning( $error );
+ }
+ continue;
+ }
+ $key = 'modules';
+
+ // Back compat
+ $isBCQuery = false;
+ if ( $module->getParent() && $module->getParent()->getModuleName() == 'query' &&
+ in_array( $module->getModuleName(), $queryModules )
+ ) {
+ $isBCQuery = true;
+ $key = 'querymodules';
+ }
+ if ( in_array( $module->getModuleName(), $formatModules ) ) {
+ $key = 'formatmodules';
+ }
+
+ $item = $this->getModuleInfo( $module );
+ if ( $isBCQuery ) {
+ $item['querytype'] = $item['group'];
+ }
+ $res[$key][] = $item;
+ }
+
+ $result = $this->getResult();
+ $result->addValue( [ $this->getModuleName() ], 'helpformat', $this->helpFormat );
+
+ foreach ( $res as $key => $stuff ) {
+ ApiResult::setIndexedTagName( $res[$key], 'module' );
+ }
+
+ if ( $params['mainmodule'] ) {
+ $res['mainmodule'] = $this->getModuleInfo( $this->getMain() );
+ }
+
+ if ( $params['pagesetmodule'] ) {
+ $pageSet = new ApiPageSet( $this->getMain()->getModuleManager()->getModule( 'query' ) );
+ $res['pagesetmodule'] = $this->getModuleInfo( $pageSet );
+ unset( $res['pagesetmodule']['name'] );
+ unset( $res['pagesetmodule']['path'] );
+ unset( $res['pagesetmodule']['group'] );
+ }
+
+ $result->addValue( null, $this->getModuleName(), $res );
+ }
+
+ /**
+ * List all submodules of a module
+ * @param ApiBase $module
+ * @param bool $recursive
+ * @return string[]
+ */
+ private function listAllSubmodules( ApiBase $module, $recursive ) {
+ $manager = $module->getModuleManager();
+ if ( $manager ) {
+ $paths = [];
+ $names = $manager->getNames();
+ sort( $names );
+ foreach ( $names as $name ) {
+ $submodule = $manager->getModule( $name );
+ $paths[] = $submodule->getModulePath();
+ if ( $recursive && $submodule->getModuleManager() ) {
+ $paths = array_merge( $paths, $this->listAllSubmodules( $submodule, $recursive ) );
+ }
+ }
+ }
+ return $paths;
+ }
+
+ /**
+ * @param array &$res Result array
+ * @param string $key Result key
+ * @param Message[] $msgs
+ * @param bool $joinLists
+ */
+ protected function formatHelpMessages( array &$res, $key, array $msgs, $joinLists = false ) {
+ switch ( $this->helpFormat ) {
+ case 'none':
+ break;
+
+ case 'wikitext':
+ $ret = [];
+ foreach ( $msgs as $m ) {
+ $ret[] = $m->setContext( $this->context )->text();
+ }
+ $res[$key] = implode( "\n\n", $ret );
+ if ( $joinLists ) {
+ $res[$key] = preg_replace( '!^(([*#:;])[^\n]*)\n\n(?=\2)!m', "$1\n", $res[$key] );
+ }
+ break;
+
+ case 'html':
+ $ret = [];
+ foreach ( $msgs as $m ) {
+ $ret[] = $m->setContext( $this->context )->parseAsBlock();
+ }
+ $ret = implode( "\n", $ret );
+ if ( $joinLists ) {
+ $ret = preg_replace( '!\s*</([oud]l)>\s*<\1>\s*!', "\n", $ret );
+ }
+ $res[$key] = Parser::stripOuterParagraph( $ret );
+ break;
+
+ case 'raw':
+ $res[$key] = [];
+ foreach ( $msgs as $m ) {
+ $a = [
+ 'key' => $m->getKey(),
+ 'params' => $m->getParams(),
+ ];
+ ApiResult::setIndexedTagName( $a['params'], 'param' );
+ if ( $m instanceof ApiHelpParamValueMessage ) {
+ $a['forvalue'] = $m->getParamValue();
+ }
+ $res[$key][] = $a;
+ }
+ ApiResult::setIndexedTagName( $res[$key], 'msg' );
+ break;
+ }
+ }
+
+ /**
+ * @param ApiBase $module
+ * @return array
+ */
+ private function getModuleInfo( $module ) {
+ $ret = [];
+ $path = $module->getModulePath();
+
+ $ret['name'] = $module->getModuleName();
+ $ret['classname'] = get_class( $module );
+ $ret['path'] = $path;
+ if ( !$module->isMain() ) {
+ $ret['group'] = $module->getParent()->getModuleManager()->getModuleGroup(
+ $module->getModuleName()
+ );
+ }
+ $ret['prefix'] = $module->getModulePrefix();
+
+ $sourceInfo = $module->getModuleSourceInfo();
+ if ( $sourceInfo ) {
+ $ret['source'] = $sourceInfo['name'];
+ if ( isset( $sourceInfo['namemsg'] ) ) {
+ $ret['sourcename'] = $this->context->msg( $sourceInfo['namemsg'] )->text();
+ } else {
+ $ret['sourcename'] = $ret['source'];
+ }
+
+ $link = SpecialPage::getTitleFor( 'Version', 'License/' . $sourceInfo['name'] )->getFullURL();
+ if ( isset( $sourceInfo['license-name'] ) ) {
+ $ret['licensetag'] = $sourceInfo['license-name'];
+ $ret['licenselink'] = (string)$link;
+ } elseif ( SpecialVersion::getExtLicenseFileName( dirname( $sourceInfo['path'] ) ) ) {
+ $ret['licenselink'] = (string)$link;
+ }
+ }
+
+ $this->formatHelpMessages( $ret, 'description', $module->getFinalDescription() );
+
+ foreach ( $module->getHelpFlags() as $flag ) {
+ $ret[$flag] = true;
+ }
+
+ $ret['helpurls'] = (array)$module->getHelpUrls();
+ if ( isset( $ret['helpurls'][0] ) && $ret['helpurls'][0] === false ) {
+ $ret['helpurls'] = [];
+ }
+ ApiResult::setIndexedTagName( $ret['helpurls'], 'helpurl' );
+
+ if ( $this->helpFormat !== 'none' ) {
+ $ret['examples'] = [];
+ $examples = $module->getExamplesMessages();
+ foreach ( $examples as $qs => $msg ) {
+ $item = [
+ 'query' => $qs
+ ];
+ $msg = ApiBase::makeMessage( $msg, $this->context, [
+ $module->getModulePrefix(),
+ $module->getModuleName(),
+ $module->getModulePath()
+ ] );
+ $this->formatHelpMessages( $item, 'description', [ $msg ] );
+ if ( isset( $item['description'] ) ) {
+ if ( is_array( $item['description'] ) ) {
+ $item['description'] = $item['description'][0];
+ } else {
+ ApiResult::setSubelementsList( $item, 'description' );
+ }
+ }
+ $ret['examples'][] = $item;
+ }
+ ApiResult::setIndexedTagName( $ret['examples'], 'example' );
+ }
+
+ $ret['parameters'] = [];
+ $params = $module->getFinalParams( ApiBase::GET_VALUES_FOR_HELP );
+ $paramDesc = $module->getFinalParamDescription();
+ foreach ( $params as $name => $settings ) {
+ if ( !is_array( $settings ) ) {
+ $settings = [ ApiBase::PARAM_DFLT => $settings ];
+ }
+
+ $item = [
+ 'name' => $name
+ ];
+ if ( isset( $paramDesc[$name] ) ) {
+ $this->formatHelpMessages( $item, 'description', $paramDesc[$name], true );
+ }
+
+ $item['required'] = !empty( $settings[ApiBase::PARAM_REQUIRED] );
+
+ if ( !empty( $settings[ApiBase::PARAM_DEPRECATED] ) ) {
+ $item['deprecated'] = true;
+ }
+
+ if ( $name === 'token' && $module->needsToken() ) {
+ $item['tokentype'] = $module->needsToken();
+ }
+
+ if ( !isset( $settings[ApiBase::PARAM_TYPE] ) ) {
+ $dflt = isset( $settings[ApiBase::PARAM_DFLT] )
+ ? $settings[ApiBase::PARAM_DFLT]
+ : null;
+ if ( is_bool( $dflt ) ) {
+ $settings[ApiBase::PARAM_TYPE] = 'boolean';
+ } elseif ( is_string( $dflt ) || is_null( $dflt ) ) {
+ $settings[ApiBase::PARAM_TYPE] = 'string';
+ } elseif ( is_int( $dflt ) ) {
+ $settings[ApiBase::PARAM_TYPE] = 'integer';
+ }
+ }
+
+ if ( isset( $settings[ApiBase::PARAM_DFLT] ) ) {
+ switch ( $settings[ApiBase::PARAM_TYPE] ) {
+ case 'boolean':
+ $item['default'] = (bool)$settings[ApiBase::PARAM_DFLT];
+ break;
+ case 'string':
+ case 'text':
+ case 'password':
+ $item['default'] = strval( $settings[ApiBase::PARAM_DFLT] );
+ break;
+ case 'integer':
+ case 'limit':
+ $item['default'] = intval( $settings[ApiBase::PARAM_DFLT] );
+ break;
+ case 'timestamp':
+ $item['default'] = wfTimestamp( TS_ISO_8601, $settings[ApiBase::PARAM_DFLT] );
+ break;
+ default:
+ $item['default'] = $settings[ApiBase::PARAM_DFLT];
+ break;
+ }
+ }
+
+ $item['multi'] = !empty( $settings[ApiBase::PARAM_ISMULTI] );
+ if ( $item['multi'] ) {
+ $item['lowlimit'] = !empty( $settings[ApiBase::PARAM_ISMULTI_LIMIT1] )
+ ? $settings[ApiBase::PARAM_ISMULTI_LIMIT1]
+ : ApiBase::LIMIT_SML1;
+ $item['highlimit'] = !empty( $settings[ApiBase::PARAM_ISMULTI_LIMIT2] )
+ ? $settings[ApiBase::PARAM_ISMULTI_LIMIT2]
+ : ApiBase::LIMIT_SML2;
+ $item['limit'] = $this->getMain()->canApiHighLimits()
+ ? $item['highlimit']
+ : $item['lowlimit'];
+ }
+
+ if ( !empty( $settings[ApiBase::PARAM_ALLOW_DUPLICATES] ) ) {
+ $item['allowsduplicates'] = true;
+ }
+
+ if ( isset( $settings[ApiBase::PARAM_TYPE] ) ) {
+ if ( $settings[ApiBase::PARAM_TYPE] === 'submodule' ) {
+ if ( isset( $settings[ApiBase::PARAM_SUBMODULE_MAP] ) ) {
+ ksort( $settings[ApiBase::PARAM_SUBMODULE_MAP] );
+ $item['type'] = array_keys( $settings[ApiBase::PARAM_SUBMODULE_MAP] );
+ $item['submodules'] = $settings[ApiBase::PARAM_SUBMODULE_MAP];
+ } else {
+ $item['type'] = $module->getModuleManager()->getNames( $name );
+ sort( $item['type'] );
+ $prefix = $module->isMain()
+ ? '' : ( $module->getModulePath() . '+' );
+ $item['submodules'] = [];
+ foreach ( $item['type'] as $v ) {
+ $item['submodules'][$v] = $prefix . $v;
+ }
+ }
+ if ( isset( $settings[ApiBase::PARAM_SUBMODULE_PARAM_PREFIX] ) ) {
+ $item['submoduleparamprefix'] = $settings[ApiBase::PARAM_SUBMODULE_PARAM_PREFIX];
+ }
+
+ $deprecatedSubmodules = [];
+ foreach ( $item['submodules'] as $v => $submodulePath ) {
+ try {
+ $submod = $this->getModuleFromPath( $submodulePath );
+ if ( $submod && $submod->isDeprecated() ) {
+ $deprecatedSubmodules[] = $v;
+ }
+ } catch ( ApiUsageException $ex ) {
+ // Ignore
+ }
+ }
+ if ( $deprecatedSubmodules ) {
+ $item['type'] = array_merge(
+ array_diff( $item['type'], $deprecatedSubmodules ),
+ $deprecatedSubmodules
+ );
+ $item['deprecatedvalues'] = $deprecatedSubmodules;
+ }
+ } elseif ( $settings[ApiBase::PARAM_TYPE] === 'tags' ) {
+ $item['type'] = ChangeTags::listExplicitlyDefinedTags();
+ } else {
+ $item['type'] = $settings[ApiBase::PARAM_TYPE];
+ }
+ if ( is_array( $item['type'] ) ) {
+ // To prevent sparse arrays from being serialized to JSON as objects
+ $item['type'] = array_values( $item['type'] );
+ ApiResult::setIndexedTagName( $item['type'], 't' );
+ }
+
+ // Add 'allspecifier' if applicable
+ if ( $item['type'] === 'namespace' ) {
+ $allowAll = true;
+ $allSpecifier = ApiBase::ALL_DEFAULT_STRING;
+ } else {
+ $allowAll = isset( $settings[ApiBase::PARAM_ALL] )
+ ? $settings[ApiBase::PARAM_ALL]
+ : false;
+ $allSpecifier = ( is_string( $allowAll ) ? $allowAll : ApiBase::ALL_DEFAULT_STRING );
+ }
+ if ( $allowAll && $item['multi'] &&
+ ( is_array( $item['type'] ) || $item['type'] === 'namespace' ) ) {
+ $item['allspecifier'] = $allSpecifier;
+ }
+
+ if ( $item['type'] === 'namespace' &&
+ isset( $settings[ApiBase::PARAM_EXTRA_NAMESPACES] ) &&
+ is_array( $settings[ApiBase::PARAM_EXTRA_NAMESPACES] )
+ ) {
+ $item['extranamespaces'] = $settings[ApiBase::PARAM_EXTRA_NAMESPACES];
+ ApiResult::setArrayType( $item['extranamespaces'], 'array' );
+ ApiResult::setIndexedTagName( $item['extranamespaces'], 'ns' );
+ }
+ }
+ if ( isset( $settings[ApiBase::PARAM_MAX] ) ) {
+ $item['max'] = $settings[ApiBase::PARAM_MAX];
+ }
+ if ( isset( $settings[ApiBase::PARAM_MAX2] ) ) {
+ $item['highmax'] = $settings[ApiBase::PARAM_MAX2];
+ }
+ if ( isset( $settings[ApiBase::PARAM_MIN] ) ) {
+ $item['min'] = $settings[ApiBase::PARAM_MIN];
+ }
+ if ( !empty( $settings[ApiBase::PARAM_RANGE_ENFORCE] ) ) {
+ $item['enforcerange'] = true;
+ }
+ if ( !empty( $settings[ApiBase::PARAM_DEPRECATED_VALUES] ) ) {
+ $deprecatedValues = array_keys( $settings[ApiBase::PARAM_DEPRECATED_VALUES] );
+ if ( is_array( $item['type'] ) ) {
+ $deprecatedValues = array_intersect( $deprecatedValues, $item['type'] );
+ }
+ if ( $deprecatedValues ) {
+ $item['deprecatedvalues'] = array_values( $deprecatedValues );
+ ApiResult::setIndexedTagName( $item['deprecatedvalues'], 'v' );
+ }
+ }
+
+ if ( !empty( $settings[ApiBase::PARAM_HELP_MSG_INFO] ) ) {
+ $item['info'] = [];
+ foreach ( $settings[ApiBase::PARAM_HELP_MSG_INFO] as $i ) {
+ $tag = array_shift( $i );
+ $info = [
+ 'name' => $tag,
+ ];
+ if ( count( $i ) ) {
+ $info['values'] = $i;
+ ApiResult::setIndexedTagName( $info['values'], 'v' );
+ }
+ $this->formatHelpMessages( $info, 'text', [
+ $this->context->msg( "apihelp-{$path}-paraminfo-{$tag}" )
+ ->numParams( count( $i ) )
+ ->params( $this->context->getLanguage()->commaList( $i ) )
+ ->params( $module->getModulePrefix() )
+ ] );
+ ApiResult::setSubelementsList( $info, 'text' );
+ $item['info'][] = $info;
+ }
+ ApiResult::setIndexedTagName( $item['info'], 'i' );
+ }
+
+ $ret['parameters'][] = $item;
+ }
+ ApiResult::setIndexedTagName( $ret['parameters'], 'param' );
+
+ $dynamicParams = $module->dynamicParameterDocumentation();
+ if ( $dynamicParams !== null ) {
+ if ( $this->helpFormat === 'none' ) {
+ $ret['dynamicparameters'] = true;
+ } else {
+ $dynamicParams = ApiBase::makeMessage( $dynamicParams, $this->context, [
+ $module->getModulePrefix(),
+ $module->getModuleName(),
+ $module->getModulePath()
+ ] );
+ $this->formatHelpMessages( $ret, 'dynamicparameters', [ $dynamicParams ] );
+ }
+ }
+
+ return $ret;
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ public function getAllowedParams() {
+ // back compat
+ $querymodules = $this->getMain()->getModuleManager()
+ ->getModule( 'query' )->getModuleManager()->getNames();
+ sort( $querymodules );
+ $formatmodules = $this->getMain()->getModuleManager()->getNames( 'format' );
+ sort( $formatmodules );
+
+ return [
+ 'modules' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'helpformat' => [
+ ApiBase::PARAM_DFLT => 'none',
+ ApiBase::PARAM_TYPE => [ 'html', 'wikitext', 'raw', 'none' ],
+ ],
+
+ 'querymodules' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => $querymodules,
+ ],
+ 'mainmodule' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'pagesetmodule' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'formatmodules' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => $formatmodules,
+ ]
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=paraminfo&modules=parse|phpfm|query%2Ballpages|query%2Bsiteinfo'
+ => 'apihelp-paraminfo-example-1',
+ 'action=paraminfo&modules=query%2B*'
+ => 'apihelp-paraminfo-example-2',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parameter_information';
+ }
+}
diff --git a/www/wiki/includes/api/ApiParse.php b/www/wiki/includes/api/ApiParse.php
new file mode 100644
index 00000000..7cbd3537
--- /dev/null
+++ b/www/wiki/includes/api/ApiParse.php
@@ -0,0 +1,918 @@
+<?php
+/**
+ * Created on Dec 01, 2007
+ *
+ * Copyright © 2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @ingroup API
+ */
+class ApiParse extends ApiBase {
+
+ /** @var string $section */
+ private $section = null;
+
+ /** @var Content $content */
+ private $content = null;
+
+ /** @var Content $pstContent */
+ private $pstContent = null;
+
+ /** @var bool */
+ private $contentIsDeleted = false, $contentIsSuppressed = false;
+
+ public function execute() {
+ // The data is hot but user-dependent, like page views, so we set vary cookies
+ $this->getMain()->setCacheMode( 'anon-public-user-private' );
+
+ // Get parameters
+ $params = $this->extractRequestParams();
+
+ // No easy way to say that text and title or revid are allowed together
+ // while the rest aren't, so just do it in three calls.
+ $this->requireMaxOneParameter( $params, 'page', 'pageid', 'oldid', 'text' );
+ $this->requireMaxOneParameter( $params, 'page', 'pageid', 'oldid', 'title' );
+ $this->requireMaxOneParameter( $params, 'page', 'pageid', 'oldid', 'revid' );
+
+ $text = $params['text'];
+ $title = $params['title'];
+ if ( $title === null ) {
+ $titleProvided = false;
+ // A title is needed for parsing, so arbitrarily choose one
+ $title = 'API';
+ } else {
+ $titleProvided = true;
+ }
+
+ $page = $params['page'];
+ $pageid = $params['pageid'];
+ $oldid = $params['oldid'];
+
+ $model = $params['contentmodel'];
+ $format = $params['contentformat'];
+
+ $prop = array_flip( $params['prop'] );
+
+ if ( isset( $params['section'] ) ) {
+ $this->section = $params['section'];
+ if ( !preg_match( '/^((T-)?\d+|new)$/', $this->section ) ) {
+ $this->dieWithError( 'apierror-invalidsection' );
+ }
+ } else {
+ $this->section = false;
+ }
+
+ // The parser needs $wgTitle to be set, apparently the
+ // $title parameter in Parser::parse isn't enough *sigh*
+ // TODO: Does this still need $wgTitle?
+ global $wgParser, $wgTitle;
+
+ $redirValues = null;
+
+ $needContent = isset( $prop['wikitext'] ) ||
+ isset( $prop['parsetree'] ) || $params['generatexml'];
+
+ // Return result
+ $result = $this->getResult();
+
+ if ( !is_null( $oldid ) || !is_null( $pageid ) || !is_null( $page ) ) {
+ if ( $this->section === 'new' ) {
+ $this->dieWithError( 'apierror-invalidparammix-parse-new-section', 'invalidparammix' );
+ }
+ if ( !is_null( $oldid ) ) {
+ // Don't use the parser cache
+ $rev = Revision::newFromId( $oldid );
+ if ( !$rev ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $oldid ] );
+ }
+
+ $this->checkTitleUserPermissions( $rev->getTitle(), 'read' );
+ if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
+ $this->dieWithError(
+ [ 'apierror-permissiondenied', $this->msg( 'action-deletedtext' ) ]
+ );
+ }
+
+ $titleObj = $rev->getTitle();
+ $wgTitle = $titleObj;
+ $pageObj = WikiPage::factory( $titleObj );
+ list( $popts, $reset, $suppressCache ) = $this->makeParserOptions( $pageObj, $params );
+ $p_result = $this->getParsedContent(
+ $pageObj, $popts, $suppressCache, $pageid, $rev, $needContent
+ );
+ } else { // Not $oldid, but $pageid or $page
+ if ( $params['redirects'] ) {
+ $reqParams = [
+ 'redirects' => '',
+ ];
+ if ( !is_null( $pageid ) ) {
+ $reqParams['pageids'] = $pageid;
+ } else { // $page
+ $reqParams['titles'] = $page;
+ }
+ $req = new FauxRequest( $reqParams );
+ $main = new ApiMain( $req );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+ $redirValues = $pageSet->getRedirectTitlesAsResult( $this->getResult() );
+
+ $to = $page;
+ foreach ( $pageSet->getRedirectTitles() as $title ) {
+ $to = $title->getFullText();
+ }
+ $pageParams = [ 'title' => $to ];
+ } elseif ( !is_null( $pageid ) ) {
+ $pageParams = [ 'pageid' => $pageid ];
+ } else { // $page
+ $pageParams = [ 'title' => $page ];
+ }
+
+ $pageObj = $this->getTitleOrPageId( $pageParams, 'fromdb' );
+ $titleObj = $pageObj->getTitle();
+ if ( !$titleObj || !$titleObj->exists() ) {
+ $this->dieWithError( 'apierror-missingtitle' );
+ }
+
+ $this->checkTitleUserPermissions( $titleObj, 'read' );
+ $wgTitle = $titleObj;
+
+ if ( isset( $prop['revid'] ) ) {
+ $oldid = $pageObj->getLatest();
+ }
+
+ list( $popts, $reset, $suppressCache ) = $this->makeParserOptions( $pageObj, $params );
+ $p_result = $this->getParsedContent(
+ $pageObj, $popts, $suppressCache, $pageid, null, $needContent
+ );
+ }
+ } else { // Not $oldid, $pageid, $page. Hence based on $text
+ $titleObj = Title::newFromText( $title );
+ if ( !$titleObj || $titleObj->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
+ }
+ $revid = $params['revid'];
+ if ( $revid !== null ) {
+ $rev = Revision::newFromId( $revid );
+ if ( !$rev ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $revid ] );
+ }
+ $pTitleObj = $titleObj;
+ $titleObj = $rev->getTitle();
+ if ( $titleProvided ) {
+ if ( !$titleObj->equals( $pTitleObj ) ) {
+ $this->addWarning( [ 'apierror-revwrongpage', $rev->getId(),
+ wfEscapeWikiText( $pTitleObj->getPrefixedText() ) ] );
+ }
+ } else {
+ // Consider the title derived from the revid as having
+ // been provided.
+ $titleProvided = true;
+ }
+ }
+ $wgTitle = $titleObj;
+ if ( $titleObj->canExist() ) {
+ $pageObj = WikiPage::factory( $titleObj );
+ } else {
+ // Do like MediaWiki::initializeArticle()
+ $article = Article::newFromTitle( $titleObj, $this->getContext() );
+ $pageObj = $article->getPage();
+ }
+
+ list( $popts, $reset ) = $this->makeParserOptions( $pageObj, $params );
+ $textProvided = !is_null( $text );
+
+ if ( !$textProvided ) {
+ if ( $titleProvided && ( $prop || $params['generatexml'] ) ) {
+ if ( $revid !== null ) {
+ $this->addWarning( 'apiwarn-parse-revidwithouttext' );
+ } else {
+ $this->addWarning( 'apiwarn-parse-titlewithouttext' );
+ }
+ }
+ // Prevent warning from ContentHandler::makeContent()
+ $text = '';
+ }
+
+ // If we are parsing text, do not use the content model of the default
+ // API title, but default to wikitext to keep BC.
+ if ( $textProvided && !$titleProvided && is_null( $model ) ) {
+ $model = CONTENT_MODEL_WIKITEXT;
+ $this->addWarning( [ 'apiwarn-parse-nocontentmodel', $model ] );
+ }
+
+ try {
+ $this->content = ContentHandler::makeContent( $text, $titleObj, $model, $format );
+ } catch ( MWContentSerializationException $ex ) {
+ $this->dieWithException( $ex, [
+ 'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
+ ] );
+ }
+
+ if ( $this->section !== false ) {
+ if ( $this->section === 'new' ) {
+ // Insert the section title above the content.
+ if ( !is_null( $params['sectiontitle'] ) && $params['sectiontitle'] !== '' ) {
+ $this->content = $this->content->addSectionHeader( $params['sectiontitle'] );
+ }
+ } else {
+ $this->content = $this->getSectionContent( $this->content, $titleObj->getPrefixedText() );
+ }
+ }
+
+ if ( $params['pst'] || $params['onlypst'] ) {
+ $this->pstContent = $this->content->preSaveTransform( $titleObj, $this->getUser(), $popts );
+ }
+ if ( $params['onlypst'] ) {
+ // Build a result and bail out
+ $result_array = [];
+ if ( $this->contentIsDeleted ) {
+ $result_array['textdeleted'] = true;
+ }
+ if ( $this->contentIsSuppressed ) {
+ $result_array['textsuppressed'] = true;
+ }
+ $result_array['text'] = $this->pstContent->serialize( $format );
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text';
+ if ( isset( $prop['wikitext'] ) ) {
+ $result_array['wikitext'] = $this->content->serialize( $format );
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'wikitext';
+ }
+ if ( !is_null( $params['summary'] ) ||
+ ( !is_null( $params['sectiontitle'] ) && $this->section === 'new' )
+ ) {
+ $result_array['parsedsummary'] = $this->formatSummary( $titleObj, $params );
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsedsummary';
+ }
+
+ $result->addValue( null, $this->getModuleName(), $result_array );
+
+ return;
+ }
+
+ // Not cached (save or load)
+ if ( $params['pst'] ) {
+ $p_result = $this->pstContent->getParserOutput( $titleObj, $revid, $popts );
+ } else {
+ $p_result = $this->content->getParserOutput( $titleObj, $revid, $popts );
+ }
+ }
+
+ $result_array = [];
+
+ $result_array['title'] = $titleObj->getPrefixedText();
+ $result_array['pageid'] = $pageid ?: $pageObj->getId();
+ if ( $this->contentIsDeleted ) {
+ $result_array['textdeleted'] = true;
+ }
+ if ( $this->contentIsSuppressed ) {
+ $result_array['textsuppressed'] = true;
+ }
+
+ if ( $params['disabletoc'] ) {
+ $p_result->setTOCEnabled( false );
+ }
+
+ if ( isset( $params['useskin'] ) ) {
+ $factory = MediaWikiServices::getInstance()->getSkinFactory();
+ $skin = $factory->makeSkin( Skin::normalizeKey( $params['useskin'] ) );
+ } else {
+ $skin = null;
+ }
+
+ $outputPage = null;
+ if ( $skin || isset( $prop['headhtml'] ) || isset( $prop['categorieshtml'] ) ) {
+ // Enabling the skin via 'useskin', 'headhtml', or 'categorieshtml'
+ // gets OutputPage and Skin involved, which (among others) applies
+ // these hooks:
+ // - ParserOutputHooks
+ // - Hook: LanguageLinks
+ // - Hook: OutputPageParserOutput
+ // - Hook: OutputPageMakeCategoryLinks
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $titleObj );
+ $context->setWikiPage( $pageObj );
+
+ if ( $skin ) {
+ // Use the skin specified by 'useskin'
+ $context->setSkin( $skin );
+ // Context clones the skin, refetch to stay in sync. (T166022)
+ $skin = $context->getSkin();
+ } else {
+ // Make sure the context's skin refers to the context. Without this,
+ // $outputPage->getSkin()->getOutput() !== $outputPage which
+ // confuses some of the output.
+ $context->setSkin( $context->getSkin() );
+ }
+
+ $outputPage = new OutputPage( $context );
+ $outputPage->addParserOutputMetadata( $p_result );
+ $context->setOutput( $outputPage );
+
+ if ( $skin ) {
+ // Based on OutputPage::output()
+ foreach ( $skin->getDefaultModules() as $group ) {
+ $outputPage->addModules( $group );
+ }
+ }
+ }
+
+ if ( !is_null( $oldid ) ) {
+ $result_array['revid'] = intval( $oldid );
+ }
+
+ if ( $params['redirects'] && !is_null( $redirValues ) ) {
+ $result_array['redirects'] = $redirValues;
+ }
+
+ if ( isset( $prop['text'] ) ) {
+ $result_array['text'] = $p_result->getText();
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text';
+ }
+
+ if ( !is_null( $params['summary'] ) ||
+ ( !is_null( $params['sectiontitle'] ) && $this->section === 'new' )
+ ) {
+ $result_array['parsedsummary'] = $this->formatSummary( $titleObj, $params );
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsedsummary';
+ }
+
+ if ( isset( $prop['langlinks'] ) ) {
+ if ( $skin ) {
+ $langlinks = $outputPage->getLanguageLinks();
+ } else {
+ $langlinks = $p_result->getLanguageLinks();
+ // The deprecated 'effectivelanglinks' option depredates OutputPage
+ // support via 'useskin'. If not already applied, then run just this
+ // one hook of OutputPage::addParserOutputMetadata here.
+ if ( $params['effectivelanglinks'] ) {
+ $linkFlags = [];
+ Hooks::run( 'LanguageLinks', [ $titleObj, &$langlinks, &$linkFlags ] );
+ }
+ }
+
+ $result_array['langlinks'] = $this->formatLangLinks( $langlinks );
+ }
+ if ( isset( $prop['categories'] ) ) {
+ $result_array['categories'] = $this->formatCategoryLinks( $p_result->getCategories() );
+ }
+ if ( isset( $prop['categorieshtml'] ) ) {
+ $result_array['categorieshtml'] = $outputPage->getSkin()->getCategories();
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'categorieshtml';
+ }
+ if ( isset( $prop['links'] ) ) {
+ $result_array['links'] = $this->formatLinks( $p_result->getLinks() );
+ }
+ if ( isset( $prop['templates'] ) ) {
+ $result_array['templates'] = $this->formatLinks( $p_result->getTemplates() );
+ }
+ if ( isset( $prop['images'] ) ) {
+ $result_array['images'] = array_keys( $p_result->getImages() );
+ }
+ if ( isset( $prop['externallinks'] ) ) {
+ $result_array['externallinks'] = array_keys( $p_result->getExternalLinks() );
+ }
+ if ( isset( $prop['sections'] ) ) {
+ $result_array['sections'] = $p_result->getSections();
+ }
+ if ( isset( $prop['parsewarnings'] ) ) {
+ $result_array['parsewarnings'] = $p_result->getWarnings();
+ }
+
+ if ( isset( $prop['displaytitle'] ) ) {
+ $result_array['displaytitle'] = $p_result->getDisplayTitle() ?:
+ $titleObj->getPrefixedText();
+ }
+
+ if ( isset( $prop['headitems'] ) ) {
+ if ( $skin ) {
+ $result_array['headitems'] = $this->formatHeadItems( $outputPage->getHeadItemsArray() );
+ } else {
+ $result_array['headitems'] = $this->formatHeadItems( $p_result->getHeadItems() );
+ }
+ }
+
+ if ( isset( $prop['headhtml'] ) ) {
+ $result_array['headhtml'] = $outputPage->headElement( $context->getSkin() );
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'headhtml';
+ }
+
+ if ( isset( $prop['modules'] ) ) {
+ if ( $skin ) {
+ $result_array['modules'] = $outputPage->getModules();
+ $result_array['modulescripts'] = $outputPage->getModuleScripts();
+ $result_array['modulestyles'] = $outputPage->getModuleStyles();
+ } else {
+ $result_array['modules'] = array_values( array_unique( $p_result->getModules() ) );
+ $result_array['modulescripts'] = array_values( array_unique( $p_result->getModuleScripts() ) );
+ $result_array['modulestyles'] = array_values( array_unique( $p_result->getModuleStyles() ) );
+ }
+ }
+
+ if ( isset( $prop['jsconfigvars'] ) ) {
+ $jsconfigvars = $skin ? $outputPage->getJsConfigVars() : $p_result->getJsConfigVars();
+ $result_array['jsconfigvars'] = ApiResult::addMetadataToResultVars( $jsconfigvars );
+ }
+
+ if ( isset( $prop['encodedjsconfigvars'] ) ) {
+ $jsconfigvars = $skin ? $outputPage->getJsConfigVars() : $p_result->getJsConfigVars();
+ $result_array['encodedjsconfigvars'] = FormatJson::encode(
+ $jsconfigvars,
+ false,
+ FormatJson::ALL_OK
+ );
+ $result_array[ApiResult::META_SUBELEMENTS][] = 'encodedjsconfigvars';
+ }
+
+ if ( isset( $prop['modules'] ) &&
+ !isset( $prop['jsconfigvars'] ) && !isset( $prop['encodedjsconfigvars'] ) ) {
+ $this->addWarning( 'apiwarn-moduleswithoutvars' );
+ }
+
+ if ( isset( $prop['indicators'] ) ) {
+ if ( $skin ) {
+ $result_array['indicators'] = (array)$outputPage->getIndicators();
+ } else {
+ $result_array['indicators'] = (array)$p_result->getIndicators();
+ }
+ ApiResult::setArrayType( $result_array['indicators'], 'BCkvp', 'name' );
+ }
+
+ if ( isset( $prop['iwlinks'] ) ) {
+ $result_array['iwlinks'] = $this->formatIWLinks( $p_result->getInterwikiLinks() );
+ }
+
+ if ( isset( $prop['wikitext'] ) ) {
+ $result_array['wikitext'] = $this->content->serialize( $format );
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'wikitext';
+ if ( !is_null( $this->pstContent ) ) {
+ $result_array['psttext'] = $this->pstContent->serialize( $format );
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'psttext';
+ }
+ }
+ if ( isset( $prop['properties'] ) ) {
+ $result_array['properties'] = (array)$p_result->getProperties();
+ ApiResult::setArrayType( $result_array['properties'], 'BCkvp', 'name' );
+ }
+
+ if ( isset( $prop['limitreportdata'] ) ) {
+ $result_array['limitreportdata'] =
+ $this->formatLimitReportData( $p_result->getLimitReportData() );
+ }
+ if ( isset( $prop['limitreporthtml'] ) ) {
+ $result_array['limitreporthtml'] = EditPage::getPreviewLimitReport( $p_result );
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'limitreporthtml';
+ }
+
+ if ( isset( $prop['parsetree'] ) || $params['generatexml'] ) {
+ if ( $this->content->getModel() != CONTENT_MODEL_WIKITEXT ) {
+ $this->dieWithError( 'apierror-parsetree-notwikitext', 'notwikitext' );
+ }
+
+ $wgParser->startExternalParse( $titleObj, $popts, Parser::OT_PREPROCESS );
+ $dom = $wgParser->preprocessToDom( $this->content->getNativeData() );
+ if ( is_callable( [ $dom, 'saveXML' ] ) ) {
+ $xml = $dom->saveXML();
+ } else {
+ $xml = $dom->__toString();
+ }
+ $result_array['parsetree'] = $xml;
+ $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsetree';
+ }
+
+ $result_mapping = [
+ 'redirects' => 'r',
+ 'langlinks' => 'll',
+ 'categories' => 'cl',
+ 'links' => 'pl',
+ 'templates' => 'tl',
+ 'images' => 'img',
+ 'externallinks' => 'el',
+ 'iwlinks' => 'iw',
+ 'sections' => 's',
+ 'headitems' => 'hi',
+ 'modules' => 'm',
+ 'indicators' => 'ind',
+ 'modulescripts' => 'm',
+ 'modulestyles' => 'm',
+ 'properties' => 'pp',
+ 'limitreportdata' => 'lr',
+ 'parsewarnings' => 'pw'
+ ];
+ $this->setIndexedTagNames( $result_array, $result_mapping );
+ $result->addValue( null, $this->getModuleName(), $result_array );
+ }
+
+ /**
+ * Constructs a ParserOptions object
+ *
+ * @param WikiPage $pageObj
+ * @param array $params
+ *
+ * @return array [ ParserOptions, ScopedCallback, bool $suppressCache ]
+ */
+ protected function makeParserOptions( WikiPage $pageObj, array $params ) {
+ $popts = $pageObj->makeParserOptions( $this->getContext() );
+ $popts->enableLimitReport( !$params['disablepp'] && !$params['disablelimitreport'] );
+ $popts->setIsPreview( $params['preview'] || $params['sectionpreview'] );
+ $popts->setIsSectionPreview( $params['sectionpreview'] );
+ $popts->setEditSection( !$params['disableeditsection'] );
+ if ( $params['disabletidy'] ) {
+ $popts->setTidy( false );
+ }
+ $popts->setWrapOutputClass(
+ $params['wrapoutputclass'] === '' ? false : $params['wrapoutputclass']
+ );
+
+ $reset = null;
+ $suppressCache = false;
+ Hooks::run( 'ApiMakeParserOptions',
+ [ $popts, $pageObj->getTitle(), $params, $this, &$reset, &$suppressCache ] );
+
+ // Force cache suppression when $popts aren't cacheable.
+ $suppressCache = $suppressCache || !$popts->isSafeToCache();
+
+ return [ $popts, $reset, $suppressCache ];
+ }
+
+ /**
+ * @param WikiPage $page
+ * @param ParserOptions $popts
+ * @param bool $suppressCache
+ * @param int $pageId
+ * @param Revision|null $rev
+ * @param bool $getContent
+ * @return ParserOutput
+ */
+ private function getParsedContent(
+ WikiPage $page, $popts, $suppressCache, $pageId, $rev, $getContent
+ ) {
+ $revId = $rev ? $rev->getId() : null;
+ $isDeleted = $rev && $rev->isDeleted( Revision::DELETED_TEXT );
+
+ if ( $getContent || $this->section !== false || $isDeleted ) {
+ if ( $rev ) {
+ $this->content = $rev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+ if ( !$this->content ) {
+ $this->dieWithError( [ 'apierror-missingcontent-revid', $revId ] );
+ }
+ } else {
+ $this->content = $page->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+ if ( !$this->content ) {
+ $this->dieWithError( [ 'apierror-missingcontent-pageid', $pageId ] );
+ }
+ }
+ $this->contentIsDeleted = $isDeleted;
+ $this->contentIsSuppressed = $rev &&
+ $rev->isDeleted( Revision::DELETED_TEXT | Revision::DELETED_RESTRICTED );
+ }
+
+ if ( $this->section !== false ) {
+ $this->content = $this->getSectionContent(
+ $this->content,
+ $pageId === null ? $page->getTitle()->getPrefixedText() : $this->msg( 'pageid', $pageId )
+ );
+ return $this->content->getParserOutput( $page->getTitle(), $revId, $popts );
+ }
+
+ if ( $isDeleted ) {
+ // getParserOutput can't do revdeled revisions
+ $pout = $this->content->getParserOutput( $page->getTitle(), $revId, $popts );
+ } else {
+ // getParserOutput will save to Parser cache if able
+ $pout = $page->getParserOutput( $popts, $revId, $suppressCache );
+ }
+ if ( !$pout ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] );
+ }
+
+ return $pout;
+ }
+
+ /**
+ * Extract the requested section from the given Content
+ *
+ * @param Content $content
+ * @param string|Message $what Identifies the content in error messages, e.g. page title.
+ * @return Content
+ */
+ private function getSectionContent( Content $content, $what ) {
+ // Not cached (save or load)
+ $section = $content->getSection( $this->section );
+ if ( $section === false ) {
+ $this->dieWithError( [ 'apierror-nosuchsection-what', $this->section, $what ], 'nosuchsection' );
+ }
+ if ( $section === null ) {
+ $this->dieWithError( [ 'apierror-sectionsnotsupported-what', $what ], 'nosuchsection' );
+ $section = false;
+ }
+
+ return $section;
+ }
+
+ /**
+ * This mimicks the behavior of EditPage in formatting a summary
+ *
+ * @param Title $title of the page being parsed
+ * @param Array $params the API parameters of the request
+ * @return Content|bool
+ */
+ private function formatSummary( $title, $params ) {
+ global $wgParser;
+ $summary = !is_null( $params['summary'] ) ? $params['summary'] : '';
+ $sectionTitle = !is_null( $params['sectiontitle'] ) ? $params['sectiontitle'] : '';
+
+ if ( $this->section === 'new' && ( $sectionTitle === '' || $summary === '' ) ) {
+ if ( $sectionTitle !== '' ) {
+ $summary = $params['sectiontitle'];
+ }
+ if ( $summary !== '' ) {
+ $summary = wfMessage( 'newsectionsummary' )
+ ->rawParams( $wgParser->stripSectionName( $summary ) )
+ ->inContentLanguage()->text();
+ }
+ }
+ return Linker::formatComment( $summary, $title, $this->section === 'new' );
+ }
+
+ private function formatLangLinks( $links ) {
+ $result = [];
+ foreach ( $links as $link ) {
+ $entry = [];
+ $bits = explode( ':', $link, 2 );
+ $title = Title::newFromText( $link );
+
+ $entry['lang'] = $bits[0];
+ if ( $title ) {
+ $entry['url'] = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+ // localised language name in 'uselang' language
+ $entry['langname'] = Language::fetchLanguageName(
+ $title->getInterwiki(),
+ $this->getLanguage()->getCode()
+ );
+
+ // native language name
+ $entry['autonym'] = Language::fetchLanguageName( $title->getInterwiki() );
+ }
+ ApiResult::setContentValue( $entry, 'title', $bits[1] );
+ $result[] = $entry;
+ }
+
+ return $result;
+ }
+
+ private function formatCategoryLinks( $links ) {
+ $result = [];
+
+ if ( !$links ) {
+ return $result;
+ }
+
+ // Fetch hiddencat property
+ $lb = new LinkBatch;
+ $lb->setArray( [ NS_CATEGORY => $links ] );
+ $db = $this->getDB();
+ $res = $db->select( [ 'page', 'page_props' ],
+ [ 'page_title', 'pp_propname' ],
+ $lb->constructSet( 'page', $db ),
+ __METHOD__,
+ [],
+ [ 'page_props' => [
+ 'LEFT JOIN', [ 'pp_propname' => 'hiddencat', 'pp_page = page_id' ]
+ ] ]
+ );
+ $hiddencats = [];
+ foreach ( $res as $row ) {
+ $hiddencats[$row->page_title] = isset( $row->pp_propname );
+ }
+
+ $linkCache = LinkCache::singleton();
+
+ foreach ( $links as $link => $sortkey ) {
+ $entry = [];
+ $entry['sortkey'] = $sortkey;
+ // array keys will cast numeric category names to ints, so cast back to string
+ ApiResult::setContentValue( $entry, 'category', (string)$link );
+ if ( !isset( $hiddencats[$link] ) ) {
+ $entry['missing'] = true;
+
+ // We already know the link doesn't exist in the database, so
+ // tell LinkCache that before calling $title->isKnown().
+ $title = Title::makeTitle( NS_CATEGORY, $link );
+ $linkCache->addBadLinkObj( $title );
+ if ( $title->isKnown() ) {
+ $entry['known'] = true;
+ }
+ } elseif ( $hiddencats[$link] ) {
+ $entry['hidden'] = true;
+ }
+ $result[] = $entry;
+ }
+
+ return $result;
+ }
+
+ private function formatLinks( $links ) {
+ $result = [];
+ foreach ( $links as $ns => $nslinks ) {
+ foreach ( $nslinks as $title => $id ) {
+ $entry = [];
+ $entry['ns'] = $ns;
+ ApiResult::setContentValue( $entry, 'title', Title::makeTitle( $ns, $title )->getFullText() );
+ $entry['exists'] = $id != 0;
+ $result[] = $entry;
+ }
+ }
+
+ return $result;
+ }
+
+ private function formatIWLinks( $iw ) {
+ $result = [];
+ foreach ( $iw as $prefix => $titles ) {
+ foreach ( array_keys( $titles ) as $title ) {
+ $entry = [];
+ $entry['prefix'] = $prefix;
+
+ $title = Title::newFromText( "{$prefix}:{$title}" );
+ if ( $title ) {
+ $entry['url'] = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+ }
+
+ ApiResult::setContentValue( $entry, 'title', $title->getFullText() );
+ $result[] = $entry;
+ }
+ }
+
+ return $result;
+ }
+
+ private function formatHeadItems( $headItems ) {
+ $result = [];
+ foreach ( $headItems as $tag => $content ) {
+ $entry = [];
+ $entry['tag'] = $tag;
+ ApiResult::setContentValue( $entry, 'content', $content );
+ $result[] = $entry;
+ }
+
+ return $result;
+ }
+
+ private function formatLimitReportData( $limitReportData ) {
+ $result = [];
+
+ foreach ( $limitReportData as $name => $value ) {
+ $entry = [];
+ $entry['name'] = $name;
+ if ( !is_array( $value ) ) {
+ $value = [ $value ];
+ }
+ ApiResult::setIndexedTagNameRecursive( $value, 'param' );
+ $entry = array_merge( $entry, $value );
+ $result[] = $entry;
+ }
+
+ return $result;
+ }
+
+ private function setIndexedTagNames( &$array, $mapping ) {
+ foreach ( $mapping as $key => $name ) {
+ if ( isset( $array[$key] ) ) {
+ ApiResult::setIndexedTagName( $array[$key], $name );
+ }
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => null,
+ 'text' => [
+ ApiBase::PARAM_TYPE => 'text',
+ ],
+ 'revid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'summary' => null,
+ 'page' => null,
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'redirects' => false,
+ 'oldid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'text|langlinks|categories|links|templates|' .
+ 'images|externallinks|sections|revid|displaytitle|iwlinks|' .
+ 'properties|parsewarnings',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'text',
+ 'langlinks',
+ 'categories',
+ 'categorieshtml',
+ 'links',
+ 'templates',
+ 'images',
+ 'externallinks',
+ 'sections',
+ 'revid',
+ 'displaytitle',
+ 'headhtml',
+ 'modules',
+ 'jsconfigvars',
+ 'encodedjsconfigvars',
+ 'indicators',
+ 'iwlinks',
+ 'wikitext',
+ 'properties',
+ 'limitreportdata',
+ 'limitreporthtml',
+ 'parsetree',
+ 'parsewarnings',
+ 'headitems',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [
+ 'parsetree' => [ 'apihelp-parse-paramvalue-prop-parsetree', CONTENT_MODEL_WIKITEXT ],
+ ],
+ ApiBase::PARAM_DEPRECATED_VALUES => [
+ 'headitems' => 'apiwarn-deprecation-parse-headitems',
+ ],
+ ],
+ 'wrapoutputclass' => 'mw-parser-output',
+ 'pst' => false,
+ 'onlypst' => false,
+ 'effectivelanglinks' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'section' => null,
+ 'sectiontitle' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'disablepp' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'disablelimitreport' => false,
+ 'disableeditsection' => false,
+ 'disabletidy' => false,
+ 'generatexml' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => [
+ 'apihelp-parse-param-generatexml', CONTENT_MODEL_WIKITEXT
+ ],
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'preview' => false,
+ 'sectionpreview' => false,
+ 'disabletoc' => false,
+ 'useskin' => [
+ ApiBase::PARAM_TYPE => array_keys( Skin::getAllowedSkins() ),
+ ],
+ 'contentformat' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
+ ],
+ 'contentmodel' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
+ ]
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=parse&page=Project:Sandbox'
+ => 'apihelp-parse-example-page',
+ 'action=parse&text={{Project:Sandbox}}&contentmodel=wikitext'
+ => 'apihelp-parse-example-text',
+ 'action=parse&text={{PAGENAME}}&title=Test'
+ => 'apihelp-parse-example-texttitle',
+ 'action=parse&summary=Some+[[link]]&prop='
+ => 'apihelp-parse-example-summary',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Parsing_wikitext#parse';
+ }
+}
diff --git a/www/wiki/includes/api/ApiPatrol.php b/www/wiki/includes/api/ApiPatrol.php
new file mode 100644
index 00000000..06e8ae28
--- /dev/null
+++ b/www/wiki/includes/api/ApiPatrol.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * API for MediaWiki 1.14+
+ *
+ * Created on Sep 2, 2008
+ *
+ * Copyright © 2008 Soxred93 soxred93@gmail.com,
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Allows user to patrol pages
+ * @ingroup API
+ */
+class ApiPatrol extends ApiBase {
+
+ /**
+ * Patrols the article or provides the reason the patrol failed.
+ */
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $this->requireOnlyOneParameter( $params, 'rcid', 'revid' );
+
+ if ( isset( $params['rcid'] ) ) {
+ $rc = RecentChange::newFromId( $params['rcid'] );
+ if ( !$rc ) {
+ $this->dieWithError( [ 'apierror-nosuchrcid', $params['rcid'] ] );
+ }
+ } else {
+ $rev = Revision::newFromId( $params['revid'] );
+ if ( !$rev ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $params['revid'] ] );
+ }
+ $rc = $rev->getRecentChange();
+ if ( !$rc ) {
+ $this->dieWithError( [ 'apierror-notpatrollable', $params['revid'] ] );
+ }
+ }
+
+ $user = $this->getUser();
+ $tags = $params['tags'];
+
+ // Check if user can add tags
+ if ( !is_null( $tags ) ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $tags, $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ $retval = $rc->doMarkPatrolled( $user, false, $tags );
+
+ if ( $retval ) {
+ $this->dieStatus( $this->errorArrayToStatus( $retval, $user ) );
+ }
+
+ $result = [ 'rcid' => intval( $rc->getAttribute( 'rc_id' ) ) ];
+ ApiQueryBase::addTitleInfo( $result, $rc->getTitle() );
+ $this->getResult()->addValue( null, $this->getModuleName(), $result );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'rcid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'revid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'patrol';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=patrol&token=123ABC&rcid=230672766'
+ => 'apihelp-patrol-example-rcid',
+ 'action=patrol&token=123ABC&revid=230672766'
+ => 'apihelp-patrol-example-revid',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Patrol';
+ }
+}
diff --git a/www/wiki/includes/api/ApiProtect.php b/www/wiki/includes/api/ApiProtect.php
new file mode 100644
index 00000000..1be4b103
--- /dev/null
+++ b/www/wiki/includes/api/ApiProtect.php
@@ -0,0 +1,204 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 1, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiProtect extends ApiBase {
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $pageObj = $this->getTitleOrPageId( $params, 'fromdbmaster' );
+ $titleObj = $pageObj->getTitle();
+
+ $this->checkTitleUserPermissions( $titleObj, 'protect' );
+
+ $user = $this->getUser();
+ $tags = $params['tags'];
+
+ // Check if user can add tags
+ if ( !is_null( $tags ) ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $tags, $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ $expiry = (array)$params['expiry'];
+ if ( count( $expiry ) != count( $params['protections'] ) ) {
+ if ( count( $expiry ) == 1 ) {
+ $expiry = array_fill( 0, count( $params['protections'] ), $expiry[0] );
+ } else {
+ $this->dieWithError( [
+ 'apierror-toofewexpiries',
+ count( $expiry ),
+ count( $params['protections'] )
+ ] );
+ }
+ }
+
+ $restrictionTypes = $titleObj->getRestrictionTypes();
+
+ $protections = [];
+ $expiryarray = [];
+ $resultProtections = [];
+ foreach ( $params['protections'] as $i => $prot ) {
+ $p = explode( '=', $prot );
+ $protections[$p[0]] = ( $p[1] == 'all' ? '' : $p[1] );
+
+ if ( $titleObj->exists() && $p[0] == 'create' ) {
+ $this->dieWithError( 'apierror-create-titleexists' );
+ }
+ if ( !$titleObj->exists() && $p[0] != 'create' ) {
+ $this->dieWithError( 'apierror-missingtitle-createonly' );
+ }
+
+ if ( !in_array( $p[0], $restrictionTypes ) && $p[0] != 'create' ) {
+ $this->dieWithError( [ 'apierror-protect-invalidaction', wfEscapeWikiText( $p[0] ) ] );
+ }
+ if ( !in_array( $p[1], $this->getConfig()->get( 'RestrictionLevels' ) ) && $p[1] != 'all' ) {
+ $this->dieWithError( [ 'apierror-protect-invalidlevel', wfEscapeWikiText( $p[1] ) ] );
+ }
+
+ if ( wfIsInfinity( $expiry[$i] ) ) {
+ $expiryarray[$p[0]] = 'infinity';
+ } else {
+ $exp = strtotime( $expiry[$i] );
+ if ( $exp < 0 || !$exp ) {
+ $this->dieWithError( [ 'apierror-invalidexpiry', wfEscapeWikiText( $expiry[$i] ) ] );
+ }
+
+ $exp = wfTimestamp( TS_MW, $exp );
+ if ( $exp < wfTimestampNow() ) {
+ $this->dieWithError( [ 'apierror-pastexpiry', wfEscapeWikiText( $expiry[$i] ) ] );
+ }
+ $expiryarray[$p[0]] = $exp;
+ }
+ $resultProtections[] = [
+ $p[0] => $protections[$p[0]],
+ 'expiry' => ApiResult::formatExpiry( $expiryarray[$p[0]], 'infinite' ),
+ ];
+ }
+
+ $cascade = $params['cascade'];
+
+ $watch = $params['watch'] ? 'watch' : $params['watchlist'];
+ $this->setWatch( $watch, $titleObj, 'watchdefault' );
+
+ $status = $pageObj->doUpdateRestrictions(
+ $protections,
+ $expiryarray,
+ $cascade,
+ $params['reason'],
+ $user,
+ $tags
+ );
+
+ if ( !$status->isOK() ) {
+ $this->dieStatus( $status );
+ }
+ $res = [
+ 'title' => $titleObj->getPrefixedText(),
+ 'reason' => $params['reason']
+ ];
+ if ( $cascade ) {
+ $res['cascade'] = true;
+ }
+ $res['protections'] = $resultProtections;
+ $result = $this->getResult();
+ ApiResult::setIndexedTagName( $res['protections'], 'protection' );
+ $result->addValue( null, $this->getModuleName(), $res );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'protections' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'expiry' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ALLOW_DUPLICATES => true,
+ ApiBase::PARAM_DFLT => 'infinite',
+ ],
+ 'reason' => '',
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'cascade' => false,
+ 'watch' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'watchlist' => [
+ ApiBase::PARAM_DFLT => 'preferences',
+ ApiBase::PARAM_TYPE => [
+ 'watch',
+ 'unwatch',
+ 'preferences',
+ 'nochange'
+ ],
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=protect&title=Main%20Page&token=123ABC&' .
+ 'protections=edit=sysop|move=sysop&cascade=&expiry=20070901163000|never'
+ => 'apihelp-protect-example-protect',
+ 'action=protect&title=Main%20Page&token=123ABC&' .
+ 'protections=edit=all|move=all&reason=Lifting%20restrictions'
+ => 'apihelp-protect-example-unprotect',
+ 'action=protect&title=Main%20Page&token=123ABC&' .
+ 'protections=&reason=Lifting%20restrictions'
+ => 'apihelp-protect-example-unprotect2',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Protect';
+ }
+}
diff --git a/www/wiki/includes/api/ApiPurge.php b/www/wiki/includes/api/ApiPurge.php
new file mode 100644
index 00000000..35f93e07
--- /dev/null
+++ b/www/wiki/includes/api/ApiPurge.php
@@ -0,0 +1,182 @@
+<?php
+
+/**
+ * API for MediaWiki 1.14+
+ *
+ * Created on Sep 2, 2008
+ *
+ * Copyright © 2008 Chad Horohoe
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * API interface for page purging
+ * @ingroup API
+ */
+class ApiPurge extends ApiBase {
+ private $mPageSet;
+
+ /**
+ * Purges the cache of a page
+ */
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $continuationManager = new ApiContinuationManager( $this, [], [] );
+ $this->setContinuationManager( $continuationManager );
+
+ $forceLinkUpdate = $params['forcelinkupdate'];
+ $forceRecursiveLinkUpdate = $params['forcerecursivelinkupdate'];
+ $pageSet = $this->getPageSet();
+ $pageSet->execute();
+
+ $result = $pageSet->getInvalidTitlesAndRevisions();
+ $user = $this->getUser();
+
+ foreach ( $pageSet->getGoodTitles() as $title ) {
+ $r = [];
+ ApiQueryBase::addTitleInfo( $r, $title );
+ $page = WikiPage::factory( $title );
+ if ( !$user->pingLimiter( 'purge' ) ) {
+ // Directly purge and skip the UI part of purge()
+ $page->doPurge();
+ $r['purged'] = true;
+ } else {
+ $this->addWarning( 'apierror-ratelimited' );
+ }
+
+ if ( $forceLinkUpdate || $forceRecursiveLinkUpdate ) {
+ if ( !$user->pingLimiter( 'linkpurge' ) ) {
+ $popts = $page->makeParserOptions( 'canonical' );
+
+ # Parse content; note that HTML generation is only needed if we want to cache the result.
+ $content = $page->getContent( Revision::RAW );
+ if ( $content ) {
+ $enableParserCache = $this->getConfig()->get( 'EnableParserCache' );
+ $p_result = $content->getParserOutput(
+ $title,
+ $page->getLatest(),
+ $popts,
+ $enableParserCache
+ );
+
+ # Logging to better see expensive usage patterns
+ if ( $forceRecursiveLinkUpdate ) {
+ LoggerFactory::getInstance( 'RecursiveLinkPurge' )->info(
+ "Recursive link purge enqueued for {title}",
+ [
+ 'user' => $this->getUser()->getName(),
+ 'title' => $title->getPrefixedText()
+ ]
+ );
+ }
+
+ # Update the links tables
+ $updates = $content->getSecondaryDataUpdates(
+ $title, null, $forceRecursiveLinkUpdate, $p_result );
+ foreach ( $updates as $update ) {
+ DeferredUpdates::addUpdate( $update, DeferredUpdates::PRESEND );
+ }
+
+ $r['linkupdate'] = true;
+
+ if ( $enableParserCache ) {
+ $pcache = MediaWikiServices::getInstance()->getParserCache();
+ $pcache->save( $p_result, $page, $popts );
+ }
+ }
+ } else {
+ $this->addWarning( 'apierror-ratelimited' );
+ $forceLinkUpdate = false;
+ }
+ }
+
+ $result[] = $r;
+ }
+ $apiResult = $this->getResult();
+ ApiResult::setIndexedTagName( $result, 'page' );
+ $apiResult->addValue( null, $this->getModuleName(), $result );
+
+ $values = $pageSet->getNormalizedTitlesAsResult( $apiResult );
+ if ( $values ) {
+ $apiResult->addValue( null, 'normalized', $values );
+ }
+ $values = $pageSet->getConvertedTitlesAsResult( $apiResult );
+ if ( $values ) {
+ $apiResult->addValue( null, 'converted', $values );
+ }
+ $values = $pageSet->getRedirectTitlesAsResult( $apiResult );
+ if ( $values ) {
+ $apiResult->addValue( null, 'redirects', $values );
+ }
+
+ $this->setContinuationManager( null );
+ $continuationManager->setContinuationIntoResult( $apiResult );
+ }
+
+ /**
+ * Get a cached instance of an ApiPageSet object
+ * @return ApiPageSet
+ */
+ private function getPageSet() {
+ if ( !isset( $this->mPageSet ) ) {
+ $this->mPageSet = new ApiPageSet( $this );
+ }
+
+ return $this->mPageSet;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function getAllowedParams( $flags = 0 ) {
+ $result = [
+ 'forcelinkupdate' => false,
+ 'forcerecursivelinkupdate' => false,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ if ( $flags ) {
+ $result += $this->getPageSet()->getFinalParams( $flags );
+ }
+
+ return $result;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=purge&titles=Main_Page|API'
+ => 'apihelp-purge-example-simple',
+ 'action=purge&generator=allpages&gapnamespace=0&gaplimit=10'
+ => 'apihelp-purge-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Purge';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQuery.php b/www/wiki/includes/api/ApiQuery.php
new file mode 100644
index 00000000..31bcc7a7
--- /dev/null
+++ b/www/wiki/includes/api/ApiQuery.php
@@ -0,0 +1,548 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 7, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * This is the main query class. It behaves similar to ApiMain: based on the
+ * parameters given, it will create a list of titles to work on (an ApiPageSet
+ * object), instantiate and execute various property/list/meta modules, and
+ * assemble all resulting data into a single ApiResult object.
+ *
+ * In generator mode, a generator will be executed first to populate a second
+ * ApiPageSet object, and that object will be used for all subsequent modules.
+ *
+ * @ingroup API
+ */
+class ApiQuery extends ApiBase {
+
+ /**
+ * List of Api Query prop modules
+ * @var array
+ */
+ private static $QueryPropModules = [
+ 'categories' => 'ApiQueryCategories',
+ 'categoryinfo' => 'ApiQueryCategoryInfo',
+ 'contributors' => 'ApiQueryContributors',
+ 'deletedrevisions' => 'ApiQueryDeletedRevisions',
+ 'duplicatefiles' => 'ApiQueryDuplicateFiles',
+ 'extlinks' => 'ApiQueryExternalLinks',
+ 'fileusage' => 'ApiQueryBacklinksprop',
+ 'images' => 'ApiQueryImages',
+ 'imageinfo' => 'ApiQueryImageInfo',
+ 'info' => 'ApiQueryInfo',
+ 'links' => 'ApiQueryLinks',
+ 'linkshere' => 'ApiQueryBacklinksprop',
+ 'iwlinks' => 'ApiQueryIWLinks',
+ 'langlinks' => 'ApiQueryLangLinks',
+ 'pageprops' => 'ApiQueryPageProps',
+ 'redirects' => 'ApiQueryBacklinksprop',
+ 'revisions' => 'ApiQueryRevisions',
+ 'stashimageinfo' => 'ApiQueryStashImageInfo',
+ 'templates' => 'ApiQueryLinks',
+ 'transcludedin' => 'ApiQueryBacklinksprop',
+ ];
+
+ /**
+ * List of Api Query list modules
+ * @var array
+ */
+ private static $QueryListModules = [
+ 'allcategories' => 'ApiQueryAllCategories',
+ 'alldeletedrevisions' => 'ApiQueryAllDeletedRevisions',
+ 'allfileusages' => 'ApiQueryAllLinks',
+ 'allimages' => 'ApiQueryAllImages',
+ 'alllinks' => 'ApiQueryAllLinks',
+ 'allpages' => 'ApiQueryAllPages',
+ 'allredirects' => 'ApiQueryAllLinks',
+ 'allrevisions' => 'ApiQueryAllRevisions',
+ 'mystashedfiles' => 'ApiQueryMyStashedFiles',
+ 'alltransclusions' => 'ApiQueryAllLinks',
+ 'allusers' => 'ApiQueryAllUsers',
+ 'backlinks' => 'ApiQueryBacklinks',
+ 'blocks' => 'ApiQueryBlocks',
+ 'categorymembers' => 'ApiQueryCategoryMembers',
+ 'deletedrevs' => 'ApiQueryDeletedrevs',
+ 'embeddedin' => 'ApiQueryBacklinks',
+ 'exturlusage' => 'ApiQueryExtLinksUsage',
+ 'filearchive' => 'ApiQueryFilearchive',
+ 'imageusage' => 'ApiQueryBacklinks',
+ 'iwbacklinks' => 'ApiQueryIWBacklinks',
+ 'langbacklinks' => 'ApiQueryLangBacklinks',
+ 'logevents' => 'ApiQueryLogEvents',
+ 'pageswithprop' => 'ApiQueryPagesWithProp',
+ 'pagepropnames' => 'ApiQueryPagePropNames',
+ 'prefixsearch' => 'ApiQueryPrefixSearch',
+ 'protectedtitles' => 'ApiQueryProtectedTitles',
+ 'querypage' => 'ApiQueryQueryPage',
+ 'random' => 'ApiQueryRandom',
+ 'recentchanges' => 'ApiQueryRecentChanges',
+ 'search' => 'ApiQuerySearch',
+ 'tags' => 'ApiQueryTags',
+ 'usercontribs' => 'ApiQueryContributions',
+ 'users' => 'ApiQueryUsers',
+ 'watchlist' => 'ApiQueryWatchlist',
+ 'watchlistraw' => 'ApiQueryWatchlistRaw',
+ ];
+
+ /**
+ * List of Api Query meta modules
+ * @var array
+ */
+ private static $QueryMetaModules = [
+ 'allmessages' => 'ApiQueryAllMessages',
+ 'authmanagerinfo' => 'ApiQueryAuthManagerInfo',
+ 'siteinfo' => 'ApiQuerySiteinfo',
+ 'userinfo' => 'ApiQueryUserInfo',
+ 'filerepoinfo' => 'ApiQueryFileRepoInfo',
+ 'tokens' => 'ApiQueryTokens',
+ ];
+
+ /**
+ * @var ApiPageSet
+ */
+ private $mPageSet;
+
+ private $mParams;
+ private $mNamedDB = [];
+ private $mModuleMgr;
+
+ /**
+ * @param ApiMain $main
+ * @param string $action
+ */
+ public function __construct( ApiMain $main, $action ) {
+ parent::__construct( $main, $action );
+
+ $this->mModuleMgr = new ApiModuleManager( $this );
+
+ // Allow custom modules to be added in LocalSettings.php
+ $config = $this->getConfig();
+ $this->mModuleMgr->addModules( self::$QueryPropModules, 'prop' );
+ $this->mModuleMgr->addModules( $config->get( 'APIPropModules' ), 'prop' );
+ $this->mModuleMgr->addModules( self::$QueryListModules, 'list' );
+ $this->mModuleMgr->addModules( $config->get( 'APIListModules' ), 'list' );
+ $this->mModuleMgr->addModules( self::$QueryMetaModules, 'meta' );
+ $this->mModuleMgr->addModules( $config->get( 'APIMetaModules' ), 'meta' );
+
+ Hooks::run( 'ApiQuery::moduleManager', [ $this->mModuleMgr ] );
+
+ // Create PageSet that will process titles/pageids/revids/generator
+ $this->mPageSet = new ApiPageSet( $this );
+ }
+
+ /**
+ * Overrides to return this instance's module manager.
+ * @return ApiModuleManager
+ */
+ public function getModuleManager() {
+ return $this->mModuleMgr;
+ }
+
+ /**
+ * Get the query database connection with the given name.
+ * If no such connection has been requested before, it will be created.
+ * Subsequent calls with the same $name will return the same connection
+ * as the first, regardless of the values of $db and $groups
+ * @param string $name Name to assign to the database connection
+ * @param int $db One of the DB_* constants
+ * @param string|string[] $groups Query groups
+ * @return IDatabase
+ */
+ public function getNamedDB( $name, $db, $groups ) {
+ if ( !array_key_exists( $name, $this->mNamedDB ) ) {
+ $this->mNamedDB[$name] = wfGetDB( $db, $groups );
+ }
+
+ return $this->mNamedDB[$name];
+ }
+
+ /**
+ * Gets the set of pages the user has requested (or generated)
+ * @return ApiPageSet
+ */
+ public function getPageSet() {
+ return $this->mPageSet;
+ }
+
+ /**
+ * @return ApiFormatRaw|null
+ */
+ public function getCustomPrinter() {
+ // If &exportnowrap is set, use the raw formatter
+ if ( $this->getParameter( 'export' ) &&
+ $this->getParameter( 'exportnowrap' )
+ ) {
+ return new ApiFormatRaw( $this->getMain(),
+ $this->getMain()->createPrinterByName( 'xml' ) );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Query execution happens in the following steps:
+ * #1 Create a PageSet object with any pages requested by the user
+ * #2 If using a generator, execute it to get a new ApiPageSet object
+ * #3 Instantiate all requested modules.
+ * This way the PageSet object will know what shared data is required,
+ * and minimize DB calls.
+ * #4 Output all normalization and redirect resolution information
+ * #5 Execute all requested modules
+ */
+ public function execute() {
+ $this->mParams = $this->extractRequestParams();
+
+ // Instantiate requested modules
+ $allModules = [];
+ $this->instantiateModules( $allModules, 'prop' );
+ $propModules = array_keys( $allModules );
+ $this->instantiateModules( $allModules, 'list' );
+ $this->instantiateModules( $allModules, 'meta' );
+
+ // Filter modules based on continue parameter
+ $continuationManager = new ApiContinuationManager( $this, $allModules, $propModules );
+ $this->setContinuationManager( $continuationManager );
+ $modules = $continuationManager->getRunModules();
+
+ if ( !$continuationManager->isGeneratorDone() ) {
+ // Query modules may optimize data requests through the $this->getPageSet()
+ // object by adding extra fields from the page table.
+ foreach ( $modules as $module ) {
+ $module->requestExtraData( $this->mPageSet );
+ }
+ // Populate page/revision information
+ $this->mPageSet->execute();
+ // Record page information (title, namespace, if exists, etc)
+ $this->outputGeneralPageInfo();
+ } else {
+ $this->mPageSet->executeDryRun();
+ }
+
+ $cacheMode = $this->mPageSet->getCacheMode();
+
+ // Execute all unfinished modules
+ /** @var ApiQueryBase $module */
+ foreach ( $modules as $module ) {
+ $params = $module->extractRequestParams();
+ $cacheMode = $this->mergeCacheMode(
+ $cacheMode, $module->getCacheMode( $params ) );
+ $module->execute();
+ Hooks::run( 'APIQueryAfterExecute', [ &$module ] );
+ }
+
+ // Set the cache mode
+ $this->getMain()->setCacheMode( $cacheMode );
+
+ // Write the continuation data into the result
+ $this->setContinuationManager( null );
+ if ( $this->mParams['rawcontinue'] ) {
+ $data = $continuationManager->getRawNonContinuation();
+ if ( $data ) {
+ $this->getResult()->addValue( null, 'query-noncontinue', $data,
+ ApiResult::ADD_ON_TOP | ApiResult::NO_SIZE_CHECK );
+ }
+ $data = $continuationManager->getRawContinuation();
+ if ( $data ) {
+ $this->getResult()->addValue( null, 'query-continue', $data,
+ ApiResult::ADD_ON_TOP | ApiResult::NO_SIZE_CHECK );
+ }
+ } else {
+ $continuationManager->setContinuationIntoResult( $this->getResult() );
+ }
+ }
+
+ /**
+ * Update a cache mode string, applying the cache mode of a new module to it.
+ * The cache mode may increase in the level of privacy, but public modules
+ * added to private data do not decrease the level of privacy.
+ *
+ * @param string $cacheMode
+ * @param string $modCacheMode
+ * @return string
+ */
+ protected function mergeCacheMode( $cacheMode, $modCacheMode ) {
+ if ( $modCacheMode === 'anon-public-user-private' ) {
+ if ( $cacheMode !== 'private' ) {
+ $cacheMode = 'anon-public-user-private';
+ }
+ } elseif ( $modCacheMode === 'public' ) {
+ // do nothing, if it's public already it will stay public
+ } else { // private
+ $cacheMode = 'private';
+ }
+
+ return $cacheMode;
+ }
+
+ /**
+ * Create instances of all modules requested by the client
+ * @param array $modules To append instantiated modules to
+ * @param string $param Parameter name to read modules from
+ */
+ private function instantiateModules( &$modules, $param ) {
+ $wasPosted = $this->getRequest()->wasPosted();
+ if ( isset( $this->mParams[$param] ) ) {
+ foreach ( $this->mParams[$param] as $moduleName ) {
+ $instance = $this->mModuleMgr->getModule( $moduleName, $param );
+ if ( $instance === null ) {
+ ApiBase::dieDebug( __METHOD__, 'Error instantiating module' );
+ }
+ if ( !$wasPosted && $instance->mustBePosted() ) {
+ $this->dieWithErrorOrDebug( [ 'apierror-mustbeposted', $moduleName ] );
+ }
+ // Ignore duplicates. TODO 2.0: die()?
+ if ( !array_key_exists( $moduleName, $modules ) ) {
+ $modules[$moduleName] = $instance;
+ }
+ }
+ }
+ }
+
+ /**
+ * Appends an element for each page in the current pageSet with the
+ * most general information (id, title), plus any title normalizations
+ * and missing or invalid title/pageids/revids.
+ */
+ private function outputGeneralPageInfo() {
+ $pageSet = $this->getPageSet();
+ $result = $this->getResult();
+
+ // We can't really handle max-result-size failure here, but we need to
+ // check anyway in case someone set the limit stupidly low.
+ $fit = true;
+
+ $values = $pageSet->getNormalizedTitlesAsResult( $result );
+ if ( $values ) {
+ $fit = $fit && $result->addValue( 'query', 'normalized', $values );
+ }
+ $values = $pageSet->getConvertedTitlesAsResult( $result );
+ if ( $values ) {
+ $fit = $fit && $result->addValue( 'query', 'converted', $values );
+ }
+ $values = $pageSet->getInterwikiTitlesAsResult( $result, $this->mParams['iwurl'] );
+ if ( $values ) {
+ $fit = $fit && $result->addValue( 'query', 'interwiki', $values );
+ }
+ $values = $pageSet->getRedirectTitlesAsResult( $result );
+ if ( $values ) {
+ $fit = $fit && $result->addValue( 'query', 'redirects', $values );
+ }
+ $values = $pageSet->getMissingRevisionIDsAsResult( $result );
+ if ( $values ) {
+ $fit = $fit && $result->addValue( 'query', 'badrevids', $values );
+ }
+
+ // Page elements
+ $pages = [];
+
+ // Report any missing titles
+ foreach ( $pageSet->getMissingTitles() as $fakeId => $title ) {
+ $vals = [];
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ $vals['missing'] = true;
+ if ( $title->isKnown() ) {
+ $vals['known'] = true;
+ }
+ $pages[$fakeId] = $vals;
+ }
+ // Report any invalid titles
+ foreach ( $pageSet->getInvalidTitlesAndReasons() as $fakeId => $data ) {
+ $pages[$fakeId] = $data + [ 'invalid' => true ];
+ }
+ // Report any missing page ids
+ foreach ( $pageSet->getMissingPageIDs() as $pageid ) {
+ $pages[$pageid] = [
+ 'pageid' => $pageid,
+ 'missing' => true,
+ ];
+ }
+ // Report special pages
+ /** @var Title $title */
+ foreach ( $pageSet->getSpecialTitles() as $fakeId => $title ) {
+ $vals = [];
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ $vals['special'] = true;
+ if ( !$title->isKnown() ) {
+ $vals['missing'] = true;
+ }
+ $pages[$fakeId] = $vals;
+ }
+
+ // Output general page information for found titles
+ foreach ( $pageSet->getGoodTitles() as $pageid => $title ) {
+ $vals = [];
+ $vals['pageid'] = $pageid;
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ $pages[$pageid] = $vals;
+ }
+
+ if ( count( $pages ) ) {
+ $pageSet->populateGeneratorData( $pages );
+ ApiResult::setArrayType( $pages, 'BCarray' );
+
+ if ( $this->mParams['indexpageids'] ) {
+ $pageIDs = array_keys( ApiResult::stripMetadataNonRecursive( $pages ) );
+ // json treats all map keys as strings - converting to match
+ $pageIDs = array_map( 'strval', $pageIDs );
+ ApiResult::setIndexedTagName( $pageIDs, 'id' );
+ $fit = $fit && $result->addValue( 'query', 'pageids', $pageIDs );
+ }
+
+ ApiResult::setIndexedTagName( $pages, 'page' );
+ $fit = $fit && $result->addValue( 'query', 'pages', $pages );
+ }
+
+ if ( !$fit ) {
+ $this->dieWithError( 'apierror-badconfig-resulttoosmall', 'badconfig' );
+ }
+
+ if ( $this->mParams['export'] ) {
+ $this->doExport( $pageSet, $result );
+ }
+ }
+
+ /**
+ * @param ApiPageSet $pageSet Pages to be exported
+ * @param ApiResult $result Result to output to
+ */
+ private function doExport( $pageSet, $result ) {
+ $exportTitles = [];
+ $titles = $pageSet->getGoodTitles();
+ if ( count( $titles ) ) {
+ $user = $this->getUser();
+ /** @var Title $title */
+ foreach ( $titles as $title ) {
+ if ( $title->userCan( 'read', $user ) ) {
+ $exportTitles[] = $title;
+ }
+ }
+ }
+
+ $exporter = new WikiExporter( $this->getDB() );
+ $sink = new DumpStringOutput;
+ $exporter->setOutputSink( $sink );
+ $exporter->openStream();
+ foreach ( $exportTitles as $title ) {
+ $exporter->pageByTitle( $title );
+ }
+ $exporter->closeStream();
+
+ // Don't check the size of exported stuff
+ // It's not continuable, so it would cause more
+ // problems than it'd solve
+ if ( $this->mParams['exportnowrap'] ) {
+ $result->reset();
+ // Raw formatter will handle this
+ $result->addValue( null, 'text', $sink, ApiResult::NO_SIZE_CHECK );
+ $result->addValue( null, 'mime', 'text/xml', ApiResult::NO_SIZE_CHECK );
+ $result->addValue( null, 'filename', 'export.xml', ApiResult::NO_SIZE_CHECK );
+ } else {
+ $result->addValue( 'query', 'export', $sink, ApiResult::NO_SIZE_CHECK );
+ $result->addValue( 'query', ApiResult::META_BC_SUBELEMENTS, [ 'export' ] );
+ }
+ }
+
+ public function getAllowedParams( $flags = 0 ) {
+ $result = [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'submodule',
+ ],
+ 'list' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'submodule',
+ ],
+ 'meta' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'submodule',
+ ],
+ 'indexpageids' => false,
+ 'export' => false,
+ 'exportnowrap' => false,
+ 'iwurl' => false,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'rawcontinue' => false,
+ ];
+ if ( $flags ) {
+ $result += $this->getPageSet()->getFinalParams( $flags );
+ }
+
+ return $result;
+ }
+
+ public function isReadMode() {
+ // We need to make an exception for certain meta modules that should be
+ // accessible even without the 'read' right. Restrict the exception as
+ // much as possible: no other modules allowed, and no pageset
+ // parameters either. We do allow the 'rawcontinue' and 'indexpageids'
+ // parameters since frameworks might add these unconditionally and they
+ // can't expose anything here.
+ $this->mParams = $this->extractRequestParams();
+ $params = array_filter(
+ array_diff_key(
+ $this->mParams + $this->getPageSet()->extractRequestParams(),
+ [ 'rawcontinue' => 1, 'indexpageids' => 1 ]
+ )
+ );
+ if ( array_keys( $params ) !== [ 'meta' ] ) {
+ return true;
+ }
+
+ // Ask each module if it requires read mode. Any true => this returns
+ // true.
+ $modules = [];
+ $this->instantiateModules( $modules, 'meta' );
+ foreach ( $modules as $module ) {
+ if ( $module->isReadMode() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=revisions&meta=siteinfo&' .
+ 'titles=Main%20Page&rvprop=user|comment&continue='
+ => 'apihelp-query-example-revisions',
+ 'action=query&generator=allpages&gapprefix=API/&prop=revisions&continue='
+ => 'apihelp-query-example-allpages',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return [
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Query',
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Meta',
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Properties',
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Lists',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAllCategories.php b/www/wiki/includes/api/ApiQueryAllCategories.php
new file mode 100644
index 00000000..aa89158f
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAllCategories.php
@@ -0,0 +1,205 @@
+<?php
+/**
+ *
+ *
+ * Created on December 12, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate all categories, even the ones that don't have
+ * category pages.
+ *
+ * @ingroup API
+ */
+class ApiQueryAllCategories extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ac' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ */
+ private function run( $resultPageSet = null ) {
+ $db = $this->getDB();
+ $params = $this->extractRequestParams();
+
+ $this->addTables( 'category' );
+ $this->addFields( 'cat_title' );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 1 );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $cont_from = $db->addQuotes( $cont[0] );
+ $this->addWhere( "cat_title $op= $cont_from" );
+ }
+
+ $dir = ( $params['dir'] == 'descending' ? 'older' : 'newer' );
+ $from = ( $params['from'] === null
+ ? null
+ : $this->titlePartToKey( $params['from'], NS_CATEGORY ) );
+ $to = ( $params['to'] === null
+ ? null
+ : $this->titlePartToKey( $params['to'], NS_CATEGORY ) );
+ $this->addWhereRange( 'cat_title', $dir, $from, $to );
+
+ $min = $params['min'];
+ $max = $params['max'];
+ if ( $dir == 'newer' ) {
+ $this->addWhereRange( 'cat_pages', 'newer', $min, $max );
+ } else {
+ $this->addWhereRange( 'cat_pages', 'older', $max, $min );
+ }
+
+ if ( isset( $params['prefix'] ) ) {
+ $this->addWhere( 'cat_title' . $db->buildLike(
+ $this->titlePartToKey( $params['prefix'], NS_CATEGORY ),
+ $db->anyString() ) );
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ $this->addOption( 'ORDER BY', 'cat_title' . $sort );
+
+ $prop = array_flip( $params['prop'] );
+ $this->addFieldsIf( [ 'cat_pages', 'cat_subcats', 'cat_files' ], isset( $prop['size'] ) );
+ if ( isset( $prop['hidden'] ) ) {
+ $this->addTables( [ 'page', 'page_props' ] );
+ $this->addJoinConds( [
+ 'page' => [ 'LEFT JOIN', [
+ 'page_namespace' => NS_CATEGORY,
+ 'page_title=cat_title' ] ],
+ 'page_props' => [ 'LEFT JOIN', [
+ 'pp_page=page_id',
+ 'pp_propname' => 'hiddencat' ] ],
+ ] );
+ $this->addFields( [ 'cat_hidden' => 'pp_propname' ] );
+ }
+
+ $res = $this->select( __METHOD__ );
+
+ $pages = [];
+
+ $result = $this->getResult();
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional cats to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->cat_title );
+ break;
+ }
+
+ // Normalize titles
+ $titleObj = Title::makeTitle( NS_CATEGORY, $row->cat_title );
+ if ( !is_null( $resultPageSet ) ) {
+ $pages[] = $titleObj;
+ } else {
+ $item = [];
+ ApiResult::setContentValue( $item, 'category', $titleObj->getText() );
+ if ( isset( $prop['size'] ) ) {
+ $item['size'] = intval( $row->cat_pages );
+ $item['pages'] = $row->cat_pages - $row->cat_subcats - $row->cat_files;
+ $item['files'] = intval( $row->cat_files );
+ $item['subcats'] = intval( $row->cat_subcats );
+ }
+ if ( isset( $prop['hidden'] ) ) {
+ $item['hidden'] = (bool)$row->cat_hidden;
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $item );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->cat_title );
+ break;
+ }
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'c' );
+ } else {
+ $resultPageSet->populateFromTitles( $pages );
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'from' => null,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'to' => null,
+ 'prefix' => null,
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ],
+ ],
+ 'min' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'max' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'prop' => [
+ ApiBase::PARAM_TYPE => [ 'size', 'hidden' ],
+ ApiBase::PARAM_DFLT => '',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=allcategories&acprop=size'
+ => 'apihelp-query+allcategories-example-size',
+ 'action=query&generator=allcategories&gacprefix=List&prop=info'
+ => 'apihelp-query+allcategories-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Allcategories';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAllDeletedRevisions.php b/www/wiki/includes/api/ApiQueryAllDeletedRevisions.php
new file mode 100644
index 00000000..b22bb1ff
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAllDeletedRevisions.php
@@ -0,0 +1,460 @@
+<?php
+/**
+ * Created on Oct 3, 2014
+ *
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * Heavily based on ApiQueryDeletedrevs,
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate all deleted revisions.
+ *
+ * @ingroup API
+ */
+class ApiQueryAllDeletedRevisions extends ApiQueryRevisionsBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'adr' );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ protected function run( ApiPageSet $resultPageSet = null ) {
+ // Before doing anything at all, let's check permissions
+ $this->checkUserRightsAny( 'deletedhistory' );
+
+ $user = $this->getUser();
+ $db = $this->getDB();
+ $params = $this->extractRequestParams( false );
+
+ $result = $this->getResult();
+
+ // If the user wants no namespaces, they get no pages.
+ if ( $params['namespace'] === [] ) {
+ if ( $resultPageSet === null ) {
+ $result->addValue( 'query', $this->getModuleName(), [] );
+ }
+ return;
+ }
+
+ // This module operates in two modes:
+ // 'user': List deleted revs by a certain user
+ // 'all': List all deleted revs in NS
+ $mode = 'all';
+ if ( !is_null( $params['user'] ) ) {
+ $mode = 'user';
+ }
+
+ if ( $mode == 'user' ) {
+ foreach ( [ 'from', 'to', 'prefix', 'excludeuser' ] as $param ) {
+ if ( !is_null( $params[$param] ) ) {
+ $p = $this->getModulePrefix();
+ $this->dieWithError(
+ [ 'apierror-invalidparammix-cannotusewith', $p.$param, "{$p}user" ],
+ 'invalidparammix'
+ );
+ }
+ }
+ } else {
+ foreach ( [ 'start', 'end' ] as $param ) {
+ if ( !is_null( $params[$param] ) ) {
+ $p = $this->getModulePrefix();
+ $this->dieWithError(
+ [ 'apierror-invalidparammix-mustusewith', $p.$param, "{$p}user" ],
+ 'invalidparammix'
+ );
+ }
+ }
+ }
+
+ // If we're generating titles only, we can use DISTINCT for a better
+ // query. But we can't do that in 'user' mode (wrong index), and we can
+ // only do it when sorting ASC (because MySQL apparently can't use an
+ // index backwards for grouping even though it can for ORDER BY, WTF?)
+ $dir = $params['dir'];
+ $optimizeGenerateTitles = false;
+ if ( $mode === 'all' && $params['generatetitles'] && $resultPageSet !== null ) {
+ if ( $dir === 'newer' ) {
+ $optimizeGenerateTitles = true;
+ } else {
+ $p = $this->getModulePrefix();
+ $this->addWarning( [ 'apiwarn-alldeletedrevisions-performance', $p ], 'performance' );
+ }
+ }
+
+ $this->addTables( 'archive' );
+ if ( $resultPageSet === null ) {
+ $this->parseParameters( $params );
+ $this->addFields( Revision::selectArchiveFields() );
+ $this->addFields( [ 'ar_title', 'ar_namespace' ] );
+ } else {
+ $this->limit = $this->getParameter( 'limit' ) ?: 10;
+ $this->addFields( [ 'ar_title', 'ar_namespace' ] );
+ if ( $optimizeGenerateTitles ) {
+ $this->addOption( 'DISTINCT' );
+ } else {
+ $this->addFields( [ 'ar_timestamp', 'ar_rev_id', 'ar_id' ] );
+ }
+ }
+
+ if ( $this->fld_tags ) {
+ $this->addTables( 'tag_summary' );
+ $this->addJoinConds(
+ [ 'tag_summary' => [ 'LEFT JOIN', [ 'ar_rev_id=ts_rev_id' ] ] ]
+ );
+ $this->addFields( 'ts_tags' );
+ }
+
+ if ( !is_null( $params['tag'] ) ) {
+ $this->addTables( 'change_tag' );
+ $this->addJoinConds(
+ [ 'change_tag' => [ 'INNER JOIN', [ 'ar_rev_id=ct_rev_id' ] ] ]
+ );
+ $this->addWhereFld( 'ct_tag', $params['tag'] );
+ }
+
+ if ( $this->fetchContent ) {
+ // Modern MediaWiki has the content for deleted revs in the 'text'
+ // table using fields old_text and old_flags. But revisions deleted
+ // pre-1.5 store the content in the 'archive' table directly using
+ // fields ar_text and ar_flags, and no corresponding 'text' row. So
+ // we have to LEFT JOIN and fetch all four fields.
+ $this->addTables( 'text' );
+ $this->addJoinConds(
+ [ 'text' => [ 'LEFT JOIN', [ 'ar_text_id=old_id' ] ] ]
+ );
+ $this->addFields( [ 'ar_text', 'ar_flags', 'old_text', 'old_flags' ] );
+
+ // This also means stricter restrictions
+ $this->checkUserRightsAny( [ 'deletedtext', 'undelete' ] );
+ }
+
+ $miser_ns = null;
+
+ if ( $mode == 'all' ) {
+ if ( $params['namespace'] !== null ) {
+ $namespaces = $params['namespace'];
+ } else {
+ $namespaces = MWNamespace::getValidNamespaces();
+ }
+ $this->addWhereFld( 'ar_namespace', $namespaces );
+
+ // For from/to/prefix, we have to consider the potential
+ // transformations of the title in all specified namespaces.
+ // Generally there will be only one transformation, but wikis with
+ // some namespaces case-sensitive could have two.
+ if ( $params['from'] !== null || $params['to'] !== null ) {
+ $isDirNewer = ( $dir === 'newer' );
+ $after = ( $isDirNewer ? '>=' : '<=' );
+ $before = ( $isDirNewer ? '<=' : '>=' );
+ $where = [];
+ foreach ( $namespaces as $ns ) {
+ $w = [];
+ if ( $params['from'] !== null ) {
+ $w[] = 'ar_title' . $after .
+ $db->addQuotes( $this->titlePartToKey( $params['from'], $ns ) );
+ }
+ if ( $params['to'] !== null ) {
+ $w[] = 'ar_title' . $before .
+ $db->addQuotes( $this->titlePartToKey( $params['to'], $ns ) );
+ }
+ $w = $db->makeList( $w, LIST_AND );
+ $where[$w][] = $ns;
+ }
+ if ( count( $where ) == 1 ) {
+ $where = key( $where );
+ $this->addWhere( $where );
+ } else {
+ $where2 = [];
+ foreach ( $where as $w => $ns ) {
+ $where2[] = $db->makeList( [ $w, 'ar_namespace' => $ns ], LIST_AND );
+ }
+ $this->addWhere( $db->makeList( $where2, LIST_OR ) );
+ }
+ }
+
+ if ( isset( $params['prefix'] ) ) {
+ $where = [];
+ foreach ( $namespaces as $ns ) {
+ $w = 'ar_title' . $db->buildLike(
+ $this->titlePartToKey( $params['prefix'], $ns ),
+ $db->anyString() );
+ $where[$w][] = $ns;
+ }
+ if ( count( $where ) == 1 ) {
+ $where = key( $where );
+ $this->addWhere( $where );
+ } else {
+ $where2 = [];
+ foreach ( $where as $w => $ns ) {
+ $where2[] = $db->makeList( [ $w, 'ar_namespace' => $ns ], LIST_AND );
+ }
+ $this->addWhere( $db->makeList( $where2, LIST_OR ) );
+ }
+ }
+ } else {
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $miser_ns = $params['namespace'];
+ } else {
+ $this->addWhereFld( 'ar_namespace', $params['namespace'] );
+ }
+ $this->addTimestampWhereRange( 'ar_timestamp', $dir, $params['start'], $params['end'] );
+ }
+
+ if ( !is_null( $params['user'] ) ) {
+ $this->addWhereFld( 'ar_user_text', $params['user'] );
+ } elseif ( !is_null( $params['excludeuser'] ) ) {
+ $this->addWhere( 'ar_user_text != ' .
+ $db->addQuotes( $params['excludeuser'] ) );
+ }
+
+ if ( !is_null( $params['user'] ) || !is_null( $params['excludeuser'] ) ) {
+ // Paranoia: avoid brute force searches (T19342)
+ // (shouldn't be able to get here without 'deletedhistory', but
+ // check it again just in case)
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $db->bitAnd( 'ar_deleted', $bitmask ) . " != $bitmask" );
+ }
+ }
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $op = ( $dir == 'newer' ? '>' : '<' );
+ if ( $optimizeGenerateTitles ) {
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $ns = intval( $cont[0] );
+ $this->dieContinueUsageIf( strval( $ns ) !== $cont[0] );
+ $title = $db->addQuotes( $cont[1] );
+ $this->addWhere( "ar_namespace $op $ns OR " .
+ "(ar_namespace = $ns AND ar_title $op= $title)" );
+ } elseif ( $mode == 'all' ) {
+ $this->dieContinueUsageIf( count( $cont ) != 4 );
+ $ns = intval( $cont[0] );
+ $this->dieContinueUsageIf( strval( $ns ) !== $cont[0] );
+ $title = $db->addQuotes( $cont[1] );
+ $ts = $db->addQuotes( $db->timestamp( $cont[2] ) );
+ $ar_id = (int)$cont[3];
+ $this->dieContinueUsageIf( strval( $ar_id ) !== $cont[3] );
+ $this->addWhere( "ar_namespace $op $ns OR " .
+ "(ar_namespace = $ns AND " .
+ "(ar_title $op $title OR " .
+ "(ar_title = $title AND " .
+ "(ar_timestamp $op $ts OR " .
+ "(ar_timestamp = $ts AND " .
+ "ar_id $op= $ar_id)))))" );
+ } else {
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $ts = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $ar_id = (int)$cont[1];
+ $this->dieContinueUsageIf( strval( $ar_id ) !== $cont[1] );
+ $this->addWhere( "ar_timestamp $op $ts OR " .
+ "(ar_timestamp = $ts AND " .
+ "ar_id $op= $ar_id)" );
+ }
+ }
+
+ $this->addOption( 'LIMIT', $this->limit + 1 );
+
+ $sort = ( $dir == 'newer' ? '' : ' DESC' );
+ $orderby = [];
+ if ( $optimizeGenerateTitles ) {
+ // Targeting index name_title_timestamp
+ if ( $params['namespace'] === null || count( array_unique( $params['namespace'] ) ) > 1 ) {
+ $orderby[] = "ar_namespace $sort";
+ }
+ $orderby[] = "ar_title $sort";
+ } elseif ( $mode == 'all' ) {
+ // Targeting index name_title_timestamp
+ if ( $params['namespace'] === null || count( array_unique( $params['namespace'] ) ) > 1 ) {
+ $orderby[] = "ar_namespace $sort";
+ }
+ $orderby[] = "ar_title $sort";
+ $orderby[] = "ar_timestamp $sort";
+ $orderby[] = "ar_id $sort";
+ } else {
+ // Targeting index usertext_timestamp
+ // 'user' is always constant.
+ $orderby[] = "ar_timestamp $sort";
+ $orderby[] = "ar_id $sort";
+ }
+ $this->addOption( 'ORDER BY', $orderby );
+
+ $res = $this->select( __METHOD__ );
+ $pageMap = []; // Maps ns&title to array index
+ $count = 0;
+ $nextIndex = 0;
+ $generated = [];
+ foreach ( $res as $row ) {
+ if ( ++$count > $this->limit ) {
+ // We've had enough
+ if ( $optimizeGenerateTitles ) {
+ $this->setContinueEnumParameter( 'continue', "$row->ar_namespace|$row->ar_title" );
+ } elseif ( $mode == 'all' ) {
+ $this->setContinueEnumParameter( 'continue',
+ "$row->ar_namespace|$row->ar_title|$row->ar_timestamp|$row->ar_id"
+ );
+ } else {
+ $this->setContinueEnumParameter( 'continue', "$row->ar_timestamp|$row->ar_id" );
+ }
+ break;
+ }
+
+ // Miser mode namespace check
+ if ( $miser_ns !== null && !in_array( $row->ar_namespace, $miser_ns ) ) {
+ continue;
+ }
+
+ if ( $resultPageSet !== null ) {
+ if ( $params['generatetitles'] ) {
+ $key = "{$row->ar_namespace}:{$row->ar_title}";
+ if ( !isset( $generated[$key] ) ) {
+ $generated[$key] = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+ }
+ } else {
+ $generated[] = $row->ar_rev_id;
+ }
+ } else {
+ $revision = Revision::newFromArchiveRow( $row );
+ $rev = $this->extractRevisionInfo( $revision, $row );
+
+ if ( !isset( $pageMap[$row->ar_namespace][$row->ar_title] ) ) {
+ $index = $nextIndex++;
+ $pageMap[$row->ar_namespace][$row->ar_title] = $index;
+ $title = $revision->getTitle();
+ $a = [
+ 'pageid' => $title->getArticleID(),
+ 'revisions' => [ $rev ],
+ ];
+ ApiResult::setIndexedTagName( $a['revisions'], 'rev' );
+ ApiQueryBase::addTitleInfo( $a, $title );
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], $index, $a );
+ } else {
+ $index = $pageMap[$row->ar_namespace][$row->ar_title];
+ $fit = $result->addValue(
+ [ 'query', $this->getModuleName(), $index, 'revisions' ],
+ null, $rev );
+ }
+ if ( !$fit ) {
+ if ( $mode == 'all' ) {
+ $this->setContinueEnumParameter( 'continue',
+ "$row->ar_namespace|$row->ar_title|$row->ar_timestamp|$row->ar_id"
+ );
+ } else {
+ $this->setContinueEnumParameter( 'continue', "$row->ar_timestamp|$row->ar_id" );
+ }
+ break;
+ }
+ }
+ }
+
+ if ( $resultPageSet !== null ) {
+ if ( $params['generatetitles'] ) {
+ $resultPageSet->populateFromTitles( $generated );
+ } else {
+ $resultPageSet->populateFromRevisionIDs( $generated );
+ }
+ } else {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'page' );
+ }
+ }
+
+ public function getAllowedParams() {
+ $ret = parent::getAllowedParams() + [
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'useronly' ] ],
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'useronly' ] ],
+ ],
+ 'dir' => [
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'from' => [
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'nonuseronly' ] ],
+ ],
+ 'to' => [
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'nonuseronly' ] ],
+ ],
+ 'prefix' => [
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'nonuseronly' ] ],
+ ],
+ 'excludeuser' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'nonuseronly' ] ],
+ ],
+ 'tag' => null,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'generatetitles' => [
+ ApiBase::PARAM_DFLT => false
+ ],
+ ];
+
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $ret['user'][ApiBase::PARAM_HELP_MSG_APPEND] = [
+ 'apihelp-query+alldeletedrevisions-param-miser-user-namespace',
+ ];
+ $ret['namespace'][ApiBase::PARAM_HELP_MSG_APPEND] = [
+ 'apihelp-query+alldeletedrevisions-param-miser-user-namespace',
+ ];
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=alldeletedrevisions&adruser=Example&adrlimit=50'
+ => 'apihelp-query+alldeletedrevisions-example-user',
+ 'action=query&list=alldeletedrevisions&adrdir=newer&adrnamespace=0&adrlimit=50'
+ => 'apihelp-query+alldeletedrevisions-example-ns-main',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Alldeletedrevisions';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAllImages.php b/www/wiki/includes/api/ApiQueryAllImages.php
new file mode 100644
index 00000000..250bee66
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAllImages.php
@@ -0,0 +1,431 @@
+<?php
+
+/**
+ * API for MediaWiki 1.12+
+ *
+ * Created on Mar 16, 2008
+ *
+ * Copyright © 2008 Vasiliev Victor vasilvv@gmail.com,
+ * based on ApiQueryAllPages.php
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Query module to enumerate all available pages.
+ *
+ * @ingroup API
+ */
+class ApiQueryAllImages extends ApiQueryGeneratorBase {
+ protected $mRepo;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ai' );
+ $this->mRepo = RepoGroup::singleton()->getLocalRepo();
+ }
+
+ /**
+ * Override parent method to make sure the repo's DB is used
+ * which may not necessarily be the same as the local DB.
+ *
+ * TODO: allow querying non-local repos.
+ * @return IDatabase
+ */
+ protected function getDB() {
+ return $this->mRepo->getReplicaDB();
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ public function executeGenerator( $resultPageSet ) {
+ if ( $resultPageSet->isResolvingRedirects() ) {
+ $this->dieWithError( 'apierror-allimages-redirect', 'invalidparammix' );
+ }
+
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $repo = $this->mRepo;
+ if ( !$repo instanceof LocalRepo ) {
+ $this->dieWithError( 'apierror-unsupportedrepo' );
+ }
+
+ $prefix = $this->getModulePrefix();
+
+ $db = $this->getDB();
+
+ $params = $this->extractRequestParams();
+ $userId = !is_null( $params['user'] ) ? User::idFromName( $params['user'] ) : null;
+
+ // Table and return fields
+ $this->addTables( 'image' );
+
+ $prop = array_flip( $params['prop'] );
+ $this->addFields( LocalFile::selectFields() );
+
+ $ascendingOrder = true;
+ if ( $params['dir'] == 'descending' || $params['dir'] == 'older' ) {
+ $ascendingOrder = false;
+ }
+
+ if ( $params['sort'] == 'name' ) {
+ // Check mutually exclusive params
+ $disallowed = [ 'start', 'end', 'user' ];
+ foreach ( $disallowed as $pname ) {
+ if ( isset( $params[$pname] ) ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-mustusewith',
+ "{$prefix}{$pname}",
+ "{$prefix}sort=timestamp"
+ ],
+ 'invalidparammix'
+ );
+ }
+ }
+ if ( $params['filterbots'] != 'all' ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-mustusewith',
+ "{$prefix}filterbots",
+ "{$prefix}sort=timestamp"
+ ],
+ 'invalidparammix'
+ );
+ }
+
+ // Pagination
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 1 );
+ $op = ( $ascendingOrder ? '>' : '<' );
+ $continueFrom = $db->addQuotes( $cont[0] );
+ $this->addWhere( "img_name $op= $continueFrom" );
+ }
+
+ // Image filters
+ $from = ( $params['from'] === null ? null : $this->titlePartToKey( $params['from'], NS_FILE ) );
+ $to = ( $params['to'] === null ? null : $this->titlePartToKey( $params['to'], NS_FILE ) );
+ $this->addWhereRange( 'img_name', ( $ascendingOrder ? 'newer' : 'older' ), $from, $to );
+
+ if ( isset( $params['prefix'] ) ) {
+ $this->addWhere( 'img_name' . $db->buildLike(
+ $this->titlePartToKey( $params['prefix'], NS_FILE ),
+ $db->anyString() ) );
+ }
+ } else {
+ // Check mutually exclusive params
+ $disallowed = [ 'from', 'to', 'prefix' ];
+ foreach ( $disallowed as $pname ) {
+ if ( isset( $params[$pname] ) ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-mustusewith',
+ "{$prefix}{$pname}",
+ "{$prefix}sort=name"
+ ],
+ 'invalidparammix'
+ );
+ }
+ }
+ if ( !is_null( $params['user'] ) && $params['filterbots'] != 'all' ) {
+ // Since filterbots checks if each user has the bot right, it
+ // doesn't make sense to use it with user
+ $this->dieWithError(
+ [ 'apierror-invalidparammix-cannotusewith', "{$prefix}user", "{$prefix}filterbots" ]
+ );
+ }
+
+ // Pagination
+ $this->addTimestampWhereRange(
+ 'img_timestamp',
+ $ascendingOrder ? 'newer' : 'older',
+ $params['start'],
+ $params['end']
+ );
+ // Include in ORDER BY for uniqueness
+ $this->addWhereRange( 'img_name', $ascendingOrder ? 'newer' : 'older', null, null );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $op = ( $ascendingOrder ? '>' : '<' );
+ $continueTimestamp = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $continueName = $db->addQuotes( $cont[1] );
+ $this->addWhere( "img_timestamp $op $continueTimestamp OR " .
+ "(img_timestamp = $continueTimestamp AND " .
+ "img_name $op= $continueName)"
+ );
+ }
+
+ // Image filters
+ if ( !is_null( $params['user'] ) ) {
+ if ( $userId ) {
+ $this->addWhereFld( 'img_user', $userId );
+ } else {
+ $this->addWhereFld( 'img_user_text', $params['user'] );
+ }
+ }
+ if ( $params['filterbots'] != 'all' ) {
+ $this->addTables( 'user_groups' );
+ $this->addJoinConds( [ 'user_groups' => [
+ 'LEFT JOIN',
+ [
+ 'ug_group' => User::getGroupsWithPermission( 'bot' ),
+ 'ug_user = img_user',
+ 'ug_expiry IS NULL OR ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
+ ]
+ ] ] );
+ $groupCond = ( $params['filterbots'] == 'nobots' ? 'NULL' : 'NOT NULL' );
+ $this->addWhere( "ug_group IS $groupCond" );
+ }
+ }
+
+ // Filters not depending on sort
+ if ( isset( $params['minsize'] ) ) {
+ $this->addWhere( 'img_size>=' . intval( $params['minsize'] ) );
+ }
+
+ if ( isset( $params['maxsize'] ) ) {
+ $this->addWhere( 'img_size<=' . intval( $params['maxsize'] ) );
+ }
+
+ $sha1 = false;
+ if ( isset( $params['sha1'] ) ) {
+ $sha1 = strtolower( $params['sha1'] );
+ if ( !$this->validateSha1Hash( $sha1 ) ) {
+ $this->dieWithError( 'apierror-invalidsha1hash' );
+ }
+ $sha1 = Wikimedia\base_convert( $sha1, 16, 36, 31 );
+ } elseif ( isset( $params['sha1base36'] ) ) {
+ $sha1 = strtolower( $params['sha1base36'] );
+ if ( !$this->validateSha1Base36Hash( $sha1 ) ) {
+ $this->dieWithError( 'apierror-invalidsha1base36hash' );
+ }
+ }
+ if ( $sha1 ) {
+ $this->addWhereFld( 'img_sha1', $sha1 );
+ }
+
+ if ( !is_null( $params['mime'] ) ) {
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $this->dieWithError( 'apierror-mimesearchdisabled' );
+ }
+
+ $mimeConds = [];
+ foreach ( $params['mime'] as $mime ) {
+ list( $major, $minor ) = File::splitMime( $mime );
+ $mimeConds[] = $db->makeList(
+ [
+ 'img_major_mime' => $major,
+ 'img_minor_mime' => $minor,
+ ],
+ LIST_AND
+ );
+ }
+ // safeguard against internal_api_error_DBQueryError
+ if ( count( $mimeConds ) > 0 ) {
+ $this->addWhere( $db->makeList( $mimeConds, LIST_OR ) );
+ } else {
+ // no MIME types, no files
+ $this->getResult()->addValue( 'query', $this->getModuleName(), [] );
+ return;
+ }
+ }
+
+ $limit = $params['limit'];
+ $this->addOption( 'LIMIT', $limit + 1 );
+ $sortFlag = '';
+ if ( !$ascendingOrder ) {
+ $sortFlag = ' DESC';
+ }
+ if ( $params['sort'] == 'timestamp' ) {
+ $this->addOption( 'ORDER BY', 'img_timestamp' . $sortFlag );
+ if ( !is_null( $params['user'] ) ) {
+ if ( $userId ) {
+ $this->addOption( 'USE INDEX', [ 'image' => 'img_user_timestamp' ] );
+ } else {
+ $this->addOption( 'USE INDEX', [ 'image' => 'img_usertext_timestamp' ] );
+ }
+ } else {
+ $this->addOption( 'USE INDEX', [ 'image' => 'img_timestamp' ] );
+ }
+ } else {
+ $this->addOption( 'ORDER BY', 'img_name' . $sortFlag );
+ }
+
+ $res = $this->select( __METHOD__ );
+
+ $titles = [];
+ $count = 0;
+ $result = $this->getResult();
+ foreach ( $res as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ if ( $params['sort'] == 'name' ) {
+ $this->setContinueEnumParameter( 'continue', $row->img_name );
+ } else {
+ $this->setContinueEnumParameter( 'continue', "$row->img_timestamp|$row->img_name" );
+ }
+ break;
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $file = $repo->newFileFromRow( $row );
+ $info = array_merge( [ 'name' => $row->img_name ],
+ ApiQueryImageInfo::getInfo( $file, $prop, $result ) );
+ self::addTitleInfo( $info, $file->getTitle() );
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $info );
+ if ( !$fit ) {
+ if ( $params['sort'] == 'name' ) {
+ $this->setContinueEnumParameter( 'continue', $row->img_name );
+ } else {
+ $this->setContinueEnumParameter( 'continue', "$row->img_timestamp|$row->img_name" );
+ }
+ break;
+ }
+ } else {
+ $titles[] = Title::makeTitle( NS_FILE, $row->img_name );
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'img' );
+ } else {
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ public function getAllowedParams() {
+ $ret = [
+ 'sort' => [
+ ApiBase::PARAM_DFLT => 'name',
+ ApiBase::PARAM_TYPE => [
+ 'name',
+ 'timestamp'
+ ]
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ // sort=name
+ 'ascending',
+ 'descending',
+ // sort=timestamp
+ 'newer',
+ 'older'
+ ]
+ ],
+ 'from' => null,
+ 'to' => null,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'prop' => [
+ ApiBase::PARAM_TYPE => ApiQueryImageInfo::getPropertyNames( $this->propertyFilter ),
+ ApiBase::PARAM_DFLT => 'timestamp|url',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+imageinfo-param-prop',
+ ApiBase::PARAM_HELP_MSG_PER_VALUE =>
+ ApiQueryImageInfo::getPropertyMessages( $this->propertyFilter ),
+ ],
+ 'prefix' => null,
+ 'minsize' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'maxsize' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'sha1' => null,
+ 'sha1base36' => null,
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'filterbots' => [
+ ApiBase::PARAM_DFLT => 'all',
+ ApiBase::PARAM_TYPE => [
+ 'all',
+ 'bots',
+ 'nobots'
+ ]
+ ],
+ 'mime' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ ];
+
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $ret['mime'][ApiBase::PARAM_HELP_MSG] = 'api-help-param-disabled-in-miser-mode';
+ }
+
+ return $ret;
+ }
+
+ private $propertyFilter = [ 'archivename', 'thumbmime', 'uploadwarning' ];
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=allimages&aifrom=B'
+ => 'apihelp-query+allimages-example-B',
+ 'action=query&list=allimages&aiprop=user|timestamp|url&' .
+ 'aisort=timestamp&aidir=older'
+ => 'apihelp-query+allimages-example-recent',
+ 'action=query&list=allimages&aimime=image/png|image/gif'
+ => 'apihelp-query+allimages-example-mimetypes',
+ 'action=query&generator=allimages&gailimit=4&' .
+ 'gaifrom=T&prop=imageinfo'
+ => 'apihelp-query+allimages-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Allimages';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAllLinks.php b/www/wiki/includes/api/ApiQueryAllLinks.php
new file mode 100644
index 00000000..9d6bf463
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAllLinks.php
@@ -0,0 +1,313 @@
+<?php
+/**
+ *
+ *
+ * Created on July 7, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate links from all pages together.
+ *
+ * @ingroup API
+ */
+class ApiQueryAllLinks extends ApiQueryGeneratorBase {
+
+ private $table, $tablePrefix, $indexTag;
+ private $fieldTitle = 'title';
+ private $dfltNamespace = NS_MAIN;
+ private $hasNamespace = true;
+ private $useIndex = null;
+ private $props = [];
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ switch ( $moduleName ) {
+ case 'alllinks':
+ $prefix = 'al';
+ $this->table = 'pagelinks';
+ $this->tablePrefix = 'pl_';
+ $this->useIndex = 'pl_namespace';
+ $this->indexTag = 'l';
+ break;
+ case 'alltransclusions':
+ $prefix = 'at';
+ $this->table = 'templatelinks';
+ $this->tablePrefix = 'tl_';
+ $this->dfltNamespace = NS_TEMPLATE;
+ $this->useIndex = 'tl_namespace';
+ $this->indexTag = 't';
+ break;
+ case 'allfileusages':
+ $prefix = 'af';
+ $this->table = 'imagelinks';
+ $this->tablePrefix = 'il_';
+ $this->fieldTitle = 'to';
+ $this->dfltNamespace = NS_FILE;
+ $this->hasNamespace = false;
+ $this->indexTag = 'f';
+ break;
+ case 'allredirects':
+ $prefix = 'ar';
+ $this->table = 'redirect';
+ $this->tablePrefix = 'rd_';
+ $this->indexTag = 'r';
+ $this->props = [
+ 'fragment' => 'rd_fragment',
+ 'interwiki' => 'rd_interwiki',
+ ];
+ break;
+ default:
+ ApiBase::dieDebug( __METHOD__, 'Unknown module name' );
+ }
+
+ parent::__construct( $query, $moduleName, $prefix );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $db = $this->getDB();
+ $params = $this->extractRequestParams();
+
+ $pfx = $this->tablePrefix;
+ $fieldTitle = $this->fieldTitle;
+ $prop = array_flip( $params['prop'] );
+ $fld_ids = isset( $prop['ids'] );
+ $fld_title = isset( $prop['title'] );
+ if ( $this->hasNamespace ) {
+ $namespace = $params['namespace'];
+ } else {
+ $namespace = $this->dfltNamespace;
+ }
+
+ if ( $params['unique'] ) {
+ $matches = array_intersect_key( $prop, $this->props + [ 'ids' => 1 ] );
+ if ( $matches ) {
+ $p = $this->getModulePrefix();
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-cannotusewith',
+ "{$p}prop=" . implode( '|', array_keys( $matches ) ),
+ "{$p}unique"
+ ],
+ 'invalidparammix'
+ );
+ }
+ $this->addOption( 'DISTINCT' );
+ }
+
+ $this->addTables( $this->table );
+ if ( $this->hasNamespace ) {
+ $this->addWhereFld( $pfx . 'namespace', $namespace );
+ }
+
+ $continue = !is_null( $params['continue'] );
+ if ( $continue ) {
+ $continueArr = explode( '|', $params['continue'] );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ if ( $params['unique'] ) {
+ $this->dieContinueUsageIf( count( $continueArr ) != 1 );
+ $continueTitle = $db->addQuotes( $continueArr[0] );
+ $this->addWhere( "{$pfx}{$fieldTitle} $op= $continueTitle" );
+ } else {
+ $this->dieContinueUsageIf( count( $continueArr ) != 2 );
+ $continueTitle = $db->addQuotes( $continueArr[0] );
+ $continueFrom = intval( $continueArr[1] );
+ $this->addWhere(
+ "{$pfx}{$fieldTitle} $op $continueTitle OR " .
+ "({$pfx}{$fieldTitle} = $continueTitle AND " .
+ "{$pfx}from $op= $continueFrom)"
+ );
+ }
+ }
+
+ // 'continue' always overrides 'from'
+ $from = ( $continue || $params['from'] === null ? null :
+ $this->titlePartToKey( $params['from'], $namespace ) );
+ $to = ( $params['to'] === null ? null :
+ $this->titlePartToKey( $params['to'], $namespace ) );
+ $this->addWhereRange( $pfx . $fieldTitle, 'newer', $from, $to );
+
+ if ( isset( $params['prefix'] ) ) {
+ $this->addWhere( $pfx . $fieldTitle . $db->buildLike( $this->titlePartToKey(
+ $params['prefix'], $namespace ), $db->anyString() ) );
+ }
+
+ $this->addFields( [ 'pl_title' => $pfx . $fieldTitle ] );
+ $this->addFieldsIf( [ 'pl_from' => $pfx . 'from' ], !$params['unique'] );
+ foreach ( $this->props as $name => $field ) {
+ $this->addFieldsIf( $field, isset( $prop[$name] ) );
+ }
+
+ if ( $this->useIndex ) {
+ $this->addOption( 'USE INDEX', $this->useIndex );
+ }
+ $limit = $params['limit'];
+ $this->addOption( 'LIMIT', $limit + 1 );
+
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ $orderBy = [];
+ $orderBy[] = $pfx . $fieldTitle . $sort;
+ if ( !$params['unique'] ) {
+ $orderBy[] = $pfx . 'from' . $sort;
+ }
+ $this->addOption( 'ORDER BY', $orderBy );
+
+ $res = $this->select( __METHOD__ );
+
+ $pageids = [];
+ $titles = [];
+ $count = 0;
+ $result = $this->getResult();
+ foreach ( $res as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ if ( $params['unique'] ) {
+ $this->setContinueEnumParameter( 'continue', $row->pl_title );
+ } else {
+ $this->setContinueEnumParameter( 'continue', $row->pl_title . '|' . $row->pl_from );
+ }
+ break;
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $vals = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ if ( $fld_ids ) {
+ $vals['fromid'] = intval( $row->pl_from );
+ }
+ if ( $fld_title ) {
+ $title = Title::makeTitle( $namespace, $row->pl_title );
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ }
+ foreach ( $this->props as $name => $field ) {
+ if ( isset( $prop[$name] ) && $row->$field !== null && $row->$field !== '' ) {
+ $vals[$name] = $row->$field;
+ }
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ if ( $params['unique'] ) {
+ $this->setContinueEnumParameter( 'continue', $row->pl_title );
+ } else {
+ $this->setContinueEnumParameter( 'continue', $row->pl_title . '|' . $row->pl_from );
+ }
+ break;
+ }
+ } elseif ( $params['unique'] ) {
+ $titles[] = Title::makeTitle( $namespace, $row->pl_title );
+ } else {
+ $pageids[] = $row->pl_from;
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], $this->indexTag );
+ } elseif ( $params['unique'] ) {
+ $resultPageSet->populateFromTitles( $titles );
+ } else {
+ $resultPageSet->populateFromPageIDs( $pageids );
+ }
+ }
+
+ public function getAllowedParams() {
+ $allowedParams = [
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'from' => null,
+ 'to' => null,
+ 'prefix' => null,
+ 'unique' => false,
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'title',
+ ApiBase::PARAM_TYPE => array_merge(
+ [ 'ids', 'title' ], array_keys( $this->props )
+ ),
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_DFLT => $this->dfltNamespace,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_EXTRA_NAMESPACES => [ NS_MEDIA, NS_SPECIAL ],
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ ];
+ if ( !$this->hasNamespace ) {
+ unset( $allowedParams['namespace'] );
+ }
+
+ return $allowedParams;
+ }
+
+ protected function getExamplesMessages() {
+ $p = $this->getModulePrefix();
+ $name = $this->getModuleName();
+ $path = $this->getModulePath();
+
+ return [
+ "action=query&list={$name}&{$p}from=B&{$p}prop=ids|title"
+ => "apihelp-$path-example-B",
+ "action=query&list={$name}&{$p}unique=&{$p}from=B"
+ => "apihelp-$path-example-unique",
+ "action=query&generator={$name}&g{$p}unique=&g{$p}from=B"
+ => "apihelp-$path-example-unique-generator",
+ "action=query&generator={$name}&g{$p}from=B"
+ => "apihelp-$path-example-generator",
+ ];
+ }
+
+ public function getHelpUrls() {
+ $name = ucfirst( $this->getModuleName() );
+
+ return "https://www.mediawiki.org/wiki/Special:MyLanguage/API:{$name}";
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAllMessages.php b/www/wiki/includes/api/ApiQueryAllMessages.php
new file mode 100644
index 00000000..271d2811
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAllMessages.php
@@ -0,0 +1,261 @@
+<?php
+/**
+ *
+ *
+ * Created on Dec 1, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query action to return messages from site message cache
+ *
+ * @ingroup API
+ */
+class ApiQueryAllMessages extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'am' );
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ if ( is_null( $params['lang'] ) ) {
+ $langObj = $this->getLanguage();
+ } elseif ( !Language::isValidCode( $params['lang'] ) ) {
+ $this->dieWithError(
+ [ 'apierror-invalidlang', $this->encodeParamName( 'lang' ) ], 'invalidlang'
+ );
+ } else {
+ $langObj = Language::factory( $params['lang'] );
+ }
+
+ if ( $params['enableparser'] ) {
+ if ( !is_null( $params['title'] ) ) {
+ $title = Title::newFromText( $params['title'] );
+ if ( !$title || $title->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
+ }
+ } else {
+ $title = Title::newFromText( 'API' );
+ }
+ }
+
+ $prop = array_flip( (array)$params['prop'] );
+
+ // Determine which messages should we print
+ if ( in_array( '*', $params['messages'] ) ) {
+ $message_names = Language::getMessageKeysFor( $langObj->getCode() );
+ if ( $params['includelocal'] ) {
+ $message_names = array_unique( array_merge(
+ $message_names,
+ // Pass in the content language code so we get local messages that have a
+ // MediaWiki:msgkey page. We might theoretically miss messages that have no
+ // MediaWiki:msgkey page but do have a MediaWiki:msgkey/lang page, but that's
+ // just a stupid case.
+ MessageCache::singleton()->getAllMessageKeys( $this->getConfig()->get( 'LanguageCode' ) )
+ ) );
+ }
+ sort( $message_names );
+ $messages_target = $message_names;
+ } else {
+ $messages_target = $params['messages'];
+ }
+
+ // Filter messages that have the specified prefix
+ // Because we sorted the message array earlier, they will appear in a clump:
+ if ( isset( $params['prefix'] ) ) {
+ $skip = false;
+ $messages_filtered = [];
+ foreach ( $messages_target as $message ) {
+ // === 0: must be at beginning of string (position 0)
+ if ( strpos( $message, $params['prefix'] ) === 0 ) {
+ if ( !$skip ) {
+ $skip = true;
+ }
+ $messages_filtered[] = $message;
+ } elseif ( $skip ) {
+ break;
+ }
+ }
+ $messages_target = $messages_filtered;
+ }
+
+ // Filter messages that contain specified string
+ if ( isset( $params['filter'] ) ) {
+ $messages_filtered = [];
+ foreach ( $messages_target as $message ) {
+ // !== is used because filter can be at the beginning of the string
+ if ( strpos( $message, $params['filter'] ) !== false ) {
+ $messages_filtered[] = $message;
+ }
+ }
+ $messages_target = $messages_filtered;
+ }
+
+ // Whether we have any sort of message customisation filtering
+ $customiseFilterEnabled = $params['customised'] !== 'all';
+ if ( $customiseFilterEnabled ) {
+ global $wgContLang;
+
+ $customisedMessages = AllMessagesTablePager::getCustomisedStatuses(
+ array_map(
+ [ $langObj, 'ucfirst' ],
+ $messages_target
+ ),
+ $langObj->getCode(),
+ !$langObj->equals( $wgContLang )
+ );
+
+ $customised = $params['customised'] === 'modified';
+ }
+
+ // Get all requested messages and print the result
+ $skip = !is_null( $params['from'] );
+ $useto = !is_null( $params['to'] );
+ $result = $this->getResult();
+ foreach ( $messages_target as $message ) {
+ // Skip all messages up to $params['from']
+ if ( $skip && $message === $params['from'] ) {
+ $skip = false;
+ }
+
+ if ( $useto && $message > $params['to'] ) {
+ break;
+ }
+
+ if ( !$skip ) {
+ $a = [
+ 'name' => $message,
+ 'normalizedname' => MessageCache::normalizeKey( $message ),
+ ];
+
+ $args = [];
+ if ( isset( $params['args'] ) && count( $params['args'] ) != 0 ) {
+ $args = $params['args'];
+ }
+
+ if ( $customiseFilterEnabled ) {
+ $messageIsCustomised = isset( $customisedMessages['pages'][$langObj->ucfirst( $message )] );
+ if ( $customised === $messageIsCustomised ) {
+ if ( $customised ) {
+ $a['customised'] = true;
+ }
+ } else {
+ continue;
+ }
+ }
+
+ $msg = wfMessage( $message, $args )->inLanguage( $langObj );
+
+ if ( !$msg->exists() ) {
+ $a['missing'] = true;
+ } else {
+ // Check if the parser is enabled:
+ if ( $params['enableparser'] ) {
+ $msgString = $msg->title( $title )->text();
+ } else {
+ $msgString = $msg->plain();
+ }
+ if ( !$params['nocontent'] ) {
+ ApiResult::setContentValue( $a, 'content', $msgString );
+ }
+ if ( isset( $prop['default'] ) ) {
+ $default = wfMessage( $message )->inLanguage( $langObj )->useDatabase( false );
+ if ( !$default->exists() ) {
+ $a['defaultmissing'] = true;
+ } elseif ( $default->plain() != $msgString ) {
+ $a['default'] = $default->plain();
+ }
+ }
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $a );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'from', $message );
+ break;
+ }
+ }
+ }
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'message' );
+ }
+
+ public function getCacheMode( $params ) {
+ if ( is_null( $params['lang'] ) ) {
+ // Language not specified, will be fetched from preferences
+ return 'anon-public-user-private';
+ } elseif ( $params['enableparser'] ) {
+ // User-specific parser options will be used
+ return 'anon-public-user-private';
+ } else {
+ // OK to cache
+ return 'public';
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'messages' => [
+ ApiBase::PARAM_DFLT => '*',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'default'
+ ]
+ ],
+ 'enableparser' => false,
+ 'nocontent' => false,
+ 'includelocal' => false,
+ 'args' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ALLOW_DUPLICATES => true,
+ ],
+ 'filter' => [],
+ 'customised' => [
+ ApiBase::PARAM_DFLT => 'all',
+ ApiBase::PARAM_TYPE => [
+ 'all',
+ 'modified',
+ 'unmodified'
+ ]
+ ],
+ 'lang' => null,
+ 'from' => null,
+ 'to' => null,
+ 'title' => null,
+ 'prefix' => null,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&meta=allmessages&amprefix=ipb-'
+ => 'apihelp-query+allmessages-example-ipb',
+ 'action=query&meta=allmessages&ammessages=august|mainpage&amlang=de'
+ => 'apihelp-query+allmessages-example-de',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Allmessages';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAllPages.php b/www/wiki/includes/api/ApiQueryAllPages.php
new file mode 100644
index 00000000..315def04
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAllPages.php
@@ -0,0 +1,360 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 25, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Query module to enumerate all available pages.
+ *
+ * @ingroup API
+ */
+class ApiQueryAllPages extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ap' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ public function executeGenerator( $resultPageSet ) {
+ if ( $resultPageSet->isResolvingRedirects() ) {
+ $this->dieWithError( 'apierror-allpages-generator-redirects', 'params' );
+ }
+
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $db = $this->getDB();
+
+ $params = $this->extractRequestParams();
+
+ // Page filters
+ $this->addTables( 'page' );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 1 );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $cont_from = $db->addQuotes( $cont[0] );
+ $this->addWhere( "page_title $op= $cont_from" );
+ }
+
+ $miserMode = $this->getConfig()->get( 'MiserMode' );
+ if ( !$miserMode ) {
+ if ( $params['filterredir'] == 'redirects' ) {
+ $this->addWhereFld( 'page_is_redirect', 1 );
+ } elseif ( $params['filterredir'] == 'nonredirects' ) {
+ $this->addWhereFld( 'page_is_redirect', 0 );
+ }
+ }
+
+ $this->addWhereFld( 'page_namespace', $params['namespace'] );
+ $dir = ( $params['dir'] == 'descending' ? 'older' : 'newer' );
+ $from = ( $params['from'] === null
+ ? null
+ : $this->titlePartToKey( $params['from'], $params['namespace'] ) );
+ $to = ( $params['to'] === null
+ ? null
+ : $this->titlePartToKey( $params['to'], $params['namespace'] ) );
+ $this->addWhereRange( 'page_title', $dir, $from, $to );
+
+ if ( isset( $params['prefix'] ) ) {
+ $this->addWhere( 'page_title' . $db->buildLike(
+ $this->titlePartToKey( $params['prefix'], $params['namespace'] ),
+ $db->anyString() ) );
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $selectFields = [
+ 'page_namespace',
+ 'page_title',
+ 'page_id'
+ ];
+ } else {
+ $selectFields = $resultPageSet->getPageTableFields();
+ }
+
+ $miserModeFilterRedirValue = null;
+ $miserModeFilterRedir = $miserMode && $params['filterredir'] !== 'all';
+ if ( $miserModeFilterRedir ) {
+ $selectFields[] = 'page_is_redirect';
+
+ if ( $params['filterredir'] == 'redirects' ) {
+ $miserModeFilterRedirValue = 1;
+ } elseif ( $params['filterredir'] == 'nonredirects' ) {
+ $miserModeFilterRedirValue = 0;
+ }
+ }
+
+ $this->addFields( $selectFields );
+ $forceNameTitleIndex = true;
+ if ( isset( $params['minsize'] ) ) {
+ $this->addWhere( 'page_len>=' . intval( $params['minsize'] ) );
+ $forceNameTitleIndex = false;
+ }
+
+ if ( isset( $params['maxsize'] ) ) {
+ $this->addWhere( 'page_len<=' . intval( $params['maxsize'] ) );
+ $forceNameTitleIndex = false;
+ }
+
+ // Page protection filtering
+ if ( count( $params['prtype'] ) || $params['prexpiry'] != 'all' ) {
+ $this->addTables( 'page_restrictions' );
+ $this->addWhere( 'page_id=pr_page' );
+ $this->addWhere( "pr_expiry > {$db->addQuotes( $db->timestamp() )} OR pr_expiry IS NULL" );
+
+ if ( count( $params['prtype'] ) ) {
+ $this->addWhereFld( 'pr_type', $params['prtype'] );
+
+ if ( isset( $params['prlevel'] ) ) {
+ // Remove the empty string and '*' from the prlevel array
+ $prlevel = array_diff( $params['prlevel'], [ '', '*' ] );
+
+ if ( count( $prlevel ) ) {
+ $this->addWhereFld( 'pr_level', $prlevel );
+ }
+ }
+ if ( $params['prfiltercascade'] == 'cascading' ) {
+ $this->addWhereFld( 'pr_cascade', 1 );
+ } elseif ( $params['prfiltercascade'] == 'noncascading' ) {
+ $this->addWhereFld( 'pr_cascade', 0 );
+ }
+ }
+ $forceNameTitleIndex = false;
+
+ if ( $params['prexpiry'] == 'indefinite' ) {
+ $this->addWhere( "pr_expiry = {$db->addQuotes( $db->getInfinity() )} OR pr_expiry IS NULL" );
+ } elseif ( $params['prexpiry'] == 'definite' ) {
+ $this->addWhere( "pr_expiry != {$db->addQuotes( $db->getInfinity() )}" );
+ }
+
+ $this->addOption( 'DISTINCT' );
+ } elseif ( isset( $params['prlevel'] ) ) {
+ $this->dieWithError(
+ [ 'apierror-invalidparammix-mustusewith', 'prlevel', 'prtype' ], 'invalidparammix'
+ );
+ }
+
+ if ( $params['filterlanglinks'] == 'withoutlanglinks' ) {
+ $this->addTables( 'langlinks' );
+ $this->addJoinConds( [ 'langlinks' => [ 'LEFT JOIN', 'page_id=ll_from' ] ] );
+ $this->addWhere( 'll_from IS NULL' );
+ $forceNameTitleIndex = false;
+ } elseif ( $params['filterlanglinks'] == 'withlanglinks' ) {
+ $this->addTables( 'langlinks' );
+ $this->addWhere( 'page_id=ll_from' );
+ $this->addOption( 'STRAIGHT_JOIN' );
+
+ // MySQL filesorts if we use a GROUP BY that works with the rules
+ // in the 1992 SQL standard (it doesn't like having the
+ // constant-in-WHERE page_namespace column in there). Using the
+ // 1999 rules works fine, but that breaks other DBs. Sigh.
+ /// @todo Once we drop support for 1992-rule DBs, we can simplify this.
+ $dbType = $db->getType();
+ if ( $dbType === 'mysql' || $dbType === 'sqlite' ) {
+ // Ignore the rules, or 1999 rules if you count unique keys
+ // over non-NULL columns as satisfying the requirement for
+ // "functional dependency" and don't require including
+ // constant-in-WHERE columns in the GROUP BY.
+ $this->addOption( 'GROUP BY', [ 'page_title' ] );
+ } elseif ( $dbType === 'postgres' && $db->getServerVersion() >= 9.1 ) {
+ // 1999 rules only counting primary keys
+ $this->addOption( 'GROUP BY', [ 'page_title', 'page_id' ] );
+ } else {
+ // 1992 rules
+ $this->addOption( 'GROUP BY', $selectFields );
+ }
+
+ $forceNameTitleIndex = false;
+ }
+
+ if ( $forceNameTitleIndex ) {
+ $this->addOption( 'USE INDEX', 'name_title' );
+ }
+
+ $limit = $params['limit'];
+ $this->addOption( 'LIMIT', $limit + 1 );
+ $res = $this->select( __METHOD__ );
+
+ // Get gender information
+ if ( MWNamespace::hasGenderDistinction( $params['namespace'] ) ) {
+ $users = [];
+ foreach ( $res as $row ) {
+ $users[] = $row->page_title;
+ }
+ MediaWikiServices::getInstance()->getGenderCache()->doQuery( $users, __METHOD__ );
+ $res->rewind(); // reset
+ }
+
+ $count = 0;
+ $result = $this->getResult();
+ foreach ( $res as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->page_title );
+ break;
+ }
+
+ if ( $miserModeFilterRedir && (int)$row->page_is_redirect !== $miserModeFilterRedirValue ) {
+ // Filter implemented in PHP due to being in Miser Mode
+ continue;
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ $vals = [
+ 'pageid' => intval( $row->page_id ),
+ 'ns' => intval( $title->getNamespace() ),
+ 'title' => $title->getPrefixedText()
+ ];
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->page_title );
+ break;
+ }
+ } else {
+ $resultPageSet->processDbRow( $row );
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'p' );
+ }
+ }
+
+ public function getAllowedParams() {
+ $ret = [
+ 'from' => null,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'to' => null,
+ 'prefix' => null,
+ 'namespace' => [
+ ApiBase::PARAM_DFLT => NS_MAIN,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ],
+ 'filterredir' => [
+ ApiBase::PARAM_DFLT => 'all',
+ ApiBase::PARAM_TYPE => [
+ 'all',
+ 'redirects',
+ 'nonredirects'
+ ]
+ ],
+ 'minsize' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'maxsize' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'prtype' => [
+ ApiBase::PARAM_TYPE => Title::getFilteredRestrictionTypes( true ),
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'prlevel' => [
+ ApiBase::PARAM_TYPE => $this->getConfig()->get( 'RestrictionLevels' ),
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'prfiltercascade' => [
+ ApiBase::PARAM_DFLT => 'all',
+ ApiBase::PARAM_TYPE => [
+ 'cascading',
+ 'noncascading',
+ 'all'
+ ],
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ 'filterlanglinks' => [
+ ApiBase::PARAM_TYPE => [
+ 'withlanglinks',
+ 'withoutlanglinks',
+ 'all'
+ ],
+ ApiBase::PARAM_DFLT => 'all'
+ ],
+ 'prexpiry' => [
+ ApiBase::PARAM_TYPE => [
+ 'indefinite',
+ 'definite',
+ 'all'
+ ],
+ ApiBase::PARAM_DFLT => 'all'
+ ],
+ ];
+
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $ret['filterredir'][ApiBase::PARAM_HELP_MSG_APPEND] = [ 'api-help-param-limited-in-miser-mode' ];
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=allpages&apfrom=B'
+ => 'apihelp-query+allpages-example-B',
+ 'action=query&generator=allpages&gaplimit=4&gapfrom=T&prop=info'
+ => 'apihelp-query+allpages-example-generator',
+ 'action=query&generator=allpages&gaplimit=2&' .
+ 'gapfilterredir=nonredirects&gapfrom=Re&prop=revisions&rvprop=content'
+ => 'apihelp-query+allpages-example-generator-revisions',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Allpages';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAllRevisions.php b/www/wiki/includes/api/ApiQueryAllRevisions.php
new file mode 100644
index 00000000..8f7d6eb2
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAllRevisions.php
@@ -0,0 +1,295 @@
+<?php
+/**
+ * Created on Sep 27, 2015
+ *
+ * Copyright © 2015 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate all revisions.
+ *
+ * @ingroup API
+ * @since 1.27
+ */
+class ApiQueryAllRevisions extends ApiQueryRevisionsBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'arv' );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ protected function run( ApiPageSet $resultPageSet = null ) {
+ $db = $this->getDB();
+ $params = $this->extractRequestParams( false );
+
+ $result = $this->getResult();
+
+ $this->requireMaxOneParameter( $params, 'user', 'excludeuser' );
+
+ // Namespace check is likely to be desired, but can't be done
+ // efficiently in SQL.
+ $miser_ns = null;
+ $needPageTable = false;
+ if ( $params['namespace'] !== null ) {
+ $params['namespace'] = array_unique( $params['namespace'] );
+ sort( $params['namespace'] );
+ if ( $params['namespace'] != MWNamespace::getValidNamespaces() ) {
+ $needPageTable = true;
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $miser_ns = $params['namespace'];
+ } else {
+ $this->addWhere( [ 'page_namespace' => $params['namespace'] ] );
+ }
+ }
+ }
+
+ $this->addTables( 'revision' );
+ if ( $resultPageSet === null ) {
+ $this->parseParameters( $params );
+ $this->addTables( 'page' );
+ $this->addJoinConds(
+ [ 'page' => [ 'INNER JOIN', [ 'rev_page = page_id' ] ] ]
+ );
+ $this->addFields( Revision::selectFields() );
+ $this->addFields( Revision::selectPageFields() );
+
+ // Review this depeneding on the outcome of T113901
+ $this->addOption( 'STRAIGHT_JOIN' );
+ } else {
+ $this->limit = $this->getParameter( 'limit' ) ?: 10;
+ $this->addFields( [ 'rev_timestamp', 'rev_id' ] );
+ if ( $params['generatetitles'] ) {
+ $this->addFields( [ 'rev_page' ] );
+ }
+
+ if ( $needPageTable ) {
+ $this->addTables( 'page' );
+ $this->addJoinConds(
+ [ 'page' => [ 'INNER JOIN', [ 'rev_page = page_id' ] ] ]
+ );
+ $this->addFieldsIf( [ 'page_namespace' ], (bool)$miser_ns );
+
+ // Review this depeneding on the outcome of T113901
+ $this->addOption( 'STRAIGHT_JOIN' );
+ }
+ }
+
+ $dir = $params['dir'];
+ $this->addTimestampWhereRange( 'rev_timestamp', $dir, $params['start'], $params['end'] );
+
+ if ( $this->fld_tags ) {
+ $this->addTables( 'tag_summary' );
+ $this->addJoinConds(
+ [ 'tag_summary' => [ 'LEFT JOIN', [ 'rev_id=ts_rev_id' ] ] ]
+ );
+ $this->addFields( 'ts_tags' );
+ }
+
+ if ( $this->fetchContent ) {
+ $this->addTables( 'text' );
+ $this->addJoinConds(
+ [ 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ] ]
+ );
+ $this->addFields( 'old_id' );
+ $this->addFields( Revision::selectTextFields() );
+ }
+
+ if ( $params['user'] !== null ) {
+ $id = User::idFromName( $params['user'] );
+ if ( $id ) {
+ $this->addWhereFld( 'rev_user', $id );
+ } else {
+ $this->addWhereFld( 'rev_user_text', $params['user'] );
+ }
+ } elseif ( $params['excludeuser'] !== null ) {
+ $id = User::idFromName( $params['excludeuser'] );
+ if ( $id ) {
+ $this->addWhere( 'rev_user != ' . $id );
+ } else {
+ $this->addWhere( 'rev_user_text != ' . $db->addQuotes( $params['excludeuser'] ) );
+ }
+ }
+
+ if ( $params['user'] !== null || $params['excludeuser'] !== null ) {
+ // Paranoia: avoid brute force searches (T19342)
+ if ( !$this->getUser()->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$this->getUser()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $db->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" );
+ }
+ }
+
+ if ( $params['continue'] !== null ) {
+ $op = ( $dir == 'newer' ? '>' : '<' );
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $ts = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $rev_id = (int)$cont[1];
+ $this->dieContinueUsageIf( strval( $rev_id ) !== $cont[1] );
+ $this->addWhere( "rev_timestamp $op $ts OR " .
+ "(rev_timestamp = $ts AND " .
+ "rev_id $op= $rev_id)" );
+ }
+
+ $this->addOption( 'LIMIT', $this->limit + 1 );
+
+ $sort = ( $dir == 'newer' ? '' : ' DESC' );
+ $orderby = [];
+ // Targeting index rev_timestamp, user_timestamp, or usertext_timestamp
+ // But 'user' is always constant for the latter two, so it doesn't matter here.
+ $orderby[] = "rev_timestamp $sort";
+ $orderby[] = "rev_id $sort";
+ $this->addOption( 'ORDER BY', $orderby );
+
+ $hookData = [];
+ $res = $this->select( __METHOD__, [], $hookData );
+ $pageMap = []; // Maps rev_page to array index
+ $count = 0;
+ $nextIndex = 0;
+ $generated = [];
+ foreach ( $res as $row ) {
+ if ( $count === 0 && $resultPageSet !== null ) {
+ // Set the non-continue since the list of all revisions is
+ // prone to having entries added at the start frequently.
+ $this->getContinuationManager()->addGeneratorNonContinueParam(
+ $this, 'continue', "$row->rev_timestamp|$row->rev_id"
+ );
+ }
+ if ( ++$count > $this->limit ) {
+ // We've had enough
+ $this->setContinueEnumParameter( 'continue', "$row->rev_timestamp|$row->rev_id" );
+ break;
+ }
+
+ // Miser mode namespace check
+ if ( $miser_ns !== null && !in_array( $row->page_namespace, $miser_ns ) ) {
+ continue;
+ }
+
+ if ( $resultPageSet !== null ) {
+ if ( $params['generatetitles'] ) {
+ $generated[$row->rev_page] = $row->rev_page;
+ } else {
+ $generated[] = $row->rev_id;
+ }
+ } else {
+ $revision = Revision::newFromRow( $row );
+ $rev = $this->extractRevisionInfo( $revision, $row );
+
+ if ( !isset( $pageMap[$row->rev_page] ) ) {
+ $index = $nextIndex++;
+ $pageMap[$row->rev_page] = $index;
+ $title = $revision->getTitle();
+ $a = [
+ 'pageid' => $title->getArticleID(),
+ 'revisions' => [ $rev ],
+ ];
+ ApiResult::setIndexedTagName( $a['revisions'], 'rev' );
+ ApiQueryBase::addTitleInfo( $a, $title );
+ $fit = $this->processRow( $row, $a['revisions'][0], $hookData ) &&
+ $result->addValue( [ 'query', $this->getModuleName() ], $index, $a );
+ } else {
+ $index = $pageMap[$row->rev_page];
+ $fit = $this->processRow( $row, $rev, $hookData ) &&
+ $result->addValue( [ 'query', $this->getModuleName(), $index, 'revisions' ], null, $rev );
+ }
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', "$row->rev_timestamp|$row->rev_id" );
+ break;
+ }
+ }
+ }
+
+ if ( $resultPageSet !== null ) {
+ if ( $params['generatetitles'] ) {
+ $resultPageSet->populateFromPageIDs( $generated );
+ } else {
+ $resultPageSet->populateFromRevisionIDs( $generated );
+ }
+ } else {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'page' );
+ }
+ }
+
+ public function getAllowedParams() {
+ $ret = parent::getAllowedParams() + [
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_DFLT => null,
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+ 'dir' => [
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'excludeuser' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'generatetitles' => [
+ ApiBase::PARAM_DFLT => false,
+ ],
+ ];
+
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $ret['namespace'][ApiBase::PARAM_HELP_MSG_APPEND] = [
+ 'api-help-param-limited-in-miser-mode',
+ ];
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=allrevisions&arvuser=Example&arvlimit=50'
+ => 'apihelp-query+allrevisions-example-user',
+ 'action=query&list=allrevisions&arvdir=newer&arvlimit=50'
+ => 'apihelp-query+allrevisions-example-ns-main',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Allrevisions';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAllUsers.php b/www/wiki/includes/api/ApiQueryAllUsers.php
new file mode 100644
index 00000000..d594ad44
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAllUsers.php
@@ -0,0 +1,395 @@
+<?php
+/**
+ *
+ *
+ * Created on July 7, 2007
+ *
+ * Copyright © 2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate all registered users.
+ *
+ * @ingroup API
+ */
+class ApiQueryAllUsers extends ApiQueryBase {
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'au' );
+ }
+
+ /**
+ * This function converts the user name to a canonical form
+ * which is stored in the database.
+ * @param string $name
+ * @return string
+ */
+ private function getCanonicalUserName( $name ) {
+ return strtr( $name, '_', ' ' );
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $activeUserDays = $this->getConfig()->get( 'ActiveUserDays' );
+
+ $db = $this->getDB();
+ $commentStore = new CommentStore( 'ipb_reason' );
+
+ $prop = $params['prop'];
+ if ( !is_null( $prop ) ) {
+ $prop = array_flip( $prop );
+ $fld_blockinfo = isset( $prop['blockinfo'] );
+ $fld_editcount = isset( $prop['editcount'] );
+ $fld_groups = isset( $prop['groups'] );
+ $fld_rights = isset( $prop['rights'] );
+ $fld_registration = isset( $prop['registration'] );
+ $fld_implicitgroups = isset( $prop['implicitgroups'] );
+ $fld_centralids = isset( $prop['centralids'] );
+ } else {
+ $fld_blockinfo = $fld_editcount = $fld_groups = $fld_registration =
+ $fld_rights = $fld_implicitgroups = $fld_centralids = false;
+ }
+
+ $limit = $params['limit'];
+
+ $this->addTables( 'user' );
+
+ $dir = ( $params['dir'] == 'descending' ? 'older' : 'newer' );
+ $from = is_null( $params['from'] ) ? null : $this->getCanonicalUserName( $params['from'] );
+ $to = is_null( $params['to'] ) ? null : $this->getCanonicalUserName( $params['to'] );
+
+ # MySQL can't figure out that 'user_name' and 'qcc_title' are the same
+ # despite the JOIN condition, so manually sort on the correct one.
+ $userFieldToSort = $params['activeusers'] ? 'qcc_title' : 'user_name';
+
+ # Some of these subtable joins are going to give us duplicate rows, so
+ # calculate the maximum number of duplicates we might see.
+ $maxDuplicateRows = 1;
+
+ $this->addWhereRange( $userFieldToSort, $dir, $from, $to );
+
+ if ( !is_null( $params['prefix'] ) ) {
+ $this->addWhere( $userFieldToSort .
+ $db->buildLike( $this->getCanonicalUserName( $params['prefix'] ), $db->anyString() ) );
+ }
+
+ if ( !is_null( $params['rights'] ) && count( $params['rights'] ) ) {
+ $groups = [];
+ foreach ( $params['rights'] as $r ) {
+ $groups = array_merge( $groups, User::getGroupsWithPermission( $r ) );
+ }
+
+ // no group with the given right(s) exists, no need for a query
+ if ( !count( $groups ) ) {
+ $this->getResult()->addIndexedTagName( [ 'query', $this->getModuleName() ], '' );
+
+ return;
+ }
+
+ $groups = array_unique( $groups );
+
+ if ( is_null( $params['group'] ) ) {
+ $params['group'] = $groups;
+ } else {
+ $params['group'] = array_unique( array_merge( $params['group'], $groups ) );
+ }
+ }
+
+ $this->requireMaxOneParameter( $params, 'group', 'excludegroup' );
+
+ if ( !is_null( $params['group'] ) && count( $params['group'] ) ) {
+ // Filter only users that belong to a given group. This might
+ // produce as many rows-per-user as there are groups being checked.
+ $this->addTables( 'user_groups', 'ug1' );
+ $this->addJoinConds( [
+ 'ug1' => [
+ 'INNER JOIN',
+ [
+ 'ug1.ug_user=user_id',
+ 'ug1.ug_group' => $params['group'],
+ 'ug1.ug_expiry IS NULL OR ug1.ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
+ ]
+ ]
+ ] );
+ $maxDuplicateRows *= count( $params['group'] );
+ }
+
+ if ( !is_null( $params['excludegroup'] ) && count( $params['excludegroup'] ) ) {
+ // Filter only users don't belong to a given group. This can only
+ // produce one row-per-user, because we only keep on "no match".
+ $this->addTables( 'user_groups', 'ug1' );
+
+ if ( count( $params['excludegroup'] ) == 1 ) {
+ $exclude = [ 'ug1.ug_group' => $params['excludegroup'][0] ];
+ } else {
+ $exclude = [ $db->makeList(
+ [ 'ug1.ug_group' => $params['excludegroup'] ],
+ LIST_OR
+ ) ];
+ }
+ $this->addJoinConds( [ 'ug1' => [ 'LEFT OUTER JOIN',
+ array_merge( [
+ 'ug1.ug_user=user_id',
+ 'ug1.ug_expiry IS NULL OR ug1.ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
+ ], $exclude )
+ ] ] );
+ $this->addWhere( 'ug1.ug_user IS NULL' );
+ }
+
+ if ( $params['witheditsonly'] ) {
+ $this->addWhere( 'user_editcount > 0' );
+ }
+
+ $this->showHiddenUsersAddBlockInfo( $fld_blockinfo );
+
+ if ( $fld_groups || $fld_rights ) {
+ $this->addFields( [ 'groups' =>
+ $db->buildGroupConcatField( '|', 'user_groups', 'ug_group', [
+ 'ug_user=user_id',
+ 'ug_expiry IS NULL OR ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
+ ] )
+ ] );
+ }
+
+ if ( $params['activeusers'] ) {
+ $activeUserSeconds = $activeUserDays * 86400;
+
+ // Filter query to only include users in the active users cache.
+ // There shouldn't be any duplicate rows in querycachetwo here.
+ $this->addTables( 'querycachetwo' );
+ $this->addJoinConds( [ 'querycachetwo' => [
+ 'INNER JOIN', [
+ 'qcc_type' => 'activeusers',
+ 'qcc_namespace' => NS_USER,
+ 'qcc_title=user_name',
+ ],
+ ] ] );
+
+ // Actually count the actions using a subquery (T66505 and T66507)
+ $timestamp = $db->timestamp( wfTimestamp( TS_UNIX ) - $activeUserSeconds );
+ $this->addFields( [
+ 'recentactions' => '(' . $db->selectSQLText(
+ 'recentchanges',
+ 'COUNT(*)',
+ [
+ 'rc_user_text = user_name',
+ 'rc_type != ' . $db->addQuotes( RC_EXTERNAL ), // no wikidata
+ 'rc_log_type IS NULL OR rc_log_type != ' . $db->addQuotes( 'newusers' ),
+ 'rc_timestamp >= ' . $db->addQuotes( $timestamp ),
+ ]
+ ) . ')'
+ ] );
+ }
+
+ $sqlLimit = $limit + $maxDuplicateRows;
+ $this->addOption( 'LIMIT', $sqlLimit );
+
+ $this->addFields( [
+ 'user_name',
+ 'user_id'
+ ] );
+ $this->addFieldsIf( 'user_editcount', $fld_editcount );
+ $this->addFieldsIf( 'user_registration', $fld_registration );
+
+ $res = $this->select( __METHOD__ );
+ $count = 0;
+ $countDuplicates = 0;
+ $lastUser = false;
+ $result = $this->getResult();
+ foreach ( $res as $row ) {
+ $count++;
+
+ if ( $lastUser === $row->user_name ) {
+ // Duplicate row due to one of the needed subtable joins.
+ // Ignore it, but count the number of them to sanely handle
+ // miscalculation of $maxDuplicateRows.
+ $countDuplicates++;
+ if ( $countDuplicates == $maxDuplicateRows ) {
+ ApiBase::dieDebug( __METHOD__, 'Saw more duplicate rows than expected' );
+ }
+ continue;
+ }
+
+ $countDuplicates = 0;
+ $lastUser = $row->user_name;
+
+ if ( $count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'from', $row->user_name );
+ break;
+ }
+
+ if ( $count == $sqlLimit ) {
+ // Should never hit this (either the $countDuplicates check or
+ // the $count > $limit check should hit first), but check it
+ // anyway just in case.
+ ApiBase::dieDebug( __METHOD__, 'Saw more duplicate rows than expected' );
+ }
+
+ if ( $params['activeusers'] && $row->recentactions === 0 ) {
+ // activeusers cache was out of date
+ continue;
+ }
+
+ $data = [
+ 'userid' => (int)$row->user_id,
+ 'name' => $row->user_name,
+ ];
+
+ if ( $fld_centralids ) {
+ $data += ApiQueryUserInfo::getCentralUserInfo(
+ $this->getConfig(), User::newFromId( $row->user_id ), $params['attachedwiki']
+ );
+ }
+
+ if ( $fld_blockinfo && !is_null( $row->ipb_by_text ) ) {
+ $data['blockid'] = (int)$row->ipb_id;
+ $data['blockedby'] = $row->ipb_by_text;
+ $data['blockedbyid'] = (int)$row->ipb_by;
+ $data['blockedtimestamp'] = wfTimestamp( TS_ISO_8601, $row->ipb_timestamp );
+ $data['blockreason'] = $commentStore->getComment( $row )->text;
+ $data['blockexpiry'] = $row->ipb_expiry;
+ }
+ if ( $row->ipb_deleted ) {
+ $data['hidden'] = true;
+ }
+ if ( $fld_editcount ) {
+ $data['editcount'] = intval( $row->user_editcount );
+ }
+ if ( $params['activeusers'] ) {
+ $data['recentactions'] = intval( $row->recentactions );
+ // @todo 'recenteditcount' is set for BC, remove in 1.25
+ $data['recenteditcount'] = $data['recentactions'];
+ }
+ if ( $fld_registration ) {
+ $data['registration'] = $row->user_registration ?
+ wfTimestamp( TS_ISO_8601, $row->user_registration ) : '';
+ }
+
+ if ( $fld_implicitgroups || $fld_groups || $fld_rights ) {
+ $implicitGroups = User::newFromId( $row->user_id )->getAutomaticGroups();
+ if ( isset( $row->groups ) && $row->groups !== '' ) {
+ $groups = array_merge( $implicitGroups, explode( '|', $row->groups ) );
+ } else {
+ $groups = $implicitGroups;
+ }
+
+ if ( $fld_groups ) {
+ $data['groups'] = $groups;
+ ApiResult::setIndexedTagName( $data['groups'], 'g' );
+ ApiResult::setArrayType( $data['groups'], 'array' );
+ }
+
+ if ( $fld_implicitgroups ) {
+ $data['implicitgroups'] = $implicitGroups;
+ ApiResult::setIndexedTagName( $data['implicitgroups'], 'g' );
+ ApiResult::setArrayType( $data['implicitgroups'], 'array' );
+ }
+
+ if ( $fld_rights ) {
+ $data['rights'] = User::getGroupPermissions( $groups );
+ ApiResult::setIndexedTagName( $data['rights'], 'r' );
+ ApiResult::setArrayType( $data['rights'], 'array' );
+ }
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $data );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'from', $data['name'] );
+ break;
+ }
+ }
+
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'u' );
+ }
+
+ public function getCacheMode( $params ) {
+ return 'anon-public-user-private';
+ }
+
+ public function getAllowedParams() {
+ $userGroups = User::getAllGroups();
+
+ return [
+ 'from' => null,
+ 'to' => null,
+ 'prefix' => null,
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ],
+ ],
+ 'group' => [
+ ApiBase::PARAM_TYPE => $userGroups,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'excludegroup' => [
+ ApiBase::PARAM_TYPE => $userGroups,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'rights' => [
+ ApiBase::PARAM_TYPE => User::getAllRights(),
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'blockinfo',
+ 'groups',
+ 'implicitgroups',
+ 'rights',
+ 'editcount',
+ 'registration',
+ 'centralids',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'witheditsonly' => false,
+ 'activeusers' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => [
+ 'apihelp-query+allusers-param-activeusers',
+ $this->getConfig()->get( 'ActiveUserDays' )
+ ],
+ ],
+ 'attachedwiki' => null,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=allusers&aufrom=Y'
+ => 'apihelp-query+allusers-example-Y',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Allusers';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryAuthManagerInfo.php b/www/wiki/includes/api/ApiQueryAuthManagerInfo.php
new file mode 100644
index 00000000..d23d8988
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryAuthManagerInfo.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * Copyright © 2016 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.27
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * A query action to return meta information about AuthManager state.
+ *
+ * @ingroup API
+ */
+class ApiQueryAuthManagerInfo extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ami' );
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $helper = new ApiAuthManagerHelper( $this );
+
+ $manager = AuthManager::singleton();
+ $ret = [
+ 'canauthenticatenow' => $manager->canAuthenticateNow(),
+ 'cancreateaccounts' => $manager->canCreateAccounts(),
+ 'canlinkaccounts' => $manager->canLinkAccounts(),
+ ];
+
+ if ( $params['securitysensitiveoperation'] !== null ) {
+ $ret['securitysensitiveoperationstatus'] = $manager->securitySensitiveOperationStatus(
+ $params['securitysensitiveoperation']
+ );
+ }
+
+ if ( $params['requestsfor'] ) {
+ $action = $params['requestsfor'];
+
+ $preservedReq = $helper->getPreservedRequest();
+ if ( $preservedReq ) {
+ $ret += [
+ 'haspreservedstate' => $preservedReq->hasStateForAction( $action ),
+ 'hasprimarypreservedstate' => $preservedReq->hasPrimaryStateForAction( $action ),
+ 'preservedusername' => (string)$preservedReq->username,
+ ];
+ } else {
+ $ret += [
+ 'haspreservedstate' => false,
+ 'hasprimarypreservedstate' => false,
+ 'preservedusername' => '',
+ ];
+ }
+
+ $reqs = $manager->getAuthenticationRequests( $action, $this->getUser() );
+
+ // Filter out blacklisted requests, depending on the action
+ switch ( $action ) {
+ case AuthManager::ACTION_CHANGE:
+ $reqs = ApiAuthManagerHelper::blacklistAuthenticationRequests(
+ $reqs, $this->getConfig()->get( 'ChangeCredentialsBlacklist' )
+ );
+ break;
+ case AuthManager::ACTION_REMOVE:
+ $reqs = ApiAuthManagerHelper::blacklistAuthenticationRequests(
+ $reqs, $this->getConfig()->get( 'RemoveCredentialsBlacklist' )
+ );
+ break;
+ }
+
+ $ret += $helper->formatRequests( $reqs );
+ }
+
+ $this->getResult()->addValue( [ 'query' ], $this->getModuleName(), $ret );
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'securitysensitiveoperation' => null,
+ 'requestsfor' => [
+ ApiBase::PARAM_TYPE => [
+ AuthManager::ACTION_LOGIN,
+ AuthManager::ACTION_LOGIN_CONTINUE,
+ AuthManager::ACTION_CREATE,
+ AuthManager::ACTION_CREATE_CONTINUE,
+ AuthManager::ACTION_LINK,
+ AuthManager::ACTION_LINK_CONTINUE,
+ AuthManager::ACTION_CHANGE,
+ AuthManager::ACTION_REMOVE,
+ AuthManager::ACTION_UNLINK,
+ ],
+ ],
+ ] + ApiAuthManagerHelper::getStandardParams( '', 'mergerequestfields', 'messageformat' );
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&meta=authmanagerinfo&amirequestsfor=' . urlencode( AuthManager::ACTION_LOGIN )
+ => 'apihelp-query+authmanagerinfo-example-login',
+ 'action=query&meta=authmanagerinfo&amirequestsfor=' . urlencode( AuthManager::ACTION_LOGIN ) .
+ '&amimergerequestfields=1'
+ => 'apihelp-query+authmanagerinfo-example-login-merged',
+ 'action=query&meta=authmanagerinfo&amisecuritysensitiveoperation=foo'
+ => 'apihelp-query+authmanagerinfo-example-securitysensitiveoperation',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Authmanagerinfo';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryBacklinks.php b/www/wiki/includes/api/ApiQueryBacklinks.php
new file mode 100644
index 00000000..54be254d
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryBacklinks.php
@@ -0,0 +1,580 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 16, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This is a three-in-one module to query:
+ * * backlinks - links pointing to the given page,
+ * * embeddedin - what pages transclude the given page within themselves,
+ * * imageusage - what pages use the given image
+ *
+ * @ingroup API
+ */
+class ApiQueryBacklinks extends ApiQueryGeneratorBase {
+
+ /**
+ * @var Title
+ */
+ private $rootTitle;
+
+ private $params, $cont, $redirect;
+ private $bl_ns, $bl_from, $bl_from_ns, $bl_table, $bl_code, $bl_title, $bl_fields, $hasNS;
+
+ /**
+ * Maps ns and title to pageid
+ *
+ * @var array
+ */
+ private $pageMap = [];
+ private $resultArr;
+
+ private $redirTitles = [];
+ private $continueStr = null;
+
+ // output element name, database column field prefix, database table
+ private $backlinksSettings = [
+ 'backlinks' => [
+ 'code' => 'bl',
+ 'prefix' => 'pl',
+ 'linktbl' => 'pagelinks',
+ 'helpurl' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Backlinks',
+ ],
+ 'embeddedin' => [
+ 'code' => 'ei',
+ 'prefix' => 'tl',
+ 'linktbl' => 'templatelinks',
+ 'helpurl' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Embeddedin',
+ ],
+ 'imageusage' => [
+ 'code' => 'iu',
+ 'prefix' => 'il',
+ 'linktbl' => 'imagelinks',
+ 'helpurl' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Imageusage',
+ ]
+ ];
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ $settings = $this->backlinksSettings[$moduleName];
+ $prefix = $settings['prefix'];
+ $code = $settings['code'];
+ $this->resultArr = [];
+
+ parent::__construct( $query, $moduleName, $code );
+ $this->bl_ns = $prefix . '_namespace';
+ $this->bl_from = $prefix . '_from';
+ $this->bl_from_ns = $prefix . '_from_namespace';
+ $this->bl_table = $settings['linktbl'];
+ $this->bl_code = $code;
+ $this->helpUrl = $settings['helpurl'];
+
+ $this->hasNS = $moduleName !== 'imageusage';
+ if ( $this->hasNS ) {
+ $this->bl_title = $prefix . '_title';
+ $this->bl_fields = [
+ $this->bl_ns,
+ $this->bl_title
+ ];
+ } else {
+ $this->bl_title = $prefix . '_to';
+ $this->bl_fields = [
+ $this->bl_title
+ ];
+ }
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function runFirstQuery( $resultPageSet = null ) {
+ $this->addTables( [ $this->bl_table, 'page' ] );
+ $this->addWhere( "{$this->bl_from}=page_id" );
+ if ( is_null( $resultPageSet ) ) {
+ $this->addFields( [ 'page_id', 'page_title', 'page_namespace' ] );
+ } else {
+ $this->addFields( $resultPageSet->getPageTableFields() );
+ }
+ $this->addFields( [ 'page_is_redirect', 'from_ns' => 'page_namespace' ] );
+
+ $this->addWhereFld( $this->bl_title, $this->rootTitle->getDBkey() );
+ if ( $this->hasNS ) {
+ $this->addWhereFld( $this->bl_ns, $this->rootTitle->getNamespace() );
+ }
+ $this->addWhereFld( $this->bl_from_ns, $this->params['namespace'] );
+
+ if ( count( $this->cont ) >= 2 ) {
+ $op = $this->params['dir'] == 'descending' ? '<' : '>';
+ if ( count( $this->params['namespace'] ) > 1 ) {
+ $this->addWhere(
+ "{$this->bl_from_ns} $op {$this->cont[0]} OR " .
+ "({$this->bl_from_ns} = {$this->cont[0]} AND " .
+ "{$this->bl_from} $op= {$this->cont[1]})"
+ );
+ } else {
+ $this->addWhere( "{$this->bl_from} $op= {$this->cont[1]}" );
+ }
+ }
+
+ if ( $this->params['filterredir'] == 'redirects' ) {
+ $this->addWhereFld( 'page_is_redirect', 1 );
+ } elseif ( $this->params['filterredir'] == 'nonredirects' && !$this->redirect ) {
+ // T24245 - Check for !redirect, as filtering nonredirects, when
+ // getting what links to them is contradictory
+ $this->addWhereFld( 'page_is_redirect', 0 );
+ }
+
+ $this->addOption( 'LIMIT', $this->params['limit'] + 1 );
+ $sort = ( $this->params['dir'] == 'descending' ? ' DESC' : '' );
+ $orderBy = [];
+ if ( count( $this->params['namespace'] ) > 1 ) {
+ $orderBy[] = $this->bl_from_ns . $sort;
+ }
+ $orderBy[] = $this->bl_from . $sort;
+ $this->addOption( 'ORDER BY', $orderBy );
+ $this->addOption( 'STRAIGHT_JOIN' );
+
+ $res = $this->select( __METHOD__ );
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $this->params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ // Continue string may be overridden at a later step
+ $this->continueStr = "{$row->from_ns}|{$row->page_id}";
+ break;
+ }
+
+ // Fill in continuation fields for later steps
+ if ( count( $this->cont ) < 2 ) {
+ $this->cont[] = $row->from_ns;
+ $this->cont[] = $row->page_id;
+ }
+
+ $this->pageMap[$row->page_namespace][$row->page_title] = $row->page_id;
+ $t = Title::makeTitle( $row->page_namespace, $row->page_title );
+ if ( $row->page_is_redirect ) {
+ $this->redirTitles[] = $t;
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $a = [ 'pageid' => intval( $row->page_id ) ];
+ ApiQueryBase::addTitleInfo( $a, $t );
+ if ( $row->page_is_redirect ) {
+ $a['redirect'] = true;
+ }
+ // Put all the results in an array first
+ $this->resultArr[$a['pageid']] = $a;
+ } else {
+ $resultPageSet->processDbRow( $row );
+ }
+ }
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function runSecondQuery( $resultPageSet = null ) {
+ $db = $this->getDB();
+ $this->addTables( [ 'page', $this->bl_table ] );
+ $this->addWhere( "{$this->bl_from}=page_id" );
+
+ if ( is_null( $resultPageSet ) ) {
+ $this->addFields( [ 'page_id', 'page_title', 'page_namespace', 'page_is_redirect' ] );
+ } else {
+ $this->addFields( $resultPageSet->getPageTableFields() );
+ }
+
+ $this->addFields( [ $this->bl_title, 'from_ns' => 'page_namespace' ] );
+ if ( $this->hasNS ) {
+ $this->addFields( $this->bl_ns );
+ }
+
+ // We can't use LinkBatch here because $this->hasNS may be false
+ $titleWhere = [];
+ $allRedirNs = [];
+ $allRedirDBkey = [];
+ /** @var Title $t */
+ foreach ( $this->redirTitles as $t ) {
+ $redirNs = $t->getNamespace();
+ $redirDBkey = $t->getDBkey();
+ $titleWhere[] = "{$this->bl_title} = " . $db->addQuotes( $redirDBkey ) .
+ ( $this->hasNS ? " AND {$this->bl_ns} = {$redirNs}" : '' );
+ $allRedirNs[$redirNs] = true;
+ $allRedirDBkey[$redirDBkey] = true;
+ }
+ $this->addWhere( $db->makeList( $titleWhere, LIST_OR ) );
+ $this->addWhereFld( 'page_namespace', $this->params['namespace'] );
+
+ if ( count( $this->cont ) >= 6 ) {
+ $op = $this->params['dir'] == 'descending' ? '<' : '>';
+
+ $where = "{$this->bl_from} $op= {$this->cont[5]}";
+ // Don't bother with namespace, title, or from_namespace if it's
+ // otherwise constant in the where clause.
+ if ( count( $this->params['namespace'] ) > 1 ) {
+ $where = "{$this->bl_from_ns} $op {$this->cont[4]} OR " .
+ "({$this->bl_from_ns} = {$this->cont[4]} AND ($where))";
+ }
+ if ( count( $allRedirDBkey ) > 1 ) {
+ $title = $db->addQuotes( $this->cont[3] );
+ $where = "{$this->bl_title} $op $title OR " .
+ "({$this->bl_title} = $title AND ($where))";
+ }
+ if ( $this->hasNS && count( $allRedirNs ) > 1 ) {
+ $where = "{$this->bl_ns} $op {$this->cont[2]} OR " .
+ "({$this->bl_ns} = {$this->cont[2]} AND ($where))";
+ }
+
+ $this->addWhere( $where );
+ }
+ if ( $this->params['filterredir'] == 'redirects' ) {
+ $this->addWhereFld( 'page_is_redirect', 1 );
+ } elseif ( $this->params['filterredir'] == 'nonredirects' ) {
+ $this->addWhereFld( 'page_is_redirect', 0 );
+ }
+
+ $this->addOption( 'LIMIT', $this->params['limit'] + 1 );
+ $orderBy = [];
+ $sort = ( $this->params['dir'] == 'descending' ? ' DESC' : '' );
+ // Don't order by namespace/title/from_namespace if it's constant in the WHERE clause
+ if ( $this->hasNS && count( $allRedirNs ) > 1 ) {
+ $orderBy[] = $this->bl_ns . $sort;
+ }
+ if ( count( $allRedirDBkey ) > 1 ) {
+ $orderBy[] = $this->bl_title . $sort;
+ }
+ if ( count( $this->params['namespace'] ) > 1 ) {
+ $orderBy[] = $this->bl_from_ns . $sort;
+ }
+ $orderBy[] = $this->bl_from . $sort;
+ $this->addOption( 'ORDER BY', $orderBy );
+ $this->addOption( 'USE INDEX', [ 'page' => 'PRIMARY' ] );
+
+ $res = $this->select( __METHOD__ );
+ $count = 0;
+ foreach ( $res as $row ) {
+ $ns = $this->hasNS ? $row->{$this->bl_ns} : NS_FILE;
+
+ if ( ++$count > $this->params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ // Note we must keep the parameters for the first query constant
+ // This may be overridden at a later step
+ $title = $row->{$this->bl_title};
+ $this->continueStr = implode( '|', array_slice( $this->cont, 0, 2 ) ) .
+ "|$ns|$title|{$row->from_ns}|{$row->page_id}";
+ break;
+ }
+
+ // Fill in continuation fields for later steps
+ if ( count( $this->cont ) < 6 ) {
+ $this->cont[] = $ns;
+ $this->cont[] = $row->{$this->bl_title};
+ $this->cont[] = $row->from_ns;
+ $this->cont[] = $row->page_id;
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $a['pageid'] = intval( $row->page_id );
+ ApiQueryBase::addTitleInfo( $a, Title::makeTitle( $row->page_namespace, $row->page_title ) );
+ if ( $row->page_is_redirect ) {
+ $a['redirect'] = true;
+ }
+ $parentID = $this->pageMap[$ns][$row->{$this->bl_title}];
+ // Put all the results in an array first
+ $this->resultArr[$parentID]['redirlinks'][$row->page_id] = $a;
+ } else {
+ $resultPageSet->processDbRow( $row );
+ }
+ }
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $this->params = $this->extractRequestParams( false );
+ $this->redirect = isset( $this->params['redirect'] ) && $this->params['redirect'];
+ $userMax = ( $this->redirect ? ApiBase::LIMIT_BIG1 / 2 : ApiBase::LIMIT_BIG1 );
+ $botMax = ( $this->redirect ? ApiBase::LIMIT_BIG2 / 2 : ApiBase::LIMIT_BIG2 );
+
+ $result = $this->getResult();
+
+ if ( $this->params['limit'] == 'max' ) {
+ $this->params['limit'] = $this->getMain()->canApiHighLimits() ? $botMax : $userMax;
+ $result->addParsedLimit( $this->getModuleName(), $this->params['limit'] );
+ } else {
+ $this->params['limit'] = intval( $this->params['limit'] );
+ $this->validateLimit( 'limit', $this->params['limit'], 1, $userMax, $botMax );
+ }
+
+ $this->rootTitle = $this->getTitleFromTitleOrPageId( $this->params );
+
+ // only image titles are allowed for the root in imageinfo mode
+ if ( !$this->hasNS && $this->rootTitle->getNamespace() !== NS_FILE ) {
+ $this->dieWithError(
+ [ 'apierror-imageusage-badtitle', $this->getModuleName() ],
+ 'bad_image_title'
+ );
+ }
+
+ // Parse and validate continuation parameter
+ $this->cont = [];
+ if ( $this->params['continue'] !== null ) {
+ $cont = explode( '|', $this->params['continue'] );
+
+ switch ( count( $cont ) ) {
+ case 8:
+ // redirect page ID for result adding
+ $this->cont[7] = (int)$cont[7];
+ $this->dieContinueUsageIf( $cont[7] !== (string)$this->cont[7] );
+
+ /* Fall through */
+
+ case 7:
+ // top-level page ID for result adding
+ $this->cont[6] = (int)$cont[6];
+ $this->dieContinueUsageIf( $cont[6] !== (string)$this->cont[6] );
+
+ /* Fall through */
+
+ case 6:
+ // ns for 2nd query (even for imageusage)
+ $this->cont[2] = (int)$cont[2];
+ $this->dieContinueUsageIf( $cont[2] !== (string)$this->cont[2] );
+
+ // title for 2nd query
+ $this->cont[3] = $cont[3];
+
+ // from_ns for 2nd query
+ $this->cont[4] = (int)$cont[4];
+ $this->dieContinueUsageIf( $cont[4] !== (string)$this->cont[4] );
+
+ // from_id for 1st query
+ $this->cont[5] = (int)$cont[5];
+ $this->dieContinueUsageIf( $cont[5] !== (string)$this->cont[5] );
+
+ /* Fall through */
+
+ case 2:
+ // from_ns for 1st query
+ $this->cont[0] = (int)$cont[0];
+ $this->dieContinueUsageIf( $cont[0] !== (string)$this->cont[0] );
+
+ // from_id for 1st query
+ $this->cont[1] = (int)$cont[1];
+ $this->dieContinueUsageIf( $cont[1] !== (string)$this->cont[1] );
+
+ break;
+
+ default:
+ $this->dieContinueUsageIf( true );
+ }
+
+ ksort( $this->cont );
+ }
+
+ $this->runFirstQuery( $resultPageSet );
+ if ( $this->redirect && count( $this->redirTitles ) ) {
+ $this->resetQueryParams();
+ $this->runSecondQuery( $resultPageSet );
+ }
+
+ // Fill in any missing fields in case it's needed below
+ $this->cont += [ 0, 0, 0, '', 0, 0, 0 ];
+
+ if ( is_null( $resultPageSet ) ) {
+ // Try to add the result data in one go and pray that it fits
+ $code = $this->bl_code;
+ $data = array_map( function ( $arr ) use ( $result, $code ) {
+ if ( isset( $arr['redirlinks'] ) ) {
+ $arr['redirlinks'] = array_values( $arr['redirlinks'] );
+ ApiResult::setIndexedTagName( $arr['redirlinks'], $code );
+ }
+ return $arr;
+ }, array_values( $this->resultArr ) );
+ $fit = $result->addValue( 'query', $this->getModuleName(), $data );
+ if ( !$fit ) {
+ // It didn't fit. Add elements one by one until the
+ // result is full.
+ ksort( $this->resultArr );
+ if ( count( $this->cont ) >= 7 ) {
+ $startAt = $this->cont[6];
+ } else {
+ reset( $this->resultArr );
+ $startAt = key( $this->resultArr );
+ }
+ $idx = 0;
+ foreach ( $this->resultArr as $pageID => $arr ) {
+ if ( $pageID < $startAt ) {
+ continue;
+ }
+
+ // Add the basic entry without redirlinks first
+ $fit = $result->addValue(
+ [ 'query', $this->getModuleName() ],
+ $idx, array_diff_key( $arr, [ 'redirlinks' => '' ] ) );
+ if ( !$fit ) {
+ $this->continueStr = implode( '|', array_slice( $this->cont, 0, 6 ) ) .
+ "|$pageID";
+ break;
+ }
+
+ $hasRedirs = false;
+ $redirLinks = isset( $arr['redirlinks'] ) ? (array)$arr['redirlinks'] : [];
+ ksort( $redirLinks );
+ if ( count( $this->cont ) >= 8 && $pageID == $startAt ) {
+ $redirStartAt = $this->cont[7];
+ } else {
+ reset( $redirLinks );
+ $redirStartAt = key( $redirLinks );
+ }
+ foreach ( $redirLinks as $key => $redir ) {
+ if ( $key < $redirStartAt ) {
+ continue;
+ }
+
+ $fit = $result->addValue(
+ [ 'query', $this->getModuleName(), $idx, 'redirlinks' ],
+ null, $redir );
+ if ( !$fit ) {
+ $this->continueStr = implode( '|', array_slice( $this->cont, 0, 6 ) ) .
+ "|$pageID|$key";
+ break;
+ }
+ $hasRedirs = true;
+ }
+ if ( $hasRedirs ) {
+ $result->addIndexedTagName(
+ [ 'query', $this->getModuleName(), $idx, 'redirlinks' ],
+ $this->bl_code );
+ }
+ if ( !$fit ) {
+ break;
+ }
+
+ $idx++;
+ }
+ }
+
+ $result->addIndexedTagName(
+ [ 'query', $this->getModuleName() ],
+ $this->bl_code
+ );
+ }
+ if ( !is_null( $this->continueStr ) ) {
+ $this->setContinueEnumParameter( 'continue', $this->continueStr );
+ }
+ }
+
+ public function getAllowedParams() {
+ $retval = [
+ 'title' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace'
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ 'filterredir' => [
+ ApiBase::PARAM_DFLT => 'all',
+ ApiBase::PARAM_TYPE => [
+ 'all',
+ 'redirects',
+ 'nonredirects'
+ ]
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ]
+ ];
+ if ( $this->getModuleName() == 'embeddedin' ) {
+ return $retval;
+ }
+ $retval['redirect'] = false;
+
+ return $retval;
+ }
+
+ protected function getExamplesMessages() {
+ static $examples = [
+ 'backlinks' => [
+ 'action=query&list=backlinks&bltitle=Main%20Page'
+ => 'apihelp-query+backlinks-example-simple',
+ 'action=query&generator=backlinks&gbltitle=Main%20Page&prop=info'
+ => 'apihelp-query+backlinks-example-generator',
+ ],
+ 'embeddedin' => [
+ 'action=query&list=embeddedin&eititle=Template:Stub'
+ => 'apihelp-query+embeddedin-example-simple',
+ 'action=query&generator=embeddedin&geititle=Template:Stub&prop=info'
+ => 'apihelp-query+embeddedin-example-generator',
+ ],
+ 'imageusage' => [
+ 'action=query&list=imageusage&iutitle=File:Albert%20Einstein%20Head.jpg'
+ => 'apihelp-query+imageusage-example-simple',
+ 'action=query&generator=imageusage&giutitle=File:Albert%20Einstein%20Head.jpg&prop=info'
+ => 'apihelp-query+imageusage-example-generator',
+ ]
+ ];
+
+ return $examples[$this->getModuleName()];
+ }
+
+ public function getHelpUrls() {
+ return $this->helpUrl;
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryBacklinksprop.php b/www/wiki/includes/api/ApiQueryBacklinksprop.php
new file mode 100644
index 00000000..1db15f87
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryBacklinksprop.php
@@ -0,0 +1,437 @@
+<?php
+/**
+ * API module to handle links table back-queries
+ *
+ * Created on Aug 19, 2014
+ *
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.24
+ */
+
+/**
+ * This implements prop=redirects, prop=linkshere, prop=catmembers,
+ * prop=transcludedin, and prop=fileusage
+ *
+ * @ingroup API
+ * @since 1.24
+ */
+class ApiQueryBacklinksprop extends ApiQueryGeneratorBase {
+
+ // Data for the various modules implemented by this class
+ private static $settings = [
+ 'redirects' => [
+ 'code' => 'rd',
+ 'prefix' => 'rd',
+ 'linktable' => 'redirect',
+ 'props' => [
+ 'fragment',
+ ],
+ 'showredirects' => false,
+ 'show' => [
+ 'fragment',
+ '!fragment',
+ ],
+ ],
+ 'linkshere' => [
+ 'code' => 'lh',
+ 'prefix' => 'pl',
+ 'linktable' => 'pagelinks',
+ 'indexes' => [ 'pl_namespace', 'pl_backlinks_namespace' ],
+ 'from_namespace' => true,
+ 'showredirects' => true,
+ ],
+ 'transcludedin' => [
+ 'code' => 'ti',
+ 'prefix' => 'tl',
+ 'linktable' => 'templatelinks',
+ 'indexes' => [ 'tl_namespace', 'tl_backlinks_namespace' ],
+ 'from_namespace' => true,
+ 'showredirects' => true,
+ ],
+ 'fileusage' => [
+ 'code' => 'fu',
+ 'prefix' => 'il',
+ 'linktable' => 'imagelinks',
+ 'indexes' => [ 'il_to', 'il_backlinks_namespace' ],
+ 'from_namespace' => true,
+ 'to_namespace' => NS_FILE,
+ 'exampletitle' => 'File:Example.jpg',
+ 'showredirects' => true,
+ ],
+ ];
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, self::$settings[$moduleName]['code'] );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ */
+ private function run( ApiPageSet $resultPageSet = null ) {
+ $settings = self::$settings[$this->getModuleName()];
+
+ $db = $this->getDB();
+ $params = $this->extractRequestParams();
+ $prop = array_flip( $params['prop'] );
+ $emptyString = $db->addQuotes( '' );
+
+ $pageSet = $this->getPageSet();
+ $titles = $pageSet->getGoodAndMissingTitles();
+ $map = $pageSet->getGoodAndMissingTitlesByNamespace();
+
+ // Add in special pages, they can theoretically have backlinks too.
+ // (although currently they only do for prop=redirects)
+ foreach ( $pageSet->getSpecialTitles() as $id => $title ) {
+ $titles[] = $title;
+ $map[$title->getNamespace()][$title->getDBkey()] = $id;
+ }
+
+ // Determine our fields to query on
+ $p = $settings['prefix'];
+ $hasNS = !isset( $settings['to_namespace'] );
+ if ( $hasNS ) {
+ $bl_namespace = "{$p}_namespace";
+ $bl_title = "{$p}_title";
+ } else {
+ $bl_namespace = $settings['to_namespace'];
+ $bl_title = "{$p}_to";
+
+ $titles = array_filter( $titles, function ( $t ) use ( $bl_namespace ) {
+ return $t->getNamespace() === $bl_namespace;
+ } );
+ $map = array_intersect_key( $map, [ $bl_namespace => true ] );
+ }
+ $bl_from = "{$p}_from";
+
+ if ( !$titles ) {
+ return; // nothing to do
+ }
+
+ // Figure out what we're sorting by, and add associated WHERE clauses.
+ // MySQL's query planner screws up if we include a field in ORDER BY
+ // when it's constant in WHERE, so we have to test that for each field.
+ $sortby = [];
+ if ( $hasNS && count( $map ) > 1 ) {
+ $sortby[$bl_namespace] = 'ns';
+ }
+ $theTitle = null;
+ foreach ( $map as $nsTitles ) {
+ reset( $nsTitles );
+ $key = key( $nsTitles );
+ if ( $theTitle === null ) {
+ $theTitle = $key;
+ }
+ if ( count( $nsTitles ) > 1 || $key !== $theTitle ) {
+ $sortby[$bl_title] = 'title';
+ break;
+ }
+ }
+ $miser_ns = null;
+ if ( $params['namespace'] !== null ) {
+ if ( empty( $settings['from_namespace'] ) ) {
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $miser_ns = $params['namespace'];
+ } else {
+ $this->addWhereFld( 'page_namespace', $params['namespace'] );
+ }
+ } else {
+ $this->addWhereFld( "{$p}_from_namespace", $params['namespace'] );
+ if ( !empty( $settings['from_namespace'] ) && count( $params['namespace'] ) > 1 ) {
+ $sortby["{$p}_from_namespace"] = 'int';
+ }
+ }
+ }
+ $sortby[$bl_from] = 'int';
+
+ // Now use the $sortby to figure out the continuation
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != count( $sortby ) );
+ $where = '';
+ $i = count( $sortby ) - 1;
+ foreach ( array_reverse( $sortby, true ) as $field => $type ) {
+ $v = $cont[$i];
+ switch ( $type ) {
+ case 'ns':
+ case 'int':
+ $v = (int)$v;
+ $this->dieContinueUsageIf( $v != $cont[$i] );
+ break;
+ default:
+ $v = $db->addQuotes( $v );
+ break;
+ }
+
+ if ( $where === '' ) {
+ $where = "$field >= $v";
+ } else {
+ $where = "$field > $v OR ($field = $v AND ($where))";
+ }
+
+ $i--;
+ }
+ $this->addWhere( $where );
+ }
+
+ // Populate the rest of the query
+ $this->addTables( [ $settings['linktable'], 'page' ] );
+ $this->addWhere( "$bl_from = page_id" );
+
+ if ( $this->getModuleName() === 'redirects' ) {
+ $this->addWhere( "rd_interwiki = $emptyString OR rd_interwiki IS NULL" );
+ }
+
+ $this->addFields( array_keys( $sortby ) );
+ $this->addFields( [ 'bl_namespace' => $bl_namespace, 'bl_title' => $bl_title ] );
+ if ( is_null( $resultPageSet ) ) {
+ $fld_pageid = isset( $prop['pageid'] );
+ $fld_title = isset( $prop['title'] );
+ $fld_redirect = isset( $prop['redirect'] );
+
+ $this->addFieldsIf( 'page_id', $fld_pageid );
+ $this->addFieldsIf( [ 'page_title', 'page_namespace' ], $fld_title );
+ $this->addFieldsIf( 'page_is_redirect', $fld_redirect );
+
+ // prop=redirects
+ $fld_fragment = isset( $prop['fragment'] );
+ $this->addFieldsIf( 'rd_fragment', $fld_fragment );
+ } else {
+ $this->addFields( $resultPageSet->getPageTableFields() );
+ }
+
+ $this->addFieldsIf( 'page_namespace', $miser_ns !== null );
+
+ if ( $hasNS ) {
+ // Can't use LinkBatch because it throws away Special titles.
+ // And we already have the needed data structure anyway.
+ $this->addWhere( $db->makeWhereFrom2d( $map, $bl_namespace, $bl_title ) );
+ } else {
+ $where = [];
+ foreach ( $titles as $t ) {
+ if ( $t->getNamespace() == $bl_namespace ) {
+ $where[] = "$bl_title = " . $db->addQuotes( $t->getDBkey() );
+ }
+ }
+ $this->addWhere( $db->makeList( $where, LIST_OR ) );
+ }
+
+ if ( $params['show'] !== null ) {
+ // prop=redirects only
+ $show = array_flip( $params['show'] );
+ if ( isset( $show['fragment'] ) && isset( $show['!fragment'] ) ||
+ isset( $show['redirect'] ) && isset( $show['!redirect'] )
+ ) {
+ $this->dieWithError( 'apierror-show' );
+ }
+ $this->addWhereIf( "rd_fragment != $emptyString", isset( $show['fragment'] ) );
+ $this->addWhereIf(
+ "rd_fragment = $emptyString OR rd_fragment IS NULL",
+ isset( $show['!fragment'] )
+ );
+ $this->addWhereIf( [ 'page_is_redirect' => 1 ], isset( $show['redirect'] ) );
+ $this->addWhereIf( [ 'page_is_redirect' => 0 ], isset( $show['!redirect'] ) );
+ }
+
+ // Override any ORDER BY from above with what we calculated earlier.
+ $this->addOption( 'ORDER BY', array_keys( $sortby ) );
+
+ // MySQL's optimizer chokes if we have too many values in "$bl_title IN
+ // (...)" and chooses the wrong index, so specify the correct index to
+ // use for the query. See T139056 for details.
+ if ( !empty( $settings['indexes'] ) ) {
+ list( $idxNoFromNS, $idxWithFromNS ) = $settings['indexes'];
+ if ( $params['namespace'] !== null && !empty( $settings['from_namespace'] ) ) {
+ $this->addOption( 'USE INDEX', [ $settings['linktable'] => $idxWithFromNS ] );
+ } else {
+ $this->addOption( 'USE INDEX', [ $settings['linktable'] => $idxNoFromNS ] );
+ }
+ }
+
+ // MySQL (or at least 5.5.5-10.0.23-MariaDB) chooses a really bad query
+ // plan if it thinks there will be more matching rows in the linktable
+ // than are in page. Use STRAIGHT_JOIN here to force it to use the
+ // intended, fast plan. See T145079 for details.
+ $this->addOption( 'STRAIGHT_JOIN' );
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+
+ $res = $this->select( __METHOD__ );
+
+ if ( is_null( $resultPageSet ) ) {
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinue( $row, $sortby );
+ break;
+ }
+
+ if ( $miser_ns !== null && !in_array( $row->page_namespace, $miser_ns ) ) {
+ // Miser mode namespace check
+ continue;
+ }
+
+ // Get the ID of the current page
+ $id = $map[$row->bl_namespace][$row->bl_title];
+
+ $vals = [];
+ if ( $fld_pageid ) {
+ $vals['pageid'] = (int)$row->page_id;
+ }
+ if ( $fld_title ) {
+ ApiQueryBase::addTitleInfo( $vals,
+ Title::makeTitle( $row->page_namespace, $row->page_title )
+ );
+ }
+ if ( $fld_fragment && $row->rd_fragment !== null && $row->rd_fragment !== '' ) {
+ $vals['fragment'] = $row->rd_fragment;
+ }
+ if ( $fld_redirect ) {
+ $vals['redirect'] = (bool)$row->page_is_redirect;
+ }
+ $fit = $this->addPageSubItem( $id, $vals );
+ if ( !$fit ) {
+ $this->setContinue( $row, $sortby );
+ break;
+ }
+ }
+ } else {
+ $titles = [];
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinue( $row, $sortby );
+ break;
+ }
+ $titles[] = Title::makeTitle( $row->page_namespace, $row->page_title );
+ }
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ private function setContinue( $row, $sortby ) {
+ $cont = [];
+ foreach ( $sortby as $field => $v ) {
+ $cont[] = $row->$field;
+ }
+ $this->setContinueEnumParameter( 'continue', implode( '|', $cont ) );
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ $settings = self::$settings[$this->getModuleName()];
+
+ $ret = [
+ 'prop' => [
+ ApiBase::PARAM_TYPE => [
+ 'pageid',
+ 'title',
+ ],
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'pageid|title',
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ],
+ 'show' => null, // Will be filled/removed below
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+
+ if ( empty( $settings['from_namespace'] ) && $this->getConfig()->get( 'MiserMode' ) ) {
+ $ret['namespace'][ApiBase::PARAM_HELP_MSG_APPEND] = [
+ 'api-help-param-limited-in-miser-mode',
+ ];
+ }
+
+ if ( !empty( $settings['showredirects'] ) ) {
+ $ret['prop'][ApiBase::PARAM_TYPE][] = 'redirect';
+ $ret['prop'][ApiBase::PARAM_DFLT] .= '|redirect';
+ }
+ if ( isset( $settings['props'] ) ) {
+ $ret['prop'][ApiBase::PARAM_TYPE] = array_merge(
+ $ret['prop'][ApiBase::PARAM_TYPE], $settings['props']
+ );
+ }
+
+ $show = [];
+ if ( !empty( $settings['showredirects'] ) ) {
+ $show[] = 'redirect';
+ $show[] = '!redirect';
+ }
+ if ( isset( $settings['show'] ) ) {
+ $show = array_merge( $show, $settings['show'] );
+ }
+ if ( $show ) {
+ $ret['show'] = [
+ ApiBase::PARAM_TYPE => $show,
+ ApiBase::PARAM_ISMULTI => true,
+ ];
+ } else {
+ unset( $ret['show'] );
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ $settings = self::$settings[$this->getModuleName()];
+ $name = $this->getModuleName();
+ $path = $this->getModulePath();
+ $title = isset( $settings['exampletitle'] ) ? $settings['exampletitle'] : 'Main Page';
+ $etitle = rawurlencode( $title );
+
+ return [
+ "action=query&prop={$name}&titles={$etitle}"
+ => "apihelp-$path-example-simple",
+ "action=query&generator={$name}&titles={$etitle}&prop=info"
+ => "apihelp-$path-example-generator",
+ ];
+ }
+
+ public function getHelpUrls() {
+ $name = ucfirst( $this->getModuleName() );
+ return "https://www.mediawiki.org/wiki/Special:MyLanguage/API:{$name}";
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryBase.php b/www/wiki/includes/api/ApiQueryBase.php
new file mode 100644
index 00000000..6987dfb1
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryBase.php
@@ -0,0 +1,616 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 7, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * This is a base class for all Query modules.
+ * It provides some common functionality such as constructing various SQL
+ * queries.
+ *
+ * @ingroup API
+ */
+abstract class ApiQueryBase extends ApiBase {
+
+ private $mQueryModule, $mDb, $tables, $where, $fields, $options, $join_conds;
+
+ /**
+ * @param ApiQuery $queryModule
+ * @param string $moduleName
+ * @param string $paramPrefix
+ */
+ public function __construct( ApiQuery $queryModule, $moduleName, $paramPrefix = '' ) {
+ parent::__construct( $queryModule->getMain(), $moduleName, $paramPrefix );
+ $this->mQueryModule = $queryModule;
+ $this->mDb = null;
+ $this->resetQueryParams();
+ }
+
+ /************************************************************************//**
+ * @name Methods to implement
+ * @{
+ */
+
+ /**
+ * Get the cache mode for the data generated by this module. Override
+ * this in the module subclass. For possible return values and other
+ * details about cache modes, see ApiMain::setCacheMode()
+ *
+ * Public caching will only be allowed if *all* the modules that supply
+ * data for a given request return a cache mode of public.
+ *
+ * @param array $params
+ * @return string
+ */
+ public function getCacheMode( $params ) {
+ return 'private';
+ }
+
+ /**
+ * Override this method to request extra fields from the pageSet
+ * using $pageSet->requestField('fieldName')
+ *
+ * Note this only makes sense for 'prop' modules, as 'list' and 'meta'
+ * modules should not be using the pageset.
+ *
+ * @param ApiPageSet $pageSet
+ */
+ public function requestExtraData( $pageSet ) {
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Data access
+ * @{
+ */
+
+ /**
+ * Get the main Query module
+ * @return ApiQuery
+ */
+ public function getQuery() {
+ return $this->mQueryModule;
+ }
+
+ /** @inheritDoc */
+ public function getParent() {
+ return $this->getQuery();
+ }
+
+ /**
+ * Get the Query database connection (read-only)
+ * @return IDatabase
+ */
+ protected function getDB() {
+ if ( is_null( $this->mDb ) ) {
+ $this->mDb = $this->getQuery()->getDB();
+ }
+
+ return $this->mDb;
+ }
+
+ /**
+ * Selects the query database connection with the given name.
+ * See ApiQuery::getNamedDB() for more information
+ * @param string $name Name to assign to the database connection
+ * @param int $db One of the DB_* constants
+ * @param string|string[] $groups Query groups
+ * @return IDatabase
+ */
+ public function selectNamedDB( $name, $db, $groups ) {
+ $this->mDb = $this->getQuery()->getNamedDB( $name, $db, $groups );
+ return $this->mDb;
+ }
+
+ /**
+ * Get the PageSet object to work on
+ * @return ApiPageSet
+ */
+ protected function getPageSet() {
+ return $this->getQuery()->getPageSet();
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Querying
+ * @{
+ */
+
+ /**
+ * Blank the internal arrays with query parameters
+ */
+ protected function resetQueryParams() {
+ $this->tables = [];
+ $this->where = [];
+ $this->fields = [];
+ $this->options = [];
+ $this->join_conds = [];
+ }
+
+ /**
+ * Add a set of tables to the internal array
+ * @param string|string[] $tables Table name or array of table names
+ * @param string|null $alias Table alias, or null for no alias. Cannot be
+ * used with multiple tables
+ */
+ protected function addTables( $tables, $alias = null ) {
+ if ( is_array( $tables ) ) {
+ if ( !is_null( $alias ) ) {
+ ApiBase::dieDebug( __METHOD__, 'Multiple table aliases not supported' );
+ }
+ $this->tables = array_merge( $this->tables, $tables );
+ } else {
+ if ( !is_null( $alias ) ) {
+ $this->tables[$alias] = $tables;
+ } else {
+ $this->tables[] = $tables;
+ }
+ }
+ }
+
+ /**
+ * Add a set of JOIN conditions to the internal array
+ *
+ * JOIN conditions are formatted as [ tablename => [ jointype, conditions ] ]
+ * e.g. [ 'page' => [ 'LEFT JOIN', 'page_id=rev_page' ] ].
+ * Conditions may be a string or an addWhere()-style array.
+ * @param array $join_conds JOIN conditions
+ */
+ protected function addJoinConds( $join_conds ) {
+ if ( !is_array( $join_conds ) ) {
+ ApiBase::dieDebug( __METHOD__, 'Join conditions have to be arrays' );
+ }
+ $this->join_conds = array_merge( $this->join_conds, $join_conds );
+ }
+
+ /**
+ * Add a set of fields to select to the internal array
+ * @param array|string $value Field name or array of field names
+ */
+ protected function addFields( $value ) {
+ if ( is_array( $value ) ) {
+ $this->fields = array_merge( $this->fields, $value );
+ } else {
+ $this->fields[] = $value;
+ }
+ }
+
+ /**
+ * Same as addFields(), but add the fields only if a condition is met
+ * @param array|string $value See addFields()
+ * @param bool $condition If false, do nothing
+ * @return bool $condition
+ */
+ protected function addFieldsIf( $value, $condition ) {
+ if ( $condition ) {
+ $this->addFields( $value );
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Add a set of WHERE clauses to the internal array.
+ * Clauses can be formatted as 'foo=bar' or [ 'foo' => 'bar' ],
+ * the latter only works if the value is a constant (i.e. not another field)
+ *
+ * If $value is an empty array, this function does nothing.
+ *
+ * For example, [ 'foo=bar', 'baz' => 3, 'bla' => 'foo' ] translates
+ * to "foo=bar AND baz='3' AND bla='foo'"
+ * @param string|array $value
+ */
+ protected function addWhere( $value ) {
+ if ( is_array( $value ) ) {
+ // Sanity check: don't insert empty arrays,
+ // Database::makeList() chokes on them
+ if ( count( $value ) ) {
+ $this->where = array_merge( $this->where, $value );
+ }
+ } else {
+ $this->where[] = $value;
+ }
+ }
+
+ /**
+ * Same as addWhere(), but add the WHERE clauses only if a condition is met
+ * @param string|array $value
+ * @param bool $condition If false, do nothing
+ * @return bool $condition
+ */
+ protected function addWhereIf( $value, $condition ) {
+ if ( $condition ) {
+ $this->addWhere( $value );
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Equivalent to addWhere(array($field => $value))
+ * @param string $field Field name
+ * @param string|string[] $value Value; ignored if null or empty array;
+ */
+ protected function addWhereFld( $field, $value ) {
+ // Use count() to its full documented capabilities to simultaneously
+ // test for null, empty array or empty countable object
+ if ( count( $value ) ) {
+ $this->where[$field] = $value;
+ }
+ }
+
+ /**
+ * Add a WHERE clause corresponding to a range, and an ORDER BY
+ * clause to sort in the right direction
+ * @param string $field Field name
+ * @param string $dir If 'newer', sort in ascending order, otherwise
+ * sort in descending order
+ * @param string $start Value to start the list at. If $dir == 'newer'
+ * this is the lower boundary, otherwise it's the upper boundary
+ * @param string $end Value to end the list at. If $dir == 'newer' this
+ * is the upper boundary, otherwise it's the lower boundary
+ * @param bool $sort If false, don't add an ORDER BY clause
+ */
+ protected function addWhereRange( $field, $dir, $start, $end, $sort = true ) {
+ $isDirNewer = ( $dir === 'newer' );
+ $after = ( $isDirNewer ? '>=' : '<=' );
+ $before = ( $isDirNewer ? '<=' : '>=' );
+ $db = $this->getDB();
+
+ if ( !is_null( $start ) ) {
+ $this->addWhere( $field . $after . $db->addQuotes( $start ) );
+ }
+
+ if ( !is_null( $end ) ) {
+ $this->addWhere( $field . $before . $db->addQuotes( $end ) );
+ }
+
+ if ( $sort ) {
+ $order = $field . ( $isDirNewer ? '' : ' DESC' );
+ // Append ORDER BY
+ $optionOrderBy = isset( $this->options['ORDER BY'] )
+ ? (array)$this->options['ORDER BY']
+ : [];
+ $optionOrderBy[] = $order;
+ $this->addOption( 'ORDER BY', $optionOrderBy );
+ }
+ }
+
+ /**
+ * Add a WHERE clause corresponding to a range, similar to addWhereRange,
+ * but converts $start and $end to database timestamps.
+ * @see addWhereRange
+ * @param string $field
+ * @param string $dir
+ * @param string $start
+ * @param string $end
+ * @param bool $sort
+ */
+ protected function addTimestampWhereRange( $field, $dir, $start, $end, $sort = true ) {
+ $db = $this->getDB();
+ $this->addWhereRange( $field, $dir,
+ $db->timestampOrNull( $start ), $db->timestampOrNull( $end ), $sort );
+ }
+
+ /**
+ * Add an option such as LIMIT or USE INDEX. If an option was set
+ * before, the old value will be overwritten
+ * @param string $name Option name
+ * @param string|string[] $value Option value
+ */
+ protected function addOption( $name, $value = null ) {
+ if ( is_null( $value ) ) {
+ $this->options[] = $name;
+ } else {
+ $this->options[$name] = $value;
+ }
+ }
+
+ /**
+ * Execute a SELECT query based on the values in the internal arrays
+ * @param string $method Function the query should be attributed to.
+ * You should usually use __METHOD__ here
+ * @param array $extraQuery Query data to add but not store in the object
+ * Format is [
+ * 'tables' => ...,
+ * 'fields' => ...,
+ * 'where' => ...,
+ * 'options' => ...,
+ * 'join_conds' => ...
+ * ]
+ * @param array|null &$hookData If set, the ApiQueryBaseBeforeQuery and
+ * ApiQueryBaseAfterQuery hooks will be called, and the
+ * ApiQueryBaseProcessRow hook will be expected.
+ * @return ResultWrapper
+ */
+ protected function select( $method, $extraQuery = [], array &$hookData = null ) {
+ $tables = array_merge(
+ $this->tables,
+ isset( $extraQuery['tables'] ) ? (array)$extraQuery['tables'] : []
+ );
+ $fields = array_merge(
+ $this->fields,
+ isset( $extraQuery['fields'] ) ? (array)$extraQuery['fields'] : []
+ );
+ $where = array_merge(
+ $this->where,
+ isset( $extraQuery['where'] ) ? (array)$extraQuery['where'] : []
+ );
+ $options = array_merge(
+ $this->options,
+ isset( $extraQuery['options'] ) ? (array)$extraQuery['options'] : []
+ );
+ $join_conds = array_merge(
+ $this->join_conds,
+ isset( $extraQuery['join_conds'] ) ? (array)$extraQuery['join_conds'] : []
+ );
+
+ if ( $hookData !== null ) {
+ Hooks::run( 'ApiQueryBaseBeforeQuery',
+ [ $this, &$tables, &$fields, &$where, &$options, &$join_conds, &$hookData ]
+ );
+ }
+
+ $res = $this->getDB()->select( $tables, $fields, $where, $method, $options, $join_conds );
+
+ if ( $hookData !== null ) {
+ Hooks::run( 'ApiQueryBaseAfterQuery', [ $this, $res, &$hookData ] );
+ }
+
+ return $res;
+ }
+
+ /**
+ * Call the ApiQueryBaseProcessRow hook
+ *
+ * Generally, a module that passed $hookData to self::select() will call
+ * this just before calling ApiResult::addValue(), and treat a false return
+ * here in the same way it treats a false return from addValue().
+ *
+ * @since 1.28
+ * @param object $row Database row
+ * @param array &$data Data to be added to the result
+ * @param array &$hookData Hook data from ApiQueryBase::select()
+ * @return bool Return false if row processing should end with continuation
+ */
+ protected function processRow( $row, array &$data, array &$hookData ) {
+ return Hooks::run( 'ApiQueryBaseProcessRow', [ $this, $row, &$data, &$hookData ] );
+ }
+
+ /**
+ * @param string $query
+ * @param string $protocol
+ * @return null|string
+ */
+ public function prepareUrlQuerySearchString( $query = null, $protocol = null ) {
+ $db = $this->getDB();
+ if ( !is_null( $query ) || $query != '' ) {
+ if ( is_null( $protocol ) ) {
+ $protocol = 'http://';
+ }
+
+ $likeQuery = LinkFilter::makeLikeArray( $query, $protocol );
+ if ( !$likeQuery ) {
+ $this->dieWithError( 'apierror-badquery' );
+ }
+
+ $likeQuery = LinkFilter::keepOneWildcard( $likeQuery );
+
+ return 'el_index ' . $db->buildLike( $likeQuery );
+ } elseif ( !is_null( $protocol ) ) {
+ return 'el_index ' . $db->buildLike( "$protocol", $db->anyString() );
+ }
+
+ return null;
+ }
+
+ /**
+ * Filters hidden users (where the user doesn't have the right to view them)
+ * Also adds relevant block information
+ *
+ * @param bool $showBlockInfo
+ * @return void
+ */
+ public function showHiddenUsersAddBlockInfo( $showBlockInfo ) {
+ $this->addTables( 'ipblocks' );
+ $this->addJoinConds( [
+ 'ipblocks' => [ 'LEFT JOIN', 'ipb_user=user_id' ],
+ ] );
+
+ $this->addFields( 'ipb_deleted' );
+
+ if ( $showBlockInfo ) {
+ $this->addFields( [
+ 'ipb_id',
+ 'ipb_by',
+ 'ipb_by_text',
+ 'ipb_expiry',
+ 'ipb_timestamp'
+ ] );
+ $commentQuery = CommentStore::newKey( 'ipb_reason' )->getJoin();
+ $this->addTables( $commentQuery['tables'] );
+ $this->addFields( $commentQuery['fields'] );
+ $this->addJoinConds( $commentQuery['joins'] );
+ }
+
+ // Don't show hidden names
+ if ( !$this->getUser()->isAllowed( 'hideuser' ) ) {
+ $this->addWhere( 'ipb_deleted = 0 OR ipb_deleted IS NULL' );
+ }
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Utility methods
+ * @{
+ */
+
+ /**
+ * Add information (title and namespace) about a Title object to a
+ * result array
+ * @param array &$arr Result array à la ApiResult
+ * @param Title $title
+ * @param string $prefix Module prefix
+ */
+ public static function addTitleInfo( &$arr, $title, $prefix = '' ) {
+ $arr[$prefix . 'ns'] = intval( $title->getNamespace() );
+ $arr[$prefix . 'title'] = $title->getPrefixedText();
+ }
+
+ /**
+ * Add a sub-element under the page element with the given page ID
+ * @param int $pageId Page ID
+ * @param array $data Data array à la ApiResult
+ * @return bool Whether the element fit in the result
+ */
+ protected function addPageSubItems( $pageId, $data ) {
+ $result = $this->getResult();
+ ApiResult::setIndexedTagName( $data, $this->getModulePrefix() );
+
+ return $result->addValue( [ 'query', 'pages', intval( $pageId ) ],
+ $this->getModuleName(),
+ $data );
+ }
+
+ /**
+ * Same as addPageSubItems(), but one element of $data at a time
+ * @param int $pageId Page ID
+ * @param array $item Data array à la ApiResult
+ * @param string $elemname XML element name. If null, getModuleName()
+ * is used
+ * @return bool Whether the element fit in the result
+ */
+ protected function addPageSubItem( $pageId, $item, $elemname = null ) {
+ if ( is_null( $elemname ) ) {
+ $elemname = $this->getModulePrefix();
+ }
+ $result = $this->getResult();
+ $fit = $result->addValue( [ 'query', 'pages', $pageId,
+ $this->getModuleName() ], null, $item );
+ if ( !$fit ) {
+ return false;
+ }
+ $result->addIndexedTagName( [ 'query', 'pages', $pageId,
+ $this->getModuleName() ], $elemname );
+
+ return true;
+ }
+
+ /**
+ * Set a query-continue value
+ * @param string $paramName Parameter name
+ * @param string|array $paramValue Parameter value
+ */
+ protected function setContinueEnumParameter( $paramName, $paramValue ) {
+ $this->getContinuationManager()->addContinueParam( $this, $paramName, $paramValue );
+ }
+
+ /**
+ * Convert an input title or title prefix into a dbkey.
+ *
+ * $namespace should always be specified in order to handle per-namespace
+ * capitalization settings.
+ *
+ * @param string $titlePart Title part
+ * @param int $namespace Namespace of the title
+ * @return string DBkey (no namespace prefix)
+ */
+ public function titlePartToKey( $titlePart, $namespace = NS_MAIN ) {
+ $t = Title::makeTitleSafe( $namespace, $titlePart . 'x' );
+ if ( !$t || $t->hasFragment() ) {
+ // Invalid title (e.g. bad chars) or contained a '#'.
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $titlePart ) ] );
+ }
+ if ( $namespace != $t->getNamespace() || $t->isExternal() ) {
+ // This can happen in two cases. First, if you call titlePartToKey with a title part
+ // that looks like a namespace, but with $defaultNamespace = NS_MAIN. It would be very
+ // difficult to handle such a case. Such cases cannot exist and are therefore treated
+ // as invalid user input. The second case is when somebody specifies a title interwiki
+ // prefix.
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $titlePart ) ] );
+ }
+
+ return substr( $t->getDBkey(), 0, -1 );
+ }
+
+ /**
+ * Convert an input title or title prefix into a namespace constant and dbkey.
+ *
+ * @since 1.26
+ * @param string $titlePart Title part
+ * @param int $defaultNamespace Default namespace if none is given
+ * @return array (int, string) Namespace number and DBkey
+ */
+ public function prefixedTitlePartToKey( $titlePart, $defaultNamespace = NS_MAIN ) {
+ $t = Title::newFromText( $titlePart . 'x', $defaultNamespace );
+ if ( !$t || $t->hasFragment() || $t->isExternal() ) {
+ // Invalid title (e.g. bad chars) or contained a '#'.
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $titlePart ) ] );
+ }
+
+ return [ $t->getNamespace(), substr( $t->getDBkey(), 0, -1 ) ];
+ }
+
+ /**
+ * @param string $hash
+ * @return bool
+ */
+ public function validateSha1Hash( $hash ) {
+ return (bool)preg_match( '/^[a-f0-9]{40}$/', $hash );
+ }
+
+ /**
+ * @param string $hash
+ * @return bool
+ */
+ public function validateSha1Base36Hash( $hash ) {
+ return (bool)preg_match( '/^[a-z0-9]{31}$/', $hash );
+ }
+
+ /**
+ * Check whether the current user has permission to view revision-deleted
+ * fields.
+ * @return bool
+ */
+ public function userCanSeeRevDel() {
+ return $this->getUser()->isAllowedAny(
+ 'deletedhistory',
+ 'deletedtext',
+ 'suppressrevision',
+ 'viewsuppressed'
+ );
+ }
+
+ /**@}*/
+}
diff --git a/www/wiki/includes/api/ApiQueryBlocks.php b/www/wiki/includes/api/ApiQueryBlocks.php
new file mode 100644
index 00000000..698c13c5
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryBlocks.php
@@ -0,0 +1,347 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 10, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate all user blocks
+ *
+ * @ingroup API
+ */
+class ApiQueryBlocks extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'bk' );
+ }
+
+ public function execute() {
+ $db = $this->getDB();
+ $commentStore = new CommentStore( 'ipb_reason' );
+ $params = $this->extractRequestParams();
+ $this->requireMaxOneParameter( $params, 'users', 'ip' );
+
+ $prop = array_flip( $params['prop'] );
+ $fld_id = isset( $prop['id'] );
+ $fld_user = isset( $prop['user'] );
+ $fld_userid = isset( $prop['userid'] );
+ $fld_by = isset( $prop['by'] );
+ $fld_byid = isset( $prop['byid'] );
+ $fld_timestamp = isset( $prop['timestamp'] );
+ $fld_expiry = isset( $prop['expiry'] );
+ $fld_reason = isset( $prop['reason'] );
+ $fld_range = isset( $prop['range'] );
+ $fld_flags = isset( $prop['flags'] );
+
+ $result = $this->getResult();
+
+ $this->addTables( 'ipblocks' );
+ $this->addFields( [ 'ipb_auto', 'ipb_id', 'ipb_timestamp' ] );
+
+ $this->addFieldsIf( [ 'ipb_address', 'ipb_user' ], $fld_user || $fld_userid );
+ $this->addFieldsIf( 'ipb_by_text', $fld_by );
+ $this->addFieldsIf( 'ipb_by', $fld_byid );
+ $this->addFieldsIf( 'ipb_expiry', $fld_expiry );
+ $this->addFieldsIf( [ 'ipb_range_start', 'ipb_range_end' ], $fld_range );
+ $this->addFieldsIf( [ 'ipb_anon_only', 'ipb_create_account', 'ipb_enable_autoblock',
+ 'ipb_block_email', 'ipb_deleted', 'ipb_allow_usertalk' ],
+ $fld_flags );
+
+ if ( $fld_reason ) {
+ $commentQuery = $commentStore->getJoin();
+ $this->addTables( $commentQuery['tables'] );
+ $this->addFields( $commentQuery['fields'] );
+ $this->addJoinConds( $commentQuery['joins'] );
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+ $this->addTimestampWhereRange(
+ 'ipb_timestamp',
+ $params['dir'],
+ $params['start'],
+ $params['end']
+ );
+ // Include in ORDER BY for uniqueness
+ $this->addWhereRange( 'ipb_id', $params['dir'], null, null );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $op = ( $params['dir'] == 'newer' ? '>' : '<' );
+ $continueTimestamp = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $continueId = (int)$cont[1];
+ $this->dieContinueUsageIf( $continueId != $cont[1] );
+ $this->addWhere( "ipb_timestamp $op $continueTimestamp OR " .
+ "(ipb_timestamp = $continueTimestamp AND " .
+ "ipb_id $op= $continueId)"
+ );
+ }
+
+ if ( isset( $params['ids'] ) ) {
+ $this->addWhereFld( 'ipb_id', $params['ids'] );
+ }
+ if ( isset( $params['users'] ) ) {
+ $usernames = [];
+ foreach ( (array)$params['users'] as $u ) {
+ $usernames[] = $this->prepareUsername( $u );
+ }
+ $this->addWhereFld( 'ipb_address', $usernames );
+ $this->addWhereFld( 'ipb_auto', 0 );
+ }
+ if ( isset( $params['ip'] ) ) {
+ $blockCIDRLimit = $this->getConfig()->get( 'BlockCIDRLimit' );
+ if ( IP::isIPv4( $params['ip'] ) ) {
+ $type = 'IPv4';
+ $cidrLimit = $blockCIDRLimit['IPv4'];
+ $prefixLen = 0;
+ } elseif ( IP::isIPv6( $params['ip'] ) ) {
+ $type = 'IPv6';
+ $cidrLimit = $blockCIDRLimit['IPv6'];
+ $prefixLen = 3; // IP::toHex output is prefixed with "v6-"
+ } else {
+ $this->dieWithError( 'apierror-badip', 'param_ip' );
+ }
+
+ # Check range validity, if it's a CIDR
+ list( $ip, $range ) = IP::parseCIDR( $params['ip'] );
+ if ( $ip !== false && $range !== false && $range < $cidrLimit ) {
+ $this->dieWithError( [ 'apierror-cidrtoobroad', $type, $cidrLimit ] );
+ }
+
+ # Let IP::parseRange handle calculating $upper, instead of duplicating the logic here.
+ list( $lower, $upper ) = IP::parseRange( $params['ip'] );
+
+ # Extract the common prefix to any rangeblock affecting this IP/CIDR
+ $prefix = substr( $lower, 0, $prefixLen + floor( $cidrLimit / 4 ) );
+
+ # Fairly hard to make a malicious SQL statement out of hex characters,
+ # but it is good practice to add quotes
+ $lower = $db->addQuotes( $lower );
+ $upper = $db->addQuotes( $upper );
+
+ $this->addWhere( [
+ 'ipb_range_start' . $db->buildLike( $prefix, $db->anyString() ),
+ 'ipb_range_start <= ' . $lower,
+ 'ipb_range_end >= ' . $upper,
+ 'ipb_auto' => 0
+ ] );
+ }
+
+ if ( !is_null( $params['show'] ) ) {
+ $show = array_flip( $params['show'] );
+
+ /* Check for conflicting parameters. */
+ if ( ( isset( $show['account'] ) && isset( $show['!account'] ) )
+ || ( isset( $show['ip'] ) && isset( $show['!ip'] ) )
+ || ( isset( $show['range'] ) && isset( $show['!range'] ) )
+ || ( isset( $show['temp'] ) && isset( $show['!temp'] ) )
+ ) {
+ $this->dieWithError( 'apierror-show' );
+ }
+
+ $this->addWhereIf( 'ipb_user = 0', isset( $show['!account'] ) );
+ $this->addWhereIf( 'ipb_user != 0', isset( $show['account'] ) );
+ $this->addWhereIf( 'ipb_user != 0 OR ipb_range_end > ipb_range_start', isset( $show['!ip'] ) );
+ $this->addWhereIf( 'ipb_user = 0 AND ipb_range_end = ipb_range_start', isset( $show['ip'] ) );
+ $this->addWhereIf( 'ipb_expiry = ' .
+ $db->addQuotes( $db->getInfinity() ), isset( $show['!temp'] ) );
+ $this->addWhereIf( 'ipb_expiry != ' .
+ $db->addQuotes( $db->getInfinity() ), isset( $show['temp'] ) );
+ $this->addWhereIf( 'ipb_range_end = ipb_range_start', isset( $show['!range'] ) );
+ $this->addWhereIf( 'ipb_range_end > ipb_range_start', isset( $show['range'] ) );
+ }
+
+ if ( !$this->getUser()->isAllowed( 'hideuser' ) ) {
+ $this->addWhereFld( 'ipb_deleted', 0 );
+ }
+
+ # Filter out expired rows
+ $this->addWhere( 'ipb_expiry > ' . $db->addQuotes( $db->timestamp() ) );
+
+ $res = $this->select( __METHOD__ );
+
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've had enough
+ $this->setContinueEnumParameter( 'continue', "$row->ipb_timestamp|$row->ipb_id" );
+ break;
+ }
+ $block = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ if ( $fld_id ) {
+ $block['id'] = (int)$row->ipb_id;
+ }
+ if ( $fld_user && !$row->ipb_auto ) {
+ $block['user'] = $row->ipb_address;
+ }
+ if ( $fld_userid && !$row->ipb_auto ) {
+ $block['userid'] = (int)$row->ipb_user;
+ }
+ if ( $fld_by ) {
+ $block['by'] = $row->ipb_by_text;
+ }
+ if ( $fld_byid ) {
+ $block['byid'] = (int)$row->ipb_by;
+ }
+ if ( $fld_timestamp ) {
+ $block['timestamp'] = wfTimestamp( TS_ISO_8601, $row->ipb_timestamp );
+ }
+ if ( $fld_expiry ) {
+ $block['expiry'] = ApiResult::formatExpiry( $row->ipb_expiry );
+ }
+ if ( $fld_reason ) {
+ $block['reason'] = $commentStore->getComment( $row )->text;
+ }
+ if ( $fld_range && !$row->ipb_auto ) {
+ $block['rangestart'] = IP::formatHex( $row->ipb_range_start );
+ $block['rangeend'] = IP::formatHex( $row->ipb_range_end );
+ }
+ if ( $fld_flags ) {
+ // For clarity, these flags use the same names as their action=block counterparts
+ $block['automatic'] = (bool)$row->ipb_auto;
+ $block['anononly'] = (bool)$row->ipb_anon_only;
+ $block['nocreate'] = (bool)$row->ipb_create_account;
+ $block['autoblock'] = (bool)$row->ipb_enable_autoblock;
+ $block['noemail'] = (bool)$row->ipb_block_email;
+ $block['hidden'] = (bool)$row->ipb_deleted;
+ $block['allowusertalk'] = (bool)$row->ipb_allow_usertalk;
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $block );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', "$row->ipb_timestamp|$row->ipb_id" );
+ break;
+ }
+ }
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'block' );
+ }
+
+ protected function prepareUsername( $user ) {
+ if ( !$user ) {
+ $encParamName = $this->encodeParamName( 'users' );
+ $this->dieWithError( [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $user ) ],
+ "baduser_{$encParamName}"
+ );
+ }
+ $name = User::isIP( $user )
+ ? $user
+ : User::getCanonicalName( $user, 'valid' );
+ if ( $name === false ) {
+ $encParamName = $this->encodeParamName( 'users' );
+ $this->dieWithError( [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $user ) ],
+ "baduser_{$encParamName}"
+ );
+ }
+ return $name;
+ }
+
+ public function getAllowedParams() {
+ $blockCIDRLimit = $this->getConfig()->get( 'BlockCIDRLimit' );
+
+ return [
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+ 'dir' => [
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'ids' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'users' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'ip' => [
+ ApiBase::PARAM_HELP_MSG => [
+ 'apihelp-query+blocks-param-ip',
+ $blockCIDRLimit['IPv4'],
+ $blockCIDRLimit['IPv6'],
+ ],
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'id|user|by|timestamp|expiry|reason|flags',
+ ApiBase::PARAM_TYPE => [
+ 'id',
+ 'user',
+ 'userid',
+ 'by',
+ 'byid',
+ 'timestamp',
+ 'expiry',
+ 'reason',
+ 'range',
+ 'flags'
+ ],
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'show' => [
+ ApiBase::PARAM_TYPE => [
+ 'account',
+ '!account',
+ 'temp',
+ '!temp',
+ 'ip',
+ '!ip',
+ 'range',
+ '!range',
+ ],
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=blocks'
+ => 'apihelp-query+blocks-example-simple',
+ 'action=query&list=blocks&bkusers=Alice|Bob'
+ => 'apihelp-query+blocks-example-users',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Blocks';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryCategories.php b/www/wiki/includes/api/ApiQueryCategories.php
new file mode 100644
index 00000000..c4428d57
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryCategories.php
@@ -0,0 +1,232 @@
+<?php
+/**
+ *
+ *
+ * Created on May 13, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query module to enumerate categories the set of pages belong to.
+ *
+ * @ingroup API
+ */
+class ApiQueryCategories extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'cl' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ */
+ private function run( $resultPageSet = null ) {
+ if ( $this->getPageSet()->getGoodTitleCount() == 0 ) {
+ return; // nothing to do
+ }
+
+ $params = $this->extractRequestParams();
+ $prop = array_flip( (array)$params['prop'] );
+ $show = array_flip( (array)$params['show'] );
+
+ $this->addFields( [
+ 'cl_from',
+ 'cl_to'
+ ] );
+
+ $this->addFieldsIf( [ 'cl_sortkey', 'cl_sortkey_prefix' ], isset( $prop['sortkey'] ) );
+ $this->addFieldsIf( 'cl_timestamp', isset( $prop['timestamp'] ) );
+
+ $this->addTables( 'categorylinks' );
+ $this->addWhereFld( 'cl_from', array_keys( $this->getPageSet()->getGoodTitles() ) );
+ if ( !is_null( $params['categories'] ) ) {
+ $cats = [];
+ foreach ( $params['categories'] as $cat ) {
+ $title = Title::newFromText( $cat );
+ if ( !$title || $title->getNamespace() != NS_CATEGORY ) {
+ $this->addWarning( [ 'apiwarn-invalidcategory', wfEscapeWikiText( $cat ) ] );
+ } else {
+ $cats[] = $title->getDBkey();
+ }
+ }
+ $this->addWhereFld( 'cl_to', $cats );
+ }
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $clfrom = intval( $cont[0] );
+ $clto = $this->getDB()->addQuotes( $cont[1] );
+ $this->addWhere(
+ "cl_from $op $clfrom OR " .
+ "(cl_from = $clfrom AND " .
+ "cl_to $op= $clto)"
+ );
+ }
+
+ if ( isset( $show['hidden'] ) && isset( $show['!hidden'] ) ) {
+ $this->dieWithError( 'apierror-show' );
+ }
+ if ( isset( $show['hidden'] ) || isset( $show['!hidden'] ) || isset( $prop['hidden'] ) ) {
+ $this->addOption( 'STRAIGHT_JOIN' );
+ $this->addTables( [ 'page', 'page_props' ] );
+ $this->addFieldsIf( 'pp_propname', isset( $prop['hidden'] ) );
+ $this->addJoinConds( [
+ 'page' => [ 'LEFT JOIN', [
+ 'page_namespace' => NS_CATEGORY,
+ 'page_title = cl_to' ] ],
+ 'page_props' => [ 'LEFT JOIN', [
+ 'pp_page=page_id',
+ 'pp_propname' => 'hiddencat' ] ]
+ ] );
+ if ( isset( $show['hidden'] ) ) {
+ $this->addWhere( [ 'pp_propname IS NOT NULL' ] );
+ } elseif ( isset( $show['!hidden'] ) ) {
+ $this->addWhere( [ 'pp_propname IS NULL' ] );
+ }
+ }
+
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ // Don't order by cl_from if it's constant in the WHERE clause
+ if ( count( $this->getPageSet()->getGoodTitles() ) == 1 ) {
+ $this->addOption( 'ORDER BY', 'cl_to' . $sort );
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'cl_from' . $sort,
+ 'cl_to' . $sort
+ ] );
+ }
+
+ $res = $this->select( __METHOD__ );
+
+ $count = 0;
+ if ( is_null( $resultPageSet ) ) {
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->cl_from . '|' . $row->cl_to );
+ break;
+ }
+
+ $title = Title::makeTitle( NS_CATEGORY, $row->cl_to );
+ $vals = [];
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ if ( isset( $prop['sortkey'] ) ) {
+ $vals['sortkey'] = bin2hex( $row->cl_sortkey );
+ $vals['sortkeyprefix'] = $row->cl_sortkey_prefix;
+ }
+ if ( isset( $prop['timestamp'] ) ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->cl_timestamp );
+ }
+ if ( isset( $prop['hidden'] ) ) {
+ $vals['hidden'] = !is_null( $row->pp_propname );
+ }
+
+ $fit = $this->addPageSubItem( $row->cl_from, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->cl_from . '|' . $row->cl_to );
+ break;
+ }
+ }
+ } else {
+ $titles = [];
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->cl_from . '|' . $row->cl_to );
+ break;
+ }
+
+ $titles[] = Title::makeTitle( NS_CATEGORY, $row->cl_to );
+ }
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'sortkey',
+ 'timestamp',
+ 'hidden',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'show' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'hidden',
+ '!hidden',
+ ]
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'categories' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=categories&titles=Albert%20Einstein'
+ => 'apihelp-query+categories-example-simple',
+ 'action=query&generator=categories&titles=Albert%20Einstein&prop=info'
+ => 'apihelp-query+categories-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Categories';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryCategoryInfo.php b/www/wiki/includes/api/ApiQueryCategoryInfo.php
new file mode 100644
index 00000000..25e9b274
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryCategoryInfo.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ *
+ *
+ * Created on May 13, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This query adds the "<categories>" subelement to all pages with the list of
+ * categories the page is in.
+ *
+ * @ingroup API
+ */
+class ApiQueryCategoryInfo extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ci' );
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $alltitles = $this->getPageSet()->getGoodAndMissingTitlesByNamespace();
+ if ( empty( $alltitles[NS_CATEGORY] ) ) {
+ return;
+ }
+ $categories = $alltitles[NS_CATEGORY];
+
+ $titles = $this->getPageSet()->getGoodAndMissingTitles();
+ $cattitles = [];
+ foreach ( $categories as $c ) {
+ /** @var Title $t */
+ $t = $titles[$c];
+ $cattitles[$c] = $t->getDBkey();
+ }
+
+ $this->addTables( [ 'category', 'page', 'page_props' ] );
+ $this->addJoinConds( [
+ 'page' => [ 'LEFT JOIN', [
+ 'page_namespace' => NS_CATEGORY,
+ 'page_title=cat_title' ] ],
+ 'page_props' => [ 'LEFT JOIN', [
+ 'pp_page=page_id',
+ 'pp_propname' => 'hiddencat' ] ],
+ ] );
+
+ $this->addFields( [
+ 'cat_title',
+ 'cat_pages',
+ 'cat_subcats',
+ 'cat_files',
+ 'cat_hidden' => 'pp_propname'
+ ] );
+ $this->addWhere( [ 'cat_title' => $cattitles ] );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $title = $this->getDB()->addQuotes( $params['continue'] );
+ $this->addWhere( "cat_title >= $title" );
+ }
+ $this->addOption( 'ORDER BY', 'cat_title' );
+
+ $res = $this->select( __METHOD__ );
+
+ $catids = array_flip( $cattitles );
+ foreach ( $res as $row ) {
+ $vals = [];
+ $vals['size'] = intval( $row->cat_pages );
+ $vals['pages'] = $row->cat_pages - $row->cat_subcats - $row->cat_files;
+ $vals['files'] = intval( $row->cat_files );
+ $vals['subcats'] = intval( $row->cat_subcats );
+ $vals['hidden'] = (bool)$row->cat_hidden;
+ $fit = $this->addPageSubItems( $catids[$row->cat_title], $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->cat_title );
+ break;
+ }
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=categoryinfo&titles=Category:Foo|Category:Bar'
+ => 'apihelp-query+categoryinfo-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Categoryinfo';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryCategoryMembers.php b/www/wiki/includes/api/ApiQueryCategoryMembers.php
new file mode 100644
index 00000000..c570ec99
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryCategoryMembers.php
@@ -0,0 +1,396 @@
+<?php
+/**
+ *
+ *
+ * Created on June 14, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query module to enumerate pages that belong to a category.
+ *
+ * @ingroup API
+ */
+class ApiQueryCategoryMembers extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'cm' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param string $hexSortkey
+ * @return bool
+ */
+ private function validateHexSortkey( $hexSortkey ) {
+ // A hex sortkey has an unbound number of 2 letter pairs
+ return (bool)preg_match( '/^(?:[a-fA-F0-9]{2})*$/D', $hexSortkey );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+
+ $categoryTitle = $this->getTitleOrPageId( $params )->getTitle();
+ if ( $categoryTitle->getNamespace() != NS_CATEGORY ) {
+ $this->dieWithError( 'apierror-invalidcategory' );
+ }
+
+ $prop = array_flip( $params['prop'] );
+ $fld_ids = isset( $prop['ids'] );
+ $fld_title = isset( $prop['title'] );
+ $fld_sortkey = isset( $prop['sortkey'] );
+ $fld_sortkeyprefix = isset( $prop['sortkeyprefix'] );
+ $fld_timestamp = isset( $prop['timestamp'] );
+ $fld_type = isset( $prop['type'] );
+
+ if ( is_null( $resultPageSet ) ) {
+ $this->addFields( [ 'cl_from', 'cl_sortkey', 'cl_type', 'page_namespace', 'page_title' ] );
+ $this->addFieldsIf( 'page_id', $fld_ids );
+ $this->addFieldsIf( 'cl_sortkey_prefix', $fld_sortkeyprefix );
+ } else {
+ $this->addFields( $resultPageSet->getPageTableFields() ); // will include page_ id, ns, title
+ $this->addFields( [ 'cl_from', 'cl_sortkey', 'cl_type' ] );
+ }
+
+ $this->addFieldsIf( 'cl_timestamp', $fld_timestamp || $params['sort'] == 'timestamp' );
+
+ $this->addTables( [ 'page', 'categorylinks' ] ); // must be in this order for 'USE INDEX'
+
+ $this->addWhereFld( 'cl_to', $categoryTitle->getDBkey() );
+ $queryTypes = $params['type'];
+ $contWhere = false;
+
+ // Scanning large datasets for rare categories sucks, and I already told
+ // how to have efficient subcategory access :-) ~~~~ (oh well, domas)
+ $miser_ns = [];
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $miser_ns = $params['namespace'];
+ } else {
+ $this->addWhereFld( 'page_namespace', $params['namespace'] );
+ }
+
+ $dir = in_array( $params['dir'], [ 'asc', 'ascending', 'newer' ] ) ? 'newer' : 'older';
+
+ if ( $params['sort'] == 'timestamp' ) {
+ $this->addTimestampWhereRange( 'cl_timestamp',
+ $dir,
+ $params['start'],
+ $params['end'] );
+ // Include in ORDER BY for uniqueness
+ $this->addWhereRange( 'cl_from', $dir, null, null );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $op = ( $dir === 'newer' ? '>' : '<' );
+ $db = $this->getDB();
+ $continueTimestamp = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $continueFrom = (int)$cont[1];
+ $this->dieContinueUsageIf( $continueFrom != $cont[1] );
+ $this->addWhere( "cl_timestamp $op $continueTimestamp OR " .
+ "(cl_timestamp = $continueTimestamp AND " .
+ "cl_from $op= $continueFrom)"
+ );
+ }
+
+ $this->addOption( 'USE INDEX', 'cl_timestamp' );
+ } else {
+ if ( $params['continue'] ) {
+ $cont = explode( '|', $params['continue'], 3 );
+ $this->dieContinueUsageIf( count( $cont ) != 3 );
+
+ // Remove the types to skip from $queryTypes
+ $contTypeIndex = array_search( $cont[0], $queryTypes );
+ $queryTypes = array_slice( $queryTypes, $contTypeIndex );
+
+ // Add a WHERE clause for sortkey and from
+ $this->dieContinueUsageIf( !$this->validateHexSortkey( $cont[1] ) );
+ $escSortkey = $this->getDB()->addQuotes( hex2bin( $cont[1] ) );
+ $from = intval( $cont[2] );
+ $op = $dir == 'newer' ? '>' : '<';
+ // $contWhere is used further down
+ $contWhere = "cl_sortkey $op $escSortkey OR " .
+ "(cl_sortkey = $escSortkey AND " .
+ "cl_from $op= $from)";
+ // The below produces ORDER BY cl_sortkey, cl_from, possibly with DESC added to each of them
+ $this->addWhereRange( 'cl_sortkey', $dir, null, null );
+ $this->addWhereRange( 'cl_from', $dir, null, null );
+ } else {
+ if ( $params['startsortkeyprefix'] !== null ) {
+ $startsortkey = Collation::singleton()->getSortKey( $params['startsortkeyprefix'] );
+ } elseif ( $params['starthexsortkey'] !== null ) {
+ if ( !$this->validateHexSortkey( $params['starthexsortkey'] ) ) {
+ $encParamName = $this->encodeParamName( 'starthexsortkey' );
+ $this->dieWithError( [ 'apierror-badparameter', $encParamName ], "badvalue_$encParamName" );
+ }
+ $startsortkey = hex2bin( $params['starthexsortkey'] );
+ } else {
+ $startsortkey = $params['startsortkey'];
+ }
+ if ( $params['endsortkeyprefix'] !== null ) {
+ $endsortkey = Collation::singleton()->getSortKey( $params['endsortkeyprefix'] );
+ } elseif ( $params['endhexsortkey'] !== null ) {
+ if ( !$this->validateHexSortkey( $params['endhexsortkey'] ) ) {
+ $encParamName = $this->encodeParamName( 'endhexsortkey' );
+ $this->dieWithError( [ 'apierror-badparameter', $encParamName ], "badvalue_$encParamName" );
+ }
+ $endsortkey = hex2bin( $params['endhexsortkey'] );
+ } else {
+ $endsortkey = $params['endsortkey'];
+ }
+
+ // The below produces ORDER BY cl_sortkey, cl_from, possibly with DESC added to each of them
+ $this->addWhereRange( 'cl_sortkey',
+ $dir,
+ $startsortkey,
+ $endsortkey );
+ $this->addWhereRange( 'cl_from', $dir, null, null );
+ }
+ $this->addOption( 'USE INDEX', 'cl_sortkey' );
+ }
+
+ $this->addWhere( 'cl_from=page_id' );
+
+ $limit = $params['limit'];
+ $this->addOption( 'LIMIT', $limit + 1 );
+
+ if ( $params['sort'] == 'sortkey' ) {
+ // Run a separate SELECT query for each value of cl_type.
+ // This is needed because cl_type is an enum, and MySQL has
+ // inconsistencies between ORDER BY cl_type and
+ // WHERE cl_type >= 'foo' making proper paging impossible
+ // and unindexed.
+ $rows = [];
+ $first = true;
+ foreach ( $queryTypes as $type ) {
+ $extraConds = [ 'cl_type' => $type ];
+ if ( $first && $contWhere ) {
+ // Continuation condition. Only added to the
+ // first query, otherwise we'll skip things
+ $extraConds[] = $contWhere;
+ }
+ $res = $this->select( __METHOD__, [ 'where' => $extraConds ] );
+ $rows = array_merge( $rows, iterator_to_array( $res ) );
+ if ( count( $rows ) >= $limit + 1 ) {
+ break;
+ }
+ $first = false;
+ }
+ } else {
+ // Sorting by timestamp
+ // No need to worry about per-type queries because we
+ // aren't sorting or filtering by type anyway
+ $res = $this->select( __METHOD__ );
+ $rows = iterator_to_array( $res );
+ }
+
+ $result = $this->getResult();
+ $count = 0;
+ foreach ( $rows as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ // @todo Security issue - if the user has no right to view next
+ // title, it will still be shown
+ if ( $params['sort'] == 'timestamp' ) {
+ $this->setContinueEnumParameter( 'continue', "$row->cl_timestamp|$row->cl_from" );
+ } else {
+ $sortkey = bin2hex( $row->cl_sortkey );
+ $this->setContinueEnumParameter( 'continue',
+ "{$row->cl_type}|$sortkey|{$row->cl_from}"
+ );
+ }
+ break;
+ }
+
+ // Since domas won't tell anyone what he told long ago, apply
+ // cmnamespace here. This means the query may return 0 actual
+ // results, but on the other hand it could save returning 5000
+ // useless results to the client. ~~~~
+ if ( count( $miser_ns ) && !in_array( $row->page_namespace, $miser_ns ) ) {
+ continue;
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $vals = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ if ( $fld_ids ) {
+ $vals['pageid'] = intval( $row->page_id );
+ }
+ if ( $fld_title ) {
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ }
+ if ( $fld_sortkey ) {
+ $vals['sortkey'] = bin2hex( $row->cl_sortkey );
+ }
+ if ( $fld_sortkeyprefix ) {
+ $vals['sortkeyprefix'] = $row->cl_sortkey_prefix;
+ }
+ if ( $fld_type ) {
+ $vals['type'] = $row->cl_type;
+ }
+ if ( $fld_timestamp ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->cl_timestamp );
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ],
+ null, $vals );
+ if ( !$fit ) {
+ if ( $params['sort'] == 'timestamp' ) {
+ $this->setContinueEnumParameter( 'continue', "$row->cl_timestamp|$row->cl_from" );
+ } else {
+ $sortkey = bin2hex( $row->cl_sortkey );
+ $this->setContinueEnumParameter( 'continue',
+ "{$row->cl_type}|$sortkey|{$row->cl_from}"
+ );
+ }
+ break;
+ }
+ } else {
+ $resultPageSet->processDbRow( $row );
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName(
+ [ 'query', $this->getModuleName() ], 'cm' );
+ }
+ }
+
+ public function getAllowedParams() {
+ $ret = [
+ 'title' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'ids|title',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'ids',
+ 'title',
+ 'sortkey',
+ 'sortkeyprefix',
+ 'type',
+ 'timestamp',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ],
+ 'type' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'page|subcat|file',
+ ApiBase::PARAM_TYPE => [
+ 'page',
+ 'subcat',
+ 'file'
+ ]
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'sort' => [
+ ApiBase::PARAM_DFLT => 'sortkey',
+ ApiBase::PARAM_TYPE => [
+ 'sortkey',
+ 'timestamp'
+ ]
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'asc',
+ 'desc',
+ // Normalising with other modules
+ 'ascending',
+ 'descending',
+ 'newer',
+ 'older',
+ ]
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'starthexsortkey' => null,
+ 'endhexsortkey' => null,
+ 'startsortkeyprefix' => null,
+ 'endsortkeyprefix' => null,
+ 'startsortkey' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'endsortkey' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ ];
+
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $ret['namespace'][ApiBase::PARAM_HELP_MSG_APPEND] = [
+ 'api-help-param-limited-in-miser-mode',
+ ];
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=categorymembers&cmtitle=Category:Physics'
+ => 'apihelp-query+categorymembers-example-simple',
+ 'action=query&generator=categorymembers&gcmtitle=Category:Physics&prop=info'
+ => 'apihelp-query+categorymembers-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Categorymembers';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryContributors.php b/www/wiki/includes/api/ApiQueryContributors.php
new file mode 100644
index 00000000..f802d9ef
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryContributors.php
@@ -0,0 +1,259 @@
+<?php
+/**
+ * Query the list of contributors to a page
+ *
+ * Created on Nov 14, 2013
+ *
+ * Copyright © 2013 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.23
+ */
+
+/**
+ * A query module to show contributors to a page
+ *
+ * @ingroup API
+ * @since 1.23
+ */
+class ApiQueryContributors extends ApiQueryBase {
+ /** We don't want to process too many pages at once (it hits cold
+ * database pages too heavily), so only do the first MAX_PAGES input pages
+ * in each API call (leaving the rest for continuation).
+ */
+ const MAX_PAGES = 100;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ // "pc" is short for "page contributors", "co" was already taken by the
+ // GeoData extension's prop=coordinates.
+ parent::__construct( $query, $moduleName, 'pc' );
+ }
+
+ public function execute() {
+ $db = $this->getDB();
+ $params = $this->extractRequestParams();
+ $this->requireMaxOneParameter( $params, 'group', 'excludegroup', 'rights', 'excluderights' );
+
+ // Only operate on existing pages
+ $pages = array_keys( $this->getPageSet()->getGoodTitles() );
+
+ // Filter out already-processed pages
+ if ( $params['continue'] !== null ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $cont_page = (int)$cont[0];
+ $pages = array_filter( $pages, function ( $v ) use ( $cont_page ) {
+ return $v >= $cont_page;
+ } );
+ }
+ if ( !count( $pages ) ) {
+ // Nothing to do
+ return;
+ }
+
+ // Apply MAX_PAGES, leaving any over the limit for a continue.
+ sort( $pages );
+ $continuePages = null;
+ if ( count( $pages ) > self::MAX_PAGES ) {
+ $continuePages = $pages[self::MAX_PAGES] . '|0';
+ $pages = array_slice( $pages, 0, self::MAX_PAGES );
+ }
+
+ $result = $this->getResult();
+
+ // First, count anons
+ $this->addTables( 'revision' );
+ $this->addFields( [
+ 'page' => 'rev_page',
+ 'anons' => 'COUNT(DISTINCT rev_user_text)',
+ ] );
+ $this->addWhereFld( 'rev_page', $pages );
+ $this->addWhere( 'rev_user = 0' );
+ $this->addWhere( $db->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' );
+ $this->addOption( 'GROUP BY', 'rev_page' );
+ $res = $this->select( __METHOD__ );
+ foreach ( $res as $row ) {
+ $fit = $result->addValue( [ 'query', 'pages', $row->page ],
+ 'anoncontributors', (int)$row->anons
+ );
+ if ( !$fit ) {
+ // This not fitting isn't reasonable, so it probably means that
+ // some other module used up all the space. Just set a dummy
+ // continue and hope it works next time.
+ $this->setContinueEnumParameter( 'continue',
+ $params['continue'] !== null ? $params['continue'] : '0|0'
+ );
+
+ return;
+ }
+ }
+
+ // Next, add logged-in users
+ $this->resetQueryParams();
+ $this->addTables( 'revision' );
+ $this->addFields( [
+ 'page' => 'rev_page',
+ 'user' => 'rev_user',
+ 'username' => 'MAX(rev_user_text)', // Non-MySQL databases don't like partial group-by
+ ] );
+ $this->addWhereFld( 'rev_page', $pages );
+ $this->addWhere( 'rev_user != 0' );
+ $this->addWhere( $db->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' );
+ $this->addOption( 'GROUP BY', 'rev_page, rev_user' );
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+
+ // Force a sort order to ensure that properties are grouped by page
+ // But only if pp_page is not constant in the WHERE clause.
+ if ( count( $pages ) > 1 ) {
+ $this->addOption( 'ORDER BY', 'rev_page, rev_user' );
+ } else {
+ $this->addOption( 'ORDER BY', 'rev_user' );
+ }
+
+ $limitGroups = [];
+ if ( $params['group'] ) {
+ $excludeGroups = false;
+ $limitGroups = $params['group'];
+ } elseif ( $params['excludegroup'] ) {
+ $excludeGroups = true;
+ $limitGroups = $params['excludegroup'];
+ } elseif ( $params['rights'] ) {
+ $excludeGroups = false;
+ foreach ( $params['rights'] as $r ) {
+ $limitGroups = array_merge( $limitGroups, User::getGroupsWithPermission( $r ) );
+ }
+
+ // If no group has the rights requested, no need to query
+ if ( !$limitGroups ) {
+ if ( $continuePages !== null ) {
+ // But we still need to continue for the next page's worth
+ // of anoncontributors
+ $this->setContinueEnumParameter( 'continue', $continuePages );
+ }
+
+ return;
+ }
+ } elseif ( $params['excluderights'] ) {
+ $excludeGroups = true;
+ foreach ( $params['excluderights'] as $r ) {
+ $limitGroups = array_merge( $limitGroups, User::getGroupsWithPermission( $r ) );
+ }
+ }
+
+ if ( $limitGroups ) {
+ $limitGroups = array_unique( $limitGroups );
+ $this->addTables( 'user_groups' );
+ $this->addJoinConds( [ 'user_groups' => [
+ $excludeGroups ? 'LEFT OUTER JOIN' : 'INNER JOIN',
+ [
+ 'ug_user=rev_user',
+ 'ug_group' => $limitGroups,
+ 'ug_expiry IS NULL OR ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
+ ]
+ ] ] );
+ $this->addWhereIf( 'ug_user IS NULL', $excludeGroups );
+ }
+
+ if ( $params['continue'] !== null ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $cont_page = (int)$cont[0];
+ $cont_user = (int)$cont[1];
+ $this->addWhere(
+ "rev_page > $cont_page OR " .
+ "(rev_page = $cont_page AND " .
+ "rev_user >= $cont_user)"
+ );
+ }
+
+ $res = $this->select( __METHOD__ );
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->page . '|' . $row->user );
+
+ return;
+ }
+
+ $fit = $this->addPageSubItem( $row->page,
+ [ 'userid' => (int)$row->user, 'name' => $row->username ],
+ 'user'
+ );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->page . '|' . $row->user );
+
+ return;
+ }
+ }
+
+ if ( $continuePages !== null ) {
+ $this->setContinueEnumParameter( 'continue', $continuePages );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ $userGroups = User::getAllGroups();
+ $userRights = User::getAllRights();
+
+ return [
+ 'group' => [
+ ApiBase::PARAM_TYPE => $userGroups,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'excludegroup' => [
+ ApiBase::PARAM_TYPE => $userGroups,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'rights' => [
+ ApiBase::PARAM_TYPE => $userRights,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'excluderights' => [
+ ApiBase::PARAM_TYPE => $userRights,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=contributors&titles=Main_Page'
+ => 'apihelp-query+contributors-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Contributors';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryDeletedRevisions.php b/www/wiki/includes/api/ApiQueryDeletedRevisions.php
new file mode 100644
index 00000000..8e4752e8
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryDeletedRevisions.php
@@ -0,0 +1,293 @@
+<?php
+/**
+ * Created on Oct 3, 2014
+ *
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * Heavily based on ApiQueryDeletedrevs,
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate deleted revisions for pages.
+ *
+ * @ingroup API
+ */
+class ApiQueryDeletedRevisions extends ApiQueryRevisionsBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'drv' );
+ }
+
+ protected function run( ApiPageSet $resultPageSet = null ) {
+ $user = $this->getUser();
+ // Before doing anything at all, let's check permissions
+ $this->checkUserRightsAny( 'deletedhistory' );
+
+ $pageSet = $this->getPageSet();
+ $pageMap = $pageSet->getGoodAndMissingTitlesByNamespace();
+ $pageCount = count( $pageSet->getGoodAndMissingTitles() );
+ $revCount = $pageSet->getRevisionCount();
+ if ( $revCount === 0 && $pageCount === 0 ) {
+ // Nothing to do
+ return;
+ }
+ if ( $revCount !== 0 && count( $pageSet->getDeletedRevisionIDs() ) === 0 ) {
+ // Nothing to do, revisions were supplied but none are deleted
+ return;
+ }
+
+ $params = $this->extractRequestParams( false );
+
+ $db = $this->getDB();
+
+ $this->requireMaxOneParameter( $params, 'user', 'excludeuser' );
+
+ $this->addTables( 'archive' );
+ if ( $resultPageSet === null ) {
+ $this->parseParameters( $params );
+ $this->addFields( Revision::selectArchiveFields() );
+ $this->addFields( [ 'ar_title', 'ar_namespace' ] );
+ } else {
+ $this->limit = $this->getParameter( 'limit' ) ?: 10;
+ $this->addFields( [ 'ar_title', 'ar_namespace', 'ar_timestamp', 'ar_rev_id', 'ar_id' ] );
+ }
+
+ if ( $this->fld_tags ) {
+ $this->addTables( 'tag_summary' );
+ $this->addJoinConds(
+ [ 'tag_summary' => [ 'LEFT JOIN', [ 'ar_rev_id=ts_rev_id' ] ] ]
+ );
+ $this->addFields( 'ts_tags' );
+ }
+
+ if ( !is_null( $params['tag'] ) ) {
+ $this->addTables( 'change_tag' );
+ $this->addJoinConds(
+ [ 'change_tag' => [ 'INNER JOIN', [ 'ar_rev_id=ct_rev_id' ] ] ]
+ );
+ $this->addWhereFld( 'ct_tag', $params['tag'] );
+ }
+
+ if ( $this->fetchContent ) {
+ // Modern MediaWiki has the content for deleted revs in the 'text'
+ // table using fields old_text and old_flags. But revisions deleted
+ // pre-1.5 store the content in the 'archive' table directly using
+ // fields ar_text and ar_flags, and no corresponding 'text' row. So
+ // we have to LEFT JOIN and fetch all four fields.
+ $this->addTables( 'text' );
+ $this->addJoinConds(
+ [ 'text' => [ 'LEFT JOIN', [ 'ar_text_id=old_id' ] ] ]
+ );
+ $this->addFields( [ 'ar_text', 'ar_flags', 'old_text', 'old_flags' ] );
+
+ // This also means stricter restrictions
+ $this->checkUserRightsAny( [ 'deletedtext', 'undelete' ] );
+ }
+
+ $dir = $params['dir'];
+
+ if ( $revCount !== 0 ) {
+ $this->addWhere( [
+ 'ar_rev_id' => array_keys( $pageSet->getDeletedRevisionIDs() )
+ ] );
+ } else {
+ // We need a custom WHERE clause that matches all titles.
+ $lb = new LinkBatch( $pageSet->getGoodAndMissingTitles() );
+ $where = $lb->constructSet( 'ar', $db );
+ $this->addWhere( $where );
+ }
+
+ if ( !is_null( $params['user'] ) ) {
+ $this->addWhereFld( 'ar_user_text', $params['user'] );
+ } elseif ( !is_null( $params['excludeuser'] ) ) {
+ $this->addWhere( 'ar_user_text != ' .
+ $db->addQuotes( $params['excludeuser'] ) );
+ }
+
+ if ( !is_null( $params['user'] ) || !is_null( $params['excludeuser'] ) ) {
+ // Paranoia: avoid brute force searches (T19342)
+ // (shouldn't be able to get here without 'deletedhistory', but
+ // check it again just in case)
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $db->bitAnd( 'ar_deleted', $bitmask ) . " != $bitmask" );
+ }
+ }
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $op = ( $dir == 'newer' ? '>' : '<' );
+ if ( $revCount !== 0 ) {
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $rev = intval( $cont[0] );
+ $this->dieContinueUsageIf( strval( $rev ) !== $cont[0] );
+ $ar_id = (int)$cont[1];
+ $this->dieContinueUsageIf( strval( $ar_id ) !== $cont[1] );
+ $this->addWhere( "ar_rev_id $op $rev OR " .
+ "(ar_rev_id = $rev AND " .
+ "ar_id $op= $ar_id)" );
+ } else {
+ $this->dieContinueUsageIf( count( $cont ) != 4 );
+ $ns = intval( $cont[0] );
+ $this->dieContinueUsageIf( strval( $ns ) !== $cont[0] );
+ $title = $db->addQuotes( $cont[1] );
+ $ts = $db->addQuotes( $db->timestamp( $cont[2] ) );
+ $ar_id = (int)$cont[3];
+ $this->dieContinueUsageIf( strval( $ar_id ) !== $cont[3] );
+ $this->addWhere( "ar_namespace $op $ns OR " .
+ "(ar_namespace = $ns AND " .
+ "(ar_title $op $title OR " .
+ "(ar_title = $title AND " .
+ "(ar_timestamp $op $ts OR " .
+ "(ar_timestamp = $ts AND " .
+ "ar_id $op= $ar_id)))))" );
+ }
+ }
+
+ $this->addOption( 'LIMIT', $this->limit + 1 );
+
+ if ( $revCount !== 0 ) {
+ // Sort by ar_rev_id when querying by ar_rev_id
+ $this->addWhereRange( 'ar_rev_id', $dir, null, null );
+ } else {
+ // Sort by ns and title in the same order as timestamp for efficiency
+ // But only when not already unique in the query
+ if ( count( $pageMap ) > 1 ) {
+ $this->addWhereRange( 'ar_namespace', $dir, null, null );
+ }
+ $oneTitle = key( reset( $pageMap ) );
+ foreach ( $pageMap as $pages ) {
+ if ( count( $pages ) > 1 || key( $pages ) !== $oneTitle ) {
+ $this->addWhereRange( 'ar_title', $dir, null, null );
+ break;
+ }
+ }
+ $this->addTimestampWhereRange( 'ar_timestamp', $dir, $params['start'], $params['end'] );
+ }
+ // Include in ORDER BY for uniqueness
+ $this->addWhereRange( 'ar_id', $dir, null, null );
+
+ $res = $this->select( __METHOD__ );
+ $count = 0;
+ $generated = [];
+ foreach ( $res as $row ) {
+ if ( ++$count > $this->limit ) {
+ // We've had enough
+ $this->setContinueEnumParameter( 'continue',
+ $revCount
+ ? "$row->ar_rev_id|$row->ar_id"
+ : "$row->ar_namespace|$row->ar_title|$row->ar_timestamp|$row->ar_id"
+ );
+ break;
+ }
+
+ if ( $resultPageSet !== null ) {
+ $generated[] = $row->ar_rev_id;
+ } else {
+ if ( !isset( $pageMap[$row->ar_namespace][$row->ar_title] ) ) {
+ // Was it converted?
+ $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+ $converted = $pageSet->getConvertedTitles();
+ if ( $title && isset( $converted[$title->getPrefixedText()] ) ) {
+ $title = Title::newFromText( $converted[$title->getPrefixedText()] );
+ if ( $title && isset( $pageMap[$title->getNamespace()][$title->getDBkey()] ) ) {
+ $pageMap[$row->ar_namespace][$row->ar_title] =
+ $pageMap[$title->getNamespace()][$title->getDBkey()];
+ }
+ }
+ }
+ if ( !isset( $pageMap[$row->ar_namespace][$row->ar_title] ) ) {
+ ApiBase::dieDebug(
+ __METHOD__,
+ "Found row in archive (ar_id={$row->ar_id}) that didn't get processed by ApiPageSet"
+ );
+ }
+
+ $fit = $this->addPageSubItem(
+ $pageMap[$row->ar_namespace][$row->ar_title],
+ $this->extractRevisionInfo( Revision::newFromArchiveRow( $row ), $row ),
+ 'rev'
+ );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue',
+ $revCount
+ ? "$row->ar_rev_id|$row->ar_id"
+ : "$row->ar_namespace|$row->ar_title|$row->ar_timestamp|$row->ar_id"
+ );
+ break;
+ }
+ }
+ }
+
+ if ( $resultPageSet !== null ) {
+ $resultPageSet->populateFromRevisionIDs( $generated );
+ }
+ }
+
+ public function getAllowedParams() {
+ return parent::getAllowedParams() + [
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+ 'dir' => [
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'tag' => null,
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'excludeuser' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=deletedrevisions&titles=Main%20Page|Talk:Main%20Page&' .
+ 'drvprop=user|comment|content'
+ => 'apihelp-query+deletedrevisions-example-titles',
+ 'action=query&prop=deletedrevisions&revids=123456'
+ => 'apihelp-query+deletedrevisions-example-revids',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Deletedrevisions';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryDeletedrevs.php b/www/wiki/includes/api/ApiQueryDeletedrevs.php
new file mode 100644
index 00000000..5dd007b4
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryDeletedrevs.php
@@ -0,0 +1,518 @@
+<?php
+/**
+ *
+ *
+ * Created on Jul 2, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate all deleted revisions.
+ *
+ * @ingroup API
+ * @deprecated since 1.25
+ */
+class ApiQueryDeletedrevs extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'dr' );
+ }
+
+ public function execute() {
+ // Before doing anything at all, let's check permissions
+ $this->checkUserRightsAny( 'deletedhistory' );
+
+ $this->addDeprecation( 'apiwarn-deprecation-deletedrevs', 'action=query&list=deletedrevs' );
+
+ $user = $this->getUser();
+ $db = $this->getDB();
+ $commentStore = new CommentStore( 'ar_comment' );
+ $params = $this->extractRequestParams( false );
+ $prop = array_flip( $params['prop'] );
+ $fld_parentid = isset( $prop['parentid'] );
+ $fld_revid = isset( $prop['revid'] );
+ $fld_user = isset( $prop['user'] );
+ $fld_userid = isset( $prop['userid'] );
+ $fld_comment = isset( $prop['comment'] );
+ $fld_parsedcomment = isset( $prop['parsedcomment'] );
+ $fld_minor = isset( $prop['minor'] );
+ $fld_len = isset( $prop['len'] );
+ $fld_sha1 = isset( $prop['sha1'] );
+ $fld_content = isset( $prop['content'] );
+ $fld_token = isset( $prop['token'] );
+ $fld_tags = isset( $prop['tags'] );
+
+ if ( isset( $prop['token'] ) ) {
+ $p = $this->getModulePrefix();
+ }
+
+ // If we're in a mode that breaks the same-origin policy, no tokens can
+ // be obtained
+ if ( $this->lacksSameOriginSecurity() ) {
+ $fld_token = false;
+ }
+
+ // If user can't undelete, no tokens
+ if ( !$user->isAllowed( 'undelete' ) ) {
+ $fld_token = false;
+ }
+
+ $result = $this->getResult();
+ $pageSet = $this->getPageSet();
+ $titles = $pageSet->getTitles();
+
+ // This module operates in three modes:
+ // 'revs': List deleted revs for certain titles (1)
+ // 'user': List deleted revs by a certain user (2)
+ // 'all': List all deleted revs in NS (3)
+ $mode = 'all';
+ if ( count( $titles ) > 0 ) {
+ $mode = 'revs';
+ } elseif ( !is_null( $params['user'] ) ) {
+ $mode = 'user';
+ }
+
+ if ( $mode == 'revs' || $mode == 'user' ) {
+ // Ignore namespace and unique due to inability to know whether they were purposely set
+ foreach ( [ 'from', 'to', 'prefix', /*'namespace', 'unique'*/ ] as $p ) {
+ if ( !is_null( $params[$p] ) ) {
+ $this->dieWithError( [ 'apierror-deletedrevs-param-not-1-2', $p ], 'badparams' );
+ }
+ }
+ } else {
+ foreach ( [ 'start', 'end' ] as $p ) {
+ if ( !is_null( $params[$p] ) ) {
+ $this->dieWithError( [ 'apierror-deletedrevs-param-not-3', $p ], 'badparams' );
+ }
+ }
+ }
+
+ if ( !is_null( $params['user'] ) && !is_null( $params['excludeuser'] ) ) {
+ $this->dieWithError( 'user and excludeuser cannot be used together', 'badparams' );
+ }
+
+ $this->addTables( 'archive' );
+ $this->addFields( [ 'ar_title', 'ar_namespace', 'ar_timestamp', 'ar_deleted', 'ar_id' ] );
+
+ $this->addFieldsIf( 'ar_parent_id', $fld_parentid );
+ $this->addFieldsIf( 'ar_rev_id', $fld_revid );
+ $this->addFieldsIf( 'ar_user_text', $fld_user );
+ $this->addFieldsIf( 'ar_user', $fld_userid );
+ $this->addFieldsIf( 'ar_minor_edit', $fld_minor );
+ $this->addFieldsIf( 'ar_len', $fld_len );
+ $this->addFieldsIf( 'ar_sha1', $fld_sha1 );
+
+ if ( $fld_comment || $fld_parsedcomment ) {
+ $commentQuery = $commentStore->getJoin();
+ $this->addTables( $commentQuery['tables'] );
+ $this->addFields( $commentQuery['fields'] );
+ $this->addJoinConds( $commentQuery['joins'] );
+ }
+
+ if ( $fld_tags ) {
+ $this->addTables( 'tag_summary' );
+ $this->addJoinConds(
+ [ 'tag_summary' => [ 'LEFT JOIN', [ 'ar_rev_id=ts_rev_id' ] ] ]
+ );
+ $this->addFields( 'ts_tags' );
+ }
+
+ if ( !is_null( $params['tag'] ) ) {
+ $this->addTables( 'change_tag' );
+ $this->addJoinConds(
+ [ 'change_tag' => [ 'INNER JOIN', [ 'ar_rev_id=ct_rev_id' ] ] ]
+ );
+ $this->addWhereFld( 'ct_tag', $params['tag'] );
+ }
+
+ if ( $fld_content ) {
+ // Modern MediaWiki has the content for deleted revs in the 'text'
+ // table using fields old_text and old_flags. But revisions deleted
+ // pre-1.5 store the content in the 'archive' table directly using
+ // fields ar_text and ar_flags, and no corresponding 'text' row. So
+ // we have to LEFT JOIN and fetch all four fields, plus ar_text_id
+ // to be able to tell the difference.
+ $this->addTables( 'text' );
+ $this->addJoinConds(
+ [ 'text' => [ 'LEFT JOIN', [ 'ar_text_id=old_id' ] ] ]
+ );
+ $this->addFields( [ 'ar_text', 'ar_flags', 'ar_text_id', 'old_text', 'old_flags' ] );
+
+ // This also means stricter restrictions
+ $this->checkUserRightsAny( [ 'deletedtext', 'undelete' ] );
+ }
+ // Check limits
+ $userMax = $fld_content ? ApiBase::LIMIT_SML1 : ApiBase::LIMIT_BIG1;
+ $botMax = $fld_content ? ApiBase::LIMIT_SML2 : ApiBase::LIMIT_BIG2;
+
+ $limit = $params['limit'];
+
+ if ( $limit == 'max' ) {
+ $limit = $this->getMain()->canApiHighLimits() ? $botMax : $userMax;
+ $this->getResult()->addParsedLimit( $this->getModuleName(), $limit );
+ }
+
+ $this->validateLimit( 'limit', $limit, 1, $userMax, $botMax );
+
+ if ( $fld_token ) {
+ // Undelete tokens are identical for all pages, so we cache one here
+ $token = $user->getEditToken( '', $this->getMain()->getRequest() );
+ }
+
+ $dir = $params['dir'];
+
+ // We need a custom WHERE clause that matches all titles.
+ if ( $mode == 'revs' ) {
+ $lb = new LinkBatch( $titles );
+ $where = $lb->constructSet( 'ar', $db );
+ $this->addWhere( $where );
+ } elseif ( $mode == 'all' ) {
+ $this->addWhereFld( 'ar_namespace', $params['namespace'] );
+
+ $from = $params['from'] === null
+ ? null
+ : $this->titlePartToKey( $params['from'], $params['namespace'] );
+ $to = $params['to'] === null
+ ? null
+ : $this->titlePartToKey( $params['to'], $params['namespace'] );
+ $this->addWhereRange( 'ar_title', $dir, $from, $to );
+
+ if ( isset( $params['prefix'] ) ) {
+ $this->addWhere( 'ar_title' . $db->buildLike(
+ $this->titlePartToKey( $params['prefix'], $params['namespace'] ),
+ $db->anyString() ) );
+ }
+ }
+
+ if ( !is_null( $params['user'] ) ) {
+ $this->addWhereFld( 'ar_user_text', $params['user'] );
+ } elseif ( !is_null( $params['excludeuser'] ) ) {
+ $this->addWhere( 'ar_user_text != ' .
+ $db->addQuotes( $params['excludeuser'] ) );
+ }
+
+ if ( !is_null( $params['user'] ) || !is_null( $params['excludeuser'] ) ) {
+ // Paranoia: avoid brute force searches (T19342)
+ // (shouldn't be able to get here without 'deletedhistory', but
+ // check it again just in case)
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $db->bitAnd( 'ar_deleted', $bitmask ) . " != $bitmask" );
+ }
+ }
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $op = ( $dir == 'newer' ? '>' : '<' );
+ if ( $mode == 'all' || $mode == 'revs' ) {
+ $this->dieContinueUsageIf( count( $cont ) != 4 );
+ $ns = intval( $cont[0] );
+ $this->dieContinueUsageIf( strval( $ns ) !== $cont[0] );
+ $title = $db->addQuotes( $cont[1] );
+ $ts = $db->addQuotes( $db->timestamp( $cont[2] ) );
+ $ar_id = (int)$cont[3];
+ $this->dieContinueUsageIf( strval( $ar_id ) !== $cont[3] );
+ $this->addWhere( "ar_namespace $op $ns OR " .
+ "(ar_namespace = $ns AND " .
+ "(ar_title $op $title OR " .
+ "(ar_title = $title AND " .
+ "(ar_timestamp $op $ts OR " .
+ "(ar_timestamp = $ts AND " .
+ "ar_id $op= $ar_id)))))" );
+ } else {
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $ts = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $ar_id = (int)$cont[1];
+ $this->dieContinueUsageIf( strval( $ar_id ) !== $cont[1] );
+ $this->addWhere( "ar_timestamp $op $ts OR " .
+ "(ar_timestamp = $ts AND " .
+ "ar_id $op= $ar_id)" );
+ }
+ }
+
+ $this->addOption( 'LIMIT', $limit + 1 );
+ $this->addOption(
+ 'USE INDEX',
+ [ 'archive' => ( $mode == 'user' ? 'ar_usertext_timestamp' : 'name_title_timestamp' ) ]
+ );
+ if ( $mode == 'all' ) {
+ if ( $params['unique'] ) {
+ // @todo Does this work on non-MySQL?
+ $this->addOption( 'GROUP BY', 'ar_title' );
+ } else {
+ $sort = ( $dir == 'newer' ? '' : ' DESC' );
+ $this->addOption( 'ORDER BY', [
+ 'ar_title' . $sort,
+ 'ar_timestamp' . $sort,
+ 'ar_id' . $sort,
+ ] );
+ }
+ } else {
+ if ( $mode == 'revs' ) {
+ // Sort by ns and title in the same order as timestamp for efficiency
+ $this->addWhereRange( 'ar_namespace', $dir, null, null );
+ $this->addWhereRange( 'ar_title', $dir, null, null );
+ }
+ $this->addTimestampWhereRange( 'ar_timestamp', $dir, $params['start'], $params['end'] );
+ // Include in ORDER BY for uniqueness
+ $this->addWhereRange( 'ar_id', $dir, null, null );
+ }
+ $res = $this->select( __METHOD__ );
+ $pageMap = []; // Maps ns&title to (fake) pageid
+ $count = 0;
+ $newPageID = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $limit ) {
+ // We've had enough
+ if ( $mode == 'all' || $mode == 'revs' ) {
+ $this->setContinueEnumParameter( 'continue',
+ "$row->ar_namespace|$row->ar_title|$row->ar_timestamp|$row->ar_id"
+ );
+ } else {
+ $this->setContinueEnumParameter( 'continue', "$row->ar_timestamp|$row->ar_id" );
+ }
+ break;
+ }
+
+ $rev = [];
+ $anyHidden = false;
+
+ $rev['timestamp'] = wfTimestamp( TS_ISO_8601, $row->ar_timestamp );
+ if ( $fld_revid ) {
+ $rev['revid'] = intval( $row->ar_rev_id );
+ }
+ if ( $fld_parentid && !is_null( $row->ar_parent_id ) ) {
+ $rev['parentid'] = intval( $row->ar_parent_id );
+ }
+ if ( $fld_user || $fld_userid ) {
+ if ( $row->ar_deleted & Revision::DELETED_USER ) {
+ $rev['userhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield( $row->ar_deleted, Revision::DELETED_USER, $user ) ) {
+ if ( $fld_user ) {
+ $rev['user'] = $row->ar_user_text;
+ }
+ if ( $fld_userid ) {
+ $rev['userid'] = (int)$row->ar_user;
+ }
+ }
+ }
+
+ if ( $fld_comment || $fld_parsedcomment ) {
+ if ( $row->ar_deleted & Revision::DELETED_COMMENT ) {
+ $rev['commenthidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield( $row->ar_deleted, Revision::DELETED_COMMENT, $user ) ) {
+ $comment = $commentStore->getComment( $row )->text;
+ if ( $fld_comment ) {
+ $rev['comment'] = $comment;
+ }
+ if ( $fld_parsedcomment ) {
+ $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+ $rev['parsedcomment'] = Linker::formatComment( $comment, $title );
+ }
+ }
+ }
+
+ if ( $fld_minor ) {
+ $rev['minor'] = $row->ar_minor_edit == 1;
+ }
+ if ( $fld_len ) {
+ $rev['len'] = $row->ar_len;
+ }
+ if ( $fld_sha1 ) {
+ if ( $row->ar_deleted & Revision::DELETED_TEXT ) {
+ $rev['sha1hidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield( $row->ar_deleted, Revision::DELETED_TEXT, $user ) ) {
+ if ( $row->ar_sha1 != '' ) {
+ $rev['sha1'] = Wikimedia\base_convert( $row->ar_sha1, 36, 16, 40 );
+ } else {
+ $rev['sha1'] = '';
+ }
+ }
+ }
+ if ( $fld_content ) {
+ if ( $row->ar_deleted & Revision::DELETED_TEXT ) {
+ $rev['texthidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield( $row->ar_deleted, Revision::DELETED_TEXT, $user ) ) {
+ if ( isset( $row->ar_text ) && !$row->ar_text_id ) {
+ // Pre-1.5 ar_text row (if condition from Revision::newFromArchiveRow)
+ ApiResult::setContentValue( $rev, 'text', Revision::getRevisionText( $row, 'ar_' ) );
+ } else {
+ ApiResult::setContentValue( $rev, 'text', Revision::getRevisionText( $row ) );
+ }
+ }
+ }
+
+ if ( $fld_tags ) {
+ if ( $row->ts_tags ) {
+ $tags = explode( ',', $row->ts_tags );
+ ApiResult::setIndexedTagName( $tags, 'tag' );
+ $rev['tags'] = $tags;
+ } else {
+ $rev['tags'] = [];
+ }
+ }
+
+ if ( $anyHidden && ( $row->ar_deleted & Revision::DELETED_RESTRICTED ) ) {
+ $rev['suppressed'] = true;
+ }
+
+ if ( !isset( $pageMap[$row->ar_namespace][$row->ar_title] ) ) {
+ $pageID = $newPageID++;
+ $pageMap[$row->ar_namespace][$row->ar_title] = $pageID;
+ $a['revisions'] = [ $rev ];
+ ApiResult::setIndexedTagName( $a['revisions'], 'rev' );
+ $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+ ApiQueryBase::addTitleInfo( $a, $title );
+ if ( $fld_token ) {
+ $a['token'] = $token;
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], $pageID, $a );
+ } else {
+ $pageID = $pageMap[$row->ar_namespace][$row->ar_title];
+ $fit = $result->addValue(
+ [ 'query', $this->getModuleName(), $pageID, 'revisions' ],
+ null, $rev );
+ }
+ if ( !$fit ) {
+ if ( $mode == 'all' || $mode == 'revs' ) {
+ $this->setContinueEnumParameter( 'continue',
+ "$row->ar_namespace|$row->ar_title|$row->ar_timestamp|$row->ar_id"
+ );
+ } else {
+ $this->setContinueEnumParameter( 'continue', "$row->ar_timestamp|$row->ar_id" );
+ }
+ break;
+ }
+ }
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'page' );
+ }
+
+ public function isDeprecated() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'modes', 1, 2 ] ],
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'modes', 1, 2 ] ],
+ ],
+ 'dir' => [
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'modes', 1, 3 ] ],
+ ],
+ 'from' => [
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'modes', 3 ] ],
+ ],
+ 'to' => [
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'modes', 3 ] ],
+ ],
+ 'prefix' => [
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'modes', 3 ] ],
+ ],
+ 'unique' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'modes', 3 ] ],
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_DFLT => NS_MAIN,
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'modes', 3 ] ],
+ ],
+ 'tag' => null,
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'excludeuser' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'user|comment',
+ ApiBase::PARAM_TYPE => [
+ 'revid',
+ 'parentid',
+ 'user',
+ 'userid',
+ 'comment',
+ 'parsedcomment',
+ 'minor',
+ 'len',
+ 'sha1',
+ 'content',
+ 'token',
+ 'tags'
+ ],
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=deletedrevs&titles=Main%20Page|Talk:Main%20Page&' .
+ 'drprop=user|comment|content'
+ => 'apihelp-query+deletedrevs-example-mode1',
+ 'action=query&list=deletedrevs&druser=Bob&drlimit=50'
+ => 'apihelp-query+deletedrevs-example-mode2',
+ 'action=query&list=deletedrevs&drdir=newer&drlimit=50'
+ => 'apihelp-query+deletedrevs-example-mode3-main',
+ 'action=query&list=deletedrevs&drdir=newer&drlimit=50&drnamespace=1&drunique='
+ => 'apihelp-query+deletedrevs-example-mode3-talk',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Deletedrevs';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryDisabled.php b/www/wiki/includes/api/ApiQueryDisabled.php
new file mode 100644
index 00000000..a94af695
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryDisabled.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 25, 2008
+ *
+ * Copyright © 2008 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that does nothing
+ *
+ * Use this to disable core modules with e.g.
+ * $wgAPIPropModules['modulename'] = 'ApiQueryDisabled';
+ *
+ * To disable top-level modules, use ApiDisabled instead
+ *
+ * @ingroup API
+ */
+class ApiQueryDisabled extends ApiQueryBase {
+
+ public function execute() {
+ $this->addWarning( [ 'apierror-moduledisabled', $this->getModuleName() ] );
+ }
+
+ public function getAllowedParams() {
+ return [];
+ }
+
+ public function getDescriptionMessage() {
+ return 'apihelp-query+disabled-summary';
+ }
+
+ public function getSummaryMessage() {
+ return 'apihelp-query+disabled-summary';
+ }
+
+ public function getExtendedDescription() {
+ return 'apihelp-query+disabled-extended-description';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryDuplicateFiles.php b/www/wiki/includes/api/ApiQueryDuplicateFiles.php
new file mode 100644
index 00000000..0eaeaece
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryDuplicateFiles.php
@@ -0,0 +1,194 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 27, 2008
+ *
+ * Copyright © 2008 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query module to list duplicates of the given file(s)
+ *
+ * @ingroup API
+ */
+class ApiQueryDuplicateFiles extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'df' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ */
+ private function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+ $namespaces = $this->getPageSet()->getGoodAndMissingTitlesByNamespace();
+ if ( empty( $namespaces[NS_FILE] ) ) {
+ return;
+ }
+ $images = $namespaces[NS_FILE];
+
+ if ( $params['dir'] == 'descending' ) {
+ $images = array_reverse( $images );
+ }
+
+ $skipUntilThisDup = false;
+ if ( isset( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $fromImage = $cont[0];
+ $skipUntilThisDup = $cont[1];
+ // Filter out any images before $fromImage
+ foreach ( $images as $image => $pageId ) {
+ if ( $image < $fromImage ) {
+ unset( $images[$image] );
+ } else {
+ break;
+ }
+ }
+ }
+
+ $filesToFind = array_keys( $images );
+ if ( $params['localonly'] ) {
+ $files = RepoGroup::singleton()->getLocalRepo()->findFiles( $filesToFind );
+ } else {
+ $files = RepoGroup::singleton()->findFiles( $filesToFind );
+ }
+
+ $fit = true;
+ $count = 0;
+ $titles = [];
+
+ $sha1s = [];
+ foreach ( $files as $file ) {
+ /** @var File $file */
+ $sha1s[$file->getName()] = $file->getSha1();
+ }
+
+ // find all files with the hashes, result format is:
+ // [ hash => [ dup1, dup2 ], hash1 => ... ]
+ $filesToFindBySha1s = array_unique( array_values( $sha1s ) );
+ if ( $params['localonly'] ) {
+ $filesBySha1s = RepoGroup::singleton()->getLocalRepo()->findBySha1s( $filesToFindBySha1s );
+ } else {
+ $filesBySha1s = RepoGroup::singleton()->findBySha1s( $filesToFindBySha1s );
+ }
+
+ // iterate over $images to handle continue param correct
+ foreach ( $images as $image => $pageId ) {
+ if ( !isset( $sha1s[$image] ) ) {
+ continue; // file does not exist
+ }
+ $sha1 = $sha1s[$image];
+ $dupFiles = $filesBySha1s[$sha1];
+ if ( $params['dir'] == 'descending' ) {
+ $dupFiles = array_reverse( $dupFiles );
+ }
+ /** @var File $dupFile */
+ foreach ( $dupFiles as $dupFile ) {
+ $dupName = $dupFile->getName();
+ if ( $image == $dupName && $dupFile->isLocal() ) {
+ continue; // ignore the local file itself
+ }
+ if ( $skipUntilThisDup !== false && $dupName < $skipUntilThisDup ) {
+ continue; // skip to pos after the image from continue param
+ }
+ $skipUntilThisDup = false;
+ if ( ++$count > $params['limit'] ) {
+ $fit = false; // break outer loop
+ // We're one over limit which shows that
+ // there are additional images to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $image . '|' . $dupName );
+ break;
+ }
+ if ( !is_null( $resultPageSet ) ) {
+ $titles[] = $dupFile->getTitle();
+ } else {
+ $r = [
+ 'name' => $dupName,
+ 'user' => $dupFile->getUser( 'text' ),
+ 'timestamp' => wfTimestamp( TS_ISO_8601, $dupFile->getTimestamp() ),
+ 'shared' => !$dupFile->isLocal(),
+ ];
+ $fit = $this->addPageSubItem( $pageId, $r );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $image . '|' . $dupName );
+ break;
+ }
+ }
+ }
+ if ( !$fit ) {
+ break;
+ }
+ }
+ if ( !is_null( $resultPageSet ) ) {
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ 'localonly' => false,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&titles=File:Albert_Einstein_Head.jpg&prop=duplicatefiles'
+ => 'apihelp-query+duplicatefiles-example-simple',
+ 'action=query&generator=allimages&prop=duplicatefiles'
+ => 'apihelp-query+duplicatefiles-example-generated',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Duplicatefiles';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryExtLinksUsage.php b/www/wiki/includes/api/ApiQueryExtLinksUsage.php
new file mode 100644
index 00000000..6c29b603
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryExtLinksUsage.php
@@ -0,0 +1,235 @@
+<?php
+/**
+ *
+ *
+ * Created on July 7, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiQueryExtLinksUsage extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'eu' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+
+ $query = $params['query'];
+ $protocol = self::getProtocolPrefix( $params['protocol'] );
+
+ $this->addTables( [ 'page', 'externallinks' ] ); // must be in this order for 'USE INDEX'
+ $this->addOption( 'USE INDEX', 'el_index' );
+ $this->addWhere( 'page_id=el_from' );
+
+ $miser_ns = [];
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $miser_ns = $params['namespace'];
+ } else {
+ $this->addWhereFld( 'page_namespace', $params['namespace'] );
+ }
+
+ // Normalize query to match the normalization applied for the externallinks table
+ $query = Parser::normalizeLinkUrl( $query );
+
+ $whereQuery = $this->prepareUrlQuerySearchString( $query, $protocol );
+
+ if ( $whereQuery !== null ) {
+ $this->addWhere( $whereQuery );
+ }
+
+ $prop = array_flip( $params['prop'] );
+ $fld_ids = isset( $prop['ids'] );
+ $fld_title = isset( $prop['title'] );
+ $fld_url = isset( $prop['url'] );
+
+ if ( is_null( $resultPageSet ) ) {
+ $this->addFields( [
+ 'page_id',
+ 'page_namespace',
+ 'page_title'
+ ] );
+ $this->addFieldsIf( 'el_to', $fld_url );
+ } else {
+ $this->addFields( $resultPageSet->getPageTableFields() );
+ }
+
+ $limit = $params['limit'];
+ $offset = $params['offset'];
+ $this->addOption( 'LIMIT', $limit + 1 );
+ if ( isset( $offset ) ) {
+ $this->addOption( 'OFFSET', $offset );
+ }
+
+ $res = $this->select( __METHOD__ );
+
+ $result = $this->getResult();
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'offset', $offset + $limit );
+ break;
+ }
+
+ if ( count( $miser_ns ) && !in_array( $row->page_namespace, $miser_ns ) ) {
+ continue;
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $vals = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ if ( $fld_ids ) {
+ $vals['pageid'] = intval( $row->page_id );
+ }
+ if ( $fld_title ) {
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ }
+ if ( $fld_url ) {
+ $to = $row->el_to;
+ // expand protocol-relative urls
+ if ( $params['expandurl'] ) {
+ $to = wfExpandUrl( $to, PROTO_CANONICAL );
+ }
+ $vals['url'] = $to;
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'offset', $offset + $count - 1 );
+ break;
+ }
+ } else {
+ $resultPageSet->processDbRow( $row );
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ],
+ $this->getModulePrefix() );
+ }
+ }
+
+ public function getAllowedParams() {
+ $ret = [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'ids|title|url',
+ ApiBase::PARAM_TYPE => [
+ 'ids',
+ 'title',
+ 'url'
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'offset' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'protocol' => [
+ ApiBase::PARAM_TYPE => self::prepareProtocols(),
+ ApiBase::PARAM_DFLT => '',
+ ],
+ 'query' => null,
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace'
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'expandurl' => false,
+ ];
+
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $ret['namespace'][ApiBase::PARAM_HELP_MSG_APPEND] = [
+ 'api-help-param-limited-in-miser-mode',
+ ];
+ }
+
+ return $ret;
+ }
+
+ public static function prepareProtocols() {
+ global $wgUrlProtocols;
+ $protocols = [ '' ];
+ foreach ( $wgUrlProtocols as $p ) {
+ if ( $p !== '//' ) {
+ $protocols[] = substr( $p, 0, strpos( $p, ':' ) );
+ }
+ }
+
+ return $protocols;
+ }
+
+ public static function getProtocolPrefix( $protocol ) {
+ // Find the right prefix
+ global $wgUrlProtocols;
+ if ( $protocol && !in_array( $protocol, $wgUrlProtocols ) ) {
+ foreach ( $wgUrlProtocols as $p ) {
+ if ( substr( $p, 0, strlen( $protocol ) ) === $protocol ) {
+ $protocol = $p;
+ break;
+ }
+ }
+
+ return $protocol;
+ } else {
+ return null;
+ }
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=exturlusage&euquery=www.mediawiki.org'
+ => 'apihelp-query+exturlusage-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Exturlusage';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryExternalLinks.php b/www/wiki/includes/api/ApiQueryExternalLinks.php
new file mode 100644
index 00000000..71fd6d1b
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryExternalLinks.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ *
+ *
+ * Created on May 13, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query module to list all external URLs found on a given set of pages.
+ *
+ * @ingroup API
+ */
+class ApiQueryExternalLinks extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'el' );
+ }
+
+ public function execute() {
+ if ( $this->getPageSet()->getGoodTitleCount() == 0 ) {
+ return;
+ }
+
+ $params = $this->extractRequestParams();
+
+ $query = $params['query'];
+ $protocol = ApiQueryExtLinksUsage::getProtocolPrefix( $params['protocol'] );
+
+ $this->addFields( [
+ 'el_from',
+ 'el_to'
+ ] );
+
+ $this->addTables( 'externallinks' );
+ $this->addWhereFld( 'el_from', array_keys( $this->getPageSet()->getGoodTitles() ) );
+
+ $whereQuery = $this->prepareUrlQuerySearchString( $query, $protocol );
+
+ if ( $whereQuery !== null ) {
+ $this->addWhere( $whereQuery );
+ }
+
+ // Don't order by el_from if it's constant in the WHERE clause
+ if ( count( $this->getPageSet()->getGoodTitles() ) != 1 ) {
+ $this->addOption( 'ORDER BY', 'el_from' );
+ }
+
+ // If we're querying all protocols, use DISTINCT to avoid repeating protocol-relative links twice
+ if ( $protocol === null ) {
+ $this->addOption( 'DISTINCT' );
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+ $offset = isset( $params['offset'] ) ? $params['offset'] : 0;
+ if ( $offset ) {
+ $this->addOption( 'OFFSET', $params['offset'] );
+ }
+
+ $res = $this->select( __METHOD__ );
+
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'offset', $offset + $params['limit'] );
+ break;
+ }
+ $entry = [];
+ $to = $row->el_to;
+ // expand protocol-relative urls
+ if ( $params['expandurl'] ) {
+ $to = wfExpandUrl( $to, PROTO_CANONICAL );
+ }
+ ApiResult::setContentValue( $entry, 'url', $to );
+ $fit = $this->addPageSubItem( $row->el_from, $entry );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'offset', $offset + $count - 1 );
+ break;
+ }
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'offset' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'protocol' => [
+ ApiBase::PARAM_TYPE => ApiQueryExtLinksUsage::prepareProtocols(),
+ ApiBase::PARAM_DFLT => '',
+ ],
+ 'query' => null,
+ 'expandurl' => false,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=extlinks&titles=Main%20Page'
+ => 'apihelp-query+extlinks-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Extlinks';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryFileRepoInfo.php b/www/wiki/includes/api/ApiQueryFileRepoInfo.php
new file mode 100644
index 00000000..45899911
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryFileRepoInfo.php
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Copyright © 2013 Mark Holmquist <mtraceur@member.fsf.org>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.22
+ */
+
+/**
+ * A query action to return meta information about the foreign file repos
+ * configured on the wiki.
+ *
+ * @ingroup API
+ */
+class ApiQueryFileRepoInfo extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'fri' );
+ }
+
+ protected function getInitialisedRepoGroup() {
+ $repoGroup = RepoGroup::singleton();
+ $repoGroup->initialiseRepos();
+
+ return $repoGroup;
+ }
+
+ public function execute() {
+ $conf = $this->getConfig();
+
+ $params = $this->extractRequestParams();
+ $props = array_flip( $params['prop'] );
+
+ $repos = [];
+
+ $repoGroup = $this->getInitialisedRepoGroup();
+ $foreignTargets = $conf->get( 'ForeignUploadTargets' );
+
+ $repoGroup->forEachForeignRepo( function ( $repo ) use ( &$repos, $props, $foreignTargets ) {
+ $repoProps = $repo->getInfo();
+ $repoProps['canUpload'] = in_array( $repoProps['name'], $foreignTargets );
+
+ $repos[] = array_intersect_key( $repoProps, $props );
+ } );
+
+ $localInfo = $repoGroup->getLocalRepo()->getInfo();
+ $localInfo['canUpload'] = $conf->get( 'EnableUploads' );
+ $repos[] = array_intersect_key( $localInfo, $props );
+
+ $result = $this->getResult();
+ ApiResult::setIndexedTagName( $repos, 'repo' );
+ ApiResult::setArrayTypeRecursive( $repos, 'assoc' );
+ ApiResult::setArrayType( $repos, 'array' );
+ $result->addValue( [ 'query' ], 'repos', $repos );
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ $props = $this->getProps();
+
+ return [
+ 'prop' => [
+ ApiBase::PARAM_DFLT => implode( '|', $props ),
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => $props,
+ ],
+ ];
+ }
+
+ public function getProps() {
+ $props = [];
+ $repoGroup = $this->getInitialisedRepoGroup();
+
+ $repoGroup->forEachForeignRepo( function ( $repo ) use ( &$props ) {
+ $props = array_merge( $props, array_keys( $repo->getInfo() ) );
+ } );
+
+ $propValues = array_values( array_unique( array_merge(
+ $props,
+ array_keys( $repoGroup->getLocalRepo()->getInfo() )
+ ) ) );
+
+ $propValues[] = 'canUpload';
+
+ return $propValues;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&meta=filerepoinfo&friprop=apiurl|name|displayname'
+ => 'apihelp-query+filerepoinfo-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Filerepoinfo';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryFilearchive.php b/www/wiki/includes/api/ApiQueryFilearchive.php
new file mode 100644
index 00000000..212b6134
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryFilearchive.php
@@ -0,0 +1,304 @@
+<?php
+/**
+ * API for MediaWiki 1.12+
+ *
+ * Created on May 10, 2010
+ *
+ * Copyright © 2010 Sam Reed
+ * Copyright © 2008 Vasiliev Victor vasilvv@gmail.com,
+ * based on ApiQueryAllPages.php
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate all deleted files.
+ *
+ * @ingroup API
+ */
+class ApiQueryFilearchive extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'fa' );
+ }
+
+ public function execute() {
+ // Before doing anything at all, let's check permissions
+ $this->checkUserRightsAny( 'deletedhistory' );
+
+ $user = $this->getUser();
+ $db = $this->getDB();
+ $commentStore = new CommentStore( 'fa_description' );
+
+ $params = $this->extractRequestParams();
+
+ $prop = array_flip( $params['prop'] );
+ $fld_sha1 = isset( $prop['sha1'] );
+ $fld_timestamp = isset( $prop['timestamp'] );
+ $fld_user = isset( $prop['user'] );
+ $fld_size = isset( $prop['size'] );
+ $fld_dimensions = isset( $prop['dimensions'] );
+ $fld_description = isset( $prop['description'] ) || isset( $prop['parseddescription'] );
+ $fld_mime = isset( $prop['mime'] );
+ $fld_mediatype = isset( $prop['mediatype'] );
+ $fld_metadata = isset( $prop['metadata'] );
+ $fld_bitdepth = isset( $prop['bitdepth'] );
+ $fld_archivename = isset( $prop['archivename'] );
+
+ $this->addTables( 'filearchive' );
+
+ $this->addFields( ArchivedFile::selectFields() );
+ $this->addFields( [ 'fa_id', 'fa_name', 'fa_timestamp', 'fa_deleted' ] );
+ $this->addFieldsIf( 'fa_sha1', $fld_sha1 );
+ $this->addFieldsIf( [ 'fa_user', 'fa_user_text' ], $fld_user );
+ $this->addFieldsIf( [ 'fa_height', 'fa_width', 'fa_size' ], $fld_dimensions || $fld_size );
+ $this->addFieldsIf( [ 'fa_major_mime', 'fa_minor_mime' ], $fld_mime );
+ $this->addFieldsIf( 'fa_media_type', $fld_mediatype );
+ $this->addFieldsIf( 'fa_metadata', $fld_metadata );
+ $this->addFieldsIf( 'fa_bits', $fld_bitdepth );
+ $this->addFieldsIf( 'fa_archive_name', $fld_archivename );
+
+ if ( $fld_description ) {
+ $commentQuery = $commentStore->getJoin();
+ $this->addTables( $commentQuery['tables'] );
+ $this->addFields( $commentQuery['fields'] );
+ $this->addJoinConds( $commentQuery['joins'] );
+ }
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 3 );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $cont_from = $db->addQuotes( $cont[0] );
+ $cont_timestamp = $db->addQuotes( $db->timestamp( $cont[1] ) );
+ $cont_id = (int)$cont[2];
+ $this->dieContinueUsageIf( $cont[2] !== (string)$cont_id );
+ $this->addWhere( "fa_name $op $cont_from OR " .
+ "(fa_name = $cont_from AND " .
+ "(fa_timestamp $op $cont_timestamp OR " .
+ "(fa_timestamp = $cont_timestamp AND " .
+ "fa_id $op= $cont_id )))"
+ );
+ }
+
+ // Image filters
+ $dir = ( $params['dir'] == 'descending' ? 'older' : 'newer' );
+ $from = ( $params['from'] === null ? null : $this->titlePartToKey( $params['from'], NS_FILE ) );
+ $to = ( $params['to'] === null ? null : $this->titlePartToKey( $params['to'], NS_FILE ) );
+ $this->addWhereRange( 'fa_name', $dir, $from, $to );
+ if ( isset( $params['prefix'] ) ) {
+ $this->addWhere( 'fa_name' . $db->buildLike(
+ $this->titlePartToKey( $params['prefix'], NS_FILE ),
+ $db->anyString() ) );
+ }
+
+ $sha1Set = isset( $params['sha1'] );
+ $sha1base36Set = isset( $params['sha1base36'] );
+ if ( $sha1Set || $sha1base36Set ) {
+ $sha1 = false;
+ if ( $sha1Set ) {
+ $sha1 = strtolower( $params['sha1'] );
+ if ( !$this->validateSha1Hash( $sha1 ) ) {
+ $this->dieWithError( 'apierror-invalidsha1hash' );
+ }
+ $sha1 = Wikimedia\base_convert( $sha1, 16, 36, 31 );
+ } elseif ( $sha1base36Set ) {
+ $sha1 = strtolower( $params['sha1base36'] );
+ if ( !$this->validateSha1Base36Hash( $sha1 ) ) {
+ $this->dieWithError( 'apierror-invalidsha1base36hash' );
+ }
+ }
+ if ( $sha1 ) {
+ $this->addWhereFld( 'fa_sha1', $sha1 );
+ }
+ }
+
+ // Exclude files this user can't view.
+ if ( !$user->isAllowed( 'deletedtext' ) ) {
+ $bitmask = File::DELETED_FILE;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = File::DELETED_FILE | File::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $this->getDB()->bitAnd( 'fa_deleted', $bitmask ) . " != $bitmask" );
+ }
+
+ $limit = $params['limit'];
+ $this->addOption( 'LIMIT', $limit + 1 );
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ $this->addOption( 'ORDER BY', [
+ 'fa_name' . $sort,
+ 'fa_timestamp' . $sort,
+ 'fa_id' . $sort,
+ ] );
+
+ $res = $this->select( __METHOD__ );
+
+ $count = 0;
+ $result = $this->getResult();
+ foreach ( $res as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter(
+ 'continue', "$row->fa_name|$row->fa_timestamp|$row->fa_id"
+ );
+ break;
+ }
+
+ $file = [];
+ $file['id'] = (int)$row->fa_id;
+ $file['name'] = $row->fa_name;
+ $title = Title::makeTitle( NS_FILE, $row->fa_name );
+ self::addTitleInfo( $file, $title );
+
+ if ( $fld_description &&
+ Revision::userCanBitfield( $row->fa_deleted, File::DELETED_COMMENT, $user )
+ ) {
+ $file['description'] = $commentStore->getComment( $row )->text;
+ if ( isset( $prop['parseddescription'] ) ) {
+ $file['parseddescription'] = Linker::formatComment(
+ $file['description'], $title );
+ }
+ }
+ if ( $fld_user &&
+ Revision::userCanBitfield( $row->fa_deleted, File::DELETED_USER, $user )
+ ) {
+ $file['userid'] = (int)$row->fa_user;
+ $file['user'] = $row->fa_user_text;
+ }
+ if ( $fld_sha1 ) {
+ $file['sha1'] = Wikimedia\base_convert( $row->fa_sha1, 36, 16, 40 );
+ }
+ if ( $fld_timestamp ) {
+ $file['timestamp'] = wfTimestamp( TS_ISO_8601, $row->fa_timestamp );
+ }
+ if ( $fld_size || $fld_dimensions ) {
+ $file['size'] = $row->fa_size;
+
+ $pageCount = ArchivedFile::newFromRow( $row )->pageCount();
+ if ( $pageCount !== false ) {
+ $file['pagecount'] = $pageCount;
+ }
+
+ $file['height'] = $row->fa_height;
+ $file['width'] = $row->fa_width;
+ }
+ if ( $fld_mediatype ) {
+ $file['mediatype'] = $row->fa_media_type;
+ }
+ if ( $fld_metadata ) {
+ $file['metadata'] = $row->fa_metadata
+ ? ApiQueryImageInfo::processMetaData( unserialize( $row->fa_metadata ), $result )
+ : null;
+ }
+ if ( $fld_bitdepth ) {
+ $file['bitdepth'] = $row->fa_bits;
+ }
+ if ( $fld_mime ) {
+ $file['mime'] = "$row->fa_major_mime/$row->fa_minor_mime";
+ }
+ if ( $fld_archivename && !is_null( $row->fa_archive_name ) ) {
+ $file['archivename'] = $row->fa_archive_name;
+ }
+
+ if ( $row->fa_deleted & File::DELETED_FILE ) {
+ $file['filehidden'] = true;
+ }
+ if ( $row->fa_deleted & File::DELETED_COMMENT ) {
+ $file['commenthidden'] = true;
+ }
+ if ( $row->fa_deleted & File::DELETED_USER ) {
+ $file['userhidden'] = true;
+ }
+ if ( $row->fa_deleted & File::DELETED_RESTRICTED ) {
+ // This file is deleted for normal admins
+ $file['suppressed'] = true;
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $file );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter(
+ 'continue', "$row->fa_name|$row->fa_timestamp|$row->fa_id"
+ );
+ break;
+ }
+ }
+
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'fa' );
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'from' => null,
+ 'to' => null,
+ 'prefix' => null,
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ 'sha1' => null,
+ 'sha1base36' => null,
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'timestamp',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'sha1',
+ 'timestamp',
+ 'user',
+ 'size',
+ 'dimensions',
+ 'description',
+ 'parseddescription',
+ 'mime',
+ 'mediatype',
+ 'metadata',
+ 'bitdepth',
+ 'archivename',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=filearchive'
+ => 'apihelp-query+filearchive-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Filearchive';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryGeneratorBase.php b/www/wiki/includes/api/ApiQueryGeneratorBase.php
new file mode 100644
index 00000000..5acd75f7
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryGeneratorBase.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 7, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+abstract class ApiQueryGeneratorBase extends ApiQueryBase {
+
+ private $mGeneratorPageSet = null;
+
+ /**
+ * Switch this module to generator mode. By default, generator mode is
+ * switched off and the module acts like a normal query module.
+ * @since 1.21 requires pageset parameter
+ * @param ApiPageSet $generatorPageSet ApiPageSet object that the module will get
+ * by calling getPageSet() when in generator mode.
+ */
+ public function setGeneratorMode( ApiPageSet $generatorPageSet ) {
+ if ( $generatorPageSet === null ) {
+ ApiBase::dieDebug( __METHOD__, 'Required parameter missing - $generatorPageSet' );
+ }
+ $this->mGeneratorPageSet = $generatorPageSet;
+ }
+
+ /**
+ * Indicate whether the module is in generator mode
+ * @since 1.28
+ * @return bool
+ */
+ public function isInGeneratorMode() {
+ return $this->mGeneratorPageSet !== null;
+ }
+
+ /**
+ * Get the PageSet object to work on.
+ * If this module is generator, the pageSet object is different from other module's
+ * @return ApiPageSet
+ */
+ protected function getPageSet() {
+ if ( $this->mGeneratorPageSet !== null ) {
+ return $this->mGeneratorPageSet;
+ }
+
+ return parent::getPageSet();
+ }
+
+ /**
+ * Overrides ApiBase to prepend 'g' to every generator parameter
+ * @param string $paramName Parameter name
+ * @return string Prefixed parameter name
+ */
+ public function encodeParamName( $paramName ) {
+ if ( $this->mGeneratorPageSet !== null ) {
+ return 'g' . parent::encodeParamName( $paramName );
+ } else {
+ return parent::encodeParamName( $paramName );
+ }
+ }
+
+ /**
+ * Overridden to set the generator param if in generator mode
+ * @param string $paramName Parameter name
+ * @param string|array $paramValue Parameter value
+ */
+ protected function setContinueEnumParameter( $paramName, $paramValue ) {
+ if ( $this->mGeneratorPageSet !== null ) {
+ $this->getContinuationManager()->addGeneratorContinueParam( $this, $paramName, $paramValue );
+ } else {
+ parent::setContinueEnumParameter( $paramName, $paramValue );
+ }
+ }
+
+ /** @inheritDoc */
+ protected function getHelpFlags() {
+ // Corresponding messages: api-help-flag-generator
+ $flags = parent::getHelpFlags();
+ $flags[] = 'generator';
+ return $flags;
+ }
+
+ /**
+ * Execute this module as a generator
+ * @param ApiPageSet $resultPageSet All output should be appended to this object
+ */
+ abstract public function executeGenerator( $resultPageSet );
+}
diff --git a/www/wiki/includes/api/ApiQueryIWBacklinks.php b/www/wiki/includes/api/ApiQueryIWBacklinks.php
new file mode 100644
index 00000000..a10ba164
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryIWBacklinks.php
@@ -0,0 +1,220 @@
+<?php
+/**
+ * API for MediaWiki 1.17+
+ *
+ * Created on May 14, 2010
+ *
+ * Copyright © 2010 Sam Reed
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This gives links pointing to the given interwiki
+ * @ingroup API
+ */
+class ApiQueryIWBacklinks extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'iwbl' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ public function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+
+ if ( isset( $params['title'] ) && !isset( $params['prefix'] ) ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-mustusewith',
+ $this->encodeParamName( 'title' ),
+ $this->encodeParamName( 'prefix' ),
+ ],
+ 'invalidparammix'
+ );
+ }
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 3 );
+
+ $db = $this->getDB();
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $prefix = $db->addQuotes( $cont[0] );
+ $title = $db->addQuotes( $cont[1] );
+ $from = intval( $cont[2] );
+ $this->addWhere(
+ "iwl_prefix $op $prefix OR " .
+ "(iwl_prefix = $prefix AND " .
+ "(iwl_title $op $title OR " .
+ "(iwl_title = $title AND " .
+ "iwl_from $op= $from)))"
+ );
+ }
+
+ $prop = array_flip( $params['prop'] );
+ $iwprefix = isset( $prop['iwprefix'] );
+ $iwtitle = isset( $prop['iwtitle'] );
+
+ $this->addTables( [ 'iwlinks', 'page' ] );
+ $this->addWhere( 'iwl_from = page_id' );
+
+ $this->addFields( [ 'page_id', 'page_title', 'page_namespace', 'page_is_redirect',
+ 'iwl_from', 'iwl_prefix', 'iwl_title' ] );
+
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ if ( isset( $params['prefix'] ) ) {
+ $this->addWhereFld( 'iwl_prefix', $params['prefix'] );
+ if ( isset( $params['title'] ) ) {
+ $this->addWhereFld( 'iwl_title', $params['title'] );
+ $this->addOption( 'ORDER BY', 'iwl_from' . $sort );
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'iwl_title' . $sort,
+ 'iwl_from' . $sort
+ ] );
+ }
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'iwl_prefix' . $sort,
+ 'iwl_title' . $sort,
+ 'iwl_from' . $sort
+ ] );
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+
+ $res = $this->select( __METHOD__ );
+
+ $pages = [];
+
+ $count = 0;
+ $result = $this->getResult();
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ // Continue string preserved in case the redirect query doesn't
+ // pass the limit
+ $this->setContinueEnumParameter(
+ 'continue',
+ "{$row->iwl_prefix}|{$row->iwl_title}|{$row->iwl_from}"
+ );
+ break;
+ }
+
+ if ( !is_null( $resultPageSet ) ) {
+ $pages[] = Title::newFromRow( $row );
+ } else {
+ $entry = [ 'pageid' => $row->page_id ];
+
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ ApiQueryBase::addTitleInfo( $entry, $title );
+
+ if ( $row->page_is_redirect ) {
+ $entry['redirect'] = true;
+ }
+
+ if ( $iwprefix ) {
+ $entry['iwprefix'] = $row->iwl_prefix;
+ }
+
+ if ( $iwtitle ) {
+ $entry['iwtitle'] = $row->iwl_title;
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter(
+ 'continue',
+ "{$row->iwl_prefix}|{$row->iwl_title}|{$row->iwl_from}"
+ );
+ break;
+ }
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'iw' );
+ } else {
+ $resultPageSet->populateFromTitles( $pages );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prefix' => null,
+ 'title' => null,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => '',
+ ApiBase::PARAM_TYPE => [
+ 'iwprefix',
+ 'iwtitle',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=iwbacklinks&iwbltitle=Test&iwblprefix=wikibooks'
+ => 'apihelp-query+iwbacklinks-example-simple',
+ 'action=query&generator=iwbacklinks&giwbltitle=Test&giwblprefix=wikibooks&prop=info'
+ => 'apihelp-query+iwbacklinks-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Iwbacklinks';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryIWLinks.php b/www/wiki/includes/api/ApiQueryIWLinks.php
new file mode 100644
index 00000000..9313af30
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryIWLinks.php
@@ -0,0 +1,199 @@
+<?php
+/**
+ * API for MediaWiki 1.17+
+ *
+ * Created on May 14, 2010
+ *
+ * Copyright © 2010 Sam Reed
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query module to list all interwiki links on a page
+ *
+ * @ingroup API
+ */
+class ApiQueryIWLinks extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'iw' );
+ }
+
+ public function execute() {
+ if ( $this->getPageSet()->getGoodTitleCount() == 0 ) {
+ return;
+ }
+
+ $params = $this->extractRequestParams();
+ $prop = array_flip( (array)$params['prop'] );
+
+ if ( isset( $params['title'] ) && !isset( $params['prefix'] ) ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-mustusewith',
+ $this->encodeParamName( 'title' ),
+ $this->encodeParamName( 'prefix' ),
+ ],
+ 'invalidparammix'
+ );
+ }
+
+ // Handle deprecated param
+ $this->requireMaxOneParameter( $params, 'url', 'prop' );
+ if ( $params['url'] ) {
+ $prop = [ 'url' => 1 ];
+ }
+
+ $this->addFields( [
+ 'iwl_from',
+ 'iwl_prefix',
+ 'iwl_title'
+ ] );
+
+ $this->addTables( 'iwlinks' );
+ $this->addWhereFld( 'iwl_from', array_keys( $this->getPageSet()->getGoodTitles() ) );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 3 );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $db = $this->getDB();
+ $iwlfrom = intval( $cont[0] );
+ $iwlprefix = $db->addQuotes( $cont[1] );
+ $iwltitle = $db->addQuotes( $cont[2] );
+ $this->addWhere(
+ "iwl_from $op $iwlfrom OR " .
+ "(iwl_from = $iwlfrom AND " .
+ "(iwl_prefix $op $iwlprefix OR " .
+ "(iwl_prefix = $iwlprefix AND " .
+ "iwl_title $op= $iwltitle)))"
+ );
+ }
+
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ if ( isset( $params['prefix'] ) ) {
+ $this->addWhereFld( 'iwl_prefix', $params['prefix'] );
+ if ( isset( $params['title'] ) ) {
+ $this->addWhereFld( 'iwl_title', $params['title'] );
+ $this->addOption( 'ORDER BY', 'iwl_from' . $sort );
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'iwl_from' . $sort,
+ 'iwl_title' . $sort
+ ] );
+ }
+ } else {
+ // Don't order by iwl_from if it's constant in the WHERE clause
+ if ( count( $this->getPageSet()->getGoodTitles() ) == 1 ) {
+ $this->addOption( 'ORDER BY', 'iwl_prefix' . $sort );
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'iwl_from' . $sort,
+ 'iwl_prefix' . $sort,
+ 'iwl_title' . $sort
+ ] );
+ }
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+ $res = $this->select( __METHOD__ );
+
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter(
+ 'continue',
+ "{$row->iwl_from}|{$row->iwl_prefix}|{$row->iwl_title}"
+ );
+ break;
+ }
+ $entry = [ 'prefix' => $row->iwl_prefix ];
+
+ if ( isset( $prop['url'] ) ) {
+ $title = Title::newFromText( "{$row->iwl_prefix}:{$row->iwl_title}" );
+ if ( $title ) {
+ $entry['url'] = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+ }
+ }
+
+ ApiResult::setContentValue( $entry, 'title', $row->iwl_title );
+ $fit = $this->addPageSubItem( $row->iwl_from, $entry );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter(
+ 'continue',
+ "{$row->iwl_from}|{$row->iwl_prefix}|{$row->iwl_title}"
+ );
+ break;
+ }
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'url',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'prefix' => null,
+ 'title' => null,
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'url' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=iwlinks&titles=Main%20Page'
+ => 'apihelp-query+iwlinks-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Iwlinks';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryImageInfo.php b/www/wiki/includes/api/ApiQueryImageInfo.php
new file mode 100644
index 00000000..b1df982d
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryImageInfo.php
@@ -0,0 +1,826 @@
+<?php
+/**
+ *
+ *
+ * Created on July 6, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query action to get image information and upload history.
+ *
+ * @ingroup API
+ */
+class ApiQueryImageInfo extends ApiQueryBase {
+ const TRANSFORM_LIMIT = 50;
+ private static $transformCount = 0;
+
+ public function __construct( ApiQuery $query, $moduleName, $prefix = 'ii' ) {
+ // We allow a subclass to override the prefix, to create a related API
+ // module. Some other parts of MediaWiki construct this with a null
+ // $prefix, which used to be ignored when this only took two arguments
+ if ( is_null( $prefix ) ) {
+ $prefix = 'ii';
+ }
+ parent::__construct( $query, $moduleName, $prefix );
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $prop = array_flip( $params['prop'] );
+
+ $scale = $this->getScale( $params );
+
+ $opts = [
+ 'version' => $params['metadataversion'],
+ 'language' => $params['extmetadatalanguage'],
+ 'multilang' => $params['extmetadatamultilang'],
+ 'extmetadatafilter' => $params['extmetadatafilter'],
+ 'revdelUser' => $this->getUser(),
+ ];
+
+ if ( isset( $params['badfilecontexttitle'] ) ) {
+ $badFileContextTitle = Title::newFromText( $params['badfilecontexttitle'] );
+ if ( !$badFileContextTitle ) {
+ $this->dieUsage( 'Invalid title in badfilecontexttitle parameter', 'invalid-title' );
+ }
+ } else {
+ $badFileContextTitle = false;
+ }
+
+ $pageIds = $this->getPageSet()->getGoodAndMissingTitlesByNamespace();
+ if ( !empty( $pageIds[NS_FILE] ) ) {
+ $titles = array_keys( $pageIds[NS_FILE] );
+ asort( $titles ); // Ensure the order is always the same
+
+ $fromTitle = null;
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $fromTitle = strval( $cont[0] );
+ $fromTimestamp = $cont[1];
+ // Filter out any titles before $fromTitle
+ foreach ( $titles as $key => $title ) {
+ if ( $title < $fromTitle ) {
+ unset( $titles[$key] );
+ } else {
+ break;
+ }
+ }
+ }
+
+ $user = $this->getUser();
+ $findTitles = array_map( function ( $title ) use ( $user ) {
+ return [
+ 'title' => $title,
+ 'private' => $user,
+ ];
+ }, $titles );
+
+ if ( $params['localonly'] ) {
+ $images = RepoGroup::singleton()->getLocalRepo()->findFiles( $findTitles );
+ } else {
+ $images = RepoGroup::singleton()->findFiles( $findTitles );
+ }
+
+ $result = $this->getResult();
+ foreach ( $titles as $title ) {
+ $info = [];
+ $pageId = $pageIds[NS_FILE][$title];
+ $start = $title === $fromTitle ? $fromTimestamp : $params['start'];
+
+ if ( !isset( $images[$title] ) ) {
+ if ( isset( $prop['uploadwarning'] ) || isset( $prop['badfile'] ) ) {
+ // uploadwarning and badfile need info about non-existing files
+ $images[$title] = wfLocalFile( $title );
+ // Doesn't exist, so set an empty image repository
+ $info['imagerepository'] = '';
+ } else {
+ $result->addValue(
+ [ 'query', 'pages', intval( $pageId ) ],
+ 'imagerepository', ''
+ );
+ // The above can't fail because it doesn't increase the result size
+ continue;
+ }
+ }
+
+ /** @var File $img */
+ $img = $images[$title];
+
+ if ( self::getTransformCount() >= self::TRANSFORM_LIMIT ) {
+ if ( count( $pageIds[NS_FILE] ) == 1 ) {
+ // See the 'the user is screwed' comment below
+ $this->setContinueEnumParameter( 'start',
+ $start !== null ? $start : wfTimestamp( TS_ISO_8601, $img->getTimestamp() )
+ );
+ } else {
+ $this->setContinueEnumParameter( 'continue',
+ $this->getContinueStr( $img, $start ) );
+ }
+ break;
+ }
+
+ if ( !isset( $info['imagerepository'] ) ) {
+ $info['imagerepository'] = $img->getRepoName();
+ }
+ if ( isset( $prop['badfile'] ) ) {
+ $info['badfile'] = (bool)wfIsBadImage( $title, $badFileContextTitle );
+ }
+
+ $fit = $result->addValue( [ 'query', 'pages' ], intval( $pageId ), $info );
+ if ( !$fit ) {
+ if ( count( $pageIds[NS_FILE] ) == 1 ) {
+ // The user is screwed. imageinfo can't be solely
+ // responsible for exceeding the limit in this case,
+ // so set a query-continue that just returns the same
+ // thing again. When the violating queries have been
+ // out-continued, the result will get through
+ $this->setContinueEnumParameter( 'start',
+ $start !== null ? $start : wfTimestamp( TS_ISO_8601, $img->getTimestamp() )
+ );
+ } else {
+ $this->setContinueEnumParameter( 'continue',
+ $this->getContinueStr( $img, $start ) );
+ }
+ break;
+ }
+
+ // Check if we can make the requested thumbnail, and get transform parameters.
+ $finalThumbParams = $this->mergeThumbParams( $img, $scale, $params['urlparam'] );
+
+ // Get information about the current version first
+ // Check that the current version is within the start-end boundaries
+ $gotOne = false;
+ if (
+ ( is_null( $start ) || $img->getTimestamp() <= $start ) &&
+ ( is_null( $params['end'] ) || $img->getTimestamp() >= $params['end'] )
+ ) {
+ $gotOne = true;
+
+ $fit = $this->addPageSubItem( $pageId,
+ static::getInfo( $img, $prop, $result,
+ $finalThumbParams, $opts
+ )
+ );
+ if ( !$fit ) {
+ if ( count( $pageIds[NS_FILE] ) == 1 ) {
+ // See the 'the user is screwed' comment above
+ $this->setContinueEnumParameter( 'start',
+ wfTimestamp( TS_ISO_8601, $img->getTimestamp() ) );
+ } else {
+ $this->setContinueEnumParameter( 'continue',
+ $this->getContinueStr( $img ) );
+ }
+ break;
+ }
+ }
+
+ // Now get the old revisions
+ // Get one more to facilitate query-continue functionality
+ $count = ( $gotOne ? 1 : 0 );
+ $oldies = $img->getHistory( $params['limit'] - $count + 1, $start, $params['end'] );
+ /** @var File $oldie */
+ foreach ( $oldies as $oldie ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the extra one which shows that there are
+ // additional pages to be had. Stop here...
+ // Only set a query-continue if there was only one title
+ if ( count( $pageIds[NS_FILE] ) == 1 ) {
+ $this->setContinueEnumParameter( 'start',
+ wfTimestamp( TS_ISO_8601, $oldie->getTimestamp() ) );
+ }
+ break;
+ }
+ $fit = self::getTransformCount() < self::TRANSFORM_LIMIT &&
+ $this->addPageSubItem( $pageId,
+ static::getInfo( $oldie, $prop, $result,
+ $finalThumbParams, $opts
+ )
+ );
+ if ( !$fit ) {
+ if ( count( $pageIds[NS_FILE] ) == 1 ) {
+ $this->setContinueEnumParameter( 'start',
+ wfTimestamp( TS_ISO_8601, $oldie->getTimestamp() ) );
+ } else {
+ $this->setContinueEnumParameter( 'continue',
+ $this->getContinueStr( $oldie ) );
+ }
+ break;
+ }
+ }
+ if ( !$fit ) {
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * From parameters, construct a 'scale' array
+ * @param array $params Parameters passed to api.
+ * @return array|null Key-val array of 'width' and 'height', or null
+ */
+ public function getScale( $params ) {
+ if ( $params['urlwidth'] != -1 ) {
+ $scale = [];
+ $scale['width'] = $params['urlwidth'];
+ $scale['height'] = $params['urlheight'];
+ } elseif ( $params['urlheight'] != -1 ) {
+ // Height is specified but width isn't
+ // Don't set $scale['width']; this signals mergeThumbParams() to fill it with the image's width
+ $scale = [];
+ $scale['height'] = $params['urlheight'];
+ } else {
+ if ( $params['urlparam'] ) {
+ // Audio files might not have a width/height.
+ $scale = [];
+ } else {
+ $scale = null;
+ }
+ }
+
+ return $scale;
+ }
+
+ /** Validate and merge scale parameters with handler thumb parameters, give error if invalid.
+ *
+ * We do this later than getScale, since we need the image
+ * to know which handler, since handlers can make their own parameters.
+ * @param File $image Image that params are for.
+ * @param array $thumbParams Thumbnail parameters from getScale
+ * @param string $otherParams String of otherParams (iiurlparam).
+ * @return array Array of parameters for transform.
+ */
+ protected function mergeThumbParams( $image, $thumbParams, $otherParams ) {
+ if ( $thumbParams === null ) {
+ // No scaling requested
+ return null;
+ }
+ if ( !isset( $thumbParams['width'] ) && isset( $thumbParams['height'] ) ) {
+ // We want to limit only by height in this situation, so pass the
+ // image's full width as the limiting width. But some file types
+ // don't have a width of their own, so pick something arbitrary so
+ // thumbnailing the default icon works.
+ if ( $image->getWidth() <= 0 ) {
+ $thumbParams['width'] = max( $this->getConfig()->get( 'ThumbLimits' ) );
+ } else {
+ $thumbParams['width'] = $image->getWidth();
+ }
+ }
+
+ if ( !$otherParams ) {
+ $this->checkParameterNormalise( $image, $thumbParams );
+ return $thumbParams;
+ }
+ $p = $this->getModulePrefix();
+
+ $h = $image->getHandler();
+ if ( !$h ) {
+ $this->addWarning( [ 'apiwarn-nothumb-noimagehandler', wfEscapeWikiText( $image->getName() ) ] );
+
+ return $thumbParams;
+ }
+
+ $paramList = $h->parseParamString( $otherParams );
+ if ( !$paramList ) {
+ // Just set a warning (instead of dieUsage), as in many cases
+ // we could still render the image using width and height parameters,
+ // and this type of thing could happen between different versions of
+ // handlers.
+ $this->addWarning( [ 'apiwarn-badurlparam', $p, wfEscapeWikiText( $image->getName() ) ] );
+ $this->checkParameterNormalise( $image, $thumbParams );
+ return $thumbParams;
+ }
+
+ if ( isset( $paramList['width'] ) && isset( $thumbParams['width'] ) ) {
+ if ( intval( $paramList['width'] ) != intval( $thumbParams['width'] ) ) {
+ $this->addWarning(
+ [ 'apiwarn-urlparamwidth', $p, $paramList['width'], $thumbParams['width'] ]
+ );
+ }
+ }
+
+ foreach ( $paramList as $name => $value ) {
+ if ( !$h->validateParam( $name, $value ) ) {
+ $this->dieWithError(
+ [ 'apierror-invalidurlparam', $p, wfEscapeWikiText( $name ), wfEscapeWikiText( $value ) ]
+ );
+ }
+ }
+
+ $finalParams = $thumbParams + $paramList;
+ $this->checkParameterNormalise( $image, $finalParams );
+ return $finalParams;
+ }
+
+ /**
+ * Verify that the final image parameters can be normalised.
+ *
+ * This doesn't use the normalised parameters, since $file->transform
+ * expects the pre-normalised parameters, but doing the normalisation
+ * allows us to catch certain error conditions early (such as missing
+ * required parameter).
+ *
+ * @param File $image
+ * @param array $finalParams List of parameters to transform image with
+ */
+ protected function checkParameterNormalise( $image, $finalParams ) {
+ $h = $image->getHandler();
+ if ( !$h ) {
+ return;
+ }
+ // Note: normaliseParams modifies the array in place, but we aren't interested
+ // in the actual normalised version, only if we can actually normalise them,
+ // so we use the functions scope to throw away the normalisations.
+ if ( !$h->normaliseParams( $image, $finalParams ) ) {
+ $this->dieWithError( [ 'apierror-urlparamnormal', wfEscapeWikiText( $image->getName() ) ] );
+ }
+ }
+
+ /**
+ * Get result information for an image revision
+ *
+ * @param File $file
+ * @param array $prop Array of properties to get (in the keys)
+ * @param ApiResult $result
+ * @param array $thumbParams Containing 'width' and 'height' items, or null
+ * @param array|bool|string $opts Options for data fetching.
+ * This is an array consisting of the keys:
+ * 'version': The metadata version for the metadata option
+ * 'language': The language for extmetadata property
+ * 'multilang': Return all translations in extmetadata property
+ * 'revdelUser': User to use when checking whether to show revision-deleted fields.
+ * @return array Result array
+ */
+ public static function getInfo( $file, $prop, $result, $thumbParams = null, $opts = false ) {
+ global $wgContLang;
+
+ $anyHidden = false;
+
+ if ( !$opts || is_string( $opts ) ) {
+ $opts = [
+ 'version' => $opts ?: 'latest',
+ 'language' => $wgContLang,
+ 'multilang' => false,
+ 'extmetadatafilter' => [],
+ 'revdelUser' => null,
+ ];
+ }
+ $version = $opts['version'];
+ $vals = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ // Timestamp is shown even if the file is revdelete'd in interface
+ // so do same here.
+ if ( isset( $prop['timestamp'] ) ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $file->getTimestamp() );
+ }
+
+ // Handle external callers who don't pass revdelUser
+ if ( isset( $opts['revdelUser'] ) && $opts['revdelUser'] ) {
+ $revdelUser = $opts['revdelUser'];
+ $canShowField = function ( $field ) use ( $file, $revdelUser ) {
+ return $file->userCan( $field, $revdelUser );
+ };
+ } else {
+ $canShowField = function ( $field ) use ( $file ) {
+ return !$file->isDeleted( $field );
+ };
+ }
+
+ $user = isset( $prop['user'] );
+ $userid = isset( $prop['userid'] );
+
+ if ( $user || $userid ) {
+ if ( $file->isDeleted( File::DELETED_USER ) ) {
+ $vals['userhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( $canShowField( File::DELETED_USER ) ) {
+ if ( $user ) {
+ $vals['user'] = $file->getUser();
+ }
+ if ( $userid ) {
+ $vals['userid'] = $file->getUser( 'id' );
+ }
+ if ( !$file->getUser( 'id' ) ) {
+ $vals['anon'] = true;
+ }
+ }
+ }
+
+ // This is shown even if the file is revdelete'd in interface
+ // so do same here.
+ if ( isset( $prop['size'] ) || isset( $prop['dimensions'] ) ) {
+ $vals['size'] = intval( $file->getSize() );
+ $vals['width'] = intval( $file->getWidth() );
+ $vals['height'] = intval( $file->getHeight() );
+
+ $pageCount = $file->pageCount();
+ if ( $pageCount !== false ) {
+ $vals['pagecount'] = $pageCount;
+ }
+
+ // length as in how many seconds long a video is.
+ $length = $file->getLength();
+ if ( $length ) {
+ // Call it duration, because "length" can be ambiguous.
+ $vals['duration'] = (float)$length;
+ }
+ }
+
+ $pcomment = isset( $prop['parsedcomment'] );
+ $comment = isset( $prop['comment'] );
+
+ if ( $pcomment || $comment ) {
+ if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
+ $vals['commenthidden'] = true;
+ $anyHidden = true;
+ }
+ if ( $canShowField( File::DELETED_COMMENT ) ) {
+ if ( $pcomment ) {
+ $vals['parsedcomment'] = Linker::formatComment(
+ $file->getDescription( File::RAW ), $file->getTitle() );
+ }
+ if ( $comment ) {
+ $vals['comment'] = $file->getDescription( File::RAW );
+ }
+ }
+ }
+
+ $canonicaltitle = isset( $prop['canonicaltitle'] );
+ $url = isset( $prop['url'] );
+ $sha1 = isset( $prop['sha1'] );
+ $meta = isset( $prop['metadata'] );
+ $extmetadata = isset( $prop['extmetadata'] );
+ $commonmeta = isset( $prop['commonmetadata'] );
+ $mime = isset( $prop['mime'] );
+ $mediatype = isset( $prop['mediatype'] );
+ $archive = isset( $prop['archivename'] );
+ $bitdepth = isset( $prop['bitdepth'] );
+ $uploadwarning = isset( $prop['uploadwarning'] );
+
+ if ( $uploadwarning ) {
+ $vals['html'] = SpecialUpload::getExistsWarning( UploadBase::getExistsWarning( $file ) );
+ }
+
+ if ( $file->isDeleted( File::DELETED_FILE ) ) {
+ $vals['filehidden'] = true;
+ $anyHidden = true;
+ }
+
+ if ( $anyHidden && $file->isDeleted( File::DELETED_RESTRICTED ) ) {
+ $vals['suppressed'] = true;
+ }
+
+ if ( !$canShowField( File::DELETED_FILE ) ) {
+ // Early return, tidier than indenting all following things one level
+ return $vals;
+ }
+
+ if ( $canonicaltitle ) {
+ $vals['canonicaltitle'] = $file->getTitle()->getPrefixedText();
+ }
+
+ if ( $url ) {
+ if ( !is_null( $thumbParams ) ) {
+ $mto = $file->transform( $thumbParams );
+ self::$transformCount++;
+ if ( $mto && !$mto->isError() ) {
+ $vals['thumburl'] = wfExpandUrl( $mto->getUrl(), PROTO_CURRENT );
+
+ // T25834 - If the URLs are the same, we haven't resized it, so shouldn't give the wanted
+ // thumbnail sizes for the thumbnail actual size
+ if ( $mto->getUrl() !== $file->getUrl() ) {
+ $vals['thumbwidth'] = intval( $mto->getWidth() );
+ $vals['thumbheight'] = intval( $mto->getHeight() );
+ } else {
+ $vals['thumbwidth'] = intval( $file->getWidth() );
+ $vals['thumbheight'] = intval( $file->getHeight() );
+ }
+
+ if ( isset( $prop['thumbmime'] ) && $file->getHandler() ) {
+ list( , $mime ) = $file->getHandler()->getThumbType(
+ $mto->getExtension(), $file->getMimeType(), $thumbParams );
+ $vals['thumbmime'] = $mime;
+ }
+ } elseif ( $mto && $mto->isError() ) {
+ $vals['thumberror'] = $mto->toText();
+ }
+ }
+ $vals['url'] = wfExpandUrl( $file->getFullUrl(), PROTO_CURRENT );
+ $vals['descriptionurl'] = wfExpandUrl( $file->getDescriptionUrl(), PROTO_CURRENT );
+
+ $shortDescriptionUrl = $file->getDescriptionShortUrl();
+ if ( $shortDescriptionUrl !== null ) {
+ $vals['descriptionshorturl'] = wfExpandUrl( $shortDescriptionUrl, PROTO_CURRENT );
+ }
+ }
+
+ if ( $sha1 ) {
+ $vals['sha1'] = Wikimedia\base_convert( $file->getSha1(), 36, 16, 40 );
+ }
+
+ if ( $meta ) {
+ MediaWiki\suppressWarnings();
+ $metadata = unserialize( $file->getMetadata() );
+ MediaWiki\restoreWarnings();
+ if ( $metadata && $version !== 'latest' ) {
+ $metadata = $file->convertMetadataVersion( $metadata, $version );
+ }
+ $vals['metadata'] = $metadata ? static::processMetaData( $metadata, $result ) : null;
+ }
+ if ( $commonmeta ) {
+ $metaArray = $file->getCommonMetaArray();
+ $vals['commonmetadata'] = $metaArray ? static::processMetaData( $metaArray, $result ) : [];
+ }
+
+ if ( $extmetadata ) {
+ // Note, this should return an array where all the keys
+ // start with a letter, and all the values are strings.
+ // Thus there should be no issue with format=xml.
+ $format = new FormatMetadata;
+ $format->setSingleLanguage( !$opts['multilang'] );
+ $format->getContext()->setLanguage( $opts['language'] );
+ $extmetaArray = $format->fetchExtendedMetadata( $file );
+ if ( $opts['extmetadatafilter'] ) {
+ $extmetaArray = array_intersect_key(
+ $extmetaArray, array_flip( $opts['extmetadatafilter'] )
+ );
+ }
+ $vals['extmetadata'] = $extmetaArray;
+ }
+
+ if ( $mime ) {
+ $vals['mime'] = $file->getMimeType();
+ }
+
+ if ( $mediatype ) {
+ $vals['mediatype'] = $file->getMediaType();
+ }
+
+ if ( $archive && $file->isOld() ) {
+ $vals['archivename'] = $file->getArchiveName();
+ }
+
+ if ( $bitdepth ) {
+ $vals['bitdepth'] = $file->getBitDepth();
+ }
+
+ return $vals;
+ }
+
+ /**
+ * Get the count of image transformations performed
+ *
+ * If this is >= TRANSFORM_LIMIT, you should probably stop processing images.
+ *
+ * @return int Count
+ */
+ static function getTransformCount() {
+ return self::$transformCount;
+ }
+
+ /**
+ *
+ * @param array $metadata
+ * @param ApiResult $result
+ * @return array
+ */
+ public static function processMetaData( $metadata, $result ) {
+ $retval = [];
+ if ( is_array( $metadata ) ) {
+ foreach ( $metadata as $key => $value ) {
+ $r = [
+ 'name' => $key,
+ ApiResult::META_BC_BOOLS => [ 'value' ],
+ ];
+ if ( is_array( $value ) ) {
+ $r['value'] = static::processMetaData( $value, $result );
+ } else {
+ $r['value'] = $value;
+ }
+ $retval[] = $r;
+ }
+ }
+ ApiResult::setIndexedTagName( $retval, 'metadata' );
+
+ return $retval;
+ }
+
+ public function getCacheMode( $params ) {
+ if ( $this->userCanSeeRevDel() ) {
+ return 'private';
+ }
+
+ return 'public';
+ }
+
+ /**
+ * @param File $img
+ * @param null|string $start
+ * @return string
+ */
+ protected function getContinueStr( $img, $start = null ) {
+ if ( $start === null ) {
+ $start = $img->getTimestamp();
+ }
+
+ return $img->getOriginalTitle()->getDBkey() . '|' . $start;
+ }
+
+ public function getAllowedParams() {
+ global $wgContLang;
+
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'timestamp|user',
+ ApiBase::PARAM_TYPE => static::getPropertyNames(),
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => static::getPropertyMessages(),
+ ],
+ 'limit' => [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_DFLT => 1,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'urlwidth' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_DFLT => -1,
+ ApiBase::PARAM_HELP_MSG => [
+ 'apihelp-query+imageinfo-param-urlwidth',
+ self::TRANSFORM_LIMIT,
+ ],
+ ],
+ 'urlheight' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_DFLT => -1
+ ],
+ 'metadataversion' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_DFLT => '1',
+ ],
+ 'extmetadatalanguage' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_DFLT => $wgContLang->getCode(),
+ ],
+ 'extmetadatamultilang' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ],
+ 'extmetadatafilter' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'urlparam' => [
+ ApiBase::PARAM_DFLT => '',
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'badfilecontexttitle' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'localonly' => false,
+ ];
+ }
+
+ /**
+ * Returns all possible parameters to iiprop
+ *
+ * @param array $filter List of properties to filter out
+ * @return array
+ */
+ public static function getPropertyNames( $filter = [] ) {
+ return array_keys( static::getPropertyMessages( $filter ) );
+ }
+
+ /**
+ * Returns messages for all possible parameters to iiprop
+ *
+ * @param array $filter List of properties to filter out
+ * @return array
+ */
+ public static function getPropertyMessages( $filter = [] ) {
+ return array_diff_key(
+ [
+ 'timestamp' => 'apihelp-query+imageinfo-paramvalue-prop-timestamp',
+ 'user' => 'apihelp-query+imageinfo-paramvalue-prop-user',
+ 'userid' => 'apihelp-query+imageinfo-paramvalue-prop-userid',
+ 'comment' => 'apihelp-query+imageinfo-paramvalue-prop-comment',
+ 'parsedcomment' => 'apihelp-query+imageinfo-paramvalue-prop-parsedcomment',
+ 'canonicaltitle' => 'apihelp-query+imageinfo-paramvalue-prop-canonicaltitle',
+ 'url' => 'apihelp-query+imageinfo-paramvalue-prop-url',
+ 'size' => 'apihelp-query+imageinfo-paramvalue-prop-size',
+ 'dimensions' => 'apihelp-query+imageinfo-paramvalue-prop-dimensions',
+ 'sha1' => 'apihelp-query+imageinfo-paramvalue-prop-sha1',
+ 'mime' => 'apihelp-query+imageinfo-paramvalue-prop-mime',
+ 'thumbmime' => 'apihelp-query+imageinfo-paramvalue-prop-thumbmime',
+ 'mediatype' => 'apihelp-query+imageinfo-paramvalue-prop-mediatype',
+ 'metadata' => 'apihelp-query+imageinfo-paramvalue-prop-metadata',
+ 'commonmetadata' => 'apihelp-query+imageinfo-paramvalue-prop-commonmetadata',
+ 'extmetadata' => 'apihelp-query+imageinfo-paramvalue-prop-extmetadata',
+ 'archivename' => 'apihelp-query+imageinfo-paramvalue-prop-archivename',
+ 'bitdepth' => 'apihelp-query+imageinfo-paramvalue-prop-bitdepth',
+ 'uploadwarning' => 'apihelp-query+imageinfo-paramvalue-prop-uploadwarning',
+ 'badfile' => 'apihelp-query+imageinfo-paramvalue-prop-badfile',
+ ],
+ array_flip( $filter )
+ );
+ }
+
+ /**
+ * Returns array key value pairs of properties and their descriptions
+ *
+ * @deprecated since 1.25
+ * @param string $modulePrefix
+ * @return array
+ */
+ private static function getProperties( $modulePrefix = '' ) {
+ return [
+ 'timestamp' => ' timestamp - Adds timestamp for the uploaded version',
+ 'user' => ' user - Adds the user who uploaded the image version',
+ 'userid' => ' userid - Add the user ID that uploaded the image version',
+ 'comment' => ' comment - Comment on the version',
+ 'parsedcomment' => ' parsedcomment - Parse the comment on the version',
+ 'canonicaltitle' => ' canonicaltitle - Adds the canonical title of the image file',
+ 'url' => ' url - Gives URL to the image and the description page',
+ 'size' => ' size - Adds the size of the image in bytes, ' .
+ 'its height and its width. Page count and duration are added if applicable',
+ 'dimensions' => ' dimensions - Alias for size', // B/C with Allimages
+ 'sha1' => ' sha1 - Adds SHA-1 hash for the image',
+ 'mime' => ' mime - Adds MIME type of the image',
+ 'thumbmime' => ' thumbmime - Adds MIME type of the image thumbnail' .
+ ' (requires url and param ' . $modulePrefix . 'urlwidth)',
+ 'mediatype' => ' mediatype - Adds the media type of the image',
+ 'metadata' => ' metadata - Lists Exif metadata for the version of the image',
+ 'commonmetadata' => ' commonmetadata - Lists file format generic metadata ' .
+ 'for the version of the image',
+ 'extmetadata' => ' extmetadata - Lists formatted metadata combined ' .
+ 'from multiple sources. Results are HTML formatted.',
+ 'archivename' => ' archivename - Adds the file name of the archive ' .
+ 'version for non-latest versions',
+ 'bitdepth' => ' bitdepth - Adds the bit depth of the version',
+ 'uploadwarning' => ' uploadwarning - Used by the Special:Upload page to ' .
+ 'get information about an existing file. Not intended for use outside MediaWiki core',
+ ];
+ }
+
+ /**
+ * Returns the descriptions for the properties provided by getPropertyNames()
+ *
+ * @deprecated since 1.25
+ * @param array $filter List of properties to filter out
+ * @param string $modulePrefix
+ * @return array
+ */
+ public static function getPropertyDescriptions( $filter = [], $modulePrefix = '' ) {
+ return array_merge(
+ [ 'What image information to get:' ],
+ array_values( array_diff_key( static::getProperties( $modulePrefix ), array_flip( $filter ) ) )
+ );
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&titles=File:Albert%20Einstein%20Head.jpg&prop=imageinfo'
+ => 'apihelp-query+imageinfo-example-simple',
+ 'action=query&titles=File:Test.jpg&prop=imageinfo&iilimit=50&' .
+ 'iiend=2007-12-31T23:59:59Z&iiprop=timestamp|user|url'
+ => 'apihelp-query+imageinfo-example-dated',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Imageinfo';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryImages.php b/www/wiki/includes/api/ApiQueryImages.php
new file mode 100644
index 00000000..0086c58a
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryImages.php
@@ -0,0 +1,177 @@
+<?php
+/**
+ *
+ *
+ * Created on May 13, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This query adds an "<images>" subelement to all pages with the list of
+ * images embedded into those pages.
+ *
+ * @ingroup API
+ */
+class ApiQueryImages extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'im' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ */
+ private function run( $resultPageSet = null ) {
+ if ( $this->getPageSet()->getGoodTitleCount() == 0 ) {
+ return; // nothing to do
+ }
+
+ $params = $this->extractRequestParams();
+ $this->addFields( [
+ 'il_from',
+ 'il_to'
+ ] );
+
+ $this->addTables( 'imagelinks' );
+ $this->addWhereFld( 'il_from', array_keys( $this->getPageSet()->getGoodTitles() ) );
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $ilfrom = intval( $cont[0] );
+ $ilto = $this->getDB()->addQuotes( $cont[1] );
+ $this->addWhere(
+ "il_from $op $ilfrom OR " .
+ "(il_from = $ilfrom AND " .
+ "il_to $op= $ilto)"
+ );
+ }
+
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ // Don't order by il_from if it's constant in the WHERE clause
+ if ( count( $this->getPageSet()->getGoodTitles() ) == 1 ) {
+ $this->addOption( 'ORDER BY', 'il_to' . $sort );
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'il_from' . $sort,
+ 'il_to' . $sort
+ ] );
+ }
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+
+ if ( !is_null( $params['images'] ) ) {
+ $images = [];
+ foreach ( $params['images'] as $img ) {
+ $title = Title::newFromText( $img );
+ if ( !$title || $title->getNamespace() != NS_FILE ) {
+ $this->addWarning( [ 'apiwarn-notfile', wfEscapeWikiText( $img ) ] );
+ } else {
+ $images[] = $title->getDBkey();
+ }
+ }
+ $this->addWhereFld( 'il_to', $images );
+ }
+
+ $res = $this->select( __METHOD__ );
+
+ if ( is_null( $resultPageSet ) ) {
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->il_from . '|' . $row->il_to );
+ break;
+ }
+ $vals = [];
+ ApiQueryBase::addTitleInfo( $vals, Title::makeTitle( NS_FILE, $row->il_to ) );
+ $fit = $this->addPageSubItem( $row->il_from, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->il_from . '|' . $row->il_to );
+ break;
+ }
+ }
+ } else {
+ $titles = [];
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->il_from . '|' . $row->il_to );
+ break;
+ }
+ $titles[] = Title::makeTitle( NS_FILE, $row->il_to );
+ }
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'images' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=images&titles=Main%20Page'
+ => 'apihelp-query+images-example-simple',
+ 'action=query&generator=images&titles=Main%20Page&prop=info'
+ => 'apihelp-query+images-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Images';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryInfo.php b/www/wiki/includes/api/ApiQueryInfo.php
new file mode 100644
index 00000000..bff19780
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryInfo.php
@@ -0,0 +1,951 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 25, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * A query module to show basic page information.
+ *
+ * @ingroup API
+ */
+class ApiQueryInfo extends ApiQueryBase {
+
+ private $fld_protection = false, $fld_talkid = false,
+ $fld_subjectid = false, $fld_url = false,
+ $fld_readable = false, $fld_watched = false,
+ $fld_watchers = false, $fld_visitingwatchers = false,
+ $fld_notificationtimestamp = false,
+ $fld_preload = false, $fld_displaytitle = false;
+
+ private $params;
+
+ /** @var Title[] */
+ private $titles;
+ /** @var Title[] */
+ private $missing;
+ /** @var Title[] */
+ private $everything;
+
+ private $pageRestrictions, $pageIsRedir, $pageIsNew, $pageTouched,
+ $pageLatest, $pageLength;
+
+ private $protections, $restrictionTypes, $watched, $watchers, $visitingwatchers,
+ $notificationtimestamps, $talkids, $subjectids, $displaytitles;
+ private $showZeroWatchers = false;
+
+ private $tokenFunctions;
+
+ private $countTestedActions = 0;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'in' );
+ }
+
+ /**
+ * @param ApiPageSet $pageSet
+ * @return void
+ */
+ public function requestExtraData( $pageSet ) {
+ $pageSet->requestField( 'page_restrictions' );
+ // If the pageset is resolving redirects we won't get page_is_redirect.
+ // But we can't know for sure until the pageset is executed (revids may
+ // turn it off), so request it unconditionally.
+ $pageSet->requestField( 'page_is_redirect' );
+ $pageSet->requestField( 'page_is_new' );
+ $config = $this->getConfig();
+ $pageSet->requestField( 'page_touched' );
+ $pageSet->requestField( 'page_latest' );
+ $pageSet->requestField( 'page_len' );
+ if ( $config->get( 'ContentHandlerUseDB' ) ) {
+ $pageSet->requestField( 'page_content_model' );
+ }
+ if ( $config->get( 'PageLanguageUseDB' ) ) {
+ $pageSet->requestField( 'page_lang' );
+ }
+ }
+
+ /**
+ * Get an array mapping token names to their handler functions.
+ * The prototype for a token function is func($pageid, $title)
+ * it should return a token or false (permission denied)
+ * @deprecated since 1.24
+ * @return array [ tokenname => function ]
+ */
+ protected function getTokenFunctions() {
+ // Don't call the hooks twice
+ if ( isset( $this->tokenFunctions ) ) {
+ return $this->tokenFunctions;
+ }
+
+ // If we're in a mode that breaks the same-origin policy, no tokens can
+ // be obtained
+ if ( $this->lacksSameOriginSecurity() ) {
+ return [];
+ }
+
+ $this->tokenFunctions = [
+ 'edit' => [ 'ApiQueryInfo', 'getEditToken' ],
+ 'delete' => [ 'ApiQueryInfo', 'getDeleteToken' ],
+ 'protect' => [ 'ApiQueryInfo', 'getProtectToken' ],
+ 'move' => [ 'ApiQueryInfo', 'getMoveToken' ],
+ 'block' => [ 'ApiQueryInfo', 'getBlockToken' ],
+ 'unblock' => [ 'ApiQueryInfo', 'getUnblockToken' ],
+ 'email' => [ 'ApiQueryInfo', 'getEmailToken' ],
+ 'import' => [ 'ApiQueryInfo', 'getImportToken' ],
+ 'watch' => [ 'ApiQueryInfo', 'getWatchToken' ],
+ ];
+ Hooks::run( 'APIQueryInfoTokens', [ &$this->tokenFunctions ] );
+
+ return $this->tokenFunctions;
+ }
+
+ static protected $cachedTokens = [];
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function resetTokenCache() {
+ self::$cachedTokens = [];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getEditToken( $pageid, $title ) {
+ // We could check for $title->userCan('edit') here,
+ // but that's too expensive for this purpose
+ // and would break caching
+ global $wgUser;
+ if ( !$wgUser->isAllowed( 'edit' ) ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['edit'] ) ) {
+ self::$cachedTokens['edit'] = $wgUser->getEditToken();
+ }
+
+ return self::$cachedTokens['edit'];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getDeleteToken( $pageid, $title ) {
+ global $wgUser;
+ if ( !$wgUser->isAllowed( 'delete' ) ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['delete'] ) ) {
+ self::$cachedTokens['delete'] = $wgUser->getEditToken();
+ }
+
+ return self::$cachedTokens['delete'];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getProtectToken( $pageid, $title ) {
+ global $wgUser;
+ if ( !$wgUser->isAllowed( 'protect' ) ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['protect'] ) ) {
+ self::$cachedTokens['protect'] = $wgUser->getEditToken();
+ }
+
+ return self::$cachedTokens['protect'];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getMoveToken( $pageid, $title ) {
+ global $wgUser;
+ if ( !$wgUser->isAllowed( 'move' ) ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['move'] ) ) {
+ self::$cachedTokens['move'] = $wgUser->getEditToken();
+ }
+
+ return self::$cachedTokens['move'];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getBlockToken( $pageid, $title ) {
+ global $wgUser;
+ if ( !$wgUser->isAllowed( 'block' ) ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['block'] ) ) {
+ self::$cachedTokens['block'] = $wgUser->getEditToken();
+ }
+
+ return self::$cachedTokens['block'];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getUnblockToken( $pageid, $title ) {
+ // Currently, this is exactly the same as the block token
+ return self::getBlockToken( $pageid, $title );
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getEmailToken( $pageid, $title ) {
+ global $wgUser;
+ if ( !$wgUser->canSendEmail() || $wgUser->isBlockedFromEmailuser() ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['email'] ) ) {
+ self::$cachedTokens['email'] = $wgUser->getEditToken();
+ }
+
+ return self::$cachedTokens['email'];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getImportToken( $pageid, $title ) {
+ global $wgUser;
+ if ( !$wgUser->isAllowedAny( 'import', 'importupload' ) ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['import'] ) ) {
+ self::$cachedTokens['import'] = $wgUser->getEditToken();
+ }
+
+ return self::$cachedTokens['import'];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getWatchToken( $pageid, $title ) {
+ global $wgUser;
+ if ( !$wgUser->isLoggedIn() ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['watch'] ) ) {
+ self::$cachedTokens['watch'] = $wgUser->getEditToken( 'watch' );
+ }
+
+ return self::$cachedTokens['watch'];
+ }
+
+ /**
+ * @deprecated since 1.24
+ */
+ public static function getOptionsToken( $pageid, $title ) {
+ global $wgUser;
+ if ( !$wgUser->isLoggedIn() ) {
+ return false;
+ }
+
+ // The token is always the same, let's exploit that
+ if ( !isset( self::$cachedTokens['options'] ) ) {
+ self::$cachedTokens['options'] = $wgUser->getEditToken();
+ }
+
+ return self::$cachedTokens['options'];
+ }
+
+ public function execute() {
+ $this->params = $this->extractRequestParams();
+ if ( !is_null( $this->params['prop'] ) ) {
+ $prop = array_flip( $this->params['prop'] );
+ $this->fld_protection = isset( $prop['protection'] );
+ $this->fld_watched = isset( $prop['watched'] );
+ $this->fld_watchers = isset( $prop['watchers'] );
+ $this->fld_visitingwatchers = isset( $prop['visitingwatchers'] );
+ $this->fld_notificationtimestamp = isset( $prop['notificationtimestamp'] );
+ $this->fld_talkid = isset( $prop['talkid'] );
+ $this->fld_subjectid = isset( $prop['subjectid'] );
+ $this->fld_url = isset( $prop['url'] );
+ $this->fld_readable = isset( $prop['readable'] );
+ $this->fld_preload = isset( $prop['preload'] );
+ $this->fld_displaytitle = isset( $prop['displaytitle'] );
+ }
+
+ $pageSet = $this->getPageSet();
+ $this->titles = $pageSet->getGoodTitles();
+ $this->missing = $pageSet->getMissingTitles();
+ $this->everything = $this->titles + $this->missing;
+ $result = $this->getResult();
+
+ uasort( $this->everything, [ 'Title', 'compare' ] );
+ if ( !is_null( $this->params['continue'] ) ) {
+ // Throw away any titles we're gonna skip so they don't
+ // clutter queries
+ $cont = explode( '|', $this->params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $conttitle = Title::makeTitleSafe( $cont[0], $cont[1] );
+ foreach ( $this->everything as $pageid => $title ) {
+ if ( Title::compare( $title, $conttitle ) >= 0 ) {
+ break;
+ }
+ unset( $this->titles[$pageid] );
+ unset( $this->missing[$pageid] );
+ unset( $this->everything[$pageid] );
+ }
+ }
+
+ $this->pageRestrictions = $pageSet->getCustomField( 'page_restrictions' );
+ // when resolving redirects, no page will have this field
+ $this->pageIsRedir = !$pageSet->isResolvingRedirects()
+ ? $pageSet->getCustomField( 'page_is_redirect' )
+ : [];
+ $this->pageIsNew = $pageSet->getCustomField( 'page_is_new' );
+
+ $this->pageTouched = $pageSet->getCustomField( 'page_touched' );
+ $this->pageLatest = $pageSet->getCustomField( 'page_latest' );
+ $this->pageLength = $pageSet->getCustomField( 'page_len' );
+
+ // Get protection info if requested
+ if ( $this->fld_protection ) {
+ $this->getProtectionInfo();
+ }
+
+ if ( $this->fld_watched || $this->fld_notificationtimestamp ) {
+ $this->getWatchedInfo();
+ }
+
+ if ( $this->fld_watchers ) {
+ $this->getWatcherInfo();
+ }
+
+ if ( $this->fld_visitingwatchers ) {
+ $this->getVisitingWatcherInfo();
+ }
+
+ // Run the talkid/subjectid query if requested
+ if ( $this->fld_talkid || $this->fld_subjectid ) {
+ $this->getTSIDs();
+ }
+
+ if ( $this->fld_displaytitle ) {
+ $this->getDisplayTitle();
+ }
+
+ /** @var Title $title */
+ foreach ( $this->everything as $pageid => $title ) {
+ $pageInfo = $this->extractPageInfo( $pageid, $title );
+ $fit = $pageInfo !== null && $result->addValue( [
+ 'query',
+ 'pages'
+ ], $pageid, $pageInfo );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue',
+ $title->getNamespace() . '|' .
+ $title->getText() );
+ break;
+ }
+ }
+ }
+
+ /**
+ * Get a result array with information about a title
+ * @param int $pageid Page ID (negative for missing titles)
+ * @param Title $title
+ * @return array|null
+ */
+ private function extractPageInfo( $pageid, $title ) {
+ $pageInfo = [];
+ // $title->exists() needs pageid, which is not set for all title objects
+ $titleExists = $pageid > 0;
+ $ns = $title->getNamespace();
+ $dbkey = $title->getDBkey();
+
+ $pageInfo['contentmodel'] = $title->getContentModel();
+
+ $pageLanguage = $title->getPageLanguage();
+ $pageInfo['pagelanguage'] = $pageLanguage->getCode();
+ $pageInfo['pagelanguagehtmlcode'] = $pageLanguage->getHtmlCode();
+ $pageInfo['pagelanguagedir'] = $pageLanguage->getDir();
+
+ if ( $titleExists ) {
+ $pageInfo['touched'] = wfTimestamp( TS_ISO_8601, $this->pageTouched[$pageid] );
+ $pageInfo['lastrevid'] = intval( $this->pageLatest[$pageid] );
+ $pageInfo['length'] = intval( $this->pageLength[$pageid] );
+
+ if ( isset( $this->pageIsRedir[$pageid] ) && $this->pageIsRedir[$pageid] ) {
+ $pageInfo['redirect'] = true;
+ }
+ if ( $this->pageIsNew[$pageid] ) {
+ $pageInfo['new'] = true;
+ }
+ }
+
+ if ( !is_null( $this->params['token'] ) ) {
+ $tokenFunctions = $this->getTokenFunctions();
+ $pageInfo['starttimestamp'] = wfTimestamp( TS_ISO_8601, time() );
+ foreach ( $this->params['token'] as $t ) {
+ $val = call_user_func( $tokenFunctions[$t], $pageid, $title );
+ if ( $val === false ) {
+ $this->addWarning( [ 'apiwarn-tokennotallowed', $t ] );
+ } else {
+ $pageInfo[$t . 'token'] = $val;
+ }
+ }
+ }
+
+ if ( $this->fld_protection ) {
+ $pageInfo['protection'] = [];
+ if ( isset( $this->protections[$ns][$dbkey] ) ) {
+ $pageInfo['protection'] =
+ $this->protections[$ns][$dbkey];
+ }
+ ApiResult::setIndexedTagName( $pageInfo['protection'], 'pr' );
+
+ $pageInfo['restrictiontypes'] = [];
+ if ( isset( $this->restrictionTypes[$ns][$dbkey] ) ) {
+ $pageInfo['restrictiontypes'] =
+ $this->restrictionTypes[$ns][$dbkey];
+ }
+ ApiResult::setIndexedTagName( $pageInfo['restrictiontypes'], 'rt' );
+ }
+
+ if ( $this->fld_watched && $this->watched !== null ) {
+ $pageInfo['watched'] = $this->watched[$ns][$dbkey];
+ }
+
+ if ( $this->fld_watchers ) {
+ if ( $this->watchers !== null && $this->watchers[$ns][$dbkey] !== 0 ) {
+ $pageInfo['watchers'] = $this->watchers[$ns][$dbkey];
+ } elseif ( $this->showZeroWatchers ) {
+ $pageInfo['watchers'] = 0;
+ }
+ }
+
+ if ( $this->fld_visitingwatchers ) {
+ if ( $this->visitingwatchers !== null && $this->visitingwatchers[$ns][$dbkey] !== 0 ) {
+ $pageInfo['visitingwatchers'] = $this->visitingwatchers[$ns][$dbkey];
+ } elseif ( $this->showZeroWatchers ) {
+ $pageInfo['visitingwatchers'] = 0;
+ }
+ }
+
+ if ( $this->fld_notificationtimestamp ) {
+ $pageInfo['notificationtimestamp'] = '';
+ if ( $this->notificationtimestamps[$ns][$dbkey] ) {
+ $pageInfo['notificationtimestamp'] =
+ wfTimestamp( TS_ISO_8601, $this->notificationtimestamps[$ns][$dbkey] );
+ }
+ }
+
+ if ( $this->fld_talkid && isset( $this->talkids[$ns][$dbkey] ) ) {
+ $pageInfo['talkid'] = $this->talkids[$ns][$dbkey];
+ }
+
+ if ( $this->fld_subjectid && isset( $this->subjectids[$ns][$dbkey] ) ) {
+ $pageInfo['subjectid'] = $this->subjectids[$ns][$dbkey];
+ }
+
+ if ( $this->fld_url ) {
+ $pageInfo['fullurl'] = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+ $pageInfo['editurl'] = wfExpandUrl( $title->getFullURL( 'action=edit' ), PROTO_CURRENT );
+ $pageInfo['canonicalurl'] = wfExpandUrl( $title->getFullURL(), PROTO_CANONICAL );
+ }
+ if ( $this->fld_readable ) {
+ $pageInfo['readable'] = $title->userCan( 'read', $this->getUser() );
+ }
+
+ if ( $this->fld_preload ) {
+ if ( $titleExists ) {
+ $pageInfo['preload'] = '';
+ } else {
+ $text = null;
+ Hooks::run( 'EditFormPreloadText', [ &$text, &$title ] );
+
+ $pageInfo['preload'] = $text;
+ }
+ }
+
+ if ( $this->fld_displaytitle ) {
+ if ( isset( $this->displaytitles[$pageid] ) ) {
+ $pageInfo['displaytitle'] = $this->displaytitles[$pageid];
+ } else {
+ $pageInfo['displaytitle'] = $title->getPrefixedText();
+ }
+ }
+
+ if ( $this->params['testactions'] ) {
+ $limit = $this->getMain()->canApiHighLimits() ? self::LIMIT_SML1 : self::LIMIT_SML2;
+ if ( $this->countTestedActions >= $limit ) {
+ return null; // force a continuation
+ }
+
+ $user = $this->getUser();
+ $pageInfo['actions'] = [];
+ foreach ( $this->params['testactions'] as $action ) {
+ $this->countTestedActions++;
+ $pageInfo['actions'][$action] = $title->userCan( $action, $user );
+ }
+ }
+
+ return $pageInfo;
+ }
+
+ /**
+ * Get information about protections and put it in $protections
+ */
+ private function getProtectionInfo() {
+ $this->protections = [];
+ $db = $this->getDB();
+
+ // Get normal protections for existing titles
+ if ( count( $this->titles ) ) {
+ $this->resetQueryParams();
+ $this->addTables( 'page_restrictions' );
+ $this->addFields( [ 'pr_page', 'pr_type', 'pr_level',
+ 'pr_expiry', 'pr_cascade' ] );
+ $this->addWhereFld( 'pr_page', array_keys( $this->titles ) );
+
+ $res = $this->select( __METHOD__ );
+ foreach ( $res as $row ) {
+ /** @var Title $title */
+ $title = $this->titles[$row->pr_page];
+ $a = [
+ 'type' => $row->pr_type,
+ 'level' => $row->pr_level,
+ 'expiry' => ApiResult::formatExpiry( $row->pr_expiry )
+ ];
+ if ( $row->pr_cascade ) {
+ $a['cascade'] = true;
+ }
+ $this->protections[$title->getNamespace()][$title->getDBkey()][] = $a;
+ }
+ // Also check old restrictions
+ foreach ( $this->titles as $pageId => $title ) {
+ if ( $this->pageRestrictions[$pageId] ) {
+ $namespace = $title->getNamespace();
+ $dbKey = $title->getDBkey();
+ $restrictions = explode( ':', trim( $this->pageRestrictions[$pageId] ) );
+ foreach ( $restrictions as $restrict ) {
+ $temp = explode( '=', trim( $restrict ) );
+ if ( count( $temp ) == 1 ) {
+ // old old format should be treated as edit/move restriction
+ $restriction = trim( $temp[0] );
+
+ if ( $restriction == '' ) {
+ continue;
+ }
+ $this->protections[$namespace][$dbKey][] = [
+ 'type' => 'edit',
+ 'level' => $restriction,
+ 'expiry' => 'infinity',
+ ];
+ $this->protections[$namespace][$dbKey][] = [
+ 'type' => 'move',
+ 'level' => $restriction,
+ 'expiry' => 'infinity',
+ ];
+ } else {
+ $restriction = trim( $temp[1] );
+ if ( $restriction == '' ) {
+ continue;
+ }
+ $this->protections[$namespace][$dbKey][] = [
+ 'type' => $temp[0],
+ 'level' => $restriction,
+ 'expiry' => 'infinity',
+ ];
+ }
+ }
+ }
+ }
+ }
+
+ // Get protections for missing titles
+ if ( count( $this->missing ) ) {
+ $this->resetQueryParams();
+ $lb = new LinkBatch( $this->missing );
+ $this->addTables( 'protected_titles' );
+ $this->addFields( [ 'pt_title', 'pt_namespace', 'pt_create_perm', 'pt_expiry' ] );
+ $this->addWhere( $lb->constructSet( 'pt', $db ) );
+ $res = $this->select( __METHOD__ );
+ foreach ( $res as $row ) {
+ $this->protections[$row->pt_namespace][$row->pt_title][] = [
+ 'type' => 'create',
+ 'level' => $row->pt_create_perm,
+ 'expiry' => ApiResult::formatExpiry( $row->pt_expiry )
+ ];
+ }
+ }
+
+ // Separate good and missing titles into files and other pages
+ // and populate $this->restrictionTypes
+ $images = $others = [];
+ foreach ( $this->everything as $title ) {
+ if ( $title->getNamespace() == NS_FILE ) {
+ $images[] = $title->getDBkey();
+ } else {
+ $others[] = $title;
+ }
+ // Applicable protection types
+ $this->restrictionTypes[$title->getNamespace()][$title->getDBkey()] =
+ array_values( $title->getRestrictionTypes() );
+ }
+
+ if ( count( $others ) ) {
+ // Non-images: check templatelinks
+ $lb = new LinkBatch( $others );
+ $this->resetQueryParams();
+ $this->addTables( [ 'page_restrictions', 'page', 'templatelinks' ] );
+ $this->addFields( [ 'pr_type', 'pr_level', 'pr_expiry',
+ 'page_title', 'page_namespace',
+ 'tl_title', 'tl_namespace' ] );
+ $this->addWhere( $lb->constructSet( 'tl', $db ) );
+ $this->addWhere( 'pr_page = page_id' );
+ $this->addWhere( 'pr_page = tl_from' );
+ $this->addWhereFld( 'pr_cascade', 1 );
+
+ $res = $this->select( __METHOD__ );
+ foreach ( $res as $row ) {
+ $source = Title::makeTitle( $row->page_namespace, $row->page_title );
+ $this->protections[$row->tl_namespace][$row->tl_title][] = [
+ 'type' => $row->pr_type,
+ 'level' => $row->pr_level,
+ 'expiry' => ApiResult::formatExpiry( $row->pr_expiry ),
+ 'source' => $source->getPrefixedText()
+ ];
+ }
+ }
+
+ if ( count( $images ) ) {
+ // Images: check imagelinks
+ $this->resetQueryParams();
+ $this->addTables( [ 'page_restrictions', 'page', 'imagelinks' ] );
+ $this->addFields( [ 'pr_type', 'pr_level', 'pr_expiry',
+ 'page_title', 'page_namespace', 'il_to' ] );
+ $this->addWhere( 'pr_page = page_id' );
+ $this->addWhere( 'pr_page = il_from' );
+ $this->addWhereFld( 'pr_cascade', 1 );
+ $this->addWhereFld( 'il_to', $images );
+
+ $res = $this->select( __METHOD__ );
+ foreach ( $res as $row ) {
+ $source = Title::makeTitle( $row->page_namespace, $row->page_title );
+ $this->protections[NS_FILE][$row->il_to][] = [
+ 'type' => $row->pr_type,
+ 'level' => $row->pr_level,
+ 'expiry' => ApiResult::formatExpiry( $row->pr_expiry ),
+ 'source' => $source->getPrefixedText()
+ ];
+ }
+ }
+ }
+
+ /**
+ * Get talk page IDs (if requested) and subject page IDs (if requested)
+ * and put them in $talkids and $subjectids
+ */
+ private function getTSIDs() {
+ $getTitles = $this->talkids = $this->subjectids = [];
+
+ /** @var Title $t */
+ foreach ( $this->everything as $t ) {
+ if ( MWNamespace::isTalk( $t->getNamespace() ) ) {
+ if ( $this->fld_subjectid ) {
+ $getTitles[] = $t->getSubjectPage();
+ }
+ } elseif ( $this->fld_talkid ) {
+ $getTitles[] = $t->getTalkPage();
+ }
+ }
+ if ( !count( $getTitles ) ) {
+ return;
+ }
+
+ $db = $this->getDB();
+
+ // Construct a custom WHERE clause that matches
+ // all titles in $getTitles
+ $lb = new LinkBatch( $getTitles );
+ $this->resetQueryParams();
+ $this->addTables( 'page' );
+ $this->addFields( [ 'page_title', 'page_namespace', 'page_id' ] );
+ $this->addWhere( $lb->constructSet( 'page', $db ) );
+ $res = $this->select( __METHOD__ );
+ foreach ( $res as $row ) {
+ if ( MWNamespace::isTalk( $row->page_namespace ) ) {
+ $this->talkids[MWNamespace::getSubject( $row->page_namespace )][$row->page_title] =
+ intval( $row->page_id );
+ } else {
+ $this->subjectids[MWNamespace::getTalk( $row->page_namespace )][$row->page_title] =
+ intval( $row->page_id );
+ }
+ }
+ }
+
+ private function getDisplayTitle() {
+ $this->displaytitles = [];
+
+ $pageIds = array_keys( $this->titles );
+
+ if ( !count( $pageIds ) ) {
+ return;
+ }
+
+ $this->resetQueryParams();
+ $this->addTables( 'page_props' );
+ $this->addFields( [ 'pp_page', 'pp_value' ] );
+ $this->addWhereFld( 'pp_page', $pageIds );
+ $this->addWhereFld( 'pp_propname', 'displaytitle' );
+ $res = $this->select( __METHOD__ );
+
+ foreach ( $res as $row ) {
+ $this->displaytitles[$row->pp_page] = $row->pp_value;
+ }
+ }
+
+ /**
+ * Get information about watched status and put it in $this->watched
+ * and $this->notificationtimestamps
+ */
+ private function getWatchedInfo() {
+ $user = $this->getUser();
+
+ if ( $user->isAnon() || count( $this->everything ) == 0
+ || !$user->isAllowed( 'viewmywatchlist' )
+ ) {
+ return;
+ }
+
+ $this->watched = [];
+ $this->notificationtimestamps = [];
+
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $timestamps = $store->getNotificationTimestampsBatch( $user, $this->everything );
+
+ if ( $this->fld_watched ) {
+ foreach ( $timestamps as $namespaceId => $dbKeys ) {
+ $this->watched[$namespaceId] = array_map(
+ function ( $x ) {
+ return $x !== false;
+ },
+ $dbKeys
+ );
+ }
+ }
+ if ( $this->fld_notificationtimestamp ) {
+ $this->notificationtimestamps = $timestamps;
+ }
+ }
+
+ /**
+ * Get the count of watchers and put it in $this->watchers
+ */
+ private function getWatcherInfo() {
+ if ( count( $this->everything ) == 0 ) {
+ return;
+ }
+
+ $user = $this->getUser();
+ $canUnwatchedpages = $user->isAllowed( 'unwatchedpages' );
+ $unwatchedPageThreshold = $this->getConfig()->get( 'UnwatchedPageThreshold' );
+ if ( !$canUnwatchedpages && !is_int( $unwatchedPageThreshold ) ) {
+ return;
+ }
+
+ $this->showZeroWatchers = $canUnwatchedpages;
+
+ $countOptions = [];
+ if ( !$canUnwatchedpages ) {
+ $countOptions['minimumWatchers'] = $unwatchedPageThreshold;
+ }
+
+ $this->watchers = MediaWikiServices::getInstance()->getWatchedItemStore()->countWatchersMultiple(
+ $this->everything,
+ $countOptions
+ );
+ }
+
+ /**
+ * Get the count of watchers who have visited recent edits and put it in
+ * $this->visitingwatchers
+ *
+ * Based on InfoAction::pageCounts
+ */
+ private function getVisitingWatcherInfo() {
+ $config = $this->getConfig();
+ $user = $this->getUser();
+ $db = $this->getDB();
+
+ $canUnwatchedpages = $user->isAllowed( 'unwatchedpages' );
+ $unwatchedPageThreshold = $this->getConfig()->get( 'UnwatchedPageThreshold' );
+ if ( !$canUnwatchedpages && !is_int( $unwatchedPageThreshold ) ) {
+ return;
+ }
+
+ $this->showZeroWatchers = $canUnwatchedpages;
+
+ $titlesWithThresholds = [];
+ if ( $this->titles ) {
+ $lb = new LinkBatch( $this->titles );
+
+ // Fetch last edit timestamps for pages
+ $this->resetQueryParams();
+ $this->addTables( [ 'page', 'revision' ] );
+ $this->addFields( [ 'page_namespace', 'page_title', 'rev_timestamp' ] );
+ $this->addWhere( [
+ 'page_latest = rev_id',
+ $lb->constructSet( 'page', $db ),
+ ] );
+ $this->addOption( 'GROUP BY', [ 'page_namespace', 'page_title' ] );
+ $timestampRes = $this->select( __METHOD__ );
+
+ $age = $config->get( 'WatchersMaxAge' );
+ $timestamps = [];
+ foreach ( $timestampRes as $row ) {
+ $revTimestamp = wfTimestamp( TS_UNIX, (int)$row->rev_timestamp );
+ $timestamps[$row->page_namespace][$row->page_title] = $revTimestamp - $age;
+ }
+ $titlesWithThresholds = array_map(
+ function ( LinkTarget $target ) use ( $timestamps ) {
+ return [
+ $target, $timestamps[$target->getNamespace()][$target->getDBkey()]
+ ];
+ },
+ $this->titles
+ );
+ }
+
+ if ( $this->missing ) {
+ $titlesWithThresholds = array_merge(
+ $titlesWithThresholds,
+ array_map(
+ function ( LinkTarget $target ) {
+ return [ $target, null ];
+ },
+ $this->missing
+ )
+ );
+ }
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $this->visitingwatchers = $store->countVisitingWatchersMultiple(
+ $titlesWithThresholds,
+ !$canUnwatchedpages ? $unwatchedPageThreshold : null
+ );
+ }
+
+ public function getCacheMode( $params ) {
+ // Other props depend on something about the current user
+ $publicProps = [
+ 'protection',
+ 'talkid',
+ 'subjectid',
+ 'url',
+ 'preload',
+ 'displaytitle',
+ ];
+ if ( array_diff( (array)$params['prop'], $publicProps ) ) {
+ return 'private';
+ }
+
+ // testactions also depends on the current user
+ if ( $params['testactions'] ) {
+ return 'private';
+ }
+
+ if ( !is_null( $params['token'] ) ) {
+ return 'private';
+ }
+
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'protection',
+ 'talkid',
+ 'watched', # private
+ 'watchers', # private
+ 'visitingwatchers', # private
+ 'notificationtimestamp', # private
+ 'subjectid',
+ 'url',
+ 'readable', # private
+ 'preload',
+ 'displaytitle',
+ // If you add more properties here, please consider whether they
+ // need to be added to getCacheMode()
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'testactions' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'token' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => array_keys( $this->getTokenFunctions() )
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=info&titles=Main%20Page'
+ => 'apihelp-query+info-example-simple',
+ 'action=query&prop=info&inprop=protection&titles=Main%20Page'
+ => 'apihelp-query+info-example-protection',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Info';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryLangBacklinks.php b/www/wiki/includes/api/ApiQueryLangBacklinks.php
new file mode 100644
index 00000000..fd67d7c4
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryLangBacklinks.php
@@ -0,0 +1,219 @@
+<?php
+/**
+ * API for MediaWiki 1.17+
+ *
+ * Created on May 14, 2011
+ *
+ * Copyright © 2011 Sam Reed
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This gives links pointing to the given interwiki
+ * @ingroup API
+ */
+class ApiQueryLangBacklinks extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'lbl' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ public function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+
+ if ( isset( $params['title'] ) && !isset( $params['lang'] ) ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-mustusewith',
+ $this->encodeParamName( 'title' ),
+ $this->encodeParamName( 'lang' )
+ ],
+ 'nolang'
+ );
+ }
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 3 );
+
+ $db = $this->getDB();
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $prefix = $db->addQuotes( $cont[0] );
+ $title = $db->addQuotes( $cont[1] );
+ $from = intval( $cont[2] );
+ $this->addWhere(
+ "ll_lang $op $prefix OR " .
+ "(ll_lang = $prefix AND " .
+ "(ll_title $op $title OR " .
+ "(ll_title = $title AND " .
+ "ll_from $op= $from)))"
+ );
+ }
+
+ $prop = array_flip( $params['prop'] );
+ $lllang = isset( $prop['lllang'] );
+ $lltitle = isset( $prop['lltitle'] );
+
+ $this->addTables( [ 'langlinks', 'page' ] );
+ $this->addWhere( 'll_from = page_id' );
+
+ $this->addFields( [ 'page_id', 'page_title', 'page_namespace', 'page_is_redirect',
+ 'll_from', 'll_lang', 'll_title' ] );
+
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ if ( isset( $params['lang'] ) ) {
+ $this->addWhereFld( 'll_lang', $params['lang'] );
+ if ( isset( $params['title'] ) ) {
+ $this->addWhereFld( 'll_title', $params['title'] );
+ $this->addOption( 'ORDER BY', 'll_from' . $sort );
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'll_title' . $sort,
+ 'll_from' . $sort
+ ] );
+ }
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'll_lang' . $sort,
+ 'll_title' . $sort,
+ 'll_from' . $sort
+ ] );
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+
+ $res = $this->select( __METHOD__ );
+
+ $pages = [];
+
+ $count = 0;
+ $result = $this->getResult();
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here... Continue string
+ // preserved in case the redirect query doesn't pass the limit.
+ $this->setContinueEnumParameter(
+ 'continue',
+ "{$row->ll_lang}|{$row->ll_title}|{$row->ll_from}"
+ );
+ break;
+ }
+
+ if ( !is_null( $resultPageSet ) ) {
+ $pages[] = Title::newFromRow( $row );
+ } else {
+ $entry = [ 'pageid' => $row->page_id ];
+
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ ApiQueryBase::addTitleInfo( $entry, $title );
+
+ if ( $row->page_is_redirect ) {
+ $entry['redirect'] = true;
+ }
+
+ if ( $lllang ) {
+ $entry['lllang'] = $row->ll_lang;
+ }
+
+ if ( $lltitle ) {
+ $entry['lltitle'] = $row->ll_title;
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter(
+ 'continue',
+ "{$row->ll_lang}|{$row->ll_title}|{$row->ll_from}"
+ );
+ break;
+ }
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'll' );
+ } else {
+ $resultPageSet->populateFromTitles( $pages );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'lang' => null,
+ 'title' => null,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => '',
+ ApiBase::PARAM_TYPE => [
+ 'lllang',
+ 'lltitle',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=langbacklinks&lbltitle=Test&lbllang=fr'
+ => 'apihelp-query+langbacklinks-example-simple',
+ 'action=query&generator=langbacklinks&glbltitle=Test&glbllang=fr&prop=info'
+ => 'apihelp-query+langbacklinks-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Langbacklinks';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryLangLinks.php b/www/wiki/includes/api/ApiQueryLangLinks.php
new file mode 100644
index 00000000..df33d027
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryLangLinks.php
@@ -0,0 +1,195 @@
+<?php
+/**
+ *
+ *
+ * Created on May 13, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query module to list all langlinks (links to corresponding foreign language pages).
+ *
+ * @ingroup API
+ */
+class ApiQueryLangLinks extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'll' );
+ }
+
+ public function execute() {
+ if ( $this->getPageSet()->getGoodTitleCount() == 0 ) {
+ return;
+ }
+
+ $params = $this->extractRequestParams();
+ $prop = array_flip( (array)$params['prop'] );
+
+ if ( isset( $params['title'] ) && !isset( $params['lang'] ) ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-mustusewith',
+ $this->encodeParamName( 'title' ),
+ $this->encodeParamName( 'lang' ),
+ ],
+ 'invalidparammix'
+ );
+ }
+
+ // Handle deprecated param
+ $this->requireMaxOneParameter( $params, 'url', 'prop' );
+ if ( $params['url'] ) {
+ $prop = [ 'url' => 1 ];
+ }
+
+ $this->addFields( [
+ 'll_from',
+ 'll_lang',
+ 'll_title'
+ ] );
+
+ $this->addTables( 'langlinks' );
+ $this->addWhereFld( 'll_from', array_keys( $this->getPageSet()->getGoodTitles() ) );
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $llfrom = intval( $cont[0] );
+ $lllang = $this->getDB()->addQuotes( $cont[1] );
+ $this->addWhere(
+ "ll_from $op $llfrom OR " .
+ "(ll_from = $llfrom AND " .
+ "ll_lang $op= $lllang)"
+ );
+ }
+
+ // FIXME: (follow-up) To allow extensions to add to the language links, we need
+ // to load them all, add the extra links, then apply paging.
+ // Should not be terrible, it's not going to be more than a few hundred links.
+
+ // Note that, since (ll_from, ll_lang) is a unique key, we don't need
+ // to sort by ll_title to ensure deterministic ordering.
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ if ( isset( $params['lang'] ) ) {
+ $this->addWhereFld( 'll_lang', $params['lang'] );
+ if ( isset( $params['title'] ) ) {
+ $this->addWhereFld( 'll_title', $params['title'] );
+ }
+ $this->addOption( 'ORDER BY', 'll_from' . $sort );
+ } else {
+ // Don't order by ll_from if it's constant in the WHERE clause
+ if ( count( $this->getPageSet()->getGoodTitles() ) == 1 ) {
+ $this->addOption( 'ORDER BY', 'll_lang' . $sort );
+ } else {
+ $this->addOption( 'ORDER BY', [
+ 'll_from' . $sort,
+ 'll_lang' . $sort
+ ] );
+ }
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+ $res = $this->select( __METHOD__ );
+
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', "{$row->ll_from}|{$row->ll_lang}" );
+ break;
+ }
+ $entry = [ 'lang' => $row->ll_lang ];
+ if ( isset( $prop['url'] ) ) {
+ $title = Title::newFromText( "{$row->ll_lang}:{$row->ll_title}" );
+ if ( $title ) {
+ $entry['url'] = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+ }
+ }
+ if ( isset( $prop['langname'] ) ) {
+ $entry['langname'] = Language::fetchLanguageName( $row->ll_lang, $params['inlanguagecode'] );
+ }
+ if ( isset( $prop['autonym'] ) ) {
+ $entry['autonym'] = Language::fetchLanguageName( $row->ll_lang );
+ }
+ ApiResult::setContentValue( $entry, 'title', $row->ll_title );
+ $fit = $this->addPageSubItem( $row->ll_from, $entry );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', "{$row->ll_from}|{$row->ll_lang}" );
+ break;
+ }
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ global $wgContLang;
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'url',
+ 'langname',
+ 'autonym',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'lang' => null,
+ 'title' => null,
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ 'inlanguagecode' => $wgContLang->getCode(),
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'url' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=langlinks&titles=Main%20Page&redirects='
+ => 'apihelp-query+langlinks-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Langlinks';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryLinks.php b/www/wiki/includes/api/ApiQueryLinks.php
new file mode 100644
index 00000000..4b340912
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryLinks.php
@@ -0,0 +1,227 @@
+<?php
+/**
+ *
+ *
+ * Created on May 12, 2007
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query module to list all wiki links on a given set of pages.
+ *
+ * @ingroup API
+ */
+class ApiQueryLinks extends ApiQueryGeneratorBase {
+
+ const LINKS = 'links';
+ const TEMPLATES = 'templates';
+
+ private $table, $prefix, $titlesParam, $helpUrl;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ switch ( $moduleName ) {
+ case self::LINKS:
+ $this->table = 'pagelinks';
+ $this->prefix = 'pl';
+ $this->titlesParam = 'titles';
+ $this->helpUrl = 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Links';
+ break;
+ case self::TEMPLATES:
+ $this->table = 'templatelinks';
+ $this->prefix = 'tl';
+ $this->titlesParam = 'templates';
+ $this->helpUrl = 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Templates';
+ break;
+ default:
+ ApiBase::dieDebug( __METHOD__, 'Unknown module name' );
+ }
+
+ parent::__construct( $query, $moduleName, $this->prefix );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ */
+ private function run( $resultPageSet = null ) {
+ if ( $this->getPageSet()->getGoodTitleCount() == 0 ) {
+ return; // nothing to do
+ }
+
+ $params = $this->extractRequestParams();
+
+ $this->addFields( [
+ 'pl_from' => $this->prefix . '_from',
+ 'pl_namespace' => $this->prefix . '_namespace',
+ 'pl_title' => $this->prefix . '_title'
+ ] );
+
+ $this->addTables( $this->table );
+ $this->addWhereFld( $this->prefix . '_from', array_keys( $this->getPageSet()->getGoodTitles() ) );
+ $this->addWhereFld( $this->prefix . '_namespace', $params['namespace'] );
+
+ if ( !is_null( $params[$this->titlesParam] ) ) {
+ $lb = new LinkBatch;
+ foreach ( $params[$this->titlesParam] as $t ) {
+ $title = Title::newFromText( $t );
+ if ( !$title ) {
+ $this->addWarning( [ 'apiwarn-invalidtitle', wfEscapeWikiText( $t ) ] );
+ } else {
+ $lb->addObj( $title );
+ }
+ }
+ $cond = $lb->constructSet( $this->prefix, $this->getDB() );
+ if ( $cond ) {
+ $this->addWhere( $cond );
+ }
+ }
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 3 );
+ $op = $params['dir'] == 'descending' ? '<' : '>';
+ $plfrom = intval( $cont[0] );
+ $plns = intval( $cont[1] );
+ $pltitle = $this->getDB()->addQuotes( $cont[2] );
+ $this->addWhere(
+ "{$this->prefix}_from $op $plfrom OR " .
+ "({$this->prefix}_from = $plfrom AND " .
+ "({$this->prefix}_namespace $op $plns OR " .
+ "({$this->prefix}_namespace = $plns AND " .
+ "{$this->prefix}_title $op= $pltitle)))"
+ );
+ }
+
+ $sort = ( $params['dir'] == 'descending' ? ' DESC' : '' );
+ // Here's some MySQL craziness going on: if you use WHERE foo='bar'
+ // and later ORDER BY foo MySQL doesn't notice the ORDER BY is pointless
+ // but instead goes and filesorts, because the index for foo was used
+ // already. To work around this, we drop constant fields in the WHERE
+ // clause from the ORDER BY clause
+ $order = [];
+ if ( count( $this->getPageSet()->getGoodTitles() ) != 1 ) {
+ $order[] = $this->prefix . '_from' . $sort;
+ }
+ if ( count( $params['namespace'] ) != 1 ) {
+ $order[] = $this->prefix . '_namespace' . $sort;
+ }
+
+ $order[] = $this->prefix . '_title' . $sort;
+ $this->addOption( 'ORDER BY', $order );
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+
+ $res = $this->select( __METHOD__ );
+
+ if ( is_null( $resultPageSet ) ) {
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue',
+ "{$row->pl_from}|{$row->pl_namespace}|{$row->pl_title}" );
+ break;
+ }
+ $vals = [];
+ ApiQueryBase::addTitleInfo( $vals, Title::makeTitle( $row->pl_namespace, $row->pl_title ) );
+ $fit = $this->addPageSubItem( $row->pl_from, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue',
+ "{$row->pl_from}|{$row->pl_namespace}|{$row->pl_title}" );
+ break;
+ }
+ }
+ } else {
+ $titles = [];
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that
+ // there are additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue',
+ "{$row->pl_from}|{$row->pl_namespace}|{$row->pl_title}" );
+ break;
+ }
+ $titles[] = Title::makeTitle( $row->pl_namespace, $row->pl_title );
+ }
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'namespace' => [
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_EXTRA_NAMESPACES => [ NS_MEDIA, NS_SPECIAL ],
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ $this->titlesParam => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ]
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ $name = $this->getModuleName();
+ $path = $this->getModulePath();
+
+ return [
+ "action=query&prop={$name}&titles=Main%20Page"
+ => "apihelp-{$path}-example-simple",
+ "action=query&generator={$name}&titles=Main%20Page&prop=info"
+ => "apihelp-{$path}-example-generator",
+ "action=query&prop={$name}&titles=Main%20Page&{$this->prefix}namespace=2|10"
+ => "apihelp-{$path}-example-namespaces",
+ ];
+ }
+
+ public function getHelpUrls() {
+ return $this->helpUrl;
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryLogEvents.php b/www/wiki/includes/api/ApiQueryLogEvents.php
new file mode 100644
index 00000000..3066720d
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryLogEvents.php
@@ -0,0 +1,482 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 16, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query action to List the log events, with optional filtering by various parameters.
+ *
+ * @ingroup API
+ */
+class ApiQueryLogEvents extends ApiQueryBase {
+
+ private $commentStore;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'le' );
+ }
+
+ private $fld_ids = false, $fld_title = false, $fld_type = false,
+ $fld_user = false, $fld_userid = false,
+ $fld_timestamp = false, $fld_comment = false, $fld_parsedcomment = false,
+ $fld_details = false, $fld_tags = false;
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $db = $this->getDB();
+ $this->commentStore = new CommentStore( 'log_comment' );
+ $this->requireMaxOneParameter( $params, 'title', 'prefix', 'namespace' );
+
+ $prop = array_flip( $params['prop'] );
+
+ $this->fld_ids = isset( $prop['ids'] );
+ $this->fld_title = isset( $prop['title'] );
+ $this->fld_type = isset( $prop['type'] );
+ $this->fld_user = isset( $prop['user'] );
+ $this->fld_userid = isset( $prop['userid'] );
+ $this->fld_timestamp = isset( $prop['timestamp'] );
+ $this->fld_comment = isset( $prop['comment'] );
+ $this->fld_parsedcomment = isset( $prop['parsedcomment'] );
+ $this->fld_details = isset( $prop['details'] );
+ $this->fld_tags = isset( $prop['tags'] );
+
+ $hideLogs = LogEventsList::getExcludeClause( $db, 'user', $this->getUser() );
+ if ( $hideLogs !== false ) {
+ $this->addWhere( $hideLogs );
+ }
+
+ // Order is significant here
+ $this->addTables( [ 'logging', 'user', 'page' ] );
+ $this->addJoinConds( [
+ 'user' => [ 'LEFT JOIN',
+ 'user_id=log_user' ],
+ 'page' => [ 'LEFT JOIN',
+ [ 'log_namespace=page_namespace',
+ 'log_title=page_title' ] ] ] );
+
+ $this->addFields( [
+ 'log_id',
+ 'log_type',
+ 'log_action',
+ 'log_timestamp',
+ 'log_deleted',
+ ] );
+
+ $this->addFieldsIf( 'page_id', $this->fld_ids );
+ // log_page is the page_id saved at log time, whereas page_id is from a
+ // join at query time. This leads to different results in various
+ // scenarios, e.g. deletion, recreation.
+ $this->addFieldsIf( 'log_page', $this->fld_ids );
+ $this->addFieldsIf( [ 'log_user', 'log_user_text', 'user_name' ], $this->fld_user );
+ $this->addFieldsIf( 'log_user', $this->fld_userid );
+ $this->addFieldsIf(
+ [ 'log_namespace', 'log_title' ],
+ $this->fld_title || $this->fld_parsedcomment
+ );
+ $this->addFieldsIf( 'log_params', $this->fld_details );
+
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ $commentQuery = $this->commentStore->getJoin();
+ $this->addTables( $commentQuery['tables'] );
+ $this->addFields( $commentQuery['fields'] );
+ $this->addJoinConds( $commentQuery['joins'] );
+ }
+
+ if ( $this->fld_tags ) {
+ $this->addTables( 'tag_summary' );
+ $this->addJoinConds( [ 'tag_summary' => [ 'LEFT JOIN', 'log_id=ts_log_id' ] ] );
+ $this->addFields( 'ts_tags' );
+ }
+
+ if ( !is_null( $params['tag'] ) ) {
+ $this->addTables( 'change_tag' );
+ $this->addJoinConds( [ 'change_tag' => [ 'INNER JOIN',
+ [ 'log_id=ct_log_id' ] ] ] );
+ $this->addWhereFld( 'ct_tag', $params['tag'] );
+ }
+
+ if ( !is_null( $params['action'] ) ) {
+ // Do validation of action param, list of allowed actions can contains wildcards
+ // Allow the param, when the actions is in the list or a wildcard version is listed.
+ $logAction = $params['action'];
+ if ( strpos( $logAction, '/' ) === false ) {
+ // all items in the list have a slash
+ $valid = false;
+ } else {
+ $logActions = array_flip( $this->getAllowedLogActions() );
+ list( $type, $action ) = explode( '/', $logAction, 2 );
+ $valid = isset( $logActions[$logAction] ) || isset( $logActions[$type . '/*'] );
+ }
+
+ if ( !$valid ) {
+ $encParamName = $this->encodeParamName( 'action' );
+ $this->dieWithError(
+ [ 'apierror-unrecognizedvalue', $encParamName, wfEscapeWikiText( $logAction ) ],
+ "unknown_$encParamName"
+ );
+ }
+
+ $this->addWhereFld( 'log_type', $type );
+ $this->addWhereFld( 'log_action', $action );
+ } elseif ( !is_null( $params['type'] ) ) {
+ $this->addWhereFld( 'log_type', $params['type'] );
+ }
+
+ $this->addTimestampWhereRange(
+ 'log_timestamp',
+ $params['dir'],
+ $params['start'],
+ $params['end']
+ );
+ // Include in ORDER BY for uniqueness
+ $this->addWhereRange( 'log_id', $params['dir'], null, null );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $op = ( $params['dir'] === 'newer' ? '>' : '<' );
+ $continueTimestamp = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $continueId = (int)$cont[1];
+ $this->dieContinueUsageIf( $continueId != $cont[1] );
+ $this->addWhere( "log_timestamp $op $continueTimestamp OR " .
+ "(log_timestamp = $continueTimestamp AND " .
+ "log_id $op= $continueId)"
+ );
+ }
+
+ $limit = $params['limit'];
+ $this->addOption( 'LIMIT', $limit + 1 );
+
+ $user = $params['user'];
+ if ( !is_null( $user ) ) {
+ $userid = User::idFromName( $user );
+ if ( $userid ) {
+ $this->addWhereFld( 'log_user', $userid );
+ } else {
+ $this->addWhereFld( 'log_user_text', $user );
+ }
+ }
+
+ $title = $params['title'];
+ if ( !is_null( $title ) ) {
+ $titleObj = Title::newFromText( $title );
+ if ( is_null( $titleObj ) ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
+ }
+ $this->addWhereFld( 'log_namespace', $titleObj->getNamespace() );
+ $this->addWhereFld( 'log_title', $titleObj->getDBkey() );
+ }
+
+ if ( $params['namespace'] !== null ) {
+ $this->addWhereFld( 'log_namespace', $params['namespace'] );
+ }
+
+ $prefix = $params['prefix'];
+
+ if ( !is_null( $prefix ) ) {
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $this->dieWithError( 'apierror-prefixsearchdisabled' );
+ }
+
+ $title = Title::newFromText( $prefix );
+ if ( is_null( $title ) ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $prefix ) ] );
+ }
+ $this->addWhereFld( 'log_namespace', $title->getNamespace() );
+ $this->addWhere( 'log_title ' . $db->buildLike( $title->getDBkey(), $db->anyString() ) );
+ }
+
+ // Paranoia: avoid brute force searches (T19342)
+ if ( $params['namespace'] !== null || !is_null( $title ) || !is_null( $user ) ) {
+ if ( !$this->getUser()->isAllowed( 'deletedhistory' ) ) {
+ $titleBits = LogPage::DELETED_ACTION;
+ $userBits = LogPage::DELETED_USER;
+ } elseif ( !$this->getUser()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $titleBits = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
+ $userBits = LogPage::DELETED_USER | LogPage::DELETED_RESTRICTED;
+ } else {
+ $titleBits = 0;
+ $userBits = 0;
+ }
+ if ( ( $params['namespace'] !== null || !is_null( $title ) ) && $titleBits ) {
+ $this->addWhere( $db->bitAnd( 'log_deleted', $titleBits ) . " != $titleBits" );
+ }
+ if ( !is_null( $user ) && $userBits ) {
+ $this->addWhere( $db->bitAnd( 'log_deleted', $userBits ) . " != $userBits" );
+ }
+ }
+
+ $count = 0;
+ $res = $this->select( __METHOD__ );
+ $result = $this->getResult();
+ foreach ( $res as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', "$row->log_timestamp|$row->log_id" );
+ break;
+ }
+
+ $vals = $this->extractRowInfo( $row );
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', "$row->log_timestamp|$row->log_id" );
+ break;
+ }
+ }
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' );
+ }
+
+ /**
+ * @deprecated since 1.25 Use LogFormatter::formatParametersForApi instead
+ * @param ApiResult $result
+ * @param array &$vals
+ * @param string $params
+ * @param string $type
+ * @param string $action
+ * @param string $ts
+ * @param bool $legacy
+ * @return array
+ */
+ public static function addLogParams( $result, &$vals, $params, $type,
+ $action, $ts, $legacy = false
+ ) {
+ wfDeprecated( __METHOD__, '1.25' );
+
+ $entry = new ManualLogEntry( $type, $action );
+ $entry->setParameters( $params );
+ $entry->setTimestamp( $ts );
+ $entry->setLegacy( $legacy );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $vals['params'] = $formatter->formatParametersForApi();
+
+ return $vals;
+ }
+
+ private function extractRowInfo( $row ) {
+ $logEntry = DatabaseLogEntry::newFromRow( $row );
+ $vals = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ $anyHidden = false;
+ $user = $this->getUser();
+
+ if ( $this->fld_ids ) {
+ $vals['logid'] = intval( $row->log_id );
+ }
+
+ if ( $this->fld_title || $this->fld_parsedcomment ) {
+ $title = Title::makeTitle( $row->log_namespace, $row->log_title );
+ }
+
+ if ( $this->fld_title || $this->fld_ids || $this->fld_details && $row->log_params !== '' ) {
+ if ( LogEventsList::isDeleted( $row, LogPage::DELETED_ACTION ) ) {
+ $vals['actionhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( LogEventsList::userCan( $row, LogPage::DELETED_ACTION, $user ) ) {
+ if ( $this->fld_title ) {
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ }
+ if ( $this->fld_ids ) {
+ $vals['pageid'] = intval( $row->page_id );
+ $vals['logpage'] = intval( $row->log_page );
+ }
+ if ( $this->fld_details ) {
+ $vals['params'] = LogFormatter::newFromEntry( $logEntry )->formatParametersForApi();
+ }
+ }
+ }
+
+ if ( $this->fld_type ) {
+ $vals['type'] = $row->log_type;
+ $vals['action'] = $row->log_action;
+ }
+
+ if ( $this->fld_user || $this->fld_userid ) {
+ if ( LogEventsList::isDeleted( $row, LogPage::DELETED_USER ) ) {
+ $vals['userhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( LogEventsList::userCan( $row, LogPage::DELETED_USER, $user ) ) {
+ if ( $this->fld_user ) {
+ $vals['user'] = $row->user_name === null ? $row->log_user_text : $row->user_name;
+ }
+ if ( $this->fld_userid ) {
+ $vals['userid'] = intval( $row->log_user );
+ }
+
+ if ( !$row->log_user ) {
+ $vals['anon'] = true;
+ }
+ }
+ }
+ if ( $this->fld_timestamp ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->log_timestamp );
+ }
+
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ if ( LogEventsList::isDeleted( $row, LogPage::DELETED_COMMENT ) ) {
+ $vals['commenthidden'] = true;
+ $anyHidden = true;
+ }
+ if ( LogEventsList::userCan( $row, LogPage::DELETED_COMMENT, $user ) ) {
+ $comment = $this->commentStore->getComment( $row )->text;
+ if ( $this->fld_comment ) {
+ $vals['comment'] = $comment;
+ }
+
+ if ( $this->fld_parsedcomment ) {
+ $vals['parsedcomment'] = Linker::formatComment( $comment, $title );
+ }
+ }
+ }
+
+ if ( $this->fld_tags ) {
+ if ( $row->ts_tags ) {
+ $tags = explode( ',', $row->ts_tags );
+ ApiResult::setIndexedTagName( $tags, 'tag' );
+ $vals['tags'] = $tags;
+ } else {
+ $vals['tags'] = [];
+ }
+ }
+
+ if ( $anyHidden && LogEventsList::isDeleted( $row, LogPage::DELETED_RESTRICTED ) ) {
+ $vals['suppressed'] = true;
+ }
+
+ return $vals;
+ }
+
+ /**
+ * @return array
+ */
+ private function getAllowedLogActions() {
+ $config = $this->getConfig();
+ return array_keys( array_merge(
+ $config->get( 'LogActions' ),
+ $config->get( 'LogActionsHandlers' )
+ ) );
+ }
+
+ public function getCacheMode( $params ) {
+ if ( $this->userCanSeeRevDel() ) {
+ return 'private';
+ }
+ if ( !is_null( $params['prop'] ) && in_array( 'parsedcomment', $params['prop'] ) ) {
+ // formatComment() calls wfMessage() among other things
+ return 'anon-public-user-private';
+ } elseif ( LogEventsList::getExcludeClause( $this->getDB(), 'user', $this->getUser() )
+ === LogEventsList::getExcludeClause( $this->getDB(), 'public' )
+ ) { // Output can only contain public data.
+ return 'public';
+ } else {
+ return 'anon-public-user-private';
+ }
+ }
+
+ public function getAllowedParams( $flags = 0 ) {
+ $config = $this->getConfig();
+ $ret = [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'ids|title|type|user|timestamp|comment|details',
+ ApiBase::PARAM_TYPE => [
+ 'ids',
+ 'title',
+ 'type',
+ 'user',
+ 'userid',
+ 'timestamp',
+ 'comment',
+ 'parsedcomment',
+ 'details',
+ 'tags'
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'type' => [
+ ApiBase::PARAM_TYPE => $config->get( 'LogTypes' )
+ ],
+ 'action' => [
+ // validation on request is done in execute()
+ ApiBase::PARAM_TYPE => ( $flags & ApiBase::GET_VALUES_FOR_HELP )
+ ? $this->getAllowedLogActions()
+ : null
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'title' => null,
+ 'namespace' => [
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_EXTRA_NAMESPACES => [ NS_MEDIA, NS_SPECIAL ],
+ ],
+ 'prefix' => [],
+ 'tag' => null,
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+
+ if ( $config->get( 'MiserMode' ) ) {
+ $ret['prefix'][ApiBase::PARAM_HELP_MSG] = 'api-help-param-disabled-in-miser-mode';
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=logevents'
+ => 'apihelp-query+logevents-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Logevents';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryMyStashedFiles.php b/www/wiki/includes/api/ApiQueryMyStashedFiles.php
new file mode 100644
index 00000000..457f6c6e
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryMyStashedFiles.php
@@ -0,0 +1,150 @@
+<?php
+/**
+ * API for MediaWiki 1.27+
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * action=query&list=mystashedfiles module, gets all stashed files for
+ * the current user.
+ *
+ * @ingroup API
+ */
+class ApiQueryMyStashedFiles extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'msf' );
+ }
+
+ public function execute() {
+ $user = $this->getUser();
+
+ if ( $user->isAnon() ) {
+ $this->dieWithError( 'apierror-mustbeloggedin-uploadstash', 'stashnotloggedin' );
+ }
+
+ // Note: If user is logged in but cannot upload, they can still see
+ // the list of stashed uploads...but it will probably be empty.
+
+ $params = $this->extractRequestParams();
+
+ $this->addTables( 'uploadstash' );
+
+ $this->addFields( [ 'us_id', 'us_key', 'us_status' ] );
+
+ $this->addWhere( [ 'us_user' => $user->getId() ] );
+
+ if ( $params['continue'] !== null ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 1 );
+ $cont_from = (int)$cont[0];
+ $this->dieContinueUsageIf( strval( $cont_from ) !== $cont[0] );
+ $this->addWhere( "us_id >= $cont_from" );
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+ $this->addOption( 'ORDER BY', 'us_id' );
+
+ $prop = array_flip( $params['prop'] );
+ $this->addFieldsIf(
+ [
+ 'us_size',
+ 'us_image_width',
+ 'us_image_height',
+ 'us_image_bits'
+ ],
+
+ isset( $prop['size'] )
+ );
+ $this->addFieldsIf( [ 'us_mime', 'us_media_type' ], isset( $prop['type'] ) );
+
+ $res = $this->select( __METHOD__ );
+ $result = $this->getResult();
+ $count = 0;
+
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional files to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->us_id );
+ break;
+ }
+
+ $item = [
+ 'filekey' => $row->us_key,
+ 'status' => $row->us_status,
+ ];
+
+ if ( isset( $prop['size'] ) ) {
+ $item['size'] = (int)$row->us_size;
+ $item['width'] = (int)$row->us_image_width;
+ $item['height'] = (int)$row->us_image_height;
+ $item['bits'] = (int)$row->us_image_bits;
+ }
+
+ if ( isset( $prop['type'] ) ) {
+ $item['mimetype'] = $row->us_mime;
+ $item['mediatype'] = $row->us_media_type;
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $item );
+
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->us_id );
+ break;
+ }
+ }
+
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'file' );
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => '',
+ ApiBase::PARAM_TYPE => [ 'size', 'type' ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+
+ 'limit' => [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2,
+ ],
+
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=mystashedfiles&msfprop=size'
+ => 'apihelp-query+mystashedfiles-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:mystashedfiles';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryPagePropNames.php b/www/wiki/includes/api/ApiQueryPagePropNames.php
new file mode 100644
index 00000000..2d56983c
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryPagePropNames.php
@@ -0,0 +1,112 @@
+<?php
+/**
+ * Created on January 21, 2013
+ *
+ * Copyright © 2013 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.21
+ */
+
+/**
+ * A query module to list used page props
+ *
+ * @ingroup API
+ * @since 1.21
+ */
+class ApiQueryPagePropNames extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ppn' );
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $this->addTables( 'page_props' );
+ $this->addFields( 'pp_propname' );
+ $this->addOption( 'DISTINCT' );
+ $this->addOption( 'ORDER BY', 'pp_propname' );
+
+ if ( $params['continue'] ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 1 );
+
+ // Add a WHERE clause
+ $this->addWhereRange( 'pp_propname', 'newer', $cont[0], null );
+ }
+
+ $limit = $params['limit'];
+
+ // mysql has issues with limit in loose index T115825
+ if ( $this->getDB()->getType() !== 'mysql' ) {
+ $this->addOption( 'LIMIT', $limit + 1 );
+ }
+
+ $result = $this->getResult();
+ $count = 0;
+ foreach ( $this->select( __METHOD__ ) as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->pp_propname );
+ break;
+ }
+
+ $vals = [];
+ $vals['propname'] = $row->pp_propname;
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->pp_propname );
+ break;
+ }
+ }
+
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'p' );
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=pagepropnames'
+ => 'apihelp-query+pagepropnames-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Pagepropnames';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryPageProps.php b/www/wiki/includes/api/ApiQueryPageProps.php
new file mode 100644
index 00000000..e49dfbcf
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryPageProps.php
@@ -0,0 +1,125 @@
+<?php
+/**
+ *
+ *
+ * Created on Aug 7, 2010
+ *
+ * Copyright © 2010 soxred93, Bryan Tong Minh
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query module to show basic page information.
+ *
+ * @ingroup API
+ */
+class ApiQueryPageProps extends ApiQueryBase {
+
+ private $params;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'pp' );
+ }
+
+ public function execute() {
+ # Only operate on existing pages
+ $pages = $this->getPageSet()->getGoodTitles();
+
+ $this->params = $this->extractRequestParams();
+ if ( $this->params['continue'] ) {
+ $continueValue = intval( $this->params['continue'] );
+ $this->dieContinueUsageIf( strval( $continueValue ) !== $this->params['continue'] );
+ $filteredPages = [];
+ foreach ( $pages as $id => $page ) {
+ if ( $id >= $continueValue ) {
+ $filteredPages[$id] = $page;
+ }
+ }
+ $pages = $filteredPages;
+ }
+
+ if ( !count( $pages ) ) {
+ # Nothing to do
+ return;
+ }
+
+ $pageProps = PageProps::getInstance();
+ $result = $this->getResult();
+ if ( $this->params['prop'] ) {
+ $propnames = $this->params['prop'];
+ $properties = $pageProps->getProperties( $pages, $propnames );
+ } else {
+ $properties = $pageProps->getAllProperties( $pages );
+ }
+
+ ksort( $properties );
+
+ foreach ( $properties as $page => $props ) {
+ if ( !$this->addPageProps( $result, $page, $props ) ) {
+ break;
+ }
+ }
+ }
+
+ /**
+ * Add page properties to an ApiResult, adding a continue
+ * parameter if it doesn't fit.
+ *
+ * @param ApiResult $result
+ * @param int $page
+ * @param array $props
+ * @return bool True if it fits in the result
+ */
+ private function addPageProps( $result, $page, $props ) {
+ ApiResult::setArrayType( $props, 'assoc' );
+ $fit = $result->addValue( [ 'query', 'pages', $page ], 'pageprops', $props );
+
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $page );
+ }
+
+ return $fit;
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=pageprops&titles=Main%20Page|MediaWiki'
+ => 'apihelp-query+pageprops-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Pageprops';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryPagesWithProp.php b/www/wiki/includes/api/ApiQueryPagesWithProp.php
new file mode 100644
index 00000000..97f79b66
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryPagesWithProp.php
@@ -0,0 +1,177 @@
+<?php
+/**
+ * Created on December 31, 2012
+ *
+ * Copyright © 2012 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.21
+ */
+
+/**
+ * A query module to enumerate pages that use a particular prop
+ *
+ * @ingroup API
+ * @since 1.21
+ */
+class ApiQueryPagesWithProp extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'pwp' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+
+ $prop = array_flip( $params['prop'] );
+ $fld_ids = isset( $prop['ids'] );
+ $fld_title = isset( $prop['title'] );
+ $fld_value = isset( $prop['value'] );
+
+ if ( $resultPageSet === null ) {
+ $this->addFields( [ 'page_id' ] );
+ $this->addFieldsIf( [ 'page_title', 'page_namespace' ], $fld_title );
+ $this->addFieldsIf( 'pp_value', $fld_value );
+ } else {
+ $this->addFields( $resultPageSet->getPageTableFields() );
+ }
+ $this->addTables( [ 'page_props', 'page' ] );
+ $this->addWhere( 'pp_page=page_id' );
+ $this->addWhereFld( 'pp_propname', $params['propname'] );
+
+ $dir = ( $params['dir'] == 'ascending' ) ? 'newer' : 'older';
+
+ if ( $params['continue'] ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 1 );
+
+ // Add a WHERE clause
+ $from = (int)$cont[0];
+ $this->addWhereRange( 'pp_page', $dir, $from, null );
+ }
+
+ $sort = ( $params['dir'] === 'descending' ? ' DESC' : '' );
+ $this->addOption( 'ORDER BY', 'pp_page' . $sort );
+
+ $limit = $params['limit'];
+ $this->addOption( 'LIMIT', $limit + 1 );
+
+ $result = $this->getResult();
+ $count = 0;
+ foreach ( $this->select( __METHOD__ ) as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $row->page_id );
+ break;
+ }
+
+ if ( $resultPageSet === null ) {
+ $vals = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ if ( $fld_ids ) {
+ $vals['pageid'] = (int)$row->page_id;
+ }
+ if ( $fld_title ) {
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ }
+ if ( $fld_value ) {
+ $vals['value'] = $row->pp_value;
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $row->page_id );
+ break;
+ }
+ } else {
+ $resultPageSet->processDbRow( $row );
+ }
+ }
+
+ if ( $resultPageSet === null ) {
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'page' );
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'propname' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'ids|title',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'ids',
+ 'title',
+ 'value',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending',
+ ]
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=pageswithprop&pwppropname=displaytitle&pwpprop=ids|title|value'
+ => 'apihelp-query+pageswithprop-example-simple',
+ 'action=query&generator=pageswithprop&gpwppropname=notoc&prop=info'
+ => 'apihelp-query+pageswithprop-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Pageswithprop';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryPrefixSearch.php b/www/wiki/includes/api/ApiQueryPrefixSearch.php
new file mode 100644
index 00000000..2fbc518b
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryPrefixSearch.php
@@ -0,0 +1,132 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.23
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiQueryPrefixSearch extends ApiQueryGeneratorBase {
+ use SearchApi;
+
+ /** @var array list of api allowed params */
+ private $allowedParams;
+
+ public function __construct( $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ps' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ */
+ private function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+ $search = $params['search'];
+ $limit = $params['limit'];
+ $offset = $params['offset'];
+
+ $searchEngine = $this->buildSearchEngine( $params );
+ $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
+
+ if ( $resultPageSet ) {
+ $resultPageSet->setRedirectMergePolicy( function ( array $current, array $new ) {
+ if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {
+ $current['index'] = $new['index'];
+ }
+ return $current;
+ } );
+ if ( count( $titles ) > $limit ) {
+ $this->setContinueEnumParameter( 'offset', $offset + $limit );
+ array_pop( $titles );
+ }
+ $resultPageSet->populateFromTitles( $titles );
+ foreach ( $titles as $index => $title ) {
+ $resultPageSet->setGeneratorData( $title, [ 'index' => $index + $offset + 1 ] );
+ }
+ } else {
+ $result = $this->getResult();
+ $count = 0;
+ foreach ( $titles as $title ) {
+ if ( ++$count > $limit ) {
+ $this->setContinueEnumParameter( 'offset', $offset + $limit );
+ break;
+ }
+ $vals = [
+ 'ns' => intval( $title->getNamespace() ),
+ 'title' => $title->getPrefixedText(),
+ ];
+ if ( $title->isSpecialPage() ) {
+ $vals['special'] = true;
+ } else {
+ $vals['pageid'] = intval( $title->getArticleID() );
+ }
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'offset', $offset + $count - 1 );
+ break;
+ }
+ }
+ $result->addIndexedTagName(
+ [ 'query', $this->getModuleName() ], $this->getModulePrefix()
+ );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ if ( $this->allowedParams !== null ) {
+ return $this->allowedParams;
+ }
+ $this->allowedParams = $this->buildCommonApiParams();
+
+ return $this->allowedParams;
+ }
+
+ public function getSearchProfileParams() {
+ return [
+ 'profile' => [
+ 'profile-type' => SearchEngine::COMPLETION_PROFILE_TYPE,
+ 'help-message' => 'apihelp-query+prefixsearch-param-profile',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=prefixsearch&pssearch=meaning'
+ => 'apihelp-query+prefixsearch-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Prefixsearch';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryProtectedTitles.php b/www/wiki/includes/api/ApiQueryProtectedTitles.php
new file mode 100644
index 00000000..b69a2996
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryProtectedTitles.php
@@ -0,0 +1,248 @@
+<?php
+/**
+ *
+ *
+ * Created on Feb 13, 2009
+ *
+ * Copyright © 2009 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate all create-protected pages.
+ *
+ * @ingroup API
+ */
+class ApiQueryProtectedTitles extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'pt' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+
+ $this->addTables( 'protected_titles' );
+ $this->addFields( [ 'pt_namespace', 'pt_title', 'pt_timestamp' ] );
+
+ $prop = array_flip( $params['prop'] );
+ $this->addFieldsIf( 'pt_user', isset( $prop['user'] ) || isset( $prop['userid'] ) );
+ $this->addFieldsIf( 'pt_expiry', isset( $prop['expiry'] ) );
+ $this->addFieldsIf( 'pt_create_perm', isset( $prop['level'] ) );
+
+ if ( isset( $prop['comment'] ) || isset( $prop['parsedcomment'] ) ) {
+ $commentStore = new CommentStore( 'pt_reason' );
+ $commentQuery = $commentStore->getJoin();
+ $this->addTables( $commentQuery['tables'] );
+ $this->addFields( $commentQuery['fields'] );
+ $this->addJoinConds( $commentQuery['joins'] );
+ }
+
+ $this->addTimestampWhereRange( 'pt_timestamp', $params['dir'], $params['start'], $params['end'] );
+ $this->addWhereFld( 'pt_namespace', $params['namespace'] );
+ $this->addWhereFld( 'pt_create_perm', $params['level'] );
+
+ // Include in ORDER BY for uniqueness
+ $this->addWhereRange( 'pt_namespace', $params['dir'], null, null );
+ $this->addWhereRange( 'pt_title', $params['dir'], null, null );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 3 );
+ $op = ( $params['dir'] === 'newer' ? '>' : '<' );
+ $db = $this->getDB();
+ $continueTimestamp = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $continueNs = (int)$cont[1];
+ $this->dieContinueUsageIf( $continueNs != $cont[1] );
+ $continueTitle = $db->addQuotes( $cont[2] );
+ $this->addWhere( "pt_timestamp $op $continueTimestamp OR " .
+ "(pt_timestamp = $continueTimestamp AND " .
+ "(pt_namespace $op $continueNs OR " .
+ "(pt_namespace = $continueNs AND " .
+ "pt_title $op= $continueTitle)))"
+ );
+ }
+
+ if ( isset( $prop['user'] ) ) {
+ $this->addTables( 'user' );
+ $this->addFields( 'user_name' );
+ $this->addJoinConds( [ 'user' => [ 'LEFT JOIN',
+ 'user_id=pt_user'
+ ] ] );
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+ $res = $this->select( __METHOD__ );
+
+ $count = 0;
+ $result = $this->getResult();
+
+ $titles = [];
+
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue',
+ "$row->pt_timestamp|$row->pt_namespace|$row->pt_title"
+ );
+ break;
+ }
+
+ $title = Title::makeTitle( $row->pt_namespace, $row->pt_title );
+ if ( is_null( $resultPageSet ) ) {
+ $vals = [];
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ if ( isset( $prop['timestamp'] ) ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->pt_timestamp );
+ }
+
+ if ( isset( $prop['user'] ) && !is_null( $row->user_name ) ) {
+ $vals['user'] = $row->user_name;
+ }
+
+ if ( isset( $prop['userid'] ) || /*B/C*/isset( $prop['user'] ) ) {
+ $vals['userid'] = (int)$row->pt_user;
+ }
+
+ if ( isset( $prop['comment'] ) ) {
+ $vals['comment'] = $commentStore->getComment( $row )->text;
+ }
+
+ if ( isset( $prop['parsedcomment'] ) ) {
+ $vals['parsedcomment'] = Linker::formatComment(
+ $commentStore->getComment( $row )->text, $titles
+ );
+ }
+
+ if ( isset( $prop['expiry'] ) ) {
+ $vals['expiry'] = ApiResult::formatExpiry( $row->pt_expiry );
+ }
+
+ if ( isset( $prop['level'] ) ) {
+ $vals['level'] = $row->pt_create_perm;
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue',
+ "$row->pt_timestamp|$row->pt_namespace|$row->pt_title"
+ );
+ break;
+ }
+ } else {
+ $titles[] = $title;
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName(
+ [ 'query', $this->getModuleName() ],
+ $this->getModulePrefix()
+ );
+ } else {
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ if ( !is_null( $params['prop'] ) && in_array( 'parsedcomment', $params['prop'] ) ) {
+ // formatComment() calls wfMessage() among other things
+ return 'anon-public-user-private';
+ } else {
+ return 'public';
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ],
+ 'level' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => array_diff( $this->getConfig()->get( 'RestrictionLevels' ), [ '' ] )
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'timestamp|level',
+ ApiBase::PARAM_TYPE => [
+ 'timestamp',
+ 'user',
+ 'userid',
+ 'comment',
+ 'parsedcomment',
+ 'expiry',
+ 'level'
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=protectedtitles'
+ => 'apihelp-query+protectedtitles-example-simple',
+ 'action=query&generator=protectedtitles&gptnamespace=0&prop=linkshere'
+ => 'apihelp-query+protectedtitles-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Protectedtitles';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryQueryPage.php b/www/wiki/includes/api/ApiQueryQueryPage.php
new file mode 100644
index 00000000..46c22655
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryQueryPage.php
@@ -0,0 +1,171 @@
+<?php
+/**
+ *
+ *
+ * Created on Dec 22, 2010
+ *
+ * Copyright © 2010 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to get the results of a QueryPage-based special page
+ *
+ * @ingroup API
+ */
+class ApiQueryQueryPage extends ApiQueryGeneratorBase {
+ private $qpMap;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'qp' );
+ // Build mapping from special page names to QueryPage classes
+ $uselessQueryPages = $this->getConfig()->get( 'APIUselessQueryPages' );
+ $this->qpMap = [];
+ foreach ( QueryPage::getPages() as $page ) {
+ if ( !in_array( $page[1], $uselessQueryPages ) ) {
+ $this->qpMap[$page[1]] = $page[0];
+ }
+ }
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ */
+ public function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+ $result = $this->getResult();
+
+ /** @var QueryPage $qp */
+ $qp = new $this->qpMap[$params['page']]();
+ if ( !$qp->userCanExecute( $this->getUser() ) ) {
+ $this->dieWithError( 'apierror-specialpage-cantexecute' );
+ }
+
+ $r = [ 'name' => $params['page'] ];
+ if ( $qp->isCached() ) {
+ if ( !$qp->isCacheable() ) {
+ $r['disabled'] = true;
+ } else {
+ $r['cached'] = true;
+ $ts = $qp->getCachedTimestamp();
+ if ( $ts ) {
+ $r['cachedtimestamp'] = wfTimestamp( TS_ISO_8601, $ts );
+ }
+ $r['maxresults'] = $this->getConfig()->get( 'QueryCacheLimit' );
+ }
+ }
+ $result->addValue( [ 'query' ], $this->getModuleName(), $r );
+
+ if ( $qp->isCached() && !$qp->isCacheable() ) {
+ // Disabled query page, don't run the query
+ return;
+ }
+
+ $res = $qp->doQuery( $params['offset'], $params['limit'] + 1 );
+ $count = 0;
+ $titles = [];
+ foreach ( $res as $row ) {
+ if ( ++$count > $params['limit'] ) {
+ // We've had enough
+ $this->setContinueEnumParameter( 'offset', $params['offset'] + $params['limit'] );
+ break;
+ }
+
+ $title = Title::makeTitle( $row->namespace, $row->title );
+ if ( is_null( $resultPageSet ) ) {
+ $data = [ 'value' => $row->value ];
+ if ( $qp->usesTimestamps() ) {
+ $data['timestamp'] = wfTimestamp( TS_ISO_8601, $row->value );
+ }
+ self::addTitleInfo( $data, $title );
+
+ foreach ( $row as $field => $value ) {
+ if ( !in_array( $field, [ 'namespace', 'title', 'value', 'qc_type' ] ) ) {
+ $data['databaseResult'][$field] = $value;
+ }
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName(), 'results' ], null, $data );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'offset', $params['offset'] + $count - 1 );
+ break;
+ }
+ } else {
+ $titles[] = $title;
+ }
+ }
+ if ( is_null( $resultPageSet ) ) {
+ $result->addIndexedTagName(
+ [ 'query', $this->getModuleName(), 'results' ],
+ 'page'
+ );
+ } else {
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ /** @var QueryPage $qp */
+ $qp = new $this->qpMap[$params['page']]();
+ if ( $qp->getRestriction() != '' ) {
+ return 'private';
+ }
+
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'page' => [
+ ApiBase::PARAM_TYPE => array_keys( $this->qpMap ),
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'offset' => [
+ ApiBase::PARAM_DFLT => 0,
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=querypage&qppage=Ancientpages'
+ => 'apihelp-query+querypage-example-ancientpages',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Querypage';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryRandom.php b/www/wiki/includes/api/ApiQueryRandom.php
new file mode 100644
index 00000000..ce62226f
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryRandom.php
@@ -0,0 +1,223 @@
+<?php
+
+/**
+ *
+ *
+ * Created on Monday, January 28, 2008
+ *
+ * Copyright © 2008 Brent Garber
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to get list of random pages
+ *
+ * @ingroup API
+ */
+class ApiQueryRandom extends ApiQueryGeneratorBase {
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'rn' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * Actually perform the query and add pages to the result.
+ * @param ApiPageSet|null $resultPageSet
+ * @param int $limit Number of pages to fetch
+ * @param string|null $start Starting page_random
+ * @param int $startId Starting page_id
+ * @param string|null $end Ending page_random
+ * @return array (int, string|null) Number of pages left to query and continuation string
+ */
+ protected function runQuery( $resultPageSet, $limit, $start, $startId, $end ) {
+ $params = $this->extractRequestParams();
+
+ $this->resetQueryParams();
+ $this->addTables( 'page' );
+ $this->addFields( [ 'page_id', 'page_random' ] );
+ if ( is_null( $resultPageSet ) ) {
+ $this->addFields( [ 'page_title', 'page_namespace' ] );
+ } else {
+ $this->addFields( $resultPageSet->getPageTableFields() );
+ }
+ $this->addWhereFld( 'page_namespace', $params['namespace'] );
+ if ( $params['redirect'] || $params['filterredir'] === 'redirects' ) {
+ $this->addWhereFld( 'page_is_redirect', 1 );
+ } elseif ( $params['filterredir'] === 'nonredirects' ) {
+ $this->addWhereFld( 'page_is_redirect', 0 );
+ } elseif ( is_null( $resultPageSet ) ) {
+ $this->addFields( [ 'page_is_redirect' ] );
+ }
+ $this->addOption( 'LIMIT', $limit + 1 );
+
+ if ( $start !== null ) {
+ $start = $this->getDB()->addQuotes( $start );
+ if ( $startId > 0 ) {
+ $startId = (int)$startId; // safety
+ $this->addWhere( "page_random = $start AND page_id >= $startId OR page_random > $start" );
+ } else {
+ $this->addWhere( "page_random >= $start" );
+ }
+ }
+ if ( $end !== null ) {
+ $this->addWhere( 'page_random < ' . $this->getDB()->addQuotes( $end ) );
+ }
+ $this->addOption( 'ORDER BY', [ 'page_random', 'page_id' ] );
+
+ $result = $this->getResult();
+ $path = [ 'query', $this->getModuleName() ];
+
+ $res = $this->select( __METHOD__ );
+ $count = 0;
+ foreach ( $res as $row ) {
+ if ( $count++ >= $limit ) {
+ return [ 0, "{$row->page_random}|{$row->page_id}" ];
+ }
+ if ( is_null( $resultPageSet ) ) {
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ $page = [
+ 'id' => (int)$row->page_id,
+ ];
+ ApiQueryBase::addTitleInfo( $page, $title );
+ if ( isset( $row->page_is_redirect ) ) {
+ $page['redirect'] = (bool)$row->page_is_redirect;
+ }
+ $fit = $result->addValue( $path, null, $page );
+ if ( !$fit ) {
+ return [ 0, "{$row->page_random}|{$row->page_id}" ];
+ }
+ } else {
+ $resultPageSet->processDbRow( $row );
+ }
+ }
+
+ return [ $limit - $count, null ];
+ }
+
+ /**
+ * @param ApiPageSet|null $resultPageSet
+ */
+ public function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+
+ // Since 'filterredir" will always be set in $params, we have to dig
+ // into the WebRequest to see if it was actually passed.
+ $request = $this->getMain()->getRequest();
+ if ( $request->getCheck( $this->encodeParamName( 'filterredir' ) ) ) {
+ $this->requireMaxOneParameter( $params, 'filterredir', 'redirect' );
+ }
+
+ if ( isset( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 4 );
+ $rand = $cont[0];
+ $start = $cont[1];
+ $startId = (int)$cont[2];
+ $end = $cont[3] ? $rand : null;
+ $this->dieContinueUsageIf( !preg_match( '/^0\.\d+$/', $rand ) );
+ $this->dieContinueUsageIf( !preg_match( '/^0\.\d+$/', $start ) );
+ $this->dieContinueUsageIf( $cont[2] !== (string)$startId );
+ $this->dieContinueUsageIf( $cont[3] !== '0' && $cont[3] !== '1' );
+ } else {
+ $rand = wfRandom();
+ $start = $rand;
+ $startId = 0;
+ $end = null;
+ }
+
+ // Set the non-continue if this is being used as a generator
+ // (as a list it doesn't matter because lists never non-continue)
+ if ( $resultPageSet !== null ) {
+ $endFlag = $end === null ? 0 : 1;
+ $this->getContinuationManager()->addGeneratorNonContinueParam(
+ $this, 'continue', "$rand|$start|$startId|$endFlag"
+ );
+ }
+
+ list( $left, $continue ) =
+ $this->runQuery( $resultPageSet, $params['limit'], $start, $startId, $end );
+ if ( $end === null && $continue === null ) {
+ // Wrap around. We do this even if $left === 0 for continuation
+ // (saving a DB query in this rare case probably isn't worth the
+ // added code complexity it would require).
+ $end = $rand;
+ list( $left, $continue ) = $this->runQuery( $resultPageSet, $left, null, null, $end );
+ }
+
+ if ( $continue !== null ) {
+ $endFlag = $end === null ? 0 : 1;
+ $this->setContinueEnumParameter( 'continue', "$rand|$continue|$endFlag" );
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $this->getResult()->addIndexedTagName( [ 'query', $this->getModuleName() ], 'page' );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'namespace' => [
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'filterredir' => [
+ ApiBase::PARAM_TYPE => [ 'all', 'redirects', 'nonredirects' ],
+ ApiBase::PARAM_DFLT => 'nonredirects', // for BC
+ ],
+ 'redirect' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ApiBase::PARAM_DFLT => false,
+ ],
+ 'limit' => [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_DFLT => 1,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue'
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=random&rnnamespace=0&rnlimit=2'
+ => 'apihelp-query+random-example-simple',
+ 'action=query&generator=random&grnnamespace=0&grnlimit=2&prop=info'
+ => 'apihelp-query+random-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Random';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryRecentChanges.php b/www/wiki/includes/api/ApiQueryRecentChanges.php
new file mode 100644
index 00000000..63e07487
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryRecentChanges.php
@@ -0,0 +1,715 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 19, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query action to enumerate the recent changes that were done to the wiki.
+ * Various filters are supported.
+ *
+ * @ingroup API
+ */
+class ApiQueryRecentChanges extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'rc' );
+ }
+
+ private $commentStore;
+
+ private $fld_comment = false, $fld_parsedcomment = false, $fld_user = false, $fld_userid = false,
+ $fld_flags = false, $fld_timestamp = false, $fld_title = false, $fld_ids = false,
+ $fld_sizes = false, $fld_redirect = false, $fld_patrolled = false, $fld_loginfo = false,
+ $fld_tags = false, $fld_sha1 = false, $token = [];
+
+ private $tokenFunctions;
+
+ /**
+ * Get an array mapping token names to their handler functions.
+ * The prototype for a token function is func($pageid, $title, $rc)
+ * it should return a token or false (permission denied)
+ * @deprecated since 1.24
+ * @return array [ tokenname => function ]
+ */
+ protected function getTokenFunctions() {
+ // Don't call the hooks twice
+ if ( isset( $this->tokenFunctions ) ) {
+ return $this->tokenFunctions;
+ }
+
+ // If we're in a mode that breaks the same-origin policy, no tokens can
+ // be obtained
+ if ( $this->lacksSameOriginSecurity() ) {
+ return [];
+ }
+
+ $this->tokenFunctions = [
+ 'patrol' => [ 'ApiQueryRecentChanges', 'getPatrolToken' ]
+ ];
+ Hooks::run( 'APIQueryRecentChangesTokens', [ &$this->tokenFunctions ] );
+
+ return $this->tokenFunctions;
+ }
+
+ /**
+ * @deprecated since 1.24
+ * @param int $pageid
+ * @param Title $title
+ * @param RecentChange|null $rc
+ * @return bool|string
+ */
+ public static function getPatrolToken( $pageid, $title, $rc = null ) {
+ global $wgUser;
+
+ $validTokenUser = false;
+
+ if ( $rc ) {
+ if ( ( $wgUser->useRCPatrol() && $rc->getAttribute( 'rc_type' ) == RC_EDIT ) ||
+ ( $wgUser->useNPPatrol() && $rc->getAttribute( 'rc_type' ) == RC_NEW )
+ ) {
+ $validTokenUser = true;
+ }
+ } elseif ( $wgUser->useRCPatrol() || $wgUser->useNPPatrol() ) {
+ $validTokenUser = true;
+ }
+
+ if ( $validTokenUser ) {
+ // The patrol token is always the same, let's exploit that
+ static $cachedPatrolToken = null;
+
+ if ( is_null( $cachedPatrolToken ) ) {
+ $cachedPatrolToken = $wgUser->getEditToken( 'patrol' );
+ }
+
+ return $cachedPatrolToken;
+ }
+
+ return false;
+ }
+
+ /**
+ * Sets internal state to include the desired properties in the output.
+ * @param array $prop Associative array of properties, only keys are used here
+ */
+ public function initProperties( $prop ) {
+ $this->fld_comment = isset( $prop['comment'] );
+ $this->fld_parsedcomment = isset( $prop['parsedcomment'] );
+ $this->fld_user = isset( $prop['user'] );
+ $this->fld_userid = isset( $prop['userid'] );
+ $this->fld_flags = isset( $prop['flags'] );
+ $this->fld_timestamp = isset( $prop['timestamp'] );
+ $this->fld_title = isset( $prop['title'] );
+ $this->fld_ids = isset( $prop['ids'] );
+ $this->fld_sizes = isset( $prop['sizes'] );
+ $this->fld_redirect = isset( $prop['redirect'] );
+ $this->fld_patrolled = isset( $prop['patrolled'] );
+ $this->fld_loginfo = isset( $prop['loginfo'] );
+ $this->fld_tags = isset( $prop['tags'] );
+ $this->fld_sha1 = isset( $prop['sha1'] );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * Generates and outputs the result of this query based upon the provided parameters.
+ *
+ * @param ApiPageSet $resultPageSet
+ */
+ public function run( $resultPageSet = null ) {
+ $user = $this->getUser();
+ /* Get the parameters of the request. */
+ $params = $this->extractRequestParams();
+
+ /* Build our basic query. Namely, something along the lines of:
+ * SELECT * FROM recentchanges WHERE rc_timestamp > $start
+ * AND rc_timestamp < $end AND rc_namespace = $namespace
+ */
+ $this->addTables( 'recentchanges' );
+ $this->addTimestampWhereRange( 'rc_timestamp', $params['dir'], $params['start'], $params['end'] );
+
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $db = $this->getDB();
+ $timestamp = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $id = intval( $cont[1] );
+ $this->dieContinueUsageIf( $id != $cont[1] );
+ $op = $params['dir'] === 'older' ? '<' : '>';
+ $this->addWhere(
+ "rc_timestamp $op $timestamp OR " .
+ "(rc_timestamp = $timestamp AND " .
+ "rc_id $op= $id)"
+ );
+ }
+
+ $order = $params['dir'] === 'older' ? 'DESC' : 'ASC';
+ $this->addOption( 'ORDER BY', [
+ "rc_timestamp $order",
+ "rc_id $order",
+ ] );
+
+ $this->addWhereFld( 'rc_namespace', $params['namespace'] );
+
+ if ( !is_null( $params['type'] ) ) {
+ try {
+ $this->addWhereFld( 'rc_type', RecentChange::parseToRCType( $params['type'] ) );
+ } catch ( Exception $e ) {
+ ApiBase::dieDebug( __METHOD__, $e->getMessage() );
+ }
+ }
+
+ if ( !is_null( $params['show'] ) ) {
+ $show = array_flip( $params['show'] );
+
+ /* Check for conflicting parameters. */
+ if ( ( isset( $show['minor'] ) && isset( $show['!minor'] ) )
+ || ( isset( $show['bot'] ) && isset( $show['!bot'] ) )
+ || ( isset( $show['anon'] ) && isset( $show['!anon'] ) )
+ || ( isset( $show['redirect'] ) && isset( $show['!redirect'] ) )
+ || ( isset( $show['patrolled'] ) && isset( $show['!patrolled'] ) )
+ || ( isset( $show['patrolled'] ) && isset( $show['unpatrolled'] ) )
+ || ( isset( $show['!patrolled'] ) && isset( $show['unpatrolled'] ) )
+ ) {
+ $this->dieWithError( 'apierror-show' );
+ }
+
+ // Check permissions
+ if ( isset( $show['patrolled'] )
+ || isset( $show['!patrolled'] )
+ || isset( $show['unpatrolled'] )
+ ) {
+ if ( !$user->useRCPatrol() && !$user->useNPPatrol() ) {
+ $this->dieWithError( 'apierror-permissiondenied-patrolflag', 'permissiondenied' );
+ }
+ }
+
+ /* Add additional conditions to query depending upon parameters. */
+ $this->addWhereIf( 'rc_minor = 0', isset( $show['!minor'] ) );
+ $this->addWhereIf( 'rc_minor != 0', isset( $show['minor'] ) );
+ $this->addWhereIf( 'rc_bot = 0', isset( $show['!bot'] ) );
+ $this->addWhereIf( 'rc_bot != 0', isset( $show['bot'] ) );
+ $this->addWhereIf( 'rc_user = 0', isset( $show['anon'] ) );
+ $this->addWhereIf( 'rc_user != 0', isset( $show['!anon'] ) );
+ $this->addWhereIf( 'rc_patrolled = 0', isset( $show['!patrolled'] ) );
+ $this->addWhereIf( 'rc_patrolled != 0', isset( $show['patrolled'] ) );
+ $this->addWhereIf( 'page_is_redirect = 1', isset( $show['redirect'] ) );
+
+ if ( isset( $show['unpatrolled'] ) ) {
+ // See ChangesList::isUnpatrolled
+ if ( $user->useRCPatrol() ) {
+ $this->addWhere( 'rc_patrolled = 0' );
+ } elseif ( $user->useNPPatrol() ) {
+ $this->addWhere( 'rc_patrolled = 0' );
+ $this->addWhereFld( 'rc_type', RC_NEW );
+ }
+ }
+
+ // Don't throw log entries out the window here
+ $this->addWhereIf(
+ 'page_is_redirect = 0 OR page_is_redirect IS NULL',
+ isset( $show['!redirect'] )
+ );
+ }
+
+ $this->requireMaxOneParameter( $params, 'user', 'excludeuser' );
+
+ if ( !is_null( $params['user'] ) ) {
+ $this->addWhereFld( 'rc_user_text', $params['user'] );
+ }
+
+ if ( !is_null( $params['excludeuser'] ) ) {
+ // We don't use the rc_user_text index here because
+ // * it would require us to sort by rc_user_text before rc_timestamp
+ // * the != condition doesn't throw out too many rows anyway
+ $this->addWhere( 'rc_user_text != ' . $this->getDB()->addQuotes( $params['excludeuser'] ) );
+ }
+
+ /* Add the fields we're concerned with to our query. */
+ $this->addFields( [
+ 'rc_id',
+ 'rc_timestamp',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_cur_id',
+ 'rc_type',
+ 'rc_deleted'
+ ] );
+
+ $showRedirects = false;
+ /* Determine what properties we need to display. */
+ if ( !is_null( $params['prop'] ) ) {
+ $prop = array_flip( $params['prop'] );
+
+ /* Set up internal members based upon params. */
+ $this->initProperties( $prop );
+
+ if ( $this->fld_patrolled && !$user->useRCPatrol() && !$user->useNPPatrol() ) {
+ $this->dieWithError( 'apierror-permissiondenied-patrolflag', 'permissiondenied' );
+ }
+
+ /* Add fields to our query if they are specified as a needed parameter. */
+ $this->addFieldsIf( [ 'rc_this_oldid', 'rc_last_oldid' ], $this->fld_ids );
+ $this->addFieldsIf( 'rc_user', $this->fld_user || $this->fld_userid );
+ $this->addFieldsIf( 'rc_user_text', $this->fld_user );
+ $this->addFieldsIf( [ 'rc_minor', 'rc_type', 'rc_bot' ], $this->fld_flags );
+ $this->addFieldsIf( [ 'rc_old_len', 'rc_new_len' ], $this->fld_sizes );
+ $this->addFieldsIf( [ 'rc_patrolled', 'rc_log_type' ], $this->fld_patrolled );
+ $this->addFieldsIf(
+ [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ],
+ $this->fld_loginfo
+ );
+ $showRedirects = $this->fld_redirect || isset( $show['redirect'] )
+ || isset( $show['!redirect'] );
+ }
+ $this->addFieldsIf( [ 'rc_this_oldid' ],
+ $resultPageSet && $params['generaterevisions'] );
+
+ if ( $this->fld_tags ) {
+ $this->addTables( 'tag_summary' );
+ $this->addJoinConds( [ 'tag_summary' => [ 'LEFT JOIN', [ 'rc_id=ts_rc_id' ] ] ] );
+ $this->addFields( 'ts_tags' );
+ }
+
+ if ( $this->fld_sha1 ) {
+ $this->addTables( 'revision' );
+ $this->addJoinConds( [ 'revision' => [ 'LEFT JOIN',
+ [ 'rc_this_oldid=rev_id' ] ] ] );
+ $this->addFields( [ 'rev_sha1', 'rev_deleted' ] );
+ }
+
+ if ( $params['toponly'] || $showRedirects ) {
+ $this->addTables( 'page' );
+ $this->addJoinConds( [ 'page' => [ 'LEFT JOIN',
+ [ 'rc_namespace=page_namespace', 'rc_title=page_title' ] ] ] );
+ $this->addFields( 'page_is_redirect' );
+
+ if ( $params['toponly'] ) {
+ $this->addWhere( 'rc_this_oldid = page_latest' );
+ }
+ }
+
+ if ( !is_null( $params['tag'] ) ) {
+ $this->addTables( 'change_tag' );
+ $this->addJoinConds( [ 'change_tag' => [ 'INNER JOIN', [ 'rc_id=ct_rc_id' ] ] ] );
+ $this->addWhereFld( 'ct_tag', $params['tag'] );
+ }
+
+ // Paranoia: avoid brute force searches (T19342)
+ if ( !is_null( $params['user'] ) || !is_null( $params['excludeuser'] ) ) {
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $this->getDB()->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask" );
+ }
+ }
+ if ( $this->getRequest()->getCheck( 'namespace' ) ) {
+ // LogPage::DELETED_ACTION hides the affected page, too.
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = LogPage::DELETED_ACTION;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $this->getDB()->makeList( [
+ 'rc_type != ' . RC_LOG,
+ $this->getDB()->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
+ ], LIST_OR ) );
+ }
+ }
+
+ $this->token = $params['token'];
+
+ if ( $this->fld_comment || $this->fld_parsedcomment || $this->token ) {
+ $this->commentStore = new CommentStore( 'rc_comment' );
+ $commentQuery = $this->commentStore->getJoin();
+ $this->addTables( $commentQuery['tables'] );
+ $this->addFields( $commentQuery['fields'] );
+ $this->addJoinConds( $commentQuery['joins'] );
+ }
+
+ $this->addOption( 'LIMIT', $params['limit'] + 1 );
+
+ $hookData = [];
+ $count = 0;
+ /* Perform the actual query. */
+ $res = $this->select( __METHOD__, [], $hookData );
+
+ $revids = [];
+ $titles = [];
+
+ $result = $this->getResult();
+
+ /* Iterate through the rows, adding data extracted from them to our query result. */
+ foreach ( $res as $row ) {
+ if ( $count === 0 && $resultPageSet !== null ) {
+ // Set the non-continue since the list of recentchanges is
+ // prone to having entries added at the start frequently.
+ $this->getContinuationManager()->addGeneratorNonContinueParam(
+ $this, 'continue', "$row->rc_timestamp|$row->rc_id"
+ );
+ }
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', "$row->rc_timestamp|$row->rc_id" );
+ break;
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ /* Extract the data from a single row. */
+ $vals = $this->extractRowInfo( $row );
+
+ /* Add that row's data to our final output. */
+ $fit = $this->processRow( $row, $vals, $hookData ) &&
+ $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', "$row->rc_timestamp|$row->rc_id" );
+ break;
+ }
+ } elseif ( $params['generaterevisions'] ) {
+ $revid = (int)$row->rc_this_oldid;
+ if ( $revid > 0 ) {
+ $revids[] = $revid;
+ }
+ } else {
+ $titles[] = Title::makeTitle( $row->rc_namespace, $row->rc_title );
+ }
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ /* Format the result */
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'rc' );
+ } elseif ( $params['generaterevisions'] ) {
+ $resultPageSet->populateFromRevisionIDs( $revids );
+ } else {
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ /**
+ * Extracts from a single sql row the data needed to describe one recent change.
+ *
+ * @param stdClass $row The row from which to extract the data.
+ * @return array An array mapping strings (descriptors) to their respective string values.
+ * @access public
+ */
+ public function extractRowInfo( $row ) {
+ /* Determine the title of the page that has been changed. */
+ $title = Title::makeTitle( $row->rc_namespace, $row->rc_title );
+ $user = $this->getUser();
+
+ /* Our output data. */
+ $vals = [];
+
+ $type = intval( $row->rc_type );
+ $vals['type'] = RecentChange::parseFromRCType( $type );
+
+ $anyHidden = false;
+
+ /* Create a new entry in the result for the title. */
+ if ( $this->fld_title || $this->fld_ids ) {
+ if ( $type === RC_LOG && ( $row->rc_deleted & LogPage::DELETED_ACTION ) ) {
+ $vals['actionhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( $type !== RC_LOG ||
+ LogEventsList::userCanBitfield( $row->rc_deleted, LogPage::DELETED_ACTION, $user )
+ ) {
+ if ( $this->fld_title ) {
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ }
+ if ( $this->fld_ids ) {
+ $vals['pageid'] = intval( $row->rc_cur_id );
+ $vals['revid'] = intval( $row->rc_this_oldid );
+ $vals['old_revid'] = intval( $row->rc_last_oldid );
+ }
+ }
+ }
+
+ if ( $this->fld_ids ) {
+ $vals['rcid'] = intval( $row->rc_id );
+ }
+
+ /* Add user data and 'anon' flag, if user is anonymous. */
+ if ( $this->fld_user || $this->fld_userid ) {
+ if ( $row->rc_deleted & Revision::DELETED_USER ) {
+ $vals['userhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield( $row->rc_deleted, Revision::DELETED_USER, $user ) ) {
+ if ( $this->fld_user ) {
+ $vals['user'] = $row->rc_user_text;
+ }
+
+ if ( $this->fld_userid ) {
+ $vals['userid'] = (int)$row->rc_user;
+ }
+
+ if ( !$row->rc_user ) {
+ $vals['anon'] = true;
+ }
+ }
+ }
+
+ /* Add flags, such as new, minor, bot. */
+ if ( $this->fld_flags ) {
+ $vals['bot'] = (bool)$row->rc_bot;
+ $vals['new'] = $row->rc_type == RC_NEW;
+ $vals['minor'] = (bool)$row->rc_minor;
+ }
+
+ /* Add sizes of each revision. (Only available on 1.10+) */
+ if ( $this->fld_sizes ) {
+ $vals['oldlen'] = intval( $row->rc_old_len );
+ $vals['newlen'] = intval( $row->rc_new_len );
+ }
+
+ /* Add the timestamp. */
+ if ( $this->fld_timestamp ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->rc_timestamp );
+ }
+
+ /* Add edit summary / log summary. */
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ if ( $row->rc_deleted & Revision::DELETED_COMMENT ) {
+ $vals['commenthidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield( $row->rc_deleted, Revision::DELETED_COMMENT, $user ) ) {
+ $comment = $this->commentStore->getComment( $row )->text;
+ if ( $this->fld_comment ) {
+ $vals['comment'] = $comment;
+ }
+
+ if ( $this->fld_parsedcomment ) {
+ $vals['parsedcomment'] = Linker::formatComment( $comment, $title );
+ }
+ }
+ }
+
+ if ( $this->fld_redirect ) {
+ $vals['redirect'] = (bool)$row->page_is_redirect;
+ }
+
+ /* Add the patrolled flag */
+ if ( $this->fld_patrolled ) {
+ $vals['patrolled'] = $row->rc_patrolled == 1;
+ $vals['unpatrolled'] = ChangesList::isUnpatrolled( $row, $user );
+ }
+
+ if ( $this->fld_loginfo && $row->rc_type == RC_LOG ) {
+ if ( $row->rc_deleted & LogPage::DELETED_ACTION ) {
+ $vals['actionhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( LogEventsList::userCanBitfield( $row->rc_deleted, LogPage::DELETED_ACTION, $user ) ) {
+ $vals['logid'] = intval( $row->rc_logid );
+ $vals['logtype'] = $row->rc_log_type;
+ $vals['logaction'] = $row->rc_log_action;
+ $vals['logparams'] = LogFormatter::newFromRow( $row )->formatParametersForApi();
+ }
+ }
+
+ if ( $this->fld_tags ) {
+ if ( $row->ts_tags ) {
+ $tags = explode( ',', $row->ts_tags );
+ ApiResult::setIndexedTagName( $tags, 'tag' );
+ $vals['tags'] = $tags;
+ } else {
+ $vals['tags'] = [];
+ }
+ }
+
+ if ( $this->fld_sha1 && $row->rev_sha1 !== null ) {
+ if ( $row->rev_deleted & Revision::DELETED_TEXT ) {
+ $vals['sha1hidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield( $row->rev_deleted, Revision::DELETED_TEXT, $user ) ) {
+ if ( $row->rev_sha1 !== '' ) {
+ $vals['sha1'] = Wikimedia\base_convert( $row->rev_sha1, 36, 16, 40 );
+ } else {
+ $vals['sha1'] = '';
+ }
+ }
+ }
+
+ if ( !is_null( $this->token ) ) {
+ $tokenFunctions = $this->getTokenFunctions();
+ foreach ( $this->token as $t ) {
+ $val = call_user_func( $tokenFunctions[$t], $row->rc_cur_id,
+ $title, RecentChange::newFromRow( $row ) );
+ if ( $val === false ) {
+ $this->addWarning( [ 'apiwarn-tokennotallowed', $t ] );
+ } else {
+ $vals[$t . 'token'] = $val;
+ }
+ }
+ }
+
+ if ( $anyHidden && ( $row->rc_deleted & Revision::DELETED_RESTRICTED ) ) {
+ $vals['suppressed'] = true;
+ }
+
+ return $vals;
+ }
+
+ public function getCacheMode( $params ) {
+ if ( isset( $params['show'] ) ) {
+ foreach ( $params['show'] as $show ) {
+ if ( $show === 'patrolled' || $show === '!patrolled' ) {
+ return 'private';
+ }
+ }
+ }
+ if ( isset( $params['token'] ) ) {
+ return 'private';
+ }
+ if ( $this->userCanSeeRevDel() ) {
+ return 'private';
+ }
+ if ( !is_null( $params['prop'] ) && in_array( 'parsedcomment', $params['prop'] ) ) {
+ // formatComment() calls wfMessage() among other things
+ return 'anon-public-user-private';
+ }
+
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_EXTRA_NAMESPACES => [ NS_MEDIA, NS_SPECIAL ],
+ ],
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'excludeuser' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'tag' => null,
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'title|timestamp|ids',
+ ApiBase::PARAM_TYPE => [
+ 'user',
+ 'userid',
+ 'comment',
+ 'parsedcomment',
+ 'flags',
+ 'timestamp',
+ 'title',
+ 'ids',
+ 'sizes',
+ 'redirect',
+ 'patrolled',
+ 'loginfo',
+ 'tags',
+ 'sha1',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'token' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ApiBase::PARAM_TYPE => array_keys( $this->getTokenFunctions() ),
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'show' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'minor',
+ '!minor',
+ 'bot',
+ '!bot',
+ 'anon',
+ '!anon',
+ 'redirect',
+ '!redirect',
+ 'patrolled',
+ '!patrolled',
+ 'unpatrolled'
+ ]
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'type' => [
+ ApiBase::PARAM_DFLT => 'edit|new|log|categorize',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => RecentChange::getChangeTypes()
+ ],
+ 'toponly' => false,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'generaterevisions' => false,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=recentchanges'
+ => 'apihelp-query+recentchanges-example-simple',
+ 'action=query&generator=recentchanges&grcshow=!patrolled&prop=info'
+ => 'apihelp-query+recentchanges-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Recentchanges';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryRevisions.php b/www/wiki/includes/api/ApiQueryRevisions.php
new file mode 100644
index 00000000..2dfa42a3
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryRevisions.php
@@ -0,0 +1,517 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 7, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query action to enumerate revisions of a given page, or show top revisions
+ * of multiple pages. Various pieces of information may be shown - flags,
+ * comments, and the actual wiki markup of the rev. In the enumeration mode,
+ * ranges of revisions may be requested and filtered.
+ *
+ * @ingroup API
+ */
+class ApiQueryRevisions extends ApiQueryRevisionsBase {
+
+ private $token = null;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'rv' );
+ }
+
+ private $tokenFunctions;
+
+ /** @deprecated since 1.24 */
+ protected function getTokenFunctions() {
+ // tokenname => function
+ // function prototype is func($pageid, $title, $rev)
+ // should return token or false
+
+ // Don't call the hooks twice
+ if ( isset( $this->tokenFunctions ) ) {
+ return $this->tokenFunctions;
+ }
+
+ // If we're in a mode that breaks the same-origin policy, no tokens can
+ // be obtained
+ if ( $this->lacksSameOriginSecurity() ) {
+ return [];
+ }
+
+ $this->tokenFunctions = [
+ 'rollback' => [ 'ApiQueryRevisions', 'getRollbackToken' ]
+ ];
+ Hooks::run( 'APIQueryRevisionsTokens', [ &$this->tokenFunctions ] );
+
+ return $this->tokenFunctions;
+ }
+
+ /**
+ * @deprecated since 1.24
+ * @param int $pageid
+ * @param Title $title
+ * @param Revision $rev
+ * @return bool|string
+ */
+ public static function getRollbackToken( $pageid, $title, $rev ) {
+ global $wgUser;
+ if ( !$wgUser->isAllowed( 'rollback' ) ) {
+ return false;
+ }
+
+ return $wgUser->getEditToken( 'rollback' );
+ }
+
+ protected function run( ApiPageSet $resultPageSet = null ) {
+ $params = $this->extractRequestParams( false );
+
+ // If any of those parameters are used, work in 'enumeration' mode.
+ // Enum mode can only be used when exactly one page is provided.
+ // Enumerating revisions on multiple pages make it extremely
+ // difficult to manage continuations and require additional SQL indexes
+ $enumRevMode = ( $params['user'] !== null || $params['excludeuser'] !== null ||
+ $params['limit'] !== null || $params['startid'] !== null ||
+ $params['endid'] !== null || $params['dir'] === 'newer' ||
+ $params['start'] !== null || $params['end'] !== null );
+
+ $pageSet = $this->getPageSet();
+ $pageCount = $pageSet->getGoodTitleCount();
+ $revCount = $pageSet->getRevisionCount();
+
+ // Optimization -- nothing to do
+ if ( $revCount === 0 && $pageCount === 0 ) {
+ // Nothing to do
+ return;
+ }
+ if ( $revCount > 0 && count( $pageSet->getLiveRevisionIDs() ) === 0 ) {
+ // We're in revisions mode but all given revisions are deleted
+ return;
+ }
+
+ if ( $revCount > 0 && $enumRevMode ) {
+ $this->dieWithError(
+ [ 'apierror-revisions-nolist', $this->getModulePrefix() ], 'invalidparammix'
+ );
+ }
+
+ if ( $pageCount > 1 && $enumRevMode ) {
+ $this->dieWithError(
+ [ 'apierror-revisions-singlepage', $this->getModulePrefix() ], 'invalidparammix'
+ );
+ }
+
+ // In non-enum mode, rvlimit can't be directly used. Use the maximum
+ // allowed value.
+ if ( !$enumRevMode ) {
+ $this->setParsedLimit = false;
+ $params['limit'] = 'max';
+ }
+
+ $db = $this->getDB();
+ $this->addTables( [ 'revision', 'page' ] );
+ $this->addJoinConds(
+ [ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ] ]
+ );
+
+ if ( $resultPageSet === null ) {
+ $this->parseParameters( $params );
+ $this->token = $params['token'];
+ $this->addFields( Revision::selectFields() );
+ if ( $this->token !== null || $pageCount > 0 ) {
+ $this->addFields( Revision::selectPageFields() );
+ }
+ } else {
+ $this->limit = $this->getParameter( 'limit' ) ?: 10;
+ $this->addFields( [ 'rev_id', 'rev_timestamp', 'rev_page' ] );
+ }
+
+ if ( $this->fld_tags ) {
+ $this->addTables( 'tag_summary' );
+ $this->addJoinConds(
+ [ 'tag_summary' => [ 'LEFT JOIN', [ 'rev_id=ts_rev_id' ] ] ]
+ );
+ $this->addFields( 'ts_tags' );
+ }
+
+ if ( $params['tag'] !== null ) {
+ $this->addTables( 'change_tag' );
+ $this->addJoinConds(
+ [ 'change_tag' => [ 'INNER JOIN', [ 'rev_id=ct_rev_id' ] ] ]
+ );
+ $this->addWhereFld( 'ct_tag', $params['tag'] );
+ }
+
+ if ( $this->fetchContent ) {
+ // For each page we will request, the user must have read rights for that page
+ $user = $this->getUser();
+ $status = Status::newGood();
+ /** @var Title $title */
+ foreach ( $pageSet->getGoodTitles() as $title ) {
+ if ( !$title->userCan( 'read', $user ) ) {
+ $status->fatal( ApiMessage::create(
+ [ 'apierror-cannotviewtitle', wfEscapeWikiText( $title->getPrefixedText() ) ],
+ 'accessdenied'
+ ) );
+ }
+ }
+ if ( !$status->isGood() ) {
+ $this->dieStatus( $status );
+ }
+
+ $this->addTables( 'text' );
+ $this->addJoinConds(
+ [ 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ] ]
+ );
+ $this->addFields( 'old_id' );
+ $this->addFields( Revision::selectTextFields() );
+ }
+
+ // add user name, if needed
+ if ( $this->fld_user ) {
+ $this->addTables( 'user' );
+ $this->addJoinConds( [ 'user' => Revision::userJoinCond() ] );
+ $this->addFields( Revision::selectUserFields() );
+ }
+
+ if ( $enumRevMode ) {
+ // Indexes targeted:
+ // page_timestamp if we don't have rvuser
+ // page_user_timestamp if we have a logged-in rvuser
+ // page_timestamp or usertext_timestamp if we have an IP rvuser
+
+ // This is mostly to prevent parameter errors (and optimize SQL?)
+ $this->requireMaxOneParameter( $params, 'startid', 'start' );
+ $this->requireMaxOneParameter( $params, 'endid', 'end' );
+ $this->requireMaxOneParameter( $params, 'user', 'excludeuser' );
+
+ if ( $params['continue'] !== null ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $op = ( $params['dir'] === 'newer' ? '>' : '<' );
+ $continueTimestamp = $db->addQuotes( $db->timestamp( $cont[0] ) );
+ $continueId = (int)$cont[1];
+ $this->dieContinueUsageIf( $continueId != $cont[1] );
+ $this->addWhere( "rev_timestamp $op $continueTimestamp OR " .
+ "(rev_timestamp = $continueTimestamp AND " .
+ "rev_id $op= $continueId)"
+ );
+ }
+
+ // Convert startid/endid to timestamps (T163532)
+ $revids = [];
+ if ( $params['startid'] !== null ) {
+ $revids[] = (int)$params['startid'];
+ }
+ if ( $params['endid'] !== null ) {
+ $revids[] = (int)$params['endid'];
+ }
+ if ( $revids ) {
+ $db = $this->getDB();
+ $sql = $db->unionQueries( [
+ $db->selectSQLText(
+ 'revision',
+ [ 'id' => 'rev_id', 'ts' => 'rev_timestamp' ],
+ [ 'rev_id' => $revids ],
+ __METHOD__
+ ),
+ $db->selectSQLText(
+ 'archive',
+ [ 'id' => 'ar_rev_id', 'ts' => 'ar_timestamp' ],
+ [ 'ar_rev_id' => $revids ],
+ __METHOD__
+ ),
+ ], false );
+ $res = $db->query( $sql, __METHOD__ );
+ foreach ( $res as $row ) {
+ if ( (int)$row->id === (int)$params['startid'] ) {
+ $params['start'] = $row->ts;
+ }
+ if ( (int)$row->id === (int)$params['endid'] ) {
+ $params['end'] = $row->ts;
+ }
+ }
+ if ( $params['startid'] !== null && $params['start'] === null ) {
+ $p = $this->encodeParamName( 'startid' );
+ $this->dieWithError( [ 'apierror-revisions-badid', $p ], "badid_$p" );
+ }
+ if ( $params['endid'] !== null && $params['end'] === null ) {
+ $p = $this->encodeParamName( 'endid' );
+ $this->dieWithError( [ 'apierror-revisions-badid', $p ], "badid_$p" );
+ }
+
+ if ( $params['start'] !== null ) {
+ $op = ( $params['dir'] === 'newer' ? '>' : '<' );
+ $ts = $db->addQuotes( $db->timestampOrNull( $params['start'] ) );
+ if ( $params['startid'] !== null ) {
+ $this->addWhere( "rev_timestamp $op $ts OR "
+ . "rev_timestamp = $ts AND rev_id $op= " . intval( $params['startid'] ) );
+ } else {
+ $this->addWhere( "rev_timestamp $op= $ts" );
+ }
+ }
+ if ( $params['end'] !== null ) {
+ $op = ( $params['dir'] === 'newer' ? '<' : '>' ); // Yes, opposite of the above
+ $ts = $db->addQuotes( $db->timestampOrNull( $params['end'] ) );
+ if ( $params['endid'] !== null ) {
+ $this->addWhere( "rev_timestamp $op $ts OR "
+ . "rev_timestamp = $ts AND rev_id $op= " . intval( $params['endid'] ) );
+ } else {
+ $this->addWhere( "rev_timestamp $op= $ts" );
+ }
+ }
+ } else {
+ $this->addTimestampWhereRange( 'rev_timestamp', $params['dir'],
+ $params['start'], $params['end'] );
+ }
+
+ $sort = ( $params['dir'] === 'newer' ? '' : 'DESC' );
+ $this->addOption( 'ORDER BY', [ "rev_timestamp $sort", "rev_id $sort" ] );
+
+ // There is only one ID, use it
+ $ids = array_keys( $pageSet->getGoodTitles() );
+ $this->addWhereFld( 'rev_page', reset( $ids ) );
+
+ if ( $params['user'] !== null ) {
+ $user = User::newFromName( $params['user'] );
+ if ( $user && $user->getId() > 0 ) {
+ $this->addWhereFld( 'rev_user', $user->getId() );
+ } else {
+ $this->addWhereFld( 'rev_user_text', $params['user'] );
+ }
+ } elseif ( $params['excludeuser'] !== null ) {
+ $user = User::newFromName( $params['excludeuser'] );
+ if ( $user && $user->getId() > 0 ) {
+ $this->addWhere( 'rev_user != ' . $user->getId() );
+ } else {
+ $this->addWhere( 'rev_user_text != ' .
+ $db->addQuotes( $params['excludeuser'] ) );
+ }
+ }
+ if ( $params['user'] !== null || $params['excludeuser'] !== null ) {
+ // Paranoia: avoid brute force searches (T19342)
+ if ( !$this->getUser()->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$this->getUser()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $db->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" );
+ }
+ }
+ } elseif ( $revCount > 0 ) {
+ // Always targets the PRIMARY index
+
+ $revs = $pageSet->getLiveRevisionIDs();
+
+ // Get all revision IDs
+ $this->addWhereFld( 'rev_id', array_keys( $revs ) );
+
+ if ( $params['continue'] !== null ) {
+ $this->addWhere( 'rev_id >= ' . intval( $params['continue'] ) );
+ }
+ $this->addOption( 'ORDER BY', 'rev_id' );
+ } elseif ( $pageCount > 0 ) {
+ // Always targets the rev_page_id index
+
+ $titles = $pageSet->getGoodTitles();
+
+ // When working in multi-page non-enumeration mode,
+ // limit to the latest revision only
+ $this->addWhere( 'page_latest=rev_id' );
+
+ // Get all page IDs
+ $this->addWhereFld( 'page_id', array_keys( $titles ) );
+ // Every time someone relies on equality propagation, god kills a kitten :)
+ $this->addWhereFld( 'rev_page', array_keys( $titles ) );
+
+ if ( $params['continue'] !== null ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $pageid = intval( $cont[0] );
+ $revid = intval( $cont[1] );
+ $this->addWhere(
+ "rev_page > $pageid OR " .
+ "(rev_page = $pageid AND " .
+ "rev_id >= $revid)"
+ );
+ }
+ $this->addOption( 'ORDER BY', [
+ 'rev_page',
+ 'rev_id'
+ ] );
+ } else {
+ ApiBase::dieDebug( __METHOD__, 'param validation?' );
+ }
+
+ $this->addOption( 'LIMIT', $this->limit + 1 );
+
+ $count = 0;
+ $generated = [];
+ $hookData = [];
+ $res = $this->select( __METHOD__, [], $hookData );
+
+ foreach ( $res as $row ) {
+ if ( ++$count > $this->limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ if ( $enumRevMode ) {
+ $this->setContinueEnumParameter( 'continue',
+ $row->rev_timestamp . '|' . intval( $row->rev_id ) );
+ } elseif ( $revCount > 0 ) {
+ $this->setContinueEnumParameter( 'continue', intval( $row->rev_id ) );
+ } else {
+ $this->setContinueEnumParameter( 'continue', intval( $row->rev_page ) .
+ '|' . intval( $row->rev_id ) );
+ }
+ break;
+ }
+
+ if ( $resultPageSet !== null ) {
+ $generated[] = $row->rev_id;
+ } else {
+ $revision = new Revision( $row );
+ $rev = $this->extractRevisionInfo( $revision, $row );
+
+ if ( $this->token !== null ) {
+ $title = $revision->getTitle();
+ $tokenFunctions = $this->getTokenFunctions();
+ foreach ( $this->token as $t ) {
+ $val = call_user_func( $tokenFunctions[$t], $title->getArticleID(), $title, $revision );
+ if ( $val === false ) {
+ $this->addWarning( [ 'apiwarn-tokennotallowed', $t ] );
+ } else {
+ $rev[$t . 'token'] = $val;
+ }
+ }
+ }
+
+ $fit = $this->processRow( $row, $rev, $hookData ) &&
+ $this->addPageSubItem( $row->rev_page, $rev, 'rev' );
+ if ( !$fit ) {
+ if ( $enumRevMode ) {
+ $this->setContinueEnumParameter( 'continue',
+ $row->rev_timestamp . '|' . intval( $row->rev_id ) );
+ } elseif ( $revCount > 0 ) {
+ $this->setContinueEnumParameter( 'continue', intval( $row->rev_id ) );
+ } else {
+ $this->setContinueEnumParameter( 'continue', intval( $row->rev_page ) .
+ '|' . intval( $row->rev_id ) );
+ }
+ break;
+ }
+ }
+ }
+
+ if ( $resultPageSet !== null ) {
+ $resultPageSet->populateFromRevisionIDs( $generated );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ if ( isset( $params['token'] ) ) {
+ return 'private';
+ }
+ return parent::getCacheMode( $params );
+ }
+
+ public function getAllowedParams() {
+ $ret = parent::getAllowedParams() + [
+ 'startid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
+ ],
+ 'endid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
+ ],
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
+ ],
+ 'excludeuser' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
+ ],
+ 'tag' => null,
+ 'token' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ApiBase::PARAM_TYPE => array_keys( $this->getTokenFunctions() ),
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+
+ $ret['limit'][ApiBase::PARAM_HELP_MSG_INFO] = [ [ 'singlepageonly' ] ];
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=revisions&titles=API|Main%20Page&' .
+ 'rvprop=timestamp|user|comment|content'
+ => 'apihelp-query+revisions-example-content',
+ 'action=query&prop=revisions&titles=Main%20Page&rvlimit=5&' .
+ 'rvprop=timestamp|user|comment'
+ => 'apihelp-query+revisions-example-last5',
+ 'action=query&prop=revisions&titles=Main%20Page&rvlimit=5&' .
+ 'rvprop=timestamp|user|comment&rvdir=newer'
+ => 'apihelp-query+revisions-example-first5',
+ 'action=query&prop=revisions&titles=Main%20Page&rvlimit=5&' .
+ 'rvprop=timestamp|user|comment&rvdir=newer&rvstart=2006-05-01T00:00:00Z'
+ => 'apihelp-query+revisions-example-first5-after',
+ 'action=query&prop=revisions&titles=Main%20Page&rvlimit=5&' .
+ 'rvprop=timestamp|user|comment&rvexcludeuser=127.0.0.1'
+ => 'apihelp-query+revisions-example-first5-not-localhost',
+ 'action=query&prop=revisions&titles=Main%20Page&rvlimit=5&' .
+ 'rvprop=timestamp|user|comment&rvuser=MediaWiki%20default'
+ => 'apihelp-query+revisions-example-first5-user',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Revisions';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryRevisionsBase.php b/www/wiki/includes/api/ApiQueryRevisionsBase.php
new file mode 100644
index 00000000..2ffd0248
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryRevisionsBase.php
@@ -0,0 +1,526 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 3, 2014 as a split from ApiQueryRevisions
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A base class for functions common to producing a list of revisions.
+ *
+ * @ingroup API
+ */
+abstract class ApiQueryRevisionsBase extends ApiQueryGeneratorBase {
+
+ protected $limit, $diffto, $difftotext, $difftotextpst, $expandTemplates, $generateXML,
+ $section, $parseContent, $fetchContent, $contentFormat, $setParsedLimit = true;
+
+ protected $fld_ids = false, $fld_flags = false, $fld_timestamp = false,
+ $fld_size = false, $fld_sha1 = false, $fld_comment = false,
+ $fld_parsedcomment = false, $fld_user = false, $fld_userid = false,
+ $fld_content = false, $fld_tags = false, $fld_contentmodel = false, $fld_parsetree = false;
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ abstract protected function run( ApiPageSet $resultPageSet = null );
+
+ /**
+ * Parse the parameters into the various instance fields.
+ *
+ * @param array $params
+ */
+ protected function parseParameters( $params ) {
+ if ( !is_null( $params['difftotext'] ) ) {
+ $this->difftotext = $params['difftotext'];
+ $this->difftotextpst = $params['difftotextpst'];
+ } elseif ( !is_null( $params['diffto'] ) ) {
+ if ( $params['diffto'] == 'cur' ) {
+ $params['diffto'] = 0;
+ }
+ if ( ( !ctype_digit( $params['diffto'] ) || $params['diffto'] < 0 )
+ && $params['diffto'] != 'prev' && $params['diffto'] != 'next'
+ ) {
+ $p = $this->getModulePrefix();
+ $this->dieWithError( [ 'apierror-baddiffto', $p ], 'diffto' );
+ }
+ // Check whether the revision exists and is readable,
+ // DifferenceEngine returns a rather ambiguous empty
+ // string if that's not the case
+ if ( $params['diffto'] != 0 ) {
+ $difftoRev = Revision::newFromId( $params['diffto'] );
+ if ( !$difftoRev ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $params['diffto'] ] );
+ }
+ if ( !$difftoRev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
+ $this->addWarning( [ 'apiwarn-difftohidden', $difftoRev->getId() ] );
+ $params['diffto'] = null;
+ }
+ }
+ $this->diffto = $params['diffto'];
+ }
+
+ $prop = array_flip( $params['prop'] );
+
+ $this->fld_ids = isset( $prop['ids'] );
+ $this->fld_flags = isset( $prop['flags'] );
+ $this->fld_timestamp = isset( $prop['timestamp'] );
+ $this->fld_comment = isset( $prop['comment'] );
+ $this->fld_parsedcomment = isset( $prop['parsedcomment'] );
+ $this->fld_size = isset( $prop['size'] );
+ $this->fld_sha1 = isset( $prop['sha1'] );
+ $this->fld_content = isset( $prop['content'] );
+ $this->fld_contentmodel = isset( $prop['contentmodel'] );
+ $this->fld_userid = isset( $prop['userid'] );
+ $this->fld_user = isset( $prop['user'] );
+ $this->fld_tags = isset( $prop['tags'] );
+ $this->fld_parsetree = isset( $prop['parsetree'] );
+
+ if ( $this->fld_parsetree ) {
+ $encParam = $this->encodeParamName( 'prop' );
+ $name = $this->getModuleName();
+ $parent = $this->getParent();
+ $parentParam = $parent->encodeParamName( $parent->getModuleManager()->getModuleGroup( $name ) );
+ $this->addDeprecation(
+ [ 'apiwarn-deprecation-parameter', "{$encParam}=parsetree" ],
+ "action=query&{$parentParam}={$name}&{$encParam}=parsetree"
+ );
+ }
+
+ if ( !empty( $params['contentformat'] ) ) {
+ $this->contentFormat = $params['contentformat'];
+ }
+
+ $this->limit = $params['limit'];
+
+ $this->fetchContent = $this->fld_content || !is_null( $this->diffto )
+ || !is_null( $this->difftotext ) || $this->fld_parsetree;
+
+ $smallLimit = false;
+ if ( $this->fetchContent ) {
+ $smallLimit = true;
+ $this->expandTemplates = $params['expandtemplates'];
+ $this->generateXML = $params['generatexml'];
+ $this->parseContent = $params['parse'];
+ if ( $this->parseContent ) {
+ // Must manually initialize unset limit
+ if ( is_null( $this->limit ) ) {
+ $this->limit = 1;
+ }
+ }
+ if ( isset( $params['section'] ) ) {
+ $this->section = $params['section'];
+ } else {
+ $this->section = false;
+ }
+ }
+
+ $userMax = $this->parseContent ? 1 : ( $smallLimit ? ApiBase::LIMIT_SML1 : ApiBase::LIMIT_BIG1 );
+ $botMax = $this->parseContent ? 1 : ( $smallLimit ? ApiBase::LIMIT_SML2 : ApiBase::LIMIT_BIG2 );
+ if ( $this->limit == 'max' ) {
+ $this->limit = $this->getMain()->canApiHighLimits() ? $botMax : $userMax;
+ if ( $this->setParsedLimit ) {
+ $this->getResult()->addParsedLimit( $this->getModuleName(), $this->limit );
+ }
+ }
+
+ if ( is_null( $this->limit ) ) {
+ $this->limit = 10;
+ }
+ $this->validateLimit( 'limit', $this->limit, 1, $userMax, $botMax );
+ }
+
+ /**
+ * Extract information from the Revision
+ *
+ * @param Revision $revision
+ * @param object $row Should have a field 'ts_tags' if $this->fld_tags is set
+ * @return array
+ */
+ protected function extractRevisionInfo( Revision $revision, $row ) {
+ $title = $revision->getTitle();
+ $user = $this->getUser();
+ $vals = [];
+ $anyHidden = false;
+
+ if ( $this->fld_ids ) {
+ $vals['revid'] = intval( $revision->getId() );
+ if ( !is_null( $revision->getParentId() ) ) {
+ $vals['parentid'] = intval( $revision->getParentId() );
+ }
+ }
+
+ if ( $this->fld_flags ) {
+ $vals['minor'] = $revision->isMinor();
+ }
+
+ if ( $this->fld_user || $this->fld_userid ) {
+ if ( $revision->isDeleted( Revision::DELETED_USER ) ) {
+ $vals['userhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( $revision->userCan( Revision::DELETED_USER, $user ) ) {
+ if ( $this->fld_user ) {
+ $vals['user'] = $revision->getUserText( Revision::RAW );
+ }
+ $userid = $revision->getUser( Revision::RAW );
+ if ( !$userid ) {
+ $vals['anon'] = true;
+ }
+
+ if ( $this->fld_userid ) {
+ $vals['userid'] = $userid;
+ }
+ }
+ }
+
+ if ( $this->fld_timestamp ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $revision->getTimestamp() );
+ }
+
+ if ( $this->fld_size ) {
+ if ( !is_null( $revision->getSize() ) ) {
+ $vals['size'] = intval( $revision->getSize() );
+ } else {
+ $vals['size'] = 0;
+ }
+ }
+
+ if ( $this->fld_sha1 ) {
+ if ( $revision->isDeleted( Revision::DELETED_TEXT ) ) {
+ $vals['sha1hidden'] = true;
+ $anyHidden = true;
+ }
+ if ( $revision->userCan( Revision::DELETED_TEXT, $user ) ) {
+ if ( $revision->getSha1() != '' ) {
+ $vals['sha1'] = Wikimedia\base_convert( $revision->getSha1(), 36, 16, 40 );
+ } else {
+ $vals['sha1'] = '';
+ }
+ }
+ }
+
+ if ( $this->fld_contentmodel ) {
+ $vals['contentmodel'] = $revision->getContentModel();
+ }
+
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ if ( $revision->isDeleted( Revision::DELETED_COMMENT ) ) {
+ $vals['commenthidden'] = true;
+ $anyHidden = true;
+ }
+ if ( $revision->userCan( Revision::DELETED_COMMENT, $user ) ) {
+ $comment = $revision->getComment( Revision::RAW );
+
+ if ( $this->fld_comment ) {
+ $vals['comment'] = $comment;
+ }
+
+ if ( $this->fld_parsedcomment ) {
+ $vals['parsedcomment'] = Linker::formatComment( $comment, $title );
+ }
+ }
+ }
+
+ if ( $this->fld_tags ) {
+ if ( $row->ts_tags ) {
+ $tags = explode( ',', $row->ts_tags );
+ ApiResult::setIndexedTagName( $tags, 'tag' );
+ $vals['tags'] = $tags;
+ } else {
+ $vals['tags'] = [];
+ }
+ }
+
+ $content = null;
+ global $wgParser;
+ if ( $this->fetchContent ) {
+ $content = $revision->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+ // Expand templates after getting section content because
+ // template-added sections don't count and Parser::preprocess()
+ // will have less input
+ if ( $content && $this->section !== false ) {
+ $content = $content->getSection( $this->section, false );
+ if ( !$content ) {
+ $this->dieWithError(
+ [
+ 'apierror-nosuchsection-what',
+ wfEscapeWikiText( $this->section ),
+ $this->msg( 'revid', $revision->getId() )
+ ],
+ 'nosuchsection'
+ );
+ }
+ }
+ if ( $revision->isDeleted( Revision::DELETED_TEXT ) ) {
+ $vals['texthidden'] = true;
+ $anyHidden = true;
+ } elseif ( !$content ) {
+ $vals['textmissing'] = true;
+ }
+ }
+ if ( $this->fld_parsetree || ( $this->fld_content && $this->generateXML ) ) {
+ if ( $content ) {
+ if ( $content->getModel() === CONTENT_MODEL_WIKITEXT ) {
+ $t = $content->getNativeData(); # note: don't set $text
+
+ $wgParser->startExternalParse(
+ $title,
+ ParserOptions::newFromContext( $this->getContext() ),
+ Parser::OT_PREPROCESS
+ );
+ $dom = $wgParser->preprocessToDom( $t );
+ if ( is_callable( [ $dom, 'saveXML' ] ) ) {
+ $xml = $dom->saveXML();
+ } else {
+ $xml = $dom->__toString();
+ }
+ $vals['parsetree'] = $xml;
+ } else {
+ $vals['badcontentformatforparsetree'] = true;
+ $this->addWarning(
+ [
+ 'apierror-parsetree-notwikitext-title',
+ wfEscapeWikiText( $title->getPrefixedText() ),
+ $content->getModel()
+ ],
+ 'parsetree-notwikitext'
+ );
+ }
+ }
+ }
+
+ if ( $this->fld_content && $content ) {
+ $text = null;
+
+ if ( $this->expandTemplates && !$this->parseContent ) {
+ # XXX: implement template expansion for all content types in ContentHandler?
+ if ( $content->getModel() === CONTENT_MODEL_WIKITEXT ) {
+ $text = $content->getNativeData();
+
+ $text = $wgParser->preprocess(
+ $text,
+ $title,
+ ParserOptions::newFromContext( $this->getContext() )
+ );
+ } else {
+ $this->addWarning( [
+ 'apierror-templateexpansion-notwikitext',
+ wfEscapeWikiText( $title->getPrefixedText() ),
+ $content->getModel()
+ ] );
+ $vals['badcontentformat'] = true;
+ $text = false;
+ }
+ }
+ if ( $this->parseContent ) {
+ $po = $content->getParserOutput(
+ $title,
+ $revision->getId(),
+ ParserOptions::newFromContext( $this->getContext() )
+ );
+ $text = $po->getText();
+ }
+
+ if ( $text === null ) {
+ $format = $this->contentFormat ?: $content->getDefaultFormat();
+ $model = $content->getModel();
+
+ if ( !$content->isSupportedFormat( $format ) ) {
+ $name = wfEscapeWikiText( $title->getPrefixedText() );
+ $this->addWarning( [ 'apierror-badformat', $this->contentFormat, $model, $name ] );
+ $vals['badcontentformat'] = true;
+ $text = false;
+ } else {
+ $text = $content->serialize( $format );
+ // always include format and model.
+ // Format is needed to deserialize, model is needed to interpret.
+ $vals['contentformat'] = $format;
+ $vals['contentmodel'] = $model;
+ }
+ }
+
+ if ( $text !== false ) {
+ ApiResult::setContentValue( $vals, 'content', $text );
+ }
+ }
+
+ if ( $content && ( !is_null( $this->diffto ) || !is_null( $this->difftotext ) ) ) {
+ static $n = 0; // Number of uncached diffs we've had
+
+ if ( $n < $this->getConfig()->get( 'APIMaxUncachedDiffs' ) ) {
+ $vals['diff'] = [];
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $title );
+ $handler = $revision->getContentHandler();
+
+ if ( !is_null( $this->difftotext ) ) {
+ $model = $title->getContentModel();
+
+ if ( $this->contentFormat
+ && !ContentHandler::getForModelID( $model )->isSupportedFormat( $this->contentFormat )
+ ) {
+ $name = wfEscapeWikiText( $title->getPrefixedText() );
+ $this->addWarning( [ 'apierror-badformat', $this->contentFormat, $model, $name ] );
+ $vals['diff']['badcontentformat'] = true;
+ $engine = null;
+ } else {
+ $difftocontent = ContentHandler::makeContent(
+ $this->difftotext,
+ $title,
+ $model,
+ $this->contentFormat
+ );
+
+ if ( $this->difftotextpst ) {
+ $popts = ParserOptions::newFromContext( $this->getContext() );
+ $difftocontent = $difftocontent->preSaveTransform( $title, $user, $popts );
+ }
+
+ $engine = $handler->createDifferenceEngine( $context );
+ $engine->setContent( $content, $difftocontent );
+ }
+ } else {
+ $engine = $handler->createDifferenceEngine( $context, $revision->getId(), $this->diffto );
+ $vals['diff']['from'] = $engine->getOldid();
+ $vals['diff']['to'] = $engine->getNewid();
+ }
+ if ( $engine ) {
+ $difftext = $engine->getDiffBody();
+ ApiResult::setContentValue( $vals['diff'], 'body', $difftext );
+ if ( !$engine->wasCacheHit() ) {
+ $n++;
+ }
+ }
+ } else {
+ $vals['diff']['notcached'] = true;
+ }
+ }
+
+ if ( $anyHidden && $revision->isDeleted( Revision::DELETED_RESTRICTED ) ) {
+ $vals['suppressed'] = true;
+ }
+
+ return $vals;
+ }
+
+ public function getCacheMode( $params ) {
+ if ( $this->userCanSeeRevDel() ) {
+ return 'private';
+ }
+
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'ids|timestamp|flags|comment|user',
+ ApiBase::PARAM_TYPE => [
+ 'ids',
+ 'flags',
+ 'timestamp',
+ 'user',
+ 'userid',
+ 'size',
+ 'sha1',
+ 'contentmodel',
+ 'comment',
+ 'parsedcomment',
+ 'content',
+ 'tags',
+ 'parsetree',
+ ],
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-prop',
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [
+ 'ids' => 'apihelp-query+revisions+base-paramvalue-prop-ids',
+ 'flags' => 'apihelp-query+revisions+base-paramvalue-prop-flags',
+ 'timestamp' => 'apihelp-query+revisions+base-paramvalue-prop-timestamp',
+ 'user' => 'apihelp-query+revisions+base-paramvalue-prop-user',
+ 'userid' => 'apihelp-query+revisions+base-paramvalue-prop-userid',
+ 'size' => 'apihelp-query+revisions+base-paramvalue-prop-size',
+ 'sha1' => 'apihelp-query+revisions+base-paramvalue-prop-sha1',
+ 'contentmodel' => 'apihelp-query+revisions+base-paramvalue-prop-contentmodel',
+ 'comment' => 'apihelp-query+revisions+base-paramvalue-prop-comment',
+ 'parsedcomment' => 'apihelp-query+revisions+base-paramvalue-prop-parsedcomment',
+ 'content' => 'apihelp-query+revisions+base-paramvalue-prop-content',
+ 'tags' => 'apihelp-query+revisions+base-paramvalue-prop-tags',
+ 'parsetree' => [ 'apihelp-query+revisions+base-paramvalue-prop-parsetree',
+ CONTENT_MODEL_WIKITEXT ],
+ ],
+ ],
+ 'limit' => [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-limit',
+ ],
+ 'expandtemplates' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-expandtemplates',
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'generatexml' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-generatexml',
+ ],
+ 'parse' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-parse',
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'section' => [
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-section',
+ ],
+ 'diffto' => [
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-diffto',
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'difftotext' => [
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-difftotext',
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'difftotextpst' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-difftotextpst',
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'contentformat' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+revisions+base-param-contentformat',
+ ],
+ ];
+ }
+
+}
diff --git a/www/wiki/includes/api/ApiQuerySearch.php b/www/wiki/includes/api/ApiQuerySearch.php
new file mode 100644
index 00000000..f0c41800
--- /dev/null
+++ b/www/wiki/includes/api/ApiQuerySearch.php
@@ -0,0 +1,413 @@
+<?php
+/**
+ *
+ *
+ * Created on July 30, 2007
+ *
+ * Copyright © 2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to perform full text search within wiki titles and content
+ *
+ * @ingroup API
+ */
+class ApiQuerySearch extends ApiQueryGeneratorBase {
+ use SearchApi;
+
+ /** @var array list of api allowed params */
+ private $allowedParams;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'sr' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ global $wgContLang;
+ $params = $this->extractRequestParams();
+
+ // Extract parameters
+ $query = $params['search'];
+ $what = $params['what'];
+ $interwiki = $params['interwiki'];
+ $searchInfo = array_flip( $params['info'] );
+ $prop = array_flip( $params['prop'] );
+
+ // Create search engine instance and set options
+ $search = $this->buildSearchEngine( $params );
+ $search->setFeatureData( 'rewrite', (bool)$params['enablerewrites'] );
+ $search->setFeatureData( 'interwiki', (bool)$interwiki );
+
+ $query = $search->transformSearchTerm( $query );
+ $query = $search->replacePrefixes( $query );
+
+ // Perform the actual search
+ if ( $what == 'text' ) {
+ $matches = $search->searchText( $query );
+ } elseif ( $what == 'title' ) {
+ $matches = $search->searchTitle( $query );
+ } elseif ( $what == 'nearmatch' ) {
+ // near matches must receive the user input as provided, otherwise
+ // the near matches within namespaces are lost.
+ $matches = $search->getNearMatcher( $this->getConfig() )
+ ->getNearMatchResultSet( $params['search'] );
+ } else {
+ // We default to title searches; this is a terrible legacy
+ // of the way we initially set up the MySQL fulltext-based
+ // search engine with separate title and text fields.
+ // In the future, the default should be for a combined index.
+ $what = 'title';
+ $matches = $search->searchTitle( $query );
+
+ // Not all search engines support a separate title search,
+ // for instance the Lucene-based engine we use on Wikipedia.
+ // In this case, fall back to full-text search (which will
+ // include titles in it!)
+ if ( is_null( $matches ) ) {
+ $what = 'text';
+ $matches = $search->searchText( $query );
+ }
+ }
+
+ if ( $matches instanceof Status ) {
+ $status = $matches;
+ $matches = $status->getValue();
+ } else {
+ $status = null;
+ }
+
+ if ( $status ) {
+ if ( $status->isOK() ) {
+ $this->getMain()->getErrorFormatter()->addMessagesFromStatus(
+ $this->getModuleName(),
+ $status
+ );
+ } else {
+ $this->dieStatus( $status );
+ }
+ } elseif ( is_null( $matches ) ) {
+ $this->dieWithError( [ 'apierror-searchdisabled', $what ], "search-{$what}-disabled" );
+ }
+
+ if ( $resultPageSet === null ) {
+ $apiResult = $this->getResult();
+ // Add search meta data to result
+ if ( isset( $searchInfo['totalhits'] ) ) {
+ $totalhits = $matches->getTotalHits();
+ if ( $totalhits !== null ) {
+ $apiResult->addValue( [ 'query', 'searchinfo' ],
+ 'totalhits', $totalhits );
+ }
+ }
+ if ( isset( $searchInfo['suggestion'] ) && $matches->hasSuggestion() ) {
+ $apiResult->addValue( [ 'query', 'searchinfo' ],
+ 'suggestion', $matches->getSuggestionQuery() );
+ $apiResult->addValue( [ 'query', 'searchinfo' ],
+ 'suggestionsnippet', $matches->getSuggestionSnippet() );
+ }
+ if ( isset( $searchInfo['rewrittenquery'] ) && $matches->hasRewrittenQuery() ) {
+ $apiResult->addValue( [ 'query', 'searchinfo' ],
+ 'rewrittenquery', $matches->getQueryAfterRewrite() );
+ $apiResult->addValue( [ 'query', 'searchinfo' ],
+ 'rewrittenquerysnippet', $matches->getQueryAfterRewriteSnippet() );
+ }
+ }
+
+ // Add the search results to the result
+ $terms = $wgContLang->convertForSearchResult( $matches->termMatches() );
+ $titles = [];
+ $count = 0;
+ $result = $matches->next();
+ $limit = $params['limit'];
+
+ while ( $result ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional items to be had. Stop here...
+ $this->setContinueEnumParameter( 'offset', $params['offset'] + $params['limit'] );
+ break;
+ }
+
+ // Silently skip broken and missing titles
+ if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
+ $result = $matches->next();
+ continue;
+ }
+
+ if ( $resultPageSet === null ) {
+ $vals = $this->getSearchResultData( $result, $prop, $terms );
+ if ( $vals ) {
+ // Add item to results and see whether it fits
+ $fit = $apiResult->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'offset', $params['offset'] + $count - 1 );
+ break;
+ }
+ }
+ } else {
+ $titles[] = $result->getTitle();
+ }
+
+ $result = $matches->next();
+ }
+
+ // Here we assume interwiki results do not count with
+ // regular search results. We may want to reconsider this
+ // if we ever return a lot of interwiki results or want pagination
+ // for them.
+ // Interwiki results inside main result set
+ $canAddInterwiki = (bool)$params['enablerewrites'] && ( $resultPageSet === null );
+ if ( $canAddInterwiki ) {
+ $this->addInterwikiResults( $matches, $apiResult, $prop, $terms, 'additional',
+ SearchResultSet::INLINE_RESULTS );
+ }
+
+ // Interwiki results outside main result set
+ if ( $interwiki && $resultPageSet === null ) {
+ $this->addInterwikiResults( $matches, $apiResult, $prop, $terms, 'interwiki',
+ SearchResultSet::SECONDARY_RESULTS );
+ }
+
+ if ( $resultPageSet === null ) {
+ $apiResult->addIndexedTagName( [
+ 'query', $this->getModuleName()
+ ], 'p' );
+ } else {
+ $resultPageSet->setRedirectMergePolicy( function ( $current, $new ) {
+ if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {
+ $current['index'] = $new['index'];
+ }
+ return $current;
+ } );
+ $resultPageSet->populateFromTitles( $titles );
+ $offset = $params['offset'] + 1;
+ foreach ( $titles as $index => $title ) {
+ $resultPageSet->setGeneratorData( $title, [ 'index' => $index + $offset ] );
+ }
+ }
+ }
+
+ /**
+ * Assemble search result data.
+ * @param SearchResult $result Search result
+ * @param array $prop Props to extract (as keys)
+ * @param array $terms Terms list
+ * @return array|null Result data or null if result is broken in some way.
+ */
+ private function getSearchResultData( SearchResult $result, $prop, $terms ) {
+ // Silently skip broken and missing titles
+ if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
+ return null;
+ }
+
+ $vals = [];
+
+ $title = $result->getTitle();
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ $vals['pageid'] = $title->getArticleID();
+
+ if ( isset( $prop['size'] ) ) {
+ $vals['size'] = $result->getByteSize();
+ }
+ if ( isset( $prop['wordcount'] ) ) {
+ $vals['wordcount'] = $result->getWordCount();
+ }
+ if ( isset( $prop['snippet'] ) ) {
+ $vals['snippet'] = $result->getTextSnippet( $terms );
+ }
+ if ( isset( $prop['timestamp'] ) ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $result->getTimestamp() );
+ }
+ if ( isset( $prop['titlesnippet'] ) ) {
+ $vals['titlesnippet'] = $result->getTitleSnippet();
+ }
+ if ( isset( $prop['categorysnippet'] ) ) {
+ $vals['categorysnippet'] = $result->getCategorySnippet();
+ }
+ if ( !is_null( $result->getRedirectTitle() ) ) {
+ if ( isset( $prop['redirecttitle'] ) ) {
+ $vals['redirecttitle'] = $result->getRedirectTitle()->getPrefixedText();
+ }
+ if ( isset( $prop['redirectsnippet'] ) ) {
+ $vals['redirectsnippet'] = $result->getRedirectSnippet();
+ }
+ }
+ if ( !is_null( $result->getSectionTitle() ) ) {
+ if ( isset( $prop['sectiontitle'] ) ) {
+ $vals['sectiontitle'] = $result->getSectionTitle()->getFragment();
+ }
+ if ( isset( $prop['sectionsnippet'] ) ) {
+ $vals['sectionsnippet'] = $result->getSectionSnippet();
+ }
+ }
+ if ( isset( $prop['isfilematch'] ) ) {
+ $vals['isfilematch'] = $result->isFileMatch();
+ }
+ return $vals;
+ }
+
+ /**
+ * Add interwiki results as a section in query results.
+ * @param SearchResultSet $matches
+ * @param ApiResult $apiResult
+ * @param array $prop Props to extract (as keys)
+ * @param array $terms Terms list
+ * @param string $section Section name where results would go
+ * @param int $type Interwiki result type
+ * @return int|null Number of total hits in the data or null if none was produced
+ */
+ private function addInterwikiResults(
+ SearchResultSet $matches, ApiResult $apiResult, $prop,
+ $terms, $section, $type
+ ) {
+ $totalhits = null;
+ if ( $matches->hasInterwikiResults( $type ) ) {
+ foreach ( $matches->getInterwikiResults( $type ) as $interwikiMatches ) {
+ // Include number of results if requested
+ $totalhits += $interwikiMatches->getTotalHits();
+
+ $result = $interwikiMatches->next();
+ while ( $result ) {
+ $title = $result->getTitle();
+ $vals = $this->getSearchResultData( $result, $prop, $terms );
+
+ $vals['namespace'] = $result->getInterwikiNamespaceText();
+ $vals['title'] = $title->getText();
+ $vals['url'] = $title->getFullURL();
+
+ // Add item to results and see whether it fits
+ $fit = $apiResult->addValue( [
+ 'query',
+ $section . $this->getModuleName(),
+ $result->getInterwikiPrefix()
+ ], null, $vals );
+
+ if ( !$fit ) {
+ // We hit the limit. We can't really provide any meaningful
+ // pagination info so just bail out
+ break;
+ }
+
+ $result = $interwikiMatches->next();
+ }
+ }
+ if ( $totalhits !== null ) {
+ $apiResult->addValue( [ 'query', $section . 'searchinfo' ], 'totalhits', $totalhits );
+ $apiResult->addIndexedTagName( [
+ 'query', $section . $this->getModuleName()
+ ], 'p' );
+ }
+ }
+ return $totalhits;
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ if ( $this->allowedParams !== null ) {
+ return $this->allowedParams;
+ }
+
+ $this->allowedParams = $this->buildCommonApiParams() + [
+ 'what' => [
+ ApiBase::PARAM_TYPE => [
+ 'title',
+ 'text',
+ 'nearmatch',
+ ]
+ ],
+ 'info' => [
+ ApiBase::PARAM_DFLT => 'totalhits|suggestion|rewrittenquery',
+ ApiBase::PARAM_TYPE => [
+ 'totalhits',
+ 'suggestion',
+ 'rewrittenquery',
+ ],
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'size|wordcount|timestamp|snippet',
+ ApiBase::PARAM_TYPE => [
+ 'size',
+ 'wordcount',
+ 'timestamp',
+ 'snippet',
+ 'titlesnippet',
+ 'redirecttitle',
+ 'redirectsnippet',
+ 'sectiontitle',
+ 'sectionsnippet',
+ 'isfilematch',
+ 'categorysnippet',
+ 'score', // deprecated
+ 'hasrelated', // deprecated
+ ],
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ApiBase::PARAM_DEPRECATED_VALUES => [
+ 'score' => true,
+ 'hasrelated' => true
+ ],
+ ],
+ 'interwiki' => false,
+ 'enablerewrites' => false,
+ ];
+
+ return $this->allowedParams;
+ }
+
+ public function getSearchProfileParams() {
+ return [
+ 'qiprofile' => [
+ 'profile-type' => SearchEngine::FT_QUERY_INDEP_PROFILE_TYPE,
+ 'help-message' => 'apihelp-query+search-param-qiprofile',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=search&srsearch=meaning'
+ => 'apihelp-query+search-example-simple',
+ 'action=query&list=search&srwhat=text&srsearch=meaning'
+ => 'apihelp-query+search-example-text',
+ 'action=query&generator=search&gsrsearch=meaning&prop=info'
+ => 'apihelp-query+search-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Search';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQuerySiteinfo.php b/www/wiki/includes/api/ApiQuerySiteinfo.php
new file mode 100644
index 00000000..6b896c95
--- /dev/null
+++ b/www/wiki/includes/api/ApiQuerySiteinfo.php
@@ -0,0 +1,938 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 25, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * A query action to return meta information about the wiki site.
+ *
+ * @ingroup API
+ */
+class ApiQuerySiteinfo extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'si' );
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $done = [];
+ $fit = false;
+ foreach ( $params['prop'] as $p ) {
+ switch ( $p ) {
+ case 'general':
+ $fit = $this->appendGeneralInfo( $p );
+ break;
+ case 'namespaces':
+ $fit = $this->appendNamespaces( $p );
+ break;
+ case 'namespacealiases':
+ $fit = $this->appendNamespaceAliases( $p );
+ break;
+ case 'specialpagealiases':
+ $fit = $this->appendSpecialPageAliases( $p );
+ break;
+ case 'magicwords':
+ $fit = $this->appendMagicWords( $p );
+ break;
+ case 'interwikimap':
+ $filteriw = isset( $params['filteriw'] ) ? $params['filteriw'] : false;
+ $fit = $this->appendInterwikiMap( $p, $filteriw );
+ break;
+ case 'dbrepllag':
+ $fit = $this->appendDbReplLagInfo( $p, $params['showalldb'] );
+ break;
+ case 'statistics':
+ $fit = $this->appendStatistics( $p );
+ break;
+ case 'usergroups':
+ $fit = $this->appendUserGroups( $p, $params['numberingroup'] );
+ break;
+ case 'libraries':
+ $fit = $this->appendInstalledLibraries( $p );
+ break;
+ case 'extensions':
+ $fit = $this->appendExtensions( $p );
+ break;
+ case 'fileextensions':
+ $fit = $this->appendFileExtensions( $p );
+ break;
+ case 'rightsinfo':
+ $fit = $this->appendRightsInfo( $p );
+ break;
+ case 'restrictions':
+ $fit = $this->appendRestrictions( $p );
+ break;
+ case 'languages':
+ $fit = $this->appendLanguages( $p );
+ break;
+ case 'languagevariants':
+ $fit = $this->appendLanguageVariants( $p );
+ break;
+ case 'skins':
+ $fit = $this->appendSkins( $p );
+ break;
+ case 'extensiontags':
+ $fit = $this->appendExtensionTags( $p );
+ break;
+ case 'functionhooks':
+ $fit = $this->appendFunctionHooks( $p );
+ break;
+ case 'showhooks':
+ $fit = $this->appendSubscribedHooks( $p );
+ break;
+ case 'variables':
+ $fit = $this->appendVariables( $p );
+ break;
+ case 'protocols':
+ $fit = $this->appendProtocols( $p );
+ break;
+ case 'defaultoptions':
+ $fit = $this->appendDefaultOptions( $p );
+ break;
+ case 'uploaddialog':
+ $fit = $this->appendUploadDialog( $p );
+ break;
+ default:
+ ApiBase::dieDebug( __METHOD__, "Unknown prop=$p" );
+ }
+ if ( !$fit ) {
+ // Abuse siprop as a query-continue parameter
+ // and set it to all unprocessed props
+ $this->setContinueEnumParameter( 'prop', implode( '|',
+ array_diff( $params['prop'], $done ) ) );
+ break;
+ }
+ $done[] = $p;
+ }
+ }
+
+ protected function appendGeneralInfo( $property ) {
+ global $wgContLang;
+
+ $config = $this->getConfig();
+
+ $data = [];
+ $mainPage = Title::newMainPage();
+ $data['mainpage'] = $mainPage->getPrefixedText();
+ $data['base'] = wfExpandUrl( $mainPage->getFullURL(), PROTO_CURRENT );
+ $data['sitename'] = $config->get( 'Sitename' );
+
+ // wgLogo can either be a relative or an absolute path
+ // make sure we always return an absolute path
+ $data['logo'] = wfExpandUrl( $config->get( 'Logo' ), PROTO_RELATIVE );
+
+ $data['generator'] = "MediaWiki {$config->get( 'Version' )}";
+
+ $data['phpversion'] = PHP_VERSION;
+ $data['phpsapi'] = PHP_SAPI;
+ if ( defined( 'HHVM_VERSION' ) ) {
+ $data['hhvmversion'] = HHVM_VERSION;
+ }
+ $data['dbtype'] = $config->get( 'DBtype' );
+ $data['dbversion'] = $this->getDB()->getServerVersion();
+
+ $allowFrom = [ '' ];
+ $allowException = true;
+ if ( !$config->get( 'AllowExternalImages' ) ) {
+ $data['imagewhitelistenabled'] = (bool)$config->get( 'EnableImageWhitelist' );
+ $allowFrom = $config->get( 'AllowExternalImagesFrom' );
+ $allowException = !empty( $allowFrom );
+ }
+ if ( $allowException ) {
+ $data['externalimages'] = (array)$allowFrom;
+ ApiResult::setIndexedTagName( $data['externalimages'], 'prefix' );
+ }
+
+ $data['langconversion'] = !$config->get( 'DisableLangConversion' );
+ $data['titleconversion'] = !$config->get( 'DisableTitleConversion' );
+
+ if ( $wgContLang->linkPrefixExtension() ) {
+ $linkPrefixCharset = $wgContLang->linkPrefixCharset();
+ $data['linkprefixcharset'] = $linkPrefixCharset;
+ // For backwards compatibility
+ $data['linkprefix'] = "/^((?>.*[^$linkPrefixCharset]|))(.+)$/sDu";
+ } else {
+ $data['linkprefixcharset'] = '';
+ $data['linkprefix'] = '';
+ }
+
+ $linktrail = $wgContLang->linkTrail();
+ $data['linktrail'] = $linktrail ?: '';
+
+ $data['legaltitlechars'] = Title::legalChars();
+ $data['invalidusernamechars'] = $config->get( 'InvalidUsernameCharacters' );
+
+ $data['allunicodefixes'] = (bool)$config->get( 'AllUnicodeFixes' );
+ $data['fixarabicunicode'] = (bool)$config->get( 'FixArabicUnicode' );
+ $data['fixmalayalamunicode'] = (bool)$config->get( 'FixMalayalamUnicode' );
+
+ global $IP;
+ $git = SpecialVersion::getGitHeadSha1( $IP );
+ if ( $git ) {
+ $data['git-hash'] = $git;
+ $data['git-branch'] =
+ SpecialVersion::getGitCurrentBranch( $GLOBALS['IP'] );
+ }
+
+ // 'case-insensitive' option is reserved for future
+ $data['case'] = $config->get( 'CapitalLinks' ) ? 'first-letter' : 'case-sensitive';
+ $data['lang'] = $config->get( 'LanguageCode' );
+
+ $fallbacks = [];
+ foreach ( $wgContLang->getFallbackLanguages() as $code ) {
+ $fallbacks[] = [ 'code' => $code ];
+ }
+ $data['fallback'] = $fallbacks;
+ ApiResult::setIndexedTagName( $data['fallback'], 'lang' );
+
+ if ( $wgContLang->hasVariants() ) {
+ $variants = [];
+ foreach ( $wgContLang->getVariants() as $code ) {
+ $variants[] = [
+ 'code' => $code,
+ 'name' => $wgContLang->getVariantname( $code ),
+ ];
+ }
+ $data['variants'] = $variants;
+ ApiResult::setIndexedTagName( $data['variants'], 'lang' );
+ }
+
+ $data['rtl'] = $wgContLang->isRTL();
+ $data['fallback8bitEncoding'] = $wgContLang->fallback8bitEncoding();
+
+ $data['readonly'] = wfReadOnly();
+ if ( $data['readonly'] ) {
+ $data['readonlyreason'] = wfReadOnlyReason();
+ }
+ $data['writeapi'] = (bool)$config->get( 'EnableWriteAPI' );
+
+ $data['maxarticlesize'] = $config->get( 'MaxArticleSize' ) * 1024;
+
+ $tz = $config->get( 'Localtimezone' );
+ $offset = $config->get( 'LocalTZoffset' );
+ if ( is_null( $tz ) ) {
+ $tz = 'UTC';
+ $offset = 0;
+ } elseif ( is_null( $offset ) ) {
+ $offset = 0;
+ }
+ $data['timezone'] = $tz;
+ $data['timeoffset'] = intval( $offset );
+ $data['articlepath'] = $config->get( 'ArticlePath' );
+ $data['scriptpath'] = $config->get( 'ScriptPath' );
+ $data['script'] = $config->get( 'Script' );
+ $data['variantarticlepath'] = $config->get( 'VariantArticlePath' );
+ $data[ApiResult::META_BC_BOOLS][] = 'variantarticlepath';
+ $data['server'] = $config->get( 'Server' );
+ $data['servername'] = $config->get( 'ServerName' );
+ $data['wikiid'] = wfWikiID();
+ $data['time'] = wfTimestamp( TS_ISO_8601, time() );
+
+ $data['misermode'] = (bool)$config->get( 'MiserMode' );
+
+ $data['uploadsenabled'] = UploadBase::isEnabled();
+ $data['maxuploadsize'] = UploadBase::getMaxUploadSize();
+ $data['minuploadchunksize'] = (int)$config->get( 'MinUploadChunkSize' );
+
+ $data['galleryoptions'] = $config->get( 'GalleryOptions' );
+
+ $data['thumblimits'] = $config->get( 'ThumbLimits' );
+ ApiResult::setArrayType( $data['thumblimits'], 'BCassoc' );
+ ApiResult::setIndexedTagName( $data['thumblimits'], 'limit' );
+ $data['imagelimits'] = [];
+ ApiResult::setArrayType( $data['imagelimits'], 'BCassoc' );
+ ApiResult::setIndexedTagName( $data['imagelimits'], 'limit' );
+ foreach ( $config->get( 'ImageLimits' ) as $k => $limit ) {
+ $data['imagelimits'][$k] = [ 'width' => $limit[0], 'height' => $limit[1] ];
+ }
+
+ $favicon = $config->get( 'Favicon' );
+ if ( !empty( $favicon ) ) {
+ // wgFavicon can either be a relative or an absolute path
+ // make sure we always return an absolute path
+ $data['favicon'] = wfExpandUrl( $favicon, PROTO_RELATIVE );
+ }
+
+ $data['centralidlookupprovider'] = $config->get( 'CentralIdLookupProvider' );
+ $providerIds = array_keys( $config->get( 'CentralIdLookupProviders' ) );
+ $data['allcentralidlookupproviders'] = $providerIds;
+
+ $data['interwikimagic'] = (bool)$config->get( 'InterwikiMagic' );
+ $data['magiclinks'] = $config->get( 'EnableMagicLinks' );
+
+ Hooks::run( 'APIQuerySiteInfoGeneralInfo', [ $this, &$data ] );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendNamespaces( $property ) {
+ global $wgContLang;
+ $data = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ foreach ( $wgContLang->getFormattedNamespaces() as $ns => $title ) {
+ $data[$ns] = [
+ 'id' => intval( $ns ),
+ 'case' => MWNamespace::isCapitalized( $ns ) ? 'first-letter' : 'case-sensitive',
+ ];
+ ApiResult::setContentValue( $data[$ns], 'name', $title );
+ $canonical = MWNamespace::getCanonicalName( $ns );
+
+ $data[$ns]['subpages'] = MWNamespace::hasSubpages( $ns );
+
+ if ( $canonical ) {
+ $data[$ns]['canonical'] = strtr( $canonical, '_', ' ' );
+ }
+
+ $data[$ns]['content'] = MWNamespace::isContent( $ns );
+ $data[$ns]['nonincludable'] = MWNamespace::isNonincludable( $ns );
+
+ $contentmodel = MWNamespace::getNamespaceContentModel( $ns );
+ if ( $contentmodel ) {
+ $data[$ns]['defaultcontentmodel'] = $contentmodel;
+ }
+ }
+
+ ApiResult::setArrayType( $data, 'assoc' );
+ ApiResult::setIndexedTagName( $data, 'ns' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendNamespaceAliases( $property ) {
+ global $wgContLang;
+ $aliases = array_merge( $this->getConfig()->get( 'NamespaceAliases' ),
+ $wgContLang->getNamespaceAliases() );
+ $namespaces = $wgContLang->getNamespaces();
+ $data = [];
+ foreach ( $aliases as $title => $ns ) {
+ if ( $namespaces[$ns] == $title ) {
+ // Don't list duplicates
+ continue;
+ }
+ $item = [
+ 'id' => intval( $ns )
+ ];
+ ApiResult::setContentValue( $item, 'alias', strtr( $title, '_', ' ' ) );
+ $data[] = $item;
+ }
+
+ sort( $data );
+
+ ApiResult::setIndexedTagName( $data, 'ns' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendSpecialPageAliases( $property ) {
+ global $wgContLang;
+ $data = [];
+ $aliases = $wgContLang->getSpecialPageAliases();
+ foreach ( SpecialPageFactory::getNames() as $specialpage ) {
+ if ( isset( $aliases[$specialpage] ) ) {
+ $arr = [ 'realname' => $specialpage, 'aliases' => $aliases[$specialpage] ];
+ ApiResult::setIndexedTagName( $arr['aliases'], 'alias' );
+ $data[] = $arr;
+ }
+ }
+ ApiResult::setIndexedTagName( $data, 'specialpage' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendMagicWords( $property ) {
+ global $wgContLang;
+ $data = [];
+ foreach ( $wgContLang->getMagicWords() as $magicword => $aliases ) {
+ $caseSensitive = array_shift( $aliases );
+ $arr = [ 'name' => $magicword, 'aliases' => $aliases ];
+ $arr['case-sensitive'] = (bool)$caseSensitive;
+ ApiResult::setIndexedTagName( $arr['aliases'], 'alias' );
+ $data[] = $arr;
+ }
+ ApiResult::setIndexedTagName( $data, 'magicword' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendInterwikiMap( $property, $filter ) {
+ $local = null;
+ if ( $filter === 'local' ) {
+ $local = 1;
+ } elseif ( $filter === '!local' ) {
+ $local = 0;
+ } elseif ( $filter ) {
+ ApiBase::dieDebug( __METHOD__, "Unknown filter=$filter" );
+ }
+
+ $params = $this->extractRequestParams();
+ $langCode = isset( $params['inlanguagecode'] ) ? $params['inlanguagecode'] : '';
+ $langNames = Language::fetchLanguageNames( $langCode );
+
+ $getPrefixes = MediaWikiServices::getInstance()->getInterwikiLookup()->getAllPrefixes( $local );
+ $extraLangPrefixes = $this->getConfig()->get( 'ExtraInterlanguageLinkPrefixes' );
+ $localInterwikis = $this->getConfig()->get( 'LocalInterwikis' );
+ $data = [];
+
+ foreach ( $getPrefixes as $row ) {
+ $prefix = $row['iw_prefix'];
+ $val = [];
+ $val['prefix'] = $prefix;
+ if ( isset( $row['iw_local'] ) && $row['iw_local'] == '1' ) {
+ $val['local'] = true;
+ }
+ if ( isset( $row['iw_trans'] ) && $row['iw_trans'] == '1' ) {
+ $val['trans'] = true;
+ }
+
+ if ( isset( $langNames[$prefix] ) ) {
+ $val['language'] = $langNames[$prefix];
+ }
+ if ( in_array( $prefix, $localInterwikis ) ) {
+ $val['localinterwiki'] = true;
+ }
+ if ( in_array( $prefix, $extraLangPrefixes ) ) {
+ $val['extralanglink'] = true;
+
+ $linktext = wfMessage( "interlanguage-link-$prefix" );
+ if ( !$linktext->isDisabled() ) {
+ $val['linktext'] = $linktext->text();
+ }
+
+ $sitename = wfMessage( "interlanguage-link-sitename-$prefix" );
+ if ( !$sitename->isDisabled() ) {
+ $val['sitename'] = $sitename->text();
+ }
+ }
+
+ $val['url'] = wfExpandUrl( $row['iw_url'], PROTO_CURRENT );
+ $val['protorel'] = substr( $row['iw_url'], 0, 2 ) == '//';
+ if ( isset( $row['iw_wikiid'] ) && $row['iw_wikiid'] !== '' ) {
+ $val['wikiid'] = $row['iw_wikiid'];
+ }
+ if ( isset( $row['iw_api'] ) && $row['iw_api'] !== '' ) {
+ $val['api'] = $row['iw_api'];
+ }
+
+ $data[] = $val;
+ }
+
+ ApiResult::setIndexedTagName( $data, 'iw' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendDbReplLagInfo( $property, $includeAll ) {
+ $data = [];
+ $lb = wfGetLB();
+ $showHostnames = $this->getConfig()->get( 'ShowHostnames' );
+ if ( $includeAll ) {
+ if ( !$showHostnames ) {
+ $this->dieWithError( 'apierror-siteinfo-includealldenied', 'includeAllDenied' );
+ }
+
+ $lags = $lb->getLagTimes();
+ foreach ( $lags as $i => $lag ) {
+ $data[] = [
+ 'host' => $lb->getServerName( $i ),
+ 'lag' => $lag
+ ];
+ }
+ } else {
+ list( , $lag, $index ) = $lb->getMaxLag();
+ $data[] = [
+ 'host' => $showHostnames
+ ? $lb->getServerName( $index )
+ : '',
+ 'lag' => intval( $lag )
+ ];
+ }
+
+ ApiResult::setIndexedTagName( $data, 'db' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendStatistics( $property ) {
+ $data = [];
+ $data['pages'] = intval( SiteStats::pages() );
+ $data['articles'] = intval( SiteStats::articles() );
+ $data['edits'] = intval( SiteStats::edits() );
+ $data['images'] = intval( SiteStats::images() );
+ $data['users'] = intval( SiteStats::users() );
+ $data['activeusers'] = intval( SiteStats::activeUsers() );
+ $data['admins'] = intval( SiteStats::numberingroup( 'sysop' ) );
+ $data['jobs'] = intval( SiteStats::jobs() );
+
+ Hooks::run( 'APIQuerySiteInfoStatisticsInfo', [ &$data ] );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendUserGroups( $property, $numberInGroup ) {
+ $config = $this->getConfig();
+
+ $data = [];
+ $result = $this->getResult();
+ $allGroups = array_values( User::getAllGroups() );
+ foreach ( $config->get( 'GroupPermissions' ) as $group => $permissions ) {
+ $arr = [
+ 'name' => $group,
+ 'rights' => array_keys( $permissions, true ),
+ ];
+
+ if ( $numberInGroup ) {
+ $autopromote = $config->get( 'Autopromote' );
+
+ if ( $group == 'user' ) {
+ $arr['number'] = SiteStats::users();
+ // '*' and autopromote groups have no size
+ } elseif ( $group !== '*' && !isset( $autopromote[$group] ) ) {
+ $arr['number'] = SiteStats::numberingroup( $group );
+ }
+ }
+
+ $groupArr = [
+ 'add' => $config->get( 'AddGroups' ),
+ 'remove' => $config->get( 'RemoveGroups' ),
+ 'add-self' => $config->get( 'GroupsAddToSelf' ),
+ 'remove-self' => $config->get( 'GroupsRemoveFromSelf' )
+ ];
+
+ foreach ( $groupArr as $type => $rights ) {
+ if ( isset( $rights[$group] ) ) {
+ if ( $rights[$group] === true ) {
+ $groups = $allGroups;
+ } else {
+ $groups = array_intersect( $rights[$group], $allGroups );
+ }
+ if ( $groups ) {
+ $arr[$type] = $groups;
+ ApiResult::setArrayType( $arr[$type], 'BCarray' );
+ ApiResult::setIndexedTagName( $arr[$type], 'group' );
+ }
+ }
+ }
+
+ ApiResult::setIndexedTagName( $arr['rights'], 'permission' );
+ $data[] = $arr;
+ }
+
+ ApiResult::setIndexedTagName( $data, 'group' );
+
+ return $result->addValue( 'query', $property, $data );
+ }
+
+ protected function appendFileExtensions( $property ) {
+ $data = [];
+ foreach ( array_unique( $this->getConfig()->get( 'FileExtensions' ) ) as $ext ) {
+ $data[] = [ 'ext' => $ext ];
+ }
+ ApiResult::setIndexedTagName( $data, 'fe' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendInstalledLibraries( $property ) {
+ global $IP;
+ $path = "$IP/vendor/composer/installed.json";
+ if ( !file_exists( $path ) ) {
+ return true;
+ }
+
+ $data = [];
+ $installed = new ComposerInstalled( $path );
+ foreach ( $installed->getInstalledDependencies() as $name => $info ) {
+ if ( strpos( $info['type'], 'mediawiki-' ) === 0 ) {
+ // Skip any extensions or skins since they'll be listed
+ // in their proper section
+ continue;
+ }
+ $data[] = [
+ 'name' => $name,
+ 'version' => $info['version'],
+ ];
+ }
+ ApiResult::setIndexedTagName( $data, 'library' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendExtensions( $property ) {
+ $data = [];
+ foreach ( $this->getConfig()->get( 'ExtensionCredits' ) as $type => $extensions ) {
+ foreach ( $extensions as $ext ) {
+ $ret = [];
+ $ret['type'] = $type;
+ if ( isset( $ext['name'] ) ) {
+ $ret['name'] = $ext['name'];
+ }
+ if ( isset( $ext['namemsg'] ) ) {
+ $ret['namemsg'] = $ext['namemsg'];
+ }
+ if ( isset( $ext['description'] ) ) {
+ $ret['description'] = $ext['description'];
+ }
+ if ( isset( $ext['descriptionmsg'] ) ) {
+ // Can be a string or [ key, param1, param2, ... ]
+ if ( is_array( $ext['descriptionmsg'] ) ) {
+ $ret['descriptionmsg'] = $ext['descriptionmsg'][0];
+ $ret['descriptionmsgparams'] = array_slice( $ext['descriptionmsg'], 1 );
+ ApiResult::setIndexedTagName( $ret['descriptionmsgparams'], 'param' );
+ } else {
+ $ret['descriptionmsg'] = $ext['descriptionmsg'];
+ }
+ }
+ if ( isset( $ext['author'] ) ) {
+ $ret['author'] = is_array( $ext['author'] ) ?
+ implode( ', ', $ext['author'] ) : $ext['author'];
+ }
+ if ( isset( $ext['url'] ) ) {
+ $ret['url'] = $ext['url'];
+ }
+ if ( isset( $ext['version'] ) ) {
+ $ret['version'] = $ext['version'];
+ }
+ if ( isset( $ext['path'] ) ) {
+ $extensionPath = dirname( $ext['path'] );
+ $gitInfo = new GitInfo( $extensionPath );
+ $vcsVersion = $gitInfo->getHeadSHA1();
+ if ( $vcsVersion !== false ) {
+ $ret['vcs-system'] = 'git';
+ $ret['vcs-version'] = $vcsVersion;
+ $ret['vcs-url'] = $gitInfo->getHeadViewUrl();
+ $vcsDate = $gitInfo->getHeadCommitDate();
+ if ( $vcsDate !== false ) {
+ $ret['vcs-date'] = wfTimestamp( TS_ISO_8601, $vcsDate );
+ }
+ }
+
+ if ( SpecialVersion::getExtLicenseFileName( $extensionPath ) ) {
+ $ret['license-name'] = isset( $ext['license-name'] ) ? $ext['license-name'] : '';
+ $ret['license'] = SpecialPage::getTitleFor(
+ 'Version',
+ "License/{$ext['name']}"
+ )->getLinkURL();
+ }
+
+ if ( SpecialVersion::getExtAuthorsFileName( $extensionPath ) ) {
+ $ret['credits'] = SpecialPage::getTitleFor(
+ 'Version',
+ "Credits/{$ext['name']}"
+ )->getLinkURL();
+ }
+ }
+ $data[] = $ret;
+ }
+ }
+
+ ApiResult::setIndexedTagName( $data, 'ext' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendRightsInfo( $property ) {
+ $config = $this->getConfig();
+ $rightsPage = $config->get( 'RightsPage' );
+ if ( is_string( $rightsPage ) ) {
+ $title = Title::newFromText( $rightsPage );
+ $url = wfExpandUrl( $title, PROTO_CURRENT );
+ } else {
+ $title = false;
+ $url = $config->get( 'RightsUrl' );
+ }
+ $text = $config->get( 'RightsText' );
+ if ( !$text && $title ) {
+ $text = $title->getPrefixedText();
+ }
+
+ $data = [
+ 'url' => $url ?: '',
+ 'text' => $text ?: ''
+ ];
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ protected function appendRestrictions( $property ) {
+ $config = $this->getConfig();
+ $data = [
+ 'types' => $config->get( 'RestrictionTypes' ),
+ 'levels' => $config->get( 'RestrictionLevels' ),
+ 'cascadinglevels' => $config->get( 'CascadingRestrictionLevels' ),
+ 'semiprotectedlevels' => $config->get( 'SemiprotectedRestrictionLevels' ),
+ ];
+
+ ApiResult::setArrayType( $data['types'], 'BCarray' );
+ ApiResult::setArrayType( $data['levels'], 'BCarray' );
+ ApiResult::setArrayType( $data['cascadinglevels'], 'BCarray' );
+ ApiResult::setArrayType( $data['semiprotectedlevels'], 'BCarray' );
+
+ ApiResult::setIndexedTagName( $data['types'], 'type' );
+ ApiResult::setIndexedTagName( $data['levels'], 'level' );
+ ApiResult::setIndexedTagName( $data['cascadinglevels'], 'level' );
+ ApiResult::setIndexedTagName( $data['semiprotectedlevels'], 'level' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ public function appendLanguages( $property ) {
+ $params = $this->extractRequestParams();
+ $langCode = isset( $params['inlanguagecode'] ) ? $params['inlanguagecode'] : '';
+ $langNames = Language::fetchLanguageNames( $langCode );
+
+ $data = [];
+
+ foreach ( $langNames as $code => $name ) {
+ $lang = [ 'code' => $code ];
+ ApiResult::setContentValue( $lang, 'name', $name );
+ $data[] = $lang;
+ }
+ ApiResult::setIndexedTagName( $data, 'lang' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ // Export information about which page languages will trigger
+ // language conversion. (T153341)
+ public function appendLanguageVariants( $property ) {
+ $langNames = LanguageConverter::$languagesWithVariants;
+ if ( $this->getConfig()->get( 'DisableLangConversion' ) ) {
+ // Ensure result is empty if language conversion is disabled.
+ $langNames = [];
+ }
+ sort( $langNames );
+
+ $data = [];
+ foreach ( $langNames as $langCode ) {
+ $lang = Language::factory( $langCode );
+ if ( $lang->getConverter() instanceof FakeConverter ) {
+ // Only languages which do not return instances of
+ // FakeConverter implement language conversion.
+ continue;
+ }
+ $data[$langCode] = [];
+ ApiResult::setIndexedTagName( $data[$langCode], 'variant' );
+ ApiResult::setArrayType( $data[$langCode], 'kvp', 'code' );
+
+ $variants = $lang->getVariants();
+ sort( $variants );
+ foreach ( $variants as $v ) {
+ $fallbacks = $lang->getConverter()->getVariantFallbacks( $v );
+ if ( !is_array( $fallbacks ) ) {
+ $fallbacks = [ $fallbacks ];
+ }
+ $data[$langCode][$v] = [
+ 'fallbacks' => $fallbacks,
+ ];
+ ApiResult::setIndexedTagName(
+ $data[$langCode][$v]['fallbacks'], 'variant'
+ );
+ }
+ }
+ ApiResult::setIndexedTagName( $data, 'lang' );
+ ApiResult::setArrayType( $data, 'kvp', 'code' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ public function appendSkins( $property ) {
+ $data = [];
+ $allowed = Skin::getAllowedSkins();
+ $default = Skin::normalizeKey( 'default' );
+ foreach ( Skin::getSkinNames() as $name => $displayName ) {
+ $msg = $this->msg( "skinname-{$name}" );
+ $code = $this->getParameter( 'inlanguagecode' );
+ if ( $code && Language::isValidCode( $code ) ) {
+ $msg->inLanguage( $code );
+ } else {
+ $msg->inContentLanguage();
+ }
+ if ( $msg->exists() ) {
+ $displayName = $msg->text();
+ }
+ $skin = [ 'code' => $name ];
+ ApiResult::setContentValue( $skin, 'name', $displayName );
+ if ( !isset( $allowed[$name] ) ) {
+ $skin['unusable'] = true;
+ }
+ if ( $name === $default ) {
+ $skin['default'] = true;
+ }
+ $data[] = $skin;
+ }
+ ApiResult::setIndexedTagName( $data, 'skin' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ public function appendExtensionTags( $property ) {
+ global $wgParser;
+ $wgParser->firstCallInit();
+ $tags = array_map( [ $this, 'formatParserTags' ], $wgParser->getTags() );
+ ApiResult::setArrayType( $tags, 'BCarray' );
+ ApiResult::setIndexedTagName( $tags, 't' );
+
+ return $this->getResult()->addValue( 'query', $property, $tags );
+ }
+
+ public function appendFunctionHooks( $property ) {
+ global $wgParser;
+ $wgParser->firstCallInit();
+ $hooks = $wgParser->getFunctionHooks();
+ ApiResult::setArrayType( $hooks, 'BCarray' );
+ ApiResult::setIndexedTagName( $hooks, 'h' );
+
+ return $this->getResult()->addValue( 'query', $property, $hooks );
+ }
+
+ public function appendVariables( $property ) {
+ $variables = MagicWord::getVariableIDs();
+ ApiResult::setArrayType( $variables, 'BCarray' );
+ ApiResult::setIndexedTagName( $variables, 'v' );
+
+ return $this->getResult()->addValue( 'query', $property, $variables );
+ }
+
+ public function appendProtocols( $property ) {
+ // Make a copy of the global so we don't try to set the _element key of it - T47130
+ $protocols = array_values( $this->getConfig()->get( 'UrlProtocols' ) );
+ ApiResult::setArrayType( $protocols, 'BCarray' );
+ ApiResult::setIndexedTagName( $protocols, 'p' );
+
+ return $this->getResult()->addValue( 'query', $property, $protocols );
+ }
+
+ public function appendDefaultOptions( $property ) {
+ $options = User::getDefaultOptions();
+ $options[ApiResult::META_BC_BOOLS] = array_keys( $options );
+ return $this->getResult()->addValue( 'query', $property, $options );
+ }
+
+ public function appendUploadDialog( $property ) {
+ $config = $this->getConfig()->get( 'UploadDialog' );
+ return $this->getResult()->addValue( 'query', $property, $config );
+ }
+
+ private function formatParserTags( $item ) {
+ return "<{$item}>";
+ }
+
+ public function appendSubscribedHooks( $property ) {
+ $hooks = $this->getConfig()->get( 'Hooks' );
+ $myWgHooks = $hooks;
+ ksort( $myWgHooks );
+
+ $data = [];
+ foreach ( $myWgHooks as $name => $subscribers ) {
+ $arr = [
+ 'name' => $name,
+ 'subscribers' => array_map( [ 'SpecialVersion', 'arrayToString' ], $subscribers ),
+ ];
+
+ ApiResult::setArrayType( $arr['subscribers'], 'array' );
+ ApiResult::setIndexedTagName( $arr['subscribers'], 's' );
+ $data[] = $arr;
+ }
+
+ ApiResult::setIndexedTagName( $data, 'hook' );
+
+ return $this->getResult()->addValue( 'query', $property, $data );
+ }
+
+ public function getCacheMode( $params ) {
+ // Messages for $wgExtraInterlanguageLinkPrefixes depend on user language
+ if (
+ count( $this->getConfig()->get( 'ExtraInterlanguageLinkPrefixes' ) ) &&
+ !is_null( $params['prop'] ) &&
+ in_array( 'interwikimap', $params['prop'] )
+ ) {
+ return 'anon-public-user-private';
+ }
+
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'general',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'general',
+ 'namespaces',
+ 'namespacealiases',
+ 'specialpagealiases',
+ 'magicwords',
+ 'interwikimap',
+ 'dbrepllag',
+ 'statistics',
+ 'usergroups',
+ 'libraries',
+ 'extensions',
+ 'fileextensions',
+ 'rightsinfo',
+ 'restrictions',
+ 'languages',
+ 'languagevariants',
+ 'skins',
+ 'extensiontags',
+ 'functionhooks',
+ 'showhooks',
+ 'variables',
+ 'protocols',
+ 'defaultoptions',
+ 'uploaddialog',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'filteriw' => [
+ ApiBase::PARAM_TYPE => [
+ 'local',
+ '!local',
+ ]
+ ],
+ 'showalldb' => false,
+ 'numberingroup' => false,
+ 'inlanguagecode' => null,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&meta=siteinfo&siprop=general|namespaces|namespacealiases|statistics'
+ => 'apihelp-query+siteinfo-example-simple',
+ 'action=query&meta=siteinfo&siprop=interwikimap&sifilteriw=local'
+ => 'apihelp-query+siteinfo-example-interwiki',
+ 'action=query&meta=siteinfo&siprop=dbrepllag&sishowalldb='
+ => 'apihelp-query+siteinfo-example-replag',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Siteinfo';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryStashImageInfo.php b/www/wiki/includes/api/ApiQueryStashImageInfo.php
new file mode 100644
index 00000000..1924ca03
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryStashImageInfo.php
@@ -0,0 +1,128 @@
+<?php
+/**
+ * API for MediaWiki 1.16+
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A query action to get image information from temporarily stashed files.
+ *
+ * @ingroup API
+ */
+class ApiQueryStashImageInfo extends ApiQueryImageInfo {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'sii' );
+ }
+
+ public function execute() {
+ if ( !$this->getUser()->isLoggedIn() ) {
+ $this->dieWithError( 'apierror-mustbeloggedin-uploadstash', 'notloggedin' );
+ }
+
+ $params = $this->extractRequestParams();
+ $modulePrefix = $this->getModulePrefix();
+
+ $prop = array_flip( $params['prop'] );
+
+ $scale = $this->getScale( $params );
+
+ $result = $this->getResult();
+
+ $this->requireAtLeastOneParameter( $params, 'filekey', 'sessionkey' );
+
+ // Alias sessionkey to filekey, but give an existing filekey precedence.
+ if ( !$params['filekey'] && $params['sessionkey'] ) {
+ $params['filekey'] = $params['sessionkey'];
+ }
+
+ try {
+ $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $this->getUser() );
+
+ foreach ( $params['filekey'] as $filekey ) {
+ $file = $stash->getFile( $filekey );
+ $finalThumbParam = $this->mergeThumbParams( $file, $scale, $params['urlparam'] );
+ $imageInfo = ApiQueryImageInfo::getInfo( $file, $prop, $result, $finalThumbParam );
+ $result->addValue( [ 'query', $this->getModuleName() ], null, $imageInfo );
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], $modulePrefix );
+ }
+ // @todo Update exception handling here to understand current getFile exceptions
+ } catch ( UploadStashFileNotFoundException $e ) {
+ $this->dieWithException( $e, [ 'wrap' => 'apierror-stashedfilenotfound' ] );
+ } catch ( UploadStashBadPathException $e ) {
+ $this->dieWithException( $e, [ 'wrap' => 'apierror-stashpathinvalid' ] );
+ }
+ }
+
+ private $propertyFilter = [
+ 'user', 'userid', 'comment', 'parsedcomment',
+ 'mediatype', 'archivename', 'uploadwarning',
+ ];
+
+ public function getAllowedParams() {
+ return [
+ 'filekey' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'sessionkey' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'timestamp|url',
+ ApiBase::PARAM_TYPE => self::getPropertyNames( $this->propertyFilter ),
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+imageinfo-param-prop',
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => self::getPropertyMessages( $this->propertyFilter )
+ ],
+ 'urlwidth' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_DFLT => -1,
+ ApiBase::PARAM_HELP_MSG => [
+ 'apihelp-query+imageinfo-param-urlwidth',
+ ApiQueryImageInfo::TRANSFORM_LIMIT,
+ ],
+ ],
+ 'urlheight' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_DFLT => -1,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+imageinfo-param-urlheight',
+ ],
+ 'urlparam' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_DFLT => '',
+ ApiBase::PARAM_HELP_MSG => 'apihelp-query+imageinfo-param-urlparam',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&prop=stashimageinfo&siifilekey=124sd34rsdf567'
+ => 'apihelp-query+stashimageinfo-example-simple',
+ 'action=query&prop=stashimageinfo&siifilekey=b34edoe3|bceffd4&' .
+ 'siiurlwidth=120&siiprop=url'
+ => 'apihelp-query+stashimageinfo-example-params',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Stashimageinfo';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryTags.php b/www/wiki/includes/api/ApiQueryTags.php
new file mode 100644
index 00000000..1b154fae
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryTags.php
@@ -0,0 +1,170 @@
+<?php
+/**
+ *
+ *
+ * Created on Jul 9, 2009
+ *
+ * Copyright © 2009
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to enumerate change tags.
+ *
+ * @ingroup API
+ */
+class ApiQueryTags extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'tg' );
+ }
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ $prop = array_flip( $params['prop'] );
+
+ $fld_displayname = isset( $prop['displayname'] );
+ $fld_description = isset( $prop['description'] );
+ $fld_hitcount = isset( $prop['hitcount'] );
+ $fld_defined = isset( $prop['defined'] );
+ $fld_source = isset( $prop['source'] );
+ $fld_active = isset( $prop['active'] );
+
+ $limit = $params['limit'];
+ $result = $this->getResult();
+
+ $softwareDefinedTags = array_fill_keys( ChangeTags::listSoftwareDefinedTags(), 0 );
+ $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
+ $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 );
+ $tagStats = ChangeTags::tagUsageStatistics();
+
+ $tagHitcounts = array_merge( $softwareDefinedTags, $explicitlyDefinedTags, $tagStats );
+ $tags = array_keys( $tagHitcounts );
+
+ # Fetch defined tags that aren't past the continuation
+ if ( $params['continue'] !== null ) {
+ $cont = $params['continue'];
+ $tags = array_filter( $tags, function ( $v ) use ( $cont ) {
+ return $v >= $cont;
+ } );
+ }
+
+ # Now make sure the array is sorted for proper continuation
+ sort( $tags );
+
+ $count = 0;
+ foreach ( $tags as $tagName ) {
+ if ( ++$count > $limit ) {
+ $this->setContinueEnumParameter( 'continue', $tagName );
+ break;
+ }
+
+ $tag = [];
+ $tag['name'] = $tagName;
+
+ if ( $fld_displayname ) {
+ $tag['displayname'] = ChangeTags::tagDescription( $tagName, $this );
+ }
+
+ if ( $fld_description ) {
+ $msg = $this->msg( "tag-$tagName-description" );
+ $tag['description'] = $msg->exists() ? $msg->text() : '';
+ }
+
+ if ( $fld_hitcount ) {
+ $tag['hitcount'] = intval( $tagHitcounts[$tagName] );
+ }
+
+ $isSoftware = isset( $softwareDefinedTags[$tagName] );
+ $isExplicit = isset( $explicitlyDefinedTags[$tagName] );
+
+ if ( $fld_defined ) {
+ $tag['defined'] = $isSoftware || $isExplicit;
+ }
+
+ if ( $fld_source ) {
+ $tag['source'] = [];
+ if ( $isSoftware ) {
+ // TODO: Can we change this to 'software'?
+ $tag['source'][] = 'extension';
+ }
+ if ( $isExplicit ) {
+ $tag['source'][] = 'manual';
+ }
+ }
+
+ if ( $fld_active ) {
+ $tag['active'] = $isExplicit || isset( $softwareActivatedTags[$tagName] );
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $tag );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $tagName );
+ break;
+ }
+ }
+
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'tag' );
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'prop' => [
+ ApiBase::PARAM_DFLT => 'name',
+ ApiBase::PARAM_TYPE => [
+ 'name',
+ 'displayname',
+ 'description',
+ 'hitcount',
+ 'defined',
+ 'source',
+ 'active',
+ ],
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ]
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=tags&tgprop=displayname|description|hitcount|defined'
+ => 'apihelp-query+tags-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Tags';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryTokens.php b/www/wiki/includes/api/ApiQueryTokens.php
new file mode 100644
index 00000000..0e46fd05
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryTokens.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Module to fetch tokens via action=query&meta=tokens
+ *
+ * Created on August 8, 2014
+ *
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.24
+ */
+
+/**
+ * Module to fetch tokens via action=query&meta=tokens
+ *
+ * @ingroup API
+ * @since 1.24
+ */
+class ApiQueryTokens extends ApiQueryBase {
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $res = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+
+ if ( $this->lacksSameOriginSecurity() ) {
+ $this->addWarning( [ 'apiwarn-tokens-origin' ] );
+ return;
+ }
+
+ $user = $this->getUser();
+ $session = $this->getRequest()->getSession();
+ $salts = self::getTokenTypeSalts();
+ foreach ( $params['type'] as $type ) {
+ $res[$type . 'token'] = self::getToken( $user, $session, $salts[$type] )->toString();
+ }
+
+ $this->getResult()->addValue( 'query', $this->getModuleName(), $res );
+ }
+
+ /**
+ * Get the salts for known token types
+ * @return (string|array)[] Returning a string will use that as the salt
+ * for User::getEditTokenObject() to fetch the token, which will give a
+ * LoggedOutEditToken (always "+\\") for anonymous users. Returning an
+ * array will use it as parameters to MediaWiki\Session\Session::getToken(),
+ * which will always return a full token even for anonymous users.
+ */
+ public static function getTokenTypeSalts() {
+ static $salts = null;
+ if ( !$salts ) {
+ $salts = [
+ 'csrf' => '',
+ 'watch' => 'watch',
+ 'patrol' => 'patrol',
+ 'rollback' => 'rollback',
+ 'userrights' => 'userrights',
+ 'login' => [ '', 'login' ],
+ 'createaccount' => [ '', 'createaccount' ],
+ ];
+ Hooks::run( 'ApiQueryTokensRegisterTypes', [ &$salts ] );
+ ksort( $salts );
+ }
+
+ return $salts;
+ }
+
+ /**
+ * Get a token from a salt
+ * @param User $user
+ * @param MediaWiki\Session\Session $session
+ * @param string|array $salt A string will be used as the salt for
+ * User::getEditTokenObject() to fetch the token, which will give a
+ * LoggedOutEditToken (always "+\\") for anonymous users. An array will
+ * be used as parameters to MediaWiki\Session\Session::getToken(), which
+ * will always return a full token even for anonymous users. An array will
+ * also persist the session.
+ * @return MediaWiki\Session\Token
+ */
+ public static function getToken( User $user, MediaWiki\Session\Session $session, $salt ) {
+ if ( is_array( $salt ) ) {
+ $session->persist();
+ return call_user_func_array( [ $session, 'getToken' ], $salt );
+ } else {
+ return $user->getEditTokenObject( $salt, $session->getRequest() );
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'type' => [
+ ApiBase::PARAM_DFLT => 'csrf',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => array_keys( self::getTokenTypeSalts() ),
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&meta=tokens'
+ => 'apihelp-query+tokens-example-simple',
+ 'action=query&meta=tokens&type=watch|patrol'
+ => 'apihelp-query+tokens-example-types',
+ ];
+ }
+
+ public function isReadMode() {
+ // So login tokens can be fetched on private wikis
+ return false;
+ }
+
+ public function getCacheMode( $params ) {
+ return 'private';
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Tokens';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryUserContributions.php b/www/wiki/includes/api/ApiQueryUserContributions.php
new file mode 100644
index 00000000..bb0f335b
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryUserContributions.php
@@ -0,0 +1,596 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 16, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This query action adds a list of a specified user's contributions to the output.
+ *
+ * @ingroup API
+ */
+class ApiQueryContributions extends ApiQueryBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'uc' );
+ }
+
+ private $params, $prefixMode, $userprefix, $multiUserMode, $idMode, $usernames, $userids,
+ $parentLens, $commentStore;
+ private $fld_ids = false, $fld_title = false, $fld_timestamp = false,
+ $fld_comment = false, $fld_parsedcomment = false, $fld_flags = false,
+ $fld_patrolled = false, $fld_tags = false, $fld_size = false, $fld_sizediff = false;
+
+ public function execute() {
+ // Parse some parameters
+ $this->params = $this->extractRequestParams();
+
+ $this->commentStore = new CommentStore( 'rev_comment' );
+
+ $prop = array_flip( $this->params['prop'] );
+ $this->fld_ids = isset( $prop['ids'] );
+ $this->fld_title = isset( $prop['title'] );
+ $this->fld_comment = isset( $prop['comment'] );
+ $this->fld_parsedcomment = isset( $prop['parsedcomment'] );
+ $this->fld_size = isset( $prop['size'] );
+ $this->fld_sizediff = isset( $prop['sizediff'] );
+ $this->fld_flags = isset( $prop['flags'] );
+ $this->fld_timestamp = isset( $prop['timestamp'] );
+ $this->fld_patrolled = isset( $prop['patrolled'] );
+ $this->fld_tags = isset( $prop['tags'] );
+
+ // Most of this code will use the 'contributions' group DB, which can map to replica DBs
+ // with extra user based indexes or partioning by user. The additional metadata
+ // queries should use a regular replica DB since the lookup pattern is not all by user.
+ $dbSecondary = $this->getDB(); // any random replica DB
+
+ // TODO: if the query is going only against the revision table, should this be done?
+ $this->selectNamedDB( 'contributions', DB_REPLICA, 'contributions' );
+
+ $this->requireOnlyOneParameter( $this->params, 'userprefix', 'userids', 'user' );
+
+ $this->idMode = false;
+ if ( isset( $this->params['userprefix'] ) ) {
+ $this->prefixMode = true;
+ $this->multiUserMode = true;
+ $this->userprefix = $this->params['userprefix'];
+ } elseif ( isset( $this->params['userids'] ) ) {
+ $this->userids = [];
+
+ if ( !count( $this->params['userids'] ) ) {
+ $encParamName = $this->encodeParamName( 'userids' );
+ $this->dieWithError( [ 'apierror-paramempty', $encParamName ], "paramempty_$encParamName" );
+ }
+
+ foreach ( $this->params['userids'] as $uid ) {
+ if ( $uid <= 0 ) {
+ $this->dieWithError( [ 'apierror-invaliduserid', $uid ], 'invaliduserid' );
+ }
+
+ $this->userids[] = $uid;
+ }
+
+ $this->prefixMode = false;
+ $this->multiUserMode = ( count( $this->params['userids'] ) > 1 );
+ $this->idMode = true;
+ } else {
+ $anyIPs = false;
+ $this->userids = [];
+ $this->usernames = [];
+ if ( !count( $this->params['user'] ) ) {
+ $encParamName = $this->encodeParamName( 'user' );
+ $this->dieWithError(
+ [ 'apierror-paramempty', $encParamName ], "paramempty_$encParamName"
+ );
+ }
+ foreach ( $this->params['user'] as $u ) {
+ if ( $u === '' ) {
+ $encParamName = $this->encodeParamName( 'user' );
+ $this->dieWithError(
+ [ 'apierror-paramempty', $encParamName ], "paramempty_$encParamName"
+ );
+ }
+
+ if ( User::isIP( $u ) ) {
+ $anyIPs = true;
+ $this->usernames[] = $u;
+ } else {
+ $name = User::getCanonicalName( $u, 'valid' );
+ if ( $name === false ) {
+ $encParamName = $this->encodeParamName( 'user' );
+ $this->dieWithError(
+ [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $u ) ], "baduser_$encParamName"
+ );
+ }
+ $this->usernames[] = $name;
+ }
+ }
+ $this->prefixMode = false;
+ $this->multiUserMode = ( count( $this->params['user'] ) > 1 );
+
+ if ( !$anyIPs ) {
+ $dbr = $this->getDB();
+ $res = $dbr->select( 'user', 'user_id', [ 'user_name' => $this->usernames ], __METHOD__ );
+ foreach ( $res as $row ) {
+ $this->userids[] = $row->user_id;
+ }
+ $this->idMode = count( $this->userids ) === count( $this->usernames );
+ }
+ }
+
+ $this->prepareQuery();
+
+ $hookData = [];
+ // Do the actual query.
+ $res = $this->select( __METHOD__, [], $hookData );
+
+ if ( $this->fld_sizediff ) {
+ $revIds = [];
+ foreach ( $res as $row ) {
+ if ( $row->rev_parent_id ) {
+ $revIds[] = $row->rev_parent_id;
+ }
+ }
+ $this->parentLens = Revision::getParentLengths( $dbSecondary, $revIds );
+ $res->rewind(); // reset
+ }
+
+ // Initialise some variables
+ $count = 0;
+ $limit = $this->params['limit'];
+
+ // Fetch each row
+ foreach ( $res as $row ) {
+ if ( ++$count > $limit ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $this->continueStr( $row ) );
+ break;
+ }
+
+ $vals = $this->extractRowInfo( $row );
+ $fit = $this->processRow( $row, $vals, $hookData ) &&
+ $this->getResult()->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $this->continueStr( $row ) );
+ break;
+ }
+ }
+
+ $this->getResult()->addIndexedTagName(
+ [ 'query', $this->getModuleName() ],
+ 'item'
+ );
+ }
+
+ /**
+ * Prepares the query and returns the limit of rows requested
+ */
+ private function prepareQuery() {
+ // We're after the revision table, and the corresponding page
+ // row for anything we retrieve. We may also need the
+ // recentchanges row and/or tag summary row.
+ $user = $this->getUser();
+ $tables = [ 'page', 'revision' ]; // Order may change
+ $this->addWhere( 'page_id=rev_page' );
+
+ // Handle continue parameter
+ if ( !is_null( $this->params['continue'] ) ) {
+ $continue = explode( '|', $this->params['continue'] );
+ $db = $this->getDB();
+ if ( $this->multiUserMode ) {
+ $this->dieContinueUsageIf( count( $continue ) != 4 );
+ $modeFlag = array_shift( $continue );
+ $this->dieContinueUsageIf( !in_array( $modeFlag, [ 'id', 'name' ] ) );
+ if ( $this->idMode && $modeFlag === 'name' ) {
+ // The users were created since this query started, but we
+ // can't go back and change modes now. So just keep on with
+ // name mode.
+ $this->idMode = false;
+ }
+ $this->dieContinueUsageIf( ( $modeFlag === 'id' ) !== $this->idMode );
+ $userField = $this->idMode ? 'rev_user' : 'rev_user_text';
+ $encUser = $db->addQuotes( array_shift( $continue ) );
+ } else {
+ $this->dieContinueUsageIf( count( $continue ) != 2 );
+ }
+ $encTS = $db->addQuotes( $db->timestamp( $continue[0] ) );
+ $encId = (int)$continue[1];
+ $this->dieContinueUsageIf( $encId != $continue[1] );
+ $op = ( $this->params['dir'] == 'older' ? '<' : '>' );
+ if ( $this->multiUserMode ) {
+ $this->addWhere(
+ "$userField $op $encUser OR " .
+ "($userField = $encUser AND " .
+ "(rev_timestamp $op $encTS OR " .
+ "(rev_timestamp = $encTS AND " .
+ "rev_id $op= $encId)))"
+ );
+ } else {
+ $this->addWhere(
+ "rev_timestamp $op $encTS OR " .
+ "(rev_timestamp = $encTS AND " .
+ "rev_id $op= $encId)"
+ );
+ }
+ }
+
+ // Don't include any revisions where we're not supposed to be able to
+ // see the username.
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = Revision::DELETED_USER;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $this->addWhere( $this->getDB()->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" );
+ }
+
+ // We only want pages by the specified users.
+ if ( $this->prefixMode ) {
+ $this->addWhere( 'rev_user_text' .
+ $this->getDB()->buildLike( $this->userprefix, $this->getDB()->anyString() ) );
+ } elseif ( $this->idMode ) {
+ $this->addWhereFld( 'rev_user', $this->userids );
+ } else {
+ $this->addWhereFld( 'rev_user_text', $this->usernames );
+ }
+ // ... and in the specified timeframe.
+ // Ensure the same sort order for rev_user/rev_user_text and rev_timestamp
+ // so our query is indexed
+ if ( $this->multiUserMode ) {
+ $this->addWhereRange( $this->idMode ? 'rev_user' : 'rev_user_text',
+ $this->params['dir'], null, null );
+ }
+ $this->addTimestampWhereRange( 'rev_timestamp',
+ $this->params['dir'], $this->params['start'], $this->params['end'] );
+ // Include in ORDER BY for uniqueness
+ $this->addWhereRange( 'rev_id', $this->params['dir'], null, null );
+
+ $this->addWhereFld( 'page_namespace', $this->params['namespace'] );
+
+ $show = $this->params['show'];
+ if ( $this->params['toponly'] ) { // deprecated/old param
+ $show[] = 'top';
+ }
+ if ( !is_null( $show ) ) {
+ $show = array_flip( $show );
+
+ if ( ( isset( $show['minor'] ) && isset( $show['!minor'] ) )
+ || ( isset( $show['patrolled'] ) && isset( $show['!patrolled'] ) )
+ || ( isset( $show['top'] ) && isset( $show['!top'] ) )
+ || ( isset( $show['new'] ) && isset( $show['!new'] ) )
+ ) {
+ $this->dieWithError( 'apierror-show' );
+ }
+
+ $this->addWhereIf( 'rev_minor_edit = 0', isset( $show['!minor'] ) );
+ $this->addWhereIf( 'rev_minor_edit != 0', isset( $show['minor'] ) );
+ $this->addWhereIf( 'rc_patrolled = 0', isset( $show['!patrolled'] ) );
+ $this->addWhereIf( 'rc_patrolled != 0', isset( $show['patrolled'] ) );
+ $this->addWhereIf( 'rev_id != page_latest', isset( $show['!top'] ) );
+ $this->addWhereIf( 'rev_id = page_latest', isset( $show['top'] ) );
+ $this->addWhereIf( 'rev_parent_id != 0', isset( $show['!new'] ) );
+ $this->addWhereIf( 'rev_parent_id = 0', isset( $show['new'] ) );
+ }
+ $this->addOption( 'LIMIT', $this->params['limit'] + 1 );
+
+ // Mandatory fields: timestamp allows request continuation
+ // ns+title checks if the user has access rights for this page
+ // user_text is necessary if multiple users were specified
+ $this->addFields( [
+ 'rev_id',
+ 'rev_timestamp',
+ 'page_namespace',
+ 'page_title',
+ 'rev_user',
+ 'rev_user_text',
+ 'rev_deleted'
+ ] );
+
+ if ( isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) ||
+ $this->fld_patrolled
+ ) {
+ if ( !$user->useRCPatrol() && !$user->useNPPatrol() ) {
+ $this->dieWithError( 'apierror-permissiondenied-patrolflag', 'permissiondenied' );
+ }
+
+ // Use a redundant join condition on both
+ // timestamp and ID so we can use the timestamp
+ // index
+ $index['recentchanges'] = 'rc_user_text';
+ if ( isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) ) {
+ // Put the tables in the right order for
+ // STRAIGHT_JOIN
+ $tables = [ 'revision', 'recentchanges', 'page' ];
+ $this->addOption( 'STRAIGHT_JOIN' );
+ $this->addWhere( 'rc_user_text=rev_user_text' );
+ $this->addWhere( 'rc_timestamp=rev_timestamp' );
+ $this->addWhere( 'rc_this_oldid=rev_id' );
+ } else {
+ $tables[] = 'recentchanges';
+ $this->addJoinConds( [ 'recentchanges' => [
+ 'LEFT JOIN', [
+ 'rc_user_text=rev_user_text',
+ 'rc_timestamp=rev_timestamp',
+ 'rc_this_oldid=rev_id' ] ] ] );
+ }
+ }
+
+ $this->addTables( $tables );
+ $this->addFieldsIf( 'rev_page', $this->fld_ids );
+ $this->addFieldsIf( 'page_latest', $this->fld_flags );
+ // $this->addFieldsIf( 'rev_text_id', $this->fld_ids ); // Should this field be exposed?
+ $this->addFieldsIf( 'rev_len', $this->fld_size || $this->fld_sizediff );
+ $this->addFieldsIf( 'rev_minor_edit', $this->fld_flags );
+ $this->addFieldsIf( 'rev_parent_id', $this->fld_flags || $this->fld_sizediff || $this->fld_ids );
+ $this->addFieldsIf( 'rc_patrolled', $this->fld_patrolled );
+
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ $commentQuery = $this->commentStore->getJoin();
+ $this->addTables( $commentQuery['tables'] );
+ $this->addFields( $commentQuery['fields'] );
+ $this->addJoinConds( $commentQuery['joins'] );
+ }
+
+ if ( $this->fld_tags ) {
+ $this->addTables( 'tag_summary' );
+ $this->addJoinConds(
+ [ 'tag_summary' => [ 'LEFT JOIN', [ 'rev_id=ts_rev_id' ] ] ]
+ );
+ $this->addFields( 'ts_tags' );
+ }
+
+ if ( isset( $this->params['tag'] ) ) {
+ $this->addTables( 'change_tag' );
+ $this->addJoinConds(
+ [ 'change_tag' => [ 'INNER JOIN', [ 'rev_id=ct_rev_id' ] ] ]
+ );
+ $this->addWhereFld( 'ct_tag', $this->params['tag'] );
+ }
+
+ if ( isset( $index ) ) {
+ $this->addOption( 'USE INDEX', $index );
+ }
+ }
+
+ /**
+ * Extract fields from the database row and append them to a result array
+ *
+ * @param stdClass $row
+ * @return array
+ */
+ private function extractRowInfo( $row ) {
+ $vals = [];
+ $anyHidden = false;
+
+ if ( $row->rev_deleted & Revision::DELETED_TEXT ) {
+ $vals['texthidden'] = true;
+ $anyHidden = true;
+ }
+
+ // Any rows where we can't view the user were filtered out in the query.
+ $vals['userid'] = (int)$row->rev_user;
+ $vals['user'] = $row->rev_user_text;
+ if ( $row->rev_deleted & Revision::DELETED_USER ) {
+ $vals['userhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( $this->fld_ids ) {
+ $vals['pageid'] = intval( $row->rev_page );
+ $vals['revid'] = intval( $row->rev_id );
+ // $vals['textid'] = intval( $row->rev_text_id ); // todo: Should this field be exposed?
+
+ if ( !is_null( $row->rev_parent_id ) ) {
+ $vals['parentid'] = intval( $row->rev_parent_id );
+ }
+ }
+
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+
+ if ( $this->fld_title ) {
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ }
+
+ if ( $this->fld_timestamp ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->rev_timestamp );
+ }
+
+ if ( $this->fld_flags ) {
+ $vals['new'] = $row->rev_parent_id == 0 && !is_null( $row->rev_parent_id );
+ $vals['minor'] = (bool)$row->rev_minor_edit;
+ $vals['top'] = $row->page_latest == $row->rev_id;
+ }
+
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ if ( $row->rev_deleted & Revision::DELETED_COMMENT ) {
+ $vals['commenthidden'] = true;
+ $anyHidden = true;
+ }
+
+ $userCanView = Revision::userCanBitfield(
+ $row->rev_deleted,
+ Revision::DELETED_COMMENT, $this->getUser()
+ );
+
+ if ( $userCanView ) {
+ $comment = $this->commentStore->getComment( $row )->text;
+ if ( $this->fld_comment ) {
+ $vals['comment'] = $comment;
+ }
+
+ if ( $this->fld_parsedcomment ) {
+ $vals['parsedcomment'] = Linker::formatComment( $comment, $title );
+ }
+ }
+ }
+
+ if ( $this->fld_patrolled ) {
+ $vals['patrolled'] = (bool)$row->rc_patrolled;
+ }
+
+ if ( $this->fld_size && !is_null( $row->rev_len ) ) {
+ $vals['size'] = intval( $row->rev_len );
+ }
+
+ if ( $this->fld_sizediff
+ && !is_null( $row->rev_len )
+ && !is_null( $row->rev_parent_id )
+ ) {
+ $parentLen = isset( $this->parentLens[$row->rev_parent_id] )
+ ? $this->parentLens[$row->rev_parent_id]
+ : 0;
+ $vals['sizediff'] = intval( $row->rev_len - $parentLen );
+ }
+
+ if ( $this->fld_tags ) {
+ if ( $row->ts_tags ) {
+ $tags = explode( ',', $row->ts_tags );
+ ApiResult::setIndexedTagName( $tags, 'tag' );
+ $vals['tags'] = $tags;
+ } else {
+ $vals['tags'] = [];
+ }
+ }
+
+ if ( $anyHidden && $row->rev_deleted & Revision::DELETED_RESTRICTED ) {
+ $vals['suppressed'] = true;
+ }
+
+ return $vals;
+ }
+
+ private function continueStr( $row ) {
+ if ( $this->multiUserMode ) {
+ if ( $this->idMode ) {
+ return "id|$row->rev_user|$row->rev_timestamp|$row->rev_id";
+ } else {
+ return "name|$row->rev_user_text|$row->rev_timestamp|$row->rev_id";
+ }
+ } else {
+ return "$row->rev_timestamp|$row->rev_id";
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ // This module provides access to deleted revisions and patrol flags if
+ // the requester is logged in
+ return 'anon-public-user-private';
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'userids' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'userprefix' => null,
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace'
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'ids|title|timestamp|comment|size|flags',
+ ApiBase::PARAM_TYPE => [
+ 'ids',
+ 'title',
+ 'timestamp',
+ 'comment',
+ 'parsedcomment',
+ 'size',
+ 'sizediff',
+ 'flags',
+ 'patrolled',
+ 'tags'
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'show' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'minor',
+ '!minor',
+ 'patrolled',
+ '!patrolled',
+ 'top',
+ '!top',
+ 'new',
+ '!new',
+ ],
+ ApiBase::PARAM_HELP_MSG => [
+ 'apihelp-query+usercontribs-param-show',
+ $this->getConfig()->get( 'RCMaxAge' )
+ ],
+ ],
+ 'tag' => null,
+ 'toponly' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=usercontribs&ucuser=Example'
+ => 'apihelp-query+usercontribs-example-user',
+ 'action=query&list=usercontribs&ucuserprefix=192.0.2.'
+ => 'apihelp-query+usercontribs-example-ipprefix',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Usercontribs';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryUserInfo.php b/www/wiki/includes/api/ApiQueryUserInfo.php
new file mode 100644
index 00000000..036515d6
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryUserInfo.php
@@ -0,0 +1,353 @@
+<?php
+/**
+ *
+ *
+ * Created on July 30, 2007
+ *
+ * Copyright © 2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Query module to get information about the currently logged-in user
+ *
+ * @ingroup API
+ */
+class ApiQueryUserInfo extends ApiQueryBase {
+
+ const WL_UNREAD_LIMIT = 1000;
+
+ private $params = [];
+ private $prop = [];
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ui' );
+ }
+
+ public function execute() {
+ $this->params = $this->extractRequestParams();
+ $result = $this->getResult();
+
+ if ( !is_null( $this->params['prop'] ) ) {
+ $this->prop = array_flip( $this->params['prop'] );
+ }
+
+ $r = $this->getCurrentUserInfo();
+ $result->addValue( 'query', $this->getModuleName(), $r );
+ }
+
+ /**
+ * Get basic info about a given block
+ * @param Block $block
+ * @return array Array containing several keys:
+ * - blockid - ID of the block
+ * - blockedby - username of the blocker
+ * - blockedbyid - user ID of the blocker
+ * - blockreason - reason provided for the block
+ * - blockedtimestamp - timestamp for when the block was placed/modified
+ * - blockexpiry - expiry time of the block
+ * - systemblocktype - system block type, if any
+ */
+ public static function getBlockInfo( Block $block ) {
+ $vals = [];
+ $vals['blockid'] = $block->getId();
+ $vals['blockedby'] = $block->getByName();
+ $vals['blockedbyid'] = $block->getBy();
+ $vals['blockreason'] = $block->mReason;
+ $vals['blockedtimestamp'] = wfTimestamp( TS_ISO_8601, $block->mTimestamp );
+ $vals['blockexpiry'] = ApiResult::formatExpiry( $block->getExpiry(), 'infinite' );
+ if ( $block->getSystemBlockType() !== null ) {
+ $vals['systemblocktype'] = $block->getSystemBlockType();
+ }
+ return $vals;
+ }
+
+ /**
+ * Get central user info
+ * @param Config $config
+ * @param User $user
+ * @param string|null $attachedWiki
+ * @return array Central user info
+ * - centralids: Array mapping non-local Central ID provider names to IDs
+ * - attachedlocal: Array mapping Central ID provider names to booleans
+ * indicating whether the local user is attached.
+ * - attachedwiki: Array mapping Central ID provider names to booleans
+ * indicating whether the user is attached to $attachedWiki.
+ */
+ public static function getCentralUserInfo( Config $config, User $user, $attachedWiki = null ) {
+ $providerIds = array_keys( $config->get( 'CentralIdLookupProviders' ) );
+
+ $ret = [
+ 'centralids' => [],
+ 'attachedlocal' => [],
+ ];
+ ApiResult::setArrayType( $ret['centralids'], 'assoc' );
+ ApiResult::setArrayType( $ret['attachedlocal'], 'assoc' );
+ if ( $attachedWiki ) {
+ $ret['attachedwiki'] = [];
+ ApiResult::setArrayType( $ret['attachedwiki'], 'assoc' );
+ }
+
+ $name = $user->getName();
+ foreach ( $providerIds as $providerId ) {
+ $provider = CentralIdLookup::factory( $providerId );
+ $ret['centralids'][$providerId] = $provider->centralIdFromName( $name );
+ $ret['attachedlocal'][$providerId] = $provider->isAttached( $user );
+ if ( $attachedWiki ) {
+ $ret['attachedwiki'][$providerId] = $provider->isAttached( $user, $attachedWiki );
+ }
+ }
+
+ return $ret;
+ }
+
+ protected function getCurrentUserInfo() {
+ $user = $this->getUser();
+ $vals = [];
+ $vals['id'] = intval( $user->getId() );
+ $vals['name'] = $user->getName();
+
+ if ( $user->isAnon() ) {
+ $vals['anon'] = true;
+ }
+
+ if ( isset( $this->prop['blockinfo'] ) && $user->isBlocked() ) {
+ $vals = array_merge( $vals, self::getBlockInfo( $user->getBlock() ) );
+ }
+
+ if ( isset( $this->prop['hasmsg'] ) ) {
+ $vals['messages'] = $user->getNewtalk();
+ }
+
+ if ( isset( $this->prop['groups'] ) ) {
+ $vals['groups'] = $user->getEffectiveGroups();
+ ApiResult::setArrayType( $vals['groups'], 'array' ); // even if empty
+ ApiResult::setIndexedTagName( $vals['groups'], 'g' ); // even if empty
+ }
+
+ if ( isset( $this->prop['groupmemberships'] ) ) {
+ $ugms = $user->getGroupMemberships();
+ $vals['groupmemberships'] = [];
+ foreach ( $ugms as $group => $ugm ) {
+ $vals['groupmemberships'][] = [
+ 'group' => $group,
+ 'expiry' => ApiResult::formatExpiry( $ugm->getExpiry() ),
+ ];
+ }
+ ApiResult::setArrayType( $vals['groupmemberships'], 'array' ); // even if empty
+ ApiResult::setIndexedTagName( $vals['groupmemberships'], 'groupmembership' ); // even if empty
+ }
+
+ if ( isset( $this->prop['implicitgroups'] ) ) {
+ $vals['implicitgroups'] = $user->getAutomaticGroups();
+ ApiResult::setArrayType( $vals['implicitgroups'], 'array' ); // even if empty
+ ApiResult::setIndexedTagName( $vals['implicitgroups'], 'g' ); // even if empty
+ }
+
+ if ( isset( $this->prop['rights'] ) ) {
+ // User::getRights() may return duplicate values, strip them
+ $vals['rights'] = array_values( array_unique( $user->getRights() ) );
+ ApiResult::setArrayType( $vals['rights'], 'array' ); // even if empty
+ ApiResult::setIndexedTagName( $vals['rights'], 'r' ); // even if empty
+ }
+
+ if ( isset( $this->prop['changeablegroups'] ) ) {
+ $vals['changeablegroups'] = $user->changeableGroups();
+ ApiResult::setIndexedTagName( $vals['changeablegroups']['add'], 'g' );
+ ApiResult::setIndexedTagName( $vals['changeablegroups']['remove'], 'g' );
+ ApiResult::setIndexedTagName( $vals['changeablegroups']['add-self'], 'g' );
+ ApiResult::setIndexedTagName( $vals['changeablegroups']['remove-self'], 'g' );
+ }
+
+ if ( isset( $this->prop['options'] ) ) {
+ $vals['options'] = $user->getOptions();
+ $vals['options'][ApiResult::META_BC_BOOLS] = array_keys( $vals['options'] );
+ }
+
+ if ( isset( $this->prop['preferencestoken'] ) &&
+ !$this->lacksSameOriginSecurity() &&
+ $user->isAllowed( 'editmyoptions' )
+ ) {
+ $vals['preferencestoken'] = $user->getEditToken( '', $this->getMain()->getRequest() );
+ }
+
+ if ( isset( $this->prop['editcount'] ) ) {
+ // use intval to prevent null if a non-logged-in user calls
+ // api.php?format=jsonfm&action=query&meta=userinfo&uiprop=editcount
+ $vals['editcount'] = intval( $user->getEditCount() );
+ }
+
+ if ( isset( $this->prop['ratelimits'] ) ) {
+ $vals['ratelimits'] = $this->getRateLimits();
+ }
+
+ if ( isset( $this->prop['realname'] ) &&
+ !in_array( 'realname', $this->getConfig()->get( 'HiddenPrefs' ) )
+ ) {
+ $vals['realname'] = $user->getRealName();
+ }
+
+ if ( $user->isAllowed( 'viewmyprivateinfo' ) ) {
+ if ( isset( $this->prop['email'] ) ) {
+ $vals['email'] = $user->getEmail();
+ $auth = $user->getEmailAuthenticationTimestamp();
+ if ( !is_null( $auth ) ) {
+ $vals['emailauthenticated'] = wfTimestamp( TS_ISO_8601, $auth );
+ }
+ }
+ }
+
+ if ( isset( $this->prop['registrationdate'] ) ) {
+ $regDate = $user->getRegistration();
+ if ( $regDate !== false ) {
+ $vals['registrationdate'] = wfTimestamp( TS_ISO_8601, $regDate );
+ }
+ }
+
+ if ( isset( $this->prop['acceptlang'] ) ) {
+ $langs = $this->getRequest()->getAcceptLang();
+ $acceptLang = [];
+ foreach ( $langs as $lang => $val ) {
+ $r = [ 'q' => $val ];
+ ApiResult::setContentValue( $r, 'code', $lang );
+ $acceptLang[] = $r;
+ }
+ ApiResult::setIndexedTagName( $acceptLang, 'lang' );
+ $vals['acceptlang'] = $acceptLang;
+ }
+
+ if ( isset( $this->prop['unreadcount'] ) ) {
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $unreadNotifications = $store->countUnreadNotifications(
+ $user,
+ self::WL_UNREAD_LIMIT
+ );
+
+ if ( $unreadNotifications === true ) {
+ $vals['unreadcount'] = self::WL_UNREAD_LIMIT . '+';
+ } else {
+ $vals['unreadcount'] = $unreadNotifications;
+ }
+ }
+
+ if ( isset( $this->prop['centralids'] ) ) {
+ $vals += self::getCentralUserInfo(
+ $this->getConfig(), $this->getUser(), $this->params['attachedwiki']
+ );
+ }
+
+ return $vals;
+ }
+
+ protected function getRateLimits() {
+ $retval = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+
+ $user = $this->getUser();
+ if ( !$user->isPingLimitable() ) {
+ return $retval; // No limits
+ }
+
+ // Find out which categories we belong to
+ $categories = [];
+ if ( $user->isAnon() ) {
+ $categories[] = 'anon';
+ } else {
+ $categories[] = 'user';
+ }
+ if ( $user->isNewbie() ) {
+ $categories[] = 'ip';
+ $categories[] = 'subnet';
+ if ( !$user->isAnon() ) {
+ $categories[] = 'newbie';
+ }
+ }
+ $categories = array_merge( $categories, $user->getGroups() );
+
+ // Now get the actual limits
+ foreach ( $this->getConfig()->get( 'RateLimits' ) as $action => $limits ) {
+ foreach ( $categories as $cat ) {
+ if ( isset( $limits[$cat] ) && !is_null( $limits[$cat] ) ) {
+ $retval[$action][$cat]['hits'] = intval( $limits[$cat][0] );
+ $retval[$action][$cat]['seconds'] = intval( $limits[$cat][1] );
+ }
+ }
+ }
+
+ return $retval;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'blockinfo',
+ 'hasmsg',
+ 'groups',
+ 'groupmemberships',
+ 'implicitgroups',
+ 'rights',
+ 'changeablegroups',
+ 'options',
+ 'editcount',
+ 'ratelimits',
+ 'email',
+ 'realname',
+ 'acceptlang',
+ 'registrationdate',
+ 'unreadcount',
+ 'centralids',
+ 'preferencestoken',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [
+ 'unreadcount' => [
+ 'apihelp-query+userinfo-paramvalue-prop-unreadcount',
+ self::WL_UNREAD_LIMIT - 1,
+ self::WL_UNREAD_LIMIT . '+',
+ ],
+ ],
+ ApiBase::PARAM_DEPRECATED_VALUES => [
+ 'preferencestoken' => [
+ 'apiwarn-deprecation-withreplacement',
+ $this->getModulePrefix() . "prop=preferencestoken",
+ 'action=query&meta=tokens',
+ ]
+ ],
+ ],
+ 'attachedwiki' => null,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&meta=userinfo'
+ => 'apihelp-query+userinfo-example-simple',
+ 'action=query&meta=userinfo&uiprop=blockinfo|groups|rights|hasmsg'
+ => 'apihelp-query+userinfo-example-data',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Userinfo';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryUsers.php b/www/wiki/includes/api/ApiQueryUsers.php
new file mode 100644
index 00000000..fbf1f9eb
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryUsers.php
@@ -0,0 +1,409 @@
+<?php
+/**
+ *
+ *
+ * Created on July 30, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Query module to get information about a list of users
+ *
+ * @ingroup API
+ */
+class ApiQueryUsers extends ApiQueryBase {
+
+ private $tokenFunctions, $prop;
+
+ /**
+ * Properties whose contents does not depend on who is looking at them. If the usprops field
+ * contains anything not listed here, the cache mode will never be public for logged-in users.
+ * @var array
+ */
+ protected static $publicProps = [
+ // everything except 'blockinfo' which might show hidden records if the user
+ // making the request has the appropriate permissions
+ 'groups',
+ 'groupmemberships',
+ 'implicitgroups',
+ 'rights',
+ 'editcount',
+ 'registration',
+ 'emailable',
+ 'gender',
+ 'centralids',
+ 'cancreate',
+ ];
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'us' );
+ }
+
+ /**
+ * Get an array mapping token names to their handler functions.
+ * The prototype for a token function is func($user)
+ * it should return a token or false (permission denied)
+ * @deprecated since 1.24
+ * @return array Array of tokenname => function
+ */
+ protected function getTokenFunctions() {
+ // Don't call the hooks twice
+ if ( isset( $this->tokenFunctions ) ) {
+ return $this->tokenFunctions;
+ }
+
+ // If we're in a mode that breaks the same-origin policy, no tokens can
+ // be obtained
+ if ( $this->lacksSameOriginSecurity() ) {
+ return [];
+ }
+
+ $this->tokenFunctions = [
+ 'userrights' => [ 'ApiQueryUsers', 'getUserrightsToken' ],
+ ];
+ Hooks::run( 'APIQueryUsersTokens', [ &$this->tokenFunctions ] );
+
+ return $this->tokenFunctions;
+ }
+
+ /**
+ * @deprecated since 1.24
+ * @param User $user
+ * @return string
+ */
+ public static function getUserrightsToken( $user ) {
+ global $wgUser;
+
+ // Since the permissions check for userrights is non-trivial,
+ // don't bother with it here
+ return $wgUser->getEditToken( $user->getName() );
+ }
+
+ public function execute() {
+ $db = $this->getDB();
+ $commentStore = new CommentStore( 'ipb_reason' );
+
+ $params = $this->extractRequestParams();
+ $this->requireMaxOneParameter( $params, 'userids', 'users' );
+
+ if ( !is_null( $params['prop'] ) ) {
+ $this->prop = array_flip( $params['prop'] );
+ } else {
+ $this->prop = [];
+ }
+ $useNames = !is_null( $params['users'] );
+
+ $users = (array)$params['users'];
+ $userids = (array)$params['userids'];
+
+ $goodNames = $done = [];
+ $result = $this->getResult();
+ // Canonicalize user names
+ foreach ( $users as $u ) {
+ $n = User::getCanonicalName( $u );
+ if ( $n === false || $n === '' ) {
+ $vals = [ 'name' => $u, 'invalid' => true ];
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ],
+ null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'users',
+ implode( '|', array_diff( $users, $done ) ) );
+ $goodNames = [];
+ break;
+ }
+ $done[] = $u;
+ } else {
+ $goodNames[] = $n;
+ }
+ }
+
+ if ( $useNames ) {
+ $parameters = &$goodNames;
+ } else {
+ $parameters = &$userids;
+ }
+
+ $result = $this->getResult();
+
+ if ( count( $parameters ) ) {
+ $this->addTables( 'user' );
+ $this->addFields( User::selectFields() );
+ if ( $useNames ) {
+ $this->addWhereFld( 'user_name', $goodNames );
+ } else {
+ $this->addWhereFld( 'user_id', $userids );
+ }
+
+ $this->showHiddenUsersAddBlockInfo( isset( $this->prop['blockinfo'] ) );
+
+ $data = [];
+ $res = $this->select( __METHOD__ );
+ $this->resetQueryParams();
+
+ // get user groups if needed
+ if ( isset( $this->prop['groups'] ) || isset( $this->prop['rights'] ) ) {
+ $userGroups = [];
+
+ $this->addTables( 'user' );
+ if ( $useNames ) {
+ $this->addWhereFld( 'user_name', $goodNames );
+ } else {
+ $this->addWhereFld( 'user_id', $userids );
+ }
+
+ $this->addTables( 'user_groups' );
+ $this->addJoinConds( [ 'user_groups' => [ 'INNER JOIN', 'ug_user=user_id' ] ] );
+ $this->addFields( [ 'user_name' ] );
+ $this->addFields( UserGroupMembership::selectFields() );
+ $this->addWhere( 'ug_expiry IS NULL OR ug_expiry >= ' .
+ $db->addQuotes( $db->timestamp() ) );
+ $userGroupsRes = $this->select( __METHOD__ );
+
+ foreach ( $userGroupsRes as $row ) {
+ $userGroups[$row->user_name][] = $row;
+ }
+ }
+
+ foreach ( $res as $row ) {
+ // create user object and pass along $userGroups if set
+ // that reduces the number of database queries needed in User dramatically
+ if ( !isset( $userGroups ) ) {
+ $user = User::newFromRow( $row );
+ } else {
+ if ( !isset( $userGroups[$row->user_name] ) || !is_array( $userGroups[$row->user_name] ) ) {
+ $userGroups[$row->user_name] = [];
+ }
+ $user = User::newFromRow( $row, [ 'user_groups' => $userGroups[$row->user_name] ] );
+ }
+ if ( $useNames ) {
+ $key = $user->getName();
+ } else {
+ $key = $user->getId();
+ }
+ $data[$key]['userid'] = $user->getId();
+ $data[$key]['name'] = $user->getName();
+
+ if ( isset( $this->prop['editcount'] ) ) {
+ $data[$key]['editcount'] = $user->getEditCount();
+ }
+
+ if ( isset( $this->prop['registration'] ) ) {
+ $data[$key]['registration'] = wfTimestampOrNull( TS_ISO_8601, $user->getRegistration() );
+ }
+
+ if ( isset( $this->prop['groups'] ) ) {
+ $data[$key]['groups'] = $user->getEffectiveGroups();
+ }
+
+ if ( isset( $this->prop['groupmemberships'] ) ) {
+ $data[$key]['groupmemberships'] = array_map( function ( $ugm ) {
+ return [
+ 'group' => $ugm->getGroup(),
+ 'expiry' => ApiResult::formatExpiry( $ugm->getExpiry() ),
+ ];
+ }, $user->getGroupMemberships() );
+ }
+
+ if ( isset( $this->prop['implicitgroups'] ) ) {
+ $data[$key]['implicitgroups'] = $user->getAutomaticGroups();
+ }
+
+ if ( isset( $this->prop['rights'] ) ) {
+ $data[$key]['rights'] = $user->getRights();
+ }
+ if ( $row->ipb_deleted ) {
+ $data[$key]['hidden'] = true;
+ }
+ if ( isset( $this->prop['blockinfo'] ) && !is_null( $row->ipb_by_text ) ) {
+ $data[$key]['blockid'] = (int)$row->ipb_id;
+ $data[$key]['blockedby'] = $row->ipb_by_text;
+ $data[$key]['blockedbyid'] = (int)$row->ipb_by;
+ $data[$key]['blockedtimestamp'] = wfTimestamp( TS_ISO_8601, $row->ipb_timestamp );
+ $data[$key]['blockreason'] = $commentStore->getComment( $row )->text;
+ $data[$key]['blockexpiry'] = $row->ipb_expiry;
+ }
+
+ if ( isset( $this->prop['emailable'] ) ) {
+ $data[$key]['emailable'] = $user->canReceiveEmail();
+ }
+
+ if ( isset( $this->prop['gender'] ) ) {
+ $gender = $user->getOption( 'gender' );
+ if ( strval( $gender ) === '' ) {
+ $gender = 'unknown';
+ }
+ $data[$key]['gender'] = $gender;
+ }
+
+ if ( isset( $this->prop['centralids'] ) ) {
+ $data[$key] += ApiQueryUserInfo::getCentralUserInfo(
+ $this->getConfig(), $user, $params['attachedwiki']
+ );
+ }
+
+ if ( !is_null( $params['token'] ) ) {
+ $tokenFunctions = $this->getTokenFunctions();
+ foreach ( $params['token'] as $t ) {
+ $val = call_user_func( $tokenFunctions[$t], $user );
+ if ( $val === false ) {
+ $this->addWarning( [ 'apiwarn-tokennotallowed', $t ] );
+ } else {
+ $data[$key][$t . 'token'] = $val;
+ }
+ }
+ }
+ }
+ }
+
+ $context = $this->getContext();
+ // Second pass: add result data to $retval
+ foreach ( $parameters as $u ) {
+ if ( !isset( $data[$u] ) ) {
+ if ( $useNames ) {
+ $data[$u] = [ 'name' => $u ];
+ $urPage = new UserrightsPage;
+ $urPage->setContext( $context );
+
+ $iwUser = $urPage->fetchUser( $u );
+
+ if ( $iwUser instanceof UserRightsProxy ) {
+ $data[$u]['interwiki'] = true;
+
+ if ( !is_null( $params['token'] ) ) {
+ $tokenFunctions = $this->getTokenFunctions();
+
+ foreach ( $params['token'] as $t ) {
+ $val = call_user_func( $tokenFunctions[$t], $iwUser );
+ if ( $val === false ) {
+ $this->addWarning( [ 'apiwarn-tokennotallowed', $t ] );
+ } else {
+ $data[$u][$t . 'token'] = $val;
+ }
+ }
+ }
+ } else {
+ $data[$u]['missing'] = true;
+ if ( isset( $this->prop['cancreate'] ) ) {
+ $status = MediaWiki\Auth\AuthManager::singleton()->canCreateAccount( $u );
+ $data[$u]['cancreate'] = $status->isGood();
+ if ( !$status->isGood() ) {
+ $data[$u]['cancreateerror'] = $this->getErrorFormatter()->arrayFromStatus( $status );
+ }
+ }
+ }
+ } else {
+ $data[$u] = [ 'userid' => $u, 'missing' => true ];
+ }
+
+ } else {
+ if ( isset( $this->prop['groups'] ) && isset( $data[$u]['groups'] ) ) {
+ ApiResult::setArrayType( $data[$u]['groups'], 'array' );
+ ApiResult::setIndexedTagName( $data[$u]['groups'], 'g' );
+ }
+ if ( isset( $this->prop['groupmemberships'] ) && isset( $data[$u]['groupmemberships'] ) ) {
+ ApiResult::setArrayType( $data[$u]['groupmemberships'], 'array' );
+ ApiResult::setIndexedTagName( $data[$u]['groupmemberships'], 'groupmembership' );
+ }
+ if ( isset( $this->prop['implicitgroups'] ) && isset( $data[$u]['implicitgroups'] ) ) {
+ ApiResult::setArrayType( $data[$u]['implicitgroups'], 'array' );
+ ApiResult::setIndexedTagName( $data[$u]['implicitgroups'], 'g' );
+ }
+ if ( isset( $this->prop['rights'] ) && isset( $data[$u]['rights'] ) ) {
+ ApiResult::setArrayType( $data[$u]['rights'], 'array' );
+ ApiResult::setIndexedTagName( $data[$u]['rights'], 'r' );
+ }
+ }
+
+ $fit = $result->addValue( [ 'query', $this->getModuleName() ],
+ null, $data[$u] );
+ if ( !$fit ) {
+ if ( $useNames ) {
+ $this->setContinueEnumParameter( 'users',
+ implode( '|', array_diff( $users, $done ) ) );
+ } else {
+ $this->setContinueEnumParameter( 'userids',
+ implode( '|', array_diff( $userids, $done ) ) );
+ }
+ break;
+ }
+ $done[] = $u;
+ }
+ $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'user' );
+ }
+
+ public function getCacheMode( $params ) {
+ if ( isset( $params['token'] ) ) {
+ return 'private';
+ } elseif ( array_diff( (array)$params['prop'], static::$publicProps ) ) {
+ return 'anon-public-user-private';
+ } else {
+ return 'public';
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'blockinfo',
+ 'groups',
+ 'groupmemberships',
+ 'implicitgroups',
+ 'rights',
+ 'editcount',
+ 'registration',
+ 'emailable',
+ 'gender',
+ 'centralids',
+ 'cancreate',
+ // When adding a prop, consider whether it should be added
+ // to self::$publicProps
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'attachedwiki' => null,
+ 'users' => [
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'userids' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'token' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ApiBase::PARAM_TYPE => array_keys( $this->getTokenFunctions() ),
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=users&ususers=Example&usprop=groups|editcount|gender'
+ => 'apihelp-query+users-example-simple',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Users';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryWatchlist.php b/www/wiki/includes/api/ApiQueryWatchlist.php
new file mode 100644
index 00000000..1e3b2c73
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryWatchlist.php
@@ -0,0 +1,512 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 25, 2006
+ *
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This query action allows clients to retrieve a list of recently modified pages
+ * that are part of the logged-in user's watchlist.
+ *
+ * @ingroup API
+ */
+class ApiQueryWatchlist extends ApiQueryGeneratorBase {
+
+ /** @var CommentStore */
+ private $commentStore;
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'wl' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ private $fld_ids = false, $fld_title = false, $fld_patrol = false,
+ $fld_flags = false, $fld_timestamp = false, $fld_user = false,
+ $fld_comment = false, $fld_parsedcomment = false, $fld_sizes = false,
+ $fld_notificationtimestamp = false, $fld_userid = false,
+ $fld_loginfo = false;
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $this->selectNamedDB( 'watchlist', DB_REPLICA, 'watchlist' );
+
+ $params = $this->extractRequestParams();
+
+ $user = $this->getUser();
+ $wlowner = $this->getWatchlistUser( $params );
+
+ if ( !is_null( $params['prop'] ) && is_null( $resultPageSet ) ) {
+ $prop = array_flip( $params['prop'] );
+
+ $this->fld_ids = isset( $prop['ids'] );
+ $this->fld_title = isset( $prop['title'] );
+ $this->fld_flags = isset( $prop['flags'] );
+ $this->fld_user = isset( $prop['user'] );
+ $this->fld_userid = isset( $prop['userid'] );
+ $this->fld_comment = isset( $prop['comment'] );
+ $this->fld_parsedcomment = isset( $prop['parsedcomment'] );
+ $this->fld_timestamp = isset( $prop['timestamp'] );
+ $this->fld_sizes = isset( $prop['sizes'] );
+ $this->fld_patrol = isset( $prop['patrol'] );
+ $this->fld_notificationtimestamp = isset( $prop['notificationtimestamp'] );
+ $this->fld_loginfo = isset( $prop['loginfo'] );
+
+ if ( $this->fld_patrol ) {
+ if ( !$user->useRCPatrol() && !$user->useNPPatrol() ) {
+ $this->dieWithError( 'apierror-permissiondenied-patrolflag', 'patrol' );
+ }
+ }
+
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ $this->commentStore = new CommentStore( 'rc_comment' );
+ }
+ }
+
+ $options = [
+ 'dir' => $params['dir'] === 'older'
+ ? WatchedItemQueryService::DIR_OLDER
+ : WatchedItemQueryService::DIR_NEWER,
+ ];
+
+ if ( is_null( $resultPageSet ) ) {
+ $options['includeFields'] = $this->getFieldsToInclude();
+ } else {
+ $options['usedInGenerator'] = true;
+ }
+
+ if ( $params['start'] ) {
+ $options['start'] = $params['start'];
+ }
+ if ( $params['end'] ) {
+ $options['end'] = $params['end'];
+ }
+
+ $startFrom = null;
+ if ( !is_null( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $continueTimestamp = $cont[0];
+ $continueId = (int)$cont[1];
+ $this->dieContinueUsageIf( $continueId != $cont[1] );
+ $startFrom = [ $continueTimestamp, $continueId ];
+ }
+
+ if ( $wlowner !== $user ) {
+ $options['watchlistOwner'] = $wlowner;
+ $options['watchlistOwnerToken'] = $params['token'];
+ }
+
+ if ( !is_null( $params['namespace'] ) ) {
+ $options['namespaceIds'] = $params['namespace'];
+ }
+
+ if ( $params['allrev'] ) {
+ $options['allRevisions'] = true;
+ }
+
+ if ( !is_null( $params['show'] ) ) {
+ $show = array_flip( $params['show'] );
+
+ /* Check for conflicting parameters. */
+ if ( $this->showParamsConflicting( $show ) ) {
+ $this->dieWithError( 'apierror-show' );
+ }
+
+ // Check permissions.
+ if ( isset( $show[WatchedItemQueryService::FILTER_PATROLLED] )
+ || isset( $show[WatchedItemQueryService::FILTER_NOT_PATROLLED] )
+ ) {
+ if ( !$user->useRCPatrol() && !$user->useNPPatrol() ) {
+ $this->dieWithError( 'apierror-permissiondenied-patrolflag', 'permissiondenied' );
+ }
+ }
+
+ $options['filters'] = array_keys( $show );
+ }
+
+ if ( !is_null( $params['type'] ) ) {
+ try {
+ $rcTypes = RecentChange::parseToRCType( $params['type'] );
+ if ( $rcTypes ) {
+ $options['rcTypes'] = $rcTypes;
+ }
+ } catch ( Exception $e ) {
+ ApiBase::dieDebug( __METHOD__, $e->getMessage() );
+ }
+ }
+
+ $this->requireMaxOneParameter( $params, 'user', 'excludeuser' );
+ if ( !is_null( $params['user'] ) ) {
+ $options['onlyByUser'] = $params['user'];
+ }
+ if ( !is_null( $params['excludeuser'] ) ) {
+ $options['notByUser'] = $params['excludeuser'];
+ }
+
+ $options['limit'] = $params['limit'];
+
+ Hooks::run( 'ApiQueryWatchlistPrepareWatchedItemQueryServiceOptions', [
+ $this, $params, &$options
+ ] );
+
+ $ids = [];
+ $count = 0;
+ $watchedItemQuery = MediaWikiServices::getInstance()->getWatchedItemQueryService();
+ $items = $watchedItemQuery->getWatchedItemsWithRecentChangeInfo( $wlowner, $options, $startFrom );
+
+ foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
+ /** @var WatchedItem $watchedItem */
+ if ( is_null( $resultPageSet ) ) {
+ $vals = $this->extractOutputData( $watchedItem, $recentChangeInfo );
+ $fit = $this->getResult()->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ if ( !$fit ) {
+ $startFrom = [ $recentChangeInfo['rc_timestamp'], $recentChangeInfo['rc_id'] ];
+ break;
+ }
+ } else {
+ if ( $params['allrev'] ) {
+ $ids[] = intval( $recentChangeInfo['rc_this_oldid'] );
+ } else {
+ $ids[] = intval( $recentChangeInfo['rc_cur_id'] );
+ }
+ }
+ }
+
+ if ( $startFrom !== null ) {
+ $this->setContinueEnumParameter( 'continue', implode( '|', $startFrom ) );
+ }
+
+ if ( is_null( $resultPageSet ) ) {
+ $this->getResult()->addIndexedTagName(
+ [ 'query', $this->getModuleName() ],
+ 'item'
+ );
+ } elseif ( $params['allrev'] ) {
+ $resultPageSet->populateFromRevisionIDs( $ids );
+ } else {
+ $resultPageSet->populateFromPageIDs( $ids );
+ }
+ }
+
+ private function getFieldsToInclude() {
+ $includeFields = [];
+ if ( $this->fld_flags ) {
+ $includeFields[] = WatchedItemQueryService::INCLUDE_FLAGS;
+ }
+ if ( $this->fld_user || $this->fld_userid ) {
+ $includeFields[] = WatchedItemQueryService::INCLUDE_USER_ID;
+ }
+ if ( $this->fld_user ) {
+ $includeFields[] = WatchedItemQueryService::INCLUDE_USER;
+ }
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ $includeFields[] = WatchedItemQueryService::INCLUDE_COMMENT;
+ }
+ if ( $this->fld_patrol ) {
+ $includeFields[] = WatchedItemQueryService::INCLUDE_PATROL_INFO;
+ }
+ if ( $this->fld_sizes ) {
+ $includeFields[] = WatchedItemQueryService::INCLUDE_SIZES;
+ }
+ if ( $this->fld_loginfo ) {
+ $includeFields[] = WatchedItemQueryService::INCLUDE_LOG_INFO;
+ }
+ return $includeFields;
+ }
+
+ private function showParamsConflicting( array $show ) {
+ return ( isset( $show[WatchedItemQueryService::FILTER_MINOR] )
+ && isset( $show[WatchedItemQueryService::FILTER_NOT_MINOR] ) )
+ || ( isset( $show[WatchedItemQueryService::FILTER_BOT] )
+ && isset( $show[WatchedItemQueryService::FILTER_NOT_BOT] ) )
+ || ( isset( $show[WatchedItemQueryService::FILTER_ANON] )
+ && isset( $show[WatchedItemQueryService::FILTER_NOT_ANON] ) )
+ || ( isset( $show[WatchedItemQueryService::FILTER_PATROLLED] )
+ && isset( $show[WatchedItemQueryService::FILTER_NOT_PATROLLED] ) )
+ || ( isset( $show[WatchedItemQueryService::FILTER_UNREAD] )
+ && isset( $show[WatchedItemQueryService::FILTER_NOT_UNREAD] ) );
+ }
+
+ private function extractOutputData( WatchedItem $watchedItem, array $recentChangeInfo ) {
+ /* Determine the title of the page that has been changed. */
+ $title = Title::newFromLinkTarget( $watchedItem->getLinkTarget() );
+ $user = $this->getUser();
+
+ /* Our output data. */
+ $vals = [];
+ $type = intval( $recentChangeInfo['rc_type'] );
+ $vals['type'] = RecentChange::parseFromRCType( $type );
+ $anyHidden = false;
+
+ /* Create a new entry in the result for the title. */
+ if ( $this->fld_title || $this->fld_ids ) {
+ // These should already have been filtered out of the query, but just in case.
+ if ( $type === RC_LOG && ( $recentChangeInfo['rc_deleted'] & LogPage::DELETED_ACTION ) ) {
+ $vals['actionhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( $type !== RC_LOG ||
+ LogEventsList::userCanBitfield(
+ $recentChangeInfo['rc_deleted'],
+ LogPage::DELETED_ACTION,
+ $user
+ )
+ ) {
+ if ( $this->fld_title ) {
+ ApiQueryBase::addTitleInfo( $vals, $title );
+ }
+ if ( $this->fld_ids ) {
+ $vals['pageid'] = intval( $recentChangeInfo['rc_cur_id'] );
+ $vals['revid'] = intval( $recentChangeInfo['rc_this_oldid'] );
+ $vals['old_revid'] = intval( $recentChangeInfo['rc_last_oldid'] );
+ }
+ }
+ }
+
+ /* Add user data and 'anon' flag, if user is anonymous. */
+ if ( $this->fld_user || $this->fld_userid ) {
+ if ( $recentChangeInfo['rc_deleted'] & Revision::DELETED_USER ) {
+ $vals['userhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield(
+ $recentChangeInfo['rc_deleted'],
+ Revision::DELETED_USER,
+ $user
+ ) ) {
+ if ( $this->fld_userid ) {
+ $vals['userid'] = (int)$recentChangeInfo['rc_user'];
+ // for backwards compatibility
+ $vals['user'] = (int)$recentChangeInfo['rc_user'];
+ }
+
+ if ( $this->fld_user ) {
+ $vals['user'] = $recentChangeInfo['rc_user_text'];
+ }
+
+ if ( !$recentChangeInfo['rc_user'] ) {
+ $vals['anon'] = true;
+ }
+ }
+ }
+
+ /* Add flags, such as new, minor, bot. */
+ if ( $this->fld_flags ) {
+ $vals['bot'] = (bool)$recentChangeInfo['rc_bot'];
+ $vals['new'] = $recentChangeInfo['rc_type'] == RC_NEW;
+ $vals['minor'] = (bool)$recentChangeInfo['rc_minor'];
+ }
+
+ /* Add sizes of each revision. (Only available on 1.10+) */
+ if ( $this->fld_sizes ) {
+ $vals['oldlen'] = intval( $recentChangeInfo['rc_old_len'] );
+ $vals['newlen'] = intval( $recentChangeInfo['rc_new_len'] );
+ }
+
+ /* Add the timestamp. */
+ if ( $this->fld_timestamp ) {
+ $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $recentChangeInfo['rc_timestamp'] );
+ }
+
+ if ( $this->fld_notificationtimestamp ) {
+ $vals['notificationtimestamp'] = ( $watchedItem->getNotificationTimestamp() == null )
+ ? ''
+ : wfTimestamp( TS_ISO_8601, $watchedItem->getNotificationTimestamp() );
+ }
+
+ /* Add edit summary / log summary. */
+ if ( $this->fld_comment || $this->fld_parsedcomment ) {
+ if ( $recentChangeInfo['rc_deleted'] & Revision::DELETED_COMMENT ) {
+ $vals['commenthidden'] = true;
+ $anyHidden = true;
+ }
+ if ( Revision::userCanBitfield(
+ $recentChangeInfo['rc_deleted'],
+ Revision::DELETED_COMMENT,
+ $user
+ ) ) {
+ $comment = $this->commentStore->getComment( $recentChangeInfo )->text;
+ if ( $this->fld_comment ) {
+ $vals['comment'] = $comment;
+ }
+
+ if ( $this->fld_parsedcomment ) {
+ $vals['parsedcomment'] = Linker::formatComment( $comment, $title );
+ }
+ }
+ }
+
+ /* Add the patrolled flag */
+ if ( $this->fld_patrol ) {
+ $vals['patrolled'] = $recentChangeInfo['rc_patrolled'] == 1;
+ $vals['unpatrolled'] = ChangesList::isUnpatrolled( (object)$recentChangeInfo, $user );
+ }
+
+ if ( $this->fld_loginfo && $recentChangeInfo['rc_type'] == RC_LOG ) {
+ if ( $recentChangeInfo['rc_deleted'] & LogPage::DELETED_ACTION ) {
+ $vals['actionhidden'] = true;
+ $anyHidden = true;
+ }
+ if ( LogEventsList::userCanBitfield(
+ $recentChangeInfo['rc_deleted'],
+ LogPage::DELETED_ACTION,
+ $user
+ ) ) {
+ $vals['logid'] = intval( $recentChangeInfo['rc_logid'] );
+ $vals['logtype'] = $recentChangeInfo['rc_log_type'];
+ $vals['logaction'] = $recentChangeInfo['rc_log_action'];
+ $vals['logparams'] = LogFormatter::newFromRow( $recentChangeInfo )->formatParametersForApi();
+ }
+ }
+
+ if ( $anyHidden && ( $recentChangeInfo['rc_deleted'] & Revision::DELETED_RESTRICTED ) ) {
+ $vals['suppressed'] = true;
+ }
+
+ Hooks::run( 'ApiQueryWatchlistExtractOutputData', [
+ $this, $watchedItem, $recentChangeInfo, &$vals
+ ] );
+
+ return $vals;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'allrev' => false,
+ 'start' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'end' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace'
+ ],
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'excludeuser' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'older',
+ ApiBase::PARAM_TYPE => [
+ 'newer',
+ 'older'
+ ],
+ ApiHelp::PARAM_HELP_MSG => 'api-help-param-direction',
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'ids|title|flags',
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ApiBase::PARAM_TYPE => [
+ 'ids',
+ 'title',
+ 'flags',
+ 'user',
+ 'userid',
+ 'comment',
+ 'parsedcomment',
+ 'timestamp',
+ 'patrol',
+ 'sizes',
+ 'notificationtimestamp',
+ 'loginfo',
+ ]
+ ],
+ 'show' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ WatchedItemQueryService::FILTER_MINOR,
+ WatchedItemQueryService::FILTER_NOT_MINOR,
+ WatchedItemQueryService::FILTER_BOT,
+ WatchedItemQueryService::FILTER_NOT_BOT,
+ WatchedItemQueryService::FILTER_ANON,
+ WatchedItemQueryService::FILTER_NOT_ANON,
+ WatchedItemQueryService::FILTER_PATROLLED,
+ WatchedItemQueryService::FILTER_NOT_PATROLLED,
+ WatchedItemQueryService::FILTER_UNREAD,
+ WatchedItemQueryService::FILTER_NOT_UNREAD,
+ ]
+ ],
+ 'type' => [
+ ApiBase::PARAM_DFLT => 'edit|new|log|categorize',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ApiBase::PARAM_TYPE => RecentChange::getChangeTypes()
+ ],
+ 'owner' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'token' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_SENSITIVE => true,
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=watchlist'
+ => 'apihelp-query+watchlist-example-simple',
+ 'action=query&list=watchlist&wlprop=ids|title|timestamp|user|comment'
+ => 'apihelp-query+watchlist-example-props',
+ 'action=query&list=watchlist&wlallrev=&wlprop=ids|title|timestamp|user|comment'
+ => 'apihelp-query+watchlist-example-allrev',
+ 'action=query&generator=watchlist&prop=info'
+ => 'apihelp-query+watchlist-example-generator',
+ 'action=query&generator=watchlist&gwlallrev=&prop=revisions&rvprop=timestamp|user'
+ => 'apihelp-query+watchlist-example-generator-rev',
+ 'action=query&list=watchlist&wlowner=Example&wltoken=123ABC'
+ => 'apihelp-query+watchlist-example-wlowner',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Watchlist';
+ }
+}
diff --git a/www/wiki/includes/api/ApiQueryWatchlistRaw.php b/www/wiki/includes/api/ApiQueryWatchlistRaw.php
new file mode 100644
index 00000000..b0b1cde9
--- /dev/null
+++ b/www/wiki/includes/api/ApiQueryWatchlistRaw.php
@@ -0,0 +1,204 @@
+<?php
+/**
+ *
+ *
+ * Created on Oct 4, 2008
+ *
+ * Copyright © 2008 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This query action allows clients to retrieve a list of pages
+ * on the logged-in user's watchlist.
+ *
+ * @ingroup API
+ */
+class ApiQueryWatchlistRaw extends ApiQueryGeneratorBase {
+
+ public function __construct( ApiQuery $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'wr' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param ApiPageSet $resultPageSet
+ * @return void
+ */
+ private function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+
+ $user = $this->getWatchlistUser( $params );
+
+ $prop = array_flip( (array)$params['prop'] );
+ $show = array_flip( (array)$params['show'] );
+ if ( isset( $show[WatchedItemQueryService::FILTER_CHANGED] )
+ && isset( $show[WatchedItemQueryService::FILTER_NOT_CHANGED] )
+ ) {
+ $this->dieWithError( 'apierror-show' );
+ }
+
+ $options = [];
+ if ( $params['namespace'] ) {
+ $options['namespaceIds'] = $params['namespace'];
+ }
+ if ( isset( $show[WatchedItemQueryService::FILTER_CHANGED] ) ) {
+ $options['filter'] = WatchedItemQueryService::FILTER_CHANGED;
+ }
+ if ( isset( $show[WatchedItemQueryService::FILTER_NOT_CHANGED] ) ) {
+ $options['filter'] = WatchedItemQueryService::FILTER_NOT_CHANGED;
+ }
+
+ if ( isset( $params['continue'] ) ) {
+ $cont = explode( '|', $params['continue'] );
+ $this->dieContinueUsageIf( count( $cont ) != 2 );
+ $ns = intval( $cont[0] );
+ $this->dieContinueUsageIf( strval( $ns ) !== $cont[0] );
+ $title = $cont[1];
+ $options['startFrom'] = new TitleValue( $ns, $title );
+ }
+
+ if ( isset( $params['fromtitle'] ) ) {
+ list( $ns, $title ) = $this->prefixedTitlePartToKey( $params['fromtitle'] );
+ $options['from'] = new TitleValue( $ns, $title );
+ }
+
+ if ( isset( $params['totitle'] ) ) {
+ list( $ns, $title ) = $this->prefixedTitlePartToKey( $params['totitle'] );
+ $options['until'] = new TitleValue( $ns, $title );
+ }
+
+ $options['sort'] = WatchedItemStore::SORT_ASC;
+ if ( $params['dir'] === 'descending' ) {
+ $options['sort'] = WatchedItemStore::SORT_DESC;
+ }
+ $options['limit'] = $params['limit'] + 1;
+
+ $titles = [];
+ $count = 0;
+ $items = MediaWikiServices::getInstance()->getWatchedItemQueryService()
+ ->getWatchedItemsForUser( $user, $options );
+ foreach ( $items as $item ) {
+ $ns = $item->getLinkTarget()->getNamespace();
+ $dbKey = $item->getLinkTarget()->getDBkey();
+ if ( ++$count > $params['limit'] ) {
+ // We've reached the one extra which shows that there are
+ // additional pages to be had. Stop here...
+ $this->setContinueEnumParameter( 'continue', $ns . '|' . $dbKey );
+ break;
+ }
+ $t = Title::makeTitle( $ns, $dbKey );
+
+ if ( is_null( $resultPageSet ) ) {
+ $vals = [];
+ ApiQueryBase::addTitleInfo( $vals, $t );
+ if ( isset( $prop['changed'] ) && !is_null( $item->getNotificationTimestamp() ) ) {
+ $vals['changed'] = wfTimestamp( TS_ISO_8601, $item->getNotificationTimestamp() );
+ }
+ $fit = $this->getResult()->addValue( $this->getModuleName(), null, $vals );
+ if ( !$fit ) {
+ $this->setContinueEnumParameter( 'continue', $ns . '|' . $dbKey );
+ break;
+ }
+ } else {
+ $titles[] = $t;
+ }
+ }
+ if ( is_null( $resultPageSet ) ) {
+ $this->getResult()->addIndexedTagName( $this->getModuleName(), 'wr' );
+ } else {
+ $resultPageSet->populateFromTitles( $titles );
+ }
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace'
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+ ],
+ 'prop' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ 'changed',
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'show' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [
+ WatchedItemQueryService::FILTER_CHANGED,
+ WatchedItemQueryService::FILTER_NOT_CHANGED
+ ]
+ ],
+ 'owner' => [
+ ApiBase::PARAM_TYPE => 'user'
+ ],
+ 'token' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_SENSITIVE => true,
+ ],
+ 'dir' => [
+ ApiBase::PARAM_DFLT => 'ascending',
+ ApiBase::PARAM_TYPE => [
+ 'ascending',
+ 'descending'
+ ],
+ ],
+ 'fromtitle' => [
+ ApiBase::PARAM_TYPE => 'string'
+ ],
+ 'totitle' => [
+ ApiBase::PARAM_TYPE => 'string'
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&list=watchlistraw'
+ => 'apihelp-query+watchlistraw-example-simple',
+ 'action=query&generator=watchlistraw&gwrshow=changed&prop=info'
+ => 'apihelp-query+watchlistraw-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Watchlistraw';
+ }
+}
diff --git a/www/wiki/includes/api/ApiRemoveAuthenticationData.php b/www/wiki/includes/api/ApiRemoveAuthenticationData.php
new file mode 100644
index 00000000..e18484be
--- /dev/null
+++ b/www/wiki/includes/api/ApiRemoveAuthenticationData.php
@@ -0,0 +1,111 @@
+<?php
+/**
+ * Copyright © 2016 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Remove authentication data from AuthManager
+ *
+ * @ingroup API
+ */
+class ApiRemoveAuthenticationData extends ApiBase {
+
+ private $authAction;
+ private $operation;
+
+ public function __construct( ApiMain $main, $action ) {
+ parent::__construct( $main, $action );
+
+ $this->authAction = $action === 'unlinkaccount'
+ ? AuthManager::ACTION_UNLINK
+ : AuthManager::ACTION_REMOVE;
+ $this->operation = $action === 'unlinkaccount'
+ ? 'UnlinkAccount'
+ : 'RemoveCredentials';
+ }
+
+ public function execute() {
+ if ( !$this->getUser()->isLoggedIn() ) {
+ $this->dieWithError( 'apierror-mustbeloggedin-removeauth', 'notloggedin' );
+ }
+
+ $params = $this->extractRequestParams();
+ $manager = AuthManager::singleton();
+
+ // Check security-sensitive operation status
+ ApiAuthManagerHelper::newForModule( $this )->securitySensitiveOperation( $this->operation );
+
+ // Fetch the request. No need to load from the request, so don't use
+ // ApiAuthManagerHelper's method.
+ $blacklist = $this->authAction === AuthManager::ACTION_REMOVE
+ ? array_flip( $this->getConfig()->get( 'RemoveCredentialsBlacklist' ) )
+ : [];
+ $reqs = array_filter(
+ $manager->getAuthenticationRequests( $this->authAction, $this->getUser() ),
+ function ( $req ) use ( $params, $blacklist ) {
+ return $req->getUniqueId() === $params['request'] &&
+ !isset( $blacklist[get_class( $req )] );
+ }
+ );
+ if ( count( $reqs ) !== 1 ) {
+ $this->dieWithError( 'apierror-changeauth-norequest', 'badrequest' );
+ }
+ $req = reset( $reqs );
+
+ // Perform the removal
+ $status = $manager->allowsAuthenticationDataChange( $req, true );
+ Hooks::run( 'ChangeAuthenticationDataAudit', [ $req, $status ] );
+ if ( !$status->isGood() ) {
+ $this->dieStatus( $status );
+ }
+ $manager->changeAuthenticationData( $req );
+
+ $this->getResult()->addValue( null, $this->getModuleName(), [ 'status' => 'success' ] );
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ public function getAllowedParams() {
+ return ApiAuthManagerHelper::getStandardParams( $this->authAction,
+ 'request'
+ );
+ }
+
+ protected function getExamplesMessages() {
+ $path = $this->getModulePath();
+ $action = $this->getModuleName();
+ return [
+ "action={$action}&request=FooAuthenticationRequest&token=123ABC"
+ => "apihelp-{$path}-example-simple",
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Manage_authentication_data';
+ }
+}
diff --git a/www/wiki/includes/api/ApiResetPassword.php b/www/wiki/includes/api/ApiResetPassword.php
new file mode 100644
index 00000000..77838269
--- /dev/null
+++ b/www/wiki/includes/api/ApiResetPassword.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Copyright © 2016 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Reset password, with AuthManager
+ *
+ * @ingroup API
+ */
+class ApiResetPassword extends ApiBase {
+
+ private $hasAnyRoutes = null;
+
+ /**
+ * Determine whether any reset routes are available.
+ * @return bool
+ */
+ private function hasAnyRoutes() {
+ if ( $this->hasAnyRoutes === null ) {
+ $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+ $this->hasAnyRoutes = !empty( $resetRoutes['username'] ) || !empty( $resetRoutes['email'] );
+ }
+ return $this->hasAnyRoutes;
+ }
+
+ protected function getExtendedDescription() {
+ if ( !$this->hasAnyRoutes() ) {
+ return 'apihelp-resetpassword-extended-description-noroutes';
+ }
+ return parent::getExtendedDescription();
+ }
+
+ public function execute() {
+ if ( !$this->hasAnyRoutes() ) {
+ $this->dieWithError( 'apihelp-resetpassword-description-noroutes', 'moduledisabled' );
+ }
+
+ $params = $this->extractRequestParams() + [
+ // Make sure the keys exist even if getAllowedParams didn't define them
+ 'user' => null,
+ 'email' => null,
+ ];
+
+ $this->requireOnlyOneParameter( $params, 'user', 'email' );
+
+ $passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
+
+ $status = $passwordReset->isAllowed( $this->getUser() );
+ if ( !$status->isOK() ) {
+ $this->dieStatus( Status::wrap( $status ) );
+ }
+
+ $status = $passwordReset->execute(
+ $this->getUser(), $params['user'], $params['email']
+ );
+ if ( !$status->isOK() ) {
+ $status->value = null;
+ $this->dieStatus( Status::wrap( $status ) );
+ }
+
+ $result = $this->getResult();
+ $result->addValue( [ 'resetpassword' ], 'status', 'success' );
+ }
+
+ public function isWriteMode() {
+ return $this->hasAnyRoutes();
+ }
+
+ public function needsToken() {
+ if ( !$this->hasAnyRoutes() ) {
+ return false;
+ }
+ return 'csrf';
+ }
+
+ public function getAllowedParams() {
+ if ( !$this->hasAnyRoutes() ) {
+ return [];
+ }
+
+ $ret = [
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'email' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ ];
+
+ $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+ if ( empty( $resetRoutes['username'] ) ) {
+ unset( $ret['user'] );
+ }
+ if ( empty( $resetRoutes['email'] ) ) {
+ unset( $ret['email'] );
+ }
+
+ return $ret;
+ }
+
+ protected function getExamplesMessages() {
+ $ret = [];
+ $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+
+ if ( !empty( $resetRoutes['username'] ) ) {
+ $ret['action=resetpassword&user=Example&token=123ABC'] = 'apihelp-resetpassword-example-user';
+ }
+ if ( !empty( $resetRoutes['email'] ) ) {
+ $ret['action=resetpassword&user=user@example.com&token=123ABC'] =
+ 'apihelp-resetpassword-example-email';
+ }
+
+ return $ret;
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Manage_authentication_data';
+ }
+}
diff --git a/www/wiki/includes/api/ApiResult.php b/www/wiki/includes/api/ApiResult.php
new file mode 100644
index 00000000..468d8783
--- /dev/null
+++ b/www/wiki/includes/api/ApiResult.php
@@ -0,0 +1,1229 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This class represents the result of the API operations.
+ * It simply wraps a nested array structure, adding some functions to simplify
+ * array's modifications. As various modules execute, they add different pieces
+ * of information to this result, structuring it as it will be given to the client.
+ *
+ * Each subarray may either be a dictionary - key-value pairs with unique keys,
+ * or lists, where the items are added using $data[] = $value notation.
+ *
+ * @since 1.25 this is no longer a subclass of ApiBase
+ * @ingroup API
+ */
+class ApiResult implements ApiSerializable {
+
+ /**
+ * Override existing value in addValue(), setValue(), and similar functions
+ * @since 1.21
+ */
+ const OVERRIDE = 1;
+
+ /**
+ * For addValue(), setValue() and similar functions, if the value does not
+ * exist, add it as the first element. In case the new value has no name
+ * (numerical index), all indexes will be renumbered.
+ * @since 1.21
+ */
+ const ADD_ON_TOP = 2;
+
+ /**
+ * For addValue() and similar functions, do not check size while adding a value
+ * Don't use this unless you REALLY know what you're doing.
+ * Values added while the size checking was disabled will never be counted.
+ * Ignored for setValue() and similar functions.
+ * @since 1.24
+ */
+ const NO_SIZE_CHECK = 4;
+
+ /**
+ * For addValue(), setValue() and similar functions, do not validate data.
+ * Also disables size checking. If you think you need to use this, you're
+ * probably wrong.
+ * @since 1.25
+ */
+ const NO_VALIDATE = 12;
+
+ /**
+ * Key for the 'indexed tag name' metadata item. Value is string.
+ * @since 1.25
+ */
+ const META_INDEXED_TAG_NAME = '_element';
+
+ /**
+ * Key for the 'subelements' metadata item. Value is string[].
+ * @since 1.25
+ */
+ const META_SUBELEMENTS = '_subelements';
+
+ /**
+ * Key for the 'preserve keys' metadata item. Value is string[].
+ * @since 1.25
+ */
+ const META_PRESERVE_KEYS = '_preservekeys';
+
+ /**
+ * Key for the 'content' metadata item. Value is string.
+ * @since 1.25
+ */
+ const META_CONTENT = '_content';
+
+ /**
+ * Key for the 'type' metadata item. Value is one of the following strings:
+ * - default: Like 'array' if all (non-metadata) keys are numeric with no
+ * gaps, otherwise like 'assoc'.
+ * - array: Keys are used for ordering, but are not output. In a format
+ * like JSON, outputs as [].
+ * - assoc: In a format like JSON, outputs as {}.
+ * - kvp: For a format like XML where object keys have a restricted
+ * character set, use an alternative output format. For example,
+ * <container><item name="key">value</item></container> rather than
+ * <container key="value" />
+ * - BCarray: Like 'array' normally, 'default' in backwards-compatibility mode.
+ * - BCassoc: Like 'assoc' normally, 'default' in backwards-compatibility mode.
+ * - BCkvp: Like 'kvp' normally. In backwards-compatibility mode, forces
+ * the alternative output format for all formats, for example
+ * [{"name":key,"*":value}] in JSON. META_KVP_KEY_NAME must also be set.
+ * @since 1.25
+ */
+ const META_TYPE = '_type';
+
+ /**
+ * Key for the metadata item whose value specifies the name used for the
+ * kvp key in the alternative output format with META_TYPE 'kvp' or
+ * 'BCkvp', i.e. the "name" in <container><item name="key">value</item></container>.
+ * Value is string.
+ * @since 1.25
+ */
+ const META_KVP_KEY_NAME = '_kvpkeyname';
+
+ /**
+ * Key for the metadata item that indicates that the KVP key should be
+ * added into an assoc value, i.e. {"key":{"val1":"a","val2":"b"}}
+ * transforms to {"name":"key","val1":"a","val2":"b"} rather than
+ * {"name":"key","value":{"val1":"a","val2":"b"}}.
+ * Value is boolean.
+ * @since 1.26
+ */
+ const META_KVP_MERGE = '_kvpmerge';
+
+ /**
+ * Key for the 'BC bools' metadata item. Value is string[].
+ * Note no setter is provided.
+ * @since 1.25
+ */
+ const META_BC_BOOLS = '_BC_bools';
+
+ /**
+ * Key for the 'BC subelements' metadata item. Value is string[].
+ * Note no setter is provided.
+ * @since 1.25
+ */
+ const META_BC_SUBELEMENTS = '_BC_subelements';
+
+ private $data, $size, $maxSize;
+ private $errorFormatter;
+
+ // Deprecated fields
+ private $checkingSize, $mainForContinuation;
+
+ /**
+ * @param int|bool $maxSize Maximum result "size", or false for no limit
+ * @since 1.25 Takes an integer|bool rather than an ApiMain
+ */
+ public function __construct( $maxSize ) {
+ if ( $maxSize instanceof ApiMain ) {
+ wfDeprecated( 'ApiMain to ' . __METHOD__, '1.25' );
+ $this->errorFormatter = $maxSize->getErrorFormatter();
+ $this->mainForContinuation = $maxSize;
+ $maxSize = $maxSize->getConfig()->get( 'APIMaxResultSize' );
+ }
+
+ $this->maxSize = $maxSize;
+ $this->checkingSize = true;
+ $this->reset();
+ }
+
+ /**
+ * Set the error formatter
+ * @since 1.25
+ * @param ApiErrorFormatter $formatter
+ */
+ public function setErrorFormatter( ApiErrorFormatter $formatter ) {
+ $this->errorFormatter = $formatter;
+ }
+
+ /**
+ * Allow for adding one ApiResult into another
+ * @since 1.25
+ * @return mixed
+ */
+ public function serializeForApiResult() {
+ return $this->data;
+ }
+
+ /************************************************************************//**
+ * @name Content
+ * @{
+ */
+
+ /**
+ * Clear the current result data.
+ */
+ public function reset() {
+ $this->data = [
+ self::META_TYPE => 'assoc', // Usually what's desired
+ ];
+ $this->size = 0;
+ }
+
+ /**
+ * Get the result data array
+ *
+ * The returned value should be considered read-only.
+ *
+ * Transformations include:
+ *
+ * Custom: (callable) Applied before other transformations. Signature is
+ * function ( &$data, &$metadata ), return value is ignored. Called for
+ * each nested array.
+ *
+ * BC: (array) This transformation does various adjustments to bring the
+ * output in line with the pre-1.25 result format. The value array is a
+ * list of flags: 'nobool', 'no*', 'nosub'.
+ * - Boolean-valued items are changed to '' if true or removed if false,
+ * unless listed in META_BC_BOOLS. This may be skipped by including
+ * 'nobool' in the value array.
+ * - The tag named by META_CONTENT is renamed to '*', and META_CONTENT is
+ * set to '*'. This may be skipped by including 'no*' in the value
+ * array.
+ * - Tags listed in META_BC_SUBELEMENTS will have their values changed to
+ * [ '*' => $value ]. This may be skipped by including 'nosub' in
+ * the value array.
+ * - If META_TYPE is 'BCarray', set it to 'default'
+ * - If META_TYPE is 'BCassoc', set it to 'default'
+ * - If META_TYPE is 'BCkvp', perform the transformation (even if
+ * the Types transformation is not being applied).
+ *
+ * Types: (assoc) Apply transformations based on META_TYPE. The values
+ * array is an associative array with the following possible keys:
+ * - AssocAsObject: (bool) If true, return arrays with META_TYPE 'assoc'
+ * as objects.
+ * - ArmorKVP: (string) If provided, transform arrays with META_TYPE 'kvp'
+ * and 'BCkvp' into arrays of two-element arrays, something like this:
+ * $output = [];
+ * foreach ( $input as $key => $value ) {
+ * $pair = [];
+ * $pair[$META_KVP_KEY_NAME ?: $ArmorKVP_value] = $key;
+ * ApiResult::setContentValue( $pair, 'value', $value );
+ * $output[] = $pair;
+ * }
+ *
+ * Strip: (string) Strips metadata keys from the result.
+ * - 'all': Strip all metadata, recursively
+ * - 'base': Strip metadata at the top-level only.
+ * - 'none': Do not strip metadata.
+ * - 'bc': Like 'all', but leave certain pre-1.25 keys.
+ *
+ * @since 1.25
+ * @param array|string|null $path Path to fetch, see ApiResult::addValue
+ * @param array $transforms See above
+ * @return mixed Result data, or null if not found
+ */
+ public function getResultData( $path = [], $transforms = [] ) {
+ $path = (array)$path;
+ if ( !$path ) {
+ return self::applyTransformations( $this->data, $transforms );
+ }
+
+ $last = array_pop( $path );
+ $ret = &$this->path( $path, 'dummy' );
+ if ( !isset( $ret[$last] ) ) {
+ return null;
+ } elseif ( is_array( $ret[$last] ) ) {
+ return self::applyTransformations( $ret[$last], $transforms );
+ } else {
+ return $ret[$last];
+ }
+ }
+
+ /**
+ * Get the size of the result, i.e. the amount of bytes in it
+ * @return int
+ */
+ public function getSize() {
+ return $this->size;
+ }
+
+ /**
+ * Add an output value to the array by name.
+ *
+ * Verifies that value with the same name has not been added before.
+ *
+ * @since 1.25
+ * @param array &$arr To add $value to
+ * @param string|int|null $name Index of $arr to add $value at,
+ * or null to use the next numeric index.
+ * @param mixed $value
+ * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
+ */
+ public static function setValue( array &$arr, $name, $value, $flags = 0 ) {
+ if ( ( $flags & self::NO_VALIDATE ) !== self::NO_VALIDATE ) {
+ $value = self::validateValue( $value );
+ }
+
+ if ( $name === null ) {
+ if ( $flags & self::ADD_ON_TOP ) {
+ array_unshift( $arr, $value );
+ } else {
+ array_push( $arr, $value );
+ }
+ return;
+ }
+
+ $exists = isset( $arr[$name] );
+ if ( !$exists || ( $flags & self::OVERRIDE ) ) {
+ if ( !$exists && ( $flags & self::ADD_ON_TOP ) ) {
+ $arr = [ $name => $value ] + $arr;
+ } else {
+ $arr[$name] = $value;
+ }
+ } elseif ( is_array( $arr[$name] ) && is_array( $value ) ) {
+ $conflicts = array_intersect_key( $arr[$name], $value );
+ if ( !$conflicts ) {
+ $arr[$name] += $value;
+ } else {
+ $keys = implode( ', ', array_keys( $conflicts ) );
+ throw new RuntimeException(
+ "Conflicting keys ($keys) when attempting to merge element $name"
+ );
+ }
+ } else {
+ throw new RuntimeException(
+ "Attempting to add element $name=$value, existing value is {$arr[$name]}"
+ );
+ }
+ }
+
+ /**
+ * Validate a value for addition to the result
+ * @param mixed $value
+ * @return array|mixed|string
+ */
+ private static function validateValue( $value ) {
+ global $wgContLang;
+
+ if ( is_object( $value ) ) {
+ // Note we use is_callable() here instead of instanceof because
+ // ApiSerializable is an informal protocol (see docs there for details).
+ if ( is_callable( [ $value, 'serializeForApiResult' ] ) ) {
+ $oldValue = $value;
+ $value = $value->serializeForApiResult();
+ if ( is_object( $value ) ) {
+ throw new UnexpectedValueException(
+ get_class( $oldValue ) . '::serializeForApiResult() returned an object of class ' .
+ get_class( $value )
+ );
+ }
+
+ // Recursive call instead of fall-through so we can throw a
+ // better exception message.
+ try {
+ return self::validateValue( $value );
+ } catch ( Exception $ex ) {
+ throw new UnexpectedValueException(
+ get_class( $oldValue ) . '::serializeForApiResult() returned an invalid value: ' .
+ $ex->getMessage(),
+ 0,
+ $ex
+ );
+ }
+ } elseif ( is_callable( [ $value, '__toString' ] ) ) {
+ $value = (string)$value;
+ } else {
+ $value = (array)$value + [ self::META_TYPE => 'assoc' ];
+ }
+ }
+ if ( is_array( $value ) ) {
+ // Work around https://bugs.php.net/bug.php?id=45959 by copying to a temporary
+ // (in this case, foreach gets $k === "1" but $tmp[$k] assigns as if $k === 1)
+ $tmp = [];
+ foreach ( $value as $k => $v ) {
+ $tmp[$k] = self::validateValue( $v );
+ }
+ $value = $tmp;
+ } elseif ( is_float( $value ) && !is_finite( $value ) ) {
+ throw new InvalidArgumentException( 'Cannot add non-finite floats to ApiResult' );
+ } elseif ( is_string( $value ) ) {
+ $value = $wgContLang->normalize( $value );
+ } elseif ( $value !== null && !is_scalar( $value ) ) {
+ $type = gettype( $value );
+ if ( is_resource( $value ) ) {
+ $type .= '(' . get_resource_type( $value ) . ')';
+ }
+ throw new InvalidArgumentException( "Cannot add $type to ApiResult" );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Add value to the output data at the given path.
+ *
+ * Path can be an indexed array, each element specifying the branch at which to add the new
+ * value. Setting $path to [ 'a', 'b', 'c' ] is equivalent to data['a']['b']['c'] = $value.
+ * If $path is null, the value will be inserted at the data root.
+ *
+ * @param array|string|int|null $path
+ * @param string|int|null $name See ApiResult::setValue()
+ * @param mixed $value
+ * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
+ * This parameter used to be boolean, and the value of OVERRIDE=1 was specifically
+ * chosen so that it would be backwards compatible with the new method signature.
+ * @return bool True if $value fits in the result, false if not
+ * @since 1.21 int $flags replaced boolean $override
+ */
+ public function addValue( $path, $name, $value, $flags = 0 ) {
+ $arr = &$this->path( $path, ( $flags & self::ADD_ON_TOP ) ? 'prepend' : 'append' );
+
+ if ( $this->checkingSize && !( $flags & self::NO_SIZE_CHECK ) ) {
+ // self::size needs the validated value. Then flag
+ // to not re-validate later.
+ $value = self::validateValue( $value );
+ $flags |= self::NO_VALIDATE;
+
+ $newsize = $this->size + self::size( $value );
+ if ( $this->maxSize !== false && $newsize > $this->maxSize ) {
+ $this->errorFormatter->addWarning(
+ 'result', [ 'apiwarn-truncatedresult', Message::numParam( $this->maxSize ) ]
+ );
+ return false;
+ }
+ $this->size = $newsize;
+ }
+
+ self::setValue( $arr, $name, $value, $flags );
+ return true;
+ }
+
+ /**
+ * Remove an output value to the array by name.
+ * @param array &$arr To remove $value from
+ * @param string|int $name Index of $arr to remove
+ * @return mixed Old value, or null
+ */
+ public static function unsetValue( array &$arr, $name ) {
+ $ret = null;
+ if ( isset( $arr[$name] ) ) {
+ $ret = $arr[$name];
+ unset( $arr[$name] );
+ }
+ return $ret;
+ }
+
+ /**
+ * Remove value from the output data at the given path.
+ *
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param string|int|null $name Index to remove at $path.
+ * If null, $path itself is removed.
+ * @param int $flags Flags used when adding the value
+ * @return mixed Old value, or null
+ */
+ public function removeValue( $path, $name, $flags = 0 ) {
+ $path = (array)$path;
+ if ( $name === null ) {
+ if ( !$path ) {
+ throw new InvalidArgumentException( 'Cannot remove the data root' );
+ }
+ $name = array_pop( $path );
+ }
+ $ret = self::unsetValue( $this->path( $path, 'dummy' ), $name );
+ if ( $this->checkingSize && !( $flags & self::NO_SIZE_CHECK ) ) {
+ $newsize = $this->size - self::size( $ret );
+ $this->size = max( $newsize, 0 );
+ }
+ return $ret;
+ }
+
+ /**
+ * Add an output value to the array by name and mark as META_CONTENT.
+ *
+ * @since 1.25
+ * @param array &$arr To add $value to
+ * @param string|int $name Index of $arr to add $value at.
+ * @param mixed $value
+ * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
+ */
+ public static function setContentValue( array &$arr, $name, $value, $flags = 0 ) {
+ if ( $name === null ) {
+ throw new InvalidArgumentException( 'Content value must be named' );
+ }
+ self::setContentField( $arr, $name, $flags );
+ self::setValue( $arr, $name, $value, $flags );
+ }
+
+ /**
+ * Add value to the output data at the given path and mark as META_CONTENT
+ *
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param string|int $name See ApiResult::setValue()
+ * @param mixed $value
+ * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
+ * @return bool True if $value fits in the result, false if not
+ */
+ public function addContentValue( $path, $name, $value, $flags = 0 ) {
+ if ( $name === null ) {
+ throw new InvalidArgumentException( 'Content value must be named' );
+ }
+ $this->addContentField( $path, $name, $flags );
+ $this->addValue( $path, $name, $value, $flags );
+ }
+
+ /**
+ * Add the numeric limit for a limit=max to the result.
+ *
+ * @since 1.25
+ * @param string $moduleName
+ * @param int $limit
+ */
+ public function addParsedLimit( $moduleName, $limit ) {
+ // Add value, allowing overwriting
+ $this->addValue( 'limits', $moduleName, $limit,
+ self::OVERRIDE | self::NO_SIZE_CHECK );
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Metadata
+ * @{
+ */
+
+ /**
+ * Set the name of the content field name (META_CONTENT)
+ *
+ * @since 1.25
+ * @param array &$arr
+ * @param string|int $name Name of the field
+ * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
+ */
+ public static function setContentField( array &$arr, $name, $flags = 0 ) {
+ if ( isset( $arr[self::META_CONTENT] ) &&
+ isset( $arr[$arr[self::META_CONTENT]] ) &&
+ !( $flags & self::OVERRIDE )
+ ) {
+ throw new RuntimeException(
+ "Attempting to set content element as $name when " . $arr[self::META_CONTENT] .
+ ' is already set as the content element'
+ );
+ }
+ $arr[self::META_CONTENT] = $name;
+ }
+
+ /**
+ * Set the name of the content field name (META_CONTENT)
+ *
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param string|int $name Name of the field
+ * @param int $flags Zero or more OR-ed flags like OVERRIDE | ADD_ON_TOP.
+ */
+ public function addContentField( $path, $name, $flags = 0 ) {
+ $arr = &$this->path( $path, ( $flags & self::ADD_ON_TOP ) ? 'prepend' : 'append' );
+ self::setContentField( $arr, $name, $flags );
+ }
+
+ /**
+ * Causes the elements with the specified names to be output as
+ * subelements rather than attributes.
+ * @since 1.25 is static
+ * @param array &$arr
+ * @param array|string|int $names The element name(s) to be output as subelements
+ */
+ public static function setSubelementsList( array &$arr, $names ) {
+ if ( !isset( $arr[self::META_SUBELEMENTS] ) ) {
+ $arr[self::META_SUBELEMENTS] = (array)$names;
+ } else {
+ $arr[self::META_SUBELEMENTS] = array_merge( $arr[self::META_SUBELEMENTS], (array)$names );
+ }
+ }
+
+ /**
+ * Causes the elements with the specified names to be output as
+ * subelements rather than attributes.
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param array|string|int $names The element name(s) to be output as subelements
+ */
+ public function addSubelementsList( $path, $names ) {
+ $arr = &$this->path( $path );
+ self::setSubelementsList( $arr, $names );
+ }
+
+ /**
+ * Causes the elements with the specified names to be output as
+ * attributes (when possible) rather than as subelements.
+ * @since 1.25
+ * @param array &$arr
+ * @param array|string|int $names The element name(s) to not be output as subelements
+ */
+ public static function unsetSubelementsList( array &$arr, $names ) {
+ if ( isset( $arr[self::META_SUBELEMENTS] ) ) {
+ $arr[self::META_SUBELEMENTS] = array_diff( $arr[self::META_SUBELEMENTS], (array)$names );
+ }
+ }
+
+ /**
+ * Causes the elements with the specified names to be output as
+ * attributes (when possible) rather than as subelements.
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param array|string|int $names The element name(s) to not be output as subelements
+ */
+ public function removeSubelementsList( $path, $names ) {
+ $arr = &$this->path( $path );
+ self::unsetSubelementsList( $arr, $names );
+ }
+
+ /**
+ * Set the tag name for numeric-keyed values in XML format
+ * @since 1.25 is static
+ * @param array &$arr
+ * @param string $tag Tag name
+ */
+ public static function setIndexedTagName( array &$arr, $tag ) {
+ if ( !is_string( $tag ) ) {
+ throw new InvalidArgumentException( 'Bad tag name' );
+ }
+ $arr[self::META_INDEXED_TAG_NAME] = $tag;
+ }
+
+ /**
+ * Set the tag name for numeric-keyed values in XML format
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param string $tag Tag name
+ */
+ public function addIndexedTagName( $path, $tag ) {
+ $arr = &$this->path( $path );
+ self::setIndexedTagName( $arr, $tag );
+ }
+
+ /**
+ * Set indexed tag name on $arr and all subarrays
+ *
+ * @since 1.25
+ * @param array &$arr
+ * @param string $tag Tag name
+ */
+ public static function setIndexedTagNameRecursive( array &$arr, $tag ) {
+ if ( !is_string( $tag ) ) {
+ throw new InvalidArgumentException( 'Bad tag name' );
+ }
+ $arr[self::META_INDEXED_TAG_NAME] = $tag;
+ foreach ( $arr as $k => &$v ) {
+ if ( !self::isMetadataKey( $k ) && is_array( $v ) ) {
+ self::setIndexedTagNameRecursive( $v, $tag );
+ }
+ }
+ }
+
+ /**
+ * Set indexed tag name on $path and all subarrays
+ *
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param string $tag Tag name
+ */
+ public function addIndexedTagNameRecursive( $path, $tag ) {
+ $arr = &$this->path( $path );
+ self::setIndexedTagNameRecursive( $arr, $tag );
+ }
+
+ /**
+ * Preserve specified keys.
+ *
+ * This prevents XML name mangling and preventing keys from being removed
+ * by self::stripMetadata().
+ *
+ * @since 1.25
+ * @param array &$arr
+ * @param array|string $names The element name(s) to preserve
+ */
+ public static function setPreserveKeysList( array &$arr, $names ) {
+ if ( !isset( $arr[self::META_PRESERVE_KEYS] ) ) {
+ $arr[self::META_PRESERVE_KEYS] = (array)$names;
+ } else {
+ $arr[self::META_PRESERVE_KEYS] = array_merge( $arr[self::META_PRESERVE_KEYS], (array)$names );
+ }
+ }
+
+ /**
+ * Preserve specified keys.
+ * @since 1.25
+ * @see self::setPreserveKeysList()
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param array|string $names The element name(s) to preserve
+ */
+ public function addPreserveKeysList( $path, $names ) {
+ $arr = &$this->path( $path );
+ self::setPreserveKeysList( $arr, $names );
+ }
+
+ /**
+ * Don't preserve specified keys.
+ * @since 1.25
+ * @see self::setPreserveKeysList()
+ * @param array &$arr
+ * @param array|string $names The element name(s) to not preserve
+ */
+ public static function unsetPreserveKeysList( array &$arr, $names ) {
+ if ( isset( $arr[self::META_PRESERVE_KEYS] ) ) {
+ $arr[self::META_PRESERVE_KEYS] = array_diff( $arr[self::META_PRESERVE_KEYS], (array)$names );
+ }
+ }
+
+ /**
+ * Don't preserve specified keys.
+ * @since 1.25
+ * @see self::setPreserveKeysList()
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param array|string $names The element name(s) to not preserve
+ */
+ public function removePreserveKeysList( $path, $names ) {
+ $arr = &$this->path( $path );
+ self::unsetPreserveKeysList( $arr, $names );
+ }
+
+ /**
+ * Set the array data type
+ *
+ * @since 1.25
+ * @param array &$arr
+ * @param string $type See ApiResult::META_TYPE
+ * @param string $kvpKeyName See ApiResult::META_KVP_KEY_NAME
+ */
+ public static function setArrayType( array &$arr, $type, $kvpKeyName = null ) {
+ if ( !in_array( $type, [
+ 'default', 'array', 'assoc', 'kvp', 'BCarray', 'BCassoc', 'BCkvp'
+ ], true ) ) {
+ throw new InvalidArgumentException( 'Bad type' );
+ }
+ $arr[self::META_TYPE] = $type;
+ if ( is_string( $kvpKeyName ) ) {
+ $arr[self::META_KVP_KEY_NAME] = $kvpKeyName;
+ }
+ }
+
+ /**
+ * Set the array data type for a path
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param string $tag See ApiResult::META_TYPE
+ * @param string $kvpKeyName See ApiResult::META_KVP_KEY_NAME
+ */
+ public function addArrayType( $path, $tag, $kvpKeyName = null ) {
+ $arr = &$this->path( $path );
+ self::setArrayType( $arr, $tag, $kvpKeyName );
+ }
+
+ /**
+ * Set the array data type recursively
+ * @since 1.25
+ * @param array &$arr
+ * @param string $type See ApiResult::META_TYPE
+ * @param string $kvpKeyName See ApiResult::META_KVP_KEY_NAME
+ */
+ public static function setArrayTypeRecursive( array &$arr, $type, $kvpKeyName = null ) {
+ self::setArrayType( $arr, $type, $kvpKeyName );
+ foreach ( $arr as $k => &$v ) {
+ if ( !self::isMetadataKey( $k ) && is_array( $v ) ) {
+ self::setArrayTypeRecursive( $v, $type, $kvpKeyName );
+ }
+ }
+ }
+
+ /**
+ * Set the array data type for a path recursively
+ * @since 1.25
+ * @param array|string|null $path See ApiResult::addValue()
+ * @param string $tag See ApiResult::META_TYPE
+ * @param string $kvpKeyName See ApiResult::META_KVP_KEY_NAME
+ */
+ public function addArrayTypeRecursive( $path, $tag, $kvpKeyName = null ) {
+ $arr = &$this->path( $path );
+ self::setArrayTypeRecursive( $arr, $tag, $kvpKeyName );
+ }
+
+ /**@}*/
+
+ /************************************************************************//**
+ * @name Utility
+ * @{
+ */
+
+ /**
+ * Test whether a key should be considered metadata
+ *
+ * @param string $key
+ * @return bool
+ */
+ public static function isMetadataKey( $key ) {
+ return substr( $key, 0, 1 ) === '_';
+ }
+
+ /**
+ * Apply transformations to an array, returning the transformed array.
+ *
+ * @see ApiResult::getResultData()
+ * @since 1.25
+ * @param array $dataIn
+ * @param array $transforms
+ * @return array|object
+ */
+ protected static function applyTransformations( array $dataIn, array $transforms ) {
+ $strip = isset( $transforms['Strip'] ) ? $transforms['Strip'] : 'none';
+ if ( $strip === 'base' ) {
+ $transforms['Strip'] = 'none';
+ }
+ $transformTypes = isset( $transforms['Types'] ) ? $transforms['Types'] : null;
+ if ( $transformTypes !== null && !is_array( $transformTypes ) ) {
+ throw new InvalidArgumentException( __METHOD__ . ':Value for "Types" must be an array' );
+ }
+
+ $metadata = [];
+ $data = self::stripMetadataNonRecursive( $dataIn, $metadata );
+
+ if ( isset( $transforms['Custom'] ) ) {
+ if ( !is_callable( $transforms['Custom'] ) ) {
+ throw new InvalidArgumentException( __METHOD__ . ': Value for "Custom" must be callable' );
+ }
+ call_user_func_array( $transforms['Custom'], [ &$data, &$metadata ] );
+ }
+
+ if ( ( isset( $transforms['BC'] ) || $transformTypes !== null ) &&
+ isset( $metadata[self::META_TYPE] ) && $metadata[self::META_TYPE] === 'BCkvp' &&
+ !isset( $metadata[self::META_KVP_KEY_NAME] )
+ ) {
+ throw new UnexpectedValueException( 'Type "BCkvp" used without setting ' .
+ 'ApiResult::META_KVP_KEY_NAME metadata item' );
+ }
+
+ // BC transformations
+ $boolKeys = null;
+ if ( isset( $transforms['BC'] ) ) {
+ if ( !is_array( $transforms['BC'] ) ) {
+ throw new InvalidArgumentException( __METHOD__ . ':Value for "BC" must be an array' );
+ }
+ if ( !in_array( 'nobool', $transforms['BC'], true ) ) {
+ $boolKeys = isset( $metadata[self::META_BC_BOOLS] )
+ ? array_flip( $metadata[self::META_BC_BOOLS] )
+ : [];
+ }
+
+ if ( !in_array( 'no*', $transforms['BC'], true ) &&
+ isset( $metadata[self::META_CONTENT] ) && $metadata[self::META_CONTENT] !== '*'
+ ) {
+ $k = $metadata[self::META_CONTENT];
+ $data['*'] = $data[$k];
+ unset( $data[$k] );
+ $metadata[self::META_CONTENT] = '*';
+ }
+
+ if ( !in_array( 'nosub', $transforms['BC'], true ) &&
+ isset( $metadata[self::META_BC_SUBELEMENTS] )
+ ) {
+ foreach ( $metadata[self::META_BC_SUBELEMENTS] as $k ) {
+ if ( isset( $data[$k] ) ) {
+ $data[$k] = [
+ '*' => $data[$k],
+ self::META_CONTENT => '*',
+ self::META_TYPE => 'assoc',
+ ];
+ }
+ }
+ }
+
+ if ( isset( $metadata[self::META_TYPE] ) ) {
+ switch ( $metadata[self::META_TYPE] ) {
+ case 'BCarray':
+ case 'BCassoc':
+ $metadata[self::META_TYPE] = 'default';
+ break;
+ case 'BCkvp':
+ $transformTypes['ArmorKVP'] = $metadata[self::META_KVP_KEY_NAME];
+ break;
+ }
+ }
+ }
+
+ // Figure out type, do recursive calls, and do boolean transform if necessary
+ $defaultType = 'array';
+ $maxKey = -1;
+ foreach ( $data as $k => &$v ) {
+ $v = is_array( $v ) ? self::applyTransformations( $v, $transforms ) : $v;
+ if ( $boolKeys !== null && is_bool( $v ) && !isset( $boolKeys[$k] ) ) {
+ if ( !$v ) {
+ unset( $data[$k] );
+ continue;
+ }
+ $v = '';
+ }
+ if ( is_string( $k ) ) {
+ $defaultType = 'assoc';
+ } elseif ( $k > $maxKey ) {
+ $maxKey = $k;
+ }
+ }
+ unset( $v );
+
+ // Determine which metadata to keep
+ switch ( $strip ) {
+ case 'all':
+ case 'base':
+ $keepMetadata = [];
+ break;
+ case 'none':
+ $keepMetadata = &$metadata;
+ break;
+ case 'bc':
+ $keepMetadata = array_intersect_key( $metadata, [
+ self::META_INDEXED_TAG_NAME => 1,
+ self::META_SUBELEMENTS => 1,
+ ] );
+ break;
+ default:
+ throw new InvalidArgumentException( __METHOD__ . ': Unknown value for "Strip"' );
+ }
+
+ // Type transformation
+ if ( $transformTypes !== null ) {
+ if ( $defaultType === 'array' && $maxKey !== count( $data ) - 1 ) {
+ $defaultType = 'assoc';
+ }
+
+ // Override type, if provided
+ $type = $defaultType;
+ if ( isset( $metadata[self::META_TYPE] ) && $metadata[self::META_TYPE] !== 'default' ) {
+ $type = $metadata[self::META_TYPE];
+ }
+ if ( ( $type === 'kvp' || $type === 'BCkvp' ) &&
+ empty( $transformTypes['ArmorKVP'] )
+ ) {
+ $type = 'assoc';
+ } elseif ( $type === 'BCarray' ) {
+ $type = 'array';
+ } elseif ( $type === 'BCassoc' ) {
+ $type = 'assoc';
+ }
+
+ // Apply transformation
+ switch ( $type ) {
+ case 'assoc':
+ $metadata[self::META_TYPE] = 'assoc';
+ $data += $keepMetadata;
+ return empty( $transformTypes['AssocAsObject'] ) ? $data : (object)$data;
+
+ case 'array':
+ ksort( $data );
+ $data = array_values( $data );
+ $metadata[self::META_TYPE] = 'array';
+ return $data + $keepMetadata;
+
+ case 'kvp':
+ case 'BCkvp':
+ $key = isset( $metadata[self::META_KVP_KEY_NAME] )
+ ? $metadata[self::META_KVP_KEY_NAME]
+ : $transformTypes['ArmorKVP'];
+ $valKey = isset( $transforms['BC'] ) ? '*' : 'value';
+ $assocAsObject = !empty( $transformTypes['AssocAsObject'] );
+ $merge = !empty( $metadata[self::META_KVP_MERGE] );
+
+ $ret = [];
+ foreach ( $data as $k => $v ) {
+ if ( $merge && ( is_array( $v ) || is_object( $v ) ) ) {
+ $vArr = (array)$v;
+ if ( isset( $vArr[self::META_TYPE] ) ) {
+ $mergeType = $vArr[self::META_TYPE];
+ } elseif ( is_object( $v ) ) {
+ $mergeType = 'assoc';
+ } else {
+ $keys = array_keys( $vArr );
+ sort( $keys, SORT_NUMERIC );
+ $mergeType = ( $keys === array_keys( $keys ) ) ? 'array' : 'assoc';
+ }
+ } else {
+ $mergeType = 'n/a';
+ }
+ if ( $mergeType === 'assoc' ) {
+ $item = $vArr + [
+ $key => $k,
+ ];
+ if ( $strip === 'none' ) {
+ self::setPreserveKeysList( $item, [ $key ] );
+ }
+ } else {
+ $item = [
+ $key => $k,
+ $valKey => $v,
+ ];
+ if ( $strip === 'none' ) {
+ $item += [
+ self::META_PRESERVE_KEYS => [ $key ],
+ self::META_CONTENT => $valKey,
+ self::META_TYPE => 'assoc',
+ ];
+ }
+ }
+ $ret[] = $assocAsObject ? (object)$item : $item;
+ }
+ $metadata[self::META_TYPE] = 'array';
+
+ return $ret + $keepMetadata;
+
+ default:
+ throw new UnexpectedValueException( "Unknown type '$type'" );
+ }
+ } else {
+ return $data + $keepMetadata;
+ }
+ }
+
+ /**
+ * Recursively remove metadata keys from a data array or object
+ *
+ * Note this removes all potential metadata keys, not just the defined
+ * ones.
+ *
+ * @since 1.25
+ * @param array|object $data
+ * @return array|object
+ */
+ public static function stripMetadata( $data ) {
+ if ( is_array( $data ) || is_object( $data ) ) {
+ $isObj = is_object( $data );
+ if ( $isObj ) {
+ $data = (array)$data;
+ }
+ $preserveKeys = isset( $data[self::META_PRESERVE_KEYS] )
+ ? (array)$data[self::META_PRESERVE_KEYS]
+ : [];
+ foreach ( $data as $k => $v ) {
+ if ( self::isMetadataKey( $k ) && !in_array( $k, $preserveKeys, true ) ) {
+ unset( $data[$k] );
+ } elseif ( is_array( $v ) || is_object( $v ) ) {
+ $data[$k] = self::stripMetadata( $v );
+ }
+ }
+ if ( $isObj ) {
+ $data = (object)$data;
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Remove metadata keys from a data array or object, non-recursive
+ *
+ * Note this removes all potential metadata keys, not just the defined
+ * ones.
+ *
+ * @since 1.25
+ * @param array|object $data
+ * @param array &$metadata Store metadata here, if provided
+ * @return array|object
+ */
+ public static function stripMetadataNonRecursive( $data, &$metadata = null ) {
+ if ( !is_array( $metadata ) ) {
+ $metadata = [];
+ }
+ if ( is_array( $data ) || is_object( $data ) ) {
+ $isObj = is_object( $data );
+ if ( $isObj ) {
+ $data = (array)$data;
+ }
+ $preserveKeys = isset( $data[self::META_PRESERVE_KEYS] )
+ ? (array)$data[self::META_PRESERVE_KEYS]
+ : [];
+ foreach ( $data as $k => $v ) {
+ if ( self::isMetadataKey( $k ) && !in_array( $k, $preserveKeys, true ) ) {
+ $metadata[$k] = $v;
+ unset( $data[$k] );
+ }
+ }
+ if ( $isObj ) {
+ $data = (object)$data;
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Get the 'real' size of a result item. This means the strlen() of the item,
+ * or the sum of the strlen()s of the elements if the item is an array.
+ * @param mixed $value Validated value (see self::validateValue())
+ * @return int
+ */
+ private static function size( $value ) {
+ $s = 0;
+ if ( is_array( $value ) ) {
+ foreach ( $value as $k => $v ) {
+ if ( !self::isMetadataKey( $k ) ) {
+ $s += self::size( $v );
+ }
+ }
+ } elseif ( is_scalar( $value ) ) {
+ $s = strlen( $value );
+ }
+
+ return $s;
+ }
+
+ /**
+ * Return a reference to the internal data at $path
+ *
+ * @param array|string|null $path
+ * @param string $create
+ * If 'append', append empty arrays.
+ * If 'prepend', prepend empty arrays.
+ * If 'dummy', return a dummy array.
+ * Else, raise an error.
+ * @return array
+ */
+ private function &path( $path, $create = 'append' ) {
+ $path = (array)$path;
+ $ret = &$this->data;
+ foreach ( $path as $i => $k ) {
+ if ( !isset( $ret[$k] ) ) {
+ switch ( $create ) {
+ case 'append':
+ $ret[$k] = [];
+ break;
+ case 'prepend':
+ $ret = [ $k => [] ] + $ret;
+ break;
+ case 'dummy':
+ $tmp = [];
+ return $tmp;
+ default:
+ $fail = implode( '.', array_slice( $path, 0, $i + 1 ) );
+ throw new InvalidArgumentException( "Path $fail does not exist" );
+ }
+ }
+ if ( !is_array( $ret[$k] ) ) {
+ $fail = implode( '.', array_slice( $path, 0, $i + 1 ) );
+ throw new InvalidArgumentException( "Path $fail is not an array" );
+ }
+ $ret = &$ret[$k];
+ }
+ return $ret;
+ }
+
+ /**
+ * Add the correct metadata to an array of vars we want to export through
+ * the API.
+ *
+ * @param array $vars
+ * @param bool $forceHash
+ * @return array
+ */
+ public static function addMetadataToResultVars( $vars, $forceHash = true ) {
+ // Process subarrays and determine if this is a JS [] or {}
+ $hash = $forceHash;
+ $maxKey = -1;
+ $bools = [];
+ foreach ( $vars as $k => $v ) {
+ if ( is_array( $v ) || is_object( $v ) ) {
+ $vars[$k] = self::addMetadataToResultVars( (array)$v, is_object( $v ) );
+ } elseif ( is_bool( $v ) ) {
+ // Better here to use real bools even in BC formats
+ $bools[] = $k;
+ }
+ if ( is_string( $k ) ) {
+ $hash = true;
+ } elseif ( $k > $maxKey ) {
+ $maxKey = $k;
+ }
+ }
+ if ( !$hash && $maxKey !== count( $vars ) - 1 ) {
+ $hash = true;
+ }
+
+ // Set metadata appropriately
+ if ( $hash ) {
+ // Get the list of keys we actually care about. Unfortunately, we can't support
+ // certain keys that conflict with ApiResult metadata.
+ $keys = array_diff( array_keys( $vars ), [
+ self::META_TYPE, self::META_PRESERVE_KEYS, self::META_KVP_KEY_NAME,
+ self::META_INDEXED_TAG_NAME, self::META_BC_BOOLS
+ ] );
+
+ return [
+ self::META_TYPE => 'kvp',
+ self::META_KVP_KEY_NAME => 'key',
+ self::META_PRESERVE_KEYS => $keys,
+ self::META_BC_BOOLS => $bools,
+ self::META_INDEXED_TAG_NAME => 'var',
+ ] + $vars;
+ } else {
+ return [
+ self::META_TYPE => 'array',
+ self::META_BC_BOOLS => $bools,
+ self::META_INDEXED_TAG_NAME => 'value',
+ ] + $vars;
+ }
+ }
+
+ /**
+ * Format an expiry timestamp for API output
+ * @since 1.29
+ * @param string $expiry Expiry timestamp, likely from the database
+ * @param string $infinity Use this string for infinite expiry
+ * (only use this to maintain backward compatibility with existing output)
+ * @return string Formatted expiry
+ */
+ public static function formatExpiry( $expiry, $infinity = 'infinity' ) {
+ static $dbInfinity;
+ if ( $dbInfinity === null ) {
+ $dbInfinity = wfGetDB( DB_REPLICA )->getInfinity();
+ }
+
+ if ( $expiry === '' || $expiry === null || $expiry === false ||
+ wfIsInfinity( $expiry ) || $expiry === $dbInfinity
+ ) {
+ return $infinity;
+ } else {
+ return wfTimestamp( TS_ISO_8601, $expiry );
+ }
+ }
+
+ /**@}*/
+
+}
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ */
diff --git a/www/wiki/includes/api/ApiRevisionDelete.php b/www/wiki/includes/api/ApiRevisionDelete.php
new file mode 100644
index 00000000..9d71a7db
--- /dev/null
+++ b/www/wiki/includes/api/ApiRevisionDelete.php
@@ -0,0 +1,204 @@
+<?php
+/**
+ * Created on Jun 25, 2013
+ *
+ * Copyright © 2013 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.23
+ */
+
+/**
+ * API interface to RevDel. The API equivalent of Special:RevisionDelete.
+ * Requires API write mode to be enabled.
+ *
+ * @ingroup API
+ */
+class ApiRevisionDelete extends ApiBase {
+
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $params = $this->extractRequestParams();
+ $user = $this->getUser();
+ $this->checkUserRightsAny( RevisionDeleter::getRestriction( $params['type'] ) );
+
+ if ( $user->isBlocked() ) {
+ $this->dieBlocked( $user->getBlock() );
+ }
+
+ if ( !$params['ids'] ) {
+ $this->dieWithError( [ 'apierror-paramempty', 'ids' ], 'paramempty_ids' );
+ }
+
+ // Check if user can add tags
+ if ( count( $params['tags'] ) ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ $hide = $params['hide'] ?: [];
+ $show = $params['show'] ?: [];
+ if ( array_intersect( $hide, $show ) ) {
+ $this->dieWithError( 'apierror-revdel-mutuallyexclusive', 'badparams' );
+ } elseif ( !$hide && !$show ) {
+ $this->dieWithError( 'apierror-revdel-paramneeded', 'badparams' );
+ }
+ $bits = [
+ 'content' => RevisionDeleter::getRevdelConstant( $params['type'] ),
+ 'comment' => Revision::DELETED_COMMENT,
+ 'user' => Revision::DELETED_USER,
+ ];
+ $bitfield = [];
+ foreach ( $bits as $key => $bit ) {
+ if ( in_array( $key, $hide ) ) {
+ $bitfield[$bit] = 1;
+ } elseif ( in_array( $key, $show ) ) {
+ $bitfield[$bit] = 0;
+ } else {
+ $bitfield[$bit] = -1;
+ }
+ }
+
+ if ( $params['suppress'] === 'yes' ) {
+ $this->checkUserRightsAny( 'suppressrevision' );
+ $bitfield[Revision::DELETED_RESTRICTED] = 1;
+ } elseif ( $params['suppress'] === 'no' ) {
+ $bitfield[Revision::DELETED_RESTRICTED] = 0;
+ } else {
+ $bitfield[Revision::DELETED_RESTRICTED] = -1;
+ }
+
+ $targetObj = null;
+ if ( $params['target'] ) {
+ $targetObj = Title::newFromText( $params['target'] );
+ }
+ $targetObj = RevisionDeleter::suggestTarget( $params['type'], $targetObj, $params['ids'] );
+ if ( $targetObj === null ) {
+ $this->dieWithError( [ 'apierror-revdel-needtarget' ], 'needtarget' );
+ }
+
+ $list = RevisionDeleter::createList(
+ $params['type'], $this->getContext(), $targetObj, $params['ids']
+ );
+ $status = $list->setVisibility( [
+ 'value' => $bitfield,
+ 'comment' => $params['reason'],
+ 'perItemStatus' => true,
+ 'tags' => $params['tags']
+ ] );
+
+ $result = $this->getResult();
+ $data = $this->extractStatusInfo( $status );
+ $data['target'] = $targetObj->getFullText();
+ $data['items'] = [];
+
+ foreach ( $status->itemStatuses as $id => $s ) {
+ $data['items'][$id] = $this->extractStatusInfo( $s );
+ $data['items'][$id]['id'] = $id;
+ }
+
+ $list->reloadFromMaster();
+ // @codingStandardsIgnoreStart Avoid function calls in a FOR loop test part
+ for ( $item = $list->reset(); $list->current(); $item = $list->next() ) {
+ $data['items'][$item->getId()] += $item->getApiData( $this->getResult() );
+ }
+ // @codingStandardsIgnoreEnd
+
+ $data['items'] = array_values( $data['items'] );
+ ApiResult::setIndexedTagName( $data['items'], 'i' );
+ $result->addValue( null, $this->getModuleName(), $data );
+ }
+
+ private function extractStatusInfo( $status ) {
+ $ret = [
+ 'status' => $status->isOK() ? 'Success' : 'Fail',
+ ];
+
+ $errors = $this->getErrorFormatter()->arrayFromStatus( $status, 'error' );
+ if ( $errors ) {
+ $ret['errors'] = $errors;
+ }
+ $warnings = $this->getErrorFormatter()->arrayFromStatus( $status, 'warning' );
+ if ( $warnings ) {
+ $ret['warnings'] = $warnings;
+ }
+
+ return $ret;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'type' => [
+ ApiBase::PARAM_TYPE => RevisionDeleter::getTypes(),
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'target' => null,
+ 'ids' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'hide' => [
+ ApiBase::PARAM_TYPE => [ 'content', 'comment', 'user' ],
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'show' => [
+ ApiBase::PARAM_TYPE => [ 'content', 'comment', 'user' ],
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'suppress' => [
+ ApiBase::PARAM_TYPE => [ 'yes', 'no', 'nochange' ],
+ ApiBase::PARAM_DFLT => 'nochange',
+ ],
+ 'reason' => null,
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=revisiondelete&target=Main%20Page&type=revision&ids=12345&' .
+ 'hide=content&token=123ABC'
+ => 'apihelp-revisiondelete-example-revision',
+ 'action=revisiondelete&type=logging&ids=67890&hide=content|comment|user&' .
+ 'reason=BLP%20violation&token=123ABC'
+ => 'apihelp-revisiondelete-example-log',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Revisiondelete';
+ }
+}
diff --git a/www/wiki/includes/api/ApiRollback.php b/www/wiki/includes/api/ApiRollback.php
new file mode 100644
index 00000000..76b6cc67
--- /dev/null
+++ b/www/wiki/includes/api/ApiRollback.php
@@ -0,0 +1,207 @@
+<?php
+/**
+ *
+ *
+ * Created on Jun 20, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiRollback extends ApiBase {
+
+ /**
+ * @var Title
+ */
+ private $mTitleObj = null;
+
+ /**
+ * @var User
+ */
+ private $mUser = null;
+
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $user = $this->getUser();
+ $params = $this->extractRequestParams();
+
+ $titleObj = $this->getRbTitle( $params );
+ $pageObj = WikiPage::factory( $titleObj );
+ $summary = $params['summary'];
+ $details = [];
+
+ // If change tagging was requested, check that the user is allowed to tag,
+ // and the tags are valid
+ if ( count( $params['tags'] ) ) {
+ $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$tagStatus->isOK() ) {
+ $this->dieStatus( $tagStatus );
+ }
+ }
+
+ $retval = $pageObj->doRollback(
+ $this->getRbUser( $params ),
+ $summary,
+ $params['token'],
+ $params['markbot'],
+ $details,
+ $user,
+ $params['tags']
+ );
+
+ if ( $retval ) {
+ $this->dieStatus( $this->errorArrayToStatus( $retval, $user ) );
+ }
+
+ $watch = 'preferences';
+ if ( isset( $params['watchlist'] ) ) {
+ $watch = $params['watchlist'];
+ }
+
+ // Watch pages
+ $this->setWatch( $watch, $titleObj, 'watchrollback' );
+
+ $info = [
+ 'title' => $titleObj->getPrefixedText(),
+ 'pageid' => intval( $details['current']->getPage() ),
+ 'summary' => $details['summary'],
+ 'revid' => intval( $details['newid'] ),
+ // The revision being reverted (previously the current revision of the page)
+ 'old_revid' => intval( $details['current']->getID() ),
+ // The revision being restored (the last revision before revision(s) by the reverted user)
+ 'last_revid' => intval( $details['target']->getID() )
+ ];
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $info );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => null,
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'summary' => '',
+ 'markbot' => false,
+ 'watchlist' => [
+ ApiBase::PARAM_DFLT => 'preferences',
+ ApiBase::PARAM_TYPE => [
+ 'watch',
+ 'unwatch',
+ 'preferences',
+ 'nochange'
+ ],
+ ],
+ 'token' => [
+ // Standard definition automatically inserted
+ ApiBase::PARAM_HELP_MSG_APPEND => [ 'api-help-param-token-webui' ],
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'rollback';
+ }
+
+ /**
+ * @param array $params
+ *
+ * @return string
+ */
+ private function getRbUser( array $params ) {
+ if ( $this->mUser !== null ) {
+ return $this->mUser;
+ }
+
+ // We need to be able to revert IPs, but getCanonicalName rejects them
+ $this->mUser = User::isIP( $params['user'] )
+ ? $params['user']
+ : User::getCanonicalName( $params['user'] );
+ if ( !$this->mUser ) {
+ $this->dieWithError( [ 'apierror-invaliduser', wfEscapeWikiText( $params['user'] ) ] );
+ }
+
+ return $this->mUser;
+ }
+
+ /**
+ * @param array $params
+ *
+ * @return Title
+ */
+ private function getRbTitle( array $params ) {
+ if ( $this->mTitleObj !== null ) {
+ return $this->mTitleObj;
+ }
+
+ $this->requireOnlyOneParameter( $params, 'title', 'pageid' );
+
+ if ( isset( $params['title'] ) ) {
+ $this->mTitleObj = Title::newFromText( $params['title'] );
+ if ( !$this->mTitleObj || $this->mTitleObj->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
+ }
+ } elseif ( isset( $params['pageid'] ) ) {
+ $this->mTitleObj = Title::newFromID( $params['pageid'] );
+ if ( !$this->mTitleObj ) {
+ $this->dieWithError( [ 'apierror-nosuchpageid', $params['pageid'] ] );
+ }
+ }
+
+ if ( !$this->mTitleObj->exists() ) {
+ $this->dieWithError( 'apierror-missingtitle' );
+ }
+
+ return $this->mTitleObj;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=rollback&title=Main%20Page&user=Example&token=123ABC' =>
+ 'apihelp-rollback-example-simple',
+ 'action=rollback&title=Main%20Page&user=192.0.2.5&' .
+ 'token=123ABC&summary=Reverting%20vandalism&markbot=1' =>
+ 'apihelp-rollback-example-summary',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Rollback';
+ }
+}
diff --git a/www/wiki/includes/api/ApiRsd.php b/www/wiki/includes/api/ApiRsd.php
new file mode 100644
index 00000000..fdc62a8e
--- /dev/null
+++ b/www/wiki/includes/api/ApiRsd.php
@@ -0,0 +1,169 @@
+<?php
+
+/**
+ * API for MediaWiki 1.17+
+ *
+ * Created on October 26, 2010
+ *
+ * Copyright © 2010 Bryan Tong Minh and Brion Vibber
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module for sending out RSD information
+ * @ingroup API
+ */
+class ApiRsd extends ApiBase {
+
+ public function execute() {
+ $result = $this->getResult();
+
+ $result->addValue( null, 'version', '1.0' );
+ $result->addValue( null, 'xmlns', 'http://archipelago.phrasewise.com/rsd' );
+
+ $service = [
+ 'apis' => $this->formatRsdApiList(),
+ 'engineName' => 'MediaWiki',
+ 'engineLink' => 'https://www.mediawiki.org/',
+ 'homePageLink' => Title::newMainPage()->getCanonicalURL(),
+ ];
+
+ ApiResult::setSubelementsList( $service, [ 'engineName', 'engineLink', 'homePageLink' ] );
+ ApiResult::setIndexedTagName( $service['apis'], 'api' );
+
+ $result->addValue( null, 'service', $service );
+ }
+
+ public function getCustomPrinter() {
+ return new ApiFormatXmlRsd( $this->getMain(), 'xml' );
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=rsd'
+ => 'apihelp-rsd-example-simple',
+ ];
+ }
+
+ public function isReadMode() {
+ return false;
+ }
+
+ /**
+ * Builds an internal list of APIs to expose information about.
+ * Normally this only lists the MediaWiki API, with its base URL,
+ * link to documentation, and a marker as to available authentication
+ * (to aid in OAuth client apps switching to support in the future).
+ *
+ * Extensions can expose other APIs, such as WordPress or Twitter-
+ * compatible APIs, by hooking 'ApiRsdServiceApis' and adding more
+ * elements to the array.
+ *
+ * See https://cyber.harvard.edu/blogs/gems/tech/rsd.html for
+ * the base RSD spec, and check WordPress and StatusNet sites for
+ * in-production examples listing several blogging and micrblogging
+ * APIs.
+ *
+ * @return array
+ */
+ protected function getRsdApiList() {
+ $apis = [
+ 'MediaWiki' => [
+ // The API link is required for all RSD API entries.
+ 'apiLink' => wfExpandUrl( wfScript( 'api' ), PROTO_CURRENT ),
+
+ // Docs link is optional, but recommended.
+ 'docs' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/API',
+
+ // Some APIs may need a blog ID, but it may be left blank.
+ 'blogID' => '',
+
+ // Additional settings are optional.
+ 'settings' => [
+ // Change this to true in the future as an aid to
+ // machine discovery of OAuth for API access.
+ 'OAuth' => false,
+ ]
+ ],
+ ];
+ Hooks::run( 'ApiRsdServiceApis', [ &$apis ] );
+
+ return $apis;
+ }
+
+ /**
+ * Formats the internal list of exposed APIs into an array suitable
+ * to pass to the API's XML formatter.
+ *
+ * @return array
+ */
+ protected function formatRsdApiList() {
+ $apis = $this->getRsdApiList();
+
+ $outputData = [];
+ foreach ( $apis as $name => $info ) {
+ $data = [
+ 'name' => $name,
+ 'preferred' => wfBoolToStr( $name == 'MediaWiki' ),
+ 'apiLink' => $info['apiLink'],
+ 'blogID' => isset( $info['blogID'] ) ? $info['blogID'] : '',
+ ];
+ $settings = [];
+ if ( isset( $info['docs'] ) ) {
+ $settings['docs'] = $info['docs'];
+ ApiResult::setSubelementsList( $settings, 'docs' );
+ }
+ if ( isset( $info['settings'] ) ) {
+ foreach ( $info['settings'] as $setting => $val ) {
+ if ( is_bool( $val ) ) {
+ $xmlVal = wfBoolToStr( $val );
+ } else {
+ $xmlVal = $val;
+ }
+ $setting = [ 'name' => $setting ];
+ ApiResult::setContentValue( $setting, 'value', $xmlVal );
+ $settings[] = $setting;
+ }
+ }
+ if ( count( $settings ) ) {
+ ApiResult::setIndexedTagName( $settings, 'setting' );
+ $data['settings'] = $settings;
+ }
+ $outputData[] = $data;
+ }
+
+ return $outputData;
+ }
+}
+
+class ApiFormatXmlRsd extends ApiFormatXml {
+ public function __construct( ApiMain $main, $format ) {
+ parent::__construct( $main, $format );
+ $this->setRootElement( 'rsd' );
+ }
+
+ public function getMimeType() {
+ return 'application/rsd+xml';
+ }
+
+ public static function recXmlPrint( $name, $value, $indent, $attributes = [] ) {
+ unset( $attributes['_idx'] );
+ return parent::recXmlPrint( $name, $value, $indent, $attributes );
+ }
+}
diff --git a/www/wiki/includes/api/ApiSerializable.php b/www/wiki/includes/api/ApiSerializable.php
new file mode 100644
index 00000000..a41f655c
--- /dev/null
+++ b/www/wiki/includes/api/ApiSerializable.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Created on Feb 25, 2015
+ *
+ * Copyright © 2015 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This interface allows for overriding the default conversion applied by
+ * ApiResult::validateValue().
+ *
+ * @note This is currently an informal interface; it need not be explicitly
+ * implemented, as long as the method is provided. This allows for extension
+ * code to maintain compatibility with older MediaWiki while still taking
+ * advantage of this where it exists.
+ *
+ * @ingroup API
+ * @since 1.25
+ */
+interface ApiSerializable {
+ /**
+ * Return the value to be added to ApiResult in place of this object.
+ *
+ * The returned value must not be an object, and must pass
+ * all checks done by ApiResult::validateValue().
+ *
+ * @return mixed
+ */
+ public function serializeForApiResult();
+}
diff --git a/www/wiki/includes/api/ApiSetNotificationTimestamp.php b/www/wiki/includes/api/ApiSetNotificationTimestamp.php
new file mode 100644
index 00000000..b6a0a783
--- /dev/null
+++ b/www/wiki/includes/api/ApiSetNotificationTimestamp.php
@@ -0,0 +1,253 @@
+<?php
+
+/**
+ * API for MediaWiki 1.14+
+ *
+ * Created on Jun 18, 2012
+ *
+ * Copyright © 2012 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * API interface for setting the wl_notificationtimestamp field
+ * @ingroup API
+ */
+class ApiSetNotificationTimestamp extends ApiBase {
+
+ private $mPageSet;
+
+ public function execute() {
+ $user = $this->getUser();
+
+ if ( $user->isAnon() ) {
+ $this->dieWithError( 'watchlistanontext', 'notloggedin' );
+ }
+ $this->checkUserRightsAny( 'editmywatchlist' );
+
+ $params = $this->extractRequestParams();
+ $this->requireMaxOneParameter( $params, 'timestamp', 'torevid', 'newerthanrevid' );
+
+ $continuationManager = new ApiContinuationManager( $this, [], [] );
+ $this->setContinuationManager( $continuationManager );
+
+ $pageSet = $this->getPageSet();
+ if ( $params['entirewatchlist'] && $pageSet->getDataSource() !== null ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-cannotusewith',
+ $this->encodeParamName( 'entirewatchlist' ),
+ $pageSet->encodeParamName( $pageSet->getDataSource() )
+ ],
+ 'multisource'
+ );
+ }
+
+ $dbw = wfGetDB( DB_MASTER, 'api' );
+
+ $timestamp = null;
+ if ( isset( $params['timestamp'] ) ) {
+ $timestamp = $dbw->timestamp( $params['timestamp'] );
+ }
+
+ if ( !$params['entirewatchlist'] ) {
+ $pageSet->execute();
+ }
+
+ if ( isset( $params['torevid'] ) ) {
+ if ( $params['entirewatchlist'] || $pageSet->getGoodTitleCount() > 1 ) {
+ $this->dieWithError( [ 'apierror-multpages', $this->encodeParamName( 'torevid' ) ] );
+ }
+ $title = reset( $pageSet->getGoodTitles() );
+ if ( $title ) {
+ $timestamp = Revision::getTimestampFromId(
+ $title, $params['torevid'], Revision::READ_LATEST );
+ if ( $timestamp ) {
+ $timestamp = $dbw->timestamp( $timestamp );
+ } else {
+ $timestamp = null;
+ }
+ }
+ } elseif ( isset( $params['newerthanrevid'] ) ) {
+ if ( $params['entirewatchlist'] || $pageSet->getGoodTitleCount() > 1 ) {
+ $this->dieWithError( [ 'apierror-multpages', $this->encodeParamName( 'newerthanrevid' ) ] );
+ }
+ $title = reset( $pageSet->getGoodTitles() );
+ if ( $title ) {
+ $revid = $title->getNextRevisionID(
+ $params['newerthanrevid'], Title::GAID_FOR_UPDATE );
+ if ( $revid ) {
+ $timestamp = $dbw->timestamp( Revision::getTimestampFromId( $title, $revid ) );
+ } else {
+ $timestamp = null;
+ }
+ }
+ }
+
+ $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $apiResult = $this->getResult();
+ $result = [];
+ if ( $params['entirewatchlist'] ) {
+ // Entire watchlist mode: Just update the thing and return a success indicator
+ $watchedItemStore->setNotificationTimestampsForUser(
+ $user,
+ $timestamp
+ );
+
+ $result['notificationtimestamp'] = is_null( $timestamp )
+ ? ''
+ : wfTimestamp( TS_ISO_8601, $timestamp );
+ } else {
+ // First, log the invalid titles
+ foreach ( $pageSet->getInvalidTitlesAndReasons() as $r ) {
+ $r['invalid'] = true;
+ $result[] = $r;
+ }
+ foreach ( $pageSet->getMissingPageIDs() as $p ) {
+ $page = [];
+ $page['pageid'] = $p;
+ $page['missing'] = true;
+ $page['notwatched'] = true;
+ $result[] = $page;
+ }
+ foreach ( $pageSet->getMissingRevisionIDs() as $r ) {
+ $rev = [];
+ $rev['revid'] = $r;
+ $rev['missing'] = true;
+ $rev['notwatched'] = true;
+ $result[] = $rev;
+ }
+
+ if ( $pageSet->getTitles() ) {
+ // Now process the valid titles
+ $watchedItemStore->setNotificationTimestampsForUser(
+ $user,
+ $timestamp,
+ $pageSet->getTitles()
+ );
+
+ // Query the results of our update
+ $timestamps = $watchedItemStore->getNotificationTimestampsBatch(
+ $user,
+ $pageSet->getTitles()
+ );
+
+ // Now, put the valid titles into the result
+ /** @var Title $title */
+ foreach ( $pageSet->getTitles() as $title ) {
+ $ns = $title->getNamespace();
+ $dbkey = $title->getDBkey();
+ $r = [
+ 'ns' => intval( $ns ),
+ 'title' => $title->getPrefixedText(),
+ ];
+ if ( !$title->exists() ) {
+ $r['missing'] = true;
+ if ( $title->isKnown() ) {
+ $r['known'] = true;
+ }
+ }
+ if ( isset( $timestamps[$ns] ) && array_key_exists( $dbkey, $timestamps[$ns] ) ) {
+ $r['notificationtimestamp'] = '';
+ if ( $timestamps[$ns][$dbkey] !== null ) {
+ $r['notificationtimestamp'] = wfTimestamp( TS_ISO_8601, $timestamps[$ns][$dbkey] );
+ }
+ } else {
+ $r['notwatched'] = true;
+ }
+ $result[] = $r;
+ }
+ }
+
+ ApiResult::setIndexedTagName( $result, 'page' );
+ }
+ $apiResult->addValue( null, $this->getModuleName(), $result );
+
+ $this->setContinuationManager( null );
+ $continuationManager->setContinuationIntoResult( $apiResult );
+ }
+
+ /**
+ * Get a cached instance of an ApiPageSet object
+ * @return ApiPageSet
+ */
+ private function getPageSet() {
+ if ( !isset( $this->mPageSet ) ) {
+ $this->mPageSet = new ApiPageSet( $this );
+ }
+
+ return $this->mPageSet;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ public function getAllowedParams( $flags = 0 ) {
+ $result = [
+ 'entirewatchlist' => [
+ ApiBase::PARAM_TYPE => 'boolean'
+ ],
+ 'timestamp' => [
+ ApiBase::PARAM_TYPE => 'timestamp'
+ ],
+ 'torevid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'newerthanrevid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ if ( $flags ) {
+ $result += $this->getPageSet()->getFinalParams( $flags );
+ }
+
+ return $result;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=setnotificationtimestamp&entirewatchlist=&token=123ABC'
+ => 'apihelp-setnotificationtimestamp-example-all',
+ 'action=setnotificationtimestamp&titles=Main_page&token=123ABC'
+ => 'apihelp-setnotificationtimestamp-example-page',
+ 'action=setnotificationtimestamp&titles=Main_page&' .
+ 'timestamp=2012-01-01T00:00:00Z&token=123ABC'
+ => 'apihelp-setnotificationtimestamp-example-pagetimestamp',
+ 'action=setnotificationtimestamp&generator=allpages&gapnamespace=2&token=123ABC'
+ => 'apihelp-setnotificationtimestamp-example-allpages',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:SetNotificationTimestamp';
+ }
+}
diff --git a/www/wiki/includes/api/ApiSetPageLanguage.php b/www/wiki/includes/api/ApiSetPageLanguage.php
new file mode 100644
index 00000000..7e3f1acf
--- /dev/null
+++ b/www/wiki/includes/api/ApiSetPageLanguage.php
@@ -0,0 +1,149 @@
+<?php
+/**
+ *
+ *
+ * Created on January 1, 2017
+ *
+ * Copyright © 2017 Justin Du "<justin.d128@gmail.com>"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that facilitates changing the language of a page.
+ * The API equivalent of SpecialPageLanguage.
+ * Requires API write mode to be enabled.
+ *
+ * @ingroup API
+ */
+class ApiSetPageLanguage extends ApiBase {
+ // Check if change language feature is enabled
+ protected function getExtendedDescription() {
+ if ( !$this->getConfig()->get( 'PageLanguageUseDB' ) ) {
+ return 'apihelp-setpagelanguage-extended-description-disabled';
+ }
+ return parent::getExtendedDescription();
+ }
+
+ /**
+ * Extracts the title and language from the request parameters and invokes
+ * the static SpecialPageLanguage::changePageLanguage() function with these as arguments.
+ * If the language change succeeds, the title, old language, and new language
+ * of the article changed, as well as the performer of the language change
+ * are added to the result object.
+ */
+ public function execute() {
+ // Check if change language feature is enabled
+ if ( !$this->getConfig()->get( 'PageLanguageUseDB' ) ) {
+ $this->dieWithError( 'apierror-pagelang-disabled' );
+ }
+
+ // Check if the user has permissions
+ $this->checkUserRightsAny( 'pagelang' );
+
+ $this->useTransactionalTimeLimit();
+
+ $params = $this->extractRequestParams();
+
+ $pageObj = $this->getTitleOrPageId( $params, 'fromdbmaster' );
+ if ( !$pageObj->exists() ) {
+ $this->dieWithError( 'apierror-missingtitle' );
+ }
+
+ $titleObj = $pageObj->getTitle();
+ $user = $this->getUser();
+
+ // Check that the user is allowed to edit the page
+ $this->checkTitleUserPermissions( $titleObj, 'edit' );
+
+ // If change tagging was requested, check that the user is allowed to tag,
+ // and the tags are valid
+ if ( count( $params['tags'] ) ) {
+ $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$tagStatus->isOK() ) {
+ $this->dieStatus( $tagStatus );
+ }
+ }
+
+ $status = SpecialPageLanguage::changePageLanguage(
+ $this,
+ $titleObj,
+ $params['lang'],
+ $params['reason'] === null ? '' : $params['reason'],
+ $params['tags'] ?: []
+ );
+
+ if ( !$status->isOK() ) {
+ $this->dieStatus( $status );
+ }
+
+ $r = [
+ 'title' => $titleObj->getPrefixedText(),
+ 'oldlanguage' => $status->value->oldLanguage,
+ 'newlanguage' => $status->value->newLanguage,
+ 'logid' => $status->value->logId
+ ];
+ $this->getResult()->addValue( null, $this->getModuleName(), $r );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => null,
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'lang' => [
+ ApiBase::PARAM_TYPE => array_merge(
+ [ 'default' ],
+ array_keys( Language::fetchLanguageNames( null, 'mwfile' ) )
+ ),
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'reason' => null,
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=setpagelanguage&title=Main%20Page&lang=eu&token=123ABC'
+ => 'apihelp-setpagelanguage-example-language',
+ 'action=setpagelanguage&pageid=123&lang=default&token=123ABC'
+ => 'apihelp-setpagelanguage-example-default',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:SetPageLanguage';
+ }
+}
diff --git a/www/wiki/includes/api/ApiStashEdit.php b/www/wiki/includes/api/ApiStashEdit.php
new file mode 100644
index 00000000..8a9de064
--- /dev/null
+++ b/www/wiki/includes/api/ApiStashEdit.php
@@ -0,0 +1,472 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Prepare an edit in shared cache so that it can be reused on edit
+ *
+ * This endpoint can be called via AJAX as the user focuses on the edit
+ * summary box. By the time of submission, the parse may have already
+ * finished, and can be immediately used on page save. Certain parser
+ * functions like {{REVISIONID}} or {{CURRENTTIME}} may cause the cache
+ * to not be used on edit. Template and files used are check for changes
+ * since the output was generated. The cache TTL is also kept low for sanity.
+ *
+ * @ingroup API
+ * @since 1.25
+ */
+class ApiStashEdit extends ApiBase {
+ const ERROR_NONE = 'stashed';
+ const ERROR_PARSE = 'error_parse';
+ const ERROR_CACHE = 'error_cache';
+ const ERROR_UNCACHEABLE = 'uncacheable';
+ const ERROR_BUSY = 'busy';
+
+ const PRESUME_FRESH_TTL_SEC = 30;
+ const MAX_CACHE_TTL = 300; // 5 minutes
+ const MAX_SIGNATURE_TTL = 60;
+
+ public function execute() {
+ $user = $this->getUser();
+ $params = $this->extractRequestParams();
+
+ if ( $user->isBot() ) { // sanity
+ $this->dieWithError( 'apierror-botsnotsupported' );
+ }
+
+ $cache = ObjectCache::getLocalClusterInstance();
+ $page = $this->getTitleOrPageId( $params );
+ $title = $page->getTitle();
+
+ if ( !ContentHandler::getForModelID( $params['contentmodel'] )
+ ->isSupportedFormat( $params['contentformat'] )
+ ) {
+ $this->dieWithError(
+ [ 'apierror-badformat-generic', $params['contentformat'], $params['contentmodel'] ],
+ 'badmodelformat'
+ );
+ }
+
+ $this->requireAtLeastOneParameter( $params, 'stashedtexthash', 'text' );
+
+ $text = null;
+ $textHash = null;
+ if ( strlen( $params['stashedtexthash'] ) ) {
+ // Load from cache since the client indicates the text is the same as last stash
+ $textHash = $params['stashedtexthash'];
+ if ( !preg_match( '/^[0-9a-f]{40}$/', $textHash ) ) {
+ $this->dieWithError( 'apierror-stashedit-missingtext', 'missingtext' );
+ }
+ $textKey = $cache->makeKey( 'stashedit', 'text', $textHash );
+ $text = $cache->get( $textKey );
+ if ( !is_string( $text ) ) {
+ $this->dieWithError( 'apierror-stashedit-missingtext', 'missingtext' );
+ }
+ } elseif ( $params['text'] !== null ) {
+ // Trim and fix newlines so the key SHA1's match (see WebRequest::getText())
+ $text = rtrim( str_replace( "\r\n", "\n", $params['text'] ) );
+ $textHash = sha1( $text );
+ } else {
+ $this->dieWithError( [
+ 'apierror-missingparam-at-least-one-of',
+ Message::listParam( [ '<var>stashedtexthash</var>', '<var>text</var>' ] ),
+ 2,
+ ], 'missingparam' );
+ }
+
+ $textContent = ContentHandler::makeContent(
+ $text, $title, $params['contentmodel'], $params['contentformat'] );
+
+ $page = WikiPage::factory( $title );
+ if ( $page->exists() ) {
+ // Page exists: get the merged content with the proposed change
+ $baseRev = Revision::newFromPageId( $page->getId(), $params['baserevid'] );
+ if ( !$baseRev ) {
+ $this->dieWithError( [ 'apierror-nosuchrevid', $params['baserevid'] ] );
+ }
+ $currentRev = $page->getRevision();
+ if ( !$currentRev ) {
+ $this->dieWithError( [ 'apierror-missingrev-pageid', $page->getId() ], 'missingrev' );
+ }
+ // Merge in the new version of the section to get the proposed version
+ $editContent = $page->replaceSectionAtRev(
+ $params['section'],
+ $textContent,
+ $params['sectiontitle'],
+ $baseRev->getId()
+ );
+ if ( !$editContent ) {
+ $this->dieWithError( 'apierror-sectionreplacefailed', 'replacefailed' );
+ }
+ if ( $currentRev->getId() == $baseRev->getId() ) {
+ // Base revision was still the latest; nothing to merge
+ $content = $editContent;
+ } else {
+ // Merge the edit into the current version
+ $baseContent = $baseRev->getContent();
+ $currentContent = $currentRev->getContent();
+ if ( !$baseContent || !$currentContent ) {
+ $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ], 'missingrev' );
+ }
+ $handler = ContentHandler::getForModelID( $baseContent->getModel() );
+ $content = $handler->merge3( $baseContent, $editContent, $currentContent );
+ }
+ } else {
+ // New pages: use the user-provided content model
+ $content = $textContent;
+ }
+
+ if ( !$content ) { // merge3() failed
+ $this->getResult()->addValue( null,
+ $this->getModuleName(), [ 'status' => 'editconflict' ] );
+ return;
+ }
+
+ // The user will abort the AJAX request by pressing "save", so ignore that
+ ignore_user_abort( true );
+
+ if ( $user->pingLimiter( 'stashedit' ) ) {
+ $status = 'ratelimited';
+ } else {
+ $status = self::parseAndStash( $page, $content, $user, $params['summary'] );
+ $textKey = $cache->makeKey( 'stashedit', 'text', $textHash );
+ $cache->set( $textKey, $text, self::MAX_CACHE_TTL );
+ }
+
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $stats->increment( "editstash.cache_stores.$status" );
+
+ $this->getResult()->addValue(
+ null,
+ $this->getModuleName(),
+ [
+ 'status' => $status,
+ 'texthash' => $textHash
+ ]
+ );
+ }
+
+ /**
+ * @param WikiPage $page
+ * @param Content $content Edit content
+ * @param User $user
+ * @param string $summary Edit summary
+ * @return string ApiStashEdit::ERROR_* constant
+ * @since 1.25
+ */
+ public static function parseAndStash( WikiPage $page, Content $content, User $user, $summary ) {
+ $cache = ObjectCache::getLocalClusterInstance();
+ $logger = LoggerFactory::getInstance( 'StashEdit' );
+
+ $title = $page->getTitle();
+ $key = self::getStashKey( $title, self::getContentHash( $content ), $user );
+
+ // Use the master DB for fast blocking locks
+ $dbw = wfGetDB( DB_MASTER );
+ if ( !$dbw->lock( $key, __METHOD__, 1 ) ) {
+ // De-duplicate requests on the same key
+ return self::ERROR_BUSY;
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $unlocker = new ScopedCallback( function () use ( $dbw, $key ) {
+ $dbw->unlock( $key, __METHOD__ );
+ } );
+
+ $cutoffTime = time() - self::PRESUME_FRESH_TTL_SEC;
+
+ // Reuse any freshly build matching edit stash cache
+ $editInfo = $cache->get( $key );
+ if ( $editInfo && wfTimestamp( TS_UNIX, $editInfo->timestamp ) >= $cutoffTime ) {
+ $alreadyCached = true;
+ } else {
+ $format = $content->getDefaultFormat();
+ $editInfo = $page->prepareContentForEdit( $content, null, $user, $format, false );
+ $alreadyCached = false;
+ }
+
+ if ( $editInfo && $editInfo->output ) {
+ // Let extensions add ParserOutput metadata or warm other caches
+ Hooks::run( 'ParserOutputStashForEdit',
+ [ $page, $content, $editInfo->output, $summary, $user ] );
+
+ if ( $alreadyCached ) {
+ $logger->debug( "Already cached parser output for key '$key' ('$title')." );
+ return self::ERROR_NONE;
+ }
+
+ list( $stashInfo, $ttl, $code ) = self::buildStashValue(
+ $editInfo->pstContent,
+ $editInfo->output,
+ $editInfo->timestamp,
+ $user
+ );
+
+ if ( $stashInfo ) {
+ $ok = $cache->set( $key, $stashInfo, $ttl );
+ if ( $ok ) {
+ $logger->debug( "Cached parser output for key '$key' ('$title')." );
+ return self::ERROR_NONE;
+ } else {
+ $logger->error( "Failed to cache parser output for key '$key' ('$title')." );
+ return self::ERROR_CACHE;
+ }
+ } else {
+ $logger->info( "Uncacheable parser output for key '$key' ('$title') [$code]." );
+ return self::ERROR_UNCACHEABLE;
+ }
+ }
+
+ return self::ERROR_PARSE;
+ }
+
+ /**
+ * Check that a prepared edit is in cache and still up-to-date
+ *
+ * This method blocks if the prepared edit is already being rendered,
+ * waiting until rendering finishes before doing final validity checks.
+ *
+ * The cache is rejected if template or file changes are detected.
+ * Note that foreign template or file transclusions are not checked.
+ *
+ * The result is a map (pstContent,output,timestamp) with fields
+ * extracted directly from WikiPage::prepareContentForEdit().
+ *
+ * @param Title $title
+ * @param Content $content
+ * @param User $user User to get parser options from
+ * @return stdClass|bool Returns false on cache miss
+ */
+ public static function checkCache( Title $title, Content $content, User $user ) {
+ if ( $user->isBot() ) {
+ return false; // bots never stash - don't pollute stats
+ }
+
+ $cache = ObjectCache::getLocalClusterInstance();
+ $logger = LoggerFactory::getInstance( 'StashEdit' );
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+
+ $key = self::getStashKey( $title, self::getContentHash( $content ), $user );
+ $editInfo = $cache->get( $key );
+ if ( !is_object( $editInfo ) ) {
+ $start = microtime( true );
+ // We ignore user aborts and keep parsing. Block on any prior parsing
+ // so as to use its results and make use of the time spent parsing.
+ // Skip this logic if there no master connection in case this method
+ // is called on an HTTP GET request for some reason.
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $dbw = $lb->getAnyOpenConnection( $lb->getWriterIndex() );
+ if ( $dbw && $dbw->lock( $key, __METHOD__, 30 ) ) {
+ $editInfo = $cache->get( $key );
+ $dbw->unlock( $key, __METHOD__ );
+ }
+
+ $timeMs = 1000 * max( 0, microtime( true ) - $start );
+ $stats->timing( 'editstash.lock_wait_time', $timeMs );
+ }
+
+ if ( !is_object( $editInfo ) || !$editInfo->output ) {
+ $stats->increment( 'editstash.cache_misses.no_stash' );
+ $logger->debug( "Empty cache for key '$key' ('$title'); user '{$user->getName()}'." );
+ return false;
+ }
+
+ $age = time() - wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() );
+ if ( $age <= self::PRESUME_FRESH_TTL_SEC ) {
+ // Assume nothing changed in this time
+ $stats->increment( 'editstash.cache_hits.presumed_fresh' );
+ $logger->debug( "Timestamp-based cache hit for key '$key' (age: $age sec)." );
+ } elseif ( isset( $editInfo->edits ) && $editInfo->edits === $user->getEditCount() ) {
+ // Logged-in user made no local upload/template edits in the meantime
+ $stats->increment( 'editstash.cache_hits.presumed_fresh' );
+ $logger->debug( "Edit count based cache hit for key '$key' (age: $age sec)." );
+ } elseif ( $user->isAnon()
+ && self::lastEditTime( $user ) < $editInfo->output->getCacheTime()
+ ) {
+ // Logged-out user made no local upload/template edits in the meantime
+ $stats->increment( 'editstash.cache_hits.presumed_fresh' );
+ $logger->debug( "Edit check based cache hit for key '$key' (age: $age sec)." );
+ } else {
+ // User may have changed included content
+ $editInfo = false;
+ }
+
+ if ( !$editInfo ) {
+ $stats->increment( 'editstash.cache_misses.proven_stale' );
+ $logger->info( "Stale cache for key '$key'; old key with outside edits. (age: $age sec)" );
+ } elseif ( $editInfo->output->getFlag( 'vary-revision' ) ) {
+ // This can be used for the initial parse, e.g. for filters or doEditContent(),
+ // but a second parse will be triggered in doEditUpdates(). This is not optimal.
+ $logger->info( "Cache for key '$key' ('$title') has vary_revision." );
+ } elseif ( $editInfo->output->getFlag( 'vary-revision-id' ) ) {
+ // Similar to the above if we didn't guess the ID correctly.
+ $logger->info( "Cache for key '$key' ('$title') has vary_revision_id." );
+ }
+
+ return $editInfo;
+ }
+
+ /**
+ * @param User $user
+ * @return string|null TS_MW timestamp or null
+ */
+ private static function lastEditTime( User $user ) {
+ $time = wfGetDB( DB_REPLICA )->selectField(
+ 'recentchanges',
+ 'MAX(rc_timestamp)',
+ [ 'rc_user_text' => $user->getName() ],
+ __METHOD__
+ );
+
+ return wfTimestampOrNull( TS_MW, $time );
+ }
+
+ /**
+ * Get hash of the content, factoring in model/format
+ *
+ * @param Content $content
+ * @return string
+ */
+ private static function getContentHash( Content $content ) {
+ return sha1( implode( "\n", [
+ $content->getModel(),
+ $content->getDefaultFormat(),
+ $content->serialize( $content->getDefaultFormat() )
+ ] ) );
+ }
+
+ /**
+ * Get the temporary prepared edit stash key for a user
+ *
+ * This key can be used for caching prepared edits provided:
+ * - a) The $user was used for PST options
+ * - b) The parser output was made from the PST using cannonical matching options
+ *
+ * @param Title $title
+ * @param string $contentHash Result of getContentHash()
+ * @param User $user User to get parser options from
+ * @return string
+ */
+ private static function getStashKey( Title $title, $contentHash, User $user ) {
+ return ObjectCache::getLocalClusterInstance()->makeKey(
+ 'prepared-edit',
+ md5( $title->getPrefixedDBkey() ),
+ // Account for the edit model/text
+ $contentHash,
+ // Account for user name related variables like signatures
+ md5( $user->getId() . "\n" . $user->getName() )
+ );
+ }
+
+ /**
+ * Build a value to store in memcached based on the PST content and parser output
+ *
+ * This makes a simple version of WikiPage::prepareContentForEdit() as stash info
+ *
+ * @param Content $pstContent Pre-Save transformed content
+ * @param ParserOutput $parserOutput
+ * @param string $timestamp TS_MW
+ * @param User $user
+ * @return array (stash info array, TTL in seconds, info code) or (null, 0, info code)
+ */
+ private static function buildStashValue(
+ Content $pstContent, ParserOutput $parserOutput, $timestamp, User $user
+ ) {
+ // If an item is renewed, mind the cache TTL determined by config and parser functions.
+ // Put an upper limit on the TTL for sanity to avoid extreme template/file staleness.
+ $since = time() - wfTimestamp( TS_UNIX, $parserOutput->getTimestamp() );
+ $ttl = min( $parserOutput->getCacheExpiry() - $since, self::MAX_CACHE_TTL );
+
+ // Avoid extremely stale user signature timestamps (T84843)
+ if ( $parserOutput->getFlag( 'user-signature' ) ) {
+ $ttl = min( $ttl, self::MAX_SIGNATURE_TTL );
+ }
+
+ if ( $ttl <= 0 ) {
+ return [ null, 0, 'no_ttl' ];
+ }
+
+ // Only store what is actually needed
+ $stashInfo = (object)[
+ 'pstContent' => $pstContent,
+ 'output' => $parserOutput,
+ 'timestamp' => $timestamp,
+ 'edits' => $user->getEditCount()
+ ];
+
+ return [ $stashInfo, $ttl, 'ok' ];
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'section' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'sectiontitle' => [
+ ApiBase::PARAM_TYPE => 'string'
+ ],
+ 'text' => [
+ ApiBase::PARAM_TYPE => 'text',
+ ApiBase::PARAM_DFLT => null
+ ],
+ 'stashedtexthash' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_DFLT => null
+ ],
+ 'summary' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'contentmodel' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'contentformat' => [
+ ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'baserevid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_REQUIRED => true
+ ]
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function isInternal() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/api/ApiTag.php b/www/wiki/includes/api/ApiTag.php
new file mode 100644
index 00000000..76c67629
--- /dev/null
+++ b/www/wiki/includes/api/ApiTag.php
@@ -0,0 +1,192 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ * @since 1.25
+ */
+class ApiTag extends ApiBase {
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+ $user = $this->getUser();
+
+ // make sure the user is allowed
+ $this->checkUserRightsAny( 'changetags' );
+
+ if ( $user->isBlocked() ) {
+ $this->dieBlocked( $user->getBlock() );
+ }
+
+ // Check if user can add tags
+ if ( count( $params['tags'] ) ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$ableToTag->isOk() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ // validate and process each revid, rcid and logid
+ $this->requireAtLeastOneParameter( $params, 'revid', 'rcid', 'logid' );
+ $ret = [];
+ if ( $params['revid'] ) {
+ foreach ( $params['revid'] as $id ) {
+ $ret[] = $this->processIndividual( 'revid', $params, $id );
+ }
+ }
+ if ( $params['rcid'] ) {
+ foreach ( $params['rcid'] as $id ) {
+ $ret[] = $this->processIndividual( 'rcid', $params, $id );
+ }
+ }
+ if ( $params['logid'] ) {
+ foreach ( $params['logid'] as $id ) {
+ $ret[] = $this->processIndividual( 'logid', $params, $id );
+ }
+ }
+
+ ApiResult::setIndexedTagName( $ret, 'result' );
+ $this->getResult()->addValue( null, $this->getModuleName(), $ret );
+ }
+
+ protected static function validateLogId( $logid ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $result = $dbr->selectField( 'logging', 'log_id', [ 'log_id' => $logid ],
+ __METHOD__ );
+ return (bool)$result;
+ }
+
+ protected function processIndividual( $type, $params, $id ) {
+ $idResult = [ $type => $id ];
+
+ // validate the ID
+ $valid = false;
+ switch ( $type ) {
+ case 'rcid':
+ $valid = RecentChange::newFromId( $id );
+ break;
+ case 'revid':
+ $valid = Revision::newFromId( $id );
+ break;
+ case 'logid':
+ $valid = self::validateLogId( $id );
+ break;
+ }
+
+ if ( !$valid ) {
+ $idResult['status'] = 'error';
+ // Messages: apierror-nosuchrcid apierror-nosuchrevid apierror-nosuchlogid
+ $idResult += $this->getErrorFormatter()->formatMessage( [ "apierror-nosuch$type", $id ] );
+ return $idResult;
+ }
+
+ $status = ChangeTags::updateTagsWithChecks( $params['add'],
+ $params['remove'],
+ ( $type === 'rcid' ? $id : null ),
+ ( $type === 'revid' ? $id : null ),
+ ( $type === 'logid' ? $id : null ),
+ null,
+ $params['reason'],
+ $this->getUser() );
+
+ if ( !$status->isOK() ) {
+ if ( $status->hasMessage( 'actionthrottledtext' ) ) {
+ $idResult['status'] = 'skipped';
+ } else {
+ $idResult['status'] = 'failure';
+ $idResult['errors'] = $this->getErrorFormatter()->arrayFromStatus( $status, 'error' );
+ }
+ } else {
+ $idResult['status'] = 'success';
+ if ( is_null( $status->value->logId ) ) {
+ $idResult['noop'] = true;
+ } else {
+ $idResult['actionlogid'] = $status->value->logId;
+ $idResult['added'] = $status->value->addedTags;
+ ApiResult::setIndexedTagName( $idResult['added'], 't' );
+ $idResult['removed'] = $status->value->removedTags;
+ ApiResult::setIndexedTagName( $idResult['removed'], 't' );
+
+ if ( $params['tags'] ) {
+ ChangeTags::addTags( $params['tags'], null, null, $status->value->logId );
+ }
+ }
+ }
+ return $idResult;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'rcid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'revid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'logid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'add' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'remove' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'reason' => [
+ ApiBase::PARAM_DFLT => '',
+ ],
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=tag&revid=123&add=vandalism&token=123ABC'
+ => 'apihelp-tag-example-rev',
+ 'action=tag&logid=123&remove=spam&reason=Wrongly+applied&token=123ABC'
+ => 'apihelp-tag-example-log',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Tag';
+ }
+}
diff --git a/www/wiki/includes/api/ApiTokens.php b/www/wiki/includes/api/ApiTokens.php
new file mode 100644
index 00000000..fc2951a9
--- /dev/null
+++ b/www/wiki/includes/api/ApiTokens.php
@@ -0,0 +1,116 @@
+<?php
+/**
+ *
+ *
+ * Created on Jul 29, 2011
+ *
+ * Copyright © 2011 John Du Hart john@johnduhart.me
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @deprecated since 1.24
+ * @ingroup API
+ */
+class ApiTokens extends ApiBase {
+
+ public function execute() {
+ $this->addDeprecation(
+ [ 'apiwarn-deprecation-withreplacement', 'action=tokens', 'action=query&meta=tokens' ],
+ 'action=tokens'
+ );
+
+ $params = $this->extractRequestParams();
+ $res = [
+ ApiResult::META_TYPE => 'assoc',
+ ];
+
+ $types = $this->getTokenTypes();
+ foreach ( $params['type'] as $type ) {
+ $val = call_user_func( $types[$type], null, null );
+
+ if ( $val === false ) {
+ $this->addWarning( [ 'apiwarn-tokennotallowed', $type ] );
+ } else {
+ $res[$type . 'token'] = $val;
+ }
+ }
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $res );
+ }
+
+ private function getTokenTypes() {
+ // If we're in a mode that breaks the same-origin policy, no tokens can
+ // be obtained
+ if ( $this->lacksSameOriginSecurity() ) {
+ return [];
+ }
+
+ static $types = null;
+ if ( $types ) {
+ return $types;
+ }
+ $types = [ 'patrol' => [ 'ApiQueryRecentChanges', 'getPatrolToken' ] ];
+ $names = [ 'edit', 'delete', 'protect', 'move', 'block', 'unblock',
+ 'email', 'import', 'watch', 'options' ];
+ foreach ( $names as $name ) {
+ $types[$name] = [ 'ApiQueryInfo', 'get' . ucfirst( $name ) . 'Token' ];
+ }
+ Hooks::run( 'ApiTokensGetTokenTypes', [ &$types ] );
+
+ // For forwards-compat, copy any token types from ApiQueryTokens that
+ // we don't already have something for.
+ $user = $this->getUser();
+ $request = $this->getRequest();
+ foreach ( ApiQueryTokens::getTokenTypeSalts() as $name => $salt ) {
+ if ( !isset( $types[$name] ) ) {
+ $types[$name] = function () use ( $salt, $user, $request ) {
+ return ApiQueryTokens::getToken( $user, $request->getSession(), $salt )->toString();
+ };
+ }
+ }
+
+ ksort( $types );
+
+ return $types;
+ }
+
+ public function isDeprecated() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'type' => [
+ ApiBase::PARAM_DFLT => 'edit',
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => array_keys( $this->getTokenTypes() ),
+ ],
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=tokens'
+ => 'apihelp-tokens-example-edit',
+ 'action=tokens&type=email|move'
+ => 'apihelp-tokens-example-emailmove',
+ ];
+ }
+}
diff --git a/www/wiki/includes/api/ApiUnblock.php b/www/wiki/includes/api/ApiUnblock.php
new file mode 100644
index 00000000..887edaae
--- /dev/null
+++ b/www/wiki/includes/api/ApiUnblock.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ *
+ *
+ * Created on Sep 7, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module that facilitates the unblocking of users. Requires API write mode
+ * to be enabled.
+ *
+ * @ingroup API
+ */
+class ApiUnblock extends ApiBase {
+
+ /**
+ * Unblocks the specified user or provides the reason the unblock failed.
+ */
+ public function execute() {
+ $user = $this->getUser();
+ $params = $this->extractRequestParams();
+
+ $this->requireOnlyOneParameter( $params, 'id', 'user', 'userid' );
+
+ if ( !$user->isAllowed( 'block' ) ) {
+ $this->dieWithError( 'apierror-permissiondenied-unblock', 'permissiondenied' );
+ }
+ # T17810: blocked admins should have limited access here
+ if ( $user->isBlocked() ) {
+ $status = SpecialBlock::checkUnblockSelf( $params['user'], $user );
+ if ( $status !== true ) {
+ $this->dieWithError(
+ $status,
+ null,
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
+ );
+ }
+ }
+
+ // Check if user can add tags
+ if ( !is_null( $params['tags'] ) ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ if ( $params['userid'] !== null ) {
+ $username = User::whoIs( $params['userid'] );
+
+ if ( $username === false ) {
+ $this->dieWithError( [ 'apierror-nosuchuserid', $params['userid'] ], 'nosuchuserid' );
+ } else {
+ $params['user'] = $username;
+ }
+ }
+
+ $data = [
+ 'Target' => is_null( $params['id'] ) ? $params['user'] : "#{$params['id']}",
+ 'Reason' => $params['reason'],
+ 'Tags' => $params['tags']
+ ];
+ $block = Block::newFromTarget( $data['Target'] );
+ $retval = SpecialUnblock::processUnblock( $data, $this->getContext() );
+ if ( $retval !== true ) {
+ $this->dieStatus( $this->errorArrayToStatus( $retval ) );
+ }
+
+ $res['id'] = $block->getId();
+ $target = $block->getType() == Block::TYPE_AUTO ? '' : $block->getTarget();
+ $res['user'] = $target instanceof User ? $target->getName() : $target;
+ $res['userid'] = $target instanceof User ? $target->getId() : 0;
+ $res['reason'] = $params['reason'];
+ $this->getResult()->addValue( null, $this->getModuleName(), $res );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'id' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'user' => null,
+ 'userid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'reason' => '',
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=unblock&id=105'
+ => 'apihelp-unblock-example-id',
+ 'action=unblock&user=Bob&reason=Sorry%20Bob'
+ => 'apihelp-unblock-example-user',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Block';
+ }
+}
diff --git a/www/wiki/includes/api/ApiUndelete.php b/www/wiki/includes/api/ApiUndelete.php
new file mode 100644
index 00000000..3aa7b608
--- /dev/null
+++ b/www/wiki/includes/api/ApiUndelete.php
@@ -0,0 +1,153 @@
+<?php
+/**
+ *
+ *
+ * Created on Jul 3, 2007
+ *
+ * Copyright © 2007 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiUndelete extends ApiBase {
+
+ public function execute() {
+ $this->useTransactionalTimeLimit();
+
+ $params = $this->extractRequestParams();
+
+ $user = $this->getUser();
+ if ( $user->isBlocked() ) {
+ $this->dieBlocked( $user->getBlock() );
+ }
+
+ $titleObj = Title::newFromText( $params['title'] );
+ if ( !$titleObj || $titleObj->isExternal() ) {
+ $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
+ }
+
+ if ( !$titleObj->userCan( 'undelete', $user, 'secure' ) ) {
+ $this->dieWithError( 'permdenied-undelete' );
+ }
+
+ // Check if user can add tags
+ if ( !is_null( $params['tags'] ) ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ // Convert timestamps
+ if ( !isset( $params['timestamps'] ) ) {
+ $params['timestamps'] = [];
+ }
+ if ( !is_array( $params['timestamps'] ) ) {
+ $params['timestamps'] = [ $params['timestamps'] ];
+ }
+ foreach ( $params['timestamps'] as $i => $ts ) {
+ $params['timestamps'][$i] = wfTimestamp( TS_MW, $ts );
+ }
+
+ $pa = new PageArchive( $titleObj, $this->getConfig() );
+ $retval = $pa->undelete(
+ ( isset( $params['timestamps'] ) ? $params['timestamps'] : [] ),
+ $params['reason'],
+ $params['fileids'],
+ false,
+ $user,
+ $params['tags']
+ );
+ if ( !is_array( $retval ) ) {
+ $this->dieWithError( 'apierror-cantundelete' );
+ }
+
+ if ( $retval[1] ) {
+ Hooks::run( 'FileUndeleteComplete',
+ [ $titleObj, $params['fileids'], $this->getUser(), $params['reason'] ] );
+ }
+
+ $this->setWatch( $params['watchlist'], $titleObj );
+
+ $info['title'] = $titleObj->getPrefixedText();
+ $info['revisions'] = intval( $retval[0] );
+ $info['fileversions'] = intval( $retval[1] );
+ $info['reason'] = $retval[2];
+ $this->getResult()->addValue( null, $this->getModuleName(), $info );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'title' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'reason' => '',
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'timestamps' => [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'fileids' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'watchlist' => [
+ ApiBase::PARAM_DFLT => 'preferences',
+ ApiBase::PARAM_TYPE => [
+ 'watch',
+ 'unwatch',
+ 'preferences',
+ 'nochange'
+ ],
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=undelete&title=Main%20Page&token=123ABC&reason=Restoring%20main%20page'
+ => 'apihelp-undelete-example-page',
+ 'action=undelete&title=Main%20Page&token=123ABC' .
+ '&timestamps=2007-07-03T22:00:45Z|2007-07-02T19:48:56Z'
+ => 'apihelp-undelete-example-revisions',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Undelete';
+ }
+}
diff --git a/www/wiki/includes/api/ApiUpload.php b/www/wiki/includes/api/ApiUpload.php
new file mode 100644
index 00000000..cfe19689
--- /dev/null
+++ b/www/wiki/includes/api/ApiUpload.php
@@ -0,0 +1,933 @@
+<?php
+/**
+ *
+ *
+ * Created on Aug 21, 2008
+ *
+ * Copyright © 2008 - 2010 Bryan Tong Minh <Bryan.TongMinh@Gmail.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiUpload extends ApiBase {
+ /** @var UploadBase|UploadFromChunks */
+ protected $mUpload = null;
+
+ protected $mParams;
+
+ public function execute() {
+ // Check whether upload is enabled
+ if ( !UploadBase::isEnabled() ) {
+ $this->dieWithError( 'uploaddisabled' );
+ }
+
+ $user = $this->getUser();
+
+ // Parameter handling
+ $this->mParams = $this->extractRequestParams();
+ $request = $this->getMain()->getRequest();
+ // Check if async mode is actually supported (jobs done in cli mode)
+ $this->mParams['async'] = ( $this->mParams['async'] &&
+ $this->getConfig()->get( 'EnableAsyncUploads' ) );
+ // Add the uploaded file to the params array
+ $this->mParams['file'] = $request->getFileName( 'file' );
+ $this->mParams['chunk'] = $request->getFileName( 'chunk' );
+
+ // Copy the session key to the file key, for backward compatibility.
+ if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
+ $this->mParams['filekey'] = $this->mParams['sessionkey'];
+ }
+
+ // Select an upload module
+ try {
+ if ( !$this->selectUploadModule() ) {
+ return; // not a true upload, but a status request or similar
+ } elseif ( !isset( $this->mUpload ) ) {
+ $this->dieDebug( __METHOD__, 'No upload module set' );
+ }
+ } catch ( UploadStashException $e ) { // XXX: don't spam exception log
+ $this->dieStatus( $this->handleStashException( $e ) );
+ }
+
+ // First check permission to upload
+ $this->checkPermissions( $user );
+
+ // Fetch the file (usually a no-op)
+ /** @var Status $status */
+ $status = $this->mUpload->fetchFile();
+ if ( !$status->isGood() ) {
+ $this->dieStatus( $status );
+ }
+
+ // Check if the uploaded file is sane
+ if ( $this->mParams['chunk'] ) {
+ $maxSize = UploadBase::getMaxUploadSize();
+ if ( $this->mParams['filesize'] > $maxSize ) {
+ $this->dieWithError( 'file-too-large' );
+ }
+ if ( !$this->mUpload->getTitle() ) {
+ $this->dieWithError( 'illegal-filename' );
+ }
+ } elseif ( $this->mParams['async'] && $this->mParams['filekey'] ) {
+ // defer verification to background process
+ } else {
+ wfDebug( __METHOD__ . " about to verify\n" );
+ $this->verifyUpload();
+ }
+
+ // Check if the user has the rights to modify or overwrite the requested title
+ // (This check is irrelevant if stashing is already requested, since the errors
+ // can always be fixed by changing the title)
+ if ( !$this->mParams['stash'] ) {
+ $permErrors = $this->mUpload->verifyTitlePermissions( $user );
+ if ( $permErrors !== true ) {
+ $this->dieRecoverableError( $permErrors, 'filename' );
+ }
+ }
+
+ // Get the result based on the current upload context:
+ try {
+ $result = $this->getContextResult();
+ } catch ( UploadStashException $e ) { // XXX: don't spam exception log
+ $this->dieStatus( $this->handleStashException( $e ) );
+ }
+ $this->getResult()->addValue( null, $this->getModuleName(), $result );
+
+ // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
+ // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
+ if ( $result['result'] === 'Success' ) {
+ $imageinfo = $this->mUpload->getImageInfo( $this->getResult() );
+ $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
+ }
+
+ // Cleanup any temporary mess
+ $this->mUpload->cleanupTempFile();
+ }
+
+ /**
+ * Get an upload result based on upload context
+ * @return array
+ */
+ private function getContextResult() {
+ $warnings = $this->getApiWarnings();
+ if ( $warnings && !$this->mParams['ignorewarnings'] ) {
+ // Get warnings formatted in result array format
+ return $this->getWarningsResult( $warnings );
+ } elseif ( $this->mParams['chunk'] ) {
+ // Add chunk, and get result
+ return $this->getChunkResult( $warnings );
+ } elseif ( $this->mParams['stash'] ) {
+ // Stash the file and get stash result
+ return $this->getStashResult( $warnings );
+ }
+
+ // Check throttle after we've handled warnings
+ if ( UploadBase::isThrottled( $this->getUser() )
+ ) {
+ $this->dieWithError( 'apierror-ratelimited' );
+ }
+
+ // This is the most common case -- a normal upload with no warnings
+ // performUpload will return a formatted properly for the API with status
+ return $this->performUpload( $warnings );
+ }
+
+ /**
+ * Get Stash Result, throws an exception if the file could not be stashed.
+ * @param array $warnings Array of Api upload warnings
+ * @return array
+ */
+ private function getStashResult( $warnings ) {
+ $result = [];
+ $result['result'] = 'Success';
+ if ( $warnings && count( $warnings ) > 0 ) {
+ $result['warnings'] = $warnings;
+ }
+ // Some uploads can request they be stashed, so as not to publish them immediately.
+ // In this case, a failure to stash ought to be fatal
+ $this->performStash( 'critical', $result );
+
+ return $result;
+ }
+
+ /**
+ * Get Warnings Result
+ * @param array $warnings Array of Api upload warnings
+ * @return array
+ */
+ private function getWarningsResult( $warnings ) {
+ $result = [];
+ $result['result'] = 'Warning';
+ $result['warnings'] = $warnings;
+ // in case the warnings can be fixed with some further user action, let's stash this upload
+ // and return a key they can use to restart it
+ $this->performStash( 'optional', $result );
+
+ return $result;
+ }
+
+ /**
+ * Get the result of a chunk upload.
+ * @param array $warnings Array of Api upload warnings
+ * @return array
+ */
+ private function getChunkResult( $warnings ) {
+ $result = [];
+
+ if ( $warnings && count( $warnings ) > 0 ) {
+ $result['warnings'] = $warnings;
+ }
+
+ $request = $this->getMain()->getRequest();
+ $chunkPath = $request->getFileTempname( 'chunk' );
+ $chunkSize = $request->getUpload( 'chunk' )->getSize();
+ $totalSoFar = $this->mParams['offset'] + $chunkSize;
+ $minChunkSize = $this->getConfig()->get( 'MinUploadChunkSize' );
+
+ // Sanity check sizing
+ if ( $totalSoFar > $this->mParams['filesize'] ) {
+ $this->dieWithError( 'apierror-invalid-chunk' );
+ }
+
+ // Enforce minimum chunk size
+ if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
+ $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
+ }
+
+ if ( $this->mParams['offset'] == 0 ) {
+ $filekey = $this->performStash( 'critical' );
+ } else {
+ $filekey = $this->mParams['filekey'];
+
+ // Don't allow further uploads to an already-completed session
+ $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
+ if ( !$progress ) {
+ // Probably can't get here, but check anyway just in case
+ $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
+ } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
+ $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
+ }
+
+ $status = $this->mUpload->addChunk(
+ $chunkPath, $chunkSize, $this->mParams['offset'] );
+ if ( !$status->isGood() ) {
+ $extradata = [
+ 'offset' => $this->mUpload->getOffset(),
+ ];
+
+ $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
+ }
+ }
+
+ // Check we added the last chunk:
+ if ( $totalSoFar == $this->mParams['filesize'] ) {
+ if ( $this->mParams['async'] ) {
+ UploadBase::setSessionStatus(
+ $this->getUser(),
+ $filekey,
+ [ 'result' => 'Poll',
+ 'stage' => 'queued', 'status' => Status::newGood() ]
+ );
+ JobQueueGroup::singleton()->push( new AssembleUploadChunksJob(
+ Title::makeTitle( NS_FILE, $filekey ),
+ [
+ 'filename' => $this->mParams['filename'],
+ 'filekey' => $filekey,
+ 'session' => $this->getContext()->exportSession()
+ ]
+ ) );
+ $result['result'] = 'Poll';
+ $result['stage'] = 'queued';
+ } else {
+ $status = $this->mUpload->concatenateChunks();
+ if ( !$status->isGood() ) {
+ UploadBase::setSessionStatus(
+ $this->getUser(),
+ $filekey,
+ [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
+ );
+ $this->dieStatusWithCode( $status, 'stashfailed' );
+ }
+
+ // We can only get warnings like 'duplicate' after concatenating the chunks
+ $warnings = $this->getApiWarnings();
+ if ( $warnings ) {
+ $result['warnings'] = $warnings;
+ }
+
+ // The fully concatenated file has a new filekey. So remove
+ // the old filekey and fetch the new one.
+ UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
+ $this->mUpload->stash->removeFile( $filekey );
+ $filekey = $this->mUpload->getStashFile()->getFileKey();
+
+ $result['result'] = 'Success';
+ }
+ } else {
+ UploadBase::setSessionStatus(
+ $this->getUser(),
+ $filekey,
+ [
+ 'result' => 'Continue',
+ 'stage' => 'uploading',
+ 'offset' => $totalSoFar,
+ 'status' => Status::newGood(),
+ ]
+ );
+ $result['result'] = 'Continue';
+ $result['offset'] = $totalSoFar;
+ }
+
+ $result['filekey'] = $filekey;
+
+ return $result;
+ }
+
+ /**
+ * Stash the file and add the file key, or error information if it fails, to the data.
+ *
+ * @param string $failureMode What to do on failure to stash:
+ * - When 'critical', use dieStatus() to produce an error response and throw an exception.
+ * Use this when stashing the file was the primary purpose of the API request.
+ * - When 'optional', only add a 'stashfailed' key to the data and return null.
+ * Use this when some error happened for a non-stash upload and we're stashing the file
+ * only to save the client the trouble of re-uploading it.
+ * @param array &$data API result to which to add the information
+ * @return string|null File key
+ */
+ private function performStash( $failureMode, &$data = null ) {
+ $isPartial = (bool)$this->mParams['chunk'];
+ try {
+ $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
+
+ if ( $status->isGood() && !$status->getValue() ) {
+ // Not actually a 'good' status...
+ $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
+ }
+ } catch ( Exception $e ) {
+ $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
+ wfDebug( __METHOD__ . ' ' . $debugMessage . "\n" );
+ $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
+ $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
+ ) );
+ }
+
+ if ( $status->isGood() ) {
+ $stashFile = $status->getValue();
+ $data['filekey'] = $stashFile->getFileKey();
+ // Backwards compatibility
+ $data['sessionkey'] = $data['filekey'];
+ return $data['filekey'];
+ }
+
+ if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
+ // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
+ // Statuses for it. Just extract the exception details and parse them ourselves.
+ list( $exceptionType, $message ) = $status->getMessage()->getParams();
+ $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
+ wfDebug( __METHOD__ . ' ' . $debugMessage . "\n" );
+ }
+
+ // Bad status
+ if ( $failureMode !== 'optional' ) {
+ $this->dieStatus( $status );
+ } else {
+ $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
+ return null;
+ }
+ }
+
+ /**
+ * Throw an error that the user can recover from by providing a better
+ * value for $parameter
+ *
+ * @param array $errors Array of Message objects, message keys, key+param
+ * arrays, or StatusValue::getErrors()-style arrays
+ * @param string|null $parameter Parameter that needs revising
+ * @throws ApiUsageException
+ */
+ private function dieRecoverableError( $errors, $parameter = null ) {
+ $this->performStash( 'optional', $data );
+
+ if ( $parameter ) {
+ $data['invalidparameter'] = $parameter;
+ }
+
+ $sv = StatusValue::newGood();
+ foreach ( $errors as $error ) {
+ $msg = ApiMessage::create( $error );
+ $msg->setApiData( $msg->getApiData() + $data );
+ $sv->fatal( $msg );
+ }
+ $this->dieStatus( $sv );
+ }
+
+ /**
+ * Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from
+ * IApiMessage.
+ *
+ * @param Status $status
+ * @param string $overrideCode Error code to use if there isn't one from IApiMessage
+ * @param array|null $moreExtraData
+ * @throws ApiUsageException
+ */
+ public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) {
+ $sv = StatusValue::newGood();
+ foreach ( $status->getErrors() as $error ) {
+ $msg = ApiMessage::create( $error, $overrideCode );
+ if ( $moreExtraData ) {
+ $msg->setApiData( $msg->getApiData() + $moreExtraData );
+ }
+ $sv->fatal( $msg );
+ }
+ $this->dieStatus( $sv );
+ }
+
+ /**
+ * Select an upload module and set it to mUpload. Dies on failure. If the
+ * request was a status request and not a true upload, returns false;
+ * otherwise true
+ *
+ * @return bool
+ */
+ protected function selectUploadModule() {
+ $request = $this->getMain()->getRequest();
+
+ // chunk or one and only one of the following parameters is needed
+ if ( !$this->mParams['chunk'] ) {
+ $this->requireOnlyOneParameter( $this->mParams,
+ 'filekey', 'file', 'url' );
+ }
+
+ // Status report for "upload to stash"/"upload from stash"
+ if ( $this->mParams['filekey'] && $this->mParams['checkstatus'] ) {
+ $progress = UploadBase::getSessionStatus( $this->getUser(), $this->mParams['filekey'] );
+ if ( !$progress ) {
+ $this->dieWithError( 'api-upload-missingresult', 'missingresult' );
+ } elseif ( !$progress['status']->isGood() ) {
+ $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
+ }
+ if ( isset( $progress['status']->value['verification'] ) ) {
+ $this->checkVerification( $progress['status']->value['verification'] );
+ }
+ if ( isset( $progress['status']->value['warnings'] ) ) {
+ $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
+ if ( $warnings ) {
+ $progress['warnings'] = $warnings;
+ }
+ }
+ unset( $progress['status'] ); // remove Status object
+ $imageinfo = null;
+ if ( isset( $progress['imageinfo'] ) ) {
+ $imageinfo = $progress['imageinfo'];
+ unset( $progress['imageinfo'] );
+ }
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $progress );
+ // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
+ // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
+ if ( $imageinfo ) {
+ $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
+ }
+
+ return false;
+ }
+
+ // The following modules all require the filename parameter to be set
+ if ( is_null( $this->mParams['filename'] ) ) {
+ $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
+ }
+
+ if ( $this->mParams['chunk'] ) {
+ // Chunk upload
+ $this->mUpload = new UploadFromChunks( $this->getUser() );
+ if ( isset( $this->mParams['filekey'] ) ) {
+ if ( $this->mParams['offset'] === 0 ) {
+ $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
+ }
+
+ // handle new chunk
+ $this->mUpload->continueChunks(
+ $this->mParams['filename'],
+ $this->mParams['filekey'],
+ $request->getUpload( 'chunk' )
+ );
+ } else {
+ if ( $this->mParams['offset'] !== 0 ) {
+ $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
+ }
+
+ // handle first chunk
+ $this->mUpload->initialize(
+ $this->mParams['filename'],
+ $request->getUpload( 'chunk' )
+ );
+ }
+ } elseif ( isset( $this->mParams['filekey'] ) ) {
+ // Upload stashed in a previous request
+ if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
+ $this->dieWithError( 'apierror-invalid-file-key' );
+ }
+
+ $this->mUpload = new UploadFromStash( $this->getUser() );
+ // This will not download the temp file in initialize() in async mode.
+ // We still have enough information to call checkWarnings() and such.
+ $this->mUpload->initialize(
+ $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
+ );
+ } elseif ( isset( $this->mParams['file'] ) ) {
+ // Can't async upload directly from a POSTed file, we'd have to
+ // stash the file and then queue the publish job. The user should
+ // just submit the two API queries to perform those two steps.
+ if ( $this->mParams['async'] ) {
+ $this->dieWithError( 'apierror-cannot-async-upload-file' );
+ }
+
+ $this->mUpload = new UploadFromFile();
+ $this->mUpload->initialize(
+ $this->mParams['filename'],
+ $request->getUpload( 'file' )
+ );
+ } elseif ( isset( $this->mParams['url'] ) ) {
+ // Make sure upload by URL is enabled:
+ if ( !UploadFromUrl::isEnabled() ) {
+ $this->dieWithError( 'copyuploaddisabled' );
+ }
+
+ if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
+ $this->dieWithError( 'apierror-copyuploadbaddomain' );
+ }
+
+ if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
+ $this->dieWithError( 'apierror-copyuploadbadurl' );
+ }
+
+ $this->mUpload = new UploadFromUrl;
+ $this->mUpload->initialize( $this->mParams['filename'],
+ $this->mParams['url'] );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks that the user has permissions to perform this upload.
+ * Dies with usage message on inadequate permissions.
+ * @param User $user The user to check.
+ */
+ protected function checkPermissions( $user ) {
+ // Check whether the user has the appropriate permissions to upload anyway
+ $permission = $this->mUpload->isAllowed( $user );
+
+ if ( $permission !== true ) {
+ if ( !$user->isLoggedIn() ) {
+ $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
+ }
+
+ $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
+ }
+
+ // Check blocks
+ if ( $user->isBlocked() ) {
+ $this->dieBlocked( $user->getBlock() );
+ }
+
+ // Global blocks
+ if ( $user->isBlockedGlobally() ) {
+ $this->dieBlocked( $user->getGlobalBlock() );
+ }
+ }
+
+ /**
+ * Performs file verification, dies on error.
+ */
+ protected function verifyUpload() {
+ $verification = $this->mUpload->verifyUpload();
+ if ( $verification['status'] === UploadBase::OK ) {
+ return;
+ }
+
+ $this->checkVerification( $verification );
+ }
+
+ /**
+ * Performs file verification, dies on error.
+ * @param array $verification
+ */
+ protected function checkVerification( array $verification ) {
+ switch ( $verification['status'] ) {
+ // Recoverable errors
+ case UploadBase::MIN_LENGTH_PARTNAME:
+ $this->dieRecoverableError( [ 'filename-tooshort' ], 'filename' );
+ break;
+ case UploadBase::ILLEGAL_FILENAME:
+ $this->dieRecoverableError(
+ [ ApiMessage::create(
+ 'illegal-filename', null, [ 'filename' => $verification['filtered'] ]
+ ) ], 'filename'
+ );
+ break;
+ case UploadBase::FILENAME_TOO_LONG:
+ $this->dieRecoverableError( [ 'filename-toolong' ], 'filename' );
+ break;
+ case UploadBase::FILETYPE_MISSING:
+ $this->dieRecoverableError( [ 'filetype-missing' ], 'filename' );
+ break;
+ case UploadBase::WINDOWS_NONASCII_FILENAME:
+ $this->dieRecoverableError( [ 'windows-nonascii-filename' ], 'filename' );
+ break;
+
+ // Unrecoverable errors
+ case UploadBase::EMPTY_FILE:
+ $this->dieWithError( 'empty-file' );
+ break;
+ case UploadBase::FILE_TOO_LARGE:
+ $this->dieWithError( 'file-too-large' );
+ break;
+
+ case UploadBase::FILETYPE_BADTYPE:
+ $extradata = [
+ 'filetype' => $verification['finalExt'],
+ 'allowed' => array_values( array_unique( $this->getConfig()->get( 'FileExtensions' ) ) )
+ ];
+ $extensions = array_unique( $this->getConfig()->get( 'FileExtensions' ) );
+ $msg = [
+ 'filetype-banned-type',
+ null, // filled in below
+ Message::listParam( $extensions, 'comma' ),
+ count( $extensions ),
+ null, // filled in below
+ ];
+ ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' );
+
+ if ( isset( $verification['blacklistedExt'] ) ) {
+ $msg[1] = Message::listParam( $verification['blacklistedExt'], 'comma' );
+ $msg[4] = count( $verification['blacklistedExt'] );
+ $extradata['blacklisted'] = array_values( $verification['blacklistedExt'] );
+ ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' );
+ } else {
+ $msg[1] = $verification['finalExt'];
+ $msg[4] = 1;
+ }
+
+ $this->dieWithError( $msg, 'filetype-banned', $extradata );
+ break;
+
+ case UploadBase::VERIFICATION_ERROR:
+ $msg = ApiMessage::create( $verification['details'], 'verification-error' );
+ if ( $verification['details'][0] instanceof MessageSpecifier ) {
+ $details = array_merge( [ $msg->getKey() ], $msg->getParams() );
+ } else {
+ $details = $verification['details'];
+ }
+ ApiResult::setIndexedTagName( $details, 'detail' );
+ $msg->setApiData( $msg->getApiData() + [ 'details' => $details ] );
+ $this->dieWithError( $msg );
+ break;
+
+ case UploadBase::HOOK_ABORTED:
+ $msg = $verification['error'] === '' ? 'hookaborted' : $verification['error'];
+ $this->dieWithError( $msg, 'hookaborted', [ 'details' => $verification['error'] ] );
+ break;
+ default:
+ $this->dieWithError( 'apierror-unknownerror-nocode', 'unknown-error',
+ [ 'details' => [ 'code' => $verification['status'] ] ] );
+ break;
+ }
+ }
+
+ /**
+ * Check warnings.
+ * Returns a suitable array for inclusion into API results if there were warnings
+ * Returns the empty array if there were no warnings
+ *
+ * @return array
+ */
+ protected function getApiWarnings() {
+ $warnings = $this->mUpload->checkWarnings();
+
+ return $this->transformWarnings( $warnings );
+ }
+
+ protected function transformWarnings( $warnings ) {
+ if ( $warnings ) {
+ // Add indices
+ ApiResult::setIndexedTagName( $warnings, 'warning' );
+
+ if ( isset( $warnings['duplicate'] ) ) {
+ $dupes = [];
+ /** @var File $dupe */
+ foreach ( $warnings['duplicate'] as $dupe ) {
+ $dupes[] = $dupe->getName();
+ }
+ ApiResult::setIndexedTagName( $dupes, 'duplicate' );
+ $warnings['duplicate'] = $dupes;
+ }
+
+ if ( isset( $warnings['exists'] ) ) {
+ $warning = $warnings['exists'];
+ unset( $warnings['exists'] );
+ /** @var LocalFile $localFile */
+ $localFile = isset( $warning['normalizedFile'] )
+ ? $warning['normalizedFile']
+ : $warning['file'];
+ $warnings[$warning['warning']] = $localFile->getName();
+ }
+
+ if ( isset( $warnings['no-change'] ) ) {
+ /** @var File $file */
+ $file = $warnings['no-change'];
+ unset( $warnings['no-change'] );
+
+ $warnings['nochange'] = [
+ 'timestamp' => wfTimestamp( TS_ISO_8601, $file->getTimestamp() )
+ ];
+ }
+
+ if ( isset( $warnings['duplicate-version'] ) ) {
+ $dupes = [];
+ /** @var File $dupe */
+ foreach ( $warnings['duplicate-version'] as $dupe ) {
+ $dupes[] = [
+ 'timestamp' => wfTimestamp( TS_ISO_8601, $dupe->getTimestamp() )
+ ];
+ }
+ unset( $warnings['duplicate-version'] );
+
+ ApiResult::setIndexedTagName( $dupes, 'ver' );
+ $warnings['duplicateversions'] = $dupes;
+ }
+ }
+
+ return $warnings;
+ }
+
+ /**
+ * Handles a stash exception, giving a useful error to the user.
+ * @todo Internationalize the exceptions then get rid of this
+ * @param Exception $e
+ * @return StatusValue
+ */
+ protected function handleStashException( $e ) {
+ switch ( get_class( $e ) ) {
+ case 'UploadStashFileNotFoundException':
+ $wrap = 'apierror-stashedfilenotfound';
+ break;
+ case 'UploadStashBadPathException':
+ $wrap = 'apierror-stashpathinvalid';
+ break;
+ case 'UploadStashFileException':
+ $wrap = 'apierror-stashfilestorage';
+ break;
+ case 'UploadStashZeroLengthFileException':
+ $wrap = 'apierror-stashzerolength';
+ break;
+ case 'UploadStashNotLoggedInException':
+ return StatusValue::newFatal( ApiMessage::create(
+ [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
+ ) );
+ case 'UploadStashWrongOwnerException':
+ $wrap = 'apierror-stashwrongowner';
+ break;
+ case 'UploadStashNoSuchKeyException':
+ $wrap = 'apierror-stashnosuchfilekey';
+ break;
+ default:
+ $wrap = [ 'uploadstash-exception', get_class( $e ) ];
+ break;
+ }
+ return StatusValue::newFatal(
+ $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
+ );
+ }
+
+ /**
+ * Perform the actual upload. Returns a suitable result array on success;
+ * dies on failure.
+ *
+ * @param array $warnings Array of Api upload warnings
+ * @return array
+ */
+ protected function performUpload( $warnings ) {
+ // Use comment as initial page text by default
+ if ( is_null( $this->mParams['text'] ) ) {
+ $this->mParams['text'] = $this->mParams['comment'];
+ }
+
+ /** @var LocalFile $file */
+ $file = $this->mUpload->getLocalFile();
+
+ // For preferences mode, we want to watch if 'watchdefault' is set,
+ // or if the *file* doesn't exist, and either 'watchuploads' or
+ // 'watchcreations' is set. But getWatchlistValue()'s automatic
+ // handling checks if the *title* exists or not, so we need to check
+ // all three preferences manually.
+ $watch = $this->getWatchlistValue(
+ $this->mParams['watchlist'], $file->getTitle(), 'watchdefault'
+ );
+
+ if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) {
+ $watch = (
+ $this->getWatchlistValue( 'preferences', $file->getTitle(), 'watchuploads' ) ||
+ $this->getWatchlistValue( 'preferences', $file->getTitle(), 'watchcreations' )
+ );
+ }
+
+ // Deprecated parameters
+ if ( $this->mParams['watch'] ) {
+ $watch = true;
+ }
+
+ if ( $this->mParams['tags'] ) {
+ $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getUser() );
+ if ( !$status->isOK() ) {
+ $this->dieStatus( $status );
+ }
+ }
+
+ // No errors, no warnings: do the upload
+ if ( $this->mParams['async'] ) {
+ $progress = UploadBase::getSessionStatus( $this->getUser(), $this->mParams['filekey'] );
+ if ( $progress && $progress['result'] === 'Poll' ) {
+ $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' );
+ }
+ UploadBase::setSessionStatus(
+ $this->getUser(),
+ $this->mParams['filekey'],
+ [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
+ );
+ JobQueueGroup::singleton()->push( new PublishStashedFileJob(
+ Title::makeTitle( NS_FILE, $this->mParams['filename'] ),
+ [
+ 'filename' => $this->mParams['filename'],
+ 'filekey' => $this->mParams['filekey'],
+ 'comment' => $this->mParams['comment'],
+ 'tags' => $this->mParams['tags'],
+ 'text' => $this->mParams['text'],
+ 'watch' => $watch,
+ 'session' => $this->getContext()->exportSession()
+ ]
+ ) );
+ $result['result'] = 'Poll';
+ $result['stage'] = 'queued';
+ } else {
+ /** @var Status $status */
+ $status = $this->mUpload->performUpload( $this->mParams['comment'],
+ $this->mParams['text'], $watch, $this->getUser(), $this->mParams['tags'] );
+
+ if ( !$status->isGood() ) {
+ $this->dieRecoverableError( $status->getErrors() );
+ }
+ $result['result'] = 'Success';
+ }
+
+ $result['filename'] = $file->getName();
+ if ( $warnings && count( $warnings ) > 0 ) {
+ $result['warnings'] = $warnings;
+ }
+
+ return $result;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ $params = [
+ 'filename' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'comment' => [
+ ApiBase::PARAM_DFLT => ''
+ ],
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'text' => [
+ ApiBase::PARAM_TYPE => 'text',
+ ],
+ 'watch' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'watchlist' => [
+ ApiBase::PARAM_DFLT => 'preferences',
+ ApiBase::PARAM_TYPE => [
+ 'watch',
+ 'preferences',
+ 'nochange'
+ ],
+ ],
+ 'ignorewarnings' => false,
+ 'file' => [
+ ApiBase::PARAM_TYPE => 'upload',
+ ],
+ 'url' => null,
+ 'filekey' => null,
+ 'sessionkey' => [
+ ApiBase::PARAM_DEPRECATED => true,
+ ],
+ 'stash' => false,
+
+ 'filesize' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => UploadBase::getMaxUploadSize(),
+ ],
+ 'offset' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ],
+ 'chunk' => [
+ ApiBase::PARAM_TYPE => 'upload',
+ ],
+
+ 'async' => false,
+ 'checkstatus' => false,
+ ];
+
+ return $params;
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=upload&filename=Wiki.png' .
+ '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
+ => 'apihelp-upload-example-url',
+ 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
+ => 'apihelp-upload-example-filekey',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
+ }
+}
diff --git a/www/wiki/includes/api/ApiUsageException.php b/www/wiki/includes/api/ApiUsageException.php
new file mode 100644
index 00000000..47902a75
--- /dev/null
+++ b/www/wiki/includes/api/ApiUsageException.php
@@ -0,0 +1,232 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @defgroup API API
+ */
+
+/**
+ * This exception will be thrown when dieUsage is called to stop module execution.
+ *
+ * @ingroup API
+ * @deprecated since 1.29, use ApiUsageException instead
+ */
+class UsageException extends MWException {
+
+ private $mCodestr;
+
+ /**
+ * @var null|array
+ */
+ private $mExtraData;
+
+ /**
+ * @param string $message
+ * @param string $codestr
+ * @param int $code
+ * @param array|null $extradata
+ */
+ public function __construct( $message, $codestr, $code = 0, $extradata = null ) {
+ parent::__construct( $message, $code );
+ $this->mCodestr = $codestr;
+ $this->mExtraData = $extradata;
+
+ if ( !$this instanceof ApiUsageException ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ }
+
+ // This should never happen, so throw an exception about it that will
+ // hopefully get logged with a backtrace (T138585)
+ if ( !is_string( $codestr ) || $codestr === '' ) {
+ throw new InvalidArgumentException( 'Invalid $codestr, was ' .
+ ( $codestr === '' ? 'empty string' : gettype( $codestr ) )
+ );
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getCodeString() {
+ wfDeprecated( __METHOD__, '1.29' );
+ return $this->mCodestr;
+ }
+
+ /**
+ * @return array
+ */
+ public function getMessageArray() {
+ wfDeprecated( __METHOD__, '1.29' );
+ $result = [
+ 'code' => $this->mCodestr,
+ 'info' => $this->getMessage()
+ ];
+ if ( is_array( $this->mExtraData ) ) {
+ $result = array_merge( $result, $this->mExtraData );
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString() {
+ return "{$this->getCodeString()}: {$this->getMessage()}";
+ }
+}
+
+/**
+ * Exception used to abort API execution with an error
+ *
+ * If possible, use ApiBase::dieWithError() instead of throwing this directly.
+ *
+ * @ingroup API
+ * @note This currently extends UsageException for backwards compatibility, so
+ * all the existing code that catches UsageException won't break when stuff
+ * starts throwing ApiUsageException. Eventually UsageException will go away
+ * and this will (probably) extend MWException directly.
+ */
+class ApiUsageException extends UsageException implements ILocalizedException {
+
+ protected $modulePath;
+ protected $status;
+
+ /**
+ * @param ApiBase|null $module API module responsible for the error, if known
+ * @param StatusValue $status Status holding errors
+ * @param int $httpCode HTTP error code to use
+ */
+ public function __construct(
+ ApiBase $module = null, StatusValue $status, $httpCode = 0
+ ) {
+ if ( $status->isOK() ) {
+ throw new InvalidArgumentException( __METHOD__ . ' requires a fatal Status' );
+ }
+
+ $this->modulePath = $module ? $module->getModulePath() : null;
+ $this->status = $status;
+
+ // Bug T46111: Messages in the log files should be in English and not
+ // customized by the local wiki.
+ $enMsg = clone $this->getApiMessage();
+ $enMsg->inLanguage( 'en' )->useDatabase( false );
+ parent::__construct(
+ ApiErrorFormatter::stripMarkup( $enMsg->text() ),
+ $enMsg->getApiCode(),
+ $httpCode,
+ $enMsg->getApiData()
+ );
+ }
+
+ /**
+ * @param ApiBase|null $module API module responsible for the error, if known
+ * @param string|array|Message $msg See ApiMessage::create()
+ * @param string|null $code See ApiMessage::create()
+ * @param array|null $data See ApiMessage::create()
+ * @param int $httpCode HTTP error code to use
+ * @return static
+ */
+ public static function newWithMessage(
+ ApiBase $module = null, $msg, $code = null, $data = null, $httpCode = 0
+ ) {
+ return new static(
+ $module,
+ StatusValue::newFatal( ApiMessage::create( $msg, $code, $data ) ),
+ $httpCode
+ );
+ }
+
+ /**
+ * @return ApiMessage
+ */
+ private function getApiMessage() {
+ $errors = $this->status->getErrorsByType( 'error' );
+ if ( !$errors ) {
+ $errors = $this->status->getErrors();
+ }
+ if ( !$errors ) {
+ $msg = new ApiMessage( 'apierror-unknownerror-nocode', 'unknownerror' );
+ } else {
+ $msg = ApiMessage::create( $errors[0] );
+ }
+ return $msg;
+ }
+
+ /**
+ * Fetch the responsible module name
+ * @return string|null
+ */
+ public function getModulePath() {
+ return $this->modulePath;
+ }
+
+ /**
+ * Fetch the error status
+ * @return StatusValue
+ */
+ public function getStatusValue() {
+ return $this->status;
+ }
+
+ /**
+ * @deprecated Do not use. This only exists here because UsageException is in
+ * the inheritance chain for backwards compatibility.
+ * @inheritDoc
+ */
+ public function getCodeString() {
+ wfDeprecated( __METHOD__, '1.29' );
+ return $this->getApiMessage()->getApiCode();
+ }
+
+ /**
+ * @deprecated Do not use. This only exists here because UsageException is in
+ * the inheritance chain for backwards compatibility.
+ * @inheritDoc
+ */
+ public function getMessageArray() {
+ wfDeprecated( __METHOD__, '1.29' );
+ $enMsg = clone $this->getApiMessage();
+ $enMsg->inLanguage( 'en' )->useDatabase( false );
+
+ return [
+ 'code' => $enMsg->getApiCode(),
+ 'info' => ApiErrorFormatter::stripMarkup( $enMsg->text() ),
+ ] + $enMsg->getApiData();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getMessageObject() {
+ return $this->status->getMessage();
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString() {
+ $enMsg = clone $this->getApiMessage();
+ $enMsg->inLanguage( 'en' )->useDatabase( false );
+ $text = ApiErrorFormatter::stripMarkup( $enMsg->text() );
+
+ return get_class( $this ) . ": {$enMsg->getApiCode()}: {$text} "
+ . "in {$this->getFile()}:{$this->getLine()}\n"
+ . "Stack trace:\n{$this->getTraceAsString()}";
+ }
+
+}
diff --git a/www/wiki/includes/api/ApiUserrights.php b/www/wiki/includes/api/ApiUserrights.php
new file mode 100644
index 00000000..2a364d97
--- /dev/null
+++ b/www/wiki/includes/api/ApiUserrights.php
@@ -0,0 +1,219 @@
+<?php
+
+/**
+ * API userrights module
+ *
+ * Copyright © 2009 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiUserrights extends ApiBase {
+
+ private $mUser = null;
+
+ /**
+ * Get a UserrightsPage object, or subclass.
+ * @return UserrightsPage
+ */
+ protected function getUserRightsPage() {
+ return new UserrightsPage;
+ }
+
+ /**
+ * Get all available groups.
+ * @return array
+ */
+ protected function getAllGroups() {
+ return User::getAllGroups();
+ }
+
+ public function execute() {
+ $pUser = $this->getUser();
+
+ // Deny if the user is blocked and doesn't have the full 'userrights' permission.
+ // This matches what Special:UserRights does for the web UI.
+ if ( $pUser->isBlocked() && !$pUser->isAllowed( 'userrights' ) ) {
+ $this->dieBlocked( $pUser->getBlock() );
+ }
+
+ $params = $this->extractRequestParams();
+
+ // Figure out expiry times from the input
+ // $params['expiry'] may not be set in subclasses
+ if ( isset( $params['expiry'] ) ) {
+ $expiry = (array)$params['expiry'];
+ } else {
+ $expiry = [ 'infinity' ];
+ }
+ if ( count( $expiry ) !== count( $params['add'] ) ) {
+ if ( count( $expiry ) === 1 ) {
+ $expiry = array_fill( 0, count( $params['add'] ), $expiry[0] );
+ } else {
+ $this->dieWithError( [
+ 'apierror-toofewexpiries',
+ count( $expiry ),
+ count( $params['add'] )
+ ] );
+ }
+ }
+
+ // Validate the expiries
+ $groupExpiries = [];
+ foreach ( $expiry as $index => $expiryValue ) {
+ $group = $params['add'][$index];
+ $groupExpiries[$group] = UserrightsPage::expiryToTimestamp( $expiryValue );
+
+ if ( $groupExpiries[$group] === false ) {
+ $this->dieWithError( [ 'apierror-invalidexpiry', wfEscapeWikiText( $expiryValue ) ] );
+ }
+
+ // not allowed to have things expiring in the past
+ if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
+ $this->dieWithError( [ 'apierror-pastexpiry', wfEscapeWikiText( $expiryValue ) ] );
+ }
+ }
+
+ $user = $this->getUrUser( $params );
+
+ $tags = $params['tags'];
+
+ // Check if user can add tags
+ if ( !is_null( $tags ) ) {
+ $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $tags, $pUser );
+ if ( !$ableToTag->isOK() ) {
+ $this->dieStatus( $ableToTag );
+ }
+ }
+
+ $form = $this->getUserRightsPage();
+ $form->setContext( $this->getContext() );
+ $r['user'] = $user->getName();
+ $r['userid'] = $user->getId();
+ list( $r['added'], $r['removed'] ) = $form->doSaveUserGroups(
+ $user, (array)$params['add'], (array)$params['remove'],
+ $params['reason'], $tags, $groupExpiries
+ );
+
+ $result = $this->getResult();
+ ApiResult::setIndexedTagName( $r['added'], 'group' );
+ ApiResult::setIndexedTagName( $r['removed'], 'group' );
+ $result->addValue( null, $this->getModuleName(), $r );
+ }
+
+ /**
+ * @param array $params
+ * @return User
+ */
+ private function getUrUser( array $params ) {
+ if ( $this->mUser !== null ) {
+ return $this->mUser;
+ }
+
+ $this->requireOnlyOneParameter( $params, 'user', 'userid' );
+
+ $user = isset( $params['user'] ) ? $params['user'] : '#' . $params['userid'];
+
+ $form = $this->getUserRightsPage();
+ $form->setContext( $this->getContext() );
+ $status = $form->fetchUser( $user );
+ if ( !$status->isOK() ) {
+ $this->dieStatus( $status );
+ }
+
+ $this->mUser = $status->value;
+
+ return $status->value;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ $a = [
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'userid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'add' => [
+ ApiBase::PARAM_TYPE => $this->getAllGroups(),
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'expiry' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ALLOW_DUPLICATES => true,
+ ApiBase::PARAM_DFLT => 'infinite',
+ ],
+ 'remove' => [
+ ApiBase::PARAM_TYPE => $this->getAllGroups(),
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ 'reason' => [
+ ApiBase::PARAM_DFLT => ''
+ ],
+ 'token' => [
+ // Standard definition automatically inserted
+ ApiBase::PARAM_HELP_MSG_APPEND => [ 'api-help-param-token-webui' ],
+ ],
+ 'tags' => [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true
+ ],
+ ];
+ if ( !$this->getUserRightsPage()->canProcessExpiries() ) {
+ unset( $a['expiry'] );
+ }
+ return $a;
+ }
+
+ public function needsToken() {
+ return 'userrights';
+ }
+
+ protected function getWebUITokenSalt( array $params ) {
+ return $this->getUrUser( $params )->getName();
+ }
+
+ protected function getExamplesMessages() {
+ $a = [
+ 'action=userrights&user=FooBot&add=bot&remove=sysop|bureaucrat&token=123ABC'
+ => 'apihelp-userrights-example-user',
+ 'action=userrights&userid=123&add=bot&remove=sysop|bureaucrat&token=123ABC'
+ => 'apihelp-userrights-example-userid',
+ ];
+ if ( $this->getUserRightsPage()->canProcessExpiries() ) {
+ $a['action=userrights&user=SometimeSysop&add=sysop&expiry=1%20month&token=123ABC']
+ = 'apihelp-userrights-example-expiry';
+ }
+ return $a;
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:User_group_membership';
+ }
+}
diff --git a/www/wiki/includes/api/ApiValidatePassword.php b/www/wiki/includes/api/ApiValidatePassword.php
new file mode 100644
index 00000000..943149da
--- /dev/null
+++ b/www/wiki/includes/api/ApiValidatePassword.php
@@ -0,0 +1,81 @@
+<?php
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * @ingroup API
+ */
+class ApiValidatePassword extends ApiBase {
+
+ public function execute() {
+ $params = $this->extractRequestParams();
+
+ // For sanity
+ $this->requirePostedParameters( [ 'password' ] );
+
+ if ( $params['user'] !== null ) {
+ $user = User::newFromName( $params['user'], 'creatable' );
+ if ( !$user ) {
+ $encParamName = $this->encodeParamName( 'user' );
+ $this->dieWithError(
+ [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $params['user'] ) ],
+ "baduser_{$encParamName}"
+ );
+ }
+
+ if ( !$user->isAnon() || AuthManager::singleton()->userExists( $user->getName() ) ) {
+ $this->dieWithError( 'userexists' );
+ }
+
+ $user->setEmail( (string)$params['email'] );
+ $user->setRealName( (string)$params['realname'] );
+ } else {
+ $user = $this->getUser();
+ }
+
+ $validity = $user->checkPasswordValidity( $params['password'] );
+ $r['validity'] = $validity->isGood() ? 'Good' : ( $validity->isOK() ? 'Change' : 'Invalid' );
+ $messages = array_merge(
+ $this->getErrorFormatter()->arrayFromStatus( $validity, 'error' ),
+ $this->getErrorFormatter()->arrayFromStatus( $validity, 'warning' )
+ );
+ if ( $messages ) {
+ $r['validitymessages'] = $messages;
+ }
+
+ Hooks::run( 'ApiValidatePassword', [ $this, &$r ] );
+
+ $this->getResult()->addValue( null, $this->getModuleName(), $r );
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'password' => [
+ ApiBase::PARAM_TYPE => 'password',
+ ApiBase::PARAM_REQUIRED => true
+ ],
+ 'user' => [
+ ApiBase::PARAM_TYPE => 'user',
+ ],
+ 'email' => null,
+ 'realname' => null,
+ ];
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=validatepassword&password=foobar'
+ => 'apihelp-validatepassword-example-1',
+ 'action=validatepassword&password=querty&user=Example'
+ => 'apihelp-validatepassword-example-2',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Validatepassword';
+ }
+}
diff --git a/www/wiki/includes/api/ApiWatch.php b/www/wiki/includes/api/ApiWatch.php
new file mode 100644
index 00000000..efe21f11
--- /dev/null
+++ b/www/wiki/includes/api/ApiWatch.php
@@ -0,0 +1,188 @@
+<?php
+/**
+ *
+ *
+ * Created on Jan 4, 2008
+ *
+ * Copyright © 2008 Yuri Astrakhan "<Firstname><Lastname>@gmail.com",
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API module to allow users to watch a page
+ *
+ * @ingroup API
+ */
+class ApiWatch extends ApiBase {
+ private $mPageSet = null;
+
+ public function execute() {
+ $user = $this->getUser();
+ if ( !$user->isLoggedIn() ) {
+ $this->dieWithError( 'watchlistanontext', 'notloggedin' );
+ }
+
+ $this->checkUserRightsAny( 'editmywatchlist' );
+
+ $params = $this->extractRequestParams();
+
+ $continuationManager = new ApiContinuationManager( $this, [], [] );
+ $this->setContinuationManager( $continuationManager );
+
+ $pageSet = $this->getPageSet();
+ // by default we use pageset to extract the page to work on.
+ // title is still supported for backward compatibility
+ if ( !isset( $params['title'] ) ) {
+ $pageSet->execute();
+ $res = $pageSet->getInvalidTitlesAndRevisions( [
+ 'invalidTitles',
+ 'special',
+ 'missingIds',
+ 'missingRevIds',
+ 'interwikiTitles'
+ ] );
+
+ foreach ( $pageSet->getMissingTitles() as $title ) {
+ $r = $this->watchTitle( $title, $user, $params );
+ $r['missing'] = true;
+ $res[] = $r;
+ }
+
+ foreach ( $pageSet->getGoodTitles() as $title ) {
+ $r = $this->watchTitle( $title, $user, $params );
+ $res[] = $r;
+ }
+ ApiResult::setIndexedTagName( $res, 'w' );
+ } else {
+ // dont allow use of old title parameter with new pageset parameters.
+ $extraParams = array_keys( array_filter( $pageSet->extractRequestParams(), function ( $x ) {
+ return $x !== null && $x !== false;
+ } ) );
+
+ if ( $extraParams ) {
+ $this->dieWithError(
+ [
+ 'apierror-invalidparammix-cannotusewith',
+ $this->encodeParamName( 'title' ),
+ $pageSet->encodeParamName( $extraParams[0] )
+ ],
+ 'invalidparammix'
+ );
+ }
+
+ $title = Title::newFromText( $params['title'] );
+ if ( !$title || !$title->isWatchable() ) {
+ $this->dieWithError( [ 'invalidtitle', $params['title'] ] );
+ }
+ $res = $this->watchTitle( $title, $user, $params, true );
+ }
+ $this->getResult()->addValue( null, $this->getModuleName(), $res );
+
+ $this->setContinuationManager( null );
+ $continuationManager->setContinuationIntoResult( $this->getResult() );
+ }
+
+ private function watchTitle( Title $title, User $user, array $params,
+ $compatibilityMode = false
+ ) {
+ if ( !$title->isWatchable() ) {
+ return [ 'title' => $title->getPrefixedText(), 'watchable' => 0 ];
+ }
+
+ $res = [ 'title' => $title->getPrefixedText() ];
+
+ if ( $params['unwatch'] ) {
+ $status = UnwatchAction::doUnwatch( $title, $user );
+ $res['unwatched'] = $status->isOK();
+ } else {
+ $status = WatchAction::doWatch( $title, $user );
+ $res['watched'] = $status->isOK();
+ }
+
+ if ( !$status->isOK() ) {
+ if ( $compatibilityMode ) {
+ $this->dieStatus( $status );
+ }
+ $res['errors'] = $this->getErrorFormatter()->arrayFromStatus( $status, 'error' );
+ $res['warnings'] = $this->getErrorFormatter()->arrayFromStatus( $status, 'warning' );
+ if ( !$res['warnings'] ) {
+ unset( $res['warnings'] );
+ }
+ }
+
+ return $res;
+ }
+
+ /**
+ * Get a cached instance of an ApiPageSet object
+ * @return ApiPageSet
+ */
+ private function getPageSet() {
+ if ( $this->mPageSet === null ) {
+ $this->mPageSet = new ApiPageSet( $this );
+ }
+
+ return $this->mPageSet;
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ public function needsToken() {
+ return 'watch';
+ }
+
+ public function getAllowedParams( $flags = 0 ) {
+ $result = [
+ 'title' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_DEPRECATED => true
+ ],
+ 'unwatch' => false,
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ ];
+ if ( $flags ) {
+ $result += $this->getPageSet()->getFinalParams( $flags );
+ }
+
+ return $result;
+ }
+
+ protected function getExamplesMessages() {
+ return [
+ 'action=watch&titles=Main_Page&token=123ABC'
+ => 'apihelp-watch-example-watch',
+ 'action=watch&titles=Main_Page&unwatch=&token=123ABC'
+ => 'apihelp-watch-example-unwatch',
+ 'action=watch&generator=allpages&gapnamespace=0&token=123ABC'
+ => 'apihelp-watch-example-generator',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Watch';
+ }
+}
diff --git a/www/wiki/includes/api/SearchApi.php b/www/wiki/includes/api/SearchApi.php
new file mode 100644
index 00000000..f7c6471e
--- /dev/null
+++ b/www/wiki/includes/api/SearchApi.php
@@ -0,0 +1,197 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.28
+ */
+
+/**
+ * Traits for API components that use a SearchEngine.
+ * @ingroup API
+ */
+trait SearchApi {
+
+ /**
+ * When $wgSearchType is null, $wgSearchAlternatives[0] is null. Null isn't
+ * a valid option for an array for PARAM_TYPE, so we'll use a fake name
+ * that can't possibly be a class name and describes what the null behavior
+ * does
+ */
+ private static $BACKEND_NULL_PARAM = 'database-backed';
+
+ /**
+ * The set of api parameters that are shared between api calls that
+ * call the SearchEngine. Primarily this defines parameters that
+ * are utilized by self::buildSearchEngine().
+ *
+ * @param bool $isScrollable True if the api offers scrolling
+ * @return array
+ */
+ public function buildCommonApiParams( $isScrollable = true ) {
+ $params = [
+ 'search' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ 'namespace' => [
+ ApiBase::PARAM_DFLT => NS_MAIN,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'limit' => [
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+ ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2,
+ ],
+ ];
+ if ( $isScrollable ) {
+ $params['offset'] = [
+ ApiBase::PARAM_DFLT => 0,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ];
+ }
+
+ $searchConfig = MediaWikiServices::getInstance()->getSearchEngineConfig();
+ $alternatives = $searchConfig->getSearchTypes();
+ if ( count( $alternatives ) > 1 ) {
+ if ( $alternatives[0] === null ) {
+ $alternatives[0] = self::$BACKEND_NULL_PARAM;
+ }
+ $this->allowedParams['backend'] = [
+ ApiBase::PARAM_DFLT => $searchConfig->getSearchType(),
+ ApiBase::PARAM_TYPE => $alternatives,
+ ];
+ // @todo: support profile selection when multiple
+ // backends are available. The solution could be to
+ // merge all possible profiles and let ApiBase
+ // subclasses do the check. Making ApiHelp and ApiSandbox
+ // comprehensive might be more difficult.
+ } else {
+ $params += $this->buildProfileApiParam();
+ }
+
+ return $params;
+ }
+
+ /**
+ * Build the profile api param definitions. Makes bold assumption only one search
+ * engine is available, ensure that is true before calling.
+ *
+ * @return array array containing available additional api param definitions.
+ * Empty if profiles are not supported by the searchEngine implementation.
+ */
+ private function buildProfileApiParam() {
+ $configs = $this->getSearchProfileParams();
+ $searchEngine = MediaWikiServices::getInstance()->newSearchEngine();
+ $params = [];
+ foreach ( $configs as $paramName => $paramConfig ) {
+ $profiles = $searchEngine->getProfiles( $paramConfig['profile-type'],
+ $this->getContext()->getUser() );
+ if ( !$profiles ) {
+ continue;
+ }
+
+ $types = [];
+ $helpMessages = [];
+ $defaultProfile = null;
+ foreach ( $profiles as $profile ) {
+ $types[] = $profile['name'];
+ if ( isset( $profile['desc-message'] ) ) {
+ $helpMessages[$profile['name']] = $profile['desc-message'];
+ }
+ if ( !empty( $profile['default'] ) ) {
+ $defaultProfile = $profile['name'];
+ }
+ }
+
+ $params[$paramName] = [
+ ApiBase::PARAM_TYPE => $types,
+ ApiBase::PARAM_HELP_MSG => $paramConfig['help-message'],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => $helpMessages,
+ ApiBase::PARAM_DFLT => $defaultProfile,
+ ];
+ }
+
+ return $params;
+ }
+
+ /**
+ * Build the search engine to use.
+ * If $params is provided then the following searchEngine options
+ * will be set:
+ * - backend: which search backend to use
+ * - limit: mandatory
+ * - offset: optional, if set limit will be incremented by
+ * one ( to support the continue parameter )
+ * - namespace: mandatory
+ * - search engine profiles defined by SearchApi::getSearchProfileParams()
+ * @param string[]|null $params API request params (must be sanitized by
+ * ApiBase::extractRequestParams() before)
+ * @return SearchEngine the search engine
+ */
+ public function buildSearchEngine( array $params = null ) {
+ if ( $params != null ) {
+ $type = isset( $params['backend'] ) ? $params['backend'] : null;
+ if ( $type === self::$BACKEND_NULL_PARAM ) {
+ $type = null;
+ }
+ $searchEngine = MediaWikiServices::getInstance()->getSearchEngineFactory()->create( $type );
+ $limit = $params['limit'];
+ $searchEngine->setNamespaces( $params['namespace'] );
+ $offset = null;
+ if ( isset( $params['offset'] ) ) {
+ // If the API supports offset then it probably
+ // wants to fetch limit+1 so it can check if
+ // more results are available to properly set
+ // the continue param
+ $offset = $params['offset'];
+ $limit += 1;
+ }
+ $searchEngine->setLimitOffset( $limit, $offset );
+
+ // Initialize requested search profiles.
+ $configs = $this->getSearchProfileParams();
+ foreach ( $configs as $paramName => $paramConfig ) {
+ if ( isset( $params[$paramName] ) ) {
+ $searchEngine->setFeatureData(
+ $paramConfig['profile-type'],
+ $params[$paramName]
+ );
+ }
+ }
+ } else {
+ $searchEngine = MediaWikiServices::getInstance()->newSearchEngine();
+ }
+ return $searchEngine;
+ }
+
+ /**
+ * @return array[] array of arrays mapping from parameter name to a two value map
+ * containing 'help-message' and 'profile-type' keys.
+ */
+ abstract public function getSearchProfileParams();
+
+ /**
+ * @return IContextSource
+ */
+ abstract public function getContext();
+}
diff --git a/www/wiki/includes/api/i18n/ar.json b/www/wiki/includes/api/i18n/ar.json
new file mode 100644
index 00000000..6d7fea2c
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ar.json
@@ -0,0 +1,423 @@
+{
+ "@metadata": {
+ "authors": [
+ "Meno25",
+ "أحمد المحمودي",
+ "Khaled",
+ "Fatz",
+ "Hiba Alshawi",
+ "Maroen1990",
+ "محمد أحمد عبد الفتاح",
+ "ديفيد",
+ "ASHmed"
+ ]
+ },
+ "apihelp-main-param-action": "أي فعل للعمل.",
+ "apihelp-main-param-format": "صيغة الخرج.",
+ "apihelp-main-param-assertuser": "التحقق من أن المستخدم الحالي هو المستخدم المسمى.",
+ "apihelp-main-param-requestid": "سيتم إدراج أي قيمة معينة هنا في الاستجابة. يمكن أن تُستخدَم لتمييز الطلبات.",
+ "apihelp-main-param-servedby": "تتضمن اسم المضيف الذي الخدم طلب في النتائج.",
+ "apihelp-main-param-curtimestamp": "تشمل الطابع الزمني الحالي في النتيجة.",
+ "apihelp-main-param-responselanginfo": "تشمل اللغات المستخدمة لأجل <var>uselang</var> and <var>errorlang</var> في النتيجة.",
+ "apihelp-main-param-errorsuselocal": "إذا ما أعطيت، النصوص الخطأ ستستخدم الرسائل المخصصة محليا من نطاق {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "منع مستخدم.",
+ "apihelp-block-param-user": "اسم المستخدم، أو عنوان IP أو نطاق عنوان IP لمنعه. لا يمكن أن يُستخدَم جنبا إلى جنب مع <var>$1userid</var>",
+ "apihelp-block-param-userid": "معرف المستخدم لمنعه، لا يمكن أن يُستخدَم جنبا إلى جنب مع <var>$1user</var>",
+ "apihelp-block-param-reason": "السبب للمنع.",
+ "apihelp-block-param-anononly": "منع المستخدمين المجهولين فقط (أي تعطيل تعديلات المجهولين من عنوان IP هذا).",
+ "apihelp-block-param-nocreate": "امنع إنشاء الحسابات.",
+ "apihelp-block-param-autoblock": "منع آخر عنوان IP مستخدم تلقائيا، وأية عناوين IP لاحقة حاولت الدخول من خلاله.",
+ "apihelp-block-param-noemail": "منع المستخدم من إرسال البريد الإلكتروني من خلال الويكي. (يتطلب صلاحية <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "إخفاء اسم المستخدم من سجل المنع. (يتطلب صلاحية <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "تسمح للمستخدم بتحرير صفحة النقاش الخاصة (يعتمد على <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-watchuser": "مشاهدة صفحة المستخدم ونقاش IP.",
+ "apihelp-block-example-ip-simple": "منع عنوان IP <kbd>192.0.2.5</kbd> لمدة ثلاثة أيام بسبب >المخالفة الأولى</kbd>.",
+ "apihelp-block-example-user-complex": "منع المستخدم <kbd>المخرب</kbd> لأجل غير مسمى بسبب <kbd>التخريب</kbd>، ومنع إنشاء حساب جديد وإرسال بريد إلكتروني.",
+ "apihelp-changeauthenticationdata-summary": "تغيير بيانات المصادقة للمستخدم الحالي.",
+ "apihelp-changeauthenticationdata-example-password": "محاولة تغيير كلمة المرور للمستخدم الحالي إلى <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "تحقق من صحة رمز من <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "نوع من الرموز يجري اختبارها.",
+ "apihelp-checktoken-param-token": "اختبار الرموز.",
+ "apihelp-checktoken-param-maxtokenage": "أقصى عمر للرمز يسمح، في ثوان.",
+ "apihelp-checktoken-example-simple": "اختبار صلاحية رمز <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "مسح <code>hasmsg</code> العلم للمستخدم الحالي.",
+ "apihelp-clearhasmsg-example-1": "مسح <code>hasmsg</code> العلم للمستخدم الحالي.",
+ "apihelp-clientlogin-summary": "تسجيل الدخول إلى ويكي باستخدام التدفق التفاعلي.",
+ "apihelp-clientlogin-example-login": "بدء عملية تسجيل الدخول إلى الويكي كمستخدم <kbd>Example</kbd> بكلمة المرور <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "واصلة تسجيل الدخول بعد استجابة <samp>UI</samp> لعاملي الصادقة، إمداد <var>OATHToken</var> ل<kbd>987654</kbd>.",
+ "apihelp-compare-summary": "الحصول على الفرق بين صفحتين.",
+ "apihelp-compare-extended-description": "يجب تمرير عنوان الصفحة أو رقم المراجعة أو معرف الصفحة لكل من \"من\" و\"إلى\".",
+ "apihelp-compare-param-fromtitle": "العنوان الأول للمقارنة.",
+ "apihelp-compare-param-fromid": "رقم الصفحة الأول للمقارنة.",
+ "apihelp-compare-param-fromrev": "أول مراجعة للمقارنة.",
+ "apihelp-compare-param-totitle": "العنوان الثاني للمقارنة.",
+ "apihelp-compare-param-toid": "رقم الصفحة الثاني للمقارنة.",
+ "apihelp-compare-param-torev": "المراجعة الثانية للمقارنة.",
+ "apihelp-compare-example-1": "إنشاء فرق بين المراجعة 1 و2.",
+ "apihelp-createaccount-summary": "انشاء حساب مستخدم جديد",
+ "apihelp-createaccount-example-create": "بدء عملية إنشاء المستخدم <kbd>Example</kbd> بكلمة المرور <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "اسم المستخدم.",
+ "apihelp-createaccount-param-domain": "مجال للمصادقة الخارجية (اختياري).",
+ "apihelp-createaccount-param-token": "حصلت على رمز إنشاء حساب في الطلب الأول.",
+ "apihelp-createaccount-param-email": "عنوان البريد الإلكتروني للمستخدم (اختياري).",
+ "apihelp-createaccount-param-realname": "الاسم الحقيقي للمستخدم (اختياري).",
+ "apihelp-createaccount-param-mailpassword": "اذا تم تعيين اي قيمة, سيتم ارسال كلمة سر عشوائية للمستخدم عن طريق الاميل.",
+ "apihelp-createaccount-param-reason": "السبب اختياري لإنشاء الحساب لوضعه في السجلات.",
+ "apihelp-createaccount-param-language": "رمز اللغة لتعيينه كافتراضي للمستخدم (اختياري، لغة المحتوى الافتراضية).",
+ "apihelp-createaccount-example-pass": "إنشاء المستخدم <kbd>testuser</kbd> بكلمة المرور <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "إنشاء مستخدم <kbd>testmailuser</kbd> وأرسل كلمة المرور بالبريد الإلكتروني بشكل عشوائي.",
+ "apihelp-cspreport-summary": "مستخدمة من قبل المتصفحات للإبلاغ عن انتهاكات سياسة أمن المحتوى. لا ينبغي أبدا أن تستخدم هذه الوحدة، إلا عند استخدامها تلقائيا باستخدام متصفح ويب CSP متوافق.",
+ "apihelp-cspreport-param-reportonly": "علم على أنه تقرير عن سياسة الرصد، وليس فرض سياسة",
+ "apihelp-delete-summary": "حذف صفحة.",
+ "apihelp-delete-param-title": "عنوان الصفحة للحذف. لا يمكن أن يُستخدَم جنبا إلى جنب مع <var>$1pageid</var",
+ "apihelp-delete-param-pageid": "معرف الصفحة للحذف. لا يمكن أن يُستخدَم جنبا إلى جنب مع <var>$1pageid</var",
+ "apihelp-delete-param-reason": "سبب الحذف. إذا لم يُحدَّد، سوف تُستخدَم أحد الأسباب التي تنشأ تلقائيا.",
+ "apihelp-delete-param-tags": "تغيير وسوم لتطبيق الإدخال في سجل الحذف.",
+ "apihelp-delete-param-watch": "أضف الصفحة إلى لائحة مراقبة المستعمل الحالي",
+ "apihelp-delete-param-unwatch": "إزالة الصفحة من قائمة المراقبة للمستخدم الحالي.",
+ "apihelp-delete-param-oldimage": "اسم الصورة القديمة لحذفها كما هو منصوص عليه [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "حذف <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "حذف <kbd>Main Page</kbd> بسبب <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "هذا الاصدار تم تعطيله.",
+ "apihelp-edit-summary": "إنشاء وتعديل الصفحات.",
+ "apihelp-edit-param-title": "عنوان الصفحة للحذف. لا يمكن أن يُستخدَم جنبا إلى جنب مع <var>$1pageid</var",
+ "apihelp-edit-param-pageid": "معرف الصفحة لتحريرها. لا يمكن أن يُستخدَم جنبا إلى جنب مع <var>$1pageid</var",
+ "apihelp-edit-param-section": "رقم القسم. <kbd>0</kbd> للقسم العلوي، <kbd>new</kbd> لقسم جديد.",
+ "apihelp-edit-param-sectiontitle": "عنوان لقسم جديد.",
+ "apihelp-edit-param-text": "محتوى الصفحة",
+ "apihelp-edit-param-summary": "ملخص التعديل. أيضا عنوان القسم عند عدم تعيين $1section=new and $1sectiontitle.",
+ "apihelp-edit-param-tags": "عدل الوسوم لتطبيق المراجعة.",
+ "apihelp-edit-param-minor": "تعديل طفيف",
+ "apihelp-edit-param-notminor": "تعديل غير طفيف.",
+ "apihelp-edit-param-bot": "علم على هذا التعديل كتعديل بوت.",
+ "apihelp-edit-param-basetimestamp": "الطابع الزمني للمراجعة الأساسية، ويُستخدَم للكشف عن الحروب التحريرية، ويمكن الحصول عليها من خلال [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "الطابع الزمني عند بدء عملية التحرير، ويُستخدَم للكشف عن الحروب التحريرية، ويمكن الحصول عليها من خلال <var>[[Special:ApiHelp/main|curtimestamp]]</var> when beginning the edit process (e.g. when loading the page content to edit).",
+ "apihelp-edit-param-recreate": "تجاوز أية أخطاء حول الصفحة التي تم حذفها في هذه الأثناء.",
+ "apihelp-edit-param-createonly": "لا تحرر الصفحة إذا كان موجودا بالفعل.",
+ "apihelp-edit-param-nocreate": "يحدث خطأ إذا كانت الصفحة غير موجودة.",
+ "apihelp-edit-param-watch": "أضف الصفحة إلى لائحة مراقبة المستعمل الحالي",
+ "apihelp-edit-param-unwatch": "إزالة الصفحة من قائمة المراقبة للمستخدم الحالي.",
+ "apihelp-edit-param-prependtext": "إضافة هذا النص إلى بداية الصفحة. تجاوز $1text.",
+ "apihelp-edit-param-appendtext": "إضافة هذا النص إلى بداية الصفحة. تجاوز $1text.\n\nاستخدم $1section=جديد لحاق القسم الجديد، بدلا من هذا الوسيط.",
+ "apihelp-edit-param-undo": "التراجع عن هذه المراجعة. تجاوز $1text, $1prependtext و$1appendtext.",
+ "apihelp-edit-param-undoafter": "التراجع عن جميع المراجعات من $1undo لهذه. إذا لم يتم التغيير، تراجع عن تعديل واحد فقط.",
+ "apihelp-edit-param-redirect": "حل التحويلات تلقائيا.",
+ "apihelp-edit-param-contentmodel": "نموذج المحتوى للمحتوى الجديد.",
+ "apihelp-edit-param-token": "ينبغي دائما أن يُرسَل الرمز كوسيط أخير، أو على الأقل بعد الوسيط $1text.",
+ "apihelp-edit-example-edit": "عدل صفحة.",
+ "apihelp-edit-example-prepend": "إضافة البادئة <kbd>_&#95;NOTOC_&#95;</kbd> إلى الصفحة.",
+ "apihelp-edit-example-undo": "التراجع عن التعديلات 13579 خلال 13585 بملخص تلقائي.",
+ "apihelp-emailuser-summary": "مراسلة المستخدم",
+ "apihelp-emailuser-param-target": "مستخدم لإرسال بريد إلكتروني له.",
+ "apihelp-emailuser-param-subject": "رأس الموضوع",
+ "apihelp-emailuser-param-text": "جسم البريد الإلكتروني",
+ "apihelp-emailuser-param-ccme": "إرسال نسخة من هذه الرسالة لي.",
+ "apihelp-emailuser-example-email": "أرسل بريدا إلكترونيا للمستخدم <kbd>WikiSysop</kbd> بالنص <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "يوسع كافة القوالب ضمن نصوص الويكي.",
+ "apihelp-expandtemplates-param-title": "عنوان الصفحة.",
+ "apihelp-expandtemplates-param-text": "نص ويكي للتحويل.",
+ "apihelp-expandtemplates-param-revid": "معرف المراجعة، ل<code><nowiki>{{REVISIONID}}</nowiki></code> والمتغيرات مماثلة.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "نص الويكي الموسع",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "خصائص الصفحة التي تحددها الكلمات السحرية الموسعة في نص الويكي.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "إذا كان الإخراج سريع التأثر، ينبغي عدم استخدامه في أي مكان آخر داخل الصفحة.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "يعطي متغيرات تكوين جافا سكريبت الخاصة بهذه الصفحة.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "يعطي متغيرات تكوين جافا سكريبت الخاصة بهذه الصفحة كسلسلة JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "شجرة تحليل XML للمدخلات.",
+ "apihelp-expandtemplates-param-includecomments": "إدراج أو عدم إدراج تعليقات HTML في الإخراج.",
+ "apihelp-expandtemplates-param-generatexml": "ولد شجرة تحليل XML (حل محلها $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "توسيع نص الويكي <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "إرجاع تغذية مساهمات المستخدم.",
+ "apihelp-feedcontributions-param-feedformat": "هيئة التلقيم.",
+ "apihelp-feedcontributions-param-user": "أي المستخدمين سيتم الحصول على تبرعات لهم.",
+ "apihelp-feedcontributions-param-namespace": "أي نطاق ستتم تصفية المساهمات حسبه.",
+ "apihelp-feedcontributions-param-year": "من سنة (وأقدم).",
+ "apihelp-feedcontributions-param-month": "من شهر (وأقدم).",
+ "apihelp-feedcontributions-param-tagfilter": "تصفية المساهمات التي بها هذه الوسوم.",
+ "apihelp-feedcontributions-param-deletedonly": "اعرض المساهمات المحذوفة فقط.",
+ "apihelp-feedcontributions-param-toponly": "تظهر فقط التعديلات التي هي أحدث المراجعات.",
+ "apihelp-feedcontributions-param-newonly": "أظهر إنشاء الصفحات فقط",
+ "apihelp-feedcontributions-param-hideminor": "إخفاء التعديلات الطفيفة.",
+ "apihelp-feedcontributions-param-showsizediff": "عرض حجم الفرق بين النسخ.",
+ "apihelp-feedrecentchanges-param-feedformat": "هيئة التلقيم.",
+ "apihelp-feedrecentchanges-param-namespace": "نطاق لتقييد النتائج.",
+ "apihelp-feedrecentchanges-param-invert": "جميع النطاقات عدا المختار.",
+ "apihelp-feedrecentchanges-param-associated": "تشمل النطاق المرتبط (نقاش أو الرئيسي).",
+ "apihelp-feedrecentchanges-param-days": "أيام لتقييد النتائج.",
+ "apihelp-feedrecentchanges-param-limit": "الحد الأقصى للنتائج المُرجعة",
+ "apihelp-feedrecentchanges-param-from": "أظهر التغييرات منذ",
+ "apihelp-feedrecentchanges-param-hideminor": "إخفاء التعديلات الطفيفة.",
+ "apihelp-feedrecentchanges-param-hidebots": "إخفاء التغييرات التي أجرتها بوتات.",
+ "apihelp-feedrecentchanges-param-hideanons": "إخفاء التغييرات التي أجراها مستخدمون مجهولون.",
+ "apihelp-feedrecentchanges-param-hideliu": "إخفاء التغييرات التي أجراها مستخدمون مسجلون.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "إخفاء التغييرات المراجعة.",
+ "apihelp-feedrecentchanges-param-hidemyself": "إخفاء التغييرات التي قام بها المستخدم الحالي.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "إخفاء تغيير عضوية التصنيف.",
+ "apihelp-feedrecentchanges-param-tagfilter": "فلتر بالوسم.",
+ "apihelp-feedrecentchanges-param-target": "أحدث التغييرات في الصفحات الموصولة من هذه الصفحة فقط",
+ "apihelp-feedrecentchanges-param-showlinkedto": "أظهر التغييرات للصفحات الموصولة للصفحة المعطاة عوضا عن ذلك",
+ "apihelp-feedrecentchanges-param-categories": "أظهر التغييرات في الصفحات في كل تصنيف من هذه التصنيفات فقط.",
+ "apihelp-feedrecentchanges-param-categories_any": "أظهر التغييرات في الصفحات في أي تصنيف بدلا من ذلك.",
+ "apihelp-feedrecentchanges-example-simple": " اظهر التغييرات الحديثة",
+ "apihelp-feedrecentchanges-example-30days": "أظهر التغييرات الأخيرة في 30 يوم.",
+ "apihelp-feedwatchlist-summary": "إرجاع تغذية قائمة المراقبة.",
+ "apihelp-feedwatchlist-param-feedformat": "هيئة التلقيم.",
+ "apihelp-feedwatchlist-param-hours": "صفحات قائمة معدلة ضمن عدة ساعات من الآن.",
+ "apihelp-feedwatchlist-example-default": "عرض تغذية قائمة المراقبة.",
+ "apihelp-feedwatchlist-example-all6hrs": "اظهر كل التغييرات في اخر 6 ساعات",
+ "apihelp-filerevert-summary": "استرجع الملف لنسخة قديمة.",
+ "apihelp-filerevert-param-filename": "اسم الملف المستهدف، دون البادئة ملف:.",
+ "apihelp-filerevert-param-comment": "تعليق الرفع.",
+ "apihelp-filerevert-example-revert": "استرجاع <kbd>Wiki.png</kbd> لنسحة <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "عرض مساعدة لوحدات محددة.",
+ "apihelp-help-param-modules": "وحدات لعرض مساعدة لها (قيم وسائط <var>action</var> و<var>format</var> أو<kbd>main</kbd>). يمكن تحديد الوحدات الفرعية ب <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "تشمل المساعدة للوحدات الفرعية من الوحدة المسماة.",
+ "apihelp-help-param-recursivesubmodules": "تشمل المساعدة للوحدات الفرعية بشكل متكرر.",
+ "apihelp-help-param-helpformat": "شكل مخرجات المساعدة.",
+ "apihelp-help-param-wrap": "التفاف المخرجات في بنية استجابة API القياسية.",
+ "apihelp-help-param-toc": "يتضمن جدول المحتويات في مخرجات HTML.",
+ "apihelp-help-example-main": "مساعدة للوحدة الرئيسية.",
+ "apihelp-help-example-submodules": "مساعدة ل<kbd>action=query</kbd> وجميع الوحدات الفرعية لها.",
+ "apihelp-help-example-recursive": "كل المساعدة في صفحة واحدة.",
+ "apihelp-help-example-help": "مساعدة لوحدة المساعدة نفسها.",
+ "apihelp-help-example-query": "مساعدة لوحدتي استعلام فرعيتين.",
+ "apihelp-imagerotate-summary": "تدوير صورة واحدة أو أكثر.",
+ "apihelp-imagerotate-param-rotation": "درجة تدوير الصورة في اتجاه عقارب الساعة.",
+ "apihelp-imagerotate-example-simple": "تدوير <kbd>File:Example.png</kbd> بمقدار <kbd>90</kbd> درجة.",
+ "apihelp-imagerotate-example-generator": "تدوير جميع الصور في <kbd>Category:Flip</kbd> بمقدار <kbd>180</kbd> درجة.",
+ "apihelp-import-param-summary": "ملخص إدخال سجل الاستيراد.",
+ "apihelp-import-param-xml": "ملف XML مرفوع.",
+ "apihelp-import-param-interwikisource": "بالنسبة لواردات الإنترويكي: ويكي للاستيراد منه.",
+ "apihelp-import-param-interwikipage": "بالنسبة لواردات الإنترويكي: صفحة لاستيرادها.",
+ "apihelp-import-param-fullhistory": "بالنسبة لواردات الإنترويكي: استيراد التاريخ كاملا، وليست النسخة الحالية فقط.",
+ "apihelp-import-param-templates": "بالنسبة لواردات الإنترويكي: الإستيراد شمل كافة القوالب كذلك.",
+ "apihelp-import-param-namespace": "استيراد إلى هذا النطاق. لا يمكن أن يُستخدَم إلى جانب <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "استيراد كصفحة فرعية لهذه الصفحة. لا يمكن أن يُستخدَم إلى جانب <var>$1rootpage</var>.",
+ "apihelp-import-example-import": "استيراد [[meta:Help:ParserFunctions]] للنطاق 100 بالتاريخ الكامل.",
+ "apihelp-linkaccount-summary": "ربط حساب من موفر طرف ثالث للمستخدم الحالي.",
+ "apihelp-linkaccount-example-link": "بدء عملية ربط حساب من <kbd>Example</kbd>.",
+ "apihelp-login-summary": "سجل دخولك الآن واحصل على مصادقة الكوكيز.",
+ "apihelp-login-extended-description": "وينبغي استخدام هذا الإجراء فقط في تركيبة مع [[Special:BotPasswords|خاص:كلمات مرور البوت]]. تم إهمال استخدام لتسجيل الدخول للحساب الرئيسي وقد يفشل دون سابق إنذار. لتسجيل الدخول بأمان إلى الحساب الرئيسي; استخدم <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "هذا العمل مستنكر وقد يفشل دون سابق إنذار. لتسجيل الدخول بأمان إلى الحساب الرئيسي; استخدم <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "اسم المستخدم.",
+ "apihelp-login-param-password": "كلمة السر",
+ "apihelp-login-param-domain": "النطاق (اختياري).",
+ "apihelp-login-param-token": "تم الحصول على رمز الدخول في الطلب الأول.",
+ "apihelp-login-example-gettoken": "استرداد رمز تسجيل الدخول.",
+ "apihelp-login-example-login": "تسجيل الدخول",
+ "apihelp-logout-summary": "تسجيل الخروج ومسح بيانات الجلسة.",
+ "apihelp-logout-example-logout": "تسجيل خروج المستخدم الحالي.",
+ "apihelp-managetags-summary": "أداء المهام الإدارية المتعلقة بتغيير الوسوم.",
+ "apihelp-managetags-param-operation": "أي الإجراءات ستنفذ:\n؛ إنشاء: إنشاء وسم التغيير جديدة للاستخدام اليدوي.\n؛ حذف: إزالة وسم التغيير من قاعدة البيانات، بما في ذلك إزالة الوسم من كافة المراجعات، وإدخالات التغيير الأخيرة، وإدخالات السجل المستخدم.\n؛ تنشيط: تنشيط وسم التغيير، مما يسمح للمستخدمين بتطبيقه يدويا.\n; إلغاء: إلغاء تنشيط وسم التغيير، ومنع المستخدمين من تطبيقه يدويا.",
+ "apihelp-managetags-param-reason": "سبب اختياري لإنشاء، وحذف، وتفعيل أو تعطيل الوسم.",
+ "apihelp-managetags-param-ignorewarnings": "إذا كان سيتم تجاهل أي تحذيرات تصدر خلال العملية.",
+ "apihelp-managetags-example-create": "إنشاء وسم مسمى <kbd>spam</kbd> بسبب <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "حذف <kbd>vandlaism</kbd> وسم بسبب <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "تنشيط الوسم المسمى <kbd>spam</kbd> بسبب <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "تعطيل الوسم المسمى <kbd>spam</kbd> بسبب <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "ادمج تاريخ الصفحة.",
+ "apihelp-mergehistory-param-from": "عنوان الصفحة التي سيتم دمج تاريخها. لا يمكن أن تُستخدَم بجانب <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "معرف الصفحة التي سيتم دمج تاريخها. لا يمكن أن تُستخدَم بجانب <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "عنوان الصفحة التي سيتم دمج تاريخها. لا يمكن أن تُستخدَم بجانب <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "معرف الصفحة التي سيتم دمج تاريخها. لا يمكن أن تُستخدَم بجانب <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "الطابع الزمني للمراجعات التي سيتم نقلها من تاريخ صفحة المصدر إلى تاريخ صفحة الوجهة. إذا تم حذفها، سيتم دمج تاريخ الصفحة كاملا من صفحة المصدر إلى صفحة الوجهة.",
+ "apihelp-mergehistory-param-reason": "سبب دمج التاريخ.",
+ "apihelp-mergehistory-example-merge": "دمج تاريخ <kbd>Oldpage</kbd> كاملا إلى <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "دمج مراجعات الصفحة <kbd>Oldpage</kbd> dating up to <kbd>2015-12-31T04:37:41Z</kbd> إلى <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "نقل صفحة.",
+ "apihelp-move-param-from": "عنوان الصفحة للنقل. لا يمكن أن تُستخدَم بجانب <var>$1pageid</var",
+ "apihelp-move-param-fromid": "معرف الصفحة للنقل. لا يمكن أن تُستخدَم بجانب <var>$1pageid</var",
+ "apihelp-move-param-to": "عنوان لإعادة تسمية الصفحة له.",
+ "apihelp-move-param-reason": "السبب لإعادة التسمية.",
+ "apihelp-move-param-movetalk": "إعادة تسمية صفحة النقاش، إن وُجِدت.",
+ "apihelp-move-param-movesubpages": "إعادة تسمية الصفحات الفرعية، إن وُجِدت.",
+ "apihelp-move-param-noredirect": "لا تنشئ تحويلة.",
+ "apihelp-move-param-watch": "إضافة الصفحة والتحويلة إلى قائمة مراقبة المستخدم الحالي.",
+ "apihelp-move-param-unwatch": "إزالة الصفحة والتحويلة إلى قائمة مراقبة المستخدم الحالي.",
+ "apihelp-move-param-ignorewarnings": "تجاهل أي تحذيرات.",
+ "apihelp-move-example-move": "انقل <kbd>Badtitle</kbd> إلى <kbd>Goodtitle</kbd> دون ترك تحويلة.",
+ "apihelp-opensearch-summary": "بحث الويكي باستخدام بروتوكول أوبن سيرش OpenSearch.",
+ "apihelp-opensearch-param-search": "سطر البحث",
+ "apihelp-opensearch-param-limit": "الحد الأقصى للنتائج المُرجعة",
+ "apihelp-opensearch-param-namespace": "النطاقات للبحث.",
+ "apihelp-opensearch-param-suggest": "لا تفعل شيئا إذا كان <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> خاطئا.",
+ "apihelp-opensearch-param-format": "شكل الإخراج.",
+ "apihelp-opensearch-param-warningsaserror": "إذا تم رفع التحذيرات ب<kbd>format=json</kbd>, أعد أخطاء API بدلا من تجاهلها.",
+ "apihelp-opensearch-example-te": "العثور على صفحات تبدأ ب<kbd>Te</kbd>.",
+ "apihelp-options-param-reset": "إعادة تعيين التفضيلات إلى إعدادات الموقع الإفتراضية.",
+ "apihelp-options-param-resetkinds": "قائمة أنواع الخيارات لإعادة ضبطها عندما يتم تعيين خيار <var>$1reset</var>.",
+ "apihelp-options-param-optionname": "اسم الخيار الذي ينبغي ضبطه إلى القيمة التي قدمها <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "قيمة للخيار المحدد من قبل <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "إعادة تعيين كل التفضيلات.",
+ "apihelp-options-example-change": "غير تفضيلات <kbd>skin</kbd> و<kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "إعادة تعيين جميع تفضيلات، ثم تعيين <kbd>skin</kbd> و<kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "الحصول على معلومات حول وحدات API.",
+ "apihelp-paraminfo-param-helpformat": "شكل سلاسل المساعدة.",
+ "apihelp-paraminfo-param-mainmodule": "الحصول على معلومات عن وحدة (المستوى الأعلى) الرئيسية أيضا. استخدم <kbd>$1modules=main</kbd> بدلا من ذلك.",
+ "apihelp-paraminfo-param-formatmodules": "قائمة بأسماء أشكال الوحدات (قيم الوسيط <var>format</var>). استخدم <var>$1modules</var> بدلا من ذلك.",
+ "apihelp-paraminfo-example-1": "عرض معلومات عن <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> و<kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd> و<kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> و<kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "أظهر المعلومات لجميع الوحدات الفرعية ل<kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-param-title": "عنوان الصفحة التي ينتمي النص إليها.إذا تم حذفها، <var>$1contentmodel</var> يجب أن تكون محددة، و[[API]] سيتم استخدامه كعنوان.",
+ "apihelp-parse-param-text": "نص للتحليل. استخدم <var>$1title</var> أو <var>$1contentmodel</var> للتحكم في نموذج المحتوى.",
+ "apihelp-parse-param-summary": "ملخص للتحليل.",
+ "apihelp-parse-param-page": "تحليل محتوى هذه الصفحة. لا يمكن أن تُستخدَم بجانب <var>$1text</var> and <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "حلل محتوى هذه الصفحة. تجاوز <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "لو <var>$1page</var> أو <var>$1pageid</var> is تم تعيينها للتحويل، حلها.",
+ "apihelp-parse-param-oldid": "تحليل مضمون هذا التعديل. تجاوز <var>$1page</var> و<var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "أي قطعة من المعلومات تريد الحصول عليها:",
+ "apihelp-parse-paramvalue-prop-langlinks": "يعطي وصلات اللغات في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-categories": "يعطي التصنيفات في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "يعطي إصدار HTML للتصنيفات.",
+ "apihelp-parse-paramvalue-prop-links": "يعطي الوصلات الداخلية في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-templates": "يعطي القوالب في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-images": "يعطي الصور في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-externallinks": "يعطي الوصلات الخارجية في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-sections": "يعطي الأقسام في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "يضيف العنوان في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-headitems": "يعطي عناصر لوضعها في <code>&lt;head&gt;</code> الصفحة.",
+ "apihelp-parse-paramvalue-prop-headhtml": "يعطي تحليل <code>&lt;head&gt;</code> الصفحة.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "يعطي متغيرات تكوين جافا سكريبت الخاصة بهذه الصفحة. للتطبيق; استخدم <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "يعطي متغيرات تكوين جافا سكريبت الخاصة بهذه الصفحة كسلسلة JSON.",
+ "apihelp-parse-paramvalue-prop-indicators": "يعطي HTML مؤشرات حالة الصفحة المستخدمة في الصفحة.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "يعطي وصلات اللغات في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-wikitext": "يعطي نصوص الويكي الأصلية التي تم تحليلها.",
+ "apihelp-parse-paramvalue-prop-properties": "يعطي الخصائص المختلفة المحددة في تحليل نصوص الويكي.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "يعطي تقرير الحد بطريقة منظمة. لا يعطي أية بيانات، عندما يتم تعيين <var>$1disablelimitreport</var>.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "يعطي إصدار HTML لتقرير الحد. لا يعطي أية بيانات، عندما يتم تعيين<var>$1disablelimitreport</var>.",
+ "apihelp-parse-paramvalue-prop-parsetree": "شجرة تحليل XML لمحتويات المراجعة (يتطلب نموذج محتوى <code>$1</code>)",
+ "apihelp-parse-param-pst": "قم بتحويل قبل الحفظ على المدخلات قبل تحليل ذلك. صالح فقط عند استخدامه مع النص.",
+ "apihelp-parse-param-effectivelanglinks": "يشمل وصلات لغة المقدمة بواسطة ملحقات (للاستخدام مع <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-disablelimitreport": "تجاهل تقرير الحد (\"NewPP limit report\") من مخرجات المحلل.",
+ "apihelp-parse-param-disablepp": "استخدم <var>$1disablelimitreport</var> بدلا من ذلك.",
+ "apihelp-parse-param-disableeditsection": "تجاهل روابط تحرير الأقسام من مخرجات المحلل.",
+ "apihelp-parse-param-disabletidy": "لا تشغل تنظيف HTML (على سبيل المثال مرتبة) على مخرجات المحلل.",
+ "apihelp-parse-param-generatexml": "توليد شجرة تحليل XML (يتطلب نموذج المحتوى <code>$1</code>; حل محلها <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "تحليل في وضع المعاينة.",
+ "apihelp-parse-param-sectionpreview": "تحليل في وضع معاينة القسم (يمكن وضع المعاينة أيضا).",
+ "apihelp-parse-param-disabletoc": "تجاهل جدول المحتويات في المخرجات.",
+ "apihelp-parse-param-contentformat": "نموذج المحتوى المسلسل يُستخدَم للنص المدخل. صالح فقط عند استخدامه مع $1text.",
+ "apihelp-parse-example-page": "تحليل صفحة.",
+ "apihelp-parse-example-text": "تحليل نصوص ويكي",
+ "apihelp-parse-example-texttitle": "تحليل نصوص ويكي، تحديد عنوان الصفحة.",
+ "apihelp-parse-example-summary": "تحليل الملخص.",
+ "apihelp-patrol-summary": "مراجعة صفحة أو مراجعة.",
+ "apihelp-patrol-param-rcid": "معرف أحدث التغييرات للمراجعة",
+ "apihelp-patrol-param-revid": "معرف مراجعة للمراجعة",
+ "apihelp-patrol-param-tags": "تغيير وسوم لتطبيق الإدخال في سجل المراجعة.",
+ "apihelp-patrol-example-rcid": "ابحث عن تغيير جديد",
+ "apihelp-patrol-example-revid": "راجع مراجعة.",
+ "apihelp-protect-summary": "غير مستوى الحماية لصفحة.",
+ "apihelp-protect-param-title": "عنوان الصفحة ل (إزالة) الحماية. لا يمكن أن تُستخدَم بجانب $1pageid.",
+ "apihelp-protect-param-pageid": "معرف الصفحة ل (إزالة) الحماية. لا يمكن أن تُستخدَم بجانب $1pageid.",
+ "apihelp-protect-param-reason": "سبب (إزالة) الحماية.",
+ "apihelp-protect-param-tags": "تغيير وسوم لتطبيق الإدخال في سجل الحماية.",
+ "apihelp-protect-param-watch": "إذا تم الضبط، أضف الصفحة (غير) المحمية لقائمة مراقبة المستخدم الحالي.",
+ "apihelp-protect-example-protect": "حماية صفحة.",
+ "apihelp-protect-example-unprotect": "إلغاء حماية الصفحة من خلال وضع قيود ل<kbd>all</kbd> (أي يُسمَح أي شخص باتخاذ الإجراءات).",
+ "apihelp-protect-example-unprotect2": "إلغاء حماية الصفحة عن طريق عدم وضع أية قيود.",
+ "apihelp-purge-summary": "مسح ذاكرة التخزين المؤقت للعناوين المعطاة",
+ "apihelp-purge-param-forcelinkupdate": "تحديث جداول الروابط.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "تحديث جدول الروابط، وتحديث جداول الروابط لأية صفحة تستخدم هذه الصفحة كقالب.",
+ "apihelp-purge-example-simple": "إفراغ كاش <kbd>Main Page</kbd> وصفحة <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "إفراغ كاش أول 10 صفحات في النطاق الرئيسي.",
+ "apihelp-query-summary": "جلب البيانات من وعن ميدياويكي.",
+ "apihelp-query-extended-description": "يجب على جميع تعديلات البيانات أولا استخدام استعلام للحصول على رمز لمنع الاعتداء من المواقع الخبيثة.",
+ "apihelp-query-param-prop": "أي الخصائص تريد الحصول على صفحات استعلام عنها.",
+ "apihelp-query-param-list": "أي القوائم تريد الحصول عليها.",
+ "apihelp-query-param-meta": "أي البيانات الوصفية تريد الحصول عليها.",
+ "apihelp-query-param-export": "تصدير المراجعات الحالية لجميع الصفحات المعينة أو المولدة.",
+ "apihelp-query-param-exportnowrap": "إعادة تصدير XML دون التفاف عليه في نتيجة XML (نفس شكل [[Special:Export|خاص:تصدير]]). يمكن استخدامها فقط مع $1export.",
+ "apihelp-query-param-rawcontinue": "إرجاع <samp>query-continue</samp> بيانات خام للاستمرار.",
+ "apihelp-query-example-revisions": "جلب [[Special:ApiHelp/query+siteinfo|معلومات الموقع]] و[[Special:ApiHelp/query+revisions|مراجعات]] <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "جلب مراجعات الصفحات التي تبدأ ب<kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "تعداد جميع التصنيفات.",
+ "apihelp-query+allcategories-param-from": "التصنيف الذي يبدأ التعداد منه.",
+ "apihelp-query+allcategories-param-to": "التصنيف الذي يقف التعداد عنده.",
+ "apihelp-query+allcategories-param-prefix": "ابحث عن جميع التصنيفات التي تبدأ أسماؤها بهذه القيمة.",
+ "apihelp-query+allcategories-param-dir": "اتجاه الفرز.",
+ "apihelp-query+allcategories-param-limit": "كم عدد الفئات في الإرجاع.",
+ "apihelp-query+allcategories-param-prop": "أي الخصائص تريد الحصول عليها:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "أضف عدد الصفحات في هذا التصنيف.",
+ "apihelp-query+allcategories-example-generator": "استرداد المعلومات حول صفحة التصنيف نفسها للتصنيفات التي تبدأ ب<kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "قائمة جميع المراجعات المحذوفة بواسطة المستخدم أو في نطاق.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "يمكن أن تُستخدَم فقط مع <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "لا يمكن أن تُستخدَم مع <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-end": "الطابع الزمني الذي يقف التعداد منه.",
+ "apihelp-query+alldeletedrevisions-param-from": "بدء الإدراج في القائمة من هذا العنوان.",
+ "apihelp-query+alldeletedrevisions-param-to": "وقف الإدراج في القائمة من هذا العنوان.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "ابحث عن جميع عناوين الصفحات التي تبدأ أسماؤها بهذه القيمة.",
+ "apihelp-query+alldeletedrevisions-param-tag": "مراجعات القائمة فقط تم وسمها بهذ الوسم.",
+ "apihelp-query+alldeletedrevisions-param-user": "أدرج المراجعات التي كتبها هذا المستخدم فقط.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "لا تدرج المراجعات التي كتبها هذا المستخدم.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "أدرج الصفحات في هذا النطاق فقط.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "عندما يُستخدَم كمولد، ولد عناوين بدلا من معرفات المراجعات.",
+ "apihelp-query+allfileusages-summary": "قائمة جميع استخدامات الملفات، بما في ذلك غير الموجودة.",
+ "apihelp-query+allfileusages-param-from": "عنوان الملف لبدء التعداد منه.",
+ "apihelp-query+allfileusages-param-to": "عنوان الملف لوقف التعداد منه.",
+ "apihelp-query+allfileusages-param-prefix": "البحث عن كل عناوين الملفات التي تبدأ بهذه القيمة.",
+ "apihelp-query+allfileusages-param-prop": "أي قطعة من المعلومات تريد تضمينها:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "تضيف معرفات استخدام الصفحات (لا يمكن استخدامها مع $1unique).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "تضيف عنوان الملف.",
+ "apihelp-query+allfileusages-param-limit": "كم عدد مجموع البنود للعودة.",
+ "apihelp-query+allimages-summary": "تعداد كافة الصور بشكل متتالي.",
+ "apihelp-query+allimages-param-sort": "خاصية للفرز وفقًا لها.",
+ "apihelp-query+allimages-param-from": "عنوان الصورة لبدء التعداد منه. يمكن استخدامها مع $1sort=name فقط.",
+ "apihelp-query+allimages-param-to": "عنوان الصورة لوقف التعداد منه. يمكن استخدامها مع $1sort=name فقط.",
+ "apihelp-query+allimages-param-start": "طابع زمني لبدء التعداد منه. يمكن استخدامه مع $1sort فقط.",
+ "apihelp-query+allimages-param-end": "طابع زمني لإنهاء التعداد منه. يمكن استخدامه مع $1sort فقط.",
+ "apihelp-query+allimages-param-prefix": "البحث عن كل عناوين الصور التي تبدأ بهذه القيمة. يمكن استخدامها مع $1sort فقط.",
+ "apihelp-query+allimages-param-sha1": "SHA1 تجزئة الصورة. تجاوز $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "SHA1 تجزئة الصورة في قاعدة 36 (تُستخدَم في ميدياويكي).",
+ "apihelp-query+allimages-param-filterbots": "كيفية تصفية الملفات التي تم تحميلها بواسطة بوتات. يمكن استخدامها مع $1sort=timestamp فقط. لا يمكن أن تُستخدَم بجانب $1user.",
+ "apihelp-query+allimages-param-mime": "عن أي أنواع MIME تبحث، على سبيل المثال <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-example-B": "أظهر قائمة الملفات التي تبدأ ب<kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "أظهر قائمة الملفات التي تم تحميلها مؤخرا، على غرار [[Special:NewFiles|خاص:ملفات جديدة]].",
+ "apihelp-query+allimages-example-mimetypes": "أظهر قائمة الملفات من نوع MIME <kbd>image/png</kbd> أو <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "عرض معلومات حول 4 ملفات تبدأ بالحرف <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "تعداد كافة الروابط التي تشير إلى نطاق معين.",
+ "apihelp-query+alllinks-param-from": "عنوان الرابط لبدء التعداد منه.",
+ "apihelp-query+alllinks-param-to": "عنوان الرابط لوقف التعداد منه.",
+ "apihelp-query+alllinks-param-prefix": "البحث عن كل العناوين المرتبطة التي تبدأ بهذه القيمة.",
+ "apihelp-query+alllinks-param-prop": "أي قطعة من المعلومات تريد تضمينها:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "تضيف معرف الصفحة للصفحة المرتبطة (لا يمكن استخدامها مع $1unique).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "تضيف عنوان الرابط.",
+ "apihelp-query+alllinks-param-namespace": "نطاق للتعداد.",
+ "apihelp-query+alllinks-param-limit": "كم عدد مجموع البنود للعودة.",
+ "apihelp-query+alllinks-example-generator": "يحصل على الصفحات التي تحتوي على وصلات.",
+ "apihelp-query+allmessages-param-prop": "أي الخصائص تريد الحصول عليها:",
+ "apihelp-query+allmessages-param-filter": "إرجاع الرسائل بالأسماء التي تحتوي على هذه السلسلة فقط.",
+ "apihelp-query+allmessages-param-lang": "إرجاع الرسائل بهذه اللغة.",
+ "apihelp-query+allmessages-param-from": "إرجاع الرسائل ابتداء من هذه الرسالة.",
+ "apihelp-query+allmessages-param-to": "إرجاع الرسائل التي تنتهي بهذه الرسالة.",
+ "apihelp-query+allmessages-param-prefix": "إرجاء الرسائل بهذه البادئة.",
+ "apihelp-query+allmessages-example-ipb": "عرض رسائل تبدأ ب<kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "عرض رسائل <kbd>august</kbd> and <kbd>mainpage</kbd> باللغة الألمانية.",
+ "apihelp-query+allpages-summary": "تعداد كافة الصفحات بشكل متتالي في نطاق معين.",
+ "apihelp-query+allpages-param-to": "عنوان الصفحة لإيقاف التعداد منه.",
+ "apihelp-query+allpages-param-prefix": "البحث عن كل عناوين الصفحات التي تبدأ بهذه القيمة.",
+ "apihelp-query+allpages-param-namespace": "نطاق للتعداد.",
+ "apihelp-query+allpages-param-filterredir": "أي الصفحات للعرض.",
+ "apihelp-query+allpages-param-limit": "كم عدد مجموع الصفحات للعودة.",
+ "apihelp-query+allpages-example-B": "عرض قائمة من الصفحات التي تبدأ بالحرف <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "عرض معلومات حول 4 صفحات تبدأ بالحرف <kbd>T</kbd>.",
+ "apihelp-query+allredirects-param-from": "عنوان التحويلة لبدء التعداد منه.",
+ "apihelp-query+allredirects-param-to": "عنوان التحويلة لإيقاف التعداد منه.",
+ "apihelp-query+allredirects-param-prefix": "ابحث عن جميع عناوين الصفحات المستهدفة التي تبدأ بهذه القيمة.",
+ "apihelp-query+allredirects-param-prop": "أي قطعة من المعلومات تريد تضمينها:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "تضيف معرف الصفحة لصفحة التحويل (لا يمكن استخدامها مع $1unique).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "تضيف عنوان التحويلة.",
+ "apihelp-query+allredirects-param-namespace": "نطاق للتعداد.",
+ "apihelp-query+allredirects-param-limit": "كم عدد مجموع البنود للعودة.",
+ "apihelp-query+allredirects-example-generator": "يحصل على الصفحات التي تحتوي على تحويلات.",
+ "apihelp-query+allrevisions-summary": "اعرض كل المراجعات.",
+ "apihelp-query+allrevisions-param-start": "التصنيف الذي يبدأ التعداد منه.",
+ "apihelp-query+allrevisions-param-end": "الطابع الزمني الذي يقف التعداد منه.",
+ "apihelp-query+allrevisions-param-generatetitles": "عندما يُستخدَم كمولد، ولد عناوين بدلا من معرفات المراجعات.",
+ "apihelp-query+mystashedfiles-param-prop": "أي الخصائص تريد لجلب للملفات.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "جلب حجم الملف وأبعاد الصورة.",
+ "apihelp-query+blocks-example-simple": "قائمة المنع.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "إضافة هوية المستخدم الذي قام بتحميل كل إصدار ملف.",
+ "apihelp-query+prefixsearch-param-offset": "عدد النتائج المراد تخطيها.",
+ "apierror-offline": "لم يمكن المتابعة بسبب مشاكل في الاتصال بالشبكة; تأكد من أنه لديك اتصال بالإنترنت وحاول مرة أخرى.",
+ "apierror-timeout": "لم يستجب الخادم ضمن الوقت المتوقع.",
+ "api-feed-error-title": "خطأ ($1)"
+}
diff --git a/www/wiki/includes/api/i18n/ast.json b/www/wiki/includes/api/i18n/ast.json
new file mode 100644
index 00000000..09c63777
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ast.json
@@ -0,0 +1,37 @@
+{
+ "@metadata": {
+ "authors": [
+ "Xuacu",
+ "Enolp"
+ ]
+ },
+ "apihelp-main-summary": "",
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Documentación]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Llista d'alderique]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Anuncios de la API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Fallos y solicitúes]\n</div>\n<strong>Estau:</strong> Toles carauterístiques qu'apaecen nesta páxina tendríen de funcionar, pero la API inda ta en desendolcu activu, y puede camudar en cualquier momentu. Suscríbete a la [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ llista de corréu mediawiki-api-announce] p'avisos sobro anovamientos.\n\n<strong>Solicitúes incorreutes:</strong> Cuando s'unvíen solicitúes incorreutes a la API, unvíase una cabecera HTTP cola clave \"MediaWiki-API-Error\" y, darréu, tanto'l valor de la cabecera como'l códigu d'error devueltu pondránse al mesmu valor. Pa más información, consulta [[mw:API:Errors_and_warnings|API: Errores y avisos]].\n\n<strong>Pruebes:</strong> Pa facilitar les pruebes de solicitúes API, consulta [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Qué aición facer.",
+ "apihelp-main-param-format": "El formatu de la salida.",
+ "apihelp-main-param-maxlag": "El retrasu (lag) máximu puede utilizase cuando MediaWiki ta instaláu nun conxuntu de bases de datos replicaes. Pa evitar les aiciones que pudieran causar un retrasu entá mayor na replicación del sitiu, esti parámetru puede causar que'l cliente espere hasta que'l retrasu de replicación sía menor que'l valor especificáu. En casu de retrasu escesivu, devuélvese un códigu d'error <samp>maxlag</samp> con un mensaxe asemeyáu a <samp>Esperando a $host: $lag segundos de retrasu<s/amp>.<br />Ver [[mw:Manual:Maxlag_parameter|Manual:Parámetru maxlag]] pa más información.",
+ "apihelp-main-param-smaxage": "Establez l'encabezáu HTTP <code>s-maxage</code> de control de caché a esta cantidá de segundos. Los errores nunca se guarden na caché.",
+ "apihelp-main-param-maxage": "Establez l'encabezáu HTTP <code>max-age</code> de control de caché a esta cantidá de segundos. Los errores nunca se guarden na caché.",
+ "apihelp-main-param-assert": "Comprobar que l'usuariu tien sesión aniciada si'l valor ye <kbd>user</kbd> o que tien el permisu de bot si ye <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Comprobar que'l usuariu actual ye l'usuariu nomáu.",
+ "apihelp-main-param-servedby": "Incluyir el nome del host que sirvió la solicitú nes resultancies.",
+ "apihelp-main-param-curtimestamp": "Incluyir la marca de tiempu actual na resultancia.",
+ "apihelp-block-summary": "Bloquiar a un usuariu.",
+ "apihelp-block-param-user": "Nome d'usuariu, dirección #IP o intervalu d'IP que quies bloquiar. Nun puede utilizase con <var>$1userid</var>",
+ "apihelp-block-param-expiry": "Fecha de caducidá. Puede ser relativa (por casu, <kbd>5 meses</kbd> o <kbd>2 selmanes</kbd>) o absoluta (por casu, 2016-01-16T12:34:56Z). Si s'establez a <kbd>infinitu</kbd>, <kbd>indefiníu</kbd>, o <kbd>nunca</kbd>, el bloquéu nun caducará nunca.",
+ "apihelp-block-param-reason": "Motivu del bloquéu.",
+ "apihelp-block-param-anononly": "Bloquiar solo los usuarios anónimos (esto ye, desactivar ediciones anónimes dende esta dirección IP).",
+ "apihelp-block-param-nocreate": "Torgar la creación de cuentes.",
+ "apihelp-block-param-autoblock": "Bloquiar automáticamente la última dirección IP usada y les siguientes direcciones IP de les que traten d'aniciar sesión darréu.",
+ "apihelp-block-param-noemail": "Torgar que l'usuariu unvie corréu al traviés de la wiki (Rique'l permisu <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Despintar el nome d'usuariu del rexistru de bloquéu (Rique'l permisu <code>hideuser</code>).",
+ "apihelp-block-param-reblock": "Si la cuenta yá ta bloquiada, sobrescribir el bloquéu esistente.",
+ "apihelp-block-param-watchuser": "Vixilar les páxines d'usuariu y d'alderique del usuariu o de la dirección IP.",
+ "apihelp-block-example-ip-simple": "Bloquiar la dirección IP <kbd>192.0.2.5</kbd> mientres 3 díes col motivu <kbd>Primer avisu</kbd>.",
+ "apihelp-block-example-user-complex": "Bloquiar al usuariu <kbd>Vandal</kbd> indefinidamente col motivu <kbd>Vandalismu</kbd> y torgar que cree nueves cuentes o unvie correos.",
+ "apihelp-changeauthenticationdata-summary": "Camudar los datos d'identificación del usuariu actual.",
+ "apihelp-changeauthenticationdata-example-password": "Intentar camudar la contraseña del usuariu actual a <kbd>ContraseñaExemplu</kbd>.",
+ "apihelp-createaccount-param-name": "Nome d'usuariu.",
+ "apihelp-createaccount-param-language": "Códigu de llingua p'afitar como predetermináu al usuariu (opcional, predetermina la llingua del conteníu).",
+ "apihelp-disabled-summary": "Esti módulu deshabilitóse."
+}
diff --git a/www/wiki/includes/api/i18n/av.json b/www/wiki/includes/api/i18n/av.json
new file mode 100644
index 00000000..df85c0b6
--- /dev/null
+++ b/www/wiki/includes/api/i18n/av.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Аль-Гимравий"
+ ]
+ },
+ "apihelp-block-param-user": "Нужее блокалда лъезе бокьун вугев гІахьалчиясул цІар, IP-адрес яги IP-адресазул диапазон"
+}
diff --git a/www/wiki/includes/api/i18n/awa.json b/www/wiki/includes/api/i18n/awa.json
new file mode 100644
index 00000000..b961399f
--- /dev/null
+++ b/www/wiki/includes/api/i18n/awa.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "1AnuraagPandey"
+ ]
+ },
+ "apihelp-block-summary": "सदस्य कय अवरोधित करा जाय।",
+ "apihelp-block-param-reason": "ब्लाक करेकै कारण",
+ "apihelp-block-param-nocreate": "खाते बनावेकै रोका जाय",
+ "apihelp-edit-param-minor": "छोट संपादन"
+}
diff --git a/www/wiki/includes/api/i18n/azb.json b/www/wiki/includes/api/i18n/azb.json
new file mode 100644
index 00000000..37259d7a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/azb.json
@@ -0,0 +1,15 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ilğım"
+ ]
+ },
+ "apihelp-main-param-action": "هانسی ایش گؤرولسون.",
+ "apihelp-main-param-requestid": "بۇرادا یازیلان هر میقدار جاوابا آرتیلاجاقدیر. ایستک‌لری فرقلندیرمه‌یه ایشلنه بیلر.",
+ "apihelp-block-param-nocreate": "حساب آچماغین قاباغینی آل.",
+ "apihelp-checktoken-param-token": "تِست اۆچون توکن.",
+ "apihelp-compare-param-fromtitle": "مۆقاییسه اۆچون ایلک باشلیق.",
+ "apihelp-delete-summary": "بیر صفحه‌نی سیل.",
+ "apihelp-delete-param-unwatch": "صفحه‌نی ایزله‌دیکلر لیستیندن سیل.",
+ "apihelp-edit-summary": "صفحه‌لری یارادیب دَییشدیر."
+}
diff --git a/www/wiki/includes/api/i18n/ba.json b/www/wiki/includes/api/i18n/ba.json
new file mode 100644
index 00000000..da8535d6
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ba.json
@@ -0,0 +1,442 @@
+{
+ "@metadata": {
+ "authors": [
+ "Рустам Нурыев",
+ "Азат Хәлилов",
+ "Sagan",
+ "Айсар",
+ "Янмурза Баки",
+ "Айбикә",
+ "Лилиә",
+ "Lizalizaufa",
+ "Гульчатай",
+ "Ilmira",
+ "Гизатуллина",
+ "Танзиля Кутлугильдина",
+ "Ләйсән"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Документация]]\n* [[mw:API:FAQ|ЧаВО]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Почта таратыу]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API яңылыҡтары]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Хаталар һәм яуаптар]\n</div>\n<strong>Статус:</strong> Был биттә күрһәтелгән бар функциялар ҙа эшләргә тейеш, шулай ҙа API әүҙем эшкәртеү хәлендә тора һәм теләгән бер ваҡытта үҙгәрергә мөмкин. Яңыртылыуҙарҙы һәр саҡ белеп торор өсөн [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ почта таратыу mediawiki-api-announce], ошоға яҙыл.\n\n<strong>Хаталы һоратыуҙар:</strong> Әгәр API хаталы һоратыу алһа, HTTP баш һүҙе «MediaWiki-API-Error» асҡысы менән кире ҡайтарыла, бынан һуң баш һүҙҙең мәғәнәһе һәм хата коды кире ебәреләсәк һәм кире шул уҡ мәғәнәлә кире ҡуйыласаҡ. Киңерәк мәғлүмәтте ошонан ҡара [[mw:API:Errors_and_warnings|API:Хаталар һәм иҫкәртеүҙәр]].\n\n<strong>Тестлау:</strong> API-һоратыуҙарҙы тестлау уңайлы булһын өсөн ҡара. [[Special:ApiSandbox]]",
+ "apihelp-main-param-action": "Үтәлергә тейеш булған ғәмәлдәр.",
+ "apihelp-main-param-format": "Мәғлүмәттәр сығарыу форматы.",
+ "apihelp-main-param-smaxage": "Cache-Control HTTP-баш һүҙҙең <code>s-maxage</code> мәғәнәһен бирелгән секунд эсендә билдәләй.",
+ "apihelp-main-param-maxage": "Cache-Control HTTP-баш һүҙҙең <code>s-maxage</code> мәғәнәһен бирелгән секунд эсендә билдәләй.",
+ "apihelp-main-param-assert": "Әгәр <kbd>user</kbd>бирелһә ҡулланыусы танылған икәненә, йәки <kbd>bot</kbd>бирелһә ҡол хоҡуғына эйә икәненә ышанырға",
+ "apihelp-main-param-requestid": "Бында бирелгән һәр мәғәнә яуапҡа индереләсәк. Һорауҙарҙы айырыу өсөн файҙаланылырға мөмкин",
+ "apihelp-main-param-servedby": "Һөҙөмтәләргә һорауҙы эшкәрткән хост исемен индерергә",
+ "apihelp-main-param-curtimestamp": "Һөҙөмтәләргә ваҡытлыса тамға ҡуйырға.",
+ "apihelp-main-param-origin": "API мөрәжәғәт иткәндә AJAX-һорау (CORS) кросс-домены ҡулланһағыҙ, параметрға тәүге домен мәғәнәһен бирегеҙ. Ул алдағы һорауҙа булырға һәм шул рәүешле URI-һорауҙың (POST түгел) бер өлөшө булырға тейеш. Ул атамалағы бер сығанаҡҡа <code>Origin<code> тап килергә тейеш, мәҫәлән, <kbd>https://ru.wikipedia.org</kbd> йәки <kbd>https://meta.wikimedia.org</kbd>. Әгәр ҙә параметр атамаға <code>Origin<code> тура килмәһә, яуап 403 хата коды менән кире ҡайтарыла. Әгәр параметр <code>Origin</code> атамаға тура килһә, һәм сығанаҡ рөхсәт ителгән исемлектә икән, <code>Access-Control-Allow-Origin</code> тигән атама ҡуйыласаҡ.",
+ "apihelp-block-summary": "Ҡатнашыусыны бикләү",
+ "apihelp-block-param-user": "Һеҙ бикләргә теләгән ҡатнашыусының IP адресы йәки IP диапозоны.",
+ "apihelp-block-param-expiry": "Ғәмәлдән сығыу ваҡыты. Ул сағыштырмаса булыуы мөмкин(мәҫәлән <kbd>5 ай</kbd> йәки <kbd>2 аҙна</kbd>) йәки абсолют (мәҫәлән <kbd>2014-09-18T12:34:56Z</kbd>). Әгәр саманан тыш ҡуйылһа <kbd>сикһеҙ</kbd>, <kbd>билдәләнмәгән</kbd>, йәки <kbd>һис ҡасан</kbd>, блок ғәмәлдән сыҡмай.",
+ "apihelp-block-param-reason": "Бикләү сәбәбе.",
+ "apihelp-block-param-anononly": "Аноним ҡатнашыусыларҙы бикләү (йәғни IP адресынан төҙәтеүҙе тыйыу).",
+ "apihelp-block-param-nocreate": "Яңы иҫәп яҙыуҙарын булдырыуҙы тыйыу.",
+ "apihelp-block-param-autoblock": "Был ҡатнашыусы ҡулланған һуңғы IP адрестарҙы һәм артабан үҙгәртеү өсөн ҡулланрға тырышҡан IP адрестарҙы бикләргә",
+ "apihelp-block-param-noemail": "Ҡулланыусының Вики аша электрон почта ебәреүен тыйыу. (Талап итә <code>blockemail</code> хоҡуғын).",
+ "apihelp-block-param-hidename": "Бикләү журналында ҡулланыусы исемен йәшерергә. (Хоҡуҡ талап ителә<code>hideuser</code>)",
+ "apihelp-block-param-allowusertalk": "Ҡатнашыусыларға үҙҙәренең биттәрен мөхәррирләргә мөмкинлек бирә (<var> менән бәйләнгән. [[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Әгәр ҡатнашыусы бикләнгән булһа, ғәмәлдәге бикләүгә күсереп яҙырға.",
+ "apihelp-block-param-watchuser": "Битте йәки IP-ҡатнашыусыны һәм фекер алышыу битен күҙәтеү аҫтына аларға.",
+ "apihelp-block-example-ip-simple": "Блок IP-адрес <KBD> 192.0.2.5 </ KBD> өс көн эсендә <KBD> Беренсе удар </ KBD>.",
+ "apihelp-block-example-user-complex": "Ҡулланыусыны ябыу <KBD> Вандал </ KBD> уйланылған билдәһеҙ мөҙҙәткә <KBD> Вандаллыҡ </ KBD>, шулай уҡ яңы иҫәп булдырыуға юл ҡуймау һәм электрон почтаға ебәреү.",
+ "apihelp-checktoken-summary": "<kbd>-нан Маркерҙың дөрөҫлөгөн тикшерегеҙ [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Тамға тибы һынау үтә.",
+ "apihelp-checktoken-param-token": "Тикшереү токены.",
+ "apihelp-checktoken-param-maxtokenage": "Токендың максималь йәше (секундтарҙа)",
+ "apihelp-checktoken-example-simple": "<kbd>csrf</kbd>-токендың яраҡлығын тикшерергә",
+ "apihelp-clearhasmsg-summary": "Ағымдағы ҡуллыныусының <code>hasmsg</code> флагын таҙарта",
+ "apihelp-clearhasmsg-example-1": "Ағымдағы ҡуллыныусының <code>hasmsg</code> флагын таҙарта",
+ "apihelp-compare-summary": "Тикшереү һаны, биттең баш һүҙе, йәки бит өсөн идентификатор баштан аҙаҡҡаса икеһе өсөн дә ҡабул ителергә тейеш",
+ "apihelp-compare-param-fromtitle": "Сағыштырыу өсөн беренсе баш һүҙ",
+ "apihelp-compare-param-fromid": "Сағыштырыу өсөн беренсе идентификатор.",
+ "apihelp-compare-param-fromrev": "Сағыштырыу өсөн беренсе редакция.",
+ "apihelp-compare-param-totitle": "Сағыштырыу өсөн икенсе баш һүҙ",
+ "apihelp-compare-param-toid": "Сағыштырыу өсөн икенсе идентификатор.",
+ "apihelp-compare-param-torev": "Сағыштырыу өсөн икенсе версия.",
+ "apihelp-compare-example-1": "1-се һәм 2-се версиялар араһында айырма эшләү",
+ "apihelp-createaccount-summary": "Ҡатнашыусыларҙың яңы иҫәп яҙыуҙарын булдырыу.",
+ "apihelp-createaccount-param-name": "Ҡатнашыусы исеме.",
+ "apihelp-createaccount-param-password": "Серһүҙ (ignored if <var>$1mailpassword</var> is set).",
+ "apihelp-createaccount-param-domain": "Тышҡы аутентификация домены (өҫтәмә).",
+ "apihelp-createaccount-param-token": "Беренсе ғариза буйынса алынған токендың иҫәп яҙмаһын булдырыу",
+ "apihelp-createaccount-param-email": "Ҡатнашыусының электрон почта адресы (өҫтәмә).",
+ "apihelp-createaccount-param-realname": "Ҡатнашыусының ысын исеме(өҫтәмә)",
+ "apihelp-createaccount-param-mailpassword": "Әгәр ҙә теләһә ниндәй мәғәнә ҡуйылһа, осраҡлы серһүҙ ҡулланыусыға ебәреләсәк",
+ "apihelp-createaccount-param-reason": "Журналға яҙыу өсөн иҫәп яҙмаһын булдырыуға өҫтәмә сәбәп",
+ "apihelp-createaccount-param-language": "Тел кодын ҡулланыусы өсөн һүҙһеҙ ҡуйырға (мотлаҡ түгел, эсенә алғандағында тел һүҙһеҙ файҙаланыла)",
+ "apihelp-createaccount-example-pass": "<kbd>test123</kbd> серһүҙле <kbd>testuser</kbd> ҡулланыусыһын булдырыу.",
+ "apihelp-createaccount-example-mail": "<kbd>testmailuser</kbd> ҡулланыусыһын һәм электрон почтаны булдырыу, осраҡлы серһеҙ яһау",
+ "apihelp-delete-summary": "Битте юйырға.",
+ "apihelp-delete-param-title": "Биттең баш һүҙен юйырға. <var>$1биттәрҙән</var> бергә файҙаланыу мөмкин түгел.",
+ "apihelp-delete-param-pageid": "Бит идентифакторы юйылыу өсөн биттәр. <var>$1title</var> менән бергә ҡулланыла алмайҙар",
+ "apihelp-delete-param-reason": "Юйылыу сәбәбе. Әгәр ул ҡуйылмаған булһа, билдәләнмәгән сәбәп менән автоматик рәүештә юйыласаҡ.",
+ "apihelp-delete-param-tags": "Юйҙырылғандар журналындағы яҙмаларға мөрәжәғәт итер өсөн, билдәләрҙе үҙгәртергә.",
+ "apihelp-delete-param-watch": "Ҡулланыусының ағымдағы күҙәтеү исемлегенә бит өҫтәргә.",
+ "apihelp-delete-param-watchlist": "Ағымдағы ҡулланыусының теҙмәһенән битте һүҙһеҙ өҫтәргә йәки юйырға, һылтанмаларҙы файҙаланығыҙ йәки сәғәтте алмаштырмаҫҡа.",
+ "apihelp-delete-param-unwatch": "Ҡулланыусының ағымдағы күҙәтеү исемлегенән битте юйырға.",
+ "apihelp-delete-param-oldimage": "\nБында нисек ҡаралғанса, юйыу өсөн иҫке һүрәтләмәнең исеме [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]]",
+ "apihelp-delete-example-simple": "Юйырға: <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Юйырға <kbd>Main Page</kbd> сәбәп <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Был модуль һүндерелгән.",
+ "apihelp-edit-summary": "Биттәрҙе төҙөргә һәм мөхәррирләргә.",
+ "apihelp-edit-param-title": "Мөхәриррләү өсөн биттең исеме.<var>$1биттәрҙән</var> бергә файҙаланыу мөмкин түгел.",
+ "apihelp-edit-param-pageid": "Бит идентифакторын мөхәррирләү өсөн биттәр. <var>$1title</var> менән бергә ҡулланыла алмайҙар",
+ "apihelp-edit-param-section": "Номерҙы айырыу. <KBD> 0 </ KBD> өҫкө секция өсөн, <KBD> яңы </ KBD> яңынан бүлеү өсөн.",
+ "apihelp-edit-param-sectiontitle": "Яңы бүлек өсөн баш исем.",
+ "apihelp-edit-param-text": "Биттең йөкмәткеһе.",
+ "apihelp-edit-param-summary": "Һығымтаны мөхәррирләргә. Шулай уҡ бүлектең $1section = яңы $1sectiontitle исеме ҡуйылмаған",
+ "apihelp-edit-param-tags": "Яңынан ҡарау өсөн, билдәләрҙе үҙгәртергә.",
+ "apihelp-edit-param-minor": "Әҙ генә үҙгәртеүҙәр.",
+ "apihelp-edit-param-notminor": "Ҙур ғына үҙгәреш (ғәҙәттә, «әҙ»ҙән күберәк төҙәтеү).",
+ "apihelp-edit-param-bot": "Төҙәтеүҙе бот яһаған тип билдәләү.",
+ "apihelp-edit-param-basetimestamp": "База тикшереү билдәһе тышҡы ҡаршылыҡтарҙы белеү өсөн файҙаланыла. \n\n[[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]] ярҙамында алынырға мөмкин",
+ "apihelp-edit-param-starttimestamp": "Билдә, мөхәриррләү процессы башланған саҡта, мөхәриррләү ҡаршылыҡтары беленгәндә файҙаланыла. Тура килгән ғәмәл <вар> [[Special:ApiHelp/main|curtimestamp]] ярҙамы менән мөхәриррләү процессы башында алынырға мөмкин (мәҫәлән, мөхәриррләү бите эстәлеген тейәгәндә)",
+ "apihelp-edit-param-recreate": "Шул уҡ ваҡытта юйыласаҡ бит тураһындағы бар хаталарҙы ҡапларға.",
+ "apihelp-edit-param-createonly": "Булған битте мөхәррирләмәҫкә.",
+ "apihelp-edit-param-nocreate": "Сик күрһәтелмәһә, хаталарҙы ташларға.",
+ "apihelp-edit-param-watch": "Ҡулланыусының ағымдағы күҙәтеү исемлегенә бит өҫтәргә.",
+ "apihelp-edit-param-unwatch": "Ҡулланыусының ағымдағы күҙәтеү исемлегенән битте юйырға.",
+ "apihelp-edit-param-watchlist": "Ағымдағы ҡулланыусының теҙмәһенән битте һүҙһеҙ өҫтәргә йәки юйырға, һылтанмаларҙы файҙаланығыҙ йәки сәғәтте алмаштырмағыҙ.",
+ "apihelp-edit-param-md5": "\n\nMD5-хэш параметрының $1 text, йәки $1 prepend тексы һәм $1 appendtext параметрҙары бәйләнгән. \nҠуйылған булһа, әгәр хэш дөрөҫ булмаһа, мөхәррирләү эшләнмәйәсәк.",
+ "apihelp-edit-param-prependtext": "Был тексты биттең башына өҫтәгеҙ. $1text алмаштыра.",
+ "apihelp-edit-param-appendtext": "Был тексты биттең аҙағынаса өҫтәгеҙ.$1text алмаштыра.\n$1section -ды файҙаланығыҙ = яңы, яңы бүлек өҫтәү өсөн, ә был параметрға түгел.",
+ "apihelp-edit-param-undo": "Был версияны кире алырға. $1text,$1prependtext,$1appendtext алмаштыра.",
+ "apihelp-edit-param-undoafter": "$1undo- нан алып барлыҡ үҙгәртеүҙәрҙе кире алырға. Әгәр ул ҡуйылмаған булһа, бер тикшереүҙе кире алыу ҙа етә.",
+ "apihelp-edit-param-redirect": "Автоматик йүнәлтеүҙе рөхсәт итергә.",
+ "apihelp-edit-param-contentformat": "Текстҡа ҡуйыу өсөн йөкмәткенең сериализация форматы.",
+ "apihelp-edit-param-contentmodel": "Яңы йөкмәткенең контент моделе.",
+ "apihelp-edit-param-token": "Маркер һуңғы параметр сифатында ебәрелергә тейеш, йәки, һәрхәлдә $1text параметрынан һуң.",
+ "apihelp-edit-example-edit": "Битте мөхәррирләү",
+ "apihelp-edit-example-prepend": "Бит башына тылсымлы һүҙ ҡуйырға <kbd>_&#95;NOTOC_&#95;</kbd>.",
+ "apihelp-edit-example-undo": " 13579-ҙан 13585-кә тиклем төҙәтеүҙәрҙе кире алырға",
+ "apihelp-emailuser-summary": "Ҡатнашыусыға хат",
+ "apihelp-emailuser-param-target": "Ҡатнашыусы электрон хат ебәрә",
+ "apihelp-emailuser-param-subject": "Теманың баш һүҙе",
+ "apihelp-emailuser-param-text": "Хат эстәлеге",
+ "apihelp-emailuser-param-ccme": "Был хәбәрҙең копияһын миңә ебәрергә",
+ "apihelp-emailuser-example-email": "Ҡатнашыусыға хат ебәрергә <kbd>WikiSysop</kbd>текст <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "wikitext ҡалыптарын аса.",
+ "apihelp-expandtemplates-param-title": "Бит баш һүҙе",
+ "apihelp-expandtemplates-param-text": "Конвертлау өсөн викитекст",
+ "apihelp-expandtemplates-param-revid": "<code><nowiki>{{REVISIONID}}</nowiki></code> һәм шуға оҡшаған алмаштар өсөн ID-ны яңынан ҡарау",
+ "apihelp-expandtemplates-param-prop": "\nАлыу өсөн, мәғлүмәттең ҡайһы өлөшө\n\nИғтибар итегеҙ, әгәр бер ғәмәл дә һайланмаһа, ул саҡта һөҙөмтә вики- текстан торасаҡ, тик сығыу элекке форматта.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Киңәйтелгән викитекст",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Бар категориялар ҙа вики текста күрһәтелмәй индерелгән мәғлүмәттәрҙе күрһәтә",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Вики-текстағы билдәле киңәйтелгән тылсымлы һүҙҙәрҙең биттәре үҙенсәлеге.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Максималь ваҡыт үткәндән һуң һөҙөмтә кэштары яраҡһыҙ тип табылырға тейеш.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Бит өсөн үҙенсәлекле JavaScript үҙгәреүсән конфигурациялар бирә.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "JavaScriptтың JSON юлы һымаҡ үҙенсәлекле биттәренә алышына торған конфигурация бирә.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "XML керетелә торған мәғлүмәт ағасы (шәжәрәһе).",
+ "apihelp-expandtemplates-param-includecomments": "Сыҡҡанда HTML комментарийҙарына индереү кәрәкме?",
+ "apihelp-expandtemplates-example-simple": "Вики-тексты асығыҙ <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Һеҙҙең исемгә килгән тәҡдимдәргә ҡайтыу",
+ "apihelp-feedcontributions-param-feedformat": "Мәғлүмәттәр сығарыу форматы.",
+ "apihelp-feedcontributions-param-year": "Йылдан башлап (һәм элегерәк):",
+ "apihelp-feedcontributions-param-month": "Айҙан башлап (һәм элегерәк):",
+ "apihelp-feedcontributions-param-deletedonly": "Юйылған төҙәтеүҙәрҙе генә күрһәтергә.",
+ "apihelp-feedcontributions-param-toponly": "Һуңғы өлгө булған төҙәтеүҙәрҙе генә күрһәтергә",
+ "apihelp-feedcontributions-param-newonly": "Яңы бит яһаған төҙәтеүҙәрҙе генә күрһәтергә",
+ "apihelp-feedcontributions-param-showsizediff": "Өлгәоәр араһыдағы күләм айырмаһын күрһәтергә",
+ "apihelp-feedcontributions-example-simple": "Ҡулланыусының өлөшөн күрһәтергә <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Каналдың һуңғы үҙгәрештәрен кире ҡайтарырға.",
+ "apihelp-feedrecentchanges-param-feedformat": "Мәғлүмәттәр сығарыу форматы.",
+ "apihelp-feedrecentchanges-param-invert": "Һайланғандан башҡа исемдәр арауығы",
+ "apihelp-feedrecentchanges-param-limit": "Ҡайтарылған һөҙөмтәләрҙең максималь һаны.",
+ "apihelp-feedrecentchanges-param-from": "Теге ваҡыттын булған үҙгәрештәрҙе күрһәтергә",
+ "apihelp-feedrecentchanges-param-hideminor": "Бәләкәй төҙәтеүҙәрҙе йәшерергә",
+ "apihelp-feedrecentchanges-param-hidebots": "Робот эшләгән төҙәтеүҙәрҙе йәшерергә",
+ "apihelp-feedrecentchanges-param-hideanons": "Аноним ҡатнашыусылар төҙәтеүен йәшерергә",
+ "apihelp-feedrecentchanges-param-hideliu": "Теркәлгән ҡатнашыусылар өлөшөн йәшерергә",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Патрулләнгән төҙәтеүҙәрҙе йәшерергә",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ағымдаға ҡатнашыусы эшләгән үҙгәртеүҙәрҙе йәшерергә.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Тэг буйынса һөҙгөс",
+ "apihelp-feedrecentchanges-param-target": "Был биттән һылтанған биттәрҙә һуңғы үҙгәртеүҙәрҙе күрһәтергә",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Киреһенсә, был биткә һылтанма яһаған биттәрҙәге үҙгәртеүҙәрҙе күрһәтергә",
+ "apihelp-feedrecentchanges-param-categories": "Бар категория биттәрендәге үҙгәрештәрҙе генә күрһәтергә",
+ "apihelp-feedrecentchanges-param-categories_any": "Был категориянан башҡа теләһә ҡайһы категориялар биттәрендәге үҙгәрештәрҙе генә күрһәтергә",
+ "apihelp-feedrecentchanges-example-simple": "Һуңғы үҙгәртеүҙәрҙе күрһәтергә.",
+ "apihelp-feedrecentchanges-example-30days": "30 көн арауығындағы һуңғы үҙгәртеүҙәрҙе күрһәтергә.",
+ "apihelp-feedwatchlist-summary": "Күҙәтеү каналын ҡайтара",
+ "apihelp-feedwatchlist-param-feedformat": "Мәғлүмәттәр сығарыу форматы.",
+ "apihelp-feedwatchlist-param-hours": "Был моменттан һуң күп сәғәт эсендә биттәр исемлеге үҙгәртелгән.",
+ "apihelp-feedwatchlist-param-linktosections": "Мөмкин булһа, үҙгәртеүҙәр булған бүлеккә тура һылтанма.",
+ "apihelp-feedwatchlist-example-default": "Күҙәтеү каналын күрһәтергә",
+ "apihelp-feedwatchlist-example-all6hrs": "Күҙәтеү биттәрендәге һуңғы 6 сәғәт эсендәге барлыҡ үҙгәрештәрҙе күрһәтергә.",
+ "apihelp-filerevert-summary": "Файлды иҫке версияға ҡайтарырға.",
+ "apihelp-filerevert-param-filename": "Префиксһыҙ файл исеме",
+ "apihelp-filerevert-param-comment": "Комментарий тейәргә",
+ "apihelp-filerevert-example-revert": "Кире <kbd>Wiki.png</kbd> юрауға <kbd>2011-03-05T15:27:40Z</kbd> ҡайтырға.",
+ "apihelp-help-summary": "Күрһәтелгән модулдәр өсөн белешмәне тасуирлау.",
+ "apihelp-help-param-modules": " Белешмәләр тасуирлау өсөн (күрһәткестәр <var>action</var> һәм <var>format</var> дәүмәленә, йәки <kbd>main</kbd>). Модулдәрҙе a <kbd>+</kbd> ярҙамында күрһәтә алаһығыҙ.",
+ "apihelp-help-param-submodules": "Модуль исеменән субмодулдәр өсөн ярҙам индерә",
+ "apihelp-help-param-recursivesubmodules": "Рекурсив рәүешле субмодулдәр өсөн ярҙам индерә.",
+ "apihelp-help-param-helpformat": "Һөҙөмтәгә ярҙам форматы",
+ "apihelp-help-example-main": "Төп модулгә ярҙам",
+ "apihelp-help-example-submodules": "<kbd>action=query</kbd> һәм уның барлыҡ субмодулдәренә ярҙам итегеҙ",
+ "apihelp-help-example-recursive": "Бар белешмә бер бүлектә.",
+ "apihelp-help-example-help": "Модулдең үҙ ярҙамына ярҙам итеү",
+ "apihelp-help-example-query": "Подмодулдәрҙең ике һорауына ярҙам итергә.",
+ "apihelp-imagerotate-summary": "Бер йәки бер нисә һүрәтте бороу.",
+ "apihelp-imagerotate-param-rotation": "Һүрәтте сәғәт йөрөшө буйынса нисә градусҡа борорға.",
+ "apihelp-imagerotate-example-simple": "<kbd>File:Example.png</kbd> на <kbd>90</kbd> градусҡа борорға.",
+ "apihelp-imagerotate-example-generator": "Бар һүрәттәрҙе лә <kbd>Category:Flip</kbd> на <kbd>180</kbd> градусҡа борорға.",
+ "apihelp-import-param-summary": "Йомғаҡты импортлау.",
+ "apihelp-import-param-xml": "Тултырылған XML-файл.",
+ "apihelp-import-param-interwikisource": "Интервики-импорт өсөн: Викинан импорт.",
+ "apihelp-import-param-interwikipage": "Интервики-импорт өсөн: битте импортлау.",
+ "apihelp-import-example-import": "Импортларға [[meta:Help:ParserFunctions]] 100 исемдәр арауығында тулы тарихы менән.",
+ "apihelp-login-param-name": "Ҡатнашыусы исеме.",
+ "apihelp-login-param-password": "Серһүҙ.",
+ "apihelp-login-param-domain": "Домен (мотлаҡ түгел).",
+ "apihelp-login-param-token": "Беренсе һорау ваҡытынла алынған логин маркер",
+ "apihelp-login-example-gettoken": "Системаға инеү маркерын алыу.",
+ "apihelp-login-example-login": "Танылыу.",
+ "apihelp-logout-summary": "Сығырға һәм сессия мәғлүмәтен юйырға.",
+ "apihelp-logout-example-logout": "Ағымдағы ҡулланыусының киткән саҡта инеүе",
+ "apihelp-managetags-summary": "Тегтарҙы үҙгәртеү менән бәйле идара итеү мәсьәләләрен хәл итеү",
+ "apihelp-managetags-param-reason": "\nБилдәне булдырыу, юйҙырыу, активациялау һәм деактивациялау өсөн мотлаҡ булмаған сәбәп",
+ "apihelp-mergehistory-summary": "Үҙгәртеүҙәр тарихын берләштереү.",
+ "apihelp-mergehistory-param-from": "Тарихты берләштергән бит атамаһы. <var>$1fromid</var> менән бергә ҡуланыуы мөмкин түгел.",
+ "apihelp-mergehistory-param-fromid": "Тарихты берләштергән бит атамаһы. <var>$1fromid</var> менән бергә ҡуланыуы мөмкин түгел.",
+ "apihelp-mergehistory-param-to": "Тарихты берләштергән бит атамаһы. <var>$1fromid</var> менән бергә ҡуланыуы мөмкин түгел.",
+ "apihelp-mergehistory-param-toid": "Тарихты берләштергән бит атамаһы. <var>$1fromid</var> менән бергә ҡуланыуы мөмкин түгел.",
+ "apihelp-mergehistory-param-reason": "Тарихты берләштереү сәбәбе",
+ "apihelp-move-summary": "Биттең исемен үҙгәртергә",
+ "apihelp-move-param-from": "Мөхәриррләү өсөн биттең исеме.<var>$1биттәрҙән</var> бергә файҙаланыу мөмкин түгел.",
+ "apihelp-move-param-fromid": "Бит идентифакторын мөхәррирләү өсөн биттәр. <var>$1title</var> менән бергә ҡулланыла алмайҙар.",
+ "apihelp-move-param-to": "Исемен үҙгәртергә тейешле биттең баш һүҙе",
+ "apihelp-move-param-reason": "Үҙгәртеү сәбәбе",
+ "apihelp-move-param-movetalk": "Фекер алышыу бите булһа, исемен үҙгәртергә.",
+ "apihelp-move-param-movesubpages": "Мөмкин булһа, ярҙамсы биттең исемен үҙгәртергә.",
+ "apihelp-move-param-noredirect": "Йүнәлтеүҙәр ҡуймаҫҡа",
+ "apihelp-move-param-watch": "Ағымдағы ҡулланыусының күҙәтеү битенә бит һәм йүнәлтеү өҫтәргә.",
+ "apihelp-move-param-unwatch": "Ағымдағы ҡулланыусының күҙәтеү битендә битте һәм йүнәлтеүҙе юйырға.",
+ "apihelp-move-param-watchlist": "Ағымдағы ҡулланыусының теҙмәһенән битте һүҙһеҙ өҫтәргә йәки юйырға, һылтанмаларҙы файҙаланығыҙ йәки сәғәтте алмаштырмаҫҡа.",
+ "apihelp-move-param-ignorewarnings": "Бөтә иҫкәрмәләргә иғтибар итмәҫкә",
+ "apihelp-move-example-move": "Исемен үҙгәртергә <kbd>Badtitle</kbd> <kbd>Goodtitle</kbd> йүнәлтеү ҡуймаҫҡа.",
+ "apihelp-opensearch-summary": "OpenSearch протоколын ҡулланып вики эҙләү.",
+ "apihelp-opensearch-param-search": "Эҙләү юлы.",
+ "apihelp-opensearch-param-limit": "Ҡайтарылған һөҙөмтәләрҙең максималь һаны.",
+ "apihelp-opensearch-param-namespace": "Эҙләү өсөн исемдәр арауығы",
+ "apihelp-opensearch-param-format": "Мәғлүмәттәр сығарыу форматы.",
+ "apihelp-opensearch-example-te": "<KBD> Te </ KBD> менән башланған биттәрҙе табырға.",
+ "apihelp-options-param-reset": "Килешеү буйынса көйләүҙәргә күсергә.",
+ "apihelp-options-example-reset": "Бөтә көйләүҙәрҙе ташларға",
+ "apihelp-paraminfo-summary": "API модуле тураһында мәғлүмәт алырға.",
+ "apihelp-paraminfo-param-helpformat": "Белешмә юлы форматы.",
+ "apihelp-parse-param-prop": "Ҡайһы мәғлүмәтте алырға:",
+ "apihelp-parse-paramvalue-prop-langlinks": "Вики-текстың синтаксик анализында тышҡы ссылкалар бирә.",
+ "apihelp-parse-paramvalue-prop-links": "Вики-текстың синтаксик анализында тышҡы ссылкалар бирә.",
+ "apihelp-parse-paramvalue-prop-templates": "Вики-текстың синтаксик анализ ҡалыбын бирә.",
+ "apihelp-parse-paramvalue-prop-images": "Вики-текстың синтаксик анализында һәрәттәр бирә.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Вики-текстың синтаксик анализында тышҡы ссылкалар бирә.",
+ "apihelp-parse-paramvalue-prop-sections": "Вики-текстың синтаксик анализында секциялар бирә.",
+ "apihelp-parse-paramvalue-prop-revid": "Тикшерелгән биттәргә версиялар идентификаторын өҫтәй.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Вики-текстың синтаксик анализына исем ҡуя.",
+ "apihelp-parse-paramvalue-prop-headitems": "<код> & ЛТ -ҡа һалыу өсөн элементтар бирә; башы & GT; биттең </ код>",
+ "apihelp-parse-paramvalue-prop-headhtml": "Айырылған <код> & лт бирә; & баштары GТ; биттең </ код>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Бит өсөн үҙенсәлекле JavaScript үҙгәреүсән конфигурациялар бирә. Ҡулланыр өсөн, <code>mw.config.set()</code> ҡабыҙырға.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "JavaScriptтың JSON юлы һымаҡ үҙенсәлекле биттәренә алышына торған конфигурация бирә.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Вики-текстың синтаксик анализында интервиктарға һылтанма бирә.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Һығымта яҺау өсөн тәүге вики-тексты күрһәтә",
+ "apihelp-parse-paramvalue-prop-properties": "Вики-текстың синтаксик анализында билдәләнгән төрлө сифаттарҙы бирә.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Структура һымаҡ итеп төплө отчет бирә. $1disablelimitreport</ вар> <алышыныусы> ҡуйылған ваҡытта бер ниндәй мәғлүмәт тә бирмәй.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Төплө отчеттың HTML версияһын бирә. <переменная>$1disablelimitreport </ вар> ҡуйылған булһа,бер мәғлүмәт тә бирмәй.",
+ "apihelp-parse-param-disablepp": "Урынына <var>$1disablelimitreport</var> ҡулланырға.",
+ "apihelp-parse-param-preview": "Алдан ҡарау режимында синтаксик анализ",
+ "apihelp-parse-example-page": "Битте тикшереү.",
+ "apihelp-parse-example-text": "Тикшереү: wikitext.",
+ "apihelp-parse-example-summary": "Һығымтаны тикшереү.",
+ "apihelp-patrol-param-rcid": "Яңы ID үҙгәртеүҙәрҙе патрулләү өсөн",
+ "apihelp-patrol-param-revid": "ID мөхәррирҙе патулләү",
+ "apihelp-patrol-param-tags": "Юйҙырылғандар журналындағы яҙмаларға мөрәжәғәт итер өсөн, билдәләрҙе үҙгәртергә.",
+ "apihelp-patrol-example-rcid": "Һуңғы үҙгәрештәрҙе ҡарау.",
+ "apihelp-patrol-example-revid": "Яңынан ҡарау.",
+ "apihelp-protect-summary": "Битте һаҡлау кимәлен үҙгәртергә",
+ "apihelp-protect-param-title": "Бит атамаһы. $1pageid менән бергә ҡулланылмай.",
+ "apihelp-protect-param-reason": "(ООН) һағы сәбәптәре.",
+ "apihelp-protect-param-tags": "Юйҙырылғандар журналындағы яҙмаларға мөрәжәғәт итер өсөн, билдәләрҙе үҙгәртергә.",
+ "apihelp-protect-param-watchlist": "Ағымдағы ҡулланыусының теҙмәһенән битте һүҙһеҙ өҫтәргә йәки юйырға, һылтанмаларҙы файҙаланығыҙ йәки сәғәтте алмаштырмаҫҡа.",
+ "apihelp-protect-example-protect": "Битте һаҡларға.",
+ "apihelp-protect-example-unprotect": "<kbd>all</kbd> сикләүҙәрен (йәғни һәр береһе эшләй ала) ҡуйып, бит һағын асырға.",
+ "apihelp-protect-example-unprotect2": "Бер ниндәй сикләүҙәр ҡуймай биттән һаҡлауҙы алырға.",
+ "apihelp-purge-param-forcelinkupdate": "Таблицалар бәйләнешен яңыртыу.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Һылтанманы һәм таблицаны яңыртығыҙ һәм был битте шаблон итеп ҡулланған башҡа биттәр өсөн һылтанмаларҙы ла яңыртығыҙ.",
+ "apihelp-query-param-list": "Ниндәй исемлекте ҡулланырға",
+ "apihelp-query-param-meta": "Ниндәй матамәғлүмәт ҡулланырға",
+ "apihelp-query+allcategories-summary": "Бөтә категорияларҙы иҫәпләргә",
+ "apihelp-query+allcategories-param-from": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+allcategories-param-to": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+allcategories-param-prefix": "Был мәғәнәнән башланған бар атамаларҙы категориялар буйынса эҙләргә.",
+ "apihelp-query+allcategories-param-dir": "Сортлау йүнәлештәре.",
+ "apihelp-query+allcategories-param-limit": "Нисә категорияны кире ҡайтарырға",
+ "apihelp-query+allcategories-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Категорияларға биттәр һаны өҫтәү",
+ "apihelp-query+allcategories-example-size": "Биттәр һаны буйынса мәғлүмәтле категориялар исемлеге.",
+ "apihelp-query+allcategories-example-generator": "<kbd>исемлек</kbd> категориялар битенән мәғлүмәт алырға.",
+ "apihelp-query+alldeletedrevisions-summary": "Бар мөхәррирләү исемлеге ҡулланыусы тарафынан юйылған.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "<var>$3ҡулланыусының</var> менән генә ҡулланыла ала.",
+ "apihelp-query+alldeletedrevisions-param-end": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Был мәғәнәнән башланған бар атамаларҙы категориялар буйынса эҙләргә.",
+ "apihelp-query+alldeletedrevisions-param-user": "Бары тик был ҡулланыусының үҙгәртеүҙәр исемлеге.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Бары тик был исемдәр арауығындағы биттәр исемлеге.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Төп исемдәр арауығында юйылған тәүге 50 үҙгәртеү исемлеге.",
+ "apihelp-query+allfileusages-summary": "Юйылғандар менән бергә барлыҡ файлдар тәртибе исемлеге.",
+ "apihelp-query+allfileusages-param-from": "Һанауҙы башлау өсөн файл атамаһы.",
+ "apihelp-query+allfileusages-param-to": "Һанауҙы туҡтатыу файлы атамаһы.",
+ "apihelp-query+allfileusages-param-prefix": "Был мәғәнәнән башланған бар атамаларҙы категориялар буйынса эҙләргә.",
+ "apihelp-query+allfileusages-param-prop": "Ҡайһы мәғлүмәтте күрһәтергә:",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Файл атамаһына ҡуша.",
+ "apihelp-query+allfileusages-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+allfileusages-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+allfileusages-example-unique": "Атамаларҙың уҙенсәлекле файлдары исемлеге.",
+ "apihelp-query+allfileusages-example-unique-generator": "Төшөп ҡалғандарҙы айырып, барлыҡ исем-һылтанмаларҙы алырға.",
+ "apihelp-query+allfileusages-example-generator": "Һылтанмалы биттәр бар.",
+ "apihelp-query+allimages-summary": "Бер-бер артлы бөтә образдарҙы һанап сығырға.",
+ "apihelp-query+allimages-param-sort": "Сортировкалау үҙенсәлектәре.",
+ "apihelp-query+allimages-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+allimages-param-minsize": "Һүрәттәр лимиты (байттарҙа).",
+ "apihelp-query+allimages-param-maxsize": "Бар һүрәттәр лимиты (байттарҙа).",
+ "apihelp-query+allimages-param-limit": "Кире ҡайтыу өсөн образдар һаны.",
+ "apihelp-query+allimages-example-B": "<kbd>Б</kbd> хәрефенән башланған файлдар исемлеген күрһәтергә.",
+ "apihelp-query+allimages-example-generator": "<kbd>Б</kbd> хәрефенән башланған файлдар исемлеген күрһәтергә.",
+ "apihelp-query+alllinks-summary": "Бирелгән исемдәр арауығына йүнәлткән барлыҡ һылтанмаларҙы һанап сығырға.",
+ "apihelp-query+alllinks-param-from": "Һанауҙы башлау өсөн һылтанма атамаһы.",
+ "apihelp-query+alllinks-param-to": "Һанауҙы туҡтатыу һылтанмаһы атамаһы.",
+ "apihelp-query+alllinks-param-prefix": "Был мәғәнәнән башланған бәйләнешле бар атамаларҙы эҙләргә.",
+ "apihelp-query+alllinks-param-prop": "Ҡайһы мәғлүмәтте күрһәтергә:",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Һылтанма атамаһын ҡуша.",
+ "apihelp-query+alllinks-param-namespace": "Һанау өсөн исемдәр арауығы.",
+ "apihelp-query+alllinks-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+alllinks-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+alllinks-example-unique": "Атамаларҙың уҙенсәлекле файлдары исемлеге.",
+ "apihelp-query+alllinks-example-unique-generator": "Төшөп ҡалғандарҙы айырып, барлыҡ исем-һылтанмаларҙы алырға.",
+ "apihelp-query+alllinks-example-generator": "Һылтанмалы биттәр бар.",
+ "apihelp-query+allmessages-summary": "Был сайттан хәбәр ҡайтарыу.",
+ "apihelp-query+allmessages-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+allmessages-param-args": "Аргументтар Хәбәрҙәрҙә биреләсәк.",
+ "apihelp-query+allpages-param-from": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+allpages-param-to": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+allpages-param-prefix": "Был мәғәнәнән башланған бар атамаларҙы категориялар буйынса эҙләргә.",
+ "apihelp-query+allpages-param-namespace": "Һанау өсөн исемдәр арауығы.",
+ "apihelp-query+allpages-param-minsize": "Һүрәттәр лимиты (байттарҙа).",
+ "apihelp-query+allpages-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+allpages-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+allpages-example-B": "<kbd>Б</kbd> хәрефенән башланған файлдар исемлеген күрһәтергә.",
+ "apihelp-query+allpages-example-generator": "<kbd>Б</kbd> хәрефенән башланған файлдар исемлеген күрһәтергә.",
+ "apihelp-query+allredirects-param-from": "Һанауҙы туҡтатыу файлы атамаһы.",
+ "apihelp-query+allredirects-param-to": "Һанауҙы туҡтатыу файлы атамаһы.",
+ "apihelp-query+allredirects-param-prefix": "Был мәғәнәнән башланған бар атамаларҙы категориялар буйынса эҙләргә.",
+ "apihelp-query+allredirects-param-prop": "Ҡайһы мәғлүмәтте күрһәтергә:",
+ "apihelp-query+allredirects-param-namespace": "Һанау өсөн исемдәр арауығы.",
+ "apihelp-query+allredirects-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+allredirects-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+allredirects-example-generator": "Һылтанмалы биттәр бар.",
+ "apihelp-query+allrevisions-param-start": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+allrevisions-param-end": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+allrevisions-param-user": "Бары тик был ҡулланыусының үҙгәртеүҙәр исемлеге.",
+ "apihelp-query+allrevisions-param-excludeuser": "Бары тик был ҡулланыусының үҙгәртеүҙәр исемлеге.",
+ "apihelp-query+alltransclusions-param-to": "Һанауҙы туҡтатыу һылтанмаһы атамаһы.",
+ "apihelp-query+alltransclusions-param-prop": "Ҡайһы мәғлүмәтте күрһәтергә:",
+ "apihelp-query+alltransclusions-param-namespace": "Һанау өсөн исемдәр арауығы.",
+ "apihelp-query+alltransclusions-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+alltransclusions-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+alltransclusions-example-generator": "Һылтанмалы биттәр бар.",
+ "apihelp-query+allusers-param-from": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+allusers-param-to": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+allusers-param-prefix": "Был мәғәнәнән башланған бар атамаларҙы категориялар буйынса эҙләргә.",
+ "apihelp-query+allusers-param-dir": "Сортлау йүнәлештәре.",
+ "apihelp-query+allusers-param-prop": "Ҡайһы мәғлүмәтте күрһәтергә:",
+ "apihelp-query+backlinks-param-title": "Мөхәриррләү өсөн биттең исеме.<var>$1биттәрҙән</var> бергә файҙаланыу мөмкин түгел.",
+ "apihelp-query+backlinks-param-pageid": "Бит идентифакторын мөхәррирләү өсөн биттәр. <var>$1title</var> менән бергә ҡулланыла алмайҙар",
+ "apihelp-query+backlinks-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+blocks-param-start": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+blocks-param-end": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+blocks-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+blocks-example-simple": "Берләшмә исемлеге",
+ "apihelp-query+categories-param-limit": "Нисә категорияны кире ҡайтарырға",
+ "apihelp-query+categories-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+categorymembers-param-pageid": "Бит идентифакторы юйылыу өсөн биттәр. <var>$1title</var> менән бергә ҡулланыла алмайҙар",
+ "apihelp-query+categorymembers-param-prop": "Ҡайһы мәғлүмәтте күрһәтергә:",
+ "apihelp-query+categorymembers-param-limit": "Кире ҡайтарылған белдереүҙәрҙең иң күп һаны",
+ "apihelp-query+categorymembers-param-sort": "Сортҡа бүлеү үҙенсәлеге",
+ "apihelp-query+contributors-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+deletedrevisions-param-user": "Бары тик был ҡулланыусының үҙгәртеүҙәр исемлеге.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Бары тик был ҡулланыусының үҙгәртеүҙәр исемлеге.",
+ "apihelp-query+deletedrevs-param-start": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+deletedrevs-param-end": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+deletedrevs-param-prefix": "Был мәғәнәнән башланған бар атамаларҙы категориялар буйынса эҙләргә.",
+ "apihelp-query+deletedrevs-param-user": "Бары тик был ҡулланыусының үҙгәртеүҙәр исемлеге.",
+ "apihelp-query+duplicatefiles-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+duplicatefiles-example-generated": "Поиск дубликатов всех файлов.",
+ "apihelp-query+embeddedin-param-title": "Мөхәриррләү өсөн биттең исеме.<var>$1биттәрҙән</var> бергә файҙаланыу мөмкин түгел.",
+ "apihelp-query+embeddedin-param-pageid": "Бит идентифакторын мөхәррирләү өсөн биттәр. <var>$1title</var> менән бергә ҡулланыла алмайҙар",
+ "apihelp-query+embeddedin-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+embeddedin-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+exturlusage-param-limit": "Күпме һылтанмаларҙы кире ҡайтарырға.",
+ "apihelp-query+filearchive-param-from": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+filearchive-param-to": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+filearchive-param-prefix": "Был мәғәнәнән башланған бар атамаларҙы категориялар буйынса эҙләргә.",
+ "apihelp-query+filearchive-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+fileusage-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+fileusage-param-limit": "Күпме һылтанмаларҙы кире ҡайтарырға.",
+ "apihelp-query+imageinfo-param-prop": "Ҡайһы мәғлүмәтте алырға:",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Файл атамаһына ҡуша.",
+ "apihelp-query+images-param-limit": "Күпме һылтанмаларҙы кире ҡайтарырға.",
+ "apihelp-query+images-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+imageusage-param-title": "Мөхәриррләү өсөн биттең исеме.<var>$1биттәрҙән</var> бергә файҙаланыу мөмкин түгел.",
+ "apihelp-query+imageusage-param-pageid": "Бит идентифакторын мөхәррирләү өсөн биттәр. <var>$1title</var> менән бергә ҡулланыла алмайҙар",
+ "apihelp-query+imageusage-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+info-paramvalue-prop-protection": "Битте һаҡлау кимәлен үҙгәртергә",
+ "apihelp-query+iwbacklinks-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+iwbacklinks-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Файл атамаһына ҡуша.",
+ "apihelp-query+iwlinks-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+langbacklinks-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+langbacklinks-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+links-param-limit": "Күпме һылтанмаларҙы кире ҡайтарырға.",
+ "apihelp-query+links-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+linkshere-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+logevents-summary": "Журналдарҙан ваҡиға алыу.",
+ "apihelp-query+logevents-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+logevents-param-start": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+logevents-param-end": "Иҫәп күсереү тамамланған ваҡыт билдәһе",
+ "apihelp-query+logevents-example-simple": " Һуңғы теркәлгән ваҡиғалар исемлеге.",
+ "apihelp-query+pagepropnames-param-limit": "Кире ҡайтарылған белдереүҙәрҙең иң күп һаны",
+ "apihelp-query+pagepropnames-example-simple": "Тәүге 10 исем сифатын алыу.",
+ "apihelp-query+pageswithprop-param-prop": "Ҡайһы мәғлүмәтте күрһәтергә:",
+ "apihelp-query+pageswithprop-param-limit": "Кире ҡайтарылған белдереүҙәрҙең иң күп һаны",
+ "apihelp-query+pageswithprop-param-dir": "Ниндәй йүнәлешкә айырырға",
+ "apihelp-query+prefixsearch-param-search": "Эҙләү юлы.",
+ "apihelp-query+prefixsearch-param-namespace": "Эҙләү өсөн исемдәр арауығы",
+ "apihelp-query+prefixsearch-param-limit": "Ҡайтарылған һөҙөмтәләрҙең максималь һаны.",
+ "apihelp-query+prefixsearch-param-offset": "Төшөрөп ҡалдырыу өсөн һөҙөмтә иҫәбе",
+ "apihelp-query+protectedtitles-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+protectedtitles-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+querypage-param-limit": "Төшөрөп ҡалдырыу өсөн һөҙөмтә иҫәбе",
+ "apihelp-query+recentchanges-param-start": "Иҫәп күсереү башланған ваҡыт билдәһе",
+ "apihelp-query+recentchanges-param-end": "Иҫәп күсереү тамамланған ваҡыт билдәһе",
+ "apihelp-query+recentchanges-param-user": "Бары тик был ҡулланыусының үҙгәртеүҙәр исемлеге.",
+ "apihelp-query+recentchanges-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+recentchanges-param-type": "Ниндәй төрҙәр үҙгәртеүҙе күрһәтергө",
+ "apihelp-query+recentchanges-example-simple": "Һуңғы үҙгәртеүҙәрҙе күрһәтергә.",
+ "apihelp-query+redirects-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+redirects-param-limit": "Күпме һылтанмаларҙы кире ҡайтарырға.",
+ "apihelp-query+revisions-example-last5": "Һуңғы 5 <kbd>Main Page</kbd> версияны алырға.",
+ "apihelp-query+revisions-example-first5": "Тәүге 5 <kbd>Main Page</kbd> версияны алырға.",
+ "apihelp-query+search-param-info": "Ниндәй матамәғлүмәт ҡулланырға",
+ "apihelp-query+search-param-prop": "Ниндәй үҙенсәлекте ҡайтарырға",
+ "apihelp-query+search-param-limit": "Нисә битте тергеҙергә?",
+ "apihelp-query+tags-summary": "Үҙгәртелгән тамғалар исемлеге.",
+ "apihelp-query+tags-param-limit": "Кире ҡайтарылған белдереүҙәрҙең иң күп һаны",
+ "apihelp-query+tags-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+tags-example-simple": "Аңлайышлы тамғалар бите",
+ "apihelp-query+templates-param-limit": "Күпме һылтанмаларҙы кире ҡайтарырға.",
+ "apihelp-query+templates-param-dir": "Һанау йүнәлеше.",
+ "apihelp-query+transcludedin-param-prop": "Ниндәй үҙенсәлек алырға:",
+ "apihelp-query+transcludedin-param-limit": "Күпме һылтанмаларҙы кире ҡайтарырға.",
+ "apihelp-query+usercontribs-summary": "Ҡулланыусының бөтә төҙәтеүҙәрен алыу",
+ "apihelp-query+usercontribs-param-limit": "Кире ҡайтарылған белдереүҙәрҙең иң күп һаны",
+ "apierror-timeout": "Көтөлгән ваҡыт эсендә сервер яуып бирмәне."
+}
diff --git a/www/wiki/includes/api/i18n/bcl.json b/www/wiki/includes/api/i18n/bcl.json
new file mode 100644
index 00000000..420aded3
--- /dev/null
+++ b/www/wiki/includes/api/i18n/bcl.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Geopoet"
+ ]
+ },
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Arinman na mga kategoriyang yaon sa pinapalaog na bakong representado sa laog kan wikitext na kinaluwasan.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Titulo (may espasyong ngaran sa enotang panigmitan) sa pagpopoon kan gikanang pinagkuanan.",
+ "apihelp-query+watchlistraw-param-totitle": "Titulo (may espasyong ngaran sa enotang panigmitan) sa pagpapauntok kan gikanang pinaghalean."
+}
diff --git a/www/wiki/includes/api/i18n/be-tarask.json b/www/wiki/includes/api/i18n/be-tarask.json
new file mode 100644
index 00000000..3dad8300
--- /dev/null
+++ b/www/wiki/includes/api/i18n/be-tarask.json
@@ -0,0 +1,62 @@
+{
+ "@metadata": {
+ "authors": [
+ "Red Winged Duck",
+ "Renessaince"
+ ]
+ },
+ "apihelp-main-summary": "",
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Дакумэнтацыя]]\n* [[mw:API:FAQ|Частыя пытаньні]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Сьпіс рассылкі]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API-аб’явы]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Памылкі і запыты]\n</div>\n<strong>Статус:</strong> усе магчымасьці на гэтай старонцы павінны працаваць, але API знаходзіцца ў актыўнай распрацоўцы і можа зьмяняцца ў любы момант. Падпісвайцеся на [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ рассылку mediawiki-api-announce] дзеля паведамленьняў пра абнаўленьні.\n\n<strong>Памылковыя запыты:</strong> калі да API дасылаюцца памылковыя запыты, HTTP-загаловак будзе дасланы з ключом «MediaWiki-API-Error», а потым значэньне загалоўку і код памылкі будуць выстаўленыя на аднолькавае значэньне. Дзеля дадатковай інфармацыі глядзіце [[mw:API:Errors_and_warnings|API: Памылкі і папярэджаньні]].\n\n<strong>Тэставаньне:</strong> для зручнасьці праверкі API-запытаў, глядзіце [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Дзеяньне для выкананьня.",
+ "apihelp-main-param-format": "Фармат вываду.",
+ "apihelp-main-param-maxlag": "Максымальная затрымка можа ўжывацца, калі MediaWiki ўсталяваная ў клястэр з рэплікаванай базай зьвестак. Дзеля захаваньня дзеяньняў, якія выклікаюць затрымку рэплікацыі, гэты парамэтар можа прымусіць кліента чакаць, пакуль затрымка рэплікацыі меншая за яго значэньне. У выпадку доўгай затрымкі, вяртаецца код памылкі <samp>maxlag</samp> з паведамленьнем кшталту <samp>Чаканьне $host: $lag сэкундаў затрымкі</samp>.<br />Глядзіце [[mw:Manual:Maxlag_parameter|Інструкцыя:Парамэтар maxlag]] дзеля дадатковай інфармацыі.",
+ "apihelp-main-param-smaxage": "Выстаўце HTTP-загаловак кантролю кэшу <code>s-maxage</code> на зададзеную колькасьць сэкундаў. Памылкі ніколі не кэшуюцца.",
+ "apihelp-main-param-maxage": "Выстаўляе HTTP-загаловак кантролю кэшу <code>max-age</code> на зададзеную колькасьць сэкундаў. Памылкі ніколі не кэшуюцца.",
+ "apihelp-main-param-assert": "Упэўніцеся, што ўдзельнік увайшоў у сыстэму, калі зададзена <kbd>user</kbd>, або мае правы робата, калі зададзена <kbd>bot</kbd>.",
+ "apihelp-main-param-requestid": "Любое значэньне, пададзенае тут, будзе ўключанае ў адказ. Можа быць выкарыстанае для адрозьненьня запытаў.",
+ "apihelp-main-param-servedby": "Уключае ў вынік назву сэрвэра, які апрацаваў запыт.",
+ "apihelp-main-param-curtimestamp": "Уключае ў вынік пазнаку актуальнага часу.",
+ "apihelp-main-param-origin": "Пры звароце да API з дапамогай міждамэннага AJAX-запыту (CORS), выстаўце парамэтру значэньне зыходнага дамэну. Ён мусіць быць уключаны ў кожны папярэдні запыт і такім чынам мусіць быць часткай URI-запыту (ня цела POST).\n\nДля аўтэнтыфікаваных запытаў ён мусіць супадаць з адной з крыніц у загалоўку <code>Origin</code>, павінна быць зададзена нешта кшталту <kbd>https://en.wikipedia.org</kbd> або <kbd>https://meta.wikimedia.org</kbd>. Калі парамэтар не супадае з загалоўкам <code>Origin</code>, будзе вернуты адказ з кодам памылкі 403. Калі парамэтар супадае з загалоўкам <code>Origin</code> і крыніца знаходзіцца ў белым сьпісе, будуць выстаўленыя загалоўкі <code>Access-Control-Allow-Origin</code> і <code>Access-Control-Allow-Credentials</code>.\n\nДля неаўтэнтыфікаваных запытаў выстаўце значэньне <kbd>*</kbd>. Гэта прывядзе да выстаўленьня загалоўку <code>Access-Control-Allow-Origin</code>, але <code>Access-Control-Allow-Credentials</code> будзе мець значэньне <code>false</code> і ўсе зьвесткі пра карыстальніка будуць абмежаваныя.",
+ "apihelp-main-param-uselang": "Мова для выкарыстаньня ў перакладах паведамленьняў. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> з <kbd>siprop=languages</kbd> вяртае сьпіс кодаў мовы, або трэба вызначыць <kbd>user</kbd>, каб ужываць налады мовы цяперашняга карыстальніка, або вызначыць <kbd>content</kbd>, каб ужываць мову зьместу гэтай вікі.",
+ "apihelp-block-summary": "Блякаваньне ўдзельніка.",
+ "apihelp-block-param-user": "Імя ўдзельніка, IP-адрас або IP-дыяпазон, якія вы хочаце заблякаваць. Ня можа быць ужыты разам з <var>$1userid</var>",
+ "apihelp-block-param-expiry": "Час заканчэньня. Можа быць адносным (напрыклад, <kbd>5 months</kbd> або <kbd>2 weeks</kbd>) ці абсалютным (напрыклад, <kbd>2014-09-18T12:34:56Z</kbd>). Калі выстаўлены на <kbd>infinite</kbd>, <kbd>indefinite</kbd> ці <kbd>never</kbd>, блякаваньне будзе бестэрміновым.",
+ "apihelp-block-param-reason": "Прычына блякаваньня.",
+ "apihelp-block-param-anononly": "Заблякаваць толькі ананімных удзельнікаў (напрыклад, забараніць ананімныя праўкі з гэтага IP-адрасу).",
+ "apihelp-block-param-nocreate": "Забарона стварэньня рахункаў.",
+ "apihelp-block-param-autoblock": "Аўтаматычна блякаваць апошні ўжыты IP-адрас, а таксама ўсе наступныя IP-адрасы, зь якіх будуць спробы ўваходу.",
+ "apihelp-block-param-noemail": "Забараняе ўдзельніку дасылаць лісты электроннай пошты празь вікі (трэба мець права <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Схаваць імя ўдзельніка з журналу блякаваньняў (патрабуе права <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Дазволіць удзельніку рэдагаваць уласную старонку гутарак (залежыць ад <var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Калі ўдзельнік ужо заблякаваны, перапісаць дзейнае блякаваньне.",
+ "apihelp-block-param-watchuser": "Назіраць за старонкай удзельніка або старонкай IP-адрасу, а таксама старонкай гутарак.",
+ "apihelp-block-example-ip-simple": "Заблякаваць IP-адрас <kbd>192.0.2.5</kbd> на тры дні з прычынай <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Заблякаваць удзельніка <kbd>Vandal</kbd> назаўсёды з прычынай <kbd>Vandalism</kbd>, а таксама забараніць стварэньне новых рахункаў і адсылку лістоў электроннай поштай.",
+ "apihelp-clearhasmsg-summary": "Ачышчае сьцяг <code>hasmsg</code> для актуальнага карыстальніка.",
+ "apihelp-clearhasmsg-example-1": "Ачыстка сьцягу <code>hasmsg</code> для актуальнага карыстальніка",
+ "apihelp-compare-summary": "Атрымаць розьніцу паміж 2 старонкамі.",
+ "apihelp-compare-extended-description": "Вы мусіце перадаць нумар вэрсіі, назву або ID старонкі для абодвух «from» і «to».",
+ "apihelp-compare-param-fromtitle": "Першая назва для параўнаньня.",
+ "apihelp-compare-param-fromid": "ID першай старонкі для параўнаньня.",
+ "apihelp-compare-param-fromrev": "Першая вэрсія для параўнаньня.",
+ "apihelp-compare-param-totitle": "Другая назва для параўнаньня.",
+ "apihelp-compare-param-toid": "ID другой старонкі для параўнаньня.",
+ "apihelp-compare-param-torev": "Другая вэрсія для параўнаньня.",
+ "apihelp-compare-example-1": "Паказвае розьніцу паміж вэрсіямі 1 і 2",
+ "apihelp-createaccount-summary": "Стварэньне новага рахунку ўдзельніка.",
+ "apihelp-createaccount-param-name": "Імя ўдзельніка.",
+ "apihelp-createaccount-param-password": "Пароль (ігнаруецца, калі выстаўлена <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Дамэн для вонкавай аўтэнтыфікацыі (неабавязкова).",
+ "apihelp-createaccount-param-token": "Маркер стварэньня рахунку, атрыманы пры першым запыце.",
+ "apihelp-createaccount-param-email": "Адрас электроннай пошты ўдзельніка (неабавязкова).",
+ "apihelp-createaccount-param-realname": "Сапраўднае імя ўдзельніка (неабавязкова).",
+ "apihelp-createaccount-param-mailpassword": "Калі ўсталяванае любое значэньне, выпадковы пароль будзе дасланы карыстальніку на электронную пошту.",
+ "apihelp-createaccount-param-reason": "Неабавязковая прычына стварэньня рахунку, якая будзе запісаная ў журнал.",
+ "apihelp-createaccount-param-language": "Моўны код, які будзе выстаўлены ўдзельніку па змоўчаньні (неабавязкова, па змоўчаньні мова зьместу).",
+ "apihelp-createaccount-example-pass": "Стварэньне ўдзельніка <kbd>testuser</kbd> з паролем <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Стварэньне ўдзельніка <kbd>testmailuser</kbd> і адпраўка выпадковага паролю электроннай поштай.",
+ "apihelp-edit-param-text": "Зьмест старонкі.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Назва кожнай старонкі.",
+ "apihelp-query+transcludedin-param-limit": "Колькі вяртаць.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Дублюе загаловак <code>Accept-Language</code>, адасланы кліентам у структураваным фармаце."
+}
diff --git a/www/wiki/includes/api/i18n/bg.json b/www/wiki/includes/api/i18n/bg.json
new file mode 100644
index 00000000..3839bda6
--- /dev/null
+++ b/www/wiki/includes/api/i18n/bg.json
@@ -0,0 +1,66 @@
+{
+ "@metadata": {
+ "authors": [
+ "Vodnokon4e",
+ "StanProg",
+ "Spas.Z.Spasov"
+ ]
+ },
+ "apihelp-main-param-action": "Кое действие да се извърши.",
+ "apihelp-block-summary": "Блокиране на потребител.",
+ "apihelp-block-param-user": "Потребителско име, IP адрес или диапазон от IP адреси, които искате да блокирате.",
+ "apihelp-block-param-reason": "Причина за блокиране.",
+ "apihelp-block-param-nocreate": "Забрана за създаване на потребителски сметки.",
+ "apihelp-block-param-hidename": "Скрива потребителското име от дневника на блокиранията. (Изисква право <code>hideuser</code>)",
+ "apihelp-createaccount-summary": "Създаване на нова потребителска сметка.",
+ "apihelp-createaccount-param-name": "Потребителско име.",
+ "apihelp-createaccount-param-email": "Адрес на електронна поща на потребителя (незадължително).",
+ "apihelp-createaccount-param-realname": "Истинско име на потребителя (незадължително).",
+ "apihelp-delete-summary": "Изтриване на страница.",
+ "apihelp-edit-summary": "Създаване и редактиране на страници.",
+ "apihelp-edit-param-text": "Съдържание на страница.",
+ "apihelp-edit-param-minor": "Малка промяна.",
+ "apihelp-edit-param-notminor": "Значителна промяна.",
+ "apihelp-edit-param-bot": "Отбелязване на редакцията като бот.",
+ "apihelp-emailuser-summary": "Изпращане на е-писмо до потребител.",
+ "apihelp-emailuser-param-target": "Получател на имейла.",
+ "apihelp-emailuser-param-subject": "Заглавие на тема.",
+ "apihelp-emailuser-param-text": "Съдържание на писмото.",
+ "apihelp-emailuser-param-ccme": "Изпращане на копие от това писмо до мен.",
+ "apihelp-expandtemplates-param-title": "Заглавие на страница.",
+ "apihelp-feedcontributions-param-year": "От година (и по-рано).",
+ "apihelp-feedcontributions-param-month": "От месец (и по-рано).",
+ "apihelp-feedcontributions-param-tagfilter": "Филтриране на приноси, които имат тези етикети.",
+ "apihelp-feedcontributions-param-deletedonly": "Покажи само изтритите приноси.",
+ "apihelp-feedcontributions-param-newonly": "Показване само на редакции за създаване на страници.",
+ "apihelp-feedcontributions-param-hideminor": "Скриване на малки промени.",
+ "apihelp-feedcontributions-param-showsizediff": "Показване на размера на разликите между версиите.",
+ "apihelp-feedrecentchanges-param-hideminor": "Скриване на малки промени.",
+ "apihelp-feedrecentchanges-param-hidebots": "Скриване на промени, направени от ботове.",
+ "apihelp-feedrecentchanges-param-hideanons": "Скриване на промени, направени от анонимни потребители.",
+ "apihelp-feedrecentchanges-param-hideliu": "Скриване на промени, направени от регистрирани потребители.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Скриване на проверени промени.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Скриване на промените, направени от настоящия потребител.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Филтриране по етикети.",
+ "apihelp-feedrecentchanges-example-simple": "Показване на последни промени.",
+ "apihelp-feedrecentchanges-example-30days": "Показване на последните промени в рамките на 30 дни.",
+ "apihelp-login-param-name": "Потребителско име.",
+ "apihelp-login-param-password": "Парола.",
+ "apihelp-login-param-domain": "Домейн (по избор).",
+ "apihelp-move-summary": "Преместване на страница.",
+ "apihelp-move-param-reason": "Причина за преименуването.",
+ "apihelp-move-param-movetalk": "Преименуване на беседата, ако има такава.",
+ "apihelp-move-param-movesubpages": "Преименуване на подстраници, ако е приложимо.",
+ "apihelp-move-param-noredirect": "Не създавай пренасочване.",
+ "apihelp-move-param-ignorewarnings": "Пренебрегване на всякакви предупреждения.",
+ "apihelp-protect-example-protect": "Защита на страница.",
+ "apihelp-query+allusers-param-prefix": "Търсене за всички потребители, които започват с тази стойност.",
+ "apihelp-query+allusers-param-dir": "Посока на сортиране.",
+ "apihelp-query+allusers-param-group": "Включва само потребители от определените групи.",
+ "apihelp-query+allusers-param-excludegroup": "Изключване на потребители от определените групи.",
+ "apihelp-query+allusers-param-prop": "Каква информация да включва:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Добавя информация за текущото блокиране на потребителя.",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Добавя пълният URL-адрес.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Заглавие на всяка страница.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Записи в дневника."
+}
diff --git a/www/wiki/includes/api/i18n/bgn.json b/www/wiki/includes/api/i18n/bgn.json
new file mode 100644
index 00000000..62fa6c83
--- /dev/null
+++ b/www/wiki/includes/api/i18n/bgn.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ibrahim khashrowdi"
+ ]
+ },
+ "apihelp-block-summary": "کار زوروکئ بستین",
+ "apihelp-createaccount-param-name": "کار زورؤکین نام.",
+ "apihelp-login-param-name": "کار زورؤکین نام.",
+ "apihelp-userrights-param-user": "کار زورؤکین نام."
+}
diff --git a/www/wiki/includes/api/i18n/bn.json b/www/wiki/includes/api/i18n/bn.json
new file mode 100644
index 00000000..bfec841c
--- /dev/null
+++ b/www/wiki/includes/api/i18n/bn.json
@@ -0,0 +1,28 @@
+{
+ "@metadata": {
+ "authors": [
+ "Aftabuzzaman",
+ "Bodhisattwa",
+ "আজিজ"
+ ]
+ },
+ "apihelp-main-param-format": "আউটপুটের বিন্যাস",
+ "apihelp-main-param-requestid": "এখানে প্রদত্ত যেকোন মান প্রতিক্রিয়ায় অন্তর্ভুক্ত করা হবে। অনুরোধের পার্থক্য করতে ব্যবহার করা যেতে পারে।",
+ "apihelp-block-summary": "ব্যবহারকারীকে বাধা দিন।",
+ "apihelp-block-param-reason": "বাধার দানের কারণ।",
+ "apihelp-createaccount-summary": "নতুন ব্যবহারকারীর অ্যাকাউন্ট তৈরি করুন",
+ "apihelp-createaccount-param-name": "ব্যবহারকারী নাম।",
+ "apihelp-delete-summary": "একটি পাতা মুছে ফেলুন।",
+ "apihelp-delete-example-simple": "<kbd>প্রধান পাতা</kbd> মুছে ফেলুন।",
+ "apihelp-edit-param-text": "পাতার বিষয়বস্তু।",
+ "apihelp-edit-param-minor": "অনুল্লেখ্য সম্পাদনা।",
+ "apihelp-edit-param-bot": "এই সম্পাদনাটি একটি বট সম্পাদনা হিসাবে চিহ্নিত করে।",
+ "apihelp-edit-param-createonly": "পাতাটি আগেই বিদ্যমান থাকলে সম্পদনা করবেন না।",
+ "apihelp-edit-param-contentmodel": "নতুন বিষয়বস্তুর, বিষয়বস্তু-মডেল।",
+ "apihelp-edit-example-edit": "একটি পাতা সম্পাদনা করুন",
+ "apihelp-edit-example-prepend": "একটি পৃষ্ঠার পূর্বে <kbd>_&#95;NOTOC_&#95;</kbd> লিখুন।",
+ "apihelp-login-example-login": "প্রবেশ",
+ "apihelp-setpagelanguage-param-reason": "পরিবর্তনের কারণ।",
+ "apierror-invaliduserid": "ব্যবহারকারী আইডি <var>$1</var> বৈধ নয়।",
+ "apierror-nosuchuserid": "$1 আইডি যুক্ত কোন ব্যবহারকারী নেই।"
+}
diff --git a/www/wiki/includes/api/i18n/br.json b/www/wiki/includes/api/i18n/br.json
new file mode 100644
index 00000000..079ea43a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/br.json
@@ -0,0 +1,35 @@
+{
+ "@metadata": {
+ "authors": [
+ "Y-M D",
+ "Fulup"
+ ]
+ },
+ "apihelp-block-summary": "Stankañ un implijer",
+ "apihelp-block-param-reason": "Abeg evit stankañ.",
+ "apihelp-createaccount-summary": "Krouiñ ur gont implijer nevez.",
+ "apihelp-createaccount-param-name": "Anv implijer.",
+ "apihelp-delete-summary": "Diverkañ ur bajenn.",
+ "apihelp-edit-summary": "Krouiñ pajennoù ha kemmañ anezho.",
+ "apihelp-edit-param-sectiontitle": "Titl ur rannbennad nevez.",
+ "apihelp-edit-param-text": "Danvez ar bajenn.",
+ "apihelp-edit-param-minor": "Kemmig dister.",
+ "apihelp-edit-example-edit": "Kemmañ ur bajenn.",
+ "apihelp-emailuser-summary": "Kas ur postel d'un implijer.",
+ "apihelp-emailuser-param-text": "Korf ar postel.",
+ "apihelp-expandtemplates-param-title": "Titl ar bajenn.",
+ "apihelp-feedcontributions-param-year": "Adalek ar bloaz (ha koshoc'h)",
+ "apihelp-feedcontributions-param-month": "Adalek ar miz (ha koshoc'h).",
+ "apihelp-feedcontributions-param-hideminor": "Kuzhat ar c'hemmoù dister.",
+ "apihelp-feedrecentchanges-param-hideminor": "Kuzhat ar c'hemmoù dister.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Silañ dre dikedennoù.",
+ "apihelp-feedrecentchanges-example-simple": "Diskouez ar c'hemmoù diwezhañ.",
+ "apihelp-login-param-name": "Anv implijer.",
+ "apihelp-login-param-password": "Ger-tremen.",
+ "apihelp-login-param-domain": "Domani (diret).",
+ "apihelp-login-example-login": "Kevreañ.",
+ "apihelp-move-summary": "Dilec'hiañ ur bajenn.",
+ "apihelp-move-param-noredirect": "Chom hep krouiñ un adkas.",
+ "apihelp-protect-example-protect": "Gwareziñ ur bajenn.",
+ "apihelp-rollback-param-tags": "Tikedennoù da lakaat e talvoud war an distroioù."
+}
diff --git a/www/wiki/includes/api/i18n/bs.json b/www/wiki/includes/api/i18n/bs.json
new file mode 100644
index 00000000..7771f80e
--- /dev/null
+++ b/www/wiki/includes/api/i18n/bs.json
@@ -0,0 +1,17 @@
+{
+ "@metadata": {
+ "authors": [
+ "Palapa",
+ "Semso98"
+ ]
+ },
+ "apihelp-main-param-action": "Koju akciju izvesti.",
+ "apihelp-main-param-format": "Format izlaza.",
+ "apihelp-block-summary": "Blokiraj korisnika",
+ "apihelp-block-param-reason": "Razlog za blokadu",
+ "apihelp-block-example-ip-simple": "Blokiraj IP adresu <kbd>192.0.2.5</kbd> na tri dana sa razlogom <kbd>Prvi napad</kbd>.",
+ "apihelp-compare-param-fromtitle": "Prvi naslov za poređenje.",
+ "apihelp-delete-summary": "Obriši stranicu.",
+ "apihelp-edit-param-text": "Sadržaj stranice.",
+ "apihelp-edit-param-minor": "Mala izmjena."
+}
diff --git a/www/wiki/includes/api/i18n/ca.json b/www/wiki/includes/api/i18n/ca.json
new file mode 100644
index 00000000..bba97338
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ca.json
@@ -0,0 +1,61 @@
+{
+ "@metadata": {
+ "authors": [
+ "Toniher",
+ "Macofe",
+ "Xavier Dengra",
+ "F3RaN",
+ "Eduardo Martinez",
+ "Fitoschido"
+ ]
+ },
+ "apihelp-main-param-action": "Quina acció realitzar.",
+ "apihelp-main-param-format": "El format de la sortida.",
+ "apihelp-main-param-curtimestamp": "Inclou la marca horària actual en el resultat.",
+ "apihelp-block-summary": "Bloca un usuari.",
+ "apihelp-block-param-reason": "Raó del blocatge.",
+ "apihelp-block-param-nocreate": "Evita la creació de comptes.",
+ "apihelp-createaccount-summary": "Creeu un nou compte d'usuari.",
+ "apihelp-createaccount-param-name": "Nom d'usuari.",
+ "apihelp-createaccount-param-password": "Contrasenya (ignorada si es defineix <var>$1mailpassword</var>)",
+ "apihelp-createaccount-param-email": "Adreça electrònica de l'usuari (opcional).",
+ "apihelp-createaccount-param-realname": "Nom real de l'usuari (opcional).",
+ "apihelp-delete-summary": "Suprimeix una pàgina.",
+ "apihelp-disabled-summary": "Aquest mòdul ha estat desactivat.",
+ "apihelp-edit-summary": "Crea i edita pàgines.",
+ "apihelp-edit-param-text": "Contingut de la pàgina.",
+ "apihelp-edit-param-minor": "Edició menor.",
+ "apihelp-edit-param-createonly": "No editeu aquesta pàgina si ja existeix.",
+ "apihelp-edit-example-edit": "Editeu una pàgina.",
+ "apihelp-emailuser-summary": "Envieu un correu electrònic a un usuari.",
+ "apihelp-emailuser-param-target": "Usuari a qui enviar el correu.",
+ "apihelp-emailuser-param-text": "Cos del correu.",
+ "apihelp-emailuser-param-ccme": "Envia'm una còpia d'aquest correu electrònic.",
+ "apihelp-expandtemplates-param-title": "Títol de la pàgina.",
+ "apihelp-feedcontributions-param-deletedonly": "Mostra només les contribucions esborrades.",
+ "apihelp-feedrecentchanges-param-hideminor": "Amaga les edicions menors.",
+ "apihelp-feedrecentchanges-param-hidebots": "Amaga les edicions de bots.",
+ "apihelp-feedrecentchanges-param-hideanons": "Amaga les edicions anònimes.",
+ "apihelp-feedrecentchanges-param-hideliu": "Amaga les edicions d'usuaris registrats.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Amaga les edicions patrullades.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Amaga les meves edicions.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtra segons etiqueta.",
+ "apihelp-feedrecentchanges-param-target": "Mostra només els canvis de les pàgines enllaçades a aquesta pàgina.",
+ "apihelp-feedrecentchanges-example-simple": "Mostra els canvis recents.",
+ "apihelp-help-summary": "Mostra l’ajuda dels mòduls especificats.",
+ "apihelp-help-example-recursive": "Tota l'ajuda en una sola pàgina.",
+ "apihelp-import-param-rootpage": "Importa com a subpàgina d'aquesta pàgina.",
+ "apihelp-login-param-name": "Nom d'usuari.",
+ "apihelp-login-param-password": "Contrasenya.",
+ "apihelp-login-example-login": "Inicia sessió.",
+ "apihelp-options-example-reset": "Reinicialitza totes les preferències.",
+ "apihelp-protect-param-cascade": "Activa la protecció en cascada (és a dir, protegeix les plantilles i imatges utilitzades en aquesta pàgina). S'ignora si cap dels nivells de protecció suporta la protecció en cascada.",
+ "apihelp-query+pageswithprop-example-generator": "Obtenir informació addicional sobre les 10 primeres pàgines utilitzant <code>__NOTOC__</code>.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Afegeix el títol de la pàgina.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Afegeix l'usuari que ha fet l'edició.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Afegeix l'IDentificador de l'usuari que ha fet l'edició.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Afegeix comentari de l'edició.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Afegeix timestamp de l'edició.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Etiqueta les modificacions que són vigilades.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Afegeix informació de registre, si s'escau."
+}
diff --git a/www/wiki/includes/api/i18n/ce.json b/www/wiki/includes/api/i18n/ce.json
new file mode 100644
index 00000000..dc6ee3ce
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ce.json
@@ -0,0 +1,29 @@
+{
+ "@metadata": {
+ "authors": [
+ "Умар"
+ ]
+ },
+ "apihelp-main-param-action": "Кхочушдан дезарг.",
+ "apihelp-main-param-format": "Гойту формат.",
+ "apihelp-main-param-curtimestamp": "Хилламийн юкъатоха ханна йолу билгало",
+ "apihelp-createaccount-param-name": "Декъашхочун цӀе.",
+ "apihelp-delete-summary": "ДӀаяккха агӀо.",
+ "apihelp-edit-example-edit": "АгӀо таян",
+ "apihelp-emailuser-summary": "Декъашхочунга кехат",
+ "apihelp-emailuser-param-target": "Электронан кехатан адрес.",
+ "apihelp-emailuser-param-subject": "Хьедаран корта.",
+ "apihelp-emailuser-param-text": "Кехатан чулацам",
+ "apihelp-expandtemplates-param-title": "АгӀонан корта.",
+ "apihelp-feedrecentchanges-param-hideminor": "Къайладаха жима нисдарш.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Тегийн луьттург.",
+ "apihelp-login-example-login": "ЧугӀо",
+ "apihelp-logout-summary": "ЧугӀой сессийн хаамаш дӀацӀанбе.",
+ "apihelp-move-summary": "АгӀон цӀе хийца.",
+ "apihelp-opensearch-param-search": "Лахаран могӀа.",
+ "apihelp-parse-example-page": "АгӀо зер",
+ "apihelp-parse-example-text": "Wikitext зер.",
+ "apihelp-protect-example-protect": "Ларъе агӀо.",
+ "apihelp-userrights-param-userid": "Декъашхочун ID.",
+ "api-help-datatypes-header": "Хаамийн тайпанаш"
+}
diff --git a/www/wiki/includes/api/i18n/ckb.json b/www/wiki/includes/api/i18n/ckb.json
new file mode 100644
index 00000000..3259f708
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ckb.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Pirehelokan",
+ "Sarchia"
+ ]
+ },
+ "apihelp-parse-param-disabletoc": "پێرستی ناوەرۆک پیشان مەدە.",
+ "api-help-param-default": "بنەڕەت: $1"
+}
diff --git a/www/wiki/includes/api/i18n/cs.json b/www/wiki/includes/api/i18n/cs.json
new file mode 100644
index 00000000..fcb4af4a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/cs.json
@@ -0,0 +1,295 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mormegil",
+ "YjM",
+ "Juandev",
+ "Aktron",
+ "Cvanca",
+ "Utar",
+ "Macofe",
+ "Danny B.",
+ "LordMsz",
+ "Dvorapa",
+ "Matěj Suchánek"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Dokumentace]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api E-mailová konference]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Oznámení k API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Chyby a požadavky]\n</div>\n<strong>Stav:</strong> Všechny funkce uvedené na této stránce by měly fungovat, ale API se stále aktivně vyvíjí a může se kdykoli změnit. Upozornění na změny získáte přihlášením se k [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ e-mailové konferenci mediawiki-api-announce].\n\n<strong>Chybné požadavky:</strong> Pokud jsou do API zaslány chybné požadavky, bude vrácena HTTP hlavička s klíčem „MediaWiki-API-Error“ a hodnota této hlavičky a chybový kód budou nastaveny na stejnou hodnotu. Více informací najdete [[mw:Special:MyLanguage/API:Errors_and_warnings|v dokumentaci]].\n\n<strong>Testování:</strong> Pro jednoduché testování požadavků na API zkuste [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Která akce se má provést.",
+ "apihelp-main-param-format": "Formát výstupu.",
+ "apihelp-main-param-maxlag": "Maximální zpoždění lze použít, když je MediaWiki nainstalováno na cluster s replikovanou databází. Abyste se vyhnuli zhoršování už tak špatného replikačního zpoždění, můžete tímto parametrem nechat klienta čekat, dokud replikační zpoždění neklesne pod uvedenou hodnotu. V případě příliš vysokého zpoždění se vrátí chybový kód „<samp>maxlag</samp>“ s hlášením typu „<samp>Waiting for $host: $lag seconds lagged</samp>“.<br />Více informací najdete v [[mw:Special:MyLanguage/Manual:Maxlag_parameter|příručce]].",
+ "apihelp-main-param-smaxage": "Nastaví HTTP hlavičku pro řízení kešování <code>s-maxage</code> na uvedený počet sekund. Chyby se nekešují nikdy.",
+ "apihelp-main-param-maxage": "Nastaví HTTP hlavičku pro řízení kešování <code>max-age</code> na uvedený počet sekund. Chyby se nekešují nikdy.",
+ "apihelp-main-param-assert": "Pokud je nastaveno na „<kbd>user</kbd>“, ověří, že je uživatel přihlášen, pokud je nastaveno na „<kbd>bot</kbd>“, ověří, že má oprávnění „bot“.",
+ "apihelp-main-param-requestid": "Libovolná zde uvedená hodnota bude zahrnuta v odpovědi. Lze použít pro rozlišení požadavků.",
+ "apihelp-main-param-servedby": "Zahrnout do odpovědi název hostitele, který požadavek obsloužil.",
+ "apihelp-main-param-curtimestamp": "Zahrnout do odpovědi aktuální časové razítko.",
+ "apihelp-main-param-origin": "Pokud k API přistupujete pomocí mezidoménového AJAXového požadavku (CORS), nastavte tento parametr na doménu původu. Musí být součástí všech předběžných požadavků, takže musí být součástí URI požadavku (nikoli těla POSTu).\n\nU autentizovaných požadavků hodnota musí přesně odpovídat jednomu z původů v hlavičce <code>Origin</code>, takže musí být nastavena na něco jako <kbd>https://en.wikipedia.org</kbd> nebo <kbd>https://meta.wikimedia.org</kbd>. Pokud parametr neodpovídá hlavičce <code>Origin</code>, bude vrácena odpověď 403. Pokud parametr odpovídá hlavičce <code>Origin</code> a tento původ je na bílé listině, budou nastaveny hlavičky <code>Access-Control-Allow-Origin</code> a <code>Access-Control-Allow-Credentials</code>.\n\nU neautentizovaných požadavků uveďte hodnotu <kbd>*</kbd>. To způsobí nastavení hlavičky <code>Access-Control-Allow-Origin</code>, ale hlavička <code>Access-Control-Allow-Credentials</code> bude <code>false</code> a budou omezena všechna data specifická pro uživatele.",
+ "apihelp-main-param-uselang": "Jazyk, který se má použít pro překlad hlášení. Pomocí <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> se <kbd>siprop=languages</kbd> získáte seznam jazykových kódů nebo zadejte „<kbd>user</kbd>“ pro použití předvoleného jazyka aktuálního uživatele či „<kbd>content</kbd>“ pro použití jazyka obsahu této wiki.",
+ "apihelp-block-summary": "Zablokovat uživatele.",
+ "apihelp-block-param-user": "Uživatelské jméno, IP adresa nebo rozsah IP adres, které chcete zablokovat. Nelze použít dohromady s <var>$1userid</var>.",
+ "apihelp-block-param-reason": "Důvod bloku.",
+ "apihelp-block-param-anononly": "Zablokovat pouze anonymní uživatele (tj. zakázat editovat anonymně z této IP).",
+ "apihelp-block-param-nocreate": "Nedovolit registraci nových uživatelů.",
+ "apihelp-block-param-noemail": "Zakázat uživateli posílat e-maily prostřednictvím wiki. (Vyžaduje oprávnění „<code>blockemail</code>“.)",
+ "apihelp-block-param-hidename": "Skrýt uživatelské jméno v knize zablokování. (Vyžaduje oprávnění <code>hideuser</code>.)",
+ "apihelp-block-param-allowusertalk": "Povolit uživateli editovat svou vlastní diskusní stránku (závisí na <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Pokud již uživatel blokován je, přepsat současný blok.",
+ "apihelp-block-param-watchuser": "Sledovat stránku uživatele nebo IP adresy a jejich diskuzní stránky.",
+ "apihelp-block-example-ip-simple": "Na tři dny zablokovat IP adresu <kbd>192.0.2.5</kbd> s odůvodněním <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Trvale zablokovat uživatele <kbd>Vandal</kbd> s odůvodněním <kbd>Vandalism</kbd> a zabránit vytváření nových účtů a odesílání e-mailů.",
+ "apihelp-checktoken-param-type": "Typ testovaného tokenu.",
+ "apihelp-checktoken-param-token": "Token, který se má otestovat.",
+ "apihelp-checktoken-param-maxtokenage": "Nejvyšší povolené stáří tokenu v sekundách.",
+ "apihelp-checktoken-example-simple": "Testuje správnost tokenu <kbd>csrf</kbd>.",
+ "apihelp-compare-summary": "Vrátí rozdíl dvou stránek.",
+ "apihelp-compare-extended-description": "Ve „from“ i „to“ musíte zadat číslo revize, název stránky nebo ID stránky.",
+ "apihelp-compare-param-fromtitle": "Název první stránky k porovnání.",
+ "apihelp-compare-param-fromid": "ID první stránky k porovnání.",
+ "apihelp-compare-param-fromrev": "Číslo revize první stránky k porovnání.",
+ "apihelp-compare-param-totitle": "Název druhé stránky k porovnání.",
+ "apihelp-compare-param-toid": "ID druhé stránky k porovnání.",
+ "apihelp-compare-param-torev": "Číslo revize druhé stránky k porovnání.",
+ "apihelp-compare-example-1": "Porovnat revize 1 a 2.",
+ "apihelp-createaccount-summary": "Vytvořit nový uživatelský účet.",
+ "apihelp-createaccount-param-name": "Uživatelské jméno.",
+ "apihelp-createaccount-param-password": "Heslo (ignorováno, pokud je nastaveno <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Doména pro externí ověření (volitelné).",
+ "apihelp-createaccount-param-email": "E-mailová adresa uživatele (nepovinné).",
+ "apihelp-createaccount-param-realname": "Skutečné jméno uživatele (nepovinné).",
+ "apihelp-createaccount-param-mailpassword": "Pokud je nastaveno na libovolnou hodnotu, zašle se náhodně vygenerované heslo na e-mail uživatele.",
+ "apihelp-createaccount-param-reason": "Případný důvod pro vytvoření účtu, který se zaznamená do logu.",
+ "apihelp-createaccount-param-language": "Kód jazyka, který se má uživateli nastavit jako výchozí (volitelné, výchozí je jazyk obsahu).",
+ "apihelp-createaccount-example-pass": "Vytvořit uživatele <kbd>testuser</kbd> s heslem <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Vytvořit uživatele <kbd>testmailuser</kbd> a zaslat mu e-mail s náhodně vygenerovaným heslem.",
+ "apihelp-delete-summary": "Smazat stránku.",
+ "apihelp-delete-param-title": "Název stránky, která se má smazat. Není možné použít společně s <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID stránky, která se má smazat. Není možné použít společně s <var>$1title</var>.",
+ "apihelp-delete-param-watch": "Přidat stránku na seznam sledovaných.",
+ "apihelp-delete-param-unwatch": "Odstranit stránku ze seznamu sledovaných.",
+ "apihelp-delete-example-simple": "Smazat stránku <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Smazat stránku <kbd>Main Page</kbd> s odůvodněním <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Tento modul byl deaktivován.",
+ "apihelp-edit-summary": "Vytvářet a upravovat stránky.",
+ "apihelp-edit-param-title": "Název stránky, kterou chcete editovat. Nelze použít společně s <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID stránky, která se má editovat. Není možné použít společně s <var>$1title</var>.",
+ "apihelp-edit-param-sectiontitle": "Název nové sekce.",
+ "apihelp-edit-param-text": "Obsah stránky.",
+ "apihelp-edit-param-minor": "Malá editace.",
+ "apihelp-edit-param-notminor": "Nemalá editace.",
+ "apihelp-edit-param-bot": "Označit tuto editaci jako editaci robota.",
+ "apihelp-edit-param-createonly": "Needitovat stránku, pokud již existuje.",
+ "apihelp-edit-param-nocreate": "Pokud stránka neexistuje, vrátit chybu.",
+ "apihelp-edit-param-watch": "Přidat stránku na seznam sledovaných.",
+ "apihelp-edit-param-unwatch": "Odstranit stránku ze seznamu sledovaných.",
+ "apihelp-edit-param-watchlist": "Bezpodmínečně přidat nebo odstranit stránku ze sledovaných stránek aktuálního uživatele, použít nastavení nebo neměnit sledování.",
+ "apihelp-edit-param-redirect": "Automaticky opravit přesměrování.",
+ "apihelp-edit-example-edit": "Upravit stránku.",
+ "apihelp-emailuser-summary": "Poslat uživateli e-mail.",
+ "apihelp-emailuser-param-target": "Uživatel, kterému se má e-mail poslat.",
+ "apihelp-emailuser-param-subject": "Hlavička s předmětem.",
+ "apihelp-emailuser-param-text": "Tělo zprávy.",
+ "apihelp-emailuser-param-ccme": "Odeslat mi kopii této zprávy.",
+ "apihelp-emailuser-example-email": "Poslat e-mail uživateli <kbd>WikiSysop</kbd> s textem <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Rozbalí všechny šablony ve wikitextu.",
+ "apihelp-expandtemplates-param-title": "Název stránky.",
+ "apihelp-expandtemplates-param-text": "Wikitext k převedení.",
+ "apihelp-expandtemplates-param-revid": "ID revize, pro <code><nowiki>{{REVISIONID}}</nowiki></code> a podobné proměnné.",
+ "apihelp-feedcontributions-summary": "Vrátí kanál příspěvků uživatele.",
+ "apihelp-feedcontributions-param-feedformat": "Formát kanálu.",
+ "apihelp-feedcontributions-param-year": "Od roku (a dříve).",
+ "apihelp-feedcontributions-param-month": "Od měsíce (a dříve)",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrovat příspěvky, které mají tyto značky.",
+ "apihelp-feedcontributions-param-deletedonly": "Zobrazit pouze smazané příspěvky.",
+ "apihelp-feedcontributions-param-toponly": "Zobrazit pouze ty editace, které jsou aktuální revize.",
+ "apihelp-feedcontributions-param-newonly": "Zobrazit pouze ty editace, které vytvořily stránku.",
+ "apihelp-feedcontributions-param-hideminor": "Skrýt malé editace.",
+ "apihelp-feedcontributions-param-showsizediff": "Zobrazit rozdíl velikosti mezi revizemi.",
+ "apihelp-feedrecentchanges-param-namespace": "Jmenný prostor, na který mají být výsledky omezeny.",
+ "apihelp-feedrecentchanges-param-from": "Zobrazit změny od",
+ "apihelp-feedrecentchanges-param-hideminor": "Skrýt drobné změny.",
+ "apihelp-feedrecentchanges-param-hidebots": "Skrýt úpravy provedené roboty.",
+ "apihelp-feedrecentchanges-param-hideanons": "Skrýt změny provedené anonymními uživateli.",
+ "apihelp-feedrecentchanges-param-hideliu": "Skrýt změny provedené registrovanými uživateli.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Skrýt prověřené změny.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Skrýt změny aktuálního uživatele.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrovat podle značek.",
+ "apihelp-feedrecentchanges-param-target": "Zobrazit jen změny na stránkách odkazovaných z této stránky.",
+ "apihelp-feedrecentchanges-example-simple": "Zobrazit poslední změny.",
+ "apihelp-feedrecentchanges-example-30days": "Zobrazit poslední změny za 30 dní.",
+ "apihelp-filerevert-summary": "Revertovat soubor na starší verzi.",
+ "apihelp-filerevert-param-filename": "Cílový název souboru, bez prefixu Soubor:",
+ "apihelp-filerevert-param-comment": "Vložit komentář.",
+ "apihelp-help-summary": "Zobrazuje nápovědu k uvedeným modulům.",
+ "apihelp-help-param-modules": "Moduly, pro které se má zobrazit nápověda (hodnoty parametrů <var>action</var> a <var>format</var> anebo <kbd>main</kbd>). Submoduly lze zadávat pomocí <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Zahrnout nápovědu pro podmoduly uvedeného modulu.",
+ "apihelp-help-param-recursivesubmodules": "Zahrnout nápovědu pro podmoduly rekurzivně.",
+ "apihelp-help-param-helpformat": "Formát výstupu nápovědy.",
+ "apihelp-help-param-wrap": "Obalit výstup do standardní struktury API odpovědi.",
+ "apihelp-help-param-toc": "Zahrnout v HTML výstupu tabulku obsahu.",
+ "apihelp-help-example-main": "Nápověda k hlavnímu modulu",
+ "apihelp-help-example-recursive": "Veškerá nápověda na jedné stránce",
+ "apihelp-help-example-help": "Nápověda k samotnému modulu nápovědy",
+ "apihelp-help-example-query": "Nápověda pro dva podmoduly query",
+ "apihelp-imagerotate-summary": "Otočit jeden nebo více obrázků.",
+ "apihelp-imagerotate-example-generator": "Otočit všechny obrázky v <kbd>Category:Flip</kbd> o <kbd>180</kbd> stupňů.",
+ "apihelp-import-param-summary": "Shrnutí do protokolovacího záznamu importu.",
+ "apihelp-import-param-xml": "Nahraný XML soubor.",
+ "apihelp-import-param-namespace": "Importovat do tohoto jmenného prostoru. Nelze používat současně s parametrem <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importovat jako podstránku k této stránce. Nelze používat současně s parametrem <var>$1namespace</var>.",
+ "apihelp-login-param-name": "Uživatelské jméno.",
+ "apihelp-login-param-password": "Heslo.",
+ "apihelp-login-param-domain": "Doména (volitelná)",
+ "apihelp-login-example-login": "Přihlášení",
+ "apihelp-logout-example-logout": "Odhlášení aktuálního uživatele.",
+ "apihelp-move-summary": "Přesunout stránku.",
+ "apihelp-move-param-reason": "Důvod k přejmenování.",
+ "apihelp-move-param-movetalk": "Přejmenovat diskuzní stránku, pokud existuje.",
+ "apihelp-move-param-movesubpages": "Přejmenovat možné podstránky",
+ "apihelp-move-param-noredirect": "Nevytvářet přesměrování.",
+ "apihelp-move-param-watch": "Přidat stránku a přesměrování do sledovaných stránek aktuálního uživatele.",
+ "apihelp-move-param-unwatch": "Odstranit stránku a přesměrování ze sledovaných stránek současného uživatele.",
+ "apihelp-move-param-ignorewarnings": "Ignorovat všechna varování.",
+ "apihelp-opensearch-summary": "Vyhledávání na wiki pomocí protokolu OpenSearch.",
+ "apihelp-opensearch-param-search": "Hledaný řetězec.",
+ "apihelp-opensearch-param-limit": "Maximální počet vrácených výsledků",
+ "apihelp-opensearch-param-namespace": "Jmenné prostory pro vyhledávání.",
+ "apihelp-opensearch-param-suggest": "Pokud je <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> vypnuto, nedělat nic.",
+ "apihelp-opensearch-param-format": "Formát výstupu.",
+ "apihelp-opensearch-example-te": "Najít stránky začínající na „<kbd>Te</kbd>“.",
+ "apihelp-options-param-reset": "Vrátit nastavení na výchozí hodnoty.",
+ "apihelp-options-example-reset": "Vrátit všechna nastavení.",
+ "apihelp-parse-param-summary": "Shrnutí, které se má parsovat.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Přidává název parsovaného wikitextu.",
+ "apihelp-parse-param-preview": "Parsovat v režimu náhledu.",
+ "apihelp-parse-example-page": "Parsovat stránku.",
+ "apihelp-parse-example-text": "Parsovat wikitext.",
+ "apihelp-parse-example-summary": "Parsovat shrnutí.",
+ "apihelp-patrol-example-revid": "Prověřit revizi.",
+ "apihelp-protect-summary": "Změnit úroveň zamčení stránky.",
+ "apihelp-protect-param-reason": "Důvod pro odemčení.",
+ "apihelp-protect-example-protect": "Zamknout stránku.",
+ "apihelp-query+allcategories-param-limit": "Kolik má být zobrazeno kategorií.",
+ "apihelp-query+alldeletedrevisions-summary": "Seznam všech smazaných revizí od konkrétního uživatele nebo v konkrétním jmenném prostoru.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Není možné užít s <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-example-user": "Seznam posledních 50 smazaných editací uživatele <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Seznam prvních 50 smazaných revizí v hlavním jmenném prostoru.",
+ "apihelp-query+allfileusages-summary": "Zobrazit seznam všech použití souboru, včetně neexistujících.",
+ "apihelp-query+allfileusages-example-unique": "Zobrazit seznam unikátních názvů souborů.",
+ "apihelp-query+allimages-param-minsize": "Omezit na obrázky, které mají alespoň tento počet bajtů.",
+ "apihelp-query+allimages-param-maxsize": "Omezit na obrázky, které mají maximálně tento počet bajtů.",
+ "apihelp-query+allimages-param-limit": "Kolik má být celkem zobrazeno obrázků.",
+ "apihelp-query+alllinks-example-generator": "Získat stránky obsahující odkazy.",
+ "apihelp-query+allpages-param-filterredir": "Které stránky uvést na seznam.",
+ "apihelp-query+allpages-param-minsize": "Omezit na stránky s určitým počtem bajtů.",
+ "apihelp-query+allpages-param-prtype": "Omezit jen na zamčené stránky.",
+ "apihelp-query+allpages-example-B": "Zobrazit seznam stránek začínajících na písmeno <kbd>B</kbd>.",
+ "apihelp-query+allredirects-summary": "Seznam všech přesměrování pro jmenný prostor.",
+ "apihelp-query+allredirects-example-unique": "Seznam unikátních cílových stránek.",
+ "apihelp-query+allredirects-example-generator": "Získat stránky obsahující přesměrování.",
+ "apihelp-query+alltransclusions-param-limit": "Kolik položek zobrazit celkem.",
+ "apihelp-query+alltransclusions-example-unique": "Seznam unikátně vložených titulů.",
+ "apihelp-query+allusers-example-Y": "Zobrazit uživatele počínaje písmenem <kbd>Y</kbd>.",
+ "apihelp-query+backlinks-summary": "Najít všechny stránky, které odkazují na danou stránku.",
+ "apihelp-query+backlinks-example-simple": "Zobrazit odkazy na <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-example-simple": "Vypsat zablokování.",
+ "apihelp-query+blocks-example-users": "Seznam bloků uživatelů <kbd>Alice</kbd> a <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Zobrazit všechny kategorie, do kterých je stránka zařazena.",
+ "apihelp-query+categories-param-limit": "Kolik kategorií má být zobrazeno.",
+ "apihelp-query+categorymembers-summary": "Seznam všech stránek v dané kategorii.",
+ "apihelp-query+categorymembers-param-limit": "Maximální počet stránek k zobrazení.",
+ "apihelp-query+categorymembers-example-simple": "Zobrazit prvních 10 stránek v <kbd>Category:Physics</kbd>",
+ "apihelp-query+categorymembers-example-generator": "Získat informace o prvních 10 stránkách v <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Zobrazit seznam registrovaných a počet anonymních přispěvatelů stránky.",
+ "apihelp-query+contributors-param-limit": "Kolik přispěvatelů má být zobrazeno.",
+ "apihelp-query+contributors-example-simple": "Zobrazit přispěvatele stránky <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Nezahrnovat revize od tohoto uživatele.",
+ "apihelp-query+deletedrevs-param-namespace": "Zahrnout pouze stránky z tohoto jmenného prostoru.",
+ "apihelp-query+deletedrevs-param-limit": "Maximální počet revizí k zobrazení.",
+ "apihelp-query+embeddedin-example-simple": "Zobrazit stránky, které obahují <kbd>Template:Stub</kbd>.",
+ "apihelp-query+filearchive-example-simple": "Zobrazit seznam všech smazaných souborů.",
+ "apihelp-query+filerepoinfo-example-simple": "Získat informace o souborových repozitářích.",
+ "apihelp-query+iwbacklinks-param-prefix": "Prefix pro interwiki",
+ "apihelp-query+iwlinks-param-limit": "Počet interwiki odkazů k zobrazení.",
+ "apihelp-query+iwlinks-param-prefix": "Zobrazit pouze interwiki odkazy s tímto prefixem.",
+ "apihelp-query+iwlinks-param-title": "Interwiki odkaz, který se má hledat. Musí se použít spolu s <var>$1prefix</var>.",
+ "apihelp-query+langbacklinks-param-lang": "Jazyk pro jazykový odkaz.",
+ "apihelp-query+langbacklinks-example-simple": "Zobrazit stránky odkazující na [[:fr:Test]]",
+ "apihelp-query+langbacklinks-example-generator": "Získat informace o stránkách odkazujících na [[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "Zobrazit všechny mezijazykové odkazy z daných stránek.",
+ "apihelp-query+langlinks-param-lang": "Zobrazit pouze jazykové odkazy s tímto kódem jazyka.",
+ "apihelp-query+linkshere-example-generator": "Získat informace o stránkách, které odkazují na [[Hlavní Stránka|Hlavní stránku]].",
+ "apihelp-query+recentchanges-param-excludeuser": "Nezobrazovat změny od tohoto uživatele.",
+ "apihelp-query+recentchanges-example-simple": "Seznam posledních změn.",
+ "apihelp-query+redirects-param-limit": "Počet přesměrování, který má být zobrazen.",
+ "apihelp-query+redirects-example-simple": "Zobrazit seznam přesměrování na stránku [[Main Page]].",
+ "apihelp-query+search-example-simple": "Hledat <kbd>meaning</kbd>",
+ "apihelp-query+tags-example-simple": "Získat seznam dostupných tagů.",
+ "apihelp-query+usercontribs-example-user": "Zobrazit příspěvky uživatele <kbd>Příklad</kbd>",
+ "apihelp-query+watchlistraw-summary": "Získat všechny stránky, které jsou aktuálním uživatelem sledovány.",
+ "apihelp-query+watchlistraw-example-simple": "Seznam sledovaných stránek uživatele.",
+ "apihelp-stashedit-param-summary": "Změnit shrnutí.",
+ "apihelp-unblock-param-user": "Uživatel, IP adresa nebo rozsah IP adres k odblokování. Nelze použít dohromady s <var>$1id</var> nebo <var>$1userid</var>.",
+ "apihelp-watch-example-watch": "Sledovat stránku <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-generator": "Zobrazit prvních několik stránek z hlavního jmenného prostoru.",
+ "apihelp-format-example-generic": "Výsledek dotazu vrátit ve formátu $1.",
+ "apihelp-json-summary": "Vypisuje data ve formátu JSON.",
+ "apihelp-json-param-callback": "Pokud je uvedeno, obalí výstup do zadaného volání funkce. Z bezpečnostních důvodů budou omezena všechna data specifická pro uživatele.",
+ "apihelp-json-param-utf8": "Pokud je uvedeno, bude většina ne-ASCII znaků (ale ne všechny) kódována v UTF-8 místo nahrazení hexadecimálními escape sekvencemi. Implicitní chování, pokud není <var>formatversion</var> nastaveno na <kbd>1</kbd>.",
+ "apihelp-jsonfm-summary": "Vypisuje data ve formátu JSON (v čitelné HTML podobě).",
+ "apihelp-none-summary": "Nevypisuje nic.",
+ "apihelp-php-summary": "Vypisuje data v serializačním formátu PHP.",
+ "apihelp-phpfm-summary": "Vypisuje data v serializačním formátu PHP (v čitelné HTML podobě).",
+ "apihelp-rawfm-summary": "Data včetně ladicích prvků vypisuje ve formátu JSON (v čitelné HTML podobě).",
+ "apihelp-xml-summary": "Vypisuje data ve formátu XML.",
+ "apihelp-xml-param-xslt": "Pokud je uvedeno, přidá uvedenou stránku jako stylopis XSL. Hodnotou musí být název stránky ve jmenném prostoru MediaWiki, jejíž název končí na <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Pokud je uvedeno, přidá jmenný prostor XML.",
+ "apihelp-xmlfm-summary": "Vypisuje data ve formátu XML (v čitelné HTML podobě).",
+ "api-format-title": "Odpověď z MediaWiki API",
+ "api-format-prettyprint-header": "Toto je HTML reprezentace formátu $1. HTML se hodí pro ladění, ale pro aplikační použití je nevhodné.\n\nPro změnu výstupního formátu uveďte parametr <var>format</var>. Abyste viděli ne-HTML reprezentaci formátu $1, nastavte <kbd>format=$2</kbd>.\n\nVíce informací najdete v [[mw:Special:MyLanguage/API|úplné dokumentaci]] nebo v [[Special:ApiHelp/main|nápovědě k API]].",
+ "api-format-prettyprint-header-only-html": "Toto je HTML reprezentace určená pro ladění, která není vhodná pro použití v aplikacích.\n\nVíce informací najdete v [[mw:Special:MyLanguage/API|úplné dokumentaci]] nebo [[Special:ApiHelp/main|dokumentaci API]].",
+ "api-help-title": "Nápověda k MediaWiki API",
+ "api-help-lead": "Toto je automaticky generovaná dokumentační stránka k MediaWiki API.\n\nDokumentace a příklady: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Hlavní modul",
+ "api-help-flag-deprecated": "Tento modul je zastaralý.",
+ "api-help-flag-internal": "<strong>Tento modul je interní nebo nestabilní.</strong> Jeho funkčnost se může bez předchozího upozornění změnit.",
+ "api-help-flag-readrights": "Tento modul vyžaduje oprávnění ke čtení.",
+ "api-help-flag-writerights": "Tento modul vyžaduje oprávnění k zápisu.",
+ "api-help-flag-mustbeposted": "Tento modul přijímá pouze požadavky POST.",
+ "api-help-flag-generator": "Tento modul lze využívat jako generátor.",
+ "api-help-source": "Zdroj: $1",
+ "api-help-license": "Licence: [[$1|$2]]",
+ "api-help-license-noname": "Licence: [[$1|Vizte odkaz]]",
+ "api-help-license-unknown": "Licence: <span class=\"apihelp-unknown\">neznámá</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parametr|Parametry}}:",
+ "api-help-param-deprecated": "Zastaralý.",
+ "api-help-param-required": "Tento parametr je povinný.",
+ "api-help-datatypes-header": "Datové typy",
+ "api-help-datatypes": "Vstupem do MediaWiki by mělo být UTF-8 normalizované do NFC. Jiný vstup se MediaWiki může pokusit převést, ale tím se může stát, že některé operace (např. [[Special:ApiHelp/edit|editace]] s kontrolou MD5) selžou.\n\nNěkteré typy parametrů v API potřebují bližší vysvětlení:\n;boolean\n:Booleovské parametry fungují jako zaškrtávací políčka v HTML: pokud je parametr uveden, bez ohledu na hodnotu, je považován za pravdivý. Pro nepravdivou hodnotu parametr zcela vynechte.\n;časová značka\n:Časové značky lze uvádět v několika formátech. Doporučuje se datum a čas podle ISO 8601. Všechny časy jsou v UTC a obsažené časové pásmo je ignorováno.\n:* Datum a čas podle ISO 8601, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (interpunkce a <kbd>Z</kbd> jsou nepovinné)\n:* Datum a čas podle ISO 8601 s (ignorovaným) zlomkem sekundy, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (pomlčky, dvojtečky a <kbd>Z</kbd> jsou nepovinné)\n:* Formát MediaWiki, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Obecný číselný formát, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (nepovinné časové pásmo <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd> nebo <kbd>-<var>##</var></kbd> se ignoruje)\n:* Formát EXIF, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formát podle RFC 2822 (časové pásmo lze vynechat), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formát podle RFC 850 (časové pásmo lze vynechat), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formát podle céčkové funkce ctime, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Sekundy od 1970-01-01T00:00:00Z jako celé číslo o 1–13 číslicích (s výjimkou <kbd>0</kbd>)\n:* Řetězec <kbd>now</kbd>\n;alternativní oddělovač vícenásobných hodnot\n:Parametry, které přijímají několik hodnot, se zpravidla předávají s hodnotami oddělenými svislítkem, např. <kbd>param=hodnota1|hodnota2</kbd> nebo <kbd>param=hodnota1%7Chodnota2</kbd>. Pokud musí hodnota obsahovat svislítko, použijte jako oddělovač znak U+001F (Unit Separator) ''a'' před hodnotu přidejte U+001F, např. <kbd>param=%1Fhodnota1%1Fhodnota2</kbd>.",
+ "api-help-param-type-integer": "Typ: {{PLURAL:$1|1=celé číslo|2=seznam celých čísel}}",
+ "api-help-param-type-boolean": "Typ: boolean ([[Special:ApiHelp/main#main/datatypes|podrobnosti]])",
+ "api-help-param-list": "{{PLURAL:$1|1=Jedna z následujících hodnot|2=Hodnoty (oddělené <kbd>{{!}}</kbd> nebo [[Special:ApiHelp/main#main/datatypes|alternativou]].)}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Musí být prázdné|Může být prázdné nebo $2}}",
+ "api-help-param-limit": "Není dovoleno více než $1.",
+ "api-help-param-limit2": "Není dovoleno více než $1 ($2 pro boty).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=Hodnota nesmí|2=Hodnoty nesmějí}} být nižší než $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Hodnota nesmí|2=Hodnoty nesmějí}} být vyšší než $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Hodnota|2=Hodnoty}} musí ležet mezi $2 a $3.",
+ "api-help-param-upload": "Musí se odeslat POST požadavkem jako načítaný soubor pomocí multipart/form-data.",
+ "api-help-param-multi-separate": "Hodnoty oddělujte pomocí <kbd>|</kbd> nebo [[Special:ApiHelp/main#main/datatypes|alternativou]].",
+ "api-help-param-multi-max": "Maximální počet hodnot je {{PLURAL:$1|$1}} (pro boty {{PLURAL:$2|$2}}).",
+ "api-help-param-default": "Implicitní hodnota: $1",
+ "api-help-param-default-empty": "Implicitní hodnota: <span class=\"apihelp-empty\">(prázdné)</span>",
+ "api-help-param-token": "Token typu „$1“ získaný pomocí [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(bez popisu)</span>",
+ "api-help-examples": "{{PLURAL:$1|Příklad|Příklady}}:",
+ "api-help-permissions": "{{PLURAL:$1|Oprávnění}}:",
+ "api-help-permissions-granted-to": "Uděleno {{PLURAL:$1|skupině|skupinám}}: $2",
+ "api-help-right-apihighlimits": "Používání vyšších limitů v API dotazech (pomalé dotazy: $1, rychlé dotazy: $2). Limity pro pomalé dotazy se vztahují i na vícehodnotové parametry.",
+ "api-help-open-in-apisandbox": "<small>[otevřít v pískovišti]</small>",
+ "apierror-nosuchsection-what": "$2 neobsahuje sekci $1.",
+ "apierror-sectionsnotsupported-what": "$1 nepodporuje sekce.",
+ "apierror-timeout": "Server neodpověděl v očekávaném čase.",
+ "api-credits-header": "Zásluhy",
+ "api-credits": "Vývojáři API:\n* Roan Kattouw (hlavní vývojář září 2007–2009)\n* Viktor Vasiljev\n* Bryan Tong Minh\n* Sam Reed\n* Jurij Astrachan (tvůrce, hlavní vývojář září 2006–září 2007)\n* Brad Jorsch (hlavní vývojář od 2013)\n\nSvé komentáře, návrhy či dotazy posílejte na mediawiki-api@lists.wikimedia.org\nnebo založte chybové hlášení na https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/cv.json b/www/wiki/includes/api/i18n/cv.json
new file mode 100644
index 00000000..88f222f8
--- /dev/null
+++ b/www/wiki/includes/api/i18n/cv.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chuvash2014"
+ ]
+ },
+ "apihelp-login-example-login": "Кĕр"
+}
diff --git a/www/wiki/includes/api/i18n/da.json b/www/wiki/includes/api/i18n/da.json
new file mode 100644
index 00000000..bbb981bb
--- /dev/null
+++ b/www/wiki/includes/api/i18n/da.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sarrus"
+ ]
+ },
+ "apihelp-feedrecentchanges-param-hideminor": "Skjul mindre ændringer."
+}
diff --git a/www/wiki/includes/api/i18n/de.json b/www/wiki/includes/api/i18n/de.json
new file mode 100644
index 00000000..d3273db4
--- /dev/null
+++ b/www/wiki/includes/api/i18n/de.json
@@ -0,0 +1,1089 @@
+{
+ "@metadata": {
+ "authors": [
+ "Florian",
+ "Kghbln",
+ "Metalhead64",
+ "Inkowik",
+ "Umherirrender",
+ "Giftpflanze",
+ "Macofe",
+ "Se4598",
+ "Purodha",
+ "Andreasburmeister",
+ "Anomie",
+ "Duder",
+ "Ljonka",
+ "FriedhelmW",
+ "Predatorix",
+ "Luke081515",
+ "Eddie",
+ "Zenith",
+ "Tacsipacsi"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Dokumentation]]\n* [[mw:Special:MyLanguage/API:FAQ|Häufig gestellte Fragen]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailingliste]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API-Ankündigungen]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Fehlerberichte und Anfragen]\n</div>\n<strong>Status:</strong> Alle auf dieser Seite gezeigten Funktionen sollten funktionieren, allerdings ist die API in aktiver Entwicklung und kann sich zu jeder Zeit ändern. Abonniere die [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ MediaWiki-API-Ankündigungs-Mailingliste], um über Aktualisierungen informiert zu werden.\n\n<strong>Fehlerhafte Anfragen:</strong> Wenn fehlerhafte Anfragen an die API gesendet werden, wird ein HTTP-Header mit dem Schlüssel „MediaWiki-API-Error“ gesendet. Der Wert des Headers und der Fehlercode werden auf den gleichen Wert gesetzt. Für weitere Informationen siehe [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Fehler und Warnungen]].\n\n<strong>Testen:</strong> Zum einfachen Testen von API-Anfragen, siehe [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Auszuführende Aktion.",
+ "apihelp-main-param-format": "Format der Ausgabe.",
+ "apihelp-main-param-maxlag": "maxlag kann verwendet werden, wenn MediaWiki auf einem datenbankreplizierten Cluster installiert ist. Um weitere Replikationsrückstände zu verhindern, lässt dieser Parameter den Client warten, bis der Replikationsrückstand kleiner als der angegebene Wert (in Sekunden) ist. Bei einem größerem Rückstand wird der Fehlercode <samp>maxlag</samp> zurückgegeben mit einer Nachricht wie <samp>Waiting for $host: $lag seconds lagged</samp>.<br />Siehe [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Handbuch: Maxlag parameter]] für weitere Informationen.",
+ "apihelp-main-param-smaxage": "Den <code>s-maxage</code>-HTTP-Cache-Control-Header auf diese Anzahl Sekunden festlegen. Fehler werden niemals gepuffert.",
+ "apihelp-main-param-maxage": "Den <code>max-age</code>-HTTP-Cache-Control-Header auf diese Anzahl Sekunden festlegen. Fehler werden niemals gecacht.",
+ "apihelp-main-param-assert": "Sicherstellen, dass der Benutzer eingeloggt ist, wenn auf <kbd>user</kbd> gesetzt, oder Bot ist, wenn auf <kbd>bot</kbd> gesetzt.",
+ "apihelp-main-param-assertuser": "Überprüft, ob der aktuelle Benutzer der benannte Benutzer ist.",
+ "apihelp-main-param-requestid": "Der angegebene Wert wird mit in die Antwort aufgenommen und kann zur Unterscheidung von Anfragen verwendet werden.",
+ "apihelp-main-param-servedby": "Namen des bearbeitenden Hosts mit zurückgeben.",
+ "apihelp-main-param-curtimestamp": "Aktuellen Zeitstempel mit zurückgeben.",
+ "apihelp-main-param-responselanginfo": "Bezieht die für <var>uselang</var> und <var>errorlang</var> verwendeten Sprachen im Ergebnis mit ein.",
+ "apihelp-main-param-origin": "Beim Zugriff auf die API mit einer Kreuz-Domain-AJAX-Anfrage (CORS) muss dies als entstehende Domäne festgelegt werden. Dies muss in jeder Vorfluganfrage mit eingeschlossen werden und deshalb ein Teil der Anfragen-URI sein (nicht des POST-Körpers).\n\nFür authentifizierte Anfragen muss dies exakt einem der Ursprünge im Header <code>Origin</code> entsprechen, so dass es auf etwas wie <kbd>https://de.wikipedia.org</kbd> oder <kbd>https://meta.wikimedia.org</kbd> festgelegt werden muss. Falls dieser Parameter nicht mit dem Header <code>Origin</code> übereinstimmt, wird eine 403-Antwort zurückgegeben. Falls dieser Parameter mit dem Header <code>Origin</code> übereinstimmt und der Ursprung weißgelistet ist, werden die Header <code>Access-Control-Allow-Origin</code> und <code>Access-Control-Allow-Credentials</code> festgelegt.\n\nGib für nicht authentifizierte Anfragen den Wert <kbd>*</kbd> an. Dies verursacht, dass der Header <code>Access-Control-Allow-Origin</code> festgelegt wird, aber <code>Access-Control-Allow-Credentials</code> wird <code>false</code> sein und alle benutzerspezifischen Daten werden beschränkt.",
+ "apihelp-main-param-uselang": "Zu verwendende Sprache für Nachrichtenübersetzungen. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> mit <kbd>siprop=languages</kbd> gibt eine Liste der Sprachcodes zurück. Gib <kbd>user</kbd> zum Verwenden der aktuellen Benutzerspracheinstellung oder <kbd>content</kbd> an, um die Inhaltssprache des Wikis zu verwenden.",
+ "apihelp-main-param-errorformat": "Zu verwendendes Format zur Ausgabe von Warnungen und Fehlertexten.\n; plaintext: Wikitext mit entfernten HTML-Tags und ersetzten Entitäten.\n; wikitext: Ungeparster Wikitext.\n; html: HTML.\n; raw: Nachrichtenschlüssel und Parameter.\n; none: Keine Textausgabe, nur die Fehlercodes.\n; bc: Vor MediaWiki 1.29 verwendetes Format. <var>errorlang</var> und <var>errorsuselocal</var> werden ignoriert.",
+ "apihelp-main-param-errorsuselocal": "Falls angegeben, verwenden Fehlertexte lokalisierte Nachrichten aus dem {{ns:MediaWiki}}-Namensraum.",
+ "apihelp-block-summary": "Sperrt einen Benutzer.",
+ "apihelp-block-param-user": "Benutzername, IP-Adresse oder IP-Adressbereich, der gesperrt werden soll. Kann nicht zusammen mit <var>$1userid</var> verwendet werden.",
+ "apihelp-block-param-userid": "Die zu sperrende Benutzerkennung. Kann nicht zusammen mit <var>$1user</var> verwendet werden.",
+ "apihelp-block-param-expiry": "Sperrdauer. Kann relativ (z.&nbsp;B. <kbd>5 months</kbd> oder <kbd>2 weeks</kbd>) oder absolut (z.&nbsp;B. <kbd>2014-09-18T12:34:56Z</kbd>) sein. Wenn auf <kbd>infinite</kbd>, <kbd>indefinite</kbd> oder <kbd>never</kbd> gesetzt, ist die Sperre unbegrenzt.",
+ "apihelp-block-param-reason": "Sperrbegründung.",
+ "apihelp-block-param-anononly": "Nur anonyme Benutzer sperren (z.&nbsp;B. anonyme Bearbeitungen für diese IP deaktivieren).",
+ "apihelp-block-param-nocreate": "Benutzerkontenerstellung verhindern.",
+ "apihelp-block-param-autoblock": "Die zuletzt verwendete IP-Adresse automatisch sperren und alle darauffolgenden IP-Adressen, die versuchen sich anzumelden.",
+ "apihelp-block-param-noemail": "Benutzer davon abhalten, E-Mails auf dem Wiki zu versenden (erfordert das <code>blockemail</code>-Recht).",
+ "apihelp-block-param-hidename": "Den Benutzernamen im Sperr-Logbuch verstecken (erfordert das <code>hideuser</code>-Recht).",
+ "apihelp-block-param-allowusertalk": "Dem Benutzer erlauben, seine eigene Diskussionsseite zu bearbeiten (abhängig von <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Falls der Benutzer bereits gesperrt ist, die vorhandene Sperre überschreiben.",
+ "apihelp-block-param-watchuser": "Benutzer- und Diskussionsseiten des Benutzers oder der IP-Adresse beobachten.",
+ "apihelp-block-param-tags": "Auf den Eintrag im Sperr-Logbuch anzuwendende Änderungsmarkierungen.",
+ "apihelp-block-example-ip-simple": "IP <kbd>192.0.2.5</kbd> für drei Tage mit der Begründung „First strike“ (erste Verwarnung) sperren",
+ "apihelp-block-example-user-complex": "Benutzer <kbd>Vandal</kbd> unbeschränkt sperren mit der Begründung „Vandalism“ (Vandalismus), Erstellung neuer Benutzerkonten sowie Versand von E-Mails verhindern.",
+ "apihelp-changeauthenticationdata-summary": "Ändert die Authentifizierungsdaten für den aktuellen Benutzer.",
+ "apihelp-changeauthenticationdata-example-password": "Versucht, das Passwort des aktuellen Benutzers in <kbd>ExamplePassword</kbd> zu ändern.",
+ "apihelp-checktoken-summary": "Überprüft die Gültigkeit eines über <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> erhaltenen Tokens.",
+ "apihelp-checktoken-param-type": "Typ des Tokens, das getestet werden soll.",
+ "apihelp-checktoken-param-token": "Token, das getestet werden soll.",
+ "apihelp-checktoken-param-maxtokenage": "Maximal erlaubtes Alter des Tokens in Sekunden.",
+ "apihelp-checktoken-example-simple": "Überprüft die Gültigkeit des <kbd>csrf</kbd>-Tokens.",
+ "apihelp-clearhasmsg-summary": "Löschen des <code>hasmsg</code>-Flags („hat Nachrichten“-Flag) für den aktuellen Benutzer.",
+ "apihelp-clearhasmsg-example-1": "<code>hasmsg</code>-Flags für den aktuellen Benutzer löschen",
+ "apihelp-clientlogin-example-login": "Startet den Prozess der Anmeldung in dem Wiki als Benutzer <kbd>Example</kbd> mit dem Passwort <kbd>ExamplePassword</kbd>.",
+ "apihelp-compare-summary": "Ruft den Unterschied zwischen zwei Seiten ab.",
+ "apihelp-compare-extended-description": "Du musst eine Versionsnummer, einen Seitentitel oder eine Seitennummer für „from“ als auch „to“ angeben.",
+ "apihelp-compare-param-fromtitle": "Erster zu vergleichender Titel.",
+ "apihelp-compare-param-fromid": "Erste zu vergleichende Seitennummer.",
+ "apihelp-compare-param-fromrev": "Erste zu vergleichende Version.",
+ "apihelp-compare-param-totitle": "Zweiter zu vergleichender Titel.",
+ "apihelp-compare-param-toid": "Zweite zu vergleichende Seitennummer.",
+ "apihelp-compare-param-torev": "Zweite zu vergleichende Version.",
+ "apihelp-compare-paramvalue-prop-title": "Die Seitentitel der Versionen „Von“ und „Nach“.",
+ "apihelp-compare-example-1": "Unterschied zwischen Version 1 und 2 abrufen",
+ "apihelp-createaccount-summary": "Erstellt ein neues Benutzerkonto.",
+ "apihelp-createaccount-param-preservestate": "Falls <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> für <samp>hasprimarypreservedstate</samp> wahr ausgegeben hat, sollten Anfragen, die als <samp>primary-required</samp> markiert wurden, ausgelassen werden. Falls ein nicht-leerer Wert für <samp>preservedusername</samp> zurückgegeben wurde, muss dieser Benutzername für den Parameter <var>username</var> verwendet werden.",
+ "apihelp-createaccount-param-name": "Benutzername.",
+ "apihelp-createaccount-param-password": "Passwort (wird ignoriert, wenn <var>$1mailpassword</var> angegeben ist).",
+ "apihelp-createaccount-param-domain": "Domain für die externe Authentifizierung (optional).",
+ "apihelp-createaccount-param-token": "Der in der ersten Anfrage erhaltene Benutzerkontenerstellungs-Token.",
+ "apihelp-createaccount-param-email": "E-Mail-Adresse des Benutzers (optional).",
+ "apihelp-createaccount-param-realname": "Realname des Benutzers (optional).",
+ "apihelp-createaccount-param-mailpassword": "Wenn ein Wert angegeben wird, wird ein zufälliges Passwort per E-Mail an den Benutzer versandt.",
+ "apihelp-createaccount-param-reason": "Optionale Begründung für die Benutzerkontenerstellung, die in den Logbüchern vermerkt wird.",
+ "apihelp-createaccount-param-language": "Festzulegender standardmäßiger Sprachcode für den Benutzer (optional, Standard ist Inhaltssprache).",
+ "apihelp-createaccount-example-pass": "Benutzer <kbd>testuser</kbd> mit dem Passwort <kbd>test123</kbd> erstellen.",
+ "apihelp-createaccount-example-mail": "Benutzer <kbd>testmailuser</kbd> erstellen und zufällig generiertes Passwort per E-Mail verschicken.",
+ "apihelp-delete-summary": "Löscht eine Seite.",
+ "apihelp-delete-param-title": "Titel der Seite, die gelöscht werden soll. Kann nicht zusammen mit <var>$1pageid</var> verwendet werden.",
+ "apihelp-delete-param-pageid": "Seitennummer der Seite, die gelöscht werden soll. Kann nicht zusammen mit <var>$1title</var> verwendet werden.",
+ "apihelp-delete-param-reason": "Löschbegründung. Falls nicht festgelegt, wird eine automatisch generierte Begründung verwendet.",
+ "apihelp-delete-param-tags": "Ändert die Markierungen, die auf den Eintrag im Lösch-Logbuch anzuwenden sind.",
+ "apihelp-delete-param-watch": "Seite auf die Beobachtungsliste des aktuellen Benutzers setzen.",
+ "apihelp-delete-param-watchlist": "Seite zur Beobachtungsliste des aktuellen Benutzers hinzufügen oder von ihr entfernen, die Standardeinstellungen verwenden oder die Beobachtung nicht ändern.",
+ "apihelp-delete-param-unwatch": "Seite von der Beobachtungsliste entfernen.",
+ "apihelp-delete-param-oldimage": "Name des alten zu löschenden Bildes, wie von [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]] angegeben.",
+ "apihelp-delete-example-simple": "<kbd>Main Page</kbd> löschen.",
+ "apihelp-delete-example-reason": "<kbd>Main Page</kbd> löschen mit der Begründung <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Dieses Modul wurde deaktiviert.",
+ "apihelp-edit-summary": "Erstellen und Bearbeiten von Seiten.",
+ "apihelp-edit-param-title": "Titel der Seite, die bearbeitet werden soll. Kann nicht zusammen mit <var>$1pageid</var> verwendet werden.",
+ "apihelp-edit-param-pageid": "Seitennummer der Seite, die bearbeitet werden soll. Kann nicht zusammen mit <var>$1title</var> verwendet werden.",
+ "apihelp-edit-param-section": "Abschnittsnummer. <kbd>0</kbd> für die Einleitung, <kbd>new</kbd> für einen neuen Abschnitt.",
+ "apihelp-edit-param-sectiontitle": "Die Überschrift für einen neuen Abschnitt.",
+ "apihelp-edit-param-text": "Seiteninhalt.",
+ "apihelp-edit-param-summary": "Bearbeitungszusammenfassung. Auch Abschnittsüberschrift, wenn $1section=new und $1sectiontitle nicht festgelegt ist.",
+ "apihelp-edit-param-tags": "Auf die Version anzuwendende Änderungsmarkierungen.",
+ "apihelp-edit-param-minor": "Kleine Bearbeitung.",
+ "apihelp-edit-param-notminor": "Nicht-kleine Bearbeitung.",
+ "apihelp-edit-param-bot": "Diese Bearbeitung als Bot-Bearbeitung markieren.",
+ "apihelp-edit-param-basetimestamp": "Zeitstempel der Basisversion, wird verwendet zum Aufspüren von Bearbeitungskonflikten. Kann abgerufen werden durch [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Zeitstempel, an dem der Bearbeitungsprozess begonnen wurde. Er wird zum Aufspüren von Bearbeitungskonflikten verwendet. Ein geeigneter Wert kann mithilfe von <var>[[Special:ApiHelp/main|curtimestamp]]</var> beim Beginn des Bearbeitungsprozesses (z.&nbsp;B. beim Laden des Seiteninhalts zum Bearbeiten) abgerufen werden.",
+ "apihelp-edit-param-recreate": "Keinen Fehler zurückgeben, wenn die Seite in der Zwischenzeit gelöscht wurde.",
+ "apihelp-edit-param-createonly": "Seite nicht bearbeiten, falls sie bereits vorhanden ist.",
+ "apihelp-edit-param-nocreate": "Einen Fehler zurückgeben, falls die Seite nicht vorhanden ist.",
+ "apihelp-edit-param-watch": "Seite der Beobachtungsliste hinzufügen.",
+ "apihelp-edit-param-unwatch": "Seite von der Beobachtungsliste entfernen.",
+ "apihelp-edit-param-watchlist": "Die Seite zur Beobachtungsliste des aktuellen Benutzers hinzufügen oder von ihr entfernen, die Standardeinstellungen verwenden oder die Beobachtung nicht ändern.",
+ "apihelp-edit-param-md5": "Der MD5-Hash des Parameters $1text oder der aneinandergehängten Parameter $1prependtext und $1appendtext. Wenn angegeben, wird die Bearbeitung nicht ausgeführt, wenn der Hash nicht korrekt ist.",
+ "apihelp-edit-param-prependtext": "Diesen Text an den Anfang der Seite setzen. Überschreibt $1text.",
+ "apihelp-edit-param-appendtext": "Diesen Text an das Ende der Seite hinzufügen. Überschreibt $1text.\n\nVerwende statt dieses Parameters $1section=new zum Anhängen eines neuen Abschnitts.",
+ "apihelp-edit-param-undo": "Diese Version rückgängig machen. Überschreibt $1text, $1prependtext und $1appendtext.",
+ "apihelp-edit-param-undoafter": "Alle Versionen von $1undo bis zu dieser rückgängig machen. Falls nicht angegeben, nur eine Version rückgängig machen.",
+ "apihelp-edit-param-redirect": "Weiterleitungen automatisch auflösen.",
+ "apihelp-edit-param-contentformat": "Für den Eingabetext verwendetes Inhaltsserialisierungsformat.",
+ "apihelp-edit-param-contentmodel": "Inhaltsmodell des neuen Inhalts.",
+ "apihelp-edit-param-token": "Der Token sollte immer als letzter Parameter gesendet werden, zumindest aber nach dem $1text-Parameter.",
+ "apihelp-edit-example-edit": "Eine Seite bearbeiten",
+ "apihelp-edit-example-prepend": "<kbd>_&#95;NOTOC_&#95;</kbd> bei einer Seite voranstellen",
+ "apihelp-edit-example-undo": "Versionen 13579 bis 13585 mit automatischer Zusammenfassung rückgängig machen",
+ "apihelp-emailuser-summary": "E-Mail an einen Benutzer senden.",
+ "apihelp-emailuser-param-target": "Benutzer, an den die E-Mail gesendet werden soll.",
+ "apihelp-emailuser-param-subject": "Betreffzeile.",
+ "apihelp-emailuser-param-text": "E-Mail-Inhalt.",
+ "apihelp-emailuser-param-ccme": "Eine Kopie dieser E-Mail an mich senden.",
+ "apihelp-emailuser-example-email": "Eine E-Mail an den Benutzer <kbd>WikiSysop</kbd> mit dem Text <kbd>Content</kbd> senden.",
+ "apihelp-expandtemplates-summary": "Alle Vorlagen innerhalb des Wikitextes expandieren.",
+ "apihelp-expandtemplates-param-title": "Titel der Seite.",
+ "apihelp-expandtemplates-param-text": "Zu konvertierender Wikitext.",
+ "apihelp-expandtemplates-param-revid": "Versionsnummer, die für die Anzeige von <code><nowiki>{{REVISIONID}}</nowiki></code> und ähnlichen Variablen verwendet wird.",
+ "apihelp-expandtemplates-param-prop": "Welche Informationen abgerufen werden sollen.\n\nBeachte bitte, dass das Ergebnis Wikitext enthält, aber die Ausgabe in einem veralteten Format ist, falls keine Werte ausgewählt sind.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Der expandierte Wikitext.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Kategorien in der Eingabe vorhanden, die nicht in der Ausgabe des Wikitextes vertreten sind.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Seiteneigenschaften, die durch expandierte magische Wörter im Wikitext definiert sind.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Ob die Ausgabe flüchtig ist und nicht an anderer Stelle auf der Seite wiederverwendet werden sollte.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Die maximale Zeit, nach der der Ergebnis-Cache ungültig wird.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Etwaige Ressourcen-Lader-Module, die Parserfunktionen angefordert haben, werden zur Ausgabe hinzugefügt. Entweder <kbd>jsconfigvars</kbd> oder <kbd>encodedjsconfigvars</kbd> müssen gemeinsam mit <kbd>modules</kbd> angefordert werden.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Gibt die JavaScript-Konfigurationsvariablen speziell für die Seite aus.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Gibt die JavaScript-Konfigurationsvariablen speziell für die Seite als JSON-Zeichenfolge aus.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "Der XML-Parserbaum der Eingabe.",
+ "apihelp-expandtemplates-param-includecomments": "Ob HTML-Kommentare in der Ausgabe eingeschlossen werden sollen.",
+ "apihelp-expandtemplates-param-generatexml": "XML-Parserbaum erzeugen (ersetzt durch $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Den Wikitext <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd> expandieren.",
+ "apihelp-feedcontributions-summary": "Gibt einen Benutzerbeiträge-Feed zurück.",
+ "apihelp-feedcontributions-param-feedformat": "Das Format des Feeds.",
+ "apihelp-feedcontributions-param-user": "Von welchen Benutzern die Beiträge abgerufen werden sollen.",
+ "apihelp-feedcontributions-param-namespace": "Auf welchen Namensraum die Beiträge begrenzt werden sollen.",
+ "apihelp-feedcontributions-param-year": "Von Jahr (und früher).",
+ "apihelp-feedcontributions-param-month": "Von Monat (und früher).",
+ "apihelp-feedcontributions-param-tagfilter": "Beiträge filtern, die diese Markierungen haben.",
+ "apihelp-feedcontributions-param-deletedonly": "Nur gelöschte Beiträge anzeigen.",
+ "apihelp-feedcontributions-param-toponly": "Nur aktuelle Versionen anzeigen.",
+ "apihelp-feedcontributions-param-newonly": "Nur Seitenerstellungen anzeigen.",
+ "apihelp-feedcontributions-param-hideminor": "Blendet Kleinigkeiten aus.",
+ "apihelp-feedcontributions-param-showsizediff": "Zeigt den Größenunterschied zwischen Versionen an.",
+ "apihelp-feedcontributions-example-simple": "Beiträge für die Benutzer <kbd>Beispiel<kbd> zurückgeben",
+ "apihelp-feedrecentchanges-summary": "Gibt einen Letzte-Änderungen-Feed zurück.",
+ "apihelp-feedrecentchanges-param-feedformat": "Das Format des Feeds.",
+ "apihelp-feedrecentchanges-param-namespace": "Namensraum, auf den die Ergebnisse beschränkt werden sollen.",
+ "apihelp-feedrecentchanges-param-invert": "Alle Namensräume außer dem ausgewählten.",
+ "apihelp-feedrecentchanges-param-associated": "Verbundenen Namensraum (Diskussions oder Hauptnamensraum) mit einschließen.",
+ "apihelp-feedrecentchanges-param-days": "Tage, auf die die Ergebnisse beschränkt werden sollen.",
+ "apihelp-feedrecentchanges-param-limit": "Maximale Anzahl zurückzugebender Ergebnisse.",
+ "apihelp-feedrecentchanges-param-from": "Änderungen seit jetzt anzeigen.",
+ "apihelp-feedrecentchanges-param-hideminor": "Kleine Änderungen ausblenden.",
+ "apihelp-feedrecentchanges-param-hidebots": "Änderungen von Bots ausblenden.",
+ "apihelp-feedrecentchanges-param-hideanons": "Änderungen von anonymen Benutzern ausblenden.",
+ "apihelp-feedrecentchanges-param-hideliu": "Änderungen von registrierten Benutzern ausblenden.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Kontrollierte Änderungen ausblenden.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Änderungen des aktuellen Benutzers ausblenden.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Änderungen der Kategorie-Zugehörigkeit verstecken.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Nach Markierung filtern.",
+ "apihelp-feedrecentchanges-param-target": "Nur Änderungen an Seiten anzeigen, die von dieser Seite verlinkt sind.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Zeige Änderungen an Seiten die von der ausgewählten Seite verlinkt sind.",
+ "apihelp-feedrecentchanges-param-categories": "Zeigt nur Änderungen von Seiten in all diesen Kategorien.",
+ "apihelp-feedrecentchanges-param-categories_any": "Zeigt stattdessen nur Änderungen auf Seiten in einer dieser Kategorien.",
+ "apihelp-feedrecentchanges-example-simple": "Letzte Änderungen anzeigen",
+ "apihelp-feedrecentchanges-example-30days": "Letzte Änderungen für 30 Tage anzeigen",
+ "apihelp-feedwatchlist-summary": "Gibt einen Beobachtungslisten-Feed zurück.",
+ "apihelp-feedwatchlist-param-feedformat": "Das Format des Feeds.",
+ "apihelp-feedwatchlist-param-hours": "Seiten auflisten, die innerhalb dieser Anzahl Stunden ab jetzt geändert wurden.",
+ "apihelp-feedwatchlist-param-linktosections": "Verlinke direkt zum veränderten Abschnitt, wenn möglich.",
+ "apihelp-feedwatchlist-example-default": "Den Beobachtungslisten-Feed anzeigen",
+ "apihelp-feedwatchlist-example-all6hrs": "Zeige alle Änderungen an beobachteten Seiten der letzten 6 Stunden.",
+ "apihelp-filerevert-summary": "Eine Datei auf eine alte Version zurücksetzen.",
+ "apihelp-filerevert-param-filename": "Ziel-Datei, ohne das Datei:-Präfix.",
+ "apihelp-filerevert-param-comment": "Hochladekommentar.",
+ "apihelp-filerevert-param-archivename": "Archivname der Version, auf die die Datei zurückgesetzt werden soll.",
+ "apihelp-filerevert-example-revert": "<kbd>Wiki.png</kbd> auf die Version vom <kbd>2011-03-05T15:27:40Z</kbd> zurücksetzen.",
+ "apihelp-help-summary": "Hilfe für die angegebenen Module anzeigen.",
+ "apihelp-help-param-modules": "Module, zu denen eine Hilfe angezeigt werden soll (Werte der Parameter <var>action</var> und <var>format</var> oder <kbd>main</kbd>). Kann Submodule mit einem <kbd>+</kbd> angeben.",
+ "apihelp-help-param-submodules": "Hilfe für Submodule des benannten Moduls einschließen.",
+ "apihelp-help-param-recursivesubmodules": "Hilfe für Submodule rekursiv einschließen.",
+ "apihelp-help-param-helpformat": "Format der Hilfe-Ausgabe.",
+ "apihelp-help-param-wrap": "Die Ausgabe in eine Standard-API-Antwort-Struktur einschließen.",
+ "apihelp-help-param-toc": "Ein Inhaltsverzeichnis in der HTML-Ausgabe einschließen.",
+ "apihelp-help-example-main": "Hilfe für das Hauptmodul",
+ "apihelp-help-example-submodules": "Hilfe für <kbd>action=query</kbd> und all seine Untermodule.",
+ "apihelp-help-example-recursive": "Alle Hilfen in einer Seite",
+ "apihelp-help-example-help": "Hilfe für das Hilfemodul selbst",
+ "apihelp-help-example-query": "Hilfe für zwei Abfrage-Submodule",
+ "apihelp-imagerotate-summary": "Ein oder mehrere Bilder drehen.",
+ "apihelp-imagerotate-param-rotation": "Anzahl der Grad, um die das Bild im Uhrzeigersinn gedreht werden soll.",
+ "apihelp-imagerotate-param-tags": "Auf den Eintrag im Datei-Logbuch anzuwendende Markierungen",
+ "apihelp-imagerotate-example-simple": "<kbd>Datei:Beispiel.png</kbd> um <kbd>90</kbd> Grad drehen.",
+ "apihelp-imagerotate-example-generator": "Alle Bilder in der <kbd>Kategorie:Flip</kbd> um <kbd>180</kbd> Grad drehen.",
+ "apihelp-import-summary": "Importiert eine Seite aus einem anderen Wiki oder von einer XML-Datei.",
+ "apihelp-import-extended-description": "Bitte beachte, dass der HTTP-POST-Vorgang als Dateiupload ausgeführt werden muss (z.B. durch multipart/form-data), um eine Datei über den <var>xml</var>-Parameter zu senden.",
+ "apihelp-import-param-summary": "Importzusammenfassung des Logbucheintrags.",
+ "apihelp-import-param-xml": "Hochgeladene XML-Datei.",
+ "apihelp-import-param-interwikisource": "Für Interwiki-Importe: Wiki, von dem importiert werden soll.",
+ "apihelp-import-param-interwikipage": "Für Interwiki-Importe: zu importierende Seite.",
+ "apihelp-import-param-fullhistory": "Für Interwiki-Importe: importiere die komplette Versionsgeschichte, nicht nur die aktuelle Version.",
+ "apihelp-import-param-templates": "Für Interwiki-Importe: importiere auch alle eingebundenen Vorlagen.",
+ "apihelp-import-param-namespace": "In diesen Namensraum importieren. Kann nicht zusammen mit <var>$1rootpage</var> verwendet werden.",
+ "apihelp-import-param-rootpage": "Als Unterseite dieser Seite importieren. Kann nicht zusammen mit <var>$1namespace</var> verwendet werden.",
+ "apihelp-import-param-tags": "Auf den Eintrag im Import-Logbuch und die Nullversion bei den importierten Seiten anzuwendende Änderungsmarkierungen.",
+ "apihelp-import-example-import": "Importiere [[meta:Help:ParserFunctions]] mit der kompletten Versionsgeschichte in den Namensraum 100.",
+ "apihelp-linkaccount-summary": "Verbindet ein Benutzerkonto von einem Drittanbieter mit dem aktuellen Benutzer.",
+ "apihelp-login-summary": "Anmelden und Authentifizierungs-Cookies beziehen.",
+ "apihelp-login-extended-description": "Diese Aktion sollte nur in Kombination mit [[Special:BotPasswords]] verwendet werden. Die Verwendung für die Anmeldung beim Hauptkonto ist veraltet und kann ohne Warnung fehlschlagen. Um sich sicher beim Hauptkonto anzumelden, verwende <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "Benutzername.",
+ "apihelp-login-param-password": "Passwort.",
+ "apihelp-login-param-domain": "Domain (optional).",
+ "apihelp-login-param-token": "Anmeldetoken, den du in der ersten Anfrage erhalten hast.",
+ "apihelp-login-example-gettoken": "Ruft einen Anmelde-Token ab",
+ "apihelp-login-example-login": "Anmelden",
+ "apihelp-logout-summary": "Abmelden und alle Sitzungsdaten löschen.",
+ "apihelp-logout-example-logout": "Meldet den aktuellen Benutzer ab",
+ "apihelp-managetags-summary": "Ermöglicht Verwaltungsaufgaben zu Änderungsmarkierungen.",
+ "apihelp-managetags-param-operation": "Welcher Vorgang soll ausgeführt werden:\n;create:Ein neues Änderungsschlagwort zum manuellen Gebrauch erstellen.\n;delete:Ein Änderungsschlagwort aus der Datenbank entfernen. Einschließlich dem Entfernen des Schlagworts von allen Überarbeitungen, kürzlichen Änderungseinträgen und Logbuch-Einträgen, in denen es genutzt wird.\n;activate:Ein Änderungsschlagwort aktivieren und damit Benutzern erlauben es manuell anzuwenden.\n;deactive:Ein Änderungsschlagwort deaktivieren und damit die manuelle Verwendung durch Benutzer unterbinden.",
+ "apihelp-managetags-param-tag": "Schlagwort zum Erstellen, Löschen, Aktivieren oder Deaktivieren. Zum Erstellen darf das Schlagwort noch nicht vorhanden sein. Zur Löschung muss das Schlagwort vorhanden sein. Zur Aktivierung muss das Schlagwort vorhanden sein, darf aber nicht von einer Erweiterung in Gebrauch sein. Zur Deaktivierung muss das Schlagwort gegenwärtig aktiv und manuell definiert sein.",
+ "apihelp-managetags-param-reason": "optionale Begründung für das Erstellen, Löschen, Aktivieren oder Deaktivieren der Markierung.",
+ "apihelp-managetags-param-ignorewarnings": "Warnungen während des Vorgangs ignorieren.",
+ "apihelp-managetags-param-tags": "Auf den Eintrag im Markierungs-Verwaltungs-Logbuch anzuwendende Änderungsmarkierungen.",
+ "apihelp-managetags-example-create": "Erstellt eine Markierung namens <kbd>spam</kbd> mit der Begründung <kbd>For use in edit patrolling</kbd> (für die Eingangskontrolle).",
+ "apihelp-managetags-example-delete": "Löscht die <kbd>vandlaism</kbd>-Markierung mit der Begründung <kbd>Misspelt</kbd>.",
+ "apihelp-managetags-example-activate": "Aktiviert eine Markierung namens <kbd>spam</kbd> mit der Begründung <kbd>For use in edit patrolling</kbd> (für die Eingangskontrolle).",
+ "apihelp-managetags-example-deactivate": "Deaktiviert eine Markierung namens <kbd>spam</kbd> mit der Begründung <kbd>No longer required</kbd> (nicht mehr benötigt).",
+ "apihelp-mergehistory-summary": "Führt Versionsgeschichten von Seiten zusammen.",
+ "apihelp-mergehistory-param-reason": "Grund für die Zusammenführung der Versionsgeschichten",
+ "apihelp-mergehistory-example-merge": "Fügt alle Versionen von <kbd>Oldpage</kbd> der Versionsgeschichte von <kbd>Newpage</kbd> hinzu.",
+ "apihelp-move-summary": "Eine Seite verschieben.",
+ "apihelp-move-param-from": "Titel der zu verschiebenden Seite. Kann nicht zusammen mit <var>$1fromid</var> verwendet werden.",
+ "apihelp-move-param-fromid": "Seitenkennung der zu verschiebenden Seite. Kann nicht zusammen mit <var>$1from</var> verwendet werden.",
+ "apihelp-move-param-to": "Titel, zu dem die Seite umbenannt werden soll.",
+ "apihelp-move-param-reason": "Grund für die Umbenennung.",
+ "apihelp-move-param-movetalk": "Verschiebt die Diskussionsseite, falls vorhanden.",
+ "apihelp-move-param-movesubpages": "Unterseiten verschieben, falls möglich.",
+ "apihelp-move-param-noredirect": "Keine Weiterleitung erstellen.",
+ "apihelp-move-param-watch": "Die Seite und die entstandene Weiterleitung zur Beobachtungsliste hinzufügen.",
+ "apihelp-move-param-unwatch": "Die Seite und die entstandene Weiterleitung von der Beobachtungsliste entfernen.",
+ "apihelp-move-param-watchlist": "Die Seite in jedem Fall zur Beobachtungsliste hinzufügen oder davon entfernen, die Voreinstellungen dafür nutzen oder den Beobachtungsstatus nicht ändern.",
+ "apihelp-move-param-ignorewarnings": "Alle Warnungen ignorieren.",
+ "apihelp-move-param-tags": "Auf den Eintrag im Verschiebungs-Logbuch und die Nullversion der Zielseite anzuwendende Änderungsmarkierungen.",
+ "apihelp-move-example-move": "<kbd>Badtitle</kbd> nach <kbd>Goodtitle</kbd> verschieben, ohne eine Weiterleitung zu erstellen.",
+ "apihelp-opensearch-summary": "Das Wiki mithilfe des OpenSearch-Protokolls durchsuchen.",
+ "apihelp-opensearch-param-search": "Such-Zeichenfolge.",
+ "apihelp-opensearch-param-limit": "Maximale Anzahl zurückzugebender Ergebnisse.",
+ "apihelp-opensearch-param-namespace": "Zu durchsuchende Namensräume.",
+ "apihelp-opensearch-param-suggest": "Nichts unternehmen, falls <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> falsch ist.",
+ "apihelp-opensearch-param-redirects": "Wie mit Weiterleitungen umgegangen werden soll:\n;return:Gibt die Weiterleitung selbst zurück.\n;resolve:Gibt die Zielseite zurück. Kann weniger als $1limit Ergebnisse zurückgeben.\nAus Kompatibilitätsgründen ist für $1format=json die Vorgabe \"return\" und \"resolve\" für alle anderen Formate.",
+ "apihelp-opensearch-param-format": "Das Format der Ausgabe.",
+ "apihelp-opensearch-param-warningsaserror": "Wenn Warnungen mit <kbd>format=json</kbd> auftreten, gib einen API-Fehler zurück, anstatt ihn zu ignorieren.",
+ "apihelp-opensearch-example-te": "Seiten finden, die mit <kbd>Te</kbd> beginnen.",
+ "apihelp-options-summary": "Die Voreinstellungen des gegenwärtigen Benutzers ändern.",
+ "apihelp-options-param-reset": "Setzt die Einstellungen auf Websitestandards zurück.",
+ "apihelp-options-param-resetkinds": "Liste von zurückzusetzenden Optionstypen, wenn die <var>$1reset</var>-Option ausgewählt ist.",
+ "apihelp-options-param-change": "Liste von Änderungen, die mit name=wert formatiert sind (z.&nbsp;B. skin=vector). Falls kein Wert angegeben wurde (ohne einem Gleichheitszeichen), z.&nbsp;B. Optionname|AndereOption|…, wird die Option auf ihren Standardwert zurückgesetzt. Falls ein übergebener Wert ein Trennzeichen enthält (<kbd>|</kbd>), verwende den [[Special:ApiHelp/main#main/datatypes|alternativen Mehrfachwerttrenner]] zur korrekten Bedienung.",
+ "apihelp-options-param-optionvalue": "Der Wert für die Option, die durch <var>$1optionname</var> angegeben ist.",
+ "apihelp-options-example-reset": "Alle Einstellungen zurücksetzen",
+ "apihelp-options-example-change": "Ändert die Einstellungen <kbd>skin</kbd> und <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Setzt alle Einstellungen zurück, dann <kbd>skin</kbd> und <kbd>nickname</kbd> festlegen.",
+ "apihelp-paraminfo-summary": "Ruft Informationen über API-Module ab.",
+ "apihelp-paraminfo-param-modules": "Liste von Modulnamen (Werte der Parameter <var>action</var> und <var>format</var> oder <kbd>main</kbd>). Kann Untermodule mit einem <kbd>+</kbd> oder alle Untermodule mit <kbd>+*</kbd> oder alle Untermodule rekursiv mit <kbd>+**</kbd> bestimmen.",
+ "apihelp-paraminfo-param-helpformat": "Format der Hilfe-Zeichenfolgen.",
+ "apihelp-paraminfo-param-querymodules": "Liste von Abfragemodulnamen (Werte von <var>prop</var>-, <var>meta</var>- oder <var>list</var>-Parameter). Benutze <kbd>$1modules=query+foo</kbd> anstatt <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Auch Informationen über die Hauptmodule (top-level) erhalten. Benutze <kbd>$1modules=main</kbd> stattdessen.",
+ "apihelp-paraminfo-param-formatmodules": "Liste von Formatmodulnamen (Wert des Parameters <var>format</var>). Stattdessen <var>$1modules</var> verwenden.",
+ "apihelp-paraminfo-example-1": "Zeige Info für <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>, und <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-parse-param-title": "Titel der Seite, zu der der Text gehört. Falls ausgelassen, muss <var>$1contentmodel</var> angegeben werden und [[API]] wird als Titel verwendet.",
+ "apihelp-parse-param-text": "Zu parsender Text. <var>$1title</var> oder <var>$1contentmodel</var> verwenden, um das Inhaltsmodell zu steuern.",
+ "apihelp-parse-param-revid": "Versionskennung, für <code><nowiki>{{REVISIONID}}</nowiki></code> und ähnliche Variablen.",
+ "apihelp-parse-param-summary": "Zu parsende Zusammenfassung.",
+ "apihelp-parse-param-page": "Parst den Inhalt dieser Seite. Kann nicht zusammen mit <var>$1text</var> und <var>$1title</var> verwendet werden.",
+ "apihelp-parse-param-pageid": "Parst den Inhalt dieser Seite. Überschreibt <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "Falls <var>$1page</var> oder <var>$1pageid</var> als eine Weiterleitung festgelegt ist, diese auflösen.",
+ "apihelp-parse-param-oldid": "Parst den Inhalt dieser Version. Überschreibt <var>$1page</var> und <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "Welche Informationen bezogen werden sollen:",
+ "apihelp-parse-paramvalue-prop-text": "Gibt den geparsten Text des Wikitextes zurück.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Gibt die Sprachlinks im geparsten Wikitext zurück.",
+ "apihelp-parse-paramvalue-prop-categories": "Gibt die Kategorien im geparsten Wikitext zurück.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Gibt die HTML-Version der Kategorien zurück.",
+ "apihelp-parse-paramvalue-prop-links": "Gibt die internen Links im geparsten Wikitext zurück.",
+ "apihelp-parse-paramvalue-prop-templates": "Gibt die Vorlagen im geparsten Wikitext zurück.",
+ "apihelp-parse-paramvalue-prop-images": "Gibt die Bilder im geparsten Wikitext zurück.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Gibt die externen Links im geparsten Wikitext zurück.",
+ "apihelp-parse-paramvalue-prop-sections": "Gibt die Abschnitte im geparsten Wikitext zurück.",
+ "apihelp-parse-paramvalue-prop-revid": "Ergänzt die Versionskennung der geparsten Seite.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Ergänzt den Titel des geparsten Wikitextes.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Gibt geparsten <code>&lt;head&gt;</code> der Seite zurück.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Gibt die JavaScript-Konfigurationsvariablen speziell für die Seite aus. Zur Anwendung verwende <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Gibt die JavaScript-Konfigurationsvariablen speziell für die Seite als JSON-Zeichenfolge aus.",
+ "apihelp-parse-paramvalue-prop-indicators": "Gibt das HTML der Seitenstatusindikatoren zurück, die auf der Seite verwendet werden.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Gibt Interwiki-Links des geparsten Wikitextes zurück.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Gibt den originalen Wikitext zurück, der geparst wurde.",
+ "apihelp-parse-paramvalue-prop-properties": "Gibt verschiedene Eigenschaften zurück, die im geparsten Wikitext definiert sind.",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Gibt die Warnungen aus, die beim Parsen des Inhalts aufgetreten sind.",
+ "apihelp-parse-param-wrapoutputclass": "Zu verwendende CSS-Klasse, in der die Parserausgabe verpackt werden soll.",
+ "apihelp-parse-param-section": "Parst nur den Inhalt dieser Abschnittsnummer.\n\nFalls <kbd>new</kbd>, parst <var>$1text</var> und <var>$1sectiontitle</var>, als ob ein neuer Abschnitt der Seite hinzugefügt wird.\n\n<kbd>new</kbd> ist nur erlaubt mit der Angabe <var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "Überschrift des neuen Abschnittes, wenn <var>section</var> = <kbd>new</kbd> ist.\n\nAnders als beim Bearbeiten der Seite wird der Parameter nicht durch die <var>summary</var> ersetzt, wenn er weggelassen oder leer ist.",
+ "apihelp-parse-param-disablepp": "Benutze <var>$1disablelimitreport</var> stattdessen.",
+ "apihelp-parse-param-disableeditsection": "Lässt Abschnittsbearbeitungslinks in der Parserausgabe weg.",
+ "apihelp-parse-param-disabletidy": "Wende keine HTML-Säuberung (z.B. Aufräumen) auf die Parser-Ausgabe an.",
+ "apihelp-parse-param-preview": "Im Vorschaumodus parsen.",
+ "apihelp-parse-param-sectionpreview": "Im Abschnitt Vorschau-Modus parsen (aktiviert ebenfalls den Vorschau-Modus)",
+ "apihelp-parse-param-disabletoc": "Inhaltsverzeichnis in der Ausgabe weglassen.",
+ "apihelp-parse-param-useskin": "Wendet die ausgewählte Benutzeroberfläche auf die Parserausgabe an. Kann Auswirkungen auf die folgenden Eigenschaften haben: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>modules</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicators</kbd>.",
+ "apihelp-parse-param-contentmodel": "Inhaltsmodell des eingegebenen Textes. Fall ausgelassen, muss $1title angegeben werden und Standardwert wird das Modell des angegebenen Titels. Ist nur gültig im Zusammenhang mit $1text.",
+ "apihelp-parse-example-page": "Eine Seite parsen.",
+ "apihelp-parse-example-text": "Wikitext parsen.",
+ "apihelp-parse-example-texttitle": "Parst den Wikitext über die Eingabe des Seitentitels.",
+ "apihelp-parse-example-summary": "Parst eine Zusammenfassung.",
+ "apihelp-patrol-summary": "Kontrolliert eine Seite oder Version.",
+ "apihelp-patrol-param-rcid": "Letzte-Änderungen-Kennung, die kontrolliert werden soll.",
+ "apihelp-patrol-param-revid": "Versionskennung, die kontrolliert werden soll.",
+ "apihelp-patrol-param-tags": "Auf den Kontroll-Logbuch-Eintrag anzuwendende Änderungsmarkierungen.",
+ "apihelp-patrol-example-rcid": "Kontrolliert eine kürzlich getätigte Änderung.",
+ "apihelp-patrol-example-revid": "Kontrolliert eine Version",
+ "apihelp-protect-summary": "Ändert den Schutzstatus einer Seite.",
+ "apihelp-protect-param-title": "Titel der Seite, die du (ent-)sperren möchtest. Kann nicht zusammen mit $1pageid verwendet werden.",
+ "apihelp-protect-param-pageid": "Seitenkennung der Seite, die du (ent-)sperren möchtest. Kann nicht zusammen mit $1title verwendet werden.",
+ "apihelp-protect-param-protections": "Listet die Schutzebenen nach dem Format <kbd>Aktion=Ebene</kbd> (z.&nbsp;B. <kbd>edit=sysop</kbd>) auf. Die Ebene <kbd>all</kbd> bedeutet, dass jeder die Aktion ausführen darf, z.&nbsp;B. keine Beschränkung.\n\n<strong>HINWEIS:</strong> Wenn eine Aktion nicht angegeben wird, wird deren Schutz entfernt.",
+ "apihelp-protect-param-expiry": "Zeitstempel des Schutzablaufs. Wenn nur ein Zeitstempel übergeben wird, ist dieser für alle Seitenschutze gültig. Um eine unendliche Schutzdauer festzulegen, kannst du die Werte <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> oder <kbd>never</kbd> übergeben.",
+ "apihelp-protect-param-reason": "Grund für den Seitenschutz oder dessen Aufhebung.",
+ "apihelp-protect-param-tags": "Auf den Seitenschutz-Logbuch-Eintrag anzuwendende Änderungsmarkierungen.",
+ "apihelp-protect-param-cascade": "Aktiviert den Kaskadenschutz (d.&nbsp;h. in dieser Seite eingebundene Vorlagen und Bilder werden geschützt). Wird ignoriert, falls keine der angegebenen Schutzebenen Kaskaden unterstützt.",
+ "apihelp-protect-param-watch": "Wenn vorhanden, fügt dieser Parameter die zu (ent-)sperrende Seite der Beobachtungsliste des aktuellen Benutzers hinzu.",
+ "apihelp-protect-param-watchlist": "Die Seite bedingungslos zur Beobachtungsliste des aktuellen Benutzers hinzufügen oder von ihr entfernen, Einstellungen verwenden oder Beobachtung nicht ändern.",
+ "apihelp-protect-example-protect": "Schützt eine Seite",
+ "apihelp-protect-example-unprotect": "Entsperrt eine Seite, indem die Einschränkungen durch den Schutz auf <kbd>all</kbd> gestellt werden (z.&nbsp;B. darf jeder die Aktion ausführen).",
+ "apihelp-protect-example-unprotect2": "Eine Seite entsperren, indem keine Einschränkungen übergeben werden",
+ "apihelp-purge-summary": "Setzt den Cache der angegebenen Seiten zurück.",
+ "apihelp-purge-param-forcelinkupdate": "Aktualisiert die Linktabellen.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Aktualisiert die Linktabelle der Seite und alle Linktabellen der Seiten, die sie als Vorlage einbinden.",
+ "apihelp-purge-example-simple": "Purgt die <kbd>Main Page</kbd> und die <kbd>API</kbd>-Seite.",
+ "apihelp-purge-example-generator": "Purgt die ersten 10 Seiten des Hauptnamensraums.",
+ "apihelp-query-summary": "Bezieht Daten von und über MediaWiki.",
+ "apihelp-query-extended-description": "Alle Änderungsvorgänge müssen unter Angabe eines Tokens ablaufen, um Missbrauch durch böswillige Anwendungen vorzubeugen.",
+ "apihelp-query-param-prop": "Zurückzugebende Eigenschaften der abgefragten Seiten.",
+ "apihelp-query-param-list": "Welche Listen abgerufen werden sollen.",
+ "apihelp-query-param-meta": "Zurückzugebende Metadaten.",
+ "apihelp-query-param-indexpageids": "Schließt einen zusätzlichen pageids-Abschnitt mit allen zurückgegebenen Seitenkennungen ein.",
+ "apihelp-query-param-export": "Exportiert die aktuellen Versionen der angegebenen oder generierten Seiten.",
+ "apihelp-query-param-exportnowrap": "Gibt den XML-Export zurück, ohne ihn in ein XML-Ergebnis einzuschließen (gleiches Format wie durch [[Special:Export]]). Kann nur zusammen mit $1export genutzt werden.",
+ "apihelp-query-param-iwurl": "Gibt an, ob die komplette URL zurückgegeben werden soll, wenn der Titel ein Interwikilink ist.",
+ "apihelp-query-param-rawcontinue": "Gibt <samp>query-continue</samp>-Rohdaten zur Fortsetzung zurück.",
+ "apihelp-query-example-revisions": "Bezieht [[Special:ApiHelp/query+siteinfo|Seiteninformationen]] und [[Special:ApiHelp/query+revisions|Versionen]] der <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "Bezieht Versionen von Seiten, die mit <kbd>API/</kbd> beginnen.",
+ "apihelp-query+allcategories-summary": "Alle Kategorien aufzählen.",
+ "apihelp-query+allcategories-param-from": "Kategorie, bei der die Auflistung beginnen soll.",
+ "apihelp-query+allcategories-param-to": "Kategorie, bei der die Auflistung enden soll.",
+ "apihelp-query+allcategories-param-prefix": "Listet alle Kategorien auf, die mit dem angegebenen Wert beginnen.",
+ "apihelp-query+allcategories-param-dir": "Sortierrichtung.",
+ "apihelp-query+allcategories-param-min": "Gibt nur Kategorien zurück, die mindestens die angegebene Anzahl an Einträgen haben.",
+ "apihelp-query+allcategories-param-max": "Gibt nur Kategorien zurück, die höchstens die angegebene Anzahl an Einträgen haben.",
+ "apihelp-query+allcategories-param-limit": "Wie viele Kategorien zurückgegeben werden sollen.",
+ "apihelp-query+allcategories-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Ergänzt die Anzahl der Einträge in der Antwort.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Markiert über <code>_&#95;HIDDENCAT_&#95;</code> versteckte Kategorien.",
+ "apihelp-query+allcategories-example-size": "Listet Kategorien mit der Anzahl ihrer Einträge auf.",
+ "apihelp-query+allcategories-example-generator": "Bezieht Informationen über die Kategorieseite selbst für Kategorien, die mit <kbd>List</kbd> beginnen.",
+ "apihelp-query+alldeletedrevisions-summary": "Bezieht alle gelöschten Versionen eines Benutzers oder eines Namensraumes.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Darf nur mit <var>$3user</var> verwendet werden.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Kann nicht zusammen mit <var>$3user</var> benutzt werden.",
+ "apihelp-query+alldeletedrevisions-param-start": "Der Zeitstempel, bei dem die Auflistung beginnen soll.",
+ "apihelp-query+alldeletedrevisions-param-end": "Der Zeitstempel, bei dem die Auflistung enden soll.",
+ "apihelp-query+alldeletedrevisions-param-from": "Seitentitel, bei dem die Auflistung beginnen soll.",
+ "apihelp-query+alldeletedrevisions-param-to": "Seitentitel, bei dem die Auflistung enden soll.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Listet alle Seitentitel auf, die mit dem angegebenen Wert beginnen.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Listet nur Versionen auf, die die angegebene Markierung haben.",
+ "apihelp-query+alldeletedrevisions-param-user": "Nur Versionen von diesem Benutzer auflisten.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Schließt Bearbeitungen des angegebenen Benutzers aus.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Nur Seiten in diesem Namensraum auflisten.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Wenn als Generator verwendet, werden eher Titel als Bearbeitungs-IDs erzeugt.",
+ "apihelp-query+alldeletedrevisions-example-user": "Liste die letzten 50 gelöschten Beiträge, sortiert nach Benutzer <kbd>Beispiel</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Liste die ersten 50 gelöschten Bearbeitungen im Hauptnamensraum.",
+ "apihelp-query+allfileusages-summary": "Liste alle Dateiverwendungen, einschließlich nicht-vorhandener.",
+ "apihelp-query+allfileusages-param-from": "Titel der Datei, bei der die Aufzählung beginnen soll.",
+ "apihelp-query+allfileusages-param-to": "Titel der Datei, bei der die Aufzählung enden soll.",
+ "apihelp-query+allfileusages-param-prefix": "Sucht nach allen Dateititeln, die mit diesem Wert beginnen.",
+ "apihelp-query+allfileusages-param-prop": "Informationsteile zum Einbinden:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Fügt die Seiten-IDs der benutzenden Seiten hinzu (kann nicht mit $1unique verwendet werden).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Ergänzt den Titel der Datei.",
+ "apihelp-query+allfileusages-param-limit": "Wie viele Gesamtobjekte zurückgegeben werden sollen.",
+ "apihelp-query+allfileusages-param-dir": "Aufzählungsrichtung.",
+ "apihelp-query+allfileusages-example-B": "Liste Dateititel, einschließlich fehlender, mit den Seiten-IDs von denen sie stammen, beginne bei <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Einheitliche Dateititel auflisten",
+ "apihelp-query+allfileusages-example-unique-generator": "Ruft alle Dateititel ab und markiert die fehlenden.",
+ "apihelp-query+allfileusages-example-generator": "Seiten abrufen, die die Dateien enthalten",
+ "apihelp-query+allimages-summary": "Alle Bilder nacheinander auflisten.",
+ "apihelp-query+allimages-param-sort": "Eigenschaft, nach der sortiert werden soll.",
+ "apihelp-query+allimages-param-dir": "Aufzählungsrichtung.",
+ "apihelp-query+allimages-param-from": "Der Bildtitel bei dem die Auflistung beginnen soll. Darf nur mit $1sort=Name verwendet werden.",
+ "apihelp-query+allimages-param-to": "Der Bildtitel bei dem die Auflistung anhalten soll. Dard nur mit $1sort=Name verwendet werden.",
+ "apihelp-query+allimages-param-start": "Der Zeitstempel bei dem die Auflistung beginnen soll. Darf nur mit $1sort=Zeitstempel verwendet werden.",
+ "apihelp-query+allimages-param-end": "Der Zeitstempel bei dem die Auflistung anhalten soll. Darf nur mit $1sort=Zeitstempel verwendet werden.",
+ "apihelp-query+allimages-param-prefix": "Suche nach allen Bilder die mit diesem Wert beginnen. Darf nur mit $1sort=Name verwendet werden.",
+ "apihelp-query+allimages-param-minsize": "Beschränkt auf Bilder mit mindestens dieser Anzahl an Bytes.",
+ "apihelp-query+allimages-param-maxsize": "Beschränkt auf Bilder mit höchstens dieser Anzahl an Bytes.",
+ "apihelp-query+allimages-param-sha1": "SHA1-Hash des Bildes. Überschreibt $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "SHA1-Hash des Bildes (Basis 36; verwendet in MediaWiki).",
+ "apihelp-query+allimages-param-user": "Gibt nur Dateien zurück, die von diesem Nutzer hochgeladen wurden. Darf nur mit $1sort=Zeitstempel verwendet werden. Darf nicht mit zusammen mit $1filterbots verwendet werden.",
+ "apihelp-query+allimages-param-filterbots": "Wie Dateien, die von Bots hochgeladen wurden, gefiltert werden sollen. Darf nur mit $1sort=Zeitstempel verwendet werden. Darf nicht zusammen mit $1user verwendet werden.",
+ "apihelp-query+allimages-param-mime": "Nach welchem MIME-Typ gesucht werden soll, z.B. <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "Wie viele Gesamtbilder zurückgegeben werden sollen.",
+ "apihelp-query+allimages-example-B": "Zeigt eine Liste der Dateien an, die mit dem Buchstaben <kbd>B</kbd> beginnen.",
+ "apihelp-query+allimages-example-recent": "Zeigt eine Liste von kürzlich hochgeladenen Dateien ähnlich zu [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Zeige eine Liste von Dateien mit den MIME-Typen <kbd>image/png</kbd> oder <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Zeige Informationen über 4 Dateien beginnend mit dem Buchstaben <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Liste alle Verknüpfungen auf, die auf einen bestimmten Namensraum verweisen.",
+ "apihelp-query+alllinks-param-from": "Der Titel der Verknüpfung bei der die Auflistung beginnen soll.",
+ "apihelp-query+alllinks-param-to": "Der Titel der Verknüpfung bei der die Auflistung enden soll.",
+ "apihelp-query+alllinks-param-prefix": "Suche nach allen verknüpften Titeln die mit diesem Wert beginnen.",
+ "apihelp-query+alllinks-param-prop": "Welche Informationsteile einbinden:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Fügt die Seiten-ID der verknüpfenden Seite hinzu (darf nicht zusammen mit <var>$1unique</var> verwendet werden).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Fügt den Titel der Verknüpfung hinzu.",
+ "apihelp-query+alllinks-param-namespace": "Der aufzulistende Namensraum.",
+ "apihelp-query+alllinks-param-limit": "Wie viele Gesamtobjekte zurückgegeben werden sollen.",
+ "apihelp-query+alllinks-param-dir": "Aufzählungsrichtung.",
+ "apihelp-query+alllinks-example-B": "Liste verknüpfte Titel, einschließlich fehlender, mit den Seiten-IDs von denen sie stammen, beginne bei <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Einheitlich verlinkte Titel auflisten",
+ "apihelp-query+alllinks-example-unique-generator": "Ruft alle verknüpften Titel ab und markiert die fehlenden.",
+ "apihelp-query+alllinks-example-generator": "Ruft Seiten ab welche die Verknüpfungen beinhalten.",
+ "apihelp-query+allmessages-summary": "Gibt Nachrichten von dieser Website zurück.",
+ "apihelp-query+allmessages-param-messages": "Welche Nachrichten ausgegeben werden sollen. <kbd>*</kbd> (Vorgabe) bedeutet alle Nachrichten.",
+ "apihelp-query+allmessages-param-prop": "Zurückzugebende Eigenschaften.",
+ "apihelp-query+allmessages-param-enableparser": "Setzen, um den Parser zu aktivieren. Dies wird den Wikitext der Nachricht vorverarbeiten (magische Worte ersetzen, Vorlagen berücksichtigen, usw.).",
+ "apihelp-query+allmessages-param-nocontent": "Wenn gesetzt, füge nicht den Inhalt der Nachricht der Ausgabe hinzu.",
+ "apihelp-query+allmessages-param-includelocal": "Schließt auch lokale Nachrichten ein, zum Beispiel Nachrichten, die nicht in der Software vorhanden sind, aber dafür im {{ns:MediaWiki}}-Namensraum.\nDies listet alle Seiten im {{ns:MediaWiki}}-Namensraum auf, auch solche, die nicht wirklich Nachrichten sind, wie [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Argumente die in der Nachricht ersetzt werden sollen.",
+ "apihelp-query+allmessages-param-filter": "Gebe nur Nachrichten mit Namen, die diese Zeichenfolge enthalten, zurück.",
+ "apihelp-query+allmessages-param-customised": "Gebe nur Nachrichten in diesem Anpassungszustand zurück.",
+ "apihelp-query+allmessages-param-lang": "Gebe Nachrichten in dieser Sprache zurück.",
+ "apihelp-query+allmessages-param-from": "Gebe Nachrichten beginnend mit dieser Nachricht zurück.",
+ "apihelp-query+allmessages-param-to": "Gebe Nachrichten bei dieser Nachricht endend zurück.",
+ "apihelp-query+allmessages-param-title": "Seitenname, der als Kontext verwendet werden soll, wenn eine Nachricht geparst wird (für die $1enableparser-Option).",
+ "apihelp-query+allmessages-param-prefix": "Gebe Nachrichten mit diesem Präfix zurück.",
+ "apihelp-query+allmessages-example-ipb": "Zeige Nachrichten die mit <kbd>ipb-</kbd> beginnen.",
+ "apihelp-query+allmessages-example-de": "Zeige Nachrichten <kbd>august</kbd> und <kbd>mainpage</kbd> auf deutsch.",
+ "apihelp-query+allpages-summary": "Listet alle Seiten in einem Namensraum nacheinander auf.",
+ "apihelp-query+allpages-param-from": "Seitentitel, bei dem die Auflistung beginnen soll.",
+ "apihelp-query+allpages-param-to": "Seitentitel, bei dem die Auflistung enden soll.",
+ "apihelp-query+allpages-param-prefix": "Nach Seitentiteln suchen, die mit diesem Wert beginnen.",
+ "apihelp-query+allpages-param-namespace": "Der zu untersuchende Namensraum.",
+ "apihelp-query+allpages-param-filterredir": "Welche Seiten aufgelistet werden sollen.",
+ "apihelp-query+allpages-param-minsize": "Nur Seiten auflisten, die mindestens diese Größe in Byte haben.",
+ "apihelp-query+allpages-param-maxsize": "Nur Seiten auflisten, die höchstens diese Größe in Byte haben.",
+ "apihelp-query+allpages-param-prtype": "Nur geschützte Seiten auflisten.",
+ "apihelp-query+allpages-param-prlevel": "Seitenschutze nach Schutzstufe filtern (muss zusammen mit $1prtype=parameter angegeben werden).",
+ "apihelp-query+allpages-param-prfiltercascade": "Seitenschutze nach Kaskadierung filtern (wird ignoriert, wenn $1prtype nicht gesetzt ist).",
+ "apihelp-query+allpages-param-limit": "Gesamtanzahl der aufzulistenden Seiten.",
+ "apihelp-query+allpages-param-dir": "Aufzählungsrichtung.",
+ "apihelp-query+allpages-param-filterlanglinks": "Nur Seiten auflisten, die Sprachlinks haben. Beachte, dass von Erweiterungen gesetzte Sprachlinks möglicherweise nicht beachtet werden.",
+ "apihelp-query+allpages-param-prexpiry": "Ablaufzeit des Seitenschutzes, nach dem die Auflistung gefiltert werden soll:\n; indefinite: Nur unbeschränkt geschützte Seiten auflisten.\n; definite: Nur für einen bestimmten Zeitraum geschützte Seiten auflisten.\n; all: geschützte Seiten unabhängig von der Schutzlänge auflisten.",
+ "apihelp-query+allpages-example-B": "Bezieht eine Liste von Seiten, die mit dem Buchstaben <kbd>B</kbd> beginnen.",
+ "apihelp-query+allpages-example-generator": "Gibt Informationen über vier Seiten mit dem Anfangsbuchstaben <kbd>T</kbd> zurück.",
+ "apihelp-query+allpages-example-generator-revisions": "Übermittelt den Inhalt der ersten beiden Seiten, die mit <kbd>Re</kbd> beginnen und keine Weiterleitungen sind.",
+ "apihelp-query+allredirects-summary": "Bezieht alle Weiterleitungen in einem Namensraum.",
+ "apihelp-query+allredirects-param-from": "Titel der Weiterleitung, bei der die Auflistung beginnen soll.",
+ "apihelp-query+allredirects-param-to": "Titel der Weiterleitung, bei der die Auflistung enden soll.",
+ "apihelp-query+allredirects-param-prefix": "Weiterleitungen auflisten, deren Zielseiten mit diesem Wert beginnen.",
+ "apihelp-query+allredirects-param-unique": "Nur Weiterleitungen mit unterschiedlichen Zielseiten anzeigen. Kann nicht zusammen mit $1prop=ids|fragment|interwiki benutzt werden. Bei Nutzung als Generator werden die Zielseiten anstelle der Ursprungsseiten zurückgegeben.",
+ "apihelp-query+allredirects-param-prop": "Zu beziehende Informationen:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Ergänzt die Seitenkennung der Weiterleitungsseite (kann nicht zusammen mit <var>$1unique</var> benutzt werden).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Ergänzt den Titel der Weiterleitung.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Ergänzt das Abschnittsziel der Weiterleitung, falls vorhanden (kann nicht zusammen mit <var>$1unique</var> benutzt werden).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Ergänzt das Interwiki-Präfix der Weiterleitung, falls vorhanden (kann nicht zusammen mit <var>$1unique</var> benutzt werden).",
+ "apihelp-query+allredirects-param-namespace": "Der zu untersuchende Namensraum.",
+ "apihelp-query+allredirects-param-limit": "Gesamtanzahl der aufzulistenden Einträge.",
+ "apihelp-query+allredirects-param-dir": "Aufzählungsrichtung.",
+ "apihelp-query+allredirects-example-B": "Listet Zielseiten, auch fehlende, mit den Seitenkennungen der Weiterleitung auf, beginnend bei <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "Einzigartige Zielseiten auflisten.",
+ "apihelp-query+allredirects-example-unique-generator": "Bezieht alle Zielseiten und markiert die Fehlenden.",
+ "apihelp-query+allredirects-example-generator": "Seiten abrufen, die die Weiterleitungen enthalten",
+ "apihelp-query+allrevisions-summary": "Liste alle Bearbeitungen.",
+ "apihelp-query+allrevisions-param-start": "Der Zeitstempel, bei dem die Auflistung beginnen soll.",
+ "apihelp-query+allrevisions-param-end": "Der Zeitstempel, bei dem die Auflistung enden soll.",
+ "apihelp-query+allrevisions-param-user": "Liste nur Bearbeitungen von diesem Benutzer auf.",
+ "apihelp-query+allrevisions-param-excludeuser": "Schließe Bearbeitungen dieses Benutzers bei der Auflistung aus.",
+ "apihelp-query+allrevisions-param-namespace": "Nur Seiten dieses Namensraums auflisten.",
+ "apihelp-query+allrevisions-param-generatetitles": "Wenn als Generator verwendet, werden eher Titel als Bearbeitungs-IDs erzeugt.",
+ "apihelp-query+allrevisions-example-user": "Liste die letzten 50 Beiträge, sortiert nach Benutzer <kbd>Beispiel</kbd> auf.",
+ "apihelp-query+allrevisions-example-ns-main": "Liste die ersten 50 Bearbeitungen im Hauptnamensraum auf.",
+ "apihelp-query+mystashedfiles-param-prop": "Welche Eigenschaften für die Dateien abgerufen werden sollen.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Ruft die Dateigröße und Bildabmessungen ab.",
+ "apihelp-query+mystashedfiles-param-limit": "Wie viele Dateien zurückgegeben werden sollen.",
+ "apihelp-query+alltransclusions-summary": "Liste alle Transklusionen auf (eingebettete Seiten die &#123;&#123;x&#125;&#125; benutzen), einschließlich nicht vorhandener.",
+ "apihelp-query+alltransclusions-param-from": "Der Titel der Transklusion bei dem die Auflistung beginnen soll.",
+ "apihelp-query+alltransclusions-param-to": "Der Titel der Transklusion bei dem die Auflistung enden soll.",
+ "apihelp-query+alltransclusions-param-prefix": "Suche nach allen transkludierten Titeln die mit diesem Wert beginnen.",
+ "apihelp-query+alltransclusions-param-prop": "Welche Informationsteile einbinden:",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Fügt den Titel der Transklusion hinzu.",
+ "apihelp-query+alltransclusions-param-namespace": "Der aufzulistende Namensraum.",
+ "apihelp-query+alltransclusions-param-limit": "Wie viele Gesamtobjekte zurückgegeben werden sollen.",
+ "apihelp-query+alltransclusions-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+alltransclusions-example-B": "Liste transkludierte Titel, einschließlich fehlender, mit den Seiten-IDs von denen sie stammen, beginne bei <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Einzigartige eingebundene Titel auflisten.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Ruft alle transkludierten Titel ab und markiert die fehlenden.",
+ "apihelp-query+alltransclusions-example-generator": "Ruft Seiten ab welche die Transklusionen beinhalten.",
+ "apihelp-query+allusers-summary": "Auflisten aller registrierten Benutzer.",
+ "apihelp-query+allusers-param-from": "Der Benutzername, bei dem die Auflistung beginnen soll.",
+ "apihelp-query+allusers-param-to": "Der Benutzername, bei dem die Auflistung enden soll.",
+ "apihelp-query+allusers-param-prefix": "Sucht nach allen Benutzern, die mit diesem Wert beginnen.",
+ "apihelp-query+allusers-param-dir": "Sortierrichtung.",
+ "apihelp-query+allusers-param-group": "Nur Benutzer der angegebenen Gruppen einbeziehen.",
+ "apihelp-query+allusers-param-excludegroup": "Benutzer dieser Gruppen ausschließen.",
+ "apihelp-query+allusers-param-prop": "Welche Informationsteile einbinden:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Fügt die Informationen über eine aktuelle Sperre des Benutzer hinzu.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Listet Gruppen auf denen der Benutzer angehört. Dies verwendet mehr Serverressourcen und kann weniger Ergebnisse als die Grenze zurückliefern.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Listet alle Gruppen auf, denen Benutzer automatisch angehört.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Listet die Berechtigungen auf, die der Benutzer hat.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Fügt den Bearbeitungszähler des Benutzers hinzu.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Fügt, falls vorhanden, den Zeitstempel hinzu, wann der Benutzer registriert wurde (kann leer sein).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Fügt die zentralen IDs und den Anhang-Status des Benutzers hinzu.",
+ "apihelp-query+allusers-param-limit": "Wie viele Benutzernamen insgesamt zurückgegeben werden sollen.",
+ "apihelp-query+allusers-param-witheditsonly": "Listet nur Benutzer auf, die Bearbeitungen vorgenommen haben.",
+ "apihelp-query+allusers-param-activeusers": "Listet nur Benutzer auf, die in den letzten $1 {{PLURAL:$1|Tag|Tagen}} aktiv waren.",
+ "apihelp-query+allusers-example-Y": "Benutzer ab <kbd>Y</kbd> auflisten.",
+ "apihelp-query+authmanagerinfo-example-login": "Ruft die Anfragen ab, die beim Beginnen einer Anmeldung verwendet werden können.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Ruft die Anfragen ab, die beim Beginnen einer Anmeldung verwendet werden können, mit zusammengeführten Formularfeldern.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Testet, ob die Authentifizierung für die Aktion <kbd>foo</kbd> ausreichend ist.",
+ "apihelp-query+backlinks-summary": "Alle Seiten finden, die auf die angegebene Seite verlinken.",
+ "apihelp-query+backlinks-param-title": "Zu suchender Titel. Darf nicht zusammen mit <var>$1pageid</var> benutzt werden.",
+ "apihelp-query+backlinks-param-pageid": "Zu suchende Seiten-ID. Darf nicht zusammen mit <var>$1title</var> benutzt werden.",
+ "apihelp-query+backlinks-param-namespace": "Der aufzulistende Namensraum.",
+ "apihelp-query+backlinks-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+backlinks-param-filterredir": "Wie nach Weiterleitungen gefiltert werden soll. Falls auf <kbd>nonredirects</kbd> gesetzt, wenn <var>$1redirect</var> aktiviert ist, wird dies nur auf die zweite Ebene angewandt.",
+ "apihelp-query+backlinks-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen. Falls <var>$1redirect<var> aktiviert ist, wird die Grenze auf jede Ebene einzeln angewandt (was bedeutet, dass bis zu 2 * <var>$1limit</var> Ergebnisse zurückgegeben werden können).",
+ "apihelp-query+backlinks-param-redirect": "Falls die verweisende Seite eine Weiterleitung ist, finde alle Seiten, die auf diese Weiterleitung ebenfalls verweisen. Die maximale Grenze wird halbiert.",
+ "apihelp-query+backlinks-example-simple": "Links auf <kbd>Main page</kbd> anzeigen.",
+ "apihelp-query+backlinks-example-generator": "Hole Informationen über die Seiten, die auf die <kbd>Hauptseite</kbd> verweisen.",
+ "apihelp-query+blocks-summary": "Liste alle gesperrten Benutzer und IP-Adressen auf.",
+ "apihelp-query+blocks-param-start": "Der Zeitstempel, bei dem die Aufzählung beginnen soll.",
+ "apihelp-query+blocks-param-end": "Der Zeitstempel, bei dem die Aufzählung beendet werden soll.",
+ "apihelp-query+blocks-param-ids": "Liste von Sperren-IDs, die aufglistet werden sollen (optional).",
+ "apihelp-query+blocks-param-users": "Liste von Benutzern, nach denen gesucht werden soll (optional).",
+ "apihelp-query+blocks-param-limit": "Die maximale Zahl der aufzulistenden Sperren.",
+ "apihelp-query+blocks-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Fügt die ID der Sperre hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Fügt den Benutzernamen des gesperrten Benutzers hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Fügt die Benutzer-ID des gesperrten Benutzers hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Fügt den Benutzernamen des sperrenden Benutzers hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Fügt die Benutzer-ID des sperrenden Benutzers hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Fügt den Zeitstempel wann die Sperre gesetzt wurde hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Fügt den Zeitstempel wann die Sperre abläuft hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Fügt den angegebenen Grund für die Sperrung hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Fügt den von der Sperrung betroffenen Bereich von IP-Adressen hinzu.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Markiert die Sperre mit (autoblock, anononly, etc.).",
+ "apihelp-query+blocks-param-show": "Zeige nur Elemente, die diese Kriterien erfüllen. Um zum Beispiel unbestimmte Sperren von IP-Adressen zu sehen, setzte <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "Sperren auflisten",
+ "apihelp-query+blocks-example-users": "Listet Sperren der Benutzer <kbd>Alice</kbd> und <kbd>Bob</kbd> auf.",
+ "apihelp-query+categories-summary": "Liste alle Kategorien auf, zu denen die Seiten gehören.",
+ "apihelp-query+categories-param-prop": "Zusätzlich zurückzugebende Eigenschaften jeder Kategorie:",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Fügt einen Zeitstempel wann die Kategorie angelegt wurde hinzu.",
+ "apihelp-query+categories-param-show": "Welche Art von Kategorien gezeigt werden soll.",
+ "apihelp-query+categories-param-limit": "Wie viele Kategorien zurückgegeben werden sollen.",
+ "apihelp-query+categories-param-categories": "Liste nur diese Kategorien auf. Nützlich um zu prüfen, ob eine bestimmte Seite in einer bestimmten Kategorie enthalten ist.",
+ "apihelp-query+categories-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+categories-example-simple": "Rufe eine Liste von Kategorien ab, zu denen die Seite <kbd>Albert Einstein</kbd> gehört.",
+ "apihelp-query+categories-example-generator": "Rufe Informationen über alle Kategorien ab, die in der Seite <kbd>Albert Einstein</kbd> eingetragen sind.",
+ "apihelp-query+categoryinfo-summary": "Gibt Informationen zu den angegebenen Kategorien zurück.",
+ "apihelp-query+categoryinfo-example-simple": "Erhalte Informationen über <kbd>Category:Foo</kbd> und <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Liste alle Seiten in der angegebenen Kategorie auf.",
+ "apihelp-query+categorymembers-param-pageid": "Seitenkennung der Kategorie, die aufgelistet werden soll. Darf nicht zusammen mit <var>$1title</var> verwendet werden.",
+ "apihelp-query+categorymembers-param-prop": "Welche Informationsteile einbinden:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Fügt die Seitenkennung hinzu.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Fügt die Titel- und Namensraum-ID der Seite hinzu.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Fügt den Sortierungsschlüssel (hexadezimale Zeichenkette) hinzu, der verwendet wird, um innerhalb dieser Kategorie zu sortieren.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Fügt das Sortierungsschlüssel-Präfix hinzu, das verwendet wird, um innerhalb dieser Kategorie zu sortieren (für Menschen lesbarer Teil des Sortierungsschlüssels).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Fügt den Typ, als der diese Seite bestimmt wurde, hinzu (<samp>page</samp>, <samp>subcat</samp> oder <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Fügt den Zeitstempel wann die Seite eingebunden wurde hinzu.",
+ "apihelp-query+categorymembers-param-limit": "Die maximale Anzahl der zurückzugebenden Seiten.",
+ "apihelp-query+categorymembers-param-sort": "Eigenschaft, nach der sortiert werden soll.",
+ "apihelp-query+categorymembers-param-dir": "Sortierungsrichtung.",
+ "apihelp-query+categorymembers-param-start": "Zeitstempel bei dem die Auflistung beginnen soll. Darf nur zusammen mit <kbd>$1sort=timestamp</kbd> benutzt werden.",
+ "apihelp-query+categorymembers-param-end": "Zeitstempel bei dem die Auflistung enden soll. Darf nur zusammen mit <kbd>$1sort=timestamp</kbd> benutzt werden.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Sortierungsschlüssel bei dem die Auflistung beginnen soll, wie von <kbd>$1prop=sortkey</kbd> zurückgegeben. Darf nur zusammen mit <kbd>$1sort=sortkey</kbd> verwendet werden.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Suchschlüssel bei dem die Auflistung enden soll, wie von <kbd>$1prop=sortkey</kbd> zurückgegeben. Darf nur zusammen mit <kbd>$1sort=sortkey</kbd> verwendet werden.",
+ "apihelp-query+categorymembers-param-startsortkey": "Stattdessen $1starthexsortkey verwenden.",
+ "apihelp-query+categorymembers-param-endsortkey": "Stattdessen $1endhexsortkey verwenden.",
+ "apihelp-query+categorymembers-example-simple": "Rufe die ersten 10 Seiten von <kbd>Category:Physics</kbd> ab.",
+ "apihelp-query+categorymembers-example-generator": "Rufe die Seiteninformationen zu den ersten 10 Seiten von<kbd>Category:Physics</kbd> ab.",
+ "apihelp-query+contributors-summary": "Rufe die Liste der angemeldeten Bearbeiter und die Zahl anonymer Bearbeiter einer Seite ab.",
+ "apihelp-query+contributors-param-limit": "Wie viele Spender zurückgegeben werden sollen.",
+ "apihelp-query+contributors-example-simple": "Zeige Mitwirkende der Seite <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-param-start": "Der Zeitstempel bei dem die Auflistung beginnen soll. Wird bei der Verarbeitung einer Liste von Bearbeitungs-IDs ignoriert.",
+ "apihelp-query+deletedrevisions-param-end": "Der Zeitstempel bei dem die Auflistung enden soll. Wird bei der Verarbeitung einer List von Bearbeitungs-IDs ignoriert.",
+ "apihelp-query+deletedrevisions-param-tag": "Listet nur Bearbeitungen auf, die die angegebene Markierung haben.",
+ "apihelp-query+deletedrevisions-param-user": "Nur Versionen von diesem Benutzer auflisten.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Schließe Bearbeitungen dieses Benutzers bei der Auflistung aus.",
+ "apihelp-query+deletedrevisions-example-titles": "Listet die gelöschten Bearbeitungen der Seiten <kbd>Main Page</kbd> und <kbd>Talk:Main Page</kbd> samt Inhalt auf.",
+ "apihelp-query+deletedrevisions-example-revids": "Liste Informationen zur gelöschten Bearbeitung <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "Liste gelöschte Bearbeitungen.",
+ "apihelp-query+deletedrevs-extended-description": "Arbeitet in drei Modi:\n# Listet gelöschte Bearbeitungen des angegeben Titels auf, sortiert nach dem Zeitstempel.\n# Listet gelöschte Beiträge des angegebenen Benutzers auf, sortiert nach dem Zeitstempel (keine Titel bestimmt)\n# Listet alle gelöschten Bearbeitungen im angegebenen Namensraum auf, sortiert nach Titel und Zeitstempel (keine Titel bestimmt, $1user nicht gesetzt).\n\nBestimmte Parameter wirken nur bei bestimmten Modi und werden in anderen nicht berücksichtigt.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Modus|Modi}}: $2",
+ "apihelp-query+deletedrevs-param-start": "Der Zeitstempel bei dem die Auflistung beginnen soll.",
+ "apihelp-query+deletedrevs-param-end": "Der Zeitstempel bei dem die Auflistung enden soll.",
+ "apihelp-query+deletedrevs-param-from": "Auflistung bei diesem Titel beginnen.",
+ "apihelp-query+deletedrevs-param-to": "Auflistung bei diesem Titel beenden.",
+ "apihelp-query+deletedrevs-param-prefix": "Suche nach allen Seitentiteln, die mit dem angegebenen Wert beginnen.",
+ "apihelp-query+deletedrevs-param-unique": "Listet nur eine Bearbeitung für jede Seite auf.",
+ "apihelp-query+deletedrevs-param-tag": "Listet nur Bearbeitungen auf, die die angegebene Markierung haben.",
+ "apihelp-query+deletedrevs-param-user": "Liste nur Bearbeitungen von diesem Benutzer auf.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Schließe Bearbeitungen dieses Benutzers bei der Auflistung aus.",
+ "apihelp-query+deletedrevs-param-namespace": "Nur Seiten dieses Namensraums auflisten.",
+ "apihelp-query+deletedrevs-param-limit": "Die maximale Anzahl aufzulistendender Bearbeitungen.",
+ "apihelp-query+deletedrevs-example-mode1": "Liste die letzten gelöschten Bearbeitungen der Seiten <kbd>Main Page</kbd> und <kbd>Talk:Main Page</kbd> samt Inhalt (Modus 1).",
+ "apihelp-query+deletedrevs-example-mode2": "Liste die letzten 50 gelöschten Beiträge von <kbd>Bob</kbd> auf (Modus 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Liste die ersten 50 gelöschten Bearbeitungen im Hauptnamensraum (Modus 3).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Liste die ersten 50 gelöschten Seiten im {{ns:talk}}-Namensraum (Modus 3).",
+ "apihelp-query+disabled-summary": "Dieses Abfrage-Modul wurde deaktiviert.",
+ "apihelp-query+duplicatefiles-summary": "Liste alle Dateien auf die, basierend auf der Prüfsumme, Duplikate der angegebenen Dateien sind.",
+ "apihelp-query+duplicatefiles-param-limit": "Wie viele doppelte Dateien zurückgeben.",
+ "apihelp-query+duplicatefiles-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+duplicatefiles-param-localonly": "Sucht nur nach Dateien im lokalen Repositorium.",
+ "apihelp-query+duplicatefiles-example-simple": "Sucht nach Duplikaten von [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Sucht nach Duplikaten aller Dateien.",
+ "apihelp-query+embeddedin-summary": "Finde alle Seiten, die den angegebenen Titel einbetten (transkludieren).",
+ "apihelp-query+embeddedin-param-title": "Titel nach dem gesucht werden soll. Darf nicht zusammen mit $1pageid verwendet werden.",
+ "apihelp-query+embeddedin-param-pageid": "Seitenkennung nach der gesucht werden soll. Darf nicht zusammen mit $1title verwendet werden.",
+ "apihelp-query+embeddedin-param-namespace": "Der aufzulistende Namensraum.",
+ "apihelp-query+embeddedin-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+embeddedin-param-filterredir": "Wie Weiterleitungen behandelt werden sollen.",
+ "apihelp-query+embeddedin-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen.",
+ "apihelp-query+embeddedin-example-simple": "Zeige Seiten, die <kbd>Template:Stub</kbd> transkludieren.",
+ "apihelp-query+embeddedin-example-generator": "Rufe Informationen über Seiten ab, die <kbd>Template:Stub</kbd> transkludieren.",
+ "apihelp-query+extlinks-summary": "Gebe alle externen URLs (nicht Interwiki) der angegebenen Seiten zurück.",
+ "apihelp-query+extlinks-param-limit": "Wie viele Links zurückgegeben werden sollen.",
+ "apihelp-query+extlinks-param-query": "Suchbegriff ohne Protokoll. Nützlich um zu prüfen, ob eine bestimmte Seite eine bestimmte externe URL enthält.",
+ "apihelp-query+extlinks-example-simple": "Rufe eine Liste erxterner Verweise auf <kbd>Main Page</kbd> ab.",
+ "apihelp-query+exturlusage-summary": "Listet Seiten auf, die die angegebene URL beinhalten.",
+ "apihelp-query+exturlusage-param-prop": "Welche Informationsteile einbinden:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Fügt die ID der Seite hinzu.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Fügt die Titel- und Namensraum-ID der Seite hinzu.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Fügt die URL, die in der Seite verwendet wird, hinzu.",
+ "apihelp-query+exturlusage-param-query": "Suchbegriff ohne Protokoll. Siehe [[Special:LinkSearch]]. Leer lassen, um alle externen Verknüpfungen aufzulisten.",
+ "apihelp-query+exturlusage-param-namespace": "Die aufzulistenden Seiten-Namensräume.",
+ "apihelp-query+exturlusage-param-limit": "Wie viele Seiten zurückgegeben werden sollen.",
+ "apihelp-query+exturlusage-example-simple": "Zeigt Seiten, die auf <kbd>http://www.mediawiki.org</kbd> verlinken.",
+ "apihelp-query+filearchive-summary": "Alle gelöschten Dateien der Reihe nach auflisten.",
+ "apihelp-query+filearchive-param-from": "Der Bildertitel, bei dem die Auflistung beginnen soll.",
+ "apihelp-query+filearchive-param-to": "Der Bildertitel, bei dem die Auflistung enden soll.",
+ "apihelp-query+filearchive-param-prefix": "Nach allen Bildtiteln, die mit diesem Wert beginnen suchen.",
+ "apihelp-query+filearchive-param-limit": "Wie viele Bilder insgesamt zurückgegeben werden sollen.",
+ "apihelp-query+filearchive-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+filearchive-param-sha1": "SHA1-Prüfsumme des Bildes. Überschreibt $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "SHA1-Prüfsumme des Bildes in Base-36 (in MediaWiki verwendet).",
+ "apihelp-query+filearchive-param-prop": "Welche Bildinformationen abgerufen werden sollen:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Ergänzt die SHA-1-Prüfsumme für das Bild.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Fügt einen Zeitstempel für die hochgeladene Version hinzu.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Fügt den Benutzer hinzu, der die Bildversion hochgeladen hat.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Fügt die Größe des Bilde in Bytes sowie die Höhe, Breite und (falls zutreffend) die Seitenzahl hinzu.",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Alias für die Größe.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Fügt die Beschreibung der Bildversion hinzu.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Analysiert die Beschreibung der Version.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Fügt den MIME-Typ des Bildes hinzu.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Ergänzt den Medientyp des Bildes.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Listet die Exif-Metadaten dieser Bildversion auf.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Ergänzt die Bittiefe der Version.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Fügt den Dateinamen der Archivversion für die nicht-neuesten Versionen hinzu.",
+ "apihelp-query+filearchive-example-simple": "Eine Liste aller gelöschten Dateien auflisten",
+ "apihelp-query+filerepoinfo-summary": "Gebe Metainformationen über Bild-Repositorien zurück, die im Wiki eingerichtet sind.",
+ "apihelp-query+filerepoinfo-example-simple": "Ruft Informationen über Dateirepositorien ab.",
+ "apihelp-query+fileusage-summary": "Alle Seiten finden, die die angegebenen Dateien verwenden.",
+ "apihelp-query+fileusage-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "Seitenkennung jeder Seite.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Titel jeder Seite.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Markieren, falls die Seite eine Weiterleitung ist.",
+ "apihelp-query+fileusage-param-namespace": "Nur Seiten dieser Namensräume einbinden.",
+ "apihelp-query+fileusage-param-limit": "Wie viel zurückgegeben werden soll.",
+ "apihelp-query+fileusage-example-simple": "Zeige eine Liste von Seiten, die [[:File:Example.jpg]] verwenden.",
+ "apihelp-query+fileusage-example-generator": "Zeige Informationen über Seiten, die [[:File:Example.jpg]] verwenden.",
+ "apihelp-query+imageinfo-summary": "Gibt Informationen und alle Versionen der Datei zurück.",
+ "apihelp-query+imageinfo-param-prop": "Welche Dateiinformationen abgerufen werden sollen:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Fügt einen Zeitstempel für die hochgeladene Version hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Fügt den Benutzer zu jeder hochgeladenen Dateiversion hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Füge die ID des Benutzers zu jeder hochgeladenen Dateiversion hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Kommentar zu der Version.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Analysiere den Kommentar zu dieser Version.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Ergänzt den kanonischen Titel für die Datei.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Gibt die URL zur Datei- und Beschreibungsseite zurück.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Fügt die Größe der Datei in Bytes und (falls zutreffend) in Höhe, Breite und Seitenzahl hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Alias für die Größe.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Fügt die SHA-1-Prüfsumme für die Datei hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Fügt den MIME-Typ dieser Datei hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Fügt den Medientyp dieser Datei hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Listet die Exif-Metadaten dieser Dateiversion auf.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Listet allgemeine Metadaten des Dateiformats dieser Dateiversion auf.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Listet formatierte Metadaten kombiniert aus mehreren Quellen auf. Die Ergebnisse sind im HTML-Format.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Fügt den Dateinamen der Archivversion für die nicht-letzten Versionen hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Fügt die Bittiefe der Version hinzu.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Ergänzt, ob die Datei auf der [[MediaWiki:Bad image list]] ist.",
+ "apihelp-query+imageinfo-param-limit": "Wie viele Dateiversionen pro Datei zurückgegeben werden sollen.",
+ "apihelp-query+imageinfo-param-start": "Zeitstempel, von dem die Liste beginnen soll.",
+ "apihelp-query+imageinfo-param-end": "Zeitstempel, an dem die Liste enden soll.",
+ "apihelp-query+imageinfo-param-urlheight": "Ähnlich wie $1urlwidth.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Falls <kbd>$2prop=badfile</kbd> festgelegt ist, ist dies der verwendete Seitentitel beim Auswerten der [[MediaWiki:Bad image list]].",
+ "apihelp-query+imageinfo-param-localonly": "Suche nur nach Dateien im lokalen Repositorium.",
+ "apihelp-query+imageinfo-example-simple": "Rufe Informationen über die aktuelle Version von [[:File:Albert Einstein Head.jpg]] ab.",
+ "apihelp-query+imageinfo-example-dated": "Rufe Informationen über Versionen von [[:File:Test.jpg]] von 2008 und später ab.",
+ "apihelp-query+images-summary": "Gibt alle Dateien zurück, die in den angegebenen Seiten enthalten sind.",
+ "apihelp-query+images-param-limit": "Wie viele Dateien zurückgegeben werden sollen.",
+ "apihelp-query+images-param-images": "Nur diese Dateien auflisten. Nützlich um zu prüfen, ob eine bestimmte Seite eine bestimmte Datei enthält.",
+ "apihelp-query+images-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+images-example-simple": "Rufe eine Liste von Dateien ab, die auf der [[Main Page]] verwendet werden.",
+ "apihelp-query+images-example-generator": "Rufe Informationen über alle Dateien ab, die auf der [[Main Page]] verwendet werden.",
+ "apihelp-query+imageusage-summary": "Finde alle Seiten, die den angegebenen Bildtitel verwenden.",
+ "apihelp-query+imageusage-param-title": "Titel nach dem gesucht werden soll. Darf nicht zusammen mit $1pageid verwendet werden.",
+ "apihelp-query+imageusage-param-pageid": "Seitenkennung nach der gesucht werden soll. Darf nicht zusammen mit $1title verwendet werden.",
+ "apihelp-query+imageusage-param-namespace": "Der aufzulistende Namensraum.",
+ "apihelp-query+imageusage-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+imageusage-param-redirect": "Falls die verweisende Seite eine Weiterleitung ist, finde alle Seiten, die ebenfalls auf diese Weiterleitung verweisen. Die maximale Grenze wird halbiert.",
+ "apihelp-query+imageusage-example-simple": "Zeige Seiten, die [[:File:Albert Einstein Head.jpg]] verwenden.",
+ "apihelp-query+info-summary": "Ruft Basisinformationen über die Seite ab.",
+ "apihelp-query+info-param-prop": "Zusätzlich zurückzugebende Eigenschaften:",
+ "apihelp-query+info-paramvalue-prop-protection": "Liste die Schutzstufe jeder Seite auf.",
+ "apihelp-query+info-paramvalue-prop-talkid": "Die Seitenkennung der Diskussionsseite für jede Nicht-Diskussionsseite.",
+ "apihelp-query+info-paramvalue-prop-watched": "Liste den Überwachungszustand jeder Seite auf.",
+ "apihelp-query+info-paramvalue-prop-watchers": "Die Anzahl der Beobachter, falls erlaubt.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "Der Beobachtungslisten-Benachrichtigungs-Zeitstempel jeder Seite.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "Die Seitenkennung der Elternseite jeder Diskussionsseite.",
+ "apihelp-query+info-paramvalue-prop-readable": "Ob der Benutzer diese Seite betrachten darf.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Gibt die Art und Weise an, in der der Seitentitel tatsächlich angezeigt wird.",
+ "apihelp-query+info-param-testactions": "Überprüft, ob der aktuelle Benutzer gewisse Aktionen auf der Seite ausführen kann.",
+ "apihelp-query+iwbacklinks-param-prefix": "Präfix für das Interwiki.",
+ "apihelp-query+iwbacklinks-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen.",
+ "apihelp-query+iwbacklinks-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Ergänzt das Präfix des Interwikis.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Ergänzt den Titel des Interwikis.",
+ "apihelp-query+iwbacklinks-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+iwbacklinks-example-simple": "Ruft Seiten ab, die auf [[wikibooks:Test]] verlinken.",
+ "apihelp-query+iwlinks-param-prop": "Zusätzlich zurückzugebende Eigenschaften jedes Interlanguage-Links:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Ergänzt die vollständige URL.",
+ "apihelp-query+iwlinks-param-limit": "Wie viele Interwiki-Links zurückgegeben werden sollen.",
+ "apihelp-query+iwlinks-param-prefix": "Gibt nur Interwiki-Links mit diesem Präfix zurück.",
+ "apihelp-query+iwlinks-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+langbacklinks-param-limit": "Wie viele Gesamtseiten zurückgegeben werden sollen.",
+ "apihelp-query+langbacklinks-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+langbacklinks-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+langbacklinks-example-simple": "Ruft Seiten ab, die auf [[:fr:Test]] verlinken.",
+ "apihelp-query+langlinks-param-limit": "Wie viele Sprachlinks zurückgegeben werden sollen.",
+ "apihelp-query+langlinks-param-prop": "Zusätzlich zurückzugebende Eigenschaften jedes Interlanguage-Links:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Ergänzt die vollständige URL.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Ergänzt den Namen der Muttersprache.",
+ "apihelp-query+langlinks-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+links-summary": "Gibt alle Links von den angegebenen Seiten zurück.",
+ "apihelp-query+links-param-namespace": "Zeigt nur Links in diesen Namensräumen.",
+ "apihelp-query+links-param-limit": "Wie viele Links zurückgegeben werden sollen.",
+ "apihelp-query+links-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+links-example-simple": "Links von der <kbd>Hauptseite</kbd> abrufen",
+ "apihelp-query+linkshere-summary": "Alle Seiten finden, die auf die angegebenen Seiten verlinken.",
+ "apihelp-query+linkshere-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "Die Seitenkennung jeder Seite.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Titel jeder Seite.",
+ "apihelp-query+linkshere-param-limit": "Wie viel zurückgegeben werden soll.",
+ "apihelp-query+linkshere-example-simple": "Holt eine Liste von Seiten, die auf [[Main Page]] verlinken.",
+ "apihelp-query+logevents-summary": "Ruft Ereignisse von Logbüchern ab.",
+ "apihelp-query+logevents-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Ergänzt die Kennung des Logbuchereignisses.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Ergänzt den Titel der Seite für das Logbuchereignis.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Ergänzt den Typ des Logbuchereignisses.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Ergänzt den verantwortlichen Benutzer für das Logbuchereignis.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Ergänzt den Kommentar des Logbuchereignisses.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Listet Markierungen für das Logbuchereignis auf.",
+ "apihelp-query+logevents-param-start": "Der Zeitstempel, bei dem die Aufzählung beginnen soll.",
+ "apihelp-query+logevents-param-end": "Der Zeitstempel, bei dem die Aufzählung enden soll.",
+ "apihelp-query+logevents-param-prefix": "Filtert Einträge, die mit diesem Präfix beginnen.",
+ "apihelp-query+logevents-param-limit": "Wie viele Ereigniseinträge insgesamt zurückgegeben werden sollen.",
+ "apihelp-query+logevents-example-simple": "Listet die letzten Logbuch-Ereignisse auf.",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Fügt die Seitenkennung hinzu.",
+ "apihelp-query+pageswithprop-param-limit": "Die maximale Anzahl zurückzugebender Seiten.",
+ "apihelp-query+pageswithprop-param-dir": "In welche Richtung sortiert werden soll.",
+ "apihelp-query+prefixsearch-param-search": "Such-Zeichenfolge.",
+ "apihelp-query+prefixsearch-param-namespace": "Welche Namensräume durchsucht werden sollen.",
+ "apihelp-query+prefixsearch-param-limit": "Maximale Anzahl zurückzugebender Ergebnisse.",
+ "apihelp-query+prefixsearch-param-offset": "Anzahl der zu überspringenden Ergebnisse.",
+ "apihelp-query+prefixsearch-param-profile": "Zu verwendendes Suchprofil.",
+ "apihelp-query+protectedtitles-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen.",
+ "apihelp-query+protectedtitles-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Ergänzt den Schutzstatus.",
+ "apihelp-query+protectedtitles-example-simple": "Listet geschützte Titel auf.",
+ "apihelp-query+querypage-param-limit": "Anzahl der zurückzugebenden Ergebnisse.",
+ "apihelp-query+recentchanges-summary": "Listet die letzten Änderungen auf.",
+ "apihelp-query+recentchanges-param-user": "Listet nur Änderungen von diesem Benutzer auf.",
+ "apihelp-query+recentchanges-param-excludeuser": "Listet keine Änderungen von diesem Benutzer auf.",
+ "apihelp-query+recentchanges-param-tag": "Listet nur Änderungen auf, die mit dieser Markierung markiert sind.",
+ "apihelp-query+recentchanges-param-prop": "Bezieht zusätzliche Informationen mit ein:",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Fügt den Kommentar für die Bearbeitung hinzu.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Ergänzt Markierungen für die Bearbeitung.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Ergänzt den Zeitstempel für die Bearbeitung.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Ergänzt den Seitentitel der Bearbeitung.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Listet Markierungen für den Eintrag auf.",
+ "apihelp-query+recentchanges-example-simple": "Listet die letzten Änderungen auf.",
+ "apihelp-query+redirects-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "Seitenkennung einer jeden Weiterleitung.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Titel einer jeden Weiterleitung.",
+ "apihelp-query+redirects-param-namespace": "Schließt nur Seiten in diesen Namensräumen ein.",
+ "apihelp-query+redirects-param-limit": "Wie viele Weiterleitungen zurückgegeben werden sollen.",
+ "apihelp-query+revisions-param-tag": "Listet nur Versionen auf, die mit dieser Markierung markiert sind.",
+ "apihelp-query+revisions+base-param-prop": "Zurückzugebende Eigenschaften jeder Version:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "Die Kennung der Version.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Versionsmarkierungen (klein).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "Der Zeitstempel der Version.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Benutzer, der die Version erstellt hat.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "Benutzerkennung des Versionserstellers.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Länge in Bytes der Version.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1-Prüfsumme (Basis 16) der Version.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "Inhaltsmodell-Kennung der Version.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Text der Version.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Markierungen für die Version.",
+ "apihelp-query+search-param-what": "Welcher Suchtyp ausgeführt werden soll.",
+ "apihelp-query+search-param-info": "Welche Metadaten zurückgegeben werden sollen.",
+ "apihelp-query+search-param-prop": "Eigenschaften zur Rückgabe:",
+ "apihelp-query+search-param-qiprofile": "Zu verwendendes anfrageunabhängiges Profil (wirkt sich auf den Ranking-Algorithmus aus).",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Ergänzt den Wortzähler der Seite.",
+ "apihelp-query+search-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen.",
+ "apihelp-query+search-example-simple": "Nach <kbd>meaning</kbd> suchen.",
+ "apihelp-query+search-example-text": "Texte nach <kbd>meaning</kbd> durchsuchen.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Liste von Spezialseiten-Aliasse.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Liste von magischen Wörtern und ihrer Aliasse.",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Gibt eine Liste für die Sprachcodes zurück, bei denen der [[mw:Special:MyLanguage/LanguageConverter|Sprachkonverter]] aktiviert ist und die unterstützten Varianten für jede Sprache.",
+ "apihelp-query+siteinfo-example-simple": "Websiteinformationen abrufen",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Alias für $1filekey, für die Rückwärtskompatibilität.",
+ "apihelp-query+stashimageinfo-example-simple": "Gibt Informationen für eine gespeicherte Datei zurück.",
+ "apihelp-query+stashimageinfo-example-params": "Gibt Vorschaubilder für zwei gespeicherte Dateien zurück.",
+ "apihelp-query+tags-summary": "Änderungs-Tags auflisten.",
+ "apihelp-query+tags-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+tags-paramvalue-prop-name": "Ergänzt den Namen der Markierung.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Ergänzt die Systemnachricht für die Markierung.",
+ "apihelp-query+tags-paramvalue-prop-description": "Ergänzt die Beschreibung der Markierung.",
+ "apihelp-query+tags-example-simple": "Verfügbare Tags auflisten",
+ "apihelp-query+templates-param-limit": "Wie viele Vorlagen zurückgegeben werden sollen.",
+ "apihelp-query+templates-param-dir": "Die Auflistungsrichtung.",
+ "apihelp-query+tokens-param-type": "Typen der Token, die abgerufen werden sollen.",
+ "apihelp-query+transcludedin-param-prop": "Zurückzugebende Eigenschaften:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "Seitenkennung jeder Seite.",
+ "apihelp-query+usercontribs-summary": "Alle Bearbeitungen von einem Benutzer abrufen.",
+ "apihelp-query+usercontribs-param-limit": "Die maximale Anzahl der zurückzugebenden Beiträge.",
+ "apihelp-query+usercontribs-param-start": "Der zurückzugebende Start-Zeitstempel.",
+ "apihelp-query+usercontribs-param-end": "Der zurückzugebende End-Zeitstempel.",
+ "apihelp-query+usercontribs-param-user": "Die Benutzer, für die Beiträge abgerufen werden sollen. Kann nicht zusammen mit <var>$1userids</var> oder <var>$1userprefix</var> verwendet werden.",
+ "apihelp-query+usercontribs-param-userprefix": "Ruft Beiträge für alle Benutzer ab, deren Namen mit diesem Wert beginnt. Kann nicht zusammen mit <var>$1user</var> oder <var>$1userids</var> verwendet werden.",
+ "apihelp-query+usercontribs-param-userids": "Die Benutzerkennungen, für die die Beiträge abgerufen werden sollen. Kann nicht zusammen mit <var>$1user</var> oder <var>$1userprefix</var> verwendet werden.",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Fügt die Seiten- und Versionskennung hinzu.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Ergänzt den Zeitstempel der Bearbeitung.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Fügt den Kommentar der Bearbeitung hinzu.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Ergänzt die neue Größe der Bearbeitung.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Ergänzt Markierungen der Bearbeitung.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Markiert kontrollierte Bearbeitungen.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Listet die Markierungen für die Bearbeitung auf.",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Markiert, ob der aktuelle Benutzer gesperrt ist, von wem und aus welchem Grund.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Listet alle Einstellungen auf, die der aktuelle Benutzer festgelegt hat.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Ergänzt den Bearbeitungszähler des aktuellen Benutzers.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Fügt den bürgerlichen Namen des Benutzers hinzu.",
+ "apihelp-query+userinfo-example-simple": "Informationen über den aktuellen Benutzer abrufen",
+ "apihelp-query+userinfo-example-data": "Ruft zusätzliche Informationen über den aktuellen Benutzer ab.",
+ "apihelp-query+users-summary": "Informationen über eine Liste von Benutzern abrufen.",
+ "apihelp-query+users-param-prop": "Welche Informationsteile einbezogen werden sollen:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Markiert, ob der Benutzer gesperrt ist, von wem und aus welchem Grund.",
+ "apihelp-query+users-paramvalue-prop-groups": "Listet alle Gruppen auf, zu denen jeder Benutzer gehört.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Listet alle Gruppen auf, bei denen der Benutzer automatisch Mitglied ist.",
+ "apihelp-query+users-paramvalue-prop-rights": "Listet alle Rechte auf, die jeder Benutzer hat.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Ergänzt den Bearbeitungszähler des Benutzers.",
+ "apihelp-query+users-param-users": "Eine Liste der Benutzer, für die Informationen abgerufen werden sollen.",
+ "apihelp-query+users-param-userids": "Eine Liste der Benutzerkennungen, für die die Informationen abgerufen werden sollen.",
+ "apihelp-query+users-example-simple": "Gibt Informationen für den Benutzer <kbd>Example</kbd> zurück.",
+ "apihelp-query+watchlist-param-user": "Listet nur Änderungen von diesem Benutzer auf.",
+ "apihelp-query+watchlist-param-excludeuser": "Listet keine Änderungen von diesem Benutzer auf.",
+ "apihelp-query+watchlist-param-prop": "Zusätzlich zurückzugebende Eigenschaften:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Ergänzt die Versions- und Seitenkennungen.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Ergänzt den Titel der Seite.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Ergänzt die Markierungen für die Bearbeitungen.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Ergänzt den Benutzer, der die Bearbeitung ausgeführt hat.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Ergänzt die Kennung des Benutzers, der die Bearbeitung ausgeführt hat.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Ergänzt den Kommentar der Bearbeitung.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Ergänzt den geparsten Kommentar der Bearbeitung.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Ergänzt den Zeitstempel der Bearbeitung.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Markiert Bearbeitungen, die kontrolliert sind.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Ergänzt die alten und neuen Längen der Seite.",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Normale Seitenbearbeitungen.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Externe Änderungen.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Seitenerstellungen.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Logbucheinträge.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Änderungen an der Kategoriemitgliedschaft.",
+ "apihelp-query+watchlistraw-summary": "Ruft alle Seiten der Beobachtungsliste des aktuellen Benutzers ab.",
+ "apihelp-query+watchlistraw-param-prop": "Zusätzlich zurückzugebende Eigenschaften:",
+ "apihelp-query+watchlistraw-param-fromtitle": "Titel (mit Namensraum-Präfix), bei dem die Aufzählung beginnen soll.",
+ "apihelp-query+watchlistraw-param-totitle": "Titel (mit Namensraum-Präfix), bei dem die Aufzählung enden soll.",
+ "apihelp-resetpassword-param-user": "Benutzer, der zurückgesetzt werden soll.",
+ "apihelp-revisiondelete-summary": "Löscht und stellt Versionen wieder her.",
+ "apihelp-revisiondelete-param-hide": "Was für jede Version versteckt werden soll.",
+ "apihelp-revisiondelete-param-show": "Was für jede Version wieder eingeblendet werden soll.",
+ "apihelp-revisiondelete-param-tags": "Auf den Eintrag im Lösch-Logbuch anzuwendende Markierungen.",
+ "apihelp-rsd-summary": "Ein RSD-Schema (Really Simple Discovery) exportieren.",
+ "apihelp-rsd-example-simple": "Das RSD-Schema exportieren",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "An allen beobachteten Seiten arbeiten.",
+ "apihelp-setpagelanguage-summary": "Ändert die Sprache einer Seite.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Das Ändern der Sprache von Seiten ist auf diesem Wiki nicht erlaubt.\n\nAktiviere <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var>, um diese Aktion zu verwenden.",
+ "apihelp-setpagelanguage-param-title": "Titel der Seite, deren Sprache du ändern möchtest. Kann nicht zusammen mit <var>$1pageid</var> verwendet werden.",
+ "apihelp-setpagelanguage-param-pageid": "Kennung der Seite, deren Sprache du ändern möchtest. Kann nicht zusammen mit <var>$1title</var> verwendet werden.",
+ "apihelp-setpagelanguage-param-lang": "Code der Sprache, auf den die Seite geändert werden soll. Verwende <kbd>default</kbd>, um die Seite auf die Standardinhaltssprache des Wikis zurückzusetzen.",
+ "apihelp-setpagelanguage-param-reason": "Grund für die Änderung.",
+ "apihelp-setpagelanguage-param-tags": "Auf den Logbucheintrag anzuwendende Änderungsmarkierungen, die sich aus dieser Aktion ergeben.",
+ "apihelp-setpagelanguage-example-language": "Ändert die Sprache von <kbd>Hauptseite</kbd> auf Baskisch.",
+ "apihelp-setpagelanguage-example-default": "Ändert die Sprache der Seite mit der Kennung 123 auf die Standardinhaltssprache des Wikis.",
+ "apihelp-stashedit-param-sectiontitle": "Der Titel für einen neuen Abschnitt.",
+ "apihelp-stashedit-param-text": "Seiteninhalt.",
+ "apihelp-stashedit-param-stashedtexthash": "Stattdessen zu verwendende Prüfsumme des Seiteninhalts von einem vorherigen Speicher.",
+ "apihelp-stashedit-param-contentmodel": "Inhaltsmodell des neuen Inhalts.",
+ "apihelp-stashedit-param-summary": "Änderungszusammenfassung.",
+ "apihelp-tag-param-reason": "Grund für die Änderung.",
+ "apihelp-tag-param-tags": "Auf den Logbucheintrag anzuwendende Markierungen, die als Ergebnis dieser Aktion erstellt wurden.",
+ "apihelp-tokens-param-type": "Abzufragende Tokentypen.",
+ "apihelp-tokens-example-edit": "Ruft einen Bearbeitungstoken ab (Standard).",
+ "apihelp-tokens-example-emailmove": "Ruft einen E-Mail- und Verschiebungstoken ab.",
+ "apihelp-unblock-summary": "Einen Benutzer freigeben.",
+ "apihelp-unblock-param-id": "Kennung der Sperre zur Freigabe (abgerufen durch <kbd>list=blocks</kbd>). Kann nicht zusammen mit <var>$1user</var> oder <var>$1userid</var> verwendet werden.",
+ "apihelp-unblock-param-user": "Benutzername, IP-Adresse oder IP-Adressbereich, der freigegeben werden soll. Kann nicht zusammen mit <var>$1id</var> oder <var>$1userid</var> verwendet werden.",
+ "apihelp-unblock-param-reason": "Grund für die Freigabe.",
+ "apihelp-unblock-param-tags": "Auf den Benutzersperr-Logbuch-Eintrag anzuwendende Änderungsmarkierungen.",
+ "apihelp-unblock-example-id": "Sperrkennung #<kbd>105</kbd> freigeben.",
+ "apihelp-undelete-param-title": "Titel der wiederherzustellenden Seite.",
+ "apihelp-undelete-param-reason": "Grund für die Wiederherstellung.",
+ "apihelp-undelete-param-tags": "Auf den Lösch-Logbuch-Eintrag anzuwendende Änderungsmarkierungen.",
+ "apihelp-upload-param-filename": "Ziel-Dateiname.",
+ "apihelp-upload-param-tags": "Auf den Datei-Logbuch-Eintrag und die Dateiseitenversion anzuwendende Änderungsmarkierungen.",
+ "apihelp-upload-param-text": "Erster Seitentext für neue Dateien.",
+ "apihelp-upload-param-watch": "Die Seite beobachten.",
+ "apihelp-upload-param-ignorewarnings": "Ignoriert Warnungen.",
+ "apihelp-upload-param-file": "Dateiinhalte.",
+ "apihelp-upload-param-url": "URL, von der die Datei abgerufen werden soll.",
+ "apihelp-upload-param-filesize": "Dateigröße des gesamten Uploads.",
+ "apihelp-upload-param-checkstatus": "Ruft nur den Hochladestatus für den angegebenen Dateischlüssel ab.",
+ "apihelp-upload-example-url": "Von einer URL hochladen",
+ "apihelp-upload-example-filekey": "Vervollständigt einen Upload, der aufgrund von Warnungen fehlgeschlagen ist.",
+ "apihelp-userrights-summary": "Ändert die Gruppenzugehörigkeit eines Benutzers.",
+ "apihelp-userrights-param-user": "Benutzername.",
+ "apihelp-userrights-param-userid": "Benutzerkennung.",
+ "apihelp-userrights-param-add": "Fügt den Benutzer zu diesen Gruppen hinzu oder falls er bereits Mitglied ist, aktualisiert den Ablauf seiner Mitgliedschaft in dieser Gruppe.",
+ "apihelp-userrights-param-remove": "Entfernt den Benutzer von diesen Gruppen.",
+ "apihelp-userrights-param-reason": "Grund für die Änderung.",
+ "apihelp-userrights-param-tags": "Auf den Eintrag im Benutzerrechte-Logbuch anzuwendende Änderungsmarkierungen.",
+ "apihelp-validatepassword-summary": "Validiert ein Passwort gegen die Passwortrichtlinien des Wikis.",
+ "apihelp-validatepassword-extended-description": "Die Validität wird als <samp>Good</samp> gemeldet, falls das Passwort akzeptabel ist, <samp>Change</samp>, falls das Passwort zur Anmeldung verwendet werden kann, jedoch geändert werden muss oder <samp>Invalid</samp>, falls das Passwort nicht verwendbar ist.",
+ "apihelp-validatepassword-param-password": "Zu validierendes Passwort.",
+ "apihelp-validatepassword-param-user": "Der beim Austesten der Benutzerkontenerstellung verwendete Benutzername. Der angegebene Benutzer darf nicht vorhanden sein.",
+ "apihelp-validatepassword-param-email": "Die beim Austesten der Benutzerkontenerstellung verwendete E-Mail-Adresse.",
+ "apihelp-validatepassword-param-realname": "Der beim Austesten der Benutzerkontenerstellung verwendete bürgerliche Name.",
+ "apihelp-validatepassword-example-1": "Validiert das Passwort <kbd>foobar</kbd> für den aktuellen Benutzer.",
+ "apihelp-validatepassword-example-2": "Validiert das Passwort <kbd>qwerty</kbd> zum Erstellen des Benutzers <kbd>Beispiel</kbd>.",
+ "apihelp-watch-example-watch": "Die Seite <kbd>Main Page</kbd> beobachten.",
+ "apihelp-watch-example-unwatch": "Die Seite <kbd>Main Page</kbd> nicht beobachten.",
+ "apihelp-format-example-generic": "Das Abfrageergebnis im $1-Format ausgeben.",
+ "apihelp-json-summary": "Daten im JSON-Format ausgeben.",
+ "apihelp-json-param-callback": "Falls angegeben, wird die Ausgabe in einen angegebenen Funktionsaufruf eingeschlossen. Aus Sicherheitsgründen sind benutzerspezifische Daten beschränkt.",
+ "apihelp-json-param-utf8": "Falls angegeben, kodiert die meisten (aber nicht alle) Nicht-ASCII-Zeichen als UTF-8 anstatt sie mit hexadezimalen Escape-Sequenzen zu ersetzen. Standard, wenn <var>formatversion</var> nicht <kbd>1</kbd> ist.",
+ "apihelp-jsonfm-summary": "Daten im JSON-Format ausgeben (schöngedruckt in HTML).",
+ "apihelp-none-summary": "Nichts ausgeben.",
+ "apihelp-php-summary": "Daten im serialisierten PHP-Format ausgeben.",
+ "apihelp-phpfm-summary": "Daten im serialisierten PHP-Format ausgeben (schöngedruckt in HTML).",
+ "apihelp-rawfm-summary": "Daten, einschließlich Fehlerbehebungselementen, im JSON-Format ausgeben (schöngedruckt in HTML).",
+ "apihelp-xml-summary": "Daten im XML-Format ausgeben.",
+ "apihelp-xml-param-xslt": "Falls angegeben, fügt die benannte Seite als XSL-Stylesheet hinzu. Der Wert muss ein Titel im Namensraum „{{ns:MediaWiki}}“ sein und mit <code>.xsl</code> enden.",
+ "apihelp-xml-param-includexmlnamespace": "Falls angegeben, ergänzt einen XML-Namensraum.",
+ "apihelp-xmlfm-summary": "Daten im XML-Format ausgeben (schöngedruckt in HTML).",
+ "api-format-title": "MediaWiki-API-Ergebnis",
+ "api-format-prettyprint-header": "Dies ist die Darstellung des $1-Formats in HTML. HTML ist gut zur Fehlerbehebung geeignet, aber unpassend für die Nutzung durch Anwendungen.\n\nGib den Parameter <var>format</var> an, um das Ausgabeformat zu ändern. Lege <kbd>format=$2</kbd> fest, um die von HTML abweichende Darstellung des $1-Formats zu erhalten.\n\nSiehe auch die [[mw:Special:MyLanguage/API|vollständige Dokumentation der API]] oder die [[Special:ApiHelp/main|API-Hilfe]] für weitere Informationen.",
+ "api-format-prettyprint-status": "Diese Antwort wird mit dem HTTP-Status $1 $2 zurückgegeben.",
+ "api-pageset-param-titles": "Eine Liste der Titel, an denen gearbeitet werden soll.",
+ "api-pageset-param-pageids": "Eine Liste der Seitenkennungen, an denen gearbeitet werden soll.",
+ "api-pageset-param-revids": "Eine Liste der Versionskennungen, an denen gearbeitet werden soll.",
+ "api-help-title": "MediaWiki-API-Hilfe",
+ "api-help-lead": "Dies ist eine automatisch generierte MediaWiki-API-Dokumentationsseite.\n\nDokumentation und Beispiele: https://www.mediawiki.org/wiki/API/de",
+ "api-help-main-header": "Hauptmodul",
+ "api-help-undocumented-module": "Keine Dokumentation für das Modul „$1“.",
+ "api-help-flag-deprecated": "Dieses Modul ist veraltet.",
+ "api-help-flag-internal": "<strong>Dieses Modul ist intern oder instabil.</strong> Seine Operationen werden ohne Kenntnisnahme geändert.",
+ "api-help-flag-readrights": "Dieses Modul erfordert Leserechte.",
+ "api-help-flag-writerights": "Dieses Modul erfordert Schreibrechte.",
+ "api-help-flag-mustbeposted": "Dieses Modul akzeptiert nur POST-Anfragen.",
+ "api-help-flag-generator": "Dieses Modul kann als Generator verwendet werden.",
+ "api-help-source": "Quelle: $1",
+ "api-help-source-unknown": "Quelle: <span class=\"apihelp-unknown\">unbekannt</span>",
+ "api-help-license": "Lizenz: [[$1|$2]]",
+ "api-help-license-noname": "Lizenz: [[$1|Siehe Link]]",
+ "api-help-license-unknown": "Lizenz: <span class=\"apihelp-unknown\">unbekannt</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parameter}}:",
+ "api-help-param-deprecated": "Veraltet.",
+ "api-help-param-required": "Dieser Parameter ist erforderlich.",
+ "api-help-datatypes-header": "Datentypen",
+ "api-help-param-type-limit": "Typ: Ganzzahl oder <kbd>max</kbd>",
+ "api-help-param-type-integer": "Typ: {{PLURAL:$1|1=Ganzzahl|2=Liste von Ganzzahlen}}",
+ "api-help-param-type-boolean": "Typ: boolesch ([[Special:ApiHelp/main#main/datatypes|Einzelheiten]])",
+ "api-help-param-type-timestamp": "Typ: {{PLURAL:$1|1=Zeitstempel|2=Liste von Zeitstempeln}} ([[Special:ApiHelp/main#main/datatypes|erlaubte Formate]])",
+ "api-help-param-type-user": "Typ: {{PLURAL:$1|1=Benutzername|2=Liste von Benutzernamen}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Einer der folgenden Werte|2=Werte (mit <kbd>{{!}}</kbd> trennen oder [[Special:ApiHelp/main#main/datatypes|Alternative]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Muss leer sein|Kann leer sein oder $2}}",
+ "api-help-param-limit": "Nicht mehr als $1 erlaubt.",
+ "api-help-param-limit2": "Nicht mehr als $1 ($2 für Bots) erlaubt.",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=Der Wert darf|2=Die Werte dürfen}} nicht kleiner sein als $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Der Wert darf|2=Die Werte dürfen}} nicht größer sein als $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Der Wert muss|2=Die Werte müssen}} zwischen $2 und $3 sein.",
+ "api-help-param-upload": "Muss als Dateiupload mithilfe eines multipart/form-data-Formular bereitgestellt werden.",
+ "api-help-param-multi-separate": "Werte mit <kbd>|</kbd> trennen oder [[Special:ApiHelp/main#main/datatypes|Alternative]].",
+ "api-help-param-multi-max": "Maximale Anzahl der Werte ist {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} für Bots).",
+ "api-help-param-multi-max-simple": "Die maximale Anzahl der Werte ist {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "Um alle Werte anzugeben, verwende <kbd>$1</kbd>.",
+ "api-help-param-default": "Standard: $1",
+ "api-help-param-default-empty": "Standard: <span class=\"apihelp-empty\">(leer)</span>",
+ "api-help-param-token": "Ein „$1“-Token abgerufen von [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "Aus Kompatibilitätsgründen wird der in der Weboberfläche verwendete Token ebenfalls akzeptiert.",
+ "api-help-param-disabled-in-miser-mode": "Deaktiviert aufgrund des [[mw:Special:MyLanguage/Manual:$wgMiserMode|Miser-Modus]].",
+ "api-help-param-continue": "Falls weitere Ergebnisse verfügbar sind, dies zum Fortfahren verwenden.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(keine Beschreibung)</span>",
+ "api-help-examples": "{{PLURAL:$1|Beispiel|Beispiele}}:",
+ "api-help-permissions": "{{PLURAL:$1|Berechtigung|Berechtigungen}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Gewährt an}}: $2",
+ "api-help-right-apihighlimits": "Höhere Beschränkungen in API-Anfragen verwenden (langsame Anfragen: $1; schnelle Anfragen: $2). Die Beschränkungen für langsame Anfragen werden auch auf Mehrwertparameter angewandt.",
+ "api-help-open-in-apisandbox": "<small>[in Spielwiese öffnen]</small>",
+ "api-help-authmanagerhelper-messageformat": "Zu verwendendes Format zur Rückgabe von Nachrichten.",
+ "apierror-badgenerator-unknown": "<kbd>generator=$1</kbd> unbekannt.",
+ "apierror-badip": "Der IP-Parameter ist nicht gültig.",
+ "apierror-badmd5": "Die angegebene MD5-Prüfsumme war falsch.",
+ "apierror-badmodule-badsubmodule": "Das Modul <kbd>$1</kbd> hat kein Untermodul namens „$2“.",
+ "apierror-badmodule-nosubmodules": "Das Modul <kbd>$1</kbd> hat keine Untermodule.",
+ "apierror-badparameter": "Ungültiger Wert für den Parameter <var>$1</var>.",
+ "apierror-badquery": "Ungültige Abfrage.",
+ "apierror-cannot-async-upload-file": "Die Parameter <var>async</var> und <var>file</var> können nicht kombiniert werden. Falls du eine asynchrone Verarbeitung deiner hochgeladenen Datei wünschst, lade sie zuerst mithilfe des Parameters <var>stash</var> auf den Speicher hoch. Veröffentliche anschließend die gespeicherte Datei asynchron mithilfe <var>filekey</var> und <var>async</var>.",
+ "apierror-emptypage": "Das Erstellen neuer leerer Seiten ist nicht erlaubt.",
+ "apierror-filedoesnotexist": "Die Datei ist nicht vorhanden.",
+ "apierror-import-unknownerror": "Unbekannter Fehler beim Importieren: $1.",
+ "apierror-invalid-file-key": "Kein gültiger Dateischlüssel.",
+ "apierror-invalidsection": "Der Parameter <var>section</var> muss eine gültige Abschnittskennung oder <kbd>new</kbd> sein.",
+ "apierror-invaliduserid": "Die Benutzerkennung <var>$1</var> ist nicht gültig.",
+ "apierror-nosuchsection": "Es gibt keinen Abschnitt $1.",
+ "apierror-nosuchuserid": "Es gibt keinen Benutzer mit der Kennung $1.",
+ "apierror-offline": "Aufgrund von Problemen bei der Netzwerkverbindung kannst du nicht weitermachen. Stelle sicher, dass du eine funktionierende Internetverbindung hast und versuche es erneut.",
+ "apierror-pagelang-disabled": "Das Ändern der Sprache von Seiten ist auf diesem Wiki nicht erlaubt.",
+ "apierror-protect-invalidaction": "Ungültiger Schutztyp „$1“.",
+ "apierror-readonly": "Das Wiki ist derzeit im schreibgeschützten Modus.",
+ "apierror-revisions-badid": "Für den Parameter <var>$1</var> wurde keine Version gefunden.",
+ "apierror-revwrongpage": "Die Version $1 ist keine Version von $2.",
+ "apierror-sectionreplacefailed": "Der aktualisierte Abschnitt konnte nicht zusammengeführt werden.",
+ "apierror-stashinvalidfile": "Ungültige gespeicherte Datei.",
+ "apierror-stashnosuchfilekey": "Kein derartiger Dateischlüssel: $1.",
+ "apierror-stashwrongowner": "Falscher Besitzer: $1",
+ "apierror-systemblocked": "Du wurdest von MediaWiki automatisch gesperrt.",
+ "apierror-timeout": "Der Server hat nicht innerhalb der erwarteten Zeit reagiert.",
+ "apierror-unknownerror-nocode": "Unbekannter Fehler.",
+ "apierror-unknownerror": "Unbekannter Fehler: „$1“.",
+ "apierror-unknownformat": "Nicht erkanntes Format „$1“.",
+ "apiwarn-invalidcategory": "„$1“ ist keine Kategorie.",
+ "apiwarn-invalidtitle": "„$1“ ist kein gültiger Titel.",
+ "apiwarn-notfile": "„$1“ ist keine Datei.",
+ "apiwarn-parse-revidwithouttext": "<var>revid</var>, ohne <var>text</var> verwendet, und geparste Seiteneigenschaften wurden angefordert. Wolltest du <var>oldid</var> anstatt <var>revid</var> verwenden?",
+ "apiwarn-toomanyvalues": "Es wurden zu viele Werte für den Parameter <var>$1</var> angegeben. Die Obergrenze liegt bei $2.",
+ "apiwarn-validationfailed-badpref": "Keine gültige Einstellung.",
+ "apiwarn-validationfailed-cannotset": "Kann nicht von diesem Modul festgelegt werden.",
+ "apiwarn-validationfailed-keytoolong": "Der Schlüssel ist zu lang. Es sind nicht mehr als $1 Bytes erlaubt.",
+ "apiwarn-validationfailed": "Validierungsfehler für <kbd>$1</kbd>: $2",
+ "api-feed-error-title": "Fehler ($1)",
+ "api-usage-docref": "Siehe $1 zur Verwendung der API.",
+ "api-usage-mailinglist-ref": "Abonniere die Mailingliste „mediawiki-api-announce“ auf &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; zum Feststellen von API-Veralterungen und „Breaking Changes“.",
+ "api-credits-header": "Danksagungen",
+ "api-credits": "API-Entwickler:\n* Roan Kattouw (Hauptentwickler von September 2007 bis 2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (Autor, Hauptentwickler von September 2006 bis September 2007)\n* Brad Jorsch (Hauptentwickler seit 2013)\n\nBitte sende deine Kommentare, Vorschläge und Fragen an mediawiki-api@lists.wikimedia.org\noder reiche einen Fehlerbericht auf https://phabricator.wikimedia.org/ ein."
+}
diff --git a/www/wiki/includes/api/i18n/diq.json b/www/wiki/includes/api/i18n/diq.json
new file mode 100644
index 00000000..2a0cbe8b
--- /dev/null
+++ b/www/wiki/includes/api/i18n/diq.json
@@ -0,0 +1,66 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gorizon",
+ "Mirzali",
+ "Kumkumuk",
+ "Asmen",
+ "1917 Ekim Devrimi",
+ "Gambollar"
+ ]
+ },
+ "apihelp-main-param-action": "Performansa kamci aksiyon",
+ "apihelp-block-summary": "Enê karberi bloqe ke",
+ "apihelp-block-param-reason": "Sebeba Bloqey",
+ "apihelp-block-param-nocreate": "Hesab viraştişi bloqe ke.",
+ "apihelp-checktoken-param-token": "Jetona test ke",
+ "apihelp-createaccount-summary": "Yew Hesabê karberi yo newe vıraze",
+ "apihelp-createaccount-param-name": "Nameyê karberi.",
+ "apihelp-createaccount-param-email": "E-postay karberi (keyfi)",
+ "apihelp-createaccount-param-realname": "Namey karberi yo raştay (keyfi)",
+ "apihelp-delete-summary": "Pele bestere.",
+ "apihelp-delete-example-simple": "<kbd>Main Page</kbd> besternê.",
+ "apihelp-disabled-summary": "Eno modul aktiv niyo.",
+ "apihelp-edit-summary": "Vıraze û pelan bıvurne.",
+ "apihelp-edit-param-text": "Zerreki pele",
+ "apihelp-edit-param-minor": "Vurriyayışê werdiy",
+ "apihelp-edit-param-notminor": "Vurnayışo qıckek niyo.",
+ "apihelp-edit-param-bot": "Nê vurnayışi zey boti nişan ke.",
+ "apihelp-edit-example-edit": "Şeker bıvurne",
+ "apihelp-emailuser-summary": "Yew karberi rê e-poste bırışe.",
+ "apihelp-emailuser-param-target": "Karbero ke cı rê e-poste do bırışiyo.",
+ "apihelp-emailuser-param-subject": "Sernameyê mewzuyi.",
+ "apihelp-emailuser-param-text": "Metınê e-posteyi.",
+ "apihelp-emailuser-param-ccme": "Yew kopyaya nê posteyi mı rê bırışe.",
+ "apihelp-expandtemplates-param-title": "Sernameyê pele.",
+ "apihelp-expandtemplates-param-text": "Wikimetıni açarnê.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Herabıyaye wikimetin",
+ "apihelp-feedcontributions-param-feedformat": "Formata warikerdışi",
+ "apihelp-feedcontributions-param-year": "Ser ra (u rewên)",
+ "apihelp-feedcontributions-param-month": "Meng ra (u rewên)",
+ "apihelp-feedcontributions-param-hideminor": "Vuryayışanê werdiyan bınımne",
+ "apihelp-feedcontributions-param-showsizediff": "Goreyê ebati ferqê versiyoni bımotné.",
+ "apihelp-feedrecentchanges-param-hideminor": "Vurriyayışanê werdiyan bınımne.",
+ "apihelp-feedrecentchanges-param-hidebots": "Vurnayışanê botan bınımne.",
+ "apihelp-feedrecentchanges-param-hideanons": "Vurnayışanê karberanê anoniman bınımne.",
+ "apihelp-feedrecentchanges-param-hideliu": "Vurnayışanê karberanê qeydınan bınımne.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrey etiketi",
+ "apihelp-feedrecentchanges-example-simple": "Vurnayışê peyênan bıvin",
+ "apihelp-feedrecentchanges-example-30days": "Peyni vurnayışanê 30 raco bımosne",
+ "apihelp-filerevert-param-comment": "Mışewre bar ke",
+ "apihelp-login-param-name": "Nameyê karberi.",
+ "apihelp-login-param-password": "Parola.",
+ "apihelp-login-param-domain": "Domain (optional).",
+ "apihelp-login-example-login": "Dekew.",
+ "apihelp-mergehistory-summary": "Verorê pela yew ke",
+ "apihelp-move-summary": "Yew pele bere.",
+ "apihelp-move-param-noredirect": "Hetenayış mevıraz",
+ "apihelp-options-example-reset": "Terciha pêron reset ke",
+ "apihelp-options-example-change": "Tercihanê <kbd>skin</kbd> u <kbd>hideminor</kbd> bıvurnê",
+ "apihelp-parse-example-page": "Peler analiz ke",
+ "apihelp-parse-example-text": "Wikimetini analiz ke",
+ "apihelp-parse-example-summary": "Xulasay analiz ke",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Sernamey rê link dek",
+ "apihelp-query+allmessages-param-lang": "Mesaja açarn ena zıwan.",
+ "apihelp-query+blocks-example-simple": "Listey bloqeyan"
+}
diff --git a/www/wiki/includes/api/i18n/el.json b/www/wiki/includes/api/i18n/el.json
new file mode 100644
index 00000000..4e8dfa04
--- /dev/null
+++ b/www/wiki/includes/api/i18n/el.json
@@ -0,0 +1,109 @@
+{
+ "@metadata": {
+ "authors": [
+ "Glavkos",
+ "Protnet",
+ "Stam.nikos",
+ "Macofe",
+ "Geraki",
+ "Giorgos456"
+ ]
+ },
+ "apihelp-main-summary": "",
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Τεκμηρίωση]]\n* [[mw:API:FAQ|Συχνές ερωτήσεις]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Λίστα αλληλογραφίας]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Ανακοινώσεις API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Σφάλματα & αιτήματα]\n</div>\n<strong>Κατάσταση:</strong> Όλα τα χαρακτηριστικά που εμφανίζονται σε αυτή τη σελίδα πρέπει να λειτουργούν, αλλά το API είναι ακόμα σε ενεργό ανάπτυξη, και μπορεί να αλλάξει ανά πάσα στιγμή. Εγγραφείτε στη [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce λίστα αλληλογραφίας] για να ειδοποιείστε για ενημερώσεις.\n\n<strong>Εσφαλμένα αιτήματα:</strong> Όταν στέλνονται εσφαλμένα αιτήματα στο API, επιστρέφεται μία κεφαλίδα HTTP (header) με το κλειδί \"MediaWiki-API-Error\" κι έπειτα η τιμή της κεφαλίδας και ο κωδικός σφάλματος που επιστρέφονται ορίζονται στην ίδια τιμή. Για περισσότερες πληροφορίες, δείτε [[mw:API:Errors_and_warnings|API: Σφάλματα και προειδοποιήσεις]].",
+ "apihelp-main-param-action": "Ποια ενέργει να εκτελεστεί.",
+ "apihelp-main-param-format": "Η μορφή των δεδομένων εξόδου.",
+ "apihelp-main-param-curtimestamp": "Συμπερίληψη της τρέχουσας χρονοσφραγίδας στο αποτέλεσμα.",
+ "apihelp-main-param-origin": "Κατά την πρόσβαση στο API χρησιμοποιώντας ένα cross-domain αίτημα AJAX (ΕΤΠ), το σύνολο αυτό το τομέα προέλευσης. Αυτό πρέπει να περιλαμβάνεται σε κάθε προ-πτήσης αίτηση, και ως εκ τούτου πρέπει να είναι μέρος του URI αιτήματος (δεν είναι η ΘΈΣΗ του σώματος). Αυτό πρέπει να ταιριάζει με μία από τις ρίζες της <code>Προέλευσης</code> κεφαλίδων ακριβώς, γι ' αυτό θα πρέπει να οριστεί σε κάτι σαν <kbd>https://en.wikipedia.org</kbd> ή <kbd>https://meta.wikimedia.org</kbd>. Εάν αυτή η παράμετρος δεν ταιριάζει με την <code>Προέλευση</code> κεφαλίδα, 403 απάντηση θα πρέπει να επιστραφεί. Εάν αυτή η παράμετρος ταιριάζει με την <code>Προέλευση</code> κεφαλίδα και η καταγωγή του είναι στη λίστα επιτρεπόμενων, μια <code>Access-Control-Allow-Origin</code> κεφαλίδα θα πρέπει να ρυθμιστεί.",
+ "apihelp-main-param-uselang": "Γλώσσα για τις μεταφράσεις μηνυμάτων. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> με <kbd>siprop=languages</kbd> επιστρέφει μια λίστα με κωδικούς γλωσσών, ή καθορίστε <kbd>user</kbd> για να χρησιμοποιήσετε την προτίμηση γλώσσας του τρέχοντα χρήστη, ή καθορίστε <kbd>content</kbd> για να χρησιμοποιήσετε τη γλώσσα περιεχομένου αυτού του wiki.",
+ "apihelp-block-summary": "Φραγή χρήστη",
+ "apihelp-block-param-user": "Όνομα χρήστη, διεύθυνση IP ή εύρος διευθύνσεων IP που θέλετε να επιβάλετε φραγή.",
+ "apihelp-block-param-expiry": "Ώρα λήξης. Μπορεί να είναι σχετική (π.χ. <kbd>σε 5 μήνες</kbd> ή <kbd>σε 2 εβδομάδες</kbd>) ή απόλυτη (π.χ. <kbd>2014-09-18T12:34:56Z</kbd>). Αν οριστεί σε <kbd>άπειρη</kbd>, <kbd>απεριόριστη</kbd>, ή <kbd>ποτέ</kbd>, ο αποκλεισμός δεν θα λήξει ποτέ.",
+ "apihelp-block-param-reason": "Λόγος φραγής.",
+ "apihelp-block-param-anononly": "Αποκλείστε ανώνυμους χρήστες μόνο (δηλ. απενεργοποιήστε ανώνυμες επεξεργασίες για αυτή τη διεύθυνση IP).",
+ "apihelp-block-param-nocreate": "Αποτροπή δημιουργίας λογαριασμού.",
+ "apihelp-block-param-autoblock": "Αποκλείστε αυτόματα την τελευταία χρησιμοποιημένη διεύθυνση IP και κάθε συνακόλουθη διεύθυνση IP από την οποία γίνεται προσπάθεια σύνδεσης.",
+ "apihelp-block-param-noemail": "Να αποτρέψει το χρήστη από την αποστολή e-mail μέσω του wiki. (Απαιτεί το δικαίωμα <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Κρύψε το όνομα χρήστη από το ημερολόγιο φραγών. (Απαιτείται το δικαίωμα <code>hideuser</code>).",
+ "apihelp-block-param-reblock": "Αν ο χρήστης είναι ήδη αποκλεισμέμος, αντικαταστήστε την υπάρχουσα φραγή.",
+ "apihelp-block-param-watchuser": "Παρακολούθηση του χρήστη ή της διεύθυνσης IP του χρήστη και των σελίδων συζήτησής του.",
+ "apihelp-block-example-ip-simple": "Φραγή διεύθυνσης IP <kbd>192.0.2.5</kbd> για τρεις μέρες με το λόγο, <kbd>Πρώτη απεργία</kbd>.",
+ "apihelp-checktoken-param-token": "Δείγμα σας για τη δοκιμή.",
+ "apihelp-checktoken-param-maxtokenage": "Μέγιστη επιτρεπόμενη διάρκεια του token, σε δευτερόλεπτα.",
+ "apihelp-createaccount-summary": "Δημιουργήστε νέο λογαριασμό χρήστη.",
+ "apihelp-createaccount-param-name": "Όνομα χρήστη.",
+ "apihelp-createaccount-param-password": "Κωδικός πρόσβασης (αγνοείται, αν έχει οριστεί το <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-email": "Διεύθυνση ηλεκτρονικού ταχυδρομείου χρήστη (προαιρετικό).",
+ "apihelp-createaccount-param-realname": "Πραγματικό όνομα χρήστη (προαιρετικό).",
+ "apihelp-createaccount-param-mailpassword": "Εάν οριστεί σε οποιαδήποτε τιμή, ένας τυχαίος κωδικός πρόσβασης θα αποσταλεί μέσω ηλεκτρονικού ταχυδρομείου στο χρήστη.",
+ "apihelp-createaccount-param-language": "Κωδικός γλώσσας που να οριστεί ως προεπιλογή για το χρήστη (προαιρετικό, έχει ως προεπιλογή τη γλώσσα περιεχομένου).",
+ "apihelp-delete-summary": "Διαγραφή σελίδας.",
+ "apihelp-delete-example-simple": "Διαγραφή <kbd>Main Page</kbd>.",
+ "apihelp-edit-summary": "Δημιουργία και επεξεργασία σελίδων.",
+ "apihelp-edit-param-sectiontitle": "Ο τίτλος νέας ενότητας.",
+ "apihelp-edit-param-text": "Περιεχόμενο σελίδας.",
+ "apihelp-edit-param-minor": "Μικροεπεξεργασία.",
+ "apihelp-edit-param-notminor": "Μη ήσσονος σημασίας επεξεργασία.",
+ "apihelp-edit-param-bot": "Σήμανση αυτής της επεξεργασίας ως επεξεργασία από ρομπότ.",
+ "apihelp-edit-param-createonly": "Να μην γίνει επεξεργασία της σελίδας εάν υπάρχει ήδη.",
+ "apihelp-edit-param-nocreate": "Να εμφανιστεί μήνυμα σφάλματος εάν η σελίδα δεν υπάρχει.",
+ "apihelp-edit-param-watch": "Να προστεθεί η σελίδα στη λίστα παρακολούθησης του τρέχοντα χρήστη.",
+ "apihelp-edit-param-unwatch": "Να αφαιρεθεί η σελίδα από τη λίστα παρακολούθησης του τρέχοντα χρήστη.",
+ "apihelp-edit-param-contentmodel": "Μοντέλο περιεχομένου για το νέο περιεχόμενο.",
+ "apihelp-edit-example-edit": "Επεξεργασία κάποιας σελίδας.",
+ "apihelp-emailuser-summary": "Αποστολή μηνύματος ηλεκτρονικού ταχυδρομείου σε χρήστη.",
+ "apihelp-emailuser-param-target": "Χρήστης στον οποίον να σταλεί το μήνυμα ηλεκτρονικού ταχυδρομείου.",
+ "apihelp-emailuser-param-subject": "Κεφαλίδα θέματος.",
+ "apihelp-emailuser-param-text": "Σώμα μηνύματος.",
+ "apihelp-emailuser-param-ccme": "Αποστολή αντιγράφου αυτού του μηνύματος σε εμένα.",
+ "apihelp-expandtemplates-summary": "Επεκτείνει όλα τα πρότυπα στον κώδικα wiki.",
+ "apihelp-expandtemplates-param-title": "Τίτλος σελίδας.",
+ "apihelp-expandtemplates-param-text": "Κώδικας wiki προς μετατροπή.",
+ "apihelp-feedcontributions-param-feedformat": "Η μορφή της ροής.",
+ "apihelp-feedcontributions-param-deletedonly": "Εμφάνιση μόνο διαγεγραμμένων συνεισφορών.",
+ "apihelp-feedcontributions-param-toponly": "Εμφάνιση μόνο των επεξεργασιών που είναι οι πιο πρόσφατες αναθεωρήσεις.",
+ "apihelp-feedcontributions-param-newonly": "Εμφάνιση μόνο των επεξεργασιών που είναι δημιουργία σελίδας.",
+ "apihelp-feedcontributions-param-showsizediff": "Εμφάνιση της διαφοράς μεγέθους μεταξύ αναθεωρήσεων.",
+ "apihelp-feedrecentchanges-param-from": "Εμφάνιση αλλαγών από τότε.",
+ "apihelp-feedrecentchanges-param-hideminor": "Απόκρυψη μικρών αλλαγών.",
+ "apihelp-feedrecentchanges-param-hidebots": "Απόκρυψη αλλαγών που έγιναν από ρομπότ.",
+ "apihelp-feedrecentchanges-param-hideanons": "Απόκρυψη αλλαγών που έγιναν από ανώνυμους χρήστες.",
+ "apihelp-feedrecentchanges-param-hideliu": "Απόκρυψη αλλαγών που έγιναν από εγγεγραμμένους χρήστες.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Απόκρυψη ελεγμένων αλλαγών.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Απόκρυψη αλλαγών που έγιναν από τον τρέχοντα χρήστη.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Φιλτράρισμα κατά ετικέτα.",
+ "apihelp-feedrecentchanges-param-target": "Εμφάνιση μόνο των αλλαγών σε σελίδες που συνδέονται με αυτή τη σελίδα.",
+ "apihelp-feedrecentchanges-example-simple": "Εμφάνιση πρόσφατων αλλαγών.",
+ "apihelp-feedrecentchanges-example-30days": "Εμφάνιση πρόσφατων αλλαγών για 30 ημέρες.",
+ "apihelp-feedwatchlist-summary": "Επιστρέφει μια ροή λίστας παρακολούθησης.",
+ "apihelp-feedwatchlist-param-feedformat": "Η μορφή της ροής.",
+ "apihelp-filerevert-param-comment": "Σχόλιο ανεβάσματος.",
+ "apihelp-help-example-recursive": "Όλη η βοήθεια σε μια σελίδα.",
+ "apihelp-imagerotate-summary": "Περιστροφή μίας ή περισσοτέρων εικόνων.",
+ "apihelp-imagerotate-param-rotation": "Μοίρες με τις οποίες να περιστραφεί η εικόνα ωρολογιακά.",
+ "apihelp-import-param-summary": "Εισαγωγή σύνοψης.",
+ "apihelp-login-param-name": "Όνομα χρήστη.",
+ "apihelp-login-param-password": "Κωδικός πρόσβασης.",
+ "apihelp-login-param-domain": "Τομέας (προαιρετικό).",
+ "apihelp-login-example-login": "Σύνδεση.",
+ "apihelp-logout-summary": "Αποσύνδεση και διαγραφή δεδομένων περιόδου λειτουργίας.",
+ "apihelp-logout-example-logout": "Αποσύνδεση του τρέχοντα χρήστη.",
+ "apihelp-move-summary": "Μετακίνηση σελίδας.",
+ "apihelp-move-param-reason": "Λόγος μετονομασίας.",
+ "apihelp-move-param-movetalk": "Μετονομασία της σελίδας συζήτησης, εάν υπάρχει.",
+ "apihelp-move-param-movesubpages": "Μετονομασία υποσελίδων, εφόσον συντρέχει περίπτωση.",
+ "apihelp-move-param-noredirect": "Να μην δημιουργηθεί ανακατεύθυνση.",
+ "apihelp-move-param-ignorewarnings": "Να αγνοηθούν τυχόν προειδοποιήσεις.",
+ "apihelp-opensearch-param-search": "Συμβολοσειρά αναζήτησης.",
+ "apihelp-opensearch-param-limit": "Μέγιστος αριθμός αποτελεσμάτων που θα επιστραφούν.",
+ "apihelp-opensearch-param-namespace": "Ονοματοχώροι προς αναζήτηση.",
+ "apihelp-opensearch-param-format": "Η μορφή των δεδομένων εξόδου.",
+ "apihelp-options-example-reset": "Επαναφορά όλων των προτιμήσεων.",
+ "apihelp-paraminfo-param-helpformat": "Μορφή των συμβολοσειρών βοήθειας.",
+ "apihelp-patrol-example-revid": "Έλεγχος αναθεώρησης.",
+ "apihelp-protect-example-protect": "Προστασία σελίδας.",
+ "apihelp-query+users-paramvalue-prop-gender": "Επισημαίνει το φύλο του χρήστη. Επιστρέφει «αρσενικό», «θηλυκό» ή «άγνωστο»",
+ "api-help-param-type-limit": "Τύπος: ακέραιος ή <kbd>max</kbd>",
+ "api-help-param-type-boolean": "Τύπος: boolean ([[Special:ApiHelp/main#main/datatypes|λεπτομέρειες]])",
+ "api-help-param-type-user": "Τύπος: {{PLURAL:$1|1=όνομα χρήστη|2=λίστα με ονόματα χρήστη}}"
+}
diff --git a/www/wiki/includes/api/i18n/en-gb.json b/www/wiki/includes/api/i18n/en-gb.json
new file mode 100644
index 00000000..777c4e87
--- /dev/null
+++ b/www/wiki/includes/api/i18n/en-gb.json
@@ -0,0 +1,154 @@
+{
+ "@metadata": {
+ "authors": [
+ "Reedy",
+ "Chase me ladies, I'm the Cavalry",
+ "Macofe"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Documentation]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n</div>\n<strong>Status:</strong> All features shown on this page should be working, but the API is still in active development, and may change at any time. Subscribe to [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] for notice of updates.\n\n<strong>Erroneous requests:</strong> When erroneous requests are sent to the API, an HTTP header will be sent with the key \"MediaWiki-API-Error\" and then both the value of the header and the error code sent back will be set to the same value. For more information see [[mw:API:Errors_and_warnings|API: Errors and warnings]].",
+ "apihelp-main-param-maxage": "Set the <code>max-age</code> HTTP cache control header to this many seconds. Errors are never cached.",
+ "apihelp-main-param-assert": "Verify the user is logged in if set to <kbd>user</kbd>, or has the bot userright if <kbd>bot</kbd>.",
+ "apihelp-block-param-user": "Username, IP address, or IP range to block.",
+ "apihelp-block-param-allowusertalk": "Allow the user to edit their own talk page (depends on <var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-watchuser": "Watch the user and talk pages of the user or IP address.",
+ "apihelp-block-example-ip-simple": "Block IP address <kbd>192.0.2.5</kbd> for three days with reason <kbd>First strike</kbd>.",
+ "apihelp-clearhasmsg-summary": "Clears the <code>hasmsg</code> flag for the current user.",
+ "apihelp-compare-summary": "Get the difference between 2 pages.",
+ "apihelp-compare-extended-description": "A revision number, a page title, or a page ID for both \"from\" and \"to\" must be passed.",
+ "apihelp-createaccount-param-password": "Password (ignored if <var>$1mailpassword</var> is set).",
+ "apihelp-delete-param-title": "Title of the page to delete. Cannot be used together with <var>$1pageid</var>.",
+ "apihelp-delete-param-watch": "Add the page to the current user's watchlist.",
+ "apihelp-delete-example-simple": "Delete <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Delete <kbd>Main Page</kbd> with the reason <kbd>Preparing for move</kbd>.",
+ "apihelp-edit-param-title": "Title of the page to edit. Cannot be used together with <var>$1pageid</var>.",
+ "apihelp-edit-param-watch": "Add the page to the current user's watchlist.",
+ "apihelp-edit-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-edit-param-contentformat": "Content serialisation format used for the input text.",
+ "apihelp-edit-example-prepend": "Prepend <kbd>_&#95;NOTOC_&#95;</kbd> to a page.",
+ "apihelp-expandtemplates-example-simple": "Expand the wikitext <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Hide changes made by the current user.",
+ "apihelp-filerevert-example-revert": "Revert <kbd>Wiki.png</kbd> to the version of <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-example-main": "Help for the main module.",
+ "apihelp-help-example-query": "Help for two query submodules.",
+ "apihelp-import-summary": "Import a page from another wiki, or an XML file.",
+ "apihelp-import-extended-description": "Note that the HTTP POST must be done as a file upload (i.e. using multipart/form-data) when sending a file for the <var>xml</var> parameter.",
+ "apihelp-login-example-gettoken": "Retrieve a login token.",
+ "apihelp-logout-example-logout": "Log the current user out.",
+ "apihelp-move-param-to": "Title to rename the page to.",
+ "apihelp-move-param-reason": "Reason for the rename.",
+ "apihelp-move-example-move": "Move <kbd>Badtitle</kbd> to <kbd>Goodtitle</kbd> without leaving a redirect.",
+ "apihelp-opensearch-example-te": "Find pages beginning with <kbd>Te</kbd>.",
+ "apihelp-options-param-resetkinds": "List of types of options to reset when the <var>$1reset</var> option is set.",
+ "apihelp-options-param-optionvalue": "A value of the option specified by <var>$1optionname</var>, can contain pipe characters.",
+ "apihelp-options-example-change": "Change <kbd>skin</kbd> and <kbd>hideminor</kbd> preferences.",
+ "apihelp-options-example-complex": "Reset all preferences, then set <kbd>skin</kbd> and <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-param-querymodules": "List of query module names (value of <var>prop</var>, <var>meta</var> or <var>list</var> parameter). Use <kbd>$1modules=query+foo</kbd> instead of <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-parse-param-pageid": "Parse the content of this page. Overrides <var>$1page</var>.",
+ "apihelp-parse-param-contentformat": "Content serialisation format used for the input text. Only valid when used with $1text.",
+ "apihelp-patrol-example-rcid": "Patrol a recent change.",
+ "apihelp-patrol-example-revid": "Patrol a revision.",
+ "apihelp-protect-param-protections": "List of protection levels, formatted <kbd>action=level</kbd> (e.g. <kbd>edit=sysop</kbd>).\n\n<strong>Note:</strong> Any actions not listed will have restrictions removed.",
+ "apihelp-protect-param-expiry": "Expiry timestamps. If only one timestamp is set, it will be used for all protections. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, or <kbd>never</kbd>, for a never-expiring protection.",
+ "apihelp-protect-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-protect-example-unprotect2": "Unprotect a page by setting no restrictions.",
+ "apihelp-purge-example-simple": "Purge the <kbd>Main Page</kbd> and the <kbd>API</kbd> page.",
+ "apihelp-query-example-allpages": "Fetch revisions of pages beginning with <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-example-generator": "Retrieve information about the category page for categories beginning <kbd>List</kbd>.",
+ "apihelp-query+allfileusages-example-unique-generator": "Gets all file titles, marking the missing ones.",
+ "apihelp-query+alllinks-param-unique": "Only show distinct linked titles. Cannot be used with <kbd>$1prop=ids</kbd>.\nWhen used as a generator, yields target pages instead of source pages.",
+ "apihelp-query+alllinks-example-B": "List linked titles, including missing ones, with page IDs they are from, starting at <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "List unique linked titles.",
+ "apihelp-query+allmessages-example-ipb": "Show messages starting with <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Show messages <kbd>august</kbd> and <kbd>mainpage</kbd> in German.",
+ "apihelp-query+allpages-example-B": "Show a list of pages starting at the letter <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-B": "List target pages, including missing ones, with page IDs they are from, starting at <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-generator": "Gets pages containing the redirects.",
+ "apihelp-query+alltransclusions-example-unique": "List unique transcluded titles.",
+ "apihelp-query+alltransclusions-example-generator": "Gets pages containing the transclusions.",
+ "apihelp-query+backlinks-param-pageid": "Page ID to search. Cannot be used together with <var>$1title</var>.",
+ "apihelp-query+backlinks-param-limit": "How many total pages to return. If <var>$1redirect</var> is enabled, limit applies to each level separately (which means up to 2 * <var>$1limit</var> results may be returned).",
+ "apihelp-query+backlinks-example-generator": "Get information about pages linking to <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-param-ip": "Get all blocks applying to this IP or CIDR range, including range blocks.\nThis cannot be used together with <var>$3users</var>. CIDR ranges broader than IPv4/$1 or IPv6/$2 will not be not accepted.",
+ "apihelp-query+blocks-example-simple": "List blocks.",
+ "apihelp-query+blocks-example-users": "List blocks of users <kbd>Alice</kbd> and <kbd>Bob</kbd>.",
+ "apihelp-query+categoryinfo-example-simple": "Get information about <kbd>Category:Foo</kbd> and <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-param-pageid": "Page ID of the category to enumerate. Cannot be used together with <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Sortkey to end listing from, as returned by <kbd>$1prop=sortkey</kbd>. Can only be used with <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+deletedrevs-example-mode2": "List the last 50 deleted contributions by <kbd>Bob</kbd> (mode 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "List the first 50 deleted revisions in the main namespace (mode 3).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "List the first 50 deleted pages in the {{ns:talk}} namespace (mode 3).",
+ "apihelp-query+duplicatefiles-example-simple": "Look for duplicates of [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Look for duplicates of all files.",
+ "apihelp-query+extlinks-example-simple": "Get a list of external links on <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-param-protocol": "Protocol of the URL. If empty and <var>$1query</var> set, the protocol is <kbd>http</kbd>. Leave both this and <var>$1query</var> empty to list all external links.",
+ "apihelp-query+filerepoinfo-example-simple": "Get information about file repositories.",
+ "apihelp-query+imageinfo-param-metadataversion": "Version of metadata to use. If <kbd>latest</kbd> is specified, use latest version. Defaults to <kbd>1</kbd> for backwards compatibility.",
+ "apihelp-query+imageinfo-example-simple": "Fetch information about the current version of [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+images-example-simple": "Get a list of files used in the [[Main Page]].",
+ "apihelp-query+imageusage-example-simple": "Show pages using [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Get information about pages using [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-example-protection": "Get general and protection information about the page <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-example-simple": "Get pages linking to [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-param-title": "Interwiki link to search for. Must be used with <var>$1prefix</var>.",
+ "apihelp-query+langbacklinks-example-simple": "Get pages linking to [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Get information about pages linking to [[:fr:Test]].",
+ "apihelp-query+langlinks-param-title": "Link to search for. Must be used with <var>$1lang</var>.",
+ "apihelp-query+links-example-simple": "Get links from the page <kbd>Main Page</kbd>",
+ "apihelp-query+links-example-namespaces": "Get links from the page <kbd>Main Page</kbd> in the {{ns:user}} and {{ns:template}} namespaces.",
+ "apihelp-query+linkshere-example-simple": "Get a list of pages linking to the [[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "Get information about pages linking to the [[Main Page]].",
+ "apihelp-query+logevents-example-simple": "List recent log events.",
+ "apihelp-query+pagepropnames-summary": "List all page property names in use on the wiki.",
+ "apihelp-query+pageswithprop-summary": "List all pages using a given page property.",
+ "apihelp-query+pageswithprop-example-generator": "Get page information about the first 10 pages using <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+protectedtitles-example-generator": "Find links to protected titles in the main namespace.",
+ "apihelp-query+random-example-simple": "Return two random pages from the main namespace.",
+ "apihelp-query+random-example-generator": "Return page info about two random pages from the main namespace.",
+ "apihelp-query+recentchanges-example-simple": "List recent changes.",
+ "apihelp-query+redirects-example-generator": "Get information about all redirects to the [[Main Page]].",
+ "apihelp-query+revisions-example-first5-not-localhost": "Get first 5 revisions of the <kbd>Main Page</kbd> that were not made by anonymous user <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions+base-param-difftotext": "Text to diff each revision to. Only diffs a limited number of revisions. Overrides <var>$1diffto</var>. If <var>$1section</var> is set, only that section will be diffed against this text",
+ "apihelp-query+revisions+base-param-contentformat": "Serialisation format used for <var>$1difftotext</var> and expected for output of content.",
+ "apihelp-query+search-example-text": "Search texts for <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-generator": "Get page information about the pages returned for a search for <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-example-simple": "Fetch site information.",
+ "apihelp-query+siteinfo-example-replag": "Check the current replication lag.",
+ "apihelp-query+stashimageinfo-example-simple": "Returns information for a stashed file.",
+ "apihelp-query+tags-example-simple": "List available tags.",
+ "apihelp-query+templates-example-simple": "Get the templates used on the page <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-generator": "Get information about the template pages used on <kbd>Main Page</kbd>.",
+ "apihelp-query+tokens-example-simple": "Retrieve a csrf token (the default).",
+ "apihelp-query+tokens-example-types": "Retrieve a watch token and a patrol token.",
+ "apihelp-query+transcludedin-example-simple": "Get a list of pages transcluding <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Get information about pages transcluding <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-example-user": "Show contributions of user <kbd>Example</kbd>.",
+ "apihelp-query+userinfo-example-simple": "Get information about the current user.",
+ "apihelp-query+watchlist-example-simple": "List the top revision for recently changed pages on the watchlist of the current user.",
+ "apihelp-query+watchlist-example-generator": "Fetch page info for recently changed pages on the current user's watchlist.",
+ "apihelp-query+watchlistraw-summary": "Get all pages on the current user's watchlist.",
+ "apihelp-query+watchlistraw-example-simple": "List pages on the watchlist of the current user.",
+ "apihelp-query+watchlistraw-example-generator": "Fetch page info for pages on the current user's watchlist.",
+ "apihelp-revisiondelete-example-revision": "Hide content for revision <kbd>12345</kbd> on the page <kbd>Main Page</kbd>.",
+ "apihelp-rollback-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-rollback-example-simple": "Roll back the last edits to page <kbd>Main Page</kbd> by user <kbd>Example</kbd>.",
+ "apihelp-setnotificationtimestamp-example-allpages": "Reset the notification status for pages in the <kbd>{{ns:user}}</kbd> namespace.",
+ "apihelp-unblock-param-id": "ID of the block to unblock (obtained through <kbd>list=blocks</kbd>). Cannot be used together with <var>$1user</var>.",
+ "apihelp-undelete-param-timestamps": "Timestamps of the revisions to restore. If both <var>$1timestamps</var> and <var>$1fileids</var> are empty, all will be restored.",
+ "apihelp-undelete-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-undelete-example-page": "Undelete page <kbd>Main Page</kbd>.",
+ "apihelp-undelete-example-revisions": "Undelete two revisions of page <kbd>Main Page</kbd>.",
+ "apihelp-upload-param-comment": "Upload comment. Also used as the initial page text for new files if <var>$1text</var> is not specified.",
+ "apihelp-upload-example-url": "Upload from a URL.",
+ "apihelp-upload-example-filekey": "Complete an upload that failed due to warnings.",
+ "apihelp-userrights-example-userid": "Add the user with ID <kbd>123</kbd> to group <kbd>bot</kbd>, and remove from groups <kbd>sysop</kbd> and <kbd>bureaucrat</kbd>.",
+ "apihelp-watch-param-title": "The page to (un)watch. Use <var>$1titles</var> instead.",
+ "apihelp-watch-example-unwatch": "Unwatch the page <kbd>Main Page</kbd>.",
+ "apihelp-php-summary": "Output data in serialised PHP format.",
+ "apihelp-phpfm-summary": "Output data in serialised PHP format (pretty-print in HTML).",
+ "api-pageset-param-redirects-generator": "Automatically resolve redirects in <var>$1titles</var>, <var>$1pageids</var>, and <var>$1revids</var>, and in pages returned by <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Automatically resolve redirects in <var>$1titles</var>, <var>$1pageids</var>, and <var>$1revids</var>.",
+ "api-help-param-multi-separate": "Separate values with <kbd>|</kbd>.",
+ "api-help-param-disabled-in-miser-mode": "Disabled due to [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]].",
+ "api-help-param-limited-in-miser-mode": "<strong>Note:</strong> Due to [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]], using this may result in fewer than <var>$1limit</var> results returned before continuing; in extreme cases, zero results may be returned."
+}
diff --git a/www/wiki/includes/api/i18n/en.json b/www/wiki/includes/api/i18n/en.json
new file mode 100644
index 00000000..dbd54514
--- /dev/null
+++ b/www/wiki/includes/api/i18n/en.json
@@ -0,0 +1,1875 @@
+{
+ "@metadata": {
+ "authors": [
+ "Anomie",
+ "Siebrand"
+ ]
+ },
+
+ "apihelp-main-summary": "",
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n</div>\n<strong>Status:</strong> All features shown on this page should be working, but the API is still in active development, and may change at any time. Subscribe to [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] for notice of updates.\n\n<strong>Erroneous requests:</strong> When erroneous requests are sent to the API, an HTTP header will be sent with the key \"MediaWiki-API-Error\" and then both the value of the header and the error code sent back will be set to the same value. For more information see [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n<strong>Testing:</strong> For ease of testing API requests, see [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Which action to perform.",
+ "apihelp-main-param-format": "The format of the output.",
+ "apihelp-main-param-maxlag": "Maximum lag can be used when MediaWiki is installed on a database replicated cluster. To save actions causing any more site replication lag, this parameter can make the client wait until the replication lag is less than the specified value. In case of excessive lag, error code <samp>maxlag</samp> is returned with a message like <samp>Waiting for $host: $lag seconds lagged</samp>.<br />See [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Maxlag parameter]] for more information.",
+ "apihelp-main-param-smaxage": "Set the <code>s-maxage</code> HTTP cache control header to this many seconds. Errors are never cached.",
+ "apihelp-main-param-maxage": "Set the <code>max-age</code> HTTP cache control header to this many seconds. Errors are never cached.",
+ "apihelp-main-param-assert": "Verify the user is logged in if set to <kbd>user</kbd>, or has the bot user right if <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Verify the current user is the named user.",
+ "apihelp-main-param-requestid": "Any value given here will be included in the response. May be used to distinguish requests.",
+ "apihelp-main-param-servedby": "Include the hostname that served the request in the results.",
+ "apihelp-main-param-curtimestamp": "Include the current timestamp in the result.",
+ "apihelp-main-param-responselanginfo": "Include the languages used for <var>uselang</var> and <var>errorlang</var> in the result.",
+ "apihelp-main-param-origin": "When accessing the API using a cross-domain AJAX request (CORS), set this to the originating domain. This must be included in any pre-flight request, and therefore must be part of the request URI (not the POST body).\n\nFor authenticated requests, this must match one of the origins in the <code>Origin</code> header exactly, so it has to be set to something like <kbd>https://en.wikipedia.org</kbd> or <kbd>https://meta.wikimedia.org</kbd>. If this parameter does not match the <code>Origin</code> header, a 403 response will be returned. If this parameter matches the <code>Origin</code> header and the origin is whitelisted, the <code>Access-Control-Allow-Origin</code> and <code>Access-Control-Allow-Credentials</code> headers will be set.\n\nFor non-authenticated requests, specify the value <kbd>*</kbd>. This will cause the <code>Access-Control-Allow-Origin</code> header to be set, but <code>Access-Control-Allow-Credentials</code> will be <code>false</code> and all user-specific data will be restricted.",
+ "apihelp-main-param-uselang": "Language to use for message translations. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> with <kbd>siprop=languages</kbd> returns a list of language codes, or specify <kbd>user</kbd> to use the current user's language preference, or specify <kbd>content</kbd> to use this wiki's content language.",
+ "apihelp-main-param-errorformat": "Format to use for warning and error text output.\n; plaintext: Wikitext with HTML tags removed and entities replaced.\n; wikitext: Unparsed wikitext.\n; html: HTML.\n; raw: Message key and parameters.\n; none: No text output, only the error codes.\n; bc: Format used prior to MediaWiki 1.29. <var>errorlang</var> and <var>errorsuselocal</var> are ignored.",
+ "apihelp-main-param-errorlang": "Language to use for warnings and errors. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> with <kbd>siprop=languages</kbd> returns a list of language codes, or specify <kbd>content</kbd> to use this wiki's content language, or specify <kbd>uselang</kbd> to use the same value as the <var>uselang</var> parameter.",
+ "apihelp-main-param-errorsuselocal": "If given, error texts will use locally-customized messages from the {{ns:MediaWiki}} namespace.",
+
+ "apihelp-block-summary": "Block a user.",
+ "apihelp-block-param-user": "Username, IP address, or IP address range to block. Cannot be used together with <var>$1userid</var>",
+ "apihelp-block-param-userid": "User ID to block. Cannot be used together with <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Expiry time. May be relative (e.g. <kbd>5 months</kbd> or <kbd>2 weeks</kbd>) or absolute (e.g. <kbd>2014-09-18T12:34:56Z</kbd>). If set to <kbd>infinite</kbd>, <kbd>indefinite</kbd>, or <kbd>never</kbd>, the block will never expire.",
+ "apihelp-block-param-reason": "Reason for block.",
+ "apihelp-block-param-anononly": "Block anonymous users only (i.e. disable anonymous edits for this IP address).",
+ "apihelp-block-param-nocreate": "Prevent account creation.",
+ "apihelp-block-param-autoblock": "Automatically block the last used IP address, and any subsequent IP addresses they try to login from.",
+ "apihelp-block-param-noemail": "Prevent user from sending email through the wiki. (Requires the <code>blockemail</code> right).",
+ "apihelp-block-param-hidename": "Hide the username from the block log. (Requires the <code>hideuser</code> right).",
+ "apihelp-block-param-allowusertalk": "Allow the user to edit their own talk page (depends on <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "If the user is already blocked, overwrite the existing block.",
+ "apihelp-block-param-watchuser": "Watch the user's or IP address's user and talk pages.",
+ "apihelp-block-param-tags": "Change tags to apply to the entry in the block log.",
+ "apihelp-block-example-ip-simple": "Block IP address <kbd>192.0.2.5</kbd> for three days with reason <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Block user <kbd>Vandal</kbd> indefinitely with reason <kbd>Vandalism</kbd>, and prevent new account creation and email sending.",
+
+ "apihelp-changeauthenticationdata-summary": "Change authentication data for the current user.",
+ "apihelp-changeauthenticationdata-example-password": "Attempt to change the current user's password to <kbd>ExamplePassword</kbd>.",
+
+ "apihelp-checktoken-summary": "Check the validity of a token from <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Type of token being tested.",
+ "apihelp-checktoken-param-token": "Token to test.",
+ "apihelp-checktoken-param-maxtokenage": "Maximum allowed age of the token, in seconds.",
+ "apihelp-checktoken-example-simple": "Test the validity of a <kbd>csrf</kbd> token.",
+
+ "apihelp-clearhasmsg-summary": "Clears the <code>hasmsg</code> flag for the current user.",
+ "apihelp-clearhasmsg-example-1": "Clear the <code>hasmsg</code> flag for the current user.",
+
+ "apihelp-clientlogin-summary": "Log in to the wiki using the interactive flow.",
+ "apihelp-clientlogin-example-login": "Start the process of logging in to the wiki as user <kbd>Example</kbd> with password <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continue logging in after a <samp>UI</samp> response for two-factor auth, supplying an <var>OATHToken</var> of <kbd>987654</kbd>.",
+
+ "apihelp-compare-summary": "Get the difference between two pages.",
+ "apihelp-compare-extended-description": "A revision number, a page title, a page ID, text, or a relative reference for both \"from\" and \"to\" must be passed.",
+ "apihelp-compare-param-fromtitle": "First title to compare.",
+ "apihelp-compare-param-fromid": "First page ID to compare.",
+ "apihelp-compare-param-fromrev": "First revision to compare.",
+ "apihelp-compare-param-fromtext": "Use this text instead of the content of the revision specified by <var>fromtitle</var>, <var>fromid</var> or <var>fromrev</var>.",
+ "apihelp-compare-param-frompst": "Do a pre-save transform on <var>fromtext</var>.",
+ "apihelp-compare-param-fromcontentmodel": "Content model of <var>fromtext</var>. If not supplied, it will be guessed based on the other parameters.",
+ "apihelp-compare-param-fromcontentformat": "Content serialization format of <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "Second title to compare.",
+ "apihelp-compare-param-toid": "Second page ID to compare.",
+ "apihelp-compare-param-torev": "Second revision to compare.",
+ "apihelp-compare-param-torelative": "Use a revision relative to the revision determined from <var>fromtitle</var>, <var>fromid</var> or <var>fromrev</var>. All of the other 'to' options will be ignored.",
+ "apihelp-compare-param-totext": "Use this text instead of the content of the revision specified by <var>totitle</var>, <var>toid</var> or <var>torev</var>.",
+ "apihelp-compare-param-topst": "Do a pre-save transform on <var>totext</var>.",
+ "apihelp-compare-param-tocontentmodel": "Content model of <var>totext</var>. If not supplied, it will be guessed based on the other parameters.",
+ "apihelp-compare-param-tocontentformat": "Content serialization format of <var>totext</var>.",
+ "apihelp-compare-param-prop": "Which pieces of information to get.",
+ "apihelp-compare-paramvalue-prop-diff": "The diff HTML.",
+ "apihelp-compare-paramvalue-prop-diffsize": "The size of the diff HTML, in bytes.",
+ "apihelp-compare-paramvalue-prop-rel": "The revision IDs of the revision previous to 'from' and after 'to', if any.",
+ "apihelp-compare-paramvalue-prop-ids": "The page and revision IDs of the 'from' and 'to' revisions.",
+ "apihelp-compare-paramvalue-prop-title": "The page titles of the 'from' and 'to' revisions.",
+ "apihelp-compare-paramvalue-prop-user": "The user name and ID of the 'from' and 'to' revisions.",
+ "apihelp-compare-paramvalue-prop-comment": "The comment on the 'from' and 'to' revisions.",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "The parsed comment on the 'from' and 'to' revisions.",
+ "apihelp-compare-paramvalue-prop-size": "The size of the 'from' and 'to' revisions.",
+ "apihelp-compare-example-1": "Create a diff between revision 1 and 2.",
+
+ "apihelp-createaccount-summary": "Create a new user account.",
+ "apihelp-createaccount-param-preservestate": "If <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> returned true for <samp>hasprimarypreservedstate</samp>, requests marked as <samp>primary-required</samp> should be omitted. If it returned a non-empty value for <samp>preservedusername</samp>, that username must be used for the <var>username</var> parameter.",
+ "apihelp-createaccount-example-create": "Start the process of creating user <kbd>Example</kbd> with password <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Username.",
+ "apihelp-createaccount-param-password": "Password (ignored if <var>$1mailpassword</var> is set).",
+ "apihelp-createaccount-param-domain": "Domain for external authentication (optional).",
+ "apihelp-createaccount-param-token": "Account creation token obtained in first request.",
+ "apihelp-createaccount-param-email": "Email address of user (optional).",
+ "apihelp-createaccount-param-realname": "Real name of user (optional).",
+ "apihelp-createaccount-param-mailpassword": "If set to any value, a random password will be emailed to the user.",
+ "apihelp-createaccount-param-reason": "Optional reason for creating the account to be put in the logs.",
+ "apihelp-createaccount-param-language": "Language code to set as default for the user (optional, defaults to content language).",
+ "apihelp-createaccount-example-pass": "Create user <kbd>testuser</kbd> with password <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Create user <kbd>testmailuser</kbd> and email a randomly-generated password.",
+
+ "apihelp-cspreport-summary": "Used by browsers to report violations of the Content Security Policy. This module should never be used, except when used automatically by a CSP compliant web browser.",
+ "apihelp-cspreport-param-reportonly": "Mark as being a report from a monitoring policy, not an enforced policy",
+ "apihelp-cspreport-param-source": "What generated the CSP header that triggered this report",
+
+ "apihelp-delete-summary": "Delete a page.",
+ "apihelp-delete-param-title": "Title of the page to delete. Cannot be used together with <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Page ID of the page to delete. Cannot be used together with <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Reason for the deletion. If not set, an automatically generated reason will be used.",
+ "apihelp-delete-param-tags": "Change tags to apply to the entry in the deletion log.",
+ "apihelp-delete-param-watch": "Add the page to the current user's watchlist.",
+ "apihelp-delete-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-delete-param-unwatch": "Remove the page from the current user's watchlist.",
+ "apihelp-delete-param-oldimage": "The name of the old image to delete as provided by [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Delete <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Delete <kbd>Main Page</kbd> with the reason <kbd>Preparing for move</kbd>.",
+
+ "apihelp-disabled-summary": "This module has been disabled.",
+
+ "apihelp-edit-summary": "Create and edit pages.",
+ "apihelp-edit-param-title": "Title of the page to edit. Cannot be used together with <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Page ID of the page to edit. Cannot be used together with <var>$1title</var>.",
+ "apihelp-edit-param-section": "Section number. <kbd>0</kbd> for the top section, <kbd>new</kbd> for a new section.",
+ "apihelp-edit-param-sectiontitle": "The title for a new section.",
+ "apihelp-edit-param-text": "Page content.",
+ "apihelp-edit-param-summary": "Edit summary. Also section title when $1section=new and $1sectiontitle is not set.",
+ "apihelp-edit-param-tags": "Change tags to apply to the revision.",
+ "apihelp-edit-param-minor": "Minor edit.",
+ "apihelp-edit-param-notminor": "Non-minor edit.",
+ "apihelp-edit-param-bot": "Mark this edit as a bot edit.",
+ "apihelp-edit-param-basetimestamp": "Timestamp of the base revision, used to detect edit conflicts. May be obtained through [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Timestamp when the editing process began, used to detect edit conflicts. An appropriate value may be obtained using <var>[[Special:ApiHelp/main|curtimestamp]]</var> when beginning the edit process (e.g. when loading the page content to edit).",
+ "apihelp-edit-param-recreate": "Override any errors about the page having been deleted in the meantime.",
+ "apihelp-edit-param-createonly": "Don't edit the page if it exists already.",
+ "apihelp-edit-param-nocreate": "Throw an error if the page doesn't exist.",
+ "apihelp-edit-param-watch": "Add the page to the current user's watchlist.",
+ "apihelp-edit-param-unwatch": "Remove the page from the current user's watchlist.",
+ "apihelp-edit-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-edit-param-md5": "The MD5 hash of the $1text parameter, or the $1prependtext and $1appendtext parameters concatenated. If set, the edit won't be done unless the hash is correct.",
+ "apihelp-edit-param-prependtext": "Add this text to the beginning of the page. Overrides $1text.",
+ "apihelp-edit-param-appendtext": "Add this text to the end of the page. Overrides $1text.\n\nUse $1section=new to append a new section, rather than this parameter.",
+ "apihelp-edit-param-undo": "Undo this revision. Overrides $1text, $1prependtext and $1appendtext.",
+ "apihelp-edit-param-undoafter": "Undo all revisions from $1undo to this one. If not set, just undo one revision.",
+ "apihelp-edit-param-redirect": "Automatically resolve redirects.",
+ "apihelp-edit-param-contentformat": "Content serialization format used for the input text.",
+ "apihelp-edit-param-contentmodel": "Content model of the new content.",
+ "apihelp-edit-param-token": "The token should always be sent as the last parameter, or at least after the $1text parameter.",
+ "apihelp-edit-example-edit": "Edit a page.",
+ "apihelp-edit-example-prepend": "Prepend <kbd>_&#95;NOTOC_&#95;</kbd> to a page.",
+ "apihelp-edit-example-undo": "Undo revisions 13579 through 13585 with autosummary.",
+
+ "apihelp-emailuser-summary": "Email a user.",
+ "apihelp-emailuser-param-target": "User to send email to.",
+ "apihelp-emailuser-param-subject": "Subject header.",
+ "apihelp-emailuser-param-text": "Mail body.",
+ "apihelp-emailuser-param-ccme": "Send a copy of this mail to me.",
+ "apihelp-emailuser-example-email": "Send an email to user <kbd>WikiSysop</kbd> with the text <kbd>Content</kbd>.",
+
+ "apihelp-expandtemplates-summary": "Expands all templates within wikitext.",
+ "apihelp-expandtemplates-param-title": "Title of page.",
+ "apihelp-expandtemplates-param-text": "Wikitext to convert.",
+ "apihelp-expandtemplates-param-revid": "Revision ID, for <code><nowiki>{{REVISIONID}}</nowiki></code> and similar variables.",
+ "apihelp-expandtemplates-param-prop": "Which pieces of information to get.\n\nNote that if no values are selected, the result will contain the wikitext, but the output will be in a deprecated format.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "The expanded wikitext.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Any categories present in the input that are not represented in the wikitext output.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Page properties defined by expanded magic words in the wikitext.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Whether the output is volatile and should not be reused elsewhere within the page.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "The maximum time after which caches of the result should be invalidated.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Any ResourceLoader modules that parser functions have requested be added to the output. Either <kbd>jsconfigvars</kbd> or <kbd>encodedjsconfigvars</kbd> must be requested jointly with <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Gives the JavaScript configuration variables specific to the page.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Gives the JavaScript configuration variables specific to the page as a JSON string.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "The XML parse tree of the input.",
+ "apihelp-expandtemplates-param-includecomments": "Whether to include HTML comments in the output.",
+ "apihelp-expandtemplates-param-generatexml": "Generate XML parse tree (replaced by $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Expand the wikitext <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+
+ "apihelp-feedcontributions-summary": "Returns a user contributions feed.",
+ "apihelp-feedcontributions-param-feedformat": "The format of the feed.",
+ "apihelp-feedcontributions-param-user": "What users to get the contributions for.",
+ "apihelp-feedcontributions-param-namespace": "Which namespace to filter the contributions by.",
+ "apihelp-feedcontributions-param-year": "From year (and earlier).",
+ "apihelp-feedcontributions-param-month": "From month (and earlier).",
+ "apihelp-feedcontributions-param-tagfilter": "Filter contributions that have these tags.",
+ "apihelp-feedcontributions-param-deletedonly": "Show only deleted contributions.",
+ "apihelp-feedcontributions-param-toponly": "Only show edits that are the latest revisions.",
+ "apihelp-feedcontributions-param-newonly": "Only show edits that are page creations.",
+ "apihelp-feedcontributions-param-hideminor": "Hide minor edits.",
+ "apihelp-feedcontributions-param-showsizediff": "Show the size difference between revisions.",
+ "apihelp-feedcontributions-example-simple": "Return contributions for user <kbd>Example</kbd>.",
+
+ "apihelp-feedrecentchanges-summary": "Returns a recent changes feed.",
+ "apihelp-feedrecentchanges-param-feedformat": "The format of the feed.",
+ "apihelp-feedrecentchanges-param-namespace": "Namespace to limit the results to.",
+ "apihelp-feedrecentchanges-param-invert": "All namespaces but the selected one.",
+ "apihelp-feedrecentchanges-param-associated": "Include associated (talk or main) namespace.",
+ "apihelp-feedrecentchanges-param-days": "Days to limit the results to.",
+ "apihelp-feedrecentchanges-param-limit": "Maximum number of results to return.",
+ "apihelp-feedrecentchanges-param-from": "Show changes since then.",
+ "apihelp-feedrecentchanges-param-hideminor": "Hide minor changes.",
+ "apihelp-feedrecentchanges-param-hidebots": "Hide changes made by bots.",
+ "apihelp-feedrecentchanges-param-hideanons": "Hide changes made by anonymous users.",
+ "apihelp-feedrecentchanges-param-hideliu": "Hide changes made by registered users.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Hide patrolled changes.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Hide changes made by the current user.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Hide category membership changes.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filter by tag.",
+ "apihelp-feedrecentchanges-param-target": "Show only changes on pages linked from this page.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Show changes on pages linked to the selected page instead.",
+ "apihelp-feedrecentchanges-param-categories": "Show only changes on pages in all of these categories.",
+ "apihelp-feedrecentchanges-param-categories_any": "Show only changes on pages in any of the categories instead.",
+ "apihelp-feedrecentchanges-example-simple": "Show recent changes.",
+ "apihelp-feedrecentchanges-example-30days": "Show recent changes for 30 days.",
+
+ "apihelp-feedwatchlist-summary": "Returns a watchlist feed.",
+ "apihelp-feedwatchlist-param-feedformat": "The format of the feed.",
+ "apihelp-feedwatchlist-param-hours": "List pages modified within this many hours from now.",
+ "apihelp-feedwatchlist-param-linktosections": "Link directly to changed sections if possible.",
+ "apihelp-feedwatchlist-example-default": "Show the watchlist feed.",
+ "apihelp-feedwatchlist-example-all6hrs": "Show all changes to watched pages in the past 6 hours.",
+
+ "apihelp-filerevert-summary": "Revert a file to an old version.",
+ "apihelp-filerevert-param-filename": "Target filename, without the File: prefix.",
+ "apihelp-filerevert-param-comment": "Upload comment.",
+ "apihelp-filerevert-param-archivename": "Archive name of the revision to revert to.",
+ "apihelp-filerevert-example-revert": "Revert <kbd>Wiki.png</kbd> to the version of <kbd>2011-03-05T15:27:40Z</kbd>.",
+
+ "apihelp-help-summary": "Display help for the specified modules.",
+ "apihelp-help-param-modules": "Modules to display help for (values of the <var>action</var> and <var>format</var> parameters, or <kbd>main</kbd>). Can specify submodules with a <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Include help for submodules of the named module.",
+ "apihelp-help-param-recursivesubmodules": "Include help for submodules recursively.",
+ "apihelp-help-param-helpformat": "Format of the help output.",
+ "apihelp-help-param-wrap": "Wrap the output in a standard API response structure.",
+ "apihelp-help-param-toc": "Include a table of contents in the HTML output.",
+ "apihelp-help-example-main": "Help for the main module.",
+ "apihelp-help-example-submodules": "Help for <kbd>action=query</kbd> and all its submodules.",
+ "apihelp-help-example-recursive": "All help in one page.",
+ "apihelp-help-example-help": "Help for the help module itself.",
+ "apihelp-help-example-query": "Help for two query submodules.",
+
+ "apihelp-imagerotate-summary": "Rotate one or more images.",
+ "apihelp-imagerotate-param-rotation": "Degrees to rotate image clockwise.",
+ "apihelp-imagerotate-param-tags": "Tags to apply to the entry in the upload log.",
+ "apihelp-imagerotate-example-simple": "Rotate <kbd>File:Example.png</kbd> by <kbd>90</kbd> degrees.",
+ "apihelp-imagerotate-example-generator": "Rotate all images in <kbd>Category:Flip</kbd> by <kbd>180</kbd> degrees.",
+
+ "apihelp-import-summary": "Import a page from another wiki, or from an XML file.",
+ "apihelp-import-extended-description": "Note that the HTTP POST must be done as a file upload (i.e. using multipart/form-data) when sending a file for the <var>xml</var> parameter.",
+ "apihelp-import-param-summary": "Log entry import summary.",
+ "apihelp-import-param-xml": "Uploaded XML file.",
+ "apihelp-import-param-interwikisource": "For interwiki imports: wiki to import from.",
+ "apihelp-import-param-interwikipage": "For interwiki imports: page to import.",
+ "apihelp-import-param-fullhistory": "For interwiki imports: import the full history, not just the current version.",
+ "apihelp-import-param-templates": "For interwiki imports: import all included templates as well.",
+ "apihelp-import-param-namespace": "Import to this namespace. Cannot be used together with <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Import as subpage of this page. Cannot be used together with <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Change tags to apply to the entry in the import log and to the null revision on the imported pages.",
+ "apihelp-import-example-import": "Import [[meta:Help:ParserFunctions]] to namespace 100 with full history.",
+
+ "apihelp-linkaccount-summary": "Link an account from a third-party provider to the current user.",
+ "apihelp-linkaccount-example-link": "Start the process of linking to an account from <kbd>Example</kbd>.",
+
+ "apihelp-login-summary": "Log in and get authentication cookies.",
+ "apihelp-login-extended-description": "This action should only be used in combination with [[Special:BotPasswords]]; use for main-account login is deprecated and may fail without warning. To safely log in to the main account, use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "This action is deprecated and may fail without warning. To safely log in, use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "User name.",
+ "apihelp-login-param-password": "Password.",
+ "apihelp-login-param-domain": "Domain (optional).",
+ "apihelp-login-param-token": "Login token obtained in first request.",
+ "apihelp-login-example-gettoken": "Retrieve a login token.",
+ "apihelp-login-example-login": "Log in.",
+
+ "apihelp-logout-summary": "Log out and clear session data.",
+ "apihelp-logout-example-logout": "Log the current user out.",
+
+ "apihelp-managetags-summary": "Perform management tasks relating to change tags.",
+ "apihelp-managetags-param-operation": "Which operation to perform:\n;create:Create a new change tag for manual use.\n;delete:Remove a change tag from the database, including removing the tag from all revisions, recent change entries and log entries on which it is used.\n;activate:Activate a change tag, allowing users to apply it manually.\n;deactivate:Deactivate a change tag, preventing users from applying it manually.",
+ "apihelp-managetags-param-tag": "Tag to create, delete, activate or deactivate. For tag creation, the tag must not exist. For tag deletion, the tag must exist. For tag activation, the tag must exist and not be in use by an extension. For tag deactivation, the tag must be currently active and manually defined.",
+ "apihelp-managetags-param-reason": "An optional reason for creating, deleting, activating or deactivating the tag.",
+ "apihelp-managetags-param-ignorewarnings": "Whether to ignore any warnings that are issued during the operation.",
+ "apihelp-managetags-param-tags": "Change tags to apply to the entry in the tag management log.",
+ "apihelp-managetags-example-create": "Create a tag named <kbd>spam</kbd> with the reason <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Delete the <kbd>vandlaism</kbd> tag with the reason <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Activate a tag named <kbd>spam</kbd> with the reason <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Deactivate a tag named <kbd>spam</kbd> with the reason <kbd>No longer required</kbd>",
+
+ "apihelp-mergehistory-summary": "Merge page histories.",
+ "apihelp-mergehistory-param-from": "Title of the page from which history will be merged. Cannot be used together with <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "Page ID of the page from which history will be merged. Cannot be used together with <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Title of the page to which history will be merged. Cannot be used together with <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "Page ID of the page to which history will be merged. Cannot be used together with <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "Timestamp up to which revisions will be moved from the source page's history to the destination page's history. If omitted, the entire page history of the source page will be merged into the destination page.",
+ "apihelp-mergehistory-param-reason": "Reason for the history merge.",
+ "apihelp-mergehistory-example-merge": "Merge the entire history of <kbd>Oldpage</kbd> into <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Merge the page revisions of <kbd>Oldpage</kbd> dating up to <kbd>2015-12-31T04:37:41Z</kbd> into <kbd>Newpage</kbd>.",
+
+ "apihelp-move-summary": "Move a page.",
+ "apihelp-move-param-from": "Title of the page to rename. Cannot be used together with <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "Page ID of the page to rename. Cannot be used together with <var>$1from</var>.",
+ "apihelp-move-param-to": "Title to rename the page to.",
+ "apihelp-move-param-reason": "Reason for the rename.",
+ "apihelp-move-param-movetalk": "Rename the talk page, if it exists.",
+ "apihelp-move-param-movesubpages": "Rename subpages, if applicable.",
+ "apihelp-move-param-noredirect": "Don't create a redirect.",
+ "apihelp-move-param-watch": "Add the page and the redirect to the current user's watchlist.",
+ "apihelp-move-param-unwatch": "Remove the page and the redirect from the current user's watchlist.",
+ "apihelp-move-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-move-param-ignorewarnings": "Ignore any warnings.",
+ "apihelp-move-param-tags": "Change tags to apply to the entry in the move log and to the null revision on the destination page.",
+ "apihelp-move-example-move": "Move <kbd>Badtitle</kbd> to <kbd>Goodtitle</kbd> without leaving a redirect.",
+
+ "apihelp-opensearch-summary": "Search the wiki using the OpenSearch protocol.",
+ "apihelp-opensearch-param-search": "Search string.",
+ "apihelp-opensearch-param-limit": "Maximum number of results to return.",
+ "apihelp-opensearch-param-namespace": "Namespaces to search.",
+ "apihelp-opensearch-param-suggest": "Do nothing if <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> is false.",
+ "apihelp-opensearch-param-redirects": "How to handle redirects:\n;return:Return the redirect itself.\n;resolve:Return the target page. May return fewer than $1limit results.\nFor historical reasons, the default is \"return\" for $1format=json and \"resolve\" for other formats.",
+ "apihelp-opensearch-param-format": "The format of the output.",
+ "apihelp-opensearch-param-warningsaserror": "If warnings are raised with <kbd>format=json</kbd>, return an API error instead of ignoring them.",
+ "apihelp-opensearch-example-te": "Find pages beginning with <kbd>Te</kbd>.",
+
+ "apihelp-options-summary": "Change preferences of the current user.",
+ "apihelp-options-extended-description": "Only options which are registered in core or in one of installed extensions, or options with keys prefixed with <code>userjs-</code> (intended to be used by user scripts), can be set.",
+ "apihelp-options-param-reset": "Resets preferences to the site defaults.",
+ "apihelp-options-param-resetkinds": "List of types of options to reset when the <var>$1reset</var> option is set.",
+ "apihelp-options-param-change": "List of changes, formatted name=value (e.g. skin=vector). If no value is given (not even an equals sign), e.g., optionname|otheroption|..., the option will be reset to its default value. If any value passed contains the pipe character (<kbd>|</kbd>), use the [[Special:ApiHelp/main#main/datatypes|alternative multiple-value separator]] for correct operation.",
+ "apihelp-options-param-optionname": "The name of the option that should be set to the value given by <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "The value for the option specified by <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Reset all preferences.",
+ "apihelp-options-example-change": "Change <kbd>skin</kbd> and <kbd>hideminor</kbd> preferences.",
+ "apihelp-options-example-complex": "Reset all preferences, then set <kbd>skin</kbd> and <kbd>nickname</kbd>.",
+
+ "apihelp-paraminfo-summary": "Obtain information about API modules.",
+ "apihelp-paraminfo-param-modules": "List of module names (values of the <var>action</var> and <var>format</var> parameters, or <kbd>main</kbd>). Can specify submodules with a <kbd>+</kbd>, or all submodules with <kbd>+*</kbd>, or all submodules recursively with <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Format of help strings.",
+ "apihelp-paraminfo-param-querymodules": "List of query module names (value of <var>prop</var>, <var>meta</var> or <var>list</var> parameter). Use <kbd>$1modules=query+foo</kbd> instead of <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Get information about the main (top-level) module as well. Use <kbd>$1modules=main</kbd> instead.",
+ "apihelp-paraminfo-param-pagesetmodule": "Get information about the pageset module (providing titles= and friends) as well.",
+ "apihelp-paraminfo-param-formatmodules": "List of format module names (value of <var>format</var> parameter). Use <var>$1modules</var> instead.",
+ "apihelp-paraminfo-example-1": "Show info for <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>, and <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "Show info for all submodules of <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+
+ "apihelp-parse-summary": "Parses content and returns parser output.",
+ "apihelp-parse-extended-description": "See the various prop-modules of <kbd>[[Special:ApiHelp/query|action=query]]</kbd> to get information from the current version of a page.\n\nThere are several ways to specify the text to parse:\n# Specify a page or revision, using <var>$1page</var>, <var>$1pageid</var>, or <var>$1oldid</var>.\n# Specify content explicitly, using <var>$1text</var>, <var>$1title</var>, <var>$1revid</var>, and <var>$1contentmodel</var>.\n# Specify only a summary to parse. <var>$1prop</var> should be given an empty value.",
+ "apihelp-parse-param-title": "Title of page the text belongs to. If omitted, <var>$1contentmodel</var> must be specified, and [[API]] will be used as the title.",
+ "apihelp-parse-param-text": "Text to parse. Use <var>$1title</var> or <var>$1contentmodel</var> to control the content model.",
+ "apihelp-parse-param-revid": "Revision ID, for <code><nowiki>{{REVISIONID}}</nowiki></code> and similar variables.",
+ "apihelp-parse-param-summary": "Summary to parse.",
+ "apihelp-parse-param-page": "Parse the content of this page. Cannot be used together with <var>$1text</var> and <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Parse the content of this page. Overrides <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "If <var>$1page</var> or <var>$1pageid</var> is set to a redirect, resolve it.",
+ "apihelp-parse-param-oldid": "Parse the content of this revision. Overrides <var>$1page</var> and <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "Which pieces of information to get:",
+ "apihelp-parse-paramvalue-prop-text": "Gives the parsed text of the wikitext.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Gives the language links in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-categories": "Gives the categories in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Gives the HTML version of the categories.",
+ "apihelp-parse-paramvalue-prop-links": "Gives the internal links in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-templates": "Gives the templates in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-images": "Gives the images in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Gives the external links in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-sections": "Gives the sections in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-revid": "Adds the revision ID of the parsed page.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Adds the title of the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-headitems": "Gives items to put in the <code>&lt;head&gt;</code> of the page.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Gives parsed <code>&lt;head&gt;</code> of the page.",
+ "apihelp-parse-paramvalue-prop-modules": "Gives the ResourceLoader modules used on the page. To load, use <code>mw.loader.using()</code>. Either <kbd>jsconfigvars</kbd> or <kbd>encodedjsconfigvars</kbd> must be requested jointly with <kbd>modules</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Gives the JavaScript configuration variables specific to the page. To apply, use <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Gives the JavaScript configuration variables specific to the page as a JSON string.",
+ "apihelp-parse-paramvalue-prop-indicators": "Gives the HTML of page status indicators used on the page.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Gives interwiki links in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Gives the original wikitext that was parsed.",
+ "apihelp-parse-paramvalue-prop-properties": "Gives various properties defined in the parsed wikitext.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Gives the limit report in a structured way. Gives no data, when <var>$1disablelimitreport</var> is set.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Gives the HTML version of the limit report. Gives no data, when <var>$1disablelimitreport</var> is set.",
+ "apihelp-parse-paramvalue-prop-parsetree": "The XML parse tree of revision content (requires content model <code>$1</code>)",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Gives the warnings that occurred while parsing content.",
+ "apihelp-parse-param-wrapoutputclass": "CSS class to use to wrap the parser output.",
+ "apihelp-parse-param-pst": "Do a pre-save transform on the input before parsing it. Only valid when used with text.",
+ "apihelp-parse-param-onlypst": "Do a pre-save transform (PST) on the input, but don't parse it. Returns the same wikitext, after a PST has been applied. Only valid when used with <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "Includes language links supplied by extensions (for use with <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "Only parse the content of this section number.\n\nWhen <kbd>new</kbd>, parse <var>$1text</var> and <var>$1sectiontitle</var> as if adding a new section to the page.\n\n<kbd>new</kbd> is allowed only when specifying <var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "New section title when <var>section</var> is <kbd>new</kbd>.\n\nUnlike page editing, this does not fall back to <var>summary</var> when omitted or empty.",
+ "apihelp-parse-param-disablelimitreport": "Omit the limit report (\"NewPP limit report\") from the parser output.",
+ "apihelp-parse-param-disablepp": "Use <var>$1disablelimitreport</var> instead.",
+ "apihelp-parse-param-disableeditsection": "Omit edit section links from the parser output.",
+ "apihelp-parse-param-disabletidy": "Do not run HTML cleanup (e.g. tidy) on the parser output.",
+ "apihelp-parse-param-generatexml": "Generate XML parse tree (requires content model <code>$1</code>; replaced by <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "Parse in preview mode.",
+ "apihelp-parse-param-sectionpreview": "Parse in section preview mode (enables preview mode too).",
+ "apihelp-parse-param-disabletoc": "Omit table of contents in output.",
+ "apihelp-parse-param-useskin": "Apply the selected skin to the parser output. May affect the following properties: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>modules</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicators</kbd>.",
+ "apihelp-parse-param-contentformat": "Content serialization format used for the input text. Only valid when used with $1text.",
+ "apihelp-parse-param-contentmodel": "Content model of the input text. If omitted, $1title must be specified, and default will be the model of the specified title. Only valid when used with $1text.",
+ "apihelp-parse-example-page": "Parse a page.",
+ "apihelp-parse-example-text": "Parse wikitext.",
+ "apihelp-parse-example-texttitle": "Parse wikitext, specifying the page title.",
+ "apihelp-parse-example-summary": "Parse a summary.",
+
+ "apihelp-patrol-summary": "Patrol a page or revision.",
+ "apihelp-patrol-param-rcid": "Recentchanges ID to patrol.",
+ "apihelp-patrol-param-revid": "Revision ID to patrol.",
+ "apihelp-patrol-param-tags": "Change tags to apply to the entry in the patrol log.",
+ "apihelp-patrol-example-rcid": "Patrol a recent change.",
+ "apihelp-patrol-example-revid": "Patrol a revision.",
+
+ "apihelp-protect-summary": "Change the protection level of a page.",
+ "apihelp-protect-param-title": "Title of the page to (un)protect. Cannot be used together with $1pageid.",
+ "apihelp-protect-param-pageid": "ID of the page to (un)protect. Cannot be used together with $1title.",
+ "apihelp-protect-param-protections": "List of protection levels, formatted <kbd>action=level</kbd> (e.g. <kbd>edit=sysop</kbd>). A level of <kbd>all</kbd> means everyone is allowed to take the action, i.e. no restriction.\n\n<strong>Note:</strong> Any actions not listed will have restrictions removed.",
+ "apihelp-protect-param-expiry": "Expiry timestamps. If only one timestamp is set, it'll be used for all protections. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, or <kbd>never</kbd>, for a never-expiring protection.",
+ "apihelp-protect-param-reason": "Reason for (un)protecting.",
+ "apihelp-protect-param-tags": "Change tags to apply to the entry in the protection log.",
+ "apihelp-protect-param-cascade": "Enable cascading protection (i.e. protect transcluded templates and images used in this page). Ignored if none of the given protection levels support cascading.",
+ "apihelp-protect-param-watch": "If set, add the page being (un)protected to the current user's watchlist.",
+ "apihelp-protect-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-protect-example-protect": "Protect a page.",
+ "apihelp-protect-example-unprotect": "Unprotect a page by setting restrictions to <kbd>all</kbd> (i.e. everyone is allowed to take the action).",
+ "apihelp-protect-example-unprotect2": "Unprotect a page by setting no restrictions.",
+
+ "apihelp-purge-summary": "Purge the cache for the given titles.",
+ "apihelp-purge-param-forcelinkupdate": "Update the links tables.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Update the links table, and update the links tables for any page that uses this page as a template.",
+ "apihelp-purge-example-simple": "Purge the <kbd>Main Page</kbd> and the <kbd>API</kbd> page.",
+ "apihelp-purge-example-generator": "Purge the first 10 pages in the main namespace.",
+
+ "apihelp-query-summary": "Fetch data from and about MediaWiki.",
+ "apihelp-query-extended-description": "All data modifications will first have to use query to acquire a token to prevent abuse from malicious sites.",
+ "apihelp-query-param-prop": "Which properties to get for the queried pages.",
+ "apihelp-query-param-list": "Which lists to get.",
+ "apihelp-query-param-meta": "Which metadata to get.",
+ "apihelp-query-param-indexpageids": "Include an additional pageids section listing all returned page IDs.",
+ "apihelp-query-param-export": "Export the current revisions of all given or generated pages.",
+ "apihelp-query-param-exportnowrap": "Return the export XML without wrapping it in an XML result (same format as [[Special:Export]]). Can only be used with $1export.",
+ "apihelp-query-param-iwurl": "Whether to get the full URL if the title is an interwiki link.",
+ "apihelp-query-param-rawcontinue": "Return raw <samp>query-continue</samp> data for continuation.",
+ "apihelp-query-example-revisions": "Fetch [[Special:ApiHelp/query+siteinfo|site info]] and [[Special:ApiHelp/query+revisions|revisions]] of <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "Fetch revisions of pages beginning with <kbd>API/</kbd>.",
+
+ "apihelp-query+allcategories-summary": "Enumerate all categories.",
+ "apihelp-query+allcategories-param-from": "The category to start enumerating from.",
+ "apihelp-query+allcategories-param-to": "The category to stop enumerating at.",
+ "apihelp-query+allcategories-param-prefix": "Search for all category titles that begin with this value.",
+ "apihelp-query+allcategories-param-dir": "Direction to sort in.",
+ "apihelp-query+allcategories-param-min": "Only return categories with at least this many members.",
+ "apihelp-query+allcategories-param-max": "Only return categories with at most this many members.",
+ "apihelp-query+allcategories-param-limit": "How many categories to return.",
+ "apihelp-query+allcategories-param-prop": "Which properties to get:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Adds number of pages in the category.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Tags categories that are hidden with <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "List categories with information on the number of pages in each.",
+ "apihelp-query+allcategories-example-generator": "Retrieve info about the category page itself for categories beginning <kbd>List</kbd>.",
+
+ "apihelp-query+alldeletedrevisions-summary": "List all deleted revisions by a user or in a namespace.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "May only be used with <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Cannot be used with <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "The timestamp to start enumerating from.",
+ "apihelp-query+alldeletedrevisions-param-end": "The timestamp to stop enumerating at.",
+ "apihelp-query+alldeletedrevisions-param-from": "Start listing at this title.",
+ "apihelp-query+alldeletedrevisions-param-to": "Stop listing at this title.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Search for all page titles that begin with this value.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Only list revisions tagged with this tag.",
+ "apihelp-query+alldeletedrevisions-param-user": "Only list revisions by this user.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Don't list revisions by this user.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Only list pages in this namespace.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>Note:</strong> Due to [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]], using <var>$1user</var> and <var>$1namespace</var> together may result in fewer than <var>$1limit</var> results returned before continuing; in extreme cases, zero results may be returned.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "When being used as a generator, generate titles rather than revision IDs.",
+ "apihelp-query+alldeletedrevisions-example-user": "List the last 50 deleted contributions by user <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "List the first 50 deleted revisions in the main namespace.",
+
+ "apihelp-query+allfileusages-summary": "List all file usages, including non-existing.",
+ "apihelp-query+allfileusages-param-from": "The title of the file to start enumerating from.",
+ "apihelp-query+allfileusages-param-to": "The title of the file to stop enumerating at.",
+ "apihelp-query+allfileusages-param-prefix": "Search for all file titles that begin with this value.",
+ "apihelp-query+allfileusages-param-unique": "Only show distinct file titles. Cannot be used with $1prop=ids.\nWhen used as a generator, yields target pages instead of source pages.",
+ "apihelp-query+allfileusages-param-prop": "Which pieces of information to include:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Adds the page IDs of the using pages (cannot be used with $1unique).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Adds the title of the file.",
+ "apihelp-query+allfileusages-param-limit": "How many total items to return.",
+ "apihelp-query+allfileusages-param-dir": "The direction in which to list.",
+ "apihelp-query+allfileusages-example-B": "List file titles, including missing ones, with page IDs they are from, starting at <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "List unique file titles.",
+ "apihelp-query+allfileusages-example-unique-generator": "Gets all file titles, marking the missing ones.",
+ "apihelp-query+allfileusages-example-generator": "Gets pages containing the files.",
+
+ "apihelp-query+allimages-summary": "Enumerate all images sequentially.",
+ "apihelp-query+allimages-param-sort": "Property to sort by.",
+ "apihelp-query+allimages-param-dir": "The direction in which to list.",
+ "apihelp-query+allimages-param-from": "The image title to start enumerating from. Can only be used with $1sort=name.",
+ "apihelp-query+allimages-param-to": "The image title to stop enumerating at. Can only be used with $1sort=name.",
+ "apihelp-query+allimages-param-start": "The timestamp to start enumerating from. Can only be used with $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "The timestamp to end enumerating. Can only be used with $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "Search for all image titles that begin with this value. Can only be used with $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "Limit to images with at least this many bytes.",
+ "apihelp-query+allimages-param-maxsize": "Limit to images with at most this many bytes.",
+ "apihelp-query+allimages-param-sha1": "SHA1 hash of image. Overrides $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "SHA1 hash of image in base 36 (used in MediaWiki).",
+ "apihelp-query+allimages-param-user": "Only return files uploaded by this user. Can only be used with $1sort=timestamp. Cannot be used together with $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "How to filter files uploaded by bots. Can only be used with $1sort=timestamp. Cannot be used together with $1user.",
+ "apihelp-query+allimages-param-mime": "What MIME types to search for, e.g. <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "How many images in total to return.",
+ "apihelp-query+allimages-example-B": "Show a list of files starting at the letter <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Show a list of recently uploaded files, similar to [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Show a list of files with MIME type <kbd>image/png</kbd> or <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Show info about 4 files starting at the letter <kbd>T</kbd>.",
+
+ "apihelp-query+alllinks-summary": "Enumerate all links that point to a given namespace.",
+ "apihelp-query+alllinks-param-from": "The title of the link to start enumerating from.",
+ "apihelp-query+alllinks-param-to": "The title of the link to stop enumerating at.",
+ "apihelp-query+alllinks-param-prefix": "Search for all linked titles that begin with this value.",
+ "apihelp-query+alllinks-param-unique": "Only show distinct linked titles. Cannot be used with <kbd>$1prop=ids</kbd>.\nWhen used as a generator, yields target pages instead of source pages.",
+ "apihelp-query+alllinks-param-prop": "Which pieces of information to include:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Adds the page ID of the linking page (cannot be used with <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Adds the title of the link.",
+ "apihelp-query+alllinks-param-namespace": "The namespace to enumerate.",
+ "apihelp-query+alllinks-param-limit": "How many total items to return.",
+ "apihelp-query+alllinks-param-dir": "The direction in which to list.",
+ "apihelp-query+alllinks-example-B": "List linked titles, including missing ones, with page IDs they are from, starting at <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "List unique linked titles.",
+ "apihelp-query+alllinks-example-unique-generator": "Gets all linked titles, marking the missing ones.",
+ "apihelp-query+alllinks-example-generator": "Gets pages containing the links.",
+
+ "apihelp-query+allmessages-summary": "Return messages from this site.",
+ "apihelp-query+allmessages-param-messages": "Which messages to output. <kbd>*</kbd> (default) means all messages.",
+ "apihelp-query+allmessages-param-prop": "Which properties to get.",
+ "apihelp-query+allmessages-param-enableparser": "Set to enable parser, will preprocess the wikitext of message (substitute magic words, handle templates, etc.).",
+ "apihelp-query+allmessages-param-nocontent": "If set, do not include the content of the messages in the output.",
+ "apihelp-query+allmessages-param-includelocal": "Also include local messages, i.e. messages that don't exist in the software but do exist as in the {{ns:MediaWiki}} namespace.\nThis lists all {{ns:MediaWiki}}-namespace pages, so it will also list those that aren't really messages such as [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Arguments to be substituted into message.",
+ "apihelp-query+allmessages-param-filter": "Return only messages with names that contain this string.",
+ "apihelp-query+allmessages-param-customised": "Return only messages in this customisation state.",
+ "apihelp-query+allmessages-param-lang": "Return messages in this language.",
+ "apihelp-query+allmessages-param-from": "Return messages starting at this message.",
+ "apihelp-query+allmessages-param-to": "Return messages ending at this message.",
+ "apihelp-query+allmessages-param-title": "Page name to use as context when parsing message (for $1enableparser option).",
+ "apihelp-query+allmessages-param-prefix": "Return messages with this prefix.",
+ "apihelp-query+allmessages-example-ipb": "Show messages starting with <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Show messages <kbd>august</kbd> and <kbd>mainpage</kbd> in German.",
+
+ "apihelp-query+allpages-summary": "Enumerate all pages sequentially in a given namespace.",
+ "apihelp-query+allpages-param-from": "The page title to start enumerating from.",
+ "apihelp-query+allpages-param-to": "The page title to stop enumerating at.",
+ "apihelp-query+allpages-param-prefix": "Search for all page titles that begin with this value.",
+ "apihelp-query+allpages-param-namespace": "The namespace to enumerate.",
+ "apihelp-query+allpages-param-filterredir": "Which pages to list.",
+ "apihelp-query+allpages-param-minsize": "Limit to pages with at least this many bytes.",
+ "apihelp-query+allpages-param-maxsize": "Limit to pages with at most this many bytes.",
+ "apihelp-query+allpages-param-prtype": "Limit to protected pages only.",
+ "apihelp-query+allpages-param-prlevel": "Filter protections based on protection level (must be used with $1prtype= parameter).",
+ "apihelp-query+allpages-param-prfiltercascade": "Filter protections based on cascadingness (ignored when $1prtype isn't set).",
+ "apihelp-query+allpages-param-limit": "How many total pages to return.",
+ "apihelp-query+allpages-param-dir": "The direction in which to list.",
+ "apihelp-query+allpages-param-filterlanglinks": "Filter based on whether a page has langlinks. Note that this may not consider langlinks added by extensions.",
+ "apihelp-query+allpages-param-prexpiry": "Which protection expiry to filter the page on:\n;indefinite:Get only pages with indefinite protection expiry.\n;definite:Get only pages with a definite (specific) protection expiry.\n;all:Get pages with any protections expiry.",
+ "apihelp-query+allpages-example-B": "Show a list of pages starting at the letter <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Show info about 4 pages starting at the letter <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Show content of first 2 non-redirect pages beginning at <kbd>Re</kbd>.",
+
+ "apihelp-query+allredirects-summary": "List all redirects to a namespace.",
+ "apihelp-query+allredirects-param-from": "The title of the redirect to start enumerating from.",
+ "apihelp-query+allredirects-param-to": "The title of the redirect to stop enumerating at.",
+ "apihelp-query+allredirects-param-prefix": "Search for all target pages that begin with this value.",
+ "apihelp-query+allredirects-param-unique": "Only show distinct target pages. Cannot be used with $1prop=ids|fragment|interwiki.\nWhen used as a generator, yields target pages instead of source pages.",
+ "apihelp-query+allredirects-param-prop": "Which pieces of information to include:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Adds the page ID of the redirecting page (cannot be used with <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Adds the title of the redirect.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Adds the fragment from the redirect, if any (cannot be used with <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Adds the interwiki prefix from the redirect, if any (cannot be used with <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "The namespace to enumerate.",
+ "apihelp-query+allredirects-param-limit": "How many total items to return.",
+ "apihelp-query+allredirects-param-dir": "The direction in which to list.",
+ "apihelp-query+allredirects-example-B": "List target pages, including missing ones, with page IDs they are from, starting at <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "List unique target pages.",
+ "apihelp-query+allredirects-example-unique-generator": "Gets all target pages, marking the missing ones.",
+ "apihelp-query+allredirects-example-generator": "Gets pages containing the redirects.",
+
+ "apihelp-query+allrevisions-summary": "List all revisions.",
+ "apihelp-query+allrevisions-param-start": "The timestamp to start enumerating from.",
+ "apihelp-query+allrevisions-param-end": "The timestamp to stop enumerating at.",
+ "apihelp-query+allrevisions-param-user": "Only list revisions by this user.",
+ "apihelp-query+allrevisions-param-excludeuser": "Don't list revisions by this user.",
+ "apihelp-query+allrevisions-param-namespace": "Only list pages in this namespace.",
+ "apihelp-query+allrevisions-param-generatetitles": "When being used as a generator, generate titles rather than revision IDs.",
+ "apihelp-query+allrevisions-example-user": "List the last 50 contributions by user <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "List the first 50 revisions in the main namespace.",
+
+ "apihelp-query+mystashedfiles-summary": "Get a list of files in the current user's upload stash.",
+ "apihelp-query+mystashedfiles-param-prop": "Which properties to fetch for the files.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Fetch the file size and image dimensions.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Fetch the file's MIME type and media type.",
+ "apihelp-query+mystashedfiles-param-limit": "How many files to get.",
+ "apihelp-query+mystashedfiles-example-simple": "Get the filekey, file size, and pixel size of files in the current user's upload stash.",
+
+ "apihelp-query+alltransclusions-summary": "List all transclusions (pages embedded using &#123;&#123;x&#125;&#125;), including non-existing.",
+ "apihelp-query+alltransclusions-param-from": "The title of the transclusion to start enumerating from.",
+ "apihelp-query+alltransclusions-param-to": "The title of the transclusion to stop enumerating at.",
+ "apihelp-query+alltransclusions-param-prefix": "Search for all transcluded titles that begin with this value.",
+ "apihelp-query+alltransclusions-param-unique": "Only show distinct transcluded titles. Cannot be used with $1prop=ids.\nWhen used as a generator, yields target pages instead of source pages.",
+ "apihelp-query+alltransclusions-param-prop": "Which pieces of information to include:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Adds the page ID of the transcluding page (cannot be used with $1unique).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Adds the title of the transclusion.",
+ "apihelp-query+alltransclusions-param-namespace": "The namespace to enumerate.",
+ "apihelp-query+alltransclusions-param-limit": "How many total items to return.",
+ "apihelp-query+alltransclusions-param-dir": "The direction in which to list.",
+ "apihelp-query+alltransclusions-example-B": "List transcluded titles, including missing ones, with page IDs they are from, starting at <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "List unique transcluded titles.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Gets all transcluded titles, marking the missing ones.",
+ "apihelp-query+alltransclusions-example-generator": "Gets pages containing the transclusions.",
+
+ "apihelp-query+allusers-summary": "Enumerate all registered users.",
+ "apihelp-query+allusers-param-from": "The user name to start enumerating from.",
+ "apihelp-query+allusers-param-to": "The user name to stop enumerating at.",
+ "apihelp-query+allusers-param-prefix": "Search for all users that begin with this value.",
+ "apihelp-query+allusers-param-dir": "Direction to sort in.",
+ "apihelp-query+allusers-param-group": "Only include users in the given groups.",
+ "apihelp-query+allusers-param-excludegroup": "Exclude users in the given groups.",
+ "apihelp-query+allusers-param-rights": "Only include users with the given rights. Does not include rights granted by implicit or auto-promoted groups like *, user, or autoconfirmed.",
+ "apihelp-query+allusers-param-prop": "Which pieces of information to include:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Adds the information about a current block on the user.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Lists groups that the user is in. This uses more server resources and may return fewer results than the limit.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Lists all the groups the user is automatically in.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Lists rights that the user has.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Adds the edit count of the user.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Adds the timestamp of when the user registered if available (may be blank).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Adds the central IDs and attachment status for the user.",
+ "apihelp-query+allusers-param-limit": "How many total user names to return.",
+ "apihelp-query+allusers-param-witheditsonly": "Only list users who have made edits.",
+ "apihelp-query+allusers-param-activeusers": "Only list users active in the last $1 {{PLURAL:$1|day|days}}.",
+ "apihelp-query+allusers-param-attachedwiki": "With <kbd>$1prop=centralids</kbd>, also indicate whether the user is attached with the wiki identified by this ID.",
+ "apihelp-query+allusers-example-Y": "List users starting at <kbd>Y</kbd>.",
+
+ "apihelp-query+authmanagerinfo-summary": "Retrieve information about the current authentication status.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Test whether the user's current authentication status is sufficient for the specified security-sensitive operation.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Fetch information about the authentication requests needed for the specified authentication action.",
+ "apihelp-query+authmanagerinfo-example-login": "Fetch the requests that may be used when beginning a login.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Fetch the requests that may be used when beginning a login, with form fields merged.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Test whether authentication is sufficient for action <kbd>foo</kbd>.",
+
+ "apihelp-query+backlinks-summary": "Find all pages that link to the given page.",
+ "apihelp-query+backlinks-param-title": "Title to search. Cannot be used together with <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "Page ID to search. Cannot be used together with <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "The namespace to enumerate.",
+ "apihelp-query+backlinks-param-dir": "The direction in which to list.",
+ "apihelp-query+backlinks-param-filterredir": "How to filter for redirects. If set to <kbd>nonredirects</kbd> when <var>$1redirect</var> is enabled, this is only applied to the second level.",
+ "apihelp-query+backlinks-param-limit": "How many total pages to return. If <var>$1redirect</var> is enabled, the limit applies to each level separately (which means up to 2 * <var>$1limit</var> results may be returned).",
+ "apihelp-query+backlinks-param-redirect": "If linking page is a redirect, find all pages that link to that redirect as well. Maximum limit is halved.",
+ "apihelp-query+backlinks-example-simple": "Show links to <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Get information about pages linking to <kbd>Main page</kbd>.",
+
+ "apihelp-query+blocks-summary": "List all blocked users and IP addresses.",
+ "apihelp-query+blocks-param-start": "The timestamp to start enumerating from.",
+ "apihelp-query+blocks-param-end": "The timestamp to stop enumerating at.",
+ "apihelp-query+blocks-param-ids": "List of block IDs to list (optional).",
+ "apihelp-query+blocks-param-users": "List of users to search for (optional).",
+ "apihelp-query+blocks-param-ip": "Get all blocks applying to this IP address or CIDR range, including range blocks.\nCannot be used together with <var>$3users</var>. CIDR ranges broader than IPv4/$1 or IPv6/$2 are not accepted.",
+ "apihelp-query+blocks-param-limit": "The maximum number of blocks to list.",
+ "apihelp-query+blocks-param-prop": "Which properties to get:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Adds the ID of the block.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Adds the username of the blocked user.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Adds the user ID of the blocked user.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Adds the username of the blocking user.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Adds the user ID of the blocking user.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Adds the timestamp of when the block was given.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Adds the timestamp of when the block expires.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Adds the reason given for the block.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Adds the range of IP addresses affected by the block.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Tags the ban with (autoblock, anononly, etc.).",
+ "apihelp-query+blocks-param-show": "Show only items that meet these criteria.\nFor example, to see only indefinite blocks on IP addresses, set <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "List blocks.",
+ "apihelp-query+blocks-example-users": "List blocks of users <kbd>Alice</kbd> and <kbd>Bob</kbd>.",
+
+ "apihelp-query+categories-summary": "List all categories the pages belong to.",
+ "apihelp-query+categories-param-prop": "Which additional properties to get for each category:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Adds the sortkey (hexadecimal string) and sortkey prefix (human-readable part) for the category.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Adds timestamp of when the category was added.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Tags categories that are hidden with <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "Which kind of categories to show.",
+ "apihelp-query+categories-param-limit": "How many categories to return.",
+ "apihelp-query+categories-param-categories": "Only list these categories. Useful for checking whether a certain page is in a certain category.",
+ "apihelp-query+categories-param-dir": "The direction in which to list.",
+ "apihelp-query+categories-example-simple": "Get a list of categories the page <kbd>Albert Einstein</kbd> belongs to.",
+ "apihelp-query+categories-example-generator": "Get information about all categories used in the page <kbd>Albert Einstein</kbd>.",
+
+ "apihelp-query+categoryinfo-summary": "Returns information about the given categories.",
+ "apihelp-query+categoryinfo-example-simple": "Get information about <kbd>Category:Foo</kbd> and <kbd>Category:Bar</kbd>.",
+
+ "apihelp-query+categorymembers-summary": "List all pages in a given category.",
+ "apihelp-query+categorymembers-param-title": "Which category to enumerate (required). Must include the <kbd>{{ns:category}}:</kbd> prefix. Cannot be used together with <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "Page ID of the category to enumerate. Cannot be used together with <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "Which pieces of information to include:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Adds the page ID.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Adds the title and namespace ID of the page.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Adds the sortkey used for sorting in the category (hexadecimal string).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Adds the sortkey prefix used for sorting in the category (human-readable part of the sortkey).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Adds the type that the page has been categorised as (<samp>page</samp>, <samp>subcat</samp> or <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Adds the timestamp of when the page was included.",
+ "apihelp-query+categorymembers-param-namespace": "Only include pages in these namespaces. Note that <kbd>$1type=subcat</kbd> or <kbd>$1type=file</kbd> may be used instead of <kbd>$1namespace=14</kbd> or <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-type": "Which type of category members to include. Ignored when <kbd>$1sort=timestamp</kbd> is set.",
+ "apihelp-query+categorymembers-param-limit": "The maximum number of pages to return.",
+ "apihelp-query+categorymembers-param-sort": "Property to sort by.",
+ "apihelp-query+categorymembers-param-dir": "In which direction to sort.",
+ "apihelp-query+categorymembers-param-start": "Timestamp to start listing from. Can only be used with <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "Timestamp to end listing at. Can only be used with <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Sortkey to start listing from, as returned by <kbd>$1prop=sortkey</kbd>. Can only be used with <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Sortkey to end listing at, as returned by <kbd>$1prop=sortkey</kbd>. Can only be used with <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "Sortkey prefix to start listing from. Can only be used with <kbd>$1sort=sortkey</kbd>. Overrides <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "Sortkey prefix to end listing <strong>before</strong> (not <strong>at</strong>; if this value occurs it will not be included!). Can only be used with $1sort=sortkey. Overrides $1endhexsortkey.",
+ "apihelp-query+categorymembers-param-startsortkey": "Use $1starthexsortkey instead.",
+ "apihelp-query+categorymembers-param-endsortkey": "Use $1endhexsortkey instead.",
+ "apihelp-query+categorymembers-example-simple": "Get first 10 pages in <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Get page info about first 10 pages in <kbd>Category:Physics</kbd>.",
+
+ "apihelp-query+contributors-summary": "Get the list of logged-in contributors and the count of anonymous contributors to a page.",
+ "apihelp-query+contributors-param-group": "Only include users in the given groups. Does not include implicit or auto-promoted groups like *, user, or autoconfirmed.",
+ "apihelp-query+contributors-param-excludegroup": "Exclude users in the given groups. Does not include implicit or auto-promoted groups like *, user, or autoconfirmed.",
+ "apihelp-query+contributors-param-rights": "Only include users having the given rights. Does not include rights granted by implicit or auto-promoted groups like *, user, or autoconfirmed.",
+ "apihelp-query+contributors-param-excluderights": "Exclude users having the given rights. Does not include rights granted by implicit or auto-promoted groups like *, user, or autoconfirmed.",
+ "apihelp-query+contributors-param-limit": "How many contributors to return.",
+ "apihelp-query+contributors-example-simple": "Show contributors to the page <kbd>Main Page</kbd>.",
+
+ "apihelp-query+deletedrevisions-summary": "Get deleted revision information.",
+ "apihelp-query+deletedrevisions-extended-description": "May be used in several ways:\n# Get deleted revisions for a set of pages, by setting titles or pageids. Ordered by title and timestamp.\n# Get data about a set of deleted revisions by setting their IDs with revids. Ordered by revision ID.",
+ "apihelp-query+deletedrevisions-param-start": "The timestamp to start enumerating from. Ignored when processing a list of revision IDs.",
+ "apihelp-query+deletedrevisions-param-end": "The timestamp to stop enumerating at. Ignored when processing a list of revision IDs.",
+ "apihelp-query+deletedrevisions-param-tag": "Only list revisions tagged with this tag.",
+ "apihelp-query+deletedrevisions-param-user": "Only list revisions by this user.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Don't list revisions by this user.",
+ "apihelp-query+deletedrevisions-example-titles": "List the deleted revisions of the pages <kbd>Main Page</kbd> and <kbd>Talk:Main Page</kbd>, with content.",
+ "apihelp-query+deletedrevisions-example-revids": "List the information for deleted revision <kbd>123456</kbd>.",
+
+ "apihelp-query+deletedrevs-summary": "List deleted revisions.",
+ "apihelp-query+deletedrevs-extended-description": "Operates in three modes:\n# List deleted revisions for the given titles, sorted by timestamp.\n# List deleted contributions for the given user, sorted by timestamp (no titles specified).\n# List all deleted revisions in the given namespace, sorted by title and timestamp (no titles specified, $1user not set).\n\nCertain parameters only apply to some modes and are ignored in others.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Mode|Modes}}: $2",
+ "apihelp-query+deletedrevs-param-start": "The timestamp to start enumerating from.",
+ "apihelp-query+deletedrevs-param-end": "The timestamp to stop enumerating at.",
+ "apihelp-query+deletedrevs-param-from": "Start listing at this title.",
+ "apihelp-query+deletedrevs-param-to": "Stop listing at this title.",
+ "apihelp-query+deletedrevs-param-prefix": "Search for all page titles that begin with this value.",
+ "apihelp-query+deletedrevs-param-unique": "List only one revision for each page.",
+ "apihelp-query+deletedrevs-param-tag": "Only list revisions tagged with this tag.",
+ "apihelp-query+deletedrevs-param-user": "Only list revisions by this user.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Don't list revisions by this user.",
+ "apihelp-query+deletedrevs-param-namespace": "Only list pages in this namespace.",
+ "apihelp-query+deletedrevs-param-limit": "The maximum amount of revisions to list.",
+ "apihelp-query+deletedrevs-param-prop": "Which properties to get:\n;revid:Adds the revision ID of the deleted revision.\n;parentid:Adds the revision ID of the previous revision to the page.\n;user:Adds the user who made the revision.\n;userid:Adds the ID of the user who made the revision.\n;comment:Adds the comment of the revision.\n;parsedcomment:Adds the parsed comment of the revision.\n;minor:Tags if the revision is minor.\n;len:Adds the length (bytes) of the revision.\n;sha1:Adds the SHA-1 (base 16) of the revision.\n;content:Adds the content of the revision.\n;token:<span class=\"apihelp-deprecated\">Deprecated.</span> Gives the edit token.\n;tags:Tags for the revision.",
+ "apihelp-query+deletedrevs-example-mode1": "List the last deleted revisions of the pages <kbd>Main Page</kbd> and <kbd>Talk:Main Page</kbd>, with content (mode 1).",
+ "apihelp-query+deletedrevs-example-mode2": "List the last 50 deleted contributions by <kbd>Bob</kbd> (mode 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "List the first 50 deleted revisions in the main namespace (mode 3).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "List the first 50 deleted pages in the {{ns:talk}} namespace (mode 3).",
+
+ "apihelp-query+disabled-summary": "This query module has been disabled.",
+
+ "apihelp-query+duplicatefiles-summary": "List all files that are duplicates of the given files based on hash values.",
+ "apihelp-query+duplicatefiles-param-limit": "How many duplicate files to return.",
+ "apihelp-query+duplicatefiles-param-dir": "The direction in which to list.",
+ "apihelp-query+duplicatefiles-param-localonly": "Look only for files in the local repository.",
+ "apihelp-query+duplicatefiles-example-simple": "Look for duplicates of [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Look for duplicates of all files.",
+
+ "apihelp-query+embeddedin-summary": "Find all pages that embed (transclude) the given title.",
+ "apihelp-query+embeddedin-param-title": "Title to search. Cannot be used together with $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "Page ID to search. Cannot be used together with $1title.",
+ "apihelp-query+embeddedin-param-namespace": "The namespace to enumerate.",
+ "apihelp-query+embeddedin-param-dir": "The direction in which to list.",
+ "apihelp-query+embeddedin-param-filterredir": "How to filter for redirects.",
+ "apihelp-query+embeddedin-param-limit": "How many total pages to return.",
+ "apihelp-query+embeddedin-example-simple": "Show pages transcluding <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "Get information about pages transcluding <kbd>Template:Stub</kbd>.",
+
+ "apihelp-query+extlinks-summary": "Returns all external URLs (not interwikis) from the given pages.",
+ "apihelp-query+extlinks-param-limit": "How many links to return.",
+ "apihelp-query+extlinks-param-protocol": "Protocol of the URL. If empty and <var>$1query</var> is set, the protocol is <kbd>http</kbd>. Leave both this and <var>$1query</var> empty to list all external links.",
+ "apihelp-query+extlinks-param-query": "Search string without protocol. Useful for checking whether a certain page contains a certain external url.",
+ "apihelp-query+extlinks-param-expandurl": "Expand protocol-relative URLs with the canonical protocol.",
+ "apihelp-query+extlinks-example-simple": "Get a list of external links on <kbd>Main Page</kbd>.",
+
+ "apihelp-query+exturlusage-summary": "Enumerate pages that contain a given URL.",
+ "apihelp-query+exturlusage-param-prop": "Which pieces of information to include:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Adds the ID of page.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Adds the title and namespace ID of the page.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Adds the URL used in the page.",
+ "apihelp-query+exturlusage-param-protocol": "Protocol of the URL. If empty and <var>$1query</var> is set, the protocol is <kbd>http</kbd>. Leave both this and <var>$1query</var> empty to list all external links.",
+ "apihelp-query+exturlusage-param-query": "Search string without protocol. See [[Special:LinkSearch]]. Leave empty to list all external links.",
+ "apihelp-query+exturlusage-param-namespace": "The page namespaces to enumerate.",
+ "apihelp-query+exturlusage-param-limit": "How many pages to return.",
+ "apihelp-query+exturlusage-param-expandurl": "Expand protocol-relative URLs with the canonical protocol.",
+ "apihelp-query+exturlusage-example-simple": "Show pages linking to <kbd>http://www.mediawiki.org</kbd>.",
+
+ "apihelp-query+filearchive-summary": "Enumerate all deleted files sequentially.",
+ "apihelp-query+filearchive-param-from": "The image title to start enumerating from.",
+ "apihelp-query+filearchive-param-to": "The image title to stop enumerating at.",
+ "apihelp-query+filearchive-param-prefix": "Search for all image titles that begin with this value.",
+ "apihelp-query+filearchive-param-limit": "How many images to return in total.",
+ "apihelp-query+filearchive-param-dir": "The direction in which to list.",
+ "apihelp-query+filearchive-param-sha1": "SHA1 hash of image. Overrides $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "SHA1 hash of image in base 36 (used in MediaWiki).",
+ "apihelp-query+filearchive-param-prop": "Which image information to get:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Adds SHA-1 hash for the image.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Adds timestamp for the uploaded version.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Adds user who uploaded the image version.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Adds the size of the image in bytes and the height, width and page count (if applicable).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Alias for size.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Adds description of the image version.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Parse the description of the version.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Adds MIME of the image.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Adds the media type of the image.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Lists Exif metadata for the version of the image.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Adds the bit depth of the version.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Adds the filename of the archive version for non-latest versions.",
+ "apihelp-query+filearchive-example-simple": "Show a list of all deleted files.",
+
+ "apihelp-query+filerepoinfo-summary": "Return meta information about image repositories configured on the wiki.",
+ "apihelp-query+filerepoinfo-param-prop": "Which repository properties to get (there may be more available on some wikis):\n;apiurl:URL to the repository API - helpful for getting image info from the host.\n;name:The key of the repository - used in e.g. <var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> and [[Special:ApiHelp/query+imageinfo|imageinfo]] return values.\n;displayname:The human-readable name of the repository wiki.\n;rooturl:Root URL for image paths.\n;local:Whether that repository is the local one or not.",
+ "apihelp-query+filerepoinfo-example-simple": "Get information about file repositories.",
+
+ "apihelp-query+fileusage-summary": "Find all pages that use the given files.",
+ "apihelp-query+fileusage-param-prop": "Which properties to get:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "Page ID of each page.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Title of each page.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Flag if the page is a redirect.",
+ "apihelp-query+fileusage-param-namespace": "Only include pages in these namespaces.",
+ "apihelp-query+fileusage-param-limit": "How many to return.",
+ "apihelp-query+fileusage-param-show": "Show only items that meet these criteria:\n;redirect:Only show redirects.\n;!redirect:Only show non-redirects.",
+ "apihelp-query+fileusage-example-simple": "Get a list of pages using [[:File:Example.jpg]].",
+ "apihelp-query+fileusage-example-generator": "Get information about pages using [[:File:Example.jpg]].",
+
+ "apihelp-query+imageinfo-summary": "Returns file information and upload history.",
+ "apihelp-query+imageinfo-param-prop": "Which file information to get:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Adds timestamp for the uploaded version.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Adds the user who uploaded each file version.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Add the ID of the user that uploaded each file version.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Comment on the version.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Parse the comment on the version.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Adds the canonical title of the file.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Gives URL to the file and the description page.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Adds the size of the file in bytes and the height, width and page count (if applicable).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Alias for size.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Adds SHA-1 hash for the file.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Adds MIME type of the file.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Adds MIME type of the image thumbnail (requires url and param $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Adds the media type of the file.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Lists Exif metadata for the version of the file.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Lists file format generic metadata for the version of the file.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Lists formatted metadata combined from multiple sources. Results are HTML formatted.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Adds the filename of the archive version for non-latest versions.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Adds the bit depth of the version.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Used by the Special:Upload page to get information about an existing file. Not intended for use outside MediaWiki core.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Adds whether the file is on the [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-limit": "How many file revisions to return per file.",
+ "apihelp-query+imageinfo-param-start": "Timestamp to start listing from.",
+ "apihelp-query+imageinfo-param-end": "Timestamp to stop listing at.",
+ "apihelp-query+imageinfo-param-urlwidth": "If $2prop=url is set, a URL to an image scaled to this width will be returned.\nFor performance reasons if this option is used, no more than $1 scaled images will be returned.",
+ "apihelp-query+imageinfo-param-urlheight": "Similar to $1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "Version of metadata to use. If <kbd>latest</kbd> is specified, use latest version. Defaults to <kbd>1</kbd> for backwards compatibility.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "What language to fetch extmetadata in. This affects both which translation to fetch, if multiple are available, as well as how things like numbers and various values are formatted.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "If translations for extmetadata property are available, fetch all of them.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "If specified and non-empty, only these keys will be returned for $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "A handler specific parameter string. For example, PDFs might use <kbd>page15-100px</kbd>. <var>$1urlwidth</var> must be used and be consistent with <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "If <kbd>$2prop=badfile</kbd> is set, this is the page title used when evaluating the [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-localonly": "Look only for files in the local repository.",
+ "apihelp-query+imageinfo-example-simple": "Fetch information about the current version of [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "Fetch information about versions of [[:File:Test.jpg]] from 2008 and later.",
+
+ "apihelp-query+images-summary": "Returns all files contained on the given pages.",
+ "apihelp-query+images-param-limit": "How many files to return.",
+ "apihelp-query+images-param-images": "Only list these files. Useful for checking whether a certain page has a certain file.",
+ "apihelp-query+images-param-dir": "The direction in which to list.",
+ "apihelp-query+images-example-simple": "Get a list of files used in the [[Main Page]].",
+ "apihelp-query+images-example-generator": "Get information about all files used in the [[Main Page]].",
+
+ "apihelp-query+imageusage-summary": "Find all pages that use the given image title.",
+ "apihelp-query+imageusage-param-title": "Title to search. Cannot be used together with $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "Page ID to search. Cannot be used together with $1title.",
+ "apihelp-query+imageusage-param-namespace": "The namespace to enumerate.",
+ "apihelp-query+imageusage-param-dir": "The direction in which to list.",
+ "apihelp-query+imageusage-param-filterredir": "How to filter for redirects. If set to nonredirects when $1redirect is enabled, this is only applied to the second level.",
+ "apihelp-query+imageusage-param-limit": "How many total pages to return. If <var>$1redirect</var> is enabled, the limit applies to each level separately (which means up to 2 * <var>$1limit</var> results may be returned).",
+ "apihelp-query+imageusage-param-redirect": "If linking page is a redirect, find all pages that link to that redirect as well. Maximum limit is halved.",
+ "apihelp-query+imageusage-example-simple": "Show pages using [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Get information about pages using [[:File:Albert Einstein Head.jpg]].",
+
+ "apihelp-query+info-summary": "Get basic page information.",
+ "apihelp-query+info-param-prop": "Which additional properties to get:",
+ "apihelp-query+info-paramvalue-prop-protection": "List the protection level of each page.",
+ "apihelp-query+info-paramvalue-prop-talkid": "The page ID of the talk page for each non-talk page.",
+ "apihelp-query+info-paramvalue-prop-watched": "List the watched status of each page.",
+ "apihelp-query+info-paramvalue-prop-watchers": "The number of watchers, if allowed.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "The number of watchers of each page who have visited recent edits to that page, if allowed.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "The watchlist notification timestamp of each page.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "The page ID of the parent page for each talk page.",
+ "apihelp-query+info-paramvalue-prop-url": "Gives a full URL, an edit URL, and the canonical URL for each page.",
+ "apihelp-query+info-paramvalue-prop-readable": "Whether the user can read this page.",
+ "apihelp-query+info-paramvalue-prop-preload": "Gives the text returned by EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Gives the manner in which the page title is actually displayed.",
+ "apihelp-query+info-param-testactions": "Test whether the current user can perform certain actions on the page.",
+ "apihelp-query+info-param-token": "Use [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] instead.",
+ "apihelp-query+info-example-simple": "Get information about the page <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Get general and protection information about the page <kbd>Main Page</kbd>.",
+
+ "apihelp-query+iwbacklinks-summary": "Find all pages that link to the given interwiki link.",
+ "apihelp-query+iwbacklinks-extended-description": "Can be used to find all links with a prefix, or all links to a title (with a given prefix). Using neither parameter is effectively \"all interwiki links\".",
+ "apihelp-query+iwbacklinks-param-prefix": "Prefix for the interwiki.",
+ "apihelp-query+iwbacklinks-param-title": "Interwiki link to search for. Must be used with <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "How many total pages to return.",
+ "apihelp-query+iwbacklinks-param-prop": "Which properties to get:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Adds the prefix of the interwiki.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Adds the title of the interwiki.",
+ "apihelp-query+iwbacklinks-param-dir": "The direction in which to list.",
+ "apihelp-query+iwbacklinks-example-simple": "Get pages linking to [[wikibooks:Test]].",
+ "apihelp-query+iwbacklinks-example-generator": "Get information about pages linking to [[wikibooks:Test]].",
+
+ "apihelp-query+iwlinks-summary": "Returns all interwiki links from the given pages.",
+ "apihelp-query+iwlinks-param-url": "Whether to get the full URL (cannot be used with $1prop).",
+ "apihelp-query+iwlinks-param-prop": "Which additional properties to get for each interlanguage link:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Adds the full URL.",
+ "apihelp-query+iwlinks-param-limit": "How many interwiki links to return.",
+ "apihelp-query+iwlinks-param-prefix": "Only return interwiki links with this prefix.",
+ "apihelp-query+iwlinks-param-title": "Interwiki link to search for. Must be used with <var>$1prefix</var>.",
+ "apihelp-query+iwlinks-param-dir": "The direction in which to list.",
+ "apihelp-query+iwlinks-example-simple": "Get interwiki links from the page <kbd>Main Page</kbd>.",
+
+ "apihelp-query+langbacklinks-summary": "Find all pages that link to the given language link.",
+ "apihelp-query+langbacklinks-extended-description": "Can be used to find all links with a language code, or all links to a title (with a given language). Using neither parameter is effectively \"all language links\".\n\nNote that this may not consider language links added by extensions.",
+ "apihelp-query+langbacklinks-param-lang": "Language for the language link.",
+ "apihelp-query+langbacklinks-param-title": "Language link to search for. Must be used with $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "How many total pages to return.",
+ "apihelp-query+langbacklinks-param-prop": "Which properties to get:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Adds the language code of the language link.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Adds the title of the language link.",
+ "apihelp-query+langbacklinks-param-dir": "The direction in which to list.",
+ "apihelp-query+langbacklinks-example-simple": "Get pages linking to [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Get information about pages linking to [[:fr:Test]].",
+
+ "apihelp-query+langlinks-summary": "Returns all interlanguage links from the given pages.",
+ "apihelp-query+langlinks-param-limit": "How many langlinks to return.",
+ "apihelp-query+langlinks-param-url": "Whether to get the full URL (cannot be used with <var>$1prop</var>).",
+ "apihelp-query+langlinks-param-prop": "Which additional properties to get for each interlanguage link:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Adds the full URL.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "Adds the localised language name (best effort). Use <var>$1inlanguagecode</var> to control the language.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Adds the native language name.",
+ "apihelp-query+langlinks-param-lang": "Only return language links with this language code.",
+ "apihelp-query+langlinks-param-title": "Link to search for. Must be used with <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "The direction in which to list.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Language code for localised language names.",
+ "apihelp-query+langlinks-example-simple": "Get interlanguage links from the page <kbd>Main Page</kbd>.",
+
+ "apihelp-query+links-summary": "Returns all links from the given pages.",
+ "apihelp-query+links-param-namespace": "Show links in these namespaces only.",
+ "apihelp-query+links-param-limit": "How many links to return.",
+ "apihelp-query+links-param-titles": "Only list links to these titles. Useful for checking whether a certain page links to a certain title.",
+ "apihelp-query+links-param-dir": "The direction in which to list.",
+ "apihelp-query+links-example-simple": "Get links from the page <kbd>Main Page</kbd>",
+ "apihelp-query+links-example-generator": "Get information about the link pages in the page <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-namespaces": "Get links from the page <kbd>Main Page</kbd> in the {{ns:user}} and {{ns:template}} namespaces.",
+
+ "apihelp-query+linkshere-summary": "Find all pages that link to the given pages.",
+ "apihelp-query+linkshere-param-prop": "Which properties to get:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "Page ID of each page.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Title of each page.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Flag if the page is a redirect.",
+ "apihelp-query+linkshere-param-namespace": "Only include pages in these namespaces.",
+ "apihelp-query+linkshere-param-limit": "How many to return.",
+ "apihelp-query+linkshere-param-show": "Show only items that meet these criteria:\n;redirect:Only show redirects.\n;!redirect:Only show non-redirects.",
+ "apihelp-query+linkshere-example-simple": "Get a list of pages linking to the [[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "Get information about pages linking to the [[Main Page]].",
+
+ "apihelp-query+logevents-summary": "Get events from logs.",
+ "apihelp-query+logevents-param-prop": "Which properties to get:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Adds the ID of the log event.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Adds the title of the page for the log event.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Adds the type of log event.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Adds the user responsible for the log event.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Adds the user ID who was responsible for the log event.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "Adds the timestamp for the log event.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Adds the comment of the log event.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "Adds the parsed comment of the log event.",
+ "apihelp-query+logevents-paramvalue-prop-details": "Lists additional details about the log event.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Lists tags for the log event.",
+ "apihelp-query+logevents-param-type": "Filter log entries to only this type.",
+ "apihelp-query+logevents-param-action": "Filter log actions to only this action. Overrides <var>$1type</var>. In the list of possible values, values with the asterisk wildcard such as <kbd>action/*</kbd> can have different strings after the slash (/).",
+ "apihelp-query+logevents-param-start": "The timestamp to start enumerating from.",
+ "apihelp-query+logevents-param-end": "The timestamp to end enumerating.",
+ "apihelp-query+logevents-param-user": "Filter entries to those made by the given user.",
+ "apihelp-query+logevents-param-title": "Filter entries to those related to a page.",
+ "apihelp-query+logevents-param-namespace": "Filter entries to those in the given namespace.",
+ "apihelp-query+logevents-param-prefix": "Filter entries that start with this prefix.",
+ "apihelp-query+logevents-param-tag": "Only list event entries tagged with this tag.",
+ "apihelp-query+logevents-param-limit": "How many total event entries to return.",
+ "apihelp-query+logevents-example-simple": "List recent log events.",
+
+ "apihelp-query+pagepropnames-summary": "List all page property names in use on the wiki.",
+ "apihelp-query+pagepropnames-param-limit": "The maximum number of names to return.",
+ "apihelp-query+pagepropnames-example-simple": "Get first 10 property names.",
+
+ "apihelp-query+pageprops-summary": "Get various page properties defined in the page content.",
+ "apihelp-query+pageprops-param-prop": "Only list these page properties (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> returns page property names in use). Useful for checking whether pages use a certain page property.",
+ "apihelp-query+pageprops-example-simple": "Get properties for the pages <kbd>Main Page</kbd> and <kbd>MediaWiki</kbd>.",
+
+ "apihelp-query+pageswithprop-summary": "List all pages using a given page property.",
+ "apihelp-query+pageswithprop-param-propname": "Page property for which to enumerate pages (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> returns page property names in use).",
+ "apihelp-query+pageswithprop-param-prop": "Which pieces of information to include:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Adds the page ID.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Adds the title and namespace ID of the page.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Adds the value of the page property.",
+ "apihelp-query+pageswithprop-param-limit": "The maximum number of pages to return.",
+ "apihelp-query+pageswithprop-param-dir": "In which direction to sort.",
+ "apihelp-query+pageswithprop-example-simple": "List the first 10 pages using <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "Get additional information about the first 10 pages using <code>_&#95;NOTOC_&#95;</code>.",
+
+ "apihelp-query+prefixsearch-summary": "Perform a prefix search for page titles.",
+ "apihelp-query+prefixsearch-extended-description": "Despite the similarity in names, this module is not intended to be equivalent to [[Special:PrefixIndex]]; for that, see <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> with the <kbd>apprefix</kbd> parameter. The purpose of this module is similar to <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>: to take user input and provide the best-matching titles. Depending on the search engine backend, this might include typo correction, redirect avoidance, or other heuristics.",
+ "apihelp-query+prefixsearch-param-search": "Search string.",
+ "apihelp-query+prefixsearch-param-namespace": "Namespaces to search.",
+ "apihelp-query+prefixsearch-param-limit": "Maximum number of results to return.",
+ "apihelp-query+prefixsearch-param-offset": "Number of results to skip.",
+ "apihelp-query+prefixsearch-example-simple": "Search for page titles beginning with <kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "Search profile to use.",
+
+ "apihelp-query+protectedtitles-summary": "List all titles protected from creation.",
+ "apihelp-query+protectedtitles-param-namespace": "Only list titles in these namespaces.",
+ "apihelp-query+protectedtitles-param-level": "Only list titles with these protection levels.",
+ "apihelp-query+protectedtitles-param-limit": "How many total pages to return.",
+ "apihelp-query+protectedtitles-param-start": "Start listing at this protection timestamp.",
+ "apihelp-query+protectedtitles-param-end": "Stop listing at this protection timestamp.",
+ "apihelp-query+protectedtitles-param-prop": "Which properties to get:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "Adds the timestamp of when protection was added.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Adds the user that added the protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Adds the user ID that added the protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Adds the comment for the protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "Adds the parsed comment for the protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "Adds the timestamp of when the protection will be lifted.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Adds the protection level.",
+ "apihelp-query+protectedtitles-example-simple": "List protected titles.",
+ "apihelp-query+protectedtitles-example-generator": "Find links to protected titles in the main namespace.",
+
+ "apihelp-query+querypage-summary": "Get a list provided by a QueryPage-based special page.",
+ "apihelp-query+querypage-param-page": "The name of the special page. Note, this is case sensitive.",
+ "apihelp-query+querypage-param-limit": "Number of results to return.",
+ "apihelp-query+querypage-example-ancientpages": "Return results from [[Special:Ancientpages]].",
+
+ "apihelp-query+random-summary": "Get a set of random pages.",
+ "apihelp-query+random-extended-description": "Pages are listed in a fixed sequence, only the starting point is random. This means that if, for example, <samp>Main Page</samp> is the first random page in the list, <samp>List of fictional monkeys</samp> will <em>always</em> be second, <samp>List of people on stamps of Vanuatu</samp> third, etc.",
+ "apihelp-query+random-param-namespace": "Return pages in these namespaces only.",
+ "apihelp-query+random-param-limit": "Limit how many random pages will be returned.",
+ "apihelp-query+random-param-redirect": "Use <kbd>$1filterredir=redirects</kbd> instead.",
+ "apihelp-query+random-param-filterredir": "How to filter for redirects.",
+ "apihelp-query+random-example-simple": "Return two random pages from the main namespace.",
+ "apihelp-query+random-example-generator": "Return page info about two random pages from the main namespace.",
+
+ "apihelp-query+recentchanges-summary": "Enumerate recent changes.",
+ "apihelp-query+recentchanges-param-start": "The timestamp to start enumerating from.",
+ "apihelp-query+recentchanges-param-end": "The timestamp to end enumerating.",
+ "apihelp-query+recentchanges-param-namespace": "Filter changes to only these namespaces.",
+ "apihelp-query+recentchanges-param-user": "Only list changes by this user.",
+ "apihelp-query+recentchanges-param-excludeuser": "Don't list changes by this user.",
+ "apihelp-query+recentchanges-param-tag": "Only list changes tagged with this tag.",
+ "apihelp-query+recentchanges-param-prop": "Include additional pieces of information:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "Adds the user responsible for the edit and tags if they are an IP.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "Adds the user ID responsible for the edit.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Adds the comment for the edit.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "Adds the parsed comment for the edit.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Adds flags for the edit.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Adds timestamp of the edit.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Adds the page title of the edit.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "Adds the page ID, recent changes ID and the new and old revision ID.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Adds the new and old page length in bytes.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "Tags edit if page is a redirect.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Tags patrollable edits as being patrolled or unpatrolled.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Adds log information (log ID, log type, etc) to log entries.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Lists tags for the entry.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Adds the content checksum for entries associated with a revision.",
+ "apihelp-query+recentchanges-param-token": "Use <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> instead.",
+ "apihelp-query+recentchanges-param-show": "Show only items that meet these criteria. For example, to see only minor edits done by logged-in users, set $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "How many total changes to return.",
+ "apihelp-query+recentchanges-param-type": "Which types of changes to show.",
+ "apihelp-query+recentchanges-param-toponly": "Only list changes which are the latest revision.",
+ "apihelp-query+recentchanges-param-generaterevisions": "When being used as a generator, generate revision IDs rather than titles. Recent change entries without associated revision IDs (e.g. most log entries) will generate nothing.",
+ "apihelp-query+recentchanges-example-simple": "List recent changes.",
+ "apihelp-query+recentchanges-example-generator": "Get page info about recent unpatrolled changes.",
+
+ "apihelp-query+redirects-summary": "Returns all redirects to the given pages.",
+ "apihelp-query+redirects-param-prop": "Which properties to get:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "Page ID of each redirect.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Title of each redirect.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "Fragment of each redirect, if any.",
+ "apihelp-query+redirects-param-namespace": "Only include pages in these namespaces.",
+ "apihelp-query+redirects-param-limit": "How many redirects to return.",
+ "apihelp-query+redirects-param-show": "Show only items that meet these criteria:\n;fragment:Only show redirects with a fragment.\n;!fragment:Only show redirects without a fragment.",
+ "apihelp-query+redirects-example-simple": "Get a list of redirects to the [[Main Page]].",
+ "apihelp-query+redirects-example-generator": "Get information about all redirects to the [[Main Page]].",
+
+ "apihelp-query+revisions-summary": "Get revision information.",
+ "apihelp-query+revisions-extended-description": "May be used in several ways:\n# Get data about a set of pages (last revision), by setting titles or pageids.\n# Get revisions for one given page, by using titles or pageids with start, end, or limit.\n# Get data about a set of revisions by setting their IDs with revids.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "May only be used with a single page (mode #2).",
+ "apihelp-query+revisions-param-startid": "Start enumeration from this revision's timestamp. The revision must exist, but need not belong to this page.",
+ "apihelp-query+revisions-param-endid": "Stop enumeration at this revision's timestamp. The revision must exist, but need not belong to this page.",
+ "apihelp-query+revisions-param-start": "From which revision timestamp to start enumeration.",
+ "apihelp-query+revisions-param-end": "Enumerate up to this timestamp.",
+ "apihelp-query+revisions-param-user": "Only include revisions made by user.",
+ "apihelp-query+revisions-param-excludeuser": "Exclude revisions made by user.",
+ "apihelp-query+revisions-param-tag": "Only list revisions tagged with this tag.",
+ "apihelp-query+revisions-param-token": "Which tokens to obtain for each revision.",
+ "apihelp-query+revisions-example-content": "Get data with content for the last revision of titles <kbd>API</kbd> and <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "Get last 5 revisions of the <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5": "Get first 5 revisions of the <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Get first 5 revisions of the <kbd>Main Page</kbd> made after 2006-05-01.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Get first 5 revisions of the <kbd>Main Page</kbd> that were not made by anonymous user <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "Get first 5 revisions of the <kbd>Main Page</kbd> that were made by the user <kbd>MediaWiki default</kbd>.",
+
+ "apihelp-query+revisions+base-param-prop": "Which properties to get for each revision:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "The ID of the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Revision flags (minor).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "The timestamp of the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "User that made the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "User ID of the revision creator.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Length (bytes) of the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1 (base 16) of the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "Content model ID of the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "Comment by the user for the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "Parsed comment by the user for the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Text of the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Tags for the revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">Deprecated.</span> Use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> or <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> instead. The XML parse tree of revision content (requires content model <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "Limit how many revisions will be returned.",
+ "apihelp-query+revisions+base-param-expandtemplates": "Use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> instead. Expand templates in revision content (requires $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "Use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> or <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> instead. Generate XML parse tree for revision content (requires $1prop=content).",
+ "apihelp-query+revisions+base-param-parse": "Use <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> instead. Parse revision content (requires $1prop=content). For performance reasons, if this option is used, $1limit is enforced to 1.",
+ "apihelp-query+revisions+base-param-section": "Only retrieve the content of this section number.",
+ "apihelp-query+revisions+base-param-diffto": "Use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> instead. Revision ID to diff each revision to. Use <kbd>prev</kbd>, <kbd>next</kbd> and <kbd>cur</kbd> for the previous, next and current revision respectively.",
+ "apihelp-query+revisions+base-param-difftotext": "Use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> instead. Text to diff each revision to. Only diffs a limited number of revisions. Overrides <var>$1diffto</var>. If <var>$1section</var> is set, only that section will be diffed against this text.",
+ "apihelp-query+revisions+base-param-difftotextpst": "Use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> instead. Perform a pre-save transform on the text before diffing it. Only valid when used with <var>$1difftotext</var>.",
+ "apihelp-query+revisions+base-param-contentformat": "Serialization format used for <var>$1difftotext</var> and expected for output of content.",
+
+ "apihelp-query+search-summary": "Perform a full text search.",
+ "apihelp-query+search-param-search": "Search for page titles or content matching this value. You can use the search string to invoke special search features, depending on what the wiki's search backend implements.",
+ "apihelp-query+search-param-namespace": "Search only within these namespaces.",
+ "apihelp-query+search-param-what": "Which type of search to perform.",
+ "apihelp-query+search-param-info": "Which metadata to return.",
+ "apihelp-query+search-param-prop": "Which properties to return:",
+ "apihelp-query+search-param-qiprofile": "Query independent profile to use (affects ranking algorithm).",
+ "apihelp-query+search-paramvalue-prop-size": "Adds the size of the page in bytes.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Adds the word count of the page.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Adds the timestamp of when the page was last edited.",
+ "apihelp-query+search-paramvalue-prop-snippet": "Adds a parsed snippet of the page.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "Adds a parsed snippet of the page title.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "Adds a parsed snippet of the redirect title.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Adds the title of the matching redirect.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "Adds a parsed snippet of the matching section title.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Adds the title of the matching section.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "Adds a parsed snippet of the matching category.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "Adds a boolean indicating if the search matched file content.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignored.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignored.",
+ "apihelp-query+search-param-limit": "How many total pages to return.",
+ "apihelp-query+search-param-interwiki": "Include interwiki results in the search, if available.",
+ "apihelp-query+search-param-backend": "Which search backend to use, if not the default.",
+ "apihelp-query+search-param-enablerewrites": "Enable internal query rewriting. Some search backends can rewrite the query into another which is thought to provide better results, for instance by correcting spelling errors.",
+ "apihelp-query+search-example-simple": "Search for <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Search texts for <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-generator": "Get page info about the pages returned for a search for <kbd>meaning</kbd>.",
+
+ "apihelp-query+siteinfo-summary": "Return general information about the site.",
+ "apihelp-query+siteinfo-param-prop": "Which information to get:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Overall system information.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "List of registered namespaces and their canonical names.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "List of registered namespace aliases.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "List of special page aliases.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "List of magic words and their aliases.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Returns site statistics.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "Returns interwiki map (optionally filtered, optionally localised by using <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "Returns database server with the highest replication lag.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Returns user groups and the associated permissions.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Returns libraries installed on the wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Returns extensions installed on the wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Returns list of file extensions (file types) allowed to be uploaded.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Returns wiki rights (license) information if available.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Returns information on available restriction (protection) types.",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "Returns a list of languages MediaWiki supports (optionally localised by using <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Returns a list of language codes for which [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]] is enabled, and the variants supported for each.",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "Returns a list of all enabled skins (optionally localised by using <var>$1inlanguagecode</var>, otherwise in the content language).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "Returns a list of parser extension tags.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "Returns a list of parser function hooks.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "Returns a list of all subscribed hooks (contents of <var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Returns a list of variable IDs.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "Returns a list of protocols that are allowed in external links.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Returns the default values for user preferences.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "Returns the upload dialog configuration.",
+ "apihelp-query+siteinfo-param-filteriw": "Return only local or only nonlocal entries of the interwiki map.",
+ "apihelp-query+siteinfo-param-showalldb": "List all database servers, not just the one lagging the most.",
+ "apihelp-query+siteinfo-param-numberingroup": "Lists the number of users in user groups.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "Language code for localised language names (best effort) and skin names.",
+ "apihelp-query+siteinfo-example-simple": "Fetch site information.",
+ "apihelp-query+siteinfo-example-interwiki": "Fetch a list of local interwiki prefixes.",
+ "apihelp-query+siteinfo-example-replag": "Check the current replication lag.",
+
+ "apihelp-query+stashimageinfo-summary": "Returns file information for stashed files.",
+ "apihelp-query+stashimageinfo-param-filekey": "Key that identifies a previous upload that was stashed temporarily.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Alias for $1filekey, for backward compatibility.",
+ "apihelp-query+stashimageinfo-example-simple": "Returns information for a stashed file.",
+ "apihelp-query+stashimageinfo-example-params": "Returns thumbnails for two stashed files.",
+
+ "apihelp-query+tags-summary": "List change tags.",
+ "apihelp-query+tags-param-limit": "The maximum number of tags to list.",
+ "apihelp-query+tags-param-prop": "Which properties to get:",
+ "apihelp-query+tags-paramvalue-prop-name": "Adds name of tag.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Adds system message for the tag.",
+ "apihelp-query+tags-paramvalue-prop-description": "Adds description of the tag.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Adds the number of revisions and log entries that have this tag.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Indicate whether the tag is defined.",
+ "apihelp-query+tags-paramvalue-prop-source": "Gets the sources of the tag, which may include <samp>extension</samp> for extension-defined tags and <samp>manual</samp> for tags that may be applied manually by users.",
+ "apihelp-query+tags-paramvalue-prop-active": "Whether the tag is still being applied.",
+ "apihelp-query+tags-example-simple": "List available tags.",
+
+ "apihelp-query+templates-summary": "Returns all pages transcluded on the given pages.",
+ "apihelp-query+templates-param-namespace": "Show templates in these namespaces only.",
+ "apihelp-query+templates-param-limit": "How many templates to return.",
+ "apihelp-query+templates-param-templates": "Only list these templates. Useful for checking whether a certain page uses a certain template.",
+ "apihelp-query+templates-param-dir": "The direction in which to list.",
+ "apihelp-query+templates-example-simple": "Get the templates used on the page <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-generator": "Get information about the template pages used on <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-namespaces": "Get pages in the {{ns:user}} and {{ns:template}} namespaces that are transcluded on the page <kbd>Main Page</kbd>.",
+
+ "apihelp-query+tokens-summary": "Gets tokens for data-modifying actions.",
+ "apihelp-query+tokens-param-type": "Types of token to request.",
+ "apihelp-query+tokens-example-simple": "Retrieve a csrf token (the default).",
+ "apihelp-query+tokens-example-types": "Retrieve a watch token and a patrol token.",
+
+ "apihelp-query+transcludedin-summary": "Find all pages that transclude the given pages.",
+ "apihelp-query+transcludedin-param-prop": "Which properties to get:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "Page ID of each page.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Title of each page.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Flag if the page is a redirect.",
+ "apihelp-query+transcludedin-param-namespace": "Only include pages in these namespaces.",
+ "apihelp-query+transcludedin-param-limit": "How many to return.",
+ "apihelp-query+transcludedin-param-show": "Show only items that meet these criteria:\n;redirect:Only show redirects.\n;!redirect:Only show non-redirects.",
+ "apihelp-query+transcludedin-example-simple": "Get a list of pages transcluding <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Get information about pages transcluding <kbd>Main Page</kbd>.",
+
+ "apihelp-query+usercontribs-summary": "Get all edits by a user.",
+ "apihelp-query+usercontribs-param-limit": "The maximum number of contributions to return.",
+ "apihelp-query+usercontribs-param-start": "The start timestamp to return from.",
+ "apihelp-query+usercontribs-param-end": "The end timestamp to return to.",
+ "apihelp-query+usercontribs-param-user": "The users to retrieve contributions for. Cannot be used with <var>$1userids</var> or <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "Retrieve contributions for all users whose names begin with this value. Cannot be used with <var>$1user</var> or <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "The user IDs to retrieve contributions for. Cannot be used with <var>$1user</var> or <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "Only list contributions in these namespaces.",
+ "apihelp-query+usercontribs-param-prop": "Include additional pieces of information:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Adds the page ID and revision ID.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Adds the title and namespace ID of the page.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Adds the timestamp of the edit.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Adds the comment of the edit.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Adds the parsed comment of the edit.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Adds the new size of the edit.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Adds the size delta of the edit against its parent.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Adds flags of the edit.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Tags patrolled edits.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Lists tags for the edit.",
+ "apihelp-query+usercontribs-param-show": "Show only items that meet these criteria, e.g. non minor edits only: <kbd>$2show=!minor</kbd>.\n\nIf <kbd>$2show=patrolled</kbd> or <kbd>$2show=!patrolled</kbd> is set, revisions older than <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var> ($1 {{PLURAL:$1|second|seconds}}) won't be shown.",
+ "apihelp-query+usercontribs-param-tag": "Only list revisions tagged with this tag.",
+ "apihelp-query+usercontribs-param-toponly": "Only list changes which are the latest revision.",
+ "apihelp-query+usercontribs-example-user": "Show contributions of user <kbd>Example</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "Show contributions from all IP addresses with prefix <kbd>192.0.2.</kbd>.",
+
+ "apihelp-query+userinfo-summary": "Get information about the current user.",
+ "apihelp-query+userinfo-param-prop": "Which pieces of information to include:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Tags if the current user is blocked, by whom, and for what reason.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Adds a tag <samp>messages</samp> if the current user has pending messages.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Lists all the groups the current user belongs to.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Lists groups that the current user has been explicitly assigned to, including the expiry date of each group membership.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Lists all the groups the current user is automatically a member of.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Lists all the rights the current user has.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Lists the groups the current user can add to and remove from.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Lists all preferences the current user has set.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Get a token to change current user's preferences.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Adds the current user's edit count.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Lists all rate limits applying to the current user.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Adds the user's real name.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Adds the user's email address and email authentication date.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Echoes the <code>Accept-Language</code> header sent by the client in a structured format.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Adds the user's registration date.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Adds the count of unread pages on the user's watchlist (maximum $1; returns <samp>$2</samp> if more).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "Adds the central IDs and attachment status for the user.",
+ "apihelp-query+userinfo-param-attachedwiki": "With <kbd>$1prop=centralids</kbd>, indicate whether the user is attached with the wiki identified by this ID.",
+ "apihelp-query+userinfo-example-simple": "Get information about the current user.",
+ "apihelp-query+userinfo-example-data": "Get additional information about the current user.",
+
+ "apihelp-query+users-summary": "Get information about a list of users.",
+ "apihelp-query+users-param-prop": "Which pieces of information to include:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Tags if the user is blocked, by whom, and for what reason.",
+ "apihelp-query+users-paramvalue-prop-groups": "Lists all the groups each user belongs to.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "Lists groups that each user has been explicitly assigned to, including the expiry date of each group membership.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Lists all the groups a user is automatically a member of.",
+ "apihelp-query+users-paramvalue-prop-rights": "Lists all the rights each user has.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Adds the user's edit count.",
+ "apihelp-query+users-paramvalue-prop-registration": "Adds the user's registration timestamp.",
+ "apihelp-query+users-paramvalue-prop-emailable": "Tags if the user can and wants to receive email through [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "Tags the gender of the user. Returns \"male\", \"female\", or \"unknown\".",
+ "apihelp-query+users-paramvalue-prop-centralids": "Adds the central IDs and attachment status for the user.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Indicates whether an account for valid but unregistered usernames can be created.",
+ "apihelp-query+users-param-attachedwiki": "With <kbd>$1prop=centralids</kbd>, indicate whether the user is attached with the wiki identified by this ID.",
+ "apihelp-query+users-param-users": "A list of users to obtain information for.",
+ "apihelp-query+users-param-userids": "A list of user IDs to obtain information for.",
+ "apihelp-query+users-param-token": "Use <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> instead.",
+ "apihelp-query+users-example-simple": "Return information for user <kbd>Example</kbd>.",
+
+ "apihelp-query+watchlist-summary": "Get recent changes to pages in the current user's watchlist.",
+ "apihelp-query+watchlist-param-allrev": "Include multiple revisions of the same page within given timeframe.",
+ "apihelp-query+watchlist-param-start": "The timestamp to start enumerating from.",
+ "apihelp-query+watchlist-param-end": "The timestamp to end enumerating.",
+ "apihelp-query+watchlist-param-namespace": "Filter changes to only the given namespaces.",
+ "apihelp-query+watchlist-param-user": "Only list changes by this user.",
+ "apihelp-query+watchlist-param-excludeuser": "Don't list changes by this user.",
+ "apihelp-query+watchlist-param-limit": "How many total results to return per request.",
+ "apihelp-query+watchlist-param-prop": "Which additional properties to get:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Adds revision IDs and page IDs.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Adds title of the page.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Adds flags for the edit.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Adds the user who made the edit.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Adds user ID of whoever made the edit.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Adds comment of the edit.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Adds parsed comment of the edit.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Adds timestamp of the edit.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Tags edits that are patrolled.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Adds the old and new lengths of the page.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Adds timestamp of when the user was last notified about the edit.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Adds log information where appropriate.",
+ "apihelp-query+watchlist-param-show": "Show only items that meet these criteria. For example, to see only minor edits done by logged-in users, set $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "Which types of changes to show:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Regular page edits.",
+ "apihelp-query+watchlist-paramvalue-type-external": "External changes.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Page creations.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Log entries.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Category membership changes.",
+ "apihelp-query+watchlist-param-owner": "Used along with $1token to access a different user's watchlist.",
+ "apihelp-query+watchlist-param-token": "A security token (available in the user's [[Special:Preferences#mw-prefsection-watchlist|preferences]]) to allow access to another user's watchlist.",
+ "apihelp-query+watchlist-example-simple": "List the top revision for recently changed pages on the current user's watchlist.",
+ "apihelp-query+watchlist-example-props": "Fetch additional information about the top revision for recently changed pages on the current user's watchlist.",
+ "apihelp-query+watchlist-example-allrev": "Fetch information about all recent changes to pages on the current user's watchlist.",
+ "apihelp-query+watchlist-example-generator": "Fetch page info for recently changed pages on the current user's watchlist.",
+ "apihelp-query+watchlist-example-generator-rev": "Fetch revision info for recent changes to pages on the current user's watchlist.",
+ "apihelp-query+watchlist-example-wlowner": "List the top revision for recently changed pages on the watchlist of user <kbd>Example</kbd>.",
+
+ "apihelp-query+watchlistraw-summary": "Get all pages on the current user's watchlist.",
+ "apihelp-query+watchlistraw-param-namespace": "Only list pages in the given namespaces.",
+ "apihelp-query+watchlistraw-param-limit": "How many total results to return per request.",
+ "apihelp-query+watchlistraw-param-prop": "Which additional properties to get:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Adds timestamp of when the user was last notified about the edit.",
+ "apihelp-query+watchlistraw-param-show": "Only list items that meet these criteria.",
+ "apihelp-query+watchlistraw-param-owner": "Used along with $1token to access a different user's watchlist.",
+ "apihelp-query+watchlistraw-param-token": "A security token (available in the user's [[Special:Preferences#mw-prefsection-watchlist|preferences]]) to allow access to another user's watchlist.",
+ "apihelp-query+watchlistraw-param-dir": "The direction in which to list.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Title (with namespace prefix) to begin enumerating from.",
+ "apihelp-query+watchlistraw-param-totitle": "Title (with namespace prefix) to stop enumerating at.",
+ "apihelp-query+watchlistraw-example-simple": "List pages on the current user's watchlist.",
+ "apihelp-query+watchlistraw-example-generator": "Fetch page info for pages on the current user's watchlist.",
+
+ "apihelp-removeauthenticationdata-summary": "Remove authentication data for the current user.",
+ "apihelp-removeauthenticationdata-example-simple": "Attempt to remove the current user's data for <kbd>FooAuthenticationRequest</kbd>.",
+
+ "apihelp-resetpassword-summary": "Send a password reset email to a user.",
+ "apihelp-resetpassword-extended-description-noroutes": "No password reset routes are available.\n\nEnable routes in <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var> to use this module.",
+ "apihelp-resetpassword-param-user": "User being reset.",
+ "apihelp-resetpassword-param-email": "Email address of the user being reset.",
+ "apihelp-resetpassword-example-user": "Send a password reset email to user <kbd>Example</kbd>.",
+ "apihelp-resetpassword-example-email": "Send a password reset email for all users with email address <kbd>user@example.com</kbd>.",
+
+ "apihelp-revisiondelete-summary": "Delete and undelete revisions.",
+ "apihelp-revisiondelete-param-type": "Type of revision deletion being performed.",
+ "apihelp-revisiondelete-param-target": "Page title for the revision deletion, if required for the type.",
+ "apihelp-revisiondelete-param-ids": "Identifiers for the revisions to be deleted.",
+ "apihelp-revisiondelete-param-hide": "What to hide for each revision.",
+ "apihelp-revisiondelete-param-show": "What to unhide for each revision.",
+ "apihelp-revisiondelete-param-suppress": "Whether to suppress data from administrators as well as others.",
+ "apihelp-revisiondelete-param-reason": "Reason for the deletion or undeletion.",
+ "apihelp-revisiondelete-param-tags": "Tags to apply to the entry in the deletion log.",
+ "apihelp-revisiondelete-example-revision": "Hide content for revision <kbd>12345</kbd> on the page <kbd>Main Page</kbd>.",
+ "apihelp-revisiondelete-example-log": "Hide all data on log entry <kbd>67890</kbd> with the reason <kbd>BLP violation</kbd>.",
+
+ "apihelp-rollback-summary": "Undo the last edit to the page.",
+ "apihelp-rollback-extended-description": "If the last user who edited the page made multiple edits in a row, they will all be rolled back.",
+ "apihelp-rollback-param-title": "Title of the page to roll back. Cannot be used together with <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "Page ID of the page to roll back. Cannot be used together with <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "Tags to apply to the rollback.",
+ "apihelp-rollback-param-user": "Name of the user whose edits are to be rolled back.",
+ "apihelp-rollback-param-summary": "Custom edit summary. If empty, default summary will be used.",
+ "apihelp-rollback-param-markbot": "Mark the reverted edits and the revert as bot edits.",
+ "apihelp-rollback-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-rollback-example-simple": "Roll back the last edits to page <kbd>Main Page</kbd> by user <kbd>Example</kbd>.",
+ "apihelp-rollback-example-summary": "Roll back the last edits to page <kbd>Main Page</kbd> by IP user <kbd>192.0.2.5</kbd> with summary <kbd>Reverting vandalism</kbd>, and mark those edits and the revert as bot edits.",
+
+ "apihelp-rsd-summary": "Export an RSD (Really Simple Discovery) schema.",
+ "apihelp-rsd-example-simple": "Export the RSD schema.",
+
+ "apihelp-setnotificationtimestamp-summary": "Update the notification timestamp for watched pages.",
+ "apihelp-setnotificationtimestamp-extended-description": "This affects the highlighting of changed pages in the watchlist and history, and the sending of email when the \"{{int:tog-enotifwatchlistpages}}\" preference is enabled.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Work on all watched pages.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "Timestamp to which to set the notification timestamp.",
+ "apihelp-setnotificationtimestamp-param-torevid": "Revision to set the notification timestamp to (one page only).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "Revision to set the notification timestamp newer than (one page only).",
+ "apihelp-setnotificationtimestamp-example-all": "Reset the notification status for the entire watchlist.",
+ "apihelp-setnotificationtimestamp-example-page": "Reset the notification status for <kbd>Main page</kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "Set the notification timestamp for <kbd>Main page</kbd> so all edits since 1 January 2012 are unviewed.",
+ "apihelp-setnotificationtimestamp-example-allpages": "Reset the notification status for pages in the <kbd>{{ns:user}}</kbd> namespace.",
+
+ "apihelp-setpagelanguage-summary": "Change the language of a page.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Changing the language of a page is not allowed on this wiki.\n\nEnable <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> to use this action.",
+ "apihelp-setpagelanguage-param-title": "Title of the page whose language you wish to change. Cannot be used together with <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "Page ID of the page whose language you wish to change. Cannot be used together with <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "Language code of the language to change the page to. Use <kbd>default</kbd> to reset the page to the wiki's default content language.",
+ "apihelp-setpagelanguage-param-reason": "Reason for the change.",
+ "apihelp-setpagelanguage-param-tags": "Change tags to apply to the log entry resulting from this action.",
+ "apihelp-setpagelanguage-example-language": "Change the language of <kbd>Main Page</kbd> to Basque.",
+ "apihelp-setpagelanguage-example-default": "Change the language of the page with ID 123 to the wiki's default content language.",
+
+ "apihelp-stashedit-summary": "Prepare an edit in shared cache.",
+ "apihelp-stashedit-extended-description": "This is intended to be used via AJAX from the edit form to improve the performance of the page save.",
+ "apihelp-stashedit-param-title": "Title of the page being edited.",
+ "apihelp-stashedit-param-section": "Section number. <kbd>0</kbd> for the top section, <kbd>new</kbd> for a new section.",
+ "apihelp-stashedit-param-sectiontitle": "The title for a new section.",
+ "apihelp-stashedit-param-text": "Page content.",
+ "apihelp-stashedit-param-stashedtexthash": "Page content hash from a prior stash to use instead.",
+ "apihelp-stashedit-param-contentmodel": "Content model of the new content.",
+ "apihelp-stashedit-param-contentformat": "Content serialization format used for the input text.",
+ "apihelp-stashedit-param-baserevid": "Revision ID of the base revision.",
+ "apihelp-stashedit-param-summary": "Change summary.",
+
+ "apihelp-tag-summary": "Add or remove change tags from individual revisions or log entries.",
+ "apihelp-tag-param-rcid": "One or more recent changes IDs from which to add or remove the tag.",
+ "apihelp-tag-param-revid": "One or more revision IDs from which to add or remove the tag.",
+ "apihelp-tag-param-logid": "One or more log entry IDs from which to add or remove the tag.",
+ "apihelp-tag-param-add": "Tags to add. Only manually defined tags can be added.",
+ "apihelp-tag-param-remove": "Tags to remove. Only tags that are either manually defined or completely undefined can be removed.",
+ "apihelp-tag-param-reason": "Reason for the change.",
+ "apihelp-tag-param-tags": "Tags to apply to the log entry that will be created as a result of this action.",
+ "apihelp-tag-example-rev": "Add the <kbd>vandalism</kbd> tag to revision ID 123 without specifying a reason",
+ "apihelp-tag-example-log": "Remove the <kbd>spam</kbd> tag from log entry ID 123 with the reason <kbd>Wrongly applied</kbd>",
+
+ "apihelp-tokens-summary": "Get tokens for data-modifying actions.",
+ "apihelp-tokens-extended-description": "This module is deprecated in favor of [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-tokens-param-type": "Types of token to request.",
+ "apihelp-tokens-example-edit": "Retrieve an edit token (the default).",
+ "apihelp-tokens-example-emailmove": "Retrieve an email token and a move token.",
+
+ "apihelp-unblock-summary": "Unblock a user.",
+ "apihelp-unblock-param-id": "ID of the block to unblock (obtained through <kbd>list=blocks</kbd>). Cannot be used together with <var>$1user</var> or <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "Username, IP address or IP address range to unblock. Cannot be used together with <var>$1id</var> or <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "User ID to unblock. Cannot be used together with <var>$1id</var> or <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "Reason for unblock.",
+ "apihelp-unblock-param-tags": "Change tags to apply to the entry in the block log.",
+ "apihelp-unblock-example-id": "Unblock block ID #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "Unblock user <kbd>Bob</kbd> with reason <kbd>Sorry Bob</kbd>.",
+
+ "apihelp-undelete-summary": "Restore revisions of a deleted page.",
+ "apihelp-undelete-extended-description": "A list of deleted revisions (including timestamps) can be retrieved through [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]], and a list of deleted file IDs can be retrieved through [[Special:ApiHelp/query+filearchive|list=filearchive]].",
+ "apihelp-undelete-param-title": "Title of the page to restore.",
+ "apihelp-undelete-param-reason": "Reason for restoring.",
+ "apihelp-undelete-param-tags": "Change tags to apply to the entry in the deletion log.",
+ "apihelp-undelete-param-timestamps": "Timestamps of the revisions to restore. If both <var>$1timestamps</var> and <var>$1fileids</var> are empty, all will be restored.",
+ "apihelp-undelete-param-fileids": "IDs of the file revisions to restore. If both <var>$1timestamps</var> and <var>$1fileids</var> are empty, all will be restored.",
+ "apihelp-undelete-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-undelete-example-page": "Undelete page <kbd>Main Page</kbd>.",
+ "apihelp-undelete-example-revisions": "Undelete two revisions of page <kbd>Main Page</kbd>.",
+
+ "apihelp-unlinkaccount-summary": "Remove a linked third-party account from the current user.",
+ "apihelp-unlinkaccount-example-simple": "Attempt to remove the current user's link for the provider associated with <kbd>FooAuthenticationRequest</kbd>.",
+
+ "apihelp-upload-summary": "Upload a file, or get the status of pending uploads.",
+ "apihelp-upload-extended-description": "Several methods are available:\n* Upload file contents directly, using the <var>$1file</var> parameter.\n* Upload the file in pieces, using the <var>$1filesize</var>, <var>$1chunk</var>, and <var>$1offset</var> parameters.\n* Have the MediaWiki server fetch a file from a URL, using the <var>$1url</var> parameter.\n* Complete an earlier upload that failed due to warnings, using the <var>$1filekey</var> parameter.\nNote that the HTTP POST must be done as a file upload (i.e. using <code>multipart/form-data</code>) when sending the <var>$1file</var>.",
+ "apihelp-upload-param-filename": "Target filename.",
+ "apihelp-upload-param-comment": "Upload comment. Also used as the initial page text for new files if <var>$1text</var> is not specified.",
+ "apihelp-upload-param-tags": "Change tags to apply to the upload log entry and file page revision.",
+ "apihelp-upload-param-text": "Initial page text for new files.",
+ "apihelp-upload-param-watch": "Watch the page.",
+ "apihelp-upload-param-watchlist": "Unconditionally add or remove the page from the current user's watchlist, use preferences or do not change watch.",
+ "apihelp-upload-param-ignorewarnings": "Ignore any warnings.",
+ "apihelp-upload-param-file": "File contents.",
+ "apihelp-upload-param-url": "URL to fetch the file from.",
+ "apihelp-upload-param-filekey": "Key that identifies a previous upload that was stashed temporarily.",
+ "apihelp-upload-param-sessionkey": "Same as $1filekey, maintained for backward compatibility.",
+ "apihelp-upload-param-stash": "If set, the server will stash the file temporarily instead of adding it to the repository.",
+ "apihelp-upload-param-filesize": "Filesize of entire upload.",
+ "apihelp-upload-param-offset": "Offset of chunk in bytes.",
+ "apihelp-upload-param-chunk": "Chunk contents.",
+ "apihelp-upload-param-async": "Make potentially large file operations asynchronous when possible.",
+ "apihelp-upload-param-checkstatus": "Only fetch the upload status for the given file key.",
+ "apihelp-upload-example-url": "Upload from a URL.",
+ "apihelp-upload-example-filekey": "Complete an upload that failed due to warnings.",
+
+ "apihelp-userrights-summary": "Change a user's group membership.",
+ "apihelp-userrights-param-user": "User name.",
+ "apihelp-userrights-param-userid": "User ID.",
+ "apihelp-userrights-param-add": "Add the user to these groups, or if they are already a member, update the expiry of their membership in that group.",
+ "apihelp-userrights-param-expiry": "Expiry timestamps. May be relative (e.g. <kbd>5 months</kbd> or <kbd>2 weeks</kbd>) or absolute (e.g. <kbd>2014-09-18T12:34:56Z</kbd>). If only one timestamp is set, it will be used for all groups passed to the <var>$1add</var> parameter. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, or <kbd>never</kbd> for a never-expiring user group.",
+ "apihelp-userrights-param-remove": "Remove the user from these groups.",
+ "apihelp-userrights-param-reason": "Reason for the change.",
+ "apihelp-userrights-param-tags": "Change tags to apply to the entry in the user rights log.",
+ "apihelp-userrights-example-user": "Add user <kbd>FooBot</kbd> to group <kbd>bot</kbd>, and remove from groups <kbd>sysop</kbd> and <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Add the user with ID <kbd>123</kbd> to group <kbd>bot</kbd>, and remove from groups <kbd>sysop</kbd> and <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-expiry": "Add user <kbd>SometimeSysop</kbd> to group <kbd>sysop</kbd> for 1 month.",
+
+ "apihelp-validatepassword-summary": "Validate a password against the wiki's password policies.",
+ "apihelp-validatepassword-extended-description": "Validity is reported as <samp>Good</samp> if the password is acceptable, <samp>Change</samp> if the password may be used for login but must be changed, or <samp>Invalid</samp> if the password is not usable.",
+ "apihelp-validatepassword-param-password": "Password to validate.",
+ "apihelp-validatepassword-param-user": "User name, for use when testing account creation. The named user must not exist.",
+ "apihelp-validatepassword-param-email": "Email address, for use when testing account creation.",
+ "apihelp-validatepassword-param-realname": "Real name, for use when testing account creation.",
+ "apihelp-validatepassword-example-1": "Validate the password <kbd>foobar</kbd> for the current user.",
+ "apihelp-validatepassword-example-2": "Validate the password <kbd>qwerty</kbd> for creating user <kbd>Example</kbd>.",
+
+ "apihelp-watch-summary": "Add or remove pages from the current user's watchlist.",
+ "apihelp-watch-param-title": "The page to (un)watch. Use <var>$1titles</var> instead.",
+ "apihelp-watch-param-unwatch": "If set the page will be unwatched rather than watched.",
+ "apihelp-watch-example-watch": "Watch the page <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Unwatch the page <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-generator": "Watch the first few pages in the main namespace.",
+
+ "apihelp-format-example-generic": "Return the query result in the $1 format.",
+ "apihelp-format-param-wrappedhtml": "Return the pretty-printed HTML and associated ResourceLoader modules as a JSON object.",
+ "apihelp-json-summary": "Output data in JSON format.",
+ "apihelp-json-param-callback": "If specified, wraps the output into a given function call. For safety, all user-specific data will be restricted.",
+ "apihelp-json-param-utf8": "If specified, encodes most (but not all) non-ASCII characters as UTF-8 instead of replacing them with hexadecimal escape sequences. Default when <var>formatversion</var> is not <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "If specified, encodes all non-ASCII using hexadecimal escape sequences. Default when <var>formatversion</var> is <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Output formatting:\n;1:Backwards-compatible format (XML-style booleans, <samp>*</samp> keys for content nodes, etc.).\n;2:Experimental modern format. Details may change!\n;latest:Use the latest format (currently <kbd>2</kbd>), may change without warning.",
+ "apihelp-jsonfm-summary": "Output data in JSON format (pretty-print in HTML).",
+ "apihelp-none-summary": "Output nothing.",
+ "apihelp-php-summary": "Output data in serialized PHP format.",
+ "apihelp-php-param-formatversion": "Output formatting:\n;1:Backwards-compatible format (XML-style booleans, <samp>*</samp> keys for content nodes, etc.).\n;2:Experimental modern format. Details may change!\n;latest:Use the latest format (currently <kbd>2</kbd>), may change without warning.",
+ "apihelp-phpfm-summary": "Output data in serialized PHP format (pretty-print in HTML).",
+ "apihelp-rawfm-summary": "Output data, including debugging elements, in JSON format (pretty-print in HTML).",
+ "apihelp-xml-summary": "Output data in XML format.",
+ "apihelp-xml-param-xslt": "If specified, adds the named page as an XSL stylesheet. The value must be a title in the {{ns:MediaWiki}} namespace ending in <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "If specified, adds an XML namespace.",
+ "apihelp-xmlfm-summary": "Output data in XML format (pretty-print in HTML).",
+
+ "api-format-title": "MediaWiki API result",
+ "api-format-prettyprint-header": "This is the HTML representation of the $1 format. HTML is good for debugging, but is unsuitable for application use.\n\nSpecify the <var>format</var> parameter to change the output format. To see the non-HTML representation of the $1 format, set <kbd>format=$2</kbd>.\n\nSee the [[mw:Special:MyLanguage/API|complete documentation]], or the [[Special:ApiHelp/main|API help]] for more information.",
+ "api-format-prettyprint-header-only-html": "This is an HTML representation intended for debugging, and is unsuitable for application use.\n\nSee the [[mw:Special:MyLanguage/API|complete documentation]], or the [[Special:ApiHelp/main|API help]] for more information.",
+ "api-format-prettyprint-header-hyperlinked": "This is the HTML representation of the $1 format. HTML is good for debugging, but is unsuitable for application use.\n\nSpecify the <var>format</var> parameter to change the output format. To see the non-HTML representation of the $1 format, set [$3 <kbd>format=$2</kbd>].\n\nSee the [[mw:API|complete documentation]], or the [[Special:ApiHelp/main|API help]] for more information.",
+ "api-format-prettyprint-status": "This response would be returned with HTTP status $1 $2.",
+
+ "api-login-fail-aborted": "Authentication requires user interaction, which is not supported by <kbd>action=login</kbd>. To be able to login with <kbd>action=login</kbd>, see [[Special:BotPasswords]]. To continue using main-account login, see <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-aborted-nobotpw": "Authentication requires user interaction, which is not supported by <kbd>action=login</kbd>. To log in, see <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-badsessionprovider": "Cannot log in when using $1.",
+ "api-login-fail-sameorigin": "Cannot log in when the same-origin policy is not applied.",
+
+ "api-pageset-param-titles": "A list of titles to work on.",
+ "api-pageset-param-pageids": "A list of page IDs to work on.",
+ "api-pageset-param-revids": "A list of revision IDs to work on.",
+ "api-pageset-param-generator": "Get the list of pages to work on by executing the specified query module.\n\n<strong>Note:</strong> Generator parameter names must be prefixed with a \"g\", see examples.",
+ "api-pageset-param-redirects-generator": "Automatically resolve redirects in <var>$1titles</var>, <var>$1pageids</var>, and <var>$1revids</var>, and in pages returned by <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Automatically resolve redirects in <var>$1titles</var>, <var>$1pageids</var>, and <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Convert titles to other variants if necessary. Only works if the wiki's content language supports variant conversion. Languages that support variant conversion include $1.",
+
+ "api-help-title": "MediaWiki API help",
+ "api-help-lead": "This is an auto-generated MediaWiki API documentation page.\n\nDocumentation and examples: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Main module",
+ "api-help-undocumented-module": "No documentation for module $1.",
+ "api-help-fallback-description": "$1",
+ "api-help-fallback-parameter": "$1",
+ "api-help-fallback-example": "$1",
+ "api-help-flags": "",
+ "api-help-flag-deprecated": "This module is deprecated.",
+ "api-help-flag-internal": "<strong>This module is internal or unstable.</strong> Its operation may change without notice.",
+ "api-help-flag-readrights": "This module requires read rights.",
+ "api-help-flag-writerights": "This module requires write rights.",
+ "api-help-flag-mustbeposted": "This module only accepts POST requests.",
+ "api-help-flag-generator": "This module can be used as a generator.",
+ "api-help-source": "Source: $1",
+ "api-help-source-unknown": "Source: <span class=\"apihelp-unknown\">unknown</span>",
+ "api-help-license": "License: [[$1|$2]]",
+ "api-help-license-noname": "License: [[$1|See link]]",
+ "api-help-license-unknown": "License: <span class=\"apihelp-unknown\">unknown</span>",
+ "api-help-help-urls": "",
+ "api-help-parameters": "{{PLURAL:$1|Parameter|Parameters}}:",
+ "api-help-param-deprecated": "Deprecated.",
+ "api-help-param-required": "This parameter is required.",
+ "api-help-datatypes-header": "Data types",
+ "api-help-datatypes": "Input to MediaWiki should be NFC-normalized UTF-8. MediaWiki may attempt to convert other input, but this may cause some operations (such as [[Special:ApiHelp/edit|edits]] with MD5 checks) to fail.\n\nSome parameter types in API requests need further explanation:\n;boolean\n:Boolean parameters work like HTML checkboxes: if the parameter is specified, regardless of value, it is considered true. For a false value, omit the parameter entirely.\n;timestamp\n:Timestamps may be specified in several formats. ISO 8601 date and time is recommended. All times are in UTC, any included timezone is ignored.\n:* ISO 8601 date and time, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (punctuation and <kbd>Z</kbd> are optional)\n:* ISO 8601 date and time with (ignored) fractional seconds, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (dashes, colons, and <kbd>Z</kbd> are optional)\n:* MediaWiki format, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Generic numeric format, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (optional timezone of <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd>, or <kbd>-<var>##</var></kbd> is ignored)\n:* EXIF format, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:*RFC 2822 format (timezone may be omitted), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* RFC 850 format (timezone may be omitted), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* C ctime format, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Seconds since 1970-01-01T00:00:00Z as a 1 to 13 digit integer (excluding <kbd>0</kbd>)\n:* The string <kbd>now</kbd>\n;alternative multiple-value separator\n:Parameters that take multiple values are normally submitted with the values separated using the pipe character, e.g. <kbd>param=value1|value2</kbd> or <kbd>param=value1%7Cvalue2</kbd>. If a value must contain the pipe character, use U+001F (Unit Separator) as the separator ''and'' prefix the value with U+001F, e.g. <kbd>param=%1Fvalue1%1Fvalue2</kbd>.",
+ "api-help-param-type-limit": "Type: integer or <kbd>max</kbd>",
+ "api-help-param-type-integer": "Type: {{PLURAL:$1|1=integer|2=list of integers}}",
+ "api-help-param-type-boolean": "Type: boolean ([[Special:ApiHelp/main#main/datatypes|details]])",
+ "api-help-param-type-password": "",
+ "api-help-param-type-timestamp": "Type: {{PLURAL:$1|1=timestamp|2=list of timestamps}} ([[Special:ApiHelp/main#main/datatypes|allowed formats]])",
+ "api-help-param-type-user": "Type: {{PLURAL:$1|1=user name|2=list of user names}}",
+ "api-help-param-list": "{{PLURAL:$1|1=One of the following values|2=Values (separate with <kbd>{{!}}</kbd> or [[Special:ApiHelp/main#main/datatypes|alternative]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Must be empty|Can be empty, or $2}}",
+ "api-help-param-limit": "No more than $1 allowed.",
+ "api-help-param-limit2": "No more than $1 ($2 for bots) allowed.",
+ "api-help-param-integer-min": "The {{PLURAL:$1|1=value|2=values}} must be no less than $2.",
+ "api-help-param-integer-max": "The {{PLURAL:$1|1=value|2=values}} must be no greater than $3.",
+ "api-help-param-integer-minmax": "The {{PLURAL:$1|1=value|2=values}} must be between $2 and $3.",
+ "api-help-param-upload": "Must be posted as a file upload using multipart/form-data.",
+ "api-help-param-multi-separate": "Separate values with <kbd>|</kbd> or [[Special:ApiHelp/main#main/datatypes|alternative]].",
+ "api-help-param-multi-max": "Maximum number of values is {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} for bots).",
+ "api-help-param-multi-max-simple": "Maximum number of values is {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "To specify all values, use <kbd>$1</kbd>.",
+ "api-help-param-default": "Default: $1",
+ "api-help-param-default-empty": "Default: <span class=\"apihelp-empty\">(empty)</span>",
+ "api-help-param-token": "A \"$1\" token retrieved from [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "For compatibility, the token used in the web UI is also accepted.",
+ "api-help-param-disabled-in-miser-mode": "Disabled due to [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]].",
+ "api-help-param-limited-in-miser-mode": "<strong>Note:</strong> Due to [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]], using this may result in fewer than <var>$1limit</var> results returned before continuing; in extreme cases, zero results may be returned.",
+ "api-help-param-direction": "In which direction to enumerate:\n;newer:List oldest first. Note: $1start has to be before $1end.\n;older:List newest first (default). Note: $1start has to be later than $1end.",
+ "api-help-param-continue": "When more results are available, use this to continue.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(no description)</span>",
+ "api-help-examples": "{{PLURAL:$1|Example|Examples}}:",
+ "api-help-permissions": "{{PLURAL:$1|Permission|Permissions}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Granted to}}: $2",
+ "api-help-right-apihighlimits": "Use higher limits in API queries (slow queries: $1; fast queries: $2). The limits for slow queries also apply to multivalue parameters.",
+ "api-help-open-in-apisandbox": "<small>[open in sandbox]</small>",
+ "api-help-no-extended-description": "",
+
+ "api-help-authmanager-general-usage": "The general procedure to use this module is:\n# Fetch the fields available from <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> with <kbd>amirequestsfor=$4</kbd>, and a <kbd>$5</kbd> token from <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# Present the fields to the user, and obtain their submission.\n# Post to this module, supplying <var>$1returnurl</var> and any relevant fields.\n# Check the <samp>status</samp> in the response.\n#* If you received <samp>PASS</samp> or <samp>FAIL</samp>, you're done. The operation either succeeded or it didn't.\n#* If you received <samp>UI</samp>, present the new fields to the user and obtain their submission. Then post to this module with <var>$1continue</var> and the relevant fields set, and repeat step 4.\n#* If you received <samp>REDIRECT</samp>, direct the user to the <samp>redirecttarget</samp> and wait for the return to <var>$1returnurl</var>. Then post to this module with <var>$1continue</var> and any fields passed to the return URL, and repeat step 4.\n#* If you received <samp>RESTART</samp>, that means the authentication worked but we don't have a linked user account. You might treat this as <samp>UI</samp> or as <samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-requests": "Only use these authentication requests, by the <samp>id</samp> returned from <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> with <kbd>amirequestsfor=$1</kbd> or from a previous response from this module.",
+ "api-help-authmanagerhelper-request": "Use this authentication request, by the <samp>id</samp> returned from <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> with <kbd>amirequestsfor=$1</kbd>.",
+ "api-help-authmanagerhelper-messageformat": "Format to use for returning messages.",
+ "api-help-authmanagerhelper-mergerequestfields": "Merge field information for all authentication requests into one array.",
+ "api-help-authmanagerhelper-preservestate": "Preserve state from a previous failed login attempt, if possible.",
+ "api-help-authmanagerhelper-returnurl": "Return URL for third-party authentication flows, must be absolute. Either this or <var>$1continue</var> is required.\n\nUpon receiving a <samp>REDIRECT</samp> response, you will typically open a browser or web view to the specified <samp>redirecttarget</samp> URL for a third-party authentication flow. When that completes, the third party will send the browser or web view to this URL. You should extract any query or POST parameters from the URL and pass them as a <var>$1continue</var> request to this API module.",
+ "api-help-authmanagerhelper-continue": "This request is a continuation after an earlier <samp>UI</samp> or <samp>REDIRECT</samp> response. Either this or <var>$1returnurl</var> is required.",
+ "api-help-authmanagerhelper-additional-params": "This module accepts additional parameters depending on the available authentication requests. Use <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> with <kbd>amirequestsfor=$1</kbd> (or a previous response from this module, if applicable) to determine the requests available and the fields that they use.",
+
+ "apierror-allimages-redirect": "Use <kbd>gaifilterredir=nonredirects</kbd> instead of <var>redirects</var> when using <kbd>allimages</kbd> as a generator.",
+ "apierror-allpages-generator-redirects": "Use <kbd>gapfilterredir=nonredirects</kbd> instead of <var>redirects</var> when using <kbd>allpages</kbd> as a generator.",
+ "apierror-appendnotsupported": "Can't append to pages using content model $1.",
+ "apierror-articleexists": "The article you tried to create has been created already.",
+ "apierror-assertbotfailed": "Assertion that the user has the <code>bot</code> right failed.",
+ "apierror-assertnameduserfailed": "Assertion that the user is \"$1\" failed.",
+ "apierror-assertuserfailed": "Assertion that the user is logged in failed.",
+ "apierror-autoblocked": "Your IP address has been blocked automatically, because it was used by a blocked user.",
+ "apierror-badconfig-resulttoosmall": "The value of <code>$wgAPIMaxResultSize</code> on this wiki is too small to hold basic result information.",
+ "apierror-badcontinue": "Invalid continue param. You should pass the original value returned by the previous query.",
+ "apierror-baddiff": "The diff cannot be retrieved. One or both revisions do not exist or you do not have permission to view them.",
+ "apierror-baddiffto": "<var>$1diffto</var> must be set to a non-negative number, <kbd>prev</kbd>, <kbd>next</kbd> or <kbd>cur</kbd>.",
+ "apierror-badformat-generic": "The requested format $1 is not supported for content model $2.",
+ "apierror-badformat": "The requested format $1 is not supported for content model $2 used by $3.",
+ "apierror-badgenerator-notgenerator": "Module <kbd>$1</kbd> cannot be used as a generator.",
+ "apierror-badgenerator-unknown": "Unknown <kbd>generator=$1</kbd>.",
+ "apierror-badip": "IP parameter is not valid.",
+ "apierror-badmd5": "The supplied MD5 hash was incorrect.",
+ "apierror-badmodule-badsubmodule": "The module <kbd>$1</kbd> does not have a submodule \"$2\".",
+ "apierror-badmodule-nosubmodules": "The module <kbd>$1</kbd> has no submodules.",
+ "apierror-badparameter": "Invalid value for parameter <var>$1</var>.",
+ "apierror-badquery": "Invalid query.",
+ "apierror-badtimestamp": "Invalid value \"$2\" for timestamp parameter <var>$1</var>.",
+ "apierror-badtoken": "Invalid CSRF token.",
+ "apierror-badupload": "File upload parameter <var>$1</var> is not a file upload; be sure to use <code>multipart/form-data</code> for your POST and include a filename in the <code>Content-Disposition</code> header.",
+ "apierror-badurl": "Invalid value \"$2\" for URL parameter <var>$1</var>.",
+ "apierror-baduser": "Invalid value \"$2\" for user parameter <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "U+001F multi-value separation may only be used for multi-valued parameters.",
+ "apierror-bad-watchlist-token": "Incorrect watchlist token provided. Please set a correct token in [[Special:Preferences]].",
+ "apierror-blockedfrommail": "You have been blocked from sending email.",
+ "apierror-blocked": "You have been blocked from editing.",
+ "apierror-botsnotsupported": "This interface is not supported for bots.",
+ "apierror-cannot-async-upload-file": "The parameters <var>async</var> and <var>file</var> cannot be combined. If you want asynchronous processing of your uploaded file, first upload it to stash (using the <var>stash</var> parameter) and then publish the stashed file asynchronously (using <var>filekey</var> and <var>async</var>).",
+ "apierror-cannotreauthenticate": "This action is not available as your identity cannot be verified.",
+ "apierror-cannotviewtitle": "You are not allowed to view $1.",
+ "apierror-cantblock-email": "You don't have permission to block users from sending email through the wiki.",
+ "apierror-cantblock": "You don't have permission to block users.",
+ "apierror-cantchangecontentmodel": "You don't have permission to change the content model of a page.",
+ "apierror-canthide": "You don't have permission to hide user names from the block log.",
+ "apierror-cantimport-upload": "You don't have permission to import uploaded pages.",
+ "apierror-cantimport": "You don't have permission to import pages.",
+ "apierror-cantoverwrite-sharedfile": "The target file exists on a shared repository and you do not have permission to override it.",
+ "apierror-cantsend": "You are not logged in, you do not have a confirmed email address, or you are not allowed to send email to other users, so you cannot send email.",
+ "apierror-cantundelete": "Couldn't undelete: the requested revisions may not exist, or may have been undeleted already.",
+ "apierror-changeauth-norequest": "Failed to create change request.",
+ "apierror-chunk-too-small": "Minimum chunk size is $1 {{PLURAL:$1|byte|bytes}} for non-final chunks.",
+ "apierror-cidrtoobroad": "$1 CIDR ranges broader than /$2 are not accepted.",
+ "apierror-compare-no-title": "Cannot pre-save transform without a title. Try specifying <var>fromtitle</var> or <var>totitle</var>.",
+ "apierror-compare-relative-to-nothing": "No 'from' revision for <var>torelative</var> to be relative to.",
+ "apierror-contentserializationexception": "Content serialization failed: $1",
+ "apierror-contenttoobig": "The content you supplied exceeds the article size limit of $1 {{PLURAL:$1|kilobyte|kilobytes}}.",
+ "apierror-copyuploadbaddomain": "Uploads by URL are not allowed from this domain.",
+ "apierror-copyuploadbadurl": "Upload not allowed from this URL.",
+ "apierror-create-titleexists": "Existing titles can't be protected with <kbd>create</kbd>.",
+ "apierror-csp-report": "Error processing CSP report: $1.",
+ "apierror-databaseerror": "[$1] Database query error.",
+ "apierror-deletedrevs-param-not-1-2": "The <var>$1</var> parameter cannot be used in modes 1 or 2.",
+ "apierror-deletedrevs-param-not-3": "The <var>$1</var> parameter cannot be used in mode 3.",
+ "apierror-emptynewsection": "Creating empty new sections is not possible.",
+ "apierror-emptypage": "Creating new, empty pages is not allowed.",
+ "apierror-exceptioncaught": "[$1] Exception caught: $2",
+ "apierror-filedoesnotexist": "File does not exist.",
+ "apierror-fileexists-sharedrepo-perm": "The target file exists on a shared repository. Use the <var>ignorewarnings</var> parameter to override it.",
+ "apierror-filenopath": "Cannot get local file path.",
+ "apierror-filetypecannotberotated": "File type cannot be rotated.",
+ "apierror-formatphp": "This response cannot be represented using <kbd>format=php</kbd>. See https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "The title for <kbd>$1</kbd> must be a file.",
+ "apierror-import-unknownerror": "Unknown error on import: $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> may not be over $2 (set to $3) for bots or sysops.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> may not be over $2 (set to $3) for users.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> may not be less than $2 (set to $3).",
+ "apierror-invalidcategory": "The category name you entered is not valid.",
+ "apierror-invalid-chunk": "Offset plus current chunk is greater than claimed file size.",
+ "apierror-invalidexpiry": "Invalid expiry time \"$1\".",
+ "apierror-invalid-file-key": "Not a valid file key.",
+ "apierror-invalidlang": "Invalid language code for parameter <var>$1</var>.",
+ "apierror-invalidoldimage": "The <var>oldimage</var> parameter has an invalid format.",
+ "apierror-invalidparammix-cannotusewith": "The <kbd>$1</kbd> parameter cannot be used with <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "The <kbd>$1</kbd> parameter may only be used with <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd> cannot be combined with the <var>oldid</var>, <var>pageid</var> or <var>page</var> parameters. Please use <var>title</var> and <var>text</var>.",
+ "apierror-invalidparammix": "The {{PLURAL:$2|parameters}} $1 can not be used together.",
+ "apierror-invalidsection": "The <var>section</var> parameter must be a valid section ID or <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "The SHA1Base36 hash provided is not valid.",
+ "apierror-invalidsha1hash": "The SHA1 hash provided is not valid.",
+ "apierror-invalidtitle": "Bad title \"$1\".",
+ "apierror-invalidurlparam": "Invalid value for <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Invalid username \"$1\".",
+ "apierror-invaliduserid": "User ID <var>$1</var> is not valid.",
+ "apierror-maxlag-generic": "Waiting for a database server: $1 {{PLURAL:$1|second|seconds}} lagged.",
+ "apierror-maxlag": "Waiting for $2: $1 {{PLURAL:$1|second|seconds}} lagged.",
+ "apierror-mimesearchdisabled": "MIME search is disabled in Miser Mode.",
+ "apierror-missingcontent-pageid": "Missing content for page ID $1.",
+ "apierror-missingcontent-revid": "Missing content for revision ID $1.",
+ "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|The parameter|At least one of the parameters}} $1 is required.",
+ "apierror-missingparam-one-of": "{{PLURAL:$2|The parameter|One of the parameters}} $1 is required.",
+ "apierror-missingparam": "The <var>$1</var> parameter must be set.",
+ "apierror-missingrev-pageid": "No current revision of page ID $1.",
+ "apierror-missingrev-title": "No current revision of title $1.",
+ "apierror-missingtitle-createonly": "Missing titles can only be protected with <kbd>create</kbd>.",
+ "apierror-missingtitle": "The page you specified doesn't exist.",
+ "apierror-missingtitle-byname": "The page $1 doesn't exist.",
+ "apierror-moduledisabled": "The <kbd>$1</kbd> module has been disabled.",
+ "apierror-multival-only-one-of": "{{PLURAL:$3|Only|Only one of}} $2 is allowed for parameter <var>$1</var>.",
+ "apierror-multival-only-one": "Only one value is allowed for parameter <var>$1</var>.",
+ "apierror-multpages": "<var>$1</var> may only be used with a single page.",
+ "apierror-mustbeloggedin-changeauth": "You must be logged in to change authentication data.",
+ "apierror-mustbeloggedin-generic": "You must be logged in.",
+ "apierror-mustbeloggedin-linkaccounts": "You must be logged in to link accounts.",
+ "apierror-mustbeloggedin-removeauth": "You must be logged in to remove authentication data.",
+ "apierror-mustbeloggedin-uploadstash": "The upload stash is only available to logged-in users.",
+ "apierror-mustbeloggedin": "You must be logged in to $1.",
+ "apierror-mustbeposted": "The <kbd>$1</kbd> module requires a POST request.",
+ "apierror-mustpostparams": "The following {{PLURAL:$2|parameter was|parameters were}} found in the query string, but must be in the POST body: $1.",
+ "apierror-noapiwrite": "Editing of this wiki through the API is disabled. Make sure the <code>$wgEnableWriteAPI=true;</code> statement is included in the wiki's <code>LocalSettings.php</code> file.",
+ "apierror-nochanges": "No changes were requested.",
+ "apierror-nodeleteablefile": "No such old version of the file.",
+ "apierror-no-direct-editing": "Direct editing via API is not supported for content model $1 used by $2.",
+ "apierror-noedit-anon": "Anonymous users can't edit pages.",
+ "apierror-noedit": "You don't have permission to edit pages.",
+ "apierror-noimageredirect-anon": "Anonymous users can't create image redirects.",
+ "apierror-noimageredirect": "You don't have permission to create image redirects.",
+ "apierror-nosuchlogid": "There is no log entry with ID $1.",
+ "apierror-nosuchpageid": "There is no page with ID $1.",
+ "apierror-nosuchrcid": "There is no recent change with ID $1.",
+ "apierror-nosuchrevid": "There is no revision with ID $1.",
+ "apierror-nosuchsection": "There is no section $1.",
+ "apierror-nosuchsection-what": "There is no section $1 in $2.",
+ "apierror-nosuchuserid": "There is no user with ID $1.",
+ "apierror-notarget": "You have not specified a valid target for this action.",
+ "apierror-notpatrollable": "The revision r$1 can't be patrolled as it's too old.",
+ "apierror-nouploadmodule": "No upload module set.",
+ "apierror-offline": "Could not proceed due to network connectivity issues. Make sure you have a working internet connection and try again.",
+ "apierror-opensearch-json-warnings": "Warnings cannot be represented in OpenSearch JSON format.",
+ "apierror-pagecannotexist": "Namespace doesn't allow actual pages.",
+ "apierror-pagedeleted": "The page has been deleted since you fetched its timestamp.",
+ "apierror-pagelang-disabled": "Changing the language of a page is not allowed on this wiki.",
+ "apierror-paramempty": "The parameter <var>$1</var> may not be empty.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> is only supported for wikitext content.",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> is only supported for wikitext content. $1 uses content model $2.",
+ "apierror-pastexpiry": "Expiry time \"$1\" is in the past.",
+ "apierror-permissiondenied": "You don't have permission to $1.",
+ "apierror-permissiondenied-generic": "Permission denied.",
+ "apierror-permissiondenied-patrolflag": "You need the <code>patrol</code> or <code>patrolmarks</code> right to request the patrolled flag.",
+ "apierror-permissiondenied-unblock": "You don't have permission to unblock users.",
+ "apierror-prefixsearchdisabled": "Prefix search is disabled in Miser Mode.",
+ "apierror-promised-nonwrite-api": "The <code>Promise-Non-Write-API-Action</code> HTTP header cannot be sent to write-mode API modules.",
+ "apierror-protect-invalidaction": "Invalid protection type \"$1\".",
+ "apierror-protect-invalidlevel": "Invalid protection level \"$1\".",
+ "apierror-ratelimited": "You've exceeded your rate limit. Please wait some time and try again.",
+ "apierror-readapidenied": "You need read permission to use this module.",
+ "apierror-readonly": "The wiki is currently in read-only mode.",
+ "apierror-reauthenticate": "You have not authenticated recently in this session, please reauthenticate.",
+ "apierror-redirect-appendonly": "You have attempted to edit using the redirect-following mode, which must be used in conjuction with <kbd>section=new</kbd>, <var>prependtext</var>, or <var>appendtext</var>.",
+ "apierror-revdel-mutuallyexclusive": "The same field cannot be used in both <var>hide</var> and <var>show</var>.",
+ "apierror-revdel-needtarget": "A target title is required for this RevDel type.",
+ "apierror-revdel-paramneeded": "At least one value is required for <var>hide</var> and/or <var>show</var>.",
+ "apierror-revisions-badid": "No revision was found for parameter <var>$1</var>.",
+ "apierror-revisions-norevids": "The <var>revids</var> parameter may not be used with the list options (<var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var>, and <var>$1end</var>).",
+ "apierror-revisions-singlepage": "<var>titles</var>, <var>pageids</var> or a generator was used to supply multiple pages, but the <var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var>, and <var>$1end</var> parameters may only be used on a single page.",
+ "apierror-revwrongpage": "r$1 is not a revision of $2.",
+ "apierror-searchdisabled": "<var>$1</var> search is disabled.",
+ "apierror-sectionreplacefailed": "Could not merge updated section.",
+ "apierror-sectionsnotsupported": "Sections are not supported for content model $1.",
+ "apierror-sectionsnotsupported-what": "Sections are not supported by $1.",
+ "apierror-show": "Incorrect parameter - mutually exclusive values may not be supplied.",
+ "apierror-siteinfo-includealldenied": "Cannot view all servers' info unless <var>$wgShowHostNames</var> is true.",
+ "apierror-sizediffdisabled": "Size difference is disabled in Miser Mode.",
+ "apierror-spamdetected": "Your edit was refused because it contained a spam fragment: <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "You don't have permission to view the results of this special page.",
+ "apierror-stashedfilenotfound": "Could not find the file in the stash: $1.",
+ "apierror-stashedit-missingtext": "No stashed text found with the given hash.",
+ "apierror-stashexception": "$1",
+ "apierror-stashfailed-complete": "Chunked upload is already completed, check status for details.",
+ "apierror-stashfailed-nosession": "No chunked upload session with this key.",
+ "apierror-stashfilestorage": "Could not store upload in the stash: $1",
+ "apierror-stashinvalidfile": "Invalid stashed file.",
+ "apierror-stashnosuchfilekey": "No such filekey: $1.",
+ "apierror-stashpathinvalid": "File key of improper format or otherwise invalid: $1.",
+ "apierror-stashwrongowner": "Wrong owner: $1",
+ "apierror-stashzerolength": "File is of zero length, and could not be stored in the stash: $1.",
+ "apierror-systemblocked": "You have been blocked automatically by MediaWiki.",
+ "apierror-templateexpansion-notwikitext": "Template expansion is only supported for wikitext content. $1 uses content model $2.",
+ "apierror-timeout": "The server did not respond within the expected time.",
+ "apierror-toofewexpiries": "$1 expiry {{PLURAL:$1|timestamp was|timestamps were}} provided where $2 {{PLURAL:$2|was|were}} needed.",
+ "apierror-unknownaction": "The action specified, <kbd>$1</kbd>, is not recognized.",
+ "apierror-unknownerror-editpage": "Unknown EditPage error: $1.",
+ "apierror-unknownerror-nocode": "Unknown error.",
+ "apierror-unknownerror": "Unknown error: \"$1\".",
+ "apierror-unknownformat": "Unrecognized format \"$1\".",
+ "apierror-unrecognizedparams": "Unrecognized {{PLURAL:$2|parameter|parameters}}: $1.",
+ "apierror-unrecognizedvalue": "Unrecognized value for parameter <var>$1</var>: $2.",
+ "apierror-unsupportedrepo": "Local file repository does not support querying all images.",
+ "apierror-upload-filekeyneeded": "Must supply a <var>filekey</var> when <var>offset</var> is non-zero.",
+ "apierror-upload-filekeynotallowed": "Cannot supply a <var>filekey</var> when <var>offset</var> is 0.",
+ "apierror-upload-inprogress": "Upload from stash already in progress.",
+ "apierror-upload-missingresult": "No result in status data.",
+ "apierror-urlparamnormal": "Could not normalize image parameters for $1.",
+ "apierror-writeapidenied": "You're not allowed to edit this wiki through the API.",
+
+ "apiwarn-alldeletedrevisions-performance": "For better performance when generating titles, set <kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "Could not parse <var>$1urlparam</var> for $2. Using only width and height.",
+ "apiwarn-badutf8": "The value passed for <var>$1</var> contains invalid or non-normalized data. Textual data should be valid, NFC-normalized Unicode without C0 control characters other than HT (\\t), LF (\\n), and CR (\\r).",
+ "apiwarn-checktoken-percentencoding": "Check that symbols such as \"+\" in the token are properly percent-encoded in the URL.",
+ "apiwarn-compare-nocontentmodel": "No content model could be determined, assuming $1.",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> has been deprecated. Please use <kbd>prop=deletedrevisions</kbd> or <kbd>list=alldeletedrevisions</kbd> instead.",
+ "apiwarn-deprecation-expandtemplates-prop": "Because no values have been specified for the <var>prop</var> parameter, a legacy format has been used for the output. This format is deprecated, and in the future, a default value will be set for the <var>prop</var> parameter, causing the new format to always be used.",
+ "apiwarn-deprecation-httpsexpected": "HTTP used when HTTPS was expected.",
+ "apiwarn-deprecation-login-botpw": "Main-account login via <kbd>action=login</kbd> is deprecated and may stop working without warning. To continue login with <kbd>action=login</kbd>, see [[Special:BotPasswords]]. To safely continue using main-account login, see <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "Main-account login via <kbd>action=login</kbd> is deprecated and may stop working without warning. To safely log in, see <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-token": "Fetching a token via <kbd>action=login</kbd> is deprecated. Use <kbd>action=query&meta=tokens&type=login</kbd> instead.",
+ "apiwarn-deprecation-parameter": "The parameter <var>$1</var> has been deprecated.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> is deprecated since MediaWiki 1.28. Use <kbd>prop=headhtml</kbd> when creating new HTML documents, or <kbd>prop=modules|jsconfigvars</kbd> when updating a document client-side.",
+ "apiwarn-deprecation-purge-get": "Use of <kbd>action=purge</kbd> via GET is deprecated. Use POST instead.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> has been deprecated. Please use <kbd>$2</kbd> instead.",
+ "apiwarn-difftohidden": "Couldn't diff to r$1: content is hidden.",
+ "apiwarn-errorprinterfailed": "Error printer failed. Will retry without params.",
+ "apiwarn-errorprinterfailed-ex": "Error printer failed (will retry without params): $1",
+ "apiwarn-invalidcategory": "\"$1\" is not a category.",
+ "apiwarn-invalidtitle": "\"$1\" is not a valid title.",
+ "apiwarn-invalidxmlstylesheetext": "Stylesheet should have <code>.xsl</code> extension.",
+ "apiwarn-invalidxmlstylesheet": "Invalid or non-existent stylesheet specified.",
+ "apiwarn-invalidxmlstylesheetns": "Stylesheet should be in the {{ns:MediaWiki}} namespace.",
+ "apiwarn-moduleswithoutvars": "Property <kbd>modules</kbd> was set but not <kbd>jsconfigvars</kbd> or <kbd>encodedjsconfigvars</kbd>. Configuration variables are necessary for proper module usage.",
+ "apiwarn-notfile": "\"$1\" is not a file.",
+ "apiwarn-nothumb-noimagehandler": "Could not create thumbnail because $1 does not have an associated image handler.",
+ "apiwarn-parse-nocontentmodel": "No <var>title</var> or <var>contentmodel</var> was given, assuming $1.",
+ "apiwarn-parse-revidwithouttext": "<var>revid</var> used without <var>text</var>, and parsed page properties were requested. Did you mean to use <var>oldid</var> instead of <var>revid</var>?",
+ "apiwarn-parse-titlewithouttext": "<var>title</var> used without <var>text</var>, and parsed page properties were requested. Did you mean to use <var>page</var> instead of <var>title</var>?",
+ "apiwarn-redirectsandrevids": "Redirect resolution cannot be used together with the <var>revids</var> parameter. Any redirects the <var>revids</var> point to have not been resolved.",
+ "apiwarn-tokennotallowed": "Action \"$1\" is not allowed for the current user.",
+ "apiwarn-tokens-origin": "Tokens may not be obtained when the same-origin policy is not applied.",
+ "apiwarn-toomanyvalues": "Too many values supplied for parameter <var>$1</var>. The limit is $2.",
+ "apiwarn-truncatedresult": "This result was truncated because it would otherwise be larger than the limit of $1 bytes.",
+ "apiwarn-unclearnowtimestamp": "Passing \"$2\" for timestamp parameter <var>$1</var> has been deprecated. If for some reason you need to explicitly specify the current time without calculating it client-side, use <kbd>now</kbd>.",
+ "apiwarn-unrecognizedvalues": "Unrecognized {{PLURAL:$3|value|values}} for parameter <var>$1</var>: $2.",
+ "apiwarn-unsupportedarray": "Parameter <var>$1</var> uses unsupported PHP array syntax.",
+ "apiwarn-urlparamwidth": "Ignoring width value set in <var>$1urlparam</var> ($2) in favor of width value derived from <var>$1urlwidth</var>/<var>$1urlheight</var> ($3).",
+ "apiwarn-validationfailed-badchars": "invalid characters in key (only <code>a-z</code>, <code>A-Z</code>, <code>0-9</code>, <code>_</code>, and <code>-</code> are allowed).",
+ "apiwarn-validationfailed-badpref": "not a valid preference.",
+ "apiwarn-validationfailed-cannotset": "cannot be set by this module.",
+ "apiwarn-validationfailed-keytoolong": "key too long (no more than $1 bytes allowed).",
+ "apiwarn-validationfailed": "Validation error for <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>Security Warning</strong>: <var>$wgDebugAPI</var> is enabled.",
+
+ "api-feed-error-title": "Error ($1)",
+ "api-usage-docref": "See $1 for API usage.",
+ "api-usage-mailinglist-ref": "Subscribe to the mediawiki-api-announce mailing list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; for notice of API deprecations and breaking changes.",
+ "api-exception-trace": "$1 at $2($3)\n$4",
+ "api-credits-header": "Credits",
+ "api-credits": "API developers:\n* Yuri Astrakhan (creator, lead developer Sep 2006–Sep 2007)\n* Roan Kattouw (lead developer Sep 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (lead developer 2013–present)\n\nPlease send your comments, suggestions and questions to mediawiki-api@lists.wikimedia.org\nor file a bug report at https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/eo.json b/www/wiki/includes/api/i18n/eo.json
new file mode 100644
index 00000000..e1c31612
--- /dev/null
+++ b/www/wiki/includes/api/i18n/eo.json
@@ -0,0 +1,30 @@
+{
+ "@metadata": {
+ "authors": [
+ "Robin van der Vliet",
+ "Renardo"
+ ]
+ },
+ "apihelp-main-param-format": "La formo de la eligaĵo.",
+ "apihelp-block-summary": "Bloki uzanton.",
+ "apihelp-block-param-user": "Salutnomo, IP-adreso aŭ IP-adresa intervalo forbarota.",
+ "apihelp-block-param-expiry": "Eksvalidiĝa tempo. Ĝi povas esti relativa (ekz. <kbd>5 months</kbd> aŭ <kbd>2 weeks</kbd> aŭ absoluta (ekz. <kbd>2014-09-18T12:34:56Z</kbd>). Se vi indikas <kbd>infinite</kbd> (senfine), <kbd>indefinite</kbd> (nedifinite) aŭ <kbd>never</kbd> (neniam), la forbaro neniam eksvalidiĝos.",
+ "apihelp-createaccount-param-name": "Uzantnomo.",
+ "apihelp-delete-summary": "Forigi paĝon.",
+ "apihelp-edit-param-minor": "Redakteto.",
+ "apihelp-edit-example-edit": "Redakti paĝon.",
+ "apihelp-feedrecentchanges-param-hideminor": "Kaŝi redaktetojn.",
+ "apihelp-feedrecentchanges-param-hidebots": "Kaŝi robotajn ŝanĝojn.",
+ "apihelp-feedrecentchanges-param-hideanons": "Kaŝi redaktojn de anonimuloj.",
+ "apihelp-feedrecentchanges-param-hideliu": "Kaŝi redaktojn de ensalutintaj uzantoj.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Kaŝi reviziitajn ŝanĝojn.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Kaŝi ŝanĝojn faritajn de la nuna uzanto.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Kaŝi ŝanĝojn de kategoria aneco.",
+ "apihelp-feedrecentchanges-example-simple": "Montri ĵusajn ŝanĝojn.",
+ "apihelp-filerevert-summary": "Restarigi malnovan version de dosiero.",
+ "apihelp-filerevert-param-comment": "Alŝuta komento.",
+ "apihelp-login-param-name": "Uzantnomo.",
+ "apihelp-login-param-password": "Pasvorto.",
+ "apihelp-login-example-login": "Ensaluti.",
+ "apihelp-userrights-param-user": "Uzantnomo."
+}
diff --git a/www/wiki/includes/api/i18n/es.json b/www/wiki/includes/api/i18n/es.json
new file mode 100644
index 00000000..af3097bc
--- /dev/null
+++ b/www/wiki/includes/api/i18n/es.json
@@ -0,0 +1,1633 @@
+{
+ "@metadata": {
+ "authors": [
+ "Macofe",
+ "Effy",
+ "Alan",
+ "Fitoschido",
+ "JasterTDC",
+ "Edslov",
+ "Carlos Cristia",
+ "Ryo567",
+ "Csbotero",
+ "Chris TR",
+ "Ncontinanza",
+ "Poco a poco",
+ "YoViajo",
+ "Eloy",
+ "AlvaroMolina",
+ "Ciencia Al Poder",
+ "Lemondoge",
+ "Mgpena",
+ "Rubentl134",
+ "2axterix2",
+ "Dgstranz",
+ "Copper12",
+ "Irus",
+ "Hamilton Abreu",
+ "Pompilos",
+ "Igv",
+ "Fortega",
+ "Luzcaru",
+ "Javiersanp"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n</div>\n<strong>Status:</strong> Todas las funciones mostradas en esta página deberían estar funcionando, pero la API aún está en desarrollo activo, y puede cambiar en cualquier momento. Suscribase a [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] para aviso de actualizaciones.\n\n<strong>Erroneous requests:</strong> Cuando se envían solicitudes erróneas a la API, se enviará un encabezado HTTP con la clave \"MediaWiki-API-Error\" y, luego, el valor del encabezado y el código de error devuelto se establecerán en el mismo valor. Para más información ver [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n<strong>Testing:</strong> Para facilitar la comprobación de las solicitudes de API, consulte [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Qué acción se realizará.",
+ "apihelp-main-param-format": "El formato de la salida.",
+ "apihelp-main-param-maxlag": "El retraso máximo puede utilizarse cuando MediaWiki se instala en un clúster replicado de base de datos. Para guardar las acciones que causan más retardo de replicación de sitio, este parámetro puede hacer que el cliente espere hasta que el retardo de replicación sea menor que el valor especificado. En caso de retraso excesivo, se devuelve el código de error <samp>maxlag</samp> con un mensaje como <samp>Esperando a $host: $lag segundos de retraso</samp>.<br />Consulta [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: parámetro Maxlag]] para más información.",
+ "apihelp-main-param-smaxage": "Establece la cabecera HTTP <code>s-maxage</code> de control de antememoria a esta cantidad de segundos. Los errores nunca se almacenan en la antememoria.",
+ "apihelp-main-param-maxage": "Establece la cabecera HTTP <code>max-age</code> de control de antememoria a esta cantidad de segundos. Los errores nunca se almacenan en la antememoria.",
+ "apihelp-main-param-assert": "Comprobar que el usuario haya iniciado sesión si el valor es <kbd>user</kbd> o si tiene el permiso de bot si es <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Verificar el usuario actual es el usuario nombrado.",
+ "apihelp-main-param-requestid": "Cualquier valor dado aquí se incluirá en la respuesta. Se puede utilizar para distinguir solicitudes.",
+ "apihelp-main-param-servedby": "Incluir el nombre del host que ha servido la solicitud en los resultados.",
+ "apihelp-main-param-curtimestamp": "Incluir la marca de tiempo actual en el resultado.",
+ "apihelp-main-param-responselanginfo": "Incluye los idiomas utilizados para <var>uselang</var> y <var>errorlang</var> en el resultado.",
+ "apihelp-main-param-origin": "Cuando se accede a la API usando una petición AJAX de distinto dominio (CORS), se establece este valor al dominio de origen. Debe ser incluido en cualquier petición pre-vuelo, y por lo tanto debe ser parte de la URI de la petición (no del cuerpo POST).\n\nEn las peticiones con autenticación, debe coincidir exactamente con uno de los orígenes de la cabecera <code>Origin</code>, por lo que debería ser algo como <kbd>https://en.wikipedia.org</kbd> o <kbd>https://meta.wikimedia.org</kbd>. Si este parámetro no coincide con la cabecera <code>Origin</code>, se devolverá una respuesta 403. Si este parámetro coincide con la cabecera <code>Origin</code> y el origen está en la lista blanca, se creará una cabecera <code>Access-Control-Allow-Origin</code>.\n\nEn las peticiones sin autenticación, introduce el valor <kbd>*</kbd>. Esto creará una cabecera <code>Access-Control-Allow-Origin</code>, pero el valor de <code>Access-Control-Allow-Credentials</code> será <code>false</code> y todos los datos que dependan del usuario estarán restringidos.",
+ "apihelp-main-param-uselang": "El idioma que se utilizará para las traducciones de mensajes. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> con <kbd>siprop=languages</kbd> devuelve una lista de códigos de idiomas. También puedes introducir <kbd>user</kbd> para usar la preferencia de idioma del usuario actual, o <kbd>content</kbd> para usar el idioma de contenido de este wiki.",
+ "apihelp-main-param-errorformat": "Formato utilizado para la salida de texto de avisos y errores.\n; plaintext: Wikitexto en el que se han eliminado las etiquetas HTML y reemplazado las entidades.\n; wikitext: Wikitexto sin analizar.\n; html: HTML.\n; raw: Clave del mensaje y parámetros.\n; none: Ninguna salida de texto, solo códigos de error.\n; bc: Formato empleado en versiones de MediaWiki anteriores a la 1.29. No se tienen en cuenta <var>errorlang</var> y <var>errorsuselocal</var>.",
+ "apihelp-main-param-errorlang": "Idioma empleado para advertencias y errores. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> con <kbd>siprop=languages</kbd> devuelve una lista de códigos de idioma. Puedes especificar <kbd>content</kbd> para utilizar el idioma del contenido de este wiki o <kbd>uselang</kbd> para utilizar el valor del parámetro <var>uselang</var>.",
+ "apihelp-main-param-errorsuselocal": "Si se da, los textos de error emplearán mensajes localmente personalizados del espacio de nombres {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "Bloquear a un usuario.",
+ "apihelp-block-param-user": "Nombre de usuario, dirección IP o intervalo de IP que quieres bloquear. No se puede utilizar junto con <var>$1userid</var>",
+ "apihelp-block-param-userid": "ID de usuario para bloquear. No se puede utilizar junto con <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Fecha de expiración. Puede ser relativa (por ejemplo, <kbd>5 months</kbd> o <kbd>2 weeks</kbd>) o absoluta (por ejemplo, <kbd>2014-09-18T12:34:56Z</kbd>). Si se establece en <kbd>infinite</kbd>, <kbd>indefinite</kbd>, o <kbd>never</kbd>, el bloqueo será permanente.",
+ "apihelp-block-param-reason": "Razón para el bloqueo.",
+ "apihelp-block-param-anononly": "Bloquear solo usuarios anónimos (es decir, desactivar ediciones anónimas de esta dirección IP).",
+ "apihelp-block-param-nocreate": "Prevenir la creación de cuentas.",
+ "apihelp-block-param-autoblock": "Bloquear automáticamente la última dirección IP y todas las direcciones IP que traten de iniciar sesión posteriormente.",
+ "apihelp-block-param-noemail": "Evitar que el usuario envíe correos a través de la wiki (es necesario el derecho <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Ocultar el nombre de usuario del registro de bloqueo (es necesario el derecho <coɗe>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Permitir que el usuario edite su propia página de discusión (depende de <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Si la cuenta ya está bloqueada, sobrescribir el bloqueo existente.",
+ "apihelp-block-param-watchuser": "Vigilar las páginas de usuario y de discusión del usuario o de la dirección IP.",
+ "apihelp-block-param-tags": "Cambiar las etiquetas que aplicar a la entrada en el registro de bloqueos.",
+ "apihelp-block-example-ip-simple": "Bloquear la dirección IP <kbd>192.0.2.5</kbd> durante 3 días por el motivo <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Bloquear al usuario <kbd>Vandal</kbd> indefinidamente con el motivo <kbd>Vandalism</kbd> y evitar que se cree nuevas cuentas o envíe correos.",
+ "apihelp-changeauthenticationdata-summary": "Cambiar los datos de autentificación para el usuario actual.",
+ "apihelp-changeauthenticationdata-example-password": "Intento para cambiar la contraseña del usuario actual a <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Comprueba la validez de una ficha desde <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Tipo de ficha a probar.",
+ "apihelp-checktoken-param-token": "Ficha a probar.",
+ "apihelp-checktoken-param-maxtokenage": "Duración máxima de la ficha, en segundos.",
+ "apihelp-checktoken-example-simple": "Probar la validez de una ficha <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Limpia la marca <code>hasmsg</code> del usuario actual.",
+ "apihelp-clearhasmsg-example-1": "Limpiar la marca <code>hasmsg</code> del usuario actual.",
+ "apihelp-clientlogin-summary": "Entrar en wiki usando el flujo interactivo.",
+ "apihelp-clientlogin-example-login": "Comenzar el proceso para iniciar sesión en el wiki como usuario <kbd>Example</kbd> con la contraseña <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continuar el inicio de sesión después de una respuesta de la <samp>UI</samp> a la autenticación de dos pasos, en la que devuelve un <var>OATHToken</var> de <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Obtener la diferencia entre 2 páginas.",
+ "apihelp-compare-extended-description": "Se debe pasar un número de revisión, un título de página o una ID tanto desde \"de\" hasta \"a\".",
+ "apihelp-compare-param-fromtitle": "Primer título para comparar",
+ "apihelp-compare-param-fromid": "ID de la primera página a comparar.",
+ "apihelp-compare-param-fromrev": "Primera revisión para comparar.",
+ "apihelp-compare-param-totitle": "Segundo título para comparar.",
+ "apihelp-compare-param-toid": "Segunda identificador de página para comparar.",
+ "apihelp-compare-param-torev": "Segunda revisión para comparar.",
+ "apihelp-compare-example-1": "Crear una diferencia entre las revisiones 1 y 2.",
+ "apihelp-createaccount-summary": "Crear una nueva cuenta de usuario.",
+ "apihelp-createaccount-param-preservestate": "Si <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> devolvió true (verdadero) para <samp>hasprimarypreservedstate</samp>, deberían omitirse las peticiones marcadas como <samp>primary-required</samp>. Si devolvió un valor no vacío para <samp>preservedusername</samp>, se debe usar ese nombre de usuario en el parámetro <var>username</var>.",
+ "apihelp-createaccount-example-create": "Empezar el proceso de creación del usuario <kbd>Example</kbd> con la contraseña <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Nombre de usuario.",
+ "apihelp-createaccount-param-password": "Contraseña (ignorada si está establecido <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Dominio de autenticación externa (opcional).",
+ "apihelp-createaccount-param-token": "La clave de creación de cuenta se obtiene en la primera solicitud.",
+ "apihelp-createaccount-param-email": "Dirección de correo electrónico del usuario (opcional).",
+ "apihelp-createaccount-param-realname": "Nombre verdadero del usuario (opcional).",
+ "apihelp-createaccount-param-mailpassword": "Si está puesto cualquier valor se enviará una contraseña aleatoria al usuario.",
+ "apihelp-createaccount-param-reason": "Motivo opcional por el que crear una cuenta puesta en los registros.",
+ "apihelp-createaccount-param-language": "Código de idioma a establecer como predeterminado para el usuario (opcional, predeterminado al contenido del idioma).",
+ "apihelp-createaccount-example-pass": "Crear usuario <kbd>testuser</kbd> con la contraseña <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Crear usuario <kbd>testmailuser</kbd> y enviar una contraseña generada aleatoriamente.",
+ "apihelp-cspreport-summary": "Utilizado por los navegadores para informar de violaciones a la normativa de seguridad de contenidos. Este módulo no debe usarse nunca, excepto cuando se usa automáticamente por un navegador web compatible con CSP.",
+ "apihelp-cspreport-param-reportonly": "Marcar como informe proveniente de una normativa de vigilancia, no una impuesta",
+ "apihelp-cspreport-param-source": "Qué generó la cabecera CSP que provocó este informe",
+ "apihelp-delete-summary": "Borrar una página.",
+ "apihelp-delete-param-title": "Título de la página a eliminar. No se puede utilizar junto a <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID de la página a eliminar. No se puede utilizar junto a <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Motivo de la eliminación. Si no se especifica, se generará uno automáticamente.",
+ "apihelp-delete-param-tags": "Cambio de etiquetas para aplicar a la entrada en la eliminación del registro.",
+ "apihelp-delete-param-watch": "Añadir esta página a la lista de seguimiento del usuario actual.",
+ "apihelp-delete-param-watchlist": "Añadir o quitar incondicionalmente la página de la lista de seguimiento del usuario actual, usar preferencias o no cambiar el estado de seguimiento.",
+ "apihelp-delete-param-unwatch": "Quitar la página de la lista de seguimiento del usuario actual.",
+ "apihelp-delete-param-oldimage": "El nombre de la imagen antigua es proporcionado conforme a lo dispuesto por [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Borrar <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Eliminar <kbd>Main Page</kbd> con el motivo <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Se desactivó este módulo.",
+ "apihelp-edit-summary": "Crear y editar páginas.",
+ "apihelp-edit-param-title": "Título de la página a editar. No se puede utilizar junto a <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID de la página a editar. No se puede utilizar junto a <var>$1title</var>.",
+ "apihelp-edit-param-section": "Número de la sección. <kbd>0</kbd> para una sección superior, <kbd>new</kbd> para una sección nueva.",
+ "apihelp-edit-param-sectiontitle": "El título de una sección nueva.",
+ "apihelp-edit-param-text": "Contenido de la página.",
+ "apihelp-edit-param-summary": "Editar resumen. Además de la sección del título cuando $1section=new y $1sectiontitle no están establecidos.",
+ "apihelp-edit-param-tags": "Cambia las etiquetas para aplicarlas a la revisión.",
+ "apihelp-edit-param-minor": "Edición menor.",
+ "apihelp-edit-param-notminor": "Edición no menor.",
+ "apihelp-edit-param-bot": "Marcar esta edición como edición de bot.",
+ "apihelp-edit-param-basetimestamp": "Marca de tiempo de la revisión base, usada para detectar conflictos de edición. Se puede obtener mediante [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]]",
+ "apihelp-edit-param-starttimestamp": "Marca de tiempo de cuando empezó el proceso de edición, usada para detectar conflictos de edición. Se puede obtener un valor apropiado usando <var>[[Special:ApiHelp/main|curtimestamp]]</var> cuando comiences el proceso de edición (por ejemplo, al cargar el contenido de la página por editar).",
+ "apihelp-edit-param-recreate": "Reemplazar los errores acerca de la página de haber sido eliminados en el ínterin.",
+ "apihelp-edit-param-createonly": "No editar la página si ya existe.",
+ "apihelp-edit-param-nocreate": "Producir un error si la página no existe.",
+ "apihelp-edit-param-watch": "Añadir la página a la lista de seguimiento del usuario actual.",
+ "apihelp-edit-param-unwatch": "Quitar la página de la lista de seguimiento del usuario actual.",
+ "apihelp-edit-param-watchlist": "Incondicionalmente añadir o eliminar la página de lista del usuario actual, utilice referencias o no cambiar el reloj.",
+ "apihelp-edit-param-md5": "El hash MD5 del parámetro $1text, o los parámetros concatenados $1prependtext y $1appendtext. Si se establece, la edición no se hará a menos que el hash sea correcto.",
+ "apihelp-edit-param-prependtext": "Añadir este texto al principio de la página. Reemplaza $1text.",
+ "apihelp-edit-param-appendtext": "Añadir este texto al principio de la página. Reemplaza $1text.\n\nUtiliza $1section=new para añadir una nueva sección, en lugar de este parámetro.",
+ "apihelp-edit-param-undo": "Deshacer esta revisión. Reemplaza $1text, $1prependtext y $1appendtext.",
+ "apihelp-edit-param-undoafter": "Deshacer todas las revisiones desde $1undo a esta. Si no está establecido solo se deshace una revisión.",
+ "apihelp-edit-param-redirect": "Resolver redirecciones automáticamente.",
+ "apihelp-edit-param-contentformat": "Formato de serialización de contenido utilizado para el texto de entrada.",
+ "apihelp-edit-param-contentmodel": "Modelo de contenido del nuevo contenido.",
+ "apihelp-edit-param-token": "La clave debe enviarse siempre como el último parámetro o, al menos, después del parámetro $1text.",
+ "apihelp-edit-example-edit": "Editar una página",
+ "apihelp-edit-example-prepend": "Anteponer <kbd>_&#95;NOTOC_&#95;</kbd> a una página.",
+ "apihelp-edit-example-undo": "Deshacer intervalo de revisiones 13579-13585 con resumen automático",
+ "apihelp-emailuser-summary": "Enviar un mensaje de correo electrónico a un usuario.",
+ "apihelp-emailuser-param-target": "Cuenta de usuario destinatario.",
+ "apihelp-emailuser-param-subject": "Cabecera de asunto.",
+ "apihelp-emailuser-param-text": "Cuerpo del mensaje.",
+ "apihelp-emailuser-param-ccme": "Enviarme una copia de este mensaje.",
+ "apihelp-emailuser-example-email": "Enviar un correo al usuario <kbd>WikiSysop</kbd> con el texto <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Expande todas las plantillas en wikitexto.",
+ "apihelp-expandtemplates-param-title": "Título de la página.",
+ "apihelp-expandtemplates-param-text": "Sintaxis wiki que se convertirá.",
+ "apihelp-expandtemplates-param-revid": "Revisión de ID, para <code><nowiki>{{REVISIONID}}</nowiki></code> y variables similares.",
+ "apihelp-expandtemplates-param-prop": "Qué elementos de información se utilizan para llegar.\n\nTenga en cuenta que si no se seleccionan los valores, el resultado contendrá el wikitexto, pero la salida será en un formato obsoleto.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "El wikitexto expandido.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Cualesquiera categorías presentes en la entrada que no están representadas en salida de wikitexto.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Propiedades de página definidas por palabras mágicas en el wikitexto.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Si la salida es volátil y no debe ser reutilizada en otro lugar dentro de la página.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "El tiempo máximo tras el cual deberían invalidarse los resultados en caché.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Todos los módulos ResourceLoader que las funciones del analizador sintáctico hayan solicitado añadir a la salida. Debe solicitarse <kbd>jsconfigvars</kbd> o bien <kbd>encodedjsconfigvars</kbd> junto con <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Da las variables de configuración JavaScript específicas para la página.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Da las variables de configuración JavaScript específicas para la página como una cadena JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "El árbol XML analiza el árbol de la entrada.",
+ "apihelp-expandtemplates-param-includecomments": "Incluir o no los comentarios HTML en la salida.",
+ "apihelp-expandtemplates-param-generatexml": "Generar un árbol de análisis XML (remplazado por $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Expandir el wikitexto <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Devuelve el canal de contribuciones de un usuario.",
+ "apihelp-feedcontributions-param-feedformat": "El formato del suministro.",
+ "apihelp-feedcontributions-param-user": "De qué usuarios recibir contribuciones.",
+ "apihelp-feedcontributions-param-namespace": "Espacio de nombre para filtrar las contribuciones.",
+ "apihelp-feedcontributions-param-year": "A partir del año (y anteriores).",
+ "apihelp-feedcontributions-param-month": "A partir del mes (y anteriores).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrar las contribuciones que tienen estas etiquetas.",
+ "apihelp-feedcontributions-param-deletedonly": "Mostrar solo las contribuciones borradas.",
+ "apihelp-feedcontributions-param-toponly": "Mostrar solo ediciones que son últimas revisiones.",
+ "apihelp-feedcontributions-param-newonly": "Mostrar solo ediciones que son creaciones de páginas.",
+ "apihelp-feedcontributions-param-hideminor": "Ocultar ediciones menores.",
+ "apihelp-feedcontributions-param-showsizediff": "Mostrar la diferencia de tamaño entre revisiones.",
+ "apihelp-feedcontributions-example-simple": "Devolver las contribuciones del usuario <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Devuelve un canal de cambios recientes.",
+ "apihelp-feedrecentchanges-param-feedformat": "El formato del suministro.",
+ "apihelp-feedrecentchanges-param-namespace": "Espacio de nombres al cual limitar los resultados.",
+ "apihelp-feedrecentchanges-param-invert": "Todos los espacios de nombres menos el que está seleccionado.",
+ "apihelp-feedrecentchanges-param-associated": "Incluir el espacio de nombres asociado (discusión o principal).",
+ "apihelp-feedrecentchanges-param-days": "Días a los que limitar los resultados.",
+ "apihelp-feedrecentchanges-param-limit": "Número máximo de resultados que devolver.",
+ "apihelp-feedrecentchanges-param-from": "Mostrar los cambios realizados a partir de entonces.",
+ "apihelp-feedrecentchanges-param-hideminor": "Ocultar cambios menores.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ocultar los cambios realizados por bots.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ocultar los cambios realizados por usuarios anónimos.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ocultar los cambios realizados por usuarios registrados.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ocultar los cambios verificados.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ocultar los cambios realizados por el usuario actual.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Ocultar los cambios de pertenencia a categorías.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrar por etiquetas.",
+ "apihelp-feedrecentchanges-param-target": "Mostrar solo los cambios en las páginas enlazadas en esta.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Mostrar los cambios en páginas enlazadas con la página seleccionada.",
+ "apihelp-feedrecentchanges-param-categories": "Mostrar sólo cambios en las páginas en todas estas categorías.",
+ "apihelp-feedrecentchanges-param-categories_any": "Mostrar sólo cambios en las páginas en cualquiera de las categorías en lugar.",
+ "apihelp-feedrecentchanges-example-simple": "Mostrar los cambios recientes.",
+ "apihelp-feedrecentchanges-example-30days": "Mostrar los cambios recientes limitados a 30 días.",
+ "apihelp-feedwatchlist-summary": "Devuelve el canal de una lista de seguimiento.",
+ "apihelp-feedwatchlist-param-feedformat": "El formato del suministro.",
+ "apihelp-feedwatchlist-param-hours": "Listar las páginas modificadas desde estas horas hasta ahora.",
+ "apihelp-feedwatchlist-param-linktosections": "Enlazar directamente a las secciones cambiadas de ser posible.",
+ "apihelp-feedwatchlist-example-default": "Mostrar el canal de la lista de seguimiento.",
+ "apihelp-feedwatchlist-example-all6hrs": "Mostrar todos los cambios en páginas vigiladas en las últimas 6 horas.",
+ "apihelp-filerevert-summary": "Revertir el archivo a una versión anterior.",
+ "apihelp-filerevert-param-filename": "Nombre de archivo final, sin el prefijo Archivo:",
+ "apihelp-filerevert-param-comment": "Comentario de carga.",
+ "apihelp-filerevert-param-archivename": "Nombre del archivo de la revisión para deshacerla.",
+ "apihelp-filerevert-example-revert": "Devolver <kbd>Wiki.png</kbd> a la versión del <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Mostrar la ayuda para los módulos especificados.",
+ "apihelp-help-param-modules": "Módulos para los que mostrar ayuda (valores de los parámetros <var>action</var> y <var>format</var> o <kbd>main</kbd>). Se pueden especificar submódulos con un <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Incluir ayuda para submódulos del módulo con nombre.",
+ "apihelp-help-param-recursivesubmodules": "Incluir ayuda para submódulos recursivamente.",
+ "apihelp-help-param-helpformat": "Formato de salida de la ayuda.",
+ "apihelp-help-param-wrap": "Envolver el producto en una estructura de respuesta de la API estándar.",
+ "apihelp-help-param-toc": "Incluir un sumario en la salida HTML.",
+ "apihelp-help-example-main": "Ayuda del módulo principal",
+ "apihelp-help-example-submodules": "Ayuda para <kbd>action=query</kbd> y todos sus submódulos.",
+ "apihelp-help-example-recursive": "Toda la ayuda en una página",
+ "apihelp-help-example-help": "Ayuda del módulo de ayuda en sí",
+ "apihelp-help-example-query": "Ayuda para dos submódulos de consulta.",
+ "apihelp-imagerotate-summary": "Girar una o más imágenes.",
+ "apihelp-imagerotate-param-rotation": "Grados que rotar una imagen en sentido horario.",
+ "apihelp-imagerotate-param-tags": "Etiquetas que añadir a la entrada en el registro de subidas.",
+ "apihelp-imagerotate-example-simple": "Rotar <kbd>File:Example.png</kbd> <kbd>90</kbd> grados.",
+ "apihelp-imagerotate-example-generator": "Rotar todas las imágenes en <kbd>Category:Flip</kbd> <kbd>180</kbd> grados.",
+ "apihelp-import-summary": "Importar una página desde otra wiki, o desde un archivo XML.",
+ "apihelp-import-extended-description": "Tenga en cuenta que el HTTP POST debe hacerse como una carga de archivos (es decir, el uso de multipart/form-data) al enviar un archivo para el parámetro <var>xml</var>.",
+ "apihelp-import-param-summary": "Resumen de importación de entrada del registro.",
+ "apihelp-import-param-xml": "Se cargó el archivo XML.",
+ "apihelp-import-param-interwikisource": "Para importaciones interwiki: wiki desde la que importar.",
+ "apihelp-import-param-interwikipage": "Para importaciones interwiki: página a importar.",
+ "apihelp-import-param-fullhistory": "Para importaciones interwiki: importar todo el historial, no solo la versión actual.",
+ "apihelp-import-param-templates": "Para importaciones interwiki: importar también todas las plantillas incluidas.",
+ "apihelp-import-param-namespace": "Importar a este espacio de nombres. No puede usarse simultáneamente con <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importar como subpágina de esta página. No puede usarse simultáneamente con <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Cambiar las etiquetas que aplicar a la entrada en el registro de importaciones y a la revisión nula de las páginas importadas.",
+ "apihelp-import-example-import": "Importar [[meta:Help:ParserFunctions]] al espacio de nombres 100 con todo el historial.",
+ "apihelp-linkaccount-summary": "Vincular una cuenta de un proveedor de terceros para el usuario actual.",
+ "apihelp-linkaccount-example-link": "Iniciar el proceso de vincular a una cuenta de <kbd>Ejemplo</kbd>.",
+ "apihelp-login-summary": "Iniciar sesión y obtener las cookies de autenticación.",
+ "apihelp-login-extended-description": "Esta acción solo se debe utilizar en combinación con [[Special:BotPasswords]]; para la cuenta de inicio de sesión no se utiliza y puede fallar sin previo aviso. Para iniciar la sesión de forma segura a la cuenta principal, utilice <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Esta acción esta obsoleta y puede fallar sin previo aviso. Para conectarse de forma segura, utilice <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "Nombre de usuario.",
+ "apihelp-login-param-password": "Contraseña.",
+ "apihelp-login-param-domain": "Dominio (opcional).",
+ "apihelp-login-param-token": "La clave de inicio de sesión se obtiene en la primera solicitud.",
+ "apihelp-login-example-gettoken": "Recuperar clave de inicio de sesión.",
+ "apihelp-login-example-login": "Acceder.",
+ "apihelp-logout-summary": "Salir y vaciar los datos de la sesión.",
+ "apihelp-logout-example-logout": "Cerrar la sesión del usuario actual.",
+ "apihelp-managetags-summary": "Realizar tareas de administración relacionadas con el cambio de etiquetas.",
+ "apihelp-managetags-param-operation": "Qué operación realizar:\n;create: Crear una nueva etiqueta de cambio de uso manual.\n;delete: Eliminar una etiqueta de cambio de la base de datos, eliminando la etiqueta de todas las revisiones, cambios en entradas recientes y registros en los que se ha utilizado.\n;activate: Activar una etiqueta de cambio, permitiendo a los usuarios aplicarla manualmente.\n;deactivate: Desactivar una etiqueta de cambio, evitando que los usuarios la apliquen manualmente.",
+ "apihelp-managetags-param-tag": "Etiqueta para crear, eliminar, activar o desactivar. Para crear una etiqueta, esta debe no existir. Para eliminarla, debe existir. Para activarla, debe existir y no estar en uso por ninguna extensión. Para desactivarla, debe estar activada y definida manualmente.",
+ "apihelp-managetags-param-reason": "Un motivo opcional para crear, eliminar, activar o desactivar la etiqueta.",
+ "apihelp-managetags-param-ignorewarnings": "Ya sea para ignorar las advertencias que se emiten durante la operación.",
+ "apihelp-managetags-param-tags": "Cambiar las etiquetas que aplicar a la entrada en el registro de administración de etiquetas.",
+ "apihelp-managetags-example-create": "Crear una etiqueta llamada <kbd>spam</kbd> con el motivo <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Eliminar la etiqueta <kbd>vandlaism</kbd> con el motivo <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Activar una etiqueta llamada <kbd>spam</kbd> con el motivo <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Desactivar una etiqueta llamada <kbd>spam</kbd> con el motivo <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Fusionar historiales de páginas.",
+ "apihelp-mergehistory-param-from": "El título de la página desde la que se combinará la historia. No se puede utilizar junto con <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "Page ID de la página desde la que se combinara el historial. No se puede utilizar junto con <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "El título de la página desde la que se combinara el historial. No se puede utilizar junto con <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "Page ID de la página desde la que se combinara el historial. No se puede utilizar junto con <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "La marca de tiempo de las revisiones se moverá del historial de la página de origen al historial de la página de destino. Si se omite, todo el historial de la página de la página de origen se fusionará en la página de destino.",
+ "apihelp-mergehistory-param-reason": "Motivo para la fusión del historial.",
+ "apihelp-mergehistory-example-merge": "Combinar todo el historial de <kbd>Oldpage</kbd> en <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Combinar las revisiones de <kbd>Oldpage</kbd> hasta el <kbd>2015-12-31T04:37:41Z</kbd> en <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Trasladar una página.",
+ "apihelp-move-param-from": "Título de la página a renombrar. No se puede utilizar con <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "ID de la página a renombrar. No se puede utilizar con <var>$1from</var>.",
+ "apihelp-move-param-to": "Título para cambiar el nombre de la página.",
+ "apihelp-move-param-reason": "Motivo del cambio de nombre.",
+ "apihelp-move-param-movetalk": "Renombrar la página de discusión si existe.",
+ "apihelp-move-param-movesubpages": "Renombrar las subpáginas si procede.",
+ "apihelp-move-param-noredirect": "No crear una redirección.",
+ "apihelp-move-param-watch": "Añadir la página y su redirección a la lista de seguimiento del usuario actual.",
+ "apihelp-move-param-unwatch": "Eliminar la página y la redirección de la lista de seguimiento del usuario.",
+ "apihelp-move-param-watchlist": "Incondicionalmente puede añadir o eliminar la página de lista del usuario actual, utilizar referencias o no cambiar el reloj.",
+ "apihelp-move-param-ignorewarnings": "Ignorar cualquier aviso.",
+ "apihelp-move-param-tags": "Cambiar las etiquetas que aplicar a la entrada en el registro de traslados y en la revisión nula de la página de destino.",
+ "apihelp-move-example-move": "Trasladar <kbd>Badtitle</kbd> a <kbd>Goodtitle</kbd> sin dejar una redirección.",
+ "apihelp-opensearch-summary": "Buscar en el wiki mediante el protocolo OpenSearch.",
+ "apihelp-opensearch-param-search": "Buscar cadena.",
+ "apihelp-opensearch-param-limit": "Número máximo de resultados que devolver.",
+ "apihelp-opensearch-param-namespace": "Espacio de nombres que buscar.",
+ "apihelp-opensearch-param-suggest": "No hacer nada si <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> es falso.",
+ "apihelp-opensearch-param-redirects": "Cómo manejar las redirecciones:\n;return: Volver a la propia redirección.\n;resolve: Volver a la página de destino. Puede devolver menos de $1limit resultados.\nPor motivos históricos, se utiliza \"return\" para $1format=json y \"resolve\" para otros formatos.",
+ "apihelp-opensearch-param-format": "El formato de salida.",
+ "apihelp-opensearch-param-warningsaserror": "Si las advertencias están planteadas con <kbd>format=json</kbd>, devolver un error de API en lugar de hacer caso omiso de ellas.",
+ "apihelp-opensearch-example-te": "Buscar páginas que empiecen por <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Cambiar preferencias del usuario actual.",
+ "apihelp-options-extended-description": "Solo se pueden establecer opciones que estén registradas en el núcleo o en una de las extensiones instaladas u opciones con claves predefinidas con <code>userjs-</code> (diseñadas para utilizarse con scripts de usuario).",
+ "apihelp-options-param-reset": "Restablece las preferencias de la página web a sus valores predeterminados.",
+ "apihelp-options-param-resetkinds": "Lista de tipos de opciones a restablecer cuando la opción <var>$1reset</var> esté establecida.",
+ "apihelp-options-param-change": "Lista de cambios con el formato nombre=valor (por ejemplo: skin=vector). Si no se da ningún valor (ni siquiera un signo de igual), por ejemplo: optionname|otheroption|..., la opción se restablecerá a sus valores predeterminados. Si algún valor contiene el carácter tubería (<kbd>|</kbd>), se debe utilizar el [[Special:ApiHelp/main#main/datatypes|separador alternativo de múltiples valores]] para que las operaciones se realicen correctamente.",
+ "apihelp-options-param-optionname": "El nombre de la opción que debe establecerse en el valor dado por <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "El valor de la opción especificada por <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Restablecer todas las preferencias",
+ "apihelp-options-example-change": "Cambiar las preferencias <kbd>skin</kbd> y <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Restablecer todas las preferencias y establecer <kbd>skin</kbd> y <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Obtener información acerca de los módulos de la API.",
+ "apihelp-paraminfo-param-modules": "Lista de los nombres de los módulos (valores de los parámetros <var>action</var> y <var>format</var> o <kbd>main</kbd>). Se pueden especificar submódulos con un <kbd>+</kbd>, todos los submódulos con <kbd>+*</kbd> o todos los submódulos recursivamente con <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Formato de las cadenas de ayuda.",
+ "apihelp-paraminfo-param-querymodules": "Lista de los nombres de los módulos de consulta (valor de los parámetros <var>prop</var>, <var>meta</var> or <var>list</var>). Utiliza <kbd>$1modules=query+foo</kbd> en vez de <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Obtener también información sobre el módulo principal (primer nivel). Utilizar <kbd>$1modules=main</kbd> en su lugar.",
+ "apihelp-paraminfo-param-pagesetmodule": "Obtener también información sobre el módulo PageSet (Proporcionar títulos= y amigos).",
+ "apihelp-paraminfo-param-formatmodules": "Lista de los nombres del formato de los módulos (valor del parámetro <var>format</var>). Utiliza <var>$1modules</var> en su lugar.",
+ "apihelp-paraminfo-example-1": "Mostrar información para <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> y <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "Mostrar información para todos los submódulos de <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-summary": "Analiza el contenido y devuelve la salida del analizador sintáctico.",
+ "apihelp-parse-extended-description": "Véanse los distintos módulos prop de <kbd>[[Special:ApiHelp/query|action=query]]</kbd> para obtener información de la versión actual de una página.\n\nHay varias maneras de especificar el texto que analizar:\n# Especificar una página o revisión, mediante <var>$1page</var>, <var>$1pageid</var> o <var>$1oldid</var>.\n# Especificar explícitamente el contenido, mediante <var>$1text</var>, <var>$1title</var> y <var>$1contentmodel</var>.\n# Especificar solamente un resumen que analizar. Se debería asignar a <var>$1prop</var> un valor vacío.",
+ "apihelp-parse-param-title": "Título de la página a la que pertenece el texto. Si se omite se debe especificar <var>$1contentmodel</var> y se debe utilizar el [[API]] como título.",
+ "apihelp-parse-param-text": "Texto a analizar. Utiliza <var>$1title</var> or <var>$1contentmodel</var> para controlar el modelo del contenido.",
+ "apihelp-parse-param-summary": "Resumen a analizar.",
+ "apihelp-parse-param-page": "Analizar el contenido de esta página. No se puede utilizar con <var>$1text</var> y <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Analizar el contenido de esta página. Remplaza <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "Si <var>$1page</var> o <var>$1pageid</var> contienen una redirección, soluciónalo.",
+ "apihelp-parse-param-oldid": "Analizar el contenido de esta revisión. Remplaza <var>$1page</var> y <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "Qué piezas de información obtener:",
+ "apihelp-parse-paramvalue-prop-text": "Da el texto analizado en wikitexto.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Da el idioma de los enlaces en el wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-categories": "Da las categorías en el wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Da la versión HTML de las categorías.",
+ "apihelp-parse-paramvalue-prop-links": "Da los enlaces internos del wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-templates": "Da las plantillas del wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-images": "Da las imágenes del wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Da los enlaces externos del wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-sections": "Da las secciones del wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-revid": "Añade la ID de revisión de la página analizada.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Añade el título del wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-headitems": "Proporciona elementos para colocar en el <code>&lt;head&gt;</code> de la página.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Proporciona la <code>&lt;head&gt;</code> analizada de la página.",
+ "apihelp-parse-paramvalue-prop-modules": "Proporciona los módulos de ResourceLoader utilizados en la página. Para cargar, utiliza <code>mw.loader.using()</code>. <kbd>jsconfigvars</kbd> o bien <kbd>encodedjsconfigvars</kbd> deben solicitarse en conjunto con <kbd>modules</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Proporciona las variables de configuración de JavaScript específicas de la página. Para obtenerlas, utiliza <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Da la configuración JavaScript de variables específica para la página como cadena JSON.",
+ "apihelp-parse-paramvalue-prop-indicators": "Da el HTML de los indicadores de estado utilizados en la página.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Da los enlaces interwiki del texto analizado.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Da el wikitexto original que se había analizado.",
+ "apihelp-parse-paramvalue-prop-properties": "Da varias propiedades definidas en el wikitexto analizado.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Da el informe del límite de forma estructurada. No da datos si <var>$1disablelimitreport</var> está establecido.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Da la versión HTML del informe del límite. No da datos si <var>$1disablelimitreport</var> está establecido.",
+ "apihelp-parse-paramvalue-prop-parsetree": "El árbol de análisis sintáctico XML del contenido de la revisión (requiere modelo de contenido <code>$1</code>)",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Da las advertencias que se produjeron al analizar el contenido.",
+ "apihelp-parse-param-pst": "Guardar previamente los cambios antes de transformar la entrada antes de analizarla. Sólo es válido cuando se utiliza con el texto.",
+ "apihelp-parse-param-onlypst": "Guardar previamente los cambios antes de transformar (PST) en la entrada. Devuelve el mismo wikitexto, después de que un PST se ha aplicado. Sólo es válido cuando se utiliza con <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "Incluye enlaces de idiomas proporcionados por las extensiones (para utilizar con <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "Analizar solo el contenido de este número de sección.\n\nSi el valor es <kbd>new</kbd>, analiza <var>$1text</var> y <var>$1sectiontitle</var> como si se añadiera una nueva sección a la página.\n\n<kbd>new</kbd> solo se permite cuando se especifique <var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "Nuevo título de sección cuando <var>section</var> tiene el valor <kbd>new</kbd>.\n\nAl contrario que en la edición de páginas, no se sustituye por <var>summary</var> cuando se omite o su valor es vacío.",
+ "apihelp-parse-param-disablelimitreport": "Omitir el informe de límite (\"NewPP limit report\") desde la salida del analizador.",
+ "apihelp-parse-param-disablepp": "Usa <var>$1disablelimitreport</var> en su lugar.",
+ "apihelp-parse-param-disableeditsection": "Omitir los enlaces de edición de sección de la salida del analizador.",
+ "apihelp-parse-param-disabletidy": "No ejecute la limpieza HTML (por ejemplo ordenada) en la salida del analizador.",
+ "apihelp-parse-param-generatexml": "Generar árbol de análisis sintáctico XML (requiere modelo de contenido <code>$1</code>; sustituido por <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "Analizar en modo de vista previa.",
+ "apihelp-parse-param-sectionpreview": "Analizar sección en modo de vista previa (también activa el modo de vista previa).",
+ "apihelp-parse-param-disabletoc": "Omitir el sumario en la salida.",
+ "apihelp-parse-param-useskin": "Aplicar la piel seleccionada a la salida del analizador. Puede afectar a las siguientes propiedades: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>módulos</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicadores</kbd>.",
+ "apihelp-parse-param-contentformat": "Formato de serialización de contenido utilizado para la introducción de texto. Sólo es válido cuando se utiliza con $1text.",
+ "apihelp-parse-param-contentmodel": "Modelo de contenido del texto de entrada. Si se omite, se debe especificar $1title, y el valor por defecto será el modelo del título especificado. Solo es válido cuando se use junto con $1text.",
+ "apihelp-parse-example-page": "Analizar una página.",
+ "apihelp-parse-example-text": "Analizar wikitexto.",
+ "apihelp-parse-example-texttitle": "Analizar wikitexto, especificando el título de la página.",
+ "apihelp-parse-example-summary": "Analizar un resumen.",
+ "apihelp-patrol-summary": "Verificar una página o revisión.",
+ "apihelp-patrol-param-rcid": "Identificador de cambios recientes que verificar.",
+ "apihelp-patrol-param-revid": "Identificador de revisión que patrullar.",
+ "apihelp-patrol-param-tags": "Cambio de etiquetas para aplicar a la entrada en la patrulla de registro.",
+ "apihelp-patrol-example-rcid": "Verificar un cambio reciente.",
+ "apihelp-patrol-example-revid": "Verificar una revisión.",
+ "apihelp-protect-summary": "Cambiar el nivel de protección de una página.",
+ "apihelp-protect-param-title": "Título de la página a (des)proteger. No se puede utilizar con $1pageid.",
+ "apihelp-protect-param-pageid": "ID de la página a (des)proteger. No se puede utilizar con $1title.",
+ "apihelp-protect-param-protections": "Lista de los niveles de protección, con formato <kbd>action=level</kbd> (por ejemplo: <kbd>edit=sysop</kbd>). Un nivel de <kbd>all</kbd> («todos») significa que cualquier usuaro puede realizar la acción, es decir, no hay restricción.\n\n<strong>Nota:</strong> Cualquier acción no mencionada tendrá las restricciones eliminadas.",
+ "apihelp-protect-param-expiry": "Marcas de tiempo de expiración. Si solo se establece una marca de tiempo, se utilizará para todas las protecciones. Utiliza <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, o <kbd>never</kbd> para una protección indefinida.",
+ "apihelp-protect-param-reason": "Motivo de la (des)protección.",
+ "apihelp-protect-param-tags": "Cambiar las etiquetas para aplicar a la entrada en el registro de protección.",
+ "apihelp-protect-param-cascade": "Activar la protección en cascada (o sea, proteger plantillas e imágenes transcluidas usadas en esta página). Se ignorará si ninguno de los niveles de protección dados son compatibles con la función de cascada.",
+ "apihelp-protect-param-watch": "Si se activa, añade la página en proceso de (des)protección a la lista de seguimiento del usuario actual.",
+ "apihelp-protect-param-watchlist": "Añadir o borrar incondicionalmente la página de la lista de seguimiento del usuario actual, utilizar las preferencias o no cambiar el estado de seguimiento.",
+ "apihelp-protect-example-protect": "Proteger una página",
+ "apihelp-protect-example-unprotect": "Desproteger una página estableciendo la restricción a <kbd>all</kbd> («todos», es decir, cualquier usuario puede realizar la acción).",
+ "apihelp-protect-example-unprotect2": "Desproteger una página anulando las restricciones.",
+ "apihelp-purge-summary": "Purgar la caché de los títulos proporcionados.",
+ "apihelp-purge-param-forcelinkupdate": "Actualizar las tablas de enlaces.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Actualizar la tabla de enlaces y todas las tablas de enlaces de cualquier página que use esta página como una plantilla.",
+ "apihelp-purge-example-simple": "Purgar la <kbd>Main Page</kbd> y la página <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "Purgar las 10 primeras páginas del espacio de nombres principal.",
+ "apihelp-query-summary": "Obtener datos de y sobre MediaWiki.",
+ "apihelp-query-extended-description": "Todas las modificaciones de datos tendrán que utilizar primero la consulta para adquirir un token para evitar el abuso desde sitios maliciosos.",
+ "apihelp-query-param-prop": "Qué propiedades obtener para las páginas consultadas.",
+ "apihelp-query-param-list": "Qué listas obtener.",
+ "apihelp-query-param-meta": "Qué metadatos obtener.",
+ "apihelp-query-param-indexpageids": "Incluir una sección de ID de páginas adicional en la que se muestran todas las ID de páginas.",
+ "apihelp-query-param-export": "Exportar las revisiones actuales de las páginas dadas o generadas.",
+ "apihelp-query-param-exportnowrap": "Devuelve el XML de exportación sin envolverlo en un resultado XML (mismo formato que [[Special:Export]]). Solo se puede usar junto con $1export.",
+ "apihelp-query-param-iwurl": "Si la URL completa si el título es un interwiki.",
+ "apihelp-query-param-rawcontinue": "Devuelve los datos <samp>query-continue</samp> en bruto para continuar.",
+ "apihelp-query-example-revisions": "Busque [[Special:ApiHelp/query+siteinfo|información del sitio]] y [[Special:ApiHelp/query+revisions|revisiones]] de <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "Obtener revisiones de páginas que comiencen por <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "Enumerar todas las categorías.",
+ "apihelp-query+allcategories-param-from": "La categoría para comenzar la enumeración",
+ "apihelp-query+allcategories-param-to": "La categoría para detener la enumeración",
+ "apihelp-query+allcategories-param-prefix": "Buscar todos los títulos de las categorías que comiencen con este valor.",
+ "apihelp-query+allcategories-param-dir": "Dirección de ordenamiento.",
+ "apihelp-query+allcategories-param-min": "Devolver solo categorías con al menos este número de miembros.",
+ "apihelp-query+allcategories-param-max": "Devolver solo categorías con como mucho este número de miembros.",
+ "apihelp-query+allcategories-param-limit": "Cuántas categorías se devolverán.",
+ "apihelp-query+allcategories-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Añade el número de páginas en la categoría.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Etiqueta las categorías que están ocultas con <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Lista las categorías con información sobre el número de páginas de cada una.",
+ "apihelp-query+allcategories-example-generator": "Recupera la información sobre la propia página de categoría para las categorías que empiezan por <kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "Listar todas las revisiones eliminadas por un usuario o en un espacio de nombres.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Solo puede usarse con <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "No puede utilizarse con <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "El sello de tiempo para comenzar la enumeración",
+ "apihelp-query+alldeletedrevisions-param-end": "El sello de tiempo para detener la enumeración",
+ "apihelp-query+alldeletedrevisions-param-from": "Empezar a listar en este título.",
+ "apihelp-query+alldeletedrevisions-param-to": "Terminar de listar en este título.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Buscar todos los títulos de las páginas que comiencen con este valor.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Listar solo las revisiones con esta etiqueta.",
+ "apihelp-query+alldeletedrevisions-param-user": "Listar solo las revisiones de este usuario.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "No listar las revisiones de este usuario.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Listar solo las páginas en este espacio de nombres.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>Nota:</strong> debido al [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo avaro]], usar juntos <var>$1user</var> y <var>$1namespace</var> puede dar lugar a que se devuelvan menos de <var>$1limit</var> resultados antes de continuar. En casos extremos, podrían devolverse cero resultados.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Cuando se utiliza como generador, generar títulos en lugar de identificadores de revisión.",
+ "apihelp-query+alldeletedrevisions-example-user": "Listar las últimas 50 contribuciones borradas del usuario <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Listar las primeras 50 revisiones borradas en el espacio de nombres principal.",
+ "apihelp-query+allfileusages-summary": "Enumerar todos los usos del archivo, incluidos los que no existen.",
+ "apihelp-query+allfileusages-param-from": "El título del archivo para comenzar la enumeración.",
+ "apihelp-query+allfileusages-param-to": "El título del archivo para detener la enumeración.",
+ "apihelp-query+allfileusages-param-prefix": "Buscar todos los títulos de los archivos que comiencen con este valor.",
+ "apihelp-query+allfileusages-param-unique": "Mostrar solo títulos únicos de archivo. No se puede usar junto con $1prop=ids. Cuando se use como generador, devuelve páginas de destino en vez de páginas de origen.",
+ "apihelp-query+allfileusages-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Agrega los ID de página de las páginas en uso (no se puede usar con $1unique).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Agrega el título del archivo.",
+ "apihelp-query+allfileusages-param-limit": "Cuántos elementos en total se devolverán.",
+ "apihelp-query+allfileusages-param-dir": "La dirección en la que se listará.",
+ "apihelp-query+allfileusages-example-B": "Listar títulos de archivos, incluyendo los desaparecidos, con las ID de páginas a las que pertenecen, empezando por la <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Listar títulos de archivos únicos.",
+ "apihelp-query+allfileusages-example-unique-generator": "Recupera los títulos de todos los archivos y marca los faltantes.",
+ "apihelp-query+allfileusages-example-generator": "Recupera las páginas que contienen los archivos.",
+ "apihelp-query+allimages-summary": "Enumerar todas las imágenes secuencialmente.",
+ "apihelp-query+allimages-param-sort": "Propiedad por la que realizar la ordenación.",
+ "apihelp-query+allimages-param-dir": "La dirección en la que se listará.",
+ "apihelp-query+allimages-param-from": "El título de la imagen para comenzar la enumeración. Solo puede utilizarse con $1sort=name.",
+ "apihelp-query+allimages-param-to": "El título de la imagen para detener la enumeración. Solo puede utilizarse con $1sort=name.",
+ "apihelp-query+allimages-param-start": "El sello de tiempo para comenzar la enumeración. Solo puede utilizarse con $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "El sello de tiempo para detener la enumeración. Solo puede utilizarse con $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "Buscar todos los títulos de imágenes que empiecen por este valor. Solo puede utilizarse con $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "Limitar a imágenes con al menos este número de bytes.",
+ "apihelp-query+allimages-param-maxsize": "Limitar a imágenes con como mucho este número de bytes.",
+ "apihelp-query+allimages-param-sha1": "Suma SHA1 de la imagen. Invalida $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "Suma SHA1 de la imagen en base 36 (usada en MediaWiki).",
+ "apihelp-query+allimages-param-user": "Devolver solo los archivos subidos por este usuario. Solo se puede usar con $1tipo=timestamp. No se puede usar junto con $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "Cómo filtrar archivos subidos por bots. Solo se puede usar con $1sort=timestamp. No se puede usar junto con $1user.",
+ "apihelp-query+allimages-param-mime": "Tipos MIME que buscar, como, por ejemplo, <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "Cuántas imágenes en total se devolverán.",
+ "apihelp-query+allimages-example-B": "Mostrar una lista de archivos que empiecen por la letra <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Mostrar una lista de archivos subidos recientemente, similar a [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Mostrar una lista de archivos tipo MIME <kbd>image/png</kbd> o <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Mostrar información acerca de 4 archivos que empiecen por la letra <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Enumerar todos los enlaces que apunten a un determinado espacio de nombres.",
+ "apihelp-query+alllinks-param-from": "El título del enlace para comenzar la enumeración.",
+ "apihelp-query+alllinks-param-to": "El título del enlace para detener la enumeración.",
+ "apihelp-query+alllinks-param-prefix": "Buscar todos los títulos vinculados que comiencen con este valor.",
+ "apihelp-query+alllinks-param-unique": "Mostrar solo títulos únicos enlazados. No se puede usar junto con $1prop=ids. Cuando se use como generador, devuelve páginas de destino en vez de páginas de origen.",
+ "apihelp-query+alllinks-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Agrega el ID de página de la página de enlace (no se puede usar con <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Añade el título del enlace.",
+ "apihelp-query+alllinks-param-namespace": "El espacio de nombres que enumerar.",
+ "apihelp-query+alllinks-param-limit": "Cuántos elementos en total se devolverán.",
+ "apihelp-query+alllinks-param-dir": "La dirección en la que se listará.",
+ "apihelp-query+alllinks-example-B": "Enumera los títulos enlazados, incluyendo los títulos faltantes, con los ID de página de los que provienen, empezando por <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Lista de títulos vinculados únicamente.",
+ "apihelp-query+alllinks-example-unique-generator": "Obtiene todos los títulos enlazados, marcando los que falten.",
+ "apihelp-query+alllinks-example-generator": "Obtiene páginas que contienen los enlaces.",
+ "apihelp-query+allmessages-summary": "Devolver los mensajes de este sitio.",
+ "apihelp-query+allmessages-param-messages": "Qué mensajes mostrar. <kbd>*</kbd> (predeterminado) significa todos los mensajes.",
+ "apihelp-query+allmessages-param-prop": "Qué propiedades se obtendrán.",
+ "apihelp-query+allmessages-param-enableparser": "Establecer para habilitar el analizador, se preprocesará el wikitexto del mensaje (sustitución de palabras mágicas, uso de plantillas, etc.).",
+ "apihelp-query+allmessages-param-nocontent": "Si se establece, no incluya el contenido de los mensajes en la salida.",
+ "apihelp-query+allmessages-param-includelocal": "Incluir también los mensajes locales, es decir, aquellos que no existen en el propio software pero sí en el espacio de nombres {{ns:MediaWiki}}.\nEsto muestra todas las páginas del espacio de nombres {{ns:MediaWiki}}, así que también mostrará las que no son propiamente mensajes, como, por ejemplo, [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Los argumentos que se sustituyen en el mensaje.",
+ "apihelp-query+allmessages-param-filter": "Devolver solo mensajes con nombres que contengan esta cadena.",
+ "apihelp-query+allmessages-param-customised": "Devolver solo mensajes en este estado de personalización.",
+ "apihelp-query+allmessages-param-lang": "Devolver mensajes en este idioma.",
+ "apihelp-query+allmessages-param-from": "Devolver mensajes que empiecen por este mensaje.",
+ "apihelp-query+allmessages-param-to": "Devolver mensajes que acaben por este mensaje.",
+ "apihelp-query+allmessages-param-title": "Nombre de página que usar como contexto al analizar el mensaje (para la opción $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "Devolver mensajes con este prefijo.",
+ "apihelp-query+allmessages-example-ipb": "Mostrar mensajes que empiecen por <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Mostrar mensajes <kbd>august</kbd> y <kbd>mainpage</kbd> en alemán.",
+ "apihelp-query+allpages-summary": "Enumerar todas las páginas secuencialmente en un espacio de nombres determinado.",
+ "apihelp-query+allpages-param-from": "El título de página para comenzar la enumeración",
+ "apihelp-query+allpages-param-to": "El título de página para detener la enumeración.",
+ "apihelp-query+allpages-param-prefix": "Buscar todos los títulos de las páginas que comiencen con este valor.",
+ "apihelp-query+allpages-param-namespace": "El espacio de nombres que enumerar.",
+ "apihelp-query+allpages-param-filterredir": "Qué páginas listar.",
+ "apihelp-query+allpages-param-minsize": "Limitar a páginas con al menos este número de bytes.",
+ "apihelp-query+allpages-param-maxsize": "Limitar a páginas con este número máximo de bytes.",
+ "apihelp-query+allpages-param-prtype": "Limitar a páginas protegidas.",
+ "apihelp-query+allpages-param-prlevel": "Filtrar protecciones según el nivel de protección (se debe usar junto con el parámetro $1prtype= ).",
+ "apihelp-query+allpages-param-prfiltercascade": "Filtrar protecciones según la protección en cascada (se ignora cuando $1prtype no está fijado).",
+ "apihelp-query+allpages-param-limit": "Cuántas páginas en total se devolverán.",
+ "apihelp-query+allpages-param-dir": "La dirección en la que se listará.",
+ "apihelp-query+allpages-param-filterlanglinks": "Filtrar en función de si una página tiene langlinks. Tenga en cuenta que esto no puede considerar langlinks agregados por extensiones.",
+ "apihelp-query+allpages-param-prexpiry": "¿Cuál término de protección para filtrar la página en:\n; Indefinida: Obtener sólo páginas con protección de vencimiento indefinida.\n; Definida: Obtener sólo las páginas con un definitivo (específico) vencimiento.\n; Todos: Obtener páginas con cualquier caducidad.",
+ "apihelp-query+allpages-example-B": "Mostrar una lista de páginas que empiecen con la letra <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Mostrar información acerca de 4 páginas que empiecen por la letra <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Mostrar el contenido de las 2 primeras páginas que no redirijan y empiecen por <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Obtener la lista de todas las redirecciones a un espacio de nombres.",
+ "apihelp-query+allredirects-param-from": "El título de la redirección para iniciar la enumeración.",
+ "apihelp-query+allredirects-param-to": "El título de la redirección para detener la enumeración.",
+ "apihelp-query+allredirects-param-prefix": "Buscar todas las páginas de destino que empiecen con este valor.",
+ "apihelp-query+allredirects-param-unique": "Mostrar solo títulos únicos de páginas de destino. No se puede usar junto con $1prop=ids|fragment|interwiki. Cuando se use como generador, devuelve páginas de destino en vez de páginas de origen.",
+ "apihelp-query+allredirects-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Añade el identificador de la página de redirección (no se puede usar junto con <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Añade el título de la redirección.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Añade el fragmento de la redirección, si existe (no se puede usar junto con <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Añade el prefijo interwiki de la redirección, si existe (no se puede usar junto con <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "El espacio de nombres a enumerar.",
+ "apihelp-query+allredirects-param-limit": "Cuántos elementos se devolverán.",
+ "apihelp-query+allredirects-param-dir": "La dirección en la que se listará.",
+ "apihelp-query+allredirects-example-B": "Enumera las páginas de destino, incluyendo las páginas desaparecidas, con los identificadores de las páginas de las que provienen, empezando por <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "La lista de páginas de destino.",
+ "apihelp-query+allredirects-example-unique-generator": "Obtiene todas las páginas de destino, marcando los que faltan.",
+ "apihelp-query+allredirects-example-generator": "Obtiene páginas que contienen las redirecciones.",
+ "apihelp-query+allrevisions-summary": "Listar todas las revisiones.",
+ "apihelp-query+allrevisions-param-start": "La marca de tiempo para iniciar la enumeración.",
+ "apihelp-query+allrevisions-param-end": "La marca de tiempo para detener la enumeración.",
+ "apihelp-query+allrevisions-param-user": "Listar solo las revisiones de este usuario.",
+ "apihelp-query+allrevisions-param-excludeuser": "No listar las revisiones de este usuario.",
+ "apihelp-query+allrevisions-param-namespace": "Listar solo las páginas en este espacio de nombres.",
+ "apihelp-query+allrevisions-param-generatetitles": "Cuando se utilice como generador, genera títulos en lugar de ID de revisión.",
+ "apihelp-query+allrevisions-example-user": "Listar las últimas 50 contribuciones del usuario <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "Listar las primeras 50 revisiones en el espacio de nombres principal.",
+ "apihelp-query+mystashedfiles-summary": "Obtener una lista de archivos en la corriente de carga de usuarios.",
+ "apihelp-query+mystashedfiles-param-prop": "Propiedades a buscar para los archivos.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Buscar el tamaño del archivo y las dimensiones de la imagen.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Obtener el tipo MIME y tipo multimedia del archivo.",
+ "apihelp-query+mystashedfiles-param-limit": "Cuántos archivos obtener.",
+ "apihelp-query+mystashedfiles-example-simple": "Obtenga la clave de archivo, el tamaño del archivo y el tamaño de los archivos en pixeles en el caché de carga del usuario actual.",
+ "apihelp-query+alltransclusions-summary": "Mostrar todas las transclusiones (páginas integradas mediante &#123;&#123;x&#125;&#125;), incluidas las inexistentes.",
+ "apihelp-query+alltransclusions-param-from": "El título de la transclusión por la que empezar la enumeración.",
+ "apihelp-query+alltransclusions-param-to": "El título de la transclusión por la que terminar la enumeración.",
+ "apihelp-query+alltransclusions-param-prefix": "Buscar todos los títulos transcluidos que comiencen con este valor.",
+ "apihelp-query+alltransclusions-param-unique": "Mostrar solo títulos únicos transcluidos. No se puede usar junto con $1prop=ids. Cuando se use como generador, devuelve páginas de destino en vez de páginas de origen.",
+ "apihelp-query+alltransclusions-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Agrega el ID de página de la página de redirección (no se puede usar con $1unique).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Añade el título de la transclusión.",
+ "apihelp-query+alltransclusions-param-namespace": "El espacio de nombres que enumerar.",
+ "apihelp-query+alltransclusions-param-limit": "Número de elementos que se desea obtener.",
+ "apihelp-query+alltransclusions-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+alltransclusions-example-B": "Enumerar los títulos transcluidos, incluyendo los faltantes, junto con los identificadores de las páginas de las que provienen, empezando por <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Listar títulos transcluidos de forma única.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Obtiene todos los títulos transcluidos, marcando los que faltan.",
+ "apihelp-query+alltransclusions-example-generator": "Obtiene las páginas que contienen las transclusiones.",
+ "apihelp-query+allusers-summary": "Enumerar todos los usuarios registrados.",
+ "apihelp-query+allusers-param-from": "El nombre de usuario por el que empezar la enumeración.",
+ "apihelp-query+allusers-param-to": "El nombre de usuario por el que finalizar la enumeración.",
+ "apihelp-query+allusers-param-prefix": "Buscar todos los usuarios que empiecen con este valor.",
+ "apihelp-query+allusers-param-dir": "Dirección de ordenamiento.",
+ "apihelp-query+allusers-param-group": "Incluir solo usuarios en los grupos dados.",
+ "apihelp-query+allusers-param-excludegroup": "Excluir a los usuarios en estos grupos",
+ "apihelp-query+allusers-param-rights": "Sólo se incluyen a los usuarios con los derechos cedidos. No incluye los derechos concedidos por la implícita o auto-promoverse grupos como *, usuario, o autoconfirmed.",
+ "apihelp-query+allusers-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Añade información sobre un bloque actual al usuario.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Lista los grupos a los que el usuario pertenece. Esto utiliza más recursos del servidor y puede devolver menos resultados que el límite.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Lista todos los grupos el usuario es automáticamente en.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Lista los permisos que tiene el usuario.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Añade el número de ediciones del usuario.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Añade la marca de tiempo del momento en que el usuario se registró, si está disponible (puede estar en blanco).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Añade el central IDs y estado de anexo para el usuario.",
+ "apihelp-query+allusers-param-limit": "Cuántos nombres de usuario se devolverán.",
+ "apihelp-query+allusers-param-witheditsonly": "Mostrar solo los usuarios que han realizado ediciones.",
+ "apihelp-query+allusers-param-activeusers": "Solo listar usuarios activos en {{PLURAL:$1|el último día|los $1 últimos días}}.",
+ "apihelp-query+allusers-param-attachedwiki": "Con <kbd>$1prop=centralids</kbd>, indicar también si el usuario está conectado con el wiki identificado por el ID.",
+ "apihelp-query+allusers-example-Y": "Listar usuarios que empiecen por <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Recuperar información sobre el estado de autenticación actual.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Compruebe si el estado de autenticación actual del usuario es suficiente para la operación sensible-seguridad especificada.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Obtener información sobre las peticiones de autentificación requeridas para la acción de autentificación especificada.",
+ "apihelp-query+authmanagerinfo-example-login": "Captura de las solicitudes que puede ser utilizadas al comienzo de inicio de sesión.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Obtener las peticiones que podrían utilizarse al empezar un inicio de sesión, con los campos de formulario integrados.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Comprueba si la autentificación es suficiente para realizar la acción <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "Encuentra todas las páginas que enlazan a la página dada.",
+ "apihelp-query+backlinks-param-title": "Título que buscar. No se puede usar junto con <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "Identificador de página que buscar. No puede usarse junto con <var>$1title</var>",
+ "apihelp-query+backlinks-param-namespace": "El espacio de nombres que enumerar.",
+ "apihelp-query+backlinks-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+backlinks-param-filterredir": "Cómo filtrar redirecciones. Si se establece a <kbd>nonredirects</kbd> cuando está activo <var>$1redirect</var>, esto sólo se aplica al segundo nivel.",
+ "apihelp-query+backlinks-param-limit": "Cuántas páginas en total se devolverán. Si está activo <var>$1redirect</var>, el límite aplica a cada nivel por separado (lo que significa que se pueden devolver hasta 2 * <var>$1limit</var> resultados).",
+ "apihelp-query+backlinks-param-redirect": "Si la página con el enlace es una redirección, encontrar también las páginas que enlacen a esa redirección. El límite máximo se reduce a la mitad.",
+ "apihelp-query+backlinks-example-simple": "Mostrar enlaces a <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Obtener información acerca de las páginas enlazadas a <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Listar todos los usuarios y direcciones IP bloqueadas.",
+ "apihelp-query+blocks-param-start": "El sello de tiempo para comenzar la enumeración",
+ "apihelp-query+blocks-param-end": "El sello de tiempo para detener la enumeración",
+ "apihelp-query+blocks-param-ids": "Lista de bloquear IDs para listar (opcional).",
+ "apihelp-query+blocks-param-users": "Lista de usuarios a buscar (opcional).",
+ "apihelp-query+blocks-param-ip": "Obtiene todos los bloqueos que se aplican a esta dirección IP o intervalo CIDR, incluyendo bloqueos de intervalos. No se puede usar en conjunto con <var>$3users</var>. No se aceptan intervalos CIDR mayores que IPv4/$1 o IPv6/$2.",
+ "apihelp-query+blocks-param-limit": "El número máximo de filtros a listar.",
+ "apihelp-query+blocks-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Agrega el ID del bloque.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Añade el nombre de usuario del usuario bloqueado.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Añade el identificador del usuario bloqueado.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Añade el nombre de usuario del bloqueo de usuario.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Añade el usuario ID del usuario bloqueador.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Añade la fecha y hora de cuando se aplicó el bloque.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Añade la marca de tiempo correspondiente a la expiración del bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Añade la razón dada para el bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Añade la gama de direcciones de IP afectó por el bloque.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Etiquetas la prohibición con (autoblock, anononly, etc.).",
+ "apihelp-query+blocks-param-show": "Muestra solamente los elementos que cumplen estos criterios.\nPor ejemplo, para mostrar solamente los bloqueos indefinidos a direcciones IP, introduce <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "Listar bloques.",
+ "apihelp-query+blocks-example-users": "Muestra los bloqueos de los usuarios <kbd>Alice</kbd> y <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Enumera todas las categorías a las que pertenecen las páginas.",
+ "apihelp-query+categories-param-prop": "Qué propiedades adicionales obtener para cada categoría:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Añade la clave de ordenación (cadena hexadecimal) y el prefijo de la clave de ordenación (la parte legible) de la categoría.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Añade la marca de tiempo del momento en que se añadió la categoría.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Etiqueta las categorías que están ocultas con <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "Qué tipo de categorías mostrar.",
+ "apihelp-query+categories-param-limit": "Cuántas categorías se devolverán.",
+ "apihelp-query+categories-param-categories": "Enumerar solamente estas categorías. Útil para comprobar si una página determinada está en una categoría determinada.",
+ "apihelp-query+categories-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+categories-example-simple": "Obtener una lista de categorías a las que pertenece la página <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categories-example-generator": "Obtener información acerca de todas las categorías utilizadas en la página <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "Devuelve información acerca de las categorías dadas.",
+ "apihelp-query+categoryinfo-example-simple": "Obtener información acerca de <kbd>Category:Foo</kbd> y <kbd>Category:Bar</kbd>",
+ "apihelp-query+categorymembers-summary": "Lista todas las páginas en una categoría dada.",
+ "apihelp-query+categorymembers-param-title": "Categoría que enumerar (requerida). Debe incluir el prefijo <kbd>{{ns:category}}:</kbd>. No se puede utilizar junto con <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "ID de página de la categoría para enumerar. No se puede utilizar junto con <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Añade el identificador de página.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Agrega el título y el identificador del espacio de nombres de la página.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Añade la clave de ordenación utilizada para la ordenación en la categoría (cadena hexadecimal).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Añade la clave de ordenación utilizada para la ordenación en la categoría (parte legible de la clave de ordenación).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Añade el tipo en el que se categorizó la página (<samp>page</samp>, <samp>subcat</samp> or <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Añade la marca de tiempo del momento en que se incluyó la página.",
+ "apihelp-query+categorymembers-param-namespace": "Incluir solamente páginas de estos espacios de nombres. Ten en cuenta que puede haberse utilizado <kbd>$1type=subcat</kbd> o <kbd>$1type=file</kbd> en lugar de <kbd>$1namespace=14</kbd> o <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-type": "Qué tipo de miembros de la categoría incluir. Ignorado cuando se ha establecido <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-limit": "Número máximo de páginas que devolver.",
+ "apihelp-query+categorymembers-param-sort": "Propiedad por la que realizar la ordenación.",
+ "apihelp-query+categorymembers-param-dir": "Dirección en la que desea ordenar.",
+ "apihelp-query+categorymembers-param-start": "Marca de tiempo por la que empezar la enumeración. Solo se puede utilizar junto con <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "Marca de tiempo por la que terminar la enumeración. Solo se puede utilizar junto con <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Clave de ordenación por la que empezar la enumeración, tal como se ha devuelto por <kbd>$1prop=sortkey</kbd>. Solo se puede utilizar junto con <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Clave de ordenación por la que terminar la enumeración, tal como se ha devuelto por <kbd>$1prop=sortkey</kbd>. Solo se puede utilizar junto con <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "Prefijo de la clave de ordenación por el que empezar la enumeración. Solo se puede utilizar junto con <kbd>$1sort=sortkey</kbd>. Reemplaza <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "Prefijo de la clave de ordenación <strong>antes</strong> del cual termina la enumeración (no <strong>en</strong> el cual; si este valor existe, no será incluido). Solo se puede utilizar junto con <kbd>$1sort=sortkey</kbd>. Reemplaza <var>$1endhexsortkey</var>.",
+ "apihelp-query+categorymembers-param-startsortkey": "Utilizar $1starthexsortkey en su lugar.",
+ "apihelp-query+categorymembers-param-endsortkey": "Utilizar $1endhexsortkey en su lugar.",
+ "apihelp-query+categorymembers-example-simple": "Obtener las primeras 10 páginas en <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Obtener información sobre las primeras 10 páginas de la <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Obtener la lista de contribuidores conectados y el número de contribuidores anónimos de una página.",
+ "apihelp-query+contributors-param-group": "Solo incluir usuarios de los grupos especificados. No incluye grupos implícitos o autopromocionados, como *, usuario o autoconfirmado.",
+ "apihelp-query+contributors-param-excludegroup": "Excluir usuarios de los grupos especificados. No incluye grupos implícitos o autopromocionados, como *, usuario o autoconfirmado.",
+ "apihelp-query+contributors-param-rights": "Solo incluir usuarios con los derechos especificados. No incluye derechos concedidos a grupos implícitos o autopromocionados, como *, usuario o autoconfirmado.",
+ "apihelp-query+contributors-param-excluderights": "Excluir usuarios con los derechos especificados. No incluye derechos concedidos a grupos implícitos o autopromocionados, como *, usuario o autoconfirmado.",
+ "apihelp-query+contributors-param-limit": "Cuántos contribuyentes se devolverán.",
+ "apihelp-query+contributors-example-simple": "Mostrar los contribuyentes de la página <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-summary": "Obtener información de revisión eliminada.",
+ "apihelp-query+deletedrevisions-extended-description": "Puede ser utilizada de varias maneras:\n# Obtenga las revisiones eliminadas de un conjunto de páginas, estableciendo títulos o ID de paginas. Ordenadas por título y marca horaria.\n# Obtener datos sobre un conjunto de revisiones eliminadas estableciendo sus ID con identificación de revisión. Ordenado por ID de revisión.",
+ "apihelp-query+deletedrevisions-param-start": "Marca de tiempo por la que empezar la enumeración. Se ignora cuando se esté procesando una lista de ID de revisión.",
+ "apihelp-query+deletedrevisions-param-end": "Marca de tiempo por la que terminar la enumeración. Se ignora cuando se esté procesando una lista de ID de revisión.",
+ "apihelp-query+deletedrevisions-param-tag": "Listar solo las revisiones con esta etiqueta.",
+ "apihelp-query+deletedrevisions-param-user": "Listar solo las revisiones de este usuario.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "No listar las revisiones de este usuario.",
+ "apihelp-query+deletedrevisions-example-titles": "Muestra la lista de revisiones borradas de las páginas <kbd>Main Page</kbd> y <kbd>Talk:Main Page</kbd>, con su contenido.",
+ "apihelp-query+deletedrevisions-example-revids": "Mostrar la información de la revisión borrada <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "Muestra la lista de revisiones borradas.",
+ "apihelp-query+deletedrevs-extended-description": "Opera en tres modos:\n# Lista de revisiones borradas de los títulos dados, ordenadas por marca de tiempo.\n# Lista de contribuciones borradas del usuario dado, ordenadas por marca de tiempo.\n# Lista de todas las revisiones borradas en el espacio de nombres dado, ordenadas por título y marca de tiempo (donde no se ha especificado ningún título ni se ha fijado $1user).",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Modo|Modos}}: $2",
+ "apihelp-query+deletedrevs-param-start": "Marca de tiempo por la que empezar la enumeración.",
+ "apihelp-query+deletedrevs-param-end": "Marca de tiempo por la que terminar la enumeración.",
+ "apihelp-query+deletedrevs-param-from": "Empezar a listar en este título.",
+ "apihelp-query+deletedrevs-param-to": "Terminar de listar en este título.",
+ "apihelp-query+deletedrevs-param-prefix": "Buscar todas las páginas que empiecen con este valor.",
+ "apihelp-query+deletedrevs-param-unique": "Listar solo una revisión por cada página.",
+ "apihelp-query+deletedrevs-param-tag": "Listar solo las revisiones con esta etiqueta.",
+ "apihelp-query+deletedrevs-param-user": "Listar solo las revisiones de este usuario.",
+ "apihelp-query+deletedrevs-param-excludeuser": "No listar las revisiones de este usuario.",
+ "apihelp-query+deletedrevs-param-namespace": "Listar solo las páginas en este espacio de nombres.",
+ "apihelp-query+deletedrevs-param-limit": "La cantidad máxima de revisiones que listar.",
+ "apihelp-query+deletedrevs-param-prop": "Propiedades que obtener:\n;revid: Añade el identificador de la revisión borrada.\n;parentid: Añade el identificador de la revisión anterior de la página.\n;user: Añade el usuario que hizo la revisión.\n;userid: Añade el identificador del usuario que hizo la revisión.\n;comment: Añade el comentario de la revisión.\n;parsedcomment: Añade el comentario de la revisión, pasado por el analizador sintáctico.\n;minor: Añade una etiqueta si la revisión es menor.\n;len: Añade la longitud (en bytes) de la revisión.\n;sha1: Añade el SHA-1 (base 16) de la revisión.\n;content: Añade el contenido de la revisión.\n;token:<span class=\"apihelp-deprecated\">Obsoleto.</span> Devuelve el token de edición.\n;tags: Etiquetas de la revisión.",
+ "apihelp-query+deletedrevs-example-mode1": "Muestra las últimas revisiones borradas de las páginas <kbd>Main Page</kbd> y <kbd>Talk:Main Page</kbd>, con contenido (modo 1).",
+ "apihelp-query+deletedrevs-example-mode2": "Muestra las últimas 50 contribuciones de <kbd>Bob</kbd> (modo 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Muestra las primeras 50 revisiones borradas del espacio principal (modo 3).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Listar las primeras 50 páginas en el espacio de nombres {{ns:talk}} (modo 3).",
+ "apihelp-query+disabled-summary": "Se ha desactivado el módulo de consulta.",
+ "apihelp-query+duplicatefiles-summary": "Enumerar todos los archivos que son duplicados de los archivos dados a partir de los valores hash.",
+ "apihelp-query+duplicatefiles-param-limit": "Número de archivos duplicados para devolver.",
+ "apihelp-query+duplicatefiles-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+duplicatefiles-param-localonly": "Buscar solo archivos en el repositorio local.",
+ "apihelp-query+duplicatefiles-example-simple": "Buscar duplicados de [[:File:Alber Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Buscar duplicados en todos los archivos.",
+ "apihelp-query+embeddedin-summary": "Encuentra todas las páginas que transcluyen el título dado.",
+ "apihelp-query+embeddedin-param-title": "Título a buscar. No puede usarse en conjunto con $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "Identificador de página que buscar. No se puede usar junto con $1title.",
+ "apihelp-query+embeddedin-param-namespace": "El espacio de nombres que enumerar.",
+ "apihelp-query+embeddedin-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+embeddedin-param-filterredir": "Cómo filtrar las redirecciones.",
+ "apihelp-query+embeddedin-param-limit": "Cuántas páginas se devolverán.",
+ "apihelp-query+embeddedin-example-simple": "Mostrar las páginas que transcluyen <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "Obtener información sobre las páginas que transcluyen <kbd>Template:Stub</kbd>.",
+ "apihelp-query+extlinks-summary": "Devuelve todas las URL externas (excluidos los interwikis) de las páginas dadas.",
+ "apihelp-query+extlinks-param-limit": "Cuántos enlaces se devolverán.",
+ "apihelp-query+extlinks-param-protocol": "Protocolo de la URL. Si está vacío y <var>$1query</var> está definido, el protocolo es <kbd>http</kbd>. Para enumerar todos los enlaces externos, deja a la vez vacíos esto y <var>$1query</var>.",
+ "apihelp-query+extlinks-param-query": "Cadena de búsqueda sin protocolo. Útil para comprobar si una determinada página contiene una determinada URL externa.",
+ "apihelp-query+extlinks-param-expandurl": "Expandir las URL relativas a un protocolo con el protocolo canónico.",
+ "apihelp-query+extlinks-example-simple": "Obtener una lista de los enlaces externos en <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-summary": "Enumera páginas que contienen una URL dada.",
+ "apihelp-query+exturlusage-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Añade el identificado de la página.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Agrega el título y el identificador del espacio de nombres de la página.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Añade el URL utilizado en la página.",
+ "apihelp-query+exturlusage-param-protocol": "Protocolo del URL. Si está vacío y se establece <var>$1query</var>, el protocolo es <kbd>http</kbd>. Deja vacío esto y <var>$1query</var> para listar todos los enlaces externos.",
+ "apihelp-query+exturlusage-param-query": "Cadena de búsqueda sin protocolo. Véase [[Special:LinkSearch]]. Deja el campo vacío para enumerar todos los enlaces externos.",
+ "apihelp-query+exturlusage-param-namespace": "Los espacios de nombres que enumerar.",
+ "apihelp-query+exturlusage-param-limit": "Cuántas páginas se devolverán.",
+ "apihelp-query+exturlusage-param-expandurl": "Expandir las URL relativas a un protocolo con el protocolo canónico.",
+ "apihelp-query+exturlusage-example-simple": "Mostrar páginas que enlacen con <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-summary": "Enumerar todos los archivos borrados de forma secuencial.",
+ "apihelp-query+filearchive-param-from": "El título de imagen para comenzar la enumeración",
+ "apihelp-query+filearchive-param-to": "El título de imagen para detener la enumeración.",
+ "apihelp-query+filearchive-param-prefix": "Buscar todos los títulos de las imágenes que comiencen con este valor.",
+ "apihelp-query+filearchive-param-limit": "Número de imágenes que devolver en total.",
+ "apihelp-query+filearchive-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+filearchive-param-sha1": "Hash SHA1 de la imagen. Reemplaza $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "Hash SHA1 de la imagen en base 36 (utilizado en MediaWiki).",
+ "apihelp-query+filearchive-param-prop": "Qué información de imagen se obtendrá:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Añade el hash SHA-1 para la imagen.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Añade la marca de tiempo de la versión subida.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Agrega el usuario que subió la versión de la imagen.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Agrega el tamaño de la imagen en bytes y la altura, la anchura y el número de páginas (si es aplicable).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Alias del tamaño.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Añade la descripción de la versión de la imagen.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Analizar la descripción de la versión.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Añade el MIME de la imagen.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Añade el tipo multimedia de la imagen.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Enumera los metadatos Exif para la versión de la imagen.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Añade la profundidad de bit de la versión.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Añade el nombre de archivo de la versión archivada para las versiones que no son las últimas.",
+ "apihelp-query+filearchive-example-simple": "Mostrar una lista de todos los archivos eliminados.",
+ "apihelp-query+filerepoinfo-summary": "Devuelve metainformación sobre los repositorios de imágenes configurados en el wiki.",
+ "apihelp-query+filerepoinfo-param-prop": "Propiedades del repositorio a obtener (puede haber más disponibles en algunos wikis):\n;apiurl:URL del repositorio API - útil para obtener información de imagen del servidor.\n;name:La clave del repositorio - usado in e.g. <var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> y [[Special:ApiHelp/query+imageinfo|imageinfo]] devuelve valores.\n;displayname:El nombre legible del repositorio wiki.\n;rooturl:Raíz URL para rutas de imágenes.\n;local:Si ese repositorio es local o no.",
+ "apihelp-query+filerepoinfo-example-simple": "Obtener información acerca de los repositorios de archivos.",
+ "apihelp-query+fileusage-summary": "Encontrar todas las páginas que utilizan los archivos dados.",
+ "apihelp-query+fileusage-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "Identificador de cada página.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Título de cada página.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Marcar si la página es una redirección.",
+ "apihelp-query+fileusage-param-namespace": "Incluir solo páginas de estos espacios de nombres.",
+ "apihelp-query+fileusage-param-limit": "Cuántos se devolverán.",
+ "apihelp-query+fileusage-param-show": "Muestra solo los elementos que cumplen estos criterios:\n;redirect: Muestra solamente redirecciones.\n;!redirect: Muestra solamente páginas que no son redirecciones.",
+ "apihelp-query+fileusage-example-simple": "Obtener una lista de páginas que utilicen [[:File:Example.jpg]].",
+ "apihelp-query+fileusage-example-generator": "Obtener información acerca de las páginas que utilicen [[:File:Example.jpg]].",
+ "apihelp-query+imageinfo-summary": "Devuelve información del archivo y su historial de subida.",
+ "apihelp-query+imageinfo-param-prop": "Qué información del archivo se obtendrá:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Añade la marca de tiempo a la versión actualizada.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Añade el usuario que subió cada versión del archivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Añade la ID de usuario que subió cada versión del archivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Comentarios sobre la versión.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Analizar el comentario de la versión.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Agrega el título canónico del archivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Devuelve la URL para el archivo y la página de descripción.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Agrega el tamaño del archivo en bytes y la altura, el ancho y el número de páginas (si aplica).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Alias para el tamaño.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Añade el hash SHA-1 para la imagen.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Añade el tipo MIME del archivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Añade el tipo MIME de la miniatura de la imagen (se requiere la URL y el parámetro $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Añade el tipo multimedia de la imagen.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Enumera los metadatos Exif para la versión del archivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Enumera los metadatos genéricos del formato del archivo para la versión del archivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Enumera metadatos con formato combinados de múltiples fuentes. Los resultados están en formato HTML.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Añade el nombre del archivo de la versión archivada para las versiones anteriores a la última.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Añade la profundidad de bits de la versión.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Usado por la página de Carga Especial para obtener información sobre un archivo existente. No está diseñado para ser utilizado fuera del núcleo MediaWiki.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Añade si el archivo está en la [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-limit": "Cuántos revisiones de archivos se devolverán por perfil.",
+ "apihelp-query+imageinfo-param-start": "Marca de tiempo por la que empezar la enumeración.",
+ "apihelp-query+imageinfo-param-end": "Marca de tiempo por la que terminar la enumeración.",
+ "apihelp-query+imageinfo-param-urlwidth": "Si se establece $2prop=url, se devolverá una URL a una imagen escalada a este ancho.\nPor razones de rendimiento, si se utiliza esta opción, no se devolverán más de $1 imágenes escaladas.",
+ "apihelp-query+imageinfo-param-urlheight": "Similar a $1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "Versión de los metadatos que se utilizará. Si se especifica <kbd>latest</kbd>, utilizará la última versión. El valor predeterminado es <kbd>1</kbd>, por motivo de retrocompatibilidad.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "En qué idioma obtener «extmetadata». Esto afecta tanto la traducción que se obtendrá ―si hay varias― como el formato de elementos como los números y algunos valores.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Si las traducciones para la propiedad extmetadata están disponibles, busque todas ellas.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Si se especifica y no vacío, sólo estas claves serán devueltos por $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "Un controlador específico de la cadena de parámetro. Por ejemplo, los archivos Pdf pueden utilizar <kbd>page15-100px</kbd>. <var>$1urlwidth</var> debe ser utilizado y debe ser consistente con <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Si <kbd>$2prop=badfile</kbd> está establecido, este es el título de la página utilizado al evaluar la [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-localonly": "Buscar solo archivos en el repositorio local.",
+ "apihelp-query+imageinfo-example-simple": "Obtener información sobre la versión actual de [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "Obtener información sobre las versiones de [[:File:Test.jpg]] a partir de 2008.",
+ "apihelp-query+images-summary": "Devuelve todos los archivos contenidos en las páginas dadas.",
+ "apihelp-query+images-param-limit": "Cuántos archivos se devolverán.",
+ "apihelp-query+images-param-images": "Mostrar solo estos archivos. Útil para comprobar si una determinada página tiene un determinado archivo.",
+ "apihelp-query+images-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+images-example-simple": "Obtener una lista de los archivos usados en la [[Main Page|Portada]].",
+ "apihelp-query+images-example-generator": "Obtener información sobre todos los archivos empleados en [[Main Page]].",
+ "apihelp-query+imageusage-summary": "Encontrar todas las páginas que usen el título de imagen dado.",
+ "apihelp-query+imageusage-param-title": "Título a buscar. No puede usarse en conjunto con $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "ID de página a buscar. No puede usarse con $1title.",
+ "apihelp-query+imageusage-param-namespace": "El espacio de nombres que enumerar.",
+ "apihelp-query+imageusage-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+imageusage-param-filterredir": "Cómo filtrar las redirecciones. Si se establece a no redirecciones cuando está habilitado $1redirect, esto solo se aplica al segundo nivel.",
+ "apihelp-query+imageusage-param-limit": "Número de páginas que devolver. Si está habilitado <var>$1redirect</var>, el límite se aplica a cada nivel de forma separada (es decir, se pueden devolver hasta 2 * <var>$1limit</var>).",
+ "apihelp-query+imageusage-param-redirect": "Si la página con el enlace es una redirección, encontrar también las páginas que enlacen a esa redirección. El límite máximo se reduce a la mitad.",
+ "apihelp-query+imageusage-example-simple": "Mostrar las páginas que usan [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Obtener información sobre las páginas que empleen [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "Obtener información básica de la página.",
+ "apihelp-query+info-param-prop": "Qué propiedades adicionales se obtendrán:",
+ "apihelp-query+info-paramvalue-prop-protection": "Listar el nivel de protección de cada página.",
+ "apihelp-query+info-paramvalue-prop-talkid": "El identificador de la página de discusión correspondiente a cada página que no es de discusión.",
+ "apihelp-query+info-paramvalue-prop-watched": "Muestra el estado de seguimiento de cada página.",
+ "apihelp-query+info-paramvalue-prop-watchers": "El número de observadores, si se permite.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "El número de observadores de cada página que ha visitado ediciones recientes a esa página, si se permite.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "La hora de notificación de la lista de seguimiento de cada página.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "La ID de página de la página principal de cada página de discusión.",
+ "apihelp-query+info-paramvalue-prop-url": "Muestra una URL completa, una URL de edición y la URL canónica de cada página.",
+ "apihelp-query+info-paramvalue-prop-readable": "Si el usuario puede leer esta página.",
+ "apihelp-query+info-paramvalue-prop-preload": "Muestra el texto devuelto por EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Proporciona la manera en que se muestra realmente el título de la página",
+ "apihelp-query+info-param-testactions": "Comprobar su el usuario actual puede realizar determinadas acciones en la página.",
+ "apihelp-query+info-param-token": "Usa [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] en su lugar.",
+ "apihelp-query+info-example-simple": "Obtener información acerca de la página <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Obtén información general y protección acerca de la página <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-summary": "Encontrar todas las páginas que enlazan al enlace interwiki dado.",
+ "apihelp-query+iwbacklinks-extended-description": "Puede utilizarse para encontrar todos los enlaces con un prefijo, o todos los enlaces a un título (con un determinado prefijo). Si no se introduce ninguno de los parámetros, se entiende como «todos los enlaces interwiki».",
+ "apihelp-query+iwbacklinks-param-prefix": "Prefijo para el interwiki.",
+ "apihelp-query+iwbacklinks-param-title": "Enlace interlingüístico que buscar. Se debe usar junto con <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "Cuántas páginas se devolverán.",
+ "apihelp-query+iwbacklinks-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Añade el prefijo del interwiki.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Añade el título del interwiki.",
+ "apihelp-query+iwbacklinks-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+iwbacklinks-example-simple": "Obtener las páginas enlazadas a [[wikibooks:Test]]",
+ "apihelp-query+iwbacklinks-example-generator": "Obtener información sobre las páginas que enlacen a [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-summary": "Devuelve todos los enlaces interwiki de las páginas dadas.",
+ "apihelp-query+iwlinks-param-url": "Si desea obtener la URL completa (no se puede usar con $1prop).",
+ "apihelp-query+iwlinks-param-prop": "Qué propiedades adicionales obtener para cada enlace interlingüe:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Añade el URL completo.",
+ "apihelp-query+iwlinks-param-limit": "Cuántos enlaces interwiki se desea devolver.",
+ "apihelp-query+iwlinks-param-prefix": "Devolver únicamente enlaces interwiki con este prefijo.",
+ "apihelp-query+iwlinks-param-title": "El enlace Interwiki para buscar. Debe utilizarse con <var>$1prefix </var>.",
+ "apihelp-query+iwlinks-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+iwlinks-example-simple": "Obtener los enlaces interwiki de la página <kbd>Main Page</kbd>.",
+ "apihelp-query+langbacklinks-summary": "Encuentra todas las páginas que conectan con el enlace de idioma dado.",
+ "apihelp-query+langbacklinks-extended-description": "Puede utilizarse para encontrar todos los enlaces con un código de idioma, o todos los enlaces a un título (con un idioma dado). El uso de ninguno de los parámetros es efectivamente \"todos los enlaces de idioma\".\n\nTenga en cuenta que esto no puede considerar los enlaces de idiomas agregados por extensiones.",
+ "apihelp-query+langbacklinks-param-lang": "Idioma del enlace de idioma.",
+ "apihelp-query+langbacklinks-param-title": "Enlace de idioma para buscar. Debe utilizarse con $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "Cuántas páginas en total se devolverán.",
+ "apihelp-query+langbacklinks-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Agrega el código de idioma del enlace de idioma.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Añade el título del enlace de idioma.",
+ "apihelp-query+langbacklinks-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+langbacklinks-example-simple": "Obtener las páginas enlazadas a [[:fr:Test]]",
+ "apihelp-query+langbacklinks-example-generator": "Obtener información acerca de las páginas enlazadas a [[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "Devuelve todos los enlaces interlingüísticos de las páginas dadas.",
+ "apihelp-query+langlinks-param-limit": "Número de enlaces interlingüísticos que devolver.",
+ "apihelp-query+langlinks-param-url": "Obtener la URL completa o no (no se puede usar con <var>$1prop</var>).",
+ "apihelp-query+langlinks-param-prop": "Qué propiedades adicionales obtener para cada enlace interlingüe:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Añade el URL completo.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "Añade el nombre del idioma localizado (o la mejor estimación). Usa <var>$1inlanguagecode</var> para controlar el idioma.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Añade el nombre nativo del idioma.",
+ "apihelp-query+langlinks-param-lang": "Devolver solo enlaces de idioma con este código de idioma.",
+ "apihelp-query+langlinks-param-title": "Enlace que buscar. Se debe usar junto con <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Código de idioma para los nombres de idiomas localizados.",
+ "apihelp-query+langlinks-example-simple": "Obtener los enlaces interlingüísticos de la página <kbd>Main Page</kbd>.",
+ "apihelp-query+links-summary": "Devuelve todos los enlaces de las páginas dadas.",
+ "apihelp-query+links-param-namespace": "Mostrar solo los enlaces en estos espacios de nombres.",
+ "apihelp-query+links-param-limit": "Cuántos enlaces se devolverán.",
+ "apihelp-query+links-param-titles": "Devolver solo los enlaces a estos títulos. Útil para comprobar si una determinada página enlaza a un determinado título.",
+ "apihelp-query+links-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+links-example-simple": "Obtener los enlaces de la página <kbd>Main Page</kbd>",
+ "apihelp-query+links-example-generator": "Obtenga información sobre las páginas de enlace en la página <kbd>Página principal</kbd>.",
+ "apihelp-query+links-example-namespaces": "Obtener enlaces de la página <kbd>Main Page</kbd> de los espacios de nombres {{ns:user}} and {{ns:template}}.",
+ "apihelp-query+linkshere-summary": "Buscar todas las páginas que enlazan a las páginas dadas.",
+ "apihelp-query+linkshere-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "Identificador de cada página.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Título de cada página.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Indicar si la página es una redirección.",
+ "apihelp-query+linkshere-param-namespace": "Incluir solo páginas de estos espacios de nombres.",
+ "apihelp-query+linkshere-param-limit": "Cuántos se devolverán.",
+ "apihelp-query+linkshere-param-show": "Muestra solo los elementos que cumplen estos criterios:\n;redirect: Muestra solamente redirecciones.\n;!redirect: Muestra solamente páginas que no son redirecciones.",
+ "apihelp-query+linkshere-example-simple": "Obtener una lista de páginas que enlacen a la [[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "Obtener información acerca de las páginas enlazadas a la [[Main Page|Portada]].",
+ "apihelp-query+logevents-summary": "Obtener eventos de los registros.",
+ "apihelp-query+logevents-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Agrega el identificador del evento de registro.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Añade el título de la página para el evento del registro.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Añade el tipo del evento de registro.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Añade el usuario responsable del evento del registro.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Agrega el identificador del usuario responsable del evento del registro.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "Añade la marca de tiempo para el evento del registro.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Añade el comentario del evento del registro.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "Añade el comentario analizado del evento de registro.",
+ "apihelp-query+logevents-paramvalue-prop-details": "Muestra detalles adicionales sobre el evento del registro.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Muestra las etiquetas para el evento del registro.",
+ "apihelp-query+logevents-param-type": "Filtrar las entradas del registro solo a este tipo.",
+ "apihelp-query+logevents-param-action": "Filtrar las acciones del registro solo a esta acción. Reemplaza <var>$1type</var>. En la lista de valores posibles, los valores con el asterisco como carácter comodín tales como <kbd>action/*</kbd> pueden tener distintas cadenas después de la barra (/).",
+ "apihelp-query+logevents-param-start": "Marca de tiempo por la que empezar la enumeración.",
+ "apihelp-query+logevents-param-end": "Marca de tiempo por la que terminar la enumeración.",
+ "apihelp-query+logevents-param-user": "Filtrar entradas a aquellas realizadas por el usuario dado.",
+ "apihelp-query+logevents-param-title": "Filtrar entradas a aquellas relacionadas con una página.",
+ "apihelp-query+logevents-param-namespace": "Filtrar entradas a aquellas en el espacio de nombres dado.",
+ "apihelp-query+logevents-param-prefix": "Filtrar entradas que empiezan por este prefijo.",
+ "apihelp-query+logevents-param-tag": "Solo mostrar las entradas de eventos con esta etiqueta.",
+ "apihelp-query+logevents-param-limit": "Número total de entradas de eventos que devolver.",
+ "apihelp-query+logevents-example-simple": "Mostrar los eventos recientes del registro.",
+ "apihelp-query+pagepropnames-summary": "Mostrar todos los nombres de propiedades de página utilizados en el wiki.",
+ "apihelp-query+pagepropnames-param-limit": "Número máximo de nombres que devolver.",
+ "apihelp-query+pagepropnames-example-simple": "Obtener los 10 primeros nombres de propiedades.",
+ "apihelp-query+pageprops-summary": "Obtener diferentes propiedades de página definidas en el contenido de la página.",
+ "apihelp-query+pageprops-param-prop": "Sólo listar estas propiedades de página (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> devuelve los nombres de las propiedades de página en uso). Útil para comprobar si las páginas usan una determinada propiedad de página.",
+ "apihelp-query+pageprops-example-simple": "Obtener las propiedades de las páginas <kbd>Main Page</kbd> y <kbd>MediaWiki</kbd>.",
+ "apihelp-query+pageswithprop-summary": "Mostrar todas las páginas que usen una propiedad de página.",
+ "apihelp-query+pageswithprop-param-propname": "Propiedad de página para la cual enumerar páginas (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> devuelve los nombres de las propiedades de página en uso).",
+ "apihelp-query+pageswithprop-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Añade el identificador de página.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Agrega el título y el identificador del espacio de nombres de la página.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Añade el valor de la propiedad de página.",
+ "apihelp-query+pageswithprop-param-limit": "El máximo número de páginas que se devolverán.",
+ "apihelp-query+pageswithprop-param-dir": "Dirección en la que se desea ordenar.",
+ "apihelp-query+pageswithprop-example-simple": "Listar las 10 primeras páginas que utilicen <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "Obtener información adicional acerca de las 10 primeras páginas que utilicen <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-summary": "Realice una búsqueda de prefijo de títulos de página.",
+ "apihelp-query+prefixsearch-extended-description": "A pesar de la similitud en los nombres, este módulo no pretende ser equivalente a [[Special:PrefixIndex]]; para eso, vea <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> con el parámetro <kbd> apprefix</kbd>. El propósito de este módulo es similar a <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>: para tomar la entrada del usuario y proporcionar los mejores títulos coincidentes. Dependiendo del motor de búsqueda backend, esto puede incluir la corrección de errores, redirigir la evasión, u otras heurísticas.",
+ "apihelp-query+prefixsearch-param-search": "Buscar cadena.",
+ "apihelp-query+prefixsearch-param-namespace": "Espacio de nombres que buscar.",
+ "apihelp-query+prefixsearch-param-limit": "Número máximo de resultados que devolver.",
+ "apihelp-query+prefixsearch-param-offset": "Número de resultados que omitir.",
+ "apihelp-query+prefixsearch-example-simple": "Buscar títulos de páginas que empiecen con <kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "Perfil de búsqueda que utilizar.",
+ "apihelp-query+protectedtitles-summary": "Mostrar todos los títulos protegidos contra creación.",
+ "apihelp-query+protectedtitles-param-namespace": "Listar solo los títulos en estos espacios de nombres.",
+ "apihelp-query+protectedtitles-param-level": "Listar solo títulos con estos niveles de protección.",
+ "apihelp-query+protectedtitles-param-limit": "Cuántas páginas se devolverán.",
+ "apihelp-query+protectedtitles-param-start": "Empezar la enumeración en esta marca de tiempo de protección.",
+ "apihelp-query+protectedtitles-param-end": "Terminar la enumeración en esta marca de tiempo de protección.",
+ "apihelp-query+protectedtitles-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "Añade la marca de tiempo de cuando se añadió la protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Agrega el usuario que agregó la protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Agrega el identificador de usuario que agregó la protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Añade el comentario de la protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "Añade el comentario analizado para la protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "Añade la fecha y hora de cuando se levantará la protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Agrega el nivel de protección.",
+ "apihelp-query+protectedtitles-example-simple": "Listar títulos protegidos.",
+ "apihelp-query+protectedtitles-example-generator": "Encuentra enlaces a títulos protegidos en el espacio de nombres principal.",
+ "apihelp-query+querypage-summary": "Obtenga una lista proporcionada por una página especial basada en QueryPage.",
+ "apihelp-query+querypage-param-page": "El nombre de la página especial. Recuerda, distingue mayúsculas y minúsculas.",
+ "apihelp-query+querypage-param-limit": "Número de resultados que se devolverán.",
+ "apihelp-query+querypage-example-ancientpages": "Devolver resultados de [[Special:Ancientpages]].",
+ "apihelp-query+random-summary": "Obtener un conjunto de páginas aleatorias.",
+ "apihelp-query+random-extended-description": "Las páginas aparecen enumeradas en una secuencia fija, solo que el punto de partida es aleatorio. Esto quiere decir que, si, por ejemplo, <samp>Portada</samp> es la primera página aleatoria de la lista, <samp>Lista de monos ficticios</samp> <em>siempre</em> será la segunda, <samp>Lista de personas en sellos de Vanuatu</samp> la tercera, etc.",
+ "apihelp-query+random-param-namespace": "Devolver solo las páginas de estos espacios de nombres.",
+ "apihelp-query+random-param-limit": "Limita el número de páginas aleatorias que se devolverán.",
+ "apihelp-query+random-param-redirect": "Usa <kbd>$1filterredir=redirects</kbd> en su lugar.",
+ "apihelp-query+random-param-filterredir": "Cómo filtrar las redirecciones.",
+ "apihelp-query+random-example-simple": "Devuelve dos páginas aleatorias del espacio de nombres principal.",
+ "apihelp-query+random-example-generator": "Devuelve la información de dos páginas aleatorias del espacio de nombres principal.",
+ "apihelp-query+recentchanges-summary": "Enumerar cambios recientes.",
+ "apihelp-query+recentchanges-param-start": "El sello de tiempo para comenzar la enumeración.",
+ "apihelp-query+recentchanges-param-end": "El sello de tiempo para finalizar la enumeración.",
+ "apihelp-query+recentchanges-param-namespace": "Filtrar cambios solamente a los espacios de nombres dados.",
+ "apihelp-query+recentchanges-param-user": "Listar solo los cambios de este usuario.",
+ "apihelp-query+recentchanges-param-excludeuser": "No listar cambios de este usuario.",
+ "apihelp-query+recentchanges-param-tag": "Listar solo los cambios con esta etiqueta.",
+ "apihelp-query+recentchanges-param-prop": "Incluir piezas adicionales de información:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "Añade el usuario responsable de la edición y añade una etiqueta si se trata de una IP.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "Añade el identificador del usuario responsable de la edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Añade el comentario de la edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "Añade el comentario analizado para la edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Añade marcas para la edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Añade la marca de tiempo de la edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Añade el título de página de la edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "Añade los códigos ID de la página, de los cambios recientes y de las revisiones antigua y nueva.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Añade la longitud antigua y la longitud nueva de la página en bytes.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "Etiqueta la edición si la página es una redirección.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Etiqueta ediciones verificables como verificadas o no verificadas.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Añade información de registro (identificador de registro, tipo de registro, etc.) a las entradas de registro.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Muestra las etiquetas de la entrada.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Añade la suma de comprobación de contenido para las entradas asociadas a una revisión.",
+ "apihelp-query+recentchanges-param-token": "Usa <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> en su lugar.",
+ "apihelp-query+recentchanges-param-show": "Muestra solo los elementos que cumplan estos criterios. Por ejemplo, para ver solo ediciones menores realizadas por usuarios conectados, introduce $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "Cuántos cambios en total se devolverán.",
+ "apihelp-query+recentchanges-param-type": "Cuántos tipos de cambios se mostrarán.",
+ "apihelp-query+recentchanges-param-toponly": "Enumerar solo las modificaciones que sean las últimas revisiones.",
+ "apihelp-query+recentchanges-param-generaterevisions": "Cuando se utilice como generador, genera identificadores de revisión en lugar de títulos. Las entradas en la lista de cambios recientes que no tengan identificador de revisión asociado (por ejemplo, la mayoría de las entradas de registro) no generarán nada.",
+ "apihelp-query+recentchanges-example-simple": "Lista de cambios recientes.",
+ "apihelp-query+recentchanges-example-generator": "Obtener información de página de cambios recientes no patrullados.",
+ "apihelp-query+redirects-summary": "Devuelve todas las redirecciones a las páginas dadas.",
+ "apihelp-query+redirects-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "Identificador de página de cada redirección.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Título de cada redirección.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "Fragmento de cada redirección, si los hubiere.",
+ "apihelp-query+redirects-param-namespace": "Incluir solo páginas de estos espacios de nombres.",
+ "apihelp-query+redirects-param-limit": "Cuántas redirecciones se devolverán.",
+ "apihelp-query+redirects-param-show": "Mostrar únicamente los elementos que cumplan con estos criterios:\n;fragment: mostrar solo redirecciones con fragmento.\n;!fragment: mostrar solo redirecciones sin fragmento.",
+ "apihelp-query+redirects-example-simple": "Mostrar una lista de las redirecciones a la [[Main Page|Portada]]",
+ "apihelp-query+redirects-example-generator": "Obtener información sobre todas las redirecciones a la [[Main Page|Portada]].",
+ "apihelp-query+revisions-summary": "Obtener información de la revisión.",
+ "apihelp-query+revisions-extended-description": "Puede ser utilizado de varias maneras:\n# Obtener datos sobre un conjunto de páginas (última revisión), estableciendo títulos o ID de paginas.\n# Obtener revisiones para una página determinada, usando títulos o ID de páginas con inicio, fin o límite.\n# Obtener datos sobre un conjunto de revisiones estableciendo sus ID con revids.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Solo se puede usar con una sola página (modo n.º 2).",
+ "apihelp-query+revisions-param-startid": "Identificador de revisión a partir del cual empezar la enumeración.",
+ "apihelp-query+revisions-param-endid": "Identificador de revisión en el que detener la enumeración.",
+ "apihelp-query+revisions-param-start": "Marca de tiempo a partir de la cual empezar la enumeración.",
+ "apihelp-query+revisions-param-end": "Enumerar hasta esta marca de tiempo.",
+ "apihelp-query+revisions-param-user": "Incluir solo las revisiones realizadas por el usuario.",
+ "apihelp-query+revisions-param-excludeuser": "Excluir las revisiones realizadas por el usuario.",
+ "apihelp-query+revisions-param-tag": "Mostrar solo revisiones marcadas con esta etiqueta.",
+ "apihelp-query+revisions-example-content": "Obtener datos con el contenido de la última revisión de los títulos <kbd>API</kbd> y <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "Mostrar las últimas 5 revisiones de la <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5": "Obtener las primeras 5 revisiones de <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Obtener las primeras 5 revisiones de <kbd>Main Page</kbd> realizadas después de 2006-05-01.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Obtener las primeras 5 revisiones de <kbd>Main Page</kbd> que no fueron realizadas por el usuario anónimo <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "Obtener las primeras 5 revisiones de <kbd>Main Page</kbd> que fueron realizadas por el usuario <kbd>MediaWiki default</kbd>.",
+ "apihelp-query+revisions+base-param-prop": "Las propiedades que se obtendrán para cada revisión:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "El identificador de la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Marcas de revisión (menor).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "La fecha y hora de la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Usuario que realizó la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "Identificador de usuario del creador de la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Longitud (en bytes) de la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1 (base 16) de la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "Identificador del modelo de contenido de la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "Comentario del usuario para la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "Comentario analizado del usuario para la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Texto de la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Etiquetas para la revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "El árbol de análisis sintáctico XML del contenido de la revisión (requiere el modelo de contenido <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "Limitar la cantidad de revisiones que se devolverán.",
+ "apihelp-query+revisions+base-param-expandtemplates": "Expandir las plantillas en el contenido de la revisión (requiere $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "Generar el árbol de análisis sintáctico XML para el contenido de la revisión (requiere $1prop=content; reemplazado por <kbd>$1prop=parsetree</kbd>).",
+ "apihelp-query+revisions+base-param-parse": "Analizar el contenido de la revisión (requiere $1prop=content). Por motivos de rendimiento, si se utiliza esta opción, el valor de $1limit es forzado a 1.",
+ "apihelp-query+revisions+base-param-section": "Recuperar solamente el contenido de este número de sección.",
+ "apihelp-query+revisions+base-param-contentformat": "Formato de serialización utilizado para <var>$1difftotext</var> y esperado para la salida de contenido.",
+ "apihelp-query+search-summary": "Realizar una búsqueda de texto completa.",
+ "apihelp-query+search-param-namespace": "Buscar solo en estos espacios de nombres.",
+ "apihelp-query+search-param-what": "Tipo de búsqueda que realizar.",
+ "apihelp-query+search-param-info": "Qué metadatos devolver.",
+ "apihelp-query+search-param-prop": "Qué propiedades se devolverán:",
+ "apihelp-query+search-paramvalue-prop-size": "Añade el tamaño de la página en bytes.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Añade el número de palabras de la página.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Añade la marca de tiempo de la última edición de la página.",
+ "apihelp-query+search-paramvalue-prop-snippet": "Añade un fragmento analizado de la página.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "Añade un fragmento analizado del título de la página.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "Añade un fragmento analizado del título de la redirección.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Añade el título de la redirección coincidente.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "Añade un fragmento analizado del título de la sección correspondiente.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Añade el título de la sección correspondiente.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "Añade un fragmento analizado de la categoría correspondiente.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "Añade un booleano que indica si la búsqueda corresponde al contenido del archivo.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignorado.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado",
+ "apihelp-query+search-param-limit": "Cuántas páginas en total se devolverán.",
+ "apihelp-query+search-param-interwiki": "Incluir resultados interwiki en la búsqueda, si es posible.",
+ "apihelp-query+search-param-backend": "Qué servidor de búsqueda utilizar, si no es el servidor por defecto.",
+ "apihelp-query+search-param-enablerewrites": "Habilitar la reescritura de consultas internas. Algunos servidores de búsqueda pueden reescribir la consulta a una que considere que da mejores resultados, por ejemplo, corrigiendo las faltas ortográficas.",
+ "apihelp-query+search-example-simple": "Buscar <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Buscar <kbd>meaning</kbd> en los textos.",
+ "apihelp-query+search-example-generator": "Obtener información acerca de las páginas devueltas por una búsqueda de <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "Devolver información general acerca de la página web.",
+ "apihelp-query+siteinfo-param-prop": "Qué información se obtendrá:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Información global del sistema.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "Lista de espacios de nombres registrados y sus nombres canónicos.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Lista de alias registrados de espacios de nombres",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Lista de alias de páginas especiales.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Lista de palabras mágicas y sus alias.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Devuelve las estadísticas del sitio.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "Devuelve el mapa interwiki (opcionalmente filtrado, opcionalmente localizado mediante el uso de <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "Devuelve el servidor de base de datos con el retraso de replicación más grande.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Devuelve los grupos de usuarios y los permisos asociados.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Devuelve las bibliotecas instaladas en el wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Devuelve las extensiones instaladas en el wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Devuelve una lista de extensiones de archivo permitidas para subirse.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Devuelve información de permisos (licencia) del wiki, si está disponible.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Devuelve información sobre tipos de restricciones (protección) disponible.",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "Devuelve una lista de los idiomas que admite MediaWiki (opcionalmente localizada mediante el uso de <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "Devuelve una lista de todos las apariencias habilitadas (opcionalmente localizada mediante el uso de <var>$1inlanguagecode</var>, de lo contrario en el idioma del contenido).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "Devuelve una lista de las etiquetas extensoras del analizador.",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Devuelve una lista de identificadores variables.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "Devuelve una lista de los protocolos que se permiten en los enlaces externos.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Devuelve los valores predeterminados de las preferencias del usuario.",
+ "apihelp-query+siteinfo-param-filteriw": "Devuelve solo entradas locales o solo entradas no locales del mapa interwiki.",
+ "apihelp-query+siteinfo-param-numberingroup": "Muestra el número de usuarios en los grupos de usuarios.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "Código de idioma para los nombres localizados de los idiomas (en el mejor intento posible) y apariencias.",
+ "apihelp-query+siteinfo-example-simple": "Obtener información del sitio.",
+ "apihelp-query+siteinfo-example-interwiki": "Obtener una lista de prefijos interwiki locales.",
+ "apihelp-query+stashimageinfo-summary": "Devuelve información del archivo para archivos escondidos.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Alias de $1filekey, para retrocompatibilidad.",
+ "apihelp-query+stashimageinfo-example-simple": "Devuelve información para un archivo escondido.",
+ "apihelp-query+stashimageinfo-example-params": "Devuelve las miniaturas de dos archivos escondidos.",
+ "apihelp-query+tags-summary": "Enumerar las etiquetas de modificación.",
+ "apihelp-query+tags-param-limit": "El número máximo de etiquetas para enumerar.",
+ "apihelp-query+tags-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+tags-paramvalue-prop-name": "Añade el nombre de la etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Agrega el mensaje de sistema para la etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-description": "Añade la descripción de la etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Añade el número de revisiones y entradas de registro que tienen esta etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Indicar si la etiqueta está definida.",
+ "apihelp-query+tags-paramvalue-prop-source": "Obtiene las fuentes de la etiqueta, que pueden incluir <samp>extension</samp> para etiquetas definidas por extensiones y <samp>manual</samp> para etiquetas que pueden aplicarse manualmente por los usuarios.",
+ "apihelp-query+tags-paramvalue-prop-active": "Si la etiqueta aún se sigue aplicando.",
+ "apihelp-query+tags-example-simple": "Enumera las etiquetas disponibles.",
+ "apihelp-query+templates-summary": "Devuelve todas las páginas transcluidas en las páginas dadas.",
+ "apihelp-query+templates-param-namespace": "Mostrar plantillas solamente en estos espacios de nombres.",
+ "apihelp-query+templates-param-limit": "Cuántas plantillas se devolverán.",
+ "apihelp-query+templates-param-templates": "Mostrar solo estas plantillas. Útil para comprobar si una determinada página utiliza una determinada plantilla.",
+ "apihelp-query+templates-param-dir": "La dirección en que ordenar la lista.",
+ "apihelp-query+templates-example-simple": "Obtener las plantillas que se usan en la página <kbd>Portada</kbd>.",
+ "apihelp-query+templates-example-generator": "Obtener información sobre las páginas de las plantillas utilizadas en <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-namespaces": "Obtener las páginas de los espacios de nombres {{ns:user}} y {{ns:template}} que están transcluidas en la página <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-summary": "Encuentra todas las páginas que transcluyan las páginas dadas.",
+ "apihelp-query+transcludedin-param-prop": "Qué propiedades se obtendrán:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "Identificador de cada página.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Título de cada página.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Marcar si la página es una redirección.",
+ "apihelp-query+transcludedin-param-namespace": "Incluir solo las páginas en estos espacios de nombres.",
+ "apihelp-query+transcludedin-param-limit": "Cuántos se devolverán.",
+ "apihelp-query+transcludedin-param-show": "Muestra solo los elementos que cumplen estos criterios:\n;redirect: Muestra solamente redirecciones.\n;!redirect: Muestra solamente páginas que no son redirecciones.",
+ "apihelp-query+transcludedin-example-simple": "Obtener una lista de páginas transcluyendo <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Obtener información sobre las páginas que transcluyen <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-summary": "Obtener todas las ediciones realizadas por un usuario.",
+ "apihelp-query+usercontribs-param-limit": "Número máximo de contribuciones que se devolverán.",
+ "apihelp-query+usercontribs-param-user": "Los usuarios para los cuales se desea recuperar las contribuciones. No se puede utilizar junto con <var>$1userids</var> o <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "Recuperar las contribuciones de todos los usuarios cuyos nombres comienzan con este valor. No se puede utilizar junto con <var>$1user</var> o <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "Los identificadores de los usuarios para los cuales se desea recuperar las contribuciones. No se puede utilizar junto con <var>$1userids</var> o <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "Enumerar solo las contribuciones en estos espacios de nombres.",
+ "apihelp-query+usercontribs-param-prop": "Incluir piezas adicionales de información:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Añade el identificador de página y el de revisión.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Agrega el título y el identificador del espacio de nombres de la página.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Añade fecha y hora de la edición.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Añade el comentario de la edición.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Añade el comentario analizado de la edición.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Añade el nuevo tamaño de la edición.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Añade la diferencia de tamaño de la edición respecto de su progenitora.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Añade las marcas de la edición.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Etiqueta ediciones verificadas.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Lista las etiquetas para la edición.",
+ "apihelp-query+usercontribs-param-show": "Mostrar solo los elementos que coinciden con estos criterios. Por ejemplo, solo ediciones no menores: <kbd>$2show=!minor</kbd>.\n\nSi se establece <kbd>$2show=patrolled</kbd> o <kbd>$2show=!patrolled</kbd>, no se mostrarán las revisiones con una antigüedad mayor que <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var> ($1 {{PLURAL:$1|segundo|segundos}}).",
+ "apihelp-query+usercontribs-param-tag": "Enumerar solo las revisiones con esta etiqueta.",
+ "apihelp-query+usercontribs-param-toponly": "Enumerar solo las modificaciones que sean las últimas revisiones.",
+ "apihelp-query+usercontribs-example-user": "Mostrar contribuciones del usuario <kbd>Example</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "Mostrar las contribuciones de todas las direcciones IP con el prefijo <kbd>192.0.2.</kbd>.",
+ "apihelp-query+userinfo-summary": "Obtener información sobre el usuario actual.",
+ "apihelp-query+userinfo-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Etiqueta si el usuario está bloqueado, por quién y por qué motivo.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Añade una etiqueta <samp>messages</samp> si el usuario actual tiene mensajes pendientes.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Lista todos los grupos al que pertenece el usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Enumera los grupos a los que se ha asignado explícitamente al usuario actual, incluida la fecha de expiración de la pertenencia a cada grupo.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Enumera todos los grupos a los que pertenece automáticamente el usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Lista todos los permisos que tiene el usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Enumera los grupos a los que el usuario actual se puede unir o retirar.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Lista todas las preferencias que haya establecido el usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Añade el número de ediciones del usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Lista todos los límites de velocidad aplicados al usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Añade el nombre real del usuario.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Añade la dirección de correo electrónico del usuario y la fecha de autenticación por correo.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Reenvía la cabecera <code>Accept-Language</code> enviada por el cliente en un formato estructurado.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Añade la fecha de registro del usuario.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Añade el recuento de páginas no leídas de la lista de seguimiento del usuario (máximo $1, devuelve <samp>$2</samp> si el número es mayor).",
+ "apihelp-query+userinfo-example-simple": "Obtener información sobre el usuario actual.",
+ "apihelp-query+userinfo-example-data": "Obtener información adicional sobre el usuario actual.",
+ "apihelp-query+users-summary": "Obtener información sobre una lista de usuarios.",
+ "apihelp-query+users-param-prop": "Qué piezas de información incluir:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Etiqueta si el usuario está bloqueado, por quién y por qué razón.",
+ "apihelp-query+users-paramvalue-prop-groups": "Lista todos los grupos a los que pertenece cada usuario.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Enumera todos los grupos a los que pertenece automáticamente un usuario.",
+ "apihelp-query+users-paramvalue-prop-rights": "Enumera todos los permisos que tiene cada usuario.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Añade el número de ediciones del usuario.",
+ "apihelp-query+users-paramvalue-prop-registration": "Añade la marca de tiempo del registro del usuario.",
+ "apihelp-query+users-paramvalue-prop-emailable": "Marca si el usuario puede y quiere recibir correo electrónico a través de [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "Etiqueta el género del usuario. Devuelve \"masculino\", \"femenino\" o \"desconocido\".",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Indica si se puede crear una cuenta para nombres de usuario válidos pero no registrados.",
+ "apihelp-query+users-param-users": "Una lista de usuarios de los que obtener información.",
+ "apihelp-query+users-param-userids": "Una lista de identificadores de usuarios de los que obtener información.",
+ "apihelp-query+users-param-token": "Usa <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> en su lugar.",
+ "apihelp-query+users-example-simple": "Devolver información del usuario <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-summary": "Obtener los cambios recientes de las páginas de la lista de seguimiento del usuario actual.",
+ "apihelp-query+watchlist-param-start": "El sello de tiempo para comenzar la enumeración",
+ "apihelp-query+watchlist-param-end": "El sello de tiempo para finalizar la enumeración.",
+ "apihelp-query+watchlist-param-namespace": "Filtrar cambios solamente a los espacios de nombres dados.",
+ "apihelp-query+watchlist-param-user": "Mostrar solamente los cambios de este usuario.",
+ "apihelp-query+watchlist-param-excludeuser": "No listar cambios de este usuario.",
+ "apihelp-query+watchlist-param-limit": "Número de resultados que devolver en cada petición.",
+ "apihelp-query+watchlist-param-prop": "Qué propiedades adicionales se obtendrán:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Añade identificadores de revisiones y de páginas.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Añade el título de la página.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Añade marcas para la edición.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Añade el usuario que hizo la edición.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Añade el identificador de usuario de quien hizo la edición.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Añade el comentario de la edición.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Añade el comentario analizado de la edición.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Añade fecha y hora de la edición.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Etiqueta las ediciones que están verificadas.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Añade la longitud vieja y la nueva de la página.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Añade fecha y hora de cuando el usuario fue notificado por última vez acerca de la edición.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Añade información del registro cuando corresponda.",
+ "apihelp-query+watchlist-param-show": "Muestra solo los elementos que cumplan estos criterios. Por ejemplo, para ver solo ediciones menores realizadas por usuarios conectados, introduce $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "Qué tipos de cambios mostrar:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Ediciones comunes a páginas",
+ "apihelp-query+watchlist-paramvalue-type-external": "Cambios externos.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Creaciones de páginas.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Entradas del registro.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Cambios de pertenencia a categorías.",
+ "apihelp-query+watchlist-param-owner": "Utilizado junto con $1token para acceder a la lista de seguimiento de otro usuario.",
+ "apihelp-query+watchlist-example-simple": "Enumera la última revisión de las páginas con cambios recientes de la lista de seguimiento del usuario actual.",
+ "apihelp-query+watchlist-example-props": "Obtener información adicional sobre la última revisión de páginas con cambios recientes en la lista de seguimiento del usuario actual.",
+ "apihelp-query+watchlist-example-allrev": "Obtener información sobre todos los cambios recientes de páginas de la lista de seguimiento del usuario actual.",
+ "apihelp-query+watchlist-example-generator": "Obtener información de página de las páginas con cambios recientes de la lista de seguimiento del usuario actual.",
+ "apihelp-query+watchlist-example-generator-rev": "Obtener información de revisión de los cambios recientes de páginas de la lista de seguimiento del usuario actual.",
+ "apihelp-query+watchlist-example-wlowner": "Enumerar la última revisión de páginas con cambios recientes de la lista de seguimiento del usuario <kbd>Example</kbd>.",
+ "apihelp-query+watchlistraw-summary": "Obtener todas las páginas de la lista de seguimiento del usuario actual.",
+ "apihelp-query+watchlistraw-param-namespace": "Mostrar solamente las páginas de los espacios de nombres dados.",
+ "apihelp-query+watchlistraw-param-limit": "Número de resultados que devolver en cada petición.",
+ "apihelp-query+watchlistraw-param-prop": "Qué propiedades adicionales se obtendrán:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Añade la marca de tiempo de la última notificación al usuario sobre la edición.",
+ "apihelp-query+watchlistraw-param-show": "Mostrar solo los elementos que cumplen con estos criterios.",
+ "apihelp-query+watchlistraw-param-owner": "Utilizado junto con $1token para acceder a la lista de seguimiento de otro usuario.",
+ "apihelp-query+watchlistraw-param-dir": "La dirección en la que se listará.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Título (con el prefijo de espacio de nombres) desde el que se empezará a enumerar.",
+ "apihelp-query+watchlistraw-param-totitle": "Título (con el prefijo de espacio de nombres) desde el que se dejará de enumerar.",
+ "apihelp-query+watchlistraw-example-simple": "Listar las páginas de la lista de seguimiento del usuario actual.",
+ "apihelp-query+watchlistraw-example-generator": "Obtener información de las páginas de la lista de seguimiento del usuario actual.",
+ "apihelp-removeauthenticationdata-summary": "Elimina los datos de autentificación del usuario actual.",
+ "apihelp-removeauthenticationdata-example-simple": "Trata de eliminar los datos del usuario actual para <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "Enviar un email de reinicialización de la contraseña a un usuario.",
+ "apihelp-resetpassword-param-user": "Usuario en proceso de reinicialización",
+ "apihelp-resetpassword-param-email": "Dirección de correo electrónico del usuario que se va a reinicializar",
+ "apihelp-resetpassword-example-user": "Enviar un correo de recuperación de contraseña al usuario <kbd>Ejemplo</kbd>.",
+ "apihelp-resetpassword-example-email": "Enviar un correo de recuperación de contraseña para todos los usuarios con dirección de correo electrónico <kbd>usuario@ejemplo.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Eliminar y restaurar revisiones",
+ "apihelp-revisiondelete-param-target": "Título de la página para el borrado de la revisión, en caso de ser necesario para ese tipo.",
+ "apihelp-revisiondelete-param-ids": "Identificadores de las revisiones para borrar.",
+ "apihelp-revisiondelete-param-hide": "Qué ocultar en cada revisión.",
+ "apihelp-revisiondelete-param-show": "Qué mostrar en cada revisión.",
+ "apihelp-revisiondelete-param-reason": "Motivo de la eliminación o restauración.",
+ "apihelp-revisiondelete-param-tags": "Etiquetas que aplicar a la entrada en el registro de borrados.",
+ "apihelp-revisiondelete-example-revision": "Ocultar el contenido de la revisión <kbd>12345</kbd> de la página <kbd>Main Page</kbd>.",
+ "apihelp-revisiondelete-example-log": "Ocultar todos los datos de la entrada de registro <kbd>67890</kbd> con el motivo <kbd>BLP violation</kbd>.",
+ "apihelp-rollback-summary": "Deshacer la última edición de la página.",
+ "apihelp-rollback-extended-description": "Si el último usuario que editó la página hizo varias ediciones consecutivas, todas ellas serán revertidas.",
+ "apihelp-rollback-param-title": "Título de la página que revertir. No se puede usar junto con <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "Identificador de la página que revertir. No se puede usar junto con <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "Etiquetas que aplicar a la reversión.",
+ "apihelp-rollback-param-user": "Nombre del usuario cuyas ediciones se van a revertir.",
+ "apihelp-rollback-param-summary": "Resumen de edición personalizado. Si se deja vacío se utilizará el predeterminado.",
+ "apihelp-rollback-param-markbot": "Marcar las acciones revertidas y la reversión como ediciones por bots.",
+ "apihelp-rollback-param-watchlist": "Añadir o borrar incondicionalmente la página de la lista de seguimiento del usuario actual, usar preferencias o no cambiar seguimiento.",
+ "apihelp-rollback-example-simple": "Revertir las últimas ediciones de la página <kbd>Main Page</kbd> por el usuario <kbd>Example</kbd>.",
+ "apihelp-rollback-example-summary": "Revertir las últimas ediciones de la página <kbd>Main Page</kbd> por el usuario de IP <kbd>192.0.2.5</kbd> con resumen <kbd>Reverting vandalism</kbd>, y marcar esas ediciones y la reversión como ediciones realizadas por bots.",
+ "apihelp-rsd-summary": "Exportar un esquema RSD (Really Simple Discovery; Descubrimiento Muy Simple).",
+ "apihelp-rsd-example-simple": "Exportar el esquema RSD.",
+ "apihelp-setnotificationtimestamp-summary": "Actualizar la marca de tiempo de notificación de las páginas en la lista de seguimiento.",
+ "apihelp-setnotificationtimestamp-extended-description": "Esto afecta a la función de resaltado de las páginas modificadas en la lista de seguimiento y al envío de correo electrónico cuando la preferencia \"{{int:tog-enotifwatchlistpages}}\" está habilitada.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Trabajar en todas las páginas en seguimiento.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "Marca de tiempo en la que fijar la marca de tiempo de notificación.",
+ "apihelp-setnotificationtimestamp-param-torevid": "Revisión a la que fijar la marca de tiempo de notificación (una sola página).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "Revisión a la que fijar la marca de tiempo de notificación más reciente (una sola página).",
+ "apihelp-setnotificationtimestamp-example-all": "Restablecer el estado de notificación para la totalidad de la lista de seguimiento.",
+ "apihelp-setnotificationtimestamp-example-page": "Restablecer el estado de notificación de <kbd>Main page</kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "Fijar la marca de tiempo de notificación de <kbd>Main page</kbd> para que todas las ediciones posteriores al 1 de enero de 2012 estén consideradas como no vistas.",
+ "apihelp-setnotificationtimestamp-example-allpages": "Restablecer el estado de notificación de las páginas del espacio de nombres <kbd>{{ns:user}}</kbd>.",
+ "apihelp-setpagelanguage-summary": "Cambiar el idioma de una página.",
+ "apihelp-setpagelanguage-extended-description-disabled": "En este wiki no se permite modificar el idioma de las páginas.\n\nActiva <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> para utilizar esta acción.",
+ "apihelp-setpagelanguage-param-title": "Título de la página cuyo idioma deseas cambiar. No se puede usar junto con <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "Identificador de la página cuyo idioma deseas cambiar. No se puede usar junto con <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "Código del idioma al que se desea cambiar la página. Usa <kbd>default</kbd> para restablecer la página al idioma predeterminado para el contenido del wiki.",
+ "apihelp-setpagelanguage-param-reason": "Motivo del cambio.",
+ "apihelp-setpagelanguage-param-tags": "Cambiar las etiquetas que aplicar a la entrada de registro resultante de esta acción.",
+ "apihelp-setpagelanguage-example-language": "Cambiar el idioma de <kbd>Main Page</kbd> al euskera.",
+ "apihelp-setpagelanguage-example-default": "Cambiar el idioma de la página con identificador 123 al idioma predeterminado para el contenido del wiki.",
+ "apihelp-stashedit-param-title": "Título de la página que se está editando.",
+ "apihelp-stashedit-param-section": "Número de la sección. <kbd>0</kbd> para una sección superior, <kbd>new</kbd> para una sección nueva.",
+ "apihelp-stashedit-param-sectiontitle": "El título de una sección nueva.",
+ "apihelp-stashedit-param-text": "Contenido de la página.",
+ "apihelp-stashedit-param-contentmodel": "Modelo del contenido nuevo.",
+ "apihelp-stashedit-param-contentformat": "Formato de serialización de contenido utilizado para el texto de entrada.",
+ "apihelp-stashedit-param-baserevid": "Identificador de la revisión de base.",
+ "apihelp-stashedit-param-summary": "Resumen de cambios.",
+ "apihelp-tag-summary": "Añadir o borrar etiquetas de modificación de revisiones individuales o entradas de registro.",
+ "apihelp-tag-param-rcid": "Uno o más identificadores de cambios recientes a los que añadir o borrar la etiqueta.",
+ "apihelp-tag-param-revid": "Uno o más identificadores de revisión a los que añadir o borrar la etiqueta.",
+ "apihelp-tag-param-logid": "Uno o más identificadores de entradas del registro a los que agregar o eliminar la etiqueta.",
+ "apihelp-tag-param-add": "Etiquetas que añadir. Solo se pueden añadir etiquetas definidas manualmente.",
+ "apihelp-tag-param-remove": "Etiquetas que borrar. Solo se pueden borrar etiquetas definidas manualmente o completamente indefinidas.",
+ "apihelp-tag-param-reason": "Motivo del cambio.",
+ "apihelp-tag-param-tags": "Etiquetas que aplicar a la entrada de registro que se generará como resultado de esta acción.",
+ "apihelp-tag-example-rev": "Añadir la etiqueta <kbd>vandalism</kbd> al identificador de revisión 123 sin especificar un motivo",
+ "apihelp-tag-example-log": "Eliminar la etiqueta <kbd>spam</kbd> de la entrada del registro con identificador 123 con el motivo <kbd>Wrongly applied</kbd>",
+ "apihelp-unblock-summary": "Desbloquear un usuario.",
+ "apihelp-unblock-param-id": "Identificador del bloqueo que se desea desbloquear (obtenido mediante <kbd>list=blocks</kbd>). No se puede usar junto con with <var>$1user</var> o <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "Nombre de usuario, dirección IP o intervalo de direcciones IP para desbloquear. No se puede utilizar junto con <var>$1id</var> o <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "ID de usuario que desbloquear. No se puede utilizar junto con <var>$1id</var> o <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "Motivo del desbloqueo.",
+ "apihelp-unblock-param-tags": "Cambiar las etiquetas que aplicar a la entrada en el registro de bloqueos.",
+ "apihelp-unblock-example-id": "Desbloquear el bloqueo de ID #<kbd>105</kbd>",
+ "apihelp-unblock-example-user": "Desbloquear al usuario <kbd>Bob</kbd> con el motivo <kbd>Sorry Bob</kbd>",
+ "apihelp-undelete-param-title": "Título de la página que restaurar.",
+ "apihelp-undelete-param-reason": "Motivo de la restauración.",
+ "apihelp-undelete-param-tags": "Cambiar las etiquetas para aplicar a la entrada en el registro de borrados.",
+ "apihelp-undelete-param-timestamps": "Marcas de tiempo de las revisiones que se desea restaurar. Si tanto <var>$1timestamps</var> como <var>$1fileids</var> están vacíos, se restaurarán todas.",
+ "apihelp-undelete-param-fileids": "Identificadores de las revisiones que se desea restaurar. Si tanto <var>$1timestamps</var> como <var>$1fileids</var> están vacíos, se restaurarán todas.",
+ "apihelp-undelete-example-page": "Restaurar la página <kbd>Main page</kbd>.",
+ "apihelp-undelete-example-revisions": "Restaurar dos revisiones de la página <kbd>Main Page</kbd>.",
+ "apihelp-upload-param-filename": "Nombre del archivo de destino.",
+ "apihelp-upload-param-tags": "Cambiar etiquetas para aplicar a la entrada del registro de subidas y a la revisión de página de archivo.",
+ "apihelp-upload-param-text": "Texto de página inicial para archivos nuevos.",
+ "apihelp-upload-param-watch": "Vigilar la página.",
+ "apihelp-upload-param-watchlist": "Añadir o borrar incondicionalmente la página de la lista de seguimiento del usuario actual, utilizar las preferencias o no cambiar el estado de seguimiento.",
+ "apihelp-upload-param-ignorewarnings": "Ignorar las advertencias.",
+ "apihelp-upload-param-file": "Contenido del archivo.",
+ "apihelp-upload-param-url": "URL de la que obtener el archivo.",
+ "apihelp-upload-param-sessionkey": "Idéntico a $1filekey, mantenido por razones de retrocompatibilidad.",
+ "apihelp-upload-param-filesize": "Tamaño de archivo total de la carga.",
+ "apihelp-upload-param-offset": "Posición del fragmento en bytes.",
+ "apihelp-upload-param-chunk": "Contenido del fragmento.",
+ "apihelp-upload-param-async": "Realizar de forma asíncrona las operaciones de archivo potencialmente grandes cuando sea posible.",
+ "apihelp-upload-example-url": "Subir desde una URL.",
+ "apihelp-upload-example-filekey": "Completar una subida que falló debido a advertencias.",
+ "apihelp-userrights-summary": "Cambiar la pertenencia a grupos de un usuario.",
+ "apihelp-userrights-param-user": "Nombre de usuario.",
+ "apihelp-userrights-param-userid": "ID de usuario.",
+ "apihelp-userrights-param-add": "Agregar el usuario a estos grupos, o, si ya es miembro, actualizar la fecha de expiración de su pertenencia a ese grupo.",
+ "apihelp-userrights-param-expiry": "Marcas de tiempo de expiración. Pueden ser relativas (por ejemplo, <kbd>5 months</kbd> o <kbd>2 weeks</kbd>) o absolutas (por ejemplo, <kbd>2014-09-18T12:34:56Z</kbd>). Si sólo se fija una marca de tiempo, se utilizará para todos los grupos que se pasen al parámetro <var>$1añadir</var>. Usa <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, o <kbd>never</kbd> para que la pertenencia al grupo no tenga fecha de expiración.",
+ "apihelp-userrights-param-remove": "Eliminar el usuario de estos grupos.",
+ "apihelp-userrights-param-reason": "Motivo del cambio.",
+ "apihelp-userrights-param-tags": "Cambia las etiquetas que aplicar a la entrada del registro de derechos del usuario.",
+ "apihelp-userrights-example-user": "Agregar al usuario <kbd>FooBot</kbd> al grupo <kbd>bot</kbd> y eliminarlo de los grupos <kbd>sysop</kbd> y <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Añade el usuario con identificador <kbd>123</kbd> al grupo <kbd>bot</kbd>, y lo borra de los grupos <kbd>sysop</kbd> y <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-expiry": "Añadir al usuario <kbd>SometimeSysop</kbd> al grupo <kbd>sysop</kbd> por 1 mes.",
+ "apihelp-validatepassword-summary": "Valida una contraseña contra las políticas de contraseñas del wiki.",
+ "apihelp-validatepassword-extended-description": "La validez es <samp>Good</samp> si la contraseña es aceptable, <samp>Change</samp> y la contraseña se puede usar para iniciar sesión pero debe cambiarse o <samp>Invalid</samp> si la contraseña no se puede usar.",
+ "apihelp-validatepassword-param-password": "Contraseña para validar.",
+ "apihelp-validatepassword-param-user": "Nombre de usuario, para pruebas de creación de cuentas. El usuario nombrado no debe existir.",
+ "apihelp-validatepassword-param-email": "Dirección de correo electrónico, para pruebas de creación de cuentas.",
+ "apihelp-validatepassword-param-realname": "Nombre real, para pruebas de creación de cuentas.",
+ "apihelp-validatepassword-example-1": "Validar la contraseña <kbd>foobar</kbd> para el usuario actual.",
+ "apihelp-validatepassword-example-2": "Validar la contraseña <kbd>qwerty</kbd> para la creación del usuario <kbd>Example</kbd>.",
+ "apihelp-watch-summary": "Añadir o borrar páginas de la lista de seguimiento del usuario actual.",
+ "apihelp-watch-param-title": "La página que seguir o dejar de seguir. Usa <var>$1titles</var> en su lugar.",
+ "apihelp-watch-param-unwatch": "Si se define, en vez de seguir la página, se dejará de seguir.",
+ "apihelp-watch-example-watch": "Vigilar la página <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Dejar de vigilar la <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-generator": "Seguir las primeras páginas del espacio de nombres principal.",
+ "apihelp-format-example-generic": "Devolver el resultado de la consulta en formato $1.",
+ "apihelp-format-param-wrappedhtml": "Devolver el HTML con resaltado sintáctico y los módulos ResourceLoader asociados en forma de objeto JSON.",
+ "apihelp-json-summary": "Extraer los datos de salida en formato JSON.",
+ "apihelp-json-param-callback": "Si se especifica, envuelve la salida dentro de una llamada a una función dada. Por motivos de seguridad, cualquier dato específico del usuario estará restringido.",
+ "apihelp-json-param-utf8": "Si se especifica, codifica la mayoría (pero no todos) de los caracteres no pertenecientes a ASCII como UTF-8 en lugar de reemplazarlos por secuencias de escape hexadecimal. Toma el comportamiento por defecto si <var>formatversion</var> no es <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "Si se especifica, codifica todos los caracteres no pertenecientes a ASCII mediante secuencias de escape hexadecimal. Toma el comportamiento por defecto si <var>formatversion</var> no es <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Formato de salida:\n;1: Formato retrocompatible (booleanos con estilo XML, claves <samp>*</samp> para nodos de contenido, etc.).\n;2: Formato moderno experimental. ¡Atención, las especificaciones pueden cambiar!\n;latest: Utiliza el último formato (actualmente <kbd>2</kbd>). Puede cambiar sin aviso.",
+ "apihelp-jsonfm-summary": "Producir los datos de salida en formato JSON (con resaltado sintáctico en HTML).",
+ "apihelp-none-summary": "No extraer nada.",
+ "apihelp-php-summary": "Extraer los datos de salida en formato serializado PHP.",
+ "apihelp-php-param-formatversion": "Formato de salida:\n;1: Formato retrocompatible (booleanos con estilo XML, claves <samp>*</samp> para nodos de contenido, etc.).\n;2: Formato moderno experimental. ¡Atención, las especificaciones pueden cambiar!\n;latest: Utilizar el último formato (actualmente <kbd>2</kbd>). Puede cambiar sin aviso.",
+ "apihelp-phpfm-summary": "Producir los datos de salida en formato PHP serializado (con resaltado sintáctico en HTML).",
+ "apihelp-rawfm-summary": "Extraer los datos de salida, incluidos los elementos de depuración, en formato JSON (embellecido en HTML).",
+ "apihelp-xml-summary": "Producir los datos de salida en formato XML.",
+ "apihelp-xml-param-xslt": "Si se especifica, añade la página nombrada como una hoja de estilo XSL. El valor debe ser un título en el espacio de nombres {{ns:MediaWiki}} que termine en <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Si se especifica, añade un espacio de nombres XML.",
+ "apihelp-xmlfm-summary": "Producir los datos de salida en formato XML (con resaltado sintáctico en HTML).",
+ "api-format-title": "Resultado de la API de MediaWiki",
+ "api-format-prettyprint-header": "Esta es la representación en HTML del formato $1. HTML es adecuado para realizar tareas de depuración, pero no para utilizarlo en aplicaciones.\n\nUtiliza el parámetro <var>format</var> para modificar el formato de salida. Para ver la representación no HTML del formato $1, emplea <kbd>format=$2</kbd>.\n\nPara obtener más información, consulta la [[mw:Special:MyLanguage/API|documentación completa]] o la [[Special:ApiHelp/main|ayuda de API]].",
+ "api-format-prettyprint-header-only-html": "Esta es una representación en HTML destinada a la depuración, y no es adecuada para el uso de la aplicación.\n\nVéase la [[mw:Special:MyLanguage/API|documentación completa]] o la [[Special:ApiHelp/main|página de ayuda de la API]] para más información.",
+ "api-format-prettyprint-status": "Esta respuesta se devolvería con el estado HTTP $1 $2.",
+ "api-login-fail-badsessionprovider": "No se puede acceder mientras esté utilizándose $1.",
+ "api-pageset-param-titles": "Una lista de títulos en los que trabajar.",
+ "api-pageset-param-pageids": "Una lista de identificadores de páginas en las que trabajar.",
+ "api-pageset-param-revids": "Una lista de identificadores de revisiones en las que trabajar.",
+ "api-pageset-param-generator": "Obtener la lista de páginas en las que trabajar mediante la ejecución del módulo de consulta especificado.\n\n<strong>Nota:</strong> Los nombres de los parámetros del generador deben prefijarse con una «g», véanse los ejemplos.",
+ "api-pageset-param-redirects-generator": "Resolver automáticamente las redirecciones en <var>$1titles</var>, <var>$1pageids</var>, y <var>$1revids</var> y en las páginas devueltas por <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Resolver automáticamente las redirecciones en <var>$1titles</var>, <var>$1pageids</var> y <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Convertir los títulos a otras variantes, si es necesario. Solo funciona si el idioma del contenido del wiki admite la conversión entre variantes. La conversión entre variantes está habilitada en idiomas tales como $1.",
+ "api-help-title": "Ayuda de la API de MediaWiki",
+ "api-help-lead": "Esta es una página de documentación autogenerada de la API de MediaWiki.\n\nDocumentación y ejemplos: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Módulo principal",
+ "api-help-flag-deprecated": "Este módulo está en desuso.",
+ "api-help-flag-internal": "<strong>Este módulo es interno o inestable.</strong> Su funcionamiento puede cambiar sin previo aviso.",
+ "api-help-flag-readrights": "Este módulo requiere permisos de lectura.",
+ "api-help-flag-writerights": "Este módulo requiere permisos de escritura.",
+ "api-help-flag-mustbeposted": "Este módulo solo acepta solicitudes POST.",
+ "api-help-flag-generator": "Este módulo puede utilizarse como un generador.",
+ "api-help-source": "Fuente: $1",
+ "api-help-source-unknown": "Fuente: <span class=\"apihelp-unknown\">desconocida</span>",
+ "api-help-license": "Licencia: [[$1|$2]]",
+ "api-help-license-noname": "Licencia: [[$1|Ver enlace]]",
+ "api-help-license-unknown": "Licencia: <span class=\"apihelp-unknown\">desconocida</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parámetro|Parámetros}}:",
+ "api-help-param-deprecated": "En desuso.",
+ "api-help-param-required": "Este parámetro es obligatorio.",
+ "api-help-datatypes-header": "Tipos de datos",
+ "api-help-datatypes": "Las entradas en MediaWiki deberían estar en UTF-8 según la norma NFC. MediaWiki puede tratar de convertir otros formatos, pero esto puede provocar errores en algunas operaciones (tales como las [[Special:ApiHelp/edit|ediciones]] con controles MD5).\n\nAlgunos tipos de parámetros en las solicitudes de API requieren de una explicación más detallada:\n;boolean\n:Los parámetros booleanos trabajo como cajas de verificación de HTML: si el parámetro está definido, independientemente de su valor, se considera verdadero. Para un valor falso, se debe omitir el parámetro por completo.\n;marca de tiempo\n:Las marcas de tiempo se pueden definir en varios formatos. Se recomienda seguir la norma ISO 8601 de fecha y hora. Todas las horas están en UTC, ignorándose cualquier indicación de zona horaria.\n:* Fecha y hora en ISO 8601, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (los signos de puntuación y la <kbd>Z</kbd> son opcionales)\n:* Fecha y hora en ISO 8601 con fracciones de segundo (que se omiten), <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (los guiones, los dos puntos y la <kbd>Z</kbd> son opcionales)\n:* Formato MediaWiki, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Formato genérico de número, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (la zona horaria opcional, sea <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd> o <kbd>-<var>##</var></kbd> se omite)\n:* Formato EXIF, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:*Formato RFC 2822 (la zona horaria es opcional), <kbd><var>lun</var>, <var>15</var> <var>ene</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formato RFC 850 (la zona horaria es opcional), <kbd><var>lunes</var>, <var>15</var>-<var>ene</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formato ctime de C, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Número de segundos desde 1970-01-01T00:00:00Z en forma de número entero de entre 1 y 13 cifras (sin <kbd>0</kbd>)\n:* La cadena <kbd>now</kbd>\n\n;separador alternativo de valores múltiples\n:Los parámetros que toman valores múltiples se envían normalmente utilizando la barra vertical para separar los valores, p. ej., <kbd>param=valor1|valor2</kbd> o <kbd>param=valor1%7Cvalor2</kbd>. Si un valor tiene que contener el carácter de barra vertical, utiliza U+001F (separador de unidades) como separador ''y'' prefija el valor con, p. ej. <kbd>param=%1Fvalor1%1Fvalor2</kbd>.",
+ "api-help-param-type-limit": "Tipo: entero o <kbd>max</kbd>",
+ "api-help-param-type-integer": "Tipo: {{PLURAL:$1|1=entero|2=lista de enteros}}",
+ "api-help-param-type-boolean": "Tipo: booleano/lógico ([[Special:ApiHelp/main#main/datatypes|detalles]])",
+ "api-help-param-type-timestamp": "Tipo: {{PLURAL:$1|1=timestamp|2=lista de timestamps}} ([[Special:ApiHelp/main#main/datatypes|formatos permitidos]])",
+ "api-help-param-type-user": "Tipo: {{PLURAL:$1|1=nombre de usuario|2=lista de nombres de usuarios}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Uno de los siguientes valores|2=Valores (separados por <kbd>{{!}}</kbd> u [[Special:ApiHelp/main#main/datatypes|otro separador]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Debe estar vacío|Puede estar vacío, o $2}}",
+ "api-help-param-limit": "No se permite más de $1.",
+ "api-help-param-limit2": "No se permite más de $1 ($2 para los bots).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=El valor no debe ser menor|2=Los valores no deben ser menores}} a $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=El valor no debe ser mayor|2=Los valores no deben ser mayores}} a $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=El valor debe|2=Los valores deben}} estar entre $2 y $3.",
+ "api-help-param-multi-separate": "Separar los valores con <kbd>|</kbd> o con una [[Special:ApiHelp/main#main/datatypes|alternativa]].",
+ "api-help-param-multi-max": "El número máximo de los valores es {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} para los bots).",
+ "api-help-param-multi-all": "Para especificar todos los valores, utiliza <kbd>$1</kbd>.",
+ "api-help-param-default": "Predeterminado: $1",
+ "api-help-param-default-empty": "Predeterminado: <span class=\"apihelp-empty\">(vacío)</span>",
+ "api-help-param-disabled-in-miser-mode": "Deshabilitado debido al [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo avaro]].",
+ "api-help-param-limited-in-miser-mode": "strong>Nota:</strong> Debido al [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo avaro]], usar esto puede dar lugar a que se devuelvan menos de <var>$1limit</var> antes de continuar. En casos extremos, podrían devolverse cero resultados.",
+ "api-help-param-direction": "En qué sentido hacer la enumeración:\n;newer: De más antiguos a más recientes. Nota: $1start debe ser anterior a $1end.\n;older: De más recientes a más antiguos (orden predefinido). Nota: $1start debe ser posterior a $1end.",
+ "api-help-param-continue": "Cuando haya más resultados disponibles, utiliza esto para continuar.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(sin descripción)</span>",
+ "api-help-examples": "{{PLURAL:$1|Ejemplo|Ejemplos}}:",
+ "api-help-permissions": "{{PLURAL:$1|Permiso|Permisos}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Concedido a|Concedidos a}}: $2",
+ "api-help-right-apihighlimits": "Usa límites más altos para consultas a través de la API (consultas lentas: $1; consultas rápidas: $2). Los límites para las consultas lentas también se aplican a los parámetros multivalorados.",
+ "api-help-open-in-apisandbox": "<small>[abrir en la zona de pruebas]</small>",
+ "api-help-authmanagerhelper-messageformat": "Formato utilizado para los mensajes devueltos.",
+ "api-help-authmanagerhelper-mergerequestfields": "Combinar la información de los campos para todas las peticiones de autentificación en una matriz.",
+ "api-help-authmanagerhelper-preservestate": "Preservar el estado de un intento fallido anterior de inicio de sesión, si es posible.",
+ "apierror-allimages-redirect": "Usar <kbd>gaifilterredir=nonredirects</kbd> en lugar de <var>redirects</var> cuando se use <kbd>allimages</kbd> como generador.",
+ "apierror-allpages-generator-redirects": "Usar <kbd>gaifilterredir=nonredirects</kbd> en lugar de <var>redirects</var> cuando se use <kbd>allpages</kbd> como generador.",
+ "apierror-appendnotsupported": "No se puede añadir a las páginas que utilizan el modelo de contenido $1.",
+ "apierror-articleexists": "El artículo que intentaste crear ya estaba creado.",
+ "apierror-assertbotfailed": "La aserción de que el usuario tiene el derecho <code>bot</code> falló.",
+ "apierror-assertnameduserfailed": "La aserción de que el usuario es «$1» falló.",
+ "apierror-assertuserfailed": "La aserción de que el usuario está conectado falló.",
+ "apierror-autoblocked": "Tu dirección IP ha sido bloqueada automáticamente porque fue utilizada por un usuario bloqueado.",
+ "apierror-badconfig-resulttoosmall": "El valor de <code>$wgAPIMaxResultSize</code> en este wiki es demasiado pequeño como para contener información básica de resultados.",
+ "apierror-badcontinue": "Parámetro continue no válido. Debes pasar el valor original devuelto por la consulta anterior.",
+ "apierror-baddiff": "La comparación no puede recuperarse. Una o ambas revisiones no existen o no tienes permiso para verlas.",
+ "apierror-baddiffto": "<var>$1diffto</var> debe fijarse a un número no negativo, <kbd>prev</kbd>, <kbd>next</kbd> or <kbd>cur</kbd>.",
+ "apierror-badformat-generic": "El formato solicitado $1 no es compatible con el modelo de contenido $2.",
+ "apierror-badformat": "El formato solicitado $1 no es compatible con el modelo de contenido $2 utilizado por $3.",
+ "apierror-badgenerator-notgenerator": "El módulo <kbd>$1</kbd> no puede utilizarse como un generador.",
+ "apierror-badgenerator-unknown": "<kbd>generator=$1</kbd> desconocido.",
+ "apierror-badip": "El parámetro IP no es válido.",
+ "apierror-badmd5": "El hash MD5 suministrado es incorrecto.",
+ "apierror-badmodule-badsubmodule": "El módulo <kbd>$1</kbd> no tiene un submódulo \"$2\".",
+ "apierror-badmodule-nosubmodules": "El módulo <kbd>$1</kbd> no tiene submódulos.",
+ "apierror-badparameter": "Valor no válido para el parámetro <var>$1</var>.",
+ "apierror-badquery": "La consulta no es válida.",
+ "apierror-badtimestamp": "Valor no válido \"$2\" para el parámetro de marca de tiempo <var>$1</var>.",
+ "apierror-badupload": "El parámetro de subida de archivo <var>$1</var> no es una subida de archivo. Asegúrate de usar <code>multipart/form-data</code> para tu POST e introduce un nombre de archivo en la cabecera <code>Content-Disposition</code>.",
+ "apierror-badurl": "Valor no válido \"$2\" para el parámetro de URL <var>$1</var>.",
+ "apierror-baduser": "Valor no válido \"$2\" para el parámetro de usuario <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "El separador multivalor U+001F solo se puede utilizar en parámetros multivalorados.",
+ "apierror-blockedfrommail": "Se te ha bloqueado de enviar email.",
+ "apierror-blocked": "Se te ha bloqueado de editar.",
+ "apierror-botsnotsupported": "Esta interfaz no está disponible para bots.",
+ "apierror-cannotreauthenticate": "Esta acción no está disponible, ya que tu identidad no se puede verificar.",
+ "apierror-cannotviewtitle": "No tienes permiso para ver $1.",
+ "apierror-cantblock-email": "No tienes permiso para bloquear a los usuarios el envío de correo electrónico a través de la wiki.",
+ "apierror-cantblock": "No tienes permiso para bloquear usuarios.",
+ "apierror-cantchangecontentmodel": "No tienes permiso para cambiar el modelo de contenido de una página.",
+ "apierror-canthide": "No tienes permiso para ocultar nombres de usuario del registro de bloqueos.",
+ "apierror-cantimport-upload": "No tienes permiso para importar páginas subidas.",
+ "apierror-cantimport": "No tienes permiso para importar páginas.",
+ "apierror-cantoverwrite-sharedfile": "El fichero objetivo existe en un repositorio compartido y no tienes permiso para reemplazarlo.",
+ "apierror-cantsend": "No estás conectado, no tienes una dirección de correo electrónico confirmada o no tienes permiso para enviar correo electrónico a otros usuarios, así que no puedes enviar correo electrónico.",
+ "apierror-cantundelete": "No se ha podido restaurar: puede que las revisiones solicitadas no existan o que ya se hayan restaurado.",
+ "apierror-changeauth-norequest": "No se ha podido crear la petición de modificación.",
+ "apierror-contentserializationexception": "La serialización de contenido falló: $1",
+ "apierror-contenttoobig": "El contenido que has suministrado supera el tamaño máximo de archivo de $1 {{PLURAL:$1|kilobyte|kilobytes}}.",
+ "apierror-copyuploadbaddomain": "No se permite realizar cargas a partir de este dominio.",
+ "apierror-copyuploadbadurl": "No se permite realizar cargas a partir de este URL.",
+ "apierror-create-titleexists": "Los títulos existentes no se pueden proteger con <kbd>create</kbd>.",
+ "apierror-csp-report": "Error de procesamiento del informe CSP: $1.",
+ "apierror-databaseerror": "[$1] Error en la consulta de la base de datos.",
+ "apierror-deletedrevs-param-not-1-2": "El parámetro <var>$1</var> no se puede utilizar en los modos 1 o 2.",
+ "apierror-deletedrevs-param-not-3": "El parámetro <var>$1</var> no se puede usar en modo 3.",
+ "apierror-emptynewsection": "Crear secciones vacías no es posible.",
+ "apierror-emptypage": "Crear páginas vacías no está permitido.",
+ "apierror-exceptioncaught": "[$1] Excepción capturada: $2",
+ "apierror-filedoesnotexist": "El archivo no existe.",
+ "apierror-fileexists-sharedrepo-perm": "El archivo objetivo existe en un repositorio compartido. Usa el parámetro <var>ignorewarnings</var> para reemplazarlo.",
+ "apierror-filenopath": "No se pudo obtener la ruta local del archivo.",
+ "apierror-filetypecannotberotated": "El tipo de archivo no se puede girar.",
+ "apierror-formatphp": "Esta respuesta no se puede representar con <kbd>format=php</kbd>. Véase https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "El título de <kbd>$1</kbd> debe ser un archivo.",
+ "apierror-import-unknownerror": "Error desconocido en la importación: $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> no puede ser mayor que $2 (fijado a $3) para bots o administradores de sistema.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> no puede ser mayor que $2 (fijado a $3) para usuarios.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> no puede ser menor que $2 (fijado a $3).",
+ "apierror-invalidcategory": "El nombre de la categoría que has introducido no es válida.",
+ "apierror-invalidexpiry": "Tiempo de expiración \"$1\" no válido.",
+ "apierror-invalidlang": "Código de idioma no válido para el parámetro <var>$1</var>.",
+ "apierror-invalidparammix-cannotusewith": "El parámetro <kbd>$1</kbd> no se puede utilizar junto con <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "El parámetro <kbd>$1</kbd> solo se puede utilizar junto con <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd> no se puede combinar con los parámetros <var>oldid</var>, <var>pageid</var> y <var>page</var>. Por favor, utiliza <var>title</var> y <var>text</var>.",
+ "apierror-invalidparammix": "{{PLURAL:$2|Los parámetros}} $1 no se pueden utilizar juntos.",
+ "apierror-invalidsection": "El parámetro <var>section</var> debe ser un identificador de sección válido, o bien <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "El hash SHA1Base36 proporcionado no es válido.",
+ "apierror-invalidsha1hash": "El hash SHA1 proporcionado no es válido.",
+ "apierror-invalidtitle": "Título incorrecto \"$1\".",
+ "apierror-invalidurlparam": "Valor no válido para <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Nombre de usuario «$1» no válido.",
+ "apierror-invaliduserid": "El identificador de usuario <var>$1</var> no es válido.",
+ "apierror-mimesearchdisabled": "La búsqueda MIME está deshabilitada en el modo avaro.",
+ "apierror-missingcontent-pageid": "Contenido faltante para la página con identificador $1.",
+ "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|El parámetro|Al menos uno de los parámetros}} $1 es necesario.",
+ "apierror-missingparam-one-of": "{{PLURAL:$2|El parámetro|Uno de los parámetros}} $1 es necesario.",
+ "apierror-missingparam": "Se debe establecer el parámetro <var>$1</var>.",
+ "apierror-missingrev-pageid": "No hay ninguna revisión actual de la página con ID $1.",
+ "apierror-missingtitle-createonly": "Los títulos faltantes solo se pueden proteger con <kbd>create</kbd>.",
+ "apierror-missingtitle": "El título especificado no existe.",
+ "apierror-missingtitle-byname": "La página $1 no existe.",
+ "apierror-moduledisabled": "El módulo <kbd>$1</kbd> ha sido deshabilitado.",
+ "apierror-multival-only-one-of": "Solo {{PLURAL:$3|se permite el valor|se permiten los valores}} $2 para el parámetro <var>$1</var>.",
+ "apierror-multival-only-one": "Solo se permite un valor para el parámetro <var>$1</var>.",
+ "apierror-multpages": "<var>$1</var> no se puede utilizar más que con una sola página.",
+ "apierror-mustbeloggedin-changeauth": "Debes estar conectado para poder cambiar los datos de autentificación.",
+ "apierror-mustbeloggedin-generic": "Debes estar conectado.",
+ "apierror-mustbeloggedin-linkaccounts": "Debes estar conectado para enlazar cuentas.",
+ "apierror-mustbeloggedin-removeauth": "Debes estar conectado para borrar datos de autentificación.",
+ "apierror-mustbeloggedin": "Debes estar conectado para $1.",
+ "apierror-mustbeposted": "El módulo <kbd>$1</kbd> requiere una petición POST.",
+ "apierror-mustpostparams": "Se {{PLURAL:$2|encontró el siguiente parámetro|encontraron los siguientes parámetros}} en la cadena de la consulta, pero deben estar en el cuerpo del POST: $1.",
+ "apierror-noapiwrite": "La edición de este wiki a través de la API está deshabilitada. Asegúrate de que la declaración <code>$wgEnableWriteAPI=true;</code> está incluida en el archivo <code>LocalSettings.php</code> del wiki.",
+ "apierror-nochanges": "No se solicitó ningún cambio.",
+ "apierror-nodeleteablefile": "No existe tal versión antigua del archivo.",
+ "apierror-no-direct-editing": "La edición directa a través de la API no es compatible con el modelo de contenido $1 utilizado por $2.",
+ "apierror-noedit-anon": "Los usuarios anónimos no pueden editar páginas.",
+ "apierror-noedit": "No tienes permiso para editar páginas.",
+ "apierror-noimageredirect-anon": "Los usuarios anónimos no pueden crear redirecciones de imágenes.",
+ "apierror-noimageredirect": "No tienes permiso para crear redirecciones de imágenes.",
+ "apierror-nosuchlogid": "No hay ninguna entrada de registro con identificador $1.",
+ "apierror-nosuchpageid": "No hay ninguna página con identificador $1.",
+ "apierror-nosuchrcid": "No hay ningún cambio reciente con identificador $1.",
+ "apierror-nosuchrevid": "No hay ninguna revisión con identificador $1.",
+ "apierror-nosuchsection": "No hay ninguna sección $1.",
+ "apierror-nosuchsection-what": "No hay ninguna sección $1 en $2.",
+ "apierror-nosuchuserid": "No hay ningún usuario con ID $1.",
+ "apierror-notarget": "No has especificado un destino válido para esta acción.",
+ "apierror-notpatrollable": "La revisión r$1 no se puede patrullar por ser demasiado antigua.",
+ "apierror-offline": "No se puede continuar debido a problemas de conectividad de la red. Asegúrate de que tienes una conexión activa a internet e inténtalo de nuevo.",
+ "apierror-opensearch-json-warnings": "No se pueden representar los avisos en formato JSON de OpenSearch.",
+ "apierror-pagecannotexist": "En este espacio de nombres no se permiten páginas reales.",
+ "apierror-pagedeleted": "La página ha sido borrada en algún momento desde que obtuviste su marca de tiempo.",
+ "apierror-pagelang-disabled": "En este wiki no se puede cambiar el idioma de una página.",
+ "apierror-paramempty": "El parámetro <var>$1</var> no puede estar vacío.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> solo es compatible con el contenido en wikitexto.",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> solo es compatible con el contenido en wikitexto. $1 usa el modelo de contenido $2.",
+ "apierror-permissiondenied": "No tienes permiso para $1.",
+ "apierror-permissiondenied-generic": "Permiso denegado.",
+ "apierror-permissiondenied-unblock": "No tienes permiso para desbloquear usuarios.",
+ "apierror-prefixsearchdisabled": "La búsqueda por prefijo está deshabilitada en el modo avaro.",
+ "apierror-promised-nonwrite-api": "La cabecera HTTP <code>Promise-Non-Write-API-Action</code> no se puede enviar a módulos de la API en modo escritura.",
+ "apierror-protect-invalidaction": "Tipo de protección «$1» no válido.",
+ "apierror-protect-invalidlevel": "Nivel de protección «$1» no válido.",
+ "apierror-readapidenied": "Necesitas permiso de lectura para utilizar este módulo.",
+ "apierror-readonly": "El wiki está actualmente en modo de solo lectura.",
+ "apierror-reauthenticate": "No te has autentificado recientemente en esta sesión. Por favor, vuelve a autentificarte.",
+ "apierror-revdel-mutuallyexclusive": "No se puede usar el mismo campo en <var>hide</var> y <var>show</var>.",
+ "apierror-revdel-paramneeded": "Se requiere al menos un valor para <var>hide</var> y/o <var>show</var>.",
+ "apierror-revisions-badid": "No se encontró ninguna revisión para el parámetro <var>$1</var>.",
+ "apierror-revisions-norevids": "El parámetro <var>revids</var> no se puede utilizar junto con las opciones de lista (<var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> y <var>$1end</var>).",
+ "apierror-revisions-singlepage": "Se utilizó <var>titles</var>, <var>pageids</var> o un generador para proporcionar múltiples páginas, pero los parámetros <var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> y <var>$1end</var> solo se pueden utilizar en una sola página.",
+ "apierror-revwrongpage": "r$1 no es una revisión de $2.",
+ "apierror-searchdisabled": "Se ha desactivado la búsqueda de «<var>$1</var>».",
+ "apierror-sectionreplacefailed": "No se ha podido combinar la sección actualizada.",
+ "apierror-sectionsnotsupported": "Las secciones no son compatibles con el modelo de contenido $1.",
+ "apierror-sectionsnotsupported-what": "Las secciones no son compatibles con $1.",
+ "apierror-show": "Parámetro incorrecto: no se pueden proporcionar valores mutuamente excluyentes.",
+ "apierror-siteinfo-includealldenied": "No se puede ver la información de todos los servidores a menos que <var>$wgShowHostNames</var> tenga valor verdadero.",
+ "apierror-sizediffdisabled": "La diferencia de tamaño está deshabilitada en el modo avaro.",
+ "apierror-spamdetected": "Tu edición fue rechazada por contener un fragmento de spam: <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "No tienes permiso para ver los resultados de esta página especial.",
+ "apierror-stashwrongowner": "Propietario incorrecto: $1",
+ "apierror-systemblocked": "Has sido bloqueado automáticamente por el software MediaWiki.",
+ "apierror-templateexpansion-notwikitext": "La expansión de plantillas solo es compatible con el contenido en wikitexto. $1 usa el modelo de contenido $2.",
+ "apierror-timeout": "El servidor no respondió en el plazo previsto.",
+ "apierror-unknownaction": "La acción especificada, <kbd>$1</kbd>, no está reconocida.",
+ "apierror-unknownerror-editpage": "Error de EditPage desconocido: $1.",
+ "apierror-unknownerror-nocode": "Error desconocido.",
+ "apierror-unknownerror": "Error desconocido: «$1»",
+ "apierror-unknownformat": "Formato no reconocido «$1».",
+ "apierror-unrecognizedparams": "{{PLURAL:$2|Parámetro no reconocido|Parámetros no reconocidos}}: $1.",
+ "apierror-unrecognizedvalue": "Valor no reconocido para el parámetro <var>$1</var>: $2.",
+ "apierror-unsupportedrepo": "El repositorio local de archivos no permite consultar todas las imágenes.",
+ "apierror-urlparamnormal": "No se pudieron normalizar los parámetros de imagen de $1.",
+ "apierror-writeapidenied": "No tienes permiso para editar este wiki a través de la API.",
+ "apiwarn-alldeletedrevisions-performance": "Para conseguir un mejor rendimiento a la hora de generar títulos, establece <kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "No se pudo analizar <var>$1urlparam</var> para $2. Se utilizarán solamente la anchura y altura.",
+ "apiwarn-badutf8": "El valor pasado para <var>$1</var> contiene datos no válidos o no normalizados. Los datos textuales deberían estar en Unicode válido, normalizado en NFC y sin caracteres de control C0 excepto HT (\\t), LF (\\n) y CR (\\r).",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> ha quedado obsoleto. En su lugar, utiliza <kbd>prop=deletedrevisions</kbd> o <kbd>list=alldeletedrevisions</kbd>.",
+ "apiwarn-deprecation-expandtemplates-prop": "Como no se ha especificado ningún valor para el parámetro <var>prop</var>, se ha utilizado un formato heredado para la salida. Este formato está en desuso y, en el futuro, el parámetro <var>prop</var> tendrá un valor predeterminado, de forma que siempre se utilizará el formato nuevo.",
+ "apiwarn-deprecation-httpsexpected": "Se ha utilizado HTTP cuando se esperaba HTTPS.",
+ "apiwarn-deprecation-login-botpw": "El inicio de sesión con la cuenta principal mediante <kbd>action=login</kbd> está en desuso y puede dejar de funcionar sin aviso previo. Para proseguir el inicio de sesión mediante <kbd>action=login</kbd>, véase [[Special:BotPasswords]]. Para proseguir el inicio de sesión con la cuenta principal de forma segura, véase <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "El inicio de sesión con la cuenta principal mediante <kbd>action=login</kbd> está en desuso y puede dejar de funcionar sin aviso previo. Para iniciar sesión de forma segura, véase <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-parameter": "El parámetro <var>$1</var> ha quedado obsoleto.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> está en desuso desde MediaWiki 1.28. Usa <kbd>prop=headhtml</kbd> cuando crees nuevos documentos HTML, o <kbd>prop=módulos|jsconfigvars</kbd> cuando actualices un documento en el lado del cliente.",
+ "apiwarn-deprecation-purge-get": "El uso de <kbd>action=purge</kbd> mediante GET está obsoleto. Usa POST en su lugar.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> ha quedado obsoleto. En su lugar, utiliza <kbd>$2</kbd>.",
+ "apiwarn-invalidcategory": "\"$1\" no es una categoría.",
+ "apiwarn-invalidtitle": "«$1» no es un título válido.",
+ "apiwarn-invalidxmlstylesheetext": "Las hojas de estilo deben tener la extensión <code>.xsl</code>.",
+ "apiwarn-invalidxmlstylesheet": "La hoja de estilos especificada no es válida o no existe.",
+ "apiwarn-invalidxmlstylesheetns": "La hoja de estilos debería estar en el espacio de nombres {{ns:MediaWiki}}.",
+ "apiwarn-moduleswithoutvars": "La propiedad <kbd>modules</kbd> está definida, pero no lo está <kbd>jsconfigvars</kbd> ni <kbd>encodedjsconfigvars</kbd>. Las variables de configuración son necesarias para el correcto uso del módulo.",
+ "apiwarn-notfile": "\"$1\" no es un archivo.",
+ "apiwarn-parse-nocontentmodel": "No se proporcionó <var>title</var> ni <var>contentmodel</var>. Se asume $1.",
+ "apiwarn-tokennotallowed": "La acción «$1» no está permitida para el usuario actual.",
+ "apiwarn-truncatedresult": "Se ha truncado este resultado porque de otra manera sobrepasaría el límite de $1 bytes.",
+ "apiwarn-unclearnowtimestamp": "El paso de «$2» para el parámetro <var>$1</var> de la marca de tiempo ha quedado obsoleto. Si por alguna razón necesitas especificar de forma explícita la hora actual sin calcularla desde el lado del cliente, utiliza <kbd>now</kbd> («ahora»).",
+ "apiwarn-unrecognizedvalues": "{{PLURAL:$3|Valor no reconocido|Valores no reconocidos}} para el parámetro <var>$1</var>: $2.",
+ "apiwarn-validationfailed-badchars": "caracteres no válidos en la clave (solamente se admiten los caracteres <code>a-z</code>, <code>A-Z</code>, <code>0-9</code>, <code>_</code> y <code>-</code>).",
+ "apiwarn-validationfailed-badpref": "no es una preferencia válida.",
+ "apiwarn-validationfailed-cannotset": "no puede ser establecido por este módulo.",
+ "apiwarn-validationfailed-keytoolong": "clave demasiado larga (no puede tener más de $1 bytes).",
+ "apiwarn-validationfailed": "Error de validación de <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>Aviso de seguridad</strong>: <var>$wgDebugAPI</var> está habilitado.",
+ "api-feed-error-title": "Error ($1)",
+ "api-usage-docref": "Véase $1 para el uso de la API.",
+ "api-exception-trace": "$1 en $2($3)\n$4",
+ "api-credits-header": "Créditos",
+ "api-credits": "Desarrolladores de la API:\n* Roan Kattouw (desarrollador principal, sep. 2007-2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (creador y desarrollador principal, sep. 2006-sep. 2007)\n* Brad Jorsch (desarrollador principal, 2013-actualidad)\n\nEnvía comentarios, sugerencias y preguntas a mediawiki-api@lists.wikimedia.org\no informa de un error en https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/et.json b/www/wiki/includes/api/i18n/et.json
new file mode 100644
index 00000000..64107fc0
--- /dev/null
+++ b/www/wiki/includes/api/i18n/et.json
@@ -0,0 +1,42 @@
+{
+ "@metadata": {
+ "authors": [
+ "Pikne"
+ ]
+ },
+ "apihelp-query+imageinfo-summary": "Tagastab failiteabe ja üleslaadimisajaloo.",
+ "apihelp-query+imageinfo-param-prop": "Millist teavet faili kohta hankida:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Lisab üles laaditud versiooni ajatempli.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Lisab kasutaja, kes iga failiversiooni üles laadis.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Lisab iga failiversiooni üles laadinud kasutaja identifikaatori.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Versioonikommentaar.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Parsib versioonikommentaari.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Lisab faili kanoonilise pealkirja.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Tagastab faili ja kirjelduslehekülje internetiaadressi.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Lisab faili suuruse baitides, kõrguse ja laiuse ning lehekülgede arvu, kui see on kohane.",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Elemendi \"size\" rööpnimi.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Lisab faili SHA-1 räsiväärtuse.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Lisab faili MIME tüübi.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Lisab faili pisipildi MIME tüübi (vaja elementi \"url\" ja parameetrit \"$1urlwidth\").",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Lisab faili meediatüübi.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Loetleb failiversiooni Exif-metaandmed.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Loetleb failiversiooni vormingu üldised metaandmed.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Loetleb mitme allika vormindatud ühendmetaandmed. Tulemused on HTML-vormingus.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Lisab praegusest versioonist vanemate arhiiviversioonide failinimed.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Lisab versiooni bitisügavuse.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Kasutab lehekülg Special:Upload, et saada teavet olemasoleva faili kohta. Pole mõeldud kasutamiseks väljaspool MediaWiki keskosa.",
+ "apihelp-query+imageinfo-param-limit": "Kui palju redaktsioone faili kohta tagastada.",
+ "apihelp-query+imageinfo-param-start": "Ajatempel, millest loetlemist alustada.",
+ "apihelp-query+imageinfo-param-end": "Ajatempel, mille juures loetlemine lõpetada.",
+ "apihelp-query+imageinfo-param-urlwidth": "Kui $2prop=url on määratud, tagastatakse selle laiusega mastaabitud pildi internetiaadress.\nKui seda valikut kasutatakse, siis ei tagastata jõudluskaalutlusel rohkem kui $1 mastaabitud pilti.",
+ "apihelp-query+imageinfo-param-urlheight": "Analoogne parameetriga \"$1urlwidth\".",
+ "apihelp-query+imageinfo-param-metadataversion": "Kasutatavate metaandmete versioon. Kui määratud on <kbd>latest</kbd>, kasutatakse viimast versiooni. Vaikeväärtus on tagasiühilduvuse huvides <kbd>1</kbd>.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "Millises keeles metaandmed välja võtta. Sellest oleneb väljavõtte tõlge, juhul kui saadaval on mitu tõlget, ning samuti numbrite ja muude väärtuste vorming.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Kui atribuudi \"extmetadata\" tõlked on saadaval, siis kasuta neid kõiki.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Kui määratud ja mittetühi, tagastatakse atribuudi $1prop=extmetadata jaoks ainult need võtmed.",
+ "apihelp-query+imageinfo-param-urlparam": "Töötlusele omane parameetriväärtus. Näiteks PDF-i jaoks võib see olla <kbd>page15-100px</kbd>. Kasutatud peab olema atribuuti <var>$1urlwidth</var> ja see peab olema kooskõlas parameetriga <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-localonly": "Kaasa päringusse ainult kohaliku hoidla failid.",
+ "apihelp-query+imageinfo-example-simple": "Faili [[:File:Albert Einstein Head.jpg|Albert Einstein Head.jpg]] praeguse versiooni teabe väljavõtt.",
+ "apihelp-query+imageinfo-example-dated": "Faili [[:File:Test.jpg|Test.jpg]] teabe väljavõtt alates 2008. aasta versioonidest.",
+ "api-help-param-continue": "Kui saadaval on rohkem tulemusi, kasuta seda jätkamiseks."
+}
diff --git a/www/wiki/includes/api/i18n/eu.json b/www/wiki/includes/api/i18n/eu.json
new file mode 100644
index 00000000..32c5164b
--- /dev/null
+++ b/www/wiki/includes/api/i18n/eu.json
@@ -0,0 +1,246 @@
+{
+ "@metadata": {
+ "authors": [
+ "Subi",
+ "Sator",
+ "An13sa",
+ "Gorkaazk",
+ "Mikel Ibaiba"
+ ]
+ },
+ "apihelp-main-param-action": "Zein ekintza burutuko da.",
+ "apihelp-main-param-format": "Irteerako formatua.",
+ "apihelp-main-param-assertuser": "Egiaztatu erabiltzaile hau izendatutakoa dela.",
+ "apihelp-main-param-requestid": "Hemen emandako edozein balio erantzunean kontuan hartuko da. Eskaerak ezberdintzeko erabili ahalko da.",
+ "apihelp-main-param-curtimestamp": "Emaitzan oraingo denbora-zigilua jarri.",
+ "apihelp-block-summary": "Blokeatu erabiltzaile bat.",
+ "apihelp-block-param-userid": "Erabiltzaile IDa blokeatzear. Ezin da honekin batera erabili: <var>$1user</var>.",
+ "apihelp-block-param-reason": "Blokeatzeko arrazoia.",
+ "apihelp-block-param-anononly": "Erabiltzaile ezezagunak bakarrik blokeatu (adb. IP helbide honetarako ezezagunen aldaketak ezgaitu).",
+ "apihelp-block-param-nocreate": "Saihestu kontuak sortzea.",
+ "apihelp-block-param-reblock": "Erabiltzailea honezkero blokeatuta badago, lehendik dagoen blokea gainidatzi.",
+ "apihelp-block-param-watchuser": "Ikusi erabiltzaile edo IP helbidearen erabiltzaileak eta mintzamen orriak.",
+ "apihelp-checktoken-param-token": "Testatzeko hartuta.",
+ "apihelp-compare-summary": "Bi orrien arteko ezberdintasuna jaso.",
+ "apihelp-compare-param-fromtitle": "Aldaratzeko lehenengo izenburua",
+ "apihelp-compare-param-fromid": "Aldaratzeko lehenengo orri IDa",
+ "apihelp-compare-param-fromrev": "Lehenengo berrikusketa aldaratzeko",
+ "apihelp-compare-param-totitle": "Aldaratzeko bigarren izenburua.",
+ "apihelp-compare-param-toid": "Aldaratzeko bigarren orri IDa.",
+ "apihelp-compare-param-torev": "Aldaratzeko bigarren berrikusketa.",
+ "apihelp-compare-param-prop": "Hartu beharreko informazio zatiak.",
+ "apihelp-compare-paramvalue-prop-diff": "HTML diff-a",
+ "apihelp-compare-paramvalue-prop-diffsize": "HTML diff-aren tamainia, byte-tan",
+ "apihelp-compare-example-1": "1. eta 2. berrikusketen arteko \"diff\"-a sortu.",
+ "apihelp-createaccount-summary": "Erabiltzaile kontu berria sortu.",
+ "apihelp-createaccount-param-name": "Erabiltzaile izena.",
+ "apihelp-createaccount-param-domain": "Kanpoko autentifikaziorako domeinua (aukerakoa).",
+ "apihelp-createaccount-param-token": "Lehenengo eskaeran lortutako kontu sorrera token-a.",
+ "apihelp-createaccount-param-email": "Erabiltzailearen helbide elektronikoa (aukerakoa).",
+ "apihelp-createaccount-param-realname": "Erabiltzailearen benetako izena (aukerakoa).",
+ "apihelp-createaccount-param-mailpassword": "Edozein baliorako jarriz, erabiltzaileari mezu elektroniko baten bitartez ausazko pasahitza bidaliko zaio.",
+ "apihelp-createaccount-param-language": "Erabiltzailearentzako lehenetsiko den hizkuntza kodea (aukerakoa, edukien hizkuntza lehenetsia).",
+ "apihelp-delete-summary": "Orrialde bat ezabatu.",
+ "apihelp-delete-param-title": "Ezabatzeko orri izenburua. Hurrengoarekin batera ezin da erabili: <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Ezabatzeko orri edo ID orria. Hurrengoarekin batera ezin da erabili: <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Ezabatzeko arrazoia. Ezartzen ez bada, automatikoki sortutako arrazoi bat erabiliko da.",
+ "apihelp-delete-param-watch": "Orria erabiltzaile honen ikus-zerrendan sartu.",
+ "apihelp-delete-param-unwatch": "Erabiltzailearen oraingo ikus-zerrendatik orria kendu.",
+ "apihelp-delete-example-simple": "Ezabatu <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Ezabatu <kbd>Orri Nagusia</kbd> <kbd> arrazoiarekin hurrengoa mugitzeko prestatuz:</kbd>.",
+ "apihelp-disabled-summary": "Modulu hau ezgaitu da.",
+ "apihelp-edit-summary": "Orrialdeak sortu eta aldatu.",
+ "apihelp-edit-param-title": "Orri izenburua aldatzeke. Hurrengoarekin batera ezin da erabili: <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Ezabatzeko orri edo ID orria. Hurrengoarekin batera ezin da erabili: <var>$1title</var>.",
+ "apihelp-edit-param-sectiontitle": "Atal berri baten titulua.",
+ "apihelp-edit-param-text": "Orrialdearen edukia.",
+ "apihelp-edit-param-tags": "Aldatu etiketak berrikusketa eskatzeko.",
+ "apihelp-edit-param-minor": "Aldaketa txikia.",
+ "apihelp-edit-param-notminor": "Aldaketa ez-txikiak",
+ "apihelp-edit-param-bot": "Aldaketa hau errobot aldaketa bezala markatu.",
+ "apihelp-edit-param-createonly": "Ez aldatu orria jadanik existitzen bada.",
+ "apihelp-edit-param-nocreate": "Orria ez bada existitzen akatsa bota.",
+ "apihelp-edit-param-watch": "Orria erabiltzaile honen ikus-zerrendan sartu.",
+ "apihelp-edit-param-unwatch": "Erabiltzailearen oraingo ikus-zerrendatik orria kendu.",
+ "apihelp-edit-param-redirect": "Birbideratzeak automatikoki konpondu.",
+ "apihelp-edit-param-contentmodel": "Eduki berriko eduki eredua.",
+ "apihelp-edit-example-edit": "Orrialde bat aldatu",
+ "apihelp-emailuser-summary": "Erabiltzaileari e-maila bidali",
+ "apihelp-emailuser-param-target": "Email-a bidaltzeko erabiltzailea.",
+ "apihelp-emailuser-param-subject": "Gaiaren goiburua.",
+ "apihelp-emailuser-param-text": "Mezuaren gorputza.",
+ "apihelp-emailuser-param-ccme": "Bidal iezadazu mezu elektroniko honen kopia bat.",
+ "apihelp-emailuser-example-email": "<kbd>WikiSysop</kbd> erabiltzaileari mezu elektronikoa bidali <kbd>Edukia</kbd> testuarekin.",
+ "apihelp-expandtemplates-param-title": "Orrialdearen izenburua.",
+ "apihelp-expandtemplates-param-text": "Bihurtzeko Wikitestua",
+ "apihelp-expandtemplates-param-revid": "Berrikusketa ID, <code><nowiki>{{REVISIONID}}</nowiki></code> eta antzeko aldagaientzako.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Wikitestu zabaldua.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Emaitzen cache-ak baliogabetu baino lehen iraun dezaketen denbora.",
+ "apihelp-feedcontributions-param-feedformat": "Produktuaren formatua.",
+ "apihelp-feedcontributions-param-user": "Zeintzuk erabiltzaileei ekarpenak egin.",
+ "apihelp-feedcontributions-param-year": "Urtetik aurrera (eta lehenagotik)",
+ "apihelp-feedcontributions-param-month": "Hilabetetik aurrera (eta lehenagotik)",
+ "apihelp-feedcontributions-param-tagfilter": "Etiketa hauek dituzten laguntzak iragazki.",
+ "apihelp-feedcontributions-param-deletedonly": "Ezabatutako laguntzak soilik erakutsi.",
+ "apihelp-feedcontributions-param-toponly": "Soilik azkenengo berriskusketak diren aldaketak erakutsi.",
+ "apihelp-feedcontributions-param-newonly": "Orrialde sorkuntza direnak soilik erakutsi",
+ "apihelp-feedcontributions-param-hideminor": "Aldaketa txikiak ezkutatu",
+ "apihelp-feedcontributions-param-showsizediff": "Berrikusketen arteko tamaina aldea erakutsi.",
+ "apihelp-feedrecentchanges-param-feedformat": "Produktuaren formatua.",
+ "apihelp-feedrecentchanges-param-days": "Egunen arabera emaitzak murriztu.",
+ "apihelp-feedrecentchanges-param-from": "Momentu horretatik aurrerako aldaketak erakutsi.",
+ "apihelp-feedrecentchanges-param-hideminor": "Ezkutatu aldaketa txikiak.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ezkutatu botek egindako aldaketak.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ezkutatu erabiltzaile anonimoek egindako aldaketak.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ezkutatu izena emandako erabiltzaileek egindako aldaketak.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ezkutatu zainpeko aldaketak.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ezkutatu zuk egindako aldaketak.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Iragazi etiketen arabera.",
+ "apihelp-feedrecentchanges-example-simple": "Erakutsi aldaketa berriak",
+ "apihelp-feedrecentchanges-example-30days": "Erakutsi aldaketa berriak 30 egunez",
+ "apihelp-feedwatchlist-param-feedformat": "Produktuaren formatua.",
+ "apihelp-filerevert-summary": "Artxibo bat bertsio zaharrera bueltatu.",
+ "apihelp-filerevert-param-comment": "Iruzkina igo.",
+ "apihelp-help-example-recursive": "Laguntza guztia orrialde batean.",
+ "apihelp-imagerotate-summary": "Irudi bat edo gehiago biratu.",
+ "apihelp-imagerotate-param-rotation": "Irudia erloju-orratzen norabidean biratzeko graduak.",
+ "apihelp-import-param-summary": "Inportazioaren laburpena.",
+ "apihelp-import-param-xml": "XML fitxategia igo da.",
+ "apihelp-import-param-tags": "Aldatu etiketak sarrera aplikatzeko inportatzeko sarreran eta inportatutako orrialdeetan berrikuspena kentzeko.",
+ "apihelp-login-param-name": "Erabiltzaile izena.",
+ "apihelp-login-param-password": "Pasahitza.",
+ "apihelp-login-param-domain": "Domeinua (hautazkoa).",
+ "apihelp-login-example-login": "Hasi saioa",
+ "apihelp-logout-summary": "Saioa itxi eta saioko datuak garbitu.",
+ "apihelp-logout-example-logout": "Erabiltzaile honi sesioa itxi.",
+ "apihelp-mergehistory-summary": "Orrialdeen historiak erakutsi.",
+ "apihelp-move-summary": "Orrialde bat mugitu",
+ "apihelp-move-param-reason": "Berrizenpenaren arrazoia.",
+ "apihelp-move-param-noredirect": "Birzuzenketarik ez sortu.",
+ "apihelp-move-param-ignorewarnings": "Edozein ohar ezikusi.",
+ "apihelp-opensearch-param-search": "Bilatu katea.",
+ "apihelp-opensearch-param-limit": "Bueltatzeko gehienezko emaitza kopurua.",
+ "apihelp-opensearch-param-namespace": "Bilatzeko izen-tarteak.",
+ "apihelp-opensearch-param-format": "Irteerako formatua.",
+ "apihelp-options-example-reset": "Berrezarri hobespen guztiak.",
+ "apihelp-paraminfo-summary": "API moduluei buruzko informazioa eskuratu.",
+ "apihelp-paraminfo-param-helpformat": "Laguntza-kateen formatua.",
+ "apihelp-parse-param-summary": "Analizatzeko laburpena.",
+ "apihelp-parse-param-preview": "Aurrebista moduaren bitartez aztertu.",
+ "apihelp-parse-example-page": "Aztertu orri bat.",
+ "apihelp-parse-example-text": "Wikitestua aztertu.",
+ "apihelp-parse-example-texttitle": "Wikitestua aztertu, orri izenburua zehaztuz.",
+ "apihelp-parse-example-summary": "Laburpen bat aztertu.",
+ "apihelp-patrol-summary": "Orri edo berrikusketa bat patruilatu.",
+ "apihelp-patrol-param-revid": "Patruilatzeko ID bat berrikusi.",
+ "apihelp-patrol-example-rcid": "Azkenaldian egindako aldaketa bat patruilatu.",
+ "apihelp-patrol-example-revid": "Patruilatu berrikusketa bat.",
+ "apihelp-protect-summary": "Aldatu orri baten segurtasun maila.",
+ "apihelp-protect-param-reason": "Babesteko edo babesa kentzeko zergatia.",
+ "apihelp-protect-example-protect": "Orrialde bat babestu",
+ "apihelp-purge-param-forcelinkupdate": "Eguneratu taula linkak.",
+ "apihelp-query-param-list": "Jasotzeko zerrendak.",
+ "apihelp-query-param-meta": "Jasotzeko metadata.",
+ "apihelp-query+allcategories-summary": "Kategoria guztiak zenbakitu.",
+ "apihelp-query+allcategories-param-prefix": "Balio honekin hasten diren kategoria guztiak bilatu.",
+ "apihelp-query+allcategories-param-dir": "Sailkatzeko norabidea.",
+ "apihelp-query+allcategories-param-min": "Soilik itzuli gutxienez kide kopuru hauek dituzten sailkapenetara.",
+ "apihelp-query+allcategories-param-max": "Soilik itzuli gehienez kide kopuru hauek dituzten sailkapenetara.",
+ "apihelp-query+allcategories-param-limit": "Zenbat kategorietara itzuli.",
+ "apihelp-query+allcategories-param-prop": "Zeintzuk propietateak hartu:",
+ "apihelp-query+alldeletedrevisions-param-from": "Zerrendatzen hasi titulu honetan.",
+ "apihelp-query+alldeletedrevisions-param-to": "Zerrendatzeari utzi titulu honetan.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Balio honekin hasten diren orri izenburu guztiak bilatu.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Erabiltzaile honen berrikusketak ez zerrendatu.",
+ "apihelp-query+allfileusages-param-prefix": "Balio honekin hasten diren artxibo izenburu guztiak bilatu.",
+ "apihelp-query+allfileusages-param-prop": "Sartu beharreko informazio zatiak:",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Fitxategiaren izenburua gehitzen du.",
+ "apihelp-query+allfileusages-param-limit": "Guztira bueltatzeko elementu kopurua.",
+ "apihelp-query+allfileusages-param-dir": "Zerrendatzeko norabidea.",
+ "apihelp-query+allfileusages-example-unique": "Zerrendatu artxibo izenburu bakarrak.",
+ "apihelp-query+allfileusages-example-unique-generator": "Artxibo izenburu guztiak hartzen ditu, falta direnak markatuz.",
+ "apihelp-query+allfileusages-example-generator": "Artxiboak dituzten orriak lortzen ditu.",
+ "apihelp-query+allimages-summary": "Sekuentzialki zenbakitzen ditu irudi guztiak.",
+ "apihelp-query+allimages-param-dir": "Zerrendatzeko norabidea.",
+ "apihelp-query+allimages-param-limit": "Guztira zenbat irudietara itzuli.",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Link-aren izenburua gehitzen du.",
+ "apihelp-query+allmessages-param-prop": "Zeintzuk propietateak hartu.",
+ "apihelp-query+allmessages-param-lang": "Hizkuntza honetan dauden mezuak itzuli.",
+ "apihelp-query+allmessages-param-from": "Mezu honetan hasten diren mezuak itzuli.",
+ "apihelp-query+allmessages-param-to": "Mezu honetan bukatzen duten mezuak itzuli.",
+ "apihelp-query+allmessages-param-prefix": "Aurrizki hau daramaten mezuak itzuli.",
+ "apihelp-query+allmessages-example-ipb": "Erakutsi honela hasten diren mezuak: <kbd>ipb-</kbd>.",
+ "apihelp-query+allpages-param-prefix": "Balio honekin hasten diren orri izenburu guztiak bilatu.",
+ "apihelp-query+allpages-param-filterredir": "Zeintzuk orri zerrendatu.",
+ "apihelp-query+allpages-param-minsize": "Gutxieneko byte kopuru hau betetzen duten orrietara mugatu.",
+ "apihelp-query+allpages-param-maxsize": "Gehienez byte kopuru hau betetzen duten orrietara mugatu.",
+ "apihelp-query+allpages-param-prtype": "Babestutako orrietara soilik mugatu.",
+ "apihelp-query+allrevisions-summary": "Zerrendatu berrikusketa guztiak.",
+ "apihelp-query+allrevisions-param-user": "Erabiltzaile honen berrikusketak soilik zerrendatu.",
+ "apihelp-query+allrevisions-param-excludeuser": "Erabiltzaile honen berrikusketak ez zerrendatu.",
+ "apihelp-query+allrevisions-example-user": "<kbd>Eredua</kbd> egindako azken 50 ekarpenak zerrendatu.",
+ "apihelp-query+mystashedfiles-param-limit": "Hartzeko artxibo kopurua",
+ "apihelp-query+alltransclusions-param-prop": "Sartu beharreko informazio zatiak:",
+ "apihelp-query+alltransclusions-param-limit": "Guztira bueltatzeko elementu kopurua.",
+ "apihelp-query+alltransclusions-param-dir": "Zerrendatzeko norabidea.",
+ "apihelp-query+allusers-summary": "Zerrendatu erregistratuko erabiltzaile guztiak.",
+ "apihelp-query+allusers-param-from": "Zerrendatzen hasteko erabiltzaile izen honetatik.",
+ "apihelp-query+allusers-param-to": "Zerrendatzen gelditzeko erabiltzaile izen honetatik.",
+ "apihelp-query+allusers-param-prefix": "Balio honekin hasten diren erabiltzaile guztiak bilatu.",
+ "apihelp-query+allusers-param-dir": "Sailkatzeko norabidea.",
+ "apihelp-query+allusers-param-group": "Soilik talde hauetatik erabiltzaileak hartu.",
+ "apihelp-query+allusers-param-witheditsonly": "Bakarrik zerrendatu aldaketak egin dituzten erabiltzaileak.",
+ "apihelp-query+allusers-param-activeusers": "Bakarrik zerrendatu azken {{PLURAL:$1|eguneko|$1 egunetako}} erabiltzaile aktiboak.",
+ "apihelp-query+blocks-summary": "Zerrendatu blokeatutako erabiltzaile eta IP helbide guztiak.",
+ "apihelp-query+blocks-example-simple": "Blokeak zerrendatu.",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Orri IDa gehitzen du.",
+ "apihelp-query+filearchive-example-simple": "Erakutsi ezabatutako fitxategi guztien zerrenda",
+ "apihelp-query+imageinfo-param-urlheight": "$1urlwidth-en antzekoa.",
+ "apihelp-query+imageusage-example-simple": "Erakutsi [[:File:Albert Einstein Head.jpg]] darabilten orriak",
+ "apihelp-query+iwbacklinks-param-prefix": "Interwikiaren aurrizkia.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Hizkuntza izenak aurkitzeko hizkuntza kodea.",
+ "apihelp-query+prefixsearch-param-search": "Bilatu katea.",
+ "apihelp-query+protectedtitles-example-simple": "Zerrendatu babestutako izenburuak",
+ "apihelp-query+recentchanges-example-simple": "Zerrendatu aldaketa berriak.",
+ "apihelp-query+revisions-example-last5": "<kbd>Orrialde Nagusiaren</kbd> azken 5 berrikuspenak eskuratu.",
+ "apihelp-query+revisions-example-first5": "<kbd>Orrialde Nagusiaren</kbd> lehen 5 berrikuspenak eskuratu.",
+ "apihelp-query+search-paramvalue-prop-score": "Ezikusia.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ezikusia.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Ikurra lortu oraingo lehentasunak aldatzeko.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Orrialdearen izenburua gehitzen du.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Aldaketa egin duen erabiltzailea gehitzen du.",
+ "apihelp-upload-param-file": "Fitxategiaren edukia.",
+ "apihelp-upload-example-url": "Igo URL batetik.",
+ "apihelp-userrights-param-reason": "Aldaketarako arrazoia.",
+ "api-help-main-header": "Modulu nagusia",
+ "api-help-undocumented-module": "Ez dago dokumentaziorik $1 modulurako.",
+ "api-help-flag-deprecated": "Modulu hau zaharkitua dago.",
+ "api-help-source": "Iturria: $1",
+ "api-help-source-unknown": "Iturria: <span class=\"apihelp-unknown\">ezezaguna</span>",
+ "api-help-license": "Lizentzia: [[$1|$2]]",
+ "api-help-license-noname": "Lizentzia: [[$1|Ikusi esteka]]",
+ "api-help-license-unknown": "Lizentzia: <span class=\"apihelp-unknown\">ezezaguna</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parametroa|Parametroak}}:",
+ "api-help-param-deprecated": "Zaharkitua.",
+ "api-help-param-required": "Parametro hau beharrezkoa da.",
+ "api-help-datatypes-header": "Datu-motak",
+ "api-help-param-type-limit": "Mota: osokoa edo <kbd>max</kbd>",
+ "api-help-param-type-integer": "Mota: {{PLURAL:$1|1=osokoa|2=osokoen zerrenda}}",
+ "api-help-param-type-boolean": "Mota: boolearra ([[Special:ApiHelp/main#main/datatypes|xehetasunak]])",
+ "api-help-param-type-timestamp": "Mota: {{PLURAL:$1|1=data-zigilua|2=data-zigiluen zerrenda}} ([[Special:ApiHelp/main#main/datatypes|onartutako formatuak]])",
+ "api-help-param-type-user": "Mota: {{PLURAL:$1|1=erabiltzaile-izena|2=erabiltzaile-izenen zerrenda}}",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Hutsik egon behar du|Hutsik egon daiteke edo $2}}",
+ "api-help-param-limit": "Ez dira $1 baino gehiago onartzen.",
+ "api-help-param-limit2": "Ez dira $1 ($2 botentzat) baino gehiago onartzen.",
+ "api-help-param-default": "Lehenetsia: $1",
+ "api-help-param-default-empty": "Lehenetsia: <span class=\"apihelp-empty\">(hutsik)</span>",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(deskribapenik gabe)</span>",
+ "api-help-examples": "{{PLURAL:$1|Adibidea|Adibideak}}:",
+ "api-help-permissions": "{{PLURAL:$1|Baimena|Baimenak}}:",
+ "apierror-timeout": "Zerbitzariak ez du erantzun espero zitekeen denboran.",
+ "apiwarn-invalidcategory": "\"$1\" ez da kategoria.",
+ "apiwarn-invalidtitle": "\"$1\" ez da baliozko izenburua.",
+ "apiwarn-notfile": "\"$1\" ez da fitxategia.",
+ "api-credits-header": "Kredituak",
+ "api-credits": "API garatzaileak:\n* Roan Kattouw (garatzaile nagusia, 2007ko ira.–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (sortzailea, garatzaile nagusia, 2006ko ira.–2007ko ira.)\n* Brad Jorsch (garatzaile nagusia 2013–gaur egun)\n\nMesedez, bidal iezazkiguzu zure iruzkinak, iradokizunak eta galderak mediawiki-api@lists.wikimedia.org helbidera edo bete ezazu errore-txostena https://phabricator.wikimedia.org/ helbidean."
+}
diff --git a/www/wiki/includes/api/i18n/fa.json b/www/wiki/includes/api/i18n/fa.json
new file mode 100644
index 00000000..c1376119
--- /dev/null
+++ b/www/wiki/includes/api/i18n/fa.json
@@ -0,0 +1,338 @@
+{
+ "@metadata": {
+ "authors": [
+ "Alirezaaa",
+ "Arash.pt",
+ "Fatemi127",
+ "Reza1615",
+ "KhabarNegar",
+ "Sahehco",
+ "Signal89",
+ "Mjbmr",
+ "Ebraminio",
+ "Macofe",
+ "Huji",
+ "Ladsgroup",
+ "Freshman404",
+ "Alifakoor"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|مستندات]]\n* [[mw:API:FAQ|پرسش‌های متداول]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api فهرست پست الکترونیکی]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce اعلانات رابط برنامه‌نویسی کاربردی]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R ایرادها و درخواست‌ها]\n</div>\n\n<strong>وضعیت:</strong> تمام ویژگی‌هایی که در این صفحه نمایش یافته‌اند باید کار بکنند، ولی رابط برنامه‌نویسی کاربردی کماکان در حال توسعه است، و ممکن است در هر زمان تغییر بکند. به عضویت [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ فهرست پست الکترونیکی mediawiki-api-announce] در بیایید تا از تغییرات باخبر شوید.\n\n<strong>درخواست‌های معیوب:</strong> وقتی درخواست‌های معیوب به رابط برنامه‌نویسی کاربردی فرستاده شوند، یک سرایند اچ‌تی‌تی‌پی با کلید «MediaWiki-API-Erorr» فرستاده می‌شود و بعد هم مقدار سرایند و هم کد خطای بازگردانده شده هر دو به یک مقدار نسبت داده می‌شوند. برای اطلاعات بیشتر [[mw:API:Errors_and_warnings|API: Errors and warnings]] را ببینید.\n\n<strong>آزمایش:</strong> برای انجام درخواست‌های API آزمایشی [[Special:ApiSandbox]] را ببینید.",
+ "apihelp-main-param-action": "کدام عملیات را انجام دهد.",
+ "apihelp-main-param-format": "فرمت خروجی.",
+ "apihelp-main-param-smaxage": "تنظيم <code>s-maxage</code> سرآیند کنترل حافضهٔ نهان HTTP بر اين تعداد ثانيه.",
+ "apihelp-main-param-maxage": "تنظيم <code>s-maxage</code> سرآیند کنترل حافضهٔ نهان HTTP بر اين تعداد ثانيه.\nخطاها هيچگاه در حافظهٔ نهان قرار نمی‌گيرند.",
+ "apihelp-main-param-requestid": "هر مقداری که در اینجا وارد شود در پاسخ گنجانده می‌شود. ممکن است برای تمايز بين درخواست‌ها بکار رود.",
+ "apihelp-main-param-servedby": "نام ميزبانی که درخواست را سرويس داده در نتايج گنجانده شود.",
+ "apihelp-main-param-curtimestamp": "برچسب زمان کنونی را در نتیجه قرار دهید.",
+ "apihelp-block-summary": "بستن کاربر",
+ "apihelp-block-param-user": "نام کاربری، آدرس آی پی یا محدوده آی پی موردنظر شما برای بستن.",
+ "apihelp-block-param-reason": "دلیل بسته‌شدن",
+ "apihelp-block-param-anononly": "فقط بستن کاربران ناشناس (مانند غیرفعال کردن ویرایش‌های ناشناس این آی‌پی).",
+ "apihelp-block-param-nocreate": "جلوگیری از ایجاد حساب.",
+ "apihelp-block-param-autoblock": "به طور خودکار آخرین نشانی آی‌پی استفاده‌شده، و هر نشانی پس از آن که سعی می‌کند از آن داخل شود را ببند.",
+ "apihelp-block-param-noemail": "از کاربر در برابر ارسال ایمیل از طریق ویکی جلوگیری شود. (نیازمند دسترسی <code>blockemail</code> است).",
+ "apihelp-block-param-hidename": "نام کاربری را از سیاههٔ بستن پنهان کن. (نیازمند دسترسی <code>hideuser</code> است).",
+ "apihelp-block-param-allowusertalk": "به کاربر برای ویرایش صفحه بحث‌شان اجازه دهید (بسته به <var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "اگر کاربر پیش از این مسدود شده‌است، مسدود موجود را بازنویسی کن.",
+ "apihelp-block-param-watchuser": "صفحه‌های کاربر و بحث کاربر نشانی آی‌پی یا کاربر را پی‌گیری کنید.",
+ "apihelp-block-example-ip-simple": "آی‌پی <kbd>۱۹۲٫۰٫۲٫۵</kbd> را برای سه روز همراه دلیل <kbd>برخورد اول</kbd> ببندید",
+ "apihelp-block-example-user-complex": "بستن کاربر<kbd>خرابکار</kbd> به شکل نامحدود به علت <kbd>خرابکاری</kbd>، همچنين جلوگيری از ايجاد حساب جديد و ارسال ايميل.",
+ "apihelp-changeauthenticationdata-summary": "تغيير اطلاعات احراز هويت برای کاربر فعلی",
+ "apihelp-changeauthenticationdata-example-password": "تلاش برای تغيير گذرواژه فعلی کاربر به <kbd>نمونهٔ گذرواژه</kbd>.",
+ "apihelp-checktoken-param-type": "نوع توکنی که دارد آزمایش می‌شود.",
+ "apihelp-checktoken-param-token": "توکن برای تست",
+ "apihelp-checktoken-param-maxtokenage": "حداکثر عمر توکن به ثانیه.",
+ "apihelp-checktoken-example-simple": "تست اعتبار یک توکن <kbd>csrf</kbd>",
+ "apihelp-clearhasmsg-summary": "پرچم <code>hasmsg</code> را برای کاربر جاری پاک کن.",
+ "apihelp-clearhasmsg-example-1": "پاک‌کردن پرچم <code>hasmsg</code> برای کاربر جاری",
+ "apihelp-clientlogin-example-login": "شروع فرآیند ورود به ويکی به عنوان کاربر <kbd>نمونه</kbd> با گذرواژهٔ <kbd>نمونهٔ گذرواژه</kbd>",
+ "apihelp-compare-summary": "تفاوت بین ۲ صفحه را بیابید.",
+ "apihelp-compare-extended-description": "شما باید یک شماره بازبینی، یک عنوان صفحه، یا یک شناسه صفحه برای هر دو «از» و «به» مشخص کنید.",
+ "apihelp-compare-param-fromtitle": "عنوان اول برای مقایسه.",
+ "apihelp-compare-param-fromid": "شناسه صفحه اول برای مقایسه.",
+ "apihelp-compare-param-fromrev": "نسخه اول برای مقایسه.",
+ "apihelp-compare-param-totitle": "عنوان دوم برای مقایسه.",
+ "apihelp-compare-param-toid": "شناسه صفحه دوم برای مقایسه.",
+ "apihelp-compare-param-torev": "نسخه دوم برای مقایسه.",
+ "apihelp-compare-paramvalue-prop-diff": "تفاوت اچ‌تی‌ام‌ال.",
+ "apihelp-compare-paramvalue-prop-diffsize": "اندازهٔ تفاوت اچ‌تی‌ام‌ال، به بایت.",
+ "apihelp-compare-example-1": "ایجاد تفاوت بین نسخه 1 و 2",
+ "apihelp-createaccount-summary": "ایجاد حساب کاربری",
+ "apihelp-createaccount-param-name": "نام کاربری.",
+ "apihelp-createaccount-param-password": "رمز عبور (نادیده گرفته می‌شود اگر <var>$1mailpassword</var> تنظیم شده‌باشد).",
+ "apihelp-createaccount-param-domain": "دامنه برای احراز هویت خارجی (اختیاری).",
+ "apihelp-createaccount-param-email": "نشانی ایمیل کاربر (اختیاری)",
+ "apihelp-createaccount-param-realname": "نام واقعی کاربر (اختیاری).",
+ "apihelp-createaccount-param-mailpassword": "اگر به هر مقداری تنظیم شود، یک رمز عبور تصادفی به کاربر ایمیل خواهد شد.",
+ "apihelp-createaccount-param-reason": "دلیل اختیاری برای ایجاد حساب کاربری جهت قرارگرفتن در سیاهه‌ها.",
+ "apihelp-createaccount-example-pass": "ایجاد کاربر <kbd>testuser</kbd> همراه رمز عبور <kbd>test123</kbd>",
+ "apihelp-createaccount-example-mail": "ایجاد کاربر <kbd>testmailuser</kbd> و ارسال یک رمز عبور تصادفی به ای‌میل.",
+ "apihelp-delete-summary": "حذف صفحه",
+ "apihelp-delete-param-title": "عنوان صفحه‌ای که قصد حذفش را دارید. نمی‌تواند در کنار <var>$1pageid</var> استفاده شود.",
+ "apihelp-delete-param-pageid": "شناسه صفحه‌ای که قصد حذفش را دارید. نمی‌تواند در کنار <var>$1title</var> استفاده شود.",
+ "apihelp-delete-param-reason": "دلیل برای حذف. اگر تنظیم نشود، یک دلیل خودکار ساخته‌شده استفاده می‌شود.",
+ "apihelp-delete-param-watch": "افزودن صفحه به فهرست پی‌گیری کاربر فعلی",
+ "apihelp-delete-param-unwatch": "صفحه را از پی‌گیری‌تان حذف کنید.",
+ "apihelp-delete-example-simple": "حذف <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "حذف <kbd>صفحهٔ اصلی</kbd> همراه دلیل <kbd>آماده‌سازی برای انتقال</kbd>",
+ "apihelp-disabled-summary": "این پودمان غیرفعال شده است.",
+ "apihelp-edit-summary": "ایجاد و ویرایش صفحه",
+ "apihelp-edit-param-title": "عنوان صفحه‌ای که قصد ویرایشش را دارید. نمی‌تواند در کنار <var>$1pageid</var> استفاده شود.",
+ "apihelp-edit-param-pageid": "شناسه صفحهٔ صفحه‌ای که می‌خواهید ویرایشش کنید. نمی‌تواند در کنار <var>$1title</var> استفاده شود.",
+ "apihelp-edit-param-section": "شماره بخش. <kbd>۰</kbd> برای بخش بالا، «<kbd>تازه</kbd>» برای یک بخش تازه.",
+ "apihelp-edit-param-sectiontitle": "عنوان برای بخش جدید.",
+ "apihelp-edit-param-text": "محتوای صفحه.",
+ "apihelp-edit-param-summary": "خلاصه را ویرایش کنید. همچنین عنوان بخش را زمانی که $1section=تازه و $1sectiontitle تنظیم نشده‌است.",
+ "apihelp-edit-param-minor": "ویرایش جزئی.",
+ "apihelp-edit-param-notminor": "ویرایش غیر جزئی.",
+ "apihelp-edit-param-bot": "علامت زدن این ویرایش به عنوان ویرایش ربات.",
+ "apihelp-edit-param-createonly": "اگر صفحه موجود بود، ویرایش نکن.",
+ "apihelp-edit-param-nocreate": "رها کردن خطا در صورتی که صفحه وجود ندارد.",
+ "apihelp-edit-param-watch": "افزودن صفحه به فهرست پی‌گیری شما",
+ "apihelp-edit-param-unwatch": "حذف صفحه از فهرست پی‌گیری شما",
+ "apihelp-edit-param-prependtext": "این متن را به ابتدای صفحه اضافه کنید. $1text را لغو می‌کند.",
+ "apihelp-edit-param-undo": "این بازبینی را برگردانید. $1text، $1prependtext و $1appendtext را باطل می‌کند.",
+ "apihelp-edit-param-undoafter": "همه بازبینی‌ها را از $1undo تا این یکی برگردانید. اگر تنظیم نشد، فقط یک بازبینی را برگردانید.",
+ "apihelp-edit-param-redirect": "اصلاح خودکار تغییرمسیرها.",
+ "apihelp-edit-param-contentmodel": "مدل محتوایی محتوای جدید",
+ "apihelp-edit-param-token": "بلیط باید همیشه به عنوان اخرین پارامتر، یا دست کم بعد از پارامتر $1text فرستاده شود.",
+ "apihelp-edit-example-edit": "ویرایش صفحه",
+ "apihelp-edit-example-undo": "واگردانی نسخه‌های ۱۳۵۷۹ تا ۱۳۵۸۵ با خلاصهٔ خودکار.",
+ "apihelp-emailuser-summary": "ایمیل به کاربر",
+ "apihelp-emailuser-param-target": "کاربر برای ارسال ایمیل به وی.",
+ "apihelp-emailuser-param-subject": "موضوع هدر.",
+ "apihelp-emailuser-param-text": "متن رایانه.",
+ "apihelp-emailuser-param-ccme": "ارسال یک نسخه از رایانه به شما.",
+ "apihelp-expandtemplates-summary": "گسترش همه الگوها در ویکی نبشته",
+ "apihelp-expandtemplates-param-title": "عنوان صفحه",
+ "apihelp-expandtemplates-param-text": "تبدیل برای ویکی‌متن.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "ویکی‌متن گسترش‌یافته.",
+ "apihelp-feedcontributions-summary": "خوراک مشارکت‌های یک کاربر را برمی‌گرداند.",
+ "apihelp-feedcontributions-param-feedformat": "فرمت خوراک.",
+ "apihelp-feedcontributions-param-namespace": "فیلتر شدن مشارکتها براساس فضای نام.",
+ "apihelp-feedcontributions-param-year": "از سال (و پیش از آن).",
+ "apihelp-feedcontributions-param-month": "از ماه (و پیش از آن).",
+ "apihelp-feedcontributions-param-tagfilter": "فیلتر کردن مشارکتها براساس این برچسب‌ها.",
+ "apihelp-feedcontributions-param-deletedonly": "فقط مشارکت‌های حذف شده نمایش داده شود.",
+ "apihelp-feedcontributions-param-toponly": "فقط ویرایش‌هایی که آخرین نسخه‌اند نمایش داده شود.",
+ "apihelp-feedcontributions-param-newonly": "فقط نمایش ویرایش‌هایی که تولید‌های صفحه هستند.",
+ "apihelp-feedcontributions-param-showsizediff": "نمایش تفاوت حجم تغییرات بین نسخه‌ها.",
+ "apihelp-feedcontributions-example-simple": "مشارکت‌های [[کاربر:نمونه]] را برگردان",
+ "apihelp-feedrecentchanges-summary": "خوراک تغییرات اخیر را برمی‌گرداند.",
+ "apihelp-feedrecentchanges-param-feedformat": "فرمت خوراک.",
+ "apihelp-feedrecentchanges-param-namespace": "فضای نام برای محدودکردن نتایج به.",
+ "apihelp-feedrecentchanges-param-invert": "همهٔ فضاهای نام به جز انتخاب‌شده‌ها.",
+ "apihelp-feedrecentchanges-param-associated": "فضای نام مرتبط (بحث یا اصلی) را شامل می‌شود.",
+ "apihelp-feedrecentchanges-param-days": "روز برای محدود کردن نتایج.",
+ "apihelp-feedrecentchanges-param-limit": "حداکثر تعداد نتایج خروجی.",
+ "apihelp-feedrecentchanges-param-from": "نمایش تغییرات پس از آن.",
+ "apihelp-feedrecentchanges-param-hideminor": "پنهان کردن تغییرات جزئی.",
+ "apihelp-feedrecentchanges-param-hidebots": "پنهان کردن تغییرات انجام شده توسط ربات‌ها.",
+ "apihelp-feedrecentchanges-param-hideanons": "پنهان کردن ویرایش‌های کاربران ناشناس.",
+ "apihelp-feedrecentchanges-param-hideliu": "پنهان کردن ویرایش‌های کاربران ثبت‌نام کرده.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "پنهان کردن ویرایش گشت‌زن‌ها.",
+ "apihelp-feedrecentchanges-param-hidemyself": "پنهان کردن ویرایش‌های کاربر فعلی.",
+ "apihelp-feedrecentchanges-param-tagfilter": "فیلتر کردن براساس برچسب",
+ "apihelp-feedrecentchanges-param-target": "فقط نمایش ویرایش‌هایی که پیوند دارند به این صفحه.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "نمایش ویرایش‌ها بر روی صفحات پیوند داده شده به صفحات انتخاب شده.",
+ "apihelp-feedrecentchanges-example-simple": "نمایش تغییرات اخیر",
+ "apihelp-feedrecentchanges-example-30days": "نمایش تغییرات اخیر در 30 روز اخیر",
+ "apihelp-feedwatchlist-summary": "برگرداندن فهرست پیگیری‌های خوراک.",
+ "apihelp-feedwatchlist-param-feedformat": "فرمت خوراک.",
+ "apihelp-feedwatchlist-param-linktosections": "اگر ممکن است به طور مستقیم به بخش‌های تغییریافته پیوند دهید.",
+ "apihelp-feedwatchlist-example-default": "نمایش خوراک فهرست پی‌گیری",
+ "apihelp-feedwatchlist-example-all6hrs": "همهٔ تغییرات ۶ ساعت گذشته در صفحه‌های پی‌گیری را نمایش دهید",
+ "apihelp-filerevert-summary": "واگردانی فایل به یک نسخه قدیمی",
+ "apihelp-filerevert-param-filename": "نام پروندهٔ مقصد، بدون پیشوند پرونده:.",
+ "apihelp-filerevert-param-comment": "ارسال دیدگاه.",
+ "apihelp-filerevert-param-archivename": "نام بایگانی بازبینی برای برگرداندن.",
+ "apihelp-filerevert-example-revert": "برگرداندن <kbd>Wiki.png</kbd> به نسخهٔ <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "راهنما برای پودمان‌های مشخص‌شده را نمایش دهید.",
+ "apihelp-help-param-helpformat": "قالب‌بندی خروجی راهنما.",
+ "apihelp-help-example-main": "راهنما برای پودمان اصلی",
+ "apihelp-help-example-recursive": "همهٔ راهنما در یک صفحه",
+ "apihelp-help-example-help": "راهنمایی برای خود راهنما.",
+ "apihelp-help-example-query": "راهنما برای دو زیرپودمانِ پرسمان",
+ "apihelp-imagerotate-summary": "چرخاندن یک یا چند تصویر",
+ "apihelp-imagerotate-param-rotation": "درجه برای چرخاندن تصویر در جهت ساعت‌گرد.",
+ "apihelp-imagerotate-example-simple": "چرخاندن <kbd>۹۰</kbd> درجه برای <kbd>File:Example.png</kbd>",
+ "apihelp-imagerotate-example-generator": "چرخاندن <kbd>۱۸۰</kbd> درجه برای همهٔ تصاویر موجود در <kbd>Category:Flip</kbd>",
+ "apihelp-import-param-summary": "خلاصه درون‌ریزی.",
+ "apihelp-import-param-xml": "پرونده XML بارگذاری شد.",
+ "apihelp-import-param-interwikisource": "برای درون‌ریز میان‌ویکی: ویکی برای درون‌ریزی از.",
+ "apihelp-import-param-interwikipage": "برای درون‌ریز میان‌ویکی: صفحه برای درون‌ریزی.",
+ "apihelp-import-param-fullhistory": "برای درون‌ریزی میان‌ویکی: درون‌ریزی تاریخچهٔ کامل، نه فقط نسخهٔ موجود.",
+ "apihelp-import-param-templates": "برای درون ریزی میان‌ویکی: همچنین درون‌ریزی الگوهای مورد استفاده.",
+ "apihelp-import-param-namespace": "درون ریزی به این فضای نام. نمی‌تواند همزمان با <var>$1rootpage</var> استفاده شود.",
+ "apihelp-import-param-rootpage": "درون‌ریزی به عنوان زیر صفحهٔ این صفحه. نمی‌تواند همزمان با <var>$1rootpage</var> استفاده شود.",
+ "apihelp-login-param-name": "نام کاربری.",
+ "apihelp-login-param-password": "گذرواژه.",
+ "apihelp-login-param-domain": "دامنه (اختیاری)",
+ "apihelp-login-param-token": "بلیط ورود به سامانه که در اولین درخواست دریافت شد.",
+ "apihelp-login-example-gettoken": "دریافت توکن ورود",
+ "apihelp-login-example-login": "ورود",
+ "apihelp-logout-summary": "خروج به همراه پاک نمودن اطلاعات این نشست",
+ "apihelp-logout-example-logout": "خروج کاربر فعلی",
+ "apihelp-mergehistory-summary": "ادغام تاریخچه صفحات",
+ "apihelp-move-summary": "انتقال صفحه",
+ "apihelp-move-param-to": "عنوانی که قصد دارید صفحه را به آن نام تغییر دهید.",
+ "apihelp-move-param-reason": "دلیل انتقال",
+ "apihelp-move-param-movetalk": "صفحهٔ بحث را تغییرنام دهید، اگر وجوددارد.",
+ "apihelp-move-param-movesubpages": "زیرصفحه را تغییرنام دهید، اگر شدنی است.",
+ "apihelp-move-param-noredirect": "عدم ساخت تغییرمسیر.",
+ "apihelp-move-param-watch": "صفحه و تغییرمسیر را به پی‌گیری کاربر کنونی بیافزایید.",
+ "apihelp-move-param-unwatch": "صفحه و تغییرمسیر را از پی‌گیری کاربر کنونی حذف کنید.",
+ "apihelp-move-param-ignorewarnings": "چشم‌پوشی از همهٔ هشدارها.",
+ "apihelp-opensearch-summary": "جستجو در ویکی بااستفاده از پروتکل اوپن‌سرچ.",
+ "apihelp-opensearch-param-search": "جستجوی رشته.",
+ "apihelp-opensearch-param-limit": "حداکثر تعداد نتایج برای بازگرداندن.",
+ "apihelp-opensearch-param-namespace": "فضاهای نامی برای جستجو",
+ "apihelp-opensearch-param-suggest": "کاری نکنید اگر <var>[[mw:Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> false است.",
+ "apihelp-opensearch-param-format": "فرمت خروجی.",
+ "apihelp-opensearch-example-te": "یافتن صفحه‌هایی که با <kbd>Te</kbd> آغاز می‌شوند",
+ "apihelp-options-param-reset": "ترجیحات را به مقادیر پیش فرض سایت بازمی گرداند.",
+ "apihelp-options-example-reset": "بازنشانی همه تنظیمات.",
+ "apihelp-paraminfo-param-helpformat": "ساختار راهنمای رشته‌ها",
+ "apihelp-parse-example-page": "تجزیه یک صفحه.",
+ "apihelp-parse-example-text": "تجزیه متن ویکی.",
+ "apihelp-parse-example-summary": "تجزیه خلاصه.",
+ "apihelp-patrol-summary": "گشت‌زنی یک صفحه یا نسخهٔ ویرایشی.",
+ "apihelp-patrol-example-rcid": "گشت‌زنی یک تغییر اخیر",
+ "apihelp-patrol-example-revid": "گشت‌زدن یک نسخه",
+ "apihelp-protect-summary": "تغییر سطح محافظت صفحه",
+ "apihelp-protect-param-reason": "دلیل برای (عدم) حفاظت.",
+ "apihelp-protect-example-protect": "محافظت از صفحه",
+ "apihelp-protect-example-unprotect": "خارج ساختن صفحه از حفاظت با تغییر سطح حفاظتی به <kbd>all</kbd>.",
+ "apihelp-protect-example-unprotect2": "خارج ساختن صفحه از حفاظت با قراردادن هیچ‌گونه محدودیت‌حفاظتی",
+ "apihelp-purge-param-forcelinkupdate": "به‌روزرسانی جداول پیوندها.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "جدول پیوندها را به‌روز رسانی کنید، و جدول‌های پیوندهای هر صفحه‌ای را که از این صفحه به عنوان الگو استفاده می‌کند به‌روز رسانی کنید.",
+ "apihelp-query-param-list": "کدام فهرست‌ها دریافت شود.",
+ "apihelp-query-param-meta": "کدام فراداده‌ها دریافت شود.",
+ "apihelp-query+allcategories-param-prefix": "عنوان همهٔ رده‌ها را که با این مقدار آغاز می‌شود جستجو کنید.",
+ "apihelp-query+allcategories-param-limit": "میزان رده‌ها برای بازگرداندن.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "نمی‌تواند همراه <var>$3user</var> به کار رود.",
+ "apihelp-query+allfileusages-param-limit": "تعداد آیتم‌ها برای بازگرداندن.",
+ "apihelp-query+allfileusages-param-dir": "جهتی که باید فهرست شود.",
+ "apihelp-query+allfileusages-example-unique": "فهرست پرونده‌های با عنوان یکتا",
+ "apihelp-query+allfileusages-example-unique-generator": "گرفتن عنوان همهٔ پرونده‌ها، برچسب زدن موارد گم شده",
+ "apihelp-query+allfileusages-example-generator": "گرفتن صفحاتی که دارای پرونده هستند",
+ "apihelp-query+allimages-summary": "متوالی شمردن همهٔ تصاویر.",
+ "apihelp-query+allimages-param-sort": "خصوصیت برای مرتب‌سازی بر پایه آن",
+ "apihelp-query+allimages-param-dir": "جهتی که باید فهرست شود.",
+ "apihelp-query+allimages-param-minsize": "محدودکردن به صفحه‌هایی که دست کم این تعداد بایت دارند.",
+ "apihelp-query+allimages-param-maxsize": "محدودکردن به صفحه‌هایی که حداکثر این تعداد بایت دارند.",
+ "apihelp-query+alllinks-param-namespace": "فضای نامی که باید شمرده شود.",
+ "apihelp-query+alllinks-param-limit": "تعداد آیتم‌ها برای بازگرداندن.",
+ "apihelp-query+alllinks-param-dir": "جهتی که باید فهرست شود.",
+ "apihelp-query+allpages-param-filterredir": "صفحه‌هایی که باید فهرست شوند.",
+ "apihelp-query+allpages-param-minsize": "محدودکردن به صفحه‌هایی که همراه دست کم این تعداد بایت است.",
+ "apihelp-query+allpages-param-limit": "میزان کل صفحه‌ها برای بازگرداندن.",
+ "apihelp-query+allredirects-param-limit": "تعداد آیتم‌ها برای بازگرداندن.",
+ "apihelp-query+allrevisions-summary": "فهرست همه نسخه‌ها",
+ "apihelp-query+mystashedfiles-param-limit": "تعداد پرونده‌هایی که باید بگیرد.",
+ "apihelp-query+allusers-param-dir": "جهتی که باید مرتب شود.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "فهرست دسترسی‌هایی که کاربر دارد.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "شمار ويرایش کاربر را می‌افزايد",
+ "apihelp-query+allusers-paramvalue-prop-registration": "زمان ثبت نام کاربر را در صورت وجود می‌افزايد (ممکن است خالی باشد)",
+ "apihelp-query+allusers-param-limit": "تعداد کل نام‌های کاربری برای بازگرداندن.",
+ "apihelp-query+allusers-param-witheditsonly": "فقط کاربرانی را که ويرایش داشته اند ليست کن",
+ "apihelp-query+allusers-param-activeusers": "فقط کاربرانی را ليست کن که در $1 روز گذشته فعاليت داشته‌اند",
+ "apihelp-query+authmanagerinfo-summary": "بازیابی اطلاعات در مورد وضعيت فعلی احراز هويت",
+ "apihelp-query+backlinks-example-simple": "نمایش پیوندها به <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-example-simple": "فهرست بسته‌شده‌ها",
+ "apihelp-query+categories-param-show": "کدام نوع رده‌ها نمایش داده‌شود.",
+ "apihelp-query+categories-param-limit": "چه میزان رده بازگردانده شود.",
+ "apihelp-query+categories-param-categories": "فقط این رده‌ها فهرست شود. کاربردی برای بررسی وجود یک صفحهٔ مشخص در یک ردهٔ مشخص.",
+ "apihelp-query+categorymembers-summary": "فهرست‌کردن همهٔ صفحه‌ها در یک ردهٔ مشخص‌شده.",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "افزودن شناسه صفحه",
+ "apihelp-query+categorymembers-param-sort": "خصوصیت برای مرتب‌سازی",
+ "apihelp-query+categorymembers-param-dir": "جهت مرتب شدن",
+ "apihelp-query+categorymembers-param-startsortkey": "جایش از $1starthexsortkey استفاده کنید.",
+ "apihelp-query+deletedrevs-param-from": "شروع فهرست کردن مواردی که این عنوان را دارند.",
+ "apihelp-query+deletedrevs-param-to": "خاتمه فهرست کردن مواردی که این عنوان را دارند.",
+ "apihelp-query+deletedrevs-param-namespace": "فقط صفحات ین فضای نام را فهرست کن.",
+ "apihelp-query+deletedrevs-param-limit": "حداکثر تعداد بازنگری‌هايي که فهرست شوند.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "عنوان هر صفحه.",
+ "apihelp-query+fileusage-param-limit": "تعدادی که باید بازگردانده شود.",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "نام مستعار برای size",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "افزودن SHA-1 hash برای پرونده",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "افزودن نوع MIME برای پرونده",
+ "apihelp-query+imageinfo-param-end": "زمان توقف فهرست کردن.",
+ "apihelp-query+imageinfo-param-urlheight": "مشابه $1urlwidth.",
+ "apihelp-query+images-param-limit": "تعداد پرونده‌هایی که باید بازگرداند.",
+ "apihelp-query+info-summary": "دریافت اطلاعات سادهٔ صفحه.",
+ "apihelp-query+iwbacklinks-param-prefix": "پیشوند میان‌ویکی.",
+ "apihelp-query+iwbacklinks-param-title": "پیوند میان‌ویکی برای جستجو. باید همراه <var>$1blprefix</var> استفاده شود.",
+ "apihelp-query+iwbacklinks-param-limit": "تعداد صفحه‌ها برای بازگرداندن.",
+ "apihelp-query+iwbacklinks-param-prop": "خصوصیتی که باید گرفته شود.",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "افزودن نشانی اینترنتی کامل.",
+ "apihelp-query+langbacklinks-param-prop": "خصوصیتی که باید گرفته شود:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "افزودن نشانی اینترنتی کامل.",
+ "apihelp-query+links-param-limit": "تعداد پیوندهایی که باید بازگرداند.",
+ "apihelp-query+linkshere-param-prop": "خصوصیتی که باید گرفته شود:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "شناسه صفحه هر صفحه.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "عنوان هر صفحه.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "اگر صفحه تغییر مسیر بود برچسب بزن.",
+ "apihelp-query+linkshere-param-namespace": "فقط صفحات این فضای نام را فهرست کن.",
+ "apihelp-query+linkshere-param-limit": "تعداد برای بازگرداندن.",
+ "apihelp-query+logevents-summary": "دریافت رویدادها از سیاهه‌ها.",
+ "apihelp-query+logevents-param-prop": "خصوصیتی که باید گرفته شود.",
+ "apihelp-query+logevents-paramvalue-prop-ids": "افزودن شناسه سیاهه رویداد.",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "افزودن شناسه صفحه",
+ "apihelp-query+pageswithprop-param-dir": "جهت مرتب شدن",
+ "apihelp-query+prefixsearch-param-search": "جستجوی رشته",
+ "apihelp-query+prefixsearch-param-namespace": "فضاهای نامی برای جستجو",
+ "apihelp-query+prefixsearch-param-limit": "حداکثر تعداد نتایج برای بازگرداندن.",
+ "apihelp-query+prefixsearch-param-offset": "تعداد نتایج برای رها کردن.",
+ "apihelp-query+protectedtitles-param-namespace": "فقط عنوان‌ها در این فضاهای نام را فهرست کنید.",
+ "apihelp-query+protectedtitles-param-level": "فقط عنوان‌ها در این سطح‌های حفاظت را فهرست کنید.",
+ "apihelp-query+protectedtitles-param-limit": "تعداد صفحه‌ها برای بازگرداندن.",
+ "apihelp-query+protectedtitles-param-start": "آغاز فهرست‌کردن از این برچسب زمانی حفاظت.",
+ "apihelp-query+protectedtitles-param-end": "متوقف‌کردن فهرست‌کردن در این برچسب زمانی حفاظت.",
+ "apihelp-query+random-param-namespace": "بازگرداندن صفحه‌های فقط در این فضاهای نام.",
+ "apihelp-query+random-param-limit": "محدود کنید چه تعداد صفحه بازگردانده خواهد شد.",
+ "apihelp-query+random-param-redirect": "از <kbd>$1filterredir=redirects</kbd> استفاده کنید.",
+ "apihelp-query+random-example-simple": "بازگرداندن تو صفحهٔ تصادفی از فضای نام اصلی",
+ "apihelp-query+random-example-generator": "بازگرداندن اطلاعات صفحه دربارهٔ دو صفحهٔ تصادفی از فضای نام اصلی",
+ "apihelp-query+recentchanges-param-start": "برچسب زمانی برای آغاز شمارش از.",
+ "apihelp-query+recentchanges-param-end": "برچسب زمانی برای پایان شمارش.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "افزودن برچسب برای ویرایش.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "افزودن زمان ویرایش.",
+ "apihelp-query+redirects-paramvalue-prop-title": "عنوان هر تغییرمسیر.",
+ "apihelp-query+redirects-param-limit": "تعداد تغییرمسیرها برای بازگرداندن.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "متن نسخه ویرایش.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "برچسب برای نسخه‌های ویرایش.",
+ "apihelp-query+siteinfo-param-prop": "اطلاعاتی که باید گرفته‌شود:",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "بازرگرداندن آمار سایت.",
+ "apihelp-query+siteinfo-example-simple": "دریافت اطلاعات سایت.",
+ "apihelp-query+tags-summary": "فهرست تغییرات برچسب‌ها.",
+ "apihelp-query+tags-param-limit": "حداکثر تعداد برچسب‌ها برای فهرست شدن.",
+ "apihelp-query+tags-param-prop": "خصوصیتی که باید گرفته شود:",
+ "apihelp-query+tags-paramvalue-prop-name": "افزودن نام برچسب.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "عنوان هر صفحه.",
+ "apihelp-query+watchlist-paramvalue-type-log": "مدخل‌های سیاهه.",
+ "apihelp-stashedit-param-text": "محتوای صفحه.",
+ "apihelp-stashedit-param-contentmodel": "مدل محتوایی محتوای جدید",
+ "apihelp-stashedit-param-summary": "خلاصه تغییرات.",
+ "apihelp-tag-param-reason": "دلیل تغییر.",
+ "apihelp-unblock-summary": "بازکردن کاربر.",
+ "apihelp-undelete-param-reason": "دلیل احیا.",
+ "apihelp-upload-param-filename": "نام پرونده مقصد.",
+ "apihelp-upload-param-ignorewarnings": "چشم‌پوشی از همهٔ هشدارها.",
+ "apihelp-upload-param-file": "محتوی پرونده.",
+ "apihelp-upload-param-url": "نشانی اینترنتی برای دریافت فایل.",
+ "apihelp-userrights-param-user": "نام کاربری.",
+ "apihelp-userrights-param-userid": "شناسه کاربر.",
+ "apihelp-userrights-param-reason": "دلیل تغییر.",
+ "apihelp-none-summary": "بیرون‌ریزی هیچ.",
+ "api-format-title": "نتیجه ای‌پی‌آی مدیاویکی",
+ "api-help-main-header": "پودمان اصلی",
+ "api-help-source": "منبع: $1",
+ "api-help-param-deprecated": "توصیه.",
+ "api-help-param-limit": "بيش از $1 مجاز نيست",
+ "api-help-param-limit2": "بيش از $1 (برای ربات‌ها $2) مجاز نيست",
+ "api-help-param-default": "پیش‌فرض: $1",
+ "apierror-timeout": "کارساز در زمان انتظار هیچ پاسخی نداد.",
+ "api-credits-header": "اعتبار"
+}
diff --git a/www/wiki/includes/api/i18n/fi.json b/www/wiki/includes/api/i18n/fi.json
new file mode 100644
index 00000000..a21afb05
--- /dev/null
+++ b/www/wiki/includes/api/i18n/fi.json
@@ -0,0 +1,99 @@
+{
+ "@metadata": {
+ "authors": [
+ "Nike",
+ "MrTapsa",
+ "Pitke",
+ "Stryn",
+ "Jaakkoh",
+ "01miki10",
+ "Silvonen"
+ ]
+ },
+ "apihelp-main-param-action": "Mikä toiminto suoritetaan.",
+ "apihelp-main-param-curtimestamp": "Sisällytä nykyinen aikaleima tulokseen.",
+ "apihelp-block-summary": "Estä käyttäjä.",
+ "apihelp-block-param-user": "Käyttäjä, IP-osoite tai IP-osoitealue, joka estetään.",
+ "apihelp-block-param-expiry": "Päättymisaika. Voi olla suhteellinen (esim. <kbd>5 months</kbd> tai <kbd>2 weeks</kbd>) tai absoluuttinen (esim. <kbd>2014-09-18T12:34:56Z</kbd>). Jos asetetaan <kbd>infinite</kbd>, <kbd>indefinite</kbd> tai <kbd>never</kbd>, esto ei pääty koskaan.",
+ "apihelp-block-param-reason": "Eston syy.",
+ "apihelp-block-param-anononly": "Estä vain anonyymit käytäjät (ts. estä anonyymit muokkaukset tästä IP-osoitteesta)",
+ "apihelp-block-param-nocreate": "Estä tunnusten luonti.",
+ "apihelp-block-param-autoblock": "Estä automaattisesti viimeksi käytetty IP-osoite, ja ne osoitteet, joista hän yrittää kirjautua sisään.",
+ "apihelp-block-param-noemail": "Estä käyttäjää lähettämästä sähköpostia wikin kautta. (Vaatii oikeuden <code>blockemail</code>.)",
+ "apihelp-block-param-hidename": "Piilota käyttäjänimi estolokista. (Vaatii oikeuden <code>hideuser</code>.)",
+ "apihelp-block-param-allowusertalk": "Salli käyttäjän muokata omaa keskustelusivuaan (riippuu asetuksesta <var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Jos käyttäjä on jo estetty, korvaa nykyinen esto.",
+ "apihelp-block-param-watchuser": "Tarkkaile käyttäjän tai IP-osoitteen käyttäjä- ja keskustelusivuja.",
+ "apihelp-block-example-ip-simple": "Estä IP-osoite <kbd>192.0.2.5</kbd> kolmeksi päiväksi syystä <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Estä käyttäjä <kbd>Vandal</kbd> ikuisesti syystä <kbd>Vandalism</kbd>, sekä estä uusien käyttäjien luonti ja sähköpostin lähetys.",
+ "apihelp-compare-param-fromtitle": "Ensimmäinen vertailtava otsikko.",
+ "apihelp-createaccount-summary": "Luo uusi käyttäjätunnus.",
+ "apihelp-createaccount-param-name": "Käyttäjätunnus.",
+ "apihelp-createaccount-param-email": "Käyttäjän sähköpostiosoite (valinnainen).",
+ "apihelp-createaccount-param-realname": "Käyttäjän oikea nimi (valinnainen).",
+ "apihelp-createaccount-example-pass": "Luo käyttäjä <kbd>testuser</kbd> salasanalla <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Luo käyttäjä <kbd>testmailuset</kbd> ja lähetä sähköpostilla satunnaisesti luotu salasana.",
+ "apihelp-delete-summary": "Poista sivu.",
+ "apihelp-delete-param-watch": "Lisää sivu nykyisen käyttäjän tarkkailulistalle.",
+ "apihelp-delete-param-unwatch": "Poista sivu nykyisen käyttäjän tarkkailulistalta.",
+ "apihelp-delete-example-simple": "Poista <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Poista <kbd>Main Page</kbd> syystä <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Tämä moduuli on poistettu käytöstä.",
+ "apihelp-edit-summary": "Luo ja muokkaa sivuja.",
+ "apihelp-edit-param-text": "Sivun sisältö.",
+ "apihelp-edit-param-minor": "Pieni muokkaus.",
+ "apihelp-edit-param-notminor": "Ei-pieni muokkaus.",
+ "apihelp-edit-param-bot": "Merkitse tämä muokkaus bottimuokkaukseksi.",
+ "apihelp-edit-param-createonly": "Älä muokkaa sivua, jos se on jo olemassa.",
+ "apihelp-edit-param-watch": "Lisää sivu nykyisen käyttäjän tarkkailulistalle.",
+ "apihelp-edit-param-unwatch": "Poista sivu nykyisen käyttäjän tarkkailulistalta.",
+ "apihelp-edit-example-edit": "Muokkaa sivua.",
+ "apihelp-emailuser-summary": "Lähetä sähköpostia käyttäjälle.",
+ "apihelp-emailuser-param-target": "Käyttäjä, jolle lähetetään sähköpostia.",
+ "apihelp-emailuser-param-subject": "Otsikko.",
+ "apihelp-emailuser-param-text": "Sähköpostin sisältö.",
+ "apihelp-emailuser-param-ccme": "Lähetä kopio tästä viestistä minulle.",
+ "apihelp-emailuser-example-email": "Lähetä käyttäjälle <kbd>WikiSysop</kbd> sähköposti, jossa lukee <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Laajentaa kaikki wikitekstin mallineet.",
+ "apihelp-expandtemplates-param-title": "Sivun otsikko.",
+ "apihelp-expandtemplates-param-text": "Muunnettava wikiteksti.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Laajennettu wikiteksti.",
+ "apihelp-feedcontributions-param-year": "Alkaen vuodesta (ja aiemmin).",
+ "apihelp-feedcontributions-param-month": "Alkaen kuukaudesta (ja aiemmin).",
+ "apihelp-feedcontributions-param-tagfilter": "Suodata muokkaukset, joissa on nämä merkkaukset.",
+ "apihelp-feedcontributions-param-deletedonly": "Näytä vain poistetut muokkaukset.",
+ "apihelp-feedcontributions-param-toponly": "Näytä vain muokkaukset, jotka ovat viimeisimpiä versioita.",
+ "apihelp-feedcontributions-param-newonly": "Näytä vain muokkaukset, joilla on luotu sivu.",
+ "apihelp-feedrecentchanges-param-limit": "Kerralla näytettävien tulosten enimmäismäärä.",
+ "apihelp-feedrecentchanges-param-hideminor": "Piilota pienet muutokset.",
+ "apihelp-feedrecentchanges-param-hidebots": "Piilota bottien tekemät muutokset.",
+ "apihelp-feedrecentchanges-param-hideanons": "Piilota kirjautumattomien käyttäjien tekemät muutokset.",
+ "apihelp-feedrecentchanges-param-hideliu": "Piilota rekisteröityneiden käyttäjien tekemät muutokset.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Piilota tarkastetut muutokset.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Piilota nykyisen käyttäjän tekemät muutokset.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Suodata merkkauksen mukaan.",
+ "apihelp-feedrecentchanges-example-simple": "Näytä tuoreet muutokset.",
+ "apihelp-filerevert-param-filename": "Kohteen nimi ilman File:-etuliitettä.",
+ "apihelp-filerevert-param-comment": "Tallennuksen kommentti.",
+ "apihelp-imagerotate-summary": "Käännä kuva tai kuvia.",
+ "apihelp-imagerotate-example-simple": "Käännä kuvaa <kbd>File:Example.png</kbd> <kbd>90</kbd> astetta.",
+ "apihelp-imagerotate-example-generator": "Käännä kaikkia kuvia luokassa <kbd>Category:Flip</kbd> <kbd>180</kbd> astetta.",
+ "apihelp-login-param-name": "Käyttäjänimi.",
+ "apihelp-login-param-password": "Salasana.",
+ "apihelp-login-example-login": "Kirjaudu sisään.",
+ "apihelp-logout-summary": "Kirjaudu ulos ja tyhjennä istunnon tiedot.",
+ "apihelp-logout-example-logout": "Kirjaa nykyinen käyttäjä ulos.",
+ "apihelp-managetags-example-create": "Luo merkkaus nimeltä <kbd>spam</kbd> syystä <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Poista merkkaus <kbd>vandlaism</kbd> syystä <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Ota käyttöön merkkaus nimeltä <kbd>spam</kbd> syystä <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Poista käytöstä merkkaus nimeltä <kbd>spam</kbd> syystä <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Yhdistä sivujen muutoshistoriat.",
+ "apihelp-move-summary": "Siirrä sivu.",
+ "apihelp-move-param-noredirect": "Älä luo ohjausta.",
+ "apihelp-move-param-watch": "Lisää sivu ja ohjaus nykyisen käyttäjän tarkkailulistalle.",
+ "apihelp-move-param-unwatch": "Poista sivu ja ohjaus nykyisen käyttäjän tarkkailulistalta.",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Lisää linkin otsikon.",
+ "apihelp-query+linkshere-param-show": "Näytä vain kohteet, jotka täyttävät nämä kriteerit:\n;redirect:Näytä vain uudelleenohjaukset.\n;!redirect:Näytä vain ei-uudelleenohjaukset",
+ "apihelp-tag-example-rev": "Lisää tunniste <kbd>vandalism</kbd> versioon 123 antamatta perustelua.",
+ "apihelp-upload-param-stash": "Mikäli valittu, palvelin säilöö tiedoston väliaikaisesti tallentamisen sijaan."
+}
diff --git a/www/wiki/includes/api/i18n/fo.json b/www/wiki/includes/api/i18n/fo.json
new file mode 100644
index 00000000..1470458a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/fo.json
@@ -0,0 +1,39 @@
+{
+ "@metadata": {
+ "authors": [
+ "EileenSanda"
+ ]
+ },
+ "apihelp-block-summary": "Sperra ein brúkara.",
+ "apihelp-block-param-user": "Brúkaranavn, IP adressa ella IP interval ið tú ynskir at sperra.",
+ "apihelp-block-param-expiry": "Lokadagur. Kann vera relativt (t.d. <kbd>5 months</kbd> ella <kbd>2 weeks</kbd>) ella absolutt (t.d. <kbd>2014-09-18T12:34:56Z</kbd>). Um ásett til <kbd>infinite</kbd>, <kbd>indefinite</kbd>, ella <kbd>never</kbd>, so gongur sperringin aldri út.",
+ "apihelp-block-param-reason": "Orsøk til sperring.",
+ "apihelp-block-param-anononly": "Sperra bara dulnevndir brúkarar (t.d. ger rættingar frá dulnendum óvirknar fyri hesa IP adressuna).",
+ "apihelp-block-param-nocreate": "Forða fyri upprættan av konto.",
+ "apihelp-block-param-autoblock": "Sperrað sjálvvirkandi tað seinastu IP adressuna og allar fylgjandi IP adressur, sum viðkomandi roynir at rætta/skriva frá.",
+ "apihelp-block-param-noemail": "Forða brúkaranum í at senda teldupost gjøgnum wikiina. (Krevur <code>blockemail</code> rættindini).",
+ "apihelp-block-param-hidename": "Fjal brúkaranavnið frá sperringarlogginum. (Krevur <code>hideuser</code> rættindi).",
+ "apihelp-block-param-allowusertalk": "Loyv brúkaranum at skriva á sína egnu síðu (avhongur av <var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Um brúkarin longu er sperraður, yvirskriva so tað verandi sperringina.",
+ "apihelp-block-example-ip-simple": "Sperra IP adressuna <kbd>192.0.2.5</kbd> í tríggjar dagar við orsøkini <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Sperra brúkara <kbd>Vandal</kbd> í óvissa tíð við orsøkini <kbd>Vandalism</kbd>, og forða fyri upprættan av nýggjum kontum og at senda teldupost.",
+ "apihelp-createaccount-summary": "Upprætta eina nýggja brúkarakonto.",
+ "apihelp-createaccount-param-name": "Brúkaranavn.",
+ "apihelp-createaccount-param-password": "Loyniorð (síggj burtur frá <var>$1mailpassword</var> um er upplýst).",
+ "apihelp-createaccount-param-email": "Teldupostadressan hjá brúkaranum (valfrítt).",
+ "apihelp-createaccount-param-realname": "Veruliga navnið hjá brúkaranum (valfrítt).",
+ "apihelp-createaccount-example-pass": "Upprætta brúkara <kbd>testuser</kbd> við loyniorðinum <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Upprætta brúkaran <kbd>testmailuser</kbd> og send eitt tilvildarliga stovnað loyniorð við telduposti.",
+ "apihelp-delete-summary": "Strika eina síðu.",
+ "apihelp-edit-example-edit": "Rætta eina síðu.",
+ "apihelp-emailuser-summary": "Send t-post til ein brúkara.",
+ "apihelp-emailuser-param-subject": "Evni teigur.",
+ "apihelp-emailuser-param-text": "Innihaldið í teldubrævinum.",
+ "apihelp-emailuser-param-ccme": "Send mær eitt avrit av hesum telduposti.",
+ "apihelp-emailuser-example-email": "Send ein teldupost til brúkaran <kbd>WikiSysop</kbd> við tekstinum <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Víðkar allar fyrimyndir í wikitekstinum.",
+ "apihelp-expandtemplates-param-title": "Heiti á síðuni.",
+ "apihelp-login-param-name": "Brúkaranavn.",
+ "apihelp-login-param-password": "Loyniorð.",
+ "apihelp-move-summary": "Flyt eina síðu."
+}
diff --git a/www/wiki/includes/api/i18n/fr.json b/www/wiki/includes/api/i18n/fr.json
new file mode 100644
index 00000000..bd9ebcf9
--- /dev/null
+++ b/www/wiki/includes/api/i18n/fr.json
@@ -0,0 +1,1766 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gomoko",
+ "Windes",
+ "Orlodrim",
+ "McDutchie",
+ "Element303",
+ "Macofe",
+ "Linedwell",
+ "Nicolapps",
+ "Raulel",
+ "Arkanosis",
+ "Ltrlg",
+ "Crochet.david",
+ "0x010C",
+ "Lucky",
+ "Freak2fast4u",
+ "Urhixidur",
+ "Wladek92",
+ "Ash Crow",
+ "L",
+ "Elfix",
+ "Lbayle",
+ "Verdy p",
+ "Yasten",
+ "Trial",
+ "Pols12",
+ "The RedBurn",
+ "Umherirrender",
+ "Thibaut120094"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Liste de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annonces de l’API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bogues et demandes]\n</div>\n<strong>État :</strong> Toutes les fonctionnalités affichées sur cette page devraient fonctionner, mais l’API est encore en cours de développement et peut changer à tout moment. Inscrivez-vous à [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la liste de diffusion mediawiki-api-announce] pour être informé des mises à jour.\n\n<strong>Requêtes erronées :</strong> Si des requêtes erronées sont envoyées à l’API, un entête HTTP sera renvoyé avec la clé « MediaWiki-API-Error ». La valeur de cet entête et le code d’erreur renvoyé prendront la même valeur. Pour plus d’information, voyez [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n<strong>Test :</strong> Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Quelle action effectuer.",
+ "apihelp-main-param-format": "Le format de sortie.",
+ "apihelp-main-param-maxlag": "La latence maximale peut être utilisée quand MédiaWiki est installé sur un cluster de base de données répliqué. Pour éviter des actions provoquant un supplément de latence de réplication de site, ce paramètre peut faire attendre le client jusqu’à ce que la latence de réplication soit inférieure à une valeur spécifiée. En cas de latence excessive, le code d’erreur <samp>maxlag</samp> est renvoyé avec un message tel que <samp>Attente de $host : $lag secondes de délai</samp>.<br />Voyez [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manuel: Maxlag parameter]] pour plus d’information.",
+ "apihelp-main-param-smaxage": "Fixer l’entête HTTP de contrôle de cache <code>s-maxage</code> à ce nombre de secondes. Les erreurs ne sont jamais mises en cache.",
+ "apihelp-main-param-maxage": "Fixer l’entête HTTP de contrôle de cache <code>max-age</code> à ce nombre de secondes. Les erreurs ne sont jamais mises en cache.",
+ "apihelp-main-param-assert": "Vérifier si l’utilisateur est connecté si la valeur est <kbd>user</kbd>, ou s’il a le droit d’un utilisateur robot si la valeur est <kbd>bot</kbd><!-- {{int:group-bot}} ? -->.",
+ "apihelp-main-param-assertuser": "Vérifier que l’utilisateur actuel est l’utilisateur nommé.",
+ "apihelp-main-param-requestid": "Toute valeur fournie ici sera incluse dans la réponse. Peut être utilisé pour distinguer des demandes.",
+ "apihelp-main-param-servedby": "Inclure le nom d’hôte qui a renvoyé la requête dans les résultats.",
+ "apihelp-main-param-curtimestamp": "Inclure l’horodatage actuel dans le résultat.",
+ "apihelp-main-param-responselanginfo": "Inclure les langues utilisées pour <var>uselang</var> et <var>errorlang</var> dans le résultat.",
+ "apihelp-main-param-origin": "En accédant à l’API en utilisant une requête AJAX inter-domaines (CORS), mettre le domaine d’origine dans ce paramètre. Il doit être inclus dans toute requête de pre-flight, et doit donc faire partie de l’URI de la requête (pas du corps du POST).\n\nPour les requêtes authentifiées, il doit correspondre exactement à une des origines dans l’entête <code>Origin</code> header, donc il doit être fixé avec quelque chose comme <kbd>https://en.wikipedia.org</kbd> ou <kbd>https://meta.wikimedia.org</kbd>. Si ce paramètre ne correspond pas à l’entête <code>Origin</code>, une réponse 403 sera renvoyée. Si ce paramètre correspond à l’entête <code>Origin</code> et que l’origine est en liste blanche, des entêtes <code>Access-Control-Allow-Origin</code> et <code>Access-Control-Allow-Credentials</code> seront positionnés.\n\nPour les requêtes non authentifiées, spécifiez la valeur <kbd>*</kbd>. Cela positionnera l’entête <code>Access-Control-Allow-Origin</code>, mais <code>Access-Control-Allow-Credentials</code> vaudra <code>false</code> et toutes les données spécifiques à l’utilisateur seront filtrées.",
+ "apihelp-main-param-uselang": "Langue à utiliser pour les traductions de message. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> avec <kbd>siprop=languages</kbd> renvoie une liste de codes de langue, ou en spécifiant <kbd>user</kbd> pour utiliser la préférence de langue de l’utilisateur actuel, ou en spécifiant <kbd>content</kbd> pour utiliser le langage du contenu de ce wiki.",
+ "apihelp-main-param-errorformat": "Format à utiliser pour la sortie du texte d’avertissement et d’erreur.\n; plaintext: Wikitexte avec balises HTML supprimées et les entités remplacées.\n; wikitext: wikitexte non analysé.\n; html: HTML.\n; raw: Clé de message et paramètres.\n; none: Aucune sortie de texte, uniquement les codes erreur.\n; bc: Format utilisé avant MédiaWiki 1.29. <var>errorlang</var> et <var>errorsuselocal</var> sont ignorés.",
+ "apihelp-main-param-errorlang": "Langue à utiliser pour les avertissements et les erreurs. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> avec <kbd>siprop=languages</kbd> renvoyant une liste de codes de langue, ou spécifier <kbd>content</kbd> pour utiliser la langue du contenu de ce wiki, ou spécifier <kbd>uselang</kbd> pour utiliser la même valeur que le paramètre <var>uselang</var>.",
+ "apihelp-main-param-errorsuselocal": "S’il est fourni, les textes d’erreur utiliseront des messages adaptés à la langue dans l’espace de noms {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "Bloquer un utilisateur.",
+ "apihelp-block-param-user": "Nom d’utilisateur, adresse IP ou plage d’adresses IP que vous voulez bloquer. Ne peut pas être utilisé en même temps que <var>$1userid</var>",
+ "apihelp-block-param-userid": "ID d'utilisateur à bloquer. Ne peut pas être utilisé avec <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Durée d’expiration. Peut être relative (par ex. <kbd>5 months</kbd> ou <kbd>2 weeks</kbd>) ou absolue (par ex. <kbd>2014-09-18T12:34:56Z</kbd>). Si elle est mise à <kbd>infinite</kbd>, <kbd>indefinite</kbd> ou <kbd>never</kbd>, le blocage n’expirera jamais.",
+ "apihelp-block-param-reason": "Motif du blocage.",
+ "apihelp-block-param-anononly": "Bloquer uniquement les utilisateurs anonymes (c’est-à-dire désactiver les modifications anonymes pour cette adresse IP).",
+ "apihelp-block-param-nocreate": "Empêcher la création de compte.",
+ "apihelp-block-param-autoblock": "Bloquer automatiquement la dernière adresse IP utilisée, et toute les adresses IP subséquentes depuis lesquelles ils ont essayé de se connecter.",
+ "apihelp-block-param-noemail": "Empêcher l’utilisateur d’envoyer des courriels via le wiki (nécessite le droit <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Masque le nom de l’utilisateur dans le journal des blocages (nécessite le droit <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Autoriser les utilisateurs à modifier leur propre page de discussion (dépend de <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Si l’utilisateur est déjà bloqué, écraser le blocage existant.",
+ "apihelp-block-param-watchuser": "Surveiller les pages utilisateur et de discussion de l’utilisateur ou de l’adresse IP.",
+ "apihelp-block-param-tags": "Modifier les balises à appliquer à l’entrée du journal des blocages.",
+ "apihelp-block-example-ip-simple": "Bloquer l’adresse IP <kbd>192.0.2.5</kbd> pour trois jours avec le motif <kbd>Premier avertissement</kbd>.",
+ "apihelp-block-example-user-complex": "Bloquer indéfiniment l’utilisateur <kbd>Vandal</kbd> avec le motif <kbd>Vandalism</kbd>, et empêcher la création de nouveau compte et l'envoi de courriel.",
+ "apihelp-changeauthenticationdata-summary": "Modifier les données d’authentification pour l’utilisateur actuel.",
+ "apihelp-changeauthenticationdata-example-password": "Tentative de modification du mot de passe de l’utilisateur actuel en <kbd>ExempleMotDePasse</kbd>.",
+ "apihelp-checktoken-summary": "Vérifier la validité d'un jeton de <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Type de jeton testé",
+ "apihelp-checktoken-param-token": "Jeton à tester.",
+ "apihelp-checktoken-param-maxtokenage": "Temps maximum autorisé pour l'utilisation du jeton, en secondes",
+ "apihelp-checktoken-example-simple": "Tester la validité d'un jeton de <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Efface le drapeau <code>hasmsg</code> pour l’utilisateur courant.",
+ "apihelp-clearhasmsg-example-1": "Effacer le drapeau <code>hasmsg</code> pour l’utilisateur courant",
+ "apihelp-clientlogin-summary": "Se connecter au wiki en utilisant le flux interactif.",
+ "apihelp-clientlogin-example-login": "Commencer le processus de connexion au wiki en tant qu’utilisateur <kbd>Exemple</kbd> avec le mot de passe <kbd>ExempleMotDePasse</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continuer la connexion après une réponse de l’<samp>IHM</samp> pour l’authentification à deux facteurs, en fournissant un <var>OATHToken</var> valant <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Obtenir la différence entre deux pages.",
+ "apihelp-compare-extended-description": "Vous devez passer un numéro de révision, un titre de page, ou un ID de page, à la fois pour « from » et « to ».",
+ "apihelp-compare-param-fromtitle": "Premier titre à comparer.",
+ "apihelp-compare-param-fromid": "ID de la première page à comparer.",
+ "apihelp-compare-param-fromrev": "Première révision à comparer.",
+ "apihelp-compare-param-fromtext": "Utiliser ce texte au lieu du contenu de la révision spécifié par <var>fromtitle</var>, <var>fromid</var> ou <var>fromrev</var>.",
+ "apihelp-compare-param-frompst": "Faire une transformation avant enregistrement sur <var>fromtext</var>.",
+ "apihelp-compare-param-fromcontentmodel": "Modèle de contenu de <var>fromtext</var>. Si non fourni, il sera deviné d’après les autres paramètres.",
+ "apihelp-compare-param-fromcontentformat": "Sérialisation du contenu de <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "Second titre à comparer.",
+ "apihelp-compare-param-toid": "ID de la seconde page à comparer.",
+ "apihelp-compare-param-torev": "Seconde révision à comparer.",
+ "apihelp-compare-param-torelative": "Utiliser une révision relative à la révision déterminée de <var>fromtitle</var>, <var>fromid</var> ou <var>fromrev</var>. Toutes les autres options 'to' seront ignorées.",
+ "apihelp-compare-param-totext": "Utiliser ce texte au lieu du contenu de la révision spécifié par <var>totitle</var>, <var>toid</var> ou <var>torev</var>.",
+ "apihelp-compare-param-topst": "Faire une transformation avant enregistrement sur <var>totext</var>.",
+ "apihelp-compare-param-tocontentmodel": "Modèle de contenu de <var>totext</var>. Si non fourni, il sera deviné d’après les autres paramètres.",
+ "apihelp-compare-param-tocontentformat": "Sérialisation du contenu de <var>totext</var>.",
+ "apihelp-compare-param-prop": "Quelles informations obtenir.",
+ "apihelp-compare-paramvalue-prop-diff": "Le diff HTML.",
+ "apihelp-compare-paramvalue-prop-diffsize": "La taille du diff HTML en octets.",
+ "apihelp-compare-paramvalue-prop-rel": "L’ID des révisions précédant 'depuis' et 'vers', s’il y en a.",
+ "apihelp-compare-paramvalue-prop-ids": "L’ID de page et de révision des révisions 'depuis' et 'vers'.",
+ "apihelp-compare-paramvalue-prop-title": "Le titre de page des révisions 'depuis' et 'vers'.",
+ "apihelp-compare-paramvalue-prop-user": "Le nom et l’ID d’utilisateur des révisions 'depuis' et 'vers'.",
+ "apihelp-compare-paramvalue-prop-comment": "Le commentaire des révisions 'depuis' et 'vers'.",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "Le commentaire analysé des révisions 'depuis' et 'vers'.",
+ "apihelp-compare-paramvalue-prop-size": "La taille des révisions 'depuis' et 'vers'.",
+ "apihelp-compare-example-1": "Créer une différence entre les révisions 1 et 2",
+ "apihelp-createaccount-summary": "Créer un nouveau compte utilisateur.",
+ "apihelp-createaccount-param-preservestate": "Si <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> a retourné true pour <samp>hasprimarypreservedstate</samp>, les demandes marquées comme <samp>primary-required</samp> doivent être omises. Si elle a retourné une valeur non vide pour <samp>preservedusername</samp>, ce nom d'utilisateur doit être utilisé pour le paramètre <var>username</var>.",
+ "apihelp-createaccount-example-create": "Commencer le processus de création d’un utilisateur <kbd>Exemple</kbd> avec le mot de passe <kbd>ExempleMotDePasse</kbd>.",
+ "apihelp-createaccount-param-name": "Nom d’utilisateur.",
+ "apihelp-createaccount-param-password": "Mot de passe (ignoré si <var>$1mailpassword</var> est défini).",
+ "apihelp-createaccount-param-domain": "Domaine pour l’authentification externe (facultatif).",
+ "apihelp-createaccount-param-token": "Jeton de création de compte obtenu à la première requête.",
+ "apihelp-createaccount-param-email": "Adresse courriel de l’utilisateur (facultatif).",
+ "apihelp-createaccount-param-realname": "Vrai nom de l’utilisateur (facultatif).",
+ "apihelp-createaccount-param-mailpassword": "S’il est fixé à une valeur quelconque, un mot de passe aléatoire sera envoyé par courriel à l’utilisateur.",
+ "apihelp-createaccount-param-reason": "Motif facultatif de création du compte à mettre dans les journaux.",
+ "apihelp-createaccount-param-language": "Code de langue à mettre par défaut pour l’utilisateur (facultatif, par défaut langue du contenu).",
+ "apihelp-createaccount-example-pass": "Créer l’utilisateur <kbd>testuser</kbd> avec le mot de passe <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Créer l’utilisateur <kbd>testmailuser</kbd> et envoyer par courriel un mot de passe généré aléatoirement.",
+ "apihelp-cspreport-summary": "Utilisé par les navigateurs pour signaler les violations de la politique de confidentialité du contenu. Ce module ne devrait jamais être utilisé, sauf quand il est utilisé automatiquement par un navigateur web compatible avec CSP.",
+ "apihelp-cspreport-param-reportonly": "Marquer comme étant un rapport d’une politique de surveillance, et non une politique exigée",
+ "apihelp-cspreport-param-source": "Ce qui a généré l’entête CSP qui a déclenché ce rapport",
+ "apihelp-delete-summary": "Supprimer une page.",
+ "apihelp-delete-param-title": "Titre de la page que vous voulez supprimer. Impossible à utiliser avec <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID de la page que vous voulez supprimer. Impossible à utiliser avec <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Motif de suppression. Si non défini, un motif généré automatiquement sera utilisé.",
+ "apihelp-delete-param-tags": "Modifier les balises à appliquer à l’entrée dans le journal des suppressions.",
+ "apihelp-delete-param-watch": "Ajouter la page à la liste de suivi de l’utilisateur actuel.",
+ "apihelp-delete-param-watchlist": "Ajouter ou supprimer sans distinction la page de la liste de suivi de l'utilisateur actuel, utiliser les préférences ou ne rien changer au suivi.",
+ "apihelp-delete-param-unwatch": "Supprimer la page de la liste de suivi de l'utilisateur actuel.",
+ "apihelp-delete-param-oldimage": "Le nom de l’ancienne image à supprimer tel que fourni par [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Supprimer <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Supprimer <kbd>Main Page</kbd> avec le motif <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Ce module a été désactivé.",
+ "apihelp-edit-summary": "Créer et modifier les pages.",
+ "apihelp-edit-param-title": "Titre de la page que vous voulez modifier. Impossible de l’utiliser avec <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID de la page que vous voulez modifier. Impossible à utiliser avec <var>$1title</var>.",
+ "apihelp-edit-param-section": "Numéro de section. <kbd>0</kbd> pour la section de tête, <kbd>new</kbd> pour une nouvelle section.",
+ "apihelp-edit-param-sectiontitle": "Le titre pour une nouvelle section.",
+ "apihelp-edit-param-text": "Contenu de la page.",
+ "apihelp-edit-param-summary": "Modifier le résumé. Également le titre de la section quand $1section=new et $1sectiontitle n’est pas défini.",
+ "apihelp-edit-param-tags": "Modifier les balises à appliquer à la version.",
+ "apihelp-edit-param-minor": "Modification mineure.",
+ "apihelp-edit-param-notminor": "Modification non mineure.",
+ "apihelp-edit-param-bot": "Marquer cette modification comme effectuée par un robot.",
+ "apihelp-edit-param-basetimestamp": "Horodatage de la révision de base, utilisé pour détecter les conflits de modification. Peut être obtenu via [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "L'horodatage, lorsque le processus d'édition est démarré, est utilisé pour détecter les conflits de modification. Une valeur appropriée peut être obtenue en utilisant <var>[[Special:ApiHelp/main|curtimestamp]]</var> lors du démarrage du processus d'édition (par ex. en chargeant le contenu de la page à modifier).",
+ "apihelp-edit-param-recreate": "Ignorer toutes les erreurs concernant la page \nqui a été supprimée entre-temps.",
+ "apihelp-edit-param-createonly": "Ne pas modifier la page si elle existe déjà.",
+ "apihelp-edit-param-nocreate": "Lever une erreur si la page n’existe pas.",
+ "apihelp-edit-param-watch": "Ajouter la page à la liste de suivi de l'utilisateur actuel.",
+ "apihelp-edit-param-unwatch": "Supprimer la page de la liste de suivi de l'utilisateur actuel.",
+ "apihelp-edit-param-watchlist": "Ajouter ou supprimer sans condition la page de votre liste de suivi, utiliser les préférences ou ne pas changer le suivi.",
+ "apihelp-edit-param-md5": "Le hachage MD5 du paramètre $1text, ou les paramètres $1prependtext et $1appendtext concaténés. Si défini, la modification ne sera pas effectuée sauf si le hachage est correct.",
+ "apihelp-edit-param-prependtext": "Ajouter ce texte au début de la page. Écrase $1text.",
+ "apihelp-edit-param-appendtext": "Ajouter ce texte à la fin de la page. Écrase $1text.\n\nUtiliser $1section=new pour ajouter une nouvelle section, plutôt que ce paramètre.",
+ "apihelp-edit-param-undo": "Annuler cette révision. Écrase $1text, $1prependtext et $1appendtext.",
+ "apihelp-edit-param-undoafter": "Annuler toutes les révisions depuis $1undo jusqu’à celle-ci. Si non défini, annuler uniquement une révision.",
+ "apihelp-edit-param-redirect": "Résoudre automatiquement les redirections.",
+ "apihelp-edit-param-contentformat": "Format de sérialisation du contenu utilisé pour le texte d’entrée.",
+ "apihelp-edit-param-contentmodel": "Modèle de contenu du nouveau contenu.",
+ "apihelp-edit-param-token": "Le jeton doit toujours être envoyé en tant que dernier paramètre, ou au moins après le paramètre $1text.",
+ "apihelp-edit-example-edit": "Modifier une page",
+ "apihelp-edit-example-prepend": "Préfixer une page par <kbd>_&#95;NOTOC_&#95;</kbd>.",
+ "apihelp-edit-example-undo": "Annuler les révisions 13579 à 13585 avec résumé automatique.",
+ "apihelp-emailuser-summary": "Envoyer un courriel à un utilisateur.",
+ "apihelp-emailuser-param-target": "Utilisateur à qui envoyer le courriel.",
+ "apihelp-emailuser-param-subject": "Entête du sujet.",
+ "apihelp-emailuser-param-text": "Corps du courriel.",
+ "apihelp-emailuser-param-ccme": "M’envoyer une copie de ce courriel.",
+ "apihelp-emailuser-example-email": "Envoyer un courriel à l’utilisateur <kbd>WikiSysop</kbd> avec le texte <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Développe tous les modèles avec du wikitexte.",
+ "apihelp-expandtemplates-param-title": "Titre de la page.",
+ "apihelp-expandtemplates-param-text": "Wikitexte à convertir.",
+ "apihelp-expandtemplates-param-revid": "ID de révision, pour <code><nowiki>{{REVISIONID}}</nowiki></code> et les variables semblables.",
+ "apihelp-expandtemplates-param-prop": "Quelles informations récupérer.\n\nNoter que si aucune valeur n’est sélectionnée, le résultat contiendra le wikitexte, mais la sortie sera dans un format désuet.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Le wikitexte développé",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Toutes les catégories présentes dans l’entrée qui ne sont pas représentées dans la sortie du wikitexte.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Propriétés de la page définies par le développement des mots magiques dans le wikitexte.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Si la sortie est volatile et ne devrait pas être réutilisée ailleurs dans la page.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Le délai maximum après lequel la mise en cache de ce résultat doit être invalidée.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Tous les modules ResourceLoader que les fonctions d’analyse ont demandé d’ajouter à la sortie. Soit <kbd>jsconfigvars</kbd> soit <kbd>encodedjsconfigvars</kbd> doit être demandé avec <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Donne les variables de configuration JavaScript spécifiques à la page.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Donne les variables de configuration JavaScript spécifiques à la page sous la forme d'une chaîne JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "L’arbre d’analyse XML de l’entrée.",
+ "apihelp-expandtemplates-param-includecomments": "S’il faut inclure les commentaires HTML dans la sortie.",
+ "apihelp-expandtemplates-param-generatexml": "Générer l’arbre d’analyse XML (remplacé par $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Développe le wikitexte <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Renvoie le fil des contributions d’un utilisateur.",
+ "apihelp-feedcontributions-param-feedformat": "Le format du flux.",
+ "apihelp-feedcontributions-param-user": "Pour quels utilisateurs récupérer les contributions.",
+ "apihelp-feedcontributions-param-namespace": "Par quels espaces de nom filtrer les contributions.",
+ "apihelp-feedcontributions-param-year": "De l’année (et antérieur).",
+ "apihelp-feedcontributions-param-month": "Depuis le mois (et plus récent).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrer les contributions qui ont ces balises.",
+ "apihelp-feedcontributions-param-deletedonly": "Afficher uniquement les contributions supprimées.",
+ "apihelp-feedcontributions-param-toponly": "Afficher uniquement les modifications qui sont les dernières révisions.",
+ "apihelp-feedcontributions-param-newonly": "Afficher uniquement les modifications qui sont des créations de page.",
+ "apihelp-feedcontributions-param-hideminor": "Masquer les modifications mineures.",
+ "apihelp-feedcontributions-param-showsizediff": "Afficher la différence de taille entre les révisions.",
+ "apihelp-feedcontributions-example-simple": "Renvoyer les contributions de l'utilisateur <kbd>Exemple</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Renvoie un fil de modifications récentes.",
+ "apihelp-feedrecentchanges-param-feedformat": "Le format du flux.",
+ "apihelp-feedrecentchanges-param-namespace": "Espace de noms auquel limiter les résultats.",
+ "apihelp-feedrecentchanges-param-invert": "Tous les espaces de noms sauf celui sélectionné.",
+ "apihelp-feedrecentchanges-param-associated": "Inclure l’espace de noms associé (discussion ou principal).",
+ "apihelp-feedrecentchanges-param-days": "Jours auxquels limiter le résultat.",
+ "apihelp-feedrecentchanges-param-limit": "Nombre maximal de résultats à renvoyer.",
+ "apihelp-feedrecentchanges-param-from": "Afficher les modifications depuis lors.",
+ "apihelp-feedrecentchanges-param-hideminor": "Masquer les modifications mineures.",
+ "apihelp-feedrecentchanges-param-hidebots": "Masquer les modifications faites par des robots.",
+ "apihelp-feedrecentchanges-param-hideanons": "Masquer les modifications faites par les utilisateurs anonymes.",
+ "apihelp-feedrecentchanges-param-hideliu": "Masquer les modifications faites par les utilisateurs enregistrés.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Masquer les modifications contrôlées.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Masquer les modifications faites par l'utilisateur actuel.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Masquer les changements de la catégorie d'appartenance.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrer par balise.",
+ "apihelp-feedrecentchanges-param-target": "Afficher uniquement les modifications sur les pages liées depuis cette page.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Afficher les modifications plutôt sur les pages liées vers la page sélectionnée.",
+ "apihelp-feedrecentchanges-param-categories": "Afficher uniquement les modifications sur les pages dans toutes ces catégories",
+ "apihelp-feedrecentchanges-param-categories_any": "Afficher plutôt uniquement les modifications sur les pages dans n’importe laquelle de ces catégories.",
+ "apihelp-feedrecentchanges-example-simple": "Afficher les modifications récentes",
+ "apihelp-feedrecentchanges-example-30days": "Afficher les modifications récentes sur 30 jours",
+ "apihelp-feedwatchlist-summary": "Renvoie un flux de liste de suivi.",
+ "apihelp-feedwatchlist-param-feedformat": "Le format du flux.",
+ "apihelp-feedwatchlist-param-hours": "Lister les pages modifiées lors de ce nombre d’heures depuis maintenant.",
+ "apihelp-feedwatchlist-param-linktosections": "Lier directement vers les sections modifées si possible.",
+ "apihelp-feedwatchlist-example-default": "Afficher le flux de la liste de suivi",
+ "apihelp-feedwatchlist-example-all6hrs": "Afficher toutes les modifications sur les pages suivies dans les dernières 6 heures",
+ "apihelp-filerevert-summary": "Rétablir un fichier dans une ancienne version.",
+ "apihelp-filerevert-param-filename": "Nom de fichier cible, sans le préfixe File:.",
+ "apihelp-filerevert-param-comment": "Téléverser le commentaire.",
+ "apihelp-filerevert-param-archivename": "Nom d’archive de la révision à rétablir.",
+ "apihelp-filerevert-example-revert": "Rétablir <kbd>Wiki.png</kbd> dans la version du <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Afficher l’aide pour les modules spécifiés.",
+ "apihelp-help-param-modules": "Modules pour lesquels afficher l’aide (valeurs des paramètres <var>action</var> et <var>format</var>, ou <kbd>main</kbd>). Les sous-modules peuvent être spécifiés avec un <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Inclure l’aide pour les sous-modules du module nommé.",
+ "apihelp-help-param-recursivesubmodules": "Inclure l’aide pour les sous-modules de façon récursive.",
+ "apihelp-help-param-helpformat": "Format de sortie de l’aide.",
+ "apihelp-help-param-wrap": "Inclut la sortie dans une structure standard de réponse API.",
+ "apihelp-help-param-toc": "Inclure une table des matières dans la sortie HTML.",
+ "apihelp-help-example-main": "Aide pour le module principal",
+ "apihelp-help-example-submodules": "Aide pour <kbd>action=query</kbd> et tous ses sous-modules.",
+ "apihelp-help-example-recursive": "Toute l’aide sur une page.",
+ "apihelp-help-example-help": "Aide pour le module d’aide lui-même.",
+ "apihelp-help-example-query": "Aide pour deux sous-modules de recherche.",
+ "apihelp-imagerotate-summary": "Faire pivoter une ou plusieurs images.",
+ "apihelp-imagerotate-param-rotation": "Degrés de rotation de l’image dans le sens des aiguilles d’une montre.",
+ "apihelp-imagerotate-param-tags": "Balises à appliquer à l’entrée dans le journal de téléversement.",
+ "apihelp-imagerotate-example-simple": "Faire pivoter <kbd>File:Example.png</kbd> de <kbd>90</kbd> degrés.",
+ "apihelp-imagerotate-example-generator": "Faire pivoter toutes les images de <kbd>Category:Flip</kbd> de <kbd>180</kbd> degrés.",
+ "apihelp-import-summary": "Importer une page depuis un autre wiki, ou depuis un fichier XML.",
+ "apihelp-import-extended-description": "Noter que le POST HTTP doit être effectué comme un import de fichier (c’est-à-dire en utilisant multipart/form-data) lors de l’envoi d’un fichier pour le paramètre <var>xml</var>.",
+ "apihelp-import-param-summary": "Résumé de l’importation de l’entrée de journal.",
+ "apihelp-import-param-xml": "Fichier XML téléversé.",
+ "apihelp-import-param-interwikisource": "Pour les importations interwiki : wiki depuis lequel importer.",
+ "apihelp-import-param-interwikipage": "Pour les importations interwiki : page à importer.",
+ "apihelp-import-param-fullhistory": "Pour les importations interwiki : importer tout l’historique, et pas seulement la version courante.",
+ "apihelp-import-param-templates": "Pour les importations interwiki : importer aussi tous les modèles inclus.",
+ "apihelp-import-param-namespace": "Importer vers cet espace de noms. Impossible à utiliser avec <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importer comme une sous-page de cette page. Impossible à utiliser avec <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Modifier les balises à appliquer à l'entrée du journal d'importation et à la version zéro des pages importées.",
+ "apihelp-import-example-import": "Importer [[meta:Help:ParserFunctions]] vers l’espace de noms 100 avec tout l’historique.",
+ "apihelp-linkaccount-summary": "Lier un compte d’un fournisseur tiers à l’utilisateur actuel.",
+ "apihelp-linkaccount-example-link": "Commencer le processus de liaison d’un compte depuis <kbd>Exemple</kbd>.",
+ "apihelp-login-summary": "Reconnecte et récupère les témoins (cookies) d'authentification.",
+ "apihelp-login-extended-description": "Cette action ne devrait être utilisée qu’en lien avec [[Special:BotPasswords]] ; l’utiliser pour la connexion du compte principal est désuet et peut échouer sans avertissement. Pour se connecter sans problème au compte principal, utiliser <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Cette action est désuète et peut échouer sans prévenir. Pour se connecter sans problème, utiliser <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "Nom d’utilisateur.",
+ "apihelp-login-param-password": "Mot de passe.",
+ "apihelp-login-param-domain": "Domaine (facultatif).",
+ "apihelp-login-param-token": "Jeton de connexion obtenu à la première requête.",
+ "apihelp-login-example-gettoken": "Récupérer un jeton de connexion",
+ "apihelp-login-example-login": "Se connecter",
+ "apihelp-logout-summary": "Se déconnecter et effacer les données de session.",
+ "apihelp-logout-example-logout": "Déconnecter l’utilisateur actuel.",
+ "apihelp-managetags-summary": "Effectuer des tâches de gestion relatives à la modification des balises.",
+ "apihelp-managetags-param-operation": "Quelle opération effectuer :\n;create:Créer une nouvelle balise de modification pour un usage manuel.\n;delete:Supprimer une balise de modification de la base de données, y compris la suppression de la marque de toutes les révisions, entrées de modification récente et entrées de journal dans lesquelles elle serait utilisée.\n;activate:Activer une balise de modification, permettant aux utilisateurs de l’appliquer manuellement.\n;deactivate:Désactiver une balise de modification, empêchant les utilisateurs de l’appliquer manuellement.",
+ "apihelp-managetags-param-tag": "Balise à créer, supprimer, activer ou désactiver. Pour la création de balise, elle ne doit pas exister. Pour la suppression de balise, elle doit exister. Pour l’activation de balise, elle doit exister et ne pas être utilisée par une extension. Pour la désactivation de balise, elle doit être actuellement active et définie manuellement.",
+ "apihelp-managetags-param-reason": "Un motif facultatif pour créer, supprimer, activer ou désactiver la balise.",
+ "apihelp-managetags-param-ignorewarnings": "S’il faut ignorer tout avertissement qui surviendrait au cours de l’opération.",
+ "apihelp-managetags-param-tags": "Modifier les balises à appliquer à l’entrée du journal de gestion des balises.",
+ "apihelp-managetags-example-create": "Créer une balise nommée <kbd>spam</kbd> avec le motif <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Supprimer la balise <kbd>vandlaism</kbd> avec le motif <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Activer une balise nommée <kbd>spam</kbd> avec le motif <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Désactiver une balise nommée <kbd>spam</kbd> avec le motif <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Fusionner les historiques des pages.",
+ "apihelp-mergehistory-param-from": "Titre de la page depuis laquelle l’historique sera fusionné. Impossible à utiliser avec <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "ID de la page depuis laquelle l’historique sera fusionné. Impossible à utiliser avec <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Titre de la page vers laquelle l’historique sera fusionné. Impossible à utiliser avec <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "ID de la page vers laquelle l’historique sera fusionné. Impossible à utiliser avec <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "Horodatage jusqu’auquel les révisions seront déplacées de l’historique de la page source vers l’historique de la page de destination. S’il est omis, tout l’historique de la page source sera fusionné avec celui de la page de destination.",
+ "apihelp-mergehistory-param-reason": "Raison pour fusionner l’historique.",
+ "apihelp-mergehistory-example-merge": "Fusionner l’historique complet de <kbd>AnciennePage</kbd> dans <kbd>NouvellePage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Fusionner les révisions de la page <kbd>AnciennePage</kbd> jusqu’au <kbd>2015-12-31T04:37:41Z</kbd> dans <kbd>NouvellePage</kbd>.",
+ "apihelp-move-summary": "Déplacer une page.",
+ "apihelp-move-param-from": "Titre de la page à renommer. Impossible de l’utiliser avec <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "ID de la page à renommer. Impossible à utiliser avec <var>$1from</var>.",
+ "apihelp-move-param-to": "Nouveau titre de la page.",
+ "apihelp-move-param-reason": "Motif du renommage.",
+ "apihelp-move-param-movetalk": "Renommer la page de discussion, si elle existe.",
+ "apihelp-move-param-movesubpages": "Renommer les sous-pages, le cas échéant.",
+ "apihelp-move-param-noredirect": "Ne pas créer une redirection.",
+ "apihelp-move-param-watch": "Ajouter la page et la redirection, à la liste de suivi de l'utilisateur actuel.",
+ "apihelp-move-param-unwatch": "Supprimer la page et la redirection de la liste de suivi de l'utilisateur actuel.",
+ "apihelp-move-param-watchlist": "Ajouter ou supprimer sans condition la page de la liste de suivi de l'utilisateur actuel, utiliser les préférences ou ne pas changer le suivi.",
+ "apihelp-move-param-ignorewarnings": "Ignorer tous les avertissements.",
+ "apihelp-move-param-tags": "Modifier les balises à appliquer à l'entrée du journal des renommages et à la version zéro de la page de destination.",
+ "apihelp-move-example-move": "Renommer <kbd>Badtitle</kbd> en <kbd>Goodtitle</kbd> sans garder de redirection.",
+ "apihelp-opensearch-summary": "Rechercher dans le wiki en utilisant le protocole OpenSearch.",
+ "apihelp-opensearch-param-search": "Chaîne de caractères cherchée.",
+ "apihelp-opensearch-param-limit": "Nombre maximal de résultats à renvoyer.",
+ "apihelp-opensearch-param-namespace": "Espaces de nom à rechercher.",
+ "apihelp-opensearch-param-suggest": "Ne rien faire si <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> vaut faux.",
+ "apihelp-opensearch-param-redirects": "Comment gérer les redirections :\n;return:Renvoie la redirection elle-même.\n;resolve:Renvoie la page cible. Peut renvoyer moins de $1limit résultats.\nPour des raisons historiques, la valeur par défaut est « return » pour $1format=json et « resolve » pour les autres formats.",
+ "apihelp-opensearch-param-format": "Le format de sortie.",
+ "apihelp-opensearch-param-warningsaserror": "Si des avertissements apparaissent avec <kbd>format=json</kbd>, renvoyer une erreur d’API au lieu de les ignorer.",
+ "apihelp-opensearch-example-te": "Trouver les pages commençant par <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Modifier les préférences de l'utilisateur courant.",
+ "apihelp-options-extended-description": "Seules les options enregistrées dans le cœur ou dans l’une des extensions installées, ou les options avec des clés préfixées par <code>userjs-</code> (devant être utilisées dans les scripts utilisateur), peuvent être définies.",
+ "apihelp-options-param-reset": "Réinitialise les préférences avec les valeurs par défaut du site.",
+ "apihelp-options-param-resetkinds": "Liste des types d’option à réinitialiser quand l’option <var>$1reset</var> est définie.",
+ "apihelp-options-param-change": "Liste des modifications, au format nom=valeur (par ex. skin=vector). Si aucune valeur n’est fournie (pas même un signe égal), par ex., nomoption|autreoption|…, l’option sera réinitialisée à sa valeur par défaut. Pour toute valeur passée contenant une barre verticale (<kbd>|</kbd>), utiliser le [[Special:ApiHelp/main#main/datatypes|séparateur alternatif de valeur multiple]] pour que l'opération soit correcte.",
+ "apihelp-options-param-optionname": "Nom de l’option qui doit être définie avec la valeur fournie par <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "La valeur de l'option spécifiée par <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Réinitialiser toutes les préférences",
+ "apihelp-options-example-change": "Modifier les préférences <kbd>skin</kbd> et <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Réinitialiser toutes les préférences, puis définir <kbd>skin</kbd> et <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Obtenir des informations sur les modules de l’API.",
+ "apihelp-paraminfo-param-modules": "Liste des noms de module (valeurs des paramètres <var>action</var> et <var>format</var>, ou <kbd>main</kbd>). Peut spécifier des sous-modules avec un <kbd>+</kbd>, ou tous les sous-modules avec <kbd>+*</kbd>, ou tous les sous-modules récursivement avec <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Format des chaînes d’aide.",
+ "apihelp-paraminfo-param-querymodules": "Liste des noms des modules de requête (valeur des paramètres <var>prop</var>, <var>meta</var> ou <var>list</var>). Utiliser <kbd>$1modules=query+foo</kbd> au lieu de <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Obtenir aussi des informations sur le module principal (niveau supérieur). Utiliser plutôt <kbd>$1modules=main</kbd>.",
+ "apihelp-paraminfo-param-pagesetmodule": "Obtenir aussi des informations sur le module pageset (en fournissant titles= et ses amis).",
+ "apihelp-paraminfo-param-formatmodules": "Liste des noms de module de mise en forme (valeur du paramètre <var>format</var>). Utiliser plutôt <var>$1modules</var>.",
+ "apihelp-paraminfo-example-1": "Afficher les informations pour <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> et <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "Afficher les informations pour tous les sous-modules de <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-summary": "Analyse le contenu et renvoie le résultat de l’analyseur.",
+ "apihelp-parse-extended-description": "Voyez les différents modules prop de <kbd>[[Special:ApiHelp/query|action=query]]</kbd> pour avoir de l’information sur la version actuelle d’une page.\n\nIl y a plusieurs moyens de spécifier le texte à analyser :\n# Spécifier une page ou une révision, en utilisant <var>$1page</var>, <var>$1pageid</var> ou <var>$1oldid</var>.\n# Spécifier explicitement un contenu, en utilisant <var>$1text</var>, <var>$1title</var> et <var>$1contentmodel</var>\n# Spécifier uniquement un résumé à analyser. <var>$1prop</var> doit recevoir une valeur vide.",
+ "apihelp-parse-param-title": "Titre de la page à laquelle appartient le texte. Si omis, <var>$1contentmodel</var> doit être spécifié, et [[API]] sera utilisé comme titre.",
+ "apihelp-parse-param-text": "Texte à analyser. utiliser <var>$1title</var> ou <var>$1contentmodel</var> pour contrôler le modèle de contenu.",
+ "apihelp-parse-param-summary": "Résumé à analyser.",
+ "apihelp-parse-param-page": "Analyser le contenu de cette page. Impossible à utiliser avec <var>$1text</var> et <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Analyser le contenu de cette page. Écrase <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "Si le paramètre <var>$1page</var> ou <var>$1pageid</var> est positionné sur une redirection, la résoudre.",
+ "apihelp-parse-param-oldid": "Analyser le contenu de cette révision. Écrase <var>$1page</var> et <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "Quelles informations obtenir :",
+ "apihelp-parse-paramvalue-prop-text": "Fournit le texte analysé du wikitexte.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Fournit les liens de langue du wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-categories": "Fournit les catégories dans le wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Fournit la version HTML des catégories.",
+ "apihelp-parse-paramvalue-prop-links": "Fournit les liens internes dans le wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-templates": "Fournit les modèles dans le wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-images": "Fournit les images dans le wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Fournit les liens externes dans le wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-sections": "Fournit les sections dans le wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-revid": "Ajoute l’ID de révision de la page analysée.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Ajoute le titre du wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-headitems": "Fournit les éléments à mettre dans le <code>&lt;head&gt;</code> de la page.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Fournit le <code>&lt;head&gt;</code> analysé de la page.",
+ "apihelp-parse-paramvalue-prop-modules": "Fournit les modules ResourceLoader utilisés sur la page. Pour les charger, utiliser <code>mw.loader.using()</code>. Soit <kbd>jsconfigvars</kbd> soit <kbd>encodedjsconfigvars</kbd> doit être demandé avec <kbd>modules</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Fournit les variables de configuration JavaScript spécifiques à la page. Pour les appliquer, utiliser <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Fournit les variables de configuration JavaScript spécifiques à la page comme chaîne JSON.",
+ "apihelp-parse-paramvalue-prop-indicators": "Fournit le HTML des indicateurs d’état de page utilisés sur la page.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Fournit les liens interwikis dans le wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Fournit le wikitexte d’origine qui a été analysé.",
+ "apihelp-parse-paramvalue-prop-properties": "Fournit les diverses propriétés définies dans le wikitexte analysé.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Fournit le rapport de limite d’une manière structurée. Ne fournit aucune donnée, si <var>$1disablelimitreport</var> est positionné.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Fournit la version HTML du rapport de limite. Ne fournit aucune donnée, si <var>$1disablelimitreport</var> est positionné.",
+ "apihelp-parse-paramvalue-prop-parsetree": "L’arbre d’analyse XML du contenu de la révision (nécessite le modèle de contenu <code>$1</code>)",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Fournit les messages d'avertissement qui sont apparus lors de l'analyse de contenu.",
+ "apihelp-parse-param-wrapoutputclass": "classe CSS à utiliser pour formater la sortie de l'analyseur.",
+ "apihelp-parse-param-pst": "Faire une transformation avant enregistrement de l’entrée avant de l’analyser. Valide uniquement quand utilisé avec du texte.",
+ "apihelp-parse-param-onlypst": "Faire une transformation avant enregistrement (PST) de l’entrée, mais ne pas l’analyser. Renvoie le même wikitexte, après que la PST a été appliquée. Valide uniquement quand utilisé avec <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "Inclut les liens de langue fournis par les extensions (à utiliser avec <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "Traiter uniquement le contenu de la section ayant ce numéro.\n\nQuand la valeur est <kbd>new</kbd>, traite <var>$1text</var> et <var>$1sectiontitle</var> comme s’ils correspondaient à une nouvelle section de la page.\n\nLa valeur <kbd>new</kbd> n’est autorisée que si <var>text</var> est défini.",
+ "apihelp-parse-param-sectiontitle": "Nouveau titre de section quand <var>section</var> vaut <kbd>nouveau</kbd>.\n\nÀ la différence de la modification de page, cela ne revient pas à <var>summary</var> quand il est omis ou vide.",
+ "apihelp-parse-param-disablelimitreport": "Omettre le rapport de limite (« rapport de limite du nouveau PP ») de la sortie de l’analyseur.",
+ "apihelp-parse-param-disablepp": "Utiliser <var>$1disablelimitreport</var> à la place.",
+ "apihelp-parse-param-disableeditsection": "Omettre les liens de modification de section de la sortie de l’analyseur.",
+ "apihelp-parse-param-disabletidy": "Ne pas exécuter de nettoyage du code HTML (par exemple, réagencer) sur la sortie de l'analyseur.",
+ "apihelp-parse-param-generatexml": "Générer un arbre d’analyse XML (nécessite le modèle de contenu <code>$1</code> ; remplacé par <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "Analyser en mode aperçu.",
+ "apihelp-parse-param-sectionpreview": "Analyser en mode aperçu de section (active aussi le mode aperçu).",
+ "apihelp-parse-param-disabletoc": "Omettre la table des matières dans la sortie.",
+ "apihelp-parse-param-useskin": "Appliquer l’habillage sélectionné sur la sortie de l’analyseur. Cela peut affecter les propriétés suivantes : <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>modules</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicators</kbd>.",
+ "apihelp-parse-param-contentformat": "Format de sérialisation du contenu utilisé pour le texte d’entrée. Valide uniquement si utilisé avec $1text.",
+ "apihelp-parse-param-contentmodel": "Modèle de contenu du texte d’entrée. Si omis, $1title doit être spécifié, et la valeur par défaut sera le modèle du titre spécifié. Valide uniquement quand utilisé avec $1text.",
+ "apihelp-parse-example-page": "Analyser une page.",
+ "apihelp-parse-example-text": "Analyser le wikitexte.",
+ "apihelp-parse-example-texttitle": "Analyser du wikitexte, en spécifiant le titre de la page.",
+ "apihelp-parse-example-summary": "Analyser un résumé.",
+ "apihelp-patrol-summary": "Patrouiller une page ou une révision.",
+ "apihelp-patrol-param-rcid": "ID de modification récente à patrouiller.",
+ "apihelp-patrol-param-revid": "ID de révision à patrouiller.",
+ "apihelp-patrol-param-tags": "Modifier les balises à appliquer à l’entrée dans le journal de surveillance.",
+ "apihelp-patrol-example-rcid": "Patrouiller une modification récente",
+ "apihelp-patrol-example-revid": "Patrouiller une révision",
+ "apihelp-protect-summary": "Modifier le niveau de protection d’une page.",
+ "apihelp-protect-param-title": "Titre de la page à (dé)protéger. Impossible à utiliser avec $1pageid.",
+ "apihelp-protect-param-pageid": "ID de la page à (dé)protéger. Impossible à utiliser avec $1title.",
+ "apihelp-protect-param-protections": "Liste des niveaux de protection, au format <kbd>action=niveau</kbd> (par exemple <kbd>edit=sysop</kbd>). Un niveau de <kbd>tout</kbd>, indique que tout le monde est autorisé à faire l'action, c'est à dire aucune restriction.\n\n<strong>NOTE :<strong> Toutes les actions non listées auront leur restrictions supprimées.",
+ "apihelp-protect-param-expiry": "Horodatages d’expiration. Si un seul horodatage est fourni, il sera utilisé pour toutes les protections. Utiliser <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> ou <kbd>never</kbd> pour une protection sans expiration.",
+ "apihelp-protect-param-reason": "Motif de (dé)protection.",
+ "apihelp-protect-param-tags": "Modifier les balises à appliquer à l’entrée dans le journal de protection.",
+ "apihelp-protect-param-cascade": "Activer la protection en cascade (c’est-à-dire protéger les modèles transclus et les images utilisés dans cette page). Ignoré si aucun des niveaux de protection fournis ne prend en charge la mise en cascade.",
+ "apihelp-protect-param-watch": "Si activé, ajouter la page (dé)protégée à la liste de suivi de l'utilisateur actuel.",
+ "apihelp-protect-param-watchlist": "Ajouter ou supprimer sans condition la page de la liste de suivi de l'utilisateur actuel, utiliser les préférences ou ne pas modifier le suivi.",
+ "apihelp-protect-example-protect": "Protéger une page",
+ "apihelp-protect-example-unprotect": "Enlever la protection d’une page en mettant les restrictions à <kbd>all</kbd> (c'est à dire tout le monde est autorisé à faire l'action).",
+ "apihelp-protect-example-unprotect2": "Enlever la protection de la page en ne mettant aucune restriction",
+ "apihelp-purge-summary": "Vider le cache des titres fournis.",
+ "apihelp-purge-param-forcelinkupdate": "Mettre à jour les tables de liens.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Mettre à jour la table des liens, et mettre à jour les tables de liens pour toute page qui utilise cette page comme modèle",
+ "apihelp-purge-example-simple": "Purger les pages <kbd>Main Page</kbd> et <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "Purger les 10 premières pages de l’espace de noms principal",
+ "apihelp-query-summary": "Extraire des données de et sur MediaWiki.",
+ "apihelp-query-extended-description": "Toutes les modifications de données devront d’abord utiliser une requête pour obtenir un jeton, afin d’éviter les abus de la part de sites malveillants.",
+ "apihelp-query-param-prop": "Quelles propriétés obtenir pour les pages demandées.",
+ "apihelp-query-param-list": "Quelles listes obtenir.",
+ "apihelp-query-param-meta": "Quelles métadonnées obtenir.",
+ "apihelp-query-param-indexpageids": "Inclure une section pageids supplémentaire listant tous les IDs de page renvoyés.",
+ "apihelp-query-param-export": "Exporter les révisions actuelles de toutes les pages fournies ou générées.",
+ "apihelp-query-param-exportnowrap": "Renvoyer le XML exporté sans l’inclure dans un résultat XML (même format que [[Special:Export]]). Utilisable uniquement avec $1export.",
+ "apihelp-query-param-iwurl": "S’il faut obtenir l’URL complète si le titre est un lien interwiki.",
+ "apihelp-query-param-rawcontinue": "Renvoyer les données <samp>query-continue</samp> brutes pour continuer.",
+ "apihelp-query-example-revisions": "Récupérer [[Special:ApiHelp/query+siteinfo|l’info du site]] et [[Special:ApiHelp/query+revisions|les révisions]] de <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "Récupérer les révisions des pages commençant par <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "Énumérer toutes les catégories.",
+ "apihelp-query+allcategories-param-from": "La catégorie depuis laquelle démarrer l’énumération.",
+ "apihelp-query+allcategories-param-to": "La catégorie à laquelle terminer l’énumération.",
+ "apihelp-query+allcategories-param-prefix": "Rechercher tous les titres de catégorie qui commencent avec cette valeur.",
+ "apihelp-query+allcategories-param-dir": "Ordre dans lequel trier.",
+ "apihelp-query+allcategories-param-min": "Renvoyer uniquement les catégories avec au moins ce nombre de membres.",
+ "apihelp-query+allcategories-param-max": "Renvoyer uniquement les catégories avec au plus ce nombre de membres.",
+ "apihelp-query+allcategories-param-limit": "Combien de catégories renvoyer.",
+ "apihelp-query+allcategories-param-prop": "Quelles propriétés récupérer :",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Ajoute le nombre de pages dans la catégorie.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Marque les catégories qui sont masquées avec <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Lister les catégories avec l’information sur le nombre de pages dans chacune",
+ "apihelp-query+allcategories-example-generator": "Récupérer l’information sur la page de catégorie elle-même pour les catégories commençant par <kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "Lister toutes les révisions supprimées par un utilisateur ou dans un espace de noms.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Utilisable uniquement avec <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Impossible à utiliser avec <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "L’horodatage auquel démarrer l’énumération.",
+ "apihelp-query+alldeletedrevisions-param-end": "L’horodatage auquel arrêter l’énumération.",
+ "apihelp-query+alldeletedrevisions-param-from": "Démarrer la liste à ce titre.",
+ "apihelp-query+alldeletedrevisions-param-to": "Arrêter la liste à ce titre.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Rechercher tous les titres de page commençant par cette valeur.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Lister uniquement les révisions marquées avec cette balise.",
+ "apihelp-query+alldeletedrevisions-param-user": "Lister uniquement les révisions par cet utilisateur.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Ne pas lister les révisions par cet utilisateur.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Lister uniquement les pages dans cet espace de noms.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>REMARQUE :</strong> du fait du [[mw:Special:MyLanguage/Manual:$wgMiserMode|mode minimal]], utiliser <var>$1user</var> et <var>$1namespace</var> ensemble peut aboutir à avoir moins de résultats renvoyés que <var>$1limit</var> avant de continuer ; dans les cas extrêmes, zéro résultats peuvent être renvoyés.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Utilisé comme générateur, générer des titres plutôt que des IDs de révision.",
+ "apihelp-query+alldeletedrevisions-example-user": "Lister les 50 dernières contributions supprimées par l'utilisateur <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Lister les 50 premières révisions supprimées dans l’espace de noms principal.",
+ "apihelp-query+allfileusages-summary": "Lister toutes les utilisations de fichiers, y compris ceux n’existant pas.",
+ "apihelp-query+allfileusages-param-from": "Le titre du fichier depuis lequel commencer l’énumération.",
+ "apihelp-query+allfileusages-param-to": "Le titre du fichier auquel arrêter l’énumération.",
+ "apihelp-query+allfileusages-param-prefix": "Rechercher tous les fichiers dont le titre commence par cette valeur.",
+ "apihelp-query+allfileusages-param-unique": "Afficher uniquement les titres de fichiers distincts. Impossible à utiliser avec $1prop=ids.\nQuand il est utilisé comme générateur, il produit les pages cible au lieu des pages source.",
+ "apihelp-query+allfileusages-param-prop": "Quelles informations inclure :",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Ajoute l'ID des pages qui l’utilisent (incompatible avec $1unique).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Ajoute le titre du fichier.",
+ "apihelp-query+allfileusages-param-limit": "Combien d’éléments renvoyer au total.",
+ "apihelp-query+allfileusages-param-dir": "L'ordre dans lequel lister.",
+ "apihelp-query+allfileusages-example-B": "Lister les titres des fichiers, y compris ceux manquants, avec les IDs de page d’où ils proviennent, en commençant à <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Lister les titres de fichier uniques.",
+ "apihelp-query+allfileusages-example-unique-generator": "Obtient tous les titres de fichier, en marquant les manquants.",
+ "apihelp-query+allfileusages-example-generator": "Obtient les pages contenant les fichiers.",
+ "apihelp-query+allimages-summary": "Énumérer toutes les images séquentiellement.",
+ "apihelp-query+allimages-param-sort": "Propriété par laquelle trier.",
+ "apihelp-query+allimages-param-dir": "L'ordre dans laquel lister.",
+ "apihelp-query+allimages-param-from": "Le titre de l’image depuis laquelle démarrer l’énumération. Ne peut être utilisé qu’avec $1sort=name.",
+ "apihelp-query+allimages-param-to": "Le titre de l’image auquel arrêter l’énumération. Ne peut être utilisé qu’avec $1sort=name.",
+ "apihelp-query+allimages-param-start": "L’horodatage depuis lequel énumérer. Ne peut être utilisé qu’avec $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "L’horodatage de la fin d’énumération. Ne peut être utilisé qu’avec $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "Rechercher toutes les images dont le titre commence par cette valeur. Utilisable uniquement avec $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "Restreindre aux images avec au moins ce nombre d’octets.",
+ "apihelp-query+allimages-param-maxsize": "Restreindre aux images avec au plus ce nombre d’octets.",
+ "apihelp-query+allimages-param-sha1": "Hachage SHA1 de l’image. Écrase $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "Hachage SHA1 de l’image en base 36 (utilisé dans MediaWiki).",
+ "apihelp-query+allimages-param-user": "Renvoyer seulement les fichiers téléversés par cet utilisateur. Utilisable uniquement avec $1sort=timestamp. Impossible à utiliser avec $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "Comment filtrer les fichiers téléversés par des robots. Peut être utilisé uniquement avec $1sort=timestamp. Impossible à utiliser avec $1user.",
+ "apihelp-query+allimages-param-mime": "Quels types MIME rechercher, par ex. <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "Combien d’images renvoyer au total.",
+ "apihelp-query+allimages-example-B": "Afficher une liste des fichiers commençant par la lettre <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Afficher une liste de fichiers récemment téléversés, semblable à [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Afficher une liste de fichiers avec le type MIME <kbd>image/png</kbd> ou <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Afficher l’information sur 4 fichiers commençant par la lettre <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Énumérer tous les liens pointant vers un espace de noms donné.",
+ "apihelp-query+alllinks-param-from": "Le titre du lien auquel démarrer l’énumération.",
+ "apihelp-query+alllinks-param-to": "Le titre du lien auquel arrêter l’énumération.",
+ "apihelp-query+alllinks-param-prefix": "Rechercher tous les titres liés commençant par cette valeur.",
+ "apihelp-query+alllinks-param-unique": "Afficher uniquement les titres liés distincts. Impossible à utiliser avec <kbd>$1prop=ids</kbd>.\nUtilisé avec un générateur, produit les pages cible au lieu des pages source.",
+ "apihelp-query+alllinks-param-prop": "Quelles informations inclure :",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Ajoute l’ID de la page avec le lien (impossible à utiliser avec <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Ajoute le titre du lien.",
+ "apihelp-query+alllinks-param-namespace": "L’espace de noms à énumérer.",
+ "apihelp-query+alllinks-param-limit": "Combien d’éléments renvoyer au total.",
+ "apihelp-query+alllinks-param-dir": "L'ordre dans lequel lister.",
+ "apihelp-query+alllinks-example-B": "Lister les titres liés, y compris ceux manquants, avec les IDs des pages d’où ils proviennent, en démarrant à <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Lister les titres liés uniques",
+ "apihelp-query+alllinks-example-unique-generator": "Obtient tous les titres liés, en marquant les manquants",
+ "apihelp-query+alllinks-example-generator": "Obtient les pages contenant les liens",
+ "apihelp-query+allmessages-summary": "Renvoyer les messages depuis ce site.",
+ "apihelp-query+allmessages-param-messages": "Quels messages sortir. <kbd>*</kbd> (par défaut) signifie tous les messages.",
+ "apihelp-query+allmessages-param-prop": "Quelles propriétés obtenir.",
+ "apihelp-query+allmessages-param-enableparser": "Positionner pour activer l’analyseur, traitera en avance le wikitexte du message (substitution des mots magiques, gestion des modèles, etc.).",
+ "apihelp-query+allmessages-param-nocontent": "Si positionné, ne pas inclure le contenu des messages dans la sortie.",
+ "apihelp-query+allmessages-param-includelocal": "Inclure aussi les messages locaux, c’est-à-dire les messages qui n’existent pas dans le logiciel mais dans l’espace de noms {{ns:MediaWiki}}.\nCela liste toutes les pages de l’espace de noms {{ns:MediaWiki}}, donc aussi celles qui ne sont pas vraiment des messages, telles que [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Arguments à substituer dans le message.",
+ "apihelp-query+allmessages-param-filter": "Renvoyer uniquement les messages avec des noms contenant cette chaîne.",
+ "apihelp-query+allmessages-param-customised": "Renvoyer uniquement les messages dans cet état de personnalisation.",
+ "apihelp-query+allmessages-param-lang": "Renvoyer les messages dans cette langue.",
+ "apihelp-query+allmessages-param-from": "Renvoyer les messages commençant à ce message.",
+ "apihelp-query+allmessages-param-to": "Renvoyer les messages en terminant à ce message.",
+ "apihelp-query+allmessages-param-title": "Nom de page à utiliser comme contexte en analysant le message (pour l’option $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "Renvoyer les messages avec ce préfixe.",
+ "apihelp-query+allmessages-example-ipb": "Afficher les messages commençant par <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Afficher les messages <kbd>august</kbd> et <kbd>mainpage</kbd> en allemand.",
+ "apihelp-query+allpages-summary": "Énumérer toutes les pages séquentiellement dans un espace de noms donné.",
+ "apihelp-query+allpages-param-from": "Le titre de la page depuis lequel commencer l’énumération.",
+ "apihelp-query+allpages-param-to": "Le titre de la page auquel stopper l’énumération.",
+ "apihelp-query+allpages-param-prefix": "Rechercher tous les titres de page qui commencent par cette valeur.",
+ "apihelp-query+allpages-param-namespace": "L’espace de noms à énumérer.",
+ "apihelp-query+allpages-param-filterredir": "Quelles pages lister.",
+ "apihelp-query+allpages-param-minsize": "Limiter aux pages avec au moins ce nombre d’octets.",
+ "apihelp-query+allpages-param-maxsize": "Limiter aux pages avec au plus ce nombre d’octets.",
+ "apihelp-query+allpages-param-prtype": "Limiter aux pages protégées uniquement.",
+ "apihelp-query+allpages-param-prlevel": "Filtrer les protections basées sur le niveau de protection (doit être utilisé avec le paramètre $1prtype=).",
+ "apihelp-query+allpages-param-prfiltercascade": "Filtrer les protections d’après leur cascade (ignoré si $1prtype n’est pas positionné).",
+ "apihelp-query+allpages-param-limit": "Combien de pages renvoyer au total.",
+ "apihelp-query+allpages-param-dir": "L'ordre dans lequel lister.",
+ "apihelp-query+allpages-param-filterlanglinks": "Filtrer si une page a des liens de langue. Noter que cela ne prend pas en compte les liens de langue ajoutés par des extensions.",
+ "apihelp-query+allpages-param-prexpiry": "Quelle expiration de protection sur laquelle filtrer la page :\n;indefinite:N’obtenir que les pages avec une expiration de protection infinie.\n;definite:N’obtenir que les pages avec une expiration de protection définie (spécifique).\n;all:Obtenir toutes les pages avec une expiration de protection.",
+ "apihelp-query+allpages-example-B": "Afficher une liste des pages commençant par la lettre <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Afficher l’information sur 4 pages commençant par la lettre <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Afficher le contenu des 2 premières pages hors redirections commençant par <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Lister toutes les redirections vers un espace de noms.",
+ "apihelp-query+allredirects-param-from": "Le titre de la redirection auquel démarrer l’énumération.",
+ "apihelp-query+allredirects-param-to": "Le titre de la redirection auquel arrêter l’énumération.",
+ "apihelp-query+allredirects-param-prefix": "Rechercher toutes les pages cible commençant par cette valeur.",
+ "apihelp-query+allredirects-param-unique": "Afficher uniquement les pages cibles distinctes. Impossible à utiliser avec $1prop=ids|fragment|interwiki.\nUtilisé avec un générateur, produit les pages cible au lieu des pages source.",
+ "apihelp-query+allredirects-param-prop": "Quelles informations inclure :",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Ajoute l’ID de la page de redirection (impossible à utiliser avec <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Ajoute le titre de la redirection.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Ajoute le fragment de la redirection, s’il existe (impossible à utiliser avec <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Ajoute le préfixe interwiki de la redirection, s’il existe (impossible à utiliser avec <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "L’espace de noms à énumérer.",
+ "apihelp-query+allredirects-param-limit": "Combien d’éléments renvoyer au total.",
+ "apihelp-query+allredirects-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+allredirects-example-B": "Lister les pages cible, y compris celles manquantes, avec les IDs de page d’où ils proviennent, en commençant à <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "Lister les pages cible unique",
+ "apihelp-query+allredirects-example-unique-generator": "Obtient toutes les pages cible, en marquant les manquantes",
+ "apihelp-query+allredirects-example-generator": "Obtient les pages contenant les redirections",
+ "apihelp-query+allrevisions-summary": "Lister toutes les révisions.",
+ "apihelp-query+allrevisions-param-start": "L’horodatage auquel démarrer l’énumération.",
+ "apihelp-query+allrevisions-param-end": "L’horodatage auquel arrêter l’énumération.",
+ "apihelp-query+allrevisions-param-user": "Lister uniquement les révisions faites par cet utilisateur.",
+ "apihelp-query+allrevisions-param-excludeuser": "Ne pas lister les révisions faites par cet utilisateur.",
+ "apihelp-query+allrevisions-param-namespace": "Lister uniquement les pages dans cet espace de noms.",
+ "apihelp-query+allrevisions-param-generatetitles": "Utilisé comme générateur, génère des titres plutôt que des IDs de révision.",
+ "apihelp-query+allrevisions-example-user": "Lister les 50 dernières contributions de l’utilisateur <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "Lister les 50 premières révisions dans l’espace de noms principal.",
+ "apihelp-query+mystashedfiles-summary": "Obtenir une liste des fichiers dans le cache de téléversement de l’utilisateur actuel",
+ "apihelp-query+mystashedfiles-param-prop": "Quelles propriétés récupérer pour les fichiers.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Récupérer la taille du fichier et les dimensions de l’image.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Récupérer le type MIME du fichier et son type de média.",
+ "apihelp-query+mystashedfiles-param-limit": "Combien de fichiers obtenir.",
+ "apihelp-query+mystashedfiles-example-simple": "Obtenir la clé du fichier, sa taille, et la taille en pixels des fichiers dans le cache de téléversement de l’utilisateur actuel.",
+ "apihelp-query+alltransclusions-summary": "Lister toutes les transclusions (pages intégrées en utilisant &#123;&#123;x&#125;&#125;), y compris les inexistantes.",
+ "apihelp-query+alltransclusions-param-from": "Le titre de la transclusion depuis lequel commencer l’énumération.",
+ "apihelp-query+alltransclusions-param-to": "Le titre de la transclusion auquel arrêter l’énumération.",
+ "apihelp-query+alltransclusions-param-prefix": "Rechercher tous les titres inclus qui commencent par cette valeur.",
+ "apihelp-query+alltransclusions-param-unique": "Afficher uniquement les titres inclus. Impossible à utiliser avec $1prop=ids.\nUtilisé avec un générateur, produit les pages cible plutôt que les pages source.",
+ "apihelp-query+alltransclusions-param-prop": "Quelles informations inclure :",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Ajout l’ID de la page de transclusion (impossible à utiliser avec $1unique).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Ajoute le titre de la transclusion.",
+ "apihelp-query+alltransclusions-param-namespace": "L’espace de noms à énumérer.",
+ "apihelp-query+alltransclusions-param-limit": "Combien d’éléments renvoyer au total.",
+ "apihelp-query+alltransclusions-param-dir": "L'ordre dans lequel lister.",
+ "apihelp-query+alltransclusions-example-B": "Lister les titres inclus, y compris les manquants, avec les IDs des pages d’où ils viennent, en commençant à <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Lister les titres inclus uniques",
+ "apihelp-query+alltransclusions-example-unique-generator": "Obtient tous les titres inclus, en marquant les manquants.",
+ "apihelp-query+alltransclusions-example-generator": "Obtient les pages contenant les transclusions.",
+ "apihelp-query+allusers-summary": "Énumérer tous les utilisateurs enregistrés.",
+ "apihelp-query+allusers-param-from": "Le nom d’utilisateur auquel démarrer l’énumération.",
+ "apihelp-query+allusers-param-to": "Le nom d’utilisateur auquel stopper l’énumération.",
+ "apihelp-query+allusers-param-prefix": "Rechercher tous les utilisateurs commençant par cette valeur.",
+ "apihelp-query+allusers-param-dir": "Direction du tri.",
+ "apihelp-query+allusers-param-group": "Inclure uniquement les utilisateurs dans les groupes donnés.",
+ "apihelp-query+allusers-param-excludegroup": "Exclure les utilisateurs dans les groupes donnés.",
+ "apihelp-query+allusers-param-rights": "Inclure uniquement les utilisateurs avec les droits indiqués. Ne comprend pas les droits accordés par des groupes implicites ou auto-promus comme *, user ou autoconfirmed.",
+ "apihelp-query+allusers-param-prop": "Quelles informations inclure :",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Ajoute l’information sur le bloc actuel d’un utilisateur.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Liste des groupes auxquels appartient l’utilisateur. Cela utilise beaucoup de ressources du serveur et peut renvoyer moins de résultats que la limite.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Liste tous les groupes auxquels l’utilisateur est affecté automatiquement.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Liste les droits qu’a l’utilisateur.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Ajoute le compteur de modifications de l’utilisateur.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Ajoute l’horodatage de l’inscription de l’utilisateur, s’il est disponible (peut être vide).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Ajoute les IDs centraux et l’état d’attachement de l’utilisateur.",
+ "apihelp-query+allusers-param-limit": "Combien de noms d’utilisateur renvoyer au total.",
+ "apihelp-query+allusers-param-witheditsonly": "Ne lister que les utilisateurs qui ont fait des modifications.",
+ "apihelp-query+allusers-param-activeusers": "Lister uniquement les utilisateurs actifs durant {{PLURAL:$1|le dernier jour|les $1 derniers jours}}.",
+ "apihelp-query+allusers-param-attachedwiki": "Avec <kbd>$1prop=centralids</kbd>, indiquer aussi si l’utilisateur est attaché avec le wiki identifié par cet ID.",
+ "apihelp-query+allusers-example-Y": "Lister les utilisateurs en commençant à <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Récupérer les informations concernant l’état d’authentification actuel.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Tester si l’état d’authentification actuel de l’utilisateur est suffisant pour l’opération spécifiée comme sensible du point de vue sécurité.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Récupérer les informations sur les requêtes d’authentification nécessaires pour l’action d’authentification spécifiée.",
+ "apihelp-query+authmanagerinfo-example-login": "Récupérer les requêtes qui peuvent être utilisées en commençant une connexion.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Récupérer les requêtes qui peuvent être utilisées au début de la connexion, avec les champs de formulaire intégrés.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Tester si l’authentification est suffisante pour l’action <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "Trouver toutes les pages qui ont un lien vers la page donnée.",
+ "apihelp-query+backlinks-param-title": "Titre à rechercher. Impossible à utiliser avec <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "ID de la page à chercher. Impossible à utiliser avec <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "L’espace de noms à énumérer.",
+ "apihelp-query+backlinks-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+backlinks-param-filterredir": "Comment filtrer les redirections. Si positionné à <kbd>nonredirects</kbd> quand <var>$1redirect</var> est activé, cela ne s’applique qu’au second niveau.",
+ "apihelp-query+backlinks-param-limit": "Combien de pages renvoyer au total. Si <var>$1redirect</var> est activé, la limite s’applique à chaque niveau séparément (ce qui signifie jusqu’à 2 * <var>$1limit</var> résultats pouvant être retournés).",
+ "apihelp-query+backlinks-param-redirect": "Si le lien vers une page est une redirection, trouver également toutes les pages qui ont un lien vers cette redirection. La limite maximale est divisée par deux.",
+ "apihelp-query+backlinks-example-simple": "Afficher les liens vers <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Obtenir des informations sur les pages ayant un lien vers <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Lister tous les utilisateurs et les adresses IP bloqués.",
+ "apihelp-query+blocks-param-start": "L’horodatage auquel démarrer l’énumération.",
+ "apihelp-query+blocks-param-end": "L’horodatage auquel arrêter l’énumération.",
+ "apihelp-query+blocks-param-ids": "Liste des IDs de bloc à lister (facultatif).",
+ "apihelp-query+blocks-param-users": "Liste des utilisateurs à rechercher (facultatif).",
+ "apihelp-query+blocks-param-ip": "Obtenir tous les blocs s’appliquant à cette adresse IP ou à cette plage CIDR, y compris les blocs de plage.\nImpossible à utiliser avec <var>$3users</var>. Les plages CIDR plus larges que IPv4/$1 ou IPv6/$2 ne sont pas acceptées.",
+ "apihelp-query+blocks-param-limit": "Le nombre maximal de blocs à lister.",
+ "apihelp-query+blocks-param-prop": "Quelles propriétés obtenir :",
+ "apihelp-query+blocks-paramvalue-prop-id": "Ajoute l’ID du blocage.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Ajoute le nom de l’utilisateur bloqué.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Ajoute l’ID de l’utilisateur bloqué.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Ajoute le nom de l’utilisateur ayant bloqué.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Ajoute l’ID de l’utilisateur ayant bloqué.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Ajoute l’horodatage du blocage.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Ajoute l’horodatage d’expiration du blocage.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Ajoute le motif du blocage.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Ajoute la plage d’adresses IP affectée par le blocage.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Marque le bannissement avec (autoblock, anononly, etc.).",
+ "apihelp-query+blocks-param-show": "Afficher uniquement les éléments correspondant à ces critères.\nPar exemple, pour voir uniquement les blocages infinis sur les adresses IP, mettre <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "Lister les blocages",
+ "apihelp-query+blocks-example-users": "Lister les blocages des utilisateurs <kbd>Alice</kbd> et <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Lister toutes les catégories auxquelles les pages appartiennent.",
+ "apihelp-query+categories-param-prop": "Quelles propriétés supplémentaires obtenir de chaque catégorie :",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Ajoute la clé de tri (chaîne hexadécimale) et son préfixe (partie lisible) de la catégorie.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Ajoute l’horodatage de l’ajout de la catégorie.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Marque les catégories cachées avec <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "Quelle sorte de catégories afficher.",
+ "apihelp-query+categories-param-limit": "Combien de catégories renvoyer.",
+ "apihelp-query+categories-param-categories": "Lister uniquement ces catégories. Utile pour vérifier si une certaine page est dans une catégorie donnée.",
+ "apihelp-query+categories-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+categories-example-simple": "Obtenir une liste des catégories auxquelles appartient la page <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categories-example-generator": "Obtenir des informations sur toutes les catégories utilisées dans la page <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "Renvoie les informations sur les catégories données.",
+ "apihelp-query+categoryinfo-example-simple": "Obtenir des informations sur <kbd>Category:Foo</kbd> et <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Lister toutes les pages d’une catégorie donnée.",
+ "apihelp-query+categorymembers-param-title": "Quelle catégorie énumérer (obligatoire). Doit comprendre le préfixe <kbd>{{ns:category}}:</kbd>. Impossible à utiliser avec <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "ID de la page de la catégorie à énumérer. Impossible à utiliser avec <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "Quelles informations inclure :",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Ajoute l’ID de la page.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Ajoute le titre et l’ID de l’espace de noms de la page.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Ajoute la clé de tri utilisée pour trier dans la catégorie (chaîne hexadécimale).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Ajoute le préfixe de la clé de tri utilisé pour trier dans la catégorie (partie lisible de la clé de tri).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Ajoute le type dans lequel a été catégorisée la page (<samp>page</samp>, <samp>subcat</samp> ou <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Ajoute l’horodatage de l’inclusion de la page.",
+ "apihelp-query+categorymembers-param-namespace": "Inclure uniquement les pages dans ces espaces de nom. Remarquez que <kbd>$1type=subcat</kbd> ou <kbd>$1type=file</kbd> peuvent être utilisés à la place de <kbd>$1namespace=14</kbd> ou <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-type": "Quel type de membres de la catégorie inclure. Ignoré quand <kbd>$1sort=timestamp</kbd> est positionné.",
+ "apihelp-query+categorymembers-param-limit": "Le nombre maximal de pages à renvoyer.",
+ "apihelp-query+categorymembers-param-sort": "Propriété par laquelle trier.",
+ "apihelp-query+categorymembers-param-dir": "Dans quelle direction trier.",
+ "apihelp-query+categorymembers-param-start": "Horodatage auquel démarrer la liste. Peut être utilisé uniquement avec <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "Horodatage auquel terminer la liste. Peut être utilisé uniquement avec <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Clé de tri à laquelle démarrer le listage, telle que renvoyée par <kbd>$1prop=sortkey</kbd>. Utilisable uniquement avec <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Clé de tri à laquelle arrêter le listage, telle que renvoyée par <kbd>$1prop=sortkey</kbd>. Utilisable uniquement avec <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "Préfixe de la clé de tri à laquelle démarrer le listage. Utilisable uniquement avec <kbd>$1sort=sortkey</kbd>. Écrase <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "Préfixe de la clé de tri <strong>avant</strong> laquelle se termine le listage (et non pas <strong>à</strong> ; si cette valeur existe elle ne sera pas incluse !). Utilisable uniquement avec $1sort=sortkey. Écrase $1endhexsortkey.",
+ "apihelp-query+categorymembers-param-startsortkey": "Utiliser plutôt $1starthexsortkey.",
+ "apihelp-query+categorymembers-param-endsortkey": "Utiliser plutôt $1endhexsortkey.",
+ "apihelp-query+categorymembers-example-simple": "Obtenir les 10 premières pages de <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Obtenir l’information sur les 10 premières pages de <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Obtenir la liste des contributeurs connectés et le nombre de contributeurs anonymes d’une page.",
+ "apihelp-query+contributors-param-group": "Inclut uniquement les utilisateurs dans les groupes donnés. N'inclut pas les groupes implicites ou auto-promus comme *, user ou autoconfirmed.",
+ "apihelp-query+contributors-param-excludegroup": "Exclure les utilisateurs des groupes donnés. Ne pas inclure les groupes implicites ou auto-promus comme *, user ou autoconfirmed.",
+ "apihelp-query+contributors-param-rights": "Inclure uniquement les utilisateurs ayant les droits donnés. Ne pas inclure les droits accordés par les groupes implicites ou auto-promus comme *, user ou autoconfirmed.",
+ "apihelp-query+contributors-param-excluderights": "Exclure les utilisateurs ayant les droits donnés. Ne pas inclure les droits accordés par les groupes implicites ou auto-promus comme *, user ou autoconfirmed.",
+ "apihelp-query+contributors-param-limit": "Combien de contributeurs renvoyer.",
+ "apihelp-query+contributors-example-simple": "Afficher les contributeurs dans la <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-summary": "Obtenir des informations sur la révision supprimée.",
+ "apihelp-query+deletedrevisions-extended-description": "Peut être utilisé de différentes manières :\n# Obtenir les révisions supprimées pour un ensemble de pages, en donnant les titres ou les ids de page. Ordonné par titre et horodatage.\n# Obtenir des données sur un ensemble de révisions supprimées en donnant leurs IDs et leurs ids de révision. Ordonné par ID de révision.",
+ "apihelp-query+deletedrevisions-param-start": "L’horodatage auquel démarrer l’énumération. Ignoré lors du traitement d’une liste d’IDs de révisions.",
+ "apihelp-query+deletedrevisions-param-end": "L’horodatage auquel arrêter l’énumération. Ignoré lors du traitement d’une liste d’IDs de révisions.",
+ "apihelp-query+deletedrevisions-param-tag": "Lister uniquement les révisions marquées par cette balise.",
+ "apihelp-query+deletedrevisions-param-user": "Lister uniquement les révisions faites par cet utilisateur.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Ne pas lister les révisions faites par cet utilisateur.",
+ "apihelp-query+deletedrevisions-example-titles": "Lister les révisions supprimées des pages <kbd>Main Page</kbd> et <kbd>Talk:Main Page</kbd>, avec leur contenu.",
+ "apihelp-query+deletedrevisions-example-revids": "Lister les informations pour la révision supprimée <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "Afficher les versions supprimées.",
+ "apihelp-query+deletedrevs-extended-description": "Opère selon trois modes :\n# Lister les révisions supprimées pour les titres donnés, triées par horodatage.\n# Lister les contributions supprimées pour l’utilisateur donné, triées par horodatage (pas de titres spécifiés).\n# Lister toutes les révisions supprimées dans l’espace de noms donné, triées par titre et horodatage (aucun titre spécifié, $1user non positionné).\n\nCertains paramètres ne s’appliquent qu’à certains modes et sont ignorés dans les autres.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Mode|Modes}} : $2",
+ "apihelp-query+deletedrevs-param-start": "L’horodatage auquel démarrer l’énumération.",
+ "apihelp-query+deletedrevs-param-end": "L’horodatage auquel arrêter l’énumération.",
+ "apihelp-query+deletedrevs-param-from": "Démarrer la liste à ce titre.",
+ "apihelp-query+deletedrevs-param-to": "Arrêter la liste à ce titre.",
+ "apihelp-query+deletedrevs-param-prefix": "Rechercher tous les titres de page commençant par cette valeur.",
+ "apihelp-query+deletedrevs-param-unique": "Lister uniquement une révision pour chaque page.",
+ "apihelp-query+deletedrevs-param-tag": "Lister uniquement les révisions marquées par cette balise.",
+ "apihelp-query+deletedrevs-param-user": "Lister uniquement les révisions par cet utilisateur.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Ne pas lister les révisions par cet utilisateur.",
+ "apihelp-query+deletedrevs-param-namespace": "Lister uniquement les pages dans cet espace de noms.",
+ "apihelp-query+deletedrevs-param-limit": "Le nombre maximal de révisions à lister.",
+ "apihelp-query+deletedrevs-param-prop": "Quelles propriétés obtenir :\n;revid : Ajoute l’ID de la révision supprimée.\n;parentid : Ajoute l’ID de la révision précédente de la page.\n;user : Ajoute l’utilisateur ayant fait la révision.\n;userid : Ajoute l’ID de l’utilisateur qui a fait la révision.\n;comment : Ajoute le commentaire de la révision.\n;parsedcomment : Ajoute le commentaire analysé de la révision.\n;minor : Marque si la révision est mineure.\n;len : Ajoute la longueur (en octets) de la révision.\n;sha1 : Ajoute le SHA-1 (base 16) de la révision.\n;content : Ajoute le contenu de la révision.\n;token : <span class=\"apihelp-deprecated\">Désuet.</span> Fournit le jeton de modification.\n;tags : Balises pour la révision.",
+ "apihelp-query+deletedrevs-example-mode1": "Lister les dernières révisions supprimées des pages <kbd>Main Page</kbd> et <kbd>Talk:Main Page</kbd>, avec le contenu (mode 1).",
+ "apihelp-query+deletedrevs-example-mode2": "Lister les 50 dernières contributions de <kbd>Bob</kbd> supprimées (mode 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Lister les 50 premières révisions supprimées dans l’espace de noms principal (mode 3)",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Lister les 50 premières pages supprimées dans l’espace de noms {{ns:talk}} (mode 3).",
+ "apihelp-query+disabled-summary": "Ce module de requête a été désactivé.",
+ "apihelp-query+duplicatefiles-summary": "Lister d’après leurs valeurs de hachage, tous les fichiers qui sont des doublons de fichiers donnés.",
+ "apihelp-query+duplicatefiles-param-limit": "Combien de fichiers dupliqués à renvoyer.",
+ "apihelp-query+duplicatefiles-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+duplicatefiles-param-localonly": "Rechercher les fichiers uniquement dans le référentiel local.",
+ "apihelp-query+duplicatefiles-example-simple": "Rechercher les doublons de [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Rechercher les doublons de tous les fichiers",
+ "apihelp-query+embeddedin-summary": "Trouver toutes les pages qui incluent (par transclusion) le titre donné.",
+ "apihelp-query+embeddedin-param-title": "Titre à rechercher. Impossible à utiliser avec $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "ID de la page à rechercher. Impossible à utiliser avec $1title.",
+ "apihelp-query+embeddedin-param-namespace": "L’espace de noms à énumérer.",
+ "apihelp-query+embeddedin-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+embeddedin-param-filterredir": "Comment filtrer les redirections.",
+ "apihelp-query+embeddedin-param-limit": "Combien de pages renvoyer au total.",
+ "apihelp-query+embeddedin-example-simple": "Afficher les pages incluant <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "Obtenir des informations sur les pages incluant <kbd>Template:Stub</kbd>.",
+ "apihelp-query+extlinks-summary": "Renvoyer toutes les URLs externes (non interwikis) des pages données.",
+ "apihelp-query+extlinks-param-limit": "Combien de liens renvoyer.",
+ "apihelp-query+extlinks-param-protocol": "Protocole de l’URL. Si vide et <var>$1query</var> est positionné, le protocole est <kbd>http</kbd>. Laisser à la fois ceci et <var>$1query</var> vides pour lister tous les liens externes.",
+ "apihelp-query+extlinks-param-query": "Rechercher une chaîne sans protocole. Utile pour vérifier si une certaine page contient une certaine URL externe.",
+ "apihelp-query+extlinks-param-expandurl": "Étendre les URLs relatives au protocole avec le protocole canonique.",
+ "apihelp-query+extlinks-example-simple": "Obtenir une liste des liens externes de <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-summary": "Énumérer les pages contenant une URL donnée.",
+ "apihelp-query+exturlusage-param-prop": "Quelles informations inclure :",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Ajoute l’ID de la page.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Ajoute le titre et l’ID de l’espace de noms de la page.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Ajoute l’URL utilisée dans la page.",
+ "apihelp-query+exturlusage-param-protocol": "Protocole de l’URL. Si vide et que <var>$1query</var> est rempli, le protocole est <kbd>http</kbd>. Le laisser avec <var>$1query</var> vide pour lister tous les liens externes.",
+ "apihelp-query+exturlusage-param-query": "Rechercher une chaîne sans protocole. Voyez [[Special:LinkSearch]]. Le laisser vide pour lister tous les liens externes.",
+ "apihelp-query+exturlusage-param-namespace": "Les espaces de nom à énumérer.",
+ "apihelp-query+exturlusage-param-limit": "Combien de pages renvoyer.",
+ "apihelp-query+exturlusage-param-expandurl": "Étendre les URLs relatives au protocole avec le protocole canonique.",
+ "apihelp-query+exturlusage-example-simple": "Afficher les pages avec un lien vers <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-summary": "Énumérer séquentiellement tous les fichiers supprimés.",
+ "apihelp-query+filearchive-param-from": "Le titre de l’image auquel démarrer l’énumération.",
+ "apihelp-query+filearchive-param-to": "Le titre de l’image auquel arrêter l’énumération.",
+ "apihelp-query+filearchive-param-prefix": "Rechercher tous les titres d’image qui commencent par cette valeur.",
+ "apihelp-query+filearchive-param-limit": "Combien d’images renvoyer au total.",
+ "apihelp-query+filearchive-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+filearchive-param-sha1": "Hachage SHA1 de l’image. Écrase $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "Hachage SHA1 de l’image en base 36 (utilisé dans MédiaWiki).",
+ "apihelp-query+filearchive-param-prop": "Quelle information obtenir sur l’image :",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Ajoute le hachage SHA-1 pour l’image.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Ajoute l’horodatage à la version téléversée.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Ajoute l’utilisateur qui a téléversé la version de l’image.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Ajoute la taille de l’image en octets et la hauteur, la largeur et le nombre de page (si c’est applicable).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Alias pour la taille.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Ajoute la description de la version de l’image.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Analyser la description de la version.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Ajoute le MIME de l’image.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Ajoute le type de média de l’image.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Liste les métadonnées Exif pour la version de l’image.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Ajoute la profondeur de bits de la version.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Ajoute le nom de fichier de la version d’archive pour les versions autres que la dernière.",
+ "apihelp-query+filearchive-example-simple": "Afficher une liste de tous les fichiers supprimés",
+ "apihelp-query+filerepoinfo-summary": "Renvoyer les méta-informations sur les référentiels d’images configurés dans le wiki.",
+ "apihelp-query+filerepoinfo-param-prop": "Quelles propriétés du référentiel récupérer (il peut y en avoir plus de disponibles sur certains wikis) :\n;apiurl:URL de l’API du référentiel - utile pour obtenir les infos de l’image depuis l’hôte.\n;name:La clé du référentiel - utilisé par ex. dans les valeurs de retour de <var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> et [[Special:ApiHelp/query+imageinfo|imageinfo]].\n;displayname:Le nom lisible du wiki référentiel.\n;rooturl:URL racine des chemins d’image.\n;local:Si ce référentiel est le référentiel local ou non.",
+ "apihelp-query+filerepoinfo-example-simple": "Obtenir des informations sur les référentiels de fichier.",
+ "apihelp-query+fileusage-summary": "Trouver toutes les pages qui utilisent les fichiers donnés.",
+ "apihelp-query+fileusage-param-prop": "Quelles propriétés obtenir :",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "ID de chaque page.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Titre de chaque page.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Marque si la page est une redirection.",
+ "apihelp-query+fileusage-param-namespace": "Inclure uniquement les pages dans ces espaces de nom.",
+ "apihelp-query+fileusage-param-limit": "Combien renvoyer.",
+ "apihelp-query+fileusage-param-show": "Afficher uniquement les éléments qui correspondent à ces critères :\n;redirect:Afficher uniquement les redirections.\n;!redirect:Afficher uniquement les non-redirections.",
+ "apihelp-query+fileusage-example-simple": "Obtenir une liste des pages utilisant [[:File:Example.jpg]]",
+ "apihelp-query+fileusage-example-generator": "Obtenir l’information sur les pages utilisant [[:File:Example.jpg]]",
+ "apihelp-query+imageinfo-summary": "Renvoyer l’information de fichier et l’historique de téléversement.",
+ "apihelp-query+imageinfo-param-prop": "Quelle information obtenir du fichier :",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Ajoute l’horodatage à la version téléversée.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Ajoute l’utilisateur qui a téléversé chaque version du fichier.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Ajouter l’ID de l’utilisateur qui a téléversé chaque version du fichier.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Commentaire sur la version.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Analyser le commentaire de la version.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Ajoute le titre canonique du fichier.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Fournit l’URL du fichier et de la page de description.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Ajoute la taille du fichier en octets et sa hauteur, sa largeur et le compteur de page (le cas échéant).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Alias pour la taille.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Ajoute le hachage SH1-1 du fichier.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Ajoute le type MIME du fichier.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Ajoute le type MIME de la vignette de l’image (nécessite l’URL et le paramètre $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Ajoute le type de média du fichier.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Liste les métadonnées Exif de la version du fichier.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Liste les métadonnées génériques du format du fichier pour la version du fichier.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Liste les métadonnées mises en forme combinées depuis diverses sources. Les résultats sont au format HTML.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Ajoute le nom de fichier de la version d’archive pour les versions autres que la dernière.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Ajoute la profondeur de bits de la version.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Utilisé par la page Special:Upload pour obtenir de l’information sur un fichier existant. Non prévu pour être utilisé en dehors du cœur de MédiaWiki.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Ajoute l'indication que le fichier est sur [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-limit": "Combien de révisions de fichier renvoyer par fichier.",
+ "apihelp-query+imageinfo-param-start": "Horodatage auquel démarrer la liste.",
+ "apihelp-query+imageinfo-param-end": "Horodatage auquel arrêter la liste.",
+ "apihelp-query+imageinfo-param-urlwidth": "Si $2prop=url est défini, une URL vers une image à l’échelle de cette largeur sera renvoyée.\nPour des raisons de performance si cette option est utilisée, pas plus de $1 images mises à l’échelle seront renvoyées.",
+ "apihelp-query+imageinfo-param-urlheight": "Similaire à $1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "Version de métadonnées à utiliser. Si <kbd>latest</kbd> est spécifié, utiliser la dernière version. Par défaut à <kbd>1</kbd> pour la compatibilité ascendante.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "Quelle langue pour analyser extmetadata. Cela affecte à la fois quelle traduction analyser, s’il y en a plusieurs, et comment les choses comme les nombres et d’autres valeurs sont mises en forme.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Si des traductions pour la propriété extmetadata sont disponibles, les analyser toutes.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Si spécifié et non vide, seules ces clés seront renvoyées pour $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "Une chaîne de paramètres spécifique à l’analyseur. Par exemple, les PDFs peuvent utiliser <kbd>page15-100px</kbd>. <var>$1urlwidth</var> doit être utilisé et être cohérent avec <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Si <kbd>$2prop=badfile</kbd> est positionné, il s'agit du titre de la page utilisé pour évaluer la [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-localonly": "Rechercher les fichiers uniquement dans le référentiel local.",
+ "apihelp-query+imageinfo-example-simple": "Analyser les informations sur la version actuelle de [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "Analyser les informations sur les versions de [[:File:Test.jpg]] depuis 2008.",
+ "apihelp-query+images-summary": "Renvoie tous les fichiers contenus dans les pages fournies.",
+ "apihelp-query+images-param-limit": "Combien de fichiers renvoyer.",
+ "apihelp-query+images-param-images": "Lister uniquement ces fichiers. Utile pour vérifier si une page donnée contient un fichier donné.",
+ "apihelp-query+images-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+images-example-simple": "Obtenir une liste des fichiers utilisés dans [[Main Page]]",
+ "apihelp-query+images-example-generator": "Obtenir des informations sur tous les fichiers utilisés dans [[Main Page]]",
+ "apihelp-query+imageusage-summary": "Trouver toutes les pages qui utilisent le titre de l’image donné.",
+ "apihelp-query+imageusage-param-title": "Titre à rechercher. Impossible à utiliser avec $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "ID de la page à rechercher. Impossible à utiliser avec $1title.",
+ "apihelp-query+imageusage-param-namespace": "L’espace de noms à énumérer.",
+ "apihelp-query+imageusage-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+imageusage-param-filterredir": "Comment filtrer les redirections. Si mis à nonredirects quand $1redirect est activé, cela ne s’appliquera qu’au second niveau.",
+ "apihelp-query+imageusage-param-limit": "Combien de pages renvoyer au total. Si <var>$1redirect</var> est activé, la limite s’applique à chaque niveau séparément (ce qui veut dire que jusqu’à 2 * <var>$1limit</var> résultats peuvent être renvoyés).",
+ "apihelp-query+imageusage-param-redirect": "Si le lien vers une page est une redirection, trouver toutes les pages qui ont aussi un lien vers cette redirection. La limite maximale est divisée par deux.",
+ "apihelp-query+imageusage-example-simple": "Afficher les pages utilisant [[:File:Albert Einstein Head.jpg]]",
+ "apihelp-query+imageusage-example-generator": "Obtenir des informations sur les pages utilisant [[:File:Albert Einstein Head.jpg]]",
+ "apihelp-query+info-summary": "Obtenir les informations de base sur la page.",
+ "apihelp-query+info-param-prop": "Quelles propriétés supplémentaires récupérer :",
+ "apihelp-query+info-paramvalue-prop-protection": "Lister le niveau de protection de chaque page.",
+ "apihelp-query+info-paramvalue-prop-talkid": "L’ID de la page de discussion de chaque page qui n’est pas de discussion.",
+ "apihelp-query+info-paramvalue-prop-watched": "Lister l’état de suivi de chaque page.",
+ "apihelp-query+info-paramvalue-prop-watchers": "Le nombre d’observateurs, si c’est autorisé.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "Le nombre de personnes suivant chaque page qui ont regardé les modifications récentes de cette page, si c’est autorisé.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "L’horodatage de notification de la liste de suivi de chaque page.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "L’ID de page de la page parent de chaque page de discussion.",
+ "apihelp-query+info-paramvalue-prop-url": "Fournit une URL complète, une URL de modification, et l’URL canonique de chaque page.",
+ "apihelp-query+info-paramvalue-prop-readable": "Si l’utilisateur peut lire cette page.",
+ "apihelp-query+info-paramvalue-prop-preload": "Fournit le texte renvoyé par EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Fournit la manière dont le titre de la page est réellement affiché.",
+ "apihelp-query+info-param-testactions": "Tester si l’utilisateur actuel peut effectuer certaines actions sur la page.",
+ "apihelp-query+info-param-token": "Utiliser plutôt [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-query+info-example-simple": "Obtenir des informations sur la page <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Obtenir des informations générales et de protection sur la page <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-summary": "Trouver toutes les pages qui ont un lien vers le lien interwiki indiqué.",
+ "apihelp-query+iwbacklinks-extended-description": "Peut être utilisé pour trouver tous les liens avec un préfixe, ou tous les liens vers un titre (avec un préfixe donné). Sans paramètre, équivaut à « tous les liens interwiki ».",
+ "apihelp-query+iwbacklinks-param-prefix": "Préfixe pour l’interwiki.",
+ "apihelp-query+iwbacklinks-param-title": "Lien interwiki à rechercher. Doit être utilisé avec <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "Combien de pages renvoyer.",
+ "apihelp-query+iwbacklinks-param-prop": "Quelles propriétés obtenir :",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Ajoute le préfixe de l’interwiki.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Ajoute le titre de l’interwiki.",
+ "apihelp-query+iwbacklinks-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+iwbacklinks-example-simple": "Obtenir les pages qui ont un lien vers [[wikibooks:Test]].",
+ "apihelp-query+iwbacklinks-example-generator": "Obtenir des informations sur les pages qui ont un lien vers [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-summary": "Renvoie tous les liens interwiki des pages indiquées.",
+ "apihelp-query+iwlinks-param-url": "S'il faut obtenir l’URL complète (impossible à utiliser avec $1prop).",
+ "apihelp-query+iwlinks-param-prop": "Quelles propriétés supplémentaires obtenir pour chaque lien interlangue :",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Ajoute l’URL complète.",
+ "apihelp-query+iwlinks-param-limit": "Combien de liens interwiki renvoyer.",
+ "apihelp-query+iwlinks-param-prefix": "Renvoyer uniquement les liens interwiki avec ce préfixe.",
+ "apihelp-query+iwlinks-param-title": "Lien interwiki à rechercher. Doit être utilisé avec <var>$1prefix</var>.",
+ "apihelp-query+iwlinks-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+iwlinks-example-simple": "Obtenir les liens interwiki de la page <kbd>Main Page</kbd>.",
+ "apihelp-query+langbacklinks-summary": "Trouver toutes les pages qui ont un lien vers le lien de langue indiqué.",
+ "apihelp-query+langbacklinks-extended-description": "Peut être utilisé pour trouver tous les liens avec un code de langue, ou tous les liens vers un titre (avec une langue donnée). Sans paramètre équivaut à « tous les liens de langue ».\n\nNotez que cela peut ne pas prendre en compte les liens de langue ajoutés par les extensions.",
+ "apihelp-query+langbacklinks-param-lang": "Langue pour le lien de langue.",
+ "apihelp-query+langbacklinks-param-title": "Lien interlangue à rechercher. Doit être utilisé avec $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "Combien de pages renvoyer au total.",
+ "apihelp-query+langbacklinks-param-prop": "Quelles propriétés obtenir :",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Ajoute le code de langue du lien de langue.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Ajoute le titre du lien de langue.",
+ "apihelp-query+langbacklinks-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+langbacklinks-example-simple": "Obtenir les pages ayant un lien vers [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Obtenir des informations sur les pages ayant un lien vers [[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "Renvoie tous les liens interlangue des pages fournies.",
+ "apihelp-query+langlinks-param-limit": "Combien de liens interlangue renvoyer.",
+ "apihelp-query+langlinks-param-url": "S’il faut récupérer l’URL complète (impossible à utiliser avec <var>$1prop</var>).",
+ "apihelp-query+langlinks-param-prop": "Quelles propriétés supplémentaires obtenir pour chaque lien interlangue :",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Ajoute l’URL complète.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "Ajoute le nom localisé de la langue (au mieux). Utiliser <var>$1inlanguagecode</var> pour contrôler la langue.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Ajoute le nom natif de la langue.",
+ "apihelp-query+langlinks-param-lang": "Renvoyer uniquement les liens interlangue avec ce code de langue.",
+ "apihelp-query+langlinks-param-title": "Lien à rechercher. Doit être utilisé avec <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Code de langue pour les noms de langue localisés.",
+ "apihelp-query+langlinks-example-simple": "Obtenir les liens interlangue de la page <kbd>Main Page</kbd>.",
+ "apihelp-query+links-summary": "Renvoie tous les liens des pages fournies.",
+ "apihelp-query+links-param-namespace": "Afficher les liens uniquement dans ces espaces de noms.",
+ "apihelp-query+links-param-limit": "Combien de liens renvoyer.",
+ "apihelp-query+links-param-titles": "Lister uniquement les liens vers ces titres. Utile pour vérifier si une certaine page a un lien vers un titre donné.",
+ "apihelp-query+links-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+links-example-simple": "Obtenir les liens de la page <kbd>Main Page</kbd>",
+ "apihelp-query+links-example-generator": "Obtenir des informations sur tous les liens de page dans <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-namespaces": "Obtenir les liens de la page <kbd>Main Page</kbd> dans les espaces de nom {{ns:user}} et {{ns:template}}.",
+ "apihelp-query+linkshere-summary": "Trouver toutes les pages ayant un lien vers les pages données.",
+ "apihelp-query+linkshere-param-prop": "Quelles propriétés obtenir :",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "ID de chaque page.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Titre de chaque page.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Indique si la page est une redirection.",
+ "apihelp-query+linkshere-param-namespace": "Inclure uniquement les pages dans ces espaces de noms.",
+ "apihelp-query+linkshere-param-limit": "Combien de résultats renvoyer.",
+ "apihelp-query+linkshere-param-show": "Afficher uniquement les éléments qui correspondent à ces critères :\n;redirect:Afficher uniquement les redirections.\n;!redirect:Afficher uniquement les non-redirections.",
+ "apihelp-query+linkshere-example-simple": "Obtenir une liste des pages liées à [[Main Page]]",
+ "apihelp-query+linkshere-example-generator": "Obtenir des informations sur les pages liées à [[Main Page]]",
+ "apihelp-query+logevents-summary": "Récupère les événements à partir des journaux.",
+ "apihelp-query+logevents-param-prop": "Quelles propriétés obtenir :",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Ajoute l’ID de l’événement.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Ajoute le titre de la page pour l’événement enregistré.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Ajoute le type de l’événement enregistré.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Ajoute l’utilisateur responsable de l’événement.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Ajoute l’ID de l’utilisateur responsable de l’événement.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "Ajoute l’horodatage de l’événement.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Ajoute le commentaire de l’événement.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "Ajoute le commentaire analysé de l’événement.",
+ "apihelp-query+logevents-paramvalue-prop-details": "Liste les détails supplémentaires sur l’événement.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Liste les balises de l’événement.",
+ "apihelp-query+logevents-param-type": "Filtrer les entrées du journal sur ce seul type.",
+ "apihelp-query+logevents-param-action": "Filtrer les actions du journal sur cette seule action. Écrase <var>$1type</var>. Dans la liste des valeurs possibles, les valeurs suivies d'un astérisque, comme <kbd>action/*</kbd>, peuvent avoir différentes chaînes après le slash.",
+ "apihelp-query+logevents-param-start": "L’horodatage auquel démarrer l’énumération.",
+ "apihelp-query+logevents-param-end": "L’horodatage auquel arrêter l’énumération.",
+ "apihelp-query+logevents-param-user": "Restreindre aux entrées générées par l’utilisateur spécifié.",
+ "apihelp-query+logevents-param-title": "Restreindre aux entrées associées à une page donnée.",
+ "apihelp-query+logevents-param-namespace": "Restreindre aux entrées dans l’espace de noms spécifié.",
+ "apihelp-query+logevents-param-prefix": "Restreindre aux entrées commençant par ce préfixe.",
+ "apihelp-query+logevents-param-tag": "Lister seulement les entrées ayant cette balise.",
+ "apihelp-query+logevents-param-limit": "Combien d'entrées renvoyer au total.",
+ "apihelp-query+logevents-example-simple": "Liste les entrées de journal récentes.",
+ "apihelp-query+pagepropnames-summary": "Lister les noms de toutes les propriétés de page utilisées sur le wiki.",
+ "apihelp-query+pagepropnames-param-limit": "Le nombre maximal de noms à renvoyer.",
+ "apihelp-query+pagepropnames-example-simple": "Obtenir les 10 premiers noms de propriété.",
+ "apihelp-query+pageprops-summary": "Obtenir diverses propriétés de page définies dans le contenu de la page.",
+ "apihelp-query+pageprops-param-prop": "Lister uniquement ces propriétés de page (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> renvoie les noms de propriété de page utilisés). Utile pour vérifier si des pages utilisent une certaine propriété de page.",
+ "apihelp-query+pageprops-example-simple": "Obtenir les propriétés des pages <kbd>Main Page</kbd> et <kbd>MediaWiki</kbd>.",
+ "apihelp-query+pageswithprop-summary": "Lister toutes les pages utilisant une propriété de page donnée.",
+ "apihelp-query+pageswithprop-param-propname": "Propriété de page pour laquelle énumérer les pages (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> renvoie les noms de propriété de page utilisés).",
+ "apihelp-query+pageswithprop-param-prop": "Quelles informations inclure :",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Ajoute l’ID de la page.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Ajoute le titre et l’ID de l’espace de noms de la page.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Ajoute la valeur de la propriété de page.",
+ "apihelp-query+pageswithprop-param-limit": "Le nombre maximal de pages à renvoyer.",
+ "apihelp-query+pageswithprop-param-dir": "Dans quelle direction trier.",
+ "apihelp-query+pageswithprop-example-simple": "Lister les 10 premières pages en utilisant <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "Obtenir des informations supplémentaires sur les 10 premières pages utilisant <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-summary": "Effectuer une recherche de préfixe sur les titres de page.",
+ "apihelp-query+prefixsearch-extended-description": "Malgré les similarités dans le nom, ce module n’est pas destiné à être l’équivalent de [[Special:PrefixIndex]] ; pour cela, voyez <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> avec le paramètre <kbd>apprefix</kbd>. Le but de ce module est similaire à <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd> : prendre l’entrée utilisateur et fournir les meilleurs titres s’en approchant. Selon le serveur du moteur de recherche, cela peut inclure corriger des fautes de frappe, éviter des redirections, ou d’autres heuristiques.",
+ "apihelp-query+prefixsearch-param-search": "Chaîne de recherche.",
+ "apihelp-query+prefixsearch-param-namespace": "Espaces de noms à rechercher.",
+ "apihelp-query+prefixsearch-param-limit": "Nombre maximal de résultats à renvoyer.",
+ "apihelp-query+prefixsearch-param-offset": "Nombre de résultats à sauter.",
+ "apihelp-query+prefixsearch-example-simple": "Rechercher les titres de page commençant par <kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "Rechercher le profil à utiliser.",
+ "apihelp-query+protectedtitles-summary": "Lister tous les titres protégés en création.",
+ "apihelp-query+protectedtitles-param-namespace": "Lister uniquement les titres dans ces espaces de nom.",
+ "apihelp-query+protectedtitles-param-level": "Lister uniquement les titres avec ces niveaux de protection.",
+ "apihelp-query+protectedtitles-param-limit": "Combien de pages renvoyer au total.",
+ "apihelp-query+protectedtitles-param-start": "Démarrer la liste à cet horodatage de protection.",
+ "apihelp-query+protectedtitles-param-end": "Arrêter la liste à cet horodatage de protection.",
+ "apihelp-query+protectedtitles-param-prop": "Quelles propriétés obtenir :",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "Ajoute l’horodatage de l’ajout de la protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Ajoute l’utilisateur ayant ajouté la protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Ajoute l’ID de l’utilisateur ayant ajouté la protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Ajoute le commentaire pour la protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "Ajoute le commentaire analysé de la protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "Ajoute l’horodatage de levée de la protection.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Ajoute le niveau de protection.",
+ "apihelp-query+protectedtitles-example-simple": "Lister les titres protégés",
+ "apihelp-query+protectedtitles-example-generator": "Trouver les liens vers les titres protégés dans l’espace de noms principal.",
+ "apihelp-query+querypage-summary": "Obtenir une liste fournie par une page spéciale basée sur QueryPage.",
+ "apihelp-query+querypage-param-page": "Le nom de la page spéciale. Notez que ce nom est sensible à la casse.",
+ "apihelp-query+querypage-param-limit": "Nombre de résultats à renvoyer.",
+ "apihelp-query+querypage-example-ancientpages": "Renvoyer les résultats de [[Special:Ancientpages]].",
+ "apihelp-query+random-summary": "Récupèrer un ensemble de pages au hasard.",
+ "apihelp-query+random-extended-description": "Les pages sont listées dans un ordre prédéterminé, seul le point de départ est aléatoire. Par exemple, cela signifie que si la première page dans la liste est <samp>Accueil</samp>, la seconde sera <em>toujours</em> <samp>Liste des singes de fiction</samp>, la troisième <samp>Liste de personnes figurant sur les timbres de Vanuatu</samp>, etc.",
+ "apihelp-query+random-param-namespace": "Renvoyer seulement des pages de ces espaces de noms.",
+ "apihelp-query+random-param-limit": "Limiter le nombre de pages aléatoires renvoyées.",
+ "apihelp-query+random-param-redirect": "Utilisez <kbd>$1filterredir=redirects</kbd> au lieu de ce paramètre.",
+ "apihelp-query+random-param-filterredir": "Comment filtrer les redirections.",
+ "apihelp-query+random-example-simple": "Obtenir deux pages aléatoires de l’espace de noms principal.",
+ "apihelp-query+random-example-generator": "Renvoyer les informations de la page sur deux pages au hasard de l’espace de noms principal.",
+ "apihelp-query+recentchanges-summary": "Énumérer les modifications récentes.",
+ "apihelp-query+recentchanges-param-start": "L’horodatage auquel démarrer l’énumération.",
+ "apihelp-query+recentchanges-param-end": "L’horodatage auquel arrêter l’énumération.",
+ "apihelp-query+recentchanges-param-namespace": "Filtrer les modifications uniquement sur ces espaces de noms.",
+ "apihelp-query+recentchanges-param-user": "Lister uniquement les modifications faites par cet utilisateur.",
+ "apihelp-query+recentchanges-param-excludeuser": "Ne pas lister les modifications faites par cet utilisateur.",
+ "apihelp-query+recentchanges-param-tag": "Lister uniquement les modifications marquées avec cette balise.",
+ "apihelp-query+recentchanges-param-prop": "Inclure des informations supplémentaires :",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "Ajoute l’utilisateur responsable de la modification et marque s'il s'agit d'une adresse IP.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "Ajoute l’ID de l’utilisateur responsable de la modification.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Ajoute le commentaire de la modification.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "Ajoute le commentaire analysé pour la modification.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Ajoute les balises de la modification.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Ajoute l’horodatage de la modification.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Ajoute le titre de la page modifiée.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "Ajoute l’ID de la page, l’ID des modifications récentes et l’ID de l’ancienne et de la nouvelle révision.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Ajoute l’ancienne et la nouvelle taille de la page en octets.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "Marque la modification si la page est une redirection.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Marque les modifications à relire comme relues ou pas.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Ajoute les informations du journal (Id du journal, type de trace, etc.) aux entrées du journal.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Liste les balises de l’entrée.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Ajoute la somme de contrôle du contenu pour les entrées associées à une révision.",
+ "apihelp-query+recentchanges-param-token": "Utiliser plutôt <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-query+recentchanges-param-show": "Afficher uniquement les éléments correspondant à ces critères. Par exemple, pour voir uniquement les modifications mineures par des utilisateurs connectés, mettre $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "Combien de modifications renvoyer au total.",
+ "apihelp-query+recentchanges-param-type": "Quels types de modification afficher.",
+ "apihelp-query+recentchanges-param-toponly": "Lister uniquement les modifications qui sont de la dernière révision.",
+ "apihelp-query+recentchanges-param-generaterevisions": "Utilisé comme générateur, générer des IDs de révision plutôt que des titres.\nLes entrées de modification récentes sans IDs de révision associé (par ex. la plupart des entrées de journaux) ne généreront rien.",
+ "apihelp-query+recentchanges-example-simple": "Lister les modifications récentes",
+ "apihelp-query+recentchanges-example-generator": "Obtenir l’information de page sur les modifications récentes non relues.",
+ "apihelp-query+redirects-summary": "Renvoie toutes les redirections vers les pages données.",
+ "apihelp-query+redirects-param-prop": "Quelles propriétés récupérer :",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "ID de page de chaque redirection.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Titre de chaque redirection.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "Fragment de chaque redirection, s’il y en a un.",
+ "apihelp-query+redirects-param-namespace": "Inclure uniquement les pages dans ces espaces de noms.",
+ "apihelp-query+redirects-param-limit": "Combien de redirections renvoyer.",
+ "apihelp-query+redirects-param-show": "Afficher uniquement les éléments correspondant à ces critères :\n;fragment:Afficher uniquement les redirections avec un fragment.\n;!fragment:Afficher uniquement les redirections sans fragment.",
+ "apihelp-query+redirects-example-simple": "Obtenir une liste des redirections vers [[Main Page]]",
+ "apihelp-query+redirects-example-generator": "Obtenir des informations sur toutes les redirections vers [[Main Page]]",
+ "apihelp-query+revisions-summary": "Récupèrer les informations de relecture.",
+ "apihelp-query+revisions-extended-description": "Peut être utilisé de différentes manières :\n# Obtenir des données sur un ensemble de pages (dernière révision), en mettant les titres ou les ids de page.\n# Obtenir les révisions d’une page donnée, en utilisant les titres ou les ids de page avec rvstart, rvend ou rvlimit.\n# Obtenir des données sur un ensemble de révisions en donnant leurs IDs avec revids.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Utilisable uniquement avec une seule page (mode #2).",
+ "apihelp-query+revisions-param-startid": "Commencer l'énumération à partir de la date de cette revue. La revue doit exister, mais ne concerne pas forcément cette page.",
+ "apihelp-query+revisions-param-endid": "Arrêter l’énumération à la date de cette revue. La revue doit exister mais ne concerne pas forcément cette page.",
+ "apihelp-query+revisions-param-start": "À quel horodatage de révision démarrer l’énumération.",
+ "apihelp-query+revisions-param-end": "Énumérer jusqu’à cet horodatage.",
+ "apihelp-query+revisions-param-user": "Inclure uniquement les révisions faites par l’utilisateur.",
+ "apihelp-query+revisions-param-excludeuser": "Exclure les révisions faites par l’utilisateur.",
+ "apihelp-query+revisions-param-tag": "Lister uniquement les révisions marquées avec cette balise.",
+ "apihelp-query+revisions-param-token": "Quels jetons obtenir pour chaque révision.",
+ "apihelp-query+revisions-example-content": "Obtenir des données avec le contenu pour la dernière révision des titres <kbd>API</kbd> et <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "Obtenir les 5 dernières révisions de la <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5": "Obtenir les 5 premières révisions de la <kbd>Page principale</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Obtenir les 5 premières révisions de la <kbd>Page principale</kbd> faites après le 01/05/2006.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Obtenir les 5 premières révisions de la <kbd>Page principale</kbd> qui n’ont pas été faites par l’utilisateur anonyme <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "Obtenir les 5 premières révisions de la <kbd>Page principale</kbd> qui ont été faites par l’utilisateur <kbd>MédiaWiki par défaut</kbd>.",
+ "apihelp-query+revisions+base-param-prop": "Quelles propriétés obtenir pour chaque révision :",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "L’ID de la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Marques de la révision (mineure).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "L’horodatage de la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "L’utilisateur qui a fait la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "L’ID de l’utilisateur créateur de la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Longueur (en octets) de la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "Hachage SHA-1 (base 16) de la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "ID du modèle de contenu de la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "Commentaire de l’utilisateur sur la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "Commentaire analysé de l’utilisateur sur la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Texte de la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Balises de la révision.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">Deprecated.</span> Utiliser <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> ou <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> à la place. L’arbre d’analyse XML du contenu de la révision (nécessite le modèle de contenu <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "Limiter le nombre de révisions retournées.",
+ "apihelp-query+revisions+base-param-expandtemplates": "Utiliser <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> à la place. Développer les modèles dans le contenu de la révision (nécessite $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "Utiliser <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> ou <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> à la place. Générer l’arbre d’analyse XML pour le contenu de la révision (nécessite $1prop=content).",
+ "apihelp-query+revisions+base-param-parse": "Utiliser <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> à la place. Analyser le contenu de la révision (nécessite $1prop=content). Pour des raisons de performance, si cette option est utilisée, $1limit est forcé à 1.",
+ "apihelp-query+revisions+base-param-section": "Récupérer uniquement le contenu de ce numéro de section.",
+ "apihelp-query+revisions+base-param-diffto": "Utiliser <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> à la place. ID de révision à prendre pour comparer chaque révision. Utiliser <kbd>prev</kbd>, <kbd>next</kbd> et <kbd>cur</kbd> pour la version précédente, suivante et actuelle respectivement.",
+ "apihelp-query+revisions+base-param-difftotext": "Utiliser <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> à la place. Texte auquel comparer chaque révision. Compare uniquement un nombre limité de révisions. Écrase <var>$1diffto</var>. Si <var>$1section</var> est positionné, seule cette section sera comparée avec ce texte.",
+ "apihelp-query+revisions+base-param-difftotextpst": "Utiliser <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> à la place. Effectuer une transformation avant enregistrement sur le texte avant de le comparer. Valide uniquement quand utilisé avec <var>$1difftotext</var>.",
+ "apihelp-query+revisions+base-param-contentformat": "Format de sérialisation utilisé pour <var>$1difftotext</var> et attendu pour la sortie du contenu.",
+ "apihelp-query+search-summary": "Effectuer une recherche en texte intégral.",
+ "apihelp-query+search-param-search": "Rechercher les titres de page ou le contenu correspondant à cette valeur. Vous pouvez utiliser la chaîne de recherche pour invoquer des fonctionnalités de recherche spéciales, selon ce que le serveur de recherche du wiki implémente.",
+ "apihelp-query+search-param-namespace": "Rechercher uniquement dans ces espaces de noms.",
+ "apihelp-query+search-param-what": "Quel type de recherche effectuer.",
+ "apihelp-query+search-param-info": "Quelles métadonnées renvoyer.",
+ "apihelp-query+search-param-prop": "Quelles propriétés renvoyer :",
+ "apihelp-query+search-param-qiprofile": "Profil indépendant des requêtes à utiliser (affecte algorithme de classement).",
+ "apihelp-query+search-paramvalue-prop-size": "Ajoute la taille de la page en octets.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Ajoute le nombre de mots de la page.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Ajoute l’horodatage de la dernière modification de la page.",
+ "apihelp-query+search-paramvalue-prop-snippet": "Ajoute un extrait analysé de la page.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "Ajoute un extrait analysé du titre de la page.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "Ajoute un extrait analysé du titre de la redirection.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Ajoute le titre de la redirection correspondante.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "Ajoute un extrait analysé du titre de la section correspondante.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Ajoute le titre de la section correspondante.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "Ajoute un extrait analysé de la catégorie correspondante.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "Ajoute un booléen indiquant si la recherche correspond au contenu du fichier.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignoré.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignoré.",
+ "apihelp-query+search-param-limit": "Combien de pages renvoyer au total.",
+ "apihelp-query+search-param-interwiki": "Inclure les résultats interwiki dans la recherche, s’ils sont disponibles.",
+ "apihelp-query+search-param-backend": "Quel serveur de recherche utiliser, si ce n’est pas celui par défaut.",
+ "apihelp-query+search-param-enablerewrites": "Activer la réécriture interne de la requête. Les serveurs de recherche peuvent réécrire la requête en une autre qui est censée donner de meilleurs résultats, par exemple en corrigeant les erreurs d’orthographe.",
+ "apihelp-query+search-example-simple": "Rechercher <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Rechercher des textes pour <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-generator": "Obtenir les informations sur les pages renvoyées par une recherche de <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "Renvoyer les informations générales sur le site.",
+ "apihelp-query+siteinfo-param-prop": "Quelles informations obtenir :",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Information globale du système.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "Liste des espaces de noms déclarés avec leur nom canonique.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Liste des alias des espaces de noms déclarés.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Liste des alias des pages spéciales.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Liste des mots magiques et leurs alias.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Renvoie les statistiques du site.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "Renvoie la correspondance interwiki (éventuellement filtrée, éventuellement localisée en utilisant <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "Renvoie le serveur de base de données ayant la plus grande latence de réplication.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Renvoie les groupes utilisateur et les droits associés.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Renvoie les bibliothèques installées sur le wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Renvoie les extensions installées sur le wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Renvoie la liste des extensions de fichier (types de fichier) autorisées au téléversement.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Renvoie l’information sur les droits du wiki (sa licence), si elle est disponible.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Renvoie l’information sur les types de restriction disponibles (protection).",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "Renvoie une liste des langues que MédiaWiki prend en charge (éventuellement localisée en utilisant <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Renvoie une liste de codes de langue pour lesquels [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]] est activé, et les variantes prises en charge pour chacun.",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "Renvoie une liste de tous les habillages activés (éventuellement localisé en utilisant <var>$1inlanguagecode</var>, sinon dans la langue du contenu).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "Renvoie une liste des balises d’extension de l’analyseur.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "Renvoie une liste des accroches de fonction de l’analyseur.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "Renvoie une liste de toutes les accroches souscrites (contenu de <var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Renvoie une liste d'IDs de variable.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "Renvoie une liste de protocoles autorisés dans les liens externes.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Renvoie les valeurs par défaut pour les préférences utilisateur.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "Renvoie la configuration du dialogue de téléversement.",
+ "apihelp-query+siteinfo-param-filteriw": "Renvoyer uniquement les entrées locales ou uniquement les non locales de la correspondance interwiki.",
+ "apihelp-query+siteinfo-param-showalldb": "Lister tous les serveurs de base de données, pas seulement celui avec la plus grande latence.",
+ "apihelp-query+siteinfo-param-numberingroup": "Liste le nombre d’utilisateurs dans les groupes.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "Code de langue pour les noms de langue localisés (du mieux possible) et les noms d’habillage.",
+ "apihelp-query+siteinfo-example-simple": "Extraire les informations du site.",
+ "apihelp-query+siteinfo-example-interwiki": "Extraire une liste des préfixes interwiki locaux.",
+ "apihelp-query+siteinfo-example-replag": "Vérifier la latence de réplication actuelle.",
+ "apihelp-query+stashimageinfo-summary": "Renvoie les informations de fichier des fichiers mis en réserve.",
+ "apihelp-query+stashimageinfo-param-filekey": "Clé qui identifie un téléversement précédent qui a été temporairement mis en réserve.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Alias pour $1filekey, pour la compatibilité ascendante.",
+ "apihelp-query+stashimageinfo-example-simple": "Renvoie les informations sur un fichier mis en réserve.",
+ "apihelp-query+stashimageinfo-example-params": "Renvoie les vignettes pour deux fichiers mis de côté.",
+ "apihelp-query+tags-summary": "Lister les balises de modification.",
+ "apihelp-query+tags-param-limit": "Le nombre maximal de balises à lister.",
+ "apihelp-query+tags-param-prop": "Quelles propriétés récupérer :",
+ "apihelp-query+tags-paramvalue-prop-name": "Ajoute le nom de la balise.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Ajoute le message système pour la balise.",
+ "apihelp-query+tags-paramvalue-prop-description": "Ajoute la description de la balise.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Ajoute le nombre de révisions et d’entrées du journal qui ont cette balise.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Indique si la balise est définie.",
+ "apihelp-query+tags-paramvalue-prop-source": "Retourne les sources de la balise, ce qui comprend <samp>extension</samp> pour les balises définies par une extension et <samp>manual</samp> pour les balises pouvant être appliquées manuellement par les utilisateurs.",
+ "apihelp-query+tags-paramvalue-prop-active": "Si la balise est encore appliquée.",
+ "apihelp-query+tags-example-simple": "Lister les balises disponibles.",
+ "apihelp-query+templates-summary": "Renvoie toutes les pages incluses dans les pages fournies.",
+ "apihelp-query+templates-param-namespace": "Afficher les modèles uniquement dans ces espaces de noms.",
+ "apihelp-query+templates-param-limit": "Combien de modèles renvoyer.",
+ "apihelp-query+templates-param-templates": "Lister uniquement ces modèles. Utile pour vérifier si une certaine page utilise un modèle donné.",
+ "apihelp-query+templates-param-dir": "La direction dans laquelle lister.",
+ "apihelp-query+templates-example-simple": "Obtenir les modèles utilisés sur la page <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-generator": "Obtenir des informations sur les pages modèle utilisé sur <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-namespaces": "Obtenir les pages des espaces de noms {{ns:user}} et {{ns:template}} qui sont inclues dans la page <kdb>Main Page<kdb>.",
+ "apihelp-query+tokens-summary": "Récupère les jetons pour les actions de modification de données.",
+ "apihelp-query+tokens-param-type": "Types de jeton à demander.",
+ "apihelp-query+tokens-example-simple": "Récupérer un jeton csrf (par défaut).",
+ "apihelp-query+tokens-example-types": "Récupérer un jeton de suivi et un de patrouille.",
+ "apihelp-query+transcludedin-summary": "Trouver toutes les pages qui incluent les pages données.",
+ "apihelp-query+transcludedin-param-prop": "Quelles propriétés obtenir :",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "ID de page de chaque page.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Titre de chaque page.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Marque si cette page est une redirection.",
+ "apihelp-query+transcludedin-param-namespace": "Inclure uniquement les pages dans ces espaces de nom.",
+ "apihelp-query+transcludedin-param-limit": "Combien en renvoyer.",
+ "apihelp-query+transcludedin-param-show": "Afficher uniquement les éléments qui correspondent à ces critères:\n;redirect:Afficher uniquement les redirections.\n;!redirect:Afficher uniquement les non-redirections.",
+ "apihelp-query+transcludedin-example-simple": "Obtenir une liste des pages incluant <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Obtenir des informations sur les pages incluant <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-summary": "Obtenir toutes les modifications d'un utilisateur.",
+ "apihelp-query+usercontribs-param-limit": "Le nombre maximal de contributions à renvoyer.",
+ "apihelp-query+usercontribs-param-start": "L’horodatage auquel démarrer le retour.",
+ "apihelp-query+usercontribs-param-end": "L’horodatage auquel arrêter le retour.",
+ "apihelp-query+usercontribs-param-user": "Utilisateurs pour lesquels il faut récupérer les contributions. Ne peut pas être utilisé avec <var>$1userid</var> ou <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "Récupérer les contributions pour tous les utilisateurs dont les noms commencent par cette valeur. Ne peut pas être utilisé avec <var>$1user</var> ou <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "Utilisateurs pour lesquels il faut récupérer les contributions. Ne peut pas être utilisé avec <var>$1user</var> ou <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "Lister uniquement les contributions dans ces espaces de noms.",
+ "apihelp-query+usercontribs-param-prop": "Inclure des informations supplémentaires:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Ajoute l’ID de page et l’ID de révision.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Ajoute le titre et l’ID d’espace de noms de la page.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Ajoute l’horodatage de la modification.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Ajoute le commentaire de la modification.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Ajoute le commentaire analysé de la modification.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Ajoute la nouvelle taille de la modification.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Ajoute le delta de taille de la modification par rapport à son parent.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Ajoute les marques de la modification.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Marque les modifications relues.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Liste les balises de la modification.",
+ "apihelp-query+usercontribs-param-show": "Afficher uniquement les éléments correspondant à ces critères, par ex. les modifications non mineures uniquement : <kbd>$2show=!minor</kbd>.\n\nSi <kbd>$2show=patrolled</kbd> ou <kbd>$2show=!patrolled</kbd> est positionné, les révisions plus anciennes que <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var> ($1 {{PLURAL:$1|seconde|secondes}}) ne seront pas affichées.",
+ "apihelp-query+usercontribs-param-tag": "Lister uniquement les révisions marquées avec cette balise.",
+ "apihelp-query+usercontribs-param-toponly": "Lister uniquement les modifications de la dernière révision.",
+ "apihelp-query+usercontribs-example-user": "Afficher les contributions de l'utilisateur <kbd>Exemple</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "Afficher les contributions de toutes les adresses IP avec le préfixe <kbd>192.0.2.</kbd>.",
+ "apihelp-query+userinfo-summary": "Obtenir des informations sur l’utilisateur courant.",
+ "apihelp-query+userinfo-param-prop": "Quelles informations inclure :",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Marque si l’utilisateur actuel est bloqué, par qui, et pour quelle raison.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Ajoute une balise <samp>messages</samp> si l’utilisateur actuel a des messages en cours.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Liste tous les groupes auxquels appartient l’utilisateur actuel.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Liste les groupes auxquels l’utilisateur actuel a été explicitement affecté, avec la date d’expiration de chaque appartenance au groupe.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Liste tous les groupes dont l’utilisateur actuel est automatiquement membre.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Liste tous les droits qu’a l’utilisateur actuel.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Liste les groupes pour lesquels l’utilisateur actuel peut ajouter ou supprimer.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Liste toutes les préférences qu’a définies l’utilisateur actuel.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Obtenir un jeton pour modifier les préférences de l’utilisateur actuel.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Ajoute le compteur de modifications de l’utilisateur actuel.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Liste toutes les limites de débit s’appliquant à l’utilisateur actuel.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Ajoute le vrai nom de l’utilisateur actuel.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Ajoute l’adresse de courriel de l’utilisateur et sa date d’authentification.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Renvoie en écho l’entête <code>Accept-Language</code> envoyé par le client dans un format structuré.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Ajoute la date d’inscription de l’utilisateur.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Ajoute le compteur de pages non lues de la liste de suivi de l’utilisateur (au maximum $1 ; renvoie <samp>$2</samp> s’il y en a plus).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "Ajoute les IDs centraux et l’état d’attachement de l’utilisateur.",
+ "apihelp-query+userinfo-param-attachedwiki": "Avec <kbd>$1prop=centralids</kbd>, indiquer si l’utilisateur est attaché au wiki identifié par cet ID.",
+ "apihelp-query+userinfo-example-simple": "Obtenir des informations sur l’utilisateur actuel.",
+ "apihelp-query+userinfo-example-data": "Obtenir des informations supplémentaires sur l’utilisateur actuel.",
+ "apihelp-query+users-summary": "Obtenir des informations sur une liste d’utilisateurs",
+ "apihelp-query+users-param-prop": "Quelles informations inclure :",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Marque si l’utilisateur est bloqué, par qui, et pour quelle raison.",
+ "apihelp-query+users-paramvalue-prop-groups": "Liste tous les groupes auxquels appartient chaque utilisateur.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "Liste les groupes auxquels chaque utilisateur a été explicitement affecté, avec la date d’expiration de l’appartenance à chaque groupe.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Liste tous les groupes dont un utilisateur est automatiquement membre.",
+ "apihelp-query+users-paramvalue-prop-rights": "Liste tous les droits qu’a un utilisateur.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Ajoute le compteur de modifications de l’utilisateur.",
+ "apihelp-query+users-paramvalue-prop-registration": "Ajoute l’horodatage d’inscription de l’utilisateur.",
+ "apihelp-query+users-paramvalue-prop-emailable": "Marque si l’utilisateur peut et veut recevoir des courriels via [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "Marque le sexe de l’utilisateur. Renvoie « male », « female », ou « unknown ».",
+ "apihelp-query+users-paramvalue-prop-centralids": "Ajoute les IDs centraux et l’état d’attachement de l’utilisateur.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Indique si un compte peut être créé pour les noms d’utilisateurs valides mais non enregistrés.",
+ "apihelp-query+users-param-attachedwiki": "Avec <kbd>$1prop=centralids</kbd>, indiquer si l’utilisateur est attaché au wiki identifié par cet ID.",
+ "apihelp-query+users-param-users": "Une liste d'utilisateurs pour lesquels obtenir des l’informations.",
+ "apihelp-query+users-param-userids": "Une liste d’ID utilisateur pour lesquels obtenir des informations.",
+ "apihelp-query+users-param-token": "Utiliser <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> à la place.",
+ "apihelp-query+users-example-simple": "Renvoyer des informations pour l'utilisateur <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-summary": "Obtenir les modifications récentes des pages de la liste de suivi de l’utilisateur actuel.",
+ "apihelp-query+watchlist-param-allrev": "Inclure les multiples révisions de la même page dans l’intervalle de temps fourni.",
+ "apihelp-query+watchlist-param-start": "L’horodatage auquel démarrer l’énumération.",
+ "apihelp-query+watchlist-param-end": "L’horodatage auquel arrêter l’énumération.",
+ "apihelp-query+watchlist-param-namespace": "Filtrer les modifications aux seuls espaces de nom fournis.",
+ "apihelp-query+watchlist-param-user": "Lister uniquement les modifications par cet utilisateur.",
+ "apihelp-query+watchlist-param-excludeuser": "Ne pas lister les modifications faites par cet utilisateur.",
+ "apihelp-query+watchlist-param-limit": "Combien de résultats au total renvoyer par demande.",
+ "apihelp-query+watchlist-param-prop": "Quelles propriétés supplémentaires obtenir :",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Ajoute les IDs de révision et de page.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Ajoute le titre de la page.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Ajoute les marqueurs de la modification.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Ajoute l’utilisateur ayant fait la modification.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Ajoute l’ID de l’utilisateur ayant fait la modification.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Ajoute le commentaire de la modification.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Ajoute le commentaire analysé de la modification.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Ajoute l’horodatage de la modification.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Marque les modifications relues.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Ajoute les tailles ancienne et nouvelle de la page.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Ajoute l’horodatage de la dernière notification de la modification à l’utilisateur.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Ajoute l’information de trace le cas échéant.",
+ "apihelp-query+watchlist-param-show": "Afficher uniquement les éléments qui correspondent à ces critères. Par exemple, pour voir uniquement les modifications mineures faites par des utilisateurs connectés, mettre $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "Quels types de modification afficher :",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Modifications normales de page.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Modifications externes.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Créations de pages.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Entrées du journal.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Modifications d’appartenance aux catégories.",
+ "apihelp-query+watchlist-param-owner": "Utilisé avec $1token pour accéder à la liste de suivi d’un autre utilisateur.",
+ "apihelp-query+watchlist-param-token": "Un jeton de sécurité (disponible dans les [[Special:Preferences#mw-prefsection-watchlist|préférences]] de l’utilsateur) pour autoriser l’accès à la liste de suivi d'un autre utilisateur.",
+ "apihelp-query+watchlist-example-simple": "Lister la révision de tête des pages récemment modifiées dans la liste de suivi de l’utilisateur actuel.",
+ "apihelp-query+watchlist-example-props": "Chercher des informations supplémentaires sur la révision de tête des pages récemment modifiées de la liste de suivi de l’utilisateur actuel.",
+ "apihelp-query+watchlist-example-allrev": "Chercher les informations sur toutes les modifications récentes des pages de la liste de suivi de l’utilisateur actuel",
+ "apihelp-query+watchlist-example-generator": "Chercher l’information de la page sur les pages récemment modifiées de la liste de suivi de l’utilisateur actuel",
+ "apihelp-query+watchlist-example-generator-rev": "Chercher l’information de la révision pour les modifications récentes des pages de la liste de suivi de l’utilisateur actuel.",
+ "apihelp-query+watchlist-example-wlowner": "Lister la révision de tête des pages récemment modifiées de la liste de suivi de l'utilisateur <kbd>Exemple</kbd>.",
+ "apihelp-query+watchlistraw-summary": "Obtenir toutes les pages de la liste de suivi de l’utilisateur actuel.",
+ "apihelp-query+watchlistraw-param-namespace": "Lister uniquement les pages dans les espaces de noms fournis.",
+ "apihelp-query+watchlistraw-param-limit": "Combien de résultats renvoyer au total par requête.",
+ "apihelp-query+watchlistraw-param-prop": "Quelles propriétés supplémentaires obtenir :",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Ajoute l’horodatage de la dernière notification de l’utilisateur à propos de la modification.",
+ "apihelp-query+watchlistraw-param-show": "Lister uniquement les éléments correspondant à ces critères.",
+ "apihelp-query+watchlistraw-param-owner": "Utilisé avec $1token pour accéder à la liste de suivi d’un autre utilisateur.",
+ "apihelp-query+watchlistraw-param-token": "Un jeton de sécurité (disponible dans les [[Special:Preferences#mw-prefsection-watchlist|préférences]] de l’utilisateur) pour permettre l’accès à la liste de suivi d’un autre utilisateur.",
+ "apihelp-query+watchlistraw-param-dir": "Le sens dans lequel lister.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Démarrer l'énumération avec ce Titre (inclure le préfixe d'espace de noms) :",
+ "apihelp-query+watchlistraw-param-totitle": "Terminer l'énumération avec ce Titre (inclure le préfixe d'espace de noms) :",
+ "apihelp-query+watchlistraw-example-simple": "Lister les pages dans la liste de suivi de l’utilisateur actuel.",
+ "apihelp-query+watchlistraw-example-generator": "Chercher l’information sur les pages de la liste de suivi de l’utilisateur actuel.",
+ "apihelp-removeauthenticationdata-summary": "Supprimer les données d’authentification pour l’utilisateur actuel.",
+ "apihelp-removeauthenticationdata-example-simple": "Tentative de suppression des données de l’utilisateur pour <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "Envoyer un courriel de réinitialisation du mot de passe à un utilisateur.",
+ "apihelp-resetpassword-extended-description-noroutes": "Aucun chemin pour réinitialiser le mot de passe n’est disponible.\n\nActiver les chemins dans <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var> pour utiliser ce module.",
+ "apihelp-resetpassword-param-user": "Utilisateur ayant été réinitialisé.",
+ "apihelp-resetpassword-param-email": "Adresse courriel de l’utilisateur ayant été réinitialisé.",
+ "apihelp-resetpassword-example-user": "Envoyer un courriel de réinitialisation du mot de passe à l’utilisateur <kbd>Exemple</kbd>.",
+ "apihelp-resetpassword-example-email": "Envoyer un courriel pour la réinitialisation de mot de passe à tous les utilisateurs avec l’adresse <kbd>user@example.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Supprimer et rétablir des révisions.",
+ "apihelp-revisiondelete-param-type": "Type de suppression de révision en cours de traitement.",
+ "apihelp-revisiondelete-param-target": "Titre de page pour la suppression de révision, s’il est nécessaire pour le type.",
+ "apihelp-revisiondelete-param-ids": "Identifiants pour les révisions à supprimer.",
+ "apihelp-revisiondelete-param-hide": "Quoi masquer pour chaque révision.",
+ "apihelp-revisiondelete-param-show": "Quoi démasquer pour chaque révision",
+ "apihelp-revisiondelete-param-suppress": "S’il faut supprimer les données aux administrateurs comme aux autres.",
+ "apihelp-revisiondelete-param-reason": "Motif de suppression ou d’annulation de suppression.",
+ "apihelp-revisiondelete-param-tags": "Balises à appliquer à l’entrée dans le journal de suppression.",
+ "apihelp-revisiondelete-example-revision": "Masquer le contenu de la révision <kbd>12345</kbd> de la page <kbd>Main Page</kbd>.",
+ "apihelp-revisiondelete-example-log": "Masquer toutes les données de l’entrée de journal <kbd>67890</kbd> avec le motif <kbd>Violation de Biographie de Personne Vivante</kbd>.",
+ "apihelp-rollback-summary": "Annuler les dernières modifications de la page.",
+ "apihelp-rollback-extended-description": "Si le dernier utilisateur à avoir modifié la page a fait plusieurs modifications sur une ligne, elles seront toutes annulées.",
+ "apihelp-rollback-param-title": "Titre de la page à restaurer. Impossible à utiliser avec <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "ID de la page à restaurer. Impossible à utiliser avec <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "Balises à appliquer à la révocation.",
+ "apihelp-rollback-param-user": "Nom de l’utilisateur dont les modifications doivent être annulées.",
+ "apihelp-rollback-param-summary": "Personnaliser le résumé de la modification. S’il est vide, le résumé par défaut sera utilisé.",
+ "apihelp-rollback-param-markbot": "Marquer les modifications annulées et les modifications annulées comme robot.",
+ "apihelp-rollback-param-watchlist": "Ajouter ou supprimer la page de la liste de suivi de l’utilisateur actuel sans condition, utiliser les préférences ou ne pas modifier le suivi.",
+ "apihelp-rollback-example-simple": "Annuler les dernières modifications à <kbd>Main Page</kbd> par l’utilisateur <kbd>Example</kbd>.",
+ "apihelp-rollback-example-summary": "Annuler les dernières modifications de la page <kbd>Main Page</kbd> par l’utilisateur à l’adresse IP <kbd>192.0.2.5</kbd> avec le résumé <kbd>Annulation de vandalisme<kbd>, et marquer ces modifications et l’annulation comme modifications de robots.",
+ "apihelp-rsd-summary": "Exporter un schéma RSD (Découverte Très Simple).",
+ "apihelp-rsd-example-simple": "Exporter le schéma RSD",
+ "apihelp-setnotificationtimestamp-summary": "Mettre à jour l’horodatage de notification pour les pages suivies.",
+ "apihelp-setnotificationtimestamp-extended-description": "Cela affecte la mise en évidence des pages modifiées dans la liste de suivi et l’historique, et l’envoi de courriel quand la préférence « {{int:tog-enotifwatchlistpages}} » est activée.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Travailler sur toutes les pages suivies.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "Horodatage auquel dater la notification.",
+ "apihelp-setnotificationtimestamp-param-torevid": "Révision pour laquelle fixer l’horodatage de notification (une page uniquement).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "Révision pour fixer l’horodatage de notification plus récent (une page uniquement).",
+ "apihelp-setnotificationtimestamp-example-all": "Réinitialiser l’état de notification pour toute la liste de suivi",
+ "apihelp-setnotificationtimestamp-example-page": "Réinitialiser l’état de notification pour la <kbd>Page principale<kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "Fixer l’horodatage de notification pour <kbd>Page principale</kbd> afin que toutes les modifications depuis le 1 janvier 2012 soient non vues",
+ "apihelp-setnotificationtimestamp-example-allpages": "Réinitialiser l’état de notification sur les pages dans l’espace de noms <kbd>{{ns:user}}</kbd>.",
+ "apihelp-setpagelanguage-summary": "Modifier la langue d’une page.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Il n’est pas possible de modifier la langue d’une page sur ce wiki.\n\nActiver <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> pour utiliser cette action.",
+ "apihelp-setpagelanguage-param-title": "Titre de la page dont vous souhaitez modifier la langue. Ne peut pas être utilisé avec <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "Identifiant (ID) de la page dont vous souhaitez modifier la langue. Ne peut être utilisé avec <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "Code de langue vers lequel la page doit être changée. Utiliser <kbd>defaut</kbd> pour réinitialiser la page sur la langue par défaut du contenu du wiki.",
+ "apihelp-setpagelanguage-param-reason": "Motif de la modification.",
+ "apihelp-setpagelanguage-param-tags": "Modifier les balises à appliquer à l'entrée du journal résultant de cette action.",
+ "apihelp-setpagelanguage-example-language": "Changer la langue de la <kbd>page principale</kbd> en basque.",
+ "apihelp-setpagelanguage-example-default": "Remplacer la langue de la page ayant l'ID 123 par la langue par défaut du contenu du wiki.",
+ "apihelp-stashedit-summary": "Préparer des modifications dans le cache partagé.",
+ "apihelp-stashedit-extended-description": "Ceci a pour but d’être utilisé via AJAX depuis le formulaire d’édition pour améliorer la performance de la sauvegarde de la page.",
+ "apihelp-stashedit-param-title": "Titre de la page en cours de modification.",
+ "apihelp-stashedit-param-section": "Numéro de section. <kbd>0</kbd> pour la section du haut, <kbd>new</kbd> pour une nouvelle section.",
+ "apihelp-stashedit-param-sectiontitle": "Le titre pour une nouvelle section.",
+ "apihelp-stashedit-param-text": "Contenu de la page.",
+ "apihelp-stashedit-param-stashedtexthash": "Empreinte du contenu de la page venant d’une réserve préalable à utiliser à la place.",
+ "apihelp-stashedit-param-contentmodel": "Modèle de contenu du nouveau contenu.",
+ "apihelp-stashedit-param-contentformat": "Format de sérialisation de contenu utilisé pour le texte saisi.",
+ "apihelp-stashedit-param-baserevid": "ID de révision de la révision de base.",
+ "apihelp-stashedit-param-summary": "Résumé du changement",
+ "apihelp-tag-summary": "Ajouter ou enlever des balises de modification aux révisions ou ou aux entrées de journal individuelles.",
+ "apihelp-tag-param-rcid": "Un ou plus IDs de modification récente à partir desquels ajouter ou supprimer la balise.",
+ "apihelp-tag-param-revid": "Un ou plusieurs IDs de révision à partir desquels ajouter ou supprimer la balise.",
+ "apihelp-tag-param-logid": "Un ou plusieurs IDs d’entrée de journal à partir desquels ajouter ou supprimer la balise.",
+ "apihelp-tag-param-add": "Balises à ajouter. Seules les balises définies manuellement peuvent être ajoutées.",
+ "apihelp-tag-param-remove": "Balises à supprimer. Seules les balises qui sont soit définies manuellement soit pas du tout définies peuvent être supprimées.",
+ "apihelp-tag-param-reason": "Motif de la modification.",
+ "apihelp-tag-param-tags": "Balises à appliquer à l’entrée de journal qui sera créée en résultat de cette action.",
+ "apihelp-tag-example-rev": "Ajoute la balise <kbd>vandalism</kbd> à partir de l’ID de révision 123 sans indiquer de motif",
+ "apihelp-tag-example-log": "Supprimer la balise <kbd>spam</kbd> à partir de l’ID d’entrée de journal 123 avec le motif <kbd>Wrongly applied</kbd>",
+ "apihelp-tokens-summary": "Obtenir des jetons pour des actions de modification des données.",
+ "apihelp-tokens-extended-description": "Ce module est désuet, remplacé par [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-tokens-param-type": "Types de jeton à demander.",
+ "apihelp-tokens-example-edit": "Récupérer un jeton de modification (par défaut).",
+ "apihelp-tokens-example-emailmove": "Récupérer un jeton de courriel et un jeton de déplacement.",
+ "apihelp-unblock-summary": "Débloquer un utilisateur.",
+ "apihelp-unblock-param-id": "ID du blocage à lever (obtenu via <kbd>list=blocks</kbd>). Impossible à utiliser avec <var>$1user</var> ou <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "Nom d’utilisateur, adresse IP ou plage d’adresses IP à débloquer. Impossible à utiliser en même temps que <var>$1id</var> ou <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "ID de l'utilisateur à débloquer. Ne peut être utilisé avec <var>$1id</var> ou <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "Motif de déblocage.",
+ "apihelp-unblock-param-tags": "Modifier les balises à appliquer à l’entrée dans le journal de blocage.",
+ "apihelp-unblock-example-id": "Lever le blocage d’ID #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "Débloquer l’utilisateur <kbd>Bob</kbd> avec le motif <kbd>Désolé Bob</kbd>.",
+ "apihelp-undelete-summary": "Restituer les versions d'une page supprimée.",
+ "apihelp-undelete-extended-description": "Une liste des révisions supprimées (avec les horodatages) peut être récupérée via [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]], et une liste d’IDs de fichier supprimé peut être récupérée via [[Special:ApiHelp/query+filearchive|list=filearchive]].",
+ "apihelp-undelete-param-title": "Titre de la page à restaurer.",
+ "apihelp-undelete-param-reason": "Motif de restauration.",
+ "apihelp-undelete-param-tags": "Modifier les balises à appliquer à l’entrée dans le journal de suppression.",
+ "apihelp-undelete-param-timestamps": "Horodatages des révisions à restaurer. Si <var>$1timestamps</var> et <var>$1fileids</var> sont vides, toutes seront restaurées.",
+ "apihelp-undelete-param-fileids": "IDs des révisions de fichier à restaurer. Si <var>$1timestamps</var> et <var>$1fileids</var> sont vides, toutes seront restaurées.",
+ "apihelp-undelete-param-watchlist": "Ajouter ou supprimer la page de la liste de suivi de l’utilisateur actuel sans condition, utiliser les préférences ou ne pas modifier le suivi.",
+ "apihelp-undelete-example-page": "Annuler la suppression de la page <kbd>Main Page</kbd>.",
+ "apihelp-undelete-example-revisions": "Annuler la suppression de deux révisions de la page <kbd>Main Page</kbd>.",
+ "apihelp-unlinkaccount-summary": "Supprimer un compte tiers lié de l’utilisateur actuel.",
+ "apihelp-unlinkaccount-example-simple": "Essayer de supprimer le lien de l’utilisateur actuel pour le fournisseur associé avec <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-upload-summary": "Téléverser un fichier, ou obtenir l’état des téléversements en cours.",
+ "apihelp-upload-extended-description": "Plusieurs méthodes sont disponibles :\n* Téléverser directement le contenu du fichier, en utilisant le paramètre <var>$1file</var>.\n* Téléverser le fichier par morceaux, en utilisant les paramètres <var>$1filesize</var>, <var>$1chunk</var>, and <var>$1offset</var>.\n* Pour que le serveur MédiaWiki cherche un fichier depuis une URL, utilisez le paramètre <var>$1url</var>.\n* Terminer un téléversement précédent qui a échoué à cause d’avertissements, en utilisant le paramètre <var>$1filekey</var>.\nNoter que le POST HTTP doit être fait comme un téléversement de fichier (par ex. en utilisant <code>multipart/form-data</code>) en envoyant le <code>multipart/form-data</code>.",
+ "apihelp-upload-param-filename": "Nom de fichier cible.",
+ "apihelp-upload-param-comment": "Téléverser le commentaire. Utilisé aussi comme texte de la page initiale pour les nouveaux fichiers si <var>$1text</var> n’est pas spécifié.",
+ "apihelp-upload-param-tags": "Modifier les balises à appliquer à l’entrée du journal de téléversement et à la révision de la page du fichier.",
+ "apihelp-upload-param-text": "Texte de page initiale pour les nouveaux fichiers.",
+ "apihelp-upload-param-watch": "Suivre la page.",
+ "apihelp-upload-param-watchlist": "Ajouter ou supprimer sans condition la page de la liste de suivi de l’utilisateur actuel, utiliser les préférences ou ne pas changer le suivi.",
+ "apihelp-upload-param-ignorewarnings": "Ignorer tous les avertissements.",
+ "apihelp-upload-param-file": "Contenu du fichier.",
+ "apihelp-upload-param-url": "URL où chercher le fichier.",
+ "apihelp-upload-param-filekey": "Clé identifiant un téléversement précédent temporairement mis en attente.",
+ "apihelp-upload-param-sessionkey": "Comme $1filekey, conservé pour des raisons de compatibilité descendante.",
+ "apihelp-upload-param-stash": "Si positionné, le serveur conservera temporairement le fichier au lieu de l’ajouter au dépôt.",
+ "apihelp-upload-param-filesize": "Taille du téléversement entier.",
+ "apihelp-upload-param-offset": "Décalage du bloc en octets.",
+ "apihelp-upload-param-chunk": "Partie du contenu.",
+ "apihelp-upload-param-async": "Faire les grosses opérations de fichiers de façon asynchrone quand c’est possible.",
+ "apihelp-upload-param-checkstatus": "Récupérer uniquement l’état de téléversement pour la clé de fichier donnée.",
+ "apihelp-upload-example-url": "Téléverser depuis une URL",
+ "apihelp-upload-example-filekey": "Terminer un téléversement qui a échoué à cause d’avertissements",
+ "apihelp-userrights-summary": "Modifier l’appartenance d’un utilisateur à un groupe.",
+ "apihelp-userrights-param-user": "Nom d’utilisateur.",
+ "apihelp-userrights-param-userid": "ID de l’utilisateur.",
+ "apihelp-userrights-param-add": "Ajouter l’utilisateur à ces groupes, ou s’ils sont déjà membres, mettre à jour la date d’expiration de leur appartenance à ce groupe.",
+ "apihelp-userrights-param-expiry": "Horodatages d’expiration. Peuvent être relatifs (par ex. <kbd>5 mois</kbd> ou <kbd>2 semaines</kbd>) ou absolus (par ex. <kbd>2014-09-18T12:34:56Z</kbd>). Si uniquement un horodatage est fixé, il sera utilisé pour tous les groupes passés au paramètre <var>$1add</var>. Utiliser <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, ou <kbd>never</kbd> pour une lien utilisateur-groupe qui n’expire jamais.",
+ "apihelp-userrights-param-remove": "Supprimer l’utilisateur de ces groupes.",
+ "apihelp-userrights-param-reason": "Motif pour la modification.",
+ "apihelp-userrights-param-tags": "Modifier les balises à appliquer à l’entrée dans le journal des droits utilisateur.",
+ "apihelp-userrights-example-user": "Ajouter l’utilisateur <kbd>FooBot</kbd> au groupe <kbd>bot</kbd><!-- {{int:group-bot}} ? -->, et le supprimer des groupes <kbd>sysop</kbd> et <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Ajouter l’utilisateur d’ID <kbd>123</kbd> au groupe <kbd>robot</kbd>, et le supprimer des groupes <kbd>sysop</kbd> et <kbd>bureaucrate</kbd>.",
+ "apihelp-userrights-example-expiry": "Ajouter l'utilisateur <kbd>SometimeSysop</kbd> au groupe <kbd>sysop</kbd> pour 1 mois.",
+ "apihelp-validatepassword-summary": "Valider un mot de passe conformément aux règles concernant les mots de passe du wiki.",
+ "apihelp-validatepassword-extended-description": "La validation est <samp>Good</samp> si le mot de passe est acceptable, <samp>Change</samp> s'il peut être utilisé pour se connecter et doit être changé, ou <samp>Invalid</samp> s'il n'est pas utilisable.",
+ "apihelp-validatepassword-param-password": "Mot de passe à valider.",
+ "apihelp-validatepassword-param-user": "Nom de l'utilisateur, pour tester la création de compte. L'utilisateur ne doit pas déja exister.",
+ "apihelp-validatepassword-param-email": "Adresse courriel, pour tester la création de compte.",
+ "apihelp-validatepassword-param-realname": "Vrai nom, pour tester la création de compte.",
+ "apihelp-validatepassword-example-1": "Valider le mot de passe <kbd>foobar</kbd> pour l'utilisateur actuel.",
+ "apihelp-validatepassword-example-2": "Valider le mot de passe <kbd>qwerty</kbd> pour la création de l'utilisateur <kbd>Example</kbd>.",
+ "apihelp-watch-summary": "Ajouter ou supprimer des pages de la liste de suivi de l’utilisateur actuel.",
+ "apihelp-watch-param-title": "La page à (ne plus) suivre. Utiliser plutôt <var>$1titles</var>.",
+ "apihelp-watch-param-unwatch": "Si défini, la page ne sera plus suivie plutôt que suivie.",
+ "apihelp-watch-example-watch": "Suivre la page <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Ne plus suivre la page <kbd>Page principale</kbd>.",
+ "apihelp-watch-example-generator": "Suivre les quelques premières pages de l’espace de nom principal",
+ "apihelp-format-example-generic": "Renvoyer le résultat de la requête dans le format $1.",
+ "apihelp-format-param-wrappedhtml": "Renvoyer le HTML avec une jolie mise en forme et les modules ResourceLoader associés comme un objet JSON.",
+ "apihelp-json-summary": "Extraire les données au format JSON.",
+ "apihelp-json-param-callback": "Si spécifié, inclut la sortie dans l’appel d’une fonction fournie. Pour plus de sûreté, toutes les données spécifiques à l’utilisateur seront restreintes.",
+ "apihelp-json-param-utf8": "Si spécifié, encode la plupart (mais pas tous) des caractères non ASCII en URF-8 au lieu de les remplacer par leur séquence d’échappement hexadécimale. Valeur par défaut quand <var>formatversion</var> ne vaut pas <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "Si spécifié, encode toutes ses séquences d’échappement non ASCII utilisant l’hexadécimal. Valeur par défaut quand <var>formatversion</var> vaut <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Mise en forme de sortie :\n;1:Format rétro-compatible (booléens de style XML, clés <samp>*</samp> pour les nœuds de contenu, etc.).\n;2:Format moderne expérimental. Des détails peuvent changer !\n;latest:Utilise le dernier format (actuellement <kbd>2</kbd>), peut changer sans avertissement.",
+ "apihelp-jsonfm-summary": "Extraire les données au format JSON (affiché proprement en HTML).",
+ "apihelp-none-summary": "Ne rien extraire.",
+ "apihelp-php-summary": "Extraire les données au format sérialisé de PHP.",
+ "apihelp-php-param-formatversion": "Mise en forme de la sortie :\n;1:Format rétro-compatible (bool&ens de style XML, clés <samp>*</samp> pour les nœuds de contenu, etc.).\n;2:Format moderne expérimental. Des détails peuvent changer !\n;latest:Utilise le dernier format (actuellement <kbd>2</kbd>), peut changer sans avertissement.",
+ "apihelp-phpfm-summary": "Extraire les données au format sérialisé de PHP (affiché proprement en HTML).",
+ "apihelp-rawfm-summary": "Extraire les données, y compris les éléments de débogage, au format JSON (affiché proprement en HTML).",
+ "apihelp-xml-summary": "Extraire les données au format XML.",
+ "apihelp-xml-param-xslt": "Si spécifié, ajoute la page nommée comme une feuille de style XSL. La valeur doit être un titre dans l’espace de noms {{ns:MediaWiki}} se terminant par <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Si spécifié, ajoute un espace de noms XML.",
+ "apihelp-xmlfm-summary": "Extraire les données au format XML (affiché proprement en HTML).",
+ "api-format-title": "Résultat de l’API de MediaWiki",
+ "api-format-prettyprint-header": "Voici la représentation HTML du format $1. HTML est utile pour le débogage, mais inapproprié pour être utilisé dans une application.\n\nSpécifiez le paramètre <var>format</var> pour modifier le format de sortie. Pour voir la représentation non HTML du format $1, mettez <kbd>format=$2</kbd>.\n\nVoyez la [[mw:Special:MyLanguage/API|documentation complète]], ou l’[[Special:ApiHelp/main|aide de l’API]] pour plus d’information.",
+ "api-format-prettyprint-header-only-html": "Ceci est une représentation HTML à des fins de débogage, et n’est pas approprié pour une utilisation applicative.\n\nVoir la [[mw:Special:MyLanguage/API|documentation complète]], ou l’[[Special:ApiHelp/main|aide de l’API]] pour plus d’information.",
+ "api-format-prettyprint-header-hyperlinked": "Voici la représentation HTML du format $1. HTML est bien pour le débogage, mais inapproprié pour être utilisé dans une application.\n\nSpécifiez le paramètre <var>format</var> pour modifier le format de sortie. Pour voir la représentation non HTML du format $1, mettez [$3 <kbd>format=$2</kbd>].\n\nVoir la [[mw:API|documentation complète]], ou l’ [[Special:ApiHelp/main|aide de l’API]] pour plus d’information.",
+ "api-format-prettyprint-status": "Cette réponse serait retournée avec l'état HTTP $1 $2.",
+ "api-login-fail-aborted": "L’authentification nécessite une interaction avec l’utilisateur, qui n’est pas prise en charge par <kbd>action=login</kbd>. Pour pouvoir se connecter avec <kbd>action=login</kbd>, voyez [[Special:BotPasswords]]. Pour continuer à utiliser la connexion du compte principal, voyez <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-aborted-nobotpw": "L’authentification nécessite une interaction avec l’utilisateur, qui n’est pas prise en charge par <kbd>action=login</kbd>. Pour se connecter, voyez <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-badsessionprovider": "Impossible de se connecter en utilisant $1.",
+ "api-login-fail-sameorigin": "Impossible de se connecter quand la politique de même origine n’est pas appliquée.",
+ "api-pageset-param-titles": "Une liste des titres sur lesquels travailler.",
+ "api-pageset-param-pageids": "Une liste des IDs de page sur lesquelles travailler.",
+ "api-pageset-param-revids": "Une liste des IDs de révision sur lesquelles travailler.",
+ "api-pageset-param-generator": "Obtenir la liste des pages sur lesquelles travailler en exécutant le module de recherche spécifié.\n\n<strong>NOTE :<strong> les noms de paramètre du générateur doivent être préfixés avec un « g », voir les exemples.",
+ "api-pageset-param-redirects-generator": "Résoudre automatiquement les redirections dans <var>$1titles</var>, <var>$1pageids</var> et <var>$1revids</var>, et dans les pages renvoyées par <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Résoudre automatiquement les redirections dans <var>$1titles</var>, <var>$1pageids</var> et <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Convertir les titres dans d’autres variantes si nécessaire. Fonctionne uniquement si la langue de contenu du wiki prend en charge la conversion en variantes. Les langues qui prennent en charge la conversion en variante incluent $1.",
+ "api-help-title": "Aide de l’API de MediaWiki",
+ "api-help-lead": "Ceci est une page d’aide de l’API de MediaWiki générée automatiquement.\n\nDocumentation et exemples : https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Module principal",
+ "api-help-undocumented-module": "Aucune documentation pour le module $1.",
+ "api-help-flag-deprecated": "Ce module est désuet.",
+ "api-help-flag-internal": "<strong>Ce module est interne ou instable.</strong> Son fonctionnement peut être modifié sans préavis.",
+ "api-help-flag-readrights": "Ce module nécessite des droits de lecture.",
+ "api-help-flag-writerights": "Ce module nécessite des droits d’écriture.",
+ "api-help-flag-mustbeposted": "Ce module n’accepte que les requêtes POST.",
+ "api-help-flag-generator": "Ce module peut être utilisé comme générateur.",
+ "api-help-source": "Source : $1",
+ "api-help-source-unknown": "Source : <span class=\"apihelp-unknown\">inconnue</span>",
+ "api-help-license": "Licence : [[$1|$2]]",
+ "api-help-license-noname": "Licence : [[$1|Voir le lien]]",
+ "api-help-license-unknown": "Licence : <span class=\"apihelp-unknown\">inconnue</span>",
+ "api-help-parameters": "{{PLURAL:$1|Paramètre|Paramètres}} :",
+ "api-help-param-deprecated": "Désuet.",
+ "api-help-param-required": "Ce paramètre est obligatoire.",
+ "api-help-datatypes-header": "Type de données",
+ "api-help-datatypes": "Les entrées dans MédiaWiki doivent être en UTF-8 à la norme NFC. MédiaWiki peut tenter de convertir d’autres types d’entrée, mais cela peut faire échouer certaines opérations (comme les [[Special:ApiHelp/edit|modifications]] avec contrôles MD5) to fail.\n\nCertains types de paramètre dans les requêtes de l’API nécessitent plus d’explication :\n;boolean\n:Les paramètres booléens fonctionnent comme des cases à cocher HTML : si le paramètre est spécifié, quelle que soit sa valeur, il est considéré comme vrai. Pour une valeur fausse, enlever complètement le paramètre.\n;timestamp\n:Les horodatages peuvent être spécifiés sous différentes formes. Date et heure ISO 8601 est recommandé. Toutes les heures sont en UTC, tout fuseau horaire inclus est ignoré.\n:* Date et heure ISO 8601, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (la ponctuation et <kbd>Z</kbd> sont facultatifs)\n:* Date et heure ISO 8601 avec fractions de seconde (ignorées), <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (tirets, deux-points et <kbd>Z</kbd> sont facultatifs)\n:* Format MédiaWiki, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Format numérique générique, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (fuseau horaire facultatif en <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd>, ou <kbd>-<var>##</var></kbd> sont ignorés)\n:* Format EXIF, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:*Format RFC 2822 (le fuseau horaire est facultatif), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Format RFC 850 (le fuseau horaire est facultatif), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Format ctime C, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Secondes depuis 1970-01-01T00:00:00Z sous forme d’entier de 1 à 13 chiffres (sans <kbd>0</kbd>)\n:* La chaîne <kbd>now</kbd>",
+ "api-help-param-type-limit": "Type : entier ou <kbd>max</kbd>",
+ "api-help-param-type-integer": "Type : {{PLURAL:$1|1=entier|2=liste d’entiers}}",
+ "api-help-param-type-boolean": "Type : booléen ([[Special:ApiHelp/main#main/datatypes|détails]])",
+ "api-help-param-type-timestamp": "Type : {{PLURAL:$1|1=horodatage|2=liste d’horodatages}} ([[Special:ApiHelp/main#main/datatypes|formats autorisés]])",
+ "api-help-param-type-user": "Type : {{PLURAL:$1|1=nom d’utilisateur|2=liste de noms d’utilisateur}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Une des valeurs suivantes|2=Valeurs (séparées par <kbd>{{!}}</kbd> ou [[Special:ApiHelp/main#main/datatypes|autre]])}} : $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Doit être vide|Peut être vide, ou $2}}",
+ "api-help-param-limit": "Pas plus de $1 autorisé.",
+ "api-help-param-limit2": "Pas plus de $1 autorisé ($2 pour les robots).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=La valeur ne doit pas être inférieure|2=Les valeurs ne doivent pas être inférieures}} à $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=La valeur ne doit pas être supérieure|2=Les valeurs ne doivent pas être supérieures}} à $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=La valeur doit|2=Les valeurs doivent}} être entre $2 et $3.",
+ "api-help-param-upload": "Doit être envoyé comme un fichier importé utilisant multipart/form-data.",
+ "api-help-param-multi-separate": "Valeurs séparées par <kbd>|</kbd> ou [[Special:ApiHelp/main#main/datatypes|autre]].",
+ "api-help-param-multi-max": "Le nombre maximal de valeurs est {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} pour les robots).",
+ "api-help-param-multi-max-simple": "Le nombre maximum de valeurs est {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "Pour spécifier toutes les valeurs, utiliser <kbd>$1</kbd>.",
+ "api-help-param-default": "Par défaut : $1",
+ "api-help-param-default-empty": "Par défaut : <span class=\"apihelp-empty\">(vide)</span>",
+ "api-help-param-token": "Un jeton « $1 » récupéré par [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "Pour rester compatible, le jeton utilisé dans l’IHM web est aussi accepté.",
+ "api-help-param-disabled-in-miser-mode": "Désactivé à cause du [[mw:Special:MyLanguage/Manual:$wgMiserMode|mode minimal]].",
+ "api-help-param-limited-in-miser-mode": "<strong>NOTE :</strong> Du fait du [[mw:Special:MyLanguage/Manual:$wgMiserMode|mode minimal]], utiliser cela peut aboutir à moins de résultats que <var>$1limit</var> renvoyés avant de continuer ; dans les cas extrêmes, zéro résultats peuvent être renvoyés.",
+ "api-help-param-direction": "Dans quelle direction énumérer :\n;newer:Lister les plus anciens en premier. Note : $1start doit être avant $1end.\n;older:Lister les nouveaux en premier (par défaut). Note : $1start doit être postérieur à $1end.",
+ "api-help-param-continue": "Quand plus de résultats sont disponibles, utiliser cela pour continuer.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(aucune description)</span>",
+ "api-help-examples": "{{PLURAL:$1|Exemple|Exemples}} :",
+ "api-help-permissions": "{{PLURAL:$1|Droit|Droits}} :",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Accordé à}} : $2",
+ "api-help-right-apihighlimits": "Utiliser des valeurs plus hautes dans les requêtes de l’API (requêtes lentes : $1 ; requêtes rapides : $2). Les limites pour les requêtes lentes s’appliquent aussi aux paramètres multivalués.",
+ "api-help-open-in-apisandbox": "<small>[ouvrir dans le bac à sable]</small>",
+ "api-help-authmanager-general-usage": "La procédure générale pour utiliser ce module est la suivante :\n# Récupérer les champs disponibles avec <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> avec <kbd>amirequestsfor=$4</kbd>, et un jeton <kbd>$5</kbd> avec <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# Présenter les champs à l’utilisateur, et les lui faire soumettre.\n# Faire un envoi à ce module, en fournissant <var>$1returnurl</var> et les champs appropriés.\n# Vérifier le <samp>status</samp> dans la réponse.\n#* Si vous avez reçu <samp>PASS</samp> ou <samp>FAIL</samp>, c’est terminé. L’opération a soit réussi, soit échoué.\n#* Si vous avez reçu <samp>UI</samp>, affichez les nouveaux champs à l’utilisateur et faites-les-lui soumettre. Puis envoyez-les à ce module avec <var>$1continue</var> et l’ensemble des champs appropriés, et recommencez l’étape 4.\n#* Si vous avez reçu <samp>REDIRECT</samp>, envoyez l’utilisateur vers la cible <samp>redirecttarget</samp> et attendez le retour vers <var>$1returnurl</var>. Puis envoyez à ce module avec <var>$1continue</var> et tous les champs passés à l’URL de retour, puis répétez l’étape 4.\n#* Si vous avez reçu <samp>RESTART</samp>, cela veut dire que l’authentification a fonctionné, mais nous n’avons pas de compte utilisateur lié. Vous pouvez traiter cela comme un <samp>UI</samp> ou un <samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-requests": "Utiliser uniquement ces requêtes d’authentification, avec l’<samp>id</samp> renvoyé par <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> avec <kbd>amirequestsfor=$1</kbd> ou depuis une réponse précédente de ce module.",
+ "api-help-authmanagerhelper-request": "Utiliser cette requête d’authentification, avec l’<samp>id</samp> renvoyé par <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> avec <kbd>amirequestsfor=$1</kbd>.",
+ "api-help-authmanagerhelper-messageformat": "Format à utiliser pour retourner les messages.",
+ "api-help-authmanagerhelper-mergerequestfields": "Fusionner dans un tableau le champ information de toutes les demandes d'authentification.",
+ "api-help-authmanagerhelper-preservestate": "Conserver l'état d'une précédente tentative de connexion qui a échoué, si possible.",
+ "api-help-authmanagerhelper-returnurl": "Renvoyer l’URL pour les flux d’authentification tiers, qui doit être absolue. Cela ou <var>$1continue</var> est obligatoire.\n\nDès réception d’une réponse <samp>REDIRECT</samp>, vous ouvrirez typiquement un navigateur ou un affichage web vers l’URL <samp>redirecttarget</samp> spécifiée pour un flux d’authentification tiers. Une fois ceci terminé, le tiers renverra le navigateur ou l’affichage web vers cette URL. Vous devez extraire toute requête ou paramètre POST de l’URL et les passer comme une requête <var>$1continue</var> à ce module de l’API.",
+ "api-help-authmanagerhelper-continue": "Cette requête est une continuation après une précédente réponse <samp>UI</samp> ou <samp>REDIRECT</samp>. Cela ou <var>$1returnurl</var> est obligatoire.",
+ "api-help-authmanagerhelper-additional-params": "Ce module accepte des paramètres supplémentaires selon les requêtes d’authentification disponibles. Utiliser <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> avec <kbd>amirequestsfor=$1</kbd> (ou une réponse précédente de ce module, le cas échéant) pour déterminer les requêtes disponibles et les champs qu’elles utilisent.",
+ "apierror-allimages-redirect": "Utiliser <kbd>gaifilterredir=nonredirects</kbd> au lieu de <var>redirects</var> quand <kbd>allimages</kbd> est utilisé comme générateur.",
+ "apierror-allpages-generator-redirects": "Utiliser <kbd>gapfilterredir=nonredirects</kbd> au lieu de <var>redirects</var> quand <kbd>allpages</kbd> est utilisé comme un générateur.",
+ "apierror-appendnotsupported": "Impossible d’ajouter aux pages utilisant le modèle de contenu $1.",
+ "apierror-articleexists": "L’article que vous essayez de créer l’a déjà été.",
+ "apierror-assertbotfailed": "La vérification que l’utilisateur a le droit <code>bot</code> a échoué.",
+ "apierror-assertnameduserfailed": "La vérification que l’utilisateur est « $1 » a échoué.",
+ "apierror-assertuserfailed": "La vérification que l’utilisateur est connecté a échoué.",
+ "apierror-autoblocked": "Votre adresse IP a été bloquée automatiquement, parce qu’elle a été utilisée par un utilisateur bloqué.",
+ "apierror-badconfig-resulttoosmall": "La valeur de <code>$wgAPIMaxResultSize</code> sur ce wiki est trop petite pour contenir des informations de résultat basiques.",
+ "apierror-badcontinue": "Paramètre de continuation non valide. Vous devez passer la valeur d’origine renvoyée par la requête précédente.",
+ "apierror-baddiff": "La différence ne peut être récupérée. Une ou les deux révisions n’existent pas ou vous n’avez pas le droit de les voir.",
+ "apierror-baddiffto": "<var>$1diffto</var> doit être fixé à un nombre positif ou nul, <kbd>prev</kbd>, <kbd>next</kbd> ou <kbd>cur</kbd>.",
+ "apierror-badformat-generic": "Le format demandé $1 n’est pas pris en charge pour le modèle de contenu $2.",
+ "apierror-badformat": "Le format demandé $1 n’est pas pris en charge pour le modèle de contenu $2 utilisé par $3.",
+ "apierror-badgenerator-notgenerator": "Le module <kbd>$1</kbd> ne peut pas être utilisé comme générateur.",
+ "apierror-badgenerator-unknown": "<kbd>generator=$1</kbd> inconnu.",
+ "apierror-badip": "Paramètre IP non valide.",
+ "apierror-badmd5": "Le hachage MD5 fourni n’était pas correct.",
+ "apierror-badmodule-badsubmodule": "Le module <kbd>$1</kbd> n’a pas de sous-module « $2 ».",
+ "apierror-badmodule-nosubmodules": "Le module <kbd>$1</kbd> n’a pas de sous-modules.",
+ "apierror-badparameter": "Valeur non valide pour le paramètre <var>$1</var>.",
+ "apierror-badquery": "Requête invalide.",
+ "apierror-badtimestamp": "Valeur non valide « $2 » pour le paramètre de référence horaire <var>$1</var>.",
+ "apierror-badtoken": "Jeton CSRF non valide.",
+ "apierror-badupload": "Le paramètre de téléversement de fichier <var>$1</var> n’est pas un téléversement de fichier ; assurez-vous d’utiliser <code>multipart/form-data</code> pour votre POST et d’inclure un nom de fichier dans l’entête <code>Content-Disposition</code>.",
+ "apierror-badurl": "Valeur « $2 » non valide pour le paramètre d’URL <var>$1</var>.",
+ "apierror-baduser": "Valeur « $2 » non valide pour le paramètre utilisateur <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "La séparation multi-valeur U+001F ne peut être utilisée que pour des paramètres multi-valeurs.",
+ "apierror-bad-watchlist-token": "Jeton de liste de suivi fourni non valide. Veuillez mettre un jeton valide dans [[Special:Preferences]].",
+ "apierror-blockedfrommail": "Vous avez été bloqué pour l’envoi de courriel.",
+ "apierror-blocked": "Vous avez été bloqué pour modifier.",
+ "apierror-botsnotsupported": "Cette interface n’est pas prise en charge pour les robots.",
+ "apierror-cannot-async-upload-file": "Les paramètres <var>async</var> et <var>file</var> ne peuvent pas être combinés. Si vous voulez un traitement asynchrone de votre fichier téléversé, importez-le d’abord dans la réserve (en utilisant le paramètre <var>stash</var>) puis publiez le fichier importé de façon asynchrone (en utilisant <var>filekey</var> et <var>async</var>).",
+ "apierror-cannotreauthenticate": "Cette action n’est pas disponible car votre identité ne peut pas être vérifiée.",
+ "apierror-cannotviewtitle": "Vous n’êtes pas autorisé à voir $1.",
+ "apierror-cantblock-email": "Vous n’avez pas le droit de bloquer des utilisateurs pour envoyer des courriels via ce wiki.",
+ "apierror-cantblock": "Vous n’avez pas le droit de bloquer des utilisateurs.",
+ "apierror-cantchangecontentmodel": "Vous n’avez pas le droit de modifier le modèle de contenu d’une page.",
+ "apierror-canthide": "Vous n’avez pas le droit de masquer les noms d’utilisateur du journal de blocages.",
+ "apierror-cantimport-upload": "Vous n’avez pas le droit d’importer des pages téléversées.",
+ "apierror-cantimport": "Vous n’avez pas le droit d’importer des pages.",
+ "apierror-cantoverwrite-sharedfile": "Le fichier cible existe dans un dépôt partagé et vous n’avez pas le droit de l’écraser.",
+ "apierror-cantsend": "Vous n’êtes pas connecté, vous n’avez pas d’adresse de courriel confirmée, ou vous n’êtes pas autorisé à envoyer des courriels aux autres utilisateurs, donc vous ne pouvez envoyer de courriel.",
+ "apierror-cantundelete": "Impossible d’annuler : les révisions demandées peuvent ne plus exister, ou avoir déjà été annulées.",
+ "apierror-changeauth-norequest": "Échec à la création de la requête de modification.",
+ "apierror-chunk-too-small": "La taille minimale d’un segment est de $1 {{PLURAL:$1|octet|octets}} pour les segments hors le dernier.",
+ "apierror-cidrtoobroad": "Les plages CIDR $1 plus large que /$2 ne sont pas acceptées.",
+ "apierror-compare-no-title": "Impossible de faire une transformation avant enregistrement sans titre. Essayez de spécifier <var>fromtitle</var> ou <var>totitle</var>.",
+ "apierror-compare-relative-to-nothing": "Pas de révision 'depuis' pour <var>torelative</var> à laquelle se rapporter.",
+ "apierror-contentserializationexception": "Échec de sérialisation du contenu : $1",
+ "apierror-contenttoobig": "Le contenu que vous avez fourni dépasse la limite de taille d’un article, qui est de $1 {{PLURAL:$1|kilooctet|kilooctets}}.",
+ "apierror-copyuploadbaddomain": "Les téléversements par URL ne sont pas autorisés pour ce domaine.",
+ "apierror-copyuploadbadurl": "Les téléversements ne sont pas autorisés depuis cette URL.",
+ "apierror-create-titleexists": "Les titres existants ne peuvent pas être protégés avec <kbd>create</kbd>.",
+ "apierror-csp-report": "Erreur lors du traitement du rapport CSP: $1.",
+ "apierror-databaseerror": "[$1] erreur de requête de base de données.",
+ "apierror-deletedrevs-param-not-1-2": "Le paramètre <var>$1</var> ne peut pas être utilisé dans les modes 1 ou 2.",
+ "apierror-deletedrevs-param-not-3": "Le paramètre <var>$1</var> ne peut pas être utilisé dans le mode 3.",
+ "apierror-emptynewsection": "Il n'est pas possible de créer de nouvelles sections vides.",
+ "apierror-emptypage": "Il n'est pas possible de créer de nouvelles pages vides.",
+ "apierror-exceptioncaught": "[$1] Exception interceptée: $2",
+ "apierror-filedoesnotexist": "Le fichier n’existe pas.",
+ "apierror-fileexists-sharedrepo-perm": "Le fichier cible existe dans un dépôt partagé. Utilisr le paramètre <var>ignorewarnings</var> pour l’écraser.",
+ "apierror-filenopath": "Il n'est pas possible de récupérer le chemin du fichier local.",
+ "apierror-filetypecannotberotated": "Le type du fichier ne peut pas être tourné.",
+ "apierror-formatphp": "Cette réponse ne peut pas être représentée en utilisant <kbd>format=php</kbd>. Voir https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "Le titre pour <kbd>$1</kbd> doit être un fichier.",
+ "apierror-import-unknownerror": "Erreur inconnue lors de l'importation: $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> ne peut pas dépasser $2 (fixé à $3) pour les robots ou les opérateurs système.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> ne peut pas dépasser $2 (fixé à $3) pour les utilisateurs.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> ne peut pas être inférieur à $2 (fixé à $3).",
+ "apierror-invalidcategory": "Le nom de la catégorie que vous avez entré n'est pas valide.",
+ "apierror-invalid-chunk": "Le déplacement plus le segment actuel dépassent la taille demandée du fichier.",
+ "apierror-invalidexpiry": "Heure d'expiration invalide \"$1\".",
+ "apierror-invalid-file-key": "Ne correspond pas à une clé valide de fichier.",
+ "apierror-invalidlang": "Code de langue non valide pour le paramètre <var>$1</var>.",
+ "apierror-invalidoldimage": "Le paramètre <var>oldimage</var> a un format non valide.",
+ "apierror-invalidparammix-cannotusewith": "Le paramètre <kbd>$1</kbd> ne peut pas être utilisé avec <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "Le paramètre <kbd>$1</kbd> ne peut être utilisé qu’avec <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd> ne peut pas être combiné avec le paramètre <var>oldid</var>, <var>pageid</var> ou <var>page</var>. Veuillez utiliser <var>title</var> et <var>text</var>.",
+ "apierror-invalidparammix": "{{PLURAL:$2|Les paramètres}} $1 ne peuvent pas être utilisés ensemble.",
+ "apierror-invalidsection": "Le paramètre <var>section</var> doit être un ID de section valide ou <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "Le hachage SHA1Base36 fourni n’est pas valide.",
+ "apierror-invalidsha1hash": "Le hachage SHA1 fourni n’est pas valide.",
+ "apierror-invalidtitle": "Mauvais titre « $1 ».",
+ "apierror-invalidurlparam": "Valeur non valide pour <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Nom d'utilisateur invalide \"$1\".",
+ "apierror-invaliduserid": "L'ID d'utilisateur <var>$1</var> n'est pas valide.",
+ "apierror-maxlag-generic": "Attente d’un serveur de base de données : $1 {{PLURAL:$1|seconde|secondes}} de délai.",
+ "apierror-maxlag": "Attente de $2 : $1 {{PLURAL:$1|seconed|secondes}} de délai.",
+ "apierror-mimesearchdisabled": "La recherche MIME est désactivée en mode Misère.",
+ "apierror-missingcontent-pageid": "Contenu manquant pour la page d’ID $1.",
+ "apierror-missingcontent-revid": "Contenu de la révision d’ID $1 manquant.",
+ "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|Le paramètre|Au moins un des paramètres}} $1 est obligatoire.",
+ "apierror-missingparam-one-of": "{{PLURAL:$2|Le paramètre|Un des paramètres}} $1 est obligatoire.",
+ "apierror-missingparam": "Le paramètre <var>$1</var> doit être défini.",
+ "apierror-missingrev-pageid": "Aucune révision actuelle de la page d’ID $1.",
+ "apierror-missingrev-title": "Aucune révision actuelle de titre $1.",
+ "apierror-missingtitle-createonly": "Les titres manquants ne peuvent être protégés qu’avec <kbd>create</kbd>.",
+ "apierror-missingtitle": "La page que vous avez spécifié n’existe pas.",
+ "apierror-missingtitle-byname": "La page $1 n’existe pas.",
+ "apierror-moduledisabled": "Le module <kbd>$1</kbd> a été désactivé.",
+ "apierror-multival-only-one-of": "{{PLURAL:$3|Seul|Seul un des}} $2 est autorisé pour le paramètre <var>$1</var>.",
+ "apierror-multival-only-one": "Une seule valeur est autorisée pour le paramètre <var>$1</var>.",
+ "apierror-multpages": "<var>$1</var> ne peut être utilisé qu’avec une seule page.",
+ "apierror-mustbeloggedin-changeauth": "Vous devez être connecté pour modifier les données d’authentification.",
+ "apierror-mustbeloggedin-generic": "Vous devez être connecté.",
+ "apierror-mustbeloggedin-linkaccounts": "Vous devez être connecté pour lier des comptes.",
+ "apierror-mustbeloggedin-removeauth": "Vous devez être connecté pour supprimer les données d’authentification.",
+ "apierror-mustbeloggedin-uploadstash": "La réserve de téléversement n’est disponible que pour les utilisateurs connectés.",
+ "apierror-mustbeloggedin": "Vous devez être connecté pour $1.",
+ "apierror-mustbeposted": "Le module <kbd>$1</kbd> nécessite une requête POST.",
+ "apierror-mustpostparams": "{{PLURAL:$2|Le paramètre suivant a été trouvé|Les paramètres suivants ont été trouvés}} dans la chaîne de requête, mais doit être dans le corps du POST : $1.",
+ "apierror-noapiwrite": "La modification de ce wiki via l’API est désactivée. Assurez-vous que la déclaration <code>$wgEnableWriteAPI=true;</code> st inclue dans le fichier <code>LocalSettings.php</code> du wiki.",
+ "apierror-nochanges": "Aucun changement n’a été demandé.",
+ "apierror-nodeleteablefile": "Pas de telle ancienne version du fichier.",
+ "apierror-no-direct-editing": "La modification directe via l’API n’est pas prise en charge pour le modèle de contenu $1 utilisé par $2.",
+ "apierror-noedit-anon": "Les utilisateurs anonymes ne peuvent pas modifier les pages.",
+ "apierror-noedit": "Vous n’avez pas le droit de modifier les pages.",
+ "apierror-noimageredirect-anon": "Les utilisateurs anonymes ne peut pas créer des redirections d’image.",
+ "apierror-noimageredirect": "Vous n’avez pas le droit de créer des redirections d’image.",
+ "apierror-nosuchlogid": "Il n’y a pas d’entrée du journal avec l’ID $1.",
+ "apierror-nosuchpageid": "Il n’y a pas de page avec l’ID $1.",
+ "apierror-nosuchrcid": "Il n’y a pas de modification récente avec l’ID $1.",
+ "apierror-nosuchrevid": "Il n’y a pas de révision d’ID $1.",
+ "apierror-nosuchsection": "Il n’y a pas de section $1.",
+ "apierror-nosuchsection-what": "Il ’y a pas de section $1 dans $2.",
+ "apierror-nosuchuserid": "Il n'y a pas d'utilisateur ayant l'ID $1.",
+ "apierror-notarget": "Vous n’avez pas spécifié une cible valide pour cette action.",
+ "apierror-notpatrollable": "La révision r$1 ne peut pas être patrouillée car elle est trop ancienne.",
+ "apierror-nouploadmodule": "Aucun module de téléversement défini.",
+ "apierror-offline": "Impossible de continuer du fait de problèmes de connexion au réseau. Assurez-vous d’avoir une connexion internet opérationnelle et réessayez.",
+ "apierror-opensearch-json-warnings": "Les avertissements ne peuvent pas être représentés dans le format JSON OpenSearch.",
+ "apierror-pagecannotexist": "L’espace de noms ne permet pas de pages réelles.",
+ "apierror-pagedeleted": "La page a été supprimée depuis que vous avez récupéré son horodatage.",
+ "apierror-pagelang-disabled": "Il n'est pas possible de modifier la langue d'une page sur ce wiki.",
+ "apierror-paramempty": "Le paramètre <var>$1</var> ne peut pas être vide.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> n’est pris en charge que pour le contenu wikitexte.",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> n’est pris en charge que pour le contenu wikitexte. $1 utilise le modèle de contenu $2.",
+ "apierror-pastexpiry": "Le temps d’expiration « $1 » est dans le passé.",
+ "apierror-permissiondenied": "Vous n’avez pas le droit de $1.",
+ "apierror-permissiondenied-generic": "Autorisation refusée.",
+ "apierror-permissiondenied-patrolflag": "Vous avez besoin du droit <code>patrol</code> ou <code>patrolmarks</code> pour demander le drapeau patrouillé.",
+ "apierror-permissiondenied-unblock": "Vous n’avez pas le droit de débloquer les utilisateurs.",
+ "apierror-prefixsearchdisabled": "La recherche de préfixe est désactivée en mode misérable.",
+ "apierror-promised-nonwrite-api": "L’entête HTTP <code>Promise-Non-Write-API-Action</code> ne peut pas être envoyé aux modules de l’API en mode écriture.",
+ "apierror-protect-invalidaction": "Type de protection non valide « $1 ».",
+ "apierror-protect-invalidlevel": "Niveau de protection non valide « $1 ».",
+ "apierror-ratelimited": "Vous avez dépassé votre limite de débit. Veuillez attendre un peu et réessayer.",
+ "apierror-readapidenied": "Vous avez besoin du droit de lecture pour utiliser ce module.",
+ "apierror-readonly": "Ce wiki est actuellement en mode lecture seule.",
+ "apierror-reauthenticate": "Vous n’avez pas authentifié récemment cette session ; veuillez vous authentifier de nouveau.",
+ "apierror-redirect-appendonly": "Vous avez essayé de modifier en utilisant le mode de suivi de redirection, qui doit être utilisé en lien avec <kbd>section=new</kbd>, <var>prependtext</var>, ou <var>appendtext</var>.",
+ "apierror-revdel-mutuallyexclusive": "Le même champ ne peut pas être utilisé à la fois en <var>hide</var> et <var>show</var>.",
+ "apierror-revdel-needtarget": "Un titre cible est nécessaire pour ce type RevDel.",
+ "apierror-revdel-paramneeded": "Au moins une valeur est nécessaire pour <var>hide</var> ou <var>show</var>.",
+ "apierror-revisions-badid": "Pas de correction trouvée pour le paramètre <var>$1</var>.",
+ "apierror-revisions-norevids": "Le paramètre <var>revids</var> ne peut pas être utilisé avec les options de liste (<var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var>, et <var>$1end</var>).",
+ "apierror-revisions-singlepage": "<var>titles</var>, <var>pageids</var> ou un générateur a été utilisé pour fournir plusieurs pages, mais les paramètres <var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> et <var>$1end</var> ne peuvent être utilisés que sur une seule page.",
+ "apierror-revwrongpage": "r$1 n'est pas une révision de $2.",
+ "apierror-searchdisabled": "La recherche <var>$1</var> est désactivée.",
+ "apierror-sectionreplacefailed": "Impossible de fusionner la section mise à jour.",
+ "apierror-sectionsnotsupported": "Les sections ne sont pas prises en charge pour le modèle de contenu $1.",
+ "apierror-sectionsnotsupported-what": "Les sections ne sont pas prises en charge par $1.",
+ "apierror-show": "Paramètre incorrect - des valeurs mutuellement exclusives ne peuvent pas être fournies.",
+ "apierror-siteinfo-includealldenied": "Impossible d’afficher toutes les informatiosn du serveur, sauf si <var>$wgShowHostNames</var> vaut vrai.",
+ "apierror-sizediffdisabled": "La différence de taille est désactivée dans le mode Miser.",
+ "apierror-spamdetected": "Votre modification a été refusée parce qu’elle contenait un fragment de pourriel : <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "Vous n'avez pas l'autorisation d'afficher les résultats de cette page spéciale.",
+ "apierror-stashedfilenotfound": "Impossible de trouver le fichier dans la réserve: $1.",
+ "apierror-stashedit-missingtext": "Pas de texte en réserve associé à la donnée de hachage.",
+ "apierror-stashfailed-complete": "Un téléversement par morceaux est déjà achevé, vérifiez l’état pour plus de détails.",
+ "apierror-stashfailed-nosession": "Aucune session de téléversement par morceaux avec cette clé.",
+ "apierror-stashfilestorage": "Impossible de mettre le téléversement en réserve: $1",
+ "apierror-stashinvalidfile": "Fichier de réserve invalide.",
+ "apierror-stashnosuchfilekey": "Filekey inconnue: $1.",
+ "apierror-stashpathinvalid": "La clé du fichier n'a pas le bon format ou est invalide: $1 .",
+ "apierror-stashwrongowner": "Erreur de propriétaire: $1",
+ "apierror-stashzerolength": "Fichier est de longueur nulle, et n'a pas pu être mis dans la réserve: $1.",
+ "apierror-systemblocked": "Vous avez été bloqué automatiquement par MediaWiki.",
+ "apierror-templateexpansion-notwikitext": "Le développement du modèle n'est effectif que sur un contenu wikitext. $1 utilise le modèle de contenu $2.",
+ "apierror-timeout": "Le serveur n’a pas répondu dans le délai imparti.",
+ "apierror-toofewexpiries": "$1 {{PLURAL:$1|horodatage d’expiration a été fourni|horodatages d’expiration ont été fournis}} alors que $2 {{PLURAL:$2|était attendu|étaient attendus}}.",
+ "apierror-unknownaction": "L'action spécifiée, <kbd>$1</kbd>, n'est pas reconnue.",
+ "apierror-unknownerror-editpage": "Erreur inconnue EditPage: $1.",
+ "apierror-unknownerror-nocode": "Erreur inconnue.",
+ "apierror-unknownerror": "Erreur inconnue : « $1 ».",
+ "apierror-unknownformat": "Format inconnu \"$1\".",
+ "apierror-unrecognizedparams": "Paramètre{{PLURAL:$2||s}} non reconnu{{PLURAL:$2||s}} : $1.",
+ "apierror-unrecognizedvalue": "Valeur non reconnue du paramètre <var>$1</var>: $2.",
+ "apierror-unsupportedrepo": "Le dépôt local des fichiers ne prend pas en charge la recherche de toutes les images.",
+ "apierror-upload-filekeyneeded": "Un <var>filekey</var> est nécessaire si le <var>décalage</var> est non nul.",
+ "apierror-upload-filekeynotallowed": "Pas possible de fournir une <var>filekey</var> si <var>offset</var> vaut 0.",
+ "apierror-upload-inprogress": "Le téléversement à partir de la réserve est déjà en cours.",
+ "apierror-upload-missingresult": "Pas de résultat dans les données d'état.",
+ "apierror-urlparamnormal": "Impossible de normaliser les paramètres de l'image pour $1.",
+ "apierror-writeapidenied": "Vous n'êtes pas autorisé à modifier ce wiki au travers de l'API.",
+ "apiwarn-alldeletedrevisions-performance": "Pour de meilleures performances lors de la génération des titres, mettre <kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "Impossible d'analyser <var>$1urlparam</var> pour $2. En utilisant seulement la largeur et la hauteur.",
+ "apiwarn-badutf8": "La valeur passée pour <var>$1</var> contient des données non valides ou non normalisées. Les données textuelles doivent être de l’Unicode valide normalisé en NFC sans caractères de contrôle c0 autres que HT (\\t), LF (\\n) et CR (\\r).",
+ "apiwarn-checktoken-percentencoding": "Vérifier que les symboles tels que \"+\" dans le jeton sont correctement codés avec des pourcents dans l'URL.",
+ "apiwarn-compare-nocontentmodel": "Aucun modèle de contenu n’a pu être déterminé, $1 est supposé.",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> est devenu désuet. Veuillez utiliser <kbd>prop=deletedrevisions</kbd> ou <kbd>list=alldeletedrevisions</kbd> à la place.",
+ "apiwarn-deprecation-expandtemplates-prop": "Comme aucune valeur n’a été spécifiée pour le paramètre <var>prop</var>, un format patrimonial a été utilisé pour la sortie. Ce format est désuet et, dans le futur, une valeur par défaut sera fixée pour le paramètre <var>prop</var>, provoquant ainsi l’utilisation systématique du nouveau format.",
+ "apiwarn-deprecation-httpsexpected": "HTTP est utilisé alors que HTTPS est attendu.",
+ "apiwarn-deprecation-login-botpw": "La connexion au compte principal via <kbd>action=login</kbd> est désuète et peut cesser de fonctionner sans avertissement. Pour continuer à vous connecter avec <kbd>action=login</kbd>, voyez [[Special:BotPasswords]]. Pour continuer à utiliser la connexion au compte principal en toute sécurité, voyez <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "La connexion au compte principal via <kbd>action=login</kbd> est désuète et peut cesser de fonctionner sans avertissement. Pour vous connecter en toute sécurité, voyez <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-token": "La récupération d’un jeton via <kbd>action=login</kbd> est désuète. Utilisez <kbd>action=query&meta=tokens&type=login</kbd> à la place.",
+ "apiwarn-deprecation-parameter": "Le paramètre <var>$1</var> est désuet.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> est désuet depuis MédiaWiki 1.28. Utilisez <kbd>prop=headhtml</kbd> lors de la création de nouveaux documents HTML, ou <kbd>prop=modules|jsconfigvars</kbd> lors de la mise à jour d’un document côté client.",
+ "apiwarn-deprecation-purge-get": "L’utilisation de <kbd>action=purge</kbd> via un GET est désuète. Utiliser POST à la place.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> est désuet. Veuillez utiliser <kbd>$2</kbd> à la place.",
+ "apiwarn-difftohidden": "Impossible de faire un diff avec r$1 : le contenu est masqué.",
+ "apiwarn-errorprinterfailed": "Erreur échec imprimante. Nouvel essai sans paramètres.",
+ "apiwarn-errorprinterfailed-ex": "Erreur d’échec de l’impression (réessayera sans paramètres) : $1",
+ "apiwarn-invalidcategory": "« $1 » n'est pas une catégorie.",
+ "apiwarn-invalidtitle": "« $1 » n’est pas un titre valide.",
+ "apiwarn-invalidxmlstylesheetext": "Une feuille de style doit avoir une extension <code>.xsl</code>.",
+ "apiwarn-invalidxmlstylesheet": "Feuille de style spécifiée non valide ou inexistante.",
+ "apiwarn-invalidxmlstylesheetns": "La feuille de style devrait être dans l’espace de noms {{ns:MediaWiki}}.",
+ "apiwarn-moduleswithoutvars": "La propriété <kbd>modules</kbd> a été définie mais pas <kbd>jsconfigvars</kbd> ni <kbd>encodedjsconfigvars</kbd>. Les variables de configuration sont nécessaires pour une utilisation correcte du module.",
+ "apiwarn-notfile": "« $1 » n'est pas un fichier.",
+ "apiwarn-nothumb-noimagehandler": "Impossible de créer la vignette car $1 n’a pas de gestionnaire d’image associé.",
+ "apiwarn-parse-nocontentmodel": "Ni <var>title</var> ni <var>contentmodel</var> n’ont été fournis, $1 est supposé.",
+ "apiwarn-parse-titlewithouttext": "<var>title</var> utilisé sans <var>text</var>, et les propriétés de page analysées sont nécessaires. Voulez-vous dire que vous voulez utiliser <var>page</var> à la place de <var>title</var> ?",
+ "apiwarn-redirectsandrevids": "La résolution de la redirection ne peut pas être utilisée avec le paramètre <var>revids</var>. Toutes les redirections vers lesquelles pointent <var>revids</var> n’ont pas été résolues.",
+ "apiwarn-tokennotallowed": "L'action « $1 » n'est pas autorisée pour l'utilisateur actuel.",
+ "apiwarn-tokens-origin": "Les jetons ne peuvent pas être obtenus quand la politique de même origine n’est pas appliquée.",
+ "apiwarn-toomanyvalues": "Trop de valeurs fournies pour le paramètre <var>$1</var>. La limite est $2.",
+ "apiwarn-truncatedresult": "Ce résultat a été tronqué parce que sinon, il dépasserait la limite de $1 octets.",
+ "apiwarn-unclearnowtimestamp": "Passer « $2 » comme paramètre d’horodatage <var>$1</var> a été rendu désuet. Si, pour une raison quelconque, vous avez besoin de spécifier explicitement l’heure courante sans la recalculer du côté client, utilisez <kbd>now</kbd>.",
+ "apiwarn-unrecognizedvalues": "{{PLURAL:$3|Valeur non reconnue|Valeurs non reconnues}} pour le paramètre <var>$1</var> : $2.",
+ "apiwarn-unsupportedarray": "Le paramètre <var>$1</var> utilise une syntaxe PHP de tableau non prise en charge.",
+ "apiwarn-urlparamwidth": "Valeur de la largeur définie dans <var>$1urlparam</var> ($2) ignorée en faveur de la largeur calculée à partir de <var>$1urlwidth</var>/<var>$1urlheight</var> ($3).",
+ "apiwarn-validationfailed-badchars": "caractères non valides dans la clé (<code>a-z</code>, <code>A-Z</code>, <code>0-9</code>, <code>_</code>et <code>-</code> sont les seuls autorisés).",
+ "apiwarn-validationfailed-badpref": "pas une préférence valide.",
+ "apiwarn-validationfailed-cannotset": "ne peut pas être initialisé par ce module.",
+ "apiwarn-validationfailed-keytoolong": "clé trop longue (au plus $1 octets).",
+ "apiwarn-validationfailed": "Erreur de validation pour <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>Avertissement de sécurité</strong>: <var>$wgDebugAPI</var> est activé.",
+ "api-feed-error-title": "Erreur ($1)",
+ "api-usage-docref": "Voir $1 concernant l'utilisation de l'API.",
+ "api-usage-mailinglist-ref": "S’abonner à la liste de diffusion mediawiki-api-announce sur &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; pour les signalisations d’obsolescence de l’API ou de modifications en rupture.",
+ "api-exception-trace": "$1 à $2($3)\n$4",
+ "api-credits-header": "Remerciements",
+ "api-credits": "Développeurs de l’API :\n* Roan Kattouw (développeur en chef Sept. 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (créateur, développeur en chef Sept. 2006–Sept. 2007)\n* Brad Jorsch (développeur en chef depuis 2013)\n\nVeuillez envoyer vos commentaires, suggestions et questions à mediawiki-api@lists.wikimedia.org\nou remplir un rapport de bogue sur https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/frc.json b/www/wiki/includes/api/i18n/frc.json
new file mode 100644
index 00000000..e8d706c1
--- /dev/null
+++ b/www/wiki/includes/api/i18n/frc.json
@@ -0,0 +1,20 @@
+{
+ "@metadata": {
+ "authors": [
+ "Hangmanwa7id",
+ "Macofe"
+ ]
+ },
+ "apihelp-block-summary": "Bloquer un useur.",
+ "apihelp-createaccount-param-name": "Nom d'useur.",
+ "apihelp-createaccount-param-password": "Mot de passe (ignoré si <var>$1mailpassword</var> est défini).",
+ "apihelp-createaccount-param-domain": "Domaine pour l’authentification externe (optional).",
+ "apihelp-delete-summary": "Effacer une page.",
+ "apihelp-delete-param-title": "Titre de la page que tu veux effacer. Impossible de l’user avec $1pageid.",
+ "apihelp-delete-example-simple": "Effacer <kbd>Main Page</kbd>.",
+ "apihelp-emailuser-summary": "Emailer un useur.",
+ "apihelp-expandtemplates-param-title": "Titre de la page.",
+ "apihelp-login-param-name": "Nom d’useur.",
+ "apihelp-login-param-password": "Mot de passe.",
+ "apihelp-login-param-domain": "Domaine (optional)."
+}
diff --git a/www/wiki/includes/api/i18n/fy.json b/www/wiki/includes/api/i18n/fy.json
new file mode 100644
index 00000000..05482cfe
--- /dev/null
+++ b/www/wiki/includes/api/i18n/fy.json
@@ -0,0 +1,15 @@
+{
+ "@metadata": {
+ "authors": [
+ "Robin0van0der0vliet"
+ ]
+ },
+ "apihelp-createaccount-param-name": "Brûkersnamme.",
+ "apihelp-login-param-name": "Brûkersnamme.",
+ "apihelp-login-param-password": "Wachtwurd.",
+ "apihelp-userrights-param-user": "Brûkersnamme.",
+ "api-help-param-default": "Standert: $1",
+ "api-help-param-default-empty": "Standert: <span class=\"apihelp-empty\">(leech)</span>",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(gjin beskriuwing)</span>",
+ "api-help-examples": "{{PLURAL:$1|Foarbyld|Foarbylden}}:"
+}
diff --git a/www/wiki/includes/api/i18n/gl.json b/www/wiki/includes/api/i18n/gl.json
new file mode 100644
index 00000000..8e978b2f
--- /dev/null
+++ b/www/wiki/includes/api/i18n/gl.json
@@ -0,0 +1,1711 @@
+{
+ "@metadata": {
+ "authors": [
+ "Elisardojm",
+ "Agremon",
+ "Chairego apc",
+ "VaiPolaSombra",
+ "Banjo",
+ "Fisterraeomar",
+ "Toliño",
+ "Amire80",
+ "Macofe",
+ "Hamilton Abreu",
+ "Umherirrender"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentación]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista de discusión]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Anuncios da API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Erros e solicitudes]\n</div>\n<strong>Estado:</strong> Tódalas funcionalidades mostradas nesta páxina deberían estar funcionanado, pero a API aínda está desenrolo, e pode ser modificada en calquera momento. Apúntese na [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ lista de discusión mediawiki-api-announce] para estar informado acerca das actualizacións.\n\n<strong>Solicitudes incorrectas:</strong> Cando se envían solicitudes incorrectas á API, envíase unha cabeceira HTTP coa chave \"MediaWiki-API-Error\" e, a seguir, tanto o valor da cabeceira como o código de erro retornado serán definidos co mesmo valor. Para máis información, consulte [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Erros e avisos]].\n\n<strong>Test:</strong> Para facilitar as probas das peticións da API, consulte [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Que acción se realizará.",
+ "apihelp-main-param-format": "O formato de saída.",
+ "apihelp-main-param-maxlag": "O retardo máximo pode usarse cando MediaWiki está instalada nun cluster de base de datos replicadas. Para gardar accións que causen calquera retardo máis de replicación do sitio, este parámetro pode facer que o cliente espere ata que o retardo de replicación sexa menor que o valor especificado. No caso de retardo excesivo, é devolto o código de erro <samp>maxlag</samp> cunha mensaxe como <samp>esperando por $host: $lag segundos de retardo</samp>.<br />Para máis información, ver [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Maxlag parameter]].",
+ "apihelp-main-param-smaxage": "Fixar a cabeceira HTTP de control de caché <code>s-maxage</code> a esos segundos. Os erros nunca se gardan na caché.",
+ "apihelp-main-param-maxage": "Fixar a cabeceira HTTP de control de caché <code>max-age</code> a esos segundos. Os erros nunca se gardan na caché.",
+ "apihelp-main-param-assert": "Verificar se o usuario está conectado como <kbd>usuario</kbd> ou ten a marca de <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Verificar que o usuario actual é o usuario nomeado.",
+ "apihelp-main-param-requestid": "Calquera valor dado aquí será incluído na resposta. Pode usarse para distingir peticións.",
+ "apihelp-main-param-servedby": "Inclúa o nome do servidor que servía a solicitude nos resultados.",
+ "apihelp-main-param-curtimestamp": "Incluir a marca de tempo actual no resultado.",
+ "apihelp-main-param-responselanginfo": "Incluír no resultado as linguas usada para <var>uselang</var> e <var>errorlang</var>.",
+ "apihelp-main-param-origin": "Cando se accede á API usando unha petición AJAX entre-dominios (CORS), inicializar o parámetro co dominio orixe. Isto debe incluírse en calquera petición pre-flight, e polo tanto debe ser parte da petición URI (non do corpo POST). Para peticións autenticadas, isto debe coincidir exactamente cunha das orixes na cabeceira <code>Origin</code>, polo que ten que ser fixado a algo como <kbd>https://en.wikipedia.org</kbd> ou <kbd>https://meta.wikimedia.org</kbd>. Se este parámetro non coincide coa cabeceira <code>Origin</code>, devolverase unha resposta 403. Se este parámetro coincide coa cabeceira <code>Origin</code> e a orixe está na lista branca, as cabeceiras <code>Access-Control-Allow-Origin</code> e <code>Access-Control-Allow-Credentials</code> serán fixadas.\n\nPara peticións non autenticadas, especifique o valor <kbd>*</kbd>. Isto fará que se fixe a cabeceira <code>Access-Control-Allow-Origin</code>, pero <code>Access-Control-Allow-Credentials</code> será <code>false</code> e todos os datos específicos do usuario serán ocultados.",
+ "apihelp-main-param-uselang": "Linga a usar para a tradución de mensaxes. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> con <kbd>siprop=languages</kbd> devolve unha lista de códigos de lingua, ou especificando <kbd>user</kbd> coa preferencia de lingua do usuario actual, ou especificando <kbd>content</kbd> para usar a lingua do contido desta wiki.",
+ "apihelp-main-param-errorformat": "Formato a usar para a saída do texto de aviso e de erroː\n; plaintext: texto wiki sen as etiquetas HTML e coas entidades substituídas.\n; wikitext: texto wiki sen analizar.\n; html: HTML.\n; raw: Clave de mensaxe e parámetros.\n; none: Sen saída de texto, só os códigos de erro.\n; bc: Formato utilizado antes de MediaWiki 1.29. <var>errorlang</var> e <var>errorsuselocal</var> non se teñen en conta.",
+ "apihelp-main-param-errorlang": "Lingua usada para advertencias e erros. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> con <kbd>siprop=languages</kbd> devolve unha lista de códigos de lingua. Pode especificar <kbd>content</kbd> para utilizar a lingua do contido deste wiki ou <kbd>uselang</kbd> para utilizar o mesmo valor que o do parámetro <var>uselang</var>.",
+ "apihelp-main-param-errorsuselocal": "Se se indica, os textos de erro empregarán mensaxes adaptadas á lingua do espazo de nomes {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "Bloquear un usuario.",
+ "apihelp-block-param-user": "Nome de usuario, dirección ou rango de IPs que quere bloquear. Non pode usarse xunto con <var>$1userid</var>",
+ "apihelp-block-param-userid": "Identificador de usuario a bloquear. Non pode usarse xunto con <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Tempo de caducidade. Pode ser relativo (p. ex.<kbd>5 meses</kbd> ou <kbd>2 semanas</kbd>) ou absoluto (p. ex. 2014-09-18T12:34:56Z</kbd>). Se se pon kbd>infinite</kbd>, <kbd>indefinite</kbd>, ou <kbd>never</kbd>, o bloqueo nunca caducará.",
+ "apihelp-block-param-reason": "Motivo para o bloqueo.",
+ "apihelp-block-param-anononly": "Bloquear só usuarios anónimos (é dicir, desactivar edicións anónimas desta dirección IP).",
+ "apihelp-block-param-nocreate": "Previr a creación de contas.",
+ "apihelp-block-param-autoblock": "Bloquear automaticamente o último enderezo IP utilizado, e calquera outro enderezo desde o que intente conectarse.",
+ "apihelp-block-param-noemail": "Impide que o usuario envíe correos electrónicos a través da wiki. (Require o permiso <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Ocultar o nome de usuario do rexistro de bloqueos. (Precisa do permiso <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Permitir que o usuario edite a súa propia páxina de conversa (depende de <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Se o usuario xa está bloqueado, sobreescribir o bloqueo existente.",
+ "apihelp-block-param-watchuser": "Vixiar a páxina de usuario ou direccións IP e a de conversa deste usuario",
+ "apihelp-block-param-tags": "Cambiar as etiquetas a aplicar á entrada no rexistro de bloqueos.",
+ "apihelp-block-example-ip-simple": "Bloquear dirección IP <kbd>192.0.2.5</kbd> durante tres días coa razón <kbd>Primeiro aviso</kbd>.",
+ "apihelp-block-example-user-complex": "Bloquear indefinidamente ó usuario <kbd>Vandal</kbd> coa razón <kbd>Vandalism</kbd>, e impedir a creación de novas contas e envío de correos electrónicos.",
+ "apihelp-changeauthenticationdata-summary": "Cambiar os datos de autenticación do usuario actual.",
+ "apihelp-changeauthenticationdata-example-password": "Intento de cambiar o contrasinal do usuario actua a <kbd>ExemploContrasinal</kbd>.",
+ "apihelp-checktoken-summary": "Verificar a validez dun identificador de <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Tipo de identificador a probar.",
+ "apihelp-checktoken-param-token": "Símbolo a testar",
+ "apihelp-checktoken-param-maxtokenage": "Tempo máximo autorizado para o identificador, en segundos.",
+ "apihelp-checktoken-example-simple": "Verificar a validez de un identificador <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Limpar a bandeira <code>hasmsg</code> para o usuario actual",
+ "apihelp-clearhasmsg-example-1": "Limpar a bandeira <code>hasmsg</code> para o usuario actual",
+ "apihelp-clientlogin-summary": "Conectarse á wiki usando o fluxo interactivo.",
+ "apihelp-clientlogin-example-login": "Comezar o proceso de conexión á wiki como o usuario <kbd>Exemplo</kbd> con contrasinal <kbd>ExemploContrasinal</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continuar a conexión despois dunha resposta de <samp>UI</samp> para unha autenticación de dous factores, proporcionando un <var>OATHToken</var> con valor <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Obter as diferencias entre dúas páxinas.",
+ "apihelp-compare-extended-description": "Debe indicar un número de revisión, un título de páxina, ou un ID de páxina tanto para \"from\" como para \"to\".",
+ "apihelp-compare-param-fromtitle": "Primeiro título para comparar.",
+ "apihelp-compare-param-fromid": "Identificador da primeira páxina a comparar.",
+ "apihelp-compare-param-fromrev": "Primeira revisión a comparar.",
+ "apihelp-compare-param-fromtext": "Uso este texto en vez do contido da revisión especificada por <var>fromtitle</var>, <var>fromid</var> ou <var>fromrev</var>.",
+ "apihelp-compare-param-totitle": "Segundo título para comparar.",
+ "apihelp-compare-param-toid": "Identificador da segunda páxina a comparar.",
+ "apihelp-compare-param-torev": "Segunda revisión a comparar.",
+ "apihelp-compare-param-prop": "Que información obter.",
+ "apihelp-compare-paramvalue-prop-diff": "O diff HTML.",
+ "apihelp-compare-paramvalue-prop-diffsize": "O tamaño do diff HTML, en bytes.",
+ "apihelp-compare-paramvalue-prop-size": "Tamaño das revisións 'desde' e 'a'.",
+ "apihelp-compare-example-1": "Mostrar diferencias entre a revisión 1 e a 2",
+ "apihelp-createaccount-summary": "Crear unha nova conta de usuario.",
+ "apihelp-createaccount-param-preservestate": "SE <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> devolve o valor \"certo\" para <samp>hasprimarypreservedstate</samp>, as consultas marcadas como <samp>primary-required</samp> deben ser omitidas. Se devolve un valor non baleiro para <samp>preservedusername</samp>, ese nome de usuario debe usarse para o parámetro <var>username</var>.",
+ "apihelp-createaccount-example-create": "Comezar o proceso de crear un usuario <kbd>Exemplo</kbd> con contrasinal <kbd>ExemploContrasinal</kbd>.",
+ "apihelp-createaccount-param-name": "Nome de usuario.",
+ "apihelp-createaccount-param-password": "Contrasinal (ignorado se <var>$1mailpassword</var> está activo)",
+ "apihelp-createaccount-param-domain": "Dominio para autenticación externa (opcional)",
+ "apihelp-createaccount-param-token": "Símbolo de creación de conta obtido á primeira.",
+ "apihelp-createaccount-param-email": "Enderezo de correo eletrónico do usuario (opcional).",
+ "apihelp-createaccount-param-realname": "Nome real do usuario (opcional).",
+ "apihelp-createaccount-param-mailpassword": "Se se establece calquera valor, enviarase un contrasinal aleatorio ao usuario.",
+ "apihelp-createaccount-param-reason": "Razón opcional de creación da conta para gardar nos rexistros.",
+ "apihelp-createaccount-param-language": "Código de lingua para usar como defecto polo usuario (de xeito opcional, usarase a lingua por defecto)",
+ "apihelp-createaccount-example-pass": "Crear usuario <kbd>testuser</kbd> con contrasinal <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Crear usuario <kbd>testmailuser</kbd>\"testmailuser\" e enviar por correo electrónico un contrasinal xenerado de forma aleatoria.",
+ "apihelp-cspreport-summary": "Usado polos navegadores para informar de violacións da política de confidencialidade de contido. Este módulo non debe se usado nunca, excepto cando é usado automaticamente por un navegador web compatible con CSP.",
+ "apihelp-cspreport-param-reportonly": "Marcar un informe dunha política de vixiancia e non unha política esixida",
+ "apihelp-cspreport-param-source": "Que xerou a cabeceira CSP que lanzou este informe",
+ "apihelp-delete-summary": "Borrar a páxina.",
+ "apihelp-delete-param-title": "Título da páxina a eliminar. Non pode usarse xunto con <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Identificador da páxina a eliminar. Non pode usarse xunto con <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Razón para o borrado. Se non se indica, usarase unha razón xenerada automaticamente.",
+ "apihelp-delete-param-tags": "Cambiar as etiquetas a aplicar na entrada do rexistro de borrado.",
+ "apihelp-delete-param-watch": "Engadir esta páxina á lista de vixilancia do usuario actual.",
+ "apihelp-delete-param-watchlist": "Engadir ou eliminar sen condicións a páxina da lista de vixiancia do usuario actual, use as preferencias ou non cambie a vixiancia.",
+ "apihelp-delete-param-unwatch": "Eliminar esta páxina da lista de vixilancia do usuario actual.",
+ "apihelp-delete-param-oldimage": "Nome da imaxe antiga a borrar como se proporciona en [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Borrar <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Eliminar <kbd>Main Page</kbd> coa razón <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Este módulo foi desactivado.",
+ "apihelp-edit-summary": "Crear e editar páxinas.",
+ "apihelp-edit-param-title": "Título da páxina que quere editar. Non pode usarse xunto con <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Identificador da páxina que quere editar. Non pode usarse xunto con <var>$1title</var>.",
+ "apihelp-edit-param-section": "Número de selección. O <kbd>0</kbd> é para a sección superior, <kbd>new</kbd> para unha sección nova.",
+ "apihelp-edit-param-sectiontitle": "Título para unha nova sección.",
+ "apihelp-edit-param-text": "Contido da páxina.",
+ "apihelp-edit-param-summary": "Resumo de edición. Tamén título de sección cando $1section=new e $1sectiontitle non está definido.",
+ "apihelp-edit-param-tags": "Cambio de etiquetas a aplicar á revisión.",
+ "apihelp-edit-param-minor": "Edición pequena.",
+ "apihelp-edit-param-notminor": "Edición non pequena.",
+ "apihelp-edit-param-bot": "Marcar esta edición como de bot.",
+ "apihelp-edit-param-basetimestamp": "Selo de tempo da revisión de base, usado para detectar conflitos de edición. Pode obterse con [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Selo de tempo do comezo do proceso de edición, usado para detectar conflitos de edición. Pode obterse un valor axeitado usando <var>[[Special:ApiHelp/main|curtimestamp]]</var> cando se comeza o proceso de edición (p.ex. cando se carga o contido da páxina a editar).",
+ "apihelp-edit-param-recreate": "Ignorar todos os erros da páxina mentres está a ser borrada.",
+ "apihelp-edit-param-createonly": "Non editar a páxina se xa existe.",
+ "apihelp-edit-param-nocreate": "Amosar un mensaxe de erro se a páxina non existe",
+ "apihelp-edit-param-watch": "Engadir esta páxina á lista de vixilancia do usuario actual.",
+ "apihelp-edit-param-unwatch": "Eliminar esta páxina da lista de vixilancia do usuario actual.",
+ "apihelp-edit-param-watchlist": "Engadir ou eliminar sen condicións a páxina da lista de vixiancia do usuario actual, use as preferencias ou non cambie a vixiancia.",
+ "apihelp-edit-param-md5": "A función hash MD5 do parámetro $1text, ou dos parámetros $1prependtext e $1appendtext concatenados. Se está definida, non se fará a edición ata que a función hash sexa correcta.",
+ "apihelp-edit-param-prependtext": "Engadir este texto ao comezo da páxina. Sobreescribirase $1text.",
+ "apihelp-edit-param-appendtext": "Engadir este texto no final da páxina. Ignorar $1text.\n\nUse $1section=new para engadir unha nova sección, máis que este parámetro.",
+ "apihelp-edit-param-undo": "Desfacer esta revisión. Ignorar $1text, $1prependtext e $1appendtext.",
+ "apihelp-edit-param-undoafter": "Desfacer tódalas revisións dende $1undo ata esta. Se non está definido, só desfacer unha revisión.",
+ "apihelp-edit-param-redirect": "Resolver redireccións automaticamente",
+ "apihelp-edit-param-contentformat": "Formato de serialización de contido utilizado para o texto de entrada.",
+ "apihelp-edit-param-contentmodel": "Modelo de contido para o novo contido.",
+ "apihelp-edit-param-token": "O identificador debería enviarse empre como o último parámetro, ou polo menos despois do parámetro $1text.",
+ "apihelp-edit-example-edit": "Editar a páxina",
+ "apihelp-edit-example-prepend": "Antepor <kbd>_&#95;NOTOC_&#95;</kbd> a unha páxina.",
+ "apihelp-edit-example-undo": "Desfacer revisións 13579 a 13585 con resumo automático.",
+ "apihelp-emailuser-summary": "Enviar un correo electrónico a un usuario.",
+ "apihelp-emailuser-param-target": "Usuario ó que lle mandar correo electrónico.",
+ "apihelp-emailuser-param-subject": "Asunto.",
+ "apihelp-emailuser-param-text": "Corpo do correo.",
+ "apihelp-emailuser-param-ccme": "Enviarme unha copia deste correo.",
+ "apihelp-emailuser-example-email": "Enviar un correo electrónico ó usuario <kbd>WikiSysop</kbd> co texto <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Expandir tódolos modelos dentro do wikitexto.",
+ "apihelp-expandtemplates-param-title": "Título da páxina.",
+ "apihelp-expandtemplates-param-text": "Sintaxis wiki a converter.",
+ "apihelp-expandtemplates-param-revid": "ID de revisión, para <code><nowiki>{{REVISIONID}}</nowiki></code> e variables similares.",
+ "apihelp-expandtemplates-param-prop": "Pezas de información a retornar.\n\nTeña en conta que se non se selecciona ningún valor o resultado conterá o texto wiki, pero a saída estará nun formato obsoleto.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "O wikitexto expandido.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Calquera categoría presente na entrada que non estea representada na saída do texto wiki.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Propiedades da páxina definidas por palabras máxicas expandidas no texto wiki.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Definir se a saída é volátil e se non debe usarse noutra parte da páxina.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Tempo máximo a partir do cal os cachés do resultado deben invalidarse.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Calquera módulo ResourceLoader que as funcións de análise teñan solicitado engadir á saída. <kbd>jsconfigvars</kbd> ou <kbd>encodedjsconfigvars</kbd> deben ser solicitadas xunto con <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Devolve as variables específicas de configuración JavaScript da páxina.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Devolve as variables específicas de configuración JavaScript da páxina como unha cadea de texto JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "A árbore de análise XML da entrada.",
+ "apihelp-expandtemplates-param-includecomments": "Cando queria incluír comentarios HTML na saída.",
+ "apihelp-expandtemplates-param-generatexml": "Xenerar árbore de análise XML (reemprazado por $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Expandir o wikitexto <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Devolve a lista de contribucións dun usuario.",
+ "apihelp-feedcontributions-param-feedformat": "O formato de alimentación.",
+ "apihelp-feedcontributions-param-user": "Para que usuarios recuperar as contribucións.",
+ "apihelp-feedcontributions-param-namespace": "Que espazo de nomes filtrar polas contribucións.",
+ "apihelp-feedcontributions-param-year": "Desde o ano (e anteriores).",
+ "apihelp-feedcontributions-param-month": "Desde o mes de (e anteriores).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrar as contribucións que teñan estas etiquetas.",
+ "apihelp-feedcontributions-param-deletedonly": "Mostrar só as contribuciones eliminadas.",
+ "apihelp-feedcontributions-param-toponly": "Mostrar só as edicións que que son as ultimas revisións.",
+ "apihelp-feedcontributions-param-newonly": "Mostrar só as edicións que crearon páxinas.",
+ "apihelp-feedcontributions-param-hideminor": "Ocultar edicións menores.",
+ "apihelp-feedcontributions-param-showsizediff": "Mostrar diferenza de tamaño entre edicións.",
+ "apihelp-feedcontributions-example-simple": "Mostrar as contribucións do usuario <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Devolve un ficheiro de cambios recentes.",
+ "apihelp-feedrecentchanges-param-feedformat": "O formato da saída.",
+ "apihelp-feedrecentchanges-param-namespace": "Espazo de nomes ó que limitar os resultados.",
+ "apihelp-feedrecentchanges-param-invert": "Tódolos nomes de espazos agás o seleccionado",
+ "apihelp-feedrecentchanges-param-associated": "Incluir o espazo de nomes asociado (conversa ou principal).",
+ "apihelp-feedrecentchanges-param-days": "Días a limitar os resultados",
+ "apihelp-feedrecentchanges-param-limit": "Número máximo de resultados a visualizar.",
+ "apihelp-feedrecentchanges-param-from": "Mostrar modificacións desde entón.",
+ "apihelp-feedrecentchanges-param-hideminor": "Ocultar cambios menores.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ocultar cambios feitos por bots.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ocultar os cambios realizados por usuarios anónimos.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ocultar os cambios realizados por usuarios rexistrados.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ocultar os cambios patrullados.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ocultar os cambios realizados polo usuario actual.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Agochar os cambios de pertenza á categoría.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrar por etiqueta.",
+ "apihelp-feedrecentchanges-param-target": "Mostrar só os cambios nas páxinas ligadas a esta.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Mostrar os cambios nas páxinas ligadas coa páxina seleccionada.",
+ "apihelp-feedrecentchanges-param-categories": "Só mostrar cambios en páxinas pertencentes a todas estas categorías.",
+ "apihelp-feedrecentchanges-param-categories_any": "Só mostrar cambios en páxinas pertencentes a calquera das categorías.",
+ "apihelp-feedrecentchanges-example-simple": "Mostrar os cambios recentes",
+ "apihelp-feedrecentchanges-example-30days": "Mostrar os cambios recentes limitados a 30 días",
+ "apihelp-feedwatchlist-summary": "Devolve o fluxo dunha lista de vixiancia.",
+ "apihelp-feedwatchlist-param-feedformat": "O formato da saída.",
+ "apihelp-feedwatchlist-param-hours": "Lista as páxinas modificadas desde estas horas ata agora.",
+ "apihelp-feedwatchlist-param-linktosections": "Ligar directamente ás seccións modificadas se é posible.",
+ "apihelp-feedwatchlist-example-default": "Mostar o fluxo da lista de vixiancia.",
+ "apihelp-feedwatchlist-example-all6hrs": "Amosar tódolos cambios feitos ás páxinas vixiadas nas últimas 6 horas.",
+ "apihelp-filerevert-summary": "Revertir o ficheiro a unha versión anterior.",
+ "apihelp-filerevert-param-filename": "Nome de ficheiro final, sen o prefixo Ficheiro:",
+ "apihelp-filerevert-param-comment": "Comentario de carga.",
+ "apihelp-filerevert-param-archivename": "Nome de ficheiro da revisión á que reverter.",
+ "apihelp-filerevert-example-revert": "Reverter <kbd>Wiki.png</kbd> á versión do <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Mostrar axuda para os módulos indicados.",
+ "apihelp-help-param-modules": "Módulos para mostar axuda (valores dos parámetros <var>acción</var> e <var>formato</var>, ou <kbd>principal</kbd>). Pode especificar submódulos con un <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Incluír axuda para os submódulos do módulo nomeado.",
+ "apihelp-help-param-recursivesubmodules": "Incluír axuda para os submódulos de forma recursiva.",
+ "apihelp-help-param-helpformat": "Formato de saída da axuda.",
+ "apihelp-help-param-wrap": "Incluír a saída nunha estrutura de resposta API estándar.",
+ "apihelp-help-param-toc": "Incluír unha táboa de contidos na saída por HTML",
+ "apihelp-help-example-main": "Axuda para o módulo principal",
+ "apihelp-help-example-submodules": "Axuda para <kbd>action=query</kbd> e todos os seus submódulos.",
+ "apihelp-help-example-recursive": "Toda a axuda nunha páxina",
+ "apihelp-help-example-help": "Axuda do módulo de axuda en si",
+ "apihelp-help-example-query": "Axuda para dous submódulos de consulta.",
+ "apihelp-imagerotate-summary": "Xirar unha ou máis imaxes.",
+ "apihelp-imagerotate-param-rotation": "Graos a rotar a imaxe no sentido do reloxio.",
+ "apihelp-imagerotate-param-tags": "Etiquetas aplicar á entrada no rexistro de subas.",
+ "apihelp-imagerotate-example-simple": "Rotar <kbd>File:Example.png</kbd> <kbd>90</kbd> graos.",
+ "apihelp-imagerotate-example-generator": "Rotar tódalas imaxes en <kbd>Category:Flip</kbd> <kbd>180</kbd> graos",
+ "apihelp-import-summary": "Importar unha páxina doutra wiki, ou dun ficheiro XML.",
+ "apihelp-import-extended-description": "Decátese de que o POST HTTP debe facerse como unha carga de ficheiro (p. ex. usando multipart/form-data) cando se envíe un ficheiro para o parámetro <var>xml</var>.",
+ "apihelp-import-param-summary": "Resume de importación de entrada no rexistro.",
+ "apihelp-import-param-xml": "Subido ficheiro XML.",
+ "apihelp-import-param-interwikisource": "Para importacións interwiki: wiki da que importar.",
+ "apihelp-import-param-interwikipage": "Para importacións interwiki: páxina a importar.",
+ "apihelp-import-param-fullhistory": "Para importacións interwiki: importar o historial completo, non só a versión actual.",
+ "apihelp-import-param-templates": "Para importacións interwiki: importar tódolos modelos incluídos.",
+ "apihelp-import-param-namespace": "Importar a este espazo de nomes. Non se pode usar de forma conxunta con <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importar como subpáxina desta páxina. Non se pode usar de forma conxunta con <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Cambiar as etiquetas a aplicar á entrada no rexistro de importacións e á revisión nula das páxinas importadas.",
+ "apihelp-import-example-import": "Importar [[meta:Help:ParserFunctions]] ó espazo de nomes 100 con todo o historial.",
+ "apihelp-linkaccount-summary": "Vincular unha conta dun provedor externo ó usuario actual.",
+ "apihelp-linkaccount-example-link": "Comezar o proceso de vincular a unha conta de <kbd>Exemplo</kbd>.",
+ "apihelp-login-summary": "Iniciar sesión e obter as cookies de autenticación.",
+ "apihelp-login-extended-description": "Esta acción só debe utilizarse en combinación con [[Special:BotPasswords]]; para a cuenta de inicio de sesión non se utiliza e pode fallar sen previo aviso. Para iniciar a sesión de forma segura na conta principal, utilice <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Esta acción está obsoleta e pode fallar sen avisar. Para conectarse sen problema use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "Nome de usuario.",
+ "apihelp-login-param-password": "Contrasinal",
+ "apihelp-login-param-domain": "Dominio (opcional).",
+ "apihelp-login-param-token": "Identificador de conexión obtido na primeira petición.",
+ "apihelp-login-example-gettoken": "Recuperar un identificador de conexión.",
+ "apihelp-login-example-login": "Identificarse",
+ "apihelp-logout-summary": "Terminar e limpar datos de sesión.",
+ "apihelp-logout-example-logout": "Cerrar a sesión do usuario actual",
+ "apihelp-managetags-summary": "Realizar tarefas de xestión relacionadas coa modificación de etiquetas.",
+ "apihelp-managetags-param-operation": "Que operación realizar:\n;create:Crear unha nova etiqueta de modificación para uso manual.\n;delete:Borar unha etiqueta de modificación da base de datos, incluíndo o borrado da etiqueta de todas as revisións, entradas de cambios recentes e entradas de rexistro onde estea a usarse.\n;activate:Activar unha etiqueta de modificación, permitindo que os usuarios a usen manualmente.\n;deactivate:Desactivar unha etiqueta de modificación, impedindo que os usuarios a usen manualmente.",
+ "apihelp-managetags-param-tag": "Etiqueta para crear, borrar, activar ou desactivar. Para a creación da etiqueta, a etiqueta non pode existir previamente. Para o borrado da etiqueta, a etiqueta debe existir. Para a activación da etiqueta, a etiqueta debe existir e non pode ser usada por unha extensión. Para desactivar unha etiqueta, a etiqueta debe estar activa e definida manualmente.",
+ "apihelp-managetags-param-reason": "Un motivo opcional para crear, borrar, activar ou desactivar a etiqueta.",
+ "apihelp-managetags-param-ignorewarnings": "Ignorar calquera aviso que apareza durante a operación.",
+ "apihelp-managetags-param-tags": "Cambiar as etiquetas a aplicar á entrada no rexistro de xestión das etiquetas.",
+ "apihelp-managetags-example-create": "Crear unha etiqueta chamada <kbd>spam</kbd> coa razón <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Borrar a etiqueta <kbd>vandalismo</kbd> coa razón <kbd>Erros ortográficos</kbd>",
+ "apihelp-managetags-example-activate": "Activar a etiqueta chamada <kbd>spam</kbd> coa razón <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Desactivar a etiqueta chamada <kbd>spam</kbd> coa razón <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Fusionar os historiais das páxinas.",
+ "apihelp-mergehistory-param-from": "Título da páxina desde a que se fusionará o historial. Non pode usarse xunto con <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "Identificador da páxina desde a que se fusionará o historial. Non pode usarse xunto con <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Título da páxina á que se fusionará o historial. Non pode usarse xunto con <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "Identificador da páxina á que se fusionará o historial. Non pode usarse xunto con <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "Selo de tempo dende o que se moverán as modificacións desde o historial da páxina fonte ó historial da páxina destino. Se non se indica, todo o historial da páxina fonte fusionarase co da páxina destino.",
+ "apihelp-mergehistory-param-reason": "Razón para a fusión de historiais.",
+ "apihelp-mergehistory-example-merge": "Fusionar o historial enteiro de <kbd>PáxinaVella</kbd> en <kbd>PáxinaNova</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Fusionar as revisións da páxina <kbd>PáxinaVella</kbd> con data <kbd>2015-12-31T04:37:41Z</kbd> en <kbd>PáxinaNova</kbd>.",
+ "apihelp-move-summary": "Mover unha páxina.",
+ "apihelp-move-param-from": "Título da páxina que quere renomear. Non pode usarse xunto con <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "Identificador da páxina que quere renomear. Non pode usarse xunto con <var>$1from</var>.",
+ "apihelp-move-param-to": "Título ó que renomear a páxina.",
+ "apihelp-move-param-reason": "Motivo para o renomeamento.",
+ "apihelp-move-param-movetalk": "Renomear a páxina de conversa, se existe.",
+ "apihelp-move-param-movesubpages": "Renomear as subpáxinas, se é aplicable.",
+ "apihelp-move-param-noredirect": "Non crear unha redirección.",
+ "apihelp-move-param-watch": "Engadir a páxina e a redirección á páxina de vixiancia do usuario actual.",
+ "apihelp-move-param-unwatch": "Eliminar a páxina e a redirección da páxina de vixiancia do usuario actual.",
+ "apihelp-move-param-watchlist": "Engadir ou eliminar sen condicións a páxina da lista de vixiancia do usuario actual, use as preferencias ou non cambie a vixiancia.",
+ "apihelp-move-param-ignorewarnings": "Ignorar as advertencias.",
+ "apihelp-move-param-tags": "Cambiar as etiquetas a aplicar á entrada do rexistro de traslados e na revisión nula da páxina de destino.",
+ "apihelp-move-example-move": "Mover <kbd>Badtitle</kbd> a <kbd>Goodtitle</kbd> sen deixar unha redirección.",
+ "apihelp-opensearch-summary": "Buscar no wiki mediante o protocolo OpenSearch.",
+ "apihelp-opensearch-param-search": "Buscar texto.",
+ "apihelp-opensearch-param-limit": "Número máximo de resultados a visualizar.",
+ "apihelp-opensearch-param-namespace": "Espazo de nomes no que buscar.",
+ "apihelp-opensearch-param-suggest": "Non facer nada se <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> é falso.",
+ "apihelp-opensearch-param-redirects": "Como xestionar as redireccións:\n;return:Devolve a mesma redirección.\n;resolve:Devolve a páxina á que apunta. Pode devolver menos de $1limit resultados.\nPor razóns históricas, o valor por defecto para $1format=json é \"return\" e \"resolve\" para outros formatos.",
+ "apihelp-opensearch-param-format": "O formato de saída.",
+ "apihelp-opensearch-param-warningsaserror": "Se os avisos son recibidos con <kbd>format=json</kbd>, devolver un erro de API no canto de ignoralos.",
+ "apihelp-opensearch-example-te": "Atopar páxinas que comezan por <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Cambiar as preferencias do usuario actual.",
+ "apihelp-options-extended-description": "Só se poden cambiar opcións que estean rexistradas no núcleo ou nunha das extensións instaladas, ou aquelas opcións con claves prefixadas con <code>userjs-</code> (previstas para ser usadas por escrituras de usuario).",
+ "apihelp-options-param-reset": "Reinicia as preferencias ás iniciais do sitio.",
+ "apihelp-options-param-resetkinds": "Lista de tipos de opcións a reinicializar cando a opción <var>$1reset</var> está definida.",
+ "apihelp-options-param-change": "Lista de cambios, con formato nome=valor (p. ex. skin=vector). Se non se da un valor (sen un símbolo de igual), p.ex. optionname|otheroption|..., a opción pasará ó valor por defecto. Para calquera valor que conteña o carácter (<kbd>|</kbd>), use o [[Special:ApiHelp/main#main/datatypes|separador alternativo para valores múltiples]] para unha operación correcta.",
+ "apihelp-options-param-optionname": "O nome da opción que debe fixarse no valor dado por <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "O valor para a opción especificada por <var>$1optionname</var>, pode conter barras verticais.",
+ "apihelp-options-example-reset": "Restablecer todas as preferencias.",
+ "apihelp-options-example-change": "Cambiar as preferencias <kbd>skin</kbd> and <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Restaurar todas as preferencias, logo fixar <kbd>skin</kbd> e <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Obter información sobre módulos API.",
+ "apihelp-paraminfo-param-modules": "Lista de nomes de módulos (valores dos parámetros <var>acción</var e <var>formato</var>, ou <kbd>principal</kbd>). Pode especificar submódulos con <kbd>+</kbd>, ou tódolos submódulos con <kbd>+*</kbd>, ou tódolos submódulos recursivamente con <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Formato das cadeas de axuda.",
+ "apihelp-paraminfo-param-querymodules": "Lista dos nomes de módulos de consulta (valores dos parámetros <var>prop</var>, <var>meta</var> ou <var>list</var>). Use <kbd>$1modules=query+foo</kbd> no canto de <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Obter información sobre o módulo principal (nivel superior). No canto use <kbd>$1modules=main</kbd>.",
+ "apihelp-paraminfo-param-pagesetmodule": "Obter información sobre o módulo pageset (proporcionando títulos= e amigos).",
+ "apihelp-paraminfo-param-formatmodules": "Lista dos nomes de módulo de formato (valores do parámetro <var>formato</var>). No canto use <var>$1modules</var>.",
+ "apihelp-paraminfo-example-1": "Amosar información para <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>, e <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "Mostrar a información para tódolos submódulos de <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-summary": "Fai a análise sintáctica do contido e devolve o resultado da análise.",
+ "apihelp-parse-extended-description": "Vexa varios módulos propostos de <kbd>[[Special:ApiHelp/query|action=query]]</kbd> para obter información sobre a versión actual dunha páxina.\n\nHai varias formas de especificar o texto a analizar:\n# Especificar unha páxina ou revisión, usando <var>$1page</var>, <var>$1pageid</var>, ou <var>$1oldid</var>.\n# Especificando contido explícitamente, usando <var>$1text</var>, <var>$1title</var>, and <var>$1contentmodel</var>.\n# Especificando só un resumo a analizar. <var>$1prop</var> debe ter un valor baleiro.",
+ "apihelp-parse-param-title": "Título da páxina á que pertence o texto. Se non se indica, debe especificarse <var>$1contentmodel</var>, e [[API]] usarase como o título.",
+ "apihelp-parse-param-text": "Texto a analizar. Use <var>$1title</var> ou <var>$1contentmodel</var> para controlar o modelo de contido.",
+ "apihelp-parse-param-summary": "Resumo a analizar.",
+ "apihelp-parse-param-page": "Analizar o contido desta páxina. Non pode usarse de forma conxunta con <var>$1text</var> e <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Analizar o contido desta páxina. Ignora <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "Se <var>$1page</var> ou <var>$1pageid</var> apuntar a unha redirección, resólvea.",
+ "apihelp-parse-param-oldid": "Analizar o contido desta revisión. Ignora <var>$1page</var> e <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "Que información obter:",
+ "apihelp-parse-paramvalue-prop-text": "Devolve o texto analizado do texto wiki.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Devolve as interwikis do texto analizado.",
+ "apihelp-parse-paramvalue-prop-categories": "Devolve as categoría do texto analizado.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Devolve a versión HTML das categorías.",
+ "apihelp-parse-paramvalue-prop-links": "Devolve as ligazóns internas do texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-templates": "Devolve os modelos do texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-images": "Devolve as imaxes do texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Devolve as ligazóns externas no texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-sections": "Devolve as seccións do texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-revid": "Engade o identificador de edición do texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Engade o título do texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-headitems": "Devolve os elementos a poñer na <code>&lt;cabeceira&gt;</code> da páxina.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Devolve <code>&lt;cabeceira&gt;</code> analizada da páxina.",
+ "apihelp-parse-paramvalue-prop-modules": "Devolve os módulos ResourceLoader usados na páxina. Para cargar, use <code>mw.loader.using()</code>. <kbd>jsconfigvars</kbd> ou <kbd>encodedjsconfigvars</kbd> deben ser solicitados xunto con <kbd>modules</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Devolve as variables específicas de configuración JavaScript da páxina. Para aplicalo, use <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Devolve as variables específicas de configuración JavaScript da páxina como unha cadea de texto JSON.",
+ "apihelp-parse-paramvalue-prop-indicators": "Devolve o HTML dos indicadores de estado de páxina usados na páxina.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Devolve as ligazóns interwiki do texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Devolve o texto wiki orixinal que foi analizado.",
+ "apihelp-parse-paramvalue-prop-properties": "Obter varias propiedades definidas no texto wiki analizado.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Devolve o informe de límite de forma estruturada. Non devolve datos cando <var>$1disablelimitreport</var> está fixado.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Devolve a versión HTML do informe de límite. Non devolve datos cando <var>$1disablelimitreport</var> está fixado.",
+ "apihelp-parse-paramvalue-prop-parsetree": "Árbores de análise XML do contido da revisión (precisa o modelo de contido <code>$1</code>)",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Devolve os avisos que ocorreron ó analizar o contido.",
+ "apihelp-parse-param-wrapoutputclass": "Clase CSS a usar para formatar a saída do analizador sintáctico.",
+ "apihelp-parse-param-pst": "Fai unha transformación antes de gardar a entrada antes de analizala. Válida unicamente para usar con texto.",
+ "apihelp-parse-param-onlypst": "Facer unha transformación antes de gardar (PST) a entrada, pero sen analizala. Devolve o mesmo wikitexto, despois de que a PST foi aplicada. Só válida cando se usa con <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "Inclúe ligazóns de idioma proporcionadas polas extensións (para usar con <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "Analizar unicamente o contido deste número de sección.\n\nCando <kbd>nova</kbd>, analiza <var>$1text</var> e <var>$1sectiontitle</var> como se fose a engadir unha nova sección da páxina.\n\n<kbd>novo</kbd> só se permite cando especifica <var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "Novo título de sección cando <var>section</var> é <kbd>new</kbd>.\n\nA diferenza da edición de páxinas, non se oculta no <var>summary</var> cando se omite ou está baleiro.",
+ "apihelp-parse-param-disablelimitreport": "Omitir o informe de límite (\"Informe de límite NewPP\") da saída do analizador.",
+ "apihelp-parse-param-disablepp": "Use <var>$1disablelimitreport</var> no seu lugar.",
+ "apihelp-parse-param-disableeditsection": "Omitir as ligazóns de edición de sección da saída do analizador.",
+ "apihelp-parse-param-disabletidy": "Non executar limpeza de HTML no retorno da análise.",
+ "apihelp-parse-param-generatexml": "Xenerar unha árbore de análise XML (necesita o modelo de contido <code>$1</code>; substituído por <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "Analizar en modo vista previa.",
+ "apihelp-parse-param-sectionpreview": "Analizar en modo vista previa de sección (activa tamén o modo de vista previa).",
+ "apihelp-parse-param-disabletoc": "Omitir o índice na saída.",
+ "apihelp-parse-param-useskin": "Aplicar o tema seleccionado á saída do analizador. Pode afectar ás seguintes propiedades: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>módulos</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicadores</kbd>.",
+ "apihelp-parse-param-contentformat": "Formato de serialización do contido usado para o texto de entrada. Só válido cando se usa con $1text.",
+ "apihelp-parse-param-contentmodel": "Modelo de contido do texto de entrada. Se se omite, debe especificarse $1title, e o valor por defecto será o modelo do título especificado. Só válido cando se usa con $1text.",
+ "apihelp-parse-example-page": "Analizar unha páxina.",
+ "apihelp-parse-example-text": "Analizar un wikitexto.",
+ "apihelp-parse-example-texttitle": "Analizar wikitexto, especificando o título da páxina.",
+ "apihelp-parse-example-summary": "Analizar un resumo.",
+ "apihelp-patrol-summary": "Patrullar unha páxina ou edición.",
+ "apihelp-patrol-param-rcid": "ID de modificación recente a vixiar.",
+ "apihelp-patrol-param-revid": "ID de revisión a vixiar.",
+ "apihelp-patrol-param-tags": "Cambiar as etiquetas a aplicar na entrada do rexistro de patrullas.",
+ "apihelp-patrol-example-rcid": "Patrullar un cambio recente",
+ "apihelp-patrol-example-revid": "Patrullar unha revisión",
+ "apihelp-protect-summary": "Cambiar o nivel de protección dunha páxina.",
+ "apihelp-protect-param-title": "Título da páxina que quere (des)protexer. Non pode usarse xunto con $1pageid.",
+ "apihelp-protect-param-pageid": "Identificador da páxina que quere (des)protexer. Non pode usarse xunto con $1title.",
+ "apihelp-protect-param-protections": "Lista dos niveis de protección, con formato <kbd>action=level</kbd> (p.ex. <kbd>edit=sysop</kbd>). Un nivel de <kbd>all</kbd> quere dicir que todo o mundo ten permiso para realizar a acción, sen restricións.\n\n<strong>Nota:</strong> Todas as accións que non estean listadas terán restriccións para ser eliminadas.",
+ "apihelp-protect-param-expiry": "Selos de tempo de caducidade. Se só se indica un selo de tempo, usarase para todas as proteccións. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, ou <kbd>never</kbd>, para unha protección sen caducidade.",
+ "apihelp-protect-param-reason": "Razón para (des)protexer.",
+ "apihelp-protect-param-tags": "Cambiar as etiquetas a aplicar na entrada do rexistro de protección.",
+ "apihelp-protect-param-cascade": "Activar a protección en cascada (por exemplo, protexer os modelos transcluídos e as imaxes usadas nesta páxina). Ignórase se ningún dos niveis de protección soporta a protección en cascada.",
+ "apihelp-protect-param-watch": "Se se define este parámetro, engadir a páxina que se (des)protexe á lista de vixilancia do usuario actual.",
+ "apihelp-protect-param-watchlist": "Engadir ou eliminar sen condicións a páxina da lista de vixiancia do usuario actual, use as preferencias ou non cambie a vixiancia.",
+ "apihelp-protect-example-protect": "Protexer unha páxina",
+ "apihelp-protect-example-unprotect": "Desprotexer unha páxina poñendo as restricións a <kbd>all</kbd>. (isto quere dicir que todo o mundo pode realizar a acción).",
+ "apihelp-protect-example-unprotect2": "Desprotexer unha páxina quitando as restricións.",
+ "apihelp-purge-summary": "Borrar a caché para os títulos indicados.",
+ "apihelp-purge-param-forcelinkupdate": "Actualizar as táboas de ligazóns.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Actualizar a táboa de ligazóns, e actualizar as táboas de ligazóns para calquera páxina que use esta páxina como modelo.",
+ "apihelp-purge-example-simple": "Purgar a <kbd>Main Page</kbd> e páxina da <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "Purgar as primeiras 10 páxinas no espazo de nomes principal.",
+ "apihelp-query-summary": "Consultar datos de e sobre MediaWiki.",
+ "apihelp-query-extended-description": "Todas as modificacións de datos primeiro teñen que facer unha busca para obter un identificador para evitar abusos de sitios maliciosos.",
+ "apihelp-query-param-prop": "Que propiedades obter para as páxinas buscadas.",
+ "apihelp-query-param-list": "Que lista obter.",
+ "apihelp-query-param-meta": "Que metadatos obter.",
+ "apihelp-query-param-indexpageids": "Incluir una sección adicional de identificadores de páxina listando todos os IDs das páxinas devoltas.",
+ "apihelp-query-param-export": "Exportar as revisións actuais de todas as páxinas dadas ou xeneradas.",
+ "apihelp-query-param-exportnowrap": "Devolver o XML exportado sen incluílo nun resultado XML (mesmo formato que [[Special:Export]]). Só pode usarse con $1export.",
+ "apihelp-query-param-iwurl": "Se fai falta obter a URL completa se o título é unha ligazón interwiki.",
+ "apihelp-query-param-rawcontinue": "Devolver os datos en bruto de <samp>query-continue</samp> para continuar.",
+ "apihelp-query-example-revisions": "Consultar [[Special:ApiHelp/query+siteinfo|información do sitio]] e [[Special:ApiHelp/query+revisions|as revisións]] da <kbd>Páxina Principal</kbd>.",
+ "apihelp-query-example-allpages": "Buscar revisións de páxinas que comecen por <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "Numerar tódalas categorías",
+ "apihelp-query+allcategories-param-from": "Categoría pola que comezar a enumeración.",
+ "apihelp-query+allcategories-param-to": "Categoría pola que rematar a enumeración.",
+ "apihelp-query+allcategories-param-prefix": "Buscar todos os títulos de categoría que comezan con este valor.",
+ "apihelp-query+allcategories-param-dir": "Dirección na que ordenar.",
+ "apihelp-query+allcategories-param-min": "Devolver só categorías con polo menos este número de membros.",
+ "apihelp-query+allcategories-param-max": "Devolver só categorías con como moito este número de membros.",
+ "apihelp-query+allcategories-param-limit": "Cantas categorías devolver.",
+ "apihelp-query+allcategories-param-prop": "Que propiedades recuperar:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Engade o número de páxinas na categoría.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Marca as categorías que están ocultas con <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Listar categorías con información do número de páxinas en cada unha.",
+ "apihelp-query+allcategories-example-generator": "Obter información sobre a páxina de categoría para categorías que comezan por <kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "Listar todas as revisións borradas por un usuario ou nun espazo de nomes.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Só pode usarse con <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Non pode usarse con <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "Selo de tempo para comezar a enumeración.",
+ "apihelp-query+alldeletedrevisions-param-end": "Selo de tempo para rematar a enumeración.",
+ "apihelp-query+alldeletedrevisions-param-from": "Comezar listado neste título.",
+ "apihelp-query+alldeletedrevisions-param-to": "Parar listado neste título.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Buscar tódolos títulos de páxinas que comezan con este valor.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Só listar revisións marcadas con esta etiqueta.",
+ "apihelp-query+alldeletedrevisions-param-user": "Só listar revisións deste usuario.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Non listar revisións deste usuario.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Só listar páxinas neste espazo de nomes.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>Nota:</strong> Debido ó [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo minimal]], ó usar á vez <var>$1user</var> e <var>$1namespace</var> pode devolver menos resultados de <var>$1limit</var> antes de continuar, en casos extremos, pode que non devolva resultados.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Usado como xenerador, xenera títulos no canto de IDs de revisión.",
+ "apihelp-query+alldeletedrevisions-example-user": "Listar as últimas 50 contribucións borradas do usuario <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Listar as 50 primeiras revisións borradas no espazo de nomes principal.",
+ "apihelp-query+allfileusages-summary": "Lista todos os usos de ficheiro, incluído os que non existen.",
+ "apihelp-query+allfileusages-param-from": "Título do ficheiro no que comezar a enumerar.",
+ "apihelp-query+allfileusages-param-to": "Título do ficheiro no que rematar de enumerar.",
+ "apihelp-query+allfileusages-param-prefix": "Buscar tódolos títulos de ficheiro que comezan con este valor.",
+ "apihelp-query+allfileusages-param-unique": "Mostrar só nomes de ficheiro distintos. Non pode usarse con $1prop=ids.\nCando se usa como xenerador, produce páxinas obxectivo no canto de páxinas fonte.",
+ "apihelp-query+allfileusages-param-prop": "Que partes de información incluír:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Engade os IDs das páxinas usadas (non pode usarse con $1unique).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Engade o nome do ficheiro.",
+ "apihelp-query+allfileusages-param-limit": "Número total de obxectos a devolver.",
+ "apihelp-query+allfileusages-param-dir": "Dirección na cal listar.",
+ "apihelp-query+allfileusages-example-B": "Lista títulos de ficheiro, incluíndo os eliminados, cos IDs de páxina dos que proveñen, comezando en <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Listar títulos únicos de ficheiros.",
+ "apihelp-query+allfileusages-example-unique-generator": "Obter todos os títulos de ficheiro, marcando os que faltan.",
+ "apihelp-query+allfileusages-example-generator": "Obtén as páxinas que conteñen os ficheiros.",
+ "apihelp-query+allimages-summary": "Enumerar tódalas imaxes secuencialmente.",
+ "apihelp-query+allimages-param-sort": "Propiedade pola que ordenar.",
+ "apihelp-query+allimages-param-dir": "Dirección na cal listar.",
+ "apihelp-query+allimages-param-from": "Título da imaxe no que comezar a enumerar. Só pode usarse con $1sort=name.",
+ "apihelp-query+allimages-param-to": "Título da imaxe no que rematar a enumerar. Só pode usarse con $1sort=name.",
+ "apihelp-query+allimages-param-start": "Título do selo de tempo no que comezar a enumerar. Só pode usarse con $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "Título do selo de tempo no que rematar a enumerar. Só pode usarse con $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "Buscar todas as imaxes cuxo título comeza por este valor. Só pode usarse con $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "Limitar a imaxes con polo menos este número de bytes.",
+ "apihelp-query+allimages-param-maxsize": "Limitar a imaxes con como máximo este número de bytes.",
+ "apihelp-query+allimages-param-sha1": "Función hash SHA1 da imaxe. Invalida $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "Función hash SHA1 da imaxe en base 36 (usada en MediaWiki).",
+ "apihelp-query+allimages-param-user": "Mostrar só ficheiros subidos por este usuario. Só pode usarse con $1sort=timestamp. Non se pode usar xunto a $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "Como filtrar ficheiros subidos por bots. Só pode usarse con $1sort=timestamp. Non pode usarse xunto con $1user.",
+ "apihelp-query+allimages-param-mime": "Que tipos MIME buscar, por exemplo <kbd>imaxe/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "Cantas imaxes mostar en total.",
+ "apihelp-query+allimages-example-B": "Mostrar unha lista de ficheiros que comezan por <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Mostrar unha lista de ficheiros subidos recentemente, similares a [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Mostrar unha lista de ficheiros con tipo MIME <kbd>image/png</kbd> ou <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Mostar información sobre catro ficheiros que comecen pola letra <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Numerar tódalas ligazóns que apuntan a un nome de espazos determinado.",
+ "apihelp-query+alllinks-param-from": "Título da ligazón na que comezar a enumerar.",
+ "apihelp-query+alllinks-param-to": "Título da ligazón na que rematar de enumerar.",
+ "apihelp-query+alllinks-param-prefix": "Buscar tódolos títulos ligados que comezan con este valor.",
+ "apihelp-query+alllinks-param-unique": "Mostrar só títulos ligados distintos. Non pode usarse con <kbd>$1prop=ids</kbd>.\nCando se usa como xenerador, produce páxinas obxectivo no canto de páxinas fonte.",
+ "apihelp-query+alllinks-param-prop": "Que partes de información incluír:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Engade o ID da páxina da ligazón (non pode usarse con <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Engade o título da ligazón.",
+ "apihelp-query+alllinks-param-namespace": "Espazo de nomes a enumerar.",
+ "apihelp-query+alllinks-param-limit": "Número total de obxectos a devolver.",
+ "apihelp-query+alllinks-param-dir": "Dirección na cal listar.",
+ "apihelp-query+alllinks-example-B": "Lista os títulos ligados, incluíndo os eliminados, cos ID das páxinas das que proveñen, comezando en <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Listar títulos ligados únicos",
+ "apihelp-query+alllinks-example-unique-generator": "Obtén tódolos títulos ligados, marcando os eliminados.",
+ "apihelp-query+alllinks-example-generator": "Obtén as páxinas que conteñen as ligazóns.",
+ "apihelp-query+allmessages-summary": "Devolver mensaxes deste sitio.",
+ "apihelp-query+allmessages-param-messages": "Que mensaxes devolver. <kbd>*</kbd> (por defecto) significa todas as mensaxes",
+ "apihelp-query+allmessages-param-prop": "Que propiedades obter.",
+ "apihelp-query+allmessages-param-enableparser": "Marcar para activar o analizador, isto preprocesará o texto wiki da mensaxe (substituir palabras máxicas, xestionar modelo, etc.)",
+ "apihelp-query+allmessages-param-nocontent": "Se se marca, non inclúe o contido das mensaxes na saída.",
+ "apihelp-query+allmessages-param-includelocal": "Tamén inclúe mensaxes locais, p.ex. mensaxes que non existen no software pero existen como no espazo de nomes {{ns:MediaWiki}}. \nIsto lista todas as páxinas do espazo de nomes {{ns:MediaWiki}}, polo que tamén listará as que non son realmente mensaxes como [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Argumentos a substituír na mensaxe.",
+ "apihelp-query+allmessages-param-filter": "Retornar só mensaxes con nomes que conteñan esta cadea.",
+ "apihelp-query+allmessages-param-customised": "Devolver só mensaxes neste estado de personalización.",
+ "apihelp-query+allmessages-param-lang": "Retornar mensaxes nesta lingua.",
+ "apihelp-query+allmessages-param-from": "Retornar mensaxes que comezan nesta mensaxe.",
+ "apihelp-query+allmessages-param-to": "Retornar mensaxes que rematan nesta mensaxe.",
+ "apihelp-query+allmessages-param-title": "Nome de páxina a usar como contexto cando se analice a mensaxe (para a opción $1enableparser)",
+ "apihelp-query+allmessages-param-prefix": "Devolver mensaxes con este prefixo.",
+ "apihelp-query+allmessages-example-ipb": "Mostar mensaxes que comecen por <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Mostrar mensaxes <kbd>august</kbd> e <kbd>mainpage</kbd> en Alemán.",
+ "apihelp-query+allpages-summary": "Numerar tódalas páxinas secuencialmente nun espazo de nomes determinado.",
+ "apihelp-query+allpages-param-from": "Título da páxina na que comezar a enumerar.",
+ "apihelp-query+allpages-param-to": "Título da páxina na que rematar de enumerar.",
+ "apihelp-query+allpages-param-prefix": "Buscar tódolos títulos de páxinas que comezan con este valor.",
+ "apihelp-query+allpages-param-namespace": "Espazo de nomes a enumerar.",
+ "apihelp-query+allpages-param-filterredir": "Que páxinas listar.",
+ "apihelp-query+allpages-param-minsize": "Limitar a páxinas con polo menos este número de bytes.",
+ "apihelp-query+allpages-param-maxsize": "Limitar a páxinas con como máximo este número de bytes.",
+ "apihelp-query+allpages-param-prtype": "Limitar a só protección de páxinas.",
+ "apihelp-query+allpages-param-prlevel": "Filtrar proteccións baseándose no nivel de protección (debe empregarse có parámetro $1prtype= ).",
+ "apihelp-query+allpages-param-prfiltercascade": "Filtrar proteccións baseadas en cascada (ignoradas se $1prtype non ten valor).",
+ "apihelp-query+allpages-param-limit": "Número total de páxinas a devolver.",
+ "apihelp-query+allpages-param-dir": "Dirección na cal listar.",
+ "apihelp-query+allpages-param-filterlanglinks": "Filtro baseado en si unha páxina ten ligazóns de lingua. Decátese de que esto pode non considerar as ligazóns de lingua engadidas polas extensións.",
+ "apihelp-query+allpages-param-prexpiry": "Que finalización de protección pola que filtrar a páxina:\n;indefinida: Só obter páxinas coa finalización de protección indefinida.\n;definite: Só obter páxinas cunha finalización de protección definida.\n;all: Obter páxinas con calquera finalización de protección.",
+ "apihelp-query+allpages-example-B": "Mostrar unha lista de páxinas que comezan pola letra <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Mostrar inforfmación sobre 4 páxinas que comecen pola letra <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Motrar o contido das dúas primeiras páxinas que non sexan redirección que comecen por <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Lista tódalas redireccións a un espazo de nomes.",
+ "apihelp-query+allredirects-param-from": "Título da redirección na que comezar a enumerar.",
+ "apihelp-query+allredirects-param-to": "Título da redirección na que rematar de enumerar.",
+ "apihelp-query+allredirects-param-prefix": "Buscar todas as páxinas que comecen con este valor.",
+ "apihelp-query+allredirects-param-unique": "Só mostrar páxinas obxectivo distintas. Non pode usarse con $1prop=ids|fragment|interwiki.\nCando se usa como xenerador, produce páxinas obxectivo no canto de páxinas fonte.",
+ "apihelp-query+allredirects-param-prop": "Que información incluír:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Engade o ID da páxina da redirección (non pode usarse con <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Engade o título da redirección.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Engade o fragmento da redirección, se o hai (non pode usarse con <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Engade o prefixo interwiki da redirección, se o hai (non pode usarse con <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "Espazo de nomes a enumerar.",
+ "apihelp-query+allredirects-param-limit": "Número total de obxectos a devolver.",
+ "apihelp-query+allredirects-param-dir": "Dirección na cal listar.",
+ "apihelp-query+allredirects-example-B": "Lista as páxinas obxectivo, incluíndo as eliminadas, cos ID das páxinas das que proveñen, comezando en <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "Lista páxinas obxectivo únicas.",
+ "apihelp-query+allredirects-example-unique-generator": "Obtén tódalas páxinas obxectivo, marcando as eliminadas.",
+ "apihelp-query+allredirects-example-generator": "Obtén as páxinas que conteñen as redireccións.",
+ "apihelp-query+allrevisions-summary": "Listar todas as revisións.",
+ "apihelp-query+allrevisions-param-start": "Selo de tempo no que comezar a enumeración.",
+ "apihelp-query+allrevisions-param-end": "Selo de tempo para rematar a enumeración.",
+ "apihelp-query+allrevisions-param-user": "Só listar revisións deste usuario.",
+ "apihelp-query+allrevisions-param-excludeuser": "Non listar revisións deste usuario.",
+ "apihelp-query+allrevisions-param-namespace": "Só listar páxinas neste espazo de nomes.",
+ "apihelp-query+allrevisions-param-generatetitles": "Usado como xenerador, xenera títulos no canto de IDs de revisión.",
+ "apihelp-query+allrevisions-example-user": "Listar as últimas 50 contribucións do usuario <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "Listar as 50 primeiras revisións do espazo de nomes principal.",
+ "apihelp-query+mystashedfiles-summary": "Obter unha lista dos ficheiros da caché de carga do usuario actual.",
+ "apihelp-query+mystashedfiles-param-prop": "Que propiedades obter para os ficheiros.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Consultar o tamaño de ficheiro e as dimensións da imaxe.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Consultar o tipo MIME do ficheiro e tipo multimedia.",
+ "apihelp-query+mystashedfiles-param-limit": "Cantos ficheiros devolver.",
+ "apihelp-query+mystashedfiles-example-simple": "Obter a clave de ficheiro, tamaño de ficheiro, e tamaño en pixels dos ficheiros na caché de carga do usuario actual.",
+ "apihelp-query+alltransclusions-summary": "Listar todas as transclusións (páxinas integradas usando &#123;&#123;x&#125;&#125;), incluíndo as eliminadas.",
+ "apihelp-query+alltransclusions-param-from": "Título da transclusión na que comezar a enumerar.",
+ "apihelp-query+alltransclusions-param-to": "Título da transclusión na que rematar de enumerar.",
+ "apihelp-query+alltransclusions-param-prefix": "Buscar todos os títulos transcluídos que comezan con este valor.",
+ "apihelp-query+alltransclusions-param-unique": "Mostrar só títulos transcluídos distintos. Non pode usarse con <kbd>$1prop=ids</kbd>.\nCando se usa como xenerador, produce páxinas obxectivo no canto de páxinas fonte.",
+ "apihelp-query+alltransclusions-param-prop": "Que partes de información incluír:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Engade o ID da páxina da páxina transcluída (non pode usarse con $1unique).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Engade o título da transclusión.",
+ "apihelp-query+alltransclusions-param-namespace": "Nome de espazos a numerar.",
+ "apihelp-query+alltransclusions-param-limit": "Número total de obxectos a devolver.",
+ "apihelp-query+alltransclusions-param-dir": "Dirección na cal listar.",
+ "apihelp-query+alltransclusions-example-B": "Lista os títulos transcluídos, incluíndo os eliminados, cos ID das páxinas das que proveñen, comezando en <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Lista os títulos transcluídos únicos.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Obtén tódolos títulos transcluídos, marcando os eliminados.",
+ "apihelp-query+alltransclusions-example-generator": "Obtén as páxinas que conteñen as transclusións.",
+ "apihelp-query+allusers-summary": "Enumerar tódolos usuarios rexistrados.",
+ "apihelp-query+allusers-param-from": "Nome de usuario para comezar a enumeración",
+ "apihelp-query+allusers-param-to": "Nome de usuario para rematar a enumeración.",
+ "apihelp-query+allusers-param-prefix": "Buscar tódolos nomes de usuario que comezan con este valor.",
+ "apihelp-query+allusers-param-dir": "Dirección na que ordenar.",
+ "apihelp-query+allusers-param-group": "Só incluír os usuarios nos grupos dados.",
+ "apihelp-query+allusers-param-excludegroup": "Excluír usuarios nos grupos dados.",
+ "apihelp-query+allusers-param-rights": "Incluír só ós usuarios cos dereitos dados. Non se inclúen grupo implícitos nin autopromocionados como *, usuario ou autoconfirmado.",
+ "apihelp-query+allusers-param-prop": "Que información incluír:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Engade información sobre o bloque actual do usuario.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Lista de grupos nos que está o usuario. Isto usa máis recursos no servidor e pode devolver menos resultados que o límite.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Lista todos os grupos ós que usuario pertence de forma automática.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Lista os dereitos que ten o usuario.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Engade o número de edicións do usuario.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Engade o selo de tempo do momento no que se rexistrou o usuario, se está dispoñible (pode ser branco).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Engade os identificadores centrais e o estado de acoplamento do usuario.",
+ "apihelp-query+allusers-param-limit": "Número total de nomes de usuario a devolver.",
+ "apihelp-query+allusers-param-witheditsonly": "Só listar usuarios que teñan feito edicións.",
+ "apihelp-query+allusers-param-activeusers": "Só listar usuarios activos {{PLURAL:$1|no último día|nos $1 últimos días}}.",
+ "apihelp-query+allusers-param-attachedwiki": "Con <kbd>$1prop=centralids</kbd>, \ntamén indica se o usuario está acoplado á wiki identificada por este identificador.",
+ "apihelp-query+allusers-example-Y": "Listar usuarios que comecen por <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Recuperar información sobre o estado de autenticación actual.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Comprobar se o estado de autenticación actual do usuario é abondo para a operación especificada como sensible dende o punto de vista da seguridade.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Recuperar a información sobre as peticións de autenticación necesarias para a acción de autenticación especificada.",
+ "apihelp-query+authmanagerinfo-example-login": "Recuperar as peticións que poden ser usadas ó comezo dunha conexión.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Recuperar as peticións que poden ser usadas ó comezo dunha conexión, xunto cos campos de formulario integrados.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Probar se a autenticación é abondo para a acción <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "Atopar todas as páxinas que ligan coa páxina dada.",
+ "apihelp-query+backlinks-param-title": "Título a buscar. Non pode usarse xunto con <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "Identificador de páxina a buscar. Non pode usarse xunto con <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "Espazo de nomes a enumerar.",
+ "apihelp-query+backlinks-param-dir": "Dirección na cal listar.",
+ "apihelp-query+backlinks-param-filterredir": "Como filtrar as redireccións. Se o valor é <kbd>nonredirects</kbd> cando <var>$1redirect</var> está activa, só se aplica ó segundo nivel.",
+ "apihelp-query+backlinks-param-limit": "Cantas páxinas devolver. Se <var>$1redirect</var> está activa, aplícase o límite a cada nivel de forma separada (isto significa que poden devolverse ata 2 * <var>$1limit</var> resultados).",
+ "apihelp-query+backlinks-param-redirect": "Se a ligazón sobre unha páxina é unha redirección, atopa tamén todas as páxinas que ligan con esa redirección. O límite máximo divídese á metade.",
+ "apihelp-query+backlinks-example-simple": "Mostrar ligazóns á <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Obter a información das páxinas que ligan á <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Listar todos os usuarios e direccións IP bloqueados.",
+ "apihelp-query+blocks-param-start": "Selo de tempo para comezar a enumeración.",
+ "apihelp-query+blocks-param-end": "Selo de tempo para rematar a enumeración.",
+ "apihelp-query+blocks-param-ids": "Lista de IDs de bloque a listar (opcional).",
+ "apihelp-query+blocks-param-users": "Lista de usuarios a buscar (opcional).",
+ "apihelp-query+blocks-param-ip": "Obter todos os bloques aplicables a esta IPs ou a este rango CIDR, incluíndo bloques de rangos.\nNon pode usarse xunto con <var>$3users</var>. Os rangos CIDR maiores que IPv4/$1 ou IPv6/$2 non se aceptan.",
+ "apihelp-query+blocks-param-limit": "Número máximo de bloques a listar.",
+ "apihelp-query+blocks-param-prop": "Que propiedades obter:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Engade o identificador do bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Engade o nome de usario do usuario bloqueado.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Engade o identificador de usuario do usuario bloqueado.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Engade o nome de usuario do usuario que fixo o bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Engade o identificador do usuario que fixo o bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Engade o selo de tempo de cando se realizou o bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Engade o selo de tempo de cando remata o bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Engade a razón dada para o bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Engade o rango de direccións IP afectadas polo bloqueo.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Etiqueta o bloqueo con (autoblock, anononly, etc.).",
+ "apihelp-query+blocks-param-show": "Só mostrar elementos correspondentes a eses criterios.\nPor exemplo, para ver só bloques indefinidos en direccións IP, ponga <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "Listar bloques.",
+ "apihelp-query+blocks-example-users": "Lista de bloques de usuarios <kbd>Alice</kbd> e <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Listar todas as categorías ás que pertencen as páxinas.",
+ "apihelp-query+categories-param-prop": "Que propiedades adicionais obter para cada categoría:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Engade a clave de ordenación (cadea hexadecimal) e o prefixo da clave de ordenación (parte lexible) da categoría.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Engade o selo de tempo de cando se engadíu a categoría.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Pon unha marca nas categorías que están ocultas con <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "Tipo de categorías a amosar.",
+ "apihelp-query+categories-param-limit": "Cantas categorías devolver.",
+ "apihelp-query+categories-param-categories": "Listar só esas categorías. Útil para verificar se unha páxina concreta está nunha categoría determinada.",
+ "apihelp-query+categories-param-dir": "Dirección na cal listar.",
+ "apihelp-query+categories-example-simple": "Obter a lista de categorías ás que pertence a páxina <kbd>Albert Einstein</kbd>",
+ "apihelp-query+categories-example-generator": "Obter a información de todas as categorías usadas na páxina <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "Devolver información sobre as categorías dadas.",
+ "apihelp-query+categoryinfo-example-simple": "Obter información sobre <kbd>Category:Foo</kbd> e <kbd>Category:Bar</kbd>",
+ "apihelp-query+categorymembers-summary": "Listar tódalas páxinas nunha categoría determinada.",
+ "apihelp-query+categorymembers-param-title": "Que categoría enumerar (obrigatorio). Debe incluír o prefixo <kbd>{{ns:category}}:</kbd>. Non pode usarse xunto con <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "ID de páxina da categoría a enumerar. Non se pode usar xunto con <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "Que información incluír:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Engade o ID da páxina.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Engade o título e o ID do espazo de nomes da páxina.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Engade a clave de ordenación usada para ordenala na categoría (cadea hexadecimal).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Engade o prefixo da clave de ordenación usado para ordenala na categoría (parte lexible da clave de ordenación).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Engade o tipo no que foi categorizado a páxina (<samp>page</samp>, <samp>subcat</samp> ou <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Engade o selo de tempo no que foi incluída a páxina.",
+ "apihelp-query+categorymembers-param-namespace": "Só incluír páxinas nestes espazos de nomes. Decátese de que poden usarse <kbd>$1type=subcat</kbd> ou <kbd>$1type=file</kbd> no canto de <kbd>$1namespace=14</kbd> ou <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-type": "Que tipo de membros da categoría incluír. Ignorado cando está activo <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-limit": "Máximo número de páxinas a retornar.",
+ "apihelp-query+categorymembers-param-sort": "Propiedade pola que ordenar.",
+ "apihelp-query+categorymembers-param-dir": "En que dirección ordenar.",
+ "apihelp-query+categorymembers-param-start": "Selo de tempo para comezar o listado. Só pode usarse con <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "Selo de tempo co que rematar o listado. Só pode usarse con <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Clave de ordenación coa que comezar a lista, como se indique en <kbd>$1prop=sortkey</kbd>. Pode usarse só con <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Clave de ordenación na que rematar a lista, como se indique en <kbd>$1prop=sortkey</kbd>. Pode usarse só con <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "Prefixo da clave de ordenación co que comezar a lista. Pode usarse só con <kbd>$1sort=sortkey</kbd>. Sobrescríbese <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "Prefixo da clave de ordenación no que rematar a lista <strong>antes</strong> (e non <strong>en</strong>; se existe este valor entón non será incluído!). Pode usarse só con $1sort=sortkey. Sobrescríbese $1endhexsortkey.",
+ "apihelp-query+categorymembers-param-startsortkey": "Usar $1starthexsortkey no seu lugar.",
+ "apihelp-query+categorymembers-param-endsortkey": "Usar $1endhexsortkey no seu lugar.",
+ "apihelp-query+categorymembers-example-simple": "Obter as dez primeiras páxinas de <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Obter a información das primeiras dez páxinas de <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Obter a lista de contribuidores conectados e o número de contribuidores anónimos dunha páxina.",
+ "apihelp-query+contributors-param-group": "Incluír só ós usuarios dos grupos dados. Non se inclúen grupos implícitos nin autopromocionados como *, usuario ou autoconfirmado.",
+ "apihelp-query+contributors-param-excludegroup": "Excluír usuarios nos grupos dados. Non se inclúen grupos implícitos nin autopromocionados como *, usuario ou autoconfirmado.",
+ "apihelp-query+contributors-param-rights": "Incluír só ós usuarios cos dereitos dados. Non se inclúen os dereitos dados a grupos implícitos nin autopromocionados como *, usuario ou autoconfirmado.",
+ "apihelp-query+contributors-param-excluderights": "Excluír usuarios cos dereitos dados. Non se inclúen os dereitos dados a grupos implícitos nin autopromocionados como *, usuario ou autoconfirmado.",
+ "apihelp-query+contributors-param-limit": "Número total de contribuidores a devolver.",
+ "apihelp-query+contributors-example-simple": "Mostrar os contribuidores á páxina <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-summary": "Obter información sobre as revisións eliminadas.",
+ "apihelp-query+deletedrevisions-extended-description": "Pode usarse de varias formas:\n#Obter as revisións borradas dun conxunto de páxinas, indicando os títulos ou os IDs das páxinas. Ordenado por título e selo de tempo.\n#Obter datos sobre un conxunto de revisións borradas, indicando os seus IDs e os seus IDs de revisión. Ordenado por ID de revisión.",
+ "apihelp-query+deletedrevisions-param-start": "Selo de tempo no que comezar a enumeración. Ignorado cando se está procesando unha lista de IDs de revisións.",
+ "apihelp-query+deletedrevisions-param-end": "Selo de tempo no que rematar a enumeración. Ignorado cando se está procesando unha lista de IDs de revisións.",
+ "apihelp-query+deletedrevisions-param-tag": "Só listar revisións marcadas con esta etiqueta.",
+ "apihelp-query+deletedrevisions-param-user": "Só listar revisións deste usuario.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Non listar revisións deste usuario.",
+ "apihelp-query+deletedrevisions-example-titles": "Listar as revisións borradas das páxinas <kbd>Main Page</kbd> e <kbd>Talk:Main Page</kbd>, con contido.",
+ "apihelp-query+deletedrevisions-example-revids": "Listar a información para a revisión borrada <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "Listar as revisións eliminadas.",
+ "apihelp-query+deletedrevs-extended-description": "Opera según tres modos:\n#Lista as modificacións borradas dos títulos indicados, ordenados por selo de tempo.\n#Lista as contribucións borradas do usuario indicado, ordenadas por selo de tempo (sen indicar títulos).\n#Lista todas as modificacións borradas no espazo de nomes indicado, ordenadas por título e selo de tempo (sen indicar títulos, sen fixar $1user).\n\nCertos parámetros só se aplican a algúns modos e son ignorados noutros.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Modo|Modos}}: $2",
+ "apihelp-query+deletedrevs-param-start": "Selo de tempo no que comezar a enumeración.",
+ "apihelp-query+deletedrevs-param-end": "Selo de tempo para rematar a enumeración.",
+ "apihelp-query+deletedrevs-param-from": "Comezar listado neste título.",
+ "apihelp-query+deletedrevs-param-to": "Rematar listado neste título.",
+ "apihelp-query+deletedrevs-param-prefix": "Buscar tódolos títulos de páxina que comezan con este valor.",
+ "apihelp-query+deletedrevs-param-unique": "Só listar unha revisión por cada páxina.",
+ "apihelp-query+deletedrevs-param-tag": "Só listar revisións marcadas con esta etiqueta.",
+ "apihelp-query+deletedrevs-param-user": "Só listar revisións deste usuario.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Non listar revisións deste usuario.",
+ "apihelp-query+deletedrevs-param-namespace": "Só listar páxinas neste espazo de nomes.",
+ "apihelp-query+deletedrevs-param-limit": "Máximo número de revisións a listar.",
+ "apihelp-query+deletedrevs-param-prop": "Que propiedades devolver:\n;revid:Engade o identificador de modificación da modificación borrada.\n;parentid:Engade o identificador de modificación da versión anterior da páxina.\n;user:Engade o usuario que fixo esa modificación.\n;userid:Engade o identificador de usuario que fixo esa modificación.\n;comment:Engade o comentario da modificación.\n;parsedcomment:Engade o comentario analizado da modificación.\n;minor:Indica se a modificación é menor.\n;len:Engade a lonxitude (bytes) da modificación.\n;sha1:Engade o SHA-1 (base 16) da modificación.\n;content:Engade o contido da modificación.\n;token:<span class=\"apihelp-deprecated\">Obsoleto.</span> Devolve o identificador da modificación.\n;tags:Etiquetas da modificación.",
+ "apihelp-query+deletedrevs-example-mode1": "Listar as últimas revisións borradas das páxinas <kbd>Main Page</kbd> e <kbd>Talk:Main Page</kbd>, con contido (modo 1).",
+ "apihelp-query+deletedrevs-example-mode2": "Listar as últimas 50 contribucións borradas de <kbd>Bob</kbd> (modo 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Listar as primeiras 50 revisións borradas no espazo de nomes principal (modo 3)",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Listar as primeiras 50 páxinas no espazo de nomes {{ns:talk}} (modo 3).",
+ "apihelp-query+disabled-summary": "Este módulo de consulta foi desactivado.",
+ "apihelp-query+duplicatefiles-summary": "Listar todos os ficheiros que son duplicados dos fichieros dados baseado nos valores da función hash.",
+ "apihelp-query+duplicatefiles-param-limit": "Cantos ficheiros duplicados devolver.",
+ "apihelp-query+duplicatefiles-param-dir": "Dirección na cal listar.",
+ "apihelp-query+duplicatefiles-param-localonly": "Só buscar por ficheiros no repositorio local.",
+ "apihelp-query+duplicatefiles-example-simple": "Buscar duplicados de [[:File:Albert Einstein Head.jpg]]",
+ "apihelp-query+duplicatefiles-example-generated": "Buscar duplicados de tódolos ficheiros",
+ "apihelp-query+embeddedin-summary": "Atopar todas as páxinas que inclúen (por transclusión) o título dado.",
+ "apihelp-query+embeddedin-param-title": "Título a buscar. Non pode usarse xunto con $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "Identificador de páxina a buscar. Non pode usarse xunto con $1title.",
+ "apihelp-query+embeddedin-param-namespace": "Espazo de nomes a enumerar.",
+ "apihelp-query+embeddedin-param-dir": "Dirección na cal listar.",
+ "apihelp-query+embeddedin-param-filterredir": "Como filtrar para redireccións.",
+ "apihelp-query+embeddedin-param-limit": "Número total de páxinas a devolver.",
+ "apihelp-query+embeddedin-example-simple": "Mostrar as páxinas que inclúan <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "Obter información sobre as páxinas que inclúen <kbd>Template:Stub</kbd>.",
+ "apihelp-query+extlinks-summary": "Devolve todas as URLs externas (sen ser interwikis) das páxinas dadas.",
+ "apihelp-query+extlinks-param-limit": "Cantas ligazóns devolver.",
+ "apihelp-query+extlinks-param-protocol": "Protocolo da URL. Se está baleiro e está activo <var>$1query</var>, o protocolo é <kbd>http</kbd>. Deixar esa variable e a <var>$1query</var> baleiras para listar todas as ligazóns externas.",
+ "apihelp-query+extlinks-param-query": "Buscar cadea sen protocolo. Útil para verificar se unha páxina determinada contén unha URL externa determinada.",
+ "apihelp-query+extlinks-param-expandurl": "Expandir as URLs relativas a un protocolo co protocolo canónico.",
+ "apihelp-query+extlinks-example-simple": "Obter unha de ligazóns externas á <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-summary": "Enumerar páxinas que conteñen unha dirección URL dada.",
+ "apihelp-query+exturlusage-param-prop": "Que información incluír:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Engade o ID da páxina.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Engade o título e o ID do espazo de nomes da páxina.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Engade a URL usada na páxina.",
+ "apihelp-query+exturlusage-param-protocol": "Protocolo da URL. Se está baleiro e está activo <var>$1query</var>, o protocolo é <kbd>http</kbd>. Deixar esa variable e a <var>$1query</var> baleiras para listar todas as ligazóns externas.",
+ "apihelp-query+exturlusage-param-query": "Buscar unha cadea sen protocolo. Ver [[Special:LinkSearch]]. Deixar baleira para listar todas as ligazóns externas.",
+ "apihelp-query+exturlusage-param-namespace": "Espazo de nomes a enumerar.",
+ "apihelp-query+exturlusage-param-limit": "Cantas páxinas devolver.",
+ "apihelp-query+exturlusage-param-expandurl": "Expandir as URLs relativas a un protocolo co protocolo canónico.",
+ "apihelp-query+exturlusage-example-simple": "Mostrar páxinas ligando a <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-summary": "Enumerar secuencialmente todos os ficheiros borrados.",
+ "apihelp-query+filearchive-param-from": "Título da imaxe coa que comezar a enumeración.",
+ "apihelp-query+filearchive-param-to": "Título da imaxe coa que rematar a enumeración.",
+ "apihelp-query+filearchive-param-prefix": "Buscar tódolos títulos de imaxes que comezan con este valor.",
+ "apihelp-query+filearchive-param-limit": "Cantas imaxes devolver en total.",
+ "apihelp-query+filearchive-param-dir": "Dirección na cal listar.",
+ "apihelp-query+filearchive-param-sha1": "Función hash SHA1 da imaxe. Invalida $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "Función hash SHA1 da imaxe en base 36 (usado en MediaWiki).",
+ "apihelp-query+filearchive-param-prop": "Que información de imaxe devolver:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Engade a función hash SHA-1 da imaxe.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Engade o selo de tempo da versión subida.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Engade o usuario que subiu a versión da imaxe.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Engade o tamaño da imaxe en bytes e a altura, anchura e contador de páxina (se é aplicable).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Alias para o tamaño.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Engade a descrición da versión da imaxe.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Analiza a descrición na versión.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Engade o tipo MIME da imaxe.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Engade o tipo multimedia da imaxe.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Lista os metadatos Exif da versión da imaxe.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Engade a profundidade de bit da versión.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Engade o nome do ficheiro da versión do ficheiro para as versións que non son a última.",
+ "apihelp-query+filearchive-example-simple": "Mostrar unha lista de tódolos fichieiros eliminados.",
+ "apihelp-query+filerepoinfo-summary": "Devolver a meta información sobre os repositorios de imaxes configurados na wiki.",
+ "apihelp-query+filerepoinfo-param-prop": "Que propiedades do repositorio mostrar (pode haber máis dispoñible nalgunhas wikis):\n;apiurl:URL ó API do repositorio - útil para obter información das imaxes no host.\n;name:A clave do repositorio - usada p. ex. nas variables de retorno de <var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> e [[Special:ApiHelp/query+imageinfo|imageinfo]]\n;displayname:O nome lexible do wiki repositorio.\n;rooturl:URL raíz dos camiños de imaxe.\n;local:Se o repositorio é o repositorio local ou non.",
+ "apihelp-query+filerepoinfo-example-simple": "Obter infomación sobre os repositorios de ficheiros",
+ "apihelp-query+fileusage-summary": "Atopar tódalas páxinas que usan os ficheiros dados.",
+ "apihelp-query+fileusage-param-prop": "Que propiedades obter:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "ID de cada páxina.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Título de cada páxina.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Marca de se a páxina é unha redirección.",
+ "apihelp-query+fileusage-param-namespace": "Só incluír páxinas nestes espazos de nomes.",
+ "apihelp-query+fileusage-param-limit": "Cantos mostrar.",
+ "apihelp-query+fileusage-param-show": "Mostrar só elementos que cumpren estes criterios:\n;redirect:Só mostra redireccións.\n;!redirect:Só mostra as que non son redireccións.",
+ "apihelp-query+fileusage-example-simple": "Obter unha lista de páxinas usando [[:File:Example.jpg]]",
+ "apihelp-query+fileusage-example-generator": "Obter infomación sobre páxinas que usan [[:File:Example.jpg]]",
+ "apihelp-query+imageinfo-summary": "Devolve información de ficheiros e historial de subidas.",
+ "apihelp-query+imageinfo-param-prop": "Que información do ficheiro obter:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Engade selo de tempo á versión subida.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Engade o usuario que subiu cada versión do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Engade o ID de usuario que subiu cada versión do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Comentario da versión.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Analizar o comentario da versión.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Engade o título canónico do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Devolve a URL ó ficheiro e á páxina de descrición.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Engade o tamaño do ficheiro en bytes e a altura, a anchura e o contador de páxina (se é aplicable).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Alias para o tamaño.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Engade a función hash SHA-1 do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Engade o tipo MIME do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Engade o tipo MIME da miniatura da imaxe (precisa a url e o parámetro $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Engade o tipo do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Lista os metadatos Exif da versión do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Lista os metadatos xenéricos do formato do ficheiro para a versión do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Lista os metadatos combinados formateados de múltiples fontes. Os resultados están en formato HTML.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Engade o nome de ficheiro da versión do ficheiro para versións anteriores ás últimas.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Engade a profundidade de bits da versión.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Usado pola páxina Special:Upload para obter información sobre un ficheiro existente. Non previsto para usar fóra do núcleo MediaWiki.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Engadido cando o ficheiro está na [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-limit": "Cantas revisións de ficheiro a devolver por ficheiro.",
+ "apihelp-query+imageinfo-param-start": "Selo de tempo dende o que comezar a lista.",
+ "apihelp-query+imageinfo-param-end": "Selo de tempo no que rematar a lista.",
+ "apihelp-query+imageinfo-param-urlwidth": "Se $2prop=url está definido, será devolta unha URL a unha imaxe escalada a este ancho.\nPor razóns de rendimento se se usa esta opción, non se devolverán máis de $1 imaxes.",
+ "apihelp-query+imageinfo-param-urlheight": "Similar a $1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "Versión de metadata a usar. Se <kbd>latest</kbd> está especificado, usa a última versión. Por defecto <kbd>1</kbd> para compatibilidade con versións anteriores.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "Que lingua buscar en extmetadata. Isto afecta tanto á tradución a buscar, se hai varias dispoñibles, como a como se formatean cousas como os números e outros valores.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Se as traducións para a propiedade extmetadata están dispoñibles, búscaas todas.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Se está especificado e non baleiro, só se devolverán esas claves para $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "Unha cadea de parámetro específico no analizador. Por exemplo, os PDFs poden usar <kbd>page15-100px</kbd>. Debe usarse <var>$1urlwidth</var> que debe ser coherente con <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Se <kbd>$2prop=badfile</kbd> está definido, este é o título da páxina usado para avaliar a [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-localonly": "Só buscar ficheiros no repositorio local.",
+ "apihelp-query+imageinfo-example-simple": "Busca a información sobre a versión actual de [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "Busca información sobre as versións de [[:File:Test.jpg]] posteriores a 2008.",
+ "apihelp-query+images-summary": "Devolve todos os ficheiros contidos nas páxinas dadas.",
+ "apihelp-query+images-param-limit": "Cantos ficheiros devolver.",
+ "apihelp-query+images-param-images": "Listar só eses ficheiros. Útil para verificar se unha páxina concreta ten un ficheiro determinado.",
+ "apihelp-query+images-param-dir": "Dirección na cal listar.",
+ "apihelp-query+images-example-simple": "Obter unha lista de arquivos empregados na [[Main Page]].",
+ "apihelp-query+images-example-generator": "Obter información sobre todos os ficheiros usados na [[Main Page]].",
+ "apihelp-query+imageusage-summary": "Atopar tódalas páxinas que usan o título da imaxe dada.",
+ "apihelp-query+imageusage-param-title": "Título a buscar. Non pode usarse xunto con $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "ID de páxina a buscar. Non pode usarse xunto con $1title.",
+ "apihelp-query+imageusage-param-namespace": "Nome de espazos a numerar.",
+ "apihelp-query+imageusage-param-dir": "Dirección na cal listar.",
+ "apihelp-query+imageusage-param-filterredir": "Como filtrar redireccións. Se se fixa a non redirección cando está activo $1redirect, isto só se aplica ó segundo nivel.",
+ "apihelp-query+imageusage-param-limit": "Cantas páxinas devolver. Se <var>$1redirect</var> está activa, aplícase o límite a cada nivel de forma separada (isto significa que poden devolverse ata 2 * <var>$1limit</var> resultados).",
+ "apihelp-query+imageusage-param-redirect": "Se a ligazón sobre unha páxina é unha redirección, atopa tamén todas as páxinas que ligan con esa redirección. O límite máximo divídese á metade.",
+ "apihelp-query+imageusage-example-simple": "Mostrar as páxinas que usan [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Obter información sobre as páxinas que usan [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "Obter información básica da páxina.",
+ "apihelp-query+info-param-prop": "Que propiedades adicionais obter:",
+ "apihelp-query+info-paramvalue-prop-protection": "Listar o nivel de protección de cada páxina.",
+ "apihelp-query+info-paramvalue-prop-talkid": "O ID de páxina da páxina de conversa para cada páxina que non é páxina de conversa.",
+ "apihelp-query+info-paramvalue-prop-watched": "Listar o estado de vixiancia de cada páxina.",
+ "apihelp-query+info-paramvalue-prop-watchers": "O número de vixiantes, se está permitido.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "O nome dos usuarios que vixían cada páxina e que teñen visitado os cambios recentes a esta páxina, se está autorizado.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "O selo de tempo de notificación da lista de vixiancia de cada páxina.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "O ID de páxina da páxina pai para cada páxina de conversa.",
+ "apihelp-query+info-paramvalue-prop-url": "Devolve unha URL completa, unha URL de modificación, e a URL canónica de cada páxina.",
+ "apihelp-query+info-paramvalue-prop-readable": "Se o usuario pode ler esta páxina.",
+ "apihelp-query+info-paramvalue-prop-preload": "Devolve o texto devolto por EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Devolve a forma na que se visualiza actualmente o título da páxina.",
+ "apihelp-query+info-param-testactions": "Proba se o usuario actual pode realizar certas accións na páxina.",
+ "apihelp-query+info-param-token": "Usar [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] no canto diso.",
+ "apihelp-query+info-example-simple": "Obter información sobre a páxina <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Obter información xeral e de protección sobre a páxina <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-summary": "Atopar todas as páxina que ligan á ligazón interwiki indicada.",
+ "apihelp-query+iwbacklinks-extended-description": "Pode usarse para atopar todas as ligazóns cun prefixo, ou todas as ligazóns a un título (co prefixo indicado). Se non se usa ningún parámetro funciona como \"todas as ligazóns interwiki\".",
+ "apihelp-query+iwbacklinks-param-prefix": "Prefixo para a interwiki.",
+ "apihelp-query+iwbacklinks-param-title": "Ligazón interwiki a buscar. Debe usarse con <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "Número total de páxinas a devolver.",
+ "apihelp-query+iwbacklinks-param-prop": "Que propiedades obter:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Engade o prefixo da interwiki.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Engade o título da interwiki.",
+ "apihelp-query+iwbacklinks-param-dir": "Dirección na cal listar.",
+ "apihelp-query+iwbacklinks-example-simple": "Obter as páxinas ligadas a [[wikibooks:Test]]",
+ "apihelp-query+iwbacklinks-example-generator": "Obter información sobre as páxinas que ligan a [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-summary": "Devolve todas as ligazóns interwiki ás páxinas indicadas.",
+ "apihelp-query+iwlinks-param-url": "Se obter a URL completa (non pode usarse con $1prop).",
+ "apihelp-query+iwlinks-param-prop": "Que propiedades adicionais obter para cada ligazón interwiki:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Engade a URL completa.",
+ "apihelp-query+iwlinks-param-limit": "Cantas ligazóns interwiki devolver.",
+ "apihelp-query+iwlinks-param-prefix": "Só devolver ligazóns interwiki con este prefixo.",
+ "apihelp-query+iwlinks-param-title": "Ligazón interwiki a buscar. Debe usarse con <var>$1prefix</var>.",
+ "apihelp-query+iwlinks-param-dir": "Dirección na cal listar.",
+ "apihelp-query+iwlinks-example-simple": "Obter as ligazóns interwiki da páxina <kbd>Main Page</kbd>.",
+ "apihelp-query+langbacklinks-summary": "Atopar todas as páxinas que ligan coa ligazón de lingua dada.",
+ "apihelp-query+langbacklinks-extended-description": "Pode usarse para atopar todas as ligazóns cun código de lingua, ou todas as ligazón a un título (cunha lingua dada). Non usar cun parámetro que sexa \"todas as ligazóns de lingua\".\n\nDecátese que isto pode non considerar as ligazóns de idioma engadidas polas extensións.",
+ "apihelp-query+langbacklinks-param-lang": "Lingua para a ligazón de lingua.",
+ "apihelp-query+langbacklinks-param-title": "Ligazón de lingua a buscar. Debe usarse con $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "Número total de páxinas a devolver.",
+ "apihelp-query+langbacklinks-param-prop": "Que propiedades obter:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Engade o código de lingua á ligazón de páxina.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Engade o título da ligazón de lingua.",
+ "apihelp-query+langbacklinks-param-dir": "Dirección na cal listar.",
+ "apihelp-query+langbacklinks-example-simple": "Obter as páxinas ligadas a [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Obter información sobre as páxinas que ligan a [[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "Devolve todas as ligazóns interwiki ás páxinas indicadas.",
+ "apihelp-query+langlinks-param-limit": "Cantas ligazóns de lingua devolver.",
+ "apihelp-query+langlinks-param-url": "Se obter a URL completa (non pode usarse con <var>$1prop</var>).",
+ "apihelp-query+langlinks-param-prop": "Que propiedades adicionais obter para cada ligazón interlingüística:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Engade a URL completa.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "Engade o nome localizado da lingua (o mellor intento). Use <var>$1inlanguagecode</var> para controlar a lingua.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Engade o nome nativo da lingua.",
+ "apihelp-query+langlinks-param-lang": "Devolver só ligazóns de lingua con este código de lingua.",
+ "apihelp-query+langlinks-param-title": "Ligazón a buscar. Debe usarse con <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "Dirección na cal listar.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Código de lingua para nomes de lingua localizados.",
+ "apihelp-query+langlinks-example-simple": "Obter ligazóns interlingua da páxina <kbd>Main Page</kbd>.",
+ "apihelp-query+links-summary": "Devolve todas as ligazóns das páxinas indicadas.",
+ "apihelp-query+links-param-namespace": "Mostra ligazóns só neste espazo de nomes.",
+ "apihelp-query+links-param-limit": "Cantas ligazóns devolver.",
+ "apihelp-query+links-param-titles": "Listar só as ligazóns a eses títulos. Útil para verificar se unha páxina concreta liga a un título determinado.",
+ "apihelp-query+links-param-dir": "Dirección na cal listar.",
+ "apihelp-query+links-example-simple": "Obter as ligazóns da páxina <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-generator": "Obter información sobre as ligazóns de páxina da <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-namespaces": "Obter as ligazóns á páxina <kbd>Main Page</kbd> nos espazos de nome {{ns:user}} e {{ns:template}}.",
+ "apihelp-query+linkshere-summary": "Atopar todas as páxinas que ligan coas páxinas dadas.",
+ "apihelp-query+linkshere-param-prop": "Que propiedades obter:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "ID de cada páxina.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Título de cada páxina.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Marca de se a páxina é unha redirección.",
+ "apihelp-query+linkshere-param-namespace": "Só incluír páxinas nestes espazos de nomes.",
+ "apihelp-query+linkshere-param-limit": "Cantos mostrar.",
+ "apihelp-query+linkshere-param-show": "Mostrar só elementos que cumpren estes criterios:\n;redirect:Só mostra redireccións.\n;!redirect:Só mostra as que non son redireccións.",
+ "apihelp-query+linkshere-example-simple": "Obter unha lista que ligan á [[Main Page]]",
+ "apihelp-query+linkshere-example-generator": "Obter a información das páxinas que ligan á [[Main Page]].",
+ "apihelp-query+logevents-summary": "Obter os eventos dos rexistros.",
+ "apihelp-query+logevents-param-prop": "Que propiedades obter:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Engade o identificador do evento.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Engade o título da páxina para o evento.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Engade o tipo de evento.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Engade o usuario responsable do evento.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Engade o identificador do usuario responsable do evento.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "Engade o selo de tempo do evento.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Engade o comentario do evento.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "Engade o comentario analizado do evento.",
+ "apihelp-query+logevents-paramvalue-prop-details": "Lista detalles adicionais do evento.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Lista as etiquetas do evento.",
+ "apihelp-query+logevents-param-type": "Filtrar as entradas do rexistro para mostrar só as deste tipo.",
+ "apihelp-query+logevents-param-action": "Filtrar accións no rexistro para mostrar só esta acción. Ignora <var>$1type</var>. Na lista de posibles valores, valores coa máscara asterisco como <kbd>action/*</kbd> poden ter diferentes cadeas despois da barra (/).",
+ "apihelp-query+logevents-param-start": "Selo de tempo no que comezar a enumeración.",
+ "apihelp-query+logevents-param-end": "Selo de tempo para rematar a enumeración.",
+ "apihelp-query+logevents-param-user": "Filtrar entradas ás feitas polo usuario indicado.",
+ "apihelp-query+logevents-param-title": "Filtrar entradas ás asociadas á páxina indicada.",
+ "apihelp-query+logevents-param-namespace": "Filtrar entradas ás do espazo de nomes indicado.",
+ "apihelp-query+logevents-param-prefix": "Filtrar entradas ás que comezan por este prefixo.",
+ "apihelp-query+logevents-param-tag": "Só listar entradas de evento marcadas con esta etiqueta.",
+ "apihelp-query+logevents-param-limit": "Número total de entradas de evento a devolver.",
+ "apihelp-query+logevents-example-simple": "Lista de eventos recentes do rexistro.",
+ "apihelp-query+pagepropnames-summary": "Listar os nomes de todas as propiedades de páxina usados na wiki.",
+ "apihelp-query+pagepropnames-param-limit": "Máximo número de nomes a retornar.",
+ "apihelp-query+pagepropnames-example-simple": "Obter os dez primeiros nomes de propiedade.",
+ "apihelp-query+pageprops-summary": "Obter varias propiedades de páxina definidas no contido da páxina.",
+ "apihelp-query+pageprops-param-prop": "Listar só estas propiedades de páxina (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> devolve os nomes das propiedades de páxina usados). Útil para verificar se as páxinas usan unha determinada propiedade de páxina.",
+ "apihelp-query+pageprops-example-simple": "Obter as propiedades para as páxinas <kbd>Main Page</kbd> e <kbd>MediaWiki</kbd>",
+ "apihelp-query+pageswithprop-summary": "Mostrar a lista de páxinas que empregan unha propiedade determinada.",
+ "apihelp-query+pageswithprop-param-propname": "Propiedade de páxina para a que enumerar as páxinas (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> devolve os nomes das propiedades de páxina en uso).",
+ "apihelp-query+pageswithprop-param-prop": "Que información incluír:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Engade o ID da páxina.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Engade o título e o ID do espazo de nomes da páxina.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Engade o valor da propiedade de páxina.",
+ "apihelp-query+pageswithprop-param-limit": "Máximo número de páxinas a retornar.",
+ "apihelp-query+pageswithprop-param-dir": "En que dirección ordenar.",
+ "apihelp-query+pageswithprop-example-simple": "Lista as dez primeiras páxinas que usan <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "Obter información adicional das dez primeiras páxinas que usan <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-summary": "Facer unha busca de prefixo nos títulos das páxinas.",
+ "apihelp-query+prefixsearch-extended-description": "A pesar das semellanzas nos nomes, este módulo non pretende ser equivalente a [[Special:PrefixIndex]]; para iso consulte <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> co parámetro <kbd>apprefix</kbd>. O propósito deste módulo é semellante ó de <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>: para coller a entrada do usuario e proporcionar mellores os títulos que mellor se lle adapten. Dependendo do motor de buscas do servidor, isto pode incluír corrección de erros, evitar as redireccións, ou outras heurísticas.",
+ "apihelp-query+prefixsearch-param-search": "Buscar texto.",
+ "apihelp-query+prefixsearch-param-namespace": "Espazo de nomes no que buscar.",
+ "apihelp-query+prefixsearch-param-limit": "Número máximo de resultados a visualizar.",
+ "apihelp-query+prefixsearch-param-offset": "Número de resultados a saltar.",
+ "apihelp-query+prefixsearch-example-simple": "Buscar títulos de páxina que comecen con <kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "Buscar o perfil a usar.",
+ "apihelp-query+protectedtitles-summary": "Listar todos os títulos protexidos en creación.",
+ "apihelp-query+protectedtitles-param-namespace": "Só listar títulos nestes espazos de nomes.",
+ "apihelp-query+protectedtitles-param-level": "Só listar títulos con estos niveis de protección.",
+ "apihelp-query+protectedtitles-param-limit": "Número total de páxinas a devolver.",
+ "apihelp-query+protectedtitles-param-start": "Comezar a listar neste selo de tempo de protección.",
+ "apihelp-query+protectedtitles-param-end": "Rematar de listar neste selo de tempo de protección.",
+ "apihelp-query+protectedtitles-param-prop": "Que propiedades obter:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "Engade o selo de tempo de cando se fixo a protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Engade o usuario que fixo a protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Engade o ID do usuario que fixo a protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Engade o comentario da protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "Engade o comentario analizado da protección.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "Engade o selo de tempo no que rematará a protección",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Engade o nivel de protección.",
+ "apihelp-query+protectedtitles-example-simple": "Listar títulos protexidos",
+ "apihelp-query+protectedtitles-example-generator": "Atopar ligazóns ós títulos protexidos no espazo de nomes principal",
+ "apihelp-query+querypage-summary": "Obtén unha lista proporcionada por unha páxina especial basada en QueryPage.",
+ "apihelp-query+querypage-param-page": "Nome da páxina especial. Teña en conta que diferencia entre maiúsculas e minúsculas.",
+ "apihelp-query+querypage-param-limit": "Número de resultados a visualizar.",
+ "apihelp-query+querypage-example-ancientpages": "Resultados devoltos de [[Special:Ancientpages]].",
+ "apihelp-query+random-summary": "Obter un conxunto de páxinas aleatorias.",
+ "apihelp-query+random-extended-description": "As páxinas están listadas nunha secuencia fixa, só o punto de comezo é aleatorio. Isto significa que se, por exemplo, a <samp>Main Page</samp> é a primeira páxina aleatoria da lista, a <samp>Lista de monos ficticios</samp> será <em>sempre</em> a segunda, <samp>Lista de xente en selos de Vanuatu</samp> será a terceira, etc.",
+ "apihelp-query+random-param-namespace": "Devolver páxinas só neste espazo de nomes.",
+ "apihelp-query+random-param-limit": "Limitar cantas páxinas aleatorias se van devolver.",
+ "apihelp-query+random-param-redirect": "No canto use <kbd>$1filterredir=redirects</kbd>.",
+ "apihelp-query+random-param-filterredir": "Como filtrar para redireccións.",
+ "apihelp-query+random-example-simple": "Obter dúas páxinas aleatorias do espazo de nomes principal.",
+ "apihelp-query+random-example-generator": "Obter a información da páxina de dúas páxinas aleatorias do espazo de nomes principal.",
+ "apihelp-query+recentchanges-summary": "Enumerar cambios recentes.",
+ "apihelp-query+recentchanges-param-start": "Selo de tempo para comezar a enumeración.",
+ "apihelp-query+recentchanges-param-end": "Selo de tempo para rematar a enumeración.",
+ "apihelp-query+recentchanges-param-namespace": "Filtrar os cambios a só eses espazos de nomes.",
+ "apihelp-query+recentchanges-param-user": "Só listar cambios deste usuario.",
+ "apihelp-query+recentchanges-param-excludeuser": "Non listar cambios deste usuario.",
+ "apihelp-query+recentchanges-param-tag": "Só listar cambios marcados con esta etiqueta.",
+ "apihelp-query+recentchanges-param-prop": "Inclúe información adicional:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "Engade o usuario responsable da modificación e marca se é unha dirección IP.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "Engade o identificador do usuario responsable da edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Engade o comentario da edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "Engade o comentario analizado da edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Engade os indicadores da edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Engade o selo de tempo da edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Engade o título da páxina da edición.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "Engade o identificador da páxina, o identificador dos cambios recentes e o identificador da versión nova e da vella.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Engade a lonxitude nova e vella da páxina en bytes.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "Pon unha marca se a páxina é unha redirección.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Marca as edicións vixiables como vixiadas ou non vixiadas.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Engade información do rexistro (identificador de rexistro, tipo de rexistro, etc) nas entradas do rexistro.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Lista as etiquetas da entrada.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Engade o control de contido para as entradas asociadas a unha revisión.",
+ "apihelp-query+recentchanges-param-token": "Usar <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> no canto diso.",
+ "apihelp-query+recentchanges-param-show": "Só mostrar elementos que cumpran esos criterios. Por exemplo, para ver só edicións menores feitas por usuarios conectados, activar $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "Número total de páxinas a devolver.",
+ "apihelp-query+recentchanges-param-type": "Que tipos de cambios mostrar.",
+ "apihelp-query+recentchanges-param-toponly": "Listar só cambios que son a última revisión.",
+ "apihelp-query+recentchanges-param-generaterevisions": "Cando é usado como xerador, xera identificadore de revisión no canto de títulos. As entradas de modificacións recentes sen identificadores de revisión asociados (p. ex. a maioría das entradas de rexistro) non xerarán nada.",
+ "apihelp-query+recentchanges-example-simple": "Listar cambios recentes.",
+ "apihelp-query+recentchanges-example-generator": "Obter a información de páxina sobre cambios recentes sen vixiancia.",
+ "apihelp-query+redirects-summary": "Devolve todas as redireccións das páxinas indicadas.",
+ "apihelp-query+redirects-param-prop": "Que propiedades recuperar:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "ID de páxina de cada redirección.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Título de cada redirección.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "Fragmento de cada redirección, se hai algún.",
+ "apihelp-query+redirects-param-namespace": "Só incluir páxinas nestes espacios de nomes.",
+ "apihelp-query+redirects-param-limit": "Cantos redireccións devolver.",
+ "apihelp-query+redirects-param-show": "Só mostrar elementos que cumpran estos criterios:\n;fragment:Só mostrar redireccións que teñan un fragmento.\n;!fragment:Só mostrar redireccións que non teñan un fragmento.",
+ "apihelp-query+redirects-example-simple": "Obter unha lista de redireccións á [[Main Page]]",
+ "apihelp-query+redirects-example-generator": "Obter información sobre tódalas redireccións á [[Main Page]]",
+ "apihelp-query+revisions-summary": "Obter información da revisión.",
+ "apihelp-query+revisions-extended-description": "Pode usarse de varias formas:\n#Obter datos sobre un conxunto de páxinas (última modificación), fixando os títulos ou os IDs das páxinas.\n#Obter as modificacións da páxina indicada, usando os títulos ou os IDs de páxinas con comezar, rematar ou límite.\n#Obter os datos sobre un conxunto de modificacións fixando os seus IDs cos seus IDs de modificación.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Só pode usarse cunha única páxina (mode #2).",
+ "apihelp-query+revisions-param-startid": "Desde que ID de revisión comezar a enumeración.",
+ "apihelp-query+revisions-param-endid": "Rematar a enumeración de revisión na data e hora desta revisión. A revisión ten que existir, pero non precisa pertencer a esta páxina.",
+ "apihelp-query+revisions-param-start": "Desde que selo de tempo comezar a enumeración.",
+ "apihelp-query+revisions-param-end": "Enumerar desde este selo de tempo.",
+ "apihelp-query+revisions-param-user": "Só incluir revisión feitas polo usuario.",
+ "apihelp-query+revisions-param-excludeuser": "Excluír revisións feitas polo usuario.",
+ "apihelp-query+revisions-param-tag": "Só listar revisións marcadas con esta etiqueta.",
+ "apihelp-query+revisions-param-token": "Que identificadores obter para cada revisión.",
+ "apihelp-query+revisions-example-content": "Obter datos con contido da última revisión dos títulos <kbd>API</kbd> e <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "Mostrar as cinco últimas revisión da <kbd>Páxina Principal</kbd>.",
+ "apihelp-query+revisions-example-first5": "Mostar as cinco primeiras revisións da <kbd>Páxina Principal</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Mostrar as cinco primeiras revisións da <kbd>Páxina Principal</kbd> feitas despois de 2006-05-01.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Mostrar as cinco primeiras revisións da <kbd>Páxina Principal</kbd> que non foron feitas polo usuario anónimo <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "Mostrar as cinco primeiras revisión da <kbd>Páxina Principal</kbd> feitas polo usuario <kbd>MediaWiki default</kbd>.",
+ "apihelp-query+revisions+base-param-prop": "Que propiedades mostrar para cada modificación:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "O identificador da modificación.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Marcas de modificación (menor).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "O selo de tempo da modificación.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Usuario que fixo a revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "Identificador de usuario do creador da modificación.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Lonxitude (en bytes) da revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1 (base 16) da modificación.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "Identificador do modelo de contido da modificación.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "Comentario do usuario para a modificación.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "Comentario analizado do usuario para a modificación.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Texto da revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Etiquetas para a revisión.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">Obsoleto.</span> En substitución, use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> ou <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Árbore de análise XML do contido da modificación (precisa o modelo de contido <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "Limitar cantas revisións se van devolver.",
+ "apihelp-query+revisions+base-param-expandtemplates": "En substitución, use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd>. Expandir os modelos no contido da revisión (require $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "En substitución, use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> ou <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Xenerar a árbore de análise XML para o contido da revisión (require $1prop=content; substituído por <kbd>$1prop=parsetree</kbd>).",
+ "apihelp-query+revisions+base-param-parse": "En substitución, use <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Analizar o contido da revisión (require $1prop=content). Por razóns de rendemento, se se usa esta opción, $1limit cámbiase a 1.",
+ "apihelp-query+revisions+base-param-section": "Recuperar unicamente o contido deste número de sección.",
+ "apihelp-query+revisions+base-param-diffto": "En substitución, use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. ID de revisión a comparar con cada revisión. Use <kbd>prev</kbd>, <kbd>next</kbd> e <kbd>cur</kbd> para a versión precedente, seguinte e actual respectivamente.",
+ "apihelp-query+revisions+base-param-difftotext": "En substitución, use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. Texto co que comparar cada revisión. Só compara un número limitado de revisións. Ignora <var>$1diffto</var>. Se <var>$1section</var> ten valor, só se comparará co texto esa sección.",
+ "apihelp-query+revisions+base-param-difftotextpst": "En substitución, use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. Facer unha transformación sobre o texto antes do gardado e antes de comparalo. Só válidoo cando se usa con <var>$1difftotext</var>.",
+ "apihelp-query+revisions+base-param-contentformat": "Formato de serialización usado por <var>$1difftotext</var> e esperado para a saída do contido.",
+ "apihelp-query+search-summary": "Facer unha busca por texto completo.",
+ "apihelp-query+search-param-search": "Buscar os títulos de páxina ou contido que coincidan con este valor. Pode usar a cadea de busca para invocar funcións especiais de busca, dependendo do motor de busca que teña a wiki.",
+ "apihelp-query+search-param-namespace": "Buscar só nestes espazos de nomes.",
+ "apihelp-query+search-param-what": "Que tipo de busca lanzar.",
+ "apihelp-query+search-param-info": "Que metadatos devolver.",
+ "apihelp-query+search-param-prop": "Que propiedades devolver:",
+ "apihelp-query+search-param-qiprofile": "Perfil independente das consultas a usar (afecta ó algoritmo de clasificación).",
+ "apihelp-query+search-paramvalue-prop-size": "Engade o tamaño da páxina en bytes.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Engade o número de palabras da páxina.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Engade o selo de tempo da última vez que foi editada a páxina.",
+ "apihelp-query+search-paramvalue-prop-snippet": "Engade o fragmento analizado da páxina.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "Engade un fragmento analizado do título da páxina.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "Engade un fragmento analizado do título da redirección.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Engade o título da redirección asociada.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "Engade un fragmento analizado do título de sección asociado.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Engade o título da sección asociada.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "Engade un fragmento analizado da categoría asociada.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "Engade unha marca indicando se o resultado da busca é un ficheiro.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignorado.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado.",
+ "apihelp-query+search-param-limit": "Número total de páxinas a devolver.",
+ "apihelp-query+search-param-interwiki": "Incluir na busca resultados de interwikis, se é posible.",
+ "apihelp-query+search-param-backend": "Que servidor de busca usar, se non se indica usa o que hai por defecto.",
+ "apihelp-query+search-param-enablerewrites": "Habilitar reescritura da consulta interna. Algúns motores de busca poden reescribir a consulta a outra que consideran que dará mellores resultados, por exemplo, corrixindo erros de ortografía.",
+ "apihelp-query+search-example-simple": "Buscar <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Buscar texto por <kbd>significado</kbd>.",
+ "apihelp-query+search-example-generator": "Obter información da páxina sobre as páxinas devoltas por unha busca por <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "Devolver información xeral sobre o sitio.",
+ "apihelp-query+siteinfo-param-prop": "Que información obter:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Información xeral do sistema.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "Lista dos espazos de nomes rexistrados e os seus nomes canónicos.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Lista de alias de espazos de nomes rexistrados .",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Lista de alias de páxinas especiais.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Lista de palabras máxicas e os seus alias.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Devolve as estatísticas do sitio.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "Devolve o mapa interwiki (opcionalmente filtrado, opcionalmente localizado usando <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "Devolve o servidor de base de datos con maior retardo de replicación.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Devolve os grupos de usuarios e os permisos que teñen asociados.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Devolve as bibliotecas de funcións software instaladas na wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Devolve as extensións instaladas na wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Devolve a lista de extenxións de ficheiro (tipos de ficheiro) permitidas para subir ficheiros.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Devolve a información dos dereitos (licenza) da wiki se está dispoñible.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Devolve información dos tipos de restricións (protección) dispoñibles.",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "Devolve unha lista dos idiomas que soporta Mediawiki (opcionalmente pode localizarse usando <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Devolve unha lista de códigos de lingua para os que [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]] está activo, e as variantes soportadas para cada un.",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "Devolve unha lista de todas as aparencias dispoñibles (opcionalmente pode localizarse usando <var>$1inlanguagecode</var>, noutro caso no idioma do contido).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "Devolve unha lista de etiquetas de extensión de analizador.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "Devolve unha lista de ganchos de función de analizador.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "Devolve unha lista de todos os ganchos subscritos (contido de <var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Devolve unha lista de identificadores de variable.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "Devolve unha lista de protocolos que están permitidos nas ligazóns externas.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Devolve os valores por defecto das preferencias de usuario.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "Devolve a configuración do diálogo de subas.",
+ "apihelp-query+siteinfo-param-filteriw": "Só devolver entradas locais ou só non locais da correspondencia interwiki.",
+ "apihelp-query+siteinfo-param-showalldb": "Listar todos os servidores de base de datos, non só o que teña máis retardo.",
+ "apihelp-query+siteinfo-param-numberingroup": "Listar o número de usuarios nos grupos de usuarios.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "Código de lingua para os nomes de lingua localizados (a mellor forma posible) e nomes de presentación.",
+ "apihelp-query+siteinfo-example-simple": "Obter información do sitio.",
+ "apihelp-query+siteinfo-example-interwiki": "Obter unha lista de prefixos interwiki locais.",
+ "apihelp-query+siteinfo-example-replag": "Revisar o retardo de replicación actual.",
+ "apihelp-query+stashimageinfo-summary": "Devolve a información dos ficheiros almacenados.",
+ "apihelp-query+stashimageinfo-param-filekey": "Clave que identifica unha subida precedente e que foi almacenada temporalmente.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Alias para $1filekey, para compatibilidade con versións antigas.",
+ "apihelp-query+stashimageinfo-example-simple": "Devolve a información dun ficheiro almacenado.",
+ "apihelp-query+stashimageinfo-example-params": "Devolve as miniaturas de dous ficheiros almacenados.",
+ "apihelp-query+tags-summary": "Lista de marcas de cambios.",
+ "apihelp-query+tags-param-limit": "Máximo número de etiquetas a listar.",
+ "apihelp-query+tags-param-prop": "Que propiedades recuperar:",
+ "apihelp-query+tags-paramvalue-prop-name": "Engade o nome da etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Engade a mensaxe do sistema para a etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-description": "Engade a descrición da etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Engade o número de modificacións e de entradas do rexistro que teñen esta etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Indica se a etiqueta está definida.",
+ "apihelp-query+tags-paramvalue-prop-source": "Obtén as fontes da etiqueta, que poden incluír <samp>extension</samp> para etiquetas definidas en extensión e <samp>manual</samp> para etiquetas que poden ser aplicadas manualmente polos usuarios.",
+ "apihelp-query+tags-paramvalue-prop-active": "Se a etiqueta aínda está a ser usada.",
+ "apihelp-query+tags-example-simple": "Listar as marcas dispoñibles",
+ "apihelp-query+templates-summary": "Devolve todas as páxinas incluídas na páxina indicada.",
+ "apihelp-query+templates-param-namespace": "Mostrar os modelos só nestes espazos de nomes.",
+ "apihelp-query+templates-param-limit": "Número de modelos a devolver.",
+ "apihelp-query+templates-param-templates": "Listar só eses modelos. Útil para verificar se unha páxina concreta ten un modelo determinado.",
+ "apihelp-query+templates-param-dir": "Dirección na cal listar.",
+ "apihelp-query+templates-example-simple": "Coller os modelos usado na <kbd>Páxina Principal</kbd>.",
+ "apihelp-query+templates-example-generator": "Obter información sobre os modelos usados na <kbd>Páxina Principal</kbd>.",
+ "apihelp-query+templates-example-namespaces": "Obter páxinas nos espazos de nomes {{ns:user}} e {{ns:template}} que se transclúen na <kbd>Páxina Principal</kbd>.",
+ "apihelp-query+tokens-summary": "Recupera os identificadores das accións de modificación de datos.",
+ "apihelp-query+tokens-param-type": "Tipos de identificadores a consultar.",
+ "apihelp-query+tokens-example-simple": "Recuperar un identificador csrf (por defecto).",
+ "apihelp-query+tokens-example-types": "Recuperar un identificador vixiancia e un de patrulla.",
+ "apihelp-query+transcludedin-summary": "Atopar todas as páxinas que inclúen ás páxinas indicadas.",
+ "apihelp-query+transcludedin-param-prop": "Que propiedades obter:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "ID de páxina de cada páxina.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Título de cada páxina.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Marca si a páxina é unha redirección.",
+ "apihelp-query+transcludedin-param-namespace": "Só incluir páxinas nestes espacios de nomes.",
+ "apihelp-query+transcludedin-param-limit": "Cantos mostrar.",
+ "apihelp-query+transcludedin-param-show": "Mostrar só elementos que cumpren estes criterios:\n;redirect:Só mostra redireccións.\n;!redirect:Só mostra as que non son redireccións.",
+ "apihelp-query+transcludedin-example-simple": "Obter unha lista de páxinas que inclúen a <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Obter información sobre as páxinas que inclúen <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-summary": "Mostrar tódalas edicións dun usuario.",
+ "apihelp-query+usercontribs-param-limit": "Máximo número de contribucións a mostar.",
+ "apihelp-query+usercontribs-param-start": "Selo de tempo de comezo ó que volver.",
+ "apihelp-query+usercontribs-param-end": "Selo de tempo de fin ó que volver.",
+ "apihelp-query+usercontribs-param-user": "Usuarios para os que recuperar as contribucións. Non pode ser usado con <var>$1userids</var> ou <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "Recuperar as contribucións de todos os usuarios cuxo nome comece por este valor. Non pode usarse con <var>$1user</var> nin con <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "IDs de usuarios para os que recuperar as contribucións. Non pode ser usado con <var>$1user</var> nin con <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "Só listar contribucións nestes espazos de nomes.",
+ "apihelp-query+usercontribs-param-prop": "Engade información adicional:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Engade os identificadores de páxina e modificación.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Engade o título e o identificador do espazo de nomes da páxina.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Engade o selo de tempo da modificación.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Engade o comentario da modificación.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Engade o comentario analizado da modificación.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Engade o novo tamaño da modificación.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Engade o delta do tamaño da modificación comparada coa anterior.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Engade os indicadores da modificación.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Marca as modificacións vixiadas.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Lista as etiquetas da modificación.",
+ "apihelp-query+usercontribs-param-show": "Só mostrar elementos que cumpran estos criterios, p.ex. só edicións menores: <kbd>$2show=!minor</kbd>.\n\nSe está fixado <kbd>$2show=patrolled</kbd> ou <kbd>$2show=!patrolled</kbd>, as modificacións máis antigas que <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var> ($1 {{PLURAL:$1|segundo|segundos}}) non se mostrarán.",
+ "apihelp-query+usercontribs-param-tag": "Só listar revisións marcadas con esta etiqueta.",
+ "apihelp-query+usercontribs-param-toponly": "Listar só cambios que son a última revisión.",
+ "apihelp-query+usercontribs-example-user": "Mostrar as contribucións do usuario <kbd>Exemplo</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "Mostrar contribucións de tódalas direccións IP que comezan por <kbd>192.0.2.</kbd>.",
+ "apihelp-query+userinfo-summary": "Obter información sobre o usuario actual.",
+ "apihelp-query+userinfo-param-prop": "Que pezas de información incluír:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Marca se o usuario actual está bloqueado, por que, e por que razón.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Engade unha etiqueta <samp>messages</samp> (mensaxe) se o usuario actual ten mensaxes pendentes.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Lista todos os grupos ós que pertence o usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Lista os grupos ós que o usuario actual foi asignado explicitamente, incluíndo a data de caducidade de afiliación a cada grupo.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Lista todos so grupos dos que o usuario actual é membro automaticamente.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Lista todos os dereitos que ten o usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Lista os grupos ós que o usuario pode engadir ou eliminar a outros usuarios.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Lista todas as preferencias que ten seleccionadas o usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Obtén un identificador para cambiar as preferencias do usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Engade o contador de edicións do usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Lista todos o límites de rango aplicados ó usuario actual.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Engade o nome real do usuario.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Engade a dirección de correo electrónico do usuario e a data de autenticación desa dirección.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Reenvía a cabeceira <code>Accept-Language</code> enviada polo cliente nun formato estruturado.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Engade a data de rexistro do usuario.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Engade o número de páxinas sen ler da lista de vixiancia do usuario (máximo $1; devolve <samp>$2</samp> se son máis).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "Engade os identificadores centrais e o estado de acoplamento do usuario.",
+ "apihelp-query+userinfo-param-attachedwiki": "Con <kbd>$1prop=centralids</kbd>, \nindica que o usuario está acoplado á wiki identificada por este identificador.",
+ "apihelp-query+userinfo-example-simple": "Obter información sobre o usuario actual.",
+ "apihelp-query+userinfo-example-data": "Obter información adicional sobre o usuario actual.",
+ "apihelp-query+users-summary": "Obter información sobre unha lista de usuarios.",
+ "apihelp-query+users-param-prop": "Que información incluír:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Etiquetas se o usuario está bloqueado, por quen, e por que razón.",
+ "apihelp-query+users-paramvalue-prop-groups": "Lista todos os grupos ós que pertence cada usuario.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "Lista os grupos ós que foi asignado explicitamente cada usuario, incluíndo a data de caducidade de afiliación a cada grupo.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Lista os grupos dos que un usuario é membro de forma automatica.",
+ "apihelp-query+users-paramvalue-prop-rights": "Lista todos os dereitos que ten cada usuario.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Engade o contador de edicións do usuario.",
+ "apihelp-query+users-paramvalue-prop-registration": "Engade o selo de tempo do rexistro do usuario.",
+ "apihelp-query+users-paramvalue-prop-emailable": "Marca se o usuario pode e quere recibir correos usando [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "Marca o xénero do usuario. Devolve \"home\", \"muller\" ou \"descoñecido\".",
+ "apihelp-query+users-paramvalue-prop-centralids": "Engade os identificadores centrais e o estado de acoplamento do usuario.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Indica se unha conta pode ser creada para nomes de usuario válidos pero non rexistrados.",
+ "apihelp-query+users-param-attachedwiki": "Con <kbd>$1prop=centralids</kbd>, \nindica que o usuario está acoplado á wiki identificada por este identificador.",
+ "apihelp-query+users-param-users": "Lista de usuarios para os que obter información.",
+ "apihelp-query+users-param-userids": "Unha lista de identificadores de usuarios dos que obter información.",
+ "apihelp-query+users-param-token": "Usar <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> no canto diso.",
+ "apihelp-query+users-example-simple": "Mostar información para o usuario <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-summary": "Ver os cambios recentes das páxinas na lista de vixiancia do usuario actual.",
+ "apihelp-query+watchlist-param-allrev": "Incluír múltiples revisións da mesma páxina dentro do intervalo de tempo indicado.",
+ "apihelp-query+watchlist-param-start": "Selo de tempo para comezar a enumeración",
+ "apihelp-query+watchlist-param-end": "Selo de tempo para rematar a enumeración.",
+ "apihelp-query+watchlist-param-namespace": "Filtrar os cambios a só os espazos de nomes indicados.",
+ "apihelp-query+watchlist-param-user": "Só listar cambios deste usuario.",
+ "apihelp-query+watchlist-param-excludeuser": "Non listar cambios deste usuario.",
+ "apihelp-query+watchlist-param-limit": "Cantos resultados totais mostrar por petición.",
+ "apihelp-query+watchlist-param-prop": "Que propiedades adicionais obter:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Engade os identificadores das revisións e os identificadores das páxinas.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Engade o título da páxina.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Engade etiquetas para a edición.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Engade o usuario que fixo a edición.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Engade o identificador do usuario que fixo a edición.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Engade o comentario da edición.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Engade o comentario analizado da edición.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Engade o selo de tempo da edición.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Marca edicións que están vixiadas.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Engade o tamaño antigo e novo da páxina.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Engade o selo de tempo da última vez en que o usuario foi avisado da modificación.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Engade información do rexistro cando sexa axeitado.",
+ "apihelp-query+watchlist-param-show": "Só mostrar elementos que cumpran esos criterios. Por exemplo, para ver só edicións menores feitas por usuarios conectados, activar $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "Que tipos de cambios mostrar:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Edicións comúns a páxinas.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Cambios externos.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Creacións de páxinas.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Entradas do rexistro",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Modificacións de pertenza á categoría.",
+ "apihelp-query+watchlist-param-owner": "Usado con $1token para acceder á lista de páxinas de vixiancia doutro usuario.",
+ "apihelp-query+watchlist-param-token": "Identificador de seguridade (dispoñible nas [[Special:Preferences#mw-prefsection-watchlist|preferencias]] de usuario) para permitir o acceso a outros á súa páxina de vixiancia.",
+ "apihelp-query+watchlist-example-simple": "Listar a última revisión das páxinas recentemente modificadas da lista de vixiancia do usuario actual.",
+ "apihelp-query+watchlist-example-props": "Buscar información adicional sobre a última revisión das páxinas modificadas recentemente da lista de vixiancia do usuario actual.",
+ "apihelp-query+watchlist-example-allrev": "Buscar a información sobre todos os cambios recentes das páxinas da lista de vixiancia do usuario actual.",
+ "apihelp-query+watchlist-example-generator": "Buscar a información de páxina das páxinas cambiadas recentemente da lista de vixiancia do usuario actual.",
+ "apihelp-query+watchlist-example-generator-rev": "Buscar a información da revisión dos cambios recentes de páxinas na lista de vixiancia do usuario actual.",
+ "apihelp-query+watchlist-example-wlowner": "Listar a última revisión das páxinas cambiadas recentemente da lista de vixiancia do usuario <kbd>Example</kbd>.",
+ "apihelp-query+watchlistraw-summary": "Obter todas as páxinas da lista de vixiancia do usuario actual.",
+ "apihelp-query+watchlistraw-param-namespace": "Só listar páxinas nestes espazos de nomes.",
+ "apihelp-query+watchlistraw-param-limit": "Cantos resultados totais mostrar por petición.",
+ "apihelp-query+watchlistraw-param-prop": "Que propiedades adicionais obter:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Engade o selo de tempo da última notificación ó usuario dunha modificación.",
+ "apihelp-query+watchlistraw-param-show": "Só listar os elementos que cumplen estos criterios.",
+ "apihelp-query+watchlistraw-param-owner": "Usado con $1token para acceder á lista de páxinas de vixiancia doutro usuario.",
+ "apihelp-query+watchlistraw-param-token": "Identificador de seguridade (dispoñible nas [[Special:Preferences#mw-prefsection-watchlist|preferencias]] de usuario) para permitir o acceso a outros á súa páxina de vixiancia.",
+ "apihelp-query+watchlistraw-param-dir": "Dirección na cal listar.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Título (co prefixo de espazo de nomes) dende o que comezar a enumerar.",
+ "apihelp-query+watchlistraw-param-totitle": "Título (co prefixo de espazo de nomes) no que rematar de enumerar.",
+ "apihelp-query+watchlistraw-example-simple": "Listar páxinas na lista de vixiancia do usuario actual.",
+ "apihelp-query+watchlistraw-example-generator": "Buscar a información de páxina das páxinas da lista de vixiancia do usuario actual.",
+ "apihelp-removeauthenticationdata-summary": "Elimina os datos de autenticación do usuario actual.",
+ "apihelp-removeauthenticationdata-example-simple": "Intenta eliminar os datos de usuario actual para <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "Envía un correo de inicialización de contrasinal a un usuario.",
+ "apihelp-resetpassword-extended-description-noroutes": "Non están dispoñibles as rutas de reinicio de contrasinal \n\nActive as rutas en <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var> para usar este módulo.",
+ "apihelp-resetpassword-param-user": "Usuario sendo reinicializado.",
+ "apihelp-resetpassword-param-email": "Está reinicializándose o enderezo de correo electrónico do usuario.",
+ "apihelp-resetpassword-example-user": "Enviar un correo de reinicialización de contrasinal ó usuario <kbd>Exemplo</kbd>.",
+ "apihelp-resetpassword-example-email": "Enviar un correo de reinicialización de contrasinal a todos os usuarios con enderezo de correo electrónico <kbd>usario@exemplo.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Borrar e restaurar revisións.",
+ "apihelp-revisiondelete-param-type": "Tipo de borrado de revisión a ser tratada.",
+ "apihelp-revisiondelete-param-target": "Título de páxina para o borrado da revisión, se requerido para o tipo.",
+ "apihelp-revisiondelete-param-ids": "Identificadores para as revisións a ser borradas.",
+ "apihelp-revisiondelete-param-hide": "Que ocultar para cada revisión.",
+ "apihelp-revisiondelete-param-show": "Que mostrar para cada revisión.",
+ "apihelp-revisiondelete-param-suppress": "Eliminar os datos dos administradores así coma dos doutros.",
+ "apihelp-revisiondelete-param-reason": "Razón para o borrado ou restaurado.",
+ "apihelp-revisiondelete-param-tags": "Etiquetas a aplicar á entrada no rexistro de borrados.",
+ "apihelp-revisiondelete-example-revision": "Ocultar contido para revisión <kbd>12345</kbd> na <kbd>Páxina Principal</kbd>.",
+ "apihelp-revisiondelete-example-log": "Ocultar todos os datos da entrada de rexistro <kbd>67890</kbd> coa razón <kbd>BLP violation</kbd>.",
+ "apihelp-rollback-summary": "Desfacer a última edición da páxina.",
+ "apihelp-rollback-extended-description": "Se o último usuario que editou a páxina fixo varias edicións consecutivas, serán revertidas todas.",
+ "apihelp-rollback-param-title": "Título da páxina a desfacer. Non pode usarse xunto con <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "ID da páxina a desfacer. Non pode usarse xunto con <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "Etiquetas a aplicar á reversión.",
+ "apihelp-rollback-param-user": "Nome do usuario cuxas modificacións van a desfacerse.",
+ "apihelp-rollback-param-summary": "Personalizar o resumo de edición. Se está baleiro, usarase o resumo por defecto.",
+ "apihelp-rollback-param-markbot": "Marcar as edicións revertidas e a reversión como edicións de bot.",
+ "apihelp-rollback-param-watchlist": "Engadir ou eliminar sen condicións a páxina da lista de vixiancia do usuario actual, use as preferencias ou non cambie a vixiancia.",
+ "apihelp-rollback-example-simple": "Desfacer as últimas edicións á <kbd>Páxina Principal</kbd> do usuario <kbd>Exemplo</kbd>.",
+ "apihelp-rollback-example-summary": "Desfacer as últimas edicións á páxina <kbd>Main Page</kbd> polo usuario da dirección IP <kbd>192.0.2.5</kbd> co resumo de edición <kbd>Revertindo vandalismo</kbd>, marcar esas edicións e a reversión como edicións de bot.",
+ "apihelp-rsd-summary": "Exportar un esquema RSD (Really Simple Discovery, Descubrimento Moi Simple).",
+ "apihelp-rsd-example-simple": "Exportar o esquema RSD.",
+ "apihelp-setnotificationtimestamp-summary": "Actualizar a data e hora de notificación das páxinas vixiadas.",
+ "apihelp-setnotificationtimestamp-extended-description": "Isto afecta ao realce das páxinas modificadas na lista de vixiancia e no historial, e ao envío de correos cando a preferencia \"{{int:tog-enotifwatchlistpages}}\" está activada.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Traballar en tódalas páxinas vixiadas.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "Selo de tempo ó que fixar a notificación.",
+ "apihelp-setnotificationtimestamp-param-torevid": "Modificación á que fixar o selo de tempo de modificación (só unha páxina).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "Modificación na que fixar o selo de tempo de modificación máis recente (só unha páxina).",
+ "apihelp-setnotificationtimestamp-example-all": "Restaurar o estado de notificación para toda a páxina de vixiancia",
+ "apihelp-setnotificationtimestamp-example-page": "Restaurar o estado de notificación para a <kbd>Páxina Principal</kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "Fixar o selo de tempo de notificación para a <kbd>Main page</kbd> de forma que todas as edicións dende o 1 se xaneiro de 2012 queden sen revisar.",
+ "apihelp-setnotificationtimestamp-example-allpages": "Restaurar o estado de notificación para as páxinas no espazo de nomes de <kbd>{{ns:user}}</kbd>.",
+ "apihelp-setpagelanguage-summary": "Cambiar a lingua dunha páxina.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Neste wiki non se permite modificar a lingua das páxinas.\n\nActive <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> para utilizar esta acción.",
+ "apihelp-setpagelanguage-param-title": "Título da páxina cuxa lingua quere cambiar. Non se pode usar xunto con <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "Identificador da páxina cuxa lingua quere cambiar. Non se pode usar xunto con <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "Código da lingua á que se quere cambiar a páxina. Use <kbd>default</kbd> para restablecer a páxina á lingua por defecto do contido da wiki.",
+ "apihelp-setpagelanguage-param-reason": "Motivo do cambio.",
+ "apihelp-setpagelanguage-param-tags": "Cambiar as etiquetas a aplicar á entrada de rexistro resultante desta acción.",
+ "apihelp-setpagelanguage-example-language": "Cambiar a lingua de <kbd>Main Page</kbd> ó éuscaro.",
+ "apihelp-setpagelanguage-example-default": "Cambiar a lingua da páxina con identificador 123 á lingua predeterminada para o contido da wiki.",
+ "apihelp-stashedit-summary": "Preparar unha edición na caché compartida.",
+ "apihelp-stashedit-extended-description": "Está previsto que sexa usado vía AJAX dende o formulario de edición para mellorar o rendemento de gardado da páxina.",
+ "apihelp-stashedit-param-title": "Título da páxina que se está a editar.",
+ "apihelp-stashedit-param-section": "Número de selección. O <kbd>0</kbd> é para a sección superior, <kbd>novo</kbd> para unha sección nova.",
+ "apihelp-stashedit-param-sectiontitle": "Título para unha nova sección.",
+ "apihelp-stashedit-param-text": "Contido da páxina.",
+ "apihelp-stashedit-param-stashedtexthash": "Función hash do contido da páxina dunha reserva anterior para ser usada.",
+ "apihelp-stashedit-param-contentmodel": "Modelo de contido para o novo contido.",
+ "apihelp-stashedit-param-contentformat": "Formato de serialización de contido utilizado para o texto de entrada.",
+ "apihelp-stashedit-param-baserevid": "Identificador da revisión da revisión de base.",
+ "apihelp-stashedit-param-summary": "Resumo do cambio.",
+ "apihelp-tag-summary": "Engadir ou eliminar etiquetas de cambio de revisións individuais ou entradas de rexistro.",
+ "apihelp-tag-param-rcid": "Identificadores de un ou máis cambios recentes nos que engadir ou eliminar a etiqueta.",
+ "apihelp-tag-param-revid": "Identificadores de unha ou máis revisións nas que engadir ou eliminar a etiqueta.",
+ "apihelp-tag-param-logid": "Identificadores de unha ou máis entradas do rexistro nas que engadir ou eliminar a etiqueta.",
+ "apihelp-tag-param-add": "Etiquetas a engadir. Só poden engadirse etiquetas definidas manualmente.",
+ "apihelp-tag-param-remove": "Etiquetas a eliminar. Só se poden eliminar as etiquetas definidas manualmente ou que non teñen ningunha definición.",
+ "apihelp-tag-param-reason": "Razón para o cambio.",
+ "apihelp-tag-param-tags": "Etiquetas a aplicar á entrada de rexistro que será creada como resultado desta acción.",
+ "apihelp-tag-example-rev": "Engadir a etiqueta <kbd>vandalismo</kbd> á revisión con identificador 123 sen indicar un motivo",
+ "apihelp-tag-example-log": "Eliminar a etiqueta <kbd>publicidade</kbd> da entrada do rexistro con identificador 123 co motivo <kbd>aplicada incorrectamente</kbd>",
+ "apihelp-tokens-summary": "Obter os identificadores para accións de modificación de datos.",
+ "apihelp-tokens-extended-description": "Este módulo está obsoleto e foi substituído por [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-tokens-param-type": "Tipos de identificadores a consultar.",
+ "apihelp-tokens-example-edit": "Recuperar un identificador de modificación (por defecto).",
+ "apihelp-tokens-example-emailmove": "Recuperar un identificador de correo e un identificador de movemento.",
+ "apihelp-unblock-summary": "Desbloquear un usuario.",
+ "apihelp-unblock-param-id": "ID do bloque a desbloquear (obtido de <kbd>list=blocks</kbd>). Non pode usarse xunto con <var>$1user</var> ou <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "Nome de usuario, enderezo IP ou rango de enderezos IP a desbloquear. Non pode usarse xunto con <var>$1id</var> ou <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "ID de usuario a desbloquear. Non pode usarse xunto con <var>$1id</var> ou <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "Razón para desbloquear.",
+ "apihelp-unblock-param-tags": "Cambiar as etiquetas a aplicar na entrada do rexistro de bloqueo.",
+ "apihelp-unblock-example-id": "Desbloquear bloqueo ID #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "Desbloquear usuario <kbd>Bob</kbd> con razón <kbd>Síntoo Bob</kbd>.",
+ "apihelp-undelete-summary": "Restaurar modificacións dunha páxina borrada.",
+ "apihelp-undelete-extended-description": "Unha lista de modificacións borradas (incluíndo os seus selos de tempo) pode consultarse a través de [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]], e unha lista de IDs de ficheiros borrados pode consultarse a través de [[Special:ApiHelp/query+filearchive|list=filearchive]].",
+ "apihelp-undelete-param-title": "Título da páxina a restaurar.",
+ "apihelp-undelete-param-reason": "Razón para restaurar.",
+ "apihelp-undelete-param-tags": "Cambiar as etiquetas a aplicar na entrada do rexistro de borrado.",
+ "apihelp-undelete-param-timestamps": "Selos de tempo das modificacións a restaurar. Se <var>$1timestamps</var> e <var>$1fileids</var> están baleiras, restaurarase todo.",
+ "apihelp-undelete-param-fileids": "IDs das modificacións de ficheiro a restaurar. Se <var>$1timestamps</var> e <var>$1fileids</var> están baleiras, serán restauradas todas.",
+ "apihelp-undelete-param-watchlist": "Engadir ou eliminar a páxina da lista de vixiancia do usuario actual sen condicións, use as preferencias ou non cambie a vixiancia.",
+ "apihelp-undelete-example-page": "Restaurar a <kbd>Páxina Principal</kbd>.",
+ "apihelp-undelete-example-revisions": "Restaurar dúas revisións de <kbd>Main Page</kbd>.",
+ "apihelp-unlinkaccount-summary": "Elimina unha conta vinculada do usuario actual.",
+ "apihelp-unlinkaccount-example-simple": "Tentar eliminar a ligazón do usuario actual co provedor asociado con <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-upload-summary": "Subir un ficheiro, ou obter o estado das subas pendentes.",
+ "apihelp-upload-extended-description": "Hai varios métodos dispoñibles:\n*Subir o contido do ficheiro directamente, usando o parámetro <var>$1file</var>.\n*Subir o ficheiro por partes, usando os parámetros <var>$1filesize</var>, <var>$1chunk</var>, e <var>$1offset</var>.\n*Mandar ó servidor MediaWiki que colla un ficheiro dunha URL, usando o parámetro <var>$1url</var>.\n*Completar unha suba anterior que fallou a causa dos avisos, usando o parámetro <var>$1filekey</var>. \nTeña en conta que o HTTP POST debe facerse como suba de ficheiro (p.ex. usando <code>multipart/form-data</code>)cando se envie o <var>$1file</var>.",
+ "apihelp-upload-param-filename": "Nome de ficheiro obxectivo.",
+ "apihelp-upload-param-comment": "Subir comentario. Tamén usado como texto da páxina inicial para ficheiros novos se non se especifica <var>$1text</var>.",
+ "apihelp-upload-param-tags": "Cambiar etiquetas a aplicar á entrada do rexistro de subas e á revisión de páxina de ficheiro.",
+ "apihelp-upload-param-text": "Texto da páxina inicial para novos ficheiros.",
+ "apihelp-upload-param-watch": "Vixiar a páxina.",
+ "apihelp-upload-param-watchlist": "Engadir ou eliminar sen condicións a páxina da lista de vixiancia do usuario actual, use as preferencias ou non cambie a vixiancia.",
+ "apihelp-upload-param-ignorewarnings": "Ignorar as advertencias.",
+ "apihelp-upload-param-file": "Contido do ficheiro.",
+ "apihelp-upload-param-url": "URL onde buscar o ficheiro.",
+ "apihelp-upload-param-filekey": "Clave que identifica unha subida precedente e que foi almacenada temporalmente.",
+ "apihelp-upload-param-sessionkey": "Igual a $1filekey, mantido por razóns de compatibilidade con procesos antigos.",
+ "apihelp-upload-param-stash": "Se está indicado, o servidor almacenará o ficheiro temporalmente no canto de engadilo ó repositorio.",
+ "apihelp-upload-param-filesize": "Tamaño de ficheiro completo da carga.",
+ "apihelp-upload-param-offset": "Desaxuste do bloque en bytes.",
+ "apihelp-upload-param-chunk": "Contido do bloque.",
+ "apihelp-upload-param-async": "Facer de forma asíncrona as operacións de ficheiro potencialmente grandes cando sexa posible.",
+ "apihelp-upload-param-checkstatus": "Só buscar o estado da subida da clave de ficheiro indicada.",
+ "apihelp-upload-example-url": "Carga dunha URL",
+ "apihelp-upload-example-filekey": "Completar carga que fallou debido a avisos",
+ "apihelp-userrights-summary": "Cambiar a pertencia dun usuario a un grupo.",
+ "apihelp-userrights-param-user": "Nome de usuario.",
+ "apihelp-userrights-param-userid": "ID de usuario.",
+ "apihelp-userrights-param-add": "Engadir o usuario a estes grupos, ou se xa é membro, actualizar a caducidade da súa afiliación.",
+ "apihelp-userrights-param-expiry": "Marcas de tempo de caducidade. Poden ser relativas (por exemplo, <kbd>5 meses</kbd> ou <kbd>2 semanas</kbd>) ou absolutas (por exemplo, <kbd>2014-09-18T12:34:56Z</kbd>). Se só se fixa unha marca de tempo, utilizarase para tódolos grupos que se pasen ó parámetro <var>$1add</var>. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, ou <kbd>never</kbd> para que a pertenza ó grupo non teña data de caducidade.",
+ "apihelp-userrights-param-remove": "Eliminar o usuario destes grupos.",
+ "apihelp-userrights-param-reason": "Motivo para o cambio.",
+ "apihelp-userrights-param-tags": "Cambia as etiquetas a aplicar á entrada do rexistro de dereitos de usuario.",
+ "apihelp-userrights-example-user": "Engadir o usuario <kbd>FooBot</kbd> ó grupo <kbd>bot</kbd>, e eliminar dos grupos <kbd>sysop</kbd> e <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Engadir ó usuario con ID <kbd>123</kbd> ó grupo <kbd>bot</kbd>, e borralo dos grupos <kbd>sysop</kbd> e <kbd>burócrata</kbd>.",
+ "apihelp-userrights-example-expiry": "Engadir o usuario <kbd>SometimeSysop</kbd> ó grupo <kbd>sysop</kbd> por 1 mes.",
+ "apihelp-validatepassword-summary": "Valida un contrasinal contra as políticas de contrasinais da wiki.",
+ "apihelp-validatepassword-extended-description": "A validez é <samp>Good</samp> se o contrasinal é aceptable, <samp>Change</samp> se o contrasinal pode usarse para iniciar sesión pero debe cambiarse ou <samp>Invalid</samp> se o contrasinal non se pode usar.",
+ "apihelp-validatepassword-param-password": "Contrasinal a validar.",
+ "apihelp-validatepassword-param-user": "Nome de usuario, para probas de creación de contas. O usuario nomeado non debe existir.",
+ "apihelp-validatepassword-param-email": "Enderezo de correo electrónico, para probas de creación de contas.",
+ "apihelp-validatepassword-param-realname": "Nome real, para probas de creación de contas.",
+ "apihelp-validatepassword-example-1": "Validar o contrasinal <kbd>foobar</kbd> para o usuario actual.",
+ "apihelp-validatepassword-example-2": "Validar o contrasinal <kbd>qwerty</kbd> para a creación do usuario <kbd>Example</kbd>.",
+ "apihelp-watch-summary": "Engadir ou borrar páxinas da lista de vixiancia do usuario actual.",
+ "apihelp-watch-param-title": "Páxina a vixiar/deixar de vixiar. Usar no canto <var>$1titles</var>.",
+ "apihelp-watch-param-unwatch": "Se está definido, a páxina deixará de estar vixiada en vez de vixiada.",
+ "apihelp-watch-example-watch": "Vixiar a páxina <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Deixar de vixiar a páxina <kbd>Páxina Principal</kbd>.",
+ "apihelp-watch-example-generator": "Vixiar as primeiras páxinas no espazo de nomes principal",
+ "apihelp-format-example-generic": "Devolver o resultado da consulta no formato $1.",
+ "apihelp-format-param-wrappedhtml": "Devolver o HTML formatado e os módulos ResourceLoader asociados como un obxecto JSON.",
+ "apihelp-json-summary": "Datos de saída en formato JSON.",
+ "apihelp-json-param-callback": "Se está especificado, inclúe a saída na chamada da función indicada. Para maior seguridade, todos os datos específicos do usuario serán restrinxidos.",
+ "apihelp-json-param-utf8": "Se está especificado, codifica a maioría (pero non todos) dos caracteres ASCII como UTF-8 no canto de reemprazalos con secuencias de escape hexadecimais. Por defecto cando <var>formatversion</var> non é <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "Se está indicado, codifica todos os caracteres que non sexan ASCII usando secuencias de escape hexadecimais. Por defecto cando <var>formatversion</var> é <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Formato de saída:\n;1:Formato compatible con versións anteriores(booleanos estilo XML,claves <samp>*</samp> para nodos, etc.).\n;2:Formato moderno experimental. Os detalles poden cambiar!\n;latest:Usa o último formato (actualmente kbd>2</kbd>), pode cambiar sen aviso previo.",
+ "apihelp-jsonfm-summary": "Datos de saída en formato JSON(impresión en HTML).",
+ "apihelp-none-summary": "Ningunha saída.",
+ "apihelp-php-summary": "Datos de saída en formato serializado de PHP.",
+ "apihelp-php-param-formatversion": "Formato de saída:\n;1:Formato compatible con versións anteriores(booleanos estilo XML,claves <samp>*</samp> para nodos, etc.).\n;2:Formato moderno experimental. Os detalles poden cambiar!\n;latest:Usa o último formato (actualmente kbd>2</kbd>), pode cambiar sen aviso previo.",
+ "apihelp-phpfm-summary": "Datos de saída en formato serializado de PHP(impresión en HTML).",
+ "apihelp-rawfm-summary": "Datos de saída, incluíndo os elementos de depuración, en formato JSON (impresión en HTML).",
+ "apihelp-xml-summary": "Datos de saída en formato XML.",
+ "apihelp-xml-param-xslt": "Se está indicado, engade o nome da páxina como unha folla de estilo XSL. O valor debe ser un título no espazo de nomes {{ns:MediaWiki}} rematando con <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Se está indicado, engade un espazo de nomes XML.",
+ "apihelp-xmlfm-summary": "Datos de saída en formato XML(impresión en HTML).",
+ "api-format-title": "Resultado de API de MediaWiki",
+ "api-format-prettyprint-header": "Esta é a representación HTML do formato $1. HTML é bó para depurar, pero non é axeitado para usar nunha aplicación.\n\nEspecifique o parámetro <var>format</var> para cambiar o formato de saída. Para ver a representación non-HTML do formato $1, fixe <kbd>format=$2</kbd>.\n\n\nRevise a [[mw:Special:MyLanguage/API|documentación completa]], ou a [[Special:ApiHelp/main|axuda da API]] para obter máis información.",
+ "api-format-prettyprint-header-only-html": "Esta é unha representación HTML empregada para a depuración de erros, e non é axeitada para o uso de aplicacións.\n\nVexa a [[mw:Special:MyLanguage/API|documentación completa]], ou a [[Special:ApiHelp/main|axuda da API]] para máis información.",
+ "api-format-prettyprint-status": "Esta resposta será devolta co estado de HTTP $1 $2.",
+ "api-login-fail-badsessionprovider": "Non é posible conectarse usando $1.",
+ "api-pageset-param-titles": "Lista de títulos nos que traballar.",
+ "api-pageset-param-pageids": "Lista de identificadores de páxina nos que traballar.",
+ "api-pageset-param-revids": "Unha lista de IDs de modificacións sobre as que traballar.",
+ "api-pageset-param-generator": "Obter a lista de páxinas sobre as que traballar executando o módulo de consulta especificado.\n\n<strong>Nota:</strong>Os nomes de parámetro do xerador deben comezar cunha \"g\", vexa os exemplos.",
+ "api-pageset-param-redirects-generator": "Resolver automaticamente as redireccións en <var>$1titles</var>, <var>$1pageids</var>, e <var>$1revids</var>, e nas páxinas devoltas por <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Resolver automaticamente as redireccións en <var>$1titles</var>, <var>$1pageids</var>, e <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Converter títulos a outras variantes se é preciso. Só funciona se a lingua de contido da wiki soporta a conversión en variantes. As linguas que soportan conversión en variante inclúen $1.",
+ "api-help-title": "Axuda da API de MediaWiki",
+ "api-help-lead": "Esta é unha páxina de documentación da API de MediaWiki xerada automaticamente.\n\nDocumentación e exemplos:\nhttps://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Módulo principal",
+ "api-help-undocumented-module": "Non existe documentación para o móduloː $1",
+ "api-help-flag-deprecated": "Este módulo está obsoleto.",
+ "api-help-flag-internal": "<strong>Este módulo é interno ou inestable. </strong> O seu funcionamento pode cambiar sen aviso previo.",
+ "api-help-flag-readrights": "Este módulo precisa permisos de lectura.",
+ "api-help-flag-writerights": "Este módulo precisa permisos de escritura.",
+ "api-help-flag-mustbeposted": "Este módulo só acepta peticións POST.",
+ "api-help-flag-generator": "Este módulo pode usarse como xenerador.",
+ "api-help-source": "Fonte: $1",
+ "api-help-source-unknown": "Fonte: <span class=\"apihelp-unknown\">descoñecida</span>",
+ "api-help-license": "Licenza: [[$1|$2]]",
+ "api-help-license-noname": "Licenza: [[$1|Ver ligazón]]",
+ "api-help-license-unknown": "Licenza: <span class=\"apihelp-unknown\">descoñecida</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parámetro|Parámetros}}:",
+ "api-help-param-deprecated": "Obsoleto.",
+ "api-help-param-required": "Este parámetro é obrigatorio.",
+ "api-help-datatypes-header": "Tipos de datos",
+ "api-help-datatypes": "A entrada a MediaWiki debe ser normalizada NFC UTF-8. MediaWiki puede intentar converter outras entradas, pero isto pode provocar que algunhas operacións (como as [[Special:ApiHelp/edit|edición]] con comprobación MD5) fallen.\n\nAlgúns tipos de parámetros nas solicitudes de API necesitan máis explicación:\n;boolean\n:Os parámetros booleanos traballan como caixas de verificación HTML: se o parámetro se especifica, independentemente do seu valor, considérase verdadeiro. Para un valor falso, omíta o parámetro completo.\n;timestamp\n:Os selos de tempo poden especificarse en varios formatos. Recoméndase o ISO 8601 coa data e a hora. Todas as horas están en UTC, a inclusión da zona horaria é ignorada.\n:* ISO 8601 con data e hora, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (signos de puntuación e <kbd>Z</kbd> son opcionais)\n:* ISO 8601 data e hora (omítense) fraccións de segundo, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (guións, dous puntos e, <kbd>Z</kbd> son opcionais)\n:* Formato MediaWiki, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Formato numérico xenérico, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (opcional na zona horaria <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd>, o <kbd>-<var>##</var></kbd> omítese)\n:* Formato EXIF, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:*Formato RFC 2822 (a zona horaria pódese omitir), <kbd><var>Mon</var>, <var>15</var> <var>Xan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formato RFC 850 (a zona horaria pódese omitir), <kbd><var>luns</var>, <var>15</var>-<var>xaneiro</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formato C ctime, <kbd><var>luns</var> <var>xaneiro</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>de 2001</var></kbd>\n:* Segundos desde 1970-01-01T00:00:00Z como de 1 a 13, díxitos enteiros (excluíndo o <kbd>0</kbd>)\n:* O texto <kbd>now</kbd> (agora)",
+ "api-help-param-type-limit": "Tipo: enteiro ou <kbd>max</kbd>",
+ "api-help-param-type-integer": "Tipo: {{PLURAL:$1|1=enteiro|2=lista de enteiros}}",
+ "api-help-param-type-boolean": "Tipo: booleano ([[Special:ApiHelp/main#main/datatypes|detalles]])",
+ "api-help-param-type-timestamp": "Tipo: {{PLURAL:$1|1=selo de tempo|2=lista de selos de tempo}} ([[Special:ApiHelp/main#main/datatypes|formatos permitidos]])",
+ "api-help-param-type-user": "Tipo: {{PLURAL:$1|1=nome de usuario|2=lista de nomes de usuarios}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Un valor dos seguintes valores|2=Valores (separados con <kbd>{{!}}</kbd> ou [[Special:ApiHelp/main#main/datatypes|outros]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Debe ser baleiro|Pode ser baleiro, ou $2}}",
+ "api-help-param-limit": "Non se permiten máis de $1.",
+ "api-help-param-limit2": "Non se permiten máis de $1 ($2 para bots).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=O valor debe ser maior |2=Os valores deben ser maiores}} que $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=O valor debe ser menor |2=Os valores deben ser menores}} que $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=O valor debe estar entre $2 e $3 |2=Os valores deben estar entre $2 e $3}}.",
+ "api-help-param-upload": "Debe ser enviado como un ficheiro importado usando multipart/form-data.",
+ "api-help-param-multi-separate": "Separe os valores con <kbd>|</kbd> ou [[Special:ApiHelp/main#main/datatypes|outros]].",
+ "api-help-param-multi-max": "O número máximo de valores é {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} para os bots).",
+ "api-help-param-multi-max-simple": "O número máximo de valores é {{PLURAL:1$|1$}}.",
+ "api-help-param-multi-all": "Para especificar tódolos valores use <kbd>$1</kbd>.",
+ "api-help-param-default": "Por defecto: $1",
+ "api-help-param-default-empty": "Por defecto: <span class=\"apihelp-empty\">(baleiro)</span>",
+ "api-help-param-token": "Un identificador \"$1\" recuperado por [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "Por compatibilidade, o identificador usado na web UI tamén é aceptado.",
+ "api-help-param-disabled-in-miser-mode": "Desactivado debido ó [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo minimal]].",
+ "api-help-param-limited-in-miser-mode": "<strong>Nota:</strong> Debido ó [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo minimal]], usar isto pode devolver menos de <var>$1limit</var> resultados antes de seguir, en casos extremos, pode que non se devolvan resultados.",
+ "api-help-param-direction": "En que dirección enumerar:\n;newer:Lista os máis antigos primeiro. Nota: $1start ten que estar antes que $1end.\n;older:Lista os máis novos primeiro (por defecto). Nota: $1start ten que estar despois que $1end.",
+ "api-help-param-continue": "Cando estean dispoñibles máis resultados, use isto para continuar.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(sen descrición)</span>",
+ "api-help-examples": "{{PLURAL:$1|Exemplo|Exemplos}}:",
+ "api-help-permissions": "{{PLURAL:$1|Permiso|Permisos}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Concedida a|Concedidas a}}: $2",
+ "api-help-right-apihighlimits": "Usar os valores superiores das consultas da API (consultas lentas: $1; consultas rápidas: $2). Os límites para as consultas lentas tamén se aplican ós parámetros multivaluados.",
+ "api-help-open-in-apisandbox": "<small>[abrir en zona de probas]</small>",
+ "api-help-authmanager-general-usage": "O procedemento xeral para usar este módulo é:\n# Buscar os campos dispoñibles dende <kbd>[[Special:ApiHelp/query+authmanagerinfo|\n\naction=query&meta=authmanagerinfo]]</kbd> con <kbd>amirequestsfor=$4</kbd>, e un identificador <kbd>$5</kbd> de <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# Presentar os campos ó usuario, e obter o seu envío.\n# Enviar a este módulo, proporcionando <var>$1returnurl</var> e calquera campo relevante.\n# Comprobar o <samp>status</samp> na resposta.\n#* Se vostede recibe <samp>PASS</samp> ou <samp>FAIL</samp>, a acción rematou. A operación foi correcta ou non se fixo.\n#* Se vostede recibe <samp>UI</samp>, presenta os novos campos ó usuario e obtén o seu envío. Logo son enviados a este módulo con <var>$1continue</var> e o conxunto de campos relevantes, e repite o paso 4.\n#* Se vostede recibe <samp>REDIRECT</samp>, dirixe ó usuario a <samp>redirecttarget</samp> e espera pola resposta a <var>$1returnurl</var>. Logo envíaa a este módulo con <var>$1continue</var> e calquera campo pasado á URL de volta, e repite o paso 4.\n#* Se recibe <samp>RESTART</samp>, isto significa que a autenticación funcionou pero que non temos unha conta de usuario ligada. Debe tratar isto igual que <samp>UI</samp> ou como <samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-requests": "Só usar estas peticións de autenticación, co <samp>id</samp> devolto por <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> con <kbd>amirequestsfor=$1</kbd> ou dunha resposta previa deste módulo.",
+ "api-help-authmanagerhelper-request": "Usar esta petición de autenticación, co <samp>id</samp> devolto por <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> con <kbd>amirequestsfor=$1</kbd>.",
+ "api-help-authmanagerhelper-messageformat": "Formato a usar para devolver as mensaxes.",
+ "api-help-authmanagerhelper-mergerequestfields": "Fusionar os campos de información para todas as peticións de autenticación nunha táboa.",
+ "api-help-authmanagerhelper-preservestate": "Conservar o estado dun intento previo de conexión fallida, se é posible.",
+ "api-help-authmanagerhelper-returnurl": "Devolve o URL para os fluxos de autenticación de terceiros, que debe ser absoluto. Este ou <var>$1continue</var> é obrigatorio.\n\nLogo da recepción dunha resposta <samp>REDIRECT</samp>, vostede normalmente abrirá un navegador web ou un visor web para ver a URL <samp>redirecttarget</samp> especificada para un fluxo de autenticación de terceiros. Cando isto se complete, a aplicación de terceiros enviará ó navegador web ou visor web a esta URL. Vostede debe eliminar calquera consulta ou parámetros POST da URL e pasalos como unha consulta <var>$1continue</var> a este módulo API.",
+ "api-help-authmanagerhelper-continue": "Esta petición é unha continucación despois dun resposta precedente <samp>UI</samp> ou <samp>REDIRECT</samp>. Esta ou <var>$1returnurl</var> é requirida.",
+ "api-help-authmanagerhelper-additional-params": "Este módulo acepta parámetros adicionais dependendo das consultas de autenticación dispoñibles. Use <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> con <kbd>amirequestsfor=$1</kbd> (ou unha resposta previa deste módulo, se aplicable) para determinar as consultas dispoñibles e os campos que usan.",
+ "apierror-allimages-redirect": "Usar <kbd>gaifilterredir=nonredirects</kbd> no canto de <var>redirects</var> cando <kbd>allimages</kbd> é usado como xerador.",
+ "apierror-allpages-generator-redirects": "Usar <kbd>gapfilterredir=nonredirects</kbd> no canto de <var>redirects</var> cando <kbd>allpages</kbd> é usado como xerador.",
+ "apierror-appendnotsupported": "Non pode anexarse a páxinas que usan o modelo de contido $1.",
+ "apierror-articleexists": "O artigo que intentou crear xa existe.",
+ "apierror-assertbotfailed": "A verificación de que o usuario ten o dereito de <code>bot</code> fallou.",
+ "apierror-assertnameduserfailed": "A verificación de que o usuario é «$1» fallou.",
+ "apierror-assertuserfailed": "A verificación de que o usuario está conectado fallou.",
+ "apierror-autoblocked": "O seu enderezo IP foi bloqueado automaticamente porque foi utilizado por un usuario bloqueado.",
+ "apierror-badconfig-resulttoosmall": "O valor de <code>$wgAPIMaxResultSize</code> neste wiki é demasiado pequeno como para conter información de resultados básicos.",
+ "apierror-badcontinue": "Parámetro de continuación non válido. Debe pasar o valor orixinal devolto pola consulta precedente.",
+ "apierror-baddiff": "A comparación non pode recuperarse. Unha ou ambas revisións non existen ou non ten permiso para velas.",
+ "apierror-baddiffto": "<var>$1diffto</var> debe fixarse cun número non negativo, <kbd>prev</kbd>, <kbd>next</kbd> ou <kbd>cur</kbd>.",
+ "apierror-badformat-generic": "O formato solicitado $1 non está soportado polo modelo de contido $2.",
+ "apierror-badformat": "O formato solicitado $1 non está soportado polo modelo de contido $2 utilizado por $3.",
+ "apierror-badgenerator-notgenerator": "O módulo <kbd>$1</kbd> non pode utilizarse como xerador.",
+ "apierror-badgenerator-unknown": "<kbd>generator=$1</kbd> descoñecido.",
+ "apierror-badip": "O parámetro IP non é válido.",
+ "apierror-badmd5": "O código hash MD5 non era incorrecto.",
+ "apierror-badmodule-badsubmodule": "O módulo <kbd>$1</kbd> non ten un submódulo \"$2\".",
+ "apierror-badmodule-nosubmodules": "O módulo <kbd>$1</kbd> non ten submódulos.",
+ "apierror-badparameter": "Valor non válido para o parámetro <var>$1</var>.",
+ "apierror-badquery": "A consulta non é válida.",
+ "apierror-badtimestamp": "Valor \"$2\" non válido para o parámetro de data e hora <var>$1</var>.",
+ "apierror-badtoken": "Identificador CSRF non válido.",
+ "apierror-badupload": "O parámetro de suba de ficheiro <var>$1</var> non é unha suba de ficheiro, asegúrese de usar <code>multipart/form-data</code> para o seu POST e de incluír un nome de ficheiro na cabeceira <code>Content-Disposition</code>.",
+ "apierror-badurl": "Valor \"$2\" non válido para o parámetro de URL <var>$1</var>.",
+ "apierror-baduser": "Valor \"$2\" non válido para o parámetro de usuario <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "O separador multivalor U+001F só pode utilizarse en parámetros multivalorados.",
+ "apierror-bad-watchlist-token": "Identificador de lista de vixilancia proporcionado incorrecto. Por favor, obteña un identificador correcto en [[Special:Preferences]].",
+ "apierror-blockedfrommail": "Foi bloqueado para o envío de correos electrónicos.",
+ "apierror-blocked": "Foi bloqueado fronte á edición.",
+ "apierror-botsnotsupported": "Esta interface non está dispoñible para bots.",
+ "apierror-cannotreauthenticate": "Esta acción non está dispoñible xa que súa identidade non se pode verificar.",
+ "apierror-cannotviewtitle": "Non está autorizado para ver $1.",
+ "apierror-cantblock-email": "Non ten permiso para bloquear ós usuarios o envío de correo electrónico a través da wiki.",
+ "apierror-cantblock": "Non ten permisos para bloquear usuarios.",
+ "apierror-cantchangecontentmodel": "Non ten permiso para cambiar o modelo de contido dunha páxina.",
+ "apierror-canthide": "Non ten permiso para ocultar nomes de usuario do rexistro de bloqueos.",
+ "apierror-cantimport-upload": "Non ten permiso para importar páxinas subidas.",
+ "apierror-cantimport": "Non ten permisos para importar páxinas.",
+ "apierror-cantoverwrite-sharedfile": "O ficheiro obxectivo existe nun repositorio compartido e non ten permiso para substituílo.",
+ "apierror-cantsend": "Non está conectado na súa conta, non ten un enderezo de correo electrónico confirmado, ou non ten permiso para enviar correos electrónicos a outros usuarios, polo que non pode enviar correo electrónico.",
+ "apierror-cantundelete": "Non se puido restaurarː pode que as revisións solicitadas non existan, ou pode que xa se restauraran.",
+ "apierror-changeauth-norequest": "Erro ó crear a petición de modificación.",
+ "apierror-chunk-too-small": "O tamaño mínimo dun segmento é de $1 {{PLURAL:$1|byte|bytes}} para os segmentos non finais.",
+ "apierror-cidrtoobroad": "Os rangos CIDR $1 maiores que /$2 non son aceptados.",
+ "apierror-contentserializationexception": "Erro de serialización do contidoː $1",
+ "apierror-contenttoobig": "O contido que achegou excede o límite de tamaño dun artigo, que é de {{PLURAL:$1|kilobyte|kilobytes}}.",
+ "apierror-copyuploadbaddomain": "As subas por URL non están permitidas para este dominio.",
+ "apierror-copyuploadbadurl": "As subas non están permitidas para esta URL.",
+ "apierror-create-titleexists": "Os títulos existentes non poden ser protexidos con <kbd>create</kbd>.",
+ "apierror-csp-report": "Erro procesando o informe CSPː $1.",
+ "apierror-databaseerror": "[$1] erro de consulta da base de datos.",
+ "apierror-deletedrevs-param-not-1-2": "O parámetro <var>$1</var> non pode usarse nos modos 1 e 2.",
+ "apierror-deletedrevs-param-not-3": "O parámetro <var>$1</var> non pode usarse no modo 3.",
+ "apierror-emptynewsection": "Non é posible crear novas seccións baleiras.",
+ "apierror-emptypage": "Non é posible crear novas páxinas baleiras.",
+ "apierror-exceptioncaught": "[$1] Excepción capturada: $2",
+ "apierror-filedoesnotexist": "O ficheiro non existe.",
+ "apierror-fileexists-sharedrepo-perm": "O ficheiro obxectivo existe nun servidor compartido. Use o parámetro <var>ignorewarnings</var> para ignoralo.",
+ "apierror-filenopath": "Non é posible obter o camiño do ficheiro local.",
+ "apierror-filetypecannotberotated": "O tipo de ficheiro non permite que sexa rotado.",
+ "apierror-formatphp": "Esta resposta non pode ser representada usando kbd>format=php</kbd>. Consulte https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "O título para <kbd>$1</kbd> debe ser un ficheiro.",
+ "apierror-import-unknownerror": "Erro descoñecido ó importarː $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> non pode pasar de $2 (fixado a $3) para bots ou administradores do sistema.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> non pode pasar de $2 (fixado a $3) para os usuarios.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> non pode ser menor de $2 (fixado a $3).",
+ "apierror-invalidcategory": "O nome da categoría que indicou non é válido.",
+ "apierror-invalid-chunk": "O desplazamento máis o segmento actual é maior que o tamaño solicitado do ficheiro.",
+ "apierror-invalidexpiry": "Hora de caducidade incorrecta \"$1\".",
+ "apierror-invalid-file-key": "Non se corresponde cunha clave válida de ficheiro.",
+ "apierror-invalidlang": "Código de lingua incorrecto para o parámetro <var>$1</var>.",
+ "apierror-invalidoldimage": "O parámetro <var>oldimage</var> ten un formato incorrecto.",
+ "apierror-invalidparammix-cannotusewith": "O parámetro <kbd>$1</kbd> non pode usarse xunto con <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "O parámetro <kbd>$1</kbd> só pode usarse xunto con <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd> non se pode combinar cos parámetros <var>oldid</var>, <var>pageid</var> e <var>page</var>. Por favor, utilice <var>title</var> e <var>text</var>.",
+ "apierror-invalidparammix": "{{PLURAL:$2|Os parámetros}} $1 non poden usarse xuntos.",
+ "apierror-invalidsection": "O parámetro <var>section</var> debe ser un ID de sección válido ou <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "O código hash SHA1Base36 proporcionado non é correcto.",
+ "apierror-invalidsha1hash": "O código hash SHA1 proporcionado non é correcto.",
+ "apierror-invalidtitle": "Título incorrecto \"$1\".",
+ "apierror-invalidurlparam": "Valor non válido para <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Nome de usuario incorrecto \"$1\".",
+ "apierror-invaliduserid": "O identificador de usuario <var>$1</var> non é válido.",
+ "apierror-maxlag-generic": "Esparando por un servidor de base de datosː $1 {{PLURAL:$1|segundo|segundos}} de atraso.",
+ "apierror-maxlag": "Esperando por $2: $1 {{PLURAL:$1|segundo|segundos}} de atraso.",
+ "apierror-mimesearchdisabled": "A busca MIME está desactivada no modo Miser (avaro).",
+ "apierror-missingcontent-pageid": "Falta contido para a páxina con identificador $1.",
+ "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|O parámetro|Polo menos un dos parámetros}} $1 é necesario.",
+ "apierror-missingparam-one-of": "{{PLURAL:$2|O parámetro|Un dos parámetros}} $1 é necesario.",
+ "apierror-missingparam": "O parámetro <var>$1</var> debe estar definido.",
+ "apierror-missingrev-pageid": "Non hai ningunha revisión actual da páxina con ID $1.",
+ "apierror-missingtitle-createonly": "Os títulos faltantes só se poden protexer con <kbd>create</kbd>.",
+ "apierror-missingtitle": "A páxina que especificou non existe.",
+ "apierror-missingtitle-byname": "A páxina $1 non existe.",
+ "apierror-moduledisabled": "O módulo <kbd>$1</kbd> foi deshabilitado.",
+ "apierror-multival-only-one-of": "Só {{PLURAL:$3|se permite o valor|se permiten os valores}} $2 para o parámetro <var>$1</var>.",
+ "apierror-multival-only-one": "Só se permite un valor para o parámetro <var>$1</var>.",
+ "apierror-multpages": "<var>$1</var> non se pode utilizar máis que con unha soa páxina.",
+ "apierror-mustbeloggedin-changeauth": "Debe estar conectado para poder cambiar os datos de autentificación.",
+ "apierror-mustbeloggedin-generic": "Debe estar conectado.",
+ "apierror-mustbeloggedin-linkaccounts": "Debe estar conectado para ligar contas.",
+ "apierror-mustbeloggedin-removeauth": "Debe estar conectado para borrar datos de autentificación.",
+ "apierror-mustbeloggedin": "Debe estar conectado para $1.",
+ "apierror-mustbeposted": "O módulo <kbd>$1</kbd> require unha petición POST.",
+ "apierror-mustpostparams": "{{PLURAL:$2|Atopouse o seguinte parámetro|Atopáronse os seguintes parámetros}} na cadea da consulta, pero deben estar no corpo do POST: $1.",
+ "apierror-noapiwrite": "A edición deste wiki a través da API está deshabilitada. Asegúrese de que a declaración <code>$wgEnableWriteAPI=true;</code> está incluída no ficheiro <code>LocalSettings.php</code> da wiki.",
+ "apierror-nochanges": "Non se solicitou ningún cambio.",
+ "apierror-nodeleteablefile": "Non existe esa versión antiga do ficheiro.",
+ "apierror-no-direct-editing": "A edición directa a través da API non é compatible co modelo de contido $1 utilizado por $2.",
+ "apierror-noedit-anon": "Os usuarios anónimos non poden editar páxinas.",
+ "apierror-noedit": "Non ten permisos para editar páxinas.",
+ "apierror-noimageredirect-anon": "Os usuarios anónimos non poden crear redireccións de imaxes.",
+ "apierror-noimageredirect": "Non ten permiso para crear redireccións de imaxes.",
+ "apierror-nosuchlogid": "Non hai ningunha entrada de rexistro con identificador $1.",
+ "apierror-nosuchpageid": "Non hai ningunha páxina con identificador $1.",
+ "apierror-nosuchrcid": "Non hai ningún cambio recente con identificador $1.",
+ "apierror-nosuchrevid": "Non hai ningunha revisión con identificador $1.",
+ "apierror-nosuchsection": "Non hai ningunha sección $1.",
+ "apierror-nosuchsection-what": "Non hai ningunha sección $1 en $2.",
+ "apierror-nosuchuserid": "Non hai ningún usuario con identificador $1.",
+ "apierror-notarget": "Non indicou un destino válido para esta acción.",
+ "apierror-notpatrollable": "A revisión r$1 non pode patrullarse por ser demasiado antiga.",
+ "apierror-nouploadmodule": "Non se definiu un módulo de carga.",
+ "apierror-offline": "Non se pode continuar debido a problemas de conectividade da rede. Asegúrese de que ten unha conexión activa a internet e inténteo de novo.",
+ "apierror-opensearch-json-warnings": "Non se poden representar os avisos en formato JSON de OpenSearch.",
+ "apierror-pagecannotexist": "O espazo de nomes non permite as páxinas actuais.",
+ "apierror-pagedeleted": "A páxina foi borrada dende que obtivo o selo de tempo.",
+ "apierror-pagelang-disabled": "Neste wiki non se pode cambiar a lingua dunha páxina.",
+ "apierror-paramempty": "O parámetro <var>$1</var> non pode estar baleiro.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> só está soportado para o contido wikitexto.",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> só está soportado para o contido wikitexto. $1 usa o modelo de contido $2.",
+ "apierror-pastexpiry": "A tempo de caducidade \"$1\" está no pasado.",
+ "apierror-permissiondenied": "Non ten permiso para $1.",
+ "apierror-permissiondenied-generic": "Permisos rexeitados.",
+ "apierror-permissiondenied-patrolflag": "Necesita o permiso <code>patrol</code> ou <code>patrolmarks</code> para solicitar a marca de patrullado.",
+ "apierror-permissiondenied-unblock": "Non ten permiso para desbloquear usuarios.",
+ "apierror-prefixsearchdisabled": "A busca de prefixo está desactivada no modo Miser (avaro).",
+ "apierror-promised-nonwrite-api": "A cabeceira HTTP <code>Promise-Non-Write-API-Action</code> non se pode enviar a módulos da API en modo escritura.",
+ "apierror-protect-invalidaction": "Tipo de protección \"$1\" non válido.",
+ "apierror-protect-invalidlevel": "Nivel de protección \"$1\" non válido.",
+ "apierror-ratelimited": "Superou o seu límite de rango. Agarde uns minutos e inténteo de novo",
+ "apierror-readapidenied": "Necesita permiso de lectura para utilizar ese módulo.",
+ "apierror-readonly": "A wiki está actualmente en modo de só lectura.",
+ "apierror-reauthenticate": "Non se autentificou recentemente nesta sesión. Por favor, volva a autentificarse.",
+ "apierror-revdel-mutuallyexclusive": "Non se pode usar o mesmo campo en <var>hide</var> e <var>show</var>.",
+ "apierror-revdel-needtarget": "É necesario un título obxectivo para este tipo RevDel.",
+ "apierror-revdel-paramneeded": "Requírese polo menos un valor para <var>hide</var> e/ou <var>show</var>.",
+ "apierror-revisions-badid": "Non se atoparon modificacións para o parámetro <var>$1</var>.",
+ "apierror-revisions-norevids": "O parámetro <var>revids</var> non se pode utilizar xunto coas opción de lista (<var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> e <var>$1end</var>).",
+ "apierror-revisions-singlepage": "Utilizouse <var>titles</var>, <var>pageids</var> ou un xerador para proporcionar múltiples páxinas, pero os parámetros <var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> e <var>$1end</var> só poden utilizarse nunha soa páxina.",
+ "apierror-revwrongpage": "r$1 non é unha revisión de $2.",
+ "apierror-searchdisabled": "A busca <var>$1</var> está desactivada.",
+ "apierror-sectionreplacefailed": "Non se puido combinar a sección actualizada.",
+ "apierror-sectionsnotsupported": "As seccións non son compatibles co modelo de contido $1.",
+ "apierror-sectionsnotsupported-what": "As seccións non son compatibles con $1.",
+ "apierror-show": "Parámetro incorrecto - non se poden proporcionar valores mutuamente excluíntes.",
+ "apierror-siteinfo-includealldenied": "Non se pode ver a información de tódolos servidores a menos que <var>$wgShowHostNames</var> teña valor verdadeiro.",
+ "apierror-sizediffdisabled": "A diferenza de tamaño está deshabilitada no modo Miser.",
+ "apierror-spamdetected": "A súa edición foi rexeitada por conter un fragmento de publicidade: <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "Non ten permiso para ver os resultados desta páxina especial.",
+ "apierror-stashedfilenotfound": "Non se puido atopar o ficheiro na reserva: $1.",
+ "apierror-stashfailed-complete": "A suba por partes completouse, revise o estado para obter máis detalles.",
+ "apierror-stashfailed-nosession": "Non hai sesión de suba por partes con esa clave.",
+ "apierror-stashfilestorage": "Non se puido almacenar a suba na reservaː $1",
+ "apierror-stashinvalidfile": "Ficheiro de reserva incorrecto.",
+ "apierror-stashnosuchfilekey": "A chave de ficheiro non existe: $1.",
+ "apierror-stashpathinvalid": "Clave de ficheiro con formato incorrecto ou non válidaː $1.",
+ "apierror-stashwrongowner": "Erro de propietarioː $1",
+ "apierror-stashzerolength": "Ficheiro de lonxitude cero, non pode ser almacenado na reservaː $1.",
+ "apierror-systemblocked": "Foi bloqueado automaticamente polo software MediaWiki.",
+ "apierror-templateexpansion-notwikitext": "A expansión de modelos só é compatible co contido en wikitexto. $1 usa o modelo de contido $2.",
+ "apierror-timeout": "O servidor non respondeu no tempo esperado.",
+ "apierror-unknownaction": "A acción especificada, <kbd>$1</kbd>, non está recoñecida.",
+ "apierror-unknownerror-editpage": "Erro descoñecido EditPageː $1.",
+ "apierror-unknownerror-nocode": "Erro descoñecido.",
+ "apierror-unknownerror": "Erro descoñecido: \"$1\".",
+ "apierror-unknownformat": "Formato descoñecido \"$1\".",
+ "apierror-unrecognizedparams": "{{PLURAL:$2|Parámetro non recoñecido|Parámetros non recoñecidos}}: $1.",
+ "apierror-unrecognizedvalue": "Valor non recoñecido para o parámetro <var>$1</var>: $2.",
+ "apierror-unsupportedrepo": "O repositorio local de ficheiros non permite consultar tódalas imaxes.",
+ "apierror-upload-filekeyneeded": "Debe proporcionar un <var>filekey</var> cando <var>offset</var> é distinto de cero.",
+ "apierror-upload-filekeynotallowed": "Non pode proporcionar <var>filekey</var> cando <var>offset</var> é 0.",
+ "apierror-upload-inprogress": "A suba dende a reserva está en progreso.",
+ "apierror-upload-missingresult": "Non hai resultado nos datos de estado.",
+ "apierror-urlparamnormal": "Non se puideron normalizar os parámetros de imaxe de $1.",
+ "apierror-writeapidenied": "Non ten permiso para editar este wiki a través da API.",
+ "apiwarn-alldeletedrevisions-performance": "Para ter un mellor rendemento á hora de xerar títulos, estableza <kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "Non se puido analizar <var>$1urlparam</var> para $2. Só se usará a anchura e a altura.",
+ "apiwarn-badutf8": "O valor pasado para <var>$1</var> contén datos non válidos ou non normalizados. Os datos de texto deberían estar en formato Unicode válido, normalizado en NFC e sen caracteres de control C0 distintos de HT (\\t), LF (\\n) e CR (\\r).",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> quedou obsoleto. No seu lugar, utilice <kbd>prop=deletedrevisions</kbd> ou <kbd>list=alldeletedrevisions</kbd>.",
+ "apiwarn-deprecation-expandtemplates-prop": "Como non se especificou ningún valor para o parámetro <var>prop</var>, utilizouse un formato herdado para a saída. Este formato está en desuso e, no futuro, o parámetro <var>prop</var> terá un valor predeterminado, de forma que sempre se utilizará o formato novo.",
+ "apiwarn-deprecation-httpsexpected": "Utilizouse HTTP cando esperábase HTTPS.",
+ "apiwarn-deprecation-login-botpw": "O inicio de sesión coa conta principal mediante <kbd>action=login</kbd> está en desuso e pode deixar de funcionar sen aviso previo. Para proseguir o inicio de sesión mediante <kbd>action=login</kbd>, consulte [[Special:BotPasswords]]. Para proseguir o inicio de sesión coa conta principal de forma segura, consulte <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "O inicio de sesión coa conta principal mediante <kbd>action=login</kbd> está en desuso e pode deixar de funcionar sen aviso previo. Para iniciar sesión de forma segura, consulte <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-parameter": "O parámetro <var>$1</var> está obsoleto.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> está en desuso desde MediaWiki 1.28. Use <kbd>prop=headhtml</kbd> cando cree novos documentos HTML, ou <kbd>prop=módulos|jsconfigvars</kbd> cando actualice un documento no lado do cliente.",
+ "apiwarn-deprecation-purge-get": "O uso de <kbd>action=purge</kbd> mediante GET está obsoleto. Use POST no seu lugar.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> está obsoleto. No seu lugar, utilice <kbd>$2</kbd>.",
+ "apiwarn-difftohidden": "Imposible facer un diff con r$1: o contido está oculto.",
+ "apiwarn-invalidcategory": "\"$1\" non é unha categoría.",
+ "apiwarn-invalidtitle": "\"$1\" non é un título válido.",
+ "apiwarn-invalidxmlstylesheetext": "As follas de estilo deben ter a extensión <code>.xsl</code>.",
+ "apiwarn-invalidxmlstylesheet": "A folla de estilos especificada non é válida ou non existe.",
+ "apiwarn-invalidxmlstylesheetns": "A folla de estilos debería estar no espazo de nomes {{ns:MediaWiki}}.",
+ "apiwarn-moduleswithoutvars": "A propiedade <kbd>modules</kbd> está definida, pero non o está <kbd>jsconfigvars</kbd> nin <kbd>encodedjsconfigvars</kbd>. As variables de configuración son necesarias para o correcto uso do módulo.",
+ "apiwarn-notfile": "\"$1\" non é un ficheiro.",
+ "apiwarn-parse-nocontentmodel": "Non se proporcionou <var>title</var> nin <var>contentmodel</var>, asúmese $1.",
+ "apiwarn-tokennotallowed": "A acción \"$1\" non está permitida para o usuario actual.",
+ "apiwarn-toomanyvalues": "Demasiados valores para o parámetro <var>$1</var>. O límite é $2.",
+ "apiwarn-truncatedresult": "Truncouse este resultado porque doutra maneira sobrepasaría o límite de $1 bytes.",
+ "apiwarn-unrecognizedvalues": "{{PLURAL:$3|Valor non recoñecido|Valores non recoñecidos}} para o parámetro <var>$1</var>: $2.",
+ "apiwarn-unsupportedarray": "O parámetro <var>$1</var> usa unha sintaxe PHP de matriz que non está soportada.",
+ "apiwarn-validationfailed-badchars": "caracteres non válidos na clave (só se admiten os caracteres <code>a-z</code>, <code>A-Z</code>, <code>0-9</code>, <code>_</code> e <code>-</code>).",
+ "apiwarn-validationfailed-badpref": "non é unha preferencia válida.",
+ "apiwarn-validationfailed-cannotset": "non pode ser establecido por este módulo.",
+ "apiwarn-validationfailed-keytoolong": "clave demasiado longa (non pode ter máis de $1 bytes).",
+ "apiwarn-validationfailed": "Erro de validación de <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>Aviso de seguridade</strong>: <var>$wgDebugAPI</var> está habilitado.",
+ "api-feed-error-title": "Erro ($1)",
+ "api-usage-docref": "Consulte $1 para ver o uso da API.",
+ "api-usage-mailinglist-ref": "Subscribirse á lista de correo mediawiki-api-announce en &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; para obter avisos de obsolescencia da API ou de modificacións importantes.",
+ "api-exception-trace": "$1 en $2($3)\n$4",
+ "api-credits-header": "Créditos",
+ "api-credits": "Desenvolvedores da API:\n* Roan Kattouw (desenvolvedor principal, set. 2007-2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (creador e desenvolvedor principal, set. 2006-sep. 2007)\n* Brad Jorsch (desenvolvedor principal, 2013-actualidade)\n\nEnvía comentarios, suxerencias e preguntas a mediawiki-api@lists.wikimedia.org\nou informa dun erro en https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/he.json b/www/wiki/includes/api/i18n/he.json
new file mode 100644
index 00000000..f9016c86
--- /dev/null
+++ b/www/wiki/includes/api/i18n/he.json
@@ -0,0 +1,1753 @@
+{
+ "@metadata": {
+ "authors": [
+ "Guycn2",
+ "Amire80",
+ "Inkbug",
+ "Danny-w",
+ "YaronSh",
+ "ערן",
+ "LaG roiL",
+ "Elyashiv",
+ "Macofe",
+ "MojoMann",
+ "Mikey641",
+ "Esh77",
+ "שמזן",
+ "Or",
+ "Umherirrender"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|תיעוד]]\n* [[mw:Special:MyLanguage/API:FAQ|שו\"ת]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api רשימת דיוור]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce הודעות על API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R באגים ובקשות]\n</div>\n<strong>מצב:</strong> כל האפשרויות שמוצגות בדף הזה אמורות לעבוד, אבל ה־API עדיין בפיתוח פעיל, ויכול להשתנות בכל זמן. עשו מינוי ל[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ רשימת הדיוור mediawiki-api-announce] להודעות על עדכונים.\n\n<strong>בקשות שגויות:</strong> כשבקשות שגויות נשלחות ל־API, תישלח כותרת HTTP עם המפתח \"MediaWiki-API-Error\" ואז גם הערך של הכותרת וגם קוד השגיאה יוגדרו לאותו ערך. למידע נוסף ר' [[mw:Special:MyLanguage/API:Errors_and_warnings|API: שגיאות ואזהרות]].\n\n<strong>בדיקה:</strong> לבדיקה קלה יותר של בקשות ר' [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "איזו פעולה לבצע.",
+ "apihelp-main-param-format": "תסדיר הפלט.",
+ "apihelp-main-param-maxlag": "שיהוי מרבי יכול לשמש כשמדיה־ויקי מותקנת בצביר עם מסד נתונים משוכפל. כדי לחסוך בפעולות שגורמות יותר שיהוי בשכפול אתר, הפרמטר הזה יכול לגרום ללקוח להמתין עד ששיהוי השכפול יורד מתחת לערך שצוין. במקרה של שיהוי מוגזם, קוד השגיאה <samp>maxlag</samp> מוחזר עם הודעה כמו <samp>Waiting for $host: $lag seconds lagged</samp>.<br />ר' [[mw:Special:MyLanguage/Manual:Maxlag_parameter|מדריך למשתמש: פרמטר maxlag]] למידע נוסף.",
+ "apihelp-main-param-smaxage": "הגדרת כותרת בקרת מטמון HTTP‏ <code>s-maxage</code> למספר כזה של שניות.",
+ "apihelp-main-param-maxage": "הגדרת כותרת בקרת מטמון HTTP‏ <code>max-age</code> למספר כזה של שניות.",
+ "apihelp-main-param-assert": "לוודא שהמשתמש נכנס אם זה מוגדר ל־<kbd>user</kbd>, או שיש לו הרשאת בוט אם זה <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "לוודא שהמשתמש הנוכחי הוא המשתמש ששמו ניתן.",
+ "apihelp-main-param-requestid": "כל ערך שיינתן כאן ייכלל בתשובה. אפשר להשתמש בזה כדי להבדיל בין בקשות.",
+ "apihelp-main-param-servedby": "לכלול את שם המארח ששירת את הבקשה בתוצאות.",
+ "apihelp-main-param-curtimestamp": "הכללת חותם־הזמן הנוכחי בתוצאה.",
+ "apihelp-main-param-responselanginfo": "לכלול את השפות שמשמשות ל־<var>uselang</var> ול־<var>errorlang</var> בתוצאה.",
+ "apihelp-main-param-origin": "בעת גישה ל־API עם בקשת AJAX חוצה מתחמים (CORS), יש להציב כאן את המתחם שהבקשה יוצאת ממנו. זה היה להיות כלול בכל בקשה מקדימה, ולכן הוא חייב להיות חלק מה־URI של הבקשה (לא גוף ה־POST).\n\nעבור בקשות מאומתות, זה חייב להיות תואם במדויק לאחד המקורות בכותרת <code>Origin</code>, כך שזה צריך להיות מוגדר למשהו כמו <kbd>https://en.wikipedia.org</kbd> או <kbd>https://meta.wikimedia.org</kbd>. אם הפרמטר הזה אינו תואם לכותרת <code>Origin</code>, תוחזר תשובת 403. אם הפרמטר הזה תורם לכותרת <code>Origin</code> והמקור נמצא ברשימה הלבנה, תוגדרנה הכותרות <code>Access-Control-Allow-Origin</code> ו־<code>Access-Control-Allow-Credentials</code>.\n\nעבור בקשות בלתי־מאומתות, יש לציין את הערך <kbd>*</kbd>. זה יגרום לכותרת להיות <code>Access-Control-Allow-Origin</code>, אבל <code>Access-Control-Allow-Credentials</code> תהיה <code>false</code> וכל הנתונים הייחודיים למשתמש יהיו מוגבלים.",
+ "apihelp-main-param-uselang": "באיזו שפה להשתמש לתרגומי הודעות. הקריאה <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> עם <kbd>siprop=languages</kbd> מחזירה רשימת קודים. ציון <kbd>user</kbd> כדי להשתמש בהעדפת השפה של המשתמש הנוכחי, וציון <kbd>content</kbd> להשתמש בקוד השפה של הוויקי הזה.",
+ "apihelp-main-param-errorformat": "תסדיר לשימוש בפלט טקסט אזהרות ושגיאות.\n; plaintext: קוד ויקי ללא תגי HTML ועם ישויות מוחלפות.\n; wikitext: קוד ויקי לא מפוענח.\n; html: קוד HTML.\n; raw: מפתח הודעה ופרמטרים.\n; none: ללא פלט טקסט, רק הודעות השגיאה.\n; bc: התסדיר ששימש לפני מדיה־ויקי 1.29. התעלמות מ־<var>errorlang</var> ו־ <var>errorsuselocal</var>.",
+ "apihelp-main-param-errorlang": "השפה שתשמש לאזהרות לשגיאות <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> עם <kbd>siprop=languages</kbd> תחזיר רשימת קודי שפה, ואפשר גם לציין <kbd>content</kbd> כדי להשתמש בשפת התוכן של הוויקי הזה, או לציין <kbd>uselang</kbd> עם אותו הערך הפרמטר <var>uselang</var>.",
+ "apihelp-main-param-errorsuselocal": "אם ניתן, הטקסטים של השגיאות ישתמשו בהודעות מותאמות מקומית ממרחב השם {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "חסימת משתמש.",
+ "apihelp-block-param-user": "שם משתמש, כתובת IP, או טווח כתובות IP שברצונך לחסום. אי־אפשר להשתמש בזה יחד עם <var>$1userid</var>",
+ "apihelp-block-param-userid": "מזהה המשתמש לחסימה. לא יכול לשמש יחד עם <var>$1user</var>.",
+ "apihelp-block-param-expiry": "זמן תפוגה. יכול להיות יחסי (למשל <kbd>5 months</kbd> או <kbd>2 weeks</kbd>) או מוחלט (למשל <kbd>2014-09-18T12:34:56Z</kbd>). אם זה מוגדר ל־<kbd>infinite</kbd>‏, <kbd>indefinite</kbd>, או <kbd>never</kbd>, החסימה לא תפוג לעולם.",
+ "apihelp-block-param-reason": "סיבה לחסימה.",
+ "apihelp-block-param-anononly": "לחסום משתמשים אלמוניים בלבד (דהיינו, השבתת עריכות אלמוניות מכתובת ה־IP הזאת)",
+ "apihelp-block-param-nocreate": "מניעת יצירת חשבונות",
+ "apihelp-block-param-autoblock": "חסימה אוטומטית גם של כתובת ה־IP האחרונה שהשתמש בה ושל כל כתובת IP שינסה להשתמש בה בעתיד.",
+ "apihelp-block-param-noemail": "למנוע ממשתמש לשלוח דואר אלקטרוני דרך הוויקי. (דורש את ההרשאה <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "הסרת השם מיומן החסימות. (דורש את ההרשאה <code>hideuser</code>.)",
+ "apihelp-block-param-allowusertalk": "לאפשר למשתמש לערוך את דף השיחה שלו או שלה (תלוי ב־<var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "אם המשתמש כבר חסום, לדרוס את החסימה הנוכחית.",
+ "apihelp-block-param-watchuser": "לעקוב אחרי דף המשתמש ודף השיחה של המשתמש או של כתובת ה־IP.",
+ "apihelp-block-param-tags": "תגי שינוי שיחולו על העיול ביומן החסימה.",
+ "apihelp-block-example-ip-simple": "חסימת כתובת ה־IP‏ <kbd>192.0.2.5</kbd> לשלושה ימים עם הסיבה <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "חסימת המשתמש <kbd>Vandal</kbd> ללא הגבלת זמן עם הסיבה <kbd>Vandalism</kbd>, ומניעת יצירת חשבונות חדשים ושליחת דוא\"ל.",
+ "apihelp-changeauthenticationdata-summary": "שינוי נתוני אימות עבור המשתמש הנוכחי.",
+ "apihelp-changeauthenticationdata-example-password": "ניסיון לשנות את הססמה של המשתמש הנוכחי ל־<kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "בדיקת התקינות של האסימון מ־<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "סוג האסימון שבבדיקה.",
+ "apihelp-checktoken-param-token": "איזה אסימון לבדוק.",
+ "apihelp-checktoken-param-maxtokenage": "הגיל המרבי המותר של האסימון, בשניות.",
+ "apihelp-checktoken-example-simple": "בדיקת התקינות של אסימון <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "מנקה את דגל <code>hasmsg</code> עבור המשתמש הנוכחי.",
+ "apihelp-clearhasmsg-example-1": "לנקות את דגל <code>hasmsg</code> עבור המשתמש הנוכחי.",
+ "apihelp-clientlogin-summary": "כניסה לוויקי באמצעות זרימה הידודית.",
+ "apihelp-clientlogin-example-login": "תחילת תהליך כניסה לוויקי בתור משתמש <kbd>Example</kbd> עם הססמה <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "המשך כניסה אחרי תשובת <samp>UI</samp> לאימות דו־גורמי, עם <var>OATHToken</var> של <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "קבלת ההבדל בין 2 דפים.",
+ "apihelp-compare-extended-description": "יש להעביר מספר גרסה, כותרת דף או מזהה דף גם ל־\"from\" וגם ל־\"to\".",
+ "apihelp-compare-param-fromtitle": "כותרת ראשונה להשוואה.",
+ "apihelp-compare-param-fromid": "מס׳ זיהוי של הדף הראשון להשוואה.",
+ "apihelp-compare-param-fromrev": "גרסה ראשונה להשוואה.",
+ "apihelp-compare-param-fromtext": "להשתמש בטקסט הזה במקום תוכן הגרסה שהוגדרה על־ידי <var dir=\"ltr\">fromtitle</var>, <var dir=\"ltr\">fromid</var> או <var dir=\"ltr\">fromrev</var>.",
+ "apihelp-compare-param-frompst": "לעשות התמרה לפני שמירה ב־<var>fromtext</var>.",
+ "apihelp-compare-param-fromcontentmodel": "מודל התוכן של <var>fromtext</var>. אם זה לא סופק, ייעשה ניחוש על סמך פרמטרים אחרים.",
+ "apihelp-compare-param-fromcontentformat": "תסדיר הסדרת תוכן של <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "כותרת שנייה להשוואה.",
+ "apihelp-compare-param-toid": "מס׳ מזהה של הדף השני להשוואה.",
+ "apihelp-compare-param-torev": "גרסה שנייה להשוואה.",
+ "apihelp-compare-param-torelative": "להשתמש בגרסה יחסית לגרסה שהוסקה מ<var dir=\"ltr\">fromtitle</var>, <var dir=\"ltr\">fromid</var> או <var dir=\"ltr\">fromrev</var>. לכל אפשריות ה־\"to\" האחרות לא תהיה השפעה.",
+ "apihelp-compare-param-totext": "להשתמש בטקסט הזה במקום התוכן של הגרסה שהוגדר ב־<var dir=\"ltr\">totitle</var>, <var dir=\"ltr\">toid</var> or <var dir=\"ltr\">torev</var>.",
+ "apihelp-compare-param-topst": "לעשות התמרה לפני שמירה ב־<var>totext</var>.",
+ "apihelp-compare-param-tocontentmodel": "מודל התוכן של <var>totext</var>. אם זה לא סופק, ייעשה ניחוש על סמך פרמטרים אחרים.",
+ "apihelp-compare-param-tocontentformat": "תסדיר הסדרת תוכן של <var>fromtext</var>.",
+ "apihelp-compare-param-prop": "אילו פריטי מידע לקבל.",
+ "apihelp-compare-paramvalue-prop-diff": "ה־HTML של ההשוואה.",
+ "apihelp-compare-paramvalue-prop-diffsize": "גודל ה־HTML של ההשוואה, בבתים.",
+ "apihelp-compare-paramvalue-prop-rel": "מזהי הגרסאות של הגרסאות לפני \"from\" ואחרי \"to\", אם יש כאלה.",
+ "apihelp-compare-paramvalue-prop-ids": "מזהי הדף והגרסה של גרסאות ה־\"from\" וה־\"to\".",
+ "apihelp-compare-paramvalue-prop-title": "כותרות הדפים של גרסאות ה־\"from\" וה־\"to\".",
+ "apihelp-compare-paramvalue-prop-user": "השם והמזהה של המשתמש של גרסאות ה־\"from\" וה־\"to\".",
+ "apihelp-compare-paramvalue-prop-comment": "התקציר על גרסאות ה־\"from\" וה־\"to\".",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "התקציר המפוענח על גרסאות ה־\"from\" וה־\"to\".",
+ "apihelp-compare-paramvalue-prop-size": "הגודל של גרסאות ה־\"from\" וה־\"to\".",
+ "apihelp-compare-example-1": "יצירת תיעוד שינוי בין גרסה 1 ל־2.",
+ "apihelp-createaccount-summary": "יצירת חשבון משתמש חדש.",
+ "apihelp-createaccount-param-preservestate": "אם <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> החזיר true עבור <samp>hasprimarypreservedstate</samp>, בקשות שמסומנות בתור <samp>primary-required</samp> אמורות להיות מושמטות. אם מוחזר ערך לא ריק ל־<samp>preservedusername</samp>, שם המשתמש הזה ישמש לפרמטר <var>username</var>.",
+ "apihelp-createaccount-example-create": "תחילת תהליך יצירת המשתמש <kbd>Example</kbd> עם הססמה <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "שם משתמש.",
+ "apihelp-createaccount-param-password": "ססמה (לא ישפיע אם הוגדר <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "שם מתחם לאימות חיצוני (רשות).",
+ "apihelp-createaccount-param-token": "אסימון יצירת חשבון הושג בבקשה הראשונה.",
+ "apihelp-createaccount-param-email": "כתובת הדוא״ל של המשתמש (רשות).",
+ "apihelp-createaccount-param-realname": "השם האמתי של המשתמש (רשות).",
+ "apihelp-createaccount-param-mailpassword": "אם הוגדר ערך כלשהו, תישלח ססמה אקראית אל המשתמש.",
+ "apihelp-createaccount-param-reason": "הסיבה כרשות ליצירת החשבון כפי שתופיע ברישומים.",
+ "apihelp-createaccount-param-language": "קוד השפה שיוגדר כבררת המחדל למשתמש (רשות, בררת המחדל היא שפת התוכן).",
+ "apihelp-createaccount-example-pass": "יצירת המשתמש <kbd>testuser</kbd> עם הססמה <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "יצירת המשתמש <kbd>testmailuser</kbd> ושליחת ססמה שיוצרה אקראית בדוא״ל.",
+ "apihelp-cspreport-summary": "משמש דפדפנים לדיווח הפרות של מדיניות אבטחת תוכן. המודול הזה לעולם לא ישמש אלא אם הוא משמש עם דפדפן תומך CSP.",
+ "apihelp-cspreport-param-reportonly": "לסמן בתור דיווח ממדיניות מנטרת, לא מדיניות כפויה",
+ "apihelp-cspreport-param-source": "מה ייצר את כותרת ה־CSP שייצרה את הדו״ח הזה",
+ "apihelp-delete-summary": "מחיקת דף.",
+ "apihelp-delete-param-title": "כותרת הדף למחיקה. לא ניתן להשתמש בשילוב עם <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "מס׳ הזיהוי של הדף למחיקה. לא ניתן להשתמש בשילוב עם <var>$1title</var>.",
+ "apihelp-delete-param-reason": "סיבת המחיקה. אם לא הוגדרה, תתווסף סיבה שנוצרה אוטומטית.",
+ "apihelp-delete-param-tags": "תגי שינוי שיחולו על העיול ביומן המחיקה.",
+ "apihelp-delete-param-watch": "הוספת הדף לרשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-delete-param-watchlist": "הוספה או הסרה של הדף ללא תנאי מרשימת המעקב של המשתמש הנוכחי, להשתמש בהעדפות או לא לשנות את המעקב.",
+ "apihelp-delete-param-unwatch": "הסרת הדף מרשימת המעקב של של המשתמש הנוכחי.",
+ "apihelp-delete-param-oldimage": "שם התמונה הישנה למחיקה כפי שסופק ל־[[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "מחיקת <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "מחיקת <kbd>Main Page</kbd>. סיבה: <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "היחידה הזאת כובתה.",
+ "apihelp-edit-summary": "יצירה ועריכה של דפים.",
+ "apihelp-edit-param-title": "שם הדף לעריכה. לא לשימוש עם <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "מזהה הדף לעריכה. לא לשימוש עם <var>$1title</var>.",
+ "apihelp-edit-param-section": "מספר הפסקה <kbd>0</kbd> לפסקה העליונה, <kbd>new</kbd> לפסקה חדשה.",
+ "apihelp-edit-param-sectiontitle": "הכותרת לפסקה החדשה.",
+ "apihelp-edit-param-text": "תוכן הדף.",
+ "apihelp-edit-param-summary": "תקציר עריכה. גם כותרת פסקה כש־$1section=new ו־$1sectiontitle אינו מוגדר.",
+ "apihelp-edit-param-tags": "אילו תגי שינוי להחיל על הגרסה.",
+ "apihelp-edit-param-minor": "עריכה משנית.",
+ "apihelp-edit-param-notminor": "שינוי לא משני.",
+ "apihelp-edit-param-bot": "סימון עריכה זו כעריכת בוט.",
+ "apihelp-edit-param-basetimestamp": "חותם־זמן של גרסת הבסיס, משמש לזיהוי התנגשויות עריכה. אפשר לקבל אותו באמצעות [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "חותם־הזמן של תחילת תהליך העריכה, משמש לזיהוי התנגשויות. אפשר לקבל ערך מתאים באמצעות <var>[[Special:ApiHelp/main|curtimestamp]]</var> בעת תחילת תהליך העריכה (למשל בזמן טעינת תוכן הדף לעריכה).",
+ "apihelp-edit-param-recreate": "לעקוב את כל הטעויות על כך שהדף נמחק בינתיים.",
+ "apihelp-edit-param-createonly": "לא לערוך את הדף אם הוא כבר קיים.",
+ "apihelp-edit-param-nocreate": "לזרוק שגיאה אם הדף אינו קיים.",
+ "apihelp-edit-param-watch": "הוספת הדף לרשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-edit-param-unwatch": "הסרת הדף מרשימת המעקב של של המשתמש הנוכחי.",
+ "apihelp-edit-param-watchlist": "להוסיף את הדף לרשימת המעקב של המשתמש הנוכחי או להסיר אותו משם, להשתמש בהעדפות, או לא לשנות את מצב המעקב.",
+ "apihelp-edit-param-md5": "גיבוב MD5 של הפרמטר $1text או צירוף של הפטמטרים $1prependtext ו־$1appendtext. אם זה מוגדר, העריכה לא תיעשה אלא אם כן הגיבוב נכון.",
+ "apihelp-edit-param-prependtext": "הוספת הטקסט הזה לתחילת הדף. דורס את $1text.",
+ "apihelp-edit-param-appendtext": "הוספת הטקסט הזה לסוף הדף. דורס את $1text.\n\nיש להשתמש ב־$1section=new כדי להוסיף פסקה חדשה, ולא בפרמטר הזה.",
+ "apihelp-edit-param-undo": "לבטל את הגרסה הזאת. דורס את $1text‏, $1prependtext ו־$1appendtext.",
+ "apihelp-edit-param-undoafter": "ביטול כל הגרסאות מ־$1undo עד כאן. אם זה לא מוגדר, לבטל רק גרסה אחת.",
+ "apihelp-edit-param-redirect": "לפתור הפניות אוטומטית.",
+ "apihelp-edit-param-contentformat": "תסדיר להסדרת תוכן שמשמש את טקסט הקלט.",
+ "apihelp-edit-param-contentmodel": "מודל התוכן של התוכן החדש.",
+ "apihelp-edit-param-token": "האסימון תמיד צריך להישלח בתור הפרמטר האחרון, או לפחות אחרי הפרמטר $1text parameter.",
+ "apihelp-edit-example-edit": "עריכת דף",
+ "apihelp-edit-example-prepend": "הוספת <kbd>_&#95;NOTOC_&#95;</kbd> לתחילת הדף.",
+ "apihelp-edit-example-undo": "ביטול גרסאות מ־13579 עד 13585 עם תקציר אוטומטי.",
+ "apihelp-emailuser-summary": "שליחת דוא\"ל למשתמש.",
+ "apihelp-emailuser-param-target": "לאיזה משתמש לשלוח דוא\"ל.",
+ "apihelp-emailuser-param-subject": "כותרת נושא.",
+ "apihelp-emailuser-param-text": "גוף הדואר.",
+ "apihelp-emailuser-param-ccme": "שליחת עותק של הדואר הזה אליי.",
+ "apihelp-emailuser-example-email": "שליחת דוא\"ל למשתמש <kbd>WikiSysop</kbd> עם הטקסט <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "הרחבת כל התבניות בתוך קוד הוויקי.",
+ "apihelp-expandtemplates-param-title": "כותרת הדף.",
+ "apihelp-expandtemplates-param-text": "איזה קוד ויקי להמיר.",
+ "apihelp-expandtemplates-param-revid": "מזהה גרסה, עבור <code><nowiki>{{REVISIONID}}</nowiki></code> ומשתנים דומים.",
+ "apihelp-expandtemplates-param-prop": "אילו חלקי מידע לקבל.\n\nיש לשים לכך שאם לא נבחרו ערכים, התוצאה תכיל את קוד הוויקי, אבל הפלט יהיה בתסדיר מיושן.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "קוד הוויקי המורחב.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "קטגוריות כלשהן שקיימות בקלט ואינן מיוצגות בפלט הוויקיטקסט.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "מאפייני דף המוגדרים במילות קסם מורחבות בקוד ויקי.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "האם הפלט הוא נדיף ולא מיועד לשימוש במקום אחר בדף.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "הזמן המרבי שאחריו המטמונים של התוצאה צריכים לפוג.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "כל יחידות ה־ResourceLoader שפונקציות מפענח ביקשו לוסיף לפלט. יש לבקש את <kbd>jsconfigvars</kbd> או את <kbd>encodedjsconfigvars</kbd> יחד עם <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "נותן משתני הגדרות של JavaScript שייחודיים לדף הזה.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "נותן משתני הגדרות של JavaScript שייחודיים לדף הזה בתור מחרוזת JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "עץ פענוח XML של הקלט.",
+ "apihelp-expandtemplates-param-includecomments": "האם לכלול הערות HTML בפלט.",
+ "apihelp-expandtemplates-param-generatexml": "יצירת עץ פענוח XML (מוחלף ב־$1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "להרחיב את קוד הוויקי <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "להחזיר הזנת תרומות משתמש.",
+ "apihelp-feedcontributions-param-feedformat": "תסדיר ההזנה.",
+ "apihelp-feedcontributions-param-user": "לקבל תרומות של אילו משמשים.",
+ "apihelp-feedcontributions-param-namespace": "לפי איזה מרחב שם לסנן את התרומות.",
+ "apihelp-feedcontributions-param-year": "החל משנה (ולפני כן).",
+ "apihelp-feedcontributions-param-month": "החל מחודש (ולפני כן).",
+ "apihelp-feedcontributions-param-tagfilter": "סינון תרומות בעלות התגיות הבאות.",
+ "apihelp-feedcontributions-param-deletedonly": "הצגת תרומות שנמחקו בלבד.",
+ "apihelp-feedcontributions-param-toponly": "הצגת עריכות שהן הגרסה העדכנית ביותר בלבד.",
+ "apihelp-feedcontributions-param-newonly": "להציג רק עריכות שהן יצירות דפים.",
+ "apihelp-feedcontributions-param-hideminor": "להסתיר עריכות משניות.",
+ "apihelp-feedcontributions-param-showsizediff": "להציג את ההבדל בגודל בין גרסאות.",
+ "apihelp-feedcontributions-example-simple": "החזרת תרומות עבור המשתמש <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "להחזיר הזנת שינויים אחרונים.",
+ "apihelp-feedrecentchanges-param-feedformat": "תסדיר ההזנה.",
+ "apihelp-feedrecentchanges-param-namespace": "לאיזה מרחב שם להגביל את התוצאות.",
+ "apihelp-feedrecentchanges-param-invert": "כל מרחבי השם למעט זה שנבחר.",
+ "apihelp-feedrecentchanges-param-associated": "לכלול מרחב שם משויך (שיחה או ראשי).",
+ "apihelp-feedrecentchanges-param-days": "לכמה ימים להגביל את התוצאות.",
+ "apihelp-feedrecentchanges-param-limit": "המספר המרבי של התוצאות להחזיר.",
+ "apihelp-feedrecentchanges-param-from": "להציג תוצאות מאז.",
+ "apihelp-feedrecentchanges-param-hideminor": "הסתרת שינוים משניים.",
+ "apihelp-feedrecentchanges-param-hidebots": "הסתרת שינויים שנעשו על ידי בוטים.",
+ "apihelp-feedrecentchanges-param-hideanons": "הסתרת שינויים שנעשו על ידי אנונימים.",
+ "apihelp-feedrecentchanges-param-hideliu": "הסתרת שינויים שנעשו על ידי משתמשים רשומים.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "הסתרת שינויים שנבדקו.",
+ "apihelp-feedrecentchanges-param-hidemyself": "הסתרת שינוים שנעשו על ידי המשתמש הנוכחי.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "להסתיר שינויים בחברות בקטגוריה.",
+ "apihelp-feedrecentchanges-param-tagfilter": "סינון לפי תגית.",
+ "apihelp-feedrecentchanges-param-target": "הצגת שינויים שנעשו בדפים המקושרים לדף זה בלבד.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "להציג את השינויים בדפים שמקושרים לדף שנבחר במקום זה.",
+ "apihelp-feedrecentchanges-param-categories": "להציג רק שינויים בדפים בכל הקטגוריות האלו.",
+ "apihelp-feedrecentchanges-param-categories_any": "להציג רק שינויים בדפים בכל הקטגוריות במקום.",
+ "apihelp-feedrecentchanges-example-simple": "הצגת שינויים אחרונים.",
+ "apihelp-feedrecentchanges-example-30days": "הצגת שינויים אחרונים עבור 30 ימים.",
+ "apihelp-feedwatchlist-summary": "החזרת הזנת רשימת מעקב.",
+ "apihelp-feedwatchlist-param-feedformat": "תסדיר ההזנה.",
+ "apihelp-feedwatchlist-param-hours": "רשימת דפים ששונו בתוך מספר כזה של שעות מעכשיו.",
+ "apihelp-feedwatchlist-param-linktosections": "לקשר ישר לפסקאות ששונו אם אפשר.",
+ "apihelp-feedwatchlist-example-default": "הצגת הזנת רשימת מעקב.",
+ "apihelp-feedwatchlist-example-all6hrs": "להציג את כל השינויים בדפים שבמעקב ב־6 השעות האחרונות.",
+ "apihelp-filerevert-summary": "לשחזר את הקובץ לגרסה ישנה יותר.",
+ "apihelp-filerevert-param-filename": "שם קובץ היעד, ללא התחילית File:.",
+ "apihelp-filerevert-param-comment": "הערת העלאה.",
+ "apihelp-filerevert-param-archivename": "שם הארכיון של הגרסה שאליה ישוחזר הקובץ.",
+ "apihelp-filerevert-example-revert": "לשחזר את <kbd>Wiki.png</kbd> לגרסה מ־<kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "הצגת עזרה עבור היחידות שצוינו.",
+ "apihelp-help-param-modules": "עזרה של אילו יחידות להציג (ערכים של הפרמטרים <var>action</var> ו־<var>format</var>, או <kbd>main</kbd>). אפשר להגדיר תת־יחידות עם <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "לכלול עזרה לתת־יחידות ליחידה שצוינה.",
+ "apihelp-help-param-recursivesubmodules": "לכלול עזרה לתת־יחידות באופן רקורסיבי.",
+ "apihelp-help-param-helpformat": "תסדיר פלט העזרה.",
+ "apihelp-help-param-wrap": "לעטוף את הפלט במבנה תשובת API תקני.",
+ "apihelp-help-param-toc": "לכלול תוכן עניינים בפלט HTML.",
+ "apihelp-help-example-main": "עזרה ליחידה הראשית.",
+ "apihelp-help-example-submodules": "עזרה עבור <kbd>action=query</kbd> וכל התת־מודולים שלו.",
+ "apihelp-help-example-recursive": "כל העזרה בדף אחד.",
+ "apihelp-help-example-help": "עזרה ליחידת העזרה עצמה.",
+ "apihelp-help-example-query": "עזרה לשתי תת־יחידות של שאילתה.",
+ "apihelp-imagerotate-summary": "סיבוב של תמונה אחת או יותר.",
+ "apihelp-imagerotate-param-rotation": "בכמה מעלות לסובב בכיוון השעון.",
+ "apihelp-imagerotate-param-tags": "אילו תגים להחיל על העיול ביומן ההעלאות.",
+ "apihelp-imagerotate-example-simple": "לסובב את <kbd>File:Example.png</kbd> ב־<kbd>90</kbd> מעלות.",
+ "apihelp-imagerotate-example-generator": "לסובב את כל התמונות ב־<kbd>Category:Flip</kbd> ב־<kbd>180</kbd> מעלות.",
+ "apihelp-import-summary": "לייבא דף מוויקי אחר או מקובץ XML.",
+ "apihelp-import-extended-description": "יש לשים לב לכך שפעולת HTTP POST צריכה להיעשות בתור העלאת קובץ (כלומר, עם multipart/form-data) בזמן שליחת קובץ לפרמטר <var>xml</var>.",
+ "apihelp-import-param-summary": "תקציר ייבוא עיולי יומן.",
+ "apihelp-import-param-xml": "קובץ XML שהועלה.",
+ "apihelp-import-param-interwikisource": "ליבוא בין אתרי ויקי: מאיזה ויקי לייבא.",
+ "apihelp-import-param-interwikipage": "ליבוא בין אתרי ויקי: איזה דף לייבא.",
+ "apihelp-import-param-fullhistory": "ליבוא בין אתרי ויקי: לייבר את ההיסטוריה המלאה, לא רק את הגרסה הנוכחית.",
+ "apihelp-import-param-templates": "ליבוא בין אתרי ויקי: לייבא גם את כל התבניות המוכללות.",
+ "apihelp-import-param-namespace": "לייבא למרחב השם הזה. לא ניתן להשתמש בזה יחד עם <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "לייבא בתור תת־משנה של הדף הזה. לא ניתן להשתמש בזה יחד עם <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "תגי שינוי שיחולו על העיול ביומן הייבוא ולגרסה הריקה בדפים המיובאים.",
+ "apihelp-import-example-import": "לייבא את [[meta:Help:ParserFunctions]] למרחב השם 100 עם היסטוריה מלאה.",
+ "apihelp-linkaccount-summary": "קישור חשבון של ספק צד־שלישי למשתמש הנוכחי.",
+ "apihelp-linkaccount-example-link": "תחילת תהליך הקישור לחשבון מ־<kbd>Example</kbd>.",
+ "apihelp-login-summary": "להיכנס ולקבל עוגיות אימות.",
+ "apihelp-login-extended-description": "הפעולה הזאת צריכה לשמש רק בשילוב [[Special:BotPasswords]]; שימוש לכניסה לחשבון ראשי מיושן ועשוי להיכשל ללא אזהרה. כדי להיכנס בבטחה לחשבון הראשי, יש להשתמש ב־<kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "הפעולה הזאת מיושנת ועשויה להיכשל ללא אזהרה. כדי להיכנס בבטחה, יש להשתמש ב־<kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "שם משתמש.",
+ "apihelp-login-param-password": "ססמה.",
+ "apihelp-login-param-domain": "שם מתחם (רשות).",
+ "apihelp-login-param-token": "אסימון כניסה התקבל בבקשה הראשונה.",
+ "apihelp-login-example-gettoken": "קבלת אסימון כניסה.",
+ "apihelp-login-example-login": "כניסה.",
+ "apihelp-logout-summary": "יציאה וניקוי של נתוני הפעילות.",
+ "apihelp-logout-example-logout": "הוצאת המשתמש הנוכחי.",
+ "apihelp-managetags-summary": "ביצוע פעולות ניהוליות הקשורות בשינוי תגיות.",
+ "apihelp-managetags-param-operation": "איזו פעולה לבצע:\n;create:יצירת תג שינוי חדש לשימוש ידני.\n;delete:הסרת תג שינוי ממסד הנתונים, כולל הסרת התג מכל הגרסאות, עיולי שינויים אחרונים ועיולי יומן שהוא משמש בהן.\n;activate:הפעלת תג שינוי, ואפשור למשתמש להחיל אותו ידנית.\n;deactivate:כיבוי תג שינוי, ומניעה ממשתמשים להחיל אותו ידנית.",
+ "apihelp-managetags-param-tag": "תג ליצירה, מחיקה, הפעלה או כיבוי. ליצירת תג, התג לא צריך להיות קיים. למחיקת תג, התג צריך להיות קיים. להפעלת תג, התג צריך להתקיים ולא להיות בשימוש של הרחבה. לכיבוי תג, התג צריך להיות קיים ומוגדר ידנית.",
+ "apihelp-managetags-param-reason": "סיבה אופציונלית ליצירה, מחיקה, הפעלה או כיבוי של תג.",
+ "apihelp-managetags-param-ignorewarnings": "האם להתעלם מכל האזהרות שמופיעות תוך כדי הפעולה.",
+ "apihelp-managetags-param-tags": "תגי השינוי שיחולו על העיול ביומן ניהול התגים.",
+ "apihelp-managetags-example-create": "יצירת תג בשם <kbd>spam</kbd> עם הסיבה <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "מחיקת התג <kbd>vandlaism</kbd> עם הסיבה <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "הפעלת התג <kbd>spam</kbd> עם הסיבה <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "כיבוי התג <kbd>spam</kbd> עם הסיבה <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "מיזוג גרסאות של דפים.",
+ "apihelp-mergehistory-param-from": "כותרת הדף שההיסטוריה שלו תמוזג. לא ניתן להשתמש בזה יחד עם <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "מזהה הדף שממנו תמוזג ההיסטוריה. לא ניתן להשתמש בזה יחד עם <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "כותרת הדף שההיסטוריה תמוזג אליו. לא ניתן להשתמש בזה יחד עם <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "מזהה הדף שההיסטוריה תמוזג אליו. לא ניתן להשתמש בזה יחד עם <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "חותם־הזמן שהגרסאות עד אליו יועברו מההיסטוריה של דף המקור על ההיסטוריה של דף היעד. אם מושמט, כל ההיסטוריה של דף המקור תמוזג עם דף היעד.",
+ "apihelp-mergehistory-param-reason": "סיבה למיזוג ההיסטוריה.",
+ "apihelp-mergehistory-example-merge": "מיזוג כל ההיסטוריה של <kbd>Oldpage</kbd> אל <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "מיזוג גרסאות הדפים של <kbd>Oldpage</kbd> עד <kbd dir=\"ltr\">2015-12-31T04:37:41Z</kbd> אל <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "העברת עמוד.",
+ "apihelp-move-param-from": "שם הדף ששמו ישונה. לא יכול לשמש יחד עם <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "מזהה הדף של הדף שצריך לשנות את שמו. לא יכול לשמש עם <var>$1from</var>.",
+ "apihelp-move-param-to": "לאיזו כותרת לשנות את שם הדף.",
+ "apihelp-move-param-reason": "הסיבה לשינוי השם.",
+ "apihelp-move-param-movetalk": "שינוי שם דף השיחה, אם הוא קיים.",
+ "apihelp-move-param-movesubpages": "שינוי השמות של דפי־המשנה, אם זה שייך.",
+ "apihelp-move-param-noredirect": "לא ליצור הפניה.",
+ "apihelp-move-param-watch": "הוספת הדף וההפניה לרשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-move-param-unwatch": "הסרת הדף וההפניה מרשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-move-param-watchlist": "הוספה או הסרה של הדף ללא תנאי מרשימת המעקב של המשתמש הנוכחי, להשתמש בהעדפות או לא לשנות את המעקב.",
+ "apihelp-move-param-ignorewarnings": "להתעלם מכל האזהרות.",
+ "apihelp-move-param-tags": "תגי שינוי שיחולו על העיול ביומן ההעברות ולגרסה הריקה בדף היעד.",
+ "apihelp-move-example-move": "העברת <kbd>Badtitle</kbd> ל־<kbd>Goodtitle</kbd> בלי להשאיר הפניה.",
+ "apihelp-opensearch-summary": "חיפוש בוויקי בפרוטוקול OpenSearch.",
+ "apihelp-opensearch-param-search": "מחרוזת לחיפוש.",
+ "apihelp-opensearch-param-limit": "המספר המרבי של התוצאות שתוחזרנה.",
+ "apihelp-opensearch-param-namespace": "שמות מתחם לחיפוש.",
+ "apihelp-opensearch-param-suggest": "לא לעשות דבר אם <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> הוא false.",
+ "apihelp-opensearch-param-redirects": "איך לטפל בהפניות:\n;return:להחזיר את ההפניה עצמה.\n;resolve:להחזיר את דף היעד. יכול להחזיר פחות מ־$1limit תוצאות.\nמסיבות היסטוריות, בררת המחדל היא \"return\" עבור $1format=json ו־\"resolve\" עבור תסדירים אחרים.",
+ "apihelp-opensearch-param-format": "תסדיר הפלט.",
+ "apihelp-opensearch-param-warningsaserror": "אם אזהרות מוּעלות עם <kbd>format=json</kbd>, להחזיר שגיאת API במקום להתעלם מהן.",
+ "apihelp-opensearch-example-te": "חיפוש דפים שמתחילים ב־<kbd>Te</kbd>.",
+ "apihelp-options-summary": "שינוי העדפות של המשתמש הנוכחי.",
+ "apihelp-options-extended-description": "רק אפשרויות שמוגדרות בליבה או באחת מההרחבות המותקנות, או אפשרויות עם מפתחות עם התחילית \"<code dir=\"ltr\">userjs-</code>\" (שמיועדות לשימוש תסריטי משתמשים) יכולות להיות מוגדרות.",
+ "apihelp-options-param-reset": "אתחול ההעדפות לבררות המחדל של האתר.",
+ "apihelp-options-param-resetkinds": "רשימת סוגי אפשרויות לאתחל כאשר מוגדרת האפשרות <var>$1reset</var>.",
+ "apihelp-options-param-change": "רשימת שינויים, בתסדיר name=value (למשל skin=vector). אם לא ניתן ערך, אפילו לא סימן שווה, למשל optionname|otheroption|...‎, האפשרות תאופס לערך בררת המחדל שלה. אם ערך מועבר כלשהו מכיל את תו המקל (<kbd>|</kbd>), יש להשתמש ב[[Special:ApiHelp/main#main/datatypes|מפריד ערכים מרובים חלופי]] בשביל פעולה נכונה.",
+ "apihelp-options-param-optionname": "שם האפשרות שצריך להגדיר לערך שניתן ב־<var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "ערך האפשרות שצוין ב־<var>$1optionname</var>.",
+ "apihelp-options-example-reset": "אתחול כל ההעדפות.",
+ "apihelp-options-example-change": "לשנות את ההעדפות <kbd>skin</kbd> ו־<kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "לאתחל את כל ההעדפות ואז להגדיר את <kbd>skin</kbd> ואת <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "קבלת מידע על יחידות של API.",
+ "apihelp-paraminfo-param-modules": "רשימה של שמות יחידות (ערכים של הפרמטרים <var>action</var> ו־<var>format</var>, או <kbd>main</kbd>). אפשר להגדיר תת־יחידות עם <kbd>+</kbd>, או כל התת־מודולים עם <kbd dir=\"ltr\">+*</kbd>, או כל התת־מודולים באופן רקורסיבי עם <kbd dir=\"ltr\">+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "תסדיר מחרוזות העזרה.",
+ "apihelp-paraminfo-param-querymodules": "רשימת שמות יחידות query (ערך של הפרמטר <var>prop</var>‏, <var>meta</var> או <var>list</var>). יש להשתמש ב־<kbd>$1modules=query+foo</kbd> במקום <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "קבלת מידע עם היחידה הראשית (העליונה). יש להשתמש ב־<kbd>$1modules=main</kbd> במקום זה.",
+ "apihelp-paraminfo-param-pagesetmodule": "קבלת מידע גם על יחידת pageset (שמספק את titles=‎ וידידיו).",
+ "apihelp-paraminfo-param-formatmodules": "רשימת שמות תסדירים (ערכים של הפרמטר <var>format</var>). יש להשתמש ב־<var>$1modules</var> במקום זה.",
+ "apihelp-paraminfo-example-1": "הצגת מידע עבור <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>‏, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>‏, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>‏, ו־<kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "הצגת מידע עבור כל התת־מודולים של <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-summary": "מפענח את התוכן ומחזיר פלט מפענח.",
+ "apihelp-parse-extended-description": "ר' את יחידת ה־prop השיונות של <kbd>[[Special:ApiHelp/query|action=query]]</kbd> כדי לקבל מידע על הגרסה הנוכחית של הדף.\n\nיש מספר דרכים לציין טקסט לפענוח:\n# ציון דף או גרסה באמצעות <var>$1page</var>‏, <var>$1pageid</var>, או <var>$1oldid</var>.\n# ציון התוכן במפורש, באמצעות <var>$1text</var>‏, <var>$1title</var>, ו־<var>$1contentmodel</var>.\n# ציון רק של התקציר לפענוח. ל־<var>$1prop</var> צריך לתת ערך ריק.",
+ "apihelp-parse-param-title": "שם הדף שהטקסט שייך אליו. אם זה מושמט, יש לציין את <var>$1contentmodel</var>, ו־[[API]] ישמש ככותרת.",
+ "apihelp-parse-param-text": "הטקסט לפענוח. יש להשתמש ב־<var>$1title</var> או ב־<var>$1contentmodel</var>.",
+ "apihelp-parse-param-summary": "התקציר שצריך לפענח.",
+ "apihelp-parse-param-page": "פענוח תוכן הדף הזה. לא יכול לשמש יחד עם <var>$1text</var> ו־<var>$1title</var>.",
+ "apihelp-parse-param-pageid": "לפענח את התוכן של הדף הזה. דורס את <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "אם <var>$1page</var> או <var>$1pageid</var> מוגדרים להפניה, לפתור אותה.",
+ "apihelp-parse-param-oldid": "לפענח את התוכן של הגרסה הזאת. דורס את <var>$1page</var> ואת <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "אילו פריטי מידע לקבל:",
+ "apihelp-parse-paramvalue-prop-text": "נותן טקסט מפוענח של קוד הוויקי.",
+ "apihelp-parse-paramvalue-prop-langlinks": "נותן קישורי שפה בקוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-categories": "נותן קטגוריות בקוד ויקי מפוענח.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "נותן את גרסת ה־HTML של הקטגוריות.",
+ "apihelp-parse-paramvalue-prop-links": "נותן קישורים פנימיים בקוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-templates": "נותן תבניות בקוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-images": "נותן תמונות בקוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-externallinks": "מתן קישורים חיצוניים בקוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-sections": "מתן הפסקאות בקוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-revid": "הוספת מזהה הגרסה של הדף המפוענח.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "הוספת הכותרת של קוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-headitems": "נותן פריטים לשים ב־<code>&lt;head&gt;</code> של הדף.",
+ "apihelp-parse-paramvalue-prop-headhtml": "נותן את ה־<code>&lt;head&gt;</code> המפוענח של הדף.",
+ "apihelp-parse-paramvalue-prop-modules": "מתן יחידות ResourceLoader שמשמשות בדף. כדי לטעון, יש להשתמש ב<code dir=\"ltr\">mw.loader.using()</code>. יש לבקש את <kbd>jsconfigvars</kbd> או את <kbd>encodedjsconfigvars</kbd> יחד עם <kbd>modules</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "נותן משתני הגדרות של JavaScript שייחודיים לדף הזה. כדי להחיל, יש להשתמש ב<code dir=\"ltr\">mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "נותן משתני הגדרות של JavaScript שייחודיים לדף הזה בתור מחרוזת JSON.",
+ "apihelp-parse-paramvalue-prop-indicators": "נותן את ה־HTML של מחווני מצב דף שמשמשים בדף.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "מתן קישורי בינוויקי בקוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-wikitext": "מתן קוד הוויקי המקורי שפוענח.",
+ "apihelp-parse-paramvalue-prop-properties": "נותן מאפיינים שונים שמוגדרים בקוד הוויקי המפוענח.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "נותן דו\"ח הגבלות בדרך מובנית. לא נותן שום נתונים כאשר מוגדר <var>$1disablelimitreport</var>.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "נותן את גרסת ה־HTML של דו\"ח ההגבלות. לא נותן שום נתונים כאשר מוגדר <var>$1disablelimitreport</var>.",
+ "apihelp-parse-paramvalue-prop-parsetree": "עץ פענוח XML של תוכן הגרסה (דורש מודל תוכן <code>$1</code>)",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "נותן אזהרות שאירעו בזמן פענוח התוכן.",
+ "apihelp-parse-param-wrapoutputclass": "מחלקה של CSS שתשמש לעטיפת פלט המפענח.",
+ "apihelp-parse-param-pst": "לעשות התמרה לפני שמירה על הקלט לפני פענוחו. תקין רק בשימוש עם טקסט.",
+ "apihelp-parse-param-onlypst": "לעשות התמרה לפני שמירה (pre-save transform‏, PST) על הקלט, אבל לא לפענח אותו. מחזיר את אותו קוד הוויקי אחרי החלת PST. תקף רק בשימוש עם <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "כולל קישור שפה שמספקות הרחבות (לשימוש עם <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "לפענח רק את התוכן של הפסקה שזה מספרה.\n\nכשצוין <kbd>new</kbd>, לפענח את <var>$1text</var> ואת <var>$1sectiontitle</var> כאילו נוספת פסקה חדשה לדף.\n\nמותר להשתמש ב־<kbd>new</kbd> רק בעת שימוש ב־<var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "כותרת פסקה חדשה כאשר <var>section</var> הוא <kbd>new</kbd>.\n\nבניגוד לעריכת דף, זה לא מתגבה ל־<var>summary</var> כשזה מושמט אם ריק.",
+ "apihelp-parse-param-disablelimitreport": "להשמיט את דו\"ח הקדם־מעבד (\"NewPP limit report\") מפלט המפענח.",
+ "apihelp-parse-param-disablepp": "יש להשתמש ב־<var>$1disablelimitreport</var> במקום.",
+ "apihelp-parse-param-disableeditsection": "להשמיט את קישורי עריכת הפסקאות מפלט המפענח.",
+ "apihelp-parse-param-disabletidy": "לא להריץ ניקוי HTML (למשל tidy) על פלט המפענח.",
+ "apihelp-parse-param-generatexml": "יצירת עץ פענוח של XML (נדרש מודל תוכן <code>$1</code>; מוחלף ב־<kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "לפענח במצב תצוגה מקדימה.",
+ "apihelp-parse-param-sectionpreview": "לפענח במצב תצוגה מקדימה של פסקה (מדליק גם את מצב תצוגה מקדימה).",
+ "apihelp-parse-param-disabletoc": "להשמיט את תוכן העניינים בפלט.",
+ "apihelp-parse-param-useskin": "להחיל את העיצוב שנבחר לפלט המפענח. יכול להשפיע על המאפיינים הבאים: <kbd dir=\"ltr\">langlinks</kbd>, <kbd dir=\"ltr\">headitems</kbd>, <kbd dir=\"ltr\">modules</kbd>, <kbd dir=\"ltr\">jsconfigvars</kbd>, <kbd dir=\"ltr\">indicators</kbd>.",
+ "apihelp-parse-param-contentformat": "תסדיר הסדרת תוכן שישמש לטקסט הקלט. תקף רק עם $1text.",
+ "apihelp-parse-param-contentmodel": "מודל התוכן של טקסט הקלט. אם זה מושמט, יש לציין את $1title והערך ההתחלתי יהיה המודל של הכותרת שצוינה. תקין רק כאשר משמש עם $1text.",
+ "apihelp-parse-example-page": "לפענח דף.",
+ "apihelp-parse-example-text": "לפענח קוד ויקי.",
+ "apihelp-parse-example-texttitle": "לפענח קוד, עם ציון כותרת דף.",
+ "apihelp-parse-example-summary": "לפענח תקציר.",
+ "apihelp-patrol-summary": "לנטר דף או גרסה.",
+ "apihelp-patrol-param-rcid": "מזהה שינויים אחרונים לניטור.",
+ "apihelp-patrol-param-revid": "מזהה גרסה לניטור.",
+ "apihelp-patrol-param-tags": "תגי שינוי שיחולו על העיול ביומן הניטור.",
+ "apihelp-patrol-example-rcid": "לנטר רשומה משינויים אחרונים.",
+ "apihelp-patrol-example-revid": "לנטר גרסה.",
+ "apihelp-protect-summary": "לשנות את רמת ההגנה של דף.",
+ "apihelp-protect-param-title": "כותרת הדף להגנה או הסרת הגנה. לא ניתן להשתמש בזה יחד עם $1pageid.",
+ "apihelp-protect-param-pageid": "מזהה הדף להגנה או הסרת הגנה. לא ניתן להשתמש בזה יחד עם $1title.",
+ "apihelp-protect-param-protections": "רשימת רמות הגנה, בתסדיר <kbd>action=level</kbd> (למשל <kbd>edit=sysop</kbd>). רמת <kbd>all</kbd> פירושה שכולם מורשים לבצע את הפעולה, כלומר אין הגנה.\n\n<strong>הערה:</strong> ההגבלות יוסרו מכל הפעולות שלא כתובות ברשימה.",
+ "apihelp-protect-param-expiry": "חותמי־זמן של תפוגה. אם הוגדר רק חותם־זמן אחד, הוא ישמש לכל ההגנות. יש להשתמש ב־<kbd>infinite</kbd>‏, <kbd>indefinite</kbd>‏, <kbd>infinity</kbd>, או <kbd>never</kbd> להגנה שלא פגה לעולם.",
+ "apihelp-protect-param-reason": "סיבה להגנה או הסרת הגנה.",
+ "apihelp-protect-param-tags": "תגי שינוי שיחולו על העיול ביומן ההגנה.",
+ "apihelp-protect-param-cascade": "הפעלת הגנה מדורגת (כלומר, להגן על דפים שמוכללים בדף הזה ועל תמונות שמשמות בו). אין לזה השפעה אם אף אחת מרמות ההגנה שניתנו אינה תומכת בדירוג.",
+ "apihelp-protect-param-watch": "אם זה מוגדר, הוספת הדף שהגנה נוספת אליו או מוסרת ממנו לרשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-protect-param-watchlist": "הוספה או הסרה של הדף ללא תנאי מרשימת המעקב של המשתמש הנוכחי, להשתמש בהעדפות או לא לשנות את המעקב.",
+ "apihelp-protect-example-protect": "הגנה על דף.",
+ "apihelp-protect-example-unprotect": "להסיר את ההגנה מהדף על־ידי הגדרת מגבלות על <kbd>all</kbd> (למשל: כולם מורשים לבצע את הפעולה).",
+ "apihelp-protect-example-unprotect2": "הסרת הגנה מדף על־ידי הגדרה של אפס הגבלות.",
+ "apihelp-purge-summary": "ניקוי המטמון לכותרות שניתנו.",
+ "apihelp-purge-param-forcelinkupdate": "עדכון טבלאות הקישורים.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "עדכון טבלת הקישורים ועדכון טבלאות הקישורים עבור כל דף שמשתמש בדף הזה בתור תבנית.",
+ "apihelp-purge-example-simple": "ניקוי המטמון של הדפים <kbd>Main Page</kbd> ו־<kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "ניקוי 10 הדפים הראשונים במרחב הראשי.",
+ "apihelp-query-summary": "אחזור נתונים ממדיה־ויקי ועליה.",
+ "apihelp-query-extended-description": "כל שינויי הנתונים יצטרכו תחילה להשתמש ב־query כדי לקבל אסימון למניעת שימוש לרעה מאתרים זדוניים.",
+ "apihelp-query-param-prop": "אילו מאפיינים לקבל על הדפים בשאילתה.",
+ "apihelp-query-param-list": "אילו רשימות לקבל.",
+ "apihelp-query-param-meta": "אילו מטא־נתונים לקבל.",
+ "apihelp-query-param-indexpageids": "לכלול פסקת pageids נוספת עם רשימת כל מזהי הדף שהוחזרו.",
+ "apihelp-query-param-export": "יצוא הגרסאות הנוכחיות של כל הדפים הנתונים המחוללים.",
+ "apihelp-query-param-exportnowrap": "להחזיר את ה־XML של היצוא בלי לעטוף אותו בתוצאת XML (אותו תסדיר כמו [[Special:Export]]). אפשר להשתמש בזה רק עם $1export.",
+ "apihelp-query-param-iwurl": "האם לקבל את ה־URL המלא אם הכותרת היא קישור בינוויקי.",
+ "apihelp-query-param-rawcontinue": "להחזיר נתוני <samp>query-continue</samp> גולמיים להמשך.",
+ "apihelp-query-example-revisions": "אחזור [[Special:ApiHelp/query+siteinfo|site info]] ו־[[Special:ApiHelp/query+revisions|revisions]] של <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "אחזור גרסאת של דפים שמתחילים ב־<kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "למנות את כל הקטגוריות.",
+ "apihelp-query+allcategories-param-from": "מאיזו קטגוריה להתחיל למנות.",
+ "apihelp-query+allcategories-param-to": "באיזו קטגוריה להפסיק למנות.",
+ "apihelp-query+allcategories-param-prefix": "חיפוש כל כותרות הקטגוריות שמתחילות בערך הזה.",
+ "apihelp-query+allcategories-param-dir": "באיזה כיוון למיין.",
+ "apihelp-query+allcategories-param-min": "להחזיר רק קטגוריות עם מספר כזה לפחות של חברים.",
+ "apihelp-query+allcategories-param-max": "להחזיר רק קטגוריות עם מספר כזה לכל היותר של חברים.",
+ "apihelp-query+allcategories-param-limit": "כמה קטגוריות להחזיר.",
+ "apihelp-query+allcategories-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "הוספת מספר הדפים בקטגוריה.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "מתייג קטגוריות מוסתרות עם <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "רשימת קטגוריות עם מידע על מספר הדפים בכל אחת מהן.",
+ "apihelp-query+allcategories-example-generator": "אחזור מידע על דף הקטגוריה עצמו עבור קטגוריות שמתחילות ב־<kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "רשימת כל הגרסאות המחוקות על־ידי משתמש או במרחב.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "יכול לשמש רק <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "לא יכול לשמש עם <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "מאיזה חותם־זמן להתחיל למנות.",
+ "apihelp-query+alldeletedrevisions-param-end": "באיזה חותם־זמן להפסיק למנות.",
+ "apihelp-query+alldeletedrevisions-param-from": "להתחיל את הרשימה בשם הזה.",
+ "apihelp-query+alldeletedrevisions-param-to": "להפסיק את הרשימה בכותרת הזאת.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "חיפוש כל שמות הדפים שמתחילים בערך הזה.",
+ "apihelp-query+alldeletedrevisions-param-tag": "לרשום רק גרסאות עם התג הזה.",
+ "apihelp-query+alldeletedrevisions-param-user": "לרשום רק גרסאות מאת המשתמש הזה.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "לא לרשום גרסאות מאת המשתמש הזה.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "לרשום רק דפים במרחב השם הזה.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>לתשומת לבך:</strong> בשל [[mw:Special:MyLanguage/Manual:$wgMiserMode|מצב חיסכון]], שימוש ב־<var>$1user</var> וב־<var>$1namespace</var> ביחד עלול להניב החזרה של פחות מ־<var>$1limit</var> תוצאות לפני המשך; במצבים קיצוניים יכולות להיות מוחזרות אפס תוצאות.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "בעת שימוש בתור מחולל, לחולל כותרת במקום מזהי גרסה.",
+ "apihelp-query+alldeletedrevisions-example-user": "לרשום את 50 התרומות המחוקות האחרונות של משתמש <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "רשימת 50 הגרסאות המחוקות הראשונות במרחב הראשי.",
+ "apihelp-query+allfileusages-summary": "לרשום את כל שימושי הקובץ, כולל בלתי־קיימים.",
+ "apihelp-query+allfileusages-param-from": "מאיזה שם קובץ להתחיל למנות.",
+ "apihelp-query+allfileusages-param-to": "שם הקובץ שהמנייה תסתיים בו.",
+ "apihelp-query+allfileusages-param-prefix": "חיפוש כל שמות הקבצים שמתחילים עם הערך הזה.",
+ "apihelp-query+allfileusages-param-unique": "להציג רק שמות קבצים ייחודיים. לא יכול לשמש עם $1prop=ids.\nבעת שימוש בתור מחולל, נותן דפי יעד במקום דפי מקור.",
+ "apihelp-query+allfileusages-param-prop": "אילו חלקי מידע לכלול:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "הוספת מזהי הדף של הדפים המשתמשים (לא יכול לשמש עם $1unique).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "הוספת שם הקובץ.",
+ "apihelp-query+allfileusages-param-limit": "כמה פריטים להחזיר בסך הכול.",
+ "apihelp-query+allfileusages-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+allfileusages-example-B": "רשימת שמות קבצים, כולל חסרים, עם מזהי הדפים שהם מופיעים בהם, החל מ־<kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "רשימת שמות קבצים ייחודיים.",
+ "apihelp-query+allfileusages-example-unique-generator": "קבלת כל שמות הקבצים, כולל חסרים.",
+ "apihelp-query+allfileusages-example-generator": "קבלת דפים שמכילים את הקבצים.",
+ "apihelp-query+allimages-summary": "למנות את כל התמונות לפי הסדר.",
+ "apihelp-query+allimages-param-sort": "לפי איזה מאפיין למיין.",
+ "apihelp-query+allimages-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+allimages-param-from": "מאיזה שם תמונה להתחיל למנות. יכול לשמש רק עם $1sort=name.",
+ "apihelp-query+allimages-param-to": "כותרת תמונה שבה תסתיים המניי. יכול לשמש רק עם $1sort=name.",
+ "apihelp-query+allimages-param-start": "מאיזה חותם־זמן להתחיל למנות. יכול לשמש רק עם $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "באיזה חותם זמן להפסיק לרשום. אפשר להשתמש בזה רק עם $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "חיפוש כל שמות התמונות שמתחילים בערך הזה. יכול לשמש רק עם $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "להגביל לתמונות עם מספר כזה של בייטים לפחות.",
+ "apihelp-query+allimages-param-maxsize": "להגביל לתמונות עם מספר כזה לכל היותר של בייטים.",
+ "apihelp-query+allimages-param-sha1": "גיבוב SHA1 של תמונה. דריסת $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "גיבוב SHA1 של התמונה בבסיס 36 (הבסיס בו נעשה שימוש במדיה־ויקי).",
+ "apihelp-query+allimages-param-user": "להחזיר רק קבצים שהועלו על־ידי המשתמש הזה. יכול לשמש רק עם $1sort=timestamp. לא יכול לשמש יחד עם $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "איך לסנן קבצים שמעלים בוטים. יכול לשמש רק עם $1sort=timestamp. לא יכול לשמש יחד עם $1user.",
+ "apihelp-query+allimages-param-mime": "אילו סוגי MIME לחבפש, למשל <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "כמה תמונות להחזיר בסך הכול.",
+ "apihelp-query+allimages-example-B": "הצגת רשימה של קבצים שמתחילים באות <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "הצגת רשימת קבצים שהועלו לאחרונה, דומה ל־[[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "להציג רשימה של קבצות שסוג ה־MIME שלהם הוא <kbd>image/png</kbd> או <kbd>image/gif</kbd>.",
+ "apihelp-query+allimages-example-generator": "הצגת מידע על 4 קבצים המתחילים באות <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "למנות את כל הקישורים שמצביעים למרחב שם נתון.",
+ "apihelp-query+alllinks-param-from": "מאיזה שם קישור להתחיל למנות.",
+ "apihelp-query+alllinks-param-to": "כותרת הקישור שהמנייה תסתיים בו.",
+ "apihelp-query+alllinks-param-prefix": "חיפוש כל הכותרות המקושרות שמתחילות בערך הזה.",
+ "apihelp-query+alllinks-param-unique": "להציג רק שמות מקושרים ייחודיים. לא יכול לשמש עם <kbd>$1prop=ids</kbd>.\nבעת שימוש בתור מחולל, נותן דפי יעד במקום דפי מקור.",
+ "apihelp-query+alllinks-param-prop": "אילו חלקי מידע לכלול:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "הוספת מזהי הדף של הדף המקשר (לא יכול לשמש עם <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "הוספת שם הקישור.",
+ "apihelp-query+alllinks-param-namespace": "איזה מרחב שם למנות.",
+ "apihelp-query+alllinks-param-limit": "כמה פריטים להחזיר בסך הכול.",
+ "apihelp-query+alllinks-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+alllinks-example-B": "רשימת כותרות מקושרות, כולל חסרות, עם מזהי הדפים שהן מופיעות בהם, החל מ־<kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "רשימת כותרות מקושרים ייחודיות.",
+ "apihelp-query+alllinks-example-unique-generator": "קבלת כל הכותרות המקושרות, וסימון החסרות.",
+ "apihelp-query+alllinks-example-generator": "קבלת דפים שמכילים את הקישורים.",
+ "apihelp-query+allmessages-summary": "החזרת הודעות מהאתר הזה.",
+ "apihelp-query+allmessages-param-messages": "אילו הודעות לפלוט. כתיבת <kbd>*</kbd> (בררת מחדל) תפלוט את כל ההודעות.",
+ "apihelp-query+allmessages-param-prop": "אלו מאפיינים לקבל.",
+ "apihelp-query+allmessages-param-enableparser": "יש להגדיר כדי להפעיל את המפענח, יעשה קדם־עיבוד לקוד ויקי של ההודעה (יחליף מילות קסם, יטפל בתבניות, וכו').",
+ "apihelp-query+allmessages-param-nocontent": "אם זה מוגדר, לא לכלול את תוכן ההודעות בפלט.",
+ "apihelp-query+allmessages-param-includelocal": "לכלול גם הודעות מקומיות, כלומר הודעות שאינן קיימות בתכנה, אבל כן קיימות במרחב {{ns:MediaWiki}}.\nזה רושם את כל הדפים במרחב {{ns:MediaWiki}}, כך שזה ירשום גם דפים שאינם באמת הודעות, כגון [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "ארגומנטים שיוחלפו לתוך ההודעה.",
+ "apihelp-query+allmessages-param-filter": "החזרה רק של הודעות עם שמות שמכילים את המחרוזת הזאת.",
+ "apihelp-query+allmessages-param-customised": "להחזיר רק הודעות במצב ההתאמה הזה.",
+ "apihelp-query+allmessages-param-lang": "החזרת הודעת בשפה הזאת.",
+ "apihelp-query+allmessages-param-from": "החזרת הודעת החל מההודעה הזאת.",
+ "apihelp-query+allmessages-param-to": "החזרת הודעות עד ההודעה הזאת.",
+ "apihelp-query+allmessages-param-title": "שם דף לשימוש בתור הֶקשר בעת ענוח הודעה (עבור האפשרות $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "החזרת הודעת עם התחילית הזאת.",
+ "apihelp-query+allmessages-example-ipb": "להציג הודעות שמתחילות ב־<kbd dir=\"ltr\">ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "להציג את ההודעות <kbd>august</kbd> ו־<kbd>mainpage</kbd> בגרמנית.",
+ "apihelp-query+allpages-summary": "למנות את כל הדפים לפי הסדר במרחב שם נתון.",
+ "apihelp-query+allpages-param-from": "מאיזה שם דף להתחיל למנות.",
+ "apihelp-query+allpages-param-to": "כותרת הדף שהמנייה תסתיים בו.",
+ "apihelp-query+allpages-param-prefix": "חיפוש כל שמות הדפים שמתחילים בערך הזה.",
+ "apihelp-query+allpages-param-namespace": "איזה מרחב שם למנות.",
+ "apihelp-query+allpages-param-filterredir": "אילו דפים לרשום.",
+ "apihelp-query+allpages-param-minsize": "להגביל לדפים עם מספר כזה לפחות של בייטים.",
+ "apihelp-query+allpages-param-maxsize": "להגביל לדפים שיש בהם לכל היותר מספר כזה של בייטים.",
+ "apihelp-query+allpages-param-prtype": "להגביל רק לדפים מוגנים.",
+ "apihelp-query+allpages-param-prlevel": "לסנו הגנות לפי רמת ההגנה (חייב לשמש עם $1prtype= parameter).",
+ "apihelp-query+allpages-param-prfiltercascade": "לסנן הגנות לפי דירוגיות (לא תקף כאשר $1prtype אינו מוגדר).",
+ "apihelp-query+allpages-param-limit": "כמה דפים להחזיר בסך הכול.",
+ "apihelp-query+allpages-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+allpages-param-filterlanglinks": "סינון על סמך קיום קישורים לשוניים בדף. יש לשים לב לכך שזה אולי לא יתייחס לקישורים לשוניים שנוספו על־ידי הרחבות.",
+ "apihelp-query+allpages-param-prexpiry": "לפי איזו תפוגת הגנה לסנן את הדף הזה:\n;indefinite:לקבל רק דפים מוגנית לצמיתות.\n;definite:לקבל רק דפים עם תפוגת הגנה מוגדרת.\n;all:לקבל דפים עם תפוגת הגנה כלשהי.",
+ "apihelp-query+allpages-example-B": "להציג רשימה של דפים במתחילים באות <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "להציג מידע על 4 דפים שמתחילים באות <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "להציג את תוכן של 2 הדפים הראשונים שמתחילים ב־<kbd>Re</kbd> ושאינם דפי הפניה.",
+ "apihelp-query+allredirects-summary": "רשימה של כל ההפניות למרחב שם.",
+ "apihelp-query+allredirects-param-from": "מאיזו כותרת הפניה להתחיל את מנייה.",
+ "apihelp-query+allredirects-param-to": "כותרת ההפניה שהמנייה תיפסק בה.",
+ "apihelp-query+allredirects-param-prefix": "חיפוש על דפי היעד שמתחילים בערך הזה.",
+ "apihelp-query+allredirects-param-unique": "להציג רק דפים ייחודיים. לא יכול לשמש עם $1prop=ids|fragment|interwiki.\nבעת שימוש בתור מחולל, נותן דפי יעד במקום דפי מקור.",
+ "apihelp-query+allredirects-param-prop": "אילו חלקי מידע לכלול:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "הוספת מזהה הדף של הדף המפנה (לא יכול לשמש עם <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "הוספת כותרת ההפניה.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "הוספת המובאה מההפניה, אם יש (לא יכול לשמש עם <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "הוספת תחילית הבינוויקי מההפניה, אם יש (לא יכול לשמש עם <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "איזה מרחב שם למנות.",
+ "apihelp-query+allredirects-param-limit": "כמה פריטים להחזיר בסך הכול.",
+ "apihelp-query+allredirects-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+allredirects-example-B": "רשימת דפי יעד, כולל חסרים, עם מזהי הדפים שהם מופיעים בהם, החל מ־<kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "רשימת דפי יעד ייחודיים.",
+ "apihelp-query+allredirects-example-unique-generator": "קבלת על דפי היעד, תוך כדי סימון החסרים.",
+ "apihelp-query+allredirects-example-generator": "קבלת דפים שמכילים את ההפניות.",
+ "apihelp-query+allrevisions-summary": "רשימת כל הגרסאות.",
+ "apihelp-query+allrevisions-param-start": "מאיזה חותם־זמן להתחיל למנות.",
+ "apihelp-query+allrevisions-param-end": "באיזה חותם־זמן להפסיק למנות.",
+ "apihelp-query+allrevisions-param-user": "לרשום רק גרסאות מאת המשתמש הזה.",
+ "apihelp-query+allrevisions-param-excludeuser": "לא לרשום גרסאות מאת המשתמש הזה.",
+ "apihelp-query+allrevisions-param-namespace": "לרשום רק דפים במרחב השם הזה.",
+ "apihelp-query+allrevisions-param-generatetitles": "בעת שימוש בתור מחולל, לחולל כותרת במקום מזהי גרסה.",
+ "apihelp-query+allrevisions-example-user": "לרשום את 50 התרומות האחרונות של משתמש <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "רשימת 50 הגרסאות הראשונות במרחב הראשי.",
+ "apihelp-query+mystashedfiles-summary": "קבלת רשימת קבצים בסליק ההעלאה של המשתמש הנוכחי.",
+ "apihelp-query+mystashedfiles-param-prop": "אילו מאפיינים לאחזר עבור הקבצים.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "אחזור גודל הקובץ וממדי התמונה.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "אחזור סוג ה־MIME של הקובץ וסוג המדיה.",
+ "apihelp-query+mystashedfiles-param-limit": "כמה קבצים לקבל.",
+ "apihelp-query+mystashedfiles-example-simple": "לקבל מפתח קובץ, גודל קובץ וגודל בפיקסלים של קבצים בסליק ההעלאה של המשתמש הנוכחי.",
+ "apihelp-query+alltransclusions-summary": "רשימת כל ההכללות (דפים שמוטבעים באמצעות &#123;&#123;x&#125;&#125;), כולל כאלה שאינם קיימים.",
+ "apihelp-query+alltransclusions-param-from": "מאיזו כותרת ההכללה להתחיל למנות.",
+ "apihelp-query+alltransclusions-param-to": "כותרת ההכללה שהמנייה תיפסק בה.",
+ "apihelp-query+alltransclusions-param-prefix": "חיפוש כל הכותרות המוכללות שמתחילות הערך הזה.",
+ "apihelp-query+alltransclusions-param-unique": "להציג רק שמות מוכללים ייחודיים. לא יכול לשמש עם $1prop=ids.\nבעת שימוש בתור מחולל, נותן דפי יעד במקום דפי מקור.",
+ "apihelp-query+alltransclusions-param-prop": "אילו חלקי מידע לכלול:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "הוספת מזהי הדף של הדפים המכלילים (לא יכול לשמש עם $1unique).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "הוספת כותרת ההכללה.",
+ "apihelp-query+alltransclusions-param-namespace": "איזה מרחב שם למנות.",
+ "apihelp-query+alltransclusions-param-limit": "כמה פריטים להחזיר בסך הכול.",
+ "apihelp-query+alltransclusions-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+alltransclusions-example-B": "רשימת כותרות מוכללות, כולל חסרות, עם מזהי הדפים שהן מופיעות בהם, החל מ־<kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "רשימת כותרת מוכללות ייחודיות.",
+ "apihelp-query+alltransclusions-example-unique-generator": "קבלת כל כל הכותרות המוכללות, תוך כדי סימון החסרות.",
+ "apihelp-query+alltransclusions-example-generator": "קבלת דפים שמכילים את ההכללות.",
+ "apihelp-query+allusers-summary": "למנות את כל המשתמשים הרשומים.",
+ "apihelp-query+allusers-param-from": "מאיזה שם משתמש להתחיל למנות.",
+ "apihelp-query+allusers-param-to": "באיזה שם משתמש להפסיק למנות.",
+ "apihelp-query+allusers-param-prefix": "חיפוש כל המשתמשים שמתחילים בערך הזה.",
+ "apihelp-query+allusers-param-dir": "באיזה כיוון למיין.",
+ "apihelp-query+allusers-param-group": "לכלול רק משתמשים בקבוצות הנתונות.",
+ "apihelp-query+allusers-param-excludegroup": "לא לכלול משתמשים בקבוצות הנתונות.",
+ "apihelp-query+allusers-param-rights": "לכלול רק משתמשים עם ההרשאות הנתונות. לא כולל הרשאות שניתנו בקבוצות משתמעות או אוטומטיות כגון *, user או autoconfirmed.",
+ "apihelp-query+allusers-param-prop": "אילו פרטי מידע לכלול:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "הוספת מידע עם החסימה הנוכחית של משתמש.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "הוספת קבוצות שמשתמש חבר בהן. זה משתמש ביותר משאבי דפדפן ויכול להחזיר פחות תוצאות מהמגבלה.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "לרשום את כל הקבוצות שהמשתמש חבר בהן אוטומטית.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "רשימת הההרשאות שיש למשתמש.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "הוספת מונה העריכות של המשתמש.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "הוספת חותם־הזמן של זמן הרישום של המשתמש (יכול להיות ריק).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "הוספת המזהה המרכזי ומצב השיוך למשתמש.",
+ "apihelp-query+allusers-param-limit": "כמה שמות משתמש בסך הכול לשנות.",
+ "apihelp-query+allusers-param-witheditsonly": "לרשום רק משתמשים שעשו עריכות.",
+ "apihelp-query+allusers-param-activeusers": "לרשום רק משתמשים שהיו פעילים {{PLURAL:$1|ביום האחרון|ביומיים האחרונים|ב־$1 הימים האחרונים}}.",
+ "apihelp-query+allusers-param-attachedwiki": "עם <kbd>$1prop=centralids</kbd>, לציין גם האם המשתמש משויך לוויקי עם המזהה הזה.",
+ "apihelp-query+allusers-example-Y": "לרשום משתמשים שמתחילים ב־<kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "אחזור מידע אודות מצב האימות הנוכחי.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "בדיקה האם מצב האימות הנוכחי של המשתמש מספיק בשביל הפעולה הרגישה מבחינת אבטחה שצוינה.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "אחזור מידע על בקשות האימות הדרושות לפעולת האימות המבוקשת.",
+ "apihelp-query+authmanagerinfo-example-login": "אחזור הבקשות שיכולות לשמש לתחילת הכניסה.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "אחזור הבקשות שיכולות לשמש לתחילת הכניסה, עם שדות טופס ממוזגים.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "בדיקה האם האימות מספיק בשביל הפעולה <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "מציאת כל הדפים שמקשרים לדף הנתון.",
+ "apihelp-query+backlinks-param-title": "איזו כותרת לחפש. לא ניתן להשתמש בזה יחד עם <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "מזהה דף לחיפוש. לא ניתן להשתמש בזה יחד עם <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "איזה מרחב שם למנות.",
+ "apihelp-query+backlinks-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+backlinks-param-filterredir": "איך לסנן הפניות. אם זה מוגדר ל־<kbd>nonredirects</kbd> כש־<var>$1redirect</var> מופעל, זה חל רק על הרמה השנייה.",
+ "apihelp-query+backlinks-param-limit": "כמה דפים להחזיר בסך הכול. אם <var>$1redirect</var> מופעל, ההגבלה חלה על כל רמה בנפרד (כלומר יכולות להיות מוחזרות עד <span dir=\"ltr\">2 * <var>$1limit</var></span> תוצאות).",
+ "apihelp-query+backlinks-param-redirect": "אם הדף המקשר הוא הפניה, למצוא גם את כל הדפים שמקשרים לאותה ההפניה. ההגבלה המרבית מוקטנת בחצי.",
+ "apihelp-query+backlinks-example-simple": "הצגת קישורים ל־<kbd>Main Page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "קבל מידע על דפים שמקשרים ל־<kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "לרשום את כל המשתמשים וכתובות ה־IP שנחסמו.",
+ "apihelp-query+blocks-param-start": "מאיזה חותם‏־זמן להתחיל למנות.",
+ "apihelp-query+blocks-param-end": "באיזה חותם זמן להפסיק למנות.",
+ "apihelp-query+blocks-param-ids": "רשימת מזהי חסימות לרשום (לא חובה).",
+ "apihelp-query+blocks-param-users": "רשימת משתמשים לחיפוש (לא חובה).",
+ "apihelp-query+blocks-param-ip": "קבלת כל החסימות שחלות על טווח ה־IP או ה־CIDR הזה, כולל חסימות טווח.\nלא יכול לשמש יחד עם <var>$3users</var>. טווחי CIDR רחבים מ־IPv4/$1 או IPv6/$2 אינם מתקבלים.",
+ "apihelp-query+blocks-param-limit": "המספר המרבי של חסימות לרשום.",
+ "apihelp-query+blocks-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+blocks-paramvalue-prop-id": "הוספת מזהה החסימה.",
+ "apihelp-query+blocks-paramvalue-prop-user": "הוספת שם המשתמש שנחסם.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "הוספת המזהה של המשמש שנחסם.",
+ "apihelp-query+blocks-paramvalue-prop-by": "הוספת שם המשתמש שחסם.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "הוספת מזהה המשתמש שחסם.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "הוספת חותם־הזמן של החסימה.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "הוספת חותם־הזמן של תפוגת החסימה.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "הוספת הסיבה שניתנה לחסימה.",
+ "apihelp-query+blocks-paramvalue-prop-range": "הוספת טווח כתובות ה־IP שהחסימה משפיעה עליהן.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "מתייג את ההחרמה (autoblock‏, anononly, וכו'.).",
+ "apihelp-query+blocks-param-show": "להציג רק פריטים שמתאימים לאמות המידה האלו.\nלמשל, כדי לראות רק חסימות ללא לצמיתות על כתובות IP יש להגדיר <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "רשימת חסימות.",
+ "apihelp-query+blocks-example-users": "רשימת חסימות של המשתמשים <kbd>Alice</kbd> ו־<kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "לרשום את כל הקטגוריות שהדף שייך אליהן.",
+ "apihelp-query+categories-param-prop": "אילו מאפיינים נוספים לקבל עבור כל קטגוריה:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "הוספת מפתח מיון (מחרוזת הקסדצימלית) ותחילית מפתח מיון (החלק הקריא) עבור קטגוריה.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "הוספת חותם־הזמן של יצירת הקטגוריה.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "תיוג קטגוריות שהוסתרו באמצעות <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "איזה סוג של קטגוריות להציג.",
+ "apihelp-query+categories-param-limit": "כמה קטגוריות להחזיר.",
+ "apihelp-query+categories-param-categories": "לרשום רק את הקטגוריות האלו. שימושי לבדיקה עם דף מסוים נמצא בקטגוריה מסוימת.",
+ "apihelp-query+categories-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+categories-example-simple": "קבלת רשימת קטגוריות שהם <kbd>Albert Einstein</kbd> שייך אליהן.",
+ "apihelp-query+categories-example-generator": "קבלת מידע על כל הקטגוריות שמשמשות בדף <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "החזרת מידע על הקטגוריות הנתונות.",
+ "apihelp-query+categoryinfo-example-simple": "קבחצ מידע על <kbd>Category:Foo</kbd> ועל <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "רשימת כל הדפים בקטגוריה נתונה.",
+ "apihelp-query+categorymembers-param-title": "איזו קטגוריה למנות (נדרש). חייב לכלול את התחילית <kbd>{{ns:category}}:</kbd>. לא יכול לשמש יחד עם <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "מזהה הדף של הקטגוריה שצריך למנות. לא יכול לשמש יחד עם <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "אילו חלקי מידע לכלול:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "הוספת מזהה הדף.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "הוספת השם ומזהה מרחב השם של הדף.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "הוספת מפתח המיון שמשמש למיון בקטגוריה (מחרזות הקסדצימלית).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "הוספת מפתח המיון שמשמש למיון בקטגוריה (מחרוזת הקסדצימלית).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "הוספת הסוג שהדף מוין אליו (<samp>page</samp>‏, <samp>subcat</samp> או <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "הוספת חותם־הזמן שבו הדף נכלל.",
+ "apihelp-query+categorymembers-param-namespace": "לכלול רק דפים במרחבי השם האלה. יש לשים לב לכך ש־<kbd>$1type=subcat</kbd> או <kbd>$1type=file</kbd> יכולים לשמש במקום <kbd>$1namespace=14</kbd> או <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-type": "איזה סוג של חברי קטגוריה לכלול. לא תקף כאשר מוגדר <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-limit": "מספר הדפים המרבי שיוחזר.",
+ "apihelp-query+categorymembers-param-sort": "לפי איזה מאפיין למיין.",
+ "apihelp-query+categorymembers-param-dir": "באיזה כיוון למיין.",
+ "apihelp-query+categorymembers-param-start": "מאיזה חותם־זמן להתחיל לרשום. יכול לשמש רק עם <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "באיזה חותם־זמן לסיים לרשום. יכול לשמש רק עם <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "מפתח מיון להתחיל לרשום ממנו, כפי שמוחזר על־ידי <kbd>$1prop=sortkey</kbd. יכול לשמש רק עם <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "מפתח מיון לסיים לרשום ממנו, כפי שמוחזר על־ידי <kbd>$1prop=sortkey</kbd>. יכול לשמש רק עם <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "תחילית מפתח מיון להתחיל לרשום ממנה. יכול לשמש רק עם <kbd>$1sort=sortkey</kbd>. דורס את <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "תחילית מפתח מיון שהרשימה תסתיים <strong>לפניה</strong> (לא <strong>בה</strong>, אם הערך הזה מוגדר, הוא לא ייכלל!). יכול לשמש רק עם $1sort=sortkey. דורס את $1endhexsortkey.",
+ "apihelp-query+categorymembers-param-startsortkey": "כדאי להשתמש ב־$1starthexsortkey במקום.",
+ "apihelp-query+categorymembers-param-endsortkey": "כדאי להשתמש ב־$1endhexsortkey במקום.",
+ "apihelp-query+categorymembers-example-simple": "קבלת עשרת העמודים הראשונים שתחת <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "קבל מידע על הדף עבור 10 הדפים הראשונים ב־<kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "קבלת רשימה של תורמים שנכנסו לחשבון ומניין של תורמים אלמוניים לדף.",
+ "apihelp-query+contributors-param-group": "לכלול רק משתמשים בקבוצות הנתונות. לא כולל קבוצות משתמעות או אוטומטיות כגון *, user או autoconfirmed.",
+ "apihelp-query+contributors-param-excludegroup": "לא לכלול משתמשים בקבוצות הנתונות. לא כולל קבוצות משתמעות או אוטומטיות כגון *, user או autoconfirmed.",
+ "apihelp-query+contributors-param-rights": "לכלול רק משתמשים עם ההרשאות הנתונות. לא כולל הרשאות שניתנו בקבוצות משתמעות או אוטומטיות כגון *, user או autoconfirmed.",
+ "apihelp-query+contributors-param-excluderights": "לא לכלול משתמשים עם ההרשאות הנתונות. לא כולל הרשאות שניתנו בקבוצות משתמעות או אוטומטיות כגון *, user או autoconfirmed.",
+ "apihelp-query+contributors-param-limit": "כמה תורמים להחזיר.",
+ "apihelp-query+contributors-example-simple": "הצגת תורמים לדף <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-summary": "קבלת מידע על גרסה מחוקה.",
+ "apihelp-query+deletedrevisions-extended-description": "יכול לשמש במספר דרכים:\n# קבלת גרסאות מחוקות עבור ערכת דפים, על־ידי הגדרת שמות או מזהי דף. ממוין לפי שם וחותם־זמן.\n# קבלת מידע על ערכת גרסאות מחוקות באמצעות הגדרת המזהים שלהם עם revid־ים. ממוין לפי מזהה גרסה.",
+ "apihelp-query+deletedrevisions-param-start": "מאיזה חותם־זמן להתחיל למנות. לא תקף בעיבוד רשימת מזהי גרסה.",
+ "apihelp-query+deletedrevisions-param-end": "באיזה חותם־זמן להפסיק למנות. לא תקף בעת עיבוד רשימת מזהי גרסה.",
+ "apihelp-query+deletedrevisions-param-tag": "לרשום רק גרסאות עם התג הזה.",
+ "apihelp-query+deletedrevisions-param-user": "לרשום רק גרסאות מאת המשתמש הזה.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "לא לרשום גרסאות מאת המשתמש הזה.",
+ "apihelp-query+deletedrevisions-example-titles": "רשימת גרסאות מחוקות של הדפים <kbd>Main Page</kbd> ו־<kbd>Talk:Main Page</kbd>, עם תוכן.",
+ "apihelp-query+deletedrevisions-example-revids": "קבלת מידע לגרסה המחוקה <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "רשימת גרסאות מחוקות.",
+ "apihelp-query+deletedrevs-extended-description": "פועל בשלושה אופנים:\n# רשימת גרסאות מחוקות לשמות שניתנו, ממוינות לפי חותם־זמן.\n# רשימת תרומות מחוקות של המשתמש שניתן, ממוינות לפי חותם־זמן (בלי לציין שמות).\n# רשימת כל הגרסאות המחוקות במרחב השם שניתן, ממוינות לפי שם וחותם־זמן (בלי לציין שמות, בלי להגדיר $1user).\n\nפרמטרים מסוימים חלים רק על חלק מהאופנים ולא תקפים באחרים.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|מצב|מצבים}}: $2",
+ "apihelp-query+deletedrevs-param-start": "באיזה חותם־זמן להתחיל למנות.",
+ "apihelp-query+deletedrevs-param-end": "באיזה חותם־זמן להפסיק למנות.",
+ "apihelp-query+deletedrevs-param-from": "להתחיל את הרשימה בשם הזה.",
+ "apihelp-query+deletedrevs-param-to": "להפסיק את הרשימה בכותרת הזאת.",
+ "apihelp-query+deletedrevs-param-prefix": "חיפוש כל שמות הדפים שמתחילים בערך הזה.",
+ "apihelp-query+deletedrevs-param-unique": "לרשום רק גרסה אחת עבור כל דף.",
+ "apihelp-query+deletedrevs-param-tag": "לרשום רק גרסאות עם התג הזה.",
+ "apihelp-query+deletedrevs-param-user": "לרשום רק גרסאות מאת המשתמש הזה.",
+ "apihelp-query+deletedrevs-param-excludeuser": "לא לרשום גרסאות מאת המשתמש הזה.",
+ "apihelp-query+deletedrevs-param-namespace": "לרשום רק דפים במרחב השם הזה.",
+ "apihelp-query+deletedrevs-param-limit": "המספר המרבי של הגרסאות שיירשם.",
+ "apihelp-query+deletedrevs-param-prop": "אילו מאפיינים לקבל:\n;revid:הוספת מזהה הגרסה של הגרסה המחוקה.\n;parentid:הוספת מזהה הגרסה של הגרסה הקודמת של הדף.\n;user:הוספת המשתמש שעשה את הגרסה.\n;userid:הוספת מזהה המשתמש שעשה את הגרסה.\n;comment:הוספת ההערה על הגרסה.\n;parsedcomment:הוספת ההערה המפוענחת על הגרסה.\n;minor:מתייג אם הגרסה משנית.\n;len:מוסיף את האורך (בבייטים) של הגרסה.\n;sha1:הוספת ה־SHA-1 (בסיס 16) של הגרסה.\n;content:הוספת התוכן של הגרסה.\n;token:<span class=\"apihelp-deprecated\">מיושן.</span> נותן אסימון עריכה.\n;tags:תגים עבור הגרסה.",
+ "apihelp-query+deletedrevs-example-mode1": "רשימת כל הגרסאות המחוקות של הדפים <kbd>Main Page</kbd> ו־<kbd>Talk:Main Page</kbd>, עם תוכן (mode 1).",
+ "apihelp-query+deletedrevs-example-mode2": "רשימת 50 העריכות המחוקות האחרונות של <kbd>Bob</kbd>‏ (mode 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "רשימת 50 הגרסאות המחוקות הראשונות במרחב הראשי (mode 3).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "רשימת 50 הדפים המחוקים הראשונים במרחב השם {{ns:talk}}‏ (mode 3).",
+ "apihelp-query+disabled-summary": "יחידת ה־query הזאת כובתה.",
+ "apihelp-query+duplicatefiles-summary": "רשימת כל הקבצים שהם כפולים של קבצים נתונים לפי ערכי הגיבוב.",
+ "apihelp-query+duplicatefiles-param-limit": "כמה קבצים כפולים להחזיר.",
+ "apihelp-query+duplicatefiles-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+duplicatefiles-param-localonly": "חיפוש אחר קבצים במאגר המקומי בלבד.",
+ "apihelp-query+duplicatefiles-example-simple": "חיפוש אחר כפילויות של [[:קובץ:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "חיפוש אחר כפילויות בין כל הקבצים.",
+ "apihelp-query+embeddedin-summary": "חיפוש כל הדפים שמטמיעים (מכלילים) את הכותרת הנתונה.",
+ "apihelp-query+embeddedin-param-title": "איזו כותרת לחפש. לא ניתן להשתמש בזה יחד עם $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "מזהה דף לחיפוש. לא יכול לשמש יחד עם $1title.",
+ "apihelp-query+embeddedin-param-namespace": "איזה מרחב שם למנות.",
+ "apihelp-query+embeddedin-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+embeddedin-param-filterredir": "איך לסנן עבור הפניות.",
+ "apihelp-query+embeddedin-param-limit": "כמה דפים להחזיר בסך הכול.",
+ "apihelp-query+embeddedin-example-simple": "הצגת דפים שמכלילים את <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "קבלת מידע על דפים שמכלילים את <kbd>Template:Stub</kbd>.",
+ "apihelp-query+extlinks-summary": "החזרת כל ה־URL־ים החיצוניים (לא בינוויקי) מהדפים הנתונים.",
+ "apihelp-query+extlinks-param-limit": "כמה קישורים להחזיר.",
+ "apihelp-query+extlinks-param-protocol": "הפרוטוקול של ה־URL. אם זה ריק, ו־<var>$1query</var> מוגדר, הפרוטוקול הוא <kbd>http</kbd>. יש להשאיר את זה ואת <var>$1query</var> ריק כדי לרשום את כל הקישורים החיצוניים.",
+ "apihelp-query+extlinks-param-query": "מחרוזת חיפוש ללא פרוטוקול. שימושי לבדיקה האם דף מסוים מכיל url חיצוני מסוים.",
+ "apihelp-query+extlinks-param-expandurl": "הרחבת URL־ים בעלי פרוטוקול יחסי בפרוטוקול קנוני.",
+ "apihelp-query+extlinks-example-simple": "קבלת רשימת קישורים חיצוניים ב־<kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-summary": "למנות דפים שמכילים URL נתון.",
+ "apihelp-query+exturlusage-param-prop": "אילו חלקי מידע לכלול:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "הוספת מזהה הדף.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "הוספת השם ומזהה מרחב השם של הדף.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "הוספת ה־URL שמשמש בדף.",
+ "apihelp-query+exturlusage-param-protocol": "הפרוטוקול של ה־URL. אם זה ריק, ו־<var>$1query</var> מוגדר, הפרוטוקול הוא <kbd>http</kbd>. יש להשאיר את זה ואת <var>$1query</var> ריק כדי לרשום את כל הקישורים החיצוניים.",
+ "apihelp-query+exturlusage-param-query": "מחרוזת חיפוש ללא פרוטוקל. ר' [[Special:LinkSearch]]. יש להשאיר את זה ריק כדי לרשום את כל הקישורים החיצוניים.",
+ "apihelp-query+exturlusage-param-namespace": "איזה מרחב שם למנות.",
+ "apihelp-query+exturlusage-param-limit": "כמה דפים להחזיר.",
+ "apihelp-query+exturlusage-param-expandurl": "הרחבת URL־ים בעלי פרוטוקול יחסי בפרוטוקול קנוני.",
+ "apihelp-query+exturlusage-example-simple": "הצגת דפים שמקשרים ל־<kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-summary": "למנות את כל הקבצים המחוקים לפי הסדר.",
+ "apihelp-query+filearchive-param-from": "מאיזו כותרת תמונה להתחיל למנות.",
+ "apihelp-query+filearchive-param-to": "באיזו כותרת תמונה להפסיק למנות.",
+ "apihelp-query+filearchive-param-prefix": "חיפוש כל שמות התמונות שמתחילים בערך הזה.",
+ "apihelp-query+filearchive-param-limit": "כמה תמונות להחזיר בסך הכול.",
+ "apihelp-query+filearchive-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+filearchive-param-sha1": "גיבוב SHA1 של תמונה. דורס את $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "גיבוב SHA1 של תמונה בבסיס 36 (משמש במדיה־ויקי).",
+ "apihelp-query+filearchive-param-prop": "איזה מידע על תמונה לקבל:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "הוספת גיבוב SHA-1 עבור התמונה.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "הוספת חותם־זמן לגרסה המועלית.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "הוספת המשתמש שהעלה על גרסת התמונה.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "הוספת הגודל של התמונה בבייטים והגובה, הרוחב ומניין הדפים (אם מתאים).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "כינוי ל־size.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "הוספת תיאור לגרסת התמונה.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "פענוח התיאור של הגרסה.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "הוספת ה־MIME של התמונה.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "הוספת סוג המדיה של התמונה.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "רשימת מטא־נתוני Exif עבור גרסת הקובץ.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "הוספת עומק הביטים של הגרסה.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "הוספת שם הקובץ של גרסה מאורכבת עבור גרסאות שאינן האחרונה.",
+ "apihelp-query+filearchive-example-simple": "הצגת רשימת כל הקבצים המחוקים.",
+ "apihelp-query+filerepoinfo-summary": "החזרת מידע מטא על מאגרי תמונות שמוגדרים בוויקי.",
+ "apihelp-query+filerepoinfo-param-prop": "אילו מאפייני מאגר לקבל (יכולים להיות יותר מזה באתרי ויקי אחדים):\n;apiurl:URL ל־API של המאגר – מועיל לקבלת מידע על התמונה מהמארח.\n;name:המפתח של המאגר – משמש למשל בערכים המוחזרים מ־<var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> ומ־[[Special:ApiHelp/query+imageinfo|imageinfo]].\n;displayname:שם קריא של אתר הוויקי של המאגר.\n;rooturl:URL שורש לנתיבי תמונות.\n;local:האם המאגר הוא מקומי או לא.",
+ "apihelp-query+filerepoinfo-example-simple": "קבלת מידע על מאגרי קבצים.",
+ "apihelp-query+fileusage-summary": "מציאת כל הדפים שמשתמשים בקבצים הנתונים.",
+ "apihelp-query+fileusage-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "מזהה הדף של כל דף.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "השם של כל דף.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "דגל אם הדף הוא הפניה.",
+ "apihelp-query+fileusage-param-namespace": "לכלול רק דפים במרחבי השם האלה.",
+ "apihelp-query+fileusage-param-limit": "כמה להחזיר.",
+ "apihelp-query+fileusage-param-show": "לחפש רק פריטים שמתאימים לאמות המידה הבאות:\n;redirect:להציג רק הפניות.\n;!redirect:לא להציג הפניות.",
+ "apihelp-query+fileusage-example-simple": "קבלת רשימת דפים שמשתמשים ב־[[:File:Example.jpg]].",
+ "apihelp-query+fileusage-example-generator": "קבלת מידע על דפים שמשתמשים ב־[[:File:Example.jpg]].",
+ "apihelp-query+imageinfo-summary": "החזרת מידע על קובץ והיסטורייה העלאה.",
+ "apihelp-query+imageinfo-param-prop": "איזה מידע על הקובץ לקבל:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "הוספת חותם־זמן לגרסה שהועלתה.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "הוספה המשתמש שהעלה כל גרסה של קובץ.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "הוספת מזהה המשתמש שהעלה כל גרסה של קובץ.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "תגובה על הגרסה.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "פענוח ההערה על גרסה.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "הוספת הכותרת הקנונית של הקובץ.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "מתן URL לקובץ ולדף התיאור.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "הוספת הגודל של הקובץ בבייטים והגובה, הרוחב ומניין הדפים (אם זה מתאים).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "כינוי ל־size.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "הוספת גיבוב SHA-1 עבור הקובץ.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "הוספת סוג ה־MIME של הקובץ.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "הוספת סוג ה־MIME של התמונה הממוזערת (נדרש url והפרמטר $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "הוספת סוג המדיה של הקובץ.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "טעינת מטא־נתונים של Exif עבור גרסת הקובץ.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "רשימת מטא־נתונים כלליים על תסדיר הקובץ עבור גרסת הקובץ.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "רשימת מטא־נתונים מעוצבים משולבים ממספר מקורות. התוצאה מעוצבת ב־HTML.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "הוספת שם הקובץ של גרסת הארכיון עבור הגרסאות שאינן האחרונה.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "הוספת עומק הביטים של הגרסה.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "משמש את Special:Upload כדי לקבל מידע על קובץ קיים. לא נועד לשימוש מחוץ לליבת MediaWiki.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "מוסיף האם הקובץ נמצא ב־[[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-limit": "כמה גרסאות של קובץ לכל קובץ.",
+ "apihelp-query+imageinfo-param-start": "מאיז חותם־זמן להתחיל רשימה.",
+ "apihelp-query+imageinfo-param-end": "באיזה חותם־זמן לסיים את הרשימה.",
+ "apihelp-query+imageinfo-param-urlwidth": "אם מוגדר $2prop=url, יוחזר URL לתמונה שגודלה הותאם לרוחב הזה.\nמסיבות של ביצועים, אם האפשרות הזאת משמשת, לא יוחזרו יותר מ־$1 תמונות.",
+ "apihelp-query+imageinfo-param-urlheight": "דומה ל־$1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "גרסת המטא־נתונים לשימוש. אם מוגדר <kbd>latest</kbd>, להשתמש בגרסה החדשה ביותר. בררת המחדל היא <kbd>1</kbd> לצורך תאימות אחורה.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "באיזו שפה לאחזר את המטא־נתונים. זה משפיע על אילו תרגומים לאחזר, האם יש כמה, וגם איך דברים כמו מספרים וערכים שונים מעוצבים.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "אם תרגומים של המאפיין extmetadata זמינים, לאחזר את כולם.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "אם זה מוגדר ולא ריק, רק המפתחות האלה יוחזרו עבור $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "מחרוזת פרמטר ייחודית למטפל. למשל, PDF־ים יכולים להשתמש ב־<kbd>page15-100px</kbd>.‏ <var>$1urlwidth</var> צריך לשמש ולהיות עקבי עם <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "אם <kbd>$2prop=badfile</kbd> מוגדר, זאת כותרת הדף שתשמש בזמן שערוך ה־[[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-localonly": "חיפוש אחר קבצים במאגר המקומי בלבד.",
+ "apihelp-query+imageinfo-example-simple": "קבלת מידע על הגרסה הנוכחית של [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "אחזור מידע על גרסאות של [[:File:Test.jpg]] מ־2008 ואחרי‏־כן.",
+ "apihelp-query+images-summary": "להחזיר את כל הקבצים שמכילים הדפים הנתונים.",
+ "apihelp-query+images-param-limit": "כמה קבצים להחזיר.",
+ "apihelp-query+images-param-images": "לרשום רק את הקבצים האלה. שימוש לבדיקת האם לדף מסוים יש קובץ מסוים.",
+ "apihelp-query+images-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+images-example-simple": "קבלת רשימת קבצים שמשמשים ב־[[Main Page]].",
+ "apihelp-query+images-example-generator": "קבלת מידע על כל הקבצים שמשמשים ב־[[Main Page]].",
+ "apihelp-query+imageusage-summary": "מציאת כל הדפים שמתמשים בשם התמונה הנתונה.",
+ "apihelp-query+imageusage-param-title": "איזו כותרת לחפש. לא ניתן להשתמש בזה יחד עם $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "מזהה דף לחיפוש. לא יכול לשמש יחד עם $1title.",
+ "apihelp-query+imageusage-param-namespace": "איזה מרחב שם למנות.",
+ "apihelp-query+imageusage-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+imageusage-param-filterredir": "איך לסנן הפניות. אם זה מוגדר ל־nonredirects כש־$1redirect מופעל, זה חל רק על הרמה השנייה.",
+ "apihelp-query+imageusage-param-limit": "כמה דפים להחזיר בסך הכול. אם <var>$1redirect</var> מופעל, ההגבלה חלה על כל רמה בנפרד (כלומר יכולות להיות מוחזרות עד <span dir=\"ltr\">2 * <var>$1limit</var></span> תוצאות).",
+ "apihelp-query+imageusage-param-redirect": "אם הדף המקשר הוא הפניה, למצוא גם את כל הדפים שמקשרים לאותה ההפניה. ההגבלה המרבית מוקטנת בחצי.",
+ "apihelp-query+imageusage-example-simple": "הצגת דפים שמשתמשים ב־[[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "קבלת פרטים על דפים שמשתמשים ב־[[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "קבלת מידע בסיסי על הדף.",
+ "apihelp-query+info-param-prop": "אילו מאפיינים נוספים לקבל:",
+ "apihelp-query+info-paramvalue-prop-protection": "לרשום את רמת ההגנה של כל דף.",
+ "apihelp-query+info-paramvalue-prop-talkid": "מזהה הדף של דף השיחה עבור כל דף שאינו דף שיחה.",
+ "apihelp-query+info-paramvalue-prop-watched": "לרשום את מצב המעקב של כל דף.",
+ "apihelp-query+info-paramvalue-prop-watchers": "מספר העוקבים, אם קיבלת הרשאה.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "מספר העוקבים אחרי כל דף שביקרו עריכות אחרונות לאותו הדף, אם זה מותר.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "חותם־זמן של הודעת רשימת מעקב של כל דף.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "מזהה הדף של הדף העיקרי של כל דף שיחה.",
+ "apihelp-query+info-paramvalue-prop-url": "נותן URL מלא, URL לעריכה ו־URL קנוני לכל דף.",
+ "apihelp-query+info-paramvalue-prop-readable": "האם המשתמש יכול להציג דף זה.",
+ "apihelp-query+info-paramvalue-prop-preload": "נותן את הטקסט שמוחזר על־ידי EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "נותן את האופן שבה שם הדף באמת מוצג.",
+ "apihelp-query+info-param-testactions": "בדיקה האם המשתמש הנוכחי יכול לבצע פעולות מסוימות על הדף.",
+ "apihelp-query+info-param-token": "להשתמש ב־[[Special:ApiHelp/query+tokens|action=query&meta=tokens]] במקום.",
+ "apihelp-query+info-example-simple": "קבלת מידע על הדף <kbd>Main Page</kbd>",
+ "apihelp-query+info-example-protection": "קבלת מידע כללי ומידע על הגנה של הדף <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-summary": "מציאות כל הדפים שמקשרים לקישור הבינוויקי הנתון.",
+ "apihelp-query+iwbacklinks-extended-description": "יכול לשמש למציאת כל הקישורים עם התחילית, או כל הקישורים לכותרת (עם תחילית נתונה). אי־שימוש בשום פרמטר אומר \"כל קישורי בינוויקי\".",
+ "apihelp-query+iwbacklinks-param-prefix": "תחילית לבינוויקי.",
+ "apihelp-query+iwbacklinks-param-title": "איזה קישור בינוויקי לחפש. צריך להשתמש בזה יחד עם <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "כמה דפים להחזיר בסך הכול.",
+ "apihelp-query+iwbacklinks-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "הוספת התחילית של הבינוויקי.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "הוספת הכותרת של הבינוויקי.",
+ "apihelp-query+iwbacklinks-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+iwbacklinks-example-simple": "קבלת דפים שמקשרים ל־[[wikibooks:Test]].",
+ "apihelp-query+iwbacklinks-example-generator": "קבלת מידע על דפים שמקשרים ל־[[wikibooks:Test]].",
+ "apihelp-query+iwlinks-summary": "החזרת כל קישורי הבינוויקי מהדפים הנתונים.",
+ "apihelp-query+iwlinks-param-url": "האם לקבל את ה־URL המלא (לא יכול לשמש עם $1prop).",
+ "apihelp-query+iwlinks-param-prop": "אילו מאפיינים נוספים לקבל עבור כל קישור בין־לשוני:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "הוספת ה־URL המלא.",
+ "apihelp-query+iwlinks-param-limit": "כמה קישורי בינוויקי להחזיר.",
+ "apihelp-query+iwlinks-param-prefix": "להחזיר רק קישורי בינוויקי עם התחילית הזאת.",
+ "apihelp-query+iwlinks-param-title": "איזה קישור בינוויקי לחפש. צריך להשתמש בזה יחד עם <var>$1prefix</var>.",
+ "apihelp-query+iwlinks-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+iwlinks-example-simple": "קבלת קישורי בינוויקי מהדף <kbd>Main Page</kbd>.",
+ "apihelp-query+langbacklinks-summary": "מציאת כל הדפים שמקשרים לקישור השפה הנתון.",
+ "apihelp-query+langbacklinks-extended-description": "יכול לשמש למציאת כל הקישורים עם קוד שפה, או כל הקישורים לכותרת (עם שפה נתונה). אי־שימוש בשום פרמטר פירושו \"כל קישורי שפה\".\n\nנא לשים לב לכך שזה עשוי לא להתייחס לקישורי שפה שמוסיפות הרחבות.",
+ "apihelp-query+langbacklinks-param-lang": "שפה עבור קישור שפה.",
+ "apihelp-query+langbacklinks-param-title": "איזה קישור שפה לחפש. חייב לשמש עם $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "כמה דפים להחזיר בסך הכול.",
+ "apihelp-query+langbacklinks-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "הוספת קוד השפה של קישור השפה.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "הוספת הכותרת של קישור השפה.",
+ "apihelp-query+langbacklinks-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+langbacklinks-example-simple": "קבלת דפים שמקשרים ל־[[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "קבלת מידע על דפים שמקשרים ל־[[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "החזרת כל הקישורים הבין־לשוניים מהדפים הנתונים.",
+ "apihelp-query+langlinks-param-limit": "כמה קישורי שפה להחזיר.",
+ "apihelp-query+langlinks-param-url": "האם לקבל את ה־URL המלא (לא יכול לשמש עם <var>$1prop</var>).",
+ "apihelp-query+langlinks-param-prop": "אילו מאפיינים נוספים לקבל עבור כל קישור בין־לשוני:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "הוספת ה־URL המלא.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "הוספת שם השפה המתורגם (עם המאמץ הטוב ביותר). יש להשתמש ב־<var>$1inlanguagecode</var> כדי לשלוט בשפה.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "הוספת השם הילידי של השפה.",
+ "apihelp-query+langlinks-param-lang": "להחזיר רק קישורי שפה עם קוד השפה הזה.",
+ "apihelp-query+langlinks-param-title": "קישור לחיפוש. חובה להשתמש עם <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+langlinks-param-inlanguagecode": "קוד שפה בשביל שמות שפות מתורגמות.",
+ "apihelp-query+langlinks-example-simple": "קבלת קישורים בין־לשוניים מהדף <kbd>Main Page</kbd>.",
+ "apihelp-query+links-summary": "החזרת כל הקישורים מהדפים שצוינו.",
+ "apihelp-query+links-param-namespace": "להציג קישורים רק במרחבי השם האלה.",
+ "apihelp-query+links-param-limit": "כמה קישורים להחזיר.",
+ "apihelp-query+links-param-titles": "לרשום רק קישורים לכותרות האלו. שימושי לבדיקה האם דף מסוים מקשר לכותרת מסוימת.",
+ "apihelp-query+links-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+links-example-simple": "קבלת קישורים מהדף <kbd>Main Page</kbd>",
+ "apihelp-query+links-example-generator": "קבלת מידע על דפי הקישור בדף <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-namespaces": "קבלת קישורים מהדף <kbd>Main Page</kbd> במרחבי השם {{ns:user}} ו־{{ns:template}}.",
+ "apihelp-query+linkshere-summary": "מציאת כל הדפים שמקשרים לדפים הנתונים.",
+ "apihelp-query+linkshere-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "מזהה הדף של כל דף.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "השם של כל דף.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "דגל אם הדף הוא הפניה.",
+ "apihelp-query+linkshere-param-namespace": "לכלול רק דפים במרחבי השם האלה.",
+ "apihelp-query+linkshere-param-limit": "כמה להחזיר.",
+ "apihelp-query+linkshere-param-show": "הצגת פריטים שתואמים את הדרישות הללו בלבד:\n;redirect:הצגת הפניות בלבד.\n;!redirect:הצגת קישורים שאינם הפניות בלבד.",
+ "apihelp-query+linkshere-example-simple": "קבלת רשימת דפים שמקשרים ל־[[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "קבל מידע על דפים שמקשרים ל־[[Main Page]].",
+ "apihelp-query+logevents-summary": "קבלת אירועים מהרישומים.",
+ "apihelp-query+logevents-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "הוספת המזהה של אירוע היומן.",
+ "apihelp-query+logevents-paramvalue-prop-title": "הוספת שם הדף של אירוע היומן.",
+ "apihelp-query+logevents-paramvalue-prop-type": "הוספת הסוג של אירוע היומן.",
+ "apihelp-query+logevents-paramvalue-prop-user": "הוספת המשתמש האחראי על אירוע היומן.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "הוספת מזהה המשתמש האחראי על אירוע היומן.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "הוספת חותם־הזמן עבור האירוע.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "הוספת ההערה של האירוע.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "הוספת ההערה המפוענחת של האירוע.",
+ "apihelp-query+logevents-paramvalue-prop-details": "הוספת פרטים נוספים על האירוע.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "רשימת התגים של האירוע.",
+ "apihelp-query+logevents-param-type": "סינון עיולי יומן רק לסוג הזה.",
+ "apihelp-query+logevents-param-action": "סינון פעולות יומן רק לפעולה הזאת. דורס את <var>$1type</var>. ברשימת הערכים האפשריים, ערכים עם תו־כל כוכבית כגון <kbd>action/*</kbd> יכולים להיות מחרוזות שונות אחרי הקו הנטוי (/).",
+ "apihelp-query+logevents-param-start": "מאיזה חותם־זמן להתחיל למנות.",
+ "apihelp-query+logevents-param-end": "באיזה חותם זמן להפסיק לרשום.",
+ "apihelp-query+logevents-param-user": "לסנן את העיולים שעשה המשתמש הנתון.",
+ "apihelp-query+logevents-param-title": "סינון עיולים רק לכאלה שמתייחסים לדף.",
+ "apihelp-query+logevents-param-namespace": "סינון עיולים רק לכאלה במרחב השם הנתון.",
+ "apihelp-query+logevents-param-prefix": "סינון עיולים לכאלה שמתחילים עם התחילית הזאת.",
+ "apihelp-query+logevents-param-tag": "לרשום רק אירועים שמתויגם בתג הזה.",
+ "apihelp-query+logevents-param-limit": "כמה עיולי אירועים להחזיר בסך הכול.",
+ "apihelp-query+logevents-example-simple": "רשימת אירועי יומן אחרונים.",
+ "apihelp-query+pagepropnames-summary": "רשימת כל שמות המאפיינים שמשמשים בוויקי.",
+ "apihelp-query+pagepropnames-param-limit": "המספר המרבי של השמות להחזיר.",
+ "apihelp-query+pagepropnames-example-simple": "לתת את 10 שמות המאפיינים הראשונים.",
+ "apihelp-query+pageprops-summary": "קבלת מאפייני דף שונים שמוגדרים בתוכן הדף.",
+ "apihelp-query+pageprops-param-prop": "לרשום רק את המאפיינים האלה (שימוש ב־<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> מחזיר רשימת שמות מאפייני דף בשימוש). זה שימושי לבדיקה האם דפים משתמשים במאפיין דף מסוים.",
+ "apihelp-query+pageprops-example-simple": "קבלת מאפיינים עבור הדפים <kbd>Main Page</kbd> ו־<kbd>MediaWiki</kbd>.",
+ "apihelp-query+pageswithprop-summary": "לרשום את כל הדפים שמשתמשים במאפיין דף נתון.",
+ "apihelp-query+pageswithprop-param-propname": "מאפיין דף שעבורו למנות דפים (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> מחזיר רשימת שמות מאפייני דף בשימוש).",
+ "apihelp-query+pageswithprop-param-prop": "אילו חלקי מידע לכלול:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "הוספת מזהה הדף.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "הוספת השם ומזהה מרחב השם של הדף.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "הוספת הערך של מאפיין הדף.",
+ "apihelp-query+pageswithprop-param-limit": "מספר הדפים המרבי שיוחזר.",
+ "apihelp-query+pageswithprop-param-dir": "באיזה כיוון לסדר.",
+ "apihelp-query+pageswithprop-example-simple": "הצגת עשרת הדפים הראשונים שעושים שימוש ב־<code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "קבלת מידע נוסף על עשרת הדפים הראשונים המשתמשים ב־<code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-summary": "ביצוע חיפוש תחילית של כותרות דפים.",
+ "apihelp-query+prefixsearch-extended-description": "למרות הדמיון בשם, המודול הזה אינו אמור להיות שווה ל־[[Special:PrefixIndex]] (\"מיוחד:דפים המתחילים ב\"); לדבר כזה, ר' <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> עם הפרמטר <kbd>apprefix</kbd>. מטרת המודול הזה דומה ל־<kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>: לקבל קלט ממשתמש ולספק את הכותרות המתאימות ביותר. בהתאם לשרת מנוע החיפוש, זה יכול לכלול תיקון שגיאות כתיב, הימנעות מדפי הפניה והירסטיקות אחרות.",
+ "apihelp-query+prefixsearch-param-search": "מחרוזת לחיפוש.",
+ "apihelp-query+prefixsearch-param-namespace": "שמות מתחם לחיפוש.",
+ "apihelp-query+prefixsearch-param-limit": "מספר התוצאות המרבי להחזרה.",
+ "apihelp-query+prefixsearch-param-offset": "מספר תוצאות לדילוג.",
+ "apihelp-query+prefixsearch-example-simple": "חיפוש שםות דפים שמתחילים ב־<kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "באיזה פרופיל חיפוש להשתמש.",
+ "apihelp-query+protectedtitles-summary": "לרשום את כל הכותרות שמוגנות מפני יצירה.",
+ "apihelp-query+protectedtitles-param-namespace": "לרשום רק כותרות במרחבי השם האלה.",
+ "apihelp-query+protectedtitles-param-level": "לרשום רק שמות עם רמת ההגנה הזאת.",
+ "apihelp-query+protectedtitles-param-limit": "כמה דפים להחזיר בסך הכול.",
+ "apihelp-query+protectedtitles-param-start": "להתחיל לרשום בחותם־זמן ההגנה הזה.",
+ "apihelp-query+protectedtitles-param-end": "באיזה חותם־זמן הגנה לסיים את הרשימה.",
+ "apihelp-query+protectedtitles-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "הוספת חותם־הזמן של הוספת ההגנה.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "הוספת המשתמש שהוסיף את ההגנה.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "הוספת מזהה המשתמש שהוסיף את ההגנה.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "הוספת ההערה עבור ההגנה.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "הוספת ההערה המפוענחת עבור ההגנה.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "הוספת חותם־הזמן של הסרת ההגנה.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "הוספת רמת ההגנה.",
+ "apihelp-query+protectedtitles-example-simple": "רשימת כותרות מוגנות.",
+ "apihelp-query+protectedtitles-example-generator": "חיפוש קישורים לכותרות מוגנות במרחב הראשי.",
+ "apihelp-query+querypage-summary": "קבלת רשימה שמסופקת על־ידי דף מיוחד מבוסס־QueryPage.",
+ "apihelp-query+querypage-param-page": "שם הדף המיוחד. לתשומת לבך, זה תלוי־רישיות.",
+ "apihelp-query+querypage-param-limit": "מספר תוצאות להחזרה.",
+ "apihelp-query+querypage-example-ancientpages": "מחזיר תוצאות מ־[[Special:Ancientpages]].",
+ "apihelp-query+random-summary": "קבלת ערכת דפים אקראיים.",
+ "apihelp-query+random-extended-description": "הדפים רשומים בסדר קבוע, ורק נקודת ההתחלה אקראית. זה אומר שאם, למשל, <samp>Main Page</samp> הוא הדף האקראי הראשון הרשימה, <samp>List of fictional monkeys</samp> יהיה <em>תמיד</em> השני, <samp>List of people on stamps of Vanuatu</samp> שלישי, וכו'.",
+ "apihelp-query+random-param-namespace": "מחזיר דפים רק במרחבי השם האלה.",
+ "apihelp-query+random-param-limit": "להגביל את מספר הדפים האקראיים שיוחזרו.",
+ "apihelp-query+random-param-redirect": "נא להשתמש ב־<kbd>$1filterredir=redirects</kbd> במקום.",
+ "apihelp-query+random-param-filterredir": "איך לסנן הפניות.",
+ "apihelp-query+random-example-simple": "להחזיר שני דפים אקראיים מהמרחב הראשי.",
+ "apihelp-query+random-example-generator": "החזרת מידע על הדף על שני דפים אקראיים מהמרחב הראשי.",
+ "apihelp-query+recentchanges-summary": "למנות שינויים אחרונים.",
+ "apihelp-query+recentchanges-param-start": "מאיזה חותם־זמן להתחיל למנות.",
+ "apihelp-query+recentchanges-param-end": "באיזה חותם זמן להפסיק לרשום.",
+ "apihelp-query+recentchanges-param-namespace": "לסנן את השינויים רק למרחבי השם האלה.",
+ "apihelp-query+recentchanges-param-user": "לרשום רק שינויים של המשתמש הזה.",
+ "apihelp-query+recentchanges-param-excludeuser": "Don't list changes by this user",
+ "apihelp-query+recentchanges-param-tag": "לרשום רק שינויים שמתויגים עם התג הזה.",
+ "apihelp-query+recentchanges-param-prop": "לכלול פריטי מידע נוספים:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "הוספת המשתמש האחראי על העריכה ותיוג אם זאת כתובת IP.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "הוספת המשתמש האחראי על העריכה.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "הוספת ההערה על העריכה.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "הוספת ההערה המפוענחת על העריכה.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "הוספת דגלים לעריכה.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "הוספת חותם־זמן של העריכה.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "הוספת שם הדף של העריכה.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "הוספת מזהה הדף, מזהה שינויים אחרונים, והמזהה הגרסה החדשה והישנה.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "הוספת אורך הדף החדש והישן בבייטים.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "מתייג שהדף הוא הפניה.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "מתייג עריכה בת־בדיקה בתור בדוקה או בלתי־בדוקה.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "הוספת מידע יומן (זהה יומן, סוג יומן וכו') לעיולי יומן.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "רשימת תגים עבור העיול.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "הוספת סיכום־ביקורת תוכן לעיולים שמשויכים לגרסה.",
+ "apihelp-query+recentchanges-param-token": "יש להשתמש ב־<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> במקום.",
+ "apihelp-query+recentchanges-param-show": "הצגה רק של פריטים שמתאימים לאמות המידה האלו. למשל, כדי לראות רק עריכות משניות שעשו משתמשים שנכנסו לחשבון, יש להגדיר $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "כמה שינויים להחזיר בסך הכול.",
+ "apihelp-query+recentchanges-param-type": "אילו סוגים של שינויים להציג.",
+ "apihelp-query+recentchanges-param-toponly": "לרשום רק שינויים שהם הגרסה האחרונה.",
+ "apihelp-query+recentchanges-param-generaterevisions": "בעת שימוש בתור מחולל, לחולל מזהי גרסה במקום כותרות. עיולי שינויים אחרונים ללא מזהה גרסה משויך (למשל רוב עיולי היומן) לא יחוללו דבר.",
+ "apihelp-query+recentchanges-example-simple": "הצגת השינויים האחרונים.",
+ "apihelp-query+recentchanges-example-generator": "קבלת מידע על הדף על שינויים אחרונים שלא נבדקו.",
+ "apihelp-query+redirects-summary": "מחזיר את כל ההפניות לדפים הנתונים.",
+ "apihelp-query+redirects-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "מזהה הדף של כל הפניה.",
+ "apihelp-query+redirects-paramvalue-prop-title": "השם של כל הפניה.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "מובאה מכל הפניה, אם יש.",
+ "apihelp-query+redirects-param-namespace": "לכלול רק דפים במרחבי השם האלה.",
+ "apihelp-query+redirects-param-limit": "כמה הפניות להחזיר.",
+ "apihelp-query+redirects-param-show": "לחפש רק פריטים שמתאימים לאמות המידה הבאות:\n;fragment:להציג רק הפניות עם מקטע.\n;!fragment:להציג רק הפניות ללא מקטע.",
+ "apihelp-query+redirects-example-simple": "קבלת רשימת הפניות ל־[[Main Page]]",
+ "apihelp-query+redirects-example-generator": "קבלת מידע על כל ההפניות ל־[[Main Page]].",
+ "apihelp-query+revisions-summary": "קבלת מידע על גרסה.",
+ "apihelp-query+revisions-extended-description": "יכול לשמש במספר דרכים:\n# קבלת נתונים על ערכת דפים (גרסה אחרונה), באמצעות כותרות או מזהי דף.\n# קבלת גרסאות עבור דף נתון אחד, באמצעות שימוש בכותרות או במזהי דף עם start‏, end או limit.\n# קבלת נתונים על ערכת גרסאות באמצעות הגדרת המזהים שלהם עם revid־ים.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "יכול לשמש רק עם דף בודד (mode #2).",
+ "apihelp-query+revisions-param-startid": "להתחיל למנות מחותם הזמן של הגרסה הזאת. הגרסה צריכה להיות קיימת, אבל לא חייבת להיות שייכת לדף הזה.",
+ "apihelp-query+revisions-param-endid": "להפסיק למנות מחותם הזמן של הגרסה הזאת. הגרסה צריכה להיות קיימת, אבל לא חייבת להיות שייכת לדף הזה.",
+ "apihelp-query+revisions-param-start": "מאיזה חותם־זמן של גרסה להתחיל למנות.",
+ "apihelp-query+revisions-param-end": "למנות עד חותם־הזמן הזה.",
+ "apihelp-query+revisions-param-user": "לכלול רק גרסאות מאת משתמש.",
+ "apihelp-query+revisions-param-excludeuser": "לא לכלול שינויים מאת משתמש.",
+ "apihelp-query+revisions-param-tag": "לרשום רק גרסאות עם התג הזה.",
+ "apihelp-query+revisions-param-token": "אילו אסימונים לקבל עבור כל גרסה.",
+ "apihelp-query+revisions-example-content": "קבל נתונים על תוכן עבור הגרסאות האחרונות של הכותרות <kbd>API</kbd> ו־<kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "קבלת 5 הגרסאות האחרונות של <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5": "קבלת 5 הגרסאות הראשונות של <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "קבלת 5 הגרסאות הראשונות של <kbd>Main Page</kbd> שנעשו אחרי 2006-05-01.",
+ "apihelp-query+revisions-example-first5-not-localhost": "קבלת 5 הגרבאות הראשונות של <kbd>Main Page</kbd> שלא נעשו על־ידי המשתמש האלמוני <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "קבלת 5 הגרסאות הראשונות של <kbd>Main Page</kbd> שנעשו על־ידי המשתמש <kbd>MediaWiki default</kbd>.",
+ "apihelp-query+revisions+base-param-prop": "אילו מאפיינים לקבל עבור כל גרסה:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "מזהה הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "דגלי גרסה (משני).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "חותם־הזמן של הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "המשתמש שעשה את הגרסה",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "מזהה המשתמש של יוצר הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "אורך (בבייטים) של הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1 (בבסיס 16) של הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "מזהה מודל התוכן של הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "הערה מאת המשתמש על הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "הערה מפוענחת מאת המשתמש על הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "הטקסט של הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "התגים עבור הגרסה.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">מיושן.</span> יש להשתמש ב־ <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> או ב־ <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> במקום בזה.\n\nעץ פענוח XML של תוכן הגרסה (דורש מודל תוכן <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "הגבלת מספר הגרסאות שיוחזרו.",
+ "apihelp-query+revisions+base-param-expandtemplates": "יש להשתמש ב־<kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> במקום בזה. להרחיב תבניות בתוכן הגרסה (דורש $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "יש להשתמש ב־<kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> או ב־<kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> במקום בזה. יצירת עץ פענוח XML עבור תוכן הגרסה (דורש את $1prop=content; מוחלף ב־<kbd>$1prop=parsetree</kbd>).",
+ "apihelp-query+revisions+base-param-parse": "יש להשתמש ב־<kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> במקום בזה. פענוח תוכן הגרסה (דורש $1prop=content). מסיבות של ביצועים, אם האפשרות הזאת משמשת, $1limit נכפה לערך 1.",
+ "apihelp-query+revisions+base-param-section": "לאחזר רק את התוכן של הפִסקה עם המספר הזה.",
+ "apihelp-query+revisions+base-param-diffto": "יש להשתמש ב־<kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> במקום בזה. מזהה הגרסה שכל גרסה תושווה אליה. יש להשתמש ב־<kbd dir=\"ltr\">prev</kbd>‏, <kbd dir=\"ltr\">next</kbd> ו־<kbd dir=\"ltr\">cur</kbd> עבור הגרסה הקודמת, הבא והנוכחית, בהתאמה.",
+ "apihelp-query+revisions+base-param-difftotext": "יש להשתמש ב־<kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> במקום בזה. הטקסט שכל גרסה גרסה תושווה אליו. מבצע השוואה רק של מספר מוגבל של גרסאות. דורס את <var>$1diffto</var>. אם מוגדר <var>$1section</var>, רק הפסקה הזאת תושווה אל מול הטקסט הזה.",
+ "apihelp-query+revisions+base-param-difftotextpst": "יש להשתמש ב־<kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> במקום בזה. ביצוע התמרה לפני שמירה על הטקסט לפני הרצת השוואה. תקף רק כשמשמש עם <var>$1difftotext</var>.",
+ "apihelp-query+revisions+base-param-contentformat": "תסדיר ההסדרה שמשמש את <var>$1difftotext</var> וצפוי לפלט של תוכן.",
+ "apihelp-query+search-summary": "ביצוע חיפוש בכל הטקסט.",
+ "apihelp-query+search-param-search": "חיפוש שמות דפים או תוכן שמתאים לערך הזה. אפשר להשתמש בחיפוש מחרוזת כדי לקרוא לאפשרויות חיפוש מתקדמות, בהתאם למה שממומש בשרת החיפוש של הוויקי.",
+ "apihelp-query+search-param-namespace": "חיפוש רק במרחבי השם האלה.",
+ "apihelp-query+search-param-what": "איזה סוג חיפוש לבצע.",
+ "apihelp-query+search-param-info": "אילו מטא־נתונים להחזיר.",
+ "apihelp-query+search-param-prop": "אילו מאפיינים להחזיר:",
+ "apihelp-query+search-param-qiprofile": "באיזה פרופיל בלתי־תלוי בשאילתה להשתמש (משפיע על אלגוריתם הדירוג).",
+ "apihelp-query+search-paramvalue-prop-size": "הוספת גודל הדף בבייטים.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "הוספת מניין המילים של הדף.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "הוספת חותם־הזמן של העריכה האחרונה של הדף.",
+ "apihelp-query+search-paramvalue-prop-snippet": "הוספת קטע קצר מפוענח מהדף.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "הוספת קטע קצר מפוענח משם הדף.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "הוספת קטע קצר מפוענח משם ההפניה.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "הוספת שם ההפניה התואמת.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "הוספת קטע קצר מפוענח של שם הפסקה התואמת.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "הוספת שם הפסקה התואמת.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "הוספת קטע קצר מפוענח של הקטגוריה התואמת.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "הוספת בוליאני שמציין אם החיפוש תאם לתוכן של קובץ.",
+ "apihelp-query+search-paramvalue-prop-score": "חסר־השפעה.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "חסר־השפעה.",
+ "apihelp-query+search-param-limit": "כמה דפים להחזיר בסך הכול.",
+ "apihelp-query+search-param-interwiki": "לכלול תוצאות בינוויקי בחיפוש, אם זמין.",
+ "apihelp-query+search-param-backend": "באיזה שרת חיפוש להשתמש אם לא בבררת המחדל.",
+ "apihelp-query+search-param-enablerewrites": "הפעלת שכתוב שאילתות פנימי. שרתי חיפוש אחדים יכולים לשכתב את השאילתה לצורה אחרת שנחשבת לכזאת שמספקת תוצאות טובות יותר, למשל באמצעות תיקון שגיאות כתיב.",
+ "apihelp-query+search-example-simple": "חיפוש <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "חיפוש טקסטים עבור <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-generator": "קבלת מידע על הדף עבור שמוחזרים מחיפוש אחרי <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "החזרת מידע כללי על האתר.",
+ "apihelp-query+siteinfo-param-prop": "איזה מיד לקבל:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "מידע מערכת כללי.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "רשימת מרחבי שם רשומים והשמות הקנוניים שלהם.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "רשימת כינויי מרחבי שם רשומים.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "רשימת כינויים דפים מיוחדים.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "רשימות מילות קסם וכינוייהן.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "החזרזת סטטיסטיקות אתר.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "החזרת מפת בינוויקי (אפשר שתהיה מסוננת, אפשר שתהיה מותאמת מקומית באמצעות <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "החזרת שרת מסד־נתונים עם שיהוי השכפול הגבוה ביותר.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "החזרת קבוצות משתמשים וההרשאות המשויכות.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "החזרת הספריות המותקנות בוויקי.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "החזרת ההרחבות המותקנות בוויקי.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "החזרת רשימת סיומות קבצים (סוגי קבצים) שאפשר להעלות.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "החזרת הזכויות (הרישיון) של הוויקי, אם זמין.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "החזרת מידע על ההגבלות (ההגנות) הזמינות.",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "החזרת השפות שמדיה־ויקי תומכת בהן (זה יכול להיות מותאם מקומים עם <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "מחזיר רשימת קודי שפה שמופעל עבורם ממיר שפה ([[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]]), וההגוונים הנתמכים עבור כל אחת מהן.",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "החזרת רשימת כל העיצובים הזמינים (זה יכול להיות מותאם מקומית באמצעות <var>$1inlanguagecode</var>, אחרת זה יהיה בשפת התוכן).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "החזרת רשימת תגי הרחבת מפענח.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "החזרת hook־ים של הרחבות מפענח.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "החזרת כל ה־hook־ים המנויים (תוכן של <var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "החזרת מזהי משתנים.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "החזרת רשימת הפרוטוקולים המותרים בקישורים חיצוניים.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "החזרת הערכים ההתחלתיים של העדפות משתמש.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "החזרת הגדרות תיבת ההעלאה.",
+ "apihelp-query+siteinfo-param-filteriw": "החזרה רק של עיולים מקומיים או רק של עיולים לא מקומיים ממפת הבינוויקי.",
+ "apihelp-query+siteinfo-param-showalldb": "רשימת כל שרתי מסד הנתונים, לא רק אלה שהכי מתעכבים.",
+ "apihelp-query+siteinfo-param-numberingroup": "רשימת מספרי משתמשים בקבוצות משתמשים.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "קוד שפה בשביל שמות שפות מתורגמות (מאמץ טוב ביותר) ושמות עיצובים.",
+ "apihelp-query+siteinfo-example-simple": "איזור מידע על האתר.",
+ "apihelp-query+siteinfo-example-interwiki": "אחזור תחיליות בינוויקי מקומיות.",
+ "apihelp-query+siteinfo-example-replag": "בדיקת שיהוי השכפול הנוכחי.",
+ "apihelp-query+stashimageinfo-summary": "החזרת מידע על הקובץ עבור הקבצים המוסלקים.",
+ "apihelp-query+stashimageinfo-param-filekey": "מפתח שמזהה העלאה קודמת שהוסלקה באופן זמני.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "כינוי ל־$1filekey, לתאימות אחורה.",
+ "apihelp-query+stashimageinfo-example-simple": "החזרת מידע על קובץ מוסלק.",
+ "apihelp-query+stashimageinfo-example-params": "החזרת תמונות ממוזערות עבור שני קבצים מוסלקים.",
+ "apihelp-query+tags-summary": "רשימת תגי שינוי.",
+ "apihelp-query+tags-param-limit": "המספר המרבי של תגים לרשום.",
+ "apihelp-query+tags-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+tags-paramvalue-prop-name": "הוספת שם התג.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "הוספת הודעת המערכת עבור התג.",
+ "apihelp-query+tags-paramvalue-prop-description": "הוספת תיאור התג.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "הוספת מספר הגרסאות ועיולי היומן עם התג הזה.",
+ "apihelp-query+tags-paramvalue-prop-defined": "ציון האם התג מוגדר.",
+ "apihelp-query+tags-paramvalue-prop-source": "קבלת מקורות התג, שיכולים להיות <samp>extension</samp> עבור תגים שמגדירות הרחבות ו־<samp>manual</samp> עבור תגים שמשתמשים יכולים להחיל ידנית.",
+ "apihelp-query+tags-paramvalue-prop-active": "האם התג עדיין מוּחל.",
+ "apihelp-query+tags-example-simple": "רשימת תגים זמינים.",
+ "apihelp-query+templates-summary": "החזרת כל הדפים המוכללים בדפים הנתונים.",
+ "apihelp-query+templates-param-namespace": "הצגת תבניות רק במרחב השם הזה.",
+ "apihelp-query+templates-param-limit": "כמה תבניות להחזיר.",
+ "apihelp-query+templates-param-templates": "לרשום רק את התבניות האלו. שימושי לבדיקה האם דף מסוים משתמש בתבנית מסוימת.",
+ "apihelp-query+templates-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+templates-example-simple": "קבלת התבניות המשמשות בדף <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-generator": "קבלת מידע על דפי התבנית שמשמשים ב־<kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-namespaces": "קבלת מידע במרחבי השם {{ns:user}} ו־{{ns:template}} שמוכללים בדף <kbd>Main Page</kbd>.",
+ "apihelp-query+tokens-summary": "קבלת אסימונים לפעולות שמשנות נתונים.",
+ "apihelp-query+tokens-param-type": "סוגי האסימונים לבקש.",
+ "apihelp-query+tokens-example-simple": "אחזור אסימון csrf (בררת המחדל).",
+ "apihelp-query+tokens-example-types": "אחזור אסימון של רשימת המעקב ואסימון של ניטור",
+ "apihelp-query+transcludedin-summary": "מציאת כל הדפים שמכלילים את הדפים הנתונים.",
+ "apihelp-query+transcludedin-param-prop": "אילו מאפיינים לקבל:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "מזהה הדף של כל דף.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "השם של כל דף.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "דגל אם הדף הוא הפניה.",
+ "apihelp-query+transcludedin-param-namespace": "לכלול רק דפים במרחבי השם האלה.",
+ "apihelp-query+transcludedin-param-limit": "כמה להחזיר.",
+ "apihelp-query+transcludedin-param-show": "לחפש רק פריטים שמתאימים לאמות המידה הבאות:\n;redirect:להציג רק הפניות.\n;!redirect:לא להציג הפניות.",
+ "apihelp-query+transcludedin-example-simple": "קבלת רשימה של דפים שמכלילים את <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "קבלת מידע על הדפים שמכלילים את <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-summary": "קבלת כל העריכות של המשתמש.",
+ "apihelp-query+usercontribs-param-limit": "המספר המרבי של התרומות להחזיר.",
+ "apihelp-query+usercontribs-param-start": "באיזה חותם־הזמן להתחיל.",
+ "apihelp-query+usercontribs-param-end": "באיזה חותם־הזמן לסיים",
+ "apihelp-query+usercontribs-param-user": "עבור אילו משתמשים לאחזר תרומות. לא יכול לשמש עם <var>$1userids</var> או <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "אחזור תרומות עבור כל המשתמשים שהשמות שלהם מתחילים בערך הזה. לא יכול לשמש עם <var>$1user</var> או <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "מזהי המשתמשים לאחזור תרומות. לא יכול לשמש עם <var>$1user</var> או <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "לרשום רק תרומות במרחבי השם האלה.",
+ "apihelp-query+usercontribs-param-prop": "לכלול פריטי מידע נוספים:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "הוספת מזהה הדף ומזהה הגרסה.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "הוספת השם ומזהה מרחב השם של הדף.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "הוספת חותם־הזמן של העריכה.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "הוספת ההערה על העריכה.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "הוספת ההערה המפוענחת של העריכה.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "הוספת הגודל החדש של העריכה.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "הוספת ההפרש של העריכה אל מול ההורה שלה.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "הוספת הדגלים של העריכה.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "מתייג עריכות בדוקות.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "רשימת תגים עבור עריכות.",
+ "apihelp-query+usercontribs-param-show": "הצגה רק של פריטים שמתאימים לאמות המידה האלה, למשל רק עריכות לא־משניות.\n\nאם מוגדר <kbd>$2show=patrolled</kbd> או <kbd>$2show=!patrolled</kbd>, גרסאות ישנות מ־<var dir=\"ltr\">[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var>‏ ({{PLURAL:$1|שנייה אחת|$1 שניות}}) לא תוצגנה.",
+ "apihelp-query+usercontribs-param-tag": "לרשום רק גרסאות עם התג הזה.",
+ "apihelp-query+usercontribs-param-toponly": "לרשום רק שינויים שהם הגרסה האחרונה.",
+ "apihelp-query+usercontribs-example-user": "הצגת התרומות של המשתמש <kbd>Example</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "הצגת תרומות מכל כתובות ה־IP שמתחילות ב־<kbd dir=\"ltr\">192.0.2.</kbd>.",
+ "apihelp-query+userinfo-summary": "קבלת מידע על המשתמש הנוכחי.",
+ "apihelp-query+userinfo-param-prop": "אילו חלקי מידע לכלול:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "מתייג אם המשתמש הנוכחי נחסם, על־ידי מי ומאיזו סיבה.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "הוספת התג <samp>messages</samp> אם למשתמש הנוכחי יש הודעות ממתינות.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "רשימת כל הקבוצות שהמשתמש שייך אליהן.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "לרשום קבוצות שהמשתמש הנוכחי משויך אליהן במפורש, כולל תאריך תפוגה לחברות בכל קבוצה.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "רשימת כל הקבוצות שהמשתמש שייך אליהן באופן אוטומטי.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "רשימת כל ההרשאות שיש למשתמש הזה.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "רשימת הקבוצות שהמשתמש הנוכחי יכול להוסיף אליהן ולגרוע מהן.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "רשימת כל ההעדפות שהמשתמש הנוכחי הגדיר.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "קבלת אסימון לשינוי ההעדפות של המשתמש הנוכחי.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "הוספת מניין העריכות של המשתמש הנוכחי.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "רשימת כל מגבלות הקצב שחלות על המשתמש הנוכחי.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "הוספת השם האמתי של המשתמש.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "הוספת כתובת הדוא\"ל ותאריך אימות כתובת הדוא\"ל.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "מדפיס את כותרת <code>Accept-Language</code> ששלח הלקוח בתסדיר מובנה.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "הוספת תאריך הרישום של המשתמש.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "הוספת מניין הדפים שלא נקראו ברשימת המעקב של המשתמש (לכל היותר $1; מחזיר <samp>$2</samp> אם יש יותר).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "הוספת המזהה המרכזי ומצב השיוך למשתמש.",
+ "apihelp-query+userinfo-param-attachedwiki": "עם <kbd>$1prop=centralids</kbd>, לציין האם המשתמש משויך לוויקי עם המזהה הזה.",
+ "apihelp-query+userinfo-example-simple": "קבלת מידע על המשתמש הנוכחי.",
+ "apihelp-query+userinfo-example-data": "קבלת מידע נוסף על המשתמש הנוכחי.",
+ "apihelp-query+users-summary": "קבלת מידע על רשימת משתמשים.",
+ "apihelp-query+users-param-prop": "אילו חלקי מידע לקבל:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "מתייג אם המשתמש חסום, על־ידי מי, ומאיזו סיבה.",
+ "apihelp-query+users-paramvalue-prop-groups": "רשימת כל הקבוצות שהמשתמש שייך אליהן.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "לרשום קבוצות שכל משתמש משויך אליהן במפורש, כולל תאריך תפוגה לחברות בכל קבוצה.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "רשימת כל הקבוצות שהמשתמש חבר בהן אוטומטית.",
+ "apihelp-query+users-paramvalue-prop-rights": "רשימת כל ההרשאות שיש למשתמש.",
+ "apihelp-query+users-paramvalue-prop-editcount": "הוספת מניין העריכות של המשתמש.",
+ "apihelp-query+users-paramvalue-prop-registration": "הוספת חותם־הזמן של רישום המשתמש.",
+ "apihelp-query+users-paramvalue-prop-emailable": "מתייג אם המשתמש יכול ורוצה לקבל דואר אלקטרוני דרך [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "מתייג את המגדר של המשתמש. מחזיר \"male\"‏, \"female\" או \"unknown\".",
+ "apihelp-query+users-paramvalue-prop-centralids": "הוספת המזהה המרכזי ומצב השיוך למשתמש.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "ציון האם אפשר ליצור חשבון עבור שמות משתמש תקינים, אבל לא רשומים.",
+ "apihelp-query+users-param-attachedwiki": "עם <kbd>$1prop=centralids</kbd>, לציין האם המשתמש משויך לוויקי עם המזהה הזה.",
+ "apihelp-query+users-param-users": "רשימת משתמשים שעליהם צריך לקבל מידע.",
+ "apihelp-query+users-param-userids": "רשימת מזהי משתמש שעבורם יתקבל המידע.",
+ "apihelp-query+users-param-token": "יש להשתמש ב־<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> במקום.",
+ "apihelp-query+users-example-simple": "החזרת מידע עבור המשתמש <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-summary": "קבלת שינויים אחרונים לדפים ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-query+watchlist-param-allrev": "לכלול גרסאות מרובות של אותו הדף בתוך מסגרת הזמן הנתונה.",
+ "apihelp-query+watchlist-param-start": "מאיזה חותם־זמן להתחיל למנות.",
+ "apihelp-query+watchlist-param-end": "באיזה חותם זמן להפסיק לרשום.",
+ "apihelp-query+watchlist-param-namespace": "סינון שינויים רק למרחבי השם שניתנו.",
+ "apihelp-query+watchlist-param-user": "לרשום רק שינויים של המשתמש הזה.",
+ "apihelp-query+watchlist-param-excludeuser": "Don't list changes by this user",
+ "apihelp-query+watchlist-param-limit": "כמה תוצאות סך הכול להחזיר בכל בקשה.",
+ "apihelp-query+watchlist-param-prop": "אילו מאפיינים נוספים לקבל:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "הוספת מזהי גסה ומזהי דף.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "הוספת שם הדף.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "הוספת דגלים לעריכה.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "הוספת המשתמש שעשה את העריכה.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "הוספת מזהה המשתמש של מי שעשה את העריכה.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "הוספת ההערה של העריכה.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "הוספת ההערכה המפוענחת של העריכה.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "הוספת חותם־זמן של העריכה.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "תיוג עריכות שנבדקו.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "הוספת האורך החדש והישן של הדף.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "הוספת חותם־זמן של ההודעה האחרונה למשתמש על העריכה.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "הוספת מידע מהיומן איפה שמתאים.",
+ "apihelp-query+watchlist-param-show": "הצגה רק של פריטים שמתאימים לאמות המידה האלו. למשל, כדי לראות רק עריכות משניות שעשו משתמשים שנכנסו לחשבון, יש להגדיר $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "אולי סוגי שינויים להציג:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "עריכות דף רגילות.",
+ "apihelp-query+watchlist-paramvalue-type-external": "שינויים חיצוניים.",
+ "apihelp-query+watchlist-paramvalue-type-new": "יצירות דף.",
+ "apihelp-query+watchlist-paramvalue-type-log": "עיולי יומן.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "שינויים בהשתייכות לקטגוריה.",
+ "apihelp-query+watchlist-param-owner": "משמש יחד עם $1token לגישה לרשימת מעקב של משתמש אחר.",
+ "apihelp-query+watchlist-param-token": "אסימון אבטחה (זמין ב־[[Special:Preferences#mw-prefsection-watchlist|העדפות]]) שמאפשר לגשת לרשימת מעקב של משתמש אחר.",
+ "apihelp-query+watchlist-example-simple": "לרשום את הגרסה האחרונה עבור דפים שהשתנו לאחרונה ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-query+watchlist-example-props": "אחזור מידע נוסף על הגרסה האחרונה עבור דפים שהשתנו לאחרונה ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-query+watchlist-example-allrev": "אחזור מידע על כל השינויים האחרונים לדפים ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-query+watchlist-example-generator": "אחזור מידע על הדף עבור דפים שהשתנו לאחרונה ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-query+watchlist-example-generator-rev": "אחזור מידע על הגרסה עבור דפים שהשתנו לאחרונה ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-query+watchlist-example-wlowner": "לרשום את הגרסה האחרונה עבור דפים שהשתנו לאחרונה ברשימת המעקב של משתמש <kbd>Example</kbd>.",
+ "apihelp-query+watchlistraw-summary": "קבלת כל הדפים ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-query+watchlistraw-param-namespace": "לרשום רק דפים במרחב השם הנתון.",
+ "apihelp-query+watchlistraw-param-limit": "כמה תוצאות סך הכול להחזיר בכל בקשה.",
+ "apihelp-query+watchlistraw-param-prop": "אילו מאפיינים נוספים לקבל:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "הוספת חותם־הזמן של ההודעה האחרונה למשתמש על העריכה.",
+ "apihelp-query+watchlistraw-param-show": "לרשום רק פריטים שעונים על אמות המידה האלו.",
+ "apihelp-query+watchlistraw-param-owner": "משמש יחד עם $1token לגישה לרשימת מעקב של משתמש אחר.",
+ "apihelp-query+watchlistraw-param-token": "אסימון אבטחה (זמין ב־[[Special:Preferences#mw-prefsection-watchlist|העדפות]]) שמאפשר לגשת לרשימת מעקב של משתמש אחר.",
+ "apihelp-query+watchlistraw-param-dir": "באיזה כיוון לרשום.",
+ "apihelp-query+watchlistraw-param-fromtitle": "מאיזו כותרת (עם תחילית מרחב שם) להתחיל למנות.",
+ "apihelp-query+watchlistraw-param-totitle": "באיזו כותרת (עם תחילית מרחב שם) להפסיק למנות.",
+ "apihelp-query+watchlistraw-example-simple": "לרשום דפים ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-query+watchlistraw-example-generator": "אחזור מידע על הדפים עבור דפים ברשימת המעקב של המשתמש הנוכחי.",
+ "apihelp-removeauthenticationdata-summary": "הסרת נתוני אימות עבור המשתמש הנוכחי.",
+ "apihelp-removeauthenticationdata-example-simple": "לנסות להסיר את נתוני המשתמש הנוכחי בשביל <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "שליחת דוא\"ל איפוס סיסמה למשתמש.",
+ "apihelp-resetpassword-extended-description-noroutes": "אין מסלולים לאיפוס ססמה.\n\nכדי להשתמש במודול הזה, יש להפעיל מסלולים ב־<var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var>.",
+ "apihelp-resetpassword-param-user": "המשתמש שמאופס.",
+ "apihelp-resetpassword-param-email": "כתובת הדוא\"ל של המשתמש שהסיסמה שלו מאופסת.",
+ "apihelp-resetpassword-example-user": "שליחת מכתב איפוס ססמה למשתמש <kbd>Example</kbd>.",
+ "apihelp-resetpassword-example-email": "שליחת מכתב איפוס ססמה לכל המשתמשים שהכתובת שלהם היא <kbd>user@example.com</kbd>.",
+ "apihelp-revisiondelete-summary": "מחיקה ושחזור ממחיקה של גרסאות.",
+ "apihelp-revisiondelete-param-type": "סוג מחיקת הגרסה שמתבצע.",
+ "apihelp-revisiondelete-param-target": "שם הדף למחיקת גרסה, אם זה נחוץ לסוג.",
+ "apihelp-revisiondelete-param-ids": "מזהים של הגרסה שתימחק.",
+ "apihelp-revisiondelete-param-hide": "מה להסתיר עבור כל גרסה.",
+ "apihelp-revisiondelete-param-show": "הסתרה של מה לבטל עבור כל גרסה.",
+ "apihelp-revisiondelete-param-suppress": "האם להעלים נתונים ממפעילים ומאחרים.",
+ "apihelp-revisiondelete-param-reason": "סיבה למחיקה או לשחזור ממחיקה.",
+ "apihelp-revisiondelete-param-tags": "אילו תגים להחיל על העיול ביומן המחיקה.",
+ "apihelp-revisiondelete-example-revision": "הסתרת התוכן של הגרסה <kbd>12345</kbd> בדף <kbd>Main Page</kbd>.",
+ "apihelp-revisiondelete-example-log": "הסתרת כל הנתונים על רשומת היומן <kbd>67890</kbd> עם הסיבה <kbd>BLP violation</kbd>.",
+ "apihelp-rollback-summary": "ביטול העריכה האחרונה לדף.",
+ "apihelp-rollback-extended-description": "אם המשמש האחרון שערך את הדף עשה מספר עריכות זו אחר זו, הן תשוחזרנה.",
+ "apihelp-rollback-param-title": "שם הדף לשחזור. לא יכול לשמש יחד עם <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "מזהה הדף לשחזור. לא יכול לשמש יחד עם <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "אילו תגים להחיל על השחזור.",
+ "apihelp-rollback-param-user": "שם המשתמשים שהעריכות שלו תשוחזרנה.",
+ "apihelp-rollback-param-summary": "תקציר עריכה מותאם. אם ריק, ישמש תקציר לפי בררת מחדל.",
+ "apihelp-rollback-param-markbot": "לסמן את העריכות ששוחזרו ואת השחזור בתור עריכות בוט.",
+ "apihelp-rollback-param-watchlist": "הוספה או הסרה של הדף ללא תנאי מרשימת המעקב של המשתמש הנוכחי, להשתמש בהעדפות או לא לשנות את המעקב.",
+ "apihelp-rollback-example-simple": "שחזור העריכות האחרונות לדף <kbd>Main Page</kbd> על־ידי המשתמש <kbd>Example</kbd>.",
+ "apihelp-rollback-example-summary": "שחזור העריכות האחרונות לדף <kbd>Main Page</kbd> מאת משתמש ה־IP‏ <kbd>192.0.2.5</kbd> עם התקציר <kbd>Reverting vandalism</kbd> וסימון של העריכות האלה ושל השחזור בתור עריכות בוט.",
+ "apihelp-rsd-summary": "יצוא סכמת RSD‏ (Really Simple Discovery).",
+ "apihelp-rsd-example-simple": "יצוא סכמת ה־RSD.",
+ "apihelp-setnotificationtimestamp-summary": "עדכון חותם־הזמן של ההודעה עבור דפים במעקב.",
+ "apihelp-setnotificationtimestamp-extended-description": "זה משפיע על הדגשת הדפים שהשתנו ברשימת המעקב ובהיסטוריה, ושליחת דואר אלקטרוני כאשר ההעדפה \"{{int:tog-enotifwatchlistpages}}\" מופעלת.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "לעבוד על כל הדפים שבמעקב.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "חותם־הזמן להגדרת חותם־זמן של הודעה.",
+ "apihelp-setnotificationtimestamp-param-torevid": "לאיזו גרסה להגדיר את חותם הזמן (רק דף אחד).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "הגרסה שחותם־הזמן של ההודעה יוגדר בתור חדש ממנה (רק דף אחד).",
+ "apihelp-setnotificationtimestamp-example-all": "אתחול מצב ההודעה עבור כל רשימת המעקב.",
+ "apihelp-setnotificationtimestamp-example-page": "אתחול מצב ההודעה עבור <kbd>Main Page</kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "הגדרת חותם־הזמן להודעה ל־<kbd>Main page</kbd> כך שכל העריכות מאז 1 בינואר 2012 מוגדרות בתור כאלה שלא נצפו.",
+ "apihelp-setnotificationtimestamp-example-allpages": "אתחול מצב ההודעה עבור דפים במרחב השם <kbd>{{ns:user}}</kbd>.",
+ "apihelp-setpagelanguage-summary": "שנה את השפה של דף",
+ "apihelp-setpagelanguage-extended-description-disabled": "שינוי השפה של דף לא מורשה בוויקי זה.\n\nהפעל את <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> על מנת להשתמש בפעולה זו",
+ "apihelp-setpagelanguage-param-title": "כותרת הדף שאת שפתו ברצונך לשנות. לא אפשרי להשתמש באפשרות עם <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "מזהה הדף שאת שפתו ברצונך לשנות. לא אפשרי להשתמש באפשרות עם <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "קוד השפה של השפה שאליה צריך לשנות את הדף. יש להשתמש ב־<kbd>default</kbd> כדי לאתחל את הדף לשפת בררת המחדל של הוויקי.",
+ "apihelp-setpagelanguage-param-reason": "הסיבה לשינוי.",
+ "apihelp-setpagelanguage-param-tags": "אילו תגי שינוי להחיל על העיול ביומן שמתבצע כתוצאה מהפעולה הזאת.",
+ "apihelp-setpagelanguage-example-language": "שינוי השפה של <kbd>Main Page</kbd> לבסקית.",
+ "apihelp-setpagelanguage-example-default": "שינוי השפה של הדף בעל המזהה 123 לשפה הרגילה של הוויקי.",
+ "apihelp-stashedit-summary": "הכנת עריכה במטמון משותף.",
+ "apihelp-stashedit-extended-description": "זה מיועד לשימוש דרך AJAX מתוך ערך כדי לשפר את הביצועים של שמירת הדף.",
+ "apihelp-stashedit-param-title": "כותרת הדף הנערך.",
+ "apihelp-stashedit-param-section": "מספר הפסקה. <kbd>0</kbd> עבור הפסקה הראשונה, <kbd>new</kbd> עבור פסקה חדשה.",
+ "apihelp-stashedit-param-sectiontitle": "כותרת הפסקה החדשה.",
+ "apihelp-stashedit-param-text": "תוכן הדף.",
+ "apihelp-stashedit-param-stashedtexthash": "גיבוב של תוכן דף מסליק קודם שישמש במקום זה.",
+ "apihelp-stashedit-param-contentmodel": "מודל התוכן של התוכן החדש.",
+ "apihelp-stashedit-param-contentformat": "תסדיר הסדרת תוכן עבור טקסט הקלט.",
+ "apihelp-stashedit-param-baserevid": "מזהה גסה של גרסת הבסיס.",
+ "apihelp-stashedit-param-summary": "לשנות תקציר.",
+ "apihelp-tag-summary": "הוספת או הסרה של תגים מגרסאות בודדות או עיולי יומן בודדים.",
+ "apihelp-tag-param-rcid": "מזהה שינוי אחרון אחד או יותר שתג יתווסף אליו או יוסר ממנו.",
+ "apihelp-tag-param-revid": "מזהה גרסה אחד או יותר שתג יתווסף אליה או יוסר ממנה.",
+ "apihelp-tag-param-logid": "מזהה רשומת יומן אחת או יותר שתג יתווסף אליה או יוסר ממנה.",
+ "apihelp-tag-param-add": "התגים להוספה. אפשר להוסיף רק תגים קיימים.",
+ "apihelp-tag-param-remove": "תגים להסרה. רק תגים שהוגדרו ידנית או שאינם מוגדרים כלל יכולים להיות מוסרים.",
+ "apihelp-tag-param-reason": "סיבה לשינוי.",
+ "apihelp-tag-param-tags": "אילו תגים להחיל על רשומת היומן שתיווצר כתוצאה מהפעולה הזאת.",
+ "apihelp-tag-example-rev": "הוספת התג <kbd>vandalism</kbd> לגרסה עם המזהה 123 בלי לציין סיבה",
+ "apihelp-tag-example-log": "הסרת התג <kbd>spam</kbd> מעיול עם המזהה 123 עם הסיבה <kbd>Wrongly applied</kbd>",
+ "apihelp-tokens-summary": "קבלת אסימונים לפעולות שמשנות נתונים.",
+ "apihelp-tokens-extended-description": "היחידה הזאת הוכרזה בתור מיושנת לטובת [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-tokens-param-type": "סוגי האסימונים לבקש.",
+ "apihelp-tokens-example-edit": "אחזור אסימון עריכה (בררת המחדל).",
+ "apihelp-tokens-example-emailmove": "אחזור אסימון דוא\"ל ואסימון העברה.",
+ "apihelp-unblock-summary": "שחרור משתמש מחסימה.",
+ "apihelp-unblock-param-id": "מזהה החסימה לשחרור (מתקבל דרך <kbd>list=blocks</kbd>). לא יכול לשמש יחד עם <var>$1user</var> או <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "שם משתמש, כתובת IP או טווח כתובות IP לחסימה. לא יכול לשמש יחד עם <var>$1id</var> או <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "מזהה המשתמש שישוחרר מחסימה. לא יכול לשמש יחד עם <var>$1id</var> או <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "סיבה להסרת חסימה.",
+ "apihelp-unblock-param-tags": "תגי שינוי שיחולו על העיול ביומן החסימה.",
+ "apihelp-unblock-example-id": "לשחרר את החסימה עם מזהה #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "לשחרר את החסימה של המשתמש <kbd>Bob</kbd> עם הסיבה <kbd>Sorry Bob</kbd>.",
+ "apihelp-undelete-summary": "שחזור גרסאות של דף מחוק.",
+ "apihelp-undelete-extended-description": "אפשר לאחזר רשימת גרסאות מחוקות (כולל חותמי־זמן) דרך [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]], ואפשר לאחזר רשימת מזהי קבצים מחוקים דרך [[Special:ApiHelp/query+filearchive|list=filearchive]].",
+ "apihelp-undelete-param-title": "שם הדף לשחזור ממחיקה.",
+ "apihelp-undelete-param-reason": "סיבה לשחזור.",
+ "apihelp-undelete-param-tags": "תגי שינוי שיחולו על העיול ביומן המחיקה.",
+ "apihelp-undelete-param-timestamps": "חותמי־זמן של הגרסה לשחזור. אם גם <var>$1timestamps</var> וגם <var>$1fileids</var> ריקים, הכול ישוחזר.",
+ "apihelp-undelete-param-fileids": "מזהי גרסאות הקובץ לשחזור. אם גם <var>$1timestamps</var> וגם <var>$1fileids</var> ריקים, הכול ישוחזר.",
+ "apihelp-undelete-param-watchlist": "הוספה או הסרה של הדף ללא תנאי מרשימת המעקב של המשתמש הנוכחי, להשתמש בהעדפות או לא לשנות את המעקב.",
+ "apihelp-undelete-example-page": "שחזור ממחיקה של הדף <kbd>Main Page</kbd>.",
+ "apihelp-undelete-example-revisions": "שחזור שתי גרסאות של הדף <kbd>Main Page</kbd>.",
+ "apihelp-unlinkaccount-summary": "ביטול קישור של חשבון צד־שלישי מהמשתמש הנוכחי.",
+ "apihelp-unlinkaccount-example-simple": "לנסות להסיר את הקישור של המשתמש הנוכחי לספק המשויך עם <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-upload-summary": "העלאת קובץ, או קבלת מצב ההעלאות הממתינות.",
+ "apihelp-upload-extended-description": "יש מספר שיטות:\n* להעלות את הקובץ ישירות, באמצעות הפרמטר <var>$1file</var>.\n* להעלות את הקובץ בחלקים, באמצעות הפרמטרים <var>$1filesize</var>‏, <var>$1chunk</var> ו־<var>$1offset</var>.\n* לגרום לשרת מדיה־ויקי לאחזר את הקובץ מ־URL באמצעות הפרמטר <var>$1url</var>.\n* להשלים העלאה קודמת שנכשלה בשל אזהרות באמצעות הפרמטר <var>$1filekey</var>.\nלתשומך לבך, יש לעשות את HTTP POST בתור העלאת קובץ (כלומר באמצעות <code>multipart/form-data</code>) בעת שליחת ה־<var>$1file</var>.",
+ "apihelp-upload-param-filename": "שם קובץ היעד.",
+ "apihelp-upload-param-comment": "הערת העלאה. משמש גם בתור טקסט הדף ההתחלתי עבור קבצים חדשים אם <var>$1text</var> אינו מצוין.",
+ "apihelp-upload-param-tags": "שינוי תגים להחלה לרשומות ההעלאה ולגרסאות דף הקובץ.",
+ "apihelp-upload-param-text": "טקסט הדף ההתחלתי לקבצים חדשים.",
+ "apihelp-upload-param-watch": "לעקוב אחרי הדף.",
+ "apihelp-upload-param-watchlist": "הוספה או הסרה של הדף ללא תנאי מרשימת המעקב של המשתמש הנוכחי, להשתמש בהעדפות או לא לשנות את המעקב.",
+ "apihelp-upload-param-ignorewarnings": "להתעלם מכל האזהרות.",
+ "apihelp-upload-param-file": "תוכן הקובץ.",
+ "apihelp-upload-param-url": "URL לאחזור הקובץ.",
+ "apihelp-upload-param-filekey": "מפתח שמזהה העלאה קודמת שהוסלקה באופן זמני.",
+ "apihelp-upload-param-sessionkey": "אותו דבר כמו $1filekey, מושאר לצור תאימות אחורה.",
+ "apihelp-upload-param-stash": "אם זה מוגדר, השרת יסליק זמנית את הקובץ במקום להוסיף אותו למאגר.",
+ "apihelp-upload-param-filesize": "גודל הקובץ של כל ההעלאה.",
+ "apihelp-upload-param-offset": "היסט הפלח בבתים.",
+ "apihelp-upload-param-chunk": "תוכן החתיכה.",
+ "apihelp-upload-param-async": "להפוך פעולות קבצים גדולות לאסינכרוניות כשאפשר.",
+ "apihelp-upload-param-checkstatus": "לאחזר רק מצב העלאה עבור מפתח הקובץ שניתן.",
+ "apihelp-upload-example-url": "להעלות מ־URL.",
+ "apihelp-upload-example-filekey": "להשלים העלאה שנכשלה בשל אזהרות.",
+ "apihelp-userrights-summary": "שינוי חברות בקבוצות של המשתמש.",
+ "apihelp-userrights-param-user": "שם משתמש.",
+ "apihelp-userrights-param-userid": "מזהה משתמש.",
+ "apihelp-userrights-param-add": "הוספת המשתמש לקבוצות האלו, ואם הוא כבר חבר, עדכון זמן התפוגה של החברות בקבוצה הזאת.",
+ "apihelp-userrights-param-expiry": "חותמי־זמן תפוגה. יכולים להיות יחסיים (למשל <kbd>5 months</kbd> או <kbd>2 weeks</kbd>) או מוחלטים (למשל <kbd>2014-09-18T12:34:56Z</kbd>). אם מוגדר רק חותם־זמן אחד, הוא ישמש לכל הקבוצות שהועברו לפרמטר <var>$1add</var>. יש להשתמש ב־<kbd>infinite</kbd>‏, <kbd>indefinite</kbd>‏, <kbd>infinity</kbd>, או <kbd>never</kbd> בשביל קבוצת משתמשים שאינה פגה לעולם.",
+ "apihelp-userrights-param-remove": "הסרת משתמש מהקבוצות האלו.",
+ "apihelp-userrights-param-reason": "סיבה לשינוי.",
+ "apihelp-userrights-param-tags": "לשנות את התגים שיוחלו על העיול ביומן הרשאות המשתמש.",
+ "apihelp-userrights-example-user": "הוספת המשתמש <kbd>FooBot</kbd> לקבוצה <kbd>bot</kbd> והסרתו מהקבוצות <kbd>sysop</kbd> ו־<kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "הוספת המשתמש עם המזהה <kbd>123</kbd> לקבוצה <kbd>bot</kbd> והסרתו מהקבוצות <kbd>sysop</kbd> ו־<kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-expiry": "להוסיף את <kbd>SometimeSysop</kbd> לקבוצה <kbd>sysop</kbd> לחודש אחד.",
+ "apihelp-validatepassword-summary": "לבדוק תקינות ססמה אל מול מדיניות הססמאות של הוויקי.",
+ "apihelp-validatepassword-extended-description": "התקינות מדווחת כ־<samp>Good</samp> אם הססמה קבילה, <samp>Change</samp> אם הססמה יכולה לשמש לכניסה, אבל צריכה להשתנות, או <samp>Invalid</samp> אם הססמה אינה שמישה.",
+ "apihelp-validatepassword-param-password": "ססמה שתקינותה תיבדק.",
+ "apihelp-validatepassword-param-user": "שם משתמש, לשימוש בעת בדיקת יצירת חשבון. המשתמש ששמו ניתן צריך לא להיות קיים.",
+ "apihelp-validatepassword-param-email": "כתובת הדוא\"ל, לשימוש בעת בדיקת יצירת חשבון.",
+ "apihelp-validatepassword-param-realname": "שם אמתי, לשימוש בעת בדיקת יצירת חשבון.",
+ "apihelp-validatepassword-example-1": "לבדוק את תקינות הססמה <kbd>foobar</kbd> עבור המשתמש הנוכחי.",
+ "apihelp-validatepassword-example-2": "לבדוק את תקינות הססמה <kbd>qwerty</kbd> ליצירת החשבון <kbd>Example</kbd>.",
+ "apihelp-watch-summary": "להוסיף דפים לרשימת המעקב של המשתמש הנוכחי או הסרתם ממנה.",
+ "apihelp-watch-param-title": "הדף להוסיף לרשימת המעקב או להסיר ממנה. יש להשתמש במקום זאת ב־<var>$1titles</var>.",
+ "apihelp-watch-param-unwatch": "אם זה מוגדר, הדף יהיה לא במעקב במקום להיות במעקב.",
+ "apihelp-watch-example-watch": "לעקוב אחרי הדף <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "להפסיק את המעקב אחרי הדף <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-generator": "לעקוב אחרי הדפים הראשונים במרחב הראשי.",
+ "apihelp-format-example-generic": "להחזיר את תוצאות השאילתה בתסדיר $1.",
+ "apihelp-format-param-wrappedhtml": "החזרת HTML מעוצב ומודולי ResourceLoader משויכים בתור עצם JSON.",
+ "apihelp-json-summary": "לפלוט נתונים בתסדיר JSON.",
+ "apihelp-json-param-callback": "אם זה צוין, עוטף את הפלט לתוך קריאת פונקציה נתונה. למען הבטיחות, כל הנתונים הייחודיים למשתמש יוגבלו.",
+ "apihelp-json-param-utf8": "אם זה צוין, רוב התווים שאינם ASCII (אבל לא כולם) יקודדו בתור UTF-8 במקום להתחלף בסדרות חילוף הקסדצימליות. זאת בררת המחדל אם הערך של <var>formatversion</var> הוא לא <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "אם זה צוין, לקודד את כל מה שאינו ASCII בסדרות חילוף הקסדצימליות. זאת בררת המחדל כש־<var>formatversion</var> היא <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "תסדיר הפלט:\n;1:תסדיר עם תאימות אחורה (ערכים בוליאניים בסגנון XML, מפתחות <samp>*</samp> לצומתי תוכן, וכו').\n;2:תסדיר מודרני ניסיוני. הפרטים יכולים להשתנות!\n;latest:להשתמש בתסדיר החדש ביותר (כרגע <kbd>2</kbd>), יכול להשתנות ללא התראה.",
+ "apihelp-jsonfm-summary": "לפלוט נתונים בתסדיר JSON (עם הדפסה יפה ב־HTML).",
+ "apihelp-none-summary": "לא לפלוט שום דבר.",
+ "apihelp-php-summary": "לפלוט נתונים בתסדיר PHP מוסדר.",
+ "apihelp-php-param-formatversion": "תסדיר הפלט:\n;1:תסדיר עם תאימות אחורה (ערכים בוליאניים בסגנון XML, מפתחות <samp>*</samp> לצומתי תוכן, וכו').\n;2:תסדיר מודרני ניסיוני. הפרטים יכולים להשתנות!\n;latest:להשתמש בתסדיר החדש ביותר (כרגע <kbd>2</kbd>), יכול להשתנות ללא התראה.",
+ "apihelp-phpfm-summary": "לפלוט נתונים בתסדיר PHP מוסדר (עם הדפסה יפה ב־HTML).",
+ "apihelp-rawfm-summary": "לפלוט את הנתונים, כולל אלמנטים לניפוי שגיאות, בתסדיר JSON (עם הדפסה יפה ב־HTML).",
+ "apihelp-xml-summary": "לפלוט נתונים בתסדיר XML.",
+ "apihelp-xml-param-xslt": "אם צוין, יש להוסיף את שם הדף כגיליון עיצוב XSL. על הערך להיות כותרת ב {{ns:MediaWiki}} במרחב שם המשתמש, המסתיים ב- <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "אם זה צוין, מוסיף מרחב שם של XML.",
+ "apihelp-xmlfm-summary": "לפלוט נתונים בתסדיר XML (עם הדפסה יפה ב־HTML).",
+ "api-format-title": "תוצאה של API של מדיה־ויקי",
+ "api-format-prettyprint-header": "זהו ייצוג ב־HTML של תסדיר $1. תסדיר HTML טוב לתיקון שגיאות, אבל אינו מתאים ליישומים.\n\nיש לציין את הפרמטר <var>format</var> כדי לשנות את תסדיר הפלט. כדי לראות ייצוג של תסדיר $1 לא ב־HTML יש לרשום <kbd>format=$2</kbd>.\n\nר' את [[mw:Special:MyLanguage/API|התיעוד המלא]], או את [[Special:ApiHelp/main|העזרה של API]] למידע נוסף.",
+ "api-format-prettyprint-header-only-html": "זה ייצוג HTML שמיועד לניפוי שגיאות ואינו מתאים לשימוש ביישומים.\n\nר' את [[mw:Special:MyLanguage/API|התיעוד המלא]] או את [[Special:ApiHelp/main|העזרה של API]] למידע נוסף.",
+ "api-format-prettyprint-header-hyperlinked": "זהו ייצוג ב־HTML של תסדיר $1. תסדיר HTML טוב לתיקון שגיאות, אבל אינו מתאים ליישומים.\n\nיש לציין את הפרמטר <var>format</var> כדי לשנות את תסדיר הפלט. כדי לראות ייצוג של תסדיר $1 לא ב־HTML יש להגדיר [$3 <kbd>format=$2</kbd>].\n\nר' את [[mw:API|התיעוד המלא]], או את [[Special:ApiHelp/main|העזרה של API]] למידע נוסף.",
+ "api-format-prettyprint-status": "התשובה הזאת הייתה מוחזרת עם סטטוס ה־HTTP מס' $1 עם הטקסט $2.",
+ "api-login-fail-aborted": "אימות דורש הידוד עם המשתמש, שאינו נתמך ב־<kbd>action=login</kbd>. כדי שיהיה אפשר להיכנס לחשבון עם <kbd>action=login</kbd>, נא לראות את [[Special:BotPasswords]]. כדי להמשיך להשתמש בכניסה עם חשבון ראשי, ר' <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-aborted-nobotpw": "אימות דורש הידוד עם המשתמש, שאינו נתמך ב־<kbd>action=login</kbd>. כדי להיכנס לחשבון, ר' <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-badsessionprovider": "לא ניתן להיכנס לחשבון באמצעות $1.",
+ "api-login-fail-sameorigin": "לא ניתן להיכנס לחשבון כאשר מדיניות מקור זהה אינה חלה.",
+ "api-pageset-param-titles": "רשימת כותרות.",
+ "api-pageset-param-pageids": "רשימת מזהי דף לעבוד עליהם.",
+ "api-pageset-param-revids": "רשימת מזהי גרסה לעבוד עליהם.",
+ "api-pageset-param-generator": "קבלת רשימת דפים לעבוד עליהם על־ידי הרצת יחידת ה־query שצוינה.\n\n<strong>לתשומת לבך:</strong> לשמות בפרמטר generator צריכה להיות התחילית \"g\", ר' דוגמאות.",
+ "api-pageset-param-redirects-generator": "פתרון אוטומטי של הפניות ב־<var>$1titles</var>, ב־<var>$1pageids</var>, וב־<var>$1revids</var>, ובדפים שמחזיר <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "פתרון אוטומטי של הפניות ב־<var>$1titles</var>, ב־<var>$1pageids</var> וב־<var>$1pageids</var>.",
+ "api-pageset-param-converttitles": "המרת כותרות לסוגי כתב אחרים אם זה נחוץ. זה עובד רק אם שפת הכותרת של הוויקי תומכת בהמרת סוגי כתב. השפות שתומכות בהמרת סוגי כתב הן $1.",
+ "api-help-title": "עזרה של MediaWiki API",
+ "api-help-lead": "זהו דף תיעוד של API שנוצר באופן אוטומטי.\n\nתיעוד ודוגמאות: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "יחידה ראשית",
+ "api-help-undocumented-module": "אין תיעוד למודול $1.",
+ "api-help-flag-deprecated": "יחידה זו אינה מומלצת לשימוש.",
+ "api-help-flag-internal": "<strong>היחידה הזאת היא פנימית או בלתי־יציבה.</strong> הפעולה שלה יכולה להשתנות ללא הודעה מוקדמת.",
+ "api-help-flag-readrights": "יחידה זו דורשת הרשאות קריאה.",
+ "api-help-flag-writerights": "היחידה הזאת דורשת הרשאות כתיבה.",
+ "api-help-flag-mustbeposted": "יחידה זו מקבלת רק בקשות POST.",
+ "api-help-flag-generator": "אפשר להשתמש ביחידה הזאת בתור מחולל.",
+ "api-help-source": "מקור: $1",
+ "api-help-source-unknown": "מקור: <span class=\"apihelp-unknown\">לא ידוע</span>",
+ "api-help-license": "רישיון: <span dir=\"auto\">[[$1|$2]]</span>",
+ "api-help-license-noname": "רישיון: [[$1|ראו קישור]]",
+ "api-help-license-unknown": "רישיון: <span class=\"apihelp-unknown\">לא ידוע</span>",
+ "api-help-parameters": "{{PLURAL:$1|פרמטר|פרמטרים}}:",
+ "api-help-param-deprecated": "מיושן.",
+ "api-help-param-required": "פרמטר זה נדרש.",
+ "api-help-datatypes-header": "סוגי נתונים",
+ "api-help-datatypes": "קלט למדיה־ויקי צריך להיות בקידוד UTF-8 מנורמל ב־NFC. מדיה־ויקי יכולה לנסות להמיר קלט אחר, אבל זה עלול לגרום לפעולות מסוימות (כגון [[Special:ApiHelp/edit|עריכות]] עם בדיקות MD5) להיכשל.\n\nחלק מסוגי הפרמטרים בבקשות API דורשים הסבר נוסף:\n;בוליאני (boolean)\n:פרמטרים בוליאניים עובדים כמו תיבות סימון של HTML: אם הפרמטר צוין, בלי קשר לערך שלו, הוא אמת (true). בשביל ערך שקר (false), יש להשמיט את הפרמטר לגמרי.\n;חותם־זמן (timestamp)\n:אפשר לכתוב חותמי־זמן במספר תסדירים. תאריך ושעה לפי ISO 8601 הוא הדבר המומלת. כל הזמנים מצוינים ב־ UTC, לא תהיה השפעה לשום אזור זמן שיצוין.\n:* תאריך ושעה לפי ISO 8601‏, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (לא חובה לכתוב פיסוק ו־<kbd>Z</kbd>)\n:* תאריך ושעה לפי ISO 8601 עם חלקי שנייה (שלא תהיה להם שום השפעה), <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (לא חובה לכתוב קווים מפרידים, נקודתיים ו־<kbd>Z</kbd>)\n:* תסדיר MediaWiki‏, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* תסדיר מספרי כללי, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (לאזור זמן אופציונלי של <kbd>GMT</kbd>‏, <kbd dir=\"ltr\">+<var>##</var></kbd>, או <kbd dir=\"ltr\">-<var>##</var></kbd> אין השפעה)\n:* תסדיר EXIF‏, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* תסדיר RFC 2822 (אפשר להשמיט את אזור הזמן), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* תסדיר RFC 850 (אפשר להשמיט את אזור הזמן), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* תסדיר C ctime‏, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* שניות מאז 1970-01-01T00:00:00Z בתור מספר שלך בין 1 ל־13 (לא כולל <kbd>0</kbd>)\n:* המחרוזת <kbd>now</kbd>\n;מפריד ערכים מרובים חלופי\n:פרמטרים שלוקחים ערכים מרובים בדרך־כלל נשלחים עם הערכים מופרדים באמצעות תו מקל, למשל <kbd>param=value1|value2</kbd> או <kbd>param=value1%7Cvalue2</kbd>. אם הערך צריך להכיל את תו המקל, יש להשתמש ב־U+001F (מפריד יחידות) בתור המפריד ''וגם'' להוסיף לתחילת הערך U+001F, למשל <kbd>param=%1Fvalue1%1Fvalue2</kbd>.",
+ "api-help-param-type-limit": "סוג: מספר שלם או <kbd>max</kbd>",
+ "api-help-param-type-integer": "סוג: {{PLURAL:$1|1=מספר שלם|2=רשימת מספרים שלמים}}",
+ "api-help-param-type-boolean": "סוג: בוליאני ([[Special:ApiHelp/main#main/datatypes|פרטים]])",
+ "api-help-param-type-timestamp": "סוג: {{PLURAL:$1|חותם־זמן|רשימת חותמי־זמן}} ([[Special:ApiHelp/main#main/datatypes|תסדירים מורשים]])",
+ "api-help-param-type-user": "סוג: {{PLURAL:$1|1=שם משתמש|2=רשימת שמות משתמשים}}",
+ "api-help-param-list": "{{PLURAL:$1|1=אחד מהערכים הבאים|2=ערכים (מופרדים באמצעות \"<kbd>{{!}}</kbd>\" או or [[Special:ApiHelp/main#main/datatypes|תו חלופי]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=חייב להיות ריק|יכול להיות ריק או $2}}",
+ "api-help-param-limit": "מספר הפרמטרים לא יכול להיות גדול מ־$1.",
+ "api-help-param-limit2": "המספר המרבי המותר הוא $1 (עבור בוטים – $2).",
+ "api-help-param-integer-min": "ה{{PLURAL:$1|1=ערך|2=ערכים}} לא יכולים להיות קטנים מ־$2.",
+ "api-help-param-integer-max": "ה{{PLURAL:$1|1=ערך לא יכול להיות גדול|2=ערכים לא יכולים להיות גדולים}} מ־$3.",
+ "api-help-param-integer-minmax": "ה{{PLURAL:$1|1=ערך חייב|2=ערכים חייבים}} להיות בין $2 ל־$3.",
+ "api-help-param-upload": "חייב להישלח (posted) בתור העלאת קובץ באמצעות multipart/form-data.",
+ "api-help-param-multi-separate": "הפרדה בין ערכים נעשית באמצעות <kbd>|</kbd> או [[Special:ApiHelp/main#main/datatypes|תו חלופי]].",
+ "api-help-param-multi-max": "מספר הערכים המרבי הוא {{PLURAL:$1|$1}} (עבור בוטים – {{PLURAL:$2|$2}}).",
+ "api-help-param-multi-max-simple": "המספר המרבי של הערכים הוא {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "כדי לתת את כל הערכים, יש להשתמש ב־<kbd>$1</kbd>.",
+ "api-help-param-default": "ברירת מחדל: $1",
+ "api-help-param-default-empty": "ברירת מחדל: <span class=\"apihelp-empty\">(ריק)</span>",
+ "api-help-param-token": "אסימון \"$1\" שאוחזר מ־[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "לשם תאימות, גם האסימון שמשמש בממשק דפדפן מתקבל.",
+ "api-help-param-disabled-in-miser-mode": "כבוי בשל [[mw:Special:MyLanguage/Manual:$wgMiserMode|מצב חיסכון]].",
+ "api-help-param-limited-in-miser-mode": "<strong>לתשומת לבך:</strong> בשל [[mw:Special:MyLanguage/Manual:$wgMiserMode|מצב חיסכון]], שימוש בזה יכול להוביל לפחות מ־<var>$1limit</var> תוצאות לפני המשך; במצבים קיצוניים ייתכן שיחזרו אפס תוצאות.",
+ "api-help-param-direction": "באיזה כיוון למנות:\n;newer:לרשום את הישנים ביותר בהתחלה. לתשומת לבך: $1start חייב להיות לפני $1end.\n;older:לרשום את החדשים ביותר בהתחלה (בררת מחדל). לתשומת לבך: $1start חייב להיות אחרי $1end.",
+ "api-help-param-continue": "כשיש עוד תוצאות, להשתמש בזה בשביל להמשיך.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(ללא תיאור)</span>",
+ "api-help-examples": "{{PLURAL:$1|דוגמה|דוגמאות}}:",
+ "api-help-permissions": "{{PLURAL:$1|הרשאה|הרשאות}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|הוענק ל|הוענקו ל}}: $2",
+ "api-help-right-apihighlimits": "להשתמש במגבלות גבוהות יותר בשאילתות API (שאילתות אטיות: $1; שאילתות מהירות: $2). המגבלות לשאילתות אטיות חלות גם על פרמטרים מרובי־ערכים.",
+ "api-help-open-in-apisandbox": "<small>[פתיחה בארגז חול]</small>",
+ "api-help-authmanager-general-usage": "הנוהל הכללי לשימוש במודול הזה הוא:\n# אחזור השדות הזמינים מ־<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> עם <kbd>amirequestsfor=$4</kbd> ואסימון <kbd>$5</kbd> מתוך <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# הצגת השדות למשתמש וקבלת אישור ממנו.\n# שליחה (Post) למודול הזה עם <var>$1returnurl</var> וכל השדות הרלוונטיים.\n# בדיקת ה־<samp>status</samp> בתשובה.\n#* אם קיבלת <samp>PASS</samp> או <samp>FAIL</samp>, זה הסיום. הפעולה שלך הצליחה או נכשלה.\n#* אם קיבלת <samp>UI</samp>, יש להציג את השדות החדשים למשתמש ולקבל את מה שהוא ישלח. אחר־כך יש לשלוח (post) למודול הזה עם <var>$1continue</var> ועם הגדרות של השדות הרלוונטיים ולחזור על צעד 4.\n#* אם קיבלת <samp>REDIRECT</samp>, יש להפנות את המשתמש ל־<samp>redirecttarget</samp> ולחכות לחזרה אל <var>$1returnurl</var>. אחר־כך לשלוח (post) למודול הזה עם <var>$1continue</var> ועם כל השדות שהועברו ל־URL שחוזרים אליו ולחזור על צעד 4.\n#* אם קיבלת <samp>RESTART</samp>, זה אומר שהאימות עבד אבל אין חשבון משתמש מקושר. באפשרותך לטפל בזה כמו ב־<samp>UI</samp> או ב־<samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-requests": "להשתמש רק בבקשות האימות האלו, מאת <samp>id</samp> שהוחזר מ־<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> עם <kbd>amirequestsfor=$1</kbd> או מתשובה קודמת למודול הזה.",
+ "api-help-authmanagerhelper-request": "להשתמש בבקשת האימות הזאת, מאת <samp>id</samp> שהוחזר מ־<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> עם <kbd>amirequestsfor=$1</kbd>.",
+ "api-help-authmanagerhelper-messageformat": "תסדיר לשימוש בהחזרת הודעות.",
+ "api-help-authmanagerhelper-mergerequestfields": "מיזוג מידע של שדות עבור כל בקשות האימות למערך אחד.",
+ "api-help-authmanagerhelper-preservestate": "שימור מצב מניסיון כניסה קודם, אם אפשר.",
+ "api-help-authmanagerhelper-returnurl": "כתובת URL לחזרה עם זרימות אימות צד־שלישי, חייב להיות מוחלט. נדרש או זה או <var>$1continue</var>.\n\nעם קבלת תשובת <samp>REDIRECT</samp>, בדרך־כלל תפתח דפדפן או תצוגת וב בכתובת ה־<samp>redirecttarget</samp> שצוינה בשביל זרימת אימות צד־שלישי. כשזה יושלם, הצד השלישי ישלח את הדפדפן או את תצוגת הווב לכתובת הזאת. יש לחלץ את כל הפרמטרים של שאילתה או בקשת POST מה־URL ולהעביר אותם בתור בקשת <var>$1continue</var> למודול ה־API הזה.",
+ "api-help-authmanagerhelper-continue": "הבקשה הזאת היא המשך אחרי תשובת <samp>UI</samp> או <samp>REDIRECT</samp> קודמת. נדרש זה או <var>$1returnurl</var>.",
+ "api-help-authmanagerhelper-additional-params": "המודול הזה מקבל פרמטרים נוספים בהתאם לבקשות אימות זמינות. יש להשתמש ב־<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> עם <kbd>amirequestsfor=$1</kbd> (או תגובה קודמת מהמודול הזה, אם זה זמין) כדי להבין מה הבקשות הזמינות ובאילו שדות הן משתמשות.",
+ "apierror-allimages-redirect": "יש להשתמש ב־<kbd>gaifilterredir=nonredirects</kbd> במקום ב־<var>redirects</var> בעת שימוש ב־<kbd>allimages</kbd> בתור מחולל.",
+ "apierror-allpages-generator-redirects": "יש להשתמש ב־<kbd>gaifilterredir=nonredirects</kbd> במקום ב־<var>redirects</var> בעת שימוש ב־<kbd>allpages</kbd> בתור מחולל.",
+ "apierror-appendnotsupported": "אי־אפשר להוסיף את זה לדפים שמשתמשים בדגם תוכן $1.",
+ "apierror-articleexists": "הערך שניסית ליצור כבר נוצר.",
+ "apierror-assertbotfailed": "הבדיקה שלמשתמש יש הרשאת <code>bot</code> נכשלה.",
+ "apierror-assertnameduserfailed": "הבדיקה שהמשתמש הוא \"$1\" נכשלה.",
+ "apierror-assertuserfailed": "הבדיקה שהמשתמש נכנס לחשבון נכשלה.",
+ "apierror-autoblocked": "כתובת ה־IP שלך נחסמה אוטומטית, כי היא שימשה משתמש חסום.",
+ "apierror-badconfig-resulttoosmall": "הערך של <code dir=\"ltr\">$wgAPIMaxResultSize</code> בוויקי הזה קטן מלהחזיק מידע בסיסי על תוצאה.",
+ "apierror-badcontinue": "פרמטר continue בלתי־תקין. יש להעביר את הערך המקורי שהחזירה השאילתה הקודמת.",
+ "apierror-baddiff": "לא ניתן לאחזר את ההשוואה. גרסה אחת לא קיימת או ששתיהן לא קיימות, או שאין לך הרשאה להציג אותן.",
+ "apierror-baddiffto": "יש להגדיר את <var>$1diffto</var> למספר שאינו שלילי, <kbd dir=\"ltr\">prev</kbd>, <kbd dir=\"ltr\">next</kbd> או <kbd dir=\"ltr\">cur</kbd>",
+ "apierror-badformat-generic": "התסדיר המבוקש $1 אינו נתמך במודל התוכן $2.",
+ "apierror-badformat": "התסדיר המבוקש $1 אינו נתמך במודל התוכן $2 שמשמש ב־$3.",
+ "apierror-badgenerator-notgenerator": "המודול <kbd>$1</kbd> אינו יכול לשמש כמחולל.",
+ "apierror-badgenerator-unknown": "<kbd>generator=$1</kbd> בלתי־ידוע.",
+ "apierror-badip": "הפרמטר IP אינו תקין.",
+ "apierror-badmd5": "גיבוב MD5 היה שגוי.",
+ "apierror-badmodule-badsubmodule": "למודול <kbd>$1</kbd> אין תת־מודול \"$2\".",
+ "apierror-badmodule-nosubmodules": "למודול <kbd>$1</kbd> אין תת־מודולים.",
+ "apierror-badparameter": "ערך בלתי־תקין לפרמטר <var>$1</var>.",
+ "apierror-badquery": "שאילתה בלתי־תקינה.",
+ "apierror-badtimestamp": "ערך בלתי־תקין \"$2\" לפרמטר חותם זמן <var>$1</var>.",
+ "apierror-badtoken": "אסימון CSRF בלתי־תקין.",
+ "apierror-badupload": "פרמטר העלאת הקובץ <var>$1</var> הוא לא העלאת קובץ; יש להקפיד להשתמש ב־<code>multipart/form-data</code> בשביל בקשת ה־POST שלך ולכלול שם קובץ בכותר <code>Content-Disposition</code>.",
+ "apierror-badurl": "ערך בלתי־תקין \"$2\" לפרמטר URL בשם <var>$1</var>.",
+ "apierror-baduser": "ערך בלתי־תקין \"$2\" לפרמטר משתמש בשם <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "הפרדת ערכים מרובים ב־U+001F אפשרית רק בפרמטרים מרובי־פרמטרים.",
+ "apierror-bad-watchlist-token": "סופק אסימון רשימת מעקב בלתי־תקין. נא להשתמש באסימון תקין ב־[[Special:Preferences]].",
+ "apierror-blockedfrommail": "נחסמת משליחת דוא״ל.",
+ "apierror-blocked": "נחסמת מעריכה.",
+ "apierror-botsnotsupported": "הממשק הזה לא נתמך עבור בוטים.",
+ "apierror-cannot-async-upload-file": "הפרמטרים <var>async</var> ו־<var>file</var> אינם יכולים להיות משולבים. אם ברצונך לבצע עיבוד אסינכרוני של הקובץ המועלה שלך, יש להעלות אותו תחילה לסליק (באמצעות הפרמטר <var>stash</var>) ואז לפרסם את הקובץ המוסלק באופן אסינכרוני (באמצעות <var>filekey</var> ו־<var>async</var>).",
+ "apierror-cannotreauthenticate": "הפעולה הזאת אינה זמינה, כי הזהות שלך לא יכולה להיות מאומתת.",
+ "apierror-cannotviewtitle": "אין לך הרשאה להציג את $1.",
+ "apierror-cantblock-email": "אין לך הרשאה לחסום משתמשים משליחת דואר אלקטרוני דרך הוויקי.",
+ "apierror-cantblock": "אין לך הרשאה לחסום משתמשים.",
+ "apierror-cantchangecontentmodel": "אין לך הרשאה לשנות את דגם התוכן של דף.",
+ "apierror-canthide": "אין לך הרשאה להסתיר שמות משתמשים ביומן החסימה.",
+ "apierror-cantimport-upload": "אין לך הרשאה לייבא דפים מוּעלים.",
+ "apierror-cantimport": "אין לך הרשאה לייבא דפים.",
+ "apierror-cantoverwrite-sharedfile": "קובץ היעד קיים במאגר משותף ואין לך הרשאה לעקוף אותו.",
+ "apierror-cantsend": "לא נכנסת לחשבון, אין לך חשבון דואר אלקטרוני מאושר, או שאסור לך לשלוח דואר אלקטרוני למשתמשים אחרים, אז אינך לך אפשרות לשלוח דואר אלקטרוני.",
+ "apierror-cantundelete": "לא היה אפשר לשחזר ממחיקה: אולי הגרסאות המבוקשות אינן קיימות, ואולי הן כבר נמחקו.",
+ "apierror-changeauth-norequest": "יצירת בקשת השינוי נכשלה.",
+ "apierror-chunk-too-small": "גודל הפלח המזערי הוא {{PLURAL:$1|בית אחד|$1 בתים}} בשביל פלחים לא סופיים.",
+ "apierror-cidrtoobroad": "טווחי CIDR של $1 שרחבים יותר מ־/$2 אינם קבילים.",
+ "apierror-compare-no-title": "לא ניתן לעשות התמרה לפני שמירה ללא כותרת. נא לנסות לציין <var>fromtitle</var> או <var>totitle</var>.",
+ "apierror-compare-relative-to-nothing": "אין גרסת \"from\" עבור <var>torelative</var> שתהיה יחסית.",
+ "apierror-contentserializationexception": "הסדרת התוכן נכשלה: $1",
+ "apierror-contenttoobig": "התוכן שסיפקת חורג מגודל הערך המרבי של {{PLURAL:$1|קילובייט אחד|$1 קילובייטים}}.",
+ "apierror-copyuploadbaddomain": "העלאות לפי URL אינם מורשות מהמתחם הזה.",
+ "apierror-copyuploadbadurl": "העלאה אינה מותרת מה־URL הזה.",
+ "apierror-create-titleexists": "כותרות קיימות אינם יכולות מוגנות עם <kbd>create</kbd>.",
+ "apierror-csp-report": "בעיבוד דו\"ח CSP אירעה שגיאה: $1",
+ "apierror-databaseerror": "[$1] שגיאת שאילתת מסד נתונים.",
+ "apierror-deletedrevs-param-not-1-2": "הפרמטר <var>$1</var> אינו יכול לשמש במצבים 1 או 2.",
+ "apierror-deletedrevs-param-not-3": "הפרמטר <var>$1</var> אינו יכול במצב 3.",
+ "apierror-emptynewsection": "יצירת פסקאות חדשות ריקות בלתי־אפשרי.",
+ "apierror-emptypage": "יצירת דפים חדשים ריקים אינו מותר.",
+ "apierror-exceptioncaught": "[$1] נתפס חריג: $2",
+ "apierror-filedoesnotexist": "הקובץ אינו קיים.",
+ "apierror-fileexists-sharedrepo-perm": "קובץ היעד קיים במאגר משותף. יש להשתמש בפרמטר <var>ignorewarnings</var> כדי לעקוף אותו.",
+ "apierror-filenopath": "לא ניתן לקבל נתיב לקובץ מקומי.",
+ "apierror-filetypecannotberotated": "לא ניתן לסובב את סוג הקובץ הזה.",
+ "apierror-formatphp": "התשובה הזאת לא יכולה להיות מיוצגת עם <kbd>format=php</kbd>. ר' https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "הכותרת בשביל <kbd>$1</kbd> צריכה להיות קובץ.",
+ "apierror-import-unknownerror": "שגיאה בלתי־ידועה בייצוא: $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> אינו יכול להיות גדול מ־$2 (עכשיו מוגדר $3) עבור בוטים או מפעילים.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> אינו יכול להיות גדול מ־$2 (עכשיו מוגדר $3) עבור משתמשים.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> אינו יכול להיות גדול מ־$2 (עכשיו מוגדר $3).",
+ "apierror-invalidcategory": "שם הקטגוריה שהזנת אינו תקין.",
+ "apierror-invalid-chunk": "ההיסט בתוספת הפלח הנוכחי גדולים מגודל הקובץ כפי שנטען.",
+ "apierror-invalidexpiry": "זמן תפוגה בלתי־תקין \"$1\".",
+ "apierror-invalid-file-key": "לא מפתח קובץ תקין.",
+ "apierror-invalidlang": "קוד שפה בלתי־תקין לפרמטר <var>$1</var>.",
+ "apierror-invalidoldimage": "הפרמטר <var>oldimage</var> נשלח בתסדיר בלתי־תקין.",
+ "apierror-invalidparammix-cannotusewith": "הפרמטר <kbd>$1</kbd> אינו יכול לשמש עם <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "הפרמטר <kbd>$1</kbd> יכול לשמש רק עם <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "לא ניתן לשלב את <kbd>section=new</kbd> עם הפרמטרים <var>oldid</var>‏, <var>pageid</var> או <var>page</var>. נא להשתמש ב־<var>title</var> ו־<var>text</var>.",
+ "apierror-invalidparammix": "{{PLURAL:$2|הפרמטרים}} $1 אינם יכולים לשמש יחדיו.",
+ "apierror-invalidsection": "הפרמטר <var>section</var> להיות מזהה מקטע תקין או <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "גיבוב ה־SHA1Base36 שסופק אינו תקין.",
+ "apierror-invalidsha1hash": "גיבוב ה־SHA1 שסופק אינו תקין.",
+ "apierror-invalidtitle": "כותרת רעה \"$1\".",
+ "apierror-invalidurlparam": "ערך בלתי־תקין עבור <var>$1urlparam</var> (ערך: <kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "שם משתמש בלתי־תקין \"$1\".",
+ "apierror-invaliduserid": "מזהה המשתמש <var>$1</var> אינו תקין.",
+ "apierror-maxlag-generic": "ממתין לשרת מסד נתונים: עיכוב של {{PLURAL:$1|שנייה אחת|$1 שניות}}.",
+ "apierror-maxlag": "ממתין ל־$2: שיהוי של {{PLURAL:$1|שנייה אחת|$1 שניות}}.",
+ "apierror-mimesearchdisabled": "חיפוש MIME כבוי במצב קמצן.",
+ "apierror-missingcontent-pageid": "תוכן חסר עבור מזהה הדף $1.",
+ "apierror-missingcontent-revid": "תוכן חסר עבור מזהה הגרסה $1.",
+ "apierror-missingparam-at-least-one-of": "דרוש {{PLURAL:$2|הפרמטר|לפחות אחד מהפרמטרים}} $1.",
+ "apierror-missingparam-one-of": "דרוש {{PLURAL:$2|הפרמטר|אחד מהפרמטרים}} $1.",
+ "apierror-missingparam": "הפרמטר <var>$1</var> צריך להיות מוגדר.",
+ "apierror-missingrev-pageid": "אין גרסה נוכחית של דף עם המזהה $1.",
+ "apierror-missingrev-title": "אין גרסה נוכחית לכותרת $1.",
+ "apierror-missingtitle-createonly": "כותרות חסרות יכולות להיות מוגנות עם <kbd>create</kbd>.",
+ "apierror-missingtitle": "הדף שנתת אינו קיים.",
+ "apierror-missingtitle-byname": "הדף $1 אינו קיים.",
+ "apierror-moduledisabled": "המודול <kbd>$1</kbd> כובה.",
+ "apierror-multival-only-one-of": "{{PLURAL:$3|רק הערך|רק אחד מתוך הערכים}} $2 מותר עבור הפרמטר <var>$1</var>.",
+ "apierror-multival-only-one": "רק ערך אחד מותר עבור הפרמטר <var>$1</var>.",
+ "apierror-multpages": "<var>$1</var> יכול לשמש רק בדף בודד.",
+ "apierror-mustbeloggedin-changeauth": "יש להיכנס לחשבון כדי לשנות נתוני אימות.",
+ "apierror-mustbeloggedin-generic": "חובה להיכנס.",
+ "apierror-mustbeloggedin-linkaccounts": "חובה להיכנס לחשבון כדי לקשר חשבונות.",
+ "apierror-mustbeloggedin-removeauth": "חובה להיכנס לחשבון כדי להסיר מידע אימות.",
+ "apierror-mustbeloggedin-uploadstash": "סליק ההעלאה זמין רק למשתמשים שנכנסו לחשבון.",
+ "apierror-mustbeloggedin": "חובה להיכנס לחשבון כדי $1.",
+ "apierror-mustbeposted": "המודול <kbd>$1</kbd> דורש בקשת POST.",
+ "apierror-mustpostparams": "{{PLURAL:$2|הפרמטר הבא|הפרמטרים הבאים}} נמצאו במחרוזת השאילתה, אבל חייבים להיות ב־POST בגוף: $1.",
+ "apierror-noapiwrite": "עריכת הוויקי הזה דרך ה־API כובתה. נא לוודא שהמשפט <code dir=\"ltr\">$wgEnableWriteAPI=true;</code> כלול בקובץ <code>LocalSettings.php</code> של הוויקי.",
+ "apierror-nochanges": "לא התבקשו שינויים.",
+ "apierror-nodeleteablefile": "אין גרסה ישנה כזאת של הקובץ.",
+ "apierror-no-direct-editing": "עריכה ישירה דרך ה־API אינה נתמכת עבור דגם התוכן $1 שמשמש ב{{GRAMMAR:תחילית|$2}}.",
+ "apierror-noedit-anon": "משתמשים אלמוניים אינם יכולים לערוך דפים.",
+ "apierror-noedit": "אין לך הרשאה לערוך דפים.",
+ "apierror-noimageredirect-anon": "משתמשים אלמוניים אינם יכולים ליצור הפניות לתמונות.",
+ "apierror-noimageredirect": "אין לך הרשאה ליצור הפניות לתמונות.",
+ "apierror-nosuchlogid": "אין רשומה ביומן עם המזהה $1.",
+ "apierror-nosuchpageid": "אין דף עם המזהה $1.",
+ "apierror-nosuchrcid": "לא נעשה לאחרונה שינוי עם המזהה $1.",
+ "apierror-nosuchrevid": "אין גרסה עם המזהה $1.",
+ "apierror-nosuchsection": "לא קיים מקטע $1.",
+ "apierror-nosuchsection-what": "אין מקטע $1 ב{{GRAMMAR:תחילית|$2}}.",
+ "apierror-nosuchuserid": "אין משתמש עם המזהה $1.",
+ "apierror-notarget": "לא נתת יעד תקין לפעולה הזאת.",
+ "apierror-notpatrollable": "לא ניתן לנטר את הגרסה $1 כי היא ישנה מדי.",
+ "apierror-nouploadmodule": "לא הוגדר מודול העלאה.",
+ "apierror-offline": "לא היה אפשר להמשיך בשל בעיות חיבור רשת. נא לוודא שיש לך חיבור אינטרנט פועל ולנסות שוב.",
+ "apierror-opensearch-json-warnings": "לא ניתן לייצג את האזהרות בתסדיר JSON של OpenSearch.",
+ "apierror-pagecannotexist": "מרחב השם אינו מתיר דפים אמתיים.",
+ "apierror-pagedeleted": "הדף הזה נמחק מאז שאחזרת את חותם הזמן שלו.",
+ "apierror-pagelang-disabled": "שינוי שפת הדף אסור בוויקי הזה.",
+ "apierror-paramempty": "הפרמטר <var>$1</var> אינו יכול להיות ריק.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> נתמך רק בתוכן קוד ויקי (wikitext).",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> נתמך רק בתוכן קוד ויקי (wikitext). $1 משתמש במודל התוכן $2.",
+ "apierror-pastexpiry": "זמן התפוגה \"$1\" בעבר.",
+ "apierror-permissiondenied": "אין לך הרשאה $1.",
+ "apierror-permissiondenied-generic": "ההרשאה נדחתה.",
+ "apierror-permissiondenied-patrolflag": "עליך להחזיק בהרשאות <code>patrol</code> או <code>patrolmarks</code> כדי לבקש דגל מנוטר.",
+ "apierror-permissiondenied-unblock": "אין לך הרשאה לשחרר חסימה של משתמשים.",
+ "apierror-prefixsearchdisabled": "חיפוש תחילית כבוי במצב קמצן.",
+ "apierror-promised-nonwrite-api": "כותר <code>Promise-Non-Write-API-Action</code> של HTTP אינו יכול להישלח למודולי API שפועלים במצב כתיבה.",
+ "apierror-protect-invalidaction": "סוג הגנה בלתי־תקין \"$1\".",
+ "apierror-protect-invalidlevel": "קמת הגנה בלתי־תקינה \"$1\".",
+ "apierror-ratelimited": "עברת את מכסת הקצב שלך. נא להמתין זמן־מה ונסות שוב.",
+ "apierror-readapidenied": "יש צורך בהרשאת קריאה כדי להשתמש במודול הזה.",
+ "apierror-readonly": "הוויקי הזה במצב לקריאה בלבד עכשיו.",
+ "apierror-reauthenticate": "לא עברת אימות לאחרונה בשיחה הזאת, נא להתאמת מחדש.",
+ "apierror-redirect-appendonly": "ניסית לערוך במצב מעבר־אחר־הפניות (redirect-following), שצריך לשמש יחד עם <kbd>section=new</kbd>‏, <var>prependtext</var>, או <var>appendtext</var>.",
+ "apierror-revdel-mutuallyexclusive": "אותו השדה אינו יכול לשמש עם <var>hide</var> ועם <var>show</var>.",
+ "apierror-revdel-needtarget": "כותרת יעד נחוצה בשביל סוג ה־RevDel הזה.",
+ "apierror-revdel-paramneeded": "לפחות ערך אחד נחוץ בשביל <var>hide</var> או <var>show</var>.",
+ "apierror-revisions-badid": "לא נמצאה גרסה לפרמטר <var>$1</var>.",
+ "apierror-revisions-norevids": "הפרמטר <var>revids</var> אינו יכול לשמש עם אפשרויות הרשימה (<var>$1limit</var>‏, <var>$1startid</var>‏, <var>$1endid</var>‏, <kbd>$1dir=newer</kbd>‏, <var>$1user</var>‏, <var>$1excludeuser</var>‏, <var>$1start</var>, ו־<var>$1end</var>).",
+ "apierror-revisions-singlepage": "<var>titles</var>‏, <var>pageids</var> או מחולל שימשו לאספקת דפים מרובים, אבל הפרמטרים <var>$1limit</var>‏, <var>$1startid</var>‏, <var>$1endid</var>‏, <kbd>$1dir=newer</kbd>‏, <var>$1user</var>‏, <var>$1excludeuser</var>‏, <var>$1start</var>, ו־<var>$1end</var> יכולים לשמש רק בדף בודד.",
+ "apierror-revwrongpage": "הגרסה $1 אינה גרסה של $2.",
+ "apierror-searchdisabled": "חיפוש <var>$1</var> כבוי.",
+ "apierror-sectionreplacefailed": "לא היה אפשר למזג את המקטע המעודכן.",
+ "apierror-sectionsnotsupported": "מקטעים אינם נתמכים במודל התוכן $1.",
+ "apierror-sectionsnotsupported-what": "מקטעים אינם נתמכים ב־$1.",
+ "apierror-show": "פרמטר לא נכון – אי־אפשר לספק ערכים שמבטלים זה את זה.",
+ "apierror-siteinfo-includealldenied": "לא ניתן להציג את המידע של כל השרתים אלא אם <var dir=\"ltr\">$wgShowHostNames</var> מוגדר להיות true.",
+ "apierror-sizediffdisabled": "ההבדל בגודל כבוי במצב קמצן.",
+ "apierror-spamdetected": "העריכה שלך סורבה כי הכילה חלק ספאם: <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "אין לך הרשאה להציג את התוצאות של הדף המיוחד הזה.",
+ "apierror-stashedfilenotfound": "לא היה אפשר למצור את הקובץ בסליק: $1.",
+ "apierror-stashedit-missingtext": "לא נמצא טקסט מוסלק עם הגיבוב שניתן.",
+ "apierror-stashfailed-complete": "העלאה מפולחת הושלמה, יש לבדוק את המצב בשביל לראות פרטים.",
+ "apierror-stashfailed-nosession": "אין שיחת העלאה מפולחת עם המפתח הזה.",
+ "apierror-stashfilestorage": "לא היה אפשר לאחסן את ההעלאה בסליק: $1",
+ "apierror-stashinvalidfile": "קובץ מוסלק בלתי־תקין.",
+ "apierror-stashnosuchfilekey": "אין מפתח קובץ כזה: $1.",
+ "apierror-stashpathinvalid": "מפתח קובץ מתסדיר בלתי־הולם או בלתי־תקין באופן אחר: $1.",
+ "apierror-stashwrongowner": "בעלים בלתי־תקין: $1",
+ "apierror-stashzerolength": "קובץ באורך אפס, ואל יכול משוחזר בסליק: $1.",
+ "apierror-systemblocked": "נחסמת אוטומטית על־ידי מדיה־ויקי.",
+ "apierror-templateexpansion-notwikitext": "הרחבת תבניות נתמכת רק בתוכן קוד ויקי (wikitext). $1 משתמש במודל התוכן $2.",
+ "apierror-timeout": "השרת לא השיב בזמן המצופה.",
+ "apierror-toofewexpiries": "{{PLURAL:$1|ניתן חותם זמן תפוגה אחד|ניתנו $1 חותמי זמן תפוגה}} כאשר {{PLURAL:$2|היה נחוץ אחד|היו נחוצים $1}}.",
+ "apierror-unknownaction": "הפעולה שניתנה, <kbd>$1</kbd>, אינה מוכרת.",
+ "apierror-unknownerror-editpage": "שגיאת EditPage בלתי־ידועה: $1.",
+ "apierror-unknownerror-nocode": "שגיאה בלתי־ידועה.",
+ "apierror-unknownerror": "שגיאה בלתי ידועה: \"$1\".",
+ "apierror-unknownformat": "תסדיר בלתי־ידוע \"$1\".",
+ "apierror-unrecognizedparams": "{{PLURAL:$2|פרמטר בלתי־מוכר|פרמטרים בלתי־מוכרים}}: $1.",
+ "apierror-unrecognizedvalue": "לפרמטר <var>$1</var> יש ערך בלתי־מוכר: $2.",
+ "apierror-unsupportedrepo": "מאגר קבצים מקומי אינו תומך בשאילתה לכל התמונות.",
+ "apierror-upload-filekeyneeded": "חובה לספק <var>filekey</var> כאשר <var>offset</var> אינו אפס.",
+ "apierror-upload-filekeynotallowed": "לא ניתן לספק <var>filekey</var> כאשר <var>offset</var> הוא 0.",
+ "apierror-upload-inprogress": "העלאה מתוך סליק כבר התחילה.",
+ "apierror-upload-missingresult": "אין תוצאות בנתוני מצב.",
+ "apierror-urlparamnormal": "לא היה אפשר לנרמל את פרמטרי התמונה עבור $1.",
+ "apierror-writeapidenied": "אין לך הרשאה לערוך את הוויקי הזה דרך ה־API.",
+ "apiwarn-alldeletedrevisions-performance": "לביצועים טובים יותר בעת יצירת כותרת, יש להשתמש ב־<kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "לא היה אפשר לפענח את <var>$1urlparam</var> עבור $2. משתמשים רק ב־width ו־height.",
+ "apiwarn-badutf8": "הערך הערך שהועבר ל־<var>$1</var> מכיל נתונים בלתי־תקינים או בלתי־מנורמלים. נתונים טקסט אמורים להיות תקינים, מנורמלי NFC ללא תווי בקרה C0 למעט HT (\\t)‏, LF (\\n), ו־CR (\\r).",
+ "apiwarn-checktoken-percentencoding": "נא לבדוק שסימנים כמו \"+\" באסימון מקודדים עם אחוזים בצורה נכונה ב־URL.",
+ "apiwarn-compare-nocontentmodel": "לא היה אפשר לקבוע את מודל התוכן, נניח שזה $1.",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> הוצהר בתור מיושן. נא להשתמש ב־ <kbd>prop=deletedrevisions</kbd> או ב־<kbd>list=alldeletedrevisions</kbd> במקום זה.",
+ "apiwarn-deprecation-expandtemplates-prop": "מכיוון שלא ניתנו ערכים לפרמטר <var>prop</var>, תסדיר מיושן ישמש לפלט. התסדיר הזה מיושן, ובעתיד יינתן ערך בררת מחדל לפרמטר <var>prop</var>, כך שתמיד ישמש התסדיר החדש.",
+ "apiwarn-deprecation-httpsexpected": "משמש HTTP כשהיה צפוי HTTPS.",
+ "apiwarn-deprecation-login-botpw": "כניסה לחשבון עיקרי (main-account) דרך <kbd>action=login</kbd> מיושנת ועלולה להפסיק לעבוד ללא אזהרה נוספת. כדי להמשיך להיכנס עם <kbd>action=login</kbd>, ר' [[Special:BotPasswords]]. כדי להמשיך באופן מאובטח באמצעות חשבון עיקרי, ר' <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "כניסה בחשבון ראשי עם <kbd>action=login</kbd> מיושנת ויכולה להפסיק לעבוד ללא אזהרה. כדי להיכנס באופן מאובטח, ר' <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-token": "אחזור אסימון דרך <kbd>action=login</kbd> מיושן. נא להשתמש ב־<kbd>action=query&meta=tokens&type=login</kbd> במקום זה.",
+ "apiwarn-deprecation-parameter": "הפרמטר <var>$1</var> מיושן.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> מיושן מאז מדיה־ויקי 1.28. יש להשתמש ב־<kbd>prop=headhtml</kbd> בעת יצירת מסמכי HTML חדשים, או ב־<kbd>prop=modules|jsconfigvars</kbd> בעת עדכון מסמך בצד הלקוח.",
+ "apiwarn-deprecation-purge-get": "שימוש ב־<kbd>action=purge</kbd> דרך GET מיושן. יש להשתמש ב־POST במקום זה.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> מיושן. יש להשתמש ב־<kbd>$2</kbd> במקום זה.",
+ "apiwarn-difftohidden": "לא היה אפשר לעשות השוואה עם גרסה $1: התוכן מוסתר.",
+ "apiwarn-errorprinterfailed": "מדפיס השגיאות לא עבד. ינסה שוב ללא פרמטרים.",
+ "apiwarn-errorprinterfailed-ex": "מדפיס השגיאות לא עבד (ינסה שוב ללא פרמטרים): $1",
+ "apiwarn-invalidcategory": "\"$1\" אינה קטגוריה.",
+ "apiwarn-invalidtitle": "\"$1\" אינה כותרת תקינה.",
+ "apiwarn-invalidxmlstylesheetext": "לגיליון הסגנונות אמור להיות הסיומת <code dir=\"ltr\">.xsl</code>.",
+ "apiwarn-invalidxmlstylesheet": "ניתן גיליון סגנונות שאינו תקין או אינו קיים.",
+ "apiwarn-invalidxmlstylesheetns": "גיליון הסגנונות אמור להיות במרחב השם {{ns:MediaWiki}}.",
+ "apiwarn-moduleswithoutvars": "המאפיין <kbd>modules</kbd> לא הוגדר, אבל לא <kbd>jsconfigvars</kbd> או <kbd>encodedjsconfigvars</kbd>. משתני הגדרות נחוצים בשביל שימוש נכון במודולים.",
+ "apiwarn-notfile": "\"$1\" אינו קובץ.",
+ "apiwarn-nothumb-noimagehandler": "לא היה אפשר ליצור תמונה ממוזערת כי לקובץ $1 לא משויך מטפל תמונה.",
+ "apiwarn-parse-nocontentmodel": "לא ניתן <var>title</var> או <var>contentmodel</var>, נניח שזה $1.",
+ "apiwarn-parse-titlewithouttext": "<var>title</var> שימש ללא <var>text</var>, והתבקשו מאפייני דף מפוענח. האם התכוונת להשתמש ב־<var>page</var> במקום <var>title</var>?",
+ "apiwarn-redirectsandrevids": "פתרון הפניות לא יכול לשמש יחד עם הפרמטר <var>revids</var>. הפניות ש־<var>revids</var> מצביע אליהן לא נפתרו.",
+ "apiwarn-tokennotallowed": "הפעולה \"$1\" אינה מותרת למשתמש הנוכחי.",
+ "apiwarn-tokens-origin": "לא ניתן לקבל אסימונים כשמדיניות המקור הזהה אינה חלה.",
+ "apiwarn-toomanyvalues": "יותר מדי ערכים סופקו לפרמטר <var>$1</var>. המגבלה היא $2.",
+ "apiwarn-truncatedresult": "התוצאה נחתכה כי אחרת היא הייתה ארוכה מהמגבלה של $1 בתים.",
+ "apiwarn-unclearnowtimestamp": "העברת \"$2\" בתור פרמטר חותם־זמן <var>$1</var> הוצהרה בתור מיושנת. אם מסיבה כלשהי אתם צריכים להגדיר במפורש את הזמן הנוכחי ללא חישובו בצד הלקוח, יש להשתמש ב־<kbd>now</kbd>.",
+ "apiwarn-unrecognizedvalues": "לפרמטר <var>$1</var> היתנ ג{{PLURAL:$3|ניתן ערך בלתי־ידוע|ניתנו ערכים בלתי־ידועים}}: $2.",
+ "apiwarn-unsupportedarray": "הפרמטר <var>$1</var> משתמש בתחביר מערכים שאינו נתמך ב־PHP.",
+ "apiwarn-urlparamwidth": "התעלמות מרוחב (width) שהוגדר ב־<var>$1urlparam</var> (ערך: $2) לטובת רוחב שנגזר מ־<var>$1urlwidth</var>/<var>$1urlheight</var> (ערך: $3).",
+ "apiwarn-validationfailed-badchars": "תווים בלתי־תקינים במפתח (מותרים רק <code>a-z</code>‏, <code>A-Z</code>‏, <code>0-9</code>‏, <code>_</code>, ו־<code>-</code>).",
+ "apiwarn-validationfailed-badpref": "לא העדפה תקינה.",
+ "apiwarn-validationfailed-cannotset": "לא יכולה להיות מוגדרת על־ידי המודול הזה.",
+ "apiwarn-validationfailed-keytoolong": "המפתח ארוך מדי (מותר לכתוב לא יותר מ־$1 בתים).",
+ "apiwarn-validationfailed": "שגיאה בבדיקת תקינות עבור <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>אזהרת אבטחה</strong>: <var dir=\"ltr\">$wgDebugAPI</var> מופעל.",
+ "api-feed-error-title": "שגיאה ($1)",
+ "api-usage-docref": "ר' $1 לשימוש ב־API.",
+ "api-usage-mailinglist-ref": "עשו מינוי לרשימת התפוצה mediawiki-api-announce בכתובת &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; בשביל הודעות על התיישנות API ושינויים שוברים.",
+ "api-exception-trace": "$1 בקובץ $2 (שורה $3)\n$4",
+ "api-credits-header": "קרדיטים",
+ "api-credits": "מפתחי ה־API:\n* רואן קטאו (מפתח מוביל 2007–2009)\n* ויקטור וסילייב\n* בריאן טונג מין\n* סאם ריד\n* יורי אסטרחן (יוצר, מפתח מוביל מספטמבר 2006 עד ספטמבר 2007)\n* בראד יורש (מפתח מוביל מאז 2013)\n\nאנא שלחו הערות, הצעות ושאלות לכתובת mediawiki-api@lists.wikimedia.org או כתבו דיווח באג באתר https://phabricator.wikimedia.org."
+}
diff --git a/www/wiki/includes/api/i18n/hr.json b/www/wiki/includes/api/i18n/hr.json
new file mode 100644
index 00000000..2273ee9d
--- /dev/null
+++ b/www/wiki/includes/api/i18n/hr.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ex13"
+ ]
+ },
+ "apihelp-block-summary": "Blokiraj suradnika.",
+ "apihelp-block-param-user": "Suradničko ime, IP adresa ili opseg koje želite blokirati."
+}
diff --git a/www/wiki/includes/api/i18n/hsb.json b/www/wiki/includes/api/i18n/hsb.json
new file mode 100644
index 00000000..b25ae9b2
--- /dev/null
+++ b/www/wiki/includes/api/i18n/hsb.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "J budissin"
+ ]
+ },
+ "apihelp-main-param-format": "Wudawanski format"
+}
diff --git a/www/wiki/includes/api/i18n/hsn.json b/www/wiki/includes/api/i18n/hsn.json
new file mode 100644
index 00000000..a646923a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/hsn.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "SolidBlock"
+ ]
+ },
+ "apihelp-main-param-action": "要搞得操作。",
+ "apihelp-main-param-format": "出的格式。"
+}
diff --git a/www/wiki/includes/api/i18n/ht.json b/www/wiki/includes/api/i18n/ht.json
new file mode 100644
index 00000000..dee39e13
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ht.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bfpage"
+ ]
+ },
+ "apihelp-query-param-rawcontinue": "Bay tounen done anvan tout koreksyon <samp>query-continue</samp> pou kontinyasyon"
+}
diff --git a/www/wiki/includes/api/i18n/hu.json b/www/wiki/includes/api/i18n/hu.json
new file mode 100644
index 00000000..92182581
--- /dev/null
+++ b/www/wiki/includes/api/i18n/hu.json
@@ -0,0 +1,1168 @@
+{
+ "@metadata": {
+ "authors": [
+ "Csega",
+ "Dorgan",
+ "Tacsipacsi",
+ "ViDam",
+ "Macofe",
+ "Wolf Rex",
+ "Dj"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Dokumentáció]]\n* [[mw:Special:MyLanguage/API:FAQ|GYIK]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Levelezőlista]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API-bejelentések]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Hibabejelentések és kérések]\n</div>\n<strong>Státusz:</strong> Minden ezen a lapon látható funkciónak működnie kell, de az API jelenleg is aktív fejlesztés alatt áll, és bármikor változhat. Iratkozz fel a [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mediawiki-api-announce levelezőlistára] a frissítések követéséhez.\n\n<strong>Hibás kérések:</strong> Ha az API hibás kérést kap, egy HTTP-fejlécet küld vissza „MediaWiki-API-Error” kulccsal, és a fejléc értéke és a visszaküldött hibakód ugyanarra az értékre lesz állítva. További információért lásd: [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Hibák és figyelmeztetések]].\n\n<strong>Tesztelés:</strong> Az API-kérések könnyebb teszteléséhez használható az [[Special:ApiSandbox|API-homokozó]].",
+ "apihelp-main-param-action": "Milyen műveletet hajtson végre.",
+ "apihelp-main-param-format": "A kimenet formátuma.",
+ "apihelp-main-param-smaxage": "Az <code>s-maxage</code> gyorsítótár-vezérlő HTTP-fejléc beállítása ennyi másodpercre. A hibák soha nincsenek gyorsítótárazva.",
+ "apihelp-main-param-maxage": "Az <code>maxage</code> gyorsítótár-vezérlő HTTP-fejléc beállítása ennyi másodpercre. A hibák soha nincsenek gyorsítótárazva.",
+ "apihelp-main-param-assert": "Annak ellenőrzése, hogy a felhasználó be van-e jelentkezve <kbd>user</kbd> érték esetén, vagy botjog ellenőrzése <kbd>bot</kbd> érték esetén.",
+ "apihelp-main-param-assertuser": "Annak ellenőrzése, hogy a jelenlegi felhasználó a megadott-e.",
+ "apihelp-main-param-requestid": "Az itt megadott bármilyen érték szerepelni fog a válaszban. Több kérés megkülönböztetésére használható.",
+ "apihelp-main-param-servedby": "Tartalmazza a válasz kérést kiszolgáló gép nevét.",
+ "apihelp-main-param-curtimestamp": "Tartalmazza a válasz az aktuális időbélyeget.",
+ "apihelp-main-param-responselanginfo": "A válasz tartalmazza <var>uselang</var> és <var>errorlang</var> paraméterekben használt nyelveket.",
+ "apihelp-main-param-uselang": "Az üzenetfordításokhoz használandó nyelv. A <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> a <kbd>siprop=languages</kbd> paraméterrel visszaadja a lehetséges nyelvkódok listáját, vagy <kbd>user</kbd> az aktuális felhasználó, illetve <kbd>content</kbd> a wiki nyelvbeállításához.",
+ "apihelp-main-param-errorformat": "A figyelmeztetések és hibaüzenetek formátuma.\n; plaintext: Wikiszöveg eltávolított HTML-címkékkel és a HTML-entitások (pl. &amp;amp;) kicserélésével.\n; wikitext: Feldolgozatlan wikiszöveg.\n; html: HTML.\n; raw: Az üzenet azonosítója és paraméterei.\n; none: Szöveges kimenet mellőzése, csak hibakódok.\n; bc: A MediaWiki 1.29 előtti formátum. A <var>errorlang</var> és <var> erroruselocal</var> paraméterek figyelmen kívül lesznek hagyva.",
+ "apihelp-main-param-errorlang": "A figyelmeztetésekhez és hibaüzenetekhez használandó nyelv. A <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> a <kbd>siprop=languages</kbd> paraméterrel visszaadja a lehetséges nyelvkódok listáját, vagy <kbd>content</kbd> a wiki nyelvbeállításához, illetve <kbd>uselang</kbd> a <var>uselang</var> paraméter értékéhez.",
+ "apihelp-main-param-errorsuselocal": "Ha meg van adva, a hibaüzenetek a helyileg testreszabott üzeneteket fogják használni a {{ns:MediaWiki}} névtérből.",
+ "apihelp-block-summary": "Szerkesztő blokkolása",
+ "apihelp-block-param-user": "Blokkolandó felhasználónév, IP-cím vagy IP-címtartomány. Nem használható együtt a <var>$1userid</var> paraméterrel.",
+ "apihelp-block-param-userid": "A blokkolandó felhasználó numerikus azonosítója. Nem használható a <var>$1user</var> paraméterrel együtt.",
+ "apihelp-block-param-expiry": "Lejárat ideje. Lehet relatív (pl. <kbd>5 months</kbd>, <kbd>2 weeks</kbd>) vagy abszolút (pl. <kbd>2014-09-18T12:34:56Z</kbd>). Ha <kbd>infinite</kbd>-re, <kbd>indefinite</kbd>-re vagy <kbd>never</kbd>-re állítod, a blokk soha nem fog lejárni.",
+ "apihelp-block-param-reason": "Blokkolás oka.",
+ "apihelp-block-param-anononly": "Csak anonim felhasználók blokkolása (anonim szerkesztések megakadályozása erről az IP-címről).",
+ "apihelp-block-param-nocreate": "Új regisztráció megakadályozása",
+ "apihelp-block-param-autoblock": "Az utoljára használt IP-cím blokkolása, valamint bármilyen további IP-cím, amiről a felhasználó megpróbál bejelentkezni.",
+ "apihelp-block-param-noemail": "A wiki e-mail-küldő rendszerének letiltása a felhasználó számára (<code>blockemail</code> jogosultság szükséges hozzá).",
+ "apihelp-block-param-hidename": "A felhasználónév elrejtése a blokknaplóból (<code>hideuser</code> jog szükséges hozzá).",
+ "apihelp-block-param-allowusertalk": "A felhasználó szerkeszthesse a saját vitalapját (a <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var> beállítástól függ).",
+ "apihelp-block-param-reblock": "Jelenlegi blokk felülírása, ha a felhasználó már blokkolva van.",
+ "apihelp-block-param-watchuser": "A szerkesztő vagy IP-cím szerkesztői- és vitalapjának figyelése.",
+ "apihelp-block-example-ip-simple": "A <kbd>192.0.2.5</kbd> IP-cím blokkolása három napra <kbd>First strike</kbd> indoklással.",
+ "apihelp-block-example-user-complex": "<kbd>Vandal</kbd> blokkolása határozatlan időre <kbd>Vandalism</kbd> indoklással, új fiók létrehozásának és e-mail küldésének megakadályozása.",
+ "apihelp-checktoken-summary": "Egy <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> kéréssel szerzett token érvényességének vizsgálata.",
+ "apihelp-checktoken-param-type": "A tesztelendő token típusa.",
+ "apihelp-checktoken-param-token": "A tesztelendő token.",
+ "apihelp-checktoken-param-maxtokenage": "A token megengedett legnagyobb kora másodpercekben.",
+ "apihelp-checktoken-example-simple": "Egy <kbd>csrf</kbd> token érvényességének vizsgálata.",
+ "apihelp-clearhasmsg-summary": "A <code>hasmsg</code> jelzés törlése az aktuális felhasználónak.",
+ "apihelp-clearhasmsg-example-1": "A <code>hasmsg</code> jelzés törlése az aktuális felhasználónak.",
+ "apihelp-clientlogin-example-login": "A bejelentkezési folyamat elkezdése <kbd>Example</kbd> felhasználónévvel és <kbd>ExamplePassword</kbd> jelszóval.",
+ "apihelp-clientlogin-example-login2": "A bejelentkezés folytatása <samp>UI</samp> válasz után a kétlépcsős azonosításra, az <var>OATHToken</var> paraméternek <kbd>987654</kbd> értéket megadva.",
+ "apihelp-compare-summary": "Két lap közötti különbség kiszámítása.",
+ "apihelp-compare-extended-description": "Mindkét laphoz kötelező megadni egy lapváltozat-azonosítót, címet vagy lapazonosítót.",
+ "apihelp-compare-param-fromtitle": "Az első összehasonlítandó lap címe.",
+ "apihelp-compare-param-fromid": "Az első összehasonlítandó lap lapazonosítója.",
+ "apihelp-compare-param-fromrev": "Az első összehasonlítandó lapváltozat azonosítója.",
+ "apihelp-compare-param-totitle": "A második összehasonlítandó lap címe.",
+ "apihelp-compare-param-toid": "A második összehasonlítandó lap lapazonosítója.",
+ "apihelp-compare-param-torev": "A második összehasonlítandó lapváltozat azonosítója.",
+ "apihelp-compare-example-1": "Az 1-es és 2-es lapváltozat összehasonlítása.",
+ "apihelp-createaccount-summary": "Új felhasználói fiók létrehozása.",
+ "apihelp-createaccount-example-create": "<kbd>Example</kbd> felhasználói fiók létrehozásának elkezdése <kbd>ExamplePassword</kbd> jelszóval.",
+ "apihelp-createaccount-param-name": "Felhasználónév.",
+ "apihelp-createaccount-param-password": "Jelszó (figyelmen kívül hagyva, ha a <var>$1mailpassword</var> be van állítva).",
+ "apihelp-createaccount-param-domain": "Tartomány külső hitelesítéshez (opcionális).",
+ "apihelp-createaccount-param-token": "Felhasználólétrehozási token az első kérésből",
+ "apihelp-createaccount-param-email": "A szerkesztő e-mail-címe (nem kötelező).",
+ "apihelp-createaccount-param-realname": "A szerkesztő valódi neve (nem kötelező).",
+ "apihelp-createaccount-param-mailpassword": "Ha bármilyen értéket kap, egy véletlenszerű jelszót kap a felhasználó e-mailben.",
+ "apihelp-createaccount-param-reason": "Opcionális indoklás a fióklétrehozáshoz a naplókba.",
+ "apihelp-createaccount-param-language": "A felhasználó alapértelmezett nyelvkódja (opcionális, alapértelmezetten a tartalom nyelve).",
+ "apihelp-createaccount-example-pass": "<kbd>testuser</kbd> felhasználó létrehozása <kbd>test123</kbd> jelszóval.",
+ "apihelp-createaccount-example-mail": "<kbd>testmailuser</kbd> felhasználó létrehozása, véletlenszerű jelszó elküldése e-mailben.",
+ "apihelp-delete-summary": "Lap törlése.",
+ "apihelp-delete-param-title": "A törlendő lap címe. Nem használható együtt a <var>$1pageid</var> paraméterrel.",
+ "apihelp-delete-param-pageid": "A törlendő lap lapazonosítója. Nem használható együtt a <var>$1title</var> paraméterrel.",
+ "apihelp-delete-param-reason": "A törlés indoka. Ha nincs beállítva, automatikusan generált indoklás helyettesíti.",
+ "apihelp-delete-param-watch": "A lap hozzáadása a felhasználó figyelőlistájához.",
+ "apihelp-delete-param-watchlist": "A lap hozzáadása a figyelőlistához vagy eltávolítása onnan feltétel nélkül, a beállítások használata vagy a figyelőlista érintetlenül hagyása.",
+ "apihelp-delete-param-unwatch": "A lap törlése a szerkesztő figyelőlistájáról.",
+ "apihelp-delete-param-oldimage": "A törlendő régi kép neve az [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]] által adott formátumban.",
+ "apihelp-delete-example-simple": "<kbd>Main Page</kbd> törlése.",
+ "apihelp-delete-example-reason": "<kbd>Main Page</kbd> törlése <kbd>Preparing for move</kbd> indoklással.",
+ "apihelp-disabled-summary": "Ez a modul le lett tiltva.",
+ "apihelp-edit-summary": "Lapok létrehozása és szerkesztése.",
+ "apihelp-edit-param-title": "A szerkesztendő lap címe. Nem használható együtt a <var>$1pageid</var> paraméterrel.",
+ "apihelp-edit-param-pageid": "A szerkesztendő lap lapazonosítója. Nem használható együtt a <var>$1title</var> paraméterrel.",
+ "apihelp-edit-param-section": "A szerkesztendő szakasz száma. <kbd>0</kbd> a bevezetőhöz, <kbd>new</kbd> új szakaszhoz.",
+ "apihelp-edit-param-sectiontitle": "Az új szakasz címe.",
+ "apihelp-edit-param-text": "A lap tartalma.",
+ "apihelp-edit-param-summary": "Szerkesztési összefoglaló. A szakasz címe is, ha $1section=new és a $1sectiontitle paraméter nincs beállítva.",
+ "apihelp-edit-param-minor": "Apró változtatás.",
+ "apihelp-edit-param-notminor": "Nem apró változtatás.",
+ "apihelp-edit-param-bot": "Szerkesztés megjelölése botszerkesztésként.",
+ "apihelp-edit-param-basetimestamp": "Az alapváltozat időbélyege, a szerkesztési ütközések felismerésére szolgál. Az [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]] kéréssel szerezhető meg.",
+ "apihelp-edit-param-starttimestamp": "A szerkesztési folyamat kezdetének időbélyege, a szerkesztési ütközések felismerésére szolgál. Egy megfelelő érték lekérhető a <var>[[Special:ApiHelp/main|curtimestamp]]</var> paraméterrel a folyamat kezdetén (pl. a szerkesztendő lap tartalmának letöltésekor).",
+ "apihelp-edit-param-recreate": "Bármilyen hiba felülírása arról, hogy a lapot a szerkesztés közben törölték.",
+ "apihelp-edit-param-createonly": "Ne szerkeszd a lapot, ha már létezik.",
+ "apihelp-edit-param-watch": "A lap hozzáadása a felhasználó figyelőlistájához.",
+ "apihelp-edit-param-unwatch": "A lap törlése a szerkesztő figyelőlistájáról.",
+ "apihelp-edit-param-watchlist": "A lap hozzáadása a figyelőlistához vagy eltávolítása onnan feltétel nélkül, a beállítások használata vagy a figyelőlista érintetlenül hagyása.",
+ "apihelp-edit-param-prependtext": "Ezen szöveg hozzáadása a lap elejére. Felülírja a <var>$1text</var> paramétert.",
+ "apihelp-edit-param-appendtext": "Ezen szöveg hozzáadása a lap végére. Felülírja a <var>$1text</var> paramétert.\n\nHasználd a <kbd>$1section=new</kbd> paramétert új szakasz hozzáadásához ezen paraméter helyett.",
+ "apihelp-edit-param-undo": "Ezen változat visszavonása. Felülírja a <var>$1text</var>, <var>$1prependtext</var> és <var>$1appendtext</var> paramétereket.",
+ "apihelp-edit-param-undoafter": "Minden változtatás visszavonása az <var>$1undo</var> paraméterben szereplőtől eddig. Ha nincs megadva, csak egy változtatás visszavonása.",
+ "apihelp-edit-param-redirect": "Átirányítások automatikus feloldása.",
+ "apihelp-edit-param-contentmodel": "Az új tartalom tartalommodellje.",
+ "apihelp-edit-param-token": "A tokennek mindig az utolsó paraméternek, vagy legalább a <var>$1text</var> után kell lennie.",
+ "apihelp-edit-example-edit": "Lap szerkesztése",
+ "apihelp-edit-example-prepend": "<kbd>_&#95;NOTOC_&#95;</kbd> hozzáadása a lap elejére.",
+ "apihelp-edit-example-undo": "Az 13579–13585. változatok visszavonása automatikus szerkesztési összefoglalóval.",
+ "apihelp-emailuser-summary": "E-mail küldése",
+ "apihelp-emailuser-param-target": "Az e-mail címzettje.",
+ "apihelp-emailuser-param-subject": "A levél tárgya.",
+ "apihelp-emailuser-param-text": "Szövegtörzs.",
+ "apihelp-emailuser-param-ccme": "Másolat küldése magamnak.",
+ "apihelp-emailuser-example-email": "E-mail küldése <kbd>WikiSysop</kbd> felhasználónak <kbd>Content</kbd> szöveggel.",
+ "apihelp-expandtemplates-summary": "Minden sablon kibontása a wikiszövegben.",
+ "apihelp-expandtemplates-param-title": "Lap címe.",
+ "apihelp-expandtemplates-param-text": "Az átalakítandó wikiszöveg.",
+ "apihelp-expandtemplates-param-revid": "Változatazonosító a <code><nowiki>{{REVISIONID}}</nowiki></code> és hasonló változók kibontásához.",
+ "apihelp-expandtemplates-param-prop": "A lekérendő információk.\n\nHa nincs megadva érték, a válasz tartalmazni fogja a wikiszöveget, de a kimenet elavult formátumú lesz.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "A kibontott wikiszöveg.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Bármilyen, a bemenetben szereplő kategória, ami nem jelenik meg a wikiszöveges kimenetben.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "A wikiszövegben kibontott varázsszavak által meghatározott laptulajdonságok.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Maximális idő, ami után az eredmény gyorsítótárazása érvénytelenítendő.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "A lapra vonatkozó JavaScript-változók.",
+ "apihelp-expandtemplates-param-includecomments": "A HTML-megjegyzések szerepeljenek-e a kimenetben.",
+ "apihelp-expandtemplates-example-simple": "A <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd> wikiszöveg kibontása.",
+ "apihelp-feedcontributions-summary": "Egy felhasználó közreműködéseinek lekérése hírcsatornaként.",
+ "apihelp-feedcontributions-param-feedformat": "A hírcsatorna formátuma.",
+ "apihelp-feedcontributions-param-user": "A lekérendő felhasználók.",
+ "apihelp-feedcontributions-param-namespace": "A közreműködések szűrése ezen névtérre.",
+ "apihelp-feedcontributions-param-year": "Közreműködések lekérése eddig az évig.",
+ "apihelp-feedcontributions-param-month": "Közreműködések lekérése ennek a hónapnak a végéig.",
+ "apihelp-feedcontributions-param-tagfilter": "A közreműködések szűrése az ezen címkével ellátottakra.",
+ "apihelp-feedcontributions-param-deletedonly": "Csak a törölt szerkesztések lekérése.",
+ "apihelp-feedcontributions-param-toponly": "Csak a jelenleg utolsónak számító változtatások lekérése.",
+ "apihelp-feedcontributions-param-newonly": "Csak az új oldalt létrehozó szerkesztések lekérése.",
+ "apihelp-feedcontributions-param-hideminor": "Apró szerkesztések kihagyása.",
+ "apihelp-feedcontributions-param-showsizediff": "A változatok közötti méretkülönbség lekérése.",
+ "apihelp-feedcontributions-example-simple": "<kbd>Example</kbd> felhasználó közreműködéseinek lekérése.",
+ "apihelp-feedrecentchanges-summary": "A friss változtatások lekérése hírcsatornaként.",
+ "apihelp-feedrecentchanges-param-feedformat": "A hírcsatorna formátuma.",
+ "apihelp-feedrecentchanges-param-namespace": "Az eredmények szűrése erre a névtérre.",
+ "apihelp-feedrecentchanges-param-invert": "Minden névtér a kiválasztott kivételével.",
+ "apihelp-feedrecentchanges-param-associated": "A kapcsolódó (vita- vagy tartalmi) névtér befoglalása.",
+ "apihelp-feedrecentchanges-param-days": "Az eredmények szűrése az elmúlt ennyi napra.",
+ "apihelp-feedrecentchanges-param-limit": "Találatok maximális száma.",
+ "apihelp-feedrecentchanges-param-from": "Szerkesztések megjelenítése ekkortól.",
+ "apihelp-feedrecentchanges-param-hideminor": "Apró változtatások elrejtése.",
+ "apihelp-feedrecentchanges-param-hidebots": "Botszerkesztések elrejtése.",
+ "apihelp-feedrecentchanges-param-hideanons": "Anonim felhasználók szerkesztéseinek elrejtése.",
+ "apihelp-feedrecentchanges-param-hideliu": "Bejelentkezett felhasználók szerkesztéseinek elrejtése.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ellenőrzött változtatások elrejtése.",
+ "apihelp-feedrecentchanges-param-hidemyself": "A jelenlegi felhasználó szerkesztéseinek elrejtése.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Kategóriaváltoztatások elrejtése.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Szűrés címke szerint.",
+ "apihelp-feedrecentchanges-param-target": "Csak a megadott lapról hivatkozott lapok szerkesztéseinek megjelenítése.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Inkább a megadott lap''ra'' hivatkozó lapok szerkesztéseinek megjelenítése.",
+ "apihelp-feedrecentchanges-param-categories": "Csak a megadott kategóriák mindegyikében szereplő lapok szerkesztéseinek megjelenítése.",
+ "apihelp-feedrecentchanges-param-categories_any": "Inkább a megadott kategóriák bármelyikében szereplő lapok szerkesztéseinek megjelenítése.",
+ "apihelp-feedrecentchanges-example-simple": "Friss változtatások megjelenítése.",
+ "apihelp-feedrecentchanges-example-30days": "Az elmúlt 30 nap friss változtatásainak megjelenítése.",
+ "apihelp-feedwatchlist-summary": "A figyelőlista lekérése hírcsatornaként.",
+ "apihelp-feedwatchlist-param-feedformat": "A hírcsatorna formátuma.",
+ "apihelp-feedwatchlist-param-hours": "Az utóbbi ennyi órában szerkesztett lapok listázása.",
+ "apihelp-feedwatchlist-param-linktosections": "Hivatkozás közvetlenül a módosított szakaszra, ha lehetséges.",
+ "apihelp-feedwatchlist-example-default": "A figyelőlista-hírcsatorna megjelenítése.",
+ "apihelp-feedwatchlist-example-all6hrs": "A figyelt lapok összes változtatásának megjelenítése az elmúlt 6 órában.",
+ "apihelp-filerevert-summary": "Egy fájl visszaállítása egy régebbi verzióra.",
+ "apihelp-filerevert-param-filename": "Célfájlnév, {{ns:6}}: (File:) előtag nélkül",
+ "apihelp-filerevert-param-comment": "Feltöltési összefoglaló.",
+ "apihelp-filerevert-param-archivename": "A visszaállítandó változat archív neve.",
+ "apihelp-filerevert-example-revert": "<kbd>Wiki.png</kbd> visszaállítása a <kbd>2011-03-05T15:27:40Z</kbd>-kori változatra.",
+ "apihelp-help-summary": "Súgó megjelenítése a megadott modulokhoz.",
+ "apihelp-help-param-submodules": "Súgó megjelenítése a megadott modul almoduljaihoz is.",
+ "apihelp-help-param-recursivesubmodules": "Súgó megjelenítése az almodulokhoz rekurzívan.",
+ "apihelp-help-param-helpformat": "A súgó kimeneti formátuma.",
+ "apihelp-help-param-wrap": "Az eredmény visszaadása a szabványos API-válaszstruktúrában.",
+ "apihelp-help-param-toc": "A HTML-kimenet tartalmazzon egy tartalomjegyzéket.",
+ "apihelp-help-example-main": "Súgó megjelenítése a fő modulhoz.",
+ "apihelp-help-example-submodules": "Súgó az <kbd>action=query</kbd> modulhoz és összes almoduljához.",
+ "apihelp-help-example-recursive": "Minden súgó egy lapon.",
+ "apihelp-help-example-help": "Súgó magához a súgó modulhoz.",
+ "apihelp-help-example-query": "Súgó két lekérdező almodulhoz.",
+ "apihelp-imagerotate-summary": "Egy vagy több kép elforgatása.",
+ "apihelp-imagerotate-param-rotation": "A kép forgatása ennyi fokkal az óramutató járásával megegyező irányban.",
+ "apihelp-imagerotate-example-simple": "<kbd>Example.png</kbd> elforgatása <kbd>90</kbd> fokkal.",
+ "apihelp-imagerotate-example-generator": "Az összes kép elforgatása a <kbd>Category:Flip</kbd> kategóriában <kbd>180</kbd> fokkal.",
+ "apihelp-import-summary": "Egy lap importálása egy másik wikiből vagy XML-fájlból.",
+ "apihelp-import-extended-description": "A HTTP POST-kérést fájlfeltöltésként kell elküldeni (multipart/form-data használatával) a <var>xml</var> paraméter használatakor.",
+ "apihelp-import-param-xml": "Feltöltött XML-fájl.",
+ "apihelp-import-param-interwikisource": "Wikiközi importálásnál: forráswiki.",
+ "apihelp-import-param-interwikipage": "Wikiközi importálásnál: az importálandó lap.",
+ "apihelp-import-param-fullhistory": "Wikiközi importálásnál: teljes laptörténet importálása, nem csak az aktuális változat.",
+ "apihelp-import-param-templates": "Wikiközi importálásnál: importálás a lapon használt sablonokkal együtt.",
+ "apihelp-import-param-namespace": "Importálás ebbe a névtérbe. Nem használható együtt a <var>$1rootpage</var> paraméterrel.",
+ "apihelp-import-param-rootpage": "Importálás ennek a lapnak az allapjaként. Nem használható együtt a <var>$1namespace</var> paraméterrel.",
+ "apihelp-import-example-import": "[[meta:Help:ParserFunctions]] importálása a 100-as névtérbe teljes laptörténettel.",
+ "apihelp-linkaccount-summary": "Egy harmadik fél szolgáltató fiókjának kapcsolása a jelenlegi felhasználóhoz.",
+ "apihelp-linkaccount-example-link": "Összekapcsolás elkezdése <kbd>Example</kbd> szolgáltató fiókjával.",
+ "apihelp-login-summary": "Bejelentkezés és hitelesítő sütik lekérése.",
+ "apihelp-login-extended-description": "Ez a művelet csak [[Special:BotPasswords|botjelszavakkal]] használandó; a fő fiókkal való használat elavult és figyelmeztetés nélkül sikertelen lehet. A fő fiókkal való biztonságos bejelentkezéshez használd az <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd> paramétert.",
+ "apihelp-login-extended-description-nobotpasswords": "Ez a művelet elavult és figyelmeztetés nélkül sikertelen lehet. A biztonságos bejelentkezéshez használd az <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd> paramétert.",
+ "apihelp-login-param-name": "Szerkesztőnév.",
+ "apihelp-login-param-password": "Jelszó.",
+ "apihelp-login-param-domain": "Tartomány (opcionális)",
+ "apihelp-login-param-token": "Az első kérésben megszerzett bejelentkezési token.",
+ "apihelp-login-example-gettoken": "Egy bejelentkezés token lekérése.",
+ "apihelp-login-example-login": "Bejelentkezés.",
+ "apihelp-logout-summary": "Kijelentkezés és munkamenetadatok törlése.",
+ "apihelp-logout-example-logout": "Aktuális felhasználó kijelentkeztetése.",
+ "apihelp-managetags-summary": "A változtatáscímkék kezelése.",
+ "apihelp-managetags-param-operation": "A végrehajtandó feladat:\n;create: Új változtatáscímke létrehozása kézi használatra.\n;delete: Egy változtatáscímke eltávolítása az adatbázisból, beleértve az eltávolítását minden lapváltozatról, frissváltoztatások-bejegyzésről és naplóbejegyzésről, ahol használatban van.\n;activate: Egy változtatáscímke aktiválása, lehetővé téve a felhasználóknak a kézi használatát.\n;deactivate: Egy változtatáscímke deaktiválása, a felhasználók megakadályozása a kézi használatban.",
+ "apihelp-managetags-param-tag": "A létrehozandó, törlendő, aktiválandó vagy deaktiválandó címke. Létrehozás esetén adott nevű címke nem létezhet. Törlés esetén a címkének léteznie kell. Aktiválás esetén a címkének léteznie kell, és nem használhatja más kiterjesztés. Deaktiválás esetén a címkének aktívnak és kézzel definiáltnak kell lennie.",
+ "apihelp-managetags-param-reason": "Opcionális indoklás a címke létrehozásához, törléséhez, aktiválásához vagy deaktiválásához.",
+ "apihelp-managetags-param-ignorewarnings": "Figyelmeztetések figyelmen kívül hagyása a művelet közben.",
+ "apihelp-managetags-example-create": "<kbd>spam</kbd> címke létrehozása <kbd>For use in edit patrolling</kbd> indoklással",
+ "apihelp-managetags-example-delete": "<kbd>vandlaism</kbd> címke törlése <kbd>Misspelt</kbd> indoklással",
+ "apihelp-managetags-example-activate": "<kbd>spam</kbd> címke aktiválása <kbd>For use in edit patrolling</kbd> indoklással",
+ "apihelp-managetags-example-deactivate": "<kbd>spam</kbd> címke deaktiválása <kbd>No longer required</kbd> indoklással",
+ "apihelp-mergehistory-summary": "Laptörténetek egyesítése",
+ "apihelp-mergehistory-param-reason": "Laptörténet egyesítésének oka.",
+ "apihelp-move-summary": "Egy lap átnevezése.",
+ "apihelp-move-param-from": "Az átnevezendő lap címe. Nem használható együtt a <var>$1fromid</var> paraméterrel.",
+ "apihelp-move-param-fromid": "Az átnevezendő lap lapazonosítója. Nem használható együtt a <var>$1from</var> paraméterrel.",
+ "apihelp-move-param-to": "A lap új címe.",
+ "apihelp-move-param-reason": "Az átnevezés oka.",
+ "apihelp-move-param-movetalk": "Nevezd át a vitalapot is, ha létezik.",
+ "apihelp-move-param-movesubpages": "Nevezd át az allapokat is, ha lehetséges.",
+ "apihelp-move-param-noredirect": "Ne készíts átirányítást.",
+ "apihelp-move-param-watch": "A lap és az átirányítás hozzáadása a jelenlegi felhasználó figyelőlistájához.",
+ "apihelp-move-param-unwatch": "A lap és az átirányítás eltávolítása a jelenlegi felhasználó figyelőlistájáról.",
+ "apihelp-move-param-watchlist": "A lap hozzáadása a figyelőlistához vagy eltávolítása onnan feltétel nélkül, a beállítások használata vagy a figyelőlista érintetlenül hagyása.",
+ "apihelp-move-param-ignorewarnings": "Figyelmeztetések figyelmen kívül hagyása.",
+ "apihelp-move-example-move": "<kbd>Badtitle</kbd> átnevezése <kbd>Goodtitle</kbd> címre átirányítás készítése nélkül.",
+ "apihelp-opensearch-summary": "Keresés a wikin az OpenSearch protokoll segítségével.",
+ "apihelp-opensearch-param-search": "A keresőkifejezés.",
+ "apihelp-opensearch-param-limit": "Találatok maximális száma.",
+ "apihelp-opensearch-param-namespace": "A keresendő névterek.",
+ "apihelp-opensearch-param-suggest": "Ne csináljon semmit, ha a <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> hamis.",
+ "apihelp-opensearch-param-redirects": "Hogyan kezelje az átirányításokat:\n;return: Magának az átirányításnak a visszaadása.\n;resolve: A céllap visszaadása. Lehet, hogy kevesebb mint <var>$1limit</var> találatot ad vissza.\nTörténeti okokból az alapértelmezés „return” <kbd>$1format=json</kbd> esetén és „resolve” más formátumoknál.",
+ "apihelp-opensearch-param-format": "A kimenet formátuma.",
+ "apihelp-opensearch-example-te": "<kbd>Te</kbd>-vel kezdődő lapok keresése.",
+ "apihelp-options-summary": "A jelenlegi felhasználó beállításainak módosítása.",
+ "apihelp-options-extended-description": "Csak a MediaWiki vagy kiterjesztései által kínált, valamint a <code>userjs-</code> előtagú (felhasználói parancsfájloknak szánt) beállítások állíthatók be.",
+ "apihelp-options-param-reset": "Beállítások visszaállítása a wiki alapértelmezéseire.",
+ "apihelp-options-param-resetkinds": "A visszaállítandó beállítások típusa(i) a <var>$1reset</var> paraméter használatakor.",
+ "apihelp-options-param-change": "Változtatások listája név=érték formátumban (pl. <kbd>skin=vector</kbd>). Ha nincs érték megadva (egyenlőségjel sem szerepel – pl. <kbd>beállítás|másik|…</kbd>), a beállítások visszaállnak az alapértelmezett értékre. Ha bármilyen érték tartalmaz függőleges vonal karaktert (<kbd>|</kbd>), használd az [[Special:ApiHelp/main#main/datatypes|alternatív elválasztókaraktert]] a megfelelő működéshez.",
+ "apihelp-options-param-optionname": "Az <var>$1optionvalue</var> értékre állítandó beállítás neve.",
+ "apihelp-options-param-optionvalue": "Az <var>$1optionname</var> beállítás értéke.",
+ "apihelp-options-example-reset": "Minden beállítás visszaállítása",
+ "apihelp-options-example-change": "A <kbd>skin</kbd> és a <kbd>hideminor</kbd> beállítások módosítása.",
+ "apihelp-options-example-complex": "Minden beállítás visszaállítása, majd a <kbd>skin</kbd> és a <kbd>nickname</kbd> beállítása.",
+ "apihelp-paraminfo-summary": "Információk lekérése API-modulokról.",
+ "apihelp-paraminfo-param-modules": "Modulnevek (az <var>action</var> és <var>format</var> paraméterek értékei vagy <kbd>main</kbd>). Megadhatók almodulok <kbd>+</kbd> elválasztással vagy minden almodul <kbd>+*</kbd>, illetve rekurzívan minden almodul <kbd>+**</kbd> végződéssel.",
+ "apihelp-paraminfo-param-helpformat": "A súgószövegek formátuma.",
+ "apihelp-paraminfo-param-querymodules": "Lekérdező modul(ok) neve (a <var>prop</var>, <var>meta</var> vagy <var>list</var> paraméter értéke). Használd a <kbd>$1modules=query+foo</kbd> formát a <kbd>$1querymodules=foo</kbd> helyett.",
+ "apihelp-paraminfo-param-mainmodule": "Információk lekérése a fő (legfelső szintű) modulról is. Használd a <kbd>$1modules=main</kbd> paramétert helyette.",
+ "apihelp-paraminfo-param-pagesetmodule": "Információk lekérése a pageset modulról is (ez szolgáltatja a <var>titles</var> paramétert és társait).",
+ "apihelp-paraminfo-param-formatmodules": "Formázómodul(ok) neve (a <var>format</var> paraméter értéke). Használd a <var>$1modules</var> paramétert helyette.",
+ "apihelp-paraminfo-example-1": "Információk megjelenítése az <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> és <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> lekérdezésekhez.",
+ "apihelp-paraminfo-example-2": "Információk megjelenítése az <kbd>[[Special:ApiHelp/query|action=query]]</kbd> összes almoduljához.",
+ "apihelp-parse-summary": "Tartalom feldolgozása.",
+ "apihelp-parse-extended-description": "Lásd az <kbd>[[Special:ApiHelp/query|action=query]]</kbd> számos prop-modulját a információk lekérésére a lap aktuális változatáról.\n\nTöbbféle módon megadható a feldolgozandó szöveg:\n# Egy lap vagy lapváltozat megadásával, a <var>$1page</var>, <var>$1pageid</var> vagy <var>$1oldid</var> paraméterrel.\n# Magának a tartalomnak a megadásával, a <var>$1text</var>, <var>$1title</var> és <var>$1contentmodel</var> paraméterrel.\n# Csak egy összefoglaló feldolgozása. A <var>$1prop</var> paraméternek üresnek kell lennie.",
+ "apihelp-parse-param-title": "A lapnak a címe, amihez a szöveg tartozik. Ha nincs megadva, a <var>$1contentmodel</var> paraméter kötelező, és a cím [[API]] lesz.",
+ "apihelp-parse-param-text": "A feldolgozandó szöveg. Használd a <var>$1title</var> vagy <var>$1contentmodel</var> paramétert a tartalommodell megadásához.",
+ "apihelp-parse-param-summary": "Feldolgozandó szerkesztési összefoglaló.",
+ "apihelp-parse-param-page": "Ezen lap tartalmának feldolgozása. Nem használható együtt a <var>$1text</var> és <var>$1title</var> paraméterrel.",
+ "apihelp-parse-param-pageid": "Ezen lap tartalmának feldolgozása. Felülírja a <var>$1page</var> paramétert.",
+ "apihelp-parse-param-redirects": "Ha a <var>$1page</var> vagy <var>$1pageid</var> átirányítás, annak feloldása.",
+ "apihelp-parse-param-oldid": "Ezen lapváltozat feldolgozása. Felülírja a <var>$1page</var> és <var>$1pageid</var> paramétert.",
+ "apihelp-parse-param-prop": "A lekérendő információk:",
+ "apihelp-parse-paramvalue-prop-text": "A feldolgozott wikiszöveg.",
+ "apihelp-parse-paramvalue-prop-langlinks": "A feldolgozott wikiszövegben talált nyelvközi hivatkozások.",
+ "apihelp-parse-paramvalue-prop-categories": "A feldolgozott wikiszövegben talált kategóriák.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "A kategóriák HTML-verziója.",
+ "apihelp-parse-paramvalue-prop-links": "A feldolgozott wikiszövegben talált belső linkek.",
+ "apihelp-parse-paramvalue-prop-templates": "A feldolgozott wikiszövegben használt sablonok.",
+ "apihelp-parse-paramvalue-prop-images": "A feldolgozott wikiszövegben használt képek.",
+ "apihelp-parse-paramvalue-prop-externallinks": "A feldolgozott wikiszövegben talált külső linkek.",
+ "apihelp-parse-paramvalue-prop-sections": "A feldolgozott wikiszövegben talált szakaszok.",
+ "apihelp-parse-paramvalue-prop-revid": "A feldolgozott lap lapváltozat-azonosítója.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "A feldolgozott wikiszöveghez tartozó cím.",
+ "apihelp-parse-paramvalue-prop-headitems": "A <code>&lt;head&gt;</code> HTML-címkébe kerülő elemek.",
+ "apihelp-parse-paramvalue-prop-headhtml": "A lap feldolgozott <code>&lt;head&gt;</code> HTML-címkéje.",
+ "apihelp-parse-paramvalue-prop-modules": "A lapon használt ResourceLoader-modulok. A betöltésükhöz használd a <code>mw.loader.using()</code> függvényt. Vagy a <kbd>jsconfigvars</kbd>, vagy az <kbd>encodedjsconfigvars</kbd> paramétert kötelező együtt használni ezzel a paraméterrel.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "A lapra jellemző JavaScript-változók. A használatukhoz állítsd be őket az <code>mw.config.set()</code> függvénnyel.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "A lapra jellemző JavaScript-változók JSON-szövegként.",
+ "apihelp-parse-paramvalue-prop-indicators": "A lap státuszindikátorainak HTML-kódja.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "A feldolgozott wikiszövegben talált interwikihivatkozások.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Az eredeti wikiszöveg.",
+ "apihelp-parse-paramvalue-prop-properties": "A feldolgozott wikiszövegben definiált különböző tulajdonságok.",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "A tartalom feldolgozása közben előforduló hibák visszaadása.",
+ "apihelp-parse-param-wrapoutputclass": "Az értelmező kimenetének körülvétele ezzel a CSS-osztállyal.",
+ "apihelp-parse-param-pst": "Mentés előtti átalakítások elvégzése a bemeneten a feldolgozás előtt. Csak szöveggel használva érvényes.",
+ "apihelp-parse-param-onlypst": "Mentés előtti átalakítások (pre-save transform, PST) végrehajtása a bemeneten, de a feldolgozás mellőzése. Csak a <var>$1text</var> paraméterrel használva érvényes.",
+ "apihelp-parse-param-section": "Csak a megadott sorszámú szakasz feldolgozása.\n\nHa <kbd>new</kbd>, a <var>$1text</var> és <var>$1sectiontitle</var> feldolgozása úgy, mintha egy új szakaszt adnál a laphoz.\n\nA <kbd>new</kbd> csak a <var>text</var> paraméter megadásakor engedélyezett.",
+ "apihelp-parse-param-sectiontitle": "Az új szakasz címe, ha a <var>section</var> paraméter <kbd>new</kbd>.\n\nA szerkesztéssel ellentétben itt nem a <var>summary</var> tartalma az alapértelmezett értéke.",
+ "apihelp-parse-param-disablepp": "Használd a <var>$1disablelimitreport</var> paramétert helyette.",
+ "apihelp-parse-param-disableeditsection": "A szakaszok szerkesztőlinkjeinek elhagyása a kimenetből.",
+ "apihelp-parse-param-preview": "Feldolgozás előnézetmódban.",
+ "apihelp-parse-param-sectionpreview": "Feldolgozás szakaszelőnézet-módban (az előnézetmódot is engedélyezi).",
+ "apihelp-parse-param-disabletoc": "Tartalomjegyzék elhagyása a kimenetből.",
+ "apihelp-parse-param-contentmodel": "A bemeneti szöveg tartalommodellje. Ha nincs megadva, a $1title paraméter kötelező, és az alapértelmezés a megadott cím tartalommodellje lesz. Csak a $1text paraméterrel együtt használva érvényes.",
+ "apihelp-parse-example-page": "Egy lap feldolgozása.",
+ "apihelp-parse-example-text": "Wikiszöveg feldolgozása.",
+ "apihelp-parse-example-texttitle": "Wikiszöveg feldolgozása a lapcím megadásával.",
+ "apihelp-parse-example-summary": "Egy szerkesztési összefoglaló feldolgozása.",
+ "apihelp-patrol-summary": "Egy lap vagy lapváltozat ellenőrzöttnek jelölése (patrol).",
+ "apihelp-patrol-param-rcid": "Az ellenőrzendő frissváltoztatások-azonosító.",
+ "apihelp-patrol-param-revid": "Az ellenőrzendő lapváltozat azonosítója (oldid).",
+ "apihelp-patrol-example-rcid": "Egy friss változtatás ellenőrzöttnek jelölése.",
+ "apihelp-patrol-example-revid": "Egy lapváltozat ellenőrzöttnek jelölése.",
+ "apihelp-protect-summary": "Egy lap védelmi szintjének változtatása.",
+ "apihelp-protect-param-title": "A levédendő/feloldandó lap címe. Nem használható együtt a <var>$1pageid</var> paraméterrel.",
+ "apihelp-protect-param-pageid": "A levédendő/feloldandó lap lapazonosítója. Nem használható együtt a <var>$1title</var> paraméterrel.",
+ "apihelp-protect-param-protections": "Védelmi szintek, <kbd>típus=szint</kbd> formátumban (pl. <kbd>edit=sysop</kbd>). Az <kbd>all</kbd> szint azt jelenti, hogy mindenki végrehajthatja az adott műveletet, vagyis nincs korlátozás.\n\n<strong>Megjegyzés:</strong> Minden nem listázott művelet védelme el lesz távolítva.",
+ "apihelp-protect-param-expiry": "A lejáratok időbélyege. Ha csak egy időbélyeg van megadva, az vonatkozik minden védelemre. Használj <kbd>infinite</kbd> (végtelen), <kbd>indefinite</kbd> (határozatlan), <kbd>infinity</kbd> (végtelen) vagy <kbd>never</kbd> (soha) értéket le nem járó védelemhez.",
+ "apihelp-protect-param-reason": "A levédés/feloldás oka.",
+ "apihelp-protect-param-cascade": "Kaszkádolt védelem engedélyezése (a lapon használt sablonok és képek védelme). Nincs hatása, ha egyetlen megadott védelmi szint sem támogatja a kaszkádolt védelmet.",
+ "apihelp-protect-param-watch": "A levédett/feloldott lap címe hozzáadása az aktuális felhasználó figyelőlistájához.",
+ "apihelp-protect-param-watchlist": "A lap hozzáadása a figyelőlistához vagy eltávolítása onnan feltétel nélkül, a beállítások használata vagy a figyelőlista érintetlenül hagyása.",
+ "apihelp-protect-example-protect": "Lap levédése.",
+ "apihelp-protect-example-unprotect": "Egy lap védelmének feloldása a korlátozások <kbd>all</kbd>-ra állításával (vagyis mindenki végrehajthatja a műveleteket).",
+ "apihelp-protect-example-unprotect2": "Egy lap védelmének feloldása semmilyen védelem beállításával.",
+ "apihelp-purge-summary": "A gyorsítótár ürítése a megadott lapoknál.",
+ "apihelp-purge-param-forcelinkupdate": "A linktáblák frissítése.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "A linktábla frissítése a megadott lapokra és minden olyan lapra, ami a megadott lapokat beilleszti sablonként.",
+ "apihelp-purge-example-simple": "A gyorsítótár ürítése a <kbd>Main Page</kbd> és <kbd>API</kbd> lapoknál.",
+ "apihelp-purge-example-generator": "A gyorsítótár ürítése az első 10 fő névtérbeli lapnál.",
+ "apihelp-query-summary": "Adatok lekérése a MediaWikiből és a MediaWikiről.",
+ "apihelp-query-extended-description": "Minden adatmódosításhoz először a <kbd>query</kbd> segítségével szereznie kell egy tokent a rosszindulatú oldalak visszaéléseinek elhárítására.",
+ "apihelp-query-param-prop": "A lapokról lekérendő tulajdonságok.",
+ "apihelp-query-param-list": "Lekérendő listák.",
+ "apihelp-query-param-meta": "Lekérendő metaadatok.",
+ "apihelp-query-param-indexpageids": "Egy <samp>pageids</samp> szakasz hozzáadása a kimenethez az összes visszaadott lapazonosítóval.",
+ "apihelp-query-param-export": "Az összes megadott vagy generált lap aktuális változatának exportálása.",
+ "apihelp-query-param-exportnowrap": "Az exportált XML visszaadása normál eredményszerkezetbe (JSON, XML stb.) burkolás nélkül (a [[Special:Export]] kimenetével megegyező formátum). Csak az <var>$1export</var> paraméterrel együtt használható.",
+ "apihelp-query-param-iwurl": "A teljes URL visszaadása, ha a cím egy interwikilink.",
+ "apihelp-query-param-rawcontinue": "Nyers <samp>query-continue</samp> adatok visszaadása a folytatáshoz.",
+ "apihelp-query-example-revisions": "[[Special:ApiHelp/query+siteinfo|Wikiinformációk]] és a <kbd>Main Page</kbd> [[Special:ApiHelp/query+revisions|laptörténetének]] lekérése.",
+ "apihelp-query-example-allpages": "Az <kbd>API/</kbd> kezdetű lapok laptörténetének lekérése.",
+ "apihelp-query+allcategories-summary": "Az összes kategória visszaadása.",
+ "apihelp-query+allcategories-param-from": "A kategóriák listázása ettől a címtől.",
+ "apihelp-query+allcategories-param-to": "A kategóriák listázása eddig a címig.",
+ "apihelp-query+allcategories-param-prefix": "Ezzel kezdődő című kategóriák keresése.",
+ "apihelp-query+allcategories-param-dir": "A rendezés iránya.",
+ "apihelp-query+allcategories-param-min": "Csak legalább ennyi taggal rendelkező kategóriák visszaadása.",
+ "apihelp-query+allcategories-param-max": "Csak legfeljebb ennyi taggal rendelkező kategóriák visszaadása.",
+ "apihelp-query+allcategories-param-limit": "A visszaadandó kategóriák száma.",
+ "apihelp-query+allcategories-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "A kategóriában lévő lapok száma.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Rejtett-e a kategória a <code>_&#95;HIDDENCAT_&#95;</code> kapcsolóval.",
+ "apihelp-query+allcategories-example-size": "Kategóriák listázása a bennük lévő lapok számával.",
+ "apihelp-query+allcategories-example-generator": "Információk lekérése magukról a kategórialapokról, amiknek a címe <kbd>List</kbd> kezdetű.",
+ "apihelp-query+alldeletedrevisions-summary": "Egy felhasználó vagy egy névtér összes törölt szerkesztésének listázása.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Csak az <var>$3user</var> paraméterrel együtt használható.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Nem használható együtt az <var>$3user</var> paraméterrel.",
+ "apihelp-query+alldeletedrevisions-param-start": "A listázás kezdő időbélyege.",
+ "apihelp-query+alldeletedrevisions-param-end": "A lista végét jelentő időbélyeg.",
+ "apihelp-query+alldeletedrevisions-param-from": "Listázás ettől a címtől.",
+ "apihelp-query+alldeletedrevisions-param-to": "Listázás eddig a címig.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Ezzel kezdődő című lapok keresése.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Csak ezzel a címkével ellátott változatok listázása.",
+ "apihelp-query+alldeletedrevisions-param-user": "Csak ezen felhasználó szerkesztéseinek listázása.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Ezen felhasználó szerkesztéseinek kihagyása.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Lapok listázása csak ebben a névtérben.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Generátorként használva címek visszaadása lapváltozat-azonosítók helyett.",
+ "apihelp-query+alldeletedrevisions-example-user": "<kbd>Example</kbd> 50 legutóbbi törölt szerkesztésének listázása.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "A fő névtér első 50 törölt szerkesztésének listázása.",
+ "apihelp-query+allfileusages-summary": "Az összes fájlhasználat listázása, beleértve a nem létező fájlokét is.",
+ "apihelp-query+allfileusages-param-from": "Listázás ettől a címtől vagy fájltól.",
+ "apihelp-query+allfileusages-param-to": "Listázás eddig a címig vagy fájlig.",
+ "apihelp-query+allfileusages-param-prefix": "Ezzel kezdődő nevű fájlok keresése.",
+ "apihelp-query+allfileusages-param-unique": "Csak különböző fájlnevek listázása. Nem használható együtt az <kbd>$1prop=id</kbd> paraméterrel.",
+ "apihelp-query+allfileusages-param-prop": "Visszaadandó információk:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "A képeket használó lapok lapazonosítói (nem használható együtt az <var>$1unique</var> paraméterrel).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "A fájl neve.",
+ "apihelp-query+allfileusages-param-limit": "A visszaadandó elemek maximális száma.",
+ "apihelp-query+allfileusages-param-dir": "A listázás iránya.",
+ "apihelp-query+allfileusages-example-B": "Fájlnevek listázása, a hiányzókat is beleértve, a forráslapok lapazonosítójával, <kbd>B</kbd>-től kezdve.",
+ "apihelp-query+allfileusages-example-unique": "Különböző fájlnevek listázása.",
+ "apihelp-query+allfileusages-example-unique-generator": "Az összes fájlnév lekérése, hiányzók megjelölése.",
+ "apihelp-query+allfileusages-example-generator": "A fájlokat használó lapok lekérése.",
+ "apihelp-query+allimages-summary": "Az összes kép visszaadása.",
+ "apihelp-query+allimages-param-sort": "Rendezési szempont.",
+ "apihelp-query+allimages-param-dir": "A listázás iránya.",
+ "apihelp-query+allimages-param-from": "Listázás ettől a fájlnévtől. Csak az <kbd>$1sort=name</kbd> paraméterrel együtt használható.",
+ "apihelp-query+allimages-param-to": "Listázás eddig a fájlnévig. Csak az <kbd>$1sort=name</kbd> paraméterrel együtt használható.",
+ "apihelp-query+allimages-param-start": "Listázás ettől az időbélyegtől. Csak az <kbd>$1sort=timestamp</kbd> paraméterrel együtt használható.",
+ "apihelp-query+allimages-param-end": "Listázás eddig az időbélyegig. Csak az <kbd>$1sort=timestamp</kbd> paraméterrel együtt használható.",
+ "apihelp-query+allimages-param-prefix": "Ezzel kezdődő nevű fájlok keresése. Csak az <kbd>$1sort=name</kbd> paraméterrel együtt használható.",
+ "apihelp-query+allimages-param-minsize": "A fájlok minimális fájlmérete bájtban.",
+ "apihelp-query+allimages-param-maxsize": "A fájlok maximális fájlmérete bájtban.",
+ "apihelp-query+allimages-param-user": "Ezen felhasználó által feltöltött fájlok visszaadása. Csak az <kbd>$1sort=timestamp</kbd> paraméterrel együtt használható. Nem használható együtt az <var>$1filterbots</var> paraméterrel.",
+ "apihelp-query+allimages-param-filterbots": "Botok által feltöltött fájlok szűrése. Csak az <kbd>$1sort=timestamp</kbd> paraméterrel együtt használható. Nem használható együtt az <var>$1user</var> paraméterrel.",
+ "apihelp-query+allimages-param-mime": "Szűrés MIME-típus alapján, pl. <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "A visszaadandó képek száma.",
+ "apihelp-query+allimages-example-B": "Fájlok listázása <kbd>B</kbd>-től kezdve.",
+ "apihelp-query+allimages-example-recent": "A legutóbb feltöltött fájlok listázása, hasonló a [[Special:NewFiles]] laphoz.",
+ "apihelp-query+allimages-example-mimetypes": "<kbd>image/png</kbd> vagy <kbd>image/gif</kbd> MIME-típusú fájlok listázása",
+ "apihelp-query+allimages-example-generator": "Információk 4 fájlról <kbd>T</kbd>-től kezdve.",
+ "apihelp-query+alllinks-summary": "Egy adott névtérbe mutató összes hivatkozás visszaadása.",
+ "apihelp-query+alllinks-param-from": "Listázás ettől a hivatkozástól.",
+ "apihelp-query+alllinks-param-to": "Listázás eddig a hivatkozásig.",
+ "apihelp-query+alllinks-param-prefix": "Ezzel kezdődő című hivatkozott lapok keresése.",
+ "apihelp-query+alllinks-param-unique": "Csak különböző címek listázása. Nem használható együtt az <kbd>$1prop=ids</kbd> paraméterrel.\nGenerátorként használva a céllapokat adja vissza a forráslapok helyett.",
+ "apihelp-query+alllinks-param-prop": "Visszaadandó információk:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "A hivatkozó lapok lapazonosítói (nem használható együtt az <var>$1unique</var> paraméterrel).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "A hivatkozott lap címe.",
+ "apihelp-query+alllinks-param-namespace": "A listázandó névtér.",
+ "apihelp-query+alllinks-param-limit": "A visszaadandó elemek maximális száma.",
+ "apihelp-query+alllinks-param-dir": "A listázás iránya.",
+ "apihelp-query+alllinks-example-B": "Hivatkozott lapok listázása, a hiányzókat is beleértve, a forráslapok lapazonosítójával, <kbd>B</kbd>-től kezdve.",
+ "apihelp-query+alllinks-example-unique": "Különböző hivatkozott lapok listázása.",
+ "apihelp-query+alllinks-example-unique-generator": "Az összes hivatkozott lap lekérése, hiányzók megjelölése.",
+ "apihelp-query+alllinks-example-generator": "A hivatkozásokat tartalmazó lapok lekérése.",
+ "apihelp-query+allmessages-summary": "A wiki felületüzeneteinek lekérése.",
+ "apihelp-query+allmessages-param-messages": "A visszaadandó üzenetek. A <kbd>*</kbd> (alapértelmezés) az összes üzenetet jelenti.",
+ "apihelp-query+allmessages-param-prop": "A lekérendő tulajdonságok.",
+ "apihelp-query+allmessages-param-nocontent": "Ne tartalmazza a kimenet az üzenetek tartalmát.",
+ "apihelp-query+allmessages-param-includelocal": "Helyi üzenetek befoglalása (a szoftverben nem, de a {{ns:MediaWiki}} névtérben létező üzenetek).\nEz az összes lapot listázza a {{ns:MediaWiki}} névtérben, így a nem valódi üzeneteket is, mint a [[MediaWiki:Common.js|Common.js]] fájl.",
+ "apihelp-query+allmessages-param-args": "Az üzenetben behelyettesítendő paraméterek.",
+ "apihelp-query+allmessages-param-filter": "Csak az ezen szöveget tartalmazó nevű üzenetek visszaadása.",
+ "apihelp-query+allmessages-param-customised": "Az üzenetek szűrése módosítási állapot alapján.",
+ "apihelp-query+allmessages-param-lang": "A visszaadott üzenetek nyelve.",
+ "apihelp-query+allmessages-param-from": "Listázás ettől az üzenettől.",
+ "apihelp-query+allmessages-param-to": "Listázás eddig az üzenetig.",
+ "apihelp-query+allmessages-param-prefix": "Ezzel kezdődő nevű üzenetek visszaadása.",
+ "apihelp-query+allmessages-example-ipb": "<kbd>ipb-</kbd> előtagú üzenetek lekérése.",
+ "apihelp-query+allmessages-example-de": "Az <kbd>august</kbd> és <kbd>mainpage</kbd> üzenetek lekérése német nyelven.",
+ "apihelp-query+allpages-summary": "Egy adott névtér összes lapjának visszaadása.",
+ "apihelp-query+allpages-param-from": "A lapok listázása ettől a címtől.",
+ "apihelp-query+allpages-param-to": "A lapok listázása eddig a címig.",
+ "apihelp-query+allpages-param-prefix": "Ezzel kezdődő című lapok keresése.",
+ "apihelp-query+allpages-param-namespace": "A listázandó névtér.",
+ "apihelp-query+allpages-param-filterredir": "A listázandó lapok.",
+ "apihelp-query+allpages-param-minsize": "A lapok minimális hossza bájtban.",
+ "apihelp-query+allpages-param-maxsize": "A lapok maximális hossza bájtban.",
+ "apihelp-query+allpages-param-prtype": "Csak védett lapok listázása.",
+ "apihelp-query+allpages-param-prlevel": "A védelmek szűrése a védelmi szint alapján (csak az <var>$1prtype=</var> paraméterrel együtt használható).",
+ "apihelp-query+allpages-param-limit": "A visszaadandó lapok maximális száma.",
+ "apihelp-query+allpages-param-dir": "A listázás iránya.",
+ "apihelp-query+allpages-param-filterlanglinks": "Szűrés az alapján, hogy vannak-e nyelvközi hivatkozások a lapon. Nem biztos, hogy figyelembe veszi a kiterjesztések által hozzáadott nyelvközi hivatkozásokat.",
+ "apihelp-query+allpages-param-prexpiry": "Szűrés a védelem lejárata alapján:\n;indefinite: Csak meghatározatlan idejű védelemmel ellátott lapok.\n;definite: Csak meghatározott idejű (konkrét lejáratú) védelemmel ellátott lapok.\n;all: Bármilyen lejáratú védelemmel ellátott lapok.",
+ "apihelp-query+allpages-example-B": "Lapok listázása <kbd>B</kbd>-től kezdve.",
+ "apihelp-query+allpages-example-generator": "Információk 4 lapról <kbd>T</kbd>-től kezdve.",
+ "apihelp-query+allpages-example-generator-revisions": "Az első két nem átirányító lap tartalmának megjelenítése <kbd>Re</kbd>-től kezdve.",
+ "apihelp-query+allredirects-summary": "Egy adott névtérbe mutató összes átirányítás listázása.",
+ "apihelp-query+allredirects-param-from": "Listázás ettől az átirányításcímtől.",
+ "apihelp-query+allredirects-param-to": "Listázás eddig az átirányításcímig.",
+ "apihelp-query+allredirects-param-prefix": "Ezzel kezdődő című céllapok keresése.",
+ "apihelp-query+allredirects-param-unique": "Csak különböző céllapok listázása. Nem használható együtt az <kbd>$1prop=ids|fragment|interwiki</kbd> paraméterrel.\nGenerátorként használva a céllapokat adja vissza a forráslapok helyett.",
+ "apihelp-query+allredirects-param-prop": "Visszaadandó információk:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Az átirányító lapok lapazonosítói (nem használható együtt az <var>$1unique</var> paraméterrel).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Az átirányítás címe.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Az átirányítás célszakasza, ha van (nem használható együtt az <var>$1unique</var> paraméterrel).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Az átirányítás céljának interwiki-előtagja, ha van (nem használható együtt az <var>$1unique</var> paraméterrel).",
+ "apihelp-query+allredirects-param-namespace": "A listázandó névtér.",
+ "apihelp-query+allredirects-param-limit": "A visszaadandó elemek maximális száma.",
+ "apihelp-query+allredirects-param-dir": "A listázás iránya.",
+ "apihelp-query+allredirects-example-B": "Céllapok listázása, a hiányzókat is beleértve, a forráslapok lapazonosítójával, <kbd>B</kbd>-től kezdve.",
+ "apihelp-query+allredirects-example-unique": "Különböző céllapok listázása.",
+ "apihelp-query+allredirects-example-unique-generator": "Az összes céllap lekérése, hiányzók megjelölése.",
+ "apihelp-query+allredirects-example-generator": "Az átirányításokat tartalmazó lapok lekérése.",
+ "apihelp-query+allrevisions-summary": "Az összes lapváltozat listázása.",
+ "apihelp-query+allrevisions-param-start": "A listázás kezdő időbélyege.",
+ "apihelp-query+allrevisions-param-end": "A lista végét jelentő időbélyeg.",
+ "apihelp-query+allrevisions-param-user": "Csak ezen felhasználó szerkesztéseinek listázása.",
+ "apihelp-query+allrevisions-param-excludeuser": "Ezen felhasználó szerkesztéseinek kihagyása.",
+ "apihelp-query+allrevisions-param-namespace": "Lapok listázása csak ebben a névtérben.",
+ "apihelp-query+allrevisions-param-generatetitles": "Generátorként használva címek visszaadása lapváltozat-azonosítók helyett.",
+ "apihelp-query+allrevisions-example-user": "<kbd>Example</kbd> 50 legutóbbi szerkesztésének listázása.",
+ "apihelp-query+allrevisions-example-ns-main": "A fő névtér első 50 szerkesztésének listázása.",
+ "apihelp-query+mystashedfiles-param-prop": "A fájlok lekérendő tulajdonságai.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "A fájlméret és a kép dimenziói (szélessége és magassága).",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "A fájl MIME-típusa és médiatípusa.",
+ "apihelp-query+mystashedfiles-param-limit": "A lekérendő fájlok száma.",
+ "apihelp-query+alltransclusions-summary": "Az összes beillesztés listázása (&#123;&#123;x&#125;&#125; kóddal beillesztett lapok), beleértve a nem létező lapokét is.",
+ "apihelp-query+alltransclusions-param-from": "Listázás ettől a beillesztett laptól.",
+ "apihelp-query+alltransclusions-param-to": "Listázás eddig a beillesztett lapig.",
+ "apihelp-query+alltransclusions-param-prefix": "Ezzel kezdődő című beillesztett lapok keresése.",
+ "apihelp-query+alltransclusions-param-unique": "Csak különböző beillesztett címek listázása. Nem használható együtt az <kbd>$1prop=ids</kbd> paraméterrel.\nGenerátorként használva a céllapokat adja vissza a forráslapok helyett.",
+ "apihelp-query+alltransclusions-param-prop": "Visszaadandó információk:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "A lapokat beillesztő lapok lapazonosítói (nem használható együtt az <var>$1unique</var> paraméterrel).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "A beillesztés címe.",
+ "apihelp-query+alltransclusions-param-namespace": "A listázandó névtér.",
+ "apihelp-query+alltransclusions-param-limit": "A visszaadandó elemek maximális száma.",
+ "apihelp-query+alltransclusions-param-dir": "A listázás iránya.",
+ "apihelp-query+alltransclusions-example-B": "Beillesztett lapok listázása, a hiányzókat is beleértve, a forráslapok lapazonosítójával, <kbd>B</kbd>-től kezdve.",
+ "apihelp-query+alltransclusions-example-unique": "Különböző beillesztett címek listázása.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Az összes beillesztett lap lekérése, hiányzók megjelölése.",
+ "apihelp-query+alltransclusions-example-generator": "A beillesztéseket tartalmazó lapok lekérése.",
+ "apihelp-query+allusers-summary": "Az összes regisztrált felhasználó visszaadása.",
+ "apihelp-query+allusers-param-from": "A felhasználók listázása ettől a névtől.",
+ "apihelp-query+allusers-param-to": "A felhasználók listázása eddig a névig.",
+ "apihelp-query+allusers-param-prefix": "Ezzel kezdődő nevű felhasználók keresése.",
+ "apihelp-query+allusers-param-dir": "A rendezés iránya.",
+ "apihelp-query+allusers-param-group": "Csak megadott csoportokba tartozó felhasználók visszaadása.",
+ "apihelp-query+allusers-param-excludegroup": "Csak a megadott csoportokba <em>nem</em> tartozó felhasználók visszaadása.",
+ "apihelp-query+allusers-param-rights": "Csak a megadott jogosultságokkal rendelkező felhasználók visszaadása. Ez nem tartalmazza azokat a jogosultságokat, amiket implicit vagy automatikusan hozzáadott csoportok adnak meg, mint a *, a user vagy az autoconfirmed.",
+ "apihelp-query+allusers-param-prop": "Visszaadandó információk:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Információk a felhasználó jelenleg érvényes blokkjáról.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "A felhasználó csoportjai. Ez több szervererőforrást használ, és lehet, hogy a limitnél kevesebb eredményt ad vissza.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "A felhasználó automatikus csoportjai.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "A felhasználó jogosultságai.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "A felhasználó szerkesztésszáma.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "A felhasználó regisztrációjának időbélyege, ha elérhető (lehet üres).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "A felhasználó központi azonosítói és az összekapcsolási státusza.",
+ "apihelp-query+allusers-param-limit": "A visszaadandó felhasználónevek maximális száma.",
+ "apihelp-query+allusers-param-witheditsonly": "Csak szerkesztéssel rendelkező felhasználók listázása.",
+ "apihelp-query+allusers-param-activeusers": "Csak az elmúlt $1 napban aktív felhasználók listázása.",
+ "apihelp-query+allusers-param-attachedwiki": "Az <kbd>$1prop=centralids</kbd> paraméter mellett annak jelzése, hogy a felhasználó össze van-e kapcsolva a megadott wikivel.",
+ "apihelp-query+allusers-example-Y": "A felhasználók listázása <kbd>Y</kbd>-tól kezdve.",
+ "apihelp-query+authmanagerinfo-summary": "Információk lekérése az aktuális azonosítási státuszról.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Annak ellenőrzése, hogy a felhasználó jelenlegi azonosítási státusza megfelelő-e a megadott biztonságkritikus művelethez.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Információk lekérése a megadott azonosítási művelethez szükséges azonosítási kérésekről.",
+ "apihelp-query+authmanagerinfo-example-login": "Egy bejelentkezés elkezdéséhez használható kérések lekérése.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Egy bejelentkezés elkezdéséhez használható kérések lekérése, az űrlapmezők összevonásával.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Annak ellenőrzése, hogy a hitelesítés megfelelő-e a <kbd>foo</kbd> művelethez.",
+ "apihelp-query+backlinks-summary": "Egy adott lapra hivatkozó más lapok megkeresése.",
+ "apihelp-query+backlinks-param-title": "A keresendő cím. Nem használható együtt a <var>$1pageid</var> paraméterrel.",
+ "apihelp-query+backlinks-param-pageid": "A keresendő lapazonosító. Nem használható együtt a <var>$1title</var> paraméterrel.",
+ "apihelp-query+backlinks-param-namespace": "A listázandó névtér.",
+ "apihelp-query+backlinks-param-dir": "A listázás iránya.",
+ "apihelp-query+backlinks-param-filterredir": "Az átirányítások szűrése. Ha <kbd>nonredirects</kbd>, és a <var>$1redirect</var> paraméter engedélyezett, csak a második szintre érvényes.",
+ "apihelp-query+backlinks-param-limit": "A visszaadandó lapok maximális száma. Ha a <var>$1redirect</var> engedélyezve van, ez a limit minden szintre külön érvényes (vagyis összesen 2 × <var>$1limit</var> eredmény lehet a válaszban).",
+ "apihelp-query+backlinks-param-redirect": "Ha a hivatkozó lap átirányítás, az arra hivatkozó lapok keresése szintén. A maximális limit feleződik.",
+ "apihelp-query+backlinks-example-simple": "A <kbd>Main Page</kbd> lapra mutató hivatkozások keresése.",
+ "apihelp-query+backlinks-example-generator": "Információk lekérése a <kbd>Main Page</kbd>-re hivatkozó lapokról.",
+ "apihelp-query+blocks-summary": "Az összes blokkolt felhasználó és IP-cím listázása.",
+ "apihelp-query+blocks-param-start": "A listázás kezdő időbélyege.",
+ "apihelp-query+blocks-param-end": "A lista végét jelentő időbélyeg.",
+ "apihelp-query+blocks-param-ids": "A listázandó blokkok blokkazonosítói (opcionális).",
+ "apihelp-query+blocks-param-users": "A keresendő felhasználók (opcionális).",
+ "apihelp-query+blocks-param-ip": "Minden erre az IP-címre vagy CIDR tartományra vonatkozó blokk listázása, a tartományblokkokat is beleértve. Nem használható együtt a <var>$3users</var> paraméterrel. A CIDR tartományok maximális szélessége IPv4 esetén /$1, IPv6 esetén /$2.",
+ "apihelp-query+blocks-param-limit": "A listázandó blokkok maximális száma.",
+ "apihelp-query+blocks-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+blocks-paramvalue-prop-id": "A blokk azonosítója.",
+ "apihelp-query+blocks-paramvalue-prop-user": "A blokkolt felhasználó felhasználóneve.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "A blokkolt felhasználó felhasználóazonosítója.",
+ "apihelp-query+blocks-paramvalue-prop-by": "A blokkoló felhasználó felhasználóneve.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "A blokkoló felhasználó felhasználóazonosítója.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "A blokkolás időbélyege.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "A blokk lejáratának időbélyege.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "A blokk indoklása.",
+ "apihelp-query+blocks-paramvalue-prop-range": "A blokk által érintett IP-címek tartománya.",
+ "apihelp-query+blocks-param-show": "Csak a megadott feltételeknek megfelelő elemek megjelenítése.\nPéldául csak IP-címek végtelen blokkjainak megjelenítéséhez állítsd <kbd>$1show=ip|!temp</kbd> értékre.",
+ "apihelp-query+blocks-example-simple": "Blokkok listázása.",
+ "apihelp-query+blocks-example-users": "<kbd>Alice</kbd> és <kbd>Bob</kbd> blokkjainak listázása.",
+ "apihelp-query+categories-summary": "A lapok összes kategóriájának listázása.",
+ "apihelp-query+categories-param-prop": "A kategóriákhoz lekérendő további tulajdonságok:",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "A kategória hozzáadásának időbélyege.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "A <code>_&#95;HIDDENCAT_&#95;</code> kapcsolóval elrejtett kategóriák megjelölése.",
+ "apihelp-query+categories-param-show": "A megjelenítendő kategóriatípusok.",
+ "apihelp-query+categories-param-limit": "A visszaadandó kategóriák száma.",
+ "apihelp-query+categories-param-categories": "Csak ezen kategóriák listázása. Annak ellenőrzésére alkalmas, hogy egy lap benne van-e egy adott kategóriában.",
+ "apihelp-query+categories-param-dir": "A listázás iránya.",
+ "apihelp-query+categories-example-simple": "Az <kbd>Albert Einstein</kbd> lap kategóriáinak lekérése.",
+ "apihelp-query+categories-example-generator": "Információk lekérése az <kbd>Albert Einstein</kbd> lap kategóriáiról.",
+ "apihelp-query+categoryinfo-summary": "Információk lekérése a megadott kategóriákról.",
+ "apihelp-query+categoryinfo-example-simple": "Információk lekérése a <kbd>Category:Foo</kbd> és a <kbd>Category:Bar</kbd> kategóriáról.",
+ "apihelp-query+categorymembers-summary": "Egy kategória összes tagjának listázása.",
+ "apihelp-query+categorymembers-param-title": "A listázandó kategória (kötelező). Tartalmaznia kell a <kbd>{{ns:category}}:</kbd> előtagot. Nem használható együtt a <var>$1pageid</var> paraméterrel.",
+ "apihelp-query+categorymembers-param-pageid": "A listázandó kategória lapazonosítója. Nem használható együtt a <var>$1title</var> paraméterrel.",
+ "apihelp-query+categorymembers-param-prop": "Visszaadandó információk:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "A lap lapazonosítója.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "A lap címe és névterének azonosítója.",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "A lap kategorizálási típusa (<samp>page</samp>, <samp>subcat</samp> vagy <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "A lap bekategorizálásának időbélyege.",
+ "apihelp-query+categorymembers-param-namespace": "Csak ezen névterekben található lapok visszaadása. A <kbd>$1namespace=14</kbd> és <kbd>$1namespace=6</kbd> helyett használható <kbd>$1type=subcat</kbd>, illetve <kbd>$1type=file</kbd>.",
+ "apihelp-query+categorymembers-param-type": "A megadott kategorizálási típusú lapok visszaadása. Nincs hatása, ha a <var>$1sort</var> paraméter értéke <kbd>timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-limit": "A lekérdezendő lapok maximális száma.",
+ "apihelp-query+categorymembers-param-sort": "Rendezési szempont.",
+ "apihelp-query+categorymembers-param-dir": "A rendezés iránya.",
+ "apihelp-query+categorymembers-param-start": "A listázás kezdő időbélyege. Csak <kbd>$1sort=timestamp</kbd> paraméterrel együtt használható.",
+ "apihelp-query+categorymembers-param-end": "A lista végét jelentő időbélyeg. Csak <kbd>$1sort=timestamp</kbd> paraméterrel együtt használható.",
+ "apihelp-query+categorymembers-param-startsortkey": "Használd a <var>$1starthexsortkey</var> paramétert helyette.",
+ "apihelp-query+categorymembers-param-endsortkey": "Használd a <var>$1endhexsortkey</var> paramétert helyette.",
+ "apihelp-query+categorymembers-example-simple": "A <kbd>Category:Physics</kbd> első 10 tagjának lekérése.",
+ "apihelp-query+categorymembers-example-generator": "Információk lekérése a <kbd>Category:Physics</kbd> első 10 tagjáról.",
+ "apihelp-query+contributors-summary": "Egy lap bejelentkezett közreműködői listájának, valamint az anonim közreműködők számának lekérése.",
+ "apihelp-query+contributors-param-group": "Csak a megadott felhasználócsoportok tagjainak visszaadása. Ez nem tartalmazza az implicit vagy automatikusan hozzáadott csoportokat, mint a *, a user vagy az autoconfirmed.",
+ "apihelp-query+contributors-param-excludegroup": "A megadott felhasználócsoportok tagjainak kihagyása. Ez nem tartalmazza az implicit vagy automatikusan hozzáadott csoportokat, mint a *, a user vagy az autoconfirmed.",
+ "apihelp-query+contributors-param-rights": "Csak a megadott jogosultságokkal rendelkező felhasználók visszaadása. Ez nem tartalmazza azokat a jogosultságokat, amiket implicit vagy automatikusan hozzáadott csoportok adnak meg, mint a *, a user vagy az autoconfirmed.",
+ "apihelp-query+contributors-param-excluderights": "A megadott jogosultságokkal rendelkező felhasználók kizárása. Ez nem tartalmazza azokat a jogosultságokat, amiket implicit vagy automatikusan hozzáadott csoportok adnak meg, mint a *, a user vagy az autoconfirmed.",
+ "apihelp-query+contributors-param-limit": "A visszaadandó közreműködők maximális száma.",
+ "apihelp-query+contributors-example-simple": "A <kbd>Main Page</kbd> lap közreműködőinek lekérése.",
+ "apihelp-query+deletedrevisions-param-start": "Listázás ettől az időbélyegtől. Nincs hatása, ha lapváltozat-azonosítók vannak megadva.",
+ "apihelp-query+deletedrevisions-param-end": "A lista végét jelentő időbélyeg. Nincs hatása, ha lapváltozat-azonosítók vannak megadva.",
+ "apihelp-query+deletedrevisions-param-tag": "Csak ezzel a címkével ellátott változatok listázása.",
+ "apihelp-query+deletedrevisions-param-user": "Csak ezen felhasználó szerkesztéseinek listázása.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Ezen felhasználó szerkesztéseinek kihagyása.",
+ "apihelp-query+deletedrevisions-example-titles": "A <kbd>Main Page</kbd> és <kbd>Talk:Main Page</kbd> lapok törölt lapváltozatainak listázása tartalommal.",
+ "apihelp-query+deletedrevisions-example-revids": "Információk listázása az <kbd>123456</kbd> törölt lapváltozatról.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Mód|Módok}}: $2",
+ "apihelp-query+deletedrevs-param-start": "A listázás kezdő időbélyege.",
+ "apihelp-query+deletedrevs-param-end": "A lista végét jelentő időbélyeg.",
+ "apihelp-query+deletedrevs-param-from": "Listázás ettől a címtől.",
+ "apihelp-query+deletedrevs-param-to": "Listázás eddig a címig.",
+ "apihelp-query+deletedrevs-param-prefix": "Ezzel kezdődő című lapok keresése.",
+ "apihelp-query+deletedrevs-param-unique": "Egyetlen lapváltozat listázása minden laphoz.",
+ "apihelp-query+deletedrevs-param-tag": "Csak ezzel a címkével ellátott változatok listázása.",
+ "apihelp-query+deletedrevs-param-user": "Csak ezen felhasználó szerkesztéseinek listázása.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Ezen felhasználó szerkesztéseinek kihagyása.",
+ "apihelp-query+deletedrevs-param-namespace": "Lapok listázása csak ebben a névtérben.",
+ "apihelp-query+deletedrevs-param-limit": "A listázandó lapváltozatok maximális száma.",
+ "apihelp-query+deletedrevs-example-mode1": "<kbd>Main Page</kbd> és <kbd>Talk:Main Page</kbd> utolsó törölt lapváltozatainak listázása tartalommal (1. mód).",
+ "apihelp-query+deletedrevs-example-mode2": "<kbd>Bob</kbd> felhasználó utolsó 50 törölt szerkesztésének listázása (2. mód).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Az első 50 törölt lapváltozat listázása a fő névtérben (3. mód).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Az első 50 törölt lapváltozat listázása a {{ns:talk}} névtérben (3. mód).",
+ "apihelp-query+disabled-summary": "Ez a lekérdezőmodul le lett tiltva.",
+ "apihelp-query+duplicatefiles-param-limit": "A visszaadandó duplikátumok száma.",
+ "apihelp-query+duplicatefiles-param-dir": "A listázás iránya.",
+ "apihelp-query+duplicatefiles-param-localonly": "Csak helyi fájlok keresése.",
+ "apihelp-query+duplicatefiles-example-simple": "[[:File:Albert Einstein Head.jpg]] duplikátumainak keresése.",
+ "apihelp-query+duplicatefiles-example-generated": "Az összes fájl duplikátumainak keresése.",
+ "apihelp-query+embeddedin-summary": "A megadott lapot beillesztő összes lap lekérése.",
+ "apihelp-query+embeddedin-param-title": "A keresendő lap címe. Nem használható együtt az <var>$1pageid</var> paraméterrel.",
+ "apihelp-query+embeddedin-param-pageid": "A keresendő lap lapazonosítója. Nem használható együtt az <var>$1title</var> paraméterrel.",
+ "apihelp-query+embeddedin-param-namespace": "A listázandó névtér.",
+ "apihelp-query+embeddedin-param-dir": "A listázás iránya.",
+ "apihelp-query+embeddedin-param-filterredir": "Szűrés átirányítások alapján.",
+ "apihelp-query+embeddedin-param-limit": "A visszaadandó lapok maximális száma.",
+ "apihelp-query+embeddedin-example-simple": "A <kbd>Template:Stub</kbd> lapot beillesztő lapok megjelenítése.",
+ "apihelp-query+embeddedin-example-generator": "Információk lekérése a <kbd>Template:Stub</kbd> lapot beillesztő lapokról.",
+ "apihelp-query+extlinks-summary": "A megadott lapokon található összes külső (nem interwiki) link visszaadása.",
+ "apihelp-query+extlinks-param-limit": "A visszaadandó linkek száma.",
+ "apihelp-query+extlinks-param-protocol": "Az URL protokollja. Ha üres és az <var>$1query</var> paraméter meg van adva, a protokoll <kbd>http</kbd>. Hagyd ezt és az <var>$1query</var> paramétert is üresen az összes külső link listázásához.",
+ "apihelp-query+extlinks-example-simple": "A <kbd>Main Page</kbd> lapon található összes külső hivatkozás listájának lekérése.",
+ "apihelp-query+exturlusage-summary": "Egy megadott URL-t tartalmazó lapok visszaadása.",
+ "apihelp-query+exturlusage-param-prop": "Visszaadandó információk:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "A lap lapazonosítója.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "A lap címe és névterének azonosítója.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "A lapon használt URL.",
+ "apihelp-query+exturlusage-param-protocol": "Az URL protokollja. Ha üres és az <var>$1query</var> paraméter meg van adva, a protokoll <kbd>http</kbd>. Hagyd ezt és az <var>$1query</var> paramétert is üresen az összes külső link listázásához.",
+ "apihelp-query+exturlusage-param-namespace": "A listázandó névtér.",
+ "apihelp-query+exturlusage-param-limit": "A visszaadandó lapok száma.",
+ "apihelp-query+exturlusage-example-simple": "A <kbd>http://www.mediawiki.org</kbd> URL-re hivatkozó lapok megjelenítése.",
+ "apihelp-query+filearchive-summary": "Az összes törölt fájl visszaadása.",
+ "apihelp-query+filearchive-param-from": "A fájlok listázása ettől a címtől.",
+ "apihelp-query+filearchive-param-to": "A fájlok listázása eddig a címig.",
+ "apihelp-query+filearchive-param-prefix": "Ezzel kezdődő című fájlok keresése.",
+ "apihelp-query+filearchive-param-limit": "A visszaadandó fájlok száma.",
+ "apihelp-query+filearchive-param-dir": "A listázás iránya.",
+ "apihelp-query+filearchive-param-prop": "A lekérendő információk:",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "A feltöltött verzió időbélyege.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "A fájlverzió feltöltője.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "A fájl mérete bájtban, magassága, szélessége és oldalszáma (ha értelmezhető).",
+ "apihelp-query+filearchive-paramvalue-prop-description": "A fájlverzió leírása.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "A fájl MIME-típusa.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "A fájl médiatípusa.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "A fájlverzió EXIF-metaadatai.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "A verzió bitmélysége.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Az archivált verzió fájlneve a nem legújabb verziók esetén.",
+ "apihelp-query+filearchive-example-simple": "Az összes törölt fájl listázása.",
+ "apihelp-query+filerepoinfo-summary": "Metainformációk visszaadása a wikin beállított fájltárolókról.",
+ "apihelp-query+filerepoinfo-example-simple": "Információk lekérése a fájltárolókról.",
+ "apihelp-query+fileusage-summary": "A megadott fájlokat használó lapok lekérése.",
+ "apihelp-query+fileusage-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "A lapok lapazonosítói.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "A lapok címei.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Az átirányítások megjelölése.",
+ "apihelp-query+fileusage-param-namespace": "Lapok listázása ezekben a névterekben.",
+ "apihelp-query+fileusage-param-limit": "A visszaadandó lapok száma.",
+ "apihelp-query+fileusage-param-show": "Szűrés az átirányítások alapján:\n;redirect: Csak átirányítások visszaadása.\n;!redirect: Átirányítások elrejtése.",
+ "apihelp-query+fileusage-example-simple": "A [[:File:Example.jpg]] képet használó lapok listázása.",
+ "apihelp-query+fileusage-example-generator": "Információk lekérése a [[:File:Example.jpg]] képet használó lapokról.",
+ "apihelp-query+imageinfo-summary": "Fájlinformációk és fájltörténet lekérése.",
+ "apihelp-query+imageinfo-param-prop": "A lekérendő fájlinformációk:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "A feltöltött verzió időbélyege.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Az egyes fájlverziók feltöltői.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Az egyes fájlverziók feltöltőinek felhasználóazonosítói.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "A verzió feltöltési összefoglalója.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "A fájlra és a leírólapra mutató URL.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "A fájl mérete bájtban, magassága, szélessége és oldalszáma (ha értelmezhető).",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "SHA-1 hash hozzáadása a fájlhoz.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "MIME-típus hozzáadása a fájlhoz.",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "A fájl médiatípusa.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "A fájlverzió EXIF-metaadatai.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "A formázott metaadatok több forrásból kombinálva. A kimenet HTML-formázott.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Az archivált verzió fájlneve a nem legújabb verziók esetén.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "A verzió bitmélysége.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "A Speciális:Feltöltés lap használja egy létező fájl információinak lekéréséhez. Nem készült a MediaWiki magján kívüli használatra.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "A fájl szerepel-e a [[MediaWiki:Bad image list]] listán.",
+ "apihelp-query+imageinfo-param-limit": "A fájlonként visszaadandó verziók száma.",
+ "apihelp-query+imageinfo-param-start": "A listázás kezdő időbélyege.",
+ "apihelp-query+imageinfo-param-end": "A lista végét jelentő időbélyeg.",
+ "apihelp-query+imageinfo-param-urlwidth": "Ha az <kbd>$2prop=url</kbd> meg van adva, erre a szélességre méretezett kép URL-jét adja vissza.\nTeljesítményi okokból ezen opció használatakor legfeljebb $1 átméretezett képet ad vissza.",
+ "apihelp-query+imageinfo-param-urlheight": "Az <var>$1urlwidth</var> paraméterhez hasonló.",
+ "apihelp-query+imageinfo-param-metadataversion": "A használandó metaadat-verzió. Ha <kbd>latest</kbd>, a legfrissebb verzió használata. Alapértelmezetten <kbd>1</kbd> a visszamenőleges kompatibilitás érdekében.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Ha elérhetők fordítások az <var>extmetadata</var> tulajdonsághoz, az összes lekérése.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Ha meg van adva és nem üres, csak ezeket a tulajdonságokat adja vissza az <kbd>$1prop=extmetadata</kbd> paraméter esetén.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Ha a <kbd>$2prop=badfile</kbd> meg van adva, ezen cím használata a [[MediaWiki:Bad image list]] kiértékeléskor.",
+ "apihelp-query+imageinfo-param-localonly": "Csak helyi fájlok keresése.",
+ "apihelp-query+imageinfo-example-simple": "Információk lekérése a [[:File:Albert Einstein Head.jpg]] aktuális verziójáról.",
+ "apihelp-query+imageinfo-example-dated": "Információk lekérése a [[:File:Test.jpg]] 2008-as és korábbi verzióiról.",
+ "apihelp-query+images-summary": "A megadott lapokon használt összes fájl visszaadása.",
+ "apihelp-query+images-param-limit": "A visszaadandó fájlok száma.",
+ "apihelp-query+images-param-images": "Csak ezen fájlok listázása. Annak ellenőrzésére alkalmas, hogy egy lap használ-e egy adott fájlt.",
+ "apihelp-query+images-param-dir": "A listázás iránya.",
+ "apihelp-query+images-example-simple": "A [[Main Page]] lapon használt fájlok listázása.",
+ "apihelp-query+images-example-generator": "Információk lekérése a [[Main Page]] lapon használt fájlokról.",
+ "apihelp-query+imageusage-summary": "A megadott képcímet használó lapok lekérése.",
+ "apihelp-query+imageusage-param-title": "A keresendő cím. Nem használható együtt az <var>$1pageid</var> paraméterrel.",
+ "apihelp-query+imageusage-param-pageid": "A keresendő lapazonosító. Nem használható együtt az <var>$1title</var> paraméterrel.",
+ "apihelp-query+imageusage-param-namespace": "A listázandó névtér.",
+ "apihelp-query+imageusage-param-dir": "A listázás iránya.",
+ "apihelp-query+imageusage-param-filterredir": "Az átirányítások szűrése. Ha <kbd>nonredirects</kbd>, és az <var>$1redirect</var> paraméter engedélyezett, csak a második szintre érvényes.",
+ "apihelp-query+imageusage-param-limit": "A visszaadandó lapok maximális száma. Ha az <var>$1redirect</var> engedélyezve van, ez a limit minden szintre külön érvényes (vagyis összesen 2 × <var>$1limit</var> eredmény lehet a válaszban).",
+ "apihelp-query+imageusage-param-redirect": "Ha a hivatkozó lap átirányítás, az arra hivatkozó lapok keresése szintén. A maximális limit feleződik.",
+ "apihelp-query+imageusage-example-simple": "A [[:File:Albert Einstein Head.jpg]] képet használó lapok megjelenítése.",
+ "apihelp-query+imageusage-example-generator": "Információk lekérése a [[:File:Albert Einstein Head.jpg]] képet használó lapokról.",
+ "apihelp-query+info-summary": "Alapvető lapinformációk lekérése.",
+ "apihelp-query+info-param-prop": "További lekérendő tulajdonságok:",
+ "apihelp-query+info-paramvalue-prop-protection": "A lapok védelmi szintjeinek listázása.",
+ "apihelp-query+info-paramvalue-prop-talkid": "A vitalap lapazonosítója a nem-vitalapoknál.",
+ "apihelp-query+info-paramvalue-prop-watched": "A lapok figyelési státusza.",
+ "apihelp-query+info-paramvalue-prop-watchers": "A lapot figyelők száma, ha lehetséges.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "A figyelők száma, akik látták a lap friss változtatásait, ha engedélyezett.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "A figyelőlista értesítési időbélyege.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "A tartalmi lap lapazonosítója a vitalapoknál.",
+ "apihelp-query+info-paramvalue-prop-readable": "A felhasználó olvashatja-e a lapot.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "A lap ténylegesen megjelenített címe.",
+ "apihelp-query+info-param-testactions": "Annak ellenőrzése, hogy a felhasználó végrehajthat-e bizonyos műveleteket a lapon.",
+ "apihelp-query+info-param-token": "Használd a <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> lekérdezést helyette.",
+ "apihelp-query+info-example-simple": "Információk lekérése a <kbd>Main Page</kbd> lapról.",
+ "apihelp-query+info-example-protection": "Alapvető és lapvédelmi információk lekérése a <kbd>Main Page</kbd> lapról.",
+ "apihelp-query+iwbacklinks-summary": "Egy adott interwikilinkre hivatkozó lapok lekérése.",
+ "apihelp-query+iwbacklinks-extended-description": "Használható adott előtagú vagy egy adott címre mutató (megadott előtagú) linkek keresésére. Mindkét paraméter elhagyásával az összes interwikilinket visszaadja.",
+ "apihelp-query+iwbacklinks-param-prefix": "Az interwiki előtagja.",
+ "apihelp-query+iwbacklinks-param-title": "A keresendő interwikilink. Az <var>$1blprefix</var> paraméterrel együtt használandó.",
+ "apihelp-query+iwbacklinks-param-limit": "A visszaadandó lapok maximális száma.",
+ "apihelp-query+iwbacklinks-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Az interwiki előtagja.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Az interwiki címe.",
+ "apihelp-query+iwbacklinks-param-dir": "A listázás iránya.",
+ "apihelp-query+iwbacklinks-example-simple": "A [[wikibooks:Test]] könyvre hivatkozó lapok lekérése.",
+ "apihelp-query+iwbacklinks-example-generator": "Információk lekérése a [[wikibooks:Test]] könyvre hivatkozó lapokról.",
+ "apihelp-query+iwlinks-summary": "A megadott lapokon található összes interwikilink lekérése.",
+ "apihelp-query+iwlinks-param-prop": "A nyelvközi hivatkozásokhoz lekérendő további tulajdonságok:",
+ "apihelp-query+iwlinks-param-limit": "A visszaadandó interwikilinkek száma.",
+ "apihelp-query+iwlinks-param-prefix": "Csak a megadott előtagú interwikilinkek visszaadása.",
+ "apihelp-query+iwlinks-param-title": "A keresendő interwikilink. Az <var>$1prefix</var> paraméterrel együtt használandó.",
+ "apihelp-query+iwlinks-param-dir": "A listázás iránya.",
+ "apihelp-query+iwlinks-example-simple": "A <kbd>Main Page</kbd> lapon található interwikilinkek lekérése.",
+ "apihelp-query+langbacklinks-summary": "A megadott nyelvközi hivatkozásra hivatkozó lapok lekérése.",
+ "apihelp-query+langbacklinks-extended-description": "Használható adott előtagú vagy egy adott címre mutató (megadott előtagú) linkek keresésére. Mindkét paraméter elhagyásával az összes nyelvközi hivatkozást visszaadja.\n\nEz a lekérdezés nem feltétlenül veszi figyelembe a kiterjesztések által hozzáadott nyelvközi hivatkozásokat.",
+ "apihelp-query+langbacklinks-param-lang": "A nyelvközi hivatkozás nyelve.",
+ "apihelp-query+langbacklinks-param-title": "A keresendő nyelvközi hivatkozás. Az <var>$1lang</var> paraméterrel együtt használandó.",
+ "apihelp-query+langbacklinks-param-limit": "A visszaadandó lapok maximális száma.",
+ "apihelp-query+langbacklinks-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "A nyelvközi hivatkozás nyelvkódja.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "A nyelvközi hivatkozás címe.",
+ "apihelp-query+langbacklinks-param-dir": "A listázás iránya.",
+ "apihelp-query+langbacklinks-example-simple": "A [[:fr:Test]] lapra hivatkozó lapok lekérése.",
+ "apihelp-query+langbacklinks-example-generator": "Információk lekérése a [[:fr:Test]] lapra hivatkozó lapokról.",
+ "apihelp-query+langlinks-summary": "A megadott lapokon található összes nyelvközi hivatkozás lekérése.",
+ "apihelp-query+langlinks-param-limit": "A visszaadandó nyelvközi hivatkozások száma.",
+ "apihelp-query+langlinks-param-prop": "A nyelvközi hivatkozásokhoz lekérendő további tulajdonságok:",
+ "apihelp-query+langlinks-param-lang": "Csak ezen nyelvű nyelvközi hivatkozások visszaadása.",
+ "apihelp-query+langlinks-param-title": "A keresendő hivatkozás. Az <var>$1prefix</var> paraméterrel együtt használandó.",
+ "apihelp-query+langlinks-param-dir": "A listázás iránya.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Nyelvkód a lefordított nyelvneveknek.",
+ "apihelp-query+langlinks-example-simple": "A <kbd>Main Page</kbd> lapon található nyelvközi hivatkozások lekérése.",
+ "apihelp-query+links-summary": "A megadott lapokon található összes hivatkozás lekérése.",
+ "apihelp-query+links-param-namespace": "Csak az ezen névterekbe mutató hivatkozások visszaadása.",
+ "apihelp-query+links-param-limit": "A visszaadandó hivatkozások száma.",
+ "apihelp-query+links-param-titles": "Csak ezen címekre mutató hivatkozások listázása. Annak ellenőrzésére alkalmas, hogy egy lap hivatkozik-e egy adott lapra.",
+ "apihelp-query+links-param-dir": "A listázás iránya.",
+ "apihelp-query+links-example-simple": "A <kbd>Main Page</kbd> lapon található hivatkozások lekérése.",
+ "apihelp-query+links-example-generator": "Információk lekérése a <kbd>Main Page</kbd> lapon lévő hivatkozások céllapjairól.",
+ "apihelp-query+links-example-namespaces": "A <kbd>Main Page</kbd> lapon található, {{ns:user}} és {{ns:template}} névterekbe mutató hivatkozások lekérése.",
+ "apihelp-query+linkshere-summary": "A megadott lapra hivatkozó lapok lekérése.",
+ "apihelp-query+linkshere-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "A lapok lapazonosítói.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "A lapok címei.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Az átirányítások megjelölése.",
+ "apihelp-query+linkshere-param-namespace": "Lapok listázása ezekben a névterekben.",
+ "apihelp-query+linkshere-param-limit": "A visszaadandó lapok száma.",
+ "apihelp-query+linkshere-param-show": "Szűrés az átirányítások alapján:\n;redirect: Csak átirányítások visszaadása.\n;!redirect: Átirányítások elrejtése.",
+ "apihelp-query+linkshere-example-simple": "A [[Main Page]] lapra hivatkozó lapok listázása.",
+ "apihelp-query+linkshere-example-generator": "Információk lekérése a [[Main Page]] lapra hivatkozó lapokról.",
+ "apihelp-query+logevents-summary": "Naplóbejegyzések lekérése.",
+ "apihelp-query+logevents-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "A naplóbejegyzés azonosítója.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Az eseményben érintett lap címe.",
+ "apihelp-query+logevents-paramvalue-prop-type": "A naplóbejegyzés típusa.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Az eseményért felelős felhasználó.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Az eseményért felelős felhasználó azonosítója.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "A naplóbejegyzés időbélyege.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "A naplóbejegyzéshez tartozó megjegyzés.",
+ "apihelp-query+logevents-paramvalue-prop-details": "További részletek a naplóbejegyzésről.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "A naplóbejegyzés címkéi.",
+ "apihelp-query+logevents-param-type": "Csak ezen típusú naplóbejegyzések visszaadása.",
+ "apihelp-query+logevents-param-start": "A listázás kezdő időbélyege.",
+ "apihelp-query+logevents-param-end": "A lista végét jelentő időbélyeg.",
+ "apihelp-query+logevents-param-user": "A bejegyzések szűrése az ezen felhasználó által végrehajtottakra.",
+ "apihelp-query+logevents-param-title": "A bejegyzések szűrése az ezen laphoz kapcsolódóakra.",
+ "apihelp-query+logevents-param-namespace": "A bejegyzések szűrése névtér alapján.",
+ "apihelp-query+logevents-param-prefix": "A bejegyzések szűrése az ezzel az előtaggal kezdődőekre.",
+ "apihelp-query+logevents-param-tag": "Csak ezzel a címkével ellátott bejegyzések listázása.",
+ "apihelp-query+logevents-param-limit": "A visszaadandó bejegyzések száma.",
+ "apihelp-query+logevents-example-simple": "A legutóbbi naplóbejegyzések listázása.",
+ "apihelp-query+pagepropnames-summary": "A wikin elérhető laptulajdonságnevek listázása.",
+ "apihelp-query+pagepropnames-param-limit": "A visszaadandó nevek maximális száma.",
+ "apihelp-query+pagepropnames-example-simple": "Az első 10 tulajdonságnév lekérése.",
+ "apihelp-query+pageprops-summary": "A lap tartalmában meghatározott különböző laptulajdonságok lekérése.",
+ "apihelp-query+pageprops-param-prop": "Csak ezen laptulajdonságok listázása (az [[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]] visszaadja a használatban lévő laptulajdonságokat). Annak ellenőrzésére alkalmas, hogy egy lap benne használ-e egy adott laptulajdonságot.",
+ "apihelp-query+pageprops-example-simple": "A <kbd>Main Page</kbd> és <kbd>MediaWiki</kbd> lap tulajdonságainak lekérése.",
+ "apihelp-query+pageswithprop-summary": "Egy adott laptulajdonságot használó lapok listázása.",
+ "apihelp-query+pageswithprop-param-propname": "A listázandó laptulajdonság (az [[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]] visszaadja a használatban lévő laptulajdonságokat).",
+ "apihelp-query+pageswithprop-param-prop": "Visszaadandó információk:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "A lap lapazonosítója.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "A lap címe és névterének azonosítója.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "A laptulajdonság értéke.",
+ "apihelp-query+pageswithprop-param-limit": "A lekérdezendő lapok maximális száma.",
+ "apihelp-query+pageswithprop-param-dir": "A rendezés iránya.",
+ "apihelp-query+pageswithprop-example-simple": "Az első 10 <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>-t használó lap listázása.",
+ "apihelp-query+pageswithprop-example-generator": "További információk lekérése az első 10 <code>_&#95;NOTOC_&#95;</code> kapcsolót használó lapról.",
+ "apihelp-query+prefixsearch-param-search": "A keresőkifejezés.",
+ "apihelp-query+prefixsearch-param-namespace": "A keresendő névterek.",
+ "apihelp-query+prefixsearch-param-limit": "Találatok maximális száma.",
+ "apihelp-query+prefixsearch-param-offset": "Kihagyandó találatok száma.",
+ "apihelp-query+prefixsearch-example-simple": "<kbd>meaning</kbd> kezdetű lapcímek keresése.",
+ "apihelp-query+prefixsearch-param-profile": "Használandó keresőprofil.",
+ "apihelp-query+protectedtitles-summary": "Létrehozás ellen védett lapok listázása.",
+ "apihelp-query+protectedtitles-param-namespace": "Címek listázása csak ezekben a névterekben.",
+ "apihelp-query+protectedtitles-param-level": "Csak ilyen védelmi szintű címek listázása.",
+ "apihelp-query+protectedtitles-param-limit": "A visszaadandó lapok maximális száma.",
+ "apihelp-query+protectedtitles-param-start": "Listázás ettől a védelem-időbélyegtől.",
+ "apihelp-query+protectedtitles-param-end": "Listázás eddig a védelem-időbélyegig.",
+ "apihelp-query+protectedtitles-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "A levédés időbélyege.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "A levédő felhasználó.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "A levédő felhasználó azonosítója.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "A védelem indoka.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "A védelem lejáratának időbélyege.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Védelmi szint.",
+ "apihelp-query+protectedtitles-example-simple": "A védett címek listázása.",
+ "apihelp-query+protectedtitles-example-generator": "A fő névtérben lévő védett címekre mutató hivatkozások lekérése.",
+ "apihelp-query+querypage-summary": "Egy QueryPage-alapú speciális lap listájának lekérése.",
+ "apihelp-query+querypage-param-limit": "Megjelenítendő találatok száma.",
+ "apihelp-query+querypage-example-ancientpages": "A [[Special:Ancientpages]] eredményeinek lekérése.",
+ "apihelp-query+random-param-namespace": "Lapok visszaadása csak ezekből a névterekből.",
+ "apihelp-query+random-param-limit": "A visszaadandó véletlenszerű lapok száma.",
+ "apihelp-query+random-param-redirect": "Használd a <kbd>$1filterredir=redirects</kbd> paramétert helyette.",
+ "apihelp-query+random-param-filterredir": "Szűrés átirányítások alapján.",
+ "apihelp-query+random-example-simple": "Két lap visszaadása találomra a fő névtérből.",
+ "apihelp-query+random-example-generator": "Lapinformációk lekérése két véletlenszerűen kiválasztott fő névtérbeli lapról.",
+ "apihelp-query+recentchanges-summary": "A friss változtatások listázása.",
+ "apihelp-query+recentchanges-param-start": "Listázás ettől az időbélyegtől.",
+ "apihelp-query+recentchanges-param-end": "Listázás eddig az időbélyegig.",
+ "apihelp-query+recentchanges-param-namespace": "A változtatások szűrése ezekre a névterekre.",
+ "apihelp-query+recentchanges-param-user": "Csak ezen felhasználó szerkesztéseinek listázása.",
+ "apihelp-query+recentchanges-param-excludeuser": "Ezen felhasználó szerkesztéseinek kihagyása.",
+ "apihelp-query+recentchanges-param-tag": "Csak ezzel a címkével ellátott szerkesztések listázása.",
+ "apihelp-query+recentchanges-param-prop": "További információk visszaadása:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "A szerkesztést végrehajtó felhasználó, és hogy anonim-e.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "A szerkesztést végrehajtó felhasználó azonosítója.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "A szerkesztési összefoglaló.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "A szerkesztés időbélyege.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "A szerkesztett lap címe.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "A lapazonosító, frissváltoztatások-azonosító, valamint a régi és az új lapváltozat-azonosító.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "A lap régi és új hossza bájtban.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "A lap átirányítás-e.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Az ellenőrizhető (patrol) szerkesztések megjelölése ellenőrzöttként vagy ellenőrizetlenként.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Naplóinformációk (naplóazonosító, naplótípus stb.) a naplóbejegyzésekhez.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "A változtatás címkéi.",
+ "apihelp-query+recentchanges-param-token": "Használd a <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> lekérdezést helyette.",
+ "apihelp-query+recentchanges-param-show": "Csak a kritériumoknak megfelelő elemek visszaadása. Például csak bejelentkezett felhasználók apró változtatásainak megtekintéséhez használd az <kbd>$1show=minor|!anon</kbd> értéket.",
+ "apihelp-query+recentchanges-param-limit": "A visszaadandó változások maximális száma.",
+ "apihelp-query+recentchanges-param-type": "A visszaadandó változások típusai.",
+ "apihelp-query+recentchanges-param-toponly": "Csak a lapok legfrissebb változtatásának visszaadása.",
+ "apihelp-query+recentchanges-example-simple": "Friss változtatások listázása.",
+ "apihelp-query+recentchanges-example-generator": "Lapinformációk lekérése az ellenőrizetlen változtatásokról (patrol).",
+ "apihelp-query+redirects-summary": "A megadott lapokra mutató átirányítások lekérése.",
+ "apihelp-query+redirects-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "Az átirányítások lapazonosítói.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Az átirányítások címei.",
+ "apihelp-query+redirects-param-namespace": "Lapok listázása csak ezekben a névterekben.",
+ "apihelp-query+redirects-param-limit": "A visszaadandó átirányítások száma.",
+ "apihelp-query+redirects-example-simple": "A [[Main Page]] lapra mutató átirányítások listázása.",
+ "apihelp-query+redirects-example-generator": "Információk lekérése a [[Main Page]] lapra mutató átirányításokról.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Csak egyetlen lappal használható (2. mód).",
+ "apihelp-query+revisions-param-startid": "Listázás ennek a lapváltozatnak az időbélyegétől. A lapváltozatnak léteznie kell, de nem szükséges ehhez a laphoz tartoznia.",
+ "apihelp-query+revisions-param-endid": "Listázás ennek a lapváltozatnak az időbélyegéig. A lapváltozatnak léteznie kell, de nem szükséges ehhez a laphoz tartoznia.",
+ "apihelp-query+revisions-param-start": "Listázás ettől az időbélyegtől.",
+ "apihelp-query+revisions-param-end": "Listázás eddig az időbélyegig.",
+ "apihelp-query+revisions-param-user": "Csak ezen felhasználó szerkesztéseinek listázása.",
+ "apihelp-query+revisions-param-excludeuser": "Ezen felhasználó szerkesztéseinek kihagyása.",
+ "apihelp-query+revisions-param-tag": "Csak ezzel a címkével ellátott változatok listázása.",
+ "apihelp-query+revisions-param-token": "Az egyes lapváltozatokhoz lekérendő tokenek.",
+ "apihelp-query+revisions-example-content": "Adatok lekérése tartalommal az <kbd>API</kbd> és <kbd>Main Page</kbd> lapok legfrissebb változatáról.",
+ "apihelp-query+revisions-example-last5": "A <kbd>Main Page</kbd> 5 legfrissebb változatának lekérése.",
+ "apihelp-query+revisions-example-first5": "A <kbd>Main Page</kbd> első 5 változatának lekérése.",
+ "apihelp-query+revisions-example-first5-after": "A <kbd>Main Page</kbd> 2006. május 1-jét követő első 5 változatának lekérése.",
+ "apihelp-query+revisions-example-first5-not-localhost": "A <kbd>Main Page</kbd> első 5 változatának lekérése, amit nem <kbd>127.0.0.1</kbd> anonim felhasználó készített.",
+ "apihelp-query+revisions-example-first5-user": "A <kbd>Main Page</kbd> első 5 változatának lekérése, amit a <kbd>MediaWiki default</kbd> felhasználó készített.",
+ "apihelp-query+revisions+base-param-prop": "Az egyes lapváltozatokhoz lekérendő tulajdonságok:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "A változat azonosítója.",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "A változat időbélyege.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "A változatot létrehozó felhasználó.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "A változatot létrehozó felhasználó azonosítója.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "A változat hossza bájtban.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "A változat tartalommodell-azonosítója.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "A szerkesztési összefoglaló.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "A változat szövege.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "A változat címkéi.",
+ "apihelp-query+revisions+base-param-limit": "A visszaadandó változatok maximális száma.",
+ "apihelp-query+revisions+base-param-expandtemplates": "Használd a <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> lekérdezést helyette. A sablonok kibontása a változat tartalmában (az <kbd>$1prop=content</kbd> paraméterrel együtt használandó).",
+ "apihelp-query+revisions+base-param-section": "Csak ezen szakasz tartalmának lekérése.",
+ "apihelp-query+search-summary": "Teljes szöveges keresés végrehajtása.",
+ "apihelp-query+search-param-search": "Erre az értékre illeszkedő lapcímek és tartalom keresése. Használható lehet speciális keresési funkciók meghívására a wiki keresőmotorjától függően.",
+ "apihelp-query+search-param-namespace": "Keresés csak ezekben a névterekben.",
+ "apihelp-query+search-param-what": "A végrehajtandó keresési típus.",
+ "apihelp-query+search-param-info": "A visszaadandó metaadatok.",
+ "apihelp-query+search-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+search-paramvalue-prop-size": "A lap mérete bájtban.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "A lap szószáma.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "A lap utolsó szerkesztésének időbélyege.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Az illeszkedő átirányítás címe.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Az illeszkedő szakaszcím.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "A fájl tartalma illeszkedik-e.",
+ "apihelp-query+search-paramvalue-prop-score": "Figyelmen kívül hagyva.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Figyelmen kívül hagyva.",
+ "apihelp-query+search-param-limit": "A visszaadandó lapok maximális száma.",
+ "apihelp-query+search-param-interwiki": "Interwiki-találatok befoglalása az eredménybe, ha elérhetők.",
+ "apihelp-query+search-param-backend": "A használandó keresőmotor, ha nem az alapértelmezett.",
+ "apihelp-query+search-param-enablerewrites": "A keresőkifejezés átírásának engedélyezése. Bizonyos keresőmotorok át tudják írni a keresőkifejezést egy jobb találatokat adónak ítéltre, például elgépelések javításával.",
+ "apihelp-query+search-example-simple": "Keresés a <kbd>meaning</kbd> szóra.",
+ "apihelp-query+search-example-text": "Keresés a <kbd>meaning</kbd> szóra a lapok szövegében.",
+ "apihelp-query+search-example-generator": "Lapinformációk lekérése a <kbd>meaning</kbd> szóra kapott találatokról.",
+ "apihelp-query+siteinfo-summary": "Általános információk lekérése a wikiről.",
+ "apihelp-query+siteinfo-param-prop": "A lekérendő információk:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Általános rendszerinformációk.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Wikistatisztikák.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Felhasználócsoportok és jogaik.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "A wikin telepített függvénykönyvtárak.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "A wikin telepített kiterjesztések.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "A feltölthető fájlkiterjesztések (fájltípusok) listája.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "A wiki szerzői jogi (licenc-) információi, ha elérhetők.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Információk az elérhető korlátozási (lapvédelmi) típusokról.",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Az elérhető változónevek listája.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "A külső hivatkozásokban engedélyezett protokollok listája.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "A felhasználói beállítások alapértelmezett értékei.",
+ "apihelp-query+siteinfo-param-filteriw": "Csak helyi vagy csak nem helyi interwikik visszaadása az interwikitérképben.",
+ "apihelp-query+siteinfo-param-numberingroup": "A egyes felhasználócsoportokba tartozó felhasználók számának listázása.",
+ "apihelp-query+siteinfo-example-simple": "Wikiinformációk lekérése.",
+ "apihelp-query+siteinfo-example-interwiki": "A helyi interwiki-előtagok listájának lekérése.",
+ "apihelp-query+tags-summary": "Változtatáscímkék listázása.",
+ "apihelp-query+tags-param-limit": "A listázandó címkék maximális száma.",
+ "apihelp-query+tags-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+tags-paramvalue-prop-name": "A címke neve.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "A címke rendszerüzenete.",
+ "apihelp-query+tags-paramvalue-prop-description": "A címke leírása.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "A címkével rendelkező lapváltozatok és naplóbejegyzések száma.",
+ "apihelp-query+tags-paramvalue-prop-defined": "A címke definiálva van-e.",
+ "apihelp-query+tags-example-simple": "Az elérhető címkék listázása.",
+ "apihelp-query+templates-summary": "A megadott lapokra beillesztett összes lap visszaadása.",
+ "apihelp-query+templates-param-namespace": "Csak ezekben a névterekben található sablonok visszaadása.",
+ "apihelp-query+templates-param-limit": "A visszaadandó sablonok száma.",
+ "apihelp-query+templates-param-templates": "Csak ezen sablonok listázása. Annak ellenőrzésére alkalmas, hogy egy lap beilleszt-e egy adott sablont.",
+ "apihelp-query+templates-param-dir": "A listázás iránya.",
+ "apihelp-query+templates-example-simple": "A <kbd>Main Page</kbd> lapon használt sablonok lekérése.",
+ "apihelp-query+templates-example-generator": "Információk lekérése a <kbd>Main Page</kbd> lapon használt sablonlapokról.",
+ "apihelp-query+templates-example-namespaces": "A <kbd>Main Page</kbd> lapon használt {{ns:user}} és {{ns:template}} névtérbeli sablonok lekérése.",
+ "apihelp-query+tokens-summary": "Tokenek lekérése adatmódosító műveletekhez.",
+ "apihelp-query+tokens-param-type": "Lekérendő tokentípusok.",
+ "apihelp-query+tokens-example-simple": "Egy csrf token lekérése (alapértelmezett).",
+ "apihelp-query+tokens-example-types": "Egy watch és egy patrol token lekérése.",
+ "apihelp-query+transcludedin-summary": "A megadott lapokat beillesztő lapok lekérése.",
+ "apihelp-query+transcludedin-param-prop": "Lekérendő tulajdonságok:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "A lapok lapazonosítói.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "A lapok címei.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Az átirányítások megjelölése.",
+ "apihelp-query+transcludedin-param-namespace": "Lapok listázása csak ezekben a névterekben.",
+ "apihelp-query+transcludedin-param-limit": "A visszaadandó lapok száma.",
+ "apihelp-query+transcludedin-param-show": "Szűrés az átirányítások alapján:\n;redirect: Csak átirányítások visszaadása.\n;!redirect: Átirányítások elrejtése.",
+ "apihelp-query+transcludedin-example-simple": "A <kbd>Main Page</kbd> lapot beillesztő lapok listájának lekérése.",
+ "apihelp-query+transcludedin-example-generator": "Információk lekérése a <kbd>Main Page</kbd> lapot beillesztő lapokról.",
+ "apihelp-query+usercontribs-summary": "Egy felhasználó összes szerkesztésének lekérése.",
+ "apihelp-query+usercontribs-param-limit": "A visszaadott szerkesztések maximális száma.",
+ "apihelp-query+usercontribs-param-start": "Visszaadás ettől az időbélyegtől.",
+ "apihelp-query+usercontribs-param-end": "Visszaadás eddig az időbélyegig.",
+ "apihelp-query+usercontribs-param-namespace": "Szerkesztések listázása csak ezekben a névterekben.",
+ "apihelp-query+usercontribs-param-prop": "További információk visszaadása:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "A lap- és lapváltozat-azonosító.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "A lap címe és névterének azonosítója.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "A szerkesztés időbélyege.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "A szerkesztési összefoglaló.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "A szerkesztett lap új mérete.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Az ellenőrzött szerkesztések megjelölése (patrol).",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "A szerkesztés címkéi.",
+ "apihelp-query+usercontribs-param-show": "Csak a kritériumoknak megfelelő szerkesztések mutatása, pl. csak nem apró szerkesztések: <kbd>$2show=!minor</kbd>.\n\nHa az <kbd>$2show=patrolled</kbd> vagy <kbd>$2show=!patrolled</kbd> meg van adva, a <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var>-nél ($1 másodperc) régebbi szerkesztések nem jelennek meg.",
+ "apihelp-query+usercontribs-param-tag": "Csak ezzel a címkével ellátott változatok listázása.",
+ "apihelp-query+usercontribs-param-toponly": "Csak a legfrissebbnek számító szerkesztések visszaadása.",
+ "apihelp-query+usercontribs-example-user": "<kbd>Example</kbd> szerkesztéseinek megjelenítése.",
+ "apihelp-query+usercontribs-example-ipprefix": "<kbd>192.0.2.</kbd> kezdetű IP-címek szerkesztéseinek megjelenítése.",
+ "apihelp-query+userinfo-summary": "Információk lekérése az aktuális felhasználóról.",
+ "apihelp-query+userinfo-param-prop": "Visszaadandó információk:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Blokkolva van-e az aktuális felhasználó, és ha igen, akkor ki és miért blokkolta.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "A jelenlegi felhasználó összes csoportjának listája.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "A jelenlegi felhasználó explicit csoportjainak listája, az egyes csoporttagságok lejárati idejével.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Azoknak a csoportoknak a listája, amiknek a jelenlegi felhasználó automatikusan tagja.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "A jelenlegi felhasználó jogosultságainak listája.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "A jelenlegi felhasználó által hozzáadható és eltávolítható csoportok listája.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "A jelenlegi felhasználó beállításai.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "A jelenlegi felhasználó beállításainak megváltoztatásához szükséges token lekérése.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "A jelenlegi felhasználó szerkesztésszáma.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "A jelenlegi felhasználóra érvényes sebességkorlátozások.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "A felhasználó valódi neve.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "A felhasználó e-mail-címe és megerősítésének dátuma.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "A felhasználó regisztrációjának dátuma.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "A felhasználó figyelőlistáján levő olvasatlan lapok száma (legfeljebb $1; <samp>$2</samp> értéket ad vissza, ha több).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "A felhasználó központi azonosítói és az összekapcsolási státusza.",
+ "apihelp-query+userinfo-param-attachedwiki": "A felhasználó össze van-e kapcsolva az ezen azonosítójú wikivel, az <kbd>$1prop=centralids</kbd> paraméterrel együtt használandó.",
+ "apihelp-query+userinfo-example-simple": "Információk lekérése az aktuális felhasználóról.",
+ "apihelp-query+userinfo-example-data": "További információk lekérése az aktuális felhasználóról.",
+ "apihelp-query+users-summary": "Információk lekérése felhasználók listájáról.",
+ "apihelp-query+users-param-prop": "Visszaadandó információk:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Blokkolva van-e a felhasználó, és ha igen, akkor ki és miért blokkolta.",
+ "apihelp-query+users-paramvalue-prop-groups": "A felhasználó összes csoportjának listája.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "A felhasználó explicit csoportjainak listája, az egyes csoporttagságok lejárati idejével.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Azoknak a csoportoknak a listája, amiknek a felhasználó automatikusan tagja.",
+ "apihelp-query+users-paramvalue-prop-rights": "A felhasználó jogosultságainak listája.",
+ "apihelp-query+users-paramvalue-prop-editcount": "A felhasználó szerkesztésszáma.",
+ "apihelp-query+users-paramvalue-prop-registration": "A felhasználó regisztrációjának időbélyege.",
+ "apihelp-query+users-paramvalue-prop-emailable": "A felhasználó szeretne-e e-mailt fogadni a [[Special:Emailuser]] lapon keresztül.",
+ "apihelp-query+users-paramvalue-prop-gender": "A felhasználó neme („male”, „female” vagy „unknown”).",
+ "apihelp-query+users-paramvalue-prop-centralids": "A felhasználó központi azonosítói és az összekapcsolási státusza.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Létrehozható-e fiók az érvényes, de nem regisztrált felhasználóneveken.",
+ "apihelp-query+users-param-attachedwiki": "A felhasználó össze van-e kapcsolva az ezen azonosítójú wikivel, az <kbd>$1prop=centralids</kbd> paraméterrel együtt használandó.",
+ "apihelp-query+users-param-users": "A lekérendő felhasználók listája.",
+ "apihelp-query+users-param-userids": "A lekérendő felhasználók azonosítóinak listája.",
+ "apihelp-query+users-param-token": "Használd a <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> lekérdezést helyette.",
+ "apihelp-query+users-example-simple": "Információk lekérése <kbd>Example</kbd> felhasználóról.",
+ "apihelp-query+watchlist-summary": "A felhasználó figyelőlistáján szereplő lapok friss változtatásainak lekérése.",
+ "apihelp-query+watchlist-param-allrev": "Egy lap összes változtatásának lekérése a megadott időszakból.",
+ "apihelp-query+watchlist-param-start": "Listázás ettől az időbélyegtől.",
+ "apihelp-query+watchlist-param-end": "Listázás eddig az időbélyegig.",
+ "apihelp-query+watchlist-param-namespace": "A változtatások szűrése ezekre a névterekre.",
+ "apihelp-query+watchlist-param-user": "Csak ezen felhasználó szerkesztéseinek listázása.",
+ "apihelp-query+watchlist-param-excludeuser": "Ezen felhasználó szerkesztéseinek kihagyása.",
+ "apihelp-query+watchlist-param-limit": "A kérésenként visszaadandó eredmények száma.",
+ "apihelp-query+watchlist-param-prop": "További lekérendő tulajdonságok:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Lapváltozat- és lapazonosítók.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "A lap címe.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "A szerkesztést végrehajtó felhasználó.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "A szerkesztést végrehajtó felhasználó azonosítója.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "A szerkesztési összefoglaló.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "A szerkesztés időbélyege.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Az ellenőrzött szerkesztések megjelölése (patrol).",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "A lap régi és új hossza.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Annak időbélyege, amikor a felhasználó utoljára értesítést kapott a szerkesztésről.",
+ "apihelp-query+watchlist-param-show": "Csak a kritériumoknak megfelelő elemek visszaadása. Például csak bejelentkezett felhasználók apró változtatásainak megtekintéséhez használd az <kbd>$1show=minor|!anon</kbd> értéket.",
+ "apihelp-query+watchlist-param-type": "A megjelenítendő változtatások típusai:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Normál lapszerkesztések.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Külső változtatások.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Laplétrehozások.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Naplóbejegyzések.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Kategóriaváltoztatások.",
+ "apihelp-query+watchlist-param-owner": "A <var>$1token</var> paraméterrel együtt használandó egy másik felhasználó figyelőlistájának elérésére.",
+ "apihelp-query+watchlist-param-token": "Egy biztonsági token (elérhető a felhasználó [[Special:Preferences#mw-prefsection-watchlist|beállításaiban]]) egy másik felhasználó figyelőlistájának eléréséhez.",
+ "apihelp-query+watchlist-example-simple": "A jelenlegi felhasználó figyelőlistáján szereplő nemrég módosított lapok legfrissebb változatának listázása.",
+ "apihelp-query+watchlist-example-props": "További információk lekérése a jelenlegi felhasználó figyelőlistáján szereplő nemrég módosított lapok legfrissebb változatáról.",
+ "apihelp-query+watchlist-example-allrev": "További információk lekérése a jelenlegi felhasználó figyelőlistáján szereplő összes friss változtatásról.",
+ "apihelp-query+watchlist-example-generator": "Lapinformációk lekérése a jelenlegi felhasználó figyelőlistáján szereplő nemrég módosított lapokról.",
+ "apihelp-query+watchlist-example-generator-rev": "Lapváltozat-információk lekérése a jelenlegi felhasználó figyelőlistáján szereplő friss változtatásokról.",
+ "apihelp-query+watchlist-example-wlowner": "<kbd>Exapmle</kbd> felhasználó figyelőlistáján szereplő nemrég módosított lapok legfrissebb változatának listázása.",
+ "apihelp-query+watchlistraw-summary": "A jelenlegi felhasználó figyelőlistáján szereplő összes lap lekérése.",
+ "apihelp-query+watchlistraw-param-namespace": "Lapok listázása csak ezekben a névterekben.",
+ "apihelp-query+watchlistraw-param-limit": "A kérésenként visszaadandó eredmények száma.",
+ "apihelp-query+watchlistraw-param-prop": "További lekérendő tulajdonságok:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Annak időbélyege, amikor a felhasználó utoljára értesítést kapott a szerkesztésről.",
+ "apihelp-query+watchlistraw-param-show": "Csak a kritériumoknak megfelelő elemek listázása.",
+ "apihelp-query+watchlistraw-param-owner": "A <var>$1token</var> paraméterrel együtt használandó egy másik felhasználó figyelőlistájának elérésére.",
+ "apihelp-query+watchlistraw-param-token": "Egy biztonsági token (elérhető a felhasználó [[Special:Preferences#mw-prefsection-watchlist|beállításaiban]]) egy másik felhasználó figyelőlistájának eléréséhez.",
+ "apihelp-query+watchlistraw-param-dir": "A listázás iránya.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Listázás ettől a címtől (névtérelőtaggal).",
+ "apihelp-query+watchlistraw-param-totitle": "Listázás eddig a címig (névtérelőtaggal).",
+ "apihelp-query+watchlistraw-example-simple": "A jelenlegi felhasználó figyelőlistáján szereplő lapok lekérése.",
+ "apihelp-query+watchlistraw-example-generator": "Lapinformációk lekérése a jelenlegi felhasználó figyelőlistáján szereplő lapokról.",
+ "apihelp-removeauthenticationdata-summary": "A jelenlegi felhasználó hitelesítési adatainak eltávolítása.",
+ "apihelp-removeauthenticationdata-example-simple": "Kísérlet a jelenlegi felhasználó <kbd>FooAuthenticationRequest</kbd> kéréshez kapcsolódó adatainak eltávolítására.",
+ "apihelp-resetpassword-summary": "Jelszó-visszaállító e-mail küldése a felhasználónak.",
+ "apihelp-resetpassword-extended-description-noroutes": "Nem érhetők el jelszó-visszaállítási módok.\n\nEngedélyezz néhány módot a <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var> PHP-változóval a modul használatához.",
+ "apihelp-resetpassword-param-user": "A visszaállítandó felhasználó.",
+ "apihelp-resetpassword-param-email": "A visszaállítandó felhasználó e-mail-címe.",
+ "apihelp-resetpassword-example-user": "Jelszó-visszaállító e-mail küldése <kbd>Example</kbd> felhasználónak.",
+ "apihelp-resetpassword-example-email": "Jelszó-visszaállító e-mail küldése az összes <kbd>user@example.com</kbd> e-mail-című felhasználónak.",
+ "apihelp-revisiondelete-summary": "Változatok törlése és helyreállítása.",
+ "apihelp-revisiondelete-param-ids": "A törlendő lapváltozatok azonosítói.",
+ "apihelp-revisiondelete-param-reason": "A törlés vagy helyreállítás indoklása.",
+ "apihelp-revisiondelete-example-revision": "A <kbd>12345</kbd> lapváltozat tartalmának elrejtése a <kbd>Main Page</kbd> lapon.",
+ "apihelp-revisiondelete-example-log": "A <kbd>67890</kbd> naplóbejegyzés összes adatának elrejtése <kbd>BLP violation</kbd> indoklással.",
+ "apihelp-rollback-summary": "A lap legutóbbi változtatásának visszavonása.",
+ "apihelp-rollback-extended-description": "Ha a lap utolsó szerkesztője egymás után több szerkesztést végzett, az összes visszavonása.",
+ "apihelp-rollback-param-title": "A visszaállítandó lap címe. Nem használható együtt a <var>$1pageid</var> paraméterrel.",
+ "apihelp-rollback-param-pageid": "A visszaállítandó lap lapazonosítója. Nem használható együtt a <var>$1title</var> paraméterrel.",
+ "apihelp-rollback-param-summary": "Egyéni szerkesztési összefoglaló. Ha üres, az alapértelmezett összefoglaló lesz használatban.",
+ "apihelp-rollback-param-markbot": "A visszavont és a visszavonó szerkesztések botszerkesztésnek jelölése.",
+ "apihelp-rollback-param-watchlist": "A lap hozzáadása a figyelőlistához vagy eltávolítása onnan feltétel nélkül, a beállítások használata vagy a figyelőlista érintetlenül hagyása.",
+ "apihelp-rsd-summary": "Egy RSD-séma (Really Simple Discovery) exportálása.",
+ "apihelp-rsd-example-simple": "Az RSD-séma exportálása.",
+ "apihelp-setnotificationtimestamp-summary": "A figyelt lapok értesítési időbélyegének frissítése.",
+ "apihelp-setnotificationtimestamp-extended-description": "Ez érinti a módosított lapok kiemelését a figyelőlistán és a laptörténetekben, valamint az e-mail-küldést a „{{int:tog-enotifwatchlistpages}}” beállítás engedélyezése esetén.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Dolgozás az összes figyelt lapon.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "Az értesítési időbélyeg állítása erre az időbélyegre.",
+ "apihelp-setnotificationtimestamp-param-torevid": "Az értesítési időbélyeg állítása erre a lapváltozatra (csak egy lap esetén).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "Az értesítési időbélyeg állítása ennél a lapváltozatnál újabbra (csak egy lap esetén).",
+ "apihelp-setnotificationtimestamp-example-all": "Az értesítési állapot visszaállítása a teljes figyelőlistára.",
+ "apihelp-setnotificationtimestamp-example-page": "A <kbd>Main page</kbd> értesítési állapotának visszaállítása.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "A <kbd>Main page</kbd> értesítési időbélyegének módosítása, hogy a 2012. január 1-jét követő szerkesztések nem megtekintettek legyenek.",
+ "apihelp-setnotificationtimestamp-example-allpages": "A <kbd>{{ns:user}}</kbd> névtérbeli lapok értesítési állapotának visszaállítása.",
+ "apihelp-setpagelanguage-summary": "Egy lap nyelvének módosítása.",
+ "apihelp-setpagelanguage-extended-description-disabled": "A lapnyelv módosítása nem engedélyezett ezen a wikin.\n\nEngedélyezd a <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> PHP-változót ezen művelet használatához.",
+ "apihelp-setpagelanguage-param-title": "A módosítandó lap címe. Nem használható együtt a <var>$1pageid</var> paraméterrel.",
+ "apihelp-setpagelanguage-param-pageid": "A módosítandó lap azonosítója. Nem használható együtt a <var>$1title</var> paraméterrel.",
+ "apihelp-setpagelanguage-param-lang": "A lap nyelvének módosítása erre a nyelvkódra. Használd a <kbd>default</kbd> értéket a wiki alapértelmezett tartalomnyelvére való visszaállításhoz.",
+ "apihelp-setpagelanguage-param-reason": "A módosítás oka.",
+ "apihelp-setpagelanguage-example-language": "A <kbd>Main Page</kbd> nyelvének módosítása baszkra.",
+ "apihelp-setpagelanguage-example-default": "A 123 azonosítójú lap nyelvének módosítása a wiki alapértelmezett tartalomnyelvére.",
+ "apihelp-stashedit-summary": "Egy szerkesztés előkészítése a megosztott gyorsítótárban.",
+ "apihelp-stashedit-extended-description": "Ez a modul AJAX segítségével, a szerkesztőűrlapról történő használatra készült a lapmentés teljesítményének javítására.",
+ "apihelp-stashedit-param-title": "A szerkesztett lap címe.",
+ "apihelp-stashedit-param-section": "A szerkesztett szakasz száma. <kbd>0</kbd> a bevezetőhöz, <kbd>new</kbd> új szakaszhoz.",
+ "apihelp-stashedit-param-sectiontitle": "Az új szakasz címe.",
+ "apihelp-stashedit-param-text": "A lap tartalma.",
+ "apihelp-stashedit-param-contentmodel": "Az új tartalom tartalommodellje.",
+ "apihelp-stashedit-param-baserevid": "Az alapváltozat változatazonosítója.",
+ "apihelp-stashedit-param-summary": "Szerkesztési összefoglaló.",
+ "apihelp-userrights-param-userid": "Felhasználói azonosító.",
+ "api-help-title": "MediaWiki API súgó",
+ "api-help-lead": "Ez egy automatikusan generált MediaWiki API-dokumentációs lap.\n\nDokumentáció és példák: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Fő modul",
+ "api-help-flag-deprecated": "Ez a modul elavult.",
+ "api-help-flag-internal": "<strong>Ez a modul belső használatú vagy nem stabil.</strong> A működése értesítés nélkül változhat.",
+ "api-help-flag-readrights": "Ez a modul olvasási jogot igényel.",
+ "api-help-flag-writerights": "Ez a modul írási jogot igényel.",
+ "api-help-flag-mustbeposted": "Ez a modul csak POST kéréseket fogad el.",
+ "api-help-flag-generator": "Ez a modul használható generátorként.",
+ "api-help-source": "Forrás: $1",
+ "api-help-source-unknown": "Forrás: <span class=\"apihelp-unknown\">ismeretlen</span>",
+ "api-help-license": "Licenc: [[$1|$2]]",
+ "api-help-license-noname": "Licenc: [[$1|Lásd a linken]]",
+ "api-help-license-unknown": "Licenc: <span class=\"apihelp-unknown\">ismeretlen</span>",
+ "api-help-parameters": "{{PLURAL:$1|Paraméter|Paraméterek}}:",
+ "api-help-param-deprecated": "Elavult.",
+ "api-help-param-required": "Ez a paraméter kötelező.",
+ "api-help-datatypes-header": "Adattípusok",
+ "api-help-param-type-limit": "Típus: egész vagy <kbd>max</kbd>",
+ "api-help-param-type-integer": "Típus: {{PLURAL:$1|1=egész|2=egészek listája}}",
+ "api-help-param-type-boolean": "Típus: logikai ([[Special:ApiHelp/main#main/datatypes|részletek]])",
+ "api-help-param-type-timestamp": "Típus: {{PLURAL:$1|1=időbélyeg|2=időbélyegek listája}} ([[Special:ApiHelp/main#main/datatypes|engedélyezett formátumok]])",
+ "api-help-param-type-user": "Típus: {{PLURAL:$1|1=felhasználónév|2=felhasználónevek listája}}",
+ "api-help-param-list": "{{PLURAL:$1|1=A következő értékek egyike|2=Értékek (elválasztó: <kbd>{{!}}</kbd> vagy [[Special:ApiHelp/main#main/datatypes|más]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Üresnek kell lennie|Lehet üres vagy $2}}",
+ "api-help-param-limit": "Nem engedélyezett több mint $1.",
+ "api-help-param-limit2": "Nem engedélyezett több mint $1 (botoknak $2).",
+ "api-help-param-integer-min": "Az {{PLURAL:$1|1=érték nem lehet kisebb|2=értékek nem lehetnek kisebbek}} mint $2.",
+ "api-help-param-integer-max": "Az {{PLURAL:$1|1=érték nem lehet nagyobb|2=értékek nem lehetnek nagyobbak}} mint $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Az értéknek $2 és $3 között kell lennie.|2=Az értékeknek $2 és $3 között kell lenniük.}}",
+ "api-help-param-default": "Alapértelmezett: $1",
+ "apierror-timeout": "A kiszolgáló nem adott választ a várt időn belül."
+}
diff --git a/www/wiki/includes/api/i18n/ia.json b/www/wiki/includes/api/i18n/ia.json
new file mode 100644
index 00000000..462726ae
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ia.json
@@ -0,0 +1,63 @@
+{
+ "@metadata": {
+ "authors": [
+ "McDutchie",
+ "Rafaneta"
+ ]
+ },
+ "apihelp-main-summary": "",
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Documentation]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Listas de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annuncios sur le API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & demandas]\n</div>\n<strong>Stato:</strong> Tote le functiones monstrate in iste pagina deberea functionar, sed le API es ancora in disveloppamento active e pote cambiar a omne momento. Subscribe te al [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ lista de diffusion mediawiki-api-announce] pro esser informate de actualisationes.\n\n<strong>Requestas erronee:</strong> Quando requestas erronee se invia al API, un capite HTTP essera inviate con le clave \"MediaWiki-API-Error\". Le valor de iste capite e le codice de error reinviate essera identic. Pro plus information vide [[mw:API:Errors_and_warnings|API: Errores e avisos]].\n\n<strong>Tests:</strong> Pro facilitar le test de requestas API, vide [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Qual action exequer.",
+ "apihelp-main-param-format": "Le formato del resultato.",
+ "apihelp-main-param-maxlag": "Le latentia maximal pote esser usate quando MediaWiki es installate in un cluster de base de datos replicate. Pro evitar actiones que causa additional latentia de replication de sito, iste parametro pote facer le cliente attender usque le latentia de replication es minus que le valor specificate. In caso de latentia excessive, le codice de error <samp>maxlag</samp> es retornate con un message como <samp>Attende $host: $lag secundas de latentia</samp>.<br />Vide [[mw:Manual:Maxlag_parameter|Manual: Maxlag parameter]] pro plus information.",
+ "apihelp-main-param-smaxage": "Fixar le capite de controlo de cache HTTP <code>s-maxage</code> a iste numero de secundas. Errores nunquam es mittite in cache.",
+ "apihelp-main-param-maxage": "Fixar le capite de controlo de cache HTTP <code>max-age</code> a iste numero de secundas. Errores nunquam es mittite in cache.",
+ "apihelp-main-param-assert": "Verificar si le usator ha aperite session si mittite a <kbd>user</kbd>, o si ha le derecto de usator robot si <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Verificar que le usator currente es le usator nominate.",
+ "apihelp-main-param-requestid": "Omne valor fornite hic essera includite in le responsa. Pote esser usate pro distinguer requestas.",
+ "apihelp-main-param-servedby": "Includer in le resultato le nomine del host que ha servite le requesta.",
+ "apihelp-main-param-curtimestamp": "Includer le data e hora actual in le resultato.",
+ "apihelp-main-param-responselanginfo": "Includer le linguas usate pro <var>uselang</var> e <var>errorlang</var> in le resultato.",
+ "apihelp-main-param-origin": "Quando se accede al API usante un requesta AJAX inter-dominios (CORS), mitte le dominio de origine in iste parametro. Illo debe esser includite in omne requesta pre-flight, e dunque debe facer parte del URI del requesta (e non del corpore POST).\n\nPro requestas authenticate, isto debe corresponder exactemente a un del origines in le capite <code>Origin</code>, dunque debe esser mittite a qualcosa como <kbd>http://ia.wikipedia.org</kbd> o <kbd>https://meta.wikimedia.org</kbd>. Si iste parametro non corresponde al capite <code>Origin</code>, un responsa 403 essera retornate. Si iste parametro corresponde al capite <code>Origin</code> e le origine es in le lista blanc, le capites <code>Access-Control-Allow-Origin</code> e <code>Access-Control-Allow-Credentials</code> essera inserite.\n\nPro requestas non authenticate, specifica le valor <kbd>*</kbd>. Isto causara le insertion del capite <code>Access-Control-Allow-Origin</code>, ma <code>Access-Control-Allow-Credentials</code> essera mittite a <code>false</code> e tote le datos specific al usator essera restringite.",
+ "apihelp-main-param-uselang": "Lingua a usar pro traductiones de messages <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> con <kbd>siprop=languages</kbd> retorna un lista de codices de lingua, o specifica <kbd>user</kbd> pro usar le preferentia de lingua del usator actual, o specifica <kbd>content</kbd> pro usar le lingua de contento de iste wiki.",
+ "apihelp-block-summary": "Blocar un usator.",
+ "apihelp-block-param-user": "Nomine de usator, adresse IP o intervallo de adresses IP a blocar. Non pote esser usate insimul a <var>$1userid</var>",
+ "apihelp-block-param-expiry": "Tempore de expiration. Pote esser relative (p.ex. <kbd>5 months</kbd> o <kbd>2 weeks<.kbd>) o absolute (p.ex. <kbd>2014-09-18T12:34:56Z</kbd>). Si es mittite a <kbd>infinite</kbd>, <kbd>indefinite</kbd> o <kbd>never</kbd>, le blocada nunquam expirara.",
+ "apihelp-block-param-reason": "Motivo del blocada.",
+ "apihelp-block-param-anononly": "Blocar solmente usatores anonyme (i.e. disactivar modificationes anonyme pro iste adresse IP).",
+ "apihelp-block-param-nocreate": "Impedir le creation de contos.",
+ "apihelp-block-param-autoblock": "Blocar automaticamente le adresse IP usate le plus recentemente, e omne IPs successive desde le quales ille/-a tenta facer modificationes.",
+ "apihelp-block-param-noemail": "Impedir que le usator invia e-mail per le wiki. (Require le derecto <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Celar le nomine de usator in le registro de blocadas. (Require le derecto <code>hideuser</code>.)",
+ "apihelp-block-param-allowusertalk": "Permitter que le usator modifica su proprie pagina de discussion (depende de <var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Si le usator es jam blocate, superscriber le blocada existente.",
+ "apihelp-block-param-watchuser": "Observar le paginas de usator e discussion del usator o del adresse IP.",
+ "apihelp-block-example-ip-simple": "Blocar le adresse IP <kbd>192.0.2.5</kbd> pro tres dies con le motivo <kbd>Prime advertimento</kbd>.",
+ "apihelp-block-example-user-complex": "Blocar le usator <kbd>Vandalo</kbd> pro tempore indeterminate con le motivo <kbd>Vandalismo</kbd>, e impedir le creation de nove contos e le invio de e-mail.",
+ "apihelp-changeauthenticationdata-summary": "Cambiar le datos de authentication pro le usator actual.",
+ "apihelp-changeauthenticationdata-example-password": "Tentar de cambiar le contrasigno del usator actual a <kbd>ExemploDeContrasigno</kbd>.",
+ "apihelp-checktoken-summary": "Verificar le validitate de un indicio ab <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Typo de indicio a testar.",
+ "apihelp-checktoken-param-token": "Indicio a testar.",
+ "apihelp-checktoken-param-maxtokenage": "Etate maxime permittite pro le indicio, in secundas.",
+ "apihelp-checktoken-example-simple": "Testar le validitate de un indicio <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Cancella le signal <code>hasmsg</code> pro le usator actual.",
+ "apihelp-clearhasmsg-example-1": "Cancellar le signal <code>hasmsg</code> pro le usator actual.",
+ "apihelp-clientlogin-summary": "Aperir session in le wiki usante le fluxo interactive.",
+ "apihelp-clientlogin-example-login": "Comenciar le processo de aperir session in le wiki como le usator <kbd>Exemplo</kbd> con le contrasigno <kbd>ExemploDeContrasigno</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continuar a aperir session post un responsa <samp>UI</samp> pro authentication bifactorial, forniente un <var>OATHToken</var> de <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Obtener le differentia inter duo paginas.",
+ "apihelp-compare-extended-description": "Es necessari indicar un numero de version, un titulo de pagina o un ID de pagina, e pro \"from\" e pro \"to\".",
+ "apihelp-compare-param-fromtitle": "Prime titulo a comparar.",
+ "apihelp-compare-param-fromid": "Prime ID de pagina comparar.",
+ "apihelp-compare-param-fromrev": "Prime version a comparar.",
+ "apihelp-compare-param-totitle": "Secunde titulo a comparar.",
+ "apihelp-compare-param-toid": "Secunde ID de pagina a comparar.",
+ "apihelp-compare-param-torev": "Secunde version a comparar.",
+ "apihelp-compare-example-1": "Crear un diff inter version 1 e 2.",
+ "apihelp-createaccount-summary": "Crear un nove conto de usator.",
+ "apihelp-createaccount-param-name": "Nomine de usator.",
+ "apihelp-query+prefixsearch-param-profile": "Le profilo de recerca a usar.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Obtener le prime 5 versiones del <kbd>Pagina principal</kbd> que non ha essite facite per le usator anonyme <kbd>127.0.0.1</kbd>",
+ "api-credits": "Programmatores del API:\n* Roan Kattouw (programmator dirigente Sept. 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (creator, programmator dirigente Sept. 2006–Sept. 2007)\n* Brad Jorsch (programmator dirigente 2013–presente)\n\nInvia tu commentos, suggestiones e questiones a mediawiki-api@lists.wikimedia.org\no insere un reportage de bug a https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/id.json b/www/wiki/includes/api/i18n/id.json
new file mode 100644
index 00000000..b1d2f286
--- /dev/null
+++ b/www/wiki/includes/api/i18n/id.json
@@ -0,0 +1,105 @@
+{
+ "@metadata": {
+ "authors": [
+ "WongKentir",
+ "Beeyan",
+ "Rachmat.Wahidi",
+ "Kenrick95",
+ "Presidenvolksraad"
+ ]
+ },
+ "apihelp-main-param-action": "Tindakan manakah yang akan dilakukan.",
+ "apihelp-main-param-format": "Format keluaran.",
+ "apihelp-block-summary": "Blokir pengguna.",
+ "apihelp-block-param-user": "Nama pengguna, alamat IP, atau rentang alamat IP untuk diblokir.",
+ "apihelp-block-param-expiry": "Waktu kedaluwarsa. Dapat berupa waktu relatif (seperti <kbd>5 bulan</kbd> atau <kbd>2 minggu</kbd>) atau waktu absolut (seperti <kbd>2014-09-18T12:34:56Z</kbd>). Jika diatur ke <kbd>selamanya</kbd>, <kbd>tak terbatas</kbd>, atau <kbd>tidak pernah</kbd>, pemblokiran itu tidak akan berakhir.",
+ "apihelp-block-param-reason": "Alasan pemblokiran.",
+ "apihelp-block-param-anononly": "Blokir hanya pengguna anonim (seperti menonaktifkan suntingan anonim untuk alamat IP ini).",
+ "apihelp-block-param-nocreate": "Cegah pembuatan akun.",
+ "apihelp-block-param-autoblock": "Blokir alamat IP terakhir yang digunakan pengguna ini, dan semua alamat IP berikutnya yang mereka coba gunakan untuk menyunting.",
+ "apihelp-block-param-noemail": "Cegah pengguna mengirimkan surel melalui wiki. (Membutuhkan hak <code>blockemail</code>).",
+ "apihelp-block-param-reblock": "Jika pengguna tersebut sudah diblokir, atur ulang setelah pemblokirannya.",
+ "apihelp-block-example-ip-simple": "Blokir alamat IP <kbd>192.0.2.5</kbd> selama tiga hari dengan alasan <kbd>Serangan pertama</kbd>.",
+ "apihelp-compare-param-fromtitle": "Judul pertama untuk dibandingkan.",
+ "apihelp-compare-param-fromid": "ID halaman pertama untuk dibandingkan.",
+ "apihelp-compare-param-fromrev": "Revisi pertama untuk dibandingkan.",
+ "apihelp-compare-param-toid": "ID halaman kedua untuk dibandingkan.",
+ "apihelp-compare-param-torev": "Revisi kedua untuk dibandingkan.",
+ "apihelp-compare-example-1": "Buat perbedaan antara revisi 1 dan 2.",
+ "apihelp-createaccount-summary": "Buat akun pengguna baru.",
+ "apihelp-createaccount-example-create": "Mulai proses pembuatan pengguna <kbd>Contoh</kbd> dengan kata sandi <kbd>ContohKataSandi</kbd>.",
+ "apihelp-createaccount-param-name": "Nama pengguna",
+ "apihelp-createaccount-param-password": "Kata sandi (diabaikan jika <var>$1mailpassword</var> diatur).",
+ "apihelp-createaccount-param-domain": "Domain untuk otentikasi eksternal (opsional).",
+ "apihelp-createaccount-param-token": "Token pembuatan akun yang diperoleh pada permintaan pertama.",
+ "apihelp-createaccount-param-email": "Alamat surel pengguna (opsional).",
+ "apihelp-createaccount-param-realname": "Nama asli pengguna (opsional).",
+ "apihelp-createaccount-param-mailpassword": "Jika diberikan nilai, kata sandi acak akan dikirimkan melalui surel kepada pengguna.",
+ "apihelp-createaccount-param-reason": "Alasan tambahan untuk membuat akun yang akan dicatat dalam log.",
+ "apihelp-createaccount-param-language": "Kode bahasa untuk diatur sebagai baku kepada pengguna (opsional, nilai bakunya adalah bahasa isi).",
+ "apihelp-createaccount-example-pass": "Buat pengguna <kbd>testuser</kbd> dengan kata sandi <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Buat pengguna <kbd>testmailuser</kbd> dan kirim surel berisi kata sandi acak.",
+ "apihelp-delete-summary": "Hapus halaman",
+ "apihelp-delete-param-title": "Judul halaman untuk dihapus. Tidak dapat digunakan bersama dengan <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID halaman dari halaman yang akan dihapus. Tidak dapat digunakan bersama dengan <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Alasan penghapusan. Jika tidak diberikan, alasan yang dihasilkan secara otomatis akan digunakan.",
+ "apihelp-delete-param-tags": "Ganti tag untuk diterapkan ke entri di log penghapusan.",
+ "apihelp-delete-param-watch": "Tambahkan halaman ke daftar pantauan pengguna saat ini.",
+ "apihelp-delete-param-watchlist": "Buat atau hapus halaman tanpa syarat dari daftar pantauan pengguna saat ini, gunakan preferensi atau jangan ganti pantauan.",
+ "apihelp-delete-param-unwatch": "Hapus halaman dari daftar pantauan pengguna saat ini.",
+ "apihelp-delete-param-oldimage": "Nama gambar lama untuk dihapus seperti yang disebutkan oleh [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Hapus <kbd>Halaman Utama</kbd>.",
+ "apihelp-delete-example-reason": "Hapus <kbd>Halaman Utama</kbd> dengan alasan <kbd>Persiapan untuk dialihkan</kbd>.",
+ "apihelp-disabled-summary": "Modul ini telah dimatikan.",
+ "apihelp-edit-summary": "Buat dan sunting halaman.",
+ "apihelp-edit-param-title": "Judul halaman untuk dibuat. Tidak dapat digunakan bersama dengan <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID halaman dari halaman yang akan disunting. Tidak dapat digunakan bersama dengan <var>$1title</var>.",
+ "apihelp-edit-param-section": "Nomor bagian. <kbd>0</kbd> untuk bagian atas, <kbd>baru</kbd> untuk bagian baru.",
+ "apihelp-edit-param-sectiontitle": "Judul untuk bagian baru.",
+ "apihelp-edit-param-text": "Isi halaman.",
+ "apihelp-edit-param-summary": "Ringkasan suntingan. Juga tajuk bagian ketika $1section=new dan $1sectiontitle tidak diatur.",
+ "apihelp-edit-param-tags": "Ganti tag untuk menerapkan ke revisi.",
+ "apihelp-edit-param-minor": "Suntingan kecil.",
+ "apihelp-edit-param-notminor": "Bukan suntingan kecil.",
+ "apihelp-edit-param-bot": "Tandai suntingan ini sebagai suntingan bot.",
+ "apihelp-edit-param-basetimestamp": "Stempel waktu dari revisi asal, digunakan untuk mendeteksi konflik penyuntingan. Dapat ditemukan di [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Stempel waktu ketika proses penyuntingan dimulai, digunakan untuk mendeteksi konflik penyuntingan. Nilai yang cocok dapat ditemukan dengan menggunakan <var>[[Special:ApiHelp/main|curtimestamp]]</var> ketika memulai proses penyuntingan (seperti ketika memuat isi konten yang akan disunting).",
+ "apihelp-edit-param-recreate": "Batalkan galat yang terjadi tentang halaman yang sudah dihapus pada saat itu.",
+ "apihelp-edit-param-createonly": "Jangan sunting halaman itu jika sudah ada.",
+ "apihelp-edit-param-nocreate": "Berikan galat jika halaman belum ada.",
+ "apihelp-edit-param-watch": "Tambahkan halaman ke daftar pantauan pengguna saat ini.",
+ "apihelp-edit-param-unwatch": "Hapus halaman dari daftar pantauan pengguna saat ini.",
+ "apihelp-edit-param-watchlist": "Buat atau hapus halaman tanpa syarat dari daftar pantauan pengguna saat ini, gunakan preferensi atau jangan ganti pantauan.",
+ "apihelp-edit-param-md5": "Hash MD5 dari parameter $1text, atau parameter $1prependtext dan $1appendtext digabungkan. Jika diatur, suntingan itu tidak akan dilakukan kecuali hash tidak benar.",
+ "apihelp-edit-param-prependtext": "Tambahkan teks berikut ke bagian awal halaman. Abaikan $1text.",
+ "apihelp-edit-param-appendtext": "Tambahkan teks berikut ke bagian akhir halaman. Abaikan $1text.\n\nGunakan $1section=new untuk menambahkan sebuah bagian baru, daripada parameter ini.",
+ "apihelp-edit-param-undo": "Batalkan revisi ini. Abaikan $1text, $1prependtext dan $1appendtext.",
+ "apihelp-edit-param-undoafter": "Batalkan semua revisi dari $1undo ke revisi ini. Jika tidak diatur, batalkan satu revisi saja.",
+ "apihelp-edit-param-redirect": "Selesaikan pengalihan secara otomatis.",
+ "apihelp-edit-param-contentformat": "Format serialisasi isi digunakan untuk teks masukan.",
+ "apihelp-edit-param-contentmodel": "Model konten dari konten baru.",
+ "apihelp-edit-param-token": "Token harus selalu dikirim sebagai parameter terakhir, atau setidaknya sesudah parameter $1text.",
+ "apihelp-edit-example-edit": "Sunting halaman.",
+ "apihelp-edit-example-prepend": "Tambahkan <kbd>_&#95;NOTOC_&#95;</kbd> ke halaman.",
+ "apihelp-edit-example-undo": "Batalkan revisi 13579 melalui 13585 dengan ringkasan otomatis.",
+ "apihelp-emailuser-summary": "Kirim surel ke pengguna ini.",
+ "apihelp-emailuser-param-target": "Pengguna yang akan dikirimi surel.",
+ "apihelp-emailuser-param-subject": "Tajuk subjek.",
+ "apihelp-emailuser-param-text": "Badan pesan.",
+ "apihelp-emailuser-param-ccme": "Kirimkan salinan pesan ini kepada saya.",
+ "apihelp-expandtemplates-summary": "Longgarkan semua templat dalam teks wiki.",
+ "apihelp-expandtemplates-param-title": "Judul halaman.",
+ "apihelp-expandtemplates-param-text": "Teks wiki yang akan diubah.",
+ "apihelp-expandtemplates-param-revid": "ID revisi, untuk <nowiki>{{REVISIONID}}</nowiki> dan variabel serupa.",
+ "apihelp-expandtemplates-param-prop": "Bagian informasi manakah yang ingin didapatkan.\n\nPerhatikan bahwa jika tidak ada nilai yang dipilih, hasilnya akan mengandung teks wiki, namun keluaran akan berupa format usang.",
+ "apihelp-feedcontributions-param-deletedonly": "Tampilkan hanya kontribusi terhapus.",
+ "apihelp-login-example-login": "Masuk log.",
+ "apihelp-move-param-noredirect": "Jangan buat pengalihan.",
+ "apihelp-move-param-unwatch": "Hapus halaman dan pengalihan dari daftar pantauan pengguna ini.",
+ "apihelp-move-example-move": "Pindahkan <kbd>Judul buruk</kbd> ke <kbd>Judul benar</kbd> tanpa membuat pengalihan.",
+ "apihelp-opensearch-param-redirects": "Bagaimana menangani pengalihan:\n;return:Kembali ke pengalihan itu.\n;resolve:Kembali ke halaman tujuan. Mungkin hasil kembali kurang dari $1limit.\nUntuk alasan riwayat, nilai baku adalah \"kembali\" untuk $1format=json dan \"resolve\" untuk format lain.",
+ "apihelp-query+prefixsearch-param-profile": "Cari profil untuk digunakan.",
+ "apihelp-query+search-param-qiprofile": "Meminta profil independen untuk digunakan (berefek pada algoritma peringkat).",
+ "apihelp-revisiondelete-param-ids": "Penanda untuk perubahan yang akan dihapus",
+ "api-format-prettyprint-status": "Tanggapan ini akan dikembalikan dengan status $1 $2 HTTP."
+}
diff --git a/www/wiki/includes/api/i18n/is.json b/www/wiki/includes/api/i18n/is.json
new file mode 100644
index 00000000..956ace83
--- /dev/null
+++ b/www/wiki/includes/api/i18n/is.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sveinn í Felli"
+ ]
+ },
+ "api-help-license": "Notkunarleyfi: [[$1|$2]]",
+ "api-help-license-noname": "Notkunarleyfi: [[$1|Sjá tengil]]",
+ "api-help-license-unknown": "Notkunarleyfi: <span class=\"apihelp-unknown\">óþekkt</span>"
+}
diff --git a/www/wiki/includes/api/i18n/it.json b/www/wiki/includes/api/i18n/it.json
new file mode 100644
index 00000000..fd88c186
--- /dev/null
+++ b/www/wiki/includes/api/i18n/it.json
@@ -0,0 +1,693 @@
+{
+ "@metadata": {
+ "authors": [
+ "Beta16",
+ "Nivit",
+ "Toadino2",
+ "Gianfranco",
+ "Alexmar983",
+ "Ricordisamoa",
+ "Valepert",
+ "Sannita",
+ "Macofe",
+ "Nemo bis",
+ "JackLantern",
+ "Urielejh",
+ "Matteocng",
+ "Einreiher",
+ "Mpiva",
+ "Margherita.mignanelli"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentazione]] (in inglese)\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]] (in inglese)\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annunci sull'API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bug & richieste]\n</div>\n<strong>Stato:</strong> tutte le funzioni e caratteristiche mostrate su questa pagina dovrebbero funzionare, ma le API sono ancora in fase attiva di sviluppo, e potrebbero cambiare in qualsiasi momento. Iscriviti alla [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la mailing list sugli annunci delle API MediaWiki] per essere informato sugli aggiornamenti.\n\n<strong>Istruzioni sbagliate:</strong> quando vengono impartite alle API delle istruzioni sbagliate, un'intestazione HTTP verrà inviata col messaggio \"MediaWiki-API-Error\" e, sia il valore dell'intestazione, sia il codice d'errore, verranno impostati con lo stesso valore. Per maggiori informazioni leggi [[mw:Special:MyLanguage/API:Errors_and_warnings|API:Errori ed avvertimenti]] (in inglese).\n\n<strong>Test:</strong> per testare facilmente le richieste API, vedi [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Azione da compiere.",
+ "apihelp-main-param-format": "Formato dell'output.",
+ "apihelp-main-param-assert": "Verifica che l'utente abbia effettuato l'accesso se si è impostato <kbd>user</kbd>, o che abbia i permessi di bot se si è impostato <kbd>bot</kbd>.",
+ "apihelp-main-param-requestid": "Tutti i valori forniti saranno implementati nella risposta. Potrebbero venir utilizzati per distinguere le richieste.",
+ "apihelp-main-param-servedby": "Includi nel risultato il nome dell'host che ha servito la richiesta.",
+ "apihelp-main-param-curtimestamp": "Includi nel risultato il timestamp attuale.",
+ "apihelp-block-summary": "Blocca un utente.",
+ "apihelp-block-param-user": "Nome utente, indirizzo IP o range di IP da bloccare. Non può essere usato insieme a <var>$1userid</var>",
+ "apihelp-block-param-expiry": "Tempo di scadenza. Può essere relativo (ad esempio, <kbd>5 months</kbd> o <kbd>2 weeks</kbd>) o assoluto (ad esempio <kbd>2014-09-18T12:34:56Z</kbd>). Se impostato a <kbd>infinite</kbd>, <kbd>indefinite</kbd> o <kbd>never</kbd>, il blocco non scadrà mai.",
+ "apihelp-block-param-reason": "Motivo del blocco.",
+ "apihelp-block-param-anononly": "Blocca solo gli utenti non registrati (cioè disattiva i contributi anonimi da questo indirizzo IP).",
+ "apihelp-block-param-nocreate": "Impedisci creazione di utenze.",
+ "apihelp-block-param-autoblock": "Blocca automaticamente l'ultimo indirizzo IP usato dall'utente e i successivi con cui viene tentato l'accesso.",
+ "apihelp-block-param-hidename": "Nascondi il nome utente dal registro dei blocchi (Richiede i permessi di <code>hideuser</code>).",
+ "apihelp-block-param-reblock": "Se l'utente è già bloccato, sovrascrivere il blocco esistente.",
+ "apihelp-block-param-watchuser": "Segui la pagina utente e le pagine di discussione utente dell'utente o dell'indirizzo IP.",
+ "apihelp-block-example-ip-simple": "Blocca l'indirizzo IP <kbd>192.0.2.5</kbd> per tre giorni con motivazione <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Blocca l'utente <kbd>Vandal</kbd> a tempo indeterminato con motivazione <kbd>Vandalism</kbd>, e impediscigli la creazione di nuovi account e l'invio di e-mail.",
+ "apihelp-changeauthenticationdata-summary": "Modificare i dati di autenticazione per l'utente corrente.",
+ "apihelp-changeauthenticationdata-example-password": "Tentativo di modificare la password dell'utente corrente a <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Verifica la validità di un token da <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Tipo di token in corso di test.",
+ "apihelp-checktoken-param-token": "Token da testare.",
+ "apihelp-checktoken-param-maxtokenage": "Massima età consentita per il token, in secondi.",
+ "apihelp-checktoken-example-simple": "Verifica la validità di un token <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Cancella il flag <code>hasmsg</code> per l'utente corrente.",
+ "apihelp-clearhasmsg-example-1": "Cancella il flag <code>hasmsg</code> per l'utente corrente.",
+ "apihelp-clientlogin-summary": "Accedi al wiki utilizzando il flusso interattivo.",
+ "apihelp-clientlogin-example-login": "Avvia il processo di accesso alla wiki come utente <kbd>Example</kbd> con password <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continua l'accesso dopo una risposta dell'<samp>UI</samp> per l'autenticazione a due fattori, fornendo un <var>OATHToken</var> di <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Ottieni le differenze tra 2 pagine.",
+ "apihelp-compare-extended-description": "Un numero di revisione, il titolo di una pagina, o un ID di pagina deve essere indicato sia per il \"da\" che per lo \"a\".",
+ "apihelp-compare-param-fromtitle": "Primo titolo da confrontare.",
+ "apihelp-compare-param-fromid": "Primo ID di pagina da confrontare.",
+ "apihelp-compare-param-fromrev": "Prima revisione da confrontare.",
+ "apihelp-compare-param-totitle": "Primo titolo da confrontare.",
+ "apihelp-compare-param-toid": "Secondo ID di pagina da confrontare.",
+ "apihelp-compare-param-torev": "Seconda revisione da confrontare.",
+ "apihelp-compare-example-1": "Crea un diff tra revisione 1 e revisione 2.",
+ "apihelp-createaccount-summary": "Crea un nuovo account utente.",
+ "apihelp-createaccount-param-preservestate": "Se <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> ha restituito true per <samp>hasprimarypreservedstate</samp>, le richieste contrassegnate come <samp>primary-required</samp> dovrebbero essere omesse. Se invece ha restituito un valore non vuoto per <samp>preservedusername</samp>, quel nome utente deve essere utilizzato per il parametro <var>username</var>.",
+ "apihelp-createaccount-example-create": "Avvia il processo di creazione utente <kbd>Example</kbd> con password <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Nome utente.",
+ "apihelp-createaccount-param-password": "Password (verrà ignorata se è impostato <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Dominio per l'autenticazione esterna (opzionale).",
+ "apihelp-createaccount-param-email": "Indirizzo Email dell'utente (opzionale).",
+ "apihelp-createaccount-param-realname": "Nome reale dell'utente (opzionale).",
+ "apihelp-createaccount-param-mailpassword": "Se impostato su un qualsiasi valore, una password random (casuale) verrà inviata all'utente.",
+ "apihelp-createaccount-param-reason": "Ragione, facoltativa, della creazione dell'account da inserire nei registri.",
+ "apihelp-createaccount-param-language": "Codice di lingua da impostare come predefinita per l'utente (opzionale, di default è la lingua del contenuto).",
+ "apihelp-createaccount-example-pass": "Crea l'utente <kbd>testuser</kbd> con password <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Crea l'utente <kbd>testmailuser</kbd> e mandagli via e-mail una password generata casualmente.",
+ "apihelp-delete-summary": "Cancella una pagina.",
+ "apihelp-delete-param-title": "Titolo della pagina che si desidera eliminare. Non può essere usato insieme a <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID di pagina della pagina da cancellare. Non può essere usato insieme con <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Motivo della cancellazione. Se non indicato, verrà usata una motivazione generata automaticamente.",
+ "apihelp-delete-param-watch": "Aggiunge la pagina agli osservati speciali dell'utente attuale.",
+ "apihelp-delete-param-unwatch": "Rimuove la pagina dagli osservati speciali dell'utente attuale.",
+ "apihelp-delete-param-oldimage": "Il nome della vecchia immagine da cancellare, come fornita da [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Cancella <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Cancella la <kbd>Main Page</kbd> con motivazione <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Questo modulo è stato disabilitato.",
+ "apihelp-edit-summary": "Crea e modifica pagine.",
+ "apihelp-edit-param-title": "Titolo della pagina da modificare. Non può essere usato insieme a <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID di pagina della pagina da modificare. Non può essere usato insieme con <var>$1title</var>.",
+ "apihelp-edit-param-section": "Numero di sezione. <kbd>0</kbd> per la sezione iniziale, <kbd>new</kbd> per una nuova sezione.",
+ "apihelp-edit-param-sectiontitle": "Il titolo per una nuova sezione.",
+ "apihelp-edit-param-text": "Contenuto della pagina.",
+ "apihelp-edit-param-summary": "Oggetto della modifica. Anche titolo della sezione se $1sezione=new e $1sectiontitle non è impostato.",
+ "apihelp-edit-param-tags": "Cambia i tag da applicare alla revisione.",
+ "apihelp-edit-param-minor": "Modifica minore.",
+ "apihelp-edit-param-notminor": "Modifica non minore.",
+ "apihelp-edit-param-bot": "Contrassegna questa modifica come eseguita da un bot.",
+ "apihelp-edit-param-createonly": "Non modificare la pagina se già esiste.",
+ "apihelp-edit-param-nocreate": "Genera un errore se la pagina non esiste.",
+ "apihelp-edit-param-watch": "Aggiunge la pagina agli osservati speciali dell'utente attuale.",
+ "apihelp-edit-param-unwatch": "Rimuove la pagina dagli osservati speciali dell'utente attuale.",
+ "apihelp-edit-param-redirect": "Risolvi automaticamente redirect.",
+ "apihelp-edit-param-contentmodel": "Modello di contenuto dei nuovi contenuti.",
+ "apihelp-edit-param-token": "Il token deve sempre essere inviato come ultimo parametro, o almeno dopo il parametro $1text.",
+ "apihelp-edit-example-edit": "Modifica una pagina.",
+ "apihelp-edit-example-prepend": "Anteponi <kbd>_&#95;NOTOC_&#95;</kbd> a una pagina.",
+ "apihelp-emailuser-summary": "Manda un'e-mail ad un utente.",
+ "apihelp-emailuser-param-target": "Utente a cui inviare l'e-mail.",
+ "apihelp-emailuser-param-subject": "Oggetto dell'e-mail.",
+ "apihelp-emailuser-param-text": "Testo dell'e-mail.",
+ "apihelp-emailuser-param-ccme": "Mandami una copia di questa mail.",
+ "apihelp-emailuser-example-email": "Manda una e-mail all'utente <kbd>WikiSysop</kbd> con il testo <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Espande tutti i template all'interno del wikitesto.",
+ "apihelp-expandtemplates-param-title": "Titolo della pagina.",
+ "apihelp-expandtemplates-param-text": "Wikitesto da convertire.",
+ "apihelp-expandtemplates-param-prop": "Quale informazione ottenere.\n\nNota che se non è selezionato alcun valore, il risultato conterrà il codice wiki, ma l'output sarà in un formato obsoleto.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Il wikitext espanso.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Proprietà della pagina definite dalle parole magiche estese nel wikitesto.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Se l'output sia volatile e non debba essere riutilizzato altrove all'interno della pagina.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Il tempo massimo dopo il quale le memorizzazioni temporanee (cache) del risultato dovrebbero essere invalidate.",
+ "apihelp-feedcontributions-param-feedformat": "Il formato del feed.",
+ "apihelp-feedcontributions-param-year": "Dall'anno (e precedenti).",
+ "apihelp-feedcontributions-param-month": "Dal mese (e precedenti).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtra contributi che hanno queste etichette.",
+ "apihelp-feedcontributions-param-deletedonly": "Mostra solo i contribuiti cancellati.",
+ "apihelp-feedcontributions-param-toponly": "Mostra solo i contributi che sono le ultime versioni per la pagina.",
+ "apihelp-feedcontributions-param-newonly": "Visualizza solo le modifiche che sono creazioni di pagina.",
+ "apihelp-feedcontributions-param-hideminor": "Nascondi le modifiche minori.",
+ "apihelp-feedcontributions-param-showsizediff": "Mostra la differenza di dimensioni tra le versioni.",
+ "apihelp-feedcontributions-example-simple": "Restituisce contributi per l'utente <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-param-feedformat": "Il formato del feed.",
+ "apihelp-feedrecentchanges-param-namespace": "Namespace a cui limitare i risultati.",
+ "apihelp-feedrecentchanges-param-associated": "Includi namespace associato (discussione o principale)",
+ "apihelp-feedrecentchanges-param-days": "Intervallo di giorni per i quali limitare i risultati.",
+ "apihelp-feedrecentchanges-param-limit": "Numero massimo di risultati da restituire.",
+ "apihelp-feedrecentchanges-param-from": "Mostra i cambiamenti da allora.",
+ "apihelp-feedrecentchanges-param-hideminor": "Nascondi le modifiche minori.",
+ "apihelp-feedrecentchanges-param-hidebots": "Nascondi le modifiche apportate da bot.",
+ "apihelp-feedrecentchanges-param-hideanons": "Nascondi le modifiche fatte da utenti anonimi.",
+ "apihelp-feedrecentchanges-param-hideliu": "Nascondi le modifiche apportate dagli utenti registrati.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Nascondi modifiche verificate.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Nasconde le modifiche effettuate dall'utente attuale.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Nascondi le variazioni di appartenenza alle categorie.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtra per etichetta.",
+ "apihelp-feedrecentchanges-param-target": "Mostra solo le modifiche alle pagine collegate da questa pagina.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Mostra solo le modifiche alle pagine collegate a quella specificata.",
+ "apihelp-feedrecentchanges-param-categories": "Mostra solo le variazioni sulle pagine di tutte queste categorie.",
+ "apihelp-feedrecentchanges-param-categories_any": "Mostra invece solo le variazioni sulle pagine in una qualunque categoria.",
+ "apihelp-feedrecentchanges-example-simple": "Mostra le ultime modifiche.",
+ "apihelp-feedrecentchanges-example-30days": "Mostra le modifiche degli ultimi 30 giorni.",
+ "apihelp-feedwatchlist-param-feedformat": "Il formato del feed.",
+ "apihelp-feedwatchlist-param-hours": "Elenca le pagine modificate entro queste ultime ore.",
+ "apihelp-feedwatchlist-param-linktosections": "Collega direttamente alla sezione modificata, se possibile.",
+ "apihelp-feedwatchlist-example-all6hrs": "Mostra tutte le modifiche alle pagine osservate nelle ultime 6 ore.",
+ "apihelp-filerevert-summary": "Ripristina un file ad una versione precedente.",
+ "apihelp-filerevert-param-filename": "Nome del file di destinazione, senza il prefisso 'File:'.",
+ "apihelp-filerevert-param-comment": "Commento sul caricamento.",
+ "apihelp-filerevert-param-archivename": "Nome dell'archivio della versione da ripristinare.",
+ "apihelp-filerevert-example-revert": "Ripristina <kbd>Wiki.png</kbd> alla versione del <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Mostra la guida per i moduli specificati.",
+ "apihelp-help-param-toc": "Includi un indice nell'output HTML.",
+ "apihelp-help-example-main": "Aiuto per il modulo principale.",
+ "apihelp-help-example-submodules": "Aiuto per <kbd>action=query</kbd> e tutti i suoi sotto-moduli.",
+ "apihelp-help-example-recursive": "Tutti gli aiuti in una pagina.",
+ "apihelp-help-example-help": "Aiuto per lo stesso modulo di aiuto.",
+ "apihelp-imagerotate-summary": "Ruota una o più immagini.",
+ "apihelp-imagerotate-param-rotation": "Gradi di rotazione dell'immagine in senso orario.",
+ "apihelp-imagerotate-example-simple": "Ruota <kbd>File:Example.png</kbd> di <kbd>90</kbd> gradi.",
+ "apihelp-imagerotate-example-generator": "Ruota tutte le immagini in <kbd>Category:Flip</kbd> di <kbd>180</kbd> gradi.",
+ "apihelp-import-param-summary": "Oggetto nel registro di importazione.",
+ "apihelp-import-param-xml": "File XML caricato.",
+ "apihelp-import-param-interwikisource": "Per importazioni interwiki: wiki da cui importare.",
+ "apihelp-import-param-interwikipage": "Per importazioni interwiki: pagina da importare.",
+ "apihelp-import-param-fullhistory": "Per importazioni interwiki: importa l'intera cronologia, non solo la versione attuale.",
+ "apihelp-import-param-templates": "Per importazioni interwiki: importa anche tutti i template inclusi.",
+ "apihelp-import-param-namespace": "Importa in questo namespace. Non può essere usato insieme a <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importa come sottopagina di questa pagina. Non può essere usato insieme a <var>$1namespace</var>.",
+ "apihelp-import-example-import": "Importa [[meta:Help:ParserFunctions]] nel namespace 100 con cronologia completa.",
+ "apihelp-linkaccount-summary": "Collegamento di un'utenza di un provider di terze parti all'utente corrente.",
+ "apihelp-linkaccount-example-link": "Avvia il processo di collegamento ad un'utenza da <kbd>Example</kbd>.",
+ "apihelp-login-summary": "Accedi e ottieni i cookie di autenticazione.",
+ "apihelp-login-extended-description": "Questa azione deve essere usata esclusivamente in combinazione con [[Special:BotPasswords]]; utilizzarla per l'accesso all'account principale è deprecato e può fallire senza preavviso. Per accedere in modo sicuro all'utenza principale, usa <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Questa azione è deprecata e può fallire senza preavviso. Per accedere in modo sicuro, usa [[Special:ApiHelp/clientlogin|action=clientlogin]].",
+ "apihelp-login-param-name": "Nome utente.",
+ "apihelp-login-param-password": "Password.",
+ "apihelp-login-param-domain": "Dominio (opzionale).",
+ "apihelp-login-example-gettoken": "Recupera un token di login.",
+ "apihelp-login-example-login": "Entra.",
+ "apihelp-logout-summary": "Esci e cancella i dati della sessione.",
+ "apihelp-logout-example-logout": "Disconnetti l'utente attuale.",
+ "apihelp-mergehistory-summary": "Unisce cronologie pagine.",
+ "apihelp-mergehistory-param-from": "Il titolo della pagina da cui cronologia sarà unita. Non può essere usato insieme a <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "L'ID della pagina da cui cronologia sarà unita. Non può essere usato insieme a <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Il titolo della pagina in cui cronologia sarà unita. Non può essere usato insieme a <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "L'ID della pagina in cui cronologia sarà unita. Non può essere usato insieme a <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "Il timestamp fino a cui le versioni saranno spostate dalla cronologia della pagina di origine a quella della pagina di destinazione. Se omesso, l'intera cronologia della pagina di origine sarà unita nella pagina di destinazione.",
+ "apihelp-mergehistory-param-reason": "Motivo per l'unione della cronologia.",
+ "apihelp-mergehistory-example-merge": "Unisci l'intera cronologia di <kbd>Oldpage</kbd> in <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Unisci le versioni della pagina <kbd>Oldpage</kbd> fino a <kbd>2015-12-31T04:37:41Z</kbd> in <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Sposta una pagina.",
+ "apihelp-move-param-to": "Titolo a cui spostare la pagina.",
+ "apihelp-move-param-reason": "Motivo della rinomina.",
+ "apihelp-move-param-movetalk": "Rinomina la pagina di discussione, se esiste.",
+ "apihelp-move-param-movesubpages": "Rinomina sottopagine, se applicabile.",
+ "apihelp-move-param-noredirect": "Non creare un rinvio.",
+ "apihelp-move-param-watch": "Aggiunge la pagina e il redirect agli osservati speciali dell'utente attuale.",
+ "apihelp-move-param-unwatch": "Rimuovi la pagina e il redirect dagli osservati speciali dell'utente attuale.",
+ "apihelp-move-param-ignorewarnings": "Ignora i messaggi di avvertimento del sistema.",
+ "apihelp-move-example-move": "Sposta <kbd>Badtitle</kbd> a <kbd>Goodtitle</kbd> senza lasciare redirect.",
+ "apihelp-opensearch-param-search": "Stringa di ricerca.",
+ "apihelp-opensearch-param-limit": "Numero massimo di risultati da restituire.",
+ "apihelp-opensearch-param-suggest": "Non fare nulla se <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> è falso.",
+ "apihelp-opensearch-param-format": "Il formato dell'output.",
+ "apihelp-opensearch-example-te": "Trova le pagine che iniziano con <kbd>Te</kbd>.",
+ "apihelp-options-param-optionvalue": "Il valore per l'opzione specificata da <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Reimposta tutte le preferenze.",
+ "apihelp-paraminfo-summary": "Ottieni informazioni sui moduli API.",
+ "apihelp-paraminfo-param-helpformat": "Formato delle stringhe di aiuto.",
+ "apihelp-parse-param-summary": "Oggetto da analizzare.",
+ "apihelp-parse-param-redirects": "Se <var>$1page</var> o <var>$1pageid</var> è impostato come reindirizzamento, lo risolve.",
+ "apihelp-parse-param-prop": "Quali pezzi di informazioni ottenere:",
+ "apihelp-parse-example-text": "Analizza wikitext.",
+ "apihelp-parse-example-texttitle": "Analizza wikitext, specificando il titolo della pagina.",
+ "apihelp-parse-example-summary": "Analizza un oggetto.",
+ "apihelp-patrol-summary": "Verifica una pagina o versione.",
+ "apihelp-patrol-param-rcid": "ID della modifica recente da verificare.",
+ "apihelp-patrol-param-revid": "ID versione da verificare.",
+ "apihelp-patrol-param-tags": "Modifica etichette da applicare all'elemento del registro delle verifiche.",
+ "apihelp-patrol-example-rcid": "Verifica una modifica recente.",
+ "apihelp-patrol-example-revid": "Verifica una versione.",
+ "apihelp-protect-summary": "Modifica il livello di protezione di una pagina.",
+ "apihelp-protect-param-title": "Titolo della pagina da (s)proteggere. Non può essere usato insieme a <var>$1pageid</var>.",
+ "apihelp-protect-param-pageid": "ID della pagina da (s)proteggere. Non può essere usato insieme con $1title.",
+ "apihelp-protect-param-tags": "Modifica etichette da applicare all'elemento del registro delle protezioni.",
+ "apihelp-protect-example-protect": "Proteggi una pagina.",
+ "apihelp-protect-example-unprotect": "Sproteggi una pagina impostando restrizione su <kbd>all</kbd> (cioè a tutti è consentito intraprendere l'azione).",
+ "apihelp-protect-example-unprotect2": "Sproteggi una pagina impostando nessuna restrizione.",
+ "apihelp-purge-summary": "Pulisce la cache per i titoli indicati.",
+ "apihelp-purge-param-forcelinkupdate": "Aggiorna la tabella dei collegamenti.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Aggiorna la tabella dei collegamenti per questa pagina, e per ogni pagina che usa questa pagina come template.",
+ "apihelp-query-param-list": "Quali elenchi ottenere.",
+ "apihelp-query-param-meta": "Quali metadati ottenere.",
+ "apihelp-query-param-export": "Esporta la versione attuale di tutte le pagine ottenute o generate.",
+ "apihelp-query+allcategories-summary": "Enumera tutte le categorie.",
+ "apihelp-query+allcategories-param-from": "La categoria da cui iniziare l'elenco.",
+ "apihelp-query+allcategories-param-to": "La categoria al quale interrompere l'elenco.",
+ "apihelp-query+allcategories-param-prefix": "Ricerca per tutti i titoli delle categorie che iniziano con questo valore.",
+ "apihelp-query+allcategories-param-dir": "Direzione dell'ordinamento.",
+ "apihelp-query+allcategories-param-limit": "Quante categorie restituire.",
+ "apihelp-query+allcategories-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Aggiungi il numero di pagine nella categoria.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Etichetta categorie che sono nascoste con <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Elenca categorie con informazioni sul numero di pagine in ognuna.",
+ "apihelp-query+alldeletedrevisions-summary": "Elenca tutte le versioni cancellate da un utente o in un namespace.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Può essere utilizzato solo con <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Non può essere utilizzato con <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "Il timestamp da cui iniziare l'elenco.",
+ "apihelp-query+alldeletedrevisions-param-end": "Il timestamp al quale interrompere l'elenco.",
+ "apihelp-query+alldeletedrevisions-param-from": "Inizia elenco a questo titolo.",
+ "apihelp-query+alldeletedrevisions-param-to": "Interrompi elenco a questo titolo.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Ricerca per tutti i titoli delle pagine che iniziano con questo valore.",
+ "apihelp-query+alldeletedrevisions-param-user": "Elenca solo le versioni di questo utente.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Non elencare le versioni di questo utente.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Elenca solo le pagine in questo namespace.",
+ "apihelp-query+alldeletedrevisions-example-user": "Elenca gli ultimi 50 contributi cancellati dell'utente <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Elenca le prime 50 versioni cancellate nel namespace principale.",
+ "apihelp-query+allfileusages-param-from": "Il titolo del file da cui iniziare l'elenco.",
+ "apihelp-query+allfileusages-param-to": "Il titolo del file al quale interrompere l'elenco.",
+ "apihelp-query+allfileusages-param-prefix": "Ricerca per tutti i titoli dei file che iniziano con questo valore.",
+ "apihelp-query+allfileusages-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Aggiunge il titolo del file.",
+ "apihelp-query+allfileusages-param-limit": "Quanti elementi totali restituire.",
+ "apihelp-query+allfileusages-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+allfileusages-example-generator": "Ottieni le pagine contenenti i file.",
+ "apihelp-query+allimages-param-sort": "Proprietà di ordinamento.",
+ "apihelp-query+allimages-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+allimages-param-from": "Il titolo dell'immagine da cui iniziare l'elenco. Può essere utilizzato solo con $1sort=name.",
+ "apihelp-query+allimages-param-start": "Il timestamp da cui iniziare l'elenco. Può essere utilizzato solo con $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "Il timestamp al quale interrompere l'elenco. Può essere utilizzato solo con $1sort=timestamp.",
+ "apihelp-query+allimages-param-limit": "Quante immagini in totale restituire.",
+ "apihelp-query+allimages-example-B": "Mostra un elenco di file a partire dalla lettera <kbd>B</kbd>.",
+ "apihelp-query+alllinks-summary": "Elenca tutti i collegamenti che puntano ad un namespace indicato.",
+ "apihelp-query+alllinks-param-from": "Il titolo del collegamento da cui iniziare l'elenco.",
+ "apihelp-query+alllinks-param-to": "Il titolo del collegamento al quale interrompere l'elenco.",
+ "apihelp-query+alllinks-param-prefix": "Ricerca per tutti i titoli dei collegamenti che iniziano con questo valore.",
+ "apihelp-query+alllinks-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Aggiunge l'ID pagina della pagina collegata (non può essere usato con <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Aggiunge il titolo del collegamento.",
+ "apihelp-query+alllinks-param-namespace": "Il namespace da elencare.",
+ "apihelp-query+alllinks-param-limit": "Quanti elementi totali restituire.",
+ "apihelp-query+alllinks-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+alllinks-example-generator": "Ottieni le pagine contenenti i collegamenti.",
+ "apihelp-query+allmessages-summary": "Restituisce messaggi da questo sito.",
+ "apihelp-query+allmessages-param-prop": "Quali proprietà ottenere.",
+ "apihelp-query+allmessages-param-lang": "Restituisci messaggi in questa lingua.",
+ "apihelp-query+allmessages-param-prefix": "Restituisci messaggi con questo prefisso.",
+ "apihelp-query+allpages-param-from": "Il titolo di pagina da cui iniziare l'elenco.",
+ "apihelp-query+allpages-param-to": "Il titolo di pagina al quale interrompere l'elenco.",
+ "apihelp-query+allpages-param-prefix": "Ricerca per tutti i titoli delle pagine che iniziano con questo valore.",
+ "apihelp-query+allpages-param-namespace": "Il namespace da elencare.",
+ "apihelp-query+allpages-param-filterredir": "Quali pagine elencare.",
+ "apihelp-query+allpages-param-prtype": "Limita alle sole pagine protette.",
+ "apihelp-query+allpages-param-limit": "Quante pagine totali restituire.",
+ "apihelp-query+allpages-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+allredirects-param-from": "Il titolo del reindirizzamento da cui iniziare l'elenco.",
+ "apihelp-query+allredirects-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Aggiunge il titolo del redirect.",
+ "apihelp-query+allredirects-param-namespace": "Il namespace da elencare.",
+ "apihelp-query+allredirects-param-limit": "Quanti elementi totali restituire.",
+ "apihelp-query+allredirects-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+allredirects-example-generator": "Ottieni le pagine contenenti i reindirizzamenti.",
+ "apihelp-query+allrevisions-summary": "Elenco di tutte le versioni.",
+ "apihelp-query+allrevisions-param-start": "Il timestamp da cui iniziare l'elenco.",
+ "apihelp-query+allrevisions-param-end": "Il timestamp al quale interrompere l'elenco.",
+ "apihelp-query+allrevisions-param-user": "Elenca solo le versioni di questo utente.",
+ "apihelp-query+allrevisions-param-excludeuser": "Non elencare le versioni di questo utente.",
+ "apihelp-query+allrevisions-param-namespace": "Elenca solo le pagine in questo namespace.",
+ "apihelp-query+allrevisions-example-user": "Elenca gli ultimi 50 contributi dell'utente <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "Elenca solo le prime 50 versioni nel namespace principale.",
+ "apihelp-query+mystashedfiles-param-prop": "Quali proprietà recuperare per il file.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Recupera la dimensione del file e le dimensioni dell'immagine.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Recupera il tipo MIME del file e il tipo media.",
+ "apihelp-query+mystashedfiles-param-limit": "Quanti file restituire.",
+ "apihelp-query+alltransclusions-summary": "Elenca tutte le inclusioni (pagine incorporate utilizzando &#123;&#123;x&#125;&#125;), comprese le non esistenti.",
+ "apihelp-query+alltransclusions-param-from": "Il titolo dell'inclusione da cui iniziare l'elenco.",
+ "apihelp-query+alltransclusions-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Aggiunge il titolo dell'inclusione.",
+ "apihelp-query+alltransclusions-param-namespace": "Il namespace da elencare.",
+ "apihelp-query+alltransclusions-param-limit": "Quanti elementi totali restituire.",
+ "apihelp-query+alltransclusions-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+alltransclusions-example-generator": "Ottieni pagine contenenti le inclusioni.",
+ "apihelp-query+allusers-param-from": "Il nome utente da cui iniziare l'elenco.",
+ "apihelp-query+allusers-param-to": "Il nome utente al quale interrompere l'elenco.",
+ "apihelp-query+allusers-param-prefix": "Ricerca per tutti gli utenti che iniziano con questo valore.",
+ "apihelp-query+allusers-param-dir": "Direzione dell'ordinamento.",
+ "apihelp-query+allusers-param-group": "Includi solo gli utenti nei gruppi indicati.",
+ "apihelp-query+allusers-param-excludegroup": "Escludi gli utenti nei gruppi indicati.",
+ "apihelp-query+allusers-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+allusers-param-limit": "Quanti nomi utente totali restituire.",
+ "apihelp-query+authmanagerinfo-summary": "Recupera informazioni circa l'attuale stato di autenticazione.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Verifica se lo stato di autenticazione dell'utente attuale è sufficiente per la specifica operazione sensibile alla sicurezza.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Recupera informazioni circa le richieste di autenticazione necessarie per la specifica azione di autenticazione.",
+ "apihelp-query+authmanagerinfo-example-login": "Recupera le richieste che possono essere utilizzate quando si inizia l'accesso.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Recupera le richieste che possono essere utilizzate quando si inizia l'accesso, con i campi del modulo uniti.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Verificare se l'autenticazione è sufficiente per l'azione <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "Trova tutte le pagine che puntano a quella specificata.",
+ "apihelp-query+backlinks-param-namespace": "Il namespace da elencare.",
+ "apihelp-query+backlinks-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+backlinks-param-redirect": "Se la pagina collegata è un redirect, trova tutte le pagine che puntano al redirect. Il limite massimo è dimezzato.",
+ "apihelp-query+backlinks-example-simple": "Mostra collegamenti a <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-param-start": "Il timestamp da cui iniziare l'elenco.",
+ "apihelp-query+blocks-param-end": "Il timestamp al quale interrompere l'elenco.",
+ "apihelp-query+blocks-param-limit": "Il numero massimo di blocchi da elencare.",
+ "apihelp-query+blocks-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Aggiunge l'ID del blocco.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Aggiunge il nome utente dell'utente bloccato.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Aggiunge l'ID utente dell'utente bloccato.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Aggiunge il nome utente dell'utente che ha effettuato il blocco.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Aggiunge l'ID utente dell'utente che ha effettuato il blocco.",
+ "apihelp-query+blocks-example-simple": "Elenca i blocchi.",
+ "apihelp-query+categories-summary": "Elenca tutte le categorie a cui appartengono le pagine.",
+ "apihelp-query+categories-param-prop": "Quali proprietà aggiuntive ottenere per ogni categoria.",
+ "apihelp-query+categories-param-show": "Quale tipo di categorie mostrare.",
+ "apihelp-query+categories-param-limit": "Quante categorie restituire.",
+ "apihelp-query+categories-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+categoryinfo-summary": "Restituisce informazioni su una categoria indicata.",
+ "apihelp-query+categoryinfo-example-simple": "Ottieni informazioni su <kbd>Category:Foo</kbd> e <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Elenca tutte le pagine in una categoria indicata.",
+ "apihelp-query+categorymembers-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Aggiunge l'ID pagina.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Aggiunge il titolo e l'ID namespace della pagina.",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Aggiunge il tipo di come la pagina è stata categorizzata (<samp>page</samp>, <samp>subcat</samp> o <samp>file</samp>).",
+ "apihelp-query+categorymembers-param-namespace": "Includi solo le pagine in questi namespace. Nota che può essere usato <kbd>$1type=subcat</kbd> o <kbd>$1type=file</kbd> anziché <kbd>$1namespace=14</kbd> o <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-limit": "Il numero massimo di pagine da restituire.",
+ "apihelp-query+categorymembers-param-sort": "Proprietà di ordinamento.",
+ "apihelp-query+categorymembers-param-dir": "In quale direzione ordinare.",
+ "apihelp-query+categorymembers-param-start": "Il timestamp da cui iniziare l'elenco. Può essere utilizzato solo con <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "Il timestamp al quale interrompere l'elenco. Può essere utilizzato solo con <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkey": "Usa $1starthexsortkey invece.",
+ "apihelp-query+categorymembers-param-endsortkey": "Usa $1endhexsortkey invece.",
+ "apihelp-query+categorymembers-example-simple": "Ottieni le prime 10 pagine in <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Ottieni informazioni sulle prime 10 pagine in <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-param-limit": "Quanti contributi restituire.",
+ "apihelp-query+deletedrevisions-param-start": "Il timestamp da cui iniziare l'elenco. Ignorato quando si elabora un elenco di ID versioni.",
+ "apihelp-query+deletedrevisions-param-end": "Il timestamp al quale interrompere l'elenco. Ignorato quando si elabora un elenco di ID versioni.",
+ "apihelp-query+deletedrevisions-param-tag": "Elenca solo le versioni etichettate con questa etichetta.",
+ "apihelp-query+deletedrevisions-param-user": "Elenca solo le versioni di questo utente.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Non elencare le versioni di questo utente.",
+ "apihelp-query+deletedrevisions-example-titles": "Elenca le versioni cancellate delle pagine <kbd>Main Page</kbd> e <kbd>Talk:Main Page</kbd>, con il contenuto.",
+ "apihelp-query+deletedrevisions-example-revids": "Elenca le informazioni per la versione cancellata <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-param-start": "Il timestamp da cui iniziare l'elenco.",
+ "apihelp-query+deletedrevs-param-end": "Il timestamp al quale interrompere l'elenco.",
+ "apihelp-query+deletedrevs-param-from": "Inizia elenco a questo titolo.",
+ "apihelp-query+deletedrevs-param-to": "Interrompi elenco a questo titolo.",
+ "apihelp-query+deletedrevs-param-prefix": "Ricerca per tutti i titoli delle pagine che iniziano con questo valore.",
+ "apihelp-query+deletedrevs-param-unique": "Elenca solo una versione per ogni pagina.",
+ "apihelp-query+deletedrevs-param-tag": "Elenca solo le versioni etichettate con questa etichetta.",
+ "apihelp-query+deletedrevs-param-user": "Elenca solo le versioni di questo utente.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Non elencare le versioni di questo utente.",
+ "apihelp-query+deletedrevs-param-namespace": "Elenca solo le pagine in questo namespace.",
+ "apihelp-query+deletedrevs-param-limit": "Il numero massimo di versioni da elencare.",
+ "apihelp-query+disabled-summary": "Questo modulo query è stato disabilitato.",
+ "apihelp-query+duplicatefiles-param-limit": "Quanti file duplicati restituire.",
+ "apihelp-query+duplicatefiles-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+duplicatefiles-example-simple": "Cerca i duplicati di [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Cerca i duplicati di tutti i file.",
+ "apihelp-query+embeddedin-summary": "Trova tutte le pagine che incorporano (transclusione) il titolo specificato.",
+ "apihelp-query+embeddedin-param-namespace": "Il namespace da elencare.",
+ "apihelp-query+embeddedin-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+embeddedin-param-limit": "Quante pagine totali restituire.",
+ "apihelp-query+extlinks-param-limit": "Quanti collegamenti restituire.",
+ "apihelp-query+exturlusage-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Aggiunge l'ID della pagina.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Aggiunge il titolo e l'ID namespace della pagina.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Aggiunge l'URL utilizzato nella pagina.",
+ "apihelp-query+exturlusage-param-namespace": "I namespace da elencare.",
+ "apihelp-query+exturlusage-param-limit": "Quante pagine restituire.",
+ "apihelp-query+filearchive-param-from": "Il titolo dell'immagine da cui iniziare l'elenco.",
+ "apihelp-query+filearchive-param-limit": "Quante immagini restituire in totale.",
+ "apihelp-query+filearchive-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Aggiunge MIME dell'immagine.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Aggiunge la profondità di bit della versione.",
+ "apihelp-query+filearchive-example-simple": "Mostra un elenco di tutti i file cancellati.",
+ "apihelp-query+fileusage-summary": "Trova tutte le pagine che utilizzano il file specificato.",
+ "apihelp-query+fileusage-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "ID pagina di ogni pagina.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Titolo di ogni pagina.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Indica se la pagina è un redirect.",
+ "apihelp-query+fileusage-param-namespace": "Includi solo le pagine in questi namespace.",
+ "apihelp-query+fileusage-param-show": "Mostra solo gli elementi che soddisfano questi criteri:\n;redirect:mostra solo i redirect.\n;!redirect:mostra solo i non redirect.",
+ "apihelp-query+fileusage-example-simple": "Ottieni un elenco di pagine che usano [[:File:Example.jpg]].",
+ "apihelp-query+imageinfo-summary": "Restituisce informazione sul file sulla cronologia di caricamento.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Aggiunge il tipo MIME del file.",
+ "apihelp-query+imageinfo-param-start": "Il timestamp da cui iniziare l'elenco.",
+ "apihelp-query+imageinfo-param-urlheight": "Simile a $1urlwidth.",
+ "apihelp-query+images-param-limit": "Quanti file restituire.",
+ "apihelp-query+images-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+images-example-simple": "Ottieni un elenco di file usati in [[Main Page]].",
+ "apihelp-query+imageusage-summary": "Trova tutte le pagine che utilizzano il titolo dell'immagine specificato.",
+ "apihelp-query+imageusage-param-namespace": "Il namespace da elencare.",
+ "apihelp-query+imageusage-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+imageusage-param-redirect": "Se la pagina collegata è un redirect, trova tutte le pagine che puntano al redirect. Il limite massimo è dimezzato.",
+ "apihelp-query+info-summary": "Ottieni informazioni base sulla pagina.",
+ "apihelp-query+info-param-prop": "Quali proprietà aggiuntive ottenere:",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "Il numero di osservatori di ogni pagina che hanno visitato le ultime modifiche alla pagina, se consentito.",
+ "apihelp-query+iwbacklinks-param-prefix": "Prefisso per l'interwiki.",
+ "apihelp-query+iwbacklinks-param-limit": "Quante pagine totali restituire.",
+ "apihelp-query+iwbacklinks-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Aggiunge il prefisso dell'interwiki.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Aggiunge il titolo dell'interwiki.",
+ "apihelp-query+iwbacklinks-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+iwlinks-summary": "Restituisce tutti i collegamenti interwiki dalle pagine indicate.",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Aggiunge l'URL completo.",
+ "apihelp-query+iwlinks-param-limit": "Quanti collegamenti interwiki restituire.",
+ "apihelp-query+iwlinks-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+langbacklinks-param-limit": "Quante pagine totali restituire.",
+ "apihelp-query+langbacklinks-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Aggiunge il titolo del collegamento linguistico.",
+ "apihelp-query+langbacklinks-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Aggiunge l'URL completo.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Aggiunge il nome nativo della lingua.",
+ "apihelp-query+langlinks-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+links-param-namespace": "Mostra collegamenti solo in questi namespace.",
+ "apihelp-query+links-param-limit": "Quanti collegamenti restituire.",
+ "apihelp-query+links-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+linkshere-summary": "Trova tutte le pagine che puntano a quelle specificate.",
+ "apihelp-query+linkshere-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "ID pagina di ogni pagina.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Titolo di ogni pagina.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Indica se la pagina è un redirect.",
+ "apihelp-query+linkshere-param-namespace": "Includi solo le pagine in questi namespace.",
+ "apihelp-query+linkshere-param-show": "Mostra solo gli elementi che soddisfano questi criteri:\n;redirect:mostra solo i redirect.\n;!redirect:mostra solo i non redirect.",
+ "apihelp-query+linkshere-example-simple": "Ottieni un elenco di pagine che puntano a [[Main Page]].",
+ "apihelp-query+logevents-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+logevents-paramvalue-prop-title": "Aggiunge il titolo della pagine per l'evento nel registro.",
+ "apihelp-query+logevents-param-start": "Il timestamp da cui iniziare l'elenco.",
+ "apihelp-query+logevents-param-end": "Il timestamp al quale interrompere l'elenco.",
+ "apihelp-query+pageswithprop-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Aggiunge l'ID pagina.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Aggiunge il titolo e l'ID namespace della pagina.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Aggiunge il valore della proprietà di pagina.",
+ "apihelp-query+pageswithprop-param-limit": "Il numero massimo di pagine da restituire.",
+ "apihelp-query+pageswithprop-param-dir": "In quale direzione ordinare.",
+ "apihelp-query+prefixsearch-param-search": "Stringa di ricerca.",
+ "apihelp-query+prefixsearch-param-limit": "Numero massimo di risultati da restituire.",
+ "apihelp-query+prefixsearch-param-offset": "Numero di risultati da saltare",
+ "apihelp-query+prefixsearch-param-profile": "Profilo di ricerca da utilizzare.",
+ "apihelp-query+protectedtitles-summary": "Elenca tutti i titoli protetti dalla creazione.",
+ "apihelp-query+protectedtitles-param-namespace": "Elenca solo i titoli in questi namespace.",
+ "apihelp-query+protectedtitles-param-level": "Elenca solo i titoli con questi livelli di protezione.",
+ "apihelp-query+protectedtitles-param-limit": "Quante pagine totali restituire.",
+ "apihelp-query+protectedtitles-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Aggiunge l'utente che ha aggiunto la protezione.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Aggiunge l'ID utente che ha aggiunto la protezione.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Aggiunge il commento per la protezione.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Aggiunge il livello di protezione.",
+ "apihelp-query+protectedtitles-example-generator": "Trova collegamenti a titoli protetti nel namespace principale.",
+ "apihelp-query+querypage-param-limit": "Numero di risultati da restituire.",
+ "apihelp-query+querypage-example-ancientpages": "Restituisce risultati da [[Special:Ancientpages|Speciale:PagineMenoRecenti]].",
+ "apihelp-query+random-param-namespace": "Restituisci le pagine solo in questi namespace.",
+ "apihelp-query+random-param-redirect": "Usa <kbd>$1filterredir=redirects</kbd> invece.",
+ "apihelp-query+random-example-simple": "Restituisce due pagine casuali dal namespace principale.",
+ "apihelp-query+recentchanges-summary": "Elenca le modifiche recenti.",
+ "apihelp-query+recentchanges-param-start": "Il timestamp da cui iniziare l'elenco.",
+ "apihelp-query+recentchanges-param-end": "Il timestamp al quale interrompere l'elenco.",
+ "apihelp-query+recentchanges-example-simple": "Elenco modifiche recenti.",
+ "apihelp-query+redirects-summary": "Restituisce tutti i reindirizzamenti alla data indicata.",
+ "apihelp-query+redirects-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "ID pagina di ogni redirect.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Titolo di ogni redirect.",
+ "apihelp-query+redirects-param-namespace": "Includi solo le pagine in questi namespace.",
+ "apihelp-query+redirects-param-limit": "Quanti reindirizzamenti restituire.",
+ "apihelp-query+redirects-example-simple": "Ottieni un elenco di redirect a [[Main Page]].",
+ "apihelp-query+revisions-param-startid": "Inizia l'elenco dal timestamp di questa versione. La versione deve esistere, ma non necessariamente deve appartenere a questa pagina.",
+ "apihelp-query+revisions-param-start": "Il timestamp della versione da cui iniziare l'elenco.",
+ "apihelp-query+revisions-param-tag": "Elenca solo le versioni etichettate con questa etichetta.",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "L'ID della versione.",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "Il timestamp della versione.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Utente che ha effettuato la versione.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "ID utente dell'autore della versione.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Testo della versione.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Etichette della versione.",
+ "apihelp-query+search-summary": "Eseguire una ricerca di testo completa.",
+ "apihelp-query+search-param-what": "Quale tipo di ricerca effettuare.",
+ "apihelp-query+search-param-info": "Quali metadati restituire.",
+ "apihelp-query+search-param-prop": "Quali proprietà restituire.",
+ "apihelp-query+search-paramvalue-prop-size": "Aggiungi la dimensione della pagina in byte.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Aggiungi il conteggio delle parole nella pagina.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Aggiungi il timestamp di quando la pagina è stata modificata l'ultima volta.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Aggiunge il titolo del redirect corrispondente.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Aggiunge il titolo della sezione corrispondente.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignorato.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorato.",
+ "apihelp-query+search-param-limit": "Quante pagine totali restituire.",
+ "apihelp-query+siteinfo-param-prop": "Quali informazioni ottenere:",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Restituisce le statistiche del sito.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Restituisci librerie installate sul wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Restituisci estensioni installate sul wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Restituisce informazioni sui tipi di restrizione (protezione) disponibili.",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Restituisce un'elenco di codici lingua per cui [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]] è attivo, e le varianti supportate per ognuno di essi.",
+ "apihelp-query+siteinfo-example-simple": "Recupera informazioni sul sito.",
+ "apihelp-query+tags-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+templates-param-limit": "Quanti template restituire.",
+ "apihelp-query+templates-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+tokens-param-type": "Tipi di token da richiedere.",
+ "apihelp-query+tokens-example-simple": "Recupera un token csrf (il predefinito).",
+ "apihelp-query+transcludedin-summary": "Trova tutte le pagine che incorporano quella specificata.",
+ "apihelp-query+transcludedin-param-prop": "Quali proprietà ottenere:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "ID pagina di ogni pagina.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Titolo di ogni pagina.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Indica se la pagina è un redirect.",
+ "apihelp-query+transcludedin-param-namespace": "Includi solo le pagine in questi namespace.",
+ "apihelp-query+transcludedin-param-show": "Mostra solo gli elementi che soddisfano questi criteri:\n;redirect:mostra solo i redirect.\n;!redirect:mostra solo i non redirect.",
+ "apihelp-query+transcludedin-example-simple": "Ottieni un elenco di pagine che includono <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-param-namespace": "Elenca solo i contributi in questi namespace.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Aggiunge il titolo e l'ID namespace della pagina.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Etichetta modifiche verificate",
+ "apihelp-query+userinfo-summary": "Ottieni informazioni sull'utente attuale.",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Etichetta se l'utente attuale è bloccato, da chi e per quale motivo.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Aggiunge un'etichetta <samp>messages</samp> se l'utente attuale ha messaggi in attesa.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Elenca tutti i gruppi di cui l'utente attuale è automaticamente membro.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Elenca tutti i gruppi di cui l'utente attuale può essere aggiunto o rimosso.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Ottieni un token per modificare le preferenze dell'utente attuale.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Aggiungi il nome reale dell'utente.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Aggiungi la data di registrazione dell'utente.",
+ "apihelp-query+userinfo-example-simple": "Ottieni informazioni sull'utente attuale.",
+ "apihelp-query+users-summary": "Ottieni informazioni su un elenco di utenti.",
+ "apihelp-query+users-param-prop": "Quali pezzi di informazioni includere:",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Indica se può essere creata un'utenza per nomi utente validi ma non registrati.",
+ "apihelp-query+users-param-users": "Un elenco di utenti di cui ottenere informazioni.",
+ "apihelp-query+watchlist-summary": "Ottieni le ultime modifiche alle pagine tra gli osservati speciali dell'utente attuale.",
+ "apihelp-query+watchlist-param-start": "Il timestamp da cui iniziare l'elenco.",
+ "apihelp-query+watchlist-param-end": "Il timestamp al quale interrompere l'elenco.",
+ "apihelp-query+watchlist-param-prop": "Quali proprietà aggiuntive ottenere:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Aggiunge l'ID versione e l'ID pagina.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Aggiungi il titolo della pagina.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Creazioni pagina.",
+ "apihelp-query+watchlistraw-param-namespace": "Elenca solo le pagine nei namespace indicati.",
+ "apihelp-query+watchlistraw-param-limit": "Numero totale di risultati da restituire per ogni richiesta.",
+ "apihelp-query+watchlistraw-param-prop": "Quali proprietà aggiuntive ottenere:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Aggiunge data e ora dell'ultima notifica all'utente riguardo la modifica.",
+ "apihelp-query+watchlistraw-param-dir": "La direzione in cui elencare.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Il titolo (con prefisso namespace) da cui iniziare l'elenco.",
+ "apihelp-query+watchlistraw-param-totitle": "Il titolo (con prefisso namespace) al quale interrompere l'elenco.",
+ "apihelp-query+watchlistraw-example-simple": "Elenca le pagine fra gli osservati speciali dell'utente attuale.",
+ "apihelp-query+watchlistraw-example-generator": "Recupera le informazioni sulle pagine fra gli osservati speciali dell'utente attuale.",
+ "apihelp-removeauthenticationdata-summary": "Rimuove i dati di autenticazione per l'utente corrente.",
+ "apihelp-removeauthenticationdata-example-simple": "Tentativo di rimuovere gli attuali dati utente per <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "Invia una mail per reimpostare la password di un utente.",
+ "apihelp-resetpassword-extended-description-noroutes": "Non sono disponibili rotte per la reimpostazione della password.\n\nAbilita le rotte in <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var> per usare questo modulo.",
+ "apihelp-resetpassword-param-user": "Utente in corso di ripristino.",
+ "apihelp-resetpassword-param-email": "Indirizzo di posta elettronica dell'utente in corso di ripristino.",
+ "apihelp-resetpassword-example-user": "Invia una mail per reimpostare la password all'utente <kbd>Example</kbd>.",
+ "apihelp-resetpassword-example-email": "Invia una mail per reimpostare la password a tutti gli utenti con indirizzo di posta elettronica <kbd>user@example.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Cancella e ripristina le versioni.",
+ "apihelp-revisiondelete-param-type": "Tipo di cancellazione della versione effettuata.",
+ "apihelp-revisiondelete-param-hide": "Cosa nascondere per ogni versione.",
+ "apihelp-revisiondelete-param-show": "Cosa mostrare per ogni versione.",
+ "apihelp-revisiondelete-param-reason": "Motivo per l'eliminazione o il ripristino.",
+ "apihelp-setpagelanguage-summary": "Cambia la lingua di una pagina.",
+ "apihelp-setpagelanguage-extended-description-disabled": "La modifica della lingua di una pagina non è consentita su questo wiki.\n\nAttiva <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> per usare questa azione.",
+ "apihelp-setpagelanguage-param-reason": "Motivo per la modifica.",
+ "apihelp-stashedit-param-title": "Titolo della pagina che si sta modificando.",
+ "apihelp-stashedit-param-sectiontitle": "Il titolo per una nuova sezione.",
+ "apihelp-stashedit-param-text": "Contenuto della pagina.",
+ "apihelp-stashedit-param-contentmodel": "Modello di contenuto dei nuovi contenuti.",
+ "apihelp-stashedit-param-summary": "Oggetto della modifica.",
+ "apihelp-tag-param-reason": "Motivo per la modifica.",
+ "apihelp-tokens-param-type": "Tipi di token da richiedere.",
+ "apihelp-tokens-example-edit": "Recupera un token di modifica (il predefinito).",
+ "apihelp-unblock-summary": "Sblocca un utente",
+ "apihelp-unblock-param-user": "Nome utente, indirizzo IP o range di IP da sbloccare. Non può essere usato insieme a <var>$1id</var> o <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "ID utente da sbloccare. Non può essere usato insieme a <var>$1id</var> o <var>$1userid</var>.",
+ "apihelp-unblock-param-reason": "Motivo dello sblocco.",
+ "apihelp-unblock-param-tags": "Modifica etichette da applicare all'elemento del registro dei blocchi.",
+ "apihelp-undelete-summary": "Ripristina versioni di una pagina cancellata.",
+ "apihelp-undelete-param-title": "Titolo della pagina da ripristinare.",
+ "apihelp-undelete-param-reason": "Motivo per il ripristino.",
+ "apihelp-undelete-param-tags": "Modifica etichette da applicare all'elemento del registro delle cancellazioni.",
+ "apihelp-unlinkaccount-summary": "Rimuove un'utenza di terze parti collegata all'utente corrente.",
+ "apihelp-unlinkaccount-example-simple": "Tentativo di rimuovere il collegamento dell'utente corrente per il provider associato con <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-upload-param-watch": "Osserva la pagina.",
+ "apihelp-upload-param-file": "Contenuto del file.",
+ "apihelp-upload-example-url": "Carica da un URL.",
+ "apihelp-userrights-param-user": "Nome utente.",
+ "apihelp-userrights-param-userid": "ID utente.",
+ "apihelp-userrights-param-add": "Aggiungere l'utente a questi gruppi, o se sono già membri, aggiornare la scadenza della loro appartenenza a quel gruppo.",
+ "apihelp-userrights-param-remove": "Rimuovi l'utente da questi gruppi.",
+ "apihelp-userrights-param-reason": "Motivo del cambiamento.",
+ "apihelp-validatepassword-summary": "Convalida una password seguendo le politiche del wiki sulle password.",
+ "apihelp-validatepassword-extended-description": "La validità è riportata come <samp>Good</samp> se la password è accettabile, <samp>Change</samp> se la password può essere utilizzata per l'accesso ma deve essere modificata, o <samp>Invalid</samp> se la password non è utilizzabile.",
+ "apihelp-validatepassword-param-password": "Password da convalidare.",
+ "apihelp-validatepassword-example-1": "Convalidare la password <kbd>foobar</kbd> per l'attuale utente.",
+ "apihelp-validatepassword-example-2": "Convalida la password <kbd>qwerty</kbd> per la creazione dell'utente <kbd>Example</kbd>.",
+ "apihelp-watch-summary": "Aggiunge o rimuove pagine dagli osservati speciali dell'utente attuale.",
+ "apihelp-format-param-wrappedhtml": "Restituisce l'HTML ben formattato e i moduli ResourceLoader associati come un oggetto JSON.",
+ "api-pageset-param-titles": "Un elenco di titoli su cui lavorare.",
+ "api-pageset-param-pageids": "Un elenco di ID pagina su cui lavorare.",
+ "api-pageset-param-revids": "Un elenco di ID versioni su cui lavorare.",
+ "api-pageset-param-redirects-generator": "Risolvi automaticamente redirect in <var>$1titles</var>, <var>$1pageids</var>, e <var>$1revids</var>, e nelle pagine restituite da <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Risolve automaticamente i reindirizzamenti in <var>$1titles</var>, <var>$1pageids</var>, e <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Converte i titoli in altre varianti, se necessario. Funziona solo se la lingua del contenuto del wiki supporta la conversione in varianti. Le lingue che supportano la conversione in varianti includono $1",
+ "api-help-main-header": "Modulo principale",
+ "api-help-undocumented-module": "Nessuna documentazione per il modulo $1.",
+ "api-help-flag-deprecated": "Questo modulo è deprecato.",
+ "api-help-flag-internal": "<strong>Questo modulo è interno o instabile.</strong> Il suo funzionamento potrebbe variare senza preavviso.",
+ "api-help-flag-readrights": "Questo modulo richiede i diritti di lettura.",
+ "api-help-flag-writerights": "Questo modulo richiede i diritti di scrittura.",
+ "api-help-flag-mustbeposted": "Questo modulo accetta solo richieste POST.",
+ "api-help-flag-generator": "Questo modulo può essere utilizzato come generatore.",
+ "api-help-source": "Fonte: $1",
+ "api-help-source-unknown": "Fonte: <span class=\"apihelp-unknown\">sconosciuta</span>",
+ "api-help-license": "Licenza: [[$1|$2]]",
+ "api-help-license-noname": "Licenza: [[$1|Vedi collegamento]]",
+ "api-help-license-unknown": "Licenza: <span class=\"apihelp-unknown\">sconosciuta</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parametro|Parametri}}:",
+ "api-help-param-deprecated": "Deprecato.",
+ "api-help-param-required": "Questo parametro è obbligatorio.",
+ "api-help-datatypes-header": "Tipi di dato",
+ "api-help-param-type-limit": "Tipo: intero o <kbd>max</kbd>",
+ "api-help-param-type-integer": "Tipo: {{PLURAL:$1|1=intero|2=elenco di interi}}",
+ "api-help-param-type-boolean": "Tipo: booleano ([[Special:ApiHelp/main#main/datatypes|dettagli]])",
+ "api-help-param-type-timestamp": "Tipo: {{PLURAL:$1|1=timestamp|2=elenco di timestamp}} ([[Special:ApiHelp/main#main/datatypes|formati consentiti]])",
+ "api-help-param-type-user": "Tipo: {{PLURAL:$1|1=nome utente|2=elenco di nomi utente}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Uno dei seguenti valori|2=Valori (separati da <kbd>{{!}}</kbd> o [[Special:ApiHelp/main#main/datatypes|alternativa]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Deve essere vuoto|Può essere vuoto, o $2}}",
+ "api-help-param-limit": "Non più di $1 consentito.",
+ "api-help-param-limit2": "Non più di $1 ($2 per bot) consentito.",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=Il valore non deve essere inferiore|2=I valori non devono essere inferiori}} a $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Il valore non deve essere superiore|2=I valori non devono essere superiori}} a $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Il valore deve essere compreso|2=I valori devono essere compresi}} tra $2 e $3.",
+ "api-help-param-multi-separate": "Separa i valori con <kbd>|</kbd> o [[Special:ApiHelp/main#main/datatypes|alternativa]].",
+ "api-help-param-multi-max": "Il numero massimo di valori è {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} per i bot).",
+ "api-help-param-multi-max-simple": "Il numero massimo di valori è {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "Per specificare tutti i valori, utilizza <kbd>$1</kbd>.",
+ "api-help-param-default": "Predefinito: $1",
+ "api-help-param-default-empty": "Predefinito: <span class=\"apihelp-empty\">(vuoto)</span>",
+ "api-help-param-token": "Un token \"$1\" recuperato da [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-continue": "Quando più risultati sono disponibili, usa questo per continuare.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(nessuna descrizione)</span>",
+ "api-help-examples": "{{PLURAL:$1|Esempio|Esempi}}:",
+ "api-help-permissions": "{{PLURAL:$1|Permesso|Permessi}}:",
+ "api-help-open-in-apisandbox": "<small>[apri in una sandbox]</small>",
+ "api-help-authmanager-general-usage": "La procedura generale per usare questo modulo è:\n# Ottenere i campi disponibili da <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> con <kbd>amirequestsfor=$4</kbd>, e un token <kbd>$5</kbd> da <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# Mostra i campi all'utente e ottieni i dati che invia.\n# Esegui un post a questo modulo, fornendo <var>$1returnurl</var> e ogni campo rilevante.\n# Controlla <samp>status</samp> nella response.\n#* Se hai ricevuto <samp>PASS</samp> o <samp>FAIL</samp>, hai finito. L'operazione nel primo caso è andata a buon fine, nel secondo no.\n#* Se hai ricevuto <samp>UI</samp>, mostra i nuovi campi all'utente e ottieni i dati che invia. Esegui un post a questo modulo con <var>$1continue</var> e i campi rilevanti settati, quindi ripeti il punto 4.\n#* Se hai ricevuto <samp>REDIRECT</samp>, dirigi l'utente a <samp>redirecttarget</samp> e aspetta che ritorni a <var>$1returnurl</var>. A quel punto esegui un post a questo modulo con <var>$1continue</var> e ogni campo passato all'URL di ritorno, e ripeti il punto 4.\n#* Se hai ricevuto <samp>RESTART</samp>, vuol dire che l'autenticazione ha funzionato ma non abbiamo un account collegato. Potresti considerare questo caso come <samp>UI</samp> o come <samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-messageformat": "Formato da utilizzare per per la restituzione dei messaggi.",
+ "api-help-authmanagerhelper-preservestate": "Conserva lo stato da un precedente tentativo di accesso non riuscito, se possibile.",
+ "api-help-authmanagerhelper-returnurl": "URL di ritorno per i flussi di autenticazione di terze parti, deve essere assoluto. E' necessario fornirlo, oppure va fornito <var>$1continue</var>.\n\nAlla ricezione di una risposta <samp>REDIRECT</samp>, in genere si apre un browser o una vista web all'URL specificato <samp>redirecttarget</samp> per un flusso di autenticazione di terze parti. Quando questo è completato, la terza parte invierà il browser o la vista web a questo URL. Dovresti estrarre qualsiasi parametro POST o della richiesta dall'URL e passarli come un request <var>$1continue</var> a questo modulo API.",
+ "api-help-authmanagerhelper-continue": "Questa richiesta è una continuazione dopo una precedente risposta <samp>UI</samp> o <samp>REDIRECT</samp>. È necessario fornirlo, oppure fornire <var>$1returnurl</var>.",
+ "api-help-authmanagerhelper-additional-params": "Questo modulo accetta parametri aggiuntivi a seconda delle richieste di autenticazione disponibili. Utilizza <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> con <kbd>amirequestsfor=$1</kbd> (o una precedente risposta da questo modulo, se applicabile) per determinare le richieste disponibili e i campi usati da queste.",
+ "apierror-invalidoldimage": "Il parametro <var>oldimage</var> ha un formato non valido.",
+ "apierror-invaliduserid": "L'ID utente <var>$1</var> non è valido.",
+ "apierror-nosuchuserid": "Non c'è alcun utente con ID $1.",
+ "apierror-timeout": "Il server non ha risposto entro il tempo previsto.",
+ "api-credits-header": "Crediti"
+}
diff --git a/www/wiki/includes/api/i18n/ja.json b/www/wiki/includes/api/i18n/ja.json
new file mode 100644
index 00000000..e637be39
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ja.json
@@ -0,0 +1,968 @@
+{
+ "@metadata": {
+ "authors": [
+ "Shirayuki",
+ "2nd-player",
+ "Los688",
+ "Whym",
+ "Mfuji",
+ "Otokoume",
+ "Sujiniku",
+ "Macofe",
+ "Suchichi02",
+ "Kkairri",
+ "ネイ"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|説明文書]]\n* [[mw:API:FAQ|よくある質問]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api メーリングリスト]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API 告知]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R バグの報告とリクエスト]\n</div>\n<strong>状態:</strong> このページに表示されている機能は全て動作するはずですが、この API は未だ活発に開発されており、変更される可能性があります。アップデートの通知を受け取るには、[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce メーリングリスト]に参加してください。\n\n<strong>誤ったリクエスト:</strong> 誤ったリクエストが API に送られた場合、\"MediaWiki-API-Error\" HTTP ヘッダーが送信され、そのヘッダーの値と送り返されるエラーコードは同じ値にセットされます。より詳しい情報は [[mw:API:Errors_and_warnings|API: Errors and warnings]] を参照してください。\n\n<strong>テスト:</strong> API のリクエストのテストは、[[Special:ApiSandbox]]で簡単に行えます。",
+ "apihelp-main-param-action": "実行する操作です。",
+ "apihelp-main-param-format": "出力する形式です。",
+ "apihelp-main-param-smaxage": "<code>s-maxage</code> HTTP キャッシュ コントロール ヘッダー に、この秒数を設定します。エラーがキャッシュされることはありません。",
+ "apihelp-main-param-maxage": "<code>max-age</code> HTTP キャッシュ コントロール ヘッダー に、この秒数を設定します。エラーがキャッシュされることはありません。",
+ "apihelp-main-param-assert": "<kbd>user</kbd> を設定した場合は利用者がログイン済みかどうかを、<kbd>bot</kbd> を指定した場合はボット権限があるかどうかを、それぞれ検証します。",
+ "apihelp-main-param-requestid": "任意の値を指定でき、その値が結果に含められます。リクエストを識別するために使用できます。",
+ "apihelp-main-param-servedby": "リクエストを処理したホスト名を結果に含めます。",
+ "apihelp-main-param-curtimestamp": "現在のタイムスタンプを結果に含めます。",
+ "apihelp-main-param-uselang": "メッセージの翻訳に使用する言語です。<kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> は <kbd>siprop=languages</kbd> を付けると言語コードの一覧を返します。<kbd>user</kbd> を指定することで現在の利用者の個人設定の言語を、<kbd>content</kbd> を指定することでこのウィキの本文の言語を使用することもできます。",
+ "apihelp-block-summary": "利用者をブロックします。",
+ "apihelp-block-param-user": "ブロックを解除する利用者名、IPアドレスまたはIPレンジ。<var>$1userid</var>とは同時に使用できません。",
+ "apihelp-block-param-userid": "ブロックする利用者のID。<var>$1user</var>とは同時に使用できません。",
+ "apihelp-block-param-expiry": "有効期限。相対的 (例: <kbd>5 months</kbd> または <kbd>2 weeks</kbd>) または絶対的 (e.g. <kbd>2014-09-18T12:34:56Z</kbd>) どちらでも構いません。<kbd>infinite</kbd>, <kbd>indefinite</kbd>, もしくは <kbd>never</kbd> と設定した場合, 無期限ブロックとなります。",
+ "apihelp-block-param-reason": "ブロックの理由。",
+ "apihelp-block-param-anononly": "匿名利用者のみブロックします(つまり、このIPアドレスからの匿名での編集を不可能にします)。",
+ "apihelp-block-param-nocreate": "アカウントの作成を禁止します。",
+ "apihelp-block-param-autoblock": "その利用者が最後に使用したIPアドレスと、ブロック後に編集を試みた際のIPアドレスを自動的にブロックします。",
+ "apihelp-block-param-noemail": "Wikiを通して電子メールを送信することを禁止します。(<code>blockemail</code> 権限が必要です)",
+ "apihelp-block-param-hidename": "ブロック記録から利用者名を秘匿します。(<code>hideuser</code> 権限が必要です)",
+ "apihelp-block-param-allowusertalk": "自身のトークページの編集を許可する (<var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var> に依存)。",
+ "apihelp-block-param-reblock": "その利用者がすでにブロックされている場合、ブロックを上書きします。",
+ "apihelp-block-param-watchuser": "その利用者またはIPアドレスの利用者ページとトークページをウォッチします。",
+ "apihelp-block-example-ip-simple": "IPアドレス <kbd>192.0.2.5</kbd> を <kbd>First strike<kbd> という理由で3日ブロックする",
+ "apihelp-block-example-user-complex": "利用者 <kbd>Vandal</kbd> を <kbd>Vandalism</kbd> という理由で無期限ブロックし、新たなアカウント作成とメールの送信を禁止する。",
+ "apihelp-changeauthenticationdata-example-password": "現在の利用者のパスワードを <kbd>ExamplePassword</kbd> に変更する。",
+ "apihelp-checktoken-summary": "<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> のトークンの妥当性を確認します。",
+ "apihelp-checktoken-param-type": "調べるトークンの種類。",
+ "apihelp-checktoken-param-token": "調べるトークン。",
+ "apihelp-checktoken-example-simple": "<kbd>csrf</kbd> トークンの妥当性を調べる。",
+ "apihelp-clearhasmsg-summary": "現在の利用者の <code>hasmsg</code> フラグを消去します。",
+ "apihelp-clearhasmsg-example-1": "現在の利用者の <code>hasmsg</code> フラグを消去する。",
+ "apihelp-clientlogin-example-login": "利用者 <kbd>Example</kbd> としてのログイン処理をパスワード <kbd>ExamplePassword</kbd> で開始する",
+ "apihelp-compare-summary": "2つの版間の差分を取得します。",
+ "apihelp-compare-extended-description": "\"from\" と \"to\" の両方の版番号、ページ名、もしくはページIDを渡す必要があります。",
+ "apihelp-compare-param-fromtitle": "比較する1つ目のページ名。",
+ "apihelp-compare-param-fromid": "比較する1つ目のページID。",
+ "apihelp-compare-param-fromrev": "比較する1つ目の版。",
+ "apihelp-compare-param-totitle": "比較する2つ目のページ名。",
+ "apihelp-compare-param-toid": "比較する2つ目のページID。",
+ "apihelp-compare-param-torev": "比較する2つ目の版。",
+ "apihelp-compare-example-1": "版1と2の差分を生成する。",
+ "apihelp-createaccount-summary": "新しい利用者アカウントを作成します。",
+ "apihelp-createaccount-param-name": "利用者名。",
+ "apihelp-createaccount-param-password": "パスワード (<var>$1mailpassword</var> が設定されると無視されます)。",
+ "apihelp-createaccount-param-domain": "外部認証のドメイン (省略可能)。",
+ "apihelp-createaccount-param-token": "最初のリクエストで得られたアカウント作成用トークンです。",
+ "apihelp-createaccount-param-email": "利用者の電子メールアドレス (任意)。",
+ "apihelp-createaccount-param-realname": "利用者の本名 (省略可能)。",
+ "apihelp-createaccount-param-mailpassword": "設定されると (その値を問わず)、無作為なパスワードがその利用者に電子メールで送られます。",
+ "apihelp-createaccount-param-reason": "ログに記録されるアカウント作成の理由 (任意)。",
+ "apihelp-createaccount-param-language": "利用者の言語コードの既定値 (省略可能, 既定ではコンテンツ言語)。",
+ "apihelp-createaccount-example-pass": "利用者 <kbd>testuser</kbd> をパスワード <kbd>test123</kbd> として作成する。",
+ "apihelp-createaccount-example-mail": "利用者 <kbd>testmailuser</kbd>を作成し、無作為に生成されたパスワードをメールで送る。",
+ "apihelp-delete-summary": "ページを削除します。",
+ "apihelp-delete-param-title": "削除するページ名です。<var>$1pageid</var> とは同時に使用できません。",
+ "apihelp-delete-param-pageid": "削除するページIDです。<var>$1title</var> とは同時に使用できません。",
+ "apihelp-delete-param-reason": "削除の理由です。入力しない場合、自動的に生成された理由が使用されます。",
+ "apihelp-delete-param-tags": "タグを変更し、削除記録の項目に適用します。",
+ "apihelp-delete-param-watch": "そのページを現在の利用者のウォッチリストに追加します。",
+ "apihelp-delete-param-unwatch": "そのページを現在の利用者のウォッチリストから除去します。",
+ "apihelp-delete-param-oldimage": "削除する古い画像の[[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]] で取得できるような名前。",
+ "apihelp-delete-example-simple": "<kbd>Main Page</kbd> を削除する",
+ "apihelp-delete-example-reason": "<kbd>Preparing for move</kbd> という理由で <kbd>Main Page</kbd> を削除する",
+ "apihelp-disabled-summary": "このモジュールは無効化されています。",
+ "apihelp-edit-summary": "ページを作成、編集します。",
+ "apihelp-edit-param-title": "編集するページ名です。<var>$1pageid</var> とは同時に使用できません。",
+ "apihelp-edit-param-pageid": "編集するページIDです。<var>$1title</var> とは同時に使用できません。",
+ "apihelp-edit-param-section": "節番号です。先頭の節の場合は <kbd>0</kbd>、新しい節の場合は <kbd>new</kbd>を指定します。",
+ "apihelp-edit-param-sectiontitle": "新しい節の名前です。",
+ "apihelp-edit-param-text": "ページの本文。",
+ "apihelp-edit-param-summary": "編集の要約。$1section=new で $1sectiontitle が設定されていない場合は節名としても利用されます。",
+ "apihelp-edit-param-tags": "この版に適用する変更タグ。",
+ "apihelp-edit-param-minor": "細部の編集",
+ "apihelp-edit-param-notminor": "細部の編集ではない。",
+ "apihelp-edit-param-bot": "この編集をボットの編集としてマークする。",
+ "apihelp-edit-param-basetimestamp": "編集前の版のタイムスタンプ。編集競合を検出するために使用されます。\n[[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]] で取得できます。",
+ "apihelp-edit-param-starttimestamp": "編集作業を開始したときのタイムスタンプ。編集競合を検出するために使用されます。適切な値は <var>[[Special:ApiHelp/main|curtimestamp]]</var> を使用して編集作業を開始するとき (たとえば、編集するページの本文を読み込んだとき) に取得できます。",
+ "apihelp-edit-param-createonly": "すでにそのページが存在する場合は編集を行いません。",
+ "apihelp-edit-param-nocreate": "そのページが存在しない場合にエラーを返します。",
+ "apihelp-edit-param-watch": "そのページを現在の利用者のウォッチリストに追加します。",
+ "apihelp-edit-param-unwatch": "そのページを現在の利用者のウォッチリストから除去します。",
+ "apihelp-edit-param-prependtext": "このテキストをページの先頭に追加します。$1text をオーバーライドします。",
+ "apihelp-edit-param-appendtext": "このテキストをページの末尾に追加する。$1textを上書きします。\n\n新しい節を追加するにはこのパラメータではなく $1section=newを使用してください。",
+ "apihelp-edit-param-undo": "この版を取り消します。$1text, $1prependtext および $1appendtext をオーバーライドします。",
+ "apihelp-edit-param-undoafter": "$1undo からこの版までのすべての版を取り消します。設定しない場合、ひとつの版のみ取り消されます。",
+ "apihelp-edit-param-redirect": "自動的に転送を解決します。",
+ "apihelp-edit-param-token": "このトークンは常に最後のパラメーターとして、または少なくとも $1text パラメーターより後に送信されるべきです。",
+ "apihelp-edit-example-edit": "ページを編集",
+ "apihelp-edit-example-prepend": "<kbd>_&#95;NOTOC_&#95;</kbd> をページの先頭に挿入する。",
+ "apihelp-edit-example-undo": "版 13579 から 13585 まで要約を自動入力して取り消す。",
+ "apihelp-emailuser-summary": "利用者に電子メールを送信します。",
+ "apihelp-emailuser-param-target": "送信先の利用者名。",
+ "apihelp-emailuser-param-subject": "題名。",
+ "apihelp-emailuser-param-text": "電子メールの本文。",
+ "apihelp-emailuser-param-ccme": "電子メールの複製を自分にも送信します。",
+ "apihelp-emailuser-example-email": "利用者 <kbd>WikiSysop</kbd> に <kbd>Content</kbd> という本文の電子メールを送信。",
+ "apihelp-expandtemplates-summary": "ウィキテキストに含まれるすべてのテンプレートを展開します。",
+ "apihelp-expandtemplates-param-title": "ページの名前です。",
+ "apihelp-expandtemplates-param-text": "変換するウィキテキストです。",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "展開されたウィキテキスト。",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "入力のXML構文解析ツリー。",
+ "apihelp-expandtemplates-param-includecomments": "HTMLコメントを出力に含めるかどうか。",
+ "apihelp-expandtemplates-param-generatexml": "XMLの構文解析ツリーを生成します (replaced by $1prop=parsetree)",
+ "apihelp-expandtemplates-example-simple": "ウィキテキスト <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd> を展開する。",
+ "apihelp-feedcontributions-summary": "利用者の投稿記録フィードを返します。",
+ "apihelp-feedcontributions-param-feedformat": "フィードの形式。",
+ "apihelp-feedcontributions-param-user": "投稿記録を取得する利用者。",
+ "apihelp-feedcontributions-param-namespace": "この名前空間への投稿記録に絞り込む。",
+ "apihelp-feedcontributions-param-year": "この年以前。",
+ "apihelp-feedcontributions-param-month": "この月以前。",
+ "apihelp-feedcontributions-param-tagfilter": "このタグが付与された投稿記録に絞り込む。",
+ "apihelp-feedcontributions-param-deletedonly": "削除された投稿記録のみ表示します。",
+ "apihelp-feedcontributions-param-toponly": "最新版の編集のみ表示します。",
+ "apihelp-feedcontributions-param-newonly": "ページ作成を伴う編集のみを表示します。",
+ "apihelp-feedcontributions-param-hideminor": "細部の編集を非表示",
+ "apihelp-feedcontributions-param-showsizediff": "版間のサイズの増減を表示する。",
+ "apihelp-feedcontributions-example-simple": "利用者 <kbd>Example</kbd> の投稿記録を取得する。",
+ "apihelp-feedrecentchanges-summary": "最近の更新フィードを返します。",
+ "apihelp-feedrecentchanges-param-feedformat": "フィードの形式。",
+ "apihelp-feedrecentchanges-param-namespace": "この名前空間の結果のみに絞り込む。",
+ "apihelp-feedrecentchanges-param-invert": "選択されたものを除く、すべての名前空間。",
+ "apihelp-feedrecentchanges-param-associated": "関連する(トークまたはメイン)名前空間を含めます。",
+ "apihelp-feedrecentchanges-param-limit": "返す結果の最大数。",
+ "apihelp-feedrecentchanges-param-from": "これ以降の編集を表示する。",
+ "apihelp-feedrecentchanges-param-hideminor": "細部の変更を隠す。",
+ "apihelp-feedrecentchanges-param-hidebots": "ボットによる変更を隠す。",
+ "apihelp-feedrecentchanges-param-hideanons": "未登録利用者による変更を隠す。",
+ "apihelp-feedrecentchanges-param-hideliu": "登録利用者による変更を隠す。",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "巡回済みの変更を隠す。",
+ "apihelp-feedrecentchanges-param-hidemyself": "現在の利用者による編集を非表示にする。",
+ "apihelp-feedrecentchanges-param-tagfilter": "タグにより絞り込む。",
+ "apihelp-feedrecentchanges-param-target": "このページからリンクされているページの変更のみを表示する。",
+ "apihelp-feedrecentchanges-example-simple": "最近の更新を表示する。",
+ "apihelp-feedrecentchanges-example-30days": "最近30日間の変更を表示する。",
+ "apihelp-feedwatchlist-summary": "ウォッチリストのフィードを返します。",
+ "apihelp-feedwatchlist-param-feedformat": "フィードの形式。",
+ "apihelp-feedwatchlist-param-linktosections": "可能であれば、変更された節に直接リンクする。",
+ "apihelp-feedwatchlist-example-default": "ウォッチリストのフィードを表示する。",
+ "apihelp-feedwatchlist-example-all6hrs": "ウォッチ中のページに対する過去6時間の更新をすべて表示する。",
+ "apihelp-filerevert-summary": "ファイルを古い版に差し戻します。",
+ "apihelp-filerevert-param-filename": "対象のファイル名 (File: 接頭辞を含めない)。",
+ "apihelp-filerevert-param-comment": "アップロードのコメント。",
+ "apihelp-filerevert-example-revert": "<kbd>Wiki.png</kbd> を <kbd>2011-03-05T15:27:40Z</kbd> の版に差し戻す。",
+ "apihelp-help-summary": "指定したモジュールのヘルプを表示します。",
+ "apihelp-help-param-modules": "ヘルプを表示するモジュールです (<var>action</var> パラメーターおよび <var>format</var> パラメーターの値、または <kbd>main</kbd>)。<kbd>+</kbd> を使用して下位モジュールを指定できます。",
+ "apihelp-help-param-submodules": "指定したモジュールの下位モジュールのヘルプを含めます。",
+ "apihelp-help-param-recursivesubmodules": "下位モジュールのヘルプを再帰的に含めます。",
+ "apihelp-help-param-helpformat": "ヘルプの出力形式です。",
+ "apihelp-help-param-toc": "HTML 出力に目次を含めます。",
+ "apihelp-help-example-main": "メイン モジュールのヘルプ",
+ "apihelp-help-example-submodules": "<kbd>action=query</kbd> とそのすべての下位モジュールに関するヘルプ。",
+ "apihelp-help-example-recursive": "すべてのヘルプを1つのページに",
+ "apihelp-help-example-help": "ヘルプ モジュール自身のヘルプ",
+ "apihelp-help-example-query": "2つの下位モジュールのヘルプ",
+ "apihelp-imagerotate-summary": "1つ以上の画像を回転させます。",
+ "apihelp-imagerotate-param-rotation": "画像を回転させる時計回りの角度。",
+ "apihelp-imagerotate-example-simple": "<kbd>File:Example.png</kbd> を <kbd>90</kbd> 度回転させる。",
+ "apihelp-imagerotate-example-generator": "<kbd>Category:Flip</kbd> 内のすべての画像を <kbd>180</kbd> 度回転させる。",
+ "apihelp-import-summary": "他のWikiまたはXMLファイルからページを取り込む。",
+ "apihelp-import-extended-description": "<var>xml</var> パラメーターでファイルを送信する場合、ファイルのアップロードとしてHTTP POSTされなければならない (例えば、multipart/form-dataを使用する) 点に注意してください。",
+ "apihelp-import-param-summary": "記録されるページ取り込みの要約。",
+ "apihelp-import-param-xml": "XMLファイルをアップロード",
+ "apihelp-import-param-interwikisource": "ウィキ間の取り込みの場合: 取り込み元のウィキ。",
+ "apihelp-import-param-interwikipage": "ウィキ間の取り込みの場合: 取り込むページ。",
+ "apihelp-import-param-fullhistory": "ウィキ間の取り込みの場合: 現在の版のみではなく完全な履歴を取り込む。",
+ "apihelp-import-param-templates": "ウィキ間の取り込みの場合: 読み込まれているテンプレートも取り込む。",
+ "apihelp-import-param-namespace": "この名前空間に取り込む。<var>$1rootpage</var>パラメータとは同時に使用できません。",
+ "apihelp-import-param-rootpage": "このページの下位ページとして取り込む。<var>$1namespace</var> パラメータとは同時に使用できません。",
+ "apihelp-import-example-import": "[[meta:Help:ParserFunctions]] をすべての履歴とともに名前空間100に取り込む。",
+ "apihelp-login-summary": "ログインして認証クッキーを取得します。",
+ "apihelp-login-extended-description": "ログインが成功した場合、必要なクッキーは HTTP 応答ヘッダに含まれます。ログインに失敗した場合、自動化のパスワード推定攻撃を制限するために、追加の試行は速度制限されることがあります。",
+ "apihelp-login-param-name": "利用者名。",
+ "apihelp-login-param-password": "パスワード。",
+ "apihelp-login-param-domain": "ドメイン (省略可能)",
+ "apihelp-login-param-token": "最初のリクエストで取得したログイントークンです。",
+ "apihelp-login-example-gettoken": "ログイントークンを取得する。",
+ "apihelp-login-example-login": "ログイン",
+ "apihelp-logout-summary": "ログアウトしてセッションデータを消去します。",
+ "apihelp-logout-example-logout": "現在の利用者をログアウトする。",
+ "apihelp-managetags-param-operation": "実行する操作:\n;create: 手動適用のための新たな変更タグを作成します。\n;delete: 変更タグをデータベースから削除し、そのタグが使用されているすべての版、最近の更新項目、記録項目からそれを除去します。\n;activate: 変更タグを有効化し、利用者がそのタグを手動で適用できるようにします。\n;deactivate: 変更タグを無効化し、利用者がそのタグを手動で適用することができないようにします。",
+ "apihelp-managetags-param-tag": "作成、削除、有効化、または無効化するタグ。タグの作成の場合、そのタグは存在しないものでなければなりません。タグの削除の場合、そのタグが存在しなければなりません。タグの有効化の場合、そのタグが存在し、かつ拡張機能によって使用されていないものでなければなりません。タグの無効化の場合、そのタグが現在有効であって手動で定義されたものでなければなりません。",
+ "apihelp-managetags-param-reason": "タグを作成、削除、有効化、または無効化する追加の理由。",
+ "apihelp-managetags-param-ignorewarnings": "操作中に発生したすべての警告を無視するかどうか。",
+ "apihelp-managetags-example-create": "<kbd>spam</kbd> という名前のタグを <kbd>For use in edit patrolling</kbd> という理由で作成する",
+ "apihelp-managetags-example-delete": "<kbd>vandlaism</kbd> タグを <kbd>Misspelt</kbd> という理由で削除する",
+ "apihelp-managetags-example-activate": "<kbd>spam</kbd> という名前のタグを <kbd>For use in edit patrolling</kbd> という理由で有効化する",
+ "apihelp-managetags-example-deactivate": "<kbd>No longer required</kbd> という理由でタグ <kbd>spam</kbd> を無効化する",
+ "apihelp-mergehistory-summary": "ページの履歴を統合する。",
+ "apihelp-mergehistory-param-from": "履歴統合元のページ名。<var>$1fromid</var> とは同時に使用できません。",
+ "apihelp-mergehistory-param-fromid": "履歴統合元のページ。<var>$1from</var> とは同時に使用できません。",
+ "apihelp-mergehistory-param-to": "履歴統合先のページ名。<var>$1toid</var> とは同時に使用できません。",
+ "apihelp-mergehistory-param-toid": "履歴統合先のページID。<var>$1to</var> とは同時に使用できません。",
+ "apihelp-mergehistory-param-reason": "履歴の統合の理由。",
+ "apihelp-mergehistory-example-merge": "<kbd>Oldpage</kbd> のすべての履歴を <kbd>Newpage</kbd> に統合する。",
+ "apihelp-move-summary": "ページを移動します。",
+ "apihelp-move-param-from": "移動するページのページ名です。<var>$1fromid</var> とは同時に使用できません。",
+ "apihelp-move-param-fromid": "移動するページのページIDです。<var>$1from</var> とは同時に使用できません。",
+ "apihelp-move-param-to": "移動後のページ名。",
+ "apihelp-move-param-reason": "改名の理由。",
+ "apihelp-move-param-movetalk": "存在する場合、トークページも名前を変更します。",
+ "apihelp-move-param-movesubpages": "可能であれば、下位ページも名前を変更します。",
+ "apihelp-move-param-noredirect": "転送ページを作成しません。",
+ "apihelp-move-param-watch": "そのページと転送ページを現在の利用者のウォッチリストに追加します。",
+ "apihelp-move-param-unwatch": "そのページと転送ページを現在の利用者のウォッチリストから除去します。",
+ "apihelp-move-param-ignorewarnings": "あらゆる警告を無視",
+ "apihelp-move-example-move": "<kbd>Badtitle</kbd> を <kbd>Goodtitle</kbd> に転送ページを残さず移動",
+ "apihelp-opensearch-summary": "OpenSearch プロトコルを使用してWiki内を検索します。",
+ "apihelp-opensearch-param-search": "検索文字列。",
+ "apihelp-opensearch-param-limit": "返す結果の最大数。",
+ "apihelp-opensearch-param-namespace": "検索する名前空間。",
+ "apihelp-opensearch-param-suggest": "<var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> が false の場合、何もしません。",
+ "apihelp-opensearch-param-redirects": "転送を処理する方法:\n;return: 転送ページそのものを返します。\n;resolve: 転送先のページを返します。$1limit より返される結果が少なくなるかもしれません。\n歴史的な理由により、$1format=json では \"return\" が、他の形式では \"resolve\" が既定です。",
+ "apihelp-opensearch-param-format": "出力する形式。",
+ "apihelp-opensearch-example-te": "<kbd>Te</kbd> から始まるページを検索する。",
+ "apihelp-options-param-reset": "個人設定をサイトの既定値にリセットする。",
+ "apihelp-options-param-resetkinds": "<var>$1reset</var> が設定されている場合、リセットする設定項目の種類のリスト。",
+ "apihelp-options-param-change": "名前=値 の形式 (例えば skin=vector) で整形された変更のリスト。optionname|otheroption|... のように値が与えられなかった (イコール記号すら無い) 場合、設定は既定値にリセットされます。与えられた値がパイプ(<kbd>|</kbd>)を含む場合、[[Special:ApiHelp/main#main/datatypes|ほかのセパレーター]]をお使いください。",
+ "apihelp-options-example-reset": "すべて初期設定に戻す。",
+ "apihelp-options-example-change": "<kbd>skin</kbd> および <kbd>hideminor</kbd> の個人設定を変更する。",
+ "apihelp-options-example-complex": "すべての個人設定を初期化し、<kbd>skin</kbd> および <kbd> nickname </kbd> を設定する。",
+ "apihelp-paraminfo-summary": "API モジュールに関する情報を取得します。",
+ "apihelp-paraminfo-param-modules": "モジュールの名前のリスト (<var>action</var> および <var>format</var> パラメーターの値, または <kbd>main</kbd>). <kbd>+</kbd> を使用して下位モジュールを指定できます。",
+ "apihelp-paraminfo-param-helpformat": "ヘルプ文字列の形式。",
+ "apihelp-paraminfo-param-querymodules": "クエリモジュール名のリスト (<var>prop</var>, <var>meta</var> or <var>list</var> パラメータの値)。<kbd>$1querymodules=foo</kbd> の代わりに <kbd>$1modules=query+foo</kbd> を使用してください。",
+ "apihelp-paraminfo-example-1": "<kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>, and <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> に関する情報を表示する。",
+ "apihelp-parse-param-title": "文字列が属するページのページ名。これを省略する場合、必ず <var>$1contentmodel</var> を指定しなければなりません。また、その場合 [[API]] がページ名として使用されます。",
+ "apihelp-parse-param-text": "構文解析する文字列。コンテンツ・モデルを制御するためには<var>$1title</var> または <var>$1contentmodel</var> を使用してください。",
+ "apihelp-parse-param-summary": "構文解析のための要約",
+ "apihelp-parse-param-page": "このページの内容を構文解析します。<var>$1text</var> および <var>$1title</var> とは同時に使用できません。",
+ "apihelp-parse-param-pageid": "このページの内容を構文解析する。<var>$1page</var> をオーバーライドします。",
+ "apihelp-parse-param-redirects": "もし <var>$1page</var> や <var>$1pageid</var> に転送ページが指定された場合、それを解決する。",
+ "apihelp-parse-param-oldid": "この版の内容を構文解析する。<var>$1page</var> および <var>$1pageid</var> をオーバーライドします。",
+ "apihelp-parse-param-prop": "どの情報を取得するか:",
+ "apihelp-parse-paramvalue-prop-text": "ウィキテキストの解析されたテキストを提供します。",
+ "apihelp-parse-paramvalue-prop-langlinks": "解析されたウィキテキストにおける言語リンクを提供します。",
+ "apihelp-parse-paramvalue-prop-categories": "構文解析されたウィキテキストのカテゴリを提供します。",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "カテゴリのHTMLバージョンを提供します。",
+ "apihelp-parse-paramvalue-prop-links": "構文解析されたウィキテキスト内で内部リンクを提供します。",
+ "apihelp-parse-paramvalue-prop-templates": "構文解析されたウィキテキストでテンプレートを提供します。",
+ "apihelp-parse-paramvalue-prop-images": "構文解析されたウィキテキストの画像を提供します。",
+ "apihelp-parse-paramvalue-prop-externallinks": "構文解析されたウィキテキスト内で外部リンクを提供します。",
+ "apihelp-parse-paramvalue-prop-sections": "構文解析されたウィキテキスト内のセクションを提供します。",
+ "apihelp-parse-paramvalue-prop-revid": "構文解析されたページの版IDを追加します。",
+ "apihelp-parse-paramvalue-prop-displaytitle": "構文解析されたウィキテキストのタイトルを追加します。",
+ "apihelp-parse-paramvalue-prop-headitems": "ページの <code>&lt;head&gt;</code> の中に入れてアイテムを提供します。",
+ "apihelp-parse-paramvalue-prop-headhtml": "ページの解析された <code>&lt;head&gt;</code> を与える。",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "ページに固有のJavaScriptの設定変数を提供します。",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "JSON文字列としてページに固有のJavaScriptの設定変数を提供します。",
+ "apihelp-parse-paramvalue-prop-indicators": "ページ上で使用されるページのステータスインジケータのHTMLを提供します。",
+ "apihelp-parse-paramvalue-prop-iwlinks": "構文解析されたウィキテキスト内でウィキ間リンクを提供します。",
+ "apihelp-parse-paramvalue-prop-wikitext": "構文解析されたオリジナルのwikiテキストを提供します。",
+ "apihelp-parse-paramvalue-prop-properties": "構文解析されたウィキテキスト内で定義されたさまざまなプロパティを提供します。",
+ "apihelp-parse-paramvalue-prop-parsetree": "版内容のXML構文解析ツリー (requires content model <code>$1</code>)",
+ "apihelp-parse-param-pst": "それを構文解析する前に、入力の上で事前保存の変換を実行してください。テキストで使用した場合のみ有効です。",
+ "apihelp-parse-param-effectivelanglinks": "エクステンションによって供給された言語リンクが含まれています (for use with <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "この節番号の内容のみを構文解析します。\n\n<kbd>new</kbd> のとき、ページに新しい節を追加するかのように <var>$1text</var> と<var>$1sectiontitle</var> を解析します。\n\n<kbd>new</kbd> は <var>text</var> を指定したときのみ許可されます。",
+ "apihelp-parse-param-sectiontitle": "<var>section</var> が <kbd>new</kbd> のときの、新しい節の節名。\n\nページ編集とは異なり、これは <var>summary</var> が省略または空のときにはフォールバックしません。",
+ "apihelp-parse-param-disablelimitreport": "構文解析の出力で制限レポート (New PP limit report) を省略する。",
+ "apihelp-parse-param-disablepp": "<var>$1disablelimitreport</var> を代わりに使用してください。",
+ "apihelp-parse-param-disableeditsection": "構文解析の出力で節リンクを省略する。",
+ "apihelp-parse-param-disabletidy": "構文解析の出力にHTMLのクリーンアップ (例えば整頓) を適用しない。",
+ "apihelp-parse-param-preview": "プレビューモードでのパース",
+ "apihelp-parse-example-page": "ページをパース",
+ "apihelp-parse-example-text": "ウィキテキストをパース",
+ "apihelp-parse-example-summary": "要約を構文解析します。",
+ "apihelp-patrol-summary": "ページまたは版を巡回済みにする。",
+ "apihelp-patrol-param-rcid": "巡回済みにする最近の更新ID。",
+ "apihelp-patrol-param-revid": "巡回済みにする版ID。",
+ "apihelp-patrol-param-tags": "巡回記録の項目に適用する変更タグ。",
+ "apihelp-patrol-example-rcid": "最近の更新を巡回",
+ "apihelp-patrol-example-revid": "版を巡回済みにする。",
+ "apihelp-protect-summary": "ページの保護レベルを変更します。",
+ "apihelp-protect-param-title": "保護(解除)するページ名です。$1pageid とは同時に使用できません。",
+ "apihelp-protect-param-pageid": "保護(解除)するページIDです。$1title とは同時に使用できません。",
+ "apihelp-protect-param-protections": "<kbd>action=level</kbd> の形式 (例えば、<kbd>edit=sysop</kbd>) で整形された、保護レベルの一覧。レベル <kbd>all</kbd> は誰もが操作できる、言い換えると制限が掛かっていないことを意味します。\n\n<strong>注意: </strong> ここに列挙されなかった操作の制限は解除されます。",
+ "apihelp-protect-param-expiry": "有効期限です。タイムスタンプがひとつだけ指定された場合は、それがすべての保護に適用されます。無期限の保護を行う場合は<kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, または <kbd>never</kbd> を指定します。",
+ "apihelp-protect-param-reason": "保護(解除)の理由。",
+ "apihelp-protect-param-tags": "保護記録の項目に適用する変更タグ。",
+ "apihelp-protect-param-watch": "指定されると、保護(解除)するページが現在の利用者のウォッチリストに追加されます。",
+ "apihelp-protect-example-protect": "ページを保護する。",
+ "apihelp-protect-example-unprotect": "制限値を <kbd>all</kbd> にしてページの保護を解除する(つまり、誰もが操作できるようになる)\n。",
+ "apihelp-protect-example-unprotect2": "制限を設定されたページ保護を解除します。",
+ "apihelp-purge-summary": "指定されたページのキャッシュを破棄します。",
+ "apihelp-purge-param-forcelinkupdate": "リンクテーブルを更新します。",
+ "apihelp-purge-example-simple": "ページ <kbd>Main Page</kbd> および <kbd>API</kbd> をパージする。",
+ "apihelp-purge-example-generator": "標準名前空間にある最初の10ページをパージする。",
+ "apihelp-query-param-prop": "照会ページ用に、どのプロパティを取得するか。",
+ "apihelp-query-param-list": "どの一覧を取得するか。",
+ "apihelp-query-param-meta": "どのメタデータを取得するか。",
+ "apihelp-query-param-export": "指定されたまたは生成されたすべてのページの、現在の版を書き出します。",
+ "apihelp-query-param-iwurl": "タイトルがウィキ間リンクである場合に、完全なURLを取得するかどうか。",
+ "apihelp-query-example-revisions": "[[Special:ApiHelp/query+siteinfo|サイト情報]]と<kbd>Main Page</kbd>の[[Special:ApiHelp/query+revisions|版]]を取得する。",
+ "apihelp-query-example-allpages": "<kbd>API/</kbd> で始まるページの版を取得する。",
+ "apihelp-query+allcategories-summary": "すべてのカテゴリを一覧表示します。",
+ "apihelp-query+allcategories-param-from": "列挙を開始するカテゴリ。",
+ "apihelp-query+allcategories-param-to": "列挙を終了するカテゴリ。",
+ "apihelp-query+allcategories-param-prefix": "この値で始まるページ名のカテゴリを検索します。",
+ "apihelp-query+allcategories-param-dir": "並べ替えの方向。",
+ "apihelp-query+allcategories-param-limit": "返すカテゴリの数。",
+ "apihelp-query+allcategories-param-prop": "取得するプロパティ:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "カテゴリ内のページ数を追加します。",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "<code>_&#95;HIDDENCAT_&#95;</code>に隠されているタグカテゴリ。",
+ "apihelp-query+allcategories-example-size": "カテゴリを、内包するページ数の情報と共に、一覧表示する。",
+ "apihelp-query+allcategories-example-generator": "<kbd>List</kbd> で始まるカテゴリページに関する情報を取得する。",
+ "apihelp-query+alldeletedrevisions-summary": "利用者によって削除された、または名前空間内の削除されたすべての版を一覧表示する。",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "<var>$3user</var> と同時に使用します。",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "<var>$3user</var> と同時に使用できません。",
+ "apihelp-query+alldeletedrevisions-param-start": "列挙の始点となるタイムスタンプ。",
+ "apihelp-query+alldeletedrevisions-param-end": "列挙の終点となるタイムスタンプ。",
+ "apihelp-query+alldeletedrevisions-param-from": "列挙の始点となるページ名。",
+ "apihelp-query+alldeletedrevisions-param-to": "列挙の終点となるページ名。",
+ "apihelp-query+alldeletedrevisions-param-prefix": "この値で始まるすべてのページ名を検索する。",
+ "apihelp-query+alldeletedrevisions-param-tag": "このタグが付与された版のみを一覧表示する。",
+ "apihelp-query+alldeletedrevisions-param-user": "この利用者による版のみを一覧表示する。",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "この利用者による版を一覧表示しない。",
+ "apihelp-query+alldeletedrevisions-param-namespace": "この名前空間に含まれるページのみを一覧表示します。",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>注意:</strong> [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]] により、<var>$1user</var> と <var>$1namespace</var> を同時に使用すると継続する前に <var>$1limit</var> より返される結果が少なくなることがあります; 極端な場合では、ゼロ件の結果が返ることもあります。",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "ジェネレーターとして使用する場合、版IDではなくページ名を生成します。",
+ "apihelp-query+alldeletedrevisions-example-user": "利用者 <kbd>Example</kbd> による削除された直近の50版を一覧表示する。",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "標準名前空間にある削除された最初の50版を一覧表示する。",
+ "apihelp-query+allfileusages-summary": "存在しないものを含め、すべてのファイルの使用状況を一覧表示する。",
+ "apihelp-query+allfileusages-param-from": "列挙を開始するファイルのページ名。",
+ "apihelp-query+allfileusages-param-to": "列挙を終了するファイルのページ名。",
+ "apihelp-query+allfileusages-param-prefix": "この値で始まるページ名のすべてのファイルを検索する。",
+ "apihelp-query+allfileusages-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "使用しているページのページIDを追加します ($1unique とは同時に使用できません)。",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "ファイルのページ名を追加します。",
+ "apihelp-query+allfileusages-param-limit": "返す項目の総数。",
+ "apihelp-query+allfileusages-param-dir": "一覧表示する方向。",
+ "apihelp-query+allfileusages-example-unique": "ユニークなファイルを一覧表示する。",
+ "apihelp-query+allfileusages-example-unique-generator": "ファイル名を、存在しないものに印をつけて、すべて取得する。",
+ "apihelp-query+allfileusages-example-generator": "ファイルを含むページを取得します。",
+ "apihelp-query+allimages-summary": "順次すべての画像を列挙します。",
+ "apihelp-query+allimages-param-sort": "並べ替えに使用するプロパティ。",
+ "apihelp-query+allimages-param-dir": "一覧表示する方向。",
+ "apihelp-query+allimages-param-from": "列挙の始点となる画像タイトル。$1sort=name を指定した場合のみ使用できます。",
+ "apihelp-query+allimages-param-to": "列挙の終点となる画像のページ名。$1sort=name を指定した場合のみ使用できます。",
+ "apihelp-query+allimages-param-start": "列挙の始点となるタイムスタンプ。$1sort=timestamp を指定した場合のみ使用できます。",
+ "apihelp-query+allimages-param-end": "列挙の終点となるタイムスタンプ。$1sort=timestamp を指定した場合のみ使用できます。",
+ "apihelp-query+allimages-param-prefix": "この値で始まるすべての画像タイトルを検索する。$1sort=name を指定した場合のみ使用できます。",
+ "apihelp-query+allimages-param-minsize": "画像の最小バイト数を制限する。",
+ "apihelp-query+allimages-param-maxsize": "画像の最大バイト数を制限する。",
+ "apihelp-query+allimages-param-sha1": "画像の SHA1 ハッシュ値。$1sha1base36 をオーバーライドします。",
+ "apihelp-query+allimages-param-user": "この利用者によりアップロードされたファイルのみを返す。$1sort=timestamp を指定した場合のみ使用できます。 $1filterbots とは同時に使用できません。",
+ "apihelp-query+allimages-param-filterbots": "ボットによりアップロードされたファイルを絞り込む方法。$1sort=timestamp を指定した場合のみ使用できます。$1user とは同時に使用できません。",
+ "apihelp-query+allimages-param-mime": "検索対象のMIMEタイプ、たとえば <kbd>image/jpeg</kbd>。",
+ "apihelp-query+allimages-param-limit": "返す画像の総数。",
+ "apihelp-query+allimages-example-B": "<kbd>B</kbd> で始まるファイルの一覧を表示する。",
+ "apihelp-query+allimages-example-recent": "[[Special:NewFiles]] のように、最近アップロードされたファイルの一覧を表示する。",
+ "apihelp-query+allimages-example-mimetypes": "MIMEタイプが <kbd>image/png</kbd> または <kbd>image/gif</kbd> であるファイルの一覧を表示する",
+ "apihelp-query+allimages-example-generator": "<kbd>T</kbd> で始まる4つのファイルに関する情報を表示する。",
+ "apihelp-query+alllinks-summary": "与えられた名前空間へのすべてのリンクを一覧表示します。",
+ "apihelp-query+alllinks-param-from": "列挙を開始するリンクのページ名。",
+ "apihelp-query+alllinks-param-to": "列挙を終了するリンクのページ名。",
+ "apihelp-query+alllinks-param-prefix": "この値で始まるすべてのリンクされたページを検索する。",
+ "apihelp-query+alllinks-param-unique": "リンクされたページ名を一度だけ表示します。<kbd>$1prop=ids</kbd> とは同時に使用できません。ジェネレーターとして使用される場合、リンク元ではなくリンク先のページを生成します。",
+ "apihelp-query+alllinks-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "リンクしているページのページIDを追加します ($1unique とは同時に使用できません)。",
+ "apihelp-query+alllinks-paramvalue-prop-title": "リンクのページ名を追加します。",
+ "apihelp-query+alllinks-param-namespace": "列挙する名前空間。",
+ "apihelp-query+alllinks-param-limit": "返す項目の総数。",
+ "apihelp-query+alllinks-param-dir": "一覧表示する方向。",
+ "apihelp-query+alllinks-example-B": "<kbd>B</kbd> で始まるリンクされたページ (存在しないページも含む)を、リンク元のページIDとともに表示する。",
+ "apihelp-query+alllinks-example-unique": "ユニークなリンクのタイトルを一覧。",
+ "apihelp-query+alllinks-example-unique-generator": "リンクされているページを、存在しないものに印をつけて、すべて取得する。",
+ "apihelp-query+alllinks-example-generator": "リンクを含むページを取得します。",
+ "apihelp-query+allmessages-param-messages": "出力のためのメッセージ。 <kbd>*</kbd>(デフォルト)は、すべてのメッセージを意味します。",
+ "apihelp-query+allmessages-param-prop": "取得するプロパティ:",
+ "apihelp-query+allmessages-param-nocontent": "設定した場合、出力内のメッセージの内容が含まれていません。",
+ "apihelp-query+allmessages-param-args": "メッセージ中に展開される引数。",
+ "apihelp-query+allmessages-param-filter": "この文字列を含んだ名前のメッセージのみを返す。",
+ "apihelp-query+allmessages-param-customised": "変更された状態のメッセージのみを返す。",
+ "apihelp-query+allmessages-param-lang": "返すメッセージの言語。",
+ "apihelp-query+allmessages-param-prefix": "この接頭辞を持つメッセージを返す。",
+ "apihelp-query+allmessages-example-ipb": "<kbd>ipb-</kbd> で始まるメッセージを表示する。",
+ "apihelp-query+allmessages-example-de": "ドイツ語のメッセージ <kbd>august</kbd> および <kbd>mainpage</kbd> を表示する。",
+ "apihelp-query+allpages-param-from": "列挙を開始するページ名。",
+ "apihelp-query+allpages-param-to": "列挙を終了するページ名。",
+ "apihelp-query+allpages-param-prefix": "この値で始まるすべてのページ名を検索します。",
+ "apihelp-query+allpages-param-namespace": "列挙する名前空間。",
+ "apihelp-query+allpages-param-minsize": "ページの最低バイト数を制限する。",
+ "apihelp-query+allpages-param-maxsize": "ページの最大バイト数を制限する。",
+ "apihelp-query+allpages-param-prtype": "保護されているページに絞り込む。",
+ "apihelp-query+allpages-param-prlevel": "保護レベルで絞り込む ($1type= パラメーターと同時に使用しなければなりません)。",
+ "apihelp-query+allpages-param-limit": "返すページの総数。",
+ "apihelp-query+allpages-param-dir": "一覧表示する方向。",
+ "apihelp-query+allpages-example-B": "<kbd>B</kbd> で始まるページの一覧を表示する。",
+ "apihelp-query+allpages-example-generator": "<kbd>T</kbd> で始まる4つのページに関する情報を表示する。",
+ "apihelp-query+allpages-example-generator-revisions": "<kbd>Re</kbd> で始まる最初の非リダイレクトの2ページの内容を表示する。",
+ "apihelp-query+allredirects-summary": "ある名前空間へのすべての転送を一覧表示する。",
+ "apihelp-query+allredirects-param-from": "列挙を開始するリダイレクトのページ名。",
+ "apihelp-query+allredirects-param-to": "列挙を終了するリダイレクトのページ名。",
+ "apihelp-query+allredirects-param-prefix": "この値で始まるすべてのページを検索する。",
+ "apihelp-query+allredirects-param-unique": "転送先ページ名を一度だけ表示します。<kbd>$1prop=ids|fragment|interwiki</kbd> とは同時に使用できません。ジェネレーターとして使用される場合、転送元ではなく転送先のページを生成します。",
+ "apihelp-query+allredirects-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+allredirects-paramvalue-prop-title": "転送ページのページ名を追加します。",
+ "apihelp-query+allredirects-param-namespace": "列挙する名前空間。",
+ "apihelp-query+allredirects-param-limit": "返す項目の総数。",
+ "apihelp-query+allredirects-param-dir": "一覧表示する方向。",
+ "apihelp-query+allredirects-example-B": "<kbd>B</kbd> で始まる転送先ページ (存在しないページも含む)を、転送元のページIDとともに表示する。",
+ "apihelp-query+allrevisions-summary": "すべての版を一覧表示する。",
+ "apihelp-query+allrevisions-param-start": "列挙の始点となるタイムスタンプ。",
+ "apihelp-query+allrevisions-param-end": "列挙の終点となるタイムスタンプ。",
+ "apihelp-query+allrevisions-param-user": "この利用者による版のみを一覧表示する。",
+ "apihelp-query+allrevisions-param-excludeuser": "この利用者による版を一覧表示しない。",
+ "apihelp-query+allrevisions-param-namespace": "この名前空間に含まれるページのみを一覧表示します。",
+ "apihelp-query+allrevisions-param-generatetitles": "ジェネレーターとして使用する場合、版IDではなくページ名を生成します。",
+ "apihelp-query+allrevisions-example-user": "利用者 <kbd>Example</kbd> による直近の50版を一覧表示する。",
+ "apihelp-query+allrevisions-example-ns-main": "標準名前空間にある最初の50版を一覧表示する。",
+ "apihelp-query+mystashedfiles-param-prop": "ファイルのどのプロパティを取得するか。",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "ファイルサイズと画像の大きさを取得します。",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "ファイルの MIME タイプとメディアタイプを取得します。",
+ "apihelp-query+mystashedfiles-param-limit": "取得するファイルの数。",
+ "apihelp-query+alltransclusions-summary": "存在しないものも含めて、すべての参照読み込み (&#123;&#123;x&#125;&#125; で埋め込まれたページ) を一覧表示します。",
+ "apihelp-query+alltransclusions-param-from": "列挙を開始する参照読み込みのページ名。",
+ "apihelp-query+alltransclusions-param-to": "列挙を終了する参照読み込みのページ名。",
+ "apihelp-query+alltransclusions-param-prefix": "この値で始まるすべての参照読み込みされているページを検索する。",
+ "apihelp-query+alltransclusions-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "参照元のページIDを追加します ($1unique とは同時に使用できません)。",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "参照読み込みのページ名を追加します。",
+ "apihelp-query+alltransclusions-param-namespace": "列挙する名前空間。",
+ "apihelp-query+alltransclusions-param-limit": "返す項目の総数。",
+ "apihelp-query+alltransclusions-param-dir": "一覧表示する方向。",
+ "apihelp-query+alltransclusions-example-B": "参照読み込みされているページ (存在しないページも含む) を、参照元のページIDとともに、<kbd>B</kbd> で始まるものから一覧表示する。",
+ "apihelp-query+alltransclusions-example-unique-generator": "参照読み込みされたページを、存在しないものに印をつけて、すべて取得する。",
+ "apihelp-query+alltransclusions-example-generator": "参照読み込みを含んでいるページを取得する。",
+ "apihelp-query+allusers-summary": "すべての登録利用者を一覧表示します。",
+ "apihelp-query+allusers-param-from": "列挙を開始する利用者名。",
+ "apihelp-query+allusers-param-to": "列挙を終了する利用者名。",
+ "apihelp-query+allusers-param-prefix": "この値で始まるすべての利用者を検索する。",
+ "apihelp-query+allusers-param-dir": "並べ替えの方向。",
+ "apihelp-query+allusers-param-group": "このグループに所属する利用者のみを結果に含める。",
+ "apihelp-query+allusers-param-excludegroup": "このグループに所属する利用者を結果から除外する。",
+ "apihelp-query+allusers-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "利用者に対する現在のブロックに関する情報を追加します。",
+ "apihelp-query+allusers-paramvalue-prop-groups": "利用者が所属する利用者グループを一覧表示します。これはサーバー資源を多めに使用するので、返る結果が制限値より少なくなります。",
+ "apihelp-query+allusers-paramvalue-prop-rights": "利用者が持っている権限を一覧表示します。",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "利用者の編集回数を追加します。",
+ "apihelp-query+allusers-paramvalue-prop-registration": "可能な場合、利用者の登録日時のタイムスタンプを追加します (空白になるかもしれません)。",
+ "apihelp-query+allusers-param-limit": "返す利用者名の総数。",
+ "apihelp-query+allusers-param-witheditsonly": "編集履歴のある利用者のみ一覧表示する。",
+ "apihelp-query+allusers-param-activeusers": "最近 $1 {{PLURAL:$1|日間}}のアクティブな利用者のみを一覧表示する。",
+ "apihelp-query+allusers-example-Y": "<kbd>Y</kbd> で始まる利用者を一覧表示する。",
+ "apihelp-query+backlinks-summary": "与えられたページにリンクしているすべてのページを検索します。",
+ "apihelp-query+backlinks-param-title": "検索するページ名。<var>$1pageid</var> とは同時に使用できません。",
+ "apihelp-query+backlinks-param-pageid": "検索するページID。<var>$1title</var>とは同時に使用できません。",
+ "apihelp-query+backlinks-param-namespace": "列挙する名前空間。",
+ "apihelp-query+backlinks-param-dir": "一覧表示する方向。",
+ "apihelp-query+backlinks-param-limit": "返すページの総数。<var>$1redirect</var> を有効化した場合は、各レベルに対し個別にlimitが適用されます (つまり、最大で 2 * <var>$1limit</var> 件の結果が返されます)。",
+ "apihelp-query+backlinks-example-simple": "<kbd>Main page</kbd> へのリンクを表示する。",
+ "apihelp-query+backlinks-example-generator": "<kbd>Main page</kbd> にリンクしているページの情報を取得する。",
+ "apihelp-query+blocks-summary": "ブロックされた利用者とIPアドレスを一覧表示します。",
+ "apihelp-query+blocks-param-start": "列挙の始点となるタイムスタンプ。",
+ "apihelp-query+blocks-param-end": "列挙の終点となるタイムスタンプ。",
+ "apihelp-query+blocks-param-ids": "一覧表示するブロックIDのリスト (任意)。",
+ "apihelp-query+blocks-param-users": "検索対象の利用者のリスト (任意)。",
+ "apihelp-query+blocks-param-limit": "一覧表示するブロックの最大数。",
+ "apihelp-query+blocks-param-prop": "取得するプロパティ:",
+ "apihelp-query+blocks-paramvalue-prop-id": "ブロックのIDを追加します。",
+ "apihelp-query+blocks-paramvalue-prop-user": "ブロックされた利用者の利用者名を追加します。",
+ "apihelp-query+blocks-paramvalue-prop-userid": "ブロックされた利用者の利用者IDを追加します。",
+ "apihelp-query+blocks-paramvalue-prop-by": "ブロック実行者の利用者名を追加します。",
+ "apihelp-query+blocks-paramvalue-prop-byid": "ブロック実行者の利用者IDを追加します。",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "ブロックが与えられたときのタイムスタンプを追加します。",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "ブロックの有効期限が切れたときのタイムスタンプを追加します。",
+ "apihelp-query+blocks-paramvalue-prop-reason": "ブロックに指定された理由を追加します。",
+ "apihelp-query+blocks-paramvalue-prop-range": "ブロックの影響を受けたIPアドレスの範囲を追加します。",
+ "apihelp-query+blocks-paramvalue-prop-flags": "(autoblock, anononly, などとの) ban をタグ付けします。",
+ "apihelp-query+blocks-param-show": "これらの基準を満たす項目のみを表示します。\nたとえば、IPアドレスの無期限ブロックのみを表示するには、<kbd>$1show=ip|!temp</kbd> を設定します。",
+ "apihelp-query+blocks-example-simple": "ブロックを一覧表示する。",
+ "apihelp-query+blocks-example-users": "利用者<kbd>Alice</kbd> および <kbd>Bob</kbd> のブロックを一覧表示する。",
+ "apihelp-query+categories-summary": "ページが属するすべてのカテゴリを一覧表示します。",
+ "apihelp-query+categories-param-prop": "各カテゴリについて取得する追加のプロパティ:",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "カテゴリが追加されたときのタイムスタンプを追加します。",
+ "apihelp-query+categories-paramvalue-prop-hidden": "<code>_&#95;HIDDENCAT_&#95;</code>で隠されているカテゴリに印を付ける。",
+ "apihelp-query+categories-param-show": "どの種類のカテゴリを表示するか。",
+ "apihelp-query+categories-param-limit": "返すカテゴリの数。",
+ "apihelp-query+categories-example-simple": "ページ <kbd>Albert Einstein</kbd> が属しているカテゴリの一覧を取得する。",
+ "apihelp-query+categories-example-generator": "ページ <kbd>Albert Einstein</kbd> で使われているすべてのカテゴリに関する情報を取得する。",
+ "apihelp-query+categoryinfo-summary": "与えられたカテゴリに関する情報を返します。",
+ "apihelp-query+categoryinfo-example-simple": "<kbd>Category:Foo</kbd> および <kbd>Category:Bar</kbd> に関する情報を取得する。",
+ "apihelp-query+categorymembers-summary": "与えられたカテゴリ内のすべてのページを一覧表示します。",
+ "apihelp-query+categorymembers-param-title": "一覧表示するカテゴリ (必須)。<kbd>{{ns:category}}:</kbd> 接頭辞を含まなければなりません。<var>$1pageid</var> とは同時に使用できません。",
+ "apihelp-query+categorymembers-param-pageid": "一覧表示するカテゴリのページID. <var>$1title</var> とは同時に使用できません。",
+ "apihelp-query+categorymembers-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "ページIDを追加します。",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "ページ名と名前空間IDを追加します。",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "カテゴリでのソートに使用するソートキーを追加します(16進数文字列)。",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "ページが含まれていたときのタイムスタンプを追加します。",
+ "apihelp-query+categorymembers-param-limit": "返すページの最大数。",
+ "apihelp-query+categorymembers-param-sort": "並べ替えに使用するプロパティ。",
+ "apihelp-query+categorymembers-param-start": "列挙の始点となるタイムスタンプ。<kbd>$1sort=timestamp</kbd>を指定した場合のみ使用できます。",
+ "apihelp-query+categorymembers-param-end": "列挙の終点となるタイムスタンプ。<kbd>$1sort=timestamp</kbd>を指定した場合のみ使用できます。",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "列挙の始点となるソートキーの接頭辞。<kbd>$1sort=sortkey</kbd>を指定した場合のみ使用できます。<var>$1starthexsortkey</var>をオーバーライドします。",
+ "apihelp-query+categorymembers-param-startsortkey": "代わりに $1starthexsortkey を使用してください。",
+ "apihelp-query+categorymembers-param-endsortkey": "代わりに $1endhexsortkey を使用してください。",
+ "apihelp-query+categorymembers-example-simple": "<kbd>Category:Physics</kbd> に含まれる最初の10ページを取得する。",
+ "apihelp-query+categorymembers-example-generator": "<kbd>Category:Physics</kbd> に含まれる最初の10ページのページ情報を取得する。",
+ "apihelp-query+contributors-summary": "ページへのログインした投稿者の一覧と匿名投稿者の数を取得します。",
+ "apihelp-query+contributors-param-limit": "返す投稿者の数。",
+ "apihelp-query+contributors-example-simple": "<kbd>Main Page</kbd> への投稿者を表示する。",
+ "apihelp-query+deletedrevisions-param-start": "列挙の始点となるタイムスタンプ。版IDの一覧を処理するときには無視されます。",
+ "apihelp-query+deletedrevisions-param-end": "列挙の終点となるタイムスタンプ。版IDの一覧を処理するときには無視されます。",
+ "apihelp-query+deletedrevisions-param-tag": "このタグが付与された版のみ表示します。",
+ "apihelp-query+deletedrevisions-param-user": "この利用者による版のみを一覧表示。",
+ "apihelp-query+deletedrevisions-param-excludeuser": "この利用者による版を一覧表示しない。",
+ "apihelp-query+deletedrevisions-example-titles": "ページ <kbd>Main Page</kbd> および <kbd>Talk:Main Page</kbd> の削除された版とその内容を一覧表示する。",
+ "apihelp-query+deletedrevisions-example-revids": "削除された版 <kbd>123456</kbd> に関する情報を一覧表示する。",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|モード}}: $2",
+ "apihelp-query+deletedrevs-param-start": "列挙の始点となるタイムスタンプ。",
+ "apihelp-query+deletedrevs-param-end": "列挙の終点となるタイムスタンプ。",
+ "apihelp-query+deletedrevs-param-from": "列挙の始点となるページ名。",
+ "apihelp-query+deletedrevs-param-to": "列挙の終点となるページ名。",
+ "apihelp-query+deletedrevs-param-tag": "このタグが付与された版のみを一覧表示する。",
+ "apihelp-query+deletedrevs-param-user": "この利用者による版のみを一覧表示する。",
+ "apihelp-query+deletedrevs-param-excludeuser": "この利用者による版を一覧表示しない。",
+ "apihelp-query+deletedrevs-param-namespace": "この名前空間に含まれるページのみを一覧表示します。",
+ "apihelp-query+deletedrevs-param-limit": "一覧表示する版の最大量。",
+ "apihelp-query+deletedrevs-param-prop": "どの情報を取得するか:\n;revid:削除された版の版IDを追加します。\n;parentid:ページの前の版の版IDを追加します。\n;user:版を作成した利用者を追加します。\n;userid:版を作成した利用者のIDを追加します。\n;comment:版のコメントを追加します。\n;parsedcomment:版のコメントを構文解析して追加します。\n;minor:版が細部の編集かどうか印をつけます。\n;len:版の長さ (バイト) を追加します。\n;sha1:版のSHA-1 (base 16) を追加します。\n;content:版の内容を追加します。\n;token:<span class=\"apihelp-deprecated\">廃止予定です。</span>編集トークンを返します。\n;tags:版のタグです。",
+ "apihelp-query+deletedrevs-example-mode1": "ページ <kbd>Main Page</kbd> および <kbd>Talk:Main Page</kbd> の最後に削除された版を内容と共に一覧表示する(モード 1)。",
+ "apihelp-query+deletedrevs-example-mode2": "<kbd>Bob</kbd> による、削除された最後の50投稿を一覧表示する(モード 2)。",
+ "apihelp-query+deletedrevs-example-mode3-main": "標準名前空間にある削除された最初の50版を一覧表示する(モード 3)。",
+ "apihelp-query+deletedrevs-example-mode3-talk": "{{ns:talk}}名前空間にある削除された最初の50版を一覧表示する(モード 3)。",
+ "apihelp-query+disabled-summary": "このクエリモジュールは無効化されています。",
+ "apihelp-query+embeddedin-param-title": "検索するページ名。$1pageid とは同時に使用できません。",
+ "apihelp-query+embeddedin-param-pageid": "検索するページID. $1titleとは同時に使用できません。",
+ "apihelp-query+embeddedin-param-namespace": "列挙する名前空間。",
+ "apihelp-query+embeddedin-param-filterredir": "転送ページを絞り込む方法。",
+ "apihelp-query+embeddedin-param-limit": "返すページの総数。",
+ "apihelp-query+embeddedin-example-simple": "<kbd>Template:Stub</kbd> を参照読み込みしているページを表示する。",
+ "apihelp-query+embeddedin-example-generator": "<kbd>Template:Stub</kbd> をトランスクルードしているページに関する情報を取得する。",
+ "apihelp-query+extlinks-summary": "与えられたページにあるすべての外部URL (インターウィキを除く) を返します。",
+ "apihelp-query+extlinks-param-limit": "返すリンクの数。",
+ "apihelp-query+extlinks-param-protocol": "URLのプロトコル。このパラメータが空であり、かつ<var>$1query</var> が設定されている場合, protocol は <kbd>http</kbd> となります。すべての外部リンクを一覧表示するためにはこのパラメータと <var>$1query</var> の両方を空にしてください。",
+ "apihelp-query+extlinks-example-simple": "<kbd>Main Page</kbd> の外部リンクの一覧を取得する。",
+ "apihelp-query+exturlusage-summary": "与えられたURLを含むページを一覧表示します。",
+ "apihelp-query+exturlusage-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "ページのIDを追加します。",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "ページ名と名前空間IDを追加します。",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "ページ内で使用されているURLを追加します。",
+ "apihelp-query+exturlusage-param-protocol": "URLのプロトコル。このパラメータが空であり、かつ<var>$1query</var> が設定されている場合, protocol は <kbd>http</kbd> となります。すべての外部リンクを一覧表示するためにはこのパラメータと <var>$1query</var> の両方を空にしてください。",
+ "apihelp-query+exturlusage-param-query": "プロトコルを除いた検索文字列。[[Special:LinkSearch]] も参照してください。すべての外部リンクを一覧表示するには空欄にしてください。",
+ "apihelp-query+exturlusage-param-namespace": "列挙するページ名前空間。",
+ "apihelp-query+exturlusage-param-limit": "返すページの数。",
+ "apihelp-query+exturlusage-example-simple": "<kbd>http://www.mediawiki.org</kbd> にリンクしているページを一覧表示する。",
+ "apihelp-query+filearchive-summary": "削除されたファイルをすべて順に列挙します。",
+ "apihelp-query+filearchive-param-from": "列挙の始点となる画像のページ名。",
+ "apihelp-query+filearchive-param-to": "列挙の終点となる画像のページ名。",
+ "apihelp-query+filearchive-param-dir": "一覧表示する方向。",
+ "apihelp-query+filearchive-param-sha1": "画像の SHA1 ハッシュ値。$1sha1base36 をオーバーライドします。",
+ "apihelp-query+filearchive-param-prop": "どの画像情報を取得するか:",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "バージョンがアップロードされたタイムスタンプを追加します。",
+ "apihelp-query+filearchive-paramvalue-prop-user": "画像のバージョンをアップロードした利用者を追加します。",
+ "apihelp-query+filearchive-paramvalue-prop-size": "バイト単位での画像や高さ、幅、ページ数のサイズを追加します(該当する場合)。",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "画像の MIME を追加します。",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "画像のメディア・タイプを追加します。",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "画像のバージョンの Exif メタデータを一覧表示します。",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "バージョンのビット深度を追加します。",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "非最新バージョンのアーカイブバージョンのファイル名を追加します。",
+ "apihelp-query+filearchive-example-simple": "削除されたファイルの一覧を表示する。",
+ "apihelp-query+filerepoinfo-example-simple": "ファイルリポジトリについての情報を取得します。",
+ "apihelp-query+fileusage-param-prop": "取得するプロパティ:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "各ページのページID。",
+ "apihelp-query+fileusage-paramvalue-prop-title": "各ページのページ名。",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "ページがリダイレクトである場合マークします。",
+ "apihelp-query+fileusage-param-namespace": "この名前空間に含まれるページのみを一覧表示します。",
+ "apihelp-query+fileusage-example-simple": "[[:File:Example.jpg]] を使用しているページの一覧を取得する。",
+ "apihelp-query+fileusage-example-generator": "[[:File:Example.jpg]] を使用しているページの情報を取得する。",
+ "apihelp-query+imageinfo-param-prop": "取得するファイル情報:",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "ファイルと説明ページへのURLを提供します。",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "バイト単位でファイルや高さ、幅、ページ数のサイズを追加します(該当する場合)。",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "ファイルのMIMEタイプを追加します。",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "画像サムネイルのMIMEタイプを追加します(url と $1urlwidth パラメータが必須です)。",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "ファイルのメディアタイプを追加します。",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "ファイルのバージョンの Exif メタデータを一覧表示します。",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "非最新バージョンのアーカイブバージョンのファイル名を追加します。",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "バージョンのビット深度を追加します。",
+ "apihelp-query+imageinfo-param-start": "一覧表示の始点となるタイムスタンプ。",
+ "apihelp-query+imageinfo-param-end": "一覧表示の終点となるタイムスタンプ。",
+ "apihelp-query+imageinfo-example-simple": "[[:File:Albert Einstein Head.jpg]] の現在のバージョンに関する情報を取得する。",
+ "apihelp-query+images-summary": "与えられたページに含まれるすべてのファイルを返します。",
+ "apihelp-query+images-param-limit": "返す画像の数。",
+ "apihelp-query+images-example-simple": "[[Main Page]] で使用されているファイルの一覧を取得する。",
+ "apihelp-query+images-example-generator": "[[Main Page]] で使用されているファイルに関する情報を取得する。",
+ "apihelp-query+imageusage-param-title": "検索するページ名。$1pageid とは同時に使用できません。",
+ "apihelp-query+imageusage-param-pageid": "検索するページID. $1titleとは同時に使用できません。",
+ "apihelp-query+imageusage-param-namespace": "列挙する名前空間。",
+ "apihelp-query+imageusage-example-simple": "[[:File:Albert Einstein Head.jpg]] を使用しているページを表示する。",
+ "apihelp-query+imageusage-example-generator": "[[:File:Albert Einstein Head.jpg]] を使用しているページに関する情報を取得する。",
+ "apihelp-query+info-summary": "ページの基本的な情報を取得します。",
+ "apihelp-query+info-param-prop": "追加で取得するプロパティ:",
+ "apihelp-query+info-paramvalue-prop-protection": "それぞれのページの保護レベルを一覧表示する。",
+ "apihelp-query+info-param-token": "代わりに [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] を使用してください。",
+ "apihelp-query+info-example-simple": "<kbd>Main Page</kbd> に関する情報を取得する。",
+ "apihelp-query+iwbacklinks-param-prefix": "インターウィキ接頭辞。",
+ "apihelp-query+iwbacklinks-param-title": "検索するウィキ間リンク。<var>$1blprefix</var>と同時に使用しなければなりません。",
+ "apihelp-query+iwbacklinks-param-limit": "返すページの総数。",
+ "apihelp-query+iwbacklinks-param-prop": "取得するプロパティ:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "インターウィキ接頭辞を追加します。",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "ウィキ間リンクのページ名を追加します。",
+ "apihelp-query+iwbacklinks-param-dir": "一覧表示する方向。",
+ "apihelp-query+iwbacklinks-example-simple": "[[wikibooks:Test]] へリンクしているページを取得する。",
+ "apihelp-query+iwbacklinks-example-generator": "[[wikibooks:Test]] へリンクしているページの情報を取得する。",
+ "apihelp-query+iwlinks-summary": "ページからのすべてのウィキ間リンクを返します。",
+ "apihelp-query+iwlinks-param-url": "完全なURLを取得するかどうか (<var>$1prop</var>とは同時に使用できません).",
+ "apihelp-query+iwlinks-param-prop": "各言語間リンクについて取得する追加のプロパティ:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "完全なURLを追加します。",
+ "apihelp-query+iwlinks-param-limit": "返すウィキ間リンクの数。",
+ "apihelp-query+iwlinks-param-prefix": "この接頭辞のウィキ間リンクのみを返す。",
+ "apihelp-query+iwlinks-param-title": "検索するウィキ間リンク。<var>$1</var> と同時に使用しなければなりません。",
+ "apihelp-query+iwlinks-param-dir": "一覧表示する方向。",
+ "apihelp-query+iwlinks-example-simple": "<kbd>Main Page</kbd> にあるウィキ間リンクを取得する。",
+ "apihelp-query+langbacklinks-param-lang": "言語間リンクの言語。",
+ "apihelp-query+langbacklinks-param-title": "検索する言語間リンク。$1lang と同時に使用しなければなりません。",
+ "apihelp-query+langbacklinks-param-limit": "返すページの総数。",
+ "apihelp-query+langbacklinks-param-prop": "取得するプロパティ:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "言語間リンクの言語コードを追加します。",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "言語間リンクのページ名を追加します。",
+ "apihelp-query+langbacklinks-param-dir": "一覧表示する方向。",
+ "apihelp-query+langbacklinks-example-simple": "[[:fr:Test]] へリンクしているページを取得する。",
+ "apihelp-query+langbacklinks-example-generator": "[[:fr:Test]] へリンクしているページの情報を取得する。",
+ "apihelp-query+langlinks-summary": "ページからのすべての言語間リンクを返します。",
+ "apihelp-query+langlinks-param-limit": "返す言語間リンクの数。",
+ "apihelp-query+langlinks-param-url": "完全なURLを取得するかどうか (<var>$1prop</var>とは同時に使用できません).",
+ "apihelp-query+langlinks-param-prop": "各言語間リンクについて取得する追加のプロパティ:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "完全なURLを追加します。",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "ネイティブ言語名を追加します。",
+ "apihelp-query+langlinks-param-lang": "この言語コードの言語間リンクのみを返す。",
+ "apihelp-query+langlinks-param-title": "検索するリンク。<var>$1lang</var>と同時に使用しなければなりません。",
+ "apihelp-query+langlinks-param-dir": "一覧表示する方向。",
+ "apihelp-query+langlinks-example-simple": "<kbd>Main Page</kbd> にある言語間リンクを取得する。",
+ "apihelp-query+links-summary": "ページからのすべてのリンクを返します。",
+ "apihelp-query+links-param-namespace": "この名前空間へのリンクのみ表示する。",
+ "apihelp-query+links-param-limit": "返すリンクの数。",
+ "apihelp-query+links-example-simple": "<kbd>Main Page</kbd> からのリンクを取得する。",
+ "apihelp-query+links-example-generator": "<kbd>Main Page</kbd> からリンクされているページに関する情報を取得する。",
+ "apihelp-query+links-example-namespaces": "<kbd>Main Page</kbd> からの {{ns:user}} および {{ns:template}} 名前空間へのリンクを取得する。",
+ "apihelp-query+linkshere-param-prop": "取得するプロパティ:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "各ページのページID。",
+ "apihelp-query+linkshere-paramvalue-prop-title": "各ページのページ名。",
+ "apihelp-query+linkshere-example-simple": "[[Main Page]] にリンクしているページの一覧を取得する。",
+ "apihelp-query+linkshere-example-generator": "<kbd>[[Main Page]]<kbd> にリンクしているページの情報を取得する。",
+ "apihelp-query+logevents-param-prop": "取得するプロパティ:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "記録イベントのIDを追加します。",
+ "apihelp-query+logevents-paramvalue-prop-title": "記録イベントにページ名を追加します。",
+ "apihelp-query+logevents-paramvalue-prop-type": "記録イベントのタイプを追加します。",
+ "apihelp-query+logevents-paramvalue-prop-comment": "記録イベントのコメントを追加します。",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "記録イベントの構文解析されたコメントを追加します。",
+ "apihelp-query+logevents-paramvalue-prop-details": "記録イベントに関する追加の詳細を一覧表示します。",
+ "apihelp-query+logevents-paramvalue-prop-tags": "記録イベントのタグを一覧表示します。",
+ "apihelp-query+logevents-param-type": "このタイプの記録項目のみに絞り込む。",
+ "apihelp-query+logevents-param-start": "列挙の始点となるタイムスタンプ。",
+ "apihelp-query+logevents-param-end": "列挙の終点となるタイムスタンプ。",
+ "apihelp-query+logevents-param-user": "与えられた利用者による記録項目に絞り込む。",
+ "apihelp-query+logevents-param-title": "そのページに関連する記録項目に絞り込む。",
+ "apihelp-query+logevents-param-namespace": "与えられた名前空間内の記録項目に絞り込む。",
+ "apihelp-query+logevents-param-prefix": "この接頭辞ではじまる記録項目に絞り込む。",
+ "apihelp-query+logevents-param-tag": "このタグが付与された記録項目のみ表示する。",
+ "apihelp-query+logevents-param-limit": "返す記録項目の総数。",
+ "apihelp-query+logevents-example-simple": "最近の記録項目を一覧表示する。",
+ "apihelp-query+pagepropnames-summary": "Wiki内で使用されているすべてのページプロパティ名を一覧表示します。",
+ "apihelp-query+pagepropnames-param-limit": "返す名前の最大数。",
+ "apihelp-query+pagepropnames-example-simple": "最初の10個のプロパティ名を取得する。",
+ "apihelp-query+pageprops-summary": "ページコンテンツで定義されている様々なページのプロパティを取得。",
+ "apihelp-query+pageprops-example-simple": "ページ <kbd>Main Page</kbd> および <kbd>MeiaWiki</kbd> のプロパティを取得する。",
+ "apihelp-query+pageswithprop-summary": "与えられたページプロパティが使用されているすべてのページを一覧表示します。",
+ "apihelp-query+pageswithprop-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "ページIDを追加します。",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "ページ名と名前空間IDを追加します。",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "ページプロパティの値を追加。",
+ "apihelp-query+pageswithprop-param-limit": "返すページの最大数。",
+ "apihelp-query+pageswithprop-example-simple": "<code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code> を使用している最初の10ページを一覧表示する。",
+ "apihelp-query+pageswithprop-example-generator": "<code>_&#95;NOTOC_&#95;</code> を使用している最初の10ページについての追加情報を取得する。",
+ "apihelp-query+prefixsearch-summary": "ページ名の先頭一致検索を行います。",
+ "apihelp-query+prefixsearch-extended-description": "名前が似ていますが、このモジュールは[[Special:PrefixIndex]]と等価であることを意図しません。そのような目的では<kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> を <kbd>apprefix</kbd> パラメーターと共に使用してください。このモジュールの目的は <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd> と似ています: 利用者から入力を受け取り、最も適合するページ名を提供するというものです。検索エンジンのバックエンドによっては、誤入力の訂正や、転送の回避、その他のヒューリスティクスが適用されることがあります。",
+ "apihelp-query+prefixsearch-param-search": "検索文字列。",
+ "apihelp-query+prefixsearch-param-namespace": "検索する名前空間。",
+ "apihelp-query+prefixsearch-param-limit": "返す結果の最大数。",
+ "apihelp-query+prefixsearch-example-simple": "<kbd>meaning</kbd> で始まるページ名を検索する。",
+ "apihelp-query+protectedtitles-summary": "作成保護が掛けられているページを一覧表示します。",
+ "apihelp-query+protectedtitles-param-namespace": "この名前空間に含まれるページのみを一覧表示します。",
+ "apihelp-query+protectedtitles-param-level": "この保護レベルのページのみを一覧表示します。",
+ "apihelp-query+protectedtitles-param-limit": "返すページの総数。",
+ "apihelp-query+protectedtitles-param-start": "一覧表示の始点となる保護タイムスタンプ。",
+ "apihelp-query+protectedtitles-param-end": "一覧表示の終点となる保護タイムスタンプ。",
+ "apihelp-query+protectedtitles-param-prop": "取得するプロパティ:",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "保護レベルを追加します。",
+ "apihelp-query+protectedtitles-example-simple": "保護されているページを一覧表示する。",
+ "apihelp-query+protectedtitles-example-generator": "標準名前空間にある保護されたページへのリンクを検索する。",
+ "apihelp-query+querypage-param-page": "特別ページの名前です。これは大文字小文字を区別することに注意。",
+ "apihelp-query+querypage-param-limit": "返す結果の数。",
+ "apihelp-query+querypage-example-ancientpages": "[[Special:Ancientpages]] の結果を返す。",
+ "apihelp-query+random-param-namespace": "この名前空間にあるページのみを返します。",
+ "apihelp-query+random-param-limit": "返す無作為なページの数を制限する。",
+ "apihelp-query+random-param-redirect": "代わりに <kbd>$1filterredir=redirects</kbd> を使用してください。",
+ "apihelp-query+random-param-filterredir": "転送ページを絞り込む方法。",
+ "apihelp-query+random-example-simple": "標準名前空間から2つのページを無作為に返す。",
+ "apihelp-query+random-example-generator": "標準名前空間から無作為に選ばれた2つのページのページ情報を返す。",
+ "apihelp-query+recentchanges-summary": "最近の更新を一覧表示します。",
+ "apihelp-query+recentchanges-param-start": "列挙の始点となるタイムスタンプ。",
+ "apihelp-query+recentchanges-param-end": "列挙の終点となるタイムスタンプ。",
+ "apihelp-query+recentchanges-param-namespace": "この名前空間の変更のみに絞り込む。",
+ "apihelp-query+recentchanges-param-user": "この利用者による変更のみを一覧表示する。",
+ "apihelp-query+recentchanges-param-excludeuser": "この利用者による変更を一覧表示しない。",
+ "apihelp-query+recentchanges-param-tag": "このタグが付与された版のみ一覧表示する。",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "構文解析された編集コメントを追加します。",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "編集のフラグを追加します。",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "編集のタイムスタンプを追加します。",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "編集のページ名を追加します。",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "ページID、最近の更新IDと新旧の版IDを追加します。",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "バイト単位の新旧のページの長さを追加します。",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "編集されたページが転送ページである場合、印を付けます。",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "巡回可能な編集について、巡回済みかどうか印を付けます。",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "記録項目に記録の情報 (記録ID, 記録タイプなど) を追加します。",
+ "apihelp-query+recentchanges-param-token": "代わりに <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> を使用してください。",
+ "apihelp-query+recentchanges-param-limit": "返す変更の総数。",
+ "apihelp-query+recentchanges-param-toponly": "最新の版である変更のみを一覧表示する。",
+ "apihelp-query+recentchanges-param-generaterevisions": "ジェネレータとして使用される場合、版IDではなくページ名を生成します。関連する版IDのない最近の変更の項目 (例えば、ほとんどの記録項目) は何も生成しません。",
+ "apihelp-query+recentchanges-example-simple": "最近の更新を一覧表示する。",
+ "apihelp-query+redirects-summary": "ページへのすべての転送を返します。",
+ "apihelp-query+redirects-param-prop": "取得するプロパティ:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "各リダイレクトのページID。",
+ "apihelp-query+redirects-paramvalue-prop-title": "各リダイレクトのページ名。",
+ "apihelp-query+redirects-param-limit": "返す転送の数。",
+ "apihelp-query+redirects-example-simple": "[[Main Page]] への転送の一覧を取得する。",
+ "apihelp-query+redirects-example-generator": "[[Main Page]] へのすべての転送ページに関する情報を取得する。",
+ "apihelp-query+revisions-param-end": "列挙の終点となるタイムスタンプ。",
+ "apihelp-query+revisions-param-user": "この利用者による版のみを結果に含める。",
+ "apihelp-query+revisions-param-excludeuser": "この利用者による版を結果に含めない。",
+ "apihelp-query+revisions-param-tag": "このタグが付与された版のみを一覧表示する。",
+ "apihelp-query+revisions-example-content": "ページ<kbd>API</kbd> および <kbd>Main Page</kbd> の最新の版のデータと本文を取得する。",
+ "apihelp-query+revisions-example-last5": "<kbd>Main Page</kbd> の直近の5版を取得する。",
+ "apihelp-query+revisions-example-first5": "<kbd>Main Page</kbd> の最初の5版を取得する。",
+ "apihelp-query+revisions-example-first5-after": "<kbd>Main Page</kbd> の 2006-05-01 以降の最初の5版を取得する。",
+ "apihelp-query+revisions-example-first5-not-localhost": "<kbd>Main Page</kbd> の匿名利用者 <kbd>127.0.0.1</kbd> 以外による最初の5版を取得する。",
+ "apihelp-query+revisions-example-first5-user": "<kbd>Main Page</kbd> の <kbd>MediaWiki default</kbd> による最初の5版を取得する。",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "版のID。",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "版のタイムスタンプ。",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "その版を作成した利用者。",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "その版の作成者の利用者ID。",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "その版の長さ (バイト) 。",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "その版の利用者によるコメント。",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "その版の利用者による、構文解析されたコメント。",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "その版のテキスト。",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "その版のタグ。",
+ "apihelp-query+revisions+base-param-limit": "返す版の数を制限する。",
+ "apihelp-query+search-summary": "全文検索を行います。",
+ "apihelp-query+search-param-search": "この値を含むページ名または本文を検索します。Wikiの検索バックエンド実装に応じて、あなたは特別な検索機能を呼び出すための文字列を検索することができます。",
+ "apihelp-query+search-param-namespace": "この名前空間内のみを検索します。",
+ "apihelp-query+search-param-what": "実行する検索の種類です。",
+ "apihelp-query+search-param-prop": "返すプロパティ:",
+ "apihelp-query+search-paramvalue-prop-size": "バイト単位のページのサイズを追加します。",
+ "apihelp-query+search-paramvalue-prop-wordcount": "ページのワード数を追加します。",
+ "apihelp-query+search-paramvalue-prop-timestamp": "ページが最後に編集されたときのタイムスタンプを追加します。",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "合致するタイトルを追加します。",
+ "apihelp-query+search-param-limit": "返すページの総数です。",
+ "apihelp-query+search-example-simple": "<kbd>meaning</kbd> を検索する。",
+ "apihelp-query+search-example-generator": "<kbd>meaning</kbd> の検索で返されたページのページ情報を取得する。",
+ "apihelp-query+siteinfo-param-prop": "どの情報を取得するか:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "システム全体の情報。",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "特別ページの別名の一覧。",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "マジックワードとこれらの別名の一覧。",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "アップロードが許可されているファイル拡張子の一覧を返します。",
+ "apihelp-query+siteinfo-param-numberingroup": "利用者グループに属する利用者の数を一覧表示します。",
+ "apihelp-query+siteinfo-example-simple": "サイト情報を取得する。",
+ "apihelp-query+tags-summary": "変更タグを一覧表示します。",
+ "apihelp-query+tags-param-limit": "一覧表示するタグの最大数。",
+ "apihelp-query+tags-param-prop": "取得するプロパティ:",
+ "apihelp-query+tags-paramvalue-prop-name": "タグの名前を追加。",
+ "apihelp-query+tags-paramvalue-prop-displayname": "タグのためのシステムメッセージを追加します。",
+ "apihelp-query+tags-paramvalue-prop-description": "タグの説明を追加します。",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "版の記録項目の数と、このタグを持っている記録項目の数を、追加します。",
+ "apihelp-query+tags-example-simple": "利用可能なタグを一覧表示する。",
+ "apihelp-query+templates-summary": "与えられたページでトランスクルードされているすべてのページを返します。",
+ "apihelp-query+templates-param-namespace": "この名前空間のテンプレートのみ表示する。",
+ "apihelp-query+templates-param-limit": "返すテンプレートの数。",
+ "apihelp-query+templates-example-simple": "<kbd>Main Page</kbd> で使用されているテンプレートを取得する。",
+ "apihelp-query+templates-example-generator": "<kbd>Main Page</kbd> で使用されているテンプレートに関する情報を取得する。",
+ "apihelp-query+templates-example-namespaces": "<kbd>Main Page</kbd> でトランスクルードされている {{ns:user}} および {{ns:template}} 名前空間のページを取得する。",
+ "apihelp-query+tokens-summary": "データ変更操作用のトークンを取得します。",
+ "apihelp-query+tokens-param-type": "リクエストするトークンの種類。",
+ "apihelp-query+tokens-example-simple": "csrfトークンを取得する (既定)。",
+ "apihelp-query+tokens-example-types": "ウォッチトークンおよび巡回トークンを取得する。",
+ "apihelp-query+transcludedin-summary": "与えられたページをトランスクルードしているすべてのページを検索します。",
+ "apihelp-query+transcludedin-param-prop": "取得するプロパティ:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "各ページのページID。",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "各ページのページ名。",
+ "apihelp-query+transcludedin-example-simple": "<kbd>Main Page</kbd> をトランスクルードしているページの一覧を取得する。",
+ "apihelp-query+transcludedin-example-generator": "<kbd>Main Page</kbd> をトランスクルードしているページに関する情報を取得する。",
+ "apihelp-query+usercontribs-summary": "利用者によるすべての編集を取得します。",
+ "apihelp-query+usercontribs-param-limit": "返す投稿記録の最大数。",
+ "apihelp-query+usercontribs-param-user": "投稿記録を取得する利用者。<var>$1userids</var> または <var>$1userprefix</var> とは同時に使用できません。",
+ "apihelp-query+usercontribs-param-userprefix": "この値で始まる名前のすべての利用者の投稿記録を取得します。<var>$1user</var> または <var>$1userids</var> とは同時に使用できません。",
+ "apihelp-query+usercontribs-param-userids": "投稿記録を取得する利用者のID。<var>$1user</var> または <var>$1userprefix</var> とは同時に使用できません。",
+ "apihelp-query+usercontribs-param-namespace": "この名前空間への投稿記録のみを一覧表示する。",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "ページIDと版IDを追加します。",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "ページ名と名前空間IDを追加します。",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "編集のタイムスタンプを追加します。",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "編集のコメントを追加します。",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "構文解析された編集コメントを追加します。",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "編集の新しいサイズを追加します。",
+ "apihelp-query+usercontribs-param-tag": "このタグが付与された版のみを一覧表示する。",
+ "apihelp-query+usercontribs-param-toponly": "最新の版である変更のみを一覧表示する。",
+ "apihelp-query+usercontribs-example-user": "利用者 <kbd>Example</kbd> の投稿記録を表示する。",
+ "apihelp-query+usercontribs-example-ipprefix": "<kbd>192.0.2.</kbd> から始まるすべてのIPアドレスからの投稿記録を表示する。",
+ "apihelp-query+userinfo-summary": "現在の利用者に関する情報を取得します。",
+ "apihelp-query+userinfo-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "利用者の本名を追加します。",
+ "apihelp-query+userinfo-example-simple": "現在の利用者に関する情報を取得します。",
+ "apihelp-query+userinfo-example-data": "現在の利用者に関する追加情報を取得します。",
+ "apihelp-query+users-summary": "利用者のリストについての情報を取得します。",
+ "apihelp-query+users-param-prop": "どの情報を結果に含めるか:",
+ "apihelp-query+users-param-token": "代わりに <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> を使用してください。",
+ "apihelp-query+users-example-simple": "利用者 <kbd>Example</kbd> の情報を返す。",
+ "apihelp-query+watchlist-summary": "現在の利用者のウォッチリストにあるページへの最近の更新を取得します。",
+ "apihelp-query+watchlist-param-start": "列挙の始点となるタイムスタンプ。",
+ "apihelp-query+watchlist-param-end": "列挙の終点となるタイムスタンプ。",
+ "apihelp-query+watchlist-param-namespace": "この名前空間の変更のみに絞り込む。",
+ "apihelp-query+watchlist-param-user": "この利用者による変更のみを一覧表示する。",
+ "apihelp-query+watchlist-param-excludeuser": "この利用者による変更を一覧表示しない。",
+ "apihelp-query+watchlist-param-prop": "追加で取得するプロパティ:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "版IDとページIDを追加します。",
+ "apihelp-query+watchlist-paramvalue-prop-title": "ページ名を追加します。",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "編集のフラグを追加します。",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "編集のコメントを追加します。",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "編集の構文解析されたコメントを追加します。",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "編集のタイムスタンプを追加します。",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "適切な場合にログ情報を追加します。",
+ "apihelp-query+watchlist-example-simple": "現在の利用者のウォッチリストにある最近変更されたページの最新版を一覧表示します。",
+ "apihelp-query+watchlist-example-generator": "現在の利用者のウォッチリスト上の最近更新されたページに関する情報を取得する。",
+ "apihelp-query+watchlistraw-summary": "現在の利用者のウォッチリストにあるすべてのページを取得します。",
+ "apihelp-query+watchlistraw-param-namespace": "この名前空間に含まれるページのみを一覧表示します。",
+ "apihelp-query+watchlistraw-param-prop": "追加で取得するプロパティ:",
+ "apihelp-query+watchlistraw-param-dir": "一覧表示する方向。",
+ "apihelp-query+watchlistraw-example-generator": "現在の利用者のウォッチリスト上のページに関する情報を取得する。",
+ "apihelp-resetpassword-example-user": "利用者 <kbd>Example</kbd> にパスワード再設定の電子メールを送信する。",
+ "apihelp-revisiondelete-summary": "版の削除および復元を行います。",
+ "apihelp-revisiondelete-param-reason": "削除または復元の理由。",
+ "apihelp-revisiondelete-example-revision": "<kbd>Main Page</kbd> の版 <kbd>12345</kbd> の本文を隠す。",
+ "apihelp-rollback-param-title": "巻き戻すページ名です。<var>$1pageid</var> とは同時に使用できません。",
+ "apihelp-rollback-param-pageid": "巻き戻すページのページIDです。<var>$1title</var> とは同時に使用できません。",
+ "apihelp-rollback-param-tags": "巻き戻しに適用するタグ。",
+ "apihelp-rollback-param-user": "巻き戻し対象の編集を行った利用者名。",
+ "apihelp-rollback-param-markbot": "巻き戻された編集と巻き戻しをボットの編集としてマークする。",
+ "apihelp-rollback-example-simple": "利用者 <kbd>Example</kbd> による <kbd>Main Page</kbd> への最後の一連の編集を巻き戻す。",
+ "apihelp-rollback-example-summary": "IP利用者 <kbd>192.0.2.5</kbd> による <kbd>Main Page</kbd> への最後の一連の編集を <kbd>Reverting vandalism</kbd> という理由で、それらの編集とその差し戻しをボットの編集としてマークして差し戻す。",
+ "apihelp-setpagelanguage-summary": "ページの言語を変更します。",
+ "apihelp-setpagelanguage-extended-description-disabled": "ページ言語の変更はこのwikiでは許可されていません。\n\nこの操作を利用するには、<var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> を設定してください。",
+ "apihelp-setpagelanguage-param-title": "言語を変更したいページのページ名。<var>$1pageid</var> とは同時に使用できません。",
+ "apihelp-setpagelanguage-param-pageid": "言語を変更したいページのページID。<var>$1title</var> とは同時に使用できません。",
+ "apihelp-setpagelanguage-param-reason": "変更の理由。",
+ "apihelp-stashedit-param-title": "編集されているページのページ名。",
+ "apihelp-stashedit-param-section": "節番号です。先頭の節の場合は <kbd>0</kbd>、新しい節の場合は <kbd>new</kbd>を指定します。",
+ "apihelp-stashedit-param-sectiontitle": "新しい節の名前です。",
+ "apihelp-stashedit-param-text": "ページの本文。",
+ "apihelp-stashedit-param-contentmodel": "新しいコンテンツのコンテンツ・モデル。",
+ "apihelp-tag-summary": "個々の版または記録項目に対しタグの追加または削除を行います。",
+ "apihelp-tag-param-add": "追加するタグ。手動で定義されたタグのみ追加可能です。",
+ "apihelp-tag-param-reason": "変更の理由。",
+ "apihelp-tag-example-rev": "版ID 123に <kbd>vandalism</kbd> タグを理由を指定せずに追加する",
+ "apihelp-tag-example-log": "<kbd>Wrongly applied</kbd> という理由で <kbd>spam</kbd> タグを 記録項目ID 123 から取り除く",
+ "apihelp-tokens-param-type": "リクエストするトークンの種類。",
+ "apihelp-tokens-example-edit": "編集トークンを取得する (既定)。",
+ "apihelp-unblock-summary": "利用者のブロックを解除します。",
+ "apihelp-unblock-param-id": "解除するブロックのID (<kbd>list=blocks</kbd>で取得できます)。<var>$1user</var> とは同時に使用できません。",
+ "apihelp-unblock-param-user": "ブロックを解除する利用者名、IPアドレスまたはIPレンジ。<var>$1id</var>とは同時に使用できません。",
+ "apihelp-unblock-param-reason": "ブロック解除の理由。",
+ "apihelp-unblock-param-tags": "ブロック記録の項目に適用する変更タグ。",
+ "apihelp-unblock-example-id": "ブロックID #<kbd>105</kbd> を解除する。",
+ "apihelp-unblock-example-user": "<kbd>Sorry Bob</kbd> という理由で利用者 <kbd>Bob</kbd> のブロックを解除する。",
+ "apihelp-undelete-summary": "削除されたページの版を復元します。",
+ "apihelp-undelete-extended-description": "削除された版の一覧 (タイムスタンプを含む) は[[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]]に、また削除されたファイルのID一覧は[[Special:ApiHelp/query+filearchive|list=filearchive]]で見つけることができます。",
+ "apihelp-undelete-param-title": "復元するページ名。",
+ "apihelp-undelete-param-reason": "復元の理由。",
+ "apihelp-undelete-param-tags": "削除記録の項目に適用する変更タグ。",
+ "apihelp-undelete-param-timestamps": "復元する版のタイムスタンプ。<var>$1timestamps</var> と <var>$1fileids</var> の両方が空の場合、すべての版が復元されます。",
+ "apihelp-undelete-example-page": "<kbd>Main Page</kbd> を復元する。",
+ "apihelp-undelete-example-revisions": "<kbd>Main Page</kbd> の2つの版を復元する。",
+ "apihelp-upload-param-watch": "このページをウォッチする。",
+ "apihelp-upload-param-ignorewarnings": "あらゆる警告を無視する。",
+ "apihelp-upload-param-url": "ファイル取得元のURL.",
+ "apihelp-userrights-summary": "利用者の所属グループを変更します。",
+ "apihelp-userrights-param-user": "利用者名。",
+ "apihelp-userrights-param-userid": "利用者ID。",
+ "apihelp-userrights-param-add": "利用者をこのグループに追加します。",
+ "apihelp-userrights-param-reason": "変更の理由。",
+ "apihelp-userrights-example-expiry": "利用者 <kbd>SometimeSysop</kbd> を 1ヶ月間 <kbd>sysop</kbd> グループに追加する。",
+ "apihelp-watch-summary": "現在の利用者のウォッチリストにページを追加/除去します。",
+ "apihelp-watch-example-watch": "<kbd>Main Page</kbd> をウォッチする。",
+ "apihelp-watch-example-unwatch": "<kbd>Main Page</kbd> のウォッチを解除する。",
+ "apihelp-format-example-generic": "クエリの結果を $1 形式で返します。",
+ "apihelp-json-summary": "データを JSON 形式で出力します。",
+ "apihelp-json-param-callback": "指定すると、指定した関数呼び出しで出力をラップします。安全のため、利用者固有のデータはすべて制限されます。",
+ "apihelp-json-param-utf8": "指定すると、大部分の非 ASCII 文字 (すべてではありません) を、16 進のエスケープ シーケンスに置換する代わりに UTF-8 として符号化します。<var>formatversion</var> が <kbd>1</kbd> でない場合は既定です。",
+ "apihelp-json-param-ascii": "指定すると、すべての非ASCII文字を16進エスケープにエンコードします。<var>formatversion</var> が <kbd>1</kbd> の場合既定です。",
+ "apihelp-jsonfm-summary": "データを JSON 形式 (HTML に埋め込んだ形式) で出力します。",
+ "apihelp-none-summary": "何も出力しません。",
+ "apihelp-php-summary": "データを PHP のシリアル化した形式で出力します。",
+ "apihelp-phpfm-summary": "データを PHP のシリアル化した形式 (HTML に埋め込んだ形式) で出力します。",
+ "apihelp-rawfm-summary": "データをデバッグ要素付きで JSON 形式 (HTML に埋め込んだ形式) で出力します。",
+ "apihelp-xml-summary": "データを XML 形式で出力します。",
+ "apihelp-xml-param-xslt": "指定すると、XSLスタイルシートとして名付けられたページを追加します。値は、必ず、{{ns:MediaWiki}} 名前空間の、ページ名の末尾が <code>.xsl</code> でのタイトルである必要があります。",
+ "apihelp-xml-param-includexmlnamespace": "指定すると、XML 名前空間を追加します。",
+ "apihelp-xmlfm-summary": "データを XML 形式 (HTML に埋め込んだ形式) で出力します。",
+ "api-format-title": "MediaWiki API の結果",
+ "api-format-prettyprint-header": "このページは $1 形式を HTML で表現したものです。HTML はデバッグに役立ちますが、アプリケーションでの使用には適していません。\n\n<var>format</var> パラメーターを指定すると出力形式を変更できます 。$1 形式の非 HTML 版を閲覧するには、format=$2 を設定してください。\n\n詳細情報については [[mw:Special:MyLanguage/API|完全な説明文書]]または [[Special:ApiHelp/main|API のヘルプ]]を参照してください。",
+ "api-pageset-param-titles": "対象のページ名のリスト。",
+ "api-pageset-param-pageids": "対象のページIDのリスト。",
+ "api-pageset-param-revids": "対象の版IDのリスト。",
+ "api-pageset-param-generator": "クエリモジュールを実行することにより対象のページの一覧を取得する。\n\n<strong>注意</strong> Generator パラメーターの名前は \"g\" で始まります。例を参照してください。",
+ "api-pageset-param-redirects-generator": "<var>$1titles</var>, <var>$1pageids</var>, および <var>$1revids</var>, および <var>$1generator</var> によって返されたページの転送を自動的に解決する。",
+ "api-pageset-param-redirects-nogenerator": "<var>$1titles</var>, <var>$1pageids</var>, および <var>$1revids</var> の転送を自動的に解決する。",
+ "api-help-title": "MediaWiki API ヘルプ",
+ "api-help-lead": "このページは自動生成された MediaWiki API の説明文書ページです。\n\n説明文書と例: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "メイン モジュール",
+ "api-help-flag-deprecated": "このモジュールは廃止予定です。",
+ "api-help-flag-internal": "<strong>このモジュールは内部的または不安定です。</strong>動作が予告なく変更される場合があります。",
+ "api-help-flag-readrights": "このモジュールは読み取りの権限を必要とします。",
+ "api-help-flag-writerights": "このモジュールは書き込みの権限を必要とします。",
+ "api-help-flag-mustbeposted": "このモジュールは POST リクエストのみを受け付けます。",
+ "api-help-flag-generator": "このモジュールはジェネレーターとして使用できます。",
+ "api-help-parameters": "{{PLURAL:$1|パラメーター}}:",
+ "api-help-param-deprecated": "廃止予定です。",
+ "api-help-param-required": "このパラメーターは必須です。",
+ "api-help-datatypes-header": "データ型",
+ "api-help-param-list": "{{PLURAL:$1|1=値 (次の値のいずれか1つ)|2=値 (<kbd>{{!}}</kbd>もしくは[[Special:ApiHelp/main#main/datatypes|別の文字列]]で区切る)}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=空欄にしてください|空欄にするか、または $2}}",
+ "api-help-param-integer-min": "{{PLURAL:$1|値}}は $2 以上にしてください。",
+ "api-help-param-integer-max": "{{PLURAL:$1|値}}は $3 以下にしてください。",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|値}}は $2 以上 $3 以下にしてください。",
+ "api-help-param-upload": "multipart/form-data 形式でファイルをアップロードしてください。",
+ "api-help-param-multi-separate": "複数の値は <kbd>|</kbd> もしくは[[Special:ApiHelp/main#main/datatypes|代わりの文字]]で区切ってください。",
+ "api-help-param-multi-max": "値の最大値は {{PLURAL:$1|$1}} (ボットの場合は {{PLURAL:$2|$2}}) です。",
+ "api-help-param-default": "既定値: $1",
+ "api-help-param-default-empty": "既定値: <span class=\"apihelp-empty\">(空)</span>",
+ "api-help-param-token": "[[Special:ApiHelp/query+tokens|action=query&meta=tokens]] から取得した「$1」トークン",
+ "api-help-param-token-webui": "互換性のために、ウェブUIで使用されるトークンも受理されます。",
+ "api-help-param-limited-in-miser-mode": "<strong>注意:</strong> [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]] により、これを使用すると継続する前に <var>$1limit</var> より返される結果が少なくなることがあります; 極端な場合では、ゼロ件の結果が返ることもあります。",
+ "api-help-param-direction": "列挙の方向:\n;newer:古いものを先に表示します。注意: $1start は $1end 以前でなければなりません。\n;older:新しいものを先に表示します (既定)。注意: $1start は $1end 以降でなければなりません。",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(説明なし)</span>",
+ "api-help-examples": "{{PLURAL:$1|例}}:",
+ "api-help-permissions": "{{PLURAL:$1|権限}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|権限を持つグループ}}: $2",
+ "api-help-open-in-apisandbox": "<small>[サンドボックスで開く]</small>",
+ "apierror-missingparam": "パラメーター <var>$1</var> を設定してください。",
+ "apierror-timeout": "サーバーが決められた時間内に応答しませんでした。",
+ "apiwarn-invalidcategory": "「$1」はカテゴリではありません。",
+ "apiwarn-notfile": "「$1」はファイルではありません。",
+ "api-credits-header": "クレジット",
+ "api-credits": "API の開発者:\n* Roan Kattouw (2007年9月-2009年の主任開発者)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (作成者、2006年9月-2007年9月の主任開発者)\n* Brad Jorsch (2013年-現在の主任開発者)\n\nコメント、提案、質問は mediawiki-api@lists.wikimedia.org にお送りください。\nバグはこちらへご報告ください: https://phabricator.wikimedia.org/"
+}
diff --git a/www/wiki/includes/api/i18n/jam.json b/www/wiki/includes/api/i18n/jam.json
new file mode 100644
index 00000000..3c44fd2a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/jam.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chabi1"
+ ]
+ },
+ "api-help-main-header": "Mien madyuul"
+}
diff --git a/www/wiki/includes/api/i18n/jv.json b/www/wiki/includes/api/i18n/jv.json
new file mode 100644
index 00000000..a250db01
--- /dev/null
+++ b/www/wiki/includes/api/i18n/jv.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "NoiX180"
+ ]
+ },
+ "apihelp-delete-example-simple": "Busak <kbd>Tepas</kbd>.",
+ "apihelp-query+backlinks-example-simple": "Tuduhaké pranala menyang <kbd>Kaca utama</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Deleng pratélan bab kaca-kaca sing nggayut <kbd>Kaca utama</kbd>.",
+ "apihelp-query+contributors-example-simple": "Tuduhaké para nyumbang <kbd>Kaca Utama</kbd>."
+}
diff --git a/www/wiki/includes/api/i18n/ka.json b/www/wiki/includes/api/i18n/ka.json
new file mode 100644
index 00000000..a9bc6860
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ka.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Irus"
+ ]
+ },
+ "apihelp-login-param-name": "მომხმარებლის სახელი",
+ "apihelp-login-param-password": "პაროლი",
+ "apihelp-login-param-domain": "ელ-ფოსტა (არასავალდებულო)",
+ "apihelp-login-example-login": "შესვლა"
+}
diff --git a/www/wiki/includes/api/i18n/kn.json b/www/wiki/includes/api/i18n/kn.json
new file mode 100644
index 00000000..705512eb
--- /dev/null
+++ b/www/wiki/includes/api/i18n/kn.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kamalaksha"
+ ]
+ },
+ "apihelp-query+watchlist-paramvalue-type-external": "ಬಾಹ್ಯ ಬದಲಾವಣೆಗಳು"
+}
diff --git a/www/wiki/includes/api/i18n/ko.json b/www/wiki/includes/api/i18n/ko.json
new file mode 100644
index 00000000..edcd41b8
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ko.json
@@ -0,0 +1,830 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kwj2772",
+ "Twotwo2019",
+ "아라",
+ "LiteHell",
+ "Ysjbserver",
+ "Alex00728",
+ "Hwangjy9",
+ "Kurousagi",
+ "Revi",
+ "Yearning",
+ "Priviet",
+ "Ykhwong",
+ "Jonghaya",
+ "Jerrykim306",
+ "코코아",
+ "Macofe"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|설명문서]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api 메일링 리스트]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API 알림 사항]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R 버그 및 요청]\n</div>\n<strong>상태:</strong> 이 페이지에 보이는 모든 기능은 정상적으로 작동하지만, API는 여전히 활발하게 개발되고 있으며, 언제든지 변경될 수 있습니다. 업데이트 공지를 받아보려면 [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mediawiki-api-announce 메일링 리스트]를 구독하십시오.\n\n<strong>잘못된 요청:</strong> API에 잘못된 요청이 전송되면 \"MediaWiki-API-Error\" 키가 포함된 HTTP 헤더가 전송되며 반환되는 헤더와 오류 코드의 값은 모두 동일한 값으로 설정됩니다. 자세한 정보에 대해서는 [[mw:Special:MyLanguage/API:Errors and warnings/ko|API:오류와 경고]]를 참조하십시오.\n\n<strong>테스트하기:</strong> API 요청 테스트를 용이하게 하려면, [[Special:ApiSandbox]]를 보십시오.",
+ "apihelp-main-param-action": "수행할 동작",
+ "apihelp-main-param-format": "출력값의 형식.",
+ "apihelp-main-param-maxlag": "최대 랙은 미디어위키가 데이터베이스 복제된 클러스터에 설치되었을 때 사용될 수 있습니다. 특정한 행동이 사이트 복제 랙을 유발할 때, 이 변수는 클라이언트가 복제 랙이 설정된 숫자 아래로 내려갈 때까지 기다리도록 지시합니다. 과도한 랙의 경우, <samp>maxlag</samp> 오류 코드와 <samp>$host 대기 중: $lag초 지연되었습니다</samp> 메시지가 제공됩니다.<br />[[mw:Special:MyLanguage/Manual:Maxlag_parameter|매뉴얼: Maxlag 변수]]에서 더 많은 정보를 얻을 수 있습니다.",
+ "apihelp-main-param-smaxage": "<code>s-maxage</code> HTTP 캐시 컨트롤 헤더를 설정합니다. 오류는 캐시되지 않습니다.",
+ "apihelp-main-param-maxage": "<code>max-age</code> HTTP 캐시 컨트롤 헤더를 설정합니다. 오류는 캐시되지 않습니다.",
+ "apihelp-main-param-assert": "<kbd>user</kbd> 플래그가 설정되어 있다면 로그인 여부를 체크하며, <kbd>bot</kbd> 플래그가 설정되어 있다면 봇 사용자 권한이 설정되어 있는지 확인합니다.",
+ "apihelp-main-param-assertuser": "현재 사용자가 지명된 사용자인지 확인합니다.",
+ "apihelp-main-param-requestid": "주어진 요청 값은 응답에 포함됩니다. 요청을 구분하기 위해 사용될 수 있습니다.",
+ "apihelp-main-param-servedby": "결과에 요청을 처리한 호스트네임을 포함합니다.",
+ "apihelp-main-param-curtimestamp": "결과의 타임스탬프를 포함합니다.",
+ "apihelp-main-param-responselanginfo": "<var>uselang</var> 및 <var>errorlang</var>에 사용되는 언어를 결과에 포함합니다.",
+ "apihelp-main-param-origin": "크로스 도메인 AJAX 요청 (CORS)을 사용하여 API에 접근할 때, 이것을 발신 도메인으로 설정하십시오. 모든 pre-flight 요청에 포함되어야 하며, 이에 따라 (POST 본문이 아닌) 요청 URI의 일부여야 합니다.\n\n인증된 요청의 경우, <code>Origin</code> 헤더의 발신지들 중 하나와 정확히 일치해야 하므로 <kbd>https://en.wikipedia.org</kbd> 또는 <kbd>https://meta.wikimedia.org</kbd>와 같이 설정되어야 합니다. 이 변수가 <code>Origin</code> 헤더와 일치하지 않으면 403 응답이 반환됩니다. 이 변수가 <code>Origin</code> 헤더와 일치하고 발신지가 화이트리스트에 있을 경우 <code>Access-Control-Allow-Origin</code>과 <code>Access-Control-Allow-Credentials</code> 헤더가 설정됩니다.\n\n인증되지 않은 요청의 경우, <kbd>*</kbd> 값을 지정하십시오. 이를 통해 <code>Access-Control-Allow-Origin</code> 헤더가 설정되지만 <code>Access-Control-Allow-Credentials</code>는 <code>false</code>로 설정되어 모든 사용자 지정 데이터가 제한을 받게 됩니다.",
+ "apihelp-main-param-uselang": "메시지 번역을 위한 언어입니다. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>에 <kbd>siprop=languages</kbd>를 함께 사용하면 언어 코드의 목록을 반환하고, <kbd>user</kbd>를 지정하면 현재 사용자의 언어 환경 설정을 사용하며, <kbd>content</kbd>를 지정하면 이 위키의 콘텐츠 언어를 사용합니다.",
+ "apihelp-main-param-errorformat": "경고 및 오류 텍스트 출력을 위해 사용할 형식입니다.\n; plaintext: HTML 태그가 제거되고 엔티티가 치환된 위키텍스트입니다.\n; wikitext: 구문 분석되지 않은 위키텍스트입니다.\n; html: HTML입니다.\n; raw: 메시지 키와 변수입니다.\n; none: 텍스트 없이 오류 코드만 출력합니다.\n; bc: 미디어위키 1.29 이전에 사용된 형식입니다. <var>errorlang</var> 및 <var>errorsuselocal</var>은 무시됩니다.",
+ "apihelp-main-param-errorlang": "경고와 오류를 위해 사용할 언어입니다. <kbd>siprop=languages</kbd>가 포함된 <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>는 언어 코드의 목록을 반환하고, <kbd>content</kbd>를 지정하면 이 위키의 내용 상의 언어를 사용하며, <kbd>uselang</kbd>을 지정하면 <var>uselang</var> 변수와 동일한 값을 사용합니다.",
+ "apihelp-main-param-errorsuselocal": "지정하면 오류 텍스트가 {{ns:MediaWiki}} 이름공간에서 지역적으로 정의된 메시지를 사용합니다.",
+ "apihelp-block-summary": "사용자를 차단합니다.",
+ "apihelp-block-param-user": "차단할 사용자 이름, IP 주소, 또는 IP 주소 대역입니다. <var>$1userid</var>와(과) 함께 사용할 수 없습니다.",
+ "apihelp-block-param-userid": "차단할 사용자 ID입니다. <var>$1user</var>와(과) 함께 사용할 수 없습니다.",
+ "apihelp-block-param-expiry": "기한. 상대값(예시: <kbd>5 months</kbd> 또는 </kbd>2 weeks</kbd>) 또는 절대값(예시: <kbd>2014-09-18T12:34:56Z</kbd>)이 될 수 있습니다. <kbd>infinite</kbd>, <kbd>indefinite</kbd> 또는 <kbd>never</kbd>로 설정하면 무기한으로 설정됩니다.",
+ "apihelp-block-param-reason": "차단 이유.",
+ "apihelp-block-param-anononly": "익명 사용자만 차단합니다. (즉, 이 IP의 익명 편집을 막음)",
+ "apihelp-block-param-nocreate": "계정 생성을 막습니다.",
+ "apihelp-block-param-autoblock": "최근 사용한 IP 주소나 로그인을 시도한 이후에 사용한 모든 IP 주소를 자동으로 차단합니다.",
+ "apihelp-block-param-noemail": "위키를 통해 이메일을 보내지 못하도록 막습니다. (<code>blockemail</code> 권한 필요)",
+ "apihelp-block-param-hidename": "차단 기록에서 사용자 이름을 숨깁니다. (<code>hideuser</code> 권한 필요)",
+ "apihelp-block-param-allowusertalk": "사용자가 자신의 토론 문서를 편집할 수 있도록 허용합니다 (<var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>에 따라 다름)",
+ "apihelp-block-param-reblock": "사용자가 이미 차단된 경우, 기존 차단 설정을 바꿉니다.",
+ "apihelp-block-param-watchuser": "해당 사용자 또는 IP 주소의 사용자 문서 및 토론 문서를 주시합니다.",
+ "apihelp-block-param-tags": "차단 기록의 항목에 적용할 태그를 변경합니다.",
+ "apihelp-block-example-ip-simple": "IP <kbd>192.0.2.5</kbd>에 대해 <kbd>First strike</kbd>라는 이유로 3일 간 차단하기",
+ "apihelp-block-example-user-complex": "사용자 <kbd>Vandal</kbd>을 <kbd>Vandalism</kbd>이라는 이유로 무기한 차단하며 계정 생성 및 이메일 발송을 막기",
+ "apihelp-changeauthenticationdata-summary": "현재 사용자의 인증 데이터를 변경합니다.",
+ "apihelp-changeauthenticationdata-example-password": "현재 사용자의 비밀번호를 <kbd>ExamplePassword</kbd>로 바꾸는 것을 시도합니다.",
+ "apihelp-checktoken-summary": "<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>의 토큰의 유효성을 확인합니다.",
+ "apihelp-checktoken-param-type": "테스트되는 토큰의 종류.",
+ "apihelp-checktoken-param-token": "테스트할 토큰",
+ "apihelp-checktoken-param-maxtokenage": "초로 계산된 토큰의 최대 나이.",
+ "apihelp-checktoken-example-simple": "<kbd>csrf</kbd> 토큰의 유효성을 테스트합니다.",
+ "apihelp-clearhasmsg-summary": "현재 사용자의 <code>hasmsg</code> 플래그를 비웁니다.",
+ "apihelp-clearhasmsg-example-1": "현재 계정의 <code>hasmsg</code> 플래그를 삭제합니다.",
+ "apihelp-clientlogin-summary": "상호작용 플로우를 이용하여 위키에 로그인합니다.",
+ "apihelp-clientlogin-example-login": "사용자 <kbd>Example</kbd>, 비밀번호 <kbd>ExamplePassword</kbd>로 위키 로그인 과정을 시작합니다.",
+ "apihelp-clientlogin-example-login2": "<kbd>987654</kbd>의 <var>OATHToken</var>을 지정하여 2요소 인증을 위한 <samp>UI</samp> 응답 이후에 로그인을 계속합니다.",
+ "apihelp-compare-summary": "두 문서 간의 차이를 가져옵니다.",
+ "apihelp-compare-extended-description": "대상이 되는 두 문서의 판 번호나 문서 제목 또는 문서 ID를 지정해야 합니다.",
+ "apihelp-compare-param-fromtitle": "비교할 첫 이름.",
+ "apihelp-compare-param-fromid": "비교할 첫 문서 ID.",
+ "apihelp-compare-param-fromrev": "비교할 첫 판.",
+ "apihelp-compare-param-totitle": "비교할 두 번째 제목.",
+ "apihelp-compare-param-toid": "비교할 두 번째 문서 ID.",
+ "apihelp-compare-param-torev": "비교할 두 번째 판.",
+ "apihelp-compare-param-prop": "가져올 정보입니다.",
+ "apihelp-compare-example-1": "판 1과 2의 차이를 생성합니다.",
+ "apihelp-createaccount-summary": "새 사용자 계정을 만듭니다.",
+ "apihelp-createaccount-example-create": "비밀번호 <kbd>ExamplePassword</kbd>로 된 사용자 <kbd>Example</kbd>의 생성 과정을 시작합니다.",
+ "apihelp-createaccount-param-name": "사용자 이름",
+ "apihelp-createaccount-param-password": "비밀번호입니다. (<var>$1mailpassword</var>가 설정되어 있으면 무시됩니다)",
+ "apihelp-createaccount-param-domain": "외부 인증의 도메인 (선택적)",
+ "apihelp-createaccount-param-token": "첫 요청에서 획득한 계정 생성 토큰.",
+ "apihelp-createaccount-param-email": "사용자 이메일 주소 (선택).",
+ "apihelp-createaccount-param-realname": "사용자 실명 (선택).",
+ "apihelp-createaccount-param-mailpassword": "아무 값이든 존재한다면, 랜덤 비밀번호가 이메일로 전송됩니다.",
+ "apihelp-createaccount-param-reason": "선택적인, 기록에 남을 계정을 만드는 이유",
+ "apihelp-createaccount-param-language": "사용자에게 기본으로 설정할 언어 코드. (선택 사항, 기본값으로는 본문의 언어입니다)",
+ "apihelp-createaccount-example-pass": "사용자 <kbd>testuser</kbd>를 만들고 비밀번호를 <kbd>test123</kbd>으로 설정합니다.",
+ "apihelp-createaccount-example-mail": "사용자 <kbd>testmailuser</kbd>를 만들고 자동 생성된 비밀번호를 이메일로 보냅니다.",
+ "apihelp-cspreport-summary": "브라우저가 콘텐츠 보안 정책의 위반을 보고하기 위해 사용합니다. 이 모듈은 SCP를 준수하는 웹 브라우저에 의해 자동으로 사용될 때를 제외하고는 사용해서는 안 됩니다.",
+ "apihelp-cspreport-param-reportonly": "강제적 정책이 아닌, 모니터링 정책에서 나온 보고서인 것으로 표시합니다",
+ "apihelp-delete-summary": "문서 삭제",
+ "apihelp-delete-param-title": "삭제할 문서의 제목. <var>$1pageid</var>과 함께 사용할 수 없습니다.",
+ "apihelp-delete-param-pageid": "삭제할 문서의 ID. <var>$1title</var>과 함께 사용할 수 없습니다.",
+ "apihelp-delete-param-reason": "삭제의 이유. 설정하지 않으면 자동 생성되는 이유를 사용합니다.",
+ "apihelp-delete-param-watch": "문서를 현재 사용자의 주시문서 목록에 추가합니다.",
+ "apihelp-delete-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.",
+ "apihelp-delete-param-unwatch": "문서를 현재 사용자의 주시문서 목록에서 제거합니다.",
+ "apihelp-delete-example-simple": "<kbd>Main Page</kbd>를 삭제합니다.",
+ "apihelp-delete-example-reason": "<kbd>Preparing for move</kbd> 라는 이유로 <kbd>Main Page</kbd>를 삭제하기.",
+ "apihelp-disabled-summary": "이 모듈은 해제되었습니다.",
+ "apihelp-edit-summary": "문서를 만들고 편집합니다.",
+ "apihelp-edit-param-title": "편집할 문서의 제목. <var>$1pageid</var>과 같이 사용할 수 없습니다.",
+ "apihelp-edit-param-section": "문단 번호입니다. <kbd>0</kbd>은 최상위 문단, <kbd>new</kbd>는 새 문단입니다.",
+ "apihelp-edit-param-sectiontitle": "새 문단을 위한 제목.",
+ "apihelp-edit-param-text": "문서 내용.",
+ "apihelp-edit-param-summary": "편집 요약. 또한 $1section=new 및 $1sectiontitle이 설정되어 있지 않을 때 문단 제목.",
+ "apihelp-edit-param-tags": "이 판에 적용할 태그를 변경합니다.",
+ "apihelp-edit-param-minor": "사소한 편집.",
+ "apihelp-edit-param-notminor": "사소하지 않은 편집.",
+ "apihelp-edit-param-bot": "이 편집을 봇 편집으로 표시.",
+ "apihelp-edit-param-createonly": "이 페이지가 이미 존재하면 편집하지 않습니다.",
+ "apihelp-edit-param-nocreate": "페이지가 존재하지 않으면 오류를 출력합니다.",
+ "apihelp-edit-param-watch": "문서를 현재 사용자의 주시문서 목록에 추가합니다.",
+ "apihelp-edit-param-unwatch": "문서를 현재 사용자의 주시문서 목록에서 제거합니다.",
+ "apihelp-edit-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.",
+ "apihelp-edit-param-redirect": "자동으로 넘겨주기 처리하기.",
+ "apihelp-edit-param-contentmodel": "새 콘텐츠의 콘텐츠 모델.",
+ "apihelp-edit-example-edit": "문서 편집",
+ "apihelp-edit-example-undo": "자동 편집요약으로 13579판에서 13585판까지 되돌리기.",
+ "apihelp-emailuser-summary": "사용자에게 이메일을 보냅니다.",
+ "apihelp-emailuser-param-target": "이메일을 받을 사용자.",
+ "apihelp-emailuser-param-subject": "제목 헤더.",
+ "apihelp-emailuser-param-text": "메일 본문.",
+ "apihelp-emailuser-param-ccme": "자신에게 메일의 복사본을 보냅니다.",
+ "apihelp-emailuser-example-email": "<kbd>WikiSysop</kbd> 사용자에게 텍스트 <kbd>Content</kbd>로 이메일을 보냅니다.",
+ "apihelp-expandtemplates-summary": "위키텍스트 안에 모든 틀을 확장합니다.",
+ "apihelp-expandtemplates-param-title": "문서 제목",
+ "apihelp-expandtemplates-param-text": "변환할 위키텍스트.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "확장된 위키텍스트.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "입력값의 XML 파서 트리.",
+ "apihelp-expandtemplates-param-includecomments": "출력에 HTML 주석을 포함할 것인지의 여부.",
+ "apihelp-expandtemplates-param-generatexml": "XML 구문 분석 트리를 생성합니다. ($1prop=parsetree로 대체함)",
+ "apihelp-expandtemplates-example-simple": "위키텍스트 <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>를 확장합니다.",
+ "apihelp-feedcontributions-summary": "사용자 기여 피드를 반환합니다.",
+ "apihelp-feedcontributions-param-feedformat": "피드 포맷.",
+ "apihelp-feedcontributions-param-user": "기여를 읽을 사용자 이름.",
+ "apihelp-feedcontributions-param-namespace": "기여를 분류할 이름공간",
+ "apihelp-feedcontributions-param-year": "년부터 (혹은 그 이전).",
+ "apihelp-feedcontributions-param-month": "달부터 (혹은 그 이전).",
+ "apihelp-feedcontributions-param-deletedonly": "삭제된 기여만 봅니다.",
+ "apihelp-feedcontributions-param-toponly": "최신 판인 편집만 봅니다.",
+ "apihelp-feedcontributions-param-newonly": "새 글인 편집만 봅니다.",
+ "apihelp-feedcontributions-param-hideminor": "사소한 편집을 숨깁니다.",
+ "apihelp-feedcontributions-param-showsizediff": "판 사이의 크기 차이를 보여줍니다.",
+ "apihelp-feedrecentchanges-summary": "최근 바뀜 피드를 반환합니다.",
+ "apihelp-feedrecentchanges-param-feedformat": "피드 포맷.",
+ "apihelp-feedrecentchanges-param-namespace": "결과를 제한할 이름공간.",
+ "apihelp-feedrecentchanges-param-invert": "선택한 항목을 제외한 모든 이름공간.",
+ "apihelp-feedrecentchanges-param-associated": "관련 (토론 또는 일반) 이름공간을 포함합니다.",
+ "apihelp-feedrecentchanges-param-limit": "반환할 결과의 최대 수.",
+ "apihelp-feedrecentchanges-param-from": "이후의 변경사항을 보여줍니다.",
+ "apihelp-feedrecentchanges-param-hideminor": "사소한 편집을 숨깁니다.",
+ "apihelp-feedrecentchanges-param-hidebots": "봇의 편집을 숨깁니다.",
+ "apihelp-feedrecentchanges-param-hideanons": "익명 사용자의 편집을 숨깁니다.",
+ "apihelp-feedrecentchanges-param-hideliu": "등록된 사용자의 편집을 숨깁니다.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "검토된 편집을 숨깁니다.",
+ "apihelp-feedrecentchanges-param-hidemyself": "현재 사용자가 변경한 사항을 숨깁니다.",
+ "apihelp-feedrecentchanges-param-tagfilter": "태그로 분류",
+ "apihelp-feedrecentchanges-example-simple": "최근 바뀜을 봅니다.",
+ "apihelp-feedrecentchanges-example-30days": "30일간의 최근 바뀜을 봅니다.",
+ "apihelp-feedwatchlist-summary": "주시문서 목록 피드를 반환합니다.",
+ "apihelp-feedwatchlist-param-feedformat": "피드 포맷.",
+ "apihelp-feedwatchlist-example-default": "주시문서 목록 피드를 보여줍니다.",
+ "apihelp-filerevert-summary": "파일을 이전 판으로 되돌립니다.",
+ "apihelp-filerevert-param-filename": "파일: 접두어가 없는 대상 파일 이름.",
+ "apihelp-filerevert-param-comment": "업로드 댓글입니다.",
+ "apihelp-filerevert-example-revert": "<kbd>Wiki.png</kbd>를 <kbd>2011-03-05T15:27:40Z</kbd> 판으로 되돌립니다.",
+ "apihelp-help-summary": "지정된 모듈의 도움말을 표시합니다.",
+ "apihelp-help-param-modules": "(<var>action</var>, <var>format</var> 변수의 값 또는 <kbd>main</kbd>)에 대한 도움말을 표시하는 모듈입니다. <kbd>+</kbd>로 하위 모듈을 지정할 수 있습니다.",
+ "apihelp-help-param-submodules": "명명된 모듈의 하위 모듈의 도움말을 포함합니다.",
+ "apihelp-help-param-recursivesubmodules": "하위 모듈의 도움말을 반복하여 포함합니다.",
+ "apihelp-help-param-helpformat": "도움말 출력 포맷.",
+ "apihelp-help-param-wrap": "표준 API 응답 구조로 출력을 감쌉니다.",
+ "apihelp-help-param-toc": "HTML 출력에 목차를 포함합니다.",
+ "apihelp-help-example-main": "메인 모듈의 도움말입니다.",
+ "apihelp-help-example-recursive": "모든 도움말을 한 페이지로 모읍니다.",
+ "apihelp-help-example-help": "도움말 모듈 자체에 대한 도움말입니다.",
+ "apihelp-help-example-query": "2개의 쿼리 하위 모듈의 도움말입니다.",
+ "apihelp-imagerotate-summary": "하나 이상의 그림을 회전합니다.",
+ "apihelp-imagerotate-param-rotation": "시계 방향으로 회전할 그림의 각도.",
+ "apihelp-import-summary": "다른 위키나 XML 파일로부터 문서를 가져옵니다.",
+ "apihelp-import-param-xml": "업로드한 XML 파일.",
+ "apihelp-linkaccount-summary": "서드파티 제공자의 계정을 현재 사용자와 연결합니다.",
+ "apihelp-login-summary": "로그인한 다음 인증 쿠키를 가져옵니다.",
+ "apihelp-login-param-name": "사용자 이름.",
+ "apihelp-login-param-password": "비밀번호.",
+ "apihelp-login-param-domain": "도메인 (선택).",
+ "apihelp-login-param-token": "처음 요청에서 로그인 토큰을 취득했습니다.",
+ "apihelp-login-example-gettoken": "로그인 토큰을 검색합니다.",
+ "apihelp-login-example-login": "로그인.",
+ "apihelp-logout-summary": "로그아웃하고 세션 데이터를 지웁니다.",
+ "apihelp-logout-example-logout": "현재 사용자를 로그아웃합니다.",
+ "apihelp-mergehistory-summary": "문서 역사를 합칩니다.",
+ "apihelp-mergehistory-param-reason": "문서 병합 이유.",
+ "apihelp-move-summary": "문서 이동하기.",
+ "apihelp-move-param-reason": "제목을 변경하는 이유",
+ "apihelp-move-param-movetalk": "토론 문서가 존재한다면, 토론 문서도 이름을 변경해주세요.",
+ "apihelp-move-param-movesubpages": "하위 문서가 있다면, 하위 문서도 이름을 변경해주세요.",
+ "apihelp-move-param-noredirect": "넘겨주기 문서 만들지 않기",
+ "apihelp-move-param-watch": "현재 사용자의 주시 문서에 이 문서와 넘겨주기 문서를 추가하기",
+ "apihelp-move-param-unwatch": "현재 사용자의 주시 문서에 이 문서와 넘겨주기 문서를 제거하기",
+ "apihelp-move-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.",
+ "apihelp-move-param-ignorewarnings": "모든 경고 무시하기",
+ "apihelp-move-example-move": "<kbd>기존 제목</kbd>에서 <kbd>대상 제목</kbd>으로 넘겨주기를 만들지 않고 이동하기.",
+ "apihelp-opensearch-summary": "OpenSearch 프로토콜을 이용하여 위키를 검색합니다.",
+ "apihelp-opensearch-param-search": "문자열 검색",
+ "apihelp-opensearch-param-limit": "반환할 결과의 최대 수",
+ "apihelp-opensearch-param-namespace": "검색할 이름공간.",
+ "apihelp-opensearch-param-suggest": "<var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var>이 거짓인 경우 아무 것도 하지 않습니다.",
+ "apihelp-opensearch-param-format": "출력 포맷.",
+ "apihelp-opensearch-example-te": "<kbd>Te</kbd>로 시작하는 문서를 찾기.",
+ "apihelp-options-summary": "현재 사용자의 환경 설정을 변경합니다.",
+ "apihelp-options-param-reset": "사이트 기본으로 설정 초기화",
+ "apihelp-options-example-reset": "모든 설정 초기화",
+ "apihelp-paraminfo-summary": "API 모듈의 정보를 가져옵니다.",
+ "apihelp-paraminfo-param-helpformat": "도움말 문자열 포맷.",
+ "apihelp-parse-summary": "내용의 구문을 분석하고 파서 출력을 반환합니다.",
+ "apihelp-parse-param-summary": "구문 분석할 요약입니다.",
+ "apihelp-parse-paramvalue-prop-text": "위키텍스트의 구문 분석된 텍스트를 제공합니다.",
+ "apihelp-parse-paramvalue-prop-langlinks": "구문 분석된 위키텍스트의 언어 링크를 제공합니다.",
+ "apihelp-parse-paramvalue-prop-categories": "구문 분석된 위키텍스트의 분류를 제공합니다.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "분류의 HTML 버전을 제공합니다.",
+ "apihelp-parse-paramvalue-prop-links": "구문 분석된 위키텍스트의 내부 링크를 제공합니다.",
+ "apihelp-parse-paramvalue-prop-templates": "구문 분석된 위키텍스트의 틀을 제공합니다.",
+ "apihelp-parse-paramvalue-prop-images": "구문 분석된 위키텍스트의 그림을 제공합니다.",
+ "apihelp-parse-paramvalue-prop-externallinks": "구문 분석된 위키텍스트의 외부 링크를 제공합니다.",
+ "apihelp-parse-paramvalue-prop-sections": "구문 분석된 위키텍스트의 문단을 제공합니다.",
+ "apihelp-parse-paramvalue-prop-revid": "구문 분석된 페이지의 판 ID를 추가합니다.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "구문 분석된 위키텍스트의 제목을 추가합니다.",
+ "apihelp-parse-paramvalue-prop-headitems": "문서의 <code>&lt;head&gt;</code> 안에 넣을 항목을 제공합니다.",
+ "apihelp-parse-paramvalue-prop-headhtml": "문서의 구문 분석된 <code>&lt;head&gt;</code>를 제공합니다.",
+ "apihelp-parse-paramvalue-prop-modules": "문서에 사용되는 ResourceLoader 모듈을 제공합니다. 불러오려면, <code>mw.loader.using()</code>을 사용하세요. <kbd>jsconfigvars</kbd> 또는 <kbd>encodedjsconfigvars</kbd>는 <kbd>modules</kbd>와 함께 요청해야 합니다.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "문서에 특화된 자바스크립트 구성 변수를 제공합니다. 적용하려면 <code>mw.config.set()</code>을 사용하세요.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "구문 분석된 위키텍스트의 인터위키 링크를 제공합니다.",
+ "apihelp-parse-paramvalue-prop-wikitext": "구문 분석된 위키텍스트 원문을 제공합니다.",
+ "apihelp-parse-paramvalue-prop-properties": "구문 분석된 위키텍스트에 정의된 다양한 속성을 제공합니다.",
+ "apihelp-parse-param-disablelimitreport": "파서 출력에서 제한 보고서(\"NewPP limit report\")를 제외합니다.",
+ "apihelp-parse-param-disablepp": "<var>$1disablelimitreport</var>를 대신 사용합니다.",
+ "apihelp-parse-param-disableeditsection": "파서 출력에서 문단 편집 링크를 제외합니다.",
+ "apihelp-parse-param-disabletidy": "파서 출력에서 HTML 정리(예: tidy)를 수행하지 않습니다.",
+ "apihelp-parse-param-preview": "미리 보기 모드에서 구문 분석을 합니다.",
+ "apihelp-parse-param-sectionpreview": "문단 미리 보기 모드에서 구문 분석을 합니다. (미리 보기 모드도 활성화함)",
+ "apihelp-parse-param-disabletoc": "출력에서 목차를 제외합니다.",
+ "apihelp-parse-param-useskin": "선택한 스킨을 파서 출력에 적용합니다. 다음의 속성에 영향을 줄 수 있습니다: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>modules</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicators</kbd>.",
+ "apihelp-parse-example-page": "페이지의 구문을 분석합니다.",
+ "apihelp-parse-example-text": "위키텍스트의 구문을 분석합니다.",
+ "apihelp-parse-example-summary": "요약을 구문 분석합니다.",
+ "apihelp-patrol-summary": "문서나 판을 점검하기.",
+ "apihelp-patrol-param-rcid": "점검할 최근 바뀜 ID입니다.",
+ "apihelp-patrol-param-revid": "점검할 판 ID입니다.",
+ "apihelp-patrol-example-rcid": "최근의 변경사항을 점검합니다.",
+ "apihelp-patrol-example-revid": "판을 점검합니다.",
+ "apihelp-protect-summary": "문서의 보호 수준을 변경합니다.",
+ "apihelp-protect-param-reason": "보호 또는 보호 해제의 이유.",
+ "apihelp-protect-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.",
+ "apihelp-protect-example-protect": "문서 보호",
+ "apihelp-purge-summary": "주어진 제목을 위한 캐시를 새로 고침.",
+ "apihelp-purge-param-forcelinkupdate": "링크 테이블을 업데이트합니다.",
+ "apihelp-purge-example-simple": "<kbd>Main Page</kbd>와 <kbd>API</kbd> 문서를 새로 고침.",
+ "apihelp-query-summary": "미디어위키의 데이터 및 정보를 가져옵니다.",
+ "apihelp-query-param-prop": "조회된 페이지에 대해 가져올 속성입니다.",
+ "apihelp-query-param-list": "가져올 목록입니다.",
+ "apihelp-query-param-meta": "가져올 메타데이터입니다.",
+ "apihelp-query-param-indexpageids": "반환된 모든 페이지 ID를 나열하는 부가적인 페이지 ID 섹션을 포함합니다.",
+ "apihelp-query-param-export": "기존의 페이지나 생성된 페이지들 전체의 현재 판들을 내보냅니다.",
+ "apihelp-query-param-exportnowrap": "XML 결과물로 래핑하지 않고 엑스포트 XML을 반환합니다. $1export와만 같이 사용할 수 있습니다.",
+ "apihelp-query-param-iwurl": "제목이 인터위키 링크인 경우 전체 URL을 가져올지의 여부입니다.",
+ "apihelp-query-param-rawcontinue": "계속하기 위해 순수 <samp>query-continue</samp> 데이터를 반환합니다.",
+ "apihelp-query+allcategories-summary": "모든 분류를 열거합니다.",
+ "apihelp-query+allcategories-param-prefix": "이 값으로 시작하는 모든 분류 제목을 검색합니다.",
+ "apihelp-query+allcategories-param-dir": "정렬 방향.",
+ "apihelp-query+allcategories-param-limit": "반환할 분류의 갯수입니다.",
+ "apihelp-query+allcategories-param-prop": "얻고자 하는 속성:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "페이지 수를 분류에 추가합니다.",
+ "apihelp-query+alldeletedrevisions-summary": "사용자에 의해서나 이름공간 안에서 삭제된 모든 판을 나열합니다.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "<var>$3user</var>와 함께 사용할 수 없습니다.",
+ "apihelp-query+alldeletedrevisions-param-from": "이 제목부터 목록을 보이기.",
+ "apihelp-query+alldeletedrevisions-param-to": "이 제목까지 목록을 보이기.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "이 값으로 시작하는 모든 문서 제목을 검색합니다.",
+ "apihelp-query+alldeletedrevisions-param-tag": "이 태그로 태그된 판만을 나열합니다.",
+ "apihelp-query+alldeletedrevisions-param-user": "이 사용자에 대한 판만 나열합니다.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "이 사용자에 대한 판을 나열하지 않습니다.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "이 이름공간의 문서만 나열합니다.",
+ "apihelp-query+alldeletedrevisions-example-user": "<kbd>Example</kbd>님의 최근 50개의 삭제된 기여를 나열합니다.",
+ "apihelp-query+allfileusages-summary": "존재하지 않는 것을 포함하여 파일을 사용하는 모든 문서를 나열합니다.",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "파일의 제목을 추가합니다.",
+ "apihelp-query+allfileusages-param-limit": "반환할 총 항목 수입니다.",
+ "apihelp-query+allfileusages-example-unique": "고유한 파일 제목을 나열합니다.",
+ "apihelp-query+allfileusages-example-unique-generator": "모든 파일 제목을 가져오되, 존재하지 않는 항목을 표시합니다.",
+ "apihelp-query+allfileusages-example-generator": "파일을 포함하는 문서를 가져옵니다.",
+ "apihelp-query+allimages-summary": "모든 그림을 순차적으로 열거합니다.",
+ "apihelp-query+allimages-example-recent": "최근 업로드된 파일을 보여줍니다. [[Special:NewFiles]]와 유사합니다.",
+ "apihelp-query+alllinks-summary": "제시된 이름공간을 가리키는 모든 링크를 열거합니다.",
+ "apihelp-query+alllinks-paramvalue-prop-title": "링크의 제목을 추가합니다.",
+ "apihelp-query+alllinks-param-namespace": "열거할 이름공간.",
+ "apihelp-query+alllinks-param-limit": "반환할 총 항목 수입니다.",
+ "apihelp-query+allmessages-summary": "이 사이트에서 반환할 메시지.",
+ "apihelp-query+allmessages-example-ipb": "<kbd>ipb-</kbd>로 시작하는 메시지를 보입니다.",
+ "apihelp-query+allpages-summary": "제시된 이름공간의 모든 문서를 순서대로 열거합니다.",
+ "apihelp-query+allpages-param-namespace": "열거할 이름공간.",
+ "apihelp-query+allredirects-summary": "이름공간의 모든 넘겨주기를 나열합니다.",
+ "apihelp-query+allredirects-paramvalue-prop-title": "넘겨주기의 제목을 추가합니다.",
+ "apihelp-query+allredirects-param-namespace": "열거할 이름공간.",
+ "apihelp-query+allredirects-param-limit": "반환할 총 항목 수입니다.",
+ "apihelp-query+allrevisions-summary": "모든 판 표시.",
+ "apihelp-query+mystashedfiles-param-limit": "가져올 파일의 갯수.",
+ "apihelp-query+alltransclusions-param-prop": "포함할 정보:",
+ "apihelp-query+alltransclusions-param-namespace": "열거할 이름공간.",
+ "apihelp-query+alltransclusions-param-limit": "반환할 총 항목 수입니다.",
+ "apihelp-query+allusers-summary": "등록된 모든 사용자를 열거합니다.",
+ "apihelp-query+allusers-param-dir": "정렬 방향.",
+ "apihelp-query+allusers-param-prop": "포함할 정보:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "현재 차단된 사용자의 정보를 추가함.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "사용자의 편집 수를 추가합니다.",
+ "apihelp-query+allusers-param-witheditsonly": "편집을 한 사용자만 나열합니다.",
+ "apihelp-query+allusers-example-Y": "<kbd>Y</kbd>로 시작하는 사용자를 나열합니다.",
+ "apihelp-query+authmanagerinfo-summary": "현재의 인증 상태에 대한 정보를 검색합니다.",
+ "apihelp-query+backlinks-summary": "제시된 문서에 연결된 모든 문서를 찾습니다.",
+ "apihelp-query+backlinks-param-namespace": "열거할 이름공간.",
+ "apihelp-query+backlinks-example-simple": "<kbd>Main Page</kbd>를 가리키는 링크를 보이기.",
+ "apihelp-query+backlinks-example-generator": "<kbd>Main Page</kbd>를 가리키는 문서의 정보를 보기.",
+ "apihelp-query+blocks-summary": "차단된 모든 사용자와 IP 주소를 나열합니다.",
+ "apihelp-query+blocks-param-start": "나열을 시작할 타임스탬프",
+ "apihelp-query+blocks-param-end": "나열을 끝낼 타임스탬프",
+ "apihelp-query+blocks-param-ids": "나열할 차단 ID 목록 (선택 사항).",
+ "apihelp-query+blocks-param-users": "검색할 사용자 목록 (선택 사항).",
+ "apihelp-query+blocks-param-prop": "얻고자 하는 속성:",
+ "apihelp-query+blocks-paramvalue-prop-id": "블록의 ID를 추가합니다.",
+ "apihelp-query+blocks-paramvalue-prop-user": "차단된 사용자의 사용자 이름을 추가합니다.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "차단된 사용자의 사용자 ID를 추가합니다.",
+ "apihelp-query+blocks-paramvalue-prop-by": "차단을 수행하는 사용자의 사용자 이름을 추가합니다.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "차단을 수행하는 사용자의 사용자 ID를 추가합니다.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "차단된 시점의 타임스탬프를 추가합니다.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "차단 만료 시점의 타임스탬프를 추가합니다.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "차단 이유를 추가합니다.",
+ "apihelp-query+blocks-paramvalue-prop-range": "차단에 영향을 받는 IP 주소 대역을 추가합니다.",
+ "apihelp-query+categories-summary": "문서가 속하는 모든 분류를 나열합니다.",
+ "apihelp-query+categories-param-limit": "반환할 분류의 갯수입니다.",
+ "apihelp-query+categoryinfo-summary": "제시된 분류의 정보를 반환합니다.",
+ "apihelp-query+categorymembers-summary": "제시된 분류의 모든 문서를 나열합니다.",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "페이지 ID를 추가합니다.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "문서의 제목과 이름공간 ID를 추가합니다.",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "문서가 포함된 시기의 타임스탬프를 추가합니다.",
+ "apihelp-query+categorymembers-param-limit": "반환할 문서의 최대 수입니다.",
+ "apihelp-query+categorymembers-param-startsortkey": "$1starthexsortkey를 대신 사용해 주십시오.",
+ "apihelp-query+categorymembers-param-endsortkey": "$1endhexsortkey를 대신 사용해 주십시오.",
+ "apihelp-query+contributors-summary": "문서에 대해 로그인한 기여자의 목록과 익명 기여자의 수를 가져옵니다.",
+ "apihelp-query+deletedrevisions-summary": "삭제된 판 정보를 가져옵니다.",
+ "apihelp-query+deletedrevs-summary": "삭제된 판을 나열합니다.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|모드|모드}}: $2",
+ "apihelp-query+deletedrevs-param-start": "나열을 시작할 타임스탬프",
+ "apihelp-query+deletedrevs-param-end": "나열을 끝낼 타임스탬프",
+ "apihelp-query+deletedrevs-param-limit": "나열할 판의 최대 양.",
+ "apihelp-query+disabled-summary": "이 쿼리 모듈은 비활성화되었습니다.",
+ "apihelp-query+duplicatefiles-summary": "해시 값 기반으로 주어진 파일들 중 중복된 모든 파일을 나열합니다.",
+ "apihelp-query+duplicatefiles-param-limit": "반환할 중복 파일의 수.",
+ "apihelp-query+embeddedin-summary": "제시된 문서를 끼워넣은 모든 문서를 찾습니다.",
+ "apihelp-query+embeddedin-param-namespace": "열거할 이름공간.",
+ "apihelp-query+extlinks-summary": "제시된 문서의 모든 외부 URL(인터위키 아님)을 반환합니다.",
+ "apihelp-query+extlinks-param-limit": "반환할 링크의 수.",
+ "apihelp-query+exturlusage-summary": "제시된 URL을 포함하는 문서를 열거합니다.",
+ "apihelp-query+exturlusage-param-prop": "포함할 정보:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "문서의 ID를 추가합니다.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "문서의 제목과 이름공간 ID를 추가합니다.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "문서에 사용된 URL을 추가합니다.",
+ "apihelp-query+exturlusage-param-namespace": "열거할 문서 이름공간.",
+ "apihelp-query+exturlusage-param-limit": "반환할 문서 수.",
+ "apihelp-query+filearchive-summary": "삭제된 모든 파일을 순서대로 열거합니다.",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "그림에 대한 SHA-1 해시를 추가합니다.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "그림 판을 올린 사용자를 추가합니다.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "그림 판의 설명을 추가합니다.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "그림의 MIME를 추가합니다.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "그림의 미디어 유형을 추가합니다.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "그림의 버전에 대한 Exif 메타데이터를 나열합니다.",
+ "apihelp-query+filearchive-example-simple": "삭제된 모든 파일의 목록을 표시합니다.",
+ "apihelp-query+filerepoinfo-summary": "위키에 구성된 그림 저장소에 대한 메타 정보를 반환합니다.",
+ "apihelp-query+filerepoinfo-example-simple": "파일 저장소의 정보를 가져옵니다.",
+ "apihelp-query+fileusage-summary": "제시된 파일을 사용하는 모든 문서를 찾습니다.",
+ "apihelp-query+fileusage-param-prop": "얻고자 하는 속성:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "각 문서의 페이지 ID.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "각 문서의 제목.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "문서가 넘겨주기이면 표시합니다.",
+ "apihelp-query+fileusage-param-namespace": "이 이름공간의 문서만 포함합니다.",
+ "apihelp-query+fileusage-param-limit": "반환할 항목 수.",
+ "apihelp-query+fileusage-param-show": "이 기준을 충족하는 항목만 표시합니다:\n;redirect:넘겨주기만 표시합니다.\n;!redirect:넘겨주기가 아닌 항목만 표시합니다.",
+ "apihelp-query+imageinfo-summary": "파일 정보와 업로드 역사를 반환합니다.",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "업로드된 판에 대한 타임스탬프를 추가합니다.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "파일에 대한 SHA-1 해시를 추가합니다.",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "파일의 미디어 유형을 추가합니다.",
+ "apihelp-query+imageinfo-param-urlheight": "$1urlwidth와 유사합니다.",
+ "apihelp-query+imageinfo-example-simple": "[[:File:Albert Einstein Head.jpg]]의 현재 판에 대한 정보를 가져옵니다.",
+ "apihelp-query+imageinfo-example-dated": "2008년 및 그 이후의 [[:File:Test.jpg]]의 판에 대한 정보를 가져옵니다.",
+ "apihelp-query+images-summary": "제시된 문서에 포함된 모든 파일을 반환합니다.",
+ "apihelp-query+images-param-limit": "반환할 파일 수.",
+ "apihelp-query+images-example-simple": "[[Main Page|대문]]에 사용된 파일 목록을 가져옵니다.",
+ "apihelp-query+images-example-generator": "[[Main Page|대문]]에 사용된 모든 파일에 관한 정보를 가져옵니다.",
+ "apihelp-query+imageusage-summary": "제시된 그림 제목을 사용하는 모든 문서를 찾습니다.",
+ "apihelp-query+imageusage-param-namespace": "열거할 이름공간.",
+ "apihelp-query+imageusage-example-generator": "[[:File:Albert Einstein Head.jpg]]를 이용하여 페이지의 정보를 가져옵니다.",
+ "apihelp-query+info-summary": "기본 페이지 정보를 가져옵니다.",
+ "apihelp-query+info-param-prop": "얻고자 하는 추가 속성:",
+ "apihelp-query+info-paramvalue-prop-protection": "각 문서의 보호 수준을 나열합니다.",
+ "apihelp-query+info-paramvalue-prop-readable": "사용자가 이 문서를 읽을 수 있는지의 여부.",
+ "apihelp-query+iwbacklinks-summary": "제시된 인터위키 링크에 연결된 모든 문서를 찾습니다.",
+ "apihelp-query+iwbacklinks-param-prefix": "인터위키의 접두사.",
+ "apihelp-query+iwbacklinks-param-title": "검색할 인터위키 링크. <var>$1blprefix</var>와 함께 사용해야 합니다.",
+ "apihelp-query+iwbacklinks-param-limit": "반활한 총 문서 수.",
+ "apihelp-query+iwbacklinks-param-prop": "얻고자 하는 속성:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "인터위키의 접두사를 추가합니다.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "인터위키의 제목을 추가합니다.",
+ "apihelp-query+iwlinks-summary": "제시된 문서의 모든 인터위키 링크를 반환합니다.",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "전체 URL을 추가합니다.",
+ "apihelp-query+langbacklinks-summary": "제시된 언어 링크에 연결된 모든 문서를 찾습니다.",
+ "apihelp-query+langbacklinks-param-lang": "언어 링크의 언어.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "언어 링크의 언어 코드를 추가합니다.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "언어 링크의 제목을 추가합니다.",
+ "apihelp-query+langlinks-summary": "제시된 문서의 모든 언어 간 링크를 반환합니다.",
+ "apihelp-query+langlinks-paramvalue-prop-url": "전체 URL을 추가합니다.",
+ "apihelp-query+langlinks-param-lang": "이 언어 코드의 언어 링크만 반환합니다.",
+ "apihelp-query+links-summary": "제시된 문서의 모든 링크를 반환합니다.",
+ "apihelp-query+linkshere-summary": "제시된 문서에 연결된 모든 문서를 찾습니다.",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "각 문서의 페이지 ID.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "각 문서의 제목.",
+ "apihelp-query+linkshere-param-namespace": "이 이름공간의 문서만 포함합니다.",
+ "apihelp-query+linkshere-param-limit": "반환할 항목 수.",
+ "apihelp-query+linkshere-param-show": "이 기준을 충족하는 항목만 표시합니다:\n;redirect:넘겨주기만 표시합니다.\n;!redirect:넘겨주기가 아닌 항목만 표시합니다.",
+ "apihelp-query+logevents-summary": "기록에서 이벤트를 가져옵니다.",
+ "apihelp-query+logevents-paramvalue-prop-ids": "로그 이벤트의 ID를 추가합니다.",
+ "apihelp-query+logevents-paramvalue-prop-type": "로그 이벤트의 유형을 추가합니다.",
+ "apihelp-query+pagepropnames-summary": "위키에서 사용 중인 모든 문서 속성 이름을 나열합니다.",
+ "apihelp-query+pagepropnames-param-limit": "반환할 이름의 최대 수.",
+ "apihelp-query+pageprops-summary": "문서 내용에 정의된 다양한 문서 속성을 가져옵니다.",
+ "apihelp-query+pageswithprop-summary": "제시된 문서 속성을 사용하는 모든 문서를 나열합니다.",
+ "apihelp-query+pageswithprop-param-prop": "포함할 정보:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "페이지 ID를 추가합니다.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "문서의 제목과 이름공간 ID를 추가합니다.",
+ "apihelp-query+pageswithprop-param-limit": "나타낼 문서의 최대 수입니다.",
+ "apihelp-query+pageswithprop-param-dir": "정렬 순서",
+ "apihelp-query+prefixsearch-summary": "문서 제목에 대해 두문자 검색을 수행합니다.",
+ "apihelp-query+prefixsearch-param-search": "문자열 검색",
+ "apihelp-query+prefixsearch-param-namespace": "검색할 이름공간.",
+ "apihelp-query+prefixsearch-param-limit": "반환할 결과의 최대 수",
+ "apihelp-query+prefixsearch-param-profile": "검색 프로파일 사용",
+ "apihelp-query+protectedtitles-summary": "작성이 보호된 모든 제목을 나열합니다.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "보호 수준을 추가합니다.",
+ "apihelp-query+querypage-summary": "QueryPage 기반 특수 문서가 제공하는 목록을 가져옵니다.",
+ "apihelp-query+querypage-example-ancientpages": "[[Special:Ancientpages|특수:오래된문서]]에서 결과를 반환합니다.",
+ "apihelp-query+random-summary": "임의 문서 집합을 가져옵니다.",
+ "apihelp-query+recentchanges-summary": "최근 바뀜을 열거합니다.",
+ "apihelp-query+recentchanges-param-prop": "추가 정보를 포함합니다:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "편집에 임할 사용자를 추가하고 IP인 경우 태그합니다.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "편집에 임할 사용자를 추가합니다.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "편집에 대한 플래그를 추가합니다.",
+ "apihelp-query+redirects-summary": "제시된 문서의 모든 넘겨주기를 반환합니다.",
+ "apihelp-query+revisions-summary": "판 정보를 가져옵니다.",
+ "apihelp-query+revisions-param-startid": "이 판의 타임스탬프에서 열거를 시작합니다. 이 판은 존재해야 하지만 이 문서에 속할 필요는 없습니다.",
+ "apihelp-query+revisions-param-endid": "이 판의 타임스탬프에서 열거를 중단합니다. 이 판은 존재해야 하지만 이 문서에 속할 필요는 없습니다.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "판의 길이. (바이트)",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "판의 SHA-1 (base 16).",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "판의 콘텐츠 모델 ID.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "판의 텍스트.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "판의 태그.",
+ "apihelp-query+revisions+base-param-parse": "<kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>를 대신 사용합니다. 판 내용의 구문을 분석합니다. ($1prop=content 필요) 성능 상의 이유로 이 옵션을 사용할 경우 $1limit은 1로 강제됩니다.",
+ "apihelp-query+search-summary": "전문 검색을 수행합니다.",
+ "apihelp-query+search-param-qiprofile": "쿼리 독립적인 프로파일 사용(순위 알고리즘에 영향있음)",
+ "apihelp-query+search-paramvalue-prop-size": "바이트 단위로 문서의 크기를 추가합니다.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "문서의 낱말 수를 추가합니다.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "문서가 마지막으로 편집된 시기의 타임스탬프를 추가합니다.",
+ "apihelp-query+search-paramvalue-prop-score": "무시됨.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "무시됨.",
+ "apihelp-query+search-example-simple": "<kbd>meaning</kbd>을 검색합니다.",
+ "apihelp-query+search-example-text": "<kbd>meaning</kbd>의 텍스트를 검색합니다.",
+ "apihelp-query+siteinfo-summary": "사이트의 전반적인 정보를 반환합니다.",
+ "apihelp-query+siteinfo-param-prop": "가져올 정보:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "전반적인 시스템 정보입니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "등록된 이름공간 및 기본 이름의 목록입니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "등록된 이름공간 별칭의 목록입니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "특수 문서의 별칭 목록입니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "매직 워드와 별칭의 목록입니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "사이트 통계를 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "인터위키 맵을 반환합니다. (<var>$1inlanguagecode</var>를 사용하여 필터링 및 지역화 선택 가능)",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "반복 지연이 가장 높은 데이터베이스 서버를 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "사용자 그룹 및 관련 권한을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "위키에 설치된 라이브러리를 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "위키에 설치된 확장 기능을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "업로드가 허용된 파일 확장자(파일 종류)의 목록을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "이용 가능한 경우 위키 권한 (라이선스) 정보를 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "이용 가능한 제한 (보호) 종류의 정보를 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "미디어위키가 지원하는 언어 목록을 반환합니다. (<var>$1inlanguagecode</var>를 사용하여 지역화 선택 가능)",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "사용 중인 모든 스킨의 목록을 반환합니다. (<var>$1inlanguagecode</var>를 사용하여 지역화 선택이 가능하며, 이를 사용하지 않으면 본문의 언어를 사용함)",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "파서 확장 태그의 목록을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "파서 함수 훅의 목록을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "예약된 모든 훅(<var>[[mw:Manual:$wgHooks|$wgHooks]]</var>의 내용)의 목록을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "변수 ID의 목록을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "외부 링크에 허용된 프로토콜의 목록을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "사용자 환경 설정의 기본값을 반환합니다.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "업로드 대화 상자 구성을 반환합니다.",
+ "apihelp-query+siteinfo-param-filteriw": "인터위키 맵의 로컬 또는 로컬이 아닌 항목만 반환합니다.",
+ "apihelp-query+siteinfo-param-showalldb": "가장 지연이 심한 서버뿐 아니라, 모든 데이터베이스 서버를 나열합니다.",
+ "apihelp-query+siteinfo-param-numberingroup": "사용자 그룹의 사용자 수를 나열합니다.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "지역화된 언어 이름 (가능한 경우) 및 스킨 이름의 언어 코드입니다.",
+ "apihelp-query+siteinfo-example-simple": "사이트 정보를 가져옵니다.",
+ "apihelp-query+siteinfo-example-interwiki": "로컬 인터위키 접두사 목록을 가져옵니다.",
+ "apihelp-query+siteinfo-example-replag": "현재의 반복 지연을 검사합니다.",
+ "apihelp-query+tags-param-limit": "나열할 태그의 최대 수.",
+ "apihelp-query+tags-paramvalue-prop-name": "태그의 이름을 추가합니다.",
+ "apihelp-query+tags-paramvalue-prop-description": "태그의 설명을 추가합니다.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "판의 수와 이 판을 가진 로그 엔트리를 추가합니다.",
+ "apihelp-query+templates-summary": "제시된 문서에 끼워넣은 모든 문서를 반환합니다.",
+ "apihelp-query+templates-param-namespace": "이 이름공간에 속한 틀만 표시합니다.",
+ "apihelp-query+templates-param-limit": "반환할 틀 수.",
+ "apihelp-query+tokens-param-type": "요청할 토큰의 종류.",
+ "apihelp-query+tokens-example-simple": "csrf 토큰을 가져옵니다. (기본값)",
+ "apihelp-query+transcludedin-summary": "제시된 문서를 끼워넣은 모든 문서를 찾습니다.",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "각 문서의 페이지 ID.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "각 문서의 제목.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "문서가 넘겨주기이면 표시합니다.",
+ "apihelp-query+transcludedin-param-namespace": "이 이름공간의 문서만 포함합니다.",
+ "apihelp-query+transcludedin-param-limit": "반환할 항목 수.",
+ "apihelp-query+transcludedin-param-show": "이 기준을 충족하는 항목만 표시합니다:\n;redirect:넘겨주기만 표시합니다.\n;!redirect:넘겨주기가 아닌 항목만 표시합니다.",
+ "apihelp-query+usercontribs-summary": "한 사용자의 모든 편집을 가져옵니다.",
+ "apihelp-query+usercontribs-param-limit": "반환할 기여의 최대 수.",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "페이지 ID와 판 ID를 추가합니다.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "문서의 제목과 이름공간 ID를 추가합니다.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "편집의 타임스탬프를 추가합니다.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "편집의 새로운 크기를 추가합니다.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "편집의 플래그를 추가합니다.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "점검한 편집을 태그합니다.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "편집의 태그를 나열합니다.",
+ "apihelp-query+usercontribs-param-tag": "이 태그로 태그된 판만을 나열합니다.",
+ "apihelp-query+usercontribs-param-toponly": "최신 판인 변경 사항만 나열합니다.",
+ "apihelp-query+usercontribs-example-user": "사용자 <kbd>Example</kbd>의 기여를 표시합니다.",
+ "apihelp-query+usercontribs-example-ipprefix": "<kbd>192.0.2.</kbd>로 시작하는 모든 IP 주소의 기여를 표시합니다.",
+ "apihelp-query+userinfo-summary": "현재 사용자의 정보를 가져옵니다.",
+ "apihelp-query+userinfo-param-prop": "포함할 정보:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "현재 사용자가 차단되면 누구에 의해 무슨 이유로 차단되었는지 태그합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "현재 사용자가 대기 중인 메시지가 있다면 <samp>messages</samp> 태그를 추가합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "현재 사용자가 소속된 모든 그룹을 나열합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "현재 사용자가 자동으로 소속된 모든 그룹을 나열합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "현재 사용자가 가진 모든 권한을 나열합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "현재 사용자가 추가 및 제거할 수 있는 그룹을 나열합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "현재 사용자가 설정한 모든 설정을 나열합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "현재의 사용자 환경 설정을 변경하기 위한 토큰을 가져옵니다.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "현재 사용자의 편집 수를 추가합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "사용자의 실명을 추가합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "사용자의 이메일 주소와 이메일 인증 날짜를 추가합니다.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "사용자의 등록 날짜를 추가합니다.",
+ "apihelp-query+userinfo-example-simple": "현재 사용자의 정보를 가져옵니다.",
+ "apihelp-query+userinfo-example-data": "현재 사용자의 추가 정보를 가져옵니다.",
+ "apihelp-query+users-summary": "사용자 목록에 대한 정보를 가져옵니다.",
+ "apihelp-query+users-param-prop": "포함할 정보:",
+ "apihelp-query+users-paramvalue-prop-editcount": "사용자의 편집 수를 추가합니다.",
+ "apihelp-query+users-paramvalue-prop-registration": "사용자의 등록 타임스탬프를 추가합니다.",
+ "apihelp-query+users-param-userids": "정보를 가져올 사용자 ID의 목록입니다.",
+ "apihelp-query+users-param-token": "<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>을 대신 사용하십시오.",
+ "apihelp-query+users-example-simple": "사용자 <kbd>Example</kbd>의 정보를 반환합니다.",
+ "apihelp-query+watchlist-summary": "현재 사용자의 주시목록의 문서의 최근 바뀜을 가져옵니다.",
+ "apihelp-query+watchlist-param-user": "이 사용자의 변경 사항만 나열합니다.",
+ "apihelp-query+watchlist-param-excludeuser": "이 사용자의 변경사항을 나열하지 않습니다.",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "판 ID와 페이지 ID를 추가합니다.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "문서의 제목을 추가합니다.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "편집에 대한 플래그를 추가합니다.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "적절한 곳에 로그 정보를 추가합니다.",
+ "apihelp-query+watchlistraw-summary": "현재 사용자의 주시문서 목록의 모든 문서를 가져옵니다.",
+ "apihelp-removeauthenticationdata-summary": "현재 사용자의 인증 데이터를 제거합니다.",
+ "apihelp-resetpassword-summary": "비밀번호 재설정 이메일을 사용자에게 보냅니다.",
+ "apihelp-resetpassword-param-user": "재설정할 사용자입니다.",
+ "apihelp-resetpassword-param-email": "재설정할 사용자의 이메일 주소입니다.",
+ "apihelp-resetpassword-example-user": "사용자 <kbd>Example</kbd>에게 비밀번호 재설정 이메일을 보냅니다.",
+ "apihelp-resetpassword-example-email": "<kbd>user@example.com</kbd> 이메일 주소를 가진 모든 사용자에 대해 비밀번호 재설정 이메일을 보냅니다.",
+ "apihelp-revisiondelete-summary": "판을 삭제하거나 되살립니다.",
+ "apihelp-revisiondelete-param-reason": "삭제 또는 복구 이유.",
+ "apihelp-rollback-summary": "문서의 마지막 편집을 취소합니다.",
+ "apihelp-rollback-param-tags": "되돌리기를 적용하기 위해 태그합니다.",
+ "apihelp-rollback-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.",
+ "apihelp-rollback-example-simple": "<kbd>Project:대문</kbd> 문서의 <kbd>예시</kbd>의 마지막 판을 되돌리기",
+ "apihelp-rsd-summary": "RSD (Really Simple Discovery) 스키마를 내보냅니다.",
+ "apihelp-setnotificationtimestamp-summary": "주시 중인 문서의 알림 타임스탬프를 업데이트합니다.",
+ "apihelp-setpagelanguage-summary": "문서의 언어를 변경합니다.",
+ "apihelp-setpagelanguage-extended-description-disabled": "이 위키에서 문서의 언어 변경은 허용되지 않습니다.\n\n이 동작을 사용하려면 <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var>을 활성화하십시오.",
+ "apihelp-setpagelanguage-param-title": "언어를 변경하려는 문서의 제목입니다. <var>$1pageid</var>와 함께 사용할 수 없습니다.",
+ "apihelp-setpagelanguage-param-pageid": "언어를 변경하려는 문서의 ID입니다. <var>$1title</var>과 함께 사용할 수 없습니다.",
+ "apihelp-setpagelanguage-param-lang": "문서를 변경할 언어의 언어 코드입니다. 문서를 위키의 기본 콘텐츠 언어로 재설정하려면 <kbd>default</kbd>를 사용하십시오.",
+ "apihelp-setpagelanguage-param-reason": "변경 이유.",
+ "apihelp-setpagelanguage-example-language": "<kbd>Main Page</kbd>의 언어를 바스크어로 변경합니다.",
+ "apihelp-stashedit-summary": "공유된 캐시에서 편집을 준비합니다.",
+ "apihelp-stashedit-param-sectiontitle": "새 문단을 위한 제목.",
+ "apihelp-stashedit-param-text": "문서 내용.",
+ "apihelp-stashedit-param-contentmodel": "새 콘텐츠의 콘텐츠 모델.",
+ "apihelp-tag-param-reason": "변경 이유.",
+ "apihelp-tokens-summary": "데이터 수정 작업을 위해 토큰을 가져옵니다.",
+ "apihelp-tokens-extended-description": "이 모듈은 [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]의 선호에 따라 사용이 권장되지 않습니다.",
+ "apihelp-tokens-param-type": "요청할 토큰의 종류.",
+ "apihelp-tokens-example-edit": "편집 토큰을 검색합니다. (기본값)",
+ "apihelp-tokens-example-emailmove": "편집 토큰과 이동 토큰을 검색합니다.",
+ "apihelp-unblock-summary": "사용자를 차단 해제합니다.",
+ "apihelp-unblock-param-id": "차단을 해제할 차단 ID입니다. (<kbd>list=blocks</kbd>를 통해 가져옴) <var>$1user</var> 또는 <var>$1userid</var>와 함께 사용할 수 없습니다.",
+ "apihelp-unblock-param-user": "차단을 해제할 사용자 이름, IP 주소, IP 주소 대역입니다. <var>$1id</var> 또는 <var>$1userid</var>와(과) 함께 사용할 수 없습니다.",
+ "apihelp-unblock-param-userid": "차단을 해제할 사용자 ID입니다. <var>$1id</var> 또는 <var>$1user</var>와(과) 함께 사용할 수 없습니다.",
+ "apihelp-unblock-param-reason": "차단 해제 이유.",
+ "apihelp-unblock-param-tags": "차단 기록의 항목에 적용할 태그를 변경합니다.",
+ "apihelp-unblock-example-id": "차단 ID #<kbd>105</kbd>의 차단을 해제합니다.",
+ "apihelp-unblock-example-user": "<kbd>Sorry Bob</kbd>이 이유인 <kbd>Bob</kbd> 사용자의 차단을 해제합니다.",
+ "apihelp-undelete-summary": "삭제된 문서의 판을 복구합니다.",
+ "apihelp-undelete-extended-description": "삭제된 판의 목록(타임스탬프 포함)은 [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]]을 통해 검색할 수 있으며 삭제된 파일 ID의 목록은 [[Special:ApiHelp/query+filearchive|list=filearchive]]을 통해 검색할 수 있습니다.",
+ "apihelp-undelete-param-title": "복구할 문서의 제목입니다.",
+ "apihelp-undelete-param-reason": "복구할 이유입니다.",
+ "apihelp-undelete-param-tags": "삭제 기록의 항목에 적용할 태그를 변경합니다.",
+ "apihelp-undelete-param-timestamps": "복구할 판의 타임스탬프입니다. <var>$1timestamps</var>와 <var>$1fileids</var>가 둘 다 비어있으면 모든 판이 복구됩니다.",
+ "apihelp-undelete-param-fileids": "복구할 파일 판의 ID입니다. <var>$1timestamps</var>와 <var>$1fileids</var>가 둘 다 비어있으면 모든 판이 복구됩니다.",
+ "apihelp-undelete-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.",
+ "apihelp-undelete-example-page": "<kbd>대문</kbd> 문서를 복구합니다.",
+ "apihelp-undelete-example-revisions": "<kbd>대문</kbd> 문서의 두 판을 복구합니다.",
+ "apihelp-unlinkaccount-summary": "현재 사용자에 연결된 타사 계정을 제거합니다.",
+ "apihelp-unlinkaccount-example-simple": "<kbd>FooAuthenticationRequest</kbd>와 연결된 제공자에 대한 현재 사용자의 토론 링크 제거를 시도합니다.",
+ "apihelp-upload-summary": "파일을 업로드하거나 대기 중인 업로드의 상태를 가져옵니다.",
+ "apihelp-upload-param-filename": "대상 파일 이름.",
+ "apihelp-upload-param-comment": "업로드 주석입니다. 또, <var>$1text</var>가 지정되지 않은 경우 새로운 파일들의 초기 페이지 텍스트로 사용됩니다.",
+ "apihelp-upload-param-tags": "업로드 기록 항목과 파일 문서 판에 적용할 태그를 변경합니다.",
+ "apihelp-upload-param-text": "새로운 파일들에 대한 초기 문서 텍스트.",
+ "apihelp-upload-param-watch": "문서를 주시합니다.",
+ "apihelp-upload-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.",
+ "apihelp-upload-param-ignorewarnings": "모든 경고를 무시합니다.",
+ "apihelp-upload-param-file": "파일의 내용입니다.",
+ "apihelp-upload-param-url": "파일을 가져올 URL입니다.",
+ "apihelp-upload-param-filekey": "임시로 보관한 이전의 업로드를 식별하는 키입니다.",
+ "apihelp-upload-param-sessionkey": "$1filekey와 동일하며, 하위 호환성을 위해 유지됩니다.",
+ "apihelp-upload-param-stash": "설정하면 서버는 저장소에 파일을 추가하는 대신 임시로 파일을 보관합니다.",
+ "apihelp-upload-param-filesize": "전체 업로드의 파일 크기입니다.",
+ "apihelp-upload-param-offset": "바이트 단위의 청크 오프셋.",
+ "apihelp-upload-param-chunk": "청크의 내용입니다.",
+ "apihelp-upload-param-async": "가능하면 잠재적으로 큰 파일 작업을 비동기로 처리합니다.",
+ "apihelp-upload-param-checkstatus": "제공된 파일 키의 업로드 상태만 가져옵니다.",
+ "apihelp-upload-example-url": "URL에서 업로드합니다.",
+ "apihelp-upload-example-filekey": "경고로 인해 실패한 업로드를 마칩니다.",
+ "apihelp-userrights-summary": "사용자의 그룹 권한을 변경합니다.",
+ "apihelp-userrights-param-user": "사용자 이름.",
+ "apihelp-userrights-param-userid": "사용자 ID.",
+ "apihelp-userrights-param-add": "이 그룹에 사용자를 추가하지만, 이미 회원이라면 해당 그룹의 회원 만료 날짜를 업데이트합니다.",
+ "apihelp-userrights-param-expiry": "만료 타임스탬프입니다. 상대값(예: <kbd>5 months</kbd> 또는 <kbd>2 weeks</kbd>)이거나 절대값(예: <kbd>2014-09-18T12:34:56Z</kbd>)이다. 타임스탬프만 설정할 경우, <var>$1add</var> 변수에 전달되는 모든 그룹에 사용됩니다. 만료되지 않는 사용자 그룹으로 지정하려면 <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, 또는 <kbd>never</kbd>를 사용하십시오.",
+ "apihelp-userrights-param-remove": "이 그룹에서 사용자를 제거합니다.",
+ "apihelp-userrights-param-reason": "변경 이유입니다.",
+ "apihelp-userrights-param-tags": "사용자 권한 기록의 항목에 적용할 태그를 변경합니다.",
+ "apihelp-userrights-example-user": "<kbd>FooBot</kbd> 사용자를 <kbd>bot</kbd> 그룹에 추가하며 <kbd>sysop</kbd>과 <kbd>bureaucrat</kbd> 그룹에서 제거합니다.",
+ "apihelp-userrights-example-userid": "ID가 <kbd>123</kbd>인 사용자를 <kbd>bot</kbd> 그룹에 추가하며, <kbd>sysop</kbd>과 <kbd>bureaucrat</kbd> 그룹에서 제거합니다.",
+ "apihelp-userrights-example-expiry": "사용자 <kbd>SometimeSysop</kbd>을 <kbd>sysop</kbd> 그룹에 1개월 간 추가합니다.",
+ "apihelp-validatepassword-summary": "위키의 비밀번호 정책에 근간하여 비밀번호를 확인합니다.",
+ "apihelp-validatepassword-extended-description": "비밀번호를 수용할 수 있으면 <samp>Good</samp>으로, 로그인 시 비밀번호를 사용할 수 있지만 변경이 필요한 경우 <samp>Change</samp>로, 비밀번호를 사용할 수 없으면 <samp>Invalid</samp>로 보고됩니다.",
+ "apihelp-validatepassword-param-password": "확인할 비밀번호.",
+ "apihelp-validatepassword-param-user": "계정 생성을 테스트할 때 사용할 사용자 이름입니다. 명명된 사용자는 존재하지 않습니다.",
+ "apihelp-validatepassword-param-email": "계정 생성을 테스트할 때 사용할 이메일 주소입니다.",
+ "apihelp-validatepassword-param-realname": "계정 생성을 테스트할 때 사용할 실명입니다.",
+ "apihelp-validatepassword-example-1": "현재 사용자에 대해 비밀번호 <kbd>foobar</kbd>를 확인합니다.",
+ "apihelp-validatepassword-example-2": "사용자 <kbd>Example</kbd>를 만들기 위해 비밀번호 <kbd>qwerty</kbd>를 확인합니다.",
+ "apihelp-watch-summary": "현재 사용자의 주시목록에서 문서를 추가하거나 제거합니다.",
+ "apihelp-watch-param-title": "주시하거나 주시를 해제할 문서입니다. <var>$1titles</var>를 대신 사용하세요.",
+ "apihelp-watch-param-unwatch": "설정하면 문서의 주시는 해제됩니다.",
+ "apihelp-watch-example-watch": "<kbd>대문</kbd> 문서를 주시합니다.",
+ "apihelp-watch-example-unwatch": "<kbd>대문</kbd> 문서의 주시를 해제합니다.",
+ "apihelp-watch-example-generator": "일반 이름공간의 일부 첫 문서들을 주시합니다.",
+ "apihelp-json-summary": "데이터를 JSON 형식으로 출력합니다.",
+ "apihelp-json-param-formatversion": "출력 형식:\n;1:하위 호환 포맷 (XML 스타일 불린, 콘텐츠 노드를 위한 <samp>*</samp> 키 등).\n;2:실험적인 모던 포맷. 상세 내용은 바뀔 수 있습니다!\n;latest:최신 포맷(현재 <kbd>2</kbd>)을 이용하지만 경고 없이 바뀔 수 있습니다.",
+ "apihelp-jsonfm-summary": "데이터를 JSON 포맷으로 출력합니다. (HTML의 가독성 증가)",
+ "apihelp-none-summary": "아무 것도 출력하지 않습니다.",
+ "apihelp-php-summary": "데이터를 직렬화된 PHP 포맷으로 출력합니다.",
+ "apihelp-phpfm-summary": "데이터를 PHP 포맷(HTML의 가독성 증가)으로 출력합니다.",
+ "apihelp-rawfm-summary": "디버깅 요소를 포함하여 데이터를 JSON 형식으로 출력합니다. (HTML의 가독성 증가)",
+ "apihelp-xml-summary": "데이터를 XML 형식으로 출력합니다.",
+ "apihelp-xml-param-includexmlnamespace": "지정하면 XML 이름공간을 추가합니다.",
+ "apihelp-xmlfm-summary": "데이터를 XML 포맷(가독성 높은 HTML 방식)으로 출력합니다.",
+ "api-format-title": "미디어위키 API 결과",
+ "api-login-fail-aborted": "인증은 사용자의 상호작용이 필요하지만, <kbd>action=login</kbd>에 의해 지원되지 않습니다. <kbd>action=login</kbd>으로 로그인할 수 있게 하려면 [[Special:BotPasswords]]를 참조하십시오. 주 계정 로그인의 사용을 계속하려면 <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>을 참조하십시오.",
+ "api-login-fail-aborted-nobotpw": "인증은 사용자의 상호작용이 필요하지만, <kbd>action=login</kbd>에 의해 지원되지 않습니다. 로그인하려면 <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>을 참조하십시오.",
+ "api-login-fail-badsessionprovider": "$1을(를) 사용할 경우 로그인할 수 없습니다.",
+ "api-login-fail-sameorigin": "기원이 동일한 정책이 적용되지 않으면 로그인할 수 없습니다.",
+ "api-pageset-param-titles": "작업할 제목의 목록입니다.",
+ "api-pageset-param-pageids": "작업할 페이지 ID의 목록입니다.",
+ "api-pageset-param-revids": "작업할 판 ID의 목록입니다.",
+ "api-pageset-param-generator": "특정 쿼리 모듈을 실행함으로써 작업할 페이지의 목록입니다.\n\n<strong>참고:</strong> 발생기 변수명은 \"g\"로 시작해야 합니다. 예시를 참고하십시오.",
+ "api-pageset-param-redirects-generator": "<var>$1titles</var>, <var>$1pageids</var>, <var>$1revids</var> 및 <var>$1generator</var>가 반환한 문서들의 넘겨주기를 자동으로 결정합니다.",
+ "api-pageset-param-redirects-nogenerator": "<var>$1titles</var>, <var>$1pageids</var>, <var>$1revids</var>의 넘겨주기를 자동으로 결정합니다.",
+ "api-pageset-param-converttitles": "필요하면 제목을 다른 형태로 변환합니다. 위키의 내용 언어가 형태 변환을 지원하는 경우에만 동작합니다. 형태 변환을 지원하는 언어는 $1을(를) 포함합니다.",
+ "api-help-title": "미디어위키 API 도움말",
+ "api-help-lead": "이 페이지는 자동으로 생성된 미디어위키 API 도움말 문서입니다.\n\n설명 문서 및 예시: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "메인 모듈",
+ "api-help-undocumented-module": "$1 모듈에 대한 설명문이 없습니다.",
+ "api-help-flag-deprecated": "이 모듈은 사용되지 않습니다.",
+ "api-help-flag-internal": "<strong>이 모듈은 내부용이거나 불안정합니다.</strong> 동작은 예고 없이 변경될 수 있습니다.",
+ "api-help-flag-readrights": "이 모듈은 read 권한을 요구합니다.",
+ "api-help-flag-writerights": "이 모듈은 write 권한을 요구합니다.",
+ "api-help-flag-mustbeposted": "이 모듈은 POST 요청만을 허용합니다.",
+ "api-help-flag-generator": "이 모듈은 생성기로 사용할 수 있습니다.",
+ "api-help-source": "출처: $1",
+ "api-help-source-unknown": "소스: <span class=\"apihelp-unknown\">알 수 없음</span>",
+ "api-help-license": "라이선스: [[$1|$2]]",
+ "api-help-license-noname": "라이선스: [[$1|링크 참조]]",
+ "api-help-license-unknown": "라이선스: <span class=\"apihelp-unknown\">알 수 없음</span>",
+ "api-help-parameters": "{{PLURAL:$1|변수}}:",
+ "api-help-param-deprecated": "사용되지 않습니다.",
+ "api-help-param-required": "이 변수는 필수 입력 사항입니다.",
+ "api-help-datatypes-header": "데이터 유형",
+ "api-help-datatypes": "API 요청 내 몇몇 매개변수형에 대해 더 자세히 설명해보겠습니다:\n;boolean\n:Boolean 매개변수들은 HTML 체크박스처럼 동작합니다: 만약 매개변수가 지정되었다면, 값에 상관없이 참의 값으로 여겨집니다. 거짓값은 매개변수 전체를 생략하세요.\n;timestamp\n:타임스탬프들은 여러 형식으로 표현될 수 있으나 ISO 8601 날짜와 시간이 추천됩니다. 모든 시간은 UTC이어야 하며, 포함된 시간대는 모두 무시됩니다.\n:* ISO 8601 날짜와 시간, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (구두점과 <kbd>Z</kbd>는 선택입니다.)\n:* ISO 8601 날짜와 시간과 (무시되는) 소수 초, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (대시, 콜론과 <kbd>Z</kbd>는 선택입니다.)\n:* 미디어위키 형식, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* 일반적인 수 형식 <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (<kbd>GMT</kbd>, <kbd>+<var>##</var></kbd>, 또는 <kbd>-<var>##</var></kbd>와 같은 선택적 시간대는 무시됩니다)\n:*RFC 2822 형식 (시간대는 생략될 수 있음), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* RFC 850 형식 (시간대는 생략될 수 있음), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* C ctime 형식, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* 1부터 13자리까지의 숫자로 표현된 1970-01-01T00:00:00Z부터 흐른 시간(초) (<kbd>0</kbd>을 제외)\n:* 문자열 <kbd>now</kbd>",
+ "api-help-param-type-limit": "유형: 정수 또는 <kbd>max</kbd>",
+ "api-help-param-type-integer": "유형: {{PLURAL:$1|1=정수|2=정수 목록}}",
+ "api-help-param-type-boolean": "유형: 부울 ([[Special:ApiHelp/main#main/datatypes|자세한 정보]])",
+ "api-help-param-type-timestamp": "유형: {{PLURAL:$1|1=타임스탬프|2=타임스탬프 목록}} ([[Special:ApiHelp/main#main/datatypes|허용되는 포맷]])",
+ "api-help-param-type-user": "유형: {{PLURAL:$1|1=사용자 이름|2=사용자 이름 목록}}",
+ "api-help-param-list": "{{PLURAL:$1|1=다음 값 중 하나|2=값 (<kbd>{{!}}</kbd>로 구분)}}: $2 또는 [[Special:ApiHelp/main#main/datatypes|alternative]]: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=비어 있어야 함|비어 있을 수 있거나 $2}}",
+ "api-help-param-limit": "$1 초과는 허용되지 않습니다.",
+ "api-help-param-limit2": "$1 초과는 허용되지 않습니다. (봇의 경우 $2)",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=값|2=값들}}은 $2 이상이어야 합니다.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=값|2=값들}}은 $3 이하여야 합니다.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=값|2=값들}}은 $2와 $3 사이여야 합니다.",
+ "api-help-param-upload": "여러 부분/폼 데이터를 사용한 파일 업로드로 게시되어야 합니다.",
+ "api-help-param-multi-separate": "<kbd>|</kbd> 또는 [[Special:ApiHelp/main#main/datatypes|대안]]으로 값을 구분합니다.",
+ "api-help-param-multi-max": "값들의 최대 수는 {{PLURAL:$1|$1}}입니다. (봇의 경우 {{PLURAL:$2|$2}})",
+ "api-help-param-multi-max-simple": "값의 최대 수는 {{PLURAL:$1|$1}}입니다.",
+ "api-help-param-default": "기본값: $1",
+ "api-help-param-default-empty": "기본값: <span class=\"apihelp-empty\">(비어 있음)</span>",
+ "api-help-param-token": "\"$1\" 토큰은 [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]에서 가져옵니다",
+ "api-help-param-token-webui": "호환성을 위해, 웹 UI에 사용된 토큰도 허용합니다.",
+ "api-help-param-continue": "더 많은 결과를 이용할 수 있을 때, 계속하려면 이것을 사용하십시오.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(설명 없음)</span>",
+ "api-help-examples": "{{PLURAL:$1|예시}}:",
+ "api-help-permissions": "{{PLURAL:$1|권한}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|다음 그룹에 부여됨}}: $2",
+ "api-help-right-apihighlimits": "API 쿼리에서 더 높은 제한 사용 (느린 쿼리: $1, 빠른 쿼리: $2) 느린 쿼리에 대한 제한은 다중값 매개변수에도 적용됩니다.",
+ "api-help-open-in-apisandbox": "<small>[연습장에서 열기]</small>",
+ "api-help-authmanager-general-usage": "이 모듈을 사용하는 일반적인 절차는 다음과 같습니다:\n# <kbd>amirequestsfor=$4</kbd>와 함께 <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>에서 사용할 수 있는 필드와 <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>의 토큰을 가져옵니다.\n# 사용자에게 필드를 제시하고 사용자의 제출 사항을 취득합니다.\n# <var>$1returnurl</var> 및 관련된 모든 필드를 제공, 이 모듈에 전달합니다.\n# 응답 시 <samp>status</samp>를 확인합니다.\n#* <samp>PASS</samp> 또는 <samp>FAIL</samp>을 수신한 경우 작업은 끝난 것입니다. 동작은 성공하였거나 그렇지 않은 경우입니다.\n#* <samp>UI</samp>를 수신한 경우 사용자에게 새로운 필드를 제시하고 사용자의 제출 사항을 취득합니다. 그 뒤 <var>$1continue</var> 및 관련된 모든 필드 집합과 함께 이 모듈에 전달하고 단계 4를 반복합니다.\n#* <samp>REDIRECT</samp>를 수신한 경우, 사용자를 <samp>redirecttarget</samp>으로 넘겨준 다음 <var>$1returnurl</var>로 반환될 때까지 기다립니다. 그 뒤 <var>$1continue</var> 및 반환 URL에 전달되는, 모든 관련 필드와 함께 이 모듈에 전달하고 단계 4를 반복합니다.\n#* <samp>RESTART</samp>를 수실한 경우 인증은 동작했으나 연결된 사용자 계정이 없다는 것을 의미합니다. <samp>UI</samp>나 <samp>FAIL</samp>로 간주할 수 있습니다.",
+ "api-help-authmanagerhelper-requests": "<kbd>amirequestsfor=$1</kbd>와(과) 함께 <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>에서 반환된 <samp>id</samp>를 통해, 또는 이 모듈의 과거 응답으로부터 이 인증 요청만을 사용합니다.",
+ "api-help-authmanagerhelper-request": "<kbd>amirequestsfor=$1</kbd>을(를) 지정하여 <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>가 반환한 <samp>id</samp>를 통해 이 인증 요청을 사용합니다.",
+ "api-help-authmanagerhelper-messageformat": "반환 메시지에 사용할 형식.",
+ "api-help-authmanagerhelper-mergerequestfields": "모든 인증 요청에 대한 필드 정보를 하나의 배열로 합칩니다.",
+ "api-help-authmanagerhelper-preservestate": "가능하면 과거에 실패한 로그인 시도의 상태를 보존합니다.",
+ "api-help-authmanagerhelper-continue": "이 요청은 초기 <samp>UI</samp> 또는 <samp>REDIRECT</samp> 응답 이후에 계속됩니다. 이것 또는 <var>$1returnurl</var> 중 하나가 필요합니다.",
+ "api-help-authmanagerhelper-additional-params": "이 모듈은 사용 가능한 인증 요청에 따라 추가 변수를 허용합니다. 사용 가능한 요청 및 사용되는 필드를 결정하려면 <kbd>amirequestsfor=$1</kbd>(또는 해당되는 경우 이 모듈의 과거 응답)과 함께 <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>을(를) 사용하십시오.",
+ "apierror-articleexists": "작성하려는 문서가 이미 만들어져 있습니다.",
+ "apierror-assertbotfailed": "사용자의 <code>bot</code> 권한 보유 표명이 실패했습니다.",
+ "apierror-assertnameduserfailed": "사용자의 \"$1\" 지정 표명이 실패했습니다.",
+ "apierror-assertuserfailed": "사용자의 로그인 표명이 실패했습니다.",
+ "apierror-autoblocked": "사용자의 IP 주소는 차단된 사용자에 의해 사용되었으므로 자동으로 차단된 상태입니다.",
+ "apierror-badgenerator-unknown": "알 수 없는 <kbd>generator=$1</kbd>.",
+ "apierror-badip": "IP 변수가 유효하지 않습니다.",
+ "apierror-badmd5": "제공된 MD5 해시가 잘못되었습니다.",
+ "apierror-badmodule-badsubmodule": "<kbd>$1</kbd> 모듈에 \"$2\" 하위 모듈이 없습니다.",
+ "apierror-badmodule-nosubmodules": "<kbd>$1</kbd> 모듈은 하위 모듈이 없습니다.",
+ "apierror-badquery": "유효하지 않은 쿼리입니다.",
+ "apierror-badtoken": "잘못된 CSRF 토큰.",
+ "apierror-blockedfrommail": "이메일 보내기에서 차단되어 있습니다.",
+ "apierror-blocked": "편집에서 차단되어 있습니다.",
+ "apierror-botsnotsupported": "이 인터페이스는 봇을 위해 지원되지 않습니다.",
+ "apierror-cannotviewtitle": "$1을(를) 볼 권한이 없습니다.",
+ "apierror-cantblock": "사용자를 차단할 권한이 없습니다.",
+ "apierror-canthide": "차단 기록에서 사용자 이름을 숨길 권한이 없습니다.",
+ "apierror-cantimport-upload": "업로드된 페이지를 가져올 권한이 없습니다.",
+ "apierror-cantimport": "페이지를 가져올 권한이 없습니다.",
+ "apierror-cantsend": "로그인하지 않았거나 인증된 이메일 주소가 없거나 다른 사용자로 이메일을 보낼 권한이 없기 때문에 이메일을 보낼 수 없습니다.",
+ "apierror-databaseerror": "[$1] 데이터베이스 쿼리 오류.",
+ "apierror-emptynewsection": "비어있는 새 문단을 만들 수 없습니다.",
+ "apierror-emptypage": "새 문서로 빈 문서를 만들 수 없습니다.",
+ "apierror-exceptioncaught": "[$1] 예외가 발생했습니다: $2",
+ "apierror-filedoesnotexist": "파일이 존재하지 않습니다.",
+ "apierror-filenopath": "로컬 파일 경로를 가져올 수 없습니다.",
+ "apierror-import-unknownerror": "알 수 없는 가져오기 오류: $1.",
+ "apierror-invalidcategory": "입력한 분류 이름이 올바르지 않습니다.",
+ "apierror-invalidexpiry": "잘못된 만료 기한 \"$1\".",
+ "apierror-invalid-file-key": "유효한 파일 키가 아닙니다.",
+ "apierror-invalidoldimage": "<var>oldimage</var> 변수에 유효하지 않은 형식이 있습니다.",
+ "apierror-invalidparammix-cannotusewith": "<kbd>$1</kbd> 변수는 <kbd>$2</kbd>와(과) 함께 사용할 수 없습니다.",
+ "apierror-invalidsection": "<var>section</var> 변수는 유효한 섹션 ID 또는 <kbd>new</kbd>이어야 합니다.",
+ "apierror-invalidsha1base36hash": "제공된 SHA1Base36 해시가 유효하지 않습니다.",
+ "apierror-invalidsha1hash": "제공된 SHA1 해시가 유효하지 않습니다.",
+ "apierror-invalidtitle": "잘못된 제목 \"$1\".",
+ "apierror-invaliduser": "잘못된 사용자 이름 \"$1\".",
+ "apierror-invaliduserid": "<var>$1</var> 사용자 ID는 유효하지 않습니다.",
+ "apierror-maxlag-generic": "데이터베이스 서버 대기 중: $1 {{PLURAL:$1|초}} 지연되었습니다.",
+ "apierror-maxlag": "$2 대기 중: $1 {{PLURAL:$1|초}} 지연되었습니다.",
+ "apierror-missingcontent-revid": "ID $1 판에 해당하는 내용이 없습니다.",
+ "apierror-missingparam": "<var>$1</var> 변수는 설정해야 합니다.",
+ "apierror-missingtitle": "지정한 페이지가 존재하지 않습니다.",
+ "apierror-missingtitle-byname": "$1 문서가 존재하지 않습니다.",
+ "apierror-moduledisabled": "<kbd>$1</kbd> 모듈은 비활성화되었습니다.",
+ "apierror-mustbeloggedin-generic": "로그인해야 합니다.",
+ "apierror-mustbeloggedin-linkaccounts": "계정을 연결하려면 로그인해야 합니다.",
+ "apierror-mustbeloggedin": "$1에 로그인해야 합니다.",
+ "apierror-mustbeposted": "<kbd>$1</kbd> 모듈은 POST 요청이 필요합니다.",
+ "apierror-noedit-anon": "익명 사용자는 문서를 편집할 수 없습니다.",
+ "apierror-noedit": "문서를 편집할 권한이 없습니다.",
+ "apierror-noimageredirect": "그림 넘겨주기를 만들 권한이 없습니다.",
+ "apierror-nosuchrevid": "ID $1에 해당하는 판이 없습니다.",
+ "apierror-nosuchuserid": "ID $1에 해당하는 사용자가 없습니다.",
+ "apierror-notarget": "이 작업을 위한 유효한 대상을 지정하지 않았습니다.",
+ "apierror-pagecannotexist": "이름공간은 실제 페이지를 허용하지 않습니다.",
+ "apierror-pagelang-disabled": "이 위키에서 문서의 언어 변경은 허용되지 않습니다.",
+ "apierror-permissiondenied": "$1에 대한 권한이 없습니다.",
+ "apierror-permissiondenied-generic": "권한이 없습니다.",
+ "apierror-permissiondenied-unblock": "사용자의 차단을 해제할 권한이 없습니다.",
+ "apierror-protect-invalidaction": "잘못된 보호 유형 \"$1\".",
+ "apierror-protect-invalidlevel": "잘못된 보호 수준 \"$1\".",
+ "apierror-ratelimited": "속도 제한을 초과했습니다. 잠시 기다렸다가 다시 시도하십시오.",
+ "apierror-readapidenied": "이 모듈을 사용하려면 읽기 권한이 필요합니다.",
+ "apierror-readonly": "위키는 현재 읽기 전용 모드입니다.",
+ "apierror-revisions-badid": "<var>$1</var> 변수에 대한 판을 발견하지 못했습니다.",
+ "apierror-revwrongpage": "r$1은(는) $2의 판이 아닙니다.",
+ "apierror-specialpage-cantexecute": "특수 문서의 결과를 볼 권한이 없습니다.",
+ "apierror-stashwrongowner": "잘못된 소유자: $1",
+ "apierror-systemblocked": "당신은 미디어위키에 의해서 자동으로 차단되었습니다.",
+ "apierror-timeout": "서버가 예측된 시간 내에 응답하지 않았습니다.",
+ "apierror-unknownerror-editpage": "알 수 없는 EditPage 오류: $1.",
+ "apierror-unknownerror-nocode": "알 수 없는 오류.",
+ "apierror-unknownerror": "알 수 없는 오류: \"$1\"",
+ "apierror-unknownformat": "인식되지 않는 형식 \"$1\".",
+ "apierror-unsupportedrepo": "로컬 파일 저장소는 모든 그림의 조회를 지원하지 않습니다.",
+ "apierror-upload-missingresult": "상태 데이터에 결과가 없습니다.",
+ "apierror-writeapidenied": "API를 통해 이 위키를 편집할 권한이 없습니다.",
+ "apiwarn-deprecation-httpsexpected": "HTTPS를 예측하였으나 HTTP가 사용되었습니다.",
+ "apiwarn-difftohidden": "r$1에 대한 차이를 만들 수 없습니다: 내용이 숨겨져 있습니다.",
+ "apiwarn-invalidcategory": "\"$1\"은(는) 분류가 아닙니다.",
+ "apiwarn-invalidtitle": "\"$1\"은(는) 올바른 제목이 아닙니다.",
+ "apiwarn-invalidxmlstylesheetns": "스타일시트는 {{ns:MediaWiki}} 이름공간에 있어야 합니다.",
+ "apiwarn-notfile": "\"$1\"은(는) 파일이 아닙니다.",
+ "apiwarn-unsupportedarray": "<var>$1</var> 변수는 지원되지 않는 PHP 배열 문법을 사용합니다.",
+ "apiwarn-validationfailed-keytoolong": "키가 너무 깁니다. ($1 바이트 이상 허용하지 않습니다)",
+ "api-feed-error-title": "오류 ($1)",
+ "api-usage-docref": "API 사용법을 보려면 $1을(를) 참고하십시오.",
+ "api-credits-header": "크레딧",
+ "api-credits": "API 개발자:\n* Yuri Astrakhan (만든이, 선임 개발자 2006년 9월~2007년 9월)\n* Roan Kattouw (선임 개발자, 2007년 9월–2009년)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (선임 개발자 2013년–현재)\n\n당신의 의견이나 제안, 질문은 mediawiki-api@lists.wikimedia.org 로 보내주시고,\n버그 보고는 https://phabricator.wikimedia.org/ 에 해주시기 바랍니다."
+}
diff --git a/www/wiki/includes/api/i18n/ksh.json b/www/wiki/includes/api/i18n/ksh.json
new file mode 100644
index 00000000..1ed917a7
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ksh.json
@@ -0,0 +1,1048 @@
+{
+ "@metadata": {
+ "authors": [
+ "Purodha",
+ "Macofe"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page/de|Dokemäntazjohn]]\n* [[mw:API:FAQ/de|Öff jefrohch]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mäileng_Leß]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Aanköndejonge zom <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i>]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Jemäldte Fähler un Wönsch]\n</div>\n<strong>Status:</strong> Alle op heh dä Sigg aanjzeischte Ußwahle sullte donn, ävver et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> weed jrahd noch äntwekeld un et kann sesch alle Nahslangs jädd ändere. Holl Der de [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ Mäileng_Leß med Aanköndejonge], öm automattesch övver Neujeschkeite enfommehrt ze wähde.\n\n<strong>Kapodde Aanfrohre:</strong> Wam_mer kapodde Aanfroheaan et API <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> schek, kritt mer ene <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Transfer Protocol\">HTTP</i>-Kopp ußjejovve met däm Täx „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">MediaWiki-API-Error</code>“ dren, dä mer als ene Schlößel bedraachte kann. Mih dohzoh fengk met op dä Sigg [[mw:API:Errors_and_warnings|<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i>: Fähler un Warnonge]].",
+ "apihelp-main-param-action": "Wat för en Aufjahb.",
+ "apihelp-main-param-format": "Et Fommaht för ußzejävve.",
+ "apihelp-main-param-maxlag": "Der hühste zohjelohße Verzoch kann jenumme wähde, wann MehdijaWikki obb enem Knubbel Rääschner medd ene replezehrte (dadd es, lebänndesch koppehrte) Dahtebangk enschtallehrt weed. Öm kein Opdräschd aan de Dahtebangk ze scheke, di dat noch schlemmer maache dähte, kam_mer övver heh dä Parramehter et Projramm affwahde lohße, bes dat dä Verzoch vum Replezehre onger däm aanjejovve Wäät lit. Wann dä Verzoch övvermähßesch jruhs es, kritt mer dä Fähler <samp lang=\"en\" xml:lang=\"en\" dir=\"ltr\">maxlag</samp> jemälldt med ene Nohreesch esu wi <samp lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Mer wahde op dä ẞööver $Maschihn un di es $Verzoch Sekonde hengerher</samp>.<br />Op dä [[mw:Manual:Maxlag_parameter|Hanndbohchsigg zom \n<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Maxlag</code>-Parramehter]] kam_mer noch mih zerdoh lässe.",
+ "apihelp-main-param-smaxage": "Säz <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">max-age</code> en dä Kopp_Reihj <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">HTTP cache control</code> obb esu vill Sekonde. Fähler wähde nimmohls faßjehallde.",
+ "apihelp-main-param-maxage": "Säz <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">max-age</code> en dä Kopp_Reihj <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">HTTP cache control</code> obb esu vill Sekonde. Fähler wähde nimmohls faßjehallde.",
+ "apihelp-main-param-assert": "Ställ sescher, dat dä Metmaacher enjelogg es (doh för jiff <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">user</kbd> en), udder ene Bot es (doh för jiff <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">bot</kbd> en).",
+ "apihelp-main-param-requestid": "Jehde Aanjahb vun heh weed widder med ußjejovve. Esuh kam_mer einzel Affrohre ussenein hallde.",
+ "apihelp-main-param-servedby": "Donn däm ẞööver, dä et jedonn hät, singe Nahme med ußjävve.",
+ "apihelp-main-param-curtimestamp": "Donn de aktoälle Zigg un et Dattum med ußjävve.",
+ "apihelp-main-param-uselang": "De Schprohch för et Övversäzze vun Täxte un Nohreeschte. <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> holle, met <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">siprop=languages</kbd> jidd en Leß met de Köözelle för Schprohche uß, udder jiff <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">user</kbd> aan, öm dem aktoälle Metmaacher sing eetzde Schprohch ze krijje, udder nemm <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">content</kbd> öm heh dämm Wikki singe Ennhald sing Schprohch ze krijje.",
+ "apihelp-block-summary": "Ene Metmaacher schpärre.",
+ "apihelp-block-param-user": "Däm Nahme vun däm Metmaacher, de <i lang=\"en\" xml:lang=\"en\" title=\"Internet Protocol\">IP</i>-Addräß udder dä Berätt, dä De Schpärre wells.",
+ "apihelp-block-param-expiry": "De Zigg bes zom Ußloufe. Kam_mer als en Door aanjävve, esu wi „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">5 months</kbd>“ udder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">2 weeks</kbd>“ un kam_mer als ene Zigg_Pongk aanjävve, esu wi „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">2014-09-18T12:34:56Z</kbd>“, un wam_mer „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">infinite</kbd>“, „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">indefinite</kbd>“ udder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">never</kbd>“ aanjitt, dohrt di Schpärr för iiwesch.",
+ "apihelp-block-param-reason": "Der Schpärrjrond.",
+ "apihelp-block-param-anononly": "Bloß de nahmelohse Metmaaacher spärre, alsu donn et nahmelohse Beärbeide vun dä <i lang=\"en\" xml:lang=\"en\" title=\"Internet Protocol\">IP</i>-Addräß uß verhendere.",
+ "apihelp-block-param-nocreate": "Et Neu-Aanmelde verbeede",
+ "apihelp-block-param-autoblock": "Dun automattesch de läzde <i lang=\"en\" xml:lang=\"en\">IP</i>-Adräß schpärre, di dä Metmaacher jehatt hät, un och all di <i lang=\"en\" xml:lang=\"en\">IP</i>-Adräße, vun wo dä versöhk, jet ze ändere.",
+ "apihelp-block-param-noemail": "Sorresch derför, dat dä Metmaacher \n<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mail</i> övver et Wiki verscheck. Bruch et Rääsch „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">blockemail</code>“.",
+ "apihelp-block-param-hidename": "Donn däm Metmaacher singe Nahme em Logbohch vum Metmaacher Schpärre verschteische. Bruch et Rääsch „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">hideuser</code>“.",
+ "apihelp-block-param-allowusertalk": "Lohß dä Metmaacher sing eije Klaafsigg verändere. Dat hängk aan „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>“.",
+ "apihelp-block-param-reblock": "Wann dä Metmaacher als jeschpächd es, donn dat övverschrihve.",
+ "apihelp-block-param-watchuser": "Donn de Metmaachersigg un de Klaafsigg dohzoh op mig Oppaßleß säze.",
+ "apihelp-block-example-ip-simple": "Donn de <i lang=\"en\" xmL:lang=\"en\" title=\"Internet Protocol\">IP</i>-Addräß <kbd>192.0.2.5</kbd> för drei ääsch schpärre mem Jrond: <kbd>Eestschlaach</kbd>.",
+ "apihelp-block-example-user-complex": "Donn dä Metmaacher „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Vandal</kbd>“ för iiwesch schpärre, mem Jrond „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Vandalism</kbd>“, un donn_em neu Zohjäng aanzelähje un <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mail</i> ze verscheke verbehde.",
+ "apihelp-checktoken-summary": "Donn de Jölteschkeid vun enem Makkehrongsschlößel vun „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>“ pröhve.",
+ "apihelp-checktoken-param-type": "De Zoot Makkehrongsschlößel zom Pröhfe.",
+ "apihelp-checktoken-param-token": "Der Makkehrongsschlößel zom Pröhve.",
+ "apihelp-checktoken-param-maxtokenage": "Et jrühßte zojelohße Allder fun däm Makkehrongsschlößel en Sekonde.",
+ "apihelp-checktoken-example-simple": "Pröhf de Jölteschkeid vun däm Makkehrongsschlößel „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">csrf</kbd>“.",
+ "apihelp-clearhasmsg-summary": "Nemmp de Makkehrong „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">hasmsg</code>“ fott vum aktoälle Metmaacher.",
+ "apihelp-clearhasmsg-example-1": "Nemm de Makkehrong „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">hasmsg</code>“ fott vum aktoälle Metmaacher.",
+ "apihelp-compare-summary": "Donn de Ongerscheide zwesche zwai Sigge beschtemme.",
+ "apihelp-compare-extended-description": "Do moß derför jeweils en Väsjohn, en Övverschreff för di Sigg, odder ener Sigg iehr Kännong aanjävve, för de beide Sigge.",
+ "apihelp-compare-param-fromtitle": "De Övverschreff vun dä eezte Sigg zom verjlihsche.",
+ "apihelp-compare-param-fromid": "De Kännong vun dä eezte Sigg zom verjlihsche.",
+ "apihelp-compare-param-fromrev": "De Väsjohn vun dä zwaite Sigg zom verjlihsche.",
+ "apihelp-compare-param-totitle": "De Övverschreff vun dä zwaite Sigg zom verjlihsche.",
+ "apihelp-compare-param-toid": "De Kännong vun dä zwaite Sigg zom verjlihsche.",
+ "apihelp-compare-param-torev": "De Väsjohn vun dä zwaite Sigg zom verjlihsche.",
+ "apihelp-compare-example-1": "Fengk de Ongerscheide zwesche dä Väsjohne 1 un 2",
+ "apihelp-createaccount-summary": "Ene neue Zohjang för ene Metmaacher aanlähje.",
+ "apihelp-createaccount-param-name": "Der Nahme för dä Metmaacher.",
+ "apihelp-createaccount-param-password": "Et Paßwoot (Weed ävver it jebruc un övverjange, wann <code lang=\"en\" xml:lang=\"en\"><var>$1mailpassword</var></code> jesaz es)",
+ "apihelp-createaccount-param-domain": "De Domäijn för de Zohjangsdaht vun ußerhallef beschtähtech ze krijje. Kam_mer fott_lohße.",
+ "apihelp-createaccount-param-token": "Der Makkehrongsschlößel för ene Zohjang aanzelähje, dä mer bei de eezde Aanfrohch krääje hät.",
+ "apihelp-createaccount-param-email": "Däm Metmaacher sing Adräß för de <i lang=\"en\" xml:lang=\"en\">e-mail</i>, kann och fott bliive.",
+ "apihelp-createaccount-param-realname": "Dämm Metmaacher singe reeschtejje Nahme - kann fott blihve.",
+ "apihelp-createaccount-param-mailpassword": "Wann heh jädd aanjejovve es, kritt dä Metmaacher e zohfällesch ußjesöhk neu Paßwood aan sing Adräß för de <i lang=\"en\" xml:lang=\"en\">e-mail</i> jescheck.",
+ "apihelp-createaccount-param-reason": "Ene Jrond för dä Zojang aanzelähje, dä en de Logböhscher kütt.",
+ "apihelp-createaccount-param-language": "Dat Schprohcheköözel, wadd als der Schtandatt för dä Metmaacher jesaz wähde sull. Kann läddesch blihve, dann es et di Schprohch vum Wikki.",
+ "apihelp-createaccount-example-pass": "Lääsch dä Metmaacher <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">testuser</kbd> aan, mem Paßwood <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Lääsch dä Metmaacher <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">testmailuser</kbd> aan med emem zohfällesch ußjewörfelte Paßwoot un schegg_em dat övver de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">e-mail</i>.",
+ "apihelp-delete-summary": "Schmieß en Sigg fott.",
+ "apihelp-delete-param-title": "De Övverschreff vun dä Sigg zom fottschmiiße. Kam_mer nit zersamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</var>“ bruche.",
+ "apihelp-delete-param-pageid": "De Kännong vun dä Sigg zom fottschmiiße. Kam_mer nit zersamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1title</var>“ bruche.",
+ "apihelp-delete-param-reason": "Der Jrond för et Fottschmiiße. Wann dä nit aanjejovve es, weed ene automattesch usjräschnete Jrond jenumme.",
+ "apihelp-delete-param-tags": "Donn de Makehronge änndere, di för dä Enndraach em Logbohch jesaz wähde sulle.",
+ "apihelp-delete-param-watch": "Donn di Sigg en däm aktoälle Metmaacher sing Oppaßleß opnämme.",
+ "apihelp-delete-param-watchlist": "Donn di Sigg op däm aktoälle Metmaacher sing Oppaßleß udder nemm se druß fott, donn de Enschtällonge nämme, udder donn de Oppaßleß jaa nit verändere.",
+ "apihelp-delete-param-unwatch": "Schmihß di Sigg us däm aktoälle Metmaacher singe Oppaßless erus.",
+ "apihelp-delete-param-oldimage": "Der Nahme vom ahle Beld zom fottschmiiße, wi hä vun [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]] kütt.",
+ "apihelp-delete-example-simple": "Schmiiß de Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ fott.",
+ "apihelp-delete-example-reason": "Schmiiß de „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ fott mem Jrond: <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Dat Moduhl wohd affjeschalldt.",
+ "apihelp-edit-summary": "Sigge aanlähje un verändere.",
+ "apihelp-edit-param-title": "De Övverschreff vun dä Sigg zom Ändere. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</var>“ bruche.",
+ "apihelp-edit-param-pageid": "De Känong vun dä Sigg zom Ändere. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1title</var>“ bruche.",
+ "apihelp-edit-param-section": "De Nommer vum Affschnedd. Nemm „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">0</kbd>“ för wat vör der eezde Övverschreff schteihd. Ene neue Affscnedd määt mer met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">new</var>“.",
+ "apihelp-edit-param-sectiontitle": "De Övverschreff för ene neue Affschnett.",
+ "apihelp-edit-param-text": "Dä Sigg ehre Ennhalld.",
+ "apihelp-edit-param-summary": "Dat Fäld för „{{int:summary}}“. Och en Öveschreff för ene Affschnedd wann „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1section=new</code>“ un „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sectiontitle</code>“ nit jesaz es.",
+ "apihelp-edit-param-tags": "De Mekhonge för op heh di väsjohn aanzewännde.",
+ "apihelp-edit-param-minor": "En klein Änderong.",
+ "apihelp-edit-param-notminor": "Kein klein Änderong.",
+ "apihelp-edit-param-bot": "Makeer heh di Änderog als vun enem Bot jemaat.",
+ "apihelp-edit-param-basetimestamp": "Dattom un Zigg för de Ußjangs_Väsjohn, di jenumme weed, öm dubbel Beärbeijdonge bemärke ze künne. Di kam_mer övver di Sigg <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\"[[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]]</code> eruß fenge.",
+ "apihelp-edit-param-starttimestamp": "Dattom un Zigg för wann et Beärbeijde loßß jing, di jenumme weed, öm dubbel Beärbeijdonge bemärke ze künne. Di kam_mer övver di Sigg <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\"[[Special:ApiHelp/main|curtimestamp]]</code> eruß fenge em Momang, woh mem Beärbeijde bejenne deihjt.",
+ "apihelp-edit-param-recreate": "Övverjangk alle Fähler övver di Sigg, di en der Zweschezigg fott jeschneße wohd.",
+ "apihelp-edit-param-createonly": "Donn di Sigg nit ändere, wann se ald doh es.",
+ "apihelp-edit-param-nocreate": "Mäld ene Fähler, wann di Sigg nit doh es.",
+ "apihelp-edit-param-watch": "Donn di Sigg op dem aktälle Metmaacher sing Oppaßleß.",
+ "apihelp-edit-param-unwatch": "schmiiß di Sigg uß heh däm Metmaacher singe oppaßleß.",
+ "apihelp-edit-param-watchlist": "Donn en Sigg en däm aktoälle Metmaacher sing Opaßleß enndrahre udder ußdrahre udder donn däm sing Vörenschtällonge nämme udder jaa nix ändere.",
+ "apihelp-edit-param-md5": "De <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Message-Digest Algorithm 5\">MD5</i>-Prööfsomm vum Parramehter „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1text</var>“ udder de Parramehtere „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prependtext</var>“ un „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1appendtext</var>“ wähde annenannderjehange. Wann se jesaz sin, weed di Ännderong nit jeamaat, wann di Prööfsomm nit schtemmp.",
+ "apihelp-edit-param-prependtext": "Donn dä Täx aam Aanfng vun dä Sigg enndrahre. Övverjeiht „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1text</code>“.",
+ "apihelp-edit-param-appendtext": "Donn dä Täx aam Ängk vun dä Sigg aanhange. Övverjeiht „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1text</code>“.\n\nNemm „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$section=new</code>“ ömm ene neuje Affschnedd aanzehange, anschtatt vun heh dämm Parramehter.",
+ "apihelp-edit-param-undo": "Donn heh di Väsjohn widder retuhr nämme. Övverjeiht „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1text</code>“, „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prependtext </code>“ un „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1appendtext</code>“.",
+ "apihelp-edit-param-undoafter": "Donn alle Väsjohne vun „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1undo </code>“ bes zeläz heh di Väsjohn widder retuhr nämme. Wann nix ennjedrahre es, nämm blohß ein Väsjohn retuhr nämme.",
+ "apihelp-edit-param-redirect": "Verfollsch de Ömleidonge automattesch.",
+ "apihelp-edit-param-contentmodel": "Et Enhalltsmodäll för dä neue Ennhalld.",
+ "apihelp-edit-param-token": "Dä Makkehrongsschlößel suld emmer als der läzde Parramehter jeschek wähde udder winneschsdens noh däm Parramehter „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1text</var>“.",
+ "apihelp-edit-example-edit": "Veränder en Sigg.",
+ "apihelp-edit-example-prepend": "Donn <kbd>_&#95;NOTOC_&#95;</kbd> för en Sigg säze.",
+ "apihelp-edit-example-undo": "Donn alle Väsjohne vun „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">13579</code>“ bes zeläz „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">13585</code>“ widder retuhr nämme u en autmatesche Zersamfaßong derför enndrahre.",
+ "apihelp-emailuser-summary": "Donn en <i lang=\"en\" xml:lang=\"en\">e-mail</i> aan dä Metmaacher schecke.",
+ "apihelp-emailuser-param-target": "D ä Metmaacher, dä di <i lang=\"en\" xml:lang=\"en\">e-mail</i> krijje sull.",
+ "apihelp-emailuser-param-subject": "Koppeih mem Beträff.",
+ "apihelp-emailuser-param-text": "Dä Täx en dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">e-mail</i>.",
+ "apihelp-emailuser-param-ccme": "scheck mer en Koppih vun heh dä <i lang=\"en\" xml:lang=\"en\">e-mail</i>.",
+ "apihelp-emailuser-example-email": "Donn en <i lang=\"en\" xml:lang=\"en\">e-mail</i> aan dä Metmaacher „<kbd lang=\"en\" xml:lang=\"en\">WikiSysop</kbd>“ schecke mem Täx „<kbd lang=\"en\" xml:lang=\"en\">Content</kbd>“ dren.",
+ "apihelp-expandtemplates-summary": "Deiht alle Schablohne en Wikkitäx ömsäze.",
+ "apihelp-expandtemplates-param-title": "De Övverschreff vun dä Sigg.",
+ "apihelp-expandtemplates-param-text": "Dä Wikkitäx zom ömwandelle.",
+ "apihelp-expandtemplates-param-revid": "De Kännong vun dä Väsjohn, för \n„<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\"><nowiki>{{REVISIONID}}</nowiki></code>“ un verwandte Wääte.",
+ "apihelp-expandtemplates-param-prop": "Wat för en Aanjahbe ze holle?\n\nOpjepaß: wann jaa kei Wääte ußjewähld sinn, kütt der Wikkitäx eruß, ävver de ßjahbe kumme en enem Fommaht, wat mer nit hann welle.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Alle Saachjroppe en dä Quällesigg, di em Wikkitäx vun de ußjejovve Sigg nit vorkumme.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "De Sigge_Eijeschaffte, di vun de Zauberwööter em Wikkitäx faßjelaat wähde.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "De längste Zigg noh dä de zweschejescheijscherte jevonge Dahte nmmieh jöltesch sin sulle.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Alle Moduhle vum <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Delivery system in MediaWiki for the optimized run-time loading and managing of modules\">ResourceLoader</i>, di noh de Paaserfonksjuhne en de Ußjahbe vörkumme sulle. Äntwehder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">jsconfigvars</kbd>“ udder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">encodedjsconfigvars</kbd>“ moß mer met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">modules</kbd>“ zesamme aanforrdere.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Jitt de Varrejahble fun de Einschtällonge vun heh Sigg, di nur för di Sigg johd sin.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Jitt de Varrejahble fun de Einschtällonge vun heh Sigg, di nur för di Sigg johd sin, em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"JavaScript Object Notation\">JSON</i>-Fommahd als en Reih vun Zeijsche.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "Dä Ennjahv iere Paaser_Boum em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Extensible Markup Language\">XML</i>_Fommaht.",
+ "apihelp-expandtemplates-param-includecomments": "Ov Aanmärkonge em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i>-Fommaht med ußjejovve wähde sulle.",
+ "apihelp-expandtemplates-param-generatexml": "Donn ene Boum vum <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Extensible Markup Language\">XML</i>-Paaser opboue. Es dorsch „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prop=parsetree</code>“ ässäz.",
+ "apihelp-expandtemplates-example-simple": "Donn dä Wikkitäx <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\"><nowiki>{{Project:Sandbox}}</nowiki></kbd> en Täx wandelle.",
+ "apihelp-feedcontributions-summary": "Jidd ene Kannahl met de Beijdrähsch vun enem Metmaacher uß.",
+ "apihelp-feedcontributions-param-feedformat": "Däm Kannahl sing Fommaht.",
+ "apihelp-feedcontributions-param-user": "De Beijdrähsch för wat för en Metmaacher holle.",
+ "apihelp-feedcontributions-param-namespace": "Wat för ene Appachtemang för de Beijdrähsch ußjeschloße wähde sull.",
+ "apihelp-feedcontributions-param-year": "Vum johr un fröhjer.",
+ "apihelp-feedcontributions-param-month": "Vun däm Mohnd un derför",
+ "apihelp-feedcontributions-param-tagfilter": "Op wat för en Makkehronge de Beijdrähsch bschrängk wähde sulle.",
+ "apihelp-feedcontributions-param-deletedonly": "zeijsch blohß de fottjeschmeße Beijdrähsch.",
+ "apihelp-feedcontributions-param-toponly": "Zeich blohß de Änderonge, di och de neußte sin.",
+ "apihelp-feedcontributions-param-newonly": "Zeich blohß de Änderonge, woh Sigge neu aanjelaat woode sin.",
+ "apihelp-feedcontributions-param-hideminor": "Donn kein Minni-Ännderonge ennblände.",
+ "apihelp-feedcontributions-param-showsizediff": "Zeijsch de Ongerscheijd en de Jrühße zwesche de Väsjohne.",
+ "apihelp-feedcontributions-example-simple": "Zeijsch de Änderonge vum Metmaacher <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Donn ene Kannahl för de neuste Änderonge ußjävve.",
+ "apihelp-feedrecentchanges-param-feedformat": "Däm Kannahl sing Fommaht.",
+ "apihelp-feedrecentchanges-param-namespace": "Op wat för ene Appachtemang de Beijdrähsch beschrängk wähde sulle.",
+ "apihelp-feedrecentchanges-param-invert": "Alle Appachtemangs ußer däm ußjesöhkte.",
+ "apihelp-feedrecentchanges-param-associated": "Donn et drop betroke Appachtemang — Klaafsigge udder Atikelle — med enschlehße.",
+ "apihelp-feedrecentchanges-param-days": "Wadd eruß küdd op di Dähsch enschrängke.",
+ "apihelp-feedrecentchanges-param-limit": "De hühßte Aanzahl vun Äjeebnesse för zeröck ze jävve",
+ "apihelp-feedrecentchanges-param-from": "Zeijsch de Änderonge zigg dämm.",
+ "apihelp-feedrecentchanges-param-hideminor": "De kleine Minni_Ännderonge verschteijsche.",
+ "apihelp-feedrecentchanges-param-hidebots": "Änderonge ußschlehße, di vun Bots jemaht wohde.",
+ "apihelp-feedrecentchanges-param-hideanons": "Änderonge ußschlehße, di vun nahmelohse Metmaacher jemaht wohde.",
+ "apihelp-feedrecentchanges-param-hideliu": "Änderonge ußschlehße, di vun aanjemälldete Metmaacher jemaht wohde.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Nohjelohrte Änderonge övverjonn.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Änderonge vun heh dämm Metmaacher övverjonn.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Donn Änderonge aan de Zohjehüreshkeit zoh Saachjroppe veschteijsche.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Noh Makkehronge beschängke.",
+ "apihelp-feedrecentchanges-param-target": "Zeijsch Änderonge aan Sigge, op di vun heh dä Sigg ene Lengk jeihd.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Zeijsch Änderonge aan Sigge, op di vun dä ußjesöhk Sigg ene Lengk jeihd.",
+ "apihelp-feedrecentchanges-param-categories": "Donn blohß de Änderonge aan de Zohjehüreshkeit för all heh di Saachjroppe zeije.",
+ "apihelp-feedrecentchanges-param-categories_any": "Donn deföhr blohß de Änderonge aan de Zohjehüreshkeit för öhndseijn fun heh dä Saachjroppe zeije.",
+ "apihelp-feedrecentchanges-example-simple": "Zeijsch de {{LCFIRST:{{int:recentchanges}}}}",
+ "apihelp-feedrecentchanges-example-30days": "Zeijsch de {{LCFIRST:{{int:recentchanges}}}} vun de läzde 30 Dähsch.",
+ "apihelp-feedwatchlist-summary": "Donn ene Kannahl met dä Oppaßleß zerökjävve.",
+ "apihelp-feedwatchlist-param-feedformat": "Däm Kannahl sing Fommaht.",
+ "apihelp-feedwatchlist-param-hours": "Zeijsch de Sigge, di en de läzde su un esu vill Schtonde vun jäz aan veränder wohde sin.",
+ "apihelp-feedwatchlist-param-linktosections": "Lengk tirägg od der veränderte Affschnedd, woh müjjelesch.",
+ "apihelp-feedwatchlist-example-default": "Zeijsch ene Kannahl met dä Oppaßleß.",
+ "apihelp-feedwatchlist-example-all6hrs": "Zeijsch alle Änderonge aan Sgge obb Oppaßleßte us de läzde 6 Schtunde.",
+ "apihelp-filerevert-summary": "Säz en Dattei obb en ahle Väsohn zerök.",
+ "apihelp-filerevert-param-filename": "De Zih_Dattei, der ohne „{{ne:file}}“ derför.",
+ "apihelp-filerevert-param-comment": "Aanmärkong huh lahde.",
+ "apihelp-filerevert-param-archivename": "Dä nahme vum Aschihv vun dä Väsjohn för wider drop zerök ze jon.",
+ "apihelp-filerevert-example-revert": "Donn <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Wiki.png</kbd> op di Väsohn vum <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">2011-03-05T15:27:40Z</kbd> zerök säze.",
+ "apihelp-help-summary": "zeisch Hölp för de aanjejovve Moduhle.",
+ "apihelp-help-param-modules": "Moduhle, öm Hölp för de Wääte vun de „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">action</var>“ un „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">format</var>“ Parramehtere, udder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">main</kbd>“. aanzezeije. Mer kann Ongermoduhle met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">+</kbd>“ aanjävve.",
+ "apihelp-help-param-submodules": "Donn Hölp för de Ongermoduhle vun dämm aanjejovve Moduhl enschschlehße.",
+ "apihelp-help-param-recursivesubmodules": "Donn Hölp för de Ongermoduhle allesammp enschschlehße, esu deef, wi et jeiht.",
+ "apihelp-help-param-helpformat": "Et Fommaht vun de Ußjahbe för de Hölp.",
+ "apihelp-help-param-wrap": "Donn de Ußjahbe en dem <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> sing schtandattmähßejje Schtruktuhr vun de Antwood enschlehße.",
+ "apihelp-help-param-toc": "Donn en Enhhaldserzeijschensß en de Ußjahbe vum <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i> ennschlehße.",
+ "apihelp-help-example-main": "Hölp för et Houpmoduhl.",
+ "apihelp-help-example-submodules": "Hölp för „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">action=query</kbd>“ un alle Ongermoduhle.",
+ "apihelp-help-example-recursive": "Alle Hölp en eine Sigg.",
+ "apihelp-help-example-help": "Alle Hölp övver de Hölp säälver.",
+ "apihelp-help-example-query": "Hölp för zwei Ongermoduhle för Frohre.",
+ "apihelp-imagerotate-summary": "Ein udder mih Bellder driehje.",
+ "apihelp-imagerotate-param-rotation": "Öm wi vill Jrahd sulle de Bellder noh de Uhr drieh wääde?",
+ "apihelp-imagerotate-example-simple": "Drieh de <kbd>Dattei:Beijschpell.png</kbd> öm <kbd>90</kbd> Jrahd.",
+ "apihelp-imagerotate-example-generator": "Drieh alle Bellder en dä <kbd>Saachjropp:Ömdriehje</kbd> öm <kbd>180</kbd> Jrahd.",
+ "apihelp-import-param-summary": "Zersammefaßong för der Empohrt för et Logbohch.",
+ "apihelp-import-param-xml": "Donn en Dattei em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Extensible Markup Language\">XML</i>-Fommaht huhjahde.",
+ "apihelp-import-param-interwikisource": "För et Empottehre us enem andere Wikki: Dat Wikki vun woh der Empohrt kumme sull.",
+ "apihelp-import-param-interwikipage": "För et Empottehre us enem andere Wikki: De Sigg zom Empottehre.",
+ "apihelp-import-param-fullhistory": "För et Empottehre us enem andere Wikki: Donn de jannze Verjangeheid empottehre, nit blohß de aktoälle Väsjohn.",
+ "apihelp-import-param-templates": "För et Empottehre us enem andere Wikki: Donn all de nühdejje Schablohne met empottehre.",
+ "apihelp-import-param-namespace": "En heh dat Appachtemang emmpotehre. Kam_mer nit mem Parramehter „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1rootpage</var>“ zersamme bruche.",
+ "apihelp-import-param-rootpage": "Als Ongersigg vun heh dä Sigg empottehre. Km_mer nit zosamme met däm Parramehter „<varlang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1namespace</var>“ bruche.",
+ "apihelp-import-example-import": "Donn di Sigg „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[meta:Help:ParserFunctions]]</code>“ en et Appachtemang <code>100</code>empottehre, met alle älldere Väsjohne ennjeschloßße.",
+ "apihelp-linkaccount-example-link": "Fang dä Vörjang vum Verlengk obb ene Zohjang aan vun <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Example</kbd> aan.",
+ "apihelp-login-param-name": "Metmaacher_Nahme.",
+ "apihelp-login-param-password": "Paßwoot.",
+ "apihelp-login-param-domain": "De Domaijn (kann fott bliehve)",
+ "apihelp-login-example-login": "Enlogge.",
+ "apihelp-logout-summary": "Donn ußlogge un maach de Dahte övver de Sezong fott.",
+ "apihelp-logout-example-logout": "Donn dä aktoälle Metmaacher ußlogge.",
+ "apihelp-managetags-summary": "Verwalldongsaufjahbe em Zersammehang met Makkehronge vun Änderonge donn.",
+ "apihelp-managetags-param-reason": "Ene Jrond för et Aanlähje, Fottschmiiße, Aanschallde un Ußschallde vun dä Makkehrong, dä mer ävver nit aanjävve moß.",
+ "apihelp-managetags-param-ignorewarnings": "Ov alle Warnonge övverjange wähde sulle, di bei dämm Opdracht opkumme.",
+ "apihelp-managetags-example-create": "Donn en Makkehrong aanlähje mem Nahme „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">spam</kbd>“ mem Jrond „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">For use in edit patrolling</kbd>“.",
+ "apihelp-managetags-example-delete": "Schmiiß de Makkehrong mem Nahme „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">vandlaism</kbd>“ fott mem Jrond „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Misspelt</kbd>“.",
+ "apihelp-managetags-example-activate": "Donn en Makkehrong aktevehre mem Nahme „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">spam</kbd>“ mem Jrond „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">For use in edit patrolling</kbd>“.",
+ "apihelp-managetags-example-deactivate": "Donn en Makkehrong mem Nahme „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">spam</kbd>“ nit mieh aktihv maache, mem Jrond „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">For use in edit patrolling</kbd>“.",
+ "apihelp-mergehistory-summary": "Väsjohne fun Sigge zosamme lähje.",
+ "apihelp-mergehistory-param-from": "De Övverschreff vun dä Sigg, vun däh de verjange Väsjohne zesamme jelaat wähde sulle. Kam_mer nit zesamme met <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1fromid</var> bruche.",
+ "apihelp-mergehistory-param-fromid": "De Kännong vun dä Sigg, vun däh de verjange Väsjohne zesamme jelaat wähde sulle. Kam_mer nit zesamme met <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1fromid</var> bruche.",
+ "apihelp-mergehistory-param-to": "De Övverschreff vun dä Sigg, wohen de verjange Väsjohne zesamme jelaat wähde sulle. Kam_mer nit zesamme met <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1toid</var> bruche.",
+ "apihelp-mergehistory-param-toid": "De Kännong vun dä Sigg, wohen de verjange Väsjohne zesamme jelaat wähde sulle. Kam_mer nit zesamme met <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1toid</var> bruche.",
+ "apihelp-mergehistory-param-reason": "Der Jrond för et Zesammelähje vun dä älldere Väsjohne.",
+ "apihelp-mergehistory-example-merge": "Donn de jannze älldere Väsjohne vun dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Oldpage</kbd>“ met dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Newpage</kbd>“ zesammelähje.",
+ "apihelp-mergehistory-example-merge-timestamp": "Donn de älldere Väsjohne vun dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Oldpage</kbd>“ bes zom <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">2015-12-31T04:37:41Z</kbd> met dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Newpage</kbd>“ zesammelähje.",
+ "apihelp-move-summary": "Donn en Sigg ömbenänne",
+ "apihelp-move-param-from": "De Övverschreff vun dä Sigg zom Ömbenänne. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1fromid</var>“ bruche.",
+ "apihelp-move-param-fromid": "De ännong vun dä Sigg zom Ömbenänne. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1from</var>“ bruche.",
+ "apihelp-move-param-to": "De neue Övverschreff för di Sigg drop ömzebenänne.",
+ "apihelp-move-param-reason": "Der jrond för di Sigg ömzebenänne.",
+ "apihelp-move-param-movetalk": "Donn de Klaafsigg ömbenänne, wann et se jitt.",
+ "apihelp-move-param-movesubpages": "Donn de Ongersigge ömbenänne, wann müjjelesch.",
+ "apihelp-move-param-noredirect": "Donn kein Ömleidong aanlähje.",
+ "apihelp-move-param-watch": "Donn de Sigg un de Ömleijdong op dem aktoälle Metmaacher sing Oppaßleß.",
+ "apihelp-move-param-unwatch": "Donn de Sigg un de Ömleijdong uß dem aktoälle Metmaacher sing Oppaßleß eruß nämme.",
+ "apihelp-move-param-watchlist": "Donn di Sigg en dem aktoälle Metmaacher sing Oppaßleß udder nemm se eruß, donn de Enschtällonge nämme udder donn de Oppaßleß nid ändere.",
+ "apihelp-move-param-ignorewarnings": "Donn alle Warnonge övverjonn",
+ "apihelp-move-example-move": "Donn di Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Badtitle</kbd>“ noh „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Goodtitle</kbd>“ önnänne, der ohne en Ömleijdong aanzelähje.",
+ "apihelp-opensearch-summary": "Em Wikki söhke mem <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„Offe Söhke“\">OpenSearch</i>",
+ "apihelp-opensearch-param-search": "Noh wat söhke?",
+ "apihelp-opensearch-param-limit": "De hühßte Aanzahl vun Äjeebnesse för zeröck ze jävve",
+ "apihelp-opensearch-param-namespace": "En wällschem Appachtemang söhke.",
+ "apihelp-opensearch-param-suggest": "Don nix wann „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[mw:Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var>“ op „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">false</code>“ jesaz es.",
+ "apihelp-opensearch-param-redirects": "Wi met Ömleidonge ömjonn?\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">return</code>:Jivv de Ömleidonge sällver uß.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">resolve</code>:Jiff de Sigg uß, woh de Ömleidong hen jeiht. Dat künnt winnijer wi „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1limit</code>“ Sigge ußjävve.\nTradizonäll es dä Schtandatt „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">return</code>“ för „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1format=json</code>“ un „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">resolve</code>“ för alle anndere.",
+ "apihelp-opensearch-param-format": "Et Fommaht zom Ußjävve.",
+ "apihelp-opensearch-param-warningsaserror": "Wann Warnonge opkumme met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">format=json</kbd>“, dann donn ene Fähler vum <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> ußjävve anschtat se ze övverjonn.",
+ "apihelp-opensearch-example-te": "Fengk Sigge, di met <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Te</kbd> aanfange.",
+ "apihelp-options-param-reset": "Säz de Enschtällonge op dem Wikki singe Standatt.",
+ "apihelp-options-param-optionname": "Dä Nahme vun ene Enschtällong, di op dä Wäät jesaz wähde sulle, dä „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1optionvalue</var>“ aanjitt.",
+ "apihelp-options-param-optionvalue": "Dä Wäät vun dä Enschtällong, di vun „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1optionname</var>“ aanjejovve weed. Kann Sänkrääschte Schresche („|“) ännthallde.",
+ "apihelp-options-example-reset": "Alle Enschtälloonge retuhr schtälle.",
+ "apihelp-options-example-change": "Donn de „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">skin</kbd>“ un „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">hideminor</kbd>“ Enschtällonge ändere.",
+ "apihelp-options-example-complex": "Donn alle Enschtällonge op der Schtandatt säze, dann säz „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">skin</kbd>“ un „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">nickname</kbd>“.",
+ "apihelp-paraminfo-summary": "Holl Aanjahbe övver dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> ier Moduhle.",
+ "apihelp-paraminfo-param-helpformat": "Et Fommaht vun de Täxe för Hölp.",
+ "apihelp-paraminfo-param-formatmodules": "Leß met de Nahme vun de Moduhle zom Fommatehre (Wäät vum „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">format</var>“-Parramehter). Nemm schtatt dämm „<varlang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1modules</var>“.",
+ "apihelp-paraminfo-example-1": "Zisch Aanjahbe övver <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>, un <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-parse-param-summary": "De Zersammefaßong för ze pahse.",
+ "apihelp-parse-param-redirects": "Wann „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1page</var>“ udder „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</var>“ obb_en Ömleijdong jesaz es, donn dä follje.",
+ "apihelp-parse-param-prop": "Wat för en Schtöker aan Ennfommazjuhne holle:",
+ "apihelp-parse-paramvalue-prop-text": "Jitt dä jepahßde Täx vum Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Jitt de Schprohche-Lengks em jepahßde Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-categories": "Jitt de Saachjroppe em jepahßde Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Jitt de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i>-Fazung vun de Saachjroppe us.",
+ "apihelp-parse-paramvalue-prop-links": "Jitt de entärne Lengks em jepahßde Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-templates": "Jitt de Schablohne em jepahßde Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-images": "Jitt de Belder em jepahßde Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Jitt de Lengks, di noh ußerhallev vum Wikki jonn, em jepahßde Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-sections": "Jitt de Affschnedde em jepahßde Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-revid": "Deiht de Kännong vun de Väsjohn vun dä jepahßde Sigg derbei.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Deiht de Övverschreff vum jepahßde Wikkitäx derbei.",
+ "apihelp-parse-paramvalue-prop-headitems": "Jitt de Saacher för enn der <code>&lt;head&gt;</code> vun dä Sigg ze donn.",
+ "apihelp-parse-paramvalue-prop-modules": "Jitt dem <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Delivery system in MediaWiki for the optimized run-time loading and managing of modules\">ResourceLoader</i> sing Moduhle uß, di en dä Sigg jebruch wähde. Äntwehder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">jsconfigvars</kbd>“ udder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">encodedjsconfigvars</kbd>“ moß mer met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">modules</kbd>“ zesamme aanforrdere.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Livvert de Varrejahble vun dä Ennschtällonge vum JavaSkrep, di äxtra för heh di Sigg enjeschtallt sin.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Livvert de Varrejahble vun dä Ennschtällonge vum JavaSkrep, di äxtra för heh di Sigg enjeschtallt sin als ene Täx em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"JavaScript Object Notation\">JSON</i>-Fommaht.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Jitt de Engewikkilengks em jepahßde Wikkitäx uß.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Jitt de der ojinahl Wikkitäx us, dä jepahß woode es.",
+ "apihelp-parse-paramvalue-prop-properties": "Jitt devärse Eijeschafte uß, di em jepahßde Wikkitäx faßjelaat woode sen.",
+ "apihelp-parse-param-section": "Donn blohß der Ennhalld vun däm Affschnett met dä Nommer paase.\n\nWann „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">new</kbd>“ enjejovve es, donn dä Täx <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1text</var> un de Övverschreff <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sectiontitle</var> paase, wi wänn_enne neuje Affschnett en dä Sigg derbei köhm.\n\nDä Parramehter „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">new</kbd>“ es blohß zohjelohße, wann och <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">text</var> aanjejovve es.",
+ "apihelp-parse-param-sectiontitle": "De Övverschreff för dä neuje Afschnet, wann <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">section</var> = <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">new</kbd> es.\n\nAnders wi beim Beärbeide vun dä Sigg weed dä Parramehter nit dorsch de <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">summary</var> ußjetuusch, wann hä fottjelohße udder läddesch es.",
+ "apihelp-parse-param-disablelimitreport": "Jiff keine Bereesch vum Vüürbereijde zom Paase (der „<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">NewPP limit report</i>“) mem Paaser singe Dahte zosamme uß.",
+ "apihelp-parse-param-disablepp": "Nämm <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1disablelimitreport</var> schtatt dämm.",
+ "apihelp-parse-param-disableeditsection": "Donn de Lenks för Affschnedde ze änndere en de Ußjahbe vum Paaser eruß lohße.",
+ "apihelp-parse-param-disabletidy": "Donn et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i> vun dä Ußjahbe nit oprühme, för e Beijschpell met <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"correct and cleans up HTML and XML documents fixing markup errors and upgrading legacy code\">tidy</i>.",
+ "apihelp-parse-param-disabletoc": "Donn et Ennhaldsverzeijscheneß en de Ußjahbe vottlohße.",
+ "apihelp-parse-example-page": "Donn en Sigg pahse.",
+ "apihelp-parse-example-text": "Donn Wikkitäx pahse.",
+ "apihelp-parse-example-texttitle": "Donn Wikkitäx pahse, un jiff derför en Övverschreff för en Sigg aan.",
+ "apihelp-parse-example-summary": "Donn Zersammefaßong pahse.",
+ "apihelp-patrol-summary": "Donn en Sigg udder Väsjohn nohkike.",
+ "apihelp-patrol-param-rcid": "De Kännong in de läzde Änderonge zum Nohkike.",
+ "apihelp-patrol-param-revid": "De Kännong vun dä Väsjohn zum Nohkike.",
+ "apihelp-patrol-example-rcid": "Donn en läzde Änderonge nohkike.",
+ "apihelp-patrol-example-revid": "Donn en Väsjohn nohkike.",
+ "apihelp-protect-summary": "Änder der Siggeschoz för en Sigg.",
+ "apihelp-protect-param-title": "De Övverschreff vun dä Sigg zom Schöze udder Freijävve. Kam_mer nit zesamme met\n„<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</code>“ bruche.",
+ "apihelp-protect-param-pageid": "De Kännong vun dä Sigg zom Schöze udder Freijävve. Kam_mer nit zesamme met\n„<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</code>“ bruche.",
+ "apihelp-protect-param-reason": "Der Jrond för et Schöze udder Freijävve.",
+ "apihelp-protect-param-tags": "Donn de Makehronge aanpaße, dat se för dä Enndraach em Logbohch vum Sigge Schöze jehühre.",
+ "apihelp-protect-param-cascade": "Donn en Schotz-Kaskahd zohlohße, alsu ene Schoz för ennjeföhschte Schablohne un upjerohfe Bellder vun dä Sigg. Deiht nix, wann keine von dä aanjejovve Zoote Schoz en Kaskahd zohlöht.",
+ "apihelp-protect-param-watchlist": "Donn di Sigg ohne Bedengonge op däm aktoälle Metmaacher sing Oppaßleß udder nemm se druß fott, donn de Enschtällonge nämme, udder donn de Oppaßleß jaa nit verändere.",
+ "apihelp-protect-example-protect": "Donn en Sigg schöze.",
+ "apihelp-protect-example-unprotect": "Donn en Sigg nit mih schöze un doh för saz de Beschrängkonge op <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">all</kbd>“. <!-- https://phabricator.wikimedia.org/T141367 -->",
+ "apihelp-protect-example-unprotect2": "Donn dä Schoz för en Sigg ophävve, un doh för kein Beschrängkonge säze.",
+ "apihelp-purge-param-forcelinkupdate": "Bräng de Tabälle met de lengks obb ene neue Schtand.",
+ "apihelp-purge-example-simple": "Donn fö de Sigge „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ un „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">API</kbd>“ de zweschejeschpeijscherte Väsjohn fottschmiiße.",
+ "apihelp-purge-example-generator": "Donn fö de eezte zehn Sigge em Schtanndadd_Appachtemang de zweschejeschpeijscherte Väsjohn fottschmiiße.",
+ "apihelp-query-param-prop": "Wat för en Eijeschaffte holle för de affjerohchte Sigge.",
+ "apihelp-query-param-list": "Wat för en Leßte holle.",
+ "apihelp-query-param-meta": "Wat för en Metta_Dahte ze holle.",
+ "apihelp-query-param-rawcontinue": "Jivv Rühdahte „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">query-continue</var>“ för et Wigger Maache us.",
+ "apihelp-query-example-allpages": "Holl Väsjohne vun Sigge, di met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">API</kbd>“ bejenne.",
+ "apihelp-query+allcategories-summary": "Alle Saachjroppe opzälle.",
+ "apihelp-query+allcategories-param-from": "De Saachjropp, vun woh aan opzälle.",
+ "apihelp-query+allcategories-param-to": "De Saachjropp, bes woh hen opzälle.",
+ "apihelp-query+allcategories-param-prefix": "Söhk noh Saachjroppe, woh de Övverschrevv esu aanfängk.",
+ "apihelp-query+allcategories-param-dir": "De Reijefollsch zum Zotehre.",
+ "apihelp-query+allcategories-param-min": "Jiff blohß Saachjroppe us, di winneschsdens esu vill Metjlehder han.",
+ "apihelp-query+allcategories-param-max": "Jiff blohß Saachjroppe us, di et mihts esu vill Metjlehder han.",
+ "apihelp-query+allcategories-param-limit": "Wi vell Saachjroppe ußjävve?",
+ "apihelp-query+allcategories-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Deiht de Aanzahl Sigge en dä Saachjropp derbei.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Makehrt de veschtoche Sachjroppe met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">_&#95;HIDDENCAT_&#95;</code>“.",
+ "apihelp-query+allcategories-example-generator": "Holl Ennfommazjuhne övver di Saaachjroppe_Sigg för Saachjroppe, di met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">List</kbd>“ bejenne.",
+ "apihelp-query+alldeletedrevisions-summary": "Donn alle fottjeschmeße Väsjohne vun enem Metmaacher udder en enem Appachemang opleßte.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Kam_mer blohß met <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$3user</var> bruche.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Kam_mer nit met <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$3user</var> bruche.",
+ "apihelp-query+alldeletedrevisions-param-start": "Et Dattom un de Zigg vun woh aff opjezallt wähde sull.",
+ "apihelp-query+alldeletedrevisions-param-end": "Et Dattom un de Zigg bes woh hen opjezallt wähde sull.",
+ "apihelp-query+alldeletedrevisions-param-from": "Bejenn de Leß bei heh dä Överschreff.",
+ "apihelp-query+alldeletedrevisions-param-to": "Hühr de Leß bei heh dä Överschreff oop.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Söhk noh Sigge, woh de Övverschrevv esu aanfängk.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Donn blohß Väsjohne met heh dä Makkehrong opleßte.",
+ "apihelp-query+alldeletedrevisions-param-user": "Donn blohß Väsjohne vun heh däm Metmaacher opleßte.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Donn kein Väsjohne vun heh däm Metmaacher opleßte.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Donn blohß Sigge en heh däm Appachtemang opleßte.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Wann als ene Jenerahtor enjesaz, brängk dat Övverschreffte un kein Kännonge vun Väsjohne.",
+ "apihelp-query+alldeletedrevisions-example-user": "Donn de läzde fuffzisch fottjeschmeße Beijdrähsch vum Metmaacher „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Example</kbd>“ opleste.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Donn de läzde fuffzisch fottjeschmeße Väsjohne em Houp-Appachemang opleste.",
+ "apihelp-query+allfileusages-summary": "Donn alle Dattei_Oprohfe opleste, och vun Datteije, di (noch) nit doh sin.",
+ "apihelp-query+allfileusages-param-from": "De Övverschreff vun dä Dattei, woh de Leß medd aanfange sull.",
+ "apihelp-query+allfileusages-param-to": "De Övverschreff vun dä Dattei, woh de Leß medd ophühre sull.",
+ "apihelp-query+allfileusages-param-prefix": "Söhk noh alle Övverschreffte, di met heh däm Täx aanfange.",
+ "apihelp-query+allfileusages-param-unique": "Donn blohß ongerscheidlijje Övverschreffte vun Datteije aanzeije. Kammer nit zesamme met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prop=ids<code>“ bruche.\nWann als ene Jenerahtor ennjesaz, brängk et Zihlsigge, un kein Kwällesigge.",
+ "apihelp-query+allfileusages-param-prop": "Wat för en Aanjahbe ennschlehße:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Deiht de Kännonge vun dä Sigge derbei, di dat bruche. Kam_mer nit zersamme met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1unique</code>“ bruche.",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Deiht dä Dattei ehr Övverschreff derbei.",
+ "apihelp-query+allfileusages-param-limit": "Wi vill sulle överhoup aanjezeisch wähde?",
+ "apihelp-query+allfileusages-param-dir": "En wälsche Reijefollsch?",
+ "apihelp-query+allfileusages-example-B": "Donn Övverschreffte vun Datteije aanzeije, och vun Datteije, di (noch) nit doh sin, zesame met dä Kännonge vun dä Sigge, woh se vun sin, aanjevange vun <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Donn ongerscheidlejje Övverschreffte vun Datteije opleßte.",
+ "apihelp-query+allfileusages-example-unique-generator": "Hollt alle Övverschreffte vun Datteije, un makehr di (noch) nit doh sin.",
+ "apihelp-query+allfileusages-example-generator": "Holl Sigge, woh Datteieje dren vorkumme.",
+ "apihelp-query+allimages-summary": "Donn alle Bellder der Reih noh opzälle.",
+ "apihelp-query+allimages-param-sort": "De Eijeschavv öm dernoh ze zottehre.",
+ "apihelp-query+allimages-param-dir": "En wälsche Reijefollsch?",
+ "apihelp-query+allimages-param-minsize": "Bejränz op Sigge met winneschßdens esu vill Bytes dren.",
+ "apihelp-query+allimages-param-maxsize": "Bejränz op Sigge met hüüschßdens esu vill Bytes dren.",
+ "apihelp-query+allimages-param-sha1": "Dam Bld sing <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"secure hash algorithm\">SHA-1</i>-Pröhvsomm. Övverjeiht „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sha1base36</code>“.",
+ "apihelp-query+allimages-param-sha1base36": "Däm Beld sing <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"secure hash algorithm\">SHA-1</i>-Pröhvsomm op dä bahses 36. Weed em Mehdiajwikki jebruch.",
+ "apihelp-query+allimages-param-user": "Jiv blohß de Datteije uß, di vun heh däm Metmaacher huh jelahde wohde sin. Kam_mer blohß met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sort=timestamp</code>“ bruche. Kam_mer nit met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1filterbots</code>“ zersamme bruche.",
+ "apihelp-query+allimages-param-filterbots": "Wi mer blohß de Datteije ußjitt, di vun Bots huh jelahde wohde sin. Kam_mer blohß met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sort=timestamp</code>“ bruche. Kam_mer nit met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1user</code>“ zersamme bruche.",
+ "apihelp-query+allimages-param-mime": "Wat för ene <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Multi-Purpose Internet Mail Extensions\">MIME</i>-Zoot ze Söhke, för e Beijschpell „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">image/jpeg</kbd>“.",
+ "apihelp-query+allimages-param-limit": "Wi vell Bellder ennsjesamp ußjävve.",
+ "apihelp-query+allimages-example-B": "Zeisch en Leß met Sigge un bejenn mem Bohchschtabe <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Zeijsch en Leß met de köözlesch huhjelahde Datteije, ähnlesch wi en [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Zeijsch en Leß met dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Multi-Purpose Internet Mail Extensions\">MIME</i>-Zoote „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">image/png</kbd>“ udder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">image/gif</kbd>“.",
+ "apihelp-query+allimages-example-generator": "Zeisch Aanjahbe övver veer Bellder un bejenn mem Bohchschtabe <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Donn alle Lengk opzälle, di en e beschtemmpt Appachtemang jonn.",
+ "apihelp-query+alllinks-param-from": "De Övverschreff vun däm Lengk, woh de Leß medd aanfange sull.",
+ "apihelp-query+alllinks-param-to": "De Övverschreff vun dä Dattei, woh et Zälle ophühre sull.",
+ "apihelp-query+alllinks-param-prefix": "Söhk noh alle verlengk Övverschreffte, di met heh däm Täx aanfange.",
+ "apihelp-query+alllinks-param-unique": "Zeijsch blohß de ongerscheidlijje verlengk Sigge ier Övverschreffte. Kam_mer nit zesamme met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prop=ids</code>“ bruche. Wam_mer et als ene Jenerahtor bruche deiht, kritt mer Zihlsiggge anschtatt vun Quällesigge.",
+ "apihelp-query+alllinks-param-prop": "Wat för en Aanjahbe ennschlehße:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Deiht de Kännonge vun dä Sigge met däm Lengk derbei. Kam_mer nit zersamme met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1unique</code>“ bruche.",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Deiht däm lengk sing Övverschreff derbei.",
+ "apihelp-query+alllinks-param-namespace": "Dat Appachtemang zom opzälle.",
+ "apihelp-query+alllinks-param-limit": "Wi vill sulle överhoup aanjezeisch wähde?",
+ "apihelp-query+alllinks-param-dir": "En wälsche Reijefollsch?",
+ "apihelp-query+alllinks-example-B": "Donn Övverschreffte aanzeije, woh Lengks drop jonnn, och di (noch) nit doh sin, zesame met dä Kännonge vun dä Sigge, woh se vun sin, aanjevange vun <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Leß ongerscheidlejje verlengk Övverschreffte.",
+ "apihelp-query+alllinks-example-unique-generator": "Hollt alle Övverschreffte, woh Lengks drop jonnn un makehr di (noch) nit doh sin.",
+ "apihelp-query+alllinks-example-generator": "Holl Sigge, di Lengks änthallde.",
+ "apihelp-query+allmessages-summary": "Donn em Wikki sing Täxte un Nohreescht ußjävve.",
+ "apihelp-query+allmessages-param-messages": "Wat för en Täxte un Nohreeschte usjävve. Der Schtandatt „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">*</kbd>“ bedügg alle Täxte un Nohreeschte.",
+ "apihelp-query+allmessages-param-prop": "Wat för en Eijeschaffte holle.",
+ "apihelp-query+allmessages-param-nocontent": "Wann dat ennjeschalld es, donn dä ennhalt vun de Täxte un Nohreeschte nit medd ußjävve.",
+ "apihelp-query+allmessages-param-args": "De Parramehtere för en dä Täx udder en di Nohreesch enzeföhje.",
+ "apihelp-query+allmessages-param-filter": "Jiv blohß de Täxte un Nohreesche uß, woh heh dat Täxschtöck dren änthallde es.",
+ "apihelp-query+allmessages-param-customised": "Jiv bloß de Täxte un Nohreesche en heh däm jewönschte Aanpaßongs_Zohschtand uß.",
+ "apihelp-query+allmessages-param-lang": "Jiv de Täxte un Nohreesche en heh dä Schprohch uß.",
+ "apihelp-query+allmessages-param-from": "Jiv de Täxte un Nohreesche vun heh aan uß.",
+ "apihelp-query+allmessages-param-to": "Jiv de Täxte un Nohreesche bes heh uß.",
+ "apihelp-query+allmessages-param-prefix": "Jiv de Täxte un Nohreesche met heh däm Aanfang uß.",
+ "apihelp-query+allmessages-example-ipb": "Zeijsch Täxde udder Nohreeschte di met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">ipb</kbd>“ aanfange.",
+ "apihelp-query+allmessages-example-de": "Zeijsch de Täxde udder Nohreeschte „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">august</kbd>“ un „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">mainpage</kbd>“ en Deutsch aan.",
+ "apihelp-query+allpages-summary": "Donn alle Sigge der Reih noh en enem jejovve Appachtemang aan.",
+ "apihelp-query+allpages-param-from": "De Övverschreff vun dä Sigg, woh de Leß medd aanfange sull.",
+ "apihelp-query+allpages-param-to": "De Kännong vun dä Sigg, bes woh hen opzälle.",
+ "apihelp-query+allpages-param-prefix": "Söhk noh alle Övverschreffte vun Sigge, di met heh dämm Wäät bejenne.",
+ "apihelp-query+allpages-param-namespace": "De Saachjropp zom opzälle.",
+ "apihelp-query+allpages-param-filterredir": "Wat för en Sigg zem opleßte.",
+ "apihelp-query+allpages-param-minsize": "Bejränz op Sigge met winneschßdens esu vill Bytes dren.",
+ "apihelp-query+allpages-param-maxsize": "Bejränz op Sigge met hüüschßdens esu vill Bytes dren.",
+ "apihelp-query+allpages-param-prtype": "Bejränz op jeschöz Sigge.",
+ "apihelp-query+allpages-param-limit": "Wi vill Sigge zem aanzeihje?",
+ "apihelp-query+allpages-param-dir": "En wälsche Reijefollsch?",
+ "apihelp-query+allpages-param-filterlanglinks": "Blohß Sigge met Schprohchelengks opleßte. Opjepaß: Dat künnt Schprohchelengks övverjonn, di vun Zohsazprojramme beijschtührt wohde sin.",
+ "apihelp-query+allpages-example-B": "Zeisch en Leß met Sigge un bejenn mem Bohchschtabe <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Zeisch Aanjahbe övver veer Bellder un bejenn mem Bohchschtabe <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Zeisch der Enhalld vu de eetsde zwai Sigg un bejenn bei <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Alle Ömleidonge op e beschtemmp Appachtemang opleßte.",
+ "apihelp-query+allredirects-param-from": "De Övverschreff vun dä Ömleidong, woh de Leß medd ophühre sull.",
+ "apihelp-query+allredirects-param-to": "De Övverschreff vun dä Sigg, woh et Zälle ophühre sull.",
+ "apihelp-query+allredirects-param-prefix": "Söhk not Sigge, di esu aanfange.",
+ "apihelp-query+allredirects-param-unique": "Zeijsch blohß de ongerscheidlijje Zihl_Sigg. Kam_mer nit zesamme met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prop=ids|fragment|interwiki</code>“ bruche. Wam_mer et als ene Jenerahtor bruche deiht, kritt mer Zihlsiggge anschtatt vun Quällesigge.",
+ "apihelp-query+allredirects-param-prop": "Wat för en Aanjahbe ennschlehße:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Deiht de Kännonge vun dä Ömleijdongssigg derbei. Kam_mer nit zersamme met „< var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1unique</var>“ bruche.",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Deiht dä Ömleijdong ehr Övverschreff derbei.",
+ "apihelp-query+allredirects-param-namespace": "Dat Appachtemang zom opzälle.",
+ "apihelp-query+allredirects-param-limit": "Wi vill sulle överhoup aanjezeisch wääde?",
+ "apihelp-query+allredirects-param-dir": "En wälsche Reijefollsch?",
+ "apihelp-query+allredirects-example-B": "Zeisch Zihlsigge, och di et (noch) nit jitt, met dä Kännonge, wo se her sin, un bejenn mem Bohchschtabe <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "Ongerscheidlijje Sigge opleste.",
+ "apihelp-query+allredirects-example-unique-generator": "Hollt alle Zihlsigge un makkehr di (noch) nit doh sin.",
+ "apihelp-query+allredirects-example-generator": "Holl de Sigge met de Ömleidonge.",
+ "apihelp-query+allrevisions-summary": "Donn alle Väsjohne opleßte.",
+ "apihelp-query+allrevisions-param-start": "Et Dattom un de Zigg vun woh aff opjezallt wähde sull.",
+ "apihelp-query+allrevisions-param-end": "Et Dattom un de Zigg bes woh hen opjezallt wähde sull.",
+ "apihelp-query+allrevisions-param-user": "Donn blohß Väsjohne vun heh däm Metmaacher opleßte.",
+ "apihelp-query+allrevisions-param-excludeuser": "Donn kein Väsjohne vun heh däm Metmaacher opleßte.",
+ "apihelp-query+allrevisions-param-namespace": "Donn blohß Sigge en heh däm Appachtemang opleßte.",
+ "apihelp-query+allrevisions-param-generatetitles": "Wann als ene Jenerahtor enjesaz, brängk dat Övverschreffte un kein Kännonge vun Väsjohne.",
+ "apihelp-query+allrevisions-example-user": "Donn de läzde fuffzisch Beijdrähsch vum Metmaacher „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Example</kbd>“ opleßte.",
+ "apihelp-query+allrevisions-example-ns-main": "Donn de eezde fuffzisch Väsjohne em Houp-Appachemang opleßte.",
+ "apihelp-query+mystashedfiles-summary": "Holl en Leß vun dem aktoälle Metmaacher singe upload stash.",
+ "apihelp-query+mystashedfiles-param-prop": "Wat för en Aanjahbe holle för di Datteije.",
+ "apihelp-query+mystashedfiles-param-limit": "Wi vill Datteije holle?",
+ "apihelp-query+alltransclusions-param-from": "De Övverschreff vun dä ennjeföhschte Sigg, woh de Leß medd aanfange sull.",
+ "apihelp-query+alltransclusions-param-to": "De Övverschreff vun dä ennjeföhschte Sigg, woh et Zälle ophühre sull.",
+ "apihelp-query+alltransclusions-param-prefix": "Söhk noh alle dä ennjeföhschte Sigge ier Övverschreffte, di met heh däm Täx aanfange.",
+ "apihelp-query+alltransclusions-param-unique": "Zeijsch blohß de ongerscheidlijje ennjeföhschte Sigge ier Övverschreffte. Kam_mer nit zesamme met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prop=ids</code>“ bruche. Wam_mer et als ene Jenerahtor bruche deiht, kritt mer Zihlsiggge anschtatt vun Quällesigge.!FUZZY!!",
+ "apihelp-query+alltransclusions-param-prop": "Wat för en Aanjahbe ennschlehße:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Deiht de Kännonge vun dä Sigg derbei, di dat bruch. Kam_mer nit zersamme met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1unique</code>“ bruche.",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Deiht de Övverschreff vun dä jebruchte Sigg derbei.",
+ "apihelp-query+alltransclusions-param-namespace": "Dat Appachtemang zom opzälle.",
+ "apihelp-query+alltransclusions-param-limit": "Wi vill sulle överhoup aanjezeisch wähde?",
+ "apihelp-query+alltransclusions-param-dir": "En wälsche Reijefollsch?",
+ "apihelp-query+alltransclusions-example-B": "Donn Övverschreffte aanzeije, och vun Sigge, di (noch) nit doh sin, zesame met dä Kännonge vun dä Sigge, woh se vun sin, aanjevange vun <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Donn de Övverschreffte vun ennjeföhschte Sigge opleßte, ävver jehde blohß eijmohl.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Hollt alle Övverschreffte vun ennjeföhschte Sigge, un makehr di (noch) nit doh sin.",
+ "apihelp-query+alltransclusions-example-generator": "Holl Sigge, di Ennföhjonge änthallde.",
+ "apihelp-query+allusers-summary": "Donn alle aanjemälldte Metmaacher opzälle.",
+ "apihelp-query+allusers-param-from": "Dä Metmaacher_Nahme vun woh aan opzälle.",
+ "apihelp-query+allusers-param-to": "Dä Metmaacher_Nahme bes woh hen opzälle.",
+ "apihelp-query+allusers-param-prefix": "Söhk noh alle Metmaacher_Nahme, di mit heh däm Wäät bejenne.",
+ "apihelp-query+allusers-param-dir": "De Reijefollsch zum Zotehre.",
+ "apihelp-query+allusers-param-group": "Donn blohß Metmaacher uß dä aanjejovve Jroppe enschlehße.",
+ "apihelp-query+allusers-param-excludegroup": "Donn keine Metmaacher uß dä aanjejovve Jroppe enschlehße.",
+ "apihelp-query+allusers-param-prop": "Wat för en Aanjahbe med enzschlehße:",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Donn alle Jroppe opleste, woh dä Metmaacher automattesch dren es.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "De Rääschde vn däm Memaacher.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Donn de Aanzahl Änderonge derbei, di dä Metmaacher em Wikki jemaat hät.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Wann aanjejovve, deihd dat heh et Dattom un de Zigg derbei, wann dä Metmaacher sesch aanjemälld hät, wann müjjelech. Dat kann läddesch blihve.",
+ "apihelp-query+allusers-param-limit": "Wi vill Nahme Metmaacher sulle mer krijje?",
+ "apihelp-query+allusers-param-witheditsonly": "Blohß Metmahcher, di och ens jät verändert han.",
+ "apihelp-query+allusers-param-activeusers": "Donn blohß Metmaacher opleßte, di {{PLURAL:$1|der läzde Daach|en de läzde $1 Dääsch|keine läzde Daach}} aktihf wohre.",
+ "apihelp-query+allusers-example-Y": "Monn metmaacher opleßte, woh de Nahme vun met <kbd>Y</kbd> aanfange.",
+ "apihelp-query+backlinks-summary": "Fengk alle Sigge, di op de aanjejovve Sigg lengke.",
+ "apihelp-query+backlinks-param-title": "De Övverschreff för noh ze Söhke. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</var>“ bruche.",
+ "apihelp-query+backlinks-param-pageid": "De Känong vun dä Sigg zom Söhke. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1title</var>“ bruche.",
+ "apihelp-query+backlinks-param-namespace": "Dat Appachtemang zom opzälle.",
+ "apihelp-query+backlinks-param-dir": "En wälsche Reijefollsch?",
+ "apihelp-query+backlinks-param-limit": "Wi vill Sigge ensjesamp ußjävve. Wann „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1redirect</var>“ ennjeschalld es, weed di Beschrängkong op jehden Nivoh äxtra aanjwandt, wat bedügg, dat bes op 2 * „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1limit</var> ußjejovve wähde künne.",
+ "apihelp-query+backlinks-param-redirect": "Wann de Sigg met dämm Lengk dren en Ömleijdong änthält, fengk derzoh och alle Sigge, di doh drop lengke. De Bovverjränz för de Aanzahl Sigge för opzeleßte weed hallbehrt.",
+ "apihelp-query+backlinks-example-simple": "Zeijsch Lengks op de Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main page</kbd>“.",
+ "apihelp-query+backlinks-example-generator": "Holl Ennfommazjuhne övver Sigge, di op de Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ lengke donn.",
+ "apihelp-query+blocks-summary": "Donn alle jeschpächte Metmaacher un <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Internet Protocol\">IP</i>-Adräße opleßte.",
+ "apihelp-query+blocks-param-start": "Et Dattom un de Zigg vun woh aff opjezallt wähde sull.",
+ "apihelp-query+blocks-param-end": "Et Dattom un de Zigg bes woh hen opjezallt wähde sull.",
+ "apihelp-query+blocks-param-ids": "Leß vun dä Kännonge vun Schpärre för dernoh ze söhke. Kann fott blihve.",
+ "apihelp-query+blocks-param-users": "Leß vun dä Metmaacher för dernoh ze söhke. Kann fott blihve.",
+ "apihelp-query+blocks-param-ip": "Holl alle Schpärre för heh di <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Internet Protocol\">IP</i>-Adräß udder heh dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Classless Inter-Domain Routing\">CIDR</i>-Berätt, och Schpärre vun Berätte. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$3users</var>“ bruche. <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Classless Inter-Domain Routing\">CIDR</i>-Berätte vun mieh wi <code dir=\"ltr\">IPv4/$1</code> udder <code dir=\"ltr\">IPv6/$2</code> wähde nit aanjenumme.",
+ "apihelp-query+blocks-param-limit": "De hühßde Aanzahl Spärre zom opleste.",
+ "apihelp-query+blocks-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Deiht dä Spärr iehr Kännonge derbei.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Deiht dä Nahme vom jeschpächte Metmaacher derbei.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Deiht de Kännong vum jeschpächte Metmaacher derbei.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Deiht dä Nahme vun däm Metmaacher derbei, dä jeschpächt hät.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Deiht de Kännong vun däm Metmaacher derbei, dä jeschpächt hät.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Deihd et Dattum un de Uhrzigg derbei, wann jeschpächt wood.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Deihd et Dattum un de Uhrzigg derbei, wann di Schparr eröm es.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Deiht der Jrond för di Schparr derbei.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Deiht dä Berätt vun <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Internet Protocol\">IP</i>-Adräße för di Schparr derbei.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "makkehrt di Spärr met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">autoblock</code>“, „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">anononly</code>“, un esu.",
+ "apihelp-query+blocks-example-simple": "Schpärre opleßte.",
+ "apihelp-query+blocks-example-users": "Donn de Schpärre vun dä Metmaacher <lang=\"en\" xml:lang=\"en\" dir=\"ltr\" kbd>Alice</kbd> un <lang=\"en\" xml:lang=\"en\" dir=\"ltr\" kbd>Bob</kbd> opleßte.",
+ "apihelp-query+categories-summary": "Donn alle Saachjroppe epleßte, woh di Sigge dren sin.",
+ "apihelp-query+categories-param-prop": "Wat för en zohsäzlejje Eijeschaffte holle för jehde Saachjropp:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Deiht dä Schlößel zom Zottehre vun dä Saachjropp derbei, en lange häxadezimahle Zahl, un der Schlößelvörsaz, woh ene Minsch jät med aanfange kann.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Deihd en Dattom un en Zigg derbei, wann di Sachjrobb aanjelaat woode es.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Makehrt de veschtoche Sachjroppe met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">_&#95;HIDDENCAT_&#95;</code>“.",
+ "apihelp-query+categories-param-show": "Wat för en Zoot Saachjroppe zeije.",
+ "apihelp-query+categories-param-limit": "Wi vell Saachjroppe ußjävve?",
+ "apihelp-query+categories-param-categories": "Donn blohß heh di Saachjroppe opleßte. Dadd es johd, öm eruß ze fenge ovv en beschtemmpte Sigg en en beschtemmpte Saachjropp dren es.",
+ "apihelp-query+categories-param-dir": "En wälsche Reijefollsch?",
+ "apihelp-query+categories-example-simple": "Holl en Leß med alle Saachjroppe, woh di Sigg <kbd>Albert Einstein</kbd> dren es.",
+ "apihelp-query+categories-example-generator": "Holl Aanjahbe övver alle Saachjroppe, di en dä Sigg <kbd>Albert Einstein</kbd> jebruch wähde.",
+ "apihelp-query+categoryinfo-summary": "Holl Aanjahbe övver de aanjejovve Saachjroppe.",
+ "apihelp-query+categoryinfo-example-simple": "Holl Enfomazjuhne övver „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Category:Foo</kbd>“ un „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Category:Bar</kbd>“.",
+ "apihelp-query+categorymembers-summary": "Donn alle Sigge en ener aanjejove saachjrobb opleste.",
+ "apihelp-query+categorymembers-param-title": "Wat för en Sachjropp opzälle. Moß aanjejovve sin. Moß der Vörsaz „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">{{ns:category}}:</kbd>“ änthallde. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</var>“ bruche.",
+ "apihelp-query+categorymembers-param-pageid": "De Kännong vun dä Sigg zom opzälle. Kam_mer nit zersamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1title</var>“ bruche.",
+ "apihelp-query+categorymembers-param-prop": "Wat för en Aanjahbe med enzschlehße:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Deiht de Kännong vun de Sigge derbei.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Donn de Övverschrevv un de Kännong för et Appachtemang derbei.",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Deihd et Dattum un de Uhrzigg derbei, wann di Sigg opjenumme wohd.",
+ "apihelp-query+categorymembers-param-limit": "De jrüüßte Zahl Sigge för ußzejävve.",
+ "apihelp-query+categorymembers-param-sort": "De Eijeschavv öm dernoh ze zottehre.",
+ "apihelp-query+categorymembers-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Der Zoteerschlößel för de Leß opzehühre, wi mer en met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prop=sortkey</kbd>“ kritt. Kann blohß met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sort=sortkey</kbd>“ jebruch wähde.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Der Zoteerschlößel för de Leß opzehühre, wi mer en met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1prop=sortkey</kbd>“ kritt. Kann blohß met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sort=sortkey</kbd>“ jebruch wähde.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "Der Aanfang vun däm Zoteerschlößel för de Leß opzehühre. Opühre deiht se <strong>för</strong>, un nit <strong>met</strong> däm. Wann dä Wäät opdouch, weed hä nit med ußjejovve. Kann blohß met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sort=sortkey</code>“ jebruch wähde un överjeihd „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1endhexsortkey</code>“.",
+ "apihelp-query+categorymembers-param-startsortkey": "Söhk „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1starthexsortkey</code>“ schtatt dämm.",
+ "apihelp-query+categorymembers-param-endsortkey": "Söhk „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1endhexsortkey </code>“ schtatt dämm.",
+ "apihelp-query+categorymembers-example-simple": "Holl de eezde zehn Sigge de dä <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Holl anjahbe övver de eezde zehn Sigge de dä <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Holl de Leß met de ennjelogg Schrihver un de Aanzahl nahmelohse Metschrihver aan ene Sigg.",
+ "apihelp-query+contributors-param-limit": "Wi vill Metschrihver ze livvere?",
+ "apihelp-query+contributors-example-simple": "Donn de Metschrihver aan dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">KMain PageBD</kbd>“ aanzeije.",
+ "apihelp-query+deletedrevisions-param-start": "Et Dattom un de Uhrzigg, von woh aan opzälle. Weed nit jebruch, wam_mer en Leß met Kännonge vun Väsjohne aam beärbeijde sin.",
+ "apihelp-query+deletedrevisions-param-end": "Et Dattom un de Uhrzigg, bes woh hen opzälle. Weed nit jebruch, wam_mer en Leß met Kännonge vun Väsjohne aam beärbeijde sin.",
+ "apihelp-query+deletedrevisions-param-tag": "Donn blohß Väsjohne met heh dä Makkehrong opleßte.",
+ "apihelp-query+deletedrevisions-param-user": "Donn blohß Väsjohne vun heh däm Metmaacher opleßte.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Donn kein Väsjohne vun heh däm Metmaacher opleßte.",
+ "apihelp-query+deletedrevisions-example-revids": "Donn de Aanjahbe för de fottjeschmeße Väsjohn <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">123456</kbd> holle.",
+ "apihelp-query+deletedrevs-param-start": "Et Dattom un de Zigg vun woh aff opjezallt wähde sull.",
+ "apihelp-query+deletedrevs-param-end": "Et Dattom un de Zigg bes woh hen opjezallt wähde sull.",
+ "apihelp-query+deletedrevs-param-from": "Bejenn de Leß bei heh dä Överschreff.",
+ "apihelp-query+deletedrevs-param-to": "Hühr de Leß bei heh dä Överschreff oop.",
+ "apihelp-query+deletedrevs-param-prefix": "Söhk noh Sigge, woh de Övverschrevv esu aanfängk.",
+ "apihelp-query+deletedrevs-param-unique": "Donn blohß ein Väsjohn för jehde Sigg opleßte.",
+ "apihelp-query+deletedrevs-param-tag": "Donn blohß Väsjohne met heh dä Makkehrong opleßte.",
+ "apihelp-query+deletedrevs-param-user": "Donn blohß Väsjohne vun heh däm Metmaacher opleßte.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Donn kein Väsjohne vun heh däm Metmaacher opleßte.",
+ "apihelp-query+deletedrevs-param-namespace": "Donn blohß Sigge en heh däm Appachtemang opleßte.",
+ "apihelp-query+deletedrevs-param-limit": "De hühßde Aanzahl Väsjohne för opzeleßte.",
+ "apihelp-query+deletedrevs-param-prop": "Wat för en Eijeschaffte holle:\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">revid</code>:Deiht de Kännong vun dä fottjeschmeße Väsjohn derbei.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">parentid</code>:Deiht de Kännong vun dä vörijje Väsjohn vun dä Sigg derbei.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">user</code>:Deiht dä Metmaacher derbei, dä di Väsjohn jemaat hät.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">userid</code>:Deiht de Kännong vun däm Metmaacher derbei, dä di Väsjohn jemaat hät.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">comment</code>:Deiht de koote Zesammefaßong vun dä Väsjohn derbei.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">parsedcomment</code>:Adds dä de jepaaste koote Zesammefaßong vun dä Väsjohn derbei.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">minor</code>:Tags, wann di Väsjohn en kleine Minni-Änderong wohr.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">len</code>:Deiht de Aanzahl <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Bytes</i> vun dä Väsjohn derbei.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">sha1</code>:Deiht dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"secure hash algorithm\">SHA-1 (base 16)</i> vun dä Väsjohn derbei.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">content</code>:Deiht dä Täx_Ennhalt vun dä Väsjohn derbei.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">token</code>:<span class=\"apihelp-deprecated\">Nit mih jewönsch.</span> Livvert de Makehrong vun dä Änderong.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">tags</code>:Makehronge vun dä Väsjohn.",
+ "apihelp-query+disabled-summary": "Dat Moduhl för Frohre ze schtälle wohd affjeschalldt.",
+ "apihelp-query+duplicatefiles-summary": "Donn alle Datteije opleßte, di desällve Prööfsomm han wi de aanjejovve Datteije.",
+ "apihelp-query+duplicatefiles-param-limit": "Wi vell datteije ußjävve.",
+ "apihelp-query+duplicatefiles-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+duplicatefiles-param-localonly": "Lohr blohß noh Datteije heh em Wikki.",
+ "apihelp-query+duplicatefiles-example-simple": "Lohr noh Datteije, di dubbelte vun dä Dattei „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[:File:Albert Einstein Head.jpg]]</code>“ sin.",
+ "apihelp-query+duplicatefiles-example-generated": "Lohr noh Dubbelte vun alle Datteije.",
+ "apihelp-query+embeddedin-summary": "Fengk alle Sigge, di di aanjejovve Dattei enneschlehße.",
+ "apihelp-query+embeddedin-param-title": "De Övverschreff för noh ze Söhke. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</var>“ bruche.",
+ "apihelp-query+embeddedin-param-pageid": "De Känong vun dä Sigg zom noh Söhke. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1title</var>“ bruche.",
+ "apihelp-query+embeddedin-param-namespace": "Dat Appachtemang zom opzälle.",
+ "apihelp-query+embeddedin-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+embeddedin-param-filterredir": "Wi de Ömleijdonge ußzottehre?",
+ "apihelp-query+embeddedin-param-limit": "Wi vill Sigge ensjesammp zem ußjävve?",
+ "apihelp-query+embeddedin-example-simple": "Zeisch de Sigge, di di Schablohn „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Template:Stub</kbd>“ oprohfe.",
+ "apihelp-query+embeddedin-example-generator": "Holl Aanjahbe övve de Sigge, di di Schablohn „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Template:Stub</kbd>“ oprohfe.",
+ "apihelp-query+extlinks-summary": "Jitt alle <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URLs</i> vun Lengks noh ußerhallef vum Wikki, ävver kein Engewiki_Lenks, vundä aanjejovve Sigge uß.",
+ "apihelp-query+extlinks-param-limit": "Wi vill Lengks ußjävve?",
+ "apihelp-query+extlinks-example-simple": "Holl en Leß met Lengks noh ußerhallef vum Wikki uß dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“.",
+ "apihelp-query+exturlusage-summary": "Donn alle Sigge upzälle med däm aanjejovve<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> dren.",
+ "apihelp-query+exturlusage-param-prop": "Wat för en Aanjahbe med enzschlehße:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Donn dä Sigg ier Kännong derbei.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Donn de Övverschrevv un de Kännong för et Appachtemang derbei.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Donn dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> derbei, dä en dä Sigg jebruch weed.",
+ "apihelp-query+exturlusage-param-protocol": "Dat Schehma uß däm <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i>. Wann et läddesch jelohße es un „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1query</var>“ aanjejogge es, es dat Schehma „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Transfer Protocol\">http</kbd>“. Lohß beeds dat un „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">1query</var>“ läddesch, öm alle Lengks noh ußerhallef opzeleßte.",
+ "apihelp-query+exturlusage-param-namespace": "Dat appachtemang met dä Sigge zom opzälle.",
+ "apihelp-query+exturlusage-param-limit": "Wi vill Sigge zem ußjävve?",
+ "apihelp-query+filearchive-summary": "Donn alle fottjeschmeße Datteije der Reih noh opzälle.",
+ "apihelp-query+filearchive-param-from": "De Övverschreff vun däm Beld, woh de Leß medd aanfange sull.",
+ "apihelp-query+filearchive-param-to": "De Övverschreff vun däm Beld, woh de Leß medd ophühre sull.",
+ "apihelp-query+filearchive-param-prefix": "Söhk noh alle Övverschreffte vun Bellder, di met heh dämm Wäät bejenne.",
+ "apihelp-query+filearchive-param-limit": "Wi vell Bellder ensjesamp zeröckjävve.",
+ "apihelp-query+filearchive-param-dir": "En wälsche Reijefollsch opleßte.",
+ "apihelp-query+filearchive-param-sha1": "Däm Beld singe <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"secure hash algorithm\">SHA-1</i>-Pröhvsomm. Övverjeiht „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1sha1base36</code>“.",
+ "apihelp-query+filearchive-param-sha1base36": "Däm Beld singe <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"secure hash algorithm\">SHA-1</i>-Pröhvsomm em 36-jer Zahlesüßtehm. Weed em Mehdiajwikki jebruch.",
+ "apihelp-query+filearchive-param-prop": "Wat för en Aanjahbe zom Beld holle:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Deiht dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"secure hash algorithm\">SHA-1 (base 16)</i> vun dä Väsjohn vn däm Beld derbei.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Adds timestamp for the uploaded version.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Deiht dä Metmaacher derbei, dä di Väsjohn huhjelahde hät.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Deiht de Aanzahl <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Bytes</i> vun dä Dattei derbei, un, wann bikannt, de Hühde, de Wiggde, un de Aanzahl Sigge.",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Et sällve, wi <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">size</code>.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Adds description för di Väsjohn vun däm Beld.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Parse the description för di Väsjohn vun däm Beld.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Adds MIME vun däm Beld.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Adds the media type vun däm Beld.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Deiht de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Exchangeable image file format\">EXIF</i>-Mettadahte för di Väsjohn vun däm Beld opleßte.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "deiht de bit depth för di Väsjohn derbei.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Deiht dä Nahme vun dä Dattei vun dä Aschihf_Väsjohn för alle Väsjohne, bes op de läzde, derbei.",
+ "apihelp-query+filearchive-example-simple": "Zeijsch en leß met alle fottjeschmeße Datteije.",
+ "apihelp-query+filerepoinfo-example-simple": "Holl ennfommazjuhne övver de Reppossetohreje met Datteije.",
+ "apihelp-query+fileusage-summary": "Fengk alle Sigge, di de aanjejovve Datteije bruche.",
+ "apihelp-query+fileusage-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "De Kännong för jehde Sigg.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "De Övverschreff för jehde Sigg.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Zeijsch aan, wann di Sigg en Ömleijdong es.",
+ "apihelp-query+fileusage-param-namespace": "Donn blohß Sigge en heh dä Appachtemangs metnämme.",
+ "apihelp-query+fileusage-param-limit": "Wi vill holle?",
+ "apihelp-query+fileusage-example-simple": "Holl Aanjahbe övver Sigge, di de Dattei „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[:File:Example.jpg]].</code>“ bruche.",
+ "apihelp-query+fileusage-example-generator": "Holl Aanjahbe övver Sigge, di de Dattei „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[:File:Example.jpg]].</code>“ bruche",
+ "apihelp-query+imageinfo-summary": "Jidd Enfommazjuhne övver Datteije un de Verjangeheid vum Huhlahde aan.",
+ "apihelp-query+imageinfo-param-prop": "Wat för en Schtöker aan Ennfommazjuhne holle:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Deihd en dattom un en Zigg aan de huhjelahde Väsjohn.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Deiht dä Metmaacher derbei, dä jehde Väsjohn vun dä Dattei huhjelahde hät.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Deiht de Kännong vun jehdem Metmaacher derbei, dä en Väsohn vun dä Dattei huh jelaahde hät.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Aanmärkonge bei dä Väsjohn.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Donn di Aanmärkonge bei dä Väsjohn paase.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Deiht de kannohnesche, schtandattmähßejje, Överschreff vun dä Dattei derbei.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Jitt dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> för di Dattei un de Sigg met däh iere Äkliehrong uß.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Deiht de Jrühße vun dä Dattei en \n<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Bytes</i>, de Hühde, de Breide, un, woh et se jitt, de Aanzahl Sigge derbei.",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Et sällve, wi de Jrühße.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Deiht de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"secure hash algorithm\">SHA-1</i>-Pröhvsomm för di Dattei derbei.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Deiht de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Multi-Purpose Internet Mail Extensions\">MIME</i>-Zoot fun dä Dattei derbei.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Deiht de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Multi-Purpose Internet Mail Extensions\">MIME</i>-Zoot fun däm Minnibelldsche vun dä Dattei derbei. Bruch en <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> un dä Parramehter „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1urlwidth</code>“.",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Deiht de Mehdijje_Zoot vun dä Dattei derbei.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Deiht de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Exchangeable image file format\">EXIF</i>-Mettadahte för di Väsjohn vun dä Dattei oplesßte.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Deiht de Mettadahte för heh di Väsjohn vun dä Dattei oplesßte, di alld schtandattmähßesch en däm Datteifommaht änthallde sin.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Deiht dä Nahme vun dä Dattei vun dä Aschihf_Väsjohn för alle Väsjohne, bes op de läzde, derbei.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "deiht de bit depth för di Väsjohn derbei.",
+ "apihelp-query+imageinfo-param-limit": "Wi vill Väsjohne för jehde Dattei ußjävve.",
+ "apihelp-query+imageinfo-param-start": "Et Dattom un de Zigg, vun woh aan opleßte.",
+ "apihelp-query+imageinfo-param-end": "Et Dattom un de Zigg, vun woh aan opleßte.",
+ "apihelp-query+imageinfo-param-urlheight": "Ähnlesch wi „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1urlwidth</code>“.",
+ "apihelp-query+imageinfo-param-localonly": "Belohr blohß de Datteije em eije Wikki singe Sammlong.",
+ "apihelp-query+imageinfo-example-simple": "Holl Enformazjuhne övver de aktoälle Väsjohn fun dä Dattei „<code lang=\"mul\" xml:lang=\"mul\" dir=\"ltr\">[[:File:Albert Einstein Head.jpg]]</code>“",
+ "apihelp-query+imageinfo-example-dated": "Holl Enformazjuhne övver de Väsjohne fun dä Dattei „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[:File:Test.jpg]]</code>“ vum Johr 2008 un schpääder.",
+ "apihelp-query+images-summary": "Jidd alle Datteije uß, di en dä aanjejovve Sigge sin.",
+ "apihelp-query+images-param-limit": "Wi vill Datteije holle?",
+ "apihelp-query+images-param-images": "Donn blohß heh di Datteije opleßte. Dadd es johd, öm eruß ze fenge ovv en en beschtemmpte Sigg beschtemmpte Datteije dren sin.",
+ "apihelp-query+images-param-dir": "En wälsche Reijefollsch opleßte.",
+ "apihelp-query+images-example-simple": "Holl en Leß vun Datteije, di en de „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Main Page]]</code>“.",
+ "apihelp-query+images-example-generator": "Holl Ennfommazjuhne övver alle Datteije, di en de „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Main Page]]</code>“ jebruch wähde.",
+ "apihelp-query+imageusage-summary": "Fengk alle Sigge, di en Beld medd ene bschtemmpte Övverschreff bruche.",
+ "apihelp-query+imageusage-param-title": "De Övverschreff för noh ze Söhke. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</var>“ bruche.",
+ "apihelp-query+imageusage-param-pageid": "De Känong vun dä Sigg zom noh Söhke. Kam_mer nit zesamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1title</var>“ bruche.",
+ "apihelp-query+imageusage-param-namespace": "Dat Appachtemang zom opzälle.",
+ "apihelp-query+imageusage-param-dir": "En wälsche Reijefollsch opleßte.",
+ "apihelp-query+imageusage-param-limit": "Wi vill Sigge ensjesamp ußjävve. Wann „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1redirect</var>“ ennjeschalld es, weed di Beschrängkong op jehden Nivoh äxtra aanjwandt, wat bedügg, dat bes op 2 * „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1limit</var> ußjejovve wähde künne.",
+ "apihelp-query+imageusage-param-redirect": "Wann de Sigg met dämm Lengk dren en Ömleijdong änthält, fengk derzoh och alle Sigge, di doh drop lengke. De Bovverjränz för de Aanzahl Sigge för opzeleßte weed hallbehrt.",
+ "apihelp-query+imageusage-example-simple": "Zeijsch Sigge, di di Dattei „<code lang=\"mul\" xml:lang=\"mul\" dir=\"ltr\">[[:File:Albert Einstein Head.jpg]]</code>“ bruche.",
+ "apihelp-query+imageusage-example-generator": "Holl Enformazjuhne övver de Sigge, di di Dattei „<code lang=\"mul\" xml:lang=\"mul\" dir=\"ltr\">[[:File:Albert Einstein Head.jpg]]</code>“ bruche.",
+ "apihelp-query+info-summary": "Holl jrondlähje Ennfommazjuhne övver di Sigg.",
+ "apihelp-query+info-param-prop": "Wat för en zohsäzlejje Eijeschaffte holle:",
+ "apihelp-query+info-paramvalue-prop-protection": "Donn der Siggeschoz för jehde Sigg opleßte.",
+ "apihelp-query+info-paramvalue-prop-talkid": "De Kännong för de Klaafsigg för jehde Nit-Klaafsigg.",
+ "apihelp-query+info-paramvalue-prop-watched": "Donn der Zohschtand vum Oppaße för jehde Sigg opleßte.",
+ "apihelp-query+info-paramvalue-prop-watchers": "De Aanzahl Oppaßer, wann zohjelohße.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "De Aanzahl Oppaßer pro Sigg, di woh zohjelohße, de neußte Änderonge aan dä Sigg belohrt hann.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "De Kännong för de övverje'odente Sigg för jehde Klaafsigg.",
+ "apihelp-query+info-paramvalue-prop-url": "Jidd en kumplätte <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i>, en <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> för et Beärbeide, un en kannohnesche <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> för jehde Sigg uß.",
+ "apihelp-query+info-paramvalue-prop-readable": "Ov dä Metmaacher heh di Sigg lässe kann.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Zeijsch de Maier, wi en Övverschreff vun en Sigg verhaftesch aanjezeijsch weed.",
+ "apihelp-query+iwbacklinks-param-prefix": "De Engerwikki_Vörsaz.",
+ "apihelp-query+iwbacklinks-param-title": "Der Engerwikki_Lengk för noh ze söhke. Moß met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1blprefix</var>“ zersamme jebruch wähde.",
+ "apihelp-query+iwbacklinks-param-limit": "Wi vill Sigge ensjesammp zem ußjävve?",
+ "apihelp-query+iwbacklinks-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Deiht dä Engerwikki_Vörsaz derbei.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Deiht de Engerwikki_Övverschreff derbei.",
+ "apihelp-query+iwbacklinks-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+iwbacklinks-example-simple": "Holl Sigge, di op „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[wikibooks:Test]]</code>“ verlengke.",
+ "apihelp-query+iwbacklinks-example-generator": "Holl Ennfommazjuhne övver Sigge, di op „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[wikibooks:Test]]</code>“ verlengke.",
+ "apihelp-query+iwlinks-summary": "Jiff alle Engerwikki_Lengks vun de aanjejovve Sigge uß.",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Deiht dä kumplätte <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> derbei.",
+ "apihelp-query+iwlinks-param-limit": "Wi vill Engerwikki_Lengks zem ußjävve?",
+ "apihelp-query+iwlinks-param-prefix": "Jiff blohß de Engerwikki_Lengks uß, di dermet aanfange.",
+ "apihelp-query+iwlinks-param-title": "Dä Engerwiki Lengk för dernoh ze söhke. Moß met <var>$1prefix</var> zesamme jebruch wähde.",
+ "apihelp-query+iwlinks-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+langbacklinks-param-lang": "Schprohch för dä Schprohche_Lengk.",
+ "apihelp-query+langbacklinks-param-title": "Der Schprohche_Lengk för noh ze söhke. Moß zersamme met <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1lang</code> jebruch wähde.",
+ "apihelp-query+langbacklinks-param-limit": "Wi vill Sigge ensjesammp zem ußjävve?",
+ "apihelp-query+langbacklinks-param-prop": "Wat för en Prijoretähte holle:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Deiht de Kännong för de Schprohch för dä Schprohchelengk derbei.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Deiht de Övverschreff för dä Schprohchelengk derbei.",
+ "apihelp-query+langbacklinks-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+langbacklinks-example-simple": "Holl Sigge, di op „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[:fr:Test]]</code>“ verlengke.",
+ "apihelp-query+langbacklinks-example-generator": "Holl Ennfommazjuhne övver Sigge, di op „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[:fr:Test]]</code>“ verlengke.",
+ "apihelp-query+langlinks-summary": "Jiff alle Schprohche_Lengks vun de aanjejovve Sigge uß.",
+ "apihelp-query+langlinks-param-limit": "Wi vill Schprohche_Lengks holle?",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Deiht dä kumplätte <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> derbei.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Deiht dä Nahme vun de Moterschprohch derbei.",
+ "apihelp-query+langlinks-param-lang": "Donn blohß de Schprohche_Lengks met däm aanjejovve Schprohche_Köözel.",
+ "apihelp-query+langlinks-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+links-summary": "Jiff alle Lengks vun de aanjejovve Sigge uß.",
+ "apihelp-query+links-param-namespace": "Zeijsch blohß de Lengks en dä Appachtemangs.",
+ "apihelp-query+links-param-limit": "Wi vill Lengks ußjävve?",
+ "apihelp-query+links-param-titles": "Donn blohß e Lengks of heh di Övverschreffte opleßte. Dadd es johd, öm eruß ze fenge ovv en en beschtemmpte Sigg op ene beschtemmpte Övverschreff verlengk es.",
+ "apihelp-query+links-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+links-example-simple": "Holl de Lengks vun dä Sigg <kbd>Main Page</kbd>",
+ "apihelp-query+linkshere-summary": "Fengk alle Sigge, di op de aanjejovve Sigge lengke.",
+ "apihelp-query+linkshere-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "Page ID of each page.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Title of each page.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Flag if the page is a redirect.",
+ "apihelp-query+linkshere-param-namespace": "Donn blohß Sigge en heh dä Appachtemangs metnämme.",
+ "apihelp-query+linkshere-param-limit": "Wi vill holle?",
+ "apihelp-query+linkshere-example-simple": "Holl en Leß vun Sigge, di op de Sigg „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Main Page]]</code>“ lengke donn.",
+ "apihelp-query+linkshere-example-generator": "Holl Ennfommazjuhne övver Sigge, di op de Sigg „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Main Page]]</code>“ lengke.",
+ "apihelp-query+logevents-summary": "Holl Enndrähsch us de Logböhscher.",
+ "apihelp-query+logevents-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+logevents-param-type": "Söhk blohß heh di Zood Enndrähsch us de Logböhscher.",
+ "apihelp-query+logevents-param-start": "Et Dattom un de Zigg vun woh aff opjezallt wähde sull.",
+ "apihelp-query+logevents-param-end": "Dattum un Uhrzigg, bes wann opzälle.",
+ "apihelp-query+logevents-param-user": "Donn de Enndrähsch beschrängke ob di vun enem bechtemmpte Metmaacher.",
+ "apihelp-query+logevents-param-title": "Donn de Enndrähsch beschrängke ob di sesch obb_en beschtemmpte Sigg beträcke.",
+ "apihelp-query+logevents-param-namespace": "Donn de Enndrähsch beschrängke obb e besschtemmp Appachtemang.",
+ "apihelp-query+logevents-param-prefix": "Donn de Enndrähsch beschrängke ob di medd enem beschtemmpte Bejenn.",
+ "apihelp-query+logevents-param-tag": "Donn blohß Väsjohne met heh dä Makkehrong opleßte.",
+ "apihelp-query+logevents-param-limit": "Wi vill Enndrähsch enjesammp ußjävve?",
+ "apihelp-query+logevents-example-simple": "Donn de neußte Enndrähsch uß de Logböhscher opleßte.",
+ "apihelp-query+pagepropnames-summary": "Donn alle Nahme vun Eijeschaffte vun Sigge heh em Wikki opleßte.",
+ "apihelp-query+pagepropnames-param-limit": "De jrüüßte Zahl Nahme för ußzejävve.",
+ "apihelp-query+pagepropnames-example-simple": "Holl de eezde zehn Nahme vun Eijeschaffte.",
+ "apihelp-query+pageprops-summary": "Jitt devärse Eijeschafte uß, di em Ennhald vun dä Sigg faßjelaat wohde sen.",
+ "apihelp-query+pageprops-example-simple": "Holl de Eijeschaffte för di Sigge „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ un „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">MediaWiki</kbd>“.",
+ "apihelp-query+pageswithprop-summary": "Donn alle Sigge met bechtemmpte Sigge_Eijeschaff opleßte.",
+ "apihelp-query+pageswithprop-param-prop": "Wat för en Aanjahbe ennschlehße:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Deiht de Kännong vun de Sigge derbei.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Donn de Övverschrevv un de Kännong för di Sigg derbei.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Deiht der Wäät för de Eijeschaff vun dä Sigg derbei.",
+ "apihelp-query+pageswithprop-param-limit": "De jrüüßte Zahl Sigge för ußzejävve.",
+ "apihelp-query+pageswithprop-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+pageswithprop-example-generator": "Holl zohsäzlejje Aanjahbe övver de eezde zehn Sigge, woh <code>_&#95;NOTOC_&#95;</code> dren vörkütt.",
+ "apihelp-query+prefixsearch-summary": "Söhk nohm Aanfang vun dä Övverschreffte vun de Sigge.",
+ "apihelp-query+prefixsearch-param-search": "Noh wat söhke?",
+ "apihelp-query+prefixsearch-param-namespace": "En wällschem Appachtemang söhke.",
+ "apihelp-query+prefixsearch-param-limit": "De hühßte Aanzahl vun Äjeebnesse för zeröck ze jävve",
+ "apihelp-query+prefixsearch-param-offset": "De Aanzahl vun Äjeebnesse för ze övverjonn.",
+ "apihelp-query+prefixsearch-example-simple": "Söhk noh Övverschreffte vun Sigge, di met \n„<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">meaning</kbd>“ bejenne.",
+ "apihelp-query+protectedtitles-summary": "Donn alle Överschreffte vun Sigge opleßte, di verbodde sin, aanzelähje.",
+ "apihelp-query+protectedtitles-param-namespace": "Donn blohß Sigge en heh dä Appachtemangs opleßte.",
+ "apihelp-query+protectedtitles-param-level": "Donn blohß de Övverschreffte vun Sigge met heh dämm Nivoh vum Sigge_Schoz opeleßte.",
+ "apihelp-query+protectedtitles-param-limit": "Wi vill Sigge ensjesammp zem ußjävve?",
+ "apihelp-query+protectedtitles-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+protectedtitles-example-simple": "Donn jeschöz Övverschreffte opleßte.",
+ "apihelp-query+protectedtitles-example-generator": "Fengk Lengks op jeschözde Övverschreffte em Houp_Appachemang.",
+ "apihelp-query+querypage-param-page": "Dä {{int:specialpage}} iere Name. Opjepaß: De Jruhs- un Kleinschreff schpelld en Roll.",
+ "apihelp-query+querypage-param-limit": "De Aanzahl vun Äjeebnesse för zeröck ze jävve",
+ "apihelp-query+querypage-example-ancientpages": "Donn de Äjehbneße vun „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:Ancientpages]]</code>“ ußjävve.",
+ "apihelp-query+random-param-namespace": "Jiff blohß sigge en heh dä Appachtemangs uß.",
+ "apihelp-query+random-param-limit": "Wi vill zohfälleje Sigge sulle ußjejovve wähde?",
+ "apihelp-query+random-param-redirect": "Nemm <kbd>$1filterredir=redirects</kbd> schtatt dämm.",
+ "apihelp-query+random-param-filterredir": "Wi de Ömleijdonge ußzottehre?",
+ "apihelp-query+random-example-simple": "Donn zwai zohfälleje Sigge vum Houb_Appachtemang ußjävve.",
+ "apihelp-query+random-example-generator": "Donn Ennfommazjuhne övver zwai zohfälleje Sigge vum Houb_Appachtemang ußjävve.",
+ "apihelp-query+recentchanges-summary": "Donn de neußte Änderonge opleßte.",
+ "apihelp-query+recentchanges-param-start": "Et Dattom un de Zigg vun woh aff opjezallt wähde sull.",
+ "apihelp-query+recentchanges-param-end": "Dattum un Uhrzigg, bes wann opzälle.",
+ "apihelp-query+recentchanges-param-namespace": "Donn de Änderonge blohß us de aanjejovve Appachtemans nämme.",
+ "apihelp-query+recentchanges-param-user": "Donn blohß Änderonge vun heh däm Metmaacher opleßte.",
+ "apihelp-query+recentchanges-param-excludeuser": "Donn kein Änderonge vun heh däm Metmaacher opleßte.",
+ "apihelp-query+recentchanges-param-tag": "Donn blohß Änderonge met heh dä Makkehrong opleßte.",
+ "apihelp-query+recentchanges-param-prop": "Donn zohsäzlejje Aanjahbe ennschlehße:",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Deihd de Makkehronge vun dä Änderong derbei.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Deihd et Dattom un de Uhrzigg vun dä Änderong derbei.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Deihd de neuje Övverschreff noh dä Änderong derbei.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Donn de Makkehronge för dä Enndraach opleßte.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Donn de Pröhvsom för di Enndrähsch oplesßte, di met enne Väsjohn zesamme hange.",
+ "apihelp-query+recentchanges-param-token": "Nemm „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>“ schtatt dämm.",
+ "apihelp-query+recentchanges-param-limit": "Wi vill Änderonge ensjesammp zem aanzeije?",
+ "apihelp-query+recentchanges-param-type": "Wat för en Zoot Änneronge aanzeije?",
+ "apihelp-query+recentchanges-param-toponly": "Bloß Änderonge aanzeije, woh de neußte Väsjohn beij eruß kohm.",
+ "apihelp-query+recentchanges-param-generaterevisions": "Wann als ene Jenerahtor enjesaz, brängk dat Kännonge vun Väsjohne un kein Övverschreffte. Enndrähsch en de neußte Änderonge der ohne en Väsjohnskännong, alsu de miehste Logbohchenndrähsch, bränge jaa nix.",
+ "apihelp-query+recentchanges-example-simple": "Zeijsch de {{LCFIRST:{{int:recentchanges}}}}",
+ "apihelp-query+redirects-summary": "Jiff alle Ömleijdonge noh dä aanjejovve Sigge uß.",
+ "apihelp-query+redirects-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "De Sigge_Kännong för jehde Ömleijdong.",
+ "apihelp-query+redirects-paramvalue-prop-title": "De Övverschreff för jehde Ömleijdong.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "Der Fragmännd_Aandeijl för jehde Ömleijdong, wann eine doh es.",
+ "apihelp-query+redirects-param-namespace": "Donn blohß Sigge en heh dä Appachtemangs metnämme.",
+ "apihelp-query+redirects-param-limit": "Wi vell Ömeijdonge ußjävve?",
+ "apihelp-query+redirects-param-show": "Zeijsch blohß de Ömleijdonge:\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">fragment</code>:med enem Fragmännd_Aandeijl.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">!fragment</code>:der ohne ene Fragmännd_Aandeijl.",
+ "apihelp-query+redirects-example-simple": "Holl en Leß met Ömleijdonge, di op de Sigg „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Main Page]]</code>“ jonn.",
+ "apihelp-query+redirects-example-generator": "Holl Ennfommazjuhne övver alle Ömleijdonge op di Sigg „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Main Page]]</code>“.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Kam_mer blohß med en einzel Sigg bruche (mode #2)",
+ "apihelp-query+revisions-param-startid": "De Kännong vun dä Väsjohn vun woh aff opjezallt wähde sull.",
+ "apihelp-query+revisions-param-endid": "De Kännong vun dä Väsjohn bes woh hen opjezallt wähde sull.",
+ "apihelp-query+revisions-param-start": "Et Dattom un de Zigg vun dä Väsjohn vun woh aff opjezallt wähde sull.",
+ "apihelp-query+revisions-param-end": "Et Dattom un de Zigg bes woh hen opjezallt wähde sull.",
+ "apihelp-query+revisions-param-user": "Väsjohne vun däm Metmaache ennschlehße.",
+ "apihelp-query+revisions-param-excludeuser": "Väsjohne vun däm Metmaache ußschlehße.",
+ "apihelp-query+revisions-param-tag": "Donn blohß Väsjohne met heh dä Makkehrong opleßte.",
+ "apihelp-query+revisions-param-token": "Wat för en Makkehronge för jehde Väsjohn holle.",
+ "apihelp-query+revisions-example-content": "Holl Dahte med Ennhalld för de läzde Väsjohn vun Övverschreffte \n„<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">API</kbd>“ un \n„<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“.",
+ "apihelp-query+revisions-example-last5": "Holl de läzde fönnef Väsjohne vun de „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“.",
+ "apihelp-query+revisions-example-first5": "Holl de eezde fönnef Väsjohne vun de „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“.",
+ "apihelp-query+revisions-example-first5-after": "Holl de eezde fönnef Väsjohne vun de „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“, di noh em eezde Mai em Johr 2006 änschtannde sin.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Holl de ehzde Väsjohne vun de „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ di nit vun dämm nahmelohse Metmaacher „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">127.0.0.1</kbd>“ jemaht wohde.",
+ "apihelp-query+revisions-example-first5-user": "Holl de eezde fönnef Väsjohne vun de „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“, di vum Metmaacher „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">MediaWiki default</kbd>“ aanjelahd wohde.",
+ "apihelp-query+revisions+base-param-prop": "Wat för en Eijeschaffte vun dä Väsjohn holle.",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "Dw Kännong vu dä Väsjohn.",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "Dattom un Zigg vun dä Väsjohn.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Dä Metmaacher, dä di Väsjohn jemaat hät.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "Däm Metmaacher sing Kännong, dä di Väsjohn aanjelaat hät.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Der Ömvang en <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Bytes</i> vun dä Väsjohn.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "De <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"secure hash algorithm\">SHA-1 (base 16)</i> Prööfsomm vun dä Väsjohn.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "De Aanmärkong vum Metmaacher för di Väsjohn.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "De jepaaste Aanmärkong vum Metmaacher för di Väsjohn.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Der Täx vun dä Väsjohn.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Makkehronge vun dä Väsjohn.",
+ "apihelp-query+revisions+base-param-limit": "Wi vill Väsjohne sulle ußjejovve wähde?",
+ "apihelp-query+revisions+base-param-section": "Holl blohß der Ennhald vun däm Affschnett met heh dä Nommer.",
+ "apihelp-query+revisions+base-param-difftotextpst": "Donn dä Täx ömsäze wi vör em Affseschere, ih de Ongerscheijde erus jefonge wähde. Jeihd blohß mem Parramehter <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1difftotext</var> zesamme.",
+ "apihelp-query+search-summary": "Söhk em jannze Täx.",
+ "apihelp-query+search-param-search": "Söhk noh alle Övverschreffte vun Sigge udder Ennhallde, woh dä Wäät drop paß. Mer kann heh met besönder Aufjahbe beim Söhke schtälle, jeh nohdämm wadd_em Wikki sing Projramm för et Söhke esu alles kann.",
+ "apihelp-query+search-param-namespace": "Söhk blohß en heh dä Appachtemangs.",
+ "apihelp-query+search-param-what": "Wat för en Aat ze Söhke?",
+ "apihelp-query+search-param-info": "Wat för en Metta_Dahte ußzejävve.",
+ "apihelp-query+search-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Deiht de Aanzahl Wööter en dä Sigg derbeij.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Deihd et Dattum un de Uhrzigg derbei, wann di Sigg et läz veränndert wohd.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Deiht dä zopaß Ömleijdong ehr Övverschreff derbei.",
+ "apihelp-query+search-param-limit": "Wi vill Sigge ensjesamp ußjävve?",
+ "apihelp-query+search-param-interwiki": "Donn de Engerwiki Lengks met ußjävve beim Söhke, wann_er doh sin.",
+ "apihelp-query+search-example-simple": "Söhk noh „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">meaning</kbd>“.",
+ "apihelp-query+search-example-text": "Söhk en Täxte noh „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">meaning</kbd>“.",
+ "apihelp-query+search-example-generator": "Holl anjahbe övver di Sigge, di jefonge wähde beim söhke noh \n„<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">meaning</kbd>“",
+ "apihelp-query+siteinfo-summary": "Jiff alljemeine Ennfommazjuhne övver heh di ẞaid_uß.",
+ "apihelp-query+siteinfo-param-prop": "Wat för en Ennfommazjuhne holle:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Alljemeine Aanjabe zom Süßtehm.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Jivv Schtatistike vum Wikki uß.",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Jid en Leß med de verföhschbahre Kännonge us.",
+ "apihelp-query+siteinfo-param-filteriw": "Donn blohß de Enndrähsch för heh et Wikki udder blohß de Enndrähsch för ußerhallef en di Leß.",
+ "apihelp-query+siteinfo-param-showalldb": "Donn alle ẞööver för de Dahtebangke opleßte, nit blohß di am mihßte hengerher sin.",
+ "apihelp-query+siteinfo-param-numberingroup": "Donn de Aanzahl Metmaacher en de Jroppe vun Metmaacher opleßte.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "Schprohche_Köözelle för de Namhme vun Schprohche en heh dä Schprohch — der bäßte Träffer zällt - un de Namhe vun de Bedehnbovverfläsche.",
+ "apihelp-query+siteinfo-example-simple": "Holl Ennfommazjuhe övver heh di ẞait.",
+ "apihelp-query+siteinfo-example-interwiki": "Holl en Leß met de Vörsäz för de Engerwiki_Lenks em eije Wikki.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Es et sällve wi „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1filekey</code>“ un kütt vun fröjere Väsjohne.",
+ "apihelp-query+tags-summary": "Leß de Makehronge vun Änderonge.",
+ "apihelp-query+tags-param-limit": "De hühßde Zahl Makkehronge zom Opleste.",
+ "apihelp-query+tags-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+tags-paramvalue-prop-name": "Deiht dä Nahme vun dä Makkehrong derbei.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Deiht der Täx vum Wikki för de Makkehrong derbei.",
+ "apihelp-query+tags-paramvalue-prop-description": "Deiht dä Beschrievongstäx vun dä Makkehrong derbei.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Deiht de Aanzahl vun Väsjohne un Enndrähsch em Logbohch derbei, di di Makkehrong han.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Jivv aan, ov di Makkehrong övverhoup doh es.",
+ "apihelp-query+tags-paramvalue-prop-source": "Hollt de Kwälle vun de Makkehrong, dat kann ömfaße: „<samp lang=\"en\" xml:lang=\"en\" dir=\"ltr\">extension</samp>“ för Makkehronge, di vun Zohsazprojramme faßjelaat wähde, un „<samp lang=\"en\" xml:lang=\"en\" dir=\"ltr\">manual</samp>“ för Makkehronge, di vun de Metmaacher vun Hand verjovve wohde.",
+ "apihelp-query+tags-paramvalue-prop-active": "Ov de Makkehrong emmer noch aktihv es.",
+ "apihelp-query+tags-example-simple": "Leß de verföhschbahre Makkehronge op.",
+ "apihelp-query+templates-summary": "Jidd alle Datteije uß, di en dä aanjejovve Sigge enjebonge sin.",
+ "apihelp-query+templates-param-namespace": "Zeijsch blohß de Schablohne en heh däm Appachtemang.",
+ "apihelp-query+templates-param-limit": "Wi vill Schablohne sulle ußjejovve wähde?",
+ "apihelp-query+templates-param-templates": "Donn blohß heh die Schablohne opleßte. Johd ze bruche zom Pröhve, ov en beschtemmpte Sigg en beschtemmpte Schlohn bruche deiht.",
+ "apihelp-query+templates-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+templates-example-simple": "Holl di Schablohne, di en dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ jebruch wähde.",
+ "apihelp-query+templates-example-generator": "Holl Ennfommazjuhneövver di Sigge met di Schablohne, di en dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ jebruch wähde.",
+ "apihelp-query+templates-example-namespaces": "Holl Sigge uß de {{ns:user}} un {{ns:template}} Appachtemangs, di en di Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ enjeschloße wähde.",
+ "apihelp-query+transcludedin-summary": "Fengk alle Sigge, di di aanjejovve Sigge enneschlehße.",
+ "apihelp-query+transcludedin-param-prop": "Wat för en Eijeschaffte holle:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "De Kännong för jehde Sigg.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "De Övverschreff för jehde Sigg.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Zeijsch aan, wann di Sigg en Ömleijdong es.",
+ "apihelp-query+transcludedin-param-namespace": "Donn blohß Sigge en heh dä Appachtemangs ennschlehße.",
+ "apihelp-query+transcludedin-param-limit": "Wi vill ußjävve.",
+ "apihelp-query+transcludedin-example-simple": "Holl en Leß met Sigge, di en dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ ennjeschloße wähde.",
+ "apihelp-query+transcludedin-example-generator": "Holl Ennfommazjuhne övver Sigge, di vun dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ ohjerohfe wähde.",
+ "apihelp-query+usercontribs-summary": "Holl alle Änderonge vun enem Metmaacher.",
+ "apihelp-query+usercontribs-param-limit": "De hühßte Aanzahl vun Meddeilonge för zeröck ze jävve",
+ "apihelp-query+usercontribs-param-start": "Dattom un Zigg vun woh aan ußjävve.",
+ "apihelp-query+usercontribs-param-end": "Dattom un Zigg bes woh hen ußjävve.",
+ "apihelp-query+usercontribs-param-user": "De Metmaacher för di mer Beijdrähsch holle welle.",
+ "apihelp-query+usercontribs-param-userprefix": "Holl beijdrähsch för alle Metmaacher, dänne ier Nahme met heh däm Wääd aanfange. Övverschriehv „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1user</code>“.",
+ "apihelp-query+usercontribs-param-namespace": "Donn blohß Beijdrähsch en heh dä Appachtemangs opleßte.",
+ "apihelp-query+usercontribs-param-prop": "Donn zohsäzlejje Aanjahbe ennschlehße:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Donn de Kännong för jehde Sigg un jehe Väsjohn derbei.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Donn de Övverschrevv un de Kännong för et Appachtemang derbei.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Deihd et Dattom un de Uhrzigg vun dä Änderong derbei.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Deihd de Zosammefaßong vun dä Änderong derbei.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Deihd de jepaaste Zosammefaßong vun dä Änderong derbei.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Deihd de neuje Jrühße noh dä Änderong derbei.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Deihd de Änderong vun dä Jrühße vun dä Änderong derbei.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Deihd de Makkehronge vun dä Änderong derbei.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Nohjelohrte Änderonge makkehre.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Donn de Makkehronge vun dä Änderong opleßte.",
+ "apihelp-query+usercontribs-param-tag": "Donn blohß Väsjohne met heh dä Makehrong opleßte.",
+ "apihelp-query+usercontribs-param-toponly": "Bloß Änderonge aanzeije, woh de neußte Väsjohn beij eruß kohm.",
+ "apihelp-query+usercontribs-example-user": "Zeijsch dem Metmaacher „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Example</kbd>“ sing Beijdrähsch.",
+ "apihelp-query+usercontribs-example-ipprefix": "Zeijsch de Beijdrähsch vun alle <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Internet Protocol\">IP</i>-Adräße, di met „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">192.0.2.</kbd>“ bejenne.",
+ "apihelp-query+userinfo-summary": "Holl Aanjahbe övver dä aktoälle Metmaacher.",
+ "apihelp-query+userinfo-param-prop": "Wat för en Aanjahbe med enzschlehße:",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Donn alle Jroppe opleßte, woh dä heh Metmaacher dren es.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Donn alle Jroppe opleßte, woh dä heh Metmaacher aotomattesch dren es.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Donn alle Rääschte opleßte, di dä Metmaacher hät.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Donn alle Jroppe opleßte, woh dä heh Metmaacher eine bei donn udder eruß nämme kann.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Donn alle Enschtällonge opleßte, di dä heh Metmaacher jesaz hät.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Donn heh däm Metmaacher sing Aanzahl Ännderonge derbeij.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Donn alle Mängebeschrängkonge opleßte, di heh dä Metmaacer hät.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Deiht däm Metmaacher singe reeschtejje Nahme derbei.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Donn de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mail</i>-Adräß vun heh däm Metmaacer opleßte, un et Dattom, wann di et läz beschtähtesch woode es.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Donn et Dattom vun dämm Metmaacher singe eetze Aanmäldong derbei.",
+ "apihelp-query+userinfo-example-simple": "Holl Aanjahbe övver dä aktoälle Metmaacher.",
+ "apihelp-query+userinfo-example-data": "Holl zohsäzlejje Aanjahbe övver dä aktoälle Metmaacher.",
+ "apihelp-query+users-summary": "Holl Aanjahbe övver en Leß vun Metmaacher.",
+ "apihelp-query+users-param-prop": "Wat för en Aanjahbe med enzschlehße:",
+ "apihelp-query+users-paramvalue-prop-groups": "Donn alle Jroppe opleßte, woh all de Metmaacher dren sin.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Donn alle Jroppe opleßte, woh ene Metmaacher aotomattesch dren es.",
+ "apihelp-query+users-paramvalue-prop-rights": "Donn alle Rääschte opleßte, di alle Metmaacher han.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Donn däm Metmaacher sing Aanzahl Ännderonge derbeij.",
+ "apihelp-query+users-paramvalue-prop-registration": "Donn et Dattom vun dämm Metmaacher singe eetze Aanmäldong derbei.",
+ "apihelp-query+users-param-users": "En Leß vun Metmaacher för Aanjahbe drövver ze holle.",
+ "apihelp-query+users-param-token": "Nemm „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>“ schtatt dämm.",
+ "apihelp-query+users-example-simple": "Holl Aanjahbe för dä Metmaacher <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Example</kbd>.",
+ "apihelp-query+watchlist-param-start": "Et Dattom un de Zigg vun woh aff opjezallt wähde sull.",
+ "apihelp-query+watchlist-param-end": "Et Dattum un Uhrzigg, bes wann opzälle.",
+ "apihelp-query+watchlist-param-namespace": "Donn de Änderonge blohß us de aanjejovve Appachtemans nämme.",
+ "apihelp-query+watchlist-param-user": "Donn blohß Änderonge vun heh däm Metmaacher opleßte.",
+ "apihelp-query+watchlist-param-excludeuser": "Donn kein Änderonge vun heh däm Metmaacher opleßte.",
+ "apihelp-query+watchlist-param-limit": "Wi vell Äjehbneße ennsjesammp pro Oprohv ußjejovve wähde sulle.",
+ "apihelp-query+watchlist-param-prop": "Wat för en zohsäzlejje Eijeschaffte holle:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Donn de Kännong vun de Väsohne un de Sigge derbei,",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Mähd en Övverschhreff övver di Sigg.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Deihd de Makkehronge vun dä Änderong derbei.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Deiht dä Metmaacher derbei, dä di Änderong jemaat hät.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Deiht de kännong vn äm Metmaacher derbei, dä di Änderong jemaat hät.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Deihd de Zosammefaßong vun dä Änderong derbei.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Deihd de jepaaste Zosammefaßong vun dä Änderong derbei.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Deihd et Dattom un de Uhrzigg vun dä Änderong derbei.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Makkehrt de nohjelohrte Ännderonge.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Deiht de vörrijje un de neuje Läng vun dä Sigg derbei.",
+ "apihelp-query+watchlist-param-type": "Wat för en Änderonge aanzeije:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Jewöhnlejje Ännderonge aan Sigge.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Änderonge vun Ußerhallef.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Neu aanjelaate Sigge.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Enndrähsch em Logbohch",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Änderonge aan de Zohjehüreshkeit zoh Saachjroppe.",
+ "apihelp-query+watchlistraw-summary": "Donn alle Sigge uß dem aktälle Metmaacher sing Oppaßleß holle.",
+ "apihelp-query+watchlistraw-param-namespace": "Donn blohß Sigge en heh däm Appachtemang opleßte.",
+ "apihelp-query+watchlistraw-param-limit": "Wi vell Äjehbneße ennsjesammp pro Oprohv ußjejovve wähde sulle.",
+ "apihelp-query+watchlistraw-param-prop": "Wat för en zohsäzlejje Eijeschaffte holle:",
+ "apihelp-query+watchlistraw-param-dir": "En wälsche Reihjefollsch opleßte.",
+ "apihelp-query+watchlistraw-example-simple": "Donn alle Sigge uß dem aktälle Metmaacher sing Oppaßleß opleßte.",
+ "apihelp-removeauthenticationdata-example-simple": "Versöhk dem aktoäle Metmaacher sing Dahte för <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">FooAuthenticationRequest</kbd> fott ze nämme.",
+ "apihelp-resetpassword-example-email": "Schegg en <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mail</i> mem Passwod neu säze aan alle Matmaacher met dä Addräß <kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">user@example.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Versione fottschmieße un widder zeröck holle.",
+ "apihelp-revisiondelete-param-hide": "Wat för jehde Väsjohn ze veschteijsche.",
+ "apihelp-revisiondelete-param-show": "Wat för jehde Väsjohn zerökzeholle.",
+ "apihelp-revisiondelete-param-suppress": "Ov dat och för de Wiki-Köbesse verschtoche wähde sull, wie för jede Andere.",
+ "apihelp-rollback-param-title": "De Övverschreff vun dä Sigg för di_j_en vörrejje Väsjohn zeröckzeholle es. Kam_mer nit zersamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1pageid</var>“ bruche.",
+ "apihelp-rollback-param-pageid": "De Kännong vun dä Sigg för di_j_en vörrejje Väsjohn zeröckzeholle es. Kam_mer nit zersamme met „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1title</var>“ bruche.",
+ "apihelp-rollback-param-watchlist": "Donn di Sigg op däm aktoälle Metmaacher sing Oppaßleß udder nemm se druß fott, donn de Enschtällonge nämme, udder donn de Oppaßleß jaa nit verändere.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Donn alle Sigge beärbeide, di en Oppaßleßte dren sin.",
+ "apihelp-stashedit-param-title": "De Övverschreff vu dä Sigg för zom Änndere.",
+ "apihelp-stashedit-param-section": "Däm Affschnett sing Nommer. „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr>0</kbd>“ brängk der eezde Affschnett, dä keijn Övverschreff hät, „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr>new</kbd>“ brängg_ene neuje Affschnett.",
+ "apihelp-stashedit-param-sectiontitle": "De Övverschreff för ene neuje Afschnett",
+ "apihelp-stashedit-param-text": "Dä Sigg ehre Ennhalld.",
+ "apihelp-stashedit-param-contentmodel": "Et Enhalltsmodäll för dä neue Ennhalld.",
+ "apihelp-stashedit-param-summary": "Zosammefaßong änndere",
+ "apihelp-tag-summary": "Donn Makkehronge vun einzel Väsjohne udder Enndraähsch em Logbohch fott nämme udder se verjävve.",
+ "apihelp-tag-param-rcid": "Ein udder mih Kännonge uß de neuste Ännderonge, woh di Makkehrong derbei jedonn udder fott jenumme wähde sull.",
+ "apihelp-tag-param-revid": "Ein Kännong udder mih, woh di Makkehrong derbei jedonn udder fott jenumme wähde sull.",
+ "apihelp-tag-param-logid": "Ein Kännong udder mih uß de neuste Änderonge, woh di Makkehrong derbei jedonn udder fott jenumme wähde sull.",
+ "apihelp-tag-param-add": "De Makkehrong zom Zohföhje. Bloß de vun Hand aanjelaat Makkehronge künne heh zohjeföhsch wähde.",
+ "apihelp-tag-param-remove": "Makkehronge zom fott nämme. Blohß vun Hand jesaz un kumplätt onjesaz Makkehronge künne fott jenumme wähde.",
+ "apihelp-tag-param-reason": "Dä Jrond för di Änderong.",
+ "apihelp-tag-example-rev": "Donn de Makkehrong „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">vandalism</kbd>“ vun dä Väsjohn met dä Kännong „<kbd>123</kbd>“ fott nämme, der ohne ene Jrond ze nänne.",
+ "apihelp-tag-example-log": "Donn de Makkehrong „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">spam</kbd>“ vun dämm Enndrahch met dä Kännong „<kbd>123</kbd>“ em Logbohch fott nämme un als Jrond draaach „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Wrongly applied</kbd>“ enn.",
+ "apihelp-unblock-summary": "Don en Schpärr för ene Metmaacher ophävve.",
+ "apihelp-unblock-param-reason": "Der Jrond för de Schpärr opzehävve.",
+ "apihelp-unblock-param-tags": "Donn de Makehronge änndere, di för dä Enndraach em Logbohch vum Schpärre jesaz wähde sulle.",
+ "apihelp-undelete-param-title": "De Övverschreff vun dä Sigg zom zerök holle.",
+ "apihelp-undelete-param-reason": "Der Jrond för et Zerök holle.",
+ "apihelp-undelete-param-tags": "Donn de Makehronge aanpaße, dat se för dä Enndraach em Logbohch vum Sigge fott Schmihße jehühre.",
+ "apihelp-undelete-param-watchlist": "Donn di Sigg ohne Bedengonge op däm aktoälle Metmaacher sing Oppaßleß udder nemm se druß fott, donn de Enschtällonge nämme, udder donn de Oppaßleß jaa nit verändere.",
+ "apihelp-undelete-example-page": "Schmiiß de Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ fott.",
+ "apihelp-undelete-example-revisions": "Holl zwai Väsjohne vun dä Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ zerök.",
+ "apihelp-upload-summary": "Donn en Dattei huh lahde, udder holl der Zohschtand vun de onfähdesch huhjelahde Datteije .",
+ "apihelp-upload-extended-description": "Et jitt ongerscheidlejje Metohde:\n* Donn de Ennhallde vun de Datteije tiräk huhlahde, övver der Parramehter „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1file</var>“.\n* Donn de Datteije en en Aanzahl Rötsche huhlahde, övver de Parramehter „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1filesize</var>“, „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1chunk</var>“, un „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1offset</var>“.\n* Lohß der ẞööver vum Wikki en Dattei vun enem <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> holle, övver de Parramehter „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1url</var>“.\n* Lohß en Dattei fähdesch huhlahde, di zeläz nit fähdesch wohd, un met Warnonge schtonn jeblevve es övver de Parramehter „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1filekey</var>“.\nOpjepaß: dä „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">POST</code>“-Befähl vum <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Transfer Protocol\">HTTP</i> moß als e Dattei-Huhlahde aanjeschtüßße wähde, allsu met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">multipart/form-data</code>“, wam_mer dä Parramehter „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1file</var>“ scheck.",
+ "apihelp-upload-param-filename": "Zihl-Dateiname.",
+ "apihelp-upload-param-text": "Der aanfänglesche Täx op Sigge för neu aanjelahte Datteije.",
+ "apihelp-upload-param-watch": "Op di Sigg heh oppaßße.",
+ "apihelp-upload-param-watchlist": "Donn di Sigg op däm aktoälle Metmaacher sing Oppaßleß udder nemm se druß fott, donn de Enschtällonge nämme, udder donn de Oppaßleß jaa nit verändere.",
+ "apihelp-upload-param-ignorewarnings": "Donn alle Warnonge övverjonn.",
+ "apihelp-upload-param-file": "Dä Dattei ier Enhallde.",
+ "apihelp-upload-param-url": "Der <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i>, öm di Dattei dervun ze holle.",
+ "apihelp-upload-param-sessionkey": "Et sälve wi „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1filekey</code>“, wat mer emmer noch noch bruche kann, weil mer et fröhjer alld ens esu hatte.",
+ "apihelp-upload-param-filesize": "De Datteijrühße vum jannze Huhlahde.",
+ "apihelp-upload-example-url": "Vun enem <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Uniform Ressource Locator\">URL</i> huhlahde.",
+ "apihelp-upload-example-filekey": "Don et Huhlahde fähdesch maace, wat wähje Warnonge nit johd jejange wohr.",
+ "apihelp-userrights-param-user": "Metmaacher_Nahme.",
+ "apihelp-userrights-param-userid": "Enem Metmaacher sing Kännong.",
+ "apihelp-userrights-param-add": "Donn dä Metmaacher en heh di Jroppe eren.",
+ "apihelp-userrights-param-remove": "Donn dä Metmaacher us heh dä Jroppe eruß nämme.",
+ "apihelp-userrights-param-reason": "Dä Jrond för di Änderong.",
+ "apihelp-watch-summary": "Donn di Sigg en däm aktoälle Metmaacher singe Oppaßless eren udder schmihß se erus.",
+ "apihelp-watch-example-watch": "Don di Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ en de Oppaßleß.",
+ "apihelp-watch-example-unwatch": "Schmiiß di Sigg „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Main Page</kbd>“ uß dä Oppaßleß erus.",
+ "apihelp-watch-example-generator": "Donn op de eezte paa Sigge em Schtanndadd_Appachtemang oppaße.",
+ "apihelp-format-example-generic": "Jiff wadd_erus kohm em Fommaht $1 us.",
+ "apihelp-json-summary": "Donn de Dahte em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Extensible Markup Language\">XML</i>-Fommahd ußjävve.",
+ "apihelp-json-param-ascii": "Wann aanjejovve, deiht alle nit-<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"American Standard Code for Information Interchange\">ASCII</i>-Zeijsche met hexadezimahle !escape-Sequänze koddehre. Dadd es der Schtandatt, wann „<var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">formatversion</var>“ <kbd>1</kbd> es.",
+ "apihelp-jsonfm-summary": "Dahte em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"JavaScript Object Notation\">JSON</i>-Fommaht ußjävve un för schöhn en et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i> wandele.",
+ "apihelp-none-summary": "Donn nix ußjävve.",
+ "apihelp-php-summary": "Dahte em hengernader jeschrevve <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"PHP Hypertext Preprocessor\">PHP</i>-Fommaht ußjävve.",
+ "apihelp-phpfm-summary": "Dahte em hengernannder jeschrevve <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"PHP Hypertext Preprocessor\">PHP</i>-Fommaht ußjävve un för schöhn en et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i> wandele.",
+ "apihelp-rawfm-summary": "Dahte, met de Aandeijle för et Fählersöhke, em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"JavaScript Object Notation\">JSON</i>-Fommaht ußjävve un för schöhn en et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i> wandele.",
+ "apihelp-xml-summary": "Donn de Dahte em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Extensible Markup Language\">XML</i>-Fommahd ußjävve.",
+ "apihelp-xml-param-includexmlnamespace": "Wann aanjejovve, deihd en <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Extensible Markup Language\">XML</i>-Appachtemand derbei.",
+ "apihelp-xmlfm-summary": "Donn de Dahte em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Extensible Markup Language\">XML</i>-Fommahd schöhn jemaht met <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i> ußjävve.",
+ "api-format-title": "Wat et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> ußjohv.",
+ "api-format-prettyprint-header-only-html": "Dat heh es en <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"HyperText Markup Language\">HTML</i>_Daaschtällong un för et Fähersöhke jedaach. Dadd is för Aanwändongsprojramme nit ze bruche.\n\nEn de [[mw:Special:MyLanguage/API|complete Dokkemäntazjohn]] un de [[Special:ApiHelp/main|API Hölp_Sigg]] kam_mer doh mih drövver lässe.",
+ "api-pageset-param-titles": "En Leß vun Övverschreffte för ze beärbeide.",
+ "api-pageset-param-pageids": "En Leß vun Kännonge vun Sigge för ze beärbeide.",
+ "api-pageset-param-revids": "En Leß vun Kännonge vun Väsjohne för ze beärbeide.",
+ "api-help-title": "Hölp för de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> vum MehdijaWikki.",
+ "api-help-main-header": "Houp_Moduhl",
+ "api-help-flag-deprecated": "Dat Moduhl es nimmih johd jeligge.",
+ "api-help-flag-readrights": "Heh da Modhul bruch et Rääsch zum Lässe.",
+ "api-help-flag-writerights": "Heh da Modhul bruch et Rääsch zom Schriive.",
+ "api-help-flag-mustbeposted": "Heh dat Modhul nemmp blohß <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">POST</code>-Opdrähschd aan.",
+ "api-help-flag-generator": "Heh dat Modhul kam_mer als ene Jenerahtor ennsäze.",
+ "api-help-source": "Quäll: $1",
+ "api-help-source-unknown": "Quäll: <span class=\"apihelp-unknown\">onbikannt</span>",
+ "api-help-license": "Lezänz: [[$1|$2]]",
+ "api-help-license-noname": "Lezänz: [[$1|Loor noh dämm Lengk]]",
+ "api-help-license-unknown": "Lezänz: <span class=\"apihelp-unknown\">onbikannt</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parramehter|Parramehtere|Parramehter}}:",
+ "api-help-param-deprecated": "Meßjevällesch.",
+ "api-help-param-required": "Heh dä Parramehter es nühdesch.",
+ "api-help-datatypes-header": "Zoote Dahte",
+ "api-help-param-type-limit": "Zoot: en jannze Zahl udder „<kbd lang=\"en\" xml:lang=\"en\" dir=\"ltr\">max</kbd>“",
+ "api-help-param-type-integer": "Zoot: {{PLURAL:$1|1=en jannze Zahl|2=en Leß met jannze Zahle}}",
+ "api-help-param-type-boolean": "Zoot: Boolsch ([[Special:ApiHelp/main#main/datatypes|Einjzelheijte]])",
+ "api-help-param-type-timestamp": "Zoot: {{PLURAL:$1|1=en Dattomm un en Zigg|2=en Leß met Aanjahbe us Dattom un Zigg}} (de [[Special:ApiHelp/main#main/datatypes|zohjelohße Fommahte]])",
+ "api-help-param-type-user": "Zoot: {{PLURAL:$1|1=ene Metmaacher_Nahme|2=en Leß met Metmaacher_Nahme}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Eijne Wäät|2=Wääte met <kbd>{{!}}</kbd> derzwesche}} vun dänne heh: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Moß läddesch sin|Kann läddesch sin, udder $2}}",
+ "api-help-param-limit": "Nit mih wi $1 sin zohjelohße.",
+ "api-help-param-limit2": "Nit mih wi $1 sin zohjelohße, ävver $2 för de Bots.",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=Dä Wäät darref|2=De Wääte dörrve}} nit kleijener wi $2 sin.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Dä Wäät darref|2=De Wääte dörrve}} nit jrühßer wi $3 sin.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Dä Wäät moß|2=De Wääte möße}} nit zwesche $2 un $3 lijje.",
+ "api-help-param-upload": "Moß als Datteij huhjelahde wähde met dä Eijeschaff „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">multipart/form-data</code>“.",
+ "api-help-param-multi-separate": "Donn de Wääte met <kbd>|</kbd> derzwesche tränne.",
+ "api-help-param-multi-max": "De jrühßte müjjelesche Zahl es {{PLURAL:$1|$1}}, un {{PLURAL:$2|$2}} för Botprojramme.",
+ "api-help-param-default": "Schtandatt: $1",
+ "api-help-param-default-empty": "Schtandatt: <span class=\"apihelp-empty\">(läddesch)</span>",
+ "api-help-param-disabled-in-miser-mode": "Dadd es wäje em [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]] affjeschalldt.",
+ "api-help-param-limited-in-miser-mode": "<strong>Opjepaß:</strong> Weil der [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]] enjeschalld es, künne heh winnijer wi <var lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1limit</var> Äjehpneße ußjejejovve wähde, vör em Wigger_Mache. En Jränzfäll künne et Noll sin.",
+ "api-help-param-direction": "En wälsche Reihjefollsch opleßte:\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">newer</code>:De Ählsde et eez. Opjepaß: „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1start</code>“ moß fröhjer sin wi „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1end</code>“.\n;<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">older</code>:De Neuste et eez, der Schtanndatt. Opjepaß: „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1start</code>“ moß schpääder sin wi „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">$1end</code>“.",
+ "api-help-param-continue": "Wann mih ze holle es, nemm dat för wigger ze maache.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(nix drövver bikannt)</span>",
+ "api-help-examples": "{{PLURAL:$1|Beijschpell|Beijschpelle|Beijschpell}}:",
+ "api-help-permissions": "{{PLURAL:$1|Rääsch|Rääschde|Rääsch}}:",
+ "api-help-permissions-granted-to": "Jejovve aan: $2{{PLURAL:$1|}}",
+ "api-help-right-apihighlimits": "Donn de Beschängkonge vun Opdrähscht aan de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> kleiner maache (langsamme Opdrähscht: $1; flöcke Opdrähscht: $2). De Beschränkonge för lahme Opdrähscht jällde och för Parramehtere met vill Wähte.",
+ "api-help-open-in-apisandbox": "<small>[en de Sandkeß opmaache]</small>",
+ "apierror-timeout": "Dä ẞööver hät en dä jewennde Zick nit jeantwoot.",
+ "api-credits-header": "Aanäkännong för Beijdrähsch",
+ "api-credits": "Dä <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Application Programming Interface\">API</i> ier Äntweklere:\n* Roan Kattouw (Aanföhrer zigg em Säptämber 2007 bes 2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (Bejenner un Aanföhrer vum Säptämber 2006 bes Säptämber 2007)\n* Brad Jorsch (Aanföhrer vun 2013 bes hük)\n\nDoht Ühr Aanmärkonge, Vörschlähsch un Frohre aan de Meijlengleß <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">mediawiki-api@lists.wikimedia.org</code> scheke, Ühr Vörschlähsch un Fählermälldong doht op <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">https://phabricator.wikimedia.org/</code> ennjävve."
+}
diff --git a/www/wiki/includes/api/i18n/ku-latn.json b/www/wiki/includes/api/i18n/ku-latn.json
new file mode 100644
index 00000000..3b477384
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ku-latn.json
@@ -0,0 +1,44 @@
+{
+ "@metadata": {
+ "authors": [
+ "George Animal",
+ "Macofe",
+ "Ghybu"
+ ]
+ },
+ "apihelp-block-summary": "Bikarhênerekî asteng bike.",
+ "apihelp-block-param-reason": "Sedemê bo astengkirinê.",
+ "apihelp-createaccount-param-name": "Navê bikarhêner.",
+ "apihelp-delete-summary": "Rûpelekê jê bibe.",
+ "apihelp-delete-example-simple": "<kbd>Main Page</kbd>ê jê bibe.",
+ "apihelp-edit-summary": "Rûpelan çêke û biguherîne.",
+ "apihelp-edit-param-sectiontitle": "Sernavê bo beşeke nû.",
+ "apihelp-edit-param-text": "Naveroka rûpelê.",
+ "apihelp-edit-param-minor": "Guhertina biçûk.",
+ "apihelp-edit-param-createonly": "Heke ku rûpel hebe wê neguherîne.",
+ "apihelp-edit-example-edit": "Rûpelekê biguherîne.",
+ "apihelp-emailuser-summary": "Ji bikarhêner re e-nameyekê bişîne.",
+ "apihelp-emailuser-param-target": "Bikarhênerê ku e-name jê rê bê şandin.",
+ "apihelp-expandtemplates-param-title": "Sernavê rûpelê.",
+ "apihelp-feedcontributions-param-deletedonly": "Tenê beşdariyên jêbirî nîşan bide.",
+ "apihelp-feedcontributions-param-hideminor": "Guherandinên biçûk veşêre.",
+ "apihelp-feedrecentchanges-param-hideminor": "Guherandinên biçûk veşêre.",
+ "apihelp-feedrecentchanges-param-hidebots": "Guherandinên botan veşêre.",
+ "apihelp-feedrecentchanges-example-simple": "Guherandinên dawî nîşan bide.",
+ "apihelp-feedrecentchanges-example-30days": "Guherandinên dawî yên 30 rojan nîşan bide",
+ "apihelp-filerevert-param-comment": "Şîroveyê bar bike.",
+ "apihelp-help-example-recursive": "Hemû alîkarî di rûpelekê de.",
+ "apihelp-login-param-name": "Navê bikarhêner.",
+ "apihelp-login-param-password": "Şîfre.",
+ "apihelp-login-example-login": "Têkeve.",
+ "apihelp-move-param-reason": "Sedemê bo guherandina nav.",
+ "apihelp-move-param-ignorewarnings": "Guh nede hişyariyan.",
+ "apihelp-opensearch-example-te": "Rûpelên ku bi <kbd>Te</kbd> dest pê dikin bibîne.",
+ "apihelp-parse-example-page": "Rûpelekê analîz bike.",
+ "apihelp-parse-example-summary": "Kurteyekê analîz bike",
+ "apihelp-protect-summary": "Asta parastinê ya rûpelekê biguherîne.",
+ "apihelp-protect-example-protect": "Rûpelekê biparêze.",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Sernavê girêdanê lê zêde dike.",
+ "apihelp-tag-param-reason": "Sedemê bo guherandinê.",
+ "api-help-parameters": "{{PLURAL:$1|Parametre}}:"
+}
diff --git a/www/wiki/includes/api/i18n/ky.json b/www/wiki/includes/api/i18n/ky.json
new file mode 100644
index 00000000..2f837aa9
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ky.json
@@ -0,0 +1,20 @@
+{
+ "@metadata": {
+ "authors": [
+ "Janatkg",
+ "Macofe"
+ ]
+ },
+ "apihelp-block-summary": "Колдонуучуну бөгөттөө",
+ "apihelp-block-param-reason": "Бөгөттөө себеби.",
+ "apihelp-block-example-ip-simple": " <kbd>192.0.2.5</kbd> IP дарегин үч күнгө <kbd>First strike</kbd> себеби менен бөгөттөө.",
+ "apihelp-checktoken-param-token": "Текшерүү белгиси.",
+ "apihelp-createaccount-param-name": "Колдонуучунун аты:",
+ "apihelp-createaccount-param-email": "Колдонуучунун email дареги (милдеттүү эмес)",
+ "apihelp-createaccount-param-realname": "Колдонуучунун чыныгы аты (милдеттүү эмес)",
+ "apihelp-delete-summary": "Баракты өчүрүү",
+ "apihelp-delete-example-simple": "<kbd>Main Page</kbd> өчүрүү.",
+ "apihelp-edit-summary": "Барактарды түзүү жана оңдоо.",
+ "apihelp-edit-param-text": "Барактын мазмуну.",
+ "apihelp-edit-param-minor": "Майда оңдоо."
+}
diff --git a/www/wiki/includes/api/i18n/lb.json b/www/wiki/includes/api/i18n/lb.json
new file mode 100644
index 00000000..0d5f2248
--- /dev/null
+++ b/www/wiki/includes/api/i18n/lb.json
@@ -0,0 +1,253 @@
+{
+ "@metadata": {
+ "authors": [
+ "Robby",
+ "Macofe"
+ ]
+ },
+ "apihelp-main-param-assertuser": "Iwwerpréifen ob den aktuelle Benotzer de Benotzer mat deem Numm ass.",
+ "apihelp-main-param-curtimestamp": "Den aktuellen Zäitstempel an d'Resultat integréieren.",
+ "apihelp-block-summary": "E Benotzer spären.",
+ "apihelp-block-param-user": "Benotzernumm, IP-Adress oder IP-Beräich fir ze spären. Kann net zesumme mat <var>$1userid</var> benotzt ginn.",
+ "apihelp-block-param-reason": "Grond fir ze spären.",
+ "apihelp-block-param-anononly": "Nëmmen anonym Benotzer spären (z. Bsp. anonym Ännerunge vun dëser IP-Adress ausschalten)",
+ "apihelp-block-param-nocreate": "Opmaache vun engem Benotzerkont verhënneren.",
+ "apihelp-block-param-reblock": "Wann de Benotzer scho gespaart ass, déi aktuell Spär iwwerschreiwen.",
+ "apihelp-block-param-watchuser": "Dem Benotzer oder der IP-Adress hier Benotzer- an Diskussiouns-Säiten iwwerwaachen.",
+ "apihelp-compare-param-fromtitle": "Éischten Titel fir ze vergläichen.",
+ "apihelp-compare-param-fromrev": "Éischt Versioun fir ze vergläichen.",
+ "apihelp-compare-param-totitle": "Zweeten Titel fir ze vergläichen.",
+ "apihelp-compare-param-torev": "Zweet Versioun fir ze vergläichen.",
+ "apihelp-createaccount-summary": "En neie Benotzerkont uleeën.",
+ "apihelp-createaccount-param-name": "Benotzernumm.",
+ "apihelp-createaccount-param-email": "E-Mail-Adress vum Benotzer (fakultativ).",
+ "apihelp-createaccount-param-realname": "Richtegen Numm vum Benotzer (fakultativ).",
+ "apihelp-delete-summary": "Eng Säit läschen.",
+ "apihelp-delete-param-watch": "D'Säit op dem aktuelle Benotzer seng Iwwerwaachungslëscht dobäisetzen.",
+ "apihelp-delete-param-unwatch": "D'Säit vun der Iwwerwaachungslëscht vum aktuelle Benotzer erofhuelen.",
+ "apihelp-delete-example-simple": "D'<kbd>Main Page</kbd> läschen.",
+ "apihelp-disabled-summary": "Dëse Modul gouf ausgeschalt.",
+ "apihelp-edit-summary": "Säiten uleeën an änneren.",
+ "apihelp-edit-param-sectiontitle": "Den Titel fir en neien Abschnitt.",
+ "apihelp-edit-param-text": "Säiteninhalt.",
+ "apihelp-edit-param-minor": "Kleng Ännerung.",
+ "apihelp-edit-param-notminor": "Keng kleng Ännerung",
+ "apihelp-edit-param-bot": "Dës Ännerung als eng Bot-Ännerung markéieren.",
+ "apihelp-edit-param-createonly": "D'Säit net ännere wann et se scho gëtt.",
+ "apihelp-edit-param-watch": "D'Säit op dem aktuelle Benotzer seng Iwwerwaachungslëscht dobäisetzen.",
+ "apihelp-edit-example-edit": "Eng Säit änneren",
+ "apihelp-emailuser-summary": "Engem Benotzer eng E-Mail schécken.",
+ "apihelp-emailuser-example-email": "Dem Benotzer <kbd>WikiSysop</kbd> eng E-Mail mam Text <kbd>Content</kbd> schécken.",
+ "apihelp-expandtemplates-param-title": "Titel vun der Säit.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "D'Maximalzäit no där den Tëschespäicher vum Resultat net méi valabel si soll.",
+ "apihelp-feedcontributions-param-year": "Vum Joer (a virdrun).",
+ "apihelp-feedcontributions-param-month": "Vum Mount (a virdrun).",
+ "apihelp-feedcontributions-param-deletedonly": "Nëmme geläscht Kontributioune weisen.",
+ "apihelp-feedcontributions-param-toponly": "Nëmmen Ännerunge weisen déi déi lescht Versioun sinn.",
+ "apihelp-feedcontributions-param-hideminor": "Kleng Ännerunge verstoppen.",
+ "apihelp-feedrecentchanges-param-days": "Deeg, op déi d'Resultater limitéiert gi sollen",
+ "apihelp-feedrecentchanges-param-hideminor": "Kleng Ännerunge verstoppen.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ännerunge vu Botte verstoppen.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ännerunge vun anonyme Benotzer verstoppen.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ännerunge vu registréierte Benotzer verstoppen.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ännerunge vum aktuelle Benotzer verstoppen.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Ännerunge vun der Memberschaft a Kategorie verstoppen.",
+ "apihelp-feedrecentchanges-param-categories": "Nëmmen Ännerunge vu Säiten aus all dëse Kategorië weisen.",
+ "apihelp-feedrecentchanges-param-categories_any": "Nëmmen Ännerunge vu Säiten aus enger vun dëse Kategorië weisen.",
+ "apihelp-feedrecentchanges-example-simple": "Rezent Ännerunge weisen",
+ "apihelp-filerevert-param-comment": "Bemierkung eroplueden.",
+ "apihelp-help-example-main": "Hëllef fir den Haaptmodul.",
+ "apihelp-help-example-recursive": "All Hëllef op enger Säit",
+ "apihelp-imagerotate-summary": "Eent oder méi Biller dréinen.",
+ "apihelp-imagerotate-example-generator": "All Biller an der <kbd>Category:Flip]]<kbd> ëm <kbd>180<kbd> Grad dréinen.",
+ "apihelp-import-param-summary": "Resumé vum importéiere vum Logbuch.",
+ "apihelp-import-param-xml": "Eropgeluedenen XML-Fichier.",
+ "apihelp-import-param-rootpage": "Als Ënnersäit vun dëser Säit importéieren. Kann net zesumme mam <var>$1namespace</var> benotzt ginn.",
+ "apihelp-login-param-name": "Benotzernumm.",
+ "apihelp-login-param-password": "Passwuert.",
+ "apihelp-login-example-login": "Aloggen.",
+ "apihelp-logout-example-logout": "Den aktuelle Benotzer ausloggen.",
+ "apihelp-mergehistory-summary": "Historique vun de Säite fusionéieren.",
+ "apihelp-move-summary": "Eng Säit réckelen.",
+ "apihelp-move-param-reason": "Grond fir d'Ëmbenennen.",
+ "apihelp-move-param-movetalk": "D'Diskussiounssäit ëmbenennen, wann et se gëtt.",
+ "apihelp-move-param-noredirect": "Keng Viruleedung uleeën.",
+ "apihelp-move-param-ignorewarnings": "All Warnungen ignoréieren.",
+ "apihelp-opensearch-param-suggest": "Näischt maache wa(nn) <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> falsch ass.",
+ "apihelp-options-summary": "Astellunge vum aktuelle Benotzer änneren.",
+ "apihelp-options-extended-description": "Nëmmen Optiounen aus dem Haaptdeel (core) oder aus enger vun den installéierten Erweiderunge, oder Optioune mat Schlësselen déi viragestallt si mat <code>userjs-</code> (geduecht fir mat Benotzer-Scripte benotzt ze ginn), kënnen agestallt ginn.",
+ "apihelp-options-param-optionname": "Den Numm vun der Optioun deen op de Wäert vun <var>$1optionvalue</var> gesat gi muss",
+ "apihelp-options-example-reset": "All Astellungen zrécksetzen",
+ "apihelp-parse-param-disablepp": "Benotzt an där Plaz <var>$1disablelimitreport</var>.",
+ "apihelp-patrol-example-rcid": "Eng rezent Ännerung nokucken.",
+ "apihelp-patrol-example-revid": "Eng Versioun nokucken.",
+ "apihelp-protect-example-protect": "Eng Säit spären",
+ "apihelp-query+allcategories-summary": "All Kategorien opzielen.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Kann nëmme mam <var>$3user</var> benotzt ginn.",
+ "apihelp-query+alldeletedrevisions-param-user": "Nëmme Versioune vun dësem Benotzer opzielen.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Versioune vun dësem Benotzer net opzielen.",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Setzt den Titel vum Fichier derbäi.",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Setzt den Titel vum Link derbäi.",
+ "apihelp-query+allrevisions-summary": "Lëscht vun alle Versiounen.",
+ "apihelp-query+allrevisions-param-user": "Nëmme Versioune vun dësem Benotzer opzielen.",
+ "apihelp-query+allrevisions-param-excludeuser": "Versioune vun dësem Benotzer net opzielen.",
+ "apihelp-query+allrevisions-param-namespace": "Nëmmen Säiten aus dësem Nummraum opzielen.",
+ "apihelp-query+allusers-summary": "All registréiert Benotzer opzielen.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Lëscht vun alle Gruppen an deenen de Benotzer automatesch dran ass.",
+ "apihelp-query+allusers-param-activeusers": "Nëmme Benotzer opzielen déi an de leschten $1 {{PLURAL:$1|Dag|Deeg}} aktiv waren.",
+ "apihelp-query+backlinks-example-simple": "Linken op d'<kbd>Main page</kbd> weisen.",
+ "apihelp-query+blocks-summary": "Lëscht vun de gespaarte Benotzer an IP-Adressen.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Setzt de Beräich vun den IP-Adressen derbäi déi vun der Spär betraff sinn.",
+ "apihelp-query+blocks-example-simple": "Lëscht vun de Spären",
+ "apihelp-query+categories-summary": "All Kategorien opzielen zu deenen dës Säit gehéiert.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Setzt den Zäitstempel vun dem Ament derbäi wou d'Kategorie derbäigesat gouf.",
+ "apihelp-query+categories-example-generator": "Informatioun iwwer all Kategorien, déi an der Säit <kbd>Albert Einstein</kbd> benotzt ginn, kréien.",
+ "apihelp-query+categorymembers-summary": "All Säiten aus enger bestëmmter Kategorie opzielen.",
+ "apihelp-query+categorymembers-example-simple": "Déi éischt 10 Säiten aus der <kbd>Category:Physics</kbd> kréien.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Versioune vun dësem Benotzer net opzielen.",
+ "apihelp-query+deletedrevs-summary": "Geläscht Versiounen oplëschten.",
+ "apihelp-query+deletedrevs-param-unique": "Nëmmen eng Versioun fir all Säit weisen.",
+ "apihelp-query+embeddedin-param-filterredir": "Wéi Viruleedungen gefiltert gi sollen.",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Alias fir Gréisst.",
+ "apihelp-query+filearchive-example-simple": "Eng Lëscht vun alle geläschte Fichiere weisen",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Titel vun all Säit.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Setzt fir all Versioun vum Fichier de Benotzer dobäi deen en eropgelueden huet.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Bemierkung iwwert d'Versioun.",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Alias fir Gréisst.",
+ "apihelp-query+imageinfo-param-urlheight": "Ähnlech wéi $1urlwidth.",
+ "apihelp-query+images-example-simple": "Eng Lëscht vun de Fichiere kréien déi op der [[Main Page|Haaptsäit]] benotzt ginn",
+ "apihelp-query+imageusage-example-simple": "Säite weisen déi [[:File:Albert Einstein Head.jpg]] benotzen",
+ "apihelp-query+info-paramvalue-prop-readable": "Ob de Benotzer dës Säit liese kann.",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Setzt déi komplett URL derbäi.",
+ "apihelp-query+langlinks-param-lang": "Nëmme Sproochlinke mat dësem Sproochcode zréckginn.",
+ "apihelp-query+links-param-namespace": "Nëmme Linken an dësen Nummräim weisen.",
+ "apihelp-query+linkshere-summary": "All Säite fannen déi op déi Säit linken déi ugi gouf.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Titel vun all Säit.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Markéiere wann d'Säit eng Viruleedung ass.",
+ "apihelp-query+pageswithprop-example-generator": "Zousätzlech Informatiounen iwwer déi 10 éischt Säite kréie mat <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+protectedtitles-param-namespace": "Nëmmen Titelen aus dësen Nummraim opzielen.",
+ "apihelp-query+random-param-redirect": "Benotzt dofir <kbd>$1filterredir=Viruleedungen</kbd>.",
+ "apihelp-query+recentchanges-summary": "Rezent Ännerungen opzielen.",
+ "apihelp-query+recentchanges-param-user": "Nëmmen Ännerunge vun dësem Benotzer opzielen.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Setzt d'Bemierkung vun der Ännerung derbäi.",
+ "apihelp-query+recentchanges-example-simple": "Rezent Ännerunge weisen",
+ "apihelp-query+redirects-paramvalue-prop-title": "Titel vun all Viruleedung.",
+ "apihelp-query+revisions-example-last5": "Déi lescht 5 Versioune vun der <kbd>Haaptsäit</kbd> kréien.",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "D'Nummer vun der Versioun.",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "Den Zäitstempel vun der Versioun.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Benotzer deen d'Versioun gemaach huet.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Längt (Bytes) vun der Versioun.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1 (base 16) vun der Versioun.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "Bemierkung vum Benotzer fir dës Versioun.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Text vun der Versioun.",
+ "apihelp-query+search-param-namespace": "Nëmmen an dësen Nummräim sichen.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Setzt d'Zuel vun de Wierder vun der Säit derbäi.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Setzt den Zäitstempel vun der leschter Ännerung vun der Säit derbäi.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignoréiert.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignoréiert.",
+ "apihelp-query+templates-param-namespace": "Schablounen nëmmen an dësen Nummräim weisen.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Titel vun all Säit.",
+ "apihelp-query+usercontribs-summary": "All Ännerunge vun engem Benotzer kréien.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Setzt den Zäitstempel vun derÄnnerung derbäi.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Setzt d'Bemierkung vun der Ännerung derbäi.",
+ "apihelp-query+userinfo-param-prop": "Informatioune fir dranzesetzen:",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Lëscht vun allen Astellungen déi den aktuelle Benotzer gemaach huet.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Setzt d'Gesamtzuel vun den Ännerunge vum aktuelle Benotzer derbäi.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Setzt dem Benotzer säi richtegen Numm derbäi.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Setzt de Registréierungsdatum vum Benotzer derbäi.",
+ "apihelp-query+users-paramvalue-prop-rights": "Weist all Rechter déi all Benotzer huet.",
+ "apihelp-query+watchlist-param-user": "Nëmmen Ännerunge vun dësem Benotzer opzielen.",
+ "apihelp-query+watchlist-param-excludeuser": "Ännerunge vun dësem Benotzer net opzielen.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Setzt den Titel vun der Säit derbäi.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Setzt de Benotzer derbäi deen d'Ännerung gemaach huet.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Setzt d'Bemierkung vun der Ännerung derbäi.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Setzt den Zäitstempel vun der Ännerung derbäi.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Extern Ännerungen.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Ugeluecht Säiten.",
+ "apihelp-query+watchlistraw-param-show": "Nëmmen Elementer opzielen déi dëse Critèren entspriechen.",
+ "apihelp-query+watchlistraw-example-simple": "Säite vum aktuelle Benotzer senger Iwwerwaachungslëscht opzielen",
+ "apihelp-revisiondelete-summary": "Versioune läschen a restauréieren.",
+ "apihelp-revisiondelete-param-reason": "Grond fir ze Läschen oder ze Restauréieren.",
+ "apihelp-rollback-summary": "Déi lescht Ännerung vun der Säit zrécksetzen.",
+ "apihelp-rsd-example-simple": "Den RSD-Schema exportéieren",
+ "apihelp-setpagelanguage-summary": "D'Sprooch vun enger Säit änneren",
+ "apihelp-setpagelanguage-extended-description-disabled": "Aschalten\n<var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> fir dëse Aktioun ze benotzen",
+ "apihelp-setpagelanguage-param-reason": "Grond fir d'Ännerung.",
+ "apihelp-setpagelanguage-example-language": "Ännert d'Sprooch vun der <kbd>Main Page</kbd> op baskesch.",
+ "apihelp-stashedit-param-title": "Titel vun der Säit déi geännert gëtt.",
+ "apihelp-stashedit-param-sectiontitle": "Den Titel fir en neien Abschnitt.",
+ "apihelp-stashedit-param-text": "Inhalt vun der Säit",
+ "apihelp-stashedit-param-summary": "Resumé änneren",
+ "apihelp-tag-param-reason": "Grond fir d'Ännerung.",
+ "apihelp-unblock-summary": "D'Spär vun engem Benotzer ophiewen.",
+ "apihelp-unblock-param-reason": "Grond fir d'Spär opzehiewen",
+ "apihelp-undelete-param-reason": "Grond fir ze restauréieren.",
+ "apihelp-undelete-example-page": "<kbd>Main Page</kbd> restauréieren.",
+ "apihelp-upload-param-watch": "D'Säit iwwerwaachen.",
+ "apihelp-upload-example-url": "Vun enger URL eroplueden.",
+ "apihelp-userrights-param-user": "Benotzernumm.",
+ "apihelp-userrights-param-userid": "Benotzer Id.",
+ "apihelp-userrights-param-reason": "Grond fir d'Ännerung.",
+ "apihelp-validatepassword-param-password": "Passwuert fir ze validéieren.",
+ "apihelp-validatepassword-example-1": "Validéiert d'Passwuert <kbd>foobar</kbd> fir den aktuelle Benotzer.",
+ "apihelp-watch-example-watch": "D'Säit <kbd>Main Page</kbd> iwwerwaachen.",
+ "api-login-fail-badsessionprovider": "Net méiglech sech anzelogge mat $1.",
+ "api-help-undocumented-module": "Keng Dokumentatioun fir de Modul $1.",
+ "api-help-source": "Quell: $1",
+ "api-help-source-unknown": "Quell: <span class=\"apihelp-unknown\">onbekannt</span>",
+ "api-help-license": "Lizenz: [[$1|$2]]",
+ "api-help-license-noname": "LiZenz: [[$1|Kuckt de Link]]",
+ "api-help-license-unknown": "Lizenz: <span class=\"apihelp-unknown\">onbekannt</span>",
+ "api-help-param-deprecated": "Vereelst.",
+ "api-help-param-required": "Dëse Parameter ass obligatoresch.",
+ "api-help-datatypes-header": "Datentypen",
+ "api-help-param-type-user": "Typ: {{PLURAL:$1|1=Benotzernumm|2=Lëscht vu Benotzernimm}}",
+ "api-help-param-multi-max-simple": "Maximal Zuel vun de Wäerter ass {{PLURAL:$1|$1}}.",
+ "api-help-examples": "{{PLURAL:$1|Beispill|Beispiler}}:",
+ "api-help-permissions": "{{PLURAL:$1|Autorisatioun|Autorisatiounen}}:",
+ "api-help-open-in-apisandbox": "<small>[an der Sandkëscht opmaachen]</small>",
+ "apierror-articleexists": "Den Artikel deen dir probéiert hutt unzeleeë gouf schonn ugeluecht.",
+ "apierror-autoblocked": "Är IP-Adress gouf automatesch gespaart well se vun engem gespaarte Benotzer benotzt gouf.",
+ "apierror-badip": "IP-Parameter ass net valabel.",
+ "apierror-cantblock": "Dir hutt net d'Recht fir Benotzer ze spären.",
+ "apierror-cantimport": "Dir hutt net déi néideg Rechter fir Säiten z'importéieren.",
+ "apierror-copyuploadbadurl": "D'Eroplueden ass vun dëser URL net erlaabt.",
+ "apierror-filetypecannotberotated": "Den Typ vu Fichier kann net rotéiert ginn.",
+ "apierror-import-unknownerror": "Onbekannte Feeler beim Import: $1\nf",
+ "apierror-invalidcategory": "Den Numm vun der Kategorie deen Dir aginn hutt ass net valabel.",
+ "apierror-invalidtitle": "Schlechten Titel \"$1\".",
+ "apierror-invaliduserid": "Benotzer ID <var>$1</var> ass net valabel.",
+ "apierror-missingrev-title": "Keng aktuell Versioun vum Titel $1.",
+ "apierror-missingtitle": "D'Säit déi Dir spezifizéiert hutt gëtt et net.",
+ "apierror-missingtitle-byname": "D'Säit $1 gëtt et net.",
+ "apierror-moduledisabled": "De(n) <kbd>$1</kbd> Modul gouf ausgeschalt.",
+ "apierror-mustbeloggedin-generic": "Dir musst ageloggt sinn.",
+ "apierror-nochanges": "Et goufe keng Ännerungen ugefrot.",
+ "apierror-nodeleteablefile": "Et gëtt keng esou al Versioun vum Fichier.",
+ "apierror-noedit": "Dir hutt net déi néideg Rechter fir Säiten z'änneren.",
+ "apierror-nosuchuserid": "Et gëtt kee Benotzer mat der ID $1.",
+ "apierror-notarget": "Dir hutt kee valabelt Zil fir dës Aktioun spezifizéiert.",
+ "apierror-notpatrollable": "D'Versioun r$1 kann net nogekuckt gi well se ze al ass.",
+ "apierror-pagecannotexist": "Nummraum erlaabt keng aktuell Säiten.",
+ "apierror-pagelang-disabled": "D'Ännere vun der Sprooch vun enger Säit ass op dëser Wiki net erlaabt.",
+ "apierror-permissiondenied-generic": "Autorisatioun refuséiert.",
+ "apierror-permissiondenied-unblock": "Dir hutt net d'Recht fir d'Spär vu Benotzer opzehiewen.",
+ "apierror-readonly": "D'Wiki kann elo just geliest ginn.",
+ "apierror-revisions-badid": "Fir de Parameter <var>$1</var> gouf keng Versioun fonnt.",
+ "apierror-revwrongpage": "r$1 ass keng Versioun vu(n) $2.",
+ "apierror-stashwrongowner": "Falsche Besëtzer: $1",
+ "apierror-systemblocked": "Dir gouft automatesch vu MediaWiki gespaart.",
+ "apierror-timeout": "De Server huet net bannen där Zäit geäntwert déi virgesinn ass.",
+ "apierror-unknownerror-editpage": "Onbekannten EditPage-Feeler: $1",
+ "apierror-unknownerror-nocode": "Onbekannte Feeler.",
+ "apierror-unknownerror": "Onbekannte Feeler: \"$1\".",
+ "apierror-unknownformat": "Net-erkannte Format \"$1\".",
+ "apierror-unrecognizedparams": "Net {{PLURAL:$2|erkannte Parameter|erkannt Parameteren}}: $1",
+ "apierror-writeapidenied": "Dir däerft dës Wiki net iwwer den API ännneren.",
+ "apiwarn-invalidcategory": "\"$1\" ass keng Kategorie.",
+ "apiwarn-invalidtitle": "\"$1\" ass kee valabelen Titel",
+ "apiwarn-notfile": "\"$1\" ass kee Fichier.",
+ "apiwarn-tokennotallowed": "Aktioun \"$1\" ass net erlaabt fir den aktuelle Benotzer.",
+ "apiwarn-validationfailed-badpref": "keng valabel Astellung",
+ "api-feed-error-title": "Feeler ($1)"
+}
diff --git a/www/wiki/includes/api/i18n/lij.json b/www/wiki/includes/api/i18n/lij.json
new file mode 100644
index 00000000..cb902b33
--- /dev/null
+++ b/www/wiki/includes/api/i18n/lij.json
@@ -0,0 +1,221 @@
+{
+ "@metadata": {
+ "authors": [
+ "Giromin Cangiaxo"
+ ]
+ },
+ "apihelp-main-param-action": "Açion da compî.",
+ "apihelp-main-param-format": "Formato de l'output.",
+ "apihelp-main-param-assert": "Veifica che l'utente o l'agge effetoòu l'accesso se s'è impostou <kbd>user</kbd>, ò ch'o l'agge i permissi di bot se s'è impostou <kbd>bot</kbd>.",
+ "apihelp-main-param-requestid": "Tutti i valoî fornii saian incruxi inta risposta. Porieivan ese doeuviæ pe distingue e receste.",
+ "apihelp-main-param-servedby": "Inciodi into risultou o nomme de l'host ch'o l'ha servio a recesta.",
+ "apihelp-main-param-curtimestamp": "Inciodi into risultou o timestamp attoâ.",
+ "apihelp-block-summary": "Blocca un utente.",
+ "apihelp-block-param-user": "Nomme utente, adresso IP o range di IP da bloccâ.",
+ "apihelp-block-param-expiry": "Tempo de scadença. O poeu ese relativo (presempio, <kbd>5 months</kbd> o <kbd>2 weeks</kbd>) ò assoluo (presempio <kbd>2014-09-18T12:34:56Z</kbd>). Se impostou a <kbd>infinite</kbd>, <kbd>indefinite</kbd> ò <kbd>never</kbd>, o blòcco o no descaziâ mai.",
+ "apihelp-block-param-reason": "Raxon do blòcco.",
+ "apihelp-block-param-anononly": "Blocca solo che i utenti non registræ (saiv'a dî disattiva i contributi anonnimi da questo adresso IP).",
+ "apihelp-block-param-nocreate": "Impedisci a creaçion de utençe.",
+ "apihelp-block-param-autoblock": "Blocca aotomaticamente l'urtimo adreçço IP doeuviou da l'utente e i succescivi co-i quæ tentan l'accesso",
+ "apihelp-block-param-hidename": "Ascondi o nomme utente da-o registro di blocchi (Ghe voeu i permissi de <code>hideuser</code>).",
+ "apihelp-block-param-reblock": "Se l'utente o l'è za bloccou, sorvescrive o blocco existente.",
+ "apihelp-block-param-watchuser": "Oserva a paggina utente e e paggine de discuscion utente de l'utente ò de l'adresso IP.",
+ "apihelp-block-example-ip-simple": "Blocca l'adresso IP <kbd>192.0.2.5</kbd> pe trei giorni con motivaçion <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Blocca l'utente <kbd>Vandal</kbd> a tempo indeterminou con motivaçion <kbd>Vandalism</kbd>, e impediscighe a creaçion de noeuve utençe e l'invio de e-mail.",
+ "apihelp-changeauthenticationdata-summary": "Modificâ i dæti d'aotenticaçion pe l'utente corente.",
+ "apihelp-changeauthenticationdata-example-password": "Tentativo de modificâ a password de l'utente corente a <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Veifica a validitæ de 'n token da <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Tipo de token in corso de test.",
+ "apihelp-checktoken-param-token": "Token da testâ.",
+ "apihelp-checktoken-param-maxtokenage": "Mascima etæ consentia pe-o token, in segondi.",
+ "apihelp-checktoken-example-simple": "Veifica a validitæ de 'n token <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Scassa o flag <code>hasmsg</code> pe l'utente corente.",
+ "apihelp-clearhasmsg-example-1": "Scassa o flag <code>hasmsg</code> pe l'utente corente.",
+ "apihelp-clientlogin-summary": "Accedi a-o wiki doeuviando o flusso interattivo.",
+ "apihelp-clientlogin-example-login": "Avvia o processo d'accesso a-a wiki comme utente <kbd>Example</kbd> con password <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continnoa l'accesso doppo una risposta de l'<samp>UI</samp> pe l'aotenticaçion a doî fattoî, fornindo un <var>OATHToken</var> de <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Otegni e differençe tra 2 paggine.",
+ "apihelp-compare-extended-description": "Un nummero de revixon, o tittolo de 'na paggina, ò un ID de paggina o dev'ese indicou segge pe-o \"da\" che pe-o \"a\".",
+ "apihelp-compare-param-fromtitle": "Primmo tittolo da confrontâ.",
+ "apihelp-compare-param-fromid": "Primo ID de paggina da confrontâ.",
+ "apihelp-compare-param-fromrev": "Primma revixon da confrontâ.",
+ "apihelp-compare-param-totitle": "Segondo tittolo da confrontâ.",
+ "apihelp-compare-param-toid": "Segondo ID de paggina da confrontâ.",
+ "apihelp-compare-param-torev": "Segonda revixon da confrontâ.",
+ "apihelp-compare-example-1": "Crea un diff tra revixon 1 e revixon 2.",
+ "apihelp-createaccount-summary": "Crea una noeuva utença.",
+ "apihelp-createaccount-param-preservestate": "Se <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> o l'ha restituto true pe <samp>hasprimarypreservedstate</samp>, e receste contrssegnæ comme <samp>primary-required</samp> dovieivan ese omisse. Se invece o l'ha restituio un valô non voeuo pe <samp>preservedusername</samp>, quello nomme utente o dev'ese doeuviou pe-o parammetro <var>username</var>.",
+ "apihelp-createaccount-example-create": "Avvia o processo de creaçion d'utente <kbd>Example</kbd> con password <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Nomme utente",
+ "apihelp-createaccount-param-password": "Password (a saiâ ignorâ se l'è impostou <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Dominnio pe l'aotenticaçion esterna (opçionâ).",
+ "apihelp-createaccount-param-email": "Adresso Email de l'utente (opçionâ).",
+ "apihelp-createaccount-param-realname": "Nomme veo de l'utente (opçionâ).",
+ "apihelp-createaccount-param-mailpassword": "Se impostou insce 'n qualonque valô, una password random (caxoâ) a saiâ inviâ a l'utente.",
+ "apihelp-createaccount-param-reason": "Raxon facortativa da creaçion de l'utença da insei inti registri.",
+ "apihelp-createaccount-param-language": "Codiçe de lengua da impostâ comme predefinia pe l'utente (opçionâ, pe difetto a l'è a lengua do contegnuo).",
+ "apihelp-createaccount-example-pass": "Crea l'utente <kbd>testuser</kbd> con password <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Crea l'utente <kbd>testmailuser</kbd> e mandighe via e-mail una password generâ abrettio.",
+ "apihelp-delete-summary": "Scassa 'na paggina",
+ "apihelp-delete-param-title": "Tittolo da paggina che se dexîa eliminâ. O no poeu vese doeuviou insemme a <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID de paggina da paggina da scassâ. O no poeu vese doeuviou insemme con <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Raxon da scassatua. S'a no saiâ indicâ, saiâ doeuviou 'na raxon generâ aotomaticamente.",
+ "apihelp-delete-param-watch": "O l'azonze a paggina a-a lista di oservæ speciali de l'utente corente.",
+ "apihelp-delete-param-unwatch": "O rimoeuve a pagina da-a lista di oservæ speciali de l'utente corente.",
+ "apihelp-delete-param-oldimage": "O nomme da vegia inmaggine da scassâ, comme fornia da [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Scassa <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Scassa a <kbd>Main Page</kbd> con motivaçion <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Questo modulo o l'è stæto disabilitou.",
+ "apihelp-edit-summary": "Crea e modifica paggine.",
+ "apihelp-edit-param-title": "Tittolo da paggina da modificâ. O no poeu vese doeuviou insemme a <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID de paggina da paggina da modificâ. O no poeu ese doeuviou insemme a <var>$1title</var>.",
+ "apihelp-edit-param-section": "Nummero de seçion. <kbd>0</kbd> pe-a seçion de d'ato, <kbd>new</kbd> pe 'na noeuva seçion.",
+ "apihelp-edit-param-sectiontitle": "O tittolo pe 'na noeuva seçion.",
+ "apihelp-edit-param-text": "Contegnuo da paggina.",
+ "apihelp-edit-param-summary": "Ogetto da modiffica. E ascì tittolo da seçion se $1sezione=new e $1sectiontitle o no l'è impostou.",
+ "apihelp-edit-param-tags": "Cangia i tag da apricâ a-a revixon.",
+ "apihelp-edit-param-minor": "Cangiamento menô.",
+ "apihelp-edit-param-notminor": "Cangiamento non-menô.",
+ "apihelp-edit-param-bot": "Marca sta modiffica comme bot.",
+ "apihelp-edit-param-createonly": "No modificâ a paggina s'a l'existe za.",
+ "apihelp-edit-param-nocreate": "O genera un errô se a paggina a no l'existe.",
+ "apihelp-edit-param-watch": "O l'azonze a paggina a-a lista di oservæ speciali de l'utente corente.",
+ "apihelp-edit-param-unwatch": "O rimoeuve a pagina da-a lista di oservæ speciali de l'utente corente.",
+ "apihelp-edit-param-redirect": "Resciorvi aotomaticamente i rimandi.",
+ "apihelp-edit-param-contentmodel": "Modello de contegnuo di noeuvi contegnui.",
+ "apihelp-edit-param-token": "O token o dev'ese delongo inviou comme urtimo parammetro, ò aomeno doppo o parametro $1text.",
+ "apihelp-edit-example-edit": "Modiffica 'na paggina.",
+ "apihelp-edit-example-prepend": "Antepon-i <kbd>_&#95;NOTOC_&#95;</kbd> a 'na paggina.",
+ "apihelp-emailuser-summary": "Manda 'n'e-mail a 'n utente.",
+ "apihelp-emailuser-param-target": "Utente a chi inviâ l'e-mail.",
+ "apihelp-emailuser-param-subject": "Ogetto de l'e-mail.",
+ "apihelp-emailuser-param-text": "Testo de l'e-mail.",
+ "apihelp-emailuser-param-ccme": "Mandime una copia de questa mail.",
+ "apihelp-emailuser-example-email": "Manda un'e-mail a l'utente <kbd>WikiSysop</kbd> co-o testo <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Espandi tutti i template into wikitesto.",
+ "apihelp-expandtemplates-param-title": "Tittolo da paggina.",
+ "apihelp-expandtemplates-param-text": "Wikitesto da convertî.",
+ "apihelp-expandtemplates-param-prop": "Quæ informaçion otegnî.\n\nNotta che se no l'è seleçionou arcun valô, o risultou o contegniâ o codiçe wiki, ma l'output o saiâ inte 'n formato obsoleto.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "O wikitext espanso.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Propietæ da paggina definie da-e paole magiche esteise into wikitesto.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Se l'output o segge volatile e o no 'agge da ese riadoeuviou atr'onde a l'interno da paggina.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "O tempo mascimo doppo o quæ e memoizaçioin tempoannie (cache) do risultou dovieivan ese invalidæ.",
+ "apihelp-feedcontributions-param-feedformat": "O formato do feed.",
+ "apihelp-feedrecentchanges-param-feedformat": "O formato do feed.",
+ "apihelp-feedrecentchanges-param-namespace": "Namespace a-o quæ limitâ i risultæ.",
+ "apihelp-feedrecentchanges-param-associated": "Inciodi namespace associou (discuscion ò prinçipâ)",
+ "apihelp-feedrecentchanges-param-days": "Intervallo de giorni pe-i quæ limitâ i risultæ.",
+ "apihelp-feedrecentchanges-param-limit": "Nummero mascimo di risultæ da restituî.",
+ "apihelp-feedrecentchanges-param-from": "Mostra i cangiamenti da alloa.",
+ "apihelp-feedrecentchanges-param-hideminor": "Ascondi e modiffiche menoî.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ascondi e modiffiche fæte da di bot.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ascondi e modiffiche fæte da di utenti anonnimi.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ascondi e modiffiche fæte da-i utenti registræ.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ascondi e modiffiche veificæ.",
+ "apihelp-feedrecentchanges-param-hidemyself": "O l'asconde e modiffiche fæte da l'utente attoale.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Ascondi e variaçioin d'apartegninça a-e categorie.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtra pe etichetta.",
+ "apihelp-feedrecentchanges-param-target": "Mostra solo e modifiche a-e paggine collegæ da questa paggina.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Fanni védde sôlo i cangiaménti a-e pàggine conligæ a-a quella speçificâ",
+ "apihelp-feedrecentchanges-param-categories": "Mostra solo e variaçioin in sce-e paggine de tutte queste categorie.",
+ "apihelp-feedrecentchanges-param-categories_any": "Mostra invece solo e variaçioin in sce-e paggine inte 'na qualonque categoria.",
+ "apihelp-feedrecentchanges-example-simple": "Mostra i urtime modiffiche.",
+ "apihelp-feedrecentchanges-example-30days": "Mostra e modifiche di urtimi 30 giorni.",
+ "apihelp-feedwatchlist-param-feedformat": "O formato do feed.",
+ "apihelp-feedwatchlist-param-hours": "Elenca e paggine modificæ inte quest'urtime oe.",
+ "apihelp-feedwatchlist-param-linktosections": "Collega direttamente a-e seçioin modificæ, se poscibbile.",
+ "apihelp-feedwatchlist-example-all6hrs": "Mostra tutte e modiffiche a-e pagine oservæ inti urtime 6 oe.",
+ "apihelp-filerevert-summary": "Ripristina un file a 'na verscion precedente.",
+ "apihelp-filerevert-param-filename": "Nomme do file de destinaçion, sença o prefisso 'File:'.",
+ "apihelp-filerevert-param-comment": "Commento in sciô caregamento.",
+ "apihelp-filerevert-param-archivename": "Nomme de l'archivvio da verscion da ripristinâ.",
+ "apihelp-filerevert-example-revert": "Ripristina <kbd>Wiki.png</kbd> a-a verscion do <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Mostra a guidda pe-i modduli speçificæ.",
+ "apihelp-help-param-toc": "Inciodi un endexo inte l'output HTML.",
+ "apihelp-help-example-main": "Agiutto pe-o moddulo prinçipâ.",
+ "apihelp-help-example-submodules": "Agiutto pe <kbd>action=query</kbd> e tutti i so sotto-modduli.",
+ "apihelp-help-example-recursive": "Tutti i agiutti inte 'na paggina.",
+ "apihelp-help-example-help": "Agiutto pe-o moddulo d'agiutto mæximo.",
+ "apihelp-imagerotate-summary": "Roeua un-a o ciù inmaggine.",
+ "apihelp-imagerotate-param-rotation": "Graddi de rotaçion de l'inmaggine in senso oaio.",
+ "apihelp-imagerotate-example-simple": "Roeua <kbd>File:Example.png</kbd> de <kbd>90</kbd> graddi.",
+ "apihelp-imagerotate-example-generator": "Roeua tutte e inmaggine in <kbd>Category:Flip</kbd> de <kbd>180</kbd> graddi.",
+ "apihelp-import-param-summary": "Ogetto into registro d'importaçion.",
+ "apihelp-import-param-xml": "File XML caregou.",
+ "apihelp-import-param-interwikisource": "Pe-e importaçioin interwiki: wiki da-e quæ importâ.",
+ "apihelp-import-param-interwikipage": "Pe-e importaçioin interwiki: paggina da importâ.",
+ "apihelp-import-param-fullhistory": "Pe-e importaçioin interwiki: importa l'intrega cronologia, non solo a verscion attoale.",
+ "apihelp-import-param-templates": "Pe-e importaçioin interwiki: importa tutti i template incioxi ascì.",
+ "apihelp-import-param-namespace": "Importa inte questo namespace. O no poeu ese doeuviou insemme a <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importa comme sottopaggina de questa paggina. O no poeu ese doeuviou insemme a <var>$1namespace</var>.",
+ "apihelp-import-example-import": "Importa [[meta:Help:ParserFunctions]] into namespace 100 con cronologia completa.",
+ "apihelp-linkaccount-summary": "Conligamento de 'n'utença de 'n provider de terçe parte a l'utente corente.",
+ "apihelp-linkaccount-example-link": "Avvia o processo de collegamento a 'n'utença da <kbd>Example</kbd>.",
+ "apihelp-login-summary": "Accedi e otegni i cookie d'aotenticaçion.",
+ "apihelp-login-extended-description": "Quest'açion dev'ese doeuviâ escluxivamente in combinaçion con [[Special:BotPasswords]]; doeuviâla pe l'accesso a l'account prinçipâ o l'è deprecou e o poeu fallî sença preaviso. Pe acedere in moddo seguo a l'utença prinçipâ, doeuvia <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Quest'açion a l'è deprecâ e a poeu fallî sença preaviso. Pe acede in moddo seguo, doeuvia [[Special:ApiHelp/clientlogin|action=clientlogin]].",
+ "apihelp-login-param-name": "Nomme utente.",
+ "apihelp-login-param-password": "Password.",
+ "apihelp-login-param-domain": "Dominnio (opçionâ).",
+ "apihelp-login-example-gettoken": "Recuppera un token de login.",
+ "apihelp-login-example-login": "Intra",
+ "apihelp-logout-summary": "Sciorti e scassa i dæti da sescion.",
+ "apihelp-logout-example-logout": "Disconnetti l'utente attoale.",
+ "apihelp-mergehistory-summary": "O l'unisce e cronologie de paggine.",
+ "apihelp-mergehistory-param-from": "O tittolo da paggina da-a quæ a cronologia a saiâ unia. O no poeu ese doeuviou insemme a <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "L'ID da paggina da-a quæ a cronologia a saiâ unia. O no poeu ese doeuviou insemme a <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "O tittolo da paggina inta quæ a cronologia a saiâ unia. O no poeu ese doeuviou insemme a <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "L'ID da paggina inta quæ a cronologia a saiâ unia. O no poeu ese doeuviou insemme a <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-timestamp": "O timestamp scin a-o quæle verscioin saian mesciæ da-a cronologia da paggina d'origine a quella da paggina de destinaçion. Se omisso, l'intrega cronologia da paggina d'origine a saiâ unia inta paggina de destinaçion.",
+ "apihelp-mergehistory-param-reason": "Raxon pe l'union da cronologia.",
+ "apihelp-mergehistory-example-merge": "Unisci l'intrega cronologia de <kbd>Oldpage</kbd> inte <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Unisci e verscioin da paggina <kbd>Oldpage</kbd> scin a <kbd>2015-12-31T04:37:41Z</kbd> inte <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Mescia 'na paggina",
+ "apihelp-move-param-from": "Tittolo da paggina da rinominâ. O no poeu vese doeuviou insemme a <var>$1pageid</var>.",
+ "apihelp-move-param-fromid": "ID de paggina da paggina da rinominâ. O no poeu ese doeuviou insemme a <var>$1title</var>.",
+ "apihelp-move-param-to": "Tittolo a-o quæ mesciâ a paggina.",
+ "apihelp-move-param-reason": "Raxon da rinommina.",
+ "apihelp-move-param-movetalk": "Rinommina a paggina de discuscion, s'a l'existe.",
+ "apihelp-move-param-movesubpages": "Rinommina e sottopaggine, se applicabile.",
+ "apihelp-move-param-noredirect": "No creâ un rinvio.",
+ "apihelp-move-param-watch": "Azonze a paggina e o redirect a-i oservæ speciali de l'utente attoale.",
+ "apihelp-move-param-unwatch": "Rimoeuvi a paggina e o redirect da-i oservæ speciali de l'utente attoale.",
+ "apihelp-move-param-ignorewarnings": "Ignora i messaggi d'avvertimento do scistema",
+ "apihelp-move-example-move": "Mescia <kbd>Badtitle</kbd> a <kbd>Goodtitle</kbd> sença lasciâ de redirect.",
+ "apihelp-opensearch-param-search": "Stringa de çerchia.",
+ "apihelp-opensearch-param-limit": "Nummero mascimo di risultæ da restituî.",
+ "apihelp-opensearch-param-suggest": "No stanni a fâ ninte se <var>[[mw:Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> o l'è faso.",
+ "apihelp-opensearch-param-format": "O formato de l'output.",
+ "apihelp-opensearch-example-te": "Troeuva e paggine che començan con <kbd>Te</kbd>.",
+ "apihelp-options-example-reset": "Reimposta tutte e preferençe.",
+ "apihelp-paraminfo-summary": "Otegni de informaçioin in scî modduli API.",
+ "apihelp-paraminfo-param-helpformat": "Formato de stringhe d'agiutto.",
+ "apihelp-parse-param-summary": "Ogetto da analizâ.",
+ "apihelp-query+allcategories-param-prop": "Quæ propietæ otegnî:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Azonzi o nummero de paggine inta categoria.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Etichetta e categorie che son ascose con <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Elenca e categorie con de informaçioin in sciô numero de paggine in ciascun-a.",
+ "apihelp-query+alldeletedrevisions-summary": "Elenca tutte e verscioin scassæ da 'n utente ò inte 'n namespace.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "O poeu ese doeuviou solo con <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "O no poeu ese doeuviou con <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "O timestamp da-o quæ començâ l'elenco.",
+ "apihelp-query+alldeletedrevisions-param-end": "O timestamp a-o quæ interrompî l'elenco.",
+ "apihelp-query+alldeletedrevisions-param-from": "Comença l'elenco a questo tittolo.",
+ "apihelp-query+alldeletedrevisions-param-to": "Interrompi l'elenco a questo titolo.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Riçerca pe tutti i titoli de pagine che començan con questo valô.",
+ "apihelp-query+alldeletedrevisions-param-user": "Elenca solo e verscioin de questo utente.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "No elencâ e verscioin de questo utente.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Elenca solo e paggine inte questo namespace.",
+ "apihelp-query+alldeletedrevisions-example-user": "Elenca i urtimi 50 contributi scassæ de l'utente <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Elenca e primme 50 verscioin scassæ into namespace prinçipâ.",
+ "apihelp-query+allfileusages-param-from": "O titolo do file da-o quæ començâ l'elenco.",
+ "apihelp-query+allfileusages-param-to": "O tittolo do file a-o quæ interrompî l'elenco.",
+ "apihelp-query+allfileusages-param-prefix": "Riçerca pe tutti i titoli di file che començan con questo valô.",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "O l'azonze o tittolo do file.",
+ "apihelp-query+allfileusages-param-limit": "Quanti elementi totali restitoî.",
+ "apihelp-query+allfileusages-param-dir": "A direçion inta quæ elencâ.",
+ "apihelp-query+allfileusages-example-generator": "Otegni e paggine contegninte i file.",
+ "apihelp-query+allimages-param-sort": "Propietæ d'amerçamento.",
+ "apihelp-query+allimages-param-dir": "A direçion inta quæ elencâ.",
+ "apihelp-query+allimages-param-from": "O titolo de l'inmagine da-a quæ començâ l'elenco. O poeu ese doeuviou solo con $1sort=name."
+}
diff --git a/www/wiki/includes/api/i18n/lki.json b/www/wiki/includes/api/i18n/lki.json
new file mode 100644
index 00000000..f16f76f0
--- /dev/null
+++ b/www/wiki/includes/api/i18n/lki.json
@@ -0,0 +1,28 @@
+{
+ "@metadata": {
+ "authors": [
+ "Hosseinblue",
+ "Arash71",
+ "Lakzon"
+ ]
+ },
+ "apihelp-main-param-action": "کام عملیات انجؤم بِ.",
+ "apihelp-main-param-format": "فرمت خروجی",
+ "apihelp-block-summary": "بستن کاربر.",
+ "apihelp-createaccount-param-name": "نۆم کاربەری:",
+ "apihelp-delete-summary": "حةذف وةڵگة",
+ "apihelp-delete-example-simple": "حذف <kbd>Main Page</kbd>.",
+ "apihelp-disabled-summary": "اێ پودمانە إکار کەتێە(غیرفعال بیە).",
+ "apihelp-edit-summary": "دؤرس کردن و دۀسکاری وۀلگۀ",
+ "apihelp-edit-param-sectiontitle": "نام سۀر وۀلگ تازۀ",
+ "apihelp-edit-example-edit": ".دةسکاری وةڵگة",
+ "apihelp-emailuser-param-subject": "موضوع سةر وةڵگ",
+ "apihelp-emailuser-param-text": "متن رایانه.",
+ "apihelp-help-example-main": "راهنما برای پودمان اصلی",
+ "apihelp-login-param-name": "نام کاربری",
+ "apihelp-login-param-password": ".رمز",
+ "apihelp-login-example-login": "إنۆم هەتِن.",
+ "apihelp-logout-summary": "دۀرچئن و پاک کردن داده متن",
+ "apihelp-logout-example-logout": "خروج کاربر فعلی",
+ "apihelp-options-example-reset": "بازنشانی همه تنظیمات."
+}
diff --git a/www/wiki/includes/api/i18n/ln.json b/www/wiki/includes/api/i18n/ln.json
new file mode 100644
index 00000000..cd117465
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ln.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Moyogo"
+ ]
+ },
+ "apihelp-edit-example-edit": "Kobɔngisa lokásá lɔ̌kɔ́."
+}
diff --git a/www/wiki/includes/api/i18n/lt.json b/www/wiki/includes/api/i18n/lt.json
new file mode 100644
index 00000000..2726944c
--- /dev/null
+++ b/www/wiki/includes/api/i18n/lt.json
@@ -0,0 +1,422 @@
+{
+ "@metadata": {
+ "authors": [
+ "Zygimantus",
+ "Eitvys200"
+ ]
+ },
+ "apihelp-main-param-action": "Kurį veiksmą atlikti.",
+ "apihelp-main-param-curtimestamp": "Prie rezultato pridėti dabartinę laiko žymę.",
+ "apihelp-block-summary": "Blokuoti vartotoją.",
+ "apihelp-block-param-reason": "Blokavimo priežastis.",
+ "apihelp-block-param-nocreate": "Neleisti kurti paskyrų.",
+ "apihelp-compare-param-fromtitle": "Pirmas pavadinimas palyginimui.",
+ "apihelp-compare-param-fromid": "Pirmojo lyginamo puslapio ID.",
+ "apihelp-compare-param-totitle": "Antrasis pavadinimas palyginimui.",
+ "apihelp-compare-param-toid": "Antrojo lyginamo puslapio ID.",
+ "apihelp-compare-param-prop": "Kokią informaciją gauti.",
+ "apihelp-createaccount-summary": "Kurti naują vartotojo paskyrą.",
+ "apihelp-createaccount-param-name": "Naudotojo vardas.",
+ "apihelp-createaccount-param-email": "Vartotojo el. pašto adresas (nebūtina).",
+ "apihelp-createaccount-param-realname": "Vardas (nebūtina).",
+ "apihelp-delete-summary": "Ištrinti puslapį.",
+ "apihelp-delete-param-watch": "Pridėti puslapį prie dabartinio vartotojo stebimųjų sąrašo.",
+ "apihelp-delete-param-unwatch": "Pašalinti puslapį iš dabartinio vartotojo stebimųjų sąrašo.",
+ "apihelp-delete-example-simple": "Ištrinti <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Ištrinti <kbd>Main Page</kbd> su priežastimi <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Šis modulis buvo išjungtas.",
+ "apihelp-edit-summary": "Kurti ir redaguoti puslapius.",
+ "apihelp-edit-param-title": "Redaguotino puslapio pavadinimas. Negali būti naudojamas kartu su <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Redaguotino puslapio ID. Negali būti naudojamas kartu su <var>$1title</var>.",
+ "apihelp-edit-param-section": "Sekcijos numeris. <kbd>0</kbd> - viršutinei sekcijai, <kbd>new</kbd> - naujai sekcijai.",
+ "apihelp-edit-param-sectiontitle": "Naujo skyriaus pavadinimas.",
+ "apihelp-edit-param-text": "Puslapio turinys.",
+ "apihelp-edit-param-summary": "Keitimo santrauka. Taip pat sekcijos pavadinimas, kai $1section=new ir $1sectiontitle yra nenustatytas.",
+ "apihelp-edit-param-minor": "Smulkus pakeitimas.",
+ "apihelp-edit-param-notminor": "Nesmulkus pakeitimas.",
+ "apihelp-edit-param-bot": "Pažymėti šį pakeitimą kaip roboto pakeitimą.",
+ "apihelp-edit-param-createonly": "Neredaguoti puslapio jei jis jau egzistuoja.",
+ "apihelp-edit-param-nocreate": "Parodyti klaidą, jei puslapis neegzistuoja.",
+ "apihelp-edit-param-watch": "Pridėti puslapį į dabartinio vartotojo stebimųjų sąrašą.",
+ "apihelp-edit-param-unwatch": "Pašalinti puslapį iš dabartinio vartotojo stebimųjų sąrašo.",
+ "apihelp-edit-param-redirect": "Automatiškai išspręsti peradresavimus.",
+ "apihelp-edit-param-contentmodel": "Naujam turiniui taikomas turinio modelis.",
+ "apihelp-edit-example-edit": "Redaguoti puslapį.",
+ "apihelp-emailuser-summary": "Siųsti el. laišką naudotojui.",
+ "apihelp-emailuser-param-target": "El. laiško gavėjas.",
+ "apihelp-emailuser-param-subject": "Temos antraštė.",
+ "apihelp-emailuser-param-ccme": "Siųsti šio laiško kopiją man.",
+ "apihelp-emailuser-example-email": "Siųsti el. pašto vartotojui <kbd>WikiSysop</kbd> su tekstu <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-param-title": "Puslapio pavadinimas.",
+ "apihelp-feedcontributions-summary": "Gražina vartotojo įnašų srautą.",
+ "apihelp-feedcontributions-param-feedformat": "Srauto formatas.",
+ "apihelp-feedcontributions-param-year": "Nuo metų (ir anksčiau).",
+ "apihelp-feedcontributions-param-month": "Nuo mėnesio (ir anksčiau).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtruoti įnašus, kurie turi šias žymes.",
+ "apihelp-feedcontributions-param-deletedonly": "Rodyti tik ištrintus įnašus.",
+ "apihelp-feedcontributions-param-toponly": "Rodyti tik keitimus, kurie yra paskutiniai pakeitimai.",
+ "apihelp-feedcontributions-param-newonly": "Rodyti tik keitimus, kurie yra puslapio sukūrimai.",
+ "apihelp-feedcontributions-param-hideminor": "Slėpti nedidelius pakeitimus.",
+ "apihelp-feedcontributions-param-showsizediff": "Rodyti dydžio skirtumą tarp keitimų.",
+ "apihelp-feedcontributions-example-simple": "Gražinti įnašus vartotojui <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Gražina naujausių pakeitimų srautą.",
+ "apihelp-feedrecentchanges-param-feedformat": "Srauto formatas.",
+ "apihelp-feedrecentchanges-param-limit": "Maksimalus grąžinamų rezultatų skaičius.",
+ "apihelp-feedrecentchanges-param-from": "Rodyti pakeitimus nuo tada.",
+ "apihelp-feedrecentchanges-param-hideminor": "Slėpti smulkius pakeitimus.",
+ "apihelp-feedrecentchanges-param-hidebots": "Slėpti robotų pakeitimus.",
+ "apihelp-feedrecentchanges-param-hideanons": "Slėpti vartotojų anonimų pakeitimus.",
+ "apihelp-feedrecentchanges-param-hideliu": "Slėpti užsiregistravusių vartotojų pakeitimus.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Slėpti pakeitimus, atliktus dabartinio vartotojo.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Slėpti kategorijos narystės pakeitimus.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtruoti pagal žymę.",
+ "apihelp-feedrecentchanges-param-target": "Rodyti tik keitimus puslapiuose, pasiekiamuose iš šio puslapio.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Vietoj to, rodyti pakeitimus puslapyje, susietame su pasirinktu puslapiu.",
+ "apihelp-feedrecentchanges-param-categories": "Rodyti pakeitimus tik puslapiuose, esančiuose visuose šiuose kategorijose.",
+ "apihelp-feedrecentchanges-param-categories_any": "Vietoj to, rodyti tik pakeitimus puslapiuse, esančiuose bet kurioje iš kategorijų.",
+ "apihelp-feedrecentchanges-example-simple": "Parodyti naujausius keitimus.",
+ "apihelp-feedrecentchanges-example-30days": "Rodyti naujausius pakeitimus per 30 dienų.",
+ "apihelp-feedwatchlist-summary": "Gražina stebimųjų sąrašo srautą.",
+ "apihelp-feedwatchlist-param-feedformat": "Srauto formatas.",
+ "apihelp-feedwatchlist-example-default": "Rodyti stebimųjų sąrašo srautą.",
+ "apihelp-feedwatchlist-example-all6hrs": "Rodyti visus pakeitimus stebimuose puslapiuose per paskutines 6 valandas.",
+ "apihelp-filerevert-param-comment": "Įkėlimo komentaras.",
+ "apihelp-help-summary": "Rodyti pagalbą pasirinktiems moduliams.",
+ "apihelp-help-example-main": "Pagalba pagrindiniam moduliui.",
+ "apihelp-help-example-recursive": "Visa pagalba viename puslapyje.",
+ "apihelp-help-example-help": "Pačio pagalbos modulio pagalba.",
+ "apihelp-imagerotate-summary": "Pasukti viena ar daugiau paveikslėlių.",
+ "apihelp-imagerotate-param-rotation": "Kiek laipsnių pasukti paveikslėlį pagal laikrodžio rodyklę.",
+ "apihelp-imagerotate-example-simple": "Pasukti <kbd>File:Example.png</kbd> <kbd>90</kbd> laipsnių.",
+ "apihelp-imagerotate-example-generator": "Pasukti visus paveikslėlius <kbd>Category:Flip</kbd> <kbd>180</kbd> laipsnių.",
+ "apihelp-import-param-xml": "XML failas įkeltas.",
+ "apihelp-login-param-name": "Vartotojo vardas.",
+ "apihelp-login-param-password": "Slaptažodis.",
+ "apihelp-login-param-domain": "Domenas (neprivaloma).",
+ "apihelp-login-example-login": "Prisijungti.",
+ "apihelp-logout-summary": "Atsijungti ir išvalyti sesijos duomenis.",
+ "apihelp-logout-example-logout": "Atjungti dabartinė vartotoją.",
+ "apihelp-managetags-example-delete": "Ištrinti <kbd>vandlaism</kbd> žymę su priežastimi <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Aktyvuoti žymę pavadinimu <kbd>spam</kbd> su priežastimi <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Išjungti žymę pavadinimu <kbd>spam</kbd> su priežastimi <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Sujungti puslapio istorijas.",
+ "apihelp-mergehistory-param-reason": "Istorijos sujungimo priežastis.",
+ "apihelp-mergehistory-example-merge": "Sujungti visą <kbd>Oldpage</kbd> istoriją į <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Perkelti puslapį.",
+ "apihelp-move-param-to": "Pavadinimas, į kuri pervadinamas puslapis.",
+ "apihelp-move-param-reason": "Pervadinimo priežastis.",
+ "apihelp-move-param-movetalk": "Pervadinti aptarimo puslapį, jei jis egzistuoja.",
+ "apihelp-move-param-noredirect": "Nekurti nukreipimo.",
+ "apihelp-move-param-watch": "Pridėti puslapį ir nukreipimą į dabartinio vartotojo stebimųjų sąrašą.",
+ "apihelp-move-param-unwatch": "Pašalinti puslapį ir nukreipimą iš dabartinio vartotojo stebimųjų sąrašo.",
+ "apihelp-move-param-ignorewarnings": "Ignuoruoti bet kokius įspėjimus.",
+ "apihelp-move-example-move": "Perkelti <kbd>Badtitle</kbd> į <kbd>Goodtitle</kbd> nepaliekant nukreipimo.",
+ "apihelp-opensearch-summary": "Ieškoti viki naudojant OpenSearch protokolą.",
+ "apihelp-opensearch-param-limit": "Maksimalus grąžinamas rezultatų skaičius.",
+ "apihelp-opensearch-example-te": "Rasti puslapius prasidedančius su <kbd>Te</kbd>.",
+ "apihelp-options-example-reset": "Nustatyti visus pageidavimus iš naujo.",
+ "apihelp-options-example-change": "Keisti <kbd>skin</kbd> ir <kbd>hideminor</kbd> pageidavimus.",
+ "apihelp-options-example-complex": "Nustatyti visus pageidavimus iš naujo, tada nustatyti <kbd>skin</kbd> ir <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Gauti informaciją apie API modulius.",
+ "apihelp-protect-example-protect": "Apsaugoti puslapį.",
+ "apihelp-query-param-list": "Kurios sąrašus gauti.",
+ "apihelp-query-param-meta": "Kokius metaduomenis gauti.",
+ "apihelp-query+allcategories-param-dir": "Rūšiavimo kryptis.",
+ "apihelp-query+allcategories-param-min": "Gražinti tik kategorijas, kuriuose yra bent tiek narių.",
+ "apihelp-query+allcategories-param-max": "Gražinti tik kategorijas, kuriuose yra iki tiek narių.",
+ "apihelp-query+allcategories-param-limit": "Kiek kategorijų gražinti.",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Prideda puslapių kategorijoje skaičių.",
+ "apihelp-query+alldeletedrevisions-param-from": "Pradėti sąrašą šiuo pavadinimu.",
+ "apihelp-query+alldeletedrevisions-param-to": "Sustabdyti sąrašą ties šiuo pavadinimu.",
+ "apihelp-query+alldeletedrevisions-example-user": "Sąrašas paskutinių 50 ištrintų indėlių pagal vartotoją\n<kbd>Pavyzdys</kbd>.",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Prideda failo pavadinimą.",
+ "apihelp-query+allfileusages-param-limit": "Kiek iš viso gražinti objektų.",
+ "apihelp-query+allfileusages-example-unique": "Pateikti unikalius failų pavadinimus.",
+ "apihelp-query+allfileusages-example-unique-generator": "Gauna visus failų pavadinimus, paženklinant trūkstamus.",
+ "apihelp-query+allfileusages-example-generator": "Gauti puslapius, kuriuose yra failai.",
+ "apihelp-query+allimages-param-sort": "Pagal ką rūšiuoti.",
+ "apihelp-query+allimages-param-limit": "Kiek iš viso gražinti paveikslėlių.",
+ "apihelp-query+allimages-example-B": "Rodyti failų sąrašą, pradedant raide <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Rodyti neseniai įkeltų failų sąrašą, panašu į [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Rodyti sąrašą failų su MIME tipu <kbd>image/png</kbd> arba <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Rodyti informaciją apie 4 failus, pradedant raide <kbd>T</kbd>.",
+ "apihelp-query+alllinks-param-prop": "Kokią informaciją įtraukti:",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Prideda nuorodos pavadinimą.",
+ "apihelp-query+alllinks-param-limit": "Kiek objektų iš viso gražinti.",
+ "apihelp-query+allmessages-param-lang": "Gražinti pranešimus šia kalba.",
+ "apihelp-query+allmessages-param-from": "Gražinti pranešimus, pradedant šiuo pranešimu.",
+ "apihelp-query+allmessages-param-to": "Gražinti pranešimus, baigiant šiuo pranešimu.",
+ "apihelp-query+allrevisions-param-namespace": "Rodyti puslapius tik šioje vardų srityje.",
+ "apihelp-query+mystashedfiles-param-limit": "Kiek gauti failų.",
+ "apihelp-query+allusers-param-prop": "Kokią informaciją įtraukti:",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Nurodo visas grupes, kuriuose vartotojas yra automatiškai.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Nurodo teises, kurias turi vartotojas.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Prideda vartotojo pakeitimų skaičių.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Prideda laiko žymą, nurodančia kada vartotojas prisiregistravo, jei prieinama (gali būti tuščias).",
+ "apihelp-query+allusers-param-limit": "Kiek viso gražinti vartotojų vardų.",
+ "apihelp-query+allusers-param-witheditsonly": "Nurodyti tik vartotojus, kurie atliko keitimus.",
+ "apihelp-query+allusers-param-activeusers": "Nurodyti tik vartotojus, kurie buvo aktyvus per {{PLURAL:$1|paskutinę dieną|paskutines $1 dienas}}.",
+ "apihelp-query+allusers-example-Y": "Nurodyti vartotojus, pradedant nuo <kbd>Y</kbd>.",
+ "apihelp-query+backlinks-summary": "Rasti visus puslapius, kurie nukreipia į pateiktą puslapį.",
+ "apihelp-query+backlinks-example-simple": "Rodyti nuorodas <kbd>Pagrindinis puslapis</kbd>.",
+ "apihelp-query+blocks-summary": "Nurodyti visus užblokuotus vartotojus ir IP adresus.",
+ "apihelp-query+blocks-param-limit": "Maksimalus nurodomų blokavimų skaičius.",
+ "apihelp-query+blocks-paramvalue-prop-id": "Prideda bloko ID.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Prideda užblokuoto vartotojo vardą.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Prideda užblokuoto vartotojo ID.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Prideda užblokuoto vartotojo vardą.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Prideda užblokuoto vartotojo ID.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Prideda blokavimo laiko žymę.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Prideda blokavimo pabaigos laiko žymes.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Prideda blokavimo priežastį.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Prideda blokavimo paveiktų IP adresų diapazoną.",
+ "apihelp-query+blocks-example-simple": "Nurodyti blokavimus.",
+ "apihelp-query+blocks-example-users": "Nurodo vartotojų <kbd>Alice</kbd> ir <kbd>Bob</kbd> blokavimus.",
+ "apihelp-query+categories-summary": "Nurodo visas kategorijas, kurioms priklauso puslapiai.",
+ "apihelp-query+categories-param-show": "Kokias kategorijas rodyti.",
+ "apihelp-query+categories-param-limit": "Kiek kategorijų gražinti.",
+ "apihelp-query+categories-param-categories": "Nurodyti tik šias kategorijas. Naudinga, kai norima patikrinti ar tam tikras puslapis yra tam tikroje kategorijoje.",
+ "apihelp-query+categories-example-simple": "Gauti sąrašą kategorijų, kurioms priklauso puslapis <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categories-example-generator": "Gauti informaciją apie visas kategorijas, panaudotas <kbd>Albert Einstein</kbd> puslapyje.",
+ "apihelp-query+categoryinfo-summary": "Gražina informaciją apie pateiktas kategorijas.",
+ "apihelp-query+categoryinfo-example-simple": "Gauti informaciją apie <kbd>Category:Foo</kbd> ir <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Nurodyti visus puslapius pateiktoje kategorijoje.",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Prideda puslapio ID.",
+ "apihelp-query+categorymembers-param-limit": "Maksimalus grąžinamų puslapių skaičius.",
+ "apihelp-query+categorymembers-param-startsortkey": "Vietoj to, naudoti $1starthexsortkey",
+ "apihelp-query+categorymembers-param-endsortkey": "Vietoj to, naudoti $1endhexsortkey",
+ "apihelp-query+categorymembers-example-simple": "Gauti pirmus 10 puslapiu iš <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Gauti puslapių informaciją apie pirmus 10 puslapių iš <kbd>Category:Physics</kbd>.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Režimas|Režimai}}: $2",
+ "apihelp-query+duplicatefiles-param-limit": "Kiek pasikartojančių failų gražinti.",
+ "apihelp-query+duplicatefiles-example-simple": "Ieškoti [[:File:Albert Einstein Head.jpg]] dublikatų.",
+ "apihelp-query+duplicatefiles-example-generated": "Ieškoti pasikartojančių visuose failuose.",
+ "apihelp-query+embeddedin-param-title": "Pavadinimas paieškai. Negali būti naudojamas kartu su $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "Puslapio ID paieškai. Negali būti naudojamas kartu su $1title.",
+ "apihelp-query+embeddedin-param-limit": "Kiek puslapių iš viso gražinti.",
+ "apihelp-query+extlinks-param-limit": "Kiek nuorodų grąžinti.",
+ "apihelp-query+extlinks-example-simple": "Gauti sąrašą <kbd>Main Page</kbd> išorinių nuorodų.",
+ "apihelp-query+exturlusage-param-prop": "Kokią informaciją įtraukti:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Prideda puslapio ID.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Prideda URL, panaudota puslapyje.",
+ "apihelp-query+exturlusage-param-limit": "Kiek puslapių gražinti.",
+ "apihelp-query+exturlusage-example-simple": "Rodyti puslapius, nurodančius į <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-param-prop": "Kokią paveikslėlio informaciją gauti:",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Prideda laiko žymę įkeltai versijai.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Prideda vartotoją, kuris įkėlė paveikslėlio versiją.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Prideda paveikslėlio dydžio informaciją baitais ir aukštį, plotį ir puslapių skaičių (jei taikoma).",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Prideda paveikslėlio MIME.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Prideda paveikslėlio medijos tipą.",
+ "apihelp-query+filearchive-example-simple": "Rodyti visų ištrintų failų sąrašą.",
+ "apihelp-query+fileusage-param-prop": "Kurias savybes gauti:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "Kiekvieno puslapio ID.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Kiekvieno puslapio pavadinimas.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Pažymėti jei puslapis yra peradresavimas.",
+ "apihelp-query+fileusage-param-limit": "Kiek gražinti.",
+ "apihelp-query+fileusage-example-simple": "Gauti sąrašą puslapių, kurie naudoja [[:File:Example.jpg]].",
+ "apihelp-query+fileusage-example-generator": "Gauti informaciją apie puslapius, kurie naudoja [[:File:Example.jpg]].",
+ "apihelp-query+imageinfo-summary": "Gražina failo informaciją ir įkėlimų istoriją.",
+ "apihelp-query+imageinfo-param-prop": "Kurią failo informaciją gauti:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Prideda laiko žymę įkeltai versijai.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Prideda vartotoją, kuris įkėlę kiekvieną failo versiją.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Prideda vartotojo ID, kuris įkėlė kiekvieną failo versiją.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Versijos komentaras.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Prideda MIME failo tipą.",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Prideda failo medijos tipą.",
+ "apihelp-query+imageinfo-param-urlheight": "Panašu į $1urlwidth.",
+ "apihelp-query+images-param-limit": "Kiek failų gražinti.",
+ "apihelp-query+images-param-images": "Nurodyti tik šiuos failus. Naudinga, kai norima patikrinti ar tam tikras puslapis turi tam tikrą failą.",
+ "apihelp-query+images-example-simple": "Gauti sąrašą failų, kurie naudojami [[Main Page]].",
+ "apihelp-query+images-example-generator": "Gauti informaciją apie failus, kurie yra naudojami [[Main Page]].",
+ "apihelp-query+imageusage-summary": "Rasti visus puslapius, kurie naudoja duotą paveikslėlio pavadinimą.",
+ "apihelp-query+info-summary": "Gauti pagrindinę puslapio informaciją.",
+ "apihelp-query+info-param-prop": "Kokias papildomas savybes gauti:",
+ "apihelp-query+info-paramvalue-prop-protection": "Nurodyti kiekvieno puslapio apsaugos lygį.",
+ "apihelp-query+info-paramvalue-prop-watched": "Kiekvieno puslapio stebėjimo būsena.",
+ "apihelp-query+info-paramvalue-prop-watchers": "Stebėtojų skaičius, jei leidžiama.",
+ "apihelp-query+info-paramvalue-prop-readable": "Ar vartotojas gali skaityti šį puslapį.",
+ "apihelp-query+info-paramvalue-prop-preload": "Pateikia tekstą, gražinta EditFormPreloadText.",
+ "apihelp-query+info-example-simple": "Gauti informaciją apie puslapį <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Gauti bendrą ir apsaugos informaciją apie <kbd>Main Page</kbd> puslapį.",
+ "apihelp-query+iwbacklinks-param-prop": "Kurias savybes gauti:",
+ "apihelp-query+iwbacklinks-example-simple": "Gauti puslapius, nurodančius į [[wikibooks:Test]].",
+ "apihelp-query+iwbacklinks-example-generator": "Gauti informaciją apie puslapius, nurodančius į [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Prideda visą URL.",
+ "apihelp-query+langbacklinks-param-lang": "Kalbos nuorodos kalba.",
+ "apihelp-query+langbacklinks-param-limit": "Kiek puslapių iš viso gražinti.",
+ "apihelp-query+langbacklinks-param-prop": "Kurias savybes gauti:",
+ "apihelp-query+langbacklinks-example-simple": "Gauti puslapius, nurodančius į [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Gauti informaciją apie puslapius, nurodančius į [[:fr:Test]].",
+ "apihelp-query+langlinks-param-url": "Ar gauti visą URL (negali būti naudojamas su <var>$1prop</var>).",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Prideda visą URL.",
+ "apihelp-query+links-param-limit": "Kiek nuorodų grąžinti.",
+ "apihelp-query+links-example-simple": "Gauti nuorodas iš puslapio <kbd>Main Page</kbd>",
+ "apihelp-query+links-example-generator": "Gauti informaciją apie puslapių nuorodas puslapyje <kbd>Main Page</kbd>.",
+ "apihelp-query+linkshere-summary": "Rasti visus puslapius, kurie nurodo į pateiktus puslapius.",
+ "apihelp-query+linkshere-param-prop": "Kurias savybes gauti:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "Kiekvieno puslapio ID.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Kiekvieno puslapio pavadinimas.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Pažymėti jei puslapis yra peradresavimas.",
+ "apihelp-query+linkshere-param-limit": "Kiek gražinti.",
+ "apihelp-query+linkshere-param-show": "Rodyti tik elementus, atitinkančius šiuos kriterijus:\n;redirect:Rodyti tik nukreipimus.\n;!redirect:Rodyti tik ne nukreipimus.",
+ "apihelp-query+linkshere-example-simple": "Gauti sąrašą puslapių, kurie nurodo į [[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "Gauti informaciją apie puslapius, kurie nurodo į [[Main Page]].",
+ "apihelp-query+logevents-summary": "Gauti įvykius iš žurnalų.",
+ "apihelp-query+logevents-param-prop": "Kurias savybes gauti:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Prideda žurnalo įvykio ID.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Prideda žurnalo įvykio tipą.",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "Kiekvieno puslapio ID.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Kiekvieno puslapio pavadinimas.",
+ "apihelp-query+transcludedin-param-limit": "Kiek gražinti.",
+ "apihelp-query+usercontribs-summary": "Gauti visus vartotojo keitimus.",
+ "apihelp-query+usercontribs-param-limit": "Maksimalus gražinamų įnašų skaičius.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Prideda keitimo komentarą.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Prideda naują keitimo dydį.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Prideda vartotojo tikrą vardą.",
+ "apihelp-query+userinfo-example-simple": "Gauti informacijos apie dabartinį vartotoją.",
+ "apihelp-query+userinfo-example-data": "Gauti papildomos informacijos apie dabartinį vartotoją.",
+ "apihelp-query+users-summary": "Gauti informacijos apie vartotojų sąrašą.",
+ "apihelp-query+users-param-prop": "Kokią informaciją įtraukti:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Pažymi ar vartotojas užblokuotas, kas tai padarė ir dėl kokios priežasties.",
+ "apihelp-query+users-paramvalue-prop-groups": "Nurodo grupes, kurioms priklauso kiekvienas vartotojas.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Nurodo visas grupes, kuriuose vartotojas yra automatiškai kaip narys.",
+ "apihelp-query+users-paramvalue-prop-rights": "Nurodo visas teises, kurias turi kiekvienas vartotojas.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Prideda vartotojo keitimų skaičių.",
+ "apihelp-query+users-paramvalue-prop-registration": "Prideda vartotojo registracijos laiko žymę.",
+ "apihelp-query+users-param-users": "Sąrašas vartotojų, kurių informaciją gauti.",
+ "apihelp-query+users-param-userids": "Vartotojų ID sąrašas, kurių informaciją gauti:",
+ "apihelp-query+users-param-token": "Vietoj to naudoti <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-query+users-example-simple": "Gražinti informaciją apie vartotoją <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-param-user": "Nurodyti tik pakeitimus, atliktus šio vartotojo.",
+ "apihelp-query+watchlist-param-excludeuser": "Nenurodyti pakeitimų, kuriuos atliko šis vartotojas.",
+ "apihelp-query+watchlist-param-limit": "Kiek viso rezultatų gražinti vienai užklausai.",
+ "apihelp-query+watchlist-param-prop": "Kokias papildomas savybes gauti:",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Prideda puslapio pavadinimą.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Prideda naują vartotoją, kuris atliko pakeitimą.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Prideda vartotojo ID, kuris atliko pakeitimą.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Prideda keitimo komentarą.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Prideda keitimo laiko žymę.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Prideda naują ir seną puslapio ilgius.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Prideda laiko žymę, kada vartotojui buvo pranešta apie pakeitimą.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Prideda žurnalo informaciją, kai reikia.",
+ "apihelp-query+watchlist-param-type": "Kokios keitimų tipus rodyti:",
+ "apihelp-query+watchlist-paramvalue-type-external": "Išoriniai keitimai.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Puslapio sukūrimai.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Žurnalo įrašai.",
+ "apihelp-resetpassword-param-user": "Iš naujo nustatomas vartotojas.",
+ "apihelp-resetpassword-param-email": "Iš naujo nustatomo vartotojo el. pašto adresas.",
+ "apihelp-setpagelanguage-summary": "Keisti puslapio kalbą.",
+ "apihelp-setpagelanguage-param-reason": "Keitimo priežastis.",
+ "apihelp-stashedit-param-title": "Puslapio pavadinimas buvo redaguotas.",
+ "apihelp-stashedit-param-sectiontitle": "Naujo skyriaus pavadinimas.",
+ "apihelp-stashedit-param-text": "Puslapio turinys.",
+ "apihelp-stashedit-param-summary": "Keisti santrauką.",
+ "apihelp-tag-param-reason": "Keitimo priežastis.",
+ "apihelp-unblock-summary": "Atblokuoti naudotoją.",
+ "apihelp-unblock-param-reason": "Atblokavimo priežastis.",
+ "apihelp-unblock-example-id": "Atblokuoti blokavimo ID #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "Atblokuoti vartoją <kbd>Bob</kbd> su priežastimi <kbd>Sorry Bob</kbd>.",
+ "apihelp-undelete-param-title": "Atkuriamo puslapio pavadinimas.",
+ "apihelp-undelete-param-reason": "Atkūrimo priežastis.",
+ "apihelp-undelete-example-page": "Atkurti puslapį <kbd>Main Page</kbd>.",
+ "apihelp-upload-param-watch": "Stebėti šį puslapį.",
+ "apihelp-upload-param-ignorewarnings": "Ignuoruoti bet kokius įspėjimus.",
+ "apihelp-upload-param-file": "Failo turinys.",
+ "apihelp-upload-param-url": "URL, iš kurio gauti failą.",
+ "apihelp-upload-example-url": "Įkelti iš URL.",
+ "apihelp-upload-example-filekey": "Baigti įkėlimą, kuris nepavyko dėl įspėjimų.",
+ "apihelp-userrights-summary": "Keisti vartotoju grupės narystę.",
+ "apihelp-userrights-param-user": "Vartotojo vardas.",
+ "apihelp-userrights-param-userid": "Vartotojo ID.",
+ "apihelp-userrights-param-add": "Pridėti vartotoją į šias grupes.",
+ "apihelp-userrights-param-remove": "Pašalinti vartotoją iš šių grupių.",
+ "apihelp-userrights-param-reason": "Keitimo priežastis.",
+ "apihelp-watch-summary": "Pridėti ar pašalinti puslapius iš dabartinio vartotojo stebimųjų sąrašo.",
+ "apihelp-watch-example-watch": "Stebėti puslapį <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Nebestebėti puslapio <kbd>Main Page</kbd>.",
+ "api-format-title": "MedijaViki API rezultatas",
+ "api-format-prettyprint-status": "Šis atsakymas būtų gražintas su HTTP statusu $1 $2.",
+ "api-help-title": "MedijaViki API pagalba",
+ "api-help-main-header": "Pagrindinis modulis",
+ "api-help-source": "Šaltinis: $1",
+ "api-help-source-unknown": "Šaltinis: <span class=\"apihelp-unknown\">nežinomas</span>",
+ "api-help-license": "Licencija: [[$1|$2]]",
+ "api-help-license-noname": "Licencija: [[$1|Žiūrėti nuorodą]]",
+ "api-help-license-unknown": "Licencija: <span class=\"apihelp-unknown\">nežinoma</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parametras|Parametrai}}:",
+ "api-help-param-required": "Šis parametras yra reikalingas.",
+ "api-help-datatypes-header": "Duomenų tipai",
+ "api-help-param-limit": "Leidžiama ne daugiau nei $1.",
+ "api-help-param-limit2": "Leidžiama ne daugiau nei $1 ($2 robotams).",
+ "api-help-param-default": "Numatytasis: $1",
+ "api-help-param-default-empty": "Numatytasis: <span class=\"apihelp-empty\">(tuščia)</span>",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(nėra aprašymo)</span>",
+ "api-help-examples": "{{PLURAL:$1|Pavyzdys|Pavyzdžiai}}:",
+ "api-help-permissions": "{{PLURAL:$1|Leidimas|Leidimai}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Suteikta}}: $2",
+ "apierror-articleexists": "Straipsnis, kurį bandėte sukurti, jau yra sukurtas.",
+ "apierror-autoblocked": "Jūsų IP adresas buvo automatiškai užblokuotas, nes jis buvo naudojamas užblokuoto vartotojo.",
+ "apierror-badgenerator-unknown": "Nežinomas <kbd>generator=$1</kbd>.",
+ "apierror-badip": "IP parametras negalimas.",
+ "apierror-badquery": "Negalima užklausa.",
+ "apierror-badurl": "Negalima reikšmė „$2“ URL parametrui <var>$1</var>.",
+ "apierror-blockedfrommail": "Jus buvote užblokuotas nuo el. laiško siuntimo.",
+ "apierror-blocked": "Jus buvote užblokuotas nuo redagavimo.",
+ "apierror-botsnotsupported": "Ši sąsaja negali būti palaikoma robotams.",
+ "apierror-cannotreauthenticate": "Veiksmas negalimas, nes jūsų tapatybė negali būti patvirtinta.",
+ "apierror-cannotviewtitle": "Jūs negalite peržiūrėti $1.",
+ "apierror-cantblock": "Neturite teisės blokuoti vartotojus.",
+ "apierror-cantchangecontentmodel": "Neturite teisės pakeisti puslapio turinio modelį.",
+ "apierror-cantimport-upload": "Neturite teisės importuoti įkeltų puslapių.",
+ "apierror-cantimport": "Neturite teisės importuoti puslapių.",
+ "apierror-copyuploadbadurl": "Įkėlimas neleidžiamas iš šio URL.",
+ "apierror-databaseerror": "[$1] Duomenų bazės užklausos klaida.",
+ "apierror-emptynewsection": "Neįmanoma kurti naujų tuščių skyrių.",
+ "apierror-filedoesnotexist": "Failas neegzistuoja.",
+ "apierror-filetypecannotberotated": "Failo tipas negali būti pasuktas.",
+ "apierror-import-unknownerror": "Nežinoma klaida importuojant: $1.",
+ "apierror-invalidcategory": "Kategorijos pavadinimas, kurį įvedėte, yra negalimas.",
+ "apierror-invalidparammix": "{{PLURAL:$2|parametrai}} $1 negali būti naudojami kartu.",
+ "apierror-invalidtitle": "Blogas pavadinimas „$1“.",
+ "apierror-invaliduser": "Negalimas vartotojo vardas „$1“.",
+ "apierror-invaliduserid": "Vartotojo ID <var>$1</var> nėra galimas.",
+ "apierror-missingtitle": "Puslapis, kurį nurodėte, neegzistuoja.",
+ "apierror-missingtitle-byname": "Puslapis $1 neegzistuoja",
+ "apierror-multpages": "<var>$1</var> gali būti naudojamas tik su vienu puslapiu.",
+ "apierror-mustbeloggedin-generic": "Turite būti prisijungęs.",
+ "apierror-mustbeloggedin-linkaccounts": "Turite būti prisijungęs, kad galėtumėte susieti paskyras.",
+ "apierror-mustbeloggedin": "Turite būti prisijungęs, kad $1.",
+ "apierror-nochanges": "Neprašyta jokių keitimų.",
+ "apierror-noedit-anon": "Anoniminiai vartotojai negali redaguoti puslapių.",
+ "apierror-noedit": "Neturite teisės redaguoti puslapius.",
+ "apierror-nosuchlogid": "Nėra žurnalo įrašo su ID $1.",
+ "apierror-nosuchpageid": "Nėra puslapio su ID $1.",
+ "apierror-nosuchsection": "Nėra skyriaus $1.",
+ "apierror-nosuchsection-what": "$2 nėra sekcijos $1.",
+ "apierror-nosuchuserid": "Nėra vartotojo su ID $1.",
+ "apierror-pagelang-disabled": "Puslapio kalbos keitimas nėra leidžiamas šioje viki.",
+ "apierror-paramempty": "Parametras <var>$1</var> negali būti tusčiau.",
+ "apierror-permissiondenied": "Neturite leidimo $1.",
+ "apierror-permissiondenied-generic": "Teisė nesuteikta.",
+ "apierror-protect-invalidaction": "Negalimas apsaugos tipas „$1“.",
+ "apierror-protect-invalidlevel": "Negalimas apsaugos lygis „$1“.",
+ "apierror-readonly": "Viki šiuo metu yra skaitymo režime.",
+ "apierror-sectionreplacefailed": "Nepavyko sujungti atnaujinto skyriaus.",
+ "apierror-specialpage-cantexecute": "Neturite teisės peržiūrėti šio specialaus puslapio rezultatus.",
+ "apierror-stashwrongowner": "Neteisingas savininkas: $1",
+ "apierror-timeout": "Serveris neatsakė per numatytą laiką.",
+ "apierror-unknownerror-nocode": "Nežinoma klaida.",
+ "apierror-unknownerror": "Nežinoma klaida: „$1“.",
+ "apierror-unknownformat": "Neatpažintas formatas „$1“.",
+ "apierror-unrecognizedparams": "{{PLURAL:$2|Neatpažintas parametras|Neatpažinti parametrai}}: $1.",
+ "apierror-writeapidenied": "Negalite redaguoti šios viki per API.",
+ "apiwarn-deprecation-httpsexpected": "panaudotas HTTP, kai buvo tikėtasi HTTPS.",
+ "apiwarn-invalidcategory": "„$1“ nėra kategorija.",
+ "apiwarn-invalidtitle": "„$1“ nėra galimas pavadinimas.",
+ "apiwarn-notfile": "„$1“ nėra failas.",
+ "apiwarn-tokennotallowed": "Veiksmas „$1“ nėra leidžiamas dabartiniam vartotojui.",
+ "apiwarn-validationfailed-badpref": "negalimas nustatymas.",
+ "apiwarn-validationfailed": "Patvirtinimo klaida skirta <kbd>$1</kbd>: $2",
+ "api-feed-error-title": "Klaida ($1)",
+ "api-credits-header": "Kreditai"
+}
diff --git a/www/wiki/includes/api/i18n/lv.json b/www/wiki/includes/api/i18n/lv.json
new file mode 100644
index 00000000..270025ce
--- /dev/null
+++ b/www/wiki/includes/api/i18n/lv.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "Papuass",
+ "Silraks"
+ ]
+ },
+ "apihelp-block-summary": "Bloķēt lietotāju",
+ "apihelp-block-param-reason": "Bloķēšanas iemesls:",
+ "apihelp-delete-summary": "Dzēst lapas",
+ "apihelp-emailuser-summary": "Sūtīt e-pastu lietotājam",
+ "apihelp-userrights-param-userid": "Lietotāja ID:",
+ "apierror-nosuchuserid": "Nav lietotāja ar ID $1."
+}
diff --git a/www/wiki/includes/api/i18n/lzh.json b/www/wiki/includes/api/i18n/lzh.json
new file mode 100644
index 00000000..970bea94
--- /dev/null
+++ b/www/wiki/includes/api/i18n/lzh.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "RalfX"
+ ]
+ },
+ "apihelp-feedcontributions-param-toponly": "僅示至新審之纂"
+}
diff --git a/www/wiki/includes/api/i18n/mg.json b/www/wiki/includes/api/i18n/mg.json
new file mode 100644
index 00000000..58d50788
--- /dev/null
+++ b/www/wiki/includes/api/i18n/mg.json
@@ -0,0 +1,37 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jagwar"
+ ]
+ },
+ "apihelp-main-summary": "",
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [https://www.mediawiki.org/wiki/API:Main_page Torohevitra be kokoa]\n* [https://www.mediawiki.org/wiki/API:FAQ Fanontaniana miverina matetika]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lisitry ny mailaka manaraka]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Filazana API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Baogy & hataka]\n</div>\n<strong>Status:</strong> \nTokony mandeha avokoa ny fitaovana aseho eto amin'ity pehy ity, na dia izany aza mbola am-panamboarana ny API ary mety hiova na oviana na oviana. Araho amin'ny alalan'ny fisoratana ny mailakao ao amin'ny [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce lisitra fampielezana] ny fiovana.\n\n<strong>Hataka diso:</strong> \nRehefa alefa ao amin'i API ny hata, ho alefa miaraka amin'ny lakile \"MediaWiki-API-Error\" ny header HTTP ary samy homen-tsanda mitovy ny header ary ny kaodin-kadisoana. Ho an'ny torohay fanampiny dia jereo https://www.mediawiki.org/wiki/API:Errors_and_warnings.",
+ "apihelp-main-param-action": "Inona ny zavatra ho atao.",
+ "apihelp-main-param-format": "Format mivoaka",
+ "apihelp-block-param-user": "Anaram-pikambana, adiresy IP na valan' IP hosakanana.",
+ "apihelp-block-param-expiry": "Fitaom-pisasarana. Mety miovaova(e.g. <kbd>5 volana</kbd> na <kbd>herinandro 2</kbd>) na voafaritra (e.g. <kbd>2014-09-18T12:34:56Z</kbd>). Raha atao <kbd>tsiefa</kbd>, <kbd>tsy fantatra</kbd>, na <kbd>mandrakizay</kbd>, dia tsy hitsahatra mihitsy ilay sakana.",
+ "apihelp-block-param-reason": "Antom-panakanana",
+ "apihelp-block-param-anononly": "Mpikambana tsy nisoratra anarana ihany no sakanana (izany hoe aza mamela fiovan'olona tsy nisoratra anarana avy amin'ity adiresy IP ity).",
+ "apihelp-block-param-nocreate": "Hanakana famoronan-kaonty.",
+ "apihelp-block-param-autoblock": "Manakana ny adiresy IP farany nampiasaina, ary izay adiresy IP mety hidirany.",
+ "apihelp-block-param-noemail": "Hanakana ny mpikambana tsy handefa mailaka amin'ny alalan'ny wiki (Mila ny zo <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Hanafina ny anaram-pikambana amin'ny laogim-panakanana (mila ny zo <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Hamela ny mpikambana hanova ny pejin-dresany (miankina amin'ny <var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>)",
+ "apihelp-block-param-reblock": "Raha efa nosakanana ilay mpikambana, itsahina ilay sakana efa misy.",
+ "apihelp-block-param-watchuser": "Hijery ny adiresy IP ary ny pejin-dresak'ilay mpikambana.",
+ "apihelp-block-example-ip-simple": "Hanakana ny adiresy IP <kbd>192.0.2.5</kbd> mandritry ny telo andro miaraka amin'ny antony <kbd>Filazana voalohany</kbd>.",
+ "apihelp-compare-param-fromtitle": "Lohateny voalohany ampitahaina.",
+ "apihelp-compare-param-fromid": "ID pejy voalohany ampitahaina.",
+ "apihelp-compare-param-fromrev": "Versions voalohany ampitahaina.",
+ "apihelp-compare-param-totitle": "Lohateny faharoa ampitahaina.",
+ "apihelp-compare-param-toid": "ID pejy faharoa ampitahaina.",
+ "apihelp-compare-param-torev": "Versiona faharoa ampitahaina.",
+ "apihelp-compare-example-1": "Hamorona raki-pahasamihafan'ny versiona 1 sy 2.",
+ "apihelp-createaccount-summary": "Hamorona kaontim-pikambana vaovao.",
+ "apihelp-createaccount-param-name": "Anaram-pikambana.",
+ "apihelp-createaccount-param-password": "Tenimiafina (tsy raharahiana raha voafaritra i <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Vala ho an'ilay famantarana avy any ivelany (azo tsy fenoina).",
+ "apihelp-createaccount-param-token": "Famantarana famoronan-kaonty azo tamin'ny hataka voalohany.",
+ "apihelp-createaccount-param-email": "Adiresy imailaky ny mpikambana (azo tsy fenoina).",
+ "apihelp-createaccount-param-realname": "Tena anaran'ilay mpikambana (azo tsy fenoina)."
+}
diff --git a/www/wiki/includes/api/i18n/mk.json b/www/wiki/includes/api/i18n/mk.json
new file mode 100644
index 00000000..5af76673
--- /dev/null
+++ b/www/wiki/includes/api/i18n/mk.json
@@ -0,0 +1,433 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bjankuloski06",
+ "Macofe"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Документација]]\n* [[mw:API:FAQ|ЧПП]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Поштенски список]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Соопштенија за Извршникот]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Грешки и барања]\n</div>\n<strong>Статус:</strong> Сите ставки на страницава би требало да работат, но Извршникот сепак е во активна разработка, што значи дека може да се смени во секое време. Објавите за измени можете да ги дознавате ако се пријавите на [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ поштенскиот список „the mediawiki-api-announce“].\n\n<strong>Погрешни барања:</strong> Кога Извршникот ќе добие погрешни барања, ќе се испрати HTTP-заглавие со клучот „MediaWiki-API-Error“ и потоа на вредностите на заглавието и шифрата на грешката што ќе се појават ќе им биде зададена истата вредност. ПОвеќе информации ќе најдете на [[mw:API:Errors_and_warnings|Извршник: Грешки и предупредувања]].",
+ "apihelp-main-param-action": "Кое дејство да се изврши.",
+ "apihelp-main-param-format": "Формат на изводот.",
+ "apihelp-main-param-maxlag": "Најголемиот допуштен заостаток може да се користи кога МедијаВики е воспоставен на грозд умножен од базата. За да спречите дополнителни заостатоци од дејства, овој параметар му наложува на клиентот да почека додека заостатокот не се намали под укажаната вредност. Во случај на преголем заостаток, системт ја дава грешката со код <samp>maxlag</samp> со порака од обликот <samp>Го чекам $host: има заостаток од $lag секунди</samp>.<br />Погл. [[mw:Manual:Maxlag_parameter|Прирачник: Параметар Maxlag]]",
+ "apihelp-main-param-smaxage": "Задајте му олку секунди на заглавието за контрола HTTP-меѓускладот <code>s-maxage</code>. Грешките никогаш не се чуваат во меѓускладот.",
+ "apihelp-main-param-maxage": "Задајте му олку секунди на заглавието за контрола HTTP-меѓускладот <code>s-maxage</code>. Грешките никогаш не се чуваат во меѓускладот.",
+ "apihelp-main-param-assert": "Провери дали корисникот е најавен ако е зададено <kbd>user</kbd> или дали го има корисничкото право на бот, ако е зададено <kbd>bot</kbd>.",
+ "apihelp-main-param-requestid": "Тука внесената вредност ќе биде вклучена во извештајот. Може да се користи за разликување на барањата.",
+ "apihelp-main-param-servedby": "Вклучи го домаќинското име што го услужило барањето во исходот.",
+ "apihelp-main-param-curtimestamp": "Вклучи тековно време и време и датум во исходот.",
+ "apihelp-main-param-origin": "Кога му пристапувате на Пирлогот користејќи повеќедоменско AJAX-барање (CORS), задајте му го на ова изворниот домен. Ова мора да се вклучи во секое подготвително барање и затоа мора да биде дел од URI на барањето (не главната содржина во POST). Ова мора точно да се совпаѓа со еден од изворниците на заглавието Origin:, така што мора да е зададен на нешто како <kbd>https://mk.wikipedia.org</kbd> or <kbd>https://meta.wikimedia.org</kbd>. Ако овој параметар не се совпаѓа со заглавието <code>Origin</code>:, ќе се појави одговор 403. Ако се совпаѓа, а изворникот е на бел список (на допуштени), тогаш ќе се зададе заглавието <code>Access-Control-Allow-Origin</code>.",
+ "apihelp-main-param-uselang": "Јазик за преведување на пораките. Список на јазични кодови ќе најдете на <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> со <kbd>siprop=languages</kbd> или укажете <kbd>user</kbd> за да го користите тековно зададениот јазик корисникот, или пак укажете <kbd>content</kbd> за да го користите јазикот на содржината на ова вики.",
+ "apihelp-block-summary": "Блокирај корисник.",
+ "apihelp-block-param-user": "Корисничко име, IP-адреса или IP-опсег ако сакате да блокирате.",
+ "apihelp-block-param-expiry": "Време на истек. Може да биде релативно (на пр. <kbd>5 months</kbd> или „2 недели“) или пак апсолутно (на пр. <kbd>2014-09-18T12:34:56Z</kbd>). Ако го зададете <kbd>infinite</kbd>, <kbd>indefinite</kbd> или <kbd>never</kbd>, блокот ќе трае засекогаш.",
+ "apihelp-block-param-reason": "Причина за блокирање.",
+ "apihelp-block-param-anononly": "Блокирај само анонимни корисници (т.е. оневозможи анонимно уредување од оваа IP-адреса).",
+ "apihelp-block-param-nocreate": "Оневозможи создавање кориснички сметки.",
+ "apihelp-block-param-autoblock": "Автоматски блокирај ја последно употребената IP-адреса и сите понатамошни IP-адреси од кои лицето ќе се обиде да се најави.",
+ "apihelp-block-param-noemail": "Оневозможи му на корисникот да испаќа е-пошта преку викито. (Го бара правото code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Скриј го корисничкото име од дневникот на блокирања. (Го бара правото <code>hideuser</code>)",
+ "apihelp-block-param-allowusertalk": "Овозможи му на корисникот да си ја уредува сопствената страница за разговор (зависи од <var>[[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Ако корисникот е веќе блокиран, наметни врз постоечкиот блок.",
+ "apihelp-block-param-watchuser": "Набљудувај ја корисничката страница и страницата за разговор на овој корисник или IP-адреса",
+ "apihelp-block-example-ip-simple": "Блокирај ја IP-адресата <kbd>192.0.2.5</kbd> три дена со причината <kbd>Прва опомена</kbd>.",
+ "apihelp-block-example-user-complex": "Блокирај го корисникот <kbd>Vandal</kbd> (Вандал) бесконечно со причината <kbd>Vandal</kbd> (Вандализам) и оневозможи создавање на нови сметки и праќање е-пошта.",
+ "apihelp-checktoken-summary": "Проверка на полноважноста на шифрата од <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Тип на шифра што се испробува.",
+ "apihelp-checktoken-param-token": "Шифра што се испробува.",
+ "apihelp-checktoken-param-maxtokenage": "Најголема допуштена старост на шифрата, во секунди.",
+ "apihelp-checktoken-example-simple": "Испробај ја полноважноста на <kbd>csrf</kbd>-шифрата.",
+ "apihelp-clearhasmsg-summary": "Ја отстранува ознаката „<code>hasmsg</code>“ од тековниот корисник.",
+ "apihelp-clearhasmsg-example-1": "Отстрани ја ознаката „<code>hasmsg</code>“ од тековниот корисник",
+ "apihelp-compare-summary": "Добивање на разлика помеѓу две страници.",
+ "apihelp-compare-extended-description": "Мора да се даде бројот на преработката, насловот на страницата или пак нејзина назнака за „од“ и за „на“.",
+ "apihelp-compare-param-fromtitle": "Прв наслов за споредба.",
+ "apihelp-compare-param-fromid": "Прва назнака на страница за споредба.",
+ "apihelp-compare-param-fromrev": "Прва преработка за споредба.",
+ "apihelp-compare-param-totitle": "Втор наслов за споредба.",
+ "apihelp-compare-param-toid": "Втора назнака на страница за споредба.",
+ "apihelp-compare-param-torev": "Бтора преработка за споредба.",
+ "apihelp-compare-example-1": "Дај разлика помеѓу преработките 1 и 2",
+ "apihelp-createaccount-summary": "Создај нова корисничка сметка.",
+ "apihelp-createaccount-param-name": "Корисничко име.",
+ "apihelp-createaccount-param-password": "Лозинка (се занемарува ако е зададено <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Домен за надворешна заверка (незадолжително).",
+ "apihelp-createaccount-param-token": "Шифра за создавање сметка добиена во првото барање.",
+ "apihelp-createaccount-param-email": "Е-пошта на корисникот (незадолжително).",
+ "apihelp-createaccount-param-realname": "Вистинско име на корисникот (незадолжително).",
+ "apihelp-createaccount-param-mailpassword": "Ако му се зададе било каква вредност, тогаш на корисникот ќе му биде испратена случајна лозинка.",
+ "apihelp-createaccount-param-reason": "Незадолжителна прочина за создавање на сметката која ќе стои во дневниците.",
+ "apihelp-createaccount-param-language": "Јазичен код кој ќе биде стандарден за корисникот (незадолжително, по основно: јазикот на самото вики).",
+ "apihelp-createaccount-example-pass": "Создај го корисникот <kbd>testuser</kbd> со лозинката <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Создај го корисникот <kbd>testmailuser</kbd> и испрати случајно-создадена лозинка по е-пошта.",
+ "apihelp-delete-summary": "Избриши страница.",
+ "apihelp-delete-param-title": "Наслов на страницата што сакате да ја избришете. Не може да се користи заедно со <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Назнака на страницата што сакате да ја избришете. Не може да се користи заедно со <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Причина за бришење. Ако не се зададе, ќе се наведе автоматска причина.",
+ "apihelp-delete-param-watch": "Додај ја страницата во набљудуваните на тековниот корисник.",
+ "apihelp-delete-param-watchlist": "Безусловно додај или отстрани ја страницата од набљудуваните на тековниот корисник, користете ги нагодувањата или не ги менувајте набљудуваните.",
+ "apihelp-delete-param-unwatch": "Отстрани ја страницата од набљудуваните на тековниот корисник.",
+ "apihelp-delete-param-oldimage": "Името на страта слика за бришење според добиеното од [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Избриши ја <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Избриши ја <kbd>Main Page</kbd> со причината <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Модулот е деактивиран.",
+ "apihelp-edit-summary": "Создај или уреди страници.",
+ "apihelp-edit-param-title": "Наслов на страницата што сакате да ја уредите. Не може да се користи заедно со <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Назнака на страницата што сакате да ја уредите. Не може да се користи заедно со <var>$1title</var>.",
+ "apihelp-edit-param-section": "Број на поднасловот. <kbd>0</kbd> за првиот, <kbd>new</kbd> за нов.",
+ "apihelp-edit-param-sectiontitle": "Назив на новиот поднаслов",
+ "apihelp-edit-param-text": "Содржина на страницата.",
+ "apihelp-edit-param-summary": "Опис на уредувањето. Ова е и назив на поднасловот кога не се зададени $1section=new и $1sectiontitle.",
+ "apihelp-edit-param-tags": "Ознаки за измена што се однесуваат на преработката.",
+ "apihelp-edit-param-minor": "Ситно уредување.",
+ "apihelp-edit-param-notminor": "Неситно уредување.",
+ "apihelp-edit-param-bot": "Означи го уредувањево како ботовско.",
+ "apihelp-edit-param-basetimestamp": "Датум и време на преработката на базата, кои се користат за утврдување на спротиставености во уредувањето. Може да се добие преку [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Датум и време кога сте почнало уредувањето, кои се користат за утврдување на спротиставености во уредувањата. Соодветната вредност се добива користејќи <var>[[Special:ApiHelp/main|curtimestamp]]</var> кога ќе почнете со уредување (на пр. кога ќе се вчита содржината што ќе ја уредувате).",
+ "apihelp-edit-param-recreate": "Занемари ги грешките што се појавуваат во врска со страницата што е избришана во меѓувреме.",
+ "apihelp-edit-param-createonly": "Не ја уредувај страницата ако веќе постои.",
+ "apihelp-edit-param-nocreate": "Дај грешка ако страницата не постои.",
+ "apihelp-edit-param-watch": "Додај ја страницата во набљудуваните на тековниот корисник.",
+ "apihelp-edit-param-unwatch": "Отстрани ја страницата од набљудуваните на тековниот корисник.",
+ "apihelp-edit-param-watchlist": "Безусловно додај или отстрани ја страницата од набљудуваните на тековниот корисник, користете ги нагодувањата или не ги менувајте набљудуваните.",
+ "apihelp-edit-param-md5": "MD5-тарабата на параметарот $1text, или параметрите $1prependtext и $1appendtext поврзани. Ако е зададено, уредувањето нема да се изврши без тарабата да биде исправна.",
+ "apihelp-edit-param-prependtext": "Ставете го текстов на почетокот од страницата. Го заменува $1text.",
+ "apihelp-edit-param-appendtext": "Ставете го текстов на крајот од страницата. Го заменува $1text.\n\nКористете $1section=new наместо овој параметар за да приложите кон новиот поднаслов.",
+ "apihelp-edit-param-undo": "Отповикај ја преработкава. Ги заменува $1text, $1prependtext и $1appendtext.",
+ "apihelp-edit-param-undoafter": "Отповикај ги преработките од $1undo до оваа. Ако не е зададено, отповикај само една.",
+ "apihelp-edit-param-redirect": "Автоматски решавај пренасочувања.",
+ "apihelp-edit-param-contentformat": "Форматот за серијализација на содржината што се користи во вносниот текст.",
+ "apihelp-edit-param-contentmodel": "Содржински модел на новата содржина.",
+ "apihelp-edit-param-token": "Шифрата треба секогаш да се испраќа како последниот параметар, или барем по параметарот $1text.",
+ "apihelp-edit-example-edit": "Уреди страница",
+ "apihelp-edit-example-prepend": "Стави <kbd>_&#95;NOTOC_&#95;</kbd> пред страницата",
+ "apihelp-edit-example-undo": "Отповикај ги преработките од 13579 до 13585 со автоматски опис",
+ "apihelp-emailuser-summary": "Испрати е-пошта на корисник.",
+ "apihelp-emailuser-param-target": "На кој корисник да му се испрати е-поштата.",
+ "apihelp-emailuser-param-subject": "Наслов.",
+ "apihelp-emailuser-param-text": "Содржина.",
+ "apihelp-emailuser-param-ccme": "Прати ми примерок и мене.",
+ "apihelp-emailuser-example-email": "Испрати е-пошта на корисникот <kbd>WikiSysop</kbd> со текстот <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Ги проширува сите шаблони во викитекст.",
+ "apihelp-expandtemplates-param-title": "Наслов на страница.",
+ "apihelp-expandtemplates-param-text": "Викитекст за претворање.",
+ "apihelp-expandtemplates-param-revid": "Назнака на преработката, за <code><nowiki>{{REVISIONID}}</nowiki></code> и слични променливи.",
+ "apihelp-expandtemplates-param-prop": "Кои информации треба да ги добиете:\n\nИмајте на ум дека ако не изберете никаква вредност, исходот ќе го содржи викитекстот, но изводот ќе биде во застарен формат.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Проширениот викитекст.",
+ "apihelp-expandtemplates-param-includecomments": "Дали во изводот да се вклучени HTML-коментари.",
+ "apihelp-expandtemplates-param-generatexml": "Создај XML-дрво на расчленување (заменето со $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Прошири го викитекстот <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Дава канал со придонеси на корисник.",
+ "apihelp-feedcontributions-param-feedformat": "Формат на каналот.",
+ "apihelp-feedcontributions-param-user": "За кои корисници да се прикажуваат придонесите.",
+ "apihelp-feedcontributions-param-namespace": "По кој именски простор да се филтрираат придонесите:",
+ "apihelp-feedcontributions-param-year": "Од година (и порано):",
+ "apihelp-feedcontributions-param-month": "Од месец (и порано):",
+ "apihelp-feedcontributions-param-tagfilter": "Филтрирај придонеси што имаат ознаки.",
+ "apihelp-feedcontributions-param-deletedonly": "Прикажувај само избришани придонеси.",
+ "apihelp-feedcontributions-param-toponly": "Прикажувај само последни преработки.",
+ "apihelp-feedcontributions-param-newonly": "Прикажувај само новосоздадени страници",
+ "apihelp-feedcontributions-param-hideminor": "Сокриј ситни уредувања.",
+ "apihelp-feedcontributions-param-showsizediff": "Покажувај ја големинската разлика меѓу преработките.",
+ "apihelp-feedcontributions-example-simple": "Покажувај придонеси на <kbd>Пример</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Дава канал со скорешни промени.",
+ "apihelp-feedrecentchanges-param-feedformat": "Форматот на каналот.",
+ "apihelp-feedrecentchanges-param-namespace": "На кој именски простор да се ограничи исходот.",
+ "apihelp-feedrecentchanges-param-invert": "Сите именски простори освен избраниот.",
+ "apihelp-feedrecentchanges-param-associated": "Вклучи придружни именски простори (разговор или главен).",
+ "apihelp-feedrecentchanges-param-days": "На кои денови да се ограничат ставките.",
+ "apihelp-feedrecentchanges-param-limit": "Највеќе ставки во исходот за прикажување.",
+ "apihelp-feedrecentchanges-param-from": "Прикажи ги промените оттогаш.",
+ "apihelp-feedrecentchanges-param-hideminor": "Скриј ги ситните промени.",
+ "apihelp-feedrecentchanges-param-hidebots": "Скриј ги промените напарвени од ботови.",
+ "apihelp-feedrecentchanges-param-hideanons": "Скриј ги промените направени од анонимни корисници.",
+ "apihelp-feedrecentchanges-param-hideliu": "Скриј ги промените направени од регистрирани корисници.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Скриј ги испатролираните промени.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Скриј ги промените на тековниот корисник.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Сокриј префрлања во други категории.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Филтрирање по ознака.",
+ "apihelp-feedrecentchanges-param-target": "Прикажи само промени на страници што водат од оваа.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Наместо тоа, прикажи ги промените на страниците поврзани со избраната страница.",
+ "apihelp-feedrecentchanges-param-categories": "Прикажи само промени на страниците во сите овие категории.",
+ "apihelp-feedrecentchanges-param-categories_any": "Прикажи само промени на страниците во било која од категориите.",
+ "apihelp-feedrecentchanges-example-simple": "Прикажи скорешни промени",
+ "apihelp-feedrecentchanges-example-30days": "Прикажувај скорешни промени 30 дена",
+ "apihelp-feedwatchlist-summary": "Дава канал од набљудуваните.",
+ "apihelp-feedwatchlist-param-feedformat": "Форматот на каналот.",
+ "apihelp-feedwatchlist-param-hours": "Испиши страници изменети во рок од олку часови отсега.",
+ "apihelp-feedwatchlist-param-linktosections": "Давај ме право на изменетите делови, ако е можно.",
+ "apihelp-feedwatchlist-example-default": "Прикажи го каналот од набљудуваните.",
+ "apihelp-feedwatchlist-example-all6hrs": "Прикажи ги сите промени во набљудуваните во последните 6 часа",
+ "apihelp-filerevert-summary": "Врати податотека на претходна верзија.",
+ "apihelp-filerevert-param-filename": "Име на целната податотека, без претставката „Податотека:“.",
+ "apihelp-filerevert-param-comment": "Коментар за подигањето.",
+ "apihelp-filerevert-param-archivename": "Архивски назив на преработката што ја повраќате.",
+ "apihelp-filerevert-example-revert": "Врати ја <kbd>Wiki.png</kbd> на верзијата од <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Прикажувај помош за укажаните модули.",
+ "apihelp-help-param-modules": "Модули за приказ на помош за (вредности на параметрите <var>action</var> и <var>format</var>, или пак <kbd>main</kbd>). Може да се укажат подмодули со <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Прикажувај и помош за подмодули на именуваниот модул.",
+ "apihelp-help-param-recursivesubmodules": "Прикажувај и помош за подмодули рекурзивно.",
+ "apihelp-help-param-helpformat": "Формат на изводот на помошта.",
+ "apihelp-help-param-wrap": "Обвиткај го изводот како станрадна одѕивна структура од прилотот.",
+ "apihelp-help-param-toc": "Вклучи табела со содржина во HTML-изводот.",
+ "apihelp-help-example-main": "Помош за главниот модул",
+ "apihelp-help-example-submodules": "Помош за <kbd>action=query</kbd> и сите негови подмодули.",
+ "apihelp-help-example-recursive": "Сета помош на една страница",
+ "apihelp-help-example-help": "Помош за самиот помошен модул",
+ "apihelp-help-example-query": "Помош за два подмодула за барања",
+ "apihelp-imagerotate-summary": "Сврти една или повеќе слики.",
+ "apihelp-imagerotate-param-rotation": "За колку степени да се сврти надесно.",
+ "apihelp-imagerotate-param-tags": "Ознаки за примена врз ставката во дневникот на подигања.",
+ "apihelp-imagerotate-example-simple": "Сврти ја <kbd>Податотека:Пример.png</kbd> за <kbd>90</kbd> степени.",
+ "apihelp-imagerotate-example-generator": "Сврти ги сите слики во <kbd>Категорија:Некоја</kbd> за <kbd>180</kbd> степени.",
+ "apihelp-import-summary": "Увези страница од друго вики или од XML-податотека.",
+ "apihelp-import-extended-description": "Имајте на ум дека POST на HTTP мора да се изведе како подигање на податотеката (т.е. користејќи повеќеделни податоци/податоци од образец) кога ја испраќате податотеката за параметарот <var>xml</var>.",
+ "apihelp-import-param-summary": "Опис на увозот на дневнички запис.",
+ "apihelp-import-param-xml": "Подигната XML-податотека.",
+ "apihelp-import-param-interwikisource": "За меѓујазични увози: од кое вики да се увезе.",
+ "apihelp-import-param-interwikipage": "За меѓујазични увози: страница за увоз.",
+ "apihelp-import-param-fullhistory": "За меѓујазични увози:: увези ја целата историја, а не само тековната верзија.",
+ "apihelp-import-param-templates": "За меѓујазични увози: увези ги и сите вклучени шаблони.",
+ "apihelp-import-param-namespace": "Увези во овој именски простор. Не може да се користи заедно со <kbd>$1rootpage</kbd>.",
+ "apihelp-import-param-rootpage": "Увези како потстраница на страницава. Не може да се користи заедно со <kbd>$1namespace</kbd>.",
+ "apihelp-import-example-import": "Увези [[meta:Help:ParserFunctions]] во именскиот простор 100 со целата историја.",
+ "apihelp-login-summary": "Најавете се и добијте колачиња за заверка.",
+ "apihelp-login-extended-description": "Во случај кога ќе се најавите успешно, потребните колачиња ќе се придодадат кон заглавијата на HTTP-одѕивот. Во случај да не успеете да се најавите, понатамошните обиди може да се ограничат за да се ограничат нападите со автоматизирано погодување на лозинката.",
+ "apihelp-login-param-name": "Корисничко име.",
+ "apihelp-login-param-password": "Лозинка.",
+ "apihelp-login-param-domain": "Домен (незадолжително).",
+ "apihelp-login-param-token": "Најавна шифра добиена со првото барање.",
+ "apihelp-login-example-gettoken": "Набави најавна шифра.",
+ "apihelp-login-example-login": "Најава",
+ "apihelp-logout-summary": "Одјави се и исчисти ги податоците на седницата.",
+ "apihelp-logout-example-logout": "Одјави го тековниот корисник",
+ "apihelp-move-summary": "Премести страница.",
+ "apihelp-move-param-from": "Наслов на страницата што треба да се премести. Не може да се користи заедно со <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "Назнака на страницата што треба да се премести. Не може да се користи заедно со <var>$1from</var>.",
+ "apihelp-move-param-to": "Како да гласи новата страница.",
+ "apihelp-move-param-reason": "Причина за преименувањето.",
+ "apihelp-move-param-movetalk": "Преименувај ја и страницата за разговор, ако ја има.",
+ "apihelp-move-param-movesubpages": "Преименувај потстраници, ако има.",
+ "apihelp-move-param-noredirect": "Не прави пренасочување.",
+ "apihelp-move-param-watch": "Додај ги страницата и пренасочувањето во набљудуваните на тековниот корисник.",
+ "apihelp-move-param-unwatch": "Отстрани ги страницата и пренасочувањето од набљудуваните на тековниот корисник.",
+ "apihelp-move-param-watchlist": "Безусловно додај или отстрани ја страницата од набљудуваните на тековниот корисник, користете ги нагодувањата или не ги менувајте набљудуваните.",
+ "apihelp-move-param-ignorewarnings": "Занемари предупредувања.",
+ "apihelp-move-example-move": "Премести го <kbd>Badtitle</kbd> на <kbd>Goodtitle</kbd>, неоставајќи пренасочување",
+ "apihelp-opensearch-summary": "Пребарување на викито со протоколот OpenSearch.",
+ "apihelp-opensearch-param-search": "Низа за пребарување.",
+ "apihelp-opensearch-param-limit": "Највеќе ставки за прикажување.",
+ "apihelp-opensearch-param-namespace": "Именски простори за пребарување.",
+ "apihelp-opensearch-param-suggest": "Не прави ништо ако <var>[[mw:Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> е неточно.",
+ "apihelp-opensearch-param-redirects": "Како да се работи со пренасочувања:\n;return: Дај го самото пренасочување.\n;resolve: Дај ја целната страница. Може да даде помалку од $1limit ставки.\nОд историски причини, по основно е „return“ за $1format=json и „resolve“ за други формати.",
+ "apihelp-opensearch-param-format": "Формат на изводот.",
+ "apihelp-opensearch-example-te": "Најди страници што почнуваат со <kbd>Те</kbd>.",
+ "apihelp-options-summary": "Смени ги нагодувањата на тековниот корисник.",
+ "apihelp-options-extended-description": "Можат да се зададат само можностите заведени во јадрото или во едно од воспоставените додатоци, или пак можности со клуч кој ја има претставката <code>userjs-</code> (предвиден за употреба од кориснички скрипти).",
+ "apihelp-options-param-reset": "Ги враќа поставките по основно.",
+ "apihelp-options-param-resetkinds": "Писок на типови можности за повраток кога е зададена можноста <var>$1reset</var>.",
+ "apihelp-options-param-change": "Список на промени во форматот name=value (на пр. skin=vector). Вредностите не треба да содржат исправени црти. Ако не зададете вредност (дури ни знак за равенство), на пр., можност|другаможност|..., ќе биде зададена вредноста на можноста по основно.",
+ "apihelp-options-param-optionname": "Назив на можноста што треба да ѝ се зададе на вредноста дадена од <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "Вредноста на можноста укажана од <var>$1optionname</var>. Може да содржи исправени црти.",
+ "apihelp-options-example-reset": "Врати ги сите поставки по основно",
+ "apihelp-options-example-change": "Смени ги поставките <kbd>skin</kbd и <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Врати ги сите нагодувања по основно, а потоа задај ги <kbd>skin</kbd> и <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Набави информации за извршнички (API) модули.",
+ "apihelp-paraminfo-param-modules": "Список на називи на модули (вредности на параметрите <var>action</var> и <var>format</var>, или пак <kbd>main</kbd>). Може да се укажат подмодули со <kbd>+</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Формат на помошните низи.",
+ "apihelp-paraminfo-param-querymodules": "Список на називи на модули за барања (вредност на параметарот <var>prop</var>, <var>meta</var> или <var>list</var>). Користете го <kbd>$1modules=query+foo</kbd> наместо <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Добави информации и за главниот (врховен) модул. Користете го <kbd>$1modules=main</kbd> наместо тоа.",
+ "apihelp-paraminfo-param-pagesetmodule": "Дај ги сите информации и за модулот на збирот страници (укажувајќи titles= и сродни).",
+ "apihelp-paraminfo-param-formatmodules": "Список на називи на форматни модули (вредностза параметарот <var>format</var>). Наместо тоа, користете го <var>$1modules</var>.",
+ "apihelp-parse-param-summary": "Опис за расчленување.",
+ "apihelp-parse-param-preview": "Расчлени во прегледен режим.",
+ "apihelp-parse-param-sectionpreview": "Расчлени во прегледен режим на поднасловот (го овозможува и прегледниот режим).",
+ "apihelp-parse-param-disabletoc": "Изземи го преглед на содржината во изводот.",
+ "apihelp-parse-param-contentformat": "Формат на серијализацијата на содржината во вносниот текст. Важи само кога се користи со $1text.",
+ "apihelp-parse-example-page": "Расчлени страница.",
+ "apihelp-parse-example-text": "Расчлени викитекст.",
+ "apihelp-parse-example-texttitle": "Расчлени страница, укажувајќи го насловот на страницата.",
+ "apihelp-parse-example-summary": "Расчлени опис.",
+ "apihelp-patrol-summary": "Испатролирај страница или преработка.",
+ "apihelp-patrol-param-rcid": "Назнака на спорешните промени за патролирање.",
+ "apihelp-patrol-param-revid": "Назнака на преработката за патролирање.",
+ "apihelp-patrol-example-rcid": "Испатролирај скорешна промена",
+ "apihelp-patrol-example-revid": "Патролирај праработка",
+ "apihelp-protect-summary": "Смени го степенот на заштита на страница.",
+ "apihelp-protect-param-title": "Наслов на страница што се (од)заштитува. Не може да се користи заедно со $1pageid.",
+ "apihelp-protect-param-pageid": "Назнака на страница што се (од)заштитува. Не може да се користи заедно со $1title.",
+ "apihelp-protect-param-reason": "Причиина за (од)заштитување",
+ "apihelp-protect-example-protect": "Заштити страница",
+ "apihelp-purge-param-forcelinkupdate": "Поднови ги табелите со врски.",
+ "apihelp-purge-example-simple": "Превчитај ги <kbd>Main Page</kbd> и <kbd>API</kbd>.",
+ "apihelp-query-param-list": "Кои списоци да се набават.",
+ "apihelp-query-param-meta": "Кои метаподатоци да се набават.",
+ "apihelp-query+allcategories-summary": "Наброј ги сите категории.",
+ "apihelp-query+allcategories-param-from": "Од која категорија да почне набројувањето.",
+ "apihelp-query+allcategories-param-to": "На која категорија да запре набројувањето.",
+ "apihelp-query+allcategories-param-dir": "Насока на подредувањето.",
+ "apihelp-query+alldeletedrevisions-param-from": "Почни го исписот од овој наслов.",
+ "apihelp-query+alldeletedrevisions-param-to": "Запри го исписот на овој наслов.",
+ "apihelp-query+alldeletedrevisions-example-user": "Список на последните 50 избришани придонеси на корисникот <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Список на последните 50 избришани преработки во главниот именски простор.",
+ "apihelp-query+allimages-example-B": "Прикажи список на податотеки што почнуваат со буквата <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Прикажи список на неодамна подигнати податотеки сличен на [[Special:NewFiles]]",
+ "apihelp-query+allimages-example-generator": "Прикажи информации за околу 4 податотеки што почнуваат со буквата <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Наброј ги сите врски што водат кон даден именски простор.",
+ "apihelp-query+alllinks-param-from": "Наслов на врската од која ќе почне набројувањето.",
+ "apihelp-query+alllinks-param-to": "Наслов на врската на која ќе запре набројувањето.",
+ "apihelp-query+alllinks-param-prefix": "Пребарај ги сите сврзани наслови што почнуваат со оваа вредност.",
+ "apihelp-query+alllinks-param-unique": "Прикажувај само различни поврзани наслови. Не може да се користи со <kbd>$1prop=ids</kbd>.\nКога се користи како создавач, дава целни страници наместо изворни.",
+ "apihelp-query+alllinks-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Ја додава назнаката на страницата на која е врската (не може да се користи со <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Го додава насловот на врската.",
+ "apihelp-query+alllinks-param-namespace": "Именскиот простор што се набројува.",
+ "apihelp-query+alllinks-param-limit": "Колку вкупно ставки да се дадат.",
+ "apihelp-query+alllinks-param-dir": "Насока на исписот.",
+ "apihelp-query+alllinks-example-B": "Списока на наслови со врски, вклучувајќи ги отсутните, со назнаки на нивните страници, почнувајќи од <kbd>Б</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Испиши единствени наслови со врски",
+ "apihelp-query+alllinks-example-unique-generator": "Ги дава сите наслови со врски, означувајќи ги отсутните",
+ "apihelp-query+alllinks-example-generator": "Дава страници што ги содржат врските",
+ "apihelp-query+allmessages-summary": "Дава пораки од ова мрежно место.",
+ "apihelp-query+allmessages-param-prop": "Кои својства да се дадат.",
+ "apihelp-query+allmessages-param-filter": "Дај само пораки со називи што ја содржат оваа низа.",
+ "apihelp-query+allmessages-param-customised": "Дај само пораки во оваа состојба на прилагоденост.",
+ "apihelp-query+allmessages-param-lang": "Дај само пораки на овој јазик.",
+ "apihelp-query+allmessages-param-from": "Дај ги пораките што почнуваат од оваа порака.",
+ "apihelp-query+allmessages-param-to": "Дај пораки што завршуваат со оваа порака.",
+ "apihelp-query+allmessages-param-title": "Назив на страницата што ќе се користи во контекст кога се расчленува порака (за можноста $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "Дај пораки со оваа претставка.",
+ "apihelp-query+allmessages-example-ipb": "Прикажи ги пораките што започнуваат со <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Прикажи ги пораките <kbd>august</kbd> and <kbd>mainpage</kbd> на германски.",
+ "apihelp-query+allpages-summary": "Наброј ги сите страници последователно во даден именски простор.",
+ "apihelp-query+allpages-param-from": "Наслов на страницата од која ќе почне набројувањето.",
+ "apihelp-query+allpages-param-to": "Наслов на страницата на која ќе запре набројувањето.",
+ "apihelp-query+allpages-param-prefix": "Пребарај ги сите наслови на страници што почнуваат со оваа вредност.",
+ "apihelp-query+allpages-param-namespace": "Именскиот простор што се набројува.",
+ "apihelp-query+allpages-param-filterredir": "Кои страници да се испишат.",
+ "apihelp-query+allpages-param-minsize": "Ограничи на страници со барем олку бајти.",
+ "apihelp-query+allpages-param-maxsize": "Ограничи на страници со највеќе олку бајти.",
+ "apihelp-query+allpages-param-prtype": "Ограничи на само заштитени страници.",
+ "apihelp-query+backlinks-example-simple": "Прикажи врски до <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Дава информации за страниците што водат до <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Список на сите блокирани корисници и IP-адреси",
+ "apihelp-query+blocks-param-start": "Од кој датум и време да почне набројувањето.",
+ "apihelp-query+blocks-param-end": "На кој датум и време да запре набројувањето.",
+ "apihelp-query+blocks-param-ids": "Список на назнаки на блоковите за испис (незадолжително)",
+ "apihelp-query+blocks-param-users": "Список на корисници што ќе се пребаруваат (незадолжително)",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Режим|Режими}}: $2",
+ "apihelp-query+imageinfo-param-urlheight": "Слично на $1urlwidth.",
+ "apihelp-query+revisions-example-last5": "Дај ги последните 5 преработки на <kbd>Главна страница</kbd>.",
+ "apihelp-query+revisions-example-first5": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> направени по 2006-05-01 (1 мај 2006 г.)",
+ "apihelp-query+revisions-example-first5-not-localhost": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> кои не се направени од анонимниот корисник „127.0.0.1“",
+ "apihelp-query+revisions-example-first5-user": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> кои се направени од корисникот „зададен од МедијаВики“ (<kbd>MediaWiki default</kbd>)",
+ "apihelp-query+search-example-simple": "Побарај <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Побарај го <kbd>meaning</kbd> по текстовите.",
+ "apihelp-query+search-example-generator": "Дај информации за страниците што излегуваат во исходот од пребарувањето на <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "Дај општи информации за мрежното место.",
+ "apihelp-upload-param-filename": "Целно име на податотеката.",
+ "apihelp-upload-param-comment": "Коментар при подигање. Се користи и како првичен текст на страницата за нови податотеки ако не е укажано <var>$1text</var>.",
+ "apihelp-upload-param-text": "Првичен текст на страницата за нови податотеки.",
+ "apihelp-upload-param-watch": "Набљудувај ја страницата.",
+ "apihelp-upload-param-watchlist": "Безусловно додај или отстрани ја страницата од набљудуваните на тековниот корисник; користете ги нагодувањата или не ги менувајте набљудуваните.",
+ "apihelp-upload-param-ignorewarnings": "Занемари предупредувања.",
+ "apihelp-upload-param-file": "Содржина на податотеката.",
+ "apihelp-upload-param-url": "Од која URL-адреса да се преземе податотеката.",
+ "apihelp-upload-param-filekey": "Клуч на претходното подигање кое е привремено складирано.",
+ "apihelp-upload-param-sessionkey": "Исто што и $1filekey. Се одржува за назадна складност.",
+ "apihelp-upload-param-stash": "Ако е зададено, опслужувачот ќе ја стави податотеката на привремено чување наместо да го додаде во складиштето.",
+ "apihelp-upload-param-filesize": "Големина на целото подигање.",
+ "apihelp-upload-param-offset": "Зафатнина на делот во бајти.",
+ "apihelp-upload-param-chunk": "Содржина на делот.",
+ "apihelp-upload-param-async": "Направи ги работите со потенцијално големи податотеки неусогласени, кога е можно.",
+ "apihelp-upload-param-checkstatus": "Дај ја состојбата на подигнатост само за дадениот податотечен клуч.",
+ "apihelp-upload-example-url": "Подигни од URL",
+ "apihelp-userrights-param-user": "Корисничко име.",
+ "apihelp-userrights-param-userid": "Корисничка назнака.",
+ "apihelp-userrights-param-add": "Стави го корисникот во следниве групи.",
+ "apihelp-userrights-param-remove": "Отстрани го корисникот од следниве групи.",
+ "apihelp-userrights-param-reason": "Причина за промената.",
+ "apihelp-userrights-example-user": "Додај го корисникот <kbd>FooBot</kbd> во групата <kbd>bot</kbd> и отстрани го од групите <kbd>sysop</kbd> и <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Додај го корисникот со назнака <kbd>123</kbd> во групата <kbd>bot</kbd> и отстрани го од групите <kbd>sysop</kbd> и <kbd>bureaucrat</kbd>.",
+ "apihelp-watch-summary": "Додај или отстрани страници од набљудуваните на тековниот корисник.",
+ "apihelp-watch-param-title": "Страницата што се става во или отстранува од набљудуваните. Наместо ова, користете <var>$1titles</var>.",
+ "apihelp-watch-param-unwatch": "Ако е зададено, страницата ќе биде отстранета од наместо ставена во набљуваните.",
+ "apihelp-watch-example-watch": "Набљудувај ја страницата <kbd>Главна страница</kbd>.",
+ "apihelp-watch-example-unwatch": "Отстрани ја страницата <kbd>Главна страница</kbd> од набљудуваните.",
+ "apihelp-watch-example-generator": "Набљудувај ги првите неколку страници во главниот именски простор",
+ "apihelp-format-example-generic": "Дај го исходот од барањето во $1-формат.",
+ "apihelp-json-summary": "Давај го изводот во JSON-формат.",
+ "apihelp-json-param-callback": "Ако е укажано, го обвива изводот во даден повик на функција. За безбедност, ќе се ограничат сите податоци што се однесуваат на корисниците.",
+ "apihelp-json-param-utf8": "Ако е укажано, ги шифрира највеќето (но не сите) не-ASCII знаци како UTF-8 наместо да ги заменува со хексадецимални изводни низи. Ова е стандардно кога <var>formatversion</var> не е <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "Ако е укажано, ги шифрира сите не-ASCII знаци како хексадецимални изводни низи. Ова е стандардно кога <var>formatversion</var> is <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Форматирање на изводот:\n;1:Назадно-складен формат (булови во XML-стил, клучеви <samp>*</samp> за содржински јазли и тн.).\n;2:Пробен современ формат. Поединостите може да се изменат!\n;најнов:Користење на најновиот формат (тековно <kbd>2</kbd>), може да се смени без предупредување.",
+ "apihelp-jsonfm-summary": "Давај го изводот во JSON-формат (подобрен испис во HTML).",
+ "apihelp-none-summary": "Де давај извод.",
+ "apihelp-php-summary": "Давај го изводот во серијализиран PHP-формат.",
+ "apihelp-php-param-formatversion": "Форматирање на изводот:\n;1:Назадно-складен формат (булови во XML-стил, клучеви <samp>*</samp> за содржински јазли и тн.).\n;2:Пробен современ формат. Поединостите може да се изменат!\n;најнов:Користење на најновиот формат (тековно <kbd>2</kbd>), може да се смени без предупредување.",
+ "apihelp-phpfm-summary": "Давај го изводот во серијализиран PHP-формат (подобрен испис во HTML).",
+ "apihelp-rawfm-summary": "Давај го изводот со елементи за отстранување грешки во JSON-формат (подобрен испис во HTML).",
+ "apihelp-xml-summary": "Давај го изводот во XML-формат.",
+ "apihelp-xml-param-xslt": "Ако е укажано, ја додава именуваната страница како XSL-стилска страница. Вредноста мора да биде наслов во именскиот простор „{{ns:MediaWiki}}“ што ќе завршува со <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Ако е укажано, додава именски простор XML.",
+ "apihelp-xmlfm-summary": "Давај го изводот во XML-формат (подобрен испис во HTML).",
+ "api-format-title": "Исход од Извршникот на МедијаВики",
+ "api-format-prettyprint-header": "Ова е HTML-претстава на форматот $1. HTML е добар за отстранување на грешки, но не е погоден за употреба во извршник.\n\nУкажете го параметарот <var>format</var> за да го смените изводниот формат. За да ги видите претставите на форматот $1 вон HTML, задајте <kbd>format=$2</kbd>.\n\nПовеќе информации ќе најдете на [[mw:Special:MyLanguage/API|целосната документација]], или пак [[Special:ApiHelp/main|помош со извршникот]].",
+ "api-pageset-param-titles": "Список на наслови на кои ќе се работи",
+ "api-pageset-param-pageids": "Список на назнаки за страници на кои ќе се работи",
+ "api-pageset-param-revids": "Список на назнаки на преработки на кои ќе се работи",
+ "api-pageset-param-generator": "Дај го списокот на страници на кои ќе се работи исполнувајќи го укажаниот модул за барање.\n\n<strong>Напомена:</strong> називите на создавачките параметри мора да ја имаат претставката „g“. Погледајте ги примерите.",
+ "api-pageset-param-redirects-generator": "Автоматски решавај пренасочувања во <var>$1titles</var>, <var>$1pageids</var> и <var>$1revids</var>, како и во страниците што ги дава <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Автоамтски решавај пренасочувања во <var>$1titles</var>, <var>$1pageids</var> и <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Ако е потребно, претворај ги насловите во други варијанти. Работи само ако јазикот на викито поддржува претворање на варијанти. Такви се $1.",
+ "api-help-title": "Помош со Извршникот на МедијаВики",
+ "api-help-lead": "Ова е самосоздадена документациска страница за извршникот на МедијаВики.\n\nДокументација и примери: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Главен модул",
+ "api-help-flag-deprecated": "Овој модул е застарен.",
+ "api-help-flag-internal": "<strong>Овој модул е внатрешен или нестабилен.</strong> Работењето може да му се промени без предупредување.",
+ "api-help-flag-readrights": "За овој модул се потребни права на читање.",
+ "api-help-flag-writerights": "За овој модул се потребни права на пишување.",
+ "api-help-flag-mustbeposted": "Овој модул прифаќа само POST-барања.",
+ "api-help-flag-generator": "Овој модул може да се користи како создавач.",
+ "api-help-source": "Извор: $1",
+ "api-help-source-unknown": "Извор: <span class=\"apihelp-unknown\">непознат</span>",
+ "api-help-license": "Лиценца: [[$1|$2]]",
+ "api-help-license-noname": "Лиценца: [[$1|Погл. врската]]",
+ "api-help-license-unknown": "Лиценца: <span class=\"apihelp-unknown\">непозната</span>",
+ "api-help-parameters": "{{PLURAL:$1|Параметар|Параметри}}:",
+ "api-help-param-deprecated": "Застарен.",
+ "api-help-param-required": "Овој параметар е задолжителен.",
+ "api-help-datatypes-header": "Типови на податоци",
+ "api-help-param-type-limit": "Тип: цел број или <kbd>max</kbd>",
+ "api-help-param-type-integer": "Тип: {{PLURAL:$1|1=цел број|2=список на цели броеви}}",
+ "api-help-param-type-boolean": "Тип: булов ([[Special:ApiHelp/main#main/datatypes|подробно]])",
+ "api-help-param-type-timestamp": "Тип: {{PLURAL:$1|1=време и датум|2=список на времиња и датуми}} ([[Special:ApiHelp/main#main/datatypes|допуштени формати]])",
+ "api-help-param-type-user": "Тип: {{PLURAL:$1|1=корисничко име|2=список на кориснички имиња}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Една вредност|2=Вредности (одделете ги со <kbd>{{!}}</kbd>)}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Мора да биде празно|Може да биде празно или $2}}",
+ "api-help-param-limit": "Не се допушта повеќе од $1.",
+ "api-help-param-limit2": "Не се допушта повеќе од $1 ($2 за ботови).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=Вредноста не може да изнесува|2=Вредностите не може да изнесуваат}} помалку од $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Вредноста не може да изнесува|2=Вредностите е може да изнесуваат}} повеќе од $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Вредноста мора да изнесува|2=Вредностите мораат да изнесуваат}} помеѓу $2 и $3.",
+ "api-help-param-upload": "Мора да биде објавено како податотечно подигање користејќи податоци кои се повеќеделни или од образец.",
+ "api-help-param-multi-separate": "Одделувајте ги вредностите со <kbd>|</kbd>.",
+ "api-help-param-multi-max": "Највеќе допуштени вредности: {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} за ботови).",
+ "api-help-param-default": "По основно: $1",
+ "api-help-param-default-empty": "По основно: <span class=\"apihelp-empty\">(празно)</span>",
+ "api-help-param-token": "Шифра „$1“ добиена од [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "За складност, се прифаќа и шифрата што се користи за обичниот кориснички посредник.",
+ "api-help-param-disabled-in-miser-mode": "Исклучено поради [[mw:Special:MyLanguage/Manual:$wgMiserMode|скржавиот режим]].",
+ "api-help-param-limited-in-miser-mode": "<strong>Напомена:</strong> Бидејќи сте во [[mw:Special:MyLanguage/Manual:$wgMiserMode|скржав режим]], користејќи го ова може да добиете помалку од <var>$1limit</var> исходни ставки пред да продолжите; во крајни случаи може да не добиете ниедна ставка.",
+ "api-help-param-direction": "Во која насока да се набројува:\n;понови:Прво најстарите. Напомена: $1start мора да биде пред $1end.\n;постари:Прво најновите (по основно). Напомена: $1start мора да биде подоцна од $1end.",
+ "api-help-param-continue": "Употребете го ова за да продолжите кога има повеќе расположиви ставки.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(нема опис)</span>",
+ "api-help-examples": "{{PLURAL:$1|Пример|Примери}}:",
+ "api-help-permissions": "{{PLURAL:$1|Дозвола|Дозволи}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Доделена на}: $2",
+ "api-help-right-apihighlimits": "Уоптреба на повисоки ограничувања за приложни барања (бавни барања: $1; брзи барања: $2). Ограничувањата за бавни барања важат и за повеќевредносни параметри.",
+ "apierror-offline": "Не можев да продолжам поради проблем при поврзувањето со мрежата. Проверете дали сте поврзани со семрежјето и обидете се повторно.",
+ "apierror-timeout": "Опслужувачот не одговори во очекуваното време.",
+ "api-credits-header": "Признанија",
+ "api-credits": "Разработувачи на Извршникот:\n* Роан Катау (главен резработувач од септември 2007 до 2009 г.)\n* Виктор Василев\n* Брајан Тонг Мињ\n* Сем Рид\n* Јуриј Астрахан (создавач, главен разработувач од септември 2006 до септември 2007 г.)\n* Brad Jorsch (главен разработувач од 2013 г. до денес)\n\nВашите коментари, предлози и прашања испраќајте ги на mediawiki-api@lists.wikimedia.org\nа грешките пријавувајте ги на https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/mr.json b/www/wiki/includes/api/i18n/mr.json
new file mode 100644
index 00000000..8d4dac46
--- /dev/null
+++ b/www/wiki/includes/api/i18n/mr.json
@@ -0,0 +1,107 @@
+{
+ "@metadata": {
+ "authors": [
+ "Rahuldeshmukh101",
+ "V.narsikar"
+ ]
+ },
+ "apihelp-main-param-action": "कोणती कार्यवाही करावयाची.",
+ "apihelp-main-param-curtimestamp": "निकालात सद्य वेळठश्याचा अंतर्भाव करा.",
+ "apihelp-block-summary": "सदस्यास प्रतिबंधित करा.",
+ "apihelp-block-param-user": "सदस्याचे नाव, अंक-पत्त्ता, किंवा प्रतिबंध करण्यासाठीचा आयपीचा आवाका.",
+ "apihelp-delete-summary": "पान वगळा",
+ "apihelp-edit-param-minor": "छोटे संपादन",
+ "apihelp-edit-param-notminor": "छोटे नसलेले संपादन",
+ "apihelp-edit-param-bot": "या संपादनावर सांगकाम्याचे संपादन म्हणून खूण करा.",
+ "apihelp-edit-example-edit": "पान संपादा",
+ "apihelp-expandtemplates-summary": "विकिमजकूरात सर्व साच्यांचा विस्तार करा.",
+ "apihelp-feedcontributions-param-toponly": "केवळ नवीनतम आवर्तने असलेलीच संपादने दाखवा.",
+ "apihelp-feedrecentchanges-param-categories": "या सर्व वर्गात असलेल्या पानांमधील बदलच फक्त दाखवा.",
+ "apihelp-feedrecentchanges-param-categories_any": "त्यापेक्षा,या कोणत्याही वर्गांमधील,पानांना झालेले बदलच फक्त दाखवा.",
+ "apihelp-login-param-name": "सदस्य नाव.",
+ "apihelp-login-param-password": "परवलीचा शब्द.",
+ "apihelp-login-example-login": "सनोंद-प्रवेश करा.",
+ "apihelp-move-summary": "पृष्ठाचे स्थानांतरण करा.",
+ "apihelp-move-param-ignorewarnings": "सर्व सूचनांकडे दुर्लक्ष करा.",
+ "apihelp-options-example-reset": "पसंतीक्रमाची पुनर्स्थापना",
+ "apihelp-patrol-summary": "पानावर किंवा आवृत्तीवर पहारा द्या.",
+ "apihelp-patrol-example-rcid": "अलीकडील बदलावर पहारा द्या.",
+ "apihelp-patrol-example-revid": "आवृत्तीवर पहारा द्या.",
+ "apihelp-protect-summary": "पानाची सुरक्षापातळी बदला.",
+ "apihelp-protect-example-protect": "पानास सुरक्षित करा.",
+ "apihelp-query-param-list": "कोणती यादी मागवायची.",
+ "apihelp-query-param-meta": "कोणता मेटाडाटा हवा.",
+ "apihelp-query+allpages-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+allredirects-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+allrevisions-summary": "सर्व आवृत्त्यांची यादी",
+ "apihelp-query+allrevisions-param-user": "फक्त या सदस्याच्याच आवृत्त्यांची यादी करा",
+ "apihelp-query+allrevisions-param-excludeuser": "या सदस्याच्या आवृत्त्यांची यादी करु नका.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "सदस्यास असलेल्या अधिकारांची यादी करते.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "सदस्याची संपादन मोजणी जोडते.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "जर उपलब्ध असेल तर,सदस्याने केंव्हा नोंदणी केली त्याचा वेळठसा(रिक्त असू शकतो)",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "सदस्याची केंद्रीय ओळखण्या व जुळल्याची स्थिती जोडते.",
+ "apihelp-query+allusers-param-witheditsonly": "फक्त संपादन केलेल्या सदस्यांचीच यादी करा.",
+ "apihelp-query+allusers-param-activeusers": "मागील $1 {{PLURAL:$1|दिवसात}} सक्रिय सदस्यांचीच यादी करा.",
+ "apihelp-query+allusers-param-attachedwiki": "<kbd>$1prop=centralids</kbd> याद्वारे असेही दर्शविण्यात येते कि सदस्य हा या विकिशी जुळलेला असून तो या ओळखणीद्वारे ओळखल्या जातो.",
+ "apihelp-query+allusers-example-Y": "<kbd>य</kbd> पासून सदस्यनाव सुरु होणाऱ्या सदस्यांचीच यादी करा.",
+ "apihelp-query+backlinks-summary": "दिलेल्या पानास दुवे असणारी सर्व पाने शोधा.",
+ "apihelp-query+backlinks-param-title": "शोधावयाचे शीर्षक.<var>$1pageid</var>यासमवेत वापरु शकत नाही.",
+ "apihelp-query+backlinks-param-pageid": "शोधावयाची पान ओळखण.<var>$1title</var>यासमवेत वापरु शकत नाही.",
+ "apihelp-query+backlinks-param-namespace": "प्रगणन करावयाचे नामविश्व.",
+ "apihelp-query+backlinks-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+backlinks-param-filterredir": "पुनर्निर्देशनांची कशी गाळणी करावयाची. जर <var>$1redirect</var>सक्षम करुन <kbd>nonredirects</kbd>ला स्थापले तर, ते केवळ दुसऱ्या स्तरासच लागू होते.",
+ "apihelp-query+backlinks-param-redirect": "जर दुवा जोडणारे पान एक पुनर्निर्देशन असेल तर,त्या पुनर्निर्देशनास दुवे असलेली पानेही शोधा. महत्तम मर्यादा अर्धी केल्या जाते.",
+ "apihelp-query+backlinks-example-simple": "<kbd>मुखपृष्ठास</kbd> असणारे दुवे दाखवा.",
+ "apihelp-query+backlinks-example-generator": "<kbd>मुखपृष्ठास</kbd> दुवे असणाऱ्या पानांची माहिती घ्या.",
+ "apihelp-query+blocks-summary": "सर्व प्रतिबंधित सदस्यांची व अंकपत्त्यांची यादी करा.",
+ "apihelp-query+blocks-param-start": "च्यापासून प्रगणना सुरु करावयाची त्याचा वेळठसा.",
+ "apihelp-query+blocks-param-end": "कुठपर्यंत प्रगणना संपवायची त्याचा वेळठसा.",
+ "apihelp-query+blocks-paramvalue-prop-user": "प्रतिबंधित सदस्याचे सदस्यनाव जोडते.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "प्रतिबंधित सदस्याची सदस्यओळखण जोडते.",
+ "apihelp-query+blocks-paramvalue-prop-by": "प्रतिबंधन करणाऱ्या सदस्याचे सदस्यनाव जोडते.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "प्रतिबंधन करणाऱ्या सदस्याची सदस्यओळखण जोडते.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "प्रतिबंधन केंव्हा केले त्याचा वेळठसा जोडते.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "प्रतिबंधनाची मुदत केंव्हा संपते त्याचा वेळठसा.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "प्रतिबंधनाची दिलेली कारणे जोडते.",
+ "apihelp-query+blocks-paramvalue-prop-range": "प्रतिबंधनाने बाधित अंकपत्त्यांचा आवाका जोडते.",
+ "apihelp-query+blocks-example-simple": "प्रतिबंधनाची यादी करा.",
+ "apihelp-query+blocks-example-users": "सदस्य<kbd>अलिस</kbd> व <kbd>बॉब</kbd> या सदस्यांचे प्रतिबंधनाची यादी करा.",
+ "apihelp-query+categories-summary": "ही पाने कोणकोणत्या वर्गात आहेत त्याची यादी करा.",
+ "apihelp-query+categories-param-show": "कोणत्या प्रकारचे वर्ग दाखवायचेत.",
+ "apihelp-query+categories-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+categories-example-simple": "<kbd>अल्बर्ट आईन्स्टाईन</kbd>हे पान कोणकोणत्या वर्गात आहे त्याची यादी करा.",
+ "apihelp-query+categories-example-generator": "<kbd>अल्बर्ट आईन्स्टाईन</kbd>या पानात वापरलेल्या सर्व वर्गांची माहिती द्या.",
+ "apihelp-query+categorymembers-summary": "दिलेल्या वर्गात असलेल्या सर्व पानांची यादी करते.",
+ "apihelp-query+deletedrevs-param-end": "कुठपर्यंत प्रगणना संपवायची त्याचा वेळठसा.",
+ "apihelp-query+deletedrevs-param-from": "या शीर्षकापासून यादी करणे सुरु करा.",
+ "apihelp-query+deletedrevs-param-to": "या शीर्षकास यादी करणे थांबवा.",
+ "apihelp-query+deletedrevs-param-unique": "प्रत्येक पानाच्या फक्त एकाच आवृत्तीची यादी करा.",
+ "apihelp-query+deletedrevs-param-user": "या सदस्याच्या आवृत्तीचीच यादी करा.",
+ "apihelp-query+deletedrevs-param-excludeuser": "या सदस्याच्या आवृत्तीची यादी करु नका.",
+ "apihelp-query+deletedrevs-param-namespace": "या नामविश्वात असलेल्या पानांचीच यादी करा.",
+ "apihelp-query+deletedrevs-param-limit": "यादी करावयाच्या आवृत्त्यांचे महत्तम प्रमाण.",
+ "apihelp-query+duplicatefiles-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+embeddedin-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+filearchive-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+images-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+imageusage-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+iwlinks-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+langbacklinks-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+langlinks-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+links-param-dir": "कोणत्या दिशेस यादी करावयाची.",
+ "apihelp-query+recentchanges-param-end": "कुठपर्यंत प्रगणना संपवायची त्याचा वेळठसा.",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "सदस्याची केंद्रीय ओळखण्या व जुळल्याची स्थिती जोडते.",
+ "apihelp-query+userinfo-param-attachedwiki": "<kbd>$1prop=centralids</kbd> याद्वारे असे दर्शविण्यात येते कि सदस्य हा या विकिशी जुळलेला असून तो या ओळखणीद्वारे ओळखल्या जातो.",
+ "apihelp-query+users-paramvalue-prop-centralids": "सदस्याची केंद्रीय ओळखण्या व जुळल्याची स्थिती जोडते.",
+ "apihelp-query+users-param-attachedwiki": "<kbd>$1prop=centralids</kbd> याद्वारे असे दर्शविण्यात येते कि सदस्य हा या विकिशी जुळलेला असून तो या ओळखणीद्वारे ओळखल्या जातो.",
+ "apihelp-query+watchlist-param-type": "कोणत्या प्रकारचे बदल दाखवायचे:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "नित्याची पान संपादने.",
+ "apihelp-query+watchlist-paramvalue-type-external": "बाह्य बदल.",
+ "apihelp-query+watchlist-paramvalue-type-new": "पान तयार करणे.",
+ "apihelp-query+watchlist-paramvalue-type-log": "नोंद प्रविष्ट्या",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "वर्ग सदस्यता बदलते.",
+ "apihelp-stashedit-param-title": "पानाच्या मथळ्याचे संपादन होत आहे.",
+ "apihelp-stashedit-param-sectiontitle": "नविन विभागाचा मथळा",
+ "apihelp-stashedit-param-summary": "सारांश बदला.",
+ "api-help-examples": "{{PLURAL:$1|उदाहरण|उदाहरणे}}:"
+}
diff --git a/www/wiki/includes/api/i18n/ms.json b/www/wiki/includes/api/i18n/ms.json
new file mode 100644
index 00000000..0224a8ac
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ms.json
@@ -0,0 +1,58 @@
+{
+ "@metadata": {
+ "authors": [
+ "Anakmalaysia",
+ "Macofe"
+ ]
+ },
+ "apihelp-main-param-action": "Tindakan mana untuk dilakukan.",
+ "apihelp-main-param-format": "Format output.",
+ "apihelp-main-param-uselang": "Bahasa yang hendak digunakan untuk penterjemahan mesej. Senarai kod boleh diperoleh dari [[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo&siprop=languages]], ataupun menyatakan \"user\" untuk menggunakan bahasa kegemaran pengguna semasa.",
+ "apihelp-expandtemplates-example-simple": "Perluaskan <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd> wikiteks.",
+ "apihelp-help-param-helpformat": "Format output bantuan.",
+ "apihelp-help-example-main": "Bantuan untuk modul utama",
+ "apihelp-help-example-recursive": "Segala bantuan dalam satu halaman",
+ "apihelp-help-example-help": "Bantuan untuk modul bantuan",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Menambahkan jenis MIME thumbnail imej (memerlukan url dan param $1urlwidth).",
+ "apihelp-query+prefixsearch-param-offset": "Bilangan hasil untuk dilangkau.",
+ "apihelp-query+usercontribs-param-show": "Hanya paparkan item-item yang mematuhi kriteria ini, cth. suntingan selain yang kecil sahaja: $2show=!minor.\n\nJika ditetapkannya $2show=patrolled atau $2show=!patrolled, maka semakan-semakan yang lebih lama daripada [https://www.mediawiki.org/wiki/Manual:$wgRCMaxAge $wgRCMaxAge] ($1 saat) tidak akan dipaparkan.",
+ "apihelp-userrights-param-userid": "ID pengguna.",
+ "apihelp-dbgfm-summary": "Data output dalam format var_export() PHP (''pretty-print'' dalam HTML).",
+ "apihelp-json-summary": "Data output dalam format JSON.",
+ "apihelp-json-param-utf8": "Jika dinyatakan, mengekodkan kenanyakan (tetapi bukan semua) aksara bukan ASCII sebagai UTF-8 daripada menggantikannya dengan jujukan lepasan perenambelasan.",
+ "apihelp-jsonfm-summary": "Output data dalam format JSON (''pretty-print'' dalam HTML).",
+ "apihelp-php-summary": "Data output dalam format PHP bersiri.",
+ "apihelp-txt-summary": "Data output dalam format print_r() PHP.",
+ "apihelp-txtfm-summary": "Data output dalam format print_r() PHP (''pretty-print'' dalam HTML).",
+ "apihelp-xml-summary": "Data output dalam format XML.",
+ "apihelp-xmlfm-summary": "Data output dalam format XML (''pretty-print'' dalam HTML).",
+ "apihelp-yaml-summary": "Data output dalam format YAML.",
+ "apihelp-yamlfm-summary": "Output data dalam format YAML (''pretty-print'' dalam HTML).",
+ "api-format-title": "Hasil API MediaWiki",
+ "api-format-prettyprint-header": "Anda sedang menyaksikan representasi format $1 dalam bentuk HTML. HTML bagus untuk menyah pepijat, tetapi tidak sesuai untuk kegunaan aplikasi.\n\nNyatakan parameter format untuk mengubah format outputnya. Untuk melihat representasi format $1 yang bukan HTML, tetapkan format=$2.\n\nSila rujuk [https://www.mediawiki.org/wiki/API dokumentasi lengkapnya] ataupun [[Special:ApiHelp/main|bantuan API]] untuk keterangan lanjut.",
+ "api-help-title": "Bantuan API MediaWiki",
+ "api-help-lead": "Ini merupakan laman dokumentasi MediaWiki API yang dihasilkan secara automatik.\n\nDokumentasi dan contoh-contoh: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Modul utama",
+ "api-help-flag-deprecated": "Modul ini sudah lapuk.",
+ "api-help-flag-internal": "<strong>Modul ini dalaman atau tidak stabil.</strong> Operasinya boleh berubah tanpa amaran.",
+ "api-help-flag-readrights": "Modul ini memerlukan hak membaca.",
+ "api-help-flag-writerights": "Modul ini memerlukan hak menulis.",
+ "api-help-flag-mustbeposted": "Modul ini menerima permohonan POST sahaja.",
+ "api-help-flag-generator": "Modul ini boleh digunakan sebagai penjana.",
+ "api-help-parameters": "{{PLURAL:$1|Parameter}}:",
+ "api-help-param-deprecated": "Lapuk.",
+ "api-help-param-required": "Parameter ini diwajibkan.",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Mestilah kosong|Bolehlah kosong atau $2}}",
+ "api-help-param-limit2": "Dibenarkannya tidak lebih daripada $1 ($2 untuk bot).",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Nilainya|2=Nilai-nilainya}} mesti tidak melebihi $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Nilainya|2=Nilai-nilainya}} mestilah antara $2 hingga $3.",
+ "api-help-param-multi-separate": "Asingkan nilai-nilai dengan \"|\".",
+ "api-help-param-multi-max": "Bilangan nilai maksimum adalah {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} untuk bot).",
+ "api-help-param-default": "Asal: $1",
+ "api-help-param-default-empty": "Asal: <span class=\"apihelp-empty\">(kosong)</span>",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(tiada keterangan)</span>",
+ "api-help-examples": "{{PLURAL:$1|Contoh|Contoh-contoh}}:",
+ "api-help-permissions": "{{PLURAL:$1|Keizinan}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Diberikan kepada}}: $2",
+ "api-credits-header": "Kredit"
+}
diff --git a/www/wiki/includes/api/i18n/my.json b/www/wiki/includes/api/i18n/my.json
new file mode 100644
index 00000000..63d9df67
--- /dev/null
+++ b/www/wiki/includes/api/i18n/my.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "9.sinistra",
+ "Ninjastrikers"
+ ]
+ },
+ "apihelp-feedrecentchanges-param-hideanons": "အမည်မသိ အသုံးပြုသူများ ပြုလုပ်သည့် ပြောင်းလဲချက်များကို ဝှက်ရန်",
+ "apihelp-feedrecentchanges-param-hideliu": "မှတ်ပုံတင်ထားသော အသုံးပြုသူများ ပြုလုပ်ထားခဲ့သည့် ပြောင်းလဲမှုများကို ဝှက်ရန်"
+}
diff --git a/www/wiki/includes/api/i18n/nap.json b/www/wiki/includes/api/i18n/nap.json
new file mode 100644
index 00000000..1a6c3ba5
--- /dev/null
+++ b/www/wiki/includes/api/i18n/nap.json
@@ -0,0 +1,148 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chelin",
+ "C.R."
+ ]
+ },
+ "apihelp-main-summary": "",
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Documentaziona]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista 'e mmasciate]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annunziaziune 'e ll'API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bug e richieste]\n</div>\n<strong>Stato:</strong> Tuttuquante 'e funziune 'e sta paggena avesser'a funziunà, ma ll'API è ancora a se sviluppà, picciò chesto putesse cagnà a nu certo mumento. Iscriviteve ccà ncoppa: [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce 'a lista 'e mmasciate] pe' n'avé cocche notifica 'e ll'agghiurnamente.\n\n<strong>Richieste sbagliate:</strong> Si se mannasse na richiesta sbagliata a ll'API, nu cap' 'e HTTP sarrà mannata c' 'a chiave 'e mmasciata \"MediaWiki-API-Error\" e po' tuttuquante 'e valure d' 'a cap' 'e mmasciata e codece 'errore se mannassero arreto e se mpustassero a 'o stesso valore. Pe n'avé cchiù nfurmaziune vedite [[mw:API:Errors_and_warnings|API: Errure e Avvise]].\n\n<strong>Test:</strong> Pe' ve ffà cchiù semprice 'e test 'e richieste API, vedite [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Quale aziona d'avess'a fà.",
+ "apihelp-main-param-format": "Qualu furmato avess'ascì d'output.",
+ "apihelp-main-param-maxlag": "'O massimo lag ca se putess'ausà quanno MediaWiki s'installasse ncopp'a nu cluster replicato 'e database. Pe' puté sarvà aziune ca causassero cchiù lag 'e replicato, stu parammetro putesse fà 'o cliente aspettà nfin'a quanno 'o tiempo 'e replicaziona fosse meno ca nu valore specificato. Si nce stesse cchiù assaje tiempo 'e lag, nu codece 'errore <samp>maxlag</samp> se turnasse comm'a na mmasciata tipo <samp>Aspettanno 'o $host: nu $lag secunde 'e lag</samp>.<br />Vedite [[mw:Manual:Maxlag_parameter|Manuale: Parammetro Maxlag]] pe' n'avé cchiù nfurmaziune.",
+ "apihelp-main-param-smaxage": "Mpustate 'a cap' 'e cuntrollo 'e cache HTTP <code>s-maxage</code> a sta quantità 'e secondi. Ll'errure nun s'acchiappassero maje.",
+ "apihelp-main-param-maxage": "Mpustate 'a cap' 'e cuntrollo 'e cache HTTP <code>max-age</code> a sta quantità 'e secondi. Ll'errure nun s'acchiappassero maje.",
+ "apihelp-main-param-assert": "Cuntrullate si l'utente è trasuto si sta mpustato comm' <kbd>user</kbd>, o pure ca téne o deritto 'e bot si <kbd>bot</kbd>.",
+ "apihelp-main-param-requestid": "Qualunque valore dato ccà se mpizzasse dint'a risposta. Se putess'ausà pe' puté distinguere richieste.",
+ "apihelp-main-param-servedby": "Include 'o risultato 'e nomme d' 'o host ca servette 'a richiesta.",
+ "apihelp-main-param-curtimestamp": "Include dint' 'o risultato 'o timestamp 'e mò.",
+ "apihelp-main-param-origin": "Quanno se trasesse a ll'API ausanno richieste 'e cross-dominio AJAX (CORS), mpustate chesto a 'o dominio origgenale. Chesto s'avess'azzeccà dint'a qualsiasi richiesta 'e pre-volo, e picciò avess'a ffà parte d' 'a richiesta d'URI (nun fosse 'o cuorpo POST). Chesto s'avess'azzeccà a uno 'e ll'origgene dint' 'o cap' 'e paggena <code>Origin</code> pricisamente, picciò s'avessa mpustà coccosa tipo <kbd>https://en.wikipedia.org</kbd> o <kbd>https://meta.wikimedia.org</kbd>. Si stu parammetro nun s'azzeccasse c' 'o cap' 'e paggena <code>Origin</code>, allora na risposta 403 se turnasse. Si stu parammetro s'azzeccasse c' 'o cap' 'e paggena <code>Origin</code> e ll'origgene fosse dint' 'a lista janca, allora nu cap' 'e paggena <code>Access-Control-Allow-Origin</code> fosse mpustato.",
+ "apihelp-block-summary": "Blocca n'utente.",
+ "apihelp-block-param-user": "Nomme utente, indirizzo IP o range IP 'a bluccà.",
+ "apihelp-block-param-reason": "Mutive p' 'o blocco.",
+ "apihelp-block-param-anononly": "Blocca surtanto ll'utente anonime (e.g. stuta 'a possibilità 'e ffà cuntribbute 'a st'indirizzo IP).",
+ "apihelp-block-param-nocreate": "Nun premmmettere 'a criazione 'e cunte",
+ "apihelp-block-param-autoblock": "Automaticamende blocca l'urdeme indirizze IP ausate, e tuttuquante ll'indirizze IP addò tentasse 'e trasì.",
+ "apihelp-block-param-noemail": "Scanza st'utente 'e mannà mmasciate pe' bbìa d' 'o wiki. (Servisse 'o <code>blockemail</code> buono).",
+ "apihelp-block-param-hidename": "Annascunne 'o nomme utente d' 'o riggistro 'e blocche (Addimanna 'e premmesse 'e <code>hideuser</code>).",
+ "apihelp-block-param-reblock": "Si l'utente è già bluccato, sovrascrive 'o blocco esistente.",
+ "apihelp-block-param-watchuser": "Vide 'a paggena utente o ll'indirizzo IP 'e ll'utente e paggene 'e chiacchiera.",
+ "apihelp-block-example-ip-simple": "Blocca l'indirizzo IP <kbd>192.0.2.5</kbd> pe' tre gghiuorne p' 'o mutivo <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Blocca l'utente <kbd>Vandal</kbd> a tiempo indeterminato c' 'o mutivo <kbd>Vandalism</kbd>, nun 'o ffà crià cunte nuove nè mannà mmasciate e-mail.",
+ "apihelp-checktoken-summary": "Cuntrolla 'a validità 'e nu token 'a <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Tipo 'e token ncurzo 'e test.",
+ "apihelp-checktoken-param-token": "Token 'a testà.",
+ "apihelp-checktoken-param-maxtokenage": "Massima ammaturità cunzentuta p' 'o token, 'n secunde.",
+ "apihelp-checktoken-example-simple": "Verifica 'a validità 'e nu token <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Scancella 'o flag <code>hasmsg</code> pe ll'utente currente.",
+ "apihelp-clearhasmsg-example-1": "Scancella 'o flag <code>hasmsg</code> pe' l'utente currente.",
+ "apihelp-compare-summary": "Piglia 'e differenze nfra 2 paggene.",
+ "apihelp-compare-extended-description": "Nu nummero 'e verziune, 'o titolo 'e na paggena, o ll'IDE 'e paggena adda essere nnicato fosse p' 'o \"'a\" ca pe' ll' \"a\".",
+ "apihelp-compare-param-fromtitle": "Primmo titolo 'a cunfruntà.",
+ "apihelp-compare-param-fromid": "Primmo ID 'e paggena a cunfruntà.",
+ "apihelp-compare-param-fromrev": "Primma verziona a cunfruntà.",
+ "apihelp-compare-param-totitle": "Seconno titolo a cunfruntà.",
+ "apihelp-compare-param-toid": "Secondo ID 'e paggena a cunfruntà.",
+ "apihelp-compare-param-torev": "Seconda verziona a cunfruntà.",
+ "apihelp-compare-example-1": "Crèa nu diff tra 'a verziona 1 e 'a verziona 2.",
+ "apihelp-createaccount-summary": "Crèa cunto nnòvo.",
+ "apihelp-createaccount-param-name": "Nomme utente.",
+ "apihelp-createaccount-param-password": "Password (sarrà gnurata se mpustato nu <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Dumminio pe' ffà autenticaziona 'a fore (opzionale).",
+ "apihelp-createaccount-param-token": "'A criazione 'e token p' 'o cunto se ngarraje dint'a primma richiesta.",
+ "apihelp-createaccount-param-email": "Indirizzo Email 'e ll'utente (opzionale).",
+ "apihelp-createaccount-param-realname": "Nomme overo 'e ll'utente (opzionale).",
+ "apihelp-createaccount-param-mailpassword": "Si mpustato a qualunque valore, na password casuale sarrà mannat'a ll'utente.",
+ "apihelp-createaccount-param-reason": "Raggiona, a facoltativa, d' 'a criaziona 'e nu cunto a mpizzà int' 'e reggistre.",
+ "apihelp-createaccount-param-language": "Codece 'e llengua a mpustà comme predefinita pe' n'utente (opzionale, 'e default fosse 'a lengue d' 'e cuntenute).",
+ "apihelp-createaccount-example-pass": "Crèa utente <kbd>testuser</kbd> c' 'a password <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Crea utente <kbd>testmailuser</kbd> e manna na mail cu na password criat' 'a ccaso.",
+ "apihelp-delete-summary": "Scancella 'na paggena.",
+ "apihelp-delete-param-title": "Titolo d' 'a paggena a scancellà. Nun se pò ausà nziem'a <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID d' 'a paggena a scancellà. Nun se pò ausà nziem'a <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Raggione p' 'o scancellà. Si nun s'è mpustato, na raggione generata automaticamente s'add'ausà.",
+ "apihelp-delete-param-tags": "Càgna 'e tag pe' puté apprecà l'entrata dint' 'o riggistro 'e scancellazione.",
+ "apihelp-delete-param-watch": "Azzecc' 'a paggena â lista 'e paggene cuntrullate.",
+ "apihelp-delete-param-watchlist": "Senza condizione, azzeccà o luvà 'a paggena 'a l'elenco 'e paggene cuntrullate 'e ll'utente, ausà mpustaziune o nun 'o cagnà l'elenco.",
+ "apihelp-delete-param-unwatch": "Liev' 'a paggena â lista 'e paggene cuntrullate.",
+ "apihelp-delete-param-oldimage": "'O nomm' 'e ll'immaggene viecchia a se scancellà comme sta scritto ccà: [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Scancella <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Scancella 'a <kbd>Main Page</kbd> c' 'o mutivo <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Stu modulo è stato stutato.",
+ "apihelp-edit-summary": "Crèa e cagna paggene.",
+ "apihelp-edit-param-title": "Titolo d' 'a paggena a cagnà. Nun se pò ausà nziem'a <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID d' 'a paggena a cagnà. Nun se pò ausà nziem'a <var>$1title</var>.",
+ "apihelp-edit-param-section": "Nummero 'e sezione. <kbd>0</kbd> p' 'a sezione ncoppa, <kbd>new</kbd> pe' na seziona nova.",
+ "apihelp-edit-param-sectiontitle": "'O titolo pe' na seziona nova.",
+ "apihelp-edit-param-text": "Cuntenuto 'e paggena.",
+ "apihelp-edit-param-summary": "Oggetto d' 'a modifica. Pure 'o titolo ra sezione quanno $1sezione=new e $1sectiontitle nun è mpustato.",
+ "apihelp-edit-param-tags": "Cagna 'e tag ca s'avesser'applicà 'a verziona.",
+ "apihelp-edit-param-minor": "Cagnamiento piccerillo.",
+ "apihelp-edit-param-notminor": "Cagnamiento nun-piccerillo.",
+ "apihelp-edit-param-bot": "Nzegna stu cagnamiento comm' 'e bot.",
+ "apihelp-edit-param-basetimestamp": "Nzegna 'o tiempo d' 'a verzione bbase, ausato pe' puté ffà scummiglià cunflitte 'edizione. Se putesse piglià pe' bbìa 'e [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Nzegna 'o tiempo d' 'a verzione bbase, ausato pe' puté ffà scummiglià cunflitte 'edizione. Nu valore buono se putess'arrepiglià pe' bbìa 'e <var>[[Special:ApiHelp/main|curtimestamp]]</var> quann'accummencia 'o prucess' 'edizione (e.g. quanno se stà a carrecà 'o contenuto 'e na paggena p' 'a cagnà).",
+ "apihelp-edit-param-recreate": "Scrive ncopp'a cocch'errore ncopp'a paggena avenno scancellato chesto a nu certo punto.",
+ "apihelp-edit-param-createonly": "Nun cagnà 'a paggena si esiste già.",
+ "apihelp-edit-param-nocreate": "Ietta 'errore si 'a paggena nun esiste.",
+ "apihelp-edit-param-watch": "Azzecc' 'a paggena â lista 'e paggene cuntrullate.",
+ "apihelp-edit-param-unwatch": "Liev' 'a paggena â lista 'e paggene cuntrullate.",
+ "apihelp-edit-param-watchlist": "Senza condizione, azzeccà o luvà 'a paggena 'a l'elenco 'e paggene cuntrullate 'e ll'utente, ausà mpustaziune o nun 'o cagnà l'elenco.",
+ "apihelp-edit-param-md5": "'O hash MD5 d' 'o parammetro 'e $1text, o chill' 'e $1prependtext e $1appendtext concatenate. Si mpustato, 'o cagnamiento nun fosse fatto... 'o cuntrario succeresse si 'o hash fosse curretto.",
+ "apihelp-edit-param-prependtext": "Azzecca stu testo addò 'o cap' 'e paggena. Se mettesse ncuoll'a $1text.",
+ "apihelp-edit-param-appendtext": "Azzecca stu testo addò 'o cap' 'e paggena. Se mettesse ncuoll'a $1text.\n\nAusate $1section=new pe' ne puté appennere na seziona nova, ato che ausà stu parammetro.",
+ "apihelp-edit-param-undo": "Torna arrèto sta verziona. Miette ncuollo 'o $1text, $1prependtext e $1appendtext.",
+ "apihelp-edit-param-undoafter": "Torna arreto tuttuquante verziune 'e $1undo a cchesta. Si chesto nun fosse mpustato, avit'a ffà surtanto turnà arreto na verziona.",
+ "apihelp-edit-param-redirect": "Risolve automaticamente 'e redirect.",
+ "apihelp-edit-param-contentformat": "Serializaziona 'e furmatt' 'e cuntenute ausata p' 'o testo trasuto.",
+ "apihelp-edit-param-contentmodel": "Mudell' 'e cuntenute d' 'e cuntenute nuove nuove.",
+ "apihelp-edit-param-token": "'O token s'avess'a mannà sempe comm'a ll'urdemo parammetro, o minimo minimo aropp'a 'o parammetro 'e $1text.",
+ "apihelp-edit-example-edit": "Cagna paggena.",
+ "apihelp-edit-example-prepend": "Pre-appenne <kbd>_&#95;NOTOC_&#95;</kbd> a na paggena.",
+ "apihelp-edit-example-undo": "Torna arreto 'e verziune 13579 nfin'a 13585 cu n'autosommario.",
+ "apihelp-emailuser-summary": "E-mail a n'utente.",
+ "apihelp-emailuser-param-target": "Utente a 'e quale s'avess'a mannà na mmasciata mail.",
+ "apihelp-emailuser-param-subject": "Oggetto d' 'a mail.",
+ "apihelp-emailuser-param-text": "Testo d' 'a mail.",
+ "apihelp-emailuser-param-ccme": "Manna na copia 'e sta mail a mme.",
+ "apihelp-emailuser-example-email": "Manna na e-mail a ll'utente <kbd>WikiSysop</kbd> c' 'o testo <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Spannere tuttuquante 'e template dint' 'o wikitesto.",
+ "apihelp-expandtemplates-param-title": "Titolo d' 'a paggena.",
+ "apihelp-expandtemplates-param-text": "Wikitesto 'a scagnà/convertire.",
+ "apihelp-expandtemplates-param-revid": "ID 'e cagnamento, pe' <nowiki>{{REVISIONID}}</nowiki> e variabbele ca s'assummigliassero.",
+ "apihelp-expandtemplates-param-prop": "Quale nfurmaziune s'avess'a piglià.\n\nTenite a mmente ca nun s'è scigliuto valore nisciuno, 'o risultato cuntenesse 'o codice wiki, ma l'output sarrà fatto comm'a nu furmato obsoleto.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "'O wikitext spannuto.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Ogne categurìa prisente int'a 'o valore 'e trasuta nun fosse rappresentato comm'asciuta 'e wikitesto.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "'E pruprietà 'e pagena definite p' 'e parole magiche spannute dint' 'o wikitesto.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Si l'output fosse volatile e nun s'avess'ausà n'atavota addò servesse dint' 'a paggena.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "'O tiempo massimo aropp' 'o quale 'e caches d' 'o risultato s'avessero a nzegnà invalide.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Ogne modulo ResourceLoader ch' 'e funzione parser addimannajero a s'azzeccà a ll'output. Fosse <kbd>jsconfigvars</kbd> o pure <kbd>encodedjsconfigvars</kbd> s'avesser'addimannà tutte 'nzieme pe' bbìa d' 'e <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Dà nfurmaziune 'e variabbele 'e mpustaziona JavaScript specifiche 'a paggena.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Dà 'e variabbele 'e mpustaziona 'e JavaScript specifiche 'a na paggena comm'a na stringa JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "L'albero 'e parse XML 'a ll'input.",
+ "apihelp-expandtemplates-param-includecomments": "Si s'avess'azzeccà cocche cummento HTML dint'a ll'output.",
+ "apihelp-expandtemplates-param-generatexml": "Generà ll'albero XML (scagnato 'a $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Spanne 'o wikitesto <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Tuorna nu feed 'e cuntribbute 'utente.",
+ "apihelp-feedcontributions-param-feedformat": "'O furmato d' 'o feed.",
+ "apihelp-feedcontributions-param-user": "'A quale 'utente nc'avimm'a piglià cuntribbute.",
+ "apihelp-feedcontributions-param-namespace": "'A qualu namespace s'avesser'a filtrà 'e cuntribbute.",
+ "apihelp-feedcontributions-param-year": "'E ll'anno (e primma).",
+ "apihelp-feedcontributions-param-month": "D' 'o mese (e pure cchiù primma).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrà cuntribbute ca teneno sti ttag.",
+ "apihelp-feedcontributions-param-deletedonly": "Mmusta surtant' 'e cuntribbute scancellate.",
+ "apihelp-feedcontributions-param-toponly": "Fà vedé sulamente 'e contribbute 'e l'urdeme verziune.",
+ "apihelp-feedcontributions-param-newonly": "Fà vedé sulamente 'e contribbute ca songo criazione 'e paggene.",
+ "apihelp-feedcontributions-param-showsizediff": "Fà vedé 'a differenza nfra verziune.",
+ "apihelp-feedcontributions-example-simple": "Tuòrna cuntribbute 'a ll'utente <kbd>Esempio</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Tuorna 'o blocco 'e nutizie 'e ll'urdeme cagnamiente.",
+ "apihelp-feedrecentchanges-param-feedformat": "'O furmato d' 'o feed.",
+ "apihelp-feedwatchlist-param-feedformat": "'O furmato d' 'o feed.",
+ "apihelp-filerevert-param-comment": "Carreca commento.",
+ "apihelp-help-summary": "Fà veré l'aiuto p' 'e module specificate",
+ "apihelp-help-param-submodules": "Azzecca n'aiuto p' 'e submodule 'e nu modulo ca téne nome.",
+ "apihelp-login-example-login": "Tràse.",
+ "apihelp-move-summary": "Mòve paggena.",
+ "apihelp-opensearch-param-search": "Ascìa stringa.",
+ "apihelp-opensearch-param-format": "'O furmato 'e ll'output."
+}
diff --git a/www/wiki/includes/api/i18n/nb.json b/www/wiki/includes/api/i18n/nb.json
new file mode 100644
index 00000000..8b86af58
--- /dev/null
+++ b/www/wiki/includes/api/i18n/nb.json
@@ -0,0 +1,263 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jeblad",
+ "Chameleon222",
+ "Macofe",
+ "Jon Harald Søby",
+ "Event",
+ "Kingu"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Dokumentasjon]]\n* [[mw:Special:MyLanguage/API:FAQ|Ofte stilte spørsmål]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api E-post-liste]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API-kunngjøringer]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Feil & forespørsler]\n</div>\n<strong>Status:</strong> Alle funksjonene som vises på denne siden skal virke, men API-en er fortsatt i aktiv utvikling, og kan bli endret når som helst. Abonner på [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ MediaWiki sin API-kunnkjøringsepostliste] for nyheter om oppdateringer.\n\n<strong>Feile kall:</strong> Hvis det blir sendt feile kall til API-et, blir det sendt en HTTP-header med nøkkelen \"MediaWiki-API-Error\" og da blir både header-verdien og feilkoden sendt tilbake med samme verdi. For mer informasjon se [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Feil og advarsler]].\n\n<strong>Testing:</strong> For enkelt å teste API-kall, se [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Hvilken handling skal utføres",
+ "apihelp-main-param-format": "Resultatets format.",
+ "apihelp-main-param-maxlag": "Maksimal forsinkelse kan brukes når MediaWiki er installert på et database-replikert cluster. For å unngå operasjoner som forårsaker replikasjonsforsinkelser, kan denne parameteren få klienten til å vente til replikasjonsforinkelsen er mindre enn angitt verdi. I tilfelle ytterliggående forsinkelser, blir feilkoden <samp>maxlag</samp> returnert med en melding som <samp>Venter på $host: $lag sekunders forsinkelse</samp>.<br />Se [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Maxlag parameter]] for mer informasjon.",
+ "apihelp-main-param-smaxage": "Sett <code>s-maxage</code> HTTP cache control header til dette antall sekunder. Feil blir aldri mellomlagret.",
+ "apihelp-main-param-maxage": "Set <code>max-age</code> HTTP cache control header til dette antall sekunder. Feil blir aldri mellomlagret.",
+ "apihelp-main-param-assert": "Verifiser at brukeren er logget inn om satt til <kbd>user</kbd>, eller har botrettighet om satt til <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Verifiser at den gjeldende brukeren er den navngitte brukeren.",
+ "apihelp-main-param-requestid": "En gitt verdi her vil inkluderes i responsen. Kan brukes til å skille forespørsler fra hverandre.",
+ "apihelp-main-param-servedby": "Inkluder navnet på tjeneren som utførte forespørselen i resultatene.",
+ "apihelp-main-param-curtimestamp": "Inkluder det nåværende tidsmerket i resultatet.",
+ "apihelp-main-param-responselanginfo": "Inkluder språkene brukt for <var>uselang</var> og <var>errorlang</var> i resultatet.",
+ "apihelp-main-param-origin": "Når man aksesserer API-en som bruker en domene-kryssende AJAX-forespørsel (CORS), sett denne til det opprinnelige domenet. Denne må tas med i alle pre-flight-forespørsler, og derfor være en del av spørre-URI-en (ikke POST-kroppen).\n\nFor autentiserte forespørsler må denne stemme helt med en av de opprinnelige i <code>Origin</code>-headeren, slik at den må settes til noe a la <kbd>https://en.wikipedia.org</kbd> eller <kbd>https://meta.wikimedia.org</kbd>. Hvis denne parameteren ikke stemmer med <code>Origin</code>-headeren, returneres et 403-svar. Hvis denne parameteren stemmer med <code>Origin</code>-headeren og originalen er hvitlistet, vil <code>Access-Control-Allow-Origin</code> og <code>Access-Control-Allow-Credentials</code>-headere bli satt.\n\nFor ikke-autentiserte forepørsler, spesifiser <kbd>*</kbd>. Denne vil gjøre at <code>Access-Control-Allow-Origin</code>-headeren blir satt, men <code>Access-Control-Allow-Credentials</code> blir <code>false</code> og alle bruerspesifikke data blir begrenset.",
+ "apihelp-main-param-uselang": "Språk å bruke for meldingsoversettelser. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> med <kbd>siprop=languages</kbd> returnerer en liste over språkkoder, eller spesifiser <kbd>user</kbd> for å bruke den nåværende brukerens språkpreferanser, eller spesifiser <kbd>content</kbd> for å bruke denne wikiens innholdsspråk.",
+ "apihelp-main-param-errorformat": "Formater som kan brukes for advarsels- og feiltekster.\n; plaintext: Wikitext der HTML-tagger er fjernet og elementer byttet ut.\n; wikitext: Ubehandlet wikitext.\n; html: HTML.\n; raw: Meldingsnøkler og -parametre.\n; none: Ingen tekst, bare feilkoder.\n; bc: Format brukt før MediaWiki 1.29. <var>errorlang</var> og <var>errorsuselocal</var> ses bort fra.",
+ "apihelp-main-param-errorlang": "Språk som skal brukes for advarsler og feil. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> med <kbd>siprop=languages/<kbd> returnerer ei liste over språkkoder, eller angi <kbd>content</kbd> for å bruke wikiens innholdsspråk, eller angi <kbd>uselang</kbd> for å bruke samme verdi som <var>uselang</var>-parameteren.",
+ "apihelp-main-param-errorsuselocal": "Hvis gitt, vil feiltekster bruke lokalt tilpassede meldinger fra {{ns:MediaWiki}}-navnerommet.",
+ "apihelp-block-summary": "Blokker en bruker.",
+ "apihelp-block-param-user": "Brukernavn, IP-adresse eller IP-intervall som skal blokkeres. Kan ikke brukes sammen med <var>$1userid</var>",
+ "apihelp-block-param-userid": "Bruker-ID som skal blokkeres. Kan ikke brukes sammen med <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Utløpstid. Kan være relativ (f.eks. <kbd>5 months</kbd> eller <kbd>2 weeks</kbd>) eller absolutt (f.eks. <kbd>2014-09-18T12:34:56Z</kbd>). Om den er satt til <kbd>infinite</kbd>, <kbd>indefinite</kbd> eller <kbd>never</kbd> vil blokkeringen ikke ha noen utløpsdato.",
+ "apihelp-block-param-reason": "Årsak for blokkering.",
+ "apihelp-block-param-anononly": "Blokker bare anonyme brukere (dvs. hindre anonyme redigeringer fra denne IP-adressen).",
+ "apihelp-block-param-nocreate": "Hindre kontoopprettelse.",
+ "apihelp-block-param-autoblock": "Blokker automatisk sist brukte IP-adresse og alle etterfølgende IP-adresser de prøver å logge inn fra.",
+ "apihelp-block-param-noemail": "Hindre brukeren å sende e-post via wikien. (Krever rettigheten <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Skjul brukernavnet fra blokkeringsloggen. (Krever rettigheten <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "La brukeren redigere sin egen diskusjonsside (avhenger av <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Overstyr den gamle blokkeringen om brukeren allerede er blokkert.",
+ "apihelp-block-param-watchuser": "Overvåk brukerens eller IP-adressas bruker- og brukerdiskusjonssider.",
+ "apihelp-block-param-tags": "Endre taggene slik at de brukes på elementet i blokk-loggen.",
+ "apihelp-block-example-ip-simple": "Blokker adressa <kbd>192.0.2.5</kbd> i tre dager med årsak <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Blokker brukeren <kbd>Vandal</kbd> på ubestemnt tid med årsak <kbd>Vandalism</kbd>, og forhindre ny kontooppretting og sending av epost.",
+ "apihelp-changeauthenticationdata-summary": "Endre autentiseringsdata for den nåværende brukeren.",
+ "apihelp-changeauthenticationdata-example-password": "Forsøk å endre den gjeldende brukerens passord til <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Sjekk gyldigheten til et tegn fra <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Type tegn som testes.",
+ "apihelp-checktoken-param-token": "Tegn å teste.",
+ "apihelp-checktoken-param-maxtokenage": "Maksimalt tillatt alder på tegnet, i sekunder.",
+ "apihelp-checktoken-example-simple": "Test gyldigheten til et <kbd>csrf</kbd>-tegn.",
+ "apihelp-clearhasmsg-summary": "Fjerner <code>hasmsg</code>-flagget for den aktuelle brukeren.",
+ "apihelp-clearhasmsg-example-1": "Fjern <code>hasmsg</code>-flagget for aktuell bruker.",
+ "apihelp-clientlogin-summary": "Logg inn på wikien med den interaktive flyten.",
+ "apihelp-clientlogin-example-login": "Start prosessen med å logge inn til wikien som bruker <kbd>Example</kbd> med passord <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Fortsett å logge inn etter en <samp>UI</samp>-respons for tofaktor-autentisering, ved å oppgi en <var>OATHToken</var> på <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Hent forskjellen mellom to sider.",
+ "apihelp-compare-extended-description": "Et revisjonsnummer, en sidetittel eller en side-ID for både «fra» og «til» må sendes.",
+ "apihelp-compare-param-fromtitle": "Første tittel å sammenligne.",
+ "apihelp-compare-param-fromid": "Første side-ID å sammenligne.",
+ "apihelp-compare-param-fromrev": "Første revisjon å sammenligne.",
+ "apihelp-compare-param-fromtext": "Bruk denne teksten i stedet for innholdet i revisjonen som angis med <var>fromtitle</var>, <var>fromid</var> eller <var>fromrev</var>.",
+ "apihelp-compare-param-frompst": "Gjør en transformering av <var>fromtext</var> før lagring.",
+ "apihelp-compare-param-fromcontentmodel": "Innholdsmodell for <var>fromtext</var>. Om den ikke angis vil den gjettes basert på de andre parameterne.",
+ "apihelp-compare-param-fromcontentformat": "Innholdsserialiseringsformat for <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "Andre tittel å sammenligne.",
+ "apihelp-compare-param-toid": "Andre side-ID å sammenligne.",
+ "apihelp-compare-param-torev": "Andre revisjon å sammenligne.",
+ "apihelp-compare-param-totext": "Bruk denne teksten i stedet for innholdet i revisjonen spesifisert av <var>totitle</var>, <var>toid</var> eller <var>torev</var>.",
+ "apihelp-compare-param-topst": "Gjør en transformering av <var>totext</var> før lagring.",
+ "apihelp-compare-param-tocontentmodel": "Innholdsmodellen til <var>totext</var>. Om denne ikke angis vil den bli gjettet ut fra andre parametere.",
+ "apihelp-compare-param-tocontentformat": "Innholdsserialiseringsformat for <var>totext</var>.",
+ "apihelp-compare-param-prop": "Hvilke informasjonsdeler som skal hentes.",
+ "apihelp-compare-paramvalue-prop-diff": "Diffens HTML.",
+ "apihelp-compare-paramvalue-prop-diffsize": "Størrelsen på diffens HTML i byte.",
+ "apihelp-compare-paramvalue-prop-rel": "Revisjons-ID-en for revisjonene foran «from» og etter «to», om de finnes.",
+ "apihelp-compare-paramvalue-prop-ids": "Side- og revisjons-ID-ene til «from»- og «to»-revisjonene.",
+ "apihelp-compare-paramvalue-prop-title": "Sidetitlene for «from»- og «to»-revisjonene.",
+ "apihelp-compare-paramvalue-prop-user": "Brukernavnet og ID-en til «from»- og «to»-revisjonene.",
+ "apihelp-compare-paramvalue-prop-comment": "Kommentaren til «from»- og «to»-revisjonene.",
+ "apihelp-compare-paramvalue-prop-size": "Størrelsen til «from»- og «to»-revisjonene.",
+ "apihelp-compare-example-1": "Lag en diff mellom revisjon 1 og 2.",
+ "apihelp-createaccount-summary": "Opprett en ny brukerkonto.",
+ "apihelp-createaccount-param-preservestate": "Om <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> returnerte true for <samp>hashprimarypreservedstate</samp> bør forespørsler merket som <samp>primary-required</samp> omgås. Om den returnerte en ikke-tom verdi for <samp>preservedusername</samp> kan det brukernavnet brukes for <var>username</var>-parameteren.",
+ "apihelp-createaccount-example-create": "Start prosessen med å opprette brukeren <kbd>Example</kbd> med passordet <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Brukernavn.",
+ "apihelp-createaccount-param-password": "Passord (ignorert dersom <var>$1mailpassword</var> er satt).",
+ "apihelp-createaccount-param-domain": "Domene for ekstern autentisering (valgfritt).",
+ "apihelp-createaccount-param-token": "Kontoopprettingstegn som hentet i den første forespørselen.",
+ "apihelp-createaccount-param-email": "Brukerens e-postadresse (valgfritt).",
+ "apihelp-createaccount-param-realname": "Brukerens virkelige navn (valgfritt).",
+ "apihelp-createaccount-param-mailpassword": "Dersom satt til en verdi vil et tilfeldig passord bli sendt med e-post til brukeren.",
+ "apihelp-createaccount-param-reason": "Valgfri grunn for å opprette kontoen for å legges i loggene.",
+ "apihelp-createaccount-param-language": "Språkkode å bruke som standard for brukeren (valgfritt, standardverdien er innholdsspråket).",
+ "apihelp-createaccount-example-pass": "Opprett bruker <kbd>testuser</kbd> med passordet <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Opprett bruker <kbd>testmailuser</kbd> og send et tilfeldig generert passord med e-post.",
+ "apihelp-cspreport-summary": "Brukes av nettlesere for å rapportere brudd på Content Security Policy. Denne modulen bør aldri brukes utenom av en CSP-mottakelig nettleser.",
+ "apihelp-cspreport-param-source": "Hva som genererte CSP-headeren som utløste denne rapporten",
+ "apihelp-delete-summary": "Slett en side.",
+ "apihelp-delete-param-title": "Tittel til siden som skal slettes. Kan ikke brukes sammen med <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Side-ID til siden som skal slettes. Kan ikke brukes sammen med <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Årsak for slettingen. Dersom ikke satt vil en automatisk generert årsak bli brukt.",
+ "apihelp-delete-param-tags": "Endringstagger å legge til oppslaget i slettingsloggen.",
+ "apihelp-delete-param-watch": "Legg til siden til aktuell brukers overvåkningsliste.",
+ "apihelp-delete-param-unwatch": "Fjern siden fra aktuell brukers overvåkningsliste.",
+ "apihelp-delete-param-oldimage": "Navnet på det gamle bildet som skal slettes som oppgitt av [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Slett <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Slett <kbd>Main Page</kbd> med grunnen <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Denne modulen har blitt deaktivert",
+ "apihelp-edit-summary": "Opprett og rediger sider.",
+ "apihelp-edit-param-title": "Tittelen til siden som skal redigeres. Kan ikke brukes sammen med <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Side-ID til siden som skal redigeres. Kan ikke brukes sammen med <var>$1title</var>.",
+ "apihelp-edit-param-section": "Avsnittsnummer. <kbd>0</kbd> for det øverste avsnittet, <kbd>new</kbd> for et nytt avsnitt.",
+ "apihelp-edit-param-sectiontitle": "Tittelen for et nytt avsnitt.",
+ "apihelp-edit-param-text": "Sideinnhold.",
+ "apihelp-edit-param-summary": "Redigeringssammendrag. Også seksjonstittel når $1section=new og $1sectiontitle ikke er satt.",
+ "apihelp-edit-param-tags": "Endringstagger som skal brukes på revisjonen.",
+ "apihelp-edit-param-minor": "Mindre redigering.",
+ "apihelp-edit-param-notminor": "Ikke mindre redigering.",
+ "apihelp-edit-param-bot": "Merk denne redigeringen som en botendring.",
+ "apihelp-edit-param-basetimestamp": "Tidsstempel for grunnrevisjonen, brukes for å oppdage redigeringskonflikter. Kan hentes via [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Tidsstempel for når redigeringsprosessen begynte, brukes for å oppdage redigeringskonflikter. En gyldig verdi kan hentes med <var>[[Special:ApiHelp/main|curtimestamp]]</var> når man begynner en redigeringsprosess (f.eks. når man laster sideinnholdet som redigeres).",
+ "apihelp-edit-param-recreate": "Overstyr feil om at siden har blitt slettet i mellomtiden.",
+ "apihelp-edit-param-createonly": "Ikke rediger siden dersom den finnes allerede.",
+ "apihelp-edit-param-nocreate": "Gi en feilmelding dersom dersom siden ikke finnes.",
+ "apihelp-edit-param-watch": "Legg til siden til aktuell brukers overvåkningsliste.",
+ "apihelp-edit-param-unwatch": "Fjern siden fra aktuell brukers overvåkningsliste.",
+ "apihelp-edit-param-prependtext": "Legg til denne teksten til starten av siden. Overstyrer $1text.",
+ "apihelp-edit-param-redirect": "Bestem omdirigeringer automatisk.",
+ "apihelp-edit-param-contentformat": "Innholdsserialiseringsformat brukt for inndatateksten.",
+ "apihelp-edit-param-contentmodel": "Det nye innholdets innholdsmodell.",
+ "apihelp-edit-example-edit": "Rediger en side.",
+ "apihelp-emailuser-summary": "Send e-post til en bruker.",
+ "apihelp-emailuser-param-target": "Bruker som det skal sendes e-post til.",
+ "apihelp-emailuser-param-subject": "Emne.",
+ "apihelp-emailuser-param-text": "E-post innhold.",
+ "apihelp-emailuser-param-ccme": "Send en kopi av denne e-posten til meg.",
+ "apihelp-expandtemplates-summary": "Ekspanderer alle maler i wikitekst.",
+ "apihelp-expandtemplates-param-title": "Sidetittel.",
+ "apihelp-expandtemplates-param-text": "Wikitekst som skal konverteres.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Den utvidede wikiteksten.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Kategorier som er tilstede i innputt som ikke representeres i utputt.",
+ "apihelp-feedcontributions-param-year": "Fra år (og tidligere).",
+ "apihelp-feedcontributions-param-month": "Fra måned (og tidligere).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrer bidrag som har disse merkene.",
+ "apihelp-feedcontributions-param-deletedonly": "Vis bare slettede bidrag.",
+ "apihelp-feedcontributions-param-toponly": "Vis kun redigeringer som er gjeldende revisjoner.",
+ "apihelp-feedcontributions-param-newonly": "Bare vis bidrag som er sideopprettinger.",
+ "apihelp-feedcontributions-param-hideminor": "Skjul mindre endringer.",
+ "apihelp-feedcontributions-param-showsizediff": "Vis størrelsesforskjellen mellom revisjoner.",
+ "apihelp-feedcontributions-example-simple": "Returner bidrag for brukeren <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-param-feedformat": "Matingens format.",
+ "apihelp-feedrecentchanges-param-namespace": "Navnerom resultater skal begrenses til.",
+ "apihelp-feedrecentchanges-param-invert": "Alle navnerom utenom det valgte.",
+ "apihelp-feedrecentchanges-param-associated": "Inkluder tilknyttede navnerom (diskusjons- eller hovednavnerom).",
+ "apihelp-feedrecentchanges-param-days": "Dager resultatene skal begrenses til.",
+ "apihelp-feedrecentchanges-param-limit": "Maksimalt antall resultater som skal returneres",
+ "apihelp-feedrecentchanges-param-from": "Vis endringer siden da.",
+ "apihelp-feedrecentchanges-param-hideminor": "Skjul mindre endringer.",
+ "apihelp-feedrecentchanges-param-hidebots": "Skjul botendringer.",
+ "apihelp-feedrecentchanges-param-hideanons": "Skjul endringer gjort av anonyme brukere.",
+ "apihelp-feedrecentchanges-param-hideliu": "Skjul endringer gjort av registrerte brukere.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Skjul patruljerte endringer.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Skjul endringer gjort av den aktuelle brukeren.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Skjul endringer i kategorimedlemsskap.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrer etter tagger.",
+ "apihelp-feedrecentchanges-param-target": "Vis bare endringer på sider som lenkes fra denne siden.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Vis endringer på sider som lenker til den valgte siden i stedet.",
+ "apihelp-feedrecentchanges-param-categories": "Vis bare endringer på sider i alle disse kategoriene.",
+ "apihelp-feedrecentchanges-param-categories_any": "Vis bare endringer på sider som er i noen av kategoriene i stedet.",
+ "apihelp-feedrecentchanges-example-simple": "Vis siste endringer.",
+ "apihelp-feedrecentchanges-example-30days": "Vis siste endringer for 30 døgn.",
+ "apihelp-feedwatchlist-summary": "Returnerer en overvåkningslistemating.",
+ "apihelp-feedwatchlist-param-feedformat": "Matingens format.",
+ "apihelp-filerevert-summary": "Tilbakestill en fil til en gammel versjon.",
+ "apihelp-filerevert-param-filename": "Målfilnavn, uten prefikset File:.",
+ "apihelp-filerevert-param-comment": "Opplastingskommentar.",
+ "apihelp-filerevert-example-revert": "Tilbakestiller <kbd>Wiki.png</kbd> til versjonen fra <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Vis hjelp for de gitte modulene.",
+ "apihelp-help-param-modules": "Moduler det skal vises hjelp for (verdiene til <var>action</var>- og <var>format</var>-parameterne, eller <kbd>main</kbd>). Kan angi undermoduler med en <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Inkluder hjelp for undermoduler av den navngitte modulen.",
+ "apihelp-help-param-recursivesubmodules": "Inkluder hjelp for undermoduler rekursivt.",
+ "apihelp-help-param-helpformat": "Format for hjelperesultatet.",
+ "apihelp-help-param-wrap": "Omgi resultatet i en standard API-responsstruktur.",
+ "apihelp-help-param-toc": "Inkluder en innholdsfortegnelse i HTML-utdataen.",
+ "apihelp-help-example-main": "Hjelp for hovedmodulen.",
+ "apihelp-help-example-submodules": "Hjelp for <kbd>action=query</kbd> og alle dens undermoduler.",
+ "apihelp-help-example-recursive": "All hjelp på en side.",
+ "apihelp-help-example-help": "Hjelp for selve hjelpemodulen.",
+ "apihelp-help-example-query": "Hjelp for to utspørringsundermoduler.",
+ "apihelp-imagerotate-summary": "Roter ett eller flere bilder.",
+ "apihelp-imagerotate-param-rotation": "Grader bildet skal roteres med klokka.",
+ "apihelp-imagerotate-param-tags": "Tagger som skal legges til oppslaget i opplastingsloggen.",
+ "apihelp-imagerotate-example-simple": "Roter <kbd>File:Example.png</kbd> <kbd>90</kbd> grader.",
+ "apihelp-imagerotate-example-generator": "Roter alle bilder i <kbd>Category:Flip</kbd> <kbd>180</kbd> grader.",
+ "apihelp-import-summary": "Importer en side fra en annen wiki eller fra en XML-fil.",
+ "apihelp-import-extended-description": "Merk at HTTP POST må gjøres som filopplasting (altså med bruk av multipart/form-data) når man sender en fil for parameteren <var>xml</var>.",
+ "apihelp-import-param-summary": "Sammendrag for importering av loggelement.",
+ "apihelp-import-param-xml": "Opplastet XML-fil.",
+ "apihelp-import-param-interwikisource": "For interwikiimport: wiki det skal importeres fra.",
+ "apihelp-import-param-interwikipage": "For interwikiimport: side som skal importeres.",
+ "apihelp-import-param-fullhistory": "For interwikiimport: importer hele historikken, ikke bare den nåværende versjonen.",
+ "apihelp-import-param-templates": "For interwikiimport: importer alle inkluderte maler i tillegg.",
+ "apihelp-import-param-namespace": "Importer til dette navnerommet: Kan ikke brukes sammen med <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importer som underside av denne siden. Kan ikke brukes sammen med <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Endringstagger som skal klistres på oppføringen i importloggen og nullrevisjonen til de importerte sidene.",
+ "apihelp-import-example-import": "Importer [[meta:Help:ParserFunctions]] til navnerom 100 med full historikk.",
+ "apihelp-login-param-name": "Brukernavn.",
+ "apihelp-login-param-password": "Passord.",
+ "apihelp-login-param-domain": "Domene (valgfritt).",
+ "apihelp-login-example-gettoken": "Henter innloggingstegn.",
+ "apihelp-login-example-login": "Logg inn.",
+ "apihelp-logout-summary": "Logg ut og fjern sesjonsdata.",
+ "apihelp-logout-example-logout": "Logg ut den aktuelle brukeren.",
+ "apihelp-managetags-example-delete": "Slett taggen <kbd>vandlaism</kbd> med årsaken <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Aktiver taggen <kbd>spam</kbd> med årsak <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Deaktiver taggen med navn <kbd>spam</kbd> med årsak <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Flett sidehistorikker.",
+ "apihelp-mergehistory-param-from": "Tittelen på siden historikken skal flettes fra. Kan ikke brukes sammen med <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "Side-ID-en til siden historikken skal flettes fra. Kan ikke brukes sammen med <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Tittelen på siden historikken skal flettes til. Kan ikke brukes sammen med <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "Side-ID-en til siden historikken skal flettes til. Kan ikke brukes sammen med <var>$1to</var>.",
+ "apihelp-mergehistory-param-reason": "Årsak for fletting av historikk.",
+ "apihelp-mergehistory-example-merge": "Flett hele historikken til <kbd>Oldpage</kbd> inn i <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Flett siderevisjonene av <kbd>Oldpage</kbd> til og med <kbd>2015-12-31T04:37:41Z</kbd> inn i <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Flytt en side.",
+ "apihelp-move-param-from": "Tittelen på siden det skal endres navn på. Kan ikke brukes sammen med <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "Side-ID til siden det skal endres navn på. Kan ikke brukes sammen med <var>$1from</var>.",
+ "apihelp-move-param-to": "Tittelen siden skal endre navn til.",
+ "apihelp-move-param-reason": "Årsak for navneendring.",
+ "apihelp-move-param-movetalk": "Bytt navn på diskusjonssiden om den finnes.",
+ "apihelp-move-param-movesubpages": "Bytt navn på undersider, om mulig.",
+ "apihelp-move-param-noredirect": "Ikke opprett en omdirigering.",
+ "apihelp-move-param-watch": "Legg til siden og omdirigeringen i den gjeldende brukerens overvåkningsliste.",
+ "apihelp-move-param-unwatch": "Fjern siden og omdirigeringen fra den gjeldende brukerens overvåkningsliste.",
+ "apihelp-opensearch-param-search": "Søkestreng.",
+ "apihelp-opensearch-param-limit": "Maksimalt antall resultater som skal returneres.",
+ "apihelp-opensearch-param-namespace": "Navnerom det skal søkes i.",
+ "apihelp-opensearch-param-suggest": "Gjør ingenting om <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> er falsk.",
+ "apihelp-opensearch-param-format": "Resultatets format.",
+ "apihelp-opensearch-example-te": "Finn sider som begynner på <kbd>Te</kbd>.",
+ "apihelp-options-param-reset": "Tilbakestiller innstillingene til sidestandarden.",
+ "apihelp-options-example-reset": "Tilbakestill alle innstillinger.",
+ "apihelp-options-example-change": "Endre innstillinger for <kbd>skin</kbd> og <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Tilbakestill alle innstillinger, og sett så <kbd>skin</kbd> og <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Hent informasjon om API-moduler.",
+ "apihelp-paraminfo-param-helpformat": "Format for hjelpestrenger.",
+ "apihelp-json-summary": "Resultatdata i JSON-format.",
+ "apihelp-none-summary": "Ingen resultat.",
+ "api-help-flag-readrights": "Denne modulen krever lesetilgang.",
+ "api-help-flag-writerights": "Denne modulen krever skrivetilgang.",
+ "api-help-flag-mustbeposted": "Denne modulen aksepterer bare POST forespørsler.",
+ "api-help-flag-generator": "Denne modulen kan brukes som en generator.",
+ "api-help-parameters": "{{PLURAL:$1|Parameter|Parametre}}:",
+ "api-help-param-deprecated": "Utgått.",
+ "api-help-param-required": "Denne parameteren er påkrevd.",
+ "apierror-multival-only-one": "Bare én verdi er tillatt for parameteret <var>$1</var>.",
+ "apierror-mustbeloggedin": "Du må være logget inn for å $1.",
+ "apierror-offline": "Kunne ikke fortsette på grunn av tilkoblingsproblemer. Sjekk at internettforbindelsen din virker og prøv igjen.",
+ "apierror-permissiondenied-generic": "Tilgang nektet.",
+ "apierror-timeout": "Tjeneren svarte ikke innenfor forventet tid.",
+ "apiwarn-validationfailed": "Bekreftelsesfeil <kbd>$1</kbd>: $2"
+}
diff --git a/www/wiki/includes/api/i18n/nds.json b/www/wiki/includes/api/i18n/nds.json
new file mode 100644
index 00000000..3f7cb12e
--- /dev/null
+++ b/www/wiki/includes/api/i18n/nds.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Servien"
+ ]
+ },
+ "apihelp-login-param-password": "Passwoort."
+}
diff --git a/www/wiki/includes/api/i18n/ne.json b/www/wiki/includes/api/i18n/ne.json
new file mode 100644
index 00000000..1a122c65
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ne.json
@@ -0,0 +1,13 @@
+{
+ "@metadata": {
+ "authors": [
+ "Nirjal stha",
+ "सरोज कुमार ढकाल"
+ ]
+ },
+ "apihelp-createaccount-param-name": "प्रयोगकर्ता नाम।",
+ "apihelp-edit-param-minor": "सामान्य सम्पादन।",
+ "apihelp-edit-example-edit": "पृष्ठ सम्पादन गर्नुहोस्।",
+ "apihelp-emailuser-summary": "प्रयोगकर्तालाई इमेल गर्नुहोस्।",
+ "apihelp-parse-param-prop": "जानकारीको कुन भाग लिनेः"
+}
diff --git a/www/wiki/includes/api/i18n/nl.json b/www/wiki/includes/api/i18n/nl.json
new file mode 100644
index 00000000..ff99dff7
--- /dev/null
+++ b/www/wiki/includes/api/i18n/nl.json
@@ -0,0 +1,392 @@
+{
+ "@metadata": {
+ "authors": [
+ "Siebrand",
+ "Sjoerddebruin",
+ "Robin0van0der0vliet",
+ "Mar(c)",
+ "Valhallasw",
+ "Sikjes",
+ "Macofe",
+ "SPQRobin",
+ "HanV",
+ "Rangekill",
+ "Robin van der Vliet",
+ "Edoderoo",
+ "Lemondoge",
+ "Hex",
+ "Mainframe98"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentatie]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api E-maillijst]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API-aankondigingen]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & verzoeken]\n</div>\n<strong>Status:</strong> Alle functies die op deze pagina worden weergegeven horen te werken. Aan de API wordt actief gewerkt, en deze kan gewijzigd worden. Abonneer u op de [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ e-maillijst mediawiki-api-announce] voor meldingen over aanpassingen.\n\n<strong>Foutieve verzoeken:</strong> als de API foutieve verzoeken ontvangt, wordt er geantwoord met een HTTP-header met de sleutel \"MediaWiki-API-Error\" en daarna worden de waarde van de header en de foutcode op dezelfde waarde ingesteld. Zie [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Foutmeldingen en waarschuwingen]] voor meer informatie.\n\n<strong>Testen:</strong> u kunt [[Special:ApiSandbox|eenvoudig API-verzoeken testen]].",
+ "apihelp-main-param-action": "Welke handeling uit te voeren.",
+ "apihelp-main-param-format": "De opmaak van de uitvoer.",
+ "apihelp-main-param-maxlag": "De maximale vertraging kan gebruikt worden als MediaWiki is geïnstalleerd op een databasecluster die gebruik maakt van replicatie. Om te voorkomen dat handelingen nog meer databasereplicatievertraging veroorzaken, kan deze parameter er voor zorgen dat de client wacht totdat de replicatievertraging lager is dan de aangegeven waarde. In het geval van buitensporige vertraging, wordt de foutcode <samp>maxlag</samp> teruggegeven met een bericht als <samp>Waiting for $host: $lag seconds lagged</samp>.<br />Zie [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Handleiding:Maxlag parameter]] voor meer informatie.",
+ "apihelp-main-param-smaxage": "Stelt de <code>s-maxage</code> HTTP cache controle header in op het aangegeven aantal seconden. Foutmeldingen komen nooit in de cache.",
+ "apihelp-main-param-maxage": "Stelt de <code>max-age</code> HTTP cache controle header in op het aangegeven aantal seconden. Foutmeldingen komen nooit in de cache.",
+ "apihelp-main-param-assert": "Controleer of de gebruiker is aangemeld als <kbd>user</kbd> is meegegeven, en of de gebruiker het robotgebruikersrecht heeft als <kbd>bot</kbd> is meegegeven.",
+ "apihelp-main-param-assertuser": "Bevestig dat de huidige gebruiker de genoemde gebruiker is.",
+ "apihelp-main-param-requestid": "Elke waarde die hier gegeven wordt, wordt aan het antwoord toegevoegd. Dit kan gebruikt worden om verzoeken te onderscheiden.",
+ "apihelp-main-param-servedby": "Voeg de hostnaam van de server die de aanvraag heeft afgehandeld toe aan het antwoord.",
+ "apihelp-main-param-curtimestamp": "Huidige tijd aan het antwoord toevoegen.",
+ "apihelp-main-param-responselanginfo": "Toon de talen gebruikt voor <var>uselang</var> en <var>errorlang</var> in het resultaat.",
+ "apihelp-main-param-errorlang": "De taal om te gebruiken voor waarschuwingen en fouten. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> met <kbd>siprop=languages</kbd> toont een lijst van taalcodes, of stel <kbd>inhoud</kbd> in om gebruik te maken van de inhoudstaal van deze wiki, of stel <kbd>uselang</kbd> in om gebruik te maken van dezelfde waarde als de <var>uselang</var> parameter.",
+ "apihelp-main-param-errorsuselocal": "Indien ingesteld maken foutmeldingen gebruik van lokaal-aangepaste berichten in de {{ns:MediaWiki}} naamruimte.",
+ "apihelp-block-summary": "Gebruiker blokkeren.",
+ "apihelp-block-param-user": "Gebruikersnaam, IP-adres of IP-range om te blokkeren. Kan niet samen worden gebruikt me <var>$1userid</var>",
+ "apihelp-block-param-userid": "Gebruikers-ID om te blokkeren. Kan niet worden gebruikt in combinatie met <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Vervaldatum. Kan relatief zijn (bijv. <kbd>5 months</kbd> of <kbd>2 weeks</kbd>) of absoluut (<kbd>2014-09-18T12:34:56Z</kbd>). Indien ingesteld op <kbd>infinite</kbd>, <kbd>indefinite</kbd>, of <kbd>never</kbd> verloopt de blokkade nooit.",
+ "apihelp-block-param-reason": "Reden voor blokkade.",
+ "apihelp-block-param-anononly": "Alleen anonieme gebruikers blokkeren (uitschakelen van anonieme bewerkingen via dit IP-adres)",
+ "apihelp-block-param-nocreate": "Voorkom registeren van accounts.",
+ "apihelp-block-param-autoblock": "Blokkeer automatisch het laatst gebruikte IP-adres en ieder volgend IP-adres van waaruit ze proberen aan te melden.",
+ "apihelp-block-param-noemail": "Gebruiker weerhouden van het sturen van e-mail. (Vereist het <code>blockemail</code> recht).",
+ "apihelp-block-param-hidename": "Verberg de gebruikersnaam uit het blokkeerlogboek. (Vereist het <code>hideuser</code> recht).",
+ "apihelp-block-param-allowusertalk": "De gebruiker toestaan om hun eigen overlegpagina te bewerken (afhankelijk van <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "De huidige blokkade aanpassen als de gebruiker al geblokkeerd is.",
+ "apihelp-block-param-watchuser": "De gebruikerspagina en overlegpagina van de gebruiker of het IP-adres volgen.",
+ "apihelp-block-param-tags": "Wijzigingslabels om toe te passen op de regel in het blokkeerlogboek.",
+ "apihelp-block-example-ip-simple": "Het IP-adres <kbd>192.0.2.5</kbd> voor drie dagen blokkeren met <kbd>First strike</kbd> als opgegeven reden.",
+ "apihelp-block-example-user-complex": "Blokkeer gebruiker<kbd>Vandal</kbd> voor altijd met reden <kbd>Vandalism</kbd> en voorkom het aanmaken van nieuwe accounts en het versturen van email",
+ "apihelp-changeauthenticationdata-example-password": "Poging tot het wachtwoord van de huidige gebruiker te veranderen naar <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Controleer de geldigheid van een token van <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Tokentype wordt getest.",
+ "apihelp-checktoken-param-token": "Token om te controleren.",
+ "apihelp-checktoken-param-maxtokenage": "Maximum levensduur van de token, in seconden.",
+ "apihelp-checktoken-example-simple": "Test de geldigheid van een <kbd>csrf</kbd> token.",
+ "apihelp-clearhasmsg-summary": "Wist de <code>hasmsg</code> vlag voor de huidige gebruiker.",
+ "apihelp-clearhasmsg-example-1": "Wis de <code>hasmsg</code> vlag voor de huidige gebruiker.",
+ "apihelp-clientlogin-summary": "Log in op de wiki met behulp van de interactieve flow.",
+ "apihelp-clientlogin-example-login": "Start het inlogproces op de wiki als gebruiker <kbd>Example</kbd> met wachtwoord <kbd>ExamplePassword</kbd>.",
+ "apihelp-compare-summary": "Toon het verschil tussen 2 pagina's.",
+ "apihelp-compare-extended-description": "Een versienummer, een paginatitel of een pagina-ID is vereist voor zowel de \"from\" en \"to\" parameter.",
+ "apihelp-compare-param-fromtitle": "Eerste paginanaam om te vergelijken.",
+ "apihelp-compare-param-fromid": "Eerste pagina-ID om te vergelijken.",
+ "apihelp-compare-param-fromrev": "Eerste versie om te vergelijken.",
+ "apihelp-compare-param-totitle": "Tweede paginanaam om te vergelijken.",
+ "apihelp-compare-param-toid": "Tweede pagina-ID om te vergelijken.",
+ "apihelp-compare-param-torev": "Tweede versie om te vergelijken.",
+ "apihelp-createaccount-summary": "Nieuwe gebruikersaccount aanmaken.",
+ "apihelp-createaccount-example-create": "Start het proces voor het aanmaken van de gebruiker <kbd>Example</kbd> met het wachtwoord <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Gebruikersnaam.",
+ "apihelp-createaccount-param-password": "Wachtwoord (genegeerd als <var>$1mailpassword</var> is ingesteld).",
+ "apihelp-createaccount-param-domain": "Domein voor externe authentificatie (optioneel).",
+ "apihelp-createaccount-param-email": "E-mailadres van de gebruiker (optioneel).",
+ "apihelp-createaccount-param-realname": "Echte naam van de gebruiker (optioneel).",
+ "apihelp-createaccount-param-reason": "Optionele reden voor het aanmaken van het account voor in het logboek.",
+ "apihelp-createaccount-param-language": "Taalcode om als standaard in te stellen voor de gebruiker (optioneel, standaard de inhoudstaal).",
+ "apihelp-createaccount-example-pass": "Maak gebruiker <kbd>testuser</kbd> aan met wachtwoord <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Maak gebruiker <kbd>testmailuser</kbd> aan en e-mail een willekeurig gegenereerd wachtwoord.",
+ "apihelp-delete-summary": "Een pagina verwijderen.",
+ "apihelp-delete-param-title": "Titel van de pagina om te verwijderen. Kan niet samen worden gebruikt met <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID van de pagina om te verwijderen. Kan niet samen worden gebruikt met <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Reden voor verwijdering. Wanneer dit niet is opgegeven wordt een automatisch gegenereerde reden gebruikt.",
+ "apihelp-delete-param-tags": "Wijzigingslabels om toe te passen op de regel in het verwijderlogboek.",
+ "apihelp-delete-param-watch": "De pagina aan de volglijst van de huidige gebruiker toevoegen.",
+ "apihelp-delete-param-unwatch": "De pagina van de volglijst van de huidige gebruiker verwijderen.",
+ "apihelp-delete-example-simple": "Verwijder <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Verwijder <kbd>Main Page</kbd> met als reden <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Deze module is uitgeschakeld.",
+ "apihelp-edit-summary": "Aanmaken en bewerken van pagina's.",
+ "apihelp-edit-param-title": "Naam van de pagina om te bewerken. Kan niet gebruikt worden samen met <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID van de pagina om te bewerken. Kan niet samen worden gebruikt met <var>$1title</var>.",
+ "apihelp-edit-param-sectiontitle": "De naam van de nieuwe sectie.",
+ "apihelp-edit-param-text": "Pagina-inhoud.",
+ "apihelp-edit-param-tags": "Wijzigingslabels om aan de versie toe te voegen.",
+ "apihelp-edit-param-minor": "Kleine bewerking.",
+ "apihelp-edit-param-notminor": "Geen kleine bewerking.",
+ "apihelp-edit-param-bot": "Deze bewerking markeren als gedaan door een robot.",
+ "apihelp-edit-param-createonly": "De pagina niet bewerken als die al bestaat.",
+ "apihelp-edit-param-nocreate": "Een foutmelding geven als de pagina niet bestaat.",
+ "apihelp-edit-param-watch": "Voeg de pagina toe aan de volglijst van de huidige gebruiker.",
+ "apihelp-edit-param-unwatch": "Verwijder de pagina van de volglijst van de huidige gebruiker.",
+ "apihelp-edit-param-md5": "De MD5-hash van de $1text parameter, of de $1prependtext en $1appendtext parameters samengevoegd. Indien ingesteld, wordt de bewerking niet gemaakt, tenzij de hash juist is.",
+ "apihelp-edit-param-prependtext": "Voeg deze tekst toe aan het begin van de pagina. Overschrijft $1text.",
+ "apihelp-edit-param-appendtext": "Voeg deze tekst toe aan het begin van de pagina. Overschrijft $1text.\n\nGebruik $1section=new in plaats van deze parameter om een nieuw kopje toe te voegen.",
+ "apihelp-edit-param-undo": "Maak deze versie ongedaan. Overschrijft $1text, $1prependtext en $1appendtext.",
+ "apihelp-edit-param-undoafter": "Maak alle versies vanaf $1undo to deze ongedaan maken. Indien niet ingesteld wordt slechts één versie ongedaan gemaakt.",
+ "apihelp-edit-param-redirect": "Doorverwijzingen automatisch oplossen.",
+ "apihelp-edit-param-contentmodel": "Inhoudsmodel van de nieuwe inhoud.",
+ "apihelp-edit-param-token": "Het token moet altijd worden verzonden als de laatste parameter, of tenminste na de $1text parameter.",
+ "apihelp-edit-example-edit": "Een pagina bewerken.",
+ "apihelp-edit-example-prepend": "Voeg <kbd>__NOTOC__</kbd> toe aan het begin van een pagina.",
+ "apihelp-edit-example-undo": "Versies 13579 tot 13585 ongedaan maken met automatische beschrijving.",
+ "apihelp-emailuser-summary": "Gebruiker e-mailen.",
+ "apihelp-emailuser-param-target": "Gebruiker naar wie de e-mail moet worden gestuurd.",
+ "apihelp-emailuser-param-subject": "Onderwerpkoptekst.",
+ "apihelp-emailuser-param-text": "E-mailtekst.",
+ "apihelp-emailuser-param-ccme": "Mij een kopie sturen van deze e-mail.",
+ "apihelp-emailuser-example-email": "Stuur een e-mail naar de gebruiker <kbd>WikiSysop</kbd> met de tekst <kbd>Inhoud</kbd>.",
+ "apihelp-expandtemplates-param-title": "Paginanaam.",
+ "apihelp-expandtemplates-param-text": "Wikitekst om om te zetten.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "De uitgevulde wikitekst.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "De maximale tijdsduur waarna de cache van het resultaat moet worden weggegooid.",
+ "apihelp-feedcontributions-summary": "Haalt de feed van de gebruikersbijdragen op.",
+ "apihelp-feedcontributions-param-feedformat": "De indeling van de feed.",
+ "apihelp-feedcontributions-param-user": "De gebruiker om de bijdragen voor te verkrijgen.",
+ "apihelp-feedcontributions-param-year": "Van jaar (en eerder).",
+ "apihelp-feedcontributions-param-month": "Van maand (en eerder).",
+ "apihelp-feedcontributions-param-deletedonly": "Alleen verwijderde bijdragen weergeven.",
+ "apihelp-feedcontributions-param-toponly": "Alleen bewerkingen die de nieuwste versies zijn weergeven.",
+ "apihelp-feedcontributions-param-newonly": "Alleen bewerkingen die nieuwe pagina's aanmaken weergeven.",
+ "apihelp-feedcontributions-param-hideminor": "Verberg kleine bewerkingen.",
+ "apihelp-feedcontributions-param-showsizediff": "Toon het verschil in grootte tussen versies.",
+ "apihelp-feedcontributions-example-simple": "Toon bijdragen voor gebruiker <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-param-feedformat": "De indeling van de feed.",
+ "apihelp-feedrecentchanges-param-namespace": "Naamruimte om de resultaten tot te beperken.",
+ "apihelp-feedrecentchanges-param-invert": "Alle naamruimten behalve de geselecteerde.",
+ "apihelp-feedrecentchanges-param-days": "Aantal dagen om de resultaten tot te beperken.",
+ "apihelp-feedrecentchanges-param-limit": "Het maximaal aantal weer te geven resultaten.",
+ "apihelp-feedrecentchanges-param-hideminor": "Kleine wijzigingen verbergen.",
+ "apihelp-feedrecentchanges-param-hidebots": "Wijzigingen gedaan door bots verbergen.",
+ "apihelp-feedrecentchanges-param-hideanons": "Wijzigingen gedaan door anonieme gebruikers verbergen.",
+ "apihelp-feedrecentchanges-param-hideliu": "Wijzigingen gedaan door geregistreerde gebruikers verbergen.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Wijzigingen gemarkeerd als gecontroleerd verbergen.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Wijzigingen door de huidige gebruiker verbergen.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Wijzigingen in categorielidmaatschap verbergen.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filteren op label.",
+ "apihelp-feedrecentchanges-example-simple": "Recente wijzigingen weergeven.",
+ "apihelp-feedrecentchanges-example-30days": "Recente wijzigingen van de afgelopen 30 dagen weergeven.",
+ "apihelp-feedwatchlist-param-feedformat": "De indeling van de feed.",
+ "apihelp-filerevert-summary": "Een oude versie van een bestand terugplaatsen.",
+ "apihelp-filerevert-param-filename": "Doel bestandsnaam, zonder het Bestand: voorvoegsel.",
+ "apihelp-filerevert-param-comment": "Opmerking voor het uploaden.",
+ "apihelp-filerevert-example-revert": "Zet <kbd>Wiki.png</kbd> terug naar de versie van <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Toon help voor de opgegeven modules.",
+ "apihelp-help-param-helpformat": "Indeling van de help uitvoer.",
+ "apihelp-help-example-main": "Hulp voor de hoofdmodule.",
+ "apihelp-help-example-submodules": "Hulp voor <kbd>action=query</kbd> en alle submodules.",
+ "apihelp-help-example-recursive": "Alle hulp op een pagina.",
+ "apihelp-help-example-help": "Help voor de help-module zelf.",
+ "apihelp-imagerotate-summary": "Een of meerdere afbeeldingen draaien.",
+ "apihelp-imagerotate-param-rotation": "Aantal graden om de afbeelding met de klok mee te draaien.",
+ "apihelp-imagerotate-param-tags": "Labels om toe te voegen aan de regel in het uploadlogboek.",
+ "apihelp-imagerotate-example-simple": "Roteer <kbd>File:Example.png</kbd> met <kbd>90</kbd> graden.",
+ "apihelp-imagerotate-example-generator": "Roteer alle afbeeldingen in <kbd>Category:Flip</kbd> met <kbd>180</kbd> graden.",
+ "apihelp-import-summary": "Importeer een pagina van een andere wiki, of van een XML bestand.",
+ "apihelp-import-extended-description": "Merk op dat de HTTP POST moet worden uitgevoerd als bestandsupload (bijv. door middel van multipart/form-data) wanneer een bestand wordt verstuurd voor de <var>xml</var> parameter.",
+ "apihelp-import-param-summary": "Importsamenvatting voor het logboek.",
+ "apihelp-import-param-xml": "Geüpload XML-bestand.",
+ "apihelp-import-param-interwikisource": "Voor interwiki imports: wiki om van te importeren.",
+ "apihelp-import-param-namespace": "Importeren in deze naamruimte. Can niet samen gebruikt worden met <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importeren als subpagina van deze pagina. Kan niet samen met <var>$1namespace</var> gebruikt worden.",
+ "apihelp-import-example-import": "Importeer [[meta:Help:ParserFunctions]] in naamruimte 100 met de volledige geschiedenis.",
+ "apihelp-login-param-name": "Gebruikersnaam.",
+ "apihelp-login-param-password": "Wachtwoord.",
+ "apihelp-login-param-domain": "Domein (optioneel).",
+ "apihelp-login-example-login": "Aanmelden",
+ "apihelp-logout-summary": "Afmelden en sessiegegevens wissen.",
+ "apihelp-logout-example-logout": "Meldt de huidige gebruiker af.",
+ "apihelp-managetags-param-tag": "Label om aan te maken, te activeren of te deactiveren. Voor het aanmaken van een label, mag het niet bestaan. Voor het verwijderen van een label, moet het bestaan. Voor het activeren van een label, moet het bestaan en mag het niet gebruikt worden door een uitbreiding. Voor het deactiveren van een label, moet het gebruikt worden en handmatig gedefinieerd zijn.",
+ "apihelp-managetags-example-create": "Maak een label met de naam <kbd>spam</kbd> aan met als reden <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Verwijder het <kbd>vandlaism</kbd> label met de reden <kbd>Misspelt</kbd>",
+ "apihelp-mergehistory-summary": "Geschiedenis van pagina's samenvoegen.",
+ "apihelp-mergehistory-param-reason": "Reden voor samenvoegen van de geschiedenis.",
+ "apihelp-mergehistory-example-merge": "Voeg de hele geschiedenis van <kbd>Oldpage</kbd> samen met <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Pagina hernoemen.",
+ "apihelp-move-param-to": "Nieuwe paginanaam.",
+ "apihelp-move-param-reason": "Reden voor de naamswijziging.",
+ "apihelp-move-param-movetalk": "Hernoem de overlegpagina, indien deze bestaat.",
+ "apihelp-move-param-noredirect": "Geen doorverwijzing achterlaten.",
+ "apihelp-move-param-watch": "Pagina en de omleiding toevoegen aan de volglijst van de huidige gebruiker.",
+ "apihelp-move-param-unwatch": "Verwijder de pagina en de doorverwijzing van de volglijst van de huidige gebruiker.",
+ "apihelp-move-param-watchlist": "De pagina onvoorwaardelijk toevoegen aan of verwijderen van de volglijst van de huidige gebruiker, gebruik voorkeuren of wijzig het volgen niet.",
+ "apihelp-move-param-ignorewarnings": "Eventuele waarschuwingen negeren.",
+ "apihelp-move-example-move": "Hernoem <kbd>Badtitle</kbd> naar <kbd>Goodtitle</kbd> zonder een doorverwijzing te laten staan.",
+ "apihelp-opensearch-summary": "Zoeken in de wiki met het OpenSearchprotocol.",
+ "apihelp-opensearch-param-search": "Zoektekst.",
+ "apihelp-opensearch-param-limit": "Het maximaal aantal weer te geven resultaten.",
+ "apihelp-opensearch-param-namespace": "Te doorzoeken naamruimten.",
+ "apihelp-opensearch-param-suggest": "Niets doen als <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> onwaar is.",
+ "apihelp-opensearch-param-redirects": "Hoe om te gaan met doorverwijzingen:\n;return:Geef de doorverwijzing terug.\n;resolve:Geef de doelpagina terug. Kan minder dan de limiet $1 resultaten teruggeven.\nOm historische redenen is de standaardinstelling \"return\" voor <code>$1format=json<code> en \"resolve\" voor andere formaten.",
+ "apihelp-opensearch-param-format": "Het uitvoerformaat.",
+ "apihelp-opensearch-param-warningsaserror": "Als er waarschuwingen zijn met <kbd>format=json</kbd>, geef dan een API-fout terug in plaats van deze te negeren.",
+ "apihelp-opensearch-example-te": "Pagina's vinden die beginnen met <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Voorkeuren van de huidige gebruiker wijzigen.",
+ "apihelp-options-extended-description": "Alleen opties die zijn geregistreerd in core of in een van de geïnstalleerde uitbreidingen, of opties met de toetsen aangeduid met <code>userjs-</code> (bedoeld om te worden gebruikt door gebruikersscripts), kunnen worden ingesteld.",
+ "apihelp-options-param-reset": "Zet de voorkeuren terug naar de standaard van de website.",
+ "apihelp-options-param-resetkinds": "Lijst van de optiestypes die opnieuw ingesteld worden wanneer de optie <var>$1reset</var> is ingesteld.",
+ "apihelp-options-param-change": "Lijst van wijzigingen, opgemaakt als <kbd>naam=waarde</kbd> (bijvoorbeeld <kbd>skin=vector</kbd>). Als er geen waarde wordt opgegeven (zelfs niet een is-gelijk teken), bijvoorbeeld <kbd>optienaam|andereoptie|...</kbd>, dan wordt de optie ingesteld op de standaardwaarde. Als een opgegeven waarde een sluisteken bevat (<kbd>|</kbd>), gebruik dan het [[Special:ApiHelp/main#main/datatypes|alternatieve scheidingsteken tussen meerdere waardes]] voor een juiste werking.",
+ "apihelp-options-param-optionname": "De naam van de optie die moet worden ingesteld op de waarde gegeven door <var>$1optiewaarde</var>.",
+ "apihelp-options-param-optionvalue": "De waarde voor de optie opgegeven door <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Alle voorkeuren opnieuw instellen.",
+ "apihelp-options-example-change": "Voorkeuren wijzigen voor <kbd>skin</kbd> en <kbd>hideminor</kbd>.",
+ "apihelp-paraminfo-summary": "Verkrijg informatie over API-modules.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Vraagt een HTML-versie van de categorieën op.",
+ "apihelp-parse-example-page": "Een pagina verwerken.",
+ "apihelp-parse-example-text": "Wikitext verwerken.",
+ "apihelp-parse-example-summary": "Een samenvatting verwerken.",
+ "apihelp-patrol-summary": "Een pagina of versie markeren als gecontroleerd.",
+ "apihelp-patrol-example-rcid": "Een recente wijziging markeren als gecontroleerd.",
+ "apihelp-patrol-example-revid": "Een versie markeren als gecontroleerd.",
+ "apihelp-protect-param-reason": "Reden voor opheffen van de beveiliging.",
+ "apihelp-protect-example-protect": "Een pagina beveiligen",
+ "apihelp-purge-param-forcelinkupdate": "Werk de koppelingstabellen bij.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Werk de koppelingentabel bij, en werk de koppelingstabellen bij voor alle pagina's die gebruik maken van deze pagina als sjabloon.",
+ "apihelp-query+allcategories-param-dir": "Richting om in te sorteren.",
+ "apihelp-query+allcategories-param-limit": "Hoeveel categorieën te tonen.",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Voegt het aantal pagina's in de categorie toe.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Markeert categorieën die verborgen zijn met <code>_&#95;HIDDENCAT_&#95;</code>",
+ "apihelp-query+alldeletedrevisions-param-tag": "Alleen versies weergeven met dit label.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Toon geen versies door deze gebruiker.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Toon alleen pagina's in deze naamruimte.",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Voegt de titel van het bestand toe.",
+ "apihelp-query+allfileusages-param-limit": "Hoeveel items er in totaal moeten worden getoond.",
+ "apihelp-query+allimages-example-recent": "Toon een lijst van recentlijk geüploade bestanden, vergelijkbaar met [[Special:NewFiles]].",
+ "apihelp-query+alllinks-param-namespace": "De naamruimte om door te lopen.",
+ "apihelp-query+alllinks-param-limit": "Hoeveel items er in totaal moeten worden getoond.",
+ "apihelp-query+allmessages-param-enableparser": "Stel in om de parser in te schakelen, zorgt voor het voorverwerken van de wikitekst van een bericht (vervangen van magische woorden, de afhandeling van sjablonen, enzovoort).",
+ "apihelp-query+allmessages-param-lang": "Toon berichten in deze taal.",
+ "apihelp-query+allmessages-param-from": "Toon berichten vanaf dit bericht.",
+ "apihelp-query+allmessages-param-to": "Toon berichten tot aan dit bericht.",
+ "apihelp-query+allredirects-summary": "Toon alle doorverwijzingen naar een naamruimte.",
+ "apihelp-query+allrevisions-example-user": "Toon de laatste 50 bijdragen van de gebruiker <kbd>Example</kbd>.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Vraag het MIME- en mediatype van het bestand op.",
+ "apihelp-query+mystashedfiles-param-limit": "Hoeveel bestanden te tonen.",
+ "apihelp-query+allusers-param-excludegroup": "Sluit gebruikers in de gegeven groepen uit.",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Voegt informatie over een actuale blokkade van de gebruiker toe.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Toont de groepen waar de gebruiker in zit. Dit gebruikt meer serverbronnen en kan minder resultaten teruggeven dat de opgegeven limiet.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Toon alle groepen de gebruiker automatisch in zit.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Toon de rechten die de gebruiker heeft.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Voegt het aantal bewerkingen van de gebruiker toe.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Voegt de registratiedatum van de gebruiker toe, indien beschikbaar (kan leeg zijn).",
+ "apihelp-query+allusers-param-witheditsonly": "Toon alleen gebruikers die bewerkingen hebben gemaakt.",
+ "apihelp-query+allusers-param-activeusers": "Toon alleen gebruikers die actief zijn geweest in de laatste $1 {{PLURAL:$1|dag|dagen}}.",
+ "apihelp-query+allusers-example-Y": "Toon gebruikers vanaf <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Haal informatie op over de huidige authentificatie status.",
+ "apihelp-query+backlinks-summary": "Vind alle pagina's die verwijzen naar de gegeven pagina.",
+ "apihelp-query+backlinks-param-title": "Titel om op te zoeken. Kan niet worden gebruikt in combinatie met<var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "Pagina ID om op te zoeken. Kan niet worden gebruikt in combinatie met <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "De naamruimte om door te lopen.",
+ "apihelp-query+backlinks-example-simple": "Toon verwijzingen naar de <kbd>Hoofdpagina</kbd>.",
+ "apihelp-query+blocks-summary": "Toon alle geblokkeerde gebruikers en IP-adressen.",
+ "apihelp-query+blocks-param-limit": "Het maximum aantal blokkades te tonen.",
+ "apihelp-query+blocks-paramvalue-prop-id": "Voegt de blokkade ID toe.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Voegt de gebruikernaam van de geblokeerde gebruiker toe.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Voegt de gebruiker-ID van de geblokkeerde gebruiker toe.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Labelt de blokkade met (automatische blokkade, alleen anoniem, enzovoort).",
+ "apihelp-query+blocks-example-simple": "Toon blokkades.",
+ "apihelp-query+blocks-example-users": "Toon blokkades van gebruikers <kbd>Alice</kbd> en <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Toon alle categorieën waar de pagina in zit.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Markeert categorieën die verborgen zijn met <code>_&#95;HIDDENCAT_&#95;</code>",
+ "apihelp-query+categories-param-show": "Welke soort categorieën te tonen.",
+ "apihelp-query+categories-param-limit": "Hoeveel categorieën te tonen.",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Voegt de pagina-ID toe.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Voegt de titel en de naamruimte-ID van de pagina toe.",
+ "apihelp-query+categorymembers-param-dir": "Richting om in te sorteren.",
+ "apihelp-query+deletedrevisions-param-tag": "Alleen versies weergeven met dit label.",
+ "apihelp-query+deletedrevs-param-tag": "Alleen versies weergeven met dit label.",
+ "apihelp-query+embeddedin-param-namespace": "De naamruimte om door te lopen.",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "Pagina ID van elke pagina.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Titel van elke pagina.",
+ "apihelp-query+imageusage-param-namespace": "De naamruimte om door te lopen.",
+ "apihelp-query+imageusage-example-simple": "Toon pagina's die gebruik maken van [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Toon informatie over pagina's die gebruik maken van [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+iwbacklinks-param-prefix": "Voorvoegsel voor de interwiki.",
+ "apihelp-query+logevents-param-type": "Logboekregels alleen voor dit type filteren.",
+ "apihelp-query+logevents-param-tag": "Alleen logboekregels weergeven met dit label.",
+ "apihelp-query+logevents-example-simple": "Recente logboekregels weergeven.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Voegt het beveiligingsniveau toe.",
+ "apihelp-query+protectedtitles-example-simple": "Toon beveiligde titels.",
+ "apihelp-query+querypage-param-limit": "Aantal resultaten om te tonen.",
+ "apihelp-query+querypage-example-ancientpages": "Toon resultaten van [[Special:Ancientpages]].",
+ "apihelp-query+random-param-namespace": "Toon alleen pagina's in deze naamruimten.",
+ "apihelp-query+random-param-limit": "Beperk het aantal aan willekeurige pagina's dat wordt getoond.",
+ "apihelp-query+random-example-simple": "Toon twee willekeurige pagina's uit de hoofdnaamruimte.",
+ "apihelp-query+random-example-generator": "Toon pagina informatie over twee willekeurige pagina's uit de hoofdnaamruimte.",
+ "apihelp-query+recentchanges-param-user": "Toon alleen wijzigingen door deze gebruiker.",
+ "apihelp-query+recentchanges-param-excludeuser": "Toon geen wijzigingen door deze gebruiker",
+ "apihelp-query+recentchanges-param-tag": "Alleen versies weergeven met dit label.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Voegt de bewerkingssamenvatting voor de bewerking toe.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Voegt logboekgegevens toe aan logboekregels (logboek-ID, logboektype, enzovoort).",
+ "apihelp-query+recentchanges-example-simple": "Toon recente wijzigingen.",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "Pagina ID van elke doorverwijzing.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Titel van elke doorverwijzing.",
+ "apihelp-query+redirects-param-namespace": "Toon alleen pagina's in deze naamruimten.",
+ "apihelp-query+redirects-param-limit": "Hoeveel doorverwijzingen te tonen.",
+ "apihelp-query+redirects-example-simple": "Toon een lijst van doorverwijzingen naar [[Main Page]].",
+ "apihelp-query+redirects-example-generator": "Toon informatie over alle doorverwijzingen naar [[Main Page]].",
+ "apihelp-query+revisions-param-tag": "Alleen versies weergeven met dit label.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Versietekst.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Labels voor de versie.",
+ "apihelp-query+revisions+base-param-difftotextpst": "Gebruik in plaats hiervan [[Special:ApiHelp/compare|action=compare]]. \"pre-save\"-transformatie uitvoeren op de tekst alvorens de verschillen te bepalen. Alleen geldig als dit wordt gebruikt met <var>$1difftotext</var>.",
+ "apihelp-query+search-summary": "Voer een volledige tekst zoekopdracht uit.",
+ "apihelp-query+search-param-limit": "Hoeveel pagina's te tonen.",
+ "apihelp-query+search-example-simple": "Zoeken naar <kbd>betekenis</kbd>.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Toon geregistreerde naamruimte aliassen.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Toon speciale pagina aliassen.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Toon magische woorden en hun aliassen.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Toon site statistieken.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Toont bibliotheken die op de wiki zijn geïnstalleerd.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Toont uitbreidingen die op de wiki zijn geïnstalleerd.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Geeft een lijst met bestandsextensies (bestandstypen) die geüpload mogen worden.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Toont wiki rechten (licentie) informatie als deze beschikbaar is.",
+ "apihelp-query+tags-summary": "Wijzigingslabels weergeven.",
+ "apihelp-query+tags-paramvalue-prop-name": "Voegt de naam van het label toe.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Voegt het systeembericht toe voor het label.",
+ "apihelp-query+tags-paramvalue-prop-description": "Voegt beschrijving van het label toe.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Geeft aan of het label is gedefinieerd.",
+ "apihelp-query+tags-paramvalue-prop-active": "Of het label nog steeds wordt toegepast.",
+ "apihelp-query+tags-example-simple": "Toon beschikbare labels.",
+ "apihelp-query+templates-summary": "Toon alle pagina's ingesloten op de gegeven pagina's.",
+ "apihelp-query+templates-param-limit": "Het aantal sjablonen om te tonen.",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "Pagina ID van elke pagina.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Titel van elke pagina.",
+ "apihelp-query+usercontribs-summary": "Toon alle bewerkingen door een gebruiker.",
+ "apihelp-query+usercontribs-param-limit": "Het maximum aantal bewerkingen om te tonen.",
+ "apihelp-query+usercontribs-param-namespace": "Toon alleen bijdragen in deze naamruimten.",
+ "apihelp-query+usercontribs-param-tag": "Alleen versies weergeven met dit label.",
+ "apihelp-query+usercontribs-example-ipprefix": "Toon bijdragen van alle IP-adressen met het voorvoegsel <kbd>192.0.2.</kbd>.",
+ "apihelp-query+userinfo-summary": "Toon informatie over de huidige gebruiker.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Toon de gebruikers echte naam.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Voegt logboekgegevens toe waar van toepassing.",
+ "apihelp-query+watchlist-param-type": "Welke typen wijzigingen weer te geven:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Gewone paginabewerkingen.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Externe wijzigingen.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Nieuwe pagina's.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Logboekregels.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Wijzigingen in categorielidmaatschap.",
+ "apihelp-stashedit-param-text": "Pagina-inhoud.",
+ "apihelp-unblock-param-user": "Gebruikersnaam, IP-adres of IP-range om te deblokkeren. Kan niet samen worden gebruikt met <var>$1id</var> of <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "Gebruikers-ID om te deblokkeren. Kan niet worden gebruikt in combinatie met <var>$1id</var> of <var>$1user</var>.",
+ "apihelp-json-param-formatversion": "Uitvoeropmaak:\n;1:Achterwaarts compatibele opmaak (XML-stijl booleans, <samp>*</samp>-sleutels voor contentnodes, enzovoort).\n;2:Experimentele moderne opmaak. Details kunnen wijzigen!\n;latest:Gebruik de meest recente opmaak (op het moment <kbd>2</kbd>), kan zonder waarschuwing wijzigen.",
+ "apihelp-php-param-formatversion": "Uitvoeropmaak:\n;1:Achterwaarts compatibele opmaak (XML-stijl booleans, <samp>*</samp>-sleutels voor contentnodes, enzovoort).\n;2:Experimentele moderne opmaak. Details kunnen wijzigen!\n;latest:Gebruik de meest recente opmaak (op het moment <kbd>2</kbd>), kan zonder waarschuwing wijzigen.",
+ "apihelp-rawfm-summary": "Uitvoergegevens, inclusief debugelementen, opgemaakt in JSON (nette opmaak in HTML).",
+ "api-help-flag-readrights": "Voor deze module zijn leesrechten nodig.",
+ "api-help-flag-writerights": "Voor deze module zijn schrijfrechten nodig.",
+ "api-help-parameters": "{{PLURAL:$1|Parameter|Parameters}}:",
+ "api-help-param-deprecated": "Verouderd.",
+ "api-help-datatypes-header": "Gegevenstypen",
+ "api-help-param-default": "Standaard: $1",
+ "api-help-examples": "{{PLURAL:$1|Voorbeeld|Voorbeelden}}:",
+ "apierror-autoblocked": "Uw IP-adres is automatisch geblokeerd, omdat het gebruikt is door een geblokkeerde gebruiker.",
+ "apierror-badmodule-nosubmodules": "De module <kbd>$1</kbd> heeft geen submodules.",
+ "apierror-blockedfrommail": "U bent geblokkeerd en kunt geen emails verzenden.",
+ "apierror-blocked": "U bent geblokkeerd en kunt niet bewerken.",
+ "apierror-filedoesnotexist": "Bestand bestaat niet.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> mag niet minder zijn dan $2 (ingesteld op $3).",
+ "apierror-invalidcategory": "De opgegeven categorienaam is niet geldig.",
+ "apierror-invaliduser": "Ongeldige gebruikersnaam \"$1\".",
+ "apierror-maxlag-generic": "Wachten op een database server: $1 {{PLURAL:$1|seconde|seconden}} vertraging.",
+ "apierror-maxlag": "Wachten op $2: $1 {{PLURAL:$1|seconde|seconden}} vertraging.",
+ "apierror-missingtitle": "De opgegeven pagina bestaat niet.",
+ "apierror-missingtitle-byname": "De pagina $1 bestaat niet.",
+ "apierror-mustbeloggedin-generic": "U moet ingelogd zijn.",
+ "apierror-nosuchuserid": "Er is geen gebruiker met ID $1.",
+ "apierror-permissiondenied": "U heeft geen toestemming om $1.",
+ "apierror-permissiondenied-generic": "Toegang geweigerd.",
+ "apierror-readonly": "De wiki is momenteel in alleen-lezen modus.",
+ "apierror-systemblocked": "U bent automatisch geblokkeerd door MediaWiki.",
+ "apierror-timeout": "De server heeft niet binnen de verwachte tijd geantwoord.",
+ "apierror-unknownerror-nocode": "Onbekende fout.",
+ "apierror-unknownerror": "Onbekende fout: \"$1\".",
+ "apierror-unrecognizedparams": "Niet-herkende {{PLURAL:$2|parameter|parameters}}: $1.",
+ "apiwarn-invalidcategory": "\"$1\" is geen categorie.",
+ "apiwarn-invalidtitle": "\"$1\" is geen geldige titel.",
+ "apiwarn-notfile": "\"$1\" is geen bestand.",
+ "apiwarn-validationfailed-badpref": "geen geldige voorkeur.",
+ "api-feed-error-title": "Fout ($1)",
+ "api-usage-docref": "Zie $1 voor API gebruik.",
+ "api-credits-header": "Vermeldingen",
+ "api-credits": "API-ontwikkelaars:\n* Roan Kattouw (hoofdontwikkelaar september 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (oorspronkelijke ontwikkelaar, hoofdontwikkelaar september 2006 – september 2007)\n* Brad Jorsch (hoofdontwikkelaar 2013 – heden)\n\nStuur uw opmerkingen, suggesties en vragen naar mediawiki-api@lists.wikimedia.org\nof maak een melding aan op https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/nso.json b/www/wiki/includes/api/i18n/nso.json
new file mode 100644
index 00000000..42b30bb7
--- /dev/null
+++ b/www/wiki/includes/api/i18n/nso.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mohau"
+ ]
+ },
+ "apihelp-createaccount-param-name": "Leina la mošomši.",
+ "apihelp-login-example-login": "Tsena."
+}
diff --git a/www/wiki/includes/api/i18n/oc.json b/www/wiki/includes/api/i18n/oc.json
new file mode 100644
index 00000000..514646f5
--- /dev/null
+++ b/www/wiki/includes/api/i18n/oc.json
@@ -0,0 +1,135 @@
+{
+ "@metadata": {
+ "authors": [
+ "Cedric31",
+ "Macofe",
+ "Nicolas Eynaud"
+ ]
+ },
+ "apihelp-main-param-action": "Quina accion cal efectuar.",
+ "apihelp-main-param-format": "Lo format de sortida.",
+ "apihelp-block-summary": "Blocar un utilizaire.",
+ "apihelp-block-param-reason": "Motiu del blocatge.",
+ "apihelp-block-param-nocreate": "Empachar la creacion de compte.",
+ "apihelp-checktoken-param-token": "Geton de testar.",
+ "apihelp-compare-param-fromtitle": "Primièr títol de comparar.",
+ "apihelp-compare-param-fromid": "ID de la primièra pagina de comparar.",
+ "apihelp-compare-param-fromrev": "Primièra revision de comparar.",
+ "apihelp-compare-param-totitle": "Segond títol de comparar.",
+ "apihelp-compare-param-toid": "ID de la segonda pagina de comparar.",
+ "apihelp-compare-param-torev": "Segonda revision de comparar.",
+ "apihelp-compare-example-1": "Crear un diff entre lei revisions 1 e 2",
+ "apihelp-createaccount-summary": "Creatz un novèl compte d'utilizaire.",
+ "apihelp-createaccount-param-name": "Nom d'utilizaire.",
+ "apihelp-createaccount-param-password": "Senhal (ignorat se <var>$1mailpassword</var> es definit).",
+ "apihelp-createaccount-param-realname": "Nom vertadièr de l’utilizaire (facultatiu).",
+ "apihelp-delete-summary": "Suprimir una pagina.",
+ "apihelp-delete-example-simple": "Suprimir la <kbd>Main Page</kbd>.",
+ "apihelp-disabled-summary": "Aqueste modul es estat desactivat.",
+ "apihelp-edit-summary": "Crear e modificar las paginas.",
+ "apihelp-edit-param-text": "Contengut de la pagina.",
+ "apihelp-edit-param-minor": "Modificacion menora.",
+ "apihelp-edit-param-notminor": "Modificacion pas menora.",
+ "apihelp-edit-param-bot": "Marcar aquesta modificacion coma efectuada per un robòt.",
+ "apihelp-edit-example-edit": "Modificar una pagina",
+ "apihelp-edit-example-prepend": "Prefixar una pagina per <kbd>_&#95;NOTOC_&#95;</kbd>",
+ "apihelp-emailuser-summary": "Mandar un corrièr electronic un l’utilizaire.",
+ "apihelp-emailuser-param-subject": "Entèsta del subjècte.",
+ "apihelp-emailuser-param-text": "Còs del corrièr electronic.",
+ "apihelp-emailuser-param-ccme": "Me mandar una còpia d'aqueste corrièr electronic.",
+ "apihelp-expandtemplates-param-title": "Títol de la pagina.",
+ "apihelp-expandtemplates-param-text": "Wikitèxte de convertir.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Lo wikitèxte desvolopat.",
+ "apihelp-feedcontributions-summary": "Renvia lo fial de las contribucions d’un utilizaire.",
+ "apihelp-feedcontributions-param-feedformat": "Lo format del flux.",
+ "apihelp-feedcontributions-param-year": "A partir de l’annada (e mai recent) :",
+ "apihelp-feedcontributions-param-month": "A partir del mes (e mai recent) :",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrar las contribucions qu'an aquestas balisas.",
+ "apihelp-feedcontributions-param-deletedonly": "Afichar solament las contribucions suprimidas.",
+ "apihelp-feedcontributions-param-hideminor": "Amagar los cambiaments mendres.",
+ "apihelp-feedcontributions-param-showsizediff": "Afichar la diferéncia de talha entre las revisions.",
+ "apihelp-feedrecentchanges-param-feedformat": "Lo format del flux.",
+ "apihelp-feedrecentchanges-param-hideminor": "Amagar las modificacions menoras.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrar per balisa.",
+ "apihelp-feedrecentchanges-example-simple": "Mostrar lei darriers cambiaments.",
+ "apihelp-filerevert-param-comment": "Telecargar lo comentari.",
+ "apihelp-filerevert-param-archivename": "Nom d’archiu de la revision de restablir.",
+ "apihelp-import-param-summary": "Resumit de l’importacion de l’entrada de jornal.",
+ "apihelp-import-param-xml": "Fichièr XML telecargat.",
+ "apihelp-login-param-name": "Nom d'utilizaire.",
+ "apihelp-login-param-password": "Senhal.",
+ "apihelp-login-param-domain": "Domeni (facultatiu).",
+ "apihelp-login-example-login": "Se connectar.",
+ "apihelp-managetags-summary": "Efectuar de prètzfaits de gestion relatius a la modificacion de las balisas.",
+ "apihelp-mergehistory-summary": "Fusionar leis istorics de pagina",
+ "apihelp-move-summary": "Desplaçar una pagina.",
+ "apihelp-opensearch-param-search": "Cadena de recèrca.",
+ "apihelp-parse-example-page": "Analisar una pagina.",
+ "apihelp-parse-example-text": "Analisar lo wikitèxte.",
+ "apihelp-parse-example-summary": "Analisar un resumit.",
+ "apihelp-patrol-summary": "Patrolhar una pagina o una revision.",
+ "apihelp-protect-example-protect": "Protegir una pagina",
+ "apihelp-query-param-list": "Quinas listas obténer.",
+ "apihelp-query-param-meta": "Quinas metadonadas obténer.",
+ "apihelp-query+allcategories-summary": "Enumerar totas las categorias.",
+ "apihelp-query+alldeletedrevisions-param-from": "Aviar la lista a aqueste títol.",
+ "apihelp-query+allimages-param-sort": "Proprietat per la quala cal triar.",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Ajustatz lo títol de la redireccion.",
+ "apihelp-query+blocks-example-simple": "Listar los blocatges",
+ "apihelp-query+blocks-example-users": "Listar los blocatges dels utilizaires <kbd>Alice</kbd> e <kbd>Bob</kbd>.",
+ "apihelp-query+imageinfo-param-urlheight": "Similar a $1urlwidth.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Tèxte de la revision.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Balisas de la revision.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Cambiaments extèrnes",
+ "apihelp-query+watchlist-paramvalue-type-new": "Creacions de pagina",
+ "apihelp-resetpassword-summary": "Mandar un corrier electronic de reïnicializacion de son senhau a l'utilizaire.",
+ "apihelp-stashedit-param-text": "Contengut de la pagina",
+ "apihelp-tag-param-reason": "Motiu de la modificacion.",
+ "apihelp-unblock-summary": "Desblocar un utilizaire.",
+ "apihelp-unblock-param-reason": "Motiu del desblocatge.",
+ "apihelp-unblock-example-id": "Levar lo blocatge d’ID #<kbd>105</kbd>.",
+ "apihelp-undelete-param-reason": "Motiu de restauracion.",
+ "apihelp-userrights-param-user": "Nom d'utilizaire.",
+ "apihelp-userrights-param-userid": "ID de l'utilizaire.",
+ "apihelp-validatepassword-param-password": "Senhau de validar.",
+ "api-help-main-header": "Modul principal",
+ "api-help-flag-deprecated": "Lo module es desaprovat.",
+ "api-help-source": "Font : $1",
+ "api-help-source-unknown": "Font : <span class=\"apihelp-unknown\">desconeguda</span>",
+ "api-help-license": "Licéncia : [[$1|$2]]",
+ "api-help-license-noname": "Licéncia : [[$1|Veire lo ligam]]",
+ "api-help-license-unknown": "Licéncia : <span class=\"apihelp-unknown\">desconeguda</span>",
+ "api-help-parameters": "{{PLURAL:$1|Paramètre|Paramètres}} :",
+ "api-help-param-deprecated": "Obsolèt.",
+ "api-help-param-required": "Aqueste paramètre es obligatòri.",
+ "api-help-datatypes-header": "Tipe de donadas",
+ "api-help-param-default": "Per defaut : $1",
+ "apierror-badquery": "Requista invalida.",
+ "apierror-cannotviewtitle": "Siatz autorizat a veire $1.",
+ "apierror-cantblock-email": "Sus aqueu wiki, avètz pas lei drechs necessaris per empedir leis utilizats de mandar de corriers electronics.",
+ "apierror-cantblock": "Avètz pas lei drechs necessaris per blocar d'utilizaires.",
+ "apierror-canthide": "Avètz pas lei drechs necessaris per escondre lo nom d'un utilizaire dins lo jornau dei blocatges.",
+ "apierror-cantimport-upload": "Avètz pas lei drechs necessaris per importar de paginas telecargadas.",
+ "apierror-cantimport": "Avètz pas lei drechs necesaris per importar de paginas.",
+ "apierror-copyuploadbadurl": "Telecargament pas autorizat a partir d'aquel URL.",
+ "apierror-create-titleexists": "Lei títols existents pòdon pas èsser protegits amb <kbd>create</kbd>.",
+ "apierror-emptynewsection": "La creacion de seccions vuejas novèlas es pas possibla.",
+ "apierror-filedoesnotexist": "Lo fichier existís pas.",
+ "apierror-noedit": "Avètz pas lei drechs necessaris per editar de paginas.",
+ "apierror-noimageredirect-anon": "Leis utilizaires anonims pòdon pas crear de redireccions d'imatge.",
+ "apierror-noimageredirect": "Avètz pas lei drechs necessaris per crear de redireccions d'imatge.",
+ "apierror-nosuchsection": "I a ges seccion $1",
+ "apierror-nosuchsection-what": "I a pas de seccion $1 dins $2.",
+ "apierror-permissiondenied-generic": "Autorizacion refusada.",
+ "apierror-unknownerror-nocode": "Error desconeguda.",
+ "apierror-unknownerror": "Error desconeguda : $1",
+ "apierror-unknownformat": "Format $1 non reconegut",
+ "apierror-unrecognizedvalue": "Valor pas reconeguda per lo paramètre <var>$1</var>: $2.",
+ "apiwarn-invalidcategory": "\"$1\" es pas una categoria.",
+ "apiwarn-invalidtitle": "\"$1\" es pas un títol valide.",
+ "apiwarn-notfile": "$1 es pas un fichier.",
+ "apiwarn-tokennotallowed": "L'accion $1 es pas autorizada per l'utilizaire actuau.",
+ "apiwarn-validationfailed-badpref": "Pas una preferéncia valida.",
+ "api-feed-error-title": "Error ($1)",
+ "api-credits-header": "Mercejaments"
+}
diff --git a/www/wiki/includes/api/i18n/olo.json b/www/wiki/includes/api/i18n/olo.json
new file mode 100644
index 00000000..47c36ddb
--- /dev/null
+++ b/www/wiki/includes/api/i18n/olo.json
@@ -0,0 +1,13 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mashoi7",
+ "Ilja.mos"
+ ]
+ },
+ "apihelp-createaccount-param-name": "Käyttäitunnus.",
+ "apihelp-delete-summary": "Ota sivu iäre.",
+ "apihelp-login-param-name": "Käyttäitunnus.",
+ "apihelp-login-param-password": "Salasana.",
+ "apihelp-login-example-login": "Kirjuttai."
+}
diff --git a/www/wiki/includes/api/i18n/or.json b/www/wiki/includes/api/i18n/or.json
new file mode 100644
index 00000000..b008f02d
--- /dev/null
+++ b/www/wiki/includes/api/i18n/or.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "ଶିତିକଣ୍ଠ ଦାଶ"
+ ]
+ },
+ "apihelp-main-param-action": "କେଉଁ କାମ କରାଯିବ ।",
+ "apihelp-main-param-format": "ଆଉଟପୁଟ୍‌ର ଫର୍ମାଟ ।",
+ "apihelp-block-summary": "ଜଣେ ବ୍ୟବହାରକାରୀଙ୍କୁ ବ୍ଲକ କରନ୍ତୁ ।",
+ "apihelp-block-param-reason": "ବ୍ଲକ କରିବାର କାରଣ ।",
+ "apihelp-block-param-nocreate": "ଆକାଉଣ୍ଟ ତିଆରି ହେବାକୁ ପ୍ରତିରୋଧ କରନ୍ତୁ ।",
+ "apihelp-createaccount-param-name": "ବ୍ୟବହାରକାରୀଙ୍କ ନାମ",
+ "apihelp-delete-summary": "ପୃଷ୍ଠାଟି ଲିଭାଇଦେବେ"
+}
diff --git a/www/wiki/includes/api/i18n/pa.json b/www/wiki/includes/api/i18n/pa.json
new file mode 100644
index 00000000..96c86941
--- /dev/null
+++ b/www/wiki/includes/api/i18n/pa.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Babanwalia"
+ ]
+ },
+ "apihelp-help-example-recursive": "ਇੱਕੋ ਸਫ਼ੇ 'ਤੇ ਸਾਰੀ ਮਦਦ"
+}
diff --git a/www/wiki/includes/api/i18n/pam.json b/www/wiki/includes/api/i18n/pam.json
new file mode 100644
index 00000000..c02fe057
--- /dev/null
+++ b/www/wiki/includes/api/i18n/pam.json
@@ -0,0 +1,32 @@
+{
+ "@metadata": {
+ "authors": [
+ "Leeheonjin",
+ "Macofe"
+ ]
+ },
+ "apihelp-delete-example-simple": "Buran ya ing <kbd>Main Page</kbd>.",
+ "apihelp-edit-example-edit": "Alilan ya ing bulung.",
+ "apihelp-feedrecentchanges-example-simple": "Pakit deng bayung mengayalili.",
+ "apihelp-help-example-main": "Saup para king pun modyul.",
+ "apihelp-help-example-recursive": "Deng eganaganang saup king metung a bulung.",
+ "apihelp-login-example-login": "Magpatala (login)",
+ "apihelp-patrol-example-rcid": "Magbante king bayung mengayalili.",
+ "apihelp-patrol-example-revid": "Banten ing meyalili.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "E malyaring gamitan a yating <var>$3user</var>.",
+ "apihelp-query+allpages-example-B": "Ipakit ing talaan da reng bulung a mangumpisa king titik <kbd>B</kbd>.",
+ "apihelp-query+categoryinfo-example-simple": "Kumuwa ning impormasyun tungkul king <kbd>Kategorya:Foo</kbd> at <kbd>Kategorya:Bar</kbd>.",
+ "apihelp-query+deletedrevs-example-mode2": "Ilista la reng 50 binurang kontribusyun nang <kbd>Bob</kbd> (mode 2).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Ilista mu la reng minunang 50 meburang bulung king {{ns:talk}} lagyu-espasyu (mode 3)",
+ "apihelp-query+duplicatefiles-example-generated": "Mayintun para kareng duplika da reng egana-ganang simpan (file).",
+ "apihelp-query+extlinks-example-simple": "Kumuwa ning lista da reng suglung paluwal king <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-example-simple": "Pakit la reng bulung a makasuglung king <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+imageusage-example-simple": "Ipakit la reng bulung a gagamit ning [[:Simpan:Albert Einstein Head.jpg]].",
+ "apihelp-query+langbacklinks-example-simple": "Kunan deng bulung a maka-suglung king [[:fr:Test]].",
+ "apihelp-query+protectedtitles-example-generator": "Pantunan deng suglung king maka-protektang titulu king pun lagyu-espasyu.",
+ "apihelp-query+recentchanges-example-simple": "Talaan da reng bayung mengayalili.",
+ "apihelp-query+search-example-text": "Pantunan mo la reng tekstu para king <kbd>kabaldugan</kbd>",
+ "apihelp-query+siteinfo-example-simple": "Kung ing impormasyun ning sityu.",
+ "apihelp-upload-example-url": "Maglulan (upload) ibat king URL.",
+ "apihelp-watch-example-unwatch": "E banten ing bulung <kbd>Pun Bulung</kbd>"
+}
diff --git a/www/wiki/includes/api/i18n/pl.json b/www/wiki/includes/api/i18n/pl.json
new file mode 100644
index 00000000..ac134f0b
--- /dev/null
+++ b/www/wiki/includes/api/i18n/pl.json
@@ -0,0 +1,712 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chrumps",
+ "Py64",
+ "Pan Cube",
+ "Alan ffm",
+ "Devwebtel",
+ "Macofe",
+ "Pio387",
+ "Peter Bowman",
+ "Darellur",
+ "The Polish",
+ "Matma Rex",
+ "Sethakill",
+ "Woytecr",
+ "InternerowyGołąb"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Dokumentacja]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista dyskusyjna]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Ogłoszenia dotyczące API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Błędy i propozycje]\n</div>\n<strong>Stan:</strong> Wszystkie funkcje opisane na tej stronie powinny działać, ale API nadal jest aktywnie rozwijane i mogą się zmienić w dowolnym czasie. Subskrybuj [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ listę dyskusyjną mediawiki-api-announce], aby móc na bieżąco dowiadywać się o aktualizacjach.\n\n<strong>Błędne żądania:</strong> Gdy zostanie wysłane błędne żądanie do API, zostanie wysłany w odpowiedzi nagłówek HTTP z kluczem \"MediaWiki-API-Error\" i zarówno jego wartość jak i wartość kodu błędu wysłanego w odpowiedzi będą miały taką samą wartość. Aby uzyskać więcej informacji, zobacz [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Błędy i ostrzeżenia]].\n\n<strong>Testowanie:</strong> Aby łatwo testować żądania API, zobacz [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Wybierz akcję do wykonania.",
+ "apihelp-main-param-format": "Format danych wyjściowych.",
+ "apihelp-main-param-maxlag": "Maksymalne opóźnienie mogą być używane kiedy MediaWiki jest zainstalowana w klastrze zreplikowanej bazy danych. By zapisać działania powodujące większe opóźnienie replikacji, ten parametr może wymusić czekanie u klienta, dopóki opóźnienie replikacji jest mniejsze niż określona wartość. W przypadku nadmiernego opóźnienia, kod błędu <samp>maxlag</samp> jest zwracany z wiadomością jak <samp>Oczekiwanie na $host: $lag sekund opóźnienia</samp>.<br />Zobacz [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Podręcznik:Parametr Maxlag]] by uzyskać więcej informacji.",
+ "apihelp-main-param-smaxage": "Ustaw nagłówek HTTP kontrolujący pamięć podręczną <code>s-maxage</code> na taką ilość sekund. Błędy nie będą nigdy przechowywane w pamięci podręcznej.",
+ "apihelp-main-param-maxage": "Ustaw nagłówek HTTP kontrolujący pamięć podręczną <code>maxage</code> na taką ilość sekund. Błędy nie będą nigdy przechowywane w pamięci podręcznej.",
+ "apihelp-main-param-assert": "Zweryfikuj, czy użytkownik jest zalogowany, jeżeli wybrano <kbd>user</kbd>, lub czy ma uprawnienia bota, jeżeli wybrano <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Sprawdź, czy bieżący użytkownik posiada nazwę.",
+ "apihelp-main-param-requestid": "Każda wartość tu podana będzie dołączana do odpowiedzi. Może być użyta do rozróżniania żądań.",
+ "apihelp-main-param-servedby": "Dołącz do odpowiedzi nazwę hosta, który obsłużył żądanie.",
+ "apihelp-main-param-curtimestamp": "Dołącz obecny znacznik czasu do wyniku.",
+ "apihelp-main-param-uselang": "Język, w którym mają być pokazywane tłumaczenia wiadomości. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> z <kbd>siprop=languages</kbd> zwróci listę języków lub ustaw jako <kbd>user</kbd>, aby pobrać z preferencji zalogowanego użytkownika lub <kbd>content</kbd>, aby wykorzystać język zawartości tej wiki.",
+ "apihelp-block-summary": "Zablokuj użytkownika.",
+ "apihelp-block-param-user": "Nazwa użytkownika, adres IP albo zakres adresów IP, które chcesz zablokować. Nie można używać razem z <var>$1userid</var>.",
+ "apihelp-block-param-expiry": "Czas trwania. Może być względny (np. <kbd>5 months</kbd> or <kbd>2 weeks</kbd>) lub konkretny (np. <kbd>2014-09-18T12:34:56Z</kbd>). Jeśli jest ustawiony na <kbd>infinite</kbd>, <kbd>indefinite</kbd>, lub <kbd>never</kbd>, blokada nigdy nie wygaśnie.",
+ "apihelp-block-param-reason": "Powód blokady.",
+ "apihelp-block-param-anononly": "Blokuj tylko anonimowych użytkowników (blokuje anonimowe edycje z tego adresu IP).",
+ "apihelp-block-param-nocreate": "Zapobiegnij utworzeniu konta.",
+ "apihelp-block-param-autoblock": "Zablokuj ostatni adres IP tego użytkownika i automatycznie wszystkie kolejne, z których będzie się logował.",
+ "apihelp-block-param-noemail": "Uniemożliwia użytkownikowi wysyłanie wiadomości e-mail za pośrednictwem interfejsu wiki. (Wymagane uprawnienie <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Ukryj nazwę użytkownika z rejestru blokad. (Wymagane uprawnienie <code>hideuser</code>)",
+ "apihelp-block-param-allowusertalk": "Pozwala użytkownikowi edytować własną stronę dyskusji (zależy od <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Jeżeli ten użytkownik jest już zablokowany, nadpisz blokadę.",
+ "apihelp-block-param-watchuser": "Obserwuj stronę użytkownika lub IP oraz ich strony dyskusji.",
+ "apihelp-block-param-tags": "Zmieniaj tagi by potwierdzić wejście do bloku logów.",
+ "apihelp-block-example-ip-simple": "Zablokuj IP <kbd>192.0.2.5</kbd> na 3 dni z powodem <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Zablokuj użytkownika <kbd>Vandal</kbd> na zawsze z powodem <kbd>Vandalism</kbd> i uniemożliw utworzenie nowego konta oraz wysyłanie emaili.",
+ "apihelp-changeauthenticationdata-summary": "Zmień dane logowania bieżącego użytkownika.",
+ "apihelp-changeauthenticationdata-example-password": "Spróbuj zmienić hasło bieżącego użytkownika na <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Sprawdź poprawność tokenu z <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Typ tokenu do przetestowania.",
+ "apihelp-checktoken-param-token": "Token do przetestowania.",
+ "apihelp-checktoken-param-maxtokenage": "Maksymalny wiek tokenu, w sekundach.",
+ "apihelp-checktoken-example-simple": "Sprawdź poprawność tokenu <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Czyści flagę <code>hasmsg</code> dla bieżącego użytkownika.",
+ "apihelp-clearhasmsg-example-1": "Wyczyść flagę <code>hasmsg</code> dla bieżącego użytkownika.",
+ "apihelp-compare-summary": "Zauważ różnicę między dwoma stronami",
+ "apihelp-compare-param-fromtitle": "Pierwszy tytuł do porównania.",
+ "apihelp-compare-param-fromid": "ID pierwszej strony do porównania.",
+ "apihelp-compare-param-fromrev": "Pierwsza wersja do porównania.",
+ "apihelp-compare-param-frompst": "Dokonaj zapisu wersji roboczej transformacji przeprowadzonej na <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "Drugi tytuł do porównania.",
+ "apihelp-compare-param-toid": "Numer drugiej strony do porównania.",
+ "apihelp-compare-param-torev": "Druga wersja do porównania.",
+ "apihelp-createaccount-summary": "Utwórz nowe konto.",
+ "apihelp-createaccount-param-name": "Nazwa użytkownika",
+ "apihelp-createaccount-param-password": "Hasło (ignorowane jeśli ustawiono <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Domena uwierzytelniania zewnętrznego (opcjonalnie).",
+ "apihelp-createaccount-param-token": "Token tworzenia konta uzyskany w pierwszym zapytaniu.",
+ "apihelp-createaccount-param-email": "Adres email użytkownika (opcjonalne).",
+ "apihelp-createaccount-param-realname": "Prawdziwe imię i nazwisko użytkownika (opcjonalne).",
+ "apihelp-createaccount-param-reason": "Opcjonalny powód tworzenia konta, który zostanie umieszczony w rejestrze.",
+ "apihelp-createaccount-example-pass": "Utwórz użytkownika <kbd>testuser</kbd> z hasłem <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Utwórz użytkownika <kbd>testmailuser</kbd> i wyślij losowo wygenerowane hasło na emaila.",
+ "apihelp-delete-summary": "Usuń stronę.",
+ "apihelp-delete-param-reason": "Powód usuwania. Jeśli pozostawisz to pole puste, zostanie użyty powód wygenerowany automatycznie.",
+ "apihelp-delete-param-watch": "Dodaj stronę do obecnej listy obserwowanych.",
+ "apihelp-delete-param-unwatch": "Usuń stronę z obecnej listy obserwowanych.",
+ "apihelp-delete-example-simple": "Usuń <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Usuń <kbd>Main Page</kbd> z powodem <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Ten moduł został wyłączony.",
+ "apihelp-edit-summary": "Twórz i edytuj strony.",
+ "apihelp-edit-param-title": "Tytuł strony do edycji. Nie może być użyty równocześnie z <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID strony do edycji. Nie może być używany równocześnie z <var>$1title</var>.",
+ "apihelp-edit-param-section": "Numer sekcji. <kbd>0</kbd> dla górnej sekcji, <kbd>new</kbd> dla nowej sekcji.",
+ "apihelp-edit-param-sectiontitle": "Tytuł nowej sekcji.",
+ "apihelp-edit-param-text": "Zawartość strony.",
+ "apihelp-edit-param-summary": "Opis edycji. Także tytuł sekcji gdy użyto $1section=new, a nie ustawiono $1sectiontitle.",
+ "apihelp-edit-param-tags": "Znaczniki zmian do zastosowania w tej edycji.",
+ "apihelp-edit-param-minor": "Drobna zmiana.",
+ "apihelp-edit-param-notminor": "Nie oznaczaj tej zmiany jako drobną.",
+ "apihelp-edit-param-bot": "Oznacz tę edycję jako edycję bota.",
+ "apihelp-edit-param-basetimestamp": "Czas wersji, która jest edytowana. Służy do wykrywania konfliktów edycji. Można pobrać poprzez [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Czas rozpoczęcia procesu edycji. Służy do wykrywania konfliktów edycji. Odpowiednia wartość może być pobrana za pomocą <var>[[Special:ApiHelp/main|curtimestamp]]</var> podczas rozpoczynania procesu edycji (np. podczas ładowania zawartości strony do edycji).",
+ "apihelp-edit-param-recreate": "Ignoruj błędy o usunięciu strony w międzyczasie.",
+ "apihelp-edit-param-createonly": "Nie edytuj strony, jeśli już istnieje.",
+ "apihelp-edit-param-nocreate": "Zwróć błąd, jeśli strona nie istnieje.",
+ "apihelp-edit-param-watch": "Dodaj stronę do listy obserwowanych bieżącego użytkownika.",
+ "apihelp-edit-param-unwatch": "Usuń stronę z listy obserwowanych bieżącego użytkownika.",
+ "apihelp-edit-param-md5": "Hash MD5 parametru $1text lub złączonych parametrów $1prependtext i $1appendtext. Jeżeli ustawiony, edycja nie zostanie zapisana dopóki hash nie będzie się zgadzać.",
+ "apihelp-edit-param-prependtext": "Tekst do dodania na początku strony. Zastępuje $1text.",
+ "apihelp-edit-param-appendtext": "Tekst do dodania na końcu strony. Zastępuje $1text.\n\nUżyj $1section=new zamiast tego parametru aby dodać nową sekcję.",
+ "apihelp-edit-param-undo": "Wycofaj tę wersję. Zastępuje $1text, $1prependtext i $1appendtext.",
+ "apihelp-edit-param-undoafter": "Wycofaj wszystkie wersje od $1undo do tej. Jeżeli nie ustawiono, wycofaj tylko jedną wersję.",
+ "apihelp-edit-param-redirect": "Automatycznie rozwiązuj przekierowania.",
+ "apihelp-edit-param-contentformat": "Format serializacji zawartości wprowadzonego tekstu.",
+ "apihelp-edit-param-contentmodel": "Model zawartości nowego tekstu.",
+ "apihelp-edit-param-token": "Token powinien być wysyłany jako ostatni parametr albo przynajmniej po parametrze $1text.",
+ "apihelp-edit-example-edit": "Edytuj stronę.",
+ "apihelp-edit-example-prepend": "Dopisz <kbd>_&#95;NOTOC_&#95;</kbd> na początku strony.",
+ "apihelp-emailuser-summary": "Wyślij e‐mail do użytkownika.",
+ "apihelp-emailuser-param-target": "Użytkownik, do którego wysłać e-mail.",
+ "apihelp-emailuser-param-subject": "Nagłówek tematu.",
+ "apihelp-emailuser-param-text": "Treść emaila.",
+ "apihelp-emailuser-param-ccme": "Wyślij kopię wiadomości do mnie.",
+ "apihelp-emailuser-example-email": "Wyślij e-mail do użytkownika <kbd>WikiSysop</kbd> z tekstem <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Rozwija wszystkie szablony zawarte w wikitekście.",
+ "apihelp-expandtemplates-param-title": "Tytuł strony.",
+ "apihelp-expandtemplates-param-text": "Wikitext do przekonwertowania.",
+ "apihelp-expandtemplates-param-revid": "ID wersji, dla <code><nowiki>{{REVISIONID}}</nowiki></code> i podobnych zmiennych.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Rozwinięty wikitekst.",
+ "apihelp-feedcontributions-summary": "Zwraca kanał wkładu użytkownika.",
+ "apihelp-feedcontributions-param-feedformat": "Format danych wyjściowych.",
+ "apihelp-feedcontributions-param-user": "Jakich użytkowników pobrać wkład.",
+ "apihelp-feedcontributions-param-namespace": "Z jakiej przestrzeni nazw wyświetlać wkład użytkownika.",
+ "apihelp-feedcontributions-param-year": "Od roku (i wcześniej).",
+ "apihelp-feedcontributions-param-month": "Od miesiąca (i wcześniej).",
+ "apihelp-feedcontributions-param-tagfilter": "Pokaż tylko wkład z tymi znacznikami.",
+ "apihelp-feedcontributions-param-deletedonly": "Pokazuj tylko usunięty wkład.",
+ "apihelp-feedcontributions-param-toponly": "Pokazuj tylko edycje będące ostatnią zmianą strony.",
+ "apihelp-feedcontributions-param-newonly": "Pokazuj tylko edycje tworzące stronę.",
+ "apihelp-feedcontributions-param-hideminor": "Ukryj drobne zmiany.",
+ "apihelp-feedcontributions-param-showsizediff": "Pokaż różnicę rozmiaru między wersjami.",
+ "apihelp-feedcontributions-example-simple": "Zwróć liste edycji dokonanych przez użytkownika <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Zwraca kanał ostatnich zmian.",
+ "apihelp-feedrecentchanges-param-feedformat": "Format danych wyjściowych.",
+ "apihelp-feedrecentchanges-param-namespace": "Przestrzeń nazw, do której ograniczone są wyniki.",
+ "apihelp-feedrecentchanges-param-invert": "Wszystkie przestrzenie nazw oprócz wybranej.",
+ "apihelp-feedrecentchanges-param-associated": "Uwzględnij powiązaną przestrzeń nazw (dyskusja lub treść).",
+ "apihelp-feedrecentchanges-param-days": "Dni, do których ograniczone są wyniki.",
+ "apihelp-feedrecentchanges-param-limit": "Maksymalna liczba zwracanych wyników.",
+ "apihelp-feedrecentchanges-param-from": "Pokaż zmiany od tamtej chwili.",
+ "apihelp-feedrecentchanges-param-hideminor": "Ukryj drobne zmiany.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ukryj zmiany zrobione przez boty.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ukryj zmiany zrobione przez anonimowych użytkowników.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ukryj zmiany zrobione przez zarejestrowanych użytkowników.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ukryj sprawdzone zmiany.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ukryj zmiany zrobione przez obecnego użytkownika.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Ukryj zmiany w kategoryzacji.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtruj po znacznikach.",
+ "apihelp-feedrecentchanges-param-target": "Pokaż tylko zmiany na stronach linkowanych z tej strony.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Pokaż zmiany na stronach linkujących do wybranej strony.",
+ "apihelp-feedrecentchanges-param-categories": "Pokaż zmiany tylko na stronach będących we wszystkich tych kategoriach.",
+ "apihelp-feedrecentchanges-param-categories_any": "Pokaż zmiany tylko na stronach będących w jednej z tych kategorii.",
+ "apihelp-feedrecentchanges-example-simple": "Pokaż ostatnie zmiany.",
+ "apihelp-feedrecentchanges-example-30days": "Pokaż ostatnie zmiany z 30 dni.",
+ "apihelp-feedwatchlist-summary": "Zwraca kanał listy obserwowanych.",
+ "apihelp-feedwatchlist-param-feedformat": "Format kanału.",
+ "apihelp-feedwatchlist-param-hours": "Wymień strony zmienione w ciągu tylu godzin licząc od teraz.",
+ "apihelp-feedwatchlist-param-linktosections": "Linkuj bezpośrednio do zmienionych sekcji jeżeli to możliwe.",
+ "apihelp-feedwatchlist-example-default": "Pokaż kanał listy obserwowanych.",
+ "apihelp-feedwatchlist-example-all6hrs": "Pokaż wszystkie zmiany na obserwowanych stronach dokonane w ciągu ostatnich 6 godzin.",
+ "apihelp-filerevert-summary": "Przywróć plik do starej wersji.",
+ "apihelp-filerevert-param-filename": "Docelowa nazwa pliku bez prefiksu Plik:",
+ "apihelp-filerevert-param-comment": "Prześlij komentarz.",
+ "apihelp-filerevert-example-revert": "Przywróć <kbd>Wiki.png</kbd> do wersji z <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Wyświetl pomoc dla określonych modułów.",
+ "apihelp-help-param-modules": "Moduły do wyświetlenia pomocy dla (wartości <var>action</var> i <var>format</var> parametry, lub <kbd>main</kbd>). Może określić podmoduły z <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Dołącz pomoc podmodułów nazwanego modułu.",
+ "apihelp-help-param-recursivesubmodules": "Zawiera pomoc dla podmodułów rekursywnie.",
+ "apihelp-help-param-helpformat": "Format wyjściowy pomocy.",
+ "apihelp-help-param-toc": "Dołącz spis treści do wyjściowego HTML.",
+ "apihelp-help-example-main": "Pomoc dla modułu głównego",
+ "apihelp-help-example-submodules": "Pomoc dla <kbd>action=query</kbd> i wszystkich jej podmodułów.",
+ "apihelp-help-example-recursive": "Cała pomoc na jednej stronie.",
+ "apihelp-help-example-help": "Pomoc dla modułu pomocy",
+ "apihelp-help-example-query": "Pomoc dla dwóch podmodułów zapytań.",
+ "apihelp-imagerotate-summary": "Obróć jeden lub wiecej obrazków.",
+ "apihelp-imagerotate-param-rotation": "Stopni w prawo, aby obrócić zdjęcie.",
+ "apihelp-imagerotate-example-simple": "Obróć <kbd>Plik:Przykład.png</kbd> o <kbd>90</kbd> stopni.",
+ "apihelp-imagerotate-example-generator": "Obróć wszystkie obrazki w <kbd>Kategorii:Flip</kbd> o <kbd>180</kbd> stopni.",
+ "apihelp-import-summary": "Zaimportuj stronę z innej wiki, lub sformułuj plik XML.",
+ "apihelp-import-param-summary": "Podsumowanie importu rekordów dziennika.",
+ "apihelp-import-param-xml": "Przesłany plik XML.",
+ "apihelp-import-param-interwikisource": "Dla importów interwiki: wiki, z której importować.",
+ "apihelp-import-param-interwikipage": "Dla importów interwiki: strona do importu.",
+ "apihelp-import-param-fullhistory": "Dla importów interwiki: importuj całą historię, a nie tylko obecną wersję.",
+ "apihelp-import-param-templates": "Dla importów interwiki: importuj też wszystkie użyte szablony.",
+ "apihelp-import-param-namespace": "Importuj do tej przestrzeni nazw. Nie może być użyte razem z <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importuj jako podstronę tej strony. Nie może być użyte razem z <var>$1namespace</var>.",
+ "apihelp-login-param-name": "Nazwa użytkownika.",
+ "apihelp-login-param-password": "Hasło.",
+ "apihelp-login-param-domain": "Domena (opcjonalnie).",
+ "apihelp-login-param-token": "Token logowania zdobyty w pierwszym zapytaniu.",
+ "apihelp-login-example-gettoken": "Zdobądź token logowania.",
+ "apihelp-login-example-login": "Zaloguj się",
+ "apihelp-logout-summary": "Wyloguj i wyczyść dane sesji.",
+ "apihelp-logout-example-logout": "Wyloguj obecnego użytkownika.",
+ "apihelp-managetags-summary": "Wykonywanie zadań związanych z zarządzaniem znacznikami zmian.",
+ "apihelp-managetags-param-operation": "Jakiej operacji dokonać:\n;create:Stworzenie nowego znacznika zmian do ręcznego użycia.\n;delete:Usunięcie znacznika zmian z bazy danych, włącznie z usunięciem danego znacznika z wszystkich oznaczonych nim zmian i wpisów rejestru i ostatnich zmian.\n;activate:Aktywuj znacznik zmian, użytkownicy będą mogli go ręcznie przypisywać.\n;deactivate:Dezaktywuj znacznik zmian, użytkownicy nie będą mogli przypisywać go ręcznie.",
+ "apihelp-managetags-param-tag": "Znacznik do utworzenia, usunięcia, aktywacji lub dezaktywacji. Do utworzenia znacznika, nazwa nie misi istnieć. Do usunięcia znacznika, musi on istnieć. Do aktywacji znacznika, musi on istnieć i nie może być w użyciu przez żadne rozszerzenie. Do dezaktywowania znacznika, musi on być do tej pory aktywowany i ręcznie zdefiniowany.",
+ "apihelp-managetags-param-reason": "Opcjonalny powód utworzenia, usunięcia, włączenia lub wyłączenia znacznika.",
+ "apihelp-managetags-param-ignorewarnings": "Czy zignorować ostrzeżenia, które pojawiają się w trakcie operacji.",
+ "apihelp-managetags-example-create": "Stworzenie znacznika o nazwie <kbd>spam</kbd> z powodem <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Usunięcie znacznika <kbd>vandlaism</kbd> z powodu <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Aktywacja znacznika o nazwie <kbd>spam</kbd> z powodem <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Dezaktywacja znacznika o nazwie <kbd>spam</kbd> z powodu <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Łączenie historii edycji.",
+ "apihelp-mergehistory-param-from": "Tytuł strony, z której historia ma zostać połączona. Nie może być używane z <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "ID strony, z której historia ma zostać połączona. Nie może być używane z <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Tytuł strony, z którą połączyć historię. Nie może być używane z <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "ID strony, z którą połączyć historię. Nie może być używane z <var>$1to</var>.",
+ "apihelp-mergehistory-param-reason": "Powód łączenia historii.",
+ "apihelp-mergehistory-example-merge": "Połącz całą historię strony <kbd>Oldpage</kbd> ze stroną <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Przenieś stronę.",
+ "apihelp-move-param-from": "Tytuł strony do zmiany nazwy. Nie można używać razem z <var>$1fromid</var>.",
+ "apihelp-move-param-to": "Tytuł na jaki zmienić nazwę strony.",
+ "apihelp-move-param-reason": "Powód zmiany nazwy.",
+ "apihelp-move-param-movetalk": "Zmień nazwę strony dyskusji, jeśli istnieje.",
+ "apihelp-move-param-movesubpages": "Zmień nazwy podstron, jeśli możliwe.",
+ "apihelp-move-param-noredirect": "Nie twórz przekierowania.",
+ "apihelp-move-param-watch": "Dodaj stronę i przekierowanie do listy obserwowanych bieżącego użytkownika.",
+ "apihelp-move-param-unwatch": "Usuń stronę i przekierowanie z listy obserwowanych bieżącego użytkownika.",
+ "apihelp-move-param-ignorewarnings": "Ignoruj wszystkie ostrzeżenia.",
+ "apihelp-move-example-move": "Przenieś <kbd>Badtitle</kbd> na <kbd>Goodtitle</kbd> bez pozostawienia przekierowania.",
+ "apihelp-opensearch-summary": "Przeszukaj wiki przy użyciu protokołu OpenSearch.",
+ "apihelp-opensearch-param-search": "Wyszukaj tekst.",
+ "apihelp-opensearch-param-limit": "Maksymalna liczba zwracanych wyników.",
+ "apihelp-opensearch-param-namespace": "Przestrzenie nazw do przeszukania.",
+ "apihelp-opensearch-param-suggest": "Nic nie robi, jeżeli <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> ustawiono na false.",
+ "apihelp-opensearch-param-redirects": "Jak obsługiwać przekierowania:\n;return:Zwróć samo przekierowanie.\n;resolve:Zwróć stronę docelową. Może zwrócić mniej niż wyników określonych w $1limit.\nZ powodów historycznych, domyślnie jest to \"return\" dla $1format=json, a \"resolve\" dla innych formatów.",
+ "apihelp-opensearch-param-format": "Format danych wyjściowych.",
+ "apihelp-opensearch-param-warningsaserror": "Jeżeli pojawią się ostrzeżenia związane z <kbd>format=json</kbd>, zwróć błąd API zamiast ignorowania ich.",
+ "apihelp-opensearch-example-te": "Znajdź strony zaczynające się od <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Zmienia preferencje bieżącego użytkownika.",
+ "apihelp-options-extended-description": "Można ustawiać tylko opcje zarejestrowane w rdzeniu, w zainstalowanych rozszerzeniach lub z kluczami o prefiksie <code>userjs-</code> (do wykorzystywania przez skrypty użytkowników).",
+ "apihelp-options-param-reset": "Resetuj preferencje do domyślnych.",
+ "apihelp-options-param-resetkinds": "Lista typów opcji do zresetowania, jeżeli ustawiono opcję <var>$1reset</var>.",
+ "apihelp-options-param-change": "Lista zmian, w formacie nazwa=wartość (np. skin=vector). Jeżeli nie zostanie podana wartość (nawet znak równości), np., optionname|otheroption|..., to opcja zostanie zresetowana do jej wartości domyślnej. Jeżeli jakakolwiek podawana wartość zawiera znak pionowej kreski (<kbd>|</kbd>), użyj [[Special:ApiHelp/main#main/datatypes|alternatywnego separatora wielu wartości]] aby operacja się powiodła.",
+ "apihelp-options-param-optionname": "Nazwa opcji, która powinna być ustawiona na wartość <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "Wartość opcji, określona w <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Resetuj wszystkie preferencje.",
+ "apihelp-options-example-change": "Zmień preferencje <kbd>skin</kbd> (skórka) i <kbd>hideminor</kbd> (ukryj drobne edycje).",
+ "apihelp-options-example-complex": "Zresetuj wszystkie preferencje, a następnie ustaw <kbd>skin</kbd> i <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Zdobądź informacje o modułach API.",
+ "apihelp-paraminfo-param-modules": "Lista nazw modułów (wartości parametrów <var>action</var> i <var>format</var> lub <kbd>main</kbd>). Można określić podmoduły za pomocą <kbd>+</kbd> lub wszystkie podmoduły, wpisując <kbd>+*</kbd>, lub wszystkie podmoduły rekursywnie <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Format tekstów pomocy.",
+ "apihelp-paraminfo-param-querymodules": "Lista nazw modułów zapytań (wartość parametrów <var>prop</var>, <var>meta</var> lub <var>list</var>). Użyj <kbd>$1modules=query+foo</kbd> zamiast <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-parse-param-summary": "Powód do sparsowania.",
+ "apihelp-parse-param-prop": "Jakie porcje informacji otrzymać:",
+ "apihelp-parse-paramvalue-prop-text": "Przetworzony tekst z wikitekstu.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Linki językowe z przetworzonego wikitekstu.",
+ "apihelp-parse-paramvalue-prop-categories": "Kategorie z przetworzonego wikitekstu.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Wersja HTML listy kategorii.",
+ "apihelp-parse-paramvalue-prop-links": "Linki wewnętrzne z przetworzonego wikitekstu.",
+ "apihelp-parse-paramvalue-prop-templates": "Szablony z przetworzonego wikitekstu.",
+ "apihelp-parse-paramvalue-prop-images": "Zdjęcia z przetworzonego wikitekstu.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Linki zewnętrzne z przetworzonego wikitekstu.",
+ "apihelp-parse-paramvalue-prop-sections": "Sekcje z przetworzonego wikitekstu.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Dodaje tytuł parsowanego wikitekstu.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Zwróć oryginalny wikitekst, który został sparsowany.",
+ "apihelp-parse-param-preview": "Parsuj w trybie podglądu.",
+ "apihelp-parse-param-disabletoc": "Pomiń spis treści na wyjściu.",
+ "apihelp-parse-example-page": "Przeanalizuj stronę.",
+ "apihelp-parse-example-text": "Parsuj wikitekst.",
+ "apihelp-parse-example-summary": "Parsuj powód.",
+ "apihelp-patrol-summary": "Sprawdź stronę lub edycję.",
+ "apihelp-patrol-param-rcid": "ID z ostatnich zmian do oznaczenia jako sprawdzone.",
+ "apihelp-patrol-param-revid": "Numer edycji do sprawdzenia.",
+ "apihelp-patrol-example-rcid": "Sprawdź ostatnią zmianę.",
+ "apihelp-patrol-example-revid": "Sprawdź edycje.",
+ "apihelp-protect-summary": "Zmień poziom zabezpieczenia strony.",
+ "apihelp-protect-param-reason": "Powód zabezpieczania/odbezpieczania.",
+ "apihelp-protect-param-cascade": "Włącz ochronę kaskadową (chronione są wszystkie osadzone szablony i obrazki na tej stronie). Ignorowane, jeśli żaden z danych poziomów ochrony nie wspiera kaskadowania.",
+ "apihelp-protect-example-protect": "Zabezpiecz stronę",
+ "apihelp-protect-example-unprotect": "Odbezpiecz stronę ustawiając ograniczenia na <kbd>all</kbd> (czyli każdy może wykonać działanie).",
+ "apihelp-protect-example-unprotect2": "Odbezpiecz stronę ustawiając brak ograniczeń.",
+ "apihelp-purge-summary": "Wyczyść pamięć podręczną dla stron o podanych tytułach.",
+ "apihelp-purge-param-forcelinkupdate": "Uaktualnij tabele linków.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Uaktualnij tabele linków włącznie z linkami dotyczącymi każdej strony wykorzystywanej jako szablon na tej stronie.",
+ "apihelp-purge-example-simple": "Wyczyść strony <kbd>Main Page</kbd> i <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "Przeczyść pierwsze 10 stron w przestrzeni głównej.",
+ "apihelp-query+allcategories-summary": "Wymień wszystkie kategorie.",
+ "apihelp-query+allcategories-param-from": "Kategoria, od której rozpocząć wyliczanie.",
+ "apihelp-query+allcategories-param-to": "Kategoria, na której zakończyć wyliczanie.",
+ "apihelp-query+allcategories-param-dir": "Kierunek sortowania.",
+ "apihelp-query+allcategories-param-limit": "Liczba kategorii do zwrócenia.",
+ "apihelp-query+allcategories-param-prop": "Jakie właściwości otrzymać:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Dodaje liczbę stron w kategorii.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Oznacza kategorie ukryte za pomocą <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Wymień kategorie z informacjami o liczbie stron w każdej z nich.",
+ "apihelp-query+alldeletedrevisions-summary": "Wymień wszystkie usunięte wersje użytkownika lub z przestrzeni nazw.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Może być użyte tylko z <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Nie może być używane z <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "Znacznik czasu, od którego rozpocząć wyliczanie.",
+ "apihelp-query+alldeletedrevisions-param-end": "Znacznik czasu, na którym zakończyć wyliczanie.",
+ "apihelp-query+alldeletedrevisions-param-from": "Zacznij listowanie na tym tytule.",
+ "apihelp-query+alldeletedrevisions-param-to": "Skończ listowanie na tym tytule.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Szukaj tytułów stron zaczynających się na tę wartość.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Pokazuj tylko zmiany oznaczone tym znacznikiem.",
+ "apihelp-query+alldeletedrevisions-param-user": "Pokazuj tylko zmiany dokonane przez tego użytkownika.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Nie pokazuj zmian dokonanych przez tego użytkownika.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Listuj tylko strony z tej przestrzeni nazw.",
+ "apihelp-query+alldeletedrevisions-example-user": "Wymień ostatnie 50 usuniętych edycji przez użytkownika <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Wymień ostatnie 50 usuniętych edycji z przestrzeni głównej.",
+ "apihelp-query+allfileusages-summary": "Lista wykorzystania pliku, także dla nieistniejących.",
+ "apihelp-query+allfileusages-param-from": "Nazwa pliku, od którego rozpocząć wyliczanie.",
+ "apihelp-query+allfileusages-param-to": "Nazwa pliku, na którym zakończyć wyliczanie.",
+ "apihelp-query+allfileusages-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Dodaje tytuł pliku.",
+ "apihelp-query+allfileusages-param-limit": "Łączna liczba obiektów do zwrócenia.",
+ "apihelp-query+allfileusages-example-unique": "Lista unikatowych tytułów plików.",
+ "apihelp-query+allimages-param-sort": "Sortowanie według właściwości.",
+ "apihelp-query+allimages-param-minsize": "Ogranicz do obrazków, mających co najmniej taką liczbę bajtów.",
+ "apihelp-query+allimages-param-maxsize": "Ogranicz do obrazków, mających co najwyżej taką liczbę bajtów.",
+ "apihelp-query+allimages-example-B": "Pokaz listę plików rozpoczynających się na literę <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Pokaż listę ostatnio przesłanych plików, podobnie do [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Pokaż listę plików z typem MIME <kbd>image/png</kbd> lub <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Pokaż informacje o 4 plikach rozpoczynających się na literę <kbd>T</kbd>.",
+ "apihelp-query+alllinks-param-from": "Nazwa linku, od którego rozpocząć wyliczanie.",
+ "apihelp-query+alllinks-param-to": "Nazwa linku, na którym zakończyć wyliczanie.",
+ "apihelp-query+alllinks-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Dodaje tytuł linku.",
+ "apihelp-query+alllinks-param-namespace": "Przestrzeń nazw, z której wymieniać.",
+ "apihelp-query+alllinks-param-limit": "Łączna liczba obiektów do zwrócenia.",
+ "apihelp-query+alllinks-example-unique": "Lista unikatowych tytułów plików.",
+ "apihelp-query+allmessages-param-prop": "Właściwości do odczytu.",
+ "apihelp-query+allmessages-param-prefix": "Zwróć wiadomości z tym prefixem.",
+ "apihelp-query+allmessages-example-ipb": "Pokaż wiadomości rozpoczynające się od <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Pokaż wiadomości <kbd>august</kbd> i <kbd>mainpage</kbd> w języku niemieckim.",
+ "apihelp-query+allpages-param-from": "Tytuł strony, od której rozpocząć wyliczanie.",
+ "apihelp-query+allpages-param-to": "Tytuł strony, na której zakończyć wyliczanie.",
+ "apihelp-query+allpages-param-minsize": "Ogranicz do stron, mających co najmniej taką liczbę bajtów.",
+ "apihelp-query+allpages-param-maxsize": "Ogranicz do stron, mających co najwyżej taką liczbę bajtów.",
+ "apihelp-query+allpages-param-prtype": "Ogranicz tylko do zabezpieczonych stron.",
+ "apihelp-query+allpages-param-limit": "Liczba stron do zwrócenia.",
+ "apihelp-query+allpages-example-B": "Pokaż listę stron rozpoczynających się na literę <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Pokaż informacje o 4 stronach rozpoczynających się na literę <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Pokaż zawartość pierwszych dwóch nieprzekierowujących stron, zaczynających się na <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Lista wszystkich przekierowań do przestrzeni nazw.",
+ "apihelp-query+allredirects-param-from": "Nazwa przekierowania, od którego rozpocząć wyliczanie.",
+ "apihelp-query+allredirects-param-to": "Nazwa przekierowania, na którym zakończyć wyliczanie.",
+ "apihelp-query+allredirects-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Dodaje tytuł przekierowania.",
+ "apihelp-query+allredirects-param-namespace": "Przestrzeń nazw, z której wymieniać.",
+ "apihelp-query+allredirects-param-limit": "Łączna liczba obiektów do zwrócenia.",
+ "apihelp-query+allrevisions-summary": "Wyświetl wszystkie wersje.",
+ "apihelp-query+allrevisions-param-start": "Znacznik czasu, od którego rozpocząć wyliczanie.",
+ "apihelp-query+allrevisions-param-end": "Znacznik czasu, na którym zakończyć wyliczanie.",
+ "apihelp-query+allrevisions-param-user": "Wyświetl wersje tylko tego użytkownika.",
+ "apihelp-query+allrevisions-param-excludeuser": "Nie wyświetlaj wersji tego użytkownika.",
+ "apihelp-query+allrevisions-param-namespace": "Wyświetl tylko strony w przestrzeni głównej.",
+ "apihelp-query+allrevisions-example-ns-main": "Wyświetl pierwsze 50 wersji w przestrzeni głównej.",
+ "apihelp-query+mystashedfiles-param-limit": "Liczba plików do pobrania.",
+ "apihelp-query+alltransclusions-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Dodaje tytuł osadzenia.",
+ "apihelp-query+alltransclusions-param-namespace": "Przestrzeń nazw, z której wymieniać.",
+ "apihelp-query+alltransclusions-param-limit": "Łączna liczba elementów do zwrócenia.",
+ "apihelp-query+allusers-param-from": "Nazwa użytkownika, od którego rozpocząć wyliczanie.",
+ "apihelp-query+allusers-param-to": "Nazwa użytkownika, na którym zakończyć wyliczanie.",
+ "apihelp-query+allusers-param-prefix": "Wyszukaj wszystkich użytkowników, których nazwy zaczynają się od tej wartości.",
+ "apihelp-query+allusers-param-dir": "Kierunek sortowania.",
+ "apihelp-query+allusers-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Wyświetla uprawnienia, które posiada użytkownik.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Dodaje liczbę edycji użytkownika.",
+ "apihelp-query+allusers-param-limit": "Łączna liczba nazw użytkowników do zwrócenia.",
+ "apihelp-query+allusers-param-witheditsonly": "Tylko użytkownicy, którzy edytowali.",
+ "apihelp-query+allusers-param-activeusers": "Wyświetl tylko użytkowników, aktywnych w ciągu {{PLURAL:$1|ostatniego dnia|ostatnich $1 dni}}.",
+ "apihelp-query+allusers-example-Y": "Wyświetl użytkowników zaczynających się na <kbd>Y</kbd>.",
+ "apihelp-query+backlinks-summary": "Znajdź wszystkie strony, które linkują do danej strony.",
+ "apihelp-query+backlinks-param-namespace": "Przestrzeń nazw, z której wymieniać.",
+ "apihelp-query+backlinks-example-simple": "Pokazuj linki do <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Lista wszystkich zablokowanych użytkowników i adresów IP.",
+ "apihelp-query+blocks-param-start": "Znacznik czasu, od którego rozpocząć wyliczanie.",
+ "apihelp-query+blocks-param-end": "Znacznik czasu, na którym zakończyć wyliczanie.",
+ "apihelp-query+blocks-param-ids": "Lista zablokowanych ID do wylistowania (opcjonalne).",
+ "apihelp-query+blocks-param-users": "Lista użytkowników do wyszukania (opcjonalne).",
+ "apihelp-query+blocks-param-limit": "Maksymalna liczba blokad do wylistowania.",
+ "apihelp-query+blocks-paramvalue-prop-id": "Dodaje identyfikator blokady.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Dodaje nazwę zablokowanego użytkownika.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Dodaje identyfikator zablokowanego użytkownika.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Dodaje znacznik czasu założenia blokady.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Dodaje znacznik czasu wygaśnięcia blokady.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Dodaje powód zablokowania.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Dodaje zakres adresów IP, na który zastosowano blokadę.",
+ "apihelp-query+blocks-example-simple": "Listuj blokady.",
+ "apihelp-query+categories-summary": "Lista kategorii, do których należą strony",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Dodaje znacznik czasu dodania kategorii.",
+ "apihelp-query+categories-param-limit": "Liczba kategorii do zwrócenia.",
+ "apihelp-query+categoryinfo-summary": "Zwraca informacje o danych kategoriach.",
+ "apihelp-query+categorymembers-summary": "Wszystkie strony w danej kategorii.",
+ "apihelp-query+categorymembers-param-title": "Kategoria, której zawartość wymienić (wymagane). Musi zawierać prefiks <kbd>{{ns:category}}:</kbd>. Nie może być używany równocześnie z <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "ID strony kategorii, z której wymienić strony. Nie może być użyty równocześnie z <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Doda ID strony.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Doda tytuł i identyfikator przestrzeni nazw strony.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Doda klucz sortowania obowiązujący w danej kategorii (ciąg szesnastkowy).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Doda klucz sortowania obowiązujący w danej kategorii (czytelna przez człowieka część klucza sortowania).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Doda informacje o typie strony w kategorii (<samp>page</samp> (strona), <samp>subcat</samp> (podkategoria) lub <samp>file</samp> (plik)).",
+ "apihelp-query+categorymembers-param-limit": "Maksymalna liczba zwracanych wyników.",
+ "apihelp-query+categorymembers-param-sort": "Sortowanie według właściwości.",
+ "apihelp-query+categorymembers-param-dir": "W jakim kierunku sortować.",
+ "apihelp-query+deletedrevisions-param-tag": "Pokazuj tylko zmiany oznaczone tym znacznikiem.",
+ "apihelp-query+deletedrevisions-param-user": "Pokazuj tylko zmiany dokonane przez tego użytkownika.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Nie pokazuj zmian dokonanych przez tego użytkownika.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Tryb|Tryby}}: $2",
+ "apihelp-query+deletedrevs-param-start": "Znacznik czasu, od którego rozpocząć wyliczanie.",
+ "apihelp-query+deletedrevs-param-end": "Znacznik czasu, na którym zakończyć wyliczanie.",
+ "apihelp-query+deletedrevs-param-unique": "Listuj tylko jedną edycję dla każdej strony.",
+ "apihelp-query+deletedrevs-param-tag": "Pokazuj tylko zmiany oznaczone tym znacznikiem.",
+ "apihelp-query+deletedrevs-param-user": "Listuj tylko zmiany dokonane przez tego użytkownika.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Nie listuj zmian dokonanych przez tego użytkownika.",
+ "apihelp-query+deletedrevs-param-namespace": "Listuj tylko strony z tej przestrzeni nazw.",
+ "apihelp-query+deletedrevs-param-limit": "Maksymalna liczba zmian do wylistowania.",
+ "apihelp-query+disabled-summary": "Ten moduł zapytań został wyłączony.",
+ "apihelp-query+duplicatefiles-summary": "Lista wszystkich plików które są duplikatami danych plików bazujących na wartościach z hashem.",
+ "apihelp-query+duplicatefiles-example-generated": "Szukaj duplikatów wśród wszystkich plików.",
+ "apihelp-query+embeddedin-param-filterredir": "Jak filtrować przekierowania.",
+ "apihelp-query+embeddedin-param-limit": "Łączna liczba stron do zwrócenia.",
+ "apihelp-query+extlinks-param-limit": "Liczba linków do zwrócenia.",
+ "apihelp-query+exturlusage-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Dodaje ID strony.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Dodaje tytuł i identyfikator przestrzeni nazw strony.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Dodaje adres URL, używany na stronie.",
+ "apihelp-query+exturlusage-param-limit": "Liczba stron do zwrócenia.",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Alias rozmiaru.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Dodaje opis wersji obrazka.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Dodaje typ MIME obrazka.",
+ "apihelp-query+filearchive-example-simple": "Pokaż listę wszystkich usuniętych plików.",
+ "apihelp-query+filerepoinfo-example-simple": "Uzyskaj informacje na temat repozytoriów plików.",
+ "apihelp-query+fileusage-summary": "Znajdź wszystkie strony, które używają danych plików.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Nazwa każdej strony.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Oznacz, jeśli strona jest przekierowaniem.",
+ "apihelp-query+fileusage-param-limit": "Ilość do zwrócenia.",
+ "apihelp-query+imageinfo-summary": "Zwraca informacje o pliku i historię przesyłania.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Dodaje kanoniczny tytuł pliku.",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Alias rozmiaru.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Dołączy sumę kontrolną SHA-1 dla tego pliku.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Dodaje typ MIME pliku.",
+ "apihelp-query+imageinfo-param-urlheight": "Podobne do $1urlwidth.",
+ "apihelp-query+images-param-limit": "Liczba plików do zwrócenia.",
+ "apihelp-query+imageusage-example-simple": "Pokaż strony, które korzystają z [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "Pokaż podstawowe informacje o stronie.",
+ "apihelp-query+info-paramvalue-prop-watchers": "Liczba obserwujących, jeśli jest to dozwolone.",
+ "apihelp-query+info-paramvalue-prop-readable": "Czy użytkownik może przeczytać tę stronę.",
+ "apihelp-query+iwbacklinks-param-prefix": "Prefix interwiki.",
+ "apihelp-query+iwbacklinks-param-limit": "Łączna liczba stron do zwrócenia.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Dodaje prefiks interwiki.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Dodaje tytuł interwiki.",
+ "apihelp-query+iwlinks-summary": "Wyświetla wszystkie liki interwiki z danych stron.",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Dodaje pełny adres URL.",
+ "apihelp-query+iwlinks-param-limit": "Łączna liczba linków interwiki do zwrócenia.",
+ "apihelp-query+langbacklinks-param-limit": "Łączna liczba stron do zwrócenia.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Dodaje kod języka linku językowego.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Dodaje tytuł linku językowego.",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Dodaje pełny adres URL.",
+ "apihelp-query+links-summary": "Zwraca wszystkie linki z danych stron.",
+ "apihelp-query+links-param-namespace": "Pokaż linki tylko w tych przestrzeniach nazw.",
+ "apihelp-query+links-param-limit": "Liczba linków do zwrócenia.",
+ "apihelp-query+linkshere-summary": "Znajdź wszystkie strony, które linkują do danych stron.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Nazwa każdej strony.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Oznacz, jeśli strona jest przekierowaniem.",
+ "apihelp-query+linkshere-param-limit": "Liczba do zwrócenia.",
+ "apihelp-query+logevents-summary": "Pobierz zdarzenia z rejestru.",
+ "apihelp-query+logevents-example-simple": "Lista ostatnich zarejestrowanych zdarzeń.",
+ "apihelp-query+pagepropnames-param-limit": "Maksymalna liczba zwracanych nazw.",
+ "apihelp-query+pageswithprop-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Doda ID strony.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Dodaje wartość właściwości strony.",
+ "apihelp-query+pageswithprop-param-limit": "Maksymalna liczba zwracanych stron.",
+ "apihelp-query+pageswithprop-param-dir": "W jakim kierunku sortować.",
+ "apihelp-query+pageswithprop-example-generator": "Pobierz dodatkowe informacje o pierwszych 10 stronach wykorzystując <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-param-search": "Wyszukaj tekst.",
+ "apihelp-query+prefixsearch-param-namespace": "Przestrzenie nazw do przeszukania.",
+ "apihelp-query+prefixsearch-param-limit": "Maksymalna liczba zwracanych wyników.",
+ "apihelp-query+prefixsearch-param-offset": "Liczba wyników do pominięcia.",
+ "apihelp-query+protectedtitles-summary": "Lista wszystkich tytułów zabezpieczonych przed tworzeniem.",
+ "apihelp-query+protectedtitles-param-namespace": "Listuj tylko strony z tych przestrzeni nazw.",
+ "apihelp-query+protectedtitles-param-limit": "Łączna liczba stron do zwrócenia.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Dodaje poziom zabezpieczeń.",
+ "apihelp-query+protectedtitles-example-simple": "Wymień zabezpieczone tytuły.",
+ "apihelp-query+querypage-param-page": "Nazwa strony specjalnej. Należy pamiętać o wielkości liter.",
+ "apihelp-query+querypage-param-limit": "Liczba zwracanych wyników.",
+ "apihelp-query+random-param-namespace": "Zwraca strony tylko w tych przestrzeniach nazw.",
+ "apihelp-query+random-param-filterredir": "Jaki filtrować przekierowania.",
+ "apihelp-query+random-example-simple": "Zwraca dwie losowe strony z głównej przestrzeni nazw.",
+ "apihelp-query+recentchanges-param-user": "Listuj tylko zmiany dokonane przez tego użytkownika.",
+ "apihelp-query+recentchanges-param-excludeuser": "Nie listuj zmian dokonanych przez tego użytkownika.",
+ "apihelp-query+recentchanges-param-tag": "Pokazuj tylko zmiany oznaczone tym znacznikiem.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Dodaje komentarz do edycji.",
+ "apihelp-query+recentchanges-example-simple": "Lista ostatnich zmian.",
+ "apihelp-query+redirects-summary": "Zwraca wszystkie przekierowania do danej strony.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Nazwa każdego przekierowania.",
+ "apihelp-query+redirects-param-limit": "Ile przekierowań zwrócić.",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "Identyfikator wersji.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Znaczniki wersji (drobne).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "Znacznik czasu wersji.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Użytkownik, który utworzył wersję.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Długość wersji (w bajtach).",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1 (base 16) wersji.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Tekst wersji.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Znaczniki wersji.",
+ "apihelp-query+revisions+base-param-limit": "Ograniczenie na liczbę wersji, które będą zwrócone.",
+ "apihelp-query+search-summary": "Wykonaj wyszukiwanie pełnotekstowe.",
+ "apihelp-query+search-param-info": "Które metadane zwrócić.",
+ "apihelp-query+search-paramvalue-prop-size": "Dodaje rozmiar strony w bajtach.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Dodaje liczbę słów na stronie.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Dodaje tytuł pasującego przekierowania.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Zignorowano",
+ "apihelp-query+search-param-limit": "Łączna liczba stron do zwrócenia.",
+ "apihelp-query+search-param-interwiki": "Dołączaj wyniki wyszukiwań interwiki w wyszukiwarce, jeśli możliwe.",
+ "apihelp-query+search-example-simple": "Szukaj <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Ogólne informacje o systemie.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "Lista zarejestrowanych przestrzeni nazw i ich nazwy kanoniczne.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Lista zarejestrowanych aliasów przestrzeni nazw.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Lista słów magicznych i ich aliasów.",
+ "apihelp-query+siteinfo-param-numberingroup": "Wyświetla liczbę użytkowników w grupach użytkowników.",
+ "apihelp-query+siteinfo-example-simple": "Pobierz informacje o stronie.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Alias dla $1filekey, dla kompatybilności wstecznej.",
+ "apihelp-query+tags-summary": "Lista znaczników zmian.",
+ "apihelp-query+tags-param-limit": "Maksymalna liczba znaczników do wyświetlenia.",
+ "apihelp-query+tags-paramvalue-prop-name": "Dodaje nazwę znacznika.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Dodaje komunikat systemowy dla znacznika.",
+ "apihelp-query+tags-paramvalue-prop-description": "Dodaje opis znacznika.",
+ "apihelp-query+tags-paramvalue-prop-active": "Czy znacznik jest nadal stosowany.",
+ "apihelp-query+tags-example-simple": "Wymień dostępne znaczniki.",
+ "apihelp-query+templates-summary": "Zwraca wszystkie strony osadzone w danych stronach.",
+ "apihelp-query+templates-param-namespace": "Pokaż szablony tylko w tych przestrzeniach nazw.",
+ "apihelp-query+templates-param-limit": "Ile szablonów zwrócić?",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Nazwa każdej strony.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Oznacz, jeśli strona jest przekierowaniem.",
+ "apihelp-query+transcludedin-param-limit": "Ile zwrócić.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Dodaje komentarz edycji.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Dodaje sparsowany komentarz edycji.",
+ "apihelp-query+userinfo-summary": "Pobierz informacje o aktualnym użytkowniku.",
+ "apihelp-query+userinfo-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Wyświetla wszystkie grupy, do których należy bieżący użytkownik.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Wyświetla wszystkie uprawnienia, które ma bieżący użytkownik.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Zdobądź token, by zmienić bieżące preferencje użytkownika.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Dodaje liczbę edycji bieżącego użytkownika.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Dodaje adres e-mail użytkownika i datę jego potwierdzenia.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Dodaje datę rejestracji użytkownika.",
+ "apihelp-query+userinfo-example-simple": "Pobierz informacje o aktualnym użytkowniku.",
+ "apihelp-query+userinfo-example-data": "Pobierz dodatkowe informacje o aktualnym użytkowniku.",
+ "apihelp-query+users-summary": "Pobierz informacje o liście użytkowników.",
+ "apihelp-query+users-param-prop": "Jakie informacje dołączyć:",
+ "apihelp-query+users-paramvalue-prop-groups": "Wyświetla wszystkie grupy, do których należy każdy z użytkowników.",
+ "apihelp-query+users-paramvalue-prop-rights": "Wyświetla wszystkie uprawnienia, które ma każdy z użytkowników.",
+ "apihelp-query+users-param-users": "Lista użytkowników, o których chcesz pobrać informacje.",
+ "apihelp-query+watchlist-param-excludeuser": "Nie wyświetlaj zmian wykonanych przez tego użytkownika.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Dodaje tytuł strony.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Dodaje użytkownika, który wykonał edycję.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Dodaje komentarz do edycji.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Dodaje znacznik czasu edycji.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Dodaje starą i nową długość strony.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Zmiany zewnętrzne.",
+ "apihelp-resetpassword-summary": "Wyślij użytkownikowi e-mail do resetowania hasła.",
+ "apihelp-resetpassword-example-email": "Wyślij e-mail do resetowania hasła do wszystkich użytkowników posiadających adres <kbd>user@example.com</kbd>.",
+ "apihelp-revisiondelete-param-ids": "Identyfikatory wersji do usunięcia.",
+ "apihelp-revisiondelete-param-hide": "Co ukryć w każdej z wersji.",
+ "apihelp-revisiondelete-param-show": "Co pokazać w każdej z wersji.",
+ "apihelp-revisiondelete-param-reason": "Powód usunięcia lub przywrócenia.",
+ "apihelp-setpagelanguage-summary": "Zmień język strony.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Zmiana języka strony nie jest dozwolona na tej wiki.\n\nWłącz <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> by użyć tej akcji.",
+ "apihelp-setpagelanguage-param-reason": "Powód zmiany.",
+ "apihelp-stashedit-param-title": "Tytuł edytowanej strony.",
+ "apihelp-stashedit-param-sectiontitle": "Tytuł nowej sekcji.",
+ "apihelp-stashedit-param-text": "Zawartość strony.",
+ "apihelp-stashedit-param-summary": "Opis zmian.",
+ "apihelp-tag-param-reason": "Powód zmiany.",
+ "apihelp-unblock-summary": "Odblokuj użytkownika.",
+ "apihelp-unblock-param-user": "Nazwa użytkownika, adres IP albo zakres adresów IP, które chcesz odblokować. Nie można używać jednocześnie z <var>$1id</var> lub <var>$1userid</var>.",
+ "apihelp-unblock-param-reason": "Powód odblokowania.",
+ "apihelp-undelete-param-title": "Tytuł strony do przywrócenia.",
+ "apihelp-undelete-param-reason": "Powód przywracania.",
+ "apihelp-upload-param-filename": "Nazwa pliku docelowego.",
+ "apihelp-upload-param-watch": "Obserwuj stronę.",
+ "apihelp-upload-param-ignorewarnings": "Ignoruj wszystkie ostrzeżenia.",
+ "apihelp-upload-param-file": "Zawartość pliku.",
+ "apihelp-userrights-param-user": "Nazwa użytkownika.",
+ "apihelp-userrights-param-userid": "Identyfikator użytkownika.",
+ "apihelp-userrights-param-add": "Dodaj użytkownika do tych grup, lub, jeżeli jest już ich członkiem, zmień czas wygaśnięcia członkostwa w tych grupach.",
+ "apihelp-userrights-param-remove": "Usuń użytkownika z tych grup.",
+ "apihelp-userrights-param-reason": "Powód zmiany.",
+ "apihelp-validatepassword-param-password": "Hasło do walidacji.",
+ "apihelp-json-summary": "Dane wyjściowe w formacie JSON.",
+ "apihelp-jsonfm-summary": "Dane wyjściowe w formacie JSON (prawidłowo wyświetlane w HTML).",
+ "apihelp-php-summary": "Dane wyjściowe w serializowany formacie PHP.",
+ "apihelp-phpfm-summary": "Dane wyjściowe w serializowanym formacie PHP (prawidłowo wyświetlane w HTML).",
+ "apihelp-xml-summary": "Dane wyjściowe w formacie XML.",
+ "apihelp-xml-param-xslt": "Jeśli określony, dodaje podaną stronę jako arkusz styli XSL. Powinna to być strona wiki w przestrzeni nazw MediaWiki, której nazwa kończy się na <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Jeśli zaznaczono, dodaje przestrzeń nazw XML.",
+ "apihelp-xmlfm-summary": "Dane wyjściowe w formacie XML (prawidłowo wyświetlane w HTML).",
+ "api-format-title": "Wynik MediaWiki API",
+ "api-pageset-param-titles": "Lista tytułów, z którymi pracować.",
+ "api-pageset-param-pageids": "Lista identyfikatorów stron, z którymi pracować.",
+ "api-pageset-param-revids": "Lista identyfikatorów wersji, z którymi pracować.",
+ "api-pageset-param-generator": "Pobierz listę stron, z którymi pracować poprzez wykonanie określonego modułu zapytań.\n\n<strong>Uwaga:</strong> Nazwy parametrów generatora musi poprzedzać prefiks „g”. Zobacz przykłady.",
+ "api-pageset-param-redirects-generator": "Automatycznie rozwiązuj przekierowania ze stron podanych w <var>$1titles</var>, <var>$1pageids</var>, oraz <var>$1revids</var>, a także ze stron zwróconych przez <var>$1generator</var>.",
+ "api-pageset-param-converttitles": "Konwertuj tytuły do innych wariantów, jeżeli trzeba. Będzie działać tylko wtedy, gdy język zawartości wiki będzie wspierał konwersje wariantów. Języki, które wspierają konwersję wariantów to m.in. $1.",
+ "api-help-title": "Pomoc MediaWiki API",
+ "api-help-lead": "To jest automatycznie wygenerowana strona dokumentacji MediaWiki API.\nDokumentacja i przykłady: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Moduł główny",
+ "api-help-undocumented-module": "Brak dokumentacji dla modułu $1.",
+ "api-help-flag-deprecated": "Ten moduł jest przestarzały.",
+ "api-help-flag-internal": "<strong>Ten moduł jest wewnętrzny lub niestabilny.</strong> Jego działanie może się zmienić bez uprzedzenia.",
+ "api-help-flag-readrights": "Ten moduł wymaga praw odczytu.",
+ "api-help-flag-writerights": "Ten moduł wymaga praw zapisu.",
+ "api-help-flag-mustbeposted": "Ten moduł akceptuje tylko żądania POST.",
+ "api-help-flag-generator": "Ten moduł może być użyty jako generator.",
+ "api-help-source": "Źródło: $1",
+ "api-help-source-unknown": "Źródło: <span class=\"apihelp-unknown\">nieznane</span>",
+ "api-help-license": "Licencja: [[$1|$2]]",
+ "api-help-license-noname": "Licencja: [[$1|Zobacz link]]",
+ "api-help-license-unknown": "Licencja: <span class=\"apihelp-unknown\">nieznana</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parametr|Parametry}}:",
+ "api-help-param-deprecated": "Przestarzałe.",
+ "api-help-param-required": "Ten parametr jest wymagany.",
+ "api-help-datatypes-header": "Typy danych",
+ "api-help-param-type-integer": "Typ: {{PLURAL:$1|1=liczba całkowita|2=lista liczb całkowitych}}",
+ "api-help-param-type-boolean": "Typ: wartość logiczna ([[Special:ApiHelp/main#main/datatypes|szczegóły]])",
+ "api-help-param-type-timestamp": "Typ: {{PLURAL:$1|1=znacznik czasu|2=lista znaczników czasu}} ([[Special:ApiHelp/main#main/datatypes|dozwolone formaty]])",
+ "api-help-param-type-user": "Typ: {{PLURAL:$1|1=nazwa użytkownika|2=lista nazw uzytkowników}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Jedna z następujących wartości|2=Wartości (oddziel za pomocą <kbd>{{!}}</kbd> lub [[Special:ApiHelp/main#main/datatypes|alternatywy]])}}: $2",
+ "api-help-param-limit": "Nie więcej niż $1 dozwolone.",
+ "api-help-param-limit2": "Nie więcej niż $1 ($2 dla botów) dozwolone.",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=Wartość musi być nie mniejsza|2=Wartości muszą być nie mniejsze}} niż $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Wartość musi być nie większa|2=Wartości muszą być nie większe}} niż $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Wartość musi|2=Wartości muszą}} być pomiędzy $2 a $3.",
+ "api-help-param-multi-separate": "Oddziel wartości za pomocą <kbd>|</kbd> lub [[Special:ApiHelp/main#main/datatypes|alternatywy]].",
+ "api-help-param-multi-max": "Maksymalna liczba wartości to {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} dla botów).",
+ "api-help-param-multi-all": "Aby wskazać wszystkie wartości, użyj <kbd>$1</kbd>.",
+ "api-help-param-default": "Domyślnie: $1",
+ "api-help-param-default-empty": "Domyślnie: <span class=\"apihelp-empty\">(puste)</span>",
+ "api-help-param-token": "Token \"$1\" zdobyty z [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-continue": "Gdy będzie dostępnych więcej wyników, użyj tego do kontynuowania.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(bez opisu)</span>",
+ "api-help-examples": "{{PLURAL:$1|Przykład|Przykłady}}:",
+ "api-help-permissions": "{{PLURAL:$2|Uprawnienie|Uprawnienia}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Przydzielone dla}}: $2",
+ "api-help-right-apihighlimits": "Użyj wyższych limitów w zapytaniach API (dla zapytań powolnych: $1; dla zapytań szbkich: $2). Limity zapytań powolnych są także stosowane dla parametrów z podanymi wieloma wartościami.",
+ "api-help-open-in-apisandbox": "<small>[otwórz w brudnopisie]</small>",
+ "apierror-articleexists": "Artykuł, który próbowałeś utworzyć, już został utworzony.",
+ "apierror-baddiff": "Różnicy wersji nie można odtworzyć. Jedna lub obie wersje nie istnieją, lub nie masz uprawnień do ich wyświetlenia.",
+ "apierror-badgenerator-unknown": "Nieznany <kbd>generator=$1</kbd>.",
+ "apierror-badip": "Parametr IP nie jest prawidłowy.",
+ "apierror-badparameter": "Nieprawidłowa wartość parametru <var>$1</var>.",
+ "apierror-badquery": "Nieprawidłowe zapytanie.",
+ "apierror-badtoken": "Nieprawidłowy token CSRF.",
+ "apierror-blockedfrommail": "Została Ci zablokowana możliwość wysyłania e-maili.",
+ "apierror-blocked": "Została Ci zablokowana możliwość edycji.",
+ "apierror-botsnotsupported": "Interfejs nie jest obsługiwany dla botów.",
+ "apierror-cannotviewtitle": "Nie masz uprawnień do oglądania $1.",
+ "apierror-cantblock": "Nie masz uprawnień do blokowania użytkowników.",
+ "apierror-cantimport-upload": "Nie masz uprawnień do importowania przesłanych stron.",
+ "apierror-cantimport": "Nie masz uprawnień do importowania stron.",
+ "apierror-cantsend": "Nie jesteś zalogowany, nie masz potwierdzonego adresu e-mail, albo nie masz prawa wysyłać e-maili do innych użytkowników, więc nie możesz wysłać wiadomości e-mail.",
+ "apierror-cantundelete": "Nie można przywrócić: dana wersja nie istnieje albo została już przywrócona.",
+ "apierror-databaseerror": "[$1] Błąd zapytania do bazy danych.",
+ "apierror-exceptioncaught": "[$1] Stwierdzono wyjątek: $2",
+ "apierror-filedoesnotexist": "Plik nie istnieje.",
+ "apierror-import-unknownerror": "Nieznany błąd podczas importowania: $1.",
+ "apierror-integeroutofrange-abovebotmax": "Wartość <var>$1</var> dla botów i administratorów nie może przekraczać $2 (ustawiono $3).",
+ "apierror-integeroutofrange-abovemax": "Wartość <var>$1</var> dla użytkowników nie może przekraczać $2 (ustawiono $3).",
+ "apierror-integeroutofrange-belowminimum": "Wartość <var>$1</var> nie może być mniejsza niż $2 (ustawiono $3).",
+ "apierror-invalidcategory": "Wprowadzona nazwa kategorii jest nieprawidłowa.",
+ "apierror-invalidlang": "Nieprawidłowy kod języka dla parametru <var>$1</var>.",
+ "apierror-invalidoldimage": "Parametr <var>oldimage</var> ma nieprawidłowy format.",
+ "apierror-invalidparammix": "{{PLURAL:$2|Parametry}} $1 nie mogą być używane razem.",
+ "apierror-invalidtitle": "Zły tytuł „$1”.",
+ "apierror-invalidurlparam": "Nieprawidłowa wartość <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Niepoprawna nazwa użytkownika „$1”.",
+ "apierror-invaliduserid": "Identyfikator użytkownika <var>$1</var> jest nieprawidłowy.",
+ "apierror-maxlag-generic": "Oczekiwania na serwer bazy danych: opóźnienie $1 {{PLURAL:$1|sekunda|sekundy|sekund}}.",
+ "apierror-missingparam": "Parametr <var>$1</var> musi być podany.",
+ "apierror-missingtitle": "Wybrana przez ciebie strona nie istnieje.",
+ "apierror-missingtitle-byname": "Strona $1 nie istnieje.",
+ "apierror-moduledisabled": "Moduł <kbd>$1</kbd> został wyłączony.",
+ "apierror-mustbeloggedin-generic": "Musisz być zalogowany.",
+ "apierror-mustbeloggedin": "Musisz się zalogować, aby mieć możliwość $1.",
+ "apierror-nodeleteablefile": "Nie ma takiej starej wersji pliku.",
+ "apierror-noedit-anon": "Niezarejestrowani użytkownicy nie mogą edytować stron.",
+ "apierror-noedit": "Nie masz uprawnień do edytowania stron.",
+ "apierror-noimageredirect-anon": "Anonimowi użytkownicy nie mogą tworzyć przekierowań plików.",
+ "apierror-noimageredirect": "Nie masz uprawnień do tworzenia przekierowań plików.",
+ "apierror-nosuchpageid": "Nie ma strony z identyfikatorem $1.",
+ "apierror-nosuchrevid": "Nie ma wersji z identyfikatorem $1.",
+ "apierror-nosuchsection": "Nie ma sekcji $1.",
+ "apierror-permissiondenied": "Nie masz uprawnień do $1.",
+ "apierror-permissiondenied-generic": "Brak dostępu.",
+ "apierror-permissiondenied-unblock": "Nie masz uprawnień do odblokowania użytkowników.",
+ "apierror-protect-invalidaction": "Nieprawidłowy rodzaj zabezpieczenia „$1”.",
+ "apierror-protect-invalidlevel": "Nieprawidłowy poziom zabezpieczeń „$1”.",
+ "apierror-readonly": "Wiki jest teraz w trybie tylko do odczytu.",
+ "apierror-revwrongpage": "r$1 nie jest wersją strony $2.",
+ "apierror-sectionsnotsupported-what": "Sekcje nie są obsługiwane przez $1.",
+ "apierror-specialpage-cantexecute": "Nie masz uprawnień, aby zobaczyć wyniki tej strony specjalnej.",
+ "apierror-stashwrongowner": "Nieprawidłowy właściciel: $1",
+ "apierror-timeout": "Serwer nie odpowiedział w spodziewanym czasie.",
+ "apierror-unknownerror-nocode": "Nieznany błąd.",
+ "apierror-unknownerror": "Nieznany błąd: „$1”.",
+ "apierror-unknownformat": "Nierozpoznany format „$1”.",
+ "apierror-unrecognizedvalue": "Nierozpoznana wartość parametru <var>$1</var>: $2.",
+ "apiwarn-invalidcategory": "„$1” nie jest kategorią.",
+ "apiwarn-invalidtitle": "„$1” nie jest poprawnym tytułem.",
+ "apiwarn-notfile": "„$1” nie jest plikiem.",
+ "apiwarn-toomanyvalues": "Podano zbyt wiele wartości dla parametru <var>$1</var>. Ograniczenie do $2.",
+ "apiwarn-validationfailed": "Błąd walidacji dla <kbd>$1</kbd>: $2",
+ "api-feed-error-title": "Błąd ($1)",
+ "api-exception-trace": "$1 w $2($3)\n$4",
+ "api-credits-header": "Twórcy",
+ "api-credits": "Deweloperzy API:\n* Roan Kattouw (główny programista wrzesień 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (twórca, główny programista wrzesień 2006–wrzesień 2007)\n* Brad Jorsch (główny programista 2013–obecnie)\n\nProsimy wysyłać komentarze, sugestie i pytania do mediawiki-api@lists.wikimedia.org\nlub zgłoś błąd na https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/ps.json b/www/wiki/includes/api/i18n/ps.json
new file mode 100644
index 00000000..d3d3b7aa
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ps.json
@@ -0,0 +1,73 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ahmed-Najib-Biabani-Ibrahimkhel",
+ "Macofe"
+ ]
+ },
+ "apihelp-main-param-action": "کومه کړنه ترسره کړم.",
+ "apihelp-block-summary": "په يو کارن بنديز لگول.",
+ "apihelp-block-param-user": "کارن-نوم، IP پته، يا IP سيمې باندې بنديز لگول.",
+ "apihelp-block-param-reason": "د بنديز سبب.",
+ "apihelp-block-param-nocreate": "د گڼون جوړولو مخ نيول.",
+ "apihelp-createaccount-param-name": "کارن-نوم.",
+ "apihelp-delete-summary": "يو مخ ړنگول.",
+ "apihelp-delete-example-simple": "<kbd>لومړی مخ</kbd> ړنگول.",
+ "apihelp-edit-summary": "مخونه جوړول او سمول.",
+ "apihelp-edit-param-sectiontitle": "د يوې نوې برخې سرليک.",
+ "apihelp-edit-param-text": "مخ مېنځپانگه.",
+ "apihelp-edit-param-minor": "وړوکی سمون.",
+ "apihelp-edit-param-bot": "دا سمون د روباټ په توگه په نښه کول.",
+ "apihelp-edit-example-edit": "يو مخ سمول.",
+ "apihelp-emailuser-summary": "کارن ته برېښليک لېږل.",
+ "apihelp-emailuser-param-target": "هغه کارن چې برېښليک ورلېږې.",
+ "apihelp-emailuser-param-subject": "د سکالو سرليک.",
+ "apihelp-emailuser-param-text": "د برېښليک جوسه.",
+ "apihelp-emailuser-param-ccme": "د دې برېښليک يوه لمېسه ماته هم راولېږه.",
+ "apihelp-expandtemplates-param-title": "د مخ سرليک.",
+ "apihelp-feedrecentchanges-param-hideminor": "وړوکي بدلونونه پټول.",
+ "apihelp-feedrecentchanges-param-hidebots": "د روباټونو لخوا ترسره شوي بدلونونه پټول.",
+ "apihelp-feedrecentchanges-param-hideanons": "د ورکنومو کارنانو لخوا ترسره شوي بدلونونه پټول.",
+ "apihelp-feedrecentchanges-param-hideliu": "د ثبت شويو کارنانو لخوا ترسره شوي بدلونونه پټول.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "څارل شوي بدلونونه پټول.",
+ "apihelp-feedrecentchanges-param-hidemyself": "د اوسني کارن لخوا ترسره شوي بدلونونه پټول.",
+ "apihelp-feedrecentchanges-param-tagfilter": "د نښلن له مخې چاڼول.",
+ "apihelp-feedrecentchanges-example-simple": "وروستي بدلونونه ښکاره کول.",
+ "apihelp-login-param-name": "کارن نوم.",
+ "apihelp-login-param-password": "پټنوم.",
+ "apihelp-login-param-domain": "شپول (اختياري).",
+ "apihelp-login-example-login": "ننوتل.",
+ "apihelp-move-summary": "يو مخ لېږدول.",
+ "apihelp-protect-example-protect": "يو مخ ژغورل.",
+ "apihelp-query+allpages-param-filterredir": "کوم مخونه چې لړليک کې راشي.",
+ "apihelp-query+search-example-simple": "د <kbd>meaning</kbd> پلټل.",
+ "apihelp-query+search-example-text": "د <kbd>مانا</kbd> لپاره متنونه پلټل.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "د يو مخ سرليک ورگډوي.",
+ "apihelp-query+watchlist-paramvalue-type-new": "مخ جوړونې.",
+ "apihelp-stashedit-param-sectiontitle": "د يوې نوې برخې سرليک.",
+ "apihelp-stashedit-param-summary": "د بدلون لنډيز.",
+ "apihelp-tag-param-reason": "د بدلون سبب.",
+ "apihelp-unblock-param-reason": "د بنديز ليرې کولو سبب.",
+ "apihelp-undelete-param-reason": "د بيازېرملو سبب.",
+ "apihelp-upload-param-watch": "مخ کتل.",
+ "apihelp-upload-param-file": "د دوتنې مېنځپانگه.",
+ "apihelp-userrights-param-user": "کارن نوم.",
+ "apihelp-userrights-param-userid": "کارن پېژند.",
+ "apihelp-userrights-param-reason": "د بدلون سبب.",
+ "api-format-title": "د مېډياويکي API پايله",
+ "api-help-title": "د مېډياويکي API لارښود",
+ "api-help-main-header": "آر ماډيول",
+ "api-help-source": "سرچينه: $1",
+ "api-help-source-unknown": "سرچينه: <span class=\"apihelp-unknown\">ناجوت</span>",
+ "api-help-license": "منښتليک: [[$1|$2]]",
+ "api-help-license-noname": "منښتليک: [[$1|تړنه وڅارئ]]",
+ "api-help-license-unknown": "منښتليک: <span class=\"apihelp-unknown\">ناجوت</span>",
+ "api-help-parameters": "{{PLURAL:$1|پاراميټر|پاراميټرونه}}:",
+ "api-help-param-required": "دې پاراميټر ته اړتيا ده.",
+ "api-help-datatypes-header": "اومتوگ ډولونه",
+ "api-help-param-default": "تلواليز: $1",
+ "api-help-param-default-empty": "تلواليز: <span class=\"apihelp-empty\">(تش)</span>",
+ "api-help-examples": "{{PLURAL:$1|بېلگه|بېلگې}}:",
+ "api-help-permissions": "{{PLURAL:$1|پرېښه|پرېښې}}:",
+ "api-credits-header": "کرېډټونه"
+}
diff --git a/www/wiki/includes/api/i18n/pt-br.json b/www/wiki/includes/api/i18n/pt-br.json
new file mode 100644
index 00000000..68d51973
--- /dev/null
+++ b/www/wiki/includes/api/i18n/pt-br.json
@@ -0,0 +1,1751 @@
+{
+ "@metadata": {
+ "authors": [
+ "Fasouzafreitas",
+ "Dianakc",
+ "Cainamarques",
+ "Rhcastilhos",
+ "Macofe",
+ "Almondega",
+ "Raphaelras",
+ "Caçador de Palavras",
+ "LucyDiniz",
+ "Eduardo Addad de Oliveira",
+ "Warley Felipe C.",
+ "TheEduGobi",
+ "Felipe L. Ewald"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentação]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista de discussão]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Anúncios da API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & solicitações]\n</div>\n<strong>Status:</strong> Todos os recursos exibidos nesta página devem estar funcionando, mas a API ainda está em desenvolvimento ativo e pode mudar a qualquer momento. Inscrever-se na [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ lista de discussão mediawiki-api-announce] para aviso de atualizações.\n\n<strong>Requisições incorretas:</strong> Quando requisições erradas são enviadas para a API, um cabeçalho HTTP será enviado com a chave \"MediaWiki-API-Error\" e então o valor do cabeçalho e o código de erro enviados de volta serão definidos para o mesmo valor. Para mais informações, veja [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Erros e avisos]].\n\n<strong>Testando:</strong> Para facilitar o teste das requisições da API, consulte [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Qual ação executar.",
+ "apihelp-main-param-format": "O formato da saída.",
+ "apihelp-main-param-maxlag": "O atraso máximo pode ser usado quando o MediaWiki está instalado em um cluster replicado no banco de dados. Para salvar as ações que causam mais atraso na replicação do site, esse parâmetro pode fazer o cliente aguardar até que o atraso da replicação seja menor do que o valor especificado. Em caso de atraso excessivo, o código de erro <samp>maxlag</samp> é retornado com uma mensagem como <samp>Waiting for $host: $lag seconds lagged</samp>.<br />Veja [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Maxlag parameter]] para mais informações.",
+ "apihelp-main-param-smaxage": "Define o cabeçalho HTTP de controle de cache <code>s-maxage</code> para esta quantidade de segundos. Erros não são armazenados em cache.",
+ "apihelp-main-param-maxage": "Define o cabeçalho HTTP de controle de cache <code>max-age</code> para esta quantidade de segundos. Erros não são armazenados em cache.",
+ "apihelp-main-param-assert": "Verifique se o usuário está logado se configurado para <kbd>user</kbd> ou tem o direito do usuário do bot se <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Verificar que o usuário atual é o utilizador nomeado.",
+ "apihelp-main-param-requestid": "Qualquer valor dado aqui será incluído na resposta. Pode ser usado para distinguir requisições.",
+ "apihelp-main-param-servedby": "Inclua o nome de host que atendeu a solicitação nos resultados.",
+ "apihelp-main-param-curtimestamp": "Inclui o timestamp atual no resultado.",
+ "apihelp-main-param-responselanginfo": "Inclua os idiomas usados para <var>uselang</var> e <var>errorlang</var> no resultado.",
+ "apihelp-main-param-origin": "Ao acessar a API usando uma solicitação AJAX por domínio cruzado (CORS), defina isto como o domínio de origem. Isto deve estar incluso em toda solicitação ''pre-flight'', sendo portanto parte do URI da solicitação (ao invés do corpo do POST).\n\nPara solicitações autenticadas, isto deve corresponder a uma das origens no cabeçalho <code>Origin</code>, para que seja algo como <kbd>https://pt.wikipedia.org</kbd> ou <kbd>https://meta.wikimedia.org</kbd>. Se este parâmetro não corresponder ao cabeçalho <code>Origin</code>, uma resposta 403 será retornada. Se este parâmetro corresponder ao cabeçalho <code>Origin</code> e a origem for permitida (''whitelisted''), os cabeçalhos <code>Access-Control-Allow-Origin</code> e <code>Access-Control-Allow-Credentials</code> serão definidos.\n\nPara solicitações não autenticadas, especifique o valor <kbd>*</kbd>. Isto fará com que o cabeçalho <code>Access-Control-Allow-Origin</code> seja definido, porém o <code>Access-Control-Allow-Credentials</code> será <code>false</code> e todos os dados específicos para usuários tornar-se-ão restritos.",
+ "apihelp-main-param-uselang": "Linguagem a ser usada para traduções de mensagens. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> com <kbd>siprop=languages</kbd> retorna uma lista de códigos de idioma ou especifique <kbd>user</kbd> para usar a preferência de idioma do usuário atual ou especifique <kbd>content</kbd> para usar o idioma de conteúdo desta wiki.",
+ "apihelp-main-param-errorformat": "Formato a ser usado aviso e saída de texto de erro.\n; Texto simples: Texto wiki com tags HTML removidas e entidades substituídas.\n; Wikitext: Unparsed wikitext. \n; html: HTML.\n; Bruto: chave e parâmetros da mensagem.\n; Nenhum: sem saída de texto, apenas os códigos de erro.\n; Bc: Formato usado antes do MediaWiki 1.29. <var>errorlang</var> e <var>errorsuselocal</var> são ignorados.",
+ "apihelp-main-param-errorlang": "Linguagem a utilizar para avisos e erros. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> com <kbd>siprop=languages</kbd> retorna uma lista de códigos de idioma ou especifique <kbd>content</kbd> para usar o idioma do conteúdo desta wiki ou especifique <kbd>uselang</kbd> para usar o mesmo valor que o parâmetro <var>uselang</var>.",
+ "apihelp-main-param-errorsuselocal": "Se for dado, os textos de erro usarão mensagens customizadas localmente a partir do espaço nominal {{ns: MediaWiki}}.",
+ "apihelp-block-summary": "Bloquear um usuário.",
+ "apihelp-block-param-user": "Nome de usuário, endereço IP ou faixa de IP para bloquear. Não pode ser usado junto com <var>$1userid</var>",
+ "apihelp-block-param-userid": "ID de usuário para bloquear. Não pode ser usado em conjunto com <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Tempo de expiração. Pode ser relativo (por exemplo <kbd>5 meses</kbd> ou <kbd>2 semanas</kbd>) ou absoluto (por exemplo <kbd>2014-09-18T12:34:56Z</kbd>). Se definido para <kbd>infinite</kbd>, <kbd>indefinite</kbd> ou <kbd>never</kbd>, o bloqueio nunca irá expirar.",
+ "apihelp-block-param-reason": "Razão do bloqueio.",
+ "apihelp-block-param-anononly": "Bloqueia apenas usuários anônimos (ou seja. desativa edições anônimas para este endereço IP).",
+ "apihelp-block-param-nocreate": "Prevenir a criação de conta.",
+ "apihelp-block-param-autoblock": "Bloquear automaticamente o endereço IP usado e quaisquer endereços IPs subsequentes que tentarem acessar a partir deles.",
+ "apihelp-block-param-noemail": "Impedir que o usuário envie e-mails através da wiki. (Requer o direito <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Oculta o nome do usuário do ''log'' de bloqueio. (Requer o direito <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Permitir que o usuário edite sua própria página de discussão (depende de <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Se o usuário já estiver bloqueado, sobrescrever o bloqueio existente.",
+ "apihelp-block-param-watchuser": "Vigiar as páginas de usuário e de discussão, do usuário ou do endereço IP.",
+ "apihelp-block-param-tags": "Alterar as tags para se inscrever na entrada no registro de bloqueio.",
+ "apihelp-block-example-ip-simple": "Bloquear endereço IP <kbd>192.0.2.5</kbd> por três dias com razão <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Bloquear usuário <kbd>Vandal</kbd> indefinidamente com razão <kbd>Vandalism</kbd> e o impedir de criar nova conta e de enviar e-mails.",
+ "apihelp-changeauthenticationdata-summary": "Alterar os dados de autenticação para o usuário atual.",
+ "apihelp-changeauthenticationdata-example-password": "Tenta alterar a senha do usuário atual para <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Verificar a validade de um token de <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Tipo de token que está sendo testado.",
+ "apihelp-checktoken-param-token": "Token para testar.",
+ "apihelp-checktoken-param-maxtokenage": "Idade máxima permitida do token, em segundos.",
+ "apihelp-checktoken-example-simple": "Testa a validade de um token <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Limpa a etiqueta <code>hasmsg</code> do usuário atual.",
+ "apihelp-clearhasmsg-example-1": "Limpa a etiqueta <code>hasmsg</code> do usuário atual.",
+ "apihelp-clientlogin-summary": "Faça o login no wiki usando o fluxo interativo.",
+ "apihelp-clientlogin-example-login": "Comeca o processo de logar na wiki como usuário <kbd>Exemple</kbd> com a senha <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continuar efetuando login após uma resposta <samp>UI</samp> para autenticação de dois fatores, fornecendo um <var>OATHToken</var> de <kbd>987654</ kbd>.",
+ "apihelp-compare-summary": "Obter a diferença entre duas páginas.",
+ "apihelp-compare-extended-description": "Um número de revisão, um título de página, um ID de página, um texto ou uma referência relativa para \"de\" e \"para\" devem ser fornecidos.",
+ "apihelp-compare-param-fromtitle": "Primeiro título para comparar.",
+ "apihelp-compare-param-fromid": "Primeiro ID de página para comparar.",
+ "apihelp-compare-param-fromrev": "Primeira revisão para comparar.",
+ "apihelp-compare-param-fromtext": "Use este texto em vez do conteúdo da revisão especificada por <var>fromtitle</var>, <var>fromid</var> ou <var>fromrev</var>.",
+ "apihelp-compare-param-frompst": "Faz uma transformação pré-salvar em <var>fromtext</var>.",
+ "apihelp-compare-param-fromcontentmodel": "Modelo de conteúdo de <var>fromtext</var>. Se não for fornecido, será adivinhado com base nos outros parâmetros.",
+ "apihelp-compare-param-fromcontentformat": "Formato de serialização de conteúdo de <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "Segundo título para comparar.",
+ "apihelp-compare-param-toid": "Segundo ID de página para comparar.",
+ "apihelp-compare-param-torev": "Segunda revisão para comparar.",
+ "apihelp-compare-param-torelative": "Use uma revisão relativa à revisão determinada de <var>fromtitle</var>, <var>fromid</var> ou <var>fromrev</var>. Todas as outras opções 'to' serão ignoradas.",
+ "apihelp-compare-param-totext": "Use este texto em vez do conteúdo da revisão especificada por <var>totitle</var>, <var>toid</var> ou <var>torev</var>.",
+ "apihelp-compare-param-topst": "Faz uma transformação pré-salvar em <var>totext</var>.",
+ "apihelp-compare-param-tocontentmodel": "Modelo de conteúdo de <var>totext</var>. Se não for fornecido, será adivinhado com base nos outros parâmetros.",
+ "apihelp-compare-param-tocontentformat": "Formato de serialização de conteúdo de <var>totext</var>.",
+ "apihelp-compare-param-prop": "Quais peças de informação incluir.",
+ "apihelp-compare-paramvalue-prop-diff": "O dif do HTML.",
+ "apihelp-compare-paramvalue-prop-diffsize": "O tamanho do diff HTML, em bytes.",
+ "apihelp-compare-paramvalue-prop-rel": "Os IDs de revisão da revisão anteriores a 'from' e depois 'to', se houver.",
+ "apihelp-compare-paramvalue-prop-ids": "Os Ids da página e de revisão das revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-title": "O título das páginas 'from' e 'to' das revisões.",
+ "apihelp-compare-paramvalue-prop-user": "O nome de usuário e o ID das revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-comment": "O comentário das revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "O comentário analisado sobre as revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-size": "O tamanho das revisões 'from' e 'to'.",
+ "apihelp-compare-example-1": "Criar um diff entre a revisão 1 e 2.",
+ "apihelp-createaccount-summary": "Criar uma nova conta de usuário.",
+ "apihelp-createaccount-param-preservestate": "Se <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> retornar true para <samp>hasprimarypreservedstate</samp>, pedidos marcados como <samp>hasprimarypreservedstate</samp> devem ser omitidos. Se retornou um valor não vazio para <samp>preservedusername</samp>, esse nome de usuário deve ser usado pelo parâmetro <var>username</var>.",
+ "apihelp-createaccount-example-create": "Inicie o processo de criação do usuário <kbd>Example</kbd> com a senha <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Nome de usuário.",
+ "apihelp-createaccount-param-password": "Senha (ignorada se <var>$1mailpassword</var> está definida).",
+ "apihelp-createaccount-param-domain": "Domínio para autenticação externa (opcional).",
+ "apihelp-createaccount-param-token": "Token de criação de conta obtido no primeiro pedido.",
+ "apihelp-createaccount-param-email": "Endereço de e-mail para o usuário (opcional).",
+ "apihelp-createaccount-param-realname": "Nome real do usuário (opcional).",
+ "apihelp-createaccount-param-mailpassword": "Se configurado para qualquer valor, uma senha aleatória será enviada por e-mail ao usuário.",
+ "apihelp-createaccount-param-reason": "Razão opcional para criar a conta a ser colocada nos logs.",
+ "apihelp-createaccount-param-language": "Código de idioma para definir como padrão para o usuário (opcional, padrão para o idioma do conteúdo).",
+ "apihelp-createaccount-example-pass": "Criar usuário <kbd>testuser</kbd> com senha <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Criar usuário <kbd>testmailuser</kbd> e enviar um e-mail com uma senha gerada aleatoriamente.",
+ "apihelp-cspreport-summary": "Usado por navegadores para denunciar violações da Política de Segurança de Conteúdo. Este módulo nunca deve ser usado, exceto quando usado automaticamente por um navegador web compatível com CSP.",
+ "apihelp-cspreport-param-reportonly": "Marque como sendo um relatório de uma política de monitoramento, não uma política forçada",
+ "apihelp-cspreport-param-source": "O que gerou o cabeçalho CSP que desencadeou este relatório",
+ "apihelp-delete-summary": "Excluir uma página.",
+ "apihelp-delete-param-title": "Título da página para excluir. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID da página para excluir. Não pode ser usada em conjunto com <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Razão para a exclusão. Se não for definido, um motivo gerado automaticamente será usado.",
+ "apihelp-delete-param-tags": "Alterar as tags para se inscrever na entrada no registro de exclusão.",
+ "apihelp-delete-param-watch": "Adiciona a página para a lista de páginas vigiadas do usuário atual.",
+ "apihelp-delete-param-watchlist": "Adicione ou remova incondicionalmente a página da lista de páginas vigiadas do usuário atual, use preferências ou não mude a vigilância.",
+ "apihelp-delete-param-unwatch": "Remove a página da lista de páginas vigiadas do usuário atual.",
+ "apihelp-delete-param-oldimage": "O nome da imagem antiga para excluir, conforme fornecido por [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Excluir <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Excluir <kbd>Main Page</kbd> com o motivo <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Este módulo foi desativado.",
+ "apihelp-edit-summary": "Criar e editar páginas.",
+ "apihelp-edit-param-title": "Título da página para editar. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID da página para editar. Não pode ser usada em conjunto com <var>$1title</var>.",
+ "apihelp-edit-param-section": "Número da seção. <kbd>0</kbd> para a seção superior, <kbd>new</kbd> para uma nova seção.",
+ "apihelp-edit-param-sectiontitle": "O título para uma nova seção.",
+ "apihelp-edit-param-text": "Conteúdo da página.",
+ "apihelp-edit-param-summary": "Edit o resumo. Também o título da seção quando $1section=new e $1sectiontitle não está definido.",
+ "apihelp-edit-param-tags": "Alterar as tags para aplicar à revisão.",
+ "apihelp-edit-param-minor": "Edição menor.",
+ "apihelp-edit-param-notminor": "Edição não-menor.",
+ "apihelp-edit-param-bot": "Marcar esta edição como uma edição de bot.",
+ "apihelp-edit-param-basetimestamp": "Timestamp da revisão base, usada para detectar conflitos de edição. Pode ser obtido através de [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Timestamp quando o processo de edição começou, usado para detectar conflitos de edição. Um valor apropriado pode ser obtido usando <var>[[Special:ApiHelp/main|curtimestamp]]</var> ao iniciar o processo de edição (por exemplo, ao carregar o conteúdo da página a editar).",
+ "apihelp-edit-param-recreate": "Substitua quaisquer erros sobre a página que foram eliminados enquanto isso.",
+ "apihelp-edit-param-createonly": "Não editar a página se ela já existir.",
+ "apihelp-edit-param-nocreate": "Mostra um erro se a página não existir.",
+ "apihelp-edit-param-watch": "Adiciona a página para a lista de páginas vigiadas do usuário atual.",
+ "apihelp-edit-param-unwatch": "Remove a página da lista de páginas vigiadas do usuário atual.",
+ "apihelp-edit-param-watchlist": "Adicione ou remova incondicionalmente a página da lista de páginas vigiadas do usuário atual, use preferências ou não mude a vigilância.",
+ "apihelp-edit-param-md5": "O hash MD5 do parâmetro $1text ou os parâmetros $1prependtext e $1appendtext concatenados. Se configurado, a edição não será feita a menos que o hash esteja correto.",
+ "apihelp-edit-param-prependtext": "Adiciona este texto ao início da página. Substitui $1text.",
+ "apihelp-edit-param-appendtext": "Adiciona este texto ao fim da página. Substitui $1text.\n\nUse $1section=new para anexar uma nova seção, em vez deste parâmetro.",
+ "apihelp-edit-param-undo": "Desfazer esta revisão. Substitui $1text, $1prependtext e $1appendtext.",
+ "apihelp-edit-param-undoafter": "Desfazer todas as revisões de $1undo para este. Se não estiver configurado, desfaz uma revisão.",
+ "apihelp-edit-param-redirect": "Resolve redirecionamento automaticamente.",
+ "apihelp-edit-param-contentformat": "Formato de serialização de conteúdo usado para o texto de entrada.",
+ "apihelp-edit-param-contentmodel": "Modelo de conteúdo do novo conteúdo.",
+ "apihelp-edit-param-token": "O token sempre deve ser enviado como o último parâmetro, ou pelo menos após o parâmetro $1text.",
+ "apihelp-edit-example-edit": "Edita uma página.",
+ "apihelp-edit-example-prepend": "Antecende <kbd>_&#95;NOTOC_&#95;</kbd> a página.",
+ "apihelp-edit-example-undo": "Desfazer as revisões 13579 até 13585 com sumário automático.",
+ "apihelp-emailuser-summary": "Envia e-mail para o usuário.",
+ "apihelp-emailuser-param-target": "Usuário a se enviar o e-mail.",
+ "apihelp-emailuser-param-subject": "Cabeçalho do assunto.",
+ "apihelp-emailuser-param-text": "Corpo do e-mail.",
+ "apihelp-emailuser-param-ccme": "Envie uma cópia deste e-mail para mim.",
+ "apihelp-emailuser-example-email": "Enviar um e-mail ao usuário <kbd>WikiSysop</kbd> com o texto <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Expande todas a predefinições em texto wiki.",
+ "apihelp-expandtemplates-param-title": "Título da página.",
+ "apihelp-expandtemplates-param-text": "Texto wiki para converter.",
+ "apihelp-expandtemplates-param-revid": "ID da revisão, para <code><nowiki>{{REVISIONID}}</nowiki></code> e variáveis semelhantes.",
+ "apihelp-expandtemplates-param-prop": "Quais peças de informação obter.\n\nNote que se nenhum valor for selecionado, o resultado conterá o texto wiki, mas o resultado será em um formato obsoleto.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "O texto wiki expandido.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Quaisquer categorias presentes na entrada que não estão representadas na saída wikitext.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Propriedades da página definidas por palavras mágicas expandidas no texto wiki.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Se a saída é volátil e não deve ser reutilizada em outro lugar dentro da página.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "O tempo máximo após o qual os caches do resultado devem ser invalidados.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Quaisquer módulos ResourceLoader que as funções do analisador solicitaram foram adicionados à saída. Contudo, <kbd>jsconfigvars</kbd> ou <kbd>encodedjsconfigvars</kbd> devem ser solicitados em conjunto com <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Fornece as variáveis de configuração JavaScript específicas da página.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Fornece as variáveis de configuração JavaScript específicas da página como uma string JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "A árvore de analise XML da entrada.",
+ "apihelp-expandtemplates-param-includecomments": "Se devem ser incluídos comentários HTML na saída.",
+ "apihelp-expandtemplates-param-generatexml": "Gerar XML parse tree (substituído por $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Expandir o texto wiki <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Retorna o feed de contribuições de um usuário.",
+ "apihelp-feedcontributions-param-feedformat": "O formato do feed.",
+ "apihelp-feedcontributions-param-user": "De quais usuários receber as contribuições.",
+ "apihelp-feedcontributions-param-namespace": "A partir de qual espaço nominal filtrar contribuições.",
+ "apihelp-feedcontributions-param-year": "Do ano (inclusive anteriores).",
+ "apihelp-feedcontributions-param-month": "Do mês (inclusive anteriores).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrar contribuições que têm essas tags.",
+ "apihelp-feedcontributions-param-deletedonly": "Mostrar apenas contribuições excluídas.",
+ "apihelp-feedcontributions-param-toponly": "Mostrar somente as edições que sejam a última revisão.",
+ "apihelp-feedcontributions-param-newonly": "Mostrar somente as edições que são criação de páginas.",
+ "apihelp-feedcontributions-param-hideminor": "Ocultar edições menores.",
+ "apihelp-feedcontributions-param-showsizediff": "Mostrar a diferença de tamanho entre as revisões.",
+ "apihelp-feedcontributions-example-simple": "Retornar contribuições do usuário <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Retorna um ''feed'' de mudanças recentes.",
+ "apihelp-feedrecentchanges-param-feedformat": "O formato do feed.",
+ "apihelp-feedrecentchanges-param-namespace": "Espaço nominal a partir do qual limitar resultados.",
+ "apihelp-feedrecentchanges-param-invert": "Todos os espaços nominais, exceto o selecionado.",
+ "apihelp-feedrecentchanges-param-associated": "Inclua espaço nominal associado (discussão ou principal).",
+ "apihelp-feedrecentchanges-param-days": "Dias para limitar os resultados.",
+ "apihelp-feedrecentchanges-param-limit": "Número máximo de resultados.",
+ "apihelp-feedrecentchanges-param-from": "Mostra modificações desde então.",
+ "apihelp-feedrecentchanges-param-hideminor": "Ocultar modificações menores.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ocultar modificações feitas por bots.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ocultar alterações feitas por usuários anônimos.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ocultar alterações feitas por usuários registrados.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ocultar mudanças patrulhadas.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ocultar alterações feitas pelo usuário atual.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Ocultar alterações de associação de categoria.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrar por tag.",
+ "apihelp-feedrecentchanges-param-target": "Mostrar apenas as alterações nas páginas vinculadas por esta página.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Mostra as alterações nas páginas vigiadas à página selecionada.",
+ "apihelp-feedrecentchanges-param-categories": "Mostre apenas as alterações em páginas em todas essas categorias.",
+ "apihelp-feedrecentchanges-param-categories_any": "Mostre apenas as alterações em páginas em qualquer uma das categorias.",
+ "apihelp-feedrecentchanges-example-simple": "Mostrar as mudanças recentes.",
+ "apihelp-feedrecentchanges-example-30days": "Mostrar as mudanças recentes por 30 dias.",
+ "apihelp-feedwatchlist-summary": "Retornar um feed da lista de páginas vigiadas.",
+ "apihelp-feedwatchlist-param-feedformat": "O formato do feed.",
+ "apihelp-feedwatchlist-param-hours": "Lista páginas modificadas dentro dessa quantia de horas a partir de agora.",
+ "apihelp-feedwatchlist-param-linktosections": "Cria link diretamente para seções alteradas, se possível.",
+ "apihelp-feedwatchlist-example-default": "Mostra o feed de páginas vigiadas.",
+ "apihelp-feedwatchlist-example-all6hrs": "Mostre todas as alterações nas páginas vigiadas nas últimas 6 horas.",
+ "apihelp-filerevert-summary": "Reverte um arquivo para uma versão antiga.",
+ "apihelp-filerevert-param-filename": "Nome do arquivo de destino, sem o prefixo File:.",
+ "apihelp-filerevert-param-comment": "Enviar comentário.",
+ "apihelp-filerevert-param-archivename": "Nome do arquivo da revisão para qual reverter.",
+ "apihelp-filerevert-example-revert": "Reverter <kbd>Wiki.png</kbd> para a versão de <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Mostra a ajuda para os módulos especificados.",
+ "apihelp-help-param-modules": "Módulos para exibir ajuda para (valores do parâmetro <var>action</var> e <var>format</var> ou <kbd>main</kbd>). Pode-se especificar submódulos com um <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Inclui a ajuda para submódulos do módulo nomeado.",
+ "apihelp-help-param-recursivesubmodules": "Inclui a ajuda para submódulos de forma recursiva.",
+ "apihelp-help-param-helpformat": "Formato da saída da ajuda.",
+ "apihelp-help-param-wrap": "Encapsula a saída em uma estrutura de resposta da API padrão.",
+ "apihelp-help-param-toc": "Inclui uma tabela de conteúdos na saída HTML.",
+ "apihelp-help-example-main": "Ajuda para o módulo principal.",
+ "apihelp-help-example-submodules": "Ajuda para <kbd>action=query</kbd> e todos os seus submódulos.",
+ "apihelp-help-example-recursive": "Toda a ajuda em uma página.",
+ "apihelp-help-example-help": "Ajuda para o próprio módulo de ajuda.",
+ "apihelp-help-example-query": "Ajuda para dois submódulos de consulta.",
+ "apihelp-imagerotate-summary": "Gira uma ou mais imagens.",
+ "apihelp-imagerotate-param-rotation": "Graus para girar imagem no sentido horário.",
+ "apihelp-imagerotate-param-tags": "Tags para se inscrever na entrada no registro de upload.",
+ "apihelp-imagerotate-example-simple": "Girar <kbd>File:Example.png</kbd> em <kbd>90</kbd> graus.",
+ "apihelp-imagerotate-example-generator": "Girar todas as imagens em <kbd>Category:Flip</kbd> em <kbd>180</kbd> graus.",
+ "apihelp-import-summary": "Importar uma página de outra wiki ou de um arquivo XML.",
+ "apihelp-import-extended-description": "Observe que o POST HTTP deve ser feito como um upload de arquivos (ou seja, usar multipart/form-data) ao enviar um arquivo para o parâmetro <var>xml</var>.",
+ "apihelp-import-param-summary": "Resumo de importação do log de entrada.",
+ "apihelp-import-param-xml": "Enviar arquivo XML.",
+ "apihelp-import-param-interwikisource": "Para importações de interwiki: wiki para importar de.",
+ "apihelp-import-param-interwikipage": "Para importações de interwiki: página para importar.",
+ "apihelp-import-param-fullhistory": "Para importações de interwiki: importa o histórico completo, não apenas a versão atual.",
+ "apihelp-import-param-templates": "Para importações de interwiki: importa também todas as predefinições incluídas.",
+ "apihelp-import-param-namespace": "Importar para este espaço nominal. Não pode ser usado em conjunto com <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importar como subpágina para esta página. Não pode ser usada em conjunto com <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Alterar as tags para aplicar à entrada no registro de importação e à revisão nula nas páginas importadas.",
+ "apihelp-import-example-import": "Importar [[meta:Help:ParserFunctions]] para espaço nominal 100 com histórico completo.",
+ "apihelp-linkaccount-summary": "Vincule uma conta de um provedor de terceiros ao usuário atual.",
+ "apihelp-linkaccount-example-link": "Inicie o processo de vincular uma conta de <kbd>Example</kbd>.",
+ "apihelp-login-summary": "Faça login e obtenha cookies de autenticação.",
+ "apihelp-login-extended-description": "Esta ação só deve ser usada em combinação com[[Special:BotPasswords]]; O uso para login da conta principal está obsoleto e pode falhar sem aviso prévio. Para fazer login com segurança na conta principal, use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Esta ação está depreciada e pode falhar sem aviso prévio. Para efetuar login com segurança, use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "Nome de usuário.",
+ "apihelp-login-param-password": "Senha.",
+ "apihelp-login-param-domain": "Domínio (opcional).",
+ "apihelp-login-param-token": "Token de login obtido no primeiro pedido.",
+ "apihelp-login-example-gettoken": "Recupere um token de login.",
+ "apihelp-login-example-login": "Entrar.",
+ "apihelp-logout-summary": "Faça o logout e limpe os dados da sessão.",
+ "apihelp-logout-example-logout": "Finaliza a sessão do usuário atual.",
+ "apihelp-managetags-summary": "Execute tarefas de gerenciamento relacionadas às tags de alteração.",
+ "apihelp-managetags-param-operation": "Qual operação para executar:\n;create: Crie uma nova tag de mudança para uso manual.\n;delete: Remove uma tag de mudança do banco de dados, incluindo a remoção da tag de todas as revisões, entradas recentes de alterações e entradas de log em que é usada.\n;active: Ativar uma tag de alteração, permitindo aos usuários aplicá-la manualmente.\n; deactivate: Desative uma tag de alteração, impedindo que usuários a apliquem manualmente.",
+ "apihelp-managetags-param-tag": "Tag para criar, excluir, ativar ou desativar. Para a criação de tags, a tag não deve existir. Para exclusão de tags, a tag deve existir. Para a ativação da tag, a tag deve existir e não ser usada por uma extensão. Para a desativação da tag, a tag deve estar atualmente ativa e definida manualmente.",
+ "apihelp-managetags-param-reason": "Uma razão opcional para criar, excluir, ativar ou desativar a tag.",
+ "apihelp-managetags-param-ignorewarnings": "Se deseja ignorar quaisquer avisos emitidos durante a operação.",
+ "apihelp-managetags-param-tags": "Alterar as tags para se inscrever na entrada no registro de gerenciamento de tags.",
+ "apihelp-managetags-example-create": "Criar uma tag chamada <kbd>spam</ kbd> com o motivo <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Excluir a tag <kbd>vandlaism</kbd> com o motivo <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Ativar uma tag nomeada <kbd>spam</kbd> com a razão <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Desative uma tag chamada <kbd> spam </ kbd> com o motivo <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Fundir históricos das páginas.",
+ "apihelp-mergehistory-param-from": "Título da página a partir da qual o histórico será mesclado. Não pode ser usado em conjunto com <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "ID da página da qual o histórico será mesclado. Não pode ser usado em conjunto com <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Título da página ao qual o histórico será mesclado. Não pode ser usado em conjunto com <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "ID da página em que o histórico será mesclado. Não pode ser usado em conjunto com <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "Timestamp até as revisões que serão movidas do histórico da página de origem para o histórico da página de destino. Se omitido, todo o histórico da página de origem será incorporado na página de destino.",
+ "apihelp-mergehistory-param-reason": "Razão para a fusão de histórico.",
+ "apihelp-mergehistory-example-merge": "Junte todo o histórico de <kbd>Oldpage</kbd> em <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Junte as revisões da página de <kbd>Oldpage</kbd> datando até <kbd>2015-12-31T04:37:41Z</kbd> em <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Mover uma página.",
+ "apihelp-move-param-from": "Título da página para renomear. Não pode ser usado em conjunto com <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "ID da página a se renomear. Não pode ser usado em conjunto com <var>$1from</var>.",
+ "apihelp-move-param-to": "Título para o qual renomear a página.",
+ "apihelp-move-param-reason": "Motivo para a alteração do nome.",
+ "apihelp-move-param-movetalk": "Renomear a página de discussão, se existir.",
+ "apihelp-move-param-movesubpages": "Renomeia subpáginas, se aplicável.",
+ "apihelp-move-param-noredirect": "Não cria um redirecionamento.",
+ "apihelp-move-param-watch": "Adiciona a página e o redirecionamento para a lista de páginas vigiadas do usuário atual.",
+ "apihelp-move-param-unwatch": "Remove a página e o redirecionamento para a lista de paginas vigiadas do usuário atual.",
+ "apihelp-move-param-watchlist": "Adicione ou remova incondicionalmente a página da lista de páginas vigiadas do usuário atual, use preferências ou não mude a vigilância.",
+ "apihelp-move-param-ignorewarnings": "Ignorar quaisquer avisos.",
+ "apihelp-move-param-tags": "Alterar as tags para aplicar à entrada no log de movimento e à revisão nula na página de destino.",
+ "apihelp-move-example-move": "Mover <kbd>Badtitle</kbd> para <kbd>Goodtitle</kbd> sem deixar um redirecionamento.",
+ "apihelp-opensearch-summary": "Procure na wiki usando o protocolo OpenSearch.",
+ "apihelp-opensearch-param-search": "Pesquisar string.",
+ "apihelp-opensearch-param-limit": "Número máximo de resultados.",
+ "apihelp-opensearch-param-namespace": "Espaço nominal para pesquisar.",
+ "apihelp-opensearch-param-suggest": "Não fazer nada se <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> é false.",
+ "apihelp-opensearch-param-redirects": "Como lidar com os redirecionamentos:\n;return: Retornar o redirecionamento em si.\n;resolve: Retornar a página de destino. Pode retornar menos de $1 resultados.\nPor razões históricas, o padrão é \"return\" para $1format=json e \"resolve\" para outros formatos.",
+ "apihelp-opensearch-param-format": "O formato da saída.",
+ "apihelp-opensearch-param-warningsaserror": "Se os avisos forem gerados com <kbd>format=json</kbd>, devolva um erro de API em vez de ignorá-los.",
+ "apihelp-opensearch-example-te": "Encontra páginas começando com <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Alterar as preferências do usuário atual.",
+ "apihelp-options-extended-description": "Somente as opções que estão registradas no núcleo ou em uma das extensões instaladas, ou as opções com as chaves com prefixo com <code>userjs-</code> (que podem ser usadas pelos scripts do usuário) podem ser definidas.",
+ "apihelp-options-param-reset": "Redefinir preferências para os padrões do site.",
+ "apihelp-options-param-resetkinds": "Lista de tipos de opções para redefinir quando a opção <var>$1reset</var> está definida.",
+ "apihelp-options-param-change": "Lista de alterações, nome formatado = valor (por exemplo, skin=vector). Se nenhum valor for dado (nem mesmo um sinal de igual), por exemplo, optionname|otheroption|..., a opção será redefinida para seu valor padrão. Se algum valor passado contém o caractere de pipe (<kbd>|</kbd>), use o [[Special:ApiHelp/main#main/datatypes|separador de múltiplo valor alternativo]] para a operação correta.",
+ "apihelp-options-param-optionname": "O nome da opção que deve ser configurado para o valor dado por <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "O valor da opção especificada por <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Resetar todas as preferências.",
+ "apihelp-options-example-change": "Mudar preferências <kbd>skin</kbd> e <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Redefine todas as preferências, então define <kbd>skin</kbd> e <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Obter informações sobre módulos da API.",
+ "apihelp-paraminfo-param-modules": "Lista de nomes de módulos (valores do parâmetro <var>action</var> e <var>format</var> ou <kbd>main</kbd>). Pode-se especificar submódulos com um <kbd>+</kbd>, todos os submódulos com <kbd>+*</kbd> ou todos os submódulos recursivamente com <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Formato das strings de ajuda.",
+ "apihelp-paraminfo-param-querymodules": "Lista de nomes de módulos de consulta (valor de parâmetro <var>prop</var>, <var>meta</var> ou <var>list</var>). Use <kbd>$1modules=query+foo</kbd> em vez de <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Obter também informações sobre o módulo principal (de nível superior). Use <kbd>$1modules=main</kbd> em vez disso.",
+ "apihelp-paraminfo-param-pagesetmodule": "Obter informações sobre o módulo do conjunto de páginas (fornecendo titles= and friends) também.",
+ "apihelp-paraminfo-param-formatmodules": "Lista de nomes de módulos de formato (valor do parâmetro <var>format</var>). Use <var>$1modules</var> em vez disso.",
+ "apihelp-paraminfo-example-1": "Mostrar informações para <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> e <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "Mostrar informações para todos os submódulos de <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-summary": "Analisa o conteúdo e retorna a saída do analisador.",
+ "apihelp-parse-extended-description": "Veja os vários módulos de suporte de <kbd>[[Special:ApiHelp/query|action=query]]</kbd> para obter informações da versão atual de uma página.\n\nHá várias maneiras de especificar o texto para analisar:\n# Especifique uma página ou revisão, usando <var>$1page</var>, <var>$1pageid</var>, ou <var>$1oldid</var>.\n#Especifica o conteúdo explicitamente, Usando <var>$1text</var>, <var>$1title</var> e <var>$1contentmodel</var>.\n# Especifique apenas um resumo a analisar. <Var>$1prop</var> deve ter um valor vazio.",
+ "apihelp-parse-param-title": "Título da página ao qual o texto pertence. Se omitido, <var>$1contentmodel</var> deve ser especificado e [[API]] será usado como título.",
+ "apihelp-parse-param-text": "Texto para analisar. Use <var>$1title</var> ou <var>$1contentmodel</var> para controlar o modelo de conteúdo.",
+ "apihelp-parse-param-summary": "Sumário para analisar.",
+ "apihelp-parse-param-page": "Analisa o conteúdo desta página. Não pode ser usado em conjunto com <var>$1text</var> e <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Analisa o conteúdo desta página. Sobrepõe <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "Se<var>$1page</var> ou <var>$1pageid</var> é definido com um redirecionamento, resolva-o.",
+ "apihelp-parse-param-oldid": "Analise o conteúdo desta revisão. Substitui <var>$1page</var> e <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "Qual pedaço de informação obter:",
+ "apihelp-parse-paramvalue-prop-text": "Fornece o texto analisado do texto wiki.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Fornece os links de idiomas do texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-categories": "Fornece as categorias no texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Fornece a versão HTML das categorias.",
+ "apihelp-parse-paramvalue-prop-links": "Fornece os links internos do texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-templates": "Fornece a predefinição no texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-images": "Fornece as imagens no texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Fornece os links externos no texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-sections": "Fornece as seções no texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-revid": "Adiciona o ID da revisão da página analisada.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Adiciona o título do texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-headitems": "Fornece itens para colocar no <code>&lt;head&gt;</code> da página.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Fornece <code>&lt;head&gt;</code> analisado da página.",
+ "apihelp-parse-paramvalue-prop-modules": "Fornece os módulos do ResourceLoader usados na página. Para carregar, use <code>mw.loader.using()</code>. Contudo, <kbd>jsconfigvars</kbd> ou <kbd>encodedjsconfigvars</kbd> deve ser solicitado conjuntamente com <kbd>modules</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Fornece as variáveis de configuração JavaScript específicas da página. Para aplicar, use <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Fornece as variáveis de configuração JavaScript específicas da página como uma string JSON.",
+ "apihelp-parse-paramvalue-prop-indicators": "Fornece o HTML de indicadores de ''status'' de página utilizados na página.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Fornece links interwiki no texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Fornece o texto wiki original que foi analisado.",
+ "apihelp-parse-paramvalue-prop-properties": "Fornece várias propriedades definidas no texto wiki analisado.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Fornece o relatório limite de uma forma estruturada. Não informa dado, quando<var>$1disablelimitreport</var> está definido.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Retorna a versão HTML do relatório de limite. Não retorna dados quando <var>$1disablelimitreport</var> está definido.",
+ "apihelp-parse-paramvalue-prop-parsetree": "A árvore de análise XML do conteúdo da revisão (requer modelo de conteúdo <code>$1</code>)",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Fornece os avisos que ocorreram ao analisar o conteúdo.",
+ "apihelp-parse-param-wrapoutputclass": "Classe CSS usada para envolver a saída do analisador.",
+ "apihelp-parse-param-pst": "Faz uma transformação pré-salvar na entrada antes de analisá-la. Apenas válido quando usado com texto.",
+ "apihelp-parse-param-onlypst": "Faz uma transformação pré-salvar (PST) na entrada, mas não analisa. Retorna o mesmo texto wiki, depois que um PST foi aplicado. Apenas válido quando usado com <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "Inclui links de idiomas fornecidos por extensões (para uso com <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "Apenas analise o conteúdo deste número de seção.\n\nQuando <kbd>new</kbd>, analise <var>$1text</var> e <var>$1sectiontitle</var> como se adicionasse uma nova seção a página.\n\n<kbd>new</kbd> é permitido somente ao especificar <var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "Novo título de seção quando <var>section</var> é <kbd>new</kbd>.\n\nAo contrário da edição de páginas Isso não recai sobre <var>summary</var> quando omitido ou vazio.",
+ "apihelp-parse-param-disablelimitreport": "Omita o relatório de limite (\"Relatório de limite NewPP\") da saída do analisador.",
+ "apihelp-parse-param-disablepp": "Use <var>$1disablelimitreport</var> em vez.",
+ "apihelp-parse-param-disableeditsection": "Omita os links da seção de edição da saída do analisador.",
+ "apihelp-parse-param-disabletidy": "Não executa a limpeza HTML (por exemplo, tidy) na saída do analisador.",
+ "apihelp-parse-param-generatexml": "Gerar XML parse tree (requer modelo de conteúdo <code>$1</code>, substituído por <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "Analisar no mode de visualização.",
+ "apihelp-parse-param-sectionpreview": "Analise no modo de visualização de seção (também permite o modo de visualização).",
+ "apihelp-parse-param-disabletoc": "Omitir tabela de conteúdos na saída.",
+ "apihelp-parse-param-useskin": "Aplique a skin selecionada na saída do analisador. Pode afetar as seguintes propriedades: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>modules</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicators</kbd>.",
+ "apihelp-parse-param-contentformat": "Formato de serialização de conteúdo usado para o texto de entrada. Válido apenas quando usado com $1text.",
+ "apihelp-parse-param-contentmodel": "Modelo de conteúdo do texto de entrada. Se omitido, $1title deve ser especificado e o padrão será o modelo do título especificado. Válido apenas quando usado com $1text.",
+ "apihelp-parse-example-page": "Analisa uma página.",
+ "apihelp-parse-example-text": "Analisa texto wiki.",
+ "apihelp-parse-example-texttitle": "Analisa texto wiki, especificando o título da página.",
+ "apihelp-parse-example-summary": "Analisa uma sumário.",
+ "apihelp-patrol-summary": "Patrulha uma página ou revisão.",
+ "apihelp-patrol-param-rcid": "ID de Mudanças recentes para patrulhar.",
+ "apihelp-patrol-param-revid": "ID de revisão para patrulhar.",
+ "apihelp-patrol-param-tags": "Alterar as tags para se inscrever na entrada no registro de patrulha.",
+ "apihelp-patrol-example-rcid": "Patrulha uma modificação recente.",
+ "apihelp-patrol-example-revid": "Patrulha uma revisão.",
+ "apihelp-protect-summary": "Modifica o nível de proteção de uma página.",
+ "apihelp-protect-param-title": "Título da página para (des)proteger. Não pode ser usado em conjunto com $1pageid.",
+ "apihelp-protect-param-pageid": "ID da página a se (des)proteger. Não pode ser usado em conjunto com $1title.",
+ "apihelp-protect-param-protections": "Lista de níveis de proteção, formatados <kbd>action=level</kbd> (por exemplo, <kbd>edit=sysop</kbd>). Um nível com <kbd>all</kbd> significa que todos podem tomar a ação, ou seja, sem restrição.\n\n<strong> Nota:</strong> Qualquer ação não listada terá restrições removidas.",
+ "apihelp-protect-param-expiry": "Expiração de timestamps. Se apenas um timestamp for configurado, ele sera usado para todas as proteções. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> ou <kbd>never</kbd>, para uma protecção que nunca expirar.",
+ "apihelp-protect-param-reason": "Motivo para (des)proteger.",
+ "apihelp-protect-param-tags": "Alterar as tags para se inscrever na entrada no registro de proteção.",
+ "apihelp-protect-param-cascade": "Ativa a proteção em cascata (ou seja, proteja as predefinições transcluídas e imagens utilizados nesta página). Ignorado se nenhum dos níveis de proteção fornecidos suporte cascata.",
+ "apihelp-protect-param-watch": "Se configurado, adicione a página sendo (des)protegida para a lista de páginas vigiadas do usuário atual.",
+ "apihelp-protect-param-watchlist": "Adicione ou remova incondicionalmente a página da lista de páginas vigiadas do usuário atual, use preferências ou não mude a vigilância.",
+ "apihelp-protect-example-protect": "Protege uma página.",
+ "apihelp-protect-example-unprotect": "Desprotege uma página definindo restrições para <kbd>all</kbd> (isto é, todos são autorizados a tomar a ação).",
+ "apihelp-protect-example-unprotect2": "Desprotege uma página ao não definir restrições.",
+ "apihelp-purge-summary": "Limpe o cache para os títulos especificados.",
+ "apihelp-purge-param-forcelinkupdate": "Atualiza as tabelas de links.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Atualiza a tabela de links e atualiza as tabelas de links para qualquer página que usa essa página como uma predefinição.",
+ "apihelp-purge-example-simple": "Purga as páginas <kbd>Main Page</kbd> e <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "Purga as primeiras 10 páginas no espaço nominal principal.",
+ "apihelp-query-summary": "Obtenha dados de e sobre o MediaWiki.",
+ "apihelp-query-extended-description": "Todas as modificações de dados terão que usar a consulta para adquirir um token para evitar abusos de sites maliciosos.",
+ "apihelp-query-param-prop": "Quais propriedades obter para as páginas consultadas.",
+ "apihelp-query-param-list": "Quais listas obter.",
+ "apihelp-query-param-meta": "Quais metadados obter.",
+ "apihelp-query-param-indexpageids": "Inclua uma seção adicional de pageids listando todas as IDs de página retornadas.",
+ "apihelp-query-param-export": "Exporte as revisões atuais de todas as páginas dadas ou geradas.",
+ "apihelp-query-param-exportnowrap": "Retorna o XML de exportação sem envolvê-lo em um resultado XML (mesmo formato que [[Special:Export]]). Só pode ser usado com $1export.",
+ "apihelp-query-param-iwurl": "Obter o URL completo se o título for um link interwiki.",
+ "apihelp-query-param-rawcontinue": "Retorne os dados de <samp>query-continue</samp> para continuar.",
+ "apihelp-query-example-revisions": "Obter [[Special:ApiHelp/query+siteinfo|site info]] e [[Special:ApiHelp/query+revisions|revisions]] da <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "Obter revisões de páginas começando com <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "Enumera todas as categorias.",
+ "apihelp-query+allcategories-param-from": "A categoria da qual começar a enumeração.",
+ "apihelp-query+allcategories-param-to": "A categoria na qual parar a enumeração.",
+ "apihelp-query+allcategories-param-prefix": "Pesquisa por todo os título de categoria que começam com este valor.",
+ "apihelp-query+allcategories-param-dir": "Direção para ordenar.",
+ "apihelp-query+allcategories-param-min": "Retorna apenas as categorias com pelo menos esta quantidade de membros.",
+ "apihelp-query+allcategories-param-max": "Retorna apenas as categorias com no máximo esta quantidade de membros.",
+ "apihelp-query+allcategories-param-limit": "Quantas categorias retornar.",
+ "apihelp-query+allcategories-param-prop": "Quais propriedades obter:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Adiciona o número de páginas na categoria.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Tags categorias que estão ocultas com <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Lista categorias com a informação sobre o número de páginas em cada uma.",
+ "apihelp-query+allcategories-example-generator": "Recupera informações sobre a página da categoria em si para as categorias que começam <kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "Lista todas as revisões excluídas por um usuário ou em um espaço nominal.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Só pode ser usada com <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Não pode ser usada com <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "A data a partir da qual começar a enumeração.",
+ "apihelp-query+alldeletedrevisions-param-end": "A data onde parar a enumeração.",
+ "apihelp-query+alldeletedrevisions-param-from": "Começar listando desse título.",
+ "apihelp-query+alldeletedrevisions-param-to": "Parar a listagem neste título.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Pesquisa por todo os título que começam com este valor.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Lista apenas as revisões com esta tag.",
+ "apihelp-query+alldeletedrevisions-param-user": "Lista apenas revisões desse usuário.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Não lista as revisões deste usuário.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Lista apenas páginas neste espaço nominal.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>Nota:</strong> Devido ao [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]], usar <var>$1user</var> e <var>$1namespace</var> juntos pode resultar em menos de <var>$1limit</var> resultados antes de continuar; em casos extremos, nenhum resultado pode ser retornado.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Quando usado como gerador, gera títulos em vez de IDs de revisão.",
+ "apihelp-query+alldeletedrevisions-example-user": "Lista as últimas 50 contribuições excluídas pelo usuário <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Lista as primeiras 50 edições excluídas no espaço nominal principal.",
+ "apihelp-query+allfileusages-summary": "Lista todas as utilizações de arquivo, incluindo os não-existentes.",
+ "apihelp-query+allfileusages-param-from": "O título do arquivo a partir do qual começar a enumerar.",
+ "apihelp-query+allfileusages-param-to": "O título do arquivo onde parar de enumerar.",
+ "apihelp-query+allfileusages-param-prefix": "Procure todos os títulos de arquivos que começam com esse valor.",
+ "apihelp-query+allfileusages-param-unique": "Somente mostra títulos de arquivos distintos. Não pode ser usado com $1prop=ids.\nQuando usado como gerador, produz páginas de destino em vez de páginas de origem.",
+ "apihelp-query+allfileusages-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Adiciona o ID das páginas em uso (não pode ser usado com $1unique).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Adiciona o título do arquivo.",
+ "apihelp-query+allfileusages-param-limit": "Quantos itens retornar.",
+ "apihelp-query+allfileusages-param-dir": "A direção na qual listar.",
+ "apihelp-query+allfileusages-example-B": "Listar títulos de arquivos, incluindo os que faltam, com IDs de página de que são, começando em <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Listar títulos únicos de arquivos.",
+ "apihelp-query+allfileusages-example-unique-generator": "Obtém todos os títulos de arquivo, marcando os que faltam.",
+ "apihelp-query+allfileusages-example-generator": "Obter as páginas contendo os arquivos.",
+ "apihelp-query+allimages-summary": "Enumera todas as imagens sequencialmente.",
+ "apihelp-query+allimages-param-sort": "Propriedade pela qual ordenar.",
+ "apihelp-query+allimages-param-dir": "A direção na qual listar.",
+ "apihelp-query+allimages-param-from": "O título da imagem do qual começar a enumeração. Só pode ser usado com $1sort=name.",
+ "apihelp-query+allimages-param-to": "O título da imagem no qual parar a enumeração. Só pode ser usado com $1sort=nome.",
+ "apihelp-query+allimages-param-start": "O timestamp do qual começar a enumeração. Só pode ser usado com $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "O timestamp no qual parar a enumeração. Só pode ser usado com $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "Procure todos os títulos de imagens que começam com esse valor. Só pode ser usado com $1sort=nome.",
+ "apihelp-query+allimages-param-minsize": "Limite à imagens com, pelo menos, esses bytes.",
+ "apihelp-query+allimages-param-maxsize": "Limite as imagens com, no máximo, esses bytes.",
+ "apihelp-query+allimages-param-sha1": "SHA1 de imagem. Substitui $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "SHA1 de imagem na base 36 (usado em MediaWiki).",
+ "apihelp-query+allimages-param-user": "Retorna apenas os arquivos enviados por este usuário. Só pode ser usado com $1sort=timestamp. Não pode ser usado em conjunto com $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "Como filtrar arquivos enviados por bots. Só pode ser usado com $1sort=timestamp. Não pode ser usado em conjunto com $1user.",
+ "apihelp-query+allimages-param-mime": "Quais tipos MIME pesquisar, por exemplo: <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "Quantas imagens retornar.",
+ "apihelp-query+allimages-example-B": "Mostra uma lista de arquivos começando com a letra <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Mostra uma lista de arquivos recentemente enviados, semelhante a [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Mostra uma lista de arquivos com o tipo MIME <kbd>image/png</kbd> ou <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Mostra informações sobre 4 arquivos começando com a letra <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Enumerar todos os links que apontam para um determinado espaço nominal.",
+ "apihelp-query+alllinks-param-from": "O título do link a partir do qual começar a enumerar.",
+ "apihelp-query+alllinks-param-to": "O título do link onde parar de enumerar.",
+ "apihelp-query+alllinks-param-prefix": "Pesquisa por todos os títulos com link que começam com este valor.",
+ "apihelp-query+alllinks-param-unique": "Somente mostra títulos vinculados diferenciados. Não pode ser usado com <kbd>$1prop=ids</kbd>.\nQuando usado como um gerador, produz páginas de destino em vez de páginas de origem.",
+ "apihelp-query+alllinks-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Adiciona o ID da página da página de ligação (não pode ser usada com <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Adiciona o título do link.",
+ "apihelp-query+alllinks-param-namespace": "O espaço nominal a se enumerar.",
+ "apihelp-query+alllinks-param-limit": "Quantos itens retornar.",
+ "apihelp-query+alllinks-param-dir": "A direção na qual listar.",
+ "apihelp-query+alllinks-example-B": "Listar títulos vinculados, incluindo os que faltam, com IDs de página de que são, começando em <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Lista de títulos vinculados exclusivos.",
+ "apihelp-query+alllinks-example-unique-generator": "Obtém todos os títulos vinculados, marcando as que faltam.",
+ "apihelp-query+alllinks-example-generator": "Obter páginas contendo os links.",
+ "apihelp-query+allmessages-summary": "Devolver as mensagens deste site.",
+ "apihelp-query+allmessages-param-messages": "Quais mensagens para retornar. <kbd>*</kbd> (padrão) indica todas as mensagens.",
+ "apihelp-query+allmessages-param-prop": "Quais propriedades obter.",
+ "apihelp-query+allmessages-param-enableparser": "Defina para ativar o analisador, irá processar o texto wiki da mensagem (substituir palavras mágicas, predefinições manipuladoras, etc.).",
+ "apihelp-query+allmessages-param-nocontent": "Se configurado, não inclua o conteúdo das mensagens na saída.",
+ "apihelp-query+allmessages-param-includelocal": "Inclua também mensagens locais, ou seja, mensagens que não existem no software, mas existem como no {{ns:MediaWiki}} namespace.\nIsso lista todas as páginas de espaço nominal-{{ns: MediaWiki}}, então também irá listar aqueles que não são realmente mensagens, como [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Argumentos para serem substituídos pela mensagem.",
+ "apihelp-query+allmessages-param-filter": "Retornar apenas mensagens com nomes que contêm essa string.",
+ "apihelp-query+allmessages-param-customised": "Retornar apenas mensagens neste estado de personalização.",
+ "apihelp-query+allmessages-param-lang": "Retornar mensagens neste idioma.",
+ "apihelp-query+allmessages-param-from": "Retornar mensagens começando com esta mensagem.",
+ "apihelp-query+allmessages-param-to": "Retornar mensagens terminando com esta mensagem.",
+ "apihelp-query+allmessages-param-title": "Nome da página para usar como contexto ao analisar a mensagem (para a opção $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "Retornar apenas mensagens com este prefixo.",
+ "apihelp-query+allmessages-example-ipb": "Mostrar mensagens começando com <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Mostrar mensagens <kbd>august</kbd> e <kbd>mainpage</kbd> em alemão.",
+ "apihelp-query+allpages-summary": "Enumerar todas as páginas sequencialmente em um determinado espaço nominal.",
+ "apihelp-query+allpages-param-from": "O título da página da qual começar a enumeração.",
+ "apihelp-query+allpages-param-to": "O título da página no qual parar de enumerar.",
+ "apihelp-query+allpages-param-prefix": "Pesquisa por todo os título que começam com este valor.",
+ "apihelp-query+allpages-param-namespace": "O espaço nominal a se enumerar.",
+ "apihelp-query+allpages-param-filterredir": "Quais páginas listar.",
+ "apihelp-query+allpages-param-minsize": "Limitar a páginas com pelo menos essa quantidade de bytes.",
+ "apihelp-query+allpages-param-maxsize": "Limitar a páginas com no máximo essa quantidade de bytes.",
+ "apihelp-query+allpages-param-prtype": "Limite apenas às páginas protegidas.",
+ "apihelp-query+allpages-param-prlevel": "Proteções de filtro com base no nível de proteção (deve ser usado com $1prtype= parameter).",
+ "apihelp-query+allpages-param-prfiltercascade": "Proteções de filtro baseadas em cascata (ignoradas quando o valor de $1 não está definido).",
+ "apihelp-query+allpages-param-limit": "Quantas páginas retornar.",
+ "apihelp-query+allpages-param-dir": "A direção na qual listar.",
+ "apihelp-query+allpages-param-filterlanglinks": "Filtrar com base em se uma página tem lingulinks. Observe que isso pode não considerar os langlinks adicionados por extensões.",
+ "apihelp-query+allpages-param-prexpiry": "Qual proteção expira para filtrar a página em:\n;indefinite: Obtém apenas páginas com expiração de proteção indefinida.\n;definite: Obtém apenas páginas com uma expiração de proteção definida (específica).\n;all: Obtém páginas com qualquer validade de proteção.",
+ "apihelp-query+allpages-example-B": "Mostrar uma lista de páginas a partir da letra <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Mostre informações sobre 4 páginas começando na letra <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Mostre o conteúdo das primeiras 2 páginas não redirecionadas que começam em <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Lista todos os redirecionamentos para um espaço nominal.",
+ "apihelp-query+allredirects-param-from": "O título do redirecionamento a partir do qual começar a enumerar.",
+ "apihelp-query+allredirects-param-to": "O título do redirecionamento onde parar de enumerar.",
+ "apihelp-query+allredirects-param-prefix": "Procure todas as páginas de destino que começam com esse valor.",
+ "apihelp-query+allredirects-param-unique": "Somente mostra páginas de destino distintas. Não pode ser usado com $1prop=ids|fragment|interwiki.\nQuando usado como gerador, produz páginas de destino em vez de páginas de origem.",
+ "apihelp-query+allredirects-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Adiciona o ID da página da página de redirecionamento (não pode ser usada com <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Adiciona o título do redirecionamento.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Adiciona o fragmento do redirecionamento, se houver (não pode ser usado com <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Adiciona o prefixo interwiki do redirecionamento, se houver (não pode ser usado com <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "O espaço nominal a se enumerar.",
+ "apihelp-query+allredirects-param-limit": "Quantos itens retornar.",
+ "apihelp-query+allredirects-param-dir": "A direção na qual listar.",
+ "apihelp-query+allredirects-example-B": "Lista de páginas de destino, incluindo as que faltam, com IDs de página de que são, começando em <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "Listar páginas de destino únicas.",
+ "apihelp-query+allredirects-example-unique-generator": "Obtém todas as páginas alvo, marcando as que faltam.",
+ "apihelp-query+allredirects-example-generator": "Obtém páginas contendo os redirecionamentos.",
+ "apihelp-query+allrevisions-summary": "Listar todas as revisões.",
+ "apihelp-query+allrevisions-param-start": "A data a partir da qual começar a enumeração.",
+ "apihelp-query+allrevisions-param-end": "A data onde parar a enumeração.",
+ "apihelp-query+allrevisions-param-user": "Lista apenas revisões desse usuário.",
+ "apihelp-query+allrevisions-param-excludeuser": "Não lista as revisões deste usuário.",
+ "apihelp-query+allrevisions-param-namespace": "Lista apenas páginas neste espaço nominal.",
+ "apihelp-query+allrevisions-param-generatetitles": "Quando usado como gerador, gera títulos em vez de IDs de revisão.",
+ "apihelp-query+allrevisions-example-user": "Lista as últimas 50 contribuições por usuário <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "Lista as primeiras 50 revisões no espaço nominal principal.",
+ "apihelp-query+mystashedfiles-summary": "Obter uma lista de arquivos no stash de dados do usuário atual.",
+ "apihelp-query+mystashedfiles-param-prop": "Quais propriedades buscar para os arquivos.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Obtenha o tamanho do arquivo e as dimensões da imagem.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Obtenha o tipo MIME e o tipo de mídia do arquivo.",
+ "apihelp-query+mystashedfiles-param-limit": "Quantos arquivos a serem retornados.",
+ "apihelp-query+mystashedfiles-example-simple": "Obter a chave de arquivo, o tamanho do arquivo e o tamanho de pixels dos arquivos no stash de dados do usuário atual.",
+ "apihelp-query+alltransclusions-summary": "Liste todas as transclusões (páginas incorporadas usando &#123;&#123;x&#125;&#125;), incluindo não-existentes.",
+ "apihelp-query+alltransclusions-param-from": "O título da transclusão do qual começar a enumeração.",
+ "apihelp-query+alltransclusions-param-to": "O título da transclusão na qual parar a enumeração.",
+ "apihelp-query+alltransclusions-param-prefix": "Procure todos os títulos transcluídos que começam com esse valor.",
+ "apihelp-query+alltransclusions-param-unique": "Somente mostra páginas transcluídas distintas. Não pode ser usado com $1prop=ids. Quando usado como gerador, produz páginas de destino em vez de páginas de origem.",
+ "apihelp-query+alltransclusions-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Adiciona o ID da página da página de transclusão (não pode ser usado com $1unique).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Adiciona o título da transclusão.",
+ "apihelp-query+alltransclusions-param-namespace": "O espaço nominal a se enumerar.",
+ "apihelp-query+alltransclusions-param-limit": "Quantos itens retornar.",
+ "apihelp-query+alltransclusions-param-dir": "A direção na qual listar.",
+ "apihelp-query+alltransclusions-example-B": "Lista de títulos transcluídos, incluindo os que faltam, com IDs de página de onde são, começando em <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Listar títulos translúcidos exclusivos.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Obtém todas as páginas transcluídas, marcando as que faltam.",
+ "apihelp-query+alltransclusions-example-generator": "Obtém páginas contendo as transclusões.",
+ "apihelp-query+allusers-summary": "Enumerar todos os usuários registrados.",
+ "apihelp-query+allusers-param-from": "O nome do usuário do qual começar a enumeração.",
+ "apihelp-query+allusers-param-to": "O nome do usuário para parar de enumerar em.",
+ "apihelp-query+allusers-param-prefix": "Procurar por todos os usuários que começam com esse valor.",
+ "apihelp-query+allusers-param-dir": "Direção para ordenar.",
+ "apihelp-query+allusers-param-group": "Somente inclua usuários nos grupos fornecidos.",
+ "apihelp-query+allusers-param-excludegroup": "Excluir os usuários nos grupos fornecidos.",
+ "apihelp-query+allusers-param-rights": "Somente inclui usuários com os direitos dados. Não inclui direitos concedidos por grupos implícitos ou auto-promovidos como *, usuário ou autoconfirmados.",
+ "apihelp-query+allusers-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Adiciona a informação sobre um bloco atual no usuário.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Lista grupos em que o usuário está. Isso usa mais recursos do servidor e pode retornar menos resultados do que o limite.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Lista todos os grupos em que o usuário está automaticamente.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Lista os direitos que o usuário possui.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Adiciona a contagem de edições do usuário.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Adiciona o timestamp de quando o usuário se registrou se disponível (pode estar em branco).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Adiciona os IDs centrais e o status do anexo do usuário.",
+ "apihelp-query+allusers-param-limit": "Quantos nomes de usuário a serem retornados.",
+ "apihelp-query+allusers-param-witheditsonly": "Apenas lista os usuários que fizeram edições.",
+ "apihelp-query+allusers-param-activeusers": "Apenas lista os usuários ativos no último $1 {{PLURAL:$1|dia|dias}}.",
+ "apihelp-query+allusers-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, também indica se o usuário está conectado com a wiki identificado por este ID.",
+ "apihelp-query+allusers-example-Y": "Listar usuários começando em <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Recupere informações sobre o status de autenticação atual.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Teste se o status de autenticação atual do usuário é suficiente para a operação específica de segurança especificada.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Obtenha informações sobre os pedidos de autenticação necessários para a ação de autenticação especificada.",
+ "apihelp-query+authmanagerinfo-example-login": "Obtenha os pedidos que podem ser usados ao iniciar um login.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Obtenha os pedidos que podem ser usados ao iniciar um login, com campos de formulário mesclados.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Teste se a autenticação é suficiente para ação <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "Encontre todas as páginas que apontam para a página dada.",
+ "apihelp-query+backlinks-param-title": "Título a se pesquisar. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "ID da página a se pesquisar. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "O espaço nominal a se enumerar.",
+ "apihelp-query+backlinks-param-dir": "A direção na qual listar.",
+ "apihelp-query+backlinks-param-filterredir": "Como filtrar para redirecionamentos. Se configurado para <kbd>nonredirects</kbd> quando <var>$1redirect</var> estiver ativado, isso só é aplicado ao segundo nível.",
+ "apihelp-query+backlinks-param-limit": "Quantas páginas retornar. Se <var>$1redirect</var> estiver ativado, o limite se aplica a cada nível separadamente (o que significa até 2 * <var>$1limit</var> resultados podem ser retornados).",
+ "apihelp-query+backlinks-param-redirect": "Se a página de link for um redirecionamento, encontre todas as páginas que se liguem a esse redirecionamento também. O limite máximo é reduzido para metade.",
+ "apihelp-query+backlinks-example-simple": "Mostrar links para <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Obter informações sobre páginas que ligam para <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Liste todos os usuários e endereços IP bloqueados.",
+ "apihelp-query+blocks-param-start": "A data a partir da qual começar a enumeração.",
+ "apihelp-query+blocks-param-end": "A data onde parar a enumeração.",
+ "apihelp-query+blocks-param-ids": "Lista de IDs de bloco para listar (opcional).",
+ "apihelp-query+blocks-param-users": "Lista de usuários para procurar (opcional).",
+ "apihelp-query+blocks-param-ip": "Obter todos os blocos aplicando a este IP ou intervalos CIDR, incluindo intervalos de blocos.\nNão pode ser usado em conjunto com <var>$3users</var>. Intervalos CIDR mais largos do que IPv4/$1 ou IPv6/$2 não são aceitos.",
+ "apihelp-query+blocks-param-limit": "O número máximo de blocos para listar.",
+ "apihelp-query+blocks-param-prop": "Quais propriedades obter:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Adiciona o ID do bloco.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Adiciona o nome de usuário do usuário bloqueado.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Adiciona o ID do usuário bloqueado.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Adiciona o nome de usuário do usuário bloqueador.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Adiciona o ID do usuário bloqueador.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Adiciona o timestamp de quando o bloqueio foi criado.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Adiciona o timestamp de quando o bloqueio expira.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Adiciona a razão dada para o bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Adiciona o intervalo de endereços IP afetados pelo bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Etiqueta a proibição com (autobloqueio, anononly, etc.).",
+ "apihelp-query+blocks-param-show": "Mostre apenas itens que atendam a esses critérios. Por exemplo, para ver apenas blocos indefinidos nos endereços IP, defina <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "Listar bloqueios.",
+ "apihelp-query+blocks-example-users": "Liste os bloqueios dos usuários <kbd>Alice</kbd> e <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Liste todas as categorias às quais as páginas pertencem.",
+ "apihelp-query+categories-param-prop": "Quais propriedades adicionais obter para cada categoria:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Adiciona a sortkey (string hexadecimal) e o prefixo da sortkey (parte legível para humanos) para a categoria.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Adiciona o timestamp de quando a categoria foi adicionada.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Tags categorias que estão ocultas com <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "Quais tipos de categorias mostrar.",
+ "apihelp-query+categories-param-limit": "Quantas categorias retornar.",
+ "apihelp-query+categories-param-categories": "Apenas liste essas categorias. Útil para verificar se uma determinada página está em uma determinada categoria.",
+ "apihelp-query+categories-param-dir": "A direção na qual listar.",
+ "apihelp-query+categories-example-simple": "Obter uma lista de categorias para as quais a página <kbd> Albert Einstein </ kbd> pertence.",
+ "apihelp-query+categories-example-generator": "Obter informações sobre todas as categorias usadas na página <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "Retorna informações sobre as categorias dadas.",
+ "apihelp-query+categoryinfo-example-simple": "Obter informações sobre <kbd>Category:Foo</kbd> e <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Lista todas as páginas numa categoria específica.",
+ "apihelp-query+categorymembers-param-title": "Qual categoria enumerar (obrigatório). Deve incluir o prefixo <kbd>{{ns:category}}:</kbd>. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "ID da página da categoria para enumerar. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Adiciona o ID da página.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Adiciona o título e o ID do espaço nominal da página.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Adiciona a sortkey usada para classificar na categoria (string hexadecimal).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Adiciona o prefixo da sortkey usado para classificar na categoria (parte da sortkey legível para humanos).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Adiciona o tipo em que a página foi categorizada como (<samp>page</samp>, <samp>subcat</samp> ou <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Adiciona o timestamp de quando a página foi incluida.",
+ "apihelp-query+categorymembers-param-namespace": "Somente inclua páginas nesses espaços de nomes. Observe que <kbd>$1type=subcat</kbd> OU <kbd>$1type=file</kbd> pode ser usado aon invéz de <kbd>$1namespace=14</kbd> ou <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-type": "Quais tipos de membros da categoria incluir. Ignorado quando <kbd>$1sort=timestamp</kbd> está ativado.",
+ "apihelp-query+categorymembers-param-limit": "O número máximo de páginas para retornar.",
+ "apihelp-query+categorymembers-param-sort": "Propriedade pela qual ordenar.",
+ "apihelp-query+categorymembers-param-dir": "Em qual sentido ordenar.",
+ "apihelp-query+categorymembers-param-start": "O timestamp do qual começar a lista. Só pode ser usado com <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "Timestamp para encerrar a lista em. Só pode ser usado com <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Sortkey para iniciar a listagem como retornado por <kbd>$1prop=sortkey</kbd>. Só pode ser usado com <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Sortkey para terminar a listagem, como retornado por <kbd>$1prop=sortkey</kbd>. Só pode ser usado com <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "Prefixo Sortkey para começar a listagem. Só pode ser usado com <kbd>$1sort=sortkey</kbd>. Substitui <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "Sortkey prefix para terminar a lista <strong>before</strong> (não <strong>at</strong>; se esse valor ocorrer, não será incluído!). Só pode ser usado com $1sort=sortkey. Substitui $1endhexsortkey.",
+ "apihelp-query+categorymembers-param-startsortkey": "Use $1starthexsortkey em vez.",
+ "apihelp-query+categorymembers-param-endsortkey": "Use $1endhexsortkey em vez.",
+ "apihelp-query+categorymembers-example-simple": "Obter as 10 primeiras páginas em <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Obter informações da página sobre as primeiras 10 páginas em <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Obter a lista de contribuidores logados e a contagem de contribuidores anônimos para uma página.",
+ "apihelp-query+contributors-param-group": "Somente inclui usuários nos grupos dados. Não inclui grupos implícitos ou auto-promovidos como *, usuário ou autoconfirmados.",
+ "apihelp-query+contributors-param-excludegroup": "Excluir os usuários nos grupos fornecidos. Não inclui grupos implícitos ou auto-promovidos como *, usuário ou autoconfirmados.",
+ "apihelp-query+contributors-param-rights": "Somente inclui usuários com os direitos dados. Não inclui direitos concedidos por grupos implícitos ou auto-promovidos como *, usuário ou autoconfirmados.",
+ "apihelp-query+contributors-param-excluderights": "Excluir usuários com os direitos dados. Não inclui direitos concedidos por grupos implícitos ou auto-promovidos como *, usuário ou autoconfirmados.",
+ "apihelp-query+contributors-param-limit": "Quantas contribuições retornar.",
+ "apihelp-query+contributors-example-simple": "Mostrar contribuidores para a página <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-summary": "Obtem informações de revisão excluídas.",
+ "apihelp-query+deletedrevisions-extended-description": "Pode ser usado de várias maneiras:\n# Obtenha revisões excluídas para um conjunto de páginas, definindo títulos ou pageids. Ordenado por título e timestamp.\n# Obter dados sobre um conjunto de revisões excluídas, definindo seus IDs com revids. Ordenado por ID de revisão.",
+ "apihelp-query+deletedrevisions-param-start": "O timestamp no qual começar a enumerar. Ignorado ao processar uma lista de IDs de revisão.",
+ "apihelp-query+deletedrevisions-param-end": "O timestamp no qual parar de enumerar. Ignorado ao processar uma lista de IDs de revisão.",
+ "apihelp-query+deletedrevisions-param-tag": "Lista apenas as revisões com esta tag.",
+ "apihelp-query+deletedrevisions-param-user": "Lista apenas revisões desse usuário.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Não lista as revisões deste usuário.",
+ "apihelp-query+deletedrevisions-example-titles": "Lista as revisões excluídas das páginas <kbd>Main Page</kbd> e <kbd>Talk:Main Page</kbd>, com conteúdo.",
+ "apihelp-query+deletedrevisions-example-revids": "Lista as informações para a revisão excluída <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "Listar revisões excluídas.",
+ "apihelp-query+deletedrevs-extended-description": "Opera em três modos:\n# Lista revisões excluídas para os títulos fornecidos, ordenados por timestamp.\n# Lista contribuições eliminadas para o usuário fornecido, ordenadas por timestamp (sem títulos especificados).\n# Liste todas as revisões excluídas no espaço nominal dado, classificado por título e timestamp (sem títulos especificados, $1user não definido).\n \nCertos parâmetros aplicam-se apenas a alguns modos e são ignorados em outros.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Modo|Modos}}: $2",
+ "apihelp-query+deletedrevs-param-start": "A data a partir da qual começar a enumeração.",
+ "apihelp-query+deletedrevs-param-end": "A data onde parar a enumeração.",
+ "apihelp-query+deletedrevs-param-from": "Começar listando desse título.",
+ "apihelp-query+deletedrevs-param-to": "Parar a listagem neste título.",
+ "apihelp-query+deletedrevs-param-prefix": "Pesquisa por todo os título que começam com este valor.",
+ "apihelp-query+deletedrevs-param-unique": "Liste apenas uma revisão para cada página.",
+ "apihelp-query+deletedrevs-param-tag": "Lista apenas as revisões com esta tag.",
+ "apihelp-query+deletedrevs-param-user": "Lista apenas revisões desse usuário.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Não lista as revisões deste usuário.",
+ "apihelp-query+deletedrevs-param-namespace": "Lista apenas páginas neste espaço nominal.",
+ "apihelp-query+deletedrevs-param-limit": "A quantidade máxima de revisões para listar.",
+ "apihelp-query+deletedrevs-param-prop": "Quais as propriedades a serem obtidas: \n; revid: Adiciona a ID da revisão da revisão excluída.\n; parentid: Adiciona a ID da revisão da revisão anterior à página.\n;user: Adiciona o usuário que fez a revisão.\n; userid: Adiciona o ID do usuário que fez a revisão. \n; comment: Adiciona o comentário da revisão.\n; parsedcomment: Adiciona o comentário analisado da revisão.\n; minor: Etiqueta se a revisão for menor.\n; len: Adiciona o comprimento (bytes) da revisão.\n; sha1: Adiciona o SHA-1 (base 16) da revisão.\n; content: Adiciona o conteúdo da revisão.\n; token: <span class=\"apihelp-deprecated\">Depreciado.</span> Dá o token de edição.\n; tags: Tags para a revisão.",
+ "apihelp-query+deletedrevs-example-mode1": "Lista as últimas revisões excluídas das páginas <kbd>Main Page</kbd> e <kbd>Talk:Main Page</kbd>, com conteúdo (modo 1).",
+ "apihelp-query+deletedrevs-example-mode2": "Lista as últimas 50 contribuições excluídas por <kbd>Bob</kbd> (modo 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Lista as primeiras 50 revisões excluídas no espaço nominal principal (modo 3).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Lista as primeiras 50 páginas excluídas no espaço nominal {{ns:talk}} (modo 3).",
+ "apihelp-query+disabled-summary": "Este módulo de consulta foi desativado.",
+ "apihelp-query+duplicatefiles-summary": "Liste todos os arquivos que são duplicatas dos arquivos fornecidos com base em valores de hash.",
+ "apihelp-query+duplicatefiles-param-limit": "Quantos arquivos duplicados retornar.",
+ "apihelp-query+duplicatefiles-param-dir": "A direção na qual listar.",
+ "apihelp-query+duplicatefiles-param-localonly": "Procure apenas arquivos no repositório local.",
+ "apihelp-query+duplicatefiles-example-simple": "Procurar por duplicatas de [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Procure duplicatas de todos os arquivos.",
+ "apihelp-query+embeddedin-summary": "Encontre todas as páginas que incorporam (transcluam) o título dado.",
+ "apihelp-query+embeddedin-param-title": "Título a se pesquisar. Não pode ser usado em conjunto com $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "ID da página para pesquisar. Não pode ser usado em conjunto com $1title.",
+ "apihelp-query+embeddedin-param-namespace": "O espaço nominal a se enumerar.",
+ "apihelp-query+embeddedin-param-dir": "A direção na qual listar.",
+ "apihelp-query+embeddedin-param-filterredir": "Como filtrar por redirecionamentos.",
+ "apihelp-query+embeddedin-param-limit": "Quantas páginas retornar.",
+ "apihelp-query+embeddedin-example-simple": "Mostrar páginas transcluíndo <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "Obter informação sobre páginas transcluindo <kbd>Template:Stub</kbd>.",
+ "apihelp-query+extlinks-summary": "Retorna todos os URLs externas (não interwikis) a partir das páginas dadas.",
+ "apihelp-query+extlinks-param-limit": "Quantos links retornar.",
+ "apihelp-query+extlinks-param-protocol": "Protocolo do URL. Se estiver vazio e <var>$1query</var> estiver definido, o protocolo é <kbd>http</kbd>. Deixe o anterior e <var>$1query </var> vazios para listar todos os links externos.",
+ "apihelp-query+extlinks-param-query": "Pesquisar string sem protocolo. Útil para verificar se uma determinada página contém uma determinada URL externa.",
+ "apihelp-query+extlinks-param-expandurl": "Expandir URLs relativos ao protocolo com o protocolo canônico.",
+ "apihelp-query+extlinks-example-simple": "Obter uma lista de links externos em <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-summary": "Enumere páginas que contenham um determinado URL.",
+ "apihelp-query+exturlusage-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Adiciona o ID da página.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Adiciona o título e o ID do espaço nominal da página.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Adiciona o URL usado na página.",
+ "apihelp-query+exturlusage-param-protocol": "Protocolo do URL. Se estiver vazio e <var>$1query</var> estiver definido, o protocolo é <kbd>http</kbd>. Deixe o anterior e <var>$1query </var> vazios para listar todos os links externos.",
+ "apihelp-query+exturlusage-param-query": "Sequência de pesquisa sem protocolo. Veja [[Special:LinkSearch]]. Deixe vazio para listar todos os links externos.",
+ "apihelp-query+exturlusage-param-namespace": "O espaço nominal das páginas para enumerar.",
+ "apihelp-query+exturlusage-param-limit": "Quantas páginas retornar.",
+ "apihelp-query+exturlusage-param-expandurl": "Expandir URLs relativos ao protocolo com o protocolo canônico.",
+ "apihelp-query+exturlusage-example-simple": "Mostra páginas vigiadas à <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-summary": "Enumerar todos os arquivos excluídos sequencialmente.",
+ "apihelp-query+filearchive-param-from": "O título da imagem do qual começar a enumeração.",
+ "apihelp-query+filearchive-param-to": "O título da imagem no qual parar a enumeração.",
+ "apihelp-query+filearchive-param-prefix": "Procure todos os títulos de imagens que começam com esse valor.",
+ "apihelp-query+filearchive-param-limit": "Quantas imagens retornar.",
+ "apihelp-query+filearchive-param-dir": "A direção na qual listar.",
+ "apihelp-query+filearchive-param-sha1": "SHA1 de imagem. Substitui $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "SHA1 de imagem na base 36 (usado em MediaWiki).",
+ "apihelp-query+filearchive-param-prop": "Quais informação de imagem obter:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Adiciona o SHA-1 da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Adiciona o timestamp para a versão carregada.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Adiciona o usuário que carregou a versão da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Adiciona o tamanho da imagem em bytes e a altura, largura e contagem de páginas (se aplicável).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Apelido para tamanho.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Adiciona descrição da versão da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Analise a descrição da versão.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Adiciona o tipo MIME da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Adiciona o tipo de mídia da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Lista metadados Exif para a versão da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Adiciona a profundidade de bits da versão.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Adiciona o nome do arquivo da versão arquivada para as versões não-mais recentes.",
+ "apihelp-query+filearchive-example-simple": "Mostrar uma lista de todos os arquivos excluídos.",
+ "apihelp-query+filerepoinfo-summary": "Retorna informações meta sobre repositórios de imagens configurados na wiki.",
+ "apihelp-query+filerepoinfo-param-prop": "Quais propriedades do repositório obter (pode haver mais disponível em algumas wikis):\n;apiurl: URL para a API do repositório - útil para obter informações de imagem do host.\n;name: A chave do repositório - usado em por exemplo, <var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> e [[Special:ApiHelp/query+imageinfo|imageinfo]] valores de retorno.\n;displayname: O legível para humanos do repositório wiki.\n;rooturl: URL raiz para caminhos de imagem.\n; local: Se esse repositório é o local ou não.",
+ "apihelp-query+filerepoinfo-example-simple": "Obter informações sobre repositórios de arquivos.",
+ "apihelp-query+fileusage-summary": "Encontre todas as páginas que usam os arquivos dados.",
+ "apihelp-query+fileusage-param-prop": "Quais propriedades obter:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "ID de cada página.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "O título de cada página.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Sinalizar se a página é um redirecionamento.",
+ "apihelp-query+fileusage-param-namespace": "Listar apenas páginas neste espaço nominal.",
+ "apihelp-query+fileusage-param-limit": "Quantos retornar.",
+ "apihelp-query+fileusage-param-show": "Mostre apenas itens que atendam a esses critérios.\n;redirect:Apenas mostra redirecionamentos.\n;!redirect:Não mostra redirecionamentos.",
+ "apihelp-query+fileusage-example-simple": "Obter uma lista de páginas usando [[:File:Example.jpg]].",
+ "apihelp-query+fileusage-example-generator": "Obter informação sobre páginas usando [[:File:Example.jpg]].",
+ "apihelp-query+imageinfo-summary": "Retorna a informação do arquivo e o histórico de upload.",
+ "apihelp-query+imageinfo-param-prop": "Quais informações de arquivo para obter:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Adiciona o timestamp para a versão carregada.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Adiciona o usuário que carregou cada versão do arquivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Adiciona a identificação do usuário que carregou cada versão do arquivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Comente sobre a versão.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Analise o comentário na versão.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Adiciona o título canônico do arquivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Fornece o URL para o arquivo e a página de descrição.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Adiciona o tamanho do arquivo em bytes e a altura, largura e contagem de páginas (se aplicável).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Apelido para tamanho.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Adiciona o SHA-1 do arquivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Adiciona o tipo MIME do arquivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Adiciona o tipo MIME da miniatura da imagem (requer url e param $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Adiciona o tipo de mídia do arquivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Lista metadados Exif para a versão do arquivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Lista os metadados genéricos do formato de arquivo para a versão do arquivo.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Lista metadados formatados combinados de várias fontes. Os resultados são formatados em HTML.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Adiciona o nome do arquivo da versão arquivada para as versões não-mais recentes.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Adiciona a profundidade de bits da versão.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Usado pela página Special:Upload para obter informações sobre um arquivo existente. Não está destinado a ser usado fora do núcleo do MediaWiki.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Adiciona se o arquivo está no [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-limit": "Quantas revisões de arquivos retornar por arquivo.",
+ "apihelp-query+imageinfo-param-start": "O timestamp do qual começar a enumeração.",
+ "apihelp-query+imageinfo-param-end": "Data e hora para a listagem.",
+ "apihelp-query+imageinfo-param-urlwidth": "Se $2prop=url estiver definido, um URL para uma imagem dimensionada para essa largura será retornado.\nPor motivos de desempenho, se essa opção for usada, não serão retornadas mais de $1 imagens dimensionadas.",
+ "apihelp-query+imageinfo-param-urlheight": "Semelhante a $1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "Versão dos metadados para usar. Se <kbd>latest</kbd> for especificado, use a versão mais recente. Por padrão, <kbd>1</kbd> para compatibilidade com versões anteriores.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "Em qual idioma procurar por extmetadata. Isso afeta tanto a tradução a ser buscada, quanto várias estão disponíveis, bem como a forma como as coisas como números e vários valores são formatados.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Se as traduções para a propriedade extmetadata estiverem disponíveis, procure todas elas.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Se especificado e não vazio, apenas essas chaves serão retornadas para $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "Uma sequência de parâmetro específico do manipulador. Por exemplo, PDFs podem usar <kbd>page15-100px</kbd>. <var>$1urlwidth</var> deve ser usado e ser consistente com <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Se <kbd>$2prop=badfile</kbd> estiver definido, este é o título da página usado ao avaliar a [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-localonly": "Procure apenas arquivos no repositório local.",
+ "apihelp-query+imageinfo-example-simple": "Obtenha informações sobre a versão atual do [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "Obtenha informações sobre versões do [[:File:Test.jpg]] a partir de 2008 e posterior.",
+ "apihelp-query+images-summary": "Retorna todos os arquivos contidos nas páginas fornecidas.",
+ "apihelp-query+images-param-limit": "Quantos arquivos retornar.",
+ "apihelp-query+images-param-images": "Apenas liste esses arquivos. Útil para verificar se uma determinada página possui um determinado arquivo.",
+ "apihelp-query+images-param-dir": "A direção na qual listar.",
+ "apihelp-query+images-example-simple": "Obter uma lista de arquivos usados na [[Main Page]].",
+ "apihelp-query+images-example-generator": "Obter informações sobre todos os arquivos usados na [[Main Page]].",
+ "apihelp-query+imageusage-summary": "Encontre todas as páginas que usam o título da imagem dada.",
+ "apihelp-query+imageusage-param-title": "Título a se pesquisar. Não pode ser usado em conjunto com $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "ID da página para pesquisar. Não pode ser usado em conjunto com $1title.",
+ "apihelp-query+imageusage-param-namespace": "O espaço nominal a se enumerar.",
+ "apihelp-query+imageusage-param-dir": "A direção na qual listar.",
+ "apihelp-query+imageusage-param-filterredir": "Como filtrar para redirecionamentos. Se configurado para não-redirecionamentos quando $1redirect estiver habilitado, isso só é aplicado ao segundo nível.",
+ "apihelp-query+imageusage-param-limit": "Quantas páginas retornar. Se <var>$1redirect</var> estiver ativado, o limite se aplica a cada nível separadamente (o que significa até 2 * <var>$1limit</var> resultados podem ser retornados).",
+ "apihelp-query+imageusage-param-redirect": "Se a página de link for um redirecionamento, encontre todas as páginas que se liguem a esse redirecionamento também. O limite máximo é reduzido para metade.",
+ "apihelp-query+imageusage-example-simple": "Mostrar páginas usando [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Obter informação sobre páginas usando [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "Obter informações básicas sobre a página.",
+ "apihelp-query+info-param-prop": "Quais propriedades adicionais obter:",
+ "apihelp-query+info-paramvalue-prop-protection": "Liste o nível de proteção de cada página.",
+ "apihelp-query+info-paramvalue-prop-talkid": "O ID da página de discussão para cada página de não-discussão.",
+ "apihelp-query+info-paramvalue-prop-watched": "Liste o estado de vigilância de cada página.",
+ "apihelp-query+info-paramvalue-prop-watchers": "Número de observadores, se permitido.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "O número de observadores de cada página que visitou edições recentes para essa página, se permitido.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "O timestamp da notificação da lista de páginas vigiadas de cada página.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "O ID da página principal para cada página de discussão.",
+ "apihelp-query+info-paramvalue-prop-url": "Retorna um URL completo, de edição e o canônico para cada página.",
+ "apihelp-query+info-paramvalue-prop-readable": "Se o usuário pode ler esta página.",
+ "apihelp-query+info-paramvalue-prop-preload": "Fornece o texto retornado por EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Fornece o modo como o título da página é exibido.",
+ "apihelp-query+info-param-testactions": "Testa se o usuário atual pode executar determinadas ações na página.",
+ "apihelp-query+info-param-token": "Use [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] em vez.",
+ "apihelp-query+info-example-simple": "Obter informações sobre a página <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Obter informações gerais e de proteção sobre a página <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-summary": "Encontra todas as páginas que apontam para o link interwiki dado.",
+ "apihelp-query+iwbacklinks-extended-description": "Pode ser usado para encontrar todos os links com um prefixo, ou todos os links para um título (com um determinado prefixo). Usar nenhum parâmetro é efetivamente \"todos os links interwiki\".",
+ "apihelp-query+iwbacklinks-param-prefix": "Prefixo para o interwiki.",
+ "apihelp-query+iwbacklinks-param-title": "Link interwiki para pesquisar. Deve ser usado com <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "Quantas páginas retornar.",
+ "apihelp-query+iwbacklinks-param-prop": "Quais propriedades obter:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Adiciona o prefixo do interwiki.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Adiciona o título do interwiki.",
+ "apihelp-query+iwbacklinks-param-dir": "A direção na qual listar.",
+ "apihelp-query+iwbacklinks-example-simple": "Obter páginas apontando para [[wikibooks:Test]].",
+ "apihelp-query+iwbacklinks-example-generator": "Obter informações sobre páginas que ligam para [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-summary": "Retorna todos os links interwiki das páginas fornecidas.",
+ "apihelp-query+iwlinks-param-url": "Obter o URL completo (não pode ser usado com $1prop).",
+ "apihelp-query+iwlinks-param-prop": "Quais propriedades adicionais obter para cada link interlanguage:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Adiciona o URL completo.",
+ "apihelp-query+iwlinks-param-limit": "Quantos interwiki links a serem retornados.",
+ "apihelp-query+iwlinks-param-prefix": "Retornar apenas links interwiki com este prefixo.",
+ "apihelp-query+iwlinks-param-title": "Link interwiki para pesquisar por. Deve ser usado com <var>$1prefix</var>.",
+ "apihelp-query+iwlinks-param-dir": "A direção na qual listar.",
+ "apihelp-query+iwlinks-example-simple": "Obtenha links interwiki da página <kbd>Main Page</kbd>.",
+ "apihelp-query+langbacklinks-summary": "Encontre todas as páginas que apontam para o link de idioma dado.",
+ "apihelp-query+langbacklinks-extended-description": "Pode ser usado para encontrar todos os links com um código de idioma ou todos os links para um título (com um determinado idioma). Usar nenhum dos parâmetros é efetivamente \"todos os links de linguagem\". \n\nNote que isso pode não considerar os links de idiomas adicionados por extensões.",
+ "apihelp-query+langbacklinks-param-lang": "Idioma para o link de idioma.",
+ "apihelp-query+langbacklinks-param-title": "Link de idioma para procurar. Deve ser usado com $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "Quantas páginas retornar.",
+ "apihelp-query+langbacklinks-param-prop": "Quais propriedades obter:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Adiciona o código de idioma do link de idioma.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Adiciona o título do link de idioma.",
+ "apihelp-query+langbacklinks-param-dir": "A direção na qual listar.",
+ "apihelp-query+langbacklinks-example-simple": "Obter páginas apontando para [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Obter informações sobre páginas que ligam para [[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "Retorna todos os links interlanguage das páginas fornecidas.",
+ "apihelp-query+langlinks-param-limit": "Quantos links de idioma retornar.",
+ "apihelp-query+langlinks-param-url": "Obter o URL completo (não pode ser usado com <var>$1prop</var>).",
+ "apihelp-query+langlinks-param-prop": "Quais propriedades adicionais obter para cada link interlanguage:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Adiciona o URL completo.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "Adiciona o nome do idioma localizado (melhor esforço). Use <var>$1inlanguagecode</var> para controlar o idioma.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Adiciona o nome do idioma nativo.",
+ "apihelp-query+langlinks-param-lang": "Retornar apenas os links de idioma com este código de idioma.",
+ "apihelp-query+langlinks-param-title": "Link para pesquisar. Deve ser usado com <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "A direção na qual listar.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Código de idioma para nomes de idiomas localizados.",
+ "apihelp-query+langlinks-example-simple": "Obter links de interligação da página <kbd>Main Page</kbd>.",
+ "apihelp-query+links-summary": "Retorna todos os links das páginas fornecidas.",
+ "apihelp-query+links-param-namespace": "Mostre apenas links nesses espaços de nominais.",
+ "apihelp-query+links-param-limit": "Quantos links retornar.",
+ "apihelp-query+links-param-titles": "Apenas lista links para esses títulos. Útil para verificar se uma determinada página liga a um certo título.",
+ "apihelp-query+links-param-dir": "A direção na qual listar.",
+ "apihelp-query+links-example-simple": "Obter links da página <kbd>Main Page</kbd>",
+ "apihelp-query+links-example-generator": "Obter informações sobre os links de páginas na página <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-namespaces": "Obter links da página <kbd>Main Page</kbd> nos espaços nominais {{ns:user}} e {{ns:template}}.",
+ "apihelp-query+linkshere-summary": "Encontre todas as páginas que apontam para as páginas dadas.",
+ "apihelp-query+linkshere-param-prop": "Quais propriedades obter:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "ID de cada página.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "O título de cada página.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Sinalizar se a página é um redirecionamento.",
+ "apihelp-query+linkshere-param-namespace": "Listar apenas páginas neste espaço nominal.",
+ "apihelp-query+linkshere-param-limit": "Quantos retornar.",
+ "apihelp-query+linkshere-param-show": "Mostre apenas itens que atendam a esses critérios.\n;redirect:Apenas mostra redirecionamentos.\n;!redirect:Não mostra redirecionamentos.",
+ "apihelp-query+linkshere-example-simple": "Obter uma lista de páginas que ligam para a [[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "Obter informações sobre páginas que ligam para [[Main Page]].",
+ "apihelp-query+logevents-summary": "Recuperar eventos dos logs.",
+ "apihelp-query+logevents-param-prop": "Quais propriedades obter:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Adiciona o ID do log de eventos.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Adiciona o título da página para o log de eventos.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Adiciona o tipo do log de eventos.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Adiciona o usuário responsável pelo evento de log.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Adiciona o ID do usuário responsável pelo evento de log.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "Adiciona o timestamp para o log de eventos.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Adiciona o comentário do evento de log.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "Adiciona o comentário analisado do log de eventos.",
+ "apihelp-query+logevents-paramvalue-prop-details": "Lista detalhes adicionais sobre o evento de log.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Lista as tags para o evento de log.",
+ "apihelp-query+logevents-param-type": "Filtre as entradas de log para apenas esse tipo.",
+ "apihelp-query+logevents-param-action": "Filtre as ações de log para apenas essa ação. Substitui <var>$1type</var>. Na lista de valores possíveis, os valores com asterisco, como <kbd>action/*</kbd>, podem ter strings diferentes após a barra (/).",
+ "apihelp-query+logevents-param-start": "A data a partir da qual começar a enumeração.",
+ "apihelp-query+logevents-param-end": "O timestamp para terminar de enumerar.",
+ "apihelp-query+logevents-param-user": "Filtrar entradas para aquelas feitas pelo usuário fornecido.",
+ "apihelp-query+logevents-param-title": "Filtre as entradas para aquelas relacionadas a uma página.",
+ "apihelp-query+logevents-param-namespace": "Filtrar as entradas para aqueles no espaço nominal fornecido.",
+ "apihelp-query+logevents-param-prefix": "Filtrar as entradas que começam com este prefixo.",
+ "apihelp-query+logevents-param-tag": "Apenas lista as entradas de eventos marcadas com esta etiqueta.",
+ "apihelp-query+logevents-param-limit": "Quantas entradas de eventos a serem retornadas.",
+ "apihelp-query+logevents-example-simple": "Listar os eventos recentes do registo.",
+ "apihelp-query+pagepropnames-summary": "Liste todos os nomes de propriedade da página em uso na wiki.",
+ "apihelp-query+pagepropnames-param-limit": "O número máximo de nomes a retornar.",
+ "apihelp-query+pagepropnames-example-simple": "Obtenha os primeiros 10 nomes de propriedade.",
+ "apihelp-query+pageprops-summary": "Obter várias propriedades da página definidas no conteúdo da página.",
+ "apihelp-query+pageprops-param-prop": "Apenas liste as propriedades desta página (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> retorna nomes de propriedade da página em uso). Útil para verificar se as páginas usam uma determinada propriedade da página.",
+ "apihelp-query+pageprops-example-simple": "Obter propriedades para as páginas <kbd>Main Page</kbd> e <kbd>MediaWiki</kbd>.",
+ "apihelp-query+pageswithprop-summary": "Liste todas as páginas usando uma propriedade de página determinada.",
+ "apihelp-query+pageswithprop-param-propname": "Propriedade da página para a qual enumeram páginas (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> retorna nomes de propriedade da página em uso).",
+ "apihelp-query+pageswithprop-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Adiciona o ID da página.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Adiciona o título e o ID do espaço nominal da página.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Adiciona o valor da propriedade da página.",
+ "apihelp-query+pageswithprop-param-limit": "O número máximo de páginas para retornar.",
+ "apihelp-query+pageswithprop-param-dir": "Em qual sentido ordenar.",
+ "apihelp-query+pageswithprop-example-simple": "Lista as primeiras 10 páginas usando <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "Obter informações adicionais sobre as primeiras 10 páginas usando <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-summary": "Execute uma pesquisa de prefixo para títulos de página.",
+ "apihelp-query+prefixsearch-extended-description": "Apesar da semelhança nos nomes, este módulo não se destina a ser equivalente a[[Special:PrefixIndex]]; para isso, veja <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> com o parâmetro <kbd>apprefix</kbd>.O propósito deste módulo é semelhante a <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>: para inserir o usuário e fornecer os títulos de melhor correspondência. Dependendo do backend do mecanismo de pesquisa, isso pode incluir correção de digitação, evasão de redirecionamento ou outras heurísticas.",
+ "apihelp-query+prefixsearch-param-search": "Pesquisar string.",
+ "apihelp-query+prefixsearch-param-namespace": "Espaço nominal para pesquisar.",
+ "apihelp-query+prefixsearch-param-limit": "Número máximo de resultados.",
+ "apihelp-query+prefixsearch-param-offset": "Número de resultados a ignorar.",
+ "apihelp-query+prefixsearch-example-simple": "Procure títulos de páginas começando com <kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "Pesquisar perfil para usar.",
+ "apihelp-query+protectedtitles-summary": "Liste todos os títulos protegidos contra criação.",
+ "apihelp-query+protectedtitles-param-namespace": "Somente lista títulos nesses espaços de nominais.",
+ "apihelp-query+protectedtitles-param-level": "Lista apenas os títulos com esses níveis de proteção.",
+ "apihelp-query+protectedtitles-param-limit": "Quantas páginas retornar.",
+ "apihelp-query+protectedtitles-param-start": "Iniciar a listar neste timestamp de proteção.",
+ "apihelp-query+protectedtitles-param-end": "Pare de listar neste timestamp de proteção.",
+ "apihelp-query+protectedtitles-param-prop": "Quais propriedades obter:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "Adiciona o timestamp de quando a proteção foi adicionada.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Adiciona o usuário que adicionou a proteção.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Adiciona a ID do usuário que adicionou a proteção.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Adiciona o comentário para a proteção.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "Adiciona o comentário analisado para a proteção.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "Adiciona o timestamp de quando a proteção será encerrada.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Adicionar o nível de proteção.",
+ "apihelp-query+protectedtitles-example-simple": "Listar títulos protegidos.",
+ "apihelp-query+protectedtitles-example-generator": "Encontre links para títulos protegidos no espaço nominal principal.",
+ "apihelp-query+querypage-summary": "Obter uma lista fornecida por uma página especial baseada em QueryPage.",
+ "apihelp-query+querypage-param-page": "O nome da página especial. Note, isso diferencia maiúsculas de minúsculas.",
+ "apihelp-query+querypage-param-limit": "Número de resultados a se retornado.",
+ "apihelp-query+querypage-example-ancientpages": "Retorna resultados de [[Special:Ancientpages]].",
+ "apihelp-query+random-summary": "Obter um conjunto de páginas aleatórias.",
+ "apihelp-query+random-extended-description": "As páginas são listadas em uma sequência fixa, apenas o ponto de partida é aleatório. Isso significa que, se, por exemplo, <samp>Main Page</samp> é a primeira página aleatória da lista, <samp>List of fictional monkeys</samp> será <em>sempre</em> a segunda, <samp>List of people on stamps of Vanuatu</samp> terceiro, etc.",
+ "apihelp-query+random-param-namespace": "Retorne páginas apenas nesses espaços nominais.",
+ "apihelp-query+random-param-limit": "Limita quantas páginas aleatórias serão retornadas.",
+ "apihelp-query+random-param-redirect": "Use <kbd>$1filterredir=redirects</kbd> em vez.",
+ "apihelp-query+random-param-filterredir": "Como filtrar por redirecionamentos.",
+ "apihelp-query+random-example-simple": "Retorna duas páginas aleatórias do espaço nominal principal.",
+ "apihelp-query+random-example-generator": "Retorna informações da página sobre duas páginas aleatórias do espaço nominal principal.",
+ "apihelp-query+recentchanges-summary": "Enumere as mudanças recentes.",
+ "apihelp-query+recentchanges-param-start": "A data a partir da qual começar a enumeração.",
+ "apihelp-query+recentchanges-param-end": "O timestamp para terminar de enumerar.",
+ "apihelp-query+recentchanges-param-namespace": "Filtrar apenas as mudanças destes espaços nominais.",
+ "apihelp-query+recentchanges-param-user": "Listar apenas alterações deste usuário.",
+ "apihelp-query+recentchanges-param-excludeuser": "Não listar as alterações deste usuário.",
+ "apihelp-query+recentchanges-param-tag": "Listar apenas as alterações marcadas com esta etiqueta.",
+ "apihelp-query+recentchanges-param-prop": "Incluir elementos de informação adicional:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "Adiciona o usuário responsável pela edição e marca se ele é um IP.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "Adiciona o ID do usuário responsável pela edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Adiciona o comentário para a edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "Adiciona o comentário analisado para a edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Adiciona etiquetas para a edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Adiciona o timestamp da edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Adiciona o título da página da edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "Adiciona o ID da página, das alterações recentes e dA revisão nova e antiga.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Adiciona o comprimento novo e antigo da página em bytes.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "Etiqueta a edição se a página é um redirecionamento.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Etiquete edições patrulháveis como sendo patrulhadas ou não-patrulhadas.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Adiciona informações de registro (ID de registro, tipo de registro, etc.) às entradas do log.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Listar as etiquetas para a entrada.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Adiciona o checksum do conteúdo para entradas associadas a uma revisão.",
+ "apihelp-query+recentchanges-param-token": "Use <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> em vez.",
+ "apihelp-query+recentchanges-param-show": "Mostre apenas itens que atendam a esses critérios. Por exemplo, para ver apenas edições menores feitas por usuários conectados, set $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "Quantas alterações a serem retornadas.",
+ "apihelp-query+recentchanges-param-type": "Quais tipos de mudanças mostrar.",
+ "apihelp-query+recentchanges-param-toponly": "Somente lista as alterações que são as últimas revisões.",
+ "apihelp-query+recentchanges-param-generaterevisions": "Quando usado como gerador, gere IDs de revisão em vez de títulos. As entradas de alterações recentes sem IDs de revisão associadas (por exemplo, a maioria das entradas de log) não gerarão nada.",
+ "apihelp-query+recentchanges-example-simple": "Listar mudanças recentes.",
+ "apihelp-query+recentchanges-example-generator": "Obter informações da página sobre as mudanças recentes não patrulhadas.",
+ "apihelp-query+redirects-summary": "Retorna todos os redirecionamentos para as páginas indicadas.",
+ "apihelp-query+redirects-param-prop": "Quais propriedades obter:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "ID de cada redirecionamento.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Título de cada redirecionamento.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "Fragmento de cada redirecionamento, se há algum.",
+ "apihelp-query+redirects-param-namespace": "Listar apenas páginas neste espaço nominal.",
+ "apihelp-query+redirects-param-limit": "Quantos redirecionamentos a serem retornados.",
+ "apihelp-query+redirects-param-show": "Mostrar apenas itens que atendam a esses critérios:\n; fragment: mostra apenas redirecionamentos com um fragmento.\n;!fragment: mostra apenas redirecionamentos sem um fragmento.",
+ "apihelp-query+redirects-example-simple": "Obter uma lista de redirecionamento para [[Main Page]].",
+ "apihelp-query+redirects-example-generator": "Obter informações sobre todos os redirecionamentos para a [[Main Page]].",
+ "apihelp-query+revisions-summary": "Obter informações de revisão.",
+ "apihelp-query+revisions-extended-description": "Pode ser usado de várias maneiras:\n#Obter dados sobre um conjunto de páginas (última revisão), definindo títulos ou pageids.\n# Obter revisões para uma página determinada, usando títulos ou pageids com início, fim ou limite.\n# Obter dados sobre um conjunto de revisões, definindo seus IDs com revids.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Só pode ser usado com uma única página (modo #2).",
+ "apihelp-query+revisions-param-startid": "Comece a enumeração do timestamp desta revisão. A revisão deve existir, mas não precisa pertencer a esta página.",
+ "apihelp-query+revisions-param-endid": "Pare a enumeração no timestamp desta revisão. A revisão deve existir, mas não precisa pertencer a esta página.",
+ "apihelp-query+revisions-param-start": "De qual timestamp de revisão iniciar a enumeração.",
+ "apihelp-query+revisions-param-end": "Enumerar até este timestamp.",
+ "apihelp-query+revisions-param-user": "Somente incluir revisões feitas pelo usuário.",
+ "apihelp-query+revisions-param-excludeuser": "Excluir revisões feitas pelo usuário.",
+ "apihelp-query+revisions-param-tag": "Lista apenas as revisões com esta tag.",
+ "apihelp-query+revisions-param-token": "Que tokens obter para cada revisão.",
+ "apihelp-query+revisions-example-content": "Obter dados com conteúdo para a última revisão de títulos <kbd>API</kbd> e <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "Mostrar as 5 últimas revisões da <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5": "Mostrar as 5 primeiras revisões da <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Mostrar as 5 primeiras revisões da <kbd>Main Page</kbd> feitas depois de 05/01/2006.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Mostrar as 5 primeiras revisões da <kbd>Main Page</kbd> que não foram feitas pelo usuário anônimo <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "Mostrar as 5 primeiras revisões da <kbd>Main Page</kbd> que foram feitas pelo usuário <kbd>MediaWiki default</kbd>.",
+ "apihelp-query+revisions+base-param-prop": "Quais propriedades mostrar para cada modificação:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "O ID da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Etiqueta de revisão (menor).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "O timestamp da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Usuário que fez a revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "ID de usuário do criador da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Comprimento (bytes) da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1 (base 16) da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "ID do modelo de conteúdo da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "Comentário do usuário para a revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "Analisar comentário do usuário para a revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Texto da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Etiquetas para a revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">Obsoleto.</span> Use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> ou <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> em vez. A árvore de análise XML de conteúdo da revisão (requer o modelo de conteúdo <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "Limita quantas revisões serão retornadas.",
+ "apihelp-query+revisions+base-param-expandtemplates": "Use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> em vez disso. Expande predefinições no conteúdo de revisão (requer $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "Use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> ou <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> em vez disso. Gerar árvore de analise XML para o conteúdo de revisão (requer $1prop=content).",
+ "apihelp-query+revisions+base-param-parse": "Use <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd> em vez disso. Analisa o conteúdo da revisão (requer $1prop=content). Por motivos de desempenho, se esta opção for usada, $1limit é definindo para 1.",
+ "apihelp-query+revisions+base-param-section": "Apenas recuperar o conteúdo deste número de seção.",
+ "apihelp-query+revisions+base-param-diffto": "Use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> em vez disso. ID de revisão para diff cada revisão. Use <kbd>prev</kbd>, <kbd>next</kbd> e <kbd>cur</kbd> para a revisão anterior, próxima e atual, respectivamente.",
+ "apihelp-query+revisions+base-param-difftotext": "Use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> em vez disso. Texto para diff cada revisão. Apenas diff um número limitado de revisões. Substitui <var>$1diffto</var>. Se <var>$1section</var> estiver definido, apenas essa seção será diferente desse texto.",
+ "apihelp-query+revisions+base-param-difftotextpst": "Use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd> em vez disso. Executa uma transformação pré-salvar no texto antes de o difundir. Apenas válido quando usado com <var>$1difftotext</var>.",
+ "apihelp-query+revisions+base-param-contentformat": "Formato de serialização usado para <var>$1difftotext</var> e esperado para saída de conteúdo.",
+ "apihelp-query+search-summary": "Fazer uma buscar completa de texto.",
+ "apihelp-query+search-param-search": "Procura por títulos de páginas ou conteúdo que corresponda a este valor. Você pode usar a sequência de pesquisa para invocar recursos de pesquisa especiais, dependendo do que implementa o backend de pesquisa da wiki.",
+ "apihelp-query+search-param-namespace": "Procure apenas nesses espaços de nominais.",
+ "apihelp-query+search-param-what": "Qual tipo de pesquisa realizada.",
+ "apihelp-query+search-param-info": "Quais metadados retornar.",
+ "apihelp-query+search-param-prop": "Que propriedades retornar:",
+ "apihelp-query+search-param-qiprofile": "Perfil independente da consulta para usar (afeta o algoritmo de classificação).",
+ "apihelp-query+search-paramvalue-prop-size": "Adiciona o tamanho da página em bytes.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Adiciona a contagem de palavras da página.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Adiciona a marcação de data (timestamp) de quando a página foi editada pela última vez.",
+ "apihelp-query+search-paramvalue-prop-snippet": "Adiciona um fragmento analisado da página.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "Adiciona um fragmento analisado do título da página.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "Adiciona um fragmento analisado do redirecionamento do título.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Adiciona o título do redirecionamento correspondente.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "Adiciona um parsed snippet do título da seção correspondente.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Adiciona o título da seção correspondente.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "Adiciona um parsed snippet da categoria correspondente.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "Adiciona um booleano que indica se a pesquisa corresponde ao conteúdo do arquivo.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignorado.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado.",
+ "apihelp-query+search-param-limit": "Quantas páginas retornar.",
+ "apihelp-query+search-param-interwiki": "Inclua resultados de interwiki na pesquisa, se disponível.",
+ "apihelp-query+search-param-backend": "Qual o backend de pesquisa a ser usado, se não for o padrão.",
+ "apihelp-query+search-param-enablerewrites": "Habilita a reescrita de consulta interna. Alguns backends de pesquisa podem reescrever a consulta em outro que é pensado para fornecer melhores resultados, por exemplo, corrigindo erros de ortografia.",
+ "apihelp-query+search-example-simple": "Procurar por <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Procurar textos para <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-generator": "Obter informações da página sobre as páginas retornadas para uma pesquisa por <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "Retorna informações gerais sobre o site.",
+ "apihelp-query+siteinfo-param-prop": "Quais informação obter:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Informação geral do sistema.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "Lista de espaços nominais registrados e seus nomes canônicos.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Lista de aliases dos espaços nominais registrados.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Lista de alias de página especiais.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Lista de palavras mágicas e seus alias.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Voltar às estatísticas do site.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "Retorna o mapa interwiki (opcionalmente filtrado, opcionalmente localizado usando <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "Retorna o servidor de banco de dados com o atraso de replicação mais alto.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Retorna os grupos de usuários e as permissões associadas.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Retorna as bibliotecas instaladas na wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Retorna as extensões instaladas na wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Retorna um lista de extensões de arquivo (tipos de arquivo) permitidos para serem carregados.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Retorna a informação sobre os direitos wiki (licença), se disponível.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Retorna informações sobre os tipos de restrição (proteção) disponíveis.",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "Retorna uma lista de idiomas suportada pelo MediaWiki (opcionalmente localizada usando <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Retorna uma lista de códigos de idioma para os quais [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]] está ativado e as variantes suportadas para cada um.",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "Retorna uma lista de todas as skins protegidas (opcionalmente localizadas usando <var>$1inlanguagecode</var>, caso contrário no idioma do conteúdo).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "Retorna uma lista de tags de extensão do analisador.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "Retorna uma lista de ganchos de função do analisador.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "Retorna uma lista de todos os ganchos subscritos (conteúdo de <var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Retorna uma lista de IDs variáveis.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "Retorna uma lista de protocolos que são permitidos em links externos.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Retorna os valores padrão para as preferências do usuário.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "Retorna a configuração da caixa de diálogo de upload.",
+ "apihelp-query+siteinfo-param-filteriw": "Retorna apenas entradas locais ou únicas não locais do mapa interwiki.",
+ "apihelp-query+siteinfo-param-showalldb": "Liste todos os servidores de banco de dados, e não apenas o que está atrasando.",
+ "apihelp-query+siteinfo-param-numberingroup": "Listar o número de usuários nos grupos de usuário.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "Código de idioma para nomes de idiomas localizados (melhor esforço) e nomes de skin.",
+ "apihelp-query+siteinfo-example-simple": "Obter informação do site.",
+ "apihelp-query+siteinfo-example-interwiki": "Obtenha uma lista de prefixos interwiki locais.",
+ "apihelp-query+siteinfo-example-replag": "Verificar o atraso de replicação atual.",
+ "apihelp-query+stashimageinfo-summary": "Retorna a informação do arquivo para arquivos stashed.",
+ "apihelp-query+stashimageinfo-param-filekey": "Chave que identifica um upload anterior que foi temporariamente armazenado.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Apelido para $1filekey, para compatibilidade com versões anteriores.",
+ "apihelp-query+stashimageinfo-example-simple": "Retorna informações de um arquivo stashed.",
+ "apihelp-query+stashimageinfo-example-params": "Retorna as miniaturas para dois arquivos stashed.",
+ "apihelp-query+tags-summary": "Lista etiquetas da modificação.",
+ "apihelp-query+tags-param-limit": "O número máximo de tags a serem listadas.",
+ "apihelp-query+tags-param-prop": "Quais propriedades obter:",
+ "apihelp-query+tags-paramvalue-prop-name": "Adiciona o nome da tag.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Adiciona mensagem do sistema para a tag.",
+ "apihelp-query+tags-paramvalue-prop-description": "Adiciona descrição da tag.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Adiciona o número de revisões e entradas do log que tem esta tag.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Indique se a etiqueta está definida.",
+ "apihelp-query+tags-paramvalue-prop-source": "Obtém as fontes da etiqueta, que podem incluir <samp>extension</samp> para tags definidas em extensão e <samp>extension</samp> para tags que podem ser aplicadas manualmente pelos usuários.",
+ "apihelp-query+tags-paramvalue-prop-active": "Se a tag ainda está sendo aplicada.",
+ "apihelp-query+tags-example-simple": "Listar as tags disponíveis.",
+ "apihelp-query+templates-summary": "Mostrar apenas as alterações nas páginas associadas desta página.",
+ "apihelp-query+templates-param-namespace": "Mostra as predefinições neste espaços nominais apenas.",
+ "apihelp-query+templates-param-limit": "Quantas predefinições retornar.",
+ "apihelp-query+templates-param-templates": "Apenas liste essas predefinições. Útil para verificar se uma determinada página usa uma determinada predefinição.",
+ "apihelp-query+templates-param-dir": "A direção na qual listar.",
+ "apihelp-query+templates-example-simple": "Obter predefinições usadas na página <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-generator": "Obter informações sobre as páginas de predefinições usada na <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-namespaces": "Obter páginas nos espaços nominais {{ns: user}} e {{ns: template}} que são transcluídos na página <kbd>Main Page</kbd>.",
+ "apihelp-query+tokens-summary": "Obtém tokens para ações de modificação de dados.",
+ "apihelp-query+tokens-param-type": "Tipos de token para solicitar.",
+ "apihelp-query+tokens-example-simple": "Recupere um token csrf (o padrão).",
+ "apihelp-query+tokens-example-types": "Recupere um token de vigilância e um token de patrulha.",
+ "apihelp-query+transcludedin-summary": "Encontre todas as páginas que transcluam as páginas dadas.",
+ "apihelp-query+transcludedin-param-prop": "Quais propriedades obter:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "ID de cada página.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "O título de cada página.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Sinalizar se a página é um redirecionamento.",
+ "apihelp-query+transcludedin-param-namespace": "Listar apenas páginas neste espaço nominal.",
+ "apihelp-query+transcludedin-param-limit": "Quantos retornar.",
+ "apihelp-query+transcludedin-param-show": "Mostre apenas itens que atendam a esses critérios.\n;redirect:Apenas mostra redirecionamentos.\n;!redirect:Não mostra redirecionamentos.",
+ "apihelp-query+transcludedin-example-simple": "Obter uma lista de páginas que transcluem <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Obter informações sobre páginas que transcluem <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-summary": "Obtêm todas as edições de um usuário.",
+ "apihelp-query+usercontribs-param-limit": "O número máximo de contribuições para retornar.",
+ "apihelp-query+usercontribs-param-start": "O timestamp de início para retornar.",
+ "apihelp-query+usercontribs-param-end": "O timestamp final para retornar.",
+ "apihelp-query+usercontribs-param-user": "Os usuários dos quais recuperar contribuições. Não pode ser usado com <var>$1userids</var> ou <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "Recupera contribuições para todos os usuários cujos nomes começam com esse valor. Não pode ser usado com <var>$1user</var> ou <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "As IDs de usuário das quais recuperar as contribuições. Não pode ser usado com<var>$1user</var> ou <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "Apenas lista as contribuições nesses espaços nominais.",
+ "apihelp-query+usercontribs-param-prop": "Incluir elementos de informação adicional:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Adiciona o ID da página e revisão.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Adiciona o título e o ID do espaço nominal da página.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Adiciona o timestamp da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Adiciona o comentário da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Adiciona o comentário analisado da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Adiciona o novo tamanho da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Adiciona o tamanho delta da edição contra o seu pai.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Adiciona etiqueta da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Etiquetas de edições patrulhadas.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Lista as tags para editar.",
+ "apihelp-query+usercontribs-param-show": "Mostre apenas itens que atendam a esses critérios, por exemplo, apenas edições não-menores: <kbd>$2show=!minor</kbd>.\n\nSe <kbd>$2show=patrolled</kbd> ou <kbd>$2show=!patrolled</kbd> estiver definido, revisões mais antigas do que <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var> ($1 {{PLURAL:$1|segundo|segundos}}) não serão exibidas.",
+ "apihelp-query+usercontribs-param-tag": "Lista apenas as revisões com esta tag.",
+ "apihelp-query+usercontribs-param-toponly": "Somente lista as alterações que são as últimas revisões.",
+ "apihelp-query+usercontribs-example-user": "Mostra as contribuições do usuário <kbd>Example</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "Mostrar contribuições de todos os endereços IP com o prefixo <kbd>192.0.2.</Kbd>.",
+ "apihelp-query+userinfo-summary": "Ober informações sobre o usuário atual.",
+ "apihelp-query+userinfo-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Etiqueta se o usuário atual está bloqueado, por quem e por que motivo.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Adiciona a tag <samp>messages</samp> se o usuário atual tiver mensagens pendentes.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Lista todos os grupos aos quais o usuário atual pertence.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Lista grupos aos quais o usuário atual foi explicitamente designado, incluindo a data de expiração de cada associação de grupo.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Lista todos os grupos aos quais o usuário atual é automaticamente membro.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Lista todos os direitos que o usuário atual possui.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Lista os grupos aos quais o usuário atual pode adicionar e remover.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Lista todas as preferências que o usuário atual estabeleceu.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Obter um token para alterar as preferências do usuário atual.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Adiciona a contagem de edições do usuário atual.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Lista todos os limites de taxa aplicáveis ao usuário atual.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Adiciona o nome real do usuário.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Adiciona o endereço de e-mail e a data de autenticação do e-mail.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Ecoa o cabeçalho <code>Accept-Language</code> enviado pelo cliente em um formato estruturado.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Adiciona a data de registro do usuário.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Adiciona a contagem de páginas não lidas na lista de páginas vigiadas do usuário (máximo $1; retorna <samp>$2</samp> se mais).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "Adiciona os IDs centrais e o status do anexo do usuário.",
+ "apihelp-query+userinfo-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indique se o usuário está conectado com a wiki identificada por este ID.",
+ "apihelp-query+userinfo-example-simple": "Ober informações sobre o usuário atual.",
+ "apihelp-query+userinfo-example-data": "Obter informações adicionais sobre o usuário atual.",
+ "apihelp-query+users-summary": "Obter informação sobre uma lista de usuários.",
+ "apihelp-query+users-param-prop": "Quais peças de informação incluir:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Etiqueta se o usuário estiver bloqueado, por quem e por que motivo.",
+ "apihelp-query+users-paramvalue-prop-groups": "Lista todos os grupos aos quais cada usuário pertence.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "Lista grupos aos quais cada usuário foi explicitamente designado, incluindo a data de expiração de cada associação de grupo.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Lista todos os grupos aos quais um usuário é automaticamente membro.",
+ "apihelp-query+users-paramvalue-prop-rights": "Lista todos os direitos que cada usuário possui.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Adiciona a contagem de edição do usuário.",
+ "apihelp-query+users-paramvalue-prop-registration": "Adiciona o timestamp de registro do usuário.",
+ "apihelp-query+users-paramvalue-prop-emailable": "Etiquetar se o usuário pode e deseja receber e-mails através de [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "Etiqueta o gênero do usuário. Retorna \"male\", \"female\" ou \"unknown\".",
+ "apihelp-query+users-paramvalue-prop-centralids": "Adiciona os IDs centrais e o status do anexo do usuário.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Indica se uma conta para nomes de usuário válidos mas não registrados pode ser criada.",
+ "apihelp-query+users-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indique se o usuário está conectado com a wiki identificada por este ID.",
+ "apihelp-query+users-param-users": "Uma lista de usuários dos quais obter informações.",
+ "apihelp-query+users-param-userids": "Uma lista de IDs de usuários dos quais obter informações.",
+ "apihelp-query+users-param-token": "Use <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> em vez.",
+ "apihelp-query+users-example-simple": "Retornar informações para o usuário <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-summary": "Obter alterações recentes nas páginas da lista de páginas vigiadas do usuário atual.",
+ "apihelp-query+watchlist-param-allrev": "Inclua várias revisões da mesma página dentro de um prazo determinado.",
+ "apihelp-query+watchlist-param-start": "A data a partir da qual começar a enumeração.",
+ "apihelp-query+watchlist-param-end": "O timestamp para terminar de enumerar.",
+ "apihelp-query+watchlist-param-namespace": "Filtrar apenas as mudanças dos espaços nominais dados.",
+ "apihelp-query+watchlist-param-user": "Listar apenas alterações deste usuário.",
+ "apihelp-query+watchlist-param-excludeuser": "Não listar as alterações deste usuário.",
+ "apihelp-query+watchlist-param-limit": "Quantos resultados retornar por solicitação.",
+ "apihelp-query+watchlist-param-prop": "Quais propriedades adicionais obter:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Adiciona o ID de revisão e de página.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Adiciona o título da página.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Adiciona etiquetas para a edição.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Adiciona o usuário que fez a edição.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Adiciona o ID de usuário de quem fez a edição.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Adicionar comentário à edição.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Adiciona o comentário analisado da edição.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Adiciona o timestamp da edição.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Edições de tags que são patrulhadas.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Adiciona os velhos e novos comprimentos da página.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Adiciona o timestamp de quando o usuário foi notificado pela última vez sobre a edição.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Adiciona informações de log, quando apropriado.",
+ "apihelp-query+watchlist-param-show": "Mostre apenas itens que atendam a esses critérios. Por exemplo, para ver apenas edições menores feitas por usuários conectados, set $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "Quais tipos de mudanças mostrar:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Edições comuns nas páginas.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Alterações externas.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Criação de páginas.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Registro de entradas.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Alterações de membros pertencentes à uma categoria.",
+ "apihelp-query+watchlist-param-owner": "Usado juntamente com $1 para acessar a lista de páginas vigiadas de um usuário diferente.",
+ "apihelp-query+watchlist-param-token": "Um token seguro (disponível nas [[Special:Preferences#mw-prefsection-watchlist|preferências]] do usuário) para permitir o acesso à lista de páginas vigiadas de outro usuário.",
+ "apihelp-query+watchlist-example-simple": "Liste a revisão superior para páginas recentemente alteradas na lista de páginas vigiadas do usuário atual.",
+ "apihelp-query+watchlist-example-props": "Obtenha informações adicionais sobre a revisão superior das páginas alteradas recentemente na lista de páginas vigiadas do usuário atual.",
+ "apihelp-query+watchlist-example-allrev": "Obtenha informações sobre todas as mudanças recentes nas páginas da lista de páginas vigiadas do usuário atual.",
+ "apihelp-query+watchlist-example-generator": "Obtenha informações de página para páginas recentemente alteradas na lista de páginas vigiadas do usuário atual.",
+ "apihelp-query+watchlist-example-generator-rev": "Obtenha informações de revisão para as mudanças recentes nas páginas da lista de páginas vigiadas do usuário atual.",
+ "apihelp-query+watchlist-example-wlowner": "Listar a revisão superior para páginas alteradas recentemente na lista de páginas vigiadas do usuário <kbd>Exemplo</ kbd>.",
+ "apihelp-query+watchlistraw-summary": "Obtenha todas as páginas da lista de páginas vigiadas do usuário atual.",
+ "apihelp-query+watchlistraw-param-namespace": "Listar apenas páginas dos espaços nominais dados.",
+ "apihelp-query+watchlistraw-param-limit": "Quantos resultados retornar por solicitação.",
+ "apihelp-query+watchlistraw-param-prop": "Quais propriedades adicionais obter:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Adiciona o timestamp de quando o usuário foi notificado pela última vez sobre a edição.",
+ "apihelp-query+watchlistraw-param-show": "Listar apenas itens que atendam a esses critérios.",
+ "apihelp-query+watchlistraw-param-owner": "Usado juntamente com $1 para acessar a lista de páginas vigiadas de um usuário diferente.",
+ "apihelp-query+watchlistraw-param-token": "Um token seguro (disponível nas [[Special:Preferences#mw-prefsection-watchlist|preferências]] do usuário) para permitir o acesso à lista de páginas vigiadas de outro usuário.",
+ "apihelp-query+watchlistraw-param-dir": "A direção na qual listar.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Título (com prefixo do espaço nominal) do qual começar a enumerar.",
+ "apihelp-query+watchlistraw-param-totitle": "Título (com prefixo do espaço nominal) do qual parar de enumerar.",
+ "apihelp-query+watchlistraw-example-simple": "Listar páginas da lista de páginas vigiadas do usuário atual.",
+ "apihelp-query+watchlistraw-example-generator": "Obtenha informações de página para páginas na lista de páginas vigiadas do usuário atual.",
+ "apihelp-removeauthenticationdata-summary": "Remova os dados de autenticação para o usuário atual.",
+ "apihelp-removeauthenticationdata-example-simple": "Tente remover os dados do usuário atual para <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "Envia um e-mail de redefinição de senha para o usuário atual.",
+ "apihelp-resetpassword-extended-description-noroutes": "Não há rotas de redefinição de senha disponíveis.\n\nAtive rotas em <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var> para usar este módulo.",
+ "apihelp-resetpassword-param-user": "Usuário sendo reiniciado.",
+ "apihelp-resetpassword-param-email": "Endereço de e-mail do usuário sendo redefinido.",
+ "apihelp-resetpassword-example-user": "Envia um e-mail de redefinição de senha para o usuário <kbd>Example</kbd>.",
+ "apihelp-resetpassword-example-email": "Envia um e-mail de redefinição de senha para todos os usuários com e-mail <kbd>user@example.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Excluir e recuperar revisões.",
+ "apihelp-revisiondelete-param-type": "Tipo de exclusão de revisão em execução.",
+ "apihelp-revisiondelete-param-target": "Título da página para a eliminação da revisão, se necessário para o tipo.",
+ "apihelp-revisiondelete-param-ids": "Identificadores para as revisões a serem excluídas.",
+ "apihelp-revisiondelete-param-hide": "O que ocultar para cada revisão.",
+ "apihelp-revisiondelete-param-show": "O que exibir para cada revisão.",
+ "apihelp-revisiondelete-param-suppress": "Seja para suprimir dados de administradores, bem como de outros.",
+ "apihelp-revisiondelete-param-reason": "Razão para a exclusão ou recuperação.",
+ "apihelp-revisiondelete-param-tags": "Etiquetas para se inscrever na entrada no registo de eliminação.",
+ "apihelp-revisiondelete-example-revision": "Ocultar conteúdo da revisão <kbd>12345</kbd> na página <kbd>Main Page</kbd>.",
+ "apihelp-revisiondelete-example-log": "Ocultar todos os dados na entrada de log <kbd>67890</kbd> com razão <kbd>BLP violation</kbd>.",
+ "apihelp-rollback-summary": "Desfazer a última edição para a página.",
+ "apihelp-rollback-extended-description": "Se o último usuário que editou a página efetuou várias edições consecutivas, todas serão revertidas.",
+ "apihelp-rollback-param-title": "Título da página para reverter. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "ID da página para reverter. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "Tags para aplicar ao rollback.",
+ "apihelp-rollback-param-user": "Nome do usuário cujas edições devem ser revertidas.",
+ "apihelp-rollback-param-summary": "Resumo de edição personalizado. Se estiver vazio, o resumo padrão será usado.",
+ "apihelp-rollback-param-markbot": "Marca as edições revertidas e a reversão como edições de bot.",
+ "apihelp-rollback-param-watchlist": "Adicione ou remova incondicionalmente a página da lista de páginas vigiadas do usuário atual, use preferências ou não mude a vigilância.",
+ "apihelp-rollback-example-simple": "Reverter as últimas edições de página <kbd>Main Page</kbd> pelo usuário <kbd>Example</kbd>.",
+ "apihelp-rollback-example-summary": "Reverter as últimas edições de página <kbd>Main Page</kbd> pelo IP <kbd>192.0.2.5</kbd> com resumo <kbd>Reverting vandalism</kbd> e marque essas edições e reversões como edições de bot.",
+ "apihelp-rsd-summary": "Exportar um esquema RSD (Really Simple Discovery).",
+ "apihelp-rsd-example-simple": "Exportar o esquema RSD.",
+ "apihelp-setnotificationtimestamp-summary": "Atualize o timestamp de notificação para páginas vigiadas.",
+ "apihelp-setnotificationtimestamp-extended-description": "Isso afeta o destaque das páginas alteradas na lista de exibição e no histórico e o envio de e-mail quando a preferência \"{{int:tog-enotifwatchlistpages}}\" estiver habilitada.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Trabalhar em todas as páginas vigiadas.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "Timestamp para o qual definir o timestamp de notificação.",
+ "apihelp-setnotificationtimestamp-param-torevid": "Revisão para definir o timestamp de notificação para (apenas uma página).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "Revisão para definir o timestamp de notificação mais recente do que (apenas uma página).",
+ "apihelp-setnotificationtimestamp-example-all": "Redefinir o status da notificação para toda a lista de páginas vigiadas.",
+ "apihelp-setnotificationtimestamp-example-page": "Redefinir o status de notificação para a <kbd>Main page</kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "Define o timestamp da notificação para <kbd>Main page</kbd> para que todas as edições a partir de 1 de janeiro de 2012 não sejam visualizadas.",
+ "apihelp-setnotificationtimestamp-example-allpages": "Restaura o status de notificação para páginas no espaço nominal <kbd>{{ns:user}}</kbd>.",
+ "apihelp-setpagelanguage-summary": "Mudar o idioma de uma página.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Mudar o idioma de uma página não é permitido nesta wiki.\n\nAtive <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> para usar esta ação.",
+ "apihelp-setpagelanguage-param-title": "Título da página cujo idioma você deseja alterar. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "ID da página cujo idioma você deseja alterar. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "Código de idioma do idioma para mudar a página para. Usar <kbd>default</kbd> para redefinir a página para o idioma de conteúdo padrão da wiki.",
+ "apihelp-setpagelanguage-param-reason": "Motivo para a mudança.",
+ "apihelp-setpagelanguage-param-tags": "Alterar as tags para aplicar à entrada de log resultante dessa ação.",
+ "apihelp-setpagelanguage-example-language": "Mudar o idioma da <kbd>Main Page</kbd> para Basque.",
+ "apihelp-setpagelanguage-example-default": "Mudar o idioma da página com ID 123 para o idioma padrão da wiki.",
+ "apihelp-stashedit-summary": "Prepare uma edição no cache compartilhado.",
+ "apihelp-stashedit-extended-description": "Isto é destinado a ser usado via AJAX a partir do formulário de edição para melhorar o desempenho da página salvar.",
+ "apihelp-stashedit-param-title": "Título da página que está sendo editada.",
+ "apihelp-stashedit-param-section": "Número da seção. <kbd>0</kbd> para a seção superior, <kbd>new</kbd> para uma nova seção.",
+ "apihelp-stashedit-param-sectiontitle": "O título para uma nova seção.",
+ "apihelp-stashedit-param-text": "Conteúdo da página.",
+ "apihelp-stashedit-param-stashedtexthash": "Hash do conteúdo da página de um stash anterior para usar em vez disso.",
+ "apihelp-stashedit-param-contentmodel": "Modelo de conteúdo do novo conteúdo.",
+ "apihelp-stashedit-param-contentformat": "Formato de serialização de conteúdo usado para o texto de entrada.",
+ "apihelp-stashedit-param-baserevid": "ID de revisão da revisão base.",
+ "apihelp-stashedit-param-summary": "Mudar resumo.",
+ "apihelp-tag-summary": "Adicionar ou remover tags de alteração de revisões individuais ou entradas de log.",
+ "apihelp-tag-param-rcid": "Uma ou mais IDs de alterações recentes a partir das quais adicionar ou remover a etiqueta.",
+ "apihelp-tag-param-revid": "Uma ou mais IDs de revisão a partir das quais adicionar ou remover a etiqueta.",
+ "apihelp-tag-param-logid": "Uma ou mais IDs de entrada de log a partir das quais adicionar ou remover a etiqueta.",
+ "apihelp-tag-param-add": "Tags para adicionar. Apenas as tags manualmente definidas podem ser adicionadas.",
+ "apihelp-tag-param-remove": "Tags para remover. Somente as tags que são definidas manualmente ou completamente indefinidas podem ser removidas.",
+ "apihelp-tag-param-reason": "Motivo para a mudança.",
+ "apihelp-tag-param-tags": "Etiquetas para aplicar à entrada de log que será criada como resultado dessa ação.",
+ "apihelp-tag-example-rev": "Adicionar a tag <kbd>vandalism</kbd> a ID de revisão 123 sem especificar uma razão",
+ "apihelp-tag-example-log": "Remova a tag <kbd>spam</kbd> da ID de entrada de registro 123 com o motivo <kbd>Wrongly applied</kbd>",
+ "apihelp-tokens-summary": "Obter tokens para ações de modificação de dados.",
+ "apihelp-tokens-extended-description": "Este módulo está depreciado em favor de [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-tokens-param-type": "Tipos de token para solicitar.",
+ "apihelp-tokens-example-edit": "Recupera um token de edição (o padrão).",
+ "apihelp-tokens-example-emailmove": "Recupere um token de e-mail e um token de movimento.",
+ "apihelp-unblock-summary": "Desbloquear usuário.",
+ "apihelp-unblock-param-id": "ID do bloco para desbloquear (obtido através de <kbd>list=blocks</kbd>). Não pode ser usado em conjunto com <var>$1user</var> ou <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "Nome de usuário, endereço IP ou intervalo de IP para desbloquear. Não pode ser usado em conjunto com <var>$1id</var> ou <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "ID do usuário para desbloquear. Não pode ser usado em conjunto com <var>$1id</var> ou <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "Motivo para o desbloqueio.",
+ "apihelp-unblock-param-tags": "Alterar as tags para se inscrever na entrada no registro de bloqueio.",
+ "apihelp-unblock-example-id": "Desbloquear bloqueio ID #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "Desbloquear o usuário <kbd>Bob</kbd> com o motivo <kbd>Sorry Bob</kbd>.",
+ "apihelp-undelete-summary": "Restaure as revisões de uma página excluída.",
+ "apihelp-undelete-extended-description": "Uma lista de revisões excluídas (incluindo timestamps) pode ser recuperada através de [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]] e uma lista de IDs de arquivo excluídos pode ser recuperada através de [[Special:ApiHelp/query+filearchive|list=filearchive]].",
+ "apihelp-undelete-param-title": "Título da página a ser restaurada.",
+ "apihelp-undelete-param-reason": "Razão para restaurar.",
+ "apihelp-undelete-param-tags": "Alterar as tags para se inscrever na entrada no registro de exclusão.",
+ "apihelp-undelete-param-timestamps": "Timestamps das revisões para restaurar. Se ambos <var>$1timestamps</var> e <var>$1fileids</var> estiverem vazios, tudo será restaurado.",
+ "apihelp-undelete-param-fileids": "IDs das revisões de arquivos para restaurar. Se ambos, <var>$1timestamps</var> e <var>$1fileids</var> estiverem, vazios, tudo será restaurado.",
+ "apihelp-undelete-param-watchlist": "Adicione ou remova incondicionalmente a página da lista de páginas vigiadas do usuário atual, use preferências ou não mude a vigilância.",
+ "apihelp-undelete-example-page": "Restaurar página <kbd>Main Page</kbd>.",
+ "apihelp-undelete-example-revisions": "Recupere duas revisões da página <kbd>Main Page</kbd>.",
+ "apihelp-unlinkaccount-summary": "Remova uma conta de terceiros vinculada ao usuário atual.",
+ "apihelp-unlinkaccount-example-simple": "Tente remover o link do usuário atual para o provedor associado com <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-upload-summary": "Carregue um arquivo ou obtenha o status dos carregamentos pendentes.",
+ "apihelp-upload-extended-description": "Vários métodos estão disponíveis:\n* Carrega o conteúdo do arquivo diretamente, usando o parâmetro <var>$1file</var>.\n* Carrega o arquivo em pedaços, usando os parâmetros <var>$1filesize</var>, <var>$1chunk</var> e <var>$1offset</var>.\n* Tenha o servidor MediaWiki buscando um arquivo de um URL, usando o parâmetro <var>$1url</var>.\n* Complete um carregamento anterior que falhou devido a avisos, usando o parâmetro <var>$1filekey</var>.\nNote que o HTTP POST deve ser feito como um upload de arquivo (ou seja, usando <code>multipart/form-data</code>) ao enviar o <var>$1file</var>.",
+ "apihelp-upload-param-filename": "Nome do arquivo de destino.",
+ "apihelp-upload-param-comment": "Faça o upload do comentário. Também usado como o texto da página inicial para novos arquivos, se <var>$1text</var> não for especificado.",
+ "apihelp-upload-param-tags": "Alterar as tags para aplicar à entrada do log de upload e à revisão da página do arquivo.",
+ "apihelp-upload-param-text": "Texto inicial da página para novos arquivos.",
+ "apihelp-upload-param-watch": "Vigiar esta página.",
+ "apihelp-upload-param-watchlist": "Adicione ou remova incondicionalmente a página da lista de páginas vigiadas do usuário atual, use preferências ou não mude a vigilância.",
+ "apihelp-upload-param-ignorewarnings": "Ignorar quaisquer avisos.",
+ "apihelp-upload-param-file": "Conteúdo do arquivo.",
+ "apihelp-upload-param-url": "URL do qual para buscar o arquivo.",
+ "apihelp-upload-param-filekey": "Chave que identifica um upload anterior que foi temporariamente armazenado.",
+ "apihelp-upload-param-sessionkey": "Igual a $1filekey, mantido para compatibilidade com versões anteriores.",
+ "apihelp-upload-param-stash": "Se configurado, o servidor armazenará o arquivo temporariamente em vez de adicioná-lo ao repositório.",
+ "apihelp-upload-param-filesize": "Tamanho completo do upload.",
+ "apihelp-upload-param-offset": "Deslocamento de pedaços em bytes.",
+ "apihelp-upload-param-chunk": "Conteúdo do pedaço.",
+ "apihelp-upload-param-async": "Tornar as operações de arquivo potencialmente grandes assíncronas quando possível.",
+ "apihelp-upload-param-checkstatus": "Apenas obtenha o status de upload para a chave de arquivo fornecida.",
+ "apihelp-upload-example-url": "Enviar a partir de um URL.",
+ "apihelp-upload-example-filekey": "Complete um upload que falhou devido a avisos.",
+ "apihelp-userrights-summary": "Alterar a associação de um grupo de usuários.",
+ "apihelp-userrights-param-user": "Nome de usuário.",
+ "apihelp-userrights-param-userid": "ID de usuário.",
+ "apihelp-userrights-param-add": "Adiciona o usuário a esses grupos ou, se ele já for membro, atualiza a expiração de sua associação nesse grupo.",
+ "apihelp-userrights-param-expiry": "Expiração de timestamps. Pode ser relativo (por exemplo <kbd>5 meses</kbd> ou <kbd>2 semanas</kbd>) ou absoluto (por exemplo <kbd>2014-09-18T12:34:56Z</kbd>). Se apenas um timestamp for configurado, ele sera usado para todos os grupos passados pelo parâmetro <var>$1add</var>. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> ou <kbd>never</kbd>, para um grupo de usuários que nunca expiram.",
+ "apihelp-userrights-param-remove": "Remover o usuário destes grupos.",
+ "apihelp-userrights-param-reason": "Motivo para a mudança.",
+ "apihelp-userrights-param-tags": "Alterar as tags para se inscrever na entrada no registro de direitos do usuário.",
+ "apihelp-userrights-example-user": "Adicionar o usuário <kbd>FooBot</kbd> ao grupo <kbd>bot</kbd> e remover dos grupos <kbd>sysop</kbd> e <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Adicionar o usuário com a ID <kbd>123</kbd> ao grupo global <kbd>bot</kbd> e remover dos grupos <kbd>sysop</kbd> e <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-expiry": "Adicionar o usuário <kbd>SometimeSysop</kbd> ao grupo <kbd>sysop</kbd> por 1 mês.",
+ "apihelp-validatepassword-summary": "Valide uma senha de acordo as políticas de senha da wiki.",
+ "apihelp-validatepassword-extended-description": "A validade é relatada como <samp>Good</samp> se a senha for aceitável, <samp>Change</samp> se a senha for usada para entrar, mas deve ser alterada, ou <samp>Invalid</samp> se a senha não é utilizável.",
+ "apihelp-validatepassword-param-password": "Senha para validar.",
+ "apihelp-validatepassword-param-user": "Nome do usuário, para uso ao testar a criação da conta. O usuário nomeado não deve existir.",
+ "apihelp-validatepassword-param-email": "Endereço de e-mail, para uso ao testar a criação de conta.",
+ "apihelp-validatepassword-param-realname": "Nome real, para uso ao testar a criação de conta.",
+ "apihelp-validatepassword-example-1": "Valide a senha <kbd>foobar</kbd> para o usuário atual.",
+ "apihelp-validatepassword-example-2": "Valide a senha <kbd>qwerty</kbd> para o usuário <kbd>Example</kbd> criado.",
+ "apihelp-watch-summary": "Adicionar ou remover páginas da lista de páginas vigiadas do usuário atual.",
+ "apihelp-watch-param-title": "A página para (não)vigiar. Use <var>$1titles</var> em vez disso.",
+ "apihelp-watch-param-unwatch": "Se configurado, a página deixara de ser vigiada ao invés de vigiada.",
+ "apihelp-watch-example-watch": "Vigiar a página <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Deixar de vigiar a página <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-generator": "Vigiar as primeiras páginas no espaço nominal principal.",
+ "apihelp-format-example-generic": "Retornar o resultado da consulta no formato $1.",
+ "apihelp-format-param-wrappedhtml": "Retorna o HTML pretty-printed e módulos ResourceLoader associados como um objeto JSON.",
+ "apihelp-json-summary": "Dados de saída em formato JSON.",
+ "apihelp-json-param-callback": "Se especificado, envolve a saída para uma determinada chamada de função. Por segurança, todos os dados específicos do usuário serão restritos.",
+ "apihelp-json-param-utf8": "Se especificado, codifica a maioria (mas não todos) caracteres não-ASCII como UTF-8 em vez de substituí-los por sequências de escape hexadecimais. Padrão quando <var>formatversion</var> não é <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "Se especificado, codifica todos os não-ASCII usando sequências de escape hexadecimais. Padrão quando <var>formatversion</var> é <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Formatação de saída:\n;1:formato compatível com versões anteriores (XML-style booleans, <samp>*</samp> chaves para nós de conteúdo, etc.).\n;2: formato moderno experimental. Detalhes podem ser alterados!\n;mais recente: use o formato mais recente (atualmente <kbd>2</kbd>), pode mudar sem aviso prévio.",
+ "apihelp-jsonfm-summary": "Dados de saída no formato JSON (pretty-print em HTML).",
+ "apihelp-none-summary": "Nenhuma saída.",
+ "apihelp-php-summary": "Dados de saída no formato PHP serializado.",
+ "apihelp-php-param-formatversion": "Formatação de saída:\n;1:formato compatível com versões anteriores (XML-style booleans, <samp>*</samp> chaves para nós de conteúdo, etc.).\n;2: formato moderno experimental. Detalhes podem ser alterados!\n;mais recente: use o formato mais recente (atualmente <kbd>2</kbd>), pode mudar sem aviso prévio.",
+ "apihelp-phpfm-summary": "Dados de saída em formato serializado em PHP (pretty-print em HTML).",
+ "apihelp-rawfm-summary": "Dados de saída, incluindo elementos de depuração, no formato JSON (pretty-print em HTML).",
+ "apihelp-xml-summary": "Dados de saída em formato XML.",
+ "apihelp-xml-param-xslt": "Se especificado, adiciona a página nomeada como uma folha de estilo XSL. O valor deve ser um título no espaço nominal {{ns: MediaWiki}} que termina em <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Se especificado, adiciona um espaço nominal XML.",
+ "apihelp-xmlfm-summary": "Dados de saída em formato XML (impressão bonita em HTML).",
+ "api-format-title": "Resultado da API MediaWiki",
+ "api-format-prettyprint-header": "Esta é a representação HTML do formato $1. O HTML é bom para depuração, mas não é adequado para o uso da aplicação.\n\nEspecifique o parâmetro <var>format</var> para alterar o formato de saída. Para ver a representação não-HTML do formato $1, defina <kbd>format=$2</kbd>.\n\nVeja a [[mw:Special:MyLanguage/API|documentação completa]] ou a [[Special:ApiHelp/main|ajuda da API]] para obter mais informações.",
+ "api-format-prettyprint-header-only-html": "Esta é uma representação HTML destinada a depuração e não é apropriada para o uso da aplicação.\n\nVeja a documentação completa [[mw:Special:MyLanguage/API|complete documentation]] ou a ajuda [[Special:ApiHelp/main|API help]] para maiores informações.",
+ "api-format-prettyprint-header-hyperlinked": "Esta é a representação HTML do formato $1. O HTML é bom para depuração, mas não é adequado para o uso da aplicação.\n\nEspecifique o parâmetro <var>format</var> para alterar o formato de saída. Para ver a representação não-HTML do formato $1, defina [$3 <kbd>format=$2</kbd>].\n\nVeja a [[mw:API|documentação completa]] ou a [[Special:ApiHelp/main|ajuda da API]] para obter mais informações.",
+ "api-format-prettyprint-status": "Essa resposta seria retornada com o status HTTP $1 $2.",
+ "api-login-fail-aborted": "A autenticação requer interação do usuário, que não é suportada por <kbd>action=login</kbd>. Para poder fazer login com <kbd>action=login</kbd>, veja [[Special:BotPasswords]]. Para continuar usando main-account loign, veja <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-aborted-nobotpw": "A autenticação requer interação do usuário, que não é suportada por <kbd>action=login</kbd>. Para fazer loging veja <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-badsessionprovider": "Não é possível fazer o login ao usar $1.",
+ "api-login-fail-sameorigin": "Não é possível iniciar sessão quando a mesma política de origem não é aplicada.",
+ "api-pageset-param-titles": "Uma lista de IDs de título para trabalhar.",
+ "api-pageset-param-pageids": "Uma lista de IDs de página para trabalhar.",
+ "api-pageset-param-revids": "Uma lista de IDs de revisão para trabalhar.",
+ "api-pageset-param-generator": "Obter a lista de páginas para trabalhar executando o módulo de consulta especificado.\n\n<strong>Nota:</strong>Os nomes dos parâmetros do gerador devem ser prefixados com um \"g\", veja exemplos.",
+ "api-pageset-param-redirects-generator": "Resolve automaticamente redirecionamentos em <var>$1titles</var>, <var>$1pageids</var> e <var>$1revids</var> e em páginas retornadas por <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Resolve automaticamente redirecionamentos em <var>$1titles</var>, <var>$1pageids</var> e <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Converte títulos para outras variantes, se necessário. Só funciona se o idioma do conteúdo do wiki suportar a conversão variante. Os idiomas que suportam a conversão variante incluem $1.",
+ "api-help-title": "Ajuda da API MediaWiki",
+ "api-help-lead": "Esta é uma página de documentação da API MediaWiki gerada automaticamente.\n\nDocumentação e exemplos: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Módulo principal",
+ "api-help-undocumented-module": "Nenhuma documentação para o módulo $1.",
+ "api-help-flag-deprecated": "Este módulo é obsoleto.",
+ "api-help-flag-internal": "<strong>Este módulo é interno ou instável.</strong> Sua operação pode mudar sem aviso prévio.",
+ "api-help-flag-readrights": "Este módulo requer direitos de leitura.",
+ "api-help-flag-writerights": "Este módulo requer direitos de gravação.",
+ "api-help-flag-mustbeposted": "Este módulo aceita apenas pedidos POST.",
+ "api-help-flag-generator": "Este módulo pode ser usado como um gerador.",
+ "api-help-source": "Fonte: $1",
+ "api-help-source-unknown": "Fonte: <span class=\"apihelp-unknown\">desconhecida</span>",
+ "api-help-license": "Licença: [[$1|$2]]",
+ "api-help-license-noname": "Licença: [[$1|Ver ligação]]",
+ "api-help-license-unknown": "Licensa: <span class=\"apihelp-unknown\">desconhecida</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parâmetro|Parâmetros}}:",
+ "api-help-param-deprecated": "Obsoleto.",
+ "api-help-param-required": "Este parâmetro é obrigatório.",
+ "api-help-datatypes-header": "Tipos de dados",
+ "api-help-datatypes": "A entrada para MediaWiki deve ser UTF-8 normalizada pelo NFC. O MediaWiki pode tentar converter outra entrada, mas isso pode causar a falha de algumas operações (como [[Special:ApiHelp/edit|editar]] com verificações MD5).\n\nAlguns tipos de parâmetros em solicitações de API precisam de uma explicação adicional:\n;boolean\n:Os parâmetros booleanos funcionam como caixas de seleção HTML: se o parâmetro for especificado, independentemente do valor, é considerado verdadeiro. Para um valor falso, omita o parâmetro inteiramente.\n;timestamp\n: As marcas de tempo podem ser especificadas em vários formatos. É recomendada a data e a hora ISO 8601. Todos os horários estão em UTC, qualquer fuso horário incluído é ignorado.\n:* Data e hora ISO 8601, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (pontuação e <kbd>Z</kbd> são opcionais)\n:* ISO 8601 data e hora com segundos fracionados (ignorados), <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (traços, dois pontos e <kbd>Z</kbd> são opcionais)\n:* Formato MediaWiki, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Formato numérico genérico, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (fuso horário opcional de <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd> ou <kbd>-<var>##</var></kbd> é ignorado)\n:* Formato EXIF, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formato RFC 2822 (o fuso horário pode ser omitido), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formato RFC 850 (fuso horário Pode ser omitido), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* C ctime format, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Segundos desde 1970-01-01T00:00:00Z como um inteiro de 1 a 13 dígitos (excluindo <kbd>0</kbd>)\n:* A string <kbd>now</kbd>\n; valor múltiplo alternativo separador\n: Os parâmetros que levam vários valores são normalmente enviados com os valores separados usando o caractere do pipe, por exemplo <kbd>param=value1|value2</kbd> ou <kbd>param=value1%7Cvalue2</kbd>. Se um valor deve conter o caractere de pipe, use U+001F (separador de unidade) como o separador ''and'' prefixa o valor com U+001F, por exemplo, <kbd>param=%1Fvalue1%1Fvalue2</kbd>.",
+ "api-help-param-type-limit": "Tipo: inteiro ou <kbd>max</kbd>",
+ "api-help-param-type-integer": "Tipo: {{PLURAL:$1|1=inteiro|2=lista de inteiros}}",
+ "api-help-param-type-boolean": "Tipo: boleano ([[Special:ApiHelp/main#main/datatypes|details]])",
+ "api-help-param-type-timestamp": "Tipo: {{PLURAL:$1|1=timestamp|2=lista de timestamps}} ([[Special:ApiHelp/main#main/datatypes|formatos permitidos]])",
+ "api-help-param-type-user": "Tipo: {{PLURAL:$1|1=nome de usuário|2=lista de nomes de usuários}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Um dos seguintes valores|2=Valores (separados com <kbd>{{!}}</kbd> ou [[Special:ApiHelp/main#main/datatypes|alternativos]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Deve estar vazio|Pode estar vazio, ou $2}}",
+ "api-help-param-limit": "Não mais do que $1 permitido.",
+ "api-help-param-limit2": "Não são permitidos mais de $1 ($2 por bots).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=O valor não pode ser inferior a|2=Os valores não podem ser inferiores a}} $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=O valor deve ser maior que|2=Os valores devem ser maiores que}} $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=O valor deve estar entre|2=Os valores devem estar entre}} $2 e $3.",
+ "api-help-param-upload": "Deve ser postado como um upload de arquivo usando multipart/form-data.",
+ "api-help-param-multi-separate": "Valores separados com <kbd>|</kbd> ou [[Special:ApiHelp/main#main/datatypes|alternativas]].",
+ "api-help-param-multi-max": "O número máximo de valores é {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} para bots).",
+ "api-help-param-multi-max-simple": "O número máximo de valores é {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "Para especificar todos os valores, use <kbd>$1</kbd>.",
+ "api-help-param-default": "Padrão: $1",
+ "api-help-param-default-empty": "Padrão: <span class=\"apihelp-empty\">(vazio)</span>",
+ "api-help-param-token": "Um token \"$1\" token recuperado de [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "Para compatibilidade, o token usado na interface web também é aceito.",
+ "api-help-param-disabled-in-miser-mode": "Desabilitado devido a [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]].",
+ "api-help-param-limited-in-miser-mode": "<strong>Nota:</strong> Devido ao [[mw:Special:MyLanguage/Manual:$wgMiserMode|miser mode]], usar isso pode resultar em menos de <var>$1limit</var> resultados antes de continuar; em casos extremos, nenhum resultado pode ser retornado.",
+ "api-help-param-direction": "Em qual direção enumerar:\n;newer: Lista primeiro mais antigo. Nota: $1start deve ser anterior a $1end.\n;older: Lista mais recente primeiro (padrão). Nota: $1start deve ser posterior a $1end.",
+ "api-help-param-continue": "Quando houver mais resultados disponíveis, use isso para continuar.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(sem descrição)</span>",
+ "api-help-examples": "{{PLURAL:$1|Exemplo|Exemplos}}:",
+ "api-help-permissions": "{{PLURAL:$1|Permissão|Permissões}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Concedido a|Concedidos a}}: $2",
+ "api-help-right-apihighlimits": "Use limites mais altos nas consultas da API (consultas lentas: $1; consultas rápidas: $2). Os limites para consultas lentas também se aplicam a parâmetros multivalores.",
+ "api-help-open-in-apisandbox": "<small>[abrir na página de testes]</small>",
+ "api-help-authmanager-general-usage": "O procedimento geral para usar este módulo é:\n# Procure os campos disponíveis de <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> com <kbd>amirequestsfor=$4</kbd> e um token <kbd>$5</kbd> de <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# Apresentar os campos para o usuário e obtenha a sua submissão.\n# Poste em este módulo, fornecendo <var>$1returnurl</var> e quaisquer campos relevantes.\n# Verifique o <samp>status</samp> na resposta.\n#* Se você recebeu <samp>PASS</samp> ou <samp>FAIL</samp>, você terminou. A operação foi bem sucedida ou não.\n#* Se você recebeu <samp>UI</samp>, apresente os novos campos ao usuário e obtenha seu envio. Em seguida, publique neste módulo com <var>$1continue</var> e os campos relevantes sejam definidos e repita a etapa 4.\n#* Se você recebeu <samp>REDIRECT</samp>, direcione o usuário para o <samp>redirecttarget</samp> e aguarde o retorno para <var>$1returnurl</var>. Em seguida, publique neste módulo com <var>$1continue</var> e quaisquer campos passados para o URL de retorno e repita a etapa 4.\n#* Se você recebeu <samp>RESTART</samp>, isso significa que a autenticação funcionou mas não temos uma conta de usuário vinculada. Você pode tratar isso como <samp>UI</samp> ou como <samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-requests": "Utilize apenas estes pedidos de autenticação, pelo <samp>id</samp> retornado de <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> com <kbd>amirequestsfor=$1</kbd> ou de uma resposta anterior deste módulo.",
+ "api-help-authmanagerhelper-request": "Use este pedido de autenticação, pelo <samp>id</ samp> retornado de <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> com <kbd>amirequestsfor=$1</kbd>.",
+ "api-help-authmanagerhelper-messageformat": "Formato a ser usado para retornar mensagens.",
+ "api-help-authmanagerhelper-mergerequestfields": "Fundir informações de campo para todos os pedidos de autenticação em uma matriz.",
+ "api-help-authmanagerhelper-preservestate": "Preserva o estado de uma tentativa de login anterior com falha, se possível.",
+ "api-help-authmanagerhelper-returnurl": "O URL de retorno para fluxos de autenticação de terceiros deve ser absoluto. Isso ou <var>$1continue</var> é necessário.\n\nQuando receber uma resposta <samp>REDIRECT</samp>, você normalmente abrirá um navegador ou uma visão da web para o <samp>redirecttarget</samp> URL para um fluxo de autenticação de terceiros. Quando isso for concluído, o terceiro enviará ao navegador ou a web para este URL. Você deve extrair qualquer consulta ou parâmetros POST do URL e passá-los como uma solicitação <var>$1continue</var> para este módulo de API.",
+ "api-help-authmanagerhelper-continue": "Esse pedido é uma continuação após uma resposta <samp>UI</samp> ou <samp>REDIRECT</samp> anterior. Ou <var>$1returnurl</var> é requerido.",
+ "api-help-authmanagerhelper-additional-params": "Este módulo aceita parâmetros adicionais dependendo dos pedidos de autenticação disponíveis. Use <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> com <kbd>amirequestsfor=$1</kbd> (ou uma resposta anterior deste módulo, se aplicável) para determinar as solicitações disponíveis e os campos que eles usam.",
+ "apierror-allimages-redirect": "Use <kbd>gapfilterredir=nonredirects</kbd> em vez de <var>redirects</var> ao usar <kbd>allimages</kbd> como um gerador.",
+ "apierror-allpages-generator-redirects": "Use <kbd>gapfilterredir=nonredirects</kbd> em vez de <var>redirects</var> ao usar <kbd>allpages</kbd> como um gerador.",
+ "apierror-appendnotsupported": "Não é possível anexar páginas usando o modelo de conteúdo $1.",
+ "apierror-articleexists": "O artigo que você tentou criar já foi criado.",
+ "apierror-assertbotfailed": "Afirmação de que o usuário tem o direito <code>bot</code> falhou.",
+ "apierror-assertnameduserfailed": "Afirmação de que o usuário é \"$1\" falhou.",
+ "apierror-assertuserfailed": "Afirmação de que o usuário está logado falhou.",
+ "apierror-autoblocked": "O seu endereço de IP foi bloqueado automaticamente, porque ele foi usado por um usuário bloqueado.",
+ "apierror-badconfig-resulttoosmall": "O valor de <code>$wgAPIMaxResultSize</code> nesta wiki é muito pequeno para manter a informação básica de resultados.",
+ "apierror-badcontinue": "Parâmetro continue inválido. Você deve passar o valor original retornado pela consulta anterior.",
+ "apierror-baddiff": "O diff não pode ser recuperado. Uma ou ambas as revisões não existem ou você não tem permissão para visualizá-las.",
+ "apierror-baddiffto": "<var>$1diffto</var> deve ser configurado para um número não negativo, <kbd>prev</kbd>, <kbd>next</kbd> ou <kbd>cur</kbd>.",
+ "apierror-badformat-generic": "O formato solicitado $1 não é suportado para o modelo de conteúdo $2.",
+ "apierror-badformat": "O formato solicitado $1 não é suportado para o modelo de conteúdo $2 usado por $3.",
+ "apierror-badgenerator-notgenerator": "O módulo <kbd>$1</kbd> não pode ser usado como um gerador.",
+ "apierror-badgenerator-unknown": "<kbd>generator=$1</kbd> desconhecido.",
+ "apierror-badip": "O parâmetro IP não é válido.",
+ "apierror-badmd5": "O hash MD5 fornecido estava incorreto.",
+ "apierror-badmodule-badsubmodule": "O módulo <kbd>$1</kbd> não possui um submódulo \"$2\".",
+ "apierror-badmodule-nosubmodules": "O módulo <kbd>$1</kbd> não tem submódulos.",
+ "apierror-badparameter": "Valor inválido para o parâmetro <var>$1</var>.",
+ "apierror-badquery": "Consulta inválida.",
+ "apierror-badtimestamp": "Valor \"$2\" inválido para o parâmetro timestamp <var>$1</var>.",
+ "apierror-badtoken": "Token de CSRF inválido.",
+ "apierror-badupload": "O parâmetro de upload do arquivo <var>$1</var> não é um upload de arquivo; Certifique-se de usar <code>multipart/form-data</code> para o seu POST e incluir um nome de arquivo no cabeçalho <code> Content-Disposition</code>.",
+ "apierror-badurl": "Valor \"$2\" não é válido para o parâmetro <var>$1</var> da URL.",
+ "apierror-baduser": "Valor \"$2\" inválido para o parâmetro de usuário <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "U+001F separação de múltiplos valores só pode ser usada para parâmetros de vários valores.",
+ "apierror-bad-watchlist-token": "Foi fornecido um token da lista de páginas vigiadas incorreto. Defina um token correto em [[Special:Preferences]].",
+ "apierror-blockedfrommail": "Você foi bloqueado de enviar e-mail.",
+ "apierror-blocked": "Você foi bloqueado de editar.",
+ "apierror-botsnotsupported": "Esta interface não é suportada por bots.",
+ "apierror-cannot-async-upload-file": "Os parâmetros <var>async</var> e <var>file</var> não podem ser combinados. Se você deseja o processamento assíncrono do seu arquivo carregado, primeiro faça o upload para armazenar (usando o parâmetro <var>stash</var>) e depois publique o arquivo armazenado de forma assíncrona (usando <var>filekey</var> e <var>async</var>).",
+ "apierror-cannotreauthenticate": "Esta ação não está disponível porque sua identidade não pode ser verificada.",
+ "apierror-cannotviewtitle": "Você não tem permissão para ver $1.",
+ "apierror-cantblock-email": "Você não tem permissão para impedir que os usuários enviem e-mails através da wiki.",
+ "apierror-cantblock": "Você não tem permissão para bloquear usuários.",
+ "apierror-cantchangecontentmodel": "Você não tem permissão para mudar o modelo de conteúdo de uma página.",
+ "apierror-canthide": "Você não tem permissão para ocultar nomes de usuários do registro de bloqueios.",
+ "apierror-cantimport-upload": "Você não tem permissão para importar páginas enviadas.",
+ "apierror-cantimport": "Você não tem permissão para importar páginas.",
+ "apierror-cantoverwrite-sharedfile": "O arquivo de destino existe em um repositório compartilhado e você não tem permissão para substituí-lo.",
+ "apierror-cantsend": "Você não está logado, não possui um endereço de e-mail confirmado ou não tem permissão para enviar e-mails para outros usuários, por isso não pode enviar e-mails.",
+ "apierror-cantundelete": "Não foi possível recuperar arquivos: as revisões solicitadas podem não existir ou talvez já tenham sido eliminadas.",
+ "apierror-changeauth-norequest": "Falha ao criar pedido de mudança.",
+ "apierror-chunk-too-small": "O tamanho mínimo do bloco é $1 {{PLURAL:$1|byte|bytes}} para os pedaços não finais.",
+ "apierror-cidrtoobroad": "Os intervalos CIDR $1 maiores que /$2 não são aceitos.",
+ "apierror-compare-no-title": "Não é possível pré-salvar a transformação sem um título. Tente especificar <var>fromtitle</var> ou <var>totitle</var>.",
+ "apierror-compare-relative-to-nothing": "Nenhuma revisão 'from' para <var>torelative</var> para ser relativa à.",
+ "apierror-contentserializationexception": "Falha na serialização de conteúdo: $1",
+ "apierror-contenttoobig": "O conteúdo fornecido excede o limite de tamanho do artigo de $1 {{PLURAL: $1|kilobyte|kilobytes}}.",
+ "apierror-copyuploadbaddomain": "Os uploads por URL não são permitidos deste domínio.",
+ "apierror-copyuploadbadurl": "Envio não permitido a partir deste URL.",
+ "apierror-create-titleexists": "Os títulos existentes não podem ser protegidos com <kbd>create</kbd>.",
+ "apierror-csp-report": "Erro ao processar o relatório CSP: $1.",
+ "apierror-databaseerror": "[$1] Houve um erro na consulta ao banco de dados.",
+ "apierror-deletedrevs-param-not-1-2": "O parâmetro <var>$1</var> não pode ser usado nos modos 1 ou 2.",
+ "apierror-deletedrevs-param-not-3": "O parâmetro <var>$1</var> não pode ser usado no modo 3.",
+ "apierror-emptynewsection": "A criação de novas seções vazias não é possível.",
+ "apierror-emptypage": "Não é permitido criar páginas novas e vazias.",
+ "apierror-exceptioncaught": "[$1] Exceção detectada: $2",
+ "apierror-filedoesnotexist": "Arquivo não existe.",
+ "apierror-fileexists-sharedrepo-perm": "O arquivo de destino existe em um repositório compartilhado. Use o parâmetro <var>ignorewarnings</var> para substituí-lo.",
+ "apierror-filenopath": "Não é possível obter o caminho do arquivo local.",
+ "apierror-filetypecannotberotated": "O tipo de arquivo não pode ser girado.",
+ "apierror-formatphp": "Esta resposta não pode ser representada usando o formato <kbd>format=php</kbd>. Consulte https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "O título para <kbd>$1</kbd> deve ser um arquivo.",
+ "apierror-import-unknownerror": "Erro desconhecido na importação: $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> não pode ser maior que $2 (definido para $3) para bots ou sysops.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> não pode ser maior que $2 (definido para $3) para usuários.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> não pode ser menor que $2 (definido para $3).",
+ "apierror-invalidcategory": "O nome da categoria que você inseriu não é válido.",
+ "apierror-invalid-chunk": "O deslocamento mais o pedaço atual é maior que o tamanho do arquivo reivindicado.",
+ "apierror-invalidexpiry": "Tempo de expiração \"$1\" não válido.",
+ "apierror-invalid-file-key": "Não é uma chave de arquivo válida.",
+ "apierror-invalidlang": "Código de idioma inválido para o parâmetro <var>$1</var>.",
+ "apierror-invalidoldimage": "O parâmetro <var>oldimage</var> possui um formato inválido.",
+ "apierror-invalidparammix-cannotusewith": "O parâmetro <kbd>$1</kbd> não pode ser usado com <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "O parâmetro <kbd>$1</kbd> só pode ser usado com <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd> não pode ser combinado com os parâmetros <var>oldid</var>, <var>pageid</var> ou <var>page</var>. Por favor, use <var>title</var> e <var>text</var>.",
+ "apierror-invalidparammix": "{{PLURAL:$2|Os parâmetros }} $1 não podem ser usado em conjunto.",
+ "apierror-invalidsection": "O parâmetro <var>section</var> deve ser um ID de seção válida ou <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "O hash SHA1Base36 fornecido não é válido.",
+ "apierror-invalidsha1hash": "O hash SHA1 informado não é válido.",
+ "apierror-invalidtitle": "Título incorreto \"$1\".",
+ "apierror-invalidurlparam": "Valor inválido para <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Nome de usuário \"$1\" é inválido.",
+ "apierror-invaliduserid": "O ID de usuário <var>$1</var> não é permitido.",
+ "apierror-maxlag-generic": "Aguardando um servidor de banco de dados: $1 {{PLURAL:$1|segundo|segundos}} atraso.",
+ "apierror-maxlag": "Esperando $2: $1 {{PLURAL: $1|segundo|segundos}} atrasado.",
+ "apierror-mimesearchdisabled": "A pesquisa MIME está desativada no Miser Mode.",
+ "apierror-missingcontent-pageid": "Falta conteúdo para a ID da página $1.",
+ "apierror-missingcontent-revid": "Falta conteúdo para a ID de revisão $1.",
+ "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|O parâmetro|Ao menos um dos parâmetros}} $1 é necessário.",
+ "apierror-missingparam-one-of": "{{PLURAL:$2|O parâmetro|Um dos parâmetros}} $1 é necessário.",
+ "apierror-missingparam": "O parâmetro <var>$1</var> precisa ser definido.",
+ "apierror-missingrev-pageid": "Nenhuma revisão atual da página com ID $1.",
+ "apierror-missingrev-title": "Nenhuma revisão atual do título $1.",
+ "apierror-missingtitle-createonly": "Os títulos em falta só podem ser protegidos com <kbd>create</kbd>.",
+ "apierror-missingtitle": "A página que você especificou não existe.",
+ "apierror-missingtitle-byname": "A página $1 não existe.",
+ "apierror-moduledisabled": "O módulo <kbd>$1</kbd> foi desativado.",
+ "apierror-multival-only-one-of": "{{PLURAL:$3|Somente|Somente um de}} $2 é permitido para parâmetro <var>$1</var>.",
+ "apierror-multival-only-one": "Apenas um valor é permitido para o parâmetro <var> $1</var>.",
+ "apierror-multpages": "<var>$1</var> só pode ser usada com uma única página.",
+ "apierror-mustbeloggedin-changeauth": "Você precisa estar autenticado para alterar dados de autenticação.",
+ "apierror-mustbeloggedin-generic": "Você deve estar logado.",
+ "apierror-mustbeloggedin-linkaccounts": "Você precisa estar autenticado para vincular contas.",
+ "apierror-mustbeloggedin-removeauth": "Você precisa estar autenticado para remover dados de autenticação.",
+ "apierror-mustbeloggedin-uploadstash": "O upload do stash só está disponível para usuários registrados.",
+ "apierror-mustbeloggedin": "Você precisa estar logado para $1.",
+ "apierror-mustbeposted": "O módulo <kbd>$1</kbd> requer uma solicitação POST.",
+ "apierror-mustpostparams": "{{PLURAL:$2|O seguinte parâmetro foi encontrado|Os seguintes parâmetros foram encontrados}} na string de consulta, mas deve estar no corpo POST: $1.",
+ "apierror-noapiwrite": "A edição deste wiki através da API está desabilitada. Certifique-se de que a declaração <code>$wgEnableWriteAPI=true;</code> está incluída no arquivo <code>LocalSettings.php</code>.",
+ "apierror-nochanges": "Nenhuma alteração foi solicitada.",
+ "apierror-nodeleteablefile": "Nenhuma versão antiga do arquivo.",
+ "apierror-no-direct-editing": "A edição direta via API não é suportada para o modelo de conteúdo $1 usado por $2.",
+ "apierror-noedit-anon": "Os usuários anônimos não podem editar páginas.",
+ "apierror-noedit": "Você não tem permissão para editar páginas.",
+ "apierror-noimageredirect-anon": "Os usuários anônimos não podem criar redirecionamentos de imagem.",
+ "apierror-noimageredirect": "Você não tem permissão para criar redirecionamentos de imagens.",
+ "apierror-nosuchlogid": "Não há entrada de log com ID $1.",
+ "apierror-nosuchpageid": "Não há página com ID $1.",
+ "apierror-nosuchrcid": "Não há mudança recente com ID $1.",
+ "apierror-nosuchrevid": "Não há revisão com ID $1.",
+ "apierror-nosuchsection": "Não há seção $1.",
+ "apierror-nosuchsection-what": "Não há seção $1 em $2.",
+ "apierror-nosuchuserid": "Não há usuário com ID $1.",
+ "apierror-notarget": "Você não especificou um alvo válido para esta ação.",
+ "apierror-notpatrollable": "A revisão r$1 não pode ser patrulhada, já que é muito antiga.",
+ "apierror-nouploadmodule": "Módulo de upload não definido.",
+ "apierror-offline": "Não foi possível prosseguir devido a problemas de conectividade de rede. Certifique-se de ter uma conexão à internet e tente novamente.",
+ "apierror-opensearch-json-warnings": "Os avisos não podem ser representados no formato JSON do OpenSearch.",
+ "apierror-pagecannotexist": "O espaço nominal não permite as páginas atuais.",
+ "apierror-pagedeleted": "A página foi excluída desde que você obteve seu timestamp.",
+ "apierror-pagelang-disabled": "Mudar o idioma de uma página não é permitido nesta wiki.",
+ "apierror-paramempty": "O parâmetro <var>$1</var> pode não estar vazio.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> só é suportado por conteúdo wikitext.",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> só é suportado por conteúdo texto wiki, $1 usa conteúdo do modelo $2.",
+ "apierror-pastexpiry": "Tempo de expiração \"$1\" está no passado.",
+ "apierror-permissiondenied": "Você não tem permissão para $1.",
+ "apierror-permissiondenied-generic": "Permissão negada.",
+ "apierror-permissiondenied-patrolflag": "Você precisa do direito <code>patrol</code> ou <code>patrolmarks</code> para requisitar a etiqueta \"patrulhado\".",
+ "apierror-permissiondenied-unblock": "Você não tem permissão para desbloquear usuários.",
+ "apierror-prefixsearchdisabled": "A pesquisa de prefixos está desativada no Miser Mode.",
+ "apierror-promised-nonwrite-api": "O cabeçalho HTTP <code>Promise-Non-Write-API-Action</code> não pode ser enviado para módulos de API em modo de gravação.",
+ "apierror-protect-invalidaction": "Tipo de proteção \"$1\" inválida.",
+ "apierror-protect-invalidlevel": "Nível de proteção inválido \"$1\".",
+ "apierror-ratelimited": "Você excedeu o limite. Por favor, aguarde algum tempo e tente novamente.",
+ "apierror-readapidenied": "Você precisa da permissão de leitura para usar este módulo.",
+ "apierror-readonly": "Esta wiki está atualmente em modo somente leitura.",
+ "apierror-reauthenticate": "Você não se autenticou recentemente nesta sessão, por favor, se autentique.",
+ "apierror-redirect-appendonly": "Você tentou editar usando o modo de redirecionamento, que deve ser usado em conjunto com <kbd>section=new</kbd>, <var>prependtext</var> ou <var>appendtext</var>.",
+ "apierror-revdel-mutuallyexclusive": "O mesmo campo não pode ser usado em ambos <var>hide</var> e <var>show</var>.",
+ "apierror-revdel-needtarget": "Um título de destino é necessário para este tipo RevDel.",
+ "apierror-revdel-paramneeded": "Pelo menos um valor é necessário para <var>hide</var> e/ou <var>show</var>.",
+ "apierror-revisions-badid": "Nenhuma revisão foi encontrada para o parâmetro <var>$1</var>.",
+ "apierror-revisions-norevids": "O parâmetro <var>revids</var> não pode ser usado com as opções da lista (<var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> e <var>$1end</var>).",
+ "apierror-revisions-singlepage": "<var>titles</var>, <var>pageids</var> ou um gerador foi usado para fornecer várias páginas, mas os parâmetros <var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> e <var>$1end</var> só podem ser usados em uma única página.",
+ "apierror-revwrongpage": "r$1 não é uma revisão de $2.",
+ "apierror-searchdisabled": "<var>$1</var> pesquisa está desativada.",
+ "apierror-sectionreplacefailed": "Não foi possível mesclar a seção atualizada.",
+ "apierror-sectionsnotsupported": "As seções não são suportadas para o modelo de conteúdo $1.",
+ "apierror-sectionsnotsupported-what": "As seções não são suportadas por $1.",
+ "apierror-show": "Parâmetro incorreto - valores mutuamente exclusivos podem não ser fornecidos.",
+ "apierror-siteinfo-includealldenied": "Não é possível visualizar a informação de todos os servidores, a menos que <var>$wgShowHostNames</var> seja true.",
+ "apierror-sizediffdisabled": "A diferença de tamanho está desativada no Miser Mode.",
+ "apierror-spamdetected": "Sua edição foi bloqueada porque contem um fragmento de spam: <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "Você não tem permissão para ver os resultados desta página especial.",
+ "apierror-stashedfilenotfound": "Não foi possível encontrar o arquivo no stash: $1.",
+ "apierror-stashedit-missingtext": "Nenhum texto stashed foi encontrado com o hash informado.",
+ "apierror-stashfailed-complete": "O carregamento fragmentado já está concluído, verifique o status para obter detalhes.",
+ "apierror-stashfailed-nosession": "Nenhuma sessão de upload fragmentada com esta chave.",
+ "apierror-stashfilestorage": "Não foi possível armazenar o upload no stash: $1",
+ "apierror-stashinvalidfile": "Arquivo stashed inválido.",
+ "apierror-stashnosuchfilekey": "Nenhuma dessas chaves de arquivo: $1.",
+ "apierror-stashpathinvalid": "Chave de arquivo de formato impróprio ou inválido: $1.",
+ "apierror-stashwrongowner": "Dono incorreto: $1",
+ "apierror-stashzerolength": "O arquivo é de comprimento zero e não pode ser armazenado no stash: $1.",
+ "apierror-systemblocked": "Você foi bloqueado automaticamente pelo MediaWiki.",
+ "apierror-templateexpansion-notwikitext": "A expansão da predefinição só é suportada pelo conteúdo do texto wiki. $1 usa o modelo de conteúdo $2.",
+ "apierror-timeout": "O servidor não respondeu dentro do tempo esperado.",
+ "apierror-toofewexpiries": "{{PLURAL:$1|Foi fornecida $1 data e hora|Foram fornecidas $1 datas e horas}} de expiração quando {{PLURAL:$2|era necessária|eram necessárias}} $2.",
+ "apierror-unknownaction": "A ação especificada, <kbd>$1</kbd>, não é reconhecida.",
+ "apierror-unknownerror-editpage": "Erro EditPage desconhecido: $1.",
+ "apierror-unknownerror-nocode": "Erro desconhecido.",
+ "apierror-unknownerror": "Erro desconhecido: \"$1\".",
+ "apierror-unknownformat": "Formato desconhecido \"$1\".",
+ "apierror-unrecognizedparams": "{{PLURAL: $2|Parâmetro não reconhecido|Parâmetros não reconhecidos}}: $1.",
+ "apierror-unrecognizedvalue": "Valor não reconhecido para o parâmetro <var>$1</var>: $2.",
+ "apierror-unsupportedrepo": "O repositório de arquivos locais não suporta a consulta de todas as imagens.",
+ "apierror-upload-filekeyneeded": "Deve fornecer uma <var>filekey</var> quando <var>offset</var> for diferente de zero.",
+ "apierror-upload-filekeynotallowed": "Não é possível fornecer uma <var>filekey</var> quando <var>offset</var> é 0.",
+ "apierror-upload-inprogress": "Carregar do stash já em andamento.",
+ "apierror-upload-missingresult": "Nenhum resultado em dados de status.",
+ "apierror-urlparamnormal": "Não foi possível normalizar parâmetros de imagem para $1.",
+ "apierror-writeapidenied": "Você não está autorizado a editar esta wiki através da API.",
+ "apiwarn-alldeletedrevisions-performance": "Para um melhor desempenho ao gerar títulos, defina <kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "Não foi possível analisar <var>$1urlparam</var> por $2. Usando apenas largura e altura.",
+ "apiwarn-badutf8": "O valor passado para <var>$1</var> contém dados inválidos ou não normalizados. Os dados textuais devem ser válidos, NFC-normalizado Unicode sem caracteres de controle C0 diferentes de HT (\\t), LF (\\n) e CR (\\r).",
+ "apiwarn-checktoken-percentencoding": "Verificar se os símbolos, como \"+\" no token, estão codificados corretamente na URL.",
+ "apiwarn-compare-nocontentmodel": "Nenhum modelo de conteúdo pode ser determinado, assumindo $1.",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> foi depreciado. Por favor, use <kbd>prop=deletedrevisions</kbd> ou <kbd>list=alldeletedrevisions</kbd> em vez.",
+ "apiwarn-deprecation-expandtemplates-prop": "Como nenhum valor foi especificado para o parâmetro <var>prop</var>, um formato herdado foi usado para a saída. Este formato está obsoleto e no futuro um valor padrão será definido para o parâmetro <var>prop</var>, fazendo com que o novo formato sempre seja usado.",
+ "apiwarn-deprecation-httpsexpected": "HTTP usado quando o HTTPS era esperado.",
+ "apiwarn-deprecation-login-botpw": "O login da conta principal via <kbd>action=login</kbd> está obsoleto e pode parar de funcionar sem aviso prévio. Para continuar com o login com <kbd>action=login</ kbd>, consulte [[Special:BotPasswords]]. Para continuar com segurança usando o login da conta principal, veja <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "O login da conta principal via <kbd>action=login</kbd> está obsoleto e pode parar de funcionar sem aviso prévio. Para fazer login com segurança, veja <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-token": "Obter um token via <kbd>action=login</kbd> está obsoleto. Use <kbd>action=query&meta=tokens&type=login</kbd> em vez.",
+ "apiwarn-deprecation-parameter": "O parâmetro <var>$1</var> é obsoleto.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> está depreciado desde o MediaWiki 1.28. Use <kbd>prop=headhtml</kbd> quando for criar novos documentos HTML, ou <kbd>prop=modules|jsconfigvars</kbd> quando for atualizar um documento no lado do cliente.",
+ "apiwarn-deprecation-purge-get": "O uso de <kbd>action=purge</kbd> via GET está obsoleto. Use o POST em vez disso.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> está obsoleto. Por favor, use <kbd>$2</kbd> em vez.",
+ "apiwarn-difftohidden": "Não foi possível diferenciar r$1: o conteúdo está oculto.",
+ "apiwarn-errorprinterfailed": "Falha na impressora de erro. Repetirá sem parâmetros.",
+ "apiwarn-errorprinterfailed-ex": "Falha na impressora de erro (repetirá sem parâmetros): $1",
+ "apiwarn-invalidcategory": "\"$1\" não é uma categoria.",
+ "apiwarn-invalidtitle": "\"$1\" não é um título válido.",
+ "apiwarn-invalidxmlstylesheetext": "Stylesheet deve ter extensão <code>.xsl</code>.",
+ "apiwarn-invalidxmlstylesheet": "Especificada folha de estilos inválida ou inexistente.",
+ "apiwarn-invalidxmlstylesheetns": "Stylesheet deve estar no espaço nominal {{ns:MediaWiki}}.",
+ "apiwarn-moduleswithoutvars": "A propriedade <kbd>modules</kbd> foi definida, mas não <kbd>jsconfigvars</kbd> ou <kbd>encodedjsconfigvars</kbd>. As variáveis de configuração são necessárias para o uso adequado do módulo.",
+ "apiwarn-notfile": "\"$1\" não é um arquivo.",
+ "apiwarn-nothumb-noimagehandler": "Não foi possível criar uma miniatura porque $1 não possui um manipulador de imagem associado.",
+ "apiwarn-parse-nocontentmodel": "Não foi dado <var>title</var> ou <var>contentmodel</var>, assumindo $1.",
+ "apiwarn-parse-titlewithouttext": "<var>title</var> usado sem <var>text</var>, e as propriedades da página analisada foram solicitadas. Você quis usar <var>page</var> ao invés de <var>title</var>?",
+ "apiwarn-redirectsandrevids": "A resolução de redirecionamento não pode ser usada em conjunto com o parâmetro <var>revids</var>. Qualquer redirecionamento <var>revids</var> apontando para não foi resolvido.",
+ "apiwarn-tokennotallowed": "A ação \"$1\" não é permitida para o usuário atual.",
+ "apiwarn-tokens-origin": "Os tokens não podem ser obtidos quando a política de origem não é aplicada.",
+ "apiwarn-toomanyvalues": "Muitos valores são fornecidos para o parâmetro <var>$1</var>. O limite é de $2.",
+ "apiwarn-truncatedresult": "Esse resultado foi truncado porque, de outra forma, seria maior do que o limite de $1 bytes.",
+ "apiwarn-unclearnowtimestamp": "Passar \"$2\" para o parâmetro timestamp <var>$1</var> está obsoleto. Se, por algum motivo, você precisa especificar explicitamente o tempo atual sem calcular o lado do cliente, use <kbd>now</kbd>.",
+ "apiwarn-unrecognizedvalues": "{{PLURAL:$3|Valor não reconhecido para o parâmetro|Valores não reconhecidos para o parâmetro}} <var>$1</var>: $2.",
+ "apiwarn-unsupportedarray": "Parâmetro <var>$1</var> usa sintaxe de array PHP não suportada.",
+ "apiwarn-urlparamwidth": "Ignorando o valor de largura definido em <var>$1urlparam</var> ($2) em favor do valor da largura derivado de <var>$1urlwidth</var>/<var>$1urlheight</var> ($3).",
+ "apiwarn-validationfailed-badchars": "caracteres inválidos na chave (apenas <code>a-z</code>, <code>A-Z</code>, <code>0-9</code>, <code>_</code> e <code>-</code> é permitido).",
+ "apiwarn-validationfailed-badpref": "não é uma preferência válida.",
+ "apiwarn-validationfailed-cannotset": "não pode ser configurado por este módulo.",
+ "apiwarn-validationfailed-keytoolong": "chave muito longa (não é permitido mais de $1 bytes).",
+ "apiwarn-validationfailed": "Erro de validação para <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>Aviso de Segurança</strong>: <var>$wgDebugAPI</var> está ativado.",
+ "api-feed-error-title": "Erro ($1)",
+ "api-usage-docref": "Veja $1 para uso da API.",
+ "api-usage-mailinglist-ref": "Inscreva-se na lista de discussão mediawiki-api-announce em &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; Para aviso de depreciações de API e alterações.",
+ "api-exception-trace": "$1 em $2($3)\n$4",
+ "api-credits-header": "Créditos",
+ "api-credits": "Desenvolvedores da API:\n* Yuri Astrakhan (criador, desenvolvedor-chefe Set 2006–Set 2007)\n* Roan Kattouw (desenvolvedor-chefe Set 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (desenvolvedor-chefe 2013–presente)\n\nPor favor, envie seus comentários, sugestões e perguntas para mediawiki-api@lists.wikimedia.org\nou apresente um relatório de erro em https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/pt.json b/www/wiki/includes/api/i18n/pt.json
new file mode 100644
index 00000000..78ec0be1
--- /dev/null
+++ b/www/wiki/includes/api/i18n/pt.json
@@ -0,0 +1,1745 @@
+{
+ "@metadata": {
+ "authors": [
+ "Vitorvicentevalente",
+ "Fúlvio",
+ "Macofe",
+ "Jkb8",
+ "Hamilton Abreu",
+ "Mansil",
+ "Felipe L. Ewald"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentação]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Lista de discussão]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Anúncios da API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Erros e pedidos]\n</div>\n<strong>Estado:</strong> Todas as funcionalidades mostradas nesta página devem ter o comportamento documentado, mas a API ainda está em desenvolvimento ativo e pode ser alterada a qualquer momento. Inscreva-se na [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ lista de discussão mediawiki-api-announce] para ser informado acerca das atualizações.\n\n<strong>Pedidos incorretos:</strong> Quando são enviados pedidos incorretos à API, será devolvido um cabeçalho HTTP com a chave \"MediaWiki-API-Error\" e depois tanto o valor desse cabeçalho como o código de erro devolvido serão definidos com o mesmo valor. Para mais informação, consulte [[mw:Special:MyLanguage/API:Errors_and_warnings|API:Erros e avisos]].\n\n<strong>Testes:</strong> Para testar facilmente pedidos à API, visite [[Special:ApiSandbox|Testes da API]].",
+ "apihelp-main-param-action": "A operação a ser realizada.",
+ "apihelp-main-param-format": "O formato do resultado.",
+ "apihelp-main-param-maxlag": "O atraso máximo pode ser usado quando o MediaWiki é instalado num ''cluster'' de bases de dados replicadas. Para impedir que as operações causem ainda mais atrasos de replicação do ''site'', este parâmetro pode fazer o cliente aguardar até que o atraso de replicação seja inferior ao valor especificado. Caso o atraso atual exceda esse valor, o código de erro <samp>maxlag</samp> é devolvido com uma mensagem como <samp>À espera do servidor $host: $lag segundos de atraso</samp>.<br />Consulte [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Parâmetro maxlag]] para mais informações.",
+ "apihelp-main-param-smaxage": "Definir no cabeçalho HTTP <code>s-maxage</code> de controlo da ''cache'' este número de segundos. Os erros nunca são armazenados na ''cache''.",
+ "apihelp-main-param-maxage": "Definir no cabeçalho HTTP <code>max-age</code> de controlo da ''cache'' este número de segundos. Os erros nunca são armazenados na ''cache''.",
+ "apihelp-main-param-assert": "Se definido com o valor <kbd>user</kbd>, verificar que o utilizador está autenticado. Se definido com o valor <kbd>bot</kbd>, verificar que o utilizador tem o privilégio de conta robô.",
+ "apihelp-main-param-assertuser": "Verificar que o utilizador atual é o utilizador nomeado.",
+ "apihelp-main-param-requestid": "Qualquer valor fornecido aqui será incluído na resposta. Pode ser usado para distinguir pedidos.",
+ "apihelp-main-param-servedby": "Incluir nos resultados o nome do servidor que serviu o pedido.",
+ "apihelp-main-param-curtimestamp": "Incluir a data e hora atuais no resultado.",
+ "apihelp-main-param-responselanginfo": "Incluir as línguas usadas para <var>uselang</var> e <var>errorlang</var> no resultado.",
+ "apihelp-main-param-origin": "Ao aceder à API usando um pedido AJAX entre domínios (CORS), coloque aqui o domínio de origem. Isto tem de ser incluído em todas as verificações prévias e, portanto, tem de fazer parte do URI do pedido (e não do conteúdo do POST).\n\nPara pedidos autenticados, este valor tem de corresponder de forma exata a um dos cabeçalhos <code>Origin</code>, portanto, tem de ser algo como <kbd>https://en.wikipedia.org</kbd> ou <kbd>https://meta.wikimedia.org</kbd>. Se este parâmetro não for igual ao cabeçalho <code>Origin</code>, será devolvida a resposta 403. Se este parâmetro for igual ao cabeçalho <code>Origin</code> e a origem for permitida (''white-listed'') os cabeçalhos <code>Access-Control-Allow-Origin</code> e <code>Access-Control-Allow-Credentials</code> serão preenchidos.\n\nPara pedidos não autenticados, especifique o valor <kbd>*</kbd>. Isto fará com que o cabeçalho <code>Access-Control-Allow-Origin</code>\nseja preenchido, mas <code>Access-Control-Allow-Credentials</code> terá o valor <code>false</code> e o acesso a todos os dados específicos do utilizador está restringido.",
+ "apihelp-main-param-uselang": "A língua a ser usada nas traduções de mensagens. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> com <kbd>siprop=languages</kbd> devolve uma lista de códigos de língua, ou especifique <kbd>user</kbd> para usar a língua nas preferências do utilizador atual, ou especifique <kbd>content</kbd> para usar a língua de conteúdo desta wiki.",
+ "apihelp-main-param-errorformat": "O formato a ser usado no texto de avisos e erros.\n; plaintext: Texto wiki com os elementos HTML removidos e as entidades substituídas.\n; wikitext: Texto wiki sem análise sintática.\n; html: HTML.\n; raw: Chave e parâmetros da mensagem.\n; none: Sem saída de texto, só os códigos de erro.\n; bc: Formato usado antes do MediaWiki 1.29. <var>errorlang</var> e <var>errorsuselocal</var> são ignorados.",
+ "apihelp-main-param-errorlang": "A língua a ser usada para avisos e erros. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> com <kbd>siprop=languages</kbd> devolve uma lista de códigos de língua, ou especifique <kbd>content</kbd> para usar a língua de conteúdo desta wiki, ou especifique <kbd>uselang</kbd> para usar o mesmo valor que o parâmetro <var>uselang</var>.",
+ "apihelp-main-param-errorsuselocal": "Se fornecido, os textos de erro utilizarão mensagens personalizadas localmente do espaço nominal {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "Bloquear um utilizador.",
+ "apihelp-block-param-user": "O nome de utilizador, endereço IP ou gama de endereços IP a serem bloqueados. Não pode ser usado em conjunto com <var>$1userid</var>",
+ "apihelp-block-param-userid": "O identificador do utilizador a ser bloqueado. Não pode ser usado em conjunto com <var>$1user</var>.",
+ "apihelp-block-param-expiry": "O período de expiração. Pode ser relativo (p. ex. <kbd>5 meses</kbd> ou <kbd>2 semanas</kbd>) ou absoluto (p. ex. <kbd>2014-09-18T12:34:56Z</kbd>). Se definido como <kbd>infinite</kbd>, <kbd>indefinite</kbd> ou <kbd>never</kbd>, o bloqueio nunca expirará.",
+ "apihelp-block-param-reason": "O motivo do bloqueio.",
+ "apihelp-block-param-anononly": "Bloquear só utilizadores anónimos (isto é, impedir edições anónimas a partir deste endereço IP)",
+ "apihelp-block-param-nocreate": "Impedir a criação de contas.",
+ "apihelp-block-param-autoblock": "Bloquear automaticamente o último endereço IP usado e quaisquer outros endereços IP subsequentes a partir do quais o utilizador tente iniciar uma sessão.",
+ "apihelp-block-param-noemail": "Impedir o utilizador de enviar correio eletrónico através da wiki. (Requer o privilégio <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Ocultar o nome do utilizador do registo de bloqueios. (Requer o privilégio <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Permitir que o utilizador edite a sua própria página de discussão (depende de <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Se o utilizador já está bloqueado, sobrescrever o bloco existente.",
+ "apihelp-block-param-watchuser": "Vigiar as páginas de utilizador e de discussão, do utilizador ou do endereço IP.",
+ "apihelp-block-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de bloqueios.",
+ "apihelp-block-example-ip-simple": "Bloquear o endereço IP <kbd>192.0.2.5</kbd> por três dias com o motivo <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Bloquear o utilizador <kbd>Vandal</kbd> indefinidamente com o motivo <kbd>Vandalism</kbd>, e impedir a criação de nova conta e o envio de correio eletrónico.",
+ "apihelp-changeauthenticationdata-summary": "Alterar os dados de autenticação do utilizador atual.",
+ "apihelp-changeauthenticationdata-example-password": "Tentar alterar a palavra-passe do utilizador atual para <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Verificar a validade de uma chave a partir de <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Tipo de chave que está a ser testado.",
+ "apihelp-checktoken-param-token": "Chave a testar.",
+ "apihelp-checktoken-param-maxtokenage": "Validade máxima da chave, em segundos.",
+ "apihelp-checktoken-example-simple": "Testar a validade de uma chave <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Limpa a indicação <code>hasmsg</code> do utilizador atual.",
+ "apihelp-clearhasmsg-example-1": "Limpar a indicação <code>hasmsg</code> do utilizador atual.",
+ "apihelp-clientlogin-summary": "Entrar na wiki usando o processo interativo.",
+ "apihelp-clientlogin-example-login": "Inicia o processo de entrada na wiki com o utilizador <kbd>Example</kbd> e a palavra-passe <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Continuar o processo de autenticação após uma resposta <samp>UI</samp> para autenticação de dois fatores, fornecendo uma <var>OATHToken</var> de <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Obter a diferença entre duas páginas.",
+ "apihelp-compare-extended-description": "Tem de ser passado um número de revisão, ou um título de página, ou um identificador de página, ou uma referência relativa para \"from\" e \"to\".",
+ "apihelp-compare-param-fromtitle": "Primeiro título a comparar.",
+ "apihelp-compare-param-fromid": "Primeiro identificador de página a comparar.",
+ "apihelp-compare-param-fromrev": "Primeira revisão a comparar.",
+ "apihelp-compare-param-fromtext": "Usar este texto em vez do conteúdo da revisão especificada por <var>fromtitle</var>, <var>fromid</var> ou <var>fromrev</var>.",
+ "apihelp-compare-param-frompst": "Fazer uma transformação anterior à gravação, de <var>fromtext</var>.",
+ "apihelp-compare-param-fromcontentmodel": "Modelo de conteúdo de <var>fromtext</var>. Se não for fornecido, ele será deduzido a partir dos outros parâmetros.",
+ "apihelp-compare-param-fromcontentformat": "Formato de seriação do conteúdo de <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "Segundo título a comparar.",
+ "apihelp-compare-param-toid": "Segundo identificador de página a comparar.",
+ "apihelp-compare-param-torev": "Segunda revisão a comparar.",
+ "apihelp-compare-param-torelative": "Usar uma revisão relativa à revisão determinada a partir de <var>fromtitle</var>, <var>fromid</var> ou <var>fromrev</var>. Todas as outras opções 'to' serão ignoradas.",
+ "apihelp-compare-param-totext": "Usar este texto em vez do conteúdo da revisão especificada por <var>totitle</var>, <var>toid</var> ou <var>torev</var>.",
+ "apihelp-compare-param-topst": "Fazer uma transformação anterior à gravação, de <var>totext</var>.",
+ "apihelp-compare-param-tocontentmodel": "Modelo de conteúdo de <var>totext</var>. Se não for fornecido, ele será deduzido a partir dos outros parâmetros.",
+ "apihelp-compare-param-tocontentformat": "Formato de seriação do conteúdo de <var>totext</var>.",
+ "apihelp-compare-param-prop": "As informações que devem ser obtidas.",
+ "apihelp-compare-paramvalue-prop-diff": "O HTML da lista de diferenças.",
+ "apihelp-compare-paramvalue-prop-diffsize": "O tamanho do HTML da lista de diferenças, em bytes.",
+ "apihelp-compare-paramvalue-prop-rel": "Os identificadores da revisão anterior a 'from' e da posterior a 'to', se existirem.",
+ "apihelp-compare-paramvalue-prop-ids": "Os identificadores de página e de revisão das revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-title": "Os títulos de página das revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-user": "O nome e o identificador de utilizador das revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-comment": "O comentário das revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "O comentário após análise sintática, das revisões 'from' e 'to'.",
+ "apihelp-compare-paramvalue-prop-size": "O tamanho das revisões 'from' e 'to'.",
+ "apihelp-compare-example-1": "Criar uma lista de diferenças entre as revisões 1 e 2.",
+ "apihelp-createaccount-summary": "Criar uma conta de utilizador nova.",
+ "apihelp-createaccount-param-preservestate": "Se <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> devolveu o valor verdadeiro para <samp>hasprimarypreservedstate</samp>, pedidos marcados como <samp>primary-required</samp> devem ser omitidos. Se devolveu um valor não vazio em <samp>preservedusername</samp>, esse nome de utilizador tem de ser usado no parâmetro <var>username</var>.",
+ "apihelp-createaccount-example-create": "Iniciar o processo de criação do utilizador <kbd>Example</kbd> com a palavra-passe <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Nome de utilizador.",
+ "apihelp-createaccount-param-password": "Palavra-passe (ignorada se <var>$1mailpassword</var> está definida).",
+ "apihelp-createaccount-param-domain": "Domínio para autenticação externa (opcional).",
+ "apihelp-createaccount-param-token": "Chave de criação da conta, obtida no primeiro pedido.",
+ "apihelp-createaccount-param-email": "Endereço de correio eletrónico do utilizador (opcional).",
+ "apihelp-createaccount-param-realname": "Nome verdadeiro do utilizador (opcional).",
+ "apihelp-createaccount-param-mailpassword": "Se qualquer valor estiver definido, uma palavra-passe aleatória será enviada por correio eletrónico ao utilizador.",
+ "apihelp-createaccount-param-reason": "Motivo opcional de criação da conta, para ser colocado nos registos.",
+ "apihelp-createaccount-param-language": "Código da língua a definir como padrão para o utilizador (opcional, por omissão é a língua de conteúdo).",
+ "apihelp-createaccount-example-pass": "Criar o utilizador <kbd>testuser</kbd> com a palavra-passe <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Criar o utilizador <kbd>testmailuser</kbd> e enviar por correio eletrónico uma palavra-passe gerada aleatoriamente.",
+ "apihelp-cspreport-summary": "Usado por '' browsers'' para reportar violações da norma \"Content Security Policy\". Este módulo nunca deve ser usado, exceto quando utilizado automaticamente por um ''browser'' compatível com a CSP.",
+ "apihelp-cspreport-param-reportonly": "Marcar como sendo um relatório vindo de uma norma de monitorização e não de uma norma exigida.",
+ "apihelp-cspreport-param-source": "Aquilo que gerou o cabeçalho CSP que desencadeou este relatório.",
+ "apihelp-delete-summary": "Eliminar uma página.",
+ "apihelp-delete-param-title": "Título da página a eliminar. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Identificador da página a eliminar. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Motivo para a eliminação. Se não for definido, será usado um motivo gerado automaticamente.",
+ "apihelp-delete-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de eliminações.",
+ "apihelp-delete-param-watch": "Adicionar a página às páginas vigiadas do utilizador atual.",
+ "apihelp-delete-param-watchlist": "Adicionar ou remover incondicionalmente a página da lista de páginas vigiadas do utilizador atual, usar as preferências ou não alterar o estado de vigilância.",
+ "apihelp-delete-param-unwatch": "Remover a página das páginas vigiadas do utilizador atual.",
+ "apihelp-delete-param-oldimage": "O nome da imagem antiga a ser eliminada, tal como fornecido por [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Eliminar a página <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Eliminar <kbd>Main Page</kbd> com o motivo <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Este módulo foi desativado.",
+ "apihelp-edit-summary": "Criar e editar páginas.",
+ "apihelp-edit-param-title": "Título da página a ser editada. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Identificador da página a ser editada. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-edit-param-section": "Número da secção. <kbd>0</kbd> para a secção de topo, <kbd>new</kbd> para uma secção nova.",
+ "apihelp-edit-param-sectiontitle": "Título para uma nova secção.",
+ "apihelp-edit-param-text": "Conteúdo da página.",
+ "apihelp-edit-param-summary": "Resumo da edição. Também é o título da secção quando $1section=new e $1sectiontitle não está definido.",
+ "apihelp-edit-param-tags": "Etiquetas de modificação a aplicar à revisão.",
+ "apihelp-edit-param-minor": "Edição menor.",
+ "apihelp-edit-param-notminor": "Edição não menor.",
+ "apihelp-edit-param-bot": "Marcar esta edição como edição de robô.",
+ "apihelp-edit-param-basetimestamp": "Data e hora da revisão de base, usada para detetar conflitos de edição. Pode ser obtida usando [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Data e hora de início do processo de edição, usada para detetar conflitos de edição. Pode-se obter um valor apropriado usando <var>[[Special:ApiHelp/main|curtimestamp]]</var> ao iniciar o processo de edição (por exemplo, ao carregar o conteúdo da página para edição).",
+ "apihelp-edit-param-recreate": "Ignorar todos os erros acerca da página ter sido eliminada entretanto.",
+ "apihelp-edit-param-createonly": "Não editar a página se ela já existe.",
+ "apihelp-edit-param-nocreate": "Gerar um erro se a página não existe.",
+ "apihelp-edit-param-watch": "Adicionar a página às páginas vigiadas do utilizador atual.",
+ "apihelp-edit-param-unwatch": "Remover a página da lista de páginas vigiadas do utilizador atual.",
+ "apihelp-edit-param-watchlist": "Adicionar ou remover incondicionalmente a página da lista de páginas vigiadas do utilizador atual, usar as preferências ou não alterar o estado de vigilância.",
+ "apihelp-edit-param-md5": "A chave MD5 do parâmetro $1text, ou os parâmetros $1prependtext e $1appendtext concatenados. Se estiver definido, a edição não será realizada a menos que a chave seja correta.",
+ "apihelp-edit-param-prependtext": "Adicionar este texto ao início da página. Tem precedência sobre $1text.",
+ "apihelp-edit-param-appendtext": "Adicionar este texto ao fim da página. Tem precedência sobre $1text.\n\nPara acrescentar uma nova secção no fim da página, usar $1section=new em vez deste parâmetro.",
+ "apihelp-edit-param-undo": "Desfazer esta revisão. Tem precedência sobre $1text, $1prependtext e $1appendtext.",
+ "apihelp-edit-param-undoafter": "Desfazer todas as revisões desde $1undo até esta. Se não for definido, desfazer só uma revisão.",
+ "apihelp-edit-param-redirect": "Resolver automaticamente redirecionamentos.",
+ "apihelp-edit-param-contentformat": "Formato para seriação do conteúdo, usado para o texto de entrada.",
+ "apihelp-edit-param-contentmodel": "Modelo de conteúdo do novo conteúdo.",
+ "apihelp-edit-param-token": "A chave deve ser sempre enviada como último parâmetro, ou pelo menos após o parâmetro $1text.",
+ "apihelp-edit-example-edit": "Editar uma página.",
+ "apihelp-edit-example-prepend": "Acrescentar <kbd>_&#95;NOTOC_&#95;</kbd> ao início de uma página.",
+ "apihelp-edit-example-undo": "Desfazer desde a revisão 13579 até à 13585 com resumo automático.",
+ "apihelp-emailuser-summary": "Enviar correio eletrónico a um utilizador.",
+ "apihelp-emailuser-param-target": "Utilizador a quem enviar correio eletrónico.",
+ "apihelp-emailuser-param-subject": "Assunto.",
+ "apihelp-emailuser-param-text": "Texto.",
+ "apihelp-emailuser-param-ccme": "Enviar-me uma cópia desta mensagem.",
+ "apihelp-emailuser-example-email": "Enviar uma mensagem de correio ao utilizador <kbd>WikiSysop</kbd> com o texto <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Expande todas as predefinições incluídas num texto em notação wiki.",
+ "apihelp-expandtemplates-param-title": "Título da página.",
+ "apihelp-expandtemplates-param-text": "Texto em notação wiki a converter.",
+ "apihelp-expandtemplates-param-revid": "Identificador da revisão, para <code><nowiki>{{REVISIONID}}</nowiki></code> e variáveis semelhantes.",
+ "apihelp-expandtemplates-param-prop": "As informações que devem ser obtidas:\n\nNote que se não for selecionado nenhum valor, o resultado irá conter texto em notação wiki mas a saída estará num formato obsoleto.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "O texto em notação wiki expandido.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Quaisquer categorias existentes na entrada que não estão representadas no texto em notação wiki de saída.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Propriedades da página, definidas por palavras mágicas expandidas, no texto em notação wiki.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Indica se o resultado é volátil e não deve ser reutilizado noutra parte da página.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "O período máximo a partir do qual os armazenamentos do resultado na ''cache'' devem ser invalidados.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Quaisquer módulos ResourceLoader que as funções do analisador sintático solicitaram que fossem adicionados ao resultado de saída. Um dos valores <kbd>jsconfigvars</kbd> ou <kbd>encodedjsconfigvars</kbd> tem de ser solicitado em conjunto com o valor <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Devolve as variáveis de configuração JavaScript específicas desta página.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Devolve as variáveis de configuração JavaScript específicas da página, no formato de uma ''string'' JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "A árvore de análise sintática em XML do texto de entrada.",
+ "apihelp-expandtemplates-param-includecomments": "Indica se devem ser incluídos comentários HTML no resultado.",
+ "apihelp-expandtemplates-param-generatexml": "Gerar a árvore de análise sintática em XML (substituído por $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Expandir o texto em notação wiki <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Devolve um ''feed'' das contribuições do utilizador.",
+ "apihelp-feedcontributions-param-feedformat": "O formato do ''feed''.",
+ "apihelp-feedcontributions-param-user": "Os utilizadores dos quais serão obtidas as contribuições.",
+ "apihelp-feedcontributions-param-namespace": "O espaço nominal pelo qual as contribuições serão filtradas.",
+ "apihelp-feedcontributions-param-year": "Desde o ano.",
+ "apihelp-feedcontributions-param-month": "Desde o mês.",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrar as contribuições para produzir as que têm estas etiquetas.",
+ "apihelp-feedcontributions-param-deletedonly": "Mostrar apenas as contribuições eliminadas.",
+ "apihelp-feedcontributions-param-toponly": "Mostrar apenas as edições mais recentes.",
+ "apihelp-feedcontributions-param-newonly": "Mostrar apenas as edições que são criações de páginas.",
+ "apihelp-feedcontributions-param-hideminor": "Ocultar edições menores.",
+ "apihelp-feedcontributions-param-showsizediff": "Mostrar diferença de tamanho entre edições.",
+ "apihelp-feedcontributions-example-simple": "Devolver as contribuições do utilizador <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Devolve um ''feed'' das mudanças recentes.",
+ "apihelp-feedrecentchanges-param-feedformat": "O formato do ''feed''.",
+ "apihelp-feedrecentchanges-param-namespace": "O espaço nominal ao qual os resultados serão limitados.",
+ "apihelp-feedrecentchanges-param-invert": "Todos os espaços nominais exceto o selecionado.",
+ "apihelp-feedrecentchanges-param-associated": "Incluir o espaço nominal associado (de discussão ou principal).",
+ "apihelp-feedrecentchanges-param-days": "Dias aos quais limitar os resultados.",
+ "apihelp-feedrecentchanges-param-limit": "O número máximo de resultados a serem devolvidos.",
+ "apihelp-feedrecentchanges-param-from": "Mostrar alterações desde então.",
+ "apihelp-feedrecentchanges-param-hideminor": "Ocultar edições menores.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ocultar mudanças feitas por robôs.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ocultar mudanças feitas por utilizadores anónimos.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ocultar mudanças feitas por utilizadores registados.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ocultar mudanças patrulhadas.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ocultar mudanças feitas pelo utilizador atual.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Ocultar mudanças de pertença a categorias.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrar por etiqueta.",
+ "apihelp-feedrecentchanges-param-target": "Mostrar apenas mudanças em páginas afluentes a esta.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Mostrar mudanças em páginas com ligações para a página selecionada.",
+ "apihelp-feedrecentchanges-param-categories": "Mostrar apenas mudanças nas páginas que estão em todas estas categorias.",
+ "apihelp-feedrecentchanges-param-categories_any": "Mostrar apenas mudanças nas páginas que estão em qualquer uma das categorias.",
+ "apihelp-feedrecentchanges-example-simple": "Mostrar mudanças recentes.",
+ "apihelp-feedrecentchanges-example-30days": "Mostrar as mudanças recentes de 30 dias.",
+ "apihelp-feedwatchlist-summary": "Devolve um ''feed'' das páginas vigiadas.",
+ "apihelp-feedwatchlist-param-feedformat": "O formato do ''feed''.",
+ "apihelp-feedwatchlist-param-hours": "Mostrar as mudanças recentes desde há este número de horas.",
+ "apihelp-feedwatchlist-param-linktosections": "Ligar diretamente às secções alteradas, se possível.",
+ "apihelp-feedwatchlist-example-default": "Mostrar o ''feed'' das páginas vigiadas.",
+ "apihelp-feedwatchlist-example-all6hrs": "Mostrar todas as mudanças às páginas vigiadas nas últimas 6 horas.",
+ "apihelp-filerevert-summary": "Reverter um ficheiro para uma versão antiga.",
+ "apihelp-filerevert-param-filename": "Nome do ficheiro de destino, sem o prefixo File:.",
+ "apihelp-filerevert-param-comment": "Comentário do carregamento.",
+ "apihelp-filerevert-param-archivename": "Nome de arquivo da revisão para a qual o ficheiro será revertido.",
+ "apihelp-filerevert-example-revert": "Reverter <kbd>Wiki.png</kbd> para a revisão de <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Apresentar ajuda para os módulos especificados.",
+ "apihelp-help-param-modules": "Módulos para os quais apresentar ajuda (valores dos parâmetros <var>action</var> e <var>format</var>, ou <kbd>main</kbd>). Pode-se especificar submódulos com um <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Incluir ajuda para submódulos do módulo nomeado.",
+ "apihelp-help-param-recursivesubmodules": "Incluir ajuda para os submódulos de forma recursiva.",
+ "apihelp-help-param-helpformat": "Formato de saída da ajuda.",
+ "apihelp-help-param-wrap": "Envolver a saída numa estrutura padrão de resposta da API.",
+ "apihelp-help-param-toc": "Incluir uma tabela de conteúdo na saída HTML.",
+ "apihelp-help-example-main": "Ajuda para o módulo principal.",
+ "apihelp-help-example-submodules": "Ajuda para <kbd>action=query</kbd> e todos os respetivos submódulos.",
+ "apihelp-help-example-recursive": "Toda a ajuda numa página.",
+ "apihelp-help-example-help": "Ajuda para o próprio módulo de ajuda.",
+ "apihelp-help-example-query": "Ajuda para dois submódulos de consulta.",
+ "apihelp-imagerotate-summary": "Rodar uma ou mais imagens.",
+ "apihelp-imagerotate-param-rotation": "Graus de rotação da imagem no sentido horário.",
+ "apihelp-imagerotate-param-tags": "Etiquetas a aplicar à entrada no registo de carregamentos.",
+ "apihelp-imagerotate-example-simple": "Rodar <kbd>File:Example.png</kbd> <kbd>90</kbd> graus.",
+ "apihelp-imagerotate-example-generator": "Rodar todas as imagens na categoria <kbd>Category:Flip</kbd> em <kbd>180</kbd> graus.",
+ "apihelp-import-summary": "Importar uma página de outra wiki ou de um ficheiro XML.",
+ "apihelp-import-extended-description": "Note que o pedido POST de HTTP tem de ser feito como um carregamento de ficheiro (isto é, usando \"multipart/form-data\") ao enviar um ficheiro para o parâmetro <var>xml</var>.",
+ "apihelp-import-param-summary": "Resumo da importação para a entrada do registo.",
+ "apihelp-import-param-xml": "Ficheiro XML carregado.",
+ "apihelp-import-param-interwikisource": "Para importações interwikis: a wiki de onde importar.",
+ "apihelp-import-param-interwikipage": "Para importações interwikis: a página a importar.",
+ "apihelp-import-param-fullhistory": "Para importações interwikis: importar o historial completo, não apenas a versão atual.",
+ "apihelp-import-param-templates": "Para importações interwikis: importar também todas as predefinições incluídas.",
+ "apihelp-import-param-namespace": "Importar para este espaço nominal. Não pode ser usado em conjunto com <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importar como subpágina desta página. Não pode ser usado em conjunto com <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de importações e à revisão nula nas páginas importadas.",
+ "apihelp-import-example-import": "Importar [[meta:Help:ParserFunctions]] para o espaço nominal 100 com o historial completo.",
+ "apihelp-linkaccount-summary": "Ligar uma conta de um fornecedor terceiro ao utilizador atual.",
+ "apihelp-linkaccount-example-link": "Iniciar o processo de ligação a uma conta do fornecedor <kbd>Example</kbd>.",
+ "apihelp-login-summary": "Iniciar uma sessão e obter cookies de autenticação.",
+ "apihelp-login-extended-description": "Esta operação só deve ser usada em combinação com [[Special:BotPasswords]]; a sua utilização para entrar com a conta principal é obsoleta e poderá falhar sem aviso. Para entrar com a conta principal de forma segura, use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Esta operação foi descontinuada e poderá falhar sem aviso. Para entrar de forma segura, use <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "Nome de utilizador.",
+ "apihelp-login-param-password": "Palavra-passe.",
+ "apihelp-login-param-domain": "Domínio (opcional).",
+ "apihelp-login-param-token": "Chave de início de sessão obtida no primeiro pedido.",
+ "apihelp-login-example-gettoken": "Obter uma chave de início de sessão.",
+ "apihelp-login-example-login": "Entrar.",
+ "apihelp-logout-summary": "Terminar a sessão e limpar os dados da sessão.",
+ "apihelp-logout-example-logout": "Terminar a sessão do utilizador atual.",
+ "apihelp-managetags-summary": "Executar tarefas de gestão relacionadas com etiquetas de modificação.",
+ "apihelp-managetags-param-operation": "A operação que será realizada:\n;create:Criar uma nova etiqueta de modificação para uso manual.\n;delete:Remover da base de dados uma etiqueta de modificação, incluindo remover a etiqueta de todas as revisões, entradas nas mudanças recentes e entradas do registo onde ela é utilizada.\n;activate:Ativar uma etiqueta de modificação, permitindo que os utilizadores a apliquem manualmente.\n;deactivate:Desativar uma etiqueta de modificação, impedindo que os utilizadores a apliquem manualmente.",
+ "apihelp-managetags-param-tag": "Etiqueta a ser criada, eliminada, ativada ou desativada. Para criar uma etiqueta ela não pode existir. Para eliminar uma etiqueta, ela tem de existir. Para ativar uma etiqueta, ela tem de existir e não estar a ser utilizada por nenhuma extensão. Para desativar uma etiqueta, ela tem de estar ativa e definida manualmente.",
+ "apihelp-managetags-param-reason": "Um motivo, opcional, para a criação, eliminação, ativação ou desativação da etiqueta.",
+ "apihelp-managetags-param-ignorewarnings": "Indica se devem ser ignorados todos os avisos gerados durante a operação.",
+ "apihelp-managetags-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de gestão de etiquetas.",
+ "apihelp-managetags-example-create": "Criar uma etiqueta com o nome <kbd>spam</kbd> e o motivo <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Eliminar a etiqueta <kbd>vandlaism</kbd> com o motivo <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Ativar uma etiqueta com o nome <kbd>spam</kbd> e o motivo <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Desativar uma etiqueta com o nome <kbd>spam</kbd> e o motivo <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Fundir o historial de páginas.",
+ "apihelp-mergehistory-param-from": "Título da página cujo historial será fundido. Não pode ser usado em conjunto com <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "Identificador da página cujo historial será fundido. Não pode ser usado em conjunto com <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Título da página à qual o historial será fundido. Não pode ser usado em conjunto com <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "Identificador da página à qual o historial será fundido. Não pode ser usado em conjunto com <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "Data e hora até a qual as revisões serão movidas do historial da página de origem para o historial das páginas de destino. Se omitido, todo o historial da página de origem será fundido com a página de destino.",
+ "apihelp-mergehistory-param-reason": "Motivo para fundir o historial.",
+ "apihelp-mergehistory-example-merge": "Fundir todo o historial da página <kbd>Oldpage</kbd> com o da página <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Fundir as revisões de <kbd>Oldpage</kbd> até à data e hora <kbd>2015-12-31T04:37:41Z</kbd> com <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Mover uma página.",
+ "apihelp-move-param-from": "Título da página cujo nome será alterado. Não pode ser usado em conjunto com <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "Identificador da página cujo nome será alterado. Não pode ser usado em conjunto com <var>$1from</var>.",
+ "apihelp-move-param-to": "Novo título da página.",
+ "apihelp-move-param-reason": "Motivo para a alteração do nome.",
+ "apihelp-move-param-movetalk": "Alterar o nome da página de discussão, se esta existir.",
+ "apihelp-move-param-movesubpages": "Alterar o nome das subpáginas, se estas existirem.",
+ "apihelp-move-param-noredirect": "Não criar um redirecionamento.",
+ "apihelp-move-param-watch": "Adicionar a página e o redirecionamento às páginas vigiadas do utilizador atual.",
+ "apihelp-move-param-unwatch": "Remover a página e o redirecionamento das páginas vigiadas do utilizador atual.",
+ "apihelp-move-param-watchlist": "Adicionar ou remover incondicionalmente a página da lista de páginas vigiadas do utilizador atual, usar as preferências ou não alterar o estado de vigilância.",
+ "apihelp-move-param-ignorewarnings": "Ignorar quaisquer avisos.",
+ "apihelp-move-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de movimentações e à revisão nula na página de destino.",
+ "apihelp-move-example-move": "Mover <kbd>Badtitle</kbd> para <kbd>Goodtitle</kbd> sem deixar um redirecionamento.",
+ "apihelp-opensearch-summary": "Pesquisar a wiki usando o protocolo OpenSearch.",
+ "apihelp-opensearch-param-search": "Texto a pesquisar.",
+ "apihelp-opensearch-param-limit": "O número máximo de resultados a serem devolvidos.",
+ "apihelp-opensearch-param-namespace": "Espaços nominais a pesquisar.",
+ "apihelp-opensearch-param-suggest": "Não fazer nada se <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> for falso.",
+ "apihelp-opensearch-param-redirects": "Como tratar redirecionamentos:\n;return:Devolver o próprio redirecionamento.\n;resolve:Devolver a página de destino. Pode devolver menos de $1limit resultados.\nPor razões históricas, o valor por omissão é \"return\" para o formato $1format=json e \"resolve\" para outros formatos.",
+ "apihelp-opensearch-param-format": "O formato do resultado.",
+ "apihelp-opensearch-param-warningsaserror": "Se forem gerados avisos com <kbd>format=json</kbd>, devolver um erro da API em vez de ignorá-los.",
+ "apihelp-opensearch-example-te": "Encontrar as páginas que começam por <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Alterar as preferências do utilizador atual.",
+ "apihelp-options-extended-description": "Só podem ser definidas as opções que estão registadas no núcleo do MediaWiki ou numa das extensões instaladas, ou opções cuja chave tem o prefixo <code>userjs-</code> (que são supostas ser usadas por ''scripts'' de utilizador).",
+ "apihelp-options-param-reset": "Reiniciar preferências para os valores por omissão do ''site''.",
+ "apihelp-options-param-resetkinds": "Lista dos tipos de opções a reiniciar quando a opção <var>$1reset</var> está definida.",
+ "apihelp-options-param-change": "Listas das alterações, na forma nome=valor (isto é, skin=vector). Se não for fornecido nenhum valor (nem sequer um sinal de igualdade), por exemplo, nomedaopção|outraopção|..., a opção será reiniciada para o seu valor por omissão. Se qualquer dos valores passados contém uma barra vertical (<kbd>|</kbd>), use um [[Special:ApiHelp/main#main/datatypes|separador alternativo para valores múltiplos]] de forma a obter o comportamento correto.",
+ "apihelp-options-param-optionname": "O nome da opção que deve ser configurada com o valor dado por <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "O valor para a opção especificada por <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Reiniciar todas as preferências.",
+ "apihelp-options-example-change": "Alterar as preferências <kbd>skin</kbd> e <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Reiniciar todas as preferências e depois definir <kbd>skin</kbd> e <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Obter informação sobre os módulos da API.",
+ "apihelp-paraminfo-param-modules": "Lista dos nomes dos módulos (valores dos parâmetros <var>action</var> e <var>format</var>, ou <kbd>main</kbd>). Podem ser especificados submódulos com <kbd>+</kbd>, ou todos os submódulos com <kbd>+*</kbd>, ou todos os submódulos de forma recursiva com <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Formato dos textos de ajuda.",
+ "apihelp-paraminfo-param-querymodules": "Lista de nomes dos módulos a consultar (valores dos parâmetros <var>prop</var>, <var>meta</var> ou <var>list</var>). Usar <kbd>$1modules=query+foo</kbd> em vez de <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Obter também informação sobre o módulo principal (do nível de topo). Em vez de usá-lo, usar <kbd>$1modules=main</kbd>.",
+ "apihelp-paraminfo-param-pagesetmodule": "Obter também informação sobre o módulo pageset (fornecendo titles= e restantes).",
+ "apihelp-paraminfo-param-formatmodules": "Lista de nomes de módulos de formato (valor do parâmetro <var>format</var>). Em vez de usá-lo, use <var>$1modules</var>.",
+ "apihelp-paraminfo-example-1": "Mostrar informação para <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> e <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "Mostrar informação de todos os módulos de <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-summary": "Faz a análise sintática do conteúdo e devolve o resultado da análise.",
+ "apihelp-parse-extended-description": "Consulte os vários módulos disponíveis no parâmetro prop de <kbd>[[Special:ApiHelp/query|action=query]]</kbd> para obter informação da versão atual de uma página.\n\nHá várias formas de especificar o texto a analisar:\n# Especificar uma página ou revisão, usando <var>$1page</var>, <var>$1pageid</var> ou <var>$1oldid</var>.\n# Especificar o conteúdo de forma explícita, usando <var>$1text</var>, <var>$1title</var> e <var>$1contentmodel</var>.\n# Especificar só um resumo a analisar. <var>$1prop</var> deve receber o valor vazio.",
+ "apihelp-parse-param-title": "Título da página à qual o texto pertence. Se omitido, é preciso especificar <var>$1contentmodel</var> e deve usar [[API]] como título.",
+ "apihelp-parse-param-text": "Texto a analisar. Usar <var>$1title</var> ou <var>$1contentmodel</var> para controlar o modelo de conteúdo.",
+ "apihelp-parse-param-summary": "Resumo a analisar.",
+ "apihelp-parse-param-page": "Analisar o conteúdo desta página. Não pode ser usado em conjunto com <var>$1text</var> e <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Analisar o conteúdo desta página. Tem precedência sobre <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "Se <var>$1page</var> ou <var>$1pageid</var> estiverem definidos para um redirecionamento, resolvê-lo.",
+ "apihelp-parse-param-oldid": "Analisar o conteúdo desta revisão. Tem precedência sobre <var>$1page</var> e <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "As informações que devem ser obtidas:",
+ "apihelp-parse-paramvalue-prop-text": "Fornece o texto analisado, de um texto com notação wiki.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Fornece os links interlínguas do texto analisado.",
+ "apihelp-parse-paramvalue-prop-categories": "Fornece as categorias do texto analisado.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Fornece a versão HTML das categorias.",
+ "apihelp-parse-paramvalue-prop-links": "Fornece os links internos do texto analisado.",
+ "apihelp-parse-paramvalue-prop-templates": "Fornece as predefinições do texto analisado.",
+ "apihelp-parse-paramvalue-prop-images": "Fornece as imagens do texto analisado.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Fornece os links externos do texto analisado.",
+ "apihelp-parse-paramvalue-prop-sections": "Fornece as secções do texto analisado.",
+ "apihelp-parse-paramvalue-prop-revid": "Adiciona o identificador de revisão da página analisada.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Adiciona o título do texto analisado.",
+ "apihelp-parse-paramvalue-prop-headitems": "Fornece os elementos a colocar no <code>&lt;head&gt;</code> da página.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Fornece o <code>&lt;head&gt;</code> analisado da página.",
+ "apihelp-parse-paramvalue-prop-modules": "Fornece os módulos ResourceLoader usados na página. Para carregá-los, usar <code>mw.loader.using()</code>. Uma das variáveis <kbd>jsconfigvars</kbd> ou <kbd>encodedjsconfigvars</kbd> tem de ser pedida em conjunto com <kbd>modules</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Fornece as variáveis de configuração JavaScript específicas da página. Para aplicá-las, usar <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Fornece as variáveis de configuração JavaScript específicas da página, no formato de uma ''string'' JSON.",
+ "apihelp-parse-paramvalue-prop-indicators": "Fornece o HTML dos indicadores de estado de página que são usados na página.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Fornece os links interwikis do texto analisado.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Fornece o texto original com notação wiki que foi analisado.",
+ "apihelp-parse-paramvalue-prop-properties": "Fornece várias propriedades definidas no texto analisado.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Fornece o relatório de limites de forma estruturada. Não fornece dados quando <var>$1disablelimitreport</var> está definido.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Fornece a versão HTML do relatório de limites. Não fornece dados quando <var>$1disablelimitreport</var> está definido.",
+ "apihelp-parse-paramvalue-prop-parsetree": "A árvore de análise XML do conteúdo da revisão (requer o modelo de conteúdo <code>$1</code>).",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Fornece os avisos gerados durante a análise sintática do conteúdo.",
+ "apihelp-parse-param-wrapoutputclass": "A classe CSS a utilizar para envolver o resultado do analisador sintático.",
+ "apihelp-parse-param-pst": "Fazer uma transformação anterior à gravação do texto de entrada, antes de analisá-lo. Só é válido quando usado com texto.",
+ "apihelp-parse-param-onlypst": "Fazer uma transformação anterior à gravação (PST, ''pre-save transform'') do texto de entrada, mas não o analisar. Devolve o mesmo texto após aplicação da PST. Só é válido quando usado com <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "Inclui links interlínguas fornecidos por extensões (para ser usado com <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "Analisar apenas o conteúdo desta secção.\n\nQuando tiver o valor <kbd>new</kbd>, analisar <var>$1text</var> e <var>$1sectiontitle</var> como se fosse adicionar uma nova secção à página.\n\n<kbd>new</kbd> só é permitido quando se especifica <var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "O novo título da secção quando <var>section</var> tem o valor <kbd>new</kbd>.\n\nAo contrário da edição de páginas, este não toma o valor de <var>summary</var> se for omitido ou estiver vazio.",
+ "apihelp-parse-param-disablelimitreport": "Omitir o relatório de limites (\"NewPP limit report\") do resultado de saída do analisador sintático.",
+ "apihelp-parse-param-disablepp": "Em vez deste, usar <var>$1disablelimitreport</var>.",
+ "apihelp-parse-param-disableeditsection": "Omitir links para edição da secção no resultado da análise sintática.",
+ "apihelp-parse-param-disabletidy": "Não fazer a limpeza do HTML (isto é, o ''tidy'') no resultado da análise sintática.",
+ "apihelp-parse-param-generatexml": "Gerar a árvore de análise XML (requer o modelo de conteúdo <code>$1</code>; substituído por <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "Executar a análise em modo de antevisão.",
+ "apihelp-parse-param-sectionpreview": "Executar a análise em modo de antevisão (também ativa o modo de antevisão).",
+ "apihelp-parse-param-disabletoc": "Omitir a tabela de conteúdo no resultado.",
+ "apihelp-parse-param-useskin": "Aplicar o tema selecionado ao resultado do analisador sintático. Pode afetar as seguintes propriedades: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>modules</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicators</kbd>.",
+ "apihelp-parse-param-contentformat": "O formato da seriação de conteúdo, usado para o texto de entrada. Só é válido quando usado com $1text.",
+ "apihelp-parse-param-contentmodel": "Modelo de conteúdo do texto de entrada. Se omitido, $1title tem de ser especificado e o valor por omissão será o modelo do título especificado. Só é válido quando usado com $1text.",
+ "apihelp-parse-example-page": "Fazer a análise sintática de uma página.",
+ "apihelp-parse-example-text": "Fazer a análise sintática do texto com notação wiki.",
+ "apihelp-parse-example-texttitle": "Fazer a análise sintática do texto com notação wiki, especificando o título da página.",
+ "apihelp-parse-example-summary": "Fazer a análise sintática de um resumo.",
+ "apihelp-patrol-summary": "Patrulhar uma página ou revisão.",
+ "apihelp-patrol-param-rcid": "Identificador da mudança recente a patrulhar.",
+ "apihelp-patrol-param-revid": "Identificador da revisão a patrulhar.",
+ "apihelp-patrol-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de edições patrulhadas.",
+ "apihelp-patrol-example-rcid": "Patrulhar uma mudança recente.",
+ "apihelp-patrol-example-revid": "Patrulhar uma revisão.",
+ "apihelp-protect-summary": "Alterar o nível de proteção de uma página.",
+ "apihelp-protect-param-title": "Título da página a proteger ou desproteger. Não pode ser usado em conjunto com $1pageid.",
+ "apihelp-protect-param-pageid": "Identificador da página a proteger ou desproteger. Não pode ser usado em conjunto com $1title.",
+ "apihelp-protect-param-protections": "Lista de níveis de proteção, na forma <kbd>action=level</kbd> (por exemplo, <kbd>edit=sysop</kbd>). O nível <kbd>all</kbd> significada que todos podem executar a operação, isto é, sem restrição.\n\n<strong>Nota:</strong> Serão removidas as restrições de quaisquer operações não listadas.",
+ "apihelp-protect-param-expiry": "Datas e horas de expiração. Se só uma data e hora for definida, será usada para todas as proteções. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> ou <kbd>never</kbd>, para proteção sem expiração.",
+ "apihelp-protect-param-reason": "Motivo da proteção ou desproteção.",
+ "apihelp-protect-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de proteções.",
+ "apihelp-protect-param-cascade": "Ativar a proteção em cascata (isto é, proteger as predefinições transcluídas e as imagens usadas nesta página). Ignorado se nenhum dos níveis de proteção dados suportam a proteção em cascata.",
+ "apihelp-protect-param-watch": "Se definido, adicionar a página que está a ser protegida ou desprotegida às páginas vigiadas do utilizador atual.",
+ "apihelp-protect-param-watchlist": "Adicionar ou remover incondicionalmente a página da lista de páginas vigiadas do utilizador atual, usar as preferências ou não alterar o estado de vigilância.",
+ "apihelp-protect-example-protect": "Proteger uma página.",
+ "apihelp-protect-example-unprotect": "Desproteger uma página definindo a restrição <kbd>all</kbd> (isto é, todos podem executar a operação).",
+ "apihelp-protect-example-unprotect2": "Desproteger uma página definindo que não há restrições.",
+ "apihelp-purge-summary": "Limpar a ''cache'' para os títulos especificados.",
+ "apihelp-purge-param-forcelinkupdate": "Atualizar as tabelas de ligações.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Atualizar a tabela de ligações, e atualizar as tabelas de ligações de qualquer página que usa esta página como modelo.",
+ "apihelp-purge-example-simple": "Purgar as páginas <kbd>Main Page</kbd> e <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "Purgar as primeiras 10 páginas no espaço nominal principal.",
+ "apihelp-query-summary": "Obter dados de, e sobre, o MediaWiki.",
+ "apihelp-query-extended-description": "Todas as modificações de dados terão primeiro que usar uma consulta para adquirir uma chave, o que visa impedir abusos de sites maliciosos.",
+ "apihelp-query-param-prop": "As propriedades a serem obtidas para as páginas consultadas.",
+ "apihelp-query-param-list": "As listas a serem obtidas.",
+ "apihelp-query-param-meta": "Os metadados a serem obtidos.",
+ "apihelp-query-param-indexpageids": "Incluir uma secção adicional de identificadores de página que lista todos os identificadores de página devolvidos.",
+ "apihelp-query-param-export": "Exportar as revisões atuais de todas as páginas fornecidas ou geradas.",
+ "apihelp-query-param-exportnowrap": "Devolver o XML de exportação sem envolvê-lo num resultado XML (o mesmo formato que [[Special:Export]]). Só pode ser usado com $1export.",
+ "apihelp-query-param-iwurl": "Indica se deve ser obtido o URL completo quando o título é um ''link'' interwikis.",
+ "apihelp-query-param-rawcontinue": "Devolver os dados em bruto de <samp>query-continue</samp> para continuar.",
+ "apihelp-query-example-revisions": "Obter [[Special:ApiHelp/query+siteinfo|informação do ''site'']] e as [[Special:ApiHelp/query+revisions|revisões]] da página <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "Obter as revisões das páginas que começam por <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "Enumerar todas as categorias.",
+ "apihelp-query+allcategories-param-from": "A categoria a partir da qual será começada a enumeração.",
+ "apihelp-query+allcategories-param-to": "A categoria na qual será terminada a enumeração.",
+ "apihelp-query+allcategories-param-prefix": "Procurar todos os títulos de categorias que começam por este valor.",
+ "apihelp-query+allcategories-param-dir": "A direção da ordenação.",
+ "apihelp-query+allcategories-param-min": "Só devolver as categorias que tenham no mínimo este número de membros.",
+ "apihelp-query+allcategories-param-max": "Só devolver as categorias que tenham no máximo este número de membros.",
+ "apihelp-query+allcategories-param-limit": "O número de categorias a serem devolvidas.",
+ "apihelp-query+allcategories-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Adiciona o número de páginas na categoria.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Etiqueta as categorias ocultadas com <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Lista as categorias com informação sobre o número de páginas em cada uma delas.",
+ "apihelp-query+allcategories-example-generator": "Obter informação sobre a própria página de categoria, para as categorias que começam por <kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "Listar todas as revisões eliminadas por um utilizador ou de um espaço nominal.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Só pode ser usado com <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Não pode ser usado com <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "A data e hora da revisão a partir da qual será começada a enumeração.",
+ "apihelp-query+alldeletedrevisions-param-end": "A data e hora na qual será terminada a enumeração.",
+ "apihelp-query+alldeletedrevisions-param-from": "Começar a listagem neste título.",
+ "apihelp-query+alldeletedrevisions-param-to": "Terminar a listagem neste título.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Procurar todos os títulos de página que começam por este valor.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Listar só as revisões marcadas com esta etiqueta.",
+ "apihelp-query+alldeletedrevisions-param-user": "Listar só as revisões feitas por este utilizador.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Não listar as revisões feitas por este utilizador.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Listar só as páginas neste espaço nominal.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>Nota:</strong> Devido ao [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo avarento]], o uso de <var>$1user</var> e <var>$1namespace</var> em conjunto pode resultar na devolução de menos de <var>$1limit</var> resultados antes de continuar; em casos extremos pode não ser devolvido qualquer resultado.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Ao ser usado como gerador, gerar títulos em vez de identificadores de revisões.",
+ "apihelp-query+alldeletedrevisions-example-user": "Listar as últimas 50 contribuições eliminadas do utilizador <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Listar as primeiras 50 revisões eliminadas no espaço nominal principal.",
+ "apihelp-query+allfileusages-summary": "Listar todas as utilizações de ficheiros, incluindo ficheiros que não existam.",
+ "apihelp-query+allfileusages-param-from": "O título do ficheiro a partir do qual será começada a enumeração.",
+ "apihelp-query+allfileusages-param-to": "O título do ficheiro no qual será terminada a enumeração.",
+ "apihelp-query+allfileusages-param-prefix": "Procurar todos os títulos de ficheiro que começam por este valor.",
+ "apihelp-query+allfileusages-param-unique": "Mostrar só nomes de ficheiro únicos. Não pode ser usado com <kbd>$1prop=ids</kbd>.\nAo ser usado como gerador, produz páginas de destino em vez de páginas de origem.",
+ "apihelp-query+allfileusages-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Adiciona os identificadores das páginas que utilizam (não pode ser usado com <var>$1unique</var>).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Adiciona o título do ficheiro.",
+ "apihelp-query+allfileusages-param-limit": "O número total de elementos a serem devolvidos.",
+ "apihelp-query+allfileusages-param-dir": "A direção de listagem.",
+ "apihelp-query+allfileusages-example-B": "Listar os títulos de ficheiros, incluindo aqueles em falta, com os identificadores das páginas de onde provêm, começando no <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Listar os títulos de ficheiro únicos.",
+ "apihelp-query+allfileusages-example-unique-generator": "Obtém todos os títulos de ficheiros, marcando aqueles em falta.",
+ "apihelp-query+allfileusages-example-generator": "Obtém as páginas que contêm os ficheiros.",
+ "apihelp-query+allimages-summary": "Enumerar todas as imagens sequencialmente.",
+ "apihelp-query+allimages-param-sort": "Propriedade pela qual fazer a ordenação.",
+ "apihelp-query+allimages-param-dir": "A direção de listagem.",
+ "apihelp-query+allimages-param-from": "O título da imagem a partir do qual será começada a enumeração. Só pode ser usado com $1sort=name.",
+ "apihelp-query+allimages-param-to": "O título da imagem no qual será terminada a enumeração. Só pode ser usado com $1sort=name.",
+ "apihelp-query+allimages-param-start": "A data e hora da imagem a partir da qual será começada a enumeração. Só pode ser usado com $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "A data e hora da imagem na qual será terminada a enumeração. Só pode ser usado com $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "Procurar todos os títulos de imagem que começam por este valor. Só pode ser usado com $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "Limitar só às imagens com este número mínimo de bytes.",
+ "apihelp-query+allimages-param-maxsize": "Limitar só às imagens com este número máximo de bytes.",
+ "apihelp-query+allimages-param-sha1": "Resumo criptográfico SHA1 da imagem. Tem precedência sobre $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "Resumo criptográfico SHA1 da imagem em base 36 (usado no MediaWiki).",
+ "apihelp-query+allimages-param-user": "Devolver só os ficheiros carregados por este utilizador. Só pode ser usado com $1sort=timestamp. Não pode ser usado em conjunto com $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "Como filtrar os ficheiros carregados por robôs. Só pode ser usado com $1sort=timestamp. Não pode ser usado em conjunto com $1user.",
+ "apihelp-query+allimages-param-mime": "Tipos MIME a procurar; por exemplo, <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "O número total de imagens a serem devolvidas.",
+ "apihelp-query+allimages-example-B": "Mostrar uma lista dos ficheiros que começam com a letra <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Mostrar uma lista dos ficheiros carregados recentemente, semelhante a [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Mostrar uma lista dos ficheiros com os tipos MIME <kbd>image/png</kbd> ou <kbd>image/gif</kbd>.",
+ "apihelp-query+allimages-example-generator": "Mostrar informação sobre 4 ficheiros, começando pela letra <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Enumerar todos os ''links'' que apontam para um determinado espaço nominal.",
+ "apihelp-query+alllinks-param-from": "O título do ''link'' a partir do qual será começada a enumeração.",
+ "apihelp-query+alllinks-param-to": "O título do ''link'' no qual será terminada a enumeração.",
+ "apihelp-query+alllinks-param-prefix": "Procurar todos os títulos ligados que começam por este valor.",
+ "apihelp-query+alllinks-param-unique": "Mostrar só títulos ligados únicos. Não pode ser usado com <kbd>$1prop=ids</kbd>.\nAo ser usado como gerador, produz páginas de destino em vez de páginas de origem.",
+ "apihelp-query+alllinks-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Adiciona o identificador da página que contém a ligação (não pode ser usado com <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Adiciona o título do ''link''.",
+ "apihelp-query+alllinks-param-namespace": "O espaço nominal a ser enumerado.",
+ "apihelp-query+alllinks-param-limit": "O número total de entradas a serem devolvidas.",
+ "apihelp-query+alllinks-param-dir": "A direção de listagem.",
+ "apihelp-query+alllinks-example-B": "Listar os títulos para os quais existem ligações, incluindo títulos em falta, com os identificadores das páginas que contêm as respetivas ligações, começando pela letra <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Listar os títulos únicos para os quais existem ligações.",
+ "apihelp-query+alllinks-example-unique-generator": "Obtém todos os títulos para os quais existem ligações, marcando aqueles em falta.",
+ "apihelp-query+alllinks-example-generator": "Obtém as páginas que contêm as ligações.",
+ "apihelp-query+allmessages-summary": "Devolver as mensagens deste ''site''.",
+ "apihelp-query+allmessages-param-messages": "Mensagens a serem produzidas no resultado. <kbd>*</kbd> (o valor por omissão) significa todas as mensagens.",
+ "apihelp-query+allmessages-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+allmessages-param-enableparser": "Definir, para ativar o analisador sintático e pré-processar o texto da mensagem com notação wiki (substituir palavras mágicas, processar predefinições, etc.).",
+ "apihelp-query+allmessages-param-nocontent": "Se definido, não incluir o conteúdo das mensagens no resultado de saída.",
+ "apihelp-query+allmessages-param-includelocal": "Incluir também as mensagens locais, isto é, mensagens que não existem no software mas existem como uma página no espaço nominal {{ns:MediaWiki}}.\nIsto lista todas as páginas do espaço nominal {{ns:MediaWiki}}, portanto, também irá listar aquelas que não são verdadeiramente mensagens, como [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Os argumentos a serem substituídos na mensagem.",
+ "apihelp-query+allmessages-param-filter": "Devolver só as mensagens cujos nomes contêm este texto.",
+ "apihelp-query+allmessages-param-customised": "Devolver só as mensagens neste estado de personalização.",
+ "apihelp-query+allmessages-param-lang": "Devolver as mensagens nesta língua.",
+ "apihelp-query+allmessages-param-from": "Devolver as mensagens, a partir desta mensagem.",
+ "apihelp-query+allmessages-param-to": "Devolver as mensagens, até esta mensagem.",
+ "apihelp-query+allmessages-param-title": "Nome da página a utilizar como contexto ao fazer a análise sintática da mensagem (para a opção $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "Devolver as mensagens com este prefixo.",
+ "apihelp-query+allmessages-example-ipb": "Mostrar mensagens que começam por <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Mostrar as mensagens <kbd>august</kbd> e <kbd>mainpage</kbd> em Alemão.",
+ "apihelp-query+allpages-summary": "Enumerar sequencialmente todas as páginas de um determinado espaço nominal.",
+ "apihelp-query+allpages-param-from": "O título de página a partir do qual será começada a enumeração.",
+ "apihelp-query+allpages-param-to": "O título de página no qual será terminada a enumeração.",
+ "apihelp-query+allpages-param-prefix": "Procurar todos os títulos de páginas que comecem com este valor.",
+ "apihelp-query+allpages-param-namespace": "O espaço nominal a ser enumerado.",
+ "apihelp-query+allpages-param-filterredir": "As páginas a serem listadas.",
+ "apihelp-query+allpages-param-minsize": "Limitar só às páginas com este número mínimo de bytes.",
+ "apihelp-query+allpages-param-maxsize": "Limitar só às páginas com este número máximo de bytes.",
+ "apihelp-query+allpages-param-prtype": "Limitar só às páginas protegidas.",
+ "apihelp-query+allpages-param-prlevel": "Filtrar as proteções com base no nível de proteção (tem de ser usado com o parâmetro $1prtype=).",
+ "apihelp-query+allpages-param-prfiltercascade": "Filtrar as proteções com base na proteção em cascata (ignorado se $1prtype não estiver presente).",
+ "apihelp-query+allpages-param-limit": "O número total de páginas a serem devolvidas.",
+ "apihelp-query+allpages-param-dir": "A direção de listagem.",
+ "apihelp-query+allpages-param-filterlanglinks": "Filtrar dependo de uma página ter ''links'' interlínguas. Note que isto pode não tomar em consideração ''links'' interlínguas adicionados por extensões.",
+ "apihelp-query+allpages-param-prexpiry": "O tipo de expiração pelo qual as páginas serão filtradas:\n;indefinite:Obter só páginas com um período de expiração indefinido.\n;definite:Obter só páginas com um período de expiração definido (específico).\n;all:Obter páginas com qualquer período de expiração.",
+ "apihelp-query+allpages-example-B": "Mostrar uma lista de páginas, começando na letra <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Mostrar informação sobre 4 páginas, começando na letra <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Mostrar o conteúdo das primeiras 2 páginas que não sejam redirecionamentos, começando na página <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Listar todos os redirecionamentos para um espaço nominal.",
+ "apihelp-query+allredirects-param-from": "O título do redirecionamento a partir do qual será começada a enumeração.",
+ "apihelp-query+allredirects-param-to": "O título do redirecionamento no qual será terminada a enumeração.",
+ "apihelp-query+allredirects-param-prefix": "Procurar todas as páginas de destino que começam por este valor.",
+ "apihelp-query+allredirects-param-unique": "Mostrar só páginas de destino únicas. Não pode ser usado com <kbd>$1prop=ids|fragment|interwiki</kbd>.\nAo ser usado como gerador, produz páginas de destino em vez de páginas de origem.",
+ "apihelp-query+allredirects-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Adiciona o identificador da página que contém o redirecionamento (não pode ser usado com <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Adiciona o título do redirecionamento.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Adiciona o fragmento do redirecionamento, se existir (não pode ser usado com <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Adiciona o prefixo interwikis do redirecionamento, se existir (não pode ser usado em conjunto com <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "O espaço nominal a ser enumerado.",
+ "apihelp-query+allredirects-param-limit": "O número total de elementos a serem devolvidos.",
+ "apihelp-query+allredirects-param-dir": "A direção de listagem.",
+ "apihelp-query+allredirects-example-B": "Listar as páginas de destino, incluindo aquelas em falta, com os identificadores da página de origem, começando na <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "Listar as páginas de destino únicas.",
+ "apihelp-query+allredirects-example-unique-generator": "Obtém todas as páginas de destino, marcando aquelas em falta.",
+ "apihelp-query+allredirects-example-generator": "Obtém as páginas que contêm os redirecionamentos.",
+ "apihelp-query+allrevisions-summary": "Listar todas as revisões.",
+ "apihelp-query+allrevisions-param-start": "A data e hora a partir da qual será começada a enumeração.",
+ "apihelp-query+allrevisions-param-end": "A data e hora na qual será terminada a enumeração.",
+ "apihelp-query+allrevisions-param-user": "Listar só as revisões deste utilizador.",
+ "apihelp-query+allrevisions-param-excludeuser": "Não listar as revisões deste utilizador.",
+ "apihelp-query+allrevisions-param-namespace": "Listar só as páginas neste espaço nominal.",
+ "apihelp-query+allrevisions-param-generatetitles": "Ao ser usado como gerador, gerar títulos em vez de identificadores de revisões.",
+ "apihelp-query+allrevisions-example-user": "Listar as últimas 50 contribuições do utilizador <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "Listar as primeiras 50 revisões no espaço nominal principal.",
+ "apihelp-query+mystashedfiles-summary": "Obter uma lista dos ficheiros que estão na área de ficheiros escondidos do utilizador atual.",
+ "apihelp-query+mystashedfiles-param-prop": "As propriedades a serem obtidas para os ficheiros.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Obter o tamanho do ficheiro e as dimensões da imagem.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Obter o tipo MIME e o tipo de multimédia do ficheiro.",
+ "apihelp-query+mystashedfiles-param-limit": "Quantos ficheiros a serem obtidos.",
+ "apihelp-query+mystashedfiles-example-simple": "Obter a chave, o tamanho e as dimensões em píxeis dos ficheiros na área de ficheiros escondidos do utilizador.",
+ "apihelp-query+alltransclusions-summary": "Listar todas as transclusões (páginas incorporadas utilizando &#123;&#123;x&#125;&#125;), incluindo as que estejam em falta.",
+ "apihelp-query+alltransclusions-param-from": "O título da transclusão a partir do qual será começada a enumeração.",
+ "apihelp-query+alltransclusions-param-to": "O título da transclusão no qual será terminada a enumeração.",
+ "apihelp-query+alltransclusions-param-prefix": "Procurar todos os títulos transcluídos que começam por este valor.",
+ "apihelp-query+alltransclusions-param-unique": "Mostrar só títulos transcluídos únicos. Não pode ser usado com <kbd>$1prop=ids</kbd>.\nAo ser usado como gerador, produz páginas de destino em vez de páginas de origem.",
+ "apihelp-query+alltransclusions-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Adiciona o identificador da página onde é feita a transclusão (não pode ser usado com <var>$1unique</var>).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Adiciona o título da transclusão.",
+ "apihelp-query+alltransclusions-param-namespace": "O espaço nominal a enumerar.",
+ "apihelp-query+alltransclusions-param-limit": "O número total de elementos a serem devolvidos.",
+ "apihelp-query+alltransclusions-param-dir": "A direção de listagem.",
+ "apihelp-query+alltransclusions-example-B": "Listar os títulos transcluídos, incluindo aqueles em falta, com os identificadores das páginas de origem, começando no <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Listar os títulos transcluídos únicos.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Obtém todos os títulos transcluídos, marcando aqueles em falta.",
+ "apihelp-query+alltransclusions-example-generator": "Obtém as páginas que contêm as transclusões.",
+ "apihelp-query+allusers-summary": "Enumerar todos os utilizadores registados.",
+ "apihelp-query+allusers-param-from": "O nome de utilizador a partir do qual será começada a enumeração.",
+ "apihelp-query+allusers-param-to": "O nome de utilizador no qual será terminada a enumeração.",
+ "apihelp-query+allusers-param-prefix": "Procurar todos os nomes de utilizador que começam por este valor.",
+ "apihelp-query+allusers-param-dir": "A direção da ordenação.",
+ "apihelp-query+allusers-param-group": "Incluir só os utilizadores nos grupos indicados.",
+ "apihelp-query+allusers-param-excludegroup": "Excluir os utilizadores nos grupos indicados.",
+ "apihelp-query+allusers-param-rights": "Incluir só os utilizadores com as permissões indicadas. Não inclui as permissões atribuídas por grupos implícitos ou de promoção automática como *, utilizador, ou autoconfirmado.",
+ "apihelp-query+allusers-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Adiciona a informação sobre um bloqueio atual do utilizador.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Lista os grupos a que o utilizador pertence. Isto usa mais recursos do servidor e pode devolver menos resultados do que o limite.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Lista todos os grupos a que o utilizador pertence de forma automática.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Lista as permissões que o utilizador tem.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Adiciona a contagem de edições do utilizador.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Adiciona a data e hora de registo do utilizador, se estiver disponível (pode estar vazia).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Adiciona os identificadores centrais e o estado de ligação central (''attachment'') do utilizador.",
+ "apihelp-query+allusers-param-limit": "O número total de nomes de utilizador a serem devolvidos.",
+ "apihelp-query+allusers-param-witheditsonly": "Listar só os utilizadores que realizaram edições.",
+ "apihelp-query+allusers-param-activeusers": "Listar só os utilizadores ativos {{PLURAL:$1|no último dia|nos últimos $1 dias}}.",
+ "apihelp-query+allusers-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indicar também se o utilizador tem ligação com a wiki designada por este identificador.",
+ "apihelp-query+allusers-example-Y": "Listar utilizadores, começando pelo <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Obter informação sobre o atual estado de autenticação.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Testar se o estado atual de autenticação do utilizador é suficiente para a operação especificada, que exige condições seguras.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Obter informação sobre os pedidos de autenticação que são necessários para a operação de autenticação especificada.",
+ "apihelp-query+authmanagerinfo-example-login": "Obter os pedidos que podem ser usados ao iniciar uma sessão.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Obter os pedidos que podem ser usados ao iniciar uma sessão, com os campos combinados.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Testar se a autenticação é suficiente para a operação <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "Encontrar todas as páginas que contêm ligações para a página indicada.",
+ "apihelp-query+backlinks-param-title": "O título a ser procurado. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "O identificador do título a ser procurado. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "O espaço nominal a ser enumerado.",
+ "apihelp-query+backlinks-param-dir": "A direção de listagem.",
+ "apihelp-query+backlinks-param-filterredir": "Como filtrar os redirecionamentos. Se definido como <kbd>nonredirects</kbd> quando <var>$1redirect</var> está ativado, isto só é aplicado ao segundo nível.",
+ "apihelp-query+backlinks-param-limit": "O número total de páginas a serem devolvidas. Se <var>$1redirect</var> estiver ativado, o limite aplica-se a cada nível em separado (o que significa que até 2 * <var>$1limit</var> resultados podem ser devolvidos).",
+ "apihelp-query+backlinks-param-redirect": "Se a página que contém a ligação é um redirecionamento, procurar também todas as páginas que contêm ligações para esse redirecionamento. O limite máximo é reduzido para metade.",
+ "apihelp-query+backlinks-example-simple": "Mostrar as ligações para <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Obter informações sobre as páginas com ligações para <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Listar todos os utilizadores e endereços IP bloqueados.",
+ "apihelp-query+blocks-param-start": "A data e hora a partir da qual será começada a enumeração.",
+ "apihelp-query+blocks-param-end": "A data e hora na qual será terminada a enumeração.",
+ "apihelp-query+blocks-param-ids": "Lista dos identificadores de bloqueios a serem listados (opcional).",
+ "apihelp-query+blocks-param-users": "Lista dos utilizadores a serem procurados (opcional).",
+ "apihelp-query+blocks-param-ip": "Obter todos os bloqueios aplicáveis a este endereço IP ou intervalo CIDR, incluindo bloqueios de intervalos. Não pode ser usado em conjunto com <var>$3users</var>. Não são aceites intervalos CIDR maiores que IPv4/$1 ou IPv6/$2.",
+ "apihelp-query+blocks-param-limit": "O número máximo de bloqueios a listar.",
+ "apihelp-query+blocks-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Adiciona o identificador do bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Adiciona o nome do utilizador bloqueado.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Adiciona o identificador do utilizador bloqueado.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Adiciona o nome do utilizador que fez o bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Adiciona o identificador do utilizador que fez o bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Adiciona a data e hora de realização do bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Adiciona a data e hora de expiração do bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Adiciona o motivo apresentado para o bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Adiciona o intervalo de endereços IP afetado pelo bloqueio.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Etiqueta o bloqueio com (autoblock, anononly, etc.).",
+ "apihelp-query+blocks-param-show": "Mostrar só os bloqueios que preenchem estes critérios.\nPor exemplo, para ver só bloqueios indefinidos de endereços IP, defina <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "Listar bloqueios.",
+ "apihelp-query+blocks-example-users": "Listar os bloqueios dos utilizadores <kbd>Alice</kbd> e <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Listar todas as categorias às quais as páginas pertencem.",
+ "apihelp-query+categories-param-prop": "As propriedades adicionais que devem ser obtidas para cada categoria:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Adiciona a chave de ordenação (''string'' hexadecimal) e o prefixo da chave de ordenação (parte legível) da categoria.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Adiciona a data e hora a que a categoria foi adicionada.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Etiqueta as categorias que estão ocultadas com <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "Os tipos de categorias que serão mostrados.",
+ "apihelp-query+categories-param-limit": "O número de categorias a serem devolvidas.",
+ "apihelp-query+categories-param-categories": "Listar só estas categorias. Útil para verificar se uma determinada página está numa determinada categoria.",
+ "apihelp-query+categories-param-dir": "A direção de listagem.",
+ "apihelp-query+categories-example-simple": "Obter uma lista das categorias às quais pertence a página <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categories-example-generator": "Obter informação sobre todas as categorias usadas na página <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "Devolve informação sobre as categorias indicadas.",
+ "apihelp-query+categoryinfo-example-simple": "Obter informações sobre <kbd>Category:Foo</kbd> e <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Listar todas as páginas numa categoria específica.",
+ "apihelp-query+categorymembers-param-title": "A categoria que será enumerada (obrigatório). Tem de incluir o prefixo <kbd>{{ns:category}}:</kbd>. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "Identificador da categoria a ser enumerada. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Adiciona o identificador da página.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Adiciona o título e o identificador do espaço nominal da página.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Adiciona a chave usada para a ordenação da categoria (''string'' hexadecimal).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Adiciona o prefixo da chave usada para a ordenação da categoria (parte legível da chave de ordenação).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Adiciona o tipo com que a página foi categorizada (<samp>page</samp>, <samp>subcat</samp> ou <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Adiciona a data e hora de inclusão da página.",
+ "apihelp-query+categorymembers-param-namespace": "Incluir só as páginas destes espaços nominais. Note que pode usar <kbd>$1type=subcat</kbd> ou <kbd>$1type=file</kbd> em vez de <kbd>$1namespace=14</kbd> ou <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-type": "O tipo de membros de categoria que devem ser incluídos. Ignorado se <kbd>$1sort=timestamp</kbd> estiver definido.",
+ "apihelp-query+categorymembers-param-limit": "O número máximo de páginas a serem devolvidas.",
+ "apihelp-query+categorymembers-param-sort": "Propriedade pela qual fazer a ordenação.",
+ "apihelp-query+categorymembers-param-dir": "A direção da ordenação.",
+ "apihelp-query+categorymembers-param-start": "A data e hora da página a partir da qual será começada a listagem. Só pode ser usado em conjunto com <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "A data e hora da página na qual será terminada a listagem. Só pode ser usado em conjunto com <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "A chave de ordenação a partir da qual a listagem será começada, como devolvida por <kbd>$1prop=sortkey</kbd>. Só pode ser usado com <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "A chave de ordenação na qual a listagem será terminada, como devolvida por <kbd>$1prop=sortkey</kbd>. só pode ser usado com <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "O prefixo da chave de ordenação a partir do qual a listagem será começada. Só pode ser usado com <kbd>$1sort=sortkey</kbd>. Tem precedência sobre <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "O prefixo da chave de ordenação <strong>antes</strong> do qual a listagem será terminada (não <strong>no</strong> qual; se este valor ocorrer não será incluído!). Só pode ser usado com <kbd>$1sort=sortkey</kbd>. Tem precedência sobre <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-startsortkey": "Em vez dele, usar $1starthexsortkey.",
+ "apihelp-query+categorymembers-param-endsortkey": "Em vez dele, usar $1endhexsortkey.",
+ "apihelp-query+categorymembers-example-simple": "Obter as primeiras 10 páginas na categoria <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Obter informações sobre as primeiras 10 páginas na categoria <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Obter a lista do contribuidores autenticados e a contagem dos contribuidores anónimos de uma página.",
+ "apihelp-query+contributors-param-group": "Incluir só os utilizadores nos grupos indicados. Não inclui os grupos implícitos ou de promoção automática como *, utilizador, ou autoconfirmado.",
+ "apihelp-query+contributors-param-excludegroup": "Excluir os utilizadores nos grupos indicados. Não inclui os grupos implícitos ou de promoção automática como *, utilizador, ou autoconfirmado.",
+ "apihelp-query+contributors-param-rights": "Incluir só os utilizadores com as permissões indicadas. Não inclui as permissões atribuídas por grupos implícitos ou de promoção automática como *, utilizador, ou autoconfirmado.",
+ "apihelp-query+contributors-param-excluderights": "Excluir os utilizadores com as permissões indicadas. Não inclui as permissões atribuídas por grupos implícitos ou de promoção automática como *, utilizador, ou autoconfirmado.",
+ "apihelp-query+contributors-param-limit": "O número de contribuidores a serem devolvidos.",
+ "apihelp-query+contributors-example-simple": "Mostrar os contribuidores da página <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-summary": "Obter informações sobre as revisões eliminadas.",
+ "apihelp-query+deletedrevisions-extended-description": "Pode ser usado de várias maneiras:\n# Obter as revisões eliminadas para um conjunto de páginas, definindo títulos ou identificadores de página. Ordenados por título e data e hora.\n# Obter dados sobre um conjunto de revisões eliminadas definindo os respetivos ids: com identificadores de revisão. Ordenados pelo identificador de revisão.",
+ "apihelp-query+deletedrevisions-param-start": "A data e hora da revisão a partir da qual será começada a enumeração. Ignorado ao processar uma lista de identificadores de revisão.",
+ "apihelp-query+deletedrevisions-param-end": "A data e hora da revisão na qual será terminada a enumeração. Ignorado ao processar uma lista de identificadores de revisão.",
+ "apihelp-query+deletedrevisions-param-tag": "Listar só as revisões marcadas com esta etiqueta.",
+ "apihelp-query+deletedrevisions-param-user": "Listar só as revisões deste utilizador.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Não listar as revisões deste utilizador.",
+ "apihelp-query+deletedrevisions-example-titles": "Listar as revisões eliminadas das páginas <kbd>Main Page</kbd> e <kbd>Talk:Main Page</kbd>, com o conteúdo.",
+ "apihelp-query+deletedrevisions-example-revids": "Listar a informação da revisão eliminada <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "Listar as revisões eliminadas.",
+ "apihelp-query+deletedrevs-extended-description": "Opera em três modos:\n# Listar as revisões eliminadas dos títulos indicados, ordenadas por data e hora.\n# Listar as contribuições eliminadas do utilizador indicado, ordenadas por data e hora (sem especificar títulos).\n# Listar todas as revisões eliminadas no espaço nominal indicado, ordenadas por título e por data e hora (sem especificar títulos, sem definir $1user).\n\nAlguns parâmetros só se aplicam a alguns modos e são ignorados noutros.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Modo|Modos}}: $2",
+ "apihelp-query+deletedrevs-param-start": "A data e hora da revisão a partir da qual será começada a enumeração.",
+ "apihelp-query+deletedrevs-param-end": "A data e hora da revisão na qual será terminada a enumeração.",
+ "apihelp-query+deletedrevs-param-from": "Começar a listagem neste título.",
+ "apihelp-query+deletedrevs-param-to": "Terminar a listagem neste título.",
+ "apihelp-query+deletedrevs-param-prefix": "Procurar todos os títulos de página que começam por este valor.",
+ "apihelp-query+deletedrevs-param-unique": "Listar só uma revisão para cada página.",
+ "apihelp-query+deletedrevs-param-tag": "Listar só as revisões marcadas com esta etiqueta.",
+ "apihelp-query+deletedrevs-param-user": "Listar só as revisões deste utilizador.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Não listar edições deste utilizador.",
+ "apihelp-query+deletedrevs-param-namespace": "Listar só as páginas neste domínio.",
+ "apihelp-query+deletedrevs-param-limit": "O número máximo de revisões a serem listadas.",
+ "apihelp-query+deletedrevs-param-prop": "As propriedades a serem obtidas:\n;revid:Adiciona o identificador da revisão eliminada.\n;parentid:Adiciona o identificador da revisão anterior da página.\n;user:Adiciona o utilizador que fez a revisão.\n;userid:Adiciona o identificador do utilizador que fez a revisão.\n;comment:Adiciona o comentário da revisão.\n;parsedcomment:Adiciona o comentário da revisão após passagem pelo analisador sintático.\n;minor:Etiqueta a revisão como uma revisão menor.\n;len:Adiciona o comprimento (em bytes) da revisão.\n;sha1:Adiciona o SHA-1 da revisão (na base 16).\n;content:Adiciona o conteúdo da revisão.\n;token:<span class=\"apihelp-deprecated\">Obsoleto.</span> Fornece a chave da edição.\n;tags:Etiquetas da revisão.",
+ "apihelp-query+deletedrevs-example-mode1": "Listar só as últimas revisões eliminadas das páginas <kbd>Main Page</kbd> e <kbd>Talk:Main Page</kbd>, com o conteúdo (modo 1).",
+ "apihelp-query+deletedrevs-example-mode2": "Listar as últimas 50 contribuições eliminadas do utilizador <kbd>Bob</kbd> (modo 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Listar as primeiras 50 revisões eliminadas no espaço nominal principal (modo 3).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Listar as primeiras 50 páginas eliminadas no espaço nominal {{ns:talk}} (modo 3).",
+ "apihelp-query+disabled-summary": "Este módulo de consulta foi desativado.",
+ "apihelp-query+duplicatefiles-summary": "Listar todos os ficheiros que são duplicados dos ficheiros indicados com base no seu resumo criptográfico.",
+ "apihelp-query+duplicatefiles-param-limit": "O número de ficheiros duplicados a serem devolvidos.",
+ "apihelp-query+duplicatefiles-param-dir": "A direção de listagem.",
+ "apihelp-query+duplicatefiles-param-localonly": "Procurar ficheiros só no repositório local.",
+ "apihelp-query+duplicatefiles-example-simple": "Procurar os ficheiros duplicados de [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Procurar duplicados de todos os ficheiros.",
+ "apihelp-query+embeddedin-summary": "Encontrar todas as páginas que incorporam (transcluem) o título indicado.",
+ "apihelp-query+embeddedin-param-title": "O título a procurar. Não pode ser usado em conjunto com $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "O identificador da página a procurar. Não pode ser usado em conjunto com $1title.",
+ "apihelp-query+embeddedin-param-namespace": "O espaço nominal a ser enumerado.",
+ "apihelp-query+embeddedin-param-dir": "A direção de listagem.",
+ "apihelp-query+embeddedin-param-filterredir": "Como filtrar os redirecionamentos.",
+ "apihelp-query+embeddedin-param-limit": "O número total de páginas a serem devolvidas.",
+ "apihelp-query+embeddedin-example-simple": "Mostrar as páginas que transcluem <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "Obter informação sobre as páginas que transcluem <kbd>Template:Stub</kbd>.",
+ "apihelp-query+extlinks-summary": "Devolve todos os URL externos (que não sejam interwikis) das páginas especificadas.",
+ "apihelp-query+extlinks-param-limit": "O número de ''links'' a serem devolvidos.",
+ "apihelp-query+extlinks-param-protocol": "Protocolo do URL. Se vazio e <var>$1query</var> está definido, o protocolo é <kbd>http</kbd>. Deixe isto e <var>$1query</var> vazios para listar todos os ''links'' externos.",
+ "apihelp-query+extlinks-param-query": "Texto de pesquisa sem protocolo. Útil para verificar se uma determinada página contém um determinado URL externo.",
+ "apihelp-query+extlinks-param-expandurl": "Expandir os URL relativos a protocolo com o protocolo canónico.",
+ "apihelp-query+extlinks-example-simple": "Obter uma lista das ligações externas na <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-summary": "Enumerar as páginas que contêm um determinado URL.",
+ "apihelp-query+exturlusage-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Adiciona o identificador da página.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Adiciona o título e o identificador do espaço nominal da página.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Adiciona o URL usado na página.",
+ "apihelp-query+exturlusage-param-protocol": "Protocolo do URL. Se vazio e <var>$1query</var> está definido, o protocolo é <kbd>http</kbd>. Deixe isto e <var>$1query</var> vazios para listar todos os ''links'' externos.",
+ "apihelp-query+exturlusage-param-query": "Texto da pesquisa sem um protocolo. Ver [[Special:LinkSearch]]. Deixar vazio para listar todos os ''links'' externos.",
+ "apihelp-query+exturlusage-param-namespace": "Os espaços nominais a serem enumerados.",
+ "apihelp-query+exturlusage-param-limit": "O número de páginas a serem devolvidas.",
+ "apihelp-query+exturlusage-param-expandurl": "Expandir os URL relativos a protocolo com o protocolo canónico.",
+ "apihelp-query+exturlusage-example-simple": "Mostrar as páginas com ligações para <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-summary": "Enumerar todos os ficheiros eliminados sequencialmente.",
+ "apihelp-query+filearchive-param-from": "O título da imagem a partir do qual será começada a enumeração.",
+ "apihelp-query+filearchive-param-to": "O título da imagem no qual será terminada a enumeração.",
+ "apihelp-query+filearchive-param-prefix": "Procurar todos os títulos de imagem que começam por este valor.",
+ "apihelp-query+filearchive-param-limit": "O número total de imagens a devolver.",
+ "apihelp-query+filearchive-param-dir": "A direção de listagem.",
+ "apihelp-query+filearchive-param-sha1": "O resumo criptográfico SHA-1 da imagem. Tem precedência sobre $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "O resumo criptográfico da imagem na base 36 (usado no MediaWiki).",
+ "apihelp-query+filearchive-param-prop": "As informações da imagem que devem ser obtidas:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Adiciona o resumo criptográfico SHA-1 da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Adiciona a data e hora da versão carregada.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Adiciona o utilizador que carregou a versão da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Adiciona o tamanho da imagem em ''bytes'' e a altura, largura e contagem de páginas (se aplicável).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Nome alternativo para ''size''.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Adiciona a descrição da versão da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Fazer a análise sintática da descrição da versão.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Adiciona o tipo MIME da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Adiciona o tipo de multimédia da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Lista os metadados Exif para a versão da imagem.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Adiciona a profundidade em ''bits'' da versão.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Adiciona o nome de ficheiro da versão arquivada das versões anteriores à última.",
+ "apihelp-query+filearchive-example-simple": "Mostrar uma lista de todos os ficheiros eliminados.",
+ "apihelp-query+filerepoinfo-summary": "Devolver meta informação sobre os repositórios de imagens configurados na wiki.",
+ "apihelp-query+filerepoinfo-param-prop": "As propriedades do repositório que devem ser obtidas (em algumas wikis poderão haver mais disponíveis):\n;apiurl:URL para a API do repositório - útil para obter informação de imagens do servidor.\n;name:A chave para o repositório - usada, por exemplo, em <var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> e nos valores de retorno de [[Special:ApiHelp/query+imageinfo|imageinfo]].\n;displayname:O nome legível da wiki repositório.\n;rooturl:URL de raiz para endereços de imagens.\n;local:Se o repositório é o local ou não.",
+ "apihelp-query+filerepoinfo-example-simple": "Obter informações sobre os repositórios de ficheiros.",
+ "apihelp-query+fileusage-summary": "Encontrar todas as páginas que usam os ficheiros indicados.",
+ "apihelp-query+fileusage-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "O identificador de cada página.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "O título de cada página.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Indicar se a página é um redirecionamento.",
+ "apihelp-query+fileusage-param-namespace": "Incluir só as páginas destes espaços nominais.",
+ "apihelp-query+fileusage-param-limit": "O número de elementos a serem devolvidos.",
+ "apihelp-query+fileusage-param-show": "Mostrar só as páginas que correspondem a estes critérios:\n;redirect:Mostrar só os redirecionamentos.\n;!redirect:Mostrar só os não redirecionamentos.",
+ "apihelp-query+fileusage-example-simple": "Obter uma lista das páginas que usam [[:File:Example.jpg]].",
+ "apihelp-query+fileusage-example-generator": "Obter informação sobre as páginas que usam [[:File:Example.jpg]].",
+ "apihelp-query+imageinfo-summary": "Devolve informação do ficheiro e o historial de carregamentos.",
+ "apihelp-query+imageinfo-param-prop": "As informações do ficheiro que devem ser obtidas:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Adiciona a data e hora da versão carregada.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Adiciona o utilizador que carregou cada versão de ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Adiciona o identificador do utilizador que carregou cada versão de ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "O comentário da versão.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Fazer a análise sintática do comentário da versão.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Adiciona o título canónico do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Devolve URL para o ficheiro e página de descrição.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Adiciona o tamanho do ficheiro em ''bytes'' e a altura, largura e contagem de páginas (se aplicável).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Nome alternativo para ''size''.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Adiciona o resumo criptográfico SHA-1 do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Adiciona o tipo MIME do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Adiciona o tipo MIME da miniatura (requer URL e o parâmetro $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Adiciona o tipo de multimédia do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Lista os metadados Exif para a versão do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Lista os metadados genéricos do formato de ficheiro para a versão do ficheiro.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Lista os metadados formatados, combinados de várias fontes. Os resultados estão no formato HTML.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Adiciona o nome de ficheiro da versão arquivada das versões anteriores à última.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Adiciona a profundidade em ''bits'' da versão.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Usado pela página Special:Upload para obter informação sobre um ficheiro existente. Não se destina a ser usado fora do núcleo central do MediaWiki.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Indica se o ficheiro está na lista [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-limit": "O número de revisões a serem devolvidas por ficheiro.",
+ "apihelp-query+imageinfo-param-start": "Data e hora a partir da qual será começada a listagem.",
+ "apihelp-query+imageinfo-param-end": "Data e hora na qual será terminada a listagem.",
+ "apihelp-query+imageinfo-param-urlwidth": "Se $2prop=url está definido, será devolvido um URL para uma imagem redimensionada com este comprimento.\nPor razões de desempenho, se esta opção for usada não serão devolvidas mais de $1 imagens redimensionadas.",
+ "apihelp-query+imageinfo-param-urlheight": "Semelhante a $1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "Versão de metadados a ser usada. Se for especificado o valor <kbd>latest</kbd>, usar a versão mais recente. Por omissão tem o valor <kbd>1</kbd> para compatibilidade com versões anteriores.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "Em que língua obter extmetadata. Isto afeta tanto a tradução que será obtida, caso existam várias, como a formatação de números e vários outros valores.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Se estiverem disponíveis traduções para a propriedade extmetadata, obtê-las todas.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Se for especificado e não estiver vazio, só serão devolvidas estas chaves para $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "Um parâmetro de texto específico do objeto. Por exemplo, ficheiros PDF podem usar <kbd>page15-100px</kbd>. <var>$1urlwidth</var> tem de ser usado e ser consistente com <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Se <kbd>$2prop=badfile</kbd> estiver definido, este é o título da página usado ao calcular a [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-localonly": "Procurar ficheiros só no repositório local.",
+ "apihelp-query+imageinfo-example-simple": "Obter informação sobre a versão atual do ficheiro [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "Obter informação sobre as versões de [[:File:Test.jpg]] a partir de 2008.",
+ "apihelp-query+images-summary": "Devolve todos os ficheiros contidos nas páginas indicadas.",
+ "apihelp-query+images-param-limit": "O número de ficheiros a serem devolvidos.",
+ "apihelp-query+images-param-images": "Listar só estes ficheiros. Útil para verificar se uma determinada página tem um determinado ficheiro.",
+ "apihelp-query+images-param-dir": "A direção de listagem.",
+ "apihelp-query+images-example-simple": "Obter uma lista dos ficheiros usados na página [[Main Page]].",
+ "apihelp-query+images-example-generator": "Obter informação sobre todos os ficheiros usados na página [[Main Page]].",
+ "apihelp-query+imageusage-summary": "Encontrar todas as páginas que utilizam o título da imagem indicada.",
+ "apihelp-query+imageusage-param-title": "O título a procurar. Não pode ser usado em conjunto com $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "O identificador da página a procurar. Não pode ser usado em conjunto com $1title.",
+ "apihelp-query+imageusage-param-namespace": "O espaço nominal a ser enumerado.",
+ "apihelp-query+imageusage-param-dir": "A direção de listagem.",
+ "apihelp-query+imageusage-param-filterredir": "Como filtrar redirecionamentos. Se definido como <kbd>nonredirects</kbd> quando <var>$1redirect</var> está ativado, isto só é aplicado ao segundo nível.",
+ "apihelp-query+imageusage-param-limit": "O número total de páginas a serem devolvidas. Se <var>$1redirect</var> estiver ativado, o nível aplica-se a cada nível em separado (o que significa que até 2 * <var>$1limit</var> resultados podem ser devolvidos).",
+ "apihelp-query+imageusage-param-redirect": "Se a página que contém a ligação é um redirecionamento, procurar também todas as páginas que contêm ligações para esse redirecionamento. O limite máximo é reduzido para metade.",
+ "apihelp-query+imageusage-example-simple": "Mostrar as páginas que usam [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Obter informações sobre as páginas que usam o ficheiro [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "Obter a informação básica da página.",
+ "apihelp-query+info-param-prop": "As propriedades adicionais que devem ser obtidas:",
+ "apihelp-query+info-paramvalue-prop-protection": "Listar o nível de proteção de cada página.",
+ "apihelp-query+info-paramvalue-prop-talkid": "O identificador da página de discussão de cada página que não seja de discussão.",
+ "apihelp-query+info-paramvalue-prop-watched": "Listar o estado de vigilância de cada página.",
+ "apihelp-query+info-paramvalue-prop-watchers": "O número de vigilantes, se for permitido.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "O número de vigilantes de cada página que visitaram edições recentes dessa página, se permitido.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "A data e hora das notificações de alterações de cada página vigiada.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "O identificador da página progenitora de cada página de discussão.",
+ "apihelp-query+info-paramvalue-prop-url": "Fornece um URL completo, um URL de edição e o URL canónico, para cada página.",
+ "apihelp-query+info-paramvalue-prop-readable": "Indica se o utilizador pode ler esta página.",
+ "apihelp-query+info-paramvalue-prop-preload": "Fornece o texto devolvido por EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Fornece a forma como o título da página é apresentado.",
+ "apihelp-query+info-param-testactions": "Testar se o utilizador pode realizar certas operações na página.",
+ "apihelp-query+info-param-token": "Em substituição, usar [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-query+info-example-simple": "Obter informações sobre a página <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Obter informação geral e de proteção sobre a página <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-summary": "Encontrar todas as páginas que contêm ''links'' para as páginas indicadas.",
+ "apihelp-query+iwbacklinks-extended-description": "Pode ser usado para encontrar todos os ''links'' com um prefixo, ou todos os ''links'' para um título (com um prefixo especificado). Se nenhum parâmetro for usado, isso efetivamente significa \"todos os ''links'' interwikis\".",
+ "apihelp-query+iwbacklinks-param-prefix": "O prefixo interwikis.",
+ "apihelp-query+iwbacklinks-param-title": "O ''link'' interwikis a ser procurado. Tem de ser usado em conjunto com <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "O número total de páginas a serem devolvidas.",
+ "apihelp-query+iwbacklinks-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Adiciona o prefixo do ''link'' interwikis.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Adiciona o título do ''link'' interwikis.",
+ "apihelp-query+iwbacklinks-param-dir": "A direção de listagem.",
+ "apihelp-query+iwbacklinks-example-simple": "Obter as páginas que contêm ligações para [[wikibooks:Test]].",
+ "apihelp-query+iwbacklinks-example-generator": "Obter informação sobre as páginas que contêm ligações para [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-summary": "Devolve todos os ''links'' interwikis das páginas indicadas.",
+ "apihelp-query+iwlinks-param-url": "Indica se deve ser obtido o URL completo (não pode ser usado com $1prop).",
+ "apihelp-query+iwlinks-param-prop": "As propriedades adicionais que devem ser obtidas para cada ''link'' interlínguas:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Adiciona o URL completo.",
+ "apihelp-query+iwlinks-param-limit": "O número de ''links'' interwikis a serem devolvidos.",
+ "apihelp-query+iwlinks-param-prefix": "Devolver só os ''links'' interwikis com este prefixo.",
+ "apihelp-query+iwlinks-param-title": "Link interwikis a ser procurado. Tem de ser usado em conjunto com <var>$1prefix</var>.",
+ "apihelp-query+iwlinks-param-dir": "A direção de listagem.",
+ "apihelp-query+iwlinks-example-simple": "Obter os ''links'' interwikis da página <kbd>Main Page</kbd>.",
+ "apihelp-query+langbacklinks-summary": "Encontrar todas as páginas que contêm ''links'' para o ''link'' interlínguas indicado.",
+ "apihelp-query+langbacklinks-extended-description": "Pode ser usado para encontrar todos os ''links'' para um determinado código de língua, ou todos os ''links'' para um determinado título (de uma língua). Se nenhum for usado, isso efetivamente significa \"todos os ''links'' interlínguas\".\n\nNote que os ''links'' interlínguas adicionados por extensões podem não ser considerados.",
+ "apihelp-query+langbacklinks-param-lang": "A língua do ''link'' interlínguas.",
+ "apihelp-query+langbacklinks-param-title": "Link interlínguas a ser procurado. Tem de ser usado com $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "O número total de páginas a serem devolvidas.",
+ "apihelp-query+langbacklinks-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Adiciona o código de língua da ligação interlínguas.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Adiciona o título do ''link'' interlínguas.",
+ "apihelp-query+langbacklinks-param-dir": "A direção de listagem.",
+ "apihelp-query+langbacklinks-example-simple": "Obter as páginas que contêm ligações para [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Obter informações sobre as páginas que contêm ligações para [[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "Devolve todos os ''links'' interlínguas das páginas indicadas.",
+ "apihelp-query+langlinks-param-limit": "O número de ''links'' interlínguas a serem devolvidos.",
+ "apihelp-query+langlinks-param-url": "Indica se deve ser obtido o URL completo (não pode ser usado com $1prop).",
+ "apihelp-query+langlinks-param-prop": "As propriedades adicionais que devem ser obtidas para cada ''link'' interlínguas:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Adiciona o URL completo.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "Adiciona o nome da língua localizado (melhor esforço). Usar <var>$1inlanguagecode</var> para controlar a língua.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Adiciona o nome nativo da língua.",
+ "apihelp-query+langlinks-param-lang": "Devolver só os ''links'' interlínguas com este código de língua.",
+ "apihelp-query+langlinks-param-title": "''Link'' a ser procurado. Tem de ser usado com <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "A direção de listagem.",
+ "apihelp-query+langlinks-param-inlanguagecode": "O código de língua para os nomes de língua localizados.",
+ "apihelp-query+langlinks-example-simple": "Obter os ''links'' interlínguas da página <kbd>Main Page</kbd>.",
+ "apihelp-query+links-summary": "Devolve todos os ''links'' das páginas indicadas.",
+ "apihelp-query+links-param-namespace": "Mostrar apenas os ''links'' destes espaços nominais.",
+ "apihelp-query+links-param-limit": "O número de ''links'' a serem devolvidos.",
+ "apihelp-query+links-param-titles": "Listar só as ligações para estes títulos. Útil para verificar se uma determinada página contém ligações para um determinado título.",
+ "apihelp-query+links-param-dir": "A direção de listagem.",
+ "apihelp-query+links-example-simple": "Obter os ''links'' da página <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-generator": "Obter informação sobre as páginas ligadas na página <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-namespaces": "Obter os ''links'' da página <kbd>Main Page</kbd> nos espaços nominais {{ns:user}} e {{ns:template}}.",
+ "apihelp-query+linkshere-summary": "Encontrar todas as páginas que contêm ''links'' para as páginas indicadas.",
+ "apihelp-query+linkshere-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "O identificador de cada página.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "O título de cada página.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Indicar se a página é um redirecionamento.",
+ "apihelp-query+linkshere-param-namespace": "Incluir só as páginas nestes espaços nominais.",
+ "apihelp-query+linkshere-param-limit": "O número de páginas a serem devolvidas.",
+ "apihelp-query+linkshere-param-show": "Mostrar só as páginas que correspondem a estes critérios:\n;redirect:Mostrar só os redirecionamentos.\n;!redirect:Mostrar só os não redirecionamentos.",
+ "apihelp-query+linkshere-example-simple": "Obter uma lista das páginas com ligações para a página [[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "Obter informação sobre as páginas com ligações para a página [[Main Page]].",
+ "apihelp-query+logevents-summary": "Obter eventos dos registos.",
+ "apihelp-query+logevents-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Adiciona o identificador do evento do registo.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Adiciona o título da página do evento do registo.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Adiciona o tipo do evento do registo.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Adiciona o utilizador responsável pelo evento do registo.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Adiciona o identificador do utilizador responsável pelo evento do registo.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "Adiciona a data e hora do evento do registo.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Adiciona o comentário do evento do registo.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "Adiciona o comentário do evento do registo, após análise sintática.",
+ "apihelp-query+logevents-paramvalue-prop-details": "Lista detalhes adicionais sobre o evento do registo.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Lista as etiquetas do evento do registo.",
+ "apihelp-query+logevents-param-type": "Filtrar as entradas do registo para produzir só as deste tipo.",
+ "apihelp-query+logevents-param-action": "Filtrar as entradas do registo para produzir só as desta operação. Tem precedência sobre <var>$1type</var>. Na lista dos valores possíveis, os valores com o carácter de substituição asterisco <kbd>action/*</kbd> podem conter outros valores após a barra (/).",
+ "apihelp-query+logevents-param-start": "A data e hora a partir da qual será começada a enumeração.",
+ "apihelp-query+logevents-param-end": "A data e hora na qual será terminada a enumeração.",
+ "apihelp-query+logevents-param-user": "Filtrar as entradas para produzir só as criadas pelo utilizador indicado.",
+ "apihelp-query+logevents-param-title": "Filtrar as entradas para produzir só as relacionadas com uma página.",
+ "apihelp-query+logevents-param-namespace": "Filtrar as entradas para produzir só as que estão no espaço nominal indicado.",
+ "apihelp-query+logevents-param-prefix": "Filtrar as entradas para produzir só as que começam por este prefixo.",
+ "apihelp-query+logevents-param-tag": "Listar só as entradas de eventos marcadas com esta etiqueta.",
+ "apihelp-query+logevents-param-limit": "O número total de entradas de eventos a serem devolvidas.",
+ "apihelp-query+logevents-example-simple": "Listar os eventos recentes do registo.",
+ "apihelp-query+pagepropnames-summary": "Listar todos os nomes de propriedades de páginas em uso nesta wiki.",
+ "apihelp-query+pagepropnames-param-limit": "O número máximo de nomes a serem devolvidos.",
+ "apihelp-query+pagepropnames-example-simple": "Obter os primeiros 10 nomes de propriedades.",
+ "apihelp-query+pageprops-summary": "Obter várias propriedades de página definidas no conteúdo da página.",
+ "apihelp-query+pageprops-param-prop": "Listar só estas propriedades de página (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> devolve os nomes das propriedades de página em uso). Útil para verificar se as páginas usam uma determinada propriedade de página.",
+ "apihelp-query+pageprops-example-simple": "Obter as propriedades das páginas <kbd>Main Page</kbd> e <kbd>MediaWiki</kbd>.",
+ "apihelp-query+pageswithprop-summary": "Listar todas as páginas que usam uma determinada propriedade.",
+ "apihelp-query+pageswithprop-param-propname": "A propriedade de página a partir da qual as páginas serão enumeradas (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> devolve os nomes das propriedades de página que estão a ser usadas).",
+ "apihelp-query+pageswithprop-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Adiciona o identificador da página.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Adiciona o título e o identificador do espaço nominal da página.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Adiciona o valor da propriedade da página.",
+ "apihelp-query+pageswithprop-param-limit": "O número máximo de páginas a serem devolvidas.",
+ "apihelp-query+pageswithprop-param-dir": "A direção da ordenação.",
+ "apihelp-query+pageswithprop-example-simple": "Listar as primeiras 10 páginas que usam a propriedade <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "Obter informação adicional sobre as primeiras 10 páginas que usam <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-summary": "Realizar uma procura de prefixo nos títulos de página.",
+ "apihelp-query+prefixsearch-extended-description": "Apesar da semelhança de nomes, este módulo não pretende ser equivalente a [[Special:PrefixIndex]]; para este, consulte <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> com o parâmetro <kbd>apprefix</kbd>. O propósito deste módulo é semelhante a <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>: receber dados introduzidos pelo utilizador e devolver os títulos com melhor correspondência. Dependendo do motor de busca do servidor, isto pode incluir correções de erros ortográficos, evitar redirecionamentos, e outras heurísticas.",
+ "apihelp-query+prefixsearch-param-search": "O texto a ser pesquisado.",
+ "apihelp-query+prefixsearch-param-namespace": "Os espaços nominais onde realizar a pesquisa.",
+ "apihelp-query+prefixsearch-param-limit": "O número máximo de resultados a serem devolvidos.",
+ "apihelp-query+prefixsearch-param-offset": "O número de resultados a serem omitidos.",
+ "apihelp-query+prefixsearch-example-simple": "Procurar os títulos de página que começam por <kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "O perfil de pesquisa a ser utilizado.",
+ "apihelp-query+protectedtitles-summary": "Listar todos os títulos cuja criação está impedida.",
+ "apihelp-query+protectedtitles-param-namespace": "Listar só os títulos nestes espaços nominais.",
+ "apihelp-query+protectedtitles-param-level": "Listar só os títulos com estes níveis de proteção.",
+ "apihelp-query+protectedtitles-param-limit": "O número total de páginas a serem devolvidas.",
+ "apihelp-query+protectedtitles-param-start": "Começar a listagem pelo título que tem esta data e hora de proteção.",
+ "apihelp-query+protectedtitles-param-end": "Terminar a listagem no título que tem esta data e hora de proteção.",
+ "apihelp-query+protectedtitles-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "Adiciona a data e hora a que a proteção foi adicionada.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Adiciona o utilizador que fez a proteção.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Adiciona o identificador do utilizador que fez a proteção.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Adiciona o comentário da proteção.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "Adiciona o comentário da proteção após a análise sintática.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "Adiciona a data e hora a que a proteção será removida.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Adiciona o nível de proteção.",
+ "apihelp-query+protectedtitles-example-simple": "Lista os títulos protegidos.",
+ "apihelp-query+protectedtitles-example-generator": "Encontrar as ligações para os títulos protegidos que pertencem ao espaço nominal principal.",
+ "apihelp-query+querypage-summary": "Obter uma lista fornecida por uma página especial baseada em consultas (''QueryPage'').",
+ "apihelp-query+querypage-param-page": "O nome da página especial. Note que este é sensível a maiúsculas e minúsculas.",
+ "apihelp-query+querypage-param-limit": "O número de resultados a serem devolvidos.",
+ "apihelp-query+querypage-example-ancientpages": "Devolver os resultados da página [[Special:Ancientpages]].",
+ "apihelp-query+random-summary": "Obter um conjunto de páginas aleatórias.",
+ "apihelp-query+random-extended-description": "As páginas são listadas em sequência fixa, só o ponto de início da listagem é aleatório. Isto significa, por exemplo, que se a primeira página aleatória na lista é <samp>Main Page</samp>, a página <samp>List of fictional monkeys</samp> será <em>sempre</em> a segunda, a página <samp>List of people on stamps of Vanuatu</samp> a terceira, etc.",
+ "apihelp-query+random-param-namespace": "Devolver só as páginas que estão nestes espaços nominais.",
+ "apihelp-query+random-param-limit": "Limitar o número de páginas aleatórias que serão devolvidas.",
+ "apihelp-query+random-param-redirect": "Em vez dele, usar <kbd>$1filterredir=redirects</kbd>.",
+ "apihelp-query+random-param-filterredir": "Como filtrar redirecionamentos.",
+ "apihelp-query+random-example-simple": "Devolver duas páginas aleatórias do espaço nominal principal.",
+ "apihelp-query+random-example-generator": "Devolver informação de página sobre duas páginas aleatórias do espaço nominal principal.",
+ "apihelp-query+recentchanges-summary": "Enumerar as mudanças recentes.",
+ "apihelp-query+recentchanges-param-start": "A data e hora a partir da qual será começada a enumeração.",
+ "apihelp-query+recentchanges-param-end": "A data e hora na qual será terminada a enumeração.",
+ "apihelp-query+recentchanges-param-namespace": "Filtrar as mudanças para produzir só as destes espaços nominais.",
+ "apihelp-query+recentchanges-param-user": "Listar só as mudanças feitas por este utilizador.",
+ "apihelp-query+recentchanges-param-excludeuser": "Não listar as mudanças feitas por este utilizador.",
+ "apihelp-query+recentchanges-param-tag": "Listar só as mudanças marcadas com esta etiqueta.",
+ "apihelp-query+recentchanges-param-prop": "Incluir informações adicionais:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "Adiciona o utilizador responsável pela edição e marca se o utilizador é um endereço IP.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "Adiciona o identificador do utilizador responsável pela edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Adiciona o comentário da edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "Adiciona o comentário da edição, após análise sintática.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Adiciona as etiquetas da edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Adiciona a data e hora da edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Adiciona o título de página da edição.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "Adiciona o identificadores da página, das mudanças recentes, e das revisões nova e antiga.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Adiciona os tamanhos antigo e novo da página em ''bytes''.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "Etiqueta a página se esta for um redirecionamento.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Etiqueta as edições que podem ser patrulhadas, marcando-as como patrulhadas ou não patrulhadas.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Adiciona informação de registo (identificador do registo, tipo de entrada, etc.) às entradas do registo.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Lista as etiquetas da entrada.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Adiciona a soma de controlo do conteúdo para as entradas associadas com uma revisão.",
+ "apihelp-query+recentchanges-param-token": "Em substituição, usar <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-query+recentchanges-param-show": "Mostrar só as entradas que correspondem a estes critérios. Por exemplo, para ver só as edições menores feitas por utilizadores autenticados, defina $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "O número total de mudanças a serem devolvidas.",
+ "apihelp-query+recentchanges-param-type": "Os tipos de mudanças a serem mostradas.",
+ "apihelp-query+recentchanges-param-toponly": "Listar só as alterações que são a revisão mais recente.",
+ "apihelp-query+recentchanges-param-generaterevisions": "Ao ser usado como gerador, gerar identificadores de revisões em vez de títulos. As entradas das mudanças recentes que não tenham identificadores de revisão associados (por exemplo, a maioria das entradas do registo) não geram nada.",
+ "apihelp-query+recentchanges-example-simple": "Listar as mudanças recentes.",
+ "apihelp-query+recentchanges-example-generator": "Obter informação de página acerca das mudanças recentes não patrulhadas.",
+ "apihelp-query+redirects-summary": "Devolve todos os redirecionamentos para as páginas indicadas.",
+ "apihelp-query+redirects-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "O identificador de página de cada redirecionamento.",
+ "apihelp-query+redirects-paramvalue-prop-title": "O título de cada redirecionamento.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "O fragmento de cada redirecionamento, se existir.",
+ "apihelp-query+redirects-param-namespace": "Incluir só as páginas destes espaços nominais.",
+ "apihelp-query+redirects-param-limit": "O número de redirecionamentos a serem devolvidos.",
+ "apihelp-query+redirects-param-show": "Mostrar só as páginas que correspondem a estes critérios:\n;fragment:Mostrar só os redirecionamentos com um fragmento.\n;!fragment:Mostrar só os redirecionamentos sem um fragmento.",
+ "apihelp-query+redirects-example-simple": "Obter uma lista dos redirecionamentos para a página [[Main Page]].",
+ "apihelp-query+redirects-example-generator": "Obter informação sobre todos os redirecionamentos para a página [[Main Page]].",
+ "apihelp-query+revisions-summary": "Obter informação da revisão.",
+ "apihelp-query+revisions-extended-description": "Pode ser usado de várias maneiras:\n# Obter dados sobre um conjunto de páginas (última revisão), definindo títulos ou identificadores de páginas.\n# Obter as revisões de uma página indicada, usando títulos ou identificadores de páginas, com start, end ou limit.\n# Obter dados sobre um conjunto de revisões definindo os respetivos identificadores de revisões.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Só pode ser usado com uma única página (modo #2)",
+ "apihelp-query+revisions-param-startid": "Iniciar a enumeração a partir da data e hora desta revisão. A revisão tem de existir, mas não precisa de pertencer a esta página.",
+ "apihelp-query+revisions-param-endid": "Terminar a enumeração na data e hora desta revisão. A revisão tem de existir, mas não precisa de pertencer a esta página.",
+ "apihelp-query+revisions-param-start": "A data e hora da revisão a partir da qual será começada a enumeração.",
+ "apihelp-query+revisions-param-end": "A data e hora da revisão na qual será terminada a enumeração.",
+ "apihelp-query+revisions-param-user": "Incluir só as revisões deste utilizador.",
+ "apihelp-query+revisions-param-excludeuser": "Excluir as revisões deste utilizador.",
+ "apihelp-query+revisions-param-tag": "Listar só as revisões marcadas com esta etiqueta.",
+ "apihelp-query+revisions-param-token": "Que chaves obter para cada revisão.",
+ "apihelp-query+revisions-example-content": "Obter dados com o conteúdo da última revisão dos títulos <kbd>API</kbd> e <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "Obter as últimas 5 revisões da página <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5": "Obter as primeiras 5 revisões da página <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Obter as primeiras 5 revisões da página <kbd>Main Page</kbd> feitas após 2006-05-01.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Obter as primeiras 5 revisões da página <kbd>Main Page</kbd> que não foram feitas pelo utilizador anónimo <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "Obter as primeiras 5 revisões da página <kbd>Main Page</kbd> feitas pelo utilizador <kbd>MediaWiki default</kbd>.",
+ "apihelp-query+revisions+base-param-prop": "As propriedades a serem obtidas para cada revisão:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "O identificador da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "As etiquetas da revisão (menor).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "A data e hora da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "O utilizador que fez a revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "O identificador de utilizador do criador da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "O tamanho (em bytes) da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "O resumo criptográfico SHA-1 (na base 16) da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "O identificador do modelo de conteúdo da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "O comentário do utilizador para a revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "O comentário do utilizador para a revisão, após a análise sintática.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "O texto da revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "As etiquetas para a revisão.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">Descontinuado.</span> Em substituição, use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> ou <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. A árvore de análise XML do conteúdo da revisão (requer o modelo de conteúdo <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "Limitar o número de revisões que serão devolvidas.",
+ "apihelp-query+revisions+base-param-expandtemplates": "Em substituição, use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd>. Expandir predefinições no conteúdo da revisão (requer $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "Em substituição, use <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> ou <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Gerar a árvore de análise sintática em XML do conteúdo da revisão (requer $1prop=content).",
+ "apihelp-query+revisions+base-param-parse": "Em substituição, use <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Fazer a análise sintática do conteúdo da revisão (requer $1prop=content). Por motivos de desempenho, se esta opção for usada $1limit é forçado a ser 1.",
+ "apihelp-query+revisions+base-param-section": "Obter apenas o conteúdo da secção que tem este número.",
+ "apihelp-query+revisions+base-param-diffto": "Em substituição, use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. O identificador da revisão contra a qual será tirada uma lista de diferenças de cada revisão. Usar <kbd>prev</kbd> (anterior), <kbd>next</kbd> (seguinte) e <kbd>cur</kbd> (atual).",
+ "apihelp-query+revisions+base-param-difftotext": "Em substituição, use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. O texto contra o qual será tirada uma lista de diferenças de cada revisão. Só produz as diferenças para um número limitado de revisões. Tem precedência sobre <var>$1diffto</var>. Se <var>$1section</var> estiver definido, só o conteúdo dessa secção será comparado contra o texto.",
+ "apihelp-query+revisions+base-param-difftotextpst": "Em substituição, use <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. Fazer uma transformação anterior à gravação do texto, antes de calcular as diferenças. Só é válido quando usado com <var>$1difftotext</var>.",
+ "apihelp-query+revisions+base-param-contentformat": "O formato de seriação usado para <var>$1difftotext</var> e esperado para o conteúdo produzido.",
+ "apihelp-query+search-summary": "Efetuar uma pesquisa do texto integral.",
+ "apihelp-query+search-param-search": "Procurar os títulos de página ou o conteúdo que corresponda a este valor. Pode usar o texto da pesquisa para invocar funcionalidades de pesquisa especiais, dependendo dos meios de pesquisa do servidor da wiki.",
+ "apihelp-query+search-param-namespace": "Pesquisar só nestes espaços nominais.",
+ "apihelp-query+search-param-what": "O tipo de pesquisa a executar.",
+ "apihelp-query+search-param-info": "Quais os metadados a serem devolvidos.",
+ "apihelp-query+search-param-prop": "As propriedades a serem devolvidas:",
+ "apihelp-query+search-param-qiprofile": "O perfil independente das pesquisas a ser usado (afeta o algoritmo de classificação).",
+ "apihelp-query+search-paramvalue-prop-size": "Adiciona o tamanho da página em ''bytes''.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Adiciona o número de palavras da página.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Adiciona a data e hora a que a página foi editada pela última vez.",
+ "apihelp-query+search-paramvalue-prop-snippet": "Adiciona um fragmento de código com a página, após análise sintática.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "Adiciona um fragmento de código com o título da página, após análise sintática.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "Adiciona um fragmento de código com o título redirecionado, após análise sintática.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Adiciona o título do redirecionamento correspondente.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "Adiciona um fragmento de código com o título da secção correspondente, após análise sintática.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Adiciona o título da secção correspondente.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "Adiciona um fragmento de código com a categoria correspondente, após análise sintática.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "Adiciona um valor booleano que indica se a pesquisa encontrou correspondência no conteúdo de ficheiros.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignorado.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado.",
+ "apihelp-query+search-param-limit": "O número total de páginas a serem devolvidas.",
+ "apihelp-query+search-param-interwiki": "Incluir resultados interwikis na pesquisa, se disponíveis.",
+ "apihelp-query+search-param-backend": "O servidor de pesquisas a ser usado, se diferente do servidor padrão.",
+ "apihelp-query+search-param-enablerewrites": "Ativar a reescrita da pesquisa interna. Alguns motores de pesquisa podem reescrever a pesquisa substituindo-a por outra que consideram que dará melhores resultados, por exemplo, por corrigir erros de ortografia.",
+ "apihelp-query+search-example-simple": "Pesquisar <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Pesquisar <kbd>meaning</kbd> nos textos.",
+ "apihelp-query+search-example-generator": "Obter informação sobre as páginas devolvidas por uma pesquisa do termo <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "Devolver informação geral sobre o ''site''.",
+ "apihelp-query+siteinfo-param-prop": "A informação a ser obtida:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Informação global do sistema.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "Uma lista dos espaços nominais registados e dos seus nomes canónicos.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Uma lista dos nomes alternativos dos espaços nominais registados.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Uma lista dos nomes alternativos das páginas especiais.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Uma lista das palavras mágicas e dos seus nomes alternativos.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Devolve as estatísticas do ''site''.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "Devolve o mapa de interwikis (opcionalmente filtrado, opcionalmente localizado usando <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "Devolve o servidor da base de dados com o maior atraso de replicação.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Devolve os grupos de utilizadores e as permissões associadas.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Devolve as bibliotecas instaladas na wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Devolve as extensões instaladas na wiki.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Devolve uma lista das extensões (tipos) dos ficheiros que podem ser carregados.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Devolve informação sobre os direitos (a licença) da wiki, se disponível.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Devolve informação sobre os tipos de restrição (proteção) disponíveis.",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "Devolve uma lista das línguas que o MediaWiki suporta (opcionalmente localizada, usando <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Devolve uma lista dos códigos de língua para os quais o [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]] está ativado, e as variantes suportadas para cada código.",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "Devolve uma lista de todos os temas ativados (opcionalmente localizada, usando <var>$1inlanguagecode</var>, ou então na língua do conteúdo).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "Devolve uma lista dos elementos de extensões do analisador sintático.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "Devolve uma lista dos ''hooks'' de funções do analisador sintático.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "Devolve uma lista de todos os ''hooks'' subscritos (conteúdo de <var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Devolve uma lista de identificadores de variáveis.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "Devolve uma lista dos protocolos permitidos nos ''links'' externos.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Devolve os valores padrão para as preferências dos utilizadores.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "Devolve a configuração do diálogo de carregamento.",
+ "apihelp-query+siteinfo-param-filteriw": "Devolver só as entradas locais, ou só as não locais, do mapa de interwikis.",
+ "apihelp-query+siteinfo-param-showalldb": "Listar todos os servidores da base de dados, não só aquele que tem maior atraso.",
+ "apihelp-query+siteinfo-param-numberingroup": "Lista o número de utilizadores nos grupos de utilizadores.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "O código de língua dos nomes localizados (o melhor possível) das línguas e dos temas.",
+ "apihelp-query+siteinfo-example-simple": "Obter as informações do ''site''.",
+ "apihelp-query+siteinfo-example-interwiki": "Obter uma lista dos prefixos interwikis locais.",
+ "apihelp-query+siteinfo-example-replag": "Verificar o atraso de replicação atual.",
+ "apihelp-query+stashimageinfo-summary": "Devolve informações dos ficheiros escondidos.",
+ "apihelp-query+stashimageinfo-param-filekey": "Chave que identifica um carregamento anterior que foi escondido temporariamente.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Nome alternativo de $1filekey, para compatibilidade com versões anteriores.",
+ "apihelp-query+stashimageinfo-example-simple": "Devolve informação sobre um ficheiro escondido.",
+ "apihelp-query+stashimageinfo-example-params": "Devolve as miniaturas de dois ficheiros escondidos.",
+ "apihelp-query+tags-summary": "Listar as etiquetas de modificação.",
+ "apihelp-query+tags-param-limit": "O número máximo de etiquetas a serem listadas.",
+ "apihelp-query+tags-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+tags-paramvalue-prop-name": "Adiciona o nome da etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Adiciona a mensagem de sistema para a etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-description": "Adiciona a descrição da etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Adiciona o número de revisões e de entradas no registo que têm esta etiqueta.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Indicar se a etiqueta está definida.",
+ "apihelp-query+tags-paramvalue-prop-source": "Obter as fontes da etiqueta, que podem incluir <samp>extension</samp> para etiquetas definidas por extensões e <samp>manual</samp> para etiquetas que podem ser manualmente aplicadas pelos utilizadores.",
+ "apihelp-query+tags-paramvalue-prop-active": "Indica se a etiqueta ainda está a ser aplicada.",
+ "apihelp-query+tags-example-simple": "Listar as etiquetas disponíveis.",
+ "apihelp-query+templates-summary": "Devolve todas as páginas que são transcluídas nas páginas indicadas.",
+ "apihelp-query+templates-param-namespace": "Mostrar só as predefinições nestes espaços nominais.",
+ "apihelp-query+templates-param-limit": "O número de predefinições a serem devolvidas.",
+ "apihelp-query+templates-param-templates": "Listar só estas predefinições. Útil para verificar se uma determinada página contém uma determinada predefinição.",
+ "apihelp-query+templates-param-dir": "A direção de listagem.",
+ "apihelp-query+templates-example-simple": "Obter as predefinições usadas na página <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-generator": "Obter informação sobre as páginas das predefinições usadas na página <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-namespaces": "Obter as páginas dos espaços nominais {{ns:user}} e {{ns:template}} que são transcluídas na página <kbd>Main Page</kbd>.",
+ "apihelp-query+tokens-summary": "Obtém chaves para operações de modificação de dados.",
+ "apihelp-query+tokens-param-type": "Tipos de chave a pedir.",
+ "apihelp-query+tokens-example-simple": "Obter uma chave csfr (padrão).",
+ "apihelp-query+tokens-example-types": "Obter uma chave de vigilância e uma chave de patrulha.",
+ "apihelp-query+transcludedin-summary": "Obter todas as páginas que transcluem as páginas indicadas.",
+ "apihelp-query+transcludedin-param-prop": "As propriedades a serem obtidas:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "O identificador de cada página.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "O título de cada página.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Indicar se a página é um redirecionamento.",
+ "apihelp-query+transcludedin-param-namespace": "Incluir só as páginas nestes espaços nominais.",
+ "apihelp-query+transcludedin-param-limit": "O número de entradas a serem devolvidas.",
+ "apihelp-query+transcludedin-param-show": "Mostrar só as entradas que correspondem a estes critérios:\n;redirect:Mostrar só os redirecionamentos.\n;!redirect:Mostrar só as que não são redirecionamentos.",
+ "apihelp-query+transcludedin-example-simple": "Obter uma lista das páginas que transcluem <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Obter informação sobre as páginas que transcluem <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-summary": "Obter todas as edições de um utilizador.",
+ "apihelp-query+usercontribs-param-limit": "O número máximo de contribuições a serem devolvidas.",
+ "apihelp-query+usercontribs-param-start": "A data e hora da contribuição pela qual será começada a devolução de resultados.",
+ "apihelp-query+usercontribs-param-end": "A data e hora da contribuição na qual será terminada a devolução de resultados.",
+ "apihelp-query+usercontribs-param-user": "Os utilizadores cujas contribuições serão obtidas. Não pode ser usado em conjunto com <var>$1userids</var> ou <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "Obter as contribuições de todos os utilizadores cujos nomes começam por este valor. Não pode ser usado em conjunto com <var>$1user</var> ou <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "Os identificadores dos utilizadores cujas contribuições serão obtidas. Não pode ser usado em conjunto com <var>$1user</var> ou <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "Listar só as contribuições nestes espaços nominais.",
+ "apihelp-query+usercontribs-param-prop": "Incluir informações adicionais:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Adiciona os identificadores da página e da revisão.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Adiciona o título e o identificador do espaço nominal da página.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Adiciona a data e hora da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Adiciona o comentário da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Adiciona o comentário da edição, após análise sintática.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Adiciona o novo tamanho da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Adiciona a diferença de tamanho entre a edição e a sua progenitora.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Adiciona as etiquetas da edição.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Etiqueta as edições patrulhadas.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Lista as etiquetas da edição.",
+ "apihelp-query+usercontribs-param-show": "Mostrar só as contribuições que correspondem a estes critérios; por exemplo, só as edições não menores: <kbd>$2show=!minor</kbd>.\n\nSe um dos valores <kbd>$2show=patrolled</kbd> ou <kbd>$2show=!patrolled</kbd> estiver definido, as revisões feitas há mais de <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var> ($1 {{PLURAL:$1|segundo|segundos}}) não serão mostradas.",
+ "apihelp-query+usercontribs-param-tag": "Listar só as revisões marcadas com esta etiqueta.",
+ "apihelp-query+usercontribs-param-toponly": "Listar só as alterações que são a revisão mais recente.",
+ "apihelp-query+usercontribs-example-user": "Mostrar as contribuições do utilizador <kbd>Example</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "Mostrar as contribuições de todos os endereços IP com o prefixo <kbd>192.0.2.</kbd>.",
+ "apihelp-query+userinfo-summary": "Obter informações sobre o utilizador atual.",
+ "apihelp-query+userinfo-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Etiquetas que indicam se o utilizador atual está bloqueado, por quem, e qual o motivo.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Adiciona uma etiqueta <samp>messages</samp> se o utilizador atual tem mensagens pendentes.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Lista todos os grupos aos quais o utilizador atual pertence.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Lista os grupos aos quais o utilizador atual foi explicitamente atribuído, incluindo a data de expiração da sua pertença a cada grupo.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Lista todos os grupos aos quais o utilizador atual pertence automaticamente.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Lista todas as permissões que o utilizador atual tem.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Lista os grupos aos quais o utilizador atual pode ser adicionado ou de onde pode ser removido.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Lista todas as preferências que o utilizador atual definiu.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Obter uma chave para alterar as preferências do utilizador atual.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Adiciona a contagem de edições do utilizador atual.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Lista todas as frequências limite do utilizador atual.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Adiciona o nome real do utilizador.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Adicionar o correio eletrónico do utilizador e a data de autenticação do correio eletrónico.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Faz eco do cabeçalho <code>Accept-Language</code> enviado pelo cliente num formato estruturado.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Adiciona a data de registo do utilizador.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Adiciona a contagem de páginas não lidas da lista de páginas vigiadas do utilizador (máximo $1; devolve <samp>$2</samp> se forem mais).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "Adiciona os identificadores centrais e o estado de ligação central (''attachment'') do utilizador.",
+ "apihelp-query+userinfo-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indicar se o utilizador tem ligação com a wiki designada por este identificador.",
+ "apihelp-query+userinfo-example-simple": "Obter informações sobre o utilizador atual.",
+ "apihelp-query+userinfo-example-data": "Obter informações adicionais sobre o utilizador atual.",
+ "apihelp-query+users-summary": "Obter informações sobre uma lista de utilizadores.",
+ "apihelp-query+users-param-prop": "As informações que devem ser incluídas:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Etiquetas que indicam se o utilizador está bloqueado, por quem, e qual o motivo.",
+ "apihelp-query+users-paramvalue-prop-groups": "Lista todos os grupos aos quais cada utilizador pertence.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "Lista os grupos aos quais cada utilizador foi explicitamente atribuído, incluindo a data de expiração da sua pertença a cada grupo.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Lista todos os grupos aos quais um utilizador pertence automaticamente.",
+ "apihelp-query+users-paramvalue-prop-rights": "Lista todas as permissões que cada utilizador tem.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Adiciona a contagem de edições do utilizador.",
+ "apihelp-query+users-paramvalue-prop-registration": "Adiciona a data e hora de registo do utilizador.",
+ "apihelp-query+users-paramvalue-prop-emailable": "Etiqueta que indica se o utilizador pode e quer receber correio eletrónico através de [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "Etiqueta que indica o género do utilizador. Devolve \"male\" (masculino), \"female\" (feminino) ou \"unknown\" (desconhecido).",
+ "apihelp-query+users-paramvalue-prop-centralids": "Adiciona os identificadores centrais e o estado de ligação central (''attachment'') do utilizador.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Indica se pode ser criada uma conta para os nomes de utilizador não registados, mas válidos.",
+ "apihelp-query+users-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indicar se o utilizador tem ligação com a wiki designada por este identificador.",
+ "apihelp-query+users-param-users": "Uma lista de utilizadores dos quais serão obtidas informações.",
+ "apihelp-query+users-param-userids": "Uma lista de identificadores dos utilizadores de que serão obtidas informações.",
+ "apihelp-query+users-param-token": "Em substituição, usar <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-query+users-example-simple": "Devolver informações sobre o utilizador <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-summary": "Obter mudanças recentes das páginas vigiadas do utilizador atual.",
+ "apihelp-query+watchlist-param-allrev": "Incluir revisões múltiplas da mesma página dentro do intervalo de tempo indicado.",
+ "apihelp-query+watchlist-param-start": "A data e hora da mudança recente a partir da qual será começada a enumeração.",
+ "apihelp-query+watchlist-param-end": "A data e hora da mudança recente na qual será terminada a enumeração.",
+ "apihelp-query+watchlist-param-namespace": "Filtrar as mudanças para produzir só as dos espaços nominais indicados.",
+ "apihelp-query+watchlist-param-user": "Listar só as mudanças deste utilizador.",
+ "apihelp-query+watchlist-param-excludeuser": "Não listar as mudanças deste utilizador.",
+ "apihelp-query+watchlist-param-limit": "O número total de resultados a serem devolvidos por pedido.",
+ "apihelp-query+watchlist-param-prop": "As propriedades adicionais que devem ser obtidas:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Adiciona identificadores de revisões e de páginas.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Adiciona o título da página.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Adiciona etiquetas para a edição.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Adiciona o utilizador que fez a edição.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Adiciona o identificador do utilizador que realizou a edição.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Adiciona o comentário da edição.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Adiciona o comentário da edição, após análise sintática.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Adiciona a data e hora da edição.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Etiqueta que indica as edições que são patrulhadas.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Adiciona os tamanhos novo e antigo da página.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Adiciona a data e hora da última vez em que o utilizador foi notificado da edição.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Adiciona informação do registo quando apropriado.",
+ "apihelp-query+watchlist-param-show": "Mostrar só as entradas que correspondem a estes critérios. Por exemplo, para ver só as edições menores feitas por utilizadores autenticados, definir $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "Os tipos de alterações a serem mostradas:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Edições normais.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Mudanças externas.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Criações de páginas.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Entradas do registo.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Alterações de pertença a categorias.",
+ "apihelp-query+watchlist-param-owner": "Usado com $1token para aceder à lista de páginas vigiadas de outro utilizador.",
+ "apihelp-query+watchlist-param-token": "Uma chave de segurança (disponível nas [[Special:Preferences#mw-prefsection-watchlist|preferências]] do utilizador) para permitir acesso à lista de páginas vigiadas de outro utilizador.",
+ "apihelp-query+watchlist-example-simple": "Listar a revisão mais recente das páginas com mudanças recentes na lista de páginas vigiadas do utilizador atual.",
+ "apihelp-query+watchlist-example-props": "Obter informação adicional sobre a revisão mais recente das páginas vigiadas do utilizador atual que tenham sido alteradas.",
+ "apihelp-query+watchlist-example-allrev": "Obter informações sobre todas as mudanças recentes às páginas vigiadas do utilizador atual.",
+ "apihelp-query+watchlist-example-generator": "Obter informações das páginas na lista de páginas vigiadas do utilizador atual que tenham sido recentemente alteradas.",
+ "apihelp-query+watchlist-example-generator-rev": "Obter informações de revisão para as mudanças recentes às páginas vigiadas do utilizador atual.",
+ "apihelp-query+watchlist-example-wlowner": "Listar a revisão mais recente das páginas na lista de páginas vigiadas do utilizador <kbd>Example</kbd> que tenham sido recentemente alteradas.",
+ "apihelp-query+watchlistraw-summary": "Obter todas as páginas na lista de páginas vigiadas do utilizador atual.",
+ "apihelp-query+watchlistraw-param-namespace": "Listar só as páginas nos espaços nominais indicados.",
+ "apihelp-query+watchlistraw-param-limit": "O número total de resultados a serem devolvidos por pedido.",
+ "apihelp-query+watchlistraw-param-prop": "As propriedades adicionais que devem ser obtidas:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Adiciona a data e hora da última vez em que o utilizador foi notificado da edição.",
+ "apihelp-query+watchlistraw-param-show": "Listar só os elementos que preenchem estes critérios.",
+ "apihelp-query+watchlistraw-param-owner": "Usado em conjunto com o parâmetro $1token para aceder à lista de páginas vigiadas de outro utilizador.",
+ "apihelp-query+watchlistraw-param-token": "Uma chave de segurança (disponível nas [[Special:Preferences#mw-prefsection-watchlist|preferências]] do utilizador) para permitir acesso à lista de páginas vigiadas de outro utilizador.",
+ "apihelp-query+watchlistraw-param-dir": "A direção de listagem.",
+ "apihelp-query+watchlistraw-param-fromtitle": "O título (com o prefixo do espaço nominal) a partir do qual será começada a enumeração.",
+ "apihelp-query+watchlistraw-param-totitle": "O título (com o prefixo do espaço nominal) no qual será terminada a enumeração.",
+ "apihelp-query+watchlistraw-example-simple": "Listar as páginas na lista de páginas vigiadas do utilizador atual.",
+ "apihelp-query+watchlistraw-example-generator": "Obter informações das páginas na lista de páginas vigiadas do utilizador atual.",
+ "apihelp-removeauthenticationdata-summary": "Remover os dados de autenticação do utilizador atual.",
+ "apihelp-removeauthenticationdata-example-simple": "Tentar remover os dados do utilizador atual para o pedido de autenticação <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "Enviar a um utilizador uma mensagem eletrónica de reinício da palavra-passe.",
+ "apihelp-resetpassword-extended-description-noroutes": "Não estão disponíveis rotas de reinício da palavra-passe.\n\nPara usar este módulo, ative uma rota em <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var>.",
+ "apihelp-resetpassword-param-user": "O utilizar cuja palavra-passe será reiniciada.",
+ "apihelp-resetpassword-param-email": "O correio eletrónico do utilizador cuja palavra-passe será reiniciada.",
+ "apihelp-resetpassword-example-user": "Enviar uma mensagem eletrónica para reinício da palavra-passe ao utilizador <kbd>Example</kbd>.",
+ "apihelp-resetpassword-example-email": "Enviar uma mensagem eletrónica para reinício da palavra-passe a todos os utilizadores com o correio eletrónico <kbd>user@example.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Eliminar e restaurar revisões.",
+ "apihelp-revisiondelete-param-type": "O tipo de eliminação de revisão que está a ser feito.",
+ "apihelp-revisiondelete-param-target": "O título de página para a eliminação da revisão, se for necessário para o tipo de eliminação.",
+ "apihelp-revisiondelete-param-ids": "Os identificadores das revisões a serem eliminadas.",
+ "apihelp-revisiondelete-param-hide": "O que deve ser ocultado para cada revisão.",
+ "apihelp-revisiondelete-param-show": "O que deve ser mostrado para cada revisão.",
+ "apihelp-revisiondelete-param-suppress": "Indica se devem ser suprimidos os dados aos administradores como a todos os restantes utilizadores.",
+ "apihelp-revisiondelete-param-reason": "O motivo da eliminação ou restauro.",
+ "apihelp-revisiondelete-param-tags": "Etiquetas a aplicar à entrada no registo de eliminações.",
+ "apihelp-revisiondelete-example-revision": "Ocultar o conteúdo da revisão <kbd>12345</kbd> na página <kbd>Main Page</kbd>.",
+ "apihelp-revisiondelete-example-log": "Ocultar todos os dados na entrada <kbd>67890</kbd> do registo com o motivo <kbd>BLP violation</kbd>.",
+ "apihelp-rollback-summary": "Desfazer a última edição da página.",
+ "apihelp-rollback-extended-description": "Se o último utilizador que editou a página tiver realizado várias edições consecutivas, elas serão todas revertidas.",
+ "apihelp-rollback-param-title": "O título da página a reverter. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "O identificador da página a reverter. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "As etiquetas a aplicar à reversão.",
+ "apihelp-rollback-param-user": "O nome do utilizador cujas edições vão ser revertidas.",
+ "apihelp-rollback-param-summary": "Resumo personalizado da edição. Se estiver vazio, será utilizado o resumo por omissão.",
+ "apihelp-rollback-param-markbot": "Marcar as edições revertidas e a reversão como edições de robôs.",
+ "apihelp-rollback-param-watchlist": "Adicionar ou remover incondicionalmente a página da lista de páginas vigiadas do utilizador atual, usar as preferências ou não alterar o estado de vigilância.",
+ "apihelp-rollback-example-simple": "Reverter as últimas edições da página <kbd>Main Page</kbd> pelo utilizador <kbd>Example</kbd>.",
+ "apihelp-rollback-example-summary": "Reverter as últimas edições da página <kbd>Main Page</kbd> pelo utilizador IP <kbd>192.0.2.5</kbd> com o resumo <kbd>Reverting vandalism</kbd>, e marcar essas edições e a reversão como edições de robôs.",
+ "apihelp-rsd-summary": "Exportar um esquema (''schema'') RSD (Really Simple Discovery).",
+ "apihelp-rsd-example-simple": "Exportar o esquema RSD.",
+ "apihelp-setnotificationtimestamp-summary": "Atualizar a data e hora de notificação de alterações às páginas vigiadas.",
+ "apihelp-setnotificationtimestamp-extended-description": "Isto afeta o realce das páginas alteradas, na lista de páginas vigiadas e no histórico, e o envio de mensagens de correio quando a preferência \"{{int:tog-enotifwatchlistpages}}\" está ativada.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Trabalhar em todas as páginas vigiadas.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "A data e hora a definir como data e hora da notificação.",
+ "apihelp-setnotificationtimestamp-param-torevid": "A revisão para a qual definir a data e hora de notificação (só uma página).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "A revisão da qual definir que a data e hora de notificação é mais recente (só uma página).",
+ "apihelp-setnotificationtimestamp-example-all": "Reiniciar o estado de notificação de todas as páginas vigiadas.",
+ "apihelp-setnotificationtimestamp-example-page": "Reiniciar o estado de notificação da página <kbd>Main page</kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "Definir a data e hora de notificação para a página <kbd>Main page</kbd> de forma a que todas as edições desde 1 de janeiro de 2012 passem a ser consideradas não vistas",
+ "apihelp-setnotificationtimestamp-example-allpages": "Reiniciar o estado de notificação das páginas no espaço nominal <kbd>{{ns:user}}</kbd>.",
+ "apihelp-setpagelanguage-summary": "Alterar a língua de uma página.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Não é permitido alterar a língua de uma página nesta wiki.\n\nAtivar <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> para usar esta operação.",
+ "apihelp-setpagelanguage-param-title": "O título da página cuja língua pretende alterar. Não pode ser usado em conjunto com <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "O identificador da página cuja língua pretende alterar. Não pode ser usado em conjunto com <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "O código de língua, da língua para a qual a página será alterada. Usar <kbd>default</kbd> para redefinir a língua da página para a língua padrão de conteúdo da wiki.",
+ "apihelp-setpagelanguage-param-reason": "Motivo da alteração.",
+ "apihelp-setpagelanguage-param-tags": "As etiquetas de modificação a aplicar à entrada no registo que resultar desta operação.",
+ "apihelp-setpagelanguage-example-language": "Alterar a língua da página <kbd>Main Page</kbd> para basco.",
+ "apihelp-setpagelanguage-example-default": "Alterar a língua da página com o identificador 123 para a língua padrão de conteúdo da wiki.",
+ "apihelp-stashedit-summary": "Preparar uma edição na cache partilhada.",
+ "apihelp-stashedit-extended-description": "É pretendido que isto seja usado através de AJAX a partir do formulário de edição, para melhorar o desempenho da gravação da página.",
+ "apihelp-stashedit-param-title": "Título da página que está a ser editada.",
+ "apihelp-stashedit-param-section": "Número da secção. <kbd>0</kbd> para a secção do topo, <kbd>new</kbd> para uma secção nova.",
+ "apihelp-stashedit-param-sectiontitle": "O título para uma secção nova.",
+ "apihelp-stashedit-param-text": "O conteúdo da página.",
+ "apihelp-stashedit-param-stashedtexthash": "O resumo criptográfico do conteúdo da página, resultante de uma colocação anterior na área de ficheiros escondidos, a ser usado em vez de outro.",
+ "apihelp-stashedit-param-contentmodel": "O modelo de conteúdo do novo conteúdo.",
+ "apihelp-stashedit-param-contentformat": "O formato de seriação do conteúdo usado para o texto de entrada.",
+ "apihelp-stashedit-param-baserevid": "O identificador de revisão da revisão de base.",
+ "apihelp-stashedit-param-summary": "O resumo da mudança.",
+ "apihelp-tag-summary": "Adicionar ou remover as etiquetas de modificação aplicadas a revisões individuais ou a entradas do registo.",
+ "apihelp-tag-param-rcid": "Um ou mais identificadores de mudanças recentes às quais adicionar ou remover a etiqueta.",
+ "apihelp-tag-param-revid": "Um ou mais identificadores de revisões às quais adicionar ou remover a etiqueta.",
+ "apihelp-tag-param-logid": "Um ou mais identificadores de entradas do registo às quais adicionar ou remover a etiqueta.",
+ "apihelp-tag-param-add": "As etiquetas a serem adicionadas. Só podem ser adicionadas as etiquetas definidas manualmente.",
+ "apihelp-tag-param-remove": "As etiquetas a serem removidas. Só podem ser removidas as etiquetas definidas manualmente ou completamente indefinidas.",
+ "apihelp-tag-param-reason": "O motivo da alteração.",
+ "apihelp-tag-param-tags": "As etiquetas de modificação a aplicar à entrada no registo que será criada em resultado desta operação.",
+ "apihelp-tag-example-rev": "Adicionar a etiqueta <kbd>vandalism</kbd> à revisão com o identificador 123, sem especificar um motivo.",
+ "apihelp-tag-example-log": "Remover a etiqueta <kbd>spam</kbd> da entrada do registo com o identificador 123, com o motivo <kbd>Wrongly applied</kbd>.",
+ "apihelp-tokens-summary": "Obter chaves para operações de modificação de dados.",
+ "apihelp-tokens-extended-description": "Este módulo foi descontinuado e substituído por [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-tokens-param-type": "Tipos de chave a pedir.",
+ "apihelp-tokens-example-edit": "Obter uma chave de edição (padrão).",
+ "apihelp-tokens-example-emailmove": "Obter uma chave de correio eletrónico e uma chave de movimentação.",
+ "apihelp-unblock-summary": "Desbloquear um utilizador.",
+ "apihelp-unblock-param-id": "Identificador do bloqueio a desfazer (obtido com <kbd>list=blocks</kbd>). Não pode ser usado em conjunto com <var>$1user</var> ou <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "O nome de utilizador, endereço IP ou gama de endereços IP a ser desbloqueado. Não pode ser usado em conjunto com <var>$1id</var> ou <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "O identificador do utilizador a ser desbloqueado. Não pode ser usado em conjunto com <var>$1id</var> ou <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "Motivo para o desbloqueio.",
+ "apihelp-unblock-param-tags": "As etiquetas de modificação a aplicar à entrada no registo de bloqueios.",
+ "apihelp-unblock-example-id": "Desfazer o bloqueio com o identificador #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "Desbloquear o utilizador <kbd>Bob</kbd> com o motivo <kbd>Sorry Bob</kbd>.",
+ "apihelp-undelete-summary": "Restaurar revisões de uma página eliminada.",
+ "apihelp-undelete-extended-description": "Pode obter-se uma lista de revisões eliminadas (incluindo as datas e horas de eliminação) com [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]] e uma lista de identificadores de ficheiros eliminados com [[Special:ApiHelp/query+filearchive|list=filearchive]].",
+ "apihelp-undelete-param-title": "Título da página a restaurar.",
+ "apihelp-undelete-param-reason": "Motivo para restaurar a página.",
+ "apihelp-undelete-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de eliminações.",
+ "apihelp-undelete-param-timestamps": "As datas e horas das revisões a serem restauradas. Se ambos os parâmetros <var>$1timestamps</var> e <var>$1fileids</var> estiverem vazios, serão restauradas todas as revisões.",
+ "apihelp-undelete-param-fileids": "Os identificadores das revisões a serem restauradas. Se ambos os parâmetros <var>$1timestamps</var> e <var>$1fileids</var> estiverem vazios, serão restauradas todas as revisões.",
+ "apihelp-undelete-param-watchlist": "Adicionar ou remover incondicionalmente a página da lista de páginas vigiadas do utilizador atual, usar as preferências ou não alterar o estado de vigilância.",
+ "apihelp-undelete-example-page": "Restaurar a página <kbd>Main Page</kbd>.",
+ "apihelp-undelete-example-revisions": "Restaurar duas revisões da página <kbd>Main Page</kbd>.",
+ "apihelp-unlinkaccount-summary": "Remover do utilizador atual uma conta ligada de uma wiki terceira.",
+ "apihelp-unlinkaccount-example-simple": "Tentar remover a ligação do utilizador atual ao fornecedor associado com <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-upload-summary": "Carregar um ficheiro, ou obter o estado dos carregamentos pendentes.",
+ "apihelp-upload-extended-description": "Estão disponíveis vários métodos:\n* Carregar diretamente o conteúdo do ficheiro, usando o parâmetro <var>$1file</var>.\n* Carregar o ficheiro por segmentos, usando os parâmetros <var>$1filesize</var>, <var>$1chunk</var> e <var>$1offset</var>.\n* Instruir o servidor do MediaWiki para obter o ficheiro a partir de um URL, usando o parâmetro <var>$1url</var>.\n* Terminar um carregamento anterior que falhou devido a avisos, usando o parâmetro <var>$1filekey</var>.\nNote que o POST do HTTP tem de ser feito como um carregamento de ficheiro (isto é, usando <code>multipart/form-data</code>) ao enviar o <var>$1file</var>.",
+ "apihelp-upload-param-filename": "O nome de destino do ficheiro.",
+ "apihelp-upload-param-comment": "O comentário do carregamento. Também é usado como texto da página inicial para ficheiros novos se <var>$1text</var> não for especificado.",
+ "apihelp-upload-param-tags": "Etiquetas de modificação a aplicar à entrada do carregamento no registo e à revisão da página de ficheiro.",
+ "apihelp-upload-param-text": "Texto inicial da página para ficheiros novos.",
+ "apihelp-upload-param-watch": "Vigiar a página.",
+ "apihelp-upload-param-watchlist": "Adicionar ou remover incondicionalmente a página da lista de páginas vigiadas do utilizador atual, usar as preferências ou não alterar o estado de vigilância.",
+ "apihelp-upload-param-ignorewarnings": "Ignorar todos os avisos.",
+ "apihelp-upload-param-file": "O conteúdo do ficheiro.",
+ "apihelp-upload-param-url": "O URL de onde obter o ficheiro.",
+ "apihelp-upload-param-filekey": "Chave que identifica um carregamento prévio que está temporariamente na área de ficheiros escondidos.",
+ "apihelp-upload-param-sessionkey": "O mesmo que $1filekey, mantido para compatibilidade com versões anteriores.",
+ "apihelp-upload-param-stash": "Se definido, o servidor irá colocar temporariamente o ficheiro na área de ficheiros escondidos em vez de o adicionar ao repositório.",
+ "apihelp-upload-param-filesize": "O tamanho do carregamento completo.",
+ "apihelp-upload-param-offset": "Posição do segmento em ''bytes''.",
+ "apihelp-upload-param-chunk": "O conteúdo do segmento.",
+ "apihelp-upload-param-async": "Tornar assíncronas as operações sobre ficheiros possivelmente grandes, quando possível.",
+ "apihelp-upload-param-checkstatus": "Obter só o estado de carregamento para a chave de ficheiro indicada.",
+ "apihelp-upload-example-url": "Carregar de um URL.",
+ "apihelp-upload-example-filekey": "Prosseguir um carregamento que falhou devido a avisos.",
+ "apihelp-userrights-summary": "Alterar os grupos a que um utilizador pertence.",
+ "apihelp-userrights-param-user": "O nome de utilizador.",
+ "apihelp-userrights-param-userid": "O identificador de utilizador.",
+ "apihelp-userrights-param-add": "Adicionar o utilizador a estes grupos ou, se já for membro de um grupo, atualizar a data de expiração da sua pertença a esse grupo.",
+ "apihelp-userrights-param-expiry": "Datas e horas de expiração. Podem ser relativas (por exemplo, <kbd>5 meses</kbd> ou <kbd>2 semanas</kbd>) ou absolutas (p. ex., <kbd>2014-09-18T12:34:56Z</kbd>). Se só estiver definida uma data e hora, ela será usada para todos os grupos passados ao parâmetro <var>$1add</var>. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> ou <kbd>never</kbd> quando a pertença a um grupo não tem expiração.",
+ "apihelp-userrights-param-remove": "Remover o utilizador destes grupos.",
+ "apihelp-userrights-param-reason": "O motivo da alteração.",
+ "apihelp-userrights-param-tags": "Etiquetas de modificação a aplicar à entrada no registo de privilégios de utilizadores.",
+ "apihelp-userrights-example-user": "Adicionar o utilizador <kbd>FooBot</kbd> ao grupo <kbd>bot</kbd> e removê-lo dos grupos <kbd>sysop</kbd> e <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Adicionar o utilizador com o identificador <kbd>123</kbd> ao grupo <kbd>bot</kbd> e removê-lo dos grupos <kbd>sysop</kbd> e <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-expiry": "Adicionar o utilizador <kbd>SometimeSysop</kbd> ao grupo <kbd>sysop</kbd> por 1 mês.",
+ "apihelp-validatepassword-summary": "Validar uma palavra-passe face às regras para palavras-passe da wiki.",
+ "apihelp-validatepassword-extended-description": "A validade é reportada <samp>Good</samp> (Boa) se a palavra-passe é aceitável, <samp>Change</samp> (Alterar) se a palavra-passe pode ser usada para iniciar uma sessão mas terá de ser alterada, ou <samp>Invalid</samp> (Inválida) se a palavra-passe não é utilizável.",
+ "apihelp-validatepassword-param-password": "A palavra-passe a ser validada.",
+ "apihelp-validatepassword-param-user": "O nome de utilizador, para ser usado ao testar a criação de conta. O nome de utilizador não pode existir.",
+ "apihelp-validatepassword-param-email": "O endereço de correio eletrónico, para ser usado ao testar a criação de conta.",
+ "apihelp-validatepassword-param-realname": "O nome verdadeiro, para ser usado ao testar a criação de conta.",
+ "apihelp-validatepassword-example-1": "Validar a palavra-passe <kbd>foobar</kbd> para o utilizador atual.",
+ "apihelp-validatepassword-example-2": "Validar a palavra-passe <kbd>qwerty</kbd> para a criação do utilizador <kbd>Example</kbd>.",
+ "apihelp-watch-summary": "Adicionar ou remover páginas da lista de páginas vigiadas do utilizador atual.",
+ "apihelp-watch-param-title": "A página a vigiar ou deixar de ser vigiada. Em vez disto, usar <var>$1titles</var>.",
+ "apihelp-watch-param-unwatch": "Se definido, a página deixará de ser vigiada, em vez de o ser.",
+ "apihelp-watch-example-watch": "Vigiar a página <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Deixar de vigiar a página <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-generator": "Vigiar as primeiras páginas do espaço nominal principal.",
+ "apihelp-format-example-generic": "Devolver o resultado da consulta no formato $1.",
+ "apihelp-format-param-wrappedhtml": "Devolver o HTML com realce sintático e os módulos ResourceLoader associados, na forma de um objeto JSON.",
+ "apihelp-json-summary": "Produzir os dados de saída no formato JSON.",
+ "apihelp-json-param-callback": "Se especificado, envolve o resultado de saída na forma de uma chamada para uma função. Por segurança, todos os dados específicos do utilizador estarão restringidos.",
+ "apihelp-json-param-utf8": "Se especificado, codifica a maioria dos caracteres não ASCII (mas não todos) em UTF-8, em vez de substitui-los por sequências de escape hexadecimais. É o comportamento padrão quando <var>formatversion</var> não tem o valor <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "Se especificado, codifica todos caracteres não ASCII usando sequências de escape hexadecimais. É o comportamento padrão quando <var>formatversion</var> tem o valor <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Formatação do resultado de saída:\n;1:Formato compatível com versões anteriores (booleanos ao estilo XML, <samp>*</samp> chaves para nodos de conteúdo, etc.).\n;2:Formato moderno experimental. As especificações podem mudar!\n;latest:Usar o formato mais recente (atualmente <kbd>2</kbd>), mas pode ser alterado sem aviso prévio.",
+ "apihelp-jsonfm-summary": "Produzir os dados de saída em formato JSON (realce sintático em HTML).",
+ "apihelp-none-summary": "Não produzir nada.",
+ "apihelp-php-summary": "Produzir os dados de saída em formato PHP seriado.",
+ "apihelp-php-param-formatversion": "Formatação do resultado de saída:\n;1:Formato compatível com versões anteriores (booleanos ao estilo XML, <samp>*</samp> chaves para nodos de conteúdo, etc.).\n;2:Formato moderno experimental. As especificações podem mudar!\n;latest:Usar o formato mais recente (atualmente <kbd>2</kbd>), mas pode ser alterado sem aviso prévio.",
+ "apihelp-phpfm-summary": "Produzir os dados de saída em formato PHP seriado (realce sintático em HTML).",
+ "apihelp-rawfm-summary": "Produzir os dados de saída, incluindo elementos para despiste de erros, em formato JSON (realce sintático em HTML).",
+ "apihelp-xml-summary": "Produzir os dados de saída em formato XML.",
+ "apihelp-xml-param-xslt": "Se especificado, adiciona a página nomeada como uma folha de estilo XSL. O valor tem de ser um título no espaço nominal {{ns:MediaWiki}} e acabar em <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Se especificado, adiciona um espaço nominal XML.",
+ "apihelp-xmlfm-summary": "Produzir os dados de saída em formato XML (realce sintático em HTML).",
+ "api-format-title": "Resultado da API do MediaWiki.",
+ "api-format-prettyprint-header": "Esta é a representação em HTML do formato $1. O HTML é bom para o despiste de erros, mas inadequado para uso na aplicação.\n\nEspecifique o parâmetro <var>format</var> para alterar o formato de saída. Para ver a representação que não é em HTML do formato $1, defina <kbd>format=$2</kbd>.\n\nConsulte a [[mw:Special:MyLanguage/API|documentação completa]], ou a [[Special:ApiHelp/main|ajuda da API]] para mais informação.",
+ "api-format-prettyprint-header-only-html": "Esta é uma representação em HTML para ser usada no despiste de erros, mas inadequada para uso na aplicação.\n\nConsulte a [[mw:Special:MyLanguage/API|documentação completa]], ou a [[Special:ApiHelp/main|ajuda da API]] para mais informação.",
+ "api-format-prettyprint-header-hyperlinked": "Esta é a representação em HTML do formato $1. O HTML é bom para o despiste de erros, mas inadequado para uso na aplicação.\n\nEspecifique o parâmetro <var>format</var> para alterar o formato de saída. Para ver a representação que não é em HTML do formato $1, defina [$3 <kbd>format=$2</kbd>].\n\nConsulte a [[mw:API|documentação completa]], ou a [[Special:ApiHelp/main|ajuda da API]] para mais informação.",
+ "api-format-prettyprint-status": "Esta resposta seria devolvida com o estado de HTTP: $1 $2.",
+ "api-login-fail-aborted": "A autenticação requer interação com o utilizador, que não é suportada por <kbd>action=login</kbd>. Para poder entrar com <kbd>action=login</kbd>, consulte [[Special:BotPasswords]]. Para continuar a usar a autenticação da conta principal, consulte <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-aborted-nobotpw": "A autenticação requer interação com o utilizador, que não é suportada por <kbd>action=login</kbd>. Para entrar, consulte <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-badsessionprovider": "Não é possível entrar usando $1.",
+ "api-login-fail-sameorigin": "Não é possível entrar quando a norma da mesma origem não é aplicada.",
+ "api-pageset-param-titles": "Uma lista dos títulos a serem trabalhados.",
+ "api-pageset-param-pageids": "Uma lista dos identificadores de página a serem trabalhados.",
+ "api-pageset-param-revids": "Uma lista dos identificadores de revisões a serem trabalhados.",
+ "api-pageset-param-generator": "Obter a lista de páginas nas quais trabalhar, executando o módulo de consulta especificado.\n\n<strong>Nota:</strong> Os nomes dos parâmetros de geradores têm de ser prefixados com um \"g\", veja os exemplos.",
+ "api-pageset-param-redirects-generator": "Resolver automaticamente os redirecionamentos listados nos parâmetros <var>$1titles</var>, <var>$1pageids</var> e <var>$1revids</var>, e nas páginas devolvidas por <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Resolver automaticamente os redirecionamentos listados nos parâmetros <var>$1titles</var>, <var>$1pageids</var> e <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Converter os títulos noutras variantes de língua, se necessário. Só funciona se a língua de conteúdo da wiki suporta a conversão entre variantes. As línguas que suportam conversão entre variantes incluem $1.",
+ "api-help-title": "Ajuda da API do MediaWiki",
+ "api-help-lead": "Esta é uma página de documentação da API do MediaWiki gerada automaticamente.\n\nDocumentação e exemplos: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Módulo principal",
+ "api-help-undocumented-module": "Não existe documentação para o módulo $1.",
+ "api-help-flag-deprecated": "Este módulo foi descontinuado.",
+ "api-help-flag-internal": "<strong>Este módulo é interno ou instável.</strong> O seu funcionamento pode ser alterado sem aviso prévio.",
+ "api-help-flag-readrights": "Este módulo requer direitos de leitura.",
+ "api-help-flag-writerights": "Este módulo requer direitos de escrita.",
+ "api-help-flag-mustbeposted": "Este módulo só aceita pedidos POST.",
+ "api-help-flag-generator": "Este módulo pode ser usado como gerador.",
+ "api-help-source": "Fonte: $1",
+ "api-help-source-unknown": "Fonte: <span class=\"apihelp-unknown\">desconhecida</span>",
+ "api-help-license": "Licença: [[$1|$2]]",
+ "api-help-license-noname": "Licença: [[$1|Ver ligação]]",
+ "api-help-license-unknown": "Licença: <span class=\"apihelp-unknown\">desconhecida</span>",
+ "api-help-parameters": "{{PLURAL:$1|Parâmetro|Parâmetros}}:",
+ "api-help-param-deprecated": "Obsoleto.",
+ "api-help-param-required": "Este parâmetro é obrigatório.",
+ "api-help-datatypes-header": "Tipo de dados",
+ "api-help-datatypes": "O formato de entrada para o MediaWiki deve ser UTF-8, normalizado de acordo com a norma NFC. O MediaWiki pode converter outros tipos de entrada, mas esta conversão pode originar a falha de algumas operações (tais como as [[Special:ApiHelp/edit|edições]] com verificações MD5).\n\nAlguns tipos de parâmetros nos pedidos à API necessitam de mais explicações:\n;boolean\n:Os parâmetros booleanos funcionam como as caixas de seleção HTML: se o parâmetro for especificado, independentemente do seu valor, é considerado verdadeiro. Para um valor falso, omitir o parâmetro completo.\n;timestamp\n:As datas e horas podem ser especificadas em vários formatos. É recomendado o formato ISO 8601. Todas as horas estão em UTC, qualquer inclusão do fuso horário é ignorada.\n:* Data e hora ISO 8601, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (pontuação e <kbd>Z</kbd> são opcionais)\n:* Data e hora ISO 8601 com segundos fracionários (estes são ignorados), <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (traços, dois pontos e <kbd>Z</kbd> são opcionais)\n:* Formato do MediaWiki, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Formato numérico genérico, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (fuso horário opcional <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd>, ou <kbd>-<var>##</var></kbd> são ignorados)\n:* Formato EXIF, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:*Formato RFC 2822 (o fuso horário pode ser omitido), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formato RFC 850 (o fuso horário pode ser omitido), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Formato C ctime, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Segundos desde 1970-01-01T00:00:00Z como um inteiro de 1 a 13 algarismos (excluindo <kbd>0</kbd>)\n:* O texto <kbd>now</kbd>\n;separador alternativo de valores múltiplos\n:Os parâmetros que aceitam vários valores são normalmente fornecidos com os valores separados por uma barra vertical (''pipe''), por exemplo <kbd>parâmetro=valor1|valor2</kbd> ou <kbd>parâmetro=valor1%7Cvalor2</kbd>. Se um valor contém a barra vertical, use como separador o U+001F (Separador de Unidades) ''e'' prefixe o valor com U+001F, isto é, <kbd>parâmetro=%1Fvalor1%1Fvalor2</kbd>.",
+ "api-help-param-type-limit": "Tipo: inteiro ou <kbd>max</kbd>",
+ "api-help-param-type-integer": "Tipo: {{PLURAL:$1|1=inteiro|2=lista de números inteiros}}",
+ "api-help-param-type-boolean": "Tipo: booleano ([[Special:ApiHelp/main#main/datatypes|detalhes]])",
+ "api-help-param-type-timestamp": "Tipo: {{PLURAL:$1|1=data e hora|2=lista de datas e horas}} ([[Special:ApiHelp/main#main/datatypes|formatos permitidos]])",
+ "api-help-param-type-user": "Tipo: {{PLURAL:$1|1=nome de utilizador|2=lista de nomes de utilizadores}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Um dos seguintes valores|2=Valores (separados com <kbd>{{!}}</kbd> ou [[Special:ApiHelp/main#main/datatypes|alternativas]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Tem de estar vazio|Pode estar vazio, ou ser $2}}",
+ "api-help-param-limit": "Não são permitidos mais do que: $1",
+ "api-help-param-limit2": "Não são permitidos mais do que $1 ($2 para robôs).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=O valor não pode ser inferior a|2=Os valores não podem ser inferiores a}} $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=O valor não pode ser superior a|2=Os valores não podem ser superiores a}} $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=O valor tem de estar compreendido|2=Os valores têm de estar compreendidos}} entre $2 e $3.",
+ "api-help-param-upload": "Tem ser enviado (''posted'') como um carregamento de ficheiro usando multipart/form-data.",
+ "api-help-param-multi-separate": "Separar os valores com <kbd>|</kbd> ou [[Special:ApiHelp/main#main/datatypes|alternativas]].",
+ "api-help-param-multi-max": "O número máximo de valores é {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} para robôs).",
+ "api-help-param-multi-max-simple": "O número máximo de valores é {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "Para especificar todos os valores, use <kbd>$1</kbd>.",
+ "api-help-param-default": "Valor por omissão: $1",
+ "api-help-param-default-empty": "Padrão: <span class=\"apihelp-empty\">(vazio)</span>",
+ "api-help-param-token": "Uma chave \"$1\" obtida de [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "Para efeitos de compatibilidade, a chave usada na interface ''web'' também é aceite.",
+ "api-help-param-disabled-in-miser-mode": "Desativado devido ao [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo avarento]] (''miser mode'').",
+ "api-help-param-limited-in-miser-mode": "<strong>Nota:</strong> devido ao [[mw:Special:MyLanguage/Manual:$wgMiserMode|modo avarento]] (''miser mode''), usar isto pode resultar na devolução de menos de <var>$1limit</var> resultados antes de continuar; em casos extremos pode não ser devolvido qualquer resultado.",
+ "api-help-param-direction": "A direção da enumeração:\n;newer:Listar o mais antigo primeiro. Nota: $1start tem de estar antes de $1end.\n;older:Listar o mais recente primeiro (padrão). Nota: $1start tem de estar depois de $1end.",
+ "api-help-param-continue": "Quando houver mais resultados disponíveis, usar isto para continuar",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(sem descrição)</span>",
+ "api-help-examples": "{{PLURAL:$1|Exemplo|Exemplos}}:",
+ "api-help-permissions": "{{PLURAL:$1|Permissão|Permissões}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Concedida a|Concedidas a}}: $2",
+ "api-help-right-apihighlimits": "Usar limites mais altos em consultas da API (consultas lentas: $1; consultas rápidas: $2). Os limites para consultas lentas também se aplicam a parâmetros com vários valores.",
+ "api-help-open-in-apisandbox": "<small>[abrir na página de testes]</small>",
+ "api-help-authmanager-general-usage": "O procedimento geral para usar este módulo é:\n# Obtenha os campos disponíveis usando <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> com <kbd>amirequestsfor=$4</kbd> e uma chave <kbd>$5</kbd> obtida de <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# Apresente os campos ao utilizador e obtenha os dados fornecidos por este.\n# Publique-os para este módulo, fornecendo <var>$1returnurl</var> e quaisquer campos relevantes.\n# Verifique o valor de <samp>status</samp> na resposta.\n#* Se recebeu <samp>PASS</samp> ou <samp>FAIL</samp>, terminou. A operação terá tido sucesso ou falhado.\n#* Se recebeu <samp>UI</samp>, apresente os novos campos ao utilizador e obtenha os dados fornecidos por este. Depois publique-os para este módulo com <var>$1continue</var> e os campos relevantes preenchidos, e repita o passo 4.\n#* Se recebeu <samp>REDIRECT</samp>, encaminhe o utilizador para <samp>redirecttarget</samp> e aguarde o retorno para o URL <var>$1returnurl</var>. Depois publique para este módulo com <var>$1continue</var> quaisquer campos que tenham sido passados ao URL de retorno, e repita o passo 4.\n#* Se recebeu <samp>RESTART</samp>, isto significa que a autenticação funcionou mas não temos uma conta de utilizador associada. Pode dar-lhe o tratamento de <samp>UI</samp> ou <samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-requests": "Usar só estes pedidos de autenticação, com o <samp>id</samp> devolvido por <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> com <kbd>amirequestsfor=$1</kbd> ou por uma resposta anterior deste módulo.",
+ "api-help-authmanagerhelper-request": "Usar este pedido de autenticação, com o <samp>id</samp> devolvido por <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> com <kbd>amirequestsfor=$1</kbd>.",
+ "api-help-authmanagerhelper-messageformat": "Formato a usar nas mensagens de saída.",
+ "api-help-authmanagerhelper-mergerequestfields": "Combinar a informação de todos os pedidos de autenticação numa única matriz.",
+ "api-help-authmanagerhelper-preservestate": "Preservar o estado de uma tentativa de autenticação anterior falhada, se possível.",
+ "api-help-authmanagerhelper-returnurl": "O URL de retorno para processos de autenticação por terceiros tem de ser absoluto. É obrigatório fornecer este URL ou <var>$1continue</var>.\n\nTipicamente, após receber uma resposta <samp>REDIRECT</samp>, abrirá um ''browser'' ou uma ''web view'' para o URL <samp>redirecttarget</samp> especificado, para dar lugar ao processo de autenticação por terceiros. Quando esse processo terminar, a terceira entidade encaminhará o ''browser'' ou a ''web view'' para este URL. Deve extrair do URL quaisquer parâmetros de consulta ou de POST, e passá-los como um pedido <var>$1continue</var> a este módulo da API.",
+ "api-help-authmanagerhelper-continue": "Este pedido é uma continuação após uma resposta anterior com o valor <samp>UI</samp> ou <samp>REDIRECT</samp>. É obrigatório fornecer este parâmetro ou o parâmetro <var>$1returnurl</var>.",
+ "api-help-authmanagerhelper-additional-params": "Este módulo aceita parâmetros adicionais, dependendo dos pedidos de autenticação disponíveis. Use <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> com <kbd>amirequestsfor=$1</kbd> (ou uma resposta anterior deste módulo, se aplicável) para determinar os pedidos disponíveis e os campos que estes utilizam.",
+ "apierror-allimages-redirect": "Usar <kbd>gaifilterredir=nonredirects</kbd> em vez de <var>redirects</var> ao utilizar <kbd>allimages</kbd> como gerador.",
+ "apierror-allpages-generator-redirects": "Usar <kbd>gapfilterredir=nonredirects</kbd> em vez de <var>redirects</var> ao utilizar <kbd>allpages</kbd> como gerador.",
+ "apierror-appendnotsupported": "Não é possível acrescentar conteúdo a páginas que usam o modelo de conteúdo $1.",
+ "apierror-articleexists": "O artigo que tentou criar já existe.",
+ "apierror-assertbotfailed": "A asserção de que o utilizador tem o privilégio <code>bot</code> falhou.",
+ "apierror-assertnameduserfailed": "A asserção de que o utilizador é \"$1\" falhou.",
+ "apierror-assertuserfailed": "A asserção de que o utilizador está autenticado falhou.",
+ "apierror-autoblocked": "O seu endereço IP foi bloqueado automaticamente, porque foi usado por um utilizador bloqueado.",
+ "apierror-badconfig-resulttoosmall": "O valor de <code>$wgAPIMaxResultSize</code> nesta wiki é demasiado pequeno para conter informação básica de resultados.",
+ "apierror-badcontinue": "Parâmetro de continuação inválido. Deve passar o valor original devolvido pela consulta anterior.",
+ "apierror-baddiff": "Não foi possível obter a lista de diferenças. Uma das revisões, ou ambas, não existem, ou não tem permissão para vê-las.",
+ "apierror-baddiffto": "<var>$1diffto</var> tem de ser um número não negativo, <kbd>prev</kbd>, <kbd>next</kbd> ou <kbd>cur</kbd>.",
+ "apierror-badformat-generic": "O formato solicitado $1 não é suportado pelo modelo de conteúdo $2.",
+ "apierror-badformat": "O formato solicitado $1 não é suportado pelo modelo de conteúdo $2 usado por $3.",
+ "apierror-badgenerator-notgenerator": "O módulo <kbd>$1</kbd> não pode ser usado como gerador.",
+ "apierror-badgenerator-unknown": "<kbd>generator=$1</kbd> desconhecido.",
+ "apierror-badip": "O parâmetro IP não é válido.",
+ "apierror-badmd5": "A chave MD5 fornecida estava incorreta.",
+ "apierror-badmodule-badsubmodule": "O módulo <kbd>$1</kbd> não tem um submódulo \"$2\".",
+ "apierror-badmodule-nosubmodules": "O módulo <kbd>$1</kbd> não tem submódulos.",
+ "apierror-badparameter": "Valor inválido para o parâmetro <var>$1</var>.",
+ "apierror-badquery": "Consulta inválida.",
+ "apierror-badtimestamp": "Valor inválido \"$2\" para o parâmetro de data e hora <var>$1</var>.",
+ "apierror-badtoken": "Chave CSRF inválida.",
+ "apierror-badupload": "O parâmetro para carregamento de ficheiros <var>$1</var> não é um carregamento de ficheiro; verifique que usou <code>multipart/form-data</code> no seu POST e inclua um nome de ficheiro no cabeçalho <code>Content-Disposition</code>.",
+ "apierror-badurl": "Valor inválido \"$2\" para o parâmetro <var>$1</var> do URL.",
+ "apierror-baduser": "Valor inválido \"$2\" para o parâmetro de utilizador <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "O separador de valores múltiplos U+001F só pode ser usado em parâmetros de valores múltiplos.",
+ "apierror-bad-watchlist-token": "A chave secreta da lista de páginas vigiadas que foi fornecida está incorreta. Configure uma chave correta em [[Special:Preferences]], por favor.",
+ "apierror-blockedfrommail": "Foi-lhe bloqueada a capacidade de enviar correio eletrónico.",
+ "apierror-blocked": "Foi-lhe bloqueada a capacidade de editar.",
+ "apierror-botsnotsupported": "Esta interface não é suportada para robôs.",
+ "apierror-cannot-async-upload-file": "Os parâmetros <var>async</var> e <var>file</var> não podem ser combinados. Se pretende o processamento assíncrono do seu ficheiro carregado, carregue-o primeiro na área de ficheiros escondidos (usando o parâmetro <var>stash</var>) e depois publique de forma assíncrona este ficheiro escondido (usando <var>filekey</var> e <var>async</var>).",
+ "apierror-cannotreauthenticate": "Esta operação não está disponível porque não é possível verificar a sua identidade.",
+ "apierror-cannotviewtitle": "Não tem permissão para ver $1.",
+ "apierror-cantblock-email": "Não tem permissão para bloquear a capacidade dos utilizadores enviarem correio eletrónico através da wiki.",
+ "apierror-cantblock": "Não tem permissão para bloquear utilizadores.",
+ "apierror-cantchangecontentmodel": "Não tem permissão para alterar o modelo de conteúdo de uma página.",
+ "apierror-canthide": "Não tem permissão para ocultar nomes de utilizadores no registo de bloqueios.",
+ "apierror-cantimport-upload": "Não tem permissão para importar páginas carregadas.",
+ "apierror-cantimport": "Não tem permissão para importar páginas.",
+ "apierror-cantoverwrite-sharedfile": "O ficheiro alvo existe num repositório partilhado e você não tem permissão para o substituir.",
+ "apierror-cantsend": "Não está autenticado, não tem um endereço de correio eletrónico confirmado, ou não lhe é permitido enviar correio a outros utilizadores, por isso não pode enviar correios eletrónicos.",
+ "apierror-cantundelete": "Não foi possível restaurar: as revisões solicitadas podem não existir ou podem já ter sido restauradas.",
+ "apierror-changeauth-norequest": "A criação do pedido de modificação falhou.",
+ "apierror-chunk-too-small": "O tamanho de segmento mínimo é $1 {{PLURAL:$1|byte|bytes}} para segmentos que não sejam segmentos finais.",
+ "apierror-cidrtoobroad": "Não são aceites intervalos CIDR $1 maiores do que /$2.",
+ "apierror-compare-no-title": "Não é possível transformar antes da gravação, sem um título. Tente especificar <var>fromtitle</var> ou <var>totitle</var>.",
+ "apierror-compare-relative-to-nothing": "Não existe uma revisão 'from' em relação à qual <var>torelative</var> possa ser relativo.",
+ "apierror-contentserializationexception": "A seriação do conteúdo falhou: $1",
+ "apierror-contenttoobig": "O conteúdo que forneceu excede o tamanho máximo dos artigos que é $1 {{PLURAL:$1|kilobyte|kilobytes}}.",
+ "apierror-copyuploadbaddomain": "Não são permitidos carregamentos por URL a partir deste domínio.",
+ "apierror-copyuploadbadurl": "Não são permitidos carregamentos a partir deste URL.",
+ "apierror-create-titleexists": "Os títulos existentes não podem ser protegidos com <kbd>create</kbd>.",
+ "apierror-csp-report": "Ocorreu um erro no processamento do relatório CSP: $1.",
+ "apierror-databaseerror": "[$1] Erro na consulta da base de dados.",
+ "apierror-deletedrevs-param-not-1-2": "O parâmetro <var>$1</var> não pode ser usado nos modos 1 e 2.",
+ "apierror-deletedrevs-param-not-3": "O parâmetro <var>$1</var> não pode ser usado no modo 3.",
+ "apierror-emptynewsection": "Não é possível criar secções novas vazias.",
+ "apierror-emptypage": "Não é permitido criar páginas novas vazias.",
+ "apierror-exceptioncaught": "[$1] Exceção intercetada: $2",
+ "apierror-filedoesnotexist": "O ficheiro não existe.",
+ "apierror-fileexists-sharedrepo-perm": "O ficheiro de destino já existe num repositório partilhado. Use o parâmetro <var>ignorewarnings</var> para substituí-lo.",
+ "apierror-filenopath": "Não é possível obter o caminho local do ficheiro.",
+ "apierror-filetypecannotberotated": "O tipo do ficheiro não pode ser rodado.",
+ "apierror-formatphp": "Esta resposta não pode ser representada com <kbd>format=php</kbd>. Consulte https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "O título para <kbd>$1</kbd> tem de ser um ficheiro.",
+ "apierror-import-unknownerror": "Ocorreu um erro desconhecido na importação: $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> não pode ultrapassar $2 (definido como $3) para robôs e administradores.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> não pode ultrapassar $2 (definido como $3) para utilizadores.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> não pode ser inferior a $2 (definido como $3).",
+ "apierror-invalidcategory": "O nome de categoria que introduziu não é válido.",
+ "apierror-invalid-chunk": "A posição mais o segmento atual ultrapassam o tamanho do ficheiro.",
+ "apierror-invalidexpiry": "A hora de expiração \"$1\" é inválida.",
+ "apierror-invalid-file-key": "Não é uma chave de ficheiro válida.",
+ "apierror-invalidlang": "Código de língua inválido para o parâmetro <var>$1</var>.",
+ "apierror-invalidoldimage": "O parâmetro <var>oldimage</var> tem um formato inválido.",
+ "apierror-invalidparammix-cannotusewith": "O parâmetro <kbd>$1</kbd> não pode ser usado com <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "O parâmetro <kbd>$1</kbd> só pode ser usado com <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd> não pode ser combinado com os parâmetros <var>oldid</var>, <var>pageid</var> e <var>page</var>. Use <var>title</var> e <var>text</var>, por favor.",
+ "apierror-invalidparammix": "{{PLURAL:$2||Os parâmetros}} $1 não podem ser usados em conjunto.",
+ "apierror-invalidsection": "O parâmetro <var>section</var> tem de ser um identificador de secção válido ou <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "O resumo criptográfico SHA1Base36 fornecido não é válido.",
+ "apierror-invalidsha1hash": "O resumo criptográfico SHA1 fornecido não é válido.",
+ "apierror-invalidtitle": "Título inválido \"$1\".",
+ "apierror-invalidurlparam": "Valor inválido para <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Nome de utilizador inválido \"$1\".",
+ "apierror-invaliduserid": "O identificador de utilizador <var>$1</var> não é válido.",
+ "apierror-maxlag-generic": "À espera de um servidor de base de dados: $1 {{PLURAL:$1|segundo|segundos}} de atraso.",
+ "apierror-maxlag": "À espera de $2: $1 {{PLURAL:$1|segundo|segundos}} de atraso.",
+ "apierror-mimesearchdisabled": "A pesquisa MIME é desativada no modo avarento.",
+ "apierror-missingcontent-pageid": "Conteúdo em falta para a página com o identificador $1.",
+ "apierror-missingcontent-revid": "Conteúdo em falta para a revisão com o identificador $1.",
+ "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|O parâmetro|Pelo menos um dos parâmetros}} $1 é obrigatório.",
+ "apierror-missingparam-one-of": "{{PLURAL:$2|O parâmetro|Um dos parâmetros}} $1 é obrigatório.",
+ "apierror-missingparam": "O parâmetro <var>$1</var> tem de ser definido.",
+ "apierror-missingrev-pageid": "Não há nenhuma revisão atual da página com o identificador $1.",
+ "apierror-missingrev-title": "Não há nenhuma revisão atual do título $1.",
+ "apierror-missingtitle-createonly": "Os títulos em falta só podem ser protegidos com <kbd>create</kbd>.",
+ "apierror-missingtitle": "A página que especificou não existe.",
+ "apierror-missingtitle-byname": "A página $1 não existe.",
+ "apierror-moduledisabled": "O módulo <kbd>$1</kbd> foi desativado.",
+ "apierror-multival-only-one-of": "Só é permitido {{PLURAL:$3|o valor|um dos valores}} $2 para o parâmetro <var>$1</var>.",
+ "apierror-multival-only-one": "Só é permitido um valor para o parâmetro <var>$1</var>.",
+ "apierror-multpages": "<var>$1</var> só pode ser usado com uma única página.",
+ "apierror-mustbeloggedin-changeauth": "Tem de estar autenticado para alterar dados de autenticação.",
+ "apierror-mustbeloggedin-generic": "Tem de estar autenticado.",
+ "apierror-mustbeloggedin-linkaccounts": "Tem de estar autenticado para ligar contas.",
+ "apierror-mustbeloggedin-removeauth": "Tem de estar autenticado para remover dados de autenticação.",
+ "apierror-mustbeloggedin-uploadstash": "A área dos ficheiros escondidos só está disponível para utilizadores autenticados.",
+ "apierror-mustbeloggedin": "Tem de ter uma sessão iniciada para $1.",
+ "apierror-mustbeposted": "O módulo <kbd>$1</kbd> requer um pedido POST.",
+ "apierror-mustpostparams": "{{PLURAL:$2|O seguinte parâmetro foi encontrado|Os seguintes parâmetros foram encontrados}} no texto da pesquisa, mas têm de estar no corpo do POST: $1.",
+ "apierror-noapiwrite": "A edição desta wiki através da API foi impossibilitada. Certifique-se de que a declaração <code>$wgEnableWriteAPI=true;</code> está incluída no ficheiro <code>LocalSettings.php</code> da wiki.",
+ "apierror-nochanges": "Não foi pedida nenhuma mudança.",
+ "apierror-nodeleteablefile": "Essa versão antiga do ficheiro não existe.",
+ "apierror-no-direct-editing": "A edição direta através da API não é suportada para o modelo de conteúdo $1 usado por $2.",
+ "apierror-noedit-anon": "Os utilizadores anónimos não podem editar páginas.",
+ "apierror-noedit": "Não tem permissão para editar páginas.",
+ "apierror-noimageredirect-anon": "Os utilizadores anónimos não podem criar redirecionamentos de imagens.",
+ "apierror-noimageredirect": "Não tem permissão para criar redirecionamentos de imagens.",
+ "apierror-nosuchlogid": "Não existe nenhuma entrada de registo com o identificador $1.",
+ "apierror-nosuchpageid": "Não existe nenhuma página com o identificador $1.",
+ "apierror-nosuchrcid": "Não existe nenhuma mudança recente com o identificador $1.",
+ "apierror-nosuchrevid": "Não existe nenhuma revisão com o identificador $1.",
+ "apierror-nosuchsection": "Não existe nenhuma secção $1.",
+ "apierror-nosuchsection-what": "Não existe nenhuma secção $1 em $2.",
+ "apierror-nosuchuserid": "Não existe nenhum utilizador com o identificador $1.",
+ "apierror-notarget": "Não especificou um destino válido para esta operação.",
+ "apierror-notpatrollable": "A revisão r$1 não pode ser patrulhada porque é demasiado antiga.",
+ "apierror-nouploadmodule": "Não foi definido nenhum módulo de carregamento.",
+ "apierror-offline": "Não foi possível continuar devido a problemas de conectividade da rede. Certifique-se de que tem ligação à Internet e tente novamente.",
+ "apierror-opensearch-json-warnings": "Os avisos não podem ser representados no formato OpenSearch JSON.",
+ "apierror-pagecannotexist": "O espaço nominal não permite páginas reais.",
+ "apierror-pagedeleted": "A página foi eliminada depois de obter a data e hora da mesma.",
+ "apierror-pagelang-disabled": "Nesta wiki não é permitido alterar a língua de uma página.",
+ "apierror-paramempty": "O parâmetro <var>$1</var> não pode estar vazio.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> só é suportado para conteúdo em texto wiki.",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> só é suportado para conteúdo em texto wiki. A página $1 usa o modelo de conteúdo $2.",
+ "apierror-pastexpiry": "A data de expiração \"$1\" é uma data passada.",
+ "apierror-permissiondenied": "Não tem permissão para $1.",
+ "apierror-permissiondenied-generic": "Permissão negada.",
+ "apierror-permissiondenied-patrolflag": "Necessita ter a permissão <code>patrol</code> ou <code>patrolmarks</code> para solicitar a marca de patrulhado.",
+ "apierror-permissiondenied-unblock": "Não tem permissão para desbloquear utilizadores.",
+ "apierror-prefixsearchdisabled": "A pesquisa por prefixo está desativada no modo avarento.",
+ "apierror-promised-nonwrite-api": "O cabeçalho HTTP <code>Promise-Non-Write-API-Action</code> não pode ser enviado a módulos da API em modo de escrita.",
+ "apierror-protect-invalidaction": "O tipo de proteção \"$1\" é inválido.",
+ "apierror-protect-invalidlevel": "O nível de proteção \"$1\" é inválido.",
+ "apierror-ratelimited": "Excedeu a sua frequência limite de edições. Aguarde um pouco e tente novamente, por favor.",
+ "apierror-readapidenied": "Precisa de ter permissão de leitura para usar este módulo.",
+ "apierror-readonly": "A wiki está em modo exclusivo de leitura.",
+ "apierror-reauthenticate": "Não se autenticou recentemente nesta sessão. Volte a autenticar-se, por favor.",
+ "apierror-redirect-appendonly": "Tentou editar usando o modo de seguimento de redirecionamentos, que só pode ser usado em conjunto com <kbd>section=new</kbd>, <var>prependtext</var>, ou <var>appendtext</var>.",
+ "apierror-revdel-mutuallyexclusive": "Não pode usar o mesmo campo em <var>hide</var> e em <var>show</var>.",
+ "apierror-revdel-needtarget": "É necessário um título de destino para este tipo RevDel.",
+ "apierror-revdel-paramneeded": "É necessário pelo menos um valor para <var>hide</var> ou <var>show</var>.",
+ "apierror-revisions-badid": "Não foi encontrada nenhuma revisão para o parâmetro <var>$1</var>.",
+ "apierror-revisions-norevids": "O parâmetro <var>revids</var> não pode ser usado com as opções de listagem (<var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> e <var>$1end</var>).",
+ "apierror-revisions-singlepage": "Foi usado <var>titles</var>, <var>pageids</var> ou um gerador para fornecer diversas páginas, mas os parâmetros <var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> e <var>$1end</var> só podem ser usados sobre uma única página.",
+ "apierror-revwrongpage": "r$1 não é uma revisão de $2.",
+ "apierror-searchdisabled": "A pesquisa <var>$1</var> está desativada.",
+ "apierror-sectionreplacefailed": "Não foi possível combinar a secção atualizada.",
+ "apierror-sectionsnotsupported": "Secções não são suportadas pelo modelo de conteúdo $1.",
+ "apierror-sectionsnotsupported-what": "Secções não são suportadas por $1.",
+ "apierror-show": "Parâmetro incorreto - não podem ser fornecidos valores mutuamente exclusivos.",
+ "apierror-siteinfo-includealldenied": "Não é possível ver a informação de todos os servidores, a menos que <var>$wgShowHostNames</var> tenha o valor \"true\".",
+ "apierror-sizediffdisabled": "A diferença de tamanho está desativada no modo avarento.",
+ "apierror-spamdetected": "A sua edição foi recusada porque continha um fragmento de spam: <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "Não tem permissão para ver os resultados desta página especial.",
+ "apierror-stashedfilenotfound": "O ficheiro não foi encontrado na área de ficheiros escondidos: $1.",
+ "apierror-stashedit-missingtext": "Não foi encontrado nenhum texto na área de ficheiros escondidos com a chave criptográfica fornecida.",
+ "apierror-stashfailed-complete": "O carregamento por segmentos já terminou. Para mais detalhes, verifique o estado.",
+ "apierror-stashfailed-nosession": "Não há nenhuma sessão de carregamento por segmentos com esta chave.",
+ "apierror-stashfilestorage": "Não foi possível armazenar na área de ficheiros escondidos o ficheiro enviado: $1.",
+ "apierror-stashinvalidfile": "Ficheiro escondido inválido.",
+ "apierror-stashnosuchfilekey": "A chave de ficheiro não existe: $1.",
+ "apierror-stashpathinvalid": "A chave de ficheiro tem um formato incorreto ou é inválida: $1.",
+ "apierror-stashwrongowner": "Proprietário incorreto: $1",
+ "apierror-stashzerolength": "O ficheiro tem comprimento zero e não foi possível armazená-lo na área de ficheiros escondidos: $1.",
+ "apierror-systemblocked": "Foi automaticamente bloqueado pelo MediaWiki.",
+ "apierror-templateexpansion-notwikitext": "A expansão de predefinições só é suportada para conteúdo em texto wiki. A página $1 usa o modelo de conteúdo $2.",
+ "apierror-timeout": "O servidor não respondeu no prazo esperado.",
+ "apierror-toofewexpiries": "{{PLURAL:$1|Foi fornecida $1 data e hora|Foram fornecidas $1 datas e horas}} de expiração quando {{PLURAL:$2|era necessária|eram necessárias}} $2.",
+ "apierror-unknownaction": "A operação especificada, <kbd>$1</kbd>, não é reconhecida.",
+ "apierror-unknownerror-editpage": "Erro EditPage desconhecido: $1.",
+ "apierror-unknownerror-nocode": "Erro desconhecido.",
+ "apierror-unknownerror": "Erro desconhecido: \"$1\".",
+ "apierror-unknownformat": "Formato não reconhecido \"$1\".",
+ "apierror-unrecognizedparams": "{{PLURAL:$2|Parâmetro não reconhecido|Parâmetros não reconhecidos}}: $1.",
+ "apierror-unrecognizedvalue": "Valor não reconhecido para o parâmetro <var>$1</var>: $2.",
+ "apierror-unsupportedrepo": "O repositório de ficheiros local não suporta consultas sobre todas as imagens.",
+ "apierror-upload-filekeyneeded": "Tem de ser fornecida uma <var>filekey</var> quando o <var>offset</var> é diferente de zero.",
+ "apierror-upload-filekeynotallowed": "Não pode ser fornecida uma <var>filekey</var> quando o <var>offset</var> é 0.",
+ "apierror-upload-inprogress": "O carregamento a partir da área de ficheiros escondidos já está em progresso.",
+ "apierror-upload-missingresult": "Não há nenhum resultado nos dados de estado.",
+ "apierror-urlparamnormal": "Não foi possível normalizar os parâmetros de imagem para $1.",
+ "apierror-writeapidenied": "Não lhe é permitido editar esta wiki através da API.",
+ "apiwarn-alldeletedrevisions-performance": "Para obter um desempenho melhor ao gerar títulos, defina <kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "Não foi possível analisar <var>$1urlparam</var> para $2. Serão utilizadas somente a largura e a altura.",
+ "apiwarn-badutf8": "O valor passado para <var>$1</var> contém dados inválidos ou não normalizados. Os dados textuais devem estar em formato Unicode válido, normalizado em NFC, sem caracteres de controlo C0 exceto HT (\\t), LF (\\n) e CR (\\r).",
+ "apiwarn-checktoken-percentencoding": "Verifique que símbolos como \"+\" na chave estão devidamente codificados com percentagem no URL.",
+ "apiwarn-compare-nocontentmodel": "Não foi possível determinar nenhum modelo de conteúdo; será assumido $1.",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> foi descontinuado. Em substituição, use <kbd>prop=deletedrevisions</kbd> ou <kbd>list=alldeletedrevisions</kbd>, por favor.",
+ "apiwarn-deprecation-expandtemplates-prop": "Dado que não foi especificado nenhum valor para o parâmetro <var>prop</var> foi usado um formato antigo para o resultado. Esse formato está descontinuado e, de futuro, será definido um valor por omissão para o parâmetro <var>prop</var>, de forma que seja sempre usado um formato novo.",
+ "apiwarn-deprecation-httpsexpected": "Foi usado HTTP quando era esperado HTTPS.",
+ "apiwarn-deprecation-login-botpw": "O início de sessões com uma conta principal através de <kbd>action=login</kbd> foi descontinuado e poderá deixar de funcionar sem aviso prévio. Para continuar a iniciar sessões com <kbd>action=login</kbd>, consulte [[Special:BotPasswords]]. Para continuar a iniciar sessões com a conta principal de forma segura, consulte <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "O início de sessões com uma conta principal através de <kbd>action=login</kbd> foi descontinuado e poderá deixar de funcionar sem aviso prévio. Para iniciar uma sessão de forma segura, consulte <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-token": "A obtenção de uma chave através de <kbd>action=login</kbd> foi descontinuada. Em substituição, use <kbd>action=query&meta=tokens&type=login</kbd>.",
+ "apiwarn-deprecation-parameter": "O parâmetro <var>$1</var> foi descontinuado.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> está obsoleto desde o MediaWiki 1.28. Use <kbd>prop=headhtml</kbd> ao criar novos documentos de HTML, ou <kbd>prop=modules|jsconfigvars</kbd> ao atualizar um documento no lado do cliente.",
+ "apiwarn-deprecation-purge-get": "O uso de <kbd>action=purge</kbd> através de um GET foi descontinuado. Em substituição, use um POST.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> foi descontinuado. Em substituição, use <kbd>$2</kbd>, por favor.",
+ "apiwarn-difftohidden": "Não foi possível criar uma lista das diferenças em relação à r$1: o conteúdo está ocultado.",
+ "apiwarn-errorprinterfailed": "A impressora de erros falhou. Será feita nova tentativa sem parâmetros.",
+ "apiwarn-errorprinterfailed-ex": "A impressora de erros falhou (será feita nova tentativa sem parâmetros): $1",
+ "apiwarn-invalidcategory": "\"$1\" não é uma categoria.",
+ "apiwarn-invalidtitle": "\"$1\" não é um título válido.",
+ "apiwarn-invalidxmlstylesheetext": "Uma folha de estilos deve ter a extensão <code>.xsl</code>.",
+ "apiwarn-invalidxmlstylesheet": "Foi especificada uma folha de estilos inválida ou inexistente.",
+ "apiwarn-invalidxmlstylesheetns": "A folha de estilos deveria estar no espaço nominal {{ns:MediaWiki}}.",
+ "apiwarn-moduleswithoutvars": "A propriedade <kbd>modules</kbd> foi definida mas <kbd>jsconfigvars</kbd> ou <kbd>encodedjsconfigvars</kbd> não o foram. Variáveis de configuração são necessárias para utilização correta de módulos.",
+ "apiwarn-notfile": "\"$1\" não é um ficheiro.",
+ "apiwarn-nothumb-noimagehandler": "Não foi possível criar a miniatura porque $1 não tem uma rotina associada de tratamento de imagens.",
+ "apiwarn-parse-nocontentmodel": "Não foi fornecido um <var>title</var> ou <var>contentmodel</var>, será assumido $1.",
+ "apiwarn-parse-titlewithouttext": "<var>title</var> foi usado sem <var>text</var>, e foram pedidas as propriedades da página analisada. Pretendia usar <var>page</var> em vez de <var>title</var>?",
+ "apiwarn-redirectsandrevids": "Resolução de redirecionamentos não pode ser usada em conjunto com o parâmetro <var>revids</var>. Quaisquer redirecionamentos para os quais <var>revids</var> aponta não foram resolvidos.",
+ "apiwarn-tokennotallowed": "A operação \"$1\" não é permitida para o utilizador atual.",
+ "apiwarn-tokens-origin": "Não é possível obter chaves quando a norma da mesma origem não é aplicada.",
+ "apiwarn-toomanyvalues": "Foram fornecidos demasiados valores para o parâmetro <var>$1</var>. O limite é $2.",
+ "apiwarn-truncatedresult": "Este resultado foi truncado porque ultrapassaria o limite de $1 bytes.",
+ "apiwarn-unclearnowtimestamp": "A passagem de \"$2\" no parâmetro de data e hora <var>$1</var> foi tornada obsoleta. Se, por qualquer razão, precisa de especificar de forma explícita a hora atual sem a calcular no lado do cliente, use <kbd>now</kbd>.",
+ "apiwarn-unrecognizedvalues": "{{PLURAL:$3|Valor não reconhecido|Valores não reconhecidos}} para o parâmetro <var>$1</var>: $2.",
+ "apiwarn-unsupportedarray": "O parâmetro <var>$1</var> usa sintaxe PHP de matrizes não suportada.",
+ "apiwarn-urlparamwidth": "O valor da largura definido em <var>$1urlparam</var> ($2) foi ignorado em favor da largura obtida a partir de <var>$1urlwidth</var>/<var>$1urlheight</var> ($3).",
+ "apiwarn-validationfailed-badchars": "caracteres inválidos na chave (só são permitidos <code>a-z</code>, <code>A-Z</code>, <code>0-9</code>, <code>_</code>, e <code>-</code>).",
+ "apiwarn-validationfailed-badpref": "não é uma preferência válida.",
+ "apiwarn-validationfailed-cannotset": "não pode ser definido por este módulo.",
+ "apiwarn-validationfailed-keytoolong": "chave demasiado longa (não pode ter mais de $1 bytes).",
+ "apiwarn-validationfailed": "Erro de validação de <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>Aviso de segurança</strong>: <var>$wgDebugAPI</var> está ativado.",
+ "api-feed-error-title": "Erro ($1)",
+ "api-usage-docref": "Consulte $1 para a utilização da API.",
+ "api-usage-mailinglist-ref": "Subscreva a lista de distribuição mediawiki-api-announce em &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; para receber anúncios de descontinuação e de alterações disruptivas da API.",
+ "api-exception-trace": "$1 em $2($3)\n$4",
+ "api-credits-header": "Créditos",
+ "api-credits": "Programadores da API:\n* Yuri Astrakhan (criador, programador principal, set 2006–set 2007)\n* Roan Kattouw (programador principal, set 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (programador principal, 2013–presente)\n\nPode enviar os seus comentários, sugestões e perguntas para o endereço mediawiki-api@lists.wikimedia.org, ou reportar quaisquer defeitos que encontre em https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/qqq.json b/www/wiki/includes/api/i18n/qqq.json
new file mode 100644
index 00000000..6aaaac70
--- /dev/null
+++ b/www/wiki/includes/api/i18n/qqq.json
@@ -0,0 +1,1762 @@
+{
+ "@metadata": {
+ "authors": [
+ "Liuxinyu970226",
+ "Robby",
+ "Shirayuki",
+ "Umherirrender",
+ "McDutchie",
+ "Raymond",
+ "Anomie",
+ "Nemo bis",
+ "Amire80",
+ "Siebrand",
+ "Purodha",
+ "Tacsipacsi",
+ "D41D8CD98F"
+ ]
+ },
+ "apihelp-main-summary": "{{doc-apihelp-summary|main}}",
+ "apihelp-main-extended-description": "{{doc-apihelp-extended-description|main}}",
+ "apihelp-main-param-action": "{{doc-apihelp-param|main|action}}",
+ "apihelp-main-param-format": "{{doc-apihelp-param|main|format}}",
+ "apihelp-main-param-maxlag": "{{doc-apihelp-param|main|maxlag}}",
+ "apihelp-main-param-smaxage": "{{doc-apihelp-param|main|smaxage}}",
+ "apihelp-main-param-maxage": "{{doc-apihelp-param|main|maxage}}",
+ "apihelp-main-param-assert": "{{doc-apihelp-param|main|assert}}",
+ "apihelp-main-param-assertuser": "{{doc-apihelp-param|main|assertuser}}",
+ "apihelp-main-param-requestid": "{{doc-apihelp-param|main|requestid}}",
+ "apihelp-main-param-servedby": "{{doc-apihelp-param|main|servedby}}",
+ "apihelp-main-param-curtimestamp": "{{doc-apihelp-param|main|curtimestamp}}",
+ "apihelp-main-param-responselanginfo": "{{doc-apihelp-param|main|responselanginfo}}",
+ "apihelp-main-param-origin": "{{doc-apihelp-param|main|origin}}",
+ "apihelp-main-param-uselang": "{{doc-apihelp-param|main|uselang}}",
+ "apihelp-main-param-errorformat": "{{doc-apihelp-param|main|errorformat}}",
+ "apihelp-main-param-errorlang": "{{doc-apihelp-param|main|errorlang}}",
+ "apihelp-main-param-errorsuselocal": "{{doc-apihelp-param|main|errorsuselocal}}",
+ "apihelp-block-summary": "{{doc-apihelp-summary|block}}",
+ "apihelp-block-param-user": "{{doc-apihelp-param|block|user}}",
+ "apihelp-block-param-userid": "{{doc-apihelp-param|block|userid}}",
+ "apihelp-block-param-expiry": "{{doc-apihelp-param|block|expiry}}\n{{doc-important|Do not translate \"5 months\", \"2 weeks\", \"infinite\", \"indefinite\" or \"never\"!}}",
+ "apihelp-block-param-reason": "{{doc-apihelp-param|block|reason}}",
+ "apihelp-block-param-anononly": "{{doc-apihelp-param|block|anononly}}\n* See also {{msg-mw|ipb-hardblock}}",
+ "apihelp-block-param-nocreate": "{{doc-apihelp-param|block|nocreate}}\n* See also {{msg-mw|ipbcreateaccount}}",
+ "apihelp-block-param-autoblock": "{{doc-singularthey}}\n{{doc-apihelp-param|block|autoblock}}\n* See also {{msg-mw|ipbenableautoblock}}",
+ "apihelp-block-param-noemail": "{{doc-apihelp-param|block|noemail}}\n* See also {{msg-mw|ipbemailban}}",
+ "apihelp-block-param-hidename": "{{doc-apihelp-param|block|hidename}}",
+ "apihelp-block-param-allowusertalk": "{{doc-apihelp-param|block|allowusertalk}}\n* See also {{msg-mw|ipb-disableusertalk}}",
+ "apihelp-block-param-reblock": "{{doc-apihelp-param|block|reblock}}",
+ "apihelp-block-param-watchuser": "{{doc-apihelp-param|block|watchuser}}",
+ "apihelp-block-param-tags": "{{doc-apihelp-param|block|tags}}",
+ "apihelp-block-example-ip-simple": "{{doc-apihelp-example|block}}",
+ "apihelp-block-example-user-complex": "{{doc-apihelp-example|block}}",
+ "apihelp-changeauthenticationdata-summary": "{{doc-apihelp-summary|changeauthenticationdata}}",
+ "apihelp-changeauthenticationdata-example-password": "{{doc-apihelp-example|changeauthenticationdata}}",
+ "apihelp-checktoken-summary": "{{doc-apihelp-summary|checktoken}}",
+ "apihelp-checktoken-param-type": "{{doc-apihelp-param|checktoken|type}}",
+ "apihelp-checktoken-param-token": "{{doc-apihelp-param|checktoken|token}}",
+ "apihelp-checktoken-param-maxtokenage": "{{doc-apihelp-param|checktoken|maxtokenage}}",
+ "apihelp-checktoken-example-simple": "{{doc-apihelp-example|checktoken}}",
+ "apihelp-clearhasmsg-summary": "{{doc-apihelp-summary|clearhasmsg}}",
+ "apihelp-clearhasmsg-example-1": "{{doc-apihelp-example|clearhasmsg}}",
+ "apihelp-clientlogin-summary": "{{doc-apihelp-summary|clientlogin}}",
+ "apihelp-clientlogin-example-login": "{{doc-apihelp-example|clientlogin}}",
+ "apihelp-clientlogin-example-login2": "{{doc-apihelp-example|clientlogin}}",
+ "apihelp-compare-summary": "{{doc-apihelp-summary|compare}}",
+ "apihelp-compare-extended-description": "{{doc-apihelp-extended-description|compare}}",
+ "apihelp-compare-param-fromtitle": "{{doc-apihelp-param|compare|fromtitle}}",
+ "apihelp-compare-param-fromid": "{{doc-apihelp-param|compare|fromid}}",
+ "apihelp-compare-param-fromrev": "{{doc-apihelp-param|compare|fromrev}}",
+ "apihelp-compare-param-fromtext": "{{doc-apihelp-param|compare|fromtext}}",
+ "apihelp-compare-param-frompst": "{{doc-apihelp-param|compare|frompst}}",
+ "apihelp-compare-param-fromcontentmodel": "{{doc-apihelp-param|compare|fromcontentmodel}}",
+ "apihelp-compare-param-fromcontentformat": "{{doc-apihelp-param|compare|fromcontentformat}}",
+ "apihelp-compare-param-totitle": "{{doc-apihelp-param|compare|totitle}}",
+ "apihelp-compare-param-toid": "{{doc-apihelp-param|compare|toid}}",
+ "apihelp-compare-param-torev": "{{doc-apihelp-param|compare|torev}}",
+ "apihelp-compare-param-torelative": "{{doc-apihelp-param|compare|torelative}}",
+ "apihelp-compare-param-totext": "{{doc-apihelp-param|compare|totext}}",
+ "apihelp-compare-param-topst": "{{doc-apihelp-param|compare|topst}}",
+ "apihelp-compare-param-tocontentmodel": "{{doc-apihelp-param|compare|tocontentmodel}}",
+ "apihelp-compare-param-tocontentformat": "{{doc-apihelp-param|compare|tocontentformat}}",
+ "apihelp-compare-param-prop": "{{doc-apihelp-param|compare|prop}}",
+ "apihelp-compare-paramvalue-prop-diff": "{{doc-apihelp-paramvalue|compare|prop|diff}}",
+ "apihelp-compare-paramvalue-prop-diffsize": "{{doc-apihelp-paramvalue|compare|prop|diffsize}}",
+ "apihelp-compare-paramvalue-prop-rel": "{{doc-apihelp-paramvalue|compare|prop|rel}}",
+ "apihelp-compare-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|compare|prop|ids}}",
+ "apihelp-compare-paramvalue-prop-title": "{{doc-apihelp-paramvalue|compare|prop|title}}",
+ "apihelp-compare-paramvalue-prop-user": "{{doc-apihelp-paramvalue|compare|prop|user}}",
+ "apihelp-compare-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|compare|prop|comment}}",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|compare|prop|parsedcomment}}",
+ "apihelp-compare-paramvalue-prop-size": "{{doc-apihelp-paramvalue|compare|prop|size}}",
+ "apihelp-compare-example-1": "{{doc-apihelp-example|compare}}",
+ "apihelp-createaccount-summary": "{{doc-apihelp-summary|createaccount}}",
+ "apihelp-createaccount-param-preservestate": "{{doc-apihelp-param|createaccount|preservestate|info=This message is displayed in addition to {{msg-mw|api-help-authmanagerhelper-preservestate}}.}}",
+ "apihelp-createaccount-example-create": "{{doc-apihelp-example|createaccount}}",
+ "apihelp-createaccount-param-name": "{{doc-apihelp-param|createaccount|name}}\n{{Identical|Username}}",
+ "apihelp-createaccount-param-password": "{{doc-apihelp-param|createaccount|password}}",
+ "apihelp-createaccount-param-domain": "{{doc-apihelp-param|createaccount|domain}}",
+ "apihelp-createaccount-param-token": "{{doc-apihelp-param|createaccount|token}}",
+ "apihelp-createaccount-param-email": "{{doc-apihelp-param|createaccount|email}}",
+ "apihelp-createaccount-param-realname": "{{doc-apihelp-param|createaccount|realname}}",
+ "apihelp-createaccount-param-mailpassword": "{{doc-apihelp-param|createaccount|mailpassword}}",
+ "apihelp-createaccount-param-reason": "{{doc-apihelp-param|createaccount|reason}}",
+ "apihelp-createaccount-param-language": "{{doc-apihelp-param|createaccount|language}}",
+ "apihelp-createaccount-example-pass": "{{doc-apihelp-example|createaccount}}",
+ "apihelp-createaccount-example-mail": "{{doc-apihelp-example|createaccount}}",
+ "apihelp-cspreport-summary": "{{doc-apihelp-summary|cspreport}}",
+ "apihelp-cspreport-param-reportonly": "{{doc-apihelp-param|cspreport|reportonly}}",
+ "apihelp-cspreport-param-source": "{{doc-apihelp-param|cspreport|source}}",
+ "apihelp-delete-summary": "{{doc-apihelp-summary|delete}}",
+ "apihelp-delete-param-title": "{{doc-apihelp-param|delete|title}}",
+ "apihelp-delete-param-pageid": "{{doc-apihelp-param|delete|pageid}}",
+ "apihelp-delete-param-reason": "{{doc-apihelp-param|delete|reason}}",
+ "apihelp-delete-param-tags": "{{doc-apihelp-param|delete|tags}}",
+ "apihelp-delete-param-watch": "{{doc-apihelp-param|delete|watch}}",
+ "apihelp-delete-param-watchlist": "{{doc-apihelp-param|delete|watchlist}}",
+ "apihelp-delete-param-unwatch": "{{doc-apihelp-param|delete|unwatch}}",
+ "apihelp-delete-param-oldimage": "{{doc-apihelp-param|delete|oldimage}}",
+ "apihelp-delete-example-simple": "{{doc-apihelp-example|delete}}",
+ "apihelp-delete-example-reason": "{{doc-apihelp-example|delete}}",
+ "apihelp-disabled-summary": "{{doc-apihelp-summary|disabled}}",
+ "apihelp-edit-summary": "{{doc-apihelp-summary|edit}}",
+ "apihelp-edit-param-title": "{{doc-apihelp-param|edit|title}}",
+ "apihelp-edit-param-pageid": "{{doc-apihelp-param|edit|pageid}}",
+ "apihelp-edit-param-section": "{{doc-apihelp-param|edit|section}}",
+ "apihelp-edit-param-sectiontitle": "{{doc-apihelp-param|edit|sectiontitle}}",
+ "apihelp-edit-param-text": "{{doc-apihelp-param|edit|text}}",
+ "apihelp-edit-param-summary": "{{doc-apihelp-param|edit|summary}}",
+ "apihelp-edit-param-tags": "{{doc-apihelp-param|edit|tags}}",
+ "apihelp-edit-param-minor": "{{doc-apihelp-param|edit|minor}}\n{{Identical|Minor edit}}",
+ "apihelp-edit-param-notminor": "{{doc-apihelp-param|edit|notminor}}",
+ "apihelp-edit-param-bot": "{{doc-apihelp-param|edit|bot}}",
+ "apihelp-edit-param-basetimestamp": "{{doc-apihelp-param|edit|basetimestamp}}",
+ "apihelp-edit-param-starttimestamp": "{{doc-apihelp-param|edit|starttimestamp}}",
+ "apihelp-edit-param-recreate": "{{doc-apihelp-param|edit|recreate}}",
+ "apihelp-edit-param-createonly": "{{doc-apihelp-param|edit|createonly}}",
+ "apihelp-edit-param-nocreate": "{{doc-apihelp-param|edit|nocreate}}",
+ "apihelp-edit-param-watch": "{{doc-apihelp-param|edit|watch}}",
+ "apihelp-edit-param-unwatch": "{{doc-apihelp-param|edit|unwatch}}",
+ "apihelp-edit-param-watchlist": "{{doc-apihelp-param|edit|watchlist}}",
+ "apihelp-edit-param-md5": "{{doc-apihelp-param|edit|md5}}",
+ "apihelp-edit-param-prependtext": "{{doc-apihelp-param|edit|prependtext}}",
+ "apihelp-edit-param-appendtext": "{{doc-apihelp-param|edit|appendtext}}",
+ "apihelp-edit-param-undo": "{{doc-apihelp-param|edit|undo}}",
+ "apihelp-edit-param-undoafter": "{{doc-apihelp-param|edit|undoafter}}",
+ "apihelp-edit-param-redirect": "{{doc-apihelp-param|edit|redirect}}",
+ "apihelp-edit-param-contentformat": "{{doc-apihelp-param|edit|contentformat}}",
+ "apihelp-edit-param-contentmodel": "{{doc-apihelp-param|edit|contentmodel}}",
+ "apihelp-edit-param-token": "{{doc-apihelp-param|edit|token}}",
+ "apihelp-edit-example-edit": "{{doc-apihelp-example|edit}}",
+ "apihelp-edit-example-prepend": "{{doc-apihelp-example|edit}}",
+ "apihelp-edit-example-undo": "{{doc-apihelp-example|edit}}",
+ "apihelp-emailuser-summary": "{{doc-apihelp-summary|emailuser}}",
+ "apihelp-emailuser-param-target": "{{doc-apihelp-param|emailuser|target}}",
+ "apihelp-emailuser-param-subject": "{{doc-apihelp-param|emailuser|subject}}",
+ "apihelp-emailuser-param-text": "{{doc-apihelp-param|emailuser|text}}",
+ "apihelp-emailuser-param-ccme": "{{doc-apihelp-param|emailuser|ccme}}",
+ "apihelp-emailuser-example-email": "{{doc-apihelp-example|emailuser}}",
+ "apihelp-expandtemplates-summary": "{{doc-apihelp-summary|expandtemplates}}",
+ "apihelp-expandtemplates-param-title": "{{doc-apihelp-param|expandtemplates|title}}",
+ "apihelp-expandtemplates-param-text": "{{doc-apihelp-param|expandtemplates|text}}",
+ "apihelp-expandtemplates-param-revid": "{{doc-apihelp-param|expandtemplates|revid}}",
+ "apihelp-expandtemplates-param-prop": "{{doc-apihelp-param|expandtemplates|prop|paramvalues=1}}",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "{{doc-apihelp-paramvalue|expandtemplates|prop|wikitext}}",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "{{doc-apihelp-paramvalue|expandtemplates|prop|categories}}",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "{{doc-apihelp-paramvalue|expandtemplates|prop|properties}}",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "{{doc-apihelp-paramvalue|expandtemplates|prop|volatile}}",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "{{doc-apihelp-paramvalue|expandtemplates|prop|ttl}}",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "{{doc-apihelp-paramvalue|expandtemplates|prop|modules}}",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "{{doc-apihelp-paramvalue|expandtemplates|prop|jsconfigvars}}",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "{{doc-apihelp-paramvalue|expandtemplates|prop|encodedjsconfigvars}}",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "{{doc-apihelp-paramvalue|expandtemplates|prop|parsetree}}",
+ "apihelp-expandtemplates-param-includecomments": "{{doc-apihelp-param|expandtemplates|includecomments}}",
+ "apihelp-expandtemplates-param-generatexml": "{{doc-apihelp-param|expandtemplates|generatexml}}",
+ "apihelp-expandtemplates-example-simple": "{{doc-apihelp-example|expandtemplates}}",
+ "apihelp-feedcontributions-summary": "{{doc-apihelp-summary|feedcontributions}}",
+ "apihelp-feedcontributions-param-feedformat": "{{doc-apihelp-param|feedcontributions|feedformat}}",
+ "apihelp-feedcontributions-param-user": "{{doc-apihelp-param|feedcontributions|user}}",
+ "apihelp-feedcontributions-param-namespace": "{{doc-apihelp-param|feedcontributions|namespace}}",
+ "apihelp-feedcontributions-param-year": "{{doc-apihelp-param|feedcontributions|year}}",
+ "apihelp-feedcontributions-param-month": "{{doc-apihelp-param|feedcontributions|month}}",
+ "apihelp-feedcontributions-param-tagfilter": "{{doc-apihelp-param|feedcontributions|tagfilter}}",
+ "apihelp-feedcontributions-param-deletedonly": "{{doc-apihelp-param|feedcontributions|deletedonly}}",
+ "apihelp-feedcontributions-param-toponly": "{{doc-apihelp-param|feedcontributions|toponly}}",
+ "apihelp-feedcontributions-param-newonly": "{{doc-apihelp-param|feedcontributions|newonly}}",
+ "apihelp-feedcontributions-param-hideminor": "{{doc-apihelp-param|feedcontributions|hideminor}}",
+ "apihelp-feedcontributions-param-showsizediff": "{{doc-apihelp-param|feedcontributions|showsizediff}}",
+ "apihelp-feedcontributions-example-simple": "{{doc-apihelp-example|feedcontributions}}",
+ "apihelp-feedrecentchanges-summary": "{{doc-apihelp-summary|feedrecentchanges}}",
+ "apihelp-feedrecentchanges-param-feedformat": "{{doc-apihelp-param|feedrecentchanges|feedformat}}",
+ "apihelp-feedrecentchanges-param-namespace": "{{doc-apihelp-param|feedrecentchanges|namespace}}",
+ "apihelp-feedrecentchanges-param-invert": "{{doc-apihelp-param|feedrecentchanges|invert}}",
+ "apihelp-feedrecentchanges-param-associated": "{{doc-apihelp-param|feedrecentchanges|associated}}",
+ "apihelp-feedrecentchanges-param-days": "{{doc-apihelp-param|feedrecentchanges|days}}",
+ "apihelp-feedrecentchanges-param-limit": "{{doc-apihelp-param|feedrecentchanges|limit}}",
+ "apihelp-feedrecentchanges-param-from": "{{doc-apihelp-param|feedrecentchanges|from}}",
+ "apihelp-feedrecentchanges-param-hideminor": "{{doc-apihelp-param|feedrecentchanges|hideminor}}",
+ "apihelp-feedrecentchanges-param-hidebots": "{{doc-apihelp-param|feedrecentchanges|hidebots}}",
+ "apihelp-feedrecentchanges-param-hideanons": "{{doc-apihelp-param|feedrecentchanges|hideanons}}",
+ "apihelp-feedrecentchanges-param-hideliu": "{{doc-apihelp-param|feedrecentchanges|hideliu}}",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "{{doc-apihelp-param|feedrecentchanges|hidepatrolled}}",
+ "apihelp-feedrecentchanges-param-hidemyself": "{{doc-apihelp-param|feedrecentchanges|hidemyself}}",
+ "apihelp-feedrecentchanges-param-hidecategorization": "{{doc-apihelp-param|feedrecentchanges|hidecategorization}}",
+ "apihelp-feedrecentchanges-param-tagfilter": "{{doc-apihelp-param|feedrecentchanges|tagfilter}}",
+ "apihelp-feedrecentchanges-param-target": "{{doc-apihelp-param|feedrecentchanges|target}}",
+ "apihelp-feedrecentchanges-param-showlinkedto": "{{doc-apihelp-param|feedrecentchanges|showlinkedto}}",
+ "apihelp-feedrecentchanges-param-categories": "{{doc-apihelp-param|feedrecentchanges|categories}}",
+ "apihelp-feedrecentchanges-param-categories_any": "{{doc-apihelp-param|feedrecentchanges|categories_any}}",
+ "apihelp-feedrecentchanges-example-simple": "{{doc-apihelp-example|feedrecentchanges}}",
+ "apihelp-feedrecentchanges-example-30days": "{{doc-apihelp-example|feedrecentchanges}}",
+ "apihelp-feedwatchlist-summary": "{{doc-apihelp-summary|feedwatchlist}}",
+ "apihelp-feedwatchlist-param-feedformat": "{{doc-apihelp-param|feedwatchlist|feedformat}}",
+ "apihelp-feedwatchlist-param-hours": "{{doc-apihelp-param|feedwatchlist|hours}}",
+ "apihelp-feedwatchlist-param-linktosections": "{{doc-apihelp-param|feedwatchlist|linktosections}}",
+ "apihelp-feedwatchlist-example-default": "{{doc-apihelp-example|feedwatchlist}}",
+ "apihelp-feedwatchlist-example-all6hrs": "{{doc-apihelp-example|feedwatchlist}}",
+ "apihelp-filerevert-summary": "{{doc-apihelp-summary|filerevert}}",
+ "apihelp-filerevert-param-filename": "{{doc-apihelp-param|filerevert|filename}}",
+ "apihelp-filerevert-param-comment": "Translate as \"a comment about the upload\".\n\n{{doc-apihelp-param|filerevert|comment}}",
+ "apihelp-filerevert-param-archivename": "{{doc-apihelp-param|filerevert|archivename}}",
+ "apihelp-filerevert-example-revert": "{{doc-apihelp-example|filerevert}}",
+ "apihelp-help-summary": "{{doc-apihelp-summary|help}}",
+ "apihelp-help-param-modules": "{{doc-apihelp-param|help|modules}}",
+ "apihelp-help-param-submodules": "{{doc-apihelp-param|help|submodules}}",
+ "apihelp-help-param-recursivesubmodules": "{{doc-apihelp-param|help|recursivesubmodules}}",
+ "apihelp-help-param-helpformat": "{{doc-apihelp-param|help|helpformat}}",
+ "apihelp-help-param-wrap": "{{doc-apihelp-param|help|wrap}}",
+ "apihelp-help-param-toc": "{{doc-apihelp-param|help|toc}}",
+ "apihelp-help-example-main": "{{doc-apihelp-example|help}}",
+ "apihelp-help-example-submodules": "{{doc-apihelp-example|help}}",
+ "apihelp-help-example-recursive": "{{doc-apihelp-example|help}}",
+ "apihelp-help-example-help": "{{doc-apihelp-example|help}}",
+ "apihelp-help-example-query": "{{doc-apihelp-example|help}}",
+ "apihelp-imagerotate-summary": "{{doc-apihelp-summary|imagerotate}}",
+ "apihelp-imagerotate-param-rotation": "{{doc-apihelp-param|imagerotate|rotation}}",
+ "apihelp-imagerotate-param-tags": "{{doc-apihelp-param|imagerotate|tags}}",
+ "apihelp-imagerotate-example-simple": "{{doc-apihelp-example|imagerotate}}",
+ "apihelp-imagerotate-example-generator": "{{doc-apihelp-example|imagerotate}}",
+ "apihelp-import-summary": "{{doc-apihelp-summary|import}}",
+ "apihelp-import-extended-description": "{{doc-apihelp-extended-description|import}}",
+ "apihelp-import-param-summary": "{{doc-apihelp-param|import|summary|info=The parameter being documented here provides the summary used on the log messages about the import. The phrase \"Import summary\" here is grammatically equivalent to a phrase such as \"science book\", not \"eat food\".}}",
+ "apihelp-import-param-xml": "{{doc-apihelp-param|import|xml}}",
+ "apihelp-import-param-interwikisource": "{{doc-apihelp-param|import|interwikisource}}",
+ "apihelp-import-param-interwikipage": "{{doc-apihelp-param|import|interwikipage}}",
+ "apihelp-import-param-fullhistory": "{{doc-apihelp-param|import|fullhistory}}",
+ "apihelp-import-param-templates": "{{doc-apihelp-param|import|templates}}",
+ "apihelp-import-param-namespace": "{{doc-apihelp-param|import|namespace}}",
+ "apihelp-import-param-rootpage": "{{doc-apihelp-param|import|rootpage}}",
+ "apihelp-import-param-tags": "{{doc-apihelp-param|import|tags}}",
+ "apihelp-import-example-import": "{{doc-apihelp-example|import}}",
+ "apihelp-linkaccount-summary": "{{doc-apihelp-summary|linkaccount}}",
+ "apihelp-linkaccount-example-link": "{{doc-apihelp-example|linkaccount}}",
+ "apihelp-login-summary": "{{doc-apihelp-summary|login}}",
+ "apihelp-login-extended-description": "{{doc-apihelp-extended-description|login|info=This message is used when <code>$wgEnableBotPasswords</code> is true.|seealso=* {{msg-mw|apihelp-login-extended-description-nobotpasswords}}}}",
+ "apihelp-login-extended-description-nobotpasswords": "{{doc-apihelp-extended-description|login|info=This message is used when <code>$wgEnableBotPasswords</code> is false.|seealso=* {{msg-mw|apihelp-login-extended-description}}}}",
+ "apihelp-login-param-name": "{{doc-apihelp-param|login|name}}\n{{Identical|Username}}",
+ "apihelp-login-param-password": "{{doc-apihelp-param|login|password}}\n{{Identical|Password}}",
+ "apihelp-login-param-domain": "{{doc-apihelp-param|login|domain}}",
+ "apihelp-login-param-token": "{{doc-apihelp-param|login|token}}",
+ "apihelp-login-example-gettoken": "{{doc-apihelp-example|login}}",
+ "apihelp-login-example-login": "{{doc-apihelp-example|login}}\n{{Identical|Log in}}",
+ "apihelp-logout-summary": "{{doc-apihelp-summary|logout}}",
+ "apihelp-logout-example-logout": "{{doc-apihelp-example|logout}}",
+ "apihelp-managetags-summary": "{{doc-apihelp-summary|managetags}}",
+ "apihelp-managetags-param-operation": "{{doc-apihelp-param|managetags|operation}}",
+ "apihelp-managetags-param-tag": "{{doc-apihelp-param|managetags|tag}}",
+ "apihelp-managetags-param-reason": "{{doc-apihelp-param|managetags|reason}}",
+ "apihelp-managetags-param-ignorewarnings": "{{doc-apihelp-param|managetags|ignorewarnings}}",
+ "apihelp-managetags-param-tags": "{{doc-apihelp-param|managetags|tags}}",
+ "apihelp-managetags-example-create": "{{doc-apihelp-example|managetags}}",
+ "apihelp-managetags-example-delete": "{{doc-apihelp-example|managetags|info={{doc-important|The text \"vandlaism\" in this message is intentionally misspelled; the example being documented by this message is the deletion of a misspelled tag.}}}}",
+ "apihelp-managetags-example-activate": "{{doc-apihelp-example|managetags}}",
+ "apihelp-managetags-example-deactivate": "{{doc-apihelp-example|managetags}}",
+ "apihelp-mergehistory-summary": "{{doc-apihelp-summary|mergehistory}}",
+ "apihelp-mergehistory-param-from": "{{doc-apihelp-param|mergehistory|from}}",
+ "apihelp-mergehistory-param-fromid": "{{doc-apihelp-param|mergehistory|fromid}}",
+ "apihelp-mergehistory-param-to": "{{doc-apihelp-param|mergehistory|to}}",
+ "apihelp-mergehistory-param-toid": "{{doc-apihelp-param|mergehistory|toid}}",
+ "apihelp-mergehistory-param-timestamp": "{{doc-apihelp-param|mergehistory|timestamp}}",
+ "apihelp-mergehistory-param-reason": "{{doc-apihelp-param|mergehistory|reason}}",
+ "apihelp-mergehistory-example-merge": "{{doc-apihelp-example|mergehistory}}",
+ "apihelp-mergehistory-example-merge-timestamp": "{{doc-apihelp-example|mergehistory}}",
+ "apihelp-move-summary": "{{doc-apihelp-summary|move}}",
+ "apihelp-move-param-from": "{{doc-apihelp-param|move|from}}",
+ "apihelp-move-param-fromid": "{{doc-apihelp-param|move|fromid}}",
+ "apihelp-move-param-to": "{{doc-apihelp-param|move|to}}",
+ "apihelp-move-param-reason": "{{doc-apihelp-param|move|reason}}",
+ "apihelp-move-param-movetalk": "{{doc-apihelp-param|move|movetalk}}",
+ "apihelp-move-param-movesubpages": "{{doc-apihelp-param|move|movesubpages}}",
+ "apihelp-move-param-noredirect": "{{doc-apihelp-param|move|noredirect}}",
+ "apihelp-move-param-watch": "{{doc-apihelp-param|move|watch}}",
+ "apihelp-move-param-unwatch": "{{doc-apihelp-param|move|unwatch}}",
+ "apihelp-move-param-watchlist": "{{doc-apihelp-param|move|watchlist}}",
+ "apihelp-move-param-ignorewarnings": "{{doc-apihelp-param|move|ignorewarnings}}",
+ "apihelp-move-param-tags": "{{doc-apihelp-param|move|tags}}",
+ "apihelp-move-example-move": "{{doc-apihelp-example|move}}",
+ "apihelp-opensearch-summary": "{{doc-apihelp-summary|opensearch}}",
+ "apihelp-opensearch-param-search": "{{doc-apihelp-param|opensearch|search}}",
+ "apihelp-opensearch-param-limit": "{{doc-apihelp-param|opensearch|limit}}",
+ "apihelp-opensearch-param-namespace": "{{doc-apihelp-param|opensearch|namespace}}",
+ "apihelp-opensearch-param-suggest": "{{doc-apihelp-param|opensearch|suggest}}",
+ "apihelp-opensearch-param-redirects": "{{doc-apihelp-param|opensearch|redirects}}",
+ "apihelp-opensearch-param-format": "{{doc-apihelp-param|opensearch|format}}",
+ "apihelp-opensearch-param-warningsaserror": "{{doc-apihelp-param|opensearch|warningsaserror}}",
+ "apihelp-opensearch-example-te": "{{doc-apihelp-example|opensearch}}",
+ "apihelp-options-summary": "{{doc-apihelp-summary|options}}",
+ "apihelp-options-extended-description": "{{doc-apihelp-extended-description|options}}",
+ "apihelp-options-param-reset": "{{doc-apihelp-param|options|reset}}",
+ "apihelp-options-param-resetkinds": "{{doc-apihelp-param|options|resetkinds}}",
+ "apihelp-options-param-change": "{{doc-apihelp-param|options|change}}",
+ "apihelp-options-param-optionname": "{{doc-apihelp-param|options|optionname}}",
+ "apihelp-options-param-optionvalue": "{{doc-apihelp-param|options|optionvalue}}",
+ "apihelp-options-example-reset": "{{doc-apihelp-example|options}}",
+ "apihelp-options-example-change": "{{doc-apihelp-example|options}}",
+ "apihelp-options-example-complex": "{{doc-apihelp-example|options}}",
+ "apihelp-paraminfo-summary": "{{doc-apihelp-summary|paraminfo}}",
+ "apihelp-paraminfo-param-modules": "{{doc-apihelp-param|paraminfo|modules}}",
+ "apihelp-paraminfo-param-helpformat": "{{doc-apihelp-param|paraminfo|helpformat}}",
+ "apihelp-paraminfo-param-querymodules": "{{doc-apihelp-param|paraminfo|querymodules}}",
+ "apihelp-paraminfo-param-mainmodule": "{{doc-apihelp-param|paraminfo|mainmodule}}",
+ "apihelp-paraminfo-param-pagesetmodule": "{{doc-apihelp-param|paraminfo|pagesetmodule}}",
+ "apihelp-paraminfo-param-formatmodules": "{{doc-apihelp-param|paraminfo|formatmodules}}",
+ "apihelp-paraminfo-example-1": "{{doc-apihelp-example|paraminfo}}",
+ "apihelp-paraminfo-example-2": "{{doc-apihelp-example|paraminfo}}",
+ "apihelp-parse-summary": "{{doc-apihelp-summary|parse}}",
+ "apihelp-parse-extended-description": "{{doc-apihelp-extended-description|parse}}",
+ "apihelp-parse-param-title": "{{doc-apihelp-param|parse|title}}",
+ "apihelp-parse-param-text": "{{doc-apihelp-param|parse|text}}",
+ "apihelp-parse-param-revid": "{{doc-apihelp-param|parse|revid}}",
+ "apihelp-parse-param-summary": "{{doc-apihelp-param|parse|summary}}",
+ "apihelp-parse-param-page": "{{doc-apihelp-param|parse|page}}",
+ "apihelp-parse-param-pageid": "{{doc-apihelp-param|parse|pageid}}",
+ "apihelp-parse-param-redirects": "{{doc-apihelp-param|parse|redirects}}",
+ "apihelp-parse-param-oldid": "{{doc-apihelp-param|parse|oldid}}",
+ "apihelp-parse-param-prop": "{{doc-apihelp-param|parse|prop|paramvalues=1}}",
+ "apihelp-parse-paramvalue-prop-text": "{{doc-apihelp-paramvalue|parse|prop|text}}",
+ "apihelp-parse-paramvalue-prop-langlinks": "{{doc-apihelp-paramvalue|parse|prop|langlinks}}",
+ "apihelp-parse-paramvalue-prop-categories": "{{doc-apihelp-paramvalue|parse|prop|categories}}",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "{{doc-apihelp-paramvalue|parse|prop|categorieshtml}}",
+ "apihelp-parse-paramvalue-prop-links": "{{doc-apihelp-paramvalue|parse|prop|links}}",
+ "apihelp-parse-paramvalue-prop-templates": "{{doc-apihelp-paramvalue|parse|prop|templates}}",
+ "apihelp-parse-paramvalue-prop-images": "{{doc-apihelp-paramvalue|parse|prop|images}}",
+ "apihelp-parse-paramvalue-prop-externallinks": "{{doc-apihelp-paramvalue|parse|prop|externallinks}}",
+ "apihelp-parse-paramvalue-prop-sections": "{{doc-apihelp-paramvalue|parse|prop|sections}}",
+ "apihelp-parse-paramvalue-prop-revid": "{{doc-apihelp-paramvalue|parse|prop|revid}}",
+ "apihelp-parse-paramvalue-prop-displaytitle": "{{doc-apihelp-paramvalue|parse|prop|displaytitle}}",
+ "apihelp-parse-paramvalue-prop-headitems": "{{doc-apihelp-paramvalue|parse|prop|headitems}}",
+ "apihelp-parse-paramvalue-prop-headhtml": "{{doc-apihelp-paramvalue|parse|prop|headhtml}}",
+ "apihelp-parse-paramvalue-prop-modules": "{{doc-apihelp-paramvalue|parse|prop|modules}}",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "{{doc-apihelp-paramvalue|parse|prop|jsconfigvars}}",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "{{doc-apihelp-paramvalue|parse|prop|encodedjsconfigvars}}",
+ "apihelp-parse-paramvalue-prop-indicators": "{{doc-apihelp-paramvalue|parse|prop|indicators}}",
+ "apihelp-parse-paramvalue-prop-iwlinks": "{{doc-apihelp-paramvalue|parse|prop|iwlinks}}",
+ "apihelp-parse-paramvalue-prop-wikitext": "{{doc-apihelp-paramvalue|parse|prop|wikitext}}",
+ "apihelp-parse-paramvalue-prop-properties": "{{doc-apihelp-paramvalue|parse|prop|properties}}",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "{{doc-apihelp-paramvalue|parse|prop|limitreportdata}}",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "{{doc-apihelp-paramvalue|parse|prop|limitreporthtml}}",
+ "apihelp-parse-paramvalue-prop-parsetree": "{{doc-apihelp-paramvalue|parse|prop|parsetree|params=* $1 - Value of the constant CONTENT_MODEL_WIKITEXT|paramstart=2}}",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "{{doc-apihelp-paramvalue|parse|prop|parsewarnings}}",
+ "apihelp-parse-param-wrapoutputclass": "{{doc-apihelp-param|parse|wrapoutputclass}}",
+ "apihelp-parse-param-pst": "{{doc-apihelp-param|parse|pst}}",
+ "apihelp-parse-param-onlypst": "{{doc-apihelp-param|parse|onlypst}}",
+ "apihelp-parse-param-effectivelanglinks": "{{doc-apihelp-param|parse|effectivelanglinks}}",
+ "apihelp-parse-param-section": "{{doc-apihelp-param|parse|section}}",
+ "apihelp-parse-param-sectiontitle": "{{doc-apihelp-param|parse|sectiontitle}}",
+ "apihelp-parse-param-disablelimitreport": "{{doc-apihelp-param|parse|disablelimitreport}}",
+ "apihelp-parse-param-disablepp": "{{doc-apihelp-param|parse|disablepp}}",
+ "apihelp-parse-param-disableeditsection": "{{doc-apihelp-param|parse|disableeditsection}}",
+ "apihelp-parse-param-disabletidy": "{{doc-apihelp-param|parse|disabletidy}}",
+ "apihelp-parse-param-generatexml": "{{doc-apihelp-param|parse|generatexml|params=* $1 - Value of the constant CONTENT_MODEL_WIKITEXT|paramstart=2}}",
+ "apihelp-parse-param-preview": "{{doc-apihelp-param|parse|preview}}",
+ "apihelp-parse-param-sectionpreview": "{{doc-apihelp-param|parse|sectionpreview}}",
+ "apihelp-parse-param-disabletoc": "{{doc-apihelp-param|parse|disabletoc}}",
+ "apihelp-parse-param-useskin": "{{doc-apihelp-param|parse|useskin}}",
+ "apihelp-parse-param-contentformat": "{{doc-apihelp-param|parse|contentformat}}",
+ "apihelp-parse-param-contentmodel": "{{doc-apihelp-param|parse|contentmodel}}",
+ "apihelp-parse-example-page": "{{doc-apihelp-example|parse}}",
+ "apihelp-parse-example-text": "{{doc-apihelp-example|parse}}",
+ "apihelp-parse-example-texttitle": "{{doc-apihelp-example|parse}}",
+ "apihelp-parse-example-summary": "{{doc-apihelp-example|parse}}",
+ "apihelp-patrol-summary": "{{doc-apihelp-summary|patrol}}",
+ "apihelp-patrol-param-rcid": "{{doc-apihelp-param|patrol|rcid}}",
+ "apihelp-patrol-param-revid": "{{doc-apihelp-param|patrol|revid}}",
+ "apihelp-patrol-param-tags": "{{doc-apihelp-param|patrol|tags}}",
+ "apihelp-patrol-example-rcid": "{{doc-apihelp-example|patrol}}",
+ "apihelp-patrol-example-revid": "{{doc-apihelp-example|patrol}}",
+ "apihelp-protect-summary": "{{doc-apihelp-summary|protect}}",
+ "apihelp-protect-param-title": "{{doc-apihelp-param|protect|title}}",
+ "apihelp-protect-param-pageid": "{{doc-apihelp-param|protect|pageid}}",
+ "apihelp-protect-param-protections": "{{doc-apihelp-param|protect|protections}}",
+ "apihelp-protect-param-expiry": "{{doc-apihelp-param|protect|expiry}}",
+ "apihelp-protect-param-reason": "{{doc-apihelp-param|protect|reason}}",
+ "apihelp-protect-param-tags": "{{doc-apihelp-param|protect|tags}}",
+ "apihelp-protect-param-cascade": "{{doc-apihelp-param|protect|cascade}}",
+ "apihelp-protect-param-watch": "{{doc-apihelp-param|protect|watch}}",
+ "apihelp-protect-param-watchlist": "{{doc-apihelp-param|protect|watchlist}}",
+ "apihelp-protect-example-protect": "{{doc-apihelp-example|protect}}",
+ "apihelp-protect-example-unprotect": "{{doc-apihelp-example|protect}}",
+ "apihelp-protect-example-unprotect2": "{{doc-apihelp-example|protect}}",
+ "apihelp-purge-summary": "{{doc-apihelp-summary|purge}}",
+ "apihelp-purge-param-forcelinkupdate": "{{doc-apihelp-param|purge|forcelinkupdate}}",
+ "apihelp-purge-param-forcerecursivelinkupdate": "{{doc-apihelp-param|purge|forcerecursivelinkupdate}}",
+ "apihelp-purge-example-simple": "{{doc-apihelp-example|purge}}",
+ "apihelp-purge-example-generator": "{{doc-apihelp-example|purge}}",
+ "apihelp-query-summary": "{{doc-apihelp-summary|query}}",
+ "apihelp-query-extended-description": "{{doc-apihelp-extended-description|query}}",
+ "apihelp-query-param-prop": "{{doc-apihelp-param|query|prop}}",
+ "apihelp-query-param-list": "{{doc-apihelp-param|query|list}}",
+ "apihelp-query-param-meta": "{{doc-apihelp-param|query|meta}}",
+ "apihelp-query-param-indexpageids": "{{doc-apihelp-param|query|indexpageids}}",
+ "apihelp-query-param-export": "{{doc-apihelp-param|query|export}}",
+ "apihelp-query-param-exportnowrap": "{{doc-apihelp-param|query|exportnowrap}}",
+ "apihelp-query-param-iwurl": "{{doc-apihelp-param|query|iwurl}}",
+ "apihelp-query-param-rawcontinue": "{{doc-apihelp-param|query|rawcontinue}}",
+ "apihelp-query-example-revisions": "{{doc-apihelp-example|query}}",
+ "apihelp-query-example-allpages": "{{doc-apihelp-example|query}}",
+ "apihelp-query+allcategories-summary": "{{doc-apihelp-summary|query+allcategories}}",
+ "apihelp-query+allcategories-param-from": "{{doc-apihelp-param|query+allcategories|from}}",
+ "apihelp-query+allcategories-param-to": "{{doc-apihelp-param|query+allcategories|to}}",
+ "apihelp-query+allcategories-param-prefix": "{{doc-apihelp-param|query+allcategories|prefix}}",
+ "apihelp-query+allcategories-param-dir": "{{doc-apihelp-param|query+allcategories|dir}}",
+ "apihelp-query+allcategories-param-min": "{{doc-apihelp-param|query+allcategories|min}}",
+ "apihelp-query+allcategories-param-max": "{{doc-apihelp-param|query+allcategories|max}}",
+ "apihelp-query+allcategories-param-limit": "{{doc-apihelp-param|query+allcategories|limit}}",
+ "apihelp-query+allcategories-param-prop": "{{doc-apihelp-param|query+allcategories|prop|paramvalues=1}}",
+ "apihelp-query+allcategories-paramvalue-prop-size": "{{doc-apihelp-paramvalue|query+allcategories|prop|size}}",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "{{doc-apihelp-paramvalue|query+allcategories|prop|hidden}}",
+ "apihelp-query+allcategories-example-size": "{{doc-apihelp-example|query+allcategories}}",
+ "apihelp-query+allcategories-example-generator": "{{doc-apihelp-example|query+allcategories}}",
+ "apihelp-query+alldeletedrevisions-summary": "{{doc-apihelp-summary|query+alldeletedrevisions}}",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "{{doc-apihelp-paraminfo|query+alldeletedrevisions|useronly}}",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "{{doc-apihelp-paraminfo|query+alldeletedrevisions|nonuseronly}}",
+ "apihelp-query+alldeletedrevisions-param-start": "{{doc-apihelp-param|query+alldeletedrevisions|start}}",
+ "apihelp-query+alldeletedrevisions-param-end": "{{doc-apihelp-param|query+alldeletedrevisions|end}}",
+ "apihelp-query+alldeletedrevisions-param-from": "{{doc-apihelp-param|query+alldeletedrevisions|from}}",
+ "apihelp-query+alldeletedrevisions-param-to": "{{doc-apihelp-param|query+alldeletedrevisions|to}}",
+ "apihelp-query+alldeletedrevisions-param-prefix": "{{doc-apihelp-param|query+alldeletedrevisions|prefix}}",
+ "apihelp-query+alldeletedrevisions-param-tag": "{{doc-apihelp-param|query+alldeletedrevisions|tag}}",
+ "apihelp-query+alldeletedrevisions-param-user": "{{doc-apihelp-param|query+alldeletedrevisions|user}}",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "{{doc-apihelp-param|query+alldeletedrevisions|excludeuser}}",
+ "apihelp-query+alldeletedrevisions-param-namespace": "{{doc-apihelp-param|query+alldeletedrevisions|namespace}}",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "{{doc-apihelp-param|query+alldeletedrevisions|miser-user-namespace}}",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "{{doc-apihelp-param|query+alldeletedrevisions|generatetitles}}",
+ "apihelp-query+alldeletedrevisions-example-user": "{{doc-apihelp-example|query+alldeletedrevisions}}",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "{{doc-apihelp-example|query+alldeletedrevisions}}",
+ "apihelp-query+allfileusages-summary": "{{doc-apihelp-summary|query+allfileusages}}",
+ "apihelp-query+allfileusages-param-from": "{{doc-apihelp-param|query+allfileusages|from}}",
+ "apihelp-query+allfileusages-param-to": "{{doc-apihelp-param|query+allfileusages|to}}",
+ "apihelp-query+allfileusages-param-prefix": "{{doc-apihelp-param|query+allfileusages|prefix}}",
+ "apihelp-query+allfileusages-param-unique": "{{doc-apihelp-param|query+allfileusages|unique}}",
+ "apihelp-query+allfileusages-param-prop": "{{doc-apihelp-param|query+allfileusages|prop|paramvalues=1}}",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+allfileusages|prop|ids}}",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+allfileusages|prop|title}}",
+ "apihelp-query+allfileusages-param-limit": "{{doc-apihelp-param|query+allfileusages|limit}}",
+ "apihelp-query+allfileusages-param-dir": "{{doc-apihelp-param|query+allfileusages|dir}}",
+ "apihelp-query+allfileusages-example-B": "{{doc-apihelp-example|query+allfileusages}}",
+ "apihelp-query+allfileusages-example-unique": "{{doc-apihelp-example|query+allfileusages}}",
+ "apihelp-query+allfileusages-example-unique-generator": "{{doc-apihelp-example|query+allfileusages}}",
+ "apihelp-query+allfileusages-example-generator": "{{doc-apihelp-example|query+allfileusages}}",
+ "apihelp-query+allimages-summary": "{{doc-apihelp-summary|query+allimages}}",
+ "apihelp-query+allimages-param-sort": "{{doc-apihelp-param|query+allimages|sort}}",
+ "apihelp-query+allimages-param-dir": "{{doc-apihelp-param|query+allimages|dir}}",
+ "apihelp-query+allimages-param-from": "{{doc-apihelp-param|query+allimages|from}}",
+ "apihelp-query+allimages-param-to": "{{doc-apihelp-param|query+allimages|to}}",
+ "apihelp-query+allimages-param-start": "{{doc-apihelp-param|query+allimages|start}}",
+ "apihelp-query+allimages-param-end": "{{doc-apihelp-param|query+allimages|end}}",
+ "apihelp-query+allimages-param-prefix": "{{doc-apihelp-param|query+allimages|prefix}}",
+ "apihelp-query+allimages-param-minsize": "{{doc-apihelp-param|query+allimages|minsize}}",
+ "apihelp-query+allimages-param-maxsize": "{{doc-apihelp-param|query+allimages|maxsize}}",
+ "apihelp-query+allimages-param-sha1": "{{doc-apihelp-param|query+allimages|sha1}}",
+ "apihelp-query+allimages-param-sha1base36": "{{doc-apihelp-param|query+allimages|sha1base36}}",
+ "apihelp-query+allimages-param-user": "{{doc-apihelp-param|query+allimages|user}}",
+ "apihelp-query+allimages-param-filterbots": "{{doc-apihelp-param|query+allimages|filterbots}}",
+ "apihelp-query+allimages-param-mime": "{{doc-apihelp-param|query+allimages|mime}}",
+ "apihelp-query+allimages-param-limit": "{{doc-apihelp-param|query+allimages|limit}}",
+ "apihelp-query+allimages-example-B": "{{doc-apihelp-example|query+allimages}}",
+ "apihelp-query+allimages-example-recent": "{{doc-apihelp-example|query+allimages}}",
+ "apihelp-query+allimages-example-mimetypes": "{{doc-apihelp-example|query+allimages}}",
+ "apihelp-query+allimages-example-generator": "{{doc-apihelp-example|query+allimages}}",
+ "apihelp-query+alllinks-summary": "{{doc-apihelp-summary|query+alllinks}}",
+ "apihelp-query+alllinks-param-from": "{{doc-apihelp-param|query+alllinks|from}}",
+ "apihelp-query+alllinks-param-to": "{{doc-apihelp-param|query+alllinks|to}}",
+ "apihelp-query+alllinks-param-prefix": "{{doc-apihelp-param|query+alllinks|prefix}}",
+ "apihelp-query+alllinks-param-unique": "{{doc-apihelp-param|query+alllinks|unique}}",
+ "apihelp-query+alllinks-param-prop": "{{doc-apihelp-param|query+alllinks|prop|paramvalues=1}}",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+alllinks|prop|ids}}",
+ "apihelp-query+alllinks-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+alllinks|prop|title}}",
+ "apihelp-query+alllinks-param-namespace": "{{doc-apihelp-param|query+alllinks|namespace}}",
+ "apihelp-query+alllinks-param-limit": "{{doc-apihelp-param|query+alllinks|limit}}",
+ "apihelp-query+alllinks-param-dir": "{{doc-apihelp-param|query+alllinks|dir}}",
+ "apihelp-query+alllinks-example-B": "{{doc-apihelp-example|query+alllinks}}",
+ "apihelp-query+alllinks-example-unique": "{{doc-apihelp-example|query+alllinks}}",
+ "apihelp-query+alllinks-example-unique-generator": "{{doc-apihelp-example|query+alllinks}}",
+ "apihelp-query+alllinks-example-generator": "{{doc-apihelp-example|query+alllinks}}",
+ "apihelp-query+allmessages-summary": "{{doc-apihelp-summary|query+allmessages}}",
+ "apihelp-query+allmessages-param-messages": "{{doc-apihelp-param|query+allmessages|messages}}",
+ "apihelp-query+allmessages-param-prop": "{{doc-apihelp-param|query+allmessages|prop}}",
+ "apihelp-query+allmessages-param-enableparser": "{{doc-apihelp-param|query+allmessages|enableparser}}",
+ "apihelp-query+allmessages-param-nocontent": "{{doc-apihelp-param|query+allmessages|nocontent}}",
+ "apihelp-query+allmessages-param-includelocal": "{{doc-apihelp-param|query+allmessages|includelocal}}",
+ "apihelp-query+allmessages-param-args": "{{doc-apihelp-param|query+allmessages|args}}",
+ "apihelp-query+allmessages-param-filter": "{{doc-apihelp-param|query+allmessages|filter}}",
+ "apihelp-query+allmessages-param-customised": "\"Customisation state\" means the choice made by the user to only list locally customised system messages or not.\n----\n{{doc-apihelp-param|query+allmessages|customised}}",
+ "apihelp-query+allmessages-param-lang": "{{doc-apihelp-param|query+allmessages|lang}}",
+ "apihelp-query+allmessages-param-from": "{{doc-apihelp-param|query+allmessages|from}}",
+ "apihelp-query+allmessages-param-to": "{{doc-apihelp-param|query+allmessages|to}}",
+ "apihelp-query+allmessages-param-title": "{{doc-apihelp-param|query+allmessages|title}}",
+ "apihelp-query+allmessages-param-prefix": "{{doc-apihelp-param|query+allmessages|prefix}}",
+ "apihelp-query+allmessages-example-ipb": "{{doc-apihelp-example|query+allmessages}}",
+ "apihelp-query+allmessages-example-de": "{{doc-apihelp-example|query+allmessages}}",
+ "apihelp-query+allpages-summary": "{{doc-apihelp-summary|query+allpages}}",
+ "apihelp-query+allpages-param-from": "{{doc-apihelp-param|query+allpages|from}}",
+ "apihelp-query+allpages-param-to": "{{doc-apihelp-param|query+allpages|to}}",
+ "apihelp-query+allpages-param-prefix": "{{doc-apihelp-param|query+allpages|prefix}}",
+ "apihelp-query+allpages-param-namespace": "{{doc-apihelp-param|query+allpages|namespace}}",
+ "apihelp-query+allpages-param-filterredir": "{{doc-apihelp-param|query+allpages|filterredir}}",
+ "apihelp-query+allpages-param-minsize": "{{doc-apihelp-param|query+allpages|minsize}}",
+ "apihelp-query+allpages-param-maxsize": "{{doc-apihelp-param|query+allpages|maxsize}}",
+ "apihelp-query+allpages-param-prtype": "{{doc-apihelp-param|query+allpages|prtype}}",
+ "apihelp-query+allpages-param-prlevel": "{{doc-apihelp-param|query+allpages|prlevel}}",
+ "apihelp-query+allpages-param-prfiltercascade": "{{doc-apihelp-param|query+allpages|prfiltercascade}}",
+ "apihelp-query+allpages-param-limit": "{{doc-apihelp-param|query+allpages|limit}}",
+ "apihelp-query+allpages-param-dir": "{{doc-apihelp-param|query+allpages|dir}}",
+ "apihelp-query+allpages-param-filterlanglinks": "{{doc-apihelp-param|query+allpages|filterlanglinks}}",
+ "apihelp-query+allpages-param-prexpiry": "{{doc-apihelp-param|query+allpages|prexpiry}}",
+ "apihelp-query+allpages-example-B": "{{doc-apihelp-example|query+allpages}}",
+ "apihelp-query+allpages-example-generator": "{{doc-apihelp-example|query+allpages}}",
+ "apihelp-query+allpages-example-generator-revisions": "{{doc-apihelp-example|query+allpages}}",
+ "apihelp-query+allredirects-summary": "{{doc-apihelp-summary|query+allredirects}}",
+ "apihelp-query+allredirects-param-from": "{{doc-apihelp-param|query+allredirects|from}}",
+ "apihelp-query+allredirects-param-to": "{{doc-apihelp-param|query+allredirects|to}}",
+ "apihelp-query+allredirects-param-prefix": "{{doc-apihelp-param|query+allredirects|prefix}}",
+ "apihelp-query+allredirects-param-unique": "{{doc-apihelp-param|query+allredirects|unique}}",
+ "apihelp-query+allredirects-param-prop": "{{doc-apihelp-param|query+allredirects|prop|paramvalues=1}}",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+allredirects|prop|ids}}",
+ "apihelp-query+allredirects-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+allredirects|prop|title}}",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "{{doc-apihelp-paramvalue|query+allredirects|prop|fragment}}",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "{{doc-apihelp-paramvalue|query+allredirects|prop|interwiki}}",
+ "apihelp-query+allredirects-param-namespace": "{{doc-apihelp-param|query+allredirects|namespace}}",
+ "apihelp-query+allredirects-param-limit": "{{doc-apihelp-param|query+allredirects|limit}}",
+ "apihelp-query+allredirects-param-dir": "{{doc-apihelp-param|query+allredirects|dir}}",
+ "apihelp-query+allredirects-example-B": "{{doc-apihelp-example|query+allredirects}}",
+ "apihelp-query+allredirects-example-unique": "{{doc-apihelp-example|query+allredirects}}",
+ "apihelp-query+allredirects-example-unique-generator": "{{doc-apihelp-example|query+allredirects}}",
+ "apihelp-query+allredirects-example-generator": "{{doc-apihelp-example|query+allredirects}}",
+ "apihelp-query+allrevisions-summary": "{{doc-apihelp-summary|query+allrevisions}}",
+ "apihelp-query+allrevisions-param-start": "{{doc-apihelp-param|query+allrevisions|start}}",
+ "apihelp-query+allrevisions-param-end": "{{doc-apihelp-param|query+allrevisions|end}}",
+ "apihelp-query+allrevisions-param-user": "{{doc-apihelp-param|query+allrevisions|user}}",
+ "apihelp-query+allrevisions-param-excludeuser": "{{doc-apihelp-param|query+allrevisions|excludeuser}}",
+ "apihelp-query+allrevisions-param-namespace": "{{doc-apihelp-param|query+allrevisions|namespace}}",
+ "apihelp-query+allrevisions-param-generatetitles": "{{doc-apihelp-param|query+allrevisions|generatetitles}}",
+ "apihelp-query+allrevisions-example-user": "{{doc-apihelp-example|query+allrevisions}}",
+ "apihelp-query+allrevisions-example-ns-main": "{{doc-apihelp-example|query+allrevisions}}",
+ "apihelp-query+mystashedfiles-summary": "{{doc-apihelp-summary|query+mystashedfiles}}",
+ "apihelp-query+mystashedfiles-param-prop": "{{doc-apihelp-param|query+mystashedfiles|prop|paramvalues=1}}",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "{{doc-apihelp-paramvalue|query+mystashedfiles|prop|size}}",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "{{doc-apihelp-paramvalue|query+mystashedfiles|prop|type}}",
+ "apihelp-query+mystashedfiles-param-limit": "{{doc-apihelp-param|query+mystashedfiles|limit}}",
+ "apihelp-query+mystashedfiles-example-simple": "{{doc-apihelp-example|query+mystashedfiles}}",
+ "apihelp-query+alltransclusions-summary": "{{doc-apihelp-summary|query+alltransclusions}}",
+ "apihelp-query+alltransclusions-param-from": "{{doc-apihelp-param|query+alltransclusions|from}}",
+ "apihelp-query+alltransclusions-param-to": "{{doc-apihelp-param|query+alltransclusions|to}}",
+ "apihelp-query+alltransclusions-param-prefix": "{{doc-apihelp-param|query+alltransclusions|prefix}}",
+ "apihelp-query+alltransclusions-param-unique": "{{doc-apihelp-param|query+alltransclusions|unique}}",
+ "apihelp-query+alltransclusions-param-prop": "{{doc-apihelp-param|query+alltransclusions|prop|paramvalues=1}}",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+alltransclusions|prop|ids}}",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+alltransclusions|prop|title}}",
+ "apihelp-query+alltransclusions-param-namespace": "{{doc-apihelp-param|query+alltransclusions|namespace}}",
+ "apihelp-query+alltransclusions-param-limit": "{{doc-apihelp-param|query+alltransclusions|limit}}",
+ "apihelp-query+alltransclusions-param-dir": "{{doc-apihelp-param|query+alltransclusions|dir}}",
+ "apihelp-query+alltransclusions-example-B": "{{doc-apihelp-example|query+alltransclusions}}",
+ "apihelp-query+alltransclusions-example-unique": "{{doc-apihelp-example|query+alltransclusions}}",
+ "apihelp-query+alltransclusions-example-unique-generator": "{{doc-apihelp-example|query+alltransclusions}}",
+ "apihelp-query+alltransclusions-example-generator": "{{doc-apihelp-example|query+alltransclusions}}",
+ "apihelp-query+allusers-summary": "{{doc-apihelp-summary|query+allusers}}",
+ "apihelp-query+allusers-param-from": "{{doc-apihelp-param|query+allusers|from}}",
+ "apihelp-query+allusers-param-to": "{{doc-apihelp-param|query+allusers|to}}",
+ "apihelp-query+allusers-param-prefix": "{{doc-apihelp-param|query+allusers|prefix}}",
+ "apihelp-query+allusers-param-dir": "{{doc-apihelp-param|query+allusers|dir}}",
+ "apihelp-query+allusers-param-group": "{{doc-apihelp-param|query+allusers|group}}",
+ "apihelp-query+allusers-param-excludegroup": "{{doc-apihelp-param|query+allusers|excludegroup}}",
+ "apihelp-query+allusers-param-rights": "{{doc-apihelp-param|query+allusers|rights}}",
+ "apihelp-query+allusers-param-prop": "{{doc-apihelp-param|query+allusers|prop|paramvalues=1}}",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "{{doc-apihelp-paramvalue|query+allusers|prop|blockinfo}}",
+ "apihelp-query+allusers-paramvalue-prop-groups": "{{doc-apihelp-paramvalue|query+allusers|prop|groups}}",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "{{doc-apihelp-paramvalue|query+allusers|prop|implicitgroups}}",
+ "apihelp-query+allusers-paramvalue-prop-rights": "{{doc-apihelp-paramvalue|query+allusers|prop|rights}}",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "{{doc-apihelp-paramvalue|query+allusers|prop|editcount}}",
+ "apihelp-query+allusers-paramvalue-prop-registration": "{{doc-apihelp-paramvalue|query+allusers|prop|registration}}",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "{{doc-apihelp-paramvalue|query+allusers|prop|centralids}}",
+ "apihelp-query+allusers-param-limit": "{{doc-apihelp-param|query+allusers|limit}}",
+ "apihelp-query+allusers-param-witheditsonly": "{{doc-apihelp-param|query+allusers|witheditsonly}}",
+ "apihelp-query+allusers-param-activeusers": "{{doc-apihelp-param|query+allusers|activeusers|params=* $1 - Value of [[mw:Manual:$wgActiveUserDays]]|paramstart=2}}",
+ "apihelp-query+allusers-param-attachedwiki": "{{doc-apihelp-param|query+allusers|attachedwiki}}",
+ "apihelp-query+allusers-example-Y": "{{doc-apihelp-example|query+allusers}}",
+ "apihelp-query+authmanagerinfo-summary": "{{doc-apihelp-summary|query+authmanagerinfo}}",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "{{doc-apihelp-param|query+authmanagerinfo|securitysensitiveoperation}}",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "{{doc-apihelp-param|query+authmanagerinfo|requestsfor}}",
+ "apihelp-query+authmanagerinfo-example-login": "{{doc-apihelp-example|query+authmanagerinfo}}",
+ "apihelp-query+authmanagerinfo-example-login-merged": "{{doc-apihelp-example|query+authmanagerinfo}}",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "{{doc-apihelp-example|query+authmanagerinfo}}",
+ "apihelp-query+backlinks-summary": "{{doc-apihelp-summary|query+backlinks}}",
+ "apihelp-query+backlinks-param-title": "{{doc-apihelp-param|query+backlinks|title}}",
+ "apihelp-query+backlinks-param-pageid": "{{doc-apihelp-param|query+backlinks|pageid}}",
+ "apihelp-query+backlinks-param-namespace": "{{doc-apihelp-param|query+backlinks|namespace}}",
+ "apihelp-query+backlinks-param-dir": "{{doc-apihelp-param|query+backlinks|dir}}",
+ "apihelp-query+backlinks-param-filterredir": "{{doc-apihelp-param|query+backlinks|filterredir}}",
+ "apihelp-query+backlinks-param-limit": "{{doc-apihelp-param|query+backlinks|limit}}",
+ "apihelp-query+backlinks-param-redirect": "\"Is halved\" means that the limits are half of the usual ones.\n----\n{{doc-apihelp-param|query+backlinks|redirect}}",
+ "apihelp-query+backlinks-example-simple": "{{doc-apihelp-example|query+backlinks}}",
+ "apihelp-query+backlinks-example-generator": "{{doc-apihelp-example|query+backlinks}}",
+ "apihelp-query+blocks-summary": "{{doc-apihelp-summary|query+blocks}}",
+ "apihelp-query+blocks-param-start": "{{doc-apihelp-param|query+blocks|start}}",
+ "apihelp-query+blocks-param-end": "{{doc-apihelp-param|query+blocks|end}}",
+ "apihelp-query+blocks-param-ids": "{{doc-apihelp-param|query+blocks|ids}}",
+ "apihelp-query+blocks-param-users": "{{doc-apihelp-param|query+blocks|users}}",
+ "apihelp-query+blocks-param-ip": "{{doc-apihelp-param|query+blocks|ip|params=* $1 - Minimum CIDR prefix for IPv4\n* $2 - Minimum CIDR prefix for IPv6|paramstart=3}}",
+ "apihelp-query+blocks-param-limit": "{{doc-apihelp-param|query+blocks|limit}}",
+ "apihelp-query+blocks-param-prop": "{{doc-apihelp-param|query+blocks|prop|paramvalues=1}}",
+ "apihelp-query+blocks-paramvalue-prop-id": "{{doc-apihelp-paramvalue|query+blocks|prop|id}}",
+ "apihelp-query+blocks-paramvalue-prop-user": "{{doc-apihelp-paramvalue|query+blocks|prop|user}}",
+ "apihelp-query+blocks-paramvalue-prop-userid": "{{doc-apihelp-paramvalue|query+blocks|prop|userid}}",
+ "apihelp-query+blocks-paramvalue-prop-by": "{{doc-apihelp-paramvalue|query+blocks|prop|by}}",
+ "apihelp-query+blocks-paramvalue-prop-byid": "{{doc-apihelp-paramvalue|query+blocks|prop|byid}}",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+blocks|prop|timestamp}}",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "{{doc-apihelp-paramvalue|query+blocks|prop|expiry}}",
+ "apihelp-query+blocks-paramvalue-prop-reason": "{{doc-apihelp-paramvalue|query+blocks|prop|reason}}",
+ "apihelp-query+blocks-paramvalue-prop-range": "{{doc-apihelp-paramvalue|query+blocks|prop|range}}",
+ "apihelp-query+blocks-paramvalue-prop-flags": "{{doc-apihelp-paramvalue|query+blocks|prop|flags}}",
+ "apihelp-query+blocks-param-show": "{{doc-apihelp-param|query+blocks|show}}",
+ "apihelp-query+blocks-example-simple": "{{doc-apihelp-example|query+blocks}}",
+ "apihelp-query+blocks-example-users": "{{doc-apihelp-example|query+blocks}}",
+ "apihelp-query+categories-summary": "{{doc-apihelp-summary|query+categories}}",
+ "apihelp-query+categories-param-prop": "{{doc-apihelp-param|query+categories|prop|paramvalues=1}}",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "{{doc-apihelp-paramvalue|query+categories|prop|sortkey}}",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+categories|prop|timestamp}}",
+ "apihelp-query+categories-paramvalue-prop-hidden": "{{doc-apihelp-paramvalue|query+categories|prop|hidden}}",
+ "apihelp-query+categories-param-show": "{{doc-apihelp-param|query+categories|show}}",
+ "apihelp-query+categories-param-limit": "{{doc-apihelp-param|query+categories|limit}}",
+ "apihelp-query+categories-param-categories": "{{doc-apihelp-param|query+categories|categories}}",
+ "apihelp-query+categories-param-dir": "{{doc-apihelp-param|query+categories|dir}}",
+ "apihelp-query+categories-example-simple": "{{doc-apihelp-example|query+categories}}",
+ "apihelp-query+categories-example-generator": "{{doc-apihelp-example|query+categories}}",
+ "apihelp-query+categoryinfo-summary": "{{doc-apihelp-summary|query+categoryinfo}}",
+ "apihelp-query+categoryinfo-example-simple": "{{doc-apihelp-example|query+categoryinfo}}",
+ "apihelp-query+categorymembers-summary": "{{doc-apihelp-summary|query+categorymembers}}",
+ "apihelp-query+categorymembers-param-title": "{{doc-apihelp-param|query+categorymembers|title}}",
+ "apihelp-query+categorymembers-param-pageid": "{{doc-apihelp-param|query+categorymembers|pageid}}",
+ "apihelp-query+categorymembers-param-prop": "{{doc-apihelp-param|query+categorymembers|prop|paramvalues=1}}",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+categorymembers|prop|ids}}",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+categorymembers|prop|title}}",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "{{doc-apihelp-paramvalue|query+categorymembers|prop|sortkey}}",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "{{doc-apihelp-paramvalue|query+categorymembers|prop|sortkeyprefix}}",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "{{doc-apihelp-paramvalue|query+categorymembers|prop|type}}",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+categorymembers|prop|timestamp}}",
+ "apihelp-query+categorymembers-param-namespace": "{{doc-apihelp-param|query+categorymembers|namespace}}",
+ "apihelp-query+categorymembers-param-type": "{{doc-apihelp-param|query+categorymembers|type}}",
+ "apihelp-query+categorymembers-param-limit": "{{doc-apihelp-param|query+categorymembers|limit}}",
+ "apihelp-query+categorymembers-param-sort": "{{doc-apihelp-param|query+categorymembers|sort}}",
+ "apihelp-query+categorymembers-param-dir": "{{doc-apihelp-param|query+categorymembers|dir}}",
+ "apihelp-query+categorymembers-param-start": "{{doc-apihelp-param|query+categorymembers|start}}",
+ "apihelp-query+categorymembers-param-end": "{{doc-apihelp-param|query+categorymembers|end}}",
+ "apihelp-query+categorymembers-param-starthexsortkey": "{{doc-apihelp-param|query+categorymembers|starthexsortkey}}",
+ "apihelp-query+categorymembers-param-endhexsortkey": "{{doc-apihelp-param|query+categorymembers|endhexsortkey}}",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "{{doc-apihelp-param|query+categorymembers|startsortkeyprefix}}",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "{{doc-apihelp-param|query+categorymembers|endsortkeyprefix}}",
+ "apihelp-query+categorymembers-param-startsortkey": "{{doc-apihelp-param|query+categorymembers|startsortkey}}",
+ "apihelp-query+categorymembers-param-endsortkey": "{{doc-apihelp-param|query+categorymembers|endsortkey}}",
+ "apihelp-query+categorymembers-example-simple": "{{doc-apihelp-example|query+categorymembers}}",
+ "apihelp-query+categorymembers-example-generator": "{{doc-apihelp-example|query+categorymembers}}",
+ "apihelp-query+contributors-summary": "{{doc-apihelp-summary|query+contributors}}",
+ "apihelp-query+contributors-param-group": "{{doc-apihelp-param|query+contributors|group}}",
+ "apihelp-query+contributors-param-excludegroup": "{{doc-apihelp-param|query+contributors|excludegroup}}",
+ "apihelp-query+contributors-param-rights": "{{doc-apihelp-param|query+contributors|rights}}",
+ "apihelp-query+contributors-param-excluderights": "{{doc-apihelp-param|query+contributors|excluderights}}",
+ "apihelp-query+contributors-param-limit": "{{doc-apihelp-param|query+contributors|limit}}",
+ "apihelp-query+contributors-example-simple": "{{doc-apihelp-example|query+contributors}}",
+ "apihelp-query+deletedrevisions-summary": "{{doc-apihelp-summary|query+deletedrevisions}}",
+ "apihelp-query+deletedrevisions-extended-description": "{{doc-apihelp-extended-description|query+deletedrevisions}}",
+ "apihelp-query+deletedrevisions-param-start": "{{doc-apihelp-param|query+deletedrevisions|start}}",
+ "apihelp-query+deletedrevisions-param-end": "{{doc-apihelp-param|query+deletedrevisions|end}}",
+ "apihelp-query+deletedrevisions-param-tag": "{{doc-apihelp-param|query+deletedrevisions|tag}}",
+ "apihelp-query+deletedrevisions-param-user": "{{doc-apihelp-param|query+deletedrevisions|user}}",
+ "apihelp-query+deletedrevisions-param-excludeuser": "{{doc-apihelp-param|query+deletedrevisions|excludeuser}}",
+ "apihelp-query+deletedrevisions-example-titles": "{{doc-apihelp-example|query+deletedrevisions}}",
+ "apihelp-query+deletedrevisions-example-revids": "{{doc-apihelp-example|query+deletedrevisions}}",
+ "apihelp-query+deletedrevs-summary": "{{doc-apihelp-summary|query+deletedrevs}}",
+ "apihelp-query+deletedrevs-extended-description": "{{doc-apihelp-extended-description|query+deletedrevs}}",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{doc-apihelp-paraminfo|query+deletedrevs|modes}}\n{{Identical|Mode}}",
+ "apihelp-query+deletedrevs-param-start": "{{doc-apihelp-param|query+deletedrevs|start}}",
+ "apihelp-query+deletedrevs-param-end": "{{doc-apihelp-param|query+deletedrevs|end}}",
+ "apihelp-query+deletedrevs-param-from": "{{doc-apihelp-param|query+deletedrevs|from}}",
+ "apihelp-query+deletedrevs-param-to": "{{doc-apihelp-param|query+deletedrevs|to}}",
+ "apihelp-query+deletedrevs-param-prefix": "{{doc-apihelp-param|query+deletedrevs|prefix}}",
+ "apihelp-query+deletedrevs-param-unique": "{{doc-apihelp-param|query+deletedrevs|unique}}",
+ "apihelp-query+deletedrevs-param-tag": "{{doc-apihelp-param|query+deletedrevs|tag}}",
+ "apihelp-query+deletedrevs-param-user": "{{doc-apihelp-param|query+deletedrevs|user}}",
+ "apihelp-query+deletedrevs-param-excludeuser": "{{doc-apihelp-param|query+deletedrevs|excludeuser}}",
+ "apihelp-query+deletedrevs-param-namespace": "{{doc-apihelp-param|query+deletedrevs|namespace}}",
+ "apihelp-query+deletedrevs-param-limit": "{{doc-apihelp-param|query+deletedrevs|limit}}",
+ "apihelp-query+deletedrevs-param-prop": "{{doc-apihelp-param|query+deletedrevs|prop}}\n{{doc-important|You can translate the word \"Deprecated\", but please do not alter the <code><nowiki>class=\"apihelp-deprecated\"</nowiki></code> attribute}}",
+ "apihelp-query+deletedrevs-example-mode1": "{{doc-apihelp-example|query+deletedrevs}}",
+ "apihelp-query+deletedrevs-example-mode2": "{{doc-apihelp-example|query+deletedrevs}}",
+ "apihelp-query+deletedrevs-example-mode3-main": "{{doc-apihelp-example|query+deletedrevs}}",
+ "apihelp-query+deletedrevs-example-mode3-talk": "{{doc-apihelp-example|query+deletedrevs}}",
+ "apihelp-query+disabled-summary": "{{doc-apihelp-summary|query+disabled}}",
+ "apihelp-query+duplicatefiles-summary": "{{doc-apihelp-summary|query+duplicatefiles}}",
+ "apihelp-query+duplicatefiles-param-limit": "{{doc-apihelp-param|query+duplicatefiles|limit}}",
+ "apihelp-query+duplicatefiles-param-dir": "{{doc-apihelp-param|query+duplicatefiles|dir}}",
+ "apihelp-query+duplicatefiles-param-localonly": "{{doc-apihelp-param|query+duplicatefiles|localonly}}",
+ "apihelp-query+duplicatefiles-example-simple": "{{doc-apihelp-example|query+duplicatefiles}}",
+ "apihelp-query+duplicatefiles-example-generated": "{{doc-apihelp-example|query+duplicatefiles}}",
+ "apihelp-query+embeddedin-summary": "{{doc-apihelp-summary|query+embeddedin}}",
+ "apihelp-query+embeddedin-param-title": "{{doc-apihelp-param|query+embeddedin|title}}",
+ "apihelp-query+embeddedin-param-pageid": "{{doc-apihelp-param|query+embeddedin|pageid}}",
+ "apihelp-query+embeddedin-param-namespace": "{{doc-apihelp-param|query+embeddedin|namespace}}",
+ "apihelp-query+embeddedin-param-dir": "{{doc-apihelp-param|query+embeddedin|dir}}",
+ "apihelp-query+embeddedin-param-filterredir": "{{doc-apihelp-param|query+embeddedin|filterredir}}",
+ "apihelp-query+embeddedin-param-limit": "{{doc-apihelp-param|query+embeddedin|limit}}",
+ "apihelp-query+embeddedin-example-simple": "{{doc-apihelp-example|query+embeddedin}}",
+ "apihelp-query+embeddedin-example-generator": "{{doc-apihelp-example|query+embeddedin}}",
+ "apihelp-query+extlinks-summary": "{{doc-apihelp-summary|query+extlinks}}",
+ "apihelp-query+extlinks-param-limit": "{{doc-apihelp-param|query+extlinks|limit}}",
+ "apihelp-query+extlinks-param-protocol": "{{doc-apihelp-param|query+extlinks|protocol}}",
+ "apihelp-query+extlinks-param-query": "{{doc-apihelp-param|query+extlinks|query}}",
+ "apihelp-query+extlinks-param-expandurl": "{{doc-apihelp-param|query+extlinks|expandurl}}",
+ "apihelp-query+extlinks-example-simple": "{{doc-apihelp-example|query+extlinks}}",
+ "apihelp-query+exturlusage-summary": "{{doc-apihelp-summary|query+exturlusage}}",
+ "apihelp-query+exturlusage-param-prop": "{{doc-apihelp-param|query+exturlusage|prop|paramvalues=1}}",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+exturlusage|prop|ids}}",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+exturlusage|prop|title}}",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "{{doc-apihelp-paramvalue|query+exturlusage|prop|url}}",
+ "apihelp-query+exturlusage-param-protocol": "{{doc-apihelp-param|query+exturlusage|protocol}}",
+ "apihelp-query+exturlusage-param-query": "{{doc-apihelp-param|query+exturlusage|query}}",
+ "apihelp-query+exturlusage-param-namespace": "{{doc-apihelp-param|query+exturlusage|namespace}}",
+ "apihelp-query+exturlusage-param-limit": "{{doc-apihelp-param|query+exturlusage|limit}}",
+ "apihelp-query+exturlusage-param-expandurl": "{{doc-apihelp-param|query+exturlusage|expandurl}}",
+ "apihelp-query+exturlusage-example-simple": "{{doc-apihelp-example|query+exturlusage}}",
+ "apihelp-query+filearchive-summary": "{{doc-apihelp-summary|query+filearchive}}",
+ "apihelp-query+filearchive-param-from": "{{doc-apihelp-param|query+filearchive|from}}",
+ "apihelp-query+filearchive-param-to": "{{doc-apihelp-param|query+filearchive|to}}",
+ "apihelp-query+filearchive-param-prefix": "{{doc-apihelp-param|query+filearchive|prefix}}",
+ "apihelp-query+filearchive-param-limit": "{{doc-apihelp-param|query+filearchive|limit}}",
+ "apihelp-query+filearchive-param-dir": "{{doc-apihelp-param|query+filearchive|dir}}",
+ "apihelp-query+filearchive-param-sha1": "{{doc-apihelp-param|query+filearchive|sha1}}",
+ "apihelp-query+filearchive-param-sha1base36": "{{doc-apihelp-param|query+filearchive|sha1base36}}",
+ "apihelp-query+filearchive-param-prop": "{{doc-apihelp-param|query+filearchive|prop|paramvalues=1}}",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "{{doc-apihelp-paramvalue|query+filearchive|prop|sha1}}",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+filearchive|prop|timestamp}}",
+ "apihelp-query+filearchive-paramvalue-prop-user": "{{doc-apihelp-paramvalue|query+filearchive|prop|user}}",
+ "apihelp-query+filearchive-paramvalue-prop-size": "{{doc-apihelp-paramvalue|query+filearchive|prop|size}}",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "{{doc-apihelp-paramvalue|query+filearchive|prop|dimensions}}",
+ "apihelp-query+filearchive-paramvalue-prop-description": "{{doc-apihelp-paramvalue|query+filearchive|prop|description}}",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "{{doc-apihelp-paramvalue|query+filearchive|prop|parseddescription}}",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "{{doc-apihelp-paramvalue|query+filearchive|prop|mime}}",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "{{doc-apihelp-paramvalue|query+filearchive|prop|mediatype}}",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "{{doc-apihelp-paramvalue|query+filearchive|prop|metadata}}",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "{{doc-apihelp-paramvalue|query+filearchive|prop|bitdepth}}",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "{{doc-apihelp-paramvalue|query+filearchive|prop|archivename}}",
+ "apihelp-query+filearchive-example-simple": "{{doc-apihelp-example|query+filearchive}}",
+ "apihelp-query+filerepoinfo-summary": "{{doc-apihelp-summary|query+filerepoinfo}}",
+ "apihelp-query+filerepoinfo-param-prop": "{{doc-apihelp-param|query+filerepoinfo|prop}}",
+ "apihelp-query+filerepoinfo-example-simple": "{{doc-apihelp-example|query+filerepoinfo}}",
+ "apihelp-query+fileusage-summary": "{{doc-apihelp-summary|query+fileusage}}",
+ "apihelp-query+fileusage-param-prop": "{{doc-apihelp-param|query+fileusage|prop|paramvalues=1}}",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "{{doc-apihelp-paramvalue|query+fileusage|prop|pageid}}",
+ "apihelp-query+fileusage-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+fileusage|prop|title}}",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "{{doc-apihelp-paramvalue|query+fileusage|prop|redirect}}",
+ "apihelp-query+fileusage-param-namespace": "{{doc-apihelp-param|query+fileusage|namespace}}",
+ "apihelp-query+fileusage-param-limit": "{{doc-apihelp-param|query+fileusage|limit}}",
+ "apihelp-query+fileusage-param-show": "{{doc-apihelp-param|query+fileusage|show}}",
+ "apihelp-query+fileusage-example-simple": "{{doc-apihelp-example|query+fileusage}}",
+ "apihelp-query+fileusage-example-generator": "{{doc-apihelp-example|query+fileusage}}",
+ "apihelp-query+imageinfo-summary": "{{doc-apihelp-summary|query+imageinfo}}",
+ "apihelp-query+imageinfo-param-prop": "{{doc-apihelp-param|query+imageinfo|prop|paramvalues=1}}",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+imageinfo|prop|timestamp}}",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "{{doc-apihelp-paramvalue|query+imageinfo|prop|user}}",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Imageinfo returns information about file revisions (normally the last revision since <code>iilimit</code> defaults to 1). <code>userid</code> includes the ID of the user who made the (re)upload which created that revision. So there will be one user ID per imageinfo item; if you set the limit high enough, you will get all revisions of all files as separate imageinfo items.\n\n{{doc-apihelp-paramvalue|query+imageinfo|prop|userid}}",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|query+imageinfo|prop|comment}}",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|query+imageinfo|prop|parsedcomment}}",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "A canonocal title is aa title formatted in the same way you would see it on the top of the page (localized namespace name, first letters capitalized, spaces instead of underscores). \n{{doc-apihelp-paramvalue|query+imageinfo|prop|canonicaltitle}}",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "{{doc-apihelp-paramvalue|query+imageinfo|prop|url}}",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "{{doc-apihelp-paramvalue|query+imageinfo|prop|size}}",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "{{doc-apihelp-paramvalue|query+imageinfo|prop|dimensions}}",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "{{doc-apihelp-paramvalue|query+imageinfo|prop|sha1}}",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "{{doc-apihelp-paramvalue|query+imageinfo|prop|mime}}",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "{{doc-apihelp-paramvalue|query+imageinfo|prop|thumbmime}}",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "{{doc-apihelp-paramvalue|query+imageinfo|prop|mediatype}}",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "{{doc-apihelp-paramvalue|query+imageinfo|prop|metadata}}",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "{{doc-apihelp-paramvalue|query+imageinfo|prop|commonmetadata}}",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "{{doc-apihelp-paramvalue|query+imageinfo|prop|extmetadata}}",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "{{doc-apihelp-paramvalue|query+imageinfo|prop|archivename}}",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "{{doc-apihelp-paramvalue|query+imageinfo|prop|bitdepth}}",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "{{doc-apihelp-paramvalue|query+imageinfo|prop|uploadwarning}}",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "{{doc-apihelp-paramvalue|query+imageinfo|prop|badfile}}",
+ "apihelp-query+imageinfo-param-limit": "{{doc-apihelp-param|query+imageinfo|limit}}",
+ "apihelp-query+imageinfo-param-start": "{{doc-apihelp-param|query+imageinfo|start}}",
+ "apihelp-query+imageinfo-param-end": "{{doc-apihelp-param|query+imageinfo|end}}",
+ "apihelp-query+imageinfo-param-urlwidth": "{{doc-apihelp-param|query+imageinfo|urlwidth|params=* $1 - Maximum number of thumbnails per query|paramstart=2}}",
+ "apihelp-query+imageinfo-param-urlheight": "{{doc-apihelp-param|query+imageinfo|urlheight}}",
+ "apihelp-query+imageinfo-param-metadataversion": "{{doc-apihelp-param|query+imageinfo|metadataversion}}",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "{{doc-apihelp-param|query+imageinfo|extmetadatalanguage}}",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "{{doc-apihelp-param|query+imageinfo|extmetadatamultilang}}",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "{{doc-apihelp-param|query+imageinfo|extmetadatafilter}}",
+ "apihelp-query+imageinfo-param-urlparam": "{{doc-apihelp-param|query+imageinfo|urlparam}}",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "{{doc-apihelp-param|query+imageinfo|badfilecontexttitle}}",
+ "apihelp-query+imageinfo-param-localonly": "{{doc-apihelp-param|query+imageinfo|localonly}}",
+ "apihelp-query+imageinfo-example-simple": "{{doc-apihelp-example|query+imageinfo}}",
+ "apihelp-query+imageinfo-example-dated": "{{doc-apihelp-example|query+imageinfo}}",
+ "apihelp-query+images-summary": "{{doc-apihelp-summary|query+images}}",
+ "apihelp-query+images-param-limit": "{{doc-apihelp-param|query+images|limit}}",
+ "apihelp-query+images-param-images": "{{doc-apihelp-param|query+images|images}}",
+ "apihelp-query+images-param-dir": "{{doc-apihelp-param|query+images|dir}}",
+ "apihelp-query+images-example-simple": "{{doc-apihelp-example|query+images}}",
+ "apihelp-query+images-example-generator": "{{doc-apihelp-example|query+images}}",
+ "apihelp-query+imageusage-summary": "{{doc-apihelp-summary|query+imageusage}}",
+ "apihelp-query+imageusage-param-title": "{{doc-apihelp-param|query+imageusage|title}}",
+ "apihelp-query+imageusage-param-pageid": "{{doc-apihelp-param|query+imageusage|pageid}}",
+ "apihelp-query+imageusage-param-namespace": "{{doc-apihelp-param|query+imageusage|namespace}}",
+ "apihelp-query+imageusage-param-dir": "{{doc-apihelp-param|query+imageusage|dir}}",
+ "apihelp-query+imageusage-param-filterredir": "{{doc-apihelp-param|query+imageusage|filterredir}}",
+ "apihelp-query+imageusage-param-limit": "{{doc-apihelp-param|query+imageusage|limit}}",
+ "apihelp-query+imageusage-param-redirect": "{{doc-apihelp-param|query+imageusage|redirect}}",
+ "apihelp-query+imageusage-example-simple": "{{doc-apihelp-example|query+imageusage}}",
+ "apihelp-query+imageusage-example-generator": "{{doc-apihelp-example|query+imageusage}}",
+ "apihelp-query+info-summary": "{{doc-apihelp-summary|query+info}}",
+ "apihelp-query+info-param-prop": "{{doc-apihelp-param|query+info|prop|paramvalues=1}}",
+ "apihelp-query+info-paramvalue-prop-protection": "{{doc-apihelp-paramvalue|query+info|prop|protection}}",
+ "apihelp-query+info-paramvalue-prop-talkid": "{{doc-apihelp-paramvalue|query+info|prop|talkid}}",
+ "apihelp-query+info-paramvalue-prop-watched": "{{doc-apihelp-paramvalue|query+info|prop|watched}}",
+ "apihelp-query+info-paramvalue-prop-watchers": "{{doc-apihelp-paramvalue|query+info|prop|watchers}}",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "{{doc-apihelp-paramvalue|query+info|prop|visitingwatchers}}",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "{{doc-apihelp-paramvalue|query+info|prop|notificationtimestamp}}",
+ "apihelp-query+info-paramvalue-prop-subjectid": "{{doc-apihelp-paramvalue|query+info|prop|subjectid}}",
+ "apihelp-query+info-paramvalue-prop-url": "{{doc-apihelp-paramvalue|query+info|prop|url}}",
+ "apihelp-query+info-paramvalue-prop-readable": "{{doc-apihelp-paramvalue|query+info|prop|readable}}",
+ "apihelp-query+info-paramvalue-prop-preload": "{{doc-apihelp-paramvalue|query+info|prop|preload}}",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "{{doc-apihelp-paramvalue|query+info|prop|displaytitle}}",
+ "apihelp-query+info-param-testactions": "{{doc-apihelp-param|query+info|testactions}}",
+ "apihelp-query+info-param-token": "{{doc-apihelp-param|query+info|token}}",
+ "apihelp-query+info-example-simple": "{{doc-apihelp-example|query+info}}",
+ "apihelp-query+info-example-protection": "{{doc-apihelp-example|query+info}}",
+ "apihelp-query+iwbacklinks-summary": "{{doc-apihelp-summary|query+iwbacklinks}}",
+ "apihelp-query+iwbacklinks-extended-description": "{{doc-apihelp-extended-description|query+iwbacklinks}}",
+ "apihelp-query+iwbacklinks-param-prefix": "{{doc-apihelp-param|query+iwbacklinks|prefix}}",
+ "apihelp-query+iwbacklinks-param-title": "{{doc-apihelp-param|query+iwbacklinks|title}}",
+ "apihelp-query+iwbacklinks-param-limit": "{{doc-apihelp-param|query+iwbacklinks|limit}}",
+ "apihelp-query+iwbacklinks-param-prop": "{{doc-apihelp-param|query+iwbacklinks|prop|paramvalues=1}}",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "{{doc-apihelp-paramvalue|query+iwbacklinks|prop|iwprefix}}",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "{{doc-apihelp-paramvalue|query+iwbacklinks|prop|iwtitle}}",
+ "apihelp-query+iwbacklinks-param-dir": "{{doc-apihelp-param|query+iwbacklinks|dir}}",
+ "apihelp-query+iwbacklinks-example-simple": "{{doc-apihelp-example|query+iwbacklinks}}",
+ "apihelp-query+iwbacklinks-example-generator": "{{doc-apihelp-example|query+iwbacklinks}}",
+ "apihelp-query+iwlinks-summary": "{{doc-apihelp-summary|query+iwlinks}}",
+ "apihelp-query+iwlinks-param-url": "{{doc-apihelp-param|query+iwlinks|url}}",
+ "apihelp-query+iwlinks-param-prop": "{{doc-apihelp-param|query+iwlinks|prop|paramvalues=1}}",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "{{doc-apihelp-paramvalue|query+iwlinks|prop|url}}",
+ "apihelp-query+iwlinks-param-limit": "{{doc-apihelp-param|query+iwlinks|limit}}",
+ "apihelp-query+iwlinks-param-prefix": "{{doc-apihelp-param|query+iwlinks|prefix}}",
+ "apihelp-query+iwlinks-param-title": "{{doc-apihelp-param|query+iwlinks|title}}",
+ "apihelp-query+iwlinks-param-dir": "{{doc-apihelp-param|query+iwlinks|dir}}",
+ "apihelp-query+iwlinks-example-simple": "{{doc-apihelp-example|query+iwlinks}}",
+ "apihelp-query+langbacklinks-summary": "{{doc-apihelp-summary|query+langbacklinks}}",
+ "apihelp-query+langbacklinks-extended-description": "{{doc-apihelp-extended-description|query+langbacklinks}}",
+ "apihelp-query+langbacklinks-param-lang": "{{doc-apihelp-param|query+langbacklinks|lang}}",
+ "apihelp-query+langbacklinks-param-title": "{{doc-apihelp-param|query+langbacklinks|title}}",
+ "apihelp-query+langbacklinks-param-limit": "{{doc-apihelp-param|query+langbacklinks|limit}}",
+ "apihelp-query+langbacklinks-param-prop": "{{doc-apihelp-param|query+langbacklinks|prop|paramvalues=1}}",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "{{doc-apihelp-paramvalue|query+langbacklinks|prop|lllang}}",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "{{doc-apihelp-paramvalue|query+langbacklinks|prop|lltitle}}",
+ "apihelp-query+langbacklinks-param-dir": "{{doc-apihelp-param|query+langbacklinks|dir}}",
+ "apihelp-query+langbacklinks-example-simple": "{{doc-apihelp-example|query+langbacklinks}}",
+ "apihelp-query+langbacklinks-example-generator": "{{doc-apihelp-example|query+langbacklinks}}",
+ "apihelp-query+langlinks-summary": "{{doc-apihelp-summary|query+langlinks}}",
+ "apihelp-query+langlinks-param-limit": "{{doc-apihelp-param|query+langlinks|limit}}",
+ "apihelp-query+langlinks-param-url": "{{doc-apihelp-param|query+langlinks|url}}",
+ "apihelp-query+langlinks-param-prop": "{{doc-apihelp-param|query+langlinks|prop|paramvalues=1}}",
+ "apihelp-query+langlinks-paramvalue-prop-url": "{{doc-apihelp-paramvalue|query+langlinks|prop|url}}",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "{{doc-apihelp-paramvalue|query+langlinks|prop|langname}}",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "{{doc-apihelp-paramvalue|query+langlinks|prop|autonym}}",
+ "apihelp-query+langlinks-param-lang": "{{doc-apihelp-param|query+langlinks|lang}}",
+ "apihelp-query+langlinks-param-title": "{{doc-apihelp-param|query+langlinks|title}}",
+ "apihelp-query+langlinks-param-dir": "{{doc-apihelp-param|query+langlinks|dir}}",
+ "apihelp-query+langlinks-param-inlanguagecode": "{{doc-apihelp-param|query+langlinks|inlanguagecode}}",
+ "apihelp-query+langlinks-example-simple": "{{doc-apihelp-example|query+langlinks}}",
+ "apihelp-query+links-summary": "{{doc-apihelp-summary|query+links}}",
+ "apihelp-query+links-param-namespace": "{{doc-apihelp-param|query+links|namespace}}",
+ "apihelp-query+links-param-limit": "{{doc-apihelp-param|query+links|limit}}",
+ "apihelp-query+links-param-titles": "{{doc-apihelp-param|query+links|titles}}",
+ "apihelp-query+links-param-dir": "{{doc-apihelp-param|query+links|dir}}",
+ "apihelp-query+links-example-simple": "{{doc-apihelp-example|query+links}}",
+ "apihelp-query+links-example-generator": "{{doc-apihelp-example|query+links}}",
+ "apihelp-query+links-example-namespaces": "{{doc-apihelp-example|query+links}}",
+ "apihelp-query+linkshere-summary": "{{doc-apihelp-summary|query+linkshere}}",
+ "apihelp-query+linkshere-param-prop": "{{doc-apihelp-param|query+linkshere|prop|paramvalues=1}}",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "{{doc-apihelp-paramvalue|query+linkshere|prop|pageid}}",
+ "apihelp-query+linkshere-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+linkshere|prop|title}}",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "{{doc-apihelp-paramvalue|query+linkshere|prop|redirect}}",
+ "apihelp-query+linkshere-param-namespace": "{{doc-apihelp-param|query+linkshere|namespace}}",
+ "apihelp-query+linkshere-param-limit": "{{doc-apihelp-param|query+linkshere|limit}}",
+ "apihelp-query+linkshere-param-show": "{{doc-apihelp-param|query+linkshere|show}}",
+ "apihelp-query+linkshere-example-simple": "{{doc-apihelp-example|query+linkshere}}",
+ "apihelp-query+linkshere-example-generator": "{{doc-apihelp-example|query+linkshere}}",
+ "apihelp-query+logevents-summary": "{{doc-apihelp-summary|query+logevents}}",
+ "apihelp-query+logevents-param-prop": "{{doc-apihelp-param|query+logevents|prop|paramvalues=1}}",
+ "apihelp-query+logevents-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+logevents|prop|ids}}",
+ "apihelp-query+logevents-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+logevents|prop|title}}",
+ "apihelp-query+logevents-paramvalue-prop-type": "{{doc-apihelp-paramvalue|query+logevents|prop|type}}",
+ "apihelp-query+logevents-paramvalue-prop-user": "{{doc-apihelp-paramvalue|query+logevents|prop|user}}",
+ "apihelp-query+logevents-paramvalue-prop-userid": "{{doc-apihelp-paramvalue|query+logevents|prop|userid}}",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+logevents|prop|timestamp}}",
+ "apihelp-query+logevents-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|query+logevents|prop|comment}}",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|query+logevents|prop|parsedcomment}}",
+ "apihelp-query+logevents-paramvalue-prop-details": "{{doc-apihelp-paramvalue|query+logevents|prop|details}}",
+ "apihelp-query+logevents-paramvalue-prop-tags": "{{doc-apihelp-paramvalue|query+logevents|prop|tags}}",
+ "apihelp-query+logevents-param-type": "{{doc-apihelp-param|query+logevents|type}}",
+ "apihelp-query+logevents-param-action": "{{doc-apihelp-param|query+logevents|action}}",
+ "apihelp-query+logevents-param-start": "{{doc-apihelp-param|query+logevents|start}}",
+ "apihelp-query+logevents-param-end": "{{doc-apihelp-param|query+logevents|end}}",
+ "apihelp-query+logevents-param-user": "{{doc-apihelp-param|query+logevents|user}}",
+ "apihelp-query+logevents-param-title": "{{doc-apihelp-param|query+logevents|title}}",
+ "apihelp-query+logevents-param-namespace": "{{doc-apihelp-param|query+logevents|namespace}}",
+ "apihelp-query+logevents-param-prefix": "{{doc-apihelp-param|query+logevents|prefix}}",
+ "apihelp-query+logevents-param-tag": "{{doc-apihelp-param|query+logevents|tag}}",
+ "apihelp-query+logevents-param-limit": "{{doc-apihelp-param|query+logevents|limit}}",
+ "apihelp-query+logevents-example-simple": "{{doc-apihelp-example|query+logevents}}",
+ "apihelp-query+pagepropnames-summary": "{{doc-apihelp-summary|query+pagepropnames}}",
+ "apihelp-query+pagepropnames-param-limit": "{{doc-apihelp-param|query+pagepropnames|limit}}",
+ "apihelp-query+pagepropnames-example-simple": "{{doc-apihelp-example|query+pagepropnames}}",
+ "apihelp-query+pageprops-summary": "{{doc-apihelp-summary|query+pageprops}}",
+ "apihelp-query+pageprops-param-prop": "{{doc-apihelp-param|query+pageprops|prop}}",
+ "apihelp-query+pageprops-example-simple": "{{doc-apihelp-example|query+pageprops}}",
+ "apihelp-query+pageswithprop-summary": "{{doc-apihelp-summary|query+pageswithprop}}",
+ "apihelp-query+pageswithprop-param-propname": "{{doc-apihelp-param|query+pageswithprop|propname}}",
+ "apihelp-query+pageswithprop-param-prop": "{{doc-apihelp-param|query+pageswithprop|prop|paramvalues=1}}",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+pageswithprop|prop|ids}}",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+pageswithprop|prop|title}}",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "{{doc-apihelp-paramvalue|query+pageswithprop|prop|value}}",
+ "apihelp-query+pageswithprop-param-limit": "{{doc-apihelp-param|query+pageswithprop|limit}}",
+ "apihelp-query+pageswithprop-param-dir": "{{doc-apihelp-param|query+pageswithprop|dir}}",
+ "apihelp-query+pageswithprop-example-simple": "{{doc-apihelp-example|query+pageswithprop}}",
+ "apihelp-query+pageswithprop-example-generator": "{{doc-apihelp-example|query+pageswithprop}}",
+ "apihelp-query+prefixsearch-summary": "{{doc-apihelp-summary|query+prefixsearch}}",
+ "apihelp-query+prefixsearch-extended-description": "{{doc-apihelp-extended-description|query+prefixsearch}}",
+ "apihelp-query+prefixsearch-param-search": "{{doc-apihelp-param|query+prefixsearch|search}}",
+ "apihelp-query+prefixsearch-param-namespace": "{{doc-apihelp-param|query+prefixsearch|namespace}}",
+ "apihelp-query+prefixsearch-param-limit": "{{doc-apihelp-param|query+prefixsearch|limit}}",
+ "apihelp-query+prefixsearch-param-offset": "{{doc-apihelp-param|query+prefixsearch|offset}}",
+ "apihelp-query+prefixsearch-example-simple": "{{doc-apihelp-example|query+prefixsearch}}",
+ "apihelp-query+prefixsearch-param-profile": "{{doc-apihelp-param|query+prefixsearch|profile|paramvalues=1}}",
+ "apihelp-query+protectedtitles-summary": "{{doc-apihelp-summary|query+protectedtitles}}",
+ "apihelp-query+protectedtitles-param-namespace": "{{doc-apihelp-param|query+protectedtitles|namespace}}",
+ "apihelp-query+protectedtitles-param-level": "{{doc-apihelp-param|query+protectedtitles|level}}",
+ "apihelp-query+protectedtitles-param-limit": "{{doc-apihelp-param|query+protectedtitles|limit}}",
+ "apihelp-query+protectedtitles-param-start": "{{doc-apihelp-param|query+protectedtitles|start}}",
+ "apihelp-query+protectedtitles-param-end": "{{doc-apihelp-param|query+protectedtitles|end}}",
+ "apihelp-query+protectedtitles-param-prop": "{{doc-apihelp-param|query+protectedtitles|prop|paramvalues=1}}",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+protectedtitles|prop|timestamp}}",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "{{doc-apihelp-paramvalue|query+protectedtitles|prop|user}}",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "{{doc-apihelp-paramvalue|query+protectedtitles|prop|userid}}",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|query+protectedtitles|prop|comment}}",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|query+protectedtitles|prop|parsedcomment}}",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "{{doc-apihelp-paramvalue|query+protectedtitles|prop|expiry}}",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "{{doc-apihelp-paramvalue|query+protectedtitles|prop|level}}",
+ "apihelp-query+protectedtitles-example-simple": "{{doc-apihelp-example|query+protectedtitles}}",
+ "apihelp-query+protectedtitles-example-generator": "{{doc-apihelp-example|query+protectedtitles}}",
+ "apihelp-query+querypage-summary": "{{doc-apihelp-summary|query+querypage}}",
+ "apihelp-query+querypage-param-page": "{{doc-apihelp-param|query+querypage|page}}",
+ "apihelp-query+querypage-param-limit": "{{doc-apihelp-param|query+querypage|limit}}",
+ "apihelp-query+querypage-example-ancientpages": "{{doc-apihelp-example|query+querypage}}",
+ "apihelp-query+random-summary": "{{doc-apihelp-summary|query+random}}",
+ "apihelp-query+random-extended-description": "{{doc-apihelp-extended-description|query+random}}",
+ "apihelp-query+random-param-namespace": "{{doc-apihelp-param|query+random|namespace}}",
+ "apihelp-query+random-param-limit": "{{doc-apihelp-param|query+random|limit}}",
+ "apihelp-query+random-param-redirect": "{{doc-apihelp-param|query+random|redirect}}",
+ "apihelp-query+random-param-filterredir": "{{doc-apihelp-param|query+random|filterredir}}",
+ "apihelp-query+random-example-simple": "{{doc-apihelp-example|query+random}}",
+ "apihelp-query+random-example-generator": "{{doc-apihelp-example|query+random}}",
+ "apihelp-query+recentchanges-summary": "{{doc-apihelp-summary|query+recentchanges}}",
+ "apihelp-query+recentchanges-param-start": "{{doc-apihelp-param|query+recentchanges|start}}",
+ "apihelp-query+recentchanges-param-end": "{{doc-apihelp-param|query+recentchanges|end}}",
+ "apihelp-query+recentchanges-param-namespace": "{{doc-apihelp-param|query+recentchanges|namespace}}",
+ "apihelp-query+recentchanges-param-user": "{{doc-apihelp-param|query+recentchanges|user}}",
+ "apihelp-query+recentchanges-param-excludeuser": "{{doc-apihelp-param|query+recentchanges|excludeuser}}",
+ "apihelp-query+recentchanges-param-tag": "{{doc-apihelp-param|query+recentchanges|tag}}",
+ "apihelp-query+recentchanges-param-prop": "{{doc-apihelp-param|query+recentchanges|prop|paramvalues=1}}",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "{{doc-apihelp-paramvalue|query+recentchanges|prop|user}}",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "{{doc-apihelp-paramvalue|query+recentchanges|prop|userid}}",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|query+recentchanges|prop|comment}}",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|query+recentchanges|prop|parsedcomment}}",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "{{doc-apihelp-paramvalue|query+recentchanges|prop|flags}}",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+recentchanges|prop|timestamp}}",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+recentchanges|prop|title}}",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+recentchanges|prop|ids}}",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "{{doc-apihelp-paramvalue|query+recentchanges|prop|sizes}}",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "{{doc-apihelp-paramvalue|query+recentchanges|prop|redirect}}",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "{{doc-apihelp-paramvalue|query+recentchanges|prop|patrolled}}",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "{{doc-apihelp-paramvalue|query+recentchanges|prop|loginfo}}",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "{{doc-apihelp-paramvalue|query+recentchanges|prop|tags}}",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "{{doc-apihelp-paramvalue|query+recentchanges|prop|sha1}}",
+ "apihelp-query+recentchanges-param-token": "{{doc-apihelp-param|query+recentchanges|token}}",
+ "apihelp-query+recentchanges-param-show": "{{doc-apihelp-param|query+recentchanges|show}}",
+ "apihelp-query+recentchanges-param-limit": "{{doc-apihelp-param|query+recentchanges|limit}}",
+ "apihelp-query+recentchanges-param-type": "{{doc-apihelp-param|query+recentchanges|type}}",
+ "apihelp-query+recentchanges-param-toponly": "{{doc-apihelp-param|query+recentchanges|toponly}}",
+ "apihelp-query+recentchanges-param-generaterevisions": "{{doc-apihelp-param|query+recentchanges|generaterevisions}}",
+ "apihelp-query+recentchanges-example-simple": "{{doc-apihelp-example|query+recentchanges}}",
+ "apihelp-query+recentchanges-example-generator": "{{doc-apihelp-example|query+recentchanges}}",
+ "apihelp-query+redirects-summary": "{{doc-apihelp-summary|query+redirects}}",
+ "apihelp-query+redirects-param-prop": "{{doc-apihelp-param|query+redirects|prop|paramvalues=1}}",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "{{doc-apihelp-paramvalue|query+redirects|prop|pageid}}",
+ "apihelp-query+redirects-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+redirects|prop|title}}",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "{{doc-apihelp-paramvalue|query+redirects|prop|fragment}}",
+ "apihelp-query+redirects-param-namespace": "{{doc-apihelp-param|query+redirects|namespace}}",
+ "apihelp-query+redirects-param-limit": "{{doc-apihelp-param|query+redirects|limit}}",
+ "apihelp-query+redirects-param-show": "{{doc-apihelp-param|query+redirects|show}}",
+ "apihelp-query+redirects-example-simple": "{{doc-apihelp-example|query+redirects}}",
+ "apihelp-query+redirects-example-generator": "{{doc-apihelp-example|query+redirects}}",
+ "apihelp-query+revisions-summary": "{{doc-apihelp-summary|query+revisions}}",
+ "apihelp-query+revisions-extended-description": "{{doc-apihelp-extended-description|query+revisions}}",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "{{doc-apihelp-paraminfo|query+revisions|singlepageonly}}",
+ "apihelp-query+revisions-param-startid": "{{doc-apihelp-param|query+revisions|startid}}",
+ "apihelp-query+revisions-param-endid": "{{doc-apihelp-param|query+revisions|endid}}",
+ "apihelp-query+revisions-param-start": "{{doc-apihelp-param|query+revisions|start}}",
+ "apihelp-query+revisions-param-end": "{{doc-apihelp-param|query+revisions|end}}",
+ "apihelp-query+revisions-param-user": "{{doc-apihelp-param|query+revisions|user}}",
+ "apihelp-query+revisions-param-excludeuser": "{{doc-apihelp-param|query+revisions|excludeuser}}",
+ "apihelp-query+revisions-param-tag": "{{doc-apihelp-param|query+revisions|tag}}",
+ "apihelp-query+revisions-param-token": "{{doc-apihelp-param|query+revisions|token}}",
+ "apihelp-query+revisions-example-content": "{{doc-apihelp-example|query+revisions}}",
+ "apihelp-query+revisions-example-last5": "{{doc-apihelp-example|query+revisions}}",
+ "apihelp-query+revisions-example-first5": "{{doc-apihelp-example|query+revisions}}",
+ "apihelp-query+revisions-example-first5-after": "{{doc-apihelp-example|query+revisions}}",
+ "apihelp-query+revisions-example-first5-not-localhost": "{{doc-apihelp-example|query+revisions}}",
+ "apihelp-query+revisions-example-first5-user": "{{doc-apihelp-example|query+revisions}}",
+ "apihelp-query+revisions+base-param-prop": "{{doc-apihelp-param|query+revisions+base|prop|description=the \"prop\" parameter to revision querying modules|noseealso=1|paramvalues=1}}",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+revisions+base|prop|ids}}",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "{{doc-apihelp-paramvalue|query+revisions+base|prop|flags}}",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+revisions+base|prop|timestamp}}",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "{{doc-apihelp-paramvalue|query+revisions+base|prop|user}}",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "{{doc-apihelp-paramvalue|query+revisions+base|prop|userid}}",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "{{doc-apihelp-paramvalue|query+revisions+base|prop|size}}",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "{{doc-apihelp-paramvalue|query+revisions+base|prop|sha1}}",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "{{doc-apihelp-paramvalue|query+revisions+base|prop|contentmodel}}",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|query+revisions+base|prop|comment}}",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|query+revisions+base|prop|parsedcomment}}",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "{{doc-apihelp-paramvalue|query+revisions+base|prop|content}}",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "{{doc-apihelp-paramvalue|query+revisions+base|prop|tags}}",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "{{doc-apihelp-paramvalue|query+revisions+base|prop|parsetree|params=* $1 - Value of the constant CONTENT_MODEL_WIKITEXT|paramstart=2}}",
+ "apihelp-query+revisions+base-param-limit": "{{doc-apihelp-param|query+revisions+base|limit|description=the \"limit\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+revisions+base-param-expandtemplates": "{{doc-apihelp-param|query+revisions+base|expandtemplates|description=the \"expandtemplates\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+revisions+base-param-generatexml": "{{doc-apihelp-param|query+revisions+base|generatexml|description=the \"generatexml\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+revisions+base-param-parse": "{{doc-apihelp-param|query+revisions+base|parse|description=the \"parse\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+revisions+base-param-section": "{{doc-apihelp-param|query+revisions+base|section|description=the \"section\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+revisions+base-param-diffto": "{{doc-apihelp-param|query+revisions+base|diffto|description=the \"diffto\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+revisions+base-param-difftotext": "{{doc-apihelp-param|query+revisions+base|difftotext|description=the \"difftotext\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+revisions+base-param-difftotextpst": "{{doc-apihelp-param|query+revisions+base|difftotextpst|description=the \"difftotextpst\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+revisions+base-param-contentformat": "{{doc-apihelp-param|query+revisions+base|contentformat|description=the \"contentformat\" parameter to revision querying modules|noseealso=1}}",
+ "apihelp-query+search-summary": "{{doc-apihelp-summary|query+search}}",
+ "apihelp-query+search-param-search": "{{doc-apihelp-param|query+search|search}}",
+ "apihelp-query+search-param-namespace": "{{doc-apihelp-param|query+search|namespace}}",
+ "apihelp-query+search-param-what": "{{doc-apihelp-param|query+search|what}}",
+ "apihelp-query+search-param-info": "{{doc-apihelp-param|query+search|info}}",
+ "apihelp-query+search-param-prop": "{{doc-apihelp-param|query+search|prop|paramvalues=1}}",
+ "apihelp-query+search-param-qiprofile": "{{doc-apihelp-param|query+search|qiprofile|paramvalues=1}}",
+ "apihelp-query+search-paramvalue-prop-size": "{{doc-apihelp-paramvalue|query+search|prop|size}}",
+ "apihelp-query+search-paramvalue-prop-wordcount": "{{doc-apihelp-paramvalue|query+search|prop|wordcount}}",
+ "apihelp-query+search-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+search|prop|timestamp}}",
+ "apihelp-query+search-paramvalue-prop-snippet": "{{doc-apihelp-paramvalue|query+search|prop|snippet}}",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "{{doc-apihelp-paramvalue|query+search|prop|titlesnippet}}",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "{{doc-apihelp-paramvalue|query+search|prop|redirectsnippet}}",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "{{doc-apihelp-paramvalue|query+search|prop|redirecttitle}}",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "{{doc-apihelp-paramvalue|query+search|prop|sectionsnippet}}",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "{{doc-apihelp-paramvalue|query+search|prop|sectiontitle}}",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "{{doc-apihelp-paramvalue|query+search|prop|categorysnippet}}",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "{{doc-apihelp-paramvalue|query+search|prop|isfilematch}}",
+ "apihelp-query+search-paramvalue-prop-score": "{{doc-apihelp-paramvalue|query+search|prop|score}}\n{{Identical|Ignored}}",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "{{doc-apihelp-paramvalue|query+search|prop|hasrelated}}\n{{Identical|Ignored}}",
+ "apihelp-query+search-param-limit": "{{doc-apihelp-param|query+search|limit}}",
+ "apihelp-query+search-param-interwiki": "{{doc-apihelp-param|query+search|interwiki}}",
+ "apihelp-query+search-param-backend": "{{doc-apihelp-param|query+search|backend}}",
+ "apihelp-query+search-param-enablerewrites": "{{doc-apihelp-param|query+search|enablerewrites}}",
+ "apihelp-query+search-example-simple": "{{doc-apihelp-example|query+search}}",
+ "apihelp-query+search-example-text": "{{doc-apihelp-example|query+search}}",
+ "apihelp-query+search-example-generator": "{{doc-apihelp-example|query+search}}",
+ "apihelp-query+siteinfo-summary": "{{doc-apihelp-summary|query+siteinfo}}",
+ "apihelp-query+siteinfo-param-prop": "{{doc-apihelp-param|query+siteinfo|prop|paramvalues=1}}",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "{{doc-apihelp-paramvalue|query+siteinfo|prop|general}}",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "{{doc-apihelp-paramvalue|query+siteinfo|prop|namespaces}}",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "{{doc-apihelp-paramvalue|query+siteinfo|prop|namespacealiases}}",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "{{doc-apihelp-paramvalue|query+siteinfo|prop|specialpagealiases}}",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "{{doc-apihelp-paramvalue|query+siteinfo|prop|magicwords}}",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "{{doc-apihelp-paramvalue|query+siteinfo|prop|statistics}}",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "{{doc-apihelp-paramvalue|query+siteinfo|prop|interwikimap}}",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "{{doc-apihelp-paramvalue|query+siteinfo|prop|dbrepllag}}",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "{{doc-apihelp-paramvalue|query+siteinfo|prop|usergroups}}",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "{{doc-apihelp-paramvalue|query+siteinfo|prop|libraries}}",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "{{doc-apihelp-paramvalue|query+siteinfo|prop|extensions}}",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "{{doc-apihelp-paramvalue|query+siteinfo|prop|fileextensions}}",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "{{doc-apihelp-paramvalue|query+siteinfo|prop|rightsinfo}}",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "{{doc-apihelp-paramvalue|query+siteinfo|prop|restrictions}}",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "{{doc-apihelp-paramvalue|query+siteinfo|prop|languages}}",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "{{doc-apihelp-paramvalue|query+siteinfo|prop|languagevariants}}",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "{{doc-apihelp-paramvalue|query+siteinfo|prop|skins}}",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "{{doc-apihelp-paramvalue|query+siteinfo|prop|extensiontags}}",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "{{doc-apihelp-paramvalue|query+siteinfo|prop|functionhooks}}",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "{{doc-apihelp-paramvalue|query+siteinfo|prop|showhooks}}",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "{{doc-apihelp-paramvalue|query+siteinfo|prop|variables}}",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "{{doc-apihelp-paramvalue|query+siteinfo|prop|protocols}}",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "{{doc-apihelp-paramvalue|query+siteinfo|prop|defaultoptions}}",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "{{doc-apihelp-paramvalue|query+siteinfo|prop|uploaddialog}}",
+ "apihelp-query+siteinfo-param-filteriw": "{{doc-apihelp-param|query+siteinfo|filteriw}}",
+ "apihelp-query+siteinfo-param-showalldb": "{{doc-apihelp-param|query+siteinfo|showalldb}}",
+ "apihelp-query+siteinfo-param-numberingroup": "{{doc-apihelp-param|query+siteinfo|numberingroup}}",
+ "apihelp-query+siteinfo-param-inlanguagecode": "{{doc-apihelp-param|query+siteinfo|inlanguagecode}}",
+ "apihelp-query+siteinfo-example-simple": "{{doc-apihelp-example|query+siteinfo}}",
+ "apihelp-query+siteinfo-example-interwiki": "{{doc-apihelp-example|query+siteinfo}}",
+ "apihelp-query+siteinfo-example-replag": "{{doc-apihelp-example|query+siteinfo}}",
+ "apihelp-query+stashimageinfo-summary": "{{doc-apihelp-summary|query+stashimageinfo}}",
+ "apihelp-query+stashimageinfo-param-filekey": "{{doc-apihelp-param|query+stashimageinfo|filekey}}",
+ "apihelp-query+stashimageinfo-param-sessionkey": "{{doc-apihelp-param|query+stashimageinfo|sessionkey}}",
+ "apihelp-query+stashimageinfo-example-simple": "{{doc-apihelp-example|query+stashimageinfo}}",
+ "apihelp-query+stashimageinfo-example-params": "{{doc-apihelp-example|query+stashimageinfo}}",
+ "apihelp-query+tags-summary": "{{doc-apihelp-summary|query+tags}}",
+ "apihelp-query+tags-param-limit": "{{doc-apihelp-param|query+tags|limit}}",
+ "apihelp-query+tags-param-prop": "{{doc-apihelp-param|query+tags|prop|paramvalues=1}}",
+ "apihelp-query+tags-paramvalue-prop-name": "{{doc-apihelp-paramvalue|query+tags|prop|name}}",
+ "apihelp-query+tags-paramvalue-prop-displayname": "{{doc-apihelp-paramvalue|query+tags|prop|displayname}}",
+ "apihelp-query+tags-paramvalue-prop-description": "{{doc-apihelp-paramvalue|query+tags|prop|description}}",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "{{doc-apihelp-paramvalue|query+tags|prop|hitcount}}",
+ "apihelp-query+tags-paramvalue-prop-defined": "{{doc-apihelp-paramvalue|query+tags|prop|defined}}",
+ "apihelp-query+tags-paramvalue-prop-source": "{{doc-apihelp-paramvalue|query+tags|prop|source}}",
+ "apihelp-query+tags-paramvalue-prop-active": "{{doc-apihelp-paramvalue|query+tags|prop|active}}",
+ "apihelp-query+tags-example-simple": "{{doc-apihelp-example|query+tags}}",
+ "apihelp-query+templates-summary": "{{doc-apihelp-summary|query+templates}}",
+ "apihelp-query+templates-param-namespace": "{{doc-apihelp-param|query+templates|namespace}}",
+ "apihelp-query+templates-param-limit": "{{doc-apihelp-param|query+templates|limit}}",
+ "apihelp-query+templates-param-templates": "{{doc-apihelp-param|query+templates|templates}}",
+ "apihelp-query+templates-param-dir": "{{doc-apihelp-param|query+templates|dir}}",
+ "apihelp-query+templates-example-simple": "{{doc-apihelp-example|query+templates}}",
+ "apihelp-query+templates-example-generator": "{{doc-apihelp-example|query+templates}}",
+ "apihelp-query+templates-example-namespaces": "{{doc-apihelp-example|query+templates}}",
+ "apihelp-query+tokens-summary": "{{doc-apihelp-summary|query+tokens}}",
+ "apihelp-query+tokens-param-type": "{{doc-apihelp-param|query+tokens|type}}",
+ "apihelp-query+tokens-example-simple": "{{doc-apihelp-example|query+tokens}}",
+ "apihelp-query+tokens-example-types": "{{doc-apihelp-example|query+tokens}}",
+ "apihelp-query+transcludedin-summary": "{{doc-apihelp-summary|query+transcludedin}}",
+ "apihelp-query+transcludedin-param-prop": "{{doc-apihelp-param|query+transcludedin|prop|paramvalues=1}}",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "{{doc-apihelp-paramvalue|query+transcludedin|prop|pageid}}",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+transcludedin|prop|title}}",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "{{doc-apihelp-paramvalue|query+transcludedin|prop|redirect}}",
+ "apihelp-query+transcludedin-param-namespace": "{{doc-apihelp-param|query+transcludedin|namespace}}",
+ "apihelp-query+transcludedin-param-limit": "{{doc-apihelp-param|query+transcludedin|limit}}",
+ "apihelp-query+transcludedin-param-show": "{{doc-apihelp-param|query+transcludedin|show}}",
+ "apihelp-query+transcludedin-example-simple": "{{doc-apihelp-example|query+transcludedin}}",
+ "apihelp-query+transcludedin-example-generator": "{{doc-apihelp-example|query+transcludedin}}",
+ "apihelp-query+usercontribs-summary": "{{doc-apihelp-summary|query+usercontribs}}",
+ "apihelp-query+usercontribs-param-limit": "{{doc-apihelp-param|query+usercontribs|limit}}",
+ "apihelp-query+usercontribs-param-start": "{{doc-apihelp-param|query+usercontribs|start}}",
+ "apihelp-query+usercontribs-param-end": "{{doc-apihelp-param|query+usercontribs|end}}",
+ "apihelp-query+usercontribs-param-user": "{{doc-apihelp-param|query+usercontribs|user}}",
+ "apihelp-query+usercontribs-param-userprefix": "{{doc-apihelp-param|query+usercontribs|userprefix}}",
+ "apihelp-query+usercontribs-param-userids": "{{doc-apihelp-param|query+usercontribs|userids}}",
+ "apihelp-query+usercontribs-param-namespace": "{{doc-apihelp-param|query+usercontribs|namespace}}",
+ "apihelp-query+usercontribs-param-prop": "{{doc-apihelp-param|query+usercontribs|prop|paramvalues=1}}",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+usercontribs|prop|ids}}",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+usercontribs|prop|title}}",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+usercontribs|prop|timestamp}}",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|query+usercontribs|prop|comment}}",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|query+usercontribs|prop|parsedcomment}}",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "{{doc-apihelp-paramvalue|query+usercontribs|prop|size}}",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "{{doc-apihelp-paramvalue|query+usercontribs|prop|sizediff}}",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "{{doc-apihelp-paramvalue|query+usercontribs|prop|flags}}",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "{{doc-apihelp-paramvalue|query+usercontribs|prop|patrolled}}",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "{{doc-apihelp-paramvalue|query+usercontribs|prop|tags}}",
+ "apihelp-query+usercontribs-param-show": "{{doc-apihelp-param|query+usercontribs|show|params=* $1 - Value of [[mw:Manual:$RCMaxAge|$RCMaxAge]]|paramstart=2}}",
+ "apihelp-query+usercontribs-param-tag": "{{doc-apihelp-param|query+usercontribs|tag}}",
+ "apihelp-query+usercontribs-param-toponly": "{{doc-apihelp-param|query+usercontribs|toponly}}",
+ "apihelp-query+usercontribs-example-user": "{{doc-apihelp-example|query+usercontribs}}",
+ "apihelp-query+usercontribs-example-ipprefix": "{{doc-apihelp-example|query+usercontribs}}",
+ "apihelp-query+userinfo-summary": "{{doc-apihelp-summary|query+userinfo}}",
+ "apihelp-query+userinfo-param-prop": "{{doc-apihelp-param|query+userinfo|prop|paramvalues=1}}",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "{{doc-apihelp-paramvalue|query+userinfo|prop|blockinfo}}",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "{{doc-apihelp-paramvalue|query+userinfo|prop|hasmsg}}",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "{{doc-apihelp-paramvalue|query+userinfo|prop|groups}}",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "{{doc-apihelp-paramvalue|query+userinfo|prop|groupmemberships}}",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "{{doc-apihelp-paramvalue|query+userinfo|prop|implicitgroups}}",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "{{doc-apihelp-paramvalue|query+userinfo|prop|rights}}",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "{{doc-apihelp-paramvalue|query+userinfo|prop|changeablegroups}}",
+ "apihelp-query+userinfo-paramvalue-prop-options": "{{doc-apihelp-paramvalue|query+userinfo|prop|options}}",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "{{doc-apihelp-paramvalue|query+userinfo|prop|preferencestoken}}\n{{doc-important|You can translate the word \"Deprecated\", but please do not alter the <code><nowiki>class=\"apihelp-deprecated\"</nowiki></code> attribute}}",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "{{doc-apihelp-paramvalue|query+userinfo|prop|editcount}}",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "{{doc-apihelp-paramvalue|query+userinfo|prop|ratelimits}}",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "{{doc-apihelp-paramvalue|query+userinfo|prop|realname}}",
+ "apihelp-query+userinfo-paramvalue-prop-email": "{{doc-apihelp-paramvalue|query+userinfo|prop|email}}",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "{{doc-apihelp-paramvalue|query+userinfo|prop|acceptlang}}",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "{{doc-apihelp-paramvalue|query+userinfo|prop|registrationdate}}",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "{{doc-apihelp-paramvalue|query+userinfo|prop|unreadcount|params=* $1 - Maximum value for the \"unreadcount\" property.\n* $2 - Return value when there are more unread pages.|paramstart=3}}",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "{{doc-apihelp-paramvalue|query+userinfo|prop|centralids}}",
+ "apihelp-query+userinfo-param-attachedwiki": "{{doc-apihelp-param|query+userinfo|attachedwiki}}",
+ "apihelp-query+userinfo-example-simple": "{{doc-apihelp-example|query+userinfo}}",
+ "apihelp-query+userinfo-example-data": "{{doc-apihelp-example|query+userinfo}}",
+ "apihelp-query+users-summary": "{{doc-apihelp-summary|query+users}}",
+ "apihelp-query+users-param-prop": "{{doc-apihelp-param|query+users|prop|paramvalues=1}}",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "{{doc-apihelp-paramvalue|query+users|prop|blockinfo}}",
+ "apihelp-query+users-paramvalue-prop-groups": "{{doc-apihelp-paramvalue|query+users|prop|groups}}",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "{{doc-apihelp-paramvalue|query+users|prop|groupmemberships}}",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "{{doc-apihelp-paramvalue|query+users|prop|implicitgroups}}",
+ "apihelp-query+users-paramvalue-prop-rights": "{{doc-apihelp-paramvalue|query+users|prop|rights}}",
+ "apihelp-query+users-paramvalue-prop-editcount": "{{doc-apihelp-paramvalue|query+users|prop|editcount}}",
+ "apihelp-query+users-paramvalue-prop-registration": "{{doc-apihelp-paramvalue|query+users|prop|registration}}",
+ "apihelp-query+users-paramvalue-prop-emailable": "{{doc-apihelp-paramvalue|query+users|prop|emailable}}",
+ "apihelp-query+users-paramvalue-prop-gender": "{{doc-apihelp-paramvalue|query+users|prop|gender}}",
+ "apihelp-query+users-paramvalue-prop-centralids": "{{doc-apihelp-paramvalue|query+users|prop|centralids}}",
+ "apihelp-query+users-paramvalue-prop-cancreate": "{{doc-apihelp-paramvalue|query+users|prop|cancreate}}",
+ "apihelp-query+users-param-attachedwiki": "{{doc-apihelp-param|query+users|attachedwiki}}",
+ "apihelp-query+users-param-users": "{{doc-apihelp-param|query+users|users}}",
+ "apihelp-query+users-param-userids": "{{doc-apihelp-param|query+users|userids}}",
+ "apihelp-query+users-param-token": "{{doc-apihelp-param|query+users|token}}",
+ "apihelp-query+users-example-simple": "{{doc-apihelp-example|query+users}}",
+ "apihelp-query+watchlist-summary": "{{doc-apihelp-summary|query+watchlist}}",
+ "apihelp-query+watchlist-param-allrev": "{{doc-apihelp-param|query+watchlist|allrev}}",
+ "apihelp-query+watchlist-param-start": "{{doc-apihelp-param|query+watchlist|start}}",
+ "apihelp-query+watchlist-param-end": "{{doc-apihelp-param|query+watchlist|end}}",
+ "apihelp-query+watchlist-param-namespace": "{{doc-apihelp-param|query+watchlist|namespace}}",
+ "apihelp-query+watchlist-param-user": "{{doc-apihelp-param|query+watchlist|user}}",
+ "apihelp-query+watchlist-param-excludeuser": "{{doc-apihelp-param|query+watchlist|excludeuser}}",
+ "apihelp-query+watchlist-param-limit": "{{doc-apihelp-param|query+watchlist|limit}}",
+ "apihelp-query+watchlist-param-prop": "{{doc-apihelp-param|query+watchlist|prop|paramvalues=1}}",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|query+watchlist|prop|ids}}",
+ "apihelp-query+watchlist-paramvalue-prop-title": "{{doc-apihelp-paramvalue|query+watchlist|prop|title}}",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "{{doc-apihelp-paramvalue|query+watchlist|prop|flags}}",
+ "apihelp-query+watchlist-paramvalue-prop-user": "{{doc-apihelp-paramvalue|query+watchlist|prop|user}}",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "{{doc-apihelp-paramvalue|query+watchlist|prop|userid}}",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|query+watchlist|prop|comment}}",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|query+watchlist|prop|parsedcomment}}",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "{{doc-apihelp-paramvalue|query+watchlist|prop|timestamp}}",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "{{doc-apihelp-paramvalue|query+watchlist|prop|patrol}}",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "{{doc-apihelp-paramvalue|query+watchlist|prop|sizes}}",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "{{doc-apihelp-paramvalue|query+watchlist|prop|notificationtimestamp}}",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "{{doc-apihelp-paramvalue|query+watchlist|prop|loginfo}}",
+ "apihelp-query+watchlist-param-show": "{{doc-apihelp-param|query+watchlist|show}}",
+ "apihelp-query+watchlist-param-type": "{{doc-apihelp-param|query+watchlist|type}}",
+ "apihelp-query+watchlist-paramvalue-type-edit": "{{doc-apihelp-paramvalue|query+watchlist|type|edit}}",
+ "apihelp-query+watchlist-paramvalue-type-external": "{{doc-apihelp-paramvalue|query+watchlist|type|external}}",
+ "apihelp-query+watchlist-paramvalue-type-new": "{{doc-apihelp-paramvalue|query+watchlist|type|new}}",
+ "apihelp-query+watchlist-paramvalue-type-log": "{{doc-apihelp-paramvalue|query+watchlist|type|log}}",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "{{doc-apihelp-paramvalue|query+watchlist|type|categorize}}",
+ "apihelp-query+watchlist-param-owner": "{{doc-apihelp-param|query+watchlist|owner}}",
+ "apihelp-query+watchlist-param-token": "{{doc-apihelp-param|query+watchlist|token}}",
+ "apihelp-query+watchlist-example-simple": "{{doc-apihelp-example|query+watchlist}}",
+ "apihelp-query+watchlist-example-props": "{{doc-apihelp-example|query+watchlist}}",
+ "apihelp-query+watchlist-example-allrev": "{{doc-apihelp-example|query+watchlist}}",
+ "apihelp-query+watchlist-example-generator": "{{doc-apihelp-example|query+watchlist}}",
+ "apihelp-query+watchlist-example-generator-rev": "{{doc-apihelp-example|query+watchlist}}",
+ "apihelp-query+watchlist-example-wlowner": "{{doc-apihelp-example|query+watchlist}}",
+ "apihelp-query+watchlistraw-summary": "{{doc-apihelp-summary|query+watchlistraw}}",
+ "apihelp-query+watchlistraw-param-namespace": "{{doc-apihelp-param|query+watchlistraw|namespace}}",
+ "apihelp-query+watchlistraw-param-limit": "{{doc-apihelp-param|query+watchlistraw|limit}}",
+ "apihelp-query+watchlistraw-param-prop": "{{doc-apihelp-param|query+watchlistraw|prop|paramvalues=1}}",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "{{doc-apihelp-paramvalue|query+watchlistraw|prop|changed}}",
+ "apihelp-query+watchlistraw-param-show": "{{doc-apihelp-param|query+watchlistraw|show}}",
+ "apihelp-query+watchlistraw-param-owner": "{{doc-apihelp-param|query+watchlistraw|owner}}",
+ "apihelp-query+watchlistraw-param-token": "{{doc-apihelp-param|query+watchlistraw|token}}",
+ "apihelp-query+watchlistraw-param-dir": "{{doc-apihelp-param|query+watchlistraw|dir}}",
+ "apihelp-query+watchlistraw-param-fromtitle": "{{doc-apihelp-param|query+watchlistraw|fromtitle}}",
+ "apihelp-query+watchlistraw-param-totitle": "{{doc-apihelp-param|query+watchlistraw|totitle}}",
+ "apihelp-query+watchlistraw-example-simple": "{{doc-apihelp-example|query+watchlistraw}}",
+ "apihelp-query+watchlistraw-example-generator": "{{doc-apihelp-example|query+watchlistraw}}",
+ "apihelp-removeauthenticationdata-summary": "{{doc-apihelp-summary|removeauthenticationdata}}",
+ "apihelp-removeauthenticationdata-example-simple": "{{doc-apihelp-example|removeauthenticationdata}}",
+ "apihelp-resetpassword-summary": "{{doc-apihelp-summary|resetpassword}}",
+ "apihelp-resetpassword-extended-description-noroutes": "{{doc-apihelp-extended-description|resetpassword|info=This message is used when no known routes are enabled in <var>[[mw:Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var>.|seealso={{msg-mw|apihelp-resetpassword-extended-description}}}}",
+ "apihelp-resetpassword-param-user": "{{doc-apihelp-param|resetpassword|user}}",
+ "apihelp-resetpassword-param-email": "{{doc-apihelp-param|resetpassword|email}}",
+ "apihelp-resetpassword-example-user": "{{doc-apihelp-example|resetpassword}}",
+ "apihelp-resetpassword-example-email": "{{doc-apihelp-example|resetpassword}}",
+ "apihelp-revisiondelete-summary": "{{doc-apihelp-summary|revisiondelete}}",
+ "apihelp-revisiondelete-param-type": "{{doc-apihelp-param|revisiondelete|type}}",
+ "apihelp-revisiondelete-param-target": "{{doc-apihelp-param|revisiondelete|target}}",
+ "apihelp-revisiondelete-param-ids": "{{doc-apihelp-param|revisiondelete|ids}}",
+ "apihelp-revisiondelete-param-hide": "{{doc-apihelp-param|revisiondelete|hide}}",
+ "apihelp-revisiondelete-param-show": "{{doc-apihelp-param|revisiondelete|show}}",
+ "apihelp-revisiondelete-param-suppress": "{{doc-apihelp-param|revisiondelete|suppress}}",
+ "apihelp-revisiondelete-param-reason": "{{doc-apihelp-param|revisiondelete|reason}}",
+ "apihelp-revisiondelete-param-tags": "{{doc-apihelp-param|revisiondelete|tags}}",
+ "apihelp-revisiondelete-example-revision": "{{doc-apihelp-example|revisiondelete}}",
+ "apihelp-revisiondelete-example-log": "{{doc-apihelp-example|revisiondelete}}",
+ "apihelp-rollback-summary": "{{doc-apihelp-summary|rollback}}",
+ "apihelp-rollback-extended-description": "{{doc-apihelp-extended-description|rollback}}",
+ "apihelp-rollback-param-title": "{{doc-apihelp-param|rollback|title}}",
+ "apihelp-rollback-param-pageid": "{{doc-apihelp-param|rollback|pageid}}",
+ "apihelp-rollback-param-tags": "{{doc-apihelp-param|rollback|tags}}",
+ "apihelp-rollback-param-user": "{{doc-apihelp-param|rollback|user}}",
+ "apihelp-rollback-param-summary": "{{doc-apihelp-param|rollback|summary}}",
+ "apihelp-rollback-param-markbot": "{{doc-apihelp-param|rollback|markbot}}",
+ "apihelp-rollback-param-watchlist": "{{doc-apihelp-param|rollback|watchlist}}",
+ "apihelp-rollback-example-simple": "{{doc-apihelp-example|rollback}}",
+ "apihelp-rollback-example-summary": "{{doc-apihelp-example|rollback}}",
+ "apihelp-rsd-summary": "{{doc-apihelp-summary|rsd}}",
+ "apihelp-rsd-example-simple": "{{doc-apihelp-example|rsd}}",
+ "apihelp-setnotificationtimestamp-summary": "{{doc-apihelp-summary|setnotificationtimestamp}}",
+ "apihelp-setnotificationtimestamp-extended-description": "{{doc-apihelp-extended-description|setnotificationtimestamp}}",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "{{doc-apihelp-param|setnotificationtimestamp|entirewatchlist}}",
+ "apihelp-setnotificationtimestamp-param-timestamp": "{{doc-apihelp-param|setnotificationtimestamp|timestamp}}",
+ "apihelp-setnotificationtimestamp-param-torevid": "{{doc-apihelp-param|setnotificationtimestamp|torevid}}",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "{{doc-apihelp-param|setnotificationtimestamp|newerthanrevid}}",
+ "apihelp-setnotificationtimestamp-example-all": "{{doc-apihelp-example|setnotificationtimestamp}}",
+ "apihelp-setnotificationtimestamp-example-page": "{{doc-apihelp-example|setnotificationtimestamp}}",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "{{doc-apihelp-example|setnotificationtimestamp}}",
+ "apihelp-setnotificationtimestamp-example-allpages": "{{doc-apihelp-example|setnotificationtimestamp}}",
+ "apihelp-setpagelanguage-summary": "{{doc-apihelp-summary|setpagelanguage}}",
+ "apihelp-setpagelanguage-extended-description-disabled": "{{doc-apihelp-extended-description|setpagelanguage|info=This message is used when changing the language of a page is not allowed on the wiki because <var>[[mw:Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> is not enabled.|seealso={{msg-mw|apihelp-setpagelanguage-extended-description}}}}",
+ "apihelp-setpagelanguage-param-title": "{{doc-apihelp-param|setpagelanguage|title}}",
+ "apihelp-setpagelanguage-param-pageid": "{{doc-apihelp-param|setpagelanguage|pageid}}",
+ "apihelp-setpagelanguage-param-lang": "{{doc-apihelp-param|setpagelanguage|lang}}",
+ "apihelp-setpagelanguage-param-reason": "{{doc-apihelp-param|setpagelanguage|reason}}",
+ "apihelp-setpagelanguage-param-tags": "{{doc-apihelp-param|setpagelanguage|tags}}",
+ "apihelp-setpagelanguage-example-language": "{{doc-apihelp-example|setpagelanguage}}",
+ "apihelp-setpagelanguage-example-default": "{{doc-apihelp-example|setpagelanguage}}",
+ "apihelp-stashedit-summary": "{{doc-apihelp-summary|stashedit}}",
+ "apihelp-stashedit-extended-description": "{{doc-apihelp-extended-description|stashedit}}",
+ "apihelp-stashedit-param-title": "{{doc-apihelp-param|stashedit|title}}",
+ "apihelp-stashedit-param-section": "{{doc-apihelp-param|stashedit|section}}",
+ "apihelp-stashedit-param-sectiontitle": "{{doc-apihelp-param|stashedit|sectiontitle}}",
+ "apihelp-stashedit-param-text": "{{doc-apihelp-param|stashedit|text}}",
+ "apihelp-stashedit-param-stashedtexthash": "{{doc-apihelp-param|stashedit|stashedtexthash}}",
+ "apihelp-stashedit-param-contentmodel": "{{doc-apihelp-param|stashedit|contentmodel}}",
+ "apihelp-stashedit-param-contentformat": "{{doc-apihelp-param|stashedit|contentformat}}",
+ "apihelp-stashedit-param-baserevid": "{{doc-apihelp-param|stashedit|baserevid}}",
+ "apihelp-stashedit-param-summary": "{{doc-apihelp-param|stashedit|summary}}",
+ "apihelp-tag-summary": "{{doc-apihelp-summary|tag}}",
+ "apihelp-tag-param-rcid": "{{doc-apihelp-param|tag|rcid}}",
+ "apihelp-tag-param-revid": "{{doc-apihelp-param|tag|revid}}",
+ "apihelp-tag-param-logid": "{{doc-apihelp-param|tag|logid}}",
+ "apihelp-tag-param-add": "{{doc-apihelp-param|tag|add}}",
+ "apihelp-tag-param-remove": "{{doc-apihelp-param|tag|remove}}",
+ "apihelp-tag-param-reason": "{{doc-apihelp-param|tag|reason}}",
+ "apihelp-tag-param-tags": "{{doc-apihelp-param|tag|tags}}",
+ "apihelp-tag-example-rev": "{{doc-apihelp-example|tag}}",
+ "apihelp-tag-example-log": "{{doc-apihelp-example|tag}}",
+ "apihelp-tokens-summary": "{{doc-apihelp-summary|tokens}}",
+ "apihelp-tokens-extended-description": "{{doc-apihelp-extended-description|tokens}}",
+ "apihelp-tokens-param-type": "{{doc-apihelp-param|tokens|type}}",
+ "apihelp-tokens-example-edit": "{{doc-apihelp-example|tokens}}",
+ "apihelp-tokens-example-emailmove": "{{doc-apihelp-example|tokens}}",
+ "apihelp-unblock-summary": "{{doc-apihelp-summary|unblock}}",
+ "apihelp-unblock-param-id": "{{doc-apihelp-param|unblock|id}}",
+ "apihelp-unblock-param-user": "{{doc-apihelp-param|unblock|user}}",
+ "apihelp-unblock-param-userid": "{{doc-apihelp-param|unblock|userid}}",
+ "apihelp-unblock-param-reason": "{{doc-apihelp-param|unblock|reason}}",
+ "apihelp-unblock-param-tags": "{{doc-apihelp-param|unblock|tags}}",
+ "apihelp-unblock-example-id": "{{doc-apihelp-example|unblock}}",
+ "apihelp-unblock-example-user": "{{doc-apihelp-example|unblock}}",
+ "apihelp-undelete-summary": "{{doc-apihelp-summary|undelete}}",
+ "apihelp-undelete-extended-description": "{{doc-apihelp-extended-description|undelete}}",
+ "apihelp-undelete-param-title": "{{doc-apihelp-param|undelete|title}}",
+ "apihelp-undelete-param-reason": "{{doc-apihelp-param|undelete|reason}}",
+ "apihelp-undelete-param-tags": "{{doc-apihelp-param|undelete|tags}}",
+ "apihelp-undelete-param-timestamps": "{{doc-apihelp-param|undelete|timestamps}}",
+ "apihelp-undelete-param-fileids": "{{doc-apihelp-param|undelete|fileids}}",
+ "apihelp-undelete-param-watchlist": "{{doc-apihelp-param|undelete|watchlist}}",
+ "apihelp-undelete-example-page": "{{doc-apihelp-example|undelete}}",
+ "apihelp-undelete-example-revisions": "{{doc-apihelp-example|undelete}}",
+ "apihelp-unlinkaccount-summary": "{{doc-apihelp-summary|unlinkaccount}}",
+ "apihelp-unlinkaccount-example-simple": "{{doc-apihelp-example|unlinkaccount}}",
+ "apihelp-upload-summary": "{{doc-apihelp-summary|upload}}",
+ "apihelp-upload-extended-description": "{{doc-apihelp-extended-description|upload}}",
+ "apihelp-upload-param-filename": "{{doc-apihelp-param|upload|filename}}",
+ "apihelp-upload-param-comment": "{{doc-apihelp-param|upload|comment}}",
+ "apihelp-upload-param-tags": "{{doc-apihelp-param|upload|tags}}",
+ "apihelp-upload-param-text": "{{doc-apihelp-param|upload|text}}",
+ "apihelp-upload-param-watch": "{{doc-apihelp-param|upload|watch}}",
+ "apihelp-upload-param-watchlist": "{{doc-apihelp-param|upload|watchlist}}",
+ "apihelp-upload-param-ignorewarnings": "{{doc-apihelp-param|upload|ignorewarnings}}",
+ "apihelp-upload-param-file": "{{doc-apihelp-param|upload|file}}",
+ "apihelp-upload-param-url": "{{doc-apihelp-param|upload|url}}",
+ "apihelp-upload-param-filekey": "{{doc-apihelp-param|upload|filekey}}",
+ "apihelp-upload-param-sessionkey": "{{doc-apihelp-param|upload|sessionkey}}",
+ "apihelp-upload-param-stash": "{{doc-apihelp-param|upload|stash}}",
+ "apihelp-upload-param-filesize": "{{doc-apihelp-param|upload|filesize}}",
+ "apihelp-upload-param-offset": "{{doc-apihelp-param|upload|offset}}",
+ "apihelp-upload-param-chunk": "{{doc-apihelp-param|upload|chunk}}",
+ "apihelp-upload-param-async": "{{doc-apihelp-param|upload|async}}",
+ "apihelp-upload-param-checkstatus": "{{doc-apihelp-param|upload|checkstatus}}",
+ "apihelp-upload-example-url": "{{doc-apihelp-example|upload}}",
+ "apihelp-upload-example-filekey": "{{doc-apihelp-example|upload}}",
+ "apihelp-userrights-summary": "{{doc-apihelp-summary|userrights}}",
+ "apihelp-userrights-param-user": "{{doc-apihelp-param|userrights|user}}\n{{Identical|Username}}",
+ "apihelp-userrights-param-userid": "{{doc-apihelp-param|userrights|userid}}\n{{Identical|User ID}}",
+ "apihelp-userrights-param-add": "{{doc-apihelp-param|userrights|add}}",
+ "apihelp-userrights-param-expiry": "{{doc-apihelp-param|userrights|expiry}}",
+ "apihelp-userrights-param-remove": "{{doc-apihelp-param|userrights|remove}}",
+ "apihelp-userrights-param-reason": "{{doc-apihelp-param|userrights|reason}}",
+ "apihelp-userrights-param-tags": "{{doc-apihelp-param|userrights|tags}}",
+ "apihelp-userrights-example-user": "{{doc-apihelp-example|userrights}}",
+ "apihelp-userrights-example-userid": "{{doc-apihelp-example|userrights}}",
+ "apihelp-userrights-example-expiry": "{{doc-apihelp-example|userrights}}",
+ "apihelp-validatepassword-summary": "{{doc-apihelp-summary|validatepassword}}",
+ "apihelp-validatepassword-extended-description": "{{doc-apihelp-extended-description|validatepassword}}",
+ "apihelp-validatepassword-param-password": "{{doc-apihelp-param|validatepassword|password}}",
+ "apihelp-validatepassword-param-user": "{{doc-apihelp-param|validatepassword|user}}",
+ "apihelp-validatepassword-param-email": "{{doc-apihelp-param|validatepassword|email}}",
+ "apihelp-validatepassword-param-realname": "{{doc-apihelp-param|validatepassword|realname}}",
+ "apihelp-validatepassword-example-1": "{{doc-apihelp-example|validatepassword}}",
+ "apihelp-validatepassword-example-2": "{{doc-apihelp-example|validatepassword}}",
+ "apihelp-watch-summary": "{{doc-apihelp-summary|watch}}",
+ "apihelp-watch-param-title": "{{doc-apihelp-param|watch|title}}",
+ "apihelp-watch-param-unwatch": "{{doc-apihelp-param|watch|unwatch}}",
+ "apihelp-watch-example-watch": "{{doc-apihelp-example|watch}}",
+ "apihelp-watch-example-unwatch": "{{doc-apihelp-example|watch}}",
+ "apihelp-watch-example-generator": "{{doc-apihelp-example|watch}}",
+ "apihelp-format-example-generic": "{{doc-apihelp-example|format|params=* $1 - Format name|paramstart=2|noseealso=1}}",
+ "apihelp-format-param-wrappedhtml": "{{doc-apihelp-param|format|wrappedhtml|description=the \"wrappedhtml\" parameter in pretty-printing format modules}}",
+ "apihelp-json-summary": "{{doc-apihelp-summary|json|seealso=* {{msg-mw|apihelp-jsonfm-summary}}}}",
+ "apihelp-json-param-callback": "{{doc-apihelp-param|json|callback}}",
+ "apihelp-json-param-utf8": "{{doc-apihelp-param|json|utf8}}",
+ "apihelp-json-param-ascii": "{{doc-apihelp-param|json|ascii}}",
+ "apihelp-json-param-formatversion": "{{doc-apihelp-param|json|formatversion}}",
+ "apihelp-jsonfm-summary": "{{doc-apihelp-summary|jsonfm|seealso=* {{msg-mw|apihelp-json-summary}}}}",
+ "apihelp-none-summary": "{{doc-apihelp-summary|none}}",
+ "apihelp-php-summary": "{{doc-apihelp-summary|php|seealso=* {{msg-mw|apihelp-phpfm-summary}}}}",
+ "apihelp-php-param-formatversion": "{{doc-apihelp-param|php|formatversion}}",
+ "apihelp-phpfm-summary": "{{doc-apihelp-summary|phpfm|seealso=* {{msg-mw|apihelp-php-summary}}}}",
+ "apihelp-rawfm-summary": "{{doc-apihelp-summary|rawfm|seealso=* {{msg-mw|apihelp-raw-summary}}}}",
+ "apihelp-xml-summary": "{{doc-apihelp-summary|xml|seealso=* {{msg-mw|apihelp-xmlfm-summary}}}}",
+ "apihelp-xml-param-xslt": "{{doc-apihelp-param|xml|xslt}}",
+ "apihelp-xml-param-includexmlnamespace": "{{doc-apihelp-param|xml|includexmlnamespace}}",
+ "apihelp-xmlfm-summary": "{{doc-apihelp-summary|xmlfm|seealso=* {{msg-mw|apihelp-xml-summary}}}}",
+ "api-format-title": "{{technical}}\nPage title when API output is pretty-printed in HTML.",
+ "api-format-prettyprint-header": "{{technical}} Displayed as a header when API output is pretty-printed in HTML, but a post request is received.\n\nParameters:\n* $1 - Format name\n* $2 - Non-pretty-printing module name",
+ "api-format-prettyprint-header-only-html": "{{technical}} Displayed as a header when API output is pretty-printed in HTML, but there is no non-html module.\n\nParameters:\n* $1 - Format name",
+ "api-format-prettyprint-header-hyperlinked": "{{technical}} Displayed as a header when API output is pretty-printed in HTML.\n\nParameters:\n* $1 - Format name\n* $2 - Non-pretty-printing module name\n* $3 - URL to Non-pretty-printing module",
+ "api-format-prettyprint-status": "{{technical}} Displayed as a header when API pretty-printed output is used for a response that uses an unusual HTTP status code.\n\nParameters:\n* $1 - HTTP status code (integer)\n* $2 - Standard English text for the status code.",
+ "api-login-fail-aborted": "{{technical}} Displayed as an error when API login fails due to AuthManager requiring user interaction.\n\nSee also:\n* {{msg-mw|api-login-fail-aborted-nobotpw}}",
+ "api-login-fail-aborted-nobotpw": "{{technical}} Displayed as an error when API login fails due to AuthManager requiring user interaction. Used when BotPasswords is disabled.\n\nSee also:\n* {{msg-mw|api-login-fail-aborted}}",
+ "api-login-fail-badsessionprovider": "{{technical}} Displayed as an error when API login is not possible due to a session provider that doesn't support login.\n\nParameters:\n* $1 - Session type in use that makes it not possible to log in, from a message like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.",
+ "api-login-fail-sameorigin": "{{technical}} Displayed as an error when API login is not possible because the request does not enforce the Same-Origin policy.",
+ "api-pageset-param-titles": "{{doc-apihelp-param|pageset|titles|description=the \"titles\" parameter in pageset-using modules}}",
+ "api-pageset-param-pageids": "{{doc-apihelp-param|pageset|pageids|description=the \"pageids\" parameter in pageset-using modules}}",
+ "api-pageset-param-revids": "{{doc-apihelp-param|pageset|revids|description=the \"revids\" parameter in pageset-using modules}}",
+ "api-pageset-param-generator": "{{doc-apihelp-param|pageset|generator|description=the \"generator\" parameter in pageset-using modules}}",
+ "api-pageset-param-redirects-generator": "{{doc-apihelp-param|pageset|redirects-generator|description=the \"redirects\" parameter in pageset-using modules when the \"generator\" parameter is also available}}",
+ "api-pageset-param-redirects-nogenerator": "{{doc-apihelp-param|pageset|redirects-generator|description=the \"redirects\" parameter in pageset-using modules when the \"generator\" parameter is not available}}",
+ "api-pageset-param-converttitles": "{{doc-apihelp-param|pageset|converttitles|description=the \"converttitles\" parameter in pageset-using modules|params=* $1 - List of languages with variants|paramstart=2}}",
+ "api-help-title": "Page title for the auto-generated help output",
+ "api-help-lead": "Text displayed at the top of the API help page",
+ "api-help-main-header": "Text for the header of the main module",
+ "api-help-undocumented-module": "Text displayed for the summary of a submodule parameter when the module can't be loaded.\n\nParameters:\n* $1 - The module path.",
+ "api-help-fallback-description": "{{notranslate}}",
+ "api-help-fallback-parameter": "{{notranslate}}",
+ "api-help-fallback-example": "{{notranslate}}",
+ "api-help-flags": "{{ignored}} Label for the API help flags box\n\nParameters:\n* $1 - Number of flags to be displayed",
+ "api-help-flag-deprecated": "Flag displayed for an API module that is deprecated",
+ "api-help-flag-internal": "Flag displayed for an API module that is considered internal or unstable",
+ "api-help-flag-readrights": "Flag displayed for an API module that requires read rights",
+ "api-help-flag-writerights": "Flag displayed for an API module that requires write rights",
+ "api-help-flag-mustbeposted": "Flag displayed for an API module that only accepts POST requests",
+ "api-help-flag-generator": "Flag displayed for an API module that can be used as a generator",
+ "api-help-source": "Displayed in the flags box to indicate the source of an API module.\n\nParameters:\n* $1 - Possibly-localised extension name, or \"MediaWiki\" if it's a core module\n* $2 - Non-localised extension name.\n\nSee also:\n* {{msg-mw|api-help-source-unknown}}",
+ "api-help-source-unknown": "Displayed in the flags box to indicate that the source of an API module is not known.\n\nSee also:\n* {{msg-mw|api-help-source}}",
+ "api-help-license": "Displayed in the flags box to indicate the license of an API module.\n\nParameters:\n* $1 - Page to link to display the full license text\n* $2 - Display text for the link\n\nSee also:\n* {{msg-mw|api-help-license-noname}}\n* {{msg-mw|api-help-license-unknown}}",
+ "api-help-license-noname": "Displayed in the flags box to indicate the license of an API module, when the tag for the license is not known.\n\nParameters:\n* $1 - Page to link to display the full license text\n\nSee also:\n* {{msg-mw|api-help-license}}\n* {{msg-mw|api-help-license-unknown}}",
+ "api-help-license-unknown": "Displayed in the flags box to indicate that the license of the API module is not known.\n\nSee also:\n* {{msg-mw|api-help-license}}\n* {{msg-mw|api-help-license-noname}}",
+ "api-help-help-urls": "{{ignored}} Label for the API help urls section\n\nParameters:\n* $1 - Number of urls to be displayed",
+ "api-help-parameters": "Label for the API help parameters section\n\nParameters:\n* $1 - Number of parameters to be displayed\n{{Identical|Parameter}}",
+ "api-help-param-deprecated": "Displayed in the API help for any deprecated parameter\n{{Identical|Deprecated}}",
+ "api-help-param-required": "Displayed in the API help for any required parameter",
+ "api-help-datatypes-header": "Header for the data type section in the API help output",
+ "api-help-datatypes": "{{technical}} {{doc-important|Do not translate or reformat dates inside <nowiki><kbd></kbd></nowiki> or <nowiki><var></var></nowiki> tags}} Documentation of certain API data types\nSee also:\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]",
+ "api-help-param-type-limit": "{{technical}} {{doc-important|Do not translate text inside &lt;kbd&gt; tags}} Used to indicate that a parameter is a \"limit\" type. Parameters:\n* $1 - Always 1.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]",
+ "api-help-param-type-integer": "{{technical}} Used to indicate that a parameter is an integer or list of integers. Parameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes a list of values.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]",
+ "api-help-param-type-boolean": "{{technical}} {{doc-important|Do not translate <code>Special:ApiHelp</code> in this message.}} Used to indicate that a parameter is a boolean. Parameters:\n* $1 - Always 1.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]",
+ "api-help-param-type-password": "{{ignored}}{{technical}} Used to indicate that a parameter is a password or list of passwords. Parameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes a list of values.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]",
+ "api-help-param-type-timestamp": "{{technical}} {{doc-important|Do not translate <code>Special:ApiHelp</code> in this message.}} Used to indicate that a parameter is a timestamp or list of timestamps. Parameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes a list of values.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]",
+ "api-help-param-type-user": "{{technical}} Used to indicate that a parameter is a username or list of usernames. Parameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes a list of values.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]",
+ "api-help-param-list": "Used to display the possible values for a parameter taking a list of values\n\nParameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes any number of values\n* $2 - Comma-separated list of values, possibly formatted using {{msg-mw|api-help-param-list-can-be-empty}}\n{{Identical|Value}}",
+ "api-help-param-list-can-be-empty": "Used to indicate that one of the possible values in the list is the empty string.\n\nParameters:\n* $1 - Number of items in the rest of the list; may be 0\n* $2 - Remainder of the list as a comma-separated string",
+ "api-help-param-limit": "Used to display the maximum value of a limit parameter\n\nParameters:\n* $1 - Maximum value",
+ "api-help-param-limit2": "Used to display the maximum values of a limit parameter\n\nParameters:\n* $1 - Maximum value without the apihighlimits right\n* $2 - Maximum value with the apihighlimits right",
+ "api-help-param-integer-min": "Used to display an integer parameter with a minimum but no maximum value\n\nParameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes any number of values\n* $2 - Minimum value\n* $3 - unused\n\nSee also:\n* {{msg-mw|api-help-param-integer-max}}\n* {{msg-mw|api-help-param-integer-minmax}}",
+ "api-help-param-integer-max": "Used to display an integer parameter with a maximum but no minimum value.\n\nParameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes any number of values\n* $2 - (Unused)\n* $3 - Maximum value\nSee also:\n* {{msg-mw|Api-help-param-integer-min}}\n* {{msg-mw|Api-help-param-integer-minmax}}",
+ "api-help-param-integer-minmax": "Used to display an integer parameter with a maximum and minimum values\n\nParameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes any number of values\n* $2 - Minimum value\n* $3 - Maximum value\n\nSee also:\n* {{msg-mw|api-help-param-integer-min}}\n* {{msg-mw|api-help-param-integer-max}}",
+ "api-help-param-upload": "{{technical}} Used to indicate that an 'upload'-type parameter must be posted as a file upload using multipart/form-data",
+ "api-help-param-multi-separate": "Used to indicate how to separate multiple values. Not used with {{msg-mw|api-help-param-list}}.",
+ "api-help-param-multi-max": "Used to indicate the maximum number of values accepted for a multi-valued parameter when that value is influenced by the user having apihighlimits right (otherwise {{msg-mw|api-help-param-multi-max-simple}} is used).\n\nParameters:\n* $1 - Maximum value without the apihighlimits right\n* $2 - Maximum value with the apihighlimits right",
+ "api-help-param-multi-max-simple": "Used to indicate the maximum number of values accepted for a multi-valued parameter when that value is not influenced by the user having apihighlimits right (otherwise {{msg-mw|api-help-param-multi-max}} is used).\n\nParameters:\n* $1 - Maximum value",
+ "api-help-param-multi-all": "Used to indicate what string can be used to specify all possible values of a multi-valued parameter. \n\nParameters:\n* $1 - String to specify all possible values of the parameter",
+ "api-help-param-default": "Used to display the default value for an API parameter\n\nParameters:\n* $1 - Default value\n\nSee also:\n* {{msg-mw|api-help-param-default-empty}}\n{{Identical|Default}}",
+ "api-help-param-default-empty": "Used to display the default value for an API parameter when that default is an empty value\n\nSee also:\n* {{msg-mw|api-help-param-default}}",
+ "api-help-param-token": "{{doc-apihelp-param|description=any 'token' parameter|paramstart=2|params=\n* $1 - Token type|noseealso=1}}",
+ "api-help-param-token-webui": "{{doc-apihelp-param|description=additional text for any \"token\" parameter, explaining that web UI tokens are also accepted|noseealso=1}}",
+ "api-help-param-disabled-in-miser-mode": "{{doc-apihelp-param|description=any parameter that is disabled when [[mw:Manual:$wgMiserMode|$wgMiserMode]] is set.|noseealso=1}}",
+ "api-help-param-limited-in-miser-mode": "{{doc-apihelp-param|description=additional text for any parameter that may cause the module to return few results when [[mw:Manual:$wgMiserMode|$wgMiserMode]] is set.|noseealso=1}}",
+ "api-help-param-direction": "{{doc-apihelp-param|description=any standard \"dir\" parameter|noseealso=1}}",
+ "api-help-param-continue": "{{doc-apihelp-param|description=any standard \"continue\" parameter, or other parameter with the same semantics|noseealso=1}}",
+ "api-help-param-no-description": "Displayed on API parameters that lack any description",
+ "api-help-examples": "Label for the API help examples section\n\nParameters:\n* $1 - Number of examples to be displayed\n{{Identical|Example}}",
+ "api-help-permissions": "Label for the \"permissions\" section in the main module's help output.\n\nParameters:\n* $1 - Number of permissions displayed\n{{Identical|Permission}}",
+ "api-help-permissions-granted-to": "Used to introduce the list of groups each permission is assigned to.\n\nParameters:\n* $1 - Number of groups\n* $2 - List of group names, comma-separated",
+ "api-help-right-apihighlimits": "{{technical}}{{doc-right|apihighlimits|prefix=api-help}}\nThis message is used instead of {{msg-mw|right-apihighlimits}} in the API help to display the actual limits.\n\nParameters:\n* $1 - Limit for slow queries\n* $2 - Limit for fast queries",
+ "api-help-open-in-apisandbox": "Text for the link to open an API example in [[Special:ApiSandbox]].",
+ "api-help-no-extended-description": "{{notranslate}} Message used when no extended description is provided. Should be empty.",
+ "api-help-authmanager-general-usage": "{{doc-important|Do not translate text that either quoted, or inside <nowiki><var></var></nowiki>, <nowiki><kbd></kbd></nowiki>, <nowiki><samp></samp></nowiki>, or <nowiki><code></code></nowiki> in this message.}}\nText giving a brief overview of how to use an AuthManager-using API module. Parameters:\n* $1 - Module parameter prefix, e.g. \"login\"\n* $2 - Module name, e.g. \"clientlogin\"\n* $3 - Module path, e.g. \"clientlogin\"\n* $4 - AuthManager action to use with this module.\n* $5 - Token type needed by the module.",
+ "api-help-authmanagerhelper-requests": "{{doc-apihelp-param|description=the \"requests\" parameter for AuthManager-using API modules|params=* $1 - AuthManager action used by this module|paramstart=2|noseealso=1}}",
+ "api-help-authmanagerhelper-request": "{{doc-apihelp-param|description=the \"request\" parameter for AuthManager-using API modules|params=* $1 - AuthManager action used by this module|paramstart=2|noseealso=1}}",
+ "api-help-authmanagerhelper-messageformat": "{{doc-apihelp-param|description=the \"messageformat\" parameter for AuthManager-using API modules|noseealso=1}}",
+ "api-help-authmanagerhelper-mergerequestfields": "{{doc-apihelp-param|description=the \"mergerequestfields\" parameter for AuthManager-using API modules|noseealso=1}}",
+ "api-help-authmanagerhelper-preservestate": "{{doc-apihelp-param|description=the \"preservestate\" parameter for AuthManager-using API modules|noseealso=1}}",
+ "api-help-authmanagerhelper-returnurl": "{{doc-apihelp-param|description=the \"returnurl\" parameter for AuthManager-using API modules|noseealso=1}}",
+ "api-help-authmanagerhelper-continue": "{{doc-apihelp-param|description=the \"continue\" parameter for AuthManager-using API modules|noseealso=1}}",
+ "api-help-authmanagerhelper-additional-params": "Message to display for AuthManager modules that take additional parameters to populate AuthenticationRequests. Parameters:\n* $1 - AuthManager action used by this module\n* $2 - Module parameter prefix, e.g. \"login\"\n* $3 - Module name, e.g. \"clientlogin\"\n* $4 - Module path, e.g. \"clientlogin\"",
+ "apierror-allimages-redirect": "{{doc-apierror}}",
+ "apierror-allpages-generator-redirects": "{{doc-apierror}}",
+ "apierror-appendnotsupported": "{{doc-apierror}}\n\nParameters:\n* $1 - Content model",
+ "apierror-articleexists": "{{doc-apierror}}",
+ "apierror-assertbotfailed": "{{doc-apierror}}",
+ "apierror-assertnameduserfailed": "{{doc-apierror}}\n\nParameters:\n* $1 - User name passed in.",
+ "apierror-assertuserfailed": "{{doc-apierror}}",
+ "apierror-autoblocked": "{{doc-apierror}}",
+ "apierror-badconfig-resulttoosmall": "{{doc-apierror}}",
+ "apierror-badcontinue": "{{doc-apierror}}",
+ "apierror-baddiff": "{{doc-apierror}}",
+ "apierror-baddiffto": "{{doc-apierror}}\n\nParameters:\n* $1 - Module parameter prefix, e.g. \"bl\".",
+ "apierror-badformat-generic": "{{doc-apierror}}\n\nParameters:\n* $1 - Content format.\n* $2 - Content model.",
+ "apierror-badformat": "{{doc-apierror}}\n\nParameters:\n* $1 - Content format.\n* $2 - Content model.\n* $3 - Title using the model.",
+ "apierror-badgenerator-notgenerator": "{{doc-apierror}}\n\nParameters:\n* $1 - Generator module name.",
+ "apierror-badgenerator-unknown": "{{doc-apierror}}\n\nParameters:\n* $1 - Generator module name.",
+ "apierror-badip": "{{doc-apierror}}",
+ "apierror-badmd5": "{{doc-apierror}}",
+ "apierror-badmodule-badsubmodule": "{{doc-apierror}}\n\nParameters:\n* $1 - Module path.\n* $2 - Submodule name.",
+ "apierror-badmodule-nosubmodules": "{{doc-apierror}}\n\nParameters:\n* $1 - Module path.",
+ "apierror-badparameter": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
+ "apierror-badquery": "{{doc-apierror}}",
+ "apierror-badtimestamp": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - Value of the parameter.",
+ "apierror-badtoken": "{{doc-apierror}}",
+ "apierror-badupload": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
+ "apierror-badurl": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - Value of the parameter.",
+ "apierror-baduser": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - Value of the parameter.",
+ "apierror-badvalue-notmultivalue": "{{doc-apierror}}",
+ "apierror-bad-watchlist-token": "{{doc-apierror}}",
+ "apierror-blockedfrommail": "{{doc-apierror}}",
+ "apierror-blocked": "{{doc-apierror}}",
+ "apierror-botsnotsupported": "{{doc-apierror}}",
+ "apierror-cannot-async-upload-file": "{{doc-apierror}}",
+ "apierror-cannotreauthenticate": "{{doc-apierror}}",
+ "apierror-cannotviewtitle": "{{doc-apierror}}\n\nParameters:\n* $1 - Title.",
+ "apierror-cantblock-email": "{{doc-apierror}}",
+ "apierror-cantblock": "{{doc-apierror}}",
+ "apierror-cantchangecontentmodel": "{{doc-apierror}}",
+ "apierror-canthide": "{{doc-apierror}}",
+ "apierror-cantimport-upload": "{{doc-apierror}}",
+ "apierror-cantimport": "{{doc-apierror}}",
+ "apierror-cantoverwrite-sharedfile": "{{doc-apierror}}",
+ "apierror-cantsend": "{{doc-apierror}}",
+ "apierror-cantundelete": "{{doc-apierror}}",
+ "apierror-changeauth-norequest": "{{doc-apierror}}",
+ "apierror-chunk-too-small": "{{doc-apierror}}\n\nParameters:\n* $1 - Minimum size in bytes.",
+ "apierror-cidrtoobroad": "{{doc-apierror}}\n\nParameters:\n* $1 - \"IPv4\" or \"IPv6\"\n* $2 - Minimum CIDR mask length.",
+ "apierror-compare-no-title": "{{doc-apierror}}",
+ "apierror-compare-relative-to-nothing": "{{doc-apierror}}",
+ "apierror-contentserializationexception": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text, may end with punctuation. Currently this is probably English, hopefully we'll fix that in the future.",
+ "apierror-contenttoobig": "{{doc-apierror}}\n\nParameters:\n* $1 - Maximum article size in kilobytes.",
+ "apierror-copyuploadbaddomain": "{{doc-apierror}}",
+ "apierror-copyuploadbadurl": "{{doc-apierror}}",
+ "apierror-create-titleexists": "{{doc-apierror}}",
+ "apierror-csp-report": "{{doc-apierror}}\n\nParameters:\n* $1 - Error code, e.g. \"toobig\".",
+ "apierror-databaseerror": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception log ID code. This is meaningless to the end user, but can be used by people with access to the logs to easily find the logged error.",
+ "apierror-deletedrevs-param-not-1-2": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n\nSee also:\n* {{msg-mw|apihelp-query+deletedrevs-extended-description}}",
+ "apierror-deletedrevs-param-not-3": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n\nSee also:\n* {{msg-mw|apihelp-query+deletedrevs-extended-description}}",
+ "apierror-emptynewsection": "{{doc-apierror}}",
+ "apierror-emptypage": "{{doc-apierror}}",
+ "apierror-exceptioncaught": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception log ID code. This is meaningless to the end user, but can be used by people with access to the logs to easily find the logged error.\n* $2 - Exception message, which may end with punctuation. Probably in English.",
+ "apierror-filedoesnotexist": "{{doc-apierror}}",
+ "apierror-fileexists-sharedrepo-perm": "{{doc-apierror}}",
+ "apierror-filenopath": "{{doc-apierror}}",
+ "apierror-filetypecannotberotated": "{{doc-apierror}}",
+ "apierror-formatphp": "{{doc-apierror}}",
+ "apierror-imageusage-badtitle": "{{doc-apierror}}\n\nParameters:\n* $1 - Module name.",
+ "apierror-import-unknownerror": "{{doc-apierror}}\n\nParameters:\n* $1 - Error message returned by the import, probably in English.",
+ "apierror-integeroutofrange-abovebotmax": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name\n* $2 - Maximum allowed value\n* $3 - Supplied value",
+ "apierror-integeroutofrange-abovemax": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name\n* $2 - Maximum allowed value\n* $3 - Supplied value",
+ "apierror-integeroutofrange-belowminimum": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name\n* $2 - Minimum allowed value\n* $3 - Supplied value",
+ "apierror-invalidcategory": "{{doc-apierror}}",
+ "apierror-invalid-chunk": "{{doc-apierror}}",
+ "apierror-invalidexpiry": "{{doc-apierror}}\n\nParameters:\n* $1 - Value provided.",
+ "apierror-invalid-file-key": "{{doc-apierror}}",
+ "apierror-invalidlang": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
+ "apierror-invalidoldimage": "{{doc-apierror}}",
+ "apierror-invalidparammix-cannotusewith": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name or \"parameter=value\" text.\n* $2 - Parameter name or \"parameter=value\" text.",
+ "apierror-invalidparammix-mustusewith": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name or \"parameter=value\" text.\n* $2 - Parameter name or \"parameter=value\" text.",
+ "apierror-invalidparammix-parse-new-section": "{{doc-apierror}}",
+ "apierror-invalidparammix": "{{doc-apierror}}\n\nParameters:\n* $1 - List of parameter names or \"parameter=value\" text.\n* $2 - Number of parameters.",
+ "apierror-invalidsection": "{{doc-apierror}}",
+ "apierror-invalidsha1base36hash": "{{doc-apierror}}",
+ "apierror-invalidsha1hash": "{{doc-apierror}}",
+ "apierror-invalidtitle": "{{doc-apierror}}\n\nParameters:\n* $1 - Title that is invalid",
+ "apierror-invalidurlparam": "{{doc-apierror}}\n\nParameters:\n* $1 - Module parameter prefix, e.g. \"bl\".\n* $2 - Key\n* $3 - Value.",
+ "apierror-invaliduser": "{{doc-apierror}}\n\nParameters:\n* $1 - User name that is invalid.",
+ "apierror-invaliduserid": "{{doc-apierror}}",
+ "apierror-maxlag-generic": "{{doc-apierror}}\n\nParameters:\n* $1 - Database is lag in seconds.",
+ "apierror-maxlag": "{{doc-apierror}}\n\nParameters:\n* $1 - Database lag in seconds.\n* $2 - Database server that is lagged.",
+ "apierror-mimesearchdisabled": "{{doc-apierror}}",
+ "apierror-missingcontent-pageid": "{{doc-apierror}}\n\nParameters:\n* $1 - Page ID number.",
+ "apierror-missingcontent-revid": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number",
+ "apierror-missingparam-at-least-one-of": "{{doc-apierror}}\n\nParameters:\n* $1 - List of parameter names.\n* $2 - Number of parameters.",
+ "apierror-missingparam-one-of": "{{doc-apierror}}\n\nParameters:\n* $1 - List of parameter names.\n* $2 - Number of parameters.",
+ "apierror-missingparam": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
+ "apierror-missingrev-pageid": "{{doc-apierror}}\n\nParameters:\n* $1 - Page ID number.",
+ "apierror-missingrev-title": "{{doc-apierror}}\n\nParameters:\n* $1 - Page title.",
+ "apierror-missingtitle-createonly": "{{doc-apierror}}",
+ "apierror-missingtitle": "{{doc-apierror}}",
+ "apierror-missingtitle-byname": "{{doc-apierror}}",
+ "apierror-moduledisabled": "{{doc-apierror}}\n\nParameters:\n* $1 - Name of the module.",
+ "apierror-multival-only-one-of": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - Possible values for the parameter.\n* $3 - Number of values.",
+ "apierror-multival-only-one": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
+ "apierror-multpages": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name",
+ "apierror-mustbeloggedin-changeauth": "{{doc-apierror}}",
+ "apierror-mustbeloggedin-generic": "{{doc-apierror}}",
+ "apierror-mustbeloggedin-linkaccounts": "{{doc-apierror}}",
+ "apierror-mustbeloggedin-removeauth": "{{doc-apierror}}",
+ "apierror-mustbeloggedin-uploadstash": "{{doc-apierror}}",
+ "apierror-mustbeloggedin": "{{doc-apierror}}\n\nParameters:\n* $1 - One of the action-* messages (for example {{msg-mw|action-edit}}) or other such messages tagged with {{tl|doc-action}} in their documentation\n\nPlease report at [[Support]] if you are unable to properly translate this message. Also see [[phab:T16246]] (now closed) for background.\n\nSee also:\n* {{msg-mw|apierror-permissiondenied}}\n* {{msg-mw|permissionserrorstext-withaction}}",
+ "apierror-mustbeposted": "{{doc-apierror}}\n\nParameters:\n* $1 - Module name.",
+ "apierror-mustpostparams": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter names.\n* $2 - Number of parameters.",
+ "apierror-noapiwrite": "{{doc-apierror}}",
+ "apierror-nochanges": "{{doc-apierror}}",
+ "apierror-nodeleteablefile": "{{doc-apierror}}",
+ "apierror-no-direct-editing": "{{doc-apierror}}\n\nParameters:\n* $1 - Content model.\n* $2 - Title using the model.",
+ "apierror-noedit-anon": "{{doc-apierror}}",
+ "apierror-noedit": "{{doc-apierror}}",
+ "apierror-noimageredirect-anon": "{{doc-apierror}}",
+ "apierror-noimageredirect": "{{doc-apierror}}",
+ "apierror-nosuchlogid": "{{doc-apierror}}\n\nParameters:\n* $1 - Log ID number.",
+ "apierror-nosuchpageid": "{{doc-apierror}}\n\nParameters:\n* $1 - Page ID number.",
+ "apierror-nosuchrcid": "{{doc-apierror}}\n\nParameters:\n* $1 - RecentChanges ID number.",
+ "apierror-nosuchrevid": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number.",
+ "apierror-nosuchsection": "{{doc-apierror}}\n\nParameters:\n* $1 - Section identifier. Probably a number or \"T-\" followed by a number.",
+ "apierror-nosuchsection-what": "{{doc-apierror}}\n\nParameters:\n* $1 - Section identifier. Probably a number or \"T-\" followed by a number.\n* $2 - Page title, revision ID formatted with {{msg-mw|revid}}, or page ID formatted with {{msg-mw|pageid}}.",
+ "apierror-nosuchuserid": "{{doc-apierror}}",
+ "apierror-notarget": "{{doc-apierror}}",
+ "apierror-notpatrollable": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number.",
+ "apierror-nouploadmodule": "{{doc-apierror}}",
+ "apierror-offline": "{{doc-apierror}}\nError message for when files could not be uploaded as a result of bad/lost internet connection.",
+ "apierror-opensearch-json-warnings": "{{doc-apierror}}",
+ "apierror-pagecannotexist": "{{doc-apierror}}",
+ "apierror-pagedeleted": "{{doc-apierror}}",
+ "apierror-pagelang-disabled": "{{doc-apierror}}",
+ "apierror-paramempty": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
+ "apierror-parsetree-notwikitext": "{{doc-apierror}}",
+ "apierror-parsetree-notwikitext-title": "{{doc-apierror}}\n\nParameters:\n* $1 - Page title.\n* $2 - Content model.",
+ "apierror-pastexpiry": "{{doc-apierror}}\n\nParameters:\n* $1 - Supplied expiry time.",
+ "apierror-permissiondenied": "{{doc-apierror}}\n\nParameters:\n* $1 - One of the action-* messages (for example {{msg-mw|action-edit}}) or other such messages tagged with {{tl|doc-action}} in their documentation\n\nPlease report at [[Support]] if you are unable to properly translate this message. Also see [[phab:T16246]] (now closed) for background.\n\nSee also:\n* {{msg-mw|permissionserrorstext-withaction}}",
+ "apierror-permissiondenied-generic": "{{doc-apierror}}",
+ "apierror-permissiondenied-patrolflag": "{{doc-apierror}}\n\nSee also:\n* {{msg-mw|apierror-permissiondenied}}",
+ "apierror-permissiondenied-unblock": "{{doc-apierror}}\n\nSee also:\n* {{msg-mw|apierror-permissiondenied}}",
+ "apierror-prefixsearchdisabled": "{{doc-apierror}}",
+ "apierror-promised-nonwrite-api": "{{doc-apierror}}",
+ "apierror-protect-invalidaction": "{{doc-apierror}}\n\nParameters:\n* $1 - Supplied protection type.",
+ "apierror-protect-invalidlevel": "{{doc-apierror}}\n\nParameters:\n* $1 - Supplied protection level.",
+ "apierror-ratelimited": "{{doc-apierror}}",
+ "apierror-readapidenied": "{{doc-apierror}}",
+ "apierror-readonly": "{{doc-apierror}}",
+ "apierror-reauthenticate": "{{doc-apierror}}",
+ "apierror-redirect-appendonly": "{{doc-apierror}}",
+ "apierror-revdel-mutuallyexclusive": "{{doc-apierror}}",
+ "apierror-revdel-needtarget": "{{doc-apierror}}",
+ "apierror-revdel-paramneeded": "{{doc-apierror}}",
+ "apierror-revisions-badid": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter in question, e.g. \"rvstartid\".",
+ "apierror-revisions-norevids": "{{doc-apierror}}\n\nParameters:\n* $1 - Module parameter prefix, e.g. \"bl\".",
+ "apierror-revisions-singlepage": "{{doc-apierror}}\n\nParameters:\n* $1 - Module parameter prefix, e.g. \"bl\".",
+ "apierror-revwrongpage": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number.\n* $2 - Page title.",
+ "apierror-searchdisabled": "{{doc-apierror}}\n\nParameters:\n* $1 - Search parameter that is disabled.",
+ "apierror-sectionreplacefailed": "{{doc-apierror}}",
+ "apierror-sectionsnotsupported": "{{doc-apierror}}\n\nParameters:\n* $1 - Content model that doesn't support sections.",
+ "apierror-sectionsnotsupported-what": "{{doc-apierror}}\n\nParameters:\n* $1 - Page title, revision ID formatted with {{msg-mw|revid}}, or page ID formatted with {{msg-mw|pageid}}.",
+ "apierror-show": "{{doc-apierror}}",
+ "apierror-siteinfo-includealldenied": "{{doc-apierror}}",
+ "apierror-sizediffdisabled": "{{doc-apierror}}",
+ "apierror-spamdetected": "{{doc-apierror}}\n\nParameters:\n* $1 - Matching \"spam filter\".\n\nSee also:\n* {{msg-mw|spamprotectionmatch}}",
+ "apierror-specialpage-cantexecute": "{{doc-apierror}}",
+ "apierror-stashedfilenotfound": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text. Currently this is probably English, hopefully we'll fix that in the future.",
+ "apierror-stashedit-missingtext": "{{doc-apierror}}",
+ "apierror-stashexception": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text. May be English or localized, may or may not end in punctuation.",
+ "apierror-stashfailed-complete": "{{doc-apierror}}",
+ "apierror-stashfailed-nosession": "{{doc-apierror}}",
+ "apierror-stashfilestorage": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text, which may already end with punctuation. Currently this is probably English, hopefully we'll fix that in the future.",
+ "apierror-stashinvalidfile": "{{doc-apierror}}",
+ "apierror-stashnosuchfilekey": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text. Currently this is probably English, hopefully we'll fix that in the future.",
+ "apierror-stashpathinvalid": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text. Currently this is probably English, hopefully we'll fix that in the future.",
+ "apierror-stashwrongowner": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text, which should already end with punctuation. Currently this is probably English, hopefully we'll fix that in the future.",
+ "apierror-stashzerolength": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text. Currently this is probably English, hopefully we'll fix that in the future.",
+ "apierror-systemblocked": "{{doc-apierror}}",
+ "apierror-templateexpansion-notwikitext": "{{doc-apierror}}\n\nParameters:\n* $1 - Page title.\n* $2 - Content model.",
+ "apierror-timeout": "{{doc-apierror}}\nAPI error message that can be used for client side localisation of API errors.",
+ "apierror-toofewexpiries": "{{doc-apierror}}\n\nParameters:\n* $1 - Number provided.\n* $2 - Number needed.",
+ "apierror-unknownaction": "{{doc-apierror}}\n\nParameters:\n* $1 - Action provided.",
+ "apierror-unknownerror-editpage": "{{doc-apierror}}\n\nParameters:\n* $1 - Error code (an integer).",
+ "apierror-unknownerror-nocode": "{{doc-apierror}}",
+ "apierror-unknownerror": "{{doc-apierror}}\n\nParameters:\n* $1 - Error code (possibly a message key) not handled by ApiBase::parseMsg().",
+ "apierror-unknownformat": "{{doc-apierror}}\n\nParameters:\n* $1 - Format provided.",
+ "apierror-unrecognizedparams": "{{doc-apierror}}\n\nParameters:\n* $1 - List of parameters.\n* $2 - Number of parameters.",
+ "apierror-unrecognizedvalue": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - Parameter value.",
+ "apierror-unsupportedrepo": "{{doc-apierror}}",
+ "apierror-upload-filekeyneeded": "{{doc-apierror}}",
+ "apierror-upload-filekeynotallowed": "{{doc-apierror}}",
+ "apierror-upload-inprogress": "{{doc-apierror}}",
+ "apierror-upload-missingresult": "{{doc-apierror}}",
+ "apierror-urlparamnormal": "{{doc-apierror}}\n\nParameters:\n* $1 - Image title.",
+ "apierror-writeapidenied": "{{doc-apierror}}",
+ "apiwarn-alldeletedrevisions-performance": "{{doc-apierror}}\n\nParameters:\n* $1 - Module parameter prefix, e.g. \"bl\".",
+ "apiwarn-badurlparam": "{{doc-apierror}}\n\nParameters:\n* $1 - Module parameter prefix, e.g. \"bl\".\n* $2 - Image title.",
+ "apiwarn-badutf8": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n{{doc-important|Do not translate \"\\t\", \"\\n\", and \"\\r\"}}",
+ "apiwarn-checktoken-percentencoding": "{{doc-apierror}}",
+ "apiwarn-compare-nocontentmodel": "{{doc-apierror}}\n\nParameters:\n* $1 - Content model being assumed.",
+ "apiwarn-deprecation-deletedrevs": "{{doc-apierror}}",
+ "apiwarn-deprecation-expandtemplates-prop": "{{doc-apierror}}",
+ "apiwarn-deprecation-httpsexpected": "{{doc-apierror}}",
+ "apiwarn-deprecation-login-botpw": "{{doc-apierror}}",
+ "apiwarn-deprecation-login-nobotpw": "{{doc-apierror}}",
+ "apiwarn-deprecation-login-token": "{{doc-apierror}}",
+ "apiwarn-deprecation-parameter": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
+ "apiwarn-deprecation-parse-headitems": "{{doc-apierror}}",
+ "apiwarn-deprecation-purge-get": "{{doc-apierror}}",
+ "apiwarn-deprecation-withreplacement": "{{doc-apierror}}\n\nParameters:\n* $1 - Query string fragment that is deprecated, e.g. \"action=tokens\".\n* $2 - Query string fragment to use instead, e.g. \"action=tokens\".",
+ "apiwarn-difftohidden": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number.\n\n\"r\" is short for \"revision\". You may translate it.",
+ "apiwarn-errorprinterfailed": "{{doc-apierror}}",
+ "apiwarn-errorprinterfailed-ex": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception message, which may already end in punctuation. Probably in English.",
+ "apiwarn-invalidcategory": "{{doc-apierror}}\n\nParameters:\n* $1 - Supplied category name.",
+ "apiwarn-invalidtitle": "{{doc-apierror}}\n\nParameters:\n* $1 - Supplied title.",
+ "apiwarn-invalidxmlstylesheetext": "{{doc-apierror}}",
+ "apiwarn-invalidxmlstylesheet": "{{doc-apierror}}",
+ "apiwarn-invalidxmlstylesheetns": "{{doc-apierror}}",
+ "apiwarn-moduleswithoutvars": "{{doc-apierror}}",
+ "apiwarn-notfile": "{{doc-apierror}}\n\nParameters:\n* $1 - Supplied file name.",
+ "apiwarn-nothumb-noimagehandler": "{{doc-apierror}}\n\nParameters:\n* $1 - File name.",
+ "apiwarn-parse-nocontentmodel": "{{doc-apierror}}\n\nParameters:\n* $1 - Content model being assumed.",
+ "apiwarn-parse-revidwithouttext": "{{doc-apierror}}",
+ "apiwarn-parse-titlewithouttext": "{{doc-apierror}}",
+ "apiwarn-redirectsandrevids": "{{doc-apierror}}",
+ "apiwarn-tokennotallowed": "{{doc-apierror}}\n\nParameters:\n* $1 - Token type being requested, typically named after the action requiring the token.",
+ "apiwarn-tokens-origin": "{{doc-apierror}}",
+ "apiwarn-toomanyvalues": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - Maximum number of values allowed.",
+ "apiwarn-truncatedresult": "{{doc-apierror}}\n\nParameters:\n* $1 - Size limit in bytes.",
+ "apiwarn-unclearnowtimestamp": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - Supplied value.",
+ "apiwarn-unrecognizedvalues": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - List of unknown values supplied.\n* $3 - Number of unknown values.",
+ "apiwarn-unsupportedarray": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
+ "apiwarn-urlparamwidth": "{{doc-apierror}}\n\nParameters:\n* $1 - Module parameter prefix, e.g. \"bl\".\n* $2 - Width being ignored.\n* $3 - Width being used.",
+ "apiwarn-validationfailed-badchars": "{{doc-apierror}}\n\nUsed with {{msg-mw|apiwarn-validationfailed}}.",
+ "apiwarn-validationfailed-badpref": "{{doc-apierror}}\n\nUsed with {{msg-mw|apiwarn-validationfailed}}.",
+ "apiwarn-validationfailed-cannotset": "{{doc-apierror}}\n\nUsed with {{msg-mw|apiwarn-validationfailed}}.",
+ "apiwarn-validationfailed-keytoolong": "{{doc-apierror}}\n\nUsed with {{msg-mw|apiwarn-validationfailed}}.\n\nParameters:\n* $1 - Maximum allowed key length in bytes.",
+ "apiwarn-validationfailed": "{{doc-apierror}}\n\nParameters:\n* $1 - User preference name.\n* $2 - Failure message, such as {{msg-mw|apiwarn-validationfailed-badpref}}. Probably already ends with punctuation",
+ "apiwarn-wgDebugAPI": "{{doc-apierror}}",
+ "api-feed-error-title": "Used as a feed item title when an error occurs in <kbd>action=feedwatchlist</kbd>.\n\nParameters:\n* $1 - API error code\n{{Identical|Error}}",
+ "api-usage-docref": "\n\nParameters:\n* $1 - URL of the API auto-generated documentation.",
+ "api-usage-mailinglist-ref": "{{doc-apierror}} Also used in the error response.",
+ "api-exception-trace": "\n\nParameters:\n* $1 - Exception class.\n* $2 - File from which the exception was thrown.\n* $3 - Line number from which the exception was thrown.\n* $4 - Exception backtrace.",
+ "api-credits-header": "Header for the API credits section in the API help output\n{{Identical|Credit}}",
+ "api-credits": "API credits text, displayed in the API help output"
+}
diff --git a/www/wiki/includes/api/i18n/ro.json b/www/wiki/includes/api/i18n/ro.json
new file mode 100644
index 00000000..30f4bdae
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ro.json
@@ -0,0 +1,19 @@
+{
+ "@metadata": {
+ "authors": [
+ "ANDROBETA"
+ ]
+ },
+ "apihelp-createaccount-param-email": "Adresa de e-mail a utilizatorului (opțional).",
+ "apihelp-createaccount-param-realname": "Numele real al utilizatorului (opțional).",
+ "apihelp-createaccount-param-mailpassword": "Dacă este setat la orice valoare, o parolă aleatoare va fi trimisă utilizatorului prin e-mail.",
+ "apihelp-delete-summary": "Șterge o pagină.",
+ "apihelp-delete-param-title": "Titlul paginii de șters. Nu poate fi folosit împreună cu <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID-ul paginii de șters. Nu poate fi folosit împreună cu <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Motivul ștergerii. Dacă nu e specificat, va fi folosit un motiv generat automat.",
+ "apihelp-disabled-summary": "Acest modul a fost dezactivat.",
+ "apihelp-edit-summary": "Creează și modifică pagini.",
+ "apihelp-edit-example-edit": "Modifică o pagină.",
+ "apihelp-expandtemplates-param-title": "Titlul paginii.",
+ "apihelp-expandtemplates-param-text": "Wikitext de convertit."
+}
diff --git a/www/wiki/includes/api/i18n/roa-tara.json b/www/wiki/includes/api/i18n/roa-tara.json
new file mode 100644
index 00000000..07064122
--- /dev/null
+++ b/www/wiki/includes/api/i18n/roa-tara.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Joetaras"
+ ]
+ },
+ "apihelp-block-param-reason": "Mutive pu blocche.",
+ "apihelp-createaccount-param-name": "Nome de l'utende.",
+ "apihelp-edit-param-text": "Vôsce.",
+ "apihelp-edit-example-edit": "Cange 'na pàgene"
+}
diff --git a/www/wiki/includes/api/i18n/ru.json b/www/wiki/includes/api/i18n/ru.json
new file mode 100644
index 00000000..a264737e
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ru.json
@@ -0,0 +1,1764 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mahairod",
+ "Okras",
+ "Eakarpov",
+ "Kaganer",
+ "Mariya",
+ "Дмитрий",
+ "WindEwriX",
+ "Ochilov",
+ "Nzeemin",
+ "INS Pirat",
+ "Macofe",
+ "Краснорядцева Елена",
+ "Iniquity",
+ "Лилиә",
+ "Айсар",
+ "Гизатуллина",
+ "MaxSem",
+ "Irus",
+ "MaxBioHazard",
+ "Kareyac",
+ "Mailman",
+ "Ping08",
+ "Ivan-r",
+ "Redredsonia",
+ "Alexey zakharenkov",
+ "Facenapalm"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Документация]]\n* [[mw:Special:MyLanguage/API:FAQ|ЧаВО]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Почтовая рассылка]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Новости API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Ошибки и запросы]\n</div>\n<strong>Статус:</strong> Все отображаемые на этой странице функции должны работать, однако API находится в статусе активной разработки и может измениться в любой момент. Подпишитесь на [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ почтовую рассылку mediawiki-api-announce], чтобы быть в курсе обновлений.\n\n<strong>Ошибочные запросы:</strong> Если API получает запрос с ошибкой, вернётся заголовок HTTP с ключом «MediaWiki-API-Error», после чего значение заголовка и код ошибки будут отправлены обратно и установлены в то же значение. Более подробную информацию см. [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Ошибки и предупреждения]].\n\n<strong>Тестирование:</strong> для удобства тестирования API-запросов, см. [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Действие, которое следует выполнить.",
+ "apihelp-main-param-format": "Формат вывода.",
+ "apihelp-main-param-maxlag": "Значение максимального отставания может использоваться, когда MediaWiki установлена на кластер из реплицируемых баз данных. Чтобы избежать ухудшения ситуации с отставанием репликации сайта, этот параметр может заставить клиента ждать, когда задержка репликации станет ниже указанного значения. В случае чрезмерной задержки возвращается код ошибки «<samp>maxlag</samp>» с сообщением «<samp>Waiting for $host: $lag seconds lagged</samp>».<br>См. подробнее на странице с описанием [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: параметра Maxlag]].",
+ "apihelp-main-param-smaxage": "Установить значение HTTP-заголовка Cache-Control <code>s-maxage</code> в заданное число секунд. Ошибки никогда не кэшируются.",
+ "apihelp-main-param-maxage": "Установить значение HTTP-заголовка Cache-Control <code>max-age</code> в заданное число секунд. Ошибки никогда не кэшируются.",
+ "apihelp-main-param-assert": "Проверить, что участник авторизован, если задано <kbd>user</kbd>, или что он имеет права бота, если задано <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Проверить, что ник текущего участника совпадает с заданным.",
+ "apihelp-main-param-requestid": "Любое заданное здесь значение будет включено в ответ. Может быть использовано для различения запросов.",
+ "apihelp-main-param-servedby": "Включить в результаты имя хоста, обработавшего запрос.",
+ "apihelp-main-param-curtimestamp": "Включить в результат временную метку.",
+ "apihelp-main-param-responselanginfo": "Включить языки, использованные для <var>uselang</var> и <var>errorlang</var>, в результат.",
+ "apihelp-main-param-origin": "При обращении к API с использованием кросс-доменного AJAX-запроса (CORS), задайте параметру значение исходного домена. Он должен быть включён в любой предварительный запрос и таким образом должен быть частью URI-запроса (не тела POST).\n\nДля аутентифицированных запросов он должен точно соответствовать одному из источников в заголовке <code>Origin</code>, так что он должен быть задан наподобие <kbd>https://ru.wikipedia.org</kbd> или <kbd>https://meta.wikimedia.org</kbd>. Если параметр не соответствует заголовку <code>Origin</code>, будет возвращён ответ с кодом ошибки 403. Если параметр соответствует заголовку <code>Origin</code>, и источник находится в белом списке, будут установлены заголовки <code>Access-Control-Allow-Origin</code> и <code>Access-Control-Allow-Credentials</code>.\n\nДля неаутентифицированных запросов укажите значение <kbd>*</kbd>. В результате заголовок <code>Access-Control-Allow-Origin</code> будет установлен, но <code>Access-Control-Allow-Credentials</code> примет значение <code>false</code> и все пользовательские данные будут ограничены.",
+ "apihelp-main-param-uselang": "Язык, используемый для перевода сообщений. Запрос «<kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>» с «<kbd>siprop=languages</kbd>» возвращает список кодов языков; укажите «<kbd>user</kbd>», чтобы использовать текущие языковые настройки участника, или «<kbd>content</kbd>» для использования основного языка этой вики.",
+ "apihelp-main-param-errorformat": "Формат, используемый для вывода текста предупреждений и ошибок.\n; plaintext: Вики-текст с удалёнными HTML-тегами и замещёнными мнемониками.\n; wikitext: Нераспарсенный вики-текст.\n; html: HTML.\n; raw: Ключ сообщения и параметры.\n; none: Без текстового вывода, только коды ошибок.\n; bc: Формат, используемый до MediaWiki 1.29. <var>errorlang</var> и <var>errorsuselocal</var> игнорируются.",
+ "apihelp-main-param-errorlang": "Язык, используемый для вывода предупреждений и сообщений об ошибках. Запрос «<kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>» с «<kbd>siprop=languages</kbd>» возвращает список кодов языков; укажите «<kbd>content</kbd>» для использования основного языка этой вики, или «<kbd>uselang</kbd>» для использования того же значения, что и в параметре «<var>uselang</var>».",
+ "apihelp-main-param-errorsuselocal": "Если задан, тексты ошибок будут использовать локально модифицированные сообщения из пространства имён {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "Блокировка участника.",
+ "apihelp-block-param-user": "Имя участника, IP-адрес или диапазон IP-адресов, которые вы хотите заблокировать. Нельзя использовать вместе с <var>$1userid</var>",
+ "apihelp-block-param-userid": "Идентификатор блокируемого участника. Нельзя использовать одновременно с <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Время истечения срока действия. Может быть относительными (например, <kbd>5 months</kbd> или <kbd>2 weeks</kbd>) или абсолютными (например, <kbd>2014-09-18T12:34:56Z</kbd>). Если задано <kbd>infinite</kbd>, <kbd>indefinite</kbd>, или <kbd>never</kbd>, блок никогда не истечёт.",
+ "apihelp-block-param-reason": "Причина блокировки.",
+ "apihelp-block-param-anononly": "Заблокировать только анонимных участников (т. е. запретить анонимные правки для этого IP-адреса).",
+ "apihelp-block-param-nocreate": "Запретить создание учётных записей.",
+ "apihelp-block-param-autoblock": "Автоматически блокировать последний использованный IP-адрес и все последующие, с которых будут совершаться попытки авторизации.",
+ "apihelp-block-param-noemail": "Запретить участнику отправлять электронную почту через интерфейс вики. (Требуется право <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Скрыть имя участника из журнала блокировок. (Требуется право <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Позволяет участникам редактировать их собственные страницы обсуждения (зависит от <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Если участник уже заблокирован, перезаписать существующую блокировку.",
+ "apihelp-block-param-watchuser": "Следить за страницей участника или IP-участника и страницей обсуждения.",
+ "apihelp-block-param-tags": "Изменить метки записи в журнале блокировок.",
+ "apihelp-block-example-ip-simple": "Заблокировать IP-адрес <kbd>192.0.2.5</kbd> на три дня с причиной <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Бессрочно заблокировать участника <kbd>Vandal</kbd> по причине <kbd>Vandalism</kbd> и предотвратить создание новых аккаунтов и отправку электронной почты.",
+ "apihelp-changeauthenticationdata-summary": "Смена параметров аутентификации для текущего участника.",
+ "apihelp-changeauthenticationdata-example-password": "Попытаться изменить текущий пароль участника на <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Проверить корректность токена из <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=token]]</kbd>.",
+ "apihelp-checktoken-param-type": "Тип проверяемого токена.",
+ "apihelp-checktoken-param-token": "Проверяемый токен.",
+ "apihelp-checktoken-param-maxtokenage": "Максимально допустимый возраст токена (в секундах).",
+ "apihelp-checktoken-example-simple": "Проверить корректность <kbd>csrf</kbd>-токена.",
+ "apihelp-clearhasmsg-summary": "Очистить флаг <code>hasmsg</code> для текущего участника.",
+ "apihelp-clearhasmsg-example-1": "Очистить флаг <code>hasmsg</code> для текущего участника.",
+ "apihelp-clientlogin-summary": "Вход в вики с помощью интерактивного потока.",
+ "apihelp-clientlogin-example-login": "Начать вход в вики в качестве участника <kbd>Example</kbd> с паролем <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Продолжить вход после ответа <samp>UI</samp> для двухфакторной авторизации, предоставив <kbd>987654</kbd> в качестве токена <var>OATHToken</var>.",
+ "apihelp-compare-summary": "Получение разницы между двумя страницами.",
+ "apihelp-compare-extended-description": "Номер версии, заголовок страницы, её идентификатор, текст, или относительная сноска должна быть задана как для «from», так и для «to».",
+ "apihelp-compare-param-fromtitle": "Заголовок первой сравниваемой страницы.",
+ "apihelp-compare-param-fromid": "Идентификатор первой сравниваемой страницы.",
+ "apihelp-compare-param-fromrev": "Первая сравниваемая версия.",
+ "apihelp-compare-param-fromtext": "Используйте этот текст вместо содержимого версии, заданной <var>fromtitle</var>, <var>fromid</var> или <var>fromrev</var>.",
+ "apihelp-compare-param-frompst": "Выполнить преобразование перед записью правки (PST) над <var>fromtext</var>.",
+ "apihelp-compare-param-fromcontentmodel": "Модель содержимого <var>fromtext</var>. Если не задана, будет угадана по другим параметрам.",
+ "apihelp-compare-param-fromcontentformat": "Формат сериализации содержимого <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "Заголовок второй сравниваемой страницы.",
+ "apihelp-compare-param-toid": "Идентификатор второй сравниваемой страницы.",
+ "apihelp-compare-param-torev": "Вторая сравниваемая версия.",
+ "apihelp-compare-param-torelative": "Использовать версию, относящуюся к определённой<var>fromtitle</var>, <var>fromid</var> или <var>fromrev</var> Все другие опции 'to' будут проигнорированы.",
+ "apihelp-compare-param-totext": "Используйте этот текст вместо содержимого версии, заданной <var>totitle</var>, <var>toid</var> или <var>torev</var>.",
+ "apihelp-compare-param-topst": "Выполнить преобразование перед записью правки (PST) над <var>totext</var>.",
+ "apihelp-compare-param-tocontentmodel": "Модель содержимого <var>totext</var>. Если не задана, будет угадана по другим параметрам.",
+ "apihelp-compare-param-tocontentformat": "Формат сериализации содержимого <var>totext</var>.",
+ "apihelp-compare-param-prop": "Какую информацию получить.",
+ "apihelp-compare-paramvalue-prop-diff": "HTML разницы.",
+ "apihelp-compare-paramvalue-prop-diffsize": "Размер HTML разницы в байтах.",
+ "apihelp-compare-paramvalue-prop-rel": "Идентификаторы предыдущей к 'from' и следующей за 'to' версий.",
+ "apihelp-compare-paramvalue-prop-ids": "Идентификаторы страниц и версий 'from' и 'to'.",
+ "apihelp-compare-paramvalue-prop-title": "Названия страниц для версий 'from' и 'to'.",
+ "apihelp-compare-paramvalue-prop-user": "Имя и идентификатор участника для версий 'from' и 'to'.",
+ "apihelp-compare-paramvalue-prop-comment": "Описания правок для версий 'from' и 'to'.",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "Распарсенные описания правок для версий 'from' и 'to'.",
+ "apihelp-compare-paramvalue-prop-size": "Размер версий 'from' и 'to'.",
+ "apihelp-compare-example-1": "Создать разницу между версиями 1 и 2.",
+ "apihelp-createaccount-summary": "Создание новой учётной записи.",
+ "apihelp-createaccount-param-preservestate": "Если запрос <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> возвращает true для <samp>hasprimarypreservedstate</samp>, то запросы, отмеченные как <samp>primary-required</samp>, должны быть пропущены. Если запрос возвращает непустое значение поля <samp>preservedusername</samp>, то это значение должно быть использовано в параметре <samp>username</var>.",
+ "apihelp-createaccount-example-create": "Начать создание участника <kbd>Example</kbd> с паролем <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Имя участника.",
+ "apihelp-createaccount-param-password": "Пароль (будет проигнорирован, если задан параметр <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Домен для внешней аутентификации (необязательно).",
+ "apihelp-createaccount-param-token": "Токен создания учётной записи, полученный в первом запросе.",
+ "apihelp-createaccount-param-email": "Адрес электронной почты участника (необязательно).",
+ "apihelp-createaccount-param-realname": "Настоящее имя участника (необязательно).",
+ "apihelp-createaccount-param-mailpassword": "При установке любого значения, случайный пароль будет выслан участнику по электронной почте.",
+ "apihelp-createaccount-param-reason": "Причина создания учетной записи для записи в журнал (необязательно).",
+ "apihelp-createaccount-param-language": "Языковой код, который будет установлен в качестве основного языка участника (необязательно, по умолчанию используется основной язык вики).",
+ "apihelp-createaccount-example-pass": "Создать участника <kbd>testuser</kbd> с паролем <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Создать участника <kbd>testmailuser</kbd> и прислать на электронную почту случайно сгенерированный пароль.",
+ "apihelp-cspreport-summary": "Используется браузерами, чтобы сообщать о нарушениях политики безопасности (CSP). Этот модуль никогда не должен использоваться, за исключением случаев автоматического использования совместимыми с CSP браузерами.",
+ "apihelp-cspreport-param-reportonly": "Отметить как доклад от политики мониторинга, не от принудительной политики",
+ "apihelp-cspreport-param-source": "Что сгенерировало заголовок SCP, вызвавший этот доклад",
+ "apihelp-delete-summary": "Удаление страницы.",
+ "apihelp-delete-param-title": "Заголовок удаляемой страницы. Нельзя использовать одновременно с <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Идентификатор удаляемой страницы. Нельзя использовать одновременно с <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Причина удаления. Если не задана, будет использована автоматически сгенерированная причина.",
+ "apihelp-delete-param-tags": "Изменить метки записи в журнале удалений.",
+ "apihelp-delete-param-watch": "Добавить страницу в список наблюдения текущего участника.",
+ "apihelp-delete-param-watchlist": "Безусловно добавить или удалить страницу из списка наблюдения текущего участника, использовать настройки или не менять наблюдение.",
+ "apihelp-delete-param-unwatch": "Удалить страницу из списка наблюдения текущего участника.",
+ "apihelp-delete-param-oldimage": "Название старого удаляемого изображения, предоставляемое [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Удалить <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Удалить <kbd>Main Page</kbd> с причиной <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Этот модуль был отключен.",
+ "apihelp-edit-summary": "Создание и редактирование страниц.",
+ "apihelp-edit-param-title": "Название редактируемой страницы. Нельзя использовать одновременно с <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Идентификатор редактируемой страницы. Нельзя использовать одновременно с <var>$1title</var>.",
+ "apihelp-edit-param-section": "Номер раздела. <kbd>0</kbd> для верхнего раздела, <kbd>new</kbd> для нового раздела.",
+ "apihelp-edit-param-sectiontitle": "Заголовок нового раздела.",
+ "apihelp-edit-param-text": "Содержимое страницы.",
+ "apihelp-edit-param-summary": "Описание изменений. Также является заголовком раздела, когда используется $1section=new, а $1sectiontitle не задано.",
+ "apihelp-edit-param-tags": "Изменить метки записи в истории изменений.",
+ "apihelp-edit-param-minor": "Малая правка.",
+ "apihelp-edit-param-notminor": "Не малая правка.",
+ "apihelp-edit-param-bot": "Пометить правку как сделанную ботом.",
+ "apihelp-edit-param-basetimestamp": "Временная метка редактируемой версии, используется для обнаружения конфликтов редактирования. Может быть получена посредством [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Метка времени начала редактирования, используется для обнаружения конфликтов редактирования. Необходимое значение может быть получено с помощью <var>[[Special:ApiHelp/main|curtimestamp]]</var> в начале редактирования (то есть, после загрузки содержимого редактируемой страницы).",
+ "apihelp-edit-param-recreate": "Игнорировать предупреждение о том, что страница была удалена во время редактирования.",
+ "apihelp-edit-param-createonly": "Не редактировать страницу, если она уже существует.",
+ "apihelp-edit-param-nocreate": "Выбрасывать ошибку, если страницы не существует.",
+ "apihelp-edit-param-watch": "Добавить страницу в список наблюдения текущего участника.",
+ "apihelp-edit-param-unwatch": "Удалить страницу из списка наблюдения текущего участника.",
+ "apihelp-edit-param-watchlist": "Безусловно добавить или удалить страницу из списка наблюдения текущего участника, использовать настройки или не менять наблюдение.",
+ "apihelp-edit-param-md5": "MD5-хеш параметра $1text, или конкатенации параметров $1prependtext и $1apendtext. Если задан, правка не будет выполнена, если хеш некорректен.",
+ "apihelp-edit-param-prependtext": "Добавить этот текст в начало страницы. Переопределяет $1text.",
+ "apihelp-edit-param-appendtext": "Добавить этот текст в конец страницы. Переопределяет $text.\n\nДля создания нового раздела, используйте $1section=new, а не этот параметр.",
+ "apihelp-edit-param-undo": "Отменить это изменение. Переопределяет $text, $1prependtext и $1appendtext.",
+ "apihelp-edit-param-undoafter": "Отменить все изменения от $1undo до данного. Если не задано, просто отменить одно изменение.",
+ "apihelp-edit-param-redirect": "Автоматически разрешать перенаправления.",
+ "apihelp-edit-param-contentformat": "Формат сериализации содержимого, используемый для введённого текста.",
+ "apihelp-edit-param-contentmodel": "Модель нового содержимого.",
+ "apihelp-edit-param-token": "Токен всегда должен быть послан в качестве последнего параметра, или, по крайней мере, после параметра $1text.",
+ "apihelp-edit-example-edit": "Редактировать страницу.",
+ "apihelp-edit-example-prepend": "Добавить магическое слово <kbd>_&#95;NOTOC_&#95;</kbd> в начало страницы.",
+ "apihelp-edit-example-undo": "Отменить изменения с 13579 по 13585 с автоматическим описанием правки.",
+ "apihelp-emailuser-summary": "Написание электронных писем участнику.",
+ "apihelp-emailuser-param-target": "Адресат электронного письма.",
+ "apihelp-emailuser-param-subject": "Заголовок темы.",
+ "apihelp-emailuser-param-text": "Содержание письма.",
+ "apihelp-emailuser-param-ccme": "Отправить копию этого сообщения мне.",
+ "apihelp-emailuser-example-email": "Отправить письмо участнику <kbd>WikiSysop</kbd> с текстом <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Разворачивание всех шаблонов в вики-текст.",
+ "apihelp-expandtemplates-param-title": "Заголовок страницы.",
+ "apihelp-expandtemplates-param-text": "Конвертируемый вики-текст.",
+ "apihelp-expandtemplates-param-revid": "Номер версии, для <code><nowiki>{{REVISIONID}}</nowiki></code> и аналогичных переменных.",
+ "apihelp-expandtemplates-param-prop": "Какую информацию включить.\n\nОбратите внимание, что если ни одно из значений не выбрано, результат будет содержать вики-текст, но вывод будет в устаревшем формате.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Расширенный вики-текст.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Любые категории, присутствующие во входных данных, но не попавшие в вики-текстовый результат.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Свойства страницы, определённые раскрытыми магическими словами в вики-тексте.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Является ли вывод нестабильным и следует ли отказаться от его повторного использования где-либо на странице.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Максимальное время, по прошествии которого кэш результата должен быть признан недействительным.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Любые модули ResourceLoader, запрашиваемые функциями парсера на добавление в результат. Одновременно с <kbd>modules</kbd> должен быть запрошен либо <kbd>jsconfigvars</kbd>, либо <kbd>encodedjsconfigvars</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Возвращает переменные JavaScript с данными настроек для этой страницы",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Возвращает переменные JavaScript с данными настроек для этой страницы в виде JSON-строки.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "Дерево парсинга XML входных данных.",
+ "apihelp-expandtemplates-param-includecomments": "Нужно ли включать комментарии HTML в результат.",
+ "apihelp-expandtemplates-param-generatexml": "Создать дерево парсинга XML (заменено $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Развернуть вики-текст <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Возвращает ленту с вкладом участников.",
+ "apihelp-feedcontributions-param-feedformat": "Формат ленты.",
+ "apihelp-feedcontributions-param-user": "Вклад каких участников получить.",
+ "apihelp-feedcontributions-param-namespace": "Вклад в каком пространстве имён показать.",
+ "apihelp-feedcontributions-param-year": "От года (и ранее).",
+ "apihelp-feedcontributions-param-month": "От месяца (и ранее).",
+ "apihelp-feedcontributions-param-tagfilter": "Показать вклад, содержащий данные метки.",
+ "apihelp-feedcontributions-param-deletedonly": "Показать только удалённые правки.",
+ "apihelp-feedcontributions-param-toponly": "Показать только правки, являющиеся последними версиями.",
+ "apihelp-feedcontributions-param-newonly": "Показать только правки, являющиеся созданием страниц.",
+ "apihelp-feedcontributions-param-hideminor": "Скрыть малые правки.",
+ "apihelp-feedcontributions-param-showsizediff": "Показать объём изменений между версиями.",
+ "apihelp-feedcontributions-example-simple": "Показать вклад участника <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Возвращает ленту последних изменений.",
+ "apihelp-feedrecentchanges-param-feedformat": "Формат ленты.",
+ "apihelp-feedrecentchanges-param-namespace": "Пространство имён, которым ограничить результат.",
+ "apihelp-feedrecentchanges-param-invert": "Все пространства имён, кроме выбранного.",
+ "apihelp-feedrecentchanges-param-associated": "Включить связанное (обсуждения или основное) пространство имён.",
+ "apihelp-feedrecentchanges-param-days": "Сколькими днями ограничить результат.",
+ "apihelp-feedrecentchanges-param-limit": "Максимальное число возвращаемых результатов.",
+ "apihelp-feedrecentchanges-param-from": "Показать изменения с тех пор.",
+ "apihelp-feedrecentchanges-param-hideminor": "Скрыть малые правки.",
+ "apihelp-feedrecentchanges-param-hidebots": "Скрыть правки ботов.",
+ "apihelp-feedrecentchanges-param-hideanons": "Скрыть правки анонимных участников.",
+ "apihelp-feedrecentchanges-param-hideliu": "Скрыть правки зарегистрированных участников.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Скрыть отпатрулированные правки.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Скрыть правки текущего участника.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Скрыть категоризацию страниц.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Фильтр по меткам.",
+ "apihelp-feedrecentchanges-param-target": "Показать только правки на страницах, на которые ссылается данная.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Показать правки на страницах, ссылающихся на данную.",
+ "apihelp-feedrecentchanges-param-categories": "Показать только правки на страницах, включённых во все данные категории.",
+ "apihelp-feedrecentchanges-param-categories_any": "Показать только правки на страницах, включённых в хотя бы одну из данных категорий.",
+ "apihelp-feedrecentchanges-example-simple": "Список последних изменений.",
+ "apihelp-feedrecentchanges-example-30days": "Список последних изменений за 30 дней.",
+ "apihelp-feedwatchlist-summary": "Возвращает ленту списка наблюдения.",
+ "apihelp-feedwatchlist-param-feedformat": "Формат ленты.",
+ "apihelp-feedwatchlist-param-hours": "Список страниц, отредактированных столько часов назад.",
+ "apihelp-feedwatchlist-param-linktosections": "Ссылаться прямо на разделы с изменениями, если возможно.",
+ "apihelp-feedwatchlist-example-default": "Показать ленту списка наблюдения.",
+ "apihelp-feedwatchlist-example-all6hrs": "Показать все изменения на наблюдаемых страницах за последние 6 часов.",
+ "apihelp-filerevert-summary": "Возвращение файла к старой версии.",
+ "apihelp-filerevert-param-filename": "Целевое имя файла без префикса «Файл:».",
+ "apihelp-filerevert-param-comment": "Комментарий загрузки.",
+ "apihelp-filerevert-param-archivename": "Архивное название возвращаемой версии.",
+ "apihelp-filerevert-example-revert": "Откат <kbd>Wiki.png</kbd> к версии от <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Отображение справки указанных модулей.",
+ "apihelp-help-param-modules": "Модули, справку которых необходимо отобразить (значения параметров <var>action</var> и <var>format</var>, или <kbd>main</kbd>). Можно указывать подмодули с помощью <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Включить справку подмодулей заданного модуля.",
+ "apihelp-help-param-recursivesubmodules": "Включить справку подмодулей рекурсивно.",
+ "apihelp-help-param-helpformat": "Формат вывода справки.",
+ "apihelp-help-param-wrap": "Обернуть вывод в стандартную структуру API-ответа.",
+ "apihelp-help-param-toc": "Включить содержание в вывод HTML.",
+ "apihelp-help-example-main": "Помощь по главному модулю.",
+ "apihelp-help-example-submodules": "Помощь по модулю <kbd>action=query</kbd> и его подмодулям.",
+ "apihelp-help-example-recursive": "Вся справка на одной странице.",
+ "apihelp-help-example-help": "Справка по самому модулю справки.",
+ "apihelp-help-example-query": "Справка по двум подмодулям query.",
+ "apihelp-imagerotate-summary": "Поворот одного или нескольких изображений.",
+ "apihelp-imagerotate-param-rotation": "На сколько градусов по часовой стрелке повернуть изображение.",
+ "apihelp-imagerotate-param-tags": "Изменить метки записи в журнале загрузок.",
+ "apihelp-imagerotate-example-simple": "Повернуть <kbd>File:Example.png</kbd> на <kbd>90</kbd> градусов.",
+ "apihelp-imagerotate-example-generator": "Повернуть все изображения в <kbd>Category:Flip</kbd> на <kbd>180</kbd> градусов.",
+ "apihelp-import-summary": "Импорт страницы из другой вики, или из XML-файла.",
+ "apihelp-import-extended-description": "Обратите внимание, что HTTP POST-запрос должен быть осуществлён как загрузка файла (то есть, с использованием многотомных данных) при отправки файла через параметр <var>xml</var>.",
+ "apihelp-import-param-summary": "Описание записи журнала импорта.",
+ "apihelp-import-param-xml": "Загруженный XML-файл.",
+ "apihelp-import-param-interwikisource": "Для импорта из других вики: импортируемая вики.",
+ "apihelp-import-param-interwikipage": "Для импорта из других вики: импортируемая страница.",
+ "apihelp-import-param-fullhistory": "Для импорта из других вики: импортировать полную историю, а не только текущую страницу.",
+ "apihelp-import-param-templates": "Для импорта из других вики: также импортировать все включённые шаблоны.",
+ "apihelp-import-param-namespace": "Импортировать в это пространство имён. Не может быть использовано одновременно с <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Импортировать в качестве подстраницы данной страницы. Не может быть использовано одновременно с <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Изменить метки записи в журнале импорта и нулевой правки в импортируемых страницах.",
+ "apihelp-import-example-import": "Импортировать [[meta:Help:ParserFunctions]] с полной историей правок в пространство имён 100.",
+ "apihelp-linkaccount-summary": "Связать аккаунт третьей стороны с текущим участником.",
+ "apihelp-linkaccount-example-link": "Начать связывание аккаунта с <kbd>Example</kdb>.",
+ "apihelp-login-summary": "Вход и получение аутентификационных cookie.",
+ "apihelp-login-extended-description": "Это действие должно быть использовано только в комбинации со [[Special:BotPasswords]]; использование этого модуля для входа в основной аккаунт не поддерживается и может сбиться без предупреждения. Для безопасного входа в основной аккаунт, используйте <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Это действие не поддерживается и может сбиться без предупреждения. Для безопасного входа, используйте <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "Имя участника.",
+ "apihelp-login-param-password": "Пароль.",
+ "apihelp-login-param-domain": "Домен (необязательно).",
+ "apihelp-login-param-token": "Токен входа, полученный при первом запросе.",
+ "apihelp-login-example-gettoken": "Получить токен входа.",
+ "apihelp-login-example-login": "Войти",
+ "apihelp-logout-summary": "Выйти и очистить данные сессии.",
+ "apihelp-logout-example-logout": "Выйти из текущего участника.",
+ "apihelp-managetags-summary": "Осуществление задач, связанных с изменением меток.",
+ "apihelp-managetags-param-operation": "Какую операцию выполнить:\n;create: Создать новую метку для ручного использования.\n;delete: Удалить метку из базы данных, что включает в себя удаление метки со всех версий и записей журналов, где она использовалось.\n;activate: Активировать изменение метки, позволив участникам устанавливать её вручную.\n;deactivate: Деактивировать изменение метки, запретив участникам устанавливать её вручную.",
+ "apihelp-managetags-param-tag": "Создаваемая, удаляемая, активируемая или деактивируемая метка. Создаваемая метка должна не существовать. Удаляемая метка должна существовать. Активируемая метка должна существовать и не быть использованной в каком-либо расширении. Деактивируемая метка должна существовать и быть заданной вручную.",
+ "apihelp-managetags-param-reason": "Причина создания, удаления, активирования или деактивирования метки (необязательно).",
+ "apihelp-managetags-param-ignorewarnings": "Игнорировать ли все предупреждения, возникающие во время операции.",
+ "apihelp-managetags-param-tags": "Изменить метки записи в журнале управления метками.",
+ "apihelp-managetags-example-create": "Создать метку с названием <kbd>spam</kbd> с причиной <kbd>For use in edit patrolling</kbd>.",
+ "apihelp-managetags-example-delete": "Удалить метку <kbd>vandlaism</kbd> с причиной <kbd>Misspelt</kbd>.",
+ "apihelp-managetags-example-activate": "Активировать метку <kbd>spam</kbd> с причиной <kbd>For use in edit patrolling</kbd>.",
+ "apihelp-managetags-example-deactivate": "Деактивировать метку <kbd>spam</kbd> с причиной <kbd>No longer required</kbd>.",
+ "apihelp-mergehistory-summary": "Объединение историй правок.",
+ "apihelp-mergehistory-param-from": "Название страницы, история из которой будет объединяться. Не может быть использовано одновременно с <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "Идентификатор страницы, история из которой будет объединяться. Не может быть использовано одновременно с <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Название страницы, в историю которой будет добавлено объединяемое. Не может быть использовано одновременно с <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "Идентификатор страницы, в историю которой будет добавлено объединяемое. Не может быть использовано одновременно с <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "Временная метка, до которой версии будут перемещены из истории страницы-источника в историю целевой страницы. Если опущено, в целевую страницу будет перемещена вся история правок страницы-источника.",
+ "apihelp-mergehistory-param-reason": "Причина для объединения истории.",
+ "apihelp-mergehistory-example-merge": "Переместить всю историю правок страницы <kbd>Oldpage</kbd> на страницу <kbd>Newpage</kdb>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Переместить историю правок из <kbd>Oldpage</kbd>, совершённых до <kbd>2015-12-31T04:37:41Z</kbd>, на страницу <kbd>Newpage</kdb>.",
+ "apihelp-move-summary": "Переименование страницы.",
+ "apihelp-move-param-from": "Название переименовываемой страницы. Нельзя использовать одновременно с <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "Идентификатор переименовываемой страницы. Нельзя использовать одновременно с <var>$1from</var>.",
+ "apihelp-move-param-to": "Новое название страницы.",
+ "apihelp-move-param-reason": "Причина переименования.",
+ "apihelp-move-param-movetalk": "Переименовать страницу обсуждения, если она есть.",
+ "apihelp-move-param-movesubpages": "Переименовать подстраницы, если это применимо.",
+ "apihelp-move-param-noredirect": "Не создавать перенаправление.",
+ "apihelp-move-param-watch": "Добавить страницу и перенаправление в список наблюдения текущего участника.",
+ "apihelp-move-param-unwatch": "Удалить страницу и перенаправление из списка наблюдения текущего участника.",
+ "apihelp-move-param-watchlist": "Безусловно добавить или удалить страницу из списка наблюдения текущего участника, использовать настройки или не менять наблюдение.",
+ "apihelp-move-param-ignorewarnings": "Игнорировать все предупреждения.",
+ "apihelp-move-param-tags": "Изменить метки записи в журнале переименований и нулевой правки на переименованной странице.",
+ "apihelp-move-example-move": "Переименовать <kbd>Badtitle</kbd> в <kbd>Goodtitle</kbd> без оставления перенаправления.",
+ "apihelp-opensearch-summary": "Поиск по вики с использованием протокола OpenSearch.",
+ "apihelp-opensearch-param-search": "Строка поиска.",
+ "apihelp-opensearch-param-limit": "Максимальное число возвращаемых результатов.",
+ "apihelp-opensearch-param-namespace": "Пространства имён для поиска.",
+ "apihelp-opensearch-param-suggest": "Ничего не делать, если <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> ложно.",
+ "apihelp-opensearch-param-redirects": "Как обрабатывать перенаправления:\n;return: Вернуть само перенаправление.\n;resolve: Вернуть целевую страницу. Может вернуть меньше $1limit результатов.\nПо историческим причинам значением по умолчанию является «return» для $1format=json и «resolve» для остальных форматов.",
+ "apihelp-opensearch-param-format": "Формат вывода.",
+ "apihelp-opensearch-param-warningsaserror": "Если предупреждения возникают при <kbd>format=json</kbd>, вернуть ошибку API вместо того, чтобы игнорировать их.",
+ "apihelp-opensearch-example-te": "Найти страницы, начинающиеся с <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Смена настроек текущего участника.",
+ "apihelp-options-extended-description": "Менять можно только настройки, зарегистрированные в ядре или в одном из установленных расширений, а также настройки, чьи ключи начинаются с <code>userjs-</code> (предназначенные для использования в пользовательских скриптах).",
+ "apihelp-options-param-reset": "Сбрасывает настройки на установленные по умолчанию.",
+ "apihelp-options-param-resetkinds": "Список типов сбрасываемых настроек, если задана опция <var>$1reset</var>.",
+ "apihelp-options-param-change": "Список изменений в формате название=значение (например, skin=vector). Если значения не даётся (нет даже знака равенства), например, названиенастройки|другаянастройка|, настройка будет возвращена в своё значение по умолчанию. Если какое-либо значение должно содержать знак пайпа (<kbd>|</kbd>), используйте [[Special:ApiHelp/main#main/datatypes|альтернативный разделитель значений]] для корректного проведения операции.",
+ "apihelp-options-param-optionname": "Название настройки, которая должна быть установлена в значение, переданное через <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "Значение настройки, заданной <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Сбросить все настройки.",
+ "apihelp-options-example-change": "Изменить настройки <kbd>skin</kbd> и <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Сбросить все настройки, а затем изменить <kbd>skin</kbd> и <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Получение информации о модулях API.",
+ "apihelp-paraminfo-param-modules": "Список названий модулей (значения параметров <var>action</var> и <var>format</var>, или <kbd>main</kbd>). Можно указать подмодули с помощью <kbd>+</kbd>, все подмодули с помощью <kbd>+*</kbd>, или все подмодули рекурсивно с помощью <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Формат строк справки.",
+ "apihelp-paraminfo-param-querymodules": "Список модулей query (значения параметров <var>prop</var>, <var>meta</var> или <var>list</var>). Используйте <kbd>$1modules=query+foo</kbd> вместо <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Также получить информацию о главном модуле. Вместо этого используйте <kbd>$1modules=main</kbd>.",
+ "apihelp-paraminfo-param-pagesetmodule": "Также получить информацию о модуле pageset (предоставляющем titles= и синонимы).",
+ "apihelp-paraminfo-param-formatmodules": "Список названий форматных модулей (значения параметра <var>format</var>). Вместо этого используйте <var>$1modules</var>.",
+ "apihelp-paraminfo-example-1": "Показать информацию для <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>, и <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "Показать информацию для всех подмодулей <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-summary": "Парсит содержимое и возвращает результат парсинга.",
+ "apihelp-parse-extended-description": "См. различные prop-модули <kbd>[[Special:ApiHelp/query|action=query]]</kbd> для получения информации о текущей версии страницы.\n\nЕсть несколько способов указать текст для парсинга:\n# Указать страницы или версию, используя <var>$1page</var>, <var>$1pageid</var> или <var>$1oldid</var>.\n# Явно указать содержимое, используя <var>$1text</var>, <var>$1title</var> и <var>$1contentmodel</var>.\n# Указать описание правки. Параметру <var>$1prop</var> должно быть присвоено пустое значение.",
+ "apihelp-parse-param-title": "Название страницы, которой принадлежит текст. Если опущено, должен быть указан параметр <var>$1contentmodel</var>, и в качестве заголовка будет использовано [[API]].",
+ "apihelp-parse-param-text": "Распарсиваемый текст. Используйте <var>$1title</var> или <var>$1contentmodel</var> для управления моделью содержимого.",
+ "apihelp-parse-param-summary": "Анализируемое описание правки.",
+ "apihelp-parse-param-page": "Распарсить содержимое этой страницы. Не может быть использовано совместно с <var>$1text</var> и <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Анализировать содержимое этой страницы. Переопределяет <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "Если значением <var>$1page</var> или <var>$1pageid</var> указано перенаправление, разрешить его.",
+ "apihelp-parse-param-oldid": "Распарсить содержимое этой версии. Переопределяет <var>$1page</var> и <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "Какую информацию включить:",
+ "apihelp-parse-paramvalue-prop-text": "Возвращает текст распарсенного вики-текста.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Возвращает языковые ссылки из распарсенного вики-текста.",
+ "apihelp-parse-paramvalue-prop-categories": "Возвращает категории из проанализированного вики-текста.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Возвращает HTML-версию категорий.",
+ "apihelp-parse-paramvalue-prop-links": "Возвращает внутренние ссылки из распарсенного вики-текста.",
+ "apihelp-parse-paramvalue-prop-templates": "Возвращает шаблоны из проанализированного вики-текста.",
+ "apihelp-parse-paramvalue-prop-images": "Возвращает изображения из распарсенного вики-текста.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Возвращает внешние ссылки из распарсенного вики-текста.",
+ "apihelp-parse-paramvalue-prop-sections": "Возвращает разделы из проанализированного вики-текста.",
+ "apihelp-parse-paramvalue-prop-revid": "Добавляет идентификатор версии распарсенной страницы.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Добавляет название проанализированного вики-текста.",
+ "apihelp-parse-paramvalue-prop-headitems": "Возвращает элементы, которые следует поместить в <code>&lt;head&gt;</code> страницы.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Возвращает распарсенный <code>&lt;head&gt;</code> страницы.",
+ "apihelp-parse-paramvalue-prop-modules": "Возвращает использованные на странице модули ResourceLoader. Для загрузки, используйте <code>mw.loader.using()</code>. Одновременно с <kbd>modules</kbd> должно быть запрошено либо <kbd>jsconfigvars</kbd>, либо <kbd>encodedjsconfigvars</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Возвращает переменные JavaScript с данными настроек для этой страницы. Для их применения используйте <code>mw.condig.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Возвращает переменные JavaScript с данными настроек для этой страницы в виде JSON-строки.",
+ "apihelp-parse-paramvalue-prop-indicators": "Возвращает HTML-код индикаторов, использованных на данной странице.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Возвращает интервики-ссылки из распарсенного вики-текста.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Возвращает исходный распарсиваемый вики-текст.",
+ "apihelp-parse-paramvalue-prop-properties": "Возвращает различные свойства, объявленные в проанализированном вики-тексте.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Возвращает структурированный отчёт о лимите. Не возвращает данных, если задан <var>$1disablelimitreport</var>.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Возвращает HTML-версию отчёта о лимите. Не возвращает данных, если задан <var>$1disablelimitreport</var>.",
+ "apihelp-parse-paramvalue-prop-parsetree": "Дерево парсинга XML содержимого версии (требуется модель содержимого <code>$1</code>).",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Возвращает предупреждения, возникшие во время анализа.",
+ "apihelp-parse-param-wrapoutputclass": "CSS-класс, используемый для оборачивания вывода парсера.",
+ "apihelp-parse-param-pst": "Выполнить преобразование перед записью правки (PST) до того, как начать анализировать текст. Доступно только когда используется с текстом.",
+ "apihelp-parse-param-onlypst": "Выполнить преобразование перед записью правки (PST) входных данных, но не парсить их. Возвращает тот же вики-текст после применения PST. Доступно только при применении с <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "Включает языковые ссылки, обеспечиваемые расширениями (для использования с <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "Распарсить содержимое только секции с заданным номером.\n\nЕсли задан <kbd>new</kbd>, распарсить <var>$1text</var> и <var>$1sectiontitle</var> так, как будто добавлена новая секция на страницу.\n\n<kbd>new</kbd> доступен только при заданном <var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "Название новой секции, когда <var>section</var> имеет значение <kbd>new</kbd>.\n\nВ отличие от редактирования страницы, оно не примет значение параметра <var>summary</var>, если опустить его или оставить пустым.",
+ "apihelp-parse-param-disablelimitreport": "Опустить отчёт о лимите («NewPP limit report») из результата парсинга.",
+ "apihelp-parse-param-disablepp": "Вместо этого используйте <var>$1disablelimitreport</var>.",
+ "apihelp-parse-param-disableeditsection": "Опустить ссылки на редактирование разделов из результата парсинга.",
+ "apihelp-parse-param-disabletidy": "Не проводить очистку HTML (например, с помощью tidy) результатов парсинга.",
+ "apihelp-parse-param-generatexml": "Сгенерировать дерево парсинга XML (требуется модель содержимого <code>$1</code>, замещено <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "Проанализировать в режиме препросмотра.",
+ "apihelp-parse-param-sectionpreview": "Распарсить в режиме предпросмотра раздела (также активирует режим предпросмотра).",
+ "apihelp-parse-param-disabletoc": "Не включать в вывод содержание.",
+ "apihelp-parse-param-useskin": "Применить выбранную тему оформления к результату работы парсера. Может затронуть следующие свойства: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>modules</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicators</kbd>.",
+ "apihelp-parse-param-contentformat": "Формат сериализации содержимого, использующийся во входном тексте. Доступен только при использовании с $1text.",
+ "apihelp-parse-param-contentmodel": "Модель содержимого входного текста. Если пропущена, должен быть задан $1title, и значение по умолчанию будет взято в зависимости от указанного названия. Доступно только при использовании с $1text.",
+ "apihelp-parse-example-page": "Парсинг страницы.",
+ "apihelp-parse-example-text": "Анализ вики-текста.",
+ "apihelp-parse-example-texttitle": "Парсинг вики-текста с заданным заголовком страницы.",
+ "apihelp-parse-example-summary": "Анализ описания правки.",
+ "apihelp-patrol-summary": "Патрулирование страницы или версии.",
+ "apihelp-patrol-param-rcid": "Идентификатор патрулируемой последней правки.",
+ "apihelp-patrol-param-revid": "Идентификатор патрулируемой версии.",
+ "apihelp-patrol-param-tags": "Изменить метки записи в журнале патрулирования.",
+ "apihelp-patrol-example-rcid": "Патрулировать недавние изменения.",
+ "apihelp-patrol-example-revid": "Отпатрулировать версию.",
+ "apihelp-protect-summary": "Изменение уровня защиты страницы.",
+ "apihelp-protect-param-title": "Название (раз)защищаемой страницы. Не может использоваться одновременно с $1pageid.",
+ "apihelp-protect-param-pageid": "Идентификатор (раз)защищаемой страницы. Не может использоваться одновременно с $1title.",
+ "apihelp-protect-param-protections": "Список уровней защиты в формате <kbd>действие=уровень</kbd> (например, <kbd>edit=sysop</kbd>). Уровень <kbd>all</kbd> означает, что кто угодно может осуществлять действие, то есть, нет ограничений.\n\n<strong>Примечания:</strong> Все неперечисленные действия потеряют уровни защиты.",
+ "apihelp-protect-param-expiry": "Временная метка истечения защиты. Если задана только одна метка, она будет использована для всех защит. Используйте <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> или <kbd>never</kbd> для бессрочных защит.",
+ "apihelp-protect-param-reason": "Причина (раз)защиты.",
+ "apihelp-protect-param-tags": "Изменить метки записи в журнале защиты.",
+ "apihelp-protect-param-cascade": "Активировать каскадную защиту (то есть, защитить включённые шаблоны и использованные изображения). Игнорируется, если ни один из заданных уровней не поддерживает каскадную защиту.",
+ "apihelp-protect-param-watch": "Если задан, добавить (раз)защищаемую страницу в список наблюдения текущего участника.",
+ "apihelp-protect-param-watchlist": "Безусловно добавить или удалить страницу из списка наблюдения текущего участника, использовать настройки или не менять наблюдение.",
+ "apihelp-protect-example-protect": "Защитить страницу.",
+ "apihelp-protect-example-unprotect": "Снять защиту страницы, установив ограничения <kbd>all</kbd> (то есть, позволив всем проводить действия над страницей).",
+ "apihelp-protect-example-unprotect2": "Снять защиту страницу, не указав ограничений.",
+ "apihelp-purge-summary": "Очистка кэша заданных страниц.",
+ "apihelp-purge-param-forcelinkupdate": "Обновить таблицы ссылок.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Обновить таблицу ссылок для данной страницы, а также всех страниц, использующих данную как шаблон.",
+ "apihelp-purge-example-simple": "Очистить кэш для страниц <kbd>Main Page</kbd> и <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "Очистить кэш первых 10 страниц в основном пространстве имен.",
+ "apihelp-query-summary": "Запросить данные с и о MediaWiki.",
+ "apihelp-query-extended-description": "Все модификации данных сначала должны запросить соответствующий токен для предотвращения злоупотреблений с вредоносных сайтов.",
+ "apihelp-query-param-prop": "Какие использовать свойства для запрашиваемых страниц.",
+ "apihelp-query-param-list": "Какие списки использовать.",
+ "apihelp-query-param-meta": "Какие метаданные использовать.",
+ "apihelp-query-param-indexpageids": "Включить дополнительную секцию pageids, содержащую список идентификаторов всех возвращённых страниц.",
+ "apihelp-query-param-export": "Экспортировать текущую версию для всех данных или сгенерированных страниц.",
+ "apihelp-query-param-exportnowrap": "Вернуть экспортируемый XML без оборачивания его в XML-результат (тот же формат, что и в [[Special:Export]]). Можно использовать только одновременно с $1export.",
+ "apihelp-query-param-iwurl": "Возвращать ли полную ссылку, если названием является интервики-ссылка.",
+ "apihelp-query-param-rawcontinue": "Вернуть сырые данные в <samp>query-continue</samp> для продолжения.",
+ "apihelp-query-example-revisions": "Получить [[Special:ApiHelp/query+siteinfo|site info]] и [[Special:ApiHelp/query+revisions|последнее изменение]] для <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "Получить последнее изменение для страниц, начиная с <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "Перечисление всех категорий.",
+ "apihelp-query+allcategories-param-from": "Категория, с которой начать перечисление.",
+ "apihelp-query+allcategories-param-to": "Категория, на которой закончить перечисление.",
+ "apihelp-query+allcategories-param-prefix": "Найти все названия категорий, начинающиеся с этого значения.",
+ "apihelp-query+allcategories-param-dir": "Порядок сортировки.",
+ "apihelp-query+allcategories-param-min": "Вернуть только категории, в которых не меньше заданного числа страниц.",
+ "apihelp-query+allcategories-param-max": "Вернуть только категории, в которых не больше заданного числа страниц.",
+ "apihelp-query+allcategories-param-limit": "Сколько категорий вернуть.",
+ "apihelp-query+allcategories-param-prop": "Какие свойства получить:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Добавляет количество страниц в категории.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Отмечает категории, скрытые магическим словом <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Составить список категорий с информацией о числе страниц в каждой из них.",
+ "apihelp-query+allcategories-example-generator": "Получить информацию о самой странице категории для категорий, начинающихся с <kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "Перечисление всех удалённых версий указанного участника или в указанном пространстве имён.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Может быть использовано только одновременно с <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Не может быть использовано одновременно с <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "Временная метка, с которой начать перечисление.",
+ "apihelp-query+alldeletedrevisions-param-end": "Временная метка, на которой закончить перечисление.",
+ "apihelp-query+alldeletedrevisions-param-from": "Начать перечисление на этом заголовке.",
+ "apihelp-query+alldeletedrevisions-param-to": "Закончить перечисление на этом заголовке.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Найти все названия страниц, начинающиеся с этого значения.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Только правки с заданной меткой.",
+ "apihelp-query+alldeletedrevisions-param-user": "Только правки данного участника.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Не перечислять правки данного участника.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Перечислять только страницы этого пространства имён.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>Примечание:</strong> Из-за [[mw:Special:MyLanguage/Manual:$wgMiserMode|жадного режима]] одновременное использование <var>$1user</var> и <var>$1namespace</var> может привести к меньшему, чем <var>$1limit</var>, числу результатов перед продолжением; в крайнем случае, может вернуться и ноль результатов.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "При использовании в качестве генератора, генерирует названия страниц вместо идентификаторов версий.",
+ "apihelp-query+alldeletedrevisions-example-user": "Перечислить последние 50 удалённых правок участника <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Перечислить первые 50 удалённых правок в основном пространстве.",
+ "apihelp-query+allfileusages-summary": "Перечисление всех использований файлов, в том числе несуществующих.",
+ "apihelp-query+allfileusages-param-from": "Название файла, с которого начать перечисление.",
+ "apihelp-query+allfileusages-param-to": "Название файла, на котором закончить перечисление.",
+ "apihelp-query+allfileusages-param-prefix": "Найти все названия файлов, начинающиеся с этого значения.",
+ "apihelp-query+allfileusages-param-unique": "Показывать только уникальные названия файлов. Не может быть использовано одновременно с $1prop=ids.\nПри использовании в качестве генератора, перечисляет целевые страницы вместо исходных.",
+ "apihelp-query+allfileusages-param-prop": "Какую информацию включить:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Добавляет идентификаторы страниц, использующих файл (не может быть использовано одновременно с $1unique).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Добавляет название файла.",
+ "apihelp-query+allfileusages-param-limit": "Сколько элементов вернуть.",
+ "apihelp-query+allfileusages-param-dir": "Порядок перечисления.",
+ "apihelp-query+allfileusages-example-B": "Список названий файлов, включая несуществующих, с идентификаторами использующих их страниц, начиная с <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Список уникальных названий файлов.",
+ "apihelp-query+allfileusages-example-unique-generator": "Список всех названий файлов с отметкой несуществующих.",
+ "apihelp-query+allfileusages-example-generator": "Список страниц, содержащих файлы.",
+ "apihelp-query+allimages-summary": "Перечисление всех файлов.",
+ "apihelp-query+allimages-param-sort": "Свойство для сортировки.",
+ "apihelp-query+allimages-param-dir": "Порядок перечисления.",
+ "apihelp-query+allimages-param-from": "Название изображения, с которого начать перечисление. Можно использовать только одновременно с $1sort=name.",
+ "apihelp-query+allimages-param-to": "Название изображения, на котором закончить перечисление. Можно использовать только одновременно с $1sort=name.",
+ "apihelp-query+allimages-param-start": "Временная метка, с которой начать перечисление. Можно использовать только одновременно с $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "Временная метка, на которой закончить перечисление. Можно использовать только одновременно с $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "Найти все названия файлов, начинающиеся с этого значения. Можно использовать только одновременно с $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "Ограничить изображения этим числом байтов снизу.",
+ "apihelp-query+allimages-param-maxsize": "Ограничить изображения этим числом байтов сверху.",
+ "apihelp-query+allimages-param-sha1": "SHA1-хэш этого изображения. Переопределяет $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "SHA1-хэш этого изображения в base 36 (используется в MediaWiki).",
+ "apihelp-query+allimages-param-user": "Вернуть только файлы, загруженные этим участником. Может быть использовано только одновременно с $1sort=timestamp и не может одновременно с $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "Как отфильтровывать файлы, загруженные ботами. Может быть использовано только одновременно с $1sort=timestamp и не может одновременно с $1user.",
+ "apihelp-query+allimages-param-mime": "Какие типы MIME искать, например, <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "Сколько изображений вернуть.",
+ "apihelp-query+allimages-example-B": "Показать список файлов, начиная с буквы <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Показать список недавно загруженных файлов, аналогично [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Показать список файлов с MIME-типом <kbd>image/png</kbd> или <kbd>image/gif</kbd>.",
+ "apihelp-query+allimages-example-generator": "Показать информацию о 4 файлах, начиная с буквы <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Перечисление всех ссылок, указывающих на заданное пространство имён.",
+ "apihelp-query+alllinks-param-from": "Название ссылки, с которой начать перечисление.",
+ "apihelp-query+alllinks-param-to": "Название ссылки, на которой закончить перечисление.",
+ "apihelp-query+alllinks-param-prefix": "Найти все названия ссылаемых страниц, начинающиеся с этого значения.",
+ "apihelp-query+alllinks-param-unique": "Показывать только уникальные названия ссылаемых страниц. Не может быть использовано одновременно с $1prop=ids.\nПри использовании в качестве генератора, перечисляет целевые страницы вместо исходных.",
+ "apihelp-query+alllinks-param-prop": "Какую информацию включить:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Добавляет идентификатор ссылаемой страницы (не может быть использовано одновременно с <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Добавляет название ссылки.",
+ "apihelp-query+alllinks-param-namespace": "Пространство имён для перечисления.",
+ "apihelp-query+alllinks-param-limit": "Сколько элементов вернуть.",
+ "apihelp-query+alllinks-param-dir": "Порядок перечисления.",
+ "apihelp-query+alllinks-example-B": "Список заголовков ссылаемых страниц, включая несуществующих, с идентификаторами страниц, ссылающихся на них, начиная с <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Список уникальных названий ссылаемых страниц.",
+ "apihelp-query+alllinks-example-unique-generator": "Список всех ссылаемых страниц с отметкой несуществующих.",
+ "apihelp-query+alllinks-example-generator": "Список страниц, содержащих ссылки.",
+ "apihelp-query+allmessages-summary": "Возвращает сообщения с этого сайта.",
+ "apihelp-query+allmessages-param-messages": "Какие сообщения выводить. <kbd>*</kbd> (по умолчанию) означает «все сообщения».",
+ "apihelp-query+allmessages-param-prop": "Какие свойства получить:",
+ "apihelp-query+allmessages-param-enableparser": "Установите, чтобы активировать парсер, который будет обрабатывать вики-текст сообщений (подставлять магические слова, обрабатывать шаблоны, и так далее).",
+ "apihelp-query+allmessages-param-nocontent": "Если установлен, ничего не делать с содержимым сообщений перед выводом.",
+ "apihelp-query+allmessages-param-includelocal": "Также включить локальные сообщения, то есть, сообщения, не существующие в программе, но существующие в пространстве имён {{ns:MediaWiki}}. Это перечислит все страницы из пространства {{ns:MediaWiki}}, поэтому в результат также могут попасть страницы, не являющимися сообщениями — например, [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Аргументы, подставляемые в сообщение.",
+ "apihelp-query+allmessages-param-filter": "Вернуть только сообщения, чьи названия содержат эту подстроку.",
+ "apihelp-query+allmessages-param-customised": "Вернуть только сообщения в этом состоянии кастомизации.",
+ "apihelp-query+allmessages-param-lang": "Вернуть сообщения на этом языке.",
+ "apihelp-query+allmessages-param-from": "Вернуть сообщения, начиная с данного.",
+ "apihelp-query+allmessages-param-to": "Вернуть сообщения, заканчивая на данном.",
+ "apihelp-query+allmessages-param-title": "Название страницы, используемой для контекста при анализе сообщения (для опции $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "Вернуть сообщения с заданным префиксом.",
+ "apihelp-query+allmessages-example-ipb": "Показать сообщения, начинающиеся с <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Показать сообщения <kbd>august</kbd> и <kbd>mainpage</kbd> на немецком языке.",
+ "apihelp-query+allpages-summary": "Перечисление всех страниц в данном пространстве имён.",
+ "apihelp-query+allpages-param-from": "Название страницы, с которой начать перечисление.",
+ "apihelp-query+allpages-param-to": "Название страницы, на которой закончить перечисление.",
+ "apihelp-query+allpages-param-prefix": "Найти все названия страниц, начинающиеся с этого значения.",
+ "apihelp-query+allpages-param-namespace": "Пространство имён для перечисления.",
+ "apihelp-query+allpages-param-filterredir": "Какие страницы перечислять.",
+ "apihelp-query+allpages-param-minsize": "Ограничить страницы этим числом байтов снизу.",
+ "apihelp-query+allpages-param-maxsize": "Ограничить страницы этим числом байтов сверху.",
+ "apihelp-query+allpages-param-prtype": "Перечислить только защищённые страницы.",
+ "apihelp-query+allpages-param-prlevel": "Отфильтровывать страницы, основываясь на уровне защиты (должно быть использовано одновременно с параметром $1prtype=).",
+ "apihelp-query+allpages-param-prfiltercascade": "Отфильтровывать страницы, основываясь на каскадности (игнорируется, если $1prtype не задан).",
+ "apihelp-query+allpages-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+allpages-param-dir": "Порядок перечисления.",
+ "apihelp-query+allpages-param-filterlanglinks": "Отфильтровывать страницы, основываясь на наличие на странице языковых ссылок. Обратите внимание: языковые ссылки, добавленные расширениями, могут не учитываться.",
+ "apihelp-query+allpages-param-prexpiry": "Отфильтровывать страницы, основываясь на длительности защиты:\n;indefinite: Получить только страницы с неограниченной защитой.\n;definite: Получить только страницы с заданной длительностью защиты.\n;all: Получить страницы с любой длительностью защиты.",
+ "apihelp-query+allpages-example-B": "Показать список страниц, начиная с буквы <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Получить информацию о четырёх страницах, начиная с буквы <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Показать содержимое первых двух страниц, не являющихся перенаправлениями, начиная с <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Перечисление всех перенаправлений на заданное пространство имён.",
+ "apihelp-query+allredirects-param-from": "Название перенаправления, с которого начать перечисление.",
+ "apihelp-query+allredirects-param-to": "Название перенаправления, на котором закончить перечисление.",
+ "apihelp-query+allredirects-param-prefix": "Найти все названия целевых страниц, начинающихся с этого значения.",
+ "apihelp-query+allredirects-param-unique": "Показывать только уникальные целевые страницы. Не может быть использовано одновременно с $1prop=ids|fragment|interwiki. При использовании в качестве генератора, перечисляет целевые страницы вместо исходных.",
+ "apihelp-query+allredirects-param-prop": "Какую информацию включить:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Добавляет идентификатор перенаправляемой страницы (не может быть использовано одновременно с <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Добавляет название перенаправления.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Добавляет фрагмент из перенаправления при наличии (не может быть использовано одновременно с <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Добавляет префикс интервики к редиректу при наличии (не может быть использовано одновременно с <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "Пространство имён для перечисления.",
+ "apihelp-query+allredirects-param-limit": "Сколько элементов вернуть.",
+ "apihelp-query+allredirects-param-dir": "Порядок перечисления.",
+ "apihelp-query+allredirects-example-B": "Список целевых страниц, в включая несуществующих, с идентификаторами перенаправлений, начиная с буквы <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "Список уникальных целевых страниц.",
+ "apihelp-query+allredirects-example-unique-generator": "Список всех целевых страниц с отметкой несуществующих.",
+ "apihelp-query+allredirects-example-generator": "Список страниц, содержащих перенаправления.",
+ "apihelp-query+allrevisions-summary": "Перечисление всех версий.",
+ "apihelp-query+allrevisions-param-start": "Временная метка, с которой начать перечисление.",
+ "apihelp-query+allrevisions-param-end": "Временная метка, на которой закончить перечисление.",
+ "apihelp-query+allrevisions-param-user": "Только правки данного участника.",
+ "apihelp-query+allrevisions-param-excludeuser": "Не перечислять правки данного участника.",
+ "apihelp-query+allrevisions-param-namespace": "Перечислять только страницы этого пространства имён.",
+ "apihelp-query+allrevisions-param-generatetitles": "При использовании в качестве генератора, генерирует названия страниц вместо идентификаторов версий.",
+ "apihelp-query+allrevisions-example-user": "Перечислить последние 50 правок участника <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "Перечислить первые 50 правок в основном пространстве.",
+ "apihelp-query+mystashedfiles-summary": "Получить список файлов в тайнике (upload stash) текущего участника.",
+ "apihelp-query+mystashedfiles-param-prop": "Какие свойства файлов запрашивать.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Запросить размер и разрешение изображения.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Запросить MIME- и медиа-тип файла.",
+ "apihelp-query+mystashedfiles-param-limit": "Сколько файлов получить.",
+ "apihelp-query+mystashedfiles-example-simple": "Получить ключ, размер и разрешение файлов в тайнике текущего участника.",
+ "apihelp-query+alltransclusions-summary": "Перечисление всех включений (страниц, вставленных с помощью &#123;&#123;x&#125;&#125;), включая несуществующие.",
+ "apihelp-query+alltransclusions-param-from": "Название включения, с которого начать перечисление.",
+ "apihelp-query+alltransclusions-param-to": "Название включения, на котором закончить перечисление.",
+ "apihelp-query+alltransclusions-param-prefix": "Найти все названия включений, начинающиеся с этого значения.",
+ "apihelp-query+alltransclusions-param-unique": "Показывать только уникальные названия включений. Не может быть использовано одновременно с $1prop=ids.\nПри использовании в качестве генератора, перечисляет целевые страницы вместо исходных.",
+ "apihelp-query+alltransclusions-param-prop": "Какую информацию включить:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Добавляет идентификаторы включающих страниц (не может быть использовано одновременно с $1unique).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Добавляет название включения.",
+ "apihelp-query+alltransclusions-param-namespace": "Пространство имён для перечисления.",
+ "apihelp-query+alltransclusions-param-limit": "Сколько элементов вернуть.",
+ "apihelp-query+alltransclusions-param-dir": "Порядок перечисления.",
+ "apihelp-query+alltransclusions-example-B": "Списки заголовков включаемых страниц, в том числе несуществующих, с идентификаторами включающих их страниц, начиная с буквы <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Список уникальных включаемых названий.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Список всех включаемых страниц с отметкой несуществующих.",
+ "apihelp-query+alltransclusions-example-generator": "Список страниц, содержащих включения.",
+ "apihelp-query+allusers-summary": "Перечисление всех зарегистрированных участников.",
+ "apihelp-query+allusers-param-from": "Ник, с которого начать перечисление.",
+ "apihelp-query+allusers-param-to": "Ник, на котором закончить перечисление.",
+ "apihelp-query+allusers-param-prefix": "Найти все ники, начинающиеся с этого значения.",
+ "apihelp-query+allusers-param-dir": "Порядок сортировки.",
+ "apihelp-query+allusers-param-group": "Включать участников только из данных групп.",
+ "apihelp-query+allusers-param-excludegroup": "Исключать участников из данных групп.",
+ "apihelp-query+allusers-param-rights": "Включать только участников с данными правами. Участники с правами, предоставляемыми автоматически присваиваемыми группами — такими, как *, user или autoconfirmed, — не включаются.",
+ "apihelp-query+allusers-param-prop": "Какую информацию включить:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Добавляет информацию о текущих блокировках участника.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Перечисляет группы, в которые входит участник. Это значительно нагружает сервера, что может привести к возвращению меньшего числа результатов, чем указанный лимит.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Перечисляет группы, в которые участник был включён автоматически.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Перечисляет права, которые есть у участника.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Добавляет счётчик правок участника.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Добавляет метку времени, когда участник был зарегистрирован, если она доступна (может быть пустым).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Добавляет центральный идентификатор и статус прикрепления участника.",
+ "apihelp-query+allusers-param-limit": "Сколько ников вернуть.",
+ "apihelp-query+allusers-param-witheditsonly": "Перечислять только участников, совершавших правки.",
+ "apihelp-query+allusers-param-activeusers": "Перечислять только участников, которые были активны в последние $1 {{PLURAL:$1|день|дня|дней}}.",
+ "apihelp-query+allusers-param-attachedwiki": "С <kbd>$1prop=centralids</kbd>, также отображает, прикреплён ли к вики участник с этим идентификатором.",
+ "apihelp-query+allusers-example-Y": "Список участников, начиная с <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Получение информации о текущем статусе аутентификации.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Проверить, достаточен ли текущий статус для осуществления чувствительных к безопасности операций.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Получить информацию о аутентификационных запросах, необходимых для указанного действия аутентификации.",
+ "apihelp-query+authmanagerinfo-example-login": "Получить запросы, которые могут быть использованы на момент начала входа.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Получить запросы, которые могут быть использованы в момент начала авторизации с объединёнными полями формы.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Проверить, необходима ли аутентификация для действия <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "Получение списка страниц, ссылающихся на данную страницу.",
+ "apihelp-query+backlinks-param-title": "Заголовок для поиска. Не может быть использован одновременно с <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "Идентификатор страницы для поиска. Не может быть использован одновременно с <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "Пространство имён для перечисления.",
+ "apihelp-query+backlinks-param-dir": "Порядок перечисления.",
+ "apihelp-query+backlinks-param-filterredir": "Как обрабатывать перенаправления. Если присвоено значение <kbd>nonredirects</kbd> при заданном <var>$1redirect</var>, это применяется только ко второму уровню.",
+ "apihelp-query+backlinks-param-limit": "Сколько страниц вернуть. Если задан <var>$1redirect</var>, лимит применяется к каждому уровню по отдельности (что означает, что всего может вернуться до 2 * <var>$1limit</var> результатов).",
+ "apihelp-query+backlinks-param-redirect": "Если ссылающаяся страница является перенаправлением, найти также все страницы, которые ссылаются на это перенаправление. Максимальный лимит становится в два раза меньше.",
+ "apihelp-query+backlinks-example-simple": "Показать ссылки на <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Получить информацию о страницах, ссылающихся на <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Перечисление всех заблокированных участников и IP-адресов.",
+ "apihelp-query+blocks-param-start": "Временная метка, с которой начать перечисление.",
+ "apihelp-query+blocks-param-end": "Временная метка, на которой закончить перечисление.",
+ "apihelp-query+blocks-param-ids": "Список идентификаторов блокировки (необязательно).",
+ "apihelp-query+blocks-param-users": "Список искомых участников (необязательно).",
+ "apihelp-query+blocks-param-ip": "Получить все блокировки, применённые к этому IP-адресу или диапазону CIDR, включая блокировки диапазонов.\nНе может быть использовано одновременно с <var>$3users</var>. Диапазоны CIDR шире IPv4/$1 или IPv6/$2 не поддерживаются.",
+ "apihelp-query+blocks-param-limit": "Максимальное число блокировок в списке.",
+ "apihelp-query+blocks-param-prop": "Какие свойства получить:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Добавляет идентификатор блокировки.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Добавляет ник заблокированного участника.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Добавляет идентификатор заблокированного участника.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Добавляет ник заблокировавшего участника.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Добавляет идентификатор заблокировавшего участника.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Добавляет метку времени, когда была дана блокировка.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Добавляет метку времени, когда блокировка истечёт.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Добавляет причину блокировки.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Добавляет диапазон IP-адресов, затронутых блокировкой.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Добавляет бану метку (autoblock, anonoly, и так далее).",
+ "apihelp-query+blocks-param-show": "Показать только элементы, удовлетворяющие этим критериям.\nНапример, чтобы отобразить только бессрочные блокировки IP-адресов, установите <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "Список блокировок.",
+ "apihelp-query+blocks-example-users": "Список блокировок участников <kbd>Alice</kbd> и <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Перечисление всех категорий, которым принадлежит страница.",
+ "apihelp-query+categories-param-prop": "Какие дополнительные свойства получить для каждой категории:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Добавляет ключ сортировки (шестнадцатеричная строка) и префикс ключа сортировки (человеко-читаемая часть) для категории.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Добавляет метку времени, когда категория была добавлена.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Отмечает категории, скрытые магическим словом <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "Какие типы категорий показать.",
+ "apihelp-query+categories-param-limit": "Сколько категорий вернуть.",
+ "apihelp-query+categories-param-categories": "Перечислять только данные категории. Полезно для проверки, включена ли конкретная страница в конкретную категорию.",
+ "apihelp-query+categories-param-dir": "Порядок перечисления.",
+ "apihelp-query+categories-example-simple": "Получить список категорий, в которые включена страница <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categories-example-generator": "Получить информацию о всех категориях, использованных на странице <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "Возвращение информации о конкретных категориях.",
+ "apihelp-query+categoryinfo-example-simple": "Получить информацию о <kbd>Category:Foo</kbd> и <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Перечисление всех страниц в данной категории.",
+ "apihelp-query+categorymembers-param-title": "Страницы какой категории перечислять (обязательно). Префикс <kbd>{{ns:category}}:</kbd> должен быть включён. Не может быть использовано одновременно с <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "Идентификатор перечисляемой категории. Не может быть использовано одновременно с <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "Какую информацию включить:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Добавляет идентификатор страницы.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Добавляет заголовок и идентификатор пространства имён страницы.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Добавляет ключ, используемый для сортировки внутри категории (шестнадцатеричная строка).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Добавляет префикс ключа, используемого для сортировки внутри категории (человеко-читаемая часть ключа).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Добавляет тип категоризованной страницы (<samp>page</samp>, <samp>subcat</samp> или <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Добавляет метку времени, когда страница была включена.",
+ "apihelp-query+categorymembers-param-namespace": "Включать только страница из этих пространств имён. Обратите внимание, что вместо <kbd>$1namespace=14</kbd> или <kbd>6</kbd> могут быть использованы <kbd>$1type=subcat</kbd> или <kbd>$1type=file</kbd>.",
+ "apihelp-query+categorymembers-param-type": "Какие типы страниц включать. Игнорируется при <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-limit": "Максимальное число возвращаемых страниц.",
+ "apihelp-query+categorymembers-param-sort": "Свойство для сортировки.",
+ "apihelp-query+categorymembers-param-dir": "Порядок сортировки.",
+ "apihelp-query+categorymembers-param-start": "Временная метка, с которой начать перечисление. Может быть использовано только одновременно с <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "Временная метка, на которой закончить перечисление. Может быть использовано только одновременно с <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Ключ сортировки, с которого начать перечисление, возвращённый <kbd>$1prop=sortkey</kbd>. Может быть использовано только одновременно с <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Ключ сортировки, на котором закончить перечисление, возвращённый <kbd>$1prop=sortkey</kbd>. Может быть использовано только одновременно с <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "Префикс ключа сортировки, с которого начать перечисление. Может быть использовано только с <kbd>$1sort=sortkey</kbd>. Переопределяет <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "Префикс ключа сортировки, <strong>перед</strong> которым закончить перечисление (не <strong>на<strong> котором; если это значение существует, оно не будет включено!). Может быть использовано только одновременно с $1sort=sortkey. Переопределяет $1endhexsortkey.",
+ "apihelp-query+categorymembers-param-startsortkey": "Используйте вместо этого $1starthexsortkey.",
+ "apihelp-query+categorymembers-param-endsortkey": "Используйте вместо этого $1endhexsortkey.",
+ "apihelp-query+categorymembers-example-simple": "Получить первые 10 страниц в <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Получить информацию о первых 10 страницах в <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Получение списка зарегистрированных и количества анонимных редакторов страницы.",
+ "apihelp-query+contributors-param-group": "Включать только участников из данных групп. Неявные или автоматически присваиваемые группы, вроде *, user или autoconfirmed, не считаются.",
+ "apihelp-query+contributors-param-excludegroup": "Исключать участников из заданных групп. Неявные или автоматически присваиваемые группы, вроде *, user или autoconfirmed, не считаются.",
+ "apihelp-query+contributors-param-rights": "Включать только участников с данными правами. Участники с правами, предоставляемыми неявными или автоматически присваиваемыми группами — такими, как *, user или autoconfirmed, — не считаются.",
+ "apihelp-query+contributors-param-excluderights": "Исключать участников с данными правами. Участники с правами, предоставляемыми неявными или автоматически присваиваемыми группами — такими, как *, user или autoconfirmed, — не считаются.",
+ "apihelp-query+contributors-param-limit": "Сколько редакторов вернуть.",
+ "apihelp-query+contributors-example-simple": "Показать редакторов страницы <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-summary": "Получение информации об удалённых правках.",
+ "apihelp-query+deletedrevisions-extended-description": "Может быть использовано несколькими способами:\n# Получение удалённых правок для набора страниц, заданного с помощью названий или идентификаторов. Сортируется по названиям и временным меткам.\n# Получение данных о наборе удалённых правок, заданных с помощью их revid. Сортируется по идентификаторам версий.",
+ "apihelp-query+deletedrevisions-param-start": "Временная метка, с которой начать перечисление. Игнорируется при обработке списка идентификаторов версий.",
+ "apihelp-query+deletedrevisions-param-end": "Временная метка, на которой закончить перечисление. Игнорируется при обработке списка идентификаторов версий.",
+ "apihelp-query+deletedrevisions-param-tag": "Только правки с заданной меткой.",
+ "apihelp-query+deletedrevisions-param-user": "Только правки данного участника.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Не перечислять правки данного участника.",
+ "apihelp-query+deletedrevisions-example-titles": "Список удалённых правок страниц <kbd>Main Page</kbd> и <kbd>Talk:Main Page</kbd> с содержимым.",
+ "apihelp-query+deletedrevisions-example-revids": "Список информации для удалённой правки <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "Перечисление удалённых правок.",
+ "apihelp-query+deletedrevs-extended-description": "Работает в трёх режимах:\n# Перечисление удалённых правок для заданных названий страниц, сортируется по временным меткам.\n# Перечисление удалённого вклада заданного участника, сортируется по временным меткам (названия страниц не указываются).\n# Перечисление удалённых правок в заданном пространстве имён, сортируется по названиям страниц и временным меткам (названия страниц и $1user не указываются).\n\nОпределённые параметры применяются только к некоторым режимам и игнорируются в других.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Мод|Моды}}: $2",
+ "apihelp-query+deletedrevs-param-start": "Временная метка, с которой начать перечисление.",
+ "apihelp-query+deletedrevs-param-end": "Временная метка, на которой закончить перечисление.",
+ "apihelp-query+deletedrevs-param-from": "Начать перечисление на этом заголовке.",
+ "apihelp-query+deletedrevs-param-to": "Закончить перечисление на этом заголовке.",
+ "apihelp-query+deletedrevs-param-prefix": "Найти все названия страниц, начинающиеся с этого значения.",
+ "apihelp-query+deletedrevs-param-unique": "Перечислять только одну правку на каждую страницу.",
+ "apihelp-query+deletedrevs-param-tag": "Только правки с заданной меткой.",
+ "apihelp-query+deletedrevs-param-user": "Только правки данного участника.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Не перечислять правки данного участника.",
+ "apihelp-query+deletedrevs-param-namespace": "Перечислять только страницы этого пространства имён.",
+ "apihelp-query+deletedrevs-param-limit": "Максимальное количество правок в списке.",
+ "apihelp-query+deletedrevs-param-prop": "Какие свойства возвращать:\n;revid: Добавляет идентификатор удалённой правки.\n;parentid: Добавляет идентификатор предыдущей версии страницы.\n;user: Добавляет ник участника, сделавшего правку.\n;userid: Добавляет идентификатор участника, сделавшего правку.\n;comment: Добавляет описание правки.\n;parsedcomment: Добавляет распарсенное описание правки.\n;minor: Отмечает, была ли правка малым.\n;len: Добавляет длину (в байтах) правки.\n;sha1: Добавляет хэш SHA-1 (base 16) правки.\n;content: Добавляет содержимое правки.\n;token: <span class=\"apihelp-deprecated\">Не поддерживается.</span> Возвращает токен редактирования.\n;tags: Теги правки.",
+ "apihelp-query+deletedrevs-example-mode1": "Список последних удалённых правок страниц <kbd>Main Page</kbd> и <kbd>Talk:Main Page</kbd> с содержимым (режим 1).",
+ "apihelp-query+deletedrevs-example-mode2": "Список последних 50 удалённых правок участника <kbd>Bob</kbd> (режим 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Список последних 50 удалённых правок в основном пространстве имён (режим 3)",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Список последних 50 удалённых страниц в пространстве имён {{ns:talk}} (режим 3).",
+ "apihelp-query+disabled-summary": "Этот запрос-модуль был отключён.",
+ "apihelp-query+duplicatefiles-summary": "Перечисление всех файлов, являющихся дубликатами данных, основываясь на сравнении хэш-сумм.",
+ "apihelp-query+duplicatefiles-param-limit": "Сколько дубликатов вернуть.",
+ "apihelp-query+duplicatefiles-param-dir": "Порядок перечисления.",
+ "apihelp-query+duplicatefiles-param-localonly": "Искать только файлы в локальном репозитории.",
+ "apihelp-query+duplicatefiles-example-simple": "Поиск дубликатов [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Поиск дубликатов всех файлов.",
+ "apihelp-query+embeddedin-summary": "Поиск всех страниц, встраивающих (включающих) данное название.",
+ "apihelp-query+embeddedin-param-title": "Искомое название. Не может использоваться вместе с $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "Искомый идентификатор страницы. Не может быть использован одновременно с $1title.",
+ "apihelp-query+embeddedin-param-namespace": "Пространство имён для перечисления.",
+ "apihelp-query+embeddedin-param-dir": "Порядок перечисления.",
+ "apihelp-query+embeddedin-param-filterredir": "Как фильтровать перенаправления.",
+ "apihelp-query+embeddedin-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+embeddedin-example-simple": "Показать включения <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "Получить информацию о страницах, включающих <kbd>Template:Stub</kbd>.",
+ "apihelp-query+extlinks-summary": "Получение всех внешних ссылок (не интервик) для данной страницы.",
+ "apihelp-query+extlinks-param-limit": "Сколько ссылок вернуть.",
+ "apihelp-query+extlinks-param-protocol": "Протокол ссылки. Если оставлено пустым, а <var>$1query</var> задано, будут найдены ссылки с протоколом <kbd>http</kbd>. Оставьте пустым и <var>$1query</var>, и данный параметр, чтобы получить список всех внешних ссылок.",
+ "apihelp-query+extlinks-param-query": "Поисковый запрос без протокола. Полезно для проверки, содержит ли определённая страница определённую внешнюю ссылку.",
+ "apihelp-query+extlinks-param-expandurl": "Раскрыть зависимые от протокола ссылки с какноничным протоколом.",
+ "apihelp-query+extlinks-example-simple": "Получить внешние ссылки на странице <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-summary": "Перечислить страницы, содержащие данную ссылку.",
+ "apihelp-query+exturlusage-param-prop": "Какую информацию включить:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Добавляет идентификатор страницы.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Добавляет заголовок и идентификатор пространства имён страницы.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Добавляет ссылку, использованную на этой странице.",
+ "apihelp-query+exturlusage-param-protocol": "Протокол ссылки. Если оставлено пустым, а <var>$1query</var> задано, будут найдены ссылки с протоколом <kbd>http</kbd>. Оставьте пустым и <var>$1query</var>, и данный параметр, чтобы получить список всех внешних ссылок.",
+ "apihelp-query+exturlusage-param-query": "Поисковый запрос без протокола. См. [[Special:LinkSearch]]. Оставьте пустым для получение списка всех внешних ссылок.",
+ "apihelp-query+exturlusage-param-namespace": "Пространства имён для перечисления.",
+ "apihelp-query+exturlusage-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+exturlusage-param-expandurl": "Раскрыть зависимые от протокола ссылки с какноничным протоколом.",
+ "apihelp-query+exturlusage-example-simple": "Показать страницы, ссылающиеся на <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-summary": "Перечисление всех удалённых файлов.",
+ "apihelp-query+filearchive-param-from": "Название изображения, с которого начать перечисление.",
+ "apihelp-query+filearchive-param-to": "Название изображения, на котором закончить перечисление.",
+ "apihelp-query+filearchive-param-prefix": "Найти все названия файлов, начинающиеся с этого значения.",
+ "apihelp-query+filearchive-param-limit": "Сколько всего изображений вернуть.",
+ "apihelp-query+filearchive-param-dir": "Порядок перечисления.",
+ "apihelp-query+filearchive-param-sha1": "SHA1-хэш этого изображения. Переопределяет $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "SHA1-хэш этого изображения в base 36 (используется в MediaWiki).",
+ "apihelp-query+filearchive-param-prop": "Какую информацию получить:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Добавляет SHA1-хэш изображения.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Добавляет метку времени загрузки файловой версии.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Добавляет участника, загрузившего изображение.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Добавляет размер изображения в байтах, высоту, ширину и количество использующих страниц (если применимо).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Синоним для size.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Добавляет описание файловой версии.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Распарсить описание файловой версии.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Добавляет MIME-тип изображения.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Добавляет медиа-тип изображения.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Перечисляет метаданные Exif для файловой версии.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Добавляет глубину цвета файловой версии.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Добавляет имя архивной версии файла.",
+ "apihelp-query+filearchive-example-simple": "Список всех удалённых файлов.",
+ "apihelp-query+filerepoinfo-summary": "Возвращает мета-информацию о файловых репозиториях, настроенных в вики.",
+ "apihelp-query+filerepoinfo-param-prop": "Какие свойства хранилища получить (на некоторых вики может быть доступно больше):\n;apiutl: Ссылка на API хранилища — полезно для получения информации об изображении с хоста.\n;name: Ключ хранилища — используется, например, <var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> и возвращаемых [[Special:ApiHelp/query+imageinfo|imageinfo]].\n;displayname: Человеко-читаемое название хранилища.\n;rooturl: Корневая ссылка для путей к файлам.\n;local: Определяет, является ли хранилище локальным, или нет.",
+ "apihelp-query+filerepoinfo-example-simple": "Получить информацию о файловых репозиториях.",
+ "apihelp-query+fileusage-summary": "Поиск всех страниц, использующих данный файл.",
+ "apihelp-query+fileusage-param-prop": "Какие свойства получить:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "Идентификатор каждой страницы.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Заголовок каждой страницы.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Метка, является ли страница перенаправлением.",
+ "apihelp-query+fileusage-param-namespace": "Включить страницы только из данных пространств имён.",
+ "apihelp-query+fileusage-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+fileusage-param-show": "Показать только элементы, соответствующие этим критериям:\n;redirect: Показать только перенаправления.\n;!redirect: Показать только не перенаправления.",
+ "apihelp-query+fileusage-example-simple": "Получить список страниц, использующих [[:File:Example.jpg]].",
+ "apihelp-query+fileusage-example-generator": "Получить информацию о страницах, использующих [[:File:Example.jpg]].",
+ "apihelp-query+imageinfo-summary": "Возвращает информацию о файле и историю загрузок.",
+ "apihelp-query+imageinfo-param-prop": "Какую информацию о файле получить:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Добавляет метку времени загрузки файловой версии.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Добавляет участников, загрузивших каждую файловую версию.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Добавляет идентификаторы участников, загрузивших каждую файловую версию.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Комментарий к версии.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Распарсенный комментарий к версии.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Добавляет каноничное название файла.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Возвращает ссылку на файл и страницу описания.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Добавляет размер файла в байтах, высоту, ширину и количество использующих страниц (если применимо).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Синоним для size.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Добавляет SHA1-хэш файла.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Добавляет MIME-тип файла.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Добавляет MIME-тип миниатюры файла (требуется url и параметр $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Добавляет медиа-тип файла.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Перечисляет метаданные Exif для файловой версии.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Перечисляет общие для данного формата метаданные для файловой версии.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Перечисляет структурированные метаданные, собранные из нескольких источников. Результат отдаётся в формате HTML.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Добавляет имя архивной версии файла.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Добавляет глубину цвета файловой версии.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Используется страницей Special:Upload для получения информации о существовании файла. Не предназначено для использования за пределами ядра MediaWiki.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Добавляет указание на то, находится ли файл в списке [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-limit": "Сколько версий каждого файла вернуть.",
+ "apihelp-query+imageinfo-param-start": "Временная метка, с которой начать перечисление.",
+ "apihelp-query+imageinfo-param-end": "Временная метка, на которой закончить перечисление.",
+ "apihelp-query+imageinfo-param-urlwidth": "Если задан $2prop=url, будет возвращена ссылка на изображение, масштабированное до указанной ширины. Из соображений производительности, при использовании этой опции будет возвращено не более $1 файлов.",
+ "apihelp-query+imageinfo-param-urlheight": "Аналогично $1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "Какую версию метаданных использовать. Если указано <kbd>latest</kbd>, будет использована последняя версия. Для обратной совместимости, значение по умолчанию — <kbd>1</kbd>.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "На каком языке запрашивать расширенные метаданные. Это затрагивает как переводы, если их доступно несколько, так и способ форматирования чисел и других значений.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Если для свойства расширенных метаданных доступны переводы, запросить их все.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Если задано и непустое, только эти ключи будут возвращены для $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "Строковой параметр, зависящий от обработчика. Например, для PDF можно использовать <kbd>page15-100px</kbd>. Должен быть использован <var>$1urlwidth</var>, не противоречащий с <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Если задан <kbd>$2prop=badfile</kbd>, этот заголовок страницы будет использован для анализа [[MediaWiki:Bad image list]].",
+ "apihelp-query+imageinfo-param-localonly": "Искать только файлы в локальном репозитории.",
+ "apihelp-query+imageinfo-example-simple": "Заросить информацию о текущей версии [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "Запросить информацию о версиях [[:File:Test.jpg]] с 2008 года и позже.",
+ "apihelp-query+images-summary": "Возвращает все файлы, содержащиеся на данных страницах.",
+ "apihelp-query+images-param-limit": "Сколько файлов вернуть.",
+ "apihelp-query+images-param-images": "Перечислять только данные файлы. Полезно для проверки, включает ли конкретная страница конкретный файл.",
+ "apihelp-query+images-param-dir": "Порядок перечисления.",
+ "apihelp-query+images-example-simple": "Получить список файлов, использованных на [[Main Page]].",
+ "apihelp-query+images-example-generator": "Получить информацию о всех файлах, использованных на [[Main Page]].",
+ "apihelp-query+imageusage-summary": "Поиск всех страниц, использующих данный файл.",
+ "apihelp-query+imageusage-param-title": "Искомое название. Не может использоваться вместе с $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "Искомый идентификатор страницы. Не может быть использован одновременно с $1title.",
+ "apihelp-query+imageusage-param-namespace": "Пространство имён для перечисления.",
+ "apihelp-query+imageusage-param-dir": "Порядок перечисления.",
+ "apihelp-query+imageusage-param-filterredir": "Как обрабатывать перенаправления. Если присвоено значение nonredirects при заданном $1redirect, это применяется только ко второму уровню.",
+ "apihelp-query+imageusage-param-limit": "Сколько страниц вернуть. Если задан <var>$1redirect</var>, лимит применяется к каждому уровню по отдельности (что означает, что всего может вернуться до 2 * <var>$1limit</var> результатов).",
+ "apihelp-query+imageusage-param-redirect": "Если ссылающаяся страница является перенаправлением, найти также все страницы, которые ссылаются на это перенаправление. Максимальный лимит становится в два раза меньше.",
+ "apihelp-query+imageusage-example-simple": "Показать страницы, использующие [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Получить информацию о страницах, использующих [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "Получение основной информации о страницах.",
+ "apihelp-query+info-param-prop": "Какие дополнительные свойства получить:",
+ "apihelp-query+info-paramvalue-prop-protection": "Перечисление уровней защиты каждой страницы.",
+ "apihelp-query+info-paramvalue-prop-talkid": "Идентификатор страницы обсуждения для каждой страницы не-обсуждения.",
+ "apihelp-query+info-paramvalue-prop-watched": "Перечислить статус наблюдения за каждой страницей.",
+ "apihelp-query+info-paramvalue-prop-watchers": "Количество наблюдающих, если разрешено.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "Количество наблюдающих за каждой страницей, просмотревших последние правки, если разрешено.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "Временная метка уведомления для списка наблюдения для каждой страницы.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "Идентификатор родительской страницы для каждой страницы обсуждения.",
+ "apihelp-query+info-paramvalue-prop-url": "Возвращает полную ссылку, ссылку на редактирование и каноничную ссылку для каждой страницы.",
+ "apihelp-query+info-paramvalue-prop-readable": "Может ли участник просматривать эту страницу.",
+ "apihelp-query+info-paramvalue-prop-preload": "Текст, возвращённый EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Возвращает стиль отображения заголовка страницы.",
+ "apihelp-query+info-param-testactions": "Проверить, может ли текущий участник провести указанные действия над страницей.",
+ "apihelp-query+info-param-token": "Вместо этого используйте [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-query+info-example-simple": "Получить информацию о странице <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Получить основную информацию и информацию о защите страницы <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-summary": "Поиск всех страниц, ссылающихся на заданную интервики ссылку.",
+ "apihelp-query+iwbacklinks-extended-description": "Может быть использована для поиска всех ссылок с префиксом, или всех ссылок на название (с заданным префиксом). Неиспользование никакого параметра фактически означает «все интервики-ссылки».",
+ "apihelp-query+iwbacklinks-param-prefix": "Префикс интервики.",
+ "apihelp-query+iwbacklinks-param-title": "Искомая интервики-ссылка. Должна быть использована вместе с <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+iwbacklinks-param-prop": "Какие свойства получить:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Добавляет префикс интервики.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Добавляет название интервики.",
+ "apihelp-query+iwbacklinks-param-dir": "Порядок перечисления.",
+ "apihelp-query+iwbacklinks-example-simple": "Получить список страниц, ссылающихся на [[wikibooks:Test]].",
+ "apihelp-query+iwbacklinks-example-generator": "Получить информацию о страницах, ссылающихся на [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-summary": "Возвращает все интервики-ссылки с данных страниц.",
+ "apihelp-query+iwlinks-param-url": "Следует ли возвращать полный URL (не может быть использовано одновременно с $1prop).",
+ "apihelp-query+iwlinks-param-prop": "Какие дополнительные свойства получить для каждой межъязыковой ссылки:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Добавляет полный URL.",
+ "apihelp-query+iwlinks-param-limit": "Сколько интервики-ссылок вернуть.",
+ "apihelp-query+iwlinks-param-prefix": "Вернуть только интервики с этим префиксом.",
+ "apihelp-query+iwlinks-param-title": "Искомая интервики-ссылка. Должна быть использована вместе с <var>$1prefix</var>.",
+ "apihelp-query+iwlinks-param-dir": "Порядок перечисления.",
+ "apihelp-query+iwlinks-example-simple": "Получить интервики-ссылки со страницы <kbd>Main Page</kbd>.",
+ "apihelp-query+langbacklinks-summary": "Поиск всех страниц, ссылающихся на заданную языковую ссылку.",
+ "apihelp-query+langbacklinks-extended-description": "Может быть использовано для поиска всех ссылок с языковым кодом, или всех ссылок на страницу с заданным языком. Неиспользование этого параметра фактически вернёт все языковые ссылки.\n\nОбратите внимания, что ссылки, добавляемые расширениями, могут не рассматриваться.",
+ "apihelp-query+langbacklinks-param-lang": "Язык ссылки.",
+ "apihelp-query+langbacklinks-param-title": "Искомая языковая ссылка. Должно быть использовано с $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+langbacklinks-param-prop": "Какие свойства получить:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Добавляет языковой код ссылки.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Добавляет название ссылки.",
+ "apihelp-query+langbacklinks-param-dir": "Порядок перечисления.",
+ "apihelp-query+langbacklinks-example-simple": "Получить список страниц, ссылающихся на [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Получить информацию о страницах, ссылающихся на [[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "Возвращает все межъязыковые ссылки с данных страниц.",
+ "apihelp-query+langlinks-param-limit": "Сколько ссылок вернуть.",
+ "apihelp-query+langlinks-param-url": "Следует ли вернуть полный URL (не может быть использовано одновременно с <var>$1prop</var>).",
+ "apihelp-query+langlinks-param-prop": "Какие дополнительные свойства получить для каждой межъязыковой ссылки:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Добавляет полный URL.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "Добавляет локализованное название языка (лучший вариант). Используйте <var>$1inlanguagecode</var> для указания языка.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Добавляет самоназвание языка.",
+ "apihelp-query+langlinks-param-lang": "Возвращает только ссылки с данным языковым кодом.",
+ "apihelp-query+langlinks-param-title": "Искомая ссылка. Должна быть использована вместе с <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "Порядок перечисления.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Языковой код для локализованных названий языков.",
+ "apihelp-query+langlinks-example-simple": "Получить межъязыковые ссылки со страницы <kbd>Main Page</kbd>.",
+ "apihelp-query+links-summary": "Возвращает все ссылки с данных страниц.",
+ "apihelp-query+links-param-namespace": "Показывать ссылки только на данные пространства имён.",
+ "apihelp-query+links-param-limit": "Сколько ссылок вернуть.",
+ "apihelp-query+links-param-titles": "Перечислять только данные ссылки. Полезно для проверки, содержит ли конкретная страница конкретную ссылку.",
+ "apihelp-query+links-param-dir": "Порядок перечисления.",
+ "apihelp-query+links-example-simple": "Получить ссылки со страницы <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-generator": "Получить информацию о страницах, на которые ссылается <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-namespaces": "Получить ссылки с <kbd>Main Page</kbd> на пространства имён {{ns:user}} и {{ns:template}}.",
+ "apihelp-query+linkshere-summary": "Поиск всех страниц, ссылающихся на данную.",
+ "apihelp-query+linkshere-param-prop": "Какие свойства получить:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "Идентификатор каждой страницы.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Заголовок каждой страницы.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Метка, является ли страница перенаправлением.",
+ "apihelp-query+linkshere-param-namespace": "Включить страницы только из данных пространств имён.",
+ "apihelp-query+linkshere-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+linkshere-param-show": "Показать только элементы, соответствующие этим критериям:\n;redirect: Показать только перенаправления.\n;!redirect: Показать только не перенаправления.",
+ "apihelp-query+linkshere-example-simple": "Получить список страниц, ссылающихся на [[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "Получить информацию о страницах, ссылающихся на [[Main Page]].",
+ "apihelp-query+logevents-summary": "Получение записей журналов.",
+ "apihelp-query+logevents-param-prop": "Какие свойства получить:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Добавляет идентификатор записи журнала.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Добавляет заголовок страницы, связанной с записью журнала.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Добавляет тип записи журнала.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Добавляет участника, ответственного за запись журнала.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Добавляет идентификатор участника, ответственного за запись журнала.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "Добавляет временную метку записи журнала.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Добавляет комментарий записи журнала.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "Добавляет распарсенный комментарий записи журнала.",
+ "apihelp-query+logevents-paramvalue-prop-details": "Перечисляет дополнительные сведения о записи в журнале.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Перечисляет метки записи журнала.",
+ "apihelp-query+logevents-param-type": "Вернуть только записи этого типа.",
+ "apihelp-query+logevents-param-action": "Вернуть только указанные действия журнала. Переопределяет <var>$1type</var>. В списке возможных значений можно использовать звёздочку, например, <kbd>action/*</kbd> найдёт различные подстроки после слэша (/).",
+ "apihelp-query+logevents-param-start": "Временная метка, с которой начать перечисление.",
+ "apihelp-query+logevents-param-end": "Временная метка, на которой закончить перечисление.",
+ "apihelp-query+logevents-param-user": "Вернуть записи, созданные указанным участником.",
+ "apihelp-query+logevents-param-title": "Вернуть записи, связанные с указанными страницами.",
+ "apihelp-query+logevents-param-namespace": "Вернуть записи, связанные с указанными пространствами имён.",
+ "apihelp-query+logevents-param-prefix": "Вернуть записи, начинающиеся с заданного префикса.",
+ "apihelp-query+logevents-param-tag": "Только записи с заданной меткой.",
+ "apihelp-query+logevents-param-limit": "Сколько записей вернуть.",
+ "apihelp-query+logevents-example-simple": "Список последних записей.",
+ "apihelp-query+pagepropnames-summary": "Перечисление всех названий свойств, использованных в вики.",
+ "apihelp-query+pagepropnames-param-limit": "Максимальное число возвращаемых названий.",
+ "apihelp-query+pagepropnames-example-simple": "Получить первые 10 названий свойств.",
+ "apihelp-query+pageprops-summary": "Получение различных свойств страниц, определённых в содержании страницы.",
+ "apihelp-query+pageprops-param-prop": "Перечислить только эти свойства страницы (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> возвращает список используемых названий свойств). Полезно для проверки, используют ли страницы конкретные свойства.",
+ "apihelp-query+pageprops-example-simple": "Получить свойства страниц <kbd>Main Page</kbd> и <kbd>MediaWiki</kbd>.",
+ "apihelp-query+pageswithprop-summary": "Перечисление всех страниц, использующих заданное свойство.",
+ "apihelp-query+pageswithprop-param-propname": "Искомое свойство (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> возвращает список используемых названий свойств).",
+ "apihelp-query+pageswithprop-param-prop": "Какую информацию включить:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Добавляет идентификатор страницы.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Добавляет заголовок и идентификатор пространства имён страницы.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Добавляет значение свойства страницы.",
+ "apihelp-query+pageswithprop-param-limit": "Максимальное число возвращаемых страниц.",
+ "apihelp-query+pageswithprop-param-dir": "Порядок сортировки.",
+ "apihelp-query+pageswithprop-example-simple": "Список первых 10 страниц, использующих <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "Получение дополнительной информации о первых десяти страницах, использующих <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-summary": "Осуществление поиска по префиксу названий страниц.",
+ "apihelp-query+prefixsearch-extended-description": "Не смотря на похожесть названий, этот модуль не является эквивалентом [[Special:PrefixIndex]]; если вы ищете его, см. <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> с параметром <kbd>apprefix</kbd>. Задача этого модуля близка к <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>: получение пользовательского ввода и представление наиболее подходящих заголовков. В зависимости от поискового движка, используемого на сервере, сюда может включаться исправление опечаток, избегание перенаправлений и другие эвристики.",
+ "apihelp-query+prefixsearch-param-search": "Поисковый запрос.",
+ "apihelp-query+prefixsearch-param-namespace": "Пространства имён для поиска.",
+ "apihelp-query+prefixsearch-param-limit": "Максимальное число возвращаемых результатов.",
+ "apihelp-query+prefixsearch-param-offset": "Количество пропускаемых результатов.",
+ "apihelp-query+prefixsearch-example-simple": "Поиск названий страниц, начинающихся с <kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "Используемый поисковый профиль.",
+ "apihelp-query+protectedtitles-summary": "Перечисление всех названий, защищённых от создания.",
+ "apihelp-query+protectedtitles-param-namespace": "Перечислять только страницы этих пространств имён.",
+ "apihelp-query+protectedtitles-param-level": "Перечислять только названия с этим уровнем защиты.",
+ "apihelp-query+protectedtitles-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+protectedtitles-param-start": "Начать перечисление с этой метки времени защиты.",
+ "apihelp-query+protectedtitles-param-end": "Закончить перечисление на этой метке времени защиты.",
+ "apihelp-query+protectedtitles-param-prop": "Какие свойства получить:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "Добавляет метку времени установки защиты.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Добавляет участника, установившего защиту.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Добавляет идентификатор участника, установившего защиту.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Добавляет описание защиты.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "Добавляет распарсенное описание защиты.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "Добавляет метку времени снятия защиты.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Добавляет уровень защиты.",
+ "apihelp-query+protectedtitles-example-simple": "Список защищенных заголовков",
+ "apihelp-query+protectedtitles-example-generator": "Поиск ссылок на защищённые заголовки в основном пространстве имён.",
+ "apihelp-query+querypage-summary": "Получение списка, предоставляемого служебной страницей, основанной на QueryPage.",
+ "apihelp-query+querypage-param-page": "Название служебной страницы. Обратите внимание: чувствительно к регистру.",
+ "apihelp-query+querypage-param-limit": "Количество возвращаемых результатов.",
+ "apihelp-query+querypage-example-ancientpages": "Вернуть результаты [[Special:Ancientpages]].",
+ "apihelp-query+random-summary": "Получение набора случайных страниц.",
+ "apihelp-query+random-extended-description": "Страницы перечисляются в строгой последовательности, случайна только входная точка. Это означает, что если, например, <samp>Main Page</samp> — первая страница в списке, то <samp>List of fictional monkeys</samp> <em>всегда</em> будет второй, <samp>List of people on stamps of Vanuatu</samp> — третьей, и так далее.",
+ "apihelp-query+random-param-namespace": "Вернуть только страницы этих пространств имён.",
+ "apihelp-query+random-param-limit": "Ограничение на количество возвращаемых страниц.",
+ "apihelp-query+random-param-redirect": "Вместо этого, используйте <kbd>$1filterredir=redirects</kbd>.",
+ "apihelp-query+random-param-filterredir": "Как фильтровать перенаправления.",
+ "apihelp-query+random-example-simple": "Вернуть две случайные страницы из основного пространства имён.",
+ "apihelp-query+random-example-generator": "Вернуть информацию о двух случайных страницах из основного пространства имён.",
+ "apihelp-query+recentchanges-summary": "Перечисление последних правок.",
+ "apihelp-query+recentchanges-param-start": "Временная метка, с которой начать перечисление.",
+ "apihelp-query+recentchanges-param-end": "Временная метка, на которой закончить перечисление.",
+ "apihelp-query+recentchanges-param-namespace": "Только правки в этих пространствах имён.",
+ "apihelp-query+recentchanges-param-user": "Только правки данного участника.",
+ "apihelp-query+recentchanges-param-excludeuser": "Не перечислять правки данного участника.",
+ "apihelp-query+recentchanges-param-tag": "Только правки с заданной меткой.",
+ "apihelp-query+recentchanges-param-prop": "Включить дополнительную информацию:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "Добавить анонимных участников, ответственных за правку или метку.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "Добавить идентификатор ответственного за правку участника.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Добавляет описание правки.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "Добавляет распарсенное описание правки.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Добавляет метки правки.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Добавляет временную метку правки.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Добавляет заголовок отредактированной страницы.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "Добавляет идентификаторы страницы, правки, старой и новой версии.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Добавляет старую и новую длину страницы в байтах.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "Отмечает правку, если страница является перенаправлением.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Отмечает патрулируемые правки как отпатрулированные или неотпатрулированные.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Добавляет информацию о записи журнала (идентификатор записи, её тип, и так далее).",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Перечисляет теги записи.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Добавляет значение контрольных сумм для записей, связанных с версией.",
+ "apihelp-query+recentchanges-param-token": "Вместо этого используйте <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-query+recentchanges-param-show": "Показать только элементы, удовлетворяющие данным критериям. Например, чтобы отобразить только малые правки, сделанные зарегистрированными участниками, установите $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "Сколько правок вернуть.",
+ "apihelp-query+recentchanges-param-type": "Какие типы правок показать.",
+ "apihelp-query+recentchanges-param-toponly": "Перечислять только последние правки страниц.",
+ "apihelp-query+recentchanges-param-generaterevisions": "При использовании в качестве генератора, генерировать идентификаторы версий вместо их названий. Записи последних изменений без привязанного идентификатора версии (например, большинство записей журналов) не сгенерируют ничего.",
+ "apihelp-query+recentchanges-example-simple": "Список последних изменений.",
+ "apihelp-query+recentchanges-example-generator": "Получить информацию о последних страницах с неотпатрулированными изменениями.",
+ "apihelp-query+redirects-summary": "Возвращает все перенаправления на данную страницу.",
+ "apihelp-query+redirects-param-prop": "Какие свойства получить:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "Идентификатор каждого перенаправления.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Название каждого перенаправления.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "Фрагемнт каждого перенаправления, если доступен.",
+ "apihelp-query+redirects-param-namespace": "Включить страницы только из данных пространств имён.",
+ "apihelp-query+redirects-param-limit": "Сколько перенаправлений вернуть.",
+ "apihelp-query+redirects-param-show": "Показывать только элементы, удовлетворяющие данным критериям:\n;fragment: Показывать только перенаправления с фрагментами.\n;!fragment: Показывать только перенаправления без фрагментов.",
+ "apihelp-query+redirects-example-simple": "Получить список перенаправлений на [[Main Page]].",
+ "apihelp-query+redirects-example-generator": "Получить информацию о всех перенаправлениях на [[Main Page]].",
+ "apihelp-query+revisions-summary": "Получение информации о версии страницы.",
+ "apihelp-query+revisions-extended-description": "Может использоваться в трёх режимах:\n# Получение данных о наборе страниц (последних версий) с помощью передачи названий или идентификаторов страниц.\n# Получение версий одной данной страницы, используя названия или идентификаторы с началом, концом или лимитом.\n# Получение данных о наборе версий, передаваемых с помощью их идентификаторов.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Может быть использовано только с одной страницей (режим №2).",
+ "apihelp-query+revisions-param-startid": "Начать перечисление с этой временной метки версии. Версия обязана существовать, но не обязана принадлежать этой странице.",
+ "apihelp-query+revisions-param-endid": "Закончить перечисление на этой временной метке версии. Версия обязана существовать, но не обязана принадлежать этой странице.",
+ "apihelp-query+revisions-param-start": "С какой временной метки начать перечисление.",
+ "apihelp-query+revisions-param-end": "Перечислять до данной временной метки.",
+ "apihelp-query+revisions-param-user": "Только версии данного участника.",
+ "apihelp-query+revisions-param-excludeuser": "Исключить версии данного участника.",
+ "apihelp-query+revisions-param-tag": "Только версии с заданной меткой.",
+ "apihelp-query+revisions-param-token": "Какие токены получить для каждой версии.",
+ "apihelp-query+revisions-example-content": "Получить данные с содержимым для последних версий страниц <kbd>API</kbd> и <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "Получить последние 5 версий <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5": "Получить первые 5 версий <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Получить первые 5 версий <kbd>Main Page</kbd> созданных после 2006-05-01.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Получить первые 5 версий <kbd>Main Page</kbd>, сделанных не анонимным участником <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "Получить первые 5 версий <kbd>Main Page</kbd>, сделанных участником <kbd>MediaWiki default</kbd>.",
+ "apihelp-query+revisions+base-param-prop": "Какие свойства каждой версии получить:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "Идентификатор версии.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Флаги версии (малая правка).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "Временная метка версии.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Участник, создавший версию.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "Идентификатор создателя версии.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Длина версии (в байтах).",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1-хэш (base 16) версии.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "Идентификатор модели содержимого версии.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "Описание правки.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "Распарсенное описание правки.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Текст версии.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Метки версии.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">Не поддерживается.</span> Вместо этого используйте <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> или <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Дерево парсинга XML содержимого версии (требуется модель содержимого <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "Сколько версий вернуть.",
+ "apihelp-query+revisions+base-param-expandtemplates": "Вместо этого используйте <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd>. Раскрыть шаблоны в содержимом версии (требуется $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "Вместо этого используйте <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> или <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Сгенерировать дерево парсинга XML содержимого версии (требуется $1prop=content).",
+ "apihelp-query+revisions+base-param-parse": "Вместо этого используйте <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Распарсить содержимое версии (требуется $1prop=content). Из соображений производительности, при использовании этой опции, в качестве $1limit принудительно устанавливается 1.",
+ "apihelp-query+revisions+base-param-section": "Вернуть содержимое только секции с заданным номером.",
+ "apihelp-query+revisions+base-param-diffto": "Вместо этого используйте <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. Идентификатор версии, с которым сравнивать каждую версию. Используйте <kbd>prev</kbd>, <kbd>next</kbd> и <kbd>cur</kbd> для предыдущей, следующей и текущей версии соответственно.",
+ "apihelp-query+revisions+base-param-difftotext": "Вместо этого используйте <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. Текст, с которым сравнивать каждую версию. Сравнивает ограниченное число версий. Переопределяет <var>$1diffto</var>. Если задано <var>$1section</var>, сравнение будет произведено только с этой секцией.",
+ "apihelp-query+revisions+base-param-difftotextpst": "Вместо этого используйте <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. Выполнить преобразование перед записью правки до сравнения. Доступно только при использовании с <var>$1difftotext</var>.",
+ "apihelp-query+revisions+base-param-contentformat": "Формат серилиализации, использованный в <var>$1difftotext</var> и ожидаемый в результате.",
+ "apihelp-query+search-summary": "Проведение полнотекстового поиска.",
+ "apihelp-query+search-param-search": "Искать страницы, названия или тексты которых содержат это значение. Вы можете использовать в поисковом запросе служебные функции в зависимости от того, какой поисковый движок используется на сервере.",
+ "apihelp-query+search-param-namespace": "Искать только в этих пространствах имён.",
+ "apihelp-query+search-param-what": "Какой тип поиска осуществить.",
+ "apihelp-query+search-param-info": "Какие метаданные вернуть.",
+ "apihelp-query+search-param-prop": "Какие свойства вернуть:",
+ "apihelp-query+search-param-qiprofile": "Используемый запросонезависимый профиль (затрагивает оценивающий алгоритм).",
+ "apihelp-query+search-paramvalue-prop-size": "Добавляет размер страницы в байтах.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Добавляет количество слов на странице.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Добавляет метку времени последнего редактирования страницы.",
+ "apihelp-query+search-paramvalue-prop-snippet": "Добавляет распарсенный фрагмент страницы.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "Добавляет распарсенный фрагмент названия страницы.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "Добавляет распарсенный фрагмент названия перенаправления.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Добавляет название найденного перенаправления.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "Добавляет распарсенный фрагмент заголовка найденного раздела.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Добавляет заголовок найденного раздела.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "Добавляет распарсенный фрагмент найденной категории.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "Добавляет логическое значение, обозначающее, удовлетворяет ли поисковому запросу содержимое файла.",
+ "apihelp-query+search-paramvalue-prop-score": "Игнорируется.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Игнорируется.",
+ "apihelp-query+search-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+search-param-interwiki": "Включить результаты из других вики, если доступны.",
+ "apihelp-query+search-param-backend": "Какой поисковый движок использовать, если не стандартный.",
+ "apihelp-query+search-param-enablerewrites": "Разрешить редактирование запроса. Некоторые поисковые движки могут отредактировать запрос, например, исправив опечатку, если посчитают, что это приведёт к лучшим результатам.",
+ "apihelp-query+search-example-simple": "Найти <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Найти тексты, содержащие <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-generator": "Получить информацию о страницах, возвращённых по поисковому запросу <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "Получение основной информации о сайте.",
+ "apihelp-query+siteinfo-param-prop": "Какую информацию получить:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Общую системную информацию.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "Список зарегистрированных пространств имён и их каноничные имена.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Список зарегистрированных синонимов пространств имён.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Список синонимов служебных страниц.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Список магических слов и их синонимы.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Статистика сайта.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "Карта интервик (может быть отфильтрована, или локализована с помощью <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "Возвращает сервер базы данных с наибольшим отставанием репликации.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Список групп участников и связанных прав.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Библиотеки, установленные в вики.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Расширения, установленные в вики.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Список файловых расширений, разрешённых к загрузке.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Возвращает правовую информацию (лицензию), если доступно.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Возвращает информацию о доступных типах защиты страниц.",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "Возвращает список языков, поддерживаемых MediaWiki (опционально локализованных с помощью <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Возвращает список языковых кодов, для которых включён [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]], а также варианты, поддерживаемые для каждого языка.",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "Возвращает список доступных скинов (опционально локализованных с помощью <var>$1inlanguagecode</var>, в противном случае — на языке вики).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "Возвращает список тегов рашсирений парсера.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "Возвращает список перехватчиков функций парсера.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "Возвращает список всех подписанных перехватчиков (содержимое <var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Возвращает список идентификаторов переменных.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "Возвращает список протоколов, разрешённых во внешних ссылках.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Возвращает значения по умолчанию настроек участников.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "Возвращает конфигурацию диалога загрузки.",
+ "apihelp-query+siteinfo-param-filteriw": "Вернуть только локальные или только нелокальные записи карты интервик.",
+ "apihelp-query+siteinfo-param-showalldb": "Перечисляет все сервера баз данных, а не только самый отстающий.",
+ "apihelp-query+siteinfo-param-numberingroup": "Перечисляет количество участников в группах.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "Языковой код для перевода названий языков и скинов.",
+ "apihelp-query+siteinfo-example-simple": "Запросить информацию о сайте.",
+ "apihelp-query+siteinfo-example-interwiki": "Запросить список локальных префиксов интервик.",
+ "apihelp-query+siteinfo-example-replag": "Проверить текущее отставание репликации.",
+ "apihelp-query+stashimageinfo-summary": "Возвращает информацию о файлах в тайнике (upload stash).",
+ "apihelp-query+stashimageinfo-param-filekey": "Ключ, идентифицирующий предыдущую временную загрузку.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Синоним $1filekey для обратной совместимости.",
+ "apihelp-query+stashimageinfo-example-simple": "Вернуть информацию о файле в тайнике.",
+ "apihelp-query+stashimageinfo-example-params": "Вернуть эскизы двух файлов в тайнике.",
+ "apihelp-query+tags-summary": "Список меток правок.",
+ "apihelp-query+tags-param-limit": "Максимальное количество меток в списке.",
+ "apihelp-query+tags-param-prop": "Какие свойства получить:",
+ "apihelp-query+tags-paramvalue-prop-name": "Добавляет название метки.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Добавляет системное сообщение метки.",
+ "apihelp-query+tags-paramvalue-prop-description": "Добавляет описание метки.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Добавляет количество правок и записей в журналах, использующих эту метку.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Показывает, определена ли метка.",
+ "apihelp-query+tags-paramvalue-prop-source": "Получить источники меток, которыми могут быть <samp>extension</samp> для меток, определённых расширениями, и <samp>manual</samp> для меток, определённых участниками вручную.",
+ "apihelp-query+tags-paramvalue-prop-active": "Применима ли метка до сих пор.",
+ "apihelp-query+tags-example-simple": "Список доступных меток.",
+ "apihelp-query+templates-summary": "Возвращает все страницы, включённые в данную.",
+ "apihelp-query+templates-param-namespace": "Показать шаблоны только данного пространства имён.",
+ "apihelp-query+templates-param-limit": "Сколько шаблонов вернуть.",
+ "apihelp-query+templates-param-templates": "Перечислять только эти шаблоны. Полезно для проверки, включает ли конкретная страница конкретный шаблон.",
+ "apihelp-query+templates-param-dir": "Порядок перечисления.",
+ "apihelp-query+templates-example-simple": "Получить список шаблонов, использующихся на <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-generator": "Получить информацию о шаблонах, использующихся на <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-namespaces": "Получить страницы из пространств имён {{ns:user}} и {{ns:template}}, включённые в <kbd>Main Page</kbd>.",
+ "apihelp-query+tokens-summary": "Получение токенов для действий, связанных с редактированием данных.",
+ "apihelp-query+tokens-param-type": "Типы запрашиваемых токенов.",
+ "apihelp-query+tokens-example-simple": "Получить csrf-токен (по умолчанию).",
+ "apihelp-query+tokens-example-types": "Получить токен наблюдения и токен патрулирования.",
+ "apihelp-query+transcludedin-summary": "Поиск всех страниц, включающих данные страницы.",
+ "apihelp-query+transcludedin-param-prop": "Какие свойства получить:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "Идентификатор каждой страницы.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Заголовок каждой страницы.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Метка, является ли страница перенаправлением.",
+ "apihelp-query+transcludedin-param-namespace": "Включить страницы только из данных пространств имён.",
+ "apihelp-query+transcludedin-param-limit": "Сколько страниц вернуть.",
+ "apihelp-query+transcludedin-param-show": "Показать только элементы, соответствующие этим критериям:\n;redirect: Показать только перенаправления.\n;!redirect: Показать только не перенаправления.",
+ "apihelp-query+transcludedin-example-simple": "Получить список страниц, включающих <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Получить информацию о страницах, включающих <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-summary": "Получение всех правок участника.",
+ "apihelp-query+usercontribs-param-limit": "Максимальное количество возвращаемых правок.",
+ "apihelp-query+usercontribs-param-start": "Временная метка, с которой начать возврат.",
+ "apihelp-query+usercontribs-param-end": "Временная метка, на которой закончить возврат.",
+ "apihelp-query+usercontribs-param-user": "Участники, чей вклад необходимо получить. Не может быть использовано с <var>$1userids</var> или <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "Получить вклад всех участников, имена которых начинаются с этого значения. Не может быть использовано с <var>$1user</var> или <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "Идентификаторы участников, чей вклад необходимо получить. Не может быть использовано с <var>$1user</var> или <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "Перечислять только правки в этих пространствах имён.",
+ "apihelp-query+usercontribs-param-prop": "Включить дополнительную информацию:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Добавляет идентификатор страницы и версии.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Добавляет заголовок и идентификатор пространства имён страницы.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Добавляет временную метку правки.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Добавляет описание правки.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Добавляет распарсенное описание правки.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Добавляет новый размер страницы.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Добавляет разницу между размерами страницы до и после правки.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Добавляет флаги правки.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Отмечает отпатрулированные правки.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Перечисляет метки правки.",
+ "apihelp-query+usercontribs-param-show": "Показать только элементы, удовлетворяющие данным критериям, например, только не малые правки: <kbd>$2show=!minor</kbd>.\n\nЕсли установлено <kbd>$2show=patrolled</kbd> или <kbd>$2show=!patrolled</kbd>, правки старее <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var> ($1 {{PLURAL:$1|секунды|секунд}}) не будут показаны.",
+ "apihelp-query+usercontribs-param-tag": "Только правки с заданной меткой.",
+ "apihelp-query+usercontribs-param-toponly": "Перечислять только последние правки страниц.",
+ "apihelp-query+usercontribs-example-user": "Показать вклад участника <kbd>Example</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "Показать вклад со всех IP-адресов, начинающихся на <kbd>192.0.2.</kbd>.",
+ "apihelp-query+userinfo-summary": "Получение информации о текущем участнике.",
+ "apihelp-query+userinfo-param-prop": "Какую информацию включить:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Определяет, заблокирован ли текущий участник, кем и по какой причине.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Добавляет метку <samp>messages</samp>, если у текущего участника есть непрочитанные сообщения.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Перечисляет все группы, в которые входит участник.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Возвращает группы, в которые участник был явно включён, включая дату окончания членства для каждой группы.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Перечисляет все группы, в которые участник был включён автоматически.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Перечисляет все права текущего участника.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Перечисляет группы, в которые или из которых участник может добавить или удалить других участников.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Перечисляет все настройки, установленные текущим участником.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Возвращает токен для смены настроек текущего участника.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Добавляет счётчик правок текущего участника.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Добавляет все скоростные лимиты, применимые к текущему участнику.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Добавляет настоящее имя участника.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Добавляет электронный адрес участника и дату проверки его подлинности.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Возвращает назад заголовок <code>Accept-Language</code>, отосланный клиентом, в структурированном формате.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Добавляет дату регистрации участника.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Добавляет число непрочитанных страниц в странице наблюдения участника (максимум $1; возвращает <samp>$2</samp>, если их больше).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "Добавляет центральный идентификатор и статус прикрепления участника.",
+ "apihelp-query+userinfo-param-attachedwiki": "Вместе с <kbd>$1prop=centralids</kbd> отображает, прикреплён ли к вики участник с этим идентификатором.",
+ "apihelp-query+userinfo-example-simple": "Получение информации о текущем участнике.",
+ "apihelp-query+userinfo-example-data": "Получение дополнительной информации о текущем участнике.",
+ "apihelp-query+users-summary": "Получение информации о списке участников.",
+ "apihelp-query+users-param-prop": "Какую информацию включить:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Определяет, заблокирован ли участник, кем и по какой причине.",
+ "apihelp-query+users-paramvalue-prop-groups": "Перечисляет все группы, в которые входит каждый участник.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "Возвращает группы, в которые каждый участник был явно включён, включая дату окончания членства для каждой группы.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Перечисляет группы, в которые участник был включён автоматически.",
+ "apihelp-query+users-paramvalue-prop-rights": "Перечисляет все права каждого участника.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Добавляет счётчики правок участников.",
+ "apihelp-query+users-paramvalue-prop-registration": "Добавляет даты регистрации участников.",
+ "apihelp-query+users-paramvalue-prop-emailable": "Отмечает, может ли и хочет ли участник получать электронную почту посредством [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "Отмечает пол текущего участника. Возвращает «male», «female» или «unknown».",
+ "apihelp-query+users-paramvalue-prop-centralids": "Добавляет центральный идентификатор и статус прикрепления участника.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Определяет, могут ли быть созданы аккаунты с корректными, но незарегистрированными именами.",
+ "apihelp-query+users-param-attachedwiki": "Вместе с <kbd>$1prop=centralids</kbd> отображает, прикреплён ли к вики участник с этим идентификатором.",
+ "apihelp-query+users-param-users": "Список участников, для которых получить информацию.",
+ "apihelp-query+users-param-userids": "Список идентификаторов участников, для которых получить информацию.",
+ "apihelp-query+users-param-token": "Вместо этого используйте <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-query+users-example-simple": "Вернуть информацию о участнике <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-summary": "Получение последних правок страниц из списка наблюдения текущего участника.",
+ "apihelp-query+watchlist-param-allrev": "Включить несколько правок одной страницы из заданного временного промежутка.",
+ "apihelp-query+watchlist-param-start": "Временная метка, с которой начать перечисление.",
+ "apihelp-query+watchlist-param-end": "Временная метка, на которой закончить перечисление.",
+ "apihelp-query+watchlist-param-namespace": "Только правки в этих пространствах имён.",
+ "apihelp-query+watchlist-param-user": "Только правки данного участника.",
+ "apihelp-query+watchlist-param-excludeuser": "Не перечислять правки данного участника.",
+ "apihelp-query+watchlist-param-limit": "Сколько результатов возвращать за один запрос.",
+ "apihelp-query+watchlist-param-prop": "Какие дополнительные свойства получить:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Добавляет идентификаторы страницы и версии.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Добавляет заголовок страницы.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Добавляет флаги правки.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Добавляет участника, сделавшего правку.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Добавляет идентификатор участника, сделавшего правку.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Добавляет описание правки.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Добавляет распарсенное описание правки.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Добавляет временную метку правки.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Определяет, была ли правка отпатрулирована.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Добавляет старую и новую длину страницы.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Добавляет метку времени, когда участник был уведомлён о правке.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Добавляет информацию о журнале, где уместно.",
+ "apihelp-query+watchlist-param-show": "Показать только элементы, удовлетворяющие данным критериям. Например, чтобы отобразить только малые правки, сделанные зарегистрированными участниками, установите $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "Какие типы правок показать:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Обычные правки страниц.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Внешние правки.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Создания страниц.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Записи журнала.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Правки категоризации.",
+ "apihelp-query+watchlist-param-owner": "Используется вместе с $1token для получения списка наблюдения другого участника.",
+ "apihelp-query+watchlist-param-token": "Токен безопасности (доступен в [[Special:Preferences#mw-prefsection-watchlist|настройках]] участника), предоставляющий доступ к списку наблюдения другого участника.",
+ "apihelp-query+watchlist-example-simple": "Список последних правок недавно отредактированных страниц из списка наблюдения текущего участника.",
+ "apihelp-query+watchlist-example-props": "Запросить дополнительную информацию о последних правках недавно отредактированных страниц из списка наблюдения текущего участника.",
+ "apihelp-query+watchlist-example-allrev": "Запросить информацию о всех недавних правках страниц из списка наблюдения текущего участника.",
+ "apihelp-query+watchlist-example-generator": "Запросить информацию о страницах для недавно отредактированных страниц из списка наблюдения текущего участника.",
+ "apihelp-query+watchlist-example-generator-rev": "Запросить информацию о версиях для последних правок страниц из списка наблюдения текущего участника.",
+ "apihelp-query+watchlist-example-wlowner": "Список последних правок недавно отредактированных страниц из списка наблюдения участника <kbd>Example</kbd>.",
+ "apihelp-query+watchlistraw-summary": "Получение всех страниц из списка наблюдения текущего участника.",
+ "apihelp-query+watchlistraw-param-namespace": "Перечислять только страницы этих пространств имён.",
+ "apihelp-query+watchlistraw-param-limit": "Сколько результатов возвращать за один запрос.",
+ "apihelp-query+watchlistraw-param-prop": "Какие дополнительные свойства получить:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Добавляет метку времени, когда участник был уведомлён о правке.",
+ "apihelp-query+watchlistraw-param-show": "Перечислять только элементы, соответствующие этим критериям.",
+ "apihelp-query+watchlistraw-param-owner": "Используется вместе с $1token для получения списка наблюдения другого участника.",
+ "apihelp-query+watchlistraw-param-token": "Токен безопасности (доступен в [[Special:Preferences#mw-prefsection-watchlist|настройках]] участника), предоставляющий доступ к списку наблюдения другого участника.",
+ "apihelp-query+watchlistraw-param-dir": "Порядок перечисления.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Название (с префиксом пространства имён), с которого начать перечисление.",
+ "apihelp-query+watchlistraw-param-totitle": "Название (с префиксом пространства имён), на котором закончить перечисление.",
+ "apihelp-query+watchlistraw-example-simple": "Получение страниц из списка наблюдения текущего участника.",
+ "apihelp-query+watchlistraw-example-generator": "Запросить информацию о страницах из списка наблюдения текущего участника.",
+ "apihelp-removeauthenticationdata-summary": "Удаление аутентификационных данных для текущего участника.",
+ "apihelp-removeauthenticationdata-example-simple": "Попытка удалить данные текущего участника для <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "Отправить участнику письмо для сброса пароля.",
+ "apihelp-resetpassword-extended-description-noroutes": "Маршруты смены пароля не доступны.\n\nВключите маршруты в <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var> для использования этого модуля.",
+ "apihelp-resetpassword-param-user": "Сбрасываемый участник.",
+ "apihelp-resetpassword-param-email": "Электронный адрес сбрасываемого участника.",
+ "apihelp-resetpassword-example-user": "Послать письмо для сброса пароля участнику <kbd>Example</kbd>.",
+ "apihelp-resetpassword-example-email": "Послать письмо для сброса пароля всем участникам с электронным адресом <kbd>user@example.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Удаление и восстановление версий страниц.",
+ "apihelp-revisiondelete-param-type": "Тип осуществляемого удаления версии.",
+ "apihelp-revisiondelete-param-target": "Название страницы удаляемой версии, если это требуется для выбранного типа.",
+ "apihelp-revisiondelete-param-ids": "Идентификаторы удаляемых версий.",
+ "apihelp-revisiondelete-param-hide": "Что скрыть для каждой версии.",
+ "apihelp-revisiondelete-param-show": "Что показать для каждой версии.",
+ "apihelp-revisiondelete-param-suppress": "Следует ли скрыть данные от администраторов так же, как и от остальных участников.",
+ "apihelp-revisiondelete-param-reason": "Причина удаления или восстановления.",
+ "apihelp-revisiondelete-param-tags": "Изменить метки записи в журнале удалений.",
+ "apihelp-revisiondelete-example-revision": "Скрыть содержимое версии <kbd>12345</kbd> страницы <kbd>Main Page</kbd>.",
+ "apihelp-revisiondelete-example-log": "Скрыть все данные записи <kbd>67890</kbd> в журнале с причиной <kbd>BLP violation</kbd>.",
+ "apihelp-rollback-summary": "Отмена последней правки на странице.",
+ "apihelp-rollback-extended-description": "Если последний редактировавший страницу участник сделал несколько правок подряд, все они будут откачены.",
+ "apihelp-rollback-param-title": "Заголовок откатываемой страницы. Не может быть использовано одновременно с <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "Идентификатор откатываемой страницы. Не может быть использовано одновременно с <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "Метки, применяемые к откату.",
+ "apihelp-rollback-param-user": "Имя участника, чьи правки следует откатить.",
+ "apihelp-rollback-param-summary": "Пользовательское описание правки. При пустом значении будет использовано стандартное описание.",
+ "apihelp-rollback-param-markbot": "Отметить откатываемые правки и откат как правки бота.",
+ "apihelp-rollback-param-watchlist": "Безусловно добавить или удалить страницу из списка наблюдения текущего участника, использовать настройки или не менять наблюдение.",
+ "apihelp-rollback-example-simple": "Откатить последние изменения страницы <kbd>Main Page</kbd> участника <kbd>Example</kbd>.",
+ "apihelp-rollback-example-summary": "Откатить последние правки страницы <kbd>Main Page</kbd> анонимного участника <kbd>192.0.2.5</kbd> с описанием <kbd>Reverting vandalism</kbd>, и отметить эти правки и их откат как правки ботов.",
+ "apihelp-rsd-summary": "Экспорт схемы RSD (Really Simple Discovery).",
+ "apihelp-rsd-example-simple": "Экспортировать схему RSD.",
+ "apihelp-setnotificationtimestamp-summary": "Обновление временной метки уведомления для отслеживаемых страниц.",
+ "apihelp-setnotificationtimestamp-extended-description": "Это затрагивает подсвечивание изменённых страниц в списке наблюдения и истории, и отправляет письмо, если включена настройка «{{int:tog-enotifwatchlistpages}}».",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Работать над всеми отслеживаемыми страницами.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "Новая временная метка уведомления.",
+ "apihelp-setnotificationtimestamp-param-torevid": "Версия, к временной метке которой приравнять временную метку уведомления (только для одной страницы).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "Версия, новее которой сделать временную метку уведомления (только для одной страницы).",
+ "apihelp-setnotificationtimestamp-example-all": "Сбросить статус уведомления для всего списка наблюдения.",
+ "apihelp-setnotificationtimestamp-example-page": "Сбросить статус уведомления для <kbd>Main page</kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "Установить временную метку уведомления для страницы <kbd>Main page</kbd> таким образом, чтобы сделать все правки с 1 января 2012 года непросмотренными.",
+ "apihelp-setnotificationtimestamp-example-allpages": "Сбросить статус уведомления для страниц из пространства имён <kbd>{{ns:user}}</kbd>.",
+ "apihelp-setpagelanguage-summary": "Изменить язык страницы.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Изменение языка страницы не разрешено в этой вики.\n\nАктивируйте <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var> для использования этого действия.",
+ "apihelp-setpagelanguage-param-title": "Название страницы, язык которой вы желаете поменять. Не может быть использовано одновременно с <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "Идентификатор страницы, язык которой вы желаете поменять. Не может быть использовано одновременно с <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "Код нового языка. Используйте <kbd>default</kbd> для смены на язык содержимого по умолчанию для этой вики.",
+ "apihelp-setpagelanguage-param-reason": "Причина изменения.",
+ "apihelp-setpagelanguage-param-tags": "Изменить теги записей в журнале, возникающих в результате этого действия.",
+ "apihelp-setpagelanguage-example-language": "Изменить язык <kbd>Main Page</kbd> на баскский.",
+ "apihelp-setpagelanguage-example-default": "Изменить язык страницы с идентификатором 123 на язык по умолчанию.",
+ "apihelp-stashedit-summary": "Подготовка правки в общем кэше.",
+ "apihelp-stashedit-extended-description": "Предназначено для использования через AJAX из формы редактирования для увеличения производительности сохранения страницы.",
+ "apihelp-stashedit-param-title": "Заголовок редактируемой страницы.",
+ "apihelp-stashedit-param-section": "Номер раздела. <kbd>0</kbd> для верхнего раздела, <kbd>new</kbd> для нового раздела.",
+ "apihelp-stashedit-param-sectiontitle": "Заголовок нового раздела.",
+ "apihelp-stashedit-param-text": "Содержимое страницы.",
+ "apihelp-stashedit-param-stashedtexthash": "Хэш содержимого страницы в кэше.",
+ "apihelp-stashedit-param-contentmodel": "Модель нового содержимого.",
+ "apihelp-stashedit-param-contentformat": "Формат сериализации содержимого, используемый для введённого текста.",
+ "apihelp-stashedit-param-baserevid": "Идентификатор предыдущей версии.",
+ "apihelp-stashedit-param-summary": "Описание правки.",
+ "apihelp-tag-summary": "Добавление или удаление меток отдельных правок или записей журналов.",
+ "apihelp-tag-param-rcid": "Один или более идентификаторов правок, метки которых нужно добавить или удалить.",
+ "apihelp-tag-param-revid": "Один или более идентификаторов версий, метки которых нужно добавить или удалить.",
+ "apihelp-tag-param-logid": "Один или более идентификаторов записей журналов, метки которых нужно добавить или удалить.",
+ "apihelp-tag-param-add": "Добавляемые метки. Добавлять можно только метки, заданные вручную.",
+ "apihelp-tag-param-remove": "Удаляемые метки. Удалять можно только метки, заданные вручную или не заданные.",
+ "apihelp-tag-param-reason": "Причина изменения.",
+ "apihelp-tag-param-tags": "Метки, применяемые к записи в журнале, создаваемой в результате этого действия.",
+ "apihelp-tag-example-rev": "Добавить метку <kbd>vandalism</kbd> к версии с идентификатором 123 без указания причины.",
+ "apihelp-tag-example-log": "Удаление метки <kbd>spam</kbd> из записи журнала с идентификатором 123 с причиной <kbd>Wrongly applied</kbd>.",
+ "apihelp-tokens-summary": "Получение токенов для действий, связанных с редактированием данных.",
+ "apihelp-tokens-extended-description": "Этот модуль не поддерживается в пользу [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-tokens-param-type": "Типы запрашиваемых токенов.",
+ "apihelp-tokens-example-edit": "Получить токен редактирования (по умолчанию).",
+ "apihelp-tokens-example-emailmove": "Получить токен электронной почты и переименования.",
+ "apihelp-unblock-summary": "Разблокировка участника.",
+ "apihelp-unblock-param-id": "Идентификатор снимаемой блокировки (получается с помощью <kbd>list=blocks</kbd>). Не может быть использовано одновременно с <var>$1user</var> или <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "Имя участника, IP-адрес или диапазон IP-адресов, которые вы хотите разблокировать. Нельзя использовать одновременно с <var>$1userid</var>",
+ "apihelp-unblock-param-userid": "Идентификатор участника, которого вы хотите разблокировать. Нельзя использовать одновременно с <var>$1id</var> или <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "Причина разблокировки.",
+ "apihelp-unblock-param-tags": "Изменить метки записи в журнале блокировок.",
+ "apihelp-unblock-example-id": "Снять блокировку с идентификатором #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "Разблокировать участника <kbd>Bob</kbd> по причине <kbd>Sorry Bob</kbd>.",
+ "apihelp-undelete-summary": "Восстановление версий удалённой страницы.",
+ "apihelp-undelete-extended-description": "Список удалённых версий с временными метками может быть получен с помощью [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]], а список идентификаторов удалённых файлов может быть получен с помощью [[Special:ApiHelp/query+filearchive|list=filearchive]].",
+ "apihelp-undelete-param-title": "Заголовок восстанавливаемой страницы.",
+ "apihelp-undelete-param-reason": "Причина восстановления.",
+ "apihelp-undelete-param-tags": "Изменить метки записи в журнале удалений.",
+ "apihelp-undelete-param-timestamps": "Временные метки восстанавливаемых версий. Если и <var>$1timestamps</var>, и <var>$1fileids</var> пустые, все версии будут восстановлены.",
+ "apihelp-undelete-param-fileids": "Идентификаторы восстанавливаемых файловых версий. Если и <var>$1timestamps</var>, и <var>$1fileids</var> пустые, все версии будут восстановлены.",
+ "apihelp-undelete-param-watchlist": "Безусловно добавить или удалить страницу из списка наблюдения текущего участника, использовать настройки или не менять наблюдение.",
+ "apihelp-undelete-example-page": "Восстановить страницу <kbd>Main Page</kbd>.",
+ "apihelp-undelete-example-revisions": "Восстановить две версии страницы <kbd>Main Page</kbd>.",
+ "apihelp-unlinkaccount-summary": "Удаление связанного стороннего аккаунта с текущим участником.",
+ "apihelp-unlinkaccount-example-simple": "Попытаться удалить связь между текущим участником и <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-upload-summary": "Загрузка файла или получение статуса незавершённых загрузок.",
+ "apihelp-upload-extended-description": "Доступно несколько режимов:\n* Прямо загрузить содержимое файла, используя параметр <var>$1file</var>.\n* Загрузить файл по кусочком, используя параметры <var>$1filesize</var>, <var>$1chunk</var> и <var>$1offset</var>.\n* Заставить сервер MediaWiki запросить файл по ссылке, используя параметр <var>$1url</var>.\n* Завершить старую загрузку, провалившуюся из-за предупреждений, используя параметр <var>$1filekey</var>.\nОбратите внимание, что запрос HTTP POST должен быть выполнен как загрузка файла (то есть, с использованием <code>multipart/form-data</code>) при отправке <var>$1file</var>.",
+ "apihelp-upload-param-filename": "Целевое название файла.",
+ "apihelp-upload-param-comment": "Описание загрузки. Также используется как начальный текст страницы при загрузке нового файла, если параметр <var>$1text</var> не задан.",
+ "apihelp-upload-param-tags": "Изменить метки записи в журнале загрузок и версии файловой страницы.",
+ "apihelp-upload-param-text": "Начальный текст страницы для новых файлов.",
+ "apihelp-upload-param-watch": "Наблюдать за этой страницей",
+ "apihelp-upload-param-watchlist": "Безусловно добавить или удалить страницу из списка наблюдения текущего участника, использовать настройки или не менять наблюдение.",
+ "apihelp-upload-param-ignorewarnings": "Игнорировать все предупреждения.",
+ "apihelp-upload-param-file": "Содержимое файла.",
+ "apihelp-upload-param-url": "Ссылка на запрашиваемый файл.",
+ "apihelp-upload-param-filekey": "Ключ, идентифицирующий предыдущую временную загрузку.",
+ "apihelp-upload-param-sessionkey": "Синоним $1filekey, обслуживаемый для обратной совместимости.",
+ "apihelp-upload-param-stash": "Если задано, сервер временно поместит файл в тайник вместо загрузки его в хранилище.",
+ "apihelp-upload-param-filesize": "Полны размер файла.",
+ "apihelp-upload-param-offset": "Смещение блока в байтах.",
+ "apihelp-upload-param-chunk": "Содержимое кусочка.",
+ "apihelp-upload-param-async": "Сделать операции над потенциально большими файлами асинхронными, когда это возможно.",
+ "apihelp-upload-param-checkstatus": "Только запросить статус загрузки для данного файлового ключа.",
+ "apihelp-upload-example-url": "Загрузка через URL.",
+ "apihelp-upload-example-filekey": "Завершение загрузки, провалившейся из-за предупреждений.",
+ "apihelp-userrights-summary": "Изменение групп участника.",
+ "apihelp-userrights-param-user": "Имя участника.",
+ "apihelp-userrights-param-userid": "Идентификатор участника.",
+ "apihelp-userrights-param-add": "Добавить участника в эти группы, или, если они уже являются её членами, обновить дату истечения членства в этих группах.",
+ "apihelp-userrights-param-expiry": "Временная метка истечения. Может быть относительной (например, <kbd>5 months</kbd> или <kbd>2 weeks</kbd>) или абсолютной (например, <kbd>2014-09-18T12:34:56Z</kbd>). Если задана только одна временная метка, она будет использована для всех групп, переданных в параметре <var>$1add</var>. Используйте <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> или <kbd>never</kbd> для неистекаемой группы.",
+ "apihelp-userrights-param-remove": "Удалить участника из этих групп.",
+ "apihelp-userrights-param-reason": "Причина изменения.",
+ "apihelp-userrights-param-tags": "Изменить метки записи в журнале прав.",
+ "apihelp-userrights-example-user": "Добавить участника <kbd>FooBot</kbd> в группу <kbd>bot</kbd> и удалить его из групп <kbd>sysop</kbd> и <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Добавить участника с идентификатором <kbd>123</kbd> в группу <kbd>bot</kbd> и удалить его из групп <kbd>sysop</kbd> и <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-expiry": "Добавить участника <kbd>SometimeSysop</kbd> в группу <kbd>sysop</kbd> на один месяц.",
+ "apihelp-validatepassword-summary": "Проверка пароля на удовлетворение политики вики.",
+ "apihelp-validatepassword-extended-description": "Результатом проверки является <samp>Good</samp>, если пароль приемлемый, <samp>Change</samp>, если пароль может быть использован для входа, но должен быть сменён, и <samp>Invalid</samp>, если пароль не может быть использован.",
+ "apihelp-validatepassword-param-password": "Проверяемый пароль.",
+ "apihelp-validatepassword-param-user": "Имя участника, при использовании во время создания аккаунта. Такого участника не должно существовать.",
+ "apihelp-validatepassword-param-email": "Электронная почта, при использовании во время создания аккаунта.",
+ "apihelp-validatepassword-param-realname": "Настоящее имя, при использовании во время создания аккаунта.",
+ "apihelp-validatepassword-example-1": "Проверка пароля <kbd>foobar</kbd> для текущего участника.",
+ "apihelp-validatepassword-example-2": "Проверка пароля <kbd>querty</kbd> для создаваемого участника <kbd>Example</kbd>.",
+ "apihelp-watch-summary": "Добавление или удаление страниц из списка наблюдения текущего участника.",
+ "apihelp-watch-param-title": "Название страницы. Используйте <var>$1titles</var> вместо этого.",
+ "apihelp-watch-param-unwatch": "Если установлено, страницы будут удалены из списка наблюдения, а не добавлены в него.",
+ "apihelp-watch-example-watch": "Следить за страницей <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Не следить за страницей <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-generator": "Следить за первым несколькими страницами основного пространства имён.",
+ "apihelp-format-example-generic": "Вернуть результат запроса в формате $1.",
+ "apihelp-format-param-wrappedhtml": "Вернуть хорошо читаемый HTML со связанными модулями ResourceLoader в виде объекта JSON.",
+ "apihelp-json-summary": "Выводить данные в формате JSON.",
+ "apihelp-json-param-callback": "Если задано, оборачивает вывод в вызов данной функции. Из соображении безопасности, вся пользовательская информация будет удалена.",
+ "apihelp-json-param-utf8": "Если задано, кодирует большинство (но не все) не-ASCII символов в UTF-8 вместо замены их на шестнадцатеричные коды. Применяется по умолчанию, когда <var>formatversion</var> не равно <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "Если задано, заменяет все не-ASCII-символы на шестнадцатеричные коды. Применяется по умолчанию, когда <var>formatversion</var> равно <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Формат вывода:\n;1: Обратно совместимый формат (логические значения в стиле XML, ключи <samp>*</samp> для узлов данных, и так далее).\n;2: Экспериментальный современный формат. Детали могут меняться!\n;latest: Использовать последний формат (сейчас <kbd>2</kbd>), может меняться без предупреждения.",
+ "apihelp-jsonfm-summary": "Выводить данные в формате JSON (отформатированном в HTML).",
+ "apihelp-none-summary": "Ничего не выводить.",
+ "apihelp-php-summary": "Выводить данные в сериализованном формате PHP.",
+ "apihelp-php-param-formatversion": "Формат вывода:\n;1: Обратно совместимый формат (логические значения в стиле XML, ключи <samp>*</samp> для узлов данных, и так далее).\n;2: Экспериментальный современный формат. Детали могут меняться!\n;latest: Использовать последний формат (сейчас <kbd>2</kbd>), может меняться без предупреждения.",
+ "apihelp-phpfm-summary": "Выводить данные в сериализованном формате PHP (отформатированном в HTML).",
+ "apihelp-rawfm-summary": "Выводить данные, включая элементы отладки, в формате JSON (отформатированном в HTML).",
+ "apihelp-xml-summary": "Выводить данные в формате XML.",
+ "apihelp-xml-param-xslt": "Если задано, добавляет названную страницу в качестве листа XSL. Значением должно быть название в пространстве имён {{ns:MediaWiki}}, заканчивающееся на <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Если задано, добавляет пространство имён XML.",
+ "apihelp-xmlfm-summary": "Выводить данные в формате XML (отформатированном в HTML).",
+ "api-format-title": "Результат MediaWiki API",
+ "api-format-prettyprint-header": "Это HTML-представление формата $1. HTML хорош для отладки, но неудобен для практического применения.\n\nУкажите параметр <var>format</var> для изменения формата вывода. Для отображения не-HTML-представления формата $1, присвойте <kbd>format=$2</kbd>.\n\nСм. [[mw:Special:MyLanguage/API|полную документацию]] или [[Special:ApiHelp/main|справку API]] для получения дополнительной информации.",
+ "api-format-prettyprint-header-only-html": "Это HTML-представление для отладки, не рассчитанное на практическое применение.\n\nСм. [[mw:Special:MyLanguage/API|полную документацию]] или [[Special:ApiHelp/main|справку API]] для получения дополнительной информации.",
+ "api-format-prettyprint-header-hyperlinked": "Это HTML-представление формата $1. HTML хорош для отладки, но неудобен для практического применения.\n\nУкажите параметр <var>format</var> для изменения формата вывода. Для отображения не-HTML-представления формата $1, присвойте [$3 <kbd>format=$2</kbd>].\n\nСм. [[mw:API|полную документацию]] или [[Special:ApiHelp/main|справку API]] для получения дополнительной информации.",
+ "api-format-prettyprint-status": "Этот ответ будет возвращён HTTP статусом $1 $2.",
+ "api-login-fail-aborted": "Аутентификация требует взаимодействия с пользователем, что не поддерживается <kbd>action=login</kbd>. Чтобы авторизовываться через <kbd>action=login</kbd>, см. [[Special:BotPasswords]]. Для продолжения использования авторизации основного аккаунта см. <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-aborted-nobotpw": "Аутентификация требует взаимодействия с пользователем, что не поддерживается <kbd>action=login</kbd>. Для авторизации см. <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-badsessionprovider": "Авторизация при использовании $1 невозможна.",
+ "api-login-fail-sameorigin": "Авторизация при использовании правила ограничения домена невозможна.",
+ "api-pageset-param-titles": "Список заголовков для работы.",
+ "api-pageset-param-pageids": "Список идентификаторов страниц для работы.",
+ "api-pageset-param-revids": "Список идентификаторов версий для работы.",
+ "api-pageset-param-generator": "Получить список страниц для работы, запустив указанный запрос-модуль.\n\n<strong>Примечание:</strong> названия параметров генераторов должны начинаться с «g», см. примеры.",
+ "api-pageset-param-redirects-generator": "Автоматически разрешать перенаправления в <var>$1titles</var>, <var>$1pageids</var> и <var>$1revids</var>, а также на страницах, возвращённых <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Автоматически разрешать перенаправления в <var>$1titles</var>, <var>$1pageids</var> и <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Преобразовать заголовки в другой вариант, если это необходимо. Работает только если язык содержимого вики поддерживает преобразование вариантов. Языки, поддерживающие преобразование, включают в себя $1.",
+ "api-help-title": "Справка MediaWiki API",
+ "api-help-lead": "Это автоматически сгенерированная страница документации MediaWiki API.\n\nДокументация и примеры: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Главный модуль",
+ "api-help-undocumented-module": "Нет документации для модуля $1.",
+ "api-help-flag-deprecated": "Этот модуль не поддерживается.",
+ "api-help-flag-internal": "<strong>Этот модуль внутренний или нестабильный.</strong> Его операции могут измениться без предупреждения.",
+ "api-help-flag-readrights": "Этот модуль требует прав на чтение.",
+ "api-help-flag-writerights": "Этот модуль требует прав на запись.",
+ "api-help-flag-mustbeposted": "Этот модуль принимает только POST-запросы.",
+ "api-help-flag-generator": "Этот модуль может быть использован в качестве генератора.",
+ "api-help-source": "Источник: $1",
+ "api-help-source-unknown": "Источник: <span class=\"apihelp-unknown\">unknown</span>",
+ "api-help-license": "Лицензия: [[$1|$2]]",
+ "api-help-license-noname": "Лицензия: [[$1|см. ссылку]]",
+ "api-help-license-unknown": "Лицензия: <span class=\"apihelp-unknown\">unknown</span>",
+ "api-help-parameters": "Параметр{{PLURAL:$1||ы}}:",
+ "api-help-param-deprecated": "Не поддерживается.",
+ "api-help-param-required": "Это обязательный параметр.",
+ "api-help-datatypes-header": "Типы данных",
+ "api-help-datatypes": "Ввод в MediaWiki должен быть NFC-нормализованным UTF-8. MediaWiki может попытаться преобразовать другой ввод, но это приведёт к провалу некоторых операций (таких, как [[Special:ApiHelp/edit|редактирование]] со сверкой MD5).\n\nНекоторые типы параметров в запросах API требуют дополнительных пояснений:\n;логический\n:Логические параметры работают как флажки (checkboxes) в HTML: если параметр задан, независимо от его значения, он воспринимается за истину. Для передачи ложного значения просто опустите параметр.\n;временные метки\n:Временные метки могут быть заданы в нескольких форматах. Рекомендуемым является дата и время ISO 8601. Всё время считается в UTC, любые включённые часовые пояса игнорируются.\n:* Дата и время ISO 8601: <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (знаки препинания и <kbd>Z</kbd> необязательны)\n:* Дата и время ISO 8601 с (игнорируемой) дробной частью секунд: <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (дефисы, двоеточия и <kbd>Z</kbd> необязательны)\n:* Формат MediaWiki: <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Общий числовой формат: <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (необязательный часовой пояс <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd> или <kbd>-<var>##</var></kbd> игнорируется)\n:* Формат EXIF: <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Формат RFC 2822 (часовой пояс может быть опущен): <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Формат RFC 850 (часовой пояс может быть опущен): <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Формат ctime языка программирования C: <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Количество секунд, прошедших с 1970-01-01T00:00:00Z, в виде челого числа с от 1 до 13 знаками (исключая <kbd>0</kbd>)\n:* Строка <kbd>now</kbd>\n;альтернативный разделитель значений\n:Параметры, принимающие несколько значений, обычно отправляются со значениями, разделёнными с помощью символа пайпа, например, <kbd>param=value1|value2</kbd> или <kbd>param=value1%7Cvalue2</kbd>. Если значение должно содержать символ пайпа, используйте U+001F (Unit Separator) в качестве разделителя ''и'' добавьте в начало значения U+001F, например, <kbd>param=%1Fvalue1%1Fvalue2</kbd>.",
+ "api-help-param-type-limit": "Тип: целое число или <kbd>max</kbd>",
+ "api-help-param-type-integer": "Тип: {{PLURAL:$1|1=целое число|2=список целых чисел}}",
+ "api-help-param-type-boolean": "Тип: логический ([[Special:ApiHelp/main#main/datatypes|подробнее]])",
+ "api-help-param-type-timestamp": "Тип: {{PLURAL:$1|1=временная метка|2=список временных меток}} ([[Special:ApiHelp/main#main/datatypes|разрешённые форматы]])",
+ "api-help-param-type-user": "Тип: {{PLURAL:$1|1=имя участника|2=список имён участников}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Одно из следующих значений|2=Значения (разделённые с помощью <kbd>{{!}}</kbd> или [[Special:ApiHelp/main#main/datatypes|альтернативного разделителя]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Должен быть пустым|Может быть пустым или $2}}",
+ "api-help-param-limit": "Разрешено не более $1.",
+ "api-help-param-limit2": "Разрешено не более $1 ($2 для ботов).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=Значение должно|2=Значения должны}} быть не меньше $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Значение должно|2=Значения должны}} быть не больше $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Значение должно|2=Значения должны}} быть между $2 и $3.",
+ "api-help-param-upload": "Должно быть отправлено как файл с использованием multipart/form-data.",
+ "api-help-param-multi-separate": "Разделяйте значения с помощью <kbd>|</kbd> или [[Special:ApiHelp/main#main/datatypes|альтернативного разделителя]].",
+ "api-help-param-multi-max": "Максимально разрешённое количество значений — {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} для ботов).",
+ "api-help-param-multi-max-simple": "Максимальное количество значений — {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "Для указания всех значений, используйте <kbd>$1</kbd>.",
+ "api-help-param-default": "По умолчанию: $1",
+ "api-help-param-default-empty": "По умолчанию: <span class=\"apihelp-empty\">(пусто)</span>",
+ "api-help-param-token": "Токен «$1», полученный из [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "Для обратной совместимости, токен, используемый в веб-интерфейсе, также применим.",
+ "api-help-param-disabled-in-miser-mode": "Отключено из-за [[mw:Special:MyLanguage/Manual:$wgMiserMode|жадного режима]].",
+ "api-help-param-limited-in-miser-mode": "<strong>Примечание:</strong> Из-за [[mw:Special:MyLanguage/Manual:$wgMiserMode|жадного режима]], использование этого может привести к меньшему, чем <var>$1limit</var>, числу результатов перед продолжением; в крайнем случае, может вернуться и ноль результатов.",
+ "api-help-param-direction": "В каком порядке перечислять:\n;newer: Начать с самых старых. Обратите внимание: $1start должно быть раньше $1end.\n;older: Начать с самых новых (по умолчанию). Обратите внимание: $1start должно быть позже $1end.",
+ "api-help-param-continue": "Когда доступно больше результатов, используйте это для продолжения.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(описание отсутствует)</span>",
+ "api-help-examples": "Пример{{PLURAL:$1||ы}}:",
+ "api-help-permissions": "{{PLURAL:$1|Разрешение|Разрешения}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Гарантируется}}: $2",
+ "api-help-right-apihighlimits": "Использовать высокие лимиты в запросах API (медленные запросы: $1, быстрые запросы: $2). Лимиты для медленных запросов также применимы к параметрам со множеством значений.",
+ "api-help-open-in-apisandbox": "<small>[открыть в песочнице]</small>",
+ "api-help-authmanager-general-usage": "Стандартная процедура использования этого модуля такова:\n# Запрос полей, доступных из <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> с <kbd>amirequestsfor=$4</kbd>, и токена <kbd>$5</kbd> из <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# Предоставление полей пользователю и получение его данных.\n# Запрос к этому модулю, содержащий <var>$1returnurl</var> или аналогичное поле.\n# Проверка поля <samp>status</samp> ответа.\n#* Если вы получили <samp>PASS</samp> или <samp>FAIL</samp>, вы закончили. Операция либо завершилась успехом, либо нет.\n#* Если вы получили <samp>UI</samp>, предоставьте новые поля польззователю и получите новые данные. Затем совершите новый запрос с параметром <var>$1continue</var> и новыми полями, после чего повторите пункт 4.\n#* Если вы получили <samp>REDIRECT</samp>, отправьте пользователя на <samp>redirecttarget</samp> и подождите возвращения на <var>$1returnurl</var>. Затем совершите запрос к этому модулю с параметром <var>$1continue</var> и всеми полями, содержащимися в возвращённой ссылке, и повторите пункт 4.\n#* Если вы получили <samp>RESTART</samp>, это означает, что аутентификация работает, но мы не привязали пользовательский аккаунт. Вы можете рассматривать это как <samp>UI</samp> или <samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-requests": "Использовать только эти аутентификационные запросы, с <samp>id</samp>, возвращённом из <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> с <kbd>amirequestsfor=$1</kbd>, или из предыдущего ответа этого модуля.",
+ "api-help-authmanagerhelper-request": "Использовать этот аутентификационный запрос, с <samp>id</samp>, возвращённом из <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> с <kbd>amirequestsfor=$1</kbd>.",
+ "api-help-authmanagerhelper-messageformat": "Формат, используемый для возвращаемых сообщений.",
+ "api-help-authmanagerhelper-mergerequestfields": "Слить поля информации со всех аутентификационных запросов в один массив.",
+ "api-help-authmanagerhelper-preservestate": "Сохранить состояние с предыдущей провалившейся попытки авторизации, если возможно.",
+ "api-help-authmanagerhelper-returnurl": "Вернуть ссылку для стороннего процесса аутентификации, должна быть абсолютной. Либо этот параметр, либо <var>$1continue</var>, обязателен.\n\nПосле получения ответа <samp>REDIRECT</samp>, вы, как правило, должны открыть в браузере или вэб-просмотрщике указанную в <samp>redirecttarget</samp> ссылку для продолжения стороннего процесса аутентификации. По завершению, сторонний сервис отошлёт браузеру или веб-просмотрщику эту ссылку. Вы должны извлечь все параметры из ссылки и отослать их в параметр <var>$1continue</var> запроса к этому модулю.",
+ "api-help-authmanagerhelper-continue": "Этот запрос — продолжение после предшествующего ответа <samp>UI</samp> или <samp>REDIRECT</samp>. Либо этот параметр, либо <var>$1returnurl</var>, обязателен.",
+ "api-help-authmanagerhelper-additional-params": "Этот модуль принимает дополнительные параметры в зависимости от доступных аутентификационных запросов. Используйте <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> с <kbd>amirequestsfor=$1</kbd> (или предыдущий ответ этого модуля, если доступен) для определения, какие запросы доступны и какие поля они используют.",
+ "apierror-allimages-redirect": "Используйте <kbd>gaifilterredir=nonredirects</kbd> вместо <var>redirects</var> при использовании <kbd>allimages</kbd> в качестве генератора.",
+ "apierror-allpages-generator-redirects": "Используйте <kbd>gaifilterredir=nonredirects</kbd> вместо <var>redirects</var> при использовании <kbd>allpages</kbd> в качестве генератора.",
+ "apierror-appendnotsupported": "Невозможно дописать страницы, использующие модель содержимого $1.",
+ "apierror-articleexists": "Статья, которую вы пытаетесь создать, уже создана.",
+ "apierror-assertbotfailed": "Проверка того, что у участника есть право <code>bot</code>, провалилась.",
+ "apierror-assertnameduserfailed": "Проверка того, что участник — «$1», провалилась.",
+ "apierror-assertuserfailed": "Проверка того, что участник авторизован, провалилась.",
+ "apierror-autoblocked": "Ваш IP-адрес был автоматически заблокирован, потому что он был использован заблокированным участником.",
+ "apierror-badconfig-resulttoosmall": "Значение <code>$wgAPIMaxResultSize</code> этой вики слишком мало, чтобы вместить базовую информацию о результате.",
+ "apierror-badcontinue": "Некорректный параметр continue. Вы должны передать значение, возвращённое предыдущим запросом.",
+ "apierror-baddiff": "Сравнение версий не может быть проведено. Одна или обе версии не существуют или у вас не достаточно прав чтобы просматривать их.",
+ "apierror-baddiffto": "<var>$1diffto</var> должно быть неотрицательным числом, <kbd>prev</kbd>, <kbd>next</kbd> или <kbd>cur</kbd>.",
+ "apierror-badformat-generic": "Запрашиваемый формат $1 не поддерживается моделью содержимого $2.",
+ "apierror-badformat": "Запрашиваемый формат $1 не поддерживается моделью содержимого $2, используемой $3.",
+ "apierror-badgenerator-notgenerator": "Модуль <kbd>$1</kbd> не может быть использован в качестве генератора.",
+ "apierror-badgenerator-unknown": "Неизвестный <kbd>generator=$1</kbd>.",
+ "apierror-badip": "Параметр IP некорректен.",
+ "apierror-badmd5": "Предоставленный хэш MD5 был некорректным.",
+ "apierror-badmodule-badsubmodule": "У модуля <kbd>$1</kbd> нет подмодуля «$2».",
+ "apierror-badmodule-nosubmodules": "У модуля <kbd>$1</kbd> нет подмодулей.",
+ "apierror-badparameter": "Некорректное значение параметра <var>$1</var>.",
+ "apierror-badquery": "Некорректный запрос.",
+ "apierror-badtimestamp": "Некорректное значение «$2» параметра временной метки <var>$1</var>.",
+ "apierror-badtoken": "Некорректный токен CSRF.",
+ "apierror-badupload": "Параметр загрузки файла <var>$1</var> не является загрузкой файла; убедитесь, что вы используете <code>multipart/form-data</code> в вашем POST запросе и включаете название файла в заголовок <code>Content-Disposition</code>.",
+ "apierror-badurl": "Некорректное значения «$2» параметра ссылки <var>$1</var>.",
+ "apierror-baduser": "Некорректное значение «$2» параметра участника <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "Разделение значений с помощью U+001F может быть использовано только в параметрах, принимающих несколько значений.",
+ "apierror-bad-watchlist-token": "Предоставлен некорректный токен списка наблюдения. Пожалуйста, установите корректный токен в [[Special:Preferences]].",
+ "apierror-blockedfrommail": "Отправка электронной почты была для вас заблокирована.",
+ "apierror-blocked": "Редактирование было для вас заблокировано.",
+ "apierror-botsnotsupported": "Этот интерфейс не поддерживается для ботов.",
+ "apierror-cannot-async-upload-file": "Параметры <var>async</var> и <var>file</var> не могут применяться вместе. Если вы хотите ассинхронно обработать загруженный файл, сначала загрузите его в тайник (используя параметр <var>stash</var>), а затем опубликуйте этот файл ассинхронно (используя параметры <var>filekey</var> и <var>async</var>).",
+ "apierror-cannotreauthenticate": "Это действие недоступно, так как ваша личность не может быть подтверждена.",
+ "apierror-cannotviewtitle": "У вас нет прав на просмотр $1.",
+ "apierror-cantblock-email": "У вас нет прав блокировать участникам отправку электронной почты через интерфейс вики.",
+ "apierror-cantblock": "У вас нет прав блокировать участников.",
+ "apierror-cantchangecontentmodel": "У вас нет прав изменять модель содержимого страницы.",
+ "apierror-canthide": "У вас нет прав скрывать имена участников из журнала блокировок.",
+ "apierror-cantimport-upload": "У вас нет прав импортировать загруженные страницы.",
+ "apierror-cantimport": "У вас нет прав импортировать страницы.",
+ "apierror-cantoverwrite-sharedfile": "Целевой файл существует в общем репозитории и у вас нет прав перезаписать его.",
+ "apierror-cantsend": "Вы не авторизованы, ваш электронный адрес не подтверждён или у вас нет прав на отправку электронной почты другим участникам, поэтому вы не можете отправить электронное письмо.",
+ "apierror-cantundelete": "Невозможно восстановить: возможно, запрашиваемые версии не существуют или уже были восстановлены.",
+ "apierror-changeauth-norequest": "Попытка создать запрос правки провалилась.",
+ "apierror-chunk-too-small": "Минимальный размер кусочка — $1 {{PLURAL:$1|байт|байта|байт}}, если кусочек не является последним.",
+ "apierror-cidrtoobroad": "Диапазоны $1 CIDR, шире /$2, не разрешены.",
+ "apierror-compare-no-title": "Невозможно выполнить преобразование перед записью правки без заголовка. Попробуйте задать <var>fromtitle</var> или <var>totitle</var>.",
+ "apierror-compare-relative-to-nothing": "Нет версии 'from', к которой относится <var>torelative</var>.",
+ "apierror-contentserializationexception": "Сериализация содержимого провалилась: $1",
+ "apierror-contenttoobig": "Предоставленное вами содержимое превышает максимальный размер страницы в $1 {{PLURAL:$1|килобайт|килобайта|килобайтов}}.",
+ "apierror-copyuploadbaddomain": "Загрузка по ссылке недоступна с этого домена.",
+ "apierror-copyuploadbadurl": "Загрузка по этой ссылке недоступна.",
+ "apierror-create-titleexists": "Существующие названия не могут быть защищены с помощью <kbd>create</kbd>.",
+ "apierror-csp-report": "Ошибка при обработке отчёта CSP: $1.",
+ "apierror-databaseerror": "[$1] Ошибка запроса к базе данных.",
+ "apierror-deletedrevs-param-not-1-2": "Параметр <var>$1</var> не может быть использован в режимах 1 и 2.",
+ "apierror-deletedrevs-param-not-3": "Параметр <var>$1</var> не может быть использован в третьем режиме.",
+ "apierror-emptynewsection": "Создание пустых секций невозможно.",
+ "apierror-emptypage": "Создание новых пустых страниц не разрешено.",
+ "apierror-exceptioncaught": "[$1] Поймано исключение: $2",
+ "apierror-filedoesnotexist": "Файл не существует.",
+ "apierror-fileexists-sharedrepo-perm": "Целевой файл существует в общем репозитории. Используйте параметр <var>ignorewarnings</var>, чтобы перезаписать его.",
+ "apierror-filenopath": "Невозможно получить локальный путь к файлу.",
+ "apierror-filetypecannotberotated": "Этот тип файлов не может быть повёрнут.",
+ "apierror-formatphp": "Этот ответ не может быть представлен с использованием <kbd>format=php</kbd>. См. https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "Название для модуля <kbd>$1</kbd> должно быть файлом.",
+ "apierror-import-unknownerror": "Неизвестная ошибка при импорте: $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> не может быть больше $2 (присвоено $3) для ботов и администраторов.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> не может быть больше $2 (присвоено $3) для участников.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> не может быть меньше $2 (присвоено $3).",
+ "apierror-invalidcategory": "Введённое вами название категории некорректно.",
+ "apierror-invalid-chunk": "Сумма смещения и размера текущего кусочка превышает заявленный размер файла.",
+ "apierror-invalidexpiry": "Некорректное время истечения «$1».",
+ "apierror-invalid-file-key": "Некорректный ключ файла.",
+ "apierror-invalidlang": "Некорректный код языка для параметра <var>$1</var>.",
+ "apierror-invalidoldimage": "Параметр <var>oldimage</var> имеет недопустимый формат.",
+ "apierror-invalidparammix-cannotusewith": "Параметр <kbd>$1</kbd> не может быть использован одновременно с <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "Параметр <kbd>$1</kbd> может быть использован только одновременно с <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd> не может быть совмещено с параметрами <var>oldid</var>, <var>pageid</var> или <var>page</var>. Пожалуйста, используйте <var>title</var> и <var>text</var>.",
+ "apierror-invalidparammix": "{{PLURAL:$2|Параметры}} $1 не могут быть использованы одновременно.",
+ "apierror-invalidsection": "Параметр <var>section</var> должен быть корректным идентификатором секции или <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "Предоставленный хэш SHA1Base36 некорректен.",
+ "apierror-invalidsha1hash": "Предоставленный хэш SHA1 некорректен.",
+ "apierror-invalidtitle": "Плохой заголовок «$1».",
+ "apierror-invalidurlparam": "Некорректное значение <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Некорректное имя участника «$1».",
+ "apierror-invaliduserid": "Некорректный идентификатор участника <var>$1</var>.",
+ "apierror-maxlag-generic": "Ожидание сервера базы данных: $1 {{PLURAL:$1|секунда|секунды|секунд}} задержки.",
+ "apierror-maxlag": "Ожидание $2: $1 {{PLURAL:$1|секунда|секунды|секунд}} задержки.",
+ "apierror-mimesearchdisabled": "Поиск по MIME отключен в жадном режиме.",
+ "apierror-missingcontent-pageid": "Отсутствует содержимое страницы с идентификатором $1.",
+ "apierror-missingcontent-revid": "Отсутствует содержимое версии с идентификатором $1.",
+ "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|Параметр|Как минимум один из параметров}} $1 обязателен.",
+ "apierror-missingparam-one-of": "{{PLURAL:$2|Параметр|Один из параметров}} $1 обязателен.",
+ "apierror-missingparam": "Параметр <var>$1</var> должен быть задан.",
+ "apierror-missingrev-pageid": "Нет текущей версии страницы с идентификатором $1.",
+ "apierror-missingrev-title": "Нет текущей версии для заголовка $1.",
+ "apierror-missingtitle-createonly": "Несуществующие названия страниц могут быть защищены только с помощью <kbd>create</kbd>.",
+ "apierror-missingtitle": "Указанная вами страница не существует.",
+ "apierror-missingtitle-byname": "Страница $1 не существует.",
+ "apierror-moduledisabled": "Модуль <kbd>$1</kbd> был отключён.",
+ "apierror-multival-only-one-of": "Параметру <var>$1</var> может быть присвоено только {{PLURAL:$3|значение|одно из значений}} $2.",
+ "apierror-multival-only-one": "Параметру <var>$1</var> может быть присвоено только одно значение.",
+ "apierror-multpages": "Параметр <var>$1</var> может быть применён только к одной странице.",
+ "apierror-mustbeloggedin-changeauth": "Вы должны быть авторизованы для смены аутентификационных данных.",
+ "apierror-mustbeloggedin-generic": "Вы должны быть авторизованы.",
+ "apierror-mustbeloggedin-linkaccounts": "Вы должны быть авторизованы для привязывания аккаунтов.",
+ "apierror-mustbeloggedin-removeauth": "Вы должны быть авторизованы для удаления аутентификационных данных.",
+ "apierror-mustbeloggedin-uploadstash": "Тайник загрузки (upload stash) доступен только для авторизованных участников.",
+ "apierror-mustbeloggedin": "Вы должны быть авторизованы в $1.",
+ "apierror-mustbeposted": "Модуль <kbd>$1</kbd> требует запроса POST.",
+ "apierror-mustpostparams": "{{PLURAL:$2|Следующий параметр был найден|Следующие параметры были найдены}} в строке запроса, но {{PLURAL:$2|должен|должны}} находиться в теле POST: $1.",
+ "apierror-noapiwrite": "Редактирование этой вики посредством API отключено. Убедитесь, что инструкция <code>$wgEnableWriteAPI=true;</code> включена в файл <code>LocalSettings.php</code> вики.",
+ "apierror-nochanges": "Никаких правок запрошено не было.",
+ "apierror-nodeleteablefile": "Нет такой старой версии файла.",
+ "apierror-no-direct-editing": "Прямое редактирование посредством API не поддерживается моделью содержимого $1, используемой $2.",
+ "apierror-noedit-anon": "Анонимные участники не могут редактировать страницы.",
+ "apierror-noedit": "У вас нет прав на редактирование страниц.",
+ "apierror-noimageredirect-anon": "Анонимные участники не могут создавать перенаправления на изображения.",
+ "apierror-noimageredirect": "У вас нет прав на создание перенаправлений на изображения.",
+ "apierror-nosuchlogid": "Нет записей журналов с идентификатором $1.",
+ "apierror-nosuchpageid": "Нет страницы с идентификатором $1.",
+ "apierror-nosuchrcid": "Нет недавней правки с идентификатором $1.",
+ "apierror-nosuchrevid": "Нет версии с идентификатором $1.",
+ "apierror-nosuchsection": "Нет секции $1.",
+ "apierror-nosuchsection-what": "Нет секции $1 в $2.",
+ "apierror-nosuchuserid": "Нет участника с идентификатором $1.",
+ "apierror-notarget": "Вы не указали корректной цели этого действия.",
+ "apierror-notpatrollable": "Версия r$1 не может быть отпатрулирована, так как она слишком стара.",
+ "apierror-nouploadmodule": "Модуль загрузки не задан.",
+ "apierror-offline": "Невозможно продолжить из-за проблем с сетевым подключением. Убедитесь, что у вас есть подключение к Интернету и повторите попытку.",
+ "apierror-opensearch-json-warnings": "Предупреждения не могут быть представлены в формате OpenSearch JSON.",
+ "apierror-pagecannotexist": "Данное пространство имён не может содержать эти страницы.",
+ "apierror-pagedeleted": "Страница была удалена с тех пор, как вы запросили её временную метку.",
+ "apierror-pagelang-disabled": "Смена языка страницы не разрешена в этой вики.",
+ "apierror-paramempty": "Параметр <var>$1</var> не может быть пустым.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> разрешён только для вики-текстового содержимого.",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> разрешён только для вики-текстового содержимого. $1 использует модель содержимого $2.",
+ "apierror-pastexpiry": "Время окончания «$1» находится в прошлом.",
+ "apierror-permissiondenied": "У вас нет прав на $1.",
+ "apierror-permissiondenied-generic": "Доступ запрещён.",
+ "apierror-permissiondenied-patrolflag": "Вам нужно право <code>patrol</code> или <code>patrolmarks</code> для запроса статуса патрулирования.",
+ "apierror-permissiondenied-unblock": "У вас нет прав снимать блокировку с участников.",
+ "apierror-prefixsearchdisabled": "Поиск по префиксу отключен в жадном режиме.",
+ "apierror-promised-nonwrite-api": "Заголовок HTTP <code>Promise-Non-Write-API-Action</code> не может быть передан в записывающие модули API.",
+ "apierror-protect-invalidaction": "Недопустимый тип защиты «$1».",
+ "apierror-protect-invalidlevel": "Недопустимый уровень защиты «$1».",
+ "apierror-ratelimited": "Вы превысили ваше ограничение скорости. Пожалуйста, подождите некоторое время и попробуйте снова.",
+ "apierror-readapidenied": "Вам нужны права на чтение для использования этого модуля.",
+ "apierror-readonly": "Эта вики находится в режиме «только для чтения».",
+ "apierror-reauthenticate": "Вы ещё не авторизовывались в этой сессии, пожалуйста, переавторизуйтесь.",
+ "apierror-redirect-appendonly": "Вы попытались отредактировать страницу в режиме следования по перенаправлениям, который должен быть использован в связке с <kbd>section=new</kbd>, <var>prependtext</var> или <var>appendtext</var>.",
+ "apierror-revdel-mutuallyexclusive": "Одно и то же поле не может быть использовано и в <var>hide</var>, и в <var>show</var>.",
+ "apierror-revdel-needtarget": "Для этого типа RevDel требуется указание целевого названия страницы.",
+ "apierror-revdel-paramneeded": "Как минимум одно значение требуется в <var>hide</var> и/или <var>show</var>.",
+ "apierror-revisions-badid": "Не было найдено версий по параметру <var>$1</var>.",
+ "apierror-revisions-norevids": "Параметр <var>revids</var> не может быть использован с настройками списка (<var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> и <var>$1end</var>).",
+ "apierror-revisions-singlepage": "Параметры <var>titles</var> и <var>pageids</var> и генераторы используются для обработки множества страниц, но параметры <var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var> и <var>$1end</var> могут быть применены только к одной странице.",
+ "apierror-revwrongpage": "r$1 не является версией $2.",
+ "apierror-searchdisabled": "Поисковый параметр <var>$1</var> отключён.",
+ "apierror-sectionreplacefailed": "Невозможно слить обновлённую секцию.",
+ "apierror-sectionsnotsupported": "Разбиение на секции не поддерживается моделью содержимого $1.",
+ "apierror-sectionsnotsupported-what": "Разбиение на секции не поддерживается $1.",
+ "apierror-show": "Некорректный параметр — вручную исключённые значения не могут быть обработаны.",
+ "apierror-siteinfo-includealldenied": "Невозможно отобразить информацию о всех серверах, если <var>$wgShowHostNames</var> не истинно.",
+ "apierror-sizediffdisabled": "Подсчёт разницы размеров отключён в жадном режиме.",
+ "apierror-spamdetected": "Ваша правка была отклонена, так как содержит спам: <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "У вас нет прав, чтобы просматривать результаты этой служебной страницы.",
+ "apierror-stashedfilenotfound": "Невозможно найти файл в тайнике: $1.",
+ "apierror-stashedit-missingtext": "Не найдено содержимого тайника для данного хэша.",
+ "apierror-stashfailed-complete": "Загрузка по кусочкам уже завершена, проверьте статус для получения подробной информации.",
+ "apierror-stashfailed-nosession": "Не найдено сессии загрузки по кусочкам с заданным ключом.",
+ "apierror-stashfilestorage": "Невозможно сохранить загрузку в тайник: $1",
+ "apierror-stashinvalidfile": "Некорректный файл в тайнике.",
+ "apierror-stashnosuchfilekey": "Нет такого ключа файла: $1.",
+ "apierror-stashpathinvalid": "Ключ файла относится к некорректному формату или сам некорректен: $1.",
+ "apierror-stashwrongowner": "Некорректный владелец: $1",
+ "apierror-stashzerolength": "Файл имеет нулевую длину и не может быть сохранён в тайник: $1",
+ "apierror-systemblocked": "Вы были заблокированы автоматически MediaWiki.",
+ "apierror-templateexpansion-notwikitext": "Раскрытие шаблонов разрешено только для вики-текстового содержимого. $1 использует модель содержимого $2.",
+ "apierror-timeout": "Сервер не ответил за ожидаемое время.",
+ "apierror-toofewexpiries": "Задано $1 {{PLURAL:$1|временная метка|временные метки|временных меток}} истечения, необходимо $2.",
+ "apierror-unknownaction": "Заданное действие, <kbd>$1</kbd>, не распознано.",
+ "apierror-unknownerror-editpage": "Неизвестная ошибка EditPage: $1.",
+ "apierror-unknownerror-nocode": "Неизвестная ошибка.",
+ "apierror-unknownerror": "Неизвестная ошибка: «$1».",
+ "apierror-unknownformat": "Нераспознанный формат «$1».",
+ "apierror-unrecognizedparams": "{{PLURAL:$2|Нераспознанный параметр|Нераспознанные параметры}}: $1",
+ "apierror-unrecognizedvalue": "Нераспознанное значение параметра <var>$1</var>: $2.",
+ "apierror-unsupportedrepo": "Локальное хранилище файлов не поддерживает запрос всех изображений.",
+ "apierror-upload-filekeyneeded": "Необходимо задать <var>filekey</var>, если <var>offset</var> не ноль.",
+ "apierror-upload-filekeynotallowed": "Невозможно обработать <var>filekey</var>, если <var>offset</var> равен 0.",
+ "apierror-upload-inprogress": "Процесс загрузки из тайника уже запущен.",
+ "apierror-upload-missingresult": "Нет результатов данных статуса.",
+ "apierror-urlparamnormal": "Невозможно нормализовать параметры изображения для $1.",
+ "apierror-writeapidenied": "У вас нет прав на редактирование этой вики через API.",
+ "apiwarn-alldeletedrevisions-performance": "Для повышения производительности, при генерировании заголовков установите <kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "Невозможно распарсить $2 из <var>$1urlparam</var>. Используется только ширина и высота.",
+ "apiwarn-badutf8": "Значение, переданное <var>$1</var>, содержит некорректные или ненормализованные данные. Текстовые данные должны быть корректным NFC-нормализованным Юникодом без символов управления C0, кроме HT (\\t), LF (\\n) и CR (\\r).",
+ "apiwarn-checktoken-percentencoding": "Проверьте, что символы вроде «+» в токене корректно закодированы %-последовательностями в ссылке.",
+ "apiwarn-compare-nocontentmodel": "Модель содержимого не может быть определена, предполагается $1.",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> не поддерживается. Пожалуйста, вместо него используйте <kbd>prop=deletedrevisions</kbd> или <kbd>list=alldeletedrevisions</kbd>.",
+ "apiwarn-deprecation-expandtemplates-prop": "Поскольку никакие значения не были указаны в параметре <var>prop</var>, был использован наследованный формат. Этот формат является устаревшим, и в будущем параметру <var>prop</var> будет присвоено значение по умолчанию, что приведёт к повсеместному использованию нового формата.",
+ "apiwarn-deprecation-httpsexpected": "Использован HTTP, где ожидался HTTPS.",
+ "apiwarn-deprecation-login-botpw": "Вход в основной аккаунт через <kbd>action=login</kbd> не поддерживается и может быть отключен без предупреждения. Для продолжения авторизации с <kbd>action=login</kbd>, см.\n[[Special:BotPasswords]]. Для безопасного продолжения использования входа в основной аккаунт, см. <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "Вход в основной аккаунт через <kbd>action=login</kbd> не поддерживается и может быть отключен без предупреждения. Для безопасной авторизации, см. <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-token": "Запрос токена через <kbd>action=login</kbd> не поддерживается. Вместо этого, см. <kbd>action=query&meta=tokens&type=login</kbd>.",
+ "apiwarn-deprecation-parameter": "Параметр <var>$1</var> не поддерживается.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> не поддерживается с MediaWiki 1.28. Используйте <kbd>prop=headhtml</kbd> при создании новых HTML документов, или <kbd>prop=modules|jsconfigvars</kbd> при обновлении документов на стороне клиента.",
+ "apiwarn-deprecation-purge-get": "Использование <kbd>action=purge</kbd> посредством GET не поддерживается. Используйте POST.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> не поддерживается. Пожалуйста, используйте <kbd>$2</kbd>.",
+ "apiwarn-difftohidden": "Невозможно сравнить с r$1: содержимое скрыто.",
+ "apiwarn-errorprinterfailed": "Сборщик ошибок упал. Будет совершена повторная попытка без параметров.",
+ "apiwarn-errorprinterfailed-ex": "Сборщик ошибок упал (будет совершена повторная попытка без параметров): $1",
+ "apiwarn-invalidcategory": "«$1» не является категорией.",
+ "apiwarn-invalidtitle": "«$1» не является некорректным заголовком.",
+ "apiwarn-invalidxmlstylesheetext": "Таблицы стилей должны иметь расширение <code>.xsl</code>.",
+ "apiwarn-invalidxmlstylesheet": "Задана некорректная или несуществующая таблица стилей.",
+ "apiwarn-invalidxmlstylesheetns": "Таблица стилей должна находиться в пространстве имён {{ns:MediaWiki}}.",
+ "apiwarn-moduleswithoutvars": "Было задано свойство kbd>modules</kbd>, но не были —<kbd>jsconfigvars</kbd> или <kbd>encodedjsconfigvars</kbd>. Конфигурационные переменные обязательны для корректного использования модуля.",
+ "apiwarn-notfile": "«$1» не является файлом.",
+ "apiwarn-nothumb-noimagehandler": "Невозможно создать эскиз, поскольку у $1 нет связанного обработчика изображений.",
+ "apiwarn-parse-nocontentmodel": "Параметры <var>title</var> или <var>contentmodel</var> не заданы, предполагается $1.",
+ "apiwarn-parse-titlewithouttext": "<var>title</var> использован без <var>text</var>, при этом запрошены распарсенные свойства страницы. Возможно, вы хотели использовать <var>page</var> вместо <var>title</var>?",
+ "apiwarn-redirectsandrevids": "Раскрытие перенаправлений не может быть использовано вместе с параметром <var>revids</var>. Все перенаправления на точку <var>revids</var> не должны быть раскрыты.",
+ "apiwarn-tokennotallowed": "Действие «$1» не разрешено для текущего участника.",
+ "apiwarn-tokens-origin": "Токены не могут быть получены, пока не применено правило ограничения домена.",
+ "apiwarn-toomanyvalues": "Слишком много значений передано параметру <var>$1</var>. Максимальное число — $2.",
+ "apiwarn-truncatedresult": "Результат был усечён, поскольку в противном случае он был бы больше лимита в $1 {{PLURAL:$1|байт|байта|байт}}.",
+ "apiwarn-unclearnowtimestamp": "Передача «$2» в качестве параметра временной метки <var>$1</var> не поддерживается. Если по какой-то причине вы хотите прямо указать текущее время без вычисления его на стороне клиента, используйте <kbd>now</kbd>.",
+ "apiwarn-unrecognizedvalues": "{{PLURAL:$3|Нераспознанное значение|Нераспознанные значения}} параметра <var>$1</var>: $2.",
+ "apiwarn-unsupportedarray": "Параметр <var>$1</var> использует неподдерживаемый синтаксис массивов PHP.",
+ "apiwarn-urlparamwidth": "Значение ширины ($2), переданное в <var>$1urlparam</var>, было проигнорировано в пользу значения ($3), полученного из параметров <var>$1urlwidth</var>/<var>$1urlheight</var>.",
+ "apiwarn-validationfailed-badchars": "некорректные символы в ключе (разрешены только <code>a-z</code>, <code>A-Z</code>, <code>0-9</code>, <code>_</code> и <code>-</code>).",
+ "apiwarn-validationfailed-badpref": "некорректная настройка.",
+ "apiwarn-validationfailed-cannotset": "не может быть задано этим модулем.",
+ "apiwarn-validationfailed-keytoolong": "ключ слишком длинен (разрешено не более $1 {{PLURAL:$1|байт|байта|байт}}).",
+ "apiwarn-validationfailed": "Ошибка проверки для <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>Предупреждение безопасности</strong>: активирован <var>$wgDebugAPI</var>.",
+ "api-feed-error-title": "Ошибка ($1)",
+ "api-usage-docref": "См. $1 для использования API.",
+ "api-usage-mailinglist-ref": "Подпишитесь на электронную рассылку MediaWiki API на &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt;, чтобы получать информацию о неподдерживаемых функциях и ломающих изменениях.",
+ "api-exception-trace": "$1 в $2($3)\n$4",
+ "api-credits-header": "Создатели",
+ "api-credits": "Разработчики API:\n* Yuri Astrakhan (создатель, ведущий разработчик с сентября 2006 по сентябрь 2007)\n* Roan Kattouw (ведущий разработчик 2007—2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (ведущий разработчик с 2013)\n\nПожалуйста, присылайте ваши комментарии, предложения и вопросы на адрес mediawiki-api@lists.wikimedia.org\nили присылайте отчёты об ошибках на https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/sah.json b/www/wiki/includes/api/i18n/sah.json
new file mode 100644
index 00000000..89e17f46
--- /dev/null
+++ b/www/wiki/includes/api/i18n/sah.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "София",
+ "HalanTul"
+ ]
+ },
+ "apihelp-stashedit-param-summary": "Түмүгү уларыт."
+}
diff --git a/www/wiki/includes/api/i18n/sd.json b/www/wiki/includes/api/i18n/sd.json
new file mode 100644
index 00000000..589fb649
--- /dev/null
+++ b/www/wiki/includes/api/i18n/sd.json
@@ -0,0 +1,15 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mehtab ahmed",
+ "Aursani"
+ ]
+ },
+ "apihelp-query+allrevisions-summary": "سمورن ڀيرن جي فهرست پيش ڪريو.",
+ "apihelp-query+watchlist-param-type": "ڪهڙن قسمن جون تبديليون ڏيکارجن:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "قاعديوار صفحاتي ترميمون.",
+ "apihelp-query+watchlist-paramvalue-type-external": "خارجي تبديليون.",
+ "apihelp-query+watchlist-paramvalue-type-new": "صفحن جون تخليقون.",
+ "apihelp-query+watchlist-paramvalue-type-log": "لاگ داخلائون.",
+ "apihelp-stashedit-param-summary": "تَتُ تبديل ڪريو."
+}
diff --git a/www/wiki/includes/api/i18n/sh.json b/www/wiki/includes/api/i18n/sh.json
new file mode 100644
index 00000000..19d31d42
--- /dev/null
+++ b/www/wiki/includes/api/i18n/sh.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Conquistador"
+ ]
+ },
+ "apihelp-login-param-password": "Lozinka."
+}
diff --git a/www/wiki/includes/api/i18n/shn.json b/www/wiki/includes/api/i18n/shn.json
new file mode 100644
index 00000000..a5804f6c
--- /dev/null
+++ b/www/wiki/includes/api/i18n/shn.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Saosukham"
+ ]
+ },
+ "apihelp-feedrecentchanges-param-hidecategorization": "သိူင်ႇပၢႆး ၽူႈၶဝ်ႈၸုမ်းလႅၵ်ႈလၢႆႈ"
+}
diff --git a/www/wiki/includes/api/i18n/si.json b/www/wiki/includes/api/i18n/si.json
new file mode 100644
index 00000000..a73785ac
--- /dev/null
+++ b/www/wiki/includes/api/i18n/si.json
@@ -0,0 +1,65 @@
+{
+ "@metadata": {
+ "authors": [
+ "Susith Chandira Gts",
+ "SusithCM"
+ ]
+ },
+ "apihelp-main-param-action": "ඉටු කිරීමට ඇත්තේ කුමන ක්‍රියාවද.",
+ "apihelp-main-param-format": "ප්‍රතිදානයේ ආකෘතිය.",
+ "apihelp-main-param-requestid": "මෙහි ඇති සියලුම වටිනාකම් ප්‍රතිචාරයන්හි අන්තර්ගතකොට ඇත. ඇතැම් විට පැහැදිලිව වටහාගත් ඉල්ලීම් සදහා භාවිතා වේ.",
+ "apihelp-main-param-servedby": "ප්‍රතිපලයන්හි ඉල්ලීම් ඉටුකළ ධාරකනාමය ඇතුලත් කරන්න.",
+ "apihelp-main-param-curtimestamp": "ප්‍රථිපලයන්හි කාල මුද්‍රාව ඇතුලත් කරන්න.",
+ "apihelp-help-summary": "නිරූපිත ඒකක සදහා උදවු පෙන්වන්න.",
+ "apihelp-help-param-submodules": "නම් කරන ලද ඒකකයේ, අනුඒකක සදහා උදවු ඇතුලත් කරන්න.",
+ "apihelp-help-param-helpformat": "උදවු ප්‍රතිදානයේ ආකෘතිය.",
+ "apihelp-help-param-wrap": "ප්‍රතිදානය නියමිත API අනුකූලතා ආකෘතියකට හරවන්න.",
+ "apihelp-help-param-toc": "HTML ප්‍රතිදනයන්ගේ පටුනේ ලැයිස්තුවක් ඇතුලත් කරන්න.",
+ "apihelp-help-example-main": "ප්‍රධාන ඒකකය සදහා උදවු කරන්න",
+ "apihelp-help-example-recursive": "සියලුම උදවු එක පිටුවක් තුල",
+ "apihelp-help-example-query": "සැකසහිත අනුඒකක සදහා උදවු කරන්න",
+ "apihelp-login-param-name": "පරිශීලක නාමය.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "මෙම පිටුව සඳහා වූ JavaScript වින්‍යාස විචල්‍යයන් ලබා දෙයි.",
+ "apihelp-userrights-param-user": "පරිශීලක නාමය.",
+ "apihelp-userrights-param-userid": "පරිශීලක අනන්‍යාංකය.",
+ "apihelp-format-example-generic": "$1 ආකෘතියේ ඇති සැක සහිත ප්‍රථිපල පරිවර්තනය කරන්න",
+ "apihelp-json-summary": "ප්‍රතිදාන දත්ත JSON ආකෘතියෙන් පවතී.",
+ "apihelp-jsonfm-summary": "ප්‍රතිදාන දත්ත JSON ආකෘතියෙන් පවතී (හොදම පිටපත HTML භාෂාවෙනි).",
+ "apihelp-none-summary": "ප්‍රතිදානයේ කිසිවක් නොමැත.",
+ "apihelp-php-summary": "ප්‍රතිදාන දත්ත serialized PHP ආකෘතියෙන් පවතී.",
+ "apihelp-phpfm-summary": "ප්‍රතිදාන දත්ත serialized PHP ආකෘතියෙන් පවතී (හොදම පිටපත HTML භාෂාවෙනි).",
+ "apihelp-xml-summary": "ප්‍රතිදාන දත්ත XML ආකෘතියෙන් පවතී.",
+ "apihelp-xml-param-includexmlnamespace": "නිරූපණය කළා නම්, XML නාමාවකාශයක් එකතු කරන්න.",
+ "apihelp-xmlfm-summary": "ප්‍රතිදාන දත්ත XML ආකෘතියෙන් පවතී (හොදම පිටපත HTML භාෂාවෙනි).",
+ "api-format-title": "මාධ්‍යවිකි API ප්‍රථිපල",
+ "api-help-title": "මාධ්‍යවිකි API උදවු",
+ "api-help-lead": "මෙය ස්වයං-ජනිත මාධ්‍යවිකි API \tප්‍රලේඛන පිටුවකි.\n\nප්‍රලේඛනය සහ උදාහරණ:\nhttps://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "ප්‍රධාන ආකෘතිය",
+ "api-help-flag-deprecated": "මෙම ආකෘතිය විරුද්ධත්වය ප්‍රකාශ කරන ලදී.",
+ "api-help-flag-internal": "<strong>මෙම ඒකකය අභ්‍යන්තර හෝ අස්ථායි.\n</strong> එහි ක්‍රියාකාරිත්වය දැනුම් දීමකින් තොරව වෙනස් වියහැක.",
+ "api-help-flag-readrights": "මෙම ඒකකය සදහා හිමිකම් කියවීම අවශ්‍ය වේ.",
+ "api-help-flag-writerights": "මෙම ඒකකය සදහා හිමිකම් ලිවීම අවශ්‍ය වේ.",
+ "api-help-flag-mustbeposted": "මෙම ඒකකය POST ඉල්ලීම් පමණක් බාරගනී.",
+ "api-help-flag-generator": "මෙම ආකෘතිය \tඋත්පාදකයක් ලෙස භාවිතා කල හැක.",
+ "api-help-parameters": "{{PLURAL:$1|පරාමිතිය|පරාමිතීන්}}:",
+ "api-help-param-deprecated": "විරුද්ධත්වය ප්‍රකාශ කර ඇත.",
+ "api-help-param-required": "මෙම පරාමිතිය අවශ්‍යයි.",
+ "api-help-param-list": "{{PLURAL:$1|1=එක් වටිනාකමක්|2=වටිනාකම් (\"{{!}}\" සමග වෙන් කරන්න)}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=හිස් කල යුතුයි|හිස් කල හැකියි, හෝ $2}}",
+ "api-help-param-limit": "$1 ට වඩා අනුමත නොකරයි.",
+ "api-help-param-limit2": "$1 කට වැඩ අනුමත කරන්නේ නැත ($2 බොට්ස් සදහාය).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=අගය|2=අගයන්}} $2 ට වඩා අඩු නොවිය යුතුය.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=වටිනාකම|2=වටිනාකම්}} $3 ට ව වැඩි නොවිය යුතුය.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=අගය|2=අගයන්}} $2 සහ $3 අතර පැවතිය යුතුය.",
+ "api-help-param-multi-separate": "වටිනාකම් \"|\" සමග වෙන් කරන්න.",
+ "api-help-param-multi-max": "අංක සදහා උපරිම වටිනාකම {{PLURAL:$1|$1}}\n({{PLURAL:$2|$2}} බොට්ස් සදහා)",
+ "api-help-param-default": "Default: $1",
+ "api-help-param-default-empty": "Default: <span class=\"apihelp-empty\">(හිස්)</span>",
+ "api-help-param-token": "[[Special:ApiHelp/query+tokens|action=query&meta=tokens]] මගින් \"$1\" \tසංඥාව සොයාගන්නා ලදී",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(විස්තරයක් නැත)</span>",
+ "api-help-examples": "{{PLURAL:$1|උදාහරණය|උදාහරණ}}:",
+ "api-help-permissions": "{{PLURAL:$1|අවසරය|අවසරයන්}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|\tප්‍රදානලාභියාට}}: $2",
+ "api-credits-header": "ස්තුතිය",
+ "api-credits": "API වැඩිදියුණු කරන්නන්:\n* Roan Kattouw (ප්‍රධානියා 2007 සැප්. –2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (නිර්මාපකයා, ප්‍රධානියා 2006 සැප්. – 2007 සැප්.)\n* Brad Jorsch (ප්‍රධානියා 2013–මේ දක්වා)\n\nඔබගේ අදහස්, යෝජනා හා ගැටළු mediawiki-api@lists.wikimedia.org වෙත යොමු කරන්න, පින්තූර හෝ ගොනු හරහා ගැටළු ඉදිරිපත් කිරීමට https://phabricator.wikimedia.org/ වෙත පිවිසෙන්න."
+}
diff --git a/www/wiki/includes/api/i18n/sk.json b/www/wiki/includes/api/i18n/sk.json
new file mode 100644
index 00000000..823e29fe
--- /dev/null
+++ b/www/wiki/includes/api/i18n/sk.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Teslaton"
+ ]
+ },
+ "apihelp-main-param-format": "Formát výstupu."
+}
diff --git a/www/wiki/includes/api/i18n/sq.json b/www/wiki/includes/api/i18n/sq.json
new file mode 100644
index 00000000..a685096f
--- /dev/null
+++ b/www/wiki/includes/api/i18n/sq.json
@@ -0,0 +1,15 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ammartivari",
+ "Kosovastar"
+ ]
+ },
+ "apihelp-block-param-reason": "Arsyeja për bllokim.",
+ "apihelp-move-param-reason": "Arsyeja për riemërtim.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Kthehet në faqen e statistikave.",
+ "apihelp-tag-param-reason": "Arsyeja për ndërrimin.",
+ "apihelp-unblock-summary": "Zhblloko një përdorues.",
+ "apihelp-upload-param-file": "Përmbajtja e skedave.",
+ "apihelp-userrights-summary": "Ndërro anëtarësinë e grupit të një përdoruesit."
+}
diff --git a/www/wiki/includes/api/i18n/sr-ec.json b/www/wiki/includes/api/i18n/sr-ec.json
new file mode 100644
index 00000000..6eba3155
--- /dev/null
+++ b/www/wiki/includes/api/i18n/sr-ec.json
@@ -0,0 +1,28 @@
+{
+ "@metadata": {
+ "authors": [
+ "Milicevic01",
+ "Aktron",
+ "Сербијана"
+ ]
+ },
+ "apihelp-block-summary": "Блокирај корисника.",
+ "apihelp-block-param-reason": "Разлог за блокирање.",
+ "apihelp-createaccount-param-name": "Корисничко име.",
+ "apihelp-delete-summary": "Обриши страницу.",
+ "apihelp-edit-param-text": "Страница са садржајем.",
+ "apihelp-edit-param-minor": "Мања измена.",
+ "apihelp-edit-example-edit": "Уређивање странице.",
+ "apihelp-emailuser-summary": "Слање имејла кориснику.",
+ "apihelp-emailuser-param-target": "Корисник је послао имејл.",
+ "apihelp-feedcontributions-param-year": "Од године (и раније).",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Сакриј патролиране измене.",
+ "apihelp-filerevert-summary": "Вратити датотеку у ранију верзију.",
+ "apihelp-help-example-recursive": "Сва помоћ у једној страници.",
+ "apihelp-login-param-name": "Корисничко име.",
+ "apihelp-login-param-password": "Лозинка.",
+ "apihelp-login-example-login": "Пријавa.",
+ "apihelp-move-summary": "Премештање странице.",
+ "apihelp-query+allrevisions-param-namespace": "Само списак страница у овом именском простору.",
+ "apihelp-stashedit-param-text": "Страница са садржајем."
+}
diff --git a/www/wiki/includes/api/i18n/sr-el.json b/www/wiki/includes/api/i18n/sr-el.json
new file mode 100644
index 00000000..15154263
--- /dev/null
+++ b/www/wiki/includes/api/i18n/sr-el.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "Milicevic01"
+ ]
+ },
+ "apihelp-block-summary": "Blokiraj korisnika.",
+ "apihelp-block-param-reason": "Razlog za blokiranje.",
+ "apihelp-delete-summary": "Obriši stranicu.",
+ "apihelp-edit-param-minor": "Manja izmena.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Sakrij patrolirane izmene."
+}
diff --git a/www/wiki/includes/api/i18n/sv.json b/www/wiki/includes/api/i18n/sv.json
new file mode 100644
index 00000000..025254d1
--- /dev/null
+++ b/www/wiki/includes/api/i18n/sv.json
@@ -0,0 +1,535 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jopparn",
+ "Lokal Profil",
+ "WikiPhoenix",
+ "Victorsa",
+ "Albinomamba",
+ "Peki01",
+ "Stens51",
+ "Boom",
+ "Jenniesarina",
+ "Marfuas",
+ "VickyC",
+ "Josve05a",
+ "Rockyfelle",
+ "Macofe",
+ "Magol"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Dokumentation]]\n* [[mw:Special:MyLanguage/API:FAQ|Vanliga frågor]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Sändlista]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API-nyheter]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Buggar och begäran]\n</div>\n<strong>Status:</strong> Alla funktioner som visas på denna sida bör fungera, men API:et är fortfarande under utveckling och kan ändras när som helst. Prenumerera på [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ sändlistan mediawiki-api-announce] för uppdateringsaviseringar.\n\n<strong>Felaktiga begäran:</strong> När felaktiga begäran skickas till API:et kommer en HTTP-header skickas med nyckeln \"MediaWiki-API-Error\" och sedan kommer både värdet i headern och felkoden som skickades tillbaka anges som samma värde. För mer information se [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Fel och varningar]].\n\n<strong>Testning:</strong> För enkelt testning av API-begäran, se [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Vilken åtgärd som ska utföras.",
+ "apihelp-main-param-format": "Formatet för utdata.",
+ "apihelp-main-param-smaxage": "Ange headervärdet <code>s-maxage</code> till så här många sekunder. Fel cachelagras aldrig.",
+ "apihelp-main-param-maxage": "Ange headervärdet <code>max-age</code> till så här många sekunder. Fel cachelagras aldrig.",
+ "apihelp-main-param-assert": "Bekräfta att användaren är inloggad om satt till <kbd>user</kbd>, eller har bot-användarrättigheter om satt till <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Verifiera att den nuvarande användaren är den namngivne användaren.",
+ "apihelp-main-param-requestid": "Alla värde som anges här kommer att inkluderas i svaret. Kan användas för att särskilja förfrågningar.",
+ "apihelp-main-param-servedby": "Inkludera det värdnamn som besvarade förfrågan i resultatet.",
+ "apihelp-main-param-curtimestamp": "Inkludera den aktuella tidsstämpeln i resultatet.",
+ "apihelp-main-param-responselanginfo": "Inkluderar de språk som används för <var>uselang</var> och <var>errorlang</var> i resultatet.",
+ "apihelp-main-param-origin": "När API:et används genom en cross-domain AJAX-begäran (CORS), ange detta till den ursprungliga domänen. Detta måste inkluderas i alla pre-flight-begäran, och mpste därför vara en del av den begärda URI:n (inte i POST-datat). Detta måste överensstämma med en av källorna i headern <code>Origin</code> exakt, så den måste sättas till något i stil med <kbd>http://en.wikipedia.org</kbd> eller <kbd>https://meta.wikimedia.org</kbd>. Om denna parameter inte överensstämmer med headern <code>Origin</code>, returneras ett 403-svar. Om denna parameter överensstämmer med headern <code>Origin</code> och källan är vitlistad, sätts en <code>Access-Control-Allow-Origin</code>-header.",
+ "apihelp-main-param-uselang": "Språk som ska användas för meddelandeöversättningar. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> med <kbd>siprop=languages</kbd> returnerar en lista med språkkoder, eller ange <kbd>user</kbd> för att använda den aktuella användarens språkpreferenser, eller ange <kbd>content</kbd> för att använda innehållsspråket.",
+ "apihelp-main-param-errorlang": "Språk att använda för varningar och fel. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> med <kbd>siprop=languages</kbd> returnerar en lista över språkkoder eller specifikt <kbd>content</kbd> för att använda innehållsspråket på denna wiki, eller specifikt <kbd>uselang</kbd> för att använda samma värde som parametern <var>uselang</var>.",
+ "apihelp-main-param-errorsuselocal": "Om angivet kommer feltexter att använda lokalt anpassade meddelande från namnrymden {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "Blockera en användare.",
+ "apihelp-block-param-user": "Användare, IP-adress eller IP-intervall att blockera. Kan inte användas tillsammans med <var>$1userid</var>",
+ "apihelp-block-param-userid": "Användar-ID att blocker. Kan inte användas tillsammans med <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Förfallotid. Kan vara Kan vara relativt (t.ex. <kbd>5 months</kbd> eller <kbd>2 weeks</kbd>) eller absolut (t.ex. <kbd>2014-09-18T12:34:56Z</kbd>). Om satt till <kbd>infinite</kbd>, <kbd>indefinite</kbd> eller <kbd>never</kbd>, kommer blockeringen aldrig att löpa ut.",
+ "apihelp-block-param-reason": "Orsak till blockering.",
+ "apihelp-block-param-anononly": "Blockera endast anonyma användare (t.ex. inaktivera anonyma redigeringar för denna IP-adress).",
+ "apihelp-block-param-nocreate": "Förhindra registrering av användarkonton.",
+ "apihelp-block-param-autoblock": "Blockera automatiskt den senast använda IP-adressen, och alla efterföljande IP-adresser de försöker logga in från.",
+ "apihelp-block-param-noemail": "Hindra användaren från att skicka e-post via wikin. (Kräver rättigheten <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Döljer användarnamnet från blockeringsloggen. (Kräver rättigheten <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Låt användaren redigera sin egen diskussionssida (beror på <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Skriv över befintlig blockering om användaren redan är blockerad.",
+ "apihelp-block-param-watchuser": "Bevaka användarens eller IP-adressens användarsida och diskussionssida",
+ "apihelp-block-param-tags": "Ändra märken att tillämpa i blockloggens post.",
+ "apihelp-block-example-ip-simple": "Blockera IP-adressen <kbd>192.0.2.5</kbd> i tre dagar med motivationen <kbd>First strike</kbd>",
+ "apihelp-block-example-user-complex": "Blockera användare <kbd>Vandal</kbd> på obegränsad tid med motivationen <kbd>Vandalism</kbd>, och förhindra kontoskapande och e-post.",
+ "apihelp-changeauthenticationdata-summary": "Ändra autentiseringsdata för aktuell användare.",
+ "apihelp-changeauthenticationdata-example-password": "Försök att ändra aktuell användares lösenord till <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Kontrollera giltigheten av en nyckel från <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Typ av token som testas.",
+ "apihelp-checktoken-param-token": "Token att testa.",
+ "apihelp-checktoken-param-maxtokenage": "Högsta tillåtna åldern för token, i sekunder.",
+ "apihelp-checktoken-example-simple": "Testa giltigheten av en <kbd>csrf</kbd>-token.",
+ "apihelp-clearhasmsg-summary": "Rensa <code>hasmsg</code>-flaggan för den aktuella användaren.",
+ "apihelp-clearhasmsg-example-1": "Rensa <code>hasmsg</code>-flaggan för den aktuella användaren",
+ "apihelp-clientlogin-summary": "Logga till på wikin med det interaktiva flödet.",
+ "apihelp-clientlogin-example-login": "Börja att logga in wikin som användaren <kbd>Example</kbd> med lösenordet <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Fortsätt logga in efter ett svar av typen <samp>UI</samp> för en tvåstegsverifiering, som uppger en <var>OATHToken</var> med värdet <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Hämta skillnaden mellan två sidor.",
+ "apihelp-compare-extended-description": "Ett versionsnummer, en sidtitel, ett sid-ID, text eller en relativ referens för både \"from\" och \"to\" måste godkännas.",
+ "apihelp-compare-param-fromtitle": "Första titeln att jämföra.",
+ "apihelp-compare-param-fromid": "Första sid-ID att jämföra.",
+ "apihelp-compare-param-fromrev": "Första version att jämföra.",
+ "apihelp-compare-param-fromtext": "Använd denna text istället för innehållet i sidversionen som anges i <var>fromtitle</var>, <var>fromid</var> eller <var>fromrev</var>.",
+ "apihelp-compare-param-fromcontentmodel": "Innehållsmodell för <var>fromtext</var>. Om det inte anges kommer den gissas fram baserat på de andra parametrarna.",
+ "apihelp-compare-param-totitle": "Andra titeln att jämföra.",
+ "apihelp-compare-param-toid": "Andra sid-ID att jämföra.",
+ "apihelp-compare-param-torev": "Andra version att jämföra.",
+ "apihelp-compare-example-1": "Skapa en diff mellan version 1 och 2",
+ "apihelp-createaccount-summary": "Skapa ett nytt användarkonto.",
+ "apihelp-createaccount-param-name": "Användarnamn.",
+ "apihelp-createaccount-param-password": "Lösenord (ignoreras om <var>$1mailpassword</var> angetts).",
+ "apihelp-createaccount-param-domain": "Domän för extern autentisering (frivillig).",
+ "apihelp-createaccount-param-token": "Nyckel för kontoskapande erhölls i första begäran.",
+ "apihelp-createaccount-param-email": "Användarens e-postadress (valfritt).",
+ "apihelp-createaccount-param-realname": "Användarens riktiga namn (valfritt).",
+ "apihelp-createaccount-param-mailpassword": "Om satt till ett värde, skickas ett slumpmässigt lösenord till användaren via e-post.",
+ "apihelp-createaccount-param-reason": "Valfri anledning för att skapa kontot för att läggas till i loggarna.",
+ "apihelp-createaccount-param-language": "Språkkod att använda som standard för användaren (valfri, standardvärdet är innehållsspråket).",
+ "apihelp-createaccount-example-pass": "Skapa användaren <kbd>testuser</kbd> med lösenordet <kbd>test123</kbd>",
+ "apihelp-createaccount-example-mail": "Skapa användaren <kbd>testmailuser</kbd> och skicka ett slumpgenererat lösenord via e-post",
+ "apihelp-delete-summary": "Radera en sida.",
+ "apihelp-delete-param-title": "Titel på sidan du vill radera. Kan inte användas tillsammans med <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Sid-ID för sidan att radera. Kan inte användas tillsammans med <var>$1titel</var>.",
+ "apihelp-delete-param-reason": "Orsak till radering. Om orsak inte ges kommer en orsak att automatiskt genereras och användas.",
+ "apihelp-delete-param-watch": "Lägg till sidan i aktuell användares bevakningslista.",
+ "apihelp-delete-param-watchlist": "Lägg till eller ta bort sidan ovillkorligen från den aktuella användarens bevakningslista, använd inställningar eller ändra inte bevakning.",
+ "apihelp-delete-param-unwatch": "Ta bort sidan från aktuell användares bevakningslista.",
+ "apihelp-delete-param-oldimage": "Namnet på den gamla bilden att radera som tillhandahålls av [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Radera <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Raderar <kbd>Main Page</kbd> med orsaken <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Denna modul har inaktiverats.",
+ "apihelp-edit-summary": "Skapa och redigera sidor.",
+ "apihelp-edit-param-title": "Titel på sidan du vill redigera. Kan inte användas tillsammans med <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "Sid-ID för sidan du vill redigera. Kan inte användas tillsammans med <var>$1titel</var>.",
+ "apihelp-edit-param-section": "Avsnittsnummer. <kbd>0</kbd> för det översta avsnittet, <kbd>new</kbd> för ett nytt avsnitt.",
+ "apihelp-edit-param-sectiontitle": "Rubriken för ett nytt avsnitt.",
+ "apihelp-edit-param-text": "Sidans innehåll.",
+ "apihelp-edit-param-summary": "Redigeringssammanfattning. Även avsnittets rubrik när $1section=new och $1sectiontitle inte anges.",
+ "apihelp-edit-param-tags": "Ändra taggar till att gälla för revideringen.",
+ "apihelp-edit-param-minor": "Mindre redigering.",
+ "apihelp-edit-param-notminor": "Icke-mindre redigering.",
+ "apihelp-edit-param-bot": "Markera denna redigering som en robotredigering.",
+ "apihelp-edit-param-basetimestamp": "Tidsstämpel för grundversionen, används för att upptäcka redigeringskonflikter. Kan erhållas genom [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Tidsstämpel för när redigeringsprocessen började, används för att upptäcka redigeringskonflikter. Ett lämpligt värde kan erhållas via <var>[[Special:ApiHelp/main|curtimestamp]]</var> när redigeringsprocessen startas (t.ex. när sidans innehåll laddas för redigering).",
+ "apihelp-edit-param-recreate": "Ignorera felmeddelande om sidan har blivit raderad under tiden.",
+ "apihelp-edit-param-createonly": "Redigera inte sidan om den redan finns.",
+ "apihelp-edit-param-nocreate": "Kasta ett fel om sidan inte finns.",
+ "apihelp-edit-param-watch": "Lägg till sidan i den aktuella användarens bevakningslista.",
+ "apihelp-edit-param-unwatch": "Ta bort sidan från aktuell användares bevakningslista.",
+ "apihelp-edit-param-watchlist": "Lägg till eller ta bort sidan ovillkorligen från den aktuella användarens bevakningslista, använd inställningar eller ändra inte bevakning.",
+ "apihelp-edit-param-md5": "MD5-hash för $1text-parametern, eller $1prependtext- och $1appendtext-parametrarna sammanfogade.",
+ "apihelp-edit-param-prependtext": "Lägg till denna text i början på sidan. Ersätter $1text.",
+ "apihelp-edit-param-appendtext": "Lägg till denna text i slutet på sidan. Ersätter $1text.\n\nAnvänd $1section=new för att lägga till en ny sektion, hellre än denna parameter.",
+ "apihelp-edit-param-undo": "Ångra denna sidversion. Skriver över $1text, $1prependtext och $1appendtext.",
+ "apihelp-edit-param-undoafter": "Ångra alla sidversioner från $1undo till denna. Om inte, ångra endast en sidversion.",
+ "apihelp-edit-param-redirect": "Åtgärda automatiskt omdirigeringar.",
+ "apihelp-edit-param-contentformat": "Det serialiseringsformat som används för indatatexten.",
+ "apihelp-edit-param-contentmodel": "Det nya innehållets innehållsmodell.",
+ "apihelp-edit-param-token": "Token ska alltid skickas som sista parameter, eller åtminstone efter $1text-parametern",
+ "apihelp-edit-example-edit": "Redigera en sida",
+ "apihelp-edit-example-prepend": "Lägg till <kbd>_&#95;NOTOC_&#95;</kbd> i början på en sida.",
+ "apihelp-edit-example-undo": "Ångra sidversioner 13579 till 13585 med automatisk sammanfattning.",
+ "apihelp-emailuser-summary": "Skicka e-post till en användare.",
+ "apihelp-emailuser-param-target": "Användare att skicka e-post till.",
+ "apihelp-emailuser-param-subject": "Ämnesrubrik.",
+ "apihelp-emailuser-param-text": "E-postmeddelandets innehåll.",
+ "apihelp-emailuser-param-ccme": "Skicka en kopia av detta e-postmeddelande till mig.",
+ "apihelp-emailuser-example-email": "Skicka ett e-postmeddelande till användaren <kbd>WikiSysop</kbd> med texten <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Expanderar alla mallar inom wikitext.",
+ "apihelp-expandtemplates-param-title": "Sidans rubrik.",
+ "apihelp-expandtemplates-param-text": "Wikitext att konvertera.",
+ "apihelp-expandtemplates-param-revid": "Revision ID, för <code><nowiki>{{REVISIONID}}</nowiki></code> och liknande variabler.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Den expanderade wikitexten.",
+ "apihelp-expandtemplates-param-includecomments": "Om HTML-kommentarer skall inkluderas i utdata.",
+ "apihelp-expandtemplates-param-generatexml": "Generera ett XML tolknings träd (ersatt av $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Expandera wikitexten <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Returnerar en användares bidragsflöde.",
+ "apihelp-feedcontributions-param-feedformat": "Flödets format.",
+ "apihelp-feedcontributions-param-user": "De användare vars bidrag ska hämtas.",
+ "apihelp-feedcontributions-param-namespace": "Vilken namnrymd att filtrera bidrag med.",
+ "apihelp-feedcontributions-param-year": "Från år (och tidigare).",
+ "apihelp-feedcontributions-param-month": "Från månad (och tidigare).",
+ "apihelp-feedcontributions-param-tagfilter": "Filtrera bidrag som har dessa taggar.",
+ "apihelp-feedcontributions-param-deletedonly": "Visa bara borttagna bidrag.",
+ "apihelp-feedcontributions-param-toponly": "Visa endast ändringar som är senaste revideringen.",
+ "apihelp-feedcontributions-param-newonly": "Visa endast redigeringar där sidor skapas.",
+ "apihelp-feedcontributions-param-hideminor": "Göm mindre ändringar.",
+ "apihelp-feedcontributions-param-showsizediff": "Visa skillnaden i storlek mellan revisioner.",
+ "apihelp-feedcontributions-example-simple": "Returnera bidrag för <kbd>Exempel</kbd>",
+ "apihelp-feedrecentchanges-summary": "Returnerar ett flöde med senaste ändringar.",
+ "apihelp-feedrecentchanges-param-feedformat": "Flödets format.",
+ "apihelp-feedrecentchanges-param-namespace": "Namnrymder att begränsa resultaten till.",
+ "apihelp-feedrecentchanges-param-invert": "Alla namnrymder utom den valda.",
+ "apihelp-feedrecentchanges-param-days": "Dagar att begränsa resultaten till.",
+ "apihelp-feedrecentchanges-param-limit": "Maximalt antal resultat att returnera.",
+ "apihelp-feedrecentchanges-param-from": "Visa förändringar sedan dess.",
+ "apihelp-feedrecentchanges-param-hideminor": "Dölj mindre ändringar.",
+ "apihelp-feedrecentchanges-param-hidebots": "Dölj robotändringar.",
+ "apihelp-feedrecentchanges-param-hideanons": "Dölj ändringar av oinloggade användare.",
+ "apihelp-feedrecentchanges-param-hideliu": "Dölj ändringar av inloggade användare.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Dölj patrullerade ändringar.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Dölj ändringar av aktuell användare.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Filtrera efter tagg.",
+ "apihelp-feedrecentchanges-param-target": "Visa endast ändringarna av sidor som den här sidan länkar till.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Visa ändringarna på sidor som är länkade till den valda sidan i stället.",
+ "apihelp-feedrecentchanges-param-categories": "Visa endast ändringar på sidor i alla dessa kategorier.",
+ "apihelp-feedrecentchanges-param-categories_any": "Visa endast ändringar på sidor i någon av kategorierna istället.",
+ "apihelp-feedrecentchanges-example-simple": "Visa senaste ändringar",
+ "apihelp-feedrecentchanges-example-30days": "Visa senaste ändringar för 30 dygn",
+ "apihelp-feedwatchlist-summary": "Returnerar ett flöde från bevakningslistan.",
+ "apihelp-feedwatchlist-param-feedformat": "Flödets format.",
+ "apihelp-feedwatchlist-param-hours": "Lista sidor ändrade inom så här många timmar från nu.",
+ "apihelp-feedwatchlist-param-linktosections": "Länka direkt till ändrade avsnitt om möjligt.",
+ "apihelp-feedwatchlist-example-default": "Visa flödet från bevakningslistan.",
+ "apihelp-feedwatchlist-example-all6hrs": "Visa alla ändringar på besökta sidor under de senaste sex timmarna.",
+ "apihelp-filerevert-summary": "Återställ en fil till en äldre version.",
+ "apihelp-filerevert-param-filename": "Målfilens namn, utan prefixet Fil:.",
+ "apihelp-filerevert-param-comment": "Ladda upp kommentar.",
+ "apihelp-filerevert-param-archivename": "Arkiv-namn för revisionen att gå tillbaka till.",
+ "apihelp-filerevert-example-revert": "Återställ <kbd>Wiki.png</kbd> till versionen från <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Visa hjälp för de angivna modulerna.",
+ "apihelp-help-param-modules": "Vilka moduler som hjälpen ska visas för (värdena på parametrarna <var>action</var> och <var>format</var>, eller <kbd>main</kbd>). Undermoduler kan anges med ett plustecken (<kbd>+</kbd>).",
+ "apihelp-help-param-submodules": "Inkludera hjälp för undermoduler av den namngivna modulen.",
+ "apihelp-help-param-recursivesubmodules": "Inkludera hjälp för undermoduler rekursivt.",
+ "apihelp-help-param-helpformat": "Formatet för hjälp-utdata.",
+ "apihelp-help-param-wrap": "Omge utdatan i en standard API respons struktur.",
+ "apihelp-help-param-toc": "Inkludera en innehållsförteckning i HTML-utdata.",
+ "apihelp-help-example-main": "Hjälp för huvudmodul",
+ "apihelp-help-example-submodules": "Hjälp för <kbd>action=query</kbd> och alla dess undermoduler.",
+ "apihelp-help-example-recursive": "All hjälp på en sida",
+ "apihelp-help-example-help": "Hjälp för själva hjälpmodulen",
+ "apihelp-help-example-query": "Hjälp för två frågeundermoduler.",
+ "apihelp-imagerotate-summary": "Rotera en eller flera bilder.",
+ "apihelp-imagerotate-param-rotation": "Grader att rotera bild medurs.",
+ "apihelp-imagerotate-param-tags": "Märken att tillämpa i uppladdningsloggens post.",
+ "apihelp-imagerotate-example-simple": "Rotera <kbd>File:Example.png</kbd> med <kbd>90</kbd> grader",
+ "apihelp-imagerotate-example-generator": "Rotera alla bilder i <kbd>Category:Flip</kbd> med <kbd>180</kbd> grader.",
+ "apihelp-import-summary": "Importer en sida från en annan wiki eller från en XML-fil.",
+ "apihelp-import-extended-description": "Notera att HTTP POST måste bli gjord som en fil uppladdning (d.v.s med multipart/form-data) när man skickar en fil för <var>xml</var> parametern.",
+ "apihelp-import-param-summary": "Sammanfattning för importering av loggpost.",
+ "apihelp-import-param-xml": "Uppladdad XML-fil.",
+ "apihelp-import-param-interwikisource": "För interwiki-importer: wiki som du vill importera från.",
+ "apihelp-import-param-interwikipage": "För interwiki-importer: sidan som du vill importera.",
+ "apihelp-import-param-fullhistory": "För interwiki-importer: importera hela historiken, inte bara den aktuella versionen.",
+ "apihelp-import-param-templates": "För interwiki-importer: importera även alla mallar som ingår.",
+ "apihelp-import-param-namespace": "Importera till denna namnrymd. Kan inte användas tillsammans med <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Importera som undersida till denna sida. Kan inte användas tillsammans med <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Ändringsmärken att tillämpa i importeringsloggens post och i nullsidversionen på de importerade sidorna.",
+ "apihelp-import-example-import": "Importera [[meta:Help:ParserFunctions]] till namnrymd 100 med full historik.",
+ "apihelp-linkaccount-summary": "Länka ett konto från en tredjepartsleverantör till nuvarande användare.",
+ "apihelp-linkaccount-example-link": "Börja länka till ett konto från <kbd>Example</kbd>.",
+ "apihelp-login-summary": "Logga in och hämta autentiseringskakor.",
+ "apihelp-login-extended-description": "Om inloggningen lyckas, finns de cookies som krävs med i HTTP-svarshuvuden. Om inloggningen misslyckas kan ytterligare försök per tidsenhet begränsas, som ett sätt att försöka minska risken för automatiserade lösenordsgissningar.",
+ "apihelp-login-param-name": "Användarnamn.",
+ "apihelp-login-param-password": "Lösenord.",
+ "apihelp-login-param-domain": "Domän (valfritt).",
+ "apihelp-login-param-token": "Login nyckel erhållen i första begäran.",
+ "apihelp-login-example-gettoken": "Hämta en login nyckel.",
+ "apihelp-login-example-login": "Logga in",
+ "apihelp-logout-summary": "Logga ut och rensa sessionsdata.",
+ "apihelp-logout-example-logout": "Logga ut den aktuella användaren",
+ "apihelp-managetags-summary": "Utför hanterings uppgifter relaterade till förändrings taggar.",
+ "apihelp-managetags-param-tag": "Tagg för att skapa, radera, aktivera eller inaktivera. Vid skapande av tagg kan taggen inte existera. Vid raderande av tagg måste taggen existera. För aktiverande av tagg måste taggen existera och inte användas i ett tillägg. För inaktivering av tagg måste taggen användas just nu och vara manuellt definierad.",
+ "apihelp-managetags-param-reason": "En icke-obligatorisk orsak för att skapa, radera, aktivera, eller inaktivera taggen.",
+ "apihelp-managetags-param-ignorewarnings": "Om du vill ignorera varningar som utfärdas under operationen.",
+ "apihelp-managetags-example-create": "Skapa en tagg vid namn <kbd>spam</kbd> med anledningen: <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Radera <kbd>vandalims</kbd> taggen med andledningen: <kbd>Felstavat</kbd>",
+ "apihelp-managetags-example-activate": "Aktivera en tagg med namn <kbd>spam</kbd> med anledningen: <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Inaktivera en tagg vid namn <kbd>spam</kbd> med anledningen: <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Sammanfoga sidhistoriker.",
+ "apihelp-mergehistory-param-reason": "Orsaken till sammanfogning av historik.",
+ "apihelp-mergehistory-example-merge": "Sammanfoga hela historiken för <kbd>Oldpage</kbd> i <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Sammanfoga den sidversion av <kbd>Oldpage</kbd> daterad fram till <kbd>2015-12-31T04:37:41Z</kbd> till <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Flytta en sida.",
+ "apihelp-move-param-from": "Titeln på sidan du vill flytta. Kan inte användas tillsammans med <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "Sid-ID för sidan att byta namn. Kan inte användas tillsammans med <var>$1from</var>.",
+ "apihelp-move-param-to": "Titel att byta namn på sidan till.",
+ "apihelp-move-param-reason": "Orsak till namnbytet.",
+ "apihelp-move-param-movetalk": "Byt namn på diskussionssidan, om den finns.",
+ "apihelp-move-param-movesubpages": "Byt namn på undersidor, om tillämpligt.",
+ "apihelp-move-param-noredirect": "Skapa inte en omdirigering.",
+ "apihelp-move-param-watch": "Lägg till sidan och omdirigeringen till den aktuella användarens bevakningslista.",
+ "apihelp-move-param-unwatch": "Ta bort sidan och omdirigeringen från den aktuella användarens bevakningslista.",
+ "apihelp-move-param-watchlist": "Lägg till eller ta bort sidan ovillkorligen från den aktuella användarens bevakningslista, använd inställningar eller ändra inte bevakning.",
+ "apihelp-move-param-ignorewarnings": "Ignorera alla varningar.",
+ "apihelp-move-example-move": "Flytta <kbd>Badtitle</kbd> till <kbd>Goodtitle</kbd> utan att lämna en omdirigering.",
+ "apihelp-opensearch-summary": "Sök wikin med protokollet OpenSearch.",
+ "apihelp-opensearch-param-search": "Söksträng.",
+ "apihelp-opensearch-param-limit": "Maximalt antal resultat att returnera.",
+ "apihelp-opensearch-param-namespace": "Namnrymder att genomsöka.",
+ "apihelp-opensearch-param-suggest": "Gör ingenting om <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> är falskt.",
+ "apihelp-opensearch-param-format": "Formatet för utdata.",
+ "apihelp-opensearch-example-te": "Hitta sidor som börjar med <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Ändra inställningar för nuvarande användare.",
+ "apihelp-options-param-reset": "Återställer inställningarna till sidans standardvärden.",
+ "apihelp-options-example-reset": "Återställ alla inställningar",
+ "apihelp-options-example-complex": "Återställ alla inställningar, ställ sedan in <kbd>skin</kbd> och <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Få information om API moduler.",
+ "apihelp-paraminfo-param-helpformat": "Format för hjälpsträngar.",
+ "apihelp-paraminfo-param-mainmodule": "Få information om huvud-modulen (top-level) också. Använd <kbd>$1modules=main</kbd> istället.",
+ "apihelp-parse-param-summary": "Sammanfattning att tolka.",
+ "apihelp-parse-param-page": "Tolka innehållet av denna sida. Kan inte användas tillsammans med <var>$1text</var> och <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Tolka innehållet på denna sida. Åsidosätter <var>$1sidan</var>.",
+ "apihelp-parse-param-prop": "Vilka bitar av information att få:",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Ger HTML-version av kategorierna.",
+ "apihelp-parse-param-preview": "Tolka i preview-läget.",
+ "apihelp-parse-example-page": "Tolka en sida.",
+ "apihelp-parse-example-text": "Tolka wikitext.",
+ "apihelp-parse-example-texttitle": "Tolka wikitext, specificera sid-titeln.",
+ "apihelp-parse-example-summary": "Tolka en sammanfattning.",
+ "apihelp-patrol-summary": "Patrullera en sida eller en version.",
+ "apihelp-patrol-param-revid": "Versions ID att patrullera.",
+ "apihelp-patrol-example-rcid": "Patrullera en nykommen ändring.",
+ "apihelp-patrol-example-revid": "Patrullera en sidversion",
+ "apihelp-protect-summary": "Ändra skyddsnivån för en sida.",
+ "apihelp-protect-example-protect": "Skydda en sida",
+ "apihelp-purge-summary": "Rensa cachen för angivna titlar.",
+ "apihelp-query-param-list": "Vilka listor att hämta.",
+ "apihelp-query-param-meta": "Vilka metadata att hämta.",
+ "apihelp-query-example-allpages": "Hämta sidversioner av sidor som börjar med <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-param-prefix": "Sök efter alla kategorititlar som börjar med detta värde.",
+ "apihelp-query+allcategories-param-dir": "Riktning att sortera mot.",
+ "apihelp-query+allcategories-param-min": "Returnera endast kategorier med minst så här många medlemmar.",
+ "apihelp-query+allcategories-param-max": "Returnera endast kategorier med som mest så här många medlemmar.",
+ "apihelp-query+allcategories-param-limit": "Hur många kategorier att returnera.",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Lägger till antal sidor i kategorin.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Märker kategorier som är dolda med <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+alldeletedrevisions-summary": "Lista alla raderade revisioner av en användare or inom en namnrymd.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Kan endast användas med <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Kan inte användas med <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-from": "Börja lista vid denna titel.",
+ "apihelp-query+alldeletedrevisions-param-to": "Sluta lista vid denna titel.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Sök alla sid-titlar som börjar med detta värde.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Lista bara revideringar taggade med denna tagg.",
+ "apihelp-query+alldeletedrevisions-param-user": "Lista bara revideringar av denna användaren.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Lista inte revideringar av denna användaren.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Lista bara sidor i denna namnrymd.",
+ "apihelp-query+alldeletedrevisions-example-user": "List de senaste 50 raderade bidragen av användaren <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Lista dem första 50 revideringarna i huvud-namnrymden",
+ "apihelp-query+allfileusages-summary": "Lista all fil användningsområden, inklusive icke-existerande.",
+ "apihelp-query+allfileusages-param-prefix": "Sök för all fil-titlar som börjar med detta värde.",
+ "apihelp-query+allfileusages-param-limit": "Hur många saker att returnera totalt.",
+ "apihelp-query+allfileusages-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+allfileusages-example-unique": "Lista unika filtitlar",
+ "apihelp-query+allfileusages-example-unique-generator": "Hämtar alla fil titlar, markerar dem saknade.",
+ "apihelp-query+allfileusages-example-generator": "Hämtar sidor som innehåller filerna.",
+ "apihelp-query+allimages-param-sort": "Egenskap att sortera efter.",
+ "apihelp-query+allimages-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+allimages-param-prefix": "Sök för alla bild titlar som börjar med detta värde. Kan endast användas med $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "Begränsning på bilder med åtminstone så här många bytes.",
+ "apihelp-query+allimages-param-maxsize": "Begränsning på bilder med som mest så här många bytes.",
+ "apihelp-query+allimages-param-sha1": "SHA1 hash av bild. Åsidosätter $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "SHA1 hash av bild i bas 36 (används i MediaWiki).",
+ "apihelp-query+allimages-param-user": "Returnera enbart filer uppladdade av denna användare. Kan enbart användas med $1sort=timestamp. Kan inte användas tillsammans med $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "Hur man filtrerar filer uppladdade av bots. Kan enbart användas med $1sort=timestamp. Kan inte användas tillsammans med $1user.",
+ "apihelp-query+allimages-param-mime": "Vilka MIME-typer du vill söka efter, t.ex. <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "Hur många bilder att returnera totalt.",
+ "apihelp-query+allimages-example-B": "Visa en lista över filer som börjar på bokstaven <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Visa en lista över nyligen överförda filer, ungefär som [[Special:Nya_filer]].",
+ "apihelp-query+allimages-example-mimetypes": "Visa en lista över filer med MIME-typen <kbd>image/png</kbd> eller <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Visa info om 4 filer som börjar med bokstaven <kbd>T</kbd>.",
+ "apihelp-query+alllinks-param-prefix": "Sök alla länkade titlar som börjar med detta värde.",
+ "apihelp-query+alllinks-param-limit": "Hur många saker att returnera totalt.",
+ "apihelp-query+alllinks-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+alllinks-example-B": "Lista länkade titlar, inkluderade saknade, med dem sid-IDs dem är från, med början vid <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Lista unika länkade titlar.",
+ "apihelp-query+alllinks-example-unique-generator": "Hämtar alla länkade titlar, markera de saknade.",
+ "apihelp-query+alllinks-example-generator": "Hämtar sidor som innehåller länkarna.",
+ "apihelp-query+allmessages-summary": "Returnera meddelande från denna sida.",
+ "apihelp-query+allmessages-param-messages": "Vilka meddelande att ge som utdata. <kbd>*</kbd> (standard) betyder alla meddelande .",
+ "apihelp-query+allmessages-param-prop": "Vilka egenskaper att hämta.",
+ "apihelp-query+allmessages-param-args": "Argument som ska substitueras i meddelandet.",
+ "apihelp-query+allmessages-param-filter": "Returnera enbart meddelande med namn som innehåller denna sträng.",
+ "apihelp-query+allmessages-param-customised": "Returnera endast meddelanden i detta anpassningstillstånd.",
+ "apihelp-query+allmessages-param-lang": "Returnera meddelanden på detta språk.",
+ "apihelp-query+allmessages-param-from": "Returnera meddelanden med början på detta meddelande.",
+ "apihelp-query+allmessages-param-to": "Returnera meddelanden fram till och med detta meddelande.",
+ "apihelp-query+allmessages-param-title": "Sidnamn som ska användas som kontext vid parsning av meddelande (för alternativet $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "Returnera meddelanden med detta prefix.",
+ "apihelp-query+allmessages-example-ipb": "Visa meddelanden som börjar med <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Visa meddelanden <kbd>august</kbd> och <kbd>mainpage</kbd> på tyska.",
+ "apihelp-query+allpages-param-prefix": "Sök efter alla sidtitlar som börjar med detta värde.",
+ "apihelp-query+allpages-param-filterredir": "Vilka sidor att lista.",
+ "apihelp-query+allpages-param-minsize": "Begränsa till sidor med detta antal byte eller fler.",
+ "apihelp-query+allpages-param-maxsize": "Begränsa till sidor med högst så här många byte.",
+ "apihelp-query+allpages-param-prtype": "Begränsa till endast skyddade sidor.",
+ "apihelp-query+allpages-param-limit": "Hur många sidor att returnera totalt.",
+ "apihelp-query+allpages-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+allpages-example-B": "Visa en lista över sidor som börjar på bokstaven <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Visa information om fyra sidor som börjar på bokstaven <kbd>T</kbd>.",
+ "apihelp-query+allredirects-summary": "Lista alla omdirigeringar till en namnrymd.",
+ "apihelp-query+allredirects-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+allredirects-example-unique-generator": "Hämtar alla målsidor, markerar de som saknas.",
+ "apihelp-query+allrevisions-summary": "Lista alla sidversioner.",
+ "apihelp-query+alltransclusions-summary": "Lista alla mallinkluderingar (sidor inbäddade med &#123;&#123;x&#125;&#125;), inklusive icke-befintliga.",
+ "apihelp-query+alltransclusions-param-limit": "Hur många objekt att returnera.",
+ "apihelp-query+alltransclusions-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+alltransclusions-example-unique": "Lista unika mallinkluderade titlar.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Hämtar alla mallinkluderade titlar, markerar de som saknas.",
+ "apihelp-query+allusers-param-prefix": "Sök för alla användare som börjar med detta värde.",
+ "apihelp-query+allusers-param-dir": "Riktning att sortera i.",
+ "apihelp-query+allusers-param-group": "Inkludera bara användare i de givna grupperna.",
+ "apihelp-query+allusers-param-excludegroup": "Exkludera användare i de givna grupperna.",
+ "apihelp-query+allusers-param-rights": "Inkludera bara användare med de givna rättigheterna. Inkluderar inte rättigheter givna med implicita eller automatiskt promotade grupper som *, användare, eller auto-konfirmerad.",
+ "apihelp-query+allusers-param-limit": "Hur många användarnamn att returnera totalt.",
+ "apihelp-query+allusers-param-witheditsonly": "Lista bara användare som har gjort redigeringar.",
+ "apihelp-query+allusers-param-activeusers": "Lista bara användare aktiva i dem sista $1{{PLURAL:$1|dagen|dagarna}}.",
+ "apihelp-query+allusers-example-Y": "Lista användare som börjar på <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Hämta information om aktuell autentiseringsstatus.",
+ "apihelp-query+backlinks-summary": "Hitta alla sidor som länkar till den givna sidan.",
+ "apihelp-query+backlinks-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+backlinks-example-simple": "Visa länkar till <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Lista alla blockerade användare och IP-adresser.",
+ "apihelp-query+blocks-param-prop": "Vilka egenskaper att hämta.",
+ "apihelp-query+blocks-paramvalue-prop-id": "Lägger till ID på blocket.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Lägger till användarnamn för den blockerade användaren.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Lägger till användar-ID för den blockerade användaren.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Lägger till en tidsstämpel för när blockeringen gavs.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Lägger till en tidsstämpel för när blockeringen går ut.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Lägger till de skäl som angetts för blockeringen.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Lägger till intervallet av IP-adresser som berörs av blockeringen.",
+ "apihelp-query+blocks-example-simple": "Lista blockeringar.",
+ "apihelp-query+blocks-example-users": "Lista blockeringar av användarna <kbd>Alice</kbd> och <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Lista alla kategorier sidorna tillhör.",
+ "apihelp-query+categories-param-show": "Vilka sorters kategorier att visa.",
+ "apihelp-query+categories-param-limit": "Hur många kategorier att returnera.",
+ "apihelp-query+categories-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+categories-example-simple": "Hämta en lista över kategorier som sidan <kbd>Albert Einstein</kbd> tillhör.",
+ "apihelp-query+categories-example-generator": "Hämta information om alla kategorier som används på sidan <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "Returnerar information om angivna kategorier.",
+ "apihelp-query+categoryinfo-example-simple": "Hämta information om <kbd>Category:Foo</kbd> och <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Lista alla sidor i en angiven kategori.",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Lägger till sid-ID.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Lägger till titeln och namnrymds-ID för sidan.",
+ "apihelp-query+categorymembers-param-dir": "I vilken riktning att sortera.",
+ "apihelp-query+categorymembers-param-startsortkey": "Använd $1starthexsortkey istället.",
+ "apihelp-query+categorymembers-param-endsortkey": "Använd $1endhexsortkey istället.",
+ "apihelp-query+categorymembers-example-simple": "Hämta de tio första sidorna i <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Hämta sidinformation om de tio första sidorna i <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Hämta listan över inloggade bidragsgivare och antalet anonyma bidragsgivare för en sida.",
+ "apihelp-query+contributors-param-limit": "Hur många bidragsgivare att returnera.",
+ "apihelp-query+deletedrevisions-summary": "Hämta information om raderad sidversion.",
+ "apihelp-query+deletedrevisions-param-user": "Lista endast sidversioner av denna användare.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Lista inte sidversioner av denna användare.",
+ "apihelp-query+deletedrevs-summary": "Lista raderade sidversioner.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Läge|Lägen}}: $2",
+ "apihelp-query+deletedrevs-param-from": "Börja lista vid denna titel.",
+ "apihelp-query+deletedrevs-param-to": "Sluta lista vid denna titel.",
+ "apihelp-query+disabled-summary": "Denna frågemodul har inaktiverats.",
+ "apihelp-query+duplicatefiles-summary": "Lista alla filer som är dubbletter av angivna filer baserat på hashvärden.",
+ "apihelp-query+duplicatefiles-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+duplicatefiles-example-generated": "Leta efter kopior av alla filer.",
+ "apihelp-query+embeddedin-summary": "Hitta alla sidor som bäddar in (inkluderar) angiven titel.",
+ "apihelp-query+embeddedin-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+embeddedin-param-limit": "Hur många sidor att returnera totalt.",
+ "apihelp-query+extlinks-example-simple": "Hämta en lista över externa länkar på <kbd>Main Page</kbd>.",
+ "apihelp-query+filearchive-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Lägger till tidsstämpel för den uppladdade versionen.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Lägger till användaren som laddade upp bildversionen.",
+ "apihelp-query+filearchive-example-simple": "Visa en lista över alla borttagna filer.",
+ "apihelp-query+filerepoinfo-summary": "Returnera metainformation om bildegenskaper som konfigureras på wikin.",
+ "apihelp-query+fileusage-summary": "Hitta alla sidor som använder angivna filer.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Titel för varje sida.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Flagga om sidan är en omdirigering.",
+ "apihelp-query+imageinfo-summary": "Returnerar filinformation och uppladdningshistorik.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Lägg till det användar-ID som laddade upp varje filversion.",
+ "apihelp-query+images-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+imageusage-summary": "Hitta alla sidor som användare angiven bildtitel.",
+ "apihelp-query+imageusage-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+imageusage-example-simple": "Visa sidor med hjälp av [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Hämta information om sidor med hjälp av [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "Få grundläggande sidinformation.",
+ "apihelp-query+iwbacklinks-param-limit": "Hur många sidor att returnera totalt.",
+ "apihelp-query+iwbacklinks-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+iwlinks-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+langbacklinks-param-limit": "Hur många sidor att returnera totalt.",
+ "apihelp-query+langbacklinks-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+langbacklinks-example-simple": "Hämta sidor som länkar till [[:fr:Test]].",
+ "apihelp-query+langlinks-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+links-summary": "Returnerar alla länkar från angivna sidor.",
+ "apihelp-query+links-param-dir": "Riktningen att lista mot.",
+ "apihelp-query+linkshere-summary": "Hitta alla sidor som länkar till angivna sidor.",
+ "apihelp-query+logevents-summary": "Hämta händelser från loggar.",
+ "apihelp-query+pageswithprop-summary": "Lista alla sidor som använder en angiven sidegenskap.",
+ "apihelp-query+prefixsearch-param-profile": "Sök profil att använda.",
+ "apihelp-query+protectedtitles-param-limit": "Hur många sidor att returnera totalt.",
+ "apihelp-query+protectedtitles-example-simple": "Lista skyddade titlar.",
+ "apihelp-query+random-summary": "Hämta en uppsättning slumpsidor.",
+ "apihelp-query+recentchanges-example-simple": "Lista de senaste ändringarna.",
+ "apihelp-query+redirects-summary": "Returnerar alla omdirigeringar till angivna sidor.",
+ "apihelp-query+revisions-summary": "Hämta information om sidversion.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Hämta första 5 revideringarna av <kbd>huvudsidan</kbd> och som inte gjorts av anonym användare <kbd>127.0.0.1</kbd>",
+ "apihelp-query+search-summary": "Utför en heltextsökning.",
+ "apihelp-query+search-paramvalue-prop-score": "Ignorerad.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorerad.",
+ "apihelp-query+siteinfo-summary": "Returnera allmän information om webbplatsen.",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Returnerar en lista över språkkoder som [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]] har aktiverat och de varianter som varje stöder.",
+ "apihelp-query+siteinfo-example-simple": "Hämta information om webbplatsen.",
+ "apihelp-query+stashimageinfo-summary": "Returnerar filinformation för temporära filer.",
+ "apihelp-query+stashimageinfo-param-filekey": "Nyckel som identifierar en tidigare uppladdning som lagrats temporärt.",
+ "apihelp-query+stashimageinfo-example-simple": "Returnerar information för en temporär fil",
+ "apihelp-query+tags-summary": "Lista ändringsmärken.",
+ "apihelp-query+tags-example-simple": "Lista tillgängliga taggar.",
+ "apihelp-query+templates-summary": "Returnerar alla sidinkluderingar på angivna sidor.",
+ "apihelp-query+templates-param-namespace": "Visa mallar i endast denna namnrymd.",
+ "apihelp-query+transcludedin-summary": "Hitta alla sidor som inkluderar angivna sidor.",
+ "apihelp-query+usercontribs-summary": "Hämta alla redigeringar av en användare.",
+ "apihelp-query+userinfo-summary": "Få information om den aktuella användaren.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Hämta en nyckel för att ändra aktuell användares inställningar.",
+ "apihelp-query+userinfo-example-simple": "Få information om den aktuella användaren.",
+ "apihelp-query+userinfo-example-data": "Få ytterligare information om den aktuella användaren.",
+ "apihelp-query+users-summary": "Hämta information om en lista över användare.",
+ "apihelp-query+watchlist-summary": "Hämta de senaste ändringarna på sidor i den nuvarande användarens bevakningslista.",
+ "apihelp-query+watchlist-example-allrev": "Hämta information om de senaste ändringarna på sidor på den aktuella användarens bevakningslista.",
+ "apihelp-query+watchlist-example-generator": "Hämta sidinformation för nyligen uppdaterade sidor på nuvarande användares bevakningslista.",
+ "apihelp-query+watchlist-example-generator-rev": "Hämta ändringsinformation för nyligen uppdaterade sidor på nuvarande användares bevakningslista.",
+ "apihelp-query+watchlistraw-summary": "Hämta alla sidor på den aktuella användarens bevakningslista.",
+ "apihelp-query+watchlistraw-example-simple": "Lista sidor på den aktuella användarens bevakningslista.",
+ "apihelp-revisiondelete-summary": "Radera och återställ sidversioner.",
+ "apihelp-rollback-summary": "Ångra den senaste redigeringen på sidan.",
+ "apihelp-rollback-extended-description": "Om den senaste användaren som redigerade sidan gjorde flera redigeringar i rad kommer alla rullas tillbaka.",
+ "apihelp-setnotificationtimestamp-example-all": "Återställ meddelandestatus för hela bevakningslistan.",
+ "apihelp-setpagelanguage-summary": "Ändra språket på en sida.",
+ "apihelp-stashedit-param-summary": "Ändra sammanfattning.",
+ "apihelp-tag-summary": "Lägg till eller ta bort ändringsmärken från individuella sidversioner eller loggposter.",
+ "apihelp-tokens-summary": "Hämta nycklar för datamodifierande handlingar.",
+ "apihelp-unblock-summary": "Upphäv en användares blockering.",
+ "apihelp-unblock-param-id": "ID för blockeringen att häva (hämtas genom <kbd>list=blocks</kbd>). Kan inte användas tillsammans med <var>$1user</var> eller <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "Användarnamn, IP-adresser eller IP-adressintervall att häva blockering för. Kan inte användas tillsammans med <var>$1id</var> eller <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "Användar-ID att häva blockering för. Kan inte användas tillsammans med <var>$1id</var> eller <var>$1user</var>.",
+ "apihelp-undelete-summary": "Återställ sidversioner för en raderad sida.",
+ "apihelp-unlinkaccount-summary": "Ta bort ett länkat tredjepartskonto från aktuell användare.",
+ "apihelp-upload-summary": "Ladda upp en fil eller hämta status för väntande uppladdningar.",
+ "apihelp-upload-param-filekey": "Nyckel som identifierar en tidigare uppladdning som lagrats temporärt.",
+ "apihelp-upload-param-stash": "Om angiven, kommer servern att temporärt lagra filen istället för att lägga till den i centralförvaret.",
+ "apihelp-upload-example-url": "Ladda upp från URL.",
+ "apihelp-upload-example-filekey": "Slutför en uppladdning som misslyckades på grund av varningar.",
+ "apihelp-userrights-summary": "Ändra en användares gruppmedlemskap.",
+ "apihelp-watch-summary": "Lägg till eller ta bort sidor från aktuell användares bevakningslista.",
+ "api-login-fail-badsessionprovider": "Kan inte logga in med $1.",
+ "api-help-main-header": "Huvudmodul",
+ "api-help-undocumented-module": "Ingen dokumentation för modulen $1.",
+ "api-help-flag-deprecated": "Denna modul är föråldrad.",
+ "api-help-flag-internal": "<strong>Denna modul är intern eller instabil.</strong> Dess funktion kan ändras utan föregående meddelande.",
+ "api-help-flag-readrights": "Denna modul kräver läsrättigheter.",
+ "api-help-flag-writerights": "Denna modul kräver skrivrättigheter.",
+ "api-help-flag-mustbeposted": "Denna modul accepterar endast POST-begäranden.",
+ "api-help-flag-generator": "Denna modul kan användas som en generator.",
+ "api-help-parameters": "{{PLURAL:$1|Parameter|Parametrar}}:",
+ "api-help-param-deprecated": "Föråldrad.",
+ "api-help-param-required": "Denna parameter är obligatorisk.",
+ "api-help-param-list": "{{PLURAL:$1|1=Ett av följande värden|2=Värden (separerade med <kbd>{{!}}</kbd> eller [[Special:ApiHelp/main#main/datatypes|alternativ]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Måste vara tom|Kan vara tom, eller $2}}",
+ "api-help-param-limit": "Inte mer än $1 tillåts.",
+ "api-help-param-limit2": "Inte mer än $1 ($2 för robotar) tillåts.",
+ "api-help-param-multi-separate": "Separera värden med <kbd>|</kbd> eller [[Special:ApiHelp/main#main/datatypes|alternativ]].",
+ "apierror-articleexists": "Artikeln du försökte skapa har redan skapats.",
+ "apierror-baddiff": "Diff kan inte hämtas. En eller båda sidversioner finns inte eller du har inte behörighet för att visa dem.",
+ "apierror-invalidoldimage": "Parametern <var>oldimage</var> har ett ogiltigt format.",
+ "apierror-invalidsection": "Parametern <var>section</var> måste vara ett giltigt avsnitts-ID eller <kbd>new</kbd>.",
+ "apierror-nosuchuserid": "Det finns ingen användare med ID $1.",
+ "apierror-offline": "Kunde inte fortsätta p.g.a. problem med nätverksanslutningen. Se till att du har en fungerande Internetanslutning och försök igen.",
+ "apierror-protect-invalidaction": "Ogiltig skyddstyp \"$1\".",
+ "apierror-revisions-badid": "Ingen revision hittades för parametern <var>$1</var>.",
+ "apierror-systemblocked": "Du har blockerats automatiskt av MediaWiki.",
+ "apierror-timeout": "Servern svarade inte inom förväntad tid.",
+ "apierror-unknownformat": "Okänt format \"$1\".",
+ "api-feed-error-title": "Fel ($1)"
+}
diff --git a/www/wiki/includes/api/i18n/ta.json b/www/wiki/includes/api/i18n/ta.json
new file mode 100644
index 00000000..6efd3f4b
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ta.json
@@ -0,0 +1,28 @@
+{
+ "@metadata": {
+ "authors": [
+ "AntanO",
+ "கலைவாணன்",
+ "Info-farmer",
+ "ElangoRamanujam"
+ ]
+ },
+ "apihelp-main-param-action": "எச்செயலை செயற்படுத்த",
+ "apihelp-main-param-format": "பெற விரும்பும் கோப்பு வடிவம்",
+ "apihelp-main-param-requestid": "இங்கு கொடுக்கப்படும் மதிப்பானது, விளைவில் இணையும். கோரிக்கைகளை வேறுபடுத்தப் பயன்படலாம்.",
+ "apihelp-import-param-namespace": "இதனைப் பெயர்வெளிக்கு இறக்குமதி செய்யவும். <kbd>$1rootpage</kbd> அளவுருவை மீறச்செய்யும்.",
+ "apihelp-import-param-rootpage": "இப்பக்கத்தின் துணைப்பக்கமாக இறக்குமதி செய்யவும். <kbd>$1namespace</kbd> அளவுரு வழங்கப்பட்டிருந்தால் இது புறக்கணிக்கப்படும்.",
+ "api-help-source": "மூலம்: $1",
+ "api-help-license": "அனுமதி: [[$1|$2]]",
+ "api-help-license-noname": "அனுமதி: [[$1|இணைப்பைப் பார்]]",
+ "apierror-invalidtitle": "தவறான தலைப்பு ”$1”",
+ "apierror-mustbeloggedin-generic": "தாங்கள் புகுபதிந்திருக்கவேண்டும்.",
+ "apierror-mustbeloggedin": "தாங்கள் $1-ல் புகுபதிந்திருக்கவேண்டும்.",
+ "apierror-nochanges": "மாற்றங்களேதும் கோரப்படவில்லை.",
+ "apierror-permissiondenied-generic": "அனுமதி மறுக்கப்பட்டது.",
+ "apierror-unknownerror-nocode": "இனம்புரியாப் பிழை.",
+ "apierror-unknownerror": "இனமறியாப் பிழை:\"$1\".",
+ "apiwarn-invalidtitle": "\"$1\" என்பது செல்லும் தலைப்பல்ல.",
+ "apiwarn-notfile": "”$1” ஒரு கோப்பல்ல.",
+ "api-feed-error-title": "பிழை ($1)"
+}
diff --git a/www/wiki/includes/api/i18n/tcy.json b/www/wiki/includes/api/i18n/tcy.json
new file mode 100644
index 00000000..4951ba0f
--- /dev/null
+++ b/www/wiki/includes/api/i18n/tcy.json
@@ -0,0 +1,22 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bharathesha Alasandemajalu",
+ "Vishwanatha Badikana",
+ "VASANTH S.N."
+ ]
+ },
+ "apihelp-createaccount-param-name": "ಸದಸ್ಯೆರ್ನ ಪುದರ್:",
+ "apihelp-delete-summary": "ಪುಟೊಕುಲೆನ್ ಮಾಜಾಲೆ",
+ "apihelp-edit-param-minor": "ಎಲ್ಯೆಲ್ಯ ಬದಲಾವಣೆಲು",
+ "apihelp-edit-example-edit": "ಪುಟೊನ್ ಸಂಪಾದನೆ ಮಲ್ಪುಲೆ",
+ "apihelp-feedcontributions-param-year": "ಈ ಒರ್ಸೊರ್ದು(ಬೊಕ್ಕ ದುಂಬುದ):",
+ "apihelp-feedcontributions-param-month": "ಈ ತಿಂಗೊಲುರ್ದ್ (ಬೊಕ್ಕ ದುಂಬುದ):",
+ "apihelp-feedrecentchanges-example-simple": "ಇಂಚಿಪದ ಬದಲಾವಣೆಲೆನ್ ತೋಜಾಲೆ.",
+ "apihelp-login-param-name": "ಸದಸ್ಯೆರೆನ ಪುದರ್",
+ "apihelp-login-param-password": "ಪ್ರವೇಶ ಪದೊ",
+ "apihelp-login-example-login": "ಲಾಗಿನ್ ಆಲೆ",
+ "apihelp-query+watchlist-param-type": "ವಾ ನಮೂನೆದ ಬದಲಾವಣೆ ತೊಜವೋಡು",
+ "apihelp-query+watchlist-paramvalue-type-external": "ಪಿದಯೀದ ಬದಲಾವಣೇ",
+ "apihelp-query+watchlist-paramvalue-type-new": "ಪುಟೊ ಉಂಡುಮಾನ್ಪುನಾ"
+}
diff --git a/www/wiki/includes/api/i18n/te.json b/www/wiki/includes/api/i18n/te.json
new file mode 100644
index 00000000..fc1f0a34
--- /dev/null
+++ b/www/wiki/includes/api/i18n/te.json
@@ -0,0 +1,21 @@
+{
+ "@metadata": {
+ "authors": [
+ "Veeven",
+ "HAUSANRIK",
+ "Ravichandra",
+ "Jedimaster26"
+ ]
+ },
+ "apihelp-block-summary": "ఓ వాడుకరిని నిరోధించండి.",
+ "apihelp-block-param-reason": "నిరోధానికి కారణం.",
+ "apihelp-block-param-nocreate": "ఖాతా సృష్టింపుని నివారించు",
+ "apihelp-createaccount-param-name": "వాడుకరి పేరు:",
+ "apihelp-delete-summary": "ఓ పేజీని తొలగించు.",
+ "apihelp-edit-param-minor": "చిన్న మార్పు",
+ "apihelp-edit-example-edit": "ఓ పేజీని మార్చు.",
+ "apihelp-emailuser-summary": "వాడుకరికి ఈమెయిలు పంపించండి.",
+ "apihelp-feedrecentchanges-example-simple": "ఇటీవలి మార్పులను చూడండి",
+ "apihelp-query+users-param-userids": "వివరములు సేకరించవలసిన ఉపయోగదారుల పేర్లు",
+ "apihelp-rawfm-summary": "బయటకు వచ్చిన సమాచారo, డీబగ్గింగ్ అంశముతో కలిపి, JSON పద్ధతిలో (HTMLలో అందంగా-ముద్రించు)"
+}
diff --git a/www/wiki/includes/api/i18n/th.json b/www/wiki/includes/api/i18n/th.json
new file mode 100644
index 00000000..db8fabd5
--- /dev/null
+++ b/www/wiki/includes/api/i18n/th.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Aefgh39622"
+ ]
+ },
+ "apihelp-imagerotate-summary": "หมุนรูปภาพอย่างน้อยหนึ่งรูป",
+ "api-help-param-continue": "เมื่อมีผลลัพธ์เพิ่มเติมพร้อมใช้งาน ใช้ตัวเลือกนี้เพื่อดำเนินการต่อ"
+}
diff --git a/www/wiki/includes/api/i18n/tl.json b/www/wiki/includes/api/i18n/tl.json
new file mode 100644
index 00000000..d8c5cfb6
--- /dev/null
+++ b/www/wiki/includes/api/i18n/tl.json
@@ -0,0 +1,38 @@
+{
+ "@metadata": {
+ "authors": [
+ "Leeheonjin",
+ "Macofe"
+ ]
+ },
+ "apihelp-feedrecentchanges-param-categories": "Ipakita lamang ang mga pagbababgo sa mga pahina sa lahat ng mga kategoriyang ito.",
+ "apihelp-feedrecentchanges-param-categories_any": "Ipakita na lang ang mga pagbabago sa mga pahina sa kahit na anong mga kategoriya.",
+ "apihelp-feedrecentchanges-example-simple": "Ipakit ang mga kamakailangang pagbabago.",
+ "apihelp-feedrecentchanges-example-30days": "Ipakita ang mga huling pagbabago sa loob para sa 30 araw.",
+ "apihelp-help-example-main": "Tulong para sa pangunahing modulo.",
+ "apihelp-help-example-recursive": "Lahat ng tulong sa iisang pahina.",
+ "apihelp-login-example-login": "Lumagda (Mag-log in).",
+ "apihelp-move-example-move": "I-urong ang <kbd>Badtitle</kbd> sa <kbd>Goodtitle</kbd> nang hindi nag-iiwan ng redirekta.",
+ "apihelp-options-example-reset": "Ibalik sa dati ang lahat ng mga kanaisan.",
+ "apihelp-patrol-example-rcid": "Bantayan ang kasalukuyang pagbabago.",
+ "apihelp-query+allimages-example-B": "Ipakita ang talaan ng mga talakasang nagsisimula sa titik <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-generator": "Kinukuha ang mga pahinang naglalaman ng mga kawing.",
+ "apihelp-query+allpages-example-B": "Ipakita ang talaan ng mga pahinang nagsisimula sa titik <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-generator": "Kinukuha ang mga pahinang naglalaman ng mga transklusyon.",
+ "apihelp-query+backlinks-example-simple": "Ipakita ang mga kawing sa <kbd>Main page</kbd>.",
+ "apihelp-query+categoryinfo-example-simple": "Kumuha ng impormasyon tungkol sa <kbd>Kategorya:Foo</kbd> at <kbd>Kategorya:Bar</kbd>.",
+ "apihelp-query+duplicatefiles-example-simple": "Maghanap para sa mga duplika ng [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Hanapin ang mga duplika ng lahat ng talakasan.",
+ "apihelp-query+images-example-simple": "Kumuha ng talaan ng mga talakasang ginagamit sa [[Unang Pahina]].",
+ "apihelp-query+imageusage-example-simple": "Ipakita ang mga pahina gamit ang [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+iwbacklinks-example-simple": "Kumuha ng mga pahinang nakarugtong sa [[wikibooks:Test]].",
+ "apihelp-query+linkshere-example-generator": "Kumuha ng kabatiran tungkol sa mga pahina na kumakawing sa [[Unang Pahina]].",
+ "apihelp-query+recentchanges-example-simple": "Talaan ng mga kamakailang pagbabago.",
+ "apihelp-query+search-example-text": "Hanapin ang mga teksto para sa <kbd>kahulugan</kbd>.",
+ "apihelp-query+siteinfo-example-simple": "Kunin ang impormasyon ng sityo.",
+ "apihelp-query+templates-example-simple": "Kumuha ng mga suleras o padron na ginamit sa pahinang <kbd>Unang Pahina</kbd>.",
+ "apihelp-query+watchlist-example-simple": "Itala ang mga punong pagbabago ng mga kasalukuyang binagong pahina sa kasalukuyang listahan ng binabantayan ng tagagamit.",
+ "apihelp-revisiondelete-example-revision": "Itago ang nilalaman para sa pagbabago ng <kbd>12345</kbd> sa pahinang <kbd>Unang Pahina</kbd>.",
+ "apihelp-upload-example-url": "Mag-karga mula sa URL.",
+ "apihelp-watch-example-watch": "Bantayan ang pahinang <kbd>Main Page</kbd>."
+}
diff --git a/www/wiki/includes/api/i18n/tr.json b/www/wiki/includes/api/i18n/tr.json
new file mode 100644
index 00000000..0a2fad0a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/tr.json
@@ -0,0 +1,58 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sayginer",
+ "Sadrettin",
+ "Uğurkent",
+ "Gorizon",
+ "HakanIST",
+ "Imabadplayer",
+ "İnternion"
+ ]
+ },
+ "apihelp-block-summary": "Bir kullanıcıyı engelle.",
+ "apihelp-block-param-reason": "Engelleme sebebi.",
+ "apihelp-createaccount-summary": "Yeni bir kullanıcı hesabı oluşturun.",
+ "apihelp-createaccount-param-name": "Kullanıcı adı.",
+ "apihelp-createaccount-param-password": "Parola (ignored if <var>$1mailpassword</var> is set).",
+ "apihelp-createaccount-param-email": "Kullanıcının e-posta adresi (isteğe bağlı).",
+ "apihelp-createaccount-param-realname": "Kullanıcının gerçek adı (isteğe bağlı).",
+ "apihelp-delete-summary": "Sayfayı sil.",
+ "apihelp-edit-summary": "Sayfa oluştur ve düzenle.",
+ "apihelp-edit-param-text": "Sayfa içeriği.",
+ "apihelp-edit-param-minor": "Küçük değişiklik.",
+ "apihelp-edit-param-nocreate": "Sayfa mevcut değilse hata oluştur.",
+ "apihelp-edit-param-watch": "Sayfayı izleme listenize ekleyin.",
+ "apihelp-edit-param-unwatch": "Sayfayı izleme listenizden çıkarın.",
+ "apihelp-edit-param-redirect": "Yönlendirmeleri otomatik olarak çöz.",
+ "apihelp-emailuser-summary": "Bir kullanıcıya e-posta gönder.",
+ "apihelp-emailuser-param-target": "E-posta gönderilecek kullanıcı.",
+ "apihelp-emailuser-param-subject": "Konu başlığı.",
+ "apihelp-emailuser-param-text": "E-posta metni.",
+ "apihelp-emailuser-param-ccme": "Bu e-postanın bir kopyasını bana gönder.",
+ "apihelp-feedcontributions-param-toponly": "Yalnızca son revizyon olan değişiklikleri göster.",
+ "apihelp-feedcontributions-param-newonly": "Yalnızca yeni sayfa oluşturan değişiklikleri göster.",
+ "apihelp-feedcontributions-param-showsizediff": "Sürümler arasındaki boyut farkını göster.",
+ "apihelp-feedrecentchanges-param-limit": "Verilecek azami sonuç sayısı.",
+ "apihelp-feedrecentchanges-param-hideminor": "Küçük değişiklikleri gizle.",
+ "apihelp-feedrecentchanges-param-hidebots": "Bot değişikliklerini gizle.",
+ "apihelp-feedrecentchanges-param-hideanons": "Anonim kullanıcı değişikliklerini gizle.",
+ "apihelp-feedrecentchanges-param-hideliu": "Kayıtlı kullanıcı değişikliklerini gizle.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Kendi değişikliklerini gizle.",
+ "apihelp-feedrecentchanges-example-simple": "Son değişiklikleri göster",
+ "apihelp-feedrecentchanges-example-30days": "Son 30 gündeki değişiklikleri göster",
+ "apihelp-filerevert-summary": "Bir dosyayı eski bir sürümüne geri döndür.",
+ "apihelp-login-param-name": "Kullanıcı adı.",
+ "apihelp-login-param-password": "Parola.",
+ "apihelp-move-summary": "Bir sayfayı taşı.",
+ "apihelp-move-param-from": "Taşımak istediğiniz sayfanın başlığı. $1fromid ile birlikte kullanılamaz.",
+ "apihelp-move-param-noredirect": "Yönlendirme oluşturmayın.",
+ "apihelp-opensearch-param-limit": "Verilecek azami sonuç sayısı.",
+ "apihelp-options-example-reset": "Tüm tercihleri sıfırla",
+ "apihelp-query+mystashedfiles-param-limit": "Alınacak kaç dosya var",
+ "api-help-title": "MediaWiki API yardımı",
+ "api-help-parameters": "{{PLURAL:$1|Parametre|Parametre}}:",
+ "api-help-param-limit": "$1 taneden fazla olamaz.",
+ "api-help-param-limit2": "$1 taneden fazla (botlar için $2) olamaz.",
+ "api-help-param-default": "Varsayılan: $1"
+}
diff --git a/www/wiki/includes/api/i18n/tt-cyrl.json b/www/wiki/includes/api/i18n/tt-cyrl.json
new file mode 100644
index 00000000..54c534cf
--- /dev/null
+++ b/www/wiki/includes/api/i18n/tt-cyrl.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ильнар"
+ ]
+ },
+ "apihelp-feedcontributions-param-newonly": "Битләр ясау үзгәртмәләрен генә күрсәтү."
+}
diff --git a/www/wiki/includes/api/i18n/udm.json b/www/wiki/includes/api/i18n/udm.json
new file mode 100644
index 00000000..420612cf
--- /dev/null
+++ b/www/wiki/includes/api/i18n/udm.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kaganer",
+ "Mouse21"
+ ]
+ },
+ "apihelp-block-summary": "Блокировка пыриськисьёс.",
+ "apihelp-edit-example-edit": "Бамез тупатъяно.",
+ "apihelp-login-example-login": "Пырыны."
+}
diff --git a/www/wiki/includes/api/i18n/uk.json b/www/wiki/includes/api/i18n/uk.json
new file mode 100644
index 00000000..e43c3830
--- /dev/null
+++ b/www/wiki/includes/api/i18n/uk.json
@@ -0,0 +1,1750 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ата",
+ "A1",
+ "Ahonc",
+ "Base",
+ "Dars",
+ "Macofe",
+ "Mix Gerder",
+ "Piramidion",
+ "Andriykopanytsia",
+ "Максим Підліснюк",
+ "AS",
+ "Umherirrender"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Документація]]\n* [[mw:Special:MyLanguage/API:FAQ|ЧаПи]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Список розсилки]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Оголошення API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Баґи і запити]\n</div>\n<strong>Статус:</strong> Усі функції, вказані на цій сторінці, мають працювати, але API далі перебуває в активній розробці і може змінитися у будь-який момент. Підпишіться на [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ список розсилки mediawiki-api-announce], щоб помічати оновлення.\n\n<strong>Хибні запити:</strong> Коли до API надсилаються хибні запити, буде відіслано HTTP-шапку з ключем «MediaWiki-API-Error», а тоді і значення шапки, і код помилки, надіслані назад, будуть встановлені з тим же значенням. Більше інформації див. на [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n<strong>Тестування:</strong> Для зручності тестування запитів API, див. [[Special:ApiSandbox]].",
+ "apihelp-main-param-action": "Яку дію виконати.",
+ "apihelp-main-param-format": "Формат виводу.",
+ "apihelp-main-param-maxlag": "Максимальна затримка може використовуватися, коли MediaWiki інстальовано на реплікований кластер бази даних. Щоб зберегти дії, які спричиняють більшу затримку реплікації, цей параметр може змусити клієнт почекати, поки затримка реплікації не буде меншою за вказане значення. У випадку непомірної затримки, видається код помилки <samp>maxlag</samp> з повідомленням на зразок <samp>Очікування на $host: $lag секунд(и) затримки</samp>.<br />Див. [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manual: Maxlag parameter]] для детальнішої інформації.",
+ "apihelp-main-param-smaxage": "Встановити <code>s-maxage</code> HTTP кеш-контроль заголовок на стільки секунд. Помилки ніколи не кешуються.",
+ "apihelp-main-param-maxage": "Встановити <code>max-age</code> HTTP кеш-контроль заголовок на стільки секунд. Помилки ніколи не кешуються.",
+ "apihelp-main-param-assert": "Перевірити, що користувач увійшов у систему, якщо задано <kbd>user</kbd>, або має права бота, якщо задано <kbd>bot</kbd>.",
+ "apihelp-main-param-assertuser": "Перевірити, чи поточний користувач є найменованим користувачем.",
+ "apihelp-main-param-requestid": "Будь-яке значення, вказане тут, буде включене у відповідь. Може використовуватися, щоб відрізняти запити.",
+ "apihelp-main-param-servedby": "Включити в результати ім'я хоста, який обробив запит.",
+ "apihelp-main-param-curtimestamp": "Включити в результат поточну мітку часу.",
+ "apihelp-main-param-responselanginfo": "Включати мови, які були використані для <var>uselang</var> і <var>errorlang</var>, у результат.",
+ "apihelp-main-param-origin": "При доступі до API з використанням крос-доменного AJAX-запиту (CORS), задайте параметру значення вихідного домена. Він має бути включений у будь-який попередній запит і таким чином мусить бути частиною запиту URI (не тіла POST). \n\nДля автентифікованих запитів він повинен точно співпадати з одним з виходів у заголовку <code>Origin</code>, тобто бути заданим чимось на зразок <kbd>https://uk.wikipedia.org</kbd> або <kbd>https://meta.wikimedia.org</kbd>. Якщо цей параметр не співпадає з заголовком <code>Origin</code>, повернеться помилка 403. Якщо цей параметр співпадає з заголовком <code>Origin</code> і вихід знаходиться у білому списку, буде встановлено заголовки <code>Access-Control-Allow-Origin</code> і <code>Access-Control-Allow-Credentials</code>.\n\nДля неавтентифікованих запитів укажіть значення <kbd>*</kbd>. Це дасть встановлення заголовка <code>Access-Control-Allow-Origin</code>, але <code>Access-Control-Allow-Credentials</code> буде <code>false</code> і всі дані про користувача будуть заборонені.",
+ "apihelp-main-param-uselang": "Мова, що використовується для перекладу повідомлень. Список кодів можна видати на <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> з <kbd>siprop=languages</kbd> або вказати <kbd>user</kbd> на використання поточного налаштування мови користувача, або вказати <kbd>content</kbd> на використання мови вмісту цієї вікі.",
+ "apihelp-main-param-errorformat": "Формат попереджень і тексту помилок.\n; plaintext: вікітекст із прираними HTML-тегами і заміненими HTML-мнемоніками.\n; wikitext: неопрацьований вікітекст.\n; html: HTML.\n; raw: лише ключ і параметри повідомлення.\n; none: без тексту, тільки коди помилок.\n; bc: формат, який використовувався до MediaWiki 1.29. <var>errorlang</var> і <var>errorsuselocal</var> ігноруються.",
+ "apihelp-main-param-errorlang": "Мова, яку використовувати для попереджень і помилок. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> із <kbd>siprop=languages</kbd> повертає список кодів мов, або ж вкажіть <kbd>content</kbd>, щоб використати мову вмісту поточної вікі, або вкажіть <kbd>uselang</kbd>, щоб використовувати те ж значення, що й параметр <var>uselang</var>.",
+ "apihelp-main-param-errorsuselocal": "Якщо задано, тексти помилок використовуватимуть локальні повідомлення з простору назв {{ns:MediaWiki}}.",
+ "apihelp-block-summary": "Заблокувати користувача.",
+ "apihelp-block-param-user": "Ім'я користувача, IP-адреса або діапазон IP-адрес для блокування. Не може бути використано разом із <var>$1userid</var>",
+ "apihelp-block-param-userid": "Ідентифікатор користувача, який заблокувати. Не може бути використано разом із <var>$1user</var>.",
+ "apihelp-block-param-expiry": "Закінчення часу. Може бути відносним (напр., <kbd>5 місяців</kbd> або <kbd>2 тижні</kbd>) чи абсолютним (напр., <kbd>2014-09-18T12:34:56Z</kbd>). Якщо вказано <kbd>infinite</kbd>, <kbd>indefinite</kbd> або <kbd>never</kbd>, блокування не закінчиться ніколи.",
+ "apihelp-block-param-reason": "Причина блокування.",
+ "apihelp-block-param-anononly": "Блокувати тільки анонімних користувачів (тобто відключити можливість анонімних редагувань з цієї IP-адреси).",
+ "apihelp-block-param-nocreate": "Заборонити створення облікових записів.",
+ "apihelp-block-param-autoblock": "Автоматично блокувати IP-адреси, які цей користувач використовував останніми, та будь-які наступні адреси, з яких він спробує зайти в систему.",
+ "apihelp-block-param-noemail": "Заборонити користувачеві надсилати електронні листи через вікі. (Вимагає права <code>blockemail</code>).",
+ "apihelp-block-param-hidename": "Приховати ім'я користувача з журналу блокувань. (Вимагає права <code>hideuser</code>).",
+ "apihelp-block-param-allowusertalk": "Дозволити користувачу редагувати власну сторінку обговорення (залежить від <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
+ "apihelp-block-param-reblock": "Якщо користувач уже заблокований, переписати наявне блокування.",
+ "apihelp-block-param-watchuser": "Спостерігати за сторінкою користувача чи IP-адреси і сторінкою обговорення.",
+ "apihelp-block-param-tags": "Змінити теги для застосування їх до запису в журналі блокувань.",
+ "apihelp-block-example-ip-simple": "Блокувати IP-адресу <kbd>192.0.2.5</kbd> на три дні з причиною <kbd>First strike</kbd>.",
+ "apihelp-block-example-user-complex": "Блокувати користувача<kbd>Vandal</kbd> на невизначений термін з причиною <kbd>Vandalism</kbd> і заборонити створення нових облікових записів та надсилання електронної пошти.",
+ "apihelp-changeauthenticationdata-summary": "Зміна параметрів аутентифікації для поточного користувача.",
+ "apihelp-changeauthenticationdata-example-password": "Спроба змінити поточний пароль користувача на <kbd>ExamplePassword</kbd>.",
+ "apihelp-checktoken-summary": "Перевірити коректність токена з <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-checktoken-param-type": "Тип токена, який тестується.",
+ "apihelp-checktoken-param-token": "Токен для тесту.",
+ "apihelp-checktoken-param-maxtokenage": "Максимально допустимий вік токена (у секундах).",
+ "apihelp-checktoken-example-simple": "Перевірити дійсність токена <kbd>csrf</kbd>.",
+ "apihelp-clearhasmsg-summary": "Очищає прапорець <code>hasmsg</code> для поточного користувача.",
+ "apihelp-clearhasmsg-example-1": "Очистити прапорець <code>hasmsg</code> для поточного користувача.",
+ "apihelp-clientlogin-summary": "Увійдіть у вікі з допомогою інтерактивного потоку.",
+ "apihelp-clientlogin-example-login": "Почати процес входу у вікі як користувач <kbd>Example</kbd> з паролем <kbd>ExamplePassword</kbd>.",
+ "apihelp-clientlogin-example-login2": "Продовжити вхід в систему після відповіді <samp>UI</samp> для двофакторної автентифікації, надаючи <var>OATHToken</var> як <kbd>987654</kbd>.",
+ "apihelp-compare-summary": "Отримати порівняння двох сторінок.",
+ "apihelp-compare-extended-description": "Повинні бути номер версії, назва сторінки або ID сторінки для «від» і «до».",
+ "apihelp-compare-param-fromtitle": "Перший заголовок для порівняння.",
+ "apihelp-compare-param-fromid": "Перший ID сторінки для порівняння.",
+ "apihelp-compare-param-fromrev": "Перша версія для порівняння.",
+ "apihelp-compare-param-fromtext": "Використати цей текст замість контенту версії, вказаної через <var>fromtitle</var>, <var>fromid</var> або <var>fromrev</var>.",
+ "apihelp-compare-param-frompst": "Зробити трансформацію перед збереженням на <var>fromtext</var>.",
+ "apihelp-compare-param-fromcontentmodel": "Контентна модель <var>fromtext</var>. Якщо не вказано, буде використано припущення на основі інших параметрів.",
+ "apihelp-compare-param-fromcontentformat": "Формат серіалізації контенту <var>fromtext</var>.",
+ "apihelp-compare-param-totitle": "Другий заголовок для порівняння.",
+ "apihelp-compare-param-toid": "Другий ID сторінки для порівняння.",
+ "apihelp-compare-param-torev": "Друга версія для порівняння.",
+ "apihelp-compare-param-torelative": "Використати версію, яка стосується версії, визначеної через <var>fromtitle</var>, <var>fromid</var> або <var>fromrev</var>. Усі інші опції 'to' буде проігноровано.",
+ "apihelp-compare-param-totext": "Використати цей текст замість контенту версії, вказаної через <var>totitle</var>, <var>toid</var> або <var>torev</var>.",
+ "apihelp-compare-param-topst": "Виконати трансформацію перед збереженням на <var>totext</var>.",
+ "apihelp-compare-param-tocontentmodel": "Контентна модель <var>totext</var>. Якщо не вказано, буде використано припущення на основі інших параметрів.",
+ "apihelp-compare-param-tocontentformat": "Формат серіалізації контенту <var>totext</var>.",
+ "apihelp-compare-param-prop": "Які уривки інформації отримати.",
+ "apihelp-compare-paramvalue-prop-diff": "HTML різниці версій.",
+ "apihelp-compare-paramvalue-prop-diffsize": "Розмір HTML різниці версій, у байтах.",
+ "apihelp-compare-paramvalue-prop-rel": "Іддентифікатори версій, які передують 'from' і йдуть після 'to', якщо такі взагалі існують.",
+ "apihelp-compare-paramvalue-prop-ids": "Ідентифікатори сторінки й версій 'from' і 'to'.",
+ "apihelp-compare-paramvalue-prop-title": "Назви сторінок версій 'from' і 'to'.",
+ "apihelp-compare-paramvalue-prop-user": "Ім'я користувача й ідентифікатор версій 'from' і 'to'.",
+ "apihelp-compare-paramvalue-prop-comment": "Опис редагування версій 'from' і 'to'.",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "Опрацьований опис редагування версій 'from' і 'to'.",
+ "apihelp-compare-paramvalue-prop-size": "Розмір версій 'from' і 'to'.",
+ "apihelp-compare-example-1": "Створити порівняння версій 1 і 2.",
+ "apihelp-createaccount-summary": "Створити новий обліковий запис користувача.",
+ "apihelp-createaccount-param-preservestate": "Якщо запит <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> повернув істину для <samp>hasprimarypreservedstate</samp>, запити позначені як <samp>primary-required</samp> повинні бути пропущені. Якщо він повернув не порожнє значення для <samp>preservedusername</samp>, це ім'я користувача повинно бути використано для параметру <var>username</var>.",
+ "apihelp-createaccount-example-create": "Почати процес створення користувача <kbd>Example</kbd> з паролем <kbd>ExamplePassword</kbd>.",
+ "apihelp-createaccount-param-name": "Ім'я користувача.",
+ "apihelp-createaccount-param-password": "Пароль (ігнорується, якщо встановлено <var>$1mailpassword</var>).",
+ "apihelp-createaccount-param-domain": "Домен для зовнішньої аутентифікації (опціонально).",
+ "apihelp-createaccount-param-token": "Токен створення облікового запису отримано у першому запиті.",
+ "apihelp-createaccount-param-email": "Адреса електронної пошти користувача (необов'язково).",
+ "apihelp-createaccount-param-realname": "Справжнє ім'я користувача (необов'язково).",
+ "apihelp-createaccount-param-mailpassword": "Якщо встановлено будь-яке значення, користувачеві буде надіслано випадковий пароль.",
+ "apihelp-createaccount-param-reason": "Необов'язкова причина для створення облікового запису, яка буде записана в журнал.",
+ "apihelp-createaccount-param-language": "Код мови для встановлення за замовчуванням для користувача (необов'язково, за замовчуванням — мова вмісту).",
+ "apihelp-createaccount-example-pass": "Створити користувача <kbd>testuser</kbd> з паролем <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Створити користувача <kbd>testmailuser</kbd> і надіслати на електронну пошту випадково-згенерований пароль.",
+ "apihelp-cspreport-summary": "Використовується браузерами для повідомлення порушень Правил безпеки контенту (Content Security Policy). Цей модуль не повинен використовуватися, окрім випадків автоматичного використання веб-браузером для CSP-скарги.",
+ "apihelp-cspreport-param-reportonly": "Позначити як доповідь із моніторингової політики, не примусової політики",
+ "apihelp-cspreport-param-source": "Що згенерувало CSP-заголовок, який запустив цю доповідь",
+ "apihelp-delete-summary": "Вилучити сторінку.",
+ "apihelp-delete-param-title": "Назва сторінки для вилучення. Не можна використати разом з <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "ID-сторінки на вилучення. Не можна використати разом з <var>$1title</var>.",
+ "apihelp-delete-param-reason": "Причина вилучення. Якщо не вказана, буде використано автоматично-згенеровану.",
+ "apihelp-delete-param-tags": "Змінити теґи, які буде застосовано до запису в журналі вилучень.",
+ "apihelp-delete-param-watch": "Додати сторінку у список спостереження поточного користувача.",
+ "apihelp-delete-param-watchlist": "Беззастережно додати або вилучити сторінку зі списку спостереження поточного користувача, використати налаштування або не змінювати спостереження.",
+ "apihelp-delete-param-unwatch": "Вилучити сторінку зі списку спостереження поточного користувача.",
+ "apihelp-delete-param-oldimage": "Назва старого зображення на вилучення, як вказано у [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]].",
+ "apihelp-delete-example-simple": "Вилучити <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Вилучити <kbd>Main Page</kbd> з причиною <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Цей модуль було вимкнено.",
+ "apihelp-edit-summary": "Створювати і редагувати сторінки.",
+ "apihelp-edit-param-title": "Назва сторінки для редагування. Не можна використати разом з <var>$1pageid</var>.",
+ "apihelp-edit-param-pageid": "ID-сторінки для редагування. Не можна використати разом з <var>$1title</var>.",
+ "apihelp-edit-param-section": "Номер розділу. <kbd>0</kbd> для вступного розділу, <kbd>new</kbd> для нового розділу.",
+ "apihelp-edit-param-sectiontitle": "Назва нового розділу.",
+ "apihelp-edit-param-text": "Вміст сторінки.",
+ "apihelp-edit-param-summary": "Опис редагування. Також заголовок розділу, коли $1section=new і коли $1sectiontitle не вказано.",
+ "apihelp-edit-param-tags": "Змінити теги для версії.",
+ "apihelp-edit-param-minor": "Незначне редагування.",
+ "apihelp-edit-param-notminor": "Не «незначне» редагування.",
+ "apihelp-edit-param-bot": "Позначити редагування як зроблене ботом.",
+ "apihelp-edit-param-basetimestamp": "Мітка часу для основної версії, використовується для виявлення конфлікту редагувань. Може бути отримана через [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].",
+ "apihelp-edit-param-starttimestamp": "Мітка часу, з якого почався процес редагування, використовується для виявлення конфліктів редагувань. Відповідне значення можна отримати з допомогою <var>[[Special:ApiHelp/main|curtimestamp]]</var> на початку процесу редагування (напр., коли завантажується вміст сторінки для редагування).",
+ "apihelp-edit-param-recreate": "Відкинути будь-які помилки щодо цієї сторінки, вилучені нещодавно.",
+ "apihelp-edit-param-createonly": "Не редагувати сторінку, якщо вона вже існує.",
+ "apihelp-edit-param-nocreate": "Видати помилку, якщо сторінка не існує.",
+ "apihelp-edit-param-watch": "Додати сторінку у список спостереження поточного користувача.",
+ "apihelp-edit-param-unwatch": "Вилучити сторінку зі списку спостереження поточного користувача.",
+ "apihelp-edit-param-watchlist": "Беззастережно додати або вилучити сторінку зі списку спостереження поточного користувача, використати налаштування або не змінювати спостереження.",
+ "apihelp-edit-param-md5": "MD5-хеш у параметрі $1text або параметрах $1prependtext і $1appendtext разом. Якщо вказано, редагування буде зроблене, лише якщо хеш правильний.",
+ "apihelp-edit-param-prependtext": "Додати цей текст на початок сторінки. Замінює $1text.",
+ "apihelp-edit-param-appendtext": "Додати цей текст у кінець сторінки. Замінює $1text.\n\nЩоб додати новий розділ, замість цього параметра використайте $1section=new.",
+ "apihelp-edit-param-undo": "Скасувати цю версію. Замінює $1text, $1prependtext та $1appendtext.",
+ "apihelp-edit-param-undoafter": "Скасувати усі версії від $1undo до цієї. Якщо не вказано, просто скасувати одну версію.",
+ "apihelp-edit-param-redirect": "Автоматично виправляти перенаправлення.",
+ "apihelp-edit-param-contentformat": "Формат серіалізації вмісту, використовуваний для введеного тексту.",
+ "apihelp-edit-param-contentmodel": "Модель вмісту нового вмісту.",
+ "apihelp-edit-param-token": "Токен завжди має надсилатися як останній параметр або хоча б після параметра $1text.",
+ "apihelp-edit-example-edit": "Редагувати сторінку",
+ "apihelp-edit-example-prepend": "Додати зміст на початок сторінки",
+ "apihelp-edit-example-undo": "Скасувати версії з 13579 по 13585 з автоматичним описом змін",
+ "apihelp-emailuser-summary": "Надіслати електронного листа користувачеві",
+ "apihelp-emailuser-param-target": "Користувач, якому відправляється електронний лист.",
+ "apihelp-emailuser-param-subject": "Заголовок теми.",
+ "apihelp-emailuser-param-text": "Тіло листа.",
+ "apihelp-emailuser-param-ccme": "Надіслати копію цього повідомлення мені.",
+ "apihelp-emailuser-example-email": "Відправити листа користувачу <kbd>WikiSysop</kbd> з текстом <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Розгортає усі шаблони в межах вікірозмітки.",
+ "apihelp-expandtemplates-param-title": "Заголовок сторінки.",
+ "apihelp-expandtemplates-param-text": "Вікітекст для перетворення.",
+ "apihelp-expandtemplates-param-revid": "ID версії, для <code><nowiki>{{REVISIONID}}</nowiki></code> і подібних змінних.",
+ "apihelp-expandtemplates-param-prop": "Яку інформацію отримувати.\n\nЗважте, що якщо не вибрано значень, результат міститиме вікітекст, але буде в застарілому форматі.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Розгорнений вікітекст.",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "Будь-які категорії, наявні у джерелі, але не виведені у вікітексті результату.",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "Властивості сторінки, визначені розгорненими магічними словами у вікітексті.",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "Чи результат тривкий і не повинен повторно використовуватись десь іще на сторінці.",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "Максимальний час, після якого кеш результату стане недійсним.",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "Будь-які модулі ResourceLoader, які парсерні функції запитують на додання у результат. Або <kbd>jsconfigvars</kbd>, або <kbd>encodedjsconfigvars</kbd> має бути запитано разом з <kbd>modules</kbd>.",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Дає конфігурації JavaScript змінні, притаманні для сторінки.",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Дає конфігурації JavaScript змінні, притаманні для сторінки, як рядок JSON.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "Дерево парсу XML вхідних даних.",
+ "apihelp-expandtemplates-param-includecomments": "Чи включати HTML-коментарі у результат.",
+ "apihelp-expandtemplates-param-generatexml": "Дерево парсу XML вхідних даних (замінене на $1prop=parsetree).",
+ "apihelp-expandtemplates-example-simple": "Розгорнути вікітекст <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+ "apihelp-feedcontributions-summary": "Повертає стрічку внеску користувача.",
+ "apihelp-feedcontributions-param-feedformat": "Формат стрічки.",
+ "apihelp-feedcontributions-param-user": "Для яких користувачів отримати внесок.",
+ "apihelp-feedcontributions-param-namespace": "За яким простором назв фільтрувати внески.",
+ "apihelp-feedcontributions-param-year": "Від року (і раніше).",
+ "apihelp-feedcontributions-param-month": "До місяця (і раніше).",
+ "apihelp-feedcontributions-param-tagfilter": "Відфільтрувати внесок, у якого є ці теґи.",
+ "apihelp-feedcontributions-param-deletedonly": "Показати лише вилучений внесок.",
+ "apihelp-feedcontributions-param-toponly": "Показати лише редагування, які є останніми версіями.",
+ "apihelp-feedcontributions-param-newonly": "Показати лише редагування, які є створеннями сторінок.",
+ "apihelp-feedcontributions-param-hideminor": "Приховати незначні редагування.",
+ "apihelp-feedcontributions-param-showsizediff": "Показати різницю розміру між версіями.",
+ "apihelp-feedcontributions-example-simple": "Вивести внесок для користувача <kbd>Example</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Видає стрічку нових редагувань.",
+ "apihelp-feedrecentchanges-param-feedformat": "Формат стрічки.",
+ "apihelp-feedrecentchanges-param-namespace": "Простір назв, до якого обмежити результати.",
+ "apihelp-feedrecentchanges-param-invert": "Усі простори назв, крім вибраного.",
+ "apihelp-feedrecentchanges-param-associated": "Включно з пов'язаним (обговорення чи головним) простором назв.",
+ "apihelp-feedrecentchanges-param-days": "Дні, до яких обмежити результати.",
+ "apihelp-feedrecentchanges-param-limit": "Максимальна кількість результатів для виведення.",
+ "apihelp-feedrecentchanges-param-from": "Показати зміни відтоді.",
+ "apihelp-feedrecentchanges-param-hideminor": "Приховати незначні редагування.",
+ "apihelp-feedrecentchanges-param-hidebots": "Приховати редагування ботів.",
+ "apihelp-feedrecentchanges-param-hideanons": "Приховати редагування анонімних користувачів.",
+ "apihelp-feedrecentchanges-param-hideliu": "Приховати редагування зареєстрованих користувачів.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Приховати відпатрульовані редагування.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Приховати редагування поточного користувача.",
+ "apihelp-feedrecentchanges-param-hidecategorization": "Приховати зміни в членстві в категорії.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Фільтрувати за теґом.",
+ "apihelp-feedrecentchanges-param-target": "Показати лише зміни на сторінках, на які посилається ця сторінка.",
+ "apihelp-feedrecentchanges-param-showlinkedto": "Показати натомість лише зміни на сторінках, які посилаються на цю сторінку.",
+ "apihelp-feedrecentchanges-param-categories": "Показати лише зміни сторінок у всіх цих категоріях.",
+ "apihelp-feedrecentchanges-param-categories_any": "Показати натомість лише зміни на сторінках у будь-якій з цих категорій.",
+ "apihelp-feedrecentchanges-example-simple": "Показати нещодавні зміни.",
+ "apihelp-feedrecentchanges-example-30days": "Показати нещодавні зміни за 30 днів.",
+ "apihelp-feedwatchlist-summary": "Видає стрічку списку спостереження.",
+ "apihelp-feedwatchlist-param-feedformat": "Формат стрічки.",
+ "apihelp-feedwatchlist-param-hours": "Список сторінок, змінених за цю кількість годин від зараз.",
+ "apihelp-feedwatchlist-param-linktosections": "За можливості, посилатися безпосередньо на змінені розділи.",
+ "apihelp-feedwatchlist-example-default": "Показати стрічку списку спостереження.",
+ "apihelp-feedwatchlist-example-all6hrs": "Показати всі зміни до спостережуваних сторінок за останні 6 годин.",
+ "apihelp-filerevert-summary": "Повернути файл до старої версії.",
+ "apihelp-filerevert-param-filename": "Цільова назва файлу, без префіксу File:.",
+ "apihelp-filerevert-param-comment": "Завантажити коментар.",
+ "apihelp-filerevert-param-archivename": "Архівна назва версії, до якої повернути.",
+ "apihelp-filerevert-example-revert": "Повернути <kbd>Wiki.png</kbd> до версії <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Відображати довідку для зазначених модулів.",
+ "apihelp-help-param-modules": "Модулі, для яких відображати довідку (значення параметрів <var>action</var> і <var>format</var> або <kbd>main</kbd>). Можна вказати підмодулі через <kbd>+</kbd>.",
+ "apihelp-help-param-submodules": "Включити довідку для підмодулів вказаного модуля.",
+ "apihelp-help-param-recursivesubmodules": "Включити довідку для підмодулів рекурсивно.",
+ "apihelp-help-param-helpformat": "Формат результату довідки.",
+ "apihelp-help-param-wrap": "Помістити результат у стандартну структуру API-відповіді.",
+ "apihelp-help-param-toc": "Включити зміст у HTML-результат.",
+ "apihelp-help-example-main": "Довідка для головного модуля.",
+ "apihelp-help-example-submodules": "Довідка для <kbd>action=query</kbd> та усіх її підмодулів.",
+ "apihelp-help-example-recursive": "Уся довідка на одній сторінці.",
+ "apihelp-help-example-help": "Довідка для самого модуля довідки.",
+ "apihelp-help-example-query": "Довідка для двох підмодулів запитів.",
+ "apihelp-imagerotate-summary": "Поворот одного або декількох зображень.",
+ "apihelp-imagerotate-param-rotation": "Градуси для повороту зображення за годинниковою стрілкою.",
+ "apihelp-imagerotate-param-tags": "Теги для застосування до запису в журналі завантажень.",
+ "apihelp-imagerotate-example-simple": "Повернути <kbd>File:Example.png</kbd> на <kbd>90</kbd> градусів.",
+ "apihelp-imagerotate-example-generator": "Повернути усі зображення у <kbd>Category:Flip</kbd> на <kbd>180</kbd> градусів.",
+ "apihelp-import-summary": "Імпортувати сторінку з іншої вікі або з XML-файлу.",
+ "apihelp-import-extended-description": "Зважте, що HTTP POST має бути виконано як завантаження файлу (тобто з використанням даних різних частин/форм) під час надсилання файлу для параметра <var>xml</var>.",
+ "apihelp-import-param-summary": "Підсумок імпорту записів журналу.",
+ "apihelp-import-param-xml": "Завантажено XML-файл.",
+ "apihelp-import-param-interwikisource": "Для інтервікі-імпорту: вікі, з якої імпортувати.",
+ "apihelp-import-param-interwikipage": "Для інтервікі-імпорту: сторінки для імпорту.",
+ "apihelp-import-param-fullhistory": "Для інтервікі-імпорту: імпортувати повну історію, не лише поточну версію.",
+ "apihelp-import-param-templates": "Для інтервікі-імпорту: імпортувати також усі включені шаблони.",
+ "apihelp-import-param-namespace": "Імпортувати у цей простір назв. Не можна використати разом з <var>$1rootpage</var>.",
+ "apihelp-import-param-rootpage": "Імпортувати як підсторінку цієї сторінки. Не можна використати разом з <var>$1namespace</var>.",
+ "apihelp-import-param-tags": "Змінити теги для застосування до запису в журналі імпорту і до нульової версії імпортованих сторінок.",
+ "apihelp-import-example-import": "Імпортувати [[meta:Help:ParserFunctions]] у простір назв 100 з повною історією.",
+ "apihelp-linkaccount-summary": "Пов'язати обліковий запис третьої сторони з поточним користувачем.",
+ "apihelp-linkaccount-example-link": "Почати процес пов'язування з обліковм записом з <kbd>Example</kbd>.",
+ "apihelp-login-summary": "Увійти в систему й отримати куки автентифікації.",
+ "apihelp-login-extended-description": "Цю дію треба використовувати лише в комбінації з [[Special:BotPasswords]]; використання для входу в основний обліковий запис застаріле і може ламатися без попередження. Щоб безпечно увійти в основний обліковий запис, використовуйте <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-extended-description-nobotpasswords": "Ця дія застаріла і може ламатися без попередження. Щоб безпечно входити в систему, використовуйте <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "apihelp-login-param-name": "Ім'я користувача.",
+ "apihelp-login-param-password": "Пароль.",
+ "apihelp-login-param-domain": "Домен (необов'язково).",
+ "apihelp-login-param-token": "Токен входу в систему, отриманий у першому запиті.",
+ "apihelp-login-example-gettoken": "Отримати токен входу в систему.",
+ "apihelp-login-example-login": "Увійти в систему.",
+ "apihelp-logout-summary": "Вийти й очистити дані сесії.",
+ "apihelp-logout-example-logout": "Вийти з поточного облікового запису.",
+ "apihelp-managetags-summary": "Виконати керівні завдання щодо зміни теґів.",
+ "apihelp-managetags-param-operation": "Яку операцію виконати:\n;create:Створити нову мітку редагування для використання вручну.\n;delete:Вилучити мітку редагування з бази даних, включно з вилученням її з усіх версій, записів нових редагувань та записів журналів, де вона використана.\n;activate:Активувати мітку редагування, дозволивши користувачам застосовувати її вручну.\n;deactivate:Деактивувати мітку редагування, заборонивши користувачам застосовувати її вручну.",
+ "apihelp-managetags-param-tag": "Мітка для створення, вилучення, активування чи деактивування. Для створення мітки, вона повинна не існувати. Для вилучення мітки, вона повинна існувати. Для активування мітки, вона повинна існувати і не використовуватися жодним розширенням. Для деактивування мітки, вона має бути жива і визначена вручну.",
+ "apihelp-managetags-param-reason": "Необов'язкова причина створення, вилучення, активування чи деактивування мітки.",
+ "apihelp-managetags-param-ignorewarnings": "Чи ігнорувати усі попередження, що з'являються під час операції.",
+ "apihelp-managetags-param-tags": "Змінити теги для застосування до запису в журналі керування тегами.",
+ "apihelp-managetags-example-create": "Створити мітку з назвою <kbd>spam</kbd> з причиною <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "Вилучити мітку <kbd>vandlaism</kbd> з причиною <kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "Активувати мітку з назвою <kbd>spam</kbd> з причиною <kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "Деактивувати мітку з назвою <kbd>spam</kbd> з причиною <kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "Об'єднання історій редагувань.",
+ "apihelp-mergehistory-param-from": "Назва сторінки, з якої буде приєднана історія редагувань. Не можна використовувати разом із <var>$1fromid</var>.",
+ "apihelp-mergehistory-param-fromid": "ID сторінки, з якої буде приєднана історія редагувань. Не можна використовувати разом із <var>$1from</var>.",
+ "apihelp-mergehistory-param-to": "Назва сторінки, до якої буде приєднана історія редагувань. Не можна використовувати разом із <var>$1toid</var>.",
+ "apihelp-mergehistory-param-toid": "ID сторінки, до якої буде приєднана історія редагувань. Не можна використовувати разом із <var>$1to</var>.",
+ "apihelp-mergehistory-param-timestamp": "Мітка часу, всі версії до якої будуть перенесені з історії редагувань вихідної сторінки до історії редагувань цільової сторінки. Якщо цей параметр пропущено, вся історія редагувань вихідної сторінки буде приєднана до цільової.",
+ "apihelp-mergehistory-param-reason": "Причина об'єднання історій.",
+ "apihelp-mergehistory-example-merge": "Приєднання всієї історії редагувань сторінки <kbd>Oldpage</kbd> до <kbd>Newpage</kbd>.",
+ "apihelp-mergehistory-example-merge-timestamp": "Приєднання версій до <kbd>2015-12-31T04:37:41Z</kbd> із <kbd>Oldpage</kbd> до <kbd>Newpage</kbd>.",
+ "apihelp-move-summary": "Перейменувати сторінку.",
+ "apihelp-move-param-from": "Назва сторінки для перейменування. Не можна використати разом з <var>$1fromid</var>.",
+ "apihelp-move-param-fromid": "ID сторінки для перейменування. Не можна використати разом з <var>$1from</var>.",
+ "apihelp-move-param-to": "Назва сторінки, на яку перейменувати.",
+ "apihelp-move-param-reason": "Причина перейменування.",
+ "apihelp-move-param-movetalk": "Перейменувати сторінку обговорення, якщо вона існує.",
+ "apihelp-move-param-movesubpages": "Перейменувати підсторінки, якщо можливо.",
+ "apihelp-move-param-noredirect": "Не створювати перенаправлення.",
+ "apihelp-move-param-watch": "Додати сторінку й перенаправлення у список спостереження поточного користувача.",
+ "apihelp-move-param-unwatch": "Вилучити сторінку й перенаправлення зі списку спостереження поточного користувача.",
+ "apihelp-move-param-watchlist": "Беззастережно додати або вилучити сторінку зі списку спостереження поточного користувача, використати налаштування або не змінювати спостереження.",
+ "apihelp-move-param-ignorewarnings": "Ігнорувати всі попередження",
+ "apihelp-move-param-tags": "Змінити теги для застосування до запису в журналі перейменувань і до нульової версії цільової сторінки.",
+ "apihelp-move-example-move": "Перейменувати <kbd>Badtitle</kbd> на <kbd>Goodtitle</kbd> без збереження перенаправлення.",
+ "apihelp-opensearch-summary": "Шукати у вікі з використанням протоколу OpenSearch.",
+ "apihelp-opensearch-param-search": "Рядок пошуку.",
+ "apihelp-opensearch-param-limit": "Максимальна кількість результатів для виведення.",
+ "apihelp-opensearch-param-namespace": "Простори назв, у яких шукати.",
+ "apihelp-opensearch-param-suggest": "Нічого не робити, якщо <var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> хибний.",
+ "apihelp-opensearch-param-redirects": "Як обробляти перенаправлення:\n;return:Видати саме перенаправлення.\n;resolve:Видати цільову сторінку. Може видати менше, ніж $1limit результат{{PLURAL:$1limit||и|ів}}.\nЗ історичних причин, за замовчуванням стоїть «return» для $1format=json і «resolve» — для інших форматів.",
+ "apihelp-opensearch-param-format": "Формат виводу.",
+ "apihelp-opensearch-param-warningsaserror": "Якщо при <kbd>format=json</kbd> з'являються попередження, видати помилку API замість того, щоб їх ігнорувати.",
+ "apihelp-opensearch-example-te": "Знайти сторінки, що починаються з <kbd>Te</kbd>.",
+ "apihelp-options-summary": "Змінити налаштування поточного користувача.",
+ "apihelp-options-extended-description": "Можна встановити лише опції, які зареєстровані у ядрі або в одному з інстальованих розширень, або опції з префіксом ключів <code>userjs-</code> (призначені для використання користувацькими скриптами).",
+ "apihelp-options-param-reset": "Встановлює налаштування сайту за замовчуванням.",
+ "apihelp-options-param-resetkinds": "Список типів опцій для перевстановлення, коли вказана опція <var>$1reset</var>.",
+ "apihelp-options-param-change": "Список змін, відформатованих як назва=значення (напр., skin=vector). Якщо значення не вказане (навіть немає знака рівності) , напр., optionname|otheroption|…, опцію буде перевстановлено до її значення за замовчуванням. Якщо будь-яке зі значень містить символ вертикальної риски (<kbd>|</kbd>), використайте [[Special:ApiHelp/main#main/datatypes|альтернативний розділювач значень]] для коректного виконання операції.",
+ "apihelp-options-param-optionname": "Назва опції, якій має бути присвоєне значення <var>$1optionvalue</var>.",
+ "apihelp-options-param-optionvalue": "Значення опції, вказане в <var>$1optionname</var>.",
+ "apihelp-options-example-reset": "Скинути всі налаштування.",
+ "apihelp-options-example-change": "Змінити налаштування <kbd>skin</kbd> та <kbd>hideminor</kbd>.",
+ "apihelp-options-example-complex": "Скинути всі налаштування, потім встановити <kbd>skin</kbd> та <kbd>nickname</kbd>.",
+ "apihelp-paraminfo-summary": "Отримати інформацію про модулі API.",
+ "apihelp-paraminfo-param-modules": "Список назв модулів (значення параметрів <var>action</var> і <var>format</var> або <kbd>main</kbd>). Можна вказати підмодулі через <kbd>+</kbd>, усі підмодулі через <kbd>+*</kbd> або усі підмодулі рекурсивно через <kbd>+**</kbd>.",
+ "apihelp-paraminfo-param-helpformat": "Формат рядків довідки.",
+ "apihelp-paraminfo-param-querymodules": "Список назв модулів запитів (значення параметра <var>prop</var>, <var>meta</var> або <var>list</var>). Використати <kbd>$1modules=query+foo</kbd> замість <kbd>$1querymodules=foo</kbd>.",
+ "apihelp-paraminfo-param-mainmodule": "Отримати інформацію також про основний модуль (топ-рівень). Використати натомість <kbd>$1modules=main</kbd>.",
+ "apihelp-paraminfo-param-pagesetmodule": "Отримати також інформацію про модуль pageset (з вказанням titles= і рідних).",
+ "apihelp-paraminfo-param-formatmodules": "Список назв модулів форматування (значення параметра <var>format</var>). Використати натомість <var>$1modules</var>.",
+ "apihelp-paraminfo-example-1": "Показати інформацію для <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>, <kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>, <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> та <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>.",
+ "apihelp-paraminfo-example-2": "Показати інформацію про всі підмодулі <kbd>[[Special:ApiHelp/query|action=query]]</kbd>.",
+ "apihelp-parse-summary": "Аналізує вміст і видає парсер виходу.",
+ "apihelp-parse-extended-description": "Див. різні prop-модулі <kbd>[[Special:ApiHelp/query|action=query]]</kbd>, щоб отримати інформацію з поточної версії сторінки.\n\nЄ декілька способів вказати текст для аналізу:\n# Вказати сторінку або версію, використавши <var>$1page</var>, <var>$1pageid</var> або <var>$1oldid</var>.\n# Вказати безпосередньо, використавши <var>$1text</var>, <var>$1title</var> і <var>$1contentmodel</var>.\n# Вказати лише підсумок аналізу. <var>$1prop</var> повинен мати порожнє значення.",
+ "apihelp-parse-param-title": "Назва сторінки, якій належить текст. Якщо пропущена, має бути вказано <var>$1contentmodel</var>, а як назву буде вжито [[API]].",
+ "apihelp-parse-param-text": "Текст для аналізу. Використати <var>$1title</var> або <var>$1contentmodel</var> для контролю моделі вмісту.",
+ "apihelp-parse-param-summary": "Підсумок для аналізу.",
+ "apihelp-parse-param-page": "Аналізувати вміст цієї сторінки. Не можна використати разом з <var>$1text</var> і <var>$1title</var>.",
+ "apihelp-parse-param-pageid": "Аналізувати вміст цієї сторінки. Перевизначає <var>$1page</var>.",
+ "apihelp-parse-param-redirects": "Якщо <var>$1page</var> або <var>$1pageid</var> вказані як перенаправлення, виправити це.",
+ "apihelp-parse-param-oldid": "Аналізувати вміст цієї версії. Перевизначає <var>$1page</var> та <var>$1pageid</var>.",
+ "apihelp-parse-param-prop": "Яку інформацію отримати?",
+ "apihelp-parse-paramvalue-prop-text": "Дає текст-аналіз вікітексту.",
+ "apihelp-parse-paramvalue-prop-langlinks": "Дає мовні посилання в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-categories": "Дає категорії в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "Дає HTML-версію категорій.",
+ "apihelp-parse-paramvalue-prop-links": "Дає зовнішні посилання в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-templates": "Дає шаблони в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-images": "Дає зображення в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-externallinks": "Дає зовнішні посилання в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-sections": "Дає розділи в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-revid": "Додає ідентифікатор версії аналізованої сторінки.",
+ "apihelp-parse-paramvalue-prop-displaytitle": "Додає заголовок аналізованого вікітексту.",
+ "apihelp-parse-paramvalue-prop-headitems": "Дає елементи для вставки в <code>&lt;head&gt;</code> сторінки.",
+ "apihelp-parse-paramvalue-prop-headhtml": "Дає проаналізований <code>&lt;head&gt;</code> сторінки.",
+ "apihelp-parse-paramvalue-prop-modules": "Дає модулі ResourceLoader, використані на сторінці. Щоб завантажити, використовуйте <code>mw.loader.using()</code>. Чи <kbd>jsconfigvars</kbd>, чи <kbd>encodedjsconfigvars</kbd> має бути запитано разом з <kbd>modules</kbd>.",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "Дає змінні конфігурації JavaScript, притаманні для сторінки. Щоб застосувати, використайте <code>mw.config.set()</code>.",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "Дає змінні конфігурації JavaScript, притаманні для сторінки, як рядок JSON.",
+ "apihelp-parse-paramvalue-prop-indicators": "Дає HTML індикаторів стану сторінки, використаних на сторінці.",
+ "apihelp-parse-paramvalue-prop-iwlinks": "Дає інтервікі-посилання в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-wikitext": "Дає вихідний вікітекст, який було аналізовано.",
+ "apihelp-parse-paramvalue-prop-properties": "Дає різні властивості, визначені в аналізованому вікітексті.",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "Дає звіт по обмеженнях у структурованому вигляді. Не видає даних, якщо встановлено <var>$1disablelimitreport</var>.",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "Дає HTML-версію звіту по обмеженнях. Не видає даних, якщо встановлено <var>$1disablelimitreport</var>.",
+ "apihelp-parse-paramvalue-prop-parsetree": "Синтаксичне дерево XML вмісту версії (передбачає модель вмісту <code>$1</code>)",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "Виводить попередження, які з'явилися при обробці контенту.",
+ "apihelp-parse-param-wrapoutputclass": "CSS-клас для загортання в нього виводу парсера.",
+ "apihelp-parse-param-pst": "Зробіть трансформацію вхідних даних перед збереженням і аналізом. Дійсне лише при використанні з текстом.",
+ "apihelp-parse-param-onlypst": "Зробіть трансформацію вхідних даних перед збереженням (PST), але не аналізуйте. Видає той самий вікітекст, після застосування PST. Дійсне лише у разі використання з <var>$1text</var>.",
+ "apihelp-parse-param-effectivelanglinks": "Включає мовні посилання, додані розширеннями (для використання з <kbd>$1prop=langlinks</kbd>).",
+ "apihelp-parse-param-section": "Розібрати вміст лише розділу з цим номером .\n\nЯкщо <kbd>new</kbd>, розібрати <var>$1text</var> та <var>$1sectiontitle</var>, як ніби новий розділ додається на сторінку.\n\n<kbd>new</kbd> дозволяється лише про вказаному <var>text</var>.",
+ "apihelp-parse-param-sectiontitle": "Заголовок нового розділу, коли <var>section</var> має значення <kbd>new</kbd>.\n\nНа відміну від редагування сторінки, це не повертається до <var>summary</var>, якщо пропустити чи лишити порожнім.",
+ "apihelp-parse-param-disablelimitreport": "Пропустити звіт про ліміти («NewPP limit report») на виході аналізу.",
+ "apihelp-parse-param-disablepp": "Використати натомість <var>$1disablelimitreport</var>.",
+ "apihelp-parse-param-disableeditsection": "Пропустити посилання на редагування розділів на виході аналізу.",
+ "apihelp-parse-param-disabletidy": "Не запускайте очищення HTML (e.g. tidy) на виході аналізу.",
+ "apihelp-parse-param-generatexml": "Генерувати синтаксичне дерево XML (передбачає модель вмісту <code>$1</code>; замінено на <kbd>$2prop=parsetree</kbd>).",
+ "apihelp-parse-param-preview": "Аналізувати у режимі попереднього перегляду.",
+ "apihelp-parse-param-sectionpreview": "Аналізувати у режимі попереднього перегляду розділу (також вмикає попередній перегляд).",
+ "apihelp-parse-param-disabletoc": "Пропустити зміст на виході.",
+ "apihelp-parse-param-useskin": "Застосувати вибрану тему оформлення до виводу парсера. Може вплинути на такі властивості: <kbd>langlinks</kbd>, <kbd>headitems</kbd>, <kbd>modules</kbd>, <kbd>jsconfigvars</kbd>, <kbd>indicators</kbd>.",
+ "apihelp-parse-param-contentformat": "Формат серіалізації вмісту, використаний у вхідному тексті. Дійсний лише при використанні разом з $1text.",
+ "apihelp-parse-param-contentmodel": "Модель вмісту вхідного тексту. Якщо пропущено, має бути вказано $1title, і за замовчуванням буде модель вказаного заголовка. Дійсне лише при використанні з $1text.",
+ "apihelp-parse-example-page": "Аналізувати сторінку.",
+ "apihelp-parse-example-text": "Аналізувати вікітекст.",
+ "apihelp-parse-example-texttitle": "Аналізувати вікітекст, вказуючи назву сторінки.",
+ "apihelp-parse-example-summary": "Аналізувати опис.",
+ "apihelp-patrol-summary": "Відпатрулювати сторінку чи версію.",
+ "apihelp-patrol-param-rcid": "ID нещодавніх змін для патрулювання.",
+ "apihelp-patrol-param-revid": "Ідентифікатор версії для патрулювання.",
+ "apihelp-patrol-param-tags": "Змінити теги, що мають бути застосовані до запису в журналі патрулювання.",
+ "apihelp-patrol-example-rcid": "Відпатрулювати останню зміну.",
+ "apihelp-patrol-example-revid": "Відпатрулювати версію.",
+ "apihelp-protect-summary": "Змінити рівень захисту сторінки.",
+ "apihelp-protect-param-title": "Заголовок сторінки для (зняття) захисту. Не може використовуватися разом із $1pageid.",
+ "apihelp-protect-param-pageid": "ID сторінки для (зняття) захисту. Не може використовуватися разом з $1title.",
+ "apihelp-protect-param-protections": "Список рівнів захисту у форматі <kbd>action=level</kbd> (напр., <kbd>edit=sysop</kbd>). Рівень <kbd>all</kbd> означає, що будь-хто може робити дію, тобто обмежень немає.\n\n<strong>Примітка:</strong> Обмеження на дії, яких нема в списку, буде знято.",
+ "apihelp-protect-param-expiry": "Часові мітки закінчення. Якщо встановлена лише одна мітка, її буде використано для усіх захистів. Для безстрокового захисту використовуйте <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> або <kbd>never</kbd>.",
+ "apihelp-protect-param-reason": "Причина для (зняття) захисту.",
+ "apihelp-protect-param-tags": "Змінити теги, що мають бути застосовані до запису в журналі захисту.",
+ "apihelp-protect-param-cascade": "Увімкнути каскадний захист (тобто захистити включені шаблоні і зображення, використані на цій сторінці). Ігнорується, якщо жоден з вказаних рівнів захисту не підтримує каскадність.",
+ "apihelp-protect-param-watch": "Якщо вказано, додати сторінку, де додається/знімається захист, до списку спостереження поточного користувача.",
+ "apihelp-protect-param-watchlist": "Беззастережно додати або вилучити сторінку зі списку спостереження поточного користувача, використати налаштування або не змінювати спостереження.",
+ "apihelp-protect-example-protect": "Захистити сторінку.",
+ "apihelp-protect-example-unprotect": "Зняти захист зі сторінки, встановивши обмеження для <kbd>all</kbd> (тобто будь-хто зможе робити дії).",
+ "apihelp-protect-example-unprotect2": "Зняти захист з сторінки, встановивши відсутність обмежень.",
+ "apihelp-purge-summary": "Очистити кеш для вказаних заголовків.",
+ "apihelp-purge-param-forcelinkupdate": "Оновити таблиці посилань.",
+ "apihelp-purge-param-forcerecursivelinkupdate": "Оновити таблицю посилань, і оновити таблиці посилань для кожної сторінки, що використовує цю сторінку як шаблон.",
+ "apihelp-purge-example-simple": "Очистити кеш <kbd>Main Page</kbd> і сторінки <kbd>API</kbd>.",
+ "apihelp-purge-example-generator": "Очистити кеш перших десяти сторінок у головному просторі назв.",
+ "apihelp-query-summary": "Вибірка даних з і про MediaWiki.",
+ "apihelp-query-extended-description": "Усі зміни даних у першу чергу мають використовувати запит на отримання токена, щоб запобігти зловживанням зі шкідливих сайтів.",
+ "apihelp-query-param-prop": "Властивості, які потрібно отримати для запитуваних сторінок.",
+ "apihelp-query-param-list": "Які списки отримати.",
+ "apihelp-query-param-meta": "Які метадані отримати.",
+ "apihelp-query-param-indexpageids": "Включити додатковий розділ pageids зі списком усіх виданих ідентифікаторів сторінки.",
+ "apihelp-query-param-export": "Експортувати поточні версії усіх заданих або створюваних сторінок.",
+ "apihelp-query-param-exportnowrap": "Видати експорт XML без огортання його в XML-результат (той же формат, що й [[Special:Export]]). Може використовуватися лише з $1export.",
+ "apihelp-query-param-iwurl": "Чи отримувати повний URL, якщо назва є інтервікі-посиланням.",
+ "apihelp-query-param-rawcontinue": "Видати сирі дані <samp>query-continue</samp> для продовження.",
+ "apihelp-query-example-revisions": "Вибірка [[Special:ApiHelp/query+siteinfo|інформації про сайт]] та [[Special:ApiHelp/query+revisions|версій]] <kbd>Main Page</kbd>.",
+ "apihelp-query-example-allpages": "Вибрати версії сторінок, які починаються з <kbd>API/</kbd>.",
+ "apihelp-query+allcategories-summary": "Перерахувати всі категорії.",
+ "apihelp-query+allcategories-param-from": "Категорія, з якої почати перелічувати.",
+ "apihelp-query+allcategories-param-to": "Категорія, на якій закінчити перелічувати.",
+ "apihelp-query+allcategories-param-prefix": "Шукати усі назви категорій, які починаються з цього значення.",
+ "apihelp-query+allcategories-param-dir": "Напрямок сортування.",
+ "apihelp-query+allcategories-param-min": "Видати лише категорії, які мають щонайменше стільки елементів.",
+ "apihelp-query+allcategories-param-max": "Видати лише категорії, які мають максимум стільки елементів.",
+ "apihelp-query+allcategories-param-limit": "Скільки категорій видати.",
+ "apihelp-query+allcategories-param-prop": "Які властивості отримати:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "Додає номер сторінок у категорії.",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "Теґує категорії, приховані з <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+allcategories-example-size": "Перерахувати категорії з інформацією про кількість сторінок у кожній.",
+ "apihelp-query+allcategories-example-generator": "Отримати інформацію про саму сторінку категорії для категорій, що починаються з <kbd>List</kbd>.",
+ "apihelp-query+alldeletedrevisions-summary": "Перерахувати усі вилучені версії за користувачем або у просторі назв.",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "Може використовуватися лише з <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "Не може використовуватися з <var>$3user</var>.",
+ "apihelp-query+alldeletedrevisions-param-start": "Часова мітка початку переліку.",
+ "apihelp-query+alldeletedrevisions-param-end": "Часова мітка закінчення переліку.",
+ "apihelp-query+alldeletedrevisions-param-from": "Почати перелік з цієї назви.",
+ "apihelp-query+alldeletedrevisions-param-to": "Закінчити перелік цією назвою.",
+ "apihelp-query+alldeletedrevisions-param-prefix": "Шукати усі назви сторінок, які починаються з цього значення.",
+ "apihelp-query+alldeletedrevisions-param-tag": "Перерахувати лише версії, помічені цим теґом.",
+ "apihelp-query+alldeletedrevisions-param-user": "Перерахувати лише версії цього користувача.",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "Не перераховувати версії цього користувача.",
+ "apihelp-query+alldeletedrevisions-param-namespace": "Перерахувати сторінки лише в цьому просторі назв.",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>Примітка:</strong> через [[mw:Special:MyLanguage/Manual:$wgMiserMode|«скупий режим»]], використання <var>$1user</var> і <var>$1namespace</var> одночасно можуть вилитися у видачу результатів менше ніж <var>$1limit</var> перед продовженням; в особливих випадках можуть видаватися нульові результати.",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "Коли використовується як генератор, генерувати заголовки замість ідентифікаторів версій.",
+ "apihelp-query+alldeletedrevisions-example-user": "Перерахувати останні 50 вилучених редагувань користувача <kbd>Example</kbd>.",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "Перерахувати останні 50 вилучених версій у головному просторі назв.",
+ "apihelp-query+allfileusages-summary": "Перерахувати усі використання файлів, включно з тими, що не існують.",
+ "apihelp-query+allfileusages-param-from": "Назва файлу, з якої почати перераховувати.",
+ "apihelp-query+allfileusages-param-to": "Назва файлу, якою закінчувати перераховувати.",
+ "apihelp-query+allfileusages-param-prefix": "Шукати усі назви файлів, які починаються з цього значення.",
+ "apihelp-query+allfileusages-param-unique": "Показувати лише окремі назви файлів. Не може використовуватися разом з $1prop=ids.\nКоли використовується як генератор, видає цільові сторінки замість вихідних сторінок.",
+ "apihelp-query+allfileusages-param-prop": "Які відомості включати:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "Додає ідентифікатори із використаних сторінок (не буде використовуватися, при єдиній $1).",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Додає назву файлу.",
+ "apihelp-query+allfileusages-param-limit": "Скільки всього елементів виводити.",
+ "apihelp-query+allfileusages-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+allfileusages-example-B": "Перерахувати назви файлів, включно з відсутніми, з ідентифікаторами сторінок, на яких вони використані, починаючи з <kbd>B</kbd>.",
+ "apihelp-query+allfileusages-example-unique": "Перерахувати унікальні назви файлів.",
+ "apihelp-query+allfileusages-example-unique-generator": "Отримує всі назви файлів, позначаючи відсутні.",
+ "apihelp-query+allfileusages-example-generator": "Отримує сторінки, на яких є файли.",
+ "apihelp-query+allimages-summary": "Перерахувати усі зображення послідовно.",
+ "apihelp-query+allimages-param-sort": "Властивість, за якою сортувати.",
+ "apihelp-query+allimages-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+allimages-param-from": "Назва зображення, з якої почати перерахунок. Можна використати лише з $1sort=name.",
+ "apihelp-query+allimages-param-to": "Назва зображення, якою закінчити перерахунок. Можна використати лише з $1sort=name.",
+ "apihelp-query+allimages-param-start": "Часова мітка, з якої почати перерахунок. Можна використати лише з $1sort=timestamp.",
+ "apihelp-query+allimages-param-end": "Часова мітка, якою закінчити перерахунок. Можна використати лише з $1sort=timestamp.",
+ "apihelp-query+allimages-param-prefix": "Шукати усі назви зображень, що починаються цим значенням. Можна використати лише разом з $1sort=name.",
+ "apihelp-query+allimages-param-minsize": "Обмежити до зображень, які мають щонайменше стільки байтів.",
+ "apihelp-query+allimages-param-maxsize": "Обмежити до зображень, які мають максимум стільки байтів.",
+ "apihelp-query+allimages-param-sha1": "SHA1-хеш зображення. Перевизначає $1sha1base36.",
+ "apihelp-query+allimages-param-sha1base36": "SHA1-хеш зображення у base 36 (використано в MediaWiki).",
+ "apihelp-query+allimages-param-user": "Видати лише файли, завантажені цим користувачем. Можна використати лише з $1sort=timestamp. Не можна використати разом з $1filterbots.",
+ "apihelp-query+allimages-param-filterbots": "Як фільтрувати файли, завантажені ботами. Можна використати лише з $1sort=timestamp. Не можна використати разом з $1user.",
+ "apihelp-query+allimages-param-mime": "Які MIME-типи шукати, напр., <kbd>image/jpeg</kbd>.",
+ "apihelp-query+allimages-param-limit": "Скільки всього зображень видати.",
+ "apihelp-query+allimages-example-B": "Показати список файлів, які починаються на літеру <kbd>B</kbd>.",
+ "apihelp-query+allimages-example-recent": "Показати список нещодавно завантажених файлів, подібно до [[Special:NewFiles]].",
+ "apihelp-query+allimages-example-mimetypes": "Показати список файлів з MIME-типом <kbd>image/png</kbd> або <kbd>image/gif</kbd>",
+ "apihelp-query+allimages-example-generator": "Показати інформацію про 4 файли, що починаються на літеру <kbd>T</kbd>.",
+ "apihelp-query+alllinks-summary": "Перераховувати всі посилання, які вказують на заданий простір назв.",
+ "apihelp-query+alllinks-param-from": "Назва посилання, з якої почати перераховувати.",
+ "apihelp-query+alllinks-param-to": "Назва посилання, якою закінчити перераховувати.",
+ "apihelp-query+alllinks-param-prefix": "Шукати усі пов'язані назви, які починаються з цього значення.",
+ "apihelp-query+alllinks-param-unique": "Показувати лише окремі пов'язані назви. Не може використовуватися з <kbd>$1prop=ids</kbd>.\nКоли використовується як генератор, видає цільові сторінки замість вихідних сторінок.",
+ "apihelp-query+alllinks-param-prop": "Які відомості включати:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "Додає ідентифікатори сторінок, що має посилання (не можна використати разом з <var>$1unique</var>).",
+ "apihelp-query+alllinks-paramvalue-prop-title": "Додає назву посилання.",
+ "apihelp-query+alllinks-param-namespace": "Простір назв для переліку.",
+ "apihelp-query+alllinks-param-limit": "Скільки всього елементів виводити.",
+ "apihelp-query+alllinks-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+alllinks-example-B": "Перерахувати пов'язані назви, включно з відсутніми, з ідентифікаторами сторінок, на яких вони використані, починаючи з <kbd>B</kbd>.",
+ "apihelp-query+alllinks-example-unique": "Перерахувати унікальні назви з посиланнями.",
+ "apihelp-query+alllinks-example-unique-generator": "Отримує всі назви з посиланнями, позначаючи відсутні.",
+ "apihelp-query+alllinks-example-generator": "Отримує сторінки, на яких є посилання.",
+ "apihelp-query+allmessages-summary": "Видати повідомлення від цього сайту.",
+ "apihelp-query+allmessages-param-messages": "Які повідомлення виводити. <kbd>*</kbd> (за замовчуванням) означає усі повідомлення.",
+ "apihelp-query+allmessages-param-prop": "Які властивості отримати.",
+ "apihelp-query+allmessages-param-enableparser": "Встановити увімкнення парсеру, це попередньо обробить вікітекст повідомлення (підставити магічні слова, розкрити шаблони тощо).",
+ "apihelp-query+allmessages-param-nocontent": "Якщо вказано, не включати повідомлення вміст повідомлення у результат.",
+ "apihelp-query+allmessages-param-includelocal": "Також включити локальні повідомлення, тобто повідомлення, що не існують у програмному забезпеченні, але існують як сторінка в просторі назв {{ns:MediaWiki}}.\nЦе видає список усіх сторінок простору {{ns:MediaWiki}}, так що у ньому також будуть сторінки, які насправді не є повідомленнями, як-то [[MediaWiki:Common.js|Common.js]].",
+ "apihelp-query+allmessages-param-args": "Аргументи будуть підставлятися в повідомлення.",
+ "apihelp-query+allmessages-param-filter": "Видати лише повідомлення з назвами, що місять цей рядок.",
+ "apihelp-query+allmessages-param-customised": "Видати лише повідомлення у цьому стані налаштувань.",
+ "apihelp-query+allmessages-param-lang": "Видає повідомлення цією мовою.",
+ "apihelp-query+allmessages-param-from": "Видає повідомлення, починаючи з цього повідомлення.",
+ "apihelp-query+allmessages-param-to": "Видає повідомлення, закінчуючи цим повідомленням.",
+ "apihelp-query+allmessages-param-title": "Назва сторінки для використання як контекст при аналізі повідомлення (для опції $1enableparser).",
+ "apihelp-query+allmessages-param-prefix": "Видати повідомлення з цим префіксом.",
+ "apihelp-query+allmessages-example-ipb": "Показати повідомлення, які починаються на <kbd>ipb-</kbd>.",
+ "apihelp-query+allmessages-example-de": "Показати повідомлення <kbd>august</kbd> і <kbd>mainpage</kbd> німецькою.",
+ "apihelp-query+allpages-summary": "Перераховувати всі сторінки послідовно в заданому просторі назв.",
+ "apihelp-query+allpages-param-from": "Заголовок сторінки, з якого почати перелічувати.",
+ "apihelp-query+allpages-param-to": "Заголовок сторінки, яким закінчувати перелічувати.",
+ "apihelp-query+allpages-param-prefix": "Шукати усі назви сторінок, які починаються з цього значення.",
+ "apihelp-query+allpages-param-namespace": "Простір назв для переліку.",
+ "apihelp-query+allpages-param-filterredir": "Які сторінки перерахувати.",
+ "apihelp-query+allpages-param-minsize": "Обмежити до сторінок, які мають щонайменше стільки байтів.",
+ "apihelp-query+allpages-param-maxsize": "Обмежити до сторінок, які мають максимум стільки байтів.",
+ "apihelp-query+allpages-param-prtype": "Обмежити до захищених сторінок.",
+ "apihelp-query+allpages-param-prlevel": "Фільтрувати захисти залежно від рівня (мусить використовуватися з $1prtype= parameter).",
+ "apihelp-query+allpages-param-prfiltercascade": "Фільтрувати захисти залежно від каскадності (ігнорується, коли $1prtype не вказано).",
+ "apihelp-query+allpages-param-limit": "Скільки всього сторінок виводити.",
+ "apihelp-query+allpages-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+allpages-param-filterlanglinks": "Фільтрувати залежно від наявності у сторінки мовних посилань. Зауважте, що це може не врахувати мовні посилання, додані розширеннями.",
+ "apihelp-query+allpages-param-prexpiry": "За якою тривалістю захисту фільтрувати сторінку:\n;indefinite:Отримати лише сторінки з нескінченним захистом.\n;definite:Отримати лише сторінки з визначеним терміном захисту.\n;all:Отримати сторінки з будь-яким терміном захисту.",
+ "apihelp-query+allpages-example-B": "Показати список сторінок, які починаються на літеру <kbd>B</kbd>.",
+ "apihelp-query+allpages-example-generator": "Показати інформацію про 4 сторінки, що починаються на літеру <kbd>T</kbd>.",
+ "apihelp-query+allpages-example-generator-revisions": "Показати вміст перших двох сторінок, що не є перенаправленнями і починаються на <kbd>Re</kbd>.",
+ "apihelp-query+allredirects-summary": "Перерахувати усі перенаправлення на простір назв.",
+ "apihelp-query+allredirects-param-from": "Назва перенаправлення, з якої почати перераховувати.",
+ "apihelp-query+allredirects-param-to": "Назва перенаправлення, якою закінчувати перераховувати.",
+ "apihelp-query+allredirects-param-prefix": "Шукати усі цільові сторінки, які починаються з цього значення.",
+ "apihelp-query+allredirects-param-unique": "Показувати лише окремі цільові сторінки. Не може використовуватися разом з $1prop=ids|fragment|interwiki.\nКоли використовується як генератор, видає цільові сторінки замість вихідних сторінок.",
+ "apihelp-query+allredirects-param-prop": "Які відомості включити:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "Додає ID сторінки-перенаправлення (не можна використати разом з <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Додає заголовок перенаправлення.",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "Додає фрагмент з перенаправлення, якщо він є (не можна використати разом з <var>$1unique</var>).",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "Додає інтервікі-префікс з перенаправлення, якщо він є (не можна використати разом з <var>$1unique</var>).",
+ "apihelp-query+allredirects-param-namespace": "Простір назв для переліку.",
+ "apihelp-query+allredirects-param-limit": "Скільки всього елементів виводити.",
+ "apihelp-query+allredirects-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+allredirects-example-B": "Перерахувати цільові сторінки, включно з відсутніми, з ідентифікаторами сторінок, на яких вони використані, починаючи з <kbd>B</kbd>.",
+ "apihelp-query+allredirects-example-unique": "Перерахувати унікальні цільові сторінки.",
+ "apihelp-query+allredirects-example-unique-generator": "Отримує всі цільові сторінки, позначаючи відсутні.",
+ "apihelp-query+allredirects-example-generator": "Отримує сторінки, які містять перенаправлення.",
+ "apihelp-query+allrevisions-summary": "Список усіх версій.",
+ "apihelp-query+allrevisions-param-start": "Часова мітка, з якої почати перелік.",
+ "apihelp-query+allrevisions-param-end": "Часова мітка закінчення переліку.",
+ "apihelp-query+allrevisions-param-user": "Перерахувати лише версії цього користувача.",
+ "apihelp-query+allrevisions-param-excludeuser": "Не перераховувати версії цього користувача.",
+ "apihelp-query+allrevisions-param-namespace": "Перерахувати сторінки лише в цьому просторі назв.",
+ "apihelp-query+allrevisions-param-generatetitles": "Коли використовується як генератор, генерувати заголовки замість ідентифікаторів версій.",
+ "apihelp-query+allrevisions-example-user": "Перерахувати останні 50 редагувань користувача <kbd>Example</kbd>.",
+ "apihelp-query+allrevisions-example-ns-main": "Перерахувати перші 50 версій у головному просторі назв.",
+ "apihelp-query+mystashedfiles-summary": "Отримати список файлів у сховку завантажень поточного користувача.",
+ "apihelp-query+mystashedfiles-param-prop": "Які властивості файлів отримати.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "Отримати розмір файлу та виміри зображення.",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "Отримати MIME-тип та тип даних файлу.",
+ "apihelp-query+mystashedfiles-param-limit": "Скільки файлів виводити.",
+ "apihelp-query+mystashedfiles-example-simple": "Отримати ключі файлів (filekey), розміри файлів та піксельні виміри файлів у сховку завантажень поточного користувача.",
+ "apihelp-query+alltransclusions-summary": "Список усіх включень (сторінки, вставлені з використанням &#123;&#123;x&#125;&#125;), включно з неіснуючими.",
+ "apihelp-query+alltransclusions-param-from": "Назва включення, з якої почати перераховувати.",
+ "apihelp-query+alltransclusions-param-to": "Назва включення, якою закінчити перераховувати.",
+ "apihelp-query+alltransclusions-param-prefix": "Шукати усі включені назви, які починаються з цього значення.",
+ "apihelp-query+alltransclusions-param-unique": "Показувати лише окремі включені назви. Не може використовуватися разом з $1prop=ids.\nКоли використовується як генератор, видає цільові сторінки замість вихідних сторінок.",
+ "apihelp-query+alltransclusions-param-prop": "Які відомості включати:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "Додає ідентифікатор сторінки включення (не можна використати разом з $1unique).",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "Додає назву включення.",
+ "apihelp-query+alltransclusions-param-namespace": "Простір назв для переліку.",
+ "apihelp-query+alltransclusions-param-limit": "Скільки всього елементів виводити.",
+ "apihelp-query+alltransclusions-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+alltransclusions-example-B": "Перерахувати включені назви, включно з відсутніми, з ідентифікаторами сторінок, на яких вони використані, починаючи з <kbd>B</kbd>.",
+ "apihelp-query+alltransclusions-example-unique": "Перерахувати унікальні включені назв.",
+ "apihelp-query+alltransclusions-example-unique-generator": "Отримує всі включені назви, позначаючи відсутні.",
+ "apihelp-query+alltransclusions-example-generator": "Отримує сторінки, на яких є включення.",
+ "apihelp-query+allusers-summary": "Перерахувати усіх зареєстрованих користувачів.",
+ "apihelp-query+allusers-param-from": "Ім'я користувача, з якого почати перелічувати.",
+ "apihelp-query+allusers-param-to": "Ім'я користувача, на якому закінчити перелічувати.",
+ "apihelp-query+allusers-param-prefix": "Шукати усіх користувачів, які починаються з цього значення.",
+ "apihelp-query+allusers-param-dir": "Напрямок сортування.",
+ "apihelp-query+allusers-param-group": "Включати лише користувачів з даних груп.",
+ "apihelp-query+allusers-param-excludegroup": "Виключити користувачів у даних групах.",
+ "apihelp-query+allusers-param-rights": "Включати лише користувачів з даними правами. Не включає права, надані безумовними або автоматичними групами на зразок *, користувач або автопідтверджені.",
+ "apihelp-query+allusers-param-prop": "Які саме відомості включати:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Додає інформацію про поточне блокування користувача.",
+ "apihelp-query+allusers-paramvalue-prop-groups": "Перераховує групи, до яких користувач належить. Це використовує більше ресурсів сервера і може видати менше результатів, ніж ліміт.",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "Перераховує усіх групи, до яких користувач належить автоматично.",
+ "apihelp-query+allusers-paramvalue-prop-rights": "Перераховує права, які користувач має.",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "Додає кількість редагувань користувача.",
+ "apihelp-query+allusers-paramvalue-prop-registration": "Додає часову мітку, коли користувач зареєструвався, якщо доступно (може бути пустою).",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "Додає центральні ідентифікатори і стан приєднання для користувача.",
+ "apihelp-query+allusers-param-limit": "Скільки всього виводити імен користувачів.",
+ "apihelp-query+allusers-param-witheditsonly": "Перерахувати лише користувачів, що зробили редагування.",
+ "apihelp-query+allusers-param-activeusers": "Перерахувати лише користувачів, що були активні $1 {{PLURAL:$1|останній день|останні дні|останніх днів}}.",
+ "apihelp-query+allusers-param-attachedwiki": "Із <kbd>$1prop=centralids</kbd>, також вказати, чи користувач має приєднану вікі, визначену цим ідентифікатором.",
+ "apihelp-query+allusers-example-Y": "Перерахувати користувачів, починаючи з <kbd>Y</kbd>.",
+ "apihelp-query+authmanagerinfo-summary": "Отримати інформацію про поточний стан автентифікації.",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "Перевірити, чи поточний стан автентифікації користувача є достатнім для даної конфіденційної операції.",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "Отримати інформацію про запити автентифікації, потрібні для даної дії автентифікації.",
+ "apihelp-query+authmanagerinfo-example-login": "Вибірка запитів, що можуть бути використані при початку входу.",
+ "apihelp-query+authmanagerinfo-example-login-merged": "Отримати запити, які можуть бути використані при початку входу, з об'єднаними полями форми.",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "Перевірити чи автентифікація є достатньою для дії <kbd>foo</kbd>.",
+ "apihelp-query+backlinks-summary": "Знайти усі сторінки, що посилаються на подану сторінку.",
+ "apihelp-query+backlinks-param-title": "Назва для пошуку. Не можна використати разом з <var>$1pageid</var>.",
+ "apihelp-query+backlinks-param-pageid": "ID сторінки для пошуку. Не можна використати разом з <var>$1title</var>.",
+ "apihelp-query+backlinks-param-namespace": "Простір назв для переліку.",
+ "apihelp-query+backlinks-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+backlinks-param-filterredir": "Як відфільтрувати перенаправлення. Якщо встановлено <kbd>nonredirects</kbd> при увімкненому <var>$1redirect</var>, це застосовується лише до другого рівня.",
+ "apihelp-query+backlinks-param-limit": "Скільки всього виводити сторінок. Якщо увімкнено <var>$1redirect</var>, ліміт застосовується до кожного рівня окремо (це означає, що може бути видано до 2 * <var>$1limit</var> результатів).",
+ "apihelp-query+backlinks-param-redirect": "Якщо сторінка, яка посилається, є перенаправленням, знайти всі сторінки, які посилаються на це перенаправлення, теж. Максимальний ліміт зменшується наполовину.",
+ "apihelp-query+backlinks-example-simple": "Показати посилання на <kbd>Main page</kbd>.",
+ "apihelp-query+backlinks-example-generator": "Отримати інформацію про сторінки, що посилаються на <kbd>Main page</kbd>.",
+ "apihelp-query+blocks-summary": "Перерахувати усіх заблокованих користувачів і IP-адреси.",
+ "apihelp-query+blocks-param-start": "Часова мітка, з якої почати перелік.",
+ "apihelp-query+blocks-param-end": "Часова мітка закінчення переліку.",
+ "apihelp-query+blocks-param-ids": "Вивести список заблокованих ID (необов'язково).",
+ "apihelp-query+blocks-param-users": "Список користувачів для пошуку (необов'язково).",
+ "apihelp-query+blocks-param-ip": "Отримати всі блокування, що стосуються цієї IP-адреси або CIDR-діапазону, включно з блокуваннями діапазонів. Не може бути використано разом з <var>$3users</var>. CIDR-діапазони, ширші, ніж IPv4/$1 чи IPv6/$2, не приймаються.",
+ "apihelp-query+blocks-param-limit": "Максимальна кількість блокувань у списку.",
+ "apihelp-query+blocks-param-prop": "Які властивості отримати:",
+ "apihelp-query+blocks-paramvalue-prop-id": "Додає ID блокування.",
+ "apihelp-query+blocks-paramvalue-prop-user": "Додає ім'я заблокованого користувача.",
+ "apihelp-query+blocks-paramvalue-prop-userid": "Додає ID заблокованого користувача.",
+ "apihelp-query+blocks-paramvalue-prop-by": "Додає ім'я користувача, який заблокував.",
+ "apihelp-query+blocks-paramvalue-prop-byid": "Додає ID користувача, який заблокував.",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "Додає часову мітку здійснення блокування.",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "Додає часову мітку закінчення терміну блокування.",
+ "apihelp-query+blocks-paramvalue-prop-reason": "Додає причину, вказану при блокуванні.",
+ "apihelp-query+blocks-paramvalue-prop-range": "Додає діапазон IP-адрес, на які поширюється блокування.",
+ "apihelp-query+blocks-paramvalue-prop-flags": "Мітки бану (автоблокування, лише анонім тощо).",
+ "apihelp-query+blocks-param-show": "Показувати лише елементи, які відповідають цим критеріям.\nНаприклад, щоб побачити лише незалежні блокування IP-адрес, встановіть <kbd>$1show=ip|!temp</kbd>.",
+ "apihelp-query+blocks-example-simple": "Вивести список блокувань.",
+ "apihelp-query+blocks-example-users": "Вивести список блокувань користувачів <kbd>Alice</kbd> та <kbd>Bob</kbd>.",
+ "apihelp-query+categories-summary": "Перерахувати категорії, до яких сторінки належать.",
+ "apihelp-query+categories-param-prop": "Які додаткові властивості отримати для кожної категорії:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "Додає ключ сортування (шістнадцятковий рядок) і префікс ключа сортування (людиночитна частина) для категорії.",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "Додає мітку часу, коли категорію було додано.",
+ "apihelp-query+categories-paramvalue-prop-hidden": "Тегує приховані категорії з допомогою <code>_&#95;HIDDENCAT_&#95;</code>.",
+ "apihelp-query+categories-param-show": "Який тип категорій показувати.",
+ "apihelp-query+categories-param-limit": "Скільки категорій видати.",
+ "apihelp-query+categories-param-categories": "Перерахувати лише ці категорії. Корисно для перевірки, чи певна сторінка є в певній категорії.",
+ "apihelp-query+categories-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+categories-example-simple": "Отримати список категорій, до яких належить сторінка <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categories-example-generator": "Отримати інформацію про усі категорії, використані на сторінці <kbd>Albert Einstein</kbd>.",
+ "apihelp-query+categoryinfo-summary": "Видає інформацію про подані категорії.",
+ "apihelp-query+categoryinfo-example-simple": "Отримати інформацію про <kbd>Category:Foo</kbd> і <kbd>Category:Bar</kbd>.",
+ "apihelp-query+categorymembers-summary": "Перерахувати усі сторінки у поданій категорії.",
+ "apihelp-query+categorymembers-param-title": "Яку категорію вивести (обов'язково). Мусить включати префікс <kbd>{{ns:category}}:</kbd>. Не можна використати разом з <var>$1pageid</var>.",
+ "apihelp-query+categorymembers-param-pageid": "ID сторінки категорії для виведення. Не можна використати разом з <var>$1title</var>.",
+ "apihelp-query+categorymembers-param-prop": "Які відомості включати:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "Додає ID сторінки.",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "Додає назву й ID простору назв сторінки.",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "Додає ключ сортування, використаний для сортування у категорії (шістнадцятковий рядок).",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "Додає ключ сортування, використаний для сортування у категорії (людиночитна частина).",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "Додає тип, за яким категоризується сторінка (<samp>page</samp>, <samp>subcat</samp> або <samp>file</samp>).",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "Додає мітку часу, коли сторінка була включена.",
+ "apihelp-query+categorymembers-param-namespace": "Включати лише сторінки у цих просторах назв. Зверніть увагу, що <kbd>$1type=subcat</kbd> чи <kbd>$1type=file</kbd> можна використовувати замість <kbd>$1namespace=14</kbd> чи <kbd>6</kbd>.",
+ "apihelp-query+categorymembers-param-type": "Який тип елементів категорії включати. Ігнорується, коли вказано <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-limit": "Максимальна кількість сторінок для виведення.",
+ "apihelp-query+categorymembers-param-sort": "Властивість, за якою сортувати.",
+ "apihelp-query+categorymembers-param-dir": "У якому напрямку сортувати.",
+ "apihelp-query+categorymembers-param-start": "Часова мітка, з якої почати список. Можна використати лише разом з <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-end": "Часова мітка, якою закінчити список. Можна використати лише разом з <kbd>$1sort=timestamp</kbd>.",
+ "apihelp-query+categorymembers-param-starthexsortkey": "Ключ сортування, з якого почати список, як видає <kbd>$1prop=sortkey</kbd>. Можна використати лише разом з <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-endhexsortkey": "Ключ сортування, з якого почати список, як видає <kbd>$1prop=sortkey</kbd>. Можна використати лише разом з <kbd>$1sort=sortkey</kbd>.",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "Префікс ключа сортування, з якого почати список. Можна використати лише разом з <kbd>$1sort=sortkey</kbd>. Перевизначає <var>$1starthexsortkey</var>.",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "Префікс ключа сортування, <strong>перед</strong> яким закінчити список (не <strong>на</strong>; якщо це значення зустрінеться, його не буде включено!). Можна використати лише разом з $1sort=sortkey. Перевизначає $1endhexsortkey.",
+ "apihelp-query+categorymembers-param-startsortkey": "Використати натомість $1starthexsortkey.",
+ "apihelp-query+categorymembers-param-endsortkey": "Використати натомість $1endhexsortkey.",
+ "apihelp-query+categorymembers-example-simple": "Отримати перші 10 сторінок у <kbd>Category:Physics</kbd>.",
+ "apihelp-query+categorymembers-example-generator": "Отримати інформацію про перші 10 сторінок у <kbd>Category:Physics</kbd>.",
+ "apihelp-query+contributors-summary": "Отримати список залогінених дописувачів і кількість анонімних дописувачів до сторінки.",
+ "apihelp-query+contributors-param-group": "Включати лише користувачів з даних груп. Не включає безумовні або автоматичні групи на зразок *, користувач або автопідтверджені.",
+ "apihelp-query+contributors-param-excludegroup": "Виключати користувачів з даних груп. Не включає безумовні або автоматичні групи на зразок *, користувач або автопідтверджені.",
+ "apihelp-query+contributors-param-rights": "Включати лише користувачів з даними правами. Не включає права, надані безумовними або автоматичними групами на зразок *, користувач або автопідтверджені.",
+ "apihelp-query+contributors-param-excluderights": "Виключати користувачів з даними правами. Не включає права, надані безумовними або автоматичними групами на зразок *, користувач або автопідтверджені.",
+ "apihelp-query+contributors-param-limit": "Скільки дописувачів виводити.",
+ "apihelp-query+contributors-example-simple": "Показати дописувачів до сторінки <kbd>Main Page</kbd>.",
+ "apihelp-query+deletedrevisions-summary": "Отримати інформацію про вилучену версію.",
+ "apihelp-query+deletedrevisions-extended-description": "Можна використати кількома способами:\n# Отримати вилучені версії набору сторінок, вказавши заголовки або ідентифікатори сторінок. Сортується за назвою і часовою міткою.\n# Отримати дані про набір вилучених версій, вказавши їх ID з ідентифікаторами версій. Сортується за ID версії.",
+ "apihelp-query+deletedrevisions-param-start": "Мітка часу, з якої почати перелік. Ігнорується, якщо обробляється список ідентифікаторів версій.",
+ "apihelp-query+deletedrevisions-param-end": "Мітка часу, якою закінчити перелік. Ігнорується, якщо обробляється список ідентифікаторів версій.",
+ "apihelp-query+deletedrevisions-param-tag": "Перерахувати лише версії, помічені цим теґом.",
+ "apihelp-query+deletedrevisions-param-user": "Перерахувати лише версії цього користувача.",
+ "apihelp-query+deletedrevisions-param-excludeuser": "Не перераховувати версії цього користувача.",
+ "apihelp-query+deletedrevisions-example-titles": "Перерахувати вилучені версії сторінок <kbd>Main Page</kbd> і <kbd>Talk:Main Page</kbd>, з вмістом.",
+ "apihelp-query+deletedrevisions-example-revids": "Вивести інформацію вилученої версії <kbd>123456</kbd>.",
+ "apihelp-query+deletedrevs-summary": "Перелічити вилучені версії.",
+ "apihelp-query+deletedrevs-extended-description": "Працює у трьох режимах:\n# Перелічити вилучені версії поданих назв, відсортованих за часовою міткою.\n# Перелічити вилучений внесок поданого користувача, відсортований за часовою міткою (без вказання заголовків).\n# Перелічити усі вилучені версії у поданому просторі назв, відсортовані за назвою та часовою міткою (без вказання заголовків, $1user не вказаний).\n\nОкремі параметри можуть застосовуватися в одному режимі й ігноруватися в іншому.",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Режим|Режими}}: $2",
+ "apihelp-query+deletedrevs-param-start": "Часова мітка початку переліку.",
+ "apihelp-query+deletedrevs-param-end": "Часова мітка закінчення переліку.",
+ "apihelp-query+deletedrevs-param-from": "Почати перелік з цієї назви.",
+ "apihelp-query+deletedrevs-param-to": "Закінчити перелік цією назвою.",
+ "apihelp-query+deletedrevs-param-prefix": "Шукати усі назви сторінок, які починаються з цього значення.",
+ "apihelp-query+deletedrevs-param-unique": "Вивести лише одну версію кожної сторінки.",
+ "apihelp-query+deletedrevs-param-tag": "Перерахувати лише версії, помічені цим теґом.",
+ "apihelp-query+deletedrevs-param-user": "Перерахувати лише версії цього користувача.",
+ "apihelp-query+deletedrevs-param-excludeuser": "Не перераховувати версії цього користувача.",
+ "apihelp-query+deletedrevs-param-namespace": "Перерахувати сторінки лише в цьому просторі назв.",
+ "apihelp-query+deletedrevs-param-limit": "Максимальна кількість версій для переліку.",
+ "apihelp-query+deletedrevs-param-prop": "Які властивості отримати:\n;revid:Додає ID вилученої версії.\n;parentid:Додає ID попередньої версії сторінки.\n;user:Додає користувача, який створив версію.\n;userid:Додає ID користувача, який створив версію.\n;comment:Додає коментар до версії.\n;parsedcomment:Додає проаналізований коментар до версії.\n;minor:Позначає, якщо версія створена незначним редагуванням.\n;len:Додає довжину (байти) версії.\n;sha1:Додає SHA-1 (base 16) версії.\n;content:Додає вміст версії.\n;token:<span class=\"apihelp-deprecated\">Застаріло.</span> Дає токен редагування.\n;tags:Теґи версії.",
+ "apihelp-query+deletedrevs-example-mode1": "Перерахувати останні вилучені версії сторінок <kbd>Main Page</kbd> і <kbd>Talk:Main Page</kbd>, з вмістом (режим 1).",
+ "apihelp-query+deletedrevs-example-mode2": "Перерахувати останні 50 вилучених редагувань <kbd>Bob</kbd> (режим 2).",
+ "apihelp-query+deletedrevs-example-mode3-main": "Перерахувати перші 50 вилучених версій у головному просторі назв (режим 3).",
+ "apihelp-query+deletedrevs-example-mode3-talk": "Перерахувати перші 50 вилучених сторінок у просторі назв {{ns:talk}} (режим 3).",
+ "apihelp-query+disabled-summary": "Цей модуль запитів було вимкнено.",
+ "apihelp-query+duplicatefiles-summary": "Перерахувати усі файли, які є дублікатами поданих файлів з огляду на значення хешу.",
+ "apihelp-query+duplicatefiles-param-limit": "Скільки файлів-дублікатів виводити.",
+ "apihelp-query+duplicatefiles-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+duplicatefiles-param-localonly": "Шукати лише файли у локальному репозиторії.",
+ "apihelp-query+duplicatefiles-example-simple": "Шукати дублікати [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+duplicatefiles-example-generated": "Шукати дублікати усіх файлів.",
+ "apihelp-query+embeddedin-summary": "Знайти всі сторінки, які вбудовують (включають) подану назву.",
+ "apihelp-query+embeddedin-param-title": "Назва для пошуку. Не можна використати разом з $1pageid.",
+ "apihelp-query+embeddedin-param-pageid": "ID сторінки для пошуку. Не можна використати разом з $1title.",
+ "apihelp-query+embeddedin-param-namespace": "Простір назв для переліку.",
+ "apihelp-query+embeddedin-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+embeddedin-param-filterredir": "Як фільтрувати перенаправлення.",
+ "apihelp-query+embeddedin-param-limit": "Скільки всього сторінок виводити.",
+ "apihelp-query+embeddedin-example-simple": "Показати сторінки, які включають <kbd>Template:Stub</kbd>.",
+ "apihelp-query+embeddedin-example-generator": "Отримати інформацію про сторінки, які включають <kbd>Template:Stub</kbd>.",
+ "apihelp-query+extlinks-summary": "Видати усі зовнішні URL (не інтервікі) з поданих сторінок.",
+ "apihelp-query+extlinks-param-limit": "Скільки посилань виводити.",
+ "apihelp-query+extlinks-param-protocol": "Протокол URL. Якщо пусто і вказано <var>$1query</var>, протокол <kbd>http</kbd>. Залиште пустими і це, і <var>$1query</var>, щоб перелічити усі зовнішні посилання.",
+ "apihelp-query+extlinks-param-query": "Шукати рядок без протоколу. Корисно для перевірки, чи містить певна сторінка певне зовнішнє посилання.",
+ "apihelp-query+extlinks-param-expandurl": "Розгорнути протокол-залежні URL за канонічним протоколом.",
+ "apihelp-query+extlinks-example-simple": "Отримати список зовнішніх посилань на <kbd>Main Page</kbd>.",
+ "apihelp-query+exturlusage-summary": "Перерахувати сторінки, які містять поданий URL.",
+ "apihelp-query+exturlusage-param-prop": "Які відомості включати:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "Додає ID сторінки.",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "Додає заголовок і ID простору назв сторінки.",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "Додає URL, використаний на сторінці.",
+ "apihelp-query+exturlusage-param-protocol": "Протокол URL. Якщо пусто і вказано <var>$1query</var>, протокол <kbd>http</kbd>. Залиште пустими і це, і <var>$1query</var>, щоб перелічити усі зовнішні посилання.",
+ "apihelp-query+exturlusage-param-query": "Шукати рядок без протоколу. Див. [[Special:LinkSearch]]. Залиште пустим, щоб вивести усі зовнішні посилання.",
+ "apihelp-query+exturlusage-param-namespace": "Простори назв для переліку.",
+ "apihelp-query+exturlusage-param-limit": "Скільки сторінок виводити.",
+ "apihelp-query+exturlusage-param-expandurl": "Розгорнути протокол-залежні URL за канонічним протоколом.",
+ "apihelp-query+exturlusage-example-simple": "Показати сторінки, які посилаються на <kbd>http://www.mediawiki.org</kbd>.",
+ "apihelp-query+filearchive-summary": "Перерахувати всі вилучені файли послідовно.",
+ "apihelp-query+filearchive-param-from": "Назва зображення, з якої почати перелічувати.",
+ "apihelp-query+filearchive-param-to": "Назва зображення, якою закінчити перелічувати.",
+ "apihelp-query+filearchive-param-prefix": "Шукати усі назви зображень, які починаються з цього значення.",
+ "apihelp-query+filearchive-param-limit": "Скільки всього зображень виводити.",
+ "apihelp-query+filearchive-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+filearchive-param-sha1": "SHA1-хеш зображення. Перевизначає $1sha1base36.",
+ "apihelp-query+filearchive-param-sha1base36": "SHA1-хеш зображення у base 36 (використано в MediaWiki).",
+ "apihelp-query+filearchive-param-prop": "Which image information to get:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "Додає хеш SHA-1 до зображення.",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "Додає часову мітку завантаженої версії.",
+ "apihelp-query+filearchive-paramvalue-prop-user": "Додає користувача, який завантажив версію зображення.",
+ "apihelp-query+filearchive-paramvalue-prop-size": "Додає розмір зображення у байтах, а також висоту, ширину і кількість сторінок (якщо є).",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "Аліас розміру.",
+ "apihelp-query+filearchive-paramvalue-prop-description": "Додає опис версії зображення.",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "Аналіз опису зображення.",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "Додає MIME-тип зображення.",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "Додає медіатип зображення.",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "Вилає Exif-метадані версії зображення.",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "Додає бітну глибину версії.",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "Додає до імені версію архіву для неостаточного варіанту файлу.",
+ "apihelp-query+filearchive-example-simple": "Показати список усіх вилучених файлів.",
+ "apihelp-query+filerepoinfo-summary": "Видати мета-інформацію про репозиторії зображень, налаштовані на вікі.",
+ "apihelp-query+filerepoinfo-param-prop": "Які властивості репозиторію отримати (на деяких вікі може бути більше):\n;apiurl:URL до репозиторію API — корисне для отримання інформації про зображення з хосту.\n;name:Ключ репозиторію — використано в e.g. <var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var> і значення [[Special:ApiHelp/query+imageinfo|imageinfo]].\n;displayname:Людиночита назва репозиторію вікі.\n;rooturl:Корінний URL для шляху зображення.\n;local:Чи репозиторій локальний, чи ні.",
+ "apihelp-query+filerepoinfo-example-simple": "Отримати інформацію про репозиторії файлів.",
+ "apihelp-query+fileusage-summary": "Знайти всі сторінки, що використовують дані файли.",
+ "apihelp-query+fileusage-param-prop": "Які властивості отримати:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "ID кожної сторінки.",
+ "apihelp-query+fileusage-paramvalue-prop-title": "Назва кожної сторінки.",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "Помітка, якщо сторінка є перенаправленням.",
+ "apihelp-query+fileusage-param-namespace": "Включати сторінки лише в цих просторах назв.",
+ "apihelp-query+fileusage-param-limit": "Скільки результатів виводити.",
+ "apihelp-query+fileusage-param-show": "Показати лише елементи, що відповідають цим критеріям:\n;redirect:Показати лише перенаправлення.\n;!redirect:Показати лише не перенаправлення.",
+ "apihelp-query+fileusage-example-simple": "Отримати список сторінок, які використовують [[:File:Example.jpg]].",
+ "apihelp-query+fileusage-example-generator": "Отримати інформацію про сторінки, які використовують [[:File:Example.jpg]].",
+ "apihelp-query+imageinfo-summary": "Видає інформацію про файл й історію завантаження.",
+ "apihelp-query+imageinfo-param-prop": "Яку інформацію отримати:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "Додає мітку часу для завантаженої версії.",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "Додає користувача, який завантажив кожну версію файлу.",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "Додати ідентифікатор користувача, який завантажив кожну версію файлу.",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "Коментар до версії.",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "Аналізований коментар версії.",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "Додає канонічну назву файлу.",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "Дає посилання на файл і сторінку опису.",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "Додає розмір файлу в байтах, а також висоту, ширину і кількість сторінок (якщо це можливо).",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "Псевдонім для розміру.",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "Додає SHA-1 хеш файлу.",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "Додає MIME-тип файлу.",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "Додає MIME-мініатюри зображення (передбачає url і параметр $1urlwidth).",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "Додає медіатип файлу.",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "Перелічує Exif-метадані версії файлу.",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "Перелічує метадані формату версії файлу.",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "Перелічує форматовані метадані, поєднані з кількох джерел. Результати у форматі HTML.",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "Додає назву файлу архівної версії для неостанніх версій.",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "Додає бітну глибину версії.",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "Використовується на Special:Upload page для отримання інформації про наявний файл. Не призначено для використання поза ядром MediaWiki.",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "Додає інформацію про те, чи перебуває файл у \n[[MediaWiki:Bad image list|списку недозволених файлів]]",
+ "apihelp-query+imageinfo-param-limit": "Скільки виводити версій кожного файлу.",
+ "apihelp-query+imageinfo-param-start": "Часова мітка, з якої почати список.",
+ "apihelp-query+imageinfo-param-end": "Часова мітка, на якій закінчити список.",
+ "apihelp-query+imageinfo-param-urlwidth": "Якщо вказано $2prop=url, буде видано URL на масштабоване до цього розміру зображення.\nДля підвищення продуктивності, якщо використовується ця опція, не буде видано більше, ніж $1 {{PLURAL:$1|масштабоване зображення|масштабовані зображення|масштабованих зображень}}.",
+ "apihelp-query+imageinfo-param-urlheight": "Аналогічно до $1urlwidth.",
+ "apihelp-query+imageinfo-param-metadataversion": "Версія метаданих, яку використати. Якщо вказано <kbd>latest</kbd>, використати останню версію. За замовчуванням — <kbd>1</kbd> для зворотної сумісності.",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "Якою мовою вибирати метадані. Це стосується і того, який переклад вибирати, якщо є різні, і як форматувати різні числа та значення.",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "Якщо переклади властивості extmetadata доступні, вибрати їх усі.",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "Якщо вказано і не порожньо, буде видано лише ці ключі для $1prop=extmetadata.",
+ "apihelp-query+imageinfo-param-urlparam": "Рядок окремого параметра. Наприклад, PDF-ки можуть використовувати <kbd>page15-100px</kbd>. <var>$1urlwidth</var> повинно використовуватись і бути сумісним з <var>$1urlparam</var>.",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "Якщо встановлено <kbd>$2prop=badfile</kbd>, це — назва сторінки, що буде використана при аналізі [[MediaWiki:Bad image list]]",
+ "apihelp-query+imageinfo-param-localonly": "Шукати лише файли у локальному репозиторії.",
+ "apihelp-query+imageinfo-example-simple": "Вибрати інформацію про поточну версію [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageinfo-example-dated": "Вибрати інформацію про версії [[:File:Test.jpg]] від 2008 і раніше.",
+ "apihelp-query+images-summary": "Видає усі файли, які містяться на вказаних сторінках.",
+ "apihelp-query+images-param-limit": "Скільки файлів виводити.",
+ "apihelp-query+images-param-images": "Перерахувати лише ці файли. Корисно для перевірки, чи певна сторінка має певний файл.",
+ "apihelp-query+images-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+images-example-simple": "Отримати список файлів, використаних на [[Main Page]].",
+ "apihelp-query+images-example-generator": "Отримати інформацію про всі файли, використані на [[Main Page]].",
+ "apihelp-query+imageusage-summary": "Знайти всі сторінки, що використовують дану назву зображення.",
+ "apihelp-query+imageusage-param-title": "Назва для пошуку. Не можна використати разом з $1pageid.",
+ "apihelp-query+imageusage-param-pageid": "ID сторінки для пошуку. Не можна використати разом з $1title.",
+ "apihelp-query+imageusage-param-namespace": "Простір назв для переліку.",
+ "apihelp-query+imageusage-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+imageusage-param-filterredir": "Як відфільтрувати перенаправлення. Якщо встановлено для неперенаправлень при увімкненому $1redirect, це застосовується лише до другого рівня.",
+ "apihelp-query+imageusage-param-limit": "Скільки всього виводити сторінок. Якщо увімкнено <var>$1redirect</var>, ліміт застосовується до кожного рівня окремо (це означає, що може бути видано до 2 * <var>$1limit</var> результатів).",
+ "apihelp-query+imageusage-param-redirect": "Якщо сторінка, яка посилається, є перенаправленням, знайти всі сторінки, які посилаються на це перенаправлення, теж. Максимальний ліміт зменшується наполовину.",
+ "apihelp-query+imageusage-example-simple": "Показати сторінки, які використовують [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+imageusage-example-generator": "Отримати інформацію про сторінки, які використовують [[:File:Albert Einstein Head.jpg]].",
+ "apihelp-query+info-summary": "Отримати основні відомості про сторінку.",
+ "apihelp-query+info-param-prop": "Які додаткові властивості отримати:",
+ "apihelp-query+info-paramvalue-prop-protection": "Вивести рівень захисту кожної сторінки.",
+ "apihelp-query+info-paramvalue-prop-talkid": "Ідентифікатор сторінки обговорення для кожної сторінки, що не є обговоренням.",
+ "apihelp-query+info-paramvalue-prop-watched": "Вивести статус спостереженості кожної сторінки.",
+ "apihelp-query+info-paramvalue-prop-watchers": "Кількість спостерігачів, якщо це дозволено.",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "Кількість спостерігачів для кожної сторінки, які відвідували останні редагування таких сторінок, якщо це дозволено.",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "Часова мітка сповіщення списку спостереження кожної сторінки.",
+ "apihelp-query+info-paramvalue-prop-subjectid": "Ідентифікатор батьківської сторінки для кожної сторінки обговорення.",
+ "apihelp-query+info-paramvalue-prop-url": "Дає повний URL, URL редагування та канонічний URL для кожної сторінки.",
+ "apihelp-query+info-paramvalue-prop-readable": "Чи користувач може редагувати цю сторінку.",
+ "apihelp-query+info-paramvalue-prop-preload": "Дає текст, виданий EditFormPreloadText.",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "Дає спосіб, у який відображається назва сторінки.",
+ "apihelp-query+info-param-testactions": "Перевірити, чи поточний користувач може виконувати певні дії на сторінці.",
+ "apihelp-query+info-param-token": "Використати натомість [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-query+info-example-simple": "Отримати інформацію про сторінку <kbd>Main Page</kbd>.",
+ "apihelp-query+info-example-protection": "Отримати загальну інформацію і дані про захист сторінки <kbd>Main Page</kbd>.",
+ "apihelp-query+iwbacklinks-summary": "Знайти всі сторінки, які посилаються на дане інтервікі-посилання.",
+ "apihelp-query+iwbacklinks-extended-description": "Може використовуватися, щоб знайти всі посилання з префіксом або всі посилання на назву (з даним префіксом). Без використання жодного параметра це, по суті, «всі інтервікі-посилання».",
+ "apihelp-query+iwbacklinks-param-prefix": "Префікс для інтервікі.",
+ "apihelp-query+iwbacklinks-param-title": "Інтервікі-посилання для пошуку. Повинно використовуватися з <var>$1blprefix</var>.",
+ "apihelp-query+iwbacklinks-param-limit": "Скільки всього сторінок виводити.",
+ "apihelp-query+iwbacklinks-param-prop": "Які властивості отримати:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "Додає префікс інтервікі.",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "Додає назву інтервікі.",
+ "apihelp-query+iwbacklinks-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+iwbacklinks-example-simple": "Отримати сторінки, що посилаються на [[wikibooks:Test]].",
+ "apihelp-query+iwbacklinks-example-generator": "Отримати інформацію про сторінки, що посилаються на [[wikibooks:Test]].",
+ "apihelp-query+iwlinks-summary": "Видає усі інтервікі-посилання із вказаних сторінок.",
+ "apihelp-query+iwlinks-param-url": "Чи отримувати повну URL-адресу (не може використовуватися з $1prop).",
+ "apihelp-query+iwlinks-param-prop": "Які додаткові властивості отримати для кожного міжмовного посилання:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "Додає повну URL-адресу.",
+ "apihelp-query+iwlinks-param-limit": "Скільки інтервікі-посилання виводити.",
+ "apihelp-query+iwlinks-param-prefix": "Видавати інтервікі-посилання лише з цим префіксом.",
+ "apihelp-query+iwlinks-param-title": "Інтервікі-посилання для пошуку. Повинно використовуватися з <var>$1prefix</var>.",
+ "apihelp-query+iwlinks-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+iwlinks-example-simple": "Отримати інтервікі-посилання зі сторінки <kbd>Main Page</kbd>.",
+ "apihelp-query+langbacklinks-summary": "Знайти всі сторінки, які посилаються на дане мовне посилання.",
+ "apihelp-query+langbacklinks-extended-description": "Може бути використано для пошуку всіх посилань з кодом мови або всіх посилань на назву (з урахуванням мови). \nБез жодного параметра це «усі мовні посилання».\n\nЗверніть увагу, що це може не розглядати мовні посилання, додані розширеннями.",
+ "apihelp-query+langbacklinks-param-lang": "Мова мовного посилання.",
+ "apihelp-query+langbacklinks-param-title": "Мовне посилання для пошуку. Мусить бути використане з $1lang.",
+ "apihelp-query+langbacklinks-param-limit": "Скільки всього сторінок виводити.",
+ "apihelp-query+langbacklinks-param-prop": "Які властивості для отримання:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "Додає код мови мовного посилання.",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Додає назву мовного посилання.",
+ "apihelp-query+langbacklinks-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+langbacklinks-example-simple": "Отримати сторінки, що посилаються на [[:fr:Test]].",
+ "apihelp-query+langbacklinks-example-generator": "Отримати інформацію про сторінки, що посилаються на [[:fr:Test]].",
+ "apihelp-query+langlinks-summary": "Видає усі міжмовні посилання із вказаних сторінок.",
+ "apihelp-query+langlinks-param-limit": "Скільки мовних посилань виводити.",
+ "apihelp-query+langlinks-param-url": "Чи отримувати повну URL-адресу (не може використовуватися з <var>$1prop</var>).",
+ "apihelp-query+langlinks-param-prop": "Які додаткові властивості для отримання кожного із міжмовного посилання:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "Додає повну URL-адресу.",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "Додає локалізовану назву мови (найкращий варіант). Використайте <var>$1inlanguagecode</var> для контролю мови.",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "Додає самоназву мови.",
+ "apihelp-query+langlinks-param-lang": "Видавати лише мовні посилання з кодом мови.",
+ "apihelp-query+langlinks-param-title": "Посилання для пошуку. Повинно використовуватися з <var>$1lang</var>.",
+ "apihelp-query+langlinks-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+langlinks-param-inlanguagecode": "Код мови для локалізованих назв мов.",
+ "apihelp-query+langlinks-example-simple": "Отримати міжмовні посилання зі сторінки <kbd>Main Page</kbd>.",
+ "apihelp-query+links-summary": "Видає усі посилання із вказаних сторінок.",
+ "apihelp-query+links-param-namespace": "Показати посилання лише у цих просторах назв.",
+ "apihelp-query+links-param-limit": "Скільки посилань виводити.",
+ "apihelp-query+links-param-titles": "Перерахувати лише посилання на ці назви. Корисно для перевірки, чи певна сторінка посилається на певну назву.",
+ "apihelp-query+links-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+links-example-simple": "Отримати посилання зі сторінки <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-generator": "Отримати інформацію про сторінки посилань на сторінці <kbd>Main Page</kbd>.",
+ "apihelp-query+links-example-namespaces": "Отримати посилання зі сторінки <kbd>Main Page</kbd> у просторах назв {{ns:user}} і {{ns:template}}.",
+ "apihelp-query+linkshere-summary": "Знайти усі сторінки, що посилаються на подані сторінки.",
+ "apihelp-query+linkshere-param-prop": "Які властивості отримати:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "ID кожної сторінки.",
+ "apihelp-query+linkshere-paramvalue-prop-title": "Назва кожної сторінки.",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "Відзначити, якщо сторінка є перенаправленням.",
+ "apihelp-query+linkshere-param-namespace": "Включати сторінки лише в цих просторах назв.",
+ "apihelp-query+linkshere-param-limit": "Скільки результатів виводити.",
+ "apihelp-query+linkshere-param-show": "Показати лише елементи, що відповідають цим критеріям:\n;redirect:Показати лише перенаправлення.\n;!redirect:Показати лише не перенаправлення.",
+ "apihelp-query+linkshere-example-simple": "Отримати список сторінок, що посилаються на [[Main Page]].",
+ "apihelp-query+linkshere-example-generator": "Отримати інформацію про сторінки, що посилаються на [[Main Page]].",
+ "apihelp-query+logevents-summary": "Отримати події з журналів.",
+ "apihelp-query+logevents-param-prop": "Які властивості отримати:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "Додає ID події в журналі.",
+ "apihelp-query+logevents-paramvalue-prop-title": "Додає назву сторінки події в журналі.",
+ "apihelp-query+logevents-paramvalue-prop-type": "Додає тип події в журналі.",
+ "apihelp-query+logevents-paramvalue-prop-user": "Додає користувача, відповідального за подію в журналі.",
+ "apihelp-query+logevents-paramvalue-prop-userid": "Додає ID користувача, відповідального за подію в журналі.",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "Додає часову мітку події.",
+ "apihelp-query+logevents-paramvalue-prop-comment": "Додає коментар події.",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "Додає проаналізований коментар події.",
+ "apihelp-query+logevents-paramvalue-prop-details": "Виводить додаткові деталі щодо події.",
+ "apihelp-query+logevents-paramvalue-prop-tags": "Виводить мітки події.",
+ "apihelp-query+logevents-param-type": "Відфільтрувати записи журналу лише цього типу.",
+ "apihelp-query+logevents-param-action": "Відфільтрувати дії журналу до лише цієї дії. Перезаписує <var>$1type</var>. У списку можливих значень, значення з джокери з астеріском на зразок <kbd>action/*</kbd> можуть мати різне після косої риски (/).",
+ "apihelp-query+logevents-param-start": "Часова мітка початку переліку.",
+ "apihelp-query+logevents-param-end": "Часова мітка завершення переліку.",
+ "apihelp-query+logevents-param-user": "Відфільтрувати серед записів зроблені поданим користувачем.",
+ "apihelp-query+logevents-param-title": "Відфільтрувати серед записів пов'язані зі сторінкою.",
+ "apihelp-query+logevents-param-namespace": "Відфільтрувати до записів у поданому просторі назв.",
+ "apihelp-query+logevents-param-prefix": "Відфільтрувати до записів, що починаються з цього префікса.",
+ "apihelp-query+logevents-param-tag": "Перерахувати лише записи подій, помічені цим теґом.",
+ "apihelp-query+logevents-param-limit": "Скільки всього виводити записів подій.",
+ "apihelp-query+logevents-example-simple": "Перелічити останні подій в журналі.",
+ "apihelp-query+pagepropnames-summary": "Перелічити усі назви властивостей сторінки, що використовуються у вікі.",
+ "apihelp-query+pagepropnames-param-limit": "Максимальна кількість назв для виведення.",
+ "apihelp-query+pagepropnames-example-simple": "Отримати перші 10 назв властивостей.",
+ "apihelp-query+pageprops-summary": "Дає різні властивості сторінки, визначені у вмісті сторінки.",
+ "apihelp-query+pageprops-param-prop": "Перерахувати лише ці властивості сторінки. (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> видає назви властивостей сторінки, що використовуються). Корисно для перевірки, чи сторінка використовує певну властивість сторінки.",
+ "apihelp-query+pageprops-example-simple": "Отримати властивості для сторінок <kbd>Main Page</kbd> і <kbd>MediaWiki</kbd>.",
+ "apihelp-query+pageswithprop-summary": "Перелічити усі сторінки, що використовують подану властивість сторінки.",
+ "apihelp-query+pageswithprop-param-propname": "Властивість сторі́нки, для якої перелічити сторінки́ (<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd> видає назви властивостей сторінки, що використовуються).",
+ "apihelp-query+pageswithprop-param-prop": "Які відомості включати:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "Додає ID сторінки.",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "Додає заголовок і ID простору назв сторінки.",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "Додає значення властивості сторінки.",
+ "apihelp-query+pageswithprop-param-limit": "Максимальна кількість сторінок для виведення.",
+ "apihelp-query+pageswithprop-param-dir": "У якому напрямку сортувати.",
+ "apihelp-query+pageswithprop-example-simple": "Перелічити перші 10, що використовують <code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>.",
+ "apihelp-query+pageswithprop-example-generator": "Отримати додаткову інформацію про перші 10 сторінок, що використовують <code>_&#95;NOTOC_&#95;</code>.",
+ "apihelp-query+prefixsearch-summary": "Виконати пошук назв сторінок за префіксом.",
+ "apihelp-query+prefixsearch-extended-description": "Незважаючи на подібність назв, цей модуль не призначений для того, аби бути еквівалентом [[Special:PrefixIndex]]; щодо цього, перегляньте <kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd> із параметром <kbd>apprefix</kbd>. Мета цього модуля така ж, як і <kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>: взяти текст, введений користувачем, і вивести найбільш відповідні назви. Залежно від програмної підоснови пошукової системи, сюди можуть також входити виправлення орфографії, уникнення перенаправлень чи інша евристика.",
+ "apihelp-query+prefixsearch-param-search": "Рядок пошуку.",
+ "apihelp-query+prefixsearch-param-namespace": "Простори назв, у яких шукати.",
+ "apihelp-query+prefixsearch-param-limit": "Максимальна кількість результатів для виведення.",
+ "apihelp-query+prefixsearch-param-offset": "Кількість результатів, які пропустити.",
+ "apihelp-query+prefixsearch-example-simple": "Шукати назви сторінок, які починаються з <kbd>meaning</kbd>.",
+ "apihelp-query+prefixsearch-param-profile": "Профіль пошуку для використання.",
+ "apihelp-query+protectedtitles-summary": "Вивести список усіх назв, захищених від створення.",
+ "apihelp-query+protectedtitles-param-namespace": "Перерахувати назви лише в цих просторах назв.",
+ "apihelp-query+protectedtitles-param-level": "Перерахувати лише назви з цими рівням захисту.",
+ "apihelp-query+protectedtitles-param-limit": "Скільки всього сторінок виводити.",
+ "apihelp-query+protectedtitles-param-start": "Почати список з цієї часової мітки захисту.",
+ "apihelp-query+protectedtitles-param-end": "Закінчити список цією часовою міткою захисту.",
+ "apihelp-query+protectedtitles-param-prop": "Які властивості отримати:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "Додає часову мітку встановлення захисту.",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "Додає користувача, який встановив захист.",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "Додає ID користувача, який встановив захист.",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "Додає коментар захисту.",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "Додає проаналізований коментар захисту.",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "Додає часову мітку закінчення захисту.",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "Додає рівень захисту.",
+ "apihelp-query+protectedtitles-example-simple": "Вивести список захищених назв.",
+ "apihelp-query+protectedtitles-example-generator": "Знайти посилання на захищені назви в основному просторі назв.",
+ "apihelp-query+querypage-summary": "Отримати список, кий дає спеціальна сторінка на базі QueryPage.",
+ "apihelp-query+querypage-param-page": "Назва спеціальної сторінки. Зважте, що чутлива до регістру.",
+ "apihelp-query+querypage-param-limit": "Кількість результатів, які виводити.",
+ "apihelp-query+querypage-example-ancientpages": "Видати результати з [[Special:Ancientpages]].",
+ "apihelp-query+random-summary": "Отримати набір випадкових сторінок.",
+ "apihelp-query+random-extended-description": "Сторінки перелічені у певній послідовності, лише початкова точка рандомна. Це означає, що якщо, наприклад, <samp>Main Page</samp> є першою випадковою сторінкою у списку, <samp>List of fictional monkeys</samp> <em>завжди</em> буде другою, <samp>List of people on stamps of Vanuatu</samp> — третьою, і т. д.",
+ "apihelp-query+random-param-namespace": "Вивести сторінки лише у цих просторах назв.",
+ "apihelp-query+random-param-limit": "Обмежити кількість випадкових сторінок, які буде видано.",
+ "apihelp-query+random-param-redirect": "Використати натомість <kbd>$1filterredir=redirects</kbd>.",
+ "apihelp-query+random-param-filterredir": "Як фільтрувати перенаправлення.",
+ "apihelp-query+random-example-simple": "Отримати дві випадкові сторінки з основного простору назв.",
+ "apihelp-query+random-example-generator": "Видати інформацію про дві випадкові сторінки з основного простору назв.",
+ "apihelp-query+recentchanges-summary": "Перерахувати нещодавні зміни.",
+ "apihelp-query+recentchanges-param-start": "Часова мітка початку переліку.",
+ "apihelp-query+recentchanges-param-end": "Часова мітка завершення переліку.",
+ "apihelp-query+recentchanges-param-namespace": "Відфільтрувати до змін лише у цих просторах назв.",
+ "apihelp-query+recentchanges-param-user": "Перерахувати лише зміни, зроблені цим користувачем.",
+ "apihelp-query+recentchanges-param-excludeuser": "Не перераховувати зміни, зроблені цим користувачем.",
+ "apihelp-query+recentchanges-param-tag": "Перерахувати лише зміни, помічені цим теґом.",
+ "apihelp-query+recentchanges-param-prop": "Включити додаткові відомості:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "Додає користувача, відповідального за редагування і мітки, якщо він IP.",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "Додає ID користувача, відповідального за редагування.",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "Додає коментар редагування.",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "Додає проаналізований коментар редагування.",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "Додає прапорці редагування.",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Додає часову мітку редагування.",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "Додає назву сторінки, де було редагування.",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "Додає ID сторінки, ID нещодавніх змін, а також ID нової і старої версій.",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Додає нову і стару довжину сторінки в байтах.",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "Помічає редагування, якщо сторінка є перенаправленням.",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Помічає редагування як відпатрульвані чи невідпатрульовані.",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Додає інформацію журналу (ID журналу, тип журналу тощо) до записів журналу.",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "Виводить мітки запису.",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "Додає контрольну суму вмісту для записів, пов'язаних з версією.",
+ "apihelp-query+recentchanges-param-token": "Використати натомість <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-query+recentchanges-param-show": "Показати лише елементи, що задовільняють ці критерії. Наприклад, для перегляду лише незначних змін, здійснених користувачами, що увійшли до системи, вкажіть $1show=minor|!anon.",
+ "apihelp-query+recentchanges-param-limit": "Скільки всього змін виводити.",
+ "apihelp-query+recentchanges-param-type": "Які типи змін показувати.",
+ "apihelp-query+recentchanges-param-toponly": "Виводити лише зміни, які є останньою версією.",
+ "apihelp-query+recentchanges-param-generaterevisions": "Коли використовується як генератор, генерувати ідентифікатори версій замість заголовків. Записи нещодавніх редагувань без прив'язаних ID версій (наприклад, більшість записів журналів) не згенерують нічого.",
+ "apihelp-query+recentchanges-example-simple": "Вивести нещодавні зміни.",
+ "apihelp-query+recentchanges-example-generator": "Отримати інформацію про сторінки з недавніми невідпатрульованими змінами.",
+ "apihelp-query+redirects-summary": "Видає усі перенаправлення на дані сторінки.",
+ "apihelp-query+redirects-param-prop": "Які властивості отримати:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "Ідентифікатор сторінки кожного перенаправлення.",
+ "apihelp-query+redirects-paramvalue-prop-title": "Назва кожного перенаправлення.",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "Фрагмент кожного перенаправлення, якщо є.",
+ "apihelp-query+redirects-param-namespace": "Включати сторінки лише в цих просторах назв.",
+ "apihelp-query+redirects-param-limit": "Скільки перенаправлень виводити.",
+ "apihelp-query+redirects-param-show": "Показати лише елементи, які відповідають цим критеріям:\n;fragment:Показати лише перенаправлення з фрагментом.\n;!fragment:Показати лише перенаправлення без фрагмента.",
+ "apihelp-query+redirects-example-simple": "Отримати список перенаправлень на [[Main Page]].",
+ "apihelp-query+redirects-example-generator": "Отримати інформацію про всі перенаправлення на [[Main Page]].",
+ "apihelp-query+revisions-summary": "Отримати інформацію про версію.",
+ "apihelp-query+revisions-extended-description": "Може бути використано кількома способами:\n# Отримати дані про набір сторінок (останні версії), вказавши назви або ідентифікатори сторінок.\n# Отримати версії для однієї вказаної сторінки, використавши назви або ідентифікатори і початок, кінець чи ліміт.\n# Отримати дані про набір версій, встановивши їх ID й ідентифікатори версій.",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "Може використовуватися тільки з однією сторінкою (режим #2).",
+ "apihelp-query+revisions-param-startid": "Почати нумерацію з мітки часу цієї версії. Версія повинна існувати, але не обов'язково має належати до цієї сторінки.",
+ "apihelp-query+revisions-param-endid": "Зупинити нумерацію на мітці часу цієї версії. Ця версія повинна існувати, але не обов'язково мусить належати до цієї сторінки.",
+ "apihelp-query+revisions-param-start": "З якої часової мітки версії почати перелік.",
+ "apihelp-query+revisions-param-end": "Перелічувати до цієї часової мітки.",
+ "apihelp-query+revisions-param-user": "Включати лише версій, зроблені цим користувачем.",
+ "apihelp-query+revisions-param-excludeuser": "Виключити версії, зроблені цим користувачем.",
+ "apihelp-query+revisions-param-tag": "Перелічити лише версії, позначені цією міткою.",
+ "apihelp-query+revisions-param-token": "Які токени отримати для кожної версії.",
+ "apihelp-query+revisions-example-content": "Отримати дані з вмістом останньої версії для заголовків <kbd>API</kbd> та <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-last5": "Отримати 5 останніх версії <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5": "Отримати 5 перших версій <kbd>Main Page</kbd>.",
+ "apihelp-query+revisions-example-first5-after": "Отримати 5 перших версій <kbd>Main Page</kbd>, зроблених після 2006-05-01.",
+ "apihelp-query+revisions-example-first5-not-localhost": "Отримати 5 перших версій <kbd>Main Page</kbd>, що не були зроблені анонімним користувачем <kbd>127.0.0.1</kbd>.",
+ "apihelp-query+revisions-example-first5-user": "Отримати 5 перших версій <kbd>Main Page</kbd>, що були зроблені користувачем <kbd>MediaWiki default</kbd>.",
+ "apihelp-query+revisions+base-param-prop": "Які властивості отримати для кожної версії:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "ID версії.",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "Позначки версії (незначні).",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "Часова мітка версії.",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "Користувач, який створив версію.",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "ID користувача, який створив версію.",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "Довжина версії (в байтах).",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "SHA-1 (base 16) версії.",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "ID моделі вмісту версії.",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "Коментар користувача до версії.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "Проаналізований коментар користувача до версії.",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "Текст версії.",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "Мітки версії.",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">Deprecated.</span> Використовуйте натомість <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> або <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Синтаксичне дерево XML вмісту версії (передбачає модель вмісту <code>$1</code>).",
+ "apihelp-query+revisions+base-param-limit": "Обмежити кількість версій, які буде видано.",
+ "apihelp-query+revisions+base-param-expandtemplates": "Використовуйте натомість <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd>. Розгорнути шаблони у вмісті версії (передбачає $1prop=content).",
+ "apihelp-query+revisions+base-param-generatexml": "Використовуйте натомість <kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd> або <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Генерувати синтаксичне дерево XML для вмісту версії (передбачає $1prop=content; замінено на <kbd>$1prop=parsetree</kbd>).",
+ "apihelp-query+revisions+base-param-parse": "Використовуйте натомість <kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>. Аналізувати вміст версії (передбачає $1prop=content). З причин продуктивності, якщо використовується ця опція, $1limit встановлюється як 1.",
+ "apihelp-query+revisions+base-param-section": "Витягнути вміст лише розділу з цим номером.",
+ "apihelp-query+revisions+base-param-diffto": "Використовуйте натомість <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. ID версії, з якою порівняти кожну версію. Використайте <kbd>prev</kbd>, <kbd>next</kbd> і <kbd>cur</kbd> для попередньої, наступної та поточної версій відповідно.",
+ "apihelp-query+revisions+base-param-difftotext": "Використовуйте натомість <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. Текст, з яким порівняти кожну версію. Порівнює лише обмежену кількість версій. Перевизначає <var>$1diffto</var>. Якщо вказано <var>$1section</var>, лише ця версія буде порівняна з цим текстом.",
+ "apihelp-query+revisions+base-param-difftotextpst": "Використовуйте натомість <kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>. Виконати попередню трансформацію тексту перед виведенням дифу. Дійсне лише з використанням <var>$1difftotext</var>.",
+ "apihelp-query+revisions+base-param-contentformat": "Формат серіалізації, використаний для <var>$1difftotext</var> й очікуваний для контенту-результату.",
+ "apihelp-query+search-summary": "Виконати повнотекстовий пошук.",
+ "apihelp-query+search-param-search": "Шукати назви сторінок або вміст, що співпадає з цим значенням. Ви можете використати рядок пошуку для виклику спеціальних функцій пошуку, залежно від внутрішніх установок пошуку у вікі.",
+ "apihelp-query+search-param-namespace": "Шукати лише в межах цих просторів назв.",
+ "apihelp-query+search-param-what": "Який тип пошуку виконати.",
+ "apihelp-query+search-param-info": "Які метадані отримати.",
+ "apihelp-query+search-param-prop": "Які властивості для виведення:",
+ "apihelp-query+search-param-qiprofile": "Незалежний профіль запиту для використання (впливає на алгоритм ранжування).",
+ "apihelp-query+search-paramvalue-prop-size": "Додає розмір сторінки в байтах.",
+ "apihelp-query+search-paramvalue-prop-wordcount": "Додає кількість слів на сторінці.",
+ "apihelp-query+search-paramvalue-prop-timestamp": "Додає часову мітку останнього редагування сторінки.",
+ "apihelp-query+search-paramvalue-prop-snippet": "Додає проаналізований уривок сторінки.",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "Додає проаналізований уривок заголовка сторінки.",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "Додає проаналізований уривок перенаправлення.",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "Додає заголовок відповідного перенаправлення.",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "Додає проаналізований уривок заголовка відповідного розділу.",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "Додає заголовок відповідного розділу.",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "Додає проаналізований уривок відповідної категорії.",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "Додає перемикач, який показує, є пошук знайшов вміст файлу.",
+ "apihelp-query+search-paramvalue-prop-score": "Інгорується.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Ігнорується.",
+ "apihelp-query+search-param-limit": "Скільки всього сторінок виводити.",
+ "apihelp-query+search-param-interwiki": "Включати інтервікі в результатах пошуку, якщо доступно.",
+ "apihelp-query+search-param-backend": "Який бекенд пошуку використовувати, якщо не за замовчуванням.",
+ "apihelp-query+search-param-enablerewrites": "Включити внутрішнє переписування запиту. Деякі пошукові бекенди можуть переписати запит так, щоб вони, теоретично, давали кращі результати, наприклад, виправивши орфографічні помилки.",
+ "apihelp-query+search-example-simple": "Шукати <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-text": "Шукати в текстах <kbd>meaning</kbd>.",
+ "apihelp-query+search-example-generator": "Отримати інформацію про сторінки, на яких знайдено <kbd>meaning</kbd>.",
+ "apihelp-query+siteinfo-summary": "Видати загальну інформацію про сайт.",
+ "apihelp-query+siteinfo-param-prop": "Яку інформацію отримати:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "Загальна системна інформація.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "Список зареєстрованих просторів назв та їхні канонічні назви.",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "Список зареєстрованого простору прізвиськ.",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Список аліасів спеціальної сторінки.",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "Список магічних слів та їх аліасів.",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "Видає статистику сайту.",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "Видає карту інтервікі (за бажанням, фільтровану, за бажанням локалізовану з використанням <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "Видає сервер бази даних з найбільшою затримкою відповіді.",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "Видає групи користувачів і пов'язані дозволи.",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "Видає бібліотеки, встановлені у вікі.",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "Видає розширення, встановлені у вікі.",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "Видає список розширень файлів (типів файлів), які дозволено завантажувати.",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "Видає інформацію щодо прав (ліцензії) вікі, якщо наявна.",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "Видає інформацію про наявні типи обмежень (захисту).",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "Видає список мов, які підтримує MediaWiki (за бажанням локалізовані через <var>$1inlanguagecode</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "Виводить список кодів мов, для яких увімкнено [[mw:Special:MyLanguage/LanguageConverter|LanguageConverter]], а також варіанти, підтримувані кожною з цих мов.",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "Видає список усіх доступних тем оформлення (опціонально локалізовані з використанням <var>$1inlanguagecode</var>, в іншому разі — мовою вмісту).",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "Видає список теґів розширення парсеру.",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "Видає список гуків парсерних функцій.",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "Видає список усіх підписаних гуків (вміст <var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>).",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "Видає список змінних ID.",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "Видає список протоколів, дозволених у зовнішніх посиланнях.",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "Видає значення налаштувань користувача за замовчуванням.",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "Повертає конфігурацію діалогу завантаження.",
+ "apihelp-query+siteinfo-param-filteriw": "Видати лише локальні або лише нелокальні елементи карти інтервікі.",
+ "apihelp-query+siteinfo-param-showalldb": "Перелічити усі сервери баз даних, а не лише той, який робить найбільшу затримку.",
+ "apihelp-query+siteinfo-param-numberingroup": "Перераховує кількість користувачів у групах користувачів.",
+ "apihelp-query+siteinfo-param-inlanguagecode": "Код мови для локалізованих назв мов (найкращий варіант) і назв тем оформлення.",
+ "apihelp-query+siteinfo-example-simple": "Вибрати інформацію про сайт.",
+ "apihelp-query+siteinfo-example-interwiki": "Отримати список локальних інтервікі-префіксів.",
+ "apihelp-query+siteinfo-example-replag": "Перевірити поточне відставання реплікації.",
+ "apihelp-query+stashimageinfo-summary": "Видає інформацію про приховані файли.",
+ "apihelp-query+stashimageinfo-param-filekey": "Ключ, який ідентифікує попереднє завантаження, що було тимчасово приховане.",
+ "apihelp-query+stashimageinfo-param-sessionkey": "Аліас для $1filekey, для зворотної сумісності.",
+ "apihelp-query+stashimageinfo-example-simple": "Видає інформацію про прихований файл.",
+ "apihelp-query+stashimageinfo-example-params": "Видає мініатюри для двох прихованих файлів.",
+ "apihelp-query+tags-summary": "Перелічити мітки змін.",
+ "apihelp-query+tags-param-limit": "Максимальна кількість міток у списку.",
+ "apihelp-query+tags-param-prop": "Які властивості отримати:",
+ "apihelp-query+tags-paramvalue-prop-name": "Додає назву мітки.",
+ "apihelp-query+tags-paramvalue-prop-displayname": "Додає системне повідомлення для мітки.",
+ "apihelp-query+tags-paramvalue-prop-description": "Додає опис мітки.",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "Додає кількість версій та записів журналу, які мають цю мітку.",
+ "apihelp-query+tags-paramvalue-prop-defined": "Показує, чи мітка визначена.",
+ "apihelp-query+tags-paramvalue-prop-source": "Отримує джерела мітки, що може включати <samp>extension</samp> для визначених розширеннями міток і <samp>manual</samp> для міток, які користувачі можуть застосовувати вручну.",
+ "apihelp-query+tags-paramvalue-prop-active": "І все ж позначка досі задіяна.",
+ "apihelp-query+tags-example-simple": "Перелічити доступні мітки.",
+ "apihelp-query+templates-summary": "Видає усі сторінки, які включені на вказаних сторінках.",
+ "apihelp-query+templates-param-namespace": "Показати шаблони лише у цьому просторі назв.",
+ "apihelp-query+templates-param-limit": "Скільки шаблонів виводити.",
+ "apihelp-query+templates-param-templates": "Перерахувати лише ці шаблони. Корисно для перевірки, чи певна сторінка використовує певний шаблон.",
+ "apihelp-query+templates-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+templates-example-simple": "Отримати шаблони, використані на сторінці <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-generator": "Отримати інформацію про сторінки шаблонів, використаних на сторінці <kbd>Main Page</kbd>.",
+ "apihelp-query+templates-example-namespaces": "Отримати сторінки у просторах назв {{ns:user}} і {{ns:template}}, які включені на сторінці <kbd>Main Page</kbd>.",
+ "apihelp-query+tokens-summary": "Отримує токени для дій, що змінюють дані.",
+ "apihelp-query+tokens-param-type": "Типи токена для запиту.",
+ "apihelp-query+tokens-example-simple": "Отримати csrf-токен (за замовчуванням).",
+ "apihelp-query+tokens-example-types": "Отримати токен спостереження і токен патрулювання.",
+ "apihelp-query+transcludedin-summary": "Знайти усі сторінки, що включають подані сторінки.",
+ "apihelp-query+transcludedin-param-prop": "Які властивості отримати:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "ID кожної сторінки.",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "Назва кожної сторінки.",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "Помітка, якщо сторінка є перенаправленням.",
+ "apihelp-query+transcludedin-param-namespace": "Включати сторінки лише в цих просторах назв.",
+ "apihelp-query+transcludedin-param-limit": "Скільки результатів виводити.",
+ "apihelp-query+transcludedin-param-show": "Показати лише елементи, що відповідають цим критеріям:\n;redirect:Показати лише перенаправлення.\n;!redirect:Показати лише не перенаправлення.",
+ "apihelp-query+transcludedin-example-simple": "Отримати список сторінок, що включають <kbd>Main Page</kbd>.",
+ "apihelp-query+transcludedin-example-generator": "Отримати інформацію про сторінки, які включають <kbd>Main Page</kbd>.",
+ "apihelp-query+usercontribs-summary": "Отримати всі редагування користувача.",
+ "apihelp-query+usercontribs-param-limit": "Максимальна кількість елементів внеску для виведення.",
+ "apihelp-query+usercontribs-param-start": "З якої часової мітки виводити.",
+ "apihelp-query+usercontribs-param-end": "До якої часової мітки виводити.",
+ "apihelp-query+usercontribs-param-user": "Користувачі, для яких отримати внесок. Не можна використовувати з <var>$1userids</var> чи <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-userprefix": "Отримати внесок усіх користувачів, чиї імена починаються цим значенням. Не можна використовувати з <var>$1user</var> чи <var>$1userids</var>.",
+ "apihelp-query+usercontribs-param-userids": "Ідентифікатори користувачів, для яких отримати внесок. Не можна використовувати з <var>$1user</var> чи <var>$1userprefix</var>.",
+ "apihelp-query+usercontribs-param-namespace": "Перерахувати записи внеску лише в цих просторах назв.",
+ "apihelp-query+usercontribs-param-prop": "Включити додаткові відомомсті:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "Додає ID сторінки й ID версії.",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "Додає назву й ID простору назв сторінки.",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "Додає часову мітку редагування.",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "Додає коментар редагування.",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "Додає проаналізований коментар редагування.",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "Додає новий розмір редагування.",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Додає зміну розміру порівняно з попереднім редагуванням.",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "Додає прапорці редагування.",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Відзначає патрульовані редагування.",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "Перелічує мітки редагування.",
+ "apihelp-query+usercontribs-param-show": "Показати лише елементи, що відповідають цим критеріям, наприклад, лише не незначні редагування: <kbd>$2show=!minor</kbd>.\n\nЯкщо вказано <kbd>$2show=patrolled</kbd> або <kbd>$2show=!patrolled</kbd>, версії, старіші ніж <var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var> ($1 {{PLURAL:$1|секунда|секунди|секунд}}) не будуть показуватися.",
+ "apihelp-query+usercontribs-param-tag": "Перерахувати лише версії, помічені цим теґом.",
+ "apihelp-query+usercontribs-param-toponly": "Виводити лише зміни, які є останньою версією.",
+ "apihelp-query+usercontribs-example-user": "Показати внесок користувача <kbd>Example</kbd>.",
+ "apihelp-query+usercontribs-example-ipprefix": "Показати внесок з усіх IP-адрес з префіксом <kbd>192.0.2.</kbd>.",
+ "apihelp-query+userinfo-summary": "Отримати інформацію про поточного користувача.",
+ "apihelp-query+userinfo-param-prop": "Які саме відомості включати:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Позначає, чи поточний користувач заблокований, ким, з якої причини.",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Додає мітку <samp>messages</samp>, якщо у користувача є непроглянуті повідомлення.",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "Перелічує усі групи, до яких належить поточний користувач.",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Перелічити групи, в які поточний користувач безпосередньо входить, а також термін дії членств.",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Перелічує усі групи, до яких поточний користувач належить автоматично.",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "Перелічує усі права, які має поточний користувач.",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Перелічує групи, у які користувач може додавати і з яких вилучати.",
+ "apihelp-query+userinfo-paramvalue-prop-options": "Перелічує усі налаштування, які поточний користувач встановив.",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Отримати токен для зміни налаштувань поточного користувача.",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "Додає кількість редагувань поточного користувача.",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "Перелічує усі ліміти оцінок, застосовні до поточного користувача.",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "Додає справжнє ім'я користувача.",
+ "apihelp-query+userinfo-paramvalue-prop-email": "Додає електронну пошту користувача та дату її підтвердження.",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "Дублює шапку <code>Accept-Language</code>, надіслану клієнтом у структурованому форматі.",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "ДОдає дату реєстрації користувача.",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Додає кількість непрочитаних сторінок у списку спостереження користувача (максимально $1; видає «<samp>$2</samp>», якщо більше).",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "Додає центральні ідентифікатори і стан приєднання для користувача.",
+ "apihelp-query+userinfo-param-attachedwiki": "Із <kbd>$1prop=centralids</kbd>, вказати, чи користувач має приєднану вікі, визначену цим ідентифікатором.",
+ "apihelp-query+userinfo-example-simple": "Отримати інформацію про поточного користувача.",
+ "apihelp-query+userinfo-example-data": "Отримати додаткову інформацію про поточного користувача.",
+ "apihelp-query+users-summary": "Отримати інформацію про список користувачів.",
+ "apihelp-query+users-param-prop": "Яку інформацію включити:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "Мітки про те чи є користувач заблокованим, ким, і з якою причиною.",
+ "apihelp-query+users-paramvalue-prop-groups": "Перелічує всі групи, до яких належить кожен з користувачів.",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "Перелічити групи, в які користувачі безпосередньо входять, а також термін дії членств.",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "Перелічує всі групи, членом яких користувач є автоматично.",
+ "apihelp-query+users-paramvalue-prop-rights": "Перелічує всі права, які має кожен з користувачів.",
+ "apihelp-query+users-paramvalue-prop-editcount": "Додає лічильник редагувань користувача.",
+ "apihelp-query+users-paramvalue-prop-registration": "Додає часову мітку реєстрації користувача.",
+ "apihelp-query+users-paramvalue-prop-emailable": "Помічає чи хоче користувач отримувати електронну пошту через [[Special:Emailuser]].",
+ "apihelp-query+users-paramvalue-prop-gender": "Помічає стать користувача. Повертає \"male\", \"female\", або \"unknown\".",
+ "apihelp-query+users-paramvalue-prop-centralids": "Додає центральні ідентифікатори і стан приєднання для користувача.",
+ "apihelp-query+users-paramvalue-prop-cancreate": "Вказує, чи можна створити обліковий запис для допустимих, але незареєстрованих імен користувачів.",
+ "apihelp-query+users-param-attachedwiki": "Із <kbd>$1prop=centralids</kbd>, вказати, чи користувач має приєднану вікі, визначену цим ідентифікатором.",
+ "apihelp-query+users-param-users": "Список користувачів, для яких отримати інформацію.",
+ "apihelp-query+users-param-userids": "Список ідентифікаторів користувачів, щодо яких треба отримати інформацію.",
+ "apihelp-query+users-param-token": "Використати натомість <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
+ "apihelp-query+users-example-simple": "Вивести інформацію для користувача <kbd>Example</kbd>.",
+ "apihelp-query+watchlist-summary": "Отримати нещодавні зміни сторінок у списку спостереження поточного користувача.",
+ "apihelp-query+watchlist-param-allrev": "Включити декілька версій тієї з сторінки у поданому часовому діапазоні.",
+ "apihelp-query+watchlist-param-start": "Часова мітка, з якої почати перелік.",
+ "apihelp-query+watchlist-param-end": "Часова мітка завершення переліку.",
+ "apihelp-query+watchlist-param-namespace": "Відфільтрувати до змін лише у поданих просторах назв.",
+ "apihelp-query+watchlist-param-user": "Перерахувати лише зміни, зроблені цим користувачем.",
+ "apihelp-query+watchlist-param-excludeuser": "Не перераховувати зміни, зроблені цим користувачем.",
+ "apihelp-query+watchlist-param-limit": "Скільки всього видати результатів за один запит.",
+ "apihelp-query+watchlist-param-prop": "Які додаткові властивості отримати:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "Додає ID версій та ID сторінок.",
+ "apihelp-query+watchlist-paramvalue-prop-title": "Додає заголовок сторінки.",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "Додає прапорці редагування.",
+ "apihelp-query+watchlist-paramvalue-prop-user": "Додає користувача, який зробив редагування.",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "Додає ідентифікатор користувача, який зробив редагування.",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "Додає коментар редагування.",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Додає проаналізований коментар редагування.",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "Додає часову мітку редагування.",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "Позначає відпатрульовані редагування.",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "Додає стару і нову довжину сторінки.",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Додає мітку часу, коли користувач був востаннє сповіщений про редагування.",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "Додає інформацію журналу, де це доречно.",
+ "apihelp-query+watchlist-param-show": "Показати лише елементи, що задовільняють ці критерії. Наприклад, для перегляду лише незначних змін, здійснених користувачами, що увійшли до системи, вкажіть $1show=minor|!anon.",
+ "apihelp-query+watchlist-param-type": "Які типи змін показувати:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "Регулярні сторінки правок.",
+ "apihelp-query+watchlist-paramvalue-type-external": "Зовнішні зміни.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Сторінка створена.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Записи в журналі.",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "Зміни членства в категорії.",
+ "apihelp-query+watchlist-param-owner": "Використовується разом з $1token для доступу до списку спостереження різних користувачів.",
+ "apihelp-query+watchlist-param-token": "Токен безпеки (доступний у [[Special:Preferences#mw-prefsection-watchlist|налаштуваннях]] користувача) для отримання доступу до списку спостереження іншого користувача.",
+ "apihelp-query+watchlist-example-simple": "Перелічити верхні версії для нещодавно змінених сторінок у списку спостереження поточного користувача.",
+ "apihelp-query+watchlist-example-props": "Вибрати додаткову інформацію про верхню версію нещодавно змінених сторінок у списку спостереження поточного користувача.",
+ "apihelp-query+watchlist-example-allrev": "Вибрати інформацію про усі нещодавні зміни на сторінках у списку спостереження поточного користувача.",
+ "apihelp-query+watchlist-example-generator": "Видати інформацію про сторінку для нещодавно змінених сторінок у списку спостереження поточного користувача.",
+ "apihelp-query+watchlist-example-generator-rev": "Вибрати інформацію про версію для усіх нещодавніх змін на сторінках у списку спостереження поточного користувача.",
+ "apihelp-query+watchlist-example-wlowner": "Перелічити верхні версії для нещодавно змінених сторінок у списку спостереження користувача <kbd>Example</kbd>.",
+ "apihelp-query+watchlistraw-summary": "Отримати усі сторінки у списку спостереження поточного користувача.",
+ "apihelp-query+watchlistraw-param-namespace": "Перерахувати сторінки лише в поданих просторах назв.",
+ "apihelp-query+watchlistraw-param-limit": "Скільки всього видати результатів за один запит.",
+ "apihelp-query+watchlistraw-param-prop": "Які додаткові властивості отримати:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "Додає мітку часу, коли користувач був востаннє сповіщений про редагування.",
+ "apihelp-query+watchlistraw-param-show": "Перелічити лише елементи, які відповідають цим критеріям.",
+ "apihelp-query+watchlistraw-param-owner": "Використовується разом з $1token для доступу до списку спостереження різних користувачів.",
+ "apihelp-query+watchlistraw-param-token": "Токен безпеки (доступний у [[Special:Preferences#mw-prefsection-watchlist|налаштуваннях]] користувача) для отримання доступу до списку спостереження іншого користувача.",
+ "apihelp-query+watchlistraw-param-dir": "Напрямок, у якому перелічити.",
+ "apihelp-query+watchlistraw-param-fromtitle": "Назва (з префіксом простору назв), з якої почати перерахування.",
+ "apihelp-query+watchlistraw-param-totitle": "Назва (з префіксом простору назв), якою закінчити перерахування.",
+ "apihelp-query+watchlistraw-example-simple": "Перелічити сторінки у списку спостереження поточного користувача.",
+ "apihelp-query+watchlistraw-example-generator": "Вибрати інформацію про сторінку для сторінок у списку спостереження поточного користувача.",
+ "apihelp-removeauthenticationdata-summary": "Вилучити параметри автентифікації для поточного користувача.",
+ "apihelp-removeauthenticationdata-example-simple": "Спроба вилучити дані поточного користувача для <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-resetpassword-summary": "Відправити користувачу лист для відновлення пароля.",
+ "apihelp-resetpassword-extended-description-noroutes": "Немає доступних способів відновити пароль.\n\nУвімкніть способи у <var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var>, щоб використовувати цей модуль.",
+ "apihelp-resetpassword-param-user": "Користувача відновлено.",
+ "apihelp-resetpassword-param-email": "Адреса електронної пошти користувача відновлено.",
+ "apihelp-resetpassword-example-user": "Надіслати лист для скидання пароля користувачу <kbd>Example</kbd>.",
+ "apihelp-resetpassword-example-email": "Надіслати лист для скидання пароля усім користувачам з адресою електронної пошти <kbd>user@example.com</kbd>.",
+ "apihelp-revisiondelete-summary": "Вилучити або відновити версії.",
+ "apihelp-revisiondelete-param-type": "Тип здійснюваного вилучення версії.",
+ "apihelp-revisiondelete-param-target": "Назва сторінки, версію якої вилучити, якщо вимагається для цього типу.",
+ "apihelp-revisiondelete-param-ids": "Ідентифікатори версій, які слід вилучити.",
+ "apihelp-revisiondelete-param-hide": "Що приховати у кожній з версій.",
+ "apihelp-revisiondelete-param-show": "Що показати у кожній з версії.",
+ "apihelp-revisiondelete-param-suppress": "Чи приховати дані від адміністраторів так само як від усіх інших.",
+ "apihelp-revisiondelete-param-reason": "Причина вилучення або відновлення.",
+ "apihelp-revisiondelete-param-tags": "Теги для застосування до запису в журналі вилучень",
+ "apihelp-revisiondelete-example-revision": "Приховати вміст версії <kbd>12345</kbd> сторінки <kbd>Main Page</kbd>.",
+ "apihelp-revisiondelete-example-log": "Приховати всі дані у записі журналу <kbd>67890</kbd> з причиною <kbd>BLP violation</kbd>.",
+ "apihelp-rollback-summary": "Скасувати останнє редагування цієї сторінки.",
+ "apihelp-rollback-extended-description": "Якщо користувач, який редагував сторінку, зробив декілька редагувань підряд, їх усі буде відкочено.",
+ "apihelp-rollback-param-title": "Назва сторінки, у якій здійснити відкіт. Не може використовуватись разом з <var>$1pageid</var>.",
+ "apihelp-rollback-param-pageid": "Ідентифікатор сторінки у якій здійснити відкіт. Не може використовуватись разом з <var>$1title</var>.",
+ "apihelp-rollback-param-tags": "Теги, які будуть застосовані до відкоту.",
+ "apihelp-rollback-param-user": "Ім'я користувача чиї редагування слід відкотити.",
+ "apihelp-rollback-param-summary": "Нестандартний опис редагування. Якщо порожній, буде використано опис редагування за замовчуванням.",
+ "apihelp-rollback-param-markbot": "Позначити відкинуті редагування та відкіт як редагування бота.",
+ "apihelp-rollback-param-watchlist": "Безумовно додати або вилучити сторінку із списку спостереження поточного користувача, використати налаштування, або не змінювати статус (не)спостереження.",
+ "apihelp-rollback-example-simple": "Відкинути останні редагування сторінки <kbd>Main Page</kbd> здійснені користувачем <kbd>Example</kbd>.",
+ "apihelp-rollback-example-summary": "Відкинути останні редагування сторінки <kbd>Main Page</kbd> здійснені IP-користувачем <kbd>192.0.2.5</kbd> з причиною <kbd>Reverting vandalism</kbd>, та позначити ці редагування та відкіт як редагування бота.",
+ "apihelp-rsd-summary": "Експортувати як схему RSD (Really Simple Discovery).",
+ "apihelp-rsd-example-simple": "Експортувати RSD-схему.",
+ "apihelp-setnotificationtimestamp-summary": "Оновити часову мітку сповіщень для сторінок, що спостерігаються.",
+ "apihelp-setnotificationtimestamp-extended-description": "Це зачепить підсвічування змінених сторінок у списку спостереження та історії, а також надсилання електронного листа якщо опція налаштувань «{{int:tog-enotifwatchlistpages}}» увімкнена.",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "Опрацювати всі сторінки, що спостерігаються.",
+ "apihelp-setnotificationtimestamp-param-timestamp": "Часова мітка, яку вказати у якості часової мітки сповіщень.",
+ "apihelp-setnotificationtimestamp-param-torevid": "Версія до якої вказати часову мітку сповіщень (лише одна сторінка).",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "Версія, до новішої від якої вказати часову мітку сповіщень (лише одна сторінка).",
+ "apihelp-setnotificationtimestamp-example-all": "Стерти статус сповіщень для всього списку спостереження.",
+ "apihelp-setnotificationtimestamp-example-page": "Стерти статус сповіщень для <kbd>Main page</kbd>.",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "Встановити часову мітку сповіщень для <kbd>Main page</kbd> так, що всі редагування після 1 січня 2012 будуть виглядати як не переглянуті.",
+ "apihelp-setnotificationtimestamp-example-allpages": "Стерти статус сповіщень для сторінок у просторі назв <kbd>{{ns:user}}</kbd>.",
+ "apihelp-setpagelanguage-summary": "Змінити мову сторінки.",
+ "apihelp-setpagelanguage-extended-description-disabled": "Зміна мови сторінки заборонена в цій вікі. \n\nУвімкніть <var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var>, щоб використовувати цю дію.",
+ "apihelp-setpagelanguage-param-title": "Назва сторінки, мову якої Ви хочете змінити. Не можна використовувати разом з <var>$1pageid</var>.",
+ "apihelp-setpagelanguage-param-pageid": "Ідентифікатор сторінки, мову якої Ви хочете змінити. Не можна використовувати разом з <var>$1title</var>.",
+ "apihelp-setpagelanguage-param-lang": "Код мови, якою треба замінити поточну мову сторінки. Використовуйте <kbd>default</kbd>, щоб встановити стандартну мову вмісту цієї вікі як мову сторінки.",
+ "apihelp-setpagelanguage-param-reason": "Причина зміни.",
+ "apihelp-setpagelanguage-param-tags": "Змінити теги для застосування до запису в журналі, який буде результатом цієї дії.",
+ "apihelp-setpagelanguage-example-language": "Змінити мову сторінки <kbd>Main Page</kbd> на «баскська».",
+ "apihelp-setpagelanguage-example-default": "Змінити мову сторінки з ідентифікатором 123 на стандартну мову вмісту вікі.",
+ "apihelp-stashedit-summary": "Підготувати редагування в загальний кеш.",
+ "apihelp-stashedit-extended-description": "Це призначено для використання через AJAX з форми редагування, щоб поліпшити продуктивність збереження сторінки.",
+ "apihelp-stashedit-param-title": "Назва редагованої сторінки.",
+ "apihelp-stashedit-param-section": "Номер розділу. <kbd>0</kbd> для вступного розділу, <kbd>new</kbd> для нового розділу.",
+ "apihelp-stashedit-param-sectiontitle": "Назва нового розділу.",
+ "apihelp-stashedit-param-text": "Вміст сторінки.",
+ "apihelp-stashedit-param-stashedtexthash": "Хеш вмісту сторінки з попереднього сховку, який треба використати натомість.",
+ "apihelp-stashedit-param-contentmodel": "Модель вмісту нового вмісту.",
+ "apihelp-stashedit-param-contentformat": "Формат серіалізації вмісту, використовуваний для введеного тексту.",
+ "apihelp-stashedit-param-baserevid": "Ідентифікатор базової версії.",
+ "apihelp-stashedit-param-summary": "Змінити опис.",
+ "apihelp-tag-summary": "Додати або вилучити зміни міток з окремих версій або записів журналу.",
+ "apihelp-tag-param-rcid": "Один або більше ідентифікаторів останніх змін, до яких додати або вилучити мітки.",
+ "apihelp-tag-param-revid": "Один або більше ідентифікатор з якого додати або вилучити мітку.",
+ "apihelp-tag-param-logid": "Один або більше ідентифікатор запису журналу з якого вилучити або додати мітку.",
+ "apihelp-tag-param-add": "Мітки, які слід додати. Лише визначені вручну мітки може бути додано.",
+ "apihelp-tag-param-remove": "Мітки, які слід вилучити. Лише мітки, які було визначено вручну, або взагалі не визначено, можуть бути вилучені.",
+ "apihelp-tag-param-reason": "Причина зміни.",
+ "apihelp-tag-param-tags": "Теги для застосування до запису в журналі, що буде створений в результаті цієї дії.",
+ "apihelp-tag-example-rev": "Додати мітку <kbd>vandalism</kbd> до версії з ідентифікатором 123 без вказання причини",
+ "apihelp-tag-example-log": "Вилучити мітку <kbd>spam</kbd> з запису журналу з ідентифікатором 123 з причиною <kbd>Wrongly applied</kbd>",
+ "apihelp-tokens-summary": "Отримати жетони для дій пов'язаних зі зміною даних.",
+ "apihelp-tokens-extended-description": "Цей модуль застарів на користь [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].",
+ "apihelp-tokens-param-type": "Які типи жетонів запитати.",
+ "apihelp-tokens-example-edit": "Отримати жетон редагування (за замовчуванням).",
+ "apihelp-tokens-example-emailmove": "Отримати жетон електронної пошти та жетон перейменування.",
+ "apihelp-unblock-summary": "Розблокувати користувача.",
+ "apihelp-unblock-param-id": "Ідентифікатор блоку чи розблокування (отриманий через <kbd>list=blocks</kbd>). Не може бути використано разом із <var>$1user</var> або <var>$1userid</var>.",
+ "apihelp-unblock-param-user": "Ім'я користувача, IP-адреса чи IP-діапазон до розблокування. Не може бути використано разом із <var>$1id</var> або <var>$1userid</var>.",
+ "apihelp-unblock-param-userid": "Ідентифікатор користувача до розблокування. Не може бути використано разом із <var>$1id</var> або <var>$1user</var>.",
+ "apihelp-unblock-param-reason": "Причина розблокування.",
+ "apihelp-unblock-param-tags": "Змінити теги, що мають бути застосовані до запису в журналі блокувань.",
+ "apihelp-unblock-example-id": "Зняти блокування з ідентифікатором #<kbd>105</kbd>.",
+ "apihelp-unblock-example-user": "Розблокувати користувача <kbd>Bob</kbd> з причиною <kbd>Sorry Bob</kbd>.",
+ "apihelp-undelete-summary": "Відновити версії вилученої сторінки.",
+ "apihelp-undelete-extended-description": "Список вилучених версій (включено з часовими мітками) може бути отримано через [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]], а список ідентифікаторів вилучених файлів може бути отримано через [[Special:ApiHelp/query+filearchive|list=filearchive]].",
+ "apihelp-undelete-param-title": "Назва сторінки, яку слід відновити.",
+ "apihelp-undelete-param-reason": "Причина відновлення.",
+ "apihelp-undelete-param-tags": "Змінити теги, що мають бути застосовані до запису в журналі вилучень.",
+ "apihelp-undelete-param-timestamps": "Часові мітки версій, які слід відновити. Якщо і <var>$1timestamps</var>, і <var>$1fileids</var> порожні, буде відновлено всі версії.",
+ "apihelp-undelete-param-fileids": "Ідентифікатори версій файлів, які слід відновити. Якщо і <var>$1timestamps</var>, і <var>$1fileids</var> порожні, буде відновлено всі версії.",
+ "apihelp-undelete-param-watchlist": "Безумовно додати або вилучити сторінку із списку спостереження поточного користувача, використати налаштування, або не змінювати статус (не)спостереження.",
+ "apihelp-undelete-example-page": "Відновити сторінку <kbd>Main Page</kbd>.",
+ "apihelp-undelete-example-revisions": "Відновити дві версії сторінки <kbd>Main Page</kbd>.",
+ "apihelp-unlinkaccount-summary": "Вилучити пов'язаний обліковий запис третьої сторони з поточного користувача.",
+ "apihelp-unlinkaccount-example-simple": "Здійснити спробу вилучити посилання поточного користувача для провайдера, асоційованого з <kbd>FooAuthenticationRequest</kbd>.",
+ "apihelp-upload-summary": "Завантажити файл, або отримати статус завантажень у процесі.",
+ "apihelp-upload-extended-description": "Доступні декілька методів:\n* Завантажити вміст файлу напряму, використовуючи параметр <var>$1file</var>.\n* Завантажити файл шматками, використовуючи параметри <var>$1filesize</var>, <var>$1chunk</var>, та <var>$1offset</var>.\n* Змусити сервер Медіавікі отримати файл за URL, використовуючи параметр <var>$1url</var>.\n* Завершити раніше розпочате завантаження, яке не вдалось через попередження, використовуючи параметр <var>$1filekey</var>.\nЗауважте, що HTTP POST повинен бути здійснений як завантаження файлу (наприклад, використовуючи <code>multipart/form-data</code>)",
+ "apihelp-upload-param-filename": "Цільова назва файлу.",
+ "apihelp-upload-param-comment": "Коментар завантаження. Також використовується як початковий текст сторінок для нових файлів, якщо <var>$1text</var> не вказано.",
+ "apihelp-upload-param-tags": "Змінити теги, які будуть застосовані до запису журналу завантажень та відповідної версії в історії редагувань сторінки файлу.",
+ "apihelp-upload-param-text": "Початковий текст сторінок для нових файлів.",
+ "apihelp-upload-param-watch": "Спостерігати за сторінкою.",
+ "apihelp-upload-param-watchlist": "Безумовно додати або вилучити сторінку із списку спостереження поточного користувача, використати налаштування, або не змінювати статус (не)спостереження.",
+ "apihelp-upload-param-ignorewarnings": "Ігнорувати всі попередження.",
+ "apihelp-upload-param-file": "Вміст файлу.",
+ "apihelp-upload-param-url": "URL з якого отримати файл.",
+ "apihelp-upload-param-filekey": "Ключ, що ідентифікує попереднє завантаження яке було відкладено тимчасово",
+ "apihelp-upload-param-sessionkey": "Те ж саме, що $1filekey, підтримується для зворотної сумісності.",
+ "apihelp-upload-param-stash": "Якщо вказано, сервер тимчасово відкладе файл замість додати його до репозиторію.",
+ "apihelp-upload-param-filesize": "Розмір файлу цілого завантаження.",
+ "apihelp-upload-param-offset": "Зміщення шматка в байтах.",
+ "apihelp-upload-param-chunk": "Шматок вмісту.",
+ "apihelp-upload-param-async": "Зробити операції з потенційно великими файлами асинхронними коли можливо.",
+ "apihelp-upload-param-checkstatus": "Отримувати статус завантаження лише для даного ключа файлу.",
+ "apihelp-upload-example-url": "Завантаження з URL.",
+ "apihelp-upload-example-filekey": "Завершити завантаження, що не вдалось через попередження.",
+ "apihelp-userrights-summary": "Змінити членство користувача у групах.",
+ "apihelp-userrights-param-user": "Ім'я користувача.",
+ "apihelp-userrights-param-userid": "Ідентифікатор користувача.",
+ "apihelp-userrights-param-add": "Додати користувача до цих груп. Якщо він вже є членом групи, оновити термін дії членства.",
+ "apihelp-userrights-param-expiry": "Часові мітки, коли завершується членство. Можуть бути відносними (наприклад, <kbd>5 months</kbd> або <kbd>2 weeks</kbd>) або абсолютними (як <kbd>2014-09-18T12:34:56Z</kbd>). Якщо задано тільки оду часову мітку, вона буде стосуватися всіх груп, переданих параметром <var>$1add</var>. Використовуйте <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd> або <kbd>never</kbd>, щоб задати безстрокове членство.",
+ "apihelp-userrights-param-remove": "Вилучити користувача із цих груп.",
+ "apihelp-userrights-param-reason": "Причина зміни.",
+ "apihelp-userrights-param-tags": "Змінити теги для застосування до запису в журналі зміни прав користувача.",
+ "apihelp-userrights-example-user": "Додати користувача <kbd>FooBot</kbd> до групи <kbd>bot</kbd> та вилучити із груп <kbd>sysop</kbd> та <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-userid": "Додати користувача з ідентифікатором <kbd>123</kbd> до групи <kbd>bot</kbd> та вилучити із груп <kbd>sysop</kbd> та <kbd>bureaucrat</kbd>.",
+ "apihelp-userrights-example-expiry": "Додати користувача <kbd>SometimeSysop</kbd> в групу <kbd>sysop</kbd> на 1 місяць.",
+ "apihelp-validatepassword-summary": "Перевірити пароль на предмет відповідності політикам вікі щодо паролів.",
+ "apihelp-validatepassword-extended-description": "Результати перевірки вказуються як <samp>Good</samp> якщо пароль прийнятний, <samp>Change</samp> якщо пароль може використовуватись для входу, але його треба змінити, і <samp>Invalid</samp> — якщо пароль використовувати не можна.",
+ "apihelp-validatepassword-param-password": "Пароль до перевірки.",
+ "apihelp-validatepassword-param-user": "Ім'я користувача, для використання при тестуванні створення облікового запису. Вказаний користувач не повинен існувати.",
+ "apihelp-validatepassword-param-email": "Адреса електронної пошти, для використання при тестуванні створення облікового запису.",
+ "apihelp-validatepassword-param-realname": "Справжнє ім'я, для використання при тестуванні створення облікового запису.",
+ "apihelp-validatepassword-example-1": "Перевірити пароль <kbd>foobar</kbd> для поточного користувача.",
+ "apihelp-validatepassword-example-2": "Перевірити пароль <kbd>qwerty</kbd> для створення користувача <kbd>Example</kbd>.",
+ "apihelp-watch-summary": "Додати або вилучити сторінки з списку спостереження поточного користувача.",
+ "apihelp-watch-param-title": "Сторінки до додання/вилучення. Використовуйте <var>$1titles</var> натомість.",
+ "apihelp-watch-param-unwatch": "Якщо вказано, сторінку буде вилучено зі списку спостереження замість додання до нього.",
+ "apihelp-watch-example-watch": "Спостерігати за сторінкою <kbd>Main Page</kbd>.",
+ "apihelp-watch-example-unwatch": "Вилучити сторінку <kbd>Головна сторінка</kbd> зі списку спостереження.",
+ "apihelp-watch-example-generator": "Додати перші декілька сторінок основного простору назв до списку спостереження.",
+ "apihelp-format-example-generic": "Повернути результат запиту у форматі $1.",
+ "apihelp-format-param-wrappedhtml": "Повернути візуально пристосований HTML та пов'язані модулі ResourceLoader як об'єкт JSON.",
+ "apihelp-json-summary": "Вивести дані у форматі JSON.",
+ "apihelp-json-param-callback": "Якщо вказано, огортає вивід викликом даної функції. З міркувань безпеки, усі специфічні до користувача дані буде утримано.",
+ "apihelp-json-param-utf8": "Якщо вказано, кодує більшість (але не всі) не-ASCII символів як UTF-8, замість заміни їх шістнадцятковими екрануючими послідовностями. За замовчуванням коли <var>formatversion</var> не є <kbd>1</kbd>.",
+ "apihelp-json-param-ascii": "Якщо вказано, кодує всі не-ASCII використовуючи шістнадцяткові екрануючі послідовності. За замовчуванням коли <var>formatversion</var> є <kbd>1</kbd>.",
+ "apihelp-json-param-formatversion": "Форматування виводу:\n;1:Формат зворотної сумісності (булеви XML-стилю, <samp>*</samp> ключі для вузлів вмісту тощо).\n;2:Експериментальний сучасний формат. Деталі можуть змінюватись.\n;latest:Використовувати найостанніший формат (наразі <kbd>2</kbd>). Може змінюватись без попередження.",
+ "apihelp-jsonfm-summary": "Вивести дані у форматі JSON (вивід відформатованого коду за допомогою HTML).",
+ "apihelp-none-summary": "Нічого не виводити.",
+ "apihelp-php-summary": "Виводити дані у форматі серіалізованого PHP.",
+ "apihelp-php-param-formatversion": "Форматування виводу:\n;1:Формат зворотної сумісності (булеви XML-стилю, <samp>*</samp> ключі для вузлів вмісту тощо).\n;2:Експериментальний сучасний формат. Деталі можуть змінюватись.\n;latest:Використовувати найостанніший формат (наразі <kbd>2</kbd>). Може змінюватись без попередження.",
+ "apihelp-phpfm-summary": "Виводити дані у форматі серіалізованого PHP (вивід відформатованого коду за допомогою HTML).",
+ "apihelp-rawfm-summary": "Виводити дані, включно з елементами налагодження, у форматі JSON (вивід відформатованого коду за допомогою HTML).",
+ "apihelp-xml-summary": "Виводити дані у форматі XML.",
+ "apihelp-xml-param-xslt": "Якщо вказано, додає названу сторінку як таблицю стилів XSL. Це значення повинне бути назвою у просторі назв {{ns:MediaWiki}}, що закінчується на <code>.xsl</code>.",
+ "apihelp-xml-param-includexmlnamespace": "Якщо вказано, додає простір назв XML.",
+ "apihelp-xmlfm-summary": "Вивести дані у форматі XML (вивід відформатованого коду за допомогою HTML).",
+ "api-format-title": "Результат запиту до API MediaWiki",
+ "api-format-prettyprint-header": "Це HTML-представлення формату $1. HTML є гарним для налагодження, однак не придатний для прикладного використання.\n\nУкажіть значення для параметра <var>format</var>, для того щоб змінити формат. Для перегляду не-HTML-представлення формату, $1, вкажіть <kbd>format=$2</kbd>.\n\nДив. [[mw:Special:MyLanguage/API|повну документацію]], або [[Special:ApiHelp/main|довідку з API]] для детальнішої інформації.",
+ "api-format-prettyprint-header-only-html": "Це HTML-представлення призначене для налагодження, однак не придатне для прикладного використання.\n\nДив. [[mw:Special:MyLanguage/API|повну документацію]], або [[Special:ApiHelp/main|довідку з API]] для детальнішої інформації.",
+ "api-format-prettyprint-header-hyperlinked": "Це — HTML-репрезентація формату $1. HTML добрий для виправлення помилок, але непридатний для використання додатків.\n\nВкажіть параметр <var>format</var>, щоб змінити формат виводу. Щоб побачити репрезентацію формату $1 не у формі HTML, вкажіть [$3 <kbd>format=$2</kbd>].\nДив. [[mw:API|повну документацію]], або [[Special:ApiHelp/main|довідку API]], щоб дізнатися більше.",
+ "api-format-prettyprint-status": "Відповідь повернеться із HTTP-статусом $1 $2.",
+ "api-login-fail-aborted": "Автентифікація вимагає взаємодії з користувачем, яка не підтримується <kbd>action=login</kbd>. Щоб мати змогу увійти в систему за допомогою <kbd>action=login</kbd>, див. [[Special:BotPasswords]]. Щоб продовжити використовувати вхід у систему через основний обліковий запис, див.<kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-aborted-nobotpw": "Автентифікація вимагає взаємодії з користувачем, яка не підтримується <kbd>action=login</kbd>. Щоб увійти в систему, див. <kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>.",
+ "api-login-fail-badsessionprovider": "Неможливо увійти в систему при використанні $1.",
+ "api-login-fail-sameorigin": "Неможливо увійти в систему, коли не застосовується ''політика того ж походження''.",
+ "api-pageset-param-titles": "Список назв над якими працювати.",
+ "api-pageset-param-pageids": "Список ідентифікаторів сторінок над якими працювати.",
+ "api-pageset-param-revids": "Список ідентифікаторів версій над якими працювати.",
+ "api-pageset-param-generator": "Отримати список сторінок над якими працювати шляхом виконання вказаного модуля запиту.\n\n<strong>Примітка:</strong> Назви параметрів генератора повинні мати префікс «g», див. приклади.",
+ "api-pageset-param-redirects-generator": "Автоматично вирішувати перенаправлення у <var>$1titles</var>, <var>$1pageids</var>, і <var>$1revids</var>, та у сторінках, повернених <var>$1generator</var>.",
+ "api-pageset-param-redirects-nogenerator": "Автоматично вирішувати перенаправлення у <var>$1titles</var>, <var>$1pageids</var>, та <var>$1revids</var>.",
+ "api-pageset-param-converttitles": "Конвертувати назви в інші варіанти за необхідності. Працює лише для вікі, мова вмісту яких підтримує конвертування варіантів. Мовами, що підтримують конвертування варіантів є $1.",
+ "api-help-title": "Довідка API MediaWiki",
+ "api-help-lead": "Це автоматично генерована сторінка документації API MediaWiki.\n\nДокументація та приклади: https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "Головний модуль",
+ "api-help-undocumented-module": "Для модуля $1 відсутня документація.",
+ "api-help-flag-deprecated": "Цей модуль є застарілим.",
+ "api-help-flag-internal": "<strong>Цей модуль є внутрішнім або нестабільним.</strong> Його робота може бути змінена без сповіщення.",
+ "api-help-flag-readrights": "Цей модуль вимагає прав на читання.",
+ "api-help-flag-writerights": "Цей модуль вимагає прав на запис.",
+ "api-help-flag-mustbeposted": "Цей модуль приймає лише POST-запити.",
+ "api-help-flag-generator": "Цей модуль може бути використаний як генератор.",
+ "api-help-source": "Джерело: $1",
+ "api-help-source-unknown": "Джерело: <span class=\"apihelp-unknown\">невідоме</span>",
+ "api-help-license": "Ліцензія: [[$1|$2]]",
+ "api-help-license-noname": "Ліцензія: [[$1|див. посилання]]",
+ "api-help-license-unknown": "Ліцензія: <span class=\"apihelp-unknown\">невідома</span>",
+ "api-help-parameters": "{{PLURAL:$1|Параметр|Параметри}}:",
+ "api-help-param-deprecated": "Застарілий.",
+ "api-help-param-required": "Цей параметр є обов'язковим.",
+ "api-help-datatypes-header": "Типи даних",
+ "api-help-datatypes": "Вхідні дані у MediaWiki мають бути в NFC-нормалізованому UTF-8. MediaWiki може спробувати конвертувати вхідні дані іншого вигляду, але від цього можуть постраждати деякі операції (як [[Special:ApiHelp/edit|редагування]] з перевіркою MD5).\n\nДеякі типи параметрів у запитах API потребують ширшого пояснення:\n;boolean\n:Логічні параметри працюють як галочки HTML: якщо параметр вказано, не залежно від значення, він вважається істинним. Щоб значення було хибним, пропустіть параметр зовсім.\n;timestamp\n:Часові мітки можуть бути вказані у кількох форматах. Рекомендується час і дата в ISO 8601. Усі значення часу в UTC, будь-які часові пояси ігноруються.\n:* Дата і час ISO 8601, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (пунктуація і <kbd>Z</kbd> необов'язокві)\n:* Дата і час ISO 8601 з (ігнорованими) частками секунди, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (дефіси, двокрапки та <kbd>Z</kbd> необов'язкові)\n:* Формат MediaWiki, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Загальний числовий формат, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (необов'язковий часовий пояс <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd> або <kbd>-<var>##</var></kbd> ігнорується)\n:* Формат EXIF, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Формат RFC 2822 (часовий пояс може бути опущений), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Формат RFC 850 (часовий пояс може бути опущений), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* Формат C ctime, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Секунди від 1970-01-01T00:00:00Z у вигляді цілого числа від 1 до 13 цифр (без <kbd>0</kbd>)\n:* Рядок <kbd>now</kbd>\n;альтернативний роздільник багатьох значень\n:Параметри, що приймають багато значень, зазвичай подаються зі значеннями, розділеними вертикальною рискою, наприклад, <kbd>param=value1|value2</kbd> або <kbd>param=value1%7Cvalue2</kbd>. Якщо значення повинне містити вертикальну риску, використовуйте як роздільник U+001F (роздільник одиниць) ''та'' поставте U+001F перед значенням, наприклад, <kbd>param=%1Fvalue1%1Fvalue2</kbd>.",
+ "api-help-param-type-limit": "Тип: ціле число або <kbd>max</kbd>",
+ "api-help-param-type-integer": "Тип: {{PLURAL:$1|1=ціле число|2=список цілих чисел}}",
+ "api-help-param-type-boolean": "Тип: логічний ([[Special:ApiHelp/main#main/datatypes|деталі]])",
+ "api-help-param-type-timestamp": "Тип: {{PLURAL:$1|1=часова мітка|2=список часових міток}} ([[Special:ApiHelp/main#main/datatypes|дозволені формати]])",
+ "api-help-param-type-user": "Тип: {{PLURAL:$1|1=ім'я користувача|2=список імен користувачів}}",
+ "api-help-param-list": "{{PLURAL:$1|1=Одне з наступних значень|2=Значення (розділені через <kbd>{{!}}</kbd> або [[Special:ApiHelp/main#main/datatypes|альтернативу]])}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Повинно бути пустим|Може бути пустим або $2}}",
+ "api-help-param-limit": "Дозволено не більше $1.",
+ "api-help-param-limit2": "Дозволено не більше $1 ($2 для ботів).",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=Значення має бути|2=Значення мають бути}} не менше $2.",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=Значення має бути|2=Значення мають бути}} не більше $3.",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=Значення має бути|2=Значення мають бути}} між $2 і $3.",
+ "api-help-param-upload": "Повинно бути надіслано у формі надсилання файлу використовуючи multipart/form-data.",
+ "api-help-param-multi-separate": "Розділіть значення з допомогою <kbd>|</kbd> або [[Special:ApiHelp/main#main/datatypes|альтернативу]].",
+ "api-help-param-multi-max": "Максимальна кількість значень — {{PLURAL:$1|$1}} ({{PLURAL:$2|$2}} для ботів).",
+ "api-help-param-multi-max-simple": "Максимальна кількість значень становить {{PLURAL:$1|$1}}.",
+ "api-help-param-multi-all": "Щоб зазначити всі значення, використовуйте <kbd>$1</kbd>.",
+ "api-help-param-default": "За замовчуванням: $1",
+ "api-help-param-default-empty": "За замовчуванням: <span class=\"apihelp-empty\">(пусто)</span>",
+ "api-help-param-token": "Токен «$1» отримано з [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]",
+ "api-help-param-token-webui": "Для сумісності, приймається також токен, використаний у користувацькому веб-інтерфейсі.",
+ "api-help-param-disabled-in-miser-mode": "Вимкнено через [[mw:Special:MyLanguage/Manual:$wgMiserMode|скупий режим]].",
+ "api-help-param-limited-in-miser-mode": "<strong>Примітка:</strong> через [[mw:Special:MyLanguage/Manual:$wgMiserMode|«скупий режим»]], використання цього може вилитися у видачу результатів менше ніж <var>$1limit</var> перед продовженням; в особливих випадках можуть видаватися нульові результати.",
+ "api-help-param-direction": "У якому напрямку перелічувати:\n;newer:Спочатку найстарші. Примітка: $1start має бути перед $1end.\n;older:Спочатку найновіші (за замовчуванням). Примітка: $1start має бути перед $1end.",
+ "api-help-param-continue": "Коли доступно більше результатів, використовуйте це, щоб продовжити.",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(без опису)</span>",
+ "api-help-examples": "{{PLURAL:$1|Приклад|Приклади}}:",
+ "api-help-permissions": "{{PLURAL:$1|Дозвіл|Дозволи}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|Надано|Надані}}: $2",
+ "api-help-right-apihighlimits": "Використовувати вищі ліміти у запитах API (повільні запити: $1; швидкі запити: $2). Ліміти для повільних запитів також застосовуються до багатозначних параметрів.",
+ "api-help-open-in-apisandbox": "<small>[відкрити в пісочниці]</small>",
+ "api-help-authmanager-general-usage": "Загальна процедура використання цього модуля така:\n# Отримайте доступні поля зі <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> за допомогою <kbd>amirequestsfor=$4</kbd>, а також токен <kbd>$5</kbd> зі <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.\n# Передайте ці поля користувачеві, і отримайте інформацію, якою він їх заповнить.\n# Напишіть до цього модуля, заповнивши <var>$1returnurl</var> та всі релевантні поля.\n# Перевірте <samp>status</samp> у відповіді.\n#* Якщо Ви отримали <samp>PASS</samp> або <samp>FAIL</samp>, роботу завершено. Операція або була успішною, або провалилася.\n#* Якщо Ви отримали <samp>UI</samp>, надішліть нові поля користувачеві й отримайте інформацію, якою він їх заповнить. Далі напишіть до цього модуля із <var>$1continue</var> та заповніть всі реелевантні поля, після чого повторіть крок 4.\n#* Якщо Ви отримали <samp>REDIRECT</samp>, направте користувача до <samp>redirecttarget</samp> і дочекайтеся повернення до <var>$1returnurl</var>. Тоді напишіть до цього модуля із <var>$1continue</var>, та з усіма полями, що були передані до повернутої URL-адреси, після чого повторіть крок 4.\n#* Якщо Ви отримали <samp>RESTART</samp>, це означає, що автентифікація спрацювала, але ми не маємо пов'язаного облікового запису користувача. Ви можете розцінити це як <samp>UI</samp>, або як <samp>FAIL</samp>.",
+ "api-help-authmanagerhelper-requests": "Використовувати ці автентифікаційні запити через <samp>id</samp>, що повертається з <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> за допомогою <kbd>amirequestsfor=$1</kbd>, або з попередньої відповіді з цього модуля.",
+ "api-help-authmanagerhelper-request": "Використовувати цей автентифікаційний запит через <samp>id</samp>, що повертається з <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> за допомогою <kbd>amirequestsfor=$1</kbd>.",
+ "api-help-authmanagerhelper-messageformat": "Формат до використання для повернення повідомлень.",
+ "api-help-authmanagerhelper-mergerequestfields": "Об'єднати інформацію всіх автентифікаційних запитів у один масив.",
+ "api-help-authmanagerhelper-preservestate": "Зберегти статус з попередньої спроби входу, що не вдалась, якщо можливо.",
+ "api-help-authmanagerhelper-returnurl": "URL-адреса повернення для сторонніх автентифікаційних потоків повинна бути абсолютною. Обов'язковим є це, або <var>$1continue</var>. \n\nПри отриманні відповіді <samp>REDIRECT</samp>, найбільш звичною Вашою дією буде відкриття браузерного чи іншого веб-перегляду вказаного URL-посилання для стороннього потоку автентифікації. Коли ця операція буде завершена, стороння програма перенаправить веб-переглядач на цю URL-адресу. Ви повинні видобувати будь-які параметри запитів або POST-параметри із цієї URL-адреси, і передавати їх як запит <var>$1continue</var> до цього модуля API.",
+ "api-help-authmanagerhelper-continue": "Цей запит є продовженням після попередньої відповіді <samp>UI</samp> або <samp>REDIRECT</samp>. Або це, або <var>$1returnurl</var> є обов'язковим.",
+ "api-help-authmanagerhelper-additional-params": "Цей модуль приймає додаткові параметри, залежно від доступних автентифікаційних запитів. Використовуйте <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> за допомогою <kbd>amirequestsfor=$1</kbd> (або попередню відповідь від цього модуля, якщо це застосовно), аби визначити доступні запити та поля, які вони використовують.",
+ "apierror-allimages-redirect": "Використовуйте <kbd>gaifilterredir=nonredirects</kbd> замість <var>redirects</var> при використанні <kbd>allimages</kbd> як генератора.",
+ "apierror-allpages-generator-redirects": "Використовуйте <kbd>gapfilterredir=nonredirects</kbd> замість <var>redirects</var> при використанні <kbd>allpages</kbd> як генератора.",
+ "apierror-appendnotsupported": "Не вдається додавати до сторінок, що використовують контентну модель $1.",
+ "apierror-articleexists": "Статтю, яку Ви намагались створити, вже було створено.",
+ "apierror-assertbotfailed": "Твердження, що користувач має право <code>bot</code>, виявилося хибним.",
+ "apierror-assertnameduserfailed": "Твердження, що ім'я користувача — «$1», виявилося хибним.",
+ "apierror-assertuserfailed": "Твердження, що користувач перебуває в системі, виявилося хибним.",
+ "apierror-autoblocked": "Вашу IP-адресу було автоматично заблоковано, оскільки її використовував заблокований користувач.",
+ "apierror-badconfig-resulttoosmall": "Значення <code>$wgAPIMaxResultSize</code> у цій вікі є надто малим, щоб містити базову інформацію щодо результатів.",
+ "apierror-badcontinue": "Параметр продовження недійсний. Вам треба вказати початкове значення, отримане з попереднього запиту.",
+ "apierror-baddiff": "Цю різницю версій неможливо відтворити: одна або й обидві версії не існують, або Ви не маєте прав на їх перегляд.",
+ "apierror-baddiffto": "Для <var>$1diffto</var> треба задати невід'ємне число,<kbd>prev</kbd>, <kbd>next</kbd> або <kbd>cur</kbd>.",
+ "apierror-badformat-generic": "Запитуваний формат $1 не підтримується контентною моделлю$2.",
+ "apierror-badformat": "Запитуваний формат $1 не підтримується контентною моделлю $2, що використовується $3.",
+ "apierror-badgenerator-notgenerator": "Модуль <kbd>$1</kbd> не може бути використаний як генератор.",
+ "apierror-badgenerator-unknown": "Невідомий <kbd>generator=$1</kbd>.",
+ "apierror-badip": "Параметр IP є недійсним.",
+ "apierror-badmd5": "Вказаний хеш MD5 був неправильним.",
+ "apierror-badmodule-badsubmodule": "Модуль <kbd>$1</kbd> не має підмодуля «$2».",
+ "apierror-badmodule-nosubmodules": "Модуль <kbd>$1</kbd> не має підмодулів.",
+ "apierror-badparameter": "Недійсне значення для параметра <var>$1</var>.",
+ "apierror-badquery": "Неприпустимий запит.",
+ "apierror-badtimestamp": "Недійсне значення «$2» для параметра мітки часу <var>$1</var>.",
+ "apierror-badtoken": "Недійсний токен CSRF.",
+ "apierror-badupload": "Параметр завантаження файлу <var>$1</var> не є завантаженням файлу; не забудьте використати <code>multipart/form-data</code> для Вашого POST і додайте назву файлу в шапку <code>Content-Disposition</code>.",
+ "apierror-badurl": "Недійсне значення «$2» для URL-параметра <var>$1</var>.",
+ "apierror-baduser": "Недійсне значення «$2» для параметра користувача <var>$1</var>.",
+ "apierror-badvalue-notmultivalue": "Відокремлення значень через U+001F може використовуватись лише в тих параметрах, де використання двох і більше значень є прийнятним.",
+ "apierror-bad-watchlist-token": "Надано некоректний токен списку спостереження. Будь ласка, подайте коректний токен на сторінці [[Special:Preferences|Спеціальна:Налаштування]].",
+ "apierror-blockedfrommail": "Вам заблоковано можливість надсилання електронної пошти.",
+ "apierror-blocked": "Вам заблоковано можливість редагування.",
+ "apierror-botsnotsupported": "Інтерфейс не підтримується для ботів.",
+ "apierror-cannot-async-upload-file": "Параметри <var>async</var> та <var>file</var> не можна поєднувати. Якщо Ви хочете, щоб завантажений Вами файл був опрацьований асинхронно, спершу завантажте його у сховок (використавши параметр <var>stash</var>), а тоді опублікуйте цей підготовлений файл (використавши <var>filekey</var> та <var>async</var>).",
+ "apierror-cannotreauthenticate": "Ця діє недоступна, оскільки Вашу ідентичність неможливо перевірити.",
+ "apierror-cannotviewtitle": "Ви не маєте дозволу на перегляд $1.",
+ "apierror-cantblock-email": "Ви не маєте прав на блокування користувачам можливості надсилання електронної пошти через вікі.",
+ "apierror-cantblock": "Ви не маєте прав на блокування користувачів.",
+ "apierror-cantchangecontentmodel": "Ви не маєте прав на зміну контентної моделі сторінки.",
+ "apierror-canthide": "Ви не маєте прав на приховування імен користувачів у журналі блокувань.",
+ "apierror-cantimport-upload": "Ви не маєте прав на імпорт завантажених сторінок.",
+ "apierror-cantimport": "Ви не маєте прав на імпорт сторінок.",
+ "apierror-cantoverwrite-sharedfile": "Цільовий файл існує в загальному репозиторії, і Ви не маєте прав, щоб проігнорувати це.",
+ "apierror-cantsend": "Ви не увійшли в систему, не маєте підтвердженої електронної адреси, або не маєте дозволу на надсилання електронної пошти іншим користувачам, тож Ви не можете надсилати електронну пошту.",
+ "apierror-cantundelete": "Не вдалося відновити: запитувані версії або не існують, або їх вже було відновлено.",
+ "apierror-changeauth-norequest": "Не вдалося створити запит на зміну.",
+ "apierror-chunk-too-small": "Мінімальний розмір шматка становить $1 {{PLURAL:$1|байт|байти|байтів}} для некінцевих шматків.",
+ "apierror-cidrtoobroad": "$1 CIDR-діапазони, ширші ніж /$2, недозволені.",
+ "apierror-compare-no-title": "Неможливо попередньо зберегти трансформацію без назви. Спробуйте зазначити <var>fromtitle</var> або <var>totitle</var>.",
+ "apierror-compare-relative-to-nothing": "Відсутня версія 'from', якої б стосувалося <var>torelative</var>.",
+ "apierror-contentserializationexception": "Невдача серіалізації вмісту: $1",
+ "apierror-contenttoobig": "Наданий Вами вміст перевищує ліміт у $1 {{PLURAL:$1|кілобайт|кілобайти|кілобайтів}} розміру сторінки.",
+ "apierror-copyuploadbaddomain": "Завантаження за URL-адресою недозволені з цього домену.",
+ "apierror-copyuploadbadurl": "Завантаження з цієї URL-адреси недозволені.",
+ "apierror-create-titleexists": "Наявні назви не можна захистити за допомогою <kbd>create</kbd>.",
+ "apierror-csp-report": "Помилка при опрацюванні CSP-звіту: $1.",
+ "apierror-databaseerror": "[$1] Помилка запиту до бази даних.",
+ "apierror-deletedrevs-param-not-1-2": "Параметр <var>$1</var> не може використовуватись у режимах 1 або 2.",
+ "apierror-deletedrevs-param-not-3": "Параметр <var>$1</var> не може використовуватись у режимі 3.",
+ "apierror-emptynewsection": "Створення нових порожніх розділів неможливе.",
+ "apierror-emptypage": "Створення нових порожніх сторінок недозволене.",
+ "apierror-exceptioncaught": "[$1] Виявлено виняток: $2",
+ "apierror-filedoesnotexist": "Файл не існує.",
+ "apierror-fileexists-sharedrepo-perm": "Цільовий файл існує у загальному репозиторії. Використовуйте параметр <var>ignorewarnings</var>, щоб проігнорувати це.",
+ "apierror-filenopath": "Не вдалося отримати шлях до локального файлу.",
+ "apierror-filetypecannotberotated": "Тип файлу не можна повернути.",
+ "apierror-formatphp": "Цю відповідь не можна представити через <kbd>format=php</kbd>. Див. https://phabricator.wikimedia.org/T68776.",
+ "apierror-imageusage-badtitle": "Назва для <kbd>$1</kbd> має бути файлом.",
+ "apierror-import-unknownerror": "Невідома помилка при імпорті: $1.",
+ "apierror-integeroutofrange-abovebotmax": "<var>$1</var> не може мати значення понад $2 (встановлено як $3) для ботів чи адмінів.",
+ "apierror-integeroutofrange-abovemax": "<var>$1</var> не може мати значення понад $2 (встановлено як $3) для користувачів.",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var> не може мати значення менш як $2 (встановлено як $3).",
+ "apierror-invalidcategory": "Введена Вами назва категорії недійсна.",
+ "apierror-invalid-chunk": "Зміщення плюс поточний шматок мають більший розмір, аніж заявлений розмір файлу.",
+ "apierror-invalidexpiry": "Недійсний час завершення «$1».",
+ "apierror-invalid-file-key": "Недійсний ключ файлу.",
+ "apierror-invalidlang": "Недійсний код мови для параметра <var>$1</var>.",
+ "apierror-invalidoldimage": "Параметр <var>oldimage</var> має недійсний формат.",
+ "apierror-invalidparammix-cannotusewith": "Параметр <kbd>$1</kbd> не можна використовувати з <kbd>$2</kbd>.",
+ "apierror-invalidparammix-mustusewith": "Параметр <kbd>$1</kbd> можна використовувати тільки з <kbd>$2</kbd>.",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd> не можна поєднувати з параметрами <var>oldid</var>, <var>pageid</var> чи <var>page</var>. Будь ласка, використовуйте <var>title</var> і <var>text</var>.",
+ "apierror-invalidparammix": "{{PLURAL:$2|Ці параметри}} $1 не можна використовувати водночас.",
+ "apierror-invalidsection": "Параметр <var>section</var> має бути дійсним ідентифікатором розділу або <kbd>new</kbd>.",
+ "apierror-invalidsha1base36hash": "Поданий хеш SHA1Base36 недійсний.",
+ "apierror-invalidsha1hash": "Поданий хеш SHA1 недійсний.",
+ "apierror-invalidtitle": "Погана назва «$1».",
+ "apierror-invalidurlparam": "Недійсне значення для <var>$1urlparam</var> (<kbd>$2=$3</kbd>).",
+ "apierror-invaliduser": "Недійсне ім'я користувача «$1».",
+ "apierror-invaliduserid": "Ідентифікатор <var>$1</var> недійсний.",
+ "apierror-maxlag-generic": "Очікування на сервер бази даних: затримка $1 {{PLURAL:$1|секунда|секунди|секунд}}.",
+ "apierror-maxlag": "Очікування на $2: затримка $1 {{PLURAL:$1|секунда|секунди|секунд}}.",
+ "apierror-mimesearchdisabled": "MIME-пошук вимкнений у скупому режимі.",
+ "apierror-missingcontent-pageid": "Відсутній вміст для сторінки з ідентифікатором $1.",
+ "apierror-missingcontent-revid": "Відсутній контент для ідентифікатора версії $1.",
+ "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|Параметр|Щонайменше один параметрів}} $1 є обов'язковим.",
+ "apierror-missingparam-one-of": "{{PLURAL:$2|Параметр|Один з параметрів}} $1 є обов'язковим.",
+ "apierror-missingparam": "Параметр <var>$1</var> має бути заповнений.",
+ "apierror-missingrev-pageid": "Немає поточної версії сторінки з ідентифікатором $1.",
+ "apierror-missingrev-title": "Для назви $1 відсутня поточна версія.",
+ "apierror-missingtitle-createonly": "Відсутні назви можна захистити тільки через <kbd>create</kbd>.",
+ "apierror-missingtitle": "Вказана Вами сторінка не існує.",
+ "apierror-missingtitle-byname": "Сторінка $1 не існує.",
+ "apierror-moduledisabled": "Модуль <kbd>$1</kbd> було вимкнено.",
+ "apierror-multival-only-one-of": "{{PLURAL:$3|Лише значення|Лише одне значення з}} $2 дозволене для параметра <var>$1</var>.",
+ "apierror-multival-only-one": "Лише одне значення дозволене для параметра <var>$1</var>.",
+ "apierror-multpages": "<var>$1</var> може використовуватись тільки з однією сторінкою.",
+ "apierror-mustbeloggedin-changeauth": "Вам треба увійти в систему, щоб змінити автентифікаційні дані.",
+ "apierror-mustbeloggedin-generic": "Ви повинні перебувати в системі.",
+ "apierror-mustbeloggedin-linkaccounts": "Щоб зв'язувати облікові записи Вам треба увійти в систему.",
+ "apierror-mustbeloggedin-removeauth": "Щоб вилучати автентифікаційні дані Вам треба увійти в систему.",
+ "apierror-mustbeloggedin-uploadstash": "Сховок завантажень доступний тільки для зареєстрованих користувачів.",
+ "apierror-mustbeloggedin": "Для $1 Вам треба увійти в систему.",
+ "apierror-mustbeposted": "Модуль <kbd>$1</kbd> потребує запиту POST.",
+ "apierror-mustpostparams": "{{PLURAL:$2|Вказаний параметр було знайдено в рядку запиту, але має|Вказані параметри було знайдено в рядку запиту, але мають}} бути у тілі POST: $1.",
+ "apierror-noapiwrite": "Редагування цієї вікі через API вимкнено. Упевніться, що твердження <code>$wgEnableWriteAPI=true;</code> включено у файл <code>LocalSettings.php</code> цієї вікі.",
+ "apierror-nochanges": "На жодні зміни запиту не було.",
+ "apierror-nodeleteablefile": "Немає такої старої версії файлу.",
+ "apierror-no-direct-editing": "Пряме редагування через API не підтримується контентною моделлю $1, що використовується $2.",
+ "apierror-noedit-anon": "Анонімні користувачі не можуть редагувати сторінки.",
+ "apierror-noedit": "У Вас немає прав на редагування сторінок.",
+ "apierror-noimageredirect-anon": "Анонімні користувачі не можуть створювати перенаправлення на файли.",
+ "apierror-noimageredirect": "Ви не маєте прав на створення перенаправлень на файли.",
+ "apierror-nosuchlogid": "Немає журнального запису з ідентифікатором $1.",
+ "apierror-nosuchpageid": "Немає сторінки з ідентифікатором $1.",
+ "apierror-nosuchrcid": "Немає недавньої зміни з ідентифікатором $1.",
+ "apierror-nosuchrevid": "Немає версії з ідентифікатором $1.",
+ "apierror-nosuchsection": "Немає розділу $1.",
+ "apierror-nosuchsection-what": "Немає розділу $1 на сторінці $2.",
+ "apierror-nosuchuserid": "Немає користувача з ідентифікатором $1.",
+ "apierror-notarget": "Ви не вказали дійсної цілі для цієї дії.",
+ "apierror-notpatrollable": "Версія r$1 не може бути відпатрульована, оскільки вона надто стара.",
+ "apierror-nouploadmodule": "Не встановлено модуля завантаження.",
+ "apierror-offline": "Не вдалося продовжити через проблеми з підключенням до мережі. Перевірте підключення до інтернету й спробуйте ще раз.",
+ "apierror-opensearch-json-warnings": "Попередження не можуть бути представлені у форматі OpenSearch JSON.",
+ "apierror-pagecannotexist": "Простір назв не дозволяє фактичних сторінок.",
+ "apierror-pagedeleted": "Цю сторінку було вилучено після того, як Ви отримали її мітку часу.",
+ "apierror-pagelang-disabled": "Зміна мови сторінки заборонена в цій вікі.",
+ "apierror-paramempty": "Параметр <var>$1</var> не може бути порожнім.",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd> підтримується лише для вмісту у форматі вікірозмітки.",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd> підтримується лише для вмісту у форматі вікірозмітки. $1 використовує контентну модель $2.",
+ "apierror-pastexpiry": "Час закінчення «$1» — в минулому.",
+ "apierror-permissiondenied": "Ви не маєте прав на $1.",
+ "apierror-permissiondenied-generic": "Доступ заборонено.",
+ "apierror-permissiondenied-patrolflag": "Вам потрібне право <code>patrol</code> або <code>patrolmarks</code>, щоб подати запит на прапорець «відпатрульовано».",
+ "apierror-permissiondenied-unblock": "Ви не маєте прав на розблокування користувачів.",
+ "apierror-prefixsearchdisabled": "Пошук за префіксом вимкнено у скупому режимі.",
+ "apierror-promised-nonwrite-api": "HTTP-заголовок <code>Promise-Non-Write-API-Action</code> не можна надсилати до модулів API із режимом запису.",
+ "apierror-protect-invalidaction": "Недійсний тип захисту «$1».",
+ "apierror-protect-invalidlevel": "Недійсний рівень захисту «$1».",
+ "apierror-ratelimited": "Ви перевищили свій ліміт частоти. Будь ласка, почекайте деякий час і спробуйте знову.",
+ "apierror-readapidenied": "Вам потрібне право на читання, щоб використовувати цей модуль.",
+ "apierror-readonly": "Ця вікі наразі перебуває в режимі тільки для читання.",
+ "apierror-reauthenticate": "Ви останнім часом не проходили автентифікацію в цій сесії. Будь ласка, пройдіть автентифікацію ще раз.",
+ "apierror-redirect-appendonly": "Ви зробили спробу редагування з використанням режиму переходу за перенаправленням, який має використовуватись разом із <kbd>section=new</kbd>, <var>prependtext</var>, або <var>appendtext</var>.",
+ "apierror-revdel-mutuallyexclusive": "Одне й те ж поле не може використовуватись у <var>hide</var> і <var>show</var> водночас.",
+ "apierror-revdel-needtarget": "Для цього типу RevDel необхідна цільова назва.",
+ "apierror-revdel-paramneeded": "Щонайменше одне значення необхідне для <var>hide</var> та/або <var>show</var>.",
+ "apierror-revisions-badid": "Для параметра <var>$1</var> не знайдено жодної версії.",
+ "apierror-revisions-norevids": "Параметр <var>revids</var> не можна використовувати з опціями списку (<var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var>, і <var>$1end</var>).",
+ "apierror-revisions-singlepage": "Було використано <var>titles</var>, <var>pageids</var> або генератор для постачання декількох сторінок, але параметри <var>$1limit</var>, <var>$1startid</var>, <var>$1endid</var>, <kbd>$1dir=newer</kbd>, <var>$1user</var>, <var>$1excludeuser</var>, <var>$1start</var>, і <var>$1end</var> можуть використовуватися тільки на одній сторінці.",
+ "apierror-revwrongpage": "r$1 не є версією сторінки $2.",
+ "apierror-searchdisabled": "<var>$1</var> пошук вимкнено.",
+ "apierror-sectionreplacefailed": "Не вдалося об'єднати оновлений розділ.",
+ "apierror-sectionsnotsupported": "Розділи не підтримуються для контентної моделі $1.",
+ "apierror-sectionsnotsupported-what": "Розділи не підтримуються $1.",
+ "apierror-show": "Неправильний параметр — не можна надавати взаємовиключні значення.",
+ "apierror-siteinfo-includealldenied": "Не можна переглядати інформацію щодо всіх серверів, якщо тільки <var>$wgShowHostNames</var> не вказано як «true».",
+ "apierror-sizediffdisabled": "Різниця у розмірах вимкнена в скупому режимі.",
+ "apierror-spamdetected": "Ваше редагування було відхилено, оскільки воно містило фрагмент спаму: <code>$1</code>.",
+ "apierror-specialpage-cantexecute": "Ви не маєте прав на перегляд результатів цієї спеціальної сторінки.",
+ "apierror-stashedfilenotfound": "Не вдалося знайти файл у сховку: $1.",
+ "apierror-stashedit-missingtext": "У сховку не знайдено збереженого тексту із заданим хешем.",
+ "apierror-stashfailed-complete": "Завантаження по шматках вже завершилося, перегляньте статус, щоб дізнатися подробиці.",
+ "apierror-stashfailed-nosession": "Немає сесії завантажень по шматках із цим ключем.",
+ "apierror-stashfilestorage": "Не вдалося зберегти завантаження у сховку: $1",
+ "apierror-stashinvalidfile": "Недійсний файл у сховку.",
+ "apierror-stashnosuchfilekey": "Немає такого filekey: $1.",
+ "apierror-stashpathinvalid": "Ключ файлу має неправильний формат або є недійсним з іншої причини: $1.",
+ "apierror-stashwrongowner": "Неправильний власник: $1",
+ "apierror-stashzerolength": "Довжина файлу дорівнює нулю, і його не можна зберегти у сховку: $1.",
+ "apierror-systemblocked": "Вас автоматично заблоковано MediaWiki.",
+ "apierror-templateexpansion-notwikitext": "Розширення шаблонів підтримується лише для вмісту у форматі вікірозмітки. $1 використовує контентну модель $2.",
+ "apierror-timeout": "Сервер не відповів протягом відведеного на це часу.",
+ "apierror-toofewexpiries": "$1 {{PLURAL:$1|мітка часу завершення була надана|мітки часу завершення були надані|міток часу завершення було надано}}, тоді як {{PLURAL:$2|потрібна була $2 така мітка|потрібні були $2 таких мітки|потрібно було $2 таких міток}}.",
+ "apierror-unknownaction": "Вказана дія, <kbd>$1</kbd>, нерозпізнана.",
+ "apierror-unknownerror-editpage": "Невідома помилка EditPage: $1.",
+ "apierror-unknownerror-nocode": "Невідома помилка.",
+ "apierror-unknownerror": "Невідома помилка: «$1».",
+ "apierror-unknownformat": "Невідомий формат «$1».",
+ "apierror-unrecognizedparams": "{{PLURAL:$2|Нерозпізнаний параметр|Нерозпізнані параметри}}: $1.",
+ "apierror-unrecognizedvalue": "Нерозпізнане значення для параметра <var>$1</var>: $2.",
+ "apierror-unsupportedrepo": "Місцевий репозиторій файлів не підтримує запитів щодо всіх зображень.",
+ "apierror-upload-filekeyneeded": "Треба вказати <var>filekey</var>, коли <var>offset</var> є ненульовим.",
+ "apierror-upload-filekeynotallowed": "Не можна вказувати <var>filekey</var>, коли <var>offset</var> дорівнює 0.",
+ "apierror-upload-inprogress": "Завантаження зі сховку вже в процесі.",
+ "apierror-upload-missingresult": "Немає результатів у даних статусу.",
+ "apierror-urlparamnormal": "Не вдалося нормалізувати параметри зображення для $1.",
+ "apierror-writeapidenied": "Ви не маєте дозволу на редагування цієї вікі через API.",
+ "apiwarn-alldeletedrevisions-performance": "Для підвищення продуктивності при генеруванні назв, встановіть <kbd>$1dir=newer</kbd>.",
+ "apiwarn-badurlparam": "Не вдалося парсити <var>$1urlparam</var> для $2. Використовується лише ширина і висота.",
+ "apiwarn-badutf8": "Значення, вказане для <var>$1</var>, містить недійсні або ненормалізовані дані. Текстові дані мають бути дійсними, NFC-нормалізований Unicode без контрольних символів C0, окрім HT (\\t), LF (\\n), і CR (\\r).",
+ "apiwarn-checktoken-percentencoding": "Перевірте, чи символи, такі як «+» у токені, пройшли правильне процентне кодування в URL.",
+ "apiwarn-compare-nocontentmodel": "Не вдалося визначити контентну модель, припускаємо $1.",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd> є застарілим. Будь ласка, використовуйте замість нього <kbd>prop=deletedrevisions</kbd> або <kbd>list=alldeletedrevisions</kbd>.",
+ "apiwarn-deprecation-expandtemplates-prop": "Оскільки не задано жодних значень для параметра <var>prop</var>, як вихідні дані було використано старий формат. Цей формат є застарілим, і в майбутньому параметру <var>prop</var> буде задано стандартне значення, наслідком чого стане те, що завжди використовуватиметься новий формат.",
+ "apiwarn-deprecation-httpsexpected": "Використано HTTP, тоді як очікувалося використання HTTPS.",
+ "apiwarn-deprecation-login-botpw": "Вхід в основний обліковий запис через <kbd>action=login</kbd> є застарілим, і може припинити працювати, без попередження. Щоб продовжити вхід у систему через <kbd>action=login</kbd>, див. [[Special:BotPasswords]]. Щоб безпечно продовжити, використовуючи вхід в основний обліковий запис, див. <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-nobotpw": "Вхід в основний обліковий запис через <kbd>action=login</kbd> є застарілим, і може припинити працювати, без попередження. Щоб безпечно увійти в систему, див. <kbd>action=clientlogin</kbd>.",
+ "apiwarn-deprecation-login-token": "Отримання токена через <kbd>action=login</kbd> є застарілим. Використовуйте натомість <kbd>action=query&meta=tokens&type=login</kbd>.",
+ "apiwarn-deprecation-parameter": "Параметр <var>$1</var> — застарілий.",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd> є застарілим, починаючи з MediaWiki 1.28. Використовуйте <kbd>prop=headhtml</kbd> при створенні нових документів HTML, або <kbd>prop=modules|jsconfigvars</kbd> при оновленні документа з боку клієнта.",
+ "apiwarn-deprecation-purge-get": "Використання <kbd>action=purge</kbd> через GET є застарілим. Використовуйте POST замість цього.",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd> є застарілим. Будь ласка, використовуйте натомість <kbd>$2</kbd>.",
+ "apiwarn-difftohidden": "Не вдалося відкрити версію r$1: вміст приховано.",
+ "apiwarn-errorprinterfailed": "Невдача через помилку принтера. Буде здійснено повторну спробу без параметрів.",
+ "apiwarn-errorprinterfailed-ex": "Невдача через помилку принтера (буде здійснено повторну спробу без параметрів): $1",
+ "apiwarn-invalidcategory": "«$1» не є категорією.",
+ "apiwarn-invalidtitle": "«$1» не є коректною назвою.",
+ "apiwarn-invalidxmlstylesheetext": "Таблиця стилів повинна мати розширення <code>.xsl</code>.",
+ "apiwarn-invalidxmlstylesheet": "Вказана таблиця стилів є недійсною або не існує.",
+ "apiwarn-invalidxmlstylesheetns": "Таблиця стилів має перебувати в просторі назв {{ns:MediaWiki}}.",
+ "apiwarn-moduleswithoutvars": "Задано властивість <kbd>modules</kbd>, але не задано <kbd>jsconfigvars</kbd> або <kbd>encodedjsconfigvars</kbd>. Змінні конфігурації необхідні для коректного використання модуля.",
+ "apiwarn-notfile": "«$1» не є файлом.",
+ "apiwarn-nothumb-noimagehandler": "Не вдалося створити мініатюру, оскільки $1 не має пов'язаного обробника зображень.",
+ "apiwarn-parse-nocontentmodel": "Не задано <var>title</var> або <var>contentmodel</var>, буде використано $1.",
+ "apiwarn-parse-titlewithouttext": "<var>title</var> використано без <var>text</var>, і надіслано запит на оброблені властивості сторінки. Може \nВи хотіли використати <var>page</var> замість <var>title</var>?",
+ "apiwarn-redirectsandrevids": "Вирішення перенаправлень не може використовуватись разом з параметром <var>revids</var>. Усі перенаправлення, на які вказує <var>revids</var>, не було вирішено.",
+ "apiwarn-tokennotallowed": "Дія «$1» недозволена для поточного користувача.",
+ "apiwarn-tokens-origin": "Токени не можна отримати, поки не застосована політика одного походження.",
+ "apiwarn-toomanyvalues": "Надто багато значень задано для параметра <var>$1</var>. Ліміт становить $2.",
+ "apiwarn-truncatedresult": "Цей результат було скорочено, оскільки інакше він перевищив би ліміт у $1 байтів.",
+ "apiwarn-unclearnowtimestamp": "Вказування «$2» для параметра мітки часу <var>$1</var> є застарілим. Якщо з якоїсь причини Вам треба чітко вказати поточний час без вираховування його з боку клієнта, використайте <kbd>now</kbd>.",
+ "apiwarn-unrecognizedvalues": "{{PLURAL:$3|Нерозпізнане|Нерозпізнані}} значення для параметра <var>$1</var>: $2.",
+ "apiwarn-unsupportedarray": "Параметр <var>$1</var> використовує непідтримуваний синтаксис PHP-масиву.",
+ "apiwarn-urlparamwidth": "Ігнорування значення ширини, встановленого в <var>$1urlparam</var> ($2) на користь значення ширини, запозиченого із <var>$1urlwidth</var>/<var>$1urlheight</var> ($3).",
+ "apiwarn-validationfailed-badchars": "недійсні символи у ключі (лише <code>a-z</code>, <code>A-Z</code>, <code>0-9</code>, <code>_</code>, і <code>-</code> дозволені).",
+ "apiwarn-validationfailed-badpref": "недійсне налаштування.",
+ "apiwarn-validationfailed-cannotset": "не може бути встановлено цим модулем.",
+ "apiwarn-validationfailed-keytoolong": "ключ надто довгий (дозволено не більш як $1 байтів).",
+ "apiwarn-validationfailed": "Помилка перевірки для <kbd>$1</kbd>: $2",
+ "apiwarn-wgDebugAPI": "<strong>Попередження щодо безпеки</strong>: увімкнено <var>$wgDebugAPI</var>.",
+ "api-feed-error-title": "Помилка ($1)",
+ "api-usage-docref": "Див. $1 щодо використання API.",
+ "api-usage-mailinglist-ref": "Щоб взнавати про заплановані і остаточні критичні зміни API, підпишіться на розсилку mediawiki-api-announce тут: &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt;.",
+ "api-exception-trace": "$1 у $2($3)\n$4",
+ "api-credits-header": "Автор(и)",
+ "api-credits": "Розробники API:\n* Roan Kattouw (головний розробник вер. 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (творець, головний розробник вер. 2006 – вер. 2007)\n* Brad Jorsch (головний розробник 2013 – тепер)\n\nБудь ласка, надсилайте свої коментарі, пропозиції та запитання на mediawiki-api@lists.wikimedia.org\nабо зафайліть звіт про баґ на https://phabricator.wikimedia.org/."
+}
diff --git a/www/wiki/includes/api/i18n/ur.json b/www/wiki/includes/api/i18n/ur.json
new file mode 100644
index 00000000..c0f1bcfa
--- /dev/null
+++ b/www/wiki/includes/api/i18n/ur.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Obaid Raza"
+ ]
+ },
+ "apihelp-delete-example-simple": "حذف <kbd>صفحۂ اول</kbd>."
+}
diff --git a/www/wiki/includes/api/i18n/vi.json b/www/wiki/includes/api/i18n/vi.json
new file mode 100644
index 00000000..c3fd889a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/vi.json
@@ -0,0 +1,225 @@
+{
+ "@metadata": {
+ "authors": [
+ "Minh Nguyen",
+ "Max20091",
+ "Dinhxuanduyet",
+ "Macofe"
+ ]
+ },
+ "apihelp-main-param-action": "Tác vụ để thực hiện.",
+ "apihelp-main-param-format": "Định dạng của dữ liệu được cho ra.",
+ "apihelp-main-param-uselang": "Ngôn ngữ để sử dụng cho các bản dịch thông điệp. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> với <kbd>siprop=languages</kbd> trả về một danh sách các mã ngôn ngữ, hoặc định rõ <kbd>user</kbd> để sử dụng ngôn ngữ của người dùng hiện tại, hoặc định rõ <kbd>content</kbd> để sử dụng ngôn ngữ nội dung của wiki này.",
+ "apihelp-block-summary": "Cấm người dùng.",
+ "apihelp-block-param-user": "Tên truy nhập, địa chỉ IP hoặc dãi IP mà bạn muốn chặn.",
+ "apihelp-block-param-reason": "Lý do cấm.",
+ "apihelp-block-param-nocreate": "Cấm tạo tài khoản.",
+ "apihelp-block-param-reblock": "Nếu người dùng này đã bị cấm, ghi đè lên vụ cấm đã tồn tại.",
+ "apihelp-block-param-watchuser": "Xem người dùng hoặc địa chỉ IP của người dùng và trang thảo luận.",
+ "apihelp-block-example-ip-simple": "Khóa địa chỉ IP <kbd>192.0.2.5</kbd> trong ba ngày với lý do <kbd>khiển trách lần đầu</kbd>.",
+ "apihelp-checktoken-param-type": "Kiểu dấu hiệu được kiểm thử.",
+ "apihelp-checktoken-param-token": "Dấu hiệu để kiểm thử.",
+ "apihelp-checktoken-example-simple": "Kiểm thử dấu hiệu <kbd>csrf</kbd> có hợp lệ hay không.",
+ "apihelp-clearhasmsg-summary": "Xóa cờ <code>hasmsg</code> cho người dùng hiện tại.",
+ "apihelp-clearhasmsg-example-1": "Xóa cờ <code>hasmsg</code> cho người dùng hiện tại",
+ "apihelp-compare-param-fromtitle": "So sánh tiêu đề đầu tiên.",
+ "apihelp-compare-param-fromid": "So sánh ID trang đầu tiên.",
+ "apihelp-compare-param-fromrev": "So sánh sửa đổi đầu tiên.",
+ "apihelp-compare-param-totitle": "So sánh tiêu đề thứ hai.",
+ "apihelp-compare-param-toid": "So sánh ID tran thứ hai.",
+ "apihelp-compare-param-torev": "So sánh sửa đổi thứ hai.",
+ "apihelp-compare-example-1": "Tạo một so sánh giữa phiên bản 1 và 2.",
+ "apihelp-createaccount-summary": "Mở tài khoản mới.",
+ "apihelp-createaccount-param-name": "Tên người dùng.",
+ "apihelp-createaccount-param-password": "Mật khẩu (được bỏ qua nếu <var>$1mailpassword</var> được đặt).",
+ "apihelp-createaccount-param-domain": "Tên miền để xác thực bên ngoài (tùy chọn).",
+ "apihelp-createaccount-param-token": "Dấu hiệu mở tài khoản được lấy trong yêu cầu đầu tiên.",
+ "apihelp-createaccount-param-email": "Địa chỉ thư điện tử của thành viên (tùy chọn).",
+ "apihelp-createaccount-param-realname": "Tên thật của thành viên (tùy chọn).",
+ "apihelp-createaccount-param-mailpassword": "Nếu đặt bất kỳ giá trị nào, một mật khẩu ngẫu nhiên sẽ được gửi lại cho người dùng qua thư điện tử.",
+ "apihelp-createaccount-param-reason": "Lý do tùy chọn cho việc tạo tài khoản để đăng nhập.",
+ "apihelp-createaccount-param-language": "Mã ngôn ngữ để thiết lập mặc định cho người dùng (tùy chọn, mặc định là ngôn ngữ nội dung).",
+ "apihelp-createaccount-example-pass": "Tạo người dùng <kbd>người kiểm tra</kbd> với mật khẩu <kbd>test123</kbd>.",
+ "apihelp-createaccount-example-mail": "Tạo người dùng <kbd>người dùng thử gửi</kbd> và gửi một mật khẩu được tạo ra ngẫu nhiên qua thư điện tử.",
+ "apihelp-delete-summary": "Xóa trang.",
+ "apihelp-delete-param-title": "Xóa tiêu đề của trang. Không thể sử dụng cùng với <var>$1pageid</var>.",
+ "apihelp-delete-param-pageid": "Xóa ID của trang. Không thể sử dụng cùng với <var>$1title</var>.",
+ "apihelp-delete-param-watch": "Thêm trang vào danh sách theo dõi của người dùng hiện tại.",
+ "apihelp-delete-param-unwatch": "Bỏ trang này khỏi danh sách theo dõi của người dùng hiện tại.",
+ "apihelp-delete-example-simple": "Xóa <kbd>Main Page</kbd>.",
+ "apihelp-delete-example-reason": "Xóa <kbd>Main Page</kbd> với lý do <kbd>Preparing for move</kbd>.",
+ "apihelp-disabled-summary": "Mô đun này đã bị vô hiệu hóa.",
+ "apihelp-edit-summary": "Tạo và sửa trang.",
+ "apihelp-edit-param-section": "Số phần trang. <kbd>0</kbd> là phần đầu; <kbd>new</kbd> là phần mới.",
+ "apihelp-edit-param-sectiontitle": "Tên của phần mới.",
+ "apihelp-edit-param-text": "Nội dung trang.",
+ "apihelp-edit-param-summary": "Tóm lược sửa đổi. Cũng là tên phần khi $1section=new và $1sectiontitle không được đặt.",
+ "apihelp-edit-param-minor": "Sửa đổi nhỏ.",
+ "apihelp-edit-param-notminor": "Sửa đổi không nhỏ.",
+ "apihelp-edit-param-bot": "Đánh dấu sửa đổi này là do bot thực hiện.",
+ "apihelp-edit-param-createonly": "Không sửa đổi trang nếu nó đã tồn tại.",
+ "apihelp-edit-param-nocreate": "Gây lỗi nếu trang không tồn tại.",
+ "apihelp-edit-param-watch": "Thêm trang vào danh sách theo dõi của người dùng hiện tại.",
+ "apihelp-edit-param-unwatch": "Bỏ trang này khỏi danh sách theo dõi của người dùng hiện tại.",
+ "apihelp-edit-param-undo": "Hoàn tác sửa đổi này. Ghi đè $1text, $1prependtext và $ 1appendtext.",
+ "apihelp-edit-param-undoafter": "Hoàn tác tất cả các sửa đổi từ $1undo cho tới sửa đổi này. Nếu không được thiết lập, chỉ cần lùi lại một sửa đổi.",
+ "apihelp-edit-param-redirect": "Tự động giải quyết các chuyển hướng.",
+ "apihelp-edit-param-contentmodel": "Mô hình nội dung của nội dung mới.",
+ "apihelp-edit-example-edit": "Sửa đổi trang",
+ "apihelp-edit-example-prepend": "Đưa <kbd>_&#95;NOTOC_&#95;</kbd> vào đầu trang",
+ "apihelp-edit-example-undo": "Lùi sửa các thay đổi 13579–13585 và tự động tóm lược",
+ "apihelp-emailuser-summary": "Gửi thư cho người dùng.",
+ "apihelp-emailuser-param-target": "Người dùng để gửi thư điện tử cho.",
+ "apihelp-emailuser-param-subject": "Tiêu đề bức thư.",
+ "apihelp-emailuser-param-text": "Nội dung bức thư.",
+ "apihelp-emailuser-param-ccme": "Gửi bản sao của thư này cho tôi.",
+ "apihelp-emailuser-example-email": "Gửi thư điện tử cho thành viên <kbd>WikiSysop</kbd> với văn bản <kbd>Content</kbd>.",
+ "apihelp-expandtemplates-summary": "Bung tất cả bản mẫu trong văn bản wiki.",
+ "apihelp-expandtemplates-param-title": "Tên trang.",
+ "apihelp-expandtemplates-param-text": "Văn bản wiki để bung.",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "Wikitext mở rộng.",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "Cây phân tích XML của đầu vào.",
+ "apihelp-feedcontributions-summary": "Trả về nguồn cấp đóng góp người dùng.",
+ "apihelp-feedcontributions-param-feedformat": "Định dạng nguồn cấp.",
+ "apihelp-feedcontributions-param-user": "Người dùng nhận được những đóng góp gì.",
+ "apihelp-feedcontributions-param-namespace": "Không gian tên để lọc các khoản đóng góp của.",
+ "apihelp-feedcontributions-param-year": "Từ năm (trở về trước).",
+ "apihelp-feedcontributions-param-month": "Từ tháng (trở về trước).",
+ "apihelp-feedcontributions-param-tagfilter": "Lọc đóng góp có những thẻ này.",
+ "apihelp-feedcontributions-param-deletedonly": "Chỉ hiện các đóng góp đã xóa.",
+ "apihelp-feedcontributions-param-toponly": "Chỉ hiện các phiên bản mới nhất.",
+ "apihelp-feedcontributions-param-newonly": "Chỉ hiện các sửa đổi tạo trang.",
+ "apihelp-feedcontributions-example-simple": "Trả về các đóng góp của người dùng <kbd>Ví dụ</kbd>.",
+ "apihelp-feedrecentchanges-summary": "Trả về nguồn cấp thay đổi gần đây.",
+ "apihelp-feedrecentchanges-param-feedformat": "Định dạng nguồn cấp.",
+ "apihelp-feedrecentchanges-param-days": "Ngày để giới hạn kết quả.",
+ "apihelp-feedrecentchanges-param-limit": "Số kết quả lớn nhất để cho ra.",
+ "apihelp-feedrecentchanges-param-hideminor": "Ẩn thay đổi nhỏ.",
+ "apihelp-feedrecentchanges-param-hidebots": "Ẩn thay đổi do bot thực hiện.",
+ "apihelp-feedrecentchanges-param-hideanons": "Ẩn thay đổi do người dùng vô danh thực hiện.",
+ "apihelp-feedrecentchanges-param-hideliu": "Ẩn thay đổi do người dùng đăng nhập thực hiện.",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "Ẩn thay đổi đã tuần tra.",
+ "apihelp-feedrecentchanges-param-hidemyself": "Ẩn thay đổi do người dùng hiện tại thực hiện.",
+ "apihelp-feedrecentchanges-param-tagfilter": "Lọc theo thẻ.",
+ "apihelp-feedrecentchanges-example-simple": "Xem thay đổi gần đây.",
+ "apihelp-feedrecentchanges-example-30days": "Hiển thị các thay đổi trong 30 ngày gần đây.",
+ "apihelp-feedwatchlist-summary": "Trả về nguồn cấp danh sách theo dõi.",
+ "apihelp-feedwatchlist-param-feedformat": "Định dạng nguồn cấp.",
+ "apihelp-feedwatchlist-example-default": "Xem nguồn cấp danh sách theo dõi.",
+ "apihelp-filerevert-summary": "Phục hồi một tập tin sang một phiên bản cũ.",
+ "apihelp-filerevert-param-comment": "Tải lên bình luận.",
+ "apihelp-filerevert-param-archivename": "Tên lưu trữ của bản sửa đổi để trở lại .",
+ "apihelp-filerevert-example-revert": "Hoàn nguyên <kbd>Wiki.png</kbd> veef phiên bản <kbd>2011-03-05T15:27:40Z</kbd>.",
+ "apihelp-help-summary": "Hiển thị trợ giúp cho các mô-đun xác định.",
+ "apihelp-help-param-helpformat": "Định dạng của văn bản trợ giúp được cho ra.",
+ "apihelp-help-example-main": "Trợ giúp cho các mô-đun chính.",
+ "apihelp-help-example-recursive": "Tất cả trợ giúp trong một trang",
+ "apihelp-help-example-help": "Trợ giúp cho chính bản thân module trợ giúp",
+ "apihelp-help-example-query": "Trợ giúp cho hai module con truy vấn",
+ "apihelp-imagerotate-summary": "Xoay một hoặc nhiều hình ảnh.",
+ "apihelp-imagerotate-param-rotation": "Độ xoay hình ảnh theo chiều kim đồng hồ.",
+ "apihelp-imagerotate-example-simple": "Xoay <kbd>Tập tin:Ví dụ.jpg</kbd> <kbd>90</kbd> độ.",
+ "apihelp-imagerotate-example-generator": "Xoay tất cả các hình ảnh trong <kbd>Thể loại:Búng</kbd> <kbd>180</kbd> độ.",
+ "apihelp-import-param-summary": "Nhập tóm lược.",
+ "apihelp-import-param-xml": "Tập tin XML đã được tải lên.",
+ "apihelp-import-param-interwikisource": "Dành cho các nhập khẩu interwiki: wiki để nhập từ.",
+ "apihelp-login-param-name": "Tên người dùng.",
+ "apihelp-login-param-password": "Mật khẩu.",
+ "apihelp-login-param-domain": "Tên miền (tùy chọn).",
+ "apihelp-login-param-token": "Dấu hiệu đăng nhập được lấy trong yêu cầu đầu tiên.",
+ "apihelp-login-example-gettoken": "Lấy dấu hiệu đăng nhập",
+ "apihelp-login-example-login": "Đăng nhập",
+ "apihelp-logout-summary": "Thoát ra và xóa dữ liệu phiên làm việc.",
+ "apihelp-logout-example-logout": "Đăng xuất người dùng hiện tại",
+ "apihelp-mergehistory-summary": "Hợp nhất lịch sử trang.",
+ "apihelp-mergehistory-param-reason": "Lý do hợp nhất lịch sử.",
+ "apihelp-move-summary": "Di chuyển trang.",
+ "apihelp-move-param-to": "Đặt tiêu đề để đổi tên trang.",
+ "apihelp-move-param-reason": "Lý do đổi tên.",
+ "apihelp-move-param-movetalk": "Đổi tên trang thảo luận, nếu nó tồn tại.",
+ "apihelp-move-param-movesubpages": "Đổi tên trang con, nếu có thể áp dụng.",
+ "apihelp-move-param-noredirect": "Không tạo trang đổi hướng.",
+ "apihelp-move-param-ignorewarnings": "Bỏ qua tất cả các cảnh báo.",
+ "apihelp-opensearch-summary": "Tìm kiếm trong wiki qua giao thức OpenSearch.",
+ "apihelp-opensearch-param-search": "Chuỗi tìm kiếm.",
+ "apihelp-opensearch-param-limit": "Đa số kết quả để cho ra.",
+ "apihelp-opensearch-param-namespace": "Không gian tên để tìm kiếm.",
+ "apihelp-opensearch-param-suggest": "Không làm gì nếu <var> [[mw:Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var> sai/lỗi.",
+ "apihelp-opensearch-param-format": "Định dạng kết quả được cho ra.",
+ "apihelp-opensearch-example-te": "Tìm trang bắt đầu với <kbd>Te</kbd>.",
+ "apihelp-options-example-reset": "Mặc định lại các tùy chọn",
+ "apihelp-paraminfo-summary": "Lấy thông tin về các module API.",
+ "apihelp-paraminfo-param-helpformat": "Định dạng chuỗi trợ giúp.",
+ "apihelp-parse-param-summary": "Lời tóm lược để phân tích.",
+ "apihelp-parse-param-prop": "Những mẩu thông tin nào muốn có:",
+ "apihelp-parse-param-section": "Chỉ phân tích nội dung của số phần này.\n\nNếu có <kbd>new</kbd> thì phân tích <var>$1text</var> và <var>$1sectiontitle</var> như thể thêm phần mới vào trang.\n\nPhần <kbd>new</kbd> chỉ được chấp nhận khi định rõ <var>text</var>.",
+ "apihelp-parse-param-disablelimitreport": "Bỏ qua thông báo bộ tiền xử lý (“NewPP limit report”) khi cho ra kết quả bộ xử lý.",
+ "apihelp-parse-example-page": "Phân tích trang.",
+ "apihelp-parse-example-text": "Phân tích văn bản wiki.",
+ "apihelp-parse-example-texttitle": "Phân tích văn bản wiki theo tên trang.",
+ "apihelp-parse-example-summary": "Phân tích lời tóm lược.",
+ "apihelp-protect-example-protect": "Khóa trang.",
+ "apihelp-protect-example-unprotect": "Mở khóa trang bằng cách đặt hạn chế thành <kbd>all</kbd>.",
+ "apihelp-protect-example-unprotect2": "Mở khóa trang bằng cách không đặt hạn chế nào",
+ "apihelp-purge-param-forcelinkupdate": "Cập nhật các bảng liên kết.",
+ "apihelp-purge-example-generator": "Làm mới 10 trang đầu tiên trong không gian tên chính",
+ "apihelp-query-param-prop": "Các thuộc tính để lấy khi truy vấn các trang.",
+ "apihelp-query-param-list": "Các danh sách để lấy.",
+ "apihelp-query-param-meta": "Siêu dữ liệu để lấy.",
+ "apihelp-query+allcategories-summary": "Liệt kê tất cả các thể loại.",
+ "apihelp-query+allcategories-param-from": "Chọn thể loại để bắt đầu đếm.",
+ "apihelp-query+allcategories-param-to": "Chọn thể loại để dừng đếm.",
+ "apihelp-query+allcategories-param-dir": "Hướng xếp loại.",
+ "apihelp-query+allcategories-param-limit": "Có bao nhiêu thể loại được trả về.",
+ "apihelp-query+allfileusages-param-limit": "Có bao nhiêu số hạng mục được trả về.",
+ "apihelp-query+allimages-param-limit": "Có bao nhiêu hình ảnh trong tổng số được trả về.",
+ "apihelp-query+alllinks-param-limit": "Có bao nhiêu số hạng mục được trả về.",
+ "apihelp-query+allpages-param-limit": "Có bao nhiêu trang được trả về.",
+ "apihelp-query+allredirects-param-limit": "Có bao nhiêu số hạng mục được trả về.",
+ "apihelp-query+mystashedfiles-param-limit": "Có bao nhiêu tập tin nhận được.",
+ "apihelp-query+alltransclusions-param-limit": "Có bao nhiêu số hạng mục được trả về.",
+ "apihelp-query+allusers-param-limit": "Có bao nhiêu tên người dùng được trả về.",
+ "apihelp-query+backlinks-param-limit": "Tất cả có bao nhiêu trang trả về. Nếu <var>$1redirect</var> được kích hoạt, giới hạn áp dụng cho mỗi cấp độ riêng biệt (có nghĩa là lên đến 2*<var>$1limit</var> kết quả có thể được trả lại).",
+ "apihelp-query+categories-param-limit": "Có bao nhiêu thể loại được trả về.",
+ "apihelp-query+extlinks-param-limit": "Có bao nhiêu liên kết được trả về.",
+ "apihelp-query+exturlusage-param-limit": "Có bao nhiêu trang được trả về.",
+ "apihelp-query+filearchive-param-limit": "Tổng cộng có bao nhiêu hình ảnh được trả về.",
+ "apihelp-query+fileusage-param-limit": "Có bao nhiêu được trả về.",
+ "apihelp-query+images-param-limit": "Có bao nhiêu tập tin được trả về.",
+ "apihelp-query+langbacklinks-param-limit": "Tổng cộng có bao nhiêu trang được trả về.",
+ "apihelp-query+links-param-limit": "Có bao nhiêu liên kết được trả về.",
+ "apihelp-query+linkshere-param-limit": "Có bao nhiêu được trả về.",
+ "apihelp-query+logevents-param-limit": "Tổng cộng có bao nhiêu bài viết sự kiện được trả về.",
+ "apihelp-query+transcludedin-param-limit": "Có bao nhiêu được trả về.",
+ "apihelp-query+watchlist-param-limit": "Cả bao nhiêu kết quả được trả về trên mỗi yêu cầu.",
+ "apihelp-rollback-summary": "Lùi lại sửa đổi cuối cùng của trang này.",
+ "apihelp-rollback-extended-description": "Nếu người dùng cuối cùng đã sửa đổi trang này nhiều lần, tất cả chúng sẽ được lùi lại cùng một lúc.",
+ "apihelp-format-example-generic": "Cho ra kết quả truy vấn dưới dạng $1.",
+ "apihelp-json-summary": "Cho ra dữ liệu dưới dạng JSON.",
+ "apihelp-jsonfm-summary": "Cho ra dữ liệu dưới dạng JSON (định dạng bằng HTML).",
+ "apihelp-none-summary": "Không cho ra gì.",
+ "apihelp-rawfm-summary": "Cho ra dữ liệu bao gồm các phần tử gỡ lỗi dưới dạng JSON (định dạng bằng HTML).",
+ "apihelp-xml-summary": "Cho ra dữ liệu dưới dạng XML.",
+ "apihelp-xmlfm-summary": "Cho ra dữ liệu dưới dạng XML (định dạng bằng HTML).",
+ "api-format-title": "Kết quả API MediaWiki",
+ "api-help-title": "Trợ giúp về API MediaWiki",
+ "api-help-main-header": "Mô đun chính",
+ "api-help-flag-deprecated": "Mô đun này đã bị phản đối.",
+ "api-help-flag-readrights": "Mô đun này cần quyền đọc.",
+ "api-help-flag-writerights": "Mô đun này cần quyền ghi.",
+ "api-help-flag-mustbeposted": "Mô đun này chỉ có nhận các yêu cầu POST.",
+ "api-help-parameters": "{{PLURAL:$1|Tham số|Các tham số}}:",
+ "api-help-param-deprecated": "Bị phản đối.",
+ "api-help-param-required": "Tham số này là bắt buộc.",
+ "api-help-param-list": "{{PLURAL:$1|1=Một trong các giá trị|2=Các giá trị (phân tách bằng <kbd>{{!}}</kbd>)}}: $2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=Cần phải để trống|Cần phải để trống hoặc là $2}}",
+ "api-help-param-limit": "Không cho phép hơn $1.",
+ "api-help-param-limit2": "Không cho phép hơn $1 ($2 đối với các bot).",
+ "api-help-param-multi-separate": "Phân tách các giá trị bằng <kbd>|</kbd>.",
+ "api-help-param-default": "Mặc định: $1",
+ "api-help-param-default-empty": "Mặc định: <span class=\"apihelp-empty\">(trống)</span>",
+ "api-help-examples": "{{PLURAL:$1|Ví dụ|Các ví dụ}}:",
+ "api-help-permissions": "{{PLURAL:$1|Quyền hạn|Các quyền hạn}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1}}Cấp cho: $2",
+ "api-credits-header": "Ghi công"
+}
diff --git a/www/wiki/includes/api/i18n/wuu.json b/www/wiki/includes/api/i18n/wuu.json
new file mode 100644
index 00000000..7df8a48d
--- /dev/null
+++ b/www/wiki/includes/api/i18n/wuu.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "反共复国"
+ ]
+ },
+ "apihelp-main-param-action": "要执行个操作。"
+}
diff --git a/www/wiki/includes/api/i18n/yi.json b/www/wiki/includes/api/i18n/yi.json
new file mode 100644
index 00000000..c2acd421
--- /dev/null
+++ b/www/wiki/includes/api/i18n/yi.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "פוילישער"
+ ]
+ },
+ "apihelp-main-param-action": "וועלכע אקציע אויסצופירן.",
+ "apihelp-main-param-format": "פארמאט פונעם אויסגאב.",
+ "api-help-source": "מקור: $1"
+}
diff --git a/www/wiki/includes/api/i18n/zh-hans.json b/www/wiki/includes/api/i18n/zh-hans.json
new file mode 100644
index 00000000..ef6fa601
--- /dev/null
+++ b/www/wiki/includes/api/i18n/zh-hans.json
@@ -0,0 +1,1760 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gaoxuewei",
+ "Linforest",
+ "Liuxinyu970226",
+ "Papapasan",
+ "LNDDYL",
+ "Shizhao",
+ "Yfdyh000",
+ "JuneAugsut",
+ "EagerLin",
+ "Simon xianyu",
+ "Kuailong",
+ "Zhxy 519",
+ "御坂美琴",
+ "RyRubyy",
+ "Apflu",
+ "Hzy980512",
+ "PhiLiP",
+ "Arthur2e5",
+ "損齋",
+ "Myy730",
+ "D41D8CD98F",
+ "Umherirrender"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|文档]]\n* [[mw:Special:MyLanguage/API:FAQ|常见问题]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api 邮件列表]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API公告]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R 程序错误与功能请求]\n</div>\n<strong>状态信息:</strong>本页所展示的所有特性都应正常工作,但是API仍在开发当中,将会随时变化。请订阅[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mediawiki-api-announce 邮件列表]以便获得更新通知。\n\n<strong>错误请求:</strong>当API收到错误请求时,HTTP header将会返回一个包含\"MediaWiki-API-Error\"的值,随后header的值与error code将会送回并设置为相同的值。详细信息请参阅[[mw:Special:MyLanguage/API:Errors_and_warnings|API:错误与警告]]。\n\n<strong>测试中:</strong>测试API请求的易用性,请参见[[Special:ApiSandbox]]。",
+ "apihelp-main-param-action": "要执行的操作。",
+ "apihelp-main-param-format": "输出的格式。",
+ "apihelp-main-param-maxlag": "最大延迟可被用于MediaWiki安装于数据库复制集中。要保存导致更多网站复制延迟的操作,此参数可使客户端等待直到复制延迟少于指定值时。万一发生过多延迟,错误代码<samp>maxlag</samp>会返回消息,例如<samp>等待$host中:延迟$lag秒</samp>。<br />参见[[mw:Special:MyLanguage/Manual:Maxlag_parameter|手册:Maxlag参数]]以获取更多信息。",
+ "apihelp-main-param-smaxage": "设置<code>s-maxage</code> HTTP缓存控制头至这些秒。错误不会缓存。",
+ "apihelp-main-param-maxage": "设置<code>max-age</code> HTTP缓存控制头至这些秒。错误不会缓存。",
+ "apihelp-main-param-assert": "如果设置为<kbd>user</kbd>就验证用户是否登录,或如果设置为<kbd>bot</kbd>就验证是否有机器人用户权限。",
+ "apihelp-main-param-assertuser": "验证当前用户是命名用户。",
+ "apihelp-main-param-requestid": "任何在此提供的值将包含在响应中。可以用以区别请求。",
+ "apihelp-main-param-servedby": "包含保存结果请求的主机名。",
+ "apihelp-main-param-curtimestamp": "在结果中包括当前时间戳。",
+ "apihelp-main-param-responselanginfo": "包含在结果中用于<var>uselang</var>和<var>errorlang</var>的语言。",
+ "apihelp-main-param-origin": "当通过跨域名AJAX请求(CORS)访问API时,设置此作为起始域名。这必须包括在任何pre-flight请求中,并因此必须是请求的URI的一部分(而不是POST正文)。\n\n对于已验证的请求,这必须正确匹配<code>Origin</code>标头中的原点之一,因此它已经设置为像<kbd>https://en.wikipedia.org</kbd>或<kbd>https://meta.wikimedia.org</kbd>的东西。如果此参数不匹配<code>Origin</code>页顶,就返回403错误响应。如果此参数匹配<code>Origin</code>页顶并且起点被白名单,将设置<code>Access-Control-Allow-Origin</code>和<code>Access-Control-Allow-Credentials</code>开头。\n\n对于未验证的请求,会指定值<kbd>*</kbd>。这将导致<code>Access-Control-Allow-Origin</code>标头被设置,但<code>Access-Control-Allow-Credentials</code>将为<code>false</code>,且所有用户特定数据将受限制。",
+ "apihelp-main-param-uselang": "用于消息翻译的语言。<kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>与<kbd>siprop=languages</kbd>可返回语言代码列表,或指定<kbd>user</kbd>以使用当前用户的语言设置,或指定<kbd>content</kbd>以使用此wiki的内容语言。",
+ "apihelp-main-param-errorformat": "用于警告和错误文本输出的格式。\n; plaintext:已移除HTML标签,并被替换实体的Wiki文本。\n; wikitext:未解析的wiki文本。\n; html:HTML。\n; raw:消息关键词和参数。\n; none:无文本输出,仅包含错误代码。\n; bc:在MediaWiki 1.29以前版本使用的格式。<var>errorlang</var>和<var>errorsuselocal</var>会被忽略。",
+ "apihelp-main-param-errorlang": "用于警告和错误的语言。<kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>带<kbd>siprop=languages</kbd>返回语言代码的列表,或指定<kbd>content</kbd>以使用此wiki的内容语言,或指定<kbd>uselang</kbd>以使用与<var>uselang</var>参数相同的值。",
+ "apihelp-main-param-errorsuselocal": "如果指定,错误文本将使用来自{{ns:MediaWiki}}名字空间的本地自定义消息。",
+ "apihelp-block-summary": "封禁一位用户。",
+ "apihelp-block-param-user": "要封禁的用户、IP地址或IP地址段。不能与<var>$1userid</var>一起使用",
+ "apihelp-block-param-userid": "要封禁的用户ID。不能与<var>$1user</var>一起使用。",
+ "apihelp-block-param-expiry": "到期时间。可以是相对时间(例如<kbd>5 months</kbd>或<kbd>2 weeks</kbd>)或绝对时间(例如<kbd>2014-09-18T12:34:56Z</kbd>)。如果设置为<kbd>infinite</kbd>、<kbd>indefinite</kbd>或<kbd>never</kbd>,封禁将无限期。",
+ "apihelp-block-param-reason": "封禁的原因。",
+ "apihelp-block-param-anononly": "只封禁匿名用户(也就是说禁止此 IP 地址的匿名编辑)。",
+ "apihelp-block-param-nocreate": "防止创建帐户。",
+ "apihelp-block-param-autoblock": "自动封禁最近使用的IP地址,以及以后他们尝试登陆使用的IP地址。",
+ "apihelp-block-param-noemail": "阻止用户通过wiki发送电子邮件。(需要<code>blockemail</code>权限)。",
+ "apihelp-block-param-hidename": "从封禁日志中隐藏用户名。(需要<code>hideuser</code>权限)。",
+ "apihelp-block-param-allowusertalk": "允许用户编辑自己的讨论页(取决于<var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>)。",
+ "apihelp-block-param-reblock": "如果该用户已被封禁,则覆盖已有的封禁。",
+ "apihelp-block-param-watchuser": "监视用户或该 IP 的用户页和讨论页。",
+ "apihelp-block-param-tags": "要在封禁日志中应用到实体的更改标签。",
+ "apihelp-block-example-ip-simple": "封禁IP地址<kbd>192.0.2.5</kbd>三天,原因<kbd>First strike</kbd>。",
+ "apihelp-block-example-user-complex": "无限期封禁用户<kbd>Vandal</kbd>,原因<kbd>Vandalism</kbd>,并阻止新账户创建和电子邮件发送。",
+ "apihelp-changeauthenticationdata-summary": "更改当前用户的身份验证数据。",
+ "apihelp-changeauthenticationdata-example-password": "尝试更改当前用户的密码至<kbd>ExamplePassword</kbd>。",
+ "apihelp-checktoken-summary": "从<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>检查令牌有效性。",
+ "apihelp-checktoken-param-type": "已开始测试的令牌类型。",
+ "apihelp-checktoken-param-token": "要测试的令牌。",
+ "apihelp-checktoken-param-maxtokenage": "令牌的最大允许年龄,以秒计。",
+ "apihelp-checktoken-example-simple": "测试<kbd>csrf</kbd>令牌的有效性。",
+ "apihelp-clearhasmsg-summary": "清除当前用户的<code>hasmsg</code>标记。",
+ "apihelp-clearhasmsg-example-1": "清除当前用户的<code>hasmsg</code>标记。",
+ "apihelp-clientlogin-summary": "使用交互式流登录wiki。",
+ "apihelp-clientlogin-example-login": "开始作为用户<kbd>Example</kbd>和密码<kbd>ExamplePassword</kbd>登录至wiki的过程。",
+ "apihelp-clientlogin-example-login2": "在<samp>UI</samp>响应双因素验证后继续登录,补充<var>OATHToken</var> <kbd>987654</kbd>。",
+ "apihelp-compare-summary": "获取两页面之间的差异。",
+ "apihelp-compare-extended-description": "必须传递“from”和“to”之间的修订版本号、页面标题、页面ID、文本或相关参考资料。",
+ "apihelp-compare-param-fromtitle": "要比较的第一个标题。",
+ "apihelp-compare-param-fromid": "要比较的第一个页面 ID。",
+ "apihelp-compare-param-fromrev": "要比较的第一个修订版本。",
+ "apihelp-compare-param-fromtext": "使用该文本而不是由<var>fromtitle</var>、<var>fromid</var>或<var>fromrev</var>指定的修订版本内容。",
+ "apihelp-compare-param-frompst": "在<var>fromtext</var>执行预保存转变。",
+ "apihelp-compare-param-fromcontentmodel": "<var>fromtext</var>的内容模型。如果未指定,这将基于其他参数猜想。",
+ "apihelp-compare-param-fromcontentformat": "<var>fromtext</var>的内容序列化格式。",
+ "apihelp-compare-param-totitle": "要比较的第二个标题。",
+ "apihelp-compare-param-toid": "要比较的第二个页面 ID。",
+ "apihelp-compare-param-torev": "要比较的第二个修订版本。",
+ "apihelp-compare-param-torelative": "使用与定义自<var>fromtitle</var>、<var>fromid</var>或<var>fromrev</var>的修订版本相关的修订版本。所有其他“to”的选项将被忽略。",
+ "apihelp-compare-param-totext": "使用该文本而不是由<var>totitle</var>、<var>toid</var>或<var>torev</var>指定的修订版本内容。",
+ "apihelp-compare-param-topst": "在<var>totext</var>执行预保存转换。",
+ "apihelp-compare-param-tocontentmodel": "<var>totext</var>的内容模型。如果未指定,这将基于其他参数猜想。",
+ "apihelp-compare-param-tocontentformat": "<var>totext</var>的内容序列化格式。",
+ "apihelp-compare-param-prop": "要获取的信息束。",
+ "apihelp-compare-paramvalue-prop-diff": "差异HTML。",
+ "apihelp-compare-paramvalue-prop-diffsize": "差异HTML的大小(字节)。",
+ "apihelp-compare-paramvalue-prop-rel": "“from”之前及“to”之后修订版本的修订ID,如果有。",
+ "apihelp-compare-paramvalue-prop-ids": "“from”和“to”修订版本的页面及修订ID。",
+ "apihelp-compare-paramvalue-prop-title": "“from”和“to”修订版本的页面标题。",
+ "apihelp-compare-paramvalue-prop-user": "“from”和“to”修订版本的用户名和ID。",
+ "apihelp-compare-paramvalue-prop-comment": "“from”和“to”修订版本的注释。",
+ "apihelp-compare-paramvalue-prop-parsedcomment": "“from”和“to”修订版本的已解析注释。",
+ "apihelp-compare-paramvalue-prop-size": "“from”和“to”修订版本的大小。",
+ "apihelp-compare-example-1": "在版本1和2中创建差异。",
+ "apihelp-createaccount-summary": "创建一个新用户账户。",
+ "apihelp-createaccount-param-preservestate": "如果<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>返回用于<samp>hasprimarypreservedstate</samp>的真值,标记为<samp>primary-required</samp>的请求应被忽略。如果它返回用于<samp>preservedusername</samp>的非空值,用户名必须用于<var>username</var>参数。",
+ "apihelp-createaccount-example-create": "开始创建用户<kbd>Example</kbd>和密码<kbd>ExamplePassword</kbd>的过程。",
+ "apihelp-createaccount-param-name": "用户名。",
+ "apihelp-createaccount-param-password": "密码(如果设置<var>$1mailpassword</var>则忽略)。",
+ "apihelp-createaccount-param-domain": "外部身份验证域 (可选)。",
+ "apihelp-createaccount-param-token": "在第一个请求中获得的帐户创建标记。",
+ "apihelp-createaccount-param-email": "用户的电子邮件地址(可选)。",
+ "apihelp-createaccount-param-realname": "用户的真实姓名(可选)。",
+ "apihelp-createaccount-param-mailpassword": "如果设置为任何值,将向用户发送一个随机密码。",
+ "apihelp-createaccount-param-reason": "将要放在日志中的,关于创建帐户的可选原因。",
+ "apihelp-createaccount-param-language": "要为用户设置为默认值的语言代码(可选,默认为内容语言)。",
+ "apihelp-createaccount-example-pass": "创建用户<kbd>testuser</kbd>和密码<kbd>test123</kbd>。",
+ "apihelp-createaccount-example-mail": "创建用户<kbd>testmailuser</kbd>并电邮发送一个随机生成的密码。",
+ "apihelp-cspreport-summary": "由浏览器使用以报告违反内容安全方针的内容。此模块应永不使用,除了在被CSP兼容的浏览器自动使用时。",
+ "apihelp-cspreport-param-reportonly": "标记作为来自监视方针的报告,而不是执行方针的报告",
+ "apihelp-cspreport-param-source": "生成引发此报告的CSP标头的事物",
+ "apihelp-delete-summary": "删除一个页面。",
+ "apihelp-delete-param-title": "要删除的页面标题。不能与<var>$1pageid</var>一起使用。",
+ "apihelp-delete-param-pageid": "要删除的页面的页面 ID。不能与<var>$1title</var>一起使用。",
+ "apihelp-delete-param-reason": "删除原因。如果未设置,将使用一个自动生成的原因。",
+ "apihelp-delete-param-tags": "要在删除日志中应用到实体的更改标签。",
+ "apihelp-delete-param-watch": "将该页面加入当前用户的监视列表。",
+ "apihelp-delete-param-watchlist": "无条件地将页面加入至当前用户的监视列表或将其移除,使用设置或不更改监视。",
+ "apihelp-delete-param-unwatch": "将该页面从当前用户的监视列表删除。",
+ "apihelp-delete-param-oldimage": "由[[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]]提供的要删除的旧图片名称。",
+ "apihelp-delete-example-simple": "删除<kbd>Main Page</kbd>。",
+ "apihelp-delete-example-reason": "删除<kbd>Main Page</kbd>,原因<kbd>Preparing for move</kbd>。",
+ "apihelp-disabled-summary": "此模块已禁用。",
+ "apihelp-edit-summary": "创建和编辑页面。",
+ "apihelp-edit-param-title": "要编辑的页面标题。不能与<var>$1pageid</var>一起使用。",
+ "apihelp-edit-param-pageid": "要编辑的页面的页面 ID。不能与<var>$1title</var>一起使用。",
+ "apihelp-edit-param-section": "段落数。<kbd>0</kbd>用于首段,<kbd>new</kbd>用于新的段落。",
+ "apihelp-edit-param-sectiontitle": "新段落的标题。",
+ "apihelp-edit-param-text": "页面内容。",
+ "apihelp-edit-param-summary": "编辑摘要。当$1section=new且未设置$1sectiontitle时,还包括小节标题。",
+ "apihelp-edit-param-tags": "应用到此修订的更改标签。",
+ "apihelp-edit-param-minor": "小编辑。",
+ "apihelp-edit-param-notminor": "不是小编辑。",
+ "apihelp-edit-param-bot": "标记此编辑为机器人编辑。",
+ "apihelp-edit-param-basetimestamp": "基础修订的时间戳,用于检测编辑冲突。可以通过[[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]]得到。",
+ "apihelp-edit-param-starttimestamp": "编辑过程开始的时间戳,用于检测编辑冲突。当开始编辑过程时(例如当加载要编辑的页面时)使用<var>[[Special:ApiHelp/main|curtimestamp]]</var>可能取得一个适当的值。",
+ "apihelp-edit-param-recreate": "覆盖有关该页面在此期间已被删除的任何错误。",
+ "apihelp-edit-param-createonly": "不要编辑页面,如果已经存在。",
+ "apihelp-edit-param-nocreate": "如果该页面不存在,则抛出一个错误。",
+ "apihelp-edit-param-watch": "将页面加入当前用户的监视列表。",
+ "apihelp-edit-param-unwatch": "将页面从当前用户的监视列表中移除。",
+ "apihelp-edit-param-watchlist": "无条件地将页面加入至当前用户的监视列表或将其移除,使用设置或不更改监视。",
+ "apihelp-edit-param-md5": "$1text参数或$1prependtext和$1appendtext级联参数的MD5哈希值。如果设置,除非哈希值正确否则编辑无法完成。",
+ "apihelp-edit-param-prependtext": "将该文本添加到该页面的开始。覆盖$1text。",
+ "apihelp-edit-param-appendtext": "将该文本添加到该页面的结尾。覆盖$1text。\n\n采用$1section=new来添加一个新的章节,而不是这个参数。",
+ "apihelp-edit-param-undo": "撤销此次修订。覆盖$1text、$1prependtext和$1appendtext。",
+ "apihelp-edit-param-undoafter": "撤销从$1undo至此的所有修订。如果不设置就撤销一次修订。",
+ "apihelp-edit-param-redirect": "自动解决重定向。",
+ "apihelp-edit-param-contentformat": "用于输入文本的内容序列化格式。",
+ "apihelp-edit-param-contentmodel": "新内容的内容模型。",
+ "apihelp-edit-param-token": "令牌应总是发送为最后参数,或至少在$1text参数之后。",
+ "apihelp-edit-example-edit": "编辑一个页面。",
+ "apihelp-edit-example-prepend": "页面中预置<kbd>_&#95;NOTOC_&#95;</kbd>。",
+ "apihelp-edit-example-undo": "撤销修订版本13579至13585并自动填写编辑摘要。",
+ "apihelp-emailuser-summary": "电子邮件联系一位用户。",
+ "apihelp-emailuser-param-target": "电子邮件的目标用户。",
+ "apihelp-emailuser-param-subject": "主题页眉。",
+ "apihelp-emailuser-param-text": "邮件正文。",
+ "apihelp-emailuser-param-ccme": "给我发送一份该邮件的副本。",
+ "apihelp-emailuser-example-email": "向用户<kbd>WikiSysop</kbd>发送邮件,带文字<kbd>Content</kbd>。",
+ "apihelp-expandtemplates-summary": "展开wiki文本中的所有模板。",
+ "apihelp-expandtemplates-param-title": "页面标题。",
+ "apihelp-expandtemplates-param-text": "要转换的wiki文本。",
+ "apihelp-expandtemplates-param-revid": "修订版本ID,用于<code><nowiki>{{REVISIONID}}</nowiki></code>和类似变体。",
+ "apihelp-expandtemplates-param-prop": "要获取的那条信息。\n\n注意如果没有选定值,结果将包含wiki文本,但将以弃用的格式显示。",
+ "apihelp-expandtemplates-paramvalue-prop-wikitext": "展开后的wiki文本。",
+ "apihelp-expandtemplates-paramvalue-prop-categories": "任何在输出中提供的,未在wiki文本输出中表现的分类。",
+ "apihelp-expandtemplates-paramvalue-prop-properties": "由wiki文本中扩充的魔术字定义的页面属性。",
+ "apihelp-expandtemplates-paramvalue-prop-volatile": "输出是否常常变动,是否不应被在页面中其他任何位置重用。",
+ "apihelp-expandtemplates-paramvalue-prop-ttl": "结果缓存应无效化后的最长时间。",
+ "apihelp-expandtemplates-paramvalue-prop-modules": "任何解析器函数请求添加至输出的ResourceLoader模块。<kbd>jsconfigvars</kbd>和<kbd>encodedjsconfigvars</kbd>之一必须与<kbd>modules</kbd>共同被请求。",
+ "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "针对页面提供JavaScript配置变量。",
+ "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "针对页面提供JavaScript配置变量为一个JSON字符串。",
+ "apihelp-expandtemplates-paramvalue-prop-parsetree": "输入的XML分析树。",
+ "apihelp-expandtemplates-param-includecomments": "输出时是否包含HTML注释。",
+ "apihelp-expandtemplates-param-generatexml": "生成XML解析树(取代自$1prop=parsetree)。",
+ "apihelp-expandtemplates-example-simple": "展开wiki文本<kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>。",
+ "apihelp-feedcontributions-summary": "返回用户贡献纲要。",
+ "apihelp-feedcontributions-param-feedformat": "纲要的格式。",
+ "apihelp-feedcontributions-param-user": "获取哪些用户的贡献。",
+ "apihelp-feedcontributions-param-namespace": "过滤哪些名字空间的贡献。",
+ "apihelp-feedcontributions-param-year": "起始年份(及更早)。",
+ "apihelp-feedcontributions-param-month": "起始月份(及更早)。",
+ "apihelp-feedcontributions-param-tagfilter": "过滤有这些标签的贡献者。",
+ "apihelp-feedcontributions-param-deletedonly": "仅显示已删除的贡献。",
+ "apihelp-feedcontributions-param-toponly": "仅仅显示那些作为最新修订的编辑。",
+ "apihelp-feedcontributions-param-newonly": "仅仅显示那些作为页面创建的编辑。",
+ "apihelp-feedcontributions-param-hideminor": "隐藏小编辑。",
+ "apihelp-feedcontributions-param-showsizediff": "显示修订版本之间的大小差别。",
+ "apihelp-feedcontributions-example-simple": "返回用户<kbd>Example</kbd>的贡献。",
+ "apihelp-feedrecentchanges-summary": "返回最近更改的摘要。",
+ "apihelp-feedrecentchanges-param-feedformat": "纲要的格式。",
+ "apihelp-feedrecentchanges-param-namespace": "用于限制结果的名字空间。",
+ "apihelp-feedrecentchanges-param-invert": "除所选定者外的所有名字空间。",
+ "apihelp-feedrecentchanges-param-associated": "包括相关的名字空间(讨论页或主要)。",
+ "apihelp-feedrecentchanges-param-days": "用于限制结果的天数。",
+ "apihelp-feedrecentchanges-param-limit": "所要返回结果的最大数目。",
+ "apihelp-feedrecentchanges-param-from": "显示自那时以来的更改。",
+ "apihelp-feedrecentchanges-param-hideminor": "隐藏小更改。",
+ "apihelp-feedrecentchanges-param-hidebots": "隐藏机器人所做的更改。",
+ "apihelp-feedrecentchanges-param-hideanons": "隐藏匿名用户做出的更改。",
+ "apihelp-feedrecentchanges-param-hideliu": "隐藏注册用户做出的更改。",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "隐藏已巡查更改。",
+ "apihelp-feedrecentchanges-param-hidemyself": "隐藏当前用户做出的更改。",
+ "apihelp-feedrecentchanges-param-hidecategorization": "隐藏分类成员更改。",
+ "apihelp-feedrecentchanges-param-tagfilter": "按标签过滤。",
+ "apihelp-feedrecentchanges-param-target": "仅仅显示从该页面链出的那些页面的变更。",
+ "apihelp-feedrecentchanges-param-showlinkedto": "仅仅显示链入到该页面的那些页面的变更。",
+ "apihelp-feedrecentchanges-param-categories": "只显示所有这些分类中的页面上的更改。",
+ "apihelp-feedrecentchanges-param-categories_any": "只显示这些分类以外页面的更改。",
+ "apihelp-feedrecentchanges-example-simple": "显示最近更改。",
+ "apihelp-feedrecentchanges-example-30days": "显示最近30天的更改。",
+ "apihelp-feedwatchlist-summary": "返回监视列表纲要。",
+ "apihelp-feedwatchlist-param-feedformat": "纲要的格式。",
+ "apihelp-feedwatchlist-param-hours": "列出从现在起数小时内修改的页面。",
+ "apihelp-feedwatchlist-param-linktosections": "如果可能的话,直接链接到已变更的小节。",
+ "apihelp-feedwatchlist-example-default": "显示监视列表订阅。",
+ "apihelp-feedwatchlist-example-all6hrs": "显示过去6小时内受监视页面的所有更改。",
+ "apihelp-filerevert-summary": "回退一个文件至某一旧版本。",
+ "apihelp-filerevert-param-filename": "目标文件名,不包含前缀“File:”。",
+ "apihelp-filerevert-param-comment": "上传评论。",
+ "apihelp-filerevert-param-archivename": "恢复到修订版存档名称。",
+ "apihelp-filerevert-example-revert": "回退<kbd>Wiki.png</kbd>至<kbd>2011-03-05T15:27:40Z</kbd>的版本。",
+ "apihelp-help-summary": "显示指定模块的帮助。",
+ "apihelp-help-param-modules": "用于显示帮助的模块(<var>action</var>和<var>format</var>参数值,或<kbd>main</kbd>)。可通过<kbd>+</kbd>指定子模块。",
+ "apihelp-help-param-submodules": "包括给定名称模块的子模块的帮助。",
+ "apihelp-help-param-recursivesubmodules": "包括递归子模块的帮助。",
+ "apihelp-help-param-helpformat": "帮助的输出格式。",
+ "apihelp-help-param-wrap": "在一个标准API响应结构中包裹输出。",
+ "apihelp-help-param-toc": "在HTML输出中包括目录。",
+ "apihelp-help-example-main": "主模块帮助。",
+ "apihelp-help-example-submodules": "用于<kbd>action=query</kbd>及其所有子模块的帮助。",
+ "apihelp-help-example-recursive": "一个页面中的所有帮助。",
+ "apihelp-help-example-help": "帮助模块本身的帮助。",
+ "apihelp-help-example-query": "两个查询子模块的帮助。",
+ "apihelp-imagerotate-summary": "旋转一幅或多幅图像。",
+ "apihelp-imagerotate-param-rotation": "顺时针旋转图像的度数。",
+ "apihelp-imagerotate-param-tags": "要在上传日志中应用到实体的标签。",
+ "apihelp-imagerotate-example-simple": "<kbd>90</kbd>度旋转<kbd>File:Example.png</kbd>。",
+ "apihelp-imagerotate-example-generator": "将<kbd>Category:Flip</kbd>之中的所有图像旋转<kbd>180</kbd>度。",
+ "apihelp-import-summary": "从其他wiki,或从XML文件导入页面。",
+ "apihelp-import-extended-description": "注意当发送用于<var>xml</var>参数的文件时,HTTP POST必须作为文件上传完成(即使用multipart/form-data)",
+ "apihelp-import-param-summary": "日志记录导入摘要。",
+ "apihelp-import-param-xml": "上传的XML文件。",
+ "apihelp-import-param-interwikisource": "用于跨wiki导入:导入的来源wiki。",
+ "apihelp-import-param-interwikipage": "用于跨wiki导入:导入的页面。",
+ "apihelp-import-param-fullhistory": "用于跨wiki导入:完整导入历史,而不只是最新版本。",
+ "apihelp-import-param-templates": "用于跨wiki导入:连带导入所有包含的模板。",
+ "apihelp-import-param-namespace": "导入至此名字空间。不能与<var>$1rootpage</var>一起使用。",
+ "apihelp-import-param-rootpage": "作为此页面的子页面导入。不能与<var>$1namespace</var>一起使用。",
+ "apihelp-import-param-tags": "要在导入日志,以及在导入页面的空修订版本中应用到实体的更改标签。",
+ "apihelp-import-example-import": "将页面[[meta:Help:ParserFunctions]]连带完整历史导入至100名字空间。",
+ "apihelp-linkaccount-summary": "将来自第三方提供商的账户链接至当前用户。",
+ "apihelp-linkaccount-example-link": "开始从<kbd>Example</kbd>链接至账户的过程。",
+ "apihelp-login-summary": "登录并获取身份验证cookie。",
+ "apihelp-login-extended-description": "此操作只应与[[Special:BotPasswords]]一起使用;用于主账户登录的方式已弃用,并可能在没有警告的情况下失败。要安全登录主账户,请使用<kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>。",
+ "apihelp-login-extended-description-nobotpasswords": "此操作已弃用,并可能在没有警告的情况下失败。要安全登录,请使用<kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>。",
+ "apihelp-login-param-name": "用户名。",
+ "apihelp-login-param-password": "密码。",
+ "apihelp-login-param-domain": "域名(可选)。",
+ "apihelp-login-param-token": "在首个请求中获得的登录令牌。",
+ "apihelp-login-example-gettoken": "检索登录令牌。",
+ "apihelp-login-example-login": "登录。",
+ "apihelp-logout-summary": "退出并清除会话数据。",
+ "apihelp-logout-example-logout": "退出当前用户。",
+ "apihelp-managetags-summary": "执行有关更改标签的管理任务。",
+ "apihelp-managetags-param-operation": "要执行哪个操作:\n;create:创建一个新的更改标签供手动使用。\n;delete:从数据库中移除一个更改标签,包括移除已使用在所有修订版本、最近更改记录和日志记录上的该标签。\n;activate:激活一个更改标签,允许用户手动应用它。\n;deactivate:停用一个更改标签,阻止用户手动应用它。",
+ "apihelp-managetags-param-tag": "要创建、删除、激活或取消激活的标签。要创建标签,标签必须不存在。要删除标签,标签必须存在。要激活标签,标签必须存在,且不被任何扩展使用。要取消激活标签,标签必须当前处于激活状态,且被手动定义。",
+ "apihelp-managetags-param-reason": "一个创建、删除、激活或停用标签时的原因,可选。",
+ "apihelp-managetags-param-ignorewarnings": "是否忽略操作期间发生的任何警告。",
+ "apihelp-managetags-param-tags": "要在标签管理日志中应用到实体的更改标签。",
+ "apihelp-managetags-example-create": "创建一个名为<kbd>spam</kbd>的标签,原因<kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-delete": "删除<kbd>vandlaism</kbd>标签,原因<kbd>Misspelt</kbd>",
+ "apihelp-managetags-example-activate": "激活一个名为<kbd>spam</kbd>的标签,原因<kbd>For use in edit patrolling</kbd>",
+ "apihelp-managetags-example-deactivate": "停用一个名为<kbd>spam</kbd>的标签,原因<kbd>No longer required</kbd>",
+ "apihelp-mergehistory-summary": "合并页面历史。",
+ "apihelp-mergehistory-param-from": "将被合并历史的页面的标题。不能与<var>$1fromid</var>一起使用。",
+ "apihelp-mergehistory-param-fromid": "将被合并历史的页面的页面ID。不能与<var>$1from</var>一起使用。",
+ "apihelp-mergehistory-param-to": "将要合并历史的页面的标题。不能与<var>$1toid</var>一起使用。",
+ "apihelp-mergehistory-param-toid": "将要合并历史的页面的页面ID。不能与<var>$1to</var>一起使用。",
+ "apihelp-mergehistory-param-timestamp": "指定时间戳,决定源页面的哪些修订历史被移动到目标页面的历史中。如果省略,源页面的所有历史记录都将被合并到目标页面。",
+ "apihelp-mergehistory-param-reason": "历史合并的原因。",
+ "apihelp-mergehistory-example-merge": "将<kbd>Oldpage</kbd>的完整历史合并至<kbd>Newpage</kbd>。",
+ "apihelp-mergehistory-example-merge-timestamp": "将<kbd>Oldpage</kbd>直到<kbd>2015-12-31T04:37:41Z</kbd>的页面修订版本合并至<kbd>Newpage</kbd>。",
+ "apihelp-move-summary": "移动一个页面。",
+ "apihelp-move-param-from": "要重命名的页面标题。不能与<var>$1fromid</var>一起使用。",
+ "apihelp-move-param-fromid": "您希望移动的页面ID。不能与<var>$1from</var>一起使用。",
+ "apihelp-move-param-to": "页面重命名的目标标题。",
+ "apihelp-move-param-reason": "重命名的原因。",
+ "apihelp-move-param-movetalk": "重命名讨论页,如果存在。",
+ "apihelp-move-param-movesubpages": "重命名子页面,如果可以。",
+ "apihelp-move-param-noredirect": "不要创建重定向。",
+ "apihelp-move-param-watch": "将页面和重定向加入至当前用户的监视列表中。",
+ "apihelp-move-param-unwatch": "从当前用户的监视列表中移除页面及重定向。",
+ "apihelp-move-param-watchlist": "无条件地将页面加入至当前用户的监视列表或将其移除,使用设置或不更改监视。",
+ "apihelp-move-param-ignorewarnings": "忽略任何警告。",
+ "apihelp-move-param-tags": "要在移动日志,以及在目标页面的空修订版本中应用到实体的更改标签。",
+ "apihelp-move-example-move": "移动<kbd>Badtitle</kbd>到<kbd>Goodtitle</kbd>,不保留重定向。",
+ "apihelp-opensearch-summary": "使用OpenSearch协议搜索wiki。",
+ "apihelp-opensearch-param-search": "搜索字符串。",
+ "apihelp-opensearch-param-limit": "要返回的结果最大数。",
+ "apihelp-opensearch-param-namespace": "搜索的名字空间。",
+ "apihelp-opensearch-param-suggest": "如果<var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var>设置为false则不做任何事情。",
+ "apihelp-opensearch-param-redirects": "如何处理重定向:\n;return:返回重定向本身。\n;resolve:返回目标页面。可能返回少于$1limit个结果。\n由于历史原因,$1format=json默认为\"return\",其他格式默认为\"resolve\"。",
+ "apihelp-opensearch-param-format": "输出格式。",
+ "apihelp-opensearch-param-warningsaserror": "如果警告通过<kbd>format=json</kbd>提升,返回一个API错误而不是忽略它们。",
+ "apihelp-opensearch-example-te": "查找以<kbd>Te</kbd>开头的页面。",
+ "apihelp-options-summary": "更改当前用户的参数设置。",
+ "apihelp-options-extended-description": "只有注册在核心或者已安装扩展中的选项,或者具有<code>userjs-</code>键值前缀(旨在被用户脚本使用)的选项可被设置。",
+ "apihelp-options-param-reset": "将参数设置重置为网站默认值。",
+ "apihelp-options-param-resetkinds": "当<var>$1reset</var>选项被设置时,要重置的选项类型列表。",
+ "apihelp-options-param-change": "更改列表,以name=value格式化(例如skin=vector)。如果没提供值(甚至没有等号),例如optionname|otheroption|...,选项将重置为默认值。如果任何传递的值包含管道字符(<kbd>|</kbd>),请改用[[Special:ApiHelp/main#main/datatypes|替代多值分隔符]]以正确操作。",
+ "apihelp-options-param-optionname": "应设置为由<var>$1optionvalue</var>提供值的选项名称。",
+ "apihelp-options-param-optionvalue": "用于由<var>$1optionname</var>指定的选项的值。",
+ "apihelp-options-example-reset": "重置所有用户设置。",
+ "apihelp-options-example-change": "更改<kbd>skin</kbd>和<kbd>hideminor</kbd>设置。",
+ "apihelp-options-example-complex": "重置所有设置,然后设置<kbd>skin</kbd>和<kbd>nickname</kbd>。",
+ "apihelp-paraminfo-summary": "获得关于API模块的信息。",
+ "apihelp-paraminfo-param-modules": "模块名称(<var>action</var>和<var>format</var>参数值,或<kbd>main</kbd>)的列表。可通过<kbd>+</kbd>指定子模块,或通过<kbd>+*</kbd>指定所有子模块,或通过<kbd>+**</kbd>指定所有递归子模块。",
+ "apihelp-paraminfo-param-helpformat": "帮助字符串的格式。",
+ "apihelp-paraminfo-param-querymodules": "查询模块名称(<var>prop</var>、<var>meta</var>或<var>list</var>参数值)的列表。使用<kbd>$1modules=query+foo</kbd>而不是<kbd>$1querymodules=foo</kbd>。",
+ "apihelp-paraminfo-param-mainmodule": "获取有关主要(最高级)模块的信息。也可使用<kbd>$1modules=main</kbd>。",
+ "apihelp-paraminfo-param-pagesetmodule": "获取有关页面设置模块(提供titles=和朋友)的信息。",
+ "apihelp-paraminfo-param-formatmodules": "格式模块名称(<var>format</var>参数的值)的列表。也可使用<var>$1modules</var>。",
+ "apihelp-paraminfo-example-1": "显示<kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>、<kbd>[[Special:ApiHelp/jsonfm|format=jsonfm]]</kbd>、<kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>和<kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd>的信息。",
+ "apihelp-paraminfo-example-2": "显示<kbd>[[Special:ApiHelp/query|action=query]]</kbd>的所有子模块的信息。",
+ "apihelp-parse-summary": "解析内容并返回解析器输出。",
+ "apihelp-parse-extended-description": "参见<kbd>[[Special:ApiHelp/query|action=query]]</kbd>的各种prop-module以从页面的当前版本获得信息。\n\n这里有几种方法可以指定解析的文本:\n# 指定一个页面或修订,使用<var>$1page</var>、<var>$1pageid</var>或<var>$1oldid</var>。\n# 明确指定内容,使用<var>$1text</var>、<var>$1title</var>和<var>$1contentmodel</var>。\n# 只指定一段摘要解析。<var>$1prop</var>应提供一个空值。",
+ "apihelp-parse-param-title": "文本属于的页面标题。如果省略,<var>$1contentmodel</var>就必须被指定,且[[API]]将作为标题使用。",
+ "apihelp-parse-param-text": "要解析的文本。使用<var>$1title</var>或<var>$1contentmodel</var>以控制内容模型。",
+ "apihelp-parse-param-summary": "要解析的摘要。",
+ "apihelp-parse-param-page": "解析此页的内容。不能与<var>$1text</var>和<var>$1title</var>一起使用。",
+ "apihelp-parse-param-pageid": "解析此页的内容。覆盖<var>$1page</var>。",
+ "apihelp-parse-param-redirects": "如果<var>$1page</var>或<var>$1pageid</var>被设置为一个重定向,则解析它。",
+ "apihelp-parse-param-oldid": "解析该修订版本的内容。覆盖<var>$1page</var>和<var>$1pageid</var>。",
+ "apihelp-parse-param-prop": "要获取的信息束:",
+ "apihelp-parse-paramvalue-prop-text": "提供wiki文本中的被解析文本。",
+ "apihelp-parse-paramvalue-prop-langlinks": "在被解析的wiki文本中提供语言链接。",
+ "apihelp-parse-paramvalue-prop-categories": "在被解析的wiki文本中提供分类。",
+ "apihelp-parse-paramvalue-prop-categorieshtml": "提供HTML版本分类。",
+ "apihelp-parse-paramvalue-prop-links": "在被解析的wiki文本中提供内部链接。",
+ "apihelp-parse-paramvalue-prop-templates": "在被解析的wiki文本中提供模板。",
+ "apihelp-parse-paramvalue-prop-images": "在被解析的wiki文本中提供图片。",
+ "apihelp-parse-paramvalue-prop-externallinks": "在被解析的wiki文本中提供外部链接。",
+ "apihelp-parse-paramvalue-prop-sections": "在被解析的wiki文本中提供段落。",
+ "apihelp-parse-paramvalue-prop-revid": "添加被解析页面的修订ID。",
+ "apihelp-parse-paramvalue-prop-displaytitle": "为被解析的wiki文本添加标题。",
+ "apihelp-parse-paramvalue-prop-headitems": "提供项目以插入至页面的<code>&lt;head&gt;</code>。",
+ "apihelp-parse-paramvalue-prop-headhtml": "提供页面的被解析<code>&lt;head&gt;</code>。",
+ "apihelp-parse-paramvalue-prop-modules": "提供在页面中使用的ResourceLoader模块。要加载,请使用<code>mw.loader.using()</code>。无论<kbd>jsconfigvars</kbd>还是<kbd>encodedjsconfigvars</kbd>都必须与<kbd>modules</kbd>共同被请求。",
+ "apihelp-parse-paramvalue-prop-jsconfigvars": "针对页面提供JavaScript配置变量。要应用,请使用<code>mw.config.set()</code>。",
+ "apihelp-parse-paramvalue-prop-encodedjsconfigvars": "针对页面提供JavaScript配置变量为一个JSON字符串。",
+ "apihelp-parse-paramvalue-prop-indicators": "提供页面上使用的页面状态指示器的HTML。",
+ "apihelp-parse-paramvalue-prop-iwlinks": "在被解析的wiki文本中提供跨wiki链接。",
+ "apihelp-parse-paramvalue-prop-wikitext": "提供被解析的原始wiki文本。",
+ "apihelp-parse-paramvalue-prop-properties": "提供多种定义在被解析的wiki文本中的属性。",
+ "apihelp-parse-paramvalue-prop-limitreportdata": "以结构化的方式提供限制报告。如果<var>$1disablelimitreport</var>被设定则不提供数据。",
+ "apihelp-parse-paramvalue-prop-limitreporthtml": "提供限制报告的HTML版本。当<var>$1disablelimitreport</var>被设置时不会提供数据。",
+ "apihelp-parse-paramvalue-prop-parsetree": "修订内容的XML解析树(需要内容模型<code>$1</code>)",
+ "apihelp-parse-paramvalue-prop-parsewarnings": "在解析内容时提供发生的警告",
+ "apihelp-parse-param-wrapoutputclass": "要用于包裹解析输出的CSS类。",
+ "apihelp-parse-param-pst": "在解析输入前,对输入做一次保存前变换处理。仅当使用文本时有效。",
+ "apihelp-parse-param-onlypst": "在输入内容中执行预保存转换(PST),但不解析它。在PST被应用后返回相同的wiki文本。只当与<var>$1text</var>一起使用时有效。",
+ "apihelp-parse-param-effectivelanglinks": "包含由扩展提供的语言链接(用于与<kbd>$1prop=langlinks</kbd>一起使用)。",
+ "apihelp-parse-param-section": "只解析此段数的内容。\n\n当<kbd>new</kbd>时,将<var>$1text</var>和<var>$1sectiontitle</var>解析为添加新段落至页面。\n\n<kbd>new</kbd>段落只当指定<var>text</var>时允许。",
+ "apihelp-parse-param-sectiontitle": "当<var>section</var>为<kbd>new</kbd>时新段落标题。\n\n不像页面编辑,当省略或为空时将不会备选为<var>summary</var>。",
+ "apihelp-parse-param-disablelimitreport": "从解析器输出中省略限制报告(“NewPP limit report”)。",
+ "apihelp-parse-param-disablepp": "请改用<var>$1disablelimitreport</var>。",
+ "apihelp-parse-param-disableeditsection": "从解析器输出中省略编辑段落链接。",
+ "apihelp-parse-param-disabletidy": "不要在解析器输出中运行HTML清理(例如tidy)。",
+ "apihelp-parse-param-generatexml": "生成XML解析树(需要内容模型<code>$1</code>;被<kbd>$2prop=parsetree</kbd>所取代)。",
+ "apihelp-parse-param-preview": "在预览模式下解析。",
+ "apihelp-parse-param-sectionpreview": "在段落预览模式下解析(同时要启用预览模式)。",
+ "apihelp-parse-param-disabletoc": "在输出中省略目录。",
+ "apihelp-parse-param-useskin": "为解析器输出应用选择的皮肤。会影响以下属性:<kbd>langlinks</kbd>、<kbd>headitems</kbd>、<kbd>modules</kbd>、<kbd>jsconfigvars</kbd>和<kbd>indicators</kbd>。",
+ "apihelp-parse-param-contentformat": "用于输入文本的内容序列化格式。只当与$1text一起使用时有效。",
+ "apihelp-parse-param-contentmodel": "输入文本的内容模型。如果省略,$1title必须指定,并且默认将为指定标题的模型。只当与$1text一起使用时有效。",
+ "apihelp-parse-example-page": "解析一个页面。",
+ "apihelp-parse-example-text": "解析wiki文本。",
+ "apihelp-parse-example-texttitle": "解析wiki文本,指定页面标题。",
+ "apihelp-parse-example-summary": "解析一个摘要。",
+ "apihelp-patrol-summary": "巡查页面或修订版本。",
+ "apihelp-patrol-param-rcid": "要巡查的最近更改 ID。",
+ "apihelp-patrol-param-revid": "要巡查的修订版本ID。",
+ "apihelp-patrol-param-tags": "要在巡查日志中应用到实体的更改标签。",
+ "apihelp-patrol-example-rcid": "巡查一次最近更改。",
+ "apihelp-patrol-example-revid": "巡查一次修订。",
+ "apihelp-protect-summary": "更改页面的保护等级。",
+ "apihelp-protect-param-title": "要(解除)保护的页面标题。不能与$1pageid一起使用。",
+ "apihelp-protect-param-pageid": "要(解除)保护的页面ID。不能与$1title一起使用。",
+ "apihelp-protect-param-protections": "保护等级列表,格式:<kbd>action=level</kbd>(例如<kbd>edit=sysop</kbd>)。等级<kbd>all</kbd>意味着任何人都可以执行操作,也就是说没有限制。\n\n<strong>注意:</strong>未列出的操作将移除限制。",
+ "apihelp-protect-param-expiry": "到期时间戳。如果只有一个时间戳被设置,它将被用于所有保护。使用<kbd>infinite</kbd>、<kbd>indefinite</kbd>、<kbd>infinity</kbd>或<kbd>never</kbd>用于永不过期的保护。",
+ "apihelp-protect-param-reason": "(解除)保护的原因。",
+ "apihelp-protect-param-tags": "要在保护日志中应用到实体的更改标签。",
+ "apihelp-protect-param-cascade": "启用连锁保护(也就是保护包含于此页面的页面)。如果所有提供的保护等级不支持连锁,就将其忽略。",
+ "apihelp-protect-param-watch": "如果设置,就加入已开始(解除)保护的页面至当前用户的监视列表。",
+ "apihelp-protect-param-watchlist": "无条件地将页面加入至当前用户的监视列表或将其移除,使用设置或不更改监视。",
+ "apihelp-protect-example-protect": "保护一个页面。",
+ "apihelp-protect-example-unprotect": "通过设置限制为<kbd>all</kbd>解除保护一个页面(就是说任何人都可以执行操作)。",
+ "apihelp-protect-example-unprotect2": "通过设置没有限制解除保护一个页面。",
+ "apihelp-purge-summary": "为指定标题刷新缓存。",
+ "apihelp-purge-param-forcelinkupdate": "更新链接表。",
+ "apihelp-purge-param-forcerecursivelinkupdate": "更新链接表中,并更新任何使用此页作为模板的页面的链接表。",
+ "apihelp-purge-example-simple": "刷新<kbd>Main Page</kbd>和<kbd>API</kbd>页面。",
+ "apihelp-purge-example-generator": "刷新主名字空间的前10个页面。",
+ "apihelp-query-summary": "取得来自并有关MediaWiki的数据。",
+ "apihelp-query-extended-description": "所有数据修改将首先不得不使用查询来获得令牌,以阻止来自恶意网站的滥用行为。",
+ "apihelp-query-param-prop": "要为已查询页面获取的属性。",
+ "apihelp-query-param-list": "要获取的列表。",
+ "apihelp-query-param-meta": "要获取的元数据。",
+ "apihelp-query-param-indexpageids": "包含一个额外的pageid段落,列举所有返回的页面ID。",
+ "apihelp-query-param-export": "导出所有指定或生成页面的当前修订。",
+ "apihelp-query-param-exportnowrap": "返回导出XML,不需要将其包裹在一个XML结果中(与[[Special:Export]]格式相同)。只能与$1export一起使用。",
+ "apihelp-query-param-iwurl": "如果标题是一个跨wiki链接的话,是否获取完整URL。",
+ "apihelp-query-param-rawcontinue": "为继续返回原始<samp>query-continue</samp>数据。",
+ "apihelp-query-example-revisions": "获取<kbd>Main Page</kbd>的[[Special:ApiHelp/query+siteinfo|网站信息]]和[[Special:ApiHelp/query+revisions|修订版本]]。",
+ "apihelp-query-example-allpages": "获取以<kbd>API/</kbd>开头的页面的修订版本。",
+ "apihelp-query+allcategories-summary": "列举所有分类。",
+ "apihelp-query+allcategories-param-from": "要作为枚举起始点的类别。",
+ "apihelp-query+allcategories-param-to": "要作为枚举终止点的类别。",
+ "apihelp-query+allcategories-param-prefix": "搜索所有以此值开头的分类标题。",
+ "apihelp-query+allcategories-param-dir": "排序方向。",
+ "apihelp-query+allcategories-param-min": "只返回至少带这么多成员的分类。",
+ "apihelp-query+allcategories-param-max": "只返回最多带这么多成员的分类。",
+ "apihelp-query+allcategories-param-limit": "要返回多少个类别。",
+ "apihelp-query+allcategories-param-prop": "要获取的属性:",
+ "apihelp-query+allcategories-paramvalue-prop-size": "在分类中添加页面数。",
+ "apihelp-query+allcategories-paramvalue-prop-hidden": "标记由<code>_&#95;HIDDENCAT_&#95;</code>隐藏的分类。",
+ "apihelp-query+allcategories-example-size": "列出分类及其含有多少页面的信息。",
+ "apihelp-query+allcategories-example-generator": "为以<kbd>List</kbd>的分类检索有关分类页面本身的信息。",
+ "apihelp-query+alldeletedrevisions-summary": "列举由一位用户或在一个名字空间中所有已删除的修订。",
+ "apihelp-query+alldeletedrevisions-paraminfo-useronly": "只可以与<var>$3user</var>一起使用。",
+ "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "不能与<var>$3user</var>一起使用。",
+ "apihelp-query+alldeletedrevisions-param-start": "枚举的起始时间戳。",
+ "apihelp-query+alldeletedrevisions-param-end": "枚举的结束时间戳。",
+ "apihelp-query+alldeletedrevisions-param-from": "从此标题开始列出。",
+ "apihelp-query+alldeletedrevisions-param-to": "列出至此标题为止。",
+ "apihelp-query+alldeletedrevisions-param-prefix": "搜索所有以此值开头的页面标题。",
+ "apihelp-query+alldeletedrevisions-param-tag": "只列出被此标签标记的修订。",
+ "apihelp-query+alldeletedrevisions-param-user": "只列出此用户做出的修订。",
+ "apihelp-query+alldeletedrevisions-param-excludeuser": "不要列出此用户做出的修订。",
+ "apihelp-query+alldeletedrevisions-param-namespace": "只列出此名字空间的页面。",
+ "apihelp-query+alldeletedrevisions-param-miser-user-namespace": "<strong>注意:</strong>由于[[mw:Special:MyLanguage/Manual:$wgMiserMode|miser模式]],同时使用<var>$1user</var>和<var>$1namespace</var>将导致继续前返回少于<var>$1limit</var>个结果,在极端条件下可能不返回任何结果。",
+ "apihelp-query+alldeletedrevisions-param-generatetitles": "当作为生成器使用时,生成标题而不是修订ID。",
+ "apihelp-query+alldeletedrevisions-example-user": "列出由<kbd>Example</kbd>作出的最近50次已删除贡献。",
+ "apihelp-query+alldeletedrevisions-example-ns-main": "列出前50次已删除的主名字空间修订。",
+ "apihelp-query+allfileusages-summary": "列出所有文件用途,包括不存在的。",
+ "apihelp-query+allfileusages-param-from": "要列举的起始文件标题。",
+ "apihelp-query+allfileusages-param-to": "要列举的最终文件标题。",
+ "apihelp-query+allfileusages-param-prefix": "搜索所有以此值开头的文件标题。",
+ "apihelp-query+allfileusages-param-unique": "只显示明显的文件标题。不能与$1prop=ids一起使用。\n当作为生成器使用时,产生目标页面而不是来源页面。",
+ "apihelp-query+allfileusages-param-prop": "要包含的信息束:",
+ "apihelp-query+allfileusages-paramvalue-prop-ids": "添加使用中的页面的页面ID(不能与$1unique一起使用)。",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "添加文件的标题。",
+ "apihelp-query+allfileusages-param-limit": "要返回的总计项目。",
+ "apihelp-query+allfileusages-param-dir": "罗列所采用的方向。",
+ "apihelp-query+allfileusages-example-B": "列举文件标题,包含丢失的文件、它们来自的页面ID,以<kbd>B</kbd>开头。",
+ "apihelp-query+allfileusages-example-unique": "列出唯一文件标题。",
+ "apihelp-query+allfileusages-example-unique-generator": "获取所有文件标题,并标记出缺失者。",
+ "apihelp-query+allfileusages-example-generator": "获取包含这些文件的页面。",
+ "apihelp-query+allimages-summary": "按顺序枚举所有图像。",
+ "apihelp-query+allimages-param-sort": "要作为排序方式的属性。",
+ "apihelp-query+allimages-param-dir": "罗列所采用的方向。",
+ "apihelp-query+allimages-param-from": "要列举的起始图片标题。只能与$1sort=name一起使用。",
+ "apihelp-query+allimages-param-to": "要列举的最终图片标题。只能与$1sort=name一起使用。",
+ "apihelp-query+allimages-param-start": "要列举的起始时间戳。只能与$1sort=timestamp一起使用。",
+ "apihelp-query+allimages-param-end": "要列举的最终时间戳。只能与$1sort=timestamp一起使用。",
+ "apihelp-query+allimages-param-prefix": "搜索所有以此值开头的图像标题。只能与$1sort=name一起使用。",
+ "apihelp-query+allimages-param-minsize": "限于至少这么多字节的图像。",
+ "apihelp-query+allimages-param-maxsize": "限于顶多这么多字节的图像。",
+ "apihelp-query+allimages-param-sha1": "图像的 SHA1 哈希。覆盖$1sha1base36。",
+ "apihelp-query+allimages-param-sha1base36": "基于base 36的图片的SHA1哈希值(用于MediaWiki)。",
+ "apihelp-query+allimages-param-user": "只返回此用户上传的文件。只能与$1sort=timestamp一起使用。不能与$1filterbots一起使用。",
+ "apihelp-query+allimages-param-filterbots": "如何过滤由机器人上传的文件。只能与$1sort=timestamp一起使用。不能与$1user一起使用。",
+ "apihelp-query+allimages-param-mime": "要搜索的MIME类型,例如<kbd>image/jpeg</kbd>。",
+ "apihelp-query+allimages-param-limit": "共计要返回多少图像。",
+ "apihelp-query+allimages-example-B": "显示以字母<kbd>B</kbd>开始的文件列表。",
+ "apihelp-query+allimages-example-recent": "显示一个最近上传文件的列表,类似[[Special:NewFiles]]。",
+ "apihelp-query+allimages-example-mimetypes": "显示带MIME类型<kbd>image/png</kbd>或<kbd>image/gif</kbd>的文件列表",
+ "apihelp-query+allimages-example-generator": "显示有关4个以<kbd>T</kbd>开头的文件的信息。",
+ "apihelp-query+alllinks-summary": "列举所有指向至指定名字空间的链接。",
+ "apihelp-query+alllinks-param-from": "要列举的起始标题链接。",
+ "apihelp-query+alllinks-param-to": "要列举的最终标题链接。",
+ "apihelp-query+alllinks-param-prefix": "搜索所有以此值开头的已链接标题。",
+ "apihelp-query+alllinks-param-unique": "只显示明显的链接标题。不能与<kbd>$1prop=ids</kbd>一起使用。\n当作为生成器使用时,产生目标页面而不是来源页面。",
+ "apihelp-query+alllinks-param-prop": "要包含的信息束:",
+ "apihelp-query+alllinks-paramvalue-prop-ids": "添加链接中的页面的页面ID(不能与<var>$1unique</var>一起使用)。",
+ "apihelp-query+alllinks-paramvalue-prop-title": "添加链接的标题。",
+ "apihelp-query+alllinks-param-namespace": "要列举的名字空间。",
+ "apihelp-query+alllinks-param-limit": "总共要返回多少个项目。",
+ "apihelp-query+alllinks-param-dir": "列出方向。",
+ "apihelp-query+alllinks-example-B": "列出链接的标题,包括丢失的,带它们来自的页面ID,以<kbd>B</kbd>开头。",
+ "apihelp-query+alllinks-example-unique": "列出唯一的链接标题。",
+ "apihelp-query+alllinks-example-unique-generator": "获取所有已链接的标题,标记缺少的。",
+ "apihelp-query+alllinks-example-generator": "获取包含这些链接的页面。",
+ "apihelp-query+allmessages-summary": "返回来自该网站的消息。",
+ "apihelp-query+allmessages-param-messages": "要输出的消息。<kbd>*</kbd>(默认)表示所有消息。",
+ "apihelp-query+allmessages-param-prop": "要获取的属性。",
+ "apihelp-query+allmessages-param-enableparser": "设置以启用解析器,将处理消息的wiki文本(替代魔术字、处理模板等)。",
+ "apihelp-query+allmessages-param-nocontent": "如果设置,不要在输出中包含消息内容。",
+ "apihelp-query+allmessages-param-includelocal": "也包括本地消息,也就是不存在于软件但存在于{{ns:MediaWiki}}名字空间的消息。\n这会列举所有{{ns:MediaWiki}}名字空间页面,因此它也将列举那些不是真消息的消息,例如[[MediaWiki:Common.js|Common.js]]。",
+ "apihelp-query+allmessages-param-args": "要替代进消息的参数。",
+ "apihelp-query+allmessages-param-filter": "只返回名称包含此字符串的消息。",
+ "apihelp-query+allmessages-param-customised": "只返回在此定制情形下的消息。",
+ "apihelp-query+allmessages-param-lang": "返回这种语言的信息。",
+ "apihelp-query+allmessages-param-from": "从此消息开始返回消息。",
+ "apihelp-query+allmessages-param-to": "返回消息至此消息为止。",
+ "apihelp-query+allmessages-param-title": "当解析消息时,要用作环境的页面(用于$1enableparser选项)。",
+ "apihelp-query+allmessages-param-prefix": "返回带有该前缀的消息。",
+ "apihelp-query+allmessages-example-ipb": "显示以<kbd>ipb-</kbd>开始的消息。",
+ "apihelp-query+allmessages-example-de": "显示德语版的<kbd>august</kbd>和<kbd>mainpage</kbd>消息。",
+ "apihelp-query+allpages-summary": "循序列举在指定名字空间中的所有页面。",
+ "apihelp-query+allpages-param-from": "枚举的起始页面标题。",
+ "apihelp-query+allpages-param-to": "枚举的结束页面标题。",
+ "apihelp-query+allpages-param-prefix": "搜索所有以此值开头的页面标题。",
+ "apihelp-query+allpages-param-namespace": "要列举的名字空间。",
+ "apihelp-query+allpages-param-filterredir": "要列出哪些页面。",
+ "apihelp-query+allpages-param-minsize": "限于至少这么多字节的页面。",
+ "apihelp-query+allpages-param-maxsize": "限于至多这么多字节的页面。",
+ "apihelp-query+allpages-param-prtype": "仅限于受保护页面。",
+ "apihelp-query+allpages-param-prlevel": "过滤基于保护等级的保护(必须与$1prtype=参数一起使用)。",
+ "apihelp-query+allpages-param-prfiltercascade": "过滤基于cascadingness的保护(当$1prtype未设置时忽略)。",
+ "apihelp-query+allpages-param-limit": "返回的总计页面数。",
+ "apihelp-query+allpages-param-dir": "罗列所采用的方向。",
+ "apihelp-query+allpages-param-filterlanglinks": "过滤基于页面是否有语言链接。注意这可能不考虑由扩展添加的语言链接。",
+ "apihelp-query+allpages-param-prexpiry": "要在页面上过滤的保护期限:\n;indefinite:只获取带无限期保护的页面。\n;definite:只获取带指定保护期限的页面。\n;all:获取任意保护期限的页面。",
+ "apihelp-query+allpages-example-B": "显示以字母<kbd>B</kbd>开头的页面的列表。",
+ "apihelp-query+allpages-example-generator": "显示有关4个以字母<kbd>T</kbd>开头的页面的信息。",
+ "apihelp-query+allpages-example-generator-revisions": "显示前2个以<kbd>Re</kbd>开头的非重定向页面的内容。",
+ "apihelp-query+allredirects-summary": "列出至一个名字空间的重定向。",
+ "apihelp-query+allredirects-param-from": "要列举的起始重定向标题。",
+ "apihelp-query+allredirects-param-to": "要列举的最终重定向标题。",
+ "apihelp-query+allredirects-param-prefix": "搜索所有以此值开头的目标页面。",
+ "apihelp-query+allredirects-param-unique": "只显示明显的目标页面。不能与$1prop=ids|fragment|interwiki一起使用。\n当作为生成器使用时,产生目标页面而不是来源页面。",
+ "apihelp-query+allredirects-param-prop": "要包含的信息束:",
+ "apihelp-query+allredirects-paramvalue-prop-ids": "添加重定向页面的页面ID(不能与<var>$1unique</var>一起使用)。",
+ "apihelp-query+allredirects-paramvalue-prop-title": "添加重定向的标题。",
+ "apihelp-query+allredirects-paramvalue-prop-fragment": "添加来自重定向的碎片,如果有(不能与<var>$1unique</var>一起使用)。",
+ "apihelp-query+allredirects-paramvalue-prop-interwiki": "添加来自重定向的跨wiki前缀,如果有(不能与<var>$1unique</var>一起使用)。",
+ "apihelp-query+allredirects-param-namespace": "要列举的名字空间。",
+ "apihelp-query+allredirects-param-limit": "返回的总计项目数。",
+ "apihelp-query+allredirects-param-dir": "罗列所采用的方向。",
+ "apihelp-query+allredirects-example-B": "列举目标页面,包含丢失的页面、它们来自的页面ID,以<kbd>B</kbd>开头。",
+ "apihelp-query+allredirects-example-unique": "列出孤立目标页面。",
+ "apihelp-query+allredirects-example-unique-generator": "获取所有目标页面,标记丢失的。",
+ "apihelp-query+allredirects-example-generator": "获取包含重定向的页面。",
+ "apihelp-query+allrevisions-summary": "列举所有修订。",
+ "apihelp-query+allrevisions-param-start": "枚举的起始时间戳。",
+ "apihelp-query+allrevisions-param-end": "枚举的结束时间戳。",
+ "apihelp-query+allrevisions-param-user": "只列出此用户做出的修订。",
+ "apihelp-query+allrevisions-param-excludeuser": "不要列出此用户做出的修订。",
+ "apihelp-query+allrevisions-param-namespace": "只列出此名字空间的页面。",
+ "apihelp-query+allrevisions-param-generatetitles": "当作为生成器使用时,生成标题而不是修订ID。",
+ "apihelp-query+allrevisions-example-user": "列出由用户<kbd>Example</kbd>作出的最近50次贡献。",
+ "apihelp-query+allrevisions-example-ns-main": "列举主名字空间中的前50次修订。",
+ "apihelp-query+mystashedfiles-summary": "获取当前用户上传暂存库中的文件列表。",
+ "apihelp-query+mystashedfiles-param-prop": "要检索文件的属性。",
+ "apihelp-query+mystashedfiles-paramvalue-prop-size": "检索文件大小和图片尺寸。",
+ "apihelp-query+mystashedfiles-paramvalue-prop-type": "检索文件的MIME类型和媒体类型。",
+ "apihelp-query+mystashedfiles-param-limit": "要获取文件的数量。",
+ "apihelp-query+mystashedfiles-example-simple": "获取当前用户上传暂存库中的文件的filekey、大小和像素尺寸。",
+ "apihelp-query+alltransclusions-summary": "列出所有嵌入页面(使用&#123;&#123;x&#125;&#125;嵌入的页面),包括不存在的。",
+ "apihelp-query+alltransclusions-param-from": "要列举的起始嵌入标题。",
+ "apihelp-query+alltransclusions-param-to": "要列举的最终嵌入标题。",
+ "apihelp-query+alltransclusions-param-prefix": "搜索所有以此值开头的嵌入的标题。",
+ "apihelp-query+alltransclusions-param-unique": "只显示明显的被嵌入标题。不能与$1prop=ids一起使用。\n当作为生成器使用时,产生目标页面而不是来源页面。",
+ "apihelp-query+alltransclusions-param-prop": "要包含的信息束:",
+ "apihelp-query+alltransclusions-paramvalue-prop-ids": "添加嵌入中的页面的页面ID(不能与$1unique一起使用)。",
+ "apihelp-query+alltransclusions-paramvalue-prop-title": "添加嵌入的标题。",
+ "apihelp-query+alltransclusions-param-namespace": "要列举的名字空间。",
+ "apihelp-query+alltransclusions-param-limit": "要返回的总计项目。",
+ "apihelp-query+alltransclusions-param-dir": "罗列所采用的方向。",
+ "apihelp-query+alltransclusions-example-B": "列出嵌入的标题,包括丢失的,带有来自的页面ID,从<kbd>B</kbd>开始。",
+ "apihelp-query+alltransclusions-example-unique": "列出孤立嵌入标题",
+ "apihelp-query+alltransclusions-example-unique-generator": "获取所有嵌入的标题,并标记缺失的。",
+ "apihelp-query+alltransclusions-example-generator": "获得包含嵌入内容的页面。",
+ "apihelp-query+allusers-summary": "列举所有注册用户。",
+ "apihelp-query+allusers-param-from": "枚举的起始用户名。",
+ "apihelp-query+allusers-param-to": "枚举的结束用户名。",
+ "apihelp-query+allusers-param-prefix": "搜索所有以此值开头的用户。",
+ "apihelp-query+allusers-param-dir": "排序方向。",
+ "apihelp-query+allusers-param-group": "只包含指定组中的用户。",
+ "apihelp-query+allusers-param-excludegroup": "排除指定组中的用户。",
+ "apihelp-query+allusers-param-rights": "仅列出有所选权限的用户。不包括隐性的或自动加入的用户组别(如*、用户或自动确认用户)所授予的权限。",
+ "apihelp-query+allusers-param-prop": "要包含的信息束:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "添加有关用户当前封禁的信息。",
+ "apihelp-query+allusers-paramvalue-prop-groups": "列举用户所在的组。这使用更多服务器资源,并可能返回少于限制的结果。",
+ "apihelp-query+allusers-paramvalue-prop-implicitgroups": "列出用户自动属于的所有组。",
+ "apihelp-query+allusers-paramvalue-prop-rights": "用户拥有的权限列表。",
+ "apihelp-query+allusers-paramvalue-prop-editcount": "添加用户的编辑计数。",
+ "apihelp-query+allusers-paramvalue-prop-registration": "如果可能,添加用户注册时的时间戳(可能为空白)。",
+ "apihelp-query+allusers-paramvalue-prop-centralids": "添加中心ID并为用户附加状态。",
+ "apihelp-query+allusers-param-limit": "返回的总计用户数。",
+ "apihelp-query+allusers-param-witheditsonly": "只列出有编辑的用户。",
+ "apihelp-query+allusers-param-activeusers": "只列出最近$1{{PLURAL:$1|天}}内活跃的用户。",
+ "apihelp-query+allusers-param-attachedwiki": "与<kbd>$1prop=centralids</kbd>一起使用,也表明用户是否附加于此ID定义的wiki。",
+ "apihelp-query+allusers-example-Y": "列出以<kbd>Y</kbd>开头的用户。",
+ "apihelp-query+authmanagerinfo-summary": "检索有关当前身份验证状态的信息。",
+ "apihelp-query+authmanagerinfo-param-securitysensitiveoperation": "测试用户当前的身份验证状态是否足够用于指定的安全敏感操作。",
+ "apihelp-query+authmanagerinfo-param-requestsfor": "取得指定身份验证操作所需的有关身份验证请求的信息。",
+ "apihelp-query+authmanagerinfo-example-login": "检索当开始登录时可能使用的请求。",
+ "apihelp-query+authmanagerinfo-example-login-merged": "检索当开始登录时可能使用的请求,并合并表单字段。",
+ "apihelp-query+authmanagerinfo-example-securitysensitiveoperation": "测试身份验证对操作<kbd>foo</kbd>是否足够。",
+ "apihelp-query+backlinks-summary": "查找所有链接至指定页面的页面。",
+ "apihelp-query+backlinks-param-title": "要搜索的标题。不能与<var>$1pageid</var>一起使用。",
+ "apihelp-query+backlinks-param-pageid": "要搜索的页面ID。不能与<var>$1title</var>一起使用。",
+ "apihelp-query+backlinks-param-namespace": "要列举的名字空间。",
+ "apihelp-query+backlinks-param-dir": "罗列所采用的方向。",
+ "apihelp-query+backlinks-param-filterredir": "如何过滤重定向。当<var>$1redirect</var>被启用时如果设置为<kbd>nonredirects</kbd>,这只会应用到第二级。",
+ "apihelp-query+backlinks-param-limit": "返回总计页面数。如果<var>$1redirect</var>被启用,则限定分别适用于每一等级(这意味着将返回多达2 * <var>$1limit</var>个结果)。",
+ "apihelp-query+backlinks-param-redirect": "如果链入页面是一个重定向,则寻找所有链接至此重定向的页面。最大限制减半。",
+ "apihelp-query+backlinks-example-simple": "显示至<kbd>Main page</kbd>的链接。",
+ "apihelp-query+backlinks-example-generator": "获取关于链接至<kbd>Main page</kbd>的页面的信息。",
+ "apihelp-query+blocks-summary": "列出所有被封禁的用户和IP地址。",
+ "apihelp-query+blocks-param-start": "枚举的起始时间戳。",
+ "apihelp-query+blocks-param-end": "枚举的结束时间戳。",
+ "apihelp-query+blocks-param-ids": "要列出的封禁ID列表(可选)。",
+ "apihelp-query+blocks-param-users": "要搜索的用户列表(可选)。",
+ "apihelp-query+blocks-param-ip": "获取应用到此IP地址或者CIDR范围的所有封禁,包括范围封禁。不能与<var>$3users</var>一起使用。CIDR范围不允许比IPv4/$1或IPv6/$2更宽。",
+ "apihelp-query+blocks-param-limit": "封禁列表的最大数量。",
+ "apihelp-query+blocks-param-prop": "要获取的属性:",
+ "apihelp-query+blocks-paramvalue-prop-id": "添加封禁ID。",
+ "apihelp-query+blocks-paramvalue-prop-user": "添加被封禁用户的用户名。",
+ "apihelp-query+blocks-paramvalue-prop-userid": "添加被封禁用户的用户ID。",
+ "apihelp-query+blocks-paramvalue-prop-by": "添加执行封禁的用户的用户名。",
+ "apihelp-query+blocks-paramvalue-prop-byid": "添加执行封禁的用户的用户ID。",
+ "apihelp-query+blocks-paramvalue-prop-timestamp": "添加封禁生效时的时间戳。",
+ "apihelp-query+blocks-paramvalue-prop-expiry": "添加封禁截止时的时间戳。",
+ "apihelp-query+blocks-paramvalue-prop-reason": "添加封禁原因。",
+ "apihelp-query+blocks-paramvalue-prop-range": "添加受封禁影响的IP地址段。",
+ "apihelp-query+blocks-paramvalue-prop-flags": "标记编辑禁止(自动封禁、仅限匿名用户等)。",
+ "apihelp-query+blocks-param-show": "只显示符合这些标准的项目。\n例如,要只查看IP地址的无限期封禁,设置<kbd>$1show=ip|!temp</kbd>。",
+ "apihelp-query+blocks-example-simple": "封禁列表。",
+ "apihelp-query+blocks-example-users": "列出用户<kbd>Alice</kbd>和<kbd>Bob</kbd>的封禁。",
+ "apihelp-query+categories-summary": "页面属于的所有分类列表。",
+ "apihelp-query+categories-param-prop": "要为每个分类获取的额外属性:",
+ "apihelp-query+categories-paramvalue-prop-sortkey": "为每个分类添加关键词(十六进制字符串)和关键词前缀(人类可读部分)。",
+ "apihelp-query+categories-paramvalue-prop-timestamp": "添加分类添加时的时间戳。",
+ "apihelp-query+categories-paramvalue-prop-hidden": "标记由<code>_&#95;HIDDENCAT_&#95;</code>隐藏的分类。",
+ "apihelp-query+categories-param-show": "显示何种分类。",
+ "apihelp-query+categories-param-limit": "返回多少分类。",
+ "apihelp-query+categories-param-categories": "只列出这些分类。对于检查某一页面使用某一分类很有用。",
+ "apihelp-query+categories-param-dir": "罗列所采用的方向。",
+ "apihelp-query+categories-example-simple": "获取属于<kbd>Albert Einstein</kbd>的分类列表。",
+ "apihelp-query+categories-example-generator": "获取有关用于<kbd>Albert Einstein</kbd>的分类的信息。",
+ "apihelp-query+categoryinfo-summary": "返回有关给定分类的信息。",
+ "apihelp-query+categoryinfo-example-simple": "获取有关<kbd>Category:Foo</kbd>和<kbd>Category:Bar</kbd>的信息。",
+ "apihelp-query+categorymembers-summary": "在指定的分类中列出所有页面。",
+ "apihelp-query+categorymembers-param-title": "要列举的分类(必需)。必须包括<kbd>{{ns:category}}:</kbd>前缀。不能与<var>$1pageid</var>一起使用。",
+ "apihelp-query+categorymembers-param-pageid": "要枚举的分类的页面 ID。不能与<var>$1title</var>一起使用。",
+ "apihelp-query+categorymembers-param-prop": "要包含的信息束:",
+ "apihelp-query+categorymembers-paramvalue-prop-ids": "添加页面ID。",
+ "apihelp-query+categorymembers-paramvalue-prop-title": "添加页面标题和名字空间ID。",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkey": "添加用于分类中排序的关键字(十六进制字符串)。",
+ "apihelp-query+categorymembers-paramvalue-prop-sortkeyprefix": "添加用于分类中排序的关键字前缀(关键字的人类可读部分)。",
+ "apihelp-query+categorymembers-paramvalue-prop-type": "添加页面被分类的类型(<samp>page</samp>、<samp>subcat</samp>或<samp>file</samp>)。",
+ "apihelp-query+categorymembers-paramvalue-prop-timestamp": "添加页面被包括时的时间戳。",
+ "apihelp-query+categorymembers-param-namespace": "仅包含这些名字空间的页面。注意<kbd>$1type=subcat</kbd>或<kbd>$1type=file</kbd>可能被使用,而不是<kbd>$1namespace=14</kbd>或<kbd>6</kbd>。",
+ "apihelp-query+categorymembers-param-type": "包含的分类成员类型。当<kbd>$1sort=timestamp</kbd>被设置时会忽略。",
+ "apihelp-query+categorymembers-param-limit": "返回页面的最大数量。",
+ "apihelp-query+categorymembers-param-sort": "要作为排序方式的属性。",
+ "apihelp-query+categorymembers-param-dir": "排序的方向。",
+ "apihelp-query+categorymembers-param-start": "开始列举的时间戳。只能与<kbd>$1sort=timestamp</kbd>一起使用。",
+ "apihelp-query+categorymembers-param-end": "列举的结尾时间戳。只能与<kbd>$1sort=timestamp</kbd>一起使用。",
+ "apihelp-query+categorymembers-param-starthexsortkey": "开始列举的关键词,由<kbd>$1prop=sortkey</kbd>返回。不能与<kbd>$1sort=sortkey</kbd>一起使用。",
+ "apihelp-query+categorymembers-param-endhexsortkey": "结束列举的关键字,由<kbd>$1prop=sortkey</kbd>返回。只能与<kbd>$1sort=sortkey</kbd>一起使用。",
+ "apihelp-query+categorymembers-param-startsortkeyprefix": "要开始列举的排序关键词前缀。只能与<kbd>$1sort=sortkey</kbd>一起使用。覆盖<var>$1starthexsortkey</var>。",
+ "apihelp-query+categorymembers-param-endsortkeyprefix": "要结束列举<strong>before</strong>的关键字前缀(而不是<strong>at</strong>;如果此值出现,它将不被包括!)只能与$1sort=sortkey一起使用。覆盖$1endhexsortkey。",
+ "apihelp-query+categorymembers-param-startsortkey": "请改用$1starthexsortkey。",
+ "apihelp-query+categorymembers-param-endsortkey": "请改用$1endhexsortkey。",
+ "apihelp-query+categorymembers-example-simple": "获取<kbd>Category:Physics</kbd>中的前10个页面。",
+ "apihelp-query+categorymembers-example-generator": "获取有关<kbd>Category:Physics</kbd>中的前10个页面的页面信息。",
+ "apihelp-query+contributors-summary": "获取对一个页面的登录贡献者列表和匿名贡献数。",
+ "apihelp-query+contributors-param-group": "只包括指定用户组中的用户。不包括隐性的或自动提升的用户组,例如*、用户或自动确认用户。",
+ "apihelp-query+contributors-param-excludegroup": "排除指定用户组中的用户。不包括隐性的或自动提升的用户组,例如*、用户或自动确认用户。",
+ "apihelp-query+contributors-param-rights": "只包括拥有指定权限的用户。不包括隐性的或自动提升的用户组,例如*、用户或自动确认用户。",
+ "apihelp-query+contributors-param-excluderights": "排除拥有指定权限的用户。不包括隐性的或自动提升的用户组,例如*、用户或自动确认用户。",
+ "apihelp-query+contributors-param-limit": "返回的贡献数。",
+ "apihelp-query+contributors-example-simple": "显示<kbd>Main Page</kbd>的贡献。",
+ "apihelp-query+deletedrevisions-summary": "获取删除的修订版本信息。",
+ "apihelp-query+deletedrevisions-extended-description": "可在很多途径中使用:\n# 获得一组页面的已删除修订,通过设置标题或页面ID。以标题和时间戳排序。\n# 通过设置它们的ID与修订ID获得关于一组已删除修订。以修订ID排序。",
+ "apihelp-query+deletedrevisions-param-start": "要开始枚举的时间戳。当处理修订ID列表时会被忽略。",
+ "apihelp-query+deletedrevisions-param-end": "要停止枚举的时间戳。当处理修订ID列表时会被忽略。",
+ "apihelp-query+deletedrevisions-param-tag": "只列出被此标签标记的修订。",
+ "apihelp-query+deletedrevisions-param-user": "只列出此用户做出的修订。",
+ "apihelp-query+deletedrevisions-param-excludeuser": "不要列出此用户做出的修订。",
+ "apihelp-query+deletedrevisions-example-titles": "列出页面<kbd>Main Page</kbd>和<kbd>Talk:Main Page</kbd>的已删除修订,包含内容。",
+ "apihelp-query+deletedrevisions-example-revids": "列出已删除修订<kbd>123456</kbd>的信息。",
+ "apihelp-query+deletedrevs-summary": "列举删除的修订版本。",
+ "apihelp-query+deletedrevs-extended-description": "操作于三种模式中:\n# 为指定标题列举已删除修订,按时间戳排列。\n# 为指定用户列举已删除贡献,按时间戳排列(未指定标题)。\n# 在指定名字空间中列举所有已删除修订,按标题和时间戳排列(无指定标题,未设置$1user)。\n\n任一参数只应用于一些模式,并忽略其他参数。",
+ "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|模式}}:$2",
+ "apihelp-query+deletedrevs-param-start": "枚举的起始时间戳。",
+ "apihelp-query+deletedrevs-param-end": "枚举的结束时间戳。",
+ "apihelp-query+deletedrevs-param-from": "从此标题开始列出。",
+ "apihelp-query+deletedrevs-param-to": "列出至此标题为止。",
+ "apihelp-query+deletedrevs-param-prefix": "搜索所有以此值开头的页面标题。",
+ "apihelp-query+deletedrevs-param-unique": "每个页面只列出一个修订。",
+ "apihelp-query+deletedrevs-param-tag": "只列出被此标签标记的修订。",
+ "apihelp-query+deletedrevs-param-user": "只列出此用户做出的修订。",
+ "apihelp-query+deletedrevs-param-excludeuser": "不要列出此用户做出的修订。",
+ "apihelp-query+deletedrevs-param-namespace": "只列出此名字空间的页面。",
+ "apihelp-query+deletedrevs-param-limit": "要列出的最大修订数量。",
+ "apihelp-query+deletedrevs-param-prop": "要获取的属性:\n;revid:添加被删除修订的修订ID。\n;parentid:添加上一修订的修订ID至页面。\n;user:添加做出修订的用户。\n;userid:添加做出修订的用户ID。\n;comment:添加修订摘要。\n;parsedcomment:添加解析过的修订摘要。\n;minor:如果修订是小编辑则加标签。\n;len:添加修订长度(字节)。\n;sha1:添加修订的SHA-1(base 16)。\n;content:添加修订内容。\n;token:<span class=\"apihelp-deprecated\">已弃用。</span>提供编辑令牌。\n;tags:修订标签。",
+ "apihelp-query+deletedrevs-example-mode1": "列出最近已删除的对页面<kbd>Main Page</kbd>和<kbd>Talk:Main Page</kbd>的贡献,带内容(模式1)。",
+ "apihelp-query+deletedrevs-example-mode2": "列出由<kbd>Bob</kbd>作出的最近50次已删除贡献(模式2)。",
+ "apihelp-query+deletedrevs-example-mode3-main": "列出前50次主名字空间已删除贡献(模式3)。",
+ "apihelp-query+deletedrevs-example-mode3-talk": "列出前50次{{ns:talk}}名字空间已删除页面(模式3)。",
+ "apihelp-query+disabled-summary": "此查询模块已被禁用。",
+ "apihelp-query+duplicatefiles-summary": "根据哈希值列出此给定文件的所有副本。",
+ "apihelp-query+duplicatefiles-param-limit": "返回多少重复文件。",
+ "apihelp-query+duplicatefiles-param-dir": "罗列所采用的方向。",
+ "apihelp-query+duplicatefiles-param-localonly": "只看本地存储库的文件。",
+ "apihelp-query+duplicatefiles-example-simple": "查找与[[:File:Albert Einstein Head.jpg]]重复的文件。",
+ "apihelp-query+duplicatefiles-example-generated": "查找所有文件的重复文件。",
+ "apihelp-query+embeddedin-summary": "查找所有嵌入指定标题的页面。",
+ "apihelp-query+embeddedin-param-title": "要搜索的标题。不能与$1pageid一起使用。",
+ "apihelp-query+embeddedin-param-pageid": "要搜索的页面ID。不能与$1title一起使用。",
+ "apihelp-query+embeddedin-param-namespace": "列举的名字空间。",
+ "apihelp-query+embeddedin-param-dir": "罗列所采用的方向。",
+ "apihelp-query+embeddedin-param-filterredir": "如何过滤重定向。",
+ "apihelp-query+embeddedin-param-limit": "返回的总计页面数。",
+ "apihelp-query+embeddedin-example-simple": "显示嵌入<kbd>Template:Stub</kbd>的页面。",
+ "apihelp-query+embeddedin-example-generator": "获取有关显示嵌入<kbd>Template:Stub</kbd>的页面的信息。",
+ "apihelp-query+extlinks-summary": "从指定页面返回所有外部URL(非跨wiki链接)。",
+ "apihelp-query+extlinks-param-limit": "返回多少链接。",
+ "apihelp-query+extlinks-param-protocol": "URL协议。如果为空并且<var>$1query</var>被设置,协议为<kbd>http</kbd>。将此和<var>$1query</var>都留空以列举所有外部链接。",
+ "apihelp-query+extlinks-param-query": "不使用协议搜索字符串。对于检查某一页面是否包含某一外部URL很有用。",
+ "apihelp-query+extlinks-param-expandurl": "扩展协议相对URL与规范协议。",
+ "apihelp-query+extlinks-example-simple": "获取<kbd>Main Page</kbd>的外部链接列表。",
+ "apihelp-query+exturlusage-summary": "列举包含一个指定URL的页面。",
+ "apihelp-query+exturlusage-param-prop": "要包含的信息束:",
+ "apihelp-query+exturlusage-paramvalue-prop-ids": "添加页面ID。",
+ "apihelp-query+exturlusage-paramvalue-prop-title": "添加页面的标题和名字空间ID。",
+ "apihelp-query+exturlusage-paramvalue-prop-url": "添加页面中使用的URL。",
+ "apihelp-query+exturlusage-param-protocol": "URL协议。如果为空并且<var>$1query</var>被设置,协议为<kbd>http</kbd>。将此和<var>$1query</var>都留空以列举所有外部链接。",
+ "apihelp-query+exturlusage-param-query": "不包括协议的搜索字符串。参见[[Special:LinkSearch]]。留空以列出所有外部链接。",
+ "apihelp-query+exturlusage-param-namespace": "要列举的页面名字空间。",
+ "apihelp-query+exturlusage-param-limit": "返回多少页面。",
+ "apihelp-query+exturlusage-param-expandurl": "用标准协议展开协议相关URL。",
+ "apihelp-query+exturlusage-example-simple": "显示链接至<kbd>http://www.mediawiki.org</kbd>的页面。",
+ "apihelp-query+filearchive-summary": "循序列举所有被删除的文件。",
+ "apihelp-query+filearchive-param-from": "枚举的起始图片标题。",
+ "apihelp-query+filearchive-param-to": "枚举的结束图片标题。",
+ "apihelp-query+filearchive-param-prefix": "搜索所有以此值开头的图像标题。",
+ "apihelp-query+filearchive-param-limit": "返回图像的总数。",
+ "apihelp-query+filearchive-param-dir": "罗列所采用的方向。",
+ "apihelp-query+filearchive-param-sha1": "图片的SHA1哈希值。覆盖$1sha1base36。",
+ "apihelp-query+filearchive-param-sha1base36": "基于base 36的图片的SHA1哈希值(用于MediaWiki)。",
+ "apihelp-query+filearchive-param-prop": "要获取的图片信息:",
+ "apihelp-query+filearchive-paramvalue-prop-sha1": "为文件加入SHA-1哈希值。",
+ "apihelp-query+filearchive-paramvalue-prop-timestamp": "为已上传版本添加时间戳。",
+ "apihelp-query+filearchive-paramvalue-prop-user": "添加上传了图片版本的用户。",
+ "apihelp-query+filearchive-paramvalue-prop-size": "添加图片大小(字节)及其高度、宽度和页面计数(如果可以)。",
+ "apihelp-query+filearchive-paramvalue-prop-dimensions": "用于大小的别名。",
+ "apihelp-query+filearchive-paramvalue-prop-description": "添加图片版本的说明。",
+ "apihelp-query+filearchive-paramvalue-prop-parseddescription": "解析版本的描述。",
+ "apihelp-query+filearchive-paramvalue-prop-mime": "添加图片的MIME。",
+ "apihelp-query+filearchive-paramvalue-prop-mediatype": "添加图片的媒体类型。",
+ "apihelp-query+filearchive-paramvalue-prop-metadata": "为图片版本列出Exif元数据。",
+ "apihelp-query+filearchive-paramvalue-prop-bitdepth": "添加版本的字节深度。",
+ "apihelp-query+filearchive-paramvalue-prop-archivename": "添加用于非最新版本的存档版本的文件名。",
+ "apihelp-query+filearchive-example-simple": "显示已删除文件列表。",
+ "apihelp-query+filerepoinfo-summary": "返回有关wiki配置的图片存储库的元信息。",
+ "apihelp-query+filerepoinfo-param-prop": "要获取的存储库属性(这在一些wiki上可能有更多可用选项):\n;apiurl:链接至API的URL - 对从主机获取图片信息有用。\n;name:存储库关键词 - 用于例如<var>[[mw:Special:MyLanguage/Manual:$wgForeignFileRepos|$wgForeignFileRepos]]</var>,并且[[Special:ApiHelp/query+imageinfo|imageinfo]]会返回值。\n;displayname:人类可读的存储库wiki名称。\n;rooturl:图片路径的根URL。\n;local:存储库是否在本地。",
+ "apihelp-query+filerepoinfo-example-simple": "获得有关文件存储库的信息。",
+ "apihelp-query+fileusage-summary": "查找所有使用指定文件的页面。",
+ "apihelp-query+fileusage-param-prop": "要获取的属性:",
+ "apihelp-query+fileusage-paramvalue-prop-pageid": "每个页面的页面ID。",
+ "apihelp-query+fileusage-paramvalue-prop-title": "每个页面的标题。",
+ "apihelp-query+fileusage-paramvalue-prop-redirect": "标记作为重定向的页面。",
+ "apihelp-query+fileusage-param-namespace": "只包括这些名字空间的页面。",
+ "apihelp-query+fileusage-param-limit": "返回多少。",
+ "apihelp-query+fileusage-param-show": "只显示符合以下标准的项:\n;redirect:只显示重定向。\n;!redirect:只显示非重定向。",
+ "apihelp-query+fileusage-example-simple": "获取使用[[:File:Example.jpg]]的页面列表。",
+ "apihelp-query+fileusage-example-generator": "获取有关使用[[:File:Example.jpg]]的页面的信息。",
+ "apihelp-query+imageinfo-summary": "返回文件信息和上传历史。",
+ "apihelp-query+imageinfo-param-prop": "要获取的文件信息:",
+ "apihelp-query+imageinfo-paramvalue-prop-timestamp": "添加时间戳至上传的版本。",
+ "apihelp-query+imageinfo-paramvalue-prop-user": "添加上传了每个文件版本的用户。",
+ "apihelp-query+imageinfo-paramvalue-prop-userid": "添加上传了每个文件版本的用户ID。",
+ "apihelp-query+imageinfo-paramvalue-prop-comment": "此版本的摘要。",
+ "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "解析版本上的注释。",
+ "apihelp-query+imageinfo-paramvalue-prop-canonicaltitle": "添加文件的规范标题。",
+ "apihelp-query+imageinfo-paramvalue-prop-url": "为文件及其描述页面提供URL。",
+ "apihelp-query+imageinfo-paramvalue-prop-size": "添加文件大小(字节)及其高度、宽度和页面数(如果可以)。",
+ "apihelp-query+imageinfo-paramvalue-prop-dimensions": "用于大小的别名。",
+ "apihelp-query+imageinfo-paramvalue-prop-sha1": "为文件加入SHA-1哈希值。",
+ "apihelp-query+imageinfo-paramvalue-prop-mime": "添加文件的MIME类型。",
+ "apihelp-query+imageinfo-paramvalue-prop-thumbmime": "添加图片缩略图的MIME类型(需要url和参数$1urlwidth)。",
+ "apihelp-query+imageinfo-paramvalue-prop-mediatype": "添加文件媒体类型。",
+ "apihelp-query+imageinfo-paramvalue-prop-metadata": "列出这个版本的文件的EXIF元数据。",
+ "apihelp-query+imageinfo-paramvalue-prop-commonmetadata": "为文件的修订版本列出文件格式相关元数据。",
+ "apihelp-query+imageinfo-paramvalue-prop-extmetadata": "列出结合自多个来源的格式化的元数据。结果均依HTML格式化。",
+ "apihelp-query+imageinfo-paramvalue-prop-archivename": "添加用于非最新修订的存档修订的文件名。",
+ "apihelp-query+imageinfo-paramvalue-prop-bitdepth": "添加修订的字节深度。",
+ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "由Special:Upload所使用,以获取关于现有文件的信息。不适用于MediaWiki核心以外代码。",
+ "apihelp-query+imageinfo-paramvalue-prop-badfile": "无论文件是否在[[MediaWiki:Bad image list]]都添加",
+ "apihelp-query+imageinfo-param-limit": "每个文件返回多少文件修订。",
+ "apihelp-query+imageinfo-param-start": "开始列举的时间戳。",
+ "apihelp-query+imageinfo-param-end": "列举的结束时间戳。",
+ "apihelp-query+imageinfo-param-urlwidth": "如果$2prop=url被设定,将返回至缩放到此宽度的一张图片的URL。\n由于性能原因,如果此消息被使用,将不会返回超过$1张被缩放的图片。",
+ "apihelp-query+imageinfo-param-urlheight": "与$1urlwidth类似。",
+ "apihelp-query+imageinfo-param-metadataversion": "要使用的元数据版本。如果<kbd>latest</kbd>被指定,则使用最新版本。默认为<kbd>1</kbd>以便向下兼容。",
+ "apihelp-query+imageinfo-param-extmetadatalanguage": "要取得extmetadata的语言。这会影响到抓取翻译的选择,如果有多个可用的话,还会影响到数字等数值的格式。",
+ "apihelp-query+imageinfo-param-extmetadatamultilang": "如果用于extmetadata属性的翻译可用,则全部取得。",
+ "apihelp-query+imageinfo-param-extmetadatafilter": "如果指定且非空,则只为$1prop=extmetadata返回这些键。",
+ "apihelp-query+imageinfo-param-urlparam": "处理器特定的参数字符串。例如PDF可能使用<kbd>page15-100px</kbd>。<var>$1urlwidth</var>必须被使用,并与<var>$1urlparam</var>一致。",
+ "apihelp-query+imageinfo-param-badfilecontexttitle": "如果<kbd>$2prop=badfile</kbd>被设置,这会是在评估[[MediaWiki:Bad image list]]时使用的页面标题",
+ "apihelp-query+imageinfo-param-localonly": "只看本地存储库的文件。",
+ "apihelp-query+imageinfo-example-simple": "取得有关[[:File:Albert Einstein Head.jpg]]的当前版本的信息。",
+ "apihelp-query+imageinfo-example-dated": "取得有关[[:File:Test.jpg]]自2008年以来版本的信息。",
+ "apihelp-query+images-summary": "返回指定页面上包含的所有文件。",
+ "apihelp-query+images-param-limit": "返回多少文件。",
+ "apihelp-query+images-param-images": "只列出这些文件。对于检查某一页面是否使用某一文件很有用。",
+ "apihelp-query+images-param-dir": "罗列所采用的方向。",
+ "apihelp-query+images-example-simple": "获取[[Main Page]]使用的文件列表。",
+ "apihelp-query+images-example-generator": "获取有关[[Main Page]]使用的文件的信息。",
+ "apihelp-query+imageusage-summary": "查找所有使用指定图片标题的页面。",
+ "apihelp-query+imageusage-param-title": "要搜索的标题。不能与$1pageid一起使用。",
+ "apihelp-query+imageusage-param-pageid": "要搜索的页面ID。不能与$1title一起使用。",
+ "apihelp-query+imageusage-param-namespace": "要列举的名字空间。",
+ "apihelp-query+imageusage-param-dir": "罗列所采用的方向。",
+ "apihelp-query+imageusage-param-filterredir": "如何过滤重定向。当$1redirect被启用时如果设置为nonredirects,这只会应用到第二级。",
+ "apihelp-query+imageusage-param-limit": "返回总计页面数。如果<var>$1redirect</var>被启用,则限定分别适用于每一等级(这意味着将返回多达2 * <var>$1limit</var>个结果)。",
+ "apihelp-query+imageusage-param-redirect": "如果链接页面是重定向,则查找所有链接至该重定向的页面。最大限制减半。",
+ "apihelp-query+imageusage-example-simple": "显示使用[[:File:Albert Einstein Head.jpg]]的页面。",
+ "apihelp-query+imageusage-example-generator": "获取有关使用[[:File:Albert Einstein Head.jpg]]的页面的信息。",
+ "apihelp-query+info-summary": "获取基本页面信息。",
+ "apihelp-query+info-param-prop": "要获取的额外属性:",
+ "apihelp-query+info-paramvalue-prop-protection": "列出每个页面的保护等级。",
+ "apihelp-query+info-paramvalue-prop-talkid": "每个非讨论页面的讨论页的页面ID。",
+ "apihelp-query+info-paramvalue-prop-watched": "列出每个页面的被监视状态。",
+ "apihelp-query+info-paramvalue-prop-watchers": "监视人员数,如果允许。",
+ "apihelp-query+info-paramvalue-prop-visitingwatchers": "访问了每个页面的最近编辑的监视者数量,如果允许。",
+ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "每个页面的监视列表通知时间戳。",
+ "apihelp-query+info-paramvalue-prop-subjectid": "每个讨论页的母页面的页面ID。",
+ "apihelp-query+info-paramvalue-prop-url": "为每个页面提供一个完整URL、一个编辑URL和规范URL。",
+ "apihelp-query+info-paramvalue-prop-readable": "用户是否可以阅读此页面。",
+ "apihelp-query+info-paramvalue-prop-preload": "提供由EditFormPreloadText返回的文本。",
+ "apihelp-query+info-paramvalue-prop-displaytitle": "在页面标题实际显示的地方提供方式。",
+ "apihelp-query+info-param-testactions": "测试当前用户是否可以在页面上执行某种操作。",
+ "apihelp-query+info-param-token": "请改用[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]。",
+ "apihelp-query+info-example-simple": "获取有关页面<kbd>Main Page</kbd>的信息。",
+ "apihelp-query+info-example-protection": "获取<kbd>Main Page</kbd>相关的常规和保护信息。",
+ "apihelp-query+iwbacklinks-summary": "查找所有链接至指定跨wiki链接的页面。",
+ "apihelp-query+iwbacklinks-extended-description": "可用于查找所有有前缀的链接,或是链至某一标题的所有链接(带指定前缀)。两参数均不使用实际上就是“all interwiki links”。",
+ "apihelp-query+iwbacklinks-param-prefix": "跨wiki前缀。",
+ "apihelp-query+iwbacklinks-param-title": "要搜索的跨wiki链接。必须与<var>$1blprefix</var>一起使用。",
+ "apihelp-query+iwbacklinks-param-limit": "返回的总计页面数。",
+ "apihelp-query+iwbacklinks-param-prop": "要获取的属性:",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwprefix": "加入跨wiki前缀。",
+ "apihelp-query+iwbacklinks-paramvalue-prop-iwtitle": "加入跨wiki标题。",
+ "apihelp-query+iwbacklinks-param-dir": "罗列所采用的方向。",
+ "apihelp-query+iwbacklinks-example-simple": "获取链接至[[wikibooks:Test]]的页面。",
+ "apihelp-query+iwbacklinks-example-generator": "获取有关链接至[[wikibooks:Test]]的页面的信息。",
+ "apihelp-query+iwlinks-summary": "从指定页面返回所有跨wiki链接。",
+ "apihelp-query+iwlinks-param-url": "是否获取完整URL(不能与$1prop一起使用)。",
+ "apihelp-query+iwlinks-param-prop": "要为每个跨语言链接获取的额外属性:",
+ "apihelp-query+iwlinks-paramvalue-prop-url": "添加完整URL。",
+ "apihelp-query+iwlinks-param-limit": "返回多少跨wiki链接。",
+ "apihelp-query+iwlinks-param-prefix": "只返回此前缀的跨wiki链接。",
+ "apihelp-query+iwlinks-param-title": "用于搜索的跨wiki链接。必须与<var>$1prefix</var>一起使用。",
+ "apihelp-query+iwlinks-param-dir": "罗列所采用的方向。",
+ "apihelp-query+iwlinks-example-simple": "从页面<kbd>Main Page</kbd>获得跨wiki链接。",
+ "apihelp-query+langbacklinks-summary": "查找所有链接至指定语言链接的页面。",
+ "apihelp-query+langbacklinks-extended-description": "可被用于查找所有带某一语言代码的链接,或所有至某一标题的链接(带指定语言)。不使用任何参数就意味着“all language links”。\n\n注意这可能不考虑由扩展添加的语言链接。",
+ "apihelp-query+langbacklinks-param-lang": "用于语言链接的语言。",
+ "apihelp-query+langbacklinks-param-title": "要搜索的语言链接。必须与$1lang一起使用。",
+ "apihelp-query+langbacklinks-param-limit": "返回的总计页面数。",
+ "apihelp-query+langbacklinks-param-prop": "要获得的属性:",
+ "apihelp-query+langbacklinks-paramvalue-prop-lllang": "添加语言链接的语言代码。",
+ "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "添加语言链接的标题。",
+ "apihelp-query+langbacklinks-param-dir": "罗列所采用的方向。",
+ "apihelp-query+langbacklinks-example-simple": "获取链接至[[:fr:Test]]的页面。",
+ "apihelp-query+langbacklinks-example-generator": "获取链接至[[:fr:Test]]的页面的信息。",
+ "apihelp-query+langlinks-summary": "从指定页面返回所有跨语言链接。",
+ "apihelp-query+langlinks-param-limit": "返回多少语言链接。",
+ "apihelp-query+langlinks-param-url": "是否获取完整URL(不能与<var>$1prop</var>一起使用)。",
+ "apihelp-query+langlinks-param-prop": "要为每个跨语言链接获取的额外属性:",
+ "apihelp-query+langlinks-paramvalue-prop-url": "添加完整URL。",
+ "apihelp-query+langlinks-paramvalue-prop-langname": "添加本地化语言名(尽可能)。使用<var>$1inlanguagecode</var>以控制语言。",
+ "apihelp-query+langlinks-paramvalue-prop-autonym": "添加本地语言名。",
+ "apihelp-query+langlinks-param-lang": "只返回带此语言代码的语言链接。",
+ "apihelp-query+langlinks-param-title": "要搜索的链接。必须与<var>$1lang</var>一起使用。",
+ "apihelp-query+langlinks-param-dir": "罗列所采用的方向。",
+ "apihelp-query+langlinks-param-inlanguagecode": "本地化语言名称的语言代码。",
+ "apihelp-query+langlinks-example-simple": "从页面<kbd>Main Page</kbd>获取跨语言链接。",
+ "apihelp-query+links-summary": "从指定页面返回所有链接。",
+ "apihelp-query+links-param-namespace": "只显示这些名字空间的链接。",
+ "apihelp-query+links-param-limit": "返回多少链接。",
+ "apihelp-query+links-param-titles": "只列出这些标题。对于检查某一页面是否使用某一标题很有用。",
+ "apihelp-query+links-param-dir": "罗列所采用的方向。",
+ "apihelp-query+links-example-simple": "从页面<kbd>Main Page</kbd>获取链接。",
+ "apihelp-query+links-example-generator": "获取有关在页面<kbd>Main Page</kbd>中连接的页面的信息。",
+ "apihelp-query+links-example-namespaces": "获取在{{ns:user}}和{{ns:template}}名字空间中来自页面<kbd>Main Page</kbd>的链接。",
+ "apihelp-query+linkshere-summary": "查找所有链接至指定页面的页面。",
+ "apihelp-query+linkshere-param-prop": "要获取的属性:",
+ "apihelp-query+linkshere-paramvalue-prop-pageid": "每个页面的页面ID。",
+ "apihelp-query+linkshere-paramvalue-prop-title": "每个页面的标题。",
+ "apihelp-query+linkshere-paramvalue-prop-redirect": "如果页面是一个重定向就标记。",
+ "apihelp-query+linkshere-param-namespace": "只包括这些名字空间的页面。",
+ "apihelp-query+linkshere-param-limit": "返回多少。",
+ "apihelp-query+linkshere-param-show": "只显示符合以下标准的项:\n;redirect:只显示重定向。\n;!redirect:只显示非重定向。",
+ "apihelp-query+linkshere-example-simple": "获取链接至[[Main Page]]的页面列表。",
+ "apihelp-query+linkshere-example-generator": "获取有关链接至[[Main Page]]的页面的信息。",
+ "apihelp-query+logevents-summary": "从日志获取事件。",
+ "apihelp-query+logevents-param-prop": "要获取的属性:",
+ "apihelp-query+logevents-paramvalue-prop-ids": "添加日志活动的ID。",
+ "apihelp-query+logevents-paramvalue-prop-title": "为日志事件添加页面标题。",
+ "apihelp-query+logevents-paramvalue-prop-type": "添加日志活动的类型。",
+ "apihelp-query+logevents-paramvalue-prop-user": "添加对此日志事件负责的用户。",
+ "apihelp-query+logevents-paramvalue-prop-userid": "添加对此日志事件负责的用户的ID。",
+ "apihelp-query+logevents-paramvalue-prop-timestamp": "为日志活动添加时间戳。",
+ "apihelp-query+logevents-paramvalue-prop-comment": "添加日志活动的摘要。",
+ "apihelp-query+logevents-paramvalue-prop-parsedcomment": "添加被解析的日志活动的摘要。",
+ "apihelp-query+logevents-paramvalue-prop-details": "列举有关日志事件的额外详细信息。",
+ "apihelp-query+logevents-paramvalue-prop-tags": "列举用于日志活动的标签。",
+ "apihelp-query+logevents-param-type": "过滤日志记录至仅限此类型。",
+ "apihelp-query+logevents-param-action": "过滤日志操作为仅限此操作。覆盖<var>$1type</var>。在可用值列表中,带星号通配符的值例如<kbd>action/*</kbd>可在斜线(/)后拥有不同字符串。",
+ "apihelp-query+logevents-param-start": "枚举的起始时间戳。",
+ "apihelp-query+logevents-param-end": "枚举的结束时间戳。",
+ "apihelp-query+logevents-param-user": "过滤记录为这些由指定用户做出的。",
+ "apihelp-query+logevents-param-title": "过滤记录至这些与页面相关的。",
+ "apihelp-query+logevents-param-namespace": "过滤事件为在这些指定的名字空间中。",
+ "apihelp-query+logevents-param-prefix": "过滤以此前缀开头的记录。",
+ "apihelp-query+logevents-param-tag": "只列举带此标签的事件日志记录。",
+ "apihelp-query+logevents-param-limit": "返回的事件日志记录总数。",
+ "apihelp-query+logevents-example-simple": "列出最近日志事件。",
+ "apihelp-query+pagepropnames-summary": "列出wiki中所有使用中的页面属性名称。",
+ "apihelp-query+pagepropnames-param-limit": "返回名称的最大数量。",
+ "apihelp-query+pagepropnames-example-simple": "获取前10个属性名称。",
+ "apihelp-query+pageprops-summary": "获取页面内容中定义的各种页面属性。",
+ "apihelp-query+pageprops-param-prop": "只列出这些页面属性(<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd>返回使用中的页面属性名称)。在检查页面是否使用某一页面属性时有用。",
+ "apihelp-query+pageprops-example-simple": "获取用于页面<kbd>Main Page</kbd>和<kbd>MediaWiki</kbd>的属性。",
+ "apihelp-query+pageswithprop-summary": "列出所有使用指定页面属性的页面。",
+ "apihelp-query+pageswithprop-param-propname": "要用于列举页面的页面属性(<kbd>[[Special:ApiHelp/query+pagepropnames|action=query&list=pagepropnames]]</kbd>返回正在使用中的页面属性名称)。",
+ "apihelp-query+pageswithprop-param-prop": "要包含的信息束:",
+ "apihelp-query+pageswithprop-paramvalue-prop-ids": "添加页面ID。",
+ "apihelp-query+pageswithprop-paramvalue-prop-title": "添加页面的标题和名字空间ID。",
+ "apihelp-query+pageswithprop-paramvalue-prop-value": "添加页面属性值。",
+ "apihelp-query+pageswithprop-param-limit": "返回页面的最大数量。",
+ "apihelp-query+pageswithprop-param-dir": "排序的方向。",
+ "apihelp-query+pageswithprop-example-simple": "列出前10个使用<code>&#123;&#123;DISPLAYTITLE:&#125;&#125;</code>的页面。",
+ "apihelp-query+pageswithprop-example-generator": "获取有关前10个使用<code>_&#95;NOTOC_&#95;</code>的页面的额外信息。",
+ "apihelp-query+prefixsearch-summary": "为页面标题执行前缀搜索。",
+ "apihelp-query+prefixsearch-extended-description": "尽管名称类似,但此模块不等于[[Special:PrefixIndex]];详见<kbd>[[Special:ApiHelp/query+allpages|action=query&list=allpages]]</kbd>中的<kbd>apprefix</kbd>参数。此模块的目的类似<kbd>[[Special:ApiHelp/opensearch|action=opensearch]]</kbd>:基于用户的输入提供最佳匹配的标题。取决于搜索引擎后端,这可能包括错拼纠正、避免重定向和其他启发性行为。",
+ "apihelp-query+prefixsearch-param-search": "搜索字符串。",
+ "apihelp-query+prefixsearch-param-namespace": "搜索的名字空间。",
+ "apihelp-query+prefixsearch-param-limit": "要返回的结果最大数。",
+ "apihelp-query+prefixsearch-param-offset": "跳过的结果数。",
+ "apihelp-query+prefixsearch-example-simple": "搜索以<kbd>meaning</kbd>开头的页面标题。",
+ "apihelp-query+prefixsearch-param-profile": "搜索要使用的配置文件。",
+ "apihelp-query+protectedtitles-summary": "列出所有被限制创建的标题。",
+ "apihelp-query+protectedtitles-param-namespace": "只列出这些名字空间的标题。",
+ "apihelp-query+protectedtitles-param-level": "只列出带这些保护级别的标题。",
+ "apihelp-query+protectedtitles-param-limit": "返回的总计页面数。",
+ "apihelp-query+protectedtitles-param-start": "从此保护时间戳开始列举。",
+ "apihelp-query+protectedtitles-param-end": "列举至此保护时间戳为止。",
+ "apihelp-query+protectedtitles-param-prop": "要获取的属性:",
+ "apihelp-query+protectedtitles-paramvalue-prop-timestamp": "添加保护被添加时的时间戳。",
+ "apihelp-query+protectedtitles-paramvalue-prop-user": "添加对页面添加保护的用户。",
+ "apihelp-query+protectedtitles-paramvalue-prop-userid": "添加对页面添加保护的用户ID。",
+ "apihelp-query+protectedtitles-paramvalue-prop-comment": "为保护添加摘要。",
+ "apihelp-query+protectedtitles-paramvalue-prop-parsedcomment": "为保护添加解析的摘要。",
+ "apihelp-query+protectedtitles-paramvalue-prop-expiry": "添加保护将被提升时的时间戳。",
+ "apihelp-query+protectedtitles-paramvalue-prop-level": "添加保护级别。",
+ "apihelp-query+protectedtitles-example-simple": "受保护标题列表。",
+ "apihelp-query+protectedtitles-example-generator": "找到主名字空间中已保护的标题的链接。",
+ "apihelp-query+querypage-summary": "获取由基于QueryPage的特殊页面提供的列表。",
+ "apihelp-query+querypage-param-page": "特殊页面的名称。注意其区分大小写。",
+ "apihelp-query+querypage-param-limit": "返回的结果数。",
+ "apihelp-query+querypage-example-ancientpages": "返回[[Special:Ancientpages]]的结果。",
+ "apihelp-query+random-summary": "获取随机页面集。",
+ "apihelp-query+random-extended-description": "页面列举在一个固定序列中,只有起始点是随机的。这意味着如果<samp>Main Page</samp>是列表中第一个随机页面的话,<samp>List of fictional monkeys</samp>将<em>总是</em>第二个,<samp>List of people on stamps of Vanuatu</samp>是第三个等。",
+ "apihelp-query+random-param-namespace": "只返回这些名字空间的页面。",
+ "apihelp-query+random-param-limit": "限制返回多少随机页面。",
+ "apihelp-query+random-param-redirect": "请改用<kbd>$1filterredir=redirects</kbd>。",
+ "apihelp-query+random-param-filterredir": "如何过滤重定向。",
+ "apihelp-query+random-example-simple": "从主名字空间返回两个随机页面。",
+ "apihelp-query+random-example-generator": "返回有关来自主名字空间的两个随机页面的页面信息。",
+ "apihelp-query+recentchanges-summary": "列举最近更改。",
+ "apihelp-query+recentchanges-param-start": "枚举的起始时间戳。",
+ "apihelp-query+recentchanges-param-end": "枚举的结束时间戳。",
+ "apihelp-query+recentchanges-param-namespace": "过滤更改为仅限这些名字空间。",
+ "apihelp-query+recentchanges-param-user": "只列出此用户的更改。",
+ "apihelp-query+recentchanges-param-excludeuser": "不要列出此用户的更改。",
+ "apihelp-query+recentchanges-param-tag": "只列出带此标签的更改。",
+ "apihelp-query+recentchanges-param-prop": "包含的额外信息束:",
+ "apihelp-query+recentchanges-paramvalue-prop-user": "添加造成编辑的用户,并标出它们是否是IP。",
+ "apihelp-query+recentchanges-paramvalue-prop-userid": "为编辑添加用户ID责任。",
+ "apihelp-query+recentchanges-paramvalue-prop-comment": "为编辑添加摘要。",
+ "apihelp-query+recentchanges-paramvalue-prop-parsedcomment": "为编辑添加解析的摘要。",
+ "apihelp-query+recentchanges-paramvalue-prop-flags": "为编辑添加标记。",
+ "apihelp-query+recentchanges-paramvalue-prop-timestamp": "添加编辑的时间戳。",
+ "apihelp-query+recentchanges-paramvalue-prop-title": "添加编辑的页面标题。",
+ "apihelp-query+recentchanges-paramvalue-prop-ids": "添加页面ID、最近更改ID和新旧修订的ID。",
+ "apihelp-query+recentchanges-paramvalue-prop-sizes": "添加新旧页面长度(字节)。",
+ "apihelp-query+recentchanges-paramvalue-prop-redirect": "如果页面是重定向的话,标记编辑。",
+ "apihelp-query+recentchanges-paramvalue-prop-patrolled": "将可巡查编辑标记为已巡查或未巡查。",
+ "apihelp-query+recentchanges-paramvalue-prop-loginfo": "添加日志信息(日志ID、日志类型等)至日志记录。",
+ "apihelp-query+recentchanges-paramvalue-prop-tags": "列举条目的标签。",
+ "apihelp-query+recentchanges-paramvalue-prop-sha1": "为与某一修订版本有关的记录添加内容校验和。",
+ "apihelp-query+recentchanges-param-token": "请改用<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>。",
+ "apihelp-query+recentchanges-param-show": "只显示满足这些标准的项目。例如,要只查看由登录用户做出的小编辑,设置$1show=minor|!anon。",
+ "apihelp-query+recentchanges-param-limit": "返回总计更新数。",
+ "apihelp-query+recentchanges-param-type": "显示的更改类型。",
+ "apihelp-query+recentchanges-param-toponly": "只列举作为最新修订的更改。",
+ "apihelp-query+recentchanges-param-generaterevisions": "当作为生成器使用时,生成修订ID而不是标题。不带关联修订ID的最近更改记录(例如大多数日志记录)将不会生成任何东西。",
+ "apihelp-query+recentchanges-example-simple": "最近更改列表。",
+ "apihelp-query+recentchanges-example-generator": "获取有关最近未巡查更改的页面信息。",
+ "apihelp-query+redirects-summary": "返回至指定页面的所有重定向。",
+ "apihelp-query+redirects-param-prop": "要获取的属性:",
+ "apihelp-query+redirects-paramvalue-prop-pageid": "每个重定向的页面ID。",
+ "apihelp-query+redirects-paramvalue-prop-title": "每个重定向的标题。",
+ "apihelp-query+redirects-paramvalue-prop-fragment": "每个重定向的碎片,如果有。",
+ "apihelp-query+redirects-param-namespace": "只包含这些名字空间的页面。",
+ "apihelp-query+redirects-param-limit": "返回多少重定向。",
+ "apihelp-query+redirects-param-show": "只显示符合这些标准的项目:\n;fragment:只显示带碎片的重定向。\n;!fragment:只显示不带碎片的重定向。",
+ "apihelp-query+redirects-example-simple": "获取至[[Main Page]]的重定向列表。",
+ "apihelp-query+redirects-example-generator": "获取所有重定向至[[Main Page]]的信息。",
+ "apihelp-query+revisions-summary": "获取修订版本信息。",
+ "apihelp-query+revisions-extended-description": "可用于以下几个方面:\n# 通过设置标题或页面ID获取一批页面(最新修订)的数据。\n# 通过使用带start、end或limit的标题或页面ID获取给定页面的多个修订。\n# 通过revid设置一批修订的ID获取它们的数据。",
+ "apihelp-query+revisions-paraminfo-singlepageonly": "可能只能与单一页面使用(模式#2)。",
+ "apihelp-query+revisions-param-startid": "从这个修订版本时间戳开始列举。修订版本必须存在,但未必与该页面相关。",
+ "apihelp-query+revisions-param-endid": "在这个修订版本时间戳停止列举。修订版本必须存在,但未必与该页面相关。",
+ "apihelp-query+revisions-param-start": "从哪个修订版本时间戳开始列举。",
+ "apihelp-query+revisions-param-end": "列举直至此时间戳。",
+ "apihelp-query+revisions-param-user": "只包含由用户做出的修订。",
+ "apihelp-query+revisions-param-excludeuser": "不包括由用户做出的修订。",
+ "apihelp-query+revisions-param-tag": "只列出被此标签标记的修订。",
+ "apihelp-query+revisions-param-token": "要为每个修订版本获得的令牌。",
+ "apihelp-query+revisions-example-content": "获取带内容的数据,用于标题<kbd>API</kbd>和<kbd>Main Page</kbd>的最近修订。",
+ "apihelp-query+revisions-example-last5": "获取<kbd>Main Page</kbd>的最近5次修订。",
+ "apihelp-query+revisions-example-first5": "获取<kbd>Main Page</kbd>的前5次修订。",
+ "apihelp-query+revisions-example-first5-after": "获取<kbd>Main Page</kbd>于2006年05月01日之后做出的前5次修订版本。",
+ "apihelp-query+revisions-example-first5-not-localhost": "获取<kbd>Main Page</kbd>的前5次不是由匿名用户<kbd>127.0.0.1</kbd>做出的修订。",
+ "apihelp-query+revisions-example-first5-user": "获取<kbd>Main Page</kbd>的前5次由用户<kbd>MediaWiki default</kbd>做出的修订。",
+ "apihelp-query+revisions+base-param-prop": "要为每个修订获取的属性:",
+ "apihelp-query+revisions+base-paramvalue-prop-ids": "修订版本的ID。",
+ "apihelp-query+revisions+base-paramvalue-prop-flags": "修订标记(小编辑)。",
+ "apihelp-query+revisions+base-paramvalue-prop-timestamp": "修订的时间戳。",
+ "apihelp-query+revisions+base-paramvalue-prop-user": "做出修订的用户。",
+ "apihelp-query+revisions+base-paramvalue-prop-userid": "修订创建者的用户ID。",
+ "apihelp-query+revisions+base-paramvalue-prop-size": "修订的长度(字节)。",
+ "apihelp-query+revisions+base-paramvalue-prop-sha1": "修订的SHA-1(base 16)。",
+ "apihelp-query+revisions+base-paramvalue-prop-contentmodel": "修订的内容模型ID。",
+ "apihelp-query+revisions+base-paramvalue-prop-comment": "由用户对修订做出的摘要。",
+ "apihelp-query+revisions+base-paramvalue-prop-parsedcomment": "由用户对修订做出的被解析的摘要。",
+ "apihelp-query+revisions+base-paramvalue-prop-content": "修订文本。",
+ "apihelp-query+revisions+base-paramvalue-prop-tags": "修订标签。",
+ "apihelp-query+revisions+base-paramvalue-prop-parsetree": "<span class=\"apihelp-deprecated\">已弃用。</span>请改用<kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd>或<kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>。修订内容的XML解析树(需要内容模型<code>$1</code>)。",
+ "apihelp-query+revisions+base-param-limit": "限制返回多少修订。",
+ "apihelp-query+revisions+base-param-expandtemplates": "请改用<kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd>。展开修订内容中的模板(需要$1prop=content)。",
+ "apihelp-query+revisions+base-param-generatexml": "请改用<kbd>[[Special:ApiHelp/expandtemplates|action=expandtemplates]]</kbd>或<kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>。生成用于修订内容的XML解析树(需要$1prop=content;被<kbd>$1prop=parsetree</kbd>所取代)。",
+ "apihelp-query+revisions+base-param-parse": "请改用<kbd>[[Special:ApiHelp/parse|action=parse]]</kbd>。解析修订内容(需要$1prop=content)。由于性能原因,如果此选项被使用,$1limit会被强制为1。",
+ "apihelp-query+revisions+base-param-section": "只检索此段落数的内容。",
+ "apihelp-query+revisions+base-param-diffto": "请改用<kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>。要比较修订差异的修订ID。使用<kbd>prev</kbd>、<kbd>next</kbd>和<kbd>cur</kbd>分别用于上个、下个和当前修订。",
+ "apihelp-query+revisions+base-param-difftotext": "请改用<kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>。要比较修订差异的文本。只有修订的有限数字内的差异。覆盖<var>$1diffto</var>。如果<var>$1section</var>被设置,只有那个段落将与此文本之间比较差异",
+ "apihelp-query+revisions+base-param-difftotextpst": "请改用<kbd>[[Special:ApiHelp/compare|action=compare]]</kbd>。在编辑文本前对其执行预保存转换。只当与<var>$1difftotext</var>一起使用时有效。",
+ "apihelp-query+revisions+base-param-contentformat": "序列化用于<var>$1difftotext</var>的格式并预估内容输出。",
+ "apihelp-query+search-summary": "执行一次全文本搜索。",
+ "apihelp-query+search-param-search": "搜索所有匹配此值的页面标题或内容。根据wiki的搜索后端工具,您可以使用搜索字符串以调用特殊搜索功能。",
+ "apihelp-query+search-param-namespace": "只在这些名字空间搜索。",
+ "apihelp-query+search-param-what": "要执行的搜索类型。",
+ "apihelp-query+search-param-info": "要返回的元数据。",
+ "apihelp-query+search-param-prop": "要返回的属性:",
+ "apihelp-query+search-param-qiprofile": "查询要使用的独立描述(影响排序算法)。",
+ "apihelp-query+search-paramvalue-prop-size": "添加页面大小,单位为字节。",
+ "apihelp-query+search-paramvalue-prop-wordcount": "添加页面的字数。",
+ "apihelp-query+search-paramvalue-prop-timestamp": "添加页面上次编辑时的时间戳。",
+ "apihelp-query+search-paramvalue-prop-snippet": "添加已解析的页面片段。",
+ "apihelp-query+search-paramvalue-prop-titlesnippet": "添加已解析的页面标题片段。",
+ "apihelp-query+search-paramvalue-prop-redirectsnippet": "添加被解析的重定向标题的片段。",
+ "apihelp-query+search-paramvalue-prop-redirecttitle": "添加匹配的重定向的标题。",
+ "apihelp-query+search-paramvalue-prop-sectionsnippet": "添加已解析的匹配章节标题片段。",
+ "apihelp-query+search-paramvalue-prop-sectiontitle": "添加匹配章节的标题。",
+ "apihelp-query+search-paramvalue-prop-categorysnippet": "添加已解析的匹配分类片段。",
+ "apihelp-query+search-paramvalue-prop-isfilematch": "添加布尔值,表明搜索是否匹配文件内容。",
+ "apihelp-query+search-paramvalue-prop-score": "已忽略。",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "已忽略。",
+ "apihelp-query+search-param-limit": "返回的总计页面数。",
+ "apihelp-query+search-param-interwiki": "搜索结果中包含跨wiki结果,如果可用。",
+ "apihelp-query+search-param-backend": "要使用的搜索后端,如果没有则为默认。",
+ "apihelp-query+search-param-enablerewrites": "启用内部查询重写。一些搜索后端可以重写查询到另一个被认为能提供更好结果的位置,例如纠正拼写错误。",
+ "apihelp-query+search-example-simple": "搜索<kbd>meaning</kbd>。",
+ "apihelp-query+search-example-text": "搜索文本<kbd>meaning</kbd>。",
+ "apihelp-query+search-example-generator": "获取有关搜索<kbd>meaning</kbd>返回页面的页面信息。",
+ "apihelp-query+siteinfo-summary": "返回有关网站的一般信息。",
+ "apihelp-query+siteinfo-param-prop": "要获取的信息:",
+ "apihelp-query+siteinfo-paramvalue-prop-general": "全部系统信息。",
+ "apihelp-query+siteinfo-paramvalue-prop-namespaces": "注册的名字空间及其规范名称列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "注册的名字空间别名列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "特殊页面别名列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-magicwords": "魔术字及其别名列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-statistics": "返回网站统计。",
+ "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "返回跨wiki映射(可选过滤,可选择使用<var>$1inlanguagecode</var>本地化)。",
+ "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "返回数据库服务器与最高反应延迟。",
+ "apihelp-query+siteinfo-paramvalue-prop-usergroups": "返回用户组及其相关权限。",
+ "apihelp-query+siteinfo-paramvalue-prop-libraries": "返回wiki上安装的库。",
+ "apihelp-query+siteinfo-paramvalue-prop-extensions": "返回wiki上安装的扩展。",
+ "apihelp-query+siteinfo-paramvalue-prop-fileextensions": "返回允许上传的文件扩展名(文件类型)列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-rightsinfo": "当可用时返回wiki的版权(许可协议)信息。",
+ "apihelp-query+siteinfo-paramvalue-prop-restrictions": "返回可用的编辑限制(保护)类型信息。",
+ "apihelp-query+siteinfo-paramvalue-prop-languages": "返回MediaWiki支持的语言列表(可选择使用<var>$1inlanguagecode</var>本地化)。",
+ "apihelp-query+siteinfo-paramvalue-prop-languagevariants": "当启用了[[mw:Special:MyLanguage/LanguageConverter|语言转换器]],并且每个语言变体都受支持时,返回语言代码列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-skins": "返回所有启用的皮肤列表(可选择使用<var>$1inlanguagecode</var>本地化,否则是内容语言)。",
+ "apihelp-query+siteinfo-paramvalue-prop-extensiontags": "返回解析器扩展标签列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-functionhooks": "返回解析器函数钩列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-showhooks": "返回所有订阅的钩列表(<var>[[mw:Special:MyLanguage/Manual:$wgHooks|$wgHooks]]</var>的内容)。",
+ "apihelp-query+siteinfo-paramvalue-prop-variables": "返回变量ID列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-protocols": "返回外部链接中允许的协议列表。",
+ "apihelp-query+siteinfo-paramvalue-prop-defaultoptions": "返回用户设置的默认值。",
+ "apihelp-query+siteinfo-paramvalue-prop-uploaddialog": "返回上传对话框的配置。",
+ "apihelp-query+siteinfo-param-filteriw": "只返回跨wiki地图中的本地或非本地记录。",
+ "apihelp-query+siteinfo-param-showalldb": "列出所有数据库服务器,不只是最落后的那个。",
+ "apihelp-query+siteinfo-param-numberingroup": "列出用户组中的用户数。",
+ "apihelp-query+siteinfo-param-inlanguagecode": "用于本地化语言名称(尽可能)和皮肤名称的语言代码。",
+ "apihelp-query+siteinfo-example-simple": "取得网站信息。",
+ "apihelp-query+siteinfo-example-interwiki": "取得本地跨wiki前缀列表。",
+ "apihelp-query+siteinfo-example-replag": "检查当前的响应延迟。",
+ "apihelp-query+stashimageinfo-summary": "返回用于藏匿文件的文件信息。",
+ "apihelp-query+stashimageinfo-param-filekey": "用于识别一次临时藏匿的早前上传的关键字。",
+ "apihelp-query+stashimageinfo-param-sessionkey": "$1filekey的别名,用于向后兼容。",
+ "apihelp-query+stashimageinfo-example-simple": "返回藏匿文件的信息。",
+ "apihelp-query+stashimageinfo-example-params": "返回两个藏匿文件的缩略图。",
+ "apihelp-query+tags-summary": "列出更改标签。",
+ "apihelp-query+tags-param-limit": "列出标签的最大数量。",
+ "apihelp-query+tags-param-prop": "要获取的属性:",
+ "apihelp-query+tags-paramvalue-prop-name": "添加标签名称。",
+ "apihelp-query+tags-paramvalue-prop-displayname": "为标签添加系统消息。",
+ "apihelp-query+tags-paramvalue-prop-description": "为标签添加描述。",
+ "apihelp-query+tags-paramvalue-prop-hitcount": "已添加此标签的修订版本与日志数量。",
+ "apihelp-query+tags-paramvalue-prop-defined": "标识标签是否已定义。",
+ "apihelp-query+tags-paramvalue-prop-source": "获得标签来源,它可能包括用于扩展定义的标签的<samp>extension</samp>,以及用于可被用户手动应用的标签的<samp>manual</samp>。",
+ "apihelp-query+tags-paramvalue-prop-active": "标签是否仍可被应用。",
+ "apihelp-query+tags-example-simple": "可用标签列表。",
+ "apihelp-query+templates-summary": "返回指定页面上所有被嵌入的页面。",
+ "apihelp-query+templates-param-namespace": "只显示此名字空间的模板。",
+ "apihelp-query+templates-param-limit": "返回的模板数量。",
+ "apihelp-query+templates-param-templates": "只列出这些模板。对于检查某一页面使用某一模板很有用。",
+ "apihelp-query+templates-param-dir": "罗列所采用的方向。",
+ "apihelp-query+templates-example-simple": "获取在页面<kbd>Main Page</kbd>使用的模板。",
+ "apihelp-query+templates-example-generator": "获取有关<kbd>Main Page</kbd>中使用的模板页面的信息。",
+ "apihelp-query+templates-example-namespaces": "获取在{{ns:user}}和{{ns:template}}名字空间中,嵌入在<kbd>Main Page</kbd>页面的页面。",
+ "apihelp-query+tokens-summary": "获取可修改数据的操作的令牌。",
+ "apihelp-query+tokens-param-type": "要请求的令牌类型。",
+ "apihelp-query+tokens-example-simple": "检索一个csrf令牌(默认)。",
+ "apihelp-query+tokens-example-types": "检索一个监视令牌和一个巡查令牌。",
+ "apihelp-query+transcludedin-summary": "查找所有嵌入指定页面的页面。",
+ "apihelp-query+transcludedin-param-prop": "要获取的属性:",
+ "apihelp-query+transcludedin-paramvalue-prop-pageid": "每个页面的页面ID。",
+ "apihelp-query+transcludedin-paramvalue-prop-title": "每个页面的标题。",
+ "apihelp-query+transcludedin-paramvalue-prop-redirect": "标记作为重定向的页面。",
+ "apihelp-query+transcludedin-param-namespace": "至包含这些名字空间的页面。",
+ "apihelp-query+transcludedin-param-limit": "返回多少。",
+ "apihelp-query+transcludedin-param-show": "只显示符合以下标准的项:\n;redirect:只显示重定向。\n;!redirect:只显示非重定向。",
+ "apihelp-query+transcludedin-example-simple": "获取嵌入<kbd>Main Page</kbd>的页面列表。",
+ "apihelp-query+transcludedin-example-generator": "获取有关嵌入<kbd>Main Page</kbd>的页面的信息。",
+ "apihelp-query+usercontribs-summary": "获取一位用户的所有编辑。",
+ "apihelp-query+usercontribs-param-limit": "返回贡献的最大数量。",
+ "apihelp-query+usercontribs-param-start": "返回的起始时间戳。",
+ "apihelp-query+usercontribs-param-end": "返回的最终时间戳。",
+ "apihelp-query+usercontribs-param-user": "要检索贡献的用户。不能与<var>$1userids</var>或<var>$1userprefix</var>一起使用。",
+ "apihelp-query+usercontribs-param-userprefix": "取得所有用户名以这个值开头的用户的贡献。不能与<var>$1user</var>或<var>$1userids</var>一起使用。",
+ "apihelp-query+usercontribs-param-userids": "要检索贡献的用户ID。不能与<var>$1user</var>或<var>$1userprefix</var>一起使用。",
+ "apihelp-query+usercontribs-param-namespace": "只列出这些名字空间的贡献。",
+ "apihelp-query+usercontribs-param-prop": "包含额外的信息束:",
+ "apihelp-query+usercontribs-paramvalue-prop-ids": "添加页面ID和修订ID。",
+ "apihelp-query+usercontribs-paramvalue-prop-title": "添加页面标题及其名字空间ID。",
+ "apihelp-query+usercontribs-paramvalue-prop-timestamp": "添加编辑的时间戳。",
+ "apihelp-query+usercontribs-paramvalue-prop-comment": "添加编辑摘要。",
+ "apihelp-query+usercontribs-paramvalue-prop-parsedcomment": "添加被解析的编辑摘要。",
+ "apihelp-query+usercontribs-paramvalue-prop-size": "添加编辑的新大小。",
+ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "添加与父编辑相比该编辑的大小变化。",
+ "apihelp-query+usercontribs-paramvalue-prop-flags": "添加编辑标记。",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "标记已巡查编辑。",
+ "apihelp-query+usercontribs-paramvalue-prop-tags": "列举用于编辑的标签。",
+ "apihelp-query+usercontribs-param-show": "只显示符合这些标准的项目,例如只显示不是小编辑的编辑:<kbd>$2show=!minor</kbd>。\n\n如果<kbd>$2show=patrolled</kbd>或<kbd>$2show=!patrolled</kbd>被设定,早于<var>[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]</var>($1秒)的修订不会被显示。",
+ "apihelp-query+usercontribs-param-tag": "只列出被此标签标记的修订。",
+ "apihelp-query+usercontribs-param-toponly": "只列举作为最新修订的更改。",
+ "apihelp-query+usercontribs-example-user": "显示用户<kbd>Example</kbd>的贡献。",
+ "apihelp-query+usercontribs-example-ipprefix": "显示来自<kbd>192.0.2.</kbd>前缀所有 IP 地址的贡献。",
+ "apihelp-query+userinfo-summary": "获取有关当前用户的信息。",
+ "apihelp-query+userinfo-param-prop": "要包含的信息束:",
+ "apihelp-query+userinfo-paramvalue-prop-blockinfo": "如果当前用户被封禁就标记,并注明是谁封禁,以何种原因封禁的。",
+ "apihelp-query+userinfo-paramvalue-prop-hasmsg": "如果当前用户有等待中的消息的话,添加标签<samp>messages</samp>。",
+ "apihelp-query+userinfo-paramvalue-prop-groups": "列举当前用户隶属的所有群组。",
+ "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "列举明确分配给当前用户的用户组,包括每个用户组成员的过期时间。",
+ "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "列举当前用户的所有自动成为成员的用户组。",
+ "apihelp-query+userinfo-paramvalue-prop-rights": "列举当前用户拥有的所有权限。",
+ "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "列举当前用户可以添加并移除的用户组。",
+ "apihelp-query+userinfo-paramvalue-prop-options": "列举当前用户设置的所有参数设置。",
+ "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "获取令牌以更改当前用户的参数设置。",
+ "apihelp-query+userinfo-paramvalue-prop-editcount": "添加当前用户的编辑计数。",
+ "apihelp-query+userinfo-paramvalue-prop-ratelimits": "列举所有应用到当前用户的速率限制。",
+ "apihelp-query+userinfo-paramvalue-prop-realname": "添加用户的真实姓名。",
+ "apihelp-query+userinfo-paramvalue-prop-email": "添加用户的电子邮件地址及电子邮件验证日期。",
+ "apihelp-query+userinfo-paramvalue-prop-acceptlang": "重复由客户端以结构化格式发送的<code>Accept-Language</code>标头。",
+ "apihelp-query+userinfo-paramvalue-prop-registrationdate": "添加用户的注册时间。",
+ "apihelp-query+userinfo-paramvalue-prop-unreadcount": "添加用户监视列表上的未独页面计数(最高$1;如果更多则返回<samp>$2</samp>)。",
+ "apihelp-query+userinfo-paramvalue-prop-centralids": "添加中心ID并为用户附加状态。",
+ "apihelp-query+userinfo-param-attachedwiki": "与<kbd>$1prop=centralids</kbd>一起使用,表明用户是否附加于此ID定义的wiki。",
+ "apihelp-query+userinfo-example-simple": "获取有关当前用户的信息。",
+ "apihelp-query+userinfo-example-data": "获取有关当前用户的额外信息。",
+ "apihelp-query+users-summary": "获取有关列出用户的信息。",
+ "apihelp-query+users-param-prop": "要包含的信息束:",
+ "apihelp-query+users-paramvalue-prop-blockinfo": "如果用户被封禁就标记,并注明是谁封禁,以何种原因封禁的。",
+ "apihelp-query+users-paramvalue-prop-groups": "列举每位用户属于的所有组。",
+ "apihelp-query+users-paramvalue-prop-groupmemberships": "列举明确分配给每位用户的用户组,包括每个用户组成员的过期时间。",
+ "apihelp-query+users-paramvalue-prop-implicitgroups": "列举用户自动作为成员之一的所有组。",
+ "apihelp-query+users-paramvalue-prop-rights": "列举每位用户拥有的所有权限。",
+ "apihelp-query+users-paramvalue-prop-editcount": "添加用户的编辑计数。",
+ "apihelp-query+users-paramvalue-prop-registration": "添加用户的注册时间戳。",
+ "apihelp-query+users-paramvalue-prop-emailable": "当用户可以并希望通过[[Special:Emailuser]]接收电子邮件时标记。",
+ "apihelp-query+users-paramvalue-prop-gender": "标记用户性别。返回“male”、“female”或“unknown”。",
+ "apihelp-query+users-paramvalue-prop-centralids": "添加中心ID并为用户附加状态。",
+ "apihelp-query+users-paramvalue-prop-cancreate": "表明是否可以为有效但尚未注册的用户名创建一个账户。",
+ "apihelp-query+users-param-attachedwiki": "与<kbd>$1prop=centralids</kbd>一起使用,表明用户是否附加于此ID定义的wiki。",
+ "apihelp-query+users-param-users": "要获取信息的用户列表。",
+ "apihelp-query+users-param-userids": "要获得信息的用户ID列表。",
+ "apihelp-query+users-param-token": "请改用<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>。",
+ "apihelp-query+users-example-simple": "返回用户<kbd>Example</kbd>的信息。",
+ "apihelp-query+watchlist-summary": "在当前用户的监视列表中获取对页面的最近更改。",
+ "apihelp-query+watchlist-param-allrev": "将同一页面的多个修订包含于指定的时间表内。",
+ "apihelp-query+watchlist-param-start": "枚举的起始时间戳。",
+ "apihelp-query+watchlist-param-end": "枚举的结束时间戳。",
+ "apihelp-query+watchlist-param-namespace": "过滤更改为仅限指定的名字空间。",
+ "apihelp-query+watchlist-param-user": "只列出此用户的更改。",
+ "apihelp-query+watchlist-param-excludeuser": "不要列出此用户的更改。",
+ "apihelp-query+watchlist-param-limit": "根据结果返回的结果总数。",
+ "apihelp-query+watchlist-param-prop": "要获取的额外属性:",
+ "apihelp-query+watchlist-paramvalue-prop-ids": "添加修订ID和页面ID。",
+ "apihelp-query+watchlist-paramvalue-prop-title": "添加页面标题。",
+ "apihelp-query+watchlist-paramvalue-prop-flags": "为编辑添加标记。",
+ "apihelp-query+watchlist-paramvalue-prop-user": "添加做出编辑的用户。",
+ "apihelp-query+watchlist-paramvalue-prop-userid": "添加做出编辑的用户的ID。",
+ "apihelp-query+watchlist-paramvalue-prop-comment": "添加编辑摘要。",
+ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "添加解析过的编辑摘要。",
+ "apihelp-query+watchlist-paramvalue-prop-timestamp": "添加编辑时间戳。",
+ "apihelp-query+watchlist-paramvalue-prop-patrol": "将编辑标记为已巡查。",
+ "apihelp-query+watchlist-paramvalue-prop-sizes": "添加页面的旧有长度和新长度。",
+ "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "添加最近被通知有关编辑的用户的时间戳。",
+ "apihelp-query+watchlist-paramvalue-prop-loginfo": "在适当位置添加日志信息。",
+ "apihelp-query+watchlist-param-show": "只显示满足这些标准的项目。例如,要只查看由登录用户做出的小编辑,设置$1show=minor|!anon。",
+ "apihelp-query+watchlist-param-type": "要显示的更改类型:",
+ "apihelp-query+watchlist-paramvalue-type-edit": "普通页面编辑。",
+ "apihelp-query+watchlist-paramvalue-type-external": "外部更改。",
+ "apihelp-query+watchlist-paramvalue-type-new": "页面创建。",
+ "apihelp-query+watchlist-paramvalue-type-log": "日志记录。",
+ "apihelp-query+watchlist-paramvalue-type-categorize": "分类成员组更改。",
+ "apihelp-query+watchlist-param-owner": "与$1token一起使用以访问不同用户的监视列表。",
+ "apihelp-query+watchlist-param-token": "允许访问其他用户监视列表的安全密钥(可通过用户的[[Special:Preferences#mw-prefsection-watchlist|参数设置]]找到)。",
+ "apihelp-query+watchlist-example-simple": "在当前用户的监视列表中列出用于最近更改页面的最新修订。",
+ "apihelp-query+watchlist-example-props": "在当前用户的监视列表中检索有关用于最近更改页面的最新修订的额外信息。",
+ "apihelp-query+watchlist-example-allrev": "在当前用户的监视列表中检索有关所有最近对页面的更改的信息。",
+ "apihelp-query+watchlist-example-generator": "在当前用户的监视列表中检索用于最近更改页面的页面信息。",
+ "apihelp-query+watchlist-example-generator-rev": "在当前用户的监视列表中检索用于对页面最近更改的修订信息。",
+ "apihelp-query+watchlist-example-wlowner": "在用户<kbd>Example</kbd>的监视列表中列出用于最近更改页面的最新修订。",
+ "apihelp-query+watchlistraw-summary": "获得当前用户的监视列表上的所有页面。",
+ "apihelp-query+watchlistraw-param-namespace": "只列出指定名字空间的页面。",
+ "apihelp-query+watchlistraw-param-limit": "根据结果返回的结果总数。",
+ "apihelp-query+watchlistraw-param-prop": "要获取的额外属性:",
+ "apihelp-query+watchlistraw-paramvalue-prop-changed": "添加最近被通知有关编辑的用户的时间戳。",
+ "apihelp-query+watchlistraw-param-show": "只列出符合这些标准的项目。",
+ "apihelp-query+watchlistraw-param-owner": "与$1token一起使用以访问不同用户的监视列表。",
+ "apihelp-query+watchlistraw-param-token": "允许访问其他用户监视列表的安全密钥(可通过用户的[[Special:Preferences#mw-prefsection-watchlist|参数设置]]找到)。",
+ "apihelp-query+watchlistraw-param-dir": "罗列所采用的方向。",
+ "apihelp-query+watchlistraw-param-fromtitle": "要列举的起始标题(带名字空间前缀)。",
+ "apihelp-query+watchlistraw-param-totitle": "要列举的最终标题(带名字空间前缀)。",
+ "apihelp-query+watchlistraw-example-simple": "列出当前用户的监视列表中的页面。",
+ "apihelp-query+watchlistraw-example-generator": "检索当前用户监视列表上的页面的页面信息。",
+ "apihelp-removeauthenticationdata-summary": "从当前用户移除身份验证数据。",
+ "apihelp-removeauthenticationdata-example-simple": "尝试移除当前用户的<kbd>FooAuthenticationRequest</kbd>数据。",
+ "apihelp-resetpassword-summary": "向用户发送密码重置邮件。",
+ "apihelp-resetpassword-extended-description-noroutes": "没有密码重置路由可用。\n\n在<var>[[mw:Special:MyLanguage/Manual:$wgPasswordResetRoutes|$wgPasswordResetRoutes]]</var>中启用路由以使用此模块。",
+ "apihelp-resetpassword-param-user": "正在重置的用户。",
+ "apihelp-resetpassword-param-email": "正在重置用户的电子邮件地址。",
+ "apihelp-resetpassword-example-user": "向用户<kbd>Example</kbd>发送密码重置邮件。",
+ "apihelp-resetpassword-example-email": "向所有电子邮件地址为<kbd>user@example.com</kbd>的用户发送密码重置邮件。",
+ "apihelp-revisiondelete-summary": "删除和恢复修订版本。",
+ "apihelp-revisiondelete-param-type": "正在执行的修订版本删除类型。",
+ "apihelp-revisiondelete-param-target": "要进行修订版本删除的页面标题,如果对某一类型需要。",
+ "apihelp-revisiondelete-param-ids": "用于将被删除的修订的标识符。",
+ "apihelp-revisiondelete-param-hide": "每次修订要隐藏的东西。",
+ "apihelp-revisiondelete-param-show": "每次修订要恢复显示的东西。",
+ "apihelp-revisiondelete-param-suppress": "是否对管理员及其他人禁止数据。",
+ "apihelp-revisiondelete-param-reason": "删除或恢复的原因。",
+ "apihelp-revisiondelete-param-tags": "要在删除日志中应用到实体的标签。",
+ "apihelp-revisiondelete-example-revision": "隐藏<kbd>首页</kbd>的修订版本<kbd>12345</kbd>的内容。",
+ "apihelp-revisiondelete-example-log": "隐藏日志记录<kbd>67890</kbd>上的所有数据,原因<kbd>BLP violation</kbd>。",
+ "apihelp-rollback-summary": "撤销对页面的最近编辑。",
+ "apihelp-rollback-extended-description": "如果上一对页面做出编辑的用户连续做出了多次编辑,它们将全数被回退。",
+ "apihelp-rollback-param-title": "要回退的页面标题。不能与<var>$1pageid</var>一起使用。",
+ "apihelp-rollback-param-pageid": "要回退的页面的页面 ID。不能与<var>$1title</var>一起使用。",
+ "apihelp-rollback-param-tags": "要应用在回退上的标签。",
+ "apihelp-rollback-param-user": "做出要回退的编辑的用户名称。",
+ "apihelp-rollback-param-summary": "自定义编辑摘要。如果为空,将使用默认摘要。",
+ "apihelp-rollback-param-markbot": "将被回退的编辑和回退操作标记为机器人编辑。",
+ "apihelp-rollback-param-watchlist": "无条件地将页面加入至当前用户的监视列表或将其移除,使用设置或不更改监视。",
+ "apihelp-rollback-example-simple": "回退由用户<kbd>Example</kbd>对<kbd>Main Page</kbd>做出的最近编辑。",
+ "apihelp-rollback-example-summary": "回退由IP用户<kbd>192.0.2.5</kbd>对页面<kbd>Main Page</kbd>做出的最近编辑,带编辑摘要<kbd>Reverting vandalism</kbd>,并将这些编辑和回退标记为机器人编辑。",
+ "apihelp-rsd-summary": "导出一个RSD(Really Simple Discovery)架构。",
+ "apihelp-rsd-example-simple": "导出RSD架构。",
+ "apihelp-setnotificationtimestamp-summary": "更新监视页面的通知时间戳。",
+ "apihelp-setnotificationtimestamp-extended-description": "这会影响监视列表和历史中已更改页面的高亮度,并且如果“{{int:tog-enotifwatchlistpages}}”设置被启用的话,也会影响电子邮件的发送。",
+ "apihelp-setnotificationtimestamp-param-entirewatchlist": "工作于所有已监视页面。",
+ "apihelp-setnotificationtimestamp-param-timestamp": "要设置通知时间戳的时间戳。",
+ "apihelp-setnotificationtimestamp-param-torevid": "要设置通知时间戳的修订(只限一个页面)。",
+ "apihelp-setnotificationtimestamp-param-newerthanrevid": "要设置通知时间戳的较新修订(只限一个页面)。",
+ "apihelp-setnotificationtimestamp-example-all": "重置整个监视列表的通知状态。",
+ "apihelp-setnotificationtimestamp-example-page": "重置用于<kbd>Main page</kbd>的通知状态。",
+ "apihelp-setnotificationtimestamp-example-pagetimestamp": "设置<kbd>Main page</kbd>的通知时间戳,这样所有从2012年1月1日起的编辑都会是未复核的。",
+ "apihelp-setnotificationtimestamp-example-allpages": "重置在<kbd>{{ns:user}}</kbd>名字空间中的页面的通知状态。",
+ "apihelp-setpagelanguage-summary": "更改页面的语言。",
+ "apihelp-setpagelanguage-extended-description-disabled": "此wiki不允许更改页面的语言。\n\n启用<var>[[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]]</var>以使用此操作。",
+ "apihelp-setpagelanguage-param-title": "您希望更改语言的页面标题。不能与<var>$1pageid</var>一起使用。",
+ "apihelp-setpagelanguage-param-pageid": "您希望更改语言的页面ID。不能与<var>$1title</var>一起使用。",
+ "apihelp-setpagelanguage-param-lang": "更改页面的目标语言的语言代码。使用<kbd>default</kbd>以重置页面为wiki的默认内容语言。",
+ "apihelp-setpagelanguage-param-reason": "更改原因。",
+ "apihelp-setpagelanguage-param-tags": "要应用到此操作导致的日志记录的更改标签。",
+ "apihelp-setpagelanguage-example-language": "更改<kbd>Main Page</kbd>的语言为巴斯克语。",
+ "apihelp-setpagelanguage-example-default": "更改ID为123的页面的语言为wiki的默认内容语言。",
+ "apihelp-stashedit-summary": "在分享缓存中准备编辑。",
+ "apihelp-stashedit-extended-description": "这是打算通过使用来自编辑表单的AJAX以改进页面保存的性能。",
+ "apihelp-stashedit-param-title": "已开始编辑的页面标题。",
+ "apihelp-stashedit-param-section": "段落数。<kbd>0</kbd>用于首段,<kbd>new</kbd>用于新的段落。",
+ "apihelp-stashedit-param-sectiontitle": "新段落的标题。",
+ "apihelp-stashedit-param-text": "页面内容。",
+ "apihelp-stashedit-param-stashedtexthash": "要使用的来自先前暂存处的页面内容哈希。",
+ "apihelp-stashedit-param-contentmodel": "新内容的内容模型。",
+ "apihelp-stashedit-param-contentformat": "用于输入文本的内容序列化格式。",
+ "apihelp-stashedit-param-baserevid": "基础修订的修订ID。",
+ "apihelp-stashedit-param-summary": "更改摘要。",
+ "apihelp-tag-summary": "从个别修订或日志记录中添加或移除更改标签。",
+ "apihelp-tag-param-rcid": "要添加或移除标签的一个或更多的最近更改ID。",
+ "apihelp-tag-param-revid": "要添加或移除标签的一个或更多的修订ID。",
+ "apihelp-tag-param-logid": "要添加或移除标签的一个或更多的日志记录ID。",
+ "apihelp-tag-param-add": "要添加的标签。只有手动定义的标签可以添加。",
+ "apihelp-tag-param-remove": "要移除的标签。只有手动定义或完全不明确的标签可以被移除。",
+ "apihelp-tag-param-reason": "更改原因。",
+ "apihelp-tag-param-tags": "要应用到将被创建为此操作结果的日志实体的标签。",
+ "apihelp-tag-example-rev": "将<kbd>vandalism</kbd>标签添加至修订ID 123,而不指定原因",
+ "apihelp-tag-example-log": "从日志记录ID 123移除<kbd>spam</kbd>标签,原因为<kbd>Wrongly applied</kbd>",
+ "apihelp-tokens-summary": "获取数据修改操作的令牌。",
+ "apihelp-tokens-extended-description": "此模块已弃用,以便[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]使用。",
+ "apihelp-tokens-param-type": "要请求的令牌类型。",
+ "apihelp-tokens-example-edit": "检索一个编辑令牌(默认)。",
+ "apihelp-tokens-example-emailmove": "检索一个电子邮件令牌和一个移动令牌。",
+ "apihelp-unblock-summary": "解封一位用户。",
+ "apihelp-unblock-param-id": "解封时需要的封禁ID(通过<kbd>list=blocks</kbd>获得)。不能与<var>$1user</var>或<var>$1userid</var>一起使用。",
+ "apihelp-unblock-param-user": "要解封的用户名、IP地址或IP地址段。不能与<var>$1id</var>或<var>$1userid</var>一起使用。",
+ "apihelp-unblock-param-userid": "要封禁的用户ID。不能与<var>$1id</var>或<var>$1user</var>一起使用。",
+ "apihelp-unblock-param-reason": "解封的原因。",
+ "apihelp-unblock-param-tags": "要在封禁日志中应用到实体的更改标签。",
+ "apihelp-unblock-example-id": "解封封禁ID #<kbd>105</kbd>。",
+ "apihelp-unblock-example-user": "解封用户<kbd>Bob</kbd>,原因<kbd>Sorry Bob</kbd>。",
+ "apihelp-undelete-summary": "恢复删除页面的修订版本。",
+ "apihelp-undelete-extended-description": "被删除修订的列表(包括时间戳)可通过[[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]]检索到,并且被删除的文件ID列表可通过[[Special:ApiHelp/query+filearchive|list=filearchive]]检索到。",
+ "apihelp-undelete-param-title": "要恢复的页面标题。",
+ "apihelp-undelete-param-reason": "恢复的原因。",
+ "apihelp-undelete-param-tags": "要在删除日志中应用到实体的更改标签。",
+ "apihelp-undelete-param-timestamps": "要回复的修订的时间戳。如果<var>$1timestamps</var>和<var>$1fileids</var>都为空,所有将被恢复。",
+ "apihelp-undelete-param-fileids": "要恢复的文件修订ID。如果<var>$1timestamps</var>和<var>$1fileids</var>都为空,所有将被恢复。",
+ "apihelp-undelete-param-watchlist": "无条件地将页面加入至当前用户的监视列表或将其移除,使用设置或不更改监视。",
+ "apihelp-undelete-example-page": "恢复页面<kbd>Main Page</kbd>。",
+ "apihelp-undelete-example-revisions": "恢复<kbd>Main Page</kbd>的两个修订。",
+ "apihelp-unlinkaccount-summary": "从当前用户移除已连接的第三方账户。",
+ "apihelp-unlinkaccount-example-simple": "尝试移除当前用户的,与<kbd>FooAuthenticationRequest</kbd>相关联提供方的链接。",
+ "apihelp-upload-summary": "上传文件,或获取正在等待中的上传的状态。",
+ "apihelp-upload-extended-description": "可以使用的几种方法:\n* 直接上传文件内容,使用<var>$1file</var>参数。\n* 成批上传文件,使用<var>$1filesize</var>、<var>$1chunk</var>和<var>$1offset</var>参数。\n* 有MediaWiki服务器从URL检索一个文件,使用<var>$1url</var>参数。\n* 完成一次由于警告而失败的早前上传,使用<var>$1filekey</var>参数。\n需要注意,当发送<var>$1file</var>时,HTTP POST必须做为一次文件上传(也就是使用<code>multipart/form-data</code>)完成。",
+ "apihelp-upload-param-filename": "目标文件名。",
+ "apihelp-upload-param-comment": "上传注释。如果没有指定<var>$1text</var>,那么它也被用于新文件的初始页面文本。",
+ "apihelp-upload-param-tags": "更改标签以应用于上传日志记录和文件页面修订中。",
+ "apihelp-upload-param-text": "用于新文件的初始页面文本。",
+ "apihelp-upload-param-watch": "监视页面。",
+ "apihelp-upload-param-watchlist": "无条件地将页面加入至当前用户的监视列表或将其移除,使用设置或不更改监视。",
+ "apihelp-upload-param-ignorewarnings": "忽略任何警告。",
+ "apihelp-upload-param-file": "文件内容。",
+ "apihelp-upload-param-url": "要检索文件来源的URL。",
+ "apihelp-upload-param-filekey": "用于识别一次临时藏匿的早前上传的关键字。",
+ "apihelp-upload-param-sessionkey": "与$1filekey相同,基于向后兼容而维护。",
+ "apihelp-upload-param-stash": "如果设置,服务器将临时藏匿文件而不是加入存储库。",
+ "apihelp-upload-param-filesize": "全部上传的文件大小。",
+ "apihelp-upload-param-offset": "数据块的偏移量(字节)。",
+ "apihelp-upload-param-chunk": "大块内容。",
+ "apihelp-upload-param-async": "在可能的情况下,使潜在的大文件操作异步进行。",
+ "apihelp-upload-param-checkstatus": "只检索指定文件密钥的上传状态。",
+ "apihelp-upload-example-url": "从URL上传。",
+ "apihelp-upload-example-filekey": "完成一次由于警告而失败的上传。",
+ "apihelp-userrights-summary": "更改一位用户的组成员。",
+ "apihelp-userrights-param-user": "用户名。",
+ "apihelp-userrights-param-userid": "用户ID。",
+ "apihelp-userrights-param-add": "将用户加入至这些组中,或如果其已作为成员,更新其所在用户组成员资格的终止时间。",
+ "apihelp-userrights-param-expiry": "到期时间戳。可以是相对值(例如<kbd>5 months</kbd>或<kbd>2 weeks</kbd>)或绝对值(例如<kbd>2014-09-18T12:34:56Z</kbd>)。如果只设置一个时间戳,它将被用于所有传递给<var>$1add</var>参数的组。对于永不过时的用户组,使用<kbd>infinite</kbd>、<kbd>indefinite</kbd>、<kbd>infinity</kbd>或<kbd>never</kbd>。",
+ "apihelp-userrights-param-remove": "将用户从这些组中移除。",
+ "apihelp-userrights-param-reason": "更改原因。",
+ "apihelp-userrights-param-tags": "要在用户权限日志中应用到实体的更改标签。",
+ "apihelp-userrights-example-user": "将用户<kbd>FooBot</kbd>添加至<kbd>bot</kbd>用户组,并从<kbd>sysop</kbd>和<kbd>bureaucrat</kbd>组移除。",
+ "apihelp-userrights-example-userid": "将ID为<kbd>123</kbd>的用户加入至<kbd>机器人</kbd>组,并将其从<kbd>管理员</kbd>和<kbd>行政员</kbd>组移除。",
+ "apihelp-userrights-example-expiry": "添加用户<kbd>SometimeSysop</kbd>至用户组<kbd>sysop</kbd>,为期1个月。",
+ "apihelp-validatepassword-summary": "验证密码是否符合wiki的密码方针。",
+ "apihelp-validatepassword-extended-description": "如果密码可以接受,就报告有效性为<samp>Good</samp>,如果密码可用于登录但必须更改,则报告为<samp>Change</samp>,或如果密码不可使用,则报告为<samp>Invalid</samp>。",
+ "apihelp-validatepassword-param-password": "要验证的密码。",
+ "apihelp-validatepassword-param-user": "用户名,供测试账户创建时使用。命名的用户必须不存在。",
+ "apihelp-validatepassword-param-email": "电子邮件,供测试账户创建时使用。",
+ "apihelp-validatepassword-param-realname": "真实姓名,供测试账户创建时使用。",
+ "apihelp-validatepassword-example-1": "验证当前用户的密码<kbd>foobar</kbd>。",
+ "apihelp-validatepassword-example-2": "为创建用户<kbd>Example</kbd>验证密码<kbd>qwerty</kbd>。",
+ "apihelp-watch-summary": "从当前用户的监视列表中添加或移除页面。",
+ "apihelp-watch-param-title": "要(取消)监视的页面。也可使用<var>$1titles</var>。",
+ "apihelp-watch-param-unwatch": "如果设置页面将被取消监视而不是被监视。",
+ "apihelp-watch-example-watch": "监视页面<kbd>Main Page</kbd>。",
+ "apihelp-watch-example-unwatch": "取消监视页面<kbd>Main Page</kbd>。",
+ "apihelp-watch-example-generator": "监视主名字空间中的最少几个页面。",
+ "apihelp-format-example-generic": "返回查询结果为$1格式。",
+ "apihelp-format-param-wrappedhtml": "作为一个JSON对象返回优质打印的HTML和关联的ResouceLoader模块。",
+ "apihelp-json-summary": "输出数据为JSON格式。",
+ "apihelp-json-param-callback": "如果指定,将输出内容包裹在一个指定的函数调用中。出于安全考虑,所有用户相关的数据将被限制。",
+ "apihelp-json-param-utf8": "如果指定,使用十六进制转义序列将大多数(但不是全部)非ASCII的字符编码为UTF-8,而不是替换它们。默认当<var>formatversion</var>不是<kbd>1</kbd>时。",
+ "apihelp-json-param-ascii": "如果指定,使用十六进制转义序列将所有非ASCII编码。默认当<var>formatversion</var>为<kbd>1</kbd>时。",
+ "apihelp-json-param-formatversion": "输出格式:\n;1:向后兼容格式(XML样式布尔值、用于内容节点的<samp>*</samp>键等)。\n;2:实验现代格式。细节可能更改!\n;latest:使用最新格式(当前为<kbd>2</kbd>),格式可能在没有警告的情况下更改。",
+ "apihelp-jsonfm-summary": "输出数据为JSON格式(HTML优质打印效果)。",
+ "apihelp-none-summary": "不输出任何东西。",
+ "apihelp-php-summary": "输出数据为序列化PHP格式。",
+ "apihelp-php-param-formatversion": "输出格式:\n;1:向后兼容格式(XML样式布尔值、用于内容节点的<samp>*</samp>键等)。\n;2:实验现代格式。细节可能更改!\n;latest:使用最新格式(当前为<kbd>2</kbd>),格式可能在没有警告的情况下更改。",
+ "apihelp-phpfm-summary": "输出数据为序列化PHP格式(HTML优质打印效果)。",
+ "apihelp-rawfm-summary": "输出数据为JSON格式,包含调试元素(HTML优质打印效果)。",
+ "apihelp-xml-summary": "输出数据为XML格式。",
+ "apihelp-xml-param-xslt": "如果指定,加入已命名的页面作为一个XSL样式表。值必须是在{{ns:MediaWiki}}名字空间以<code>.xsl</code>为结尾的标题。",
+ "apihelp-xml-param-includexmlnamespace": "如果指定,添加一个XML名字空间。",
+ "apihelp-xmlfm-summary": "输出数据为XML格式(HTML优质打印效果)。",
+ "api-format-title": "MediaWiki API 结果",
+ "api-format-prettyprint-header": "这是$1格式的HTML实现。HTML对调试很有用,但不适合应用程序使用。\n\n指定<var>format</var>参数以更改输出格式。要查看$1格式的非HTML实现,设置<kbd>format=$2</kbd>。\n\n参见[[mw:Special:MyLanguage/API|完整文档]],或[[Special:ApiHelp/main|API帮助]]以获取更多信息。",
+ "api-format-prettyprint-header-only-html": "这是用来调试的HTML实现,不适合实际使用。\n\n参见[[mw:Special:MyLanguage/API|完整文档]],或[[Special:ApiHelp/main|API帮助]]以获取更多信息。",
+ "api-format-prettyprint-header-hyperlinked": "这是$1格式的HTML实现。HTML对调试很有用,但不适合应用程序使用。\n\n指定<var>format</var>参数以更改输出格式。要查看$1格式的非HTML实现,设置[$3 <kbd>format=$2</kbd>]。\n\n参见[[mw:API|完整文档]],或[[Special:ApiHelp/main|API帮助]]以获取更多信息。",
+ "api-format-prettyprint-status": "此响应将会返回HTTP状态$1 $2。",
+ "api-login-fail-aborted": "身份验证需要用户交互,而其不被<kbd>action=login</kbd>支持。要通过<kbd>action=login</kbd>登录,请参见[[Special:BotPasswords]]。要继续使用主账户登录,请参见<kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>。",
+ "api-login-fail-aborted-nobotpw": "身份验证需要用户交互,而其不被<kbd>action=login</kbd>支持。要登录,请参见<kbd>[[Special:ApiHelp/clientlogin|action=clientlogin]]</kbd>。",
+ "api-login-fail-badsessionprovider": "当使用$1时不能登录。",
+ "api-login-fail-sameorigin": "当同源协议未应用时不能登录。",
+ "api-pageset-param-titles": "要工作的标题列表。",
+ "api-pageset-param-pageids": "要工作的页面ID列表。",
+ "api-pageset-param-revids": "要工作的修订ID列表。",
+ "api-pageset-param-generator": "通过执行指定查询模块获得页面列表以工作。\n\n<strong>注意:</strong>发生器参数名称必须以“g”开头,参见例子。",
+ "api-pageset-param-redirects-generator": "自动解决在<var>$1titles</var>、<var>$1pageids</var>和<var>$1revids</var>,以及在由<var>$1generator</var>返回的页面中的重定向。",
+ "api-pageset-param-redirects-nogenerator": "自动解决<var>$1titles</var>、<var>$1pageids</var>和<var>$1revids</var>中的重定向。",
+ "api-pageset-param-converttitles": "如有需要,将标题转换为其他变体。只有当wiki的内容语言支持变体转换时才能工作。支持变体转换的语言包括$1。",
+ "api-help-title": "MediaWiki API 帮助",
+ "api-help-lead": "这是自动生成的MediaWiki API文档页面。\n\n文档和例子:https://www.mediawiki.org/wiki/API:Main_page/zh",
+ "api-help-main-header": "主模块",
+ "api-help-undocumented-module": "没有用于模块$1的文档。",
+ "api-help-flag-deprecated": "此模块已弃用。",
+ "api-help-flag-internal": "<strong>此模块是内部或不稳定的。</strong>它的操作可以更改而不另行通知。",
+ "api-help-flag-readrights": "此模块需要读取权限。",
+ "api-help-flag-writerights": "此模块需要写入权限。",
+ "api-help-flag-mustbeposted": "此模块只允许POST请求。",
+ "api-help-flag-generator": "此模块可作为发生器使用。",
+ "api-help-source": "来源:$1",
+ "api-help-source-unknown": "来源:<span class=\"apihelp-unknown\">未知</span>",
+ "api-help-license": "许可协议:[[$1|$2]]",
+ "api-help-license-noname": "许可协议:[[$1|参见链接]]",
+ "api-help-license-unknown": "许可协议:<span class=\"apihelp-unknown\">未知</span>",
+ "api-help-parameters": "{{PLURAL:$1|参数}}:",
+ "api-help-param-deprecated": "已弃用。",
+ "api-help-param-required": "这个参数是必须的。",
+ "api-help-datatypes-header": "数据类型",
+ "api-help-datatypes": "至MediaWiki的输入应为NFC标准化的UTF-8。MediaWiki可以尝试转换其他输入,但这可能导致一些操作失败(例如带MD5校验[[Special:ApiHelp/edit|编辑]])。\n\n一些在API请求中的参数类型需要更进一步解释:\n;boolean\n:布尔参数就像HTML复选框一样工作:如果指定参数,无论何值都被认为是真。如果要假值,则可完全忽略参数。\n;timestamp\n:时间戳可被指定为很多格式。推荐使用ISO 8601日期和时间标准。所有时间为UTC时间,包含的任何时区会被忽略。\n:* ISO 8601日期和时间,<kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd>(标点和<kbd>Z</kbd>是可选项)\n:* 带小数秒(会被忽略)的ISO 8601日期和时间,<kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd>(破折号、冒号和<kbd>Z</kbd>是可选的)\n:* MediaWiki格式,<kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* 一般数字格式,<kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>(<kbd>GMT</kbd>、<kbd>+<var>##</var></kbd>或<kbd>-<var>##</var></kbd>的可选时区会被忽略)\n:* EXIF格式,<kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* RFC 2822格式(时区可省略),<kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* RFC 850格式(时区可省略),<kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* C ctime格式,<kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* 从1970-01-01T00:00:00Z开始的秒数,作为1到13位数的整数(除了<kbd>0</kbd>)\n:* 字符串<kbd>now</kbd>\n;替代多值分隔符\n:使用多个值的参数通常会与管道符号分隔的值一起提交,例如<kbd>param=value1|value2</kbd>或<kbd>param=value1%7Cvalue2</kbd>。如果值必须包含管道符号,使用U+001F(单位分隔符)作为分隔符,''并''在值前加前缀U+001F,例如<kbd>param=%1Fvalue1%1Fvalue2</kbd>。",
+ "api-help-param-type-limit": "类型:整数或<kbd>max</kbd>",
+ "api-help-param-type-integer": "类型:{{PLURAL:$1|1=整数|2=整数列表}}",
+ "api-help-param-type-boolean": "类型:布尔值([[Special:ApiHelp/main#main/datatypes|详细信息]])",
+ "api-help-param-type-timestamp": "类型:{{PLURAL:$1|1=时间戳|2=时间戳列表}}([[Special:ApiHelp/main#main/datatypes|允许格式]])",
+ "api-help-param-type-user": "类型:{{PLURAL:$1|1=用户名|2=用户名列表}}",
+ "api-help-param-list": "{{PLURAL:$1|1=以下值中的一个|2=值(以<kbd>{{!}}</kbd>或[[Special:ApiHelp/main#main/datatypes|替代物]]分隔)}}:$2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=必须为空|可以为空,或$2}}",
+ "api-help-param-limit": "不允许超过$1。",
+ "api-help-param-limit2": "不允许超过$1个(对于机器人则是$2个)。",
+ "api-help-param-integer-min": "{{PLURAL:$1|值}}必须不少于$2。",
+ "api-help-param-integer-max": "{{PLURAL:$1|值}}必须不大于$3。",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|值}}必须介于$2和$3之间。",
+ "api-help-param-upload": "必须被公布为使用multipart/form-data的一次文件上传。",
+ "api-help-param-multi-separate": "通过<kbd>|</kbd>或[[Special:ApiHelp/main#main/datatypes|替代物]]隔开各值。",
+ "api-help-param-multi-max": "值的最大数量是{{PLURAL:$1|$1}}(对于机器人则是{{PLURAL:$2|$2}})。",
+ "api-help-param-multi-max-simple": "值的最大数量为{{PLURAL:$1|$1}}。",
+ "api-help-param-multi-all": "要指定所有值,请使用<kbd>$1</kbd>。",
+ "api-help-param-default": "默认:$1",
+ "api-help-param-default-empty": "默认:<span class=\"apihelp-empty\">(空)</span>",
+ "api-help-param-token": "从[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]取回的“$1”令牌",
+ "api-help-param-token-webui": "出于兼容性考虑,web UI中使用的令牌也被接受。",
+ "api-help-param-disabled-in-miser-mode": "由于[[mw:Special:MyLanguage/Manual:$wgMiserMode|miser模式]]而禁用。",
+ "api-help-param-limited-in-miser-mode": "<strong>注意:</strong>由于[[mw:Special:MyLanguage/Manual:$wgMiserMode|miser模式]],使用这个可能导致继续前返回少于<var>$1limit</var>个结果;极端情况下可能不会返回任何结果。",
+ "api-help-param-direction": "列举的方向:\n;newer:最早的优先。注意:$1start应早于$1end。\n;older:最新的优先(默认)。注意:$1start应晚于$1end。",
+ "api-help-param-continue": "当更多结果可用时,使用这个继续。",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(没有说明)</span>",
+ "api-help-examples": "{{PLURAL:$1|例子}}:",
+ "api-help-permissions": "{{PLURAL:$1|权限}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|授予}}:$2",
+ "api-help-right-apihighlimits": "在API查询中使用更高的上限(慢查询:$1;快查询:$2)。慢查询的限制也适用于多值参数。",
+ "api-help-open-in-apisandbox": "<small>[在沙盒中打开]</small>",
+ "api-help-authmanager-general-usage": "使用此模块的一般程序是:\n# 通过<kbd>amirequestsfor=$4</kbd>取得来自<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>的可用字段,和来自<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>的<kbd>$5</kbd>令牌。\n# 向用户显示字段,并获得其提交的内容。\n# 发送(POST)至此模块,提供<var>$1returnurl</var>及任何相关字段。\n# 在响应中检查<samp>status</samp>。\n#* 如果您收到了<samp>PASS</samp>(成功)或<samp>FAIL</samp>(失败),则认为操作结束。成功与否如上句所示。\n#* 如果您收到了<samp>UI</samp>,向用户显示新字段,并再次获取其提交的内容。然后再次使用<var>$1continue</var>,向本模块提交相关字段,并重复第四步。\n#* 如果您收到了<samp>REDIRECT</samp>,将用户指向<samp>redirecttarget</samp>中的目标,等待其返回<var>$1returnurl</var>。然后再次使用<var>$1continue</var>,向本模块提交返回URL中提供的一切字段,并重复第四步。\n#* 如果您收到了<samp>RESTART</samp>,这意味着身份验证正常运作,但我们没有链接的用户账户。您可以将此看做<samp>UI</samp>或<samp>FAIL</samp>。",
+ "api-help-authmanagerhelper-requests": "只使用这些身份验证请求,通过返回自<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>的<samp>id</samp>与<kbd>amirequestsfor=$1</kbd>,或来自此模块之前的响应。",
+ "api-help-authmanagerhelper-request": "使用此身份验证请求,通过返回自<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>的<samp>id</samp>与<kbd>amirequestsfor=$1</kbd>。",
+ "api-help-authmanagerhelper-messageformat": "返回消息使用的格式。",
+ "api-help-authmanagerhelper-mergerequestfields": "合并用于所有身份验证请求的字段信息至一个数组中。",
+ "api-help-authmanagerhelper-preservestate": "从之前失败的登录尝试中保持状态,如果可能。",
+ "api-help-authmanagerhelper-returnurl": "为第三方身份验证流返回URL,必须为绝对值。需要此值或<var>$1continue</var>两者之一。\n\n在接收<samp>REDIRECT</samp>响应时,您将代表性的打开浏览器或web视图到特定用于第三方身份验证流的<samp>redirecttarget</samp> URL。当它完成时,第三方将发生浏览器或web视图至此URL。您应当提取任何来自URL的查询或POST参数,并作为<var>$1continue</var>请求传递至此API模块。",
+ "api-help-authmanagerhelper-continue": "此请求是在早先的<samp>UI</samp>或<samp>REDIRECT</samp>响应之后的附加请求。必需此值或<var>$1returnurl</var>。",
+ "api-help-authmanagerhelper-additional-params": "此模块允许额外参数,取决于可用的身份验证请求。使用<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>与<kbd>amirequestsfor=$1</kbd>(或之前来自此模块的相应,如果可以)以决定可用请求及其使用的字段。",
+ "apierror-allimages-redirect": "当使用<kbd>allimages</kbd>作为发生器时,请使用<kbd>gaifilterredir=nonredirects</kbd>而不是<var>redirects</var>。",
+ "apierror-allpages-generator-redirects": "当使用<kbd>allpages</kbd>作为发生器时,请使用<kbd>gapfilterredir=nonredirects</kbd>而不是<var>redirects</var>。",
+ "apierror-appendnotsupported": "不能使用内容模型$1附加在页面上。",
+ "apierror-articleexists": "您尝试创建的条目已刚刚被创建。",
+ "apierror-assertbotfailed": "主张用户有<code>bot</code>权限失败。",
+ "apierror-assertnameduserfailed": "主张用户为“$1”失败。",
+ "apierror-assertuserfailed": "主张用户已登录失败。",
+ "apierror-autoblocked": "您的IP地址已被自动封禁,因为它曾被一位已封禁用户使用。",
+ "apierror-badconfig-resulttoosmall": "此wiki上<code>$wgAPIMaxResultSize</code>的值太小,不能获得基础结果信息。",
+ "apierror-badcontinue": "无效继续参数。您应该传递由之前查询返回的原始值。",
+ "apierror-baddiff": "不能取得差异。一个或多个修订版本不存在,或您没有权限查看它们。",
+ "apierror-baddiffto": "<var>$1diffto</var>必须设置为非负数、<kbd>prev</kbd>、<kbd>next</kbd>或<kbd>cur</kbd>。",
+ "apierror-badformat-generic": "内容模型$2尚不支持请求的内容格式$1。",
+ "apierror-badformat": "由$3使用的内容模型$2尚不支持请求的内容格式$1。",
+ "apierror-badgenerator-notgenerator": "模块<kbd>$1</kbd>不能用作发生器。",
+ "apierror-badgenerator-unknown": "未知<kbd>generator=$1</kbd>。",
+ "apierror-badip": "IP参数无效。",
+ "apierror-badmd5": "提供的MD5哈希不正确。",
+ "apierror-badmodule-badsubmodule": "模块<kbd>$1</kbd>不包含子模块“$2”。",
+ "apierror-badmodule-nosubmodules": "模块<kbd>$1</kbd>没有子模块。",
+ "apierror-badparameter": "用于参数<var>$1</var>的值无效。",
+ "apierror-badquery": "无效的查询。",
+ "apierror-badtimestamp": "用于时间戳参数<var>$1</var>的值“$2”无效。",
+ "apierror-badtoken": "无效的CSRF令牌。",
+ "apierror-badupload": "文件上传参数<var>$1</var>不是文件上传;确保为您的POST使用<code>multipart/form-data</code>,并在<code>Content-Disposition</code>标头中包含文件名。",
+ "apierror-badurl": "用于URL参数<var>$1</var>的值“$2”无效。",
+ "apierror-baduser": "用于用户参数<var>$1</var>的值“$2”无效。",
+ "apierror-badvalue-notmultivalue": "U+001F多值分隔符只可以用于多值参数。",
+ "apierror-bad-watchlist-token": "提供了不正确的监视列表令牌。请在[[Special:Preferences]]设置正确的令牌。",
+ "apierror-blockedfrommail": "您已被封禁,不能发送电子邮件。",
+ "apierror-blocked": "您已被封禁,不能编辑。",
+ "apierror-botsnotsupported": "此界面不支持机器人。",
+ "apierror-cannot-async-upload-file": "参数<var>async</var>和<var>file</var>不能结合。如果您希望对您上传的文件进行不同处理,请将其首先上传至暂存处(使用<var>stash</var>参数),然后异步发布暂存文件(使用<var>filekey</var>和<var>async</var>)。",
+ "apierror-cannotreauthenticate": "由于您的身份不能被验证,此操作不可用。",
+ "apierror-cannotviewtitle": "您不被允许查看$1。",
+ "apierror-cantblock-email": "您没有权限封禁用户通过wiki发送电子邮件。",
+ "apierror-cantblock": "您没有权限封禁用户。",
+ "apierror-cantchangecontentmodel": "您没有权限更改页面的内容模型。",
+ "apierror-canthide": "您没有权限从封禁日志中隐藏用户名。",
+ "apierror-cantimport-upload": "您没有权限导入上传的页面。",
+ "apierror-cantimport": "您没有权限导入页面。",
+ "apierror-cantoverwrite-sharedfile": "目标文件存在于分享存储库,并且您没有权限覆盖它。",
+ "apierror-cantsend": "您没有登录,您没有已确认的电子邮件地址,或者您未被允许向其他用户发送电子邮件,所以您不能发送电子邮件。",
+ "apierror-cantundelete": "不能还原:请求的修订版本可能不存在,或可能已被还原。",
+ "apierror-changeauth-norequest": "创建更改请求失败。",
+ "apierror-chunk-too-small": "对于非最终块,最小块大小为$1{{PLURAL:$1|字节}}。",
+ "apierror-cidrtoobroad": "比/$2更宽的$1 CIDR地址段不被接受。",
+ "apierror-compare-no-title": "不能在没有标题的情况下预保存转换。尝试指定<var>fromtitle</var>或<var>totitle</var>。",
+ "apierror-compare-relative-to-nothing": "没有与<var>torelative</var>的“from”修订版本相对的版本。",
+ "apierror-contentserializationexception": "内容序列化失败:$1",
+ "apierror-contenttoobig": "您提供的内容超过了$1{{PLURAL:$1|千字节}}的条目大小限制。",
+ "apierror-copyuploadbaddomain": "不允许从此域名通过URL上传。",
+ "apierror-copyuploadbadurl": "不允许从此URL上传。",
+ "apierror-create-titleexists": "现有标题不能通过<kbd>create</kbd>保护。",
+ "apierror-csp-report": "处理CSP报告时出错:$1。",
+ "apierror-databaseerror": "[$1]数据库查询错误。",
+ "apierror-deletedrevs-param-not-1-2": "<var>$1</var>参数不能用于模式1或2。",
+ "apierror-deletedrevs-param-not-3": "<var>$1</var>参数不能用于模式3。",
+ "apierror-emptynewsection": "无法创建空白新章节。",
+ "apierror-emptypage": "不允许创建新的,空白的页面。",
+ "apierror-exceptioncaught": "[$1]捕获异常:$2",
+ "apierror-filedoesnotexist": "文件不存在。",
+ "apierror-fileexists-sharedrepo-perm": "目标文件存在于共享存储库。使用<var>ignorewarnings</var>参数覆盖它。",
+ "apierror-filenopath": "不能获取本地文件路径。",
+ "apierror-filetypecannotberotated": "文件类型不能旋转。",
+ "apierror-formatphp": "此响应不能使用<kbd>format=php</kbd>代表。请参见https://phabricator.wikimedia.org/T68776。",
+ "apierror-imageusage-badtitle": "<kbd>$1</kbd>的标题必须是文件。",
+ "apierror-import-unknownerror": "导入时的未知错误:$1。",
+ "apierror-integeroutofrange-abovebotmax": "对于机器人和管理员,<var>$1</var>不能超过$2(设置为$3)。",
+ "apierror-integeroutofrange-abovemax": "对于用户,<var>$1</var>不能超过$2(设置为$3)。",
+ "apierror-integeroutofrange-belowminimum": "<var>$1</var>不能小于$2(设置为$3)。",
+ "apierror-invalidcategory": "您输入的分类名称无效。",
+ "apierror-invalid-chunk": "偏移值与当前数据块之和大于声称的文件大小。",
+ "apierror-invalidexpiry": "无效的过期时间“$1”。",
+ "apierror-invalid-file-key": "不是有效的文件关键词。",
+ "apierror-invalidlang": "用于参数<var>$1</var>的语言值无效。",
+ "apierror-invalidoldimage": "<var>oldimage</var>参数有无效格式。",
+ "apierror-invalidparammix-cannotusewith": "<kbd>$1</kbd>参数不能与<kbd>$2</kbd>一起使用。",
+ "apierror-invalidparammix-mustusewith": "<kbd>$1</kbd>参数只能与<kbd>$2</kbd>一起使用。",
+ "apierror-invalidparammix-parse-new-section": "<kbd>section=new</kbd>不能连同<var>oldid</var>、<var>pageid</var>或<var>page</var>参数使用。请使用<var>title</var>和<var>text</var>。",
+ "apierror-invalidparammix": "{{PLURAL:$2|参数}}$1不能一起使用。",
+ "apierror-invalidsection": "<var>section</var>参数必须为有效的章节ID或<kbd>new</kbd>。",
+ "apierror-invalidsha1base36hash": "提供的SHA1Base36哈希无效。",
+ "apierror-invalidsha1hash": "提供的SHA1哈希无效。",
+ "apierror-invalidtitle": "错误标题“$1”。",
+ "apierror-invalidurlparam": "<var>$1urlparam</var>的值无效(<kbd>$2=$3</kbd>)。",
+ "apierror-invaliduser": "无效用户名“$1”。",
+ "apierror-invaliduserid": "用户ID<var>$1</var>无效。",
+ "apierror-maxlag-generic": "正在等待数据库服务器:已延迟$1{{PLURAL:$1|秒}}。",
+ "apierror-maxlag": "正在等待$2:已延迟$1{{PLURAL:$1|秒}}。",
+ "apierror-mimesearchdisabled": "MIME搜索在Miser模式中被禁用。",
+ "apierror-missingcontent-pageid": "丢失ID为$1的页面的内容。",
+ "apierror-missingcontent-revid": "丢失ID为$1的修订版本的内容。",
+ "apierror-missingparam-at-least-one-of": "需要{{PLURAL:$2|参数$1|$1中的至少一个参数}}。",
+ "apierror-missingparam-one-of": "需要{{PLURAL:$2|参数$1|$1中的一个参数}}。",
+ "apierror-missingparam": "<var>$1</var>参数必须被设置。",
+ "apierror-missingrev-pageid": "没有ID为$1的页面的当前修订版本。",
+ "apierror-missingrev-title": "没有标题$1的当前修订版本。",
+ "apierror-missingtitle-createonly": "丢失标题只可以通过<kbd>create</kbd>保护。",
+ "apierror-missingtitle": "您指定的页面不存在。",
+ "apierror-missingtitle-byname": "页面$1不存在。",
+ "apierror-moduledisabled": "<kbd>$1</kbd>模块已被禁用。",
+ "apierror-multival-only-one-of": "参数<var>$1</var>只允许$2{{PLURAL:$3||之一}}。",
+ "apierror-multival-only-one": "参数<var>$1</var>只允许一个值。",
+ "apierror-multpages": "<var>$1</var>只可以在单一页面使用。",
+ "apierror-mustbeloggedin-changeauth": "您必须登录以更改身份验证数据。",
+ "apierror-mustbeloggedin-generic": "您必须登录。",
+ "apierror-mustbeloggedin-linkaccounts": "您必须登录以链接账户。",
+ "apierror-mustbeloggedin-removeauth": "您必须登录以移除身份验证数据。",
+ "apierror-mustbeloggedin-uploadstash": "上传暂存功能只对已登录用户可用。",
+ "apierror-mustbeloggedin": "您必须登录至$1。",
+ "apierror-mustbeposted": "<kbd>$1</kbd>模块需要POST请求。",
+ "apierror-mustpostparams": "以下{{PLURAL:$2|参数}}在查询字符串中被找到,但必须在POST正文中:$1。",
+ "apierror-noapiwrite": "通过API编辑此wiki已禁用。请确保<code>$wgEnableWriteAPI=true;</code>声明包含在wiki的<code>LocalSettings.php</code>文件中。",
+ "apierror-nochanges": "没有请求的更改。",
+ "apierror-nodeleteablefile": "没有该文件的旧版本。",
+ "apierror-no-direct-editing": "$2使用的内容模型$1不支持通过API直接编辑。",
+ "apierror-noedit-anon": "匿名用户不能编辑页面。",
+ "apierror-noedit": "您没有权限编辑页面。",
+ "apierror-noimageredirect-anon": "匿名用户不能创建图片重定向。",
+ "apierror-noimageredirect": "您没有权限创建图片重定向。",
+ "apierror-nosuchlogid": "没有ID为$1的日志记录。",
+ "apierror-nosuchpageid": "没有ID为$1的页面。",
+ "apierror-nosuchrcid": "没有ID为$1的最近更改。",
+ "apierror-nosuchrevid": "没有ID为$1的修订版本。",
+ "apierror-nosuchsection": "没有章节$1。",
+ "apierror-nosuchsection-what": "在$2中没有章节$1。",
+ "apierror-nosuchuserid": "没有ID为$1的用户。",
+ "apierror-notarget": "您没有为此章节指定有效目标。",
+ "apierror-notpatrollable": "修订版本r$1不能巡查,因为它太旧了。",
+ "apierror-nouploadmodule": "未设置上传模块。",
+ "apierror-offline": "由于网络连接问题无法进行。请确保您的网络连接正常工作,并重试。",
+ "apierror-opensearch-json-warnings": "警告不能以OpenSearch JSON格式表示。",
+ "apierror-pagecannotexist": "名字空间不允许实际页面。",
+ "apierror-pagedeleted": "在您取得页面时间戳以来,页面已被删除。",
+ "apierror-pagelang-disabled": "此wiki不允许更改页面的语言。",
+ "apierror-paramempty": "参数<var>$1</var>不能为空。",
+ "apierror-parsetree-notwikitext": "<kbd>prop=parsetree</kbd>只支持wiki文本内容。",
+ "apierror-parsetree-notwikitext-title": "<kbd>prop=parsetree</kbd>只支持wiki文本内容。$1使用内容模型$2。",
+ "apierror-pastexpiry": "终止时间“$1”已过去。",
+ "apierror-permissiondenied": "您没有权限$1。",
+ "apierror-permissiondenied-generic": "权限被拒绝。",
+ "apierror-permissiondenied-patrolflag": "您需要<code>patrol</code>或<code>patrolmarks</code>权限来请求巡查标记。",
+ "apierror-permissiondenied-unblock": "您没有权限解封用户。",
+ "apierror-prefixsearchdisabled": "前缀搜索在Miser模式中被禁用。",
+ "apierror-promised-nonwrite-api": "<code>Promise-Non-Write-API-Action</code> HTTP标头不能发送至写模式API模块。",
+ "apierror-protect-invalidaction": "无效的保护类型“$1”。",
+ "apierror-protect-invalidlevel": "无效的保护级别“$1”。",
+ "apierror-ratelimited": "您已超过您的速率限制。请等待一段时间再试。",
+ "apierror-readapidenied": "您需要读取权限以使用此模块。",
+ "apierror-readonly": "此wiki目前为只读模式。",
+ "apierror-reauthenticate": "您在该会话中尚未经过验证,请重新验证。",
+ "apierror-redirect-appendonly": "您试图使用重定向跟随模式编辑,而这必须与<kbd>section=new</kbd>、<var>prependtext</var>或<var>appendtext</var>共同使用。",
+ "apierror-revdel-mutuallyexclusive": "同一字段不能同时用于<var>hide</var>和<var>show</var>。",
+ "apierror-revdel-needtarget": "此修订版本删除类型需要目标标题。",
+ "apierror-revdel-paramneeded": "需要<var>hide</var>和/或<var>show</var>的至少一个值。",
+ "apierror-revisions-badid": "未找到参数<var>$1</var>的修订版本。",
+ "apierror-revisions-norevids": "<var>revids</var>参数不能与列表选项(<var>$1limit</var>、<var>$1startid</var>、<var>$1endid</var>、<kbd>$1dir=newer</kbd>、<var>$1user</var>、<var>$1excludeuser</var>、<var>$1start</var>和<var>$1end</var>)一起使用",
+ "apierror-revisions-singlepage": "<var>titles</var>、<var>pageids</var>或发生器用于提供多个页面,但<var>$1limit</var>、<var>$1startid</var>、<var>$1endid</var>、<kbd>$1dir=newer</kbd>、<var>$1user</var>、<var>$1excludeuser</var>、<var>$1start</var>和<var>$1end</var>参数只能在一个页面上使用。",
+ "apierror-revwrongpage": "r$1不是$2的修订版本。",
+ "apierror-searchdisabled": "<var>$1</var>搜索已禁用。",
+ "apierror-sectionreplacefailed": "不能合并更新的章节。",
+ "apierror-sectionsnotsupported": "内容模型$1不支持章节。",
+ "apierror-sectionsnotsupported-what": "章节不被$1所支持。",
+ "apierror-show": "不正确的参数——不可提供互斥值。",
+ "apierror-siteinfo-includealldenied": "除非<var>$wgShowHostNames</var>为真,否则不能查看所有服务器的信息。",
+ "apierror-sizediffdisabled": "大小差异在Miser模式中被禁用。",
+ "apierror-spamdetected": "您的编辑被拒绝,因为它包含垃圾部分:<code>$1</code>。",
+ "apierror-specialpage-cantexecute": "您没有权限查看此特殊页面的结果。",
+ "apierror-stashedfilenotfound": "无法在暂存处找到文件:$1。",
+ "apierror-stashedit-missingtext": "提供的哈希中找不到暂存文本。",
+ "apierror-stashfailed-complete": "大块上传已经完成,检查状态以获取详情。",
+ "apierror-stashfailed-nosession": "没有带此关键词的大块上传会话。",
+ "apierror-stashfilestorage": "不能在暂存处存储上传:$1",
+ "apierror-stashinvalidfile": "无效暂存文件。",
+ "apierror-stashnosuchfilekey": "没有这个filekey:$1。",
+ "apierror-stashpathinvalid": "文件密钥的格式不正确,或属于其他无效格式:$1。",
+ "apierror-stashwrongowner": "错误所有者:$1",
+ "apierror-stashzerolength": "文件长度为0,并且不能在暂存库中储存:$1。",
+ "apierror-systemblocked": "您已被MediaWiki自动封禁。",
+ "apierror-templateexpansion-notwikitext": "模板展开只支持wiki文本内容。$1使用内容模型$2。",
+ "apierror-timeout": "服务器没有在预期时间内响应。",
+ "apierror-toofewexpiries": "提供了$1个逾期{{PLURAL:$1|时间戳}},实际则需要$2个。",
+ "apierror-unknownaction": "指定的操作<kbd>$1</kbd>不被承认。",
+ "apierror-unknownerror-editpage": "未知的编辑页面错误:$1。",
+ "apierror-unknownerror-nocode": "未知错误。",
+ "apierror-unknownerror": "未知错误:“$1”。",
+ "apierror-unknownformat": "无法识别的格式“$1”。",
+ "apierror-unrecognizedparams": "无法识别的{{PLURAL:$2|参数}}:$1。",
+ "apierror-unrecognizedvalue": "无法识别的参数<var>$1</var>的值:$2。",
+ "apierror-unsupportedrepo": "本地文件存储库不支持查询所有图片。",
+ "apierror-upload-filekeyneeded": "当<var>offset</var>不为0时,必须提供<var>filekey</var>。",
+ "apierror-upload-filekeynotallowed": "当<var>offset</var>为0时,不能提供<var>filekey</var>。",
+ "apierror-upload-inprogress": "从暂存处上传已在进行中。",
+ "apierror-upload-missingresult": "状态数据中没有结果。",
+ "apierror-urlparamnormal": "不能为$1标准化图片参数。",
+ "apierror-writeapidenied": "您不被允许通过API编辑此wiki。",
+ "apiwarn-alldeletedrevisions-performance": "当生成标题时,为获得更好性能,请设置<kbd>$1dir=newer</kbd>。",
+ "apiwarn-badurlparam": "不能为$2解析<var>$1urlparam</var>。请只使用宽和高。",
+ "apiwarn-badutf8": "<var>$1</var>通过的值包含无效或非标准化数据。正文数据应为有效的NFC标准化Unicode,没有除HT(\\t)、LF(\\n)和CR(\\r)以外的C0控制字符。",
+ "apiwarn-checktoken-percentencoding": "在令牌中检查例如“+”的符号会在URL中适当进行百分号编码。",
+ "apiwarn-compare-nocontentmodel": "没有可以定义的模型,假定为$1。",
+ "apiwarn-deprecation-deletedrevs": "<kbd>list=deletedrevs</kbd>已弃用。请改用<kbd>prop=deletedrevisions</kbd>或<kbd>list=alldeletedrevisions</kbd>。",
+ "apiwarn-deprecation-expandtemplates-prop": "因为没有为<var>prop</var>参数指定值,所以在输出上使用了遗留格式。这种格式已弃用,并且将来会为<var>prop</var>参数设置默认值,这会导致新格式总会被使用。",
+ "apiwarn-deprecation-httpsexpected": "当应为HTTPS时,HTTP被使用。",
+ "apiwarn-deprecation-login-botpw": "通过<kbd>action=login</kbd>的主账户登录已被弃用,并可能在未事先警告的情况下停止工作。要继续通过<kbd>action=login</kbd>登录,请参见[[Special:BotPasswords]]。要安全继续使用主账户登录,请参见<kbd>action=clientlogin</kbd>。",
+ "apiwarn-deprecation-login-nobotpw": "通过<kbd>action=login</kbd>的主账户登录已被弃用,并可能在未事先警告的情况下停止工作。要安全登录,请参见<kbd>action=clientlogin</kbd>。",
+ "apiwarn-deprecation-login-token": "通过<kbd>action=login</kbd>取得令牌已弃用。请改用<kbd>action=query&meta=tokens&type=login</kbd>。",
+ "apiwarn-deprecation-parameter": "参数<var>$1</var>已被弃用。",
+ "apiwarn-deprecation-parse-headitems": "<kbd>prop=headitems</kbd>从MediaWiki 1.28版开始已弃用。在创建新HTML文档时请使用<kbd>prop=headhtml</kbd>,或当更新文档客户端时请使用<kbd>prop=modules|jsconfigvars</kbd>。",
+ "apiwarn-deprecation-purge-get": "通过GET使用<kbd>action=purge</kbd>已弃用。请改用POST。",
+ "apiwarn-deprecation-withreplacement": "<kbd>$1</kbd>已弃用。请改用<kbd>$2</kbd>。",
+ "apiwarn-difftohidden": "不能与r$1做差异比较:内容被隐藏。",
+ "apiwarn-errorprinterfailed": "错误打印失败。将在没有参数的前提下重试。",
+ "apiwarn-errorprinterfailed-ex": "错误打印失败(将在没有参数的前提下重试):$1",
+ "apiwarn-invalidcategory": "“$1”不是一个分类。",
+ "apiwarn-invalidtitle": "“$1”不是一个有效的标题。",
+ "apiwarn-invalidxmlstylesheetext": "样式表应拥有<code>.xsl</code>扩展名。",
+ "apiwarn-invalidxmlstylesheet": "指定了无效或不存在的样式表。",
+ "apiwarn-invalidxmlstylesheetns": "样式表应位于{{ns:MediaWiki}}名字空间。",
+ "apiwarn-moduleswithoutvars": "属性<kbd>modules</kbd>被设置,但不是<kbd>jsconfigvars</kbd>或<kbd>encodedjsconfigvars</kbd>。需要配置变量以获得适当的模块使用。",
+ "apiwarn-notfile": "“$1”不是文件。",
+ "apiwarn-nothumb-noimagehandler": "不能创建缩略图,因为$1没有关联的图片处理器。",
+ "apiwarn-parse-nocontentmodel": "<var>title</var>或<var>contentmodel</var>未提供,假设$1。",
+ "apiwarn-parse-titlewithouttext": "<var>title</var>在没有<var>text</var>的情况下被使用,并且请求了已解析页面的属性。您是想用<var>page</var>而不是<var>title</var>么?",
+ "apiwarn-redirectsandrevids": "重定向解决方案不能与<var>revids</var>参数一起使用。任何<var>revids</var>所指向的重定向都未被解决。",
+ "apiwarn-tokennotallowed": "操作“$1”不允许当前用户使用。",
+ "apiwarn-tokens-origin": "在未应用同来源方针时,令牌可能无法获得。",
+ "apiwarn-toomanyvalues": "参数<var>$1</var>指定了太多的值。上限为$2。",
+ "apiwarn-truncatedresult": "此结果被缩短,否则其将大于$1字节的限制。",
+ "apiwarn-unclearnowtimestamp": "为时间戳参数<var>$1</var>传递“$2”已被弃用。如因某些原因您需要明确指定当前时间而不计算客户端,请使用<kbd>now</kbd>。",
+ "apiwarn-unrecognizedvalues": "参数<var>$1</var>有无法识别的{{PLURAL:$3|值}}:$2。",
+ "apiwarn-unsupportedarray": "参数<var>$1</var>使用未受支持的PHP数组语法。",
+ "apiwarn-urlparamwidth": "为了获得衍生自<var>$1urlwidth</var>/<var>$1urlheight</var>的宽度值($3),正在忽略<var>$1urlparam</var>的宽度值集($2)。",
+ "apiwarn-validationfailed-badchars": "关键词中的字符无效(只允许<code>a-z</code>、<code>A-Z</code>、<code>0-9</code>、<code>_</code>和<code>-</code>)。",
+ "apiwarn-validationfailed-badpref": "不是有效的偏好。",
+ "apiwarn-validationfailed-cannotset": "不能通过此模块设置。",
+ "apiwarn-validationfailed-keytoolong": "关键词太长(不允许超过$1字节)。",
+ "apiwarn-validationfailed": "<kbd>$1</kbd>的合法性错误:$2",
+ "apiwarn-wgDebugAPI": "<strong>安全警告</strong>:<var>$wgDebugAPI</var>已启用。",
+ "api-feed-error-title": "错误($1)",
+ "api-usage-docref": "参见$1以获取API用法。",
+ "api-usage-mailinglist-ref": "在&lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt;订阅mediawiki-api-announce列表以获取API弃用和重大更新的通知。",
+ "api-exception-trace": "$1在$2($3)\n$4",
+ "api-credits-header": "制作人员",
+ "api-credits": "API 开发人员:\n* Yuri Astrakhan(创建者,2006年9月~2007年9月的开发组领导)\n* Roan Kattouw(2007年9月~2009年的开发组领导)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch(2013年至今的开发组领导)\n\n请将您的评论、建议和问题发送至mediawiki-api@lists.wikimedia.org,或提交错误请求至https://phabricator.wikimedia.org/。"
+}
diff --git a/www/wiki/includes/api/i18n/zh-hant.json b/www/wiki/includes/api/i18n/zh-hant.json
new file mode 100644
index 00000000..0767b3ea
--- /dev/null
+++ b/www/wiki/includes/api/i18n/zh-hant.json
@@ -0,0 +1,315 @@
+{
+ "@metadata": {
+ "authors": [
+ "Cwlin0416",
+ "Liuxinyu970226",
+ "LNDDYL",
+ "EagerLin",
+ "Zhxy 519",
+ "Macofe",
+ "Jasonzhuocn",
+ "Winstonyin",
+ "Arthur2e5",
+ "烈羽",
+ "Corainn",
+ "A2093064",
+ "Wwycheuk"
+ ]
+ },
+ "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|說明文件]]\n* [[mw:Special:MyLanguage/API:FAQ|常見問題]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api 郵遞清單]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API公告]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R 報告錯誤及請求功能]\n</div>\n<strong>狀態資訊:</strong>本頁所展示的所有功能都應正常運作,但API仍在開發,會隨時變化。請訂閱[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mediawiki-api-announce 郵遞清單]以便獲得更新通知。\n\n<strong>錯誤的請求:</strong>當API收到錯誤的請求,會發出以「MediaWiki-API-Error」為鍵的HTTP標頭欄位,隨後標頭欄位的值,以及傳回的錯誤碼會設為相同值。詳細資訊請參閱[[mw:Special:MyLanguage/API:Errors_and_warnings|API: 錯誤與警告]]。\n\n<strong>測試:</strong>要簡化API請求的測試過程,請見[[Special:ApiSandbox]]。",
+ "apihelp-main-param-action": "要執行的動作。",
+ "apihelp-main-param-format": "輸出的格式。",
+ "apihelp-main-param-smaxage": "將HTTP緩存控制頭欄位設為<code>s-maxage</code>秒。錯誤不會做緩存。",
+ "apihelp-main-param-maxage": "將HTTP緩存控制頭欄位設為<code>max-age</code>秒。錯誤不會做緩存。",
+ "apihelp-main-param-assert": "若設為<kbd>user</kbd>,會確認使用者是否已登入;若設為<kbd>bot</kbd>,會確認是否擁有機械人權限。",
+ "apihelp-main-param-assertuser": "確認目前使用者就是指定的使用者。",
+ "apihelp-main-param-requestid": "在此處提供的任何值都將包括在響應之中。可用於區分請求。",
+ "apihelp-main-param-servedby": "在結果中包括提出請求的主機名。",
+ "apihelp-main-param-curtimestamp": "在結果中包括目前的時間戳。",
+ "apihelp-main-param-responselanginfo": "在結果中包括<var>uselang</var>和<var>errorlang</var>所用的語言。",
+ "apihelp-block-summary": "封鎖使用者。",
+ "apihelp-block-param-user": "要封鎖的使用者名稱、IP 位址或 IP 範圍。不能與 <var>$1userid</var> 一起使用",
+ "apihelp-block-param-reason": "封鎖原因。",
+ "apihelp-block-param-anononly": "僅封鎖匿名使用者 (禁止這個 IP 位址的匿名使用者編輯)。",
+ "apihelp-block-param-nocreate": "禁止建立帳號。",
+ "apihelp-block-param-autoblock": "自動封鎖最後使用的 IP 位址,以及在這之後嘗試登入的 IP 位址。",
+ "apihelp-block-param-noemail": "禁止使用者透過 Wiki 寄送電子郵件。 (需要 <code>blockemail</code> 權限)。",
+ "apihelp-block-param-hidename": "隱藏封鎖日誌的使用者名稱。 (需要 <code>hideuser</code> 權限)。",
+ "apihelp-block-param-allowusertalk": "允許使用者編輯自己的對話頁面 (依據 <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var> 的設定)。",
+ "apihelp-block-param-reblock": "若使用者已被封鎖,覆寫既有的封鎖設定值。",
+ "apihelp-block-param-watchuser": "監視使用者或 IP 位址的使用者頁面與對話頁面。",
+ "apihelp-block-example-ip-simple": "封鎖 IP 位址 <kbd>192.0.2.5</kbd> 三天,原因為 <kbd>First strike</kbd>。",
+ "apihelp-block-example-user-complex": "永久封鎖 IP 位址 <kbd>Vandal</kbd>,原因為 <kbd>Vandalism</kbd>。",
+ "apihelp-changeauthenticationdata-summary": "為目前使用者變更身分核對資料。",
+ "apihelp-checktoken-summary": "檢查來自 <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> 的密鑰有效性。",
+ "apihelp-checktoken-param-type": "要測試的密鑰類型。",
+ "apihelp-checktoken-param-token": "要測試的密鑰。",
+ "apihelp-checktoken-param-maxtokenage": "密鑰的有效期間,以秒為單位。",
+ "apihelp-checktoken-example-simple": "測試 <kbd>csrf</kbd> 密鑰的有效性。",
+ "apihelp-clearhasmsg-summary": "清除目前使用者的 <code>hasmsg</code> 標記。",
+ "apihelp-clearhasmsg-example-1": "清除目前使用者的 <code>hasmsg</code> 標記。",
+ "apihelp-compare-summary": "比較 2 個頁面間的差異。",
+ "apihelp-compare-extended-description": "\"from\" 以及 \"to\" 的修訂編號,頁面標題或頁面 ID 為必填。",
+ "apihelp-compare-param-fromtitle": "要比對的第一個標題。",
+ "apihelp-compare-param-fromid": "要比對的第一個頁面 ID。",
+ "apihelp-compare-param-fromrev": "要比對的第一個修訂。",
+ "apihelp-compare-param-totitle": "要比對的第二個標題。",
+ "apihelp-compare-param-toid": "要比對的第二個頁面 ID。",
+ "apihelp-compare-param-torev": "要比對的第二個修訂。",
+ "apihelp-compare-example-1": "建立修訂 1 與 1 的差異檔",
+ "apihelp-createaccount-summary": "建立新使用者帳號。",
+ "apihelp-createaccount-param-name": "使用者名稱。",
+ "apihelp-createaccount-param-password": "密碼 (若有設定 <var>$1mailpassword</var> 則可略過)。",
+ "apihelp-createaccount-param-domain": "外部身分核對使用的網域 (可有可無)。",
+ "apihelp-createaccount-param-token": "在第一次請求時已取得的帳號建立金鑰。",
+ "apihelp-createaccount-param-email": "使用者的電子郵件地址 (可有可無) 。",
+ "apihelp-createaccount-param-realname": "使用者的真實姓名 (可有可無)。",
+ "apihelp-createaccount-param-mailpassword": "若設為其他值,將會以電子郵件寄送隨機密碼給使用者。",
+ "apihelp-createaccount-param-reason": "建立帳號時選填的原因,會被記錄到日誌當中。",
+ "apihelp-createaccount-param-language": "要設定的使用者預設語言代碼 (選填,預設依據內容語言)。",
+ "apihelp-createaccount-example-pass": "建立使用者 <kbd>testuser</kbd> 使用密碼 <kbd>test123</kbd>",
+ "apihelp-createaccount-example-mail": "建立使用者 <kbd>testmailuser</kbd> 並且電子郵件通知隨機產生的密碼。",
+ "apihelp-delete-summary": "刪除頁面。",
+ "apihelp-delete-param-title": "您欲刪除的頁面標題。 無法與 <var>$1pageid</var> 同時使用。",
+ "apihelp-delete-param-pageid": "您欲刪除頁面的頁面 ID。 無法與 <var>$1title</var> 同時使用。",
+ "apihelp-delete-param-reason": "刪除的原因。 若未設定,將會使用自動產生的原因。",
+ "apihelp-delete-param-watch": "加入目前頁面至您的監視清單。",
+ "apihelp-delete-param-watchlist": "無條件使用設置將頁面加入或移除目前使用者的監視清單或者是不更改監視清單。",
+ "apihelp-delete-param-unwatch": "從您的監視清單中移除目前頁面。",
+ "apihelp-delete-param-oldimage": "由 [[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]] 所提供要刪除的舊圖片名稱。",
+ "apihelp-delete-example-simple": "刪除 <kbd>Main Page</kbd>。",
+ "apihelp-delete-example-reason": "刪除 <kbd>Main Page</kbd> 原因為 <kbd>Preparing for move</kbd>。",
+ "apihelp-disabled-summary": "已停用此模組。",
+ "apihelp-edit-summary": "建立與編輯頁面。",
+ "apihelp-edit-param-title": "您欲編輯的頁面標題。 無法與 <var>$1pageid</var> 同時使用。",
+ "apihelp-edit-param-pageid": "您欲編輯頁面的頁面 ID。 無法與 <var>$1title</var> 同時使用。",
+ "apihelp-edit-param-section": "章節編號。 <kbd>0</kbd> 代表最上層章節,<kbd>new</kbd> 代表新章節。",
+ "apihelp-edit-param-sectiontitle": "新章節的標題。",
+ "apihelp-edit-param-text": "頁面內容。",
+ "apihelp-edit-param-summary": "編輯摘要。 當未設定 $1section=new 與 $1sectiontitle 時也會當做章節標題。",
+ "apihelp-edit-param-minor": "小編輯。",
+ "apihelp-edit-param-notminor": "非小編輯。",
+ "apihelp-edit-param-bot": "標記此編輯為機器人編輯。",
+ "apihelp-edit-param-basetimestamp": "基於修訂的時間戳記,用來檢測編輯衝突。也许可以取得[[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]]認可。",
+ "apihelp-edit-param-createonly": "若頁面已存在,則不編輯頁面。",
+ "apihelp-edit-param-nocreate": "若頁面不存在,則產生錯誤。",
+ "apihelp-edit-param-watch": "加入目前頁面至您的監視清單。",
+ "apihelp-edit-param-unwatch": "從您的監視清單中移除目前頁面。",
+ "apihelp-edit-example-edit": "編輯頁面",
+ "apihelp-emailuser-summary": "寄送電子郵件給使用者。",
+ "apihelp-emailuser-param-target": "電子郵件的收件使用者。",
+ "apihelp-emailuser-param-subject": "郵件主旨。",
+ "apihelp-emailuser-param-text": "郵件內容。",
+ "apihelp-emailuser-param-ccme": "寄送一份此郵件的複本給我。",
+ "apihelp-emailuser-example-email": "寄送電子郵件給使用者 <kbd>WikiSysop</kbd> 使用內容 <kbd>Content</kbd>",
+ "apihelp-expandtemplates-summary": "展開所有於 wikitext 中模板。",
+ "apihelp-expandtemplates-param-title": "頁面標題。",
+ "apihelp-expandtemplates-param-text": "要轉換的 Wikitext。",
+ "apihelp-feedcontributions-summary": "回傳使用者貢獻 Feed。",
+ "apihelp-feedcontributions-param-feedformat": "Feed 的格式。",
+ "apihelp-feedcontributions-param-hideminor": "隱藏小修改。",
+ "apihelp-feedcontributions-param-showsizediff": "顯示修訂版本之間的差異大小。",
+ "apihelp-feedcontributions-example-simple": "返回使用者<kbd>Example</kbd>的貢獻。",
+ "apihelp-feedrecentchanges-summary": "返回最近變更摘要。",
+ "apihelp-feedrecentchanges-param-feedformat": "摘要格式。",
+ "apihelp-feedrecentchanges-param-namespace": "用於限制結果的命名空間。",
+ "apihelp-feedrecentchanges-param-invert": "除所選定者外的所有命名空間。",
+ "apihelp-feedrecentchanges-param-limit": "回傳的結果數量上限。",
+ "apihelp-feedrecentchanges-param-hideminor": "隱藏小編輯。",
+ "apihelp-feedrecentchanges-param-hidebots": "隱藏由機器人做的變更。",
+ "apihelp-feedrecentchanges-param-hideanons": "隱藏匿名使用者做的變更。",
+ "apihelp-feedrecentchanges-param-hideliu": "隱藏已註冊使用者做的變更。",
+ "apihelp-feedrecentchanges-param-hidepatrolled": "隱藏已巡查的變更。",
+ "apihelp-feedrecentchanges-example-simple": "顯示近期變更。",
+ "apihelp-feedrecentchanges-example-30days": "顯示近期30天內的變動",
+ "apihelp-feedwatchlist-summary": "返回監視清單 feed。",
+ "apihelp-feedwatchlist-param-feedformat": "Feed 的格式。",
+ "apihelp-filerevert-param-comment": "上載意見。",
+ "apihelp-help-example-main": "主模組使用說明",
+ "apihelp-help-example-recursive": "一個頁面中的所有說明。",
+ "apihelp-help-example-help": "說明模組自身的說明。",
+ "apihelp-imagerotate-summary": "旋轉一張或多張圖片。",
+ "apihelp-import-param-summary": "匯入摘要。",
+ "apihelp-import-param-xml": "上載的 XML 檔。",
+ "apihelp-import-param-interwikisource": "用於跨 wiki 匯入:匯入的來源 wiki。",
+ "apihelp-import-param-interwikipage": "用於跨 wiki 匯入:匯入的頁面。",
+ "apihelp-import-param-fullhistory": "用於跨 wiki 匯入:完整匯入歷史,而不只是最新版本。",
+ "apihelp-import-param-templates": "用於跨 wiki 匯入:匯入一切包含的模板。",
+ "apihelp-import-param-namespace": "匯入至此命名空間。無法與 <var>$1rootpage</var> 一起使用。",
+ "apihelp-import-param-rootpage": "匯入作為此頁面的子頁面。無法與 <var>$1namespace</var> 一起使用。",
+ "apihelp-login-summary": "登入並取得身分核對 cookies",
+ "apihelp-login-param-name": "使用者名稱。",
+ "apihelp-login-param-password": "密碼。",
+ "apihelp-login-param-domain": "網域名稱(可有可無)。",
+ "apihelp-login-example-login": "登入",
+ "apihelp-logout-summary": "登出並清除 session 資料。",
+ "apihelp-logout-example-logout": "登出當前使用者",
+ "apihelp-mergehistory-summary": "合併頁面歷史",
+ "apihelp-mergehistory-param-reason": "合併歷史的原因。",
+ "apihelp-mergehistory-example-merge": "將<kbd>Oldpage</kbd>的整個歷史合併至<kbd>Newpage</kbd>。",
+ "apihelp-mergehistory-example-merge-timestamp": "將<kbd>Oldpage</kbd>直至<kbd>2015-12-31T04:37:41Z</kbd>的頁面修訂版本合併至<kbd>Newpage</kbd>。",
+ "apihelp-move-summary": "移動頁面。",
+ "apihelp-move-param-from": "重新命名本頁面的標題。不能與 <var>$1fromid</var> 一起出現。",
+ "apihelp-move-param-fromid": "重新命名本頁面的 ID 。不能與 <var>$1fromid</var> 一起出現。",
+ "apihelp-move-param-to": "將本頁面的標題重新命名為",
+ "apihelp-move-param-reason": "重新命名的原因。",
+ "apihelp-move-param-movetalk": "如果討論頁存在,變更討論頁名稱。",
+ "apihelp-move-param-movesubpages": "如果適用,則重新命名子頁面。",
+ "apihelp-move-param-noredirect": "不要建立重新導向。",
+ "apihelp-move-param-watch": "將頁面和重定向加入目前使用者的監視清單。",
+ "apihelp-move-param-unwatch": "從目前使用者的監視清單中移除頁面和重定向。",
+ "apihelp-move-param-watchlist": "在目前使用者的監視清單中無條件地加入或移除頁面,或使用設定,或不變更監視清單。",
+ "apihelp-move-param-ignorewarnings": "忽略所有警告。",
+ "apihelp-move-example-move": "將<kbd>Badtitle</kbd>移動至<kbd>Goodtitle</kbd>,不留下重定向。",
+ "apihelp-opensearch-summary": "使用 OpenSearch 協定搜尋本 wiki。",
+ "apihelp-opensearch-param-search": "搜尋字串。",
+ "apihelp-opensearch-param-limit": "回傳的結果數量上限。",
+ "apihelp-opensearch-param-namespace": "搜尋的命名空間。",
+ "apihelp-opensearch-param-suggest": "若<var>[[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]]</var>設定為false,則不做任何事。",
+ "apihelp-opensearch-param-redirects": "如何處理重定向:\n;return:傳回重定向本身。\n;resolve:傳回目標頁面,傳回的結果數目可能少於$1limit。\n由於歷史原因,$1format=json的預設值為「return」,其他格式則為「resolve」。",
+ "apihelp-opensearch-param-format": "輸出的格式。",
+ "apihelp-options-param-reset": "重設偏好設定為網站預設值。",
+ "apihelp-options-example-reset": "重設所有偏好設定",
+ "apihelp-parse-example-page": "解析頁面。",
+ "apihelp-parse-example-text": "解析 wikitext。",
+ "apihelp-parse-example-texttitle": "解析 wikitext,指定頁面標題。",
+ "apihelp-parse-example-summary": "解析摘要。",
+ "apihelp-patrol-summary": "巡查頁面或修訂。",
+ "apihelp-patrol-param-rcid": "要巡查的最近變更 ID。",
+ "apihelp-patrol-param-revid": "要巡查的修訂 ID。",
+ "apihelp-patrol-example-rcid": "巡查一次最近變更。",
+ "apihelp-patrol-example-revid": "巡查一個修訂。",
+ "apihelp-protect-summary": "變更頁面的保護層級。",
+ "apihelp-protect-param-title": "要(解除)保護頁面的標題。 不能與 $1pageid 一起使用。",
+ "apihelp-protect-param-pageid": "要(解除)保護頁面的 ID。 不能與 $1title 一起使用。",
+ "apihelp-protect-param-protections": "保護層級清單,格式為 <kbd>action=level</kbd> (例如 <kbd>edit=sysop</kbd>)。<kbd>all</kbd> 層級代表所有人都可以進行行動,亦即無限制。\n\n<strong>注意:</strong>未列入清單項目的限制皆會移除。",
+ "apihelp-protect-param-expiry": "期限時間戳記,若只設定一個時間戳記,該時間戳記將會套用至所有的保護層級。 使用 <kbd>infinite</kbd>、<kbd>indefinite</kbd>、<kbd>infinity</kbd> 或 <kbd>never</kbd> 來設定保護層級期限為永遠。",
+ "apihelp-protect-param-reason": "(解除)保護的原因。",
+ "apihelp-query-summary": "擷取來自及有關MediaWiki的數據。",
+ "apihelp-query+allcategories-param-limit": "要回傳的分類數量。",
+ "apihelp-query+allfileusages-param-limit": "要回傳的項目總數。",
+ "apihelp-query+allimages-param-limit": "要回傳的圖片總數。",
+ "apihelp-query+alllinks-param-limit": "要回傳的項目總數。",
+ "apihelp-query+allmessages-summary": "返回來自該網站的訊息。",
+ "apihelp-query+allpages-param-limit": "要回傳的頁面總數。",
+ "apihelp-query+allredirects-param-limit": "要回傳的項目總數。",
+ "apihelp-query+allrevisions-summary": "列出所有修訂版本。",
+ "apihelp-query+alltransclusions-param-limit": "要回傳的項目總數。",
+ "apihelp-query+authmanagerinfo-summary": "取得目前身分核對狀態的資訊。",
+ "apihelp-query+categories-param-limit": "要回傳的分類數量。",
+ "apihelp-query+categoryinfo-summary": "回傳有關指定分類的資訊。",
+ "apihelp-query+categorymembers-summary": "在指定的分類中列出所有頁面。",
+ "apihelp-query+categorymembers-param-limit": "回傳的頁面數量上限。",
+ "apihelp-query+contributors-param-limit": "要回傳的貢獻人員數量。",
+ "apihelp-query+duplicatefiles-param-limit": "要回傳的重複檔案數量。",
+ "apihelp-query+embeddedin-param-filterredir": "如何過濾重新導向。",
+ "apihelp-query+embeddedin-param-limit": "要回傳的頁面總數。",
+ "apihelp-query+extlinks-summary": "回傳所有指定頁面的外部 URL (非 interwiki)。",
+ "apihelp-query+extlinks-param-limit": "要回傳的連結數量。",
+ "apihelp-query+exturlusage-param-limit": "要回傳的頁面數量。",
+ "apihelp-query+filearchive-param-limit": "要回傳的圖片總數。",
+ "apihelp-query+fileusage-param-limit": "要回傳的數量。",
+ "apihelp-query+imageinfo-summary": "回傳檔案資訊與上傳日誌。",
+ "apihelp-query+imageinfo-param-limit": "每個檔案要回傳的檔案修訂數量。",
+ "apihelp-query+images-summary": "回傳指定頁面中包含的所有檔案。",
+ "apihelp-query+images-param-limit": "要回傳的檔案數量。",
+ "apihelp-query+info-summary": "取得基本頁面訊息。",
+ "apihelp-query+iwlinks-summary": "回傳指定頁面的所有 interwiki 連結。",
+ "apihelp-query+iwlinks-param-limit": "要回傳的跨 Wiki 連結數量。",
+ "apihelp-query+langbacklinks-param-limit": "要回傳的頁面總數。",
+ "apihelp-query+langlinks-summary": "回傳指定頁面的所有跨語言連結。",
+ "apihelp-query+langlinks-param-limit": "要回傳的 langlinks 數量。",
+ "apihelp-query+links-summary": "回傳指定頁面的所有連結。",
+ "apihelp-query+links-param-limit": "要回傳的連結數量。",
+ "apihelp-query+linkshere-param-limit": "要回傳的數量。",
+ "apihelp-query+logevents-summary": "從日誌中獲取事件。",
+ "apihelp-query+logevents-param-limit": "要回傳的事件項目總數。",
+ "apihelp-query+pagepropnames-param-limit": "回傳的名稱數量上限。",
+ "apihelp-query+pageswithprop-param-limit": "回傳的頁面數量上限。",
+ "apihelp-query+prefixsearch-param-limit": "回傳的結果數量上限。",
+ "apihelp-query+protectedtitles-param-limit": "要回傳的頁面總數。",
+ "apihelp-query+querypage-param-limit": "回傳的結果數量。",
+ "apihelp-query+recentchanges-summary": "列舉出最近變更。",
+ "apihelp-query+recentchanges-param-limit": "要回傳變更總數。",
+ "apihelp-query+recentchanges-example-simple": "最近變更清單",
+ "apihelp-query+redirects-summary": "回傳連結至指定頁面的所有重新導向。",
+ "apihelp-query+redirects-param-limit": "要回傳的重新導向數量。",
+ "apihelp-query+search-paramvalue-prop-score": "已忽略",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "已忽略",
+ "apihelp-query+search-param-limit": "要回傳的頁面總數。",
+ "apihelp-query+stashimageinfo-summary": "回傳多筆儲藏檔案的檔案資訊。",
+ "apihelp-query+stashimageinfo-example-simple": "回傳儲藏檔案的檔案資訊。",
+ "apihelp-query+tags-summary": "列出更改標籤。",
+ "apihelp-query+templates-summary": "回傳指定頁面中所有引用的頁面。",
+ "apihelp-query+templates-param-limit": "要回傳的模板數量。",
+ "apihelp-query+tokens-param-type": "要求的權杖類型。",
+ "apihelp-query+tokens-example-simple": "接收 csrf 密鑰 (預設)。",
+ "apihelp-query+tokens-example-types": "接收監視密鑰以及巡邏密鑰。",
+ "apihelp-query+transcludedin-param-limit": "回傳的數量。",
+ "apihelp-query+usercontribs-param-limit": "回傳的貢獻數量上限。",
+ "apihelp-query+watchlist-param-limit": "每個請求要回傳的結果總數。",
+ "apihelp-query+watchlistraw-param-limit": "每個請求要回傳的結果總數。",
+ "apihelp-removeauthenticationdata-summary": "為目前使用者移除身分核對資料。",
+ "apihelp-revisiondelete-summary": "刪除和取消刪除修訂。",
+ "apihelp-stashedit-param-title": "正在編輯此頁面的標題。",
+ "apihelp-stashedit-param-text": "頁面內容。",
+ "apihelp-tokens-summary": "取得資料修改動作的密鑰。",
+ "apihelp-tokens-extended-description": "此模組已因支援 [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] 而停用。",
+ "apihelp-unblock-summary": "解除封鎖一位使用者。",
+ "apihelp-unblock-param-reason": "解除封鎖的原因。",
+ "apihelp-unblock-example-id": "解除封銷 ID #<kbd>105</kbd>。",
+ "apihelp-undelete-param-reason": "還原的原因。",
+ "apihelp-userrights-summary": "更改一位使用者的群組成員。",
+ "apihelp-userrights-param-user": "使用者名稱。",
+ "apihelp-userrights-param-userid": "使用者 ID。",
+ "apihelp-userrights-param-add": "加入使用者至這些群組;若已是成員,則更新失效時間。",
+ "apihelp-userrights-param-remove": "從這些群組移除使用者。",
+ "apihelp-userrights-param-reason": "變更的原因。",
+ "apihelp-format-example-generic": "以 $1 格式傳回查詢結果。",
+ "apihelp-json-summary": "使用 JSON 格式輸出資料。",
+ "apihelp-jsonfm-summary": "使用 JSON 格式輸出資料 (使用 HTML 格式顯示)。",
+ "apihelp-none-summary": "不輸出。",
+ "apihelp-php-summary": "使用序列化 PHP 格式輸出資料。",
+ "apihelp-phpfm-summary": "使用序列化 PHP 格式輸出資料 (使用 HTML 格式顯示)。",
+ "apihelp-rawfm-summary": "使用 JSON 格式的除錯元素輸出資料 (使用 HTML 格式顯示)。",
+ "apihelp-xml-summary": "使用 XML 格式輸出資料。",
+ "apihelp-xmlfm-summary": "使用 XML 格式輸出資料 (使用 HTML 格式顯示)。",
+ "api-format-title": "MediaWiki API 結果",
+ "api-pageset-param-titles": "要使用的標題清單。",
+ "api-pageset-param-pageids": "要使用的頁面 ID 清單。",
+ "api-pageset-param-revids": "要使用的修訂 ID 清單。",
+ "api-help-title": "MediaWiki API 說明",
+ "api-help-lead": "此頁為自動產生的 MediaWiki API 說明文件頁面。\n\n說明文件與範例:https://www.mediawiki.org/wiki/API",
+ "api-help-main-header": "主要模組",
+ "api-help-flag-deprecated": "此模組已停用。",
+ "api-help-flag-readrights": "此模組需要讀取權限。",
+ "api-help-flag-writerights": "此模組需要寫入權限。",
+ "api-help-flag-mustbeposted": "此模組僅接受 POST 請求。",
+ "api-help-parameters": "{{PLURAL:$1|參數}}:",
+ "api-help-param-deprecated": "已停用。",
+ "api-help-param-required": "此參數為必填。",
+ "api-help-param-list": "{{PLURAL:$1|1=單值|2=多值 (以 <kbd>{{!}}</kbd> 或 [[Special:ApiHelp/main#main/datatypes|alternative]] 分隔)}}:$2",
+ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=必須空白|可以空白,或 $2}}",
+ "api-help-param-limit": "不允許超過 $1。",
+ "api-help-param-limit2": "不允許超過 $1 (機器人為 $2)。",
+ "api-help-param-integer-min": "{{PLURAL:$1|1=數值|2=數值}}不可小於 $2。",
+ "api-help-param-integer-max": "{{PLURAL:$1|1=數值|2=數值}}不可大於 $3。",
+ "api-help-param-integer-minmax": "{{PLURAL:$1|1=數值|2=數值}}必須在 $2 與 $3 之間。",
+ "api-help-param-upload": "必須使用 multipart/form-data 以檔案上傳的方式傳送。",
+ "api-help-param-multi-separate": "將幾個值以 <kbd>|</kbd> 或 [[Special:ApiHelp/main#main/datatypes|alternative]] 分隔。",
+ "api-help-param-multi-max": "上限值為 {{PLURAL:$1|$1}} (機器人為 {{PLURAL:$2|$2}})。",
+ "api-help-param-default": "預設值:$1",
+ "api-help-param-default-empty": "預設值:<span class=\"apihelp-empty\">(空)</span>",
+ "api-help-param-token": "自 [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] 接收的 \"$1\" 密鑰。",
+ "api-help-param-no-description": "<span class=\"apihelp-empty\">(無描述)</span>",
+ "api-help-examples": "{{PLURAL:$1|範例}}:",
+ "api-help-permissions": "{{PLURAL:$1|權限}}:",
+ "api-help-permissions-granted-to": "{{PLURAL:$1|已授權給}}: $2",
+ "api-help-authmanager-general-usage": "使用此模組的一般程式是:\n# 通過<kbd>amirequestsfor=$4</kbd>取得來自<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>的可用欄位,和來自<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>的<kbd>$5</kbd>令牌。\n# 向用戶顯示欄位,並獲得其提交的內容。\n# 提交(POST)至此模組,提供<var>$1returnurl</var>及任何相關欄位。\n# 在回应中檢查<samp>status</samp>。\n#* 如果您收到了<samp>PASS</samp>(成功)或<samp>FAIL</samp>(失敗),則認為操作結束。成功與否如上句所示。\n#* 如果您收到了<samp>UI</samp>,向用戶顯示新欄位,並再次獲取其提交的內容。然後再次使用<var>$1continue</var>,向本模組提交相關欄位,並重復第四步。\n#* 如果您收到了<samp>REDIRECT</samp>,將使用者指向<samp>redirecttarget</samp>中的目標,等待其返回<var>$1returnurl</var>。然後再次使用<var>$1continue</var>,向本模組提交返回URL中提供的一切欄位,並重復第四步。\n#* 如果您收到了<samp>RESTART</samp>,這意味著身份驗證正常運作,但我們沒有連結的使用者賬戶。您可以將此看做<samp>UI</samp>或<samp>FAIL</samp>。",
+ "apierror-mustbeloggedin-changeauth": "必須登入,才能變更身分核對資取。",
+ "apierror-mustbeloggedin-removeauth": "必須登入,才能移除身分核對資取。",
+ "apierror-reauthenticate": "於本工作階段還未核對身分,請重新核對。",
+ "apierror-timeout": "伺服器未有在預計的時間內回應。",
+ "api-credits-header": "製作群",
+ "api-credits": "API 開發人員:\n* Roan Kattouw (首席開發者 Sep 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (創立者,首席開發者 Sep 2006–Sep 2007)\n* Brad Jorsch (首席開發者 2013–present)\n\n請傳送您的評論、建議以及問題至 mediawiki-api@lists.wikimedia.org\n或者回報問題至 https://phabricator.wikimedia.org/。"
+}
diff --git a/www/wiki/includes/api/i18n/zu.json b/www/wiki/includes/api/i18n/zu.json
new file mode 100644
index 00000000..6536d37a
--- /dev/null
+++ b/www/wiki/includes/api/i18n/zu.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Irus"
+ ]
+ },
+ "apihelp-block-summary": "Vimbela umsebenzisi",
+ "apihelp-block-param-user": "Igama lomsebenzisi, ikheli le-IP, noma ikheli le-IP uhla ukuvimba.",
+ "apihelp-block-param-reblock": "Uma umsebenzisi usevele ivinjiwe, isula block ekhona."
+}
diff --git a/www/wiki/includes/auth/AbstractAuthenticationProvider.php b/www/wiki/includes/auth/AbstractAuthenticationProvider.php
new file mode 100644
index 00000000..58cec118
--- /dev/null
+++ b/www/wiki/includes/auth/AbstractAuthenticationProvider.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Config;
+use Psr\Log\LoggerInterface;
+
+/**
+ * A base class that implements some of the boilerplate for an AuthenticationProvider
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractAuthenticationProvider implements AuthenticationProvider {
+ /** @var LoggerInterface */
+ protected $logger;
+ /** @var AuthManager */
+ protected $manager;
+ /** @var Config */
+ protected $config;
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ public function setManager( AuthManager $manager ) {
+ $this->manager = $manager;
+ }
+
+ public function setConfig( Config $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * @inheritDoc
+ * @note Override this if it makes sense to support more than one instance
+ */
+ public function getUniqueId() {
+ return static::class;
+ }
+}
diff --git a/www/wiki/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php
new file mode 100644
index 00000000..f5bfc2a2
--- /dev/null
+++ b/www/wiki/includes/auth/AbstractPasswordPrimaryAuthenticationProvider.php
@@ -0,0 +1,171 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Password;
+use PasswordFactory;
+use Status;
+
+/**
+ * Basic framework for a primary authentication provider that uses passwords
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractPasswordPrimaryAuthenticationProvider
+ extends AbstractPrimaryAuthenticationProvider
+{
+ /** @var bool Whether this provider should ABSTAIN (false) or FAIL (true) on password failure */
+ protected $authoritative;
+
+ private $passwordFactory = null;
+
+ /**
+ * @param array $params Settings
+ * - authoritative: Whether this provider should ABSTAIN (false) or FAIL
+ * (true) on password failure
+ */
+ public function __construct( array $params = [] ) {
+ $this->authoritative = !isset( $params['authoritative'] ) || (bool)$params['authoritative'];
+ }
+
+ /**
+ * Get the PasswordFactory
+ * @return PasswordFactory
+ */
+ protected function getPasswordFactory() {
+ if ( $this->passwordFactory === null ) {
+ $this->passwordFactory = new PasswordFactory();
+ $this->passwordFactory->init( $this->config );
+ }
+ return $this->passwordFactory;
+ }
+
+ /**
+ * Get a Password object from the hash
+ * @param string $hash
+ * @return Password
+ */
+ protected function getPassword( $hash ) {
+ $passwordFactory = $this->getPasswordFactory();
+ try {
+ return $passwordFactory->newFromCiphertext( $hash );
+ } catch ( \PasswordError $e ) {
+ $class = static::class;
+ $this->logger->debug( "Invalid password hash in {$class}::getPassword()" );
+ return $passwordFactory->newFromCiphertext( null );
+ }
+ }
+
+ /**
+ * Return the appropriate response for failure
+ * @param PasswordAuthenticationRequest $req
+ * @return AuthenticationResponse
+ */
+ protected function failResponse( PasswordAuthenticationRequest $req ) {
+ if ( $this->authoritative ) {
+ return AuthenticationResponse::newFail(
+ wfMessage( $req->password === '' ? 'wrongpasswordempty' : 'wrongpassword' )
+ );
+ } else {
+ return AuthenticationResponse::newAbstain();
+ }
+ }
+
+ /**
+ * Check that the password is valid
+ *
+ * This should be called *before* validating the password. If the result is
+ * not ok, login should fail immediately.
+ *
+ * @param string $username
+ * @param string $password
+ * @return Status
+ */
+ protected function checkPasswordValidity( $username, $password ) {
+ return \User::newFromName( $username )->checkPasswordValidity( $password );
+ }
+
+ /**
+ * Check if the password should be reset
+ *
+ * This should be called after a successful login. It sets 'reset-pass'
+ * authentication data if necessary, see
+ * ResetPassSecondaryAuthenticationProvider.
+ *
+ * @param string $username
+ * @param Status $status From $this->checkPasswordValidity()
+ * @param mixed $data Passed through to $this->getPasswordResetData()
+ */
+ protected function setPasswordResetFlag( $username, Status $status, $data = null ) {
+ $reset = $this->getPasswordResetData( $username, $data );
+
+ if ( !$reset && $this->config->get( 'InvalidPasswordReset' ) && !$status->isGood() ) {
+ $reset = (object)[
+ 'msg' => $status->getMessage( 'resetpass-validity-soft' ),
+ 'hard' => false,
+ ];
+ }
+
+ if ( $reset ) {
+ $this->manager->setAuthenticationSessionData( 'reset-pass', $reset );
+ }
+ }
+
+ /**
+ * Get password reset data, if any
+ *
+ * @param string $username
+ * @param mixed $data
+ * @return object|null { 'hard' => bool, 'msg' => Message }
+ */
+ protected function getPasswordResetData( $username, $data ) {
+ return null;
+ }
+
+ /**
+ * Get expiration date for a new password, if any
+ *
+ * @param string $username
+ * @return string|null
+ */
+ protected function getNewPasswordExpiry( $username ) {
+ $days = $this->config->get( 'PasswordExpirationDays' );
+ $expires = $days ? wfTimestamp( TS_MW, time() + $days * 86400 ) : null;
+
+ // Give extensions a chance to force an expiration
+ \Hooks::run( 'ResetPasswordExpiration', [ \User::newFromName( $username ), &$expires ] );
+
+ return $expires;
+ }
+
+ public function getAuthenticationRequests( $action, array $options ) {
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ case AuthManager::ACTION_REMOVE:
+ case AuthManager::ACTION_CREATE:
+ case AuthManager::ACTION_CHANGE:
+ return [ new PasswordAuthenticationRequest() ];
+ default:
+ return [];
+ }
+ }
+}
diff --git a/www/wiki/includes/auth/AbstractPreAuthenticationProvider.php b/www/wiki/includes/auth/AbstractPreAuthenticationProvider.php
new file mode 100644
index 00000000..d997dbbc
--- /dev/null
+++ b/www/wiki/includes/auth/AbstractPreAuthenticationProvider.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * A base class that implements some of the boilerplate for a PreAuthenticationProvider
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractPreAuthenticationProvider extends AbstractAuthenticationProvider
+ implements PreAuthenticationProvider
+{
+
+ public function getAuthenticationRequests( $action, array $options ) {
+ return [];
+ }
+
+ public function testForAuthentication( array $reqs ) {
+ return \StatusValue::newGood();
+ }
+
+ public function postAuthentication( $user, AuthenticationResponse $response ) {
+ }
+
+ public function testForAccountCreation( $user, $creator, array $reqs ) {
+ return \StatusValue::newGood();
+ }
+
+ public function testUserForCreation( $user, $autocreate, array $options = [] ) {
+ return \StatusValue::newGood();
+ }
+
+ public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) {
+ }
+
+ public function testForAccountLink( $user ) {
+ return \StatusValue::newGood();
+ }
+
+ public function postAccountLink( $user, AuthenticationResponse $response ) {
+ }
+
+}
diff --git a/www/wiki/includes/auth/AbstractPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/AbstractPrimaryAuthenticationProvider.php
new file mode 100644
index 00000000..ca947b61
--- /dev/null
+++ b/www/wiki/includes/auth/AbstractPrimaryAuthenticationProvider.php
@@ -0,0 +1,118 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * A base class that implements some of the boilerplate for a PrimaryAuthenticationProvider
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractPrimaryAuthenticationProvider extends AbstractAuthenticationProvider
+ implements PrimaryAuthenticationProvider
+{
+
+ public function continuePrimaryAuthentication( array $reqs ) {
+ throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' );
+ }
+
+ public function postAuthentication( $user, AuthenticationResponse $response ) {
+ }
+
+ public function testUserCanAuthenticate( $username ) {
+ // Assume it can authenticate if it exists
+ return $this->testUserExists( $username );
+ }
+
+ /**
+ * @inheritDoc
+ * @note Reimplement this if you do anything other than
+ * User::getCanonicalName( $req->username ) to determine the user being
+ * authenticated.
+ */
+ public function providerNormalizeUsername( $username ) {
+ $name = User::getCanonicalName( $username );
+ return $name === false ? null : $name;
+ }
+
+ /**
+ * @inheritDoc
+ * @note Reimplement this if self::getAuthenticationRequests( AuthManager::ACTION_REMOVE )
+ * doesn't return requests that will revoke all access for the user.
+ */
+ public function providerRevokeAccessForUser( $username ) {
+ $reqs = $this->getAuthenticationRequests(
+ AuthManager::ACTION_REMOVE, [ 'username' => $username ]
+ );
+ foreach ( $reqs as $req ) {
+ $req->username = $username;
+ $req->action = AuthManager::ACTION_REMOVE;
+ $this->providerChangeAuthenticationData( $req );
+ }
+ }
+
+ public function providerAllowsPropertyChange( $property ) {
+ return true;
+ }
+
+ public function testForAccountCreation( $user, $creator, array $reqs ) {
+ return \StatusValue::newGood();
+ }
+
+ public function continuePrimaryAccountCreation( $user, $creator, array $reqs ) {
+ throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' );
+ }
+
+ public function finishAccountCreation( $user, $creator, AuthenticationResponse $response ) {
+ return null;
+ }
+
+ public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) {
+ }
+
+ public function testUserForCreation( $user, $autocreate, array $options = [] ) {
+ return \StatusValue::newGood();
+ }
+
+ public function autoCreatedAccount( $user, $source ) {
+ }
+
+ public function beginPrimaryAccountLink( $user, array $reqs ) {
+ if ( $this->accountCreationType() === self::TYPE_LINK ) {
+ throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' );
+ } else {
+ throw new \BadMethodCallException(
+ __METHOD__ . ' should not be called on a non-link provider.'
+ );
+ }
+ }
+
+ public function continuePrimaryAccountLink( $user, array $reqs ) {
+ throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' );
+ }
+
+ public function postAccountLink( $user, AuthenticationResponse $response ) {
+ }
+
+}
diff --git a/www/wiki/includes/auth/AbstractSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/AbstractSecondaryAuthenticationProvider.php
new file mode 100644
index 00000000..4a2accaf
--- /dev/null
+++ b/www/wiki/includes/auth/AbstractSecondaryAuthenticationProvider.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * A base class that implements some of the boilerplate for a SecondaryAuthenticationProvider
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AbstractSecondaryAuthenticationProvider extends AbstractAuthenticationProvider
+ implements SecondaryAuthenticationProvider
+{
+
+ public function continueSecondaryAuthentication( $user, array $reqs ) {
+ throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' );
+ }
+
+ public function postAuthentication( $user, AuthenticationResponse $response ) {
+ }
+
+ public function providerAllowsPropertyChange( $property ) {
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ * @note Reimplement this if self::getAuthenticationRequests( AuthManager::ACTION_REMOVE )
+ * doesn't return requests that will revoke all access for the user.
+ */
+ public function providerRevokeAccessForUser( $username ) {
+ $reqs = $this->getAuthenticationRequests(
+ AuthManager::ACTION_REMOVE, [ 'username' => $username ]
+ );
+ foreach ( $reqs as $req ) {
+ $req->username = $username;
+ $this->providerChangeAuthenticationData( $req );
+ }
+ }
+
+ public function providerAllowsAuthenticationDataChange(
+ AuthenticationRequest $req, $checkData = true
+ ) {
+ return \StatusValue::newGood( 'ignored' );
+ }
+
+ public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
+ }
+
+ public function testForAccountCreation( $user, $creator, array $reqs ) {
+ return \StatusValue::newGood();
+ }
+
+ public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) {
+ throw new \BadMethodCallException( __METHOD__ . ' is not implemented.' );
+ }
+
+ public function postAccountCreation( $user, $creator, AuthenticationResponse $response ) {
+ }
+
+ public function testUserForCreation( $user, $autocreate, array $options = [] ) {
+ return \StatusValue::newGood();
+ }
+
+ public function autoCreatedAccount( $user, $source ) {
+ }
+}
diff --git a/www/wiki/includes/auth/AuthManager.php b/www/wiki/includes/auth/AuthManager.php
new file mode 100644
index 00000000..9407c422
--- /dev/null
+++ b/www/wiki/includes/auth/AuthManager.php
@@ -0,0 +1,2450 @@
+<?php
+/**
+ * Authentication (and possibly Authorization in the future) system entry point
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Config;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Status;
+use StatusValue;
+use User;
+use WebRequest;
+
+/**
+ * This serves as the entry point to the authentication system.
+ *
+ * In the future, it may also serve as the entry point to the authorization
+ * system.
+ *
+ * If you are looking at this because you are working on an extension that creates its own
+ * login or signup page, then 1) you really shouldn't do that, 2) if you feel you absolutely
+ * have to, subclass AuthManagerSpecialPage or build it on the client side using the clientlogin
+ * or the createaccount API. Trying to call this class directly will very likely end up in
+ * security vulnerabilities or broken UX in edge cases.
+ *
+ * If you are working on an extension that needs to integrate with the authentication system
+ * (e.g. by providing a new login method, or doing extra permission checks), you'll probably
+ * need to write an AuthenticationProvider.
+ *
+ * If you want to create a "reserved" user programmatically, User::newSystemUser() might be what
+ * you are looking for. If you want to change user data, use User::changeAuthenticationData().
+ * Code that is related to some SessionProvider or PrimaryAuthenticationProvider can
+ * create a (non-reserved) user by calling AuthManager::autoCreateUser(); it is then the provider's
+ * responsibility to ensure that the user can authenticate somehow (see especially
+ * PrimaryAuthenticationProvider::autoCreatedAccount()).
+ * If you are writing code that is not associated with such a provider and needs to create accounts
+ * programmatically for real users, you should rethink your architecture. There is no good way to
+ * do that as such code has no knowledge of what authentication methods are enabled on the wiki and
+ * cannot provide any means for users to access the accounts it would create.
+ *
+ * The two main control flows when using this class are as follows:
+ * * Login, user creation or account linking code will call getAuthenticationRequests(), populate
+ * the requests with data (by using them to build a HTMLForm and have the user fill it, or by
+ * exposing a form specification via the API, so that the client can build it), and pass them to
+ * the appropriate begin* method. That will return either a success/failure response, or more
+ * requests to fill (either by building a form or by redirecting the user to some external
+ * provider which will send the data back), in which case they need to be submitted to the
+ * appropriate continue* method and that step has to be repeated until the response is a success
+ * or failure response. AuthManager will use the session to maintain internal state during the
+ * process.
+ * * Code doing an authentication data change will call getAuthenticationRequests(), select
+ * a single request, populate it, and pass it to allowsAuthenticationDataChange() and then
+ * changeAuthenticationData(). If the data change is user-initiated, the whole process needs
+ * to be preceded by a call to securitySensitiveOperationStatus() and aborted if that returns
+ * a non-OK status.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
+ */
+class AuthManager implements LoggerAwareInterface {
+ /** Log in with an existing (not necessarily local) user */
+ const ACTION_LOGIN = 'login';
+ /** Continue a login process that was interrupted by the need for user input or communication
+ * with an external provider */
+ const ACTION_LOGIN_CONTINUE = 'login-continue';
+ /** Create a new user */
+ const ACTION_CREATE = 'create';
+ /** Continue a user creation process that was interrupted by the need for user input or
+ * communication with an external provider */
+ const ACTION_CREATE_CONTINUE = 'create-continue';
+ /** Link an existing user to a third-party account */
+ const ACTION_LINK = 'link';
+ /** Continue a user linking process that was interrupted by the need for user input or
+ * communication with an external provider */
+ const ACTION_LINK_CONTINUE = 'link-continue';
+ /** Change a user's credentials */
+ const ACTION_CHANGE = 'change';
+ /** Remove a user's credentials */
+ const ACTION_REMOVE = 'remove';
+ /** Like ACTION_REMOVE but for linking providers only */
+ const ACTION_UNLINK = 'unlink';
+
+ /** Security-sensitive operations are ok. */
+ const SEC_OK = 'ok';
+ /** Security-sensitive operations should re-authenticate. */
+ const SEC_REAUTH = 'reauth';
+ /** Security-sensitive should not be performed. */
+ const SEC_FAIL = 'fail';
+
+ /** Auto-creation is due to SessionManager */
+ const AUTOCREATE_SOURCE_SESSION = \MediaWiki\Session\SessionManager::class;
+
+ /** @var AuthManager|null */
+ private static $instance = null;
+
+ /** @var WebRequest */
+ private $request;
+
+ /** @var Config */
+ private $config;
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ /** @var AuthenticationProvider[] */
+ private $allAuthenticationProviders = [];
+
+ /** @var PreAuthenticationProvider[] */
+ private $preAuthenticationProviders = null;
+
+ /** @var PrimaryAuthenticationProvider[] */
+ private $primaryAuthenticationProviders = null;
+
+ /** @var SecondaryAuthenticationProvider[] */
+ private $secondaryAuthenticationProviders = null;
+
+ /** @var CreatedAccountAuthenticationRequest[] */
+ private $createdAccountAuthenticationRequests = [];
+
+ /**
+ * Get the global AuthManager
+ * @return AuthManager
+ */
+ public static function singleton() {
+ if ( self::$instance === null ) {
+ self::$instance = new self(
+ \RequestContext::getMain()->getRequest(),
+ MediaWikiServices::getInstance()->getMainConfig()
+ );
+ }
+ return self::$instance;
+ }
+
+ /**
+ * @param WebRequest $request
+ * @param Config $config
+ */
+ public function __construct( WebRequest $request, Config $config ) {
+ $this->request = $request;
+ $this->config = $config;
+ $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ) );
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @return WebRequest
+ */
+ public function getRequest() {
+ return $this->request;
+ }
+
+ /**
+ * Force certain PrimaryAuthenticationProviders
+ * @deprecated For backwards compatibility only
+ * @param PrimaryAuthenticationProvider[] $providers
+ * @param string $why
+ */
+ public function forcePrimaryAuthenticationProviders( array $providers, $why ) {
+ $this->logger->warning( "Overriding AuthManager primary authn because $why" );
+
+ if ( $this->primaryAuthenticationProviders !== null ) {
+ $this->logger->warning(
+ 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.'
+ );
+
+ $this->allAuthenticationProviders = array_diff_key(
+ $this->allAuthenticationProviders,
+ $this->primaryAuthenticationProviders
+ );
+ $session = $this->request->getSession();
+ $session->remove( 'AuthManager::authnState' );
+ $session->remove( 'AuthManager::accountCreationState' );
+ $session->remove( 'AuthManager::accountLinkState' );
+ $this->createdAccountAuthenticationRequests = [];
+ }
+
+ $this->primaryAuthenticationProviders = [];
+ foreach ( $providers as $provider ) {
+ if ( !$provider instanceof PrimaryAuthenticationProvider ) {
+ throw new \RuntimeException(
+ 'Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got ' .
+ get_class( $provider )
+ );
+ }
+ $provider->setLogger( $this->logger );
+ $provider->setManager( $this );
+ $provider->setConfig( $this->config );
+ $id = $provider->getUniqueId();
+ if ( isset( $this->allAuthenticationProviders[$id] ) ) {
+ throw new \RuntimeException(
+ "Duplicate specifications for id $id (classes " .
+ get_class( $provider ) . ' and ' .
+ get_class( $this->allAuthenticationProviders[$id] ) . ')'
+ );
+ }
+ $this->allAuthenticationProviders[$id] = $provider;
+ $this->primaryAuthenticationProviders[$id] = $provider;
+ }
+ }
+
+ /**
+ * Call a legacy AuthPlugin method, if necessary
+ * @codeCoverageIgnore
+ * @deprecated For backwards compatibility only, should be avoided in new code
+ * @param string $method AuthPlugin method to call
+ * @param array $params Parameters to pass
+ * @param mixed $return Return value if AuthPlugin wasn't called
+ * @return mixed Return value from the AuthPlugin method, or $return
+ */
+ public static function callLegacyAuthPlugin( $method, array $params, $return = null ) {
+ global $wgAuth;
+
+ if ( $wgAuth && !$wgAuth instanceof AuthManagerAuthPlugin ) {
+ return call_user_func_array( [ $wgAuth, $method ], $params );
+ } else {
+ return $return;
+ }
+ }
+
+ /**
+ * @name Authentication
+ * @{
+ */
+
+ /**
+ * Indicate whether user authentication is possible
+ *
+ * It may not be if the session is provided by something like OAuth
+ * for which each individual request includes authentication data.
+ *
+ * @return bool
+ */
+ public function canAuthenticateNow() {
+ return $this->request->getSession()->canSetUser();
+ }
+
+ /**
+ * Start an authentication flow
+ *
+ * In addition to the AuthenticationRequests returned by
+ * $this->getAuthenticationRequests(), a client might include a
+ * CreateFromLoginAuthenticationRequest from a previous login attempt to
+ * preserve state.
+ *
+ * Instead of the AuthenticationRequests returned by
+ * $this->getAuthenticationRequests(), a client might pass a
+ * CreatedAccountAuthenticationRequest from an account creation that just
+ * succeeded to log in to the just-created account.
+ *
+ * @param AuthenticationRequest[] $reqs
+ * @param string $returnToUrl Url that REDIRECT responses should eventually
+ * return to.
+ * @return AuthenticationResponse See self::continueAuthentication()
+ */
+ public function beginAuthentication( array $reqs, $returnToUrl ) {
+ $session = $this->request->getSession();
+ if ( !$session->canSetUser() ) {
+ // Caller should have called canAuthenticateNow()
+ $session->remove( 'AuthManager::authnState' );
+ throw new \LogicException( 'Authentication is not possible now' );
+ }
+
+ $guessUserName = null;
+ foreach ( $reqs as $req ) {
+ $req->returnToUrl = $returnToUrl;
+ // @codeCoverageIgnoreStart
+ if ( $req->username !== null && $req->username !== '' ) {
+ if ( $guessUserName === null ) {
+ $guessUserName = $req->username;
+ } elseif ( $guessUserName !== $req->username ) {
+ $guessUserName = null;
+ break;
+ }
+ }
+ // @codeCoverageIgnoreEnd
+ }
+
+ // Check for special-case login of a just-created account
+ $req = AuthenticationRequest::getRequestByClass(
+ $reqs, CreatedAccountAuthenticationRequest::class
+ );
+ if ( $req ) {
+ if ( !in_array( $req, $this->createdAccountAuthenticationRequests, true ) ) {
+ throw new \LogicException(
+ 'CreatedAccountAuthenticationRequests are only valid on ' .
+ 'the same AuthManager that created the account'
+ );
+ }
+
+ $user = User::newFromName( $req->username );
+ // @codeCoverageIgnoreStart
+ if ( !$user ) {
+ throw new \UnexpectedValueException(
+ "CreatedAccountAuthenticationRequest had invalid username \"{$req->username}\""
+ );
+ } elseif ( $user->getId() != $req->id ) {
+ throw new \UnexpectedValueException(
+ "ID for \"{$req->username}\" was {$user->getId()}, expected {$req->id}"
+ );
+ }
+ // @codeCoverageIgnoreEnd
+
+ $this->logger->info( 'Logging in {user} after account creation', [
+ 'user' => $user->getName(),
+ ] );
+ $ret = AuthenticationResponse::newPass( $user->getName() );
+ $this->setSessionDataForUser( $user );
+ $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
+ $session->remove( 'AuthManager::authnState' );
+ \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] );
+ return $ret;
+ }
+
+ $this->removeAuthenticationSessionData( null );
+
+ foreach ( $this->getPreAuthenticationProviders() as $provider ) {
+ $status = $provider->testForAuthentication( $reqs );
+ if ( !$status->isGood() ) {
+ $this->logger->debug( 'Login failed in pre-authentication by ' . $provider->getUniqueId() );
+ $ret = AuthenticationResponse::newFail(
+ Status::wrap( $status )->getMessage()
+ );
+ $this->callMethodOnProviders( 7, 'postAuthentication',
+ [ User::newFromName( $guessUserName ) ?: null, $ret ]
+ );
+ \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, null, $guessUserName ] );
+ return $ret;
+ }
+ }
+
+ $state = [
+ 'reqs' => $reqs,
+ 'returnToUrl' => $returnToUrl,
+ 'guessUserName' => $guessUserName,
+ 'primary' => null,
+ 'primaryResponse' => null,
+ 'secondary' => [],
+ 'maybeLink' => [],
+ 'continueRequests' => [],
+ ];
+
+ // Preserve state from a previous failed login
+ $req = AuthenticationRequest::getRequestByClass(
+ $reqs, CreateFromLoginAuthenticationRequest::class
+ );
+ if ( $req ) {
+ $state['maybeLink'] = $req->maybeLink;
+ }
+
+ $session = $this->request->getSession();
+ $session->setSecret( 'AuthManager::authnState', $state );
+ $session->persist();
+
+ return $this->continueAuthentication( $reqs );
+ }
+
+ /**
+ * Continue an authentication flow
+ *
+ * Return values are interpreted as follows:
+ * - status FAIL: Authentication failed. If $response->createRequest is
+ * set, that may be passed to self::beginAuthentication() or to
+ * self::beginAccountCreation() to preserve state.
+ * - status REDIRECT: The client should be redirected to the contained URL,
+ * new AuthenticationRequests should be made (if any), then
+ * AuthManager::continueAuthentication() should be called.
+ * - status UI: The client should be presented with a user interface for
+ * the fields in the specified AuthenticationRequests, then new
+ * AuthenticationRequests should be made, then
+ * AuthManager::continueAuthentication() should be called.
+ * - status RESTART: The user logged in successfully with a third-party
+ * service, but the third-party credentials aren't attached to any local
+ * account. This could be treated as a UI or a FAIL.
+ * - status PASS: Authentication was successful.
+ *
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse
+ */
+ public function continueAuthentication( array $reqs ) {
+ $session = $this->request->getSession();
+ try {
+ if ( !$session->canSetUser() ) {
+ // Caller should have called canAuthenticateNow()
+ // @codeCoverageIgnoreStart
+ throw new \LogicException( 'Authentication is not possible now' );
+ // @codeCoverageIgnoreEnd
+ }
+
+ $state = $session->getSecret( 'AuthManager::authnState' );
+ if ( !is_array( $state ) ) {
+ return AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-authn-not-in-progress' )
+ );
+ }
+ $state['continueRequests'] = [];
+
+ $guessUserName = $state['guessUserName'];
+
+ foreach ( $reqs as $req ) {
+ $req->returnToUrl = $state['returnToUrl'];
+ }
+
+ // Step 1: Choose an primary authentication provider, and call it until it succeeds.
+
+ if ( $state['primary'] === null ) {
+ // We haven't picked a PrimaryAuthenticationProvider yet
+ // @codeCoverageIgnoreStart
+ $guessUserName = null;
+ foreach ( $reqs as $req ) {
+ if ( $req->username !== null && $req->username !== '' ) {
+ if ( $guessUserName === null ) {
+ $guessUserName = $req->username;
+ } elseif ( $guessUserName !== $req->username ) {
+ $guessUserName = null;
+ break;
+ }
+ }
+ }
+ $state['guessUserName'] = $guessUserName;
+ // @codeCoverageIgnoreEnd
+ $state['reqs'] = $reqs;
+
+ foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
+ $res = $provider->beginPrimaryAuthentication( $reqs );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS;
+ $state['primary'] = $id;
+ $state['primaryResponse'] = $res;
+ $this->logger->debug( "Primary login with $id succeeded" );
+ break 2;
+ case AuthenticationResponse::FAIL;
+ $this->logger->debug( "Login failed in primary authentication by $id" );
+ if ( $res->createRequest || $state['maybeLink'] ) {
+ $res->createRequest = new CreateFromLoginAuthenticationRequest(
+ $res->createRequest, $state['maybeLink']
+ );
+ }
+ $this->callMethodOnProviders( 7, 'postAuthentication',
+ [ User::newFromName( $guessUserName ) ?: null, $res ]
+ );
+ $session->remove( 'AuthManager::authnState' );
+ \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName ] );
+ return $res;
+ case AuthenticationResponse::ABSTAIN;
+ // Continue loop
+ break;
+ case AuthenticationResponse::REDIRECT;
+ case AuthenticationResponse::UI;
+ $this->logger->debug( "Primary login with $id returned $res->status" );
+ $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName );
+ $state['primary'] = $id;
+ $state['continueRequests'] = $res->neededRequests;
+ $session->setSecret( 'AuthManager::authnState', $state );
+ return $res;
+
+ // @codeCoverageIgnoreStart
+ default:
+ throw new \DomainException(
+ get_class( $provider ) . "::beginPrimaryAuthentication() returned $res->status"
+ );
+ // @codeCoverageIgnoreEnd
+ }
+ }
+ if ( $state['primary'] === null ) {
+ $this->logger->debug( 'Login failed in primary authentication because no provider accepted' );
+ $ret = AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-authn-no-primary' )
+ );
+ $this->callMethodOnProviders( 7, 'postAuthentication',
+ [ User::newFromName( $guessUserName ) ?: null, $ret ]
+ );
+ $session->remove( 'AuthManager::authnState' );
+ return $ret;
+ }
+ } elseif ( $state['primaryResponse'] === null ) {
+ $provider = $this->getAuthenticationProvider( $state['primary'] );
+ if ( !$provider instanceof PrimaryAuthenticationProvider ) {
+ // Configuration changed? Force them to start over.
+ // @codeCoverageIgnoreStart
+ $ret = AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-authn-not-in-progress' )
+ );
+ $this->callMethodOnProviders( 7, 'postAuthentication',
+ [ User::newFromName( $guessUserName ) ?: null, $ret ]
+ );
+ $session->remove( 'AuthManager::authnState' );
+ return $ret;
+ // @codeCoverageIgnoreEnd
+ }
+ $id = $provider->getUniqueId();
+ $res = $provider->continuePrimaryAuthentication( $reqs );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS;
+ $state['primaryResponse'] = $res;
+ $this->logger->debug( "Primary login with $id succeeded" );
+ break;
+ case AuthenticationResponse::FAIL;
+ $this->logger->debug( "Login failed in primary authentication by $id" );
+ if ( $res->createRequest || $state['maybeLink'] ) {
+ $res->createRequest = new CreateFromLoginAuthenticationRequest(
+ $res->createRequest, $state['maybeLink']
+ );
+ }
+ $this->callMethodOnProviders( 7, 'postAuthentication',
+ [ User::newFromName( $guessUserName ) ?: null, $res ]
+ );
+ $session->remove( 'AuthManager::authnState' );
+ \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName ] );
+ return $res;
+ case AuthenticationResponse::REDIRECT;
+ case AuthenticationResponse::UI;
+ $this->logger->debug( "Primary login with $id returned $res->status" );
+ $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName );
+ $state['continueRequests'] = $res->neededRequests;
+ $session->setSecret( 'AuthManager::authnState', $state );
+ return $res;
+ default:
+ throw new \DomainException(
+ get_class( $provider ) . "::continuePrimaryAuthentication() returned $res->status"
+ );
+ }
+ }
+
+ $res = $state['primaryResponse'];
+ if ( $res->username === null ) {
+ $provider = $this->getAuthenticationProvider( $state['primary'] );
+ if ( !$provider instanceof PrimaryAuthenticationProvider ) {
+ // Configuration changed? Force them to start over.
+ // @codeCoverageIgnoreStart
+ $ret = AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-authn-not-in-progress' )
+ );
+ $this->callMethodOnProviders( 7, 'postAuthentication',
+ [ User::newFromName( $guessUserName ) ?: null, $ret ]
+ );
+ $session->remove( 'AuthManager::authnState' );
+ return $ret;
+ // @codeCoverageIgnoreEnd
+ }
+
+ if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK &&
+ $res->linkRequest &&
+ // don't confuse the user with an incorrect message if linking is disabled
+ $this->getAuthenticationProvider( ConfirmLinkSecondaryAuthenticationProvider::class )
+ ) {
+ $state['maybeLink'][$res->linkRequest->getUniqueId()] = $res->linkRequest;
+ $msg = 'authmanager-authn-no-local-user-link';
+ } else {
+ $msg = 'authmanager-authn-no-local-user';
+ }
+ $this->logger->debug(
+ "Primary login with {$provider->getUniqueId()} succeeded, but returned no user"
+ );
+ $ret = AuthenticationResponse::newRestart( wfMessage( $msg ) );
+ $ret->neededRequests = $this->getAuthenticationRequestsInternal(
+ self::ACTION_LOGIN,
+ [],
+ $this->getPrimaryAuthenticationProviders() + $this->getSecondaryAuthenticationProviders()
+ );
+ if ( $res->createRequest || $state['maybeLink'] ) {
+ $ret->createRequest = new CreateFromLoginAuthenticationRequest(
+ $res->createRequest, $state['maybeLink']
+ );
+ $ret->neededRequests[] = $ret->createRequest;
+ }
+ $this->fillRequests( $ret->neededRequests, self::ACTION_LOGIN, null, true );
+ $session->setSecret( 'AuthManager::authnState', [
+ 'reqs' => [], // Will be filled in later
+ 'primary' => null,
+ 'primaryResponse' => null,
+ 'secondary' => [],
+ 'continueRequests' => $ret->neededRequests,
+ ] + $state );
+ return $ret;
+ }
+
+ // Step 2: Primary authentication succeeded, create the User object
+ // (and add the user locally if necessary)
+
+ $user = User::newFromName( $res->username, 'usable' );
+ if ( !$user ) {
+ $provider = $this->getAuthenticationProvider( $state['primary'] );
+ throw new \DomainException(
+ get_class( $provider ) . " returned an invalid username: {$res->username}"
+ );
+ }
+ if ( $user->getId() === 0 ) {
+ // User doesn't exist locally. Create it.
+ $this->logger->info( 'Auto-creating {user} on login', [
+ 'user' => $user->getName(),
+ ] );
+ $status = $this->autoCreateUser( $user, $state['primary'], false );
+ if ( !$status->isGood() ) {
+ $ret = AuthenticationResponse::newFail(
+ Status::wrap( $status )->getMessage( 'authmanager-authn-autocreate-failed' )
+ );
+ $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
+ $session->remove( 'AuthManager::authnState' );
+ \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] );
+ return $ret;
+ }
+ }
+
+ // Step 3: Iterate over all the secondary authentication providers.
+
+ $beginReqs = $state['reqs'];
+
+ foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
+ if ( !isset( $state['secondary'][$id] ) ) {
+ // This provider isn't started yet, so we pass it the set
+ // of reqs from beginAuthentication instead of whatever
+ // might have been used by a previous provider in line.
+ $func = 'beginSecondaryAuthentication';
+ $res = $provider->beginSecondaryAuthentication( $user, $beginReqs );
+ } elseif ( !$state['secondary'][$id] ) {
+ $func = 'continueSecondaryAuthentication';
+ $res = $provider->continueSecondaryAuthentication( $user, $reqs );
+ } else {
+ continue;
+ }
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS;
+ $this->logger->debug( "Secondary login with $id succeeded" );
+ // fall through
+ case AuthenticationResponse::ABSTAIN;
+ $state['secondary'][$id] = true;
+ break;
+ case AuthenticationResponse::FAIL;
+ $this->logger->debug( "Login failed in secondary authentication by $id" );
+ $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $res ] );
+ $session->remove( 'AuthManager::authnState' );
+ \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, $user, $user->getName() ] );
+ return $res;
+ case AuthenticationResponse::REDIRECT;
+ case AuthenticationResponse::UI;
+ $this->logger->debug( "Secondary login with $id returned " . $res->status );
+ $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $user->getName() );
+ $state['secondary'][$id] = false;
+ $state['continueRequests'] = $res->neededRequests;
+ $session->setSecret( 'AuthManager::authnState', $state );
+ return $res;
+
+ // @codeCoverageIgnoreStart
+ default:
+ throw new \DomainException(
+ get_class( $provider ) . "::{$func}() returned $res->status"
+ );
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ // Step 4: Authentication complete! Set the user in the session and
+ // clean up.
+
+ $this->logger->info( 'Login for {user} succeeded', [
+ 'user' => $user->getName(),
+ ] );
+ /** @var RememberMeAuthenticationRequest $req */
+ $req = AuthenticationRequest::getRequestByClass(
+ $beginReqs, RememberMeAuthenticationRequest::class
+ );
+ $this->setSessionDataForUser( $user, $req && $req->rememberMe );
+ $ret = AuthenticationResponse::newPass( $user->getName() );
+ $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
+ $session->remove( 'AuthManager::authnState' );
+ $this->removeAuthenticationSessionData( null );
+ \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName() ] );
+ return $ret;
+ } catch ( \Exception $ex ) {
+ $session->remove( 'AuthManager::authnState' );
+ throw $ex;
+ }
+ }
+
+ /**
+ * Whether security-sensitive operations should proceed.
+ *
+ * A "security-sensitive operation" is something like a password or email
+ * change, that would normally have a "reenter your password to confirm"
+ * box if we only supported password-based authentication.
+ *
+ * @param string $operation Operation being checked. This should be a
+ * message-key-like string such as 'change-password' or 'change-email'.
+ * @return string One of the SEC_* constants.
+ */
+ public function securitySensitiveOperationStatus( $operation ) {
+ $status = self::SEC_OK;
+
+ $this->logger->debug( __METHOD__ . ": Checking $operation" );
+
+ $session = $this->request->getSession();
+ $aId = $session->getUser()->getId();
+ if ( $aId === 0 ) {
+ // User isn't authenticated. DWIM?
+ $status = $this->canAuthenticateNow() ? self::SEC_REAUTH : self::SEC_FAIL;
+ $this->logger->info( __METHOD__ . ": Not logged in! $operation is $status" );
+ return $status;
+ }
+
+ if ( $session->canSetUser() ) {
+ $id = $session->get( 'AuthManager:lastAuthId' );
+ $last = $session->get( 'AuthManager:lastAuthTimestamp' );
+ if ( $id !== $aId || $last === null ) {
+ $timeSinceLogin = PHP_INT_MAX; // Forever ago
+ } else {
+ $timeSinceLogin = max( 0, time() - $last );
+ }
+
+ $thresholds = $this->config->get( 'ReauthenticateTime' );
+ if ( isset( $thresholds[$operation] ) ) {
+ $threshold = $thresholds[$operation];
+ } elseif ( isset( $thresholds['default'] ) ) {
+ $threshold = $thresholds['default'];
+ } else {
+ throw new \UnexpectedValueException( '$wgReauthenticateTime lacks a default' );
+ }
+
+ if ( $threshold >= 0 && $timeSinceLogin > $threshold ) {
+ $status = self::SEC_REAUTH;
+ }
+ } else {
+ $timeSinceLogin = -1;
+
+ $pass = $this->config->get( 'AllowSecuritySensitiveOperationIfCannotReauthenticate' );
+ if ( isset( $pass[$operation] ) ) {
+ $status = $pass[$operation] ? self::SEC_OK : self::SEC_FAIL;
+ } elseif ( isset( $pass['default'] ) ) {
+ $status = $pass['default'] ? self::SEC_OK : self::SEC_FAIL;
+ } else {
+ throw new \UnexpectedValueException(
+ '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default'
+ );
+ }
+ }
+
+ \Hooks::run( 'SecuritySensitiveOperationStatus', [
+ &$status, $operation, $session, $timeSinceLogin
+ ] );
+
+ // If authentication is not possible, downgrade from "REAUTH" to "FAIL".
+ if ( !$this->canAuthenticateNow() && $status === self::SEC_REAUTH ) {
+ $status = self::SEC_FAIL;
+ }
+
+ $this->logger->info( __METHOD__ . ": $operation is $status" );
+
+ return $status;
+ }
+
+ /**
+ * Determine whether a username can authenticate
+ *
+ * This is mainly for internal purposes and only takes authentication data into account,
+ * not things like blocks that can change without the authentication system being aware.
+ *
+ * @param string $username MediaWiki username
+ * @return bool
+ */
+ public function userCanAuthenticate( $username ) {
+ foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
+ if ( $provider->testUserCanAuthenticate( $username ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Provide normalized versions of the username for security checks
+ *
+ * Since different providers can normalize the input in different ways,
+ * this returns an array of all the different ways the name might be
+ * normalized for authentication.
+ *
+ * The returned strings should not be revealed to the user, as that might
+ * leak private information (e.g. an email address might be normalized to a
+ * username).
+ *
+ * @param string $username
+ * @return string[]
+ */
+ public function normalizeUsername( $username ) {
+ $ret = [];
+ foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
+ $normalized = $provider->providerNormalizeUsername( $username );
+ if ( $normalized !== null ) {
+ $ret[$normalized] = true;
+ }
+ }
+ return array_keys( $ret );
+ }
+
+ /**@}*/
+
+ /**
+ * @name Authentication data changing
+ * @{
+ */
+
+ /**
+ * Revoke any authentication credentials for a user
+ *
+ * After this, the user should no longer be able to log in.
+ *
+ * @param string $username
+ */
+ public function revokeAccessForUser( $username ) {
+ $this->logger->info( 'Revoking access for {user}', [
+ 'user' => $username,
+ ] );
+ $this->callMethodOnProviders( 6, 'providerRevokeAccessForUser', [ $username ] );
+ }
+
+ /**
+ * Validate a change of authentication data (e.g. passwords)
+ * @param AuthenticationRequest $req
+ * @param bool $checkData If false, $req hasn't been loaded from the
+ * submission so checks on user-submitted fields should be skipped. $req->username is
+ * considered user-submitted for this purpose, even if it cannot be changed via
+ * $req->loadFromSubmission.
+ * @return Status
+ */
+ public function allowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) {
+ $any = false;
+ $providers = $this->getPrimaryAuthenticationProviders() +
+ $this->getSecondaryAuthenticationProviders();
+ foreach ( $providers as $provider ) {
+ $status = $provider->providerAllowsAuthenticationDataChange( $req, $checkData );
+ if ( !$status->isGood() ) {
+ return Status::wrap( $status );
+ }
+ $any = $any || $status->value !== 'ignored';
+ }
+ if ( !$any ) {
+ $status = Status::newGood( 'ignored' );
+ $status->warning( 'authmanager-change-not-supported' );
+ return $status;
+ }
+ return Status::newGood();
+ }
+
+ /**
+ * Change authentication data (e.g. passwords)
+ *
+ * If $req was returned for AuthManager::ACTION_CHANGE, using $req should
+ * result in a successful login in the future.
+ *
+ * If $req was returned for AuthManager::ACTION_REMOVE, using $req should
+ * no longer result in a successful login.
+ *
+ * This method should only be called if allowsAuthenticationDataChange( $req, true )
+ * returned success.
+ *
+ * @param AuthenticationRequest $req
+ */
+ public function changeAuthenticationData( AuthenticationRequest $req ) {
+ $this->logger->info( 'Changing authentication data for {user} class {what}', [
+ 'user' => is_string( $req->username ) ? $req->username : '<no name>',
+ 'what' => get_class( $req ),
+ ] );
+
+ $this->callMethodOnProviders( 6, 'providerChangeAuthenticationData', [ $req ] );
+
+ // When the main account's authentication data is changed, invalidate
+ // all BotPasswords too.
+ \BotPassword::invalidateAllPasswordsForUser( $req->username );
+ }
+
+ /**@}*/
+
+ /**
+ * @name Account creation
+ * @{
+ */
+
+ /**
+ * Determine whether accounts can be created
+ * @return bool
+ */
+ public function canCreateAccounts() {
+ foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
+ switch ( $provider->accountCreationType() ) {
+ case PrimaryAuthenticationProvider::TYPE_CREATE:
+ case PrimaryAuthenticationProvider::TYPE_LINK:
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Determine whether a particular account can be created
+ * @param string $username MediaWiki username
+ * @param array $options
+ * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL
+ * - creating: (bool) For internal use only. Never specify this.
+ * @return Status
+ */
+ public function canCreateAccount( $username, $options = [] ) {
+ // Back compat
+ if ( is_int( $options ) ) {
+ $options = [ 'flags' => $options ];
+ }
+ $options += [
+ 'flags' => User::READ_NORMAL,
+ 'creating' => false,
+ ];
+ $flags = $options['flags'];
+
+ if ( !$this->canCreateAccounts() ) {
+ return Status::newFatal( 'authmanager-create-disabled' );
+ }
+
+ if ( $this->userExists( $username, $flags ) ) {
+ return Status::newFatal( 'userexists' );
+ }
+
+ $user = User::newFromName( $username, 'creatable' );
+ if ( !is_object( $user ) ) {
+ return Status::newFatal( 'noname' );
+ } else {
+ $user->load( $flags ); // Explicitly load with $flags, auto-loading always uses READ_NORMAL
+ if ( $user->getId() !== 0 ) {
+ return Status::newFatal( 'userexists' );
+ }
+ }
+
+ // Denied by providers?
+ $providers = $this->getPreAuthenticationProviders() +
+ $this->getPrimaryAuthenticationProviders() +
+ $this->getSecondaryAuthenticationProviders();
+ foreach ( $providers as $provider ) {
+ $status = $provider->testUserForCreation( $user, false, $options );
+ if ( !$status->isGood() ) {
+ return Status::wrap( $status );
+ }
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Basic permissions checks on whether a user can create accounts
+ * @param User $creator User doing the account creation
+ * @return Status
+ */
+ public function checkAccountCreatePermissions( User $creator ) {
+ // Wiki is read-only?
+ if ( wfReadOnly() ) {
+ return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) );
+ }
+
+ // This is awful, this permission check really shouldn't go through Title.
+ $permErrors = \SpecialPage::getTitleFor( 'CreateAccount' )
+ ->getUserPermissionsErrors( 'createaccount', $creator, 'secure' );
+ if ( $permErrors ) {
+ $status = Status::newGood();
+ foreach ( $permErrors as $args ) {
+ call_user_func_array( [ $status, 'fatal' ], $args );
+ }
+ return $status;
+ }
+
+ $block = $creator->isBlockedFromCreateAccount();
+ if ( $block ) {
+ $errorParams = [
+ $block->getTarget(),
+ $block->mReason ?: wfMessage( 'blockednoreason' )->text(),
+ $block->getByName()
+ ];
+
+ if ( $block->getType() === \Block::TYPE_RANGE ) {
+ $errorMessage = 'cantcreateaccount-range-text';
+ $errorParams[] = $this->getRequest()->getIP();
+ } else {
+ $errorMessage = 'cantcreateaccount-text';
+ }
+
+ return Status::newFatal( wfMessage( $errorMessage, $errorParams ) );
+ }
+
+ $ip = $this->getRequest()->getIP();
+ if ( $creator->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) {
+ return Status::newFatal( 'sorbs_create_account_reason' );
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Start an account creation flow
+ *
+ * In addition to the AuthenticationRequests returned by
+ * $this->getAuthenticationRequests(), a client might include a
+ * CreateFromLoginAuthenticationRequest from a previous login attempt. If
+ * <code>
+ * $createFromLoginAuthenticationRequest->hasPrimaryStateForAction( AuthManager::ACTION_CREATE )
+ * </code>
+ * returns true, any AuthenticationRequest::PRIMARY_REQUIRED requests
+ * should be omitted. If the CreateFromLoginAuthenticationRequest has a
+ * username set, that username must be used for all other requests.
+ *
+ * @param User $creator User doing the account creation
+ * @param AuthenticationRequest[] $reqs
+ * @param string $returnToUrl Url that REDIRECT responses should eventually
+ * return to.
+ * @return AuthenticationResponse
+ */
+ public function beginAccountCreation( User $creator, array $reqs, $returnToUrl ) {
+ $session = $this->request->getSession();
+ if ( !$this->canCreateAccounts() ) {
+ // Caller should have called canCreateAccounts()
+ $session->remove( 'AuthManager::accountCreationState' );
+ throw new \LogicException( 'Account creation is not possible' );
+ }
+
+ try {
+ $username = AuthenticationRequest::getUsernameFromRequests( $reqs );
+ } catch ( \UnexpectedValueException $ex ) {
+ $username = null;
+ }
+ if ( $username === null ) {
+ $this->logger->debug( __METHOD__ . ': No username provided' );
+ return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
+ }
+
+ // Permissions check
+ $status = $this->checkAccountCreatePermissions( $creator );
+ if ( !$status->isGood() ) {
+ $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [
+ 'user' => $username,
+ 'creator' => $creator->getName(),
+ 'reason' => $status->getWikiText( null, null, 'en' )
+ ] );
+ return AuthenticationResponse::newFail( $status->getMessage() );
+ }
+
+ $status = $this->canCreateAccount(
+ $username, [ 'flags' => User::READ_LOCKING, 'creating' => true ]
+ );
+ if ( !$status->isGood() ) {
+ $this->logger->debug( __METHOD__ . ': {user} cannot be created: {reason}', [
+ 'user' => $username,
+ 'creator' => $creator->getName(),
+ 'reason' => $status->getWikiText( null, null, 'en' )
+ ] );
+ return AuthenticationResponse::newFail( $status->getMessage() );
+ }
+
+ $user = User::newFromName( $username, 'creatable' );
+ foreach ( $reqs as $req ) {
+ $req->username = $username;
+ $req->returnToUrl = $returnToUrl;
+ if ( $req instanceof UserDataAuthenticationRequest ) {
+ $status = $req->populateUser( $user );
+ if ( !$status->isGood() ) {
+ $status = Status::wrap( $status );
+ $session->remove( 'AuthManager::accountCreationState' );
+ $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ 'reason' => $status->getWikiText( null, null, 'en' ),
+ ] );
+ return AuthenticationResponse::newFail( $status->getMessage() );
+ }
+ }
+ }
+
+ $this->removeAuthenticationSessionData( null );
+
+ $state = [
+ 'username' => $username,
+ 'userid' => 0,
+ 'creatorid' => $creator->getId(),
+ 'creatorname' => $creator->getName(),
+ 'reqs' => $reqs,
+ 'returnToUrl' => $returnToUrl,
+ 'primary' => null,
+ 'primaryResponse' => null,
+ 'secondary' => [],
+ 'continueRequests' => [],
+ 'maybeLink' => [],
+ 'ranPreTests' => false,
+ ];
+
+ // Special case: converting a login to an account creation
+ $req = AuthenticationRequest::getRequestByClass(
+ $reqs, CreateFromLoginAuthenticationRequest::class
+ );
+ if ( $req ) {
+ $state['maybeLink'] = $req->maybeLink;
+
+ if ( $req->createRequest ) {
+ $reqs[] = $req->createRequest;
+ $state['reqs'][] = $req->createRequest;
+ }
+ }
+
+ $session->setSecret( 'AuthManager::accountCreationState', $state );
+ $session->persist();
+
+ return $this->continueAccountCreation( $reqs );
+ }
+
+ /**
+ * Continue an account creation flow
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse
+ */
+ public function continueAccountCreation( array $reqs ) {
+ $session = $this->request->getSession();
+ try {
+ if ( !$this->canCreateAccounts() ) {
+ // Caller should have called canCreateAccounts()
+ $session->remove( 'AuthManager::accountCreationState' );
+ throw new \LogicException( 'Account creation is not possible' );
+ }
+
+ $state = $session->getSecret( 'AuthManager::accountCreationState' );
+ if ( !is_array( $state ) ) {
+ return AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-create-not-in-progress' )
+ );
+ }
+ $state['continueRequests'] = [];
+
+ // Step 0: Prepare and validate the input
+
+ $user = User::newFromName( $state['username'], 'creatable' );
+ if ( !is_object( $user ) ) {
+ $session->remove( 'AuthManager::accountCreationState' );
+ $this->logger->debug( __METHOD__ . ': Invalid username', [
+ 'user' => $state['username'],
+ ] );
+ return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
+ }
+
+ if ( $state['creatorid'] ) {
+ $creator = User::newFromId( $state['creatorid'] );
+ } else {
+ $creator = new User;
+ $creator->setName( $state['creatorname'] );
+ }
+
+ // Avoid account creation races on double submissions
+ $cache = \ObjectCache::getLocalClusterInstance();
+ $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $user->getName() ) ) );
+ if ( !$lock ) {
+ // Don't clear AuthManager::accountCreationState for this code
+ // path because the process that won the race owns it.
+ $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ return AuthenticationResponse::newFail( wfMessage( 'usernameinprogress' ) );
+ }
+
+ // Permissions check
+ $status = $this->checkAccountCreatePermissions( $creator );
+ if ( !$status->isGood() ) {
+ $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ 'reason' => $status->getWikiText( null, null, 'en' )
+ ] );
+ $ret = AuthenticationResponse::newFail( $status->getMessage() );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $ret;
+ }
+
+ // Load from master for existence check
+ $user->load( User::READ_LOCKING );
+
+ if ( $state['userid'] === 0 ) {
+ if ( $user->getId() != 0 ) {
+ $this->logger->debug( __METHOD__ . ': User exists locally', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $ret = AuthenticationResponse::newFail( wfMessage( 'userexists' ) );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $ret;
+ }
+ } else {
+ if ( $user->getId() == 0 ) {
+ $this->logger->debug( __METHOD__ . ': User does not exist locally when it should', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ 'expected_id' => $state['userid'],
+ ] );
+ throw new \UnexpectedValueException(
+ "User \"{$state['username']}\" should exist now, but doesn't!"
+ );
+ }
+ if ( $user->getId() != $state['userid'] ) {
+ $this->logger->debug( __METHOD__ . ': User ID/name mismatch', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ 'expected_id' => $state['userid'],
+ 'actual_id' => $user->getId(),
+ ] );
+ throw new \UnexpectedValueException(
+ "User \"{$state['username']}\" exists, but " .
+ "ID {$user->getId()} != {$state['userid']}!"
+ );
+ }
+ }
+ foreach ( $state['reqs'] as $req ) {
+ if ( $req instanceof UserDataAuthenticationRequest ) {
+ $status = $req->populateUser( $user );
+ if ( !$status->isGood() ) {
+ // This should never happen...
+ $status = Status::wrap( $status );
+ $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ 'reason' => $status->getWikiText( null, null, 'en' ),
+ ] );
+ $ret = AuthenticationResponse::newFail( $status->getMessage() );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $ret;
+ }
+ }
+ }
+
+ foreach ( $reqs as $req ) {
+ $req->returnToUrl = $state['returnToUrl'];
+ $req->username = $state['username'];
+ }
+
+ // Run pre-creation tests, if we haven't already
+ if ( !$state['ranPreTests'] ) {
+ $providers = $this->getPreAuthenticationProviders() +
+ $this->getPrimaryAuthenticationProviders() +
+ $this->getSecondaryAuthenticationProviders();
+ foreach ( $providers as $id => $provider ) {
+ $status = $provider->testForAccountCreation( $user, $creator, $reqs );
+ if ( !$status->isGood() ) {
+ $this->logger->debug( __METHOD__ . ": Fail in pre-authentication by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $ret = AuthenticationResponse::newFail(
+ Status::wrap( $status )->getMessage()
+ );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $ret;
+ }
+ }
+
+ $state['ranPreTests'] = true;
+ }
+
+ // Step 1: Choose a primary authentication provider and call it until it succeeds.
+
+ if ( $state['primary'] === null ) {
+ // We haven't picked a PrimaryAuthenticationProvider yet
+ foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
+ if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_NONE ) {
+ continue;
+ }
+ $res = $provider->beginPrimaryAccountCreation( $user, $creator, $reqs );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS;
+ $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $state['primary'] = $id;
+ $state['primaryResponse'] = $res;
+ break 2;
+ case AuthenticationResponse::FAIL;
+ $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $res;
+ case AuthenticationResponse::ABSTAIN;
+ // Continue loop
+ break;
+ case AuthenticationResponse::REDIRECT;
+ case AuthenticationResponse::UI;
+ $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
+ $state['primary'] = $id;
+ $state['continueRequests'] = $res->neededRequests;
+ $session->setSecret( 'AuthManager::accountCreationState', $state );
+ return $res;
+
+ // @codeCoverageIgnoreStart
+ default:
+ throw new \DomainException(
+ get_class( $provider ) . "::beginPrimaryAccountCreation() returned $res->status"
+ );
+ // @codeCoverageIgnoreEnd
+ }
+ }
+ if ( $state['primary'] === null ) {
+ $this->logger->debug( __METHOD__ . ': Primary creation failed because no provider accepted', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $ret = AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-create-no-primary' )
+ );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $ret;
+ }
+ } elseif ( $state['primaryResponse'] === null ) {
+ $provider = $this->getAuthenticationProvider( $state['primary'] );
+ if ( !$provider instanceof PrimaryAuthenticationProvider ) {
+ // Configuration changed? Force them to start over.
+ // @codeCoverageIgnoreStart
+ $ret = AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-create-not-in-progress' )
+ );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $ret;
+ // @codeCoverageIgnoreEnd
+ }
+ $id = $provider->getUniqueId();
+ $res = $provider->continuePrimaryAccountCreation( $user, $creator, $reqs );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS;
+ $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $state['primaryResponse'] = $res;
+ break;
+ case AuthenticationResponse::FAIL;
+ $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $res;
+ case AuthenticationResponse::REDIRECT;
+ case AuthenticationResponse::UI;
+ $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
+ $state['continueRequests'] = $res->neededRequests;
+ $session->setSecret( 'AuthManager::accountCreationState', $state );
+ return $res;
+ default:
+ throw new \DomainException(
+ get_class( $provider ) . "::continuePrimaryAccountCreation() returned $res->status"
+ );
+ }
+ }
+
+ // Step 2: Primary authentication succeeded, create the User object
+ // and add the user locally.
+
+ if ( $state['userid'] === 0 ) {
+ $this->logger->info( 'Creating user {user} during account creation', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $status = $user->addToDatabase();
+ if ( !$status->isOK() ) {
+ // @codeCoverageIgnoreStart
+ $ret = AuthenticationResponse::newFail( $status->getMessage() );
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ return $ret;
+ // @codeCoverageIgnoreEnd
+ }
+ $this->setDefaultUserOptions( $user, $creator->isAnon() );
+ \Hooks::run( 'LocalUserCreated', [ $user, false ] );
+ $user->saveSettings();
+ $state['userid'] = $user->getId();
+
+ // Update user count
+ \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
+
+ // Watch user's userpage and talk page
+ $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS );
+
+ // Inform the provider
+ $logSubtype = $provider->finishAccountCreation( $user, $creator, $state['primaryResponse'] );
+
+ // Log the creation
+ if ( $this->config->get( 'NewUserLog' ) ) {
+ $isAnon = $creator->isAnon();
+ $logEntry = new \ManualLogEntry(
+ 'newusers',
+ $logSubtype ?: ( $isAnon ? 'create' : 'create2' )
+ );
+ $logEntry->setPerformer( $isAnon ? $user : $creator );
+ $logEntry->setTarget( $user->getUserPage() );
+ /** @var CreationReasonAuthenticationRequest $req */
+ $req = AuthenticationRequest::getRequestByClass(
+ $state['reqs'], CreationReasonAuthenticationRequest::class
+ );
+ $logEntry->setComment( $req ? $req->reason : '' );
+ $logEntry->setParameters( [
+ '4::userid' => $user->getId(),
+ ] );
+ $logid = $logEntry->insert();
+ $logEntry->publish( $logid );
+ }
+ }
+
+ // Step 3: Iterate over all the secondary authentication providers.
+
+ $beginReqs = $state['reqs'];
+
+ foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
+ if ( !isset( $state['secondary'][$id] ) ) {
+ // This provider isn't started yet, so we pass it the set
+ // of reqs from beginAuthentication instead of whatever
+ // might have been used by a previous provider in line.
+ $func = 'beginSecondaryAccountCreation';
+ $res = $provider->beginSecondaryAccountCreation( $user, $creator, $beginReqs );
+ } elseif ( !$state['secondary'][$id] ) {
+ $func = 'continueSecondaryAccountCreation';
+ $res = $provider->continueSecondaryAccountCreation( $user, $creator, $reqs );
+ } else {
+ continue;
+ }
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS;
+ $this->logger->debug( __METHOD__ . ": Secondary creation passed by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ // fall through
+ case AuthenticationResponse::ABSTAIN;
+ $state['secondary'][$id] = true;
+ break;
+ case AuthenticationResponse::REDIRECT;
+ case AuthenticationResponse::UI;
+ $this->logger->debug( __METHOD__ . ": Secondary creation $res->status by $id", [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+ $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
+ $state['secondary'][$id] = false;
+ $state['continueRequests'] = $res->neededRequests;
+ $session->setSecret( 'AuthManager::accountCreationState', $state );
+ return $res;
+ case AuthenticationResponse::FAIL;
+ throw new \DomainException(
+ get_class( $provider ) . "::{$func}() returned $res->status." .
+ ' Secondary providers are not allowed to fail account creation, that' .
+ ' should have been done via testForAccountCreation().'
+ );
+ // @codeCoverageIgnoreStart
+ default:
+ throw new \DomainException(
+ get_class( $provider ) . "::{$func}() returned $res->status"
+ );
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ $id = $user->getId();
+ $name = $user->getName();
+ $req = new CreatedAccountAuthenticationRequest( $id, $name );
+ $ret = AuthenticationResponse::newPass( $name );
+ $ret->loginRequest = $req;
+ $this->createdAccountAuthenticationRequests[] = $req;
+
+ $this->logger->info( __METHOD__ . ': Account creation succeeded for {user}', [
+ 'user' => $user->getName(),
+ 'creator' => $creator->getName(),
+ ] );
+
+ $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
+ $session->remove( 'AuthManager::accountCreationState' );
+ $this->removeAuthenticationSessionData( null );
+ return $ret;
+ } catch ( \Exception $ex ) {
+ $session->remove( 'AuthManager::accountCreationState' );
+ throw $ex;
+ }
+ }
+
+ /**
+ * Auto-create an account, and log into that account
+ *
+ * PrimaryAuthenticationProviders can invoke this method by returning a PASS from
+ * beginPrimaryAuthentication/continuePrimaryAuthentication with the username of a
+ * non-existing user. SessionProviders can invoke it by returning a SessionInfo with
+ * the username of a non-existing user from provideSessionInfo(). Calling this method
+ * explicitly (e.g. from a maintenance script) is also fine.
+ *
+ * @param User $user User to auto-create
+ * @param string $source What caused the auto-creation? This must be the ID
+ * of a PrimaryAuthenticationProvider or the constant self::AUTOCREATE_SOURCE_SESSION.
+ * @param bool $login Whether to also log the user in
+ * @return Status Good if user was created, Ok if user already existed, otherwise Fatal
+ */
+ public function autoCreateUser( User $user, $source, $login = true ) {
+ if ( $source !== self::AUTOCREATE_SOURCE_SESSION &&
+ !$this->getAuthenticationProvider( $source ) instanceof PrimaryAuthenticationProvider
+ ) {
+ throw new \InvalidArgumentException( "Unknown auto-creation source: $source" );
+ }
+
+ $username = $user->getName();
+
+ // Try the local user from the replica DB
+ $localId = User::idFromName( $username );
+ $flags = User::READ_NORMAL;
+
+ // Fetch the user ID from the master, so that we don't try to create the user
+ // when they already exist, due to replication lag
+ // @codeCoverageIgnoreStart
+ if ( !$localId && wfGetLB()->getReaderIndex() != 0 ) {
+ $localId = User::idFromName( $username, User::READ_LATEST );
+ $flags = User::READ_LATEST;
+ }
+ // @codeCoverageIgnoreEnd
+
+ if ( $localId ) {
+ $this->logger->debug( __METHOD__ . ': {username} already exists locally', [
+ 'username' => $username,
+ ] );
+ $user->setId( $localId );
+ $user->loadFromId( $flags );
+ if ( $login ) {
+ $this->setSessionDataForUser( $user );
+ }
+ $status = Status::newGood();
+ $status->warning( 'userexists' );
+ return $status;
+ }
+
+ // Wiki is read-only?
+ if ( wfReadOnly() ) {
+ $this->logger->debug( __METHOD__ . ': denied by wfReadOnly(): {reason}', [
+ 'username' => $username,
+ 'reason' => wfReadOnlyReason(),
+ ] );
+ $user->setId( 0 );
+ $user->loadFromId();
+ return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) );
+ }
+
+ // Check the session, if we tried to create this user already there's
+ // no point in retrying.
+ $session = $this->request->getSession();
+ if ( $session->get( 'AuthManager::AutoCreateBlacklist' ) ) {
+ $this->logger->debug( __METHOD__ . ': blacklisted in session {sessionid}', [
+ 'username' => $username,
+ 'sessionid' => $session->getId(),
+ ] );
+ $user->setId( 0 );
+ $user->loadFromId();
+ $reason = $session->get( 'AuthManager::AutoCreateBlacklist' );
+ if ( $reason instanceof StatusValue ) {
+ return Status::wrap( $reason );
+ } else {
+ return Status::newFatal( $reason );
+ }
+ }
+
+ // Is the username creatable?
+ if ( !User::isCreatableName( $username ) ) {
+ $this->logger->debug( __METHOD__ . ': name "{username}" is not creatable', [
+ 'username' => $username,
+ ] );
+ $session->set( 'AuthManager::AutoCreateBlacklist', 'noname' );
+ $user->setId( 0 );
+ $user->loadFromId();
+ return Status::newFatal( 'noname' );
+ }
+
+ // Is the IP user able to create accounts?
+ $anon = new User;
+ if ( !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' ) ) {
+ $this->logger->debug( __METHOD__ . ': IP lacks the ability to create or autocreate accounts', [
+ 'username' => $username,
+ 'ip' => $anon->getName(),
+ ] );
+ $session->set( 'AuthManager::AutoCreateBlacklist', 'authmanager-autocreate-noperm' );
+ $session->persist();
+ $user->setId( 0 );
+ $user->loadFromId();
+ return Status::newFatal( 'authmanager-autocreate-noperm' );
+ }
+
+ // Avoid account creation races on double submissions
+ $cache = \ObjectCache::getLocalClusterInstance();
+ $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
+ if ( !$lock ) {
+ $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [
+ 'user' => $username,
+ ] );
+ $user->setId( 0 );
+ $user->loadFromId();
+ return Status::newFatal( 'usernameinprogress' );
+ }
+
+ // Denied by providers?
+ $options = [
+ 'flags' => User::READ_LATEST,
+ 'creating' => true,
+ ];
+ $providers = $this->getPreAuthenticationProviders() +
+ $this->getPrimaryAuthenticationProviders() +
+ $this->getSecondaryAuthenticationProviders();
+ foreach ( $providers as $provider ) {
+ $status = $provider->testUserForCreation( $user, $source, $options );
+ if ( !$status->isGood() ) {
+ $ret = Status::wrap( $status );
+ $this->logger->debug( __METHOD__ . ': Provider denied creation of {username}: {reason}', [
+ 'username' => $username,
+ 'reason' => $ret->getWikiText( null, null, 'en' ),
+ ] );
+ $session->set( 'AuthManager::AutoCreateBlacklist', $status );
+ $user->setId( 0 );
+ $user->loadFromId();
+ return $ret;
+ }
+ }
+
+ $backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
+ if ( $cache->get( $backoffKey ) ) {
+ $this->logger->debug( __METHOD__ . ': {username} denied by prior creation attempt failures', [
+ 'username' => $username,
+ ] );
+ $user->setId( 0 );
+ $user->loadFromId();
+ return Status::newFatal( 'authmanager-autocreate-exception' );
+ }
+
+ // Checks passed, create the user...
+ $from = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI';
+ $this->logger->info( __METHOD__ . ': creating new user ({username}) - from: {from}', [
+ 'username' => $username,
+ 'from' => $from,
+ ] );
+
+ // Ignore warnings about master connections/writes...hard to avoid here
+ $trxProfiler = \Profiler::instance()->getTransactionProfiler();
+ $old = $trxProfiler->setSilenced( true );
+ try {
+ $status = $user->addToDatabase();
+ if ( !$status->isOK() ) {
+ // Double-check for a race condition (T70012). We make use of the fact that when
+ // addToDatabase fails due to the user already existing, the user object gets loaded.
+ if ( $user->getId() ) {
+ $this->logger->info( __METHOD__ . ': {username} already exists locally (race)', [
+ 'username' => $username,
+ ] );
+ if ( $login ) {
+ $this->setSessionDataForUser( $user );
+ }
+ $status = Status::newGood();
+ $status->warning( 'userexists' );
+ } else {
+ $this->logger->error( __METHOD__ . ': {username} failed with message {msg}', [
+ 'username' => $username,
+ 'msg' => $status->getWikiText( null, null, 'en' )
+ ] );
+ $user->setId( 0 );
+ $user->loadFromId();
+ }
+ return $status;
+ }
+ } catch ( \Exception $ex ) {
+ $trxProfiler->setSilenced( $old );
+ $this->logger->error( __METHOD__ . ': {username} failed with exception {exception}', [
+ 'username' => $username,
+ 'exception' => $ex,
+ ] );
+ // Do not keep throwing errors for a while
+ $cache->set( $backoffKey, 1, 600 );
+ // Bubble up error; which should normally trigger DB rollbacks
+ throw $ex;
+ }
+
+ $this->setDefaultUserOptions( $user, false );
+
+ // Inform the providers
+ $this->callMethodOnProviders( 6, 'autoCreatedAccount', [ $user, $source ] );
+
+ \Hooks::run( 'AuthPluginAutoCreate', [ $user ], '1.27' );
+ \Hooks::run( 'LocalUserCreated', [ $user, true ] );
+ $user->saveSettings();
+
+ // Update user count
+ \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
+ // Watch user's userpage and talk page
+ \DeferredUpdates::addCallableUpdate( function () use ( $user ) {
+ $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS );
+ } );
+
+ // Log the creation
+ if ( $this->config->get( 'NewUserLog' ) ) {
+ $logEntry = new \ManualLogEntry( 'newusers', 'autocreate' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( $user->getUserPage() );
+ $logEntry->setComment( '' );
+ $logEntry->setParameters( [
+ '4::userid' => $user->getId(),
+ ] );
+ $logEntry->insert();
+ }
+
+ $trxProfiler->setSilenced( $old );
+
+ if ( $login ) {
+ $this->setSessionDataForUser( $user );
+ }
+
+ return Status::newGood();
+ }
+
+ /**@}*/
+
+ /**
+ * @name Account linking
+ * @{
+ */
+
+ /**
+ * Determine whether accounts can be linked
+ * @return bool
+ */
+ public function canLinkAccounts() {
+ foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
+ if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Start an account linking flow
+ *
+ * @param User $user User being linked
+ * @param AuthenticationRequest[] $reqs
+ * @param string $returnToUrl Url that REDIRECT responses should eventually
+ * return to.
+ * @return AuthenticationResponse
+ */
+ public function beginAccountLink( User $user, array $reqs, $returnToUrl ) {
+ $session = $this->request->getSession();
+ $session->remove( 'AuthManager::accountLinkState' );
+
+ if ( !$this->canLinkAccounts() ) {
+ // Caller should have called canLinkAccounts()
+ throw new \LogicException( 'Account linking is not possible' );
+ }
+
+ if ( $user->getId() === 0 ) {
+ if ( !User::isUsableName( $user->getName() ) ) {
+ $msg = wfMessage( 'noname' );
+ } else {
+ $msg = wfMessage( 'authmanager-userdoesnotexist', $user->getName() );
+ }
+ return AuthenticationResponse::newFail( $msg );
+ }
+ foreach ( $reqs as $req ) {
+ $req->username = $user->getName();
+ $req->returnToUrl = $returnToUrl;
+ }
+
+ $this->removeAuthenticationSessionData( null );
+
+ $providers = $this->getPreAuthenticationProviders();
+ foreach ( $providers as $id => $provider ) {
+ $status = $provider->testForAccountLink( $user );
+ if ( !$status->isGood() ) {
+ $this->logger->debug( __METHOD__ . ": Account linking pre-check failed by $id", [
+ 'user' => $user->getName(),
+ ] );
+ $ret = AuthenticationResponse::newFail(
+ Status::wrap( $status )->getMessage()
+ );
+ $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
+ return $ret;
+ }
+ }
+
+ $state = [
+ 'username' => $user->getName(),
+ 'userid' => $user->getId(),
+ 'returnToUrl' => $returnToUrl,
+ 'primary' => null,
+ 'continueRequests' => [],
+ ];
+
+ $providers = $this->getPrimaryAuthenticationProviders();
+ foreach ( $providers as $id => $provider ) {
+ if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider::TYPE_LINK ) {
+ continue;
+ }
+
+ $res = $provider->beginPrimaryAccountLink( $user, $reqs );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS;
+ $this->logger->info( "Account linked to {user} by $id", [
+ 'user' => $user->getName(),
+ ] );
+ $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
+ return $res;
+
+ case AuthenticationResponse::FAIL;
+ $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
+ 'user' => $user->getName(),
+ ] );
+ $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
+ return $res;
+
+ case AuthenticationResponse::ABSTAIN;
+ // Continue loop
+ break;
+
+ case AuthenticationResponse::REDIRECT;
+ case AuthenticationResponse::UI;
+ $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
+ 'user' => $user->getName(),
+ ] );
+ $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
+ $state['primary'] = $id;
+ $state['continueRequests'] = $res->neededRequests;
+ $session->setSecret( 'AuthManager::accountLinkState', $state );
+ $session->persist();
+ return $res;
+
+ // @codeCoverageIgnoreStart
+ default:
+ throw new \DomainException(
+ get_class( $provider ) . "::beginPrimaryAccountLink() returned $res->status"
+ );
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ $this->logger->debug( __METHOD__ . ': Account linking failed because no provider accepted', [
+ 'user' => $user->getName(),
+ ] );
+ $ret = AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-link-no-primary' )
+ );
+ $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
+ return $ret;
+ }
+
+ /**
+ * Continue an account linking flow
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse
+ */
+ public function continueAccountLink( array $reqs ) {
+ $session = $this->request->getSession();
+ try {
+ if ( !$this->canLinkAccounts() ) {
+ // Caller should have called canLinkAccounts()
+ $session->remove( 'AuthManager::accountLinkState' );
+ throw new \LogicException( 'Account linking is not possible' );
+ }
+
+ $state = $session->getSecret( 'AuthManager::accountLinkState' );
+ if ( !is_array( $state ) ) {
+ return AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-link-not-in-progress' )
+ );
+ }
+ $state['continueRequests'] = [];
+
+ // Step 0: Prepare and validate the input
+
+ $user = User::newFromName( $state['username'], 'usable' );
+ if ( !is_object( $user ) ) {
+ $session->remove( 'AuthManager::accountLinkState' );
+ return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
+ }
+ if ( $user->getId() != $state['userid'] ) {
+ throw new \UnexpectedValueException(
+ "User \"{$state['username']}\" is valid, but " .
+ "ID {$user->getId()} != {$state['userid']}!"
+ );
+ }
+
+ foreach ( $reqs as $req ) {
+ $req->username = $state['username'];
+ $req->returnToUrl = $state['returnToUrl'];
+ }
+
+ // Step 1: Call the primary again until it succeeds
+
+ $provider = $this->getAuthenticationProvider( $state['primary'] );
+ if ( !$provider instanceof PrimaryAuthenticationProvider ) {
+ // Configuration changed? Force them to start over.
+ // @codeCoverageIgnoreStart
+ $ret = AuthenticationResponse::newFail(
+ wfMessage( 'authmanager-link-not-in-progress' )
+ );
+ $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
+ $session->remove( 'AuthManager::accountLinkState' );
+ return $ret;
+ // @codeCoverageIgnoreEnd
+ }
+ $id = $provider->getUniqueId();
+ $res = $provider->continuePrimaryAccountLink( $user, $reqs );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS;
+ $this->logger->info( "Account linked to {user} by $id", [
+ 'user' => $user->getName(),
+ ] );
+ $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
+ $session->remove( 'AuthManager::accountLinkState' );
+ return $res;
+ case AuthenticationResponse::FAIL;
+ $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
+ 'user' => $user->getName(),
+ ] );
+ $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
+ $session->remove( 'AuthManager::accountLinkState' );
+ return $res;
+ case AuthenticationResponse::REDIRECT;
+ case AuthenticationResponse::UI;
+ $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
+ 'user' => $user->getName(),
+ ] );
+ $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
+ $state['continueRequests'] = $res->neededRequests;
+ $session->setSecret( 'AuthManager::accountLinkState', $state );
+ return $res;
+ default:
+ throw new \DomainException(
+ get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status"
+ );
+ }
+ } catch ( \Exception $ex ) {
+ $session->remove( 'AuthManager::accountLinkState' );
+ throw $ex;
+ }
+ }
+
+ /**@}*/
+
+ /**
+ * @name Information methods
+ * @{
+ */
+
+ /**
+ * Return the applicable list of AuthenticationRequests
+ *
+ * Possible values for $action:
+ * - ACTION_LOGIN: Valid for passing to beginAuthentication
+ * - ACTION_LOGIN_CONTINUE: Valid for passing to continueAuthentication in the current state
+ * - ACTION_CREATE: Valid for passing to beginAccountCreation
+ * - ACTION_CREATE_CONTINUE: Valid for passing to continueAccountCreation in the current state
+ * - ACTION_LINK: Valid for passing to beginAccountLink
+ * - ACTION_LINK_CONTINUE: Valid for passing to continueAccountLink in the current state
+ * - ACTION_CHANGE: Valid for passing to changeAuthenticationData to change credentials
+ * - ACTION_REMOVE: Valid for passing to changeAuthenticationData to remove credentials.
+ * - ACTION_UNLINK: Same as ACTION_REMOVE, but limited to linked accounts.
+ *
+ * @param string $action One of the AuthManager::ACTION_* constants
+ * @param User|null $user User being acted on, instead of the current user.
+ * @return AuthenticationRequest[]
+ */
+ public function getAuthenticationRequests( $action, User $user = null ) {
+ $options = [];
+ $providerAction = $action;
+
+ // Figure out which providers to query
+ switch ( $action ) {
+ case self::ACTION_LOGIN:
+ case self::ACTION_CREATE:
+ $providers = $this->getPreAuthenticationProviders() +
+ $this->getPrimaryAuthenticationProviders() +
+ $this->getSecondaryAuthenticationProviders();
+ break;
+
+ case self::ACTION_LOGIN_CONTINUE:
+ $state = $this->request->getSession()->getSecret( 'AuthManager::authnState' );
+ return is_array( $state ) ? $state['continueRequests'] : [];
+
+ case self::ACTION_CREATE_CONTINUE:
+ $state = $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' );
+ return is_array( $state ) ? $state['continueRequests'] : [];
+
+ case self::ACTION_LINK:
+ $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) {
+ return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK;
+ } );
+ break;
+
+ case self::ACTION_UNLINK:
+ $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) {
+ return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK;
+ } );
+
+ // To providers, unlink and remove are identical.
+ $providerAction = self::ACTION_REMOVE;
+ break;
+
+ case self::ACTION_LINK_CONTINUE:
+ $state = $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' );
+ return is_array( $state ) ? $state['continueRequests'] : [];
+
+ case self::ACTION_CHANGE:
+ case self::ACTION_REMOVE:
+ $providers = $this->getPrimaryAuthenticationProviders() +
+ $this->getSecondaryAuthenticationProviders();
+ break;
+
+ // @codeCoverageIgnoreStart
+ default:
+ throw new \DomainException( __METHOD__ . ": Invalid action \"$action\"" );
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $this->getAuthenticationRequestsInternal( $providerAction, $options, $providers, $user );
+ }
+
+ /**
+ * Internal request lookup for self::getAuthenticationRequests
+ *
+ * @param string $providerAction Action to pass to providers
+ * @param array $options Options to pass to providers
+ * @param AuthenticationProvider[] $providers
+ * @param User|null $user
+ * @return AuthenticationRequest[]
+ */
+ private function getAuthenticationRequestsInternal(
+ $providerAction, array $options, array $providers, User $user = null
+ ) {
+ $user = $user ?: \RequestContext::getMain()->getUser();
+ $options['username'] = $user->isAnon() ? null : $user->getName();
+
+ // Query them and merge results
+ $reqs = [];
+ foreach ( $providers as $provider ) {
+ $isPrimary = $provider instanceof PrimaryAuthenticationProvider;
+ foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) {
+ $id = $req->getUniqueId();
+
+ // If a required request if from a Primary, mark it as "primary-required" instead
+ if ( $isPrimary ) {
+ if ( $req->required ) {
+ $req->required = AuthenticationRequest::PRIMARY_REQUIRED;
+ }
+ }
+
+ if (
+ !isset( $reqs[$id] )
+ || $req->required === AuthenticationRequest::REQUIRED
+ || $reqs[$id] === AuthenticationRequest::OPTIONAL
+ ) {
+ $reqs[$id] = $req;
+ }
+ }
+ }
+
+ // AuthManager has its own req for some actions
+ switch ( $providerAction ) {
+ case self::ACTION_LOGIN:
+ $reqs[] = new RememberMeAuthenticationRequest;
+ break;
+
+ case self::ACTION_CREATE:
+ $reqs[] = new UsernameAuthenticationRequest;
+ $reqs[] = new UserDataAuthenticationRequest;
+ if ( $options['username'] !== null ) {
+ $reqs[] = new CreationReasonAuthenticationRequest;
+ $options['username'] = null; // Don't fill in the username below
+ }
+ break;
+ }
+
+ // Fill in reqs data
+ $this->fillRequests( $reqs, $providerAction, $options['username'], true );
+
+ // For self::ACTION_CHANGE, filter out any that something else *doesn't* allow changing
+ if ( $providerAction === self::ACTION_CHANGE || $providerAction === self::ACTION_REMOVE ) {
+ $reqs = array_filter( $reqs, function ( $req ) {
+ return $this->allowsAuthenticationDataChange( $req, false )->isGood();
+ } );
+ }
+
+ return array_values( $reqs );
+ }
+
+ /**
+ * Set values in an array of requests
+ * @param AuthenticationRequest[] &$reqs
+ * @param string $action
+ * @param string|null $username
+ * @param bool $forceAction
+ */
+ private function fillRequests( array &$reqs, $action, $username, $forceAction = false ) {
+ foreach ( $reqs as $req ) {
+ if ( !$req->action || $forceAction ) {
+ $req->action = $action;
+ }
+ if ( $req->username === null ) {
+ $req->username = $username;
+ }
+ }
+ }
+
+ /**
+ * Determine whether a username exists
+ * @param string $username
+ * @param int $flags Bitfield of User:READ_* constants
+ * @return bool
+ */
+ public function userExists( $username, $flags = User::READ_NORMAL ) {
+ foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
+ if ( $provider->testUserExists( $username, $flags ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine whether a user property should be allowed to be changed.
+ *
+ * Supported properties are:
+ * - emailaddress
+ * - realname
+ * - nickname
+ *
+ * @param string $property
+ * @return bool
+ */
+ public function allowsPropertyChange( $property ) {
+ $providers = $this->getPrimaryAuthenticationProviders() +
+ $this->getSecondaryAuthenticationProviders();
+ foreach ( $providers as $provider ) {
+ if ( !$provider->providerAllowsPropertyChange( $property ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get a provider by ID
+ * @note This is public so extensions can check whether their own provider
+ * is installed and so they can read its configuration if necessary.
+ * Other uses are not recommended.
+ * @param string $id
+ * @return AuthenticationProvider|null
+ */
+ public function getAuthenticationProvider( $id ) {
+ // Fast version
+ if ( isset( $this->allAuthenticationProviders[$id] ) ) {
+ return $this->allAuthenticationProviders[$id];
+ }
+
+ // Slow version: instantiate each kind and check
+ $providers = $this->getPrimaryAuthenticationProviders();
+ if ( isset( $providers[$id] ) ) {
+ return $providers[$id];
+ }
+ $providers = $this->getSecondaryAuthenticationProviders();
+ if ( isset( $providers[$id] ) ) {
+ return $providers[$id];
+ }
+ $providers = $this->getPreAuthenticationProviders();
+ if ( isset( $providers[$id] ) ) {
+ return $providers[$id];
+ }
+
+ return null;
+ }
+
+ /**@}*/
+
+ /**
+ * @name Internal methods
+ * @{
+ */
+
+ /**
+ * Store authentication in the current session
+ * @protected For use by AuthenticationProviders
+ * @param string $key
+ * @param mixed $data Must be serializable
+ */
+ public function setAuthenticationSessionData( $key, $data ) {
+ $session = $this->request->getSession();
+ $arr = $session->getSecret( 'authData' );
+ if ( !is_array( $arr ) ) {
+ $arr = [];
+ }
+ $arr[$key] = $data;
+ $session->setSecret( 'authData', $arr );
+ }
+
+ /**
+ * Fetch authentication data from the current session
+ * @protected For use by AuthenticationProviders
+ * @param string $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function getAuthenticationSessionData( $key, $default = null ) {
+ $arr = $this->request->getSession()->getSecret( 'authData' );
+ if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
+ return $arr[$key];
+ } else {
+ return $default;
+ }
+ }
+
+ /**
+ * Remove authentication data
+ * @protected For use by AuthenticationProviders
+ * @param string|null $key If null, all data is removed
+ */
+ public function removeAuthenticationSessionData( $key ) {
+ $session = $this->request->getSession();
+ if ( $key === null ) {
+ $session->remove( 'authData' );
+ } else {
+ $arr = $session->getSecret( 'authData' );
+ if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
+ unset( $arr[$key] );
+ $session->setSecret( 'authData', $arr );
+ }
+ }
+ }
+
+ /**
+ * Create an array of AuthenticationProviders from an array of ObjectFactory specs
+ * @param string $class
+ * @param array[] $specs
+ * @return AuthenticationProvider[]
+ */
+ protected function providerArrayFromSpecs( $class, array $specs ) {
+ $i = 0;
+ foreach ( $specs as &$spec ) {
+ $spec = [ 'sort2' => $i++ ] + $spec + [ 'sort' => 0 ];
+ }
+ unset( $spec );
+ usort( $specs, function ( $a, $b ) {
+ return ( (int)$a['sort'] ) - ( (int)$b['sort'] )
+ ?: $a['sort2'] - $b['sort2'];
+ } );
+
+ $ret = [];
+ foreach ( $specs as $spec ) {
+ $provider = \ObjectFactory::getObjectFromSpec( $spec );
+ if ( !$provider instanceof $class ) {
+ throw new \RuntimeException(
+ "Expected instance of $class, got " . get_class( $provider )
+ );
+ }
+ $provider->setLogger( $this->logger );
+ $provider->setManager( $this );
+ $provider->setConfig( $this->config );
+ $id = $provider->getUniqueId();
+ if ( isset( $this->allAuthenticationProviders[$id] ) ) {
+ throw new \RuntimeException(
+ "Duplicate specifications for id $id (classes " .
+ get_class( $provider ) . ' and ' .
+ get_class( $this->allAuthenticationProviders[$id] ) . ')'
+ );
+ }
+ $this->allAuthenticationProviders[$id] = $provider;
+ $ret[$id] = $provider;
+ }
+ return $ret;
+ }
+
+ /**
+ * Get the configuration
+ * @return array
+ */
+ private function getConfiguration() {
+ return $this->config->get( 'AuthManagerConfig' ) ?: $this->config->get( 'AuthManagerAutoConfig' );
+ }
+
+ /**
+ * Get the list of PreAuthenticationProviders
+ * @return PreAuthenticationProvider[]
+ */
+ protected function getPreAuthenticationProviders() {
+ if ( $this->preAuthenticationProviders === null ) {
+ $conf = $this->getConfiguration();
+ $this->preAuthenticationProviders = $this->providerArrayFromSpecs(
+ PreAuthenticationProvider::class, $conf['preauth']
+ );
+ }
+ return $this->preAuthenticationProviders;
+ }
+
+ /**
+ * Get the list of PrimaryAuthenticationProviders
+ * @return PrimaryAuthenticationProvider[]
+ */
+ protected function getPrimaryAuthenticationProviders() {
+ if ( $this->primaryAuthenticationProviders === null ) {
+ $conf = $this->getConfiguration();
+ $this->primaryAuthenticationProviders = $this->providerArrayFromSpecs(
+ PrimaryAuthenticationProvider::class, $conf['primaryauth']
+ );
+ }
+ return $this->primaryAuthenticationProviders;
+ }
+
+ /**
+ * Get the list of SecondaryAuthenticationProviders
+ * @return SecondaryAuthenticationProvider[]
+ */
+ protected function getSecondaryAuthenticationProviders() {
+ if ( $this->secondaryAuthenticationProviders === null ) {
+ $conf = $this->getConfiguration();
+ $this->secondaryAuthenticationProviders = $this->providerArrayFromSpecs(
+ SecondaryAuthenticationProvider::class, $conf['secondaryauth']
+ );
+ }
+ return $this->secondaryAuthenticationProviders;
+ }
+
+ /**
+ * Log the user in
+ * @param User $user
+ * @param bool|null $remember
+ */
+ private function setSessionDataForUser( $user, $remember = null ) {
+ $session = $this->request->getSession();
+ $delay = $session->delaySave();
+
+ $session->resetId();
+ $session->resetAllTokens();
+ if ( $session->canSetUser() ) {
+ $session->setUser( $user );
+ }
+ if ( $remember !== null ) {
+ $session->setRememberUser( $remember );
+ }
+ $session->set( 'AuthManager:lastAuthId', $user->getId() );
+ $session->set( 'AuthManager:lastAuthTimestamp', time() );
+ $session->persist();
+
+ \Wikimedia\ScopedCallback::consume( $delay );
+
+ \Hooks::run( 'UserLoggedIn', [ $user ] );
+ }
+
+ /**
+ * @param User $user
+ * @param bool $useContextLang Use 'uselang' to set the user's language
+ */
+ private function setDefaultUserOptions( User $user, $useContextLang ) {
+ global $wgContLang;
+
+ $user->setToken();
+
+ $lang = $useContextLang ? \RequestContext::getMain()->getLanguage() : $wgContLang;
+ $user->setOption( 'language', $lang->getPreferredVariant() );
+
+ if ( $wgContLang->hasVariants() ) {
+ $user->setOption( 'variant', $wgContLang->getPreferredVariant() );
+ }
+ }
+
+ /**
+ * @param int $which Bitmask: 1 = pre, 2 = primary, 4 = secondary
+ * @param string $method
+ * @param array $args
+ */
+ private function callMethodOnProviders( $which, $method, array $args ) {
+ $providers = [];
+ if ( $which & 1 ) {
+ $providers += $this->getPreAuthenticationProviders();
+ }
+ if ( $which & 2 ) {
+ $providers += $this->getPrimaryAuthenticationProviders();
+ }
+ if ( $which & 4 ) {
+ $providers += $this->getSecondaryAuthenticationProviders();
+ }
+ foreach ( $providers as $provider ) {
+ call_user_func_array( [ $provider, $method ], $args );
+ }
+ }
+
+ /**
+ * Reset the internal caching for unit testing
+ * @protected Unit tests only
+ */
+ public static function resetCache() {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ // @codeCoverageIgnoreStart
+ throw new \MWException( __METHOD__ . ' may only be called from unit tests!' );
+ // @codeCoverageIgnoreEnd
+ }
+
+ self::$instance = null;
+ }
+
+ /**@}*/
+
+}
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ */
diff --git a/www/wiki/includes/auth/AuthManagerAuthPlugin.php b/www/wiki/includes/auth/AuthManagerAuthPlugin.php
new file mode 100644
index 00000000..88458582
--- /dev/null
+++ b/www/wiki/includes/auth/AuthManagerAuthPlugin.php
@@ -0,0 +1,229 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * Backwards-compatibility wrapper for AuthManager via $wgAuth
+ * @since 1.27
+ * @deprecated since 1.27
+ */
+class AuthManagerAuthPlugin extends \AuthPlugin {
+ /** @var string|null */
+ protected $domain = null;
+
+ /** @var \\Psr\\Log\\LoggerInterface */
+ protected $logger = null;
+
+ public function __construct() {
+ $this->logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' );
+ }
+
+ public function userExists( $name ) {
+ return AuthManager::singleton()->userExists( $name );
+ }
+
+ public function authenticate( $username, $password ) {
+ $data = [
+ 'username' => $username,
+ 'password' => $password,
+ ];
+ if ( $this->domain !== null && $this->domain !== '' ) {
+ $data['domain'] = $this->domain;
+ }
+ $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_LOGIN );
+ $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
+
+ $res = AuthManager::singleton()->beginAuthentication( $reqs, 'null:' );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS:
+ return true;
+ case AuthenticationResponse::FAIL:
+ // Hope it's not a PreAuthenticationProvider that failed...
+ $msg = $res->message instanceof \Message ? $res->message : new \Message( $res->message );
+ $this->logger->info( __METHOD__ . ': Authentication failed: ' . $msg->plain() );
+ return false;
+ default:
+ throw new \BadMethodCallException(
+ 'AuthManager does not support such simplified authentication'
+ );
+ }
+ }
+
+ public function modifyUITemplate( &$template, &$type ) {
+ // AuthManager does not support direct UI screwing-around-with
+ }
+
+ public function setDomain( $domain ) {
+ $this->domain = $domain;
+ }
+
+ public function getDomain() {
+ if ( isset( $this->domain ) ) {
+ return $this->domain;
+ } else {
+ return 'invaliddomain';
+ }
+ }
+
+ public function validDomain( $domain ) {
+ $domainList = $this->domainList();
+ return $domainList ? in_array( $domain, $domainList, true ) : $domain === '';
+ }
+
+ public function updateUser( &$user ) {
+ \Hooks::run( 'UserLoggedIn', [ $user ] );
+ return true;
+ }
+
+ public function autoCreate() {
+ return true;
+ }
+
+ public function allowPropChange( $prop = '' ) {
+ return AuthManager::singleton()->allowsPropertyChange( $prop );
+ }
+
+ public function allowPasswordChange() {
+ $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CHANGE );
+ foreach ( $reqs as $req ) {
+ if ( $req instanceof PasswordAuthenticationRequest ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function allowSetLocalPassword() {
+ // There should be a PrimaryAuthenticationProvider that does this, if necessary
+ return false;
+ }
+
+ public function setPassword( $user, $password ) {
+ $data = [
+ 'username' => $user->getName(),
+ 'password' => $password,
+ ];
+ if ( $this->domain !== null && $this->domain !== '' ) {
+ $data['domain'] = $this->domain;
+ }
+ $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CHANGE );
+ $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
+ foreach ( $reqs as $req ) {
+ $status = AuthManager::singleton()->allowsAuthenticationDataChange( $req );
+ if ( !$status->isGood() ) {
+ $this->logger->info( __METHOD__ . ': Password change rejected: {reason}', [
+ 'username' => $data['username'],
+ 'reason' => $status->getWikiText( null, null, 'en' ),
+ ] );
+ return false;
+ }
+ }
+ foreach ( $reqs as $req ) {
+ AuthManager::singleton()->changeAuthenticationData( $req );
+ }
+ return true;
+ }
+
+ public function updateExternalDB( $user ) {
+ // This fires the necessary hook
+ $user->saveSettings();
+ return true;
+ }
+
+ public function updateExternalDBGroups( $user, $addgroups, $delgroups = [] ) {
+ \Hooks::run( 'UserGroupsChanged', [ $user, $addgroups, $delgroups ] );
+ return true;
+ }
+
+ public function canCreateAccounts() {
+ return AuthManager::singleton()->canCreateAccounts();
+ }
+
+ public function addUser( $user, $password, $email = '', $realname = '' ) {
+ throw new \BadMethodCallException(
+ 'Creation of users via AuthPlugin is not supported with '
+ . 'AuthManager. Generally, user creation should be left to either '
+ . 'Special:CreateAccount, auto-creation when triggered by a '
+ . 'SessionProvider or PrimaryAuthenticationProvider, or '
+ . 'User::newSystemUser().'
+ );
+ }
+
+ public function strict() {
+ // There should be a PrimaryAuthenticationProvider that does this, if necessary
+ return true;
+ }
+
+ public function strictUserAuth( $username ) {
+ // There should be a PrimaryAuthenticationProvider that does this, if necessary
+ return true;
+ }
+
+ public function initUser( &$user, $autocreate = false ) {
+ \Hooks::run( 'LocalUserCreated', [ $user, $autocreate ] );
+ }
+
+ public function getCanonicalName( $username ) {
+ // AuthManager doesn't support restrictions beyond MediaWiki's
+ return $username;
+ }
+
+ public function getUserInstance( User &$user ) {
+ return new AuthManagerAuthPluginUser( $user );
+ }
+
+ public function domainList() {
+ return [];
+ }
+}
+
+/**
+ * @since 1.27
+ * @deprecated since 1.27
+ */
+class AuthManagerAuthPluginUser extends \AuthPluginUser {
+ /** @var User */
+ private $user;
+
+ function __construct( $user ) {
+ $this->user = $user;
+ }
+
+ public function getId() {
+ return $this->user->getId();
+ }
+
+ public function isLocked() {
+ return $this->user->isLocked();
+ }
+
+ public function isHidden() {
+ return $this->user->isHidden();
+ }
+
+ public function resetAuthToken() {
+ \MediaWiki\Session\SessionManager::singleton()->invalidateSessionsForUser( $this->user );
+ return true;
+ }
+}
diff --git a/www/wiki/includes/auth/AuthPluginPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/AuthPluginPrimaryAuthenticationProvider.php
new file mode 100644
index 00000000..b8e36bc4
--- /dev/null
+++ b/www/wiki/includes/auth/AuthPluginPrimaryAuthenticationProvider.php
@@ -0,0 +1,429 @@
+<?php
+/**
+ * Primary authentication provider wrapper for AuthPlugin
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use AuthPlugin;
+use User;
+
+/**
+ * Primary authentication provider wrapper for AuthPlugin
+ * @warning If anything depends on the wrapped AuthPlugin being $wgAuth, it won't work with this!
+ * @ingroup Auth
+ * @since 1.27
+ * @deprecated since 1.27
+ */
+class AuthPluginPrimaryAuthenticationProvider
+ extends AbstractPasswordPrimaryAuthenticationProvider
+{
+ private $auth;
+ private $hasDomain;
+ private $requestType = null;
+
+ /**
+ * @param AuthPlugin $auth AuthPlugin to wrap
+ * @param string|null $requestType Class name of the
+ * PasswordAuthenticationRequest to use. If $auth->domainList() returns
+ * more than one domain, this must be a PasswordDomainAuthenticationRequest.
+ */
+ public function __construct( AuthPlugin $auth, $requestType = null ) {
+ parent::__construct();
+
+ if ( $auth instanceof AuthManagerAuthPlugin ) {
+ throw new \InvalidArgumentException(
+ 'Trying to wrap AuthManagerAuthPlugin in AuthPluginPrimaryAuthenticationProvider ' .
+ 'makes no sense.'
+ );
+ }
+
+ $need = count( $auth->domainList() ) > 1
+ ? PasswordDomainAuthenticationRequest::class
+ : PasswordAuthenticationRequest::class;
+ if ( $requestType === null ) {
+ $requestType = $need;
+ } elseif ( $requestType !== $need && !is_subclass_of( $requestType, $need ) ) {
+ throw new \InvalidArgumentException( "$requestType is not a $need" );
+ }
+
+ $this->auth = $auth;
+ $this->requestType = $requestType;
+ $this->hasDomain = (
+ $requestType === PasswordDomainAuthenticationRequest::class ||
+ is_subclass_of( $requestType, PasswordDomainAuthenticationRequest::class )
+ );
+ $this->authoritative = $auth->strict();
+
+ // Registering hooks from core is unusual, but is needed here to be
+ // able to call the AuthPlugin methods those hooks replace.
+ \Hooks::register( 'UserSaveSettings', [ $this, 'onUserSaveSettings' ] );
+ \Hooks::register( 'UserGroupsChanged', [ $this, 'onUserGroupsChanged' ] );
+ \Hooks::register( 'UserLoggedIn', [ $this, 'onUserLoggedIn' ] );
+ \Hooks::register( 'LocalUserCreated', [ $this, 'onLocalUserCreated' ] );
+ }
+
+ /**
+ * Create an appropriate AuthenticationRequest
+ * @return PasswordAuthenticationRequest
+ */
+ protected function makeAuthReq() {
+ $class = $this->requestType;
+ if ( $this->hasDomain ) {
+ return new $class( $this->auth->domainList() );
+ } else {
+ return new $class();
+ }
+ }
+
+ /**
+ * Call $this->auth->setDomain()
+ * @param PasswordAuthenticationRequest $req
+ */
+ protected function setDomain( $req ) {
+ if ( $this->hasDomain ) {
+ $domain = $req->domain;
+ } else {
+ // Just grab the first one.
+ $domainList = $this->auth->domainList();
+ $domain = reset( $domainList );
+ }
+
+ // Special:UserLogin does this. Strange.
+ if ( !$this->auth->validDomain( $domain ) ) {
+ $domain = $this->auth->getDomain();
+ }
+ $this->auth->setDomain( $domain );
+ }
+
+ /**
+ * Hook function to call AuthPlugin::updateExternalDB()
+ * @param User $user
+ * @codeCoverageIgnore
+ */
+ public function onUserSaveSettings( $user ) {
+ // No way to know the domain, just hope the provider handles that.
+ $this->auth->updateExternalDB( $user );
+ }
+
+ /**
+ * Hook function to call AuthPlugin::updateExternalDBGroups()
+ * @param User $user
+ * @param array $added
+ * @param array $removed
+ */
+ public function onUserGroupsChanged( $user, $added, $removed ) {
+ // No way to know the domain, just hope the provider handles that.
+ $this->auth->updateExternalDBGroups( $user, $added, $removed );
+ }
+
+ /**
+ * Hook function to call AuthPlugin::updateUser()
+ * @param User $user
+ */
+ public function onUserLoggedIn( $user ) {
+ $hookUser = $user;
+ // No way to know the domain, just hope the provider handles that.
+ $this->auth->updateUser( $hookUser );
+ if ( $hookUser !== $user ) {
+ throw new \UnexpectedValueException(
+ get_class( $this->auth ) . '::updateUser() tried to replace $user!'
+ );
+ }
+ }
+
+ /**
+ * Hook function to call AuthPlugin::initUser()
+ * @param User $user
+ * @param bool $autocreated
+ */
+ public function onLocalUserCreated( $user, $autocreated ) {
+ // For $autocreated, see self::autoCreatedAccount()
+ if ( !$autocreated ) {
+ $hookUser = $user;
+ // No way to know the domain, just hope the provider handles that.
+ $this->auth->initUser( $hookUser, $autocreated );
+ if ( $hookUser !== $user ) {
+ throw new \UnexpectedValueException(
+ get_class( $this->auth ) . '::initUser() tried to replace $user!'
+ );
+ }
+ }
+ }
+
+ public function getUniqueId() {
+ return parent::getUniqueId() . ':' . get_class( $this->auth );
+ }
+
+ public function getAuthenticationRequests( $action, array $options ) {
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ case AuthManager::ACTION_CREATE:
+ return [ $this->makeAuthReq() ];
+
+ case AuthManager::ACTION_CHANGE:
+ case AuthManager::ACTION_REMOVE:
+ // No way to know the domain, just hope the provider handles that.
+ return $this->auth->allowPasswordChange() ? [ $this->makeAuthReq() ] : [];
+
+ default:
+ return [];
+ }
+ }
+
+ public function beginPrimaryAuthentication( array $reqs ) {
+ $req = AuthenticationRequest::getRequestByClass( $reqs, $this->requestType );
+ if ( !$req || $req->username === null || $req->password === null ||
+ ( $this->hasDomain && $req->domain === null )
+ ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $username = User::getCanonicalName( $req->username, 'usable' );
+ if ( $username === false ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $this->setDomain( $req );
+ if ( $this->testUserCanAuthenticateInternal( User::newFromName( $username ) ) &&
+ $this->auth->authenticate( $username, $req->password )
+ ) {
+ return AuthenticationResponse::newPass( $username );
+ } else {
+ $this->authoritative = $this->auth->strict() || $this->auth->strictUserAuth( $username );
+ return $this->failResponse( $req );
+ }
+ }
+
+ public function testUserCanAuthenticate( $username ) {
+ $username = User::getCanonicalName( $username, 'usable' );
+ if ( $username === false ) {
+ return false;
+ }
+
+ // We have to check every domain, because at least LdapAuthentication
+ // interprets AuthPlugin::userExists() as applying only to the current
+ // domain.
+ $curDomain = $this->auth->getDomain();
+ $domains = $this->auth->domainList() ?: [ '' ];
+ foreach ( $domains as $domain ) {
+ $this->auth->setDomain( $domain );
+ if ( $this->testUserCanAuthenticateInternal( User::newFromName( $username ) ) ) {
+ $this->auth->setDomain( $curDomain );
+ return true;
+ }
+ }
+ $this->auth->setDomain( $curDomain );
+ return false;
+ }
+
+ /**
+ * @see self::testUserCanAuthenticate
+ * @note The caller is responsible for calling $this->auth->setDomain()
+ * @param User $user
+ * @return bool
+ */
+ private function testUserCanAuthenticateInternal( $user ) {
+ if ( $this->auth->userExists( $user->getName() ) ) {
+ return !$this->auth->getUserInstance( $user )->isLocked();
+ } else {
+ return false;
+ }
+ }
+
+ public function providerRevokeAccessForUser( $username ) {
+ $username = User::getCanonicalName( $username, 'usable' );
+ if ( $username === false ) {
+ return;
+ }
+ $user = User::newFromName( $username );
+ if ( $user ) {
+ // Reset the password on every domain.
+ $curDomain = $this->auth->getDomain();
+ $domains = $this->auth->domainList() ?: [ '' ];
+ $failed = [];
+ foreach ( $domains as $domain ) {
+ $this->auth->setDomain( $domain );
+ if ( $this->testUserCanAuthenticateInternal( $user ) &&
+ !$this->auth->setPassword( $user, null )
+ ) {
+ $failed[] = $domain === '' ? '(default)' : $domain;
+ }
+ }
+ $this->auth->setDomain( $curDomain );
+ if ( $failed ) {
+ throw new \UnexpectedValueException(
+ "AuthPlugin failed to reset password for $username in the following domains: "
+ . join( ' ', $failed )
+ );
+ }
+ }
+ }
+
+ public function testUserExists( $username, $flags = User::READ_NORMAL ) {
+ $username = User::getCanonicalName( $username, 'usable' );
+ if ( $username === false ) {
+ return false;
+ }
+
+ // We have to check every domain, because at least LdapAuthentication
+ // interprets AuthPlugin::userExists() as applying only to the current
+ // domain.
+ $curDomain = $this->auth->getDomain();
+ $domains = $this->auth->domainList() ?: [ '' ];
+ foreach ( $domains as $domain ) {
+ $this->auth->setDomain( $domain );
+ if ( $this->auth->userExists( $username ) ) {
+ $this->auth->setDomain( $curDomain );
+ return true;
+ }
+ }
+ $this->auth->setDomain( $curDomain );
+ return false;
+ }
+
+ public function providerAllowsPropertyChange( $property ) {
+ // No way to know the domain, just hope the provider handles that.
+ return $this->auth->allowPropChange( $property );
+ }
+
+ public function providerAllowsAuthenticationDataChange(
+ AuthenticationRequest $req, $checkData = true
+ ) {
+ if ( get_class( $req ) !== $this->requestType ) {
+ return \StatusValue::newGood( 'ignored' );
+ }
+
+ // Hope it works, AuthPlugin gives us no way to do this.
+ $curDomain = $this->auth->getDomain();
+ $this->setDomain( $req );
+ try {
+ // If !$checkData the domain might be wrong. Nothing we can do about that.
+ if ( !$this->auth->allowPasswordChange() ) {
+ return \StatusValue::newFatal( 'authmanager-authplugin-setpass-denied' );
+ }
+
+ if ( !$checkData ) {
+ return \StatusValue::newGood();
+ }
+
+ if ( $this->hasDomain ) {
+ if ( $req->domain === null ) {
+ return \StatusValue::newGood( 'ignored' );
+ }
+ if ( !$this->auth->validDomain( $req->domain ) ) {
+ return \StatusValue::newFatal( 'authmanager-authplugin-setpass-bad-domain' );
+ }
+ }
+
+ $username = User::getCanonicalName( $req->username, 'usable' );
+ if ( $username !== false ) {
+ $sv = \StatusValue::newGood();
+ if ( $req->password !== null ) {
+ if ( $req->password !== $req->retype ) {
+ $sv->fatal( 'badretype' );
+ } else {
+ $sv->merge( $this->checkPasswordValidity( $username, $req->password ) );
+ }
+ }
+ return $sv;
+ } else {
+ return \StatusValue::newGood( 'ignored' );
+ }
+ } finally {
+ $this->auth->setDomain( $curDomain );
+ }
+ }
+
+ public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
+ if ( get_class( $req ) === $this->requestType ) {
+ $username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false;
+ if ( $username === false ) {
+ return;
+ }
+
+ if ( $this->hasDomain && $req->domain === null ) {
+ return;
+ }
+
+ $this->setDomain( $req );
+ $user = User::newFromName( $username );
+ if ( !$this->auth->setPassword( $user, $req->password ) ) {
+ // This is totally unfriendly and leaves other
+ // AuthenticationProviders in an uncertain state, but what else
+ // can we do?
+ throw new \ErrorPageError(
+ 'authmanager-authplugin-setpass-failed-title',
+ 'authmanager-authplugin-setpass-failed-message'
+ );
+ }
+ }
+ }
+
+ public function accountCreationType() {
+ // No way to know the domain, just hope the provider handles that.
+ return $this->auth->canCreateAccounts() ? self::TYPE_CREATE : self::TYPE_NONE;
+ }
+
+ public function testForAccountCreation( $user, $creator, array $reqs ) {
+ return \StatusValue::newGood();
+ }
+
+ public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
+ if ( $this->accountCreationType() === self::TYPE_NONE ) {
+ throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' );
+ }
+
+ $req = AuthenticationRequest::getRequestByClass( $reqs, $this->requestType );
+ if ( !$req || $req->username === null || $req->password === null ||
+ ( $this->hasDomain && $req->domain === null )
+ ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $username = User::getCanonicalName( $req->username, 'usable' );
+ if ( $username === false ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $this->setDomain( $req );
+ if ( $this->auth->addUser(
+ $user, $req->password, $user->getEmail(), $user->getRealName()
+ ) ) {
+ return AuthenticationResponse::newPass();
+ } else {
+ return AuthenticationResponse::newFail(
+ new \Message( 'authmanager-authplugin-create-fail' )
+ );
+ }
+ }
+
+ public function autoCreatedAccount( $user, $source ) {
+ $hookUser = $user;
+ // No way to know the domain, just hope the provider handles that.
+ $this->auth->initUser( $hookUser, true );
+ if ( $hookUser !== $user ) {
+ throw new \UnexpectedValueException(
+ get_class( $this->auth ) . '::initUser() tried to replace $user!'
+ );
+ }
+ }
+}
diff --git a/www/wiki/includes/auth/AuthenticationProvider.php b/www/wiki/includes/auth/AuthenticationProvider.php
new file mode 100644
index 00000000..11f3e226
--- /dev/null
+++ b/www/wiki/includes/auth/AuthenticationProvider.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Authentication provider interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Config;
+use Psr\Log\LoggerAwareInterface;
+
+/**
+ * An AuthenticationProvider is used by AuthManager when authenticating users.
+ *
+ * This interface should not be implemented directly; use one of its children.
+ *
+ * Authentication providers can be registered via $wgAuthManagerAutoConfig.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+interface AuthenticationProvider extends LoggerAwareInterface {
+
+ /**
+ * Set AuthManager
+ * @param AuthManager $manager
+ */
+ public function setManager( AuthManager $manager );
+
+ /**
+ * Set configuration
+ * @param Config $config
+ */
+ public function setConfig( Config $config );
+
+ /**
+ * Return a unique identifier for this instance
+ *
+ * This must be the same across requests. If multiple instances return the
+ * same ID, exceptions will be thrown from AuthManager.
+ *
+ * @return string
+ */
+ public function getUniqueId();
+
+ /**
+ * Return the applicable list of AuthenticationRequests
+ *
+ * Possible values for $action depend on whether the implementing class is
+ * also a PreAuthenticationProvider, PrimaryAuthenticationProvider, or
+ * SecondaryAuthenticationProvider.
+ * - ACTION_LOGIN: Valid for passing to beginAuthentication. Called on all
+ * providers.
+ * - ACTION_CREATE: Valid for passing to beginAccountCreation. Called on
+ * all providers.
+ * - ACTION_LINK: Valid for passing to beginAccountLink. Called on linking
+ * primary providers only.
+ * - ACTION_CHANGE: Valid for passing to AuthManager::changeAuthenticationData
+ * to change credentials. Called on primary and secondary providers.
+ * - ACTION_REMOVE: Valid for passing to AuthManager::changeAuthenticationData
+ * to remove credentials. Must work without additional user input (i.e.
+ * without calling loadFromSubmission). Called on primary and secondary
+ * providers.
+ *
+ * @see AuthManager::getAuthenticationRequests()
+ * @param string $action
+ * @param array $options Options are:
+ * - username: User name related to the action, or null/unset if anon.
+ * - ACTION_LOGIN: The currently logged-in user, if any.
+ * - ACTION_CREATE: The account creator, if non-anonymous.
+ * - ACTION_LINK: The local user being linked to.
+ * - ACTION_CHANGE: The user having data changed.
+ * - ACTION_REMOVE: The user having data removed.
+ * If you leave the username property of the returned requests empty, this
+ * will automatically be copied there (except for ACTION_CREATE where it
+ * wouldn't really make sense).
+ * @return AuthenticationRequest[]
+ */
+ public function getAuthenticationRequests( $action, array $options );
+
+}
diff --git a/www/wiki/includes/auth/AuthenticationRequest.php b/www/wiki/includes/auth/AuthenticationRequest.php
new file mode 100644
index 00000000..7fc362a2
--- /dev/null
+++ b/www/wiki/includes/auth/AuthenticationRequest.php
@@ -0,0 +1,379 @@
+<?php
+/**
+ * Authentication request value object
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Message;
+
+/**
+ * This is a value object for authentication requests.
+ *
+ * An AuthenticationRequest represents a set of form fields that are needed on
+ * and provided from a login, account creation, password change or similar form.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+abstract class AuthenticationRequest {
+
+ /** Indicates that the request is not required for authentication to proceed. */
+ const OPTIONAL = 0;
+
+ /** Indicates that the request is required for authentication to proceed.
+ * This will only be used for UI purposes; it is the authentication providers'
+ * responsibility to verify that all required requests are present.
+ */
+ const REQUIRED = 1;
+
+ /** Indicates that the request is required by a primary authentication
+ * provider. Since the user can choose which primary to authenticate with,
+ * the request might or might not end up being actually required. */
+ const PRIMARY_REQUIRED = 2;
+
+ /** @var string|null The AuthManager::ACTION_* constant this request was
+ * created to be used for. The *_CONTINUE constants are not used here, the
+ * corresponding "begin" constant is used instead.
+ */
+ public $action = null;
+
+ /** @var int For login, continue, and link actions, one of self::OPTIONAL,
+ * self::REQUIRED, or self::PRIMARY_REQUIRED */
+ public $required = self::REQUIRED;
+
+ /** @var string|null Return-to URL, in case of redirect */
+ public $returnToUrl = null;
+
+ /** @var string|null Username. See AuthenticationProvider::getAuthenticationRequests()
+ * for details of what this means and how it behaves. */
+ public $username = null;
+
+ /**
+ * Supply a unique key for deduplication
+ *
+ * When the AuthenticationRequests instances returned by the providers are
+ * merged, the value returned here is used for keeping only one copy of
+ * duplicate requests.
+ *
+ * Subclasses should override this if multiple distinct instances would
+ * make sense, i.e. the request class has internal state of some sort.
+ *
+ * This value might be exposed to the user in web forms so it should not
+ * contain private information.
+ *
+ * @return string
+ */
+ public function getUniqueId() {
+ return get_called_class();
+ }
+
+ /**
+ * Fetch input field info
+ *
+ * The field info is an associative array mapping field names to info
+ * arrays. The info arrays have the following keys:
+ * - type: (string) Type of input. Types and equivalent HTML widgets are:
+ * - string: <input type="text">
+ * - password: <input type="password">
+ * - select: <select>
+ * - checkbox: <input type="checkbox">
+ * - multiselect: More a grid of checkboxes than <select multi>
+ * - button: <input type="submit"> (uses 'label' as button text)
+ * - hidden: Not visible to the user, but needs to be preserved for the next request
+ * - null: No widget, just display the 'label' message.
+ * - options: (array) Maps option values to Messages for the
+ * 'select' and 'multiselect' types.
+ * - value: (string) Value (for 'null' and 'hidden') or default value (for other types).
+ * - label: (Message) Text suitable for a label in an HTML form
+ * - help: (Message) Text suitable as a description of what the field is
+ * - optional: (bool) If set and truthy, the field may be left empty
+ * - sensitive: (bool) If set and truthy, the field is considered sensitive. Code using the
+ * request should avoid exposing the value of the field.
+ * - skippable: (bool) If set and truthy, the client is free to hide this
+ * field from the user to streamline the workflow. If all fields are
+ * skippable (except possibly a single button), no user interaction is
+ * required at all.
+ *
+ * All AuthenticationRequests are populated from the same data, so most of the time you'll
+ * want to prefix fields names with something unique to the extension/provider (although
+ * in some cases sharing the field with other requests is the right thing to do, e.g. for
+ * a 'password' field).
+ *
+ * @return array As above
+ */
+ abstract public function getFieldInfo();
+
+ /**
+ * Returns metadata about this request.
+ *
+ * This is mainly for the benefit of API clients which need more detailed render hints
+ * than what's available through getFieldInfo(). Semantics are unspecified and left to the
+ * individual subclasses, but the contents of the array should be primitive types so that they
+ * can be transformed into JSON or similar formats.
+ *
+ * @return array A (possibly nested) array with primitive types
+ */
+ public function getMetadata() {
+ return [];
+ }
+
+ /**
+ * Initialize form submitted form data.
+ *
+ * The default behavior is to to check for each key of self::getFieldInfo()
+ * in the submitted data, and copy the value - after type-appropriate transformations -
+ * to $this->$key. Most subclasses won't need to override this; if you do override it,
+ * make sure to always return false if self::getFieldInfo() returns an empty array.
+ *
+ * @param array $data Submitted data as an associative array (keys will correspond
+ * to getFieldInfo())
+ * @return bool Whether the request data was successfully loaded
+ */
+ public function loadFromSubmission( array $data ) {
+ $fields = array_filter( $this->getFieldInfo(), function ( $info ) {
+ return $info['type'] !== 'null';
+ } );
+ if ( !$fields ) {
+ return false;
+ }
+
+ foreach ( $fields as $field => $info ) {
+ // Checkboxes and buttons are special. Depending on the method used
+ // to populate $data, they might be unset meaning false or they
+ // might be boolean. Further, image buttons might submit the
+ // coordinates of the click rather than the expected value.
+ if ( $info['type'] === 'checkbox' || $info['type'] === 'button' ) {
+ $this->$field = isset( $data[$field] ) && $data[$field] !== false
+ || isset( $data["{$field}_x"] ) && $data["{$field}_x"] !== false;
+ if ( !$this->$field && empty( $info['optional'] ) ) {
+ return false;
+ }
+ continue;
+ }
+
+ // Multiselect are too, slightly
+ if ( !isset( $data[$field] ) && $info['type'] === 'multiselect' ) {
+ $data[$field] = [];
+ }
+
+ if ( !isset( $data[$field] ) ) {
+ return false;
+ }
+ if ( $data[$field] === '' || $data[$field] === [] ) {
+ if ( empty( $info['optional'] ) ) {
+ return false;
+ }
+ } else {
+ switch ( $info['type'] ) {
+ case 'select':
+ if ( !isset( $info['options'][$data[$field]] ) ) {
+ return false;
+ }
+ break;
+
+ case 'multiselect':
+ $data[$field] = (array)$data[$field];
+ $allowed = array_keys( $info['options'] );
+ if ( array_diff( $data[$field], $allowed ) !== [] ) {
+ return false;
+ }
+ break;
+ }
+ }
+
+ $this->$field = $data[$field];
+ }
+
+ return true;
+ }
+
+ /**
+ * Describe the credentials represented by this request
+ *
+ * This is used on requests returned by
+ * AuthenticationProvider::getAuthenticationRequests() for ACTION_LINK
+ * and ACTION_REMOVE and for requests returned in
+ * AuthenticationResponse::$linkRequest to create useful user interfaces.
+ *
+ * @return Message[] with the following keys:
+ * - provider: A Message identifying the service that provides
+ * the credentials, e.g. the name of the third party authentication
+ * service.
+ * - account: A Message identifying the credentials themselves,
+ * e.g. the email address used with the third party authentication
+ * service.
+ */
+ public function describeCredentials() {
+ return [
+ 'provider' => new \RawMessage( '$1', [ get_called_class() ] ),
+ 'account' => new \RawMessage( '$1', [ $this->getUniqueId() ] ),
+ ];
+ }
+
+ /**
+ * Update a set of requests with form submit data, discarding ones that fail
+ * @param AuthenticationRequest[] $reqs
+ * @param array $data
+ * @return AuthenticationRequest[]
+ */
+ public static function loadRequestsFromSubmission( array $reqs, array $data ) {
+ return array_values( array_filter( $reqs, function ( $req ) use ( $data ) {
+ return $req->loadFromSubmission( $data );
+ } ) );
+ }
+
+ /**
+ * Select a request by class name.
+ * @param AuthenticationRequest[] $reqs
+ * @param string $class Class name
+ * @param bool $allowSubclasses If true, also returns any request that's a subclass of the given
+ * class.
+ * @return AuthenticationRequest|null Returns null if there is not exactly
+ * one matching request.
+ */
+ public static function getRequestByClass( array $reqs, $class, $allowSubclasses = false ) {
+ $requests = array_filter( $reqs, function ( $req ) use ( $class, $allowSubclasses ) {
+ if ( $allowSubclasses ) {
+ return is_a( $req, $class, false );
+ } else {
+ return get_class( $req ) === $class;
+ }
+ } );
+ return count( $requests ) === 1 ? reset( $requests ) : null;
+ }
+
+ /**
+ * Get the username from the set of requests
+ *
+ * Only considers requests that have a "username" field.
+ *
+ * @param AuthenticationRequest[] $reqs
+ * @return string|null
+ * @throws \UnexpectedValueException If multiple different usernames are present.
+ */
+ public static function getUsernameFromRequests( array $reqs ) {
+ $username = null;
+ $otherClass = null;
+ foreach ( $reqs as $req ) {
+ $info = $req->getFieldInfo();
+ if ( $info && array_key_exists( 'username', $info ) && $req->username !== null ) {
+ if ( $username === null ) {
+ $username = $req->username;
+ $otherClass = get_class( $req );
+ } elseif ( $username !== $req->username ) {
+ $requestClass = get_class( $req );
+ throw new \UnexpectedValueException( "Conflicting username fields: \"{$req->username}\" from "
+ . "$requestClass::\$username vs. \"$username\" from $otherClass::\$username" );
+ }
+ }
+ }
+ return $username;
+ }
+
+ /**
+ * Merge the output of multiple AuthenticationRequest::getFieldInfo() calls.
+ * @param AuthenticationRequest[] $reqs
+ * @return array
+ * @throws \UnexpectedValueException If fields cannot be merged
+ */
+ public static function mergeFieldInfo( array $reqs ) {
+ $merged = [];
+
+ // fields that are required by some primary providers but not others are not actually required
+ $primaryRequests = array_filter( $reqs, function ( $req ) {
+ return $req->required === AuthenticationRequest::PRIMARY_REQUIRED;
+ } );
+ $sharedRequiredPrimaryFields = array_reduce( $primaryRequests, function ( $shared, $req ) {
+ $required = array_keys( array_filter( $req->getFieldInfo(), function ( $options ) {
+ return empty( $options['optional'] );
+ } ) );
+ if ( $shared === null ) {
+ return $required;
+ } else {
+ return array_intersect( $shared, $required );
+ }
+ }, null );
+
+ foreach ( $reqs as $req ) {
+ $info = $req->getFieldInfo();
+ if ( !$info ) {
+ continue;
+ }
+
+ foreach ( $info as $name => $options ) {
+ if (
+ // If the request isn't required, its fields aren't required either.
+ $req->required === self::OPTIONAL
+ // If there is a primary not requiring this field, no matter how many others do,
+ // authentication can proceed without it.
+ || $req->required === self::PRIMARY_REQUIRED
+ && !in_array( $name, $sharedRequiredPrimaryFields, true )
+ ) {
+ $options['optional'] = true;
+ } else {
+ $options['optional'] = !empty( $options['optional'] );
+ }
+
+ $options['sensitive'] = !empty( $options['sensitive'] );
+
+ if ( !array_key_exists( $name, $merged ) ) {
+ $merged[$name] = $options;
+ } elseif ( $merged[$name]['type'] !== $options['type'] ) {
+ throw new \UnexpectedValueException( "Field type conflict for \"$name\", " .
+ "\"{$merged[$name]['type']}\" vs \"{$options['type']}\""
+ );
+ } else {
+ if ( isset( $options['options'] ) ) {
+ if ( isset( $merged[$name]['options'] ) ) {
+ $merged[$name]['options'] += $options['options'];
+ } else {
+ // @codeCoverageIgnoreStart
+ $merged[$name]['options'] = $options['options'];
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ $merged[$name]['optional'] = $merged[$name]['optional'] && $options['optional'];
+ $merged[$name]['sensitive'] = $merged[$name]['sensitive'] || $options['sensitive'];
+
+ // No way to merge 'value', 'image', 'help', or 'label', so just use
+ // the value from the first request.
+ }
+ }
+ }
+
+ return $merged;
+ }
+
+ /**
+ * Implementing this mainly for use from the unit tests.
+ * @param array $data
+ * @return AuthenticationRequest
+ */
+ public static function __set_state( $data ) {
+ $ret = new static();
+ foreach ( $data as $k => $v ) {
+ $ret->$k = $v;
+ }
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/auth/AuthenticationResponse.php b/www/wiki/includes/auth/AuthenticationResponse.php
new file mode 100644
index 00000000..956c9850
--- /dev/null
+++ b/www/wiki/includes/auth/AuthenticationResponse.php
@@ -0,0 +1,219 @@
+<?php
+/**
+ * Authentication response value object
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Message;
+
+/**
+ * This is a value object to hold authentication response data
+ *
+ * An AuthenticationResponse represents both the status of the authentication
+ * (success, failure, in progress) and it its state (what data is needed to continue).
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class AuthenticationResponse {
+ /** Indicates that the authentication succeeded. */
+ const PASS = 'PASS';
+
+ /** Indicates that the authentication failed. */
+ const FAIL = 'FAIL';
+
+ /** Indicates that third-party authentication succeeded but no user exists.
+ * Either treat this like a UI response or pass $this->createRequest to
+ * AuthManager::beginCreateAccount(). For use by AuthManager only (providers
+ * should just return a PASS with no username).
+ */
+ const RESTART = 'RESTART';
+
+ /** Indicates that the authentication provider does not handle this request. */
+ const ABSTAIN = 'ABSTAIN';
+
+ /** Indicates that the authentication needs further user input of some sort. */
+ const UI = 'UI';
+
+ /** Indicates that the authentication needs to be redirected to a third party to proceed. */
+ const REDIRECT = 'REDIRECT';
+
+ /** @var string One of the constants above */
+ public $status;
+
+ /** @var string|null URL to redirect to for a REDIRECT response */
+ public $redirectTarget = null;
+
+ /**
+ * @var mixed Data for a REDIRECT response that a client might use to
+ * query the remote site via its API rather than by following $redirectTarget.
+ * Value must be something acceptable to ApiResult::addValue().
+ */
+ public $redirectApiData = null;
+
+ /**
+ * @var AuthenticationRequest[] Needed AuthenticationRequests to continue
+ * after a UI or REDIRECT response. This plays the same role when continuing
+ * authentication as AuthManager::getAuthenticationRequests() does when
+ * beginning it.
+ */
+ public $neededRequests = [];
+
+ /** @var Message|null I18n message to display in case of UI or FAIL */
+ public $message = null;
+
+ /** @var string Whether the $message is an error or warning message, for styling reasons */
+ public $messageType = 'warning';
+
+ /**
+ * @var string|null Local user name from authentication.
+ * May be null if the authentication passed but no local user is known.
+ */
+ public $username = null;
+
+ /**
+ * @var AuthenticationRequest|null
+ *
+ * Returned with a PrimaryAuthenticationProvider login FAIL or a PASS with
+ * no username, this can be set to a request that should result in a PASS when
+ * passed to that provider's PrimaryAuthenticationProvider::beginPrimaryAccountCreation().
+ * The client will be able to send that back for expedited account creation where only
+ * the username needs to be filled.
+ *
+ * Returned with an AuthManager login FAIL or RESTART, this holds a
+ * CreateFromLoginAuthenticationRequest that may be passed to
+ * AuthManager::beginCreateAccount(), possibly in place of any
+ * "primary-required" requests. It may also be passed to
+ * AuthManager::beginAuthentication() to preserve the list of
+ * accounts which can be linked after success (see $linkRequest).
+ */
+ public $createRequest = null;
+
+ /**
+ * @var AuthenticationRequest|null When returned with a PrimaryAuthenticationProvider
+ * login PASS with no username, the request this holds will be passed to
+ * AuthManager::changeAuthenticationData() once the local user has been determined and the
+ * user has confirmed the account ownership (by reviewing the information given by
+ * $linkRequest->describeCredentials()). The provider should handle that
+ * changeAuthenticationData() call by doing the actual linking.
+ */
+ public $linkRequest = null;
+
+ /**
+ * @var AuthenticationRequest|null Returned with an AuthManager account
+ * creation PASS, this holds a request to pass to AuthManager::beginAuthentication()
+ * to immediately log into the created account. All provider methods except
+ * postAuthentication will be skipped.
+ */
+ public $loginRequest = null;
+
+ /**
+ * @param string|null $username Local username
+ * @return AuthenticationResponse
+ * @see AuthenticationResponse::PASS
+ */
+ public static function newPass( $username = null ) {
+ $ret = new AuthenticationResponse;
+ $ret->status = self::PASS;
+ $ret->username = $username;
+ return $ret;
+ }
+
+ /**
+ * @param Message $msg
+ * @return AuthenticationResponse
+ * @see AuthenticationResponse::FAIL
+ */
+ public static function newFail( Message $msg ) {
+ $ret = new AuthenticationResponse;
+ $ret->status = self::FAIL;
+ $ret->message = $msg;
+ $ret->messageType = 'error';
+ return $ret;
+ }
+
+ /**
+ * @param Message $msg
+ * @return AuthenticationResponse
+ * @see AuthenticationResponse::RESTART
+ */
+ public static function newRestart( Message $msg ) {
+ $ret = new AuthenticationResponse;
+ $ret->status = self::RESTART;
+ $ret->message = $msg;
+ return $ret;
+ }
+
+ /**
+ * @return AuthenticationResponse
+ * @see AuthenticationResponse::ABSTAIN
+ */
+ public static function newAbstain() {
+ $ret = new AuthenticationResponse;
+ $ret->status = self::ABSTAIN;
+ return $ret;
+ }
+
+ /**
+ * @param AuthenticationRequest[] $reqs AuthenticationRequests needed to continue
+ * @param Message $msg
+ * @param string $msgtype
+ * @return AuthenticationResponse
+ * @see AuthenticationResponse::UI
+ */
+ public static function newUI( array $reqs, Message $msg, $msgtype = 'warning' ) {
+ if ( !$reqs ) {
+ throw new \InvalidArgumentException( '$reqs may not be empty' );
+ }
+ if ( $msgtype !== 'warning' && $msgtype !== 'error' ) {
+ throw new \InvalidArgumentException( $msgtype . ' is not a valid message type.' );
+ }
+
+ $ret = new AuthenticationResponse;
+ $ret->status = self::UI;
+ $ret->neededRequests = $reqs;
+ $ret->message = $msg;
+ $ret->messageType = $msgtype;
+ return $ret;
+ }
+
+ /**
+ * @param AuthenticationRequest[] $reqs AuthenticationRequests needed to continue
+ * @param string $redirectTarget URL
+ * @param mixed $redirectApiData Data suitable for adding to an ApiResult
+ * @return AuthenticationResponse
+ * @see AuthenticationResponse::REDIRECT
+ */
+ public static function newRedirect( array $reqs, $redirectTarget, $redirectApiData = null ) {
+ if ( !$reqs ) {
+ throw new \InvalidArgumentException( '$reqs may not be empty' );
+ }
+
+ $ret = new AuthenticationResponse;
+ $ret->status = self::REDIRECT;
+ $ret->neededRequests = $reqs;
+ $ret->redirectTarget = $redirectTarget;
+ $ret->redirectApiData = $redirectApiData;
+ return $ret;
+ }
+
+}
diff --git a/www/wiki/includes/auth/ButtonAuthenticationRequest.php b/www/wiki/includes/auth/ButtonAuthenticationRequest.php
new file mode 100644
index 00000000..d274e18f
--- /dev/null
+++ b/www/wiki/includes/auth/ButtonAuthenticationRequest.php
@@ -0,0 +1,108 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Message;
+
+/**
+ * This is an authentication request that just implements a simple button.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class ButtonAuthenticationRequest extends AuthenticationRequest {
+ /** @var string */
+ protected $name;
+
+ /** @var Message */
+ protected $label;
+
+ /** @var Message */
+ protected $help;
+
+ /**
+ * @param string $name Button name
+ * @param Message $label Button label
+ * @param Message $help Button help
+ * @param bool $required The button is required for authentication to proceed.
+ */
+ public function __construct( $name, Message $label, Message $help, $required = false ) {
+ $this->name = $name;
+ $this->label = $label;
+ $this->help = $help;
+ $this->required = $required ? self::REQUIRED : self::OPTIONAL;
+ }
+
+ public function getUniqueId() {
+ return parent::getUniqueId() . ':' . $this->name;
+ }
+
+ public function getFieldInfo() {
+ return [
+ $this->name => [
+ 'type' => 'button',
+ 'label' => $this->label,
+ 'help' => $this->help,
+ ]
+ ];
+ }
+
+ /**
+ * Fetch a ButtonAuthenticationRequest or subclass by name
+ * @param AuthenticationRequest[] $reqs Requests to search
+ * @param string $name Name to look for
+ * @return ButtonAuthenticationRequest|null Returns null if there is not
+ * exactly one matching request.
+ */
+ public static function getRequestByName( array $reqs, $name ) {
+ $requests = array_filter( $reqs, function ( $req ) use ( $name ) {
+ return $req instanceof ButtonAuthenticationRequest && $req->name === $name;
+ } );
+ return count( $requests ) === 1 ? reset( $requests ) : null;
+ }
+
+ /**
+ * @codeCoverageIgnore
+ * @param array $data
+ * @return AuthenticationRequest|static
+ */
+ public static function __set_state( $data ) {
+ if ( !isset( $data['label'] ) ) {
+ $data['label'] = new \RawMessage( '$1', $data['name'] );
+ } elseif ( is_string( $data['label'] ) ) {
+ $data['label'] = new \Message( $data['label'] );
+ } elseif ( is_array( $data['label'] ) ) {
+ $data['label'] = call_user_func_array( 'Message::newFromKey', $data['label'] );
+ }
+ if ( !isset( $data['help'] ) ) {
+ $data['help'] = new \RawMessage( '$1', $data['name'] );
+ } elseif ( is_string( $data['help'] ) ) {
+ $data['help'] = new \Message( $data['help'] );
+ } elseif ( is_array( $data['help'] ) ) {
+ $data['help'] = call_user_func_array( 'Message::newFromKey', $data['help'] );
+ }
+ $ret = new static( $data['name'], $data['label'], $data['help'] );
+ foreach ( $data as $k => $v ) {
+ $ret->$k = $v;
+ }
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php
new file mode 100644
index 00000000..f7a7ec19
--- /dev/null
+++ b/www/wiki/includes/auth/CheckBlocksSecondaryAuthenticationProvider.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use Config;
+use StatusValue;
+
+/**
+ * Check if the user is blocked, and prevent authentication if so.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class CheckBlocksSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider {
+
+ /** @var bool */
+ protected $blockDisablesLogin = null;
+
+ /**
+ * @param array $params
+ * - blockDisablesLogin: (bool) Whether blocked accounts can log in,
+ * defaults to $wgBlockDisablesLogin
+ */
+ public function __construct( $params = [] ) {
+ if ( isset( $params['blockDisablesLogin'] ) ) {
+ $this->blockDisablesLogin = (bool)$params['blockDisablesLogin'];
+ }
+ }
+
+ public function setConfig( Config $config ) {
+ parent::setConfig( $config );
+
+ if ( $this->blockDisablesLogin === null ) {
+ $this->blockDisablesLogin = $this->config->get( 'BlockDisablesLogin' );
+ }
+ }
+
+ public function getAuthenticationRequests( $action, array $options ) {
+ return [];
+ }
+
+ public function beginSecondaryAuthentication( $user, array $reqs ) {
+ if ( !$this->blockDisablesLogin ) {
+ return AuthenticationResponse::newAbstain();
+ } elseif ( $user->isBlocked() ) {
+ return AuthenticationResponse::newFail(
+ new \Message( 'login-userblocked', [ $user->getName() ] )
+ );
+ } else {
+ return AuthenticationResponse::newPass();
+ }
+ }
+
+ public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ public function testUserForCreation( $user, $autocreate, array $options = [] ) {
+ $block = $user->isBlockedFromCreateAccount();
+ if ( $block ) {
+ $errorParams = [
+ $block->getTarget(),
+ $block->mReason ?: \Message::newFromKey( 'blockednoreason' )->text(),
+ $block->getByName()
+ ];
+
+ if ( $block->getType() === \Block::TYPE_RANGE ) {
+ $errorMessage = 'cantcreateaccount-range-text';
+ $errorParams[] = $this->manager->getRequest()->getIP();
+ } else {
+ $errorMessage = 'cantcreateaccount-text';
+ }
+
+ return StatusValue::newFatal(
+ new \Message( $errorMessage, $errorParams )
+ );
+ } else {
+ return StatusValue::newGood();
+ }
+ }
+
+}
diff --git a/www/wiki/includes/auth/ConfirmLinkAuthenticationRequest.php b/www/wiki/includes/auth/ConfirmLinkAuthenticationRequest.php
new file mode 100644
index 00000000..b82914f5
--- /dev/null
+++ b/www/wiki/includes/auth/ConfirmLinkAuthenticationRequest.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+class ConfirmLinkAuthenticationRequest extends AuthenticationRequest {
+ /** @var AuthenticationRequest[] */
+ protected $linkRequests;
+
+ /** @var string[] List of unique IDs of the confirmed accounts. */
+ public $confirmedLinkIDs = [];
+
+ /**
+ * @param AuthenticationRequest[] $linkRequests A list of autolink requests
+ * which need to be confirmed.
+ */
+ public function __construct( array $linkRequests ) {
+ if ( !$linkRequests ) {
+ throw new \InvalidArgumentException( '$linkRequests must not be empty' );
+ }
+ $this->linkRequests = $linkRequests;
+ }
+
+ public function getFieldInfo() {
+ $options = [];
+ foreach ( $this->linkRequests as $req ) {
+ $description = $req->describeCredentials();
+ $options[$req->getUniqueId()] = wfMessage(
+ 'authprovider-confirmlink-option',
+ $description['provider']->text(), $description['account']->text()
+ );
+ }
+ return [
+ 'confirmedLinkIDs' => [
+ 'type' => 'multiselect',
+ 'options' => $options,
+ 'label' => wfMessage( 'authprovider-confirmlink-request-label' ),
+ 'help' => wfMessage( 'authprovider-confirmlink-request-help' ),
+ 'optional' => true,
+ ]
+ ];
+ }
+
+ public function getUniqueId() {
+ return parent::getUniqueId() . ':' . implode( '|', array_map( function ( $req ) {
+ return $req->getUniqueId();
+ }, $this->linkRequests ) );
+ }
+
+ /**
+ * Implementing this mainly for use from the unit tests.
+ * @param array $data
+ * @return AuthenticationRequest
+ */
+ public static function __set_state( $data ) {
+ $ret = new static( $data['linkRequests'] );
+ foreach ( $data as $k => $v ) {
+ $ret->$k = $v;
+ }
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php
new file mode 100644
index 00000000..7f121cde
--- /dev/null
+++ b/www/wiki/includes/auth/ConfirmLinkSecondaryAuthenticationProvider.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * Links third-party authentication to the user's account
+ *
+ * If the user logged into linking provider accounts that aren't linked to a
+ * local user, this provider will prompt the user to link them after a
+ * successful login or account creation.
+ *
+ * To avoid confusing behavior, this provider should be later in the
+ * configuration list than any provider that can abort the authentication
+ * process, so that it is only invoked for successful authentication.
+ */
+class ConfirmLinkSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider {
+
+ public function getAuthenticationRequests( $action, array $options ) {
+ return [];
+ }
+
+ public function beginSecondaryAuthentication( $user, array $reqs ) {
+ return $this->beginLinkAttempt( $user, 'AuthManager::authnState' );
+ }
+
+ public function continueSecondaryAuthentication( $user, array $reqs ) {
+ return $this->continueLinkAttempt( $user, 'AuthManager::authnState', $reqs );
+ }
+
+ public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) {
+ return $this->beginLinkAttempt( $user, 'AuthManager::accountCreationState' );
+ }
+
+ public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) {
+ return $this->continueLinkAttempt( $user, 'AuthManager::accountCreationState', $reqs );
+ }
+
+ /**
+ * Begin the link attempt
+ * @param User $user
+ * @param string $key Session key to look in
+ * @return AuthenticationResponse
+ */
+ protected function beginLinkAttempt( $user, $key ) {
+ $session = $this->manager->getRequest()->getSession();
+ $state = $session->getSecret( $key );
+ if ( !is_array( $state ) ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $maybeLink = array_filter( $state['maybeLink'], function ( $req ) use ( $user ) {
+ if ( !$req->action ) {
+ $req->action = AuthManager::ACTION_CHANGE;
+ }
+ $req->username = $user->getName();
+ return $this->manager->allowsAuthenticationDataChange( $req )->isGood();
+ } );
+ if ( !$maybeLink ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $req = new ConfirmLinkAuthenticationRequest( $maybeLink );
+ return AuthenticationResponse::newUI(
+ [ $req ],
+ wfMessage( 'authprovider-confirmlink-message' ),
+ 'warning'
+ );
+ }
+
+ /**
+ * Continue the link attempt
+ * @param User $user
+ * @param string $key Session key to look in
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse
+ */
+ protected function continueLinkAttempt( $user, $key, array $reqs ) {
+ $req = ButtonAuthenticationRequest::getRequestByName( $reqs, 'linkOk' );
+ if ( $req ) {
+ return AuthenticationResponse::newPass();
+ }
+
+ $req = AuthenticationRequest::getRequestByClass( $reqs, ConfirmLinkAuthenticationRequest::class );
+ if ( !$req ) {
+ // WTF? Retry.
+ return $this->beginLinkAttempt( $user, $key );
+ }
+
+ $session = $this->manager->getRequest()->getSession();
+ $state = $session->getSecret( $key );
+ if ( !is_array( $state ) ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $maybeLink = [];
+ foreach ( $state['maybeLink'] as $linkReq ) {
+ $maybeLink[$linkReq->getUniqueId()] = $linkReq;
+ }
+ if ( !$maybeLink ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $state['maybeLink'] = [];
+ $session->setSecret( $key, $state );
+
+ $statuses = [];
+ $anyFailed = false;
+ foreach ( $req->confirmedLinkIDs as $id ) {
+ if ( isset( $maybeLink[$id] ) ) {
+ $req = $maybeLink[$id];
+ $req->username = $user->getName();
+ if ( !$req->action ) {
+ // Make sure the action is set, but don't override it if
+ // the provider filled it in.
+ $req->action = AuthManager::ACTION_CHANGE;
+ }
+ $status = $this->manager->allowsAuthenticationDataChange( $req );
+ $statuses[] = [ $req, $status ];
+ if ( $status->isGood() ) {
+ $this->manager->changeAuthenticationData( $req );
+ } else {
+ $anyFailed = true;
+ }
+ }
+ }
+ if ( !$anyFailed ) {
+ return AuthenticationResponse::newPass();
+ }
+
+ $combinedStatus = \Status::newGood();
+ foreach ( $statuses as $data ) {
+ list( $req, $status ) = $data;
+ $descriptionInfo = $req->describeCredentials();
+ $description = wfMessage(
+ 'authprovider-confirmlink-option',
+ $descriptionInfo['provider']->text(), $descriptionInfo['account']->text()
+ )->text();
+ if ( $status->isGood() ) {
+ $combinedStatus->error( wfMessage( 'authprovider-confirmlink-success-line', $description ) );
+ } else {
+ $combinedStatus->error( wfMessage(
+ 'authprovider-confirmlink-failed-line', $description, $status->getMessage()->text()
+ ) );
+ }
+ }
+ return AuthenticationResponse::newUI(
+ [
+ new ButtonAuthenticationRequest(
+ 'linkOk', wfMessage( 'ok' ), wfMessage( 'authprovider-confirmlink-ok-help' )
+ )
+ ],
+ $combinedStatus->getMessage( 'authprovider-confirmlink-failed' ),
+ 'error'
+ );
+ }
+}
diff --git a/www/wiki/includes/auth/CreateFromLoginAuthenticationRequest.php b/www/wiki/includes/auth/CreateFromLoginAuthenticationRequest.php
new file mode 100644
index 00000000..db827972
--- /dev/null
+++ b/www/wiki/includes/auth/CreateFromLoginAuthenticationRequest.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * This transfers state between the login and account creation flows.
+ *
+ * AuthManager::getAuthenticationRequests() won't return this type, but it
+ * may be passed to AuthManager::beginAuthentication() or
+ * AuthManager::beginAccountCreation() anyway.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class CreateFromLoginAuthenticationRequest extends AuthenticationRequest {
+ public $required = self::OPTIONAL;
+
+ /** @var AuthenticationRequest|null */
+ public $createRequest;
+
+ /** @var AuthenticationRequest[] */
+ public $maybeLink = [];
+
+ /**
+ * @param AuthenticationRequest|null $createRequest A request to use to
+ * begin creating the account
+ * @param AuthenticationRequest[] $maybeLink Additional accounts to link
+ * after creation.
+ */
+ public function __construct(
+ AuthenticationRequest $createRequest = null, array $maybeLink = []
+ ) {
+ $this->createRequest = $createRequest;
+ $this->maybeLink = $maybeLink;
+ $this->username = $createRequest ? $createRequest->username : null;
+ }
+
+ public function getFieldInfo() {
+ return [];
+ }
+
+ public function loadFromSubmission( array $data ) {
+ return true;
+ }
+
+ /**
+ * Indicate whether this request contains any state for the specified
+ * action.
+ * @param string $action One of the AuthManager::ACTION_* constants
+ * @return bool
+ */
+ public function hasStateForAction( $action ) {
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ return (bool)$this->maybeLink;
+ case AuthManager::ACTION_CREATE:
+ return $this->maybeLink || $this->createRequest;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Indicate whether this request contains state for the specified
+ * action sufficient to replace other primary-required requests.
+ * @param string $action One of the AuthManager::ACTION_* constants
+ * @return bool
+ */
+ public function hasPrimaryStateForAction( $action ) {
+ switch ( $action ) {
+ case AuthManager::ACTION_CREATE:
+ return (bool)$this->createRequest;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/www/wiki/includes/auth/CreatedAccountAuthenticationRequest.php b/www/wiki/includes/auth/CreatedAccountAuthenticationRequest.php
new file mode 100644
index 00000000..48a6e1d3
--- /dev/null
+++ b/www/wiki/includes/auth/CreatedAccountAuthenticationRequest.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * Returned from account creation to allow for logging into the created account
+ * @ingroup Auth
+ * @since 1.27
+ */
+class CreatedAccountAuthenticationRequest extends AuthenticationRequest {
+
+ public $required = self::OPTIONAL;
+
+ /** @var int User id */
+ public $id;
+
+ public function getFieldInfo() {
+ return [];
+ }
+
+ /**
+ * @param int $id User id
+ * @param string $name Username
+ */
+ public function __construct( $id, $name ) {
+ $this->id = $id;
+ $this->username = $name;
+ }
+}
diff --git a/www/wiki/includes/auth/CreationReasonAuthenticationRequest.php b/www/wiki/includes/auth/CreationReasonAuthenticationRequest.php
new file mode 100644
index 00000000..146470ed
--- /dev/null
+++ b/www/wiki/includes/auth/CreationReasonAuthenticationRequest.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+/**
+ * Authentication request for the reason given for account creation.
+ * Used in logs and for notification.
+ */
+class CreationReasonAuthenticationRequest extends AuthenticationRequest {
+ /** @var string Account creation reason (only used when creating for someone else) */
+ public $reason;
+
+ public $required = self::OPTIONAL;
+
+ public function getFieldInfo() {
+ return [
+ 'reason' => [
+ 'type' => 'string',
+ 'label' => wfMessage( 'createacct-reason' ),
+ 'help' => wfMessage( 'createacct-reason-help' ),
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php
new file mode 100644
index 00000000..a4855318
--- /dev/null
+++ b/www/wiki/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace MediaWiki\Auth;
+
+use Config;
+
+/**
+ * Handles email notification / email address confirmation for account creation.
+ *
+ * Set 'no-email' to true (via AuthManager::setAuthenticationSessionData) to skip this provider.
+ * Primary providers doing so are expected to take care of email address confirmation.
+ */
+class EmailNotificationSecondaryAuthenticationProvider
+ extends AbstractSecondaryAuthenticationProvider
+{
+ /** @var bool */
+ protected $sendConfirmationEmail;
+
+ /**
+ * @param array $params
+ * - sendConfirmationEmail: (bool) send an email asking the user to confirm their email
+ * address after a successful registration
+ */
+ public function __construct( $params = [] ) {
+ if ( isset( $params['sendConfirmationEmail'] ) ) {
+ $this->sendConfirmationEmail = (bool)$params['sendConfirmationEmail'];
+ }
+ }
+
+ public function setConfig( Config $config ) {
+ parent::setConfig( $config );
+
+ if ( $this->sendConfirmationEmail === null ) {
+ $this->sendConfirmationEmail = $this->config->get( 'EnableEmail' )
+ && $this->config->get( 'EmailAuthentication' );
+ }
+ }
+
+ public function getAuthenticationRequests( $action, array $options ) {
+ return [];
+ }
+
+ public function beginSecondaryAuthentication( $user, array $reqs ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) {
+ if (
+ $this->sendConfirmationEmail
+ && $user->getEmail()
+ && !$this->manager->getAuthenticationSessionData( 'no-email' )
+ ) {
+ // TODO show 'confirmemail_oncreate'/'confirmemail_sendfailed' message
+ wfGetDB( DB_MASTER )->onTransactionIdle(
+ function () use ( $user ) {
+ $user = $user->getInstanceForUpdate();
+ $status = $user->sendConfirmationMail();
+ $user->saveSettings();
+ if ( !$status->isGood() ) {
+ $this->logger->warning( 'Could not send confirmation email: ' .
+ $status->getWikiText( false, false, 'en' ) );
+ }
+ },
+ __METHOD__
+ );
+ }
+
+ return AuthenticationResponse::newPass();
+ }
+}
diff --git a/www/wiki/includes/auth/LegacyHookPreAuthenticationProvider.php b/www/wiki/includes/auth/LegacyHookPreAuthenticationProvider.php
new file mode 100644
index 00000000..cab6e32d
--- /dev/null
+++ b/www/wiki/includes/auth/LegacyHookPreAuthenticationProvider.php
@@ -0,0 +1,181 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use LoginForm;
+use StatusValue;
+use User;
+
+/**
+ * A pre-authentication provider to call some legacy hooks.
+ * @ingroup Auth
+ * @since 1.27
+ * @deprecated since 1.27
+ */
+class LegacyHookPreAuthenticationProvider extends AbstractPreAuthenticationProvider {
+
+ public function testForAuthentication( array $reqs ) {
+ $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
+ if ( $req ) {
+ $user = User::newFromName( $req->username );
+ $password = $req->password;
+ } else {
+ $user = null;
+ foreach ( $reqs as $req ) {
+ if ( $req->username !== null ) {
+ $user = User::newFromName( $req->username );
+ break;
+ }
+ }
+ if ( !$user ) {
+ $this->logger->debug( __METHOD__ . ': No username in $reqs, skipping hooks' );
+ return StatusValue::newGood();
+ }
+
+ // Something random for the 'AbortLogin' hook.
+ $password = wfRandomString( 32 );
+ }
+
+ $msg = null;
+ if ( !\Hooks::run( 'LoginUserMigrated', [ $user, &$msg ] ) ) {
+ return $this->makeFailResponse(
+ $user, null, LoginForm::USER_MIGRATED, $msg, 'LoginUserMigrated'
+ );
+ }
+
+ $abort = LoginForm::ABORTED;
+ $msg = null;
+ if ( !\Hooks::run( 'AbortLogin', [ $user, $password, &$abort, &$msg ] ) ) {
+ return $this->makeFailResponse( $user, null, $abort, $msg, 'AbortLogin' );
+ }
+
+ return StatusValue::newGood();
+ }
+
+ public function testForAccountCreation( $user, $creator, array $reqs ) {
+ $abortError = '';
+ $abortStatus = null;
+ if ( !\Hooks::run( 'AbortNewAccount', [ $user, &$abortError, &$abortStatus ] ) ) {
+ // Hook point to add extra creation throttles and blocks
+ $this->logger->debug( __METHOD__ . ': a hook blocked creation' );
+ if ( $abortStatus === null ) {
+ // Report back the old string as a raw message status.
+ // This will report the error back as 'createaccount-hook-aborted'
+ // with the given string as the message.
+ // To return a different error code, return a StatusValue object.
+ $msg = wfMessage( 'createaccount-hook-aborted' )->rawParams( $abortError );
+ return StatusValue::newFatal( $msg );
+ } else {
+ // For MediaWiki 1.23+ and updated hooks, return the Status object
+ // returned from the hook.
+ $ret = StatusValue::newGood();
+ $ret->merge( $abortStatus );
+ return $ret;
+ }
+ }
+
+ return StatusValue::newGood();
+ }
+
+ public function testUserForCreation( $user, $autocreate, array $options = [] ) {
+ if ( $autocreate !== false ) {
+ $abortError = '';
+ if ( !\Hooks::run( 'AbortAutoAccount', [ $user, &$abortError ] ) ) {
+ // Hook point to add extra creation throttles and blocks
+ $this->logger->debug( __METHOD__ . ": a hook blocked auto-creation: $abortError\n" );
+ return $this->makeFailResponse(
+ $user, $user, LoginForm::ABORTED, $abortError, 'AbortAutoAccount'
+ );
+ }
+ }
+
+ return StatusValue::newGood();
+ }
+
+ /**
+ * Construct an appropriate failure response
+ * @param User $user
+ * @param User|null $creator
+ * @param int $constant LoginForm constant
+ * @param string|null $msg Message
+ * @param string $hook Hook
+ * @return StatusValue
+ */
+ protected function makeFailResponse( $user, $creator, $constant, $msg, $hook ) {
+ switch ( $constant ) {
+ case LoginForm::SUCCESS:
+ // WTF?
+ $this->logger->debug( "$hook is SUCCESS?!" );
+ return StatusValue::newGood();
+
+ case LoginForm::NEED_TOKEN:
+ return StatusValue::newFatal( $msg ?: 'nocookiesforlogin' );
+
+ case LoginForm::WRONG_TOKEN:
+ return StatusValue::newFatal( $msg ?: 'sessionfailure' );
+
+ case LoginForm::NO_NAME:
+ case LoginForm::ILLEGAL:
+ return StatusValue::newFatal( $msg ?: 'noname' );
+
+ case LoginForm::WRONG_PLUGIN_PASS:
+ case LoginForm::WRONG_PASS:
+ return StatusValue::newFatal( $msg ?: 'wrongpassword' );
+
+ case LoginForm::NOT_EXISTS:
+ return StatusValue::newFatal( $msg ?: 'nosuchusershort', wfEscapeWikiText( $user->getName() ) );
+
+ case LoginForm::EMPTY_PASS:
+ return StatusValue::newFatal( $msg ?: 'wrongpasswordempty' );
+
+ case LoginForm::RESET_PASS:
+ return StatusValue::newFatal( $msg ?: 'resetpass_announce' );
+
+ case LoginForm::THROTTLED:
+ $throttle = $this->config->get( 'PasswordAttemptThrottle' );
+ return StatusValue::newFatal(
+ $msg ?: 'login-throttled',
+ \Message::durationParam( $throttle['seconds'] )
+ );
+
+ case LoginForm::USER_BLOCKED:
+ return StatusValue::newFatal(
+ $msg ?: 'login-userblocked', wfEscapeWikiText( $user->getName() )
+ );
+
+ case LoginForm::ABORTED:
+ return StatusValue::newFatal(
+ $msg ?: 'login-abort-generic', wfEscapeWikiText( $user->getName() )
+ );
+
+ case LoginForm::USER_MIGRATED:
+ $error = $msg ?: 'login-migrated-generic';
+ return call_user_func_array( 'StatusValue::newFatal', (array)$error );
+
+ // @codeCoverageIgnoreStart
+ case LoginForm::CREATE_BLOCKED: // Can never happen
+ default:
+ throw new \DomainException( __METHOD__ . ": Unhandled case value from $hook" );
+ }
+ // @codeCoverageIgnoreEnd
+ }
+}
diff --git a/www/wiki/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php
new file mode 100644
index 00000000..86a6aae0
--- /dev/null
+++ b/www/wiki/includes/auth/LocalPasswordPrimaryAuthenticationProvider.php
@@ -0,0 +1,324 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * A primary authentication provider that uses the password field in the 'user' table.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class LocalPasswordPrimaryAuthenticationProvider
+ extends AbstractPasswordPrimaryAuthenticationProvider
+{
+
+ /** @var bool If true, this instance is for legacy logins only. */
+ protected $loginOnly = false;
+
+ /**
+ * @param array $params Settings
+ * - loginOnly: If true, the local passwords are for legacy logins only:
+ * the local password will be invalidated when authentication is changed
+ * and new users will not have a valid local password set.
+ */
+ public function __construct( $params = [] ) {
+ parent::__construct( $params );
+ $this->loginOnly = !empty( $params['loginOnly'] );
+ }
+
+ protected function getPasswordResetData( $username, $row ) {
+ $now = wfTimestamp();
+ $expiration = wfTimestampOrNull( TS_UNIX, $row->user_password_expires );
+ if ( $expiration === null || $expiration >= $now ) {
+ return null;
+ }
+
+ $grace = $this->config->get( 'PasswordExpireGrace' );
+ if ( $expiration + $grace < $now ) {
+ $data = [
+ 'hard' => true,
+ 'msg' => \Status::newFatal( 'resetpass-expired' )->getMessage(),
+ ];
+ } else {
+ $data = [
+ 'hard' => false,
+ 'msg' => \Status::newFatal( 'resetpass-expired-soft' )->getMessage(),
+ ];
+ }
+
+ return (object)$data;
+ }
+
+ public function beginPrimaryAuthentication( array $reqs ) {
+ $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
+ if ( !$req ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ if ( $req->username === null || $req->password === null ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $username = User::getCanonicalName( $req->username, 'usable' );
+ if ( $username === false ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $fields = [
+ 'user_id', 'user_password', 'user_password_expires',
+ ];
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'user',
+ $fields,
+ [ 'user_name' => $username ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ // Do not reveal whether its bad username or
+ // bad password to prevent username enumeration
+ // on private wikis. (T134100)
+ return $this->failResponse( $req );
+ }
+
+ $oldRow = clone $row;
+ // Check for *really* old password hashes that don't even have a type
+ // The old hash format was just an md5 hex hash, with no type information
+ if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) {
+ if ( $this->config->get( 'PasswordSalt' ) ) {
+ $row->user_password = ":B:{$row->user_id}:{$row->user_password}";
+ } else {
+ $row->user_password = ":A:{$row->user_password}";
+ }
+ }
+
+ $status = $this->checkPasswordValidity( $username, $req->password );
+ if ( !$status->isOK() ) {
+ // Fatal, can't log in
+ return AuthenticationResponse::newFail( $status->getMessage() );
+ }
+
+ $pwhash = $this->getPassword( $row->user_password );
+ if ( !$pwhash->equals( $req->password ) ) {
+ if ( $this->config->get( 'LegacyEncoding' ) ) {
+ // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted
+ // Check for this with iconv
+ $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $req->password );
+ if ( $cp1252Password === $req->password || !$pwhash->equals( $cp1252Password ) ) {
+ return $this->failResponse( $req );
+ }
+ } else {
+ return $this->failResponse( $req );
+ }
+ }
+
+ // @codeCoverageIgnoreStart
+ if ( $this->getPasswordFactory()->needsUpdate( $pwhash ) ) {
+ $newHash = $this->getPasswordFactory()->newFromPlaintext( $req->password );
+ \DeferredUpdates::addCallableUpdate( function () use ( $newHash, $oldRow ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'user',
+ [ 'user_password' => $newHash->toString() ],
+ [
+ 'user_id' => $oldRow->user_id,
+ 'user_password' => $oldRow->user_password
+ ],
+ __METHOD__
+ );
+ } );
+ }
+ // @codeCoverageIgnoreEnd
+
+ $this->setPasswordResetFlag( $username, $status, $row );
+
+ return AuthenticationResponse::newPass( $username );
+ }
+
+ public function testUserCanAuthenticate( $username ) {
+ $username = User::getCanonicalName( $username, 'usable' );
+ if ( $username === false ) {
+ return false;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'user',
+ [ 'user_password' ],
+ [ 'user_name' => $username ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ return false;
+ }
+
+ // Check for *really* old password hashes that don't even have a type
+ // The old hash format was just an md5 hex hash, with no type information
+ if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) {
+ return true;
+ }
+
+ return !$this->getPassword( $row->user_password ) instanceof \InvalidPassword;
+ }
+
+ public function testUserExists( $username, $flags = User::READ_NORMAL ) {
+ $username = User::getCanonicalName( $username, 'usable' );
+ if ( $username === false ) {
+ return false;
+ }
+
+ list( $db, $options ) = \DBAccessObjectUtils::getDBOptions( $flags );
+ return (bool)wfGetDB( $db )->selectField(
+ [ 'user' ],
+ [ 'user_id' ],
+ [ 'user_name' => $username ],
+ __METHOD__,
+ $options
+ );
+ }
+
+ public function providerAllowsAuthenticationDataChange(
+ AuthenticationRequest $req, $checkData = true
+ ) {
+ // We only want to blank the password if something else will accept the
+ // new authentication data, so return 'ignore' here.
+ if ( $this->loginOnly ) {
+ return \StatusValue::newGood( 'ignored' );
+ }
+
+ if ( get_class( $req ) === PasswordAuthenticationRequest::class ) {
+ if ( !$checkData ) {
+ return \StatusValue::newGood();
+ }
+
+ $username = User::getCanonicalName( $req->username, 'usable' );
+ if ( $username !== false ) {
+ $row = wfGetDB( DB_MASTER )->selectRow(
+ 'user',
+ [ 'user_id' ],
+ [ 'user_name' => $username ],
+ __METHOD__
+ );
+ if ( $row ) {
+ $sv = \StatusValue::newGood();
+ if ( $req->password !== null ) {
+ if ( $req->password !== $req->retype ) {
+ $sv->fatal( 'badretype' );
+ } else {
+ $sv->merge( $this->checkPasswordValidity( $username, $req->password ) );
+ }
+ }
+ return $sv;
+ }
+ }
+ }
+
+ return \StatusValue::newGood( 'ignored' );
+ }
+
+ public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
+ $username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false;
+ if ( $username === false ) {
+ return;
+ }
+
+ $pwhash = null;
+
+ if ( get_class( $req ) === PasswordAuthenticationRequest::class ) {
+ if ( $this->loginOnly ) {
+ $pwhash = $this->getPasswordFactory()->newFromCiphertext( null );
+ $expiry = null;
+ } else {
+ $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password );
+ $expiry = $this->getNewPasswordExpiry( $username );
+ }
+ }
+
+ if ( $pwhash ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'user',
+ [
+ 'user_password' => $pwhash->toString(),
+ 'user_password_expires' => $dbw->timestampOrNull( $expiry ),
+ ],
+ [ 'user_name' => $username ],
+ __METHOD__
+ );
+ }
+ }
+
+ public function accountCreationType() {
+ return $this->loginOnly ? self::TYPE_NONE : self::TYPE_CREATE;
+ }
+
+ public function testForAccountCreation( $user, $creator, array $reqs ) {
+ $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
+
+ $ret = \StatusValue::newGood();
+ if ( !$this->loginOnly && $req && $req->username !== null && $req->password !== null ) {
+ if ( $req->password !== $req->retype ) {
+ $ret->fatal( 'badretype' );
+ } else {
+ $ret->merge(
+ $this->checkPasswordValidity( $user->getName(), $req->password )
+ );
+ }
+ }
+ return $ret;
+ }
+
+ public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
+ if ( $this->accountCreationType() === self::TYPE_NONE ) {
+ throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' );
+ }
+
+ $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
+ if ( $req ) {
+ if ( $req->username !== null && $req->password !== null ) {
+ // Nothing we can do besides claim it, because the user isn't in
+ // the DB yet
+ if ( $req->username !== $user->getName() ) {
+ $req = clone $req;
+ $req->username = $user->getName();
+ }
+ $ret = AuthenticationResponse::newPass( $req->username );
+ $ret->createRequest = $req;
+ return $ret;
+ }
+ }
+ return AuthenticationResponse::newAbstain();
+ }
+
+ public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) {
+ if ( $this->accountCreationType() === self::TYPE_NONE ) {
+ throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' );
+ }
+
+ // Now that the user is in the DB, set the password on it.
+ $this->providerChangeAuthenticationData( $res->createRequest );
+
+ return null;
+ }
+}
diff --git a/www/wiki/includes/auth/PasswordAuthenticationRequest.php b/www/wiki/includes/auth/PasswordAuthenticationRequest.php
new file mode 100644
index 00000000..8550f3e2
--- /dev/null
+++ b/www/wiki/includes/auth/PasswordAuthenticationRequest.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * This is a value object for authentication requests with a username and password
+ * @ingroup Auth
+ * @since 1.27
+ */
+class PasswordAuthenticationRequest extends AuthenticationRequest {
+ /** @var string Password */
+ public $password = null;
+
+ /** @var string Password, again */
+ public $retype = null;
+
+ public function getFieldInfo() {
+ if ( $this->action === AuthManager::ACTION_REMOVE ) {
+ return [];
+ }
+
+ // for password change it's nice to make extra clear that we are asking for the new password
+ $forNewPassword = $this->action === AuthManager::ACTION_CHANGE;
+ $passwordLabel = $forNewPassword ? 'newpassword' : 'userlogin-yourpassword';
+ $retypeLabel = $forNewPassword ? 'retypenew' : 'yourpasswordagain';
+
+ $ret = [
+ 'username' => [
+ 'type' => 'string',
+ 'label' => wfMessage( 'userlogin-yourname' ),
+ 'help' => wfMessage( 'authmanager-username-help' ),
+ ],
+ 'password' => [
+ 'type' => 'password',
+ 'label' => wfMessage( $passwordLabel ),
+ 'help' => wfMessage( 'authmanager-password-help' ),
+ 'sensitive' => true,
+ ],
+ ];
+
+ switch ( $this->action ) {
+ case AuthManager::ACTION_CHANGE:
+ case AuthManager::ACTION_REMOVE:
+ unset( $ret['username'] );
+ break;
+ }
+
+ if ( $this->action !== AuthManager::ACTION_LOGIN ) {
+ $ret['retype'] = [
+ 'type' => 'password',
+ 'label' => wfMessage( $retypeLabel ),
+ 'help' => wfMessage( 'authmanager-retype-help' ),
+ 'sensitive' => true,
+ ];
+ }
+
+ return $ret;
+ }
+
+ public function describeCredentials() {
+ return [
+ 'provider' => wfMessage( 'authmanager-provider-password' ),
+ 'account' => new \RawMessage( '$1', [ $this->username ] ),
+ ];
+ }
+}
diff --git a/www/wiki/includes/auth/PasswordDomainAuthenticationRequest.php b/www/wiki/includes/auth/PasswordDomainAuthenticationRequest.php
new file mode 100644
index 00000000..3db7e212
--- /dev/null
+++ b/www/wiki/includes/auth/PasswordDomainAuthenticationRequest.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * This is a value object for authentication requests with a username, password, and domain
+ * @ingroup Auth
+ * @since 1.27
+ */
+class PasswordDomainAuthenticationRequest extends PasswordAuthenticationRequest {
+ /** @var string[] Domains available */
+ private $domainList;
+
+ /** @var string Domain */
+ public $domain = null;
+
+ /**
+ * @param string[] $domainList List of available domains
+ */
+ public function __construct( array $domainList ) {
+ $this->domainList = $domainList;
+ }
+
+ public function getFieldInfo() {
+ $ret = parent::getFieldInfo();
+
+ // Only add a domain field if we have the username field included
+ if ( isset( $ret['username'] ) ) {
+ $ret['domain'] = [
+ 'type' => 'select',
+ 'options' => [],
+ 'label' => wfMessage( 'yourdomainname' ),
+ 'help' => wfMessage( 'authmanager-domain-help' ),
+ ];
+ foreach ( $this->domainList as $domain ) {
+ $ret['domain']['options'][$domain] = new \RawMessage( '$1', [ $domain ] );
+ }
+ }
+
+ return $ret;
+ }
+
+ public function describeCredentials() {
+ return [
+ 'provider' => wfMessage( 'authmanager-provider-password-domain' ),
+ 'account' => wfMessage(
+ 'authmanager-account-password-domain', [ $this->username, $this->domain ]
+ ),
+ ];
+ }
+
+ /**
+ * @codeCoverageIgnore
+ * @param array $data
+ * @return AuthenticationRequest|static
+ */
+ public static function __set_state( $data ) {
+ $ret = new static( $data['domainList'] );
+ foreach ( $data as $k => $v ) {
+ if ( $k !== 'domainList' ) {
+ $ret->$k = $v;
+ }
+ }
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/auth/PreAuthenticationProvider.php b/www/wiki/includes/auth/PreAuthenticationProvider.php
new file mode 100644
index 00000000..8590cbd1
--- /dev/null
+++ b/www/wiki/includes/auth/PreAuthenticationProvider.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * Pre-authentication provider interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use StatusValue;
+use User;
+
+/**
+ * A pre-authentication provider can prevent authentication early on.
+ *
+ * A PreAuthenticationProvider is used to supply arbitrary checks to be
+ * performed before the PrimaryAuthenticationProviders are consulted during the
+ * login / account creation / account linking process. Possible uses include
+ * checking that a per-IP throttle has not been reached or that a captcha has been solved.
+ *
+ * This interface also provides callbacks that are invoked after login / account creation
+ * / account linking succeeded or failed.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
+ */
+interface PreAuthenticationProvider extends AuthenticationProvider {
+
+ /**
+ * Determine whether an authentication may begin
+ *
+ * Called from AuthManager::beginAuthentication()
+ *
+ * @param AuthenticationRequest[] $reqs
+ * @return StatusValue
+ */
+ public function testForAuthentication( array $reqs );
+
+ /**
+ * Post-login callback
+ *
+ * This will be called at the end of a login attempt. It will not be called for unfinished
+ * login attempts that fail by the session timing out.
+ *
+ * @note Under certain circumstances, this can be called even when testForAuthentication
+ * was not; see AuthenticationRequest::$loginRequest.
+ * @param User|null $user User that was attempted to be logged in, if known.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param AuthenticationResponse $response Authentication response that will be returned
+ * (PASS or FAIL)
+ */
+ public function postAuthentication( $user, AuthenticationResponse $response );
+
+ /**
+ * Determine whether an account creation may begin
+ *
+ * Called from AuthManager::beginAccountCreation()
+ *
+ * @note No need to test if the account exists, AuthManager checks that
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return StatusValue
+ */
+ public function testForAccountCreation( $user, $creator, array $reqs );
+
+ /**
+ * Determine whether an account may be created
+ *
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param bool|string $autocreate False if this is not an auto-creation, or
+ * the source of the auto-creation passed to AuthManager::autoCreateUser().
+ * @param array $options
+ * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL
+ * - creating: (bool) If false (or missing), this call is only testing if
+ * a user could be created. If set, this (non-autocreation) is for
+ * actually creating an account and will be followed by a call to
+ * testForAccountCreation(). In this case, the provider might return
+ * StatusValue::newGood() here and let the later call to
+ * testForAccountCreation() do a more thorough test.
+ * @return StatusValue
+ */
+ public function testUserForCreation( $user, $autocreate, array $options = [] );
+
+ /**
+ * Post-creation callback
+ *
+ * This will be called at the end of an account creation attempt. It will not be called if
+ * the account creation process results in a session timeout (possibly after a successful
+ * user creation, while a secondary provider is waiting for a response).
+ *
+ * @param User $user User that was attempted to be created.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationResponse $response Authentication response that will be returned
+ * (PASS or FAIL)
+ */
+ public function postAccountCreation( $user, $creator, AuthenticationResponse $response );
+
+ /**
+ * Determine whether an account may linked to another authentication method
+ *
+ * @param User $user User being linked.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @return StatusValue
+ */
+ public function testForAccountLink( $user );
+
+ /**
+ * Post-link callback
+ *
+ * This will be called at the end of an account linking attempt.
+ *
+ * @param User $user User that was attempted to be linked.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param AuthenticationResponse $response Authentication response that will be returned
+ * (PASS or FAIL)
+ */
+ public function postAccountLink( $user, AuthenticationResponse $response );
+
+}
diff --git a/www/wiki/includes/auth/PrimaryAuthenticationProvider.php b/www/wiki/includes/auth/PrimaryAuthenticationProvider.php
new file mode 100644
index 00000000..5d82f899
--- /dev/null
+++ b/www/wiki/includes/auth/PrimaryAuthenticationProvider.php
@@ -0,0 +1,400 @@
+<?php
+/**
+ * Primary authentication provider interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use StatusValue;
+use User;
+
+/**
+ * A primary authentication provider is responsible for associating the submitted
+ * authentication data with a MediaWiki account.
+ *
+ * When multiple primary authentication providers are configured for a site, they
+ * act as alternatives; the first one that recognizes the data will handle it,
+ * and further primary providers are not called (although they all get a chance
+ * to prevent actions).
+ *
+ * For login, the PrimaryAuthenticationProvider takes form data and determines
+ * which authenticated user (if any) corresponds to that form data. It might
+ * do this on the basis of a username and password in that data, or by
+ * interacting with an external authentication service (e.g. using OpenID),
+ * or by some other mechanism.
+ *
+ * (A PrimaryAuthenticationProvider would not be appropriate for something like
+ * HTTP authentication, OAuth, or SSL client certificates where each HTTP
+ * request contains all the information needed to identify the user. In that
+ * case you'll want to be looking at a \MediaWiki\Session\SessionProvider
+ * instead.)
+ *
+ * For account creation, the PrimaryAuthenticationProvider takes form data and
+ * stores some authentication details which will allow it to verify a login by
+ * that user in the future. This might for example involve saving it in the
+ * database in a table that can be joined to the user table, or sending it to
+ * some external service for account creation, or authenticating the user with
+ * some remote service and then recording that the remote identity is linked to
+ * the local account.
+ * The creation of the local user (i.e. calling User::addToDatabase()) is handled
+ * by AuthManager once the primary authentication provider returns a PASS
+ * from begin/continueAccountCreation; do not try to do it yourself.
+ *
+ * For account linking, the PrimaryAuthenticationProvider verifies the user's
+ * identity at some external service (typically by redirecting the user and
+ * asking the external service to verify) and then records which local account
+ * is linked to which remote accounts. It should keep track of this and be able
+ * to enumerate linked accounts via getAuthenticationRequests(ACTION_REMOVE).
+ *
+ * This interface also provides methods for changing authentication data such
+ * as passwords, and callbacks that are invoked after login / account creation
+ * / account linking succeeded or failed.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
+ */
+interface PrimaryAuthenticationProvider extends AuthenticationProvider {
+ /** Provider can create accounts */
+ const TYPE_CREATE = 'create';
+ /** Provider can link to existing accounts elsewhere */
+ const TYPE_LINK = 'link';
+ /** Provider cannot create or link to accounts */
+ const TYPE_NONE = 'none';
+
+ /**
+ * @inheritDoc
+ *
+ * Of the requests returned by this method, exactly one should have
+ * {@link AuthenticationRequest::$required} set to REQUIRED.
+ */
+ public function getAuthenticationRequests( $action, array $options );
+
+ /**
+ * Start an authentication flow
+ *
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user is authenticated. Secondary providers will now run.
+ * - FAIL: The user is not authenticated. Fail the authentication process.
+ * - ABSTAIN: These $reqs are not handled. Some other primary provider may handle it.
+ * - UI: The $reqs are accepted, no other primary provider will run.
+ * Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: The $reqs are accepted, no other primary provider will run.
+ * Redirection to a third party is needed to complete the process.
+ */
+ public function beginPrimaryAuthentication( array $reqs );
+
+ /**
+ * Continue an authentication flow
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user is authenticated. Secondary providers will now run.
+ * - FAIL: The user is not authenticated. Fail the authentication process.
+ * - UI: Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: Redirection to a third party is needed to complete the process.
+ */
+ public function continuePrimaryAuthentication( array $reqs );
+
+ /**
+ * Post-login callback
+ *
+ * This will be called at the end of any login attempt, regardless of whether this provider was
+ * the one that handled it. It will not be called for unfinished login attempts that fail by
+ * the session timing out.
+ *
+ * @param User|null $user User that was attempted to be logged in, if known.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param AuthenticationResponse $response Authentication response that will be returned
+ * (PASS or FAIL)
+ */
+ public function postAuthentication( $user, AuthenticationResponse $response );
+
+ /**
+ * Test whether the named user exists
+ *
+ * Single-sign-on providers can use this to reserve a username for autocreation.
+ *
+ * @param string $username MediaWiki username
+ * @param int $flags Bitfield of User:READ_* constants
+ * @return bool
+ */
+ public function testUserExists( $username, $flags = User::READ_NORMAL );
+
+ /**
+ * Test whether the named user can authenticate with this provider
+ *
+ * Should return true if the provider has any data for this user which can be used to
+ * authenticate it, even if the user is temporarily prevented from authentication somehow.
+ *
+ * @param string $username MediaWiki username
+ * @return bool
+ */
+ public function testUserCanAuthenticate( $username );
+
+ /**
+ * Normalize the username for authentication
+ *
+ * Any two inputs that would result in the same user being authenticated
+ * should return the same string here, while inputs that would result in
+ * different users should return different strings.
+ *
+ * If possible, the best thing to do here is to return the canonicalized
+ * name of the local user account that would be used. If not, return
+ * something that would be invalid as a local username (e.g. wrap an email
+ * address in "<>", or append "#servicename" to the username passed to a
+ * third-party service).
+ *
+ * If the provider doesn't use a username at all in its
+ * AuthenticationRequests, return null. If the name is syntactically
+ * invalid, it's probably best to return null.
+ *
+ * @param string $username
+ * @return string|null
+ */
+ public function providerNormalizeUsername( $username );
+
+ /**
+ * Revoke the user's credentials
+ *
+ * This may cause the user to no longer exist for the provider, or the user
+ * may continue to exist in a "disabled" state.
+ *
+ * The intention is that the named account will never again be usable for
+ * normal login (i.e. there is no way to undo the revocation of access).
+ *
+ * @param string $username
+ */
+ public function providerRevokeAccessForUser( $username );
+
+ /**
+ * Determine whether a property can change
+ * @see AuthManager::allowsPropertyChange()
+ * @param string $property
+ * @return bool
+ */
+ public function providerAllowsPropertyChange( $property );
+
+ /**
+ * Validate a change of authentication data (e.g. passwords)
+ *
+ * Return StatusValue::newGood( 'ignored' ) if you don't support this
+ * AuthenticationRequest type.
+ *
+ * @param AuthenticationRequest $req
+ * @param bool $checkData If false, $req hasn't been loaded from the
+ * submission so checks on user-submitted fields should be skipped.
+ * $req->username is considered user-submitted for this purpose, even
+ * if it cannot be changed via $req->loadFromSubmission.
+ * @return StatusValue
+ */
+ public function providerAllowsAuthenticationDataChange(
+ AuthenticationRequest $req, $checkData = true
+ );
+
+ /**
+ * Change or remove authentication data (e.g. passwords)
+ *
+ * If $req was returned for AuthManager::ACTION_CHANGE, the corresponding
+ * credentials should result in a successful login in the future.
+ *
+ * If $req was returned for AuthManager::ACTION_REMOVE, the corresponding
+ * credentials should no longer result in a successful login.
+ *
+ * It can be assumed that providerAllowsAuthenticationDataChange with $checkData === true
+ * was called before this, and passed. This method should never fail (other than throwing an
+ * exception).
+ *
+ * @param AuthenticationRequest $req
+ */
+ public function providerChangeAuthenticationData( AuthenticationRequest $req );
+
+ /**
+ * Fetch the account-creation type
+ * @return string One of the TYPE_* constants
+ */
+ public function accountCreationType();
+
+ /**
+ * Determine whether an account creation may begin
+ *
+ * Called from AuthManager::beginAccountCreation()
+ *
+ * @note No need to test if the account exists, AuthManager checks that
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return StatusValue
+ */
+ public function testForAccountCreation( $user, $creator, array $reqs );
+
+ /**
+ * Start an account creation flow
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user may be created. Secondary providers will now run.
+ * - FAIL: The user may not be created. Fail the creation process.
+ * - ABSTAIN: These $reqs are not handled. Some other primary provider may handle it.
+ * - UI: The $reqs are accepted, no other primary provider will run.
+ * Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: The $reqs are accepted, no other primary provider will run.
+ * Redirection to a third party is needed to complete the process.
+ */
+ public function beginPrimaryAccountCreation( $user, $creator, array $reqs );
+
+ /**
+ * Continue an account creation flow
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user may be created. Secondary providers will now run.
+ * - FAIL: The user may not be created. Fail the creation process.
+ * - UI: Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: Redirection to a third party is needed to complete the process.
+ */
+ public function continuePrimaryAccountCreation( $user, $creator, array $reqs );
+
+ /**
+ * Post-creation callback
+ *
+ * Called after the user is added to the database, before secondary
+ * authentication providers are run. Only called if this provider was the one that issued
+ * a PASS.
+ *
+ * @param User $user User being created (has been added to the database now).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationResponse $response PASS response returned earlier
+ * @return string|null 'newusers' log subtype to use for logging the
+ * account creation. If null, either 'create' or 'create2' will be used
+ * depending on $creator.
+ */
+ public function finishAccountCreation( $user, $creator, AuthenticationResponse $response );
+
+ /**
+ * Post-creation callback
+ *
+ * This will be called at the end of any account creation attempt, regardless of whether this
+ * provider was the one that handled it. It will not be called if the account creation process
+ * results in a session timeout (possibly after a successful user creation, while a secondary
+ * provider is waiting for a response).
+ *
+ * @param User $user User that was attempted to be created.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationResponse $response Authentication response that will be returned
+ * (PASS or FAIL)
+ */
+ public function postAccountCreation( $user, $creator, AuthenticationResponse $response );
+
+ /**
+ * Determine whether an account may be created
+ *
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param bool|string $autocreate False if this is not an auto-creation, or
+ * the source of the auto-creation passed to AuthManager::autoCreateUser().
+ * @param array $options
+ * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL
+ * - creating: (bool) If false (or missing), this call is only testing if
+ * a user could be created. If set, this (non-autocreation) is for
+ * actually creating an account and will be followed by a call to
+ * testForAccountCreation(). In this case, the provider might return
+ * StatusValue::newGood() here and let the later call to
+ * testForAccountCreation() do a more thorough test.
+ * @return StatusValue
+ */
+ public function testUserForCreation( $user, $autocreate, array $options = [] );
+
+ /**
+ * Post-auto-creation callback
+ * @param User $user User being created (has been added to the database now).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param string $source The source of the auto-creation passed to
+ * AuthManager::autoCreateUser().
+ */
+ public function autoCreatedAccount( $user, $source );
+
+ /**
+ * Start linking an account to an existing user
+ * @param User $user User being linked.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user is linked.
+ * - FAIL: The user is not linked. Fail the linking process.
+ * - ABSTAIN: These $reqs are not handled. Some other primary provider may handle it.
+ * - UI: The $reqs are accepted, no other primary provider will run.
+ * Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: The $reqs are accepted, no other primary provider will run.
+ * Redirection to a third party is needed to complete the process.
+ */
+ public function beginPrimaryAccountLink( $user, array $reqs );
+
+ /**
+ * Continue linking an account to an existing user
+ * @param User $user User being linked.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user is linked.
+ * - FAIL: The user is not linked. Fail the linking process.
+ * - UI: Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: Redirection to a third party is needed to complete the process.
+ */
+ public function continuePrimaryAccountLink( $user, array $reqs );
+
+ /**
+ * Post-link callback
+ *
+ * This will be called at the end of any account linking attempt, regardless of whether this
+ * provider was the one that handled it.
+ *
+ * @param User $user User that was attempted to be linked.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param AuthenticationResponse $response Authentication response that will be returned
+ * (PASS or FAIL)
+ */
+ public function postAccountLink( $user, AuthenticationResponse $response );
+
+}
diff --git a/www/wiki/includes/auth/RememberMeAuthenticationRequest.php b/www/wiki/includes/auth/RememberMeAuthenticationRequest.php
new file mode 100644
index 00000000..06060b16
--- /dev/null
+++ b/www/wiki/includes/auth/RememberMeAuthenticationRequest.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\Session\SessionManager;
+use MediaWiki\Session\SessionProvider;
+
+/**
+ * This is an authentication request added by AuthManager to show a "remember
+ * me" checkbox. When checked, it will take more time for the authenticated session to expire.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class RememberMeAuthenticationRequest extends AuthenticationRequest {
+
+ public $required = self::OPTIONAL;
+
+ /** @var int How long the user will be remembered, in seconds */
+ protected $expiration = null;
+
+ /** @var bool */
+ public $rememberMe = false;
+
+ public function __construct() {
+ /** @var SessionProvider $provider */
+ $provider = SessionManager::getGlobalSession()->getProvider();
+ $this->expiration = $provider->getRememberUserDuration();
+ }
+
+ public function getFieldInfo() {
+ if ( !$this->expiration ) {
+ return [];
+ }
+
+ $expirationDays = ceil( $this->expiration / ( 3600 * 24 ) );
+ return [
+ 'rememberMe' => [
+ 'type' => 'checkbox',
+ 'label' => wfMessage( 'userlogin-remembermypassword' )->numParams( $expirationDays ),
+ 'help' => wfMessage( 'authmanager-userlogin-remembermypassword-help' ),
+ 'optional' => true,
+ 'skippable' => true,
+ ]
+ ];
+ }
+}
diff --git a/www/wiki/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php b/www/wiki/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php
new file mode 100644
index 00000000..45ac3aa0
--- /dev/null
+++ b/www/wiki/includes/auth/ResetPasswordSecondaryAuthenticationProvider.php
@@ -0,0 +1,133 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * Reset the local password, if signalled via $this->manager->setAuthenticationSessionData()
+ *
+ * The authentication data key is 'reset-pass'; the data is an object with the
+ * following properties:
+ * - msg: Message object to display to the user
+ * - hard: Boolean, if true the reset cannot be skipped.
+ * - req: Optional PasswordAuthenticationRequest to use to actually reset the
+ * password. Won't be displayed to the user.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class ResetPasswordSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider {
+
+ public function getAuthenticationRequests( $action, array $options ) {
+ return [];
+ }
+
+ public function beginSecondaryAuthentication( $user, array $reqs ) {
+ return $this->tryReset( $user, $reqs );
+ }
+
+ public function continueSecondaryAuthentication( $user, array $reqs ) {
+ return $this->tryReset( $user, $reqs );
+ }
+
+ public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) {
+ return $this->tryReset( $user, $reqs );
+ }
+
+ public function continueSecondaryAccountCreation( $user, $creator, array $reqs ) {
+ return $this->tryReset( $user, $reqs );
+ }
+
+ /**
+ * Try to reset the password
+ * @param \User $user
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse
+ */
+ protected function tryReset( \User $user, array $reqs ) {
+ $data = $this->manager->getAuthenticationSessionData( 'reset-pass' );
+ if ( !$data ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ if ( is_array( $data ) ) {
+ $data = (object)$data;
+ }
+ if ( !is_object( $data ) ) {
+ throw new \UnexpectedValueException( 'reset-pass is not valid' );
+ }
+
+ if ( !isset( $data->msg ) ) {
+ throw new \UnexpectedValueException( 'reset-pass msg is missing' );
+ } elseif ( !$data->msg instanceof \Message ) {
+ throw new \UnexpectedValueException( 'reset-pass msg is not valid' );
+ } elseif ( !isset( $data->hard ) ) {
+ throw new \UnexpectedValueException( 'reset-pass hard is missing' );
+ } elseif ( isset( $data->req ) && (
+ !$data->req instanceof PasswordAuthenticationRequest ||
+ !array_key_exists( 'retype', $data->req->getFieldInfo() )
+ ) ) {
+ throw new \UnexpectedValueException( 'reset-pass req is not valid' );
+ }
+
+ if ( !$data->hard ) {
+ $req = ButtonAuthenticationRequest::getRequestByName( $reqs, 'skipReset' );
+ if ( $req ) {
+ $this->manager->removeAuthenticationSessionData( 'reset-pass' );
+ return AuthenticationResponse::newPass();
+ }
+ }
+
+ $needReq = isset( $data->req ) ? $data->req : new PasswordAuthenticationRequest();
+ if ( !$needReq->action ) {
+ $needReq->action = AuthManager::ACTION_CHANGE;
+ }
+ $needReq->required = $data->hard ? AuthenticationRequest::REQUIRED
+ : AuthenticationRequest::OPTIONAL;
+ $needReqs = [ $needReq ];
+ if ( !$data->hard ) {
+ $needReqs[] = new ButtonAuthenticationRequest(
+ 'skipReset',
+ wfMessage( 'authprovider-resetpass-skip-label' ),
+ wfMessage( 'authprovider-resetpass-skip-help' )
+ );
+ }
+
+ $req = AuthenticationRequest::getRequestByClass( $reqs, get_class( $needReq ) );
+ if ( !$req || !array_key_exists( 'retype', $req->getFieldInfo() ) ) {
+ return AuthenticationResponse::newUI( $needReqs, $data->msg, 'warning' );
+ }
+
+ if ( $req->password !== $req->retype ) {
+ return AuthenticationResponse::newUI( $needReqs, new \Message( 'badretype' ), 'error' );
+ }
+
+ $req->username = $user->getName();
+ $status = $this->manager->allowsAuthenticationDataChange( $req );
+ if ( !$status->isGood() ) {
+ return AuthenticationResponse::newUI( $needReqs, $status->getMessage(), 'error' );
+ }
+ $this->manager->changeAuthenticationData( $req );
+
+ $this->manager->removeAuthenticationSessionData( 'reset-pass' );
+ return AuthenticationResponse::newPass();
+ }
+}
diff --git a/www/wiki/includes/auth/SecondaryAuthenticationProvider.php b/www/wiki/includes/auth/SecondaryAuthenticationProvider.php
new file mode 100644
index 00000000..c55e65d5
--- /dev/null
+++ b/www/wiki/includes/auth/SecondaryAuthenticationProvider.php
@@ -0,0 +1,258 @@
+<?php
+/**
+ * Secondary authentication provider interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use StatusValue;
+use User;
+
+/**
+ * A secondary provider mostly acts when the submitted authentication data has
+ * already been associated to a MediaWiki user account.
+ *
+ * For login, a secondary provider performs additional authentication steps
+ * after a PrimaryAuthenticationProvider has identified which MediaWiki user is
+ * trying to log in. For example, it might implement a password reset, request
+ * the second factor for two-factor auth, or prevent the login if the account is blocked.
+ *
+ * For account creation, a secondary provider performs optional extra steps after
+ * a PrimaryAuthenticationProvider has created the user; for example, it can collect
+ * further user information such as a biography.
+ *
+ * (For account linking, secondary providers are not involved.)
+ *
+ * This interface also provides methods for changing authentication data such
+ * as a second-factor token, and callbacks that are invoked after login / account creation
+ * succeeded or failed.
+ *
+ * @ingroup Auth
+ * @since 1.27
+ * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
+ */
+interface SecondaryAuthenticationProvider extends AuthenticationProvider {
+
+ /**
+ * Start an authentication flow
+ *
+ * Note that this may be called for a user even if
+ * beginSecondaryAccountCreation() was never called. The module should take
+ * the opportunity to do any necessary setup in that case.
+ *
+ * @param User $user User being authenticated. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user is authenticated. Additional secondary providers may run.
+ * - FAIL: The user is not authenticated. Fail the authentication process.
+ * - ABSTAIN: Additional secondary providers may run.
+ * - UI: Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: Redirection to a third party is needed to complete the process.
+ */
+ public function beginSecondaryAuthentication( $user, array $reqs );
+
+ /**
+ * Continue an authentication flow
+ * @param User $user User being authenticated. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user is authenticated. Additional secondary providers may run.
+ * - FAIL: The user is not authenticated. Fail the authentication process.
+ * - ABSTAIN: Additional secondary providers may run.
+ * - UI: Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: Redirection to a third party is needed to complete the process.
+ */
+ public function continueSecondaryAuthentication( $user, array $reqs );
+
+ /**
+ * Post-login callback
+ *
+ * This will be called at the end of a login attempt. It will not be called for unfinished
+ * login attempts that fail by the session timing out.
+ *
+ * @param User|null $user User that was attempted to be logged in, if known.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param AuthenticationResponse $response Authentication response that will be returned
+ * (PASS or FAIL)
+ */
+ public function postAuthentication( $user, AuthenticationResponse $response );
+
+ /**
+ * Revoke the user's credentials
+ *
+ * This may cause the user to no longer exist for the provider, or the user
+ * may continue to exist in a "disabled" state.
+ *
+ * The intention is that the named account will never again be usable for
+ * normal login (i.e. there is no way to undo the revocation of access).
+ *
+ * @param string $username
+ */
+ public function providerRevokeAccessForUser( $username );
+
+ /**
+ * Determine whether a property can change
+ * @see AuthManager::allowsPropertyChange()
+ * @param string $property
+ * @return bool
+ */
+ public function providerAllowsPropertyChange( $property );
+
+ /**
+ * Validate a change of authentication data (e.g. passwords)
+ *
+ * Return StatusValue::newGood( 'ignored' ) if you don't support this
+ * AuthenticationRequest type.
+ *
+ * @param AuthenticationRequest $req
+ * @param bool $checkData If false, $req hasn't been loaded from the
+ * submission so checks on user-submitted fields should be skipped.
+ * $req->username is considered user-submitted for this purpose, even
+ * if it cannot be changed via $req->loadFromSubmission.
+ * @return StatusValue
+ */
+ public function providerAllowsAuthenticationDataChange(
+ AuthenticationRequest $req, $checkData = true
+ );
+
+ /**
+ * Change or remove authentication data (e.g. passwords)
+ *
+ * If $req was returned for AuthManager::ACTION_CHANGE, the corresponding
+ * credentials should result in a successful login in the future.
+ *
+ * If $req was returned for AuthManager::ACTION_REMOVE, the corresponding
+ * credentials should no longer result in a successful login.
+ *
+ * It can be assumed that providerAllowsAuthenticationDataChange with $checkData === true
+ * was called before this, and passed. This method should never fail (other than throwing an
+ * exception).
+ *
+ * @param AuthenticationRequest $req
+ */
+ public function providerChangeAuthenticationData( AuthenticationRequest $req );
+
+ /**
+ * Determine whether an account creation may begin
+ *
+ * Called from AuthManager::beginAccountCreation()
+ *
+ * @note No need to test if the account exists, AuthManager checks that
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return StatusValue
+ */
+ public function testForAccountCreation( $user, $creator, array $reqs );
+
+ /**
+ * Start an account creation flow
+ *
+ * @note There is no guarantee this will be called in a successful account
+ * creation process as the user can just abandon the process at any time
+ * after the primary provider has issued a PASS and still have a valid
+ * account. Be prepared to handle any database inconsistencies that result
+ * from this or continueSecondaryAccountCreation() not being called.
+ * @param User $user User being created (has been added to the database).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user creation is ok. Additional secondary providers may run.
+ * - ABSTAIN: Additional secondary providers may run.
+ * - UI: Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: Redirection to a third party is needed to complete the process.
+ */
+ public function beginSecondaryAccountCreation( $user, $creator, array $reqs );
+
+ /**
+ * Continue an authentication flow
+ *
+ * @param User $user User being created (has been added to the database).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationRequest[] $reqs
+ * @return AuthenticationResponse Expected responses:
+ * - PASS: The user creation is ok. Additional secondary providers may run.
+ * - ABSTAIN: Additional secondary providers may run.
+ * - UI: Additional AuthenticationRequests are needed to complete the process.
+ * - REDIRECT: Redirection to a third party is needed to complete the process.
+ */
+ public function continueSecondaryAccountCreation( $user, $creator, array $reqs );
+
+ /**
+ * Post-creation callback
+ *
+ * This will be called at the end of an account creation attempt. It will not be called if
+ * the account creation process results in a session timeout (possibly after a successful
+ * user creation, while a secondary provider is waiting for a response).
+ *
+ * @param User $user User that was attempted to be created.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param User $creator User doing the creation. This may become a
+ * "UserValue" in the future, or User may be refactored into such.
+ * @param AuthenticationResponse $response Authentication response that will be returned
+ * (PASS or FAIL)
+ */
+ public function postAccountCreation( $user, $creator, AuthenticationResponse $response );
+
+ /**
+ * Determine whether an account may be created
+ *
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param bool|string $autocreate False if this is not an auto-creation, or
+ * the source of the auto-creation passed to AuthManager::autoCreateUser().
+ * @param array $options
+ * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL
+ * - creating: (bool) If false (or missing), this call is only testing if
+ * a user could be created. If set, this (non-autocreation) is for
+ * actually creating an account and will be followed by a call to
+ * testForAccountCreation(). In this case, the provider might return
+ * StatusValue::newGood() here and let the later call to
+ * testForAccountCreation() do a more thorough test.
+ * @return StatusValue
+ */
+ public function testUserForCreation( $user, $autocreate, array $options = [] );
+
+ /**
+ * Post-auto-creation callback
+ * @param User $user User being created (has been added to the database now).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @param string $source The source of the auto-creation passed to
+ * AuthManager::autoCreateUser().
+ */
+ public function autoCreatedAccount( $user, $source );
+
+}
diff --git a/www/wiki/includes/auth/TemporaryPasswordAuthenticationRequest.php b/www/wiki/includes/auth/TemporaryPasswordAuthenticationRequest.php
new file mode 100644
index 00000000..bc7c779d
--- /dev/null
+++ b/www/wiki/includes/auth/TemporaryPasswordAuthenticationRequest.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This represents the intention to set a temporary password for the user.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class TemporaryPasswordAuthenticationRequest extends AuthenticationRequest {
+ /** @var string|null Temporary password */
+ public $password;
+
+ /** @var bool Email password to the user. */
+ public $mailpassword = false;
+
+ /** @var string Username or IP address of the caller */
+ public $caller;
+
+ public function getFieldInfo() {
+ return [
+ 'mailpassword' => [
+ 'type' => 'checkbox',
+ 'label' => wfMessage( 'createaccountmail' ),
+ 'help' => wfMessage( 'createaccountmail-help' ),
+ ],
+ ];
+ }
+
+ /**
+ * @param string|null $password
+ */
+ public function __construct( $password = null ) {
+ $this->password = $password;
+ if ( $password ) {
+ $this->mailpassword = true;
+ }
+ }
+
+ /**
+ * Return an instance with a new, random password
+ * @return TemporaryPasswordAuthenticationRequest
+ */
+ public static function newRandom() {
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+
+ // get the min password length
+ $minLength = $config->get( 'MinimalPasswordLength' );
+ $policy = $config->get( 'PasswordPolicy' );
+ foreach ( $policy['policies'] as $p ) {
+ if ( isset( $p['MinimalPasswordLength'] ) ) {
+ $minLength = max( $minLength, $p['MinimalPasswordLength'] );
+ }
+ if ( isset( $p['MinimalPasswordLengthToLogin'] ) ) {
+ $minLength = max( $minLength, $p['MinimalPasswordLengthToLogin'] );
+ }
+ }
+
+ $password = \PasswordFactory::generateRandomPasswordString( $minLength );
+
+ return new self( $password );
+ }
+
+ /**
+ * Return an instance with an invalid password
+ * @return TemporaryPasswordAuthenticationRequest
+ */
+ public static function newInvalid() {
+ $request = new self( null );
+ return $request;
+ }
+
+ public function describeCredentials() {
+ return [
+ 'provider' => wfMessage( 'authmanager-provider-temporarypassword' ),
+ 'account' => new \RawMessage( '$1', [ $this->username ] ),
+ ] + parent::describeCredentials();
+ }
+
+}
diff --git a/www/wiki/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php b/www/wiki/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php
new file mode 100644
index 00000000..4a2d0094
--- /dev/null
+++ b/www/wiki/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php
@@ -0,0 +1,477 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use User;
+
+/**
+ * A primary authentication provider that uses the temporary password field in
+ * the 'user' table.
+ *
+ * A successful login will force a password reset.
+ *
+ * @note For proper operation, this should generally come before any other
+ * password-based authentication providers.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class TemporaryPasswordPrimaryAuthenticationProvider
+ extends AbstractPasswordPrimaryAuthenticationProvider
+{
+ /** @var bool */
+ protected $emailEnabled = null;
+
+ /** @var int */
+ protected $newPasswordExpiry = null;
+
+ /** @var int */
+ protected $passwordReminderResendTime = null;
+
+ /**
+ * @param array $params
+ * - emailEnabled: (bool) must be true for the option to email passwords to be present
+ * - newPasswordExpiry: (int) expiraton time of temporary passwords, in seconds
+ * - passwordReminderResendTime: (int) cooldown period in hours until a password reminder can
+ * be sent to the same user again,
+ */
+ public function __construct( $params = [] ) {
+ parent::__construct( $params );
+
+ if ( isset( $params['emailEnabled'] ) ) {
+ $this->emailEnabled = (bool)$params['emailEnabled'];
+ }
+ if ( isset( $params['newPasswordExpiry'] ) ) {
+ $this->newPasswordExpiry = (int)$params['newPasswordExpiry'];
+ }
+ if ( isset( $params['passwordReminderResendTime'] ) ) {
+ $this->passwordReminderResendTime = $params['passwordReminderResendTime'];
+ }
+ }
+
+ public function setConfig( \Config $config ) {
+ parent::setConfig( $config );
+
+ if ( $this->emailEnabled === null ) {
+ $this->emailEnabled = $this->config->get( 'EnableEmail' );
+ }
+ if ( $this->newPasswordExpiry === null ) {
+ $this->newPasswordExpiry = $this->config->get( 'NewPasswordExpiry' );
+ }
+ if ( $this->passwordReminderResendTime === null ) {
+ $this->passwordReminderResendTime = $this->config->get( 'PasswordReminderResendTime' );
+ }
+ }
+
+ protected function getPasswordResetData( $username, $data ) {
+ // Always reset
+ return (object)[
+ 'msg' => wfMessage( 'resetpass-temp-emailed' ),
+ 'hard' => true,
+ ];
+ }
+
+ public function getAuthenticationRequests( $action, array $options ) {
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ return [ new PasswordAuthenticationRequest() ];
+
+ case AuthManager::ACTION_CHANGE:
+ return [ TemporaryPasswordAuthenticationRequest::newRandom() ];
+
+ case AuthManager::ACTION_CREATE:
+ if ( isset( $options['username'] ) && $this->emailEnabled ) {
+ // Creating an account for someone else
+ return [ TemporaryPasswordAuthenticationRequest::newRandom() ];
+ } else {
+ // It's not terribly likely that an anonymous user will
+ // be creating an account for someone else.
+ return [];
+ }
+
+ case AuthManager::ACTION_REMOVE:
+ return [ new TemporaryPasswordAuthenticationRequest ];
+
+ default:
+ return [];
+ }
+ }
+
+ public function beginPrimaryAuthentication( array $reqs ) {
+ $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class );
+ if ( !$req || $req->username === null || $req->password === null ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $username = User::getCanonicalName( $req->username, 'usable' );
+ if ( $username === false ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'user',
+ [
+ 'user_id', 'user_newpassword', 'user_newpass_time',
+ ],
+ [ 'user_name' => $username ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ return AuthenticationResponse::newAbstain();
+ }
+
+ $status = $this->checkPasswordValidity( $username, $req->password );
+ if ( !$status->isOK() ) {
+ // Fatal, can't log in
+ return AuthenticationResponse::newFail( $status->getMessage() );
+ }
+
+ $pwhash = $this->getPassword( $row->user_newpassword );
+ if ( !$pwhash->equals( $req->password ) ) {
+ return $this->failResponse( $req );
+ }
+
+ if ( !$this->isTimestampValid( $row->user_newpass_time ) ) {
+ return $this->failResponse( $req );
+ }
+
+ // Add an extra log entry since a temporary password is
+ // an unusual way to log in, so its important to keep track
+ // of in case of abuse.
+ $this->logger->info( "{user} successfully logged in using temp password",
+ [
+ 'user' => $username,
+ 'requestIP' => $this->manager->getRequest()->getIP()
+ ]
+ );
+
+ $this->setPasswordResetFlag( $username, $status );
+
+ return AuthenticationResponse::newPass( $username );
+ }
+
+ public function testUserCanAuthenticate( $username ) {
+ $username = User::getCanonicalName( $username, 'usable' );
+ if ( $username === false ) {
+ return false;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'user',
+ [ 'user_newpassword', 'user_newpass_time' ],
+ [ 'user_name' => $username ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ return false;
+ }
+
+ if ( $this->getPassword( $row->user_newpassword ) instanceof \InvalidPassword ) {
+ return false;
+ }
+
+ if ( !$this->isTimestampValid( $row->user_newpass_time ) ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function testUserExists( $username, $flags = User::READ_NORMAL ) {
+ $username = User::getCanonicalName( $username, 'usable' );
+ if ( $username === false ) {
+ return false;
+ }
+
+ list( $db, $options ) = \DBAccessObjectUtils::getDBOptions( $flags );
+ return (bool)wfGetDB( $db )->selectField(
+ [ 'user' ],
+ [ 'user_id' ],
+ [ 'user_name' => $username ],
+ __METHOD__,
+ $options
+ );
+ }
+
+ public function providerAllowsAuthenticationDataChange(
+ AuthenticationRequest $req, $checkData = true
+ ) {
+ if ( get_class( $req ) !== TemporaryPasswordAuthenticationRequest::class ) {
+ // We don't really ignore it, but this is what the caller expects.
+ return \StatusValue::newGood( 'ignored' );
+ }
+
+ if ( !$checkData ) {
+ return \StatusValue::newGood();
+ }
+
+ $username = User::getCanonicalName( $req->username, 'usable' );
+ if ( $username === false ) {
+ return \StatusValue::newGood( 'ignored' );
+ }
+
+ $row = wfGetDB( DB_MASTER )->selectRow(
+ 'user',
+ [ 'user_id', 'user_newpass_time' ],
+ [ 'user_name' => $username ],
+ __METHOD__
+ );
+
+ if ( !$row ) {
+ return \StatusValue::newGood( 'ignored' );
+ }
+
+ $sv = \StatusValue::newGood();
+ if ( $req->password !== null ) {
+ $sv->merge( $this->checkPasswordValidity( $username, $req->password ) );
+
+ if ( $req->mailpassword ) {
+ if ( !$this->emailEnabled ) {
+ return \StatusValue::newFatal( 'passwordreset-emaildisabled' );
+ }
+
+ // We don't check whether the user has an email address;
+ // that information should not be exposed to the caller.
+
+ // do not allow temporary password creation within
+ // $wgPasswordReminderResendTime from the last attempt
+ if (
+ $this->passwordReminderResendTime
+ && $row->user_newpass_time
+ && time() < wfTimestamp( TS_UNIX, $row->user_newpass_time )
+ + $this->passwordReminderResendTime * 3600
+ ) {
+ // Round the time in hours to 3 d.p., in case someone is specifying
+ // minutes or seconds.
+ return \StatusValue::newFatal( 'throttled-mailpassword',
+ round( $this->passwordReminderResendTime, 3 ) );
+ }
+
+ if ( !$req->caller ) {
+ return \StatusValue::newFatal( 'passwordreset-nocaller' );
+ }
+ if ( !\IP::isValid( $req->caller ) ) {
+ $caller = User::newFromName( $req->caller );
+ if ( !$caller ) {
+ return \StatusValue::newFatal( 'passwordreset-nosuchcaller', $req->caller );
+ }
+ }
+ }
+ }
+ return $sv;
+ }
+
+ public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
+ $username = $req->username !== null ? User::getCanonicalName( $req->username, 'usable' ) : false;
+ if ( $username === false ) {
+ return;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ $sendMail = false;
+ if ( $req->action !== AuthManager::ACTION_REMOVE &&
+ get_class( $req ) === TemporaryPasswordAuthenticationRequest::class
+ ) {
+ $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password );
+ $newpassTime = $dbw->timestamp();
+ $sendMail = $req->mailpassword;
+ } else {
+ // Invalidate the temporary password when any other auth is reset, or when removing
+ $pwhash = $this->getPasswordFactory()->newFromCiphertext( null );
+ $newpassTime = null;
+ }
+
+ $dbw->update(
+ 'user',
+ [
+ 'user_newpassword' => $pwhash->toString(),
+ 'user_newpass_time' => $newpassTime,
+ ],
+ [ 'user_name' => $username ],
+ __METHOD__
+ );
+
+ if ( $sendMail ) {
+ // Send email after DB commit
+ $dbw->onTransactionIdle(
+ function () use ( $req ) {
+ /** @var TemporaryPasswordAuthenticationRequest $req */
+ $this->sendPasswordResetEmail( $req );
+ },
+ __METHOD__
+ );
+ }
+ }
+
+ public function accountCreationType() {
+ return self::TYPE_CREATE;
+ }
+
+ public function testForAccountCreation( $user, $creator, array $reqs ) {
+ /** @var TemporaryPasswordAuthenticationRequest $req */
+ $req = AuthenticationRequest::getRequestByClass(
+ $reqs, TemporaryPasswordAuthenticationRequest::class
+ );
+
+ $ret = \StatusValue::newGood();
+ if ( $req ) {
+ if ( $req->mailpassword ) {
+ if ( !$this->emailEnabled ) {
+ $ret->merge( \StatusValue::newFatal( 'emaildisabled' ) );
+ } elseif ( !$user->getEmail() ) {
+ $ret->merge( \StatusValue::newFatal( 'noemailcreate' ) );
+ }
+ }
+
+ $ret->merge(
+ $this->checkPasswordValidity( $user->getName(), $req->password )
+ );
+ }
+ return $ret;
+ }
+
+ public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
+ /** @var TemporaryPasswordAuthenticationRequest $req */
+ $req = AuthenticationRequest::getRequestByClass(
+ $reqs, TemporaryPasswordAuthenticationRequest::class
+ );
+ if ( $req ) {
+ if ( $req->username !== null && $req->password !== null ) {
+ // Nothing we can do yet, because the user isn't in the DB yet
+ if ( $req->username !== $user->getName() ) {
+ $req = clone $req;
+ $req->username = $user->getName();
+ }
+
+ if ( $req->mailpassword ) {
+ // prevent EmailNotificationSecondaryAuthenticationProvider from sending another mail
+ $this->manager->setAuthenticationSessionData( 'no-email', true );
+ }
+
+ $ret = AuthenticationResponse::newPass( $req->username );
+ $ret->createRequest = $req;
+ return $ret;
+ }
+ }
+ return AuthenticationResponse::newAbstain();
+ }
+
+ public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) {
+ /** @var TemporaryPasswordAuthenticationRequest $req */
+ $req = $res->createRequest;
+ $mailpassword = $req->mailpassword;
+ $req->mailpassword = false; // providerChangeAuthenticationData would send the wrong email
+
+ // Now that the user is in the DB, set the password on it.
+ $this->providerChangeAuthenticationData( $req );
+
+ if ( $mailpassword ) {
+ // Send email after DB commit
+ wfGetDB( DB_MASTER )->onTransactionIdle(
+ function () use ( $user, $creator, $req ) {
+ $this->sendNewAccountEmail( $user, $creator, $req->password );
+ },
+ __METHOD__
+ );
+ }
+
+ return $mailpassword ? 'byemail' : null;
+ }
+
+ /**
+ * Check that a temporary password is still valid (hasn't expired).
+ * @param string $timestamp A timestamp in MediaWiki (TS_MW) format
+ * @return bool
+ */
+ protected function isTimestampValid( $timestamp ) {
+ $time = wfTimestampOrNull( TS_MW, $timestamp );
+ if ( $time !== null ) {
+ $expiry = wfTimestamp( TS_UNIX, $time ) + $this->newPasswordExpiry;
+ if ( time() >= $expiry ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Send an email about the new account creation and the temporary password.
+ * @param User $user The new user account
+ * @param User $creatingUser The user who created the account (can be anonymous)
+ * @param string $password The temporary password
+ * @return \Status
+ */
+ protected function sendNewAccountEmail( User $user, User $creatingUser, $password ) {
+ $ip = $creatingUser->getRequest()->getIP();
+ // @codeCoverageIgnoreStart
+ if ( !$ip ) {
+ return \Status::newFatal( 'badipaddress' );
+ }
+ // @codeCoverageIgnoreEnd
+
+ \Hooks::run( 'User::mailPasswordInternal', [ &$creatingUser, &$ip, &$user ] );
+
+ $mainPageUrl = \Title::newMainPage()->getCanonicalURL();
+ $userLanguage = $user->getOption( 'language' );
+ $subjectMessage = wfMessage( 'createaccount-title' )->inLanguage( $userLanguage );
+ $bodyMessage = wfMessage( 'createaccount-text', $ip, $user->getName(), $password,
+ '<' . $mainPageUrl . '>', round( $this->newPasswordExpiry / 86400 ) )
+ ->inLanguage( $userLanguage );
+
+ $status = $user->sendMail( $subjectMessage->text(), $bodyMessage->text() );
+
+ // TODO show 'mailerror' message on error, 'accmailtext' success message otherwise?
+ // @codeCoverageIgnoreStart
+ if ( !$status->isGood() ) {
+ $this->logger->warning( 'Could not send account creation email: ' .
+ $status->getWikiText( false, false, 'en' ) );
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $status;
+ }
+
+ /**
+ * @param TemporaryPasswordAuthenticationRequest $req
+ * @return \Status
+ */
+ protected function sendPasswordResetEmail( TemporaryPasswordAuthenticationRequest $req ) {
+ $user = User::newFromName( $req->username );
+ if ( !$user ) {
+ return \Status::newFatal( 'noname' );
+ }
+ $userLanguage = $user->getOption( 'language' );
+ $callerIsAnon = \IP::isValid( $req->caller );
+ $callerName = $callerIsAnon ? $req->caller : User::newFromName( $req->caller )->getName();
+ $passwordMessage = wfMessage( 'passwordreset-emailelement', $user->getName(),
+ $req->password )->inLanguage( $userLanguage );
+ $emailMessage = wfMessage( $callerIsAnon ? 'passwordreset-emailtext-ip'
+ : 'passwordreset-emailtext-user' )->inLanguage( $userLanguage );
+ $emailMessage->params( $callerName, $passwordMessage->text(), 1,
+ '<' . \Title::newMainPage()->getCanonicalURL() . '>',
+ round( $this->newPasswordExpiry / 86400 ) );
+ $emailTitle = wfMessage( 'passwordreset-emailtitle' )->inLanguage( $userLanguage );
+ return $user->sendMail( $emailTitle->text(), $emailMessage->text() );
+ }
+}
diff --git a/www/wiki/includes/auth/ThrottlePreAuthenticationProvider.php b/www/wiki/includes/auth/ThrottlePreAuthenticationProvider.php
new file mode 100644
index 00000000..ae0bc6bb
--- /dev/null
+++ b/www/wiki/includes/auth/ThrottlePreAuthenticationProvider.php
@@ -0,0 +1,180 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use BagOStuff;
+use Config;
+
+/**
+ * A pre-authentication provider to throttle authentication actions.
+ *
+ * Adding this provider will throttle account creations and primary authentication attempts
+ * (more specifically, any authentication that returns FAIL on failure). Secondary authentication
+ * cannot be easily throttled on a framework level (since it would typically return UI on failure);
+ * secondary providers are expected to do their own throttling.
+ * @ingroup Auth
+ * @since 1.27
+ */
+class ThrottlePreAuthenticationProvider extends AbstractPreAuthenticationProvider {
+ /** @var array */
+ protected $throttleSettings;
+
+ /** @var Throttler */
+ protected $accountCreationThrottle;
+
+ /** @var Throttler */
+ protected $passwordAttemptThrottle;
+
+ /** @var BagOStuff */
+ protected $cache;
+
+ /**
+ * @param array $params
+ * - accountCreationThrottle: (array) Condition array for the account creation throttle; an array
+ * of arrays in a format like $wgPasswordAttemptThrottle, passed to the Throttler constructor.
+ * - passwordAttemptThrottle: (array) Condition array for the password attempt throttle, in the
+ * same format as accountCreationThrottle.
+ * - cache: (BagOStuff) Where to store the throttle, defaults to the local cluster instance.
+ */
+ public function __construct( $params = [] ) {
+ $this->throttleSettings = array_intersect_key( $params,
+ [ 'accountCreationThrottle' => true, 'passwordAttemptThrottle' => true ] );
+ $this->cache = isset( $params['cache'] ) ? $params['cache'] :
+ \ObjectCache::getLocalClusterInstance();
+ }
+
+ public function setConfig( Config $config ) {
+ parent::setConfig( $config );
+
+ $accountCreationThrottle = $this->config->get( 'AccountCreationThrottle' );
+ // Handle old $wgAccountCreationThrottle format (number of attempts per 24 hours)
+ if ( !is_array( $accountCreationThrottle ) ) {
+ $accountCreationThrottle = [ [
+ 'count' => $accountCreationThrottle,
+ 'seconds' => 86400,
+ ] ];
+ }
+
+ // @codeCoverageIgnoreStart
+ $this->throttleSettings += [
+ // @codeCoverageIgnoreEnd
+ 'accountCreationThrottle' => $accountCreationThrottle,
+ 'passwordAttemptThrottle' => $this->config->get( 'PasswordAttemptThrottle' ),
+ ];
+
+ if ( !empty( $this->throttleSettings['accountCreationThrottle'] ) ) {
+ $this->accountCreationThrottle = new Throttler(
+ $this->throttleSettings['accountCreationThrottle'], [
+ 'type' => 'acctcreate',
+ 'cache' => $this->cache,
+ ]
+ );
+ }
+ if ( !empty( $this->throttleSettings['passwordAttemptThrottle'] ) ) {
+ $this->passwordAttemptThrottle = new Throttler(
+ $this->throttleSettings['passwordAttemptThrottle'], [
+ 'type' => 'password',
+ 'cache' => $this->cache,
+ ]
+ );
+ }
+ }
+
+ public function testForAccountCreation( $user, $creator, array $reqs ) {
+ if ( !$this->accountCreationThrottle || !$creator->isPingLimitable() ) {
+ return \StatusValue::newGood();
+ }
+
+ $ip = $this->manager->getRequest()->getIP();
+
+ if ( !\Hooks::run( 'ExemptFromAccountCreationThrottle', [ $ip ] ) ) {
+ $this->logger->debug( __METHOD__ . ": a hook allowed account creation w/o throttle\n" );
+ return \StatusValue::newGood();
+ }
+
+ $result = $this->accountCreationThrottle->increase( null, $ip, __METHOD__ );
+ if ( $result ) {
+ $message = wfMessage( 'acct_creation_throttle_hit' )->params( $result['count'] )
+ ->durationParams( $result['wait'] );
+ return \StatusValue::newFatal( $message );
+ }
+
+ return \StatusValue::newGood();
+ }
+
+ public function testForAuthentication( array $reqs ) {
+ if ( !$this->passwordAttemptThrottle ) {
+ return \StatusValue::newGood();
+ }
+
+ $ip = $this->manager->getRequest()->getIP();
+ try {
+ $username = AuthenticationRequest::getUsernameFromRequests( $reqs );
+ } catch ( \UnexpectedValueException $e ) {
+ $username = '';
+ }
+
+ // Get everything this username could normalize to, and throttle each one individually.
+ // If nothing uses usernames, just throttle by IP.
+ $usernames = $this->manager->normalizeUsername( $username );
+ $result = false;
+ foreach ( $usernames as $name ) {
+ $r = $this->passwordAttemptThrottle->increase( $name, $ip, __METHOD__ );
+ if ( $r && ( !$result || $result['wait'] < $r['wait'] ) ) {
+ $result = $r;
+ }
+ }
+
+ if ( $result ) {
+ $message = wfMessage( 'login-throttled' )->durationParams( $result['wait'] );
+ return \StatusValue::newFatal( $message );
+ } else {
+ $this->manager->setAuthenticationSessionData( 'LoginThrottle',
+ [ 'users' => $usernames, 'ip' => $ip ] );
+ return \StatusValue::newGood();
+ }
+ }
+
+ /**
+ * @param null|\User $user
+ * @param AuthenticationResponse $response
+ */
+ public function postAuthentication( $user, AuthenticationResponse $response ) {
+ if ( $response->status !== AuthenticationResponse::PASS ) {
+ return;
+ } elseif ( !$this->passwordAttemptThrottle ) {
+ return;
+ }
+
+ $data = $this->manager->getAuthenticationSessionData( 'LoginThrottle' );
+ if ( !$data ) {
+ // this can occur when login is happening via AuthenticationRequest::$loginRequest
+ // so testForAuthentication is skipped
+ $this->logger->info( 'throttler data not found for {user}', [ 'user' => $user->getName() ] );
+ return;
+ }
+
+ foreach ( $data['users'] as $name ) {
+ $this->passwordAttemptThrottle->clear( $name, $data['ip'] );
+ }
+ }
+}
diff --git a/www/wiki/includes/auth/Throttler.php b/www/wiki/includes/auth/Throttler.php
new file mode 100644
index 00000000..3125bd3f
--- /dev/null
+++ b/www/wiki/includes/auth/Throttler.php
@@ -0,0 +1,208 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use BagOStuff;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\LogLevel;
+
+/**
+ * A helper class for throttling authentication attempts.
+ * @package MediaWiki\Auth
+ * @ingroup Auth
+ * @since 1.27
+ */
+class Throttler implements LoggerAwareInterface {
+ /** @var string */
+ protected $type;
+ /**
+ * See documentation of $wgPasswordAttemptThrottle for format. Old (pre-1.27) format is not
+ * allowed here.
+ * @var array
+ * @see https://www.mediawiki.org/wiki/Manual:$wgPasswordAttemptThrottle
+ */
+ protected $conditions;
+ /** @var BagOStuff */
+ protected $cache;
+ /** @var LoggerInterface */
+ protected $logger;
+ /** @var int|float */
+ protected $warningLimit;
+
+ /**
+ * @param array $conditions An array of arrays describing throttling conditions.
+ * Defaults to $wgPasswordAttemptThrottle. See documentation of that variable for format.
+ * @param array $params Parameters (all optional):
+ * - type: throttle type, used as a namespace for counters,
+ * - cache: a BagOStuff object where throttle counters are stored.
+ * - warningLimit: the log level will be raised to warning when rejecting an attempt after
+ * no less than this many failures.
+ */
+ public function __construct( array $conditions = null, array $params = [] ) {
+ $invalidParams = array_diff_key( $params,
+ array_fill_keys( [ 'type', 'cache', 'warningLimit' ], true ) );
+ if ( $invalidParams ) {
+ throw new \InvalidArgumentException( 'unrecognized parameters: '
+ . implode( ', ', array_keys( $invalidParams ) ) );
+ }
+
+ if ( $conditions === null ) {
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ $conditions = $config->get( 'PasswordAttemptThrottle' );
+ $params += [
+ 'type' => 'password',
+ 'cache' => \ObjectCache::getLocalClusterInstance(),
+ 'warningLimit' => 50,
+ ];
+ } else {
+ $params += [
+ 'type' => 'custom',
+ 'cache' => \ObjectCache::getLocalClusterInstance(),
+ 'warningLimit' => INF,
+ ];
+ }
+
+ $this->type = $params['type'];
+ $this->conditions = static::normalizeThrottleConditions( $conditions );
+ $this->cache = $params['cache'];
+ $this->warningLimit = $params['warningLimit'];
+
+ $this->setLogger( LoggerFactory::getInstance( 'throttler' ) );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Increase the throttle counter and return whether the attempt should be throttled.
+ *
+ * Should be called before an authentication attempt.
+ *
+ * @param string|null $username
+ * @param string|null $ip
+ * @param string|null $caller The authentication method from which we were called.
+ * @return array|false False if the attempt should not be throttled, an associative array
+ * with three keys otherwise:
+ * - throttleIndex: which throttle condition was met (a key of the conditions array)
+ * - count: throttle count (ie. number of failed attempts)
+ * - wait: time in seconds until authentication can be attempted
+ */
+ public function increase( $username = null, $ip = null, $caller = null ) {
+ if ( $username === null && $ip === null ) {
+ throw new \InvalidArgumentException( 'Either username or IP must be set for throttling' );
+ }
+
+ $userKey = $username ? md5( $username ) : null;
+ foreach ( $this->conditions as $index => $throttleCondition ) {
+ $ipKey = isset( $throttleCondition['allIPs'] ) ? null : $ip;
+ $count = $throttleCondition['count'];
+ $expiry = $throttleCondition['seconds'];
+
+ // a limit of 0 is used as a disable flag in some throttling configuration settings
+ // throttling the whole world is probably a bad idea
+ if ( !$count || $userKey === null && $ipKey === null ) {
+ continue;
+ }
+
+ $throttleKey = $this->cache->makeGlobalKey( 'throttler', $this->type, $index, $ipKey, $userKey );
+ $throttleCount = $this->cache->get( $throttleKey );
+
+ if ( !$throttleCount ) { // counter not started yet
+ $this->cache->add( $throttleKey, 1, $expiry );
+ } elseif ( $throttleCount < $count ) { // throttle limited not yet reached
+ $this->cache->incr( $throttleKey );
+ } else { // throttled
+ $this->logRejection( [
+ 'throttle' => $this->type,
+ 'index' => $index,
+ 'ip' => $ipKey,
+ 'username' => $username,
+ 'count' => $count,
+ 'expiry' => $expiry,
+ // @codeCoverageIgnoreStart
+ 'method' => $caller ?: __METHOD__,
+ // @codeCoverageIgnoreEnd
+ ] );
+
+ return [
+ 'throttleIndex' => $index,
+ 'count' => $count,
+ 'wait' => $expiry,
+ ];
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Clear the throttle counter.
+ *
+ * Should be called after a successful authentication attempt.
+ *
+ * @param string|null $username
+ * @param string|null $ip
+ * @throws \MWException
+ */
+ public function clear( $username = null, $ip = null ) {
+ $userKey = $username ? md5( $username ) : null;
+ foreach ( $this->conditions as $index => $specificThrottle ) {
+ $ipKey = isset( $specificThrottle['allIPs'] ) ? null : $ip;
+ $throttleKey = $this->cache->makeGlobalKey( 'throttler', $this->type, $index, $ipKey, $userKey );
+ $this->cache->delete( $throttleKey );
+ }
+ }
+
+ /**
+ * Handles B/C for $wgPasswordAttemptThrottle.
+ * @param array $throttleConditions
+ * @return array
+ * @see $wgPasswordAttemptThrottle for structure
+ */
+ protected static function normalizeThrottleConditions( $throttleConditions ) {
+ if ( !is_array( $throttleConditions ) ) {
+ return [];
+ }
+ if ( isset( $throttleConditions['count'] ) ) { // old style
+ $throttleConditions = [ $throttleConditions ];
+ }
+ return $throttleConditions;
+ }
+
+ protected function logRejection( array $context ) {
+ $logMsg = 'Throttle {throttle} hit, throttled for {expiry} seconds due to {count} attempts '
+ . 'from username {username} and IP {ip}';
+
+ // If we are hitting a throttle for >= warningLimit attempts, it is much more likely to be
+ // an attack than someone simply forgetting their password, so log it at a higher level.
+ $level = $context['count'] >= $this->warningLimit ? LogLevel::WARNING : LogLevel::INFO;
+
+ // It should be noted that once the throttle is hit, every attempt to login will
+ // generate the log message until the throttle expires, not just the attempt that
+ // puts the throttle over the top.
+ $this->logger->log( $level, $logMsg, $context );
+ }
+
+}
diff --git a/www/wiki/includes/auth/UserDataAuthenticationRequest.php b/www/wiki/includes/auth/UserDataAuthenticationRequest.php
new file mode 100644
index 00000000..35d66523
--- /dev/null
+++ b/www/wiki/includes/auth/UserDataAuthenticationRequest.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+use MediaWiki\MediaWikiServices;
+use StatusValue;
+use User;
+
+/**
+ * This represents additional user data requested on the account creation form
+ *
+ * @ingroup Auth
+ * @since 1.27
+ */
+class UserDataAuthenticationRequest extends AuthenticationRequest {
+ /** @var string|null Email address */
+ public $email;
+
+ /** @var string|null Real name */
+ public $realname;
+
+ public function getFieldInfo() {
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ $ret = [
+ 'email' => [
+ 'type' => 'string',
+ 'label' => wfMessage( 'authmanager-email-label' ),
+ 'help' => wfMessage( 'authmanager-email-help' ),
+ 'optional' => true,
+ ],
+ 'realname' => [
+ 'type' => 'string',
+ 'label' => wfMessage( 'authmanager-realname-label' ),
+ 'help' => wfMessage( 'authmanager-realname-help' ),
+ 'optional' => true,
+ ],
+ ];
+
+ if ( !$config->get( 'EnableEmail' ) ) {
+ unset( $ret['email'] );
+ }
+
+ if ( in_array( 'realname', $config->get( 'HiddenPrefs' ), true ) ) {
+ unset( $ret['realname'] );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Add data to the User object
+ * @param User $user User being created (not added to the database yet).
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ * @return StatusValue
+ */
+ public function populateUser( $user ) {
+ if ( $this->email !== null && $this->email !== '' ) {
+ if ( !\Sanitizer::validateEmail( $this->email ) ) {
+ return StatusValue::newFatal( 'invalidemailaddress' );
+ }
+ $user->setEmail( $this->email );
+ }
+ if ( $this->realname !== null && $this->realname !== '' ) {
+ $user->setRealName( $this->realname );
+ }
+ return StatusValue::newGood();
+ }
+
+}
diff --git a/www/wiki/includes/auth/UsernameAuthenticationRequest.php b/www/wiki/includes/auth/UsernameAuthenticationRequest.php
new file mode 100644
index 00000000..7bf8f130
--- /dev/null
+++ b/www/wiki/includes/auth/UsernameAuthenticationRequest.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Auth
+ */
+
+namespace MediaWiki\Auth;
+
+/**
+ * AuthenticationRequest to ensure something with a username is present
+ * @ingroup Auth
+ * @since 1.27
+ */
+class UsernameAuthenticationRequest extends AuthenticationRequest {
+ public function getFieldInfo() {
+ return [
+ 'username' => [
+ 'type' => 'string',
+ 'label' => wfMessage( 'userlogin-yourname' ),
+ 'help' => wfMessage( 'authmanager-username-help' ),
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/includes/cache/BacklinkCache.php b/www/wiki/includes/cache/BacklinkCache.php
new file mode 100644
index 00000000..4341daaf
--- /dev/null
+++ b/www/wiki/includes/cache/BacklinkCache.php
@@ -0,0 +1,546 @@
+<?php
+/**
+ * Class for fetching backlink lists, approximate backlink counts and
+ * partitions.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Tim Starling
+ * @copyright © 2009, Tim Starling, Domas Mituzas
+ * @copyright © 2010, Max Sem
+ * @copyright © 2011, Antoine Musso
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Class for fetching backlink lists, approximate backlink counts and
+ * partitions. This is a shared cache.
+ *
+ * Instances of this class should typically be fetched with the method
+ * $title->getBacklinkCache().
+ *
+ * Ideally you should only get your backlinks from here when you think
+ * there is some advantage in caching them. Otherwise it's just a waste
+ * of memory.
+ *
+ * Introduced by r47317
+ */
+class BacklinkCache {
+ /** @var BacklinkCache */
+ protected static $instance;
+
+ /**
+ * Multi dimensions array representing batches. Keys are:
+ * > (string) links table name
+ * > (int) batch size
+ * > 'numRows' : Number of rows for this link table
+ * > 'batches' : [ $start, $end ]
+ *
+ * @see BacklinkCache::partitionResult()
+ *
+ * Cleared with BacklinkCache::clear()
+ * @var array[]
+ */
+ protected $partitionCache = [];
+
+ /**
+ * Contains the whole links from a database result.
+ * This is raw data that will be partitioned in $partitionCache
+ *
+ * Initialized with BacklinkCache::getLinks()
+ * Cleared with BacklinkCache::clear()
+ * @var ResultWrapper[]
+ */
+ protected $fullResultCache = [];
+
+ /**
+ * Local copy of a database object.
+ *
+ * Accessor: BacklinkCache::getDB()
+ * Mutator : BacklinkCache::setDB()
+ * Cleared with BacklinkCache::clear()
+ */
+ protected $db;
+
+ /**
+ * Local copy of a Title object
+ */
+ protected $title;
+
+ const CACHE_EXPIRY = 3600;
+
+ /**
+ * Create a new BacklinkCache
+ *
+ * @param Title $title : Title object to create a backlink cache for
+ */
+ public function __construct( Title $title ) {
+ $this->title = $title;
+ }
+
+ /**
+ * Create a new BacklinkCache or reuse any existing one.
+ * Currently, only one cache instance can exist; callers that
+ * need multiple backlink cache objects should keep them in scope.
+ *
+ * @param Title $title Title object to get a backlink cache for
+ * @return BacklinkCache
+ */
+ public static function get( Title $title ) {
+ if ( !self::$instance || !self::$instance->title->equals( $title ) ) {
+ self::$instance = new self( $title );
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Serialization handler, diasallows to serialize the database to prevent
+ * failures after this class is deserialized from cache with dead DB
+ * connection.
+ *
+ * @return array
+ */
+ function __sleep() {
+ return [ 'partitionCache', 'fullResultCache', 'title' ];
+ }
+
+ /**
+ * Clear locally stored data and database object.
+ */
+ public function clear() {
+ $this->partitionCache = [];
+ $this->fullResultCache = [];
+ unset( $this->db );
+ }
+
+ /**
+ * Set the Database object to use
+ *
+ * @param IDatabase $db
+ */
+ public function setDB( $db ) {
+ $this->db = $db;
+ }
+
+ /**
+ * Get the replica DB connection to the database
+ * When non existing, will initialize the connection.
+ * @return IDatabase
+ */
+ protected function getDB() {
+ if ( !isset( $this->db ) ) {
+ $this->db = wfGetDB( DB_REPLICA );
+ }
+
+ return $this->db;
+ }
+
+ /**
+ * Get the backlinks for a given table. Cached in process memory only.
+ * @param string $table
+ * @param int|bool $startId
+ * @param int|bool $endId
+ * @param int $max
+ * @return TitleArrayFromResult
+ */
+ public function getLinks( $table, $startId = false, $endId = false, $max = INF ) {
+ return TitleArray::newFromResult( $this->queryLinks( $table, $startId, $endId, $max ) );
+ }
+
+ /**
+ * Get the backlinks for a given table. Cached in process memory only.
+ * @param string $table
+ * @param int|bool $startId
+ * @param int|bool $endId
+ * @param int $max
+ * @param string $select 'all' or 'ids'
+ * @return ResultWrapper
+ */
+ protected function queryLinks( $table, $startId, $endId, $max, $select = 'all' ) {
+ $fromField = $this->getPrefix( $table ) . '_from';
+
+ if ( !$startId && !$endId && is_infinite( $max )
+ && isset( $this->fullResultCache[$table] )
+ ) {
+ wfDebug( __METHOD__ . ": got results from cache\n" );
+ $res = $this->fullResultCache[$table];
+ } else {
+ wfDebug( __METHOD__ . ": got results from DB\n" );
+ $conds = $this->getConditions( $table );
+ // Use the from field in the condition rather than the joined page_id,
+ // because databases are stupid and don't necessarily propagate indexes.
+ if ( $startId ) {
+ $conds[] = "$fromField >= " . intval( $startId );
+ }
+ if ( $endId ) {
+ $conds[] = "$fromField <= " . intval( $endId );
+ }
+ $options = [ 'ORDER BY' => $fromField ];
+ if ( is_finite( $max ) && $max > 0 ) {
+ $options['LIMIT'] = $max;
+ }
+
+ if ( $select === 'ids' ) {
+ // Just select from the backlink table and ignore the page JOIN
+ $res = $this->getDB()->select(
+ $table,
+ [ $this->getPrefix( $table ) . '_from AS page_id' ],
+ array_filter( $conds, function ( $clause ) { // kind of janky
+ return !preg_match( '/(\b|=)page_id(\b|=)/', $clause );
+ } ),
+ __METHOD__,
+ $options
+ );
+ } else {
+ // Select from the backlink table and JOIN with page title information
+ $res = $this->getDB()->select(
+ [ $table, 'page' ],
+ [ 'page_namespace', 'page_title', 'page_id' ],
+ $conds,
+ __METHOD__,
+ array_merge( [ 'STRAIGHT_JOIN' ], $options )
+ );
+ }
+
+ if ( $select === 'all' && !$startId && !$endId && $res->numRows() < $max ) {
+ // The full results fit within the limit, so cache them
+ $this->fullResultCache[$table] = $res;
+ } else {
+ wfDebug( __METHOD__ . ": results from DB were uncacheable\n" );
+ }
+ }
+
+ return $res;
+ }
+
+ /**
+ * Get the field name prefix for a given table
+ * @param string $table
+ * @throws MWException
+ * @return null|string
+ */
+ protected function getPrefix( $table ) {
+ static $prefixes = [
+ 'pagelinks' => 'pl',
+ 'imagelinks' => 'il',
+ 'categorylinks' => 'cl',
+ 'templatelinks' => 'tl',
+ 'redirect' => 'rd',
+ ];
+
+ if ( isset( $prefixes[$table] ) ) {
+ return $prefixes[$table];
+ } else {
+ $prefix = null;
+ Hooks::run( 'BacklinkCacheGetPrefix', [ $table, &$prefix ] );
+ if ( $prefix ) {
+ return $prefix;
+ } else {
+ throw new MWException( "Invalid table \"$table\" in " . __CLASS__ );
+ }
+ }
+ }
+
+ /**
+ * Get the SQL condition array for selecting backlinks, with a join
+ * on the page table.
+ * @param string $table
+ * @throws MWException
+ * @return array|null
+ */
+ protected function getConditions( $table ) {
+ $prefix = $this->getPrefix( $table );
+
+ switch ( $table ) {
+ case 'pagelinks':
+ case 'templatelinks':
+ $conds = [
+ "{$prefix}_namespace" => $this->title->getNamespace(),
+ "{$prefix}_title" => $this->title->getDBkey(),
+ "page_id={$prefix}_from"
+ ];
+ break;
+ case 'redirect':
+ $conds = [
+ "{$prefix}_namespace" => $this->title->getNamespace(),
+ "{$prefix}_title" => $this->title->getDBkey(),
+ $this->getDB()->makeList( [
+ "{$prefix}_interwiki" => '',
+ "{$prefix}_interwiki IS NULL",
+ ], LIST_OR ),
+ "page_id={$prefix}_from"
+ ];
+ break;
+ case 'imagelinks':
+ case 'categorylinks':
+ $conds = [
+ "{$prefix}_to" => $this->title->getDBkey(),
+ "page_id={$prefix}_from"
+ ];
+ break;
+ default:
+ $conds = null;
+ Hooks::run( 'BacklinkCacheGetConditions', [ $table, $this->title, &$conds ] );
+ if ( !$conds ) {
+ throw new MWException( "Invalid table \"$table\" in " . __CLASS__ );
+ }
+ }
+
+ return $conds;
+ }
+
+ /**
+ * Check if there are any backlinks
+ * @param string $table
+ * @return bool
+ */
+ public function hasLinks( $table ) {
+ return ( $this->getNumLinks( $table, 1 ) > 0 );
+ }
+
+ /**
+ * Get the approximate number of backlinks
+ * @param string $table
+ * @param int $max Only count up to this many backlinks
+ * @return int
+ */
+ public function getNumLinks( $table, $max = INF ) {
+ global $wgUpdateRowsPerJob;
+
+ $cache = ObjectCache::getMainWANInstance();
+ // 1) try partition cache ...
+ if ( isset( $this->partitionCache[$table] ) ) {
+ $entry = reset( $this->partitionCache[$table] );
+
+ return min( $max, $entry['numRows'] );
+ }
+
+ // 2) ... then try full result cache ...
+ if ( isset( $this->fullResultCache[$table] ) ) {
+ return min( $max, $this->fullResultCache[$table]->numRows() );
+ }
+
+ $memcKey = $cache->makeKey(
+ 'numbacklinks',
+ md5( $this->title->getPrefixedDBkey() ),
+ $table
+ );
+
+ // 3) ... fallback to memcached ...
+ $count = $cache->get( $memcKey );
+ if ( $count ) {
+ return min( $max, $count );
+ }
+
+ // 4) fetch from the database ...
+ if ( is_infinite( $max ) ) { // no limit at all
+ // Use partition() since it will batch the query and skip the JOIN.
+ // Use $wgUpdateRowsPerJob just to encourage cache reuse for jobs.
+ $this->partition( $table, $wgUpdateRowsPerJob ); // updates $this->partitionCache
+ return $this->partitionCache[$table][$wgUpdateRowsPerJob]['numRows'];
+ } else { // probably some sane limit
+ // Fetch the full title info, since the caller will likely need it next
+ $count = $this->getLinks( $table, false, false, $max )->count();
+ if ( $count < $max ) { // full count
+ $cache->set( $memcKey, $count, self::CACHE_EXPIRY );
+ }
+ }
+
+ return min( $max, $count );
+ }
+
+ /**
+ * Partition the backlinks into batches.
+ * Returns an array giving the start and end of each range. The first
+ * batch has a start of false, and the last batch has an end of false.
+ *
+ * @param string $table The links table name
+ * @param int $batchSize
+ * @return array
+ */
+ public function partition( $table, $batchSize ) {
+ // 1) try partition cache ...
+ if ( isset( $this->partitionCache[$table][$batchSize] ) ) {
+ wfDebug( __METHOD__ . ": got from partition cache\n" );
+
+ return $this->partitionCache[$table][$batchSize]['batches'];
+ }
+
+ $cache = ObjectCache::getMainWANInstance();
+ $this->partitionCache[$table][$batchSize] = false;
+ $cacheEntry =& $this->partitionCache[$table][$batchSize];
+
+ // 2) ... then try full result cache ...
+ if ( isset( $this->fullResultCache[$table] ) ) {
+ $cacheEntry = $this->partitionResult( $this->fullResultCache[$table], $batchSize );
+ wfDebug( __METHOD__ . ": got from full result cache\n" );
+
+ return $cacheEntry['batches'];
+ }
+
+ $memcKey = $cache->makeKey(
+ 'backlinks',
+ md5( $this->title->getPrefixedDBkey() ),
+ $table,
+ $batchSize
+ );
+
+ // 3) ... fallback to memcached ...
+ $memcValue = $cache->get( $memcKey );
+ if ( is_array( $memcValue ) ) {
+ $cacheEntry = $memcValue;
+ wfDebug( __METHOD__ . ": got from memcached $memcKey\n" );
+
+ return $cacheEntry['batches'];
+ }
+
+ // 4) ... finally fetch from the slow database :(
+ $cacheEntry = [ 'numRows' => 0, 'batches' => [] ]; // final result
+ // Do the selects in batches to avoid client-side OOMs (T45452).
+ // Use a LIMIT that plays well with $batchSize to keep equal sized partitions.
+ $selectSize = max( $batchSize, 200000 - ( 200000 % $batchSize ) );
+ $start = false;
+ do {
+ $res = $this->queryLinks( $table, $start, false, $selectSize, 'ids' );
+ $partitions = $this->partitionResult( $res, $batchSize, false );
+ // Merge the link count and range partitions for this chunk
+ $cacheEntry['numRows'] += $partitions['numRows'];
+ $cacheEntry['batches'] = array_merge( $cacheEntry['batches'], $partitions['batches'] );
+ if ( count( $partitions['batches'] ) ) {
+ list( , $lEnd ) = end( $partitions['batches'] );
+ $start = $lEnd + 1; // pick up after this inclusive range
+ }
+ } while ( $partitions['numRows'] >= $selectSize );
+ // Make sure the first range has start=false and the last one has end=false
+ if ( count( $cacheEntry['batches'] ) ) {
+ $cacheEntry['batches'][0][0] = false;
+ $cacheEntry['batches'][count( $cacheEntry['batches'] ) - 1][1] = false;
+ }
+
+ // Save partitions to memcached
+ $cache->set( $memcKey, $cacheEntry, self::CACHE_EXPIRY );
+
+ // Save backlink count to memcached
+ $memcKey = $cache->makeKey(
+ 'numbacklinks',
+ md5( $this->title->getPrefixedDBkey() ),
+ $table
+ );
+ $cache->set( $memcKey, $cacheEntry['numRows'], self::CACHE_EXPIRY );
+
+ wfDebug( __METHOD__ . ": got from database\n" );
+
+ return $cacheEntry['batches'];
+ }
+
+ /**
+ * Partition a DB result with backlinks in it into batches
+ * @param ResultWrapper $res Database result
+ * @param int $batchSize
+ * @param bool $isComplete Whether $res includes all the backlinks
+ * @throws MWException
+ * @return array
+ */
+ protected function partitionResult( $res, $batchSize, $isComplete = true ) {
+ $batches = [];
+ $numRows = $res->numRows();
+ $numBatches = ceil( $numRows / $batchSize );
+
+ for ( $i = 0; $i < $numBatches; $i++ ) {
+ if ( $i == 0 && $isComplete ) {
+ $start = false;
+ } else {
+ $rowNum = $i * $batchSize;
+ $res->seek( $rowNum );
+ $row = $res->fetchObject();
+ $start = (int)$row->page_id;
+ }
+
+ if ( $i == ( $numBatches - 1 ) && $isComplete ) {
+ $end = false;
+ } else {
+ $rowNum = min( $numRows - 1, ( $i + 1 ) * $batchSize - 1 );
+ $res->seek( $rowNum );
+ $row = $res->fetchObject();
+ $end = (int)$row->page_id;
+ }
+
+ # Sanity check order
+ if ( $start && $end && $start > $end ) {
+ throw new MWException( __METHOD__ . ': Internal error: query result out of order' );
+ }
+
+ $batches[] = [ $start, $end ];
+ }
+
+ return [ 'numRows' => $numRows, 'batches' => $batches ];
+ }
+
+ /**
+ * Get a Title iterator for cascade-protected template/file use backlinks
+ *
+ * @return TitleArray
+ * @since 1.25
+ */
+ public function getCascadeProtectedLinks() {
+ $dbr = $this->getDB();
+
+ // @todo: use UNION without breaking tests that use temp tables
+ $resSets = [];
+ $resSets[] = $dbr->select(
+ [ 'templatelinks', 'page_restrictions', 'page' ],
+ [ 'page_namespace', 'page_title', 'page_id' ],
+ [
+ 'tl_namespace' => $this->title->getNamespace(),
+ 'tl_title' => $this->title->getDBkey(),
+ 'tl_from = pr_page',
+ 'pr_cascade' => 1,
+ 'page_id = tl_from'
+ ],
+ __METHOD__,
+ [ 'DISTINCT' ]
+ );
+ if ( $this->title->getNamespace() == NS_FILE ) {
+ $resSets[] = $dbr->select(
+ [ 'imagelinks', 'page_restrictions', 'page' ],
+ [ 'page_namespace', 'page_title', 'page_id' ],
+ [
+ 'il_to' => $this->title->getDBkey(),
+ 'il_from = pr_page',
+ 'pr_cascade' => 1,
+ 'page_id = il_from'
+ ],
+ __METHOD__,
+ [ 'DISTINCT' ]
+ );
+ }
+
+ // Combine and de-duplicate the results
+ $mergedRes = [];
+ foreach ( $resSets as $res ) {
+ foreach ( $res as $row ) {
+ $mergedRes[$row->page_id] = $row;
+ }
+ }
+
+ return TitleArray::newFromResult(
+ new FakeResultWrapper( array_values( $mergedRes ) ) );
+ }
+}
diff --git a/www/wiki/includes/cache/CacheDependency.php b/www/wiki/includes/cache/CacheDependency.php
new file mode 100644
index 00000000..a59ba97d
--- /dev/null
+++ b/www/wiki/includes/cache/CacheDependency.php
@@ -0,0 +1,294 @@
+<?php
+/**
+ * Data caching with dependencies.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This class stores an arbitrary value along with its dependencies.
+ * Users should typically only use DependencyWrapper::getValueFromCache(),
+ * rather than instantiating one of these objects directly.
+ * @ingroup Cache
+ */
+class DependencyWrapper {
+ private $value;
+ /** @var CacheDependency[] */
+ private $deps;
+
+ /**
+ * Create an instance.
+ * @param mixed $value The user-supplied value
+ * @param CacheDependency|CacheDependency[] $deps A dependency or dependency
+ * array. All dependencies must be objects implementing CacheDependency.
+ */
+ function __construct( $value = false, $deps = [] ) {
+ $this->value = $value;
+
+ if ( !is_array( $deps ) ) {
+ $deps = [ $deps ];
+ }
+
+ $this->deps = $deps;
+ }
+
+ /**
+ * Returns true if any of the dependencies have expired
+ *
+ * @return bool
+ */
+ function isExpired() {
+ foreach ( $this->deps as $dep ) {
+ if ( $dep->isExpired() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Initialise dependency values in preparation for storing. This must be
+ * called before serialization.
+ */
+ function initialiseDeps() {
+ foreach ( $this->deps as $dep ) {
+ $dep->loadDependencyValues();
+ }
+ }
+
+ /**
+ * Get the user-defined value
+ * @return bool|mixed
+ */
+ function getValue() {
+ return $this->value;
+ }
+
+ /**
+ * Store the wrapper to a cache
+ *
+ * @param BagOStuff $cache
+ * @param string $key
+ * @param int $expiry
+ */
+ function storeToCache( $cache, $key, $expiry = 0 ) {
+ $this->initialiseDeps();
+ $cache->set( $key, $this, $expiry );
+ }
+
+ /**
+ * Attempt to get a value from the cache. If the value is expired or missing,
+ * it will be generated with the callback function (if present), and the newly
+ * calculated value will be stored to the cache in a wrapper.
+ *
+ * @param BagOStuff $cache A cache object
+ * @param string $key The cache key
+ * @param int $expiry The expiry timestamp or interval in seconds
+ * @param bool|callable $callback The callback for generating the value, or false
+ * @param array $callbackParams The function parameters for the callback
+ * @param array $deps The dependencies to store on a cache miss. Note: these
+ * are not the dependencies used on a cache hit! Cache hits use the stored
+ * dependency array.
+ *
+ * @return mixed The value, or null if it was not present in the cache and no
+ * callback was defined.
+ */
+ static function getValueFromCache( $cache, $key, $expiry = 0, $callback = false,
+ $callbackParams = [], $deps = []
+ ) {
+ $obj = $cache->get( $key );
+
+ if ( is_object( $obj ) && $obj instanceof DependencyWrapper && !$obj->isExpired() ) {
+ $value = $obj->value;
+ } elseif ( $callback ) {
+ $value = call_user_func_array( $callback, $callbackParams );
+ # Cache the newly-generated value
+ $wrapper = new DependencyWrapper( $value, $deps );
+ $wrapper->storeToCache( $cache, $key, $expiry );
+ } else {
+ $value = null;
+ }
+
+ return $value;
+ }
+}
+
+/**
+ * @ingroup Cache
+ */
+abstract class CacheDependency {
+ /**
+ * Returns true if the dependency is expired, false otherwise
+ */
+ abstract function isExpired();
+
+ /**
+ * Hook to perform any expensive pre-serialize loading of dependency values.
+ */
+ function loadDependencyValues() {
+ }
+}
+
+/**
+ * @ingroup Cache
+ */
+class FileDependency extends CacheDependency {
+ private $filename;
+ private $timestamp;
+
+ /**
+ * Create a file dependency
+ *
+ * @param string $filename The name of the file, preferably fully qualified
+ * @param null|bool|int $timestamp The unix last modified timestamp, or false if the
+ * file does not exist. If omitted, the timestamp will be loaded from
+ * the file.
+ *
+ * A dependency on a nonexistent file will be triggered when the file is
+ * created. A dependency on an existing file will be triggered when the
+ * file is changed.
+ */
+ function __construct( $filename, $timestamp = null ) {
+ $this->filename = $filename;
+ $this->timestamp = $timestamp;
+ }
+
+ /**
+ * @return array
+ */
+ function __sleep() {
+ $this->loadDependencyValues();
+
+ return [ 'filename', 'timestamp' ];
+ }
+
+ function loadDependencyValues() {
+ if ( is_null( $this->timestamp ) ) {
+ MediaWiki\suppressWarnings();
+ # Dependency on a non-existent file stores "false"
+ # This is a valid concept!
+ $this->timestamp = filemtime( $this->filename );
+ MediaWiki\restoreWarnings();
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ function isExpired() {
+ MediaWiki\suppressWarnings();
+ $lastmod = filemtime( $this->filename );
+ MediaWiki\restoreWarnings();
+ if ( $lastmod === false ) {
+ if ( $this->timestamp === false ) {
+ # Still nonexistent
+ return false;
+ } else {
+ # Deleted
+ wfDebug( "Dependency triggered: {$this->filename} deleted.\n" );
+
+ return true;
+ }
+ } else {
+ if ( $lastmod > $this->timestamp ) {
+ # Modified or created
+ wfDebug( "Dependency triggered: {$this->filename} changed.\n" );
+
+ return true;
+ } else {
+ # Not modified
+ return false;
+ }
+ }
+ }
+}
+
+/**
+ * @ingroup Cache
+ */
+class GlobalDependency extends CacheDependency {
+ private $name;
+ private $value;
+
+ function __construct( $name ) {
+ $this->name = $name;
+ $this->value = $GLOBALS[$name];
+ }
+
+ /**
+ * @return bool
+ */
+ function isExpired() {
+ if ( !isset( $GLOBALS[$this->name] ) ) {
+ return true;
+ }
+
+ return $GLOBALS[$this->name] != $this->value;
+ }
+}
+
+/**
+ * @ingroup Cache
+ */
+class MainConfigDependency extends CacheDependency {
+ private $name;
+ private $value;
+
+ function __construct( $name ) {
+ $this->name = $name;
+ $this->value = $this->getConfig()->get( $this->name );
+ }
+
+ private function getConfig() {
+ return MediaWikiServices::getInstance()->getMainConfig();
+ }
+
+ /**
+ * @return bool
+ */
+ function isExpired() {
+ if ( !$this->getConfig()->has( $this->name ) ) {
+ return true;
+ }
+
+ return $this->getConfig()->get( $this->name ) != $this->value;
+ }
+}
+
+/**
+ * @ingroup Cache
+ */
+class ConstantDependency extends CacheDependency {
+ private $name;
+ private $value;
+
+ function __construct( $name ) {
+ $this->name = $name;
+ $this->value = constant( $name );
+ }
+
+ /**
+ * @return bool
+ */
+ function isExpired() {
+ return constant( $this->name ) != $this->value;
+ }
+}
diff --git a/www/wiki/includes/cache/CacheHelper.php b/www/wiki/includes/cache/CacheHelper.php
new file mode 100644
index 00000000..8c70be24
--- /dev/null
+++ b/www/wiki/includes/cache/CacheHelper.php
@@ -0,0 +1,388 @@
+<?php
+/**
+ * Cache of various elements in a single cache entry.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GNU GPL v2 or later
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+
+/**
+ * Interface for all classes implementing CacheHelper functionality.
+ *
+ * @since 1.20
+ */
+interface ICacheHelper {
+ /**
+ * Sets if the cache should be enabled or not.
+ *
+ * @since 1.20
+ * @param bool $cacheEnabled
+ */
+ function setCacheEnabled( $cacheEnabled );
+
+ /**
+ * Initializes the caching.
+ * Should be called before the first time anything is added via addCachedHTML.
+ *
+ * @since 1.20
+ *
+ * @param int|null $cacheExpiry Sets the cache expiry, either ttl in seconds or unix timestamp.
+ * @param bool|null $cacheEnabled Sets if the cache should be enabled or not.
+ */
+ function startCache( $cacheExpiry = null, $cacheEnabled = null );
+
+ /**
+ * Get a cached value if available or compute it if not and then cache it if possible.
+ * The provided $computeFunction is only called when the computation needs to happen
+ * and should return a result value. $args are arguments that will be passed to the
+ * compute function when called.
+ *
+ * @since 1.20
+ *
+ * @param callable $computeFunction
+ * @param array|mixed $args
+ * @param string|null $key
+ *
+ * @return mixed
+ */
+ function getCachedValue( $computeFunction, $args = [], $key = null );
+
+ /**
+ * Saves the HTML to the cache in case it got recomputed.
+ * Should be called after the last time anything is added via addCachedHTML.
+ *
+ * @since 1.20
+ */
+ function saveCache();
+
+ /**
+ * Sets the time to live for the cache, in seconds or a unix timestamp
+ * indicating the point of expiry...
+ *
+ * @since 1.20
+ *
+ * @param int $cacheExpiry
+ */
+ function setExpiry( $cacheExpiry );
+}
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Helper class for caching various elements in a single cache entry.
+ *
+ * To get a cached value or compute it, use getCachedValue like this:
+ * $this->getCachedValue( $callback );
+ *
+ * To add HTML that should be cached, use addCachedHTML like this:
+ * $this->addCachedHTML( $callback );
+ *
+ * The callback function is only called when needed, so do all your expensive
+ * computations here. This function should returns the HTML to be cached.
+ * It should not add anything to the PageOutput object!
+ *
+ * Before the first addCachedHTML call, you should call $this->startCache();
+ * After adding the last HTML that should be cached, call $this->saveCache();
+ *
+ * @since 1.20
+ */
+class CacheHelper implements ICacheHelper {
+ /**
+ * The time to live for the cache, in seconds or a unix timestamp indicating the point of expiry.
+ *
+ * @since 1.20
+ * @var int
+ */
+ protected $cacheExpiry = 3600;
+
+ /**
+ * List of HTML chunks to be cached (if !hasCached) or that where cached (of hasCached).
+ * If not cached already, then the newly computed chunks are added here,
+ * if it as cached already, chunks are removed from this list as they are needed.
+ *
+ * @since 1.20
+ * @var array
+ */
+ protected $cachedChunks;
+
+ /**
+ * Indicates if the to be cached content was already cached.
+ * Null if this information is not available yet.
+ *
+ * @since 1.20
+ * @var bool|null
+ */
+ protected $hasCached = null;
+
+ /**
+ * If the cache is enabled or not.
+ *
+ * @since 1.20
+ * @var bool
+ */
+ protected $cacheEnabled = true;
+
+ /**
+ * Function that gets called when initialization is done.
+ *
+ * @since 1.20
+ * @var callable
+ */
+ protected $onInitHandler = false;
+
+ /**
+ * Elements to build a cache key with.
+ *
+ * @since 1.20
+ * @var array
+ */
+ protected $cacheKey = [];
+
+ /**
+ * Sets if the cache should be enabled or not.
+ *
+ * @since 1.20
+ * @param bool $cacheEnabled
+ */
+ public function setCacheEnabled( $cacheEnabled ) {
+ $this->cacheEnabled = $cacheEnabled;
+ }
+
+ /**
+ * Initializes the caching.
+ * Should be called before the first time anything is added via addCachedHTML.
+ *
+ * @since 1.20
+ *
+ * @param int|null $cacheExpiry Sets the cache expiry, either ttl in seconds or unix timestamp.
+ * @param bool|null $cacheEnabled Sets if the cache should be enabled or not.
+ */
+ public function startCache( $cacheExpiry = null, $cacheEnabled = null ) {
+ if ( is_null( $this->hasCached ) ) {
+ if ( !is_null( $cacheExpiry ) ) {
+ $this->cacheExpiry = $cacheExpiry;
+ }
+
+ if ( !is_null( $cacheEnabled ) ) {
+ $this->setCacheEnabled( $cacheEnabled );
+ }
+
+ $this->initCaching();
+ }
+ }
+
+ /**
+ * Returns a message that notifies the user he/she is looking at
+ * a cached version of the page, including a refresh link.
+ *
+ * @since 1.20
+ *
+ * @param IContextSource $context
+ * @param bool $includePurgeLink
+ *
+ * @return string
+ */
+ public function getCachedNotice( IContextSource $context, $includePurgeLink = true ) {
+ if ( $this->cacheExpiry < 86400 * 3650 ) {
+ $message = $context->msg(
+ 'cachedspecial-viewing-cached-ttl',
+ $context->getLanguage()->formatDuration( $this->cacheExpiry )
+ )->escaped();
+ } else {
+ $message = $context->msg(
+ 'cachedspecial-viewing-cached-ts'
+ )->escaped();
+ }
+
+ if ( $includePurgeLink ) {
+ $refreshArgs = $context->getRequest()->getQueryValues();
+ unset( $refreshArgs['title'] );
+ $refreshArgs['action'] = 'purge';
+
+ $subPage = $context->getTitle()->getFullText();
+ $subPage = explode( '/', $subPage, 2 );
+ $subPage = count( $subPage ) > 1 ? $subPage[1] : false;
+
+ $message .= ' ' . MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
+ $context->getTitle( $subPage ),
+ $context->msg( 'cachedspecial-refresh-now' )->text(),
+ [],
+ $refreshArgs
+ );
+ }
+
+ return $message;
+ }
+
+ /**
+ * Initializes the caching if not already done so.
+ * Should be called before any of the caching functionality is used.
+ *
+ * @since 1.20
+ */
+ protected function initCaching() {
+ if ( $this->cacheEnabled && is_null( $this->hasCached ) ) {
+ $cachedChunks = wfGetCache( CACHE_ANYTHING )->get( $this->getCacheKeyString() );
+
+ $this->hasCached = is_array( $cachedChunks );
+ $this->cachedChunks = $this->hasCached ? $cachedChunks : [];
+
+ if ( $this->onInitHandler !== false ) {
+ call_user_func( $this->onInitHandler, $this->hasCached );
+ }
+ }
+ }
+
+ /**
+ * Get a cached value if available or compute it if not and then cache it if possible.
+ * The provided $computeFunction is only called when the computation needs to happen
+ * and should return a result value. $args are arguments that will be passed to the
+ * compute function when called.
+ *
+ * @since 1.20
+ *
+ * @param callable $computeFunction
+ * @param array|mixed $args
+ * @param string|null $key
+ *
+ * @return mixed
+ */
+ public function getCachedValue( $computeFunction, $args = [], $key = null ) {
+ $this->initCaching();
+
+ if ( $this->cacheEnabled && $this->hasCached ) {
+ $value = null;
+
+ if ( is_null( $key ) ) {
+ $itemKey = array_keys( array_slice( $this->cachedChunks, 0, 1 ) );
+ $itemKey = array_shift( $itemKey );
+
+ if ( !is_integer( $itemKey ) ) {
+ wfWarn( "Attempted to get item with non-numeric key while " .
+ "the next item in the queue has a key ($itemKey) in " . __METHOD__ );
+ } elseif ( is_null( $itemKey ) ) {
+ wfWarn( "Attempted to get an item while the queue is empty in " . __METHOD__ );
+ } else {
+ $value = array_shift( $this->cachedChunks );
+ }
+ } else {
+ if ( array_key_exists( $key, $this->cachedChunks ) ) {
+ $value = $this->cachedChunks[$key];
+ unset( $this->cachedChunks[$key] );
+ } else {
+ wfWarn( "There is no item with key '$key' in this->cachedChunks in " . __METHOD__ );
+ }
+ }
+ } else {
+ if ( !is_array( $args ) ) {
+ $args = [ $args ];
+ }
+
+ $value = call_user_func_array( $computeFunction, $args );
+
+ if ( $this->cacheEnabled ) {
+ if ( is_null( $key ) ) {
+ $this->cachedChunks[] = $value;
+ } else {
+ $this->cachedChunks[$key] = $value;
+ }
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * Saves the HTML to the cache in case it got recomputed.
+ * Should be called after the last time anything is added via addCachedHTML.
+ *
+ * @since 1.20
+ */
+ public function saveCache() {
+ if ( $this->cacheEnabled && $this->hasCached === false && !empty( $this->cachedChunks ) ) {
+ wfGetCache( CACHE_ANYTHING )->set(
+ $this->getCacheKeyString(),
+ $this->cachedChunks,
+ $this->cacheExpiry
+ );
+ }
+ }
+
+ /**
+ * Sets the time to live for the cache, in seconds or a unix timestamp
+ * indicating the point of expiry...
+ *
+ * @since 1.20
+ *
+ * @param int $cacheExpiry
+ */
+ public function setExpiry( $cacheExpiry ) {
+ $this->cacheExpiry = $cacheExpiry;
+ }
+
+ /**
+ * Returns the cache key to use to cache this page's HTML output.
+ * Is constructed from the special page name and language code.
+ *
+ * @since 1.20
+ *
+ * @return string
+ * @throws MWException
+ */
+ protected function getCacheKeyString() {
+ if ( $this->cacheKey === [] ) {
+ throw new MWException( 'No cache key set, so cannot obtain or save the CacheHelper values.' );
+ }
+
+ return call_user_func_array( 'wfMemcKey', $this->cacheKey );
+ }
+
+ /**
+ * Sets the cache key that should be used.
+ *
+ * @since 1.20
+ *
+ * @param array $cacheKey
+ */
+ public function setCacheKey( array $cacheKey ) {
+ $this->cacheKey = $cacheKey;
+ }
+
+ /**
+ * Rebuild the content, even if it's already cached.
+ * This effectively has the same effect as purging the cache,
+ * since it will be overridden with the new value on the next request.
+ *
+ * @since 1.20
+ */
+ public function rebuildOnDemand() {
+ $this->hasCached = false;
+ }
+
+ /**
+ * Sets a function that gets called when initialization of the cache is done.
+ *
+ * @since 1.20
+ *
+ * @param callable $handlerFunction
+ */
+ public function setOnInitializedHandler( $handlerFunction ) {
+ $this->onInitHandler = $handlerFunction;
+ }
+}
diff --git a/www/wiki/includes/cache/FileCacheBase.php b/www/wiki/includes/cache/FileCacheBase.php
new file mode 100644
index 00000000..f2da08a3
--- /dev/null
+++ b/www/wiki/includes/cache/FileCacheBase.php
@@ -0,0 +1,278 @@
+<?php
+/**
+ * Data storage in the file system.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * Base class for data storage in the file system.
+ *
+ * @ingroup Cache
+ */
+abstract class FileCacheBase {
+ protected $mKey;
+ protected $mType = 'object';
+ protected $mExt = 'cache';
+ protected $mFilePath;
+ protected $mUseGzip;
+ /* lazy loaded */
+ protected $mCached;
+
+ /* @todo configurable? */
+ const MISS_FACTOR = 15; // log 1 every MISS_FACTOR cache misses
+ const MISS_TTL_SEC = 3600; // how many seconds ago is "recent"
+
+ protected function __construct() {
+ global $wgUseGzip;
+
+ $this->mUseGzip = (bool)$wgUseGzip;
+ }
+
+ /**
+ * Get the base file cache directory
+ * @return string
+ */
+ final protected function baseCacheDirectory() {
+ global $wgFileCacheDirectory;
+
+ return $wgFileCacheDirectory;
+ }
+
+ /**
+ * Get the base cache directory (not specific to this file)
+ * @return string
+ */
+ abstract protected function cacheDirectory();
+
+ /**
+ * Get the path to the cache file
+ * @return string
+ */
+ protected function cachePath() {
+ if ( $this->mFilePath !== null ) {
+ return $this->mFilePath;
+ }
+
+ $dir = $this->cacheDirectory();
+ # Build directories (methods include the trailing "/")
+ $subDirs = $this->typeSubdirectory() . $this->hashSubdirectory();
+ # Avoid extension confusion
+ $key = str_replace( '.', '%2E', urlencode( $this->mKey ) );
+ # Build the full file path
+ $this->mFilePath = "{$dir}/{$subDirs}{$key}.{$this->mExt}";
+ if ( $this->useGzip() ) {
+ $this->mFilePath .= '.gz';
+ }
+
+ return $this->mFilePath;
+ }
+
+ /**
+ * Check if the cache file exists
+ * @return bool
+ */
+ public function isCached() {
+ if ( $this->mCached === null ) {
+ $this->mCached = file_exists( $this->cachePath() );
+ }
+
+ return $this->mCached;
+ }
+
+ /**
+ * Get the last-modified timestamp of the cache file
+ * @return string|bool TS_MW timestamp
+ */
+ public function cacheTimestamp() {
+ $timestamp = filemtime( $this->cachePath() );
+
+ return ( $timestamp !== false )
+ ? wfTimestamp( TS_MW, $timestamp )
+ : false;
+ }
+
+ /**
+ * Check if up to date cache file exists
+ * @param string $timestamp MW_TS timestamp
+ *
+ * @return bool
+ */
+ public function isCacheGood( $timestamp = '' ) {
+ global $wgCacheEpoch;
+
+ if ( !$this->isCached() ) {
+ return false;
+ }
+
+ $cachetime = $this->cacheTimestamp();
+ $good = ( $timestamp <= $cachetime && $wgCacheEpoch <= $cachetime );
+ wfDebug( __METHOD__ .
+ ": cachetime $cachetime, touched '{$timestamp}' epoch {$wgCacheEpoch}, good $good\n" );
+
+ return $good;
+ }
+
+ /**
+ * Check if the cache is gzipped
+ * @return bool
+ */
+ protected function useGzip() {
+ return $this->mUseGzip;
+ }
+
+ /**
+ * Get the uncompressed text from the cache
+ * @return string
+ */
+ public function fetchText() {
+ if ( $this->useGzip() ) {
+ $fh = gzopen( $this->cachePath(), 'rb' );
+
+ return stream_get_contents( $fh );
+ } else {
+ return file_get_contents( $this->cachePath() );
+ }
+ }
+
+ /**
+ * Save and compress text to the cache
+ * @param string $text
+ * @return string|false Compressed text
+ */
+ public function saveText( $text ) {
+ if ( $this->useGzip() ) {
+ $text = gzencode( $text );
+ }
+
+ $this->checkCacheDirs(); // build parent dir
+ if ( !file_put_contents( $this->cachePath(), $text, LOCK_EX ) ) {
+ wfDebug( __METHOD__ . "() failed saving " . $this->cachePath() . "\n" );
+ $this->mCached = null;
+
+ return false;
+ }
+
+ $this->mCached = true;
+
+ return $text;
+ }
+
+ /**
+ * Clear the cache for this page
+ * @return void
+ */
+ public function clearCache() {
+ MediaWiki\suppressWarnings();
+ unlink( $this->cachePath() );
+ MediaWiki\restoreWarnings();
+ $this->mCached = false;
+ }
+
+ /**
+ * Create parent directors of $this->cachePath()
+ * @return void
+ */
+ protected function checkCacheDirs() {
+ wfMkdirParents( dirname( $this->cachePath() ), null, __METHOD__ );
+ }
+
+ /**
+ * Get the cache type subdirectory (with trailing slash)
+ * An extending class could use that method to alter the type -> directory
+ * mapping. @see HTMLFileCache::typeSubdirectory() for an example.
+ *
+ * @return string
+ */
+ protected function typeSubdirectory() {
+ return $this->mType . '/';
+ }
+
+ /**
+ * Return relative multi-level hash subdirectory (with trailing slash)
+ * or the empty string if not $wgFileCacheDepth
+ * @return string
+ */
+ protected function hashSubdirectory() {
+ global $wgFileCacheDepth;
+
+ $subdir = '';
+ if ( $wgFileCacheDepth > 0 ) {
+ $hash = md5( $this->mKey );
+ for ( $i = 1; $i <= $wgFileCacheDepth; $i++ ) {
+ $subdir .= substr( $hash, 0, $i ) . '/';
+ }
+ }
+
+ return $subdir;
+ }
+
+ /**
+ * Roughly increments the cache misses in the last hour by unique visitors
+ * @param WebRequest $request
+ * @return void
+ */
+ public function incrMissesRecent( WebRequest $request ) {
+ if ( mt_rand( 0, self::MISS_FACTOR - 1 ) == 0 ) {
+ $cache = ObjectCache::getLocalClusterInstance();
+ # Get a large IP range that should include the user even if that
+ # person's IP address changes
+ $ip = $request->getIP();
+ if ( !IP::isValid( $ip ) ) {
+ return;
+ }
+ $ip = IP::isIPv6( $ip )
+ ? IP::sanitizeRange( "$ip/32" )
+ : IP::sanitizeRange( "$ip/16" );
+
+ # Bail out if a request already came from this range...
+ $key = $cache->makeKey( static::class, 'attempt', $this->mType, $this->mKey, $ip );
+ if ( $cache->get( $key ) ) {
+ return; // possibly the same user
+ }
+ $cache->set( $key, 1, self::MISS_TTL_SEC );
+
+ # Increment the number of cache misses...
+ $key = $this->cacheMissKey( $cache );
+ if ( $cache->get( $key ) === false ) {
+ $cache->set( $key, 1, self::MISS_TTL_SEC );
+ } else {
+ $cache->incr( $key );
+ }
+ }
+ }
+
+ /**
+ * Roughly gets the cache misses in the last hour by unique visitors
+ * @return int
+ */
+ public function getMissesRecent() {
+ $cache = ObjectCache::getLocalClusterInstance();
+
+ return self::MISS_FACTOR * $cache->get( $this->cacheMissKey( $cache ) );
+ }
+
+ /**
+ * @param BagOStuff $cache Instance that the key will be used with
+ * @return string
+ */
+ protected function cacheMissKey( BagOStuff $cache ) {
+ return $cache->makeKey( static::class, 'misses', $this->mType, $this->mKey );
+ }
+}
diff --git a/www/wiki/includes/cache/GenderCache.php b/www/wiki/includes/cache/GenderCache.php
new file mode 100644
index 00000000..a34d2358
--- /dev/null
+++ b/www/wiki/includes/cache/GenderCache.php
@@ -0,0 +1,188 @@
+<?php
+/**
+ * Caches user genders when needed to use correct namespace aliases.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @ingroup Cache
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Caches user genders when needed to use correct namespace aliases.
+ *
+ * @since 1.18
+ */
+class GenderCache {
+ protected $cache = [];
+ protected $default;
+ protected $misses = 0;
+ protected $missLimit = 1000;
+
+ /**
+ * @deprecated in 1.28 see MediaWikiServices::getInstance()->getGenderCache()
+ * @return GenderCache
+ */
+ public static function singleton() {
+ return MediaWikiServices::getInstance()->getGenderCache();
+ }
+
+ /**
+ * Returns the default gender option in this wiki.
+ * @return string
+ */
+ protected function getDefault() {
+ if ( $this->default === null ) {
+ $this->default = User::getDefaultOption( 'gender' );
+ }
+
+ return $this->default;
+ }
+
+ /**
+ * Returns the gender for given username.
+ * @param string|User $username Username
+ * @param string $caller The calling method
+ * @return string
+ */
+ public function getGenderOf( $username, $caller = '' ) {
+ global $wgUser;
+
+ if ( $username instanceof User ) {
+ $username = $username->getName();
+ }
+
+ $username = self::normalizeUsername( $username );
+ if ( !isset( $this->cache[$username] ) ) {
+ if ( $this->misses >= $this->missLimit && $wgUser->getName() !== $username ) {
+ if ( $this->misses === $this->missLimit ) {
+ $this->misses++;
+ wfDebug( __METHOD__ . ": too many misses, returning default onwards\n" );
+ }
+
+ return $this->getDefault();
+ } else {
+ $this->misses++;
+ $this->doQuery( $username, $caller );
+ }
+ }
+
+ /* Undefined if there is a valid username which for some reason doesn't
+ * exist in the database.
+ */
+ return isset( $this->cache[$username] ) ? $this->cache[$username] : $this->getDefault();
+ }
+
+ /**
+ * Wrapper for doQuery that processes raw LinkBatch data.
+ *
+ * @param array $data
+ * @param string $caller
+ */
+ public function doLinkBatch( $data, $caller = '' ) {
+ $users = [];
+ foreach ( $data as $ns => $pagenames ) {
+ if ( !MWNamespace::hasGenderDistinction( $ns ) ) {
+ continue;
+ }
+ foreach ( array_keys( $pagenames ) as $username ) {
+ $users[$username] = true;
+ }
+ }
+
+ $this->doQuery( array_keys( $users ), $caller );
+ }
+
+ /**
+ * Wrapper for doQuery that processes a title or string array.
+ *
+ * @since 1.20
+ * @param array $titles Array of Title objects or strings
+ * @param string $caller The calling method
+ */
+ public function doTitlesArray( $titles, $caller = '' ) {
+ $users = [];
+ foreach ( $titles as $title ) {
+ $titleObj = is_string( $title ) ? Title::newFromText( $title ) : $title;
+ if ( !$titleObj ) {
+ continue;
+ }
+ if ( !MWNamespace::hasGenderDistinction( $titleObj->getNamespace() ) ) {
+ continue;
+ }
+ $users[] = $titleObj->getText();
+ }
+
+ $this->doQuery( $users, $caller );
+ }
+
+ /**
+ * Preloads genders for given list of users.
+ * @param array|string $users Usernames
+ * @param string $caller The calling method
+ */
+ public function doQuery( $users, $caller = '' ) {
+ $default = $this->getDefault();
+
+ $usersToCheck = [];
+ foreach ( (array)$users as $value ) {
+ $name = self::normalizeUsername( $value );
+ // Skip users whose gender setting we already know
+ if ( !isset( $this->cache[$name] ) ) {
+ // For existing users, this value will be overwritten by the correct value
+ $this->cache[$name] = $default;
+ // query only for valid names, which can be in the database
+ if ( User::isValidUserName( $name ) ) {
+ $usersToCheck[] = $name;
+ }
+ }
+ }
+
+ if ( count( $usersToCheck ) === 0 ) {
+ return;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $table = [ 'user', 'user_properties' ];
+ $fields = [ 'user_name', 'up_value' ];
+ $conds = [ 'user_name' => $usersToCheck ];
+ $joins = [ 'user_properties' =>
+ [ 'LEFT JOIN', [ 'user_id = up_user', 'up_property' => 'gender' ] ] ];
+
+ $comment = __METHOD__;
+ if ( strval( $caller ) !== '' ) {
+ $comment .= "/$caller";
+ }
+ $res = $dbr->select( $table, $fields, $conds, $comment, [], $joins );
+
+ foreach ( $res as $row ) {
+ $this->cache[$row->user_name] = $row->up_value ? $row->up_value : $default;
+ }
+ }
+
+ private static function normalizeUsername( $username ) {
+ // Strip off subpages
+ $indexSlash = strpos( $username, '/' );
+ if ( $indexSlash !== false ) {
+ $username = substr( $username, 0, $indexSlash );
+ }
+
+ // normalize underscore/spaces
+ return strtr( $username, '_', ' ' );
+ }
+}
diff --git a/www/wiki/includes/cache/HTMLFileCache.php b/www/wiki/includes/cache/HTMLFileCache.php
new file mode 100644
index 00000000..7ae2ee0e
--- /dev/null
+++ b/www/wiki/includes/cache/HTMLFileCache.php
@@ -0,0 +1,246 @@
+<?php
+/**
+ * Page view caching in the file system.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Page view caching in the file system.
+ * The only cacheable actions are "view" and "history". Also special pages
+ * will not be cached.
+ *
+ * @ingroup Cache
+ */
+class HTMLFileCache extends FileCacheBase {
+ const MODE_NORMAL = 0; // normal cache mode
+ const MODE_OUTAGE = 1; // fallback cache for DB outages
+ const MODE_REBUILD = 2; // background cache rebuild mode
+
+ /**
+ * @param Title|string $title Title object or prefixed DB key string
+ * @param string $action
+ * @throws MWException
+ */
+ public function __construct( $title, $action ) {
+ parent::__construct();
+
+ $allowedTypes = self::cacheablePageActions();
+ if ( !in_array( $action, $allowedTypes ) ) {
+ throw new MWException( 'Invalid file cache type given.' );
+ }
+ $this->mKey = ( $title instanceof Title )
+ ? $title->getPrefixedDBkey()
+ : (string)$title;
+ $this->mType = (string)$action;
+ $this->mExt = 'html';
+ }
+
+ /**
+ * Cacheable actions
+ * @return array
+ */
+ protected static function cacheablePageActions() {
+ return [ 'view', 'history' ];
+ }
+
+ /**
+ * Get the base file cache directory
+ * @return string
+ */
+ protected function cacheDirectory() {
+ return $this->baseCacheDirectory(); // no subdir for b/c with old cache files
+ }
+
+ /**
+ * Get the cache type subdirectory (with the trailing slash) or the empty string
+ * Alter the type -> directory mapping to put action=view cache at the root.
+ *
+ * @return string
+ */
+ protected function typeSubdirectory() {
+ if ( $this->mType === 'view' ) {
+ return ''; // b/c to not skip existing cache
+ } else {
+ return $this->mType . '/';
+ }
+ }
+
+ /**
+ * Check if pages can be cached for this request/user
+ * @param IContextSource $context
+ * @param int $mode One of the HTMLFileCache::MODE_* constants (since 1.28)
+ * @return bool
+ */
+ public static function useFileCache( IContextSource $context, $mode = self::MODE_NORMAL ) {
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+
+ if ( !$config->get( 'UseFileCache' ) && $mode !== self::MODE_REBUILD ) {
+ return false;
+ } elseif ( $config->get( 'DebugToolbar' ) ) {
+ wfDebug( "HTML file cache skipped. \$wgDebugToolbar on\n" );
+
+ return false;
+ }
+
+ // Get all query values
+ $queryVals = $context->getRequest()->getValues();
+ foreach ( $queryVals as $query => $val ) {
+ if ( $query === 'title' || $query === 'curid' ) {
+ continue; // note: curid sets title
+ // Normal page view in query form can have action=view.
+ } elseif ( $query === 'action' && in_array( $val, self::cacheablePageActions() ) ) {
+ continue;
+ // Below are header setting params
+ } elseif ( $query === 'maxage' || $query === 'smaxage' ) {
+ continue;
+ }
+
+ return false;
+ }
+
+ $user = $context->getUser();
+ // Check for non-standard user language; this covers uselang,
+ // and extensions for auto-detecting user language.
+ $ulang = $context->getLanguage();
+
+ // Check that there are no other sources of variation
+ if ( $user->getId() || $ulang->getCode() !== $config->get( 'LanguageCode' ) ) {
+ return false;
+ }
+
+ if ( $mode === self::MODE_NORMAL ) {
+ if ( $user->getNewtalk() ) {
+ return false;
+ }
+ }
+
+ // Allow extensions to disable caching
+ return Hooks::run( 'HTMLFileCache::useFileCache', [ $context ] );
+ }
+
+ /**
+ * Read from cache to context output
+ * @param IContextSource $context
+ * @param int $mode One of the HTMLFileCache::MODE_* constants
+ * @return void
+ */
+ public function loadFromFileCache( IContextSource $context, $mode = self::MODE_NORMAL ) {
+ global $wgContLang;
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+
+ wfDebug( __METHOD__ . "()\n" );
+ $filename = $this->cachePath();
+
+ if ( $mode === self::MODE_OUTAGE ) {
+ // Avoid DB errors for queries in sendCacheControl()
+ $context->getTitle()->resetArticleID( 0 );
+ }
+
+ $context->getOutput()->sendCacheControl();
+ header( "Content-Type: {$config->get( 'MimeType' )}; charset=UTF-8" );
+ header( "Content-Language: {$wgContLang->getHtmlCode()}" );
+ if ( $this->useGzip() ) {
+ if ( wfClientAcceptsGzip() ) {
+ header( 'Content-Encoding: gzip' );
+ readfile( $filename );
+ } else {
+ /* Send uncompressed */
+ wfDebug( __METHOD__ . " uncompressing cache file and sending it\n" );
+ readgzfile( $filename );
+ }
+ } else {
+ readfile( $filename );
+ }
+
+ $context->getOutput()->disable(); // tell $wgOut that output is taken care of
+ }
+
+ /**
+ * Save this cache object with the given text.
+ * Use this as an ob_start() handler.
+ *
+ * Normally this is only registed as a handler if $wgUseFileCache is on.
+ * If can be explicitly called by rebuildFileCache.php when it takes over
+ * handling file caching itself, disabling any automatic handling the the
+ * process.
+ *
+ * @param string $text
+ * @return string|bool The annotated $text or false on error
+ */
+ public function saveToFileCache( $text ) {
+ if ( strlen( $text ) < 512 ) {
+ // Disabled or empty/broken output (OOM and PHP errors)
+ return $text;
+ }
+
+ wfDebug( __METHOD__ . "()\n", 'private' );
+
+ $now = wfTimestampNow();
+ if ( $this->useGzip() ) {
+ $text = str_replace(
+ '</html>', '<!-- Cached/compressed ' . $now . " -->\n</html>", $text );
+ } else {
+ $text = str_replace(
+ '</html>', '<!-- Cached ' . $now . " -->\n</html>", $text );
+ }
+
+ // Store text to FS...
+ $compressed = $this->saveText( $text );
+ if ( $compressed === false ) {
+ return $text; // error
+ }
+
+ // gzip output to buffer as needed and set headers...
+ if ( $this->useGzip() ) {
+ // @todo Ugly wfClientAcceptsGzip() function - use context!
+ if ( wfClientAcceptsGzip() ) {
+ header( 'Content-Encoding: gzip' );
+
+ return $compressed;
+ } else {
+ return $text;
+ }
+ } else {
+ return $text;
+ }
+ }
+
+ /**
+ * Clear the file caches for a page for all actions
+ * @param Title $title
+ * @return bool Whether $wgUseFileCache is enabled
+ */
+ public static function clearFileCache( Title $title ) {
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+
+ if ( !$config->get( 'UseFileCache' ) ) {
+ return false;
+ }
+
+ foreach ( self::cacheablePageActions() as $type ) {
+ $fc = new self( $title, $type );
+ $fc->clearCache();
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/cache/LinkBatch.php b/www/wiki/includes/cache/LinkBatch.php
new file mode 100644
index 00000000..30d105b2
--- /dev/null
+++ b/www/wiki/includes/cache/LinkBatch.php
@@ -0,0 +1,246 @@
+<?php
+/**
+ * Batch query to determine page existence.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Class representing a list of titles
+ * The execute() method checks them all for existence and adds them to a LinkCache object
+ *
+ * @ingroup Cache
+ */
+class LinkBatch {
+ /**
+ * 2-d array, first index namespace, second index dbkey, value arbitrary
+ */
+ public $data = [];
+
+ /**
+ * For debugging which method is using this class.
+ */
+ protected $caller;
+
+ /**
+ * @param Traversable|LinkTarget[] $arr Initial items to be added to the batch
+ */
+ public function __construct( $arr = [] ) {
+ foreach ( $arr as $item ) {
+ $this->addObj( $item );
+ }
+ }
+
+ /**
+ * Use ->setCaller( __METHOD__ ) to indicate which code is using this
+ * class. Only used in debugging output.
+ * @since 1.17
+ *
+ * @param string $caller
+ */
+ public function setCaller( $caller ) {
+ $this->caller = $caller;
+ }
+
+ /**
+ * @param LinkTarget $linkTarget
+ */
+ public function addObj( $linkTarget ) {
+ if ( is_object( $linkTarget ) ) {
+ $this->add( $linkTarget->getNamespace(), $linkTarget->getDBkey() );
+ } else {
+ wfDebug( "Warning: LinkBatch::addObj got invalid LinkTarget object\n" );
+ }
+ }
+
+ /**
+ * @param int $ns
+ * @param string $dbkey
+ */
+ public function add( $ns, $dbkey ) {
+ if ( $ns < 0 || $dbkey === '' ) {
+ return; // T137083
+ }
+ if ( !array_key_exists( $ns, $this->data ) ) {
+ $this->data[$ns] = [];
+ }
+
+ $this->data[$ns][strtr( $dbkey, ' ', '_' )] = 1;
+ }
+
+ /**
+ * Set the link list to a given 2-d array
+ * First key is the namespace, second is the DB key, value arbitrary
+ *
+ * @param array $array
+ */
+ public function setArray( $array ) {
+ $this->data = $array;
+ }
+
+ /**
+ * Returns true if no pages have been added, false otherwise.
+ *
+ * @return bool
+ */
+ public function isEmpty() {
+ return $this->getSize() == 0;
+ }
+
+ /**
+ * Returns the size of the batch.
+ *
+ * @return int
+ */
+ public function getSize() {
+ return count( $this->data );
+ }
+
+ /**
+ * Do the query and add the results to the LinkCache object
+ *
+ * @return array Mapping PDBK to ID
+ */
+ public function execute() {
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+
+ return $this->executeInto( $linkCache );
+ }
+
+ /**
+ * Do the query and add the results to a given LinkCache object
+ * Return an array mapping PDBK to ID
+ *
+ * @param LinkCache &$cache
+ * @return array Remaining IDs
+ */
+ protected function executeInto( &$cache ) {
+ $res = $this->doQuery();
+ $this->doGenderQuery();
+ $ids = $this->addResultToCache( $cache, $res );
+
+ return $ids;
+ }
+
+ /**
+ * Add a ResultWrapper containing IDs and titles to a LinkCache object.
+ * As normal, titles will go into the static Title cache field.
+ * This function *also* stores extra fields of the title used for link
+ * parsing to avoid extra DB queries.
+ *
+ * @param LinkCache $cache
+ * @param ResultWrapper $res
+ * @return array Array of remaining titles
+ */
+ public function addResultToCache( $cache, $res ) {
+ if ( !$res ) {
+ return [];
+ }
+
+ $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
+ // For each returned entry, add it to the list of good links, and remove it from $remaining
+
+ $ids = [];
+ $remaining = $this->data;
+ foreach ( $res as $row ) {
+ $title = new TitleValue( (int)$row->page_namespace, $row->page_title );
+ $cache->addGoodLinkObjFromRow( $title, $row );
+ $pdbk = $titleFormatter->getPrefixedDBkey( $title );
+ $ids[$pdbk] = $row->page_id;
+ unset( $remaining[$row->page_namespace][$row->page_title] );
+ }
+
+ // The remaining links in $data are bad links, register them as such
+ foreach ( $remaining as $ns => $dbkeys ) {
+ foreach ( $dbkeys as $dbkey => $unused ) {
+ $title = new TitleValue( (int)$ns, (string)$dbkey );
+ $cache->addBadLinkObj( $title );
+ $pdbk = $titleFormatter->getPrefixedDBkey( $title );
+ $ids[$pdbk] = 0;
+ }
+ }
+
+ return $ids;
+ }
+
+ /**
+ * Perform the existence test query, return a ResultWrapper with page_id fields
+ * @return bool|ResultWrapper
+ */
+ public function doQuery() {
+ if ( $this->isEmpty() ) {
+ return false;
+ }
+
+ // This is similar to LinkHolderArray::replaceInternal
+ $dbr = wfGetDB( DB_REPLICA );
+ $table = 'page';
+ $fields = array_merge(
+ LinkCache::getSelectFields(),
+ [ 'page_namespace', 'page_title' ]
+ );
+
+ $conds = $this->constructSet( 'page', $dbr );
+
+ // Do query
+ $caller = __METHOD__;
+ if ( strval( $this->caller ) !== '' ) {
+ $caller .= " (for {$this->caller})";
+ }
+ $res = $dbr->select( $table, $fields, $conds, $caller );
+
+ return $res;
+ }
+
+ /**
+ * Do (and cache) {{GENDER:...}} information for userpages in this LinkBatch
+ *
+ * @return bool Whether the query was successful
+ */
+ public function doGenderQuery() {
+ if ( $this->isEmpty() ) {
+ return false;
+ }
+
+ global $wgContLang;
+ if ( !$wgContLang->needsGenderDistinction() ) {
+ return false;
+ }
+
+ $genderCache = MediaWikiServices::getInstance()->getGenderCache();
+ $genderCache->doLinkBatch( $this->data, $this->caller );
+
+ return true;
+ }
+
+ /**
+ * Construct a WHERE clause which will match all the given titles.
+ *
+ * @param string $prefix The appropriate table's field name prefix ('page', 'pl', etc)
+ * @param IDatabase $db DB object to use
+ * @return string|bool String with SQL where clause fragment, or false if no items.
+ */
+ public function constructSet( $prefix, $db ) {
+ return $db->makeWhereFrom2d( $this->data, "{$prefix}_namespace", "{$prefix}_title" );
+ }
+}
diff --git a/www/wiki/includes/cache/LinkCache.php b/www/wiki/includes/cache/LinkCache.php
new file mode 100644
index 00000000..2d088952
--- /dev/null
+++ b/www/wiki/includes/cache/LinkCache.php
@@ -0,0 +1,338 @@
+<?php
+/**
+ * Page existence cache.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Cache for article titles (prefixed DB keys) and ids linked from one source
+ *
+ * @ingroup Cache
+ */
+class LinkCache {
+ /** @var HashBagOStuff */
+ private $mGoodLinks;
+ /** @var HashBagOStuff */
+ private $mBadLinks;
+ /** @var WANObjectCache */
+ private $wanCache;
+
+ /** @var bool */
+ private $mForUpdate = false;
+
+ /** @var TitleFormatter */
+ private $titleFormatter;
+
+ /**
+ * How many Titles to store. There are two caches, so the amount actually
+ * stored in memory can be up to twice this.
+ */
+ const MAX_SIZE = 10000;
+
+ public function __construct( TitleFormatter $titleFormatter, WANObjectCache $cache ) {
+ $this->mGoodLinks = new HashBagOStuff( [ 'maxKeys' => self::MAX_SIZE ] );
+ $this->mBadLinks = new HashBagOStuff( [ 'maxKeys' => self::MAX_SIZE ] );
+ $this->wanCache = $cache;
+ $this->titleFormatter = $titleFormatter;
+ }
+
+ /**
+ * Get an instance of this class.
+ *
+ * @return LinkCache
+ * @deprecated since 1.28, use MediaWikiServices instead
+ */
+ public static function singleton() {
+ return MediaWikiServices::getInstance()->getLinkCache();
+ }
+
+ /**
+ * General accessor to get/set whether the master DB should be used
+ *
+ * This used to also set the FOR UPDATE option (locking the rows read
+ * in order to avoid link table inconsistency), which was later removed
+ * for performance on wikis with a high edit rate.
+ *
+ * @param bool $update
+ * @return bool
+ */
+ public function forUpdate( $update = null ) {
+ return wfSetVar( $this->mForUpdate, $update );
+ }
+
+ /**
+ * @param string $title Prefixed DB key
+ * @return int Page ID or zero
+ */
+ public function getGoodLinkID( $title ) {
+ $info = $this->mGoodLinks->get( $title );
+ if ( !$info ) {
+ return 0;
+ }
+ return $info['id'];
+ }
+
+ /**
+ * Get a field of a title object from cache.
+ * If this link is not a cached good title, it will return NULL.
+ * @param LinkTarget $target
+ * @param string $field ('length','redirect','revision','model')
+ * @return string|int|null
+ */
+ public function getGoodLinkFieldObj( LinkTarget $target, $field ) {
+ $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
+ $info = $this->mGoodLinks->get( $dbkey );
+ if ( !$info ) {
+ return null;
+ }
+ return $info[$field];
+ }
+
+ /**
+ * @param string $title Prefixed DB key
+ * @return bool
+ */
+ public function isBadLink( $title ) {
+ // Use get() to ensure it records as used for LRU.
+ return $this->mBadLinks->get( $title ) !== false;
+ }
+
+ /**
+ * Add a link for the title to the link cache
+ *
+ * @param int $id Page's ID
+ * @param LinkTarget $target
+ * @param int $len Text's length
+ * @param int $redir Whether the page is a redirect
+ * @param int $revision Latest revision's ID
+ * @param string|null $model Latest revision's content model ID
+ * @param string|null $lang Language code of the page, if not the content language
+ */
+ public function addGoodLinkObj( $id, LinkTarget $target, $len = -1, $redir = null,
+ $revision = 0, $model = null, $lang = null
+ ) {
+ $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
+ $this->mGoodLinks->set( $dbkey, [
+ 'id' => (int)$id,
+ 'length' => (int)$len,
+ 'redirect' => (int)$redir,
+ 'revision' => (int)$revision,
+ 'model' => $model ? (string)$model : null,
+ 'lang' => $lang ? (string)$lang : null,
+ ] );
+ }
+
+ /**
+ * Same as above with better interface.
+ * @since 1.19
+ * @param LinkTarget $target
+ * @param stdClass $row Object which has the fields page_id, page_is_redirect,
+ * page_latest and page_content_model
+ */
+ public function addGoodLinkObjFromRow( LinkTarget $target, $row ) {
+ $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
+ $this->mGoodLinks->set( $dbkey, [
+ 'id' => intval( $row->page_id ),
+ 'length' => intval( $row->page_len ),
+ 'redirect' => intval( $row->page_is_redirect ),
+ 'revision' => intval( $row->page_latest ),
+ 'model' => !empty( $row->page_content_model ) ? strval( $row->page_content_model ) : null,
+ 'lang' => !empty( $row->page_lang ) ? strval( $row->page_lang ) : null,
+ ] );
+ }
+
+ /**
+ * @param LinkTarget $target
+ */
+ public function addBadLinkObj( LinkTarget $target ) {
+ $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
+ if ( !$this->isBadLink( $dbkey ) ) {
+ $this->mBadLinks->set( $dbkey, 1 );
+ }
+ }
+
+ /**
+ * @param string $title Prefixed DB key
+ */
+ public function clearBadLink( $title ) {
+ $this->mBadLinks->delete( $title );
+ }
+
+ /**
+ * @param LinkTarget $target
+ */
+ public function clearLink( LinkTarget $target ) {
+ $dbkey = $this->titleFormatter->getPrefixedDBkey( $target );
+ $this->mBadLinks->delete( $dbkey );
+ $this->mGoodLinks->delete( $dbkey );
+ }
+
+ /**
+ * Add a title to the link cache, return the page_id or zero if non-existent
+ *
+ * @deprecated since 1.27, unused
+ * @param string $title Prefixed DB key
+ * @return int Page ID or zero
+ */
+ public function addLink( $title ) {
+ $nt = Title::newFromDBkey( $title );
+ if ( !$nt ) {
+ return 0;
+ }
+ return $this->addLinkObj( $nt );
+ }
+
+ /**
+ * Fields that LinkCache needs to select
+ *
+ * @since 1.28
+ * @return array
+ */
+ public static function getSelectFields() {
+ global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
+
+ $fields = [ 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ];
+ if ( $wgContentHandlerUseDB ) {
+ $fields[] = 'page_content_model';
+ }
+ if ( $wgPageLanguageUseDB ) {
+ $fields[] = 'page_lang';
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Add a title to the link cache, return the page_id or zero if non-existent
+ *
+ * @param LinkTarget $nt LinkTarget object to add
+ * @return int Page ID or zero
+ */
+ public function addLinkObj( LinkTarget $nt ) {
+ $key = $this->titleFormatter->getPrefixedDBkey( $nt );
+ if ( $this->isBadLink( $key ) || $nt->isExternal()
+ || $nt->inNamespace( NS_SPECIAL )
+ ) {
+ return 0;
+ }
+ $id = $this->getGoodLinkID( $key );
+ if ( $id != 0 ) {
+ return $id;
+ }
+
+ if ( $key === '' ) {
+ return 0;
+ }
+
+ // Cache template/file pages as they are less often viewed but heavily used
+ if ( $this->mForUpdate ) {
+ $row = $this->fetchPageRow( wfGetDB( DB_MASTER ), $nt );
+ } elseif ( $this->isCacheable( $nt ) ) {
+ // These pages are often transcluded heavily, so cache them
+ $cache = $this->wanCache;
+ $row = $cache->getWithSetCallback(
+ $cache->makeKey( 'page', $nt->getNamespace(), sha1( $nt->getDBkey() ) ),
+ $cache::TTL_DAY,
+ function ( $curValue, &$ttl, array &$setOpts ) use ( $cache, $nt ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $setOpts += Database::getCacheSetOptions( $dbr );
+
+ $row = $this->fetchPageRow( $dbr, $nt );
+ $mtime = $row ? wfTimestamp( TS_UNIX, $row->page_touched ) : false;
+ $ttl = $cache->adaptiveTTL( $mtime, $ttl );
+
+ return $row;
+ }
+ );
+ } else {
+ $row = $this->fetchPageRow( wfGetDB( DB_REPLICA ), $nt );
+ }
+
+ if ( $row ) {
+ $this->addGoodLinkObjFromRow( $nt, $row );
+ $id = intval( $row->page_id );
+ } else {
+ $this->addBadLinkObj( $nt );
+ $id = 0;
+ }
+
+ return $id;
+ }
+
+ /**
+ * @param WANObjectCache $cache
+ * @param TitleValue $t
+ * @return string[]
+ * @since 1.28
+ */
+ public function getMutableCacheKeys( WANObjectCache $cache, TitleValue $t ) {
+ if ( $this->isCacheable( $t ) ) {
+ return [ $cache->makeKey( 'page', $t->getNamespace(), sha1( $t->getDBkey() ) ) ];
+ }
+
+ return [];
+ }
+
+ private function isCacheable( LinkTarget $title ) {
+ return ( $title->inNamespace( NS_TEMPLATE ) || $title->inNamespace( NS_FILE ) );
+ }
+
+ private function fetchPageRow( IDatabase $db, LinkTarget $nt ) {
+ $fields = self::getSelectFields();
+ if ( $this->isCacheable( $nt ) ) {
+ $fields[] = 'page_touched';
+ }
+
+ return $db->selectRow(
+ 'page',
+ $fields,
+ [ 'page_namespace' => $nt->getNamespace(), 'page_title' => $nt->getDBkey() ],
+ __METHOD__
+ );
+ }
+
+ /**
+ * Purge the link cache for a title
+ *
+ * @param LinkTarget $title
+ * @since 1.28
+ */
+ public function invalidateTitle( LinkTarget $title ) {
+ if ( $this->isCacheable( $title ) ) {
+ $cache = ObjectCache::getMainWANInstance();
+ $cache->delete(
+ $cache->makeKey( 'page', $title->getNamespace(), sha1( $title->getDBkey() ) )
+ );
+ }
+ }
+
+ /**
+ * Clears cache
+ */
+ public function clear() {
+ $this->mGoodLinks->clear();
+ $this->mBadLinks->clear();
+ }
+}
diff --git a/www/wiki/includes/cache/MessageBlobStore.php b/www/wiki/includes/cache/MessageBlobStore.php
new file mode 100644
index 00000000..b076a083
--- /dev/null
+++ b/www/wiki/includes/cache/MessageBlobStore.php
@@ -0,0 +1,253 @@
+<?php
+/**
+ * Message blobs storage used by ResourceLoader.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Roan Kattouw
+ * @author Trevor Parscal
+ * @author Timo Tijhof
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\Rdbms\Database;
+
+/**
+ * This class generates message blobs for use by ResourceLoader modules.
+ *
+ * A message blob is a JSON object containing the interface messages for a certain module in
+ * a certain language.
+ */
+class MessageBlobStore implements LoggerAwareInterface {
+
+ /* @var ResourceLoader|null */
+ private $resourceloader;
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * @var WANObjectCache
+ */
+ protected $wanCache;
+
+ /**
+ * @param ResourceLoader $rl
+ * @param LoggerInterface $logger
+ */
+ public function __construct( ResourceLoader $rl = null, LoggerInterface $logger = null ) {
+ $this->resourceloader = $rl;
+ $this->logger = $logger ?: new NullLogger();
+ $this->wanCache = ObjectCache::getMainWANInstance();
+ }
+
+ /**
+ * @since 1.27
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get the message blob for a module
+ *
+ * @since 1.27
+ * @param ResourceLoaderModule $module
+ * @param string $lang Language code
+ * @return string JSON
+ */
+ public function getBlob( ResourceLoaderModule $module, $lang ) {
+ $blobs = $this->getBlobs( [ $module->getName() => $module ], $lang );
+ return $blobs[$module->getName()];
+ }
+
+ /**
+ * Get the message blobs for a set of modules
+ *
+ * @since 1.27
+ * @param ResourceLoaderModule[] $modules Array of module objects keyed by name
+ * @param string $lang Language code
+ * @return array An array mapping module names to message blobs
+ */
+ public function getBlobs( array $modules, $lang ) {
+ // Each cache key for a message blob by module name and language code also has a generic
+ // check key without language code. This is used to invalidate any and all language subkeys
+ // that exist for a module from the updateMessage() method.
+ $cache = $this->wanCache;
+ $checkKeys = [
+ // Global check key, see clear()
+ $cache->makeKey( __CLASS__ )
+ ];
+ $cacheKeys = [];
+ foreach ( $modules as $name => $module ) {
+ $cacheKey = $this->makeCacheKey( $module, $lang );
+ $cacheKeys[$name] = $cacheKey;
+ // Per-module check key, see updateMessage()
+ $checkKeys[$cacheKey][] = $cache->makeKey( __CLASS__, $name );
+ }
+ $curTTLs = [];
+ $result = $cache->getMulti( array_values( $cacheKeys ), $curTTLs, $checkKeys );
+
+ $blobs = [];
+ foreach ( $modules as $name => $module ) {
+ $key = $cacheKeys[$name];
+ if ( !isset( $result[$key] ) || $curTTLs[$key] === null || $curTTLs[$key] < 0 ) {
+ $blobs[$name] = $this->recacheMessageBlob( $key, $module, $lang );
+ } else {
+ // Use unexpired cache
+ $blobs[$name] = $result[$key];
+ }
+ }
+ return $blobs;
+ }
+
+ /**
+ * @deprecated since 1.27 Use getBlobs() instead
+ * @return array
+ */
+ public function get( ResourceLoader $resourceLoader, $modules, $lang ) {
+ return $this->getBlobs( $modules, $lang );
+ }
+
+ /**
+ * @deprecated since 1.27 Obsolete. Used to populate a cache table in the database.
+ * @return bool
+ */
+ public function insertMessageBlob( $name, ResourceLoaderModule $module, $lang ) {
+ return false;
+ }
+
+ /**
+ * @since 1.27
+ * @param ResourceLoaderModule $module
+ * @param string $lang
+ * @return string Cache key
+ */
+ private function makeCacheKey( ResourceLoaderModule $module, $lang ) {
+ $messages = array_values( array_unique( $module->getMessages() ) );
+ sort( $messages );
+ return $this->wanCache->makeKey( __CLASS__, $module->getName(), $lang,
+ md5( json_encode( $messages ) )
+ );
+ }
+
+ /**
+ * @since 1.27
+ * @param string $cacheKey
+ * @param ResourceLoaderModule $module
+ * @param string $lang
+ * @return string JSON blob
+ */
+ protected function recacheMessageBlob( $cacheKey, ResourceLoaderModule $module, $lang ) {
+ $blob = $this->generateMessageBlob( $module, $lang );
+ $cache = $this->wanCache;
+ $cache->set( $cacheKey, $blob,
+ // Add part of a day to TTL to avoid all modules expiring at once
+ $cache::TTL_WEEK + mt_rand( 0, $cache::TTL_DAY ),
+ Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) )
+ );
+ return $blob;
+ }
+
+ /**
+ * Invalidate cache keys for modules using this message key.
+ * Called by MessageCache when a message has changed.
+ *
+ * @param string $key Message key
+ */
+ public function updateMessage( $key ) {
+ $moduleNames = $this->getResourceLoader()->getModulesByMessage( $key );
+ foreach ( $moduleNames as $moduleName ) {
+ // Uses a holdoff to account for database replica DB lag (for MessageCache)
+ $this->wanCache->touchCheckKey( $this->wanCache->makeKey( __CLASS__, $moduleName ) );
+ }
+ }
+
+ /**
+ * Invalidate cache keys for all known modules.
+ * Called by LocalisationCache after cache is regenerated.
+ */
+ public function clear() {
+ $cache = $this->wanCache;
+ // Disable holdoff because this invalidates all modules and also not needed since
+ // LocalisationCache is stored outside the database and doesn't have lag.
+ $cache->touchCheckKey( $cache->makeKey( __CLASS__ ), $cache::HOLDOFF_NONE );
+ }
+
+ /**
+ * @since 1.27
+ * @return ResourceLoader
+ */
+ protected function getResourceLoader() {
+ // Back-compat: This class supports instantiation without a ResourceLoader object.
+ // Lazy-initialise this property because most callers don't need it.
+ if ( $this->resourceloader === null ) {
+ $this->logger->warning( __CLASS__ . ' created without a ResourceLoader instance' );
+ $this->resourceloader = new ResourceLoader();
+ }
+ return $this->resourceloader;
+ }
+
+ /**
+ * @since 1.27
+ * @param string $key Message key
+ * @param string $lang Language code
+ * @return string
+ */
+ protected function fetchMessage( $key, $lang ) {
+ $message = wfMessage( $key )->inLanguage( $lang );
+ $value = $message->plain();
+ if ( !$message->exists() ) {
+ $this->logger->warning( 'Failed to find {messageKey} ({lang})', [
+ 'messageKey' => $key,
+ 'lang' => $lang,
+ ] );
+ }
+ return $value;
+ }
+
+ /**
+ * Generate the message blob for a given module in a given language.
+ *
+ * @param ResourceLoaderModule $module
+ * @param string $lang Language code
+ * @return string JSON blob
+ */
+ private function generateMessageBlob( ResourceLoaderModule $module, $lang ) {
+ $messages = [];
+ foreach ( $module->getMessages() as $key ) {
+ $messages[$key] = $this->fetchMessage( $key, $lang );
+ }
+
+ $json = FormatJson::encode( (object)$messages );
+ // @codeCoverageIgnoreStart
+ if ( $json === false ) {
+ $this->logger->warning( 'Failed to encode message blob for {module} ({lang})', [
+ 'module' => $module->getName(),
+ 'lang' => $lang,
+ ] );
+ $json = '{}';
+ }
+ // codeCoverageIgnoreEnd
+ return $json;
+ }
+}
diff --git a/www/wiki/includes/cache/MessageCache.php b/www/wiki/includes/cache/MessageCache.php
new file mode 100644
index 00000000..768f980b
--- /dev/null
+++ b/www/wiki/includes/cache/MessageCache.php
@@ -0,0 +1,1331 @@
+<?php
+/**
+ * Localisation messages cache.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+use MediaWiki\Logger\LoggerFactory;
+use Wikimedia\Rdbms\Database;
+
+/**
+ * MediaWiki message cache structure version.
+ * Bump this whenever the message cache format has changed.
+ */
+define( 'MSG_CACHE_VERSION', 2 );
+
+/**
+ * Message cache
+ * Performs various MediaWiki namespace-related functions
+ * @ingroup Cache
+ */
+class MessageCache {
+ const FOR_UPDATE = 1; // force message reload
+
+ /** How long to wait for memcached locks */
+ const WAIT_SEC = 15;
+ /** How long memcached locks last */
+ const LOCK_TTL = 30;
+
+ /**
+ * Process local cache of loaded messages that are defined in
+ * MediaWiki namespace. First array level is a language code,
+ * second level is message key and the values are either message
+ * content prefixed with space, or !NONEXISTENT for negative
+ * caching.
+ * @var array $mCache
+ */
+ protected $mCache;
+
+ /**
+ * @var bool[] Map of (language code => boolean)
+ */
+ protected $mCacheVolatile = [];
+
+ /**
+ * Should mean that database cannot be used, but check
+ * @var bool $mDisable
+ */
+ protected $mDisable;
+
+ /**
+ * Lifetime for cache, used by object caching.
+ * Set on construction, see __construct().
+ */
+ protected $mExpiry;
+
+ /**
+ * Message cache has its own parser which it uses to transform messages
+ * @var ParserOptions
+ */
+ protected $mParserOptions;
+ /** @var Parser */
+ protected $mParser;
+
+ /**
+ * Variable for tracking which variables are already loaded
+ * @var array $mLoadedLanguages
+ */
+ protected $mLoadedLanguages = [];
+
+ /**
+ * @var bool $mInParser
+ */
+ protected $mInParser = false;
+
+ /** @var WANObjectCache */
+ protected $wanCache;
+ /** @var BagOStuff */
+ protected $clusterCache;
+ /** @var BagOStuff */
+ protected $srvCache;
+
+ /**
+ * Singleton instance
+ *
+ * @var MessageCache $instance
+ */
+ private static $instance;
+
+ /**
+ * Get the signleton instance of this class
+ *
+ * @since 1.18
+ * @return MessageCache
+ */
+ public static function singleton() {
+ if ( self::$instance === null ) {
+ global $wgUseDatabaseMessages, $wgMsgCacheExpiry, $wgUseLocalMessageCache;
+ self::$instance = new self(
+ MediaWikiServices::getInstance()->getMainWANObjectCache(),
+ wfGetMessageCacheStorage(),
+ $wgUseLocalMessageCache
+ ? MediaWikiServices::getInstance()->getLocalServerObjectCache()
+ : new EmptyBagOStuff(),
+ $wgUseDatabaseMessages,
+ $wgMsgCacheExpiry
+ );
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Destroy the singleton instance
+ *
+ * @since 1.18
+ */
+ public static function destroyInstance() {
+ self::$instance = null;
+ }
+
+ /**
+ * Normalize message key input
+ *
+ * @param string $key Input message key to be normalized
+ * @return string Normalized message key
+ */
+ public static function normalizeKey( $key ) {
+ global $wgContLang;
+
+ $lckey = strtr( $key, ' ', '_' );
+ if ( ord( $lckey ) < 128 ) {
+ $lckey[0] = strtolower( $lckey[0] );
+ } else {
+ $lckey = $wgContLang->lcfirst( $lckey );
+ }
+
+ return $lckey;
+ }
+
+ /**
+ * @param WANObjectCache $wanCache WAN cache instance
+ * @param BagOStuff $clusterCache Cluster cache instance
+ * @param BagOStuff $srvCache Server cache instance
+ * @param bool $useDB Whether to look for message overrides (e.g. MediaWiki: pages)
+ * @param int $expiry Lifetime for cache. @see $mExpiry.
+ */
+ public function __construct(
+ WANObjectCache $wanCache,
+ BagOStuff $clusterCache,
+ BagOStuff $srvCache,
+ $useDB,
+ $expiry
+ ) {
+ $this->wanCache = $wanCache;
+ $this->clusterCache = $clusterCache;
+ $this->srvCache = $srvCache;
+
+ $this->mDisable = !$useDB;
+ $this->mExpiry = $expiry;
+ }
+
+ /**
+ * ParserOptions is lazy initialised.
+ *
+ * @return ParserOptions
+ */
+ function getParserOptions() {
+ global $wgUser;
+
+ if ( !$this->mParserOptions ) {
+ if ( !$wgUser->isSafeToLoad() ) {
+ // $wgUser isn't unstubbable yet, so don't try to get a
+ // ParserOptions for it. And don't cache this ParserOptions
+ // either.
+ $po = ParserOptions::newFromAnon();
+ $po->setEditSection( false );
+ $po->setAllowUnsafeRawHtml( false );
+ $po->setWrapOutputClass( false );
+ return $po;
+ }
+
+ $this->mParserOptions = new ParserOptions;
+ $this->mParserOptions->setEditSection( false );
+ // Messages may take parameters that could come
+ // from malicious sources. As a precaution, disable
+ // the <html> parser tag when parsing messages.
+ $this->mParserOptions->setAllowUnsafeRawHtml( false );
+ // Wrapping messages in an extra <div> is probably not expected. If
+ // they're outside the content area they probably shouldn't be
+ // targeted by CSS that's targeting the parser output, and if
+ // they're inside they already are from the outer div.
+ $this->mParserOptions->setWrapOutputClass( false );
+ }
+
+ return $this->mParserOptions;
+ }
+
+ /**
+ * Try to load the cache from APC.
+ *
+ * @param string $code Optional language code, see documenation of load().
+ * @return array|bool The cache array, or false if not in cache.
+ */
+ protected function getLocalCache( $code ) {
+ $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
+
+ return $this->srvCache->get( $cacheKey );
+ }
+
+ /**
+ * Save the cache to APC.
+ *
+ * @param string $code
+ * @param array $cache The cache array
+ */
+ protected function saveToLocalCache( $code, $cache ) {
+ $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
+ $this->srvCache->set( $cacheKey, $cache );
+ }
+
+ /**
+ * Loads messages from caches or from database in this order:
+ * (1) local message cache (if $wgUseLocalMessageCache is enabled)
+ * (2) memcached
+ * (3) from the database.
+ *
+ * When succesfully loading from (2) or (3), all higher level caches are
+ * updated for the newest version.
+ *
+ * Nothing is loaded if member variable mDisable is true, either manually
+ * set by calling code or if message loading fails (is this possible?).
+ *
+ * Returns true if cache is already populated or it was succesfully populated,
+ * or false if populating empty cache fails. Also returns true if MessageCache
+ * is disabled.
+ *
+ * @param string $code Language to which load messages
+ * @param int $mode Use MessageCache::FOR_UPDATE to skip process cache [optional]
+ * @throws MWException
+ * @return bool
+ */
+ protected function load( $code, $mode = null ) {
+ if ( !is_string( $code ) ) {
+ throw new InvalidArgumentException( "Missing language code" );
+ }
+
+ # Don't do double loading...
+ if ( isset( $this->mLoadedLanguages[$code] ) && $mode != self::FOR_UPDATE ) {
+ return true;
+ }
+
+ # 8 lines of code just to say (once) that message cache is disabled
+ if ( $this->mDisable ) {
+ static $shownDisabled = false;
+ if ( !$shownDisabled ) {
+ wfDebug( __METHOD__ . ": disabled\n" );
+ $shownDisabled = true;
+ }
+
+ return true;
+ }
+
+ # Loading code starts
+ $success = false; # Keep track of success
+ $staleCache = false; # a cache array with expired data, or false if none has been loaded
+ $where = []; # Debug info, delayed to avoid spamming debug log too much
+
+ # Hash of the contents is stored in memcache, to detect if data-center cache
+ # or local cache goes out of date (e.g. due to replace() on some other server)
+ list( $hash, $hashVolatile ) = $this->getValidationHash( $code );
+ $this->mCacheVolatile[$code] = $hashVolatile;
+
+ # Try the local cache and check against the cluster hash key...
+ $cache = $this->getLocalCache( $code );
+ if ( !$cache ) {
+ $where[] = 'local cache is empty';
+ } elseif ( !isset( $cache['HASH'] ) || $cache['HASH'] !== $hash ) {
+ $where[] = 'local cache has the wrong hash';
+ $staleCache = $cache;
+ } elseif ( $this->isCacheExpired( $cache ) ) {
+ $where[] = 'local cache is expired';
+ $staleCache = $cache;
+ } elseif ( $hashVolatile ) {
+ $where[] = 'local cache validation key is expired/volatile';
+ $staleCache = $cache;
+ } else {
+ $where[] = 'got from local cache';
+ $success = true;
+ $this->mCache[$code] = $cache;
+ }
+
+ if ( !$success ) {
+ $cacheKey = $this->clusterCache->makeKey( 'messages', $code ); # Key in memc for messages
+ # Try the global cache. If it is empty, try to acquire a lock. If
+ # the lock can't be acquired, wait for the other thread to finish
+ # and then try the global cache a second time.
+ for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) {
+ if ( $hashVolatile && $staleCache ) {
+ # Do not bother fetching the whole cache blob to avoid I/O.
+ # Instead, just try to get the non-blocking $statusKey lock
+ # below, and use the local stale value if it was not acquired.
+ $where[] = 'global cache is presumed expired';
+ } else {
+ $cache = $this->clusterCache->get( $cacheKey );
+ if ( !$cache ) {
+ $where[] = 'global cache is empty';
+ } elseif ( $this->isCacheExpired( $cache ) ) {
+ $where[] = 'global cache is expired';
+ $staleCache = $cache;
+ } elseif ( $hashVolatile ) {
+ # DB results are replica DB lag prone until the holdoff TTL passes.
+ # By then, updates should be reflected in loadFromDBWithLock().
+ # One thread renerates the cache while others use old values.
+ $where[] = 'global cache is expired/volatile';
+ $staleCache = $cache;
+ } else {
+ $where[] = 'got from global cache';
+ $this->mCache[$code] = $cache;
+ $this->saveToCaches( $cache, 'local-only', $code );
+ $success = true;
+ }
+ }
+
+ if ( $success ) {
+ # Done, no need to retry
+ break;
+ }
+
+ # We need to call loadFromDB. Limit the concurrency to one process.
+ # This prevents the site from going down when the cache expires.
+ # Note that the DB slam protection lock here is non-blocking.
+ $loadStatus = $this->loadFromDBWithLock( $code, $where, $mode );
+ if ( $loadStatus === true ) {
+ $success = true;
+ break;
+ } elseif ( $staleCache ) {
+ # Use the stale cache while some other thread constructs the new one
+ $where[] = 'using stale cache';
+ $this->mCache[$code] = $staleCache;
+ $success = true;
+ break;
+ } elseif ( $failedAttempts > 0 ) {
+ # Already blocked once, so avoid another lock/unlock cycle.
+ # This case will typically be hit if memcached is down, or if
+ # loadFromDB() takes longer than LOCK_WAIT.
+ $where[] = "could not acquire status key.";
+ break;
+ } elseif ( $loadStatus === 'cantacquire' ) {
+ # Wait for the other thread to finish, then retry. Normally,
+ # the memcached get() will then yeild the other thread's result.
+ $where[] = 'waited for other thread to complete';
+ $this->getReentrantScopedLock( $cacheKey );
+ } else {
+ # Disable cache; $loadStatus is 'disabled'
+ break;
+ }
+ }
+ }
+
+ if ( !$success ) {
+ $where[] = 'loading FAILED - cache is disabled';
+ $this->mDisable = true;
+ $this->mCache = false;
+ wfDebugLog( 'MessageCacheError', __METHOD__ . ": Failed to load $code\n" );
+ # This used to throw an exception, but that led to nasty side effects like
+ # the whole wiki being instantly down if the memcached server died
+ } else {
+ # All good, just record the success
+ $this->mLoadedLanguages[$code] = true;
+ }
+
+ $info = implode( ', ', $where );
+ wfDebugLog( 'MessageCache', __METHOD__ . ": Loading $code... $info\n" );
+
+ return $success;
+ }
+
+ /**
+ * @param string $code
+ * @param array &$where List of wfDebug() comments
+ * @param int $mode Use MessageCache::FOR_UPDATE to use DB_MASTER
+ * @return bool|string True on success or one of ("cantacquire", "disabled")
+ */
+ protected function loadFromDBWithLock( $code, array &$where, $mode = null ) {
+ # If cache updates on all levels fail, give up on message overrides.
+ # This is to avoid easy site outages; see $saveSuccess comments below.
+ $statusKey = $this->clusterCache->makeKey( 'messages', $code, 'status' );
+ $status = $this->clusterCache->get( $statusKey );
+ if ( $status === 'error' ) {
+ $where[] = "could not load; method is still globally disabled";
+ return 'disabled';
+ }
+
+ # Now let's regenerate
+ $where[] = 'loading from database';
+
+ # Lock the cache to prevent conflicting writes.
+ # This lock is non-blocking so stale cache can quickly be used.
+ # Note that load() will call a blocking getReentrantScopedLock()
+ # after this if it really need to wait for any current thread.
+ $cacheKey = $this->clusterCache->makeKey( 'messages', $code );
+ $scopedLock = $this->getReentrantScopedLock( $cacheKey, 0 );
+ if ( !$scopedLock ) {
+ $where[] = 'could not acquire main lock';
+ return 'cantacquire';
+ }
+
+ $cache = $this->loadFromDB( $code, $mode );
+ $this->mCache[$code] = $cache;
+ $saveSuccess = $this->saveToCaches( $cache, 'all', $code );
+
+ if ( !$saveSuccess ) {
+ /**
+ * Cache save has failed.
+ *
+ * There are two main scenarios where this could be a problem:
+ * - The cache is more than the maximum size (typically 1MB compressed).
+ * - Memcached has no space remaining in the relevant slab class. This is
+ * unlikely with recent versions of memcached.
+ *
+ * Either way, if there is a local cache, nothing bad will happen. If there
+ * is no local cache, disabling the message cache for all requests avoids
+ * incurring a loadFromDB() overhead on every request, and thus saves the
+ * wiki from complete downtime under moderate traffic conditions.
+ */
+ if ( $this->srvCache instanceof EmptyBagOStuff ) {
+ $this->clusterCache->set( $statusKey, 'error', 60 * 5 );
+ $where[] = 'could not save cache, disabled globally for 5 minutes';
+ } else {
+ $where[] = "could not save global cache";
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Loads cacheable messages from the database. Messages bigger than
+ * $wgMaxMsgCacheEntrySize are assigned a special value, and are loaded
+ * on-demand from the database later.
+ *
+ * @param string $code Language code
+ * @param int $mode Use MessageCache::FOR_UPDATE to skip process cache
+ * @return array Loaded messages for storing in caches
+ */
+ protected function loadFromDB( $code, $mode = null ) {
+ global $wgMaxMsgCacheEntrySize, $wgLanguageCode, $wgAdaptiveMessageCache;
+
+ // (T164666) The query here performs really poorly on WMF's
+ // contributions replicas. We don't have a way to say "any group except
+ // contributions", so for the moment let's specify 'api'.
+ // @todo: Get rid of this hack.
+ $dbr = wfGetDB( ( $mode == self::FOR_UPDATE ) ? DB_MASTER : DB_REPLICA, 'api' );
+
+ $cache = [];
+
+ # Common conditions
+ $conds = [
+ 'page_is_redirect' => 0,
+ 'page_namespace' => NS_MEDIAWIKI,
+ ];
+
+ $mostused = [];
+ if ( $wgAdaptiveMessageCache && $code !== $wgLanguageCode ) {
+ if ( !isset( $this->mCache[$wgLanguageCode] ) ) {
+ $this->load( $wgLanguageCode );
+ }
+ $mostused = array_keys( $this->mCache[$wgLanguageCode] );
+ foreach ( $mostused as $key => $value ) {
+ $mostused[$key] = "$value/$code";
+ }
+ }
+
+ if ( count( $mostused ) ) {
+ $conds['page_title'] = $mostused;
+ } elseif ( $code !== $wgLanguageCode ) {
+ $conds[] = 'page_title' . $dbr->buildLike( $dbr->anyString(), '/', $code );
+ } else {
+ # Effectively disallows use of '/' character in NS_MEDIAWIKI for uses
+ # other than language code.
+ $conds[] = 'page_title NOT' .
+ $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() );
+ }
+
+ # Conditions to fetch oversized pages to ignore them
+ $bigConds = $conds;
+ $bigConds[] = 'page_len > ' . intval( $wgMaxMsgCacheEntrySize );
+
+ # Load titles for all oversized pages in the MediaWiki namespace
+ $res = $dbr->select(
+ 'page',
+ [ 'page_title', 'page_latest' ],
+ $bigConds,
+ __METHOD__ . "($code)-big"
+ );
+ foreach ( $res as $row ) {
+ $cache[$row->page_title] = '!TOO BIG';
+ // At least include revision ID so page changes are reflected in the hash
+ $cache['EXCESSIVE'][$row->page_title] = $row->page_latest;
+ }
+
+ # Conditions to load the remaining pages with their contents
+ $smallConds = $conds;
+ $smallConds[] = 'page_len <= ' . intval( $wgMaxMsgCacheEntrySize );
+
+ $res = $dbr->select(
+ [ 'page', 'revision', 'text' ],
+ [ 'page_title', 'old_id', 'old_text', 'old_flags' ],
+ $smallConds,
+ __METHOD__ . "($code)-small",
+ [],
+ [
+ 'revision' => [ 'JOIN', 'page_latest=rev_id' ],
+ 'text' => [ 'JOIN', 'rev_text_id=old_id' ],
+ ]
+ );
+
+ foreach ( $res as $row ) {
+ $text = Revision::getRevisionText( $row );
+ if ( $text === false ) {
+ // Failed to fetch data; possible ES errors?
+ // Store a marker to fetch on-demand as a workaround...
+ // TODO Use a differnt marker
+ $entry = '!TOO BIG';
+ wfDebugLog(
+ 'MessageCache',
+ __METHOD__
+ . ": failed to load message page text for {$row->page_title} ($code)"
+ );
+ } else {
+ $entry = ' ' . $text;
+ }
+ $cache[$row->page_title] = $entry;
+ }
+
+ $cache['VERSION'] = MSG_CACHE_VERSION;
+ ksort( $cache );
+
+ # Hash for validating local cache (APC). No need to take into account
+ # messages larger than $wgMaxMsgCacheEntrySize, since those are only
+ # stored and fetched from memcache.
+ $cache['HASH'] = md5( serialize( $cache ) );
+ $cache['EXPIRY'] = wfTimestamp( TS_MW, time() + $this->mExpiry );
+
+ return $cache;
+ }
+
+ /**
+ * Updates cache as necessary when message page is changed
+ *
+ * @param string $title Message cache key with initial uppercase letter
+ * @param string|bool $text New contents of the page (false if deleted)
+ */
+ public function replace( $title, $text ) {
+ global $wgLanguageCode;
+
+ if ( $this->mDisable ) {
+ return;
+ }
+
+ list( $msg, $code ) = $this->figureMessage( $title );
+ if ( strpos( $title, '/' ) !== false && $code === $wgLanguageCode ) {
+ // Content language overrides do not use the /<code> suffix
+ return;
+ }
+
+ // (a) Update the process cache with the new message text
+ if ( $text === false ) {
+ // Page deleted
+ $this->mCache[$code][$title] = '!NONEXISTENT';
+ } else {
+ // Ignore $wgMaxMsgCacheEntrySize so the process cache is up to date
+ $this->mCache[$code][$title] = ' ' . $text;
+ }
+
+ // (b) Update the shared caches in a deferred update with a fresh DB snapshot
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $title, $msg, $code ) {
+ global $wgContLang, $wgMaxMsgCacheEntrySize;
+ // Allow one caller at a time to avoid race conditions
+ $scopedLock = $this->getReentrantScopedLock(
+ $this->clusterCache->makeKey( 'messages', $code )
+ );
+ if ( !$scopedLock ) {
+ LoggerFactory::getInstance( 'MessageCache' )->error(
+ __METHOD__ . ': could not acquire lock to update {title} ({code})',
+ [ 'title' => $title, 'code' => $code ] );
+ return;
+ }
+ // Load the messages from the master DB to avoid race conditions
+ $cache = $this->loadFromDB( $code, self::FOR_UPDATE );
+ $this->mCache[$code] = $cache;
+ // Load the process cache values and set the per-title cache keys
+ $page = WikiPage::factory( Title::makeTitle( NS_MEDIAWIKI, $title ) );
+ $page->loadPageData( $page::READ_LATEST );
+ $text = $this->getMessageTextFromContent( $page->getContent() );
+ // Check if an individual cache key should exist and update cache accordingly
+ if ( is_string( $text ) && strlen( $text ) > $wgMaxMsgCacheEntrySize ) {
+ $titleKey = $this->bigMessageCacheKey( $this->mCache[$code]['HASH'], $title );
+ $this->wanCache->set( $titleKey, ' ' . $text, $this->mExpiry );
+ }
+ // Mark this cache as definitely being "latest" (non-volatile) so
+ // load() calls do try to refresh the cache with replica DB data
+ $this->mCache[$code]['LATEST'] = time();
+ // Pre-emptively update the local datacenter cache so things like edit filter and
+ // blacklist changes are reflect immediately, as these often use MediaWiki: pages.
+ // The datacenter handling replace() calls should be the same one handling edits
+ // as they require HTTP POST.
+ $this->saveToCaches( $this->mCache[$code], 'all', $code );
+ // Release the lock now that the cache is saved
+ ScopedCallback::consume( $scopedLock );
+
+ // Relay the purge. Touching this check key expires cache contents
+ // and local cache (APC) validation hash across all datacenters.
+ $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'messages', $code ) );
+ // Also delete cached sidebar... just in case it is affected
+ // @TODO: shouldn't this be $code === $wgLanguageCode?
+ if ( $code === 'en' ) {
+ // Purge all language sidebars, e.g. on ?action=purge to the sidebar messages
+ $codes = array_keys( Language::fetchLanguageNames() );
+ } else {
+ // Purge only the sidebar for this language
+ $codes = [ $code ];
+ }
+ foreach ( $codes as $code ) {
+ $this->wanCache->delete( $this->wanCache->makeKey( 'sidebar', $code ) );
+ }
+
+ // Purge the message in the message blob store
+ $resourceloader = RequestContext::getMain()->getOutput()->getResourceLoader();
+ $blobStore = $resourceloader->getMessageBlobStore();
+ $blobStore->updateMessage( $wgContLang->lcfirst( $msg ) );
+
+ Hooks::run( 'MessageCacheReplace', [ $title, $text ] );
+ },
+ DeferredUpdates::PRESEND
+ );
+ }
+
+ /**
+ * Is the given cache array expired due to time passing or a version change?
+ *
+ * @param array $cache
+ * @return bool
+ */
+ protected function isCacheExpired( $cache ) {
+ if ( !isset( $cache['VERSION'] ) || !isset( $cache['EXPIRY'] ) ) {
+ return true;
+ }
+ if ( $cache['VERSION'] != MSG_CACHE_VERSION ) {
+ return true;
+ }
+ if ( wfTimestampNow() >= $cache['EXPIRY'] ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Shortcut to update caches.
+ *
+ * @param array $cache Cached messages with a version.
+ * @param string $dest Either "local-only" to save to local caches only
+ * or "all" to save to all caches.
+ * @param string|bool $code Language code (default: false)
+ * @return bool
+ */
+ protected function saveToCaches( array $cache, $dest, $code = false ) {
+ if ( $dest === 'all' ) {
+ $cacheKey = $this->clusterCache->makeKey( 'messages', $code );
+ $success = $this->clusterCache->set( $cacheKey, $cache );
+ $this->setValidationHash( $code, $cache );
+ } else {
+ $success = true;
+ }
+
+ $this->saveToLocalCache( $code, $cache );
+
+ return $success;
+ }
+
+ /**
+ * Get the md5 used to validate the local APC cache
+ *
+ * @param string $code
+ * @return array (hash or false, bool expiry/volatility status)
+ */
+ protected function getValidationHash( $code ) {
+ $curTTL = null;
+ $value = $this->wanCache->get(
+ $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ),
+ $curTTL,
+ [ $this->wanCache->makeKey( 'messages', $code ) ]
+ );
+
+ if ( $value ) {
+ $hash = $value['hash'];
+ if ( ( time() - $value['latest'] ) < WANObjectCache::TTL_MINUTE ) {
+ // Cache was recently updated via replace() and should be up-to-date.
+ // That method is only called in the primary datacenter and uses FOR_UPDATE.
+ // Also, it is unlikely that the current datacenter is *now* secondary one.
+ $expired = false;
+ } else {
+ // See if the "check" key was bumped after the hash was generated
+ $expired = ( $curTTL < 0 );
+ }
+ } else {
+ // No hash found at all; cache must regenerate to be safe
+ $hash = false;
+ $expired = true;
+ }
+
+ return [ $hash, $expired ];
+ }
+
+ /**
+ * Set the md5 used to validate the local disk cache
+ *
+ * If $cache has a 'LATEST' UNIX timestamp key, then the hash will not
+ * be treated as "volatile" by getValidationHash() for the next few seconds.
+ * This is triggered when $cache is generated using FOR_UPDATE mode.
+ *
+ * @param string $code
+ * @param array $cache Cached messages with a version
+ */
+ protected function setValidationHash( $code, array $cache ) {
+ $this->wanCache->set(
+ $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ),
+ [
+ 'hash' => $cache['HASH'],
+ 'latest' => isset( $cache['LATEST'] ) ? $cache['LATEST'] : 0
+ ],
+ WANObjectCache::TTL_INDEFINITE
+ );
+ }
+
+ /**
+ * @param string $key A language message cache key that stores blobs
+ * @param int $timeout Wait timeout in seconds
+ * @return null|ScopedCallback
+ */
+ protected function getReentrantScopedLock( $key, $timeout = self::WAIT_SEC ) {
+ return $this->clusterCache->getScopedLock( $key, $timeout, self::LOCK_TTL, __METHOD__ );
+ }
+
+ /**
+ * Get a message from either the content language or the user language.
+ *
+ * First, assemble a list of languages to attempt getting the message from. This
+ * chain begins with the requested language and its fallbacks and then continues with
+ * the content language and its fallbacks. For each language in the chain, the following
+ * process will occur (in this order):
+ * 1. If a language-specific override, i.e., [[MW:msg/lang]], is available, use that.
+ * Note: for the content language, there is no /lang subpage.
+ * 2. Fetch from the static CDB cache.
+ * 3. If available, check the database for fallback language overrides.
+ *
+ * This process provides a number of guarantees. When changing this code, make sure all
+ * of these guarantees are preserved.
+ * * If the requested language is *not* the content language, then the CDB cache for that
+ * specific language will take precedence over the root database page ([[MW:msg]]).
+ * * Fallbacks will be just that: fallbacks. A fallback language will never be reached if
+ * the message is available *anywhere* in the language for which it is a fallback.
+ *
+ * @param string $key The message key
+ * @param bool $useDB If true, look for the message in the DB, false
+ * to use only the compiled l10n cache.
+ * @param bool|string|object $langcode Code of the language to get the message for.
+ * - If string and a valid code, will create a standard language object
+ * - If string but not a valid code, will create a basic language object
+ * - If boolean and false, create object from the current users language
+ * - If boolean and true, create object from the wikis content language
+ * - If language object, use it as given
+ * @param bool $isFullKey Specifies whether $key is a two part key "msg/lang".
+ *
+ * @throws MWException When given an invalid key
+ * @return string|bool False if the message doesn't exist, otherwise the
+ * message (which can be empty)
+ */
+ function get( $key, $useDB = true, $langcode = true, $isFullKey = false ) {
+ if ( is_int( $key ) ) {
+ // Fix numerical strings that somehow become ints
+ // on their way here
+ $key = (string)$key;
+ } elseif ( !is_string( $key ) ) {
+ throw new MWException( 'Non-string key given' );
+ } elseif ( $key === '' ) {
+ // Shortcut: the empty key is always missing
+ return false;
+ }
+
+ // For full keys, get the language code from the key
+ $pos = strrpos( $key, '/' );
+ if ( $isFullKey && $pos !== false ) {
+ $langcode = substr( $key, $pos + 1 );
+ $key = substr( $key, 0, $pos );
+ }
+
+ // Normalise title-case input (with some inlining)
+ $lckey = self::normalizeKey( $key );
+
+ Hooks::run( 'MessageCache::get', [ &$lckey ] );
+
+ // Loop through each language in the fallback list until we find something useful
+ $lang = wfGetLangObj( $langcode );
+ $message = $this->getMessageFromFallbackChain(
+ $lang,
+ $lckey,
+ !$this->mDisable && $useDB
+ );
+
+ // If we still have no message, maybe the key was in fact a full key so try that
+ if ( $message === false ) {
+ $parts = explode( '/', $lckey );
+ // We may get calls for things that are http-urls from sidebar
+ // Let's not load nonexistent languages for those
+ // They usually have more than one slash.
+ if ( count( $parts ) == 2 && $parts[1] !== '' ) {
+ $message = Language::getMessageFor( $parts[0], $parts[1] );
+ if ( $message === null ) {
+ $message = false;
+ }
+ }
+ }
+
+ // Post-processing if the message exists
+ if ( $message !== false ) {
+ // Fix whitespace
+ $message = str_replace(
+ [
+ # Fix for trailing whitespace, removed by textarea
+ '&#32;',
+ # Fix for NBSP, converted to space by firefox
+ '&nbsp;',
+ '&#160;',
+ '&shy;'
+ ],
+ [
+ ' ',
+ "\xc2\xa0",
+ "\xc2\xa0",
+ "\xc2\xad"
+ ],
+ $message
+ );
+ }
+
+ return $message;
+ }
+
+ /**
+ * Given a language, try and fetch messages from that language.
+ *
+ * Will also consider fallbacks of that language, the site language, and fallbacks for
+ * the site language.
+ *
+ * @see MessageCache::get
+ * @param Language|StubObject $lang Preferred language
+ * @param string $lckey Lowercase key for the message (as for localisation cache)
+ * @param bool $useDB Whether to include messages from the wiki database
+ * @return string|bool The message, or false if not found
+ */
+ protected function getMessageFromFallbackChain( $lang, $lckey, $useDB ) {
+ global $wgContLang;
+
+ $alreadyTried = [];
+
+ // First try the requested language.
+ $message = $this->getMessageForLang( $lang, $lckey, $useDB, $alreadyTried );
+ if ( $message !== false ) {
+ return $message;
+ }
+
+ // Now try checking the site language.
+ $message = $this->getMessageForLang( $wgContLang, $lckey, $useDB, $alreadyTried );
+ return $message;
+ }
+
+ /**
+ * Given a language, try and fetch messages from that language and its fallbacks.
+ *
+ * @see MessageCache::get
+ * @param Language|StubObject $lang Preferred language
+ * @param string $lckey Lowercase key for the message (as for localisation cache)
+ * @param bool $useDB Whether to include messages from the wiki database
+ * @param bool[] $alreadyTried Contains true for each language that has been tried already
+ * @return string|bool The message, or false if not found
+ */
+ private function getMessageForLang( $lang, $lckey, $useDB, &$alreadyTried ) {
+ global $wgContLang;
+
+ $langcode = $lang->getCode();
+
+ // Try checking the database for the requested language
+ if ( $useDB ) {
+ $uckey = $wgContLang->ucfirst( $lckey );
+
+ if ( !isset( $alreadyTried[ $langcode ] ) ) {
+ $message = $this->getMsgFromNamespace(
+ $this->getMessagePageName( $langcode, $uckey ),
+ $langcode
+ );
+
+ if ( $message !== false ) {
+ return $message;
+ }
+ $alreadyTried[ $langcode ] = true;
+ }
+ } else {
+ $uckey = null;
+ }
+
+ // Check the CDB cache
+ $message = $lang->getMessage( $lckey );
+ if ( $message !== null ) {
+ return $message;
+ }
+
+ // Try checking the database for all of the fallback languages
+ if ( $useDB ) {
+ $fallbackChain = Language::getFallbacksFor( $langcode );
+
+ foreach ( $fallbackChain as $code ) {
+ if ( isset( $alreadyTried[ $code ] ) ) {
+ continue;
+ }
+
+ $message = $this->getMsgFromNamespace(
+ $this->getMessagePageName( $code, $uckey ), $code );
+
+ if ( $message !== false ) {
+ return $message;
+ }
+ $alreadyTried[ $code ] = true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the message page name for a given language
+ *
+ * @param string $langcode
+ * @param string $uckey Uppercase key for the message
+ * @return string The page name
+ */
+ private function getMessagePageName( $langcode, $uckey ) {
+ global $wgLanguageCode;
+
+ if ( $langcode === $wgLanguageCode ) {
+ // Messages created in the content language will not have the /lang extension
+ return $uckey;
+ } else {
+ return "$uckey/$langcode";
+ }
+ }
+
+ /**
+ * Get a message from the MediaWiki namespace, with caching. The key must
+ * first be converted to two-part lang/msg form if necessary.
+ *
+ * Unlike self::get(), this function doesn't resolve fallback chains, and
+ * some callers require this behavior. LanguageConverter::parseCachedTable()
+ * and self::get() are some examples in core.
+ *
+ * @param string $title Message cache key with initial uppercase letter
+ * @param string $code Code denoting the language to try
+ * @return string|bool The message, or false if it does not exist or on error
+ */
+ public function getMsgFromNamespace( $title, $code ) {
+ $this->load( $code );
+
+ if ( isset( $this->mCache[$code][$title] ) ) {
+ $entry = $this->mCache[$code][$title];
+ if ( substr( $entry, 0, 1 ) === ' ' ) {
+ // The message exists, so make sure a string is returned.
+ return (string)substr( $entry, 1 );
+ } elseif ( $entry === '!NONEXISTENT' ) {
+ return false;
+ } elseif ( $entry === '!TOO BIG' ) {
+ // Fall through and try invididual message cache below
+ }
+ } else {
+ // XXX: This is not cached in process cache, should it?
+ $message = false;
+ Hooks::run( 'MessagesPreLoad', [ $title, &$message, $code ] );
+ if ( $message !== false ) {
+ return $message;
+ }
+
+ return false;
+ }
+
+ // Individual message cache key
+ $titleKey = $this->bigMessageCacheKey( $this->mCache[$code]['HASH'], $title );
+
+ if ( $this->mCacheVolatile[$code] ) {
+ $entry = false;
+ // Make sure that individual keys respect the WAN cache holdoff period too
+ LoggerFactory::getInstance( 'MessageCache' )->debug(
+ __METHOD__ . ': loading volatile key \'{titleKey}\'',
+ [ 'titleKey' => $titleKey, 'code' => $code ] );
+ } else {
+ // Try the individual message cache
+ $entry = $this->wanCache->get( $titleKey );
+ }
+
+ if ( $entry !== false ) {
+ if ( substr( $entry, 0, 1 ) === ' ' ) {
+ $this->mCache[$code][$title] = $entry;
+ // The message exists, so make sure a string is returned
+ return (string)substr( $entry, 1 );
+ } elseif ( $entry === '!NONEXISTENT' ) {
+ $this->mCache[$code][$title] = '!NONEXISTENT';
+
+ return false;
+ } else {
+ // Corrupt/obsolete entry, delete it
+ $this->wanCache->delete( $titleKey );
+ }
+ }
+
+ // Try loading the message from the database
+ $dbr = wfGetDB( DB_REPLICA );
+ $cacheOpts = Database::getCacheSetOptions( $dbr );
+ // Use newKnownCurrent() to avoid querying revision/user tables
+ $titleObj = Title::makeTitle( NS_MEDIAWIKI, $title );
+ if ( $titleObj->getLatestRevID() ) {
+ $revision = Revision::newKnownCurrent(
+ $dbr,
+ $titleObj->getArticleID(),
+ $titleObj->getLatestRevID()
+ );
+ } else {
+ $revision = false;
+ }
+
+ if ( $revision ) {
+ $content = $revision->getContent();
+ if ( $content ) {
+ $message = $this->getMessageTextFromContent( $content );
+ if ( is_string( $message ) ) {
+ $this->mCache[$code][$title] = ' ' . $message;
+ $this->wanCache->set( $titleKey, ' ' . $message, $this->mExpiry, $cacheOpts );
+ }
+ } else {
+ // A possibly temporary loading failure
+ LoggerFactory::getInstance( 'MessageCache' )->warning(
+ __METHOD__ . ': failed to load message page text for \'{titleKey}\'',
+ [ 'titleKey' => $titleKey, 'code' => $code ] );
+ $message = null; // no negative caching
+ }
+ } else {
+ $message = false; // negative caching
+ }
+
+ if ( $message === false ) {
+ // Negative caching in case a "too big" message is no longer available (deleted)
+ $this->mCache[$code][$title] = '!NONEXISTENT';
+ $this->wanCache->set( $titleKey, '!NONEXISTENT', $this->mExpiry, $cacheOpts );
+ }
+
+ return $message;
+ }
+
+ /**
+ * @param string $message
+ * @param bool $interface
+ * @param string $language Language code
+ * @param Title $title
+ * @return string
+ */
+ function transform( $message, $interface = false, $language = null, $title = null ) {
+ // Avoid creating parser if nothing to transform
+ if ( strpos( $message, '{{' ) === false ) {
+ return $message;
+ }
+
+ if ( $this->mInParser ) {
+ return $message;
+ }
+
+ $parser = $this->getParser();
+ if ( $parser ) {
+ $popts = $this->getParserOptions();
+ $popts->setInterfaceMessage( $interface );
+ $popts->setTargetLanguage( $language );
+
+ $userlang = $popts->setUserLang( $language );
+ $this->mInParser = true;
+ $message = $parser->transformMsg( $message, $popts, $title );
+ $this->mInParser = false;
+ $popts->setUserLang( $userlang );
+ }
+
+ return $message;
+ }
+
+ /**
+ * @return Parser
+ */
+ function getParser() {
+ global $wgParser, $wgParserConf;
+
+ if ( !$this->mParser && isset( $wgParser ) ) {
+ # Do some initialisation so that we don't have to do it twice
+ $wgParser->firstCallInit();
+ # Clone it and store it
+ $class = $wgParserConf['class'];
+ if ( $class == 'ParserDiffTest' ) {
+ # Uncloneable
+ $this->mParser = new $class( $wgParserConf );
+ } else {
+ $this->mParser = clone $wgParser;
+ }
+ }
+
+ return $this->mParser;
+ }
+
+ /**
+ * @param string $text
+ * @param Title $title
+ * @param bool $linestart Whether or not this is at the start of a line
+ * @param bool $interface Whether this is an interface message
+ * @param Language|string $language Language code
+ * @return ParserOutput|string
+ */
+ public function parse( $text, $title = null, $linestart = true,
+ $interface = false, $language = null
+ ) {
+ global $wgTitle;
+
+ if ( $this->mInParser ) {
+ return htmlspecialchars( $text );
+ }
+
+ $parser = $this->getParser();
+ $popts = $this->getParserOptions();
+ $popts->setInterfaceMessage( $interface );
+
+ if ( is_string( $language ) ) {
+ $language = Language::factory( $language );
+ }
+ $popts->setTargetLanguage( $language );
+
+ if ( !$title || !$title instanceof Title ) {
+ wfDebugLog( 'GlobalTitleFail', __METHOD__ . ' called by ' .
+ wfGetAllCallers( 6 ) . ' with no title set.' );
+ $title = $wgTitle;
+ }
+ // Sometimes $wgTitle isn't set either...
+ if ( !$title ) {
+ # It's not uncommon having a null $wgTitle in scripts. See r80898
+ # Create a ghost title in such case
+ $title = Title::makeTitle( NS_SPECIAL, 'Badtitle/title not set in ' . __METHOD__ );
+ }
+
+ $this->mInParser = true;
+ $res = $parser->parse( $text, $title, $popts, $linestart );
+ $this->mInParser = false;
+
+ return $res;
+ }
+
+ function disable() {
+ $this->mDisable = true;
+ }
+
+ function enable() {
+ $this->mDisable = false;
+ }
+
+ /**
+ * Whether DB/cache usage is disabled for determining messages
+ *
+ * If so, this typically indicates either:
+ * - a) load() failed to find a cached copy nor query the DB
+ * - b) we are in a special context or error mode that cannot use the DB
+ * If the DB is ignored, any derived HTML output or cached objects may be wrong.
+ * To avoid long-term cache pollution, TTLs can be adjusted accordingly.
+ *
+ * @return bool
+ * @since 1.27
+ */
+ public function isDisabled() {
+ return $this->mDisable;
+ }
+
+ /**
+ * Clear all stored messages. Mainly used after a mass rebuild.
+ */
+ function clear() {
+ $langs = Language::fetchLanguageNames( null, 'mw' );
+ foreach ( array_keys( $langs ) as $code ) {
+ # Global and local caches
+ $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'messages', $code ) );
+ }
+
+ $this->mLoadedLanguages = [];
+ }
+
+ /**
+ * @param string $key
+ * @return array
+ */
+ public function figureMessage( $key ) {
+ global $wgLanguageCode;
+
+ $pieces = explode( '/', $key );
+ if ( count( $pieces ) < 2 ) {
+ return [ $key, $wgLanguageCode ];
+ }
+
+ $lang = array_pop( $pieces );
+ if ( !Language::fetchLanguageName( $lang, null, 'mw' ) ) {
+ return [ $key, $wgLanguageCode ];
+ }
+
+ $message = implode( '/', $pieces );
+
+ return [ $message, $lang ];
+ }
+
+ /**
+ * Get all message keys stored in the message cache for a given language.
+ * If $code is the content language code, this will return all message keys
+ * for which MediaWiki:msgkey exists. If $code is another language code, this
+ * will ONLY return message keys for which MediaWiki:msgkey/$code exists.
+ * @param string $code Language code
+ * @return array Array of message keys (strings)
+ */
+ public function getAllMessageKeys( $code ) {
+ global $wgContLang;
+
+ $this->load( $code );
+ if ( !isset( $this->mCache[$code] ) ) {
+ // Apparently load() failed
+ return null;
+ }
+ // Remove administrative keys
+ $cache = $this->mCache[$code];
+ unset( $cache['VERSION'] );
+ unset( $cache['EXPIRY'] );
+ unset( $cache['EXCESSIVE'] );
+ // Remove any !NONEXISTENT keys
+ $cache = array_diff( $cache, [ '!NONEXISTENT' ] );
+
+ // Keys may appear with a capital first letter. lcfirst them.
+ return array_map( [ $wgContLang, 'lcfirst' ], array_keys( $cache ) );
+ }
+
+ /**
+ * Purge message caches when a MediaWiki: page is created, updated, or deleted
+ *
+ * @param Title $title Message page title
+ * @param Content|null $content New content for edit/create, null on deletion
+ * @since 1.29
+ */
+ public function updateMessageOverride( Title $title, Content $content = null ) {
+ global $wgContLang;
+
+ $msgText = $this->getMessageTextFromContent( $content );
+ if ( $msgText === null ) {
+ $msgText = false; // treat as not existing
+ }
+
+ $this->replace( $title->getDBkey(), $msgText );
+
+ if ( $wgContLang->hasVariants() ) {
+ $wgContLang->updateConversionTable( $title );
+ }
+ }
+
+ /**
+ * @param Content|null $content Content or null if the message page does not exist
+ * @return string|bool|null Returns false if $content is null and null on error
+ */
+ private function getMessageTextFromContent( Content $content = null ) {
+ // @TODO: could skip pseudo-messages like js/css here, based on content model
+ if ( $content ) {
+ // Message page exists...
+ // XXX: Is this the right way to turn a Content object into a message?
+ // NOTE: $content is typically either WikitextContent, JavaScriptContent or
+ // CssContent. MessageContent is *not* used for storing messages, it's
+ // only used for wrapping them when needed.
+ $msgText = $content->getWikitextForTransclusion();
+ if ( $msgText === false || $msgText === null ) {
+ // This might be due to some kind of misconfiguration...
+ $msgText = null;
+ LoggerFactory::getInstance( 'MessageCache' )->warning(
+ __METHOD__ . ": message content doesn't provide wikitext "
+ . "(content model: " . $content->getModel() . ")" );
+ }
+ } else {
+ // Message page does not exist...
+ $msgText = false;
+ }
+
+ return $msgText;
+ }
+
+ /**
+ * @param string $hash Hash for this version of the entire key/value overrides map
+ * @param string $title Message cache key with initial uppercase letter
+ * @return string
+ */
+ private function bigMessageCacheKey( $hash, $title ) {
+ return $this->wanCache->makeKey( 'messages-big', $hash, $title );
+ }
+}
diff --git a/www/wiki/includes/cache/ObjectFileCache.php b/www/wiki/includes/cache/ObjectFileCache.php
new file mode 100644
index 00000000..c7ef0443
--- /dev/null
+++ b/www/wiki/includes/cache/ObjectFileCache.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * Object cache in the file system.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * Object cache in the file system.
+ *
+ * @ingroup Cache
+ */
+class ObjectFileCache extends FileCacheBase {
+ /**
+ * Construct an ObjectFileCache from a key and a type
+ * @param string $key
+ * @param string $type
+ * @return ObjectFileCache
+ */
+ public static function newFromKey( $key, $type ) {
+ $cache = new self();
+
+ $cache->mKey = (string)$key;
+ $cache->mType = (string)$type;
+
+ return $cache;
+ }
+
+ /**
+ * Get the base file cache directory
+ * @return string
+ */
+ protected function cacheDirectory() {
+ return $this->baseCacheDirectory() . '/object';
+ }
+}
diff --git a/www/wiki/includes/cache/ResourceFileCache.php b/www/wiki/includes/cache/ResourceFileCache.php
new file mode 100644
index 00000000..326d0659
--- /dev/null
+++ b/www/wiki/includes/cache/ResourceFileCache.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * ResourceLoader request result caching in the file system.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * ResourceLoader request result caching in the file system.
+ *
+ * @ingroup Cache
+ */
+class ResourceFileCache extends FileCacheBase {
+ protected $mCacheWorthy;
+
+ /* @todo configurable? */
+ const MISS_THRESHOLD = 360; // 6/min * 60 min
+
+ /**
+ * Construct an ResourceFileCache from a context
+ * @param ResourceLoaderContext $context
+ * @return ResourceFileCache
+ */
+ public static function newFromContext( ResourceLoaderContext $context ) {
+ $cache = new self();
+
+ if ( $context->getImage() ) {
+ $cache->mType = 'image';
+ } elseif ( $context->getOnly() === 'styles' ) {
+ $cache->mType = 'css';
+ } else {
+ $cache->mType = 'js';
+ }
+ $modules = array_unique( $context->getModules() ); // remove duplicates
+ sort( $modules ); // normalize the order (permutation => combination)
+ $cache->mKey = sha1( $context->getHash() . implode( '|', $modules ) );
+ if ( count( $modules ) == 1 ) {
+ $cache->mCacheWorthy = true; // won't take up much space
+ }
+
+ return $cache;
+ }
+
+ /**
+ * Check if an RL request can be cached.
+ * Caller is responsible for checking if any modules are private.
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public static function useFileCache( ResourceLoaderContext $context ) {
+ global $wgUseFileCache, $wgDefaultSkin, $wgLanguageCode;
+ if ( !$wgUseFileCache ) {
+ return false;
+ }
+ // Get all query values
+ $queryVals = $context->getRequest()->getValues();
+ foreach ( $queryVals as $query => $val ) {
+ if ( in_array( $query, [ 'modules', 'image', 'variant', 'version', '*' ] ) ) {
+ // Use file cache regardless of the value of this parameter
+ continue; // note: &* added as IE fix
+ } elseif ( $query === 'skin' && $val === $wgDefaultSkin ) {
+ continue;
+ } elseif ( $query === 'lang' && $val === $wgLanguageCode ) {
+ continue;
+ } elseif ( $query === 'only' && in_array( $val, [ 'styles', 'scripts' ] ) ) {
+ continue;
+ } elseif ( $query === 'debug' && $val === 'false' ) {
+ continue;
+ } elseif ( $query === 'format' && $val === 'rasterized' ) {
+ continue;
+ }
+
+ return false;
+ }
+
+ return true; // cacheable
+ }
+
+ /**
+ * Get the base file cache directory
+ * @return string
+ */
+ protected function cacheDirectory() {
+ return $this->baseCacheDirectory() . '/resources';
+ }
+
+ /**
+ * Item has many recent cache misses
+ * @return bool
+ */
+ public function isCacheWorthy() {
+ if ( $this->mCacheWorthy === null ) {
+ $this->mCacheWorthy = (
+ $this->isCached() || // even stale cache indicates it was cache worthy
+ $this->getMissesRecent() >= self::MISS_THRESHOLD // many misses
+ );
+ }
+
+ return $this->mCacheWorthy;
+ }
+}
diff --git a/www/wiki/includes/cache/UserCache.php b/www/wiki/includes/cache/UserCache.php
new file mode 100644
index 00000000..5c752926
--- /dev/null
+++ b/www/wiki/includes/cache/UserCache.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Caches current user names and other info based on user IDs.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * @since 1.20
+ */
+class UserCache {
+ protected $cache = []; // (uid => property => value)
+ protected $typesCached = []; // (uid => cache type => 1)
+
+ /**
+ * @return UserCache
+ */
+ public static function singleton() {
+ static $instance = null;
+ if ( $instance === null ) {
+ $instance = new self();
+ }
+
+ return $instance;
+ }
+
+ protected function __construct() {
+ }
+
+ /**
+ * Get a property of a user based on their user ID
+ *
+ * @param int $userId User ID
+ * @param string $prop User property
+ * @return mixed|bool The property or false if the user does not exist
+ */
+ public function getProp( $userId, $prop ) {
+ if ( !isset( $this->cache[$userId][$prop] ) ) {
+ wfDebug( __METHOD__ . ": querying DB for prop '$prop' for user ID '$userId'.\n" );
+ $this->doQuery( [ $userId ] ); // cache miss
+ }
+
+ return isset( $this->cache[$userId][$prop] )
+ ? $this->cache[$userId][$prop]
+ : false; // user does not exist?
+ }
+
+ /**
+ * Get the name of a user or return $ip if the user ID is 0
+ *
+ * @param int $userId
+ * @param string $ip
+ * @return string
+ * @since 1.22
+ */
+ public function getUserName( $userId, $ip ) {
+ return $userId > 0 ? $this->getProp( $userId, 'name' ) : $ip;
+ }
+
+ /**
+ * Preloads user names for given list of users.
+ * @param array $userIds List of user IDs
+ * @param array $options Option flags; include 'userpage' and 'usertalk'
+ * @param string $caller The calling method
+ */
+ public function doQuery( array $userIds, $options = [], $caller = '' ) {
+ $usersToCheck = [];
+ $usersToQuery = [];
+
+ $userIds = array_unique( $userIds );
+
+ foreach ( $userIds as $userId ) {
+ $userId = (int)$userId;
+ if ( $userId <= 0 ) {
+ continue; // skip anons
+ }
+ if ( isset( $this->cache[$userId]['name'] ) ) {
+ $usersToCheck[$userId] = $this->cache[$userId]['name']; // already have name
+ } else {
+ $usersToQuery[] = $userId; // we need to get the name
+ }
+ }
+
+ // Lookup basic info for users not yet loaded...
+ if ( count( $usersToQuery ) ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $table = [ 'user' ];
+ $conds = [ 'user_id' => $usersToQuery ];
+ $fields = [ 'user_name', 'user_real_name', 'user_registration', 'user_id' ];
+
+ $comment = __METHOD__;
+ if ( strval( $caller ) !== '' ) {
+ $comment .= "/$caller";
+ }
+
+ $res = $dbr->select( $table, $fields, $conds, $comment );
+ foreach ( $res as $row ) { // load each user into cache
+ $userId = (int)$row->user_id;
+ $this->cache[$userId]['name'] = $row->user_name;
+ $this->cache[$userId]['real_name'] = $row->user_real_name;
+ $this->cache[$userId]['registration'] = $row->user_registration;
+ $usersToCheck[$userId] = $row->user_name;
+ }
+ }
+
+ $lb = new LinkBatch();
+ foreach ( $usersToCheck as $userId => $name ) {
+ if ( $this->queryNeeded( $userId, 'userpage', $options ) ) {
+ $lb->add( NS_USER, $name );
+ $this->typesCached[$userId]['userpage'] = 1;
+ }
+ if ( $this->queryNeeded( $userId, 'usertalk', $options ) ) {
+ $lb->add( NS_USER_TALK, $name );
+ $this->typesCached[$userId]['usertalk'] = 1;
+ }
+ }
+ $lb->execute();
+ }
+
+ /**
+ * Check if a cache type is in $options and was not loaded for this user
+ *
+ * @param int $uid User ID
+ * @param string $type Cache type
+ * @param array $options Requested cache types
+ * @return bool
+ */
+ protected function queryNeeded( $uid, $type, array $options ) {
+ return ( in_array( $type, $options ) && !isset( $this->typesCached[$uid][$type] ) );
+ }
+}
diff --git a/www/wiki/includes/cache/localisation/LCStore.php b/www/wiki/includes/cache/localisation/LCStore.php
new file mode 100644
index 00000000..cb1e2612
--- /dev/null
+++ b/www/wiki/includes/cache/localisation/LCStore.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Interface for the persistence layer of LocalisationCache.
+ *
+ * The persistence layer is two-level hierarchical cache. The first level
+ * is the language, the second level is the item or subitem.
+ *
+ * Since the data for a whole language is rebuilt in one operation, it needs
+ * to have a fast and atomic method for deleting or replacing all of the
+ * current data for a given language. The interface reflects this bulk update
+ * operation. Callers writing to the cache must first call startWrite(), then
+ * will call set() a couple of thousand times, then will call finishWrite()
+ * to commit the operation. When finishWrite() is called, the cache is
+ * expected to delete all data previously stored for that language.
+ *
+ * The values stored are PHP variables suitable for serialize(). Implementations
+ * of LCStore are responsible for serializing and unserializing.
+ */
+interface LCStore {
+
+ /**
+ * Get a value.
+ * @param string $code Language code
+ * @param string $key Cache key
+ */
+ function get( $code, $key );
+
+ /**
+ * Start a write transaction.
+ * @param string $code Language code
+ */
+ function startWrite( $code );
+
+ /**
+ * Finish a write transaction.
+ */
+ function finishWrite();
+
+ /**
+ * Set a key to a given value. startWrite() must be called before this
+ * is called, and finishWrite() must be called afterwards.
+ * @param string $key
+ * @param mixed $value
+ */
+ function set( $key, $value );
+
+}
diff --git a/www/wiki/includes/cache/localisation/LCStoreCDB.php b/www/wiki/includes/cache/localisation/LCStoreCDB.php
new file mode 100644
index 00000000..78a4863f
--- /dev/null
+++ b/www/wiki/includes/cache/localisation/LCStoreCDB.php
@@ -0,0 +1,144 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Cdb\Exception;
+use Cdb\Reader;
+use Cdb\Writer;
+
+/**
+ * LCStore implementation which stores data as a collection of CDB files in the
+ * directory given by $wgCacheDirectory. If $wgCacheDirectory is not set, this
+ * will throw an exception.
+ *
+ * Profiling indicates that on Linux, this implementation outperforms MySQL if
+ * the directory is on a local filesystem and there is ample kernel cache
+ * space. The performance advantage is greater when the DBA extension is
+ * available than it is with the PHP port.
+ *
+ * See Cdb.php and https://cr.yp.to/cdb.html
+ */
+class LCStoreCDB implements LCStore {
+
+ /** @var Reader[] */
+ private $readers;
+
+ /** @var Writer */
+ private $writer;
+
+ /** @var string Current language code */
+ private $currentLang;
+
+ /** @var bool|string Cache directory. False if not set */
+ private $directory;
+
+ function __construct( $conf = [] ) {
+ global $wgCacheDirectory;
+
+ if ( isset( $conf['directory'] ) ) {
+ $this->directory = $conf['directory'];
+ } else {
+ $this->directory = $wgCacheDirectory;
+ }
+ }
+
+ public function get( $code, $key ) {
+ if ( !isset( $this->readers[$code] ) ) {
+ $fileName = $this->getFileName( $code );
+
+ $this->readers[$code] = false;
+ if ( file_exists( $fileName ) ) {
+ try {
+ $this->readers[$code] = Reader::open( $fileName );
+ } catch ( Exception $e ) {
+ wfDebug( __METHOD__ . ": unable to open cdb file for reading\n" );
+ }
+ }
+ }
+
+ if ( !$this->readers[$code] ) {
+ return null;
+ } else {
+ $value = false;
+ try {
+ $value = $this->readers[$code]->get( $key );
+ } catch ( Exception $e ) {
+ wfDebug( __METHOD__ . ": \Cdb\Exception caught, error message was "
+ . $e->getMessage() . "\n" );
+ }
+ if ( $value === false ) {
+ return null;
+ }
+
+ return unserialize( $value );
+ }
+ }
+
+ public function startWrite( $code ) {
+ if ( !file_exists( $this->directory ) ) {
+ if ( !wfMkdirParents( $this->directory, null, __METHOD__ ) ) {
+ throw new MWException( "Unable to create the localisation store " .
+ "directory \"{$this->directory}\"" );
+ }
+ }
+
+ // Close reader to stop permission errors on write
+ if ( !empty( $this->readers[$code] ) ) {
+ $this->readers[$code]->close();
+ }
+
+ try {
+ $this->writer = Writer::open( $this->getFileName( $code ) );
+ } catch ( Exception $e ) {
+ throw new MWException( $e->getMessage() );
+ }
+ $this->currentLang = $code;
+ }
+
+ public function finishWrite() {
+ // Close the writer
+ try {
+ $this->writer->close();
+ } catch ( Exception $e ) {
+ throw new MWException( $e->getMessage() );
+ }
+ $this->writer = null;
+ unset( $this->readers[$this->currentLang] );
+ $this->currentLang = null;
+ }
+
+ public function set( $key, $value ) {
+ if ( is_null( $this->writer ) ) {
+ throw new MWException( __CLASS__ . ': must call startWrite() before calling set()' );
+ }
+ try {
+ $this->writer->set( $key, serialize( $value ) );
+ } catch ( Exception $e ) {
+ throw new MWException( $e->getMessage() );
+ }
+ }
+
+ protected function getFileName( $code ) {
+ if ( strval( $code ) === '' || strpos( $code, '/' ) !== false ) {
+ throw new MWException( __METHOD__ . ": Invalid language \"$code\"" );
+ }
+
+ return "{$this->directory}/l10n_cache-$code.cdb";
+ }
+
+}
diff --git a/www/wiki/includes/cache/localisation/LCStoreDB.php b/www/wiki/includes/cache/localisation/LCStoreDB.php
new file mode 100644
index 00000000..c57145c0
--- /dev/null
+++ b/www/wiki/includes/cache/localisation/LCStoreDB.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBQueryError;
+
+/**
+ * LCStore implementation which uses the standard DB functions to store data.
+ * This will work on any MediaWiki installation.
+ */
+class LCStoreDB implements LCStore {
+
+ /** @var string */
+ private $currentLang;
+ /** @var bool */
+ private $writesDone = false;
+ /** @var IDatabase */
+ private $dbw;
+ /** @var array */
+ private $batch = [];
+ /** @var bool */
+ private $readOnly = false;
+
+ public function get( $code, $key ) {
+ if ( $this->writesDone && $this->dbw ) {
+ $db = $this->dbw; // see the changes in finishWrite()
+ } else {
+ $db = wfGetDB( DB_REPLICA );
+ }
+
+ $value = $db->selectField(
+ 'l10n_cache',
+ 'lc_value',
+ [ 'lc_lang' => $code, 'lc_key' => $key ],
+ __METHOD__
+ );
+
+ return ( $value !== false ) ? unserialize( $db->decodeBlob( $value ) ) : null;
+ }
+
+ public function startWrite( $code ) {
+ if ( $this->readOnly ) {
+ return;
+ } elseif ( !$code ) {
+ throw new MWException( __METHOD__ . ": Invalid language \"$code\"" );
+ }
+
+ $this->dbw = wfGetDB( DB_MASTER );
+ $this->readOnly = $this->dbw->isReadOnly();
+
+ $this->currentLang = $code;
+ $this->batch = [];
+ }
+
+ public function finishWrite() {
+ if ( $this->readOnly ) {
+ return;
+ } elseif ( is_null( $this->currentLang ) ) {
+ throw new MWException( __CLASS__ . ': must call startWrite() before finishWrite()' );
+ }
+
+ $this->dbw->startAtomic( __METHOD__ );
+ try {
+ $this->dbw->delete(
+ 'l10n_cache',
+ [ 'lc_lang' => $this->currentLang ],
+ __METHOD__
+ );
+ foreach ( array_chunk( $this->batch, 500 ) as $rows ) {
+ $this->dbw->insert( 'l10n_cache', $rows, __METHOD__ );
+ }
+ $this->writesDone = true;
+ } catch ( DBQueryError $e ) {
+ if ( $this->dbw->wasReadOnlyError() ) {
+ $this->readOnly = true; // just avoid site down time
+ } else {
+ throw $e;
+ }
+ }
+ $this->dbw->endAtomic( __METHOD__ );
+
+ $this->currentLang = null;
+ $this->batch = [];
+ }
+
+ public function set( $key, $value ) {
+ if ( $this->readOnly ) {
+ return;
+ } elseif ( is_null( $this->currentLang ) ) {
+ throw new MWException( __CLASS__ . ': must call startWrite() before set()' );
+ }
+
+ $this->batch[] = [
+ 'lc_lang' => $this->currentLang,
+ 'lc_key' => $key,
+ 'lc_value' => $this->dbw->encodeBlob( serialize( $value ) )
+ ];
+ }
+
+}
diff --git a/www/wiki/includes/cache/localisation/LCStoreNull.php b/www/wiki/includes/cache/localisation/LCStoreNull.php
new file mode 100644
index 00000000..62f88ebf
--- /dev/null
+++ b/www/wiki/includes/cache/localisation/LCStoreNull.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Null store backend, used to avoid DB errors during install
+ */
+class LCStoreNull implements LCStore {
+
+ public function get( $code, $key ) {
+ return null;
+ }
+
+ public function startWrite( $code ) {
+ }
+
+ public function finishWrite() {
+ }
+
+ public function set( $key, $value ) {
+ }
+
+}
diff --git a/www/wiki/includes/cache/localisation/LCStoreStaticArray.php b/www/wiki/includes/cache/localisation/LCStoreStaticArray.php
new file mode 100644
index 00000000..1e20082f
--- /dev/null
+++ b/www/wiki/includes/cache/localisation/LCStoreStaticArray.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Localisation cache storage based on PHP files and static arrays.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @since 1.26
+ */
+class LCStoreStaticArray implements LCStore {
+ /** @var string|null Current language code. */
+ private $currentLang = null;
+
+ /** @var array Localisation data. */
+ private $data = [];
+
+ /** @var string File name. */
+ private $fname = null;
+
+ /** @var string Directory for cache files. */
+ private $directory;
+
+ public function __construct( $conf = [] ) {
+ global $wgCacheDirectory;
+
+ if ( isset( $conf['directory'] ) ) {
+ $this->directory = $conf['directory'];
+ } else {
+ $this->directory = $wgCacheDirectory;
+ }
+ }
+
+ public function startWrite( $code ) {
+ $this->currentLang = $code;
+ $this->fname = $this->directory . '/' . $code . '.l10n.php';
+ $this->data[$code] = [];
+ if ( file_exists( $this->fname ) ) {
+ $this->data[$code] = require $this->fname;
+ }
+ }
+
+ public function set( $key, $value ) {
+ $this->data[$this->currentLang][$key] = self::encode( $value );
+ }
+
+ /**
+ * Encodes a value into an array format
+ *
+ * @param mixed $value
+ * @return array
+ * @throws RuntimeException
+ */
+ public static function encode( $value ) {
+ if ( is_scalar( $value ) || $value === null ) {
+ // [V]alue
+ return [ 'v', $value ];
+ }
+ if ( is_object( $value ) ) {
+ // [S]erialized
+ return [ 's', serialize( $value ) ];
+ }
+ if ( is_array( $value ) ) {
+ // [A]rray
+ return [ 'a', array_map( function ( $v ) {
+ return LCStoreStaticArray::encode( $v );
+ }, $value ) ];
+ }
+
+ throw new RuntimeException( 'Cannot encode ' . var_export( $value, true ) );
+ }
+
+ /**
+ * Decode something that was encoded with encode
+ *
+ * @param array $encoded
+ * @return array|mixed
+ * @throws RuntimeException
+ */
+ public static function decode( array $encoded ) {
+ $type = $encoded[0];
+ $data = $encoded[1];
+
+ switch ( $type ) {
+ case 'v':
+ return $data;
+ case 's':
+ return unserialize( $data );
+ case 'a':
+ return array_map( function ( $v ) {
+ return LCStoreStaticArray::decode( $v );
+ }, $data );
+ default:
+ throw new RuntimeException(
+ 'Unable to decode ' . var_export( $encoded, true ) );
+ }
+ }
+
+ public function finishWrite() {
+ file_put_contents(
+ $this->fname,
+ "<?php\n" .
+ "// Generated by LCStoreStaticArray.php -- do not edit!\n" .
+ "return " .
+ var_export( $this->data[$this->currentLang], true ) . ';'
+ );
+ $this->currentLang = null;
+ $this->fname = null;
+ }
+
+ public function get( $code, $key ) {
+ if ( !array_key_exists( $code, $this->data ) ) {
+ $fname = $this->directory . '/' . $code . '.l10n.php';
+ if ( !file_exists( $fname ) ) {
+ return null;
+ }
+ $this->data[$code] = require $fname;
+ }
+ $data = $this->data[$code];
+ if ( array_key_exists( $key, $data ) ) {
+ return self::decode( $data[$key] );
+ }
+ return null;
+ }
+}
diff --git a/www/wiki/includes/cache/localisation/LocalisationCache.php b/www/wiki/includes/cache/localisation/LocalisationCache.php
new file mode 100644
index 00000000..a0ce95e4
--- /dev/null
+++ b/www/wiki/includes/cache/localisation/LocalisationCache.php
@@ -0,0 +1,1096 @@
+<?php
+/**
+ * Cache of the contents of localisation files.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use CLDRPluralRuleParser\Evaluator;
+use CLDRPluralRuleParser\Error as CLDRPluralRuleError;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Class for caching the contents of localisation files, Messages*.php
+ * and *.i18n.php.
+ *
+ * An instance of this class is available using Language::getLocalisationCache().
+ *
+ * The values retrieved from here are merged, containing items from extension
+ * files, core messages files and the language fallback sequence (e.g. zh-cn ->
+ * zh-hans -> en ). Some common errors are corrected, for example namespace
+ * names with spaces instead of underscores, but heavyweight processing, such
+ * as grammatical transformation, is done by the caller.
+ */
+class LocalisationCache {
+ const VERSION = 4;
+
+ /** Configuration associative array */
+ private $conf;
+
+ /**
+ * True if recaching should only be done on an explicit call to recache().
+ * Setting this reduces the overhead of cache freshness checking, which
+ * requires doing a stat() for every extension i18n file.
+ */
+ private $manualRecache = false;
+
+ /**
+ * True to treat all files as expired until they are regenerated by this object.
+ */
+ private $forceRecache = false;
+
+ /**
+ * The cache data. 3-d array, where the first key is the language code,
+ * the second key is the item key e.g. 'messages', and the third key is
+ * an item specific subkey index. Some items are not arrays and so for those
+ * items, there are no subkeys.
+ */
+ protected $data = [];
+
+ /**
+ * The persistent store object. An instance of LCStore.
+ *
+ * @var LCStore
+ */
+ private $store;
+
+ /**
+ * A 2-d associative array, code/key, where presence indicates that the item
+ * is loaded. Value arbitrary.
+ *
+ * For split items, if set, this indicates that all of the subitems have been
+ * loaded.
+ */
+ private $loadedItems = [];
+
+ /**
+ * A 3-d associative array, code/key/subkey, where presence indicates that
+ * the subitem is loaded. Only used for the split items, i.e. messages.
+ */
+ private $loadedSubitems = [];
+
+ /**
+ * An array where presence of a key indicates that that language has been
+ * initialised. Initialisation includes checking for cache expiry and doing
+ * any necessary updates.
+ */
+ private $initialisedLangs = [];
+
+ /**
+ * An array mapping non-existent pseudo-languages to fallback languages. This
+ * is filled by initShallowFallback() when data is requested from a language
+ * that lacks a Messages*.php file.
+ */
+ private $shallowFallbacks = [];
+
+ /**
+ * An array where the keys are codes that have been recached by this instance.
+ */
+ private $recachedLangs = [];
+
+ /**
+ * All item keys
+ */
+ static public $allKeys = [
+ 'fallback', 'namespaceNames', 'bookstoreList',
+ 'magicWords', 'messages', 'rtl', 'capitalizeAllNouns', 'digitTransformTable',
+ 'separatorTransformTable', 'fallback8bitEncoding', 'linkPrefixExtension',
+ 'linkTrail', 'linkPrefixCharset', 'namespaceAliases',
+ 'dateFormats', 'datePreferences', 'datePreferenceMigrationMap',
+ 'defaultDateFormat', 'extraUserToggles', 'specialPageAliases',
+ 'imageFiles', 'preloadedMessages', 'namespaceGenderAliases',
+ 'digitGroupingPattern', 'pluralRules', 'pluralRuleTypes', 'compiledPluralRules',
+ ];
+
+ /**
+ * Keys for items which consist of associative arrays, which may be merged
+ * by a fallback sequence.
+ */
+ static public $mergeableMapKeys = [ 'messages', 'namespaceNames',
+ 'namespaceAliases', 'dateFormats', 'imageFiles', 'preloadedMessages'
+ ];
+
+ /**
+ * Keys for items which are a numbered array.
+ */
+ static public $mergeableListKeys = [ 'extraUserToggles' ];
+
+ /**
+ * Keys for items which contain an array of arrays of equivalent aliases
+ * for each subitem. The aliases may be merged by a fallback sequence.
+ */
+ static public $mergeableAliasListKeys = [ 'specialPageAliases' ];
+
+ /**
+ * Keys for items which contain an associative array, and may be merged if
+ * the primary value contains the special array key "inherit". That array
+ * key is removed after the first merge.
+ */
+ static public $optionalMergeKeys = [ 'bookstoreList' ];
+
+ /**
+ * Keys for items that are formatted like $magicWords
+ */
+ static public $magicWordKeys = [ 'magicWords' ];
+
+ /**
+ * Keys for items where the subitems are stored in the backend separately.
+ */
+ static public $splitKeys = [ 'messages' ];
+
+ /**
+ * Keys which are loaded automatically by initLanguage()
+ */
+ static public $preloadedKeys = [ 'dateFormats', 'namespaceNames' ];
+
+ /**
+ * Associative array of cached plural rules. The key is the language code,
+ * the value is an array of plural rules for that language.
+ */
+ private $pluralRules = null;
+
+ /**
+ * Associative array of cached plural rule types. The key is the language
+ * code, the value is an array of plural rule types for that language. For
+ * example, $pluralRuleTypes['ar'] = ['zero', 'one', 'two', 'few', 'many'].
+ * The index for each rule type matches the index for the rule in
+ * $pluralRules, thus allowing correlation between the two. The reason we
+ * don't just use the type names as the keys in $pluralRules is because
+ * Language::convertPlural applies the rules based on numeric order (or
+ * explicit numeric parameter), not based on the name of the rule type. For
+ * example, {{plural:count|wordform1|wordform2|wordform3}}, rather than
+ * {{plural:count|one=wordform1|two=wordform2|many=wordform3}}.
+ */
+ private $pluralRuleTypes = null;
+
+ private $mergeableKeys = null;
+
+ /**
+ * For constructor parameters, see the documentation in DefaultSettings.php
+ * for $wgLocalisationCacheConf.
+ *
+ * @param array $conf
+ * @throws MWException
+ */
+ function __construct( $conf ) {
+ global $wgCacheDirectory;
+
+ $this->conf = $conf;
+ $storeConf = [];
+ if ( !empty( $conf['storeClass'] ) ) {
+ $storeClass = $conf['storeClass'];
+ } else {
+ switch ( $conf['store'] ) {
+ case 'files':
+ case 'file':
+ $storeClass = 'LCStoreCDB';
+ break;
+ case 'db':
+ $storeClass = 'LCStoreDB';
+ break;
+ case 'array':
+ $storeClass = 'LCStoreStaticArray';
+ break;
+ case 'detect':
+ if ( !empty( $conf['storeDirectory'] ) ) {
+ $storeClass = 'LCStoreCDB';
+ } elseif ( $wgCacheDirectory ) {
+ $storeConf['directory'] = $wgCacheDirectory;
+ $storeClass = 'LCStoreCDB';
+ } else {
+ $storeClass = 'LCStoreDB';
+ }
+ break;
+ default:
+ throw new MWException(
+ 'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
+ );
+ }
+ }
+
+ wfDebugLog( 'caches', static::class . ": using store $storeClass" );
+ if ( !empty( $conf['storeDirectory'] ) ) {
+ $storeConf['directory'] = $conf['storeDirectory'];
+ }
+
+ $this->store = new $storeClass( $storeConf );
+ foreach ( [ 'manualRecache', 'forceRecache' ] as $var ) {
+ if ( isset( $conf[$var] ) ) {
+ $this->$var = $conf[$var];
+ }
+ }
+ }
+
+ /**
+ * Returns true if the given key is mergeable, that is, if it is an associative
+ * array which can be merged through a fallback sequence.
+ * @param string $key
+ * @return bool
+ */
+ public function isMergeableKey( $key ) {
+ if ( $this->mergeableKeys === null ) {
+ $this->mergeableKeys = array_flip( array_merge(
+ self::$mergeableMapKeys,
+ self::$mergeableListKeys,
+ self::$mergeableAliasListKeys,
+ self::$optionalMergeKeys,
+ self::$magicWordKeys
+ ) );
+ }
+
+ return isset( $this->mergeableKeys[$key] );
+ }
+
+ /**
+ * Get a cache item.
+ *
+ * Warning: this may be slow for split items (messages), since it will
+ * need to fetch all of the subitems from the cache individually.
+ * @param string $code
+ * @param string $key
+ * @return mixed
+ */
+ public function getItem( $code, $key ) {
+ if ( !isset( $this->loadedItems[$code][$key] ) ) {
+ $this->loadItem( $code, $key );
+ }
+
+ if ( $key === 'fallback' && isset( $this->shallowFallbacks[$code] ) ) {
+ return $this->shallowFallbacks[$code];
+ }
+
+ return $this->data[$code][$key];
+ }
+
+ /**
+ * Get a subitem, for instance a single message for a given language.
+ * @param string $code
+ * @param string $key
+ * @param string $subkey
+ * @return mixed|null
+ */
+ public function getSubitem( $code, $key, $subkey ) {
+ if ( !isset( $this->loadedSubitems[$code][$key][$subkey] ) &&
+ !isset( $this->loadedItems[$code][$key] )
+ ) {
+ $this->loadSubitem( $code, $key, $subkey );
+ }
+
+ if ( isset( $this->data[$code][$key][$subkey] ) ) {
+ return $this->data[$code][$key][$subkey];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Get the list of subitem keys for a given item.
+ *
+ * This is faster than array_keys($lc->getItem(...)) for the items listed in
+ * self::$splitKeys.
+ *
+ * Will return null if the item is not found, or false if the item is not an
+ * array.
+ * @param string $code
+ * @param string $key
+ * @return bool|null|string|string[]
+ */
+ public function getSubitemList( $code, $key ) {
+ if ( in_array( $key, self::$splitKeys ) ) {
+ return $this->getSubitem( $code, 'list', $key );
+ } else {
+ $item = $this->getItem( $code, $key );
+ if ( is_array( $item ) ) {
+ return array_keys( $item );
+ } else {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Load an item into the cache.
+ * @param string $code
+ * @param string $key
+ */
+ protected function loadItem( $code, $key ) {
+ if ( !isset( $this->initialisedLangs[$code] ) ) {
+ $this->initLanguage( $code );
+ }
+
+ // Check to see if initLanguage() loaded it for us
+ if ( isset( $this->loadedItems[$code][$key] ) ) {
+ return;
+ }
+
+ if ( isset( $this->shallowFallbacks[$code] ) ) {
+ $this->loadItem( $this->shallowFallbacks[$code], $key );
+
+ return;
+ }
+
+ if ( in_array( $key, self::$splitKeys ) ) {
+ $subkeyList = $this->getSubitem( $code, 'list', $key );
+ foreach ( $subkeyList as $subkey ) {
+ if ( isset( $this->data[$code][$key][$subkey] ) ) {
+ continue;
+ }
+ $this->data[$code][$key][$subkey] = $this->getSubitem( $code, $key, $subkey );
+ }
+ } else {
+ $this->data[$code][$key] = $this->store->get( $code, $key );
+ }
+
+ $this->loadedItems[$code][$key] = true;
+ }
+
+ /**
+ * Load a subitem into the cache
+ * @param string $code
+ * @param string $key
+ * @param string $subkey
+ */
+ protected function loadSubitem( $code, $key, $subkey ) {
+ if ( !in_array( $key, self::$splitKeys ) ) {
+ $this->loadItem( $code, $key );
+
+ return;
+ }
+
+ if ( !isset( $this->initialisedLangs[$code] ) ) {
+ $this->initLanguage( $code );
+ }
+
+ // Check to see if initLanguage() loaded it for us
+ if ( isset( $this->loadedItems[$code][$key] ) ||
+ isset( $this->loadedSubitems[$code][$key][$subkey] )
+ ) {
+ return;
+ }
+
+ if ( isset( $this->shallowFallbacks[$code] ) ) {
+ $this->loadSubitem( $this->shallowFallbacks[$code], $key, $subkey );
+
+ return;
+ }
+
+ $value = $this->store->get( $code, "$key:$subkey" );
+ $this->data[$code][$key][$subkey] = $value;
+ $this->loadedSubitems[$code][$key][$subkey] = true;
+ }
+
+ /**
+ * Returns true if the cache identified by $code is missing or expired.
+ *
+ * @param string $code
+ *
+ * @return bool
+ */
+ public function isExpired( $code ) {
+ if ( $this->forceRecache && !isset( $this->recachedLangs[$code] ) ) {
+ wfDebug( __METHOD__ . "($code): forced reload\n" );
+
+ return true;
+ }
+
+ $deps = $this->store->get( $code, 'deps' );
+ $keys = $this->store->get( $code, 'list' );
+ $preload = $this->store->get( $code, 'preload' );
+ // Different keys may expire separately for some stores
+ if ( $deps === null || $keys === null || $preload === null ) {
+ wfDebug( __METHOD__ . "($code): cache missing, need to make one\n" );
+
+ return true;
+ }
+
+ foreach ( $deps as $dep ) {
+ // Because we're unserializing stuff from cache, we
+ // could receive objects of classes that don't exist
+ // anymore (e.g. uninstalled extensions)
+ // When this happens, always expire the cache
+ if ( !$dep instanceof CacheDependency || $dep->isExpired() ) {
+ wfDebug( __METHOD__ . "($code): cache for $code expired due to " .
+ get_class( $dep ) . "\n" );
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Initialise a language in this object. Rebuild the cache if necessary.
+ * @param string $code
+ * @throws MWException
+ */
+ protected function initLanguage( $code ) {
+ if ( isset( $this->initialisedLangs[$code] ) ) {
+ return;
+ }
+
+ $this->initialisedLangs[$code] = true;
+
+ # If the code is of the wrong form for a Messages*.php file, do a shallow fallback
+ if ( !Language::isValidBuiltInCode( $code ) ) {
+ $this->initShallowFallback( $code, 'en' );
+
+ return;
+ }
+
+ # Recache the data if necessary
+ if ( !$this->manualRecache && $this->isExpired( $code ) ) {
+ if ( Language::isSupportedLanguage( $code ) ) {
+ $this->recache( $code );
+ } elseif ( $code === 'en' ) {
+ throw new MWException( 'MessagesEn.php is missing.' );
+ } else {
+ $this->initShallowFallback( $code, 'en' );
+ }
+
+ return;
+ }
+
+ # Preload some stuff
+ $preload = $this->getItem( $code, 'preload' );
+ if ( $preload === null ) {
+ if ( $this->manualRecache ) {
+ // No Messages*.php file. Do shallow fallback to en.
+ if ( $code === 'en' ) {
+ throw new MWException( 'No localisation cache found for English. ' .
+ 'Please run maintenance/rebuildLocalisationCache.php.' );
+ }
+ $this->initShallowFallback( $code, 'en' );
+
+ return;
+ } else {
+ throw new MWException( 'Invalid or missing localisation cache.' );
+ }
+ }
+ $this->data[$code] = $preload;
+ foreach ( $preload as $key => $item ) {
+ if ( in_array( $key, self::$splitKeys ) ) {
+ foreach ( $item as $subkey => $subitem ) {
+ $this->loadedSubitems[$code][$key][$subkey] = true;
+ }
+ } else {
+ $this->loadedItems[$code][$key] = true;
+ }
+ }
+ }
+
+ /**
+ * Create a fallback from one language to another, without creating a
+ * complete persistent cache.
+ * @param string $primaryCode
+ * @param string $fallbackCode
+ */
+ public function initShallowFallback( $primaryCode, $fallbackCode ) {
+ $this->data[$primaryCode] =& $this->data[$fallbackCode];
+ $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
+ $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
+ $this->shallowFallbacks[$primaryCode] = $fallbackCode;
+ }
+
+ /**
+ * Read a PHP file containing localisation data.
+ * @param string $_fileName
+ * @param string $_fileType
+ * @throws MWException
+ * @return array
+ */
+ protected function readPHPFile( $_fileName, $_fileType ) {
+ // Disable APC caching
+ MediaWiki\suppressWarnings();
+ $_apcEnabled = ini_set( 'apc.cache_by_default', '0' );
+ MediaWiki\restoreWarnings();
+
+ include $_fileName;
+
+ MediaWiki\suppressWarnings();
+ ini_set( 'apc.cache_by_default', $_apcEnabled );
+ MediaWiki\restoreWarnings();
+
+ if ( $_fileType == 'core' || $_fileType == 'extension' ) {
+ $data = compact( self::$allKeys );
+ } elseif ( $_fileType == 'aliases' ) {
+ $data = compact( 'aliases' );
+ } else {
+ throw new MWException( __METHOD__ . ": Invalid file type: $_fileType" );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Read a JSON file containing localisation messages.
+ * @param string $fileName Name of file to read
+ * @throws MWException If there is a syntax error in the JSON file
+ * @return array Array with a 'messages' key, or empty array if the file doesn't exist
+ */
+ public function readJSONFile( $fileName ) {
+ if ( !is_readable( $fileName ) ) {
+ return [];
+ }
+
+ $json = file_get_contents( $fileName );
+ if ( $json === false ) {
+ return [];
+ }
+
+ $data = FormatJson::decode( $json, true );
+ if ( $data === null ) {
+ throw new MWException( __METHOD__ . ": Invalid JSON file: $fileName" );
+ }
+
+ // Remove keys starting with '@', they're reserved for metadata and non-message data
+ foreach ( $data as $key => $unused ) {
+ if ( $key === '' || $key[0] === '@' ) {
+ unset( $data[$key] );
+ }
+ }
+
+ // The JSON format only supports messages, none of the other variables, so wrap the data
+ return [ 'messages' => $data ];
+ }
+
+ /**
+ * Get the compiled plural rules for a given language from the XML files.
+ * @since 1.20
+ * @param string $code
+ * @return array|null
+ */
+ public function getCompiledPluralRules( $code ) {
+ $rules = $this->getPluralRules( $code );
+ if ( $rules === null ) {
+ return null;
+ }
+ try {
+ $compiledRules = Evaluator::compile( $rules );
+ } catch ( CLDRPluralRuleError $e ) {
+ wfDebugLog( 'l10n', $e->getMessage() );
+
+ return [];
+ }
+
+ return $compiledRules;
+ }
+
+ /**
+ * Get the plural rules for a given language from the XML files.
+ * Cached.
+ * @since 1.20
+ * @param string $code
+ * @return array|null
+ */
+ public function getPluralRules( $code ) {
+ if ( $this->pluralRules === null ) {
+ $this->loadPluralFiles();
+ }
+ if ( !isset( $this->pluralRules[$code] ) ) {
+ return null;
+ } else {
+ return $this->pluralRules[$code];
+ }
+ }
+
+ /**
+ * Get the plural rule types for a given language from the XML files.
+ * Cached.
+ * @since 1.22
+ * @param string $code
+ * @return array|null
+ */
+ public function getPluralRuleTypes( $code ) {
+ if ( $this->pluralRuleTypes === null ) {
+ $this->loadPluralFiles();
+ }
+ if ( !isset( $this->pluralRuleTypes[$code] ) ) {
+ return null;
+ } else {
+ return $this->pluralRuleTypes[$code];
+ }
+ }
+
+ /**
+ * Load the plural XML files.
+ */
+ protected function loadPluralFiles() {
+ global $IP;
+ $cldrPlural = "$IP/languages/data/plurals.xml";
+ $mwPlural = "$IP/languages/data/plurals-mediawiki.xml";
+ // Load CLDR plural rules
+ $this->loadPluralFile( $cldrPlural );
+ if ( file_exists( $mwPlural ) ) {
+ // Override or extend
+ $this->loadPluralFile( $mwPlural );
+ }
+ }
+
+ /**
+ * Load a plural XML file with the given filename, compile the relevant
+ * rules, and save the compiled rules in a process-local cache.
+ *
+ * @param string $fileName
+ * @throws MWException
+ */
+ protected function loadPluralFile( $fileName ) {
+ // Use file_get_contents instead of DOMDocument::load (T58439)
+ $xml = file_get_contents( $fileName );
+ if ( !$xml ) {
+ throw new MWException( "Unable to read plurals file $fileName" );
+ }
+ $doc = new DOMDocument;
+ $doc->loadXML( $xml );
+ $rulesets = $doc->getElementsByTagName( "pluralRules" );
+ foreach ( $rulesets as $ruleset ) {
+ $codes = $ruleset->getAttribute( 'locales' );
+ $rules = [];
+ $ruleTypes = [];
+ $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
+ foreach ( $ruleElements as $elt ) {
+ $ruleType = $elt->getAttribute( 'count' );
+ if ( $ruleType === 'other' ) {
+ // Don't record "other" rules, which have an empty condition
+ continue;
+ }
+ $rules[] = $elt->nodeValue;
+ $ruleTypes[] = $ruleType;
+ }
+ foreach ( explode( ' ', $codes ) as $code ) {
+ $this->pluralRules[$code] = $rules;
+ $this->pluralRuleTypes[$code] = $ruleTypes;
+ }
+ }
+ }
+
+ /**
+ * Read the data from the source files for a given language, and register
+ * the relevant dependencies in the $deps array. If the localisation
+ * exists, the data array is returned, otherwise false is returned.
+ *
+ * @param string $code
+ * @param array &$deps
+ * @return array
+ */
+ protected function readSourceFilesAndRegisterDeps( $code, &$deps ) {
+ global $IP;
+
+ // This reads in the PHP i18n file with non-messages l10n data
+ $fileName = Language::getMessagesFileName( $code );
+ if ( !file_exists( $fileName ) ) {
+ $data = [];
+ } else {
+ $deps[] = new FileDependency( $fileName );
+ $data = $this->readPHPFile( $fileName, 'core' );
+ }
+
+ # Load CLDR plural rules for JavaScript
+ $data['pluralRules'] = $this->getPluralRules( $code );
+ # And for PHP
+ $data['compiledPluralRules'] = $this->getCompiledPluralRules( $code );
+ # Load plural rule types
+ $data['pluralRuleTypes'] = $this->getPluralRuleTypes( $code );
+
+ $deps['plurals'] = new FileDependency( "$IP/languages/data/plurals.xml" );
+ $deps['plurals-mw'] = new FileDependency( "$IP/languages/data/plurals-mediawiki.xml" );
+
+ return $data;
+ }
+
+ /**
+ * Merge two localisation values, a primary and a fallback, overwriting the
+ * primary value in place.
+ * @param string $key
+ * @param mixed &$value
+ * @param mixed $fallbackValue
+ */
+ protected function mergeItem( $key, &$value, $fallbackValue ) {
+ if ( !is_null( $value ) ) {
+ if ( !is_null( $fallbackValue ) ) {
+ if ( in_array( $key, self::$mergeableMapKeys ) ) {
+ $value = $value + $fallbackValue;
+ } elseif ( in_array( $key, self::$mergeableListKeys ) ) {
+ $value = array_unique( array_merge( $fallbackValue, $value ) );
+ } elseif ( in_array( $key, self::$mergeableAliasListKeys ) ) {
+ $value = array_merge_recursive( $value, $fallbackValue );
+ } elseif ( in_array( $key, self::$optionalMergeKeys ) ) {
+ if ( !empty( $value['inherit'] ) ) {
+ $value = array_merge( $fallbackValue, $value );
+ }
+
+ if ( isset( $value['inherit'] ) ) {
+ unset( $value['inherit'] );
+ }
+ } elseif ( in_array( $key, self::$magicWordKeys ) ) {
+ $this->mergeMagicWords( $value, $fallbackValue );
+ }
+ }
+ } else {
+ $value = $fallbackValue;
+ }
+ }
+
+ /**
+ * @param mixed &$value
+ * @param mixed $fallbackValue
+ */
+ protected function mergeMagicWords( &$value, $fallbackValue ) {
+ foreach ( $fallbackValue as $magicName => $fallbackInfo ) {
+ if ( !isset( $value[$magicName] ) ) {
+ $value[$magicName] = $fallbackInfo;
+ } else {
+ $oldSynonyms = array_slice( $fallbackInfo, 1 );
+ $newSynonyms = array_slice( $value[$magicName], 1 );
+ $synonyms = array_values( array_unique( array_merge(
+ $newSynonyms, $oldSynonyms ) ) );
+ $value[$magicName] = array_merge( [ $fallbackInfo[0] ], $synonyms );
+ }
+ }
+ }
+
+ /**
+ * Given an array mapping language code to localisation value, such as is
+ * found in extension *.i18n.php files, iterate through a fallback sequence
+ * to merge the given data with an existing primary value.
+ *
+ * Returns true if any data from the extension array was used, false
+ * otherwise.
+ * @param array $codeSequence
+ * @param string $key
+ * @param mixed &$value
+ * @param mixed $fallbackValue
+ * @return bool
+ */
+ protected function mergeExtensionItem( $codeSequence, $key, &$value, $fallbackValue ) {
+ $used = false;
+ foreach ( $codeSequence as $code ) {
+ if ( isset( $fallbackValue[$code] ) ) {
+ $this->mergeItem( $key, $value, $fallbackValue[$code] );
+ $used = true;
+ }
+ }
+
+ return $used;
+ }
+
+ /**
+ * Gets the combined list of messages dirs from
+ * core and extensions
+ *
+ * @since 1.25
+ * @return array
+ */
+ public function getMessagesDirs() {
+ global $IP;
+
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ $messagesDirs = $config->get( 'MessagesDirs' );
+ return [
+ 'core' => "$IP/languages/i18n",
+ 'api' => "$IP/includes/api/i18n",
+ 'oojs-ui' => "$IP/resources/lib/oojs-ui/i18n",
+ ] + $messagesDirs;
+ }
+
+ /**
+ * Load localisation data for a given language for both core and extensions
+ * and save it to the persistent cache store and the process cache
+ * @param string $code
+ * @throws MWException
+ */
+ public function recache( $code ) {
+ global $wgExtensionMessagesFiles;
+
+ if ( !$code ) {
+ throw new MWException( "Invalid language code requested" );
+ }
+ $this->recachedLangs[$code] = true;
+
+ # Initial values
+ $initialData = array_fill_keys( self::$allKeys, null );
+ $coreData = $initialData;
+ $deps = [];
+
+ # Load the primary localisation from the source file
+ $data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
+ if ( $data === false ) {
+ wfDebug( __METHOD__ . ": no localisation file for $code, using fallback to en\n" );
+ $coreData['fallback'] = 'en';
+ } else {
+ wfDebug( __METHOD__ . ": got localisation for $code from source\n" );
+
+ # Merge primary localisation
+ foreach ( $data as $key => $value ) {
+ $this->mergeItem( $key, $coreData[$key], $value );
+ }
+ }
+
+ # Fill in the fallback if it's not there already
+ if ( is_null( $coreData['fallback'] ) ) {
+ $coreData['fallback'] = $code === 'en' ? false : 'en';
+ }
+ if ( $coreData['fallback'] === false ) {
+ $coreData['fallbackSequence'] = [];
+ } else {
+ $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
+ $len = count( $coreData['fallbackSequence'] );
+
+ # Ensure that the sequence ends at en
+ if ( $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
+ $coreData['fallbackSequence'][] = 'en';
+ }
+ }
+
+ $codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] );
+ $messageDirs = $this->getMessagesDirs();
+
+ # Load non-JSON localisation data for extensions
+ $extensionData = array_fill_keys( $codeSequence, $initialData );
+ foreach ( $wgExtensionMessagesFiles as $extension => $fileName ) {
+ if ( isset( $messageDirs[$extension] ) ) {
+ # This extension has JSON message data; skip the PHP shim
+ continue;
+ }
+
+ $data = $this->readPHPFile( $fileName, 'extension' );
+ $used = false;
+
+ foreach ( $data as $key => $item ) {
+ foreach ( $codeSequence as $csCode ) {
+ if ( isset( $item[$csCode] ) ) {
+ $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
+ $used = true;
+ }
+ }
+ }
+
+ if ( $used ) {
+ $deps[] = new FileDependency( $fileName );
+ }
+ }
+
+ # Load the localisation data for each fallback, then merge it into the full array
+ $allData = $initialData;
+ foreach ( $codeSequence as $csCode ) {
+ $csData = $initialData;
+
+ # Load core messages and the extension localisations.
+ foreach ( $messageDirs as $dirs ) {
+ foreach ( (array)$dirs as $dir ) {
+ $fileName = "$dir/$csCode.json";
+ $data = $this->readJSONFile( $fileName );
+
+ foreach ( $data as $key => $item ) {
+ $this->mergeItem( $key, $csData[$key], $item );
+ }
+
+ $deps[] = new FileDependency( $fileName );
+ }
+ }
+
+ # Merge non-JSON extension data
+ if ( isset( $extensionData[$csCode] ) ) {
+ foreach ( $extensionData[$csCode] as $key => $item ) {
+ $this->mergeItem( $key, $csData[$key], $item );
+ }
+ }
+
+ if ( $csCode === $code ) {
+ # Merge core data into extension data
+ foreach ( $coreData as $key => $item ) {
+ $this->mergeItem( $key, $csData[$key], $item );
+ }
+ } else {
+ # Load the secondary localisation from the source file to
+ # avoid infinite cycles on cyclic fallbacks
+ $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
+ if ( $fbData !== false ) {
+ # Only merge the keys that make sense to merge
+ foreach ( self::$allKeys as $key ) {
+ if ( !isset( $fbData[$key] ) ) {
+ continue;
+ }
+
+ if ( is_null( $coreData[$key] ) || $this->isMergeableKey( $key ) ) {
+ $this->mergeItem( $key, $csData[$key], $fbData[$key] );
+ }
+ }
+ }
+ }
+
+ # Allow extensions an opportunity to adjust the data for this
+ # fallback
+ Hooks::run( 'LocalisationCacheRecacheFallback', [ $this, $csCode, &$csData ] );
+
+ # Merge the data for this fallback into the final array
+ if ( $csCode === $code ) {
+ $allData = $csData;
+ } else {
+ foreach ( self::$allKeys as $key ) {
+ if ( !isset( $csData[$key] ) ) {
+ continue;
+ }
+
+ if ( is_null( $allData[$key] ) || $this->isMergeableKey( $key ) ) {
+ $this->mergeItem( $key, $allData[$key], $csData[$key] );
+ }
+ }
+ }
+ }
+
+ # Add cache dependencies for any referenced globals
+ $deps['wgExtensionMessagesFiles'] = new GlobalDependency( 'wgExtensionMessagesFiles' );
+ // The 'MessagesDirs' config setting is used in LocalisationCache::getMessagesDirs().
+ // We use the key 'wgMessagesDirs' for historical reasons.
+ $deps['wgMessagesDirs'] = new MainConfigDependency( 'MessagesDirs' );
+ $deps['version'] = new ConstantDependency( 'LocalisationCache::VERSION' );
+
+ # Add dependencies to the cache entry
+ $allData['deps'] = $deps;
+
+ # Replace spaces with underscores in namespace names
+ $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
+
+ # And do the same for special page aliases. $page is an array.
+ foreach ( $allData['specialPageAliases'] as &$page ) {
+ $page = str_replace( ' ', '_', $page );
+ }
+ # Decouple the reference to prevent accidental damage
+ unset( $page );
+
+ # If there were no plural rules, return an empty array
+ if ( $allData['pluralRules'] === null ) {
+ $allData['pluralRules'] = [];
+ }
+ if ( $allData['compiledPluralRules'] === null ) {
+ $allData['compiledPluralRules'] = [];
+ }
+ # If there were no plural rule types, return an empty array
+ if ( $allData['pluralRuleTypes'] === null ) {
+ $allData['pluralRuleTypes'] = [];
+ }
+
+ # Set the list keys
+ $allData['list'] = [];
+ foreach ( self::$splitKeys as $key ) {
+ $allData['list'][$key] = array_keys( $allData[$key] );
+ }
+ # Run hooks
+ $purgeBlobs = true;
+ Hooks::run( 'LocalisationCacheRecache', [ $this, $code, &$allData, &$purgeBlobs ] );
+
+ if ( is_null( $allData['namespaceNames'] ) ) {
+ throw new MWException( __METHOD__ . ': Localisation data failed sanity check! ' .
+ 'Check that your languages/messages/MessagesEn.php file is intact.' );
+ }
+
+ # Set the preload key
+ $allData['preload'] = $this->buildPreload( $allData );
+
+ # Save to the process cache and register the items loaded
+ $this->data[$code] = $allData;
+ foreach ( $allData as $key => $item ) {
+ $this->loadedItems[$code][$key] = true;
+ }
+
+ # Save to the persistent cache
+ $this->store->startWrite( $code );
+ foreach ( $allData as $key => $value ) {
+ if ( in_array( $key, self::$splitKeys ) ) {
+ foreach ( $value as $subkey => $subvalue ) {
+ $this->store->set( "$key:$subkey", $subvalue );
+ }
+ } else {
+ $this->store->set( $key, $value );
+ }
+ }
+ $this->store->finishWrite();
+
+ # Clear out the MessageBlobStore
+ # HACK: If using a null (i.e. disabled) storage backend, we
+ # can't write to the MessageBlobStore either
+ if ( $purgeBlobs && !$this->store instanceof LCStoreNull ) {
+ $blobStore = new MessageBlobStore();
+ $blobStore->clear();
+ }
+ }
+
+ /**
+ * Build the preload item from the given pre-cache data.
+ *
+ * The preload item will be loaded automatically, improving performance
+ * for the commonly-requested items it contains.
+ * @param array $data
+ * @return array
+ */
+ protected function buildPreload( $data ) {
+ $preload = [ 'messages' => [] ];
+ foreach ( self::$preloadedKeys as $key ) {
+ $preload[$key] = $data[$key];
+ }
+
+ foreach ( $data['preloadedMessages'] as $subkey ) {
+ if ( isset( $data['messages'][$subkey] ) ) {
+ $subitem = $data['messages'][$subkey];
+ } else {
+ $subitem = null;
+ }
+ $preload['messages'][$subkey] = $subitem;
+ }
+
+ return $preload;
+ }
+
+ /**
+ * Unload the data for a given language from the object cache.
+ * Reduces memory usage.
+ * @param string $code
+ */
+ public function unload( $code ) {
+ unset( $this->data[$code] );
+ unset( $this->loadedItems[$code] );
+ unset( $this->loadedSubitems[$code] );
+ unset( $this->initialisedLangs[$code] );
+ unset( $this->shallowFallbacks[$code] );
+
+ foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
+ if ( $fbCode === $code ) {
+ $this->unload( $shallowCode );
+ }
+ }
+ }
+
+ /**
+ * Unload all data
+ */
+ public function unloadAll() {
+ foreach ( $this->initialisedLangs as $lang => $unused ) {
+ $this->unload( $lang );
+ }
+ }
+
+ /**
+ * Disable the storage backend
+ */
+ public function disableBackend() {
+ $this->store = new LCStoreNull;
+ $this->manualRecache = false;
+ }
+
+}
diff --git a/www/wiki/includes/cache/localisation/LocalisationCacheBulkLoad.php b/www/wiki/includes/cache/localisation/LocalisationCacheBulkLoad.php
new file mode 100644
index 00000000..30c7d375
--- /dev/null
+++ b/www/wiki/includes/cache/localisation/LocalisationCacheBulkLoad.php
@@ -0,0 +1,126 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A localisation cache optimised for loading large amounts of data for many
+ * languages. Used by rebuildLocalisationCache.php.
+ */
+class LocalisationCacheBulkLoad extends LocalisationCache {
+
+ /**
+ * A cache of the contents of data files.
+ * Core files are serialized to avoid using ~1GB of RAM during a recache.
+ */
+ private $fileCache = [];
+
+ /**
+ * Most recently used languages. Uses the linked-list aspect of PHP hashtables
+ * to keep the most recently used language codes at the end of the array, and
+ * the language codes that are ready to be deleted at the beginning.
+ */
+ private $mruLangs = [];
+
+ /**
+ * Maximum number of languages that may be loaded into $this->data
+ */
+ private $maxLoadedLangs = 10;
+
+ /**
+ * @param string $fileName
+ * @param string $fileType
+ * @return array|mixed
+ */
+ protected function readPHPFile( $fileName, $fileType ) {
+ $serialize = $fileType === 'core';
+ if ( !isset( $this->fileCache[$fileName][$fileType] ) ) {
+ $data = parent::readPHPFile( $fileName, $fileType );
+
+ if ( $serialize ) {
+ $encData = serialize( $data );
+ } else {
+ $encData = $data;
+ }
+
+ $this->fileCache[$fileName][$fileType] = $encData;
+
+ return $data;
+ } elseif ( $serialize ) {
+ return unserialize( $this->fileCache[$fileName][$fileType] );
+ } else {
+ return $this->fileCache[$fileName][$fileType];
+ }
+ }
+
+ /**
+ * @param string $code
+ * @param string $key
+ * @return mixed
+ */
+ public function getItem( $code, $key ) {
+ unset( $this->mruLangs[$code] );
+ $this->mruLangs[$code] = true;
+
+ return parent::getItem( $code, $key );
+ }
+
+ /**
+ * @param string $code
+ * @param string $key
+ * @param string $subkey
+ * @return mixed
+ */
+ public function getSubitem( $code, $key, $subkey ) {
+ unset( $this->mruLangs[$code] );
+ $this->mruLangs[$code] = true;
+
+ return parent::getSubitem( $code, $key, $subkey );
+ }
+
+ /**
+ * @param string $code
+ */
+ public function recache( $code ) {
+ parent::recache( $code );
+ unset( $this->mruLangs[$code] );
+ $this->mruLangs[$code] = true;
+ $this->trimCache();
+ }
+
+ /**
+ * @param string $code
+ */
+ public function unload( $code ) {
+ unset( $this->mruLangs[$code] );
+ parent::unload( $code );
+ }
+
+ /**
+ * Unload cached languages until there are less than $this->maxLoadedLangs
+ */
+ protected function trimCache() {
+ while ( count( $this->data ) > $this->maxLoadedLangs && count( $this->mruLangs ) ) {
+ reset( $this->mruLangs );
+ $code = key( $this->mruLangs );
+ wfDebug( __METHOD__ . ": unloading $code\n" );
+ $this->unload( $code );
+ }
+ }
+
+}
diff --git a/www/wiki/includes/changes/CategoryMembershipChange.php b/www/wiki/includes/changes/CategoryMembershipChange.php
new file mode 100644
index 00000000..6fa69070
--- /dev/null
+++ b/www/wiki/includes/changes/CategoryMembershipChange.php
@@ -0,0 +1,286 @@
+<?php
+/**
+ * Helper class for category membership changes
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Kai Nissen
+ * @author Addshore
+ * @since 1.27
+ */
+
+use Wikimedia\Assert\Assert;
+
+class CategoryMembershipChange {
+
+ const CATEGORY_ADDITION = 1;
+ const CATEGORY_REMOVAL = -1;
+
+ /**
+ * @var string Current timestamp, set during CategoryMembershipChange::__construct()
+ */
+ private $timestamp;
+
+ /**
+ * @var Title Title instance of the categorized page
+ */
+ private $pageTitle;
+
+ /**
+ * @var Revision|null Latest Revision instance of the categorized page
+ */
+ private $revision;
+
+ /**
+ * @var int
+ * Number of pages this WikiPage is embedded by
+ * Set by CategoryMembershipChange::checkTemplateLinks()
+ */
+ private $numTemplateLinks = 0;
+
+ /**
+ * @var callable|null
+ */
+ private $newForCategorizationCallback = null;
+
+ /**
+ * @param Title $pageTitle Title instance of the categorized page
+ * @param Revision $revision Latest Revision instance of the categorized page
+ *
+ * @throws MWException
+ */
+ public function __construct( Title $pageTitle, Revision $revision = null ) {
+ $this->pageTitle = $pageTitle;
+ if ( $revision === null ) {
+ $this->timestamp = wfTimestampNow();
+ } else {
+ $this->timestamp = $revision->getTimestamp();
+ }
+ $this->revision = $revision;
+ $this->newForCategorizationCallback = [ 'RecentChange', 'newForCategorization' ];
+ }
+
+ /**
+ * Overrides the default new for categorization callback
+ * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+ *
+ * @param callable $callback
+ * @see RecentChange::newForCategorization for callback signiture
+ *
+ * @throws MWException
+ */
+ public function overrideNewForCategorizationCallback( $callback ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException( 'Cannot override newForCategorization callback in operation.' );
+ }
+ Assert::parameterType( 'callable', $callback, '$callback' );
+ $this->newForCategorizationCallback = $callback;
+ }
+
+ /**
+ * Determines the number of template links for recursive link updates
+ */
+ public function checkTemplateLinks() {
+ $this->numTemplateLinks = $this->pageTitle->getBacklinkCache()->getNumLinks( 'templatelinks' );
+ }
+
+ /**
+ * Create a recentchanges entry for category additions
+ *
+ * @param Title $categoryTitle
+ */
+ public function triggerCategoryAddedNotification( Title $categoryTitle ) {
+ $this->createRecentChangesEntry( $categoryTitle, self::CATEGORY_ADDITION );
+ }
+
+ /**
+ * Create a recentchanges entry for category removals
+ *
+ * @param Title $categoryTitle
+ */
+ public function triggerCategoryRemovedNotification( Title $categoryTitle ) {
+ $this->createRecentChangesEntry( $categoryTitle, self::CATEGORY_REMOVAL );
+ }
+
+ /**
+ * Create a recentchanges entry using RecentChange::notifyCategorization()
+ *
+ * @param Title $categoryTitle
+ * @param int $type
+ */
+ private function createRecentChangesEntry( Title $categoryTitle, $type ) {
+ $this->notifyCategorization(
+ $this->timestamp,
+ $categoryTitle,
+ $this->getUser(),
+ $this->getChangeMessageText(
+ $type,
+ $this->pageTitle->getPrefixedText(),
+ $this->numTemplateLinks
+ ),
+ $this->pageTitle,
+ $this->getPreviousRevisionTimestamp(),
+ $this->revision,
+ $type === self::CATEGORY_ADDITION
+ );
+ }
+
+ /**
+ * @param string $timestamp Timestamp of the recent change to occur in TS_MW format
+ * @param Title $categoryTitle Title of the category a page is being added to or removed from
+ * @param User $user User object of the user that made the change
+ * @param string $comment Change summary
+ * @param Title $pageTitle Title of the page that is being added or removed
+ * @param string $lastTimestamp Parent revision timestamp of this change in TS_MW format
+ * @param Revision|null $revision
+ * @param bool $added true, if the category was added, false for removed
+ *
+ * @throws MWException
+ */
+ private function notifyCategorization(
+ $timestamp,
+ Title $categoryTitle,
+ User $user = null,
+ $comment,
+ Title $pageTitle,
+ $lastTimestamp,
+ $revision,
+ $added
+ ) {
+ $deleted = $revision ? $revision->getVisibility() & Revision::SUPPRESSED_USER : 0;
+ $newRevId = $revision ? $revision->getId() : 0;
+
+ /**
+ * T109700 - Default bot flag to true when there is no corresponding RC entry
+ * This means all changes caused by parser functions & Lua on reparse are marked as bot
+ * Also in the case no RC entry could be found due to replica DB lag
+ */
+ $bot = 1;
+ $lastRevId = 0;
+ $ip = '';
+
+ # If no revision is given, the change was probably triggered by parser functions
+ if ( $revision !== null ) {
+ $correspondingRc = $this->revision->getRecentChange();
+ if ( $correspondingRc === null ) {
+ $correspondingRc = $this->revision->getRecentChange( Revision::READ_LATEST );
+ }
+ if ( $correspondingRc !== null ) {
+ $bot = $correspondingRc->getAttribute( 'rc_bot' ) ?: 0;
+ $ip = $correspondingRc->getAttribute( 'rc_ip' ) ?: '';
+ $lastRevId = $correspondingRc->getAttribute( 'rc_last_oldid' ) ?: 0;
+ }
+ }
+
+ /** @var RecentChange $rc */
+ $rc = call_user_func_array(
+ $this->newForCategorizationCallback,
+ [
+ $timestamp,
+ $categoryTitle,
+ $user,
+ $comment,
+ $pageTitle,
+ $lastRevId,
+ $newRevId,
+ $lastTimestamp,
+ $bot,
+ $ip,
+ $deleted,
+ $added
+ ]
+ );
+ $rc->save();
+ }
+
+ /**
+ * Get the user associated with this change.
+ *
+ * If there is no revision associated with the change and thus no editing user
+ * fallback to a default.
+ *
+ * False will be returned if the user name specified in the
+ * 'autochange-username' message is invalid.
+ *
+ * @return User|bool
+ */
+ private function getUser() {
+ if ( $this->revision ) {
+ $userId = $this->revision->getUser( Revision::RAW );
+ if ( $userId === 0 ) {
+ return User::newFromName( $this->revision->getUserText( Revision::RAW ), false );
+ } else {
+ return User::newFromId( $userId );
+ }
+ }
+
+ $username = wfMessage( 'autochange-username' )->inContentLanguage()->text();
+ $user = User::newFromName( $username );
+ # User::newFromName() can return false on a badly configured wiki.
+ if ( $user && !$user->isLoggedIn() ) {
+ $user->addToDatabase();
+ }
+
+ return $user;
+ }
+
+ /**
+ * Returns the change message according to the type of category membership change
+ *
+ * The message keys created in this method may be one of:
+ * - recentchanges-page-added-to-category
+ * - recentchanges-page-added-to-category-bundled
+ * - recentchanges-page-removed-from-category
+ * - recentchanges-page-removed-from-category-bundled
+ *
+ * @param int $type may be CategoryMembershipChange::CATEGORY_ADDITION
+ * or CategoryMembershipChange::CATEGORY_REMOVAL
+ * @param string $prefixedText result of Title::->getPrefixedText()
+ * @param int $numTemplateLinks
+ *
+ * @return string
+ */
+ private function getChangeMessageText( $type, $prefixedText, $numTemplateLinks ) {
+ $array = [
+ self::CATEGORY_ADDITION => 'recentchanges-page-added-to-category',
+ self::CATEGORY_REMOVAL => 'recentchanges-page-removed-from-category',
+ ];
+
+ $msgKey = $array[$type];
+
+ if ( intval( $numTemplateLinks ) > 0 ) {
+ $msgKey .= '-bundled';
+ }
+
+ return wfMessage( $msgKey, $prefixedText )->inContentLanguage()->text();
+ }
+
+ /**
+ * Returns the timestamp of the page's previous revision or null if the latest revision
+ * does not refer to a parent revision
+ *
+ * @return null|string
+ */
+ private function getPreviousRevisionTimestamp() {
+ $previousRev = Revision::newFromId(
+ $this->pageTitle->getPreviousRevisionID( $this->pageTitle->getLatestRevID() )
+ );
+
+ return $previousRev ? $previousRev->getTimestamp() : null;
+ }
+
+}
diff --git a/www/wiki/includes/changes/ChangesFeed.php b/www/wiki/includes/changes/ChangesFeed.php
new file mode 100644
index 00000000..df964e0a
--- /dev/null
+++ b/www/wiki/includes/changes/ChangesFeed.php
@@ -0,0 +1,241 @@
+<?php
+/**
+ * Feed for list of changes.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * Feed to Special:RecentChanges and Special:RecentChangesLiked
+ *
+ * @ingroup Feed
+ */
+class ChangesFeed {
+ public $format, $type, $titleMsg, $descMsg;
+
+ /**
+ * @param string $format Feed's format (either 'rss' or 'atom')
+ * @param string $type Type of feed (for cache keys)
+ */
+ public function __construct( $format, $type ) {
+ $this->format = $format;
+ $this->type = $type;
+ }
+
+ /**
+ * Get a ChannelFeed subclass object to use
+ *
+ * @param string $title Feed's title
+ * @param string $description Feed's description
+ * @param string $url Url of origin page
+ * @return ChannelFeed|bool ChannelFeed subclass or false on failure
+ */
+ public function getFeedObject( $title, $description, $url ) {
+ global $wgSitename, $wgLanguageCode, $wgFeedClasses;
+
+ if ( !isset( $wgFeedClasses[$this->format] ) ) {
+ return false;
+ }
+
+ if ( !array_key_exists( $this->format, $wgFeedClasses ) ) {
+ // falling back to atom
+ $this->format = 'atom';
+ }
+
+ $feedTitle = "$wgSitename - {$title} [$wgLanguageCode]";
+ return new $wgFeedClasses[$this->format](
+ $feedTitle, htmlspecialchars( $description ), $url );
+ }
+
+ /**
+ * Generates feed's content
+ *
+ * @param ChannelFeed $feed ChannelFeed subclass object (generally the one returned
+ * by getFeedObject())
+ * @param ResultWrapper $rows ResultWrapper object with rows in recentchanges table
+ * @param int $lastmod Timestamp of the last item in the recentchanges table (only
+ * used for the cache key)
+ * @param FormOptions $opts As in SpecialRecentChanges::getDefaultOptions()
+ * @return null|bool True or null
+ */
+ public function execute( $feed, $rows, $lastmod, $opts ) {
+ global $wgLang, $wgRenderHashAppend;
+
+ if ( !FeedUtils::checkFeedOutput( $this->format ) ) {
+ return null;
+ }
+
+ $optionsHash = md5( serialize( $opts->getAllValues() ) ) . $wgRenderHashAppend;
+ $timekey = wfMemcKey(
+ $this->type, $this->format, $wgLang->getCode(), $optionsHash, 'timestamp' );
+ $key = wfMemcKey( $this->type, $this->format, $wgLang->getCode(), $optionsHash );
+
+ FeedUtils::checkPurge( $timekey, $key );
+
+ /**
+ * Bumping around loading up diffs can be pretty slow, so where
+ * possible we want to cache the feed output so the next visitor
+ * gets it quick too.
+ */
+ $cachedFeed = $this->loadFromCache( $lastmod, $timekey, $key );
+ if ( is_string( $cachedFeed ) ) {
+ wfDebug( "RC: Outputting cached feed\n" );
+ $feed->httpHeaders();
+ echo $cachedFeed;
+ } else {
+ wfDebug( "RC: rendering new feed and caching it\n" );
+ ob_start();
+ self::generateFeed( $rows, $feed );
+ $cachedFeed = ob_get_contents();
+ ob_end_flush();
+ $this->saveToCache( $cachedFeed, $timekey, $key );
+ }
+ return true;
+ }
+
+ /**
+ * Save to feed result to cache
+ *
+ * @param string $feed Feed's content
+ * @param string $timekey Memcached key of the last modification
+ * @param string $key Memcached key of the content
+ */
+ public function saveToCache( $feed, $timekey, $key ) {
+ $cache = ObjectCache::getMainWANInstance();
+ $cache->set( $key, $feed, $cache::TTL_DAY );
+ $cache->set( $timekey, wfTimestamp( TS_MW ), $cache::TTL_DAY );
+ }
+
+ /**
+ * Try to load the feed result from cache
+ *
+ * @param int $lastmod Timestamp of the last item in the recentchanges table
+ * @param string $timekey Memcached key of the last modification
+ * @param string $key Memcached key of the content
+ * @return string|bool Feed's content on cache hit or false on cache miss
+ */
+ public function loadFromCache( $lastmod, $timekey, $key ) {
+ global $wgFeedCacheTimeout, $wgOut;
+
+ $cache = ObjectCache::getMainWANInstance();
+ $feedLastmod = $cache->get( $timekey );
+
+ if ( ( $wgFeedCacheTimeout > 0 ) && $feedLastmod ) {
+ /**
+ * If the cached feed was rendered very recently, we may
+ * go ahead and use it even if there have been edits made
+ * since it was rendered. This keeps a swarm of requests
+ * from being too bad on a super-frequently edited wiki.
+ */
+
+ $feedAge = time() - wfTimestamp( TS_UNIX, $feedLastmod );
+ $feedLastmodUnix = wfTimestamp( TS_UNIX, $feedLastmod );
+ $lastmodUnix = wfTimestamp( TS_UNIX, $lastmod );
+
+ if ( $feedAge < $wgFeedCacheTimeout || $feedLastmodUnix > $lastmodUnix ) {
+ wfDebug( "RC: loading feed from cache ($key; $feedLastmod; $lastmod)...\n" );
+ if ( $feedLastmodUnix < $lastmodUnix ) {
+ $wgOut->setLastModified( $feedLastmod ); // T23916
+ }
+ return $cache->get( $key );
+ } else {
+ wfDebug( "RC: cached feed timestamp check failed ($feedLastmod; $lastmod)\n" );
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Generate the feed items given a row from the database, printing the feed.
+ * @param object $rows IDatabase resource with recentchanges rows
+ * @param ChannelFeed &$feed
+ */
+ public static function generateFeed( $rows, &$feed ) {
+ $items = self::buildItems( $rows );
+ $feed->outHeader();
+ foreach ( $items as $item ) {
+ $feed->outItem( $item );
+ }
+ $feed->outFooter();
+ }
+
+ /**
+ * Generate the feed items given a row from the database.
+ * @param object $rows IDatabase resource with recentchanges rows
+ * @return array
+ */
+ public static function buildItems( $rows ) {
+ $items = [];
+
+ # Merge adjacent edits by one user
+ $sorted = [];
+ $n = 0;
+ foreach ( $rows as $obj ) {
+ if ( $obj->rc_type == RC_EXTERNAL ) {
+ continue;
+ }
+
+ if ( $n > 0 &&
+ $obj->rc_type == RC_EDIT &&
+ $obj->rc_namespace >= 0 &&
+ $obj->rc_cur_id == $sorted[$n - 1]->rc_cur_id &&
+ $obj->rc_user_text == $sorted[$n - 1]->rc_user_text ) {
+ $sorted[$n - 1]->rc_last_oldid = $obj->rc_last_oldid;
+ } else {
+ $sorted[$n] = $obj;
+ $n++;
+ }
+ }
+
+ foreach ( $sorted as $obj ) {
+ $title = Title::makeTitle( $obj->rc_namespace, $obj->rc_title );
+ $talkpage = MWNamespace::canTalk( $obj->rc_namespace )
+ ? $title->getTalkPage()->getFullURL()
+ : '';
+
+ // Skip items with deleted content (avoids partially complete/inconsistent output)
+ if ( $obj->rc_deleted ) {
+ continue;
+ }
+
+ if ( $obj->rc_this_oldid ) {
+ $url = $title->getFullURL( [
+ 'diff' => $obj->rc_this_oldid,
+ 'oldid' => $obj->rc_last_oldid,
+ ] );
+ } else {
+ // log entry or something like that.
+ $url = $title->getFullURL();
+ }
+
+ $items[] = new FeedItem(
+ $title->getPrefixedText(),
+ FeedUtils::formatDiff( $obj ),
+ $url,
+ $obj->rc_timestamp,
+ ( $obj->rc_deleted & Revision::DELETED_USER )
+ ? wfMessage( 'rev-deleted-user' )->escaped() : $obj->rc_user_text,
+ $talkpage
+ );
+ }
+
+ return $items;
+ }
+}
diff --git a/www/wiki/includes/changes/ChangesList.php b/www/wiki/includes/changes/ChangesList.php
new file mode 100644
index 00000000..bc50096f
--- /dev/null
+++ b/www/wiki/includes/changes/ChangesList.php
@@ -0,0 +1,785 @@
+<?php
+/**
+ * Base class for all changes lists.
+ *
+ * The class is used for formatting recent changes, related changes and watchlist.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+
+class ChangesList extends ContextSource {
+ const CSS_CLASS_PREFIX = 'mw-changeslist-';
+
+ /**
+ * @var Skin
+ */
+ public $skin;
+
+ protected $watchlist = false;
+ protected $lastdate;
+ protected $message;
+ protected $rc_cache;
+ protected $rcCacheIndex;
+ protected $rclistOpen;
+ protected $rcMoveIndex;
+
+ /** @var callable */
+ protected $changeLinePrefixer;
+
+ /** @var BagOStuff */
+ protected $watchMsgCache;
+
+ /**
+ * @var LinkRenderer
+ */
+ protected $linkRenderer;
+
+ /**
+ * @var array
+ */
+ protected $filterGroups;
+
+ /**
+ * Changeslist constructor
+ *
+ * @param Skin|IContextSource $obj
+ * @param array $filterGroups Array of ChangesListFilterGroup objects (currently optional)
+ */
+ public function __construct( $obj, array $filterGroups = [] ) {
+ if ( $obj instanceof IContextSource ) {
+ $this->setContext( $obj );
+ $this->skin = $obj->getSkin();
+ } else {
+ $this->setContext( $obj->getContext() );
+ $this->skin = $obj;
+ }
+ $this->preCacheMessages();
+ $this->watchMsgCache = new HashBagOStuff( [ 'maxKeys' => 50 ] );
+ $this->linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ $this->filterGroups = $filterGroups;
+ }
+
+ /**
+ * Fetch an appropriate changes list class for the specified context
+ * Some users might want to use an enhanced list format, for instance
+ *
+ * @param IContextSource $context
+ * @param array $groups Array of ChangesListFilterGroup objects (currently optional)
+ * @return ChangesList
+ */
+ public static function newFromContext( IContextSource $context, array $groups = [] ) {
+ $user = $context->getUser();
+ $sk = $context->getSkin();
+ $list = null;
+ if ( Hooks::run( 'FetchChangesList', [ $user, &$sk, &$list ] ) ) {
+ $new = $context->getRequest()->getBool( 'enhanced', $user->getOption( 'usenewrc' ) );
+
+ return $new ?
+ new EnhancedChangesList( $context, $groups ) :
+ new OldChangesList( $context, $groups );
+ } else {
+ return $list;
+ }
+ }
+
+ /**
+ * Format a line
+ *
+ * @since 1.27
+ *
+ * @param RecentChange &$rc Passed by reference
+ * @param bool $watched (default false)
+ * @param int $linenumber (default null)
+ *
+ * @return string|bool
+ */
+ public function recentChangesLine( &$rc, $watched = false, $linenumber = null ) {
+ throw new RuntimeException( 'recentChangesLine should be implemented' );
+ }
+
+ /**
+ * Sets the list to use a "<li class='watchlist-(namespace)-(page)'>" tag
+ * @param bool $value
+ */
+ public function setWatchlistDivs( $value = true ) {
+ $this->watchlist = $value;
+ }
+
+ /**
+ * @return bool True when setWatchlistDivs has been called
+ * @since 1.23
+ */
+ public function isWatchlist() {
+ return (bool)$this->watchlist;
+ }
+
+ /**
+ * As we use the same small set of messages in various methods and that
+ * they are called often, we call them once and save them in $this->message
+ */
+ private function preCacheMessages() {
+ if ( !isset( $this->message ) ) {
+ foreach ( [
+ 'cur', 'diff', 'hist', 'enhancedrc-history', 'last', 'blocklink', 'history',
+ 'semicolon-separator', 'pipe-separator' ] as $msg
+ ) {
+ $this->message[$msg] = $this->msg( $msg )->escaped();
+ }
+ }
+ }
+
+ /**
+ * Returns the appropriate flags for new page, minor change and patrolling
+ * @param array $flags Associative array of 'flag' => Bool
+ * @param string $nothing To use for empty space
+ * @return string
+ */
+ public function recentChangesFlags( $flags, $nothing = '&#160;' ) {
+ $f = '';
+ foreach ( array_keys( $this->getConfig()->get( 'RecentChangesFlags' ) ) as $flag ) {
+ $f .= isset( $flags[$flag] ) && $flags[$flag]
+ ? self::flag( $flag, $this->getContext() )
+ : $nothing;
+ }
+
+ return $f;
+ }
+
+ /**
+ * Get an array of default HTML class attributes for the change.
+ *
+ * @param RecentChange|RCCacheEntry $rc
+ * @param string|bool $watched Optionally timestamp for adding watched class
+ *
+ * @return array of classes
+ */
+ protected function getHTMLClasses( $rc, $watched ) {
+ $classes = [ self::CSS_CLASS_PREFIX . 'line' ];
+ $logType = $rc->mAttribs['rc_log_type'];
+
+ if ( $logType ) {
+ $classes[] = self::CSS_CLASS_PREFIX . 'log';
+ $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'log-' . $logType );
+ } else {
+ $classes[] = self::CSS_CLASS_PREFIX . 'edit';
+ $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns' .
+ $rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] );
+ }
+ $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns-' .
+ $rc->mAttribs['rc_namespace'] );
+
+ // Indicate watched status on the line to allow for more
+ // comprehensive styling.
+ $classes[] = $watched && $rc->mAttribs['rc_timestamp'] >= $watched
+ ? self::CSS_CLASS_PREFIX . 'line-watched'
+ : self::CSS_CLASS_PREFIX . 'line-not-watched';
+
+ $classes = array_merge( $classes, $this->getHTMLClassesForFilters( $rc ) );
+
+ return $classes;
+ }
+
+ /**
+ * Get an array of CSS classes attributed to filters for this row
+ *
+ * @param RecentChange $rc
+ * @return array Array of CSS classes
+ */
+ protected function getHTMLClassesForFilters( $rc ) {
+ $classes = [];
+
+ if ( $this->filterGroups !== null ) {
+ foreach ( $this->filterGroups as $filterGroup ) {
+ foreach ( $filterGroup->getFilters() as $filter ) {
+ $filter->applyCssClassIfNeeded( $this, $rc, $classes );
+ }
+ }
+ }
+
+ return $classes;
+ }
+
+ /**
+ * Make an "<abbr>" element for a given change flag. The flag indicating a new page, minor edit,
+ * bot edit, or unpatrolled edit. In English it typically contains "N", "m", "b", or "!".
+ *
+ * @param string $flag One key of $wgRecentChangesFlags
+ * @param IContextSource $context
+ * @return string HTML
+ */
+ public static function flag( $flag, IContextSource $context = null ) {
+ static $map = [ 'minoredit' => 'minor', 'botedit' => 'bot' ];
+ static $flagInfos = null;
+
+ if ( is_null( $flagInfos ) ) {
+ global $wgRecentChangesFlags;
+ $flagInfos = [];
+ foreach ( $wgRecentChangesFlags as $key => $value ) {
+ $flagInfos[$key]['letter'] = $value['letter'];
+ $flagInfos[$key]['title'] = $value['title'];
+ // Allow customized class name, fall back to flag name
+ $flagInfos[$key]['class'] = isset( $value['class'] ) ? $value['class'] : $key;
+ }
+ }
+
+ $context = $context ?: RequestContext::getMain();
+
+ // Inconsistent naming, kepted for b/c
+ if ( isset( $map[$flag] ) ) {
+ $flag = $map[$flag];
+ }
+
+ $info = $flagInfos[$flag];
+ return Html::element( 'abbr', [
+ 'class' => $info['class'],
+ 'title' => wfMessage( $info['title'] )->setContext( $context )->text(),
+ ], wfMessage( $info['letter'] )->setContext( $context )->text() );
+ }
+
+ /**
+ * Returns text for the start of the tabular part of RC
+ * @return string
+ */
+ public function beginRecentChangesList() {
+ $this->rc_cache = [];
+ $this->rcMoveIndex = 0;
+ $this->rcCacheIndex = 0;
+ $this->lastdate = '';
+ $this->rclistOpen = false;
+ $this->getOutput()->addModuleStyles( 'mediawiki.special.changeslist' );
+
+ return '<div class="mw-changeslist">';
+ }
+
+ /**
+ * @param ResultWrapper|array $rows
+ */
+ public function initChangesListRows( $rows ) {
+ Hooks::run( 'ChangesListInitRows', [ $this, $rows ] );
+ }
+
+ /**
+ * Show formatted char difference
+ *
+ * Needs the css module 'mediawiki.special.changeslist' to style output
+ *
+ * @param int $old Number of bytes
+ * @param int $new Number of bytes
+ * @param IContextSource $context
+ * @return string
+ */
+ public static function showCharacterDifference( $old, $new, IContextSource $context = null ) {
+ if ( !$context ) {
+ $context = RequestContext::getMain();
+ }
+
+ $new = (int)$new;
+ $old = (int)$old;
+ $szdiff = $new - $old;
+
+ $lang = $context->getLanguage();
+ $config = $context->getConfig();
+ $code = $lang->getCode();
+ static $fastCharDiff = [];
+ if ( !isset( $fastCharDiff[$code] ) ) {
+ $fastCharDiff[$code] = $config->get( 'MiserMode' )
+ || $context->msg( 'rc-change-size' )->plain() === '$1';
+ }
+
+ $formattedSize = $lang->formatNum( $szdiff );
+
+ if ( !$fastCharDiff[$code] ) {
+ $formattedSize = $context->msg( 'rc-change-size', $formattedSize )->text();
+ }
+
+ if ( abs( $szdiff ) > abs( $config->get( 'RCChangedSizeThreshold' ) ) ) {
+ $tag = 'strong';
+ } else {
+ $tag = 'span';
+ }
+
+ if ( $szdiff === 0 ) {
+ $formattedSizeClass = 'mw-plusminus-null';
+ } elseif ( $szdiff > 0 ) {
+ $formattedSize = '+' . $formattedSize;
+ $formattedSizeClass = 'mw-plusminus-pos';
+ } else {
+ $formattedSizeClass = 'mw-plusminus-neg';
+ }
+
+ $formattedTotalSize = $context->msg( 'rc-change-size-new' )->numParams( $new )->text();
+
+ return Html::element( $tag,
+ [ 'dir' => 'ltr', 'class' => $formattedSizeClass, 'title' => $formattedTotalSize ],
+ $context->msg( 'parentheses', $formattedSize )->plain() ) . $lang->getDirMark();
+ }
+
+ /**
+ * Format the character difference of one or several changes.
+ *
+ * @param RecentChange $old
+ * @param RecentChange $new Last change to use, if not provided, $old will be used
+ * @return string HTML fragment
+ */
+ public function formatCharacterDifference( RecentChange $old, RecentChange $new = null ) {
+ $oldlen = $old->mAttribs['rc_old_len'];
+
+ if ( $new ) {
+ $newlen = $new->mAttribs['rc_new_len'];
+ } else {
+ $newlen = $old->mAttribs['rc_new_len'];
+ }
+
+ if ( $oldlen === null || $newlen === null ) {
+ return '';
+ }
+
+ return self::showCharacterDifference( $oldlen, $newlen, $this->getContext() );
+ }
+
+ /**
+ * Returns text for the end of RC
+ * @return string
+ */
+ public function endRecentChangesList() {
+ $out = $this->rclistOpen ? "</ul>\n" : '';
+ $out .= '</div>';
+
+ return $out;
+ }
+
+ /**
+ * @param string &$s HTML to update
+ * @param mixed $rc_timestamp
+ */
+ public function insertDateHeader( &$s, $rc_timestamp ) {
+ # Make date header if necessary
+ $date = $this->getLanguage()->userDate( $rc_timestamp, $this->getUser() );
+ if ( $date != $this->lastdate ) {
+ if ( $this->lastdate != '' ) {
+ $s .= "</ul>\n";
+ }
+ $s .= Xml::element( 'h4', null, $date ) . "\n<ul class=\"special\">";
+ $this->lastdate = $date;
+ $this->rclistOpen = true;
+ }
+ }
+
+ /**
+ * @param string &$s HTML to update
+ * @param Title $title
+ * @param string $logtype
+ */
+ public function insertLog( &$s, $title, $logtype ) {
+ $page = new LogPage( $logtype );
+ $logname = $page->getName()->setContext( $this->getContext() )->text();
+ $s .= $this->msg( 'parentheses' )->rawParams(
+ $this->linkRenderer->makeKnownLink( $title, $logname )
+ )->escaped();
+ }
+
+ /**
+ * @param string &$s HTML to update
+ * @param RecentChange &$rc
+ * @param bool|null $unpatrolled Unused variable, since 1.27.
+ */
+ public function insertDiffHist( &$s, &$rc, $unpatrolled = null ) {
+ # Diff link
+ if (
+ $rc->mAttribs['rc_type'] == RC_NEW ||
+ $rc->mAttribs['rc_type'] == RC_LOG ||
+ $rc->mAttribs['rc_type'] == RC_CATEGORIZE
+ ) {
+ $diffLink = $this->message['diff'];
+ } elseif ( !self::userCan( $rc, Revision::DELETED_TEXT, $this->getUser() ) ) {
+ $diffLink = $this->message['diff'];
+ } else {
+ $query = [
+ 'curid' => $rc->mAttribs['rc_cur_id'],
+ 'diff' => $rc->mAttribs['rc_this_oldid'],
+ 'oldid' => $rc->mAttribs['rc_last_oldid']
+ ];
+
+ $diffLink = $this->linkRenderer->makeKnownLink(
+ $rc->getTitle(),
+ new HtmlArmor( $this->message['diff'] ),
+ [ 'class' => 'mw-changeslist-diff' ],
+ $query
+ );
+ }
+ if ( $rc->mAttribs['rc_type'] == RC_CATEGORIZE ) {
+ $diffhist = $diffLink . $this->message['pipe-separator'] . $this->message['hist'];
+ } else {
+ $diffhist = $diffLink . $this->message['pipe-separator'];
+ # History link
+ $diffhist .= $this->linkRenderer->makeKnownLink(
+ $rc->getTitle(),
+ new HtmlArmor( $this->message['hist'] ),
+ [ 'class' => 'mw-changeslist-history' ],
+ [
+ 'curid' => $rc->mAttribs['rc_cur_id'],
+ 'action' => 'history'
+ ]
+ );
+ }
+
+ // @todo FIXME: Hard coded ". .". Is there a message for this? Should there be?
+ $s .= $this->msg( 'parentheses' )->rawParams( $diffhist )->escaped() .
+ ' <span class="mw-changeslist-separator">. .</span> ';
+ }
+
+ /**
+ * @param string &$s Article link will be appended to this string, in place.
+ * @param RecentChange $rc
+ * @param bool $unpatrolled
+ * @param bool $watched
+ * @deprecated since 1.27, use getArticleLink instead.
+ */
+ public function insertArticleLink( &$s, RecentChange $rc, $unpatrolled, $watched ) {
+ $s .= $this->getArticleLink( $rc, $unpatrolled, $watched );
+ }
+
+ /**
+ * @param RecentChange &$rc
+ * @param bool $unpatrolled
+ * @param bool $watched
+ * @return string HTML
+ * @since 1.26
+ */
+ public function getArticleLink( &$rc, $unpatrolled, $watched ) {
+ $params = [];
+ if ( $rc->getTitle()->isRedirect() ) {
+ $params = [ 'redirect' => 'no' ];
+ }
+
+ $articlelink = $this->linkRenderer->makeLink(
+ $rc->getTitle(),
+ null,
+ [ 'class' => 'mw-changeslist-title' ],
+ $params
+ );
+ if ( $this->isDeleted( $rc, Revision::DELETED_TEXT ) ) {
+ $articlelink = '<span class="history-deleted">' . $articlelink . '</span>';
+ }
+ # To allow for boldening pages watched by this user
+ $articlelink = "<span class=\"mw-title\">{$articlelink}</span>";
+ # RTL/LTR marker
+ $articlelink .= $this->getLanguage()->getDirMark();
+
+ # TODO: Deprecate the $s argument, it seems happily unused.
+ $s = '';
+ # Avoid PHP 7.1 warning from passing $this by reference
+ $changesList = $this;
+ Hooks::run( 'ChangesListInsertArticleLink',
+ [ &$changesList, &$articlelink, &$s, &$rc, $unpatrolled, $watched ] );
+
+ return "{$s} {$articlelink}";
+ }
+
+ /**
+ * Get the timestamp from $rc formatted with current user's settings
+ * and a separator
+ *
+ * @param RecentChange $rc
+ * @return string HTML fragment
+ */
+ public function getTimestamp( $rc ) {
+ // @todo FIXME: Hard coded ". .". Is there a message for this? Should there be?
+ return $this->message['semicolon-separator'] . '<span class="mw-changeslist-date">' .
+ $this->getLanguage()->userTime(
+ $rc->mAttribs['rc_timestamp'],
+ $this->getUser()
+ ) . '</span> <span class="mw-changeslist-separator">. .</span> ';
+ }
+
+ /**
+ * Insert time timestamp string from $rc into $s
+ *
+ * @param string &$s HTML to update
+ * @param RecentChange $rc
+ */
+ public function insertTimestamp( &$s, $rc ) {
+ $s .= $this->getTimestamp( $rc );
+ }
+
+ /**
+ * Insert links to user page, user talk page and eventually a blocking link
+ *
+ * @param string &$s HTML to update
+ * @param RecentChange &$rc
+ */
+ public function insertUserRelatedLinks( &$s, &$rc ) {
+ if ( $this->isDeleted( $rc, Revision::DELETED_USER ) ) {
+ $s .= ' <span class="history-deleted">' .
+ $this->msg( 'rev-deleted-user' )->escaped() . '</span>';
+ } else {
+ $s .= $this->getLanguage()->getDirMark() . Linker::userLink( $rc->mAttribs['rc_user'],
+ $rc->mAttribs['rc_user_text'] );
+ $s .= Linker::userToolLinks( $rc->mAttribs['rc_user'], $rc->mAttribs['rc_user_text'] );
+ }
+ }
+
+ /**
+ * Insert a formatted action
+ *
+ * @param RecentChange $rc
+ * @return string
+ */
+ public function insertLogEntry( $rc ) {
+ $formatter = LogFormatter::newFromRow( $rc->mAttribs );
+ $formatter->setContext( $this->getContext() );
+ $formatter->setShowUserToolLinks( true );
+ $mark = $this->getLanguage()->getDirMark();
+
+ return $formatter->getActionText() . " $mark" . $formatter->getComment();
+ }
+
+ /**
+ * Insert a formatted comment
+ * @param RecentChange $rc
+ * @return string
+ */
+ public function insertComment( $rc ) {
+ if ( $this->isDeleted( $rc, Revision::DELETED_COMMENT ) ) {
+ return ' <span class="history-deleted">' .
+ $this->msg( 'rev-deleted-comment' )->escaped() . '</span>';
+ } else {
+ return Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() );
+ }
+ }
+
+ /**
+ * Returns the string which indicates the number of watching users
+ * @param int $count Number of user watching a page
+ * @return string
+ */
+ protected function numberofWatchingusers( $count ) {
+ if ( $count <= 0 ) {
+ return '';
+ }
+ $cache = $this->watchMsgCache;
+ return $cache->getWithSetCallback( $count, $cache::TTL_INDEFINITE,
+ function () use ( $count ) {
+ return $this->msg( 'number_of_watching_users_RCview' )
+ ->numParams( $count )->escaped();
+ }
+ );
+ }
+
+ /**
+ * Determine if said field of a revision is hidden
+ * @param RCCacheEntry|RecentChange $rc
+ * @param int $field One of DELETED_* bitfield constants
+ * @return bool
+ */
+ public static function isDeleted( $rc, $field ) {
+ return ( $rc->mAttribs['rc_deleted'] & $field ) == $field;
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this revision, if it's marked as deleted.
+ * @param RCCacheEntry|RecentChange $rc
+ * @param int $field
+ * @param User $user User object to check, or null to use $wgUser
+ * @return bool
+ */
+ public static function userCan( $rc, $field, User $user = null ) {
+ if ( $rc->mAttribs['rc_type'] == RC_LOG ) {
+ return LogEventsList::userCanBitfield( $rc->mAttribs['rc_deleted'], $field, $user );
+ } else {
+ return Revision::userCanBitfield( $rc->mAttribs['rc_deleted'], $field, $user );
+ }
+ }
+
+ /**
+ * @param string $link
+ * @param bool $watched
+ * @return string
+ */
+ protected function maybeWatchedLink( $link, $watched = false ) {
+ if ( $watched ) {
+ return '<strong class="mw-watched">' . $link . '</strong>';
+ } else {
+ return '<span class="mw-rc-unwatched">' . $link . '</span>';
+ }
+ }
+
+ /** Inserts a rollback link
+ *
+ * @param string &$s
+ * @param RecentChange &$rc
+ */
+ public function insertRollback( &$s, &$rc ) {
+ if ( $rc->mAttribs['rc_type'] == RC_EDIT
+ && $rc->mAttribs['rc_this_oldid']
+ && $rc->mAttribs['rc_cur_id']
+ ) {
+ $page = $rc->getTitle();
+ /** Check for rollback and edit permissions, disallow special pages, and only
+ * show a link on the top-most revision */
+ if ( $this->getUser()->isAllowed( 'rollback' )
+ && $rc->mAttribs['page_latest'] == $rc->mAttribs['rc_this_oldid']
+ ) {
+ $rev = new Revision( [
+ 'title' => $page,
+ 'id' => $rc->mAttribs['rc_this_oldid'],
+ 'user' => $rc->mAttribs['rc_user'],
+ 'user_text' => $rc->mAttribs['rc_user_text'],
+ 'deleted' => $rc->mAttribs['rc_deleted']
+ ] );
+ $s .= ' ' . Linker::generateRollback( $rev, $this->getContext() );
+ }
+ }
+ }
+
+ /**
+ * @param RecentChange $rc
+ * @return string
+ * @since 1.26
+ */
+ public function getRollback( RecentChange $rc ) {
+ $s = '';
+ $this->insertRollback( $s, $rc );
+ return $s;
+ }
+
+ /**
+ * @param string &$s
+ * @param RecentChange &$rc
+ * @param array &$classes
+ */
+ public function insertTags( &$s, &$rc, &$classes ) {
+ if ( empty( $rc->mAttribs['ts_tags'] ) ) {
+ return;
+ }
+
+ list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow(
+ $rc->mAttribs['ts_tags'],
+ 'changeslist',
+ $this->getContext()
+ );
+ $classes = array_merge( $classes, $newClasses );
+ $s .= ' ' . $tagSummary;
+ }
+
+ /**
+ * @param RecentChange $rc
+ * @param array &$classes
+ * @return string
+ * @since 1.26
+ */
+ public function getTags( RecentChange $rc, array &$classes ) {
+ $s = '';
+ $this->insertTags( $s, $rc, $classes );
+ return $s;
+ }
+
+ public function insertExtra( &$s, &$rc, &$classes ) {
+ // Empty, used for subclasses to add anything special.
+ }
+
+ protected function showAsUnpatrolled( RecentChange $rc ) {
+ return self::isUnpatrolled( $rc, $this->getUser() );
+ }
+
+ /**
+ * @param object|RecentChange $rc Database row from recentchanges or a RecentChange object
+ * @param User $user
+ * @return bool
+ */
+ public static function isUnpatrolled( $rc, User $user ) {
+ if ( $rc instanceof RecentChange ) {
+ $isPatrolled = $rc->mAttribs['rc_patrolled'];
+ $rcType = $rc->mAttribs['rc_type'];
+ $rcLogType = $rc->mAttribs['rc_log_type'];
+ } else {
+ $isPatrolled = $rc->rc_patrolled;
+ $rcType = $rc->rc_type;
+ $rcLogType = $rc->rc_log_type;
+ }
+
+ if ( !$isPatrolled ) {
+ if ( $user->useRCPatrol() ) {
+ return true;
+ }
+ if ( $user->useNPPatrol() && $rcType == RC_NEW ) {
+ return true;
+ }
+ if ( $user->useFilePatrol() && $rcLogType == 'upload' ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines whether a revision is linked to this change; this may not be the case
+ * when the categorization wasn't done by an edit but a conditional parser function
+ *
+ * @since 1.27
+ *
+ * @param RecentChange|RCCacheEntry $rcObj
+ * @return bool
+ */
+ protected function isCategorizationWithoutRevision( $rcObj ) {
+ return intval( $rcObj->getAttribute( 'rc_type' ) ) === RC_CATEGORIZE
+ && intval( $rcObj->getAttribute( 'rc_this_oldid' ) ) === 0;
+ }
+
+ /**
+ * Get recommended data attributes for a change line.
+ * @param RecentChange $rc
+ * @return string[] attribute name => value
+ */
+ protected function getDataAttributes( RecentChange $rc ) {
+ $attrs = [];
+
+ $type = $rc->getAttribute( 'rc_source' );
+ switch ( $type ) {
+ case RecentChange::SRC_EDIT:
+ case RecentChange::SRC_NEW:
+ $attrs['data-mw-revid'] = $rc->mAttribs['rc_this_oldid'];
+ break;
+ case RecentChange::SRC_LOG:
+ $attrs['data-mw-logid'] = $rc->mAttribs['rc_logid'];
+ $attrs['data-mw-logaction'] =
+ $rc->mAttribs['rc_log_type'] . '/' . $rc->mAttribs['rc_log_action'];
+ break;
+ }
+
+ $attrs[ 'data-mw-ts' ] = $rc->getAttribute( 'rc_timestamp' );
+
+ return $attrs;
+ }
+
+ /**
+ * Sets the callable that generates a change line prefix added to the beginning of each line.
+ *
+ * @param callable $prefixer Callable to run that generates the change line prefix.
+ * Takes three parameters: a RecentChange object, a ChangesList object,
+ * and whether the current entry is a grouped entry.
+ */
+ public function setChangeLinePrefixer( callable $prefixer ) {
+ $this->changeLinePrefixer = $prefixer;
+ }
+}
diff --git a/www/wiki/includes/changes/ChangesListBooleanFilter.php b/www/wiki/includes/changes/ChangesListBooleanFilter.php
new file mode 100644
index 00000000..2a7ba884
--- /dev/null
+++ b/www/wiki/includes/changes/ChangesListBooleanFilter.php
@@ -0,0 +1,261 @@
+<?php
+/**
+ * Represents a hide-based boolean filter (used on ChangesListSpecialPage and descendants)
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Matthew Flaschen
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * An individual filter in a boolean group
+ *
+ * @since 1.29
+ */
+class ChangesListBooleanFilter extends ChangesListFilter {
+ // This can sometimes be different on Special:RecentChanges
+ // and Special:Watchlist, due to the double-legacy hooks
+ // (SpecialRecentChangesFilters and SpecialWatchlistFilters)
+
+ // but there will be separate sets of ChangesListFilterGroup and ChangesListFilter instances
+ // for those pages (it should work even if they're both loaded
+ // at once, but that can't happen).
+ /**
+ * Main unstructured UI i18n key
+ *
+ * @var string $showHide
+ */
+ protected $showHide;
+
+ /**
+ * Whether there is a feature designed to replace this filter available on the
+ * structured UI
+ *
+ * @var bool $isReplacedInStructuredUi
+ */
+ protected $isReplacedInStructuredUi;
+
+ /**
+ * Default
+ *
+ * @var bool $defaultValue
+ */
+ protected $defaultValue;
+
+ /**
+ * Callable used to do the actual query modification; see constructor
+ *
+ * @var callable $queryCallable
+ */
+ protected $queryCallable;
+
+ /**
+ * Value that defined when this filter is considered active
+ *
+ * @var bool $activeValue
+ */
+ protected $activeValue;
+
+ /**
+ * Create a new filter with the specified configuration.
+ *
+ * It infers which UI (it can be either or both) to display the filter on based on
+ * which messages are provided.
+ *
+ * If 'label' is provided, it will be displayed on the structured UI. If
+ * 'showHide' is provided, it will be displayed on the unstructured UI. Thus,
+ * 'label', 'description', and 'showHide' are optional depending on which UI
+ * it's for.
+ *
+ * @param array $filterDefinition ChangesListFilter definition
+ * * $filterDefinition['name'] string Name. Used as URL parameter.
+ * * $filterDefinition['group'] ChangesListFilterGroup Group. Filter group this
+ * belongs to.
+ * * $filterDefinition['label'] string i18n key of label for structured UI.
+ * * $filterDefinition['description'] string i18n key of description for structured
+ * UI.
+ * * $filterDefinition['showHide'] string Main i18n key used for unstructured UI.
+ * * $filterDefinition['isReplacedInStructuredUi'] bool Whether there is an
+ * equivalent feature available in the structured UI; this is optional, defaulting
+ * to true. It does not need to be set if the exact same filter is simply visible
+ * on both.
+ * * $filterDefinition['default'] bool Default
+ * * $filterDefinition['activeValue'] bool This filter is considered active when
+ * its value is equal to its activeValue. Default is true.
+ * * $filterDefinition['priority'] int Priority integer. Higher value means higher
+ * up in the group's filter list.
+ * * $filterDefinition['queryCallable'] callable Callable accepting parameters, used
+ * to implement filter's DB query modification. Required, except for legacy
+ * filters that still use the query hooks directly. Callback parameters:
+ * * string $specialPageClassName Class name of current special page
+ * * IContextSource $context Context, for e.g. user
+ * * IDatabase $dbr Database, for addQuotes, makeList, and similar
+ * * array &$tables Array of tables; see IDatabase::select $table
+ * * array &$fields Array of fields; see IDatabase::select $vars
+ * * array &$conds Array of conditions; see IDatabase::select $conds
+ * * array &$query_options Array of query options; see IDatabase::select $options
+ * * array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+ */
+ public function __construct( $filterDefinition ) {
+ parent::__construct( $filterDefinition );
+
+ if ( isset( $filterDefinition['showHide'] ) ) {
+ $this->showHide = $filterDefinition['showHide'];
+ }
+
+ if ( isset( $filterDefinition['isReplacedInStructuredUi'] ) ) {
+ $this->isReplacedInStructuredUi = $filterDefinition['isReplacedInStructuredUi'];
+ } else {
+ $this->isReplacedInStructuredUi = false;
+ }
+
+ if ( isset( $filterDefinition['default'] ) ) {
+ $this->setDefault( $filterDefinition['default'] );
+ } else {
+ throw new MWException( 'You must set a default' );
+ }
+
+ if ( isset( $filterDefinition['queryCallable'] ) ) {
+ $this->queryCallable = $filterDefinition['queryCallable'];
+ }
+
+ if ( isset( $filterDefinition['activeValue'] ) ) {
+ $this->activeValue = $filterDefinition['activeValue'];
+ } else {
+ $this->activeValue = true;
+ }
+ }
+
+ /**
+ * Get the default value
+ *
+ * @param bool $structuredUI Are we currently showing the structured UI
+ * @return bool|null Default value
+ */
+ public function getDefault( $structuredUI = false ) {
+ return $this->isReplacedInStructuredUi && $structuredUI ?
+ !$this->activeValue :
+ $this->defaultValue;
+ }
+
+ /**
+ * Sets default. It must be a boolean.
+ *
+ * It will be coerced to boolean.
+ *
+ * @param bool $defaultValue
+ */
+ public function setDefault( $defaultValue ) {
+ $this->defaultValue = (bool)$defaultValue;
+ }
+
+ /**
+ * @return string Main i18n key for unstructured UI
+ */
+ public function getShowHide() {
+ return $this->showHide;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function displaysOnUnstructuredUi() {
+ return !!$this->showHide;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isFeatureAvailableOnStructuredUi() {
+ return $this->isReplacedInStructuredUi ||
+ parent::isFeatureAvailableOnStructuredUi();
+ }
+
+ /**
+ * Modifies the query to include the filter. This is only called if the filter is
+ * in effect (taking into account the default).
+ *
+ * @param IDatabase $dbr Database, for addQuotes, makeList, and similar
+ * @param ChangesListSpecialPage $specialPage Current special page
+ * @param array &$tables Array of tables; see IDatabase::select $table
+ * @param array &$fields Array of fields; see IDatabase::select $vars
+ * @param array &$conds Array of conditions; see IDatabase::select $conds
+ * @param array &$query_options Array of query options; see IDatabase::select $options
+ * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+ */
+ public function modifyQuery( IDatabase $dbr, ChangesListSpecialPage $specialPage,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds
+ ) {
+ if ( $this->queryCallable === null ) {
+ return;
+ }
+
+ call_user_func_array(
+ $this->queryCallable,
+ [
+ get_class( $specialPage ),
+ $specialPage->getContext(),
+ $dbr,
+ &$tables,
+ &$fields,
+ &$conds,
+ &$query_options,
+ &$join_conds
+ ]
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getJsData() {
+ $output = parent::getJsData();
+
+ $output['default'] = $this->defaultValue;
+
+ return $output;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isSelected( FormOptions $opts ) {
+ return !$opts[ $this->getName() ] &&
+ array_filter(
+ $this->getSiblings(),
+ function ( ChangesListBooleanFilter $sibling ) use ( $opts ) {
+ return $opts[ $sibling->getName() ];
+ }
+ );
+ }
+
+ /**
+ * @param FormOptions $opts Query parameters merged with defaults
+ * @param bool $isStructuredUI Whether the structured UI is currently enabled
+ * @return bool Whether this filter should be considered active
+ */
+ public function isActive( FormOptions $opts, $isStructuredUI ) {
+ if ( $this->isReplacedInStructuredUi && $isStructuredUI ) {
+ return false;
+ }
+
+ return $opts[ $this->getName() ] === $this->activeValue;
+ }
+}
diff --git a/www/wiki/includes/changes/ChangesListBooleanFilterGroup.php b/www/wiki/includes/changes/ChangesListBooleanFilterGroup.php
new file mode 100644
index 00000000..0622211f
--- /dev/null
+++ b/www/wiki/includes/changes/ChangesListBooleanFilterGroup.php
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * If the group is active, any unchecked filters will
+ * translate to hide parameters in the URL. E.g. if 'Human (not bot)' is checked,
+ * but 'Bot' is unchecked, hidebots=1 will be sent.
+ *
+ * @since 1.29
+ */
+class ChangesListBooleanFilterGroup extends ChangesListFilterGroup {
+ /**
+ * Type marker, used by JavaScript
+ */
+ const TYPE = 'send_unselected_if_any';
+
+ /**
+ * Create a new filter group with the specified configuration
+ *
+ * @param array $groupDefinition Configuration of group
+ * * $groupDefinition['name'] string Group name
+ * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
+ * only if none of the filters in the group display in the structured UI)
+ * * $groupDefinition['priority'] int Priority integer. Higher means higher in the
+ * group list.
+ * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
+ * is an associative array to be passed to the filter constructor. However,
+ * 'priority' is optional for the filters. Any filter that has priority unset
+ * will be put to the bottom, in the order given.
+ * * $groupDefinition['whatsThisHeader'] string i18n key for header of "What's
+ * This" popup (optional).
+ * * $groupDefinition['whatsThisBody'] string i18n key for body of "What's This"
+ * popup (optional).
+ * * $groupDefinition['whatsThisUrl'] string URL for main link of "What's This"
+ * popup (optional).
+ * * $groupDefinition['whatsThisLinkText'] string i18n key of text for main link of
+ * "What's This" popup (optional).
+ */
+ public function __construct( array $groupDefinition ) {
+ $groupDefinition['isFullCoverage'] = true;
+ $groupDefinition['type'] = self::TYPE;
+
+ parent::__construct( $groupDefinition );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function createFilter( array $filterDefinition ) {
+ return new ChangesListBooleanFilter( $filterDefinition );
+ }
+
+ /**
+ * Registers a filter in this group
+ *
+ * @param ChangesListBooleanFilter $filter ChangesListBooleanFilter
+ */
+ public function registerFilter( ChangesListBooleanFilter $filter ) {
+ $this->filters[$filter->getName()] = $filter;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isPerGroupRequestParameter() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/changes/ChangesListFilter.php b/www/wiki/includes/changes/ChangesListFilter.php
new file mode 100644
index 00000000..2fc1006e
--- /dev/null
+++ b/www/wiki/includes/changes/ChangesListFilter.php
@@ -0,0 +1,497 @@
+<?php
+/**
+ * Represents a filter (used on ChangesListSpecialPage and descendants)
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Matthew Flaschen
+ */
+
+/**
+ * Represents a filter (used on ChangesListSpecialPage and descendants)
+ *
+ * @since 1.29
+ */
+abstract class ChangesListFilter {
+ /**
+ * Filter name
+ *
+ * @var string $name
+ */
+ protected $name;
+
+ /**
+ * CSS class suffix used for attribution, e.g. 'bot'.
+ *
+ * In this example, if bot actions are included in the result set, this CSS class
+ * will then be included in all bot-flagged actions.
+ *
+ * @var string|null $cssClassSuffix
+ */
+ protected $cssClassSuffix;
+
+ /**
+ * Callable that returns true if and only if a row is attributed to this filter
+ *
+ * @var callable $isRowApplicableCallable
+ */
+ protected $isRowApplicableCallable;
+
+ /**
+ * Group. ChangesListFilterGroup this belongs to
+ *
+ * @var ChangesListFilterGroup $group
+ */
+ protected $group;
+
+ /**
+ * i18n key of label for structured UI
+ *
+ * @var string $label
+ */
+ protected $label;
+
+ /**
+ * i18n key of description for structured UI
+ *
+ * @var string $description
+ */
+ protected $description;
+
+ /**
+ * Array of associative arrays with conflict information. See
+ * setUnidirectionalConflict
+ *
+ * @var array $conflictingGroups
+ */
+ protected $conflictingGroups = [];
+
+ /**
+ * Array of associative arrays with conflict information. See
+ * setUnidirectionalConflict
+ *
+ * @var array $conflictingFilters
+ */
+ protected $conflictingFilters = [];
+
+ /**
+ * Array of associative arrays with subset information
+ *
+ * @var array $subsetFilters
+ */
+ protected $subsetFilters = [];
+
+ /**
+ * Priority integer. Higher value means higher up in the group's filter list.
+ *
+ * @var string $priority
+ */
+ protected $priority;
+
+ const RESERVED_NAME_CHAR = '_';
+
+ /**
+ * Creates a new filter with the specified configuration, and registers it to the
+ * specified group.
+ *
+ * It infers which UI (it can be either or both) to display the filter on based on
+ * which messages are provided.
+ *
+ * If 'label' is provided, it will be displayed on the structured UI. Thus,
+ * 'label', 'description', and sub-class parameters are optional depending on which
+ * UI it's for.
+ *
+ * @param array $filterDefinition ChangesListFilter definition
+ * * $filterDefinition['name'] string Name of filter; use lowercase with no
+ * punctuation
+ * * $filterDefinition['cssClassSuffix'] string CSS class suffix, used to mark
+ * that a particular row belongs to this filter (when a row is included by the
+ * filter) (optional)
+ * * $filterDefinition['isRowApplicableCallable'] Callable taking two parameters, the
+ * IContextSource, and the RecentChange object for the row, and returning true if
+ * the row is attributed to this filter. The above CSS class will then be
+ * automatically added (optional, required if cssClassSuffix is used).
+ * * $filterDefinition['group'] ChangesListFilterGroup Group. Filter group this
+ * belongs to.
+ * * $filterDefinition['label'] string i18n key of label for structured UI.
+ * * $filterDefinition['description'] string i18n key of description for structured
+ * UI.
+ * * $filterDefinition['priority'] int Priority integer. Higher value means higher
+ * up in the group's filter list.
+ */
+ public function __construct( array $filterDefinition ) {
+ if ( isset( $filterDefinition['group'] ) ) {
+ $this->group = $filterDefinition['group'];
+ } else {
+ throw new MWException( 'You must use \'group\' to specify the ' .
+ 'ChangesListFilterGroup this filter belongs to' );
+ }
+
+ if ( strpos( $filterDefinition['name'], self::RESERVED_NAME_CHAR ) !== false ) {
+ throw new MWException( 'Filter names may not contain \'' .
+ self::RESERVED_NAME_CHAR .
+ '\'. Use the naming convention: \'lowercase\''
+ );
+ }
+
+ if ( $this->group->getFilter( $filterDefinition['name'] ) ) {
+ throw new MWException( 'Two filters in a group cannot have the ' .
+ "same name: '{$filterDefinition['name']}'" );
+ }
+
+ $this->name = $filterDefinition['name'];
+
+ if ( isset( $filterDefinition['cssClassSuffix'] ) ) {
+ $this->cssClassSuffix = $filterDefinition['cssClassSuffix'];
+ $this->isRowApplicableCallable = $filterDefinition['isRowApplicableCallable'];
+ }
+
+ if ( isset( $filterDefinition['label'] ) ) {
+ $this->label = $filterDefinition['label'];
+ $this->description = $filterDefinition['description'];
+ }
+
+ $this->priority = $filterDefinition['priority'];
+
+ $this->group->registerFilter( $this );
+ }
+
+ /**
+ * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
+ *
+ * WARNING: This means there is a conflict when both things are *shown*
+ * (not filtered out), even for the hide-based filters. So e.g. conflicting with
+ * 'hideanons' means there is a conflict if only anonymous users are *shown*.
+ *
+ * @param ChangesListFilterGroup|ChangesListFilter $other Other
+ * ChangesListFilterGroup or ChangesListFilter
+ * @param string $globalKey i18n key for top-level conflict message
+ * @param string $forwardKey i18n key for conflict message in this
+ * direction (when in UI context of $this object)
+ * @param string $backwardKey i18n key for conflict message in reverse
+ * direction (when in UI context of $other object)
+ */
+ public function conflictsWith( $other, $globalKey, $forwardKey, $backwardKey ) {
+ if ( $globalKey === null || $forwardKey === null || $backwardKey === null ) {
+ throw new MWException( 'All messages must be specified' );
+ }
+
+ $this->setUnidirectionalConflict(
+ $other,
+ $globalKey,
+ $forwardKey
+ );
+
+ $other->setUnidirectionalConflict(
+ $this,
+ $globalKey,
+ $backwardKey
+ );
+ }
+
+ /**
+ * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
+ * this object.
+ *
+ * Internal use ONLY.
+ *
+ * @param ChangesListFilterGroup|ChangesListFilter $other Other
+ * ChangesListFilterGroup or ChangesListFilter
+ * @param string $globalDescription i18n key for top-level conflict message
+ * @param string $contextDescription i18n key for conflict message in this
+ * direction (when in UI context of $this object)
+ */
+ public function setUnidirectionalConflict( $other, $globalDescription, $contextDescription ) {
+ if ( $other instanceof ChangesListFilterGroup ) {
+ $this->conflictingGroups[] = [
+ 'group' => $other->getName(),
+ 'groupObject' => $other,
+ 'globalDescription' => $globalDescription,
+ 'contextDescription' => $contextDescription,
+ ];
+ } elseif ( $other instanceof ChangesListFilter ) {
+ $this->conflictingFilters[] = [
+ 'group' => $other->getGroup()->getName(),
+ 'filter' => $other->getName(),
+ 'filterObject' => $other,
+ 'globalDescription' => $globalDescription,
+ 'contextDescription' => $contextDescription,
+ ];
+ } else {
+ throw new MWException( 'You can only pass in a ChangesListFilterGroup or a ChangesListFilter' );
+ }
+ }
+
+ /**
+ * Marks that the current instance is (also) a superset of the filter passed in.
+ * This can be called more than once.
+ *
+ * This means that anything in the results for the other filter is also in the
+ * results for this one.
+ *
+ * @param ChangesListFilter $other The filter the current instance is a superset of
+ */
+ public function setAsSupersetOf( ChangesListFilter $other ) {
+ if ( $other->getGroup() !== $this->getGroup() ) {
+ throw new MWException( 'Supersets can only be defined for filters in the same group' );
+ }
+
+ $this->subsetFilters[] = [
+ // It's always the same group, but this makes the representation
+ // more consistent with conflicts.
+ 'group' => $other->getGroup()->getName(),
+ 'filter' => $other->getName(),
+ ];
+ }
+
+ /**
+ * @return string Name, e.g. hideanons
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * @return ChangesListFilterGroup Group this belongs to
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * @return string i18n key of label for structured UI
+ */
+ public function getLabel() {
+ return $this->label;
+ }
+
+ /**
+ * @return string i18n key of description for structured UI
+ */
+ public function getDescription() {
+ return $this->description;
+ }
+
+ /**
+ * Checks whether the filter should display on the unstructured UI
+ *
+ * @return bool Whether to display
+ */
+ abstract public function displaysOnUnstructuredUi();
+
+ /**
+ * Checks whether the filter should display on the structured UI
+ * This refers to the exact filter. See also isFeatureAvailableOnStructuredUi.
+ *
+ * @return bool Whether to display
+ */
+ public function displaysOnStructuredUi() {
+ return $this->label !== null;
+ }
+
+ /**
+ * Checks whether an equivalent feature for this filter is available on the
+ * structured UI.
+ *
+ * This can either be the exact filter, or a new filter that replaces it.
+ * @return bool
+ */
+ public function isFeatureAvailableOnStructuredUi() {
+ return $this->displaysOnStructuredUi();
+ }
+
+ /**
+ * @return int Priority. Higher value means higher up in the group list
+ */
+ public function getPriority() {
+ return $this->priority;
+ }
+
+ /**
+ * Gets the CSS class
+ *
+ * @return string|null CSS class, or null if not defined
+ */
+ protected function getCssClass() {
+ if ( $this->cssClassSuffix !== null ) {
+ return ChangesList::CSS_CLASS_PREFIX . $this->cssClassSuffix;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Add CSS class if needed
+ *
+ * @param IContextSource $ctx Context source
+ * @param RecentChange $rc Recent changes object
+ * @param array &$classes Non-associative array of CSS class names; appended to if needed
+ */
+ public function applyCssClassIfNeeded( IContextSource $ctx, RecentChange $rc, array &$classes ) {
+ if ( $this->isRowApplicableCallable === null ) {
+ return;
+ }
+
+ if ( call_user_func( $this->isRowApplicableCallable, $ctx, $rc ) ) {
+ $classes[] = $this->getCssClass();
+ }
+ }
+
+ /**
+ * Gets the JS data required by the front-end of the structured UI
+ *
+ * @return array Associative array Data required by the front-end. messageKeys is
+ * a special top-level value, with the value being an array of the message keys to
+ * send to the client.
+ */
+ public function getJsData() {
+ $output = [
+ 'name' => $this->getName(),
+ 'label' => $this->getLabel(),
+ 'description' => $this->getDescription(),
+ 'cssClass' => $this->getCssClass(),
+ 'priority' => $this->priority,
+ 'subset' => $this->subsetFilters,
+ 'conflicts' => [],
+ ];
+
+ $output['messageKeys'] = [
+ $this->getLabel(),
+ $this->getDescription(),
+ ];
+
+ $conflicts = array_merge(
+ $this->conflictingGroups,
+ $this->conflictingFilters
+ );
+
+ foreach ( $conflicts as $conflictInfo ) {
+ unset( $conflictInfo['filterObject'] );
+ unset( $conflictInfo['groupObject'] );
+ $output['conflicts'][] = $conflictInfo;
+ array_push(
+ $output['messageKeys'],
+ $conflictInfo['globalDescription'],
+ $conflictInfo['contextDescription']
+ );
+ }
+
+ return $output;
+ }
+
+ /**
+ * Checks whether this filter is selected in the provided options
+ *
+ * @param FormOptions $opts
+ * @return bool
+ */
+ abstract public function isSelected( FormOptions $opts );
+
+ /**
+ * Get groups conflicting with this filter
+ *
+ * @return ChangesListFilterGroup[]
+ */
+ public function getConflictingGroups() {
+ return array_map(
+ function ( $conflictDesc ) {
+ return $conflictDesc[ 'groupObject' ];
+ },
+ $this->conflictingGroups
+ );
+ }
+
+ /**
+ * Get filters conflicting with this filter
+ *
+ * @return ChangesListFilter[]
+ */
+ public function getConflictingFilters() {
+ return array_map(
+ function ( $conflictDesc ) {
+ return $conflictDesc[ 'filterObject' ];
+ },
+ $this->conflictingFilters
+ );
+ }
+
+ /**
+ * Check if the conflict with a group is currently "active"
+ *
+ * @param ChangesListFilterGroup $group
+ * @param FormOptions $opts
+ * @return bool
+ */
+ public function activelyInConflictWithGroup( ChangesListFilterGroup $group, FormOptions $opts ) {
+ if ( $group->anySelected( $opts ) && $this->isSelected( $opts ) ) {
+ /** @var ChangesListFilter $siblingFilter */
+ foreach ( $this->getSiblings() as $siblingFilter ) {
+ if ( $siblingFilter->isSelected( $opts ) && !$siblingFilter->hasConflictWithGroup( $group ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private function hasConflictWithGroup( ChangesListFilterGroup $group ) {
+ return in_array( $group, $this->getConflictingGroups() );
+ }
+
+ /**
+ * Check if the conflict with a filter is currently "active"
+ *
+ * @param ChangesListFilter $filter
+ * @param FormOptions $opts
+ * @return bool
+ */
+ public function activelyInConflictWithFilter( ChangeslistFilter $filter, FormOptions $opts ) {
+ if ( $this->isSelected( $opts ) && $filter->isSelected( $opts ) ) {
+ /** @var ChangesListFilter $siblingFilter */
+ foreach ( $this->getSiblings() as $siblingFilter ) {
+ if (
+ $siblingFilter->isSelected( $opts ) &&
+ !$siblingFilter->hasConflictWithFilter( $filter )
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private function hasConflictWithFilter( ChangeslistFilter $filter ) {
+ return in_array( $filter, $this->getConflictingFilters() );
+ }
+
+ /**
+ * Get filters in the same group
+ *
+ * @return ChangesListFilter[]
+ */
+ protected function getSiblings() {
+ return array_filter(
+ $this->getGroup()->getFilters(),
+ function ( $filter ) {
+ return $filter !== $this;
+ }
+ );
+ }
+}
diff --git a/www/wiki/includes/changes/ChangesListFilterGroup.php b/www/wiki/includes/changes/ChangesListFilterGroup.php
new file mode 100644
index 00000000..e9140da2
--- /dev/null
+++ b/www/wiki/includes/changes/ChangesListFilterGroup.php
@@ -0,0 +1,452 @@
+<?php
+/**
+ * Represents a filter group (used on ChangesListSpecialPage and descendants)
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Matthew Flaschen
+ */
+
+// TODO: Might want to make a super-class or trait to share behavior (especially re
+// conflicts) between ChangesListFilter and ChangesListFilterGroup.
+// What to call it. FilterStructure? That would also let me make
+// setUnidirectionalConflict protected.
+
+/**
+ * Represents a filter group (used on ChangesListSpecialPage and descendants)
+ *
+ * @since 1.29
+ */
+abstract class ChangesListFilterGroup {
+ /**
+ * Name (internal identifier)
+ *
+ * @var string $name
+ */
+ protected $name;
+
+ /**
+ * i18n key for title
+ *
+ * @var string $title
+ */
+ protected $title;
+
+ /**
+ * i18n key for header of What's This?
+ *
+ * @var string|null $whatsThisHeader
+ */
+ protected $whatsThisHeader;
+
+ /**
+ * i18n key for body of What's This?
+ *
+ * @var string|null $whatsThisBody
+ */
+ protected $whatsThisBody;
+
+ /**
+ * URL of What's This? link
+ *
+ * @var string|null $whatsThisUrl
+ */
+ protected $whatsThisUrl;
+
+ /**
+ * i18n key for What's This? link
+ *
+ * @var string|null $whatsThisLinkText
+ */
+ protected $whatsThisLinkText;
+
+ /**
+ * Type, from a TYPE constant of a subclass
+ *
+ * @var string $type
+ */
+ protected $type;
+
+ /**
+ * Priority integer. Higher values means higher up in the
+ * group list.
+ *
+ * @var string $priority
+ */
+ protected $priority;
+
+ /**
+ * Associative array of filters, as ChangesListFilter objects, with filter name as key
+ *
+ * @var array $filters
+ */
+ protected $filters;
+
+ /**
+ * Whether this group is full coverage. This means that checking every item in the
+ * group means no changes list (e.g. RecentChanges) entries are filtered out.
+ *
+ * @var bool $isFullCoverage
+ */
+ protected $isFullCoverage;
+
+ /**
+ * Array of associative arrays with conflict information. See
+ * setUnidirectionalConflict
+ *
+ * @var array $conflictingGroups
+ */
+ protected $conflictingGroups = [];
+
+ /**
+ * Array of associative arrays with conflict information. See
+ * setUnidirectionalConflict
+ *
+ * @var array $conflictingFilters
+ */
+ protected $conflictingFilters = [];
+
+ const DEFAULT_PRIORITY = -100;
+
+ const RESERVED_NAME_CHAR = '_';
+
+ /**
+ * Create a new filter group with the specified configuration
+ *
+ * @param array $groupDefinition Configuration of group
+ * * $groupDefinition['name'] string Group name; use camelCase with no punctuation
+ * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
+ * only if none of the filters in the group display in the structured UI)
+ * * $groupDefinition['type'] string A type constant from a subclass of this one
+ * * $groupDefinition['priority'] int Priority integer. Higher value means higher
+ * up in the group list (optional, defaults to -100).
+ * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
+ * is an associative array to be passed to the filter constructor. However,
+ * 'priority' is optional for the filters. Any filter that has priority unset
+ * will be put to the bottom, in the order given.
+ * * $groupDefinition['isFullCoverage'] bool Whether the group is full coverage;
+ * if true, this means that checking every item in the group means no
+ * changes list entries are filtered out.
+ * * $groupDefinition['whatsThisHeader'] string i18n key for header of "What's
+ * This" popup (optional).
+ * * $groupDefinition['whatsThisBody'] string i18n key for body of "What's This"
+ * popup (optional).
+ * * $groupDefinition['whatsThisUrl'] string URL for main link of "What's This"
+ * popup (optional).
+ * * $groupDefinition['whatsThisLinkText'] string i18n key of text for main link of
+ * "What's This" popup (optional).
+ */
+ public function __construct( array $groupDefinition ) {
+ if ( strpos( $groupDefinition['name'], self::RESERVED_NAME_CHAR ) !== false ) {
+ throw new MWException( 'Group names may not contain \'' .
+ self::RESERVED_NAME_CHAR .
+ '\'. Use the naming convention: \'camelCase\''
+ );
+ }
+
+ $this->name = $groupDefinition['name'];
+
+ if ( isset( $groupDefinition['title'] ) ) {
+ $this->title = $groupDefinition['title'];
+ }
+
+ if ( isset( $groupDefinition['whatsThisHeader'] ) ) {
+ $this->whatsThisHeader = $groupDefinition['whatsThisHeader'];
+ $this->whatsThisBody = $groupDefinition['whatsThisBody'];
+ $this->whatsThisUrl = $groupDefinition['whatsThisUrl'];
+ $this->whatsThisLinkText = $groupDefinition['whatsThisLinkText'];
+ }
+
+ $this->type = $groupDefinition['type'];
+ if ( isset( $groupDefinition['priority'] ) ) {
+ $this->priority = $groupDefinition['priority'];
+ } else {
+ $this->priority = self::DEFAULT_PRIORITY;
+ }
+
+ $this->isFullCoverage = $groupDefinition['isFullCoverage'];
+
+ $this->filters = [];
+ $lowestSpecifiedPriority = -1;
+ foreach ( $groupDefinition['filters'] as $filterDefinition ) {
+ if ( isset( $filterDefinition['priority'] ) ) {
+ $lowestSpecifiedPriority = min( $lowestSpecifiedPriority, $filterDefinition['priority'] );
+ }
+ }
+
+ // Convenience feature: If you specify a group (and its filters) all in
+ // one place, you don't have to specify priority. You can just put them
+ // in order. However, if you later add one (e.g. an extension adds a filter
+ // to a core-defined group), you need to specify it.
+ $autoFillPriority = $lowestSpecifiedPriority - 1;
+ foreach ( $groupDefinition['filters'] as $filterDefinition ) {
+ if ( !isset( $filterDefinition['priority'] ) ) {
+ $filterDefinition['priority'] = $autoFillPriority;
+ $autoFillPriority--;
+ }
+ $filterDefinition['group'] = $this;
+
+ $filter = $this->createFilter( $filterDefinition );
+ $this->registerFilter( $filter );
+ }
+ }
+
+ /**
+ * Creates a filter of the appropriate type for this group, from the definition
+ *
+ * @param array $filterDefinition Filter definition
+ * @return ChangesListFilter Filter
+ */
+ abstract protected function createFilter( array $filterDefinition );
+
+ /**
+ * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
+ *
+ * WARNING: This means there is a conflict when both things are *shown*
+ * (not filtered out), even for the hide-based filters. So e.g. conflicting with
+ * 'hideanons' means there is a conflict if only anonymous users are *shown*.
+ *
+ * @param ChangesListFilterGroup|ChangesListFilter $other Other
+ * ChangesListFilterGroup or ChangesListFilter
+ * @param string $globalKey i18n key for top-level conflict message
+ * @param string $forwardKey i18n key for conflict message in this
+ * direction (when in UI context of $this object)
+ * @param string $backwardKey i18n key for conflict message in reverse
+ * direction (when in UI context of $other object)
+ */
+ public function conflictsWith( $other, $globalKey, $forwardKey, $backwardKey ) {
+ if ( $globalKey === null || $forwardKey === null || $backwardKey === null ) {
+ throw new MWException( 'All messages must be specified' );
+ }
+
+ $this->setUnidirectionalConflict(
+ $other,
+ $globalKey,
+ $forwardKey
+ );
+
+ $other->setUnidirectionalConflict(
+ $this,
+ $globalKey,
+ $backwardKey
+ );
+ }
+
+ /**
+ * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
+ * this object.
+ *
+ * Internal use ONLY.
+ *
+ * @param ChangesListFilterGroup|ChangesListFilter $other Other
+ * ChangesListFilterGroup or ChangesListFilter
+ * @param string $globalDescription i18n key for top-level conflict message
+ * @param string $contextDescription i18n key for conflict message in this
+ * direction (when in UI context of $this object)
+ */
+ public function setUnidirectionalConflict( $other, $globalDescription, $contextDescription ) {
+ if ( $other instanceof ChangesListFilterGroup ) {
+ $this->conflictingGroups[] = [
+ 'group' => $other->getName(),
+ 'groupObject' => $other,
+ 'globalDescription' => $globalDescription,
+ 'contextDescription' => $contextDescription,
+ ];
+ } elseif ( $other instanceof ChangesListFilter ) {
+ $this->conflictingFilters[] = [
+ 'group' => $other->getGroup()->getName(),
+ 'filter' => $other->getName(),
+ 'filterObject' => $other,
+ 'globalDescription' => $globalDescription,
+ 'contextDescription' => $contextDescription,
+ ];
+ } else {
+ throw new MWException( 'You can only pass in a ChangesListFilterGroup or a ChangesListFilter' );
+ }
+ }
+
+ /**
+ * @return string Internal name
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * @return string i18n key for title
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * @return string Type (TYPE constant from a subclass)
+ */
+ public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * @return int Priority. Higher means higher in the group list
+ */
+ public function getPriority() {
+ return $this->priority;
+ }
+
+ /**
+ * @return ChangesListFilter[] Associative array of ChangesListFilter objects, with
+ * filter name as key
+ */
+ public function getFilters() {
+ return $this->filters;
+ }
+
+ /**
+ * Get filter by name
+ *
+ * @param string $name Filter name
+ * @return ChangesListFilter|null Specified filter, or null if it is not registered
+ */
+ public function getFilter( $name ) {
+ return isset( $this->filters[$name] ) ? $this->filters[$name] : null;
+ }
+
+ /**
+ * Check whether the URL parameter is for the group, or for individual filters.
+ * Defaults can also be defined on the group if and only if this is true.
+ *
+ * @return bool True if and only if the URL parameter is per-group
+ */
+ abstract public function isPerGroupRequestParameter();
+
+ /**
+ * Gets the JS data in the format required by the front-end of the structured UI
+ *
+ * @return array|null Associative array, or null if there are no filters that
+ * display in the structured UI. messageKeys is a special top-level value, with
+ * the value being an array of the message keys to send to the client.
+ */
+ public function getJsData() {
+ $output = [
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'fullCoverage' => $this->isFullCoverage,
+ 'filters' => [],
+ 'priority' => $this->priority,
+ 'conflicts' => [],
+ 'messageKeys' => [ $this->title ]
+ ];
+
+ if ( isset( $this->whatsThisHeader ) ) {
+ $output['whatsThisHeader'] = $this->whatsThisHeader;
+ $output['whatsThisBody'] = $this->whatsThisBody;
+ $output['whatsThisUrl'] = $this->whatsThisUrl;
+ $output['whatsThisLinkText'] = $this->whatsThisLinkText;
+
+ array_push(
+ $output['messageKeys'],
+ $output['whatsThisHeader'],
+ $output['whatsThisBody'],
+ $output['whatsThisLinkText']
+ );
+ }
+
+ usort( $this->filters, function ( $a, $b ) {
+ return $b->getPriority() - $a->getPriority();
+ } );
+
+ foreach ( $this->filters as $filterName => $filter ) {
+ if ( $filter->displaysOnStructuredUi() ) {
+ $filterData = $filter->getJsData();
+ $output['messageKeys'] = array_merge(
+ $output['messageKeys'],
+ $filterData['messageKeys']
+ );
+ unset( $filterData['messageKeys'] );
+ $output['filters'][] = $filterData;
+ }
+ }
+
+ if ( count( $output['filters'] ) === 0 ) {
+ return null;
+ }
+
+ $output['title'] = $this->title;
+
+ $conflicts = array_merge(
+ $this->conflictingGroups,
+ $this->conflictingFilters
+ );
+
+ foreach ( $conflicts as $conflictInfo ) {
+ unset( $conflictInfo['filterObject'] );
+ unset( $conflictInfo['groupObject'] );
+ $output['conflicts'][] = $conflictInfo;
+ array_push(
+ $output['messageKeys'],
+ $conflictInfo['globalDescription'],
+ $conflictInfo['contextDescription']
+ );
+ }
+
+ return $output;
+ }
+
+ /**
+ * Get groups conflicting with this filter group
+ *
+ * @return ChangesListFilterGroup[]
+ */
+ public function getConflictingGroups() {
+ return array_map(
+ function ( $conflictDesc ) {
+ return $conflictDesc[ 'groupObject' ];
+ },
+ $this->conflictingGroups
+ );
+ }
+
+ /**
+ * Get filters conflicting with this filter group
+ *
+ * @return ChangesListFilter[]
+ */
+ public function getConflictingFilters() {
+ return array_map(
+ function ( $conflictDesc ) {
+ return $conflictDesc[ 'filterObject' ];
+ },
+ $this->conflictingFilters
+ );
+ }
+
+ /**
+ * Check if any filter in this group is selected
+ *
+ * @param FormOptions $opts
+ * @return bool
+ */
+ public function anySelected( FormOptions $opts ) {
+ return !!count( array_filter(
+ $this->getFilters(),
+ function ( ChangesListFilter $filter ) use ( $opts ) {
+ return $filter->isSelected( $opts );
+ }
+ ) );
+ }
+}
diff --git a/www/wiki/includes/changes/ChangesListStringOptionsFilter.php b/www/wiki/includes/changes/ChangesListStringOptionsFilter.php
new file mode 100644
index 00000000..930ba128
--- /dev/null
+++ b/www/wiki/includes/changes/ChangesListStringOptionsFilter.php
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * An individual filter in a ChangesListStringOptionsFilterGroup.
+ *
+ * This filter type will only be displayed on the structured UI currently.
+ *
+ * @since 1.29
+ */
+class ChangesListStringOptionsFilter extends ChangesListFilter {
+ /**
+ * @inheritDoc
+ */
+ public function displaysOnUnstructuredUi() {
+ return false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isSelected( FormOptions $opts ) {
+ $option = $opts[ $this->getGroup()->getName() ];
+ if ( $option === ChangesListStringOptionsFilterGroup::ALL ) {
+ return true;
+ }
+
+ $values = explode( ChangesListStringOptionsFilterGroup::SEPARATOR, $option );
+ return in_array( $this->getName(), $values );
+ }
+}
diff --git a/www/wiki/includes/changes/ChangesListStringOptionsFilterGroup.php b/www/wiki/includes/changes/ChangesListStringOptionsFilterGroup.php
new file mode 100644
index 00000000..59efd82b
--- /dev/null
+++ b/www/wiki/includes/changes/ChangesListStringOptionsFilterGroup.php
@@ -0,0 +1,245 @@
+<?php
+/**
+ * Represents a filter group (used on ChangesListSpecialPage and descendants)
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Matthew Flaschen
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Represents a filter group with multiple string options. They are passed to the server as
+ * a single form parameter separated by a delimiter. The parameter name is the
+ * group name. E.g. groupname=opt1;opt2 .
+ *
+ * If all options are selected they are replaced by the term "all".
+ *
+ * There is also a single DB query modification for the whole group.
+ *
+ * @since 1.29
+ */
+
+class ChangesListStringOptionsFilterGroup extends ChangesListFilterGroup {
+ /**
+ * Type marker, used by JavaScript
+ */
+ const TYPE = 'string_options';
+
+ /**
+ * Delimiter
+ */
+ const SEPARATOR = ';';
+
+ /**
+ * Signifies that all options in the group are selected.
+ */
+ const ALL = 'all';
+
+ /**
+ * Signifies that no options in the group are selected, meaning the group has no effect.
+ *
+ * For full-coverage groups, this is the same as ALL if all filters are allowed.
+ * For others, it is not.
+ */
+ const NONE = '';
+
+ /**
+ * Defaul parameter value
+ *
+ * @var string $defaultValue
+ */
+ protected $defaultValue;
+
+ /**
+ * Callable used to do the actual query modification; see constructor
+ *
+ * @var callable $queryCallable
+ */
+ protected $queryCallable;
+
+ /**
+ * Create a new filter group with the specified configuration
+ *
+ * @param array $groupDefinition Configuration of group
+ * * $groupDefinition['name'] string Group name
+ * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
+ * only if none of the filters in the group display in the structured UI)
+ * * $groupDefinition['priority'] int Priority integer. Higher means higher in the
+ * group list.
+ * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
+ * is an associative array to be passed to the filter constructor. However,
+ * 'priority' is optional for the filters. Any filter that has priority unset
+ * will be put to the bottom, in the order given.
+ * * $groupDefinition['default'] string Default for group.
+ * * $groupDefinition['isFullCoverage'] bool Whether the group is full coverage;
+ * if true, this means that checking every item in the group means no
+ * changes list entries are filtered out.
+ * * $groupDefinition['queryCallable'] callable Callable accepting parameters:
+ * * string $specialPageClassName Class name of current special page
+ * * IContextSource $context Context, for e.g. user
+ * * IDatabase $dbr Database, for addQuotes, makeList, and similar
+ * * array &$tables Array of tables; see IDatabase::select $table
+ * * array &$fields Array of fields; see IDatabase::select $vars
+ * * array &$conds Array of conditions; see IDatabase::select $conds
+ * * array &$query_options Array of query options; see IDatabase::select $options
+ * * array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+ * * array $selectedValues The allowed and requested values, lower-cased and sorted
+ * * $groupDefinition['whatsThisHeader'] string i18n key for header of "What's
+ * This" popup (optional).
+ * * $groupDefinition['whatsThisBody'] string i18n key for body of "What's This"
+ * popup (optional).
+ * * $groupDefinition['whatsThisUrl'] string URL for main link of "What's This"
+ * popup (optional).
+ * * $groupDefinition['whatsThisLinkText'] string i18n key of text for main link of
+ * "What's This" popup (optional).
+ */
+ public function __construct( array $groupDefinition ) {
+ if ( !isset( $groupDefinition['isFullCoverage'] ) ) {
+ throw new MWException( 'You must specify isFullCoverage' );
+ }
+
+ $groupDefinition['type'] = self::TYPE;
+
+ parent::__construct( $groupDefinition );
+
+ $this->queryCallable = $groupDefinition['queryCallable'];
+
+ if ( isset( $groupDefinition['default'] ) ) {
+ $this->setDefault( $groupDefinition['default'] );
+ } else {
+ throw new MWException( 'You must specify a default' );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isPerGroupRequestParameter() {
+ return true;
+ }
+
+ /**
+ * Sets default of filter group.
+ *
+ * @param string $defaultValue
+ */
+ public function setDefault( $defaultValue ) {
+ $this->defaultValue = $defaultValue;
+ }
+
+ /**
+ * Gets default of filter group
+ *
+ * @return string $defaultValue
+ */
+ public function getDefault() {
+ return $this->defaultValue;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function createFilter( array $filterDefinition ) {
+ return new ChangesListStringOptionsFilter( $filterDefinition );
+ }
+
+ /**
+ * Registers a filter in this group
+ *
+ * @param ChangesListStringOptionsFilter $filter ChangesListStringOptionsFilter
+ */
+ public function registerFilter( ChangesListStringOptionsFilter $filter ) {
+ $this->filters[$filter->getName()] = $filter;
+ }
+
+ /**
+ * Modifies the query to include the filter group.
+ *
+ * The modification is only done if the filter group is in effect. This means that
+ * one or more valid and allowed filters were selected.
+ *
+ * @param IDatabase $dbr Database, for addQuotes, makeList, and similar
+ * @param ChangesListSpecialPage $specialPage Current special page
+ * @param array &$tables Array of tables; see IDatabase::select $table
+ * @param array &$fields Array of fields; see IDatabase::select $vars
+ * @param array &$conds Array of conditions; see IDatabase::select $conds
+ * @param array &$query_options Array of query options; see IDatabase::select $options
+ * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+ * @param string $value URL parameter value
+ */
+ public function modifyQuery( IDatabase $dbr, ChangesListSpecialPage $specialPage,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, $value
+ ) {
+ $allowedFilterNames = [];
+ foreach ( $this->filters as $filter ) {
+ $allowedFilterNames[] = $filter->getName();
+ }
+
+ if ( $value === self::ALL ) {
+ $selectedValues = $allowedFilterNames;
+ } else {
+ $selectedValues = explode( self::SEPARATOR, strtolower( $value ) );
+
+ // remove values that are not recognized or not currently allowed
+ $selectedValues = array_intersect(
+ $selectedValues,
+ $allowedFilterNames
+ );
+ }
+
+ // If there are now no values, because all are disallowed or invalid (also,
+ // the user may not have selected any), this is a no-op.
+
+ // If everything is unchecked, the group always has no effect, regardless
+ // of full-coverage.
+ if ( count( $selectedValues ) === 0 ) {
+ return;
+ }
+
+ sort( $selectedValues );
+
+ call_user_func_array(
+ $this->queryCallable,
+ [
+ get_class( $specialPage ),
+ $specialPage->getContext(),
+ $dbr,
+ &$tables,
+ &$fields,
+ &$conds,
+ &$query_options,
+ &$join_conds,
+ $selectedValues
+ ]
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getJsData() {
+ $output = parent::getJsData();
+
+ $output['separator'] = self::SEPARATOR;
+ $output['default'] = $this->getDefault();
+
+ return $output;
+ }
+}
diff --git a/www/wiki/includes/changes/EnhancedChangesList.php b/www/wiki/includes/changes/EnhancedChangesList.php
new file mode 100644
index 00000000..0df68281
--- /dev/null
+++ b/www/wiki/includes/changes/EnhancedChangesList.php
@@ -0,0 +1,806 @@
+<?php
+/**
+ * Generates a list of changes using an Enhanced system (uses javascript).
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class EnhancedChangesList extends ChangesList {
+
+ /**
+ * @var RCCacheEntryFactory
+ */
+ protected $cacheEntryFactory;
+
+ /**
+ * @var array Array of array of RCCacheEntry
+ */
+ protected $rc_cache;
+
+ /**
+ * @var TemplateParser
+ */
+ protected $templateParser;
+
+ /**
+ * @param IContextSource|Skin $obj
+ * @param array $filterGroups Array of ChangesListFilterGroup objects (currently optional)
+ * @throws MWException
+ */
+ public function __construct( $obj, array $filterGroups = [] ) {
+ if ( $obj instanceof Skin ) {
+ // @todo: deprecate constructing with Skin
+ $context = $obj->getContext();
+ } else {
+ if ( !$obj instanceof IContextSource ) {
+ throw new MWException( 'EnhancedChangesList must be constructed with a '
+ . 'context source or skin.' );
+ }
+
+ $context = $obj;
+ }
+
+ parent::__construct( $context, $filterGroups );
+
+ // message is set by the parent ChangesList class
+ $this->cacheEntryFactory = new RCCacheEntryFactory(
+ $context,
+ $this->message,
+ $this->linkRenderer
+ );
+ $this->templateParser = new TemplateParser();
+ }
+
+ /**
+ * Add the JavaScript file for enhanced changeslist
+ * @return string
+ */
+ public function beginRecentChangesList() {
+ $this->rc_cache = [];
+ $this->rcMoveIndex = 0;
+ $this->rcCacheIndex = 0;
+ $this->lastdate = '';
+ $this->rclistOpen = false;
+ $this->getOutput()->addModuleStyles( [
+ 'mediawiki.special.changeslist',
+ 'mediawiki.special.changeslist.enhanced',
+ ] );
+ $this->getOutput()->addModules( [
+ 'jquery.makeCollapsible',
+ 'mediawiki.icon',
+ ] );
+
+ return '<div class="mw-changeslist">';
+ }
+
+ /**
+ * Format a line for enhanced recentchange (aka with javascript and block of lines).
+ *
+ * @param RecentChange &$rc
+ * @param bool $watched
+ * @param int $linenumber (default null)
+ *
+ * @return string
+ */
+ public function recentChangesLine( &$rc, $watched = false, $linenumber = null ) {
+ $date = $this->getLanguage()->userDate(
+ $rc->mAttribs['rc_timestamp'],
+ $this->getUser()
+ );
+ if ( $this->lastdate === '' ) {
+ $this->lastdate = $date;
+ }
+
+ $ret = '';
+
+ # If it's a new day, flush the cache and update $this->lastdate
+ if ( $date !== $this->lastdate ) {
+ # Process current cache (uses $this->lastdate to generate a heading)
+ $ret = $this->recentChangesBlock();
+ $this->rc_cache = [];
+ $this->lastdate = $date;
+ }
+
+ $cacheEntry = $this->cacheEntryFactory->newFromRecentChange( $rc, $watched );
+ $this->addCacheEntry( $cacheEntry );
+
+ return $ret;
+ }
+
+ /**
+ * Put accumulated information into the cache, for later display.
+ * Page moves go on their own line.
+ *
+ * @param RCCacheEntry $cacheEntry
+ */
+ protected function addCacheEntry( RCCacheEntry $cacheEntry ) {
+ $cacheGroupingKey = $this->makeCacheGroupingKey( $cacheEntry );
+
+ if ( !isset( $this->rc_cache[$cacheGroupingKey] ) ) {
+ $this->rc_cache[$cacheGroupingKey] = [];
+ }
+
+ array_push( $this->rc_cache[$cacheGroupingKey], $cacheEntry );
+ }
+
+ /**
+ * @todo use rc_source to group, if set; fallback to rc_type
+ *
+ * @param RCCacheEntry $cacheEntry
+ *
+ * @return string
+ */
+ protected function makeCacheGroupingKey( RCCacheEntry $cacheEntry ) {
+ $title = $cacheEntry->getTitle();
+ $cacheGroupingKey = $title->getPrefixedDBkey();
+
+ $type = $cacheEntry->mAttribs['rc_type'];
+
+ if ( $type == RC_LOG ) {
+ // Group by log type
+ $cacheGroupingKey = SpecialPage::getTitleFor(
+ 'Log',
+ $cacheEntry->mAttribs['rc_log_type']
+ )->getPrefixedDBkey();
+ }
+
+ return $cacheGroupingKey;
+ }
+
+ /**
+ * Enhanced RC group
+ * @param RCCacheEntry[] $block
+ * @return string
+ * @throws DomainException
+ */
+ protected function recentChangesBlockGroup( $block ) {
+ $recentChangesFlags = $this->getConfig()->get( 'RecentChangesFlags' );
+
+ # Add the namespace and title of the block as part of the class
+ $tableClasses = [ 'mw-collapsible', 'mw-collapsed', 'mw-enhanced-rc', 'mw-changeslist-line' ];
+ if ( $block[0]->mAttribs['rc_log_type'] ) {
+ # Log entry
+ $tableClasses[] = 'mw-changeslist-log';
+ $tableClasses[] = Sanitizer::escapeClass( 'mw-changeslist-log-'
+ . $block[0]->mAttribs['rc_log_type'] );
+ } else {
+ $tableClasses[] = 'mw-changeslist-edit';
+ $tableClasses[] = Sanitizer::escapeClass( 'mw-changeslist-ns'
+ . $block[0]->mAttribs['rc_namespace'] . '-' . $block[0]->mAttribs['rc_title'] );
+ }
+ if ( $block[0]->watched
+ && $block[0]->mAttribs['rc_timestamp'] >= $block[0]->watched
+ ) {
+ $tableClasses[] = 'mw-changeslist-line-watched';
+ } else {
+ $tableClasses[] = 'mw-changeslist-line-not-watched';
+ }
+
+ # Collate list of users
+ $userlinks = [];
+ # Other properties
+ $curId = 0;
+ # Some catalyst variables...
+ $namehidden = true;
+ $allLogs = true;
+ $RCShowChangedSize = $this->getConfig()->get( 'RCShowChangedSize' );
+
+ # Default values for RC flags
+ $collectedRcFlags = [];
+ foreach ( $recentChangesFlags as $key => $value ) {
+ $flagGrouping = ( isset( $recentChangesFlags[$key]['grouping'] ) ?
+ $recentChangesFlags[$key]['grouping'] : 'any' );
+ switch ( $flagGrouping ) {
+ case 'all':
+ $collectedRcFlags[$key] = true;
+ break;
+ case 'any':
+ $collectedRcFlags[$key] = false;
+ break;
+ default:
+ throw new DomainException( "Unknown grouping type \"{$flagGrouping}\"" );
+ }
+ }
+ foreach ( $block as $rcObj ) {
+ // If all log actions to this page were hidden, then don't
+ // give the name of the affected page for this block!
+ if ( !$this->isDeleted( $rcObj, LogPage::DELETED_ACTION ) ) {
+ $namehidden = false;
+ }
+ $u = $rcObj->userlink;
+ if ( !isset( $userlinks[$u] ) ) {
+ $userlinks[$u] = 0;
+ }
+ if ( $rcObj->mAttribs['rc_type'] != RC_LOG ) {
+ $allLogs = false;
+ }
+ # Get the latest entry with a page_id and oldid
+ # since logs may not have these.
+ if ( !$curId && $rcObj->mAttribs['rc_cur_id'] ) {
+ $curId = $rcObj->mAttribs['rc_cur_id'];
+ }
+
+ $userlinks[$u]++;
+ }
+
+ # Sort the list and convert to text
+ krsort( $userlinks );
+ asort( $userlinks );
+ $users = [];
+ foreach ( $userlinks as $userlink => $count ) {
+ $text = $userlink;
+ $text .= $this->getLanguage()->getDirMark();
+ if ( $count > 1 ) {
+ $formattedCount = $this->msg( 'ntimes' )->numParams( $count )->escaped();
+ $text .= ' ' . $this->msg( 'parentheses' )->rawParams( $formattedCount )->escaped();
+ }
+ array_push( $users, $text );
+ }
+
+ # Article link
+ $articleLink = '';
+ $revDeletedMsg = false;
+ if ( $namehidden ) {
+ $revDeletedMsg = $this->msg( 'rev-deleted-event' )->escaped();
+ } elseif ( $allLogs ) {
+ $articleLink = $this->maybeWatchedLink( $block[0]->link, $block[0]->watched );
+ } else {
+ $articleLink = $this->getArticleLink( $block[0], $block[0]->unpatrolled, $block[0]->watched );
+ }
+
+ $queryParams['curid'] = $curId;
+
+ # Sub-entries
+ $lines = [];
+ foreach ( $block as $i => $rcObj ) {
+ $line = $this->getLineData( $block, $rcObj, $queryParams );
+ if ( !$line ) {
+ // completely ignore this RC entry if we don't want to render it
+ unset( $block[$i] );
+ continue;
+ }
+
+ // Roll up flags
+ foreach ( $line['recentChangesFlagsRaw'] as $key => $value ) {
+ $flagGrouping = ( isset( $recentChangesFlags[$key]['grouping'] ) ?
+ $recentChangesFlags[$key]['grouping'] : 'any' );
+ switch ( $flagGrouping ) {
+ case 'all':
+ if ( !$value ) {
+ $collectedRcFlags[$key] = false;
+ }
+ break;
+ case 'any':
+ if ( $value ) {
+ $collectedRcFlags[$key] = true;
+ }
+ break;
+ default:
+ throw new DomainException( "Unknown grouping type \"{$flagGrouping}\"" );
+ }
+ }
+
+ $lines[] = $line;
+ }
+
+ // Further down are some assumptions that $block is a 0-indexed array
+ // with (count-1) as last key. Let's make sure it is.
+ $block = array_values( $block );
+
+ if ( empty( $block ) || !$lines ) {
+ // if we can't show anything, don't display this block altogether
+ return '';
+ }
+
+ $logText = $this->getLogText( $block, $queryParams, $allLogs,
+ $collectedRcFlags['newpage'], $namehidden
+ );
+
+ # Character difference (does not apply if only log items)
+ $charDifference = false;
+ if ( $RCShowChangedSize && !$allLogs ) {
+ $last = 0;
+ $first = count( $block ) - 1;
+ # Some events (like logs and category changes) have an "empty" size, so we need to skip those...
+ while ( $last < $first && $block[$last]->mAttribs['rc_new_len'] === null ) {
+ $last++;
+ }
+ while ( $last < $first && $block[$first]->mAttribs['rc_old_len'] === null ) {
+ $first--;
+ }
+ # Get net change
+ $charDifference = $this->formatCharacterDifference( $block[$first], $block[$last] );
+ }
+
+ $numberofWatchingusers = $this->numberofWatchingusers( $block[0]->numberofWatchingusers );
+ $usersList = $this->msg( 'brackets' )->rawParams(
+ implode( $this->message['semicolon-separator'], $users )
+ )->escaped();
+
+ $prefix = '';
+ if ( is_callable( $this->changeLinePrefixer ) ) {
+ $prefix = call_user_func( $this->changeLinePrefixer, $block[0], $this, true );
+ }
+
+ $templateParams = [
+ 'articleLink' => $articleLink,
+ 'charDifference' => $charDifference,
+ 'collectedRcFlags' => $this->recentChangesFlags( $collectedRcFlags ),
+ 'languageDirMark' => $this->getLanguage()->getDirMark(),
+ 'lines' => $lines,
+ 'logText' => $logText,
+ 'numberofWatchingusers' => $numberofWatchingusers,
+ 'prefix' => $prefix,
+ 'rev-deleted-event' => $revDeletedMsg,
+ 'tableClasses' => $tableClasses,
+ 'timestamp' => $block[0]->timestamp,
+ 'fullTimestamp' => $block[0]->getAttribute( 'rc_timestamp' ),
+ 'users' => $usersList,
+ ];
+
+ $this->rcCacheIndex++;
+
+ return $this->templateParser->processTemplate(
+ 'EnhancedChangesListGroup',
+ $templateParams
+ );
+ }
+
+ /**
+ * @param RCCacheEntry[] $block
+ * @param RCCacheEntry $rcObj
+ * @param array $queryParams
+ * @return array
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ */
+ protected function getLineData( array $block, RCCacheEntry $rcObj, array $queryParams = [] ) {
+ $RCShowChangedSize = $this->getConfig()->get( 'RCShowChangedSize' );
+
+ $type = $rcObj->mAttribs['rc_type'];
+ $data = [];
+ $lineParams = [ 'targetTitle' => $rcObj->getTitle() ];
+
+ $classes = [ 'mw-enhanced-rc' ];
+ if ( $rcObj->watched
+ && $rcObj->mAttribs['rc_timestamp'] >= $rcObj->watched
+ ) {
+ $classes[] = 'mw-enhanced-watched';
+ }
+ $classes = array_merge( $classes, $this->getHTMLClasses( $rcObj, $rcObj->watched ) );
+
+ $separator = ' <span class="mw-changeslist-separator">. .</span> ';
+
+ $data['recentChangesFlags'] = [
+ 'newpage' => $type == RC_NEW,
+ 'minor' => $rcObj->mAttribs['rc_minor'],
+ 'unpatrolled' => $rcObj->unpatrolled,
+ 'bot' => $rcObj->mAttribs['rc_bot'],
+ ];
+
+ $params = $queryParams;
+
+ if ( $rcObj->mAttribs['rc_this_oldid'] != 0 ) {
+ $params['oldid'] = $rcObj->mAttribs['rc_this_oldid'];
+ }
+
+ # Log timestamp
+ if ( $type == RC_LOG ) {
+ $link = $rcObj->timestamp;
+ # Revision link
+ } elseif ( !ChangesList::userCan( $rcObj, Revision::DELETED_TEXT, $this->getUser() ) ) {
+ $link = '<span class="history-deleted">' . $rcObj->timestamp . '</span> ';
+ } else {
+ $link = $this->linkRenderer->makeKnownLink(
+ $rcObj->getTitle(),
+ new HtmlArmor( $rcObj->timestamp ),
+ [],
+ $params
+ );
+ if ( $this->isDeleted( $rcObj, Revision::DELETED_TEXT ) ) {
+ $link = '<span class="history-deleted">' . $link . '</span> ';
+ }
+ }
+ $data['timestampLink'] = $link;
+
+ $currentAndLastLinks = '';
+ if ( !$type == RC_LOG || $type == RC_NEW ) {
+ $currentAndLastLinks .= ' ' . $this->msg( 'parentheses' )->rawParams(
+ $rcObj->curlink .
+ $this->message['pipe-separator'] .
+ $rcObj->lastlink
+ )->escaped();
+ }
+ $data['currentAndLastLinks'] = $currentAndLastLinks;
+ $data['separatorAfterCurrentAndLastLinks'] = $separator;
+
+ # Character diff
+ if ( $RCShowChangedSize ) {
+ $cd = $this->formatCharacterDifference( $rcObj );
+ if ( $cd !== '' ) {
+ $data['characterDiff'] = $cd;
+ $data['separatorAfterCharacterDiff'] = $separator;
+ }
+ }
+
+ if ( $rcObj->mAttribs['rc_type'] == RC_LOG ) {
+ $data['logEntry'] = $this->insertLogEntry( $rcObj );
+ } elseif ( $this->isCategorizationWithoutRevision( $rcObj ) ) {
+ $data['comment'] = $this->insertComment( $rcObj );
+ } else {
+ # User links
+ $data['userLink'] = $rcObj->userlink;
+ $data['userTalkLink'] = $rcObj->usertalklink;
+ $data['comment'] = $this->insertComment( $rcObj );
+ }
+
+ # Rollback
+ $data['rollback'] = $this->getRollback( $rcObj );
+
+ # Tags
+ $data['tags'] = $this->getTags( $rcObj, $classes );
+
+ $attribs = $this->getDataAttributes( $rcObj );
+
+ // give the hook a chance to modify the data
+ $success = Hooks::run( 'EnhancedChangesListModifyLineData',
+ [ $this, &$data, $block, $rcObj, &$classes, &$attribs ] );
+ if ( !$success ) {
+ // skip entry if hook aborted it
+ return [];
+ }
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+
+ $lineParams['recentChangesFlagsRaw'] = [];
+ if ( isset( $data['recentChangesFlags'] ) ) {
+ $lineParams['recentChangesFlags'] = $this->recentChangesFlags( $data['recentChangesFlags'] );
+ # FIXME: This is used by logic, don't return it in the template params.
+ $lineParams['recentChangesFlagsRaw'] = $data['recentChangesFlags'];
+ unset( $data['recentChangesFlags'] );
+ }
+
+ if ( isset( $data['timestampLink'] ) ) {
+ $lineParams['timestampLink'] = $data['timestampLink'];
+ unset( $data['timestampLink'] );
+ }
+
+ $lineParams['classes'] = array_values( $classes );
+ $lineParams['attribs'] = Html::expandAttributes( $attribs );
+
+ // everything else: makes it easier for extensions to add or remove data
+ $lineParams['data'] = array_values( $data );
+
+ return $lineParams;
+ }
+
+ /**
+ * Generates amount of changes (linking to diff ) & link to history.
+ *
+ * @param array $block
+ * @param array $queryParams
+ * @param bool $allLogs
+ * @param bool $isnew
+ * @param bool $namehidden
+ * @return string
+ */
+ protected function getLogText( $block, $queryParams, $allLogs, $isnew, $namehidden ) {
+ if ( empty( $block ) ) {
+ return '';
+ }
+
+ # Changes message
+ static $nchanges = [];
+ static $sinceLastVisitMsg = [];
+
+ $n = count( $block );
+ if ( !isset( $nchanges[$n] ) ) {
+ $nchanges[$n] = $this->msg( 'nchanges' )->numParams( $n )->escaped();
+ }
+
+ $sinceLast = 0;
+ $unvisitedOldid = null;
+ /** @var RCCacheEntry $rcObj */
+ foreach ( $block as $rcObj ) {
+ // Same logic as below inside main foreach
+ if ( $rcObj->watched && $rcObj->mAttribs['rc_timestamp'] >= $rcObj->watched ) {
+ $sinceLast++;
+ $unvisitedOldid = $rcObj->mAttribs['rc_last_oldid'];
+ }
+ }
+ if ( !isset( $sinceLastVisitMsg[$sinceLast] ) ) {
+ $sinceLastVisitMsg[$sinceLast] =
+ $this->msg( 'enhancedrc-since-last-visit' )->numParams( $sinceLast )->escaped();
+ }
+
+ $currentRevision = 0;
+ foreach ( $block as $rcObj ) {
+ if ( !$currentRevision ) {
+ $currentRevision = $rcObj->mAttribs['rc_this_oldid'];
+ }
+ }
+
+ # Total change link
+ $links = [];
+ /** @var RecentChange $block0 */
+ $block0 = $block[0];
+ $last = $block[count( $block ) - 1];
+ if ( !$allLogs ) {
+ if ( !ChangesList::userCan( $rcObj, Revision::DELETED_TEXT, $this->getUser() ) ||
+ $isnew ||
+ $rcObj->mAttribs['rc_type'] == RC_CATEGORIZE
+ ) {
+ $links['total-changes'] = $nchanges[$n];
+ } else {
+ $links['total-changes'] = $this->linkRenderer->makeKnownLink(
+ $block0->getTitle(),
+ new HtmlArmor( $nchanges[$n] ),
+ [ 'class' => 'mw-changeslist-groupdiff' ],
+ $queryParams + [
+ 'diff' => $currentRevision,
+ 'oldid' => $last->mAttribs['rc_last_oldid'],
+ ]
+ );
+ if ( $sinceLast > 0 && $sinceLast < $n ) {
+ $links['total-changes-since-last'] = $this->linkRenderer->makeKnownLink(
+ $block0->getTitle(),
+ new HtmlArmor( $sinceLastVisitMsg[$sinceLast] ),
+ [ 'class' => 'mw-changeslist-groupdiff' ],
+ $queryParams + [
+ 'diff' => $currentRevision,
+ 'oldid' => $unvisitedOldid,
+ ]
+ );
+ }
+ }
+ }
+
+ # History
+ if ( $allLogs || $rcObj->mAttribs['rc_type'] == RC_CATEGORIZE ) {
+ // don't show history link for logs
+ } elseif ( $namehidden || !$block0->getTitle()->exists() ) {
+ $links['history'] = $this->message['enhancedrc-history'];
+ } else {
+ $params = $queryParams;
+ $params['action'] = 'history';
+
+ $links['history'] = $this->linkRenderer->makeKnownLink(
+ $block0->getTitle(),
+ new HtmlArmor( $this->message['enhancedrc-history'] ),
+ [ 'class' => 'mw-changeslist-history' ],
+ $params
+ );
+ }
+
+ # Allow others to alter, remove or add to these links
+ Hooks::run( 'EnhancedChangesList::getLogText',
+ [ $this, &$links, $block ] );
+
+ if ( !$links ) {
+ return '';
+ }
+
+ $logtext = implode( $this->message['pipe-separator'], $links );
+ $logtext = $this->msg( 'parentheses' )->rawParams( $logtext )->escaped();
+ return ' ' . $logtext;
+ }
+
+ /**
+ * Enhanced RC ungrouped line.
+ *
+ * @param RecentChange|RCCacheEntry $rcObj
+ * @return string A HTML formatted line (generated using $r)
+ */
+ protected function recentChangesBlockLine( $rcObj ) {
+ $data = [];
+
+ $query['curid'] = $rcObj->mAttribs['rc_cur_id'];
+
+ $type = $rcObj->mAttribs['rc_type'];
+ $logType = $rcObj->mAttribs['rc_log_type'];
+ $classes = $this->getHTMLClasses( $rcObj, $rcObj->watched );
+ $classes[] = 'mw-enhanced-rc';
+
+ if ( $logType ) {
+ # Log entry
+ $classes[] = 'mw-changeslist-log';
+ $classes[] = Sanitizer::escapeClass( 'mw-changeslist-log-' . $logType );
+ } else {
+ $classes[] = 'mw-changeslist-edit';
+ $classes[] = Sanitizer::escapeClass( 'mw-changeslist-ns' .
+ $rcObj->mAttribs['rc_namespace'] . '-' . $rcObj->mAttribs['rc_title'] );
+ }
+
+ # Flag and Timestamp
+ $data['recentChangesFlags'] = [
+ 'newpage' => $type == RC_NEW,
+ 'minor' => $rcObj->mAttribs['rc_minor'],
+ 'unpatrolled' => $rcObj->unpatrolled,
+ 'bot' => $rcObj->mAttribs['rc_bot'],
+ ];
+ // timestamp is not really a link here, but is called timestampLink
+ // for consistency with EnhancedChangesListModifyLineData
+ $data['timestampLink'] = $rcObj->timestamp;
+
+ # Article or log link
+ if ( $logType ) {
+ $logPage = new LogPage( $logType );
+ $logTitle = SpecialPage::getTitleFor( 'Log', $logType );
+ $logName = $logPage->getName()->text();
+ $data['logLink'] = $this->msg( 'parentheses' )
+ ->rawParams(
+ $this->linkRenderer->makeKnownLink( $logTitle, $logName )
+ )->escaped();
+ } else {
+ $data['articleLink'] = $this->getArticleLink( $rcObj, $rcObj->unpatrolled, $rcObj->watched );
+ }
+
+ # Diff and hist links
+ if ( $type != RC_LOG && $type != RC_CATEGORIZE ) {
+ $query['action'] = 'history';
+ $data['historyLink'] = $this->getDiffHistLinks( $rcObj, $query );
+ }
+ $data['separatorAfterLinks'] = ' <span class="mw-changeslist-separator">. .</span> ';
+
+ # Character diff
+ if ( $this->getConfig()->get( 'RCShowChangedSize' ) ) {
+ $cd = $this->formatCharacterDifference( $rcObj );
+ if ( $cd !== '' ) {
+ $data['characterDiff'] = $cd;
+ $data['separatorAftercharacterDiff'] = ' <span class="mw-changeslist-separator">. .</span> ';
+ }
+ }
+
+ if ( $type == RC_LOG ) {
+ $data['logEntry'] = $this->insertLogEntry( $rcObj );
+ } elseif ( $this->isCategorizationWithoutRevision( $rcObj ) ) {
+ $data['comment'] = $this->insertComment( $rcObj );
+ } else {
+ $data['userLink'] = $rcObj->userlink;
+ $data['userTalkLink'] = $rcObj->usertalklink;
+ $data['comment'] = $this->insertComment( $rcObj );
+ if ( $type == RC_CATEGORIZE ) {
+ $data['historyLink'] = $this->getDiffHistLinks( $rcObj, $query );
+ }
+ $data['rollback'] = $this->getRollback( $rcObj );
+ }
+
+ # Tags
+ $data['tags'] = $this->getTags( $rcObj, $classes );
+
+ # Show how many people are watching this if enabled
+ $data['watchingUsers'] = $this->numberofWatchingusers( $rcObj->numberofWatchingusers );
+
+ $data['attribs'] = array_merge( $this->getDataAttributes( $rcObj ), [ 'class' => $classes ] );
+
+ // give the hook a chance to modify the data
+ $success = Hooks::run( 'EnhancedChangesListModifyBlockLineData',
+ [ $this, &$data, $rcObj ] );
+ if ( !$success ) {
+ // skip entry if hook aborted it
+ return '';
+ }
+ $attribs = $data['attribs'];
+ unset( $data['attribs'] );
+ $attribs = wfArrayFilterByKey( $attribs, function ( $key ) {
+ return $key === 'class' || Sanitizer::isReservedDataAttribute( $key );
+ } );
+
+ $prefix = '';
+ if ( is_callable( $this->changeLinePrefixer ) ) {
+ $prefix = call_user_func( $this->changeLinePrefixer, $rcObj, $this, false );
+ }
+
+ $line = Html::openElement( 'table', $attribs ) . Html::openElement( 'tr' );
+ $line .= Html::rawElement( 'td', [], '<span class="mw-enhancedchanges-arrow-space"></span>' );
+ $line .= Html::rawElement( 'td', [ 'class' => 'mw-changeslist-line-prefix' ], $prefix );
+ $line .= '<td class="mw-enhanced-rc">';
+
+ if ( isset( $data['recentChangesFlags'] ) ) {
+ $line .= $this->recentChangesFlags( $data['recentChangesFlags'] );
+ unset( $data['recentChangesFlags'] );
+ }
+
+ if ( isset( $data['timestampLink'] ) ) {
+ $line .= '&#160;' . $data['timestampLink'];
+ unset( $data['timestampLink'] );
+ }
+ $line .= '&#160;</td>';
+ $line .= Html::openElement( 'td', [
+ 'class' => 'mw-changeslist-line-inner',
+ // Used for reliable determination of the affiliated page
+ 'data-target-page' => $rcObj->getTitle(),
+ ] );
+
+ // everything else: makes it easier for extensions to add or remove data
+ $line .= implode( '', $data );
+
+ $line .= "</td></tr></table>\n";
+
+ return $line;
+ }
+
+ /**
+ * Returns value to be used in 'historyLink' element of $data param in
+ * EnhancedChangesListModifyBlockLineData hook.
+ *
+ * @since 1.27
+ *
+ * @param RCCacheEntry $rc
+ * @param array $query array of key/value pairs to append as a query string
+ * @return string HTML
+ */
+ public function getDiffHistLinks( RCCacheEntry $rc, array $query ) {
+ $pageTitle = $rc->getTitle();
+ if ( $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE ) {
+ // For categorizations we must swap the category title with the page title!
+ $pageTitle = Title::newFromID( $rc->getAttribute( 'rc_cur_id' ) );
+ if ( !$pageTitle ) {
+ // The page has been deleted, but the RC entry
+ // deletion job has not run yet. Just skip.
+ return '';
+ }
+ }
+
+ $retVal = ' ' . $this->msg( 'parentheses' )
+ ->rawParams( $rc->difflink . $this->message['pipe-separator']
+ . $this->linkRenderer->makeKnownLink(
+ $pageTitle,
+ new HtmlArmor( $this->message['hist'] ),
+ [ 'class' => 'mw-changeslist-history' ],
+ $query
+ ) )->escaped();
+ return $retVal;
+ }
+
+ /**
+ * If enhanced RC is in use, this function takes the previously cached
+ * RC lines, arranges them, and outputs the HTML
+ *
+ * @return string
+ */
+ protected function recentChangesBlock() {
+ if ( count( $this->rc_cache ) == 0 ) {
+ return '';
+ }
+
+ $blockOut = '';
+ foreach ( $this->rc_cache as $block ) {
+ if ( count( $block ) < 2 ) {
+ $blockOut .= $this->recentChangesBlockLine( array_shift( $block ) );
+ } else {
+ $blockOut .= $this->recentChangesBlockGroup( $block );
+ }
+ }
+
+ if ( $blockOut === '' ) {
+ return '';
+ }
+ // $this->lastdate is kept up to date by recentChangesLine()
+ return Xml::element( 'h4', null, $this->lastdate ) . "\n<div>" . $blockOut . '</div>';
+ }
+
+ /**
+ * Returns text for the end of RC
+ * If enhanced RC is in use, returns pretty much all the text
+ * @return string
+ */
+ public function endRecentChangesList() {
+ return $this->recentChangesBlock() . '</div>';
+ }
+}
diff --git a/www/wiki/includes/changes/OldChangesList.php b/www/wiki/includes/changes/OldChangesList.php
new file mode 100644
index 00000000..88c3c226
--- /dev/null
+++ b/www/wiki/includes/changes/OldChangesList.php
@@ -0,0 +1,156 @@
+<?php
+/**
+ * Generate a list of changes using the good old system (no javascript).
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class OldChangesList extends ChangesList {
+
+ /**
+ * Format a line using the old system (aka without any javascript).
+ *
+ * @param RecentChange &$rc Passed by reference
+ * @param bool $watched (default false)
+ * @param int $linenumber (default null)
+ *
+ * @return string|bool
+ */
+ public function recentChangesLine( &$rc, $watched = false, $linenumber = null ) {
+ $classes = $this->getHTMLClasses( $rc, $watched );
+ // use mw-line-even/mw-line-odd class only if linenumber is given (feature from T16468)
+ if ( $linenumber ) {
+ if ( $linenumber & 1 ) {
+ $classes[] = 'mw-line-odd';
+ } else {
+ $classes[] = 'mw-line-even';
+ }
+ }
+
+ $html = $this->formatChangeLine( $rc, $classes, $watched );
+
+ if ( $this->watchlist ) {
+ $classes[] = Sanitizer::escapeClass( 'watchlist-' .
+ $rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] );
+ }
+
+ $attribs = $this->getDataAttributes( $rc );
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $list = $this;
+ if ( !Hooks::run( 'OldChangesListRecentChangesLine',
+ [ &$list, &$html, $rc, &$classes, &$attribs ] )
+ ) {
+ return false;
+ }
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+
+ $dateheader = ''; // $html now contains only <li>...</li>, for hooks' convenience.
+ $this->insertDateHeader( $dateheader, $rc->mAttribs['rc_timestamp'] );
+
+ $attribs['class'] = implode( ' ', $classes );
+
+ return $dateheader . Html::rawElement( 'li', $attribs, $html ) . "\n";
+ }
+
+ /**
+ * @param RecentChange $rc
+ * @param string[] &$classes
+ * @param bool $watched
+ *
+ * @return string
+ */
+ private function formatChangeLine( RecentChange $rc, array &$classes, $watched ) {
+ $html = '';
+ $unpatrolled = $this->showAsUnpatrolled( $rc );
+
+ if ( $rc->mAttribs['rc_log_type'] ) {
+ $logtitle = SpecialPage::getTitleFor( 'Log', $rc->mAttribs['rc_log_type'] );
+ $this->insertLog( $html, $logtitle, $rc->mAttribs['rc_log_type'] );
+ $flags = $this->recentChangesFlags( [ 'unpatrolled' => $unpatrolled,
+ 'bot' => $rc->mAttribs['rc_bot'] ], '' );
+ if ( $flags !== '' ) {
+ $html .= ' ' . $flags;
+ }
+ // Log entries (old format) or log targets, and special pages
+ } elseif ( $rc->mAttribs['rc_namespace'] == NS_SPECIAL ) {
+ list( $name, $htmlubpage ) = SpecialPageFactory::resolveAlias( $rc->mAttribs['rc_title'] );
+ if ( $name == 'Log' ) {
+ $this->insertLog( $html, $rc->getTitle(), $htmlubpage );
+ }
+ // Regular entries
+ } else {
+ $this->insertDiffHist( $html, $rc );
+ # M, N, b and ! (minor, new, bot and unpatrolled)
+ $html .= $this->recentChangesFlags(
+ [
+ 'newpage' => $rc->mAttribs['rc_type'] == RC_NEW,
+ 'minor' => $rc->mAttribs['rc_minor'],
+ 'unpatrolled' => $unpatrolled,
+ 'bot' => $rc->mAttribs['rc_bot']
+ ],
+ ''
+ );
+ $html .= $this->getArticleLink( $rc, $unpatrolled, $watched );
+ }
+ # Edit/log timestamp
+ $this->insertTimestamp( $html, $rc );
+ # Bytes added or removed
+ if ( $this->getConfig()->get( 'RCShowChangedSize' ) ) {
+ $cd = $this->formatCharacterDifference( $rc );
+ if ( $cd !== '' ) {
+ $html .= $cd . ' <span class="mw-changeslist-separator">. .</span> ';
+ }
+ }
+
+ if ( $rc->mAttribs['rc_type'] == RC_LOG ) {
+ $html .= $this->insertLogEntry( $rc );
+ } elseif ( $this->isCategorizationWithoutRevision( $rc ) ) {
+ $html .= $this->insertComment( $rc );
+ } else {
+ # User tool links
+ $this->insertUserRelatedLinks( $html, $rc );
+ # LTR/RTL direction mark
+ $html .= $this->getLanguage()->getDirMark();
+ $html .= $this->insertComment( $rc );
+ }
+
+ # Tags
+ $this->insertTags( $html, $rc, $classes );
+ # Rollback
+ $this->insertRollback( $html, $rc );
+ # For subclasses
+ $this->insertExtra( $html, $rc, $classes );
+
+ # How many users watch this page
+ if ( $rc->numberofWatchingusers > 0 ) {
+ $html .= ' ' . $this->numberofWatchingusers( $rc->numberofWatchingusers );
+ }
+
+ $html = Html::rawElement( 'span', [
+ 'class' => 'mw-changeslist-line-inner',
+ 'data-target-page' => $rc->getTitle(), // Used for reliable determination of the affiliated page
+ ], $html );
+ if ( is_callable( $this->changeLinePrefixer ) ) {
+ $prefix = call_user_func( $this->changeLinePrefixer, $rc, $this, false );
+ $html = Html::rawElement( 'span', [ 'class' => 'mw-changeslist-line-prefix' ], $prefix ) . $html;
+ }
+
+ return $html;
+ }
+}
diff --git a/www/wiki/includes/changes/RCCacheEntry.php b/www/wiki/includes/changes/RCCacheEntry.php
new file mode 100644
index 00000000..9f85aa14
--- /dev/null
+++ b/www/wiki/includes/changes/RCCacheEntry.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class RCCacheEntry extends RecentChange {
+ public $curlink;
+ public $difflink;
+ public $lastlink;
+ public $link;
+ public $timestamp;
+ public $unpatrolled;
+ public $userlink;
+ public $usertalklink;
+ public $watched;
+ public $mAttribs;
+ public $mExtra;
+
+ /**
+ * @param RecentChange $rc
+ * @return RCCacheEntry
+ */
+ static function newFromParent( $rc ) {
+ $rc2 = new RCCacheEntry;
+ $rc2->mAttribs = $rc->mAttribs;
+ $rc2->mExtra = $rc->mExtra;
+
+ return $rc2;
+ }
+}
diff --git a/www/wiki/includes/changes/RCCacheEntryFactory.php b/www/wiki/includes/changes/RCCacheEntryFactory.php
new file mode 100644
index 00000000..8ce21f5f
--- /dev/null
+++ b/www/wiki/includes/changes/RCCacheEntryFactory.php
@@ -0,0 +1,298 @@
+<?php
+/**
+ * Creates a RCCacheEntry from a RecentChange to use in EnhancedChangesList
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\Linker\LinkRenderer;
+
+class RCCacheEntryFactory {
+
+ /* @var IContextSource */
+ private $context;
+
+ /* @var string[] */
+ private $messages;
+
+ /**
+ * @var LinkRenderer
+ */
+ private $linkRenderer;
+
+ /**
+ * @param IContextSource $context
+ * @param string[] $messages
+ * @param LinkRenderer $linkRenderer
+ */
+ public function __construct(
+ IContextSource $context, $messages, LinkRenderer $linkRenderer
+ ) {
+ $this->context = $context;
+ $this->messages = $messages;
+ $this->linkRenderer = $linkRenderer;
+ }
+
+ /**
+ * @param RecentChange $baseRC
+ * @param bool $watched
+ *
+ * @return RCCacheEntry
+ */
+ public function newFromRecentChange( RecentChange $baseRC, $watched ) {
+ $user = $this->context->getUser();
+ $counter = $baseRC->counter;
+
+ $cacheEntry = RCCacheEntry::newFromParent( $baseRC );
+
+ // Should patrol-related stuff be shown?
+ $cacheEntry->unpatrolled = ChangesList::isUnpatrolled( $baseRC, $user );
+
+ $cacheEntry->watched = $cacheEntry->mAttribs['rc_type'] == RC_LOG ? false : $watched;
+ $cacheEntry->numberofWatchingusers = $baseRC->numberofWatchingusers;
+
+ $cacheEntry->link = $this->buildCLink( $cacheEntry );
+ $cacheEntry->timestamp = $this->buildTimestamp( $cacheEntry );
+
+ // Make "cur" and "diff" links. Do not use link(), it is too slow if
+ // called too many times (50% of CPU time on RecentChanges!).
+ $showDiffLinks = $this->showDiffLinks( $cacheEntry, $user );
+
+ $cacheEntry->difflink = $this->buildDiffLink( $cacheEntry, $showDiffLinks, $counter );
+ $cacheEntry->curlink = $this->buildCurLink( $cacheEntry, $showDiffLinks, $counter );
+ $cacheEntry->lastlink = $this->buildLastLink( $cacheEntry, $showDiffLinks );
+
+ // Make user links
+ $cacheEntry->userlink = $this->getUserLink( $cacheEntry );
+
+ if ( !ChangesList::isDeleted( $cacheEntry, Revision::DELETED_USER ) ) {
+ $cacheEntry->usertalklink = Linker::userToolLinks(
+ $cacheEntry->mAttribs['rc_user'],
+ $cacheEntry->mAttribs['rc_user_text']
+ );
+ }
+
+ return $cacheEntry;
+ }
+
+ /**
+ * @param RecentChange $cacheEntry
+ * @param User $user
+ *
+ * @return bool
+ */
+ private function showDiffLinks( RecentChange $cacheEntry, User $user ) {
+ return ChangesList::userCan( $cacheEntry, Revision::DELETED_TEXT, $user );
+ }
+
+ /**
+ * @param RecentChange $cacheEntry
+ *
+ * @return string
+ */
+ private function buildCLink( RecentChange $cacheEntry ) {
+ $type = $cacheEntry->mAttribs['rc_type'];
+
+ // New unpatrolled pages
+ if ( $cacheEntry->unpatrolled && $type == RC_NEW ) {
+ $clink = $this->linkRenderer->makeKnownLink( $cacheEntry->getTitle() );
+ // Log entries
+ } elseif ( $type == RC_LOG ) {
+ $logType = $cacheEntry->mAttribs['rc_log_type'];
+
+ if ( $logType ) {
+ $clink = $this->getLogLink( $logType );
+ } else {
+ wfDebugLog( 'recentchanges', 'Unexpected log entry with no log type in recent changes' );
+ $clink = $this->linkRenderer->makeLink( $cacheEntry->getTitle() );
+ }
+ // Log entries (old format) and special pages
+ } elseif ( $cacheEntry->mAttribs['rc_namespace'] == NS_SPECIAL ) {
+ wfDebugLog( 'recentchanges', 'Unexpected special page in recentchanges' );
+ $clink = '';
+ // Edits
+ } else {
+ $clink = $this->linkRenderer->makeKnownLink( $cacheEntry->getTitle() );
+ }
+
+ return $clink;
+ }
+
+ private function getLogLink( $logType ) {
+ $logtitle = SpecialPage::getTitleFor( 'Log', $logType );
+ $logpage = new LogPage( $logType );
+ $logname = $logpage->getName()->text();
+
+ $logLink = $this->context->msg( 'parentheses' )
+ ->rawParams(
+ $this->linkRenderer->makeKnownLink( $logtitle, $logname )
+ )->escaped();
+
+ return $logLink;
+ }
+
+ /**
+ * @param RecentChange $cacheEntry
+ *
+ * @return string
+ */
+ private function buildTimestamp( RecentChange $cacheEntry ) {
+ return $this->context->getLanguage()->userTime(
+ $cacheEntry->mAttribs['rc_timestamp'],
+ $this->context->getUser()
+ );
+ }
+
+ /**
+ * @param RecentChange $recentChange
+ *
+ * @return array
+ */
+ private function buildCurQueryParams( RecentChange $recentChange ) {
+ return [
+ 'curid' => $recentChange->mAttribs['rc_cur_id'],
+ 'diff' => 0,
+ 'oldid' => $recentChange->mAttribs['rc_this_oldid']
+ ];
+ }
+
+ /**
+ * @param RecentChange $cacheEntry
+ * @param bool $showDiffLinks
+ * @param int $counter
+ *
+ * @return string
+ */
+ private function buildCurLink( RecentChange $cacheEntry, $showDiffLinks, $counter ) {
+ $queryParams = $this->buildCurQueryParams( $cacheEntry );
+ $curMessage = $this->getMessage( 'cur' );
+ $logTypes = [ RC_LOG ];
+
+ if ( !$showDiffLinks || in_array( $cacheEntry->mAttribs['rc_type'], $logTypes ) ) {
+ $curLink = $curMessage;
+ } else {
+ $curUrl = htmlspecialchars( $cacheEntry->getTitle()->getLinkURL( $queryParams ) );
+ $curLink = "<a class=\"mw-changeslist-diff-cur\" href=\"$curUrl\">$curMessage</a>";
+ }
+
+ return $curLink;
+ }
+
+ /**
+ * @param RecentChange $recentChange
+ *
+ * @return array
+ */
+ private function buildDiffQueryParams( RecentChange $recentChange ) {
+ return [
+ 'curid' => $recentChange->mAttribs['rc_cur_id'],
+ 'diff' => $recentChange->mAttribs['rc_this_oldid'],
+ 'oldid' => $recentChange->mAttribs['rc_last_oldid']
+ ];
+ }
+
+ /**
+ * @param RecentChange $cacheEntry
+ * @param bool $showDiffLinks
+ * @param int $counter
+ *
+ * @return string
+ */
+ private function buildDiffLink( RecentChange $cacheEntry, $showDiffLinks, $counter ) {
+ $queryParams = $this->buildDiffQueryParams( $cacheEntry );
+ $diffMessage = $this->getMessage( 'diff' );
+ $logTypes = [ RC_NEW, RC_LOG ];
+
+ if ( !$showDiffLinks ) {
+ $diffLink = $diffMessage;
+ } elseif ( in_array( $cacheEntry->mAttribs['rc_type'], $logTypes ) ) {
+ $diffLink = $diffMessage;
+ } elseif ( $cacheEntry->getAttribute( 'rc_type' ) == RC_CATEGORIZE ) {
+ $rcCurId = $cacheEntry->getAttribute( 'rc_cur_id' );
+ $pageTitle = Title::newFromID( $rcCurId );
+ if ( $pageTitle === null ) {
+ wfDebugLog( 'RCCacheEntryFactory', 'Could not get Title for rc_cur_id: ' . $rcCurId );
+ return $diffMessage;
+ }
+ $diffUrl = htmlspecialchars( $pageTitle->getLinkURL( $queryParams ) );
+ $diffLink = "<a class=\"mw-changeslist-diff\" href=\"$diffUrl\">$diffMessage</a>";
+ } else {
+ $diffUrl = htmlspecialchars( $cacheEntry->getTitle()->getLinkURL( $queryParams ) );
+ $diffLink = "<a class=\"mw-changeslist-diff\" href=\"$diffUrl\">$diffMessage</a>";
+ }
+
+ return $diffLink;
+ }
+
+ /**
+ * Builds the link to the previous version
+ *
+ * @param RecentChange $cacheEntry
+ * @param bool $showDiffLinks
+ *
+ * @return string
+ */
+ private function buildLastLink( RecentChange $cacheEntry, $showDiffLinks ) {
+ $lastOldid = $cacheEntry->mAttribs['rc_last_oldid'];
+ $lastMessage = $this->getMessage( 'last' );
+ $type = $cacheEntry->mAttribs['rc_type'];
+ $logTypes = [ RC_LOG ];
+
+ // Make "last" link
+ if ( !$showDiffLinks || !$lastOldid || in_array( $type, $logTypes ) ) {
+ $lastLink = $lastMessage;
+ } else {
+ $lastLink = $this->linkRenderer->makeKnownLink(
+ $cacheEntry->getTitle(),
+ new HtmlArmor( $lastMessage ),
+ [ 'class' => 'mw-changeslist-diff' ],
+ $this->buildDiffQueryParams( $cacheEntry )
+ );
+ }
+
+ return $lastLink;
+ }
+
+ /**
+ * @param RecentChange $cacheEntry
+ *
+ * @return string
+ */
+ private function getUserLink( RecentChange $cacheEntry ) {
+ if ( ChangesList::isDeleted( $cacheEntry, Revision::DELETED_USER ) ) {
+ $userLink = ' <span class="history-deleted">' .
+ $this->context->msg( 'rev-deleted-user' )->escaped() . '</span>';
+ } else {
+ $userLink = Linker::userLink(
+ $cacheEntry->mAttribs['rc_user'],
+ $cacheEntry->mAttribs['rc_user_text']
+ );
+ }
+
+ return $userLink;
+ }
+
+ /**
+ * @param string $key
+ *
+ * @return string
+ */
+ private function getMessage( $key ) {
+ return $this->messages[$key];
+ }
+
+}
diff --git a/www/wiki/includes/changes/RecentChange.php b/www/wiki/includes/changes/RecentChange.php
new file mode 100644
index 00000000..fd789a64
--- /dev/null
+++ b/www/wiki/includes/changes/RecentChange.php
@@ -0,0 +1,1088 @@
+<?php
+/**
+ * Utility class for creating and accessing recent change entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Utility class for creating new RC entries
+ *
+ * mAttribs:
+ * rc_id id of the row in the recentchanges table
+ * rc_timestamp time the entry was made
+ * rc_namespace namespace #
+ * rc_title non-prefixed db key
+ * rc_type is new entry, used to determine whether updating is necessary
+ * rc_source string representation of change source
+ * rc_minor is minor
+ * rc_cur_id page_id of associated page entry
+ * rc_user user id who made the entry
+ * rc_user_text user name who made the entry
+ * rc_comment edit summary
+ * rc_this_oldid rev_id associated with this entry (or zero)
+ * rc_last_oldid rev_id associated with the entry before this one (or zero)
+ * rc_bot is bot, hidden
+ * rc_ip IP address of the user in dotted quad notation
+ * rc_new obsolete, use rc_type==RC_NEW
+ * rc_patrolled boolean whether or not someone has marked this edit as patrolled
+ * rc_old_len integer byte length of the text before the edit
+ * rc_new_len the same after the edit
+ * rc_deleted partial deletion
+ * rc_logid the log_id value for this log entry (or zero)
+ * rc_log_type the log type (or null)
+ * rc_log_action the log action (or null)
+ * rc_params log params
+ *
+ * mExtra:
+ * prefixedDBkey prefixed db key, used by external app via msg queue
+ * lastTimestamp timestamp of previous entry, used in WHERE clause during update
+ * oldSize text size before the change
+ * newSize text size after the change
+ * pageStatus status of the page: created, deleted, moved, restored, changed
+ *
+ * temporary: not stored in the database
+ * notificationtimestamp
+ * numberofWatchingusers
+ *
+ * @todo Deprecate access to mAttribs (direct or via getAttributes). Right now
+ * we're having to include both rc_comment and rc_comment_text/rc_comment_data
+ * so random crap works right.
+ */
+class RecentChange {
+ // Constants for the rc_source field. Extensions may also have
+ // their own source constants.
+ const SRC_EDIT = 'mw.edit';
+ const SRC_NEW = 'mw.new';
+ const SRC_LOG = 'mw.log';
+ const SRC_EXTERNAL = 'mw.external'; // obsolete
+ const SRC_CATEGORIZE = 'mw.categorize';
+
+ public $mAttribs = [];
+ public $mExtra = [];
+
+ /**
+ * @var Title
+ */
+ public $mTitle = false;
+
+ /**
+ * @var User
+ */
+ private $mPerformer = false;
+
+ public $numberofWatchingusers = 0; # Dummy to prevent error message in SpecialRecentChangesLinked
+ public $notificationtimestamp;
+
+ /**
+ * @var int Line number of recent change. Default -1.
+ */
+ public $counter = -1;
+
+ /**
+ * @var array List of tags to apply
+ */
+ private $tags = [];
+
+ /**
+ * @var array Array of change types
+ */
+ private static $changeTypes = [
+ 'edit' => RC_EDIT,
+ 'new' => RC_NEW,
+ 'log' => RC_LOG,
+ 'external' => RC_EXTERNAL,
+ 'categorize' => RC_CATEGORIZE,
+ ];
+
+ # Factory methods
+
+ /**
+ * @param mixed $row
+ * @return RecentChange
+ */
+ public static function newFromRow( $row ) {
+ $rc = new RecentChange;
+ $rc->loadFromRow( $row );
+
+ return $rc;
+ }
+
+ /**
+ * Parsing text to RC_* constants
+ * @since 1.24
+ * @param string|array $type
+ * @throws MWException
+ * @return int|array RC_TYPE
+ */
+ public static function parseToRCType( $type ) {
+ if ( is_array( $type ) ) {
+ $retval = [];
+ foreach ( $type as $t ) {
+ $retval[] = self::parseToRCType( $t );
+ }
+
+ return $retval;
+ }
+
+ if ( !array_key_exists( $type, self::$changeTypes ) ) {
+ throw new MWException( "Unknown type '$type'" );
+ }
+ return self::$changeTypes[$type];
+ }
+
+ /**
+ * Parsing RC_* constants to human-readable test
+ * @since 1.24
+ * @param int $rcType
+ * @return string $type
+ */
+ public static function parseFromRCType( $rcType ) {
+ return array_search( $rcType, self::$changeTypes, true ) ?: "$rcType";
+ }
+
+ /**
+ * Get an array of all change types
+ *
+ * @since 1.26
+ *
+ * @return array
+ */
+ public static function getChangeTypes() {
+ return array_keys( self::$changeTypes );
+ }
+
+ /**
+ * Obtain the recent change with a given rc_id value
+ *
+ * @param int $rcid The rc_id value to retrieve
+ * @return RecentChange|null
+ */
+ public static function newFromId( $rcid ) {
+ return self::newFromConds( [ 'rc_id' => $rcid ], __METHOD__ );
+ }
+
+ /**
+ * Find the first recent change matching some specific conditions
+ *
+ * @param array $conds Array of conditions
+ * @param mixed $fname Override the method name in profiling/logs
+ * @param int $dbType DB_* constant
+ *
+ * @return RecentChange|null
+ */
+ public static function newFromConds(
+ $conds,
+ $fname = __METHOD__,
+ $dbType = DB_REPLICA
+ ) {
+ $db = wfGetDB( $dbType );
+ $row = $db->selectRow( 'recentchanges', self::selectFields(), $conds, $fname );
+ if ( $row !== false ) {
+ return self::newFromRow( $row );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Return the list of recentchanges fields that should be selected to create
+ * a new recentchanges object.
+ * @todo Deprecate this in favor of a method that returns tables and joins
+ * as well, and use CommentStore::getJoin().
+ * @return array
+ */
+ public static function selectFields() {
+ return [
+ 'rc_id',
+ 'rc_timestamp',
+ 'rc_user',
+ 'rc_user_text',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_minor',
+ 'rc_bot',
+ 'rc_new',
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ 'rc_type',
+ 'rc_source',
+ 'rc_patrolled',
+ 'rc_ip',
+ 'rc_old_len',
+ 'rc_new_len',
+ 'rc_deleted',
+ 'rc_logid',
+ 'rc_log_type',
+ 'rc_log_action',
+ 'rc_params',
+ ] + CommentStore::newKey( 'rc_comment' )->getFields();
+ }
+
+ # Accessors
+
+ /**
+ * @param array $attribs
+ */
+ public function setAttribs( $attribs ) {
+ $this->mAttribs = $attribs;
+ }
+
+ /**
+ * @param array $extra
+ */
+ public function setExtra( $extra ) {
+ $this->mExtra = $extra;
+ }
+
+ /**
+ * @return Title
+ */
+ public function &getTitle() {
+ if ( $this->mTitle === false ) {
+ $this->mTitle = Title::makeTitle( $this->mAttribs['rc_namespace'], $this->mAttribs['rc_title'] );
+ }
+
+ return $this->mTitle;
+ }
+
+ /**
+ * Get the User object of the person who performed this change.
+ *
+ * @return User
+ */
+ public function getPerformer() {
+ if ( $this->mPerformer === false ) {
+ if ( $this->mAttribs['rc_user'] ) {
+ $this->mPerformer = User::newFromId( $this->mAttribs['rc_user'] );
+ } else {
+ $this->mPerformer = User::newFromName( $this->mAttribs['rc_user_text'], false );
+ }
+ }
+
+ return $this->mPerformer;
+ }
+
+ /**
+ * Writes the data in this object to the database
+ * @param bool $noudp
+ */
+ public function save( $noudp = false ) {
+ global $wgPutIPinRC, $wgUseEnotif, $wgShowUpdatedMarker;
+
+ $dbw = wfGetDB( DB_MASTER );
+ if ( !is_array( $this->mExtra ) ) {
+ $this->mExtra = [];
+ }
+
+ if ( !$wgPutIPinRC ) {
+ $this->mAttribs['rc_ip'] = '';
+ }
+
+ # Strict mode fixups (not-NULL fields)
+ foreach ( [ 'minor', 'bot', 'new', 'patrolled', 'deleted' ] as $field ) {
+ $this->mAttribs["rc_$field"] = (int)$this->mAttribs["rc_$field"];
+ }
+ # ...more fixups (NULL fields)
+ foreach ( [ 'old_len', 'new_len' ] as $field ) {
+ $this->mAttribs["rc_$field"] = isset( $this->mAttribs["rc_$field"] )
+ ? (int)$this->mAttribs["rc_$field"]
+ : null;
+ }
+
+ # If our database is strict about IP addresses, use NULL instead of an empty string
+ $strictIPs = in_array( $dbw->getType(), [ 'oracle', 'postgres' ] ); // legacy
+ if ( $strictIPs && $this->mAttribs['rc_ip'] == '' ) {
+ unset( $this->mAttribs['rc_ip'] );
+ }
+
+ # Trim spaces on user supplied text
+ $this->mAttribs['rc_comment'] = trim( $this->mAttribs['rc_comment'] );
+
+ # Fixup database timestamps
+ $this->mAttribs['rc_timestamp'] = $dbw->timestamp( $this->mAttribs['rc_timestamp'] );
+
+ # # If we are using foreign keys, an entry of 0 for the page_id will fail, so use NULL
+ if ( $this->mAttribs['rc_cur_id'] == 0 ) {
+ unset( $this->mAttribs['rc_cur_id'] );
+ }
+
+ # Convert mAttribs['rc_comment'] for CommentStore
+ $row = $this->mAttribs;
+ $comment = $row['rc_comment'];
+ unset( $row['rc_comment'], $row['rc_comment_text'], $row['rc_comment_data'] );
+ $row += CommentStore::newKey( 'rc_comment' )->insert( $dbw, $comment );
+
+ # Don't reuse an existing rc_id for the new row, if one happens to be
+ # set for some reason.
+ unset( $row['rc_id'] );
+
+ # Insert new row
+ $dbw->insert( 'recentchanges', $row, __METHOD__ );
+
+ # Set the ID
+ $this->mAttribs['rc_id'] = $dbw->insertId();
+
+ # Notify extensions
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $rc = $this;
+ Hooks::run( 'RecentChange_save', [ &$rc ] );
+
+ if ( count( $this->tags ) ) {
+ ChangeTags::addTags( $this->tags, $this->mAttribs['rc_id'],
+ $this->mAttribs['rc_this_oldid'], $this->mAttribs['rc_logid'], null, $this );
+ }
+
+ # Notify external application via UDP
+ if ( !$noudp ) {
+ $this->notifyRCFeeds();
+ }
+
+ # E-mail notifications
+ if ( $wgUseEnotif || $wgShowUpdatedMarker ) {
+ $editor = $this->getPerformer();
+ $title = $this->getTitle();
+
+ // Never send an RC notification email about categorization changes
+ if (
+ Hooks::run( 'AbortEmailNotification', [ $editor, $title, $this ] ) &&
+ $this->mAttribs['rc_type'] != RC_CATEGORIZE
+ ) {
+ // @FIXME: This would be better as an extension hook
+ // Send emails or email jobs once this row is safely committed
+ $dbw->onTransactionIdle(
+ function () use ( $editor, $title ) {
+ $enotif = new EmailNotification();
+ $enotif->notifyOnPageChange(
+ $editor,
+ $title,
+ $this->mAttribs['rc_timestamp'],
+ $this->mAttribs['rc_comment'],
+ $this->mAttribs['rc_minor'],
+ $this->mAttribs['rc_last_oldid'],
+ $this->mExtra['pageStatus']
+ );
+ },
+ __METHOD__
+ );
+ }
+ }
+
+ // Update the cached list of active users
+ if ( $this->mAttribs['rc_user'] > 0 ) {
+ JobQueueGroup::singleton()->lazyPush( RecentChangesUpdateJob::newCacheUpdateJob() );
+ }
+ }
+
+ /**
+ * Notify all the feeds about the change.
+ * @param array $feeds Optional feeds to send to, defaults to $wgRCFeeds
+ */
+ public function notifyRCFeeds( array $feeds = null ) {
+ global $wgRCFeeds;
+ if ( $feeds === null ) {
+ $feeds = $wgRCFeeds;
+ }
+
+ $performer = $this->getPerformer();
+
+ foreach ( $feeds as $params ) {
+ $params += [
+ 'omit_bots' => false,
+ 'omit_anon' => false,
+ 'omit_user' => false,
+ 'omit_minor' => false,
+ 'omit_patrolled' => false,
+ ];
+
+ if (
+ ( $params['omit_bots'] && $this->mAttribs['rc_bot'] ) ||
+ ( $params['omit_anon'] && $performer->isAnon() ) ||
+ ( $params['omit_user'] && !$performer->isAnon() ) ||
+ ( $params['omit_minor'] && $this->mAttribs['rc_minor'] ) ||
+ ( $params['omit_patrolled'] && $this->mAttribs['rc_patrolled'] ) ||
+ $this->mAttribs['rc_type'] == RC_EXTERNAL
+ ) {
+ continue;
+ }
+
+ if ( isset( $this->mExtra['actionCommentIRC'] ) ) {
+ $actionComment = $this->mExtra['actionCommentIRC'];
+ } else {
+ $actionComment = null;
+ }
+
+ $feed = RCFeed::factory( $params );
+ $feed->notify( $this, $actionComment );
+ }
+ }
+
+ /**
+ * @since 1.22
+ * @deprecated since 1.29 Use RCFeed::factory() instead
+ * @param string $uri URI to get the engine object for
+ * @param array $params
+ * @return RCFeedEngine The engine object
+ * @throws MWException
+ */
+ public static function getEngine( $uri, $params = [] ) {
+ // TODO: Merge into RCFeed::factory().
+ global $wgRCEngines;
+ $scheme = parse_url( $uri, PHP_URL_SCHEME );
+ if ( !$scheme ) {
+ throw new MWException( "Invalid RCFeed uri: '$uri'" );
+ }
+ if ( !isset( $wgRCEngines[$scheme] ) ) {
+ throw new MWException( "Unknown RCFeedEngine scheme: '$scheme'" );
+ }
+ if ( defined( 'MW_PHPUNIT_TEST' ) && is_object( $wgRCEngines[$scheme] ) ) {
+ return $wgRCEngines[$scheme];
+ }
+ return new $wgRCEngines[$scheme]( $params );
+ }
+
+ /**
+ * Mark a given change as patrolled
+ *
+ * @param RecentChange|int $change RecentChange or corresponding rc_id
+ * @param bool $auto For automatic patrol
+ * @param string|string[] $tags Change tags to add to the patrol log entry
+ * ($user should be able to add the specified tags before this is called)
+ * @return array See doMarkPatrolled(), or null if $change is not an existing rc_id
+ */
+ public static function markPatrolled( $change, $auto = false, $tags = null ) {
+ global $wgUser;
+
+ $change = $change instanceof RecentChange
+ ? $change
+ : self::newFromId( $change );
+
+ if ( !$change instanceof RecentChange ) {
+ return null;
+ }
+
+ return $change->doMarkPatrolled( $wgUser, $auto, $tags );
+ }
+
+ /**
+ * Mark this RecentChange as patrolled
+ *
+ * NOTE: Can also return 'rcpatroldisabled', 'hookaborted' and
+ * 'markedaspatrollederror-noautopatrol' as errors
+ * @param User $user User object doing the action
+ * @param bool $auto For automatic patrol
+ * @param string|string[] $tags Change tags to add to the patrol log entry
+ * ($user should be able to add the specified tags before this is called)
+ * @return array Array of permissions errors, see Title::getUserPermissionsErrors()
+ */
+ public function doMarkPatrolled( User $user, $auto = false, $tags = null ) {
+ global $wgUseRCPatrol, $wgUseNPPatrol, $wgUseFilePatrol;
+
+ $errors = [];
+ // If recentchanges patrol is disabled, only new pages or new file versions
+ // can be patrolled, provided the appropriate config variable is set
+ if ( !$wgUseRCPatrol && ( !$wgUseNPPatrol || $this->getAttribute( 'rc_type' ) != RC_NEW ) &&
+ ( !$wgUseFilePatrol || !( $this->getAttribute( 'rc_type' ) == RC_LOG &&
+ $this->getAttribute( 'rc_log_type' ) == 'upload' ) ) ) {
+ $errors[] = [ 'rcpatroldisabled' ];
+ }
+ // Automatic patrol needs "autopatrol", ordinary patrol needs "patrol"
+ $right = $auto ? 'autopatrol' : 'patrol';
+ $errors = array_merge( $errors, $this->getTitle()->getUserPermissionsErrors( $right, $user ) );
+ if ( !Hooks::run( 'MarkPatrolled',
+ [ $this->getAttribute( 'rc_id' ), &$user, false, $auto ] )
+ ) {
+ $errors[] = [ 'hookaborted' ];
+ }
+ // Users without the 'autopatrol' right can't patrol their
+ // own revisions
+ if ( $user->getName() === $this->getAttribute( 'rc_user_text' )
+ && !$user->isAllowed( 'autopatrol' )
+ ) {
+ $errors[] = [ 'markedaspatrollederror-noautopatrol' ];
+ }
+ if ( $errors ) {
+ return $errors;
+ }
+ // If the change was patrolled already, do nothing
+ if ( $this->getAttribute( 'rc_patrolled' ) ) {
+ return [];
+ }
+ // Actually set the 'patrolled' flag in RC
+ $this->reallyMarkPatrolled();
+ // Log this patrol event
+ PatrolLog::record( $this, $auto, $user, $tags );
+
+ Hooks::run(
+ 'MarkPatrolledComplete',
+ [ $this->getAttribute( 'rc_id' ), &$user, false, $auto ]
+ );
+
+ return [];
+ }
+
+ /**
+ * Mark this RecentChange patrolled, without error checking
+ * @return int Number of affected rows
+ */
+ public function reallyMarkPatrolled() {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'recentchanges',
+ [
+ 'rc_patrolled' => 1
+ ],
+ [
+ 'rc_id' => $this->getAttribute( 'rc_id' )
+ ],
+ __METHOD__
+ );
+ // Invalidate the page cache after the page has been patrolled
+ // to make sure that the Patrol link isn't visible any longer!
+ $this->getTitle()->invalidateCache();
+
+ return $dbw->affectedRows();
+ }
+
+ /**
+ * Makes an entry in the database corresponding to an edit
+ *
+ * @param string $timestamp
+ * @param Title &$title
+ * @param bool $minor
+ * @param User &$user
+ * @param string $comment
+ * @param int $oldId
+ * @param string $lastTimestamp
+ * @param bool $bot
+ * @param string $ip
+ * @param int $oldSize
+ * @param int $newSize
+ * @param int $newId
+ * @param int $patrol
+ * @param array $tags
+ * @return RecentChange
+ */
+ public static function notifyEdit(
+ $timestamp, &$title, $minor, &$user, $comment, $oldId, $lastTimestamp,
+ $bot, $ip = '', $oldSize = 0, $newSize = 0, $newId = 0, $patrol = 0,
+ $tags = []
+ ) {
+ $rc = new RecentChange;
+ $rc->mTitle = $title;
+ $rc->mPerformer = $user;
+ $rc->mAttribs = [
+ 'rc_timestamp' => $timestamp,
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_title' => $title->getDBkey(),
+ 'rc_type' => RC_EDIT,
+ 'rc_source' => self::SRC_EDIT,
+ 'rc_minor' => $minor ? 1 : 0,
+ 'rc_cur_id' => $title->getArticleID(),
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_comment' => &$comment,
+ 'rc_comment_text' => &$comment,
+ 'rc_comment_data' => null,
+ 'rc_this_oldid' => $newId,
+ 'rc_last_oldid' => $oldId,
+ 'rc_bot' => $bot ? 1 : 0,
+ 'rc_ip' => self::checkIPAddress( $ip ),
+ 'rc_patrolled' => intval( $patrol ),
+ 'rc_new' => 0, # obsolete
+ 'rc_old_len' => $oldSize,
+ 'rc_new_len' => $newSize,
+ 'rc_deleted' => 0,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => ''
+ ];
+
+ $rc->mExtra = [
+ 'prefixedDBkey' => $title->getPrefixedDBkey(),
+ 'lastTimestamp' => $lastTimestamp,
+ 'oldSize' => $oldSize,
+ 'newSize' => $newSize,
+ 'pageStatus' => 'changed'
+ ];
+
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $rc, $tags ) {
+ $rc->addTags( $tags );
+ $rc->save();
+ if ( $rc->mAttribs['rc_patrolled'] ) {
+ PatrolLog::record( $rc, true, $rc->getPerformer() );
+ }
+ },
+ DeferredUpdates::POSTSEND,
+ wfGetDB( DB_MASTER )
+ );
+
+ return $rc;
+ }
+
+ /**
+ * Makes an entry in the database corresponding to page creation
+ * Note: the title object must be loaded with the new id using resetArticleID()
+ *
+ * @param string $timestamp
+ * @param Title &$title
+ * @param bool $minor
+ * @param User &$user
+ * @param string $comment
+ * @param bool $bot
+ * @param string $ip
+ * @param int $size
+ * @param int $newId
+ * @param int $patrol
+ * @param array $tags
+ * @return RecentChange
+ */
+ public static function notifyNew(
+ $timestamp, &$title, $minor, &$user, $comment, $bot,
+ $ip = '', $size = 0, $newId = 0, $patrol = 0, $tags = []
+ ) {
+ $rc = new RecentChange;
+ $rc->mTitle = $title;
+ $rc->mPerformer = $user;
+ $rc->mAttribs = [
+ 'rc_timestamp' => $timestamp,
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_title' => $title->getDBkey(),
+ 'rc_type' => RC_NEW,
+ 'rc_source' => self::SRC_NEW,
+ 'rc_minor' => $minor ? 1 : 0,
+ 'rc_cur_id' => $title->getArticleID(),
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_comment' => &$comment,
+ 'rc_comment_text' => &$comment,
+ 'rc_comment_data' => null,
+ 'rc_this_oldid' => $newId,
+ 'rc_last_oldid' => 0,
+ 'rc_bot' => $bot ? 1 : 0,
+ 'rc_ip' => self::checkIPAddress( $ip ),
+ 'rc_patrolled' => intval( $patrol ),
+ 'rc_new' => 1, # obsolete
+ 'rc_old_len' => 0,
+ 'rc_new_len' => $size,
+ 'rc_deleted' => 0,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => ''
+ ];
+
+ $rc->mExtra = [
+ 'prefixedDBkey' => $title->getPrefixedDBkey(),
+ 'lastTimestamp' => 0,
+ 'oldSize' => 0,
+ 'newSize' => $size,
+ 'pageStatus' => 'created'
+ ];
+
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $rc, $tags ) {
+ $rc->addTags( $tags );
+ $rc->save();
+ if ( $rc->mAttribs['rc_patrolled'] ) {
+ PatrolLog::record( $rc, true, $rc->getPerformer() );
+ }
+ },
+ DeferredUpdates::POSTSEND,
+ wfGetDB( DB_MASTER )
+ );
+
+ return $rc;
+ }
+
+ /**
+ * @param string $timestamp
+ * @param Title &$title
+ * @param User &$user
+ * @param string $actionComment
+ * @param string $ip
+ * @param string $type
+ * @param string $action
+ * @param Title $target
+ * @param string $logComment
+ * @param string $params
+ * @param int $newId
+ * @param string $actionCommentIRC
+ * @return bool
+ */
+ public static function notifyLog( $timestamp, &$title, &$user, $actionComment, $ip, $type,
+ $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = ''
+ ) {
+ global $wgLogRestrictions;
+
+ # Don't add private logs to RC!
+ if ( isset( $wgLogRestrictions[$type] ) && $wgLogRestrictions[$type] != '*' ) {
+ return false;
+ }
+ $rc = self::newLogEntry( $timestamp, $title, $user, $actionComment, $ip, $type, $action,
+ $target, $logComment, $params, $newId, $actionCommentIRC );
+ $rc->save();
+
+ return true;
+ }
+
+ /**
+ * @param string $timestamp
+ * @param Title &$title
+ * @param User &$user
+ * @param string $actionComment
+ * @param string $ip
+ * @param string $type
+ * @param string $action
+ * @param Title $target
+ * @param string $logComment
+ * @param string $params
+ * @param int $newId
+ * @param string $actionCommentIRC
+ * @param int $revId Id of associated revision, if any
+ * @param bool $isPatrollable Whether this log entry is patrollable
+ * @return RecentChange
+ */
+ public static function newLogEntry( $timestamp, &$title, &$user, $actionComment, $ip,
+ $type, $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = '',
+ $revId = 0, $isPatrollable = false ) {
+ global $wgRequest;
+
+ # # Get pageStatus for email notification
+ switch ( $type . '-' . $action ) {
+ case 'delete-delete':
+ case 'delete-delete_redir':
+ $pageStatus = 'deleted';
+ break;
+ case 'move-move':
+ case 'move-move_redir':
+ $pageStatus = 'moved';
+ break;
+ case 'delete-restore':
+ $pageStatus = 'restored';
+ break;
+ case 'upload-upload':
+ $pageStatus = 'created';
+ break;
+ case 'upload-overwrite':
+ default:
+ $pageStatus = 'changed';
+ break;
+ }
+
+ // Allow unpatrolled status for patrollable log entries
+ $markPatrolled = $isPatrollable ? $user->isAllowed( 'autopatrol' ) : true;
+
+ $rc = new RecentChange;
+ $rc->mTitle = $target;
+ $rc->mPerformer = $user;
+ $rc->mAttribs = [
+ 'rc_timestamp' => $timestamp,
+ 'rc_namespace' => $target->getNamespace(),
+ 'rc_title' => $target->getDBkey(),
+ 'rc_type' => RC_LOG,
+ 'rc_source' => self::SRC_LOG,
+ 'rc_minor' => 0,
+ 'rc_cur_id' => $target->getArticleID(),
+ 'rc_user' => $user->getId(),
+ 'rc_user_text' => $user->getName(),
+ 'rc_comment' => &$logComment,
+ 'rc_comment_text' => &$logComment,
+ 'rc_comment_data' => null,
+ 'rc_this_oldid' => $revId,
+ 'rc_last_oldid' => 0,
+ 'rc_bot' => $user->isAllowed( 'bot' ) ? (int)$wgRequest->getBool( 'bot', true ) : 0,
+ 'rc_ip' => self::checkIPAddress( $ip ),
+ 'rc_patrolled' => $markPatrolled ? 1 : 0,
+ 'rc_new' => 0, # obsolete
+ 'rc_old_len' => null,
+ 'rc_new_len' => null,
+ 'rc_deleted' => 0,
+ 'rc_logid' => $newId,
+ 'rc_log_type' => $type,
+ 'rc_log_action' => $action,
+ 'rc_params' => $params
+ ];
+
+ $rc->mExtra = [
+ 'prefixedDBkey' => $title->getPrefixedDBkey(),
+ 'lastTimestamp' => 0,
+ 'actionComment' => $actionComment, // the comment appended to the action, passed from LogPage
+ 'pageStatus' => $pageStatus,
+ 'actionCommentIRC' => $actionCommentIRC
+ ];
+
+ return $rc;
+ }
+
+ /**
+ * Constructs a RecentChange object for the given categorization
+ * This does not call save() on the object and thus does not write to the db
+ *
+ * @since 1.27
+ *
+ * @param string $timestamp Timestamp of the recent change to occur
+ * @param Title $categoryTitle Title of the category a page is being added to or removed from
+ * @param User $user User object of the user that made the change
+ * @param string $comment Change summary
+ * @param Title $pageTitle Title of the page that is being added or removed
+ * @param int $oldRevId Parent revision ID of this change
+ * @param int $newRevId Revision ID of this change
+ * @param string $lastTimestamp Parent revision timestamp of this change
+ * @param bool $bot true, if the change was made by a bot
+ * @param string $ip IP address of the user, if the change was made anonymously
+ * @param int $deleted Indicates whether the change has been deleted
+ * @param bool $added true, if the category was added, false for removed
+ *
+ * @return RecentChange
+ */
+ public static function newForCategorization(
+ $timestamp,
+ Title $categoryTitle,
+ User $user = null,
+ $comment,
+ Title $pageTitle,
+ $oldRevId,
+ $newRevId,
+ $lastTimestamp,
+ $bot,
+ $ip = '',
+ $deleted = 0,
+ $added = null
+ ) {
+ // Done in a backwards compatible way.
+ $params = [
+ 'hidden-cat' => WikiCategoryPage::factory( $categoryTitle )->isHidden()
+ ];
+ if ( $added !== null ) {
+ $params['added'] = $added;
+ }
+
+ $rc = new RecentChange;
+ $rc->mTitle = $categoryTitle;
+ $rc->mPerformer = $user;
+ $rc->mAttribs = [
+ 'rc_timestamp' => $timestamp,
+ 'rc_namespace' => $categoryTitle->getNamespace(),
+ 'rc_title' => $categoryTitle->getDBkey(),
+ 'rc_type' => RC_CATEGORIZE,
+ 'rc_source' => self::SRC_CATEGORIZE,
+ 'rc_minor' => 0,
+ 'rc_cur_id' => $pageTitle->getArticleID(),
+ 'rc_user' => $user ? $user->getId() : 0,
+ 'rc_user_text' => $user ? $user->getName() : '',
+ 'rc_comment' => &$comment,
+ 'rc_comment_text' => &$comment,
+ 'rc_comment_data' => null,
+ 'rc_this_oldid' => $newRevId,
+ 'rc_last_oldid' => $oldRevId,
+ 'rc_bot' => $bot ? 1 : 0,
+ 'rc_ip' => self::checkIPAddress( $ip ),
+ 'rc_patrolled' => 1, // Always patrolled, just like log entries
+ 'rc_new' => 0, # obsolete
+ 'rc_old_len' => null,
+ 'rc_new_len' => null,
+ 'rc_deleted' => $deleted,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => serialize( $params )
+ ];
+
+ $rc->mExtra = [
+ 'prefixedDBkey' => $categoryTitle->getPrefixedDBkey(),
+ 'lastTimestamp' => $lastTimestamp,
+ 'oldSize' => 0,
+ 'newSize' => 0,
+ 'pageStatus' => 'changed'
+ ];
+
+ return $rc;
+ }
+
+ /**
+ * Get a parameter value
+ *
+ * @since 1.27
+ *
+ * @param string $name parameter name
+ * @return mixed
+ */
+ public function getParam( $name ) {
+ $params = $this->parseParams();
+ return isset( $params[$name] ) ? $params[$name] : null;
+ }
+
+ /**
+ * Initialises the members of this object from a mysql row object
+ *
+ * @param mixed $row
+ */
+ public function loadFromRow( $row ) {
+ $this->mAttribs = get_object_vars( $row );
+ $this->mAttribs['rc_timestamp'] = wfTimestamp( TS_MW, $this->mAttribs['rc_timestamp'] );
+ // rc_deleted MUST be set
+ $this->mAttribs['rc_deleted'] = $row->rc_deleted;
+
+ if ( isset( $this->mAttribs['rc_ip'] ) ) {
+ // Clean up CIDRs for Postgres per T164898. ("127.0.0.1" casts to "127.0.0.1/32")
+ $n = strpos( $this->mAttribs['rc_ip'], '/' );
+ if ( $n !== false ) {
+ $this->mAttribs['rc_ip'] = substr( $this->mAttribs['rc_ip'], 0, $n );
+ }
+ }
+
+ $comment = CommentStore::newKey( 'rc_comment' )
+ // Legacy because $row probably came from self::selectFields()
+ ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row, true )->text;
+ $this->mAttribs['rc_comment'] = &$comment;
+ $this->mAttribs['rc_comment_text'] = &$comment;
+ $this->mAttribs['rc_comment_data'] = null;
+ }
+
+ /**
+ * Get an attribute value
+ *
+ * @param string $name Attribute name
+ * @return mixed
+ */
+ public function getAttribute( $name ) {
+ if ( $name === 'rc_comment' ) {
+ return CommentStore::newKey( 'rc_comment' )->getComment( $this->mAttribs, true )->text;
+ }
+ return isset( $this->mAttribs[$name] ) ? $this->mAttribs[$name] : null;
+ }
+
+ /**
+ * @return array
+ */
+ public function getAttributes() {
+ return $this->mAttribs;
+ }
+
+ /**
+ * Gets the end part of the diff URL associated with this object
+ * Blank if no diff link should be displayed
+ * @param bool $forceCur
+ * @return string
+ */
+ public function diffLinkTrail( $forceCur ) {
+ if ( $this->mAttribs['rc_type'] == RC_EDIT ) {
+ $trail = "curid=" . (int)( $this->mAttribs['rc_cur_id'] ) .
+ "&oldid=" . (int)( $this->mAttribs['rc_last_oldid'] );
+ if ( $forceCur ) {
+ $trail .= '&diff=0';
+ } else {
+ $trail .= '&diff=' . (int)( $this->mAttribs['rc_this_oldid'] );
+ }
+ } else {
+ $trail = '';
+ }
+
+ return $trail;
+ }
+
+ /**
+ * Returns the change size (HTML).
+ * The lengths can be given optionally.
+ * @param int $old
+ * @param int $new
+ * @return string
+ */
+ public function getCharacterDifference( $old = 0, $new = 0 ) {
+ if ( $old === 0 ) {
+ $old = $this->mAttribs['rc_old_len'];
+ }
+ if ( $new === 0 ) {
+ $new = $this->mAttribs['rc_new_len'];
+ }
+ if ( $old === null || $new === null ) {
+ return '';
+ }
+
+ return ChangesList::showCharacterDifference( $old, $new );
+ }
+
+ private static function checkIPAddress( $ip ) {
+ global $wgRequest;
+ if ( $ip ) {
+ if ( !IP::isIPAddress( $ip ) ) {
+ throw new MWException( "Attempt to write \"" . $ip .
+ "\" as an IP address into recent changes" );
+ }
+ } else {
+ $ip = $wgRequest->getIP();
+ if ( !$ip ) {
+ $ip = '';
+ }
+ }
+
+ return $ip;
+ }
+
+ /**
+ * Check whether the given timestamp is new enough to have a RC row with a given tolerance
+ * as the recentchanges table might not be cleared out regularly (so older entries might exist)
+ * or rows which will be deleted soon shouldn't be included.
+ *
+ * @param mixed $timestamp MWTimestamp compatible timestamp
+ * @param int $tolerance Tolerance in seconds
+ * @return bool
+ */
+ public static function isInRCLifespan( $timestamp, $tolerance = 0 ) {
+ global $wgRCMaxAge;
+
+ return wfTimestamp( TS_UNIX, $timestamp ) > time() - $tolerance - $wgRCMaxAge;
+ }
+
+ /**
+ * Parses and returns the rc_params attribute
+ *
+ * @since 1.26
+ *
+ * @return mixed|bool false on failed unserialization
+ */
+ public function parseParams() {
+ $rcParams = $this->getAttribute( 'rc_params' );
+
+ MediaWiki\suppressWarnings();
+ $unserializedParams = unserialize( $rcParams );
+ MediaWiki\restoreWarnings();
+
+ return $unserializedParams;
+ }
+
+ /**
+ * Tags to append to the recent change,
+ * and associated revision/log
+ *
+ * @since 1.28
+ *
+ * @param string|array $tags
+ */
+ public function addTags( $tags ) {
+ if ( is_string( $tags ) ) {
+ $this->tags[] = $tags;
+ } else {
+ $this->tags = array_merge( $tags, $this->tags );
+ }
+ }
+}
diff --git a/www/wiki/includes/changetags/ChangeTags.php b/www/wiki/includes/changetags/ChangeTags.php
new file mode 100644
index 00000000..fa981247
--- /dev/null
+++ b/www/wiki/includes/changetags/ChangeTags.php
@@ -0,0 +1,1424 @@
+<?php
+/**
+ * Recent changes tagging.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Change tagging
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\Database;
+
+class ChangeTags {
+ /**
+ * Can't delete tags with more than this many uses. Similar in intent to
+ * the bigdelete user right
+ * @todo Use the job queue for tag deletion to avoid this restriction
+ */
+ const MAX_DELETE_USES = 5000;
+
+ /**
+ * @var string[]
+ */
+ private static $coreTags = [ 'mw-contentmodelchange' ];
+
+ /**
+ * Creates HTML for the given tags
+ *
+ * @param string $tags Comma-separated list of tags
+ * @param string $page A label for the type of action which is being displayed,
+ * for example: 'history', 'contributions' or 'newpages'
+ * @param IContextSource|null $context
+ * @note Even though it takes null as a valid argument, an IContextSource is preferred
+ * in a new code, as the null value is subject to change in the future
+ * @return array Array with two items: (html, classes)
+ * - html: String: HTML for displaying the tags (empty string when param $tags is empty)
+ * - classes: Array of strings: CSS classes used in the generated html, one class for each tag
+ */
+ public static function formatSummaryRow( $tags, $page, IContextSource $context = null ) {
+ if ( !$tags ) {
+ return [ '', [] ];
+ }
+ if ( !$context ) {
+ $context = RequestContext::getMain();
+ }
+
+ $classes = [];
+
+ $tags = explode( ',', $tags );
+ $displayTags = [];
+ foreach ( $tags as $tag ) {
+ if ( !$tag ) {
+ continue;
+ }
+ $description = self::tagDescription( $tag, $context );
+ if ( $description === false ) {
+ continue;
+ }
+ $displayTags[] = Xml::tags(
+ 'span',
+ [ 'class' => 'mw-tag-marker ' .
+ Sanitizer::escapeClass( "mw-tag-marker-$tag" ) ],
+ $description
+ );
+ $classes[] = Sanitizer::escapeClass( "mw-tag-$tag" );
+ }
+
+ if ( !$displayTags ) {
+ return [ '', [] ];
+ }
+
+ $markers = $context->msg( 'tag-list-wrapper' )
+ ->numParams( count( $displayTags ) )
+ ->rawParams( $context->getLanguage()->commaList( $displayTags ) )
+ ->parse();
+ $markers = Xml::tags( 'span', [ 'class' => 'mw-tag-markers' ], $markers );
+
+ return [ $markers, $classes ];
+ }
+
+ /**
+ * Get a short description for a tag.
+ *
+ * Checks if message key "mediawiki:tag-$tag" exists. If it does not,
+ * returns the HTML-escaped tag name. Uses the message if the message
+ * exists, provided it is not disabled. If the message is disabled,
+ * we consider the tag hidden, and return false.
+ *
+ * @param string $tag Tag
+ * @param IContextSource $context
+ * @return string|bool Tag description or false if tag is to be hidden.
+ * @since 1.25 Returns false if tag is to be hidden.
+ */
+ public static function tagDescription( $tag, IContextSource $context ) {
+ $msg = $context->msg( "tag-$tag" );
+ if ( !$msg->exists() ) {
+ // No such message, so return the HTML-escaped tag name.
+ return htmlspecialchars( $tag );
+ }
+ if ( $msg->isDisabled() ) {
+ // The message exists but is disabled, hide the tag.
+ return false;
+ }
+
+ // Message exists and isn't disabled, use it.
+ return $msg->parse();
+ }
+
+ /**
+ * Get the message object for the tag's long description.
+ *
+ * Checks if message key "mediawiki:tag-$tag-description" exists. If it does not,
+ * or if message is disabled, returns false. Otherwise, returns the message object
+ * for the long description.
+ *
+ * @param string $tag Tag
+ * @param IContextSource $context
+ * @return Message|bool Message object of the tag long description or false if
+ * there is no description.
+ */
+ public static function tagLongDescriptionMessage( $tag, IContextSource $context ) {
+ $msg = $context->msg( "tag-$tag-description" );
+ if ( !$msg->exists() ) {
+ return false;
+ }
+ if ( $msg->isDisabled() ) {
+ // The message exists but is disabled, hide the description.
+ return false;
+ }
+
+ // Message exists and isn't disabled, use it.
+ return $msg;
+ }
+
+ /**
+ * Add tags to a change given its rc_id, rev_id and/or log_id
+ *
+ * @param string|string[] $tags Tags to add to the change
+ * @param int|null $rc_id The rc_id of the change to add the tags to
+ * @param int|null $rev_id The rev_id of the change to add the tags to
+ * @param int|null $log_id The log_id of the change to add the tags to
+ * @param string $params Params to put in the ct_params field of table 'change_tag'
+ * @param RecentChange|null $rc Recent change, in case the tagging accompanies the action
+ * (this should normally be the case)
+ *
+ * @throws MWException
+ * @return bool False if no changes are made, otherwise true
+ */
+ public static function addTags( $tags, $rc_id = null, $rev_id = null,
+ $log_id = null, $params = null, RecentChange $rc = null
+ ) {
+ $result = self::updateTags( $tags, null, $rc_id, $rev_id, $log_id, $params, $rc );
+ return (bool)$result[0];
+ }
+
+ /**
+ * Add and remove tags to/from a change given its rc_id, rev_id and/or log_id,
+ * without verifying that the tags exist or are valid. If a tag is present in
+ * both $tagsToAdd and $tagsToRemove, it will be removed.
+ *
+ * This function should only be used by extensions to manipulate tags they
+ * have registered using the ListDefinedTags hook. When dealing with user
+ * input, call updateTagsWithChecks() instead.
+ *
+ * @param string|array|null $tagsToAdd Tags to add to the change
+ * @param string|array|null $tagsToRemove Tags to remove from the change
+ * @param int|null &$rc_id The rc_id of the change to add the tags to.
+ * Pass a variable whose value is null if the rc_id is not relevant or unknown.
+ * @param int|null &$rev_id The rev_id of the change to add the tags to.
+ * Pass a variable whose value is null if the rev_id is not relevant or unknown.
+ * @param int|null &$log_id The log_id of the change to add the tags to.
+ * Pass a variable whose value is null if the log_id is not relevant or unknown.
+ * @param string $params Params to put in the ct_params field of table
+ * 'change_tag' when adding tags
+ * @param RecentChange|null $rc Recent change being tagged, in case the tagging accompanies
+ * the action
+ * @param User|null $user Tagging user, in case the tagging is subsequent to the tagged action
+ *
+ * @throws MWException When $rc_id, $rev_id and $log_id are all null
+ * @return array Index 0 is an array of tags actually added, index 1 is an
+ * array of tags actually removed, index 2 is an array of tags present on the
+ * revision or log entry before any changes were made
+ *
+ * @since 1.25
+ */
+ public static function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id = null,
+ &$rev_id = null, &$log_id = null, $params = null, RecentChange $rc = null,
+ User $user = null
+ ) {
+ $tagsToAdd = array_filter( (array)$tagsToAdd ); // Make sure we're submitting all tags...
+ $tagsToRemove = array_filter( (array)$tagsToRemove );
+
+ if ( !$rc_id && !$rev_id && !$log_id ) {
+ throw new MWException( 'At least one of: RCID, revision ID, and log ID MUST be ' .
+ 'specified when adding or removing a tag from a change!' );
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ // Might as well look for rcids and so on.
+ if ( !$rc_id ) {
+ // Info might be out of date, somewhat fractionally, on replica DB.
+ // LogEntry/LogPage and WikiPage match rev/log/rc timestamps,
+ // so use that relation to avoid full table scans.
+ if ( $log_id ) {
+ $rc_id = $dbw->selectField(
+ [ 'logging', 'recentchanges' ],
+ 'rc_id',
+ [
+ 'log_id' => $log_id,
+ 'rc_timestamp = log_timestamp',
+ 'rc_logid = log_id'
+ ],
+ __METHOD__
+ );
+ } elseif ( $rev_id ) {
+ $rc_id = $dbw->selectField(
+ [ 'revision', 'recentchanges' ],
+ 'rc_id',
+ [
+ 'rev_id' => $rev_id,
+ 'rc_timestamp = rev_timestamp',
+ 'rc_this_oldid = rev_id'
+ ],
+ __METHOD__
+ );
+ }
+ } elseif ( !$log_id && !$rev_id ) {
+ // Info might be out of date, somewhat fractionally, on replica DB.
+ $log_id = $dbw->selectField(
+ 'recentchanges',
+ 'rc_logid',
+ [ 'rc_id' => $rc_id ],
+ __METHOD__
+ );
+ $rev_id = $dbw->selectField(
+ 'recentchanges',
+ 'rc_this_oldid',
+ [ 'rc_id' => $rc_id ],
+ __METHOD__
+ );
+ }
+
+ if ( $log_id && !$rev_id ) {
+ $rev_id = $dbw->selectField(
+ 'log_search',
+ 'ls_value',
+ [ 'ls_field' => 'associated_rev_id', 'ls_log_id' => $log_id ],
+ __METHOD__
+ );
+ } elseif ( !$log_id && $rev_id ) {
+ $log_id = $dbw->selectField(
+ 'log_search',
+ 'ls_log_id',
+ [ 'ls_field' => 'associated_rev_id', 'ls_value' => $rev_id ],
+ __METHOD__
+ );
+ }
+
+ // update the tag_summary row
+ $prevTags = [];
+ if ( !self::updateTagSummaryRow( $tagsToAdd, $tagsToRemove, $rc_id, $rev_id,
+ $log_id, $prevTags )
+ ) {
+ // nothing to do
+ return [ [], [], $prevTags ];
+ }
+
+ // insert a row into change_tag for each new tag
+ if ( count( $tagsToAdd ) ) {
+ $tagsRows = [];
+ foreach ( $tagsToAdd as $tag ) {
+ // Filter so we don't insert NULLs as zero accidentally.
+ // Keep in mind that $rc_id === null means "I don't care/know about the
+ // rc_id, just delete $tag on this revision/log entry". It doesn't
+ // mean "only delete tags on this revision/log WHERE rc_id IS NULL".
+ $tagsRows[] = array_filter(
+ [
+ 'ct_tag' => $tag,
+ 'ct_rc_id' => $rc_id,
+ 'ct_log_id' => $log_id,
+ 'ct_rev_id' => $rev_id,
+ 'ct_params' => $params
+ ]
+ );
+ }
+
+ $dbw->insert( 'change_tag', $tagsRows, __METHOD__, [ 'IGNORE' ] );
+ }
+
+ // delete from change_tag
+ if ( count( $tagsToRemove ) ) {
+ foreach ( $tagsToRemove as $tag ) {
+ $conds = array_filter(
+ [
+ 'ct_tag' => $tag,
+ 'ct_rc_id' => $rc_id,
+ 'ct_log_id' => $log_id,
+ 'ct_rev_id' => $rev_id
+ ]
+ );
+ $dbw->delete( 'change_tag', $conds, __METHOD__ );
+ }
+ }
+
+ self::purgeTagUsageCache();
+
+ Hooks::run( 'ChangeTagsAfterUpdateTags', [ $tagsToAdd, $tagsToRemove, $prevTags,
+ $rc_id, $rev_id, $log_id, $params, $rc, $user ] );
+
+ return [ $tagsToAdd, $tagsToRemove, $prevTags ];
+ }
+
+ /**
+ * Adds or removes a given set of tags to/from the relevant row of the
+ * tag_summary table. Modifies the tagsToAdd and tagsToRemove arrays to
+ * reflect the tags that were actually added and/or removed.
+ *
+ * @param array &$tagsToAdd
+ * @param array &$tagsToRemove If a tag is present in both $tagsToAdd and
+ * $tagsToRemove, it will be removed
+ * @param int|null $rc_id Null if not known or not applicable
+ * @param int|null $rev_id Null if not known or not applicable
+ * @param int|null $log_id Null if not known or not applicable
+ * @param array &$prevTags Optionally outputs a list of the tags that were
+ * in the tag_summary row to begin with
+ * @return bool True if any modifications were made, otherwise false
+ * @since 1.25
+ */
+ protected static function updateTagSummaryRow( &$tagsToAdd, &$tagsToRemove,
+ $rc_id, $rev_id, $log_id, &$prevTags = []
+ ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $tsConds = array_filter( [
+ 'ts_rc_id' => $rc_id,
+ 'ts_rev_id' => $rev_id,
+ 'ts_log_id' => $log_id
+ ] );
+
+ // Can't both add and remove a tag at the same time...
+ $tagsToAdd = array_diff( $tagsToAdd, $tagsToRemove );
+
+ // Update the summary row.
+ // $prevTags can be out of date on replica DBs, especially when addTags is called consecutively,
+ // causing loss of tags added recently in tag_summary table.
+ $prevTags = $dbw->selectField( 'tag_summary', 'ts_tags', $tsConds, __METHOD__ );
+ $prevTags = $prevTags ? $prevTags : '';
+ $prevTags = array_filter( explode( ',', $prevTags ) );
+
+ // add tags
+ $tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
+ $newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
+
+ // remove tags
+ $tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
+ $newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
+
+ sort( $prevTags );
+ sort( $newTags );
+ if ( $prevTags == $newTags ) {
+ // No change.
+ return false;
+ }
+
+ if ( !$newTags ) {
+ // no tags left, so delete the row altogether
+ $dbw->delete( 'tag_summary', $tsConds, __METHOD__ );
+ } else {
+ $dbw->replace( 'tag_summary',
+ [ 'ts_rev_id', 'ts_rc_id', 'ts_log_id' ],
+ array_filter( array_merge( $tsConds, [ 'ts_tags' => implode( ',', $newTags ) ] ) ),
+ __METHOD__
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Helper function to generate a fatal status with a 'not-allowed' type error.
+ *
+ * @param string $msgOne Message key to use in the case of one tag
+ * @param string $msgMulti Message key to use in the case of more than one tag
+ * @param array $tags Restricted tags (passed as $1 into the message, count of
+ * $tags passed as $2)
+ * @return Status
+ * @since 1.25
+ */
+ protected static function restrictedTagError( $msgOne, $msgMulti, $tags ) {
+ $lang = RequestContext::getMain()->getLanguage();
+ $count = count( $tags );
+ return Status::newFatal( ( $count > 1 ) ? $msgMulti : $msgOne,
+ $lang->commaList( $tags ), $count );
+ }
+
+ /**
+ * Is it OK to allow the user to apply all the specified tags at the same time
+ * as they edit/make the change?
+ *
+ * @param array $tags Tags that you are interested in applying
+ * @param User|null $user User whose permission you wish to check, or null if
+ * you don't care (e.g. maintenance scripts)
+ * @return Status
+ * @since 1.25
+ */
+ public static function canAddTagsAccompanyingChange( array $tags, User $user = null ) {
+ if ( !is_null( $user ) ) {
+ if ( !$user->isAllowed( 'applychangetags' ) ) {
+ return Status::newFatal( 'tags-apply-no-permission' );
+ } elseif ( $user->isBlocked() ) {
+ return Status::newFatal( 'tags-apply-blocked', $user->getName() );
+ }
+ }
+
+ // to be applied, a tag has to be explicitly defined
+ $allowedTags = self::listExplicitlyDefinedTags();
+ Hooks::run( 'ChangeTagsAllowedAdd', [ &$allowedTags, $tags, $user ] );
+ $disallowedTags = array_diff( $tags, $allowedTags );
+ if ( $disallowedTags ) {
+ return self::restrictedTagError( 'tags-apply-not-allowed-one',
+ 'tags-apply-not-allowed-multi', $disallowedTags );
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Adds tags to a given change, checking whether it is allowed first, but
+ * without adding a log entry. Useful for cases where the tag is being added
+ * along with the action that generated the change (e.g. tagging an edit as
+ * it is being made).
+ *
+ * Extensions should not use this function, unless directly handling a user
+ * request to add a particular tag. Normally, extensions should call
+ * ChangeTags::updateTags() instead.
+ *
+ * @param array $tags Tags to apply
+ * @param int|null $rc_id The rc_id of the change to add the tags to
+ * @param int|null $rev_id The rev_id of the change to add the tags to
+ * @param int|null $log_id The log_id of the change to add the tags to
+ * @param string $params Params to put in the ct_params field of table
+ * 'change_tag' when adding tags
+ * @param User $user Who to give credit for the action
+ * @return Status
+ * @since 1.25
+ */
+ public static function addTagsAccompanyingChangeWithChecks(
+ array $tags, $rc_id, $rev_id, $log_id, $params, User $user
+ ) {
+ // are we allowed to do this?
+ $result = self::canAddTagsAccompanyingChange( $tags, $user );
+ if ( !$result->isOK() ) {
+ $result->value = null;
+ return $result;
+ }
+
+ // do it!
+ self::addTags( $tags, $rc_id, $rev_id, $log_id, $params );
+
+ return Status::newGood( true );
+ }
+
+ /**
+ * Is it OK to allow the user to adds and remove the given tags tags to/from a
+ * change?
+ *
+ * @param array $tagsToAdd Tags that you are interested in adding
+ * @param array $tagsToRemove Tags that you are interested in removing
+ * @param User|null $user User whose permission you wish to check, or null if
+ * you don't care (e.g. maintenance scripts)
+ * @return Status
+ * @since 1.25
+ */
+ public static function canUpdateTags( array $tagsToAdd, array $tagsToRemove,
+ User $user = null
+ ) {
+ if ( !is_null( $user ) ) {
+ if ( !$user->isAllowed( 'changetags' ) ) {
+ return Status::newFatal( 'tags-update-no-permission' );
+ } elseif ( $user->isBlocked() ) {
+ return Status::newFatal( 'tags-update-blocked', $user->getName() );
+ }
+ }
+
+ if ( $tagsToAdd ) {
+ // to be added, a tag has to be explicitly defined
+ // @todo Allow extensions to define tags that can be applied by users...
+ $explicitlyDefinedTags = self::listExplicitlyDefinedTags();
+ $diff = array_diff( $tagsToAdd, $explicitlyDefinedTags );
+ if ( $diff ) {
+ return self::restrictedTagError( 'tags-update-add-not-allowed-one',
+ 'tags-update-add-not-allowed-multi', $diff );
+ }
+ }
+
+ if ( $tagsToRemove ) {
+ // to be removed, a tag must not be defined by an extension, or equivalently it
+ // has to be either explicitly defined or not defined at all
+ // (assuming no edge case of a tag both explicitly-defined and extension-defined)
+ $softwareDefinedTags = self::listSoftwareDefinedTags();
+ $intersect = array_intersect( $tagsToRemove, $softwareDefinedTags );
+ if ( $intersect ) {
+ return self::restrictedTagError( 'tags-update-remove-not-allowed-one',
+ 'tags-update-remove-not-allowed-multi', $intersect );
+ }
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Adds and/or removes tags to/from a given change, checking whether it is
+ * allowed first, and adding a log entry afterwards.
+ *
+ * Includes a call to ChangeTag::canUpdateTags(), so your code doesn't need
+ * to do that. However, it doesn't check whether the *_id parameters are a
+ * valid combination. That is up to you to enforce. See ApiTag::execute() for
+ * an example.
+ *
+ * @param array|null $tagsToAdd If none, pass array() or null
+ * @param array|null $tagsToRemove If none, pass array() or null
+ * @param int|null $rc_id The rc_id of the change to add the tags to
+ * @param int|null $rev_id The rev_id of the change to add the tags to
+ * @param int|null $log_id The log_id of the change to add the tags to
+ * @param string $params Params to put in the ct_params field of table
+ * 'change_tag' when adding tags
+ * @param string $reason Comment for the log
+ * @param User $user Who to give credit for the action
+ * @return Status If successful, the value of this Status object will be an
+ * object (stdClass) with the following fields:
+ * - logId: the ID of the added log entry, or null if no log entry was added
+ * (i.e. no operation was performed)
+ * - addedTags: an array containing the tags that were actually added
+ * - removedTags: an array containing the tags that were actually removed
+ * @since 1.25
+ */
+ public static function updateTagsWithChecks( $tagsToAdd, $tagsToRemove,
+ $rc_id, $rev_id, $log_id, $params, $reason, User $user
+ ) {
+ if ( is_null( $tagsToAdd ) ) {
+ $tagsToAdd = [];
+ }
+ if ( is_null( $tagsToRemove ) ) {
+ $tagsToRemove = [];
+ }
+ if ( !$tagsToAdd && !$tagsToRemove ) {
+ // no-op, don't bother
+ return Status::newGood( (object)[
+ 'logId' => null,
+ 'addedTags' => [],
+ 'removedTags' => [],
+ ] );
+ }
+
+ // are we allowed to do this?
+ $result = self::canUpdateTags( $tagsToAdd, $tagsToRemove, $user );
+ if ( !$result->isOK() ) {
+ $result->value = null;
+ return $result;
+ }
+
+ // basic rate limiting
+ if ( $user->pingLimiter( 'changetag' ) ) {
+ return Status::newFatal( 'actionthrottledtext' );
+ }
+
+ // do it!
+ list( $tagsAdded, $tagsRemoved, $initialTags ) = self::updateTags( $tagsToAdd,
+ $tagsToRemove, $rc_id, $rev_id, $log_id, $params, null, $user );
+ if ( !$tagsAdded && !$tagsRemoved ) {
+ // no-op, don't log it
+ return Status::newGood( (object)[
+ 'logId' => null,
+ 'addedTags' => [],
+ 'removedTags' => [],
+ ] );
+ }
+
+ // log it
+ $logEntry = new ManualLogEntry( 'tag', 'update' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setComment( $reason );
+
+ // find the appropriate target page
+ if ( $rev_id ) {
+ $rev = Revision::newFromId( $rev_id );
+ if ( $rev ) {
+ $logEntry->setTarget( $rev->getTitle() );
+ }
+ } elseif ( $log_id ) {
+ // This function is from revision deletion logic and has nothing to do with
+ // change tags, but it appears to be the only other place in core where we
+ // perform logged actions on log items.
+ $logEntry->setTarget( RevDelLogList::suggestTarget( null, [ $log_id ] ) );
+ }
+
+ if ( !$logEntry->getTarget() ) {
+ // target is required, so we have to set something
+ $logEntry->setTarget( SpecialPage::getTitleFor( 'Tags' ) );
+ }
+
+ $logParams = [
+ '4::revid' => $rev_id,
+ '5::logid' => $log_id,
+ '6:list:tagsAdded' => $tagsAdded,
+ '7:number:tagsAddedCount' => count( $tagsAdded ),
+ '8:list:tagsRemoved' => $tagsRemoved,
+ '9:number:tagsRemovedCount' => count( $tagsRemoved ),
+ 'initialTags' => $initialTags,
+ ];
+ $logEntry->setParameters( $logParams );
+ $logEntry->setRelations( [ 'Tag' => array_merge( $tagsAdded, $tagsRemoved ) ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $logId = $logEntry->insert( $dbw );
+ // Only send this to UDP, not RC, similar to patrol events
+ $logEntry->publish( $logId, 'udp' );
+
+ return Status::newGood( (object)[
+ 'logId' => $logId,
+ 'addedTags' => $tagsAdded,
+ 'removedTags' => $tagsRemoved,
+ ] );
+ }
+
+ /**
+ * Applies all tags-related changes to a query.
+ * Handles selecting tags, and filtering.
+ * Needs $tables to be set up properly, so we can figure out which join conditions to use.
+ *
+ * WARNING: If $filter_tag contains more than one tag, this function will add DISTINCT,
+ * which may cause performance problems for your query unless you put the ID field of your
+ * table at the end of the ORDER BY, and set a GROUP BY equal to the ORDER BY. For example,
+ * if you had ORDER BY foo_timestamp DESC, you will now need GROUP BY foo_timestamp, foo_id
+ * ORDER BY foo_timestamp DESC, foo_id DESC.
+ *
+ * @param string|array &$tables Table names, see Database::select
+ * @param string|array &$fields Fields used in query, see Database::select
+ * @param string|array &$conds Conditions used in query, see Database::select
+ * @param array &$join_conds Join conditions, see Database::select
+ * @param string|array &$options Options, see Database::select
+ * @param string|array $filter_tag Tag(s) to select on
+ *
+ * @throws MWException When unable to determine appropriate JOIN condition for tagging
+ */
+ public static function modifyDisplayQuery( &$tables, &$fields, &$conds,
+ &$join_conds, &$options, $filter_tag = '' ) {
+ global $wgUseTagFilter;
+
+ // Normalize to arrays
+ $tables = (array)$tables;
+ $fields = (array)$fields;
+ $conds = (array)$conds;
+ $options = (array)$options;
+
+ // Figure out which ID field to use
+ if ( in_array( 'recentchanges', $tables ) ) {
+ $join_cond = 'ct_rc_id=rc_id';
+ } elseif ( in_array( 'logging', $tables ) ) {
+ $join_cond = 'ct_log_id=log_id';
+ } elseif ( in_array( 'revision', $tables ) ) {
+ $join_cond = 'ct_rev_id=rev_id';
+ } elseif ( in_array( 'archive', $tables ) ) {
+ $join_cond = 'ct_rev_id=ar_rev_id';
+ } else {
+ throw new MWException( 'Unable to determine appropriate JOIN condition for tagging.' );
+ }
+
+ $fields['ts_tags'] = wfGetDB( DB_REPLICA )->buildGroupConcatField(
+ ',', 'change_tag', 'ct_tag', $join_cond
+ );
+
+ if ( $wgUseTagFilter && $filter_tag ) {
+ // Somebody wants to filter on a tag.
+ // Add an INNER JOIN on change_tag
+
+ $tables[] = 'change_tag';
+ $join_conds['change_tag'] = [ 'INNER JOIN', $join_cond ];
+ $conds['ct_tag'] = $filter_tag;
+ if (
+ is_array( $filter_tag ) && count( $filter_tag ) > 1 &&
+ !in_array( 'DISTINCT', $options )
+ ) {
+ $options[] = 'DISTINCT';
+ }
+ }
+ }
+
+ /**
+ * Build a text box to select a change tag
+ *
+ * @param string $selected Tag to select by default
+ * @param bool $ooui Use an OOUI TextInputWidget as selector instead of a non-OOUI input field
+ * You need to call OutputPage::enableOOUI() yourself.
+ * @param IContextSource|null $context
+ * @note Even though it takes null as a valid argument, an IContextSource is preferred
+ * in a new code, as the null value can change in the future
+ * @return array an array of (label, selector)
+ */
+ public static function buildTagFilterSelector(
+ $selected = '', $ooui = false, IContextSource $context = null
+ ) {
+ if ( !$context ) {
+ $context = RequestContext::getMain();
+ }
+
+ $config = $context->getConfig();
+ if ( !$config->get( 'UseTagFilter' ) || !count( self::listDefinedTags() ) ) {
+ return [];
+ }
+
+ $data = [
+ Html::rawElement(
+ 'label',
+ [ 'for' => 'tagfilter' ],
+ $context->msg( 'tag-filter' )->parse()
+ )
+ ];
+
+ if ( $ooui ) {
+ $data[] = new OOUI\TextInputWidget( [
+ 'id' => 'tagfilter',
+ 'name' => 'tagfilter',
+ 'value' => $selected,
+ 'classes' => 'mw-tagfilter-input',
+ ] );
+ } else {
+ $data[] = Xml::input(
+ 'tagfilter',
+ 20,
+ $selected,
+ [ 'class' => 'mw-tagfilter-input mw-ui-input mw-ui-input-inline', 'id' => 'tagfilter' ]
+ );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Defines a tag in the valid_tag table, without checking that the tag name
+ * is valid.
+ * Extensions should NOT use this function; they can use the ListDefinedTags
+ * hook instead.
+ *
+ * @param string $tag Tag to create
+ * @since 1.25
+ */
+ public static function defineTag( $tag ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->replace( 'valid_tag',
+ [ 'vt_tag' ],
+ [ 'vt_tag' => $tag ],
+ __METHOD__ );
+
+ // clear the memcache of defined tags
+ self::purgeTagCacheAll();
+ }
+
+ /**
+ * Removes a tag from the valid_tag table. The tag may remain in use by
+ * extensions, and may still show up as 'defined' if an extension is setting
+ * it from the ListDefinedTags hook.
+ *
+ * @param string $tag Tag to remove
+ * @since 1.25
+ */
+ public static function undefineTag( $tag ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete( 'valid_tag', [ 'vt_tag' => $tag ], __METHOD__ );
+
+ // clear the memcache of defined tags
+ self::purgeTagCacheAll();
+ }
+
+ /**
+ * Writes a tag action into the tag management log.
+ *
+ * @param string $action
+ * @param string $tag
+ * @param string $reason
+ * @param User $user Who to attribute the action to
+ * @param int $tagCount For deletion only, how many usages the tag had before
+ * it was deleted.
+ * @param array $logEntryTags Change tags to apply to the entry
+ * that will be created in the tag management log
+ * @return int ID of the inserted log entry
+ * @since 1.25
+ */
+ protected static function logTagManagementAction( $action, $tag, $reason,
+ User $user, $tagCount = null, array $logEntryTags = []
+ ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $logEntry = new ManualLogEntry( 'managetags', $action );
+ $logEntry->setPerformer( $user );
+ // target page is not relevant, but it has to be set, so we just put in
+ // the title of Special:Tags
+ $logEntry->setTarget( Title::newFromText( 'Special:Tags' ) );
+ $logEntry->setComment( $reason );
+
+ $params = [ '4::tag' => $tag ];
+ if ( !is_null( $tagCount ) ) {
+ $params['5:number:count'] = $tagCount;
+ }
+ $logEntry->setParameters( $params );
+ $logEntry->setRelations( [ 'Tag' => $tag ] );
+ $logEntry->setTags( $logEntryTags );
+
+ $logId = $logEntry->insert( $dbw );
+ $logEntry->publish( $logId );
+ return $logId;
+ }
+
+ /**
+ * Is it OK to allow the user to activate this tag?
+ *
+ * @param string $tag Tag that you are interested in activating
+ * @param User|null $user User whose permission you wish to check, or null if
+ * you don't care (e.g. maintenance scripts)
+ * @return Status
+ * @since 1.25
+ */
+ public static function canActivateTag( $tag, User $user = null ) {
+ if ( !is_null( $user ) ) {
+ if ( !$user->isAllowed( 'managechangetags' ) ) {
+ return Status::newFatal( 'tags-manage-no-permission' );
+ } elseif ( $user->isBlocked() ) {
+ return Status::newFatal( 'tags-manage-blocked', $user->getName() );
+ }
+ }
+
+ // defined tags cannot be activated (a defined tag is either extension-
+ // defined, in which case the extension chooses whether or not to active it;
+ // or user-defined, in which case it is considered active)
+ $definedTags = self::listDefinedTags();
+ if ( in_array( $tag, $definedTags ) ) {
+ return Status::newFatal( 'tags-activate-not-allowed', $tag );
+ }
+
+ // non-existing tags cannot be activated
+ $tagUsage = self::tagUsageStatistics();
+ if ( !isset( $tagUsage[$tag] ) ) { // we already know the tag is undefined
+ return Status::newFatal( 'tags-activate-not-found', $tag );
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Activates a tag, checking whether it is allowed first, and adding a log
+ * entry afterwards.
+ *
+ * Includes a call to ChangeTag::canActivateTag(), so your code doesn't need
+ * to do that.
+ *
+ * @param string $tag
+ * @param string $reason
+ * @param User $user Who to give credit for the action
+ * @param bool $ignoreWarnings Can be used for API interaction, default false
+ * @param array $logEntryTags Change tags to apply to the entry
+ * that will be created in the tag management log
+ * @return Status If successful, the Status contains the ID of the added log
+ * entry as its value
+ * @since 1.25
+ */
+ public static function activateTagWithChecks( $tag, $reason, User $user,
+ $ignoreWarnings = false, array $logEntryTags = []
+ ) {
+ // are we allowed to do this?
+ $result = self::canActivateTag( $tag, $user );
+ if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
+ $result->value = null;
+ return $result;
+ }
+
+ // do it!
+ self::defineTag( $tag );
+
+ // log it
+ $logId = self::logTagManagementAction( 'activate', $tag, $reason, $user,
+ null, $logEntryTags );
+
+ return Status::newGood( $logId );
+ }
+
+ /**
+ * Is it OK to allow the user to deactivate this tag?
+ *
+ * @param string $tag Tag that you are interested in deactivating
+ * @param User|null $user User whose permission you wish to check, or null if
+ * you don't care (e.g. maintenance scripts)
+ * @return Status
+ * @since 1.25
+ */
+ public static function canDeactivateTag( $tag, User $user = null ) {
+ if ( !is_null( $user ) ) {
+ if ( !$user->isAllowed( 'managechangetags' ) ) {
+ return Status::newFatal( 'tags-manage-no-permission' );
+ } elseif ( $user->isBlocked() ) {
+ return Status::newFatal( 'tags-manage-blocked', $user->getName() );
+ }
+ }
+
+ // only explicitly-defined tags can be deactivated
+ $explicitlyDefinedTags = self::listExplicitlyDefinedTags();
+ if ( !in_array( $tag, $explicitlyDefinedTags ) ) {
+ return Status::newFatal( 'tags-deactivate-not-allowed', $tag );
+ }
+ return Status::newGood();
+ }
+
+ /**
+ * Deactivates a tag, checking whether it is allowed first, and adding a log
+ * entry afterwards.
+ *
+ * Includes a call to ChangeTag::canDeactivateTag(), so your code doesn't need
+ * to do that.
+ *
+ * @param string $tag
+ * @param string $reason
+ * @param User $user Who to give credit for the action
+ * @param bool $ignoreWarnings Can be used for API interaction, default false
+ * @param array $logEntryTags Change tags to apply to the entry
+ * that will be created in the tag management log
+ * @return Status If successful, the Status contains the ID of the added log
+ * entry as its value
+ * @since 1.25
+ */
+ public static function deactivateTagWithChecks( $tag, $reason, User $user,
+ $ignoreWarnings = false, array $logEntryTags = []
+ ) {
+ // are we allowed to do this?
+ $result = self::canDeactivateTag( $tag, $user );
+ if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
+ $result->value = null;
+ return $result;
+ }
+
+ // do it!
+ self::undefineTag( $tag );
+
+ // log it
+ $logId = self::logTagManagementAction( 'deactivate', $tag, $reason, $user,
+ null, $logEntryTags );
+
+ return Status::newGood( $logId );
+ }
+
+ /**
+ * Is the tag name valid?
+ *
+ * @param string $tag Tag that you are interested in creating
+ * @return Status
+ * @since 1.30
+ */
+ public static function isTagNameValid( $tag ) {
+ // no empty tags
+ if ( $tag === '' ) {
+ return Status::newFatal( 'tags-create-no-name' );
+ }
+
+ // tags cannot contain commas (used as a delimiter in tag_summary table),
+ // pipe (used as a delimiter between multiple tags in
+ // SpecialRecentchanges and friends), or slashes (would break tag description messages in
+ // MediaWiki namespace)
+ if ( strpos( $tag, ',' ) !== false || strpos( $tag, '|' ) !== false
+ || strpos( $tag, '/' ) !== false ) {
+ return Status::newFatal( 'tags-create-invalid-chars' );
+ }
+
+ // could the MediaWiki namespace description messages be created?
+ $title = Title::makeTitleSafe( NS_MEDIAWIKI, "Tag-$tag-description" );
+ if ( is_null( $title ) ) {
+ return Status::newFatal( 'tags-create-invalid-title-chars' );
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Is it OK to allow the user to create this tag?
+ *
+ * @param string $tag Tag that you are interested in creating
+ * @param User|null $user User whose permission you wish to check, or null if
+ * you don't care (e.g. maintenance scripts)
+ * @return Status
+ * @since 1.25
+ */
+ public static function canCreateTag( $tag, User $user = null ) {
+ if ( !is_null( $user ) ) {
+ if ( !$user->isAllowed( 'managechangetags' ) ) {
+ return Status::newFatal( 'tags-manage-no-permission' );
+ } elseif ( $user->isBlocked() ) {
+ return Status::newFatal( 'tags-manage-blocked', $user->getName() );
+ }
+ }
+
+ $status = self::isTagNameValid( $tag );
+ if ( !$status->isGood() ) {
+ return $status;
+ }
+
+ // does the tag already exist?
+ $tagUsage = self::tagUsageStatistics();
+ if ( isset( $tagUsage[$tag] ) || in_array( $tag, self::listDefinedTags() ) ) {
+ return Status::newFatal( 'tags-create-already-exists', $tag );
+ }
+
+ // check with hooks
+ $canCreateResult = Status::newGood();
+ Hooks::run( 'ChangeTagCanCreate', [ $tag, $user, &$canCreateResult ] );
+ return $canCreateResult;
+ }
+
+ /**
+ * Creates a tag by adding a row to the `valid_tag` table.
+ *
+ * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to
+ * do that.
+ *
+ * @param string $tag
+ * @param string $reason
+ * @param User $user Who to give credit for the action
+ * @param bool $ignoreWarnings Can be used for API interaction, default false
+ * @param array $logEntryTags Change tags to apply to the entry
+ * that will be created in the tag management log
+ * @return Status If successful, the Status contains the ID of the added log
+ * entry as its value
+ * @since 1.25
+ */
+ public static function createTagWithChecks( $tag, $reason, User $user,
+ $ignoreWarnings = false, array $logEntryTags = []
+ ) {
+ // are we allowed to do this?
+ $result = self::canCreateTag( $tag, $user );
+ if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
+ $result->value = null;
+ return $result;
+ }
+
+ // do it!
+ self::defineTag( $tag );
+
+ // log it
+ $logId = self::logTagManagementAction( 'create', $tag, $reason, $user,
+ null, $logEntryTags );
+
+ return Status::newGood( $logId );
+ }
+
+ /**
+ * Permanently removes all traces of a tag from the DB. Good for removing
+ * misspelt or temporary tags.
+ *
+ * This function should be directly called by maintenance scripts only, never
+ * by user-facing code. See deleteTagWithChecks() for functionality that can
+ * safely be exposed to users.
+ *
+ * @param string $tag Tag to remove
+ * @return Status The returned status will be good unless a hook changed it
+ * @since 1.25
+ */
+ public static function deleteTagEverywhere( $tag ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ // delete from valid_tag
+ self::undefineTag( $tag );
+
+ // find out which revisions use this tag, so we can delete from tag_summary
+ $result = $dbw->select( 'change_tag',
+ [ 'ct_rc_id', 'ct_log_id', 'ct_rev_id', 'ct_tag' ],
+ [ 'ct_tag' => $tag ],
+ __METHOD__ );
+ foreach ( $result as $row ) {
+ // remove the tag from the relevant row of tag_summary
+ $tagsToAdd = [];
+ $tagsToRemove = [ $tag ];
+ self::updateTagSummaryRow( $tagsToAdd, $tagsToRemove, $row->ct_rc_id,
+ $row->ct_rev_id, $row->ct_log_id );
+ }
+
+ // delete from change_tag
+ $dbw->delete( 'change_tag', [ 'ct_tag' => $tag ], __METHOD__ );
+
+ $dbw->endAtomic( __METHOD__ );
+
+ // give extensions a chance
+ $status = Status::newGood();
+ Hooks::run( 'ChangeTagAfterDelete', [ $tag, &$status ] );
+ // let's not allow error results, as the actual tag deletion succeeded
+ if ( !$status->isOK() ) {
+ wfDebug( 'ChangeTagAfterDelete error condition downgraded to warning' );
+ $status->setOK( true );
+ }
+
+ // clear the memcache of defined tags
+ self::purgeTagCacheAll();
+
+ return $status;
+ }
+
+ /**
+ * Is it OK to allow the user to delete this tag?
+ *
+ * @param string $tag Tag that you are interested in deleting
+ * @param User|null $user User whose permission you wish to check, or null if
+ * you don't care (e.g. maintenance scripts)
+ * @return Status
+ * @since 1.25
+ */
+ public static function canDeleteTag( $tag, User $user = null ) {
+ $tagUsage = self::tagUsageStatistics();
+
+ if ( !is_null( $user ) ) {
+ if ( !$user->isAllowed( 'deletechangetags' ) ) {
+ return Status::newFatal( 'tags-delete-no-permission' );
+ } elseif ( $user->isBlocked() ) {
+ return Status::newFatal( 'tags-manage-blocked', $user->getName() );
+ }
+ }
+
+ if ( !isset( $tagUsage[$tag] ) && !in_array( $tag, self::listDefinedTags() ) ) {
+ return Status::newFatal( 'tags-delete-not-found', $tag );
+ }
+
+ if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > self::MAX_DELETE_USES ) {
+ return Status::newFatal( 'tags-delete-too-many-uses', $tag, self::MAX_DELETE_USES );
+ }
+
+ $softwareDefined = self::listSoftwareDefinedTags();
+ if ( in_array( $tag, $softwareDefined ) ) {
+ // extension-defined tags can't be deleted unless the extension
+ // specifically allows it
+ $status = Status::newFatal( 'tags-delete-not-allowed' );
+ } else {
+ // user-defined tags are deletable unless otherwise specified
+ $status = Status::newGood();
+ }
+
+ Hooks::run( 'ChangeTagCanDelete', [ $tag, $user, &$status ] );
+ return $status;
+ }
+
+ /**
+ * Deletes a tag, checking whether it is allowed first, and adding a log entry
+ * afterwards.
+ *
+ * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to
+ * do that.
+ *
+ * @param string $tag
+ * @param string $reason
+ * @param User $user Who to give credit for the action
+ * @param bool $ignoreWarnings Can be used for API interaction, default false
+ * @param array $logEntryTags Change tags to apply to the entry
+ * that will be created in the tag management log
+ * @return Status If successful, the Status contains the ID of the added log
+ * entry as its value
+ * @since 1.25
+ */
+ public static function deleteTagWithChecks( $tag, $reason, User $user,
+ $ignoreWarnings = false, array $logEntryTags = []
+ ) {
+ // are we allowed to do this?
+ $result = self::canDeleteTag( $tag, $user );
+ if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) {
+ $result->value = null;
+ return $result;
+ }
+
+ // store the tag usage statistics
+ $tagUsage = self::tagUsageStatistics();
+ $hitcount = isset( $tagUsage[$tag] ) ? $tagUsage[$tag] : 0;
+
+ // do it!
+ $deleteResult = self::deleteTagEverywhere( $tag );
+ if ( !$deleteResult->isOK() ) {
+ return $deleteResult;
+ }
+
+ // log it
+ $logId = self::logTagManagementAction( 'delete', $tag, $reason, $user,
+ $hitcount, $logEntryTags );
+
+ $deleteResult->value = $logId;
+ return $deleteResult;
+ }
+
+ /**
+ * Lists those tags which core or extensions report as being "active".
+ *
+ * @return array
+ * @since 1.25
+ */
+ public static function listSoftwareActivatedTags() {
+ // core active tags
+ $tags = self::$coreTags;
+ if ( !Hooks::isRegistered( 'ChangeTagsListActive' ) ) {
+ return $tags;
+ }
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'active-tags' ),
+ WANObjectCache::TTL_MINUTE * 5,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags ) {
+ $setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
+
+ // Ask extensions which tags they consider active
+ Hooks::run( 'ChangeTagsListActive', [ &$tags ] );
+ return $tags;
+ },
+ [
+ 'checkKeys' => [ $cache->makeKey( 'active-tags' ) ],
+ 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
+ 'pcTTL' => WANObjectCache::TTL_PROC_LONG
+ ]
+ );
+ }
+
+ /**
+ * @see listSoftwareActivatedTags
+ * @deprecated since 1.28 call listSoftwareActivatedTags directly
+ * @return array
+ */
+ public static function listExtensionActivatedTags() {
+ wfDeprecated( __METHOD__, '1.28' );
+ return self::listSoftwareActivatedTags();
+ }
+
+ /**
+ * Basically lists defined tags which count even if they aren't applied to anything.
+ * It returns a union of the results of listExplicitlyDefinedTags() and
+ * listExtensionDefinedTags().
+ *
+ * @return string[] Array of strings: tags
+ */
+ public static function listDefinedTags() {
+ $tags1 = self::listExplicitlyDefinedTags();
+ $tags2 = self::listSoftwareDefinedTags();
+ return array_values( array_unique( array_merge( $tags1, $tags2 ) ) );
+ }
+
+ /**
+ * Lists tags explicitly defined in the `valid_tag` table of the database.
+ * Tags in table 'change_tag' which are not in table 'valid_tag' are not
+ * included.
+ *
+ * Tries memcached first.
+ *
+ * @return string[] Array of strings: tags
+ * @since 1.25
+ */
+ public static function listExplicitlyDefinedTags() {
+ $fname = __METHOD__;
+
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'valid-tags-db' ),
+ WANObjectCache::TTL_MINUTE * 5,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $setOpts += Database::getCacheSetOptions( $dbr );
+
+ $tags = $dbr->selectFieldValues( 'valid_tag', 'vt_tag', [], $fname );
+
+ return array_filter( array_unique( $tags ) );
+ },
+ [
+ 'checkKeys' => [ $cache->makeKey( 'valid-tags-db' ) ],
+ 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
+ 'pcTTL' => WANObjectCache::TTL_PROC_LONG
+ ]
+ );
+ }
+
+ /**
+ * Lists tags defined by core or extensions using the ListDefinedTags hook.
+ * Extensions need only define those tags they deem to be in active use.
+ *
+ * Tries memcached first.
+ *
+ * @return string[] Array of strings: tags
+ * @since 1.25
+ */
+ public static function listSoftwareDefinedTags() {
+ // core defined tags
+ $tags = self::$coreTags;
+ if ( !Hooks::isRegistered( 'ListDefinedTags' ) ) {
+ return $tags;
+ }
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'valid-tags-hook' ),
+ WANObjectCache::TTL_MINUTE * 5,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags ) {
+ $setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
+
+ Hooks::run( 'ListDefinedTags', [ &$tags ] );
+ return array_filter( array_unique( $tags ) );
+ },
+ [
+ 'checkKeys' => [ $cache->makeKey( 'valid-tags-hook' ) ],
+ 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
+ 'pcTTL' => WANObjectCache::TTL_PROC_LONG
+ ]
+ );
+ }
+
+ /**
+ * Call listSoftwareDefinedTags directly
+ *
+ * @see listSoftwareDefinedTags
+ * @deprecated since 1.28
+ * @return array
+ */
+ public static function listExtensionDefinedTags() {
+ wfDeprecated( __METHOD__, '1.28' );
+ return self::listSoftwareDefinedTags();
+ }
+
+ /**
+ * Invalidates the short-term cache of defined tags used by the
+ * list*DefinedTags functions, as well as the tag statistics cache.
+ * @since 1.25
+ */
+ public static function purgeTagCacheAll() {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+
+ $cache->touchCheckKey( $cache->makeKey( 'active-tags' ) );
+ $cache->touchCheckKey( $cache->makeKey( 'valid-tags-db' ) );
+ $cache->touchCheckKey( $cache->makeKey( 'valid-tags-hook' ) );
+
+ self::purgeTagUsageCache();
+ }
+
+ /**
+ * Invalidates the tag statistics cache only.
+ * @since 1.25
+ */
+ public static function purgeTagUsageCache() {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+
+ $cache->touchCheckKey( $cache->makeKey( 'change-tag-statistics' ) );
+ }
+
+ /**
+ * Returns a map of any tags used on the wiki to number of edits
+ * tagged with them, ordered descending by the hitcount.
+ * This does not include tags defined somewhere that have never been applied.
+ *
+ * Keeps a short-term cache in memory, so calling this multiple times in the
+ * same request should be fine.
+ *
+ * @return array Array of string => int
+ */
+ public static function tagUsageStatistics() {
+ $fname = __METHOD__;
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'change-tag-statistics' ),
+ WANObjectCache::TTL_MINUTE * 5,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname ) {
+ $dbr = wfGetDB( DB_REPLICA, 'vslow' );
+
+ $setOpts += Database::getCacheSetOptions( $dbr );
+
+ $res = $dbr->select(
+ 'change_tag',
+ [ 'ct_tag', 'hitcount' => 'count(*)' ],
+ [],
+ $fname,
+ [ 'GROUP BY' => 'ct_tag', 'ORDER BY' => 'hitcount DESC' ]
+ );
+
+ $out = [];
+ foreach ( $res as $row ) {
+ $out[$row->ct_tag] = $row->hitcount;
+ }
+
+ return $out;
+ },
+ [
+ 'checkKeys' => [ $cache->makeKey( 'change-tag-statistics' ) ],
+ 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
+ 'pcTTL' => WANObjectCache::TTL_PROC_LONG
+ ]
+ );
+ }
+
+ /**
+ * Indicate whether change tag editing UI is relevant
+ *
+ * Returns true if the user has the necessary right and there are any
+ * editable tags defined.
+ *
+ * This intentionally doesn't check "any addable || any deletable", because
+ * it seems like it would be more confusing than useful if the checkboxes
+ * suddenly showed up because some abuse filter stopped defining a tag and
+ * then suddenly disappeared when someone deleted all uses of that tag.
+ *
+ * @param User $user
+ * @return bool
+ */
+ public static function showTagEditingUI( User $user ) {
+ return $user->isAllowed( 'changetags' ) && (bool)self::listExplicitlyDefinedTags();
+ }
+}
diff --git a/www/wiki/includes/changetags/ChangeTagsList.php b/www/wiki/includes/changetags/ChangeTagsList.php
new file mode 100644
index 00000000..afbbb2bf
--- /dev/null
+++ b/www/wiki/includes/changetags/ChangeTagsList.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Change tagging
+ */
+
+/**
+ * Generic list for change tagging.
+ */
+abstract class ChangeTagsList extends RevisionListBase {
+ function __construct( IContextSource $context, Title $title, array $ids ) {
+ parent::__construct( $context, $title );
+ $this->ids = $ids;
+ }
+
+ /**
+ * Creates a ChangeTags*List of the requested type.
+ *
+ * @param string $typeName 'revision' or 'logentry'
+ * @param IContextSource $context
+ * @param Title $title
+ * @param array $ids
+ * @return ChangeTagsList An instance of the requested subclass
+ * @throws Exception If you give an unknown $typeName
+ */
+ public static function factory( $typeName, IContextSource $context,
+ Title $title, array $ids
+ ) {
+ switch ( $typeName ) {
+ case 'revision':
+ $className = 'ChangeTagsRevisionList';
+ break;
+ case 'logentry':
+ $className = 'ChangeTagsLogList';
+ break;
+ default:
+ throw new Exception( "Class $typeName requested, but does not exist" );
+ }
+
+ return new $className( $context, $title, $ids );
+ }
+
+ /**
+ * Reload the list data from the master DB.
+ */
+ function reloadFromMaster() {
+ $dbw = wfGetDB( DB_MASTER );
+ $this->res = $this->doQuery( $dbw );
+ }
+
+ /**
+ * Add/remove change tags from all the items in the list.
+ *
+ * @param array $tagsToAdd
+ * @param array $tagsToRemove
+ * @param array $params
+ * @param string $reason
+ * @param User $user
+ * @return Status
+ */
+ abstract function updateChangeTagsOnAll( $tagsToAdd, $tagsToRemove, $params,
+ $reason, $user );
+}
diff --git a/www/wiki/includes/changetags/ChangeTagsLogItem.php b/www/wiki/includes/changetags/ChangeTagsLogItem.php
new file mode 100644
index 00000000..b78efafa
--- /dev/null
+++ b/www/wiki/includes/changetags/ChangeTagsLogItem.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Change tagging
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Item class for a logging table row with its associated change tags.
+ * @todo Abstract out a base class for this and RevDelLogItem, similar to the
+ * RevisionItem class but specifically for log items.
+ * @since 1.25
+ */
+class ChangeTagsLogItem extends RevisionItemBase {
+ public function getIdField() {
+ return 'log_id';
+ }
+
+ public function getTimestampField() {
+ return 'log_timestamp';
+ }
+
+ public function getAuthorIdField() {
+ return 'log_user';
+ }
+
+ public function getAuthorNameField() {
+ return 'log_user_text';
+ }
+
+ public function canView() {
+ return LogEventsList::userCan( $this->row, Revision::DELETED_RESTRICTED, $this->list->getUser() );
+ }
+
+ public function canViewContent() {
+ return true; // none
+ }
+
+ /**
+ * @return string Comma-separated list of tags
+ */
+ public function getTags() {
+ return $this->row->ts_tags;
+ }
+
+ /**
+ * @return string A HTML <li> element representing this revision, showing
+ * change tags and everything
+ */
+ public function getHTML() {
+ $date = htmlspecialchars( $this->list->getLanguage()->userTimeAndDate(
+ $this->row->log_timestamp, $this->list->getUser() ) );
+ $title = Title::makeTitle( $this->row->log_namespace, $this->row->log_title );
+ $formatter = LogFormatter::newFromRow( $this->row );
+ $formatter->setContext( $this->list->getContext() );
+ $formatter->setAudience( LogFormatter::FOR_THIS_USER );
+
+ // Log link for this page
+ $loglink = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
+ SpecialPage::getTitleFor( 'Log' ),
+ $this->list->msg( 'log' )->text(),
+ [],
+ [ 'page' => $title->getPrefixedText() ]
+ );
+ $loglink = $this->list->msg( 'parentheses' )->rawParams( $loglink )->escaped();
+ // User links and action text
+ $action = $formatter->getActionText();
+ // Comment
+ $comment = $this->list->getLanguage()->getDirMark() .
+ $formatter->getComment();
+
+ if ( LogEventsList::isDeleted( $this->row, LogPage::DELETED_COMMENT ) ) {
+ $comment = '<span class="history-deleted">' . $comment . '</span>';
+ }
+
+ $content = "$loglink $date $action $comment";
+ $attribs = [];
+ $tags = $this->getTags();
+ if ( $tags ) {
+ list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow(
+ $tags,
+ 'edittags',
+ $this->list->getContext()
+ );
+ $content .= " $tagSummary";
+ $attribs['class'] = implode( ' ', $classes );
+ }
+ return Xml::tags( 'li', $attribs, $content );
+ }
+}
diff --git a/www/wiki/includes/changetags/ChangeTagsLogList.php b/www/wiki/includes/changetags/ChangeTagsLogList.php
new file mode 100644
index 00000000..e6d918a6
--- /dev/null
+++ b/www/wiki/includes/changetags/ChangeTagsLogList.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Change tagging
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Stores a list of taggable log entries.
+ * @since 1.25
+ */
+class ChangeTagsLogList extends ChangeTagsList {
+ public function getType() {
+ return 'logentry';
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return mixed
+ */
+ public function doQuery( $db ) {
+ $ids = array_map( 'intval', $this->ids );
+ $queryInfo = DatabaseLogEntry::getSelectQueryData();
+ $queryInfo['conds'] += [ 'log_id' => $ids ];
+ $queryInfo['options'] += [ 'ORDER BY' => 'log_id DESC' ];
+ ChangeTags::modifyDisplayQuery(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ $queryInfo['join_conds'],
+ $queryInfo['options'],
+ ''
+ );
+ return $db->select(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ __METHOD__,
+ $queryInfo['options'],
+ $queryInfo['join_conds']
+ );
+ }
+
+ public function newItem( $row ) {
+ return new ChangeTagsLogItem( $this, $row );
+ }
+
+ /**
+ * Add/remove change tags from all the log entries in the list.
+ *
+ * @param array $tagsToAdd
+ * @param array $tagsToRemove
+ * @param array $params
+ * @param string $reason
+ * @param User $user
+ * @return Status
+ */
+ public function updateChangeTagsOnAll( $tagsToAdd, $tagsToRemove, $params, $reason, $user ) {
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $this->reset(); $this->current(); $this->next() ) {
+ // @codingStandardsIgnoreEnd
+ $item = $this->current();
+ $status = ChangeTags::updateTagsWithChecks( $tagsToAdd, $tagsToRemove,
+ null, null, $item->getId(), $params, $reason, $user );
+ // Should only fail on second and subsequent times if the user trips
+ // the rate limiter
+ if ( !$status->isOK() ) {
+ break;
+ }
+ }
+
+ return $status;
+ }
+}
diff --git a/www/wiki/includes/changetags/ChangeTagsRevisionItem.php b/www/wiki/includes/changetags/ChangeTagsRevisionItem.php
new file mode 100644
index 00000000..2a98e20f
--- /dev/null
+++ b/www/wiki/includes/changetags/ChangeTagsRevisionItem.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Change tagging
+ */
+
+/**
+ * Item class for a live revision table row with its associated change tags.
+ * @since 1.25
+ */
+class ChangeTagsRevisionItem extends RevisionItem {
+ /**
+ * @return string Comma-separated list of tags
+ */
+ public function getTags() {
+ return $this->row->ts_tags;
+ }
+
+ /**
+ * @return string A HTML <li> element representing this revision, showing
+ * change tags and everything
+ */
+ public function getHTML() {
+ $difflink = $this->list->msg( 'parentheses' )
+ ->rawParams( $this->getDiffLink() )->escaped();
+ $revlink = $this->getRevisionLink();
+ $userlink = Linker::revUserLink( $this->revision );
+ $comment = Linker::revComment( $this->revision );
+ if ( $this->isDeleted() ) {
+ $revlink = "<span class=\"history-deleted\">$revlink</span>";
+ }
+
+ $content = "$difflink $revlink $userlink $comment";
+ $attribs = [];
+ $tags = $this->getTags();
+ if ( $tags ) {
+ list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow(
+ $tags,
+ 'edittags',
+ $this->list->getContext()
+ );
+ $content .= " $tagSummary";
+ $attribs['class'] = implode( ' ', $classes );
+ }
+ return Xml::tags( 'li', $attribs, $content );
+ }
+}
diff --git a/www/wiki/includes/changetags/ChangeTagsRevisionList.php b/www/wiki/includes/changetags/ChangeTagsRevisionList.php
new file mode 100644
index 00000000..91193b0e
--- /dev/null
+++ b/www/wiki/includes/changetags/ChangeTagsRevisionList.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Change tagging
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Stores a list of taggable revisions.
+ * @since 1.25
+ */
+class ChangeTagsRevisionList extends ChangeTagsList {
+ public function getType() {
+ return 'revision';
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return mixed
+ */
+ public function doQuery( $db ) {
+ $ids = array_map( 'intval', $this->ids );
+ $queryInfo = [
+ 'tables' => [ 'revision', 'user' ],
+ 'fields' => array_merge( Revision::selectFields(), Revision::selectUserFields() ),
+ 'conds' => [
+ 'rev_page' => $this->title->getArticleID(),
+ 'rev_id' => $ids,
+ ],
+ 'options' => [ 'ORDER BY' => 'rev_id DESC' ],
+ 'join_conds' => [
+ 'page' => Revision::pageJoinCond(),
+ 'user' => Revision::userJoinCond(),
+ ],
+ ];
+ ChangeTags::modifyDisplayQuery(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ $queryInfo['join_conds'],
+ $queryInfo['options'],
+ ''
+ );
+ return $db->select(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ __METHOD__,
+ $queryInfo['options'],
+ $queryInfo['join_conds']
+ );
+ }
+
+ public function newItem( $row ) {
+ return new ChangeTagsRevisionItem( $this, $row );
+ }
+
+ /**
+ * Add/remove change tags from all the revisions in the list.
+ *
+ * @param array $tagsToAdd
+ * @param array $tagsToRemove
+ * @param array $params
+ * @param string $reason
+ * @param User $user
+ * @return Status
+ */
+ public function updateChangeTagsOnAll( $tagsToAdd, $tagsToRemove, $params, $reason, $user ) {
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $this->reset(); $this->current(); $this->next() ) {
+ // @codingStandardsIgnoreEnd
+ $item = $this->current();
+ $status = ChangeTags::updateTagsWithChecks( $tagsToAdd, $tagsToRemove,
+ null, $item->getId(), null, $params, $reason, $user );
+ // Should only fail on second and subsequent times if the user trips
+ // the rate limiter
+ if ( !$status->isOK() ) {
+ break;
+ }
+ }
+
+ return $status;
+ }
+}
diff --git a/www/wiki/includes/clientpool/RedisConnectionPool.php b/www/wiki/includes/clientpool/RedisConnectionPool.php
new file mode 100644
index 00000000..a9bc5937
--- /dev/null
+++ b/www/wiki/includes/clientpool/RedisConnectionPool.php
@@ -0,0 +1,581 @@
+<?php
+/**
+ * Redis client connection pooling manager.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @defgroup Redis Redis
+ * @author Aaron Schulz
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Helper class to manage Redis connections.
+ *
+ * This can be used to get handle wrappers that free the handle when the wrapper
+ * leaves scope. The maximum number of free handles (connections) is configurable.
+ * This provides an easy way to cache connection handles that may also have state,
+ * such as a handle does between multi() and exec(), and without hoarding connections.
+ * The wrappers use PHP magic methods so that calling functions on them calls the
+ * function of the actual Redis object handle.
+ *
+ * @ingroup Redis
+ * @since 1.21
+ */
+class RedisConnectionPool implements LoggerAwareInterface {
+ /**
+ * @name Pool settings.
+ * Settings there are shared for any connection made in this pool.
+ * See the singleton() method documentation for more details.
+ * @{
+ */
+ /** @var string Connection timeout in seconds */
+ protected $connectTimeout;
+ /** @var string Read timeout in seconds */
+ protected $readTimeout;
+ /** @var string Plaintext auth password */
+ protected $password;
+ /** @var bool Whether connections persist */
+ protected $persistent;
+ /** @var int Serializer to use (Redis::SERIALIZER_*) */
+ protected $serializer;
+ /** @} */
+
+ /** @var int Current idle pool size */
+ protected $idlePoolSize = 0;
+
+ /** @var array (server name => ((connection info array),...) */
+ protected $connections = [];
+ /** @var array (server name => UNIX timestamp) */
+ protected $downServers = [];
+
+ /** @var array (pool ID => RedisConnectionPool) */
+ protected static $instances = [];
+
+ /** integer; seconds to cache servers as "down". */
+ const SERVER_DOWN_TTL = 30;
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * @param array $options
+ * @throws Exception
+ */
+ protected function __construct( array $options ) {
+ if ( !class_exists( 'Redis' ) ) {
+ throw new Exception( __CLASS__ . ' requires a Redis client library. ' .
+ 'See https://www.mediawiki.org/wiki/Redis#Setup' );
+ }
+ if ( isset( $options['logger'] ) ) {
+ $this->setLogger( $options['logger'] );
+ } else {
+ $this->setLogger( LoggerFactory::getInstance( 'redis' ) );
+ }
+ $this->connectTimeout = $options['connectTimeout'];
+ $this->readTimeout = $options['readTimeout'];
+ $this->persistent = $options['persistent'];
+ $this->password = $options['password'];
+ if ( !isset( $options['serializer'] ) || $options['serializer'] === 'php' ) {
+ $this->serializer = Redis::SERIALIZER_PHP;
+ } elseif ( $options['serializer'] === 'igbinary' ) {
+ $this->serializer = Redis::SERIALIZER_IGBINARY;
+ } elseif ( $options['serializer'] === 'none' ) {
+ $this->serializer = Redis::SERIALIZER_NONE;
+ } else {
+ throw new InvalidArgumentException( "Invalid serializer specified." );
+ }
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ * @return null
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ protected static function applyDefaultConfig( array $options ) {
+ if ( !isset( $options['connectTimeout'] ) ) {
+ $options['connectTimeout'] = 1;
+ }
+ if ( !isset( $options['readTimeout'] ) ) {
+ $options['readTimeout'] = 1;
+ }
+ if ( !isset( $options['persistent'] ) ) {
+ $options['persistent'] = false;
+ }
+ if ( !isset( $options['password'] ) ) {
+ $options['password'] = null;
+ }
+
+ return $options;
+ }
+
+ /**
+ * @param array $options
+ * $options include:
+ * - connectTimeout : The timeout for new connections, in seconds.
+ * Optional, default is 1 second.
+ * - readTimeout : The timeout for operation reads, in seconds.
+ * Commands like BLPOP can fail if told to wait longer than this.
+ * Optional, default is 1 second.
+ * - persistent : Set this to true to allow connections to persist across
+ * multiple web requests. False by default.
+ * - password : The authentication password, will be sent to Redis in clear text.
+ * Optional, if it is unspecified, no AUTH command will be sent.
+ * - serializer : Set to "php", "igbinary", or "none". Default is "php".
+ * @return RedisConnectionPool
+ */
+ public static function singleton( array $options ) {
+ $options = self::applyDefaultConfig( $options );
+ // Map the options to a unique hash...
+ ksort( $options ); // normalize to avoid pool fragmentation
+ $id = sha1( serialize( $options ) );
+ // Initialize the object at the hash as needed...
+ if ( !isset( self::$instances[$id] ) ) {
+ self::$instances[$id] = new self( $options );
+ LoggerFactory::getInstance( 'redis' )->debug(
+ "Creating a new " . __CLASS__ . " instance with id $id."
+ );
+ }
+
+ return self::$instances[$id];
+ }
+
+ /**
+ * Destroy all singleton() instances
+ * @since 1.27
+ */
+ public static function destroySingletons() {
+ self::$instances = [];
+ }
+
+ /**
+ * Get a connection to a redis server. Based on code in RedisBagOStuff.php.
+ *
+ * @param string $server A hostname/port combination or the absolute path of a UNIX socket.
+ * If a hostname is specified but no port, port 6379 will be used.
+ * @return RedisConnRef|bool Returns false on failure
+ * @throws MWException
+ */
+ public function getConnection( $server ) {
+ // Check the listing "dead" servers which have had a connection errors.
+ // Servers are marked dead for a limited period of time, to
+ // avoid excessive overhead from repeated connection timeouts.
+ if ( isset( $this->downServers[$server] ) ) {
+ $now = time();
+ if ( $now > $this->downServers[$server] ) {
+ // Dead time expired
+ unset( $this->downServers[$server] );
+ } else {
+ // Server is dead
+ $this->logger->debug(
+ 'Server "{redis_server}" is marked down for another ' .
+ ( $this->downServers[$server] - $now ) . 'seconds',
+ [ 'redis_server' => $server ]
+ );
+
+ return false;
+ }
+ }
+
+ // Check if a connection is already free for use
+ if ( isset( $this->connections[$server] ) ) {
+ foreach ( $this->connections[$server] as &$connection ) {
+ if ( $connection['free'] ) {
+ $connection['free'] = false;
+ --$this->idlePoolSize;
+
+ return new RedisConnRef(
+ $this, $server, $connection['conn'], $this->logger
+ );
+ }
+ }
+ }
+
+ if ( substr( $server, 0, 1 ) === '/' ) {
+ // UNIX domain socket
+ // These are required by the redis extension to start with a slash, but
+ // we still need to set the port to a special value to make it work.
+ $host = $server;
+ $port = 0;
+ } else {
+ // TCP connection
+ $hostPort = IP::splitHostAndPort( $server );
+ if ( !$server || !$hostPort ) {
+ throw new InvalidArgumentException(
+ __CLASS__ . ": invalid configured server \"$server\""
+ );
+ }
+ list( $host, $port ) = $hostPort;
+ if ( $port === false ) {
+ $port = 6379;
+ }
+ }
+
+ $conn = new Redis();
+ try {
+ if ( $this->persistent ) {
+ $result = $conn->pconnect( $host, $port, $this->connectTimeout );
+ } else {
+ $result = $conn->connect( $host, $port, $this->connectTimeout );
+ }
+ if ( !$result ) {
+ $this->logger->error(
+ 'Could not connect to server "{redis_server}"',
+ [ 'redis_server' => $server ]
+ );
+ // Mark server down for some time to avoid further timeouts
+ $this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
+
+ return false;
+ }
+ if ( $this->password !== null ) {
+ if ( !$conn->auth( $this->password ) ) {
+ $this->logger->error(
+ 'Authentication error connecting to "{redis_server}"',
+ [ 'redis_server' => $server ]
+ );
+ }
+ }
+ } catch ( RedisException $e ) {
+ $this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
+ $this->logger->error(
+ 'Redis exception connecting to "{redis_server}"',
+ [
+ 'redis_server' => $server,
+ 'exception' => $e,
+ ]
+ );
+
+ return false;
+ }
+
+ if ( $conn ) {
+ $conn->setOption( Redis::OPT_READ_TIMEOUT, $this->readTimeout );
+ $conn->setOption( Redis::OPT_SERIALIZER, $this->serializer );
+ $this->connections[$server][] = [ 'conn' => $conn, 'free' => false ];
+
+ return new RedisConnRef( $this, $server, $conn, $this->logger );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Mark a connection to a server as free to return to the pool
+ *
+ * @param string $server
+ * @param Redis $conn
+ * @return bool
+ */
+ public function freeConnection( $server, Redis $conn ) {
+ $found = false;
+
+ foreach ( $this->connections[$server] as &$connection ) {
+ if ( $connection['conn'] === $conn && !$connection['free'] ) {
+ $connection['free'] = true;
+ ++$this->idlePoolSize;
+ break;
+ }
+ }
+
+ $this->closeExcessIdleConections();
+
+ return $found;
+ }
+
+ /**
+ * Close any extra idle connections if there are more than the limit
+ */
+ protected function closeExcessIdleConections() {
+ if ( $this->idlePoolSize <= count( $this->connections ) ) {
+ return; // nothing to do (no more connections than servers)
+ }
+
+ foreach ( $this->connections as &$serverConnections ) {
+ foreach ( $serverConnections as $key => &$connection ) {
+ if ( $connection['free'] ) {
+ unset( $serverConnections[$key] );
+ if ( --$this->idlePoolSize <= count( $this->connections ) ) {
+ return; // done (no more connections than servers)
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * The redis extension throws an exception in response to various read, write
+ * and protocol errors. Sometimes it also closes the connection, sometimes
+ * not. The safest response for us is to explicitly destroy the connection
+ * object and let it be reopened during the next request.
+ *
+ * @param string $server
+ * @param RedisConnRef $cref
+ * @param RedisException $e
+ * @deprecated since 1.23
+ */
+ public function handleException( $server, RedisConnRef $cref, RedisException $e ) {
+ $this->handleError( $cref, $e );
+ }
+
+ /**
+ * The redis extension throws an exception in response to various read, write
+ * and protocol errors. Sometimes it also closes the connection, sometimes
+ * not. The safest response for us is to explicitly destroy the connection
+ * object and let it be reopened during the next request.
+ *
+ * @param RedisConnRef $cref
+ * @param RedisException $e
+ */
+ public function handleError( RedisConnRef $cref, RedisException $e ) {
+ $server = $cref->getServer();
+ $this->logger->error(
+ 'Redis exception on server "{redis_server}"',
+ [
+ 'redis_server' => $server,
+ 'exception' => $e,
+ ]
+ );
+ foreach ( $this->connections[$server] as $key => $connection ) {
+ if ( $cref->isConnIdentical( $connection['conn'] ) ) {
+ $this->idlePoolSize -= $connection['free'] ? 1 : 0;
+ unset( $this->connections[$server][$key] );
+ break;
+ }
+ }
+ }
+
+ /**
+ * Re-send an AUTH request to the redis server (useful after disconnects).
+ *
+ * This works around an upstream bug in phpredis. phpredis hides disconnects by transparently
+ * reconnecting, but it neglects to re-authenticate the new connection. To the user of the
+ * phpredis client API this manifests as a seemingly random tendency of connections to lose
+ * their authentication status.
+ *
+ * This method is for internal use only.
+ *
+ * @see https://github.com/nicolasff/phpredis/issues/403
+ *
+ * @param string $server
+ * @param Redis $conn
+ * @return bool Success
+ */
+ public function reauthenticateConnection( $server, Redis $conn ) {
+ if ( $this->password !== null ) {
+ if ( !$conn->auth( $this->password ) ) {
+ $this->logger->error(
+ 'Authentication error connecting to "{redis_server}"',
+ [ 'redis_server' => $server ]
+ );
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Adjust or reset the connection handle read timeout value
+ *
+ * @param Redis $conn
+ * @param int $timeout Optional
+ */
+ public function resetTimeout( Redis $conn, $timeout = null ) {
+ $conn->setOption( Redis::OPT_READ_TIMEOUT, $timeout ?: $this->readTimeout );
+ }
+
+ /**
+ * Make sure connections are closed for sanity
+ */
+ function __destruct() {
+ foreach ( $this->connections as $server => &$serverConnections ) {
+ foreach ( $serverConnections as $key => &$connection ) {
+ $connection['conn']->close();
+ }
+ }
+ }
+}
+
+/**
+ * Helper class to handle automatically marking connectons as reusable (via RAII pattern)
+ *
+ * This class simply wraps the Redis class and can be used the same way
+ *
+ * @ingroup Redis
+ * @since 1.21
+ */
+class RedisConnRef {
+ /** @var RedisConnectionPool */
+ protected $pool;
+ /** @var Redis */
+ protected $conn;
+
+ protected $server; // string
+ protected $lastError; // string
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * @param RedisConnectionPool $pool
+ * @param string $server
+ * @param Redis $conn
+ * @param LoggerInterface $logger
+ */
+ public function __construct(
+ RedisConnectionPool $pool, $server, Redis $conn, LoggerInterface $logger
+ ) {
+ $this->pool = $pool;
+ $this->server = $server;
+ $this->conn = $conn;
+ $this->logger = $logger;
+ }
+
+ /**
+ * @return string
+ * @since 1.23
+ */
+ public function getServer() {
+ return $this->server;
+ }
+
+ public function getLastError() {
+ return $this->lastError;
+ }
+
+ public function clearLastError() {
+ $this->lastError = null;
+ }
+
+ public function __call( $name, $arguments ) {
+ $conn = $this->conn; // convenience
+
+ // Work around https://github.com/nicolasff/phpredis/issues/70
+ $lname = strtolower( $name );
+ if ( ( $lname === 'blpop' || $lname == 'brpop' )
+ && is_array( $arguments[0] ) && isset( $arguments[1] )
+ ) {
+ $this->pool->resetTimeout( $conn, $arguments[1] + 1 );
+ } elseif ( $lname === 'brpoplpush' && isset( $arguments[2] ) ) {
+ $this->pool->resetTimeout( $conn, $arguments[2] + 1 );
+ }
+
+ $conn->clearLastError();
+ try {
+ $res = call_user_func_array( [ $conn, $name ], $arguments );
+ if ( preg_match( '/^ERR operation not permitted\b/', $conn->getLastError() ) ) {
+ $this->pool->reauthenticateConnection( $this->server, $conn );
+ $conn->clearLastError();
+ $res = call_user_func_array( [ $conn, $name ], $arguments );
+ $this->logger->info(
+ "Used automatic re-authentication for method '$name'.",
+ [ 'redis_server' => $this->server ]
+ );
+ }
+ } catch ( RedisException $e ) {
+ $this->pool->resetTimeout( $conn ); // restore
+ throw $e;
+ }
+
+ $this->lastError = $conn->getLastError() ?: $this->lastError;
+
+ $this->pool->resetTimeout( $conn ); // restore
+
+ return $res;
+ }
+
+ /**
+ * @param string $script
+ * @param array $params
+ * @param int $numKeys
+ * @return mixed
+ * @throws RedisException
+ */
+ public function luaEval( $script, array $params, $numKeys ) {
+ $sha1 = sha1( $script ); // 40 char hex
+ $conn = $this->conn; // convenience
+ $server = $this->server; // convenience
+
+ // Try to run the server-side cached copy of the script
+ $conn->clearLastError();
+ $res = $conn->evalSha( $sha1, $params, $numKeys );
+ // If we got a permission error reply that means that (a) we are not in
+ // multi()/pipeline() and (b) some connection problem likely occurred. If
+ // the password the client gave was just wrong, an exception should have
+ // been thrown back in getConnection() previously.
+ if ( preg_match( '/^ERR operation not permitted\b/', $conn->getLastError() ) ) {
+ $this->pool->reauthenticateConnection( $server, $conn );
+ $conn->clearLastError();
+ $res = $conn->eval( $script, $params, $numKeys );
+ $this->logger->info(
+ "Used automatic re-authentication for Lua script '$sha1'.",
+ [ 'redis_server' => $server ]
+ );
+ }
+ // If the script is not in cache, use eval() to retry and cache it
+ if ( preg_match( '/^NOSCRIPT/', $conn->getLastError() ) ) {
+ $conn->clearLastError();
+ $res = $conn->eval( $script, $params, $numKeys );
+ $this->logger->info(
+ "Used eval() for Lua script '$sha1'.",
+ [ 'redis_server' => $server ]
+ );
+ }
+
+ if ( $conn->getLastError() ) { // script bug?
+ $this->logger->error(
+ 'Lua script error on server "{redis_server}": {lua_error}',
+ [
+ 'redis_server' => $server,
+ 'lua_error' => $conn->getLastError()
+ ]
+ );
+ }
+
+ $this->lastError = $conn->getLastError() ?: $this->lastError;
+
+ return $res;
+ }
+
+ /**
+ * @param Redis $conn
+ * @return bool
+ */
+ public function isConnIdentical( Redis $conn ) {
+ return $this->conn === $conn;
+ }
+
+ function __destruct() {
+ $this->pool->freeConnection( $this->server, $this->conn );
+ }
+}
diff --git a/www/wiki/includes/clientpool/SquidPurgeClient.php b/www/wiki/includes/clientpool/SquidPurgeClient.php
new file mode 100644
index 00000000..f454bd4c
--- /dev/null
+++ b/www/wiki/includes/clientpool/SquidPurgeClient.php
@@ -0,0 +1,396 @@
+<?php
+/**
+ * Squid and Varnish cache purging.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * An HTTP 1.0 client built for the purposes of purging Squid and Varnish.
+ * Uses asynchronous I/O, allowing purges to be done in a highly parallel
+ * manner.
+ *
+ * Could be replaced by curl_multi_exec() or some such.
+ */
+class SquidPurgeClient {
+ /** @var string */
+ protected $host;
+
+ /** @var int */
+ protected $port;
+
+ /** @var string|bool */
+ protected $ip;
+
+ /** @var string */
+ protected $readState = 'idle';
+
+ /** @var string */
+ protected $writeBuffer = '';
+
+ /** @var array */
+ protected $requests = [];
+
+ /** @var mixed */
+ protected $currentRequestIndex;
+
+ const EINTR = 4;
+ const EAGAIN = 11;
+ const EINPROGRESS = 115;
+ const BUFFER_SIZE = 8192;
+
+ /**
+ * @var resource|null The socket resource, or null for unconnected, or false
+ * for disabled due to error.
+ */
+ protected $socket;
+
+ /** @var string */
+ protected $readBuffer;
+
+ /** @var int */
+ protected $bodyRemaining;
+
+ /**
+ * @param string $server
+ * @param array $options
+ */
+ public function __construct( $server, $options = [] ) {
+ $parts = explode( ':', $server, 2 );
+ $this->host = $parts[0];
+ $this->port = isset( $parts[1] ) ? $parts[1] : 80;
+ }
+
+ /**
+ * Open a socket if there isn't one open already, return it.
+ * Returns false on error.
+ *
+ * @return bool|resource
+ */
+ protected function getSocket() {
+ if ( $this->socket !== null ) {
+ return $this->socket;
+ }
+
+ $ip = $this->getIP();
+ if ( !$ip ) {
+ $this->log( "DNS error" );
+ $this->markDown();
+ return false;
+ }
+ $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
+ socket_set_nonblock( $this->socket );
+ MediaWiki\suppressWarnings();
+ $ok = socket_connect( $this->socket, $ip, $this->port );
+ MediaWiki\restoreWarnings();
+ if ( !$ok ) {
+ $error = socket_last_error( $this->socket );
+ if ( $error !== self::EINPROGRESS ) {
+ $this->log( "connection error: " . socket_strerror( $error ) );
+ $this->markDown();
+ return false;
+ }
+ }
+
+ return $this->socket;
+ }
+
+ /**
+ * Get read socket array for select()
+ * @return array
+ */
+ public function getReadSocketsForSelect() {
+ if ( $this->readState == 'idle' ) {
+ return [];
+ }
+ $socket = $this->getSocket();
+ if ( $socket === false ) {
+ return [];
+ }
+ return [ $socket ];
+ }
+
+ /**
+ * Get write socket array for select()
+ * @return array
+ */
+ public function getWriteSocketsForSelect() {
+ if ( !strlen( $this->writeBuffer ) ) {
+ return [];
+ }
+ $socket = $this->getSocket();
+ if ( $socket === false ) {
+ return [];
+ }
+ return [ $socket ];
+ }
+
+ /**
+ * Get the host's IP address.
+ * Does not support IPv6 at present due to the lack of a convenient interface in PHP.
+ * @throws MWException
+ * @return string
+ */
+ protected function getIP() {
+ if ( $this->ip === null ) {
+ if ( IP::isIPv4( $this->host ) ) {
+ $this->ip = $this->host;
+ } elseif ( IP::isIPv6( $this->host ) ) {
+ throw new MWException( '$wgSquidServers does not support IPv6' );
+ } else {
+ MediaWiki\suppressWarnings();
+ $this->ip = gethostbyname( $this->host );
+ if ( $this->ip === $this->host ) {
+ $this->ip = false;
+ }
+ MediaWiki\restoreWarnings();
+ }
+ }
+ return $this->ip;
+ }
+
+ /**
+ * Close the socket and ignore any future purge requests.
+ * This is called if there is a protocol error.
+ */
+ protected function markDown() {
+ $this->close();
+ $this->socket = false;
+ }
+
+ /**
+ * Close the socket but allow it to be reopened for future purge requests
+ */
+ public function close() {
+ if ( $this->socket ) {
+ MediaWiki\suppressWarnings();
+ socket_set_block( $this->socket );
+ socket_shutdown( $this->socket );
+ socket_close( $this->socket );
+ MediaWiki\restoreWarnings();
+ }
+ $this->socket = null;
+ $this->readBuffer = '';
+ // Write buffer is kept since it may contain a request for the next socket
+ }
+
+ /**
+ * Queue a purge operation
+ *
+ * @param string $url
+ */
+ public function queuePurge( $url ) {
+ global $wgSquidPurgeUseHostHeader;
+ $url = CdnCacheUpdate::expand( str_replace( "\n", '', $url ) );
+ $request = [];
+ if ( $wgSquidPurgeUseHostHeader ) {
+ $url = wfParseUrl( $url );
+ $host = $url['host'];
+ if ( isset( $url['port'] ) && strlen( $url['port'] ) > 0 ) {
+ $host .= ":" . $url['port'];
+ }
+ $path = $url['path'];
+ if ( isset( $url['query'] ) && is_string( $url['query'] ) ) {
+ $path = wfAppendQuery( $path, $url['query'] );
+ }
+ $request[] = "PURGE $path HTTP/1.1";
+ $request[] = "Host: $host";
+ } else {
+ $request[] = "PURGE $url HTTP/1.0";
+ }
+ $request[] = "Connection: Keep-Alive";
+ $request[] = "Proxy-Connection: Keep-Alive";
+ $request[] = "User-Agent: " . Http::userAgent() . ' ' . __CLASS__;
+ // Two ''s to create \r\n\r\n
+ $request[] = '';
+ $request[] = '';
+
+ $this->requests[] = implode( "\r\n", $request );
+ if ( $this->currentRequestIndex === null ) {
+ $this->nextRequest();
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function isIdle() {
+ return strlen( $this->writeBuffer ) == 0 && $this->readState == 'idle';
+ }
+
+ /**
+ * Perform pending writes. Call this when socket_select() indicates that writing will not block.
+ */
+ public function doWrites() {
+ if ( !strlen( $this->writeBuffer ) ) {
+ return;
+ }
+ $socket = $this->getSocket();
+ if ( !$socket ) {
+ return;
+ }
+
+ if ( strlen( $this->writeBuffer ) <= self::BUFFER_SIZE ) {
+ $buf = $this->writeBuffer;
+ $flags = MSG_EOR;
+ } else {
+ $buf = substr( $this->writeBuffer, 0, self::BUFFER_SIZE );
+ $flags = 0;
+ }
+ MediaWiki\suppressWarnings();
+ $bytesSent = socket_send( $socket, $buf, strlen( $buf ), $flags );
+ MediaWiki\restoreWarnings();
+
+ if ( $bytesSent === false ) {
+ $error = socket_last_error( $socket );
+ if ( $error != self::EAGAIN && $error != self::EINTR ) {
+ $this->log( 'write error: ' . socket_strerror( $error ) );
+ $this->markDown();
+ }
+ return;
+ }
+
+ $this->writeBuffer = substr( $this->writeBuffer, $bytesSent );
+ }
+
+ /**
+ * Read some data. Call this when socket_select() indicates that the read buffer is non-empty.
+ */
+ public function doReads() {
+ $socket = $this->getSocket();
+ if ( !$socket ) {
+ return;
+ }
+
+ $buf = '';
+ MediaWiki\suppressWarnings();
+ $bytesRead = socket_recv( $socket, $buf, self::BUFFER_SIZE, 0 );
+ MediaWiki\restoreWarnings();
+ if ( $bytesRead === false ) {
+ $error = socket_last_error( $socket );
+ if ( $error != self::EAGAIN && $error != self::EINTR ) {
+ $this->log( 'read error: ' . socket_strerror( $error ) );
+ $this->markDown();
+ return;
+ }
+ } elseif ( $bytesRead === 0 ) {
+ // Assume EOF
+ $this->close();
+ return;
+ }
+
+ $this->readBuffer .= $buf;
+ while ( $this->socket && $this->processReadBuffer() === 'continue' );
+ }
+
+ /**
+ * @throws MWException
+ * @return string
+ */
+ protected function processReadBuffer() {
+ switch ( $this->readState ) {
+ case 'idle':
+ return 'done';
+ case 'status':
+ case 'header':
+ $lines = explode( "\r\n", $this->readBuffer, 2 );
+ if ( count( $lines ) < 2 ) {
+ return 'done';
+ }
+ if ( $this->readState == 'status' ) {
+ $this->processStatusLine( $lines[0] );
+ } else { // header
+ $this->processHeaderLine( $lines[0] );
+ }
+ $this->readBuffer = $lines[1];
+ return 'continue';
+ case 'body':
+ if ( $this->bodyRemaining !== null ) {
+ if ( $this->bodyRemaining > strlen( $this->readBuffer ) ) {
+ $this->bodyRemaining -= strlen( $this->readBuffer );
+ $this->readBuffer = '';
+ return 'done';
+ } else {
+ $this->readBuffer = substr( $this->readBuffer, $this->bodyRemaining );
+ $this->bodyRemaining = 0;
+ $this->nextRequest();
+ return 'continue';
+ }
+ } else {
+ // No content length, read all data to EOF
+ $this->readBuffer = '';
+ return 'done';
+ }
+ default:
+ throw new MWException( __METHOD__ . ': unexpected state' );
+ }
+ }
+
+ /**
+ * @param string $line
+ */
+ protected function processStatusLine( $line ) {
+ if ( !preg_match( '!^HTTP/(\d+)\.(\d+) (\d{3}) (.*)$!', $line, $m ) ) {
+ $this->log( 'invalid status line' );
+ $this->markDown();
+ return;
+ }
+ list( , , , $status, $reason ) = $m;
+ $status = intval( $status );
+ if ( $status !== 200 && $status !== 404 ) {
+ $this->log( "unexpected status code: $status $reason" );
+ $this->markDown();
+ return;
+ }
+ $this->readState = 'header';
+ }
+
+ /**
+ * @param string $line
+ */
+ protected function processHeaderLine( $line ) {
+ if ( preg_match( '/^Content-Length: (\d+)$/i', $line, $m ) ) {
+ $this->bodyRemaining = intval( $m[1] );
+ } elseif ( $line === '' ) {
+ $this->readState = 'body';
+ }
+ }
+
+ protected function nextRequest() {
+ if ( $this->currentRequestIndex !== null ) {
+ unset( $this->requests[$this->currentRequestIndex] );
+ }
+ if ( count( $this->requests ) ) {
+ $this->readState = 'status';
+ $this->currentRequestIndex = key( $this->requests );
+ $this->writeBuffer = $this->requests[$this->currentRequestIndex];
+ } else {
+ $this->readState = 'idle';
+ $this->currentRequestIndex = null;
+ $this->writeBuffer = '';
+ }
+ $this->bodyRemaining = null;
+ }
+
+ /**
+ * @param string $msg
+ */
+ protected function log( $msg ) {
+ wfDebugLog( 'squid', __CLASS__ . " ($this->host): $msg" );
+ }
+}
diff --git a/www/wiki/includes/clientpool/SquidPurgeClientPool.php b/www/wiki/includes/clientpool/SquidPurgeClientPool.php
new file mode 100644
index 00000000..7b327d65
--- /dev/null
+++ b/www/wiki/includes/clientpool/SquidPurgeClientPool.php
@@ -0,0 +1,108 @@
+<?php
+/**
+ * Squid and Varnish cache purging.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+class SquidPurgeClientPool {
+ /** @var array Array of SquidPurgeClient */
+ protected $clients = [];
+
+ /** @var int */
+ protected $timeout = 5;
+
+ /**
+ * @param array $options
+ */
+ function __construct( $options = [] ) {
+ if ( isset( $options['timeout'] ) ) {
+ $this->timeout = $options['timeout'];
+ }
+ }
+
+ /**
+ * @param SquidPurgeClient $client
+ * @return void
+ */
+ public function addClient( $client ) {
+ $this->clients[] = $client;
+ }
+
+ public function run() {
+ $done = false;
+ $startTime = microtime( true );
+ while ( !$done ) {
+ $readSockets = $writeSockets = [];
+ /**
+ * @var $client SquidPurgeClient
+ */
+ foreach ( $this->clients as $clientIndex => $client ) {
+ $sockets = $client->getReadSocketsForSelect();
+ foreach ( $sockets as $i => $socket ) {
+ $readSockets["$clientIndex/$i"] = $socket;
+ }
+ $sockets = $client->getWriteSocketsForSelect();
+ foreach ( $sockets as $i => $socket ) {
+ $writeSockets["$clientIndex/$i"] = $socket;
+ }
+ }
+ if ( !count( $readSockets ) && !count( $writeSockets ) ) {
+ break;
+ }
+ $exceptSockets = null;
+ $timeout = min( $startTime + $this->timeout - microtime( true ), 1 );
+ MediaWiki\suppressWarnings();
+ $numReady = socket_select( $readSockets, $writeSockets, $exceptSockets, $timeout );
+ MediaWiki\restoreWarnings();
+ if ( $numReady === false ) {
+ wfDebugLog( 'squid', __METHOD__ . ': Error in stream_select: ' .
+ socket_strerror( socket_last_error() ) . "\n" );
+ break;
+ }
+ // Check for timeout, use 1% tolerance since we aimed at having socket_select()
+ // exit at precisely the overall timeout
+ if ( microtime( true ) - $startTime > $this->timeout * 0.99 ) {
+ wfDebugLog( 'squid', __CLASS__ . ": timeout ({$this->timeout}s)\n" );
+ break;
+ } elseif ( !$numReady ) {
+ continue;
+ }
+
+ foreach ( $readSockets as $key => $socket ) {
+ list( $clientIndex, ) = explode( '/', $key );
+ $client = $this->clients[$clientIndex];
+ $client->doReads();
+ }
+ foreach ( $writeSockets as $key => $socket ) {
+ list( $clientIndex, ) = explode( '/', $key );
+ $client = $this->clients[$clientIndex];
+ $client->doWrites();
+ }
+
+ $done = true;
+ foreach ( $this->clients as $client ) {
+ if ( !$client->isIdle() ) {
+ $done = false;
+ }
+ }
+ }
+ foreach ( $this->clients as $client ) {
+ $client->close();
+ }
+ }
+}
diff --git a/www/wiki/includes/collation/BashkirUppercaseCollation.php b/www/wiki/includes/collation/BashkirUppercaseCollation.php
new file mode 100644
index 00000000..33ed9bc8
--- /dev/null
+++ b/www/wiki/includes/collation/BashkirUppercaseCollation.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.30
+ *
+ * @file
+ */
+
+class BashkirUppercaseCollation extends CustomUppercaseCollation {
+
+ public function __construct() {
+ parent::__construct( [
+ 'А',
+ 'Б',
+ 'В',
+ 'Г',
+ 'Ғ',
+ 'Д',
+ 'Ҙ',
+ 'Е',
+ 'Ё',
+ 'Ж',
+ 'З',
+ 'И',
+ 'Й',
+ 'К',
+ 'Ҡ',
+ 'Л',
+ 'М',
+ 'Н',
+ 'Ң',
+ 'О',
+ 'Ө',
+ 'П',
+ 'Р',
+ 'С',
+ 'Ҫ',
+ 'Т',
+ 'У',
+ 'Ү',
+ 'Ф',
+ 'Х',
+ 'Һ',
+ 'Ц',
+ 'Ч',
+ 'Ш',
+ 'Щ',
+ 'Ъ',
+ 'Ы',
+ 'Ь',
+ 'Э',
+ 'Ә',
+ 'Ю',
+ 'Я',
+ ], Language::factory( 'ba' ) );
+ }
+}
diff --git a/www/wiki/includes/collation/Collation.php b/www/wiki/includes/collation/Collation.php
new file mode 100644
index 00000000..d009168d
--- /dev/null
+++ b/www/wiki/includes/collation/Collation.php
@@ -0,0 +1,131 @@
+<?php
+/**
+ * Database row sorting.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @since 1.16.3
+ * @author Tim Starling
+ */
+abstract class Collation {
+ private static $instance;
+
+ /**
+ * @since 1.16.3
+ * @return Collation
+ */
+ public static function singleton() {
+ if ( !self::$instance ) {
+ global $wgCategoryCollation;
+ self::$instance = self::factory( $wgCategoryCollation );
+ }
+ return self::$instance;
+ }
+
+ /**
+ * @since 1.16.3
+ * @throws MWException
+ * @param string $collationName
+ * @return Collation
+ */
+ public static function factory( $collationName ) {
+ global $wgContLang;
+
+ switch ( $collationName ) {
+ case 'uppercase':
+ return new UppercaseCollation;
+ case 'numeric':
+ return new NumericUppercaseCollation( $wgContLang );
+ case 'identity':
+ return new IdentityCollation;
+ case 'uca-default':
+ return new IcuCollation( 'root' );
+ case 'uca-default-u-kn':
+ return new IcuCollation( 'root-u-kn' );
+ case 'xx-uca-ckb':
+ return new CollationCkb;
+ case 'xx-uca-et':
+ return new CollationEt;
+ case 'xx-uca-fa':
+ return new CollationFa;
+ case 'uppercase-ba':
+ return new BashkirUppercaseCollation;
+ default:
+ $match = [];
+ if ( preg_match( '/^uca-([A-Za-z@=-]+)$/', $collationName, $match ) ) {
+ return new IcuCollation( $match[1] );
+ }
+
+ # Provide a mechanism for extensions to hook in.
+ $collationObject = null;
+ Hooks::run( 'Collation::factory', [ $collationName, &$collationObject ] );
+
+ if ( $collationObject instanceof Collation ) {
+ return $collationObject;
+ }
+
+ // If all else fails...
+ throw new MWException( __METHOD__ . ": unknown collation type \"$collationName\"" );
+ }
+ }
+
+ /**
+ * Given a string, convert it to a (hopefully short) key that can be used
+ * for efficient sorting. A binary sort according to the sortkeys
+ * corresponds to a logical sort of the corresponding strings. Current
+ * code expects that a line feed character should sort before all others, but
+ * has no other particular expectations (and that one can be changed if
+ * necessary).
+ *
+ * @since 1.16.3
+ *
+ * @param string $string UTF-8 string
+ * @return string Binary sortkey
+ */
+ abstract function getSortKey( $string );
+
+ /**
+ * Given a string, return the logical "first letter" to be used for
+ * grouping on category pages and so on. This has to be coordinated
+ * carefully with convertToSortkey(), or else the sorted list might jump
+ * back and forth between the same "initial letters" or other pathological
+ * behavior. For instance, if you just return the first character, but "a"
+ * sorts the same as "A" based on getSortKey(), then you might get a
+ * list like
+ *
+ * == A ==
+ * * [[Aardvark]]
+ *
+ * == a ==
+ * * [[antelope]]
+ *
+ * == A ==
+ * * [[Ape]]
+ *
+ * etc., assuming for the sake of argument that $wgCapitalLinks is false.
+ *
+ * @since 1.16.3
+ *
+ * @param string $string UTF-8 string
+ * @return string UTF-8 string corresponding to the first letter of input
+ */
+ abstract function getFirstLetter( $string );
+
+}
diff --git a/www/wiki/includes/collation/CollationCkb.php b/www/wiki/includes/collation/CollationCkb.php
new file mode 100644
index 00000000..01a4f7f5
--- /dev/null
+++ b/www/wiki/includes/collation/CollationCkb.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Workaround for the lack of support of Sorani Kurdish / Central Kurdish language ('ckb') in ICU.
+ *
+ * Uses the same collation rules as Persian / Farsi ('fa'), but different characters for digits.
+ *
+ * @since 1.23
+ */
+class CollationCkb extends IcuCollation {
+ public function __construct() {
+ // This will set $locale and collators, which affect the actual sorting order
+ parent::__construct( 'fa' );
+ // Override the 'fa' language set by parent constructor, which affects #getFirstLetterData()
+ $this->digitTransformLanguage = Language::factory( 'ckb' );
+ }
+}
diff --git a/www/wiki/includes/collation/CollationEt.php b/www/wiki/includes/collation/CollationEt.php
new file mode 100644
index 00000000..ca7b7653
--- /dev/null
+++ b/www/wiki/includes/collation/CollationEt.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Workaround for incorrect collation of Estonian language ('et') in ICU (T56168).
+ *
+ * 'W' and 'V' should not be considered the same letter for the purposes of collation in modern
+ * Estonian. We work around this by replacing 'W' and 'w' with 'ᴡ' U+1D21 'LATIN LETTER SMALL
+ * CAPITAL W' for sortkey generation, which is collated like 'W' and is not tailored to have the
+ * same primary weight as 'V' in Estonian.
+ *
+ * @since 1.24
+ */
+class CollationEt extends IcuCollation {
+ public function __construct() {
+ parent::__construct( 'et' );
+ }
+
+ private static function mangle( $string ) {
+ return str_replace(
+ [ 'w', 'W' ],
+ 'ᴡ', // U+1D21 'LATIN LETTER SMALL CAPITAL W'
+ $string
+ );
+ }
+
+ private static function unmangle( $string ) {
+ // Casing data is lost…
+ return str_replace(
+ 'ᴡ', // U+1D21 'LATIN LETTER SMALL CAPITAL W'
+ 'W',
+ $string
+ );
+ }
+
+ public function getSortKey( $string ) {
+ return parent::getSortKey( self::mangle( $string ) );
+ }
+
+ public function getFirstLetter( $string ) {
+ return self::unmangle( parent::getFirstLetter( self::mangle( $string ) ) );
+ }
+}
diff --git a/www/wiki/includes/collation/CollationFa.php b/www/wiki/includes/collation/CollationFa.php
new file mode 100644
index 00000000..7410886e
--- /dev/null
+++ b/www/wiki/includes/collation/CollationFa.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Temporary workaround for incorrect collation of Persian language ('fa') in ICU 52 (bug T139110).
+ *
+ * Replace with other letters that appear in an okish spot in the alphabet
+ *
+ * - Characters 'و' 'ا' (often appear at the beginning of words)
+ * - Characters 'ٲ' 'ٳ' (may appear at the beginning of words in loanwords)
+ *
+ * @since 1.29
+ */
+class CollationFa extends IcuCollation {
+
+ // Really hacky - replace with stuff from other blocks.
+ private $override = [
+ // U+0627 ARABIC LETTER ALEF => U+0623 ARABIC LETTER ALEF WITH HAMZA ABOVE
+ "\xd8\xa7" => "\xd8\xa3",
+ // U+0648 ARABIC LETTER WAW => U+0649 ARABIC LETTER ALEF MAKSURA
+ "\xd9\x88" => "\xd9\x89",
+ // U+0672 ARABIC LETTER ALEF WITH WAVY HAMZA ABOVE => U+F3001 (private use area)
+ "\xd9\xb2" => "\xF3\xB3\x80\x81",
+ // U+0673 ARABIC LETTER ALEF WITH WAVY HAMZA BELOW => U+F3002 (private use area)
+ "\xd9\xb3" => "\xF3\xB3\x80\x82",
+ ];
+
+ public function __construct() {
+ parent::__construct( 'fa' );
+ }
+
+ public function getSortKey( $string ) {
+ $modified = strtr( $string, $this->override );
+ return parent::getSortKey( $modified );
+ }
+
+ public function getFirstLetter( $string ) {
+ if ( isset( $this->override[substr( $string, 0, 2 )] ) ) {
+ return substr( $string, 0, 2 );
+ }
+ return parent::getFirstLetter( $string );
+ }
+}
diff --git a/www/wiki/includes/collation/CustomUppercaseCollation.php b/www/wiki/includes/collation/CustomUppercaseCollation.php
new file mode 100644
index 00000000..301972d9
--- /dev/null
+++ b/www/wiki/includes/collation/CustomUppercaseCollation.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.30
+ *
+ * @file
+ */
+
+/**
+ * Resort normal UTF-8 order by putting a bunch of stuff in PUA
+ *
+ * This takes a bunch of characters (The alphabet) that should,
+ * be together, and converts them all to private-use-area characters
+ * so that they are all sorted in the right order relative to each
+ * other.
+ *
+ * This renumbers characters starting at U+F3000 (Chosen to avoid
+ * conflicts with other people using private use area)
+ *
+ * This does not support fancy things like secondary differences, etc.
+ *
+ * It is expected most people will subclass this and just override the
+ * constructor to hard-code an alphabet.
+ */
+class CustomUppercaseCollation extends NumericUppercaseCollation {
+
+ /** @var array $alphabet Sorted array of letters */
+ private $alphabet;
+
+ /** @var array $puaSubset List of private use area codes */
+ private $puaSubset;
+
+ /**
+ * @note This assumes $alphabet does not contain U+F3000-U+F303F
+ *
+ * @param array $alphabet Sorted array of uppercase characters.
+ * @param Language $lang What language for number sorting.
+ */
+ public function __construct( array $alphabet, Language $lang ) {
+ // It'd be trivial to extend this past 64, you'd just
+ // need a bit of bit-fiddling. Doesn't seem necessary right
+ // now.
+ if ( count( $alphabet ) < 1 || count( $alphabet ) >= 64 ) {
+ throw new UnexpectedValueException( "Alphabet must be < 64 items" );
+ }
+ $this->alphabet = $alphabet;
+
+ $this->puaSubset = [];
+ $len = count( $alphabet );
+ for ( $i = 0; $i < $len; $i++ ) {
+ $this->puaSubset[] = "\xF3\xB3\x80" . chr( $i + 128 );
+ }
+ parent::__construct( $lang );
+ }
+
+ private function convertToPua( $string ) {
+ return str_replace( $this->alphabet, $this->puaSubset, $string );
+ }
+
+ public function getSortKey( $string ) {
+ return $this->convertToPua( parent::getSortKey( $string ) );
+ }
+
+ public function getFirstLetter( $string ) {
+ // In case a title has a PUA code in it, make it sort
+ // under the header for the character it would replace
+ // to avoid inconsistent behaviour. This class mostly
+ // assumes that people will not use PUA codes.
+ return parent::getFirstLetter(
+ str_replace( $this->puaSubset, $this->alphabet, $string )
+ );
+ }
+}
diff --git a/www/wiki/includes/collation/IcuCollation.php b/www/wiki/includes/collation/IcuCollation.php
new file mode 100644
index 00000000..efda5963
--- /dev/null
+++ b/www/wiki/includes/collation/IcuCollation.php
@@ -0,0 +1,579 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @since 1.16.3
+ */
+class IcuCollation extends Collation {
+ const FIRST_LETTER_VERSION = 3;
+
+ /** @var Collator */
+ private $primaryCollator;
+
+ /** @var Collator */
+ private $mainCollator;
+
+ /** @var string */
+ private $locale;
+
+ /** @var Language */
+ protected $digitTransformLanguage;
+
+ /** @var bool */
+ private $useNumericCollation = false;
+
+ /** @var array */
+ private $firstLetterData;
+
+ /**
+ * Unified CJK blocks.
+ *
+ * The same definition of a CJK block must be used for both Collation and
+ * generateCollationData.php. These blocks are omitted from the first
+ * letter data, as an optimisation measure and because the default UCA table
+ * is pretty useless for sorting Chinese text anyway. Japanese and Korean
+ * blocks are not included here, because they are smaller and more useful.
+ */
+ private static $cjkBlocks = [
+ [ 0x2E80, 0x2EFF ], // CJK Radicals Supplement
+ [ 0x2F00, 0x2FDF ], // Kangxi Radicals
+ [ 0x2FF0, 0x2FFF ], // Ideographic Description Characters
+ [ 0x3000, 0x303F ], // CJK Symbols and Punctuation
+ [ 0x31C0, 0x31EF ], // CJK Strokes
+ [ 0x3200, 0x32FF ], // Enclosed CJK Letters and Months
+ [ 0x3300, 0x33FF ], // CJK Compatibility
+ [ 0x3400, 0x4DBF ], // CJK Unified Ideographs Extension A
+ [ 0x4E00, 0x9FFF ], // CJK Unified Ideographs
+ [ 0xF900, 0xFAFF ], // CJK Compatibility Ideographs
+ [ 0xFE30, 0xFE4F ], // CJK Compatibility Forms
+ [ 0x20000, 0x2A6DF ], // CJK Unified Ideographs Extension B
+ [ 0x2A700, 0x2B73F ], // CJK Unified Ideographs Extension C
+ [ 0x2B740, 0x2B81F ], // CJK Unified Ideographs Extension D
+ [ 0x2F800, 0x2FA1F ], // CJK Compatibility Ideographs Supplement
+ ];
+
+ /**
+ * Additional characters (or character groups) to be considered separate
+ * letters for given languages, or to be removed from the list of such
+ * letters (denoted by keys starting with '-').
+ *
+ * These are additions to (or subtractions from) the data stored in the
+ * first-letters-root.ser file (which among others includes full basic latin,
+ * cyrillic and greek alphabets).
+ *
+ * "Separate letter" is a letter that would have a separate heading/section
+ * for it in a dictionary or a phone book in this language. This data isn't
+ * used for sorting (the ICU library handles that), only for deciding which
+ * characters (or character groups) to use as headings.
+ *
+ * Initially generated based on the primary level of Unicode collation
+ * tailorings available at http://developer.mimer.com/charts/tailorings.htm ,
+ * later modified.
+ *
+ * Empty arrays are intended; this signifies that the data for the language is
+ * available and that there are, in fact, no additional letters to consider.
+ */
+ private static $tailoringFirstLetters = [
+ 'af' => [],
+ 'am' => [],
+ 'ar' => [],
+ 'as' => [ "\xe0\xa6\x82", "\xe0\xa6\x81", "\xe0\xa6\x83", "\xe0\xa7\x8e", "ক্ষ " ],
+ 'ast' => [ "Ch", "Ll", "Ñ" ], // not in libicu
+ 'az' => [ "Ç", "Ə", "Ğ", "İ", "Ö", "Ş", "Ü" ],
+ 'be' => [ "Ё" ],
+ 'be-tarask' => [ "Ё" ],
+ 'bg' => [],
+ 'bn' => [ 'ং', 'ঃ', 'ঁ' ],
+ 'bn@collation=traditional' => [
+ 'ং', 'ঃ', 'ঁ', 'ক্', 'খ্', 'গ্', 'ঘ্', 'ঙ্', 'চ্', 'ছ্', 'জ্', 'ঝ্',
+ 'ঞ্', 'ট্', 'ঠ্', 'ড্', 'ঢ্', 'ণ্', 'ৎ', 'থ্', 'দ্', 'ধ্', 'ন্', 'প্',
+ 'ফ্', 'ব্', 'ভ্', 'ম্', 'য্', 'র্', 'ৰ্', 'ল্', 'ৱ্', 'শ্', 'ষ্', 'স্', 'হ্'
+ ],
+ 'bo' => [],
+ 'br' => [ "Ch", "C'h" ],
+ 'bs' => [ "Č", "Ć", "Dž", "Đ", "Lj", "Nj", "Š", "Ž" ],
+ 'bs-Cyrl' => [],
+ 'ca' => [],
+ 'chr' => [],
+ 'co' => [], // not in libicu
+ 'cs' => [ "Č", "Ch", "Ř", "Š", "Ž" ],
+ 'cy' => [ "Ch", "Dd", "Ff", "Ng", "Ll", "Ph", "Rh", "Th" ],
+ 'da' => [ "Æ", "Ø", "Å" ],
+ 'de' => [],
+ 'de-AT@collation=phonebook' => [ 'ä', 'ö', 'ü', 'ß' ],
+ 'dsb' => [ "Č", "Ć", "Dź", "Ě", "Ch", "Ł", "Ń", "Ŕ", "Š", "Ś", "Ž", "Ź" ],
+ 'ee' => [ "Dz", "Ɖ", "Ɛ", "Ƒ", "Gb", "Ɣ", "Kp", "Ny", "Ŋ", "Ɔ", "Ts", "Ʋ" ],
+ 'el' => [],
+ 'en' => [],
+ 'eo' => [ "Ĉ", "Ĝ", "Ĥ", "Ĵ", "Ŝ", "Ŭ" ],
+ 'es' => [ "Ñ" ],
+ 'et' => [ "Š", "Ž", "Õ", "Ä", "Ö", "Ü", "W" ], // added W for CollationEt (xx-uca-et)
+ 'eu' => [ "Ñ" ], // not in libicu
+ 'fa' => [
+ // RTL, let's put each letter on a new line
+ "آ",
+ "ء",
+ "ه",
+ "ا",
+ "و"
+ ],
+ 'fi' => [ "Å", "Ä", "Ö" ],
+ 'fil' => [ "Ñ", "Ng" ],
+ 'fo' => [ "Á", "Ð", "Í", "Ó", "Ú", "Ý", "Æ", "Ø", "Å" ],
+ 'fr' => [],
+ 'fr-CA' => [], // fr-CA sorts accents slightly different from fr.
+ 'fur' => [ "À", "Á", "Â", "È", "Ì", "Ò", "Ù" ], // not in libicu
+ 'fy' => [], // not in libicu
+ 'ga' => [],
+ 'gd' => [], // not in libicu
+ 'gl' => [ "Ch", "Ll", "Ñ" ],
+ 'gu' => [ "\xe0\xaa\x82", "\xe0\xaa\x83", "\xe0\xaa\x81", "\xe0\xaa\xb3" ],
+ 'ha' => [ 'Ɓ', 'Ɗ', 'Ƙ', 'Sh', 'Ts', 'Ƴ' ],
+ 'haw' => [ 'ʻ' ],
+ 'he' => [],
+ 'hi' => [ "\xe0\xa4\x82", "\xe0\xa4\x83" ],
+ 'hr' => [ "Č", "Ć", "Dž", "Đ", "Lj", "Nj", "Š", "Ž" ],
+ 'hsb' => [ "Č", "Dź", "Ě", "Ch", "Ł", "Ń", "Ř", "Š", "Ć", "Ž" ],
+ 'hu' => [ "Cs", "Dz", "Dzs", "Gy", "Ly", "Ny", "Ö", "Sz", "Ty", "Ü", "Zs" ],
+ 'hy' => [ "և" ],
+ 'id' => [],
+ 'ig' => [ "Ch", "Gb", "Gh", "Gw", "Ị", "Kp", "Kw", "Ṅ", "Nw", "Ny", "Ọ", "Sh", "Ụ" ],
+ 'is' => [ "Á", "Ð", "É", "Í", "Ó", "Ú", "Ý", "Þ", "Æ", "Ö", "Å" ],
+ 'it' => [],
+ 'ka' => [],
+ 'kk' => [ "Ү", "І" ],
+ 'kl' => [ "Æ", "Ø", "Å" ],
+ 'km' => [
+ "រ", "ឫ", "ឬ", "ល", "ឭ", "ឮ", "\xe1\x9e\xbb\xe1\x9f\x86",
+ "\xe1\x9f\x86", "\xe1\x9e\xb6\xe1\x9f\x86", "\xe1\x9f\x87",
+ "\xe1\x9e\xb7\xe1\x9f\x87", "\xe1\x9e\xbb\xe1\x9f\x87",
+ "\xe1\x9f\x81\xe1\x9f\x87", "\xe1\x9f\x84\xe1\x9f\x87",
+ ],
+ 'kn' => [ "\xe0\xb2\x81", "\xe0\xb2\x83", "\xe0\xb3\xb1", "\xe0\xb3\xb2" ],
+ 'kok' => [ "\xe0\xa4\x82", "\xe0\xa4\x83", "ळ", "क्ष" ],
+ 'ku' => [ "Ç", "Ê", "Î", "Ş", "Û" ], // not in libicu
+ 'ky' => [ "Ё" ],
+ 'la' => [], // not in libicu
+ 'lb' => [],
+ 'lkt' => [ 'Č', 'Ǧ', 'Ȟ', 'Š', 'Ž' ],
+ 'ln' => [ 'Ɛ' ],
+ 'lo' => [],
+ 'lt' => [ "Č", "Š", "Ž" ],
+ 'lv' => [ "Č", "Ģ", "Ķ", "Ļ", "Ņ", "Š", "Ž" ],
+ 'mk' => [ "Ѓ", "Ќ" ],
+ 'ml' => [],
+ 'mn' => [],
+ 'mo' => [ "Ă", "Â", "Î", "Ș", "Ț" ], // not in libicu
+ 'mr' => [ "\xe0\xa4\x82", "\xe0\xa4\x83", "ळ", "क्ष", "ज्ञ" ],
+ 'ms' => [],
+ 'mt' => [ "Ċ", "Ġ", "Għ", "Ħ", "Ż" ],
+ 'nb' => [ "Æ", "Ø", "Å" ],
+ 'ne' => [],
+ 'nl' => [],
+ 'nn' => [ "Æ", "Ø", "Å" ],
+ 'no' => [ "Æ", "Ø", "Å" ], // not in libicu. You should probably use nb or nn instead.
+ 'oc' => [], // not in libicu
+ 'om' => [ 'Ch', 'Dh', 'Kh', 'Ny', 'Ph', 'Sh' ],
+ 'or' => [ "\xe0\xac\x81", "\xe0\xac\x82", "\xe0\xac\x83", "କ୍ଷ" ],
+ 'pa' => [ "\xe0\xa9\x8d" ],
+ 'pl' => [ "Ą", "Ć", "Ę", "Ł", "Ń", "Ó", "Ś", "Ź", "Ż" ],
+ 'pt' => [],
+ 'rm' => [], // not in libicu
+ 'ro' => [ "Ă", "Â", "Î", "Ș", "Ț" ],
+ 'ru' => [],
+ 'rup' => [ "Ă", "Â", "Î", "Ľ", "Ń", "Ș", "Ț" ], // not in libicu
+ 'sco' => [],
+ 'se' => [
+ 'Á', 'Č', 'Ʒ', 'Ǯ', 'Đ', 'Ǧ', 'Ǥ', 'Ǩ', 'Ŋ',
+ 'Š', 'Ŧ', 'Ž', 'Ø', 'Æ', 'Ȧ', 'Ä', 'Ö'
+ ],
+ 'si' => [ "\xe0\xb6\x82", "\xe0\xb6\x83", "\xe0\xb6\xa4" ],
+ 'sk' => [ "Ä", "Č", "Ch", "Ô", "Š", "Ž" ],
+ 'sl' => [ "Č", "Š", "Ž" ],
+ 'smn' => [ "Á", "Č", "Đ", "Ŋ", "Š", "Ŧ", "Ž", "Æ", "Ø", "Å", "Ä", "Ö" ],
+ 'sq' => [ "Ç", "Dh", "Ë", "Gj", "Ll", "Nj", "Rr", "Sh", "Th", "Xh", "Zh" ],
+ 'sr' => [],
+ 'sr-Latn' => [ "Č", "Ć", "Dž", "Đ", "Lj", "Nj", "Š", "Ž" ],
+ 'sv' => [ "Å", "Ä", "Ö" ],
+ 'sv@collation=standard' => [ "Å", "Ä", "Ö" ],
+ 'sw' => [],
+ 'ta' => [
+ "\xE0\xAE\x82", "ஃ", "க்ஷ", "க்", "ங்", "ச்", "ஞ்", "ட்", "ண்", "த்", "ந்",
+ "ப்", "ம்", "ய்", "ர்", "ல்", "வ்", "ழ்", "ள்", "ற்", "ன்", "ஜ்", "ஶ்", "ஷ்",
+ "ஸ்", "ஹ்", "க்ஷ்"
+ ],
+ 'te' => [ "\xe0\xb0\x81", "\xe0\xb0\x82", "\xe0\xb0\x83" ],
+ 'th' => [ "ฯ", "\xe0\xb9\x86", "\xe0\xb9\x8d", "\xe0\xb8\xba" ],
+ 'tk' => [ "Ç", "Ä", "Ž", "Ň", "Ö", "Ş", "Ü", "Ý" ],
+ 'tl' => [ "Ñ", "Ng" ], // not in libicu
+ 'to' => [ "Ng", "ʻ" ],
+ 'tr' => [ "Ç", "Ğ", "İ", "Ö", "Ş", "Ü" ],
+ 'tt' => [ "Ә", "Ө", "Ү", "Җ", "Ң", "Һ" ], // not in libicu
+ 'uk' => [ "Ґ", "Ь" ],
+ 'uz' => [ "Ch", "G'", "Ng", "O'", "Sh" ], // not in libicu
+ 'vi' => [ "Ă", "Â", "Đ", "Ê", "Ô", "Ơ", "Ư" ],
+ 'vo' => [ "Ä", "Ö", "Ü" ],
+ 'yi' => [
+ "\xd7\x91\xd6\xbf", "\xd7\x9b\xd6\xbc", "\xd7\xa4\xd6\xbc",
+ "\xd7\xa9\xd7\x82", "\xd7\xaa\xd6\xbc"
+ ],
+ 'yo' => [ "Ẹ", "Gb", "Ọ", "Ṣ" ],
+ 'zu' => [],
+ ];
+
+ /**
+ * @since 1.16.3
+ */
+ const RECORD_LENGTH = 14;
+
+ public function __construct( $locale ) {
+ if ( !extension_loaded( 'intl' ) ) {
+ throw new MWException( 'An ICU collation was requested, ' .
+ 'but the intl extension is not available.' );
+ }
+
+ $this->locale = $locale;
+ // Drop everything after the '@' in locale's name
+ $localeParts = explode( '@', $locale );
+ $this->digitTransformLanguage = Language::factory( $locale === 'root' ? 'en' : $localeParts[0] );
+
+ $this->mainCollator = Collator::create( $locale );
+ if ( !$this->mainCollator ) {
+ throw new MWException( "Invalid ICU locale specified for collation: $locale" );
+ }
+
+ $this->primaryCollator = Collator::create( $locale );
+ $this->primaryCollator->setStrength( Collator::PRIMARY );
+
+ // If the special suffix for numeric collation is present, turn on numeric collation.
+ if ( substr( $locale, -5, 5 ) === '-u-kn' ) {
+ $this->useNumericCollation = true;
+ // Strip off the special suffix so it doesn't trip up fetchFirstLetterData().
+ $this->locale = substr( $this->locale, 0, -5 );
+ $this->mainCollator->setAttribute( Collator::NUMERIC_COLLATION, Collator::ON );
+ $this->primaryCollator->setAttribute( Collator::NUMERIC_COLLATION, Collator::ON );
+ }
+ }
+
+ public function getSortKey( $string ) {
+ return $this->mainCollator->getSortKey( $string );
+ }
+
+ public function getPrimarySortKey( $string ) {
+ return $this->primaryCollator->getSortKey( $string );
+ }
+
+ public function getFirstLetter( $string ) {
+ $string = strval( $string );
+ if ( $string === '' ) {
+ return '';
+ }
+
+ $firstChar = mb_substr( $string, 0, 1, 'UTF-8' );
+
+ // If the first character is a CJK character, just return that character.
+ if ( ord( $firstChar ) > 0x7f && self::isCjk( UtfNormal\Utils::utf8ToCodepoint( $firstChar ) ) ) {
+ return $firstChar;
+ }
+
+ $sortKey = $this->getPrimarySortKey( $string );
+
+ // Do a binary search to find the correct letter to sort under
+ $min = ArrayUtils::findLowerBound(
+ [ $this, 'getSortKeyByLetterIndex' ],
+ $this->getFirstLetterCount(),
+ 'strcmp',
+ $sortKey );
+
+ if ( $min === false ) {
+ // Before the first letter
+ return '';
+ }
+
+ $sortLetter = $this->getLetterByIndex( $min );
+
+ if ( $this->useNumericCollation ) {
+ // If the sort letter is a number, return '0–9' (or localized equivalent).
+ // ASCII value of 0 is 48. ASCII value of 9 is 57.
+ // Note that this also applies to non-Arabic numerals since they are
+ // mapped to Arabic numeral sort letters. For example, ২ sorts as 2.
+ if ( ord( $sortLetter ) >= 48 && ord( $sortLetter ) <= 57 ) {
+ $sortLetter = wfMessage( 'category-header-numerals' )->numParams( 0, 9 )->text();
+ }
+ }
+ return $sortLetter;
+ }
+
+ /**
+ * @since 1.16.3
+ * @return array
+ */
+ public function getFirstLetterData() {
+ if ( $this->firstLetterData === null ) {
+ $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
+ $cacheKey = $cache->makeKey(
+ 'first-letters',
+ static::class,
+ $this->locale,
+ $this->digitTransformLanguage->getCode(),
+ self::getICUVersion(),
+ self::FIRST_LETTER_VERSION
+ );
+ $this->firstLetterData = $cache->getWithSetCallback( $cacheKey, $cache::TTL_WEEK, function () {
+ return $this->fetchFirstLetterData();
+ } );
+ }
+ return $this->firstLetterData;
+ }
+
+ /**
+ * @return array
+ * @throws MWException
+ */
+ private function fetchFirstLetterData() {
+ // Generate data from serialized data file
+ if ( isset( self::$tailoringFirstLetters[$this->locale] ) ) {
+ $letters = wfGetPrecompiledData( 'first-letters-root.ser' );
+ // Append additional characters
+ $letters = array_merge( $letters, self::$tailoringFirstLetters[$this->locale] );
+ // Remove unnecessary ones, if any
+ if ( isset( self::$tailoringFirstLetters['-' . $this->locale] ) ) {
+ $letters = array_diff( $letters, self::$tailoringFirstLetters['-' . $this->locale] );
+ }
+ // Apply digit transforms
+ $digits = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' ];
+ $letters = array_diff( $letters, $digits );
+ foreach ( $digits as $digit ) {
+ $letters[] = $this->digitTransformLanguage->formatNum( $digit, true );
+ }
+ } else {
+ $letters = wfGetPrecompiledData( "first-letters-{$this->locale}.ser" );
+ if ( $letters === false ) {
+ throw new MWException( "MediaWiki does not support ICU locale " .
+ "\"{$this->locale}\"" );
+ }
+ }
+
+ /* Sort the letters.
+ *
+ * It's impossible to have the precompiled data file properly sorted,
+ * because the sort order changes depending on ICU version. If the
+ * array is not properly sorted, the binary search will return random
+ * results.
+ *
+ * We also take this opportunity to remove primary collisions.
+ */
+ $letterMap = [];
+ foreach ( $letters as $letter ) {
+ $key = $this->getPrimarySortKey( $letter );
+ if ( isset( $letterMap[$key] ) ) {
+ // Primary collision
+ // Keep whichever one sorts first in the main collator
+ if ( $this->mainCollator->compare( $letter, $letterMap[$key] ) < 0 ) {
+ $letterMap[$key] = $letter;
+ }
+ } else {
+ $letterMap[$key] = $letter;
+ }
+ }
+ ksort( $letterMap, SORT_STRING );
+
+ /* Remove duplicate prefixes. Basically if something has a sortkey
+ * which is a prefix of some other sortkey, then it is an
+ * expansion and probably should not be considered a section
+ * header.
+ *
+ * For example 'þ' is sometimes sorted as if it is the letters
+ * 'th'. Other times it is its own primary element. Another
+ * example is '₨'. Sometimes its a currency symbol. Sometimes it
+ * is an 'R' followed by an 's'.
+ *
+ * Additionally an expanded element should always sort directly
+ * after its first element due to they way sortkeys work.
+ *
+ * UCA sortkey elements are of variable length but no collation
+ * element should be a prefix of some other element, so I think
+ * this is safe. See:
+ * - https://ssl.icu-project.org/repos/icu/icuhtml/trunk/design/collation/ICU_collation_design.htm
+ * - http://site.icu-project.org/design/collation/uca-weight-allocation
+ *
+ * Additionally, there is something called primary compression to
+ * worry about. Basically, if you have two primary elements that
+ * are more than one byte and both start with the same byte then
+ * the first byte is dropped on the second primary. Additionally
+ * either \x03 or \xFF may be added to mean that the next primary
+ * does not start with the first byte of the first primary.
+ *
+ * This shouldn't matter much, as the first primary is not
+ * changed, and that is what we are comparing against.
+ *
+ * tl;dr: This makes some assumptions about how icu implements
+ * collations. It seems incredibly unlikely these assumptions
+ * will change, but nonetheless they are assumptions.
+ */
+
+ $prev = false;
+ $duplicatePrefixes = [];
+ foreach ( $letterMap as $key => $value ) {
+ // Remove terminator byte. Otherwise the prefix
+ // comparison will get hung up on that.
+ $trimmedKey = rtrim( $key, "\0" );
+ if ( $prev === false || $prev === '' ) {
+ $prev = $trimmedKey;
+ // We don't yet have a collation element
+ // to compare against, so continue.
+ continue;
+ }
+
+ // Due to the fact the array is sorted, we only have
+ // to compare with the element directly previous
+ // to the current element (skipping expansions).
+ // An element "X" will always sort directly
+ // before "XZ" (Unless we have "XY", but we
+ // do not update $prev in that case).
+ if ( substr( $trimmedKey, 0, strlen( $prev ) ) === $prev ) {
+ $duplicatePrefixes[] = $key;
+ // If this is an expansion, we don't want to
+ // compare the next element to this element,
+ // but to what is currently $prev
+ continue;
+ }
+ $prev = $trimmedKey;
+ }
+ foreach ( $duplicatePrefixes as $badKey ) {
+ wfDebug( "Removing '{$letterMap[$badKey]}' from first letters.\n" );
+ unset( $letterMap[$badKey] );
+ // This code assumes that unsetting does not change sort order.
+ }
+ $data = [
+ 'chars' => array_values( $letterMap ),
+ 'keys' => array_keys( $letterMap ),
+ ];
+
+ // Reduce memory usage before caching
+ unset( $letterMap );
+
+ return $data;
+ }
+
+ /**
+ * @param string $index
+ * @return string
+ * @since 1.16.3
+ */
+ public function getLetterByIndex( $index ) {
+ return $this->getFirstLetterData()['chars'][$index];
+ }
+
+ /**
+ * @param string $index
+ * @return string
+ * @since 1.16.3
+ */
+ public function getSortKeyByLetterIndex( $index ) {
+ return $this->getFirstLetterData()['keys'][$index];
+ }
+
+ /**
+ * @param string $index
+ * @return string
+ * @since 1.16.3
+ */
+ public function getFirstLetterCount() {
+ return count( $this->getFirstLetterData()['chars'] );
+ }
+
+ /**
+ * Test if a code point is a CJK (Chinese, Japanese, Korean) character
+ * @param int $codepoint
+ * @return bool
+ * @since 1.16.3
+ */
+ public static function isCjk( $codepoint ) {
+ foreach ( self::$cjkBlocks as $block ) {
+ if ( $codepoint >= $block[0] && $codepoint <= $block[1] ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Return the version of ICU library used by PHP's intl extension,
+ * or false when the extension is not installed of the version
+ * can't be determined.
+ *
+ * The constant INTL_ICU_VERSION this function refers to isn't really
+ * documented. It is available since PHP 5.3.7 (see PHP 54561
+ * https://bugs.php.net/bug.php?id=54561). This function will return
+ * false on older PHPs.
+ *
+ * TODO: Remove the backwards-compatibility as MediaWiki now requires
+ * higher levels of PHP.
+ *
+ * @since 1.21
+ * @return string|bool
+ */
+ static function getICUVersion() {
+ return defined( 'INTL_ICU_VERSION' ) ? INTL_ICU_VERSION : false;
+ }
+
+ /**
+ * Return the version of Unicode appropriate for the version of ICU library
+ * currently in use, or false when it can't be determined.
+ *
+ * @since 1.21
+ * @return string|bool
+ */
+ static function getUnicodeVersionForICU() {
+ $icuVersion = self::getICUVersion();
+ if ( !$icuVersion ) {
+ return false;
+ }
+
+ $versionPrefix = substr( $icuVersion, 0, 3 );
+ // Source: http://site.icu-project.org/download
+ $map = [
+ '57.' => '8.0',
+ '56.' => '8.0',
+ '55.' => '7.0',
+ '54.' => '7.0',
+ '53.' => '6.3',
+ '52.' => '6.3',
+ '51.' => '6.2',
+ '50.' => '6.2',
+ '49.' => '6.1',
+ '4.8' => '6.0',
+ '4.6' => '6.0',
+ '4.4' => '5.2',
+ '4.2' => '5.1',
+ '4.0' => '5.1',
+ '3.8' => '5.0',
+ '3.6' => '5.0',
+ '3.4' => '4.1',
+ ];
+
+ if ( isset( $map[$versionPrefix] ) ) {
+ return $map[$versionPrefix];
+ } else {
+ return false;
+ }
+ }
+}
diff --git a/www/wiki/includes/collation/IdentityCollation.php b/www/wiki/includes/collation/IdentityCollation.php
new file mode 100644
index 00000000..46e7f38f
--- /dev/null
+++ b/www/wiki/includes/collation/IdentityCollation.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Collation class that's essentially a no-op.
+ *
+ * Does sorting based on binary value of the string.
+ * Like how things were pre 1.17.
+ *
+ * @since 1.18
+ */
+class IdentityCollation extends Collation {
+
+ public function getSortKey( $string ) {
+ return $string;
+ }
+
+ public function getFirstLetter( $string ) {
+ global $wgContLang;
+ // Copied from UppercaseCollation.
+ // I'm kind of unclear on when this could happen...
+ if ( $string[0] == "\0" ) {
+ $string = substr( $string, 1 );
+ }
+ return $wgContLang->firstChar( $string );
+ }
+}
diff --git a/www/wiki/includes/collation/NumericUppercaseCollation.php b/www/wiki/includes/collation/NumericUppercaseCollation.php
new file mode 100644
index 00000000..da78a051
--- /dev/null
+++ b/www/wiki/includes/collation/NumericUppercaseCollation.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Collation that orders text with numbers "naturally", so that 'Foo 1' < 'Foo 2' < 'Foo 12'.
+ *
+ * Note that this only works in terms of sequences of digits, and the behavior for decimal fractions
+ * or pretty-formatted numbers may be unexpected.
+ *
+ * Digits will be based on the wiki's content language settings. If
+ * you change the content langauge of a wiki you will need to run
+ * updateCollation.php --force. Only English (ASCII 0-9) and the
+ * localized version will be counted. Localized digits from other languages
+ * or weird unicode digit equivalents (e.g. 4, 𝟜, ⓸ , ⁴, etc) will not count.
+ *
+ * @since 1.28
+ */
+class NumericUppercaseCollation extends UppercaseCollation {
+
+ /**
+ * @var $digitTransformLang Language How to convert digits (usually $wgContLang)
+ */
+ private $digitTransformLang;
+
+ /**
+ * @param Language $lang How to convert digits.
+ * For example, if given language "my" than ၇ is treated like 7.
+ *
+ * It is expected that usually this is given $wgContLang.
+ */
+ public function __construct( Language $lang ) {
+ $this->digitTransformLang = $lang;
+ parent::__construct();
+ }
+
+ public function getSortKey( $string ) {
+ $sortkey = parent::getSortKey( $string );
+ $sortkey = $this->convertDigits( $sortkey );
+ // For each sequence of digits, insert the digit '0' and then the length of the sequence
+ // (encoded in two bytes) before it. That's all folks, it sorts correctly now! The '0' ensures
+ // correct position (where digits would normally sort), then the length will be compared putting
+ // shorter numbers before longer ones; if identical, then the characters will be compared, which
+ // generates the correct results for numbers of equal length.
+ $sortkey = preg_replace_callback( '/\d+/', function ( $matches ) {
+ // Strip any leading zeros
+ $number = ltrim( $matches[0], '0' );
+ $len = strlen( $number );
+ // This allows sequences of up to 65536 numeric characters to be handled correctly. One byte
+ // would allow only for 256, which doesn't feel future-proof.
+ $prefix = chr( floor( $len / 256 ) ) . chr( $len % 256 );
+ return '0' . $prefix . $number;
+ }, $sortkey );
+
+ return $sortkey;
+ }
+
+ /**
+ * Convert localized digits to english digits.
+ *
+ * based on Language::parseFormattedNumber but without commas.
+ *
+ * @param string $string sortkey to unlocalize digits of
+ * @return string Sortkey with all localized digits replaced with ASCII digits.
+ */
+ private function convertDigits( $string ) {
+ $table = $this->digitTransformLang->digitTransformTable();
+ if ( $table ) {
+ $table = array_filter( $table );
+ $flipped = array_flip( $table );
+ // Some languages seem to also have commas in this table.
+ $flipped = array_filter( $flipped, 'is_numeric' );
+ $string = strtr( $string, $flipped );
+ }
+ return $string;
+ }
+
+ public function getFirstLetter( $string ) {
+ $convertedString = $this->convertDigits( $string );
+
+ if ( preg_match( '/^\d/', $convertedString ) ) {
+ return wfMessage( 'category-header-numerals' )
+ ->numParams( 0, 9 )
+ ->text();
+ } else {
+ return parent::getFirstLetter( $string );
+ }
+ }
+}
diff --git a/www/wiki/includes/collation/UppercaseCollation.php b/www/wiki/includes/collation/UppercaseCollation.php
new file mode 100644
index 00000000..92a4c3b4
--- /dev/null
+++ b/www/wiki/includes/collation/UppercaseCollation.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.16.3
+ *
+ * @file
+ */
+
+class UppercaseCollation extends Collation {
+
+ private $lang;
+
+ public function __construct() {
+ // Get a language object so that we can use the generic UTF-8 uppercase
+ // function there
+ $this->lang = Language::factory( 'en' );
+ }
+
+ public function getSortKey( $string ) {
+ return $this->lang->uc( $string );
+ }
+
+ public function getFirstLetter( $string ) {
+ if ( $string[0] == "\0" ) {
+ $string = substr( $string, 1 );
+ }
+ return $this->lang->ucfirst( $this->lang->firstChar( $string ) );
+ }
+
+}
diff --git a/www/wiki/includes/compat/CdbCompat.php b/www/wiki/includes/compat/CdbCompat.php
new file mode 100644
index 00000000..0074cc96
--- /dev/null
+++ b/www/wiki/includes/compat/CdbCompat.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/***
+ * This file contains a set of backwards-compatability class names
+ * after the cdb functions were moved out into a separate library
+ * and put under a proper namespace
+ *
+ * @since 1.25
+ */
+
+/**
+ * @deprecated since 1.25
+ */
+abstract class CdbReader extends \Cdb\Reader {
+}
+
+/**
+ * @deprecated since 1.25
+ */
+abstract class CdbWriter extends \Cdb\Writer {
+}
+
+/**
+ * @deprecated since 1.25
+ */
+class CdbException extends \Cdb\Exception {
+}
diff --git a/www/wiki/includes/compat/IPSetCompat.php b/www/wiki/includes/compat/IPSetCompat.php
new file mode 100644
index 00000000..79c60004
--- /dev/null
+++ b/www/wiki/includes/compat/IPSetCompat.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Backward-compatibility alias for IPSet, which was moved out
+ * into an external library and namespaced.
+ *
+ * @deprecated since 1.26 use IPSet\IPSet directly
+ */
+class IPSet extends IPSet\IPSet {
+}
diff --git a/www/wiki/includes/compat/MemcachedClientCompat.php b/www/wiki/includes/compat/MemcachedClientCompat.php
new file mode 100644
index 00000000..23047339
--- /dev/null
+++ b/www/wiki/includes/compat/MemcachedClientCompat.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Backward-compatibility alias for MemcachedClient
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.27
+ * @file
+ */
+
+/**
+ * @deprecated since 1.27
+ */
+class MWMemcached extends MemcachedClient {
+}
+
+/**
+ * @deprecated since 1.27
+ */
+class MemCachedClientforWiki extends MWMemcached {
+}
diff --git a/www/wiki/includes/compat/RunningStatCompat.php b/www/wiki/includes/compat/RunningStatCompat.php
new file mode 100644
index 00000000..ac82f44d
--- /dev/null
+++ b/www/wiki/includes/compat/RunningStatCompat.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Backward-compatibility alias for RunningStat, which was moved out
+ * into an external library and namespaced.
+ *
+ * @deprecated since 1.27 use RunningStat\RunningStat directly
+ */
+class RunningStat extends RunningStat\RunningStat {
+}
diff --git a/www/wiki/includes/compat/ScopedCallback.php b/www/wiki/includes/compat/ScopedCallback.php
new file mode 100644
index 00000000..4fd4bc79
--- /dev/null
+++ b/www/wiki/includes/compat/ScopedCallback.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Compatibility class for pre-namespace, pre-library class name
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @deprecated since 1.28 use Wikimedia\ScopedCallback
+ *
+ * @since 1.21
+ */
+class ScopedCallback extends Wikimedia\ScopedCallback {
+}
diff --git a/www/wiki/includes/compat/Timestamp.php b/www/wiki/includes/compat/Timestamp.php
new file mode 100644
index 00000000..bd254327
--- /dev/null
+++ b/www/wiki/includes/compat/Timestamp.php
@@ -0,0 +1,18 @@
+<?php
+// This file is loaded by composer.json#autoload.files instead of autoload.php,
+// because PHP's class loader does not support autoloading an alias for a class that
+// isn't already loaded. See also AutoLoaderTest and ClassCollector.
+
+// By using an autoload file, this will trigger directly at runtime outside any class
+// loading context. This file will then register the alias and, as class_alias() does
+// by default, it will trigger a plain autoload for the destination class.
+
+// The below uses string concatenation for the alias to avoid being seen by ClassCollector,
+// which would insist on adding it to autoload.php, after which AutoLoaderTest will
+// complain about class_alias() not being in the target class file.
+
+/**
+ * @deprecated since 1.29
+ * @since 1.20
+ */
+class_alias( Wikimedia\Timestamp\TimestampException::class, 'Timestamp' . 'Exception' );
diff --git a/www/wiki/includes/compat/normal/UtfNormal.php b/www/wiki/includes/compat/normal/UtfNormal.php
new file mode 100644
index 00000000..9b35cad4
--- /dev/null
+++ b/www/wiki/includes/compat/normal/UtfNormal.php
@@ -0,0 +1,129 @@
+<?php
+/**
+ * Unicode normalization routines
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup UtfNormal
+ */
+
+/**
+ * @defgroup UtfNormal UtfNormal
+ */
+
+use UtfNormal\Validator;
+
+/**
+ * Unicode normalization routines for working with UTF-8 strings.
+ * Currently assumes that input strings are valid UTF-8!
+ *
+ * Not as fast as I'd like, but should be usable for most purposes.
+ * UtfNormal::toNFC() will bail early if given ASCII text or text
+ * it can quickly determine is already normalized.
+ *
+ * All functions can be called static.
+ *
+ * See description of forms at http://www.unicode.org/reports/tr15/
+ *
+ * @deprecated since 1.25, use UtfNormal\Validator directly
+ * @ingroup UtfNormal
+ */
+class UtfNormal {
+ /**
+ * The ultimate convenience function! Clean up invalid UTF-8 sequences,
+ * and convert to normal form C, canonical composition.
+ *
+ * Fast return for pure ASCII strings; some lesser optimizations for
+ * strings containing only known-good characters. Not as fast as toNFC().
+ *
+ * @param string $string a UTF-8 string
+ * @return string a clean, shiny, normalized UTF-8 string
+ */
+ static function cleanUp( $string ) {
+ return Validator::cleanUp( $string );
+ }
+
+ /**
+ * Convert a UTF-8 string to normal form C, canonical composition.
+ * Fast return for pure ASCII strings; some lesser optimizations for
+ * strings containing only known-good characters.
+ *
+ * @param string $string a valid UTF-8 string. Input is not validated.
+ * @return string a UTF-8 string in normal form C
+ */
+ static function toNFC( $string ) {
+ return Validator::toNFC( $string );
+ }
+
+ /**
+ * Convert a UTF-8 string to normal form D, canonical decomposition.
+ * Fast return for pure ASCII strings.
+ *
+ * @param string $string a valid UTF-8 string. Input is not validated.
+ * @return string a UTF-8 string in normal form D
+ */
+ static function toNFD( $string ) {
+ return Validator::toNFD( $string );
+ }
+
+ /**
+ * Convert a UTF-8 string to normal form KC, compatibility composition.
+ * This may cause irreversible information loss, use judiciously.
+ * Fast return for pure ASCII strings.
+ *
+ * @param string $string a valid UTF-8 string. Input is not validated.
+ * @return string a UTF-8 string in normal form KC
+ */
+ static function toNFKC( $string ) {
+ return Validator::toNFKC( $string );
+ }
+
+ /**
+ * Convert a UTF-8 string to normal form KD, compatibility decomposition.
+ * This may cause irreversible information loss, use judiciously.
+ * Fast return for pure ASCII strings.
+ *
+ * @param string $string a valid UTF-8 string. Input is not validated.
+ * @return string a UTF-8 string in normal form KD
+ */
+ static function toNFKD( $string ) {
+ return Validator::toNFKD( $string );
+ }
+
+ /**
+ * Returns true if the string is _definitely_ in NFC.
+ * Returns false if not or uncertain.
+ * @param string $string a valid UTF-8 string. Input is not validated.
+ * @return bool
+ */
+ static function quickIsNFC( $string ) {
+ return Validator::quickIsNFC( $string );
+ }
+
+ /**
+ * Returns true if the string is _definitely_ in NFC.
+ * Returns false if not or uncertain.
+ * @param string &$string a UTF-8 string, altered on output to be valid UTF-8 safe for XML.
+ * @return bool
+ */
+ static function quickIsNFCVerify( &$string ) {
+ return Validator::quickIsNFCVerify( $string );
+ }
+}
diff --git a/www/wiki/includes/compat/normal/UtfNormalDefines.php b/www/wiki/includes/compat/normal/UtfNormalDefines.php
new file mode 100644
index 00000000..38ce8550
--- /dev/null
+++ b/www/wiki/includes/compat/normal/UtfNormalDefines.php
@@ -0,0 +1,186 @@
+<?php
+/**
+ * Backwards-compatability constants which are now provided by the
+ * UtfNormal library. They are hardcoded here since they are needed
+ * before the composer autoloader is initialized.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup UtfNormal
+ */
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_FIRST', 0xac00 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_LAST', 0xd7a3 );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_LBASE', 0x1100 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_VBASE', 0x1161 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_TBASE', 0x11a7 );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_LCOUNT', 19 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_VCOUNT', 21 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_TCOUNT', 28 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_NCOUNT', UNICODE_HANGUL_VCOUNT * UNICODE_HANGUL_TCOUNT );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_LEND', UNICODE_HANGUL_LBASE + UNICODE_HANGUL_LCOUNT - 1 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_VEND', UNICODE_HANGUL_VBASE + UNICODE_HANGUL_VCOUNT - 1 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_HANGUL_TEND', UNICODE_HANGUL_TBASE + UNICODE_HANGUL_TCOUNT - 1 );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_SURROGATE_FIRST', 0xd800 );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_SURROGATE_LAST', 0xdfff );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_MAX', 0x10ffff );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UNICODE_REPLACEMENT', 0xfffd );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HANGUL_FIRST', "\xea\xb0\x80" /*codepointToUtf8( UNICODE_HANGUL_FIRST )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HANGUL_LAST', "\xed\x9e\xa3" /*codepointToUtf8( UNICODE_HANGUL_LAST )*/ );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HANGUL_LBASE', "\xe1\x84\x80" /*codepointToUtf8( UNICODE_HANGUL_LBASE )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HANGUL_VBASE', "\xe1\x85\xa1" /*codepointToUtf8( UNICODE_HANGUL_VBASE )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HANGUL_TBASE', "\xe1\x86\xa7" /*codepointToUtf8( UNICODE_HANGUL_TBASE )*/ );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HANGUL_LEND', "\xe1\x84\x92" /*codepointToUtf8( UNICODE_HANGUL_LEND )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HANGUL_VEND', "\xe1\x85\xb5" /*codepointToUtf8( UNICODE_HANGUL_VEND )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HANGUL_TEND', "\xe1\x87\x82" /*codepointToUtf8( UNICODE_HANGUL_TEND )*/ );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_SURROGATE_FIRST', "\xed\xa0\x80" /*codepointToUtf8( UNICODE_SURROGATE_FIRST )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_SURROGATE_LAST', "\xed\xbf\xbf" /*codepointToUtf8( UNICODE_SURROGATE_LAST )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_MAX', "\xf4\x8f\xbf\xbf" /*codepointToUtf8( UNICODE_MAX )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_REPLACEMENT', "\xef\xbf\xbd" /*codepointToUtf8( UNICODE_REPLACEMENT )*/ );
+# define( 'UTF8_REPLACEMENT', '!' );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_OVERLONG_A', "\xc1\xbf" );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_OVERLONG_B', "\xe0\x9f\xbf" );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_OVERLONG_C', "\xf0\x8f\xbf\xbf" );
+
+# These two ranges are illegal
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_FDD0', "\xef\xb7\x90" /*codepointToUtf8( 0xfdd0 )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_FDEF', "\xef\xb7\xaf" /*codepointToUtf8( 0xfdef )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_FFFE', "\xef\xbf\xbe" /*codepointToUtf8( 0xfffe )*/ );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_FFFF', "\xef\xbf\xbf" /*codepointToUtf8( 0xffff )*/ );
+
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_HEAD', false );
+/**
+ * @deprecated since 1.25, use UtfNormal\Constants instead
+ */
+define( 'UTF8_TAIL', true );
diff --git a/www/wiki/includes/compat/normal/UtfNormalUtil.php b/www/wiki/includes/compat/normal/UtfNormalUtil.php
new file mode 100644
index 00000000..d60c8e33
--- /dev/null
+++ b/www/wiki/includes/compat/normal/UtfNormalUtil.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * Some of these functions are adapted from places in MediaWiki.
+ * Should probably merge them for consistency.
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup UtfNormal
+ */
+
+use UtfNormal\Utils;
+
+/**
+ * Return UTF-8 sequence for a given Unicode code point.
+ *
+ * @param int $codepoint
+ * @return string
+ * @throws InvalidArgumentException if fed out of range data.
+ * @public
+ * @deprecated since 1.25, use UtfNormal\Utils directly
+ */
+function codepointToUtf8( $codepoint ) {
+ return Utils::codepointToUtf8( $codepoint );
+}
+
+/**
+ * Take a series of space-separated hexadecimal numbers representing
+ * Unicode code points and return a UTF-8 string composed of those
+ * characters. Used by UTF-8 data generation and testing routines.
+ *
+ * @param string $sequence
+ * @return string
+ * @throws InvalidArgumentException if fed out of range data.
+ * @private
+ * @deprecated since 1.25, use UtfNormal\Utils directly
+ */
+function hexSequenceToUtf8( $sequence ) {
+ return Utils::hexSequenceToUtf8( $sequence );
+}
+
+/**
+ * Take a UTF-8 string and return a space-separated series of hex
+ * numbers representing Unicode code points. For debugging.
+ *
+ * @fixme this is private but extensions + maint scripts are using it
+ * @param string $str UTF-8 string.
+ * @return string
+ * @private
+ */
+function utf8ToHexSequence( $str ) {
+ $buf = '';
+ foreach ( preg_split( '//u', $str, -1, PREG_SPLIT_NO_EMPTY ) as $cp ) {
+ $buf .= sprintf( '%04x ', UtfNormal\Utils::utf8ToCodepoint( $cp ) );
+ }
+
+ return rtrim( $buf );
+}
+
+/**
+ * Determine the Unicode codepoint of a single-character UTF-8 sequence.
+ * Does not check for invalid input data.
+ *
+ * @param string $char
+ * @return int
+ * @public
+ * @deprecated since 1.25, use UtfNormal\Utils directly
+ */
+function utf8ToCodepoint( $char ) {
+ return Utils::utf8ToCodepoint( $char );
+}
+
+/**
+ * Escape a string for inclusion in a PHP single-quoted string literal.
+ *
+ * @param string $string string to be escaped.
+ * @return string escaped string.
+ * @public
+ * @deprecated since 1.25, use UtfNormal\Utils directly
+ */
+function escapeSingleString( $string ) {
+ return Utils::escapeSingleString( $string );
+}
diff --git a/www/wiki/includes/composer/ComposerHookHandler.php b/www/wiki/includes/composer/ComposerHookHandler.php
new file mode 100644
index 00000000..2587b1d8
--- /dev/null
+++ b/www/wiki/includes/composer/ComposerHookHandler.php
@@ -0,0 +1,37 @@
+<?php
+
+use Composer\Package\Package;
+use Composer\Script\Event;
+
+$GLOBALS['IP'] = __DIR__ . '/../../';
+require_once __DIR__ . '/../AutoLoader.php';
+
+/**
+ * @licence GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class ComposerHookHandler {
+
+ public static function onPreUpdate( Event $event ) {
+ self::handleChangeEvent( $event );
+ }
+
+ public static function onPreInstall( Event $event ) {
+ self::handleChangeEvent( $event );
+ }
+
+ private static function handleChangeEvent( Event $event ) {
+ $package = $event->getComposer()->getPackage();
+
+ if ( $package instanceof Package ) {
+ $packageModifier = new ComposerPackageModifier(
+ $package,
+ new ComposerVersionNormalizer(),
+ new MediaWikiVersionFetcher()
+ );
+
+ $packageModifier->setProvidesMediaWiki();
+ }
+ }
+
+}
diff --git a/www/wiki/includes/composer/ComposerPackageModifier.php b/www/wiki/includes/composer/ComposerPackageModifier.php
new file mode 100644
index 00000000..9f603947
--- /dev/null
+++ b/www/wiki/includes/composer/ComposerPackageModifier.php
@@ -0,0 +1,62 @@
+<?php
+
+use Composer\Package\Link;
+use Composer\Package\Package;
+use Composer\Semver\Constraint\Constraint;
+
+/**
+ * @licence GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class ComposerPackageModifier {
+
+ const MEDIAWIKI_PACKAGE_NAME = 'mediawiki/mediawiki';
+
+ protected $package;
+ protected $versionNormalizer;
+ protected $versionFetcher;
+
+ public function __construct( Package $package,
+ ComposerVersionNormalizer $versionNormalizer, MediaWikiVersionFetcher $versionFetcher
+ ) {
+ $this->package = $package;
+ $this->versionNormalizer = $versionNormalizer;
+ $this->versionFetcher = $versionFetcher;
+ }
+
+ public function setProvidesMediaWiki() {
+ $this->setLinkAsProvides( $this->newMediaWikiLink() );
+ }
+
+ private function setLinkAsProvides( Link $link ) {
+ $this->package->setProvides( [ $link ] );
+ }
+
+ private function newMediaWikiLink() {
+ $version = $this->getMediaWikiVersionConstraint();
+
+ $link = new Link(
+ '__root__',
+ self::MEDIAWIKI_PACKAGE_NAME,
+ $version,
+ 'provides',
+ $version->getPrettyString()
+ );
+
+ return $link;
+ }
+
+ private function getMediaWikiVersionConstraint() {
+ $mvVersion = $this->versionFetcher->fetchVersion();
+ $mvVersion = $this->versionNormalizer->normalizeSuffix( $mvVersion );
+
+ $version = new Constraint(
+ '==',
+ $this->versionNormalizer->normalizeLevelCount( $mvVersion )
+ );
+ $version->setPrettyString( $mvVersion );
+
+ return $version;
+ }
+
+}
diff --git a/www/wiki/includes/composer/ComposerVendorHtaccessCreator.php b/www/wiki/includes/composer/ComposerVendorHtaccessCreator.php
new file mode 100644
index 00000000..1e5efdf1
--- /dev/null
+++ b/www/wiki/includes/composer/ComposerVendorHtaccessCreator.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * Copyright (C) 2017 Kunal Mehta <legoktm@member.fsf.org>
+ *
+ * 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.
+ *
+ */
+
+/**
+ * Creates a .htaccess in the vendor/ directory
+ * to prevent web access.
+ *
+ * This class runs *outside* of the normal MediaWiki
+ * environment and cannot depend upon any MediaWiki
+ * code.
+ */
+class ComposerVendorHtaccessCreator {
+
+ /**
+ * Handle post-install-cmd and post-update-cmd hooks
+ */
+ public static function onEvent() {
+ $fname = dirname( dirname( __DIR__ ) ) . "/vendor/.htaccess";
+ if ( file_exists( $fname ) ) {
+ // Already exists
+ return;
+ }
+
+ file_put_contents( $fname, "Deny from all\n" );
+ }
+}
diff --git a/www/wiki/includes/composer/ComposerVersionNormalizer.php b/www/wiki/includes/composer/ComposerVersionNormalizer.php
new file mode 100644
index 00000000..a0d31cf2
--- /dev/null
+++ b/www/wiki/includes/composer/ComposerVersionNormalizer.php
@@ -0,0 +1,66 @@
+<?php
+
+/**
+ * @licence GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class ComposerVersionNormalizer {
+
+ /**
+ * Ensures there is a dash in between the version and the stability suffix.
+ *
+ * Examples:
+ * - 1.23RC => 1.23-RC
+ * - 1.23alpha => 1.23-alpha
+ * - 1.23alpha3 => 1.23-alpha3
+ * - 1.23-beta => 1.23-beta
+ *
+ * @param string $version
+ *
+ * @return string
+ * @throws InvalidArgumentException
+ */
+ public function normalizeSuffix( $version ) {
+ if ( !is_string( $version ) ) {
+ throw new InvalidArgumentException( '$version must be a string' );
+ }
+
+ return preg_replace( '/^(\d[\d\.]*)([a-zA-Z]+)(\d*)$/', '$1-$2$3', $version, 1 );
+ }
+
+ /**
+ * Ensures the version has four levels.
+ * Version suffixes are supported, as long as they start with a dash.
+ *
+ * Examples:
+ * - 1.19 => 1.19.0.0
+ * - 1.19.2.3 => 1.19.2.3
+ * - 1.19-alpha => 1.19.0.0-alpha
+ * - 1337 => 1337.0.0.0
+ *
+ * @param string $version
+ *
+ * @return string
+ * @throws InvalidArgumentException
+ */
+ public function normalizeLevelCount( $version ) {
+ if ( !is_string( $version ) ) {
+ throw new InvalidArgumentException( '$version must be a string' );
+ }
+
+ $dashPosition = strpos( $version, '-' );
+
+ if ( $dashPosition !== false ) {
+ $suffix = substr( $version, $dashPosition );
+ $version = substr( $version, 0, $dashPosition );
+ }
+
+ $version = implode( '.', array_pad( explode( '.', $version ), 4, '0' ) );
+
+ if ( $dashPosition !== false ) {
+ $version .= $suffix;
+ }
+
+ return $version;
+ }
+}
diff --git a/www/wiki/includes/config/Config.php b/www/wiki/includes/config/Config.php
new file mode 100644
index 00000000..38f589dc
--- /dev/null
+++ b/www/wiki/includes/config/Config.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Interface for configuration instances
+ *
+ * @since 1.23
+ */
+interface Config {
+
+ /**
+ * Get a configuration variable such as "Sitename" or "UploadMaintenance."
+ *
+ * @param string $name Name of configuration option
+ * @return mixed Value configured
+ * @throws ConfigException
+ */
+ public function get( $name );
+
+ /**
+ * Check whether a configuration option is set for the given name
+ *
+ * @param string $name Name of configuration option
+ * @return bool
+ * @since 1.24
+ */
+ public function has( $name );
+}
diff --git a/www/wiki/includes/config/ConfigException.php b/www/wiki/includes/config/ConfigException.php
new file mode 100644
index 00000000..3b3ba9de
--- /dev/null
+++ b/www/wiki/includes/config/ConfigException.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Exceptions for config failures
+ *
+ * @since 1.23
+ */
+class ConfigException extends Exception {
+}
diff --git a/www/wiki/includes/config/ConfigFactory.php b/www/wiki/includes/config/ConfigFactory.php
new file mode 100644
index 00000000..cd25352d
--- /dev/null
+++ b/www/wiki/includes/config/ConfigFactory.php
@@ -0,0 +1,150 @@
+<?php
+
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\Services\SalvageableService;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Factory class to create Config objects
+ *
+ * @since 1.23
+ */
+class ConfigFactory implements SalvageableService {
+
+ /**
+ * Map of config name => callback
+ * @var array
+ */
+ protected $factoryFunctions = [];
+
+ /**
+ * Config objects that have already been created
+ * name => Config object
+ * @var array
+ */
+ protected $configs = [];
+
+ /**
+ * @deprecated since 1.27, use MediaWikiServices::getConfigFactory() instead.
+ *
+ * @return ConfigFactory
+ */
+ public static function getDefaultInstance() {
+ return \MediaWiki\MediaWikiServices::getInstance()->getConfigFactory();
+ }
+
+ /**
+ * Re-uses existing Cache objects from $other. Cache objects are only re-used if the
+ * registered factory function for both is the same. Cache config is not copied,
+ * and only instances of caches defined on this instance with the same config
+ * are copied.
+ *
+ * @see SalvageableService::salvage()
+ *
+ * @param SalvageableService $other The object to salvage state from. $other must have the
+ * exact same type as $this.
+ */
+ public function salvage( SalvageableService $other ) {
+ Assert::parameterType( self::class, $other, '$other' );
+
+ /** @var ConfigFactory $other */
+ foreach ( $other->factoryFunctions as $name => $otherFunc ) {
+ if ( !isset( $this->factoryFunctions[$name] ) ) {
+ continue;
+ }
+
+ // if the callback function is the same, salvage the Cache object
+ // XXX: Closures are never equal!
+ if ( isset( $other->configs[$name] )
+ && $this->factoryFunctions[$name] == $otherFunc
+ ) {
+ $this->configs[$name] = $other->configs[$name];
+ unset( $other->configs[$name] );
+ }
+ }
+
+ // disable $other
+ $other->factoryFunctions = [];
+ $other->configs = [];
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getConfigNames() {
+ return array_keys( $this->factoryFunctions );
+ }
+
+ /**
+ * Register a new config factory function.
+ * Will override if it's already registered.
+ * Use "*" for $name to provide a fallback config for all unknown names.
+ * @param string $name
+ * @param callable|Config $callback A factory callabck that takes this ConfigFactory
+ * as an argument and returns a Config instance, or an existing Config instance.
+ * @throws InvalidArgumentException If an invalid callback is provided
+ */
+ public function register( $name, $callback ) {
+ if ( !is_callable( $callback ) && !( $callback instanceof Config ) ) {
+ throw new InvalidArgumentException( 'Invalid callback provided' );
+ }
+
+ unset( $this->configs[$name] );
+ $this->factoryFunctions[$name] = $callback;
+ }
+
+ /**
+ * Create a given Config using the registered callback for $name.
+ * If an object was already created, the same Config object is returned.
+ * @param string $name Name of the extension/component you want a Config object for
+ * 'main' is used for core
+ * @throws ConfigException If a factory function isn't registered for $name
+ * @throws UnexpectedValueException If the factory function returns a non-Config object
+ * @return Config
+ */
+ public function makeConfig( $name ) {
+ if ( !isset( $this->configs[$name] ) ) {
+ $key = $name;
+ if ( !isset( $this->factoryFunctions[$key] ) ) {
+ $key = '*';
+ }
+ if ( !isset( $this->factoryFunctions[$key] ) ) {
+ throw new ConfigException( "No registered builder available for $name." );
+ }
+
+ if ( $this->factoryFunctions[$key] instanceof Config ) {
+ $conf = $this->factoryFunctions[$key];
+ } else {
+ $conf = call_user_func( $this->factoryFunctions[$key], $this );
+ }
+
+ if ( $conf instanceof Config ) {
+ $this->configs[$name] = $conf;
+ } else {
+ throw new UnexpectedValueException( "The builder for $name returned a non-Config object." );
+ }
+ }
+
+ return $this->configs[$name];
+ }
+
+}
diff --git a/www/wiki/includes/config/EtcdConfig.php b/www/wiki/includes/config/EtcdConfig.php
new file mode 100644
index 00000000..0ec21cb9
--- /dev/null
+++ b/www/wiki/includes/config/EtcdConfig.php
@@ -0,0 +1,315 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Wikimedia\WaitConditionLoop;
+
+/**
+ * Interface for configuration instances
+ *
+ * @since 1.29
+ */
+class EtcdConfig implements Config, LoggerAwareInterface {
+ /** @var MultiHttpClient */
+ private $http;
+ /** @var BagOStuff */
+ private $srvCache;
+ /** @var array */
+ private $procCache;
+ /** @var LoggerInterface */
+ private $logger;
+
+ /** @var string */
+ private $host;
+ /** @var string */
+ private $protocol;
+ /** @var string */
+ private $directory;
+ /** @var string */
+ private $encoding;
+ /** @var int */
+ private $baseCacheTTL;
+ /** @var int */
+ private $skewCacheTTL;
+ /** @var int */
+ private $timeout;
+
+ /**
+ * @param array $params Parameter map:
+ * - host: the host address and port
+ * - protocol: either http or https
+ * - directory: the etc "directory" were MediaWiki specific variables are located
+ * - encoding: one of ("JSON", "YAML"). Defaults to JSON. [optional]
+ * - cache: BagOStuff instance or ObjectFactory spec thereof for a server cache.
+ * The cache will also be used as a fallback if etcd is down. [optional]
+ * - cacheTTL: logical cache TTL in seconds [optional]
+ * - skewTTL: maximum seconds to randomly lower the assigned TTL on cache save [optional]
+ * - timeout: seconds to wait for etcd before throwing an error [optional]
+ */
+ public function __construct( array $params ) {
+ $params += [
+ 'protocol' => 'http',
+ 'encoding' => 'JSON',
+ 'cacheTTL' => 10,
+ 'skewTTL' => 1,
+ 'timeout' => 2
+ ];
+
+ $this->host = $params['host'];
+ $this->protocol = $params['protocol'];
+ $this->directory = trim( $params['directory'], '/' );
+ $this->encoding = $params['encoding'];
+ $this->skewCacheTTL = $params['skewTTL'];
+ $this->baseCacheTTL = max( $params['cacheTTL'] - $this->skewCacheTTL, 0 );
+ $this->timeout = $params['timeout'];
+
+ if ( !isset( $params['cache'] ) ) {
+ $this->srvCache = new HashBagOStuff();
+ } elseif ( $params['cache'] instanceof BagOStuff ) {
+ $this->srvCache = $params['cache'];
+ } else {
+ $this->srvCache = ObjectFactory::getObjectFromSpec( $params['cache'] );
+ }
+
+ $this->logger = new Psr\Log\NullLogger();
+ $this->http = new MultiHttpClient( [
+ 'connTimeout' => $this->timeout,
+ 'reqTimeout' => $this->timeout,
+ 'logger' => $this->logger
+ ] );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ $this->http->setLogger( $logger );
+ }
+
+ public function has( $name ) {
+ $this->load();
+
+ return array_key_exists( $name, $this->procCache['config'] );
+ }
+
+ public function get( $name ) {
+ $this->load();
+
+ if ( !array_key_exists( $name, $this->procCache['config'] ) ) {
+ throw new ConfigException( "No entry found for '$name'." );
+ }
+
+ return $this->procCache['config'][$name];
+ }
+
+ /**
+ * @throws ConfigException
+ */
+ private function load() {
+ if ( $this->procCache !== null ) {
+ return; // already loaded
+ }
+
+ $now = microtime( true );
+ $key = $this->srvCache->makeGlobalKey(
+ __CLASS__,
+ $this->host,
+ $this->directory
+ );
+
+ // Get the cached value or block until it is regenerated (by this or another thread)...
+ $data = null; // latest config info
+ $error = null; // last error message
+ $loop = new WaitConditionLoop(
+ function () use ( $key, $now, &$data, &$error ) {
+ // Check if the values are in cache yet...
+ $data = $this->srvCache->get( $key );
+ if ( is_array( $data ) && $data['expires'] > $now ) {
+ $this->logger->debug( "Found up-to-date etcd configuration cache." );
+
+ return WaitConditionLoop::CONDITION_REACHED;
+ }
+
+ // Cache is either empty or stale;
+ // refresh the cache from etcd, using a mutex to reduce stampedes...
+ if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) {
+ try {
+ list( $config, $error, $retry ) = $this->fetchAllFromEtcd();
+ if ( is_array( $config ) ) {
+ // Avoid having all servers expire cache keys at the same time
+ $expiry = microtime( true ) + $this->baseCacheTTL;
+ $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
+
+ $data = [ 'config' => $config, 'expires' => $expiry ];
+ $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE );
+
+ $this->logger->info( "Refreshed stale etcd configuration cache." );
+
+ return WaitConditionLoop::CONDITION_REACHED;
+ } else {
+ $this->logger->error( "Failed to fetch configuration: $error" );
+ if ( !$retry ) {
+ // Fail fast since the error is likely to keep happening
+ return WaitConditionLoop::CONDITION_FAILED;
+ }
+ }
+ } finally {
+ $this->srvCache->unlock( $key ); // release mutex
+ }
+ }
+
+ if ( is_array( $data ) ) {
+ $this->logger->info( "Using stale etcd configuration cache." );
+
+ return WaitConditionLoop::CONDITION_REACHED;
+ }
+
+ return WaitConditionLoop::CONDITION_CONTINUE;
+ },
+ $this->timeout
+ );
+
+ if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
+ // No cached value exists and etcd query failed; throw an error
+ throw new ConfigException( "Failed to load configuration from etcd: $error" );
+ }
+
+ $this->procCache = $data;
+ }
+
+ /**
+ * @return array (config array or null, error string, allow retries)
+ */
+ public function fetchAllFromEtcd() {
+ $dsd = new DnsSrvDiscoverer( $this->host );
+ $servers = $dsd->getServers();
+ if ( !$servers ) {
+ return $this->fetchAllFromEtcdServer( $this->host );
+ }
+
+ do {
+ // Pick a random etcd server from dns
+ $server = $dsd->pickServer( $servers );
+ $host = IP::combineHostAndPort( $server['target'], $server['port'] );
+ // Try to load the config from this particular server
+ list( $config, $error, $retry ) = $this->fetchAllFromEtcdServer( $host );
+ if ( is_array( $config ) || !$retry ) {
+ break;
+ }
+
+ // Avoid the server next time if that failed
+ $servers = $dsd->removeServer( $server, $servers );
+ } while ( $servers );
+
+ return [ $config, $error, $retry ];
+ }
+
+ /**
+ * @param string $address Host and port
+ * @return array (config array or null, error string, whether to allow retries)
+ */
+ protected function fetchAllFromEtcdServer( $address ) {
+ // Retrieve all the values under the MediaWiki config directory
+ list( $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'GET',
+ 'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/?recursive=true",
+ 'headers' => [ 'content-type' => 'application/json' ]
+ ] );
+
+ static $terminalCodes = [ 404 => true ];
+ if ( $rcode < 200 || $rcode > 399 ) {
+ return [
+ null,
+ strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)",
+ empty( $terminalCodes[$rcode] )
+ ];
+ }
+ try {
+ return [ $this->parseResponse( $rbody ), null, false ];
+ } catch ( EtcdConfigParseError $e ) {
+ return [ null, $e->getMessage(), false ];
+ }
+ }
+
+ /**
+ * Parse a response body, throwing EtcdConfigParseError if there is a validation error
+ *
+ * @param string $rbody
+ * @return array
+ */
+ protected function parseResponse( $rbody ) {
+ $info = json_decode( $rbody, true );
+ if ( $info === null ) {
+ throw new EtcdConfigParseError( "Error unserializing JSON response." );
+ }
+ if ( !isset( $info['node'] ) || !is_array( $info['node'] ) ) {
+ throw new EtcdConfigParseError(
+ "Unexpected JSON response: Missing or invalid node at top level." );
+ }
+ $config = [];
+ $this->parseDirectory( '', $info['node'], $config );
+ return $config;
+ }
+
+ /**
+ * Recursively parse a directory node and populate the array passed by
+ * reference, throwing EtcdConfigParseError if there is a validation error
+ *
+ * @param string $dirName The relative directory name
+ * @param array $dirNode The decoded directory node
+ * @param array &$config The output array
+ */
+ protected function parseDirectory( $dirName, $dirNode, &$config ) {
+ if ( !isset( $dirNode['nodes'] ) ) {
+ throw new EtcdConfigParseError(
+ "Unexpected JSON response in dir '$dirName'; missing 'nodes' list." );
+ }
+ if ( !is_array( $dirNode['nodes'] ) ) {
+ throw new EtcdConfigParseError(
+ "Unexpected JSON response in dir '$dirName'; 'nodes' is not an array." );
+ }
+
+ foreach ( $dirNode['nodes'] as $node ) {
+ $baseName = basename( $node['key'] );
+ $fullName = $dirName === '' ? $baseName : "$dirName/$baseName";
+ if ( !empty( $node['dir'] ) ) {
+ $this->parseDirectory( $fullName, $node, $config );
+ } else {
+ $value = $this->unserialize( $node['value'] );
+ if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) {
+ throw new EtcdConfigParseError( "Failed to parse value for '$fullName'." );
+ }
+
+ $config[$fullName] = $value['val'];
+ }
+ }
+ }
+
+ /**
+ * @param string $string
+ * @return mixed
+ */
+ private function unserialize( $string ) {
+ if ( $this->encoding === 'YAML' ) {
+ return yaml_parse( $string );
+ } else { // JSON
+ return json_decode( $string, true );
+ }
+ }
+}
diff --git a/www/wiki/includes/config/EtcdConfigParseError.php b/www/wiki/includes/config/EtcdConfigParseError.php
new file mode 100644
index 00000000..cab90a8e
--- /dev/null
+++ b/www/wiki/includes/config/EtcdConfigParseError.php
@@ -0,0 +1,4 @@
+<?php
+
+class EtcdConfigParseError extends Exception {
+}
diff --git a/www/wiki/includes/config/GlobalVarConfig.php b/www/wiki/includes/config/GlobalVarConfig.php
new file mode 100644
index 00000000..62953719
--- /dev/null
+++ b/www/wiki/includes/config/GlobalVarConfig.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Accesses configuration settings from $GLOBALS
+ *
+ * @since 1.23
+ */
+class GlobalVarConfig implements Config {
+
+ /**
+ * Prefix to use for configuration variables
+ * @var string
+ */
+ private $prefix;
+
+ /**
+ * Default builder function
+ * @return GlobalVarConfig
+ */
+ public static function newInstance() {
+ return new GlobalVarConfig();
+ }
+
+ public function __construct( $prefix = 'wg' ) {
+ $this->prefix = $prefix;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get( $name ) {
+ if ( !$this->has( $name ) ) {
+ throw new ConfigException( __METHOD__ . ": undefined option: '$name'" );
+ }
+ return $this->getWithPrefix( $this->prefix, $name );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function has( $name ) {
+ return $this->hasWithPrefix( $this->prefix, $name );
+ }
+
+ /**
+ * Get a variable with a given prefix, if not the defaults.
+ *
+ * @param string $prefix Prefix to use on the variable, if one.
+ * @param string $name Variable name without prefix
+ * @return mixed
+ */
+ protected function getWithPrefix( $prefix, $name ) {
+ return $GLOBALS[$prefix . $name];
+ }
+
+ /**
+ * Check if a variable with a given prefix is set
+ *
+ * @param string $prefix Prefix to use on the variable
+ * @param string $name Variable name without prefix
+ * @return bool
+ */
+ protected function hasWithPrefix( $prefix, $name ) {
+ $var = $prefix . $name;
+ return array_key_exists( $var, $GLOBALS );
+ }
+}
diff --git a/www/wiki/includes/config/HashConfig.php b/www/wiki/includes/config/HashConfig.php
new file mode 100644
index 00000000..d020d20f
--- /dev/null
+++ b/www/wiki/includes/config/HashConfig.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A Config instance which stores all settings as a member variable
+ *
+ * @since 1.24
+ */
+class HashConfig implements Config, MutableConfig {
+
+ /**
+ * Array of config settings
+ *
+ * @var array
+ */
+ private $settings;
+
+ /**
+ * @return HashConfig
+ */
+ public static function newInstance() {
+ return new HashConfig;
+ }
+
+ /**
+ * @param array $settings Any current settings to pre-load
+ */
+ public function __construct( array $settings = [] ) {
+ $this->settings = $settings;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get( $name ) {
+ if ( !$this->has( $name ) ) {
+ throw new ConfigException( __METHOD__ . ": undefined option: '$name'" );
+ }
+
+ return $this->settings[$name];
+ }
+
+ /**
+ * @inheritDoc
+ * @since 1.24
+ */
+ public function has( $name ) {
+ return array_key_exists( $name, $this->settings );
+ }
+
+ /**
+ * @see MutableConfig::set
+ * @param string $name
+ * @param mixed $value
+ */
+ public function set( $name, $value ) {
+ $this->settings[$name] = $value;
+ }
+}
diff --git a/www/wiki/includes/config/MultiConfig.php b/www/wiki/includes/config/MultiConfig.php
new file mode 100644
index 00000000..2bbc84c9
--- /dev/null
+++ b/www/wiki/includes/config/MultiConfig.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Provides a fallback sequence for Config objects
+ *
+ * @since 1.24
+ */
+class MultiConfig implements Config {
+
+ /**
+ * Array of Config objects to use
+ * Order matters, the Config objects
+ * will be checked in order to see
+ * whether they have the requested setting
+ *
+ * @var Config[]
+ */
+ private $configs;
+
+ /**
+ * @param Config[] $configs
+ */
+ public function __construct( array $configs ) {
+ $this->configs = $configs;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get( $name ) {
+ foreach ( $this->configs as $config ) {
+ if ( $config->has( $name ) ) {
+ return $config->get( $name );
+ }
+ }
+
+ throw new ConfigException( __METHOD__ . ": undefined option: '$name'" );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function has( $name ) {
+ foreach ( $this->configs as $config ) {
+ if ( $config->has( $name ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/config/MutableConfig.php b/www/wiki/includes/config/MutableConfig.php
new file mode 100644
index 00000000..e765e3bc
--- /dev/null
+++ b/www/wiki/includes/config/MutableConfig.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Interface for mutable configuration instances
+ *
+ * @since 1.24
+ */
+interface MutableConfig {
+
+ /**
+ * Set a configuration variable such a "Sitename" to something like "My Wiki"
+ *
+ * @param string $name Name of configuration option
+ * @param mixed $value Value to set
+ * @throws ConfigException
+ */
+ public function set( $name, $value );
+}
diff --git a/www/wiki/includes/content/AbstractContent.php b/www/wiki/includes/content/AbstractContent.php
new file mode 100644
index 00000000..c12d28d9
--- /dev/null
+++ b/www/wiki/includes/content/AbstractContent.php
@@ -0,0 +1,551 @@
+<?php
+/**
+ * A content object represents page content, e.g. the text to show on a page.
+ * Content objects have no knowledge about how they relate to Wiki pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ *
+ * @author Daniel Kinzler
+ */
+
+/**
+ * Base implementation for content objects.
+ *
+ * @ingroup Content
+ */
+abstract class AbstractContent implements Content {
+ /**
+ * Name of the content model this Content object represents.
+ * Use with CONTENT_MODEL_XXX constants
+ *
+ * @since 1.21
+ *
+ * @var string $model_id
+ */
+ protected $model_id;
+
+ /**
+ * @param string $modelId
+ *
+ * @since 1.21
+ */
+ public function __construct( $modelId = null ) {
+ $this->model_id = $modelId;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @see Content::getModel
+ * @return string
+ */
+ public function getModel() {
+ return $this->model_id;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string $modelId The model to check
+ *
+ * @throws MWException If the provided ID is not the ID of the content model supported by this
+ * Content object.
+ */
+ protected function checkModelID( $modelId ) {
+ if ( $modelId !== $this->model_id ) {
+ throw new MWException(
+ "Bad content model: " .
+ "expected {$this->model_id} " .
+ "but got $modelId."
+ );
+ }
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @see Content::getContentHandler
+ * @return ContentHandler
+ */
+ public function getContentHandler() {
+ return ContentHandler::getForContent( $this );
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @see Content::getDefaultFormat
+ * @return string
+ */
+ public function getDefaultFormat() {
+ return $this->getContentHandler()->getDefaultFormat();
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @see Content::getSupportedFormats
+ * @return string[]
+ */
+ public function getSupportedFormats() {
+ return $this->getContentHandler()->getSupportedFormats();
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string $format
+ *
+ * @return bool
+ *
+ * @see Content::isSupportedFormat
+ */
+ public function isSupportedFormat( $format ) {
+ if ( !$format ) {
+ return true; // this means "use the default"
+ }
+
+ return $this->getContentHandler()->isSupportedFormat( $format );
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string $format The serialization format to check.
+ *
+ * @throws MWException If the format is not supported by this content handler.
+ */
+ protected function checkFormat( $format ) {
+ if ( !$this->isSupportedFormat( $format ) ) {
+ throw new MWException(
+ "Format $format is not supported for content model " .
+ $this->getModel()
+ );
+ }
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string $format
+ *
+ * @return string
+ *
+ * @see Content::serialize
+ */
+ public function serialize( $format = null ) {
+ return $this->getContentHandler()->serializeContent( $this, $format );
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @return bool
+ *
+ * @see Content::isEmpty
+ */
+ public function isEmpty() {
+ return $this->getSize() === 0;
+ }
+
+ /**
+ * Subclasses may override this to implement (light weight) validation.
+ *
+ * @since 1.21
+ *
+ * @return bool Always true.
+ *
+ * @see Content::isValid
+ */
+ public function isValid() {
+ return true;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param Content $that
+ *
+ * @return bool
+ *
+ * @see Content::equals
+ */
+ public function equals( Content $that = null ) {
+ if ( is_null( $that ) ) {
+ return false;
+ }
+
+ if ( $that === $this ) {
+ return true;
+ }
+
+ if ( $that->getModel() !== $this->getModel() ) {
+ return false;
+ }
+
+ return $this->getNativeData() === $that->getNativeData();
+ }
+
+ /**
+ * Returns a list of DataUpdate objects for recording information about this
+ * Content in some secondary data store.
+ *
+ * This default implementation returns a LinksUpdate object and calls the
+ * SecondaryDataUpdates hook.
+ *
+ * Subclasses may override this to determine the secondary data updates more
+ * efficiently, preferably without the need to generate a parser output object.
+ * They should however make sure to call SecondaryDataUpdates to give extensions
+ * a chance to inject additional updates.
+ *
+ * @since 1.21
+ *
+ * @param Title $title
+ * @param Content $old
+ * @param bool $recursive
+ * @param ParserOutput $parserOutput
+ *
+ * @return DataUpdate[]
+ *
+ * @see Content::getSecondaryDataUpdates()
+ */
+ public function getSecondaryDataUpdates( Title $title, Content $old = null,
+ $recursive = true, ParserOutput $parserOutput = null
+ ) {
+ if ( $parserOutput === null ) {
+ $parserOutput = $this->getParserOutput( $title, null, null, false );
+ }
+
+ $updates = [
+ new LinksUpdate( $title, $parserOutput, $recursive )
+ ];
+
+ Hooks::run( 'SecondaryDataUpdates', [ $title, $old, $recursive, $parserOutput, &$updates ] );
+
+ return $updates;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @return Title[]|null
+ *
+ * @see Content::getRedirectChain
+ */
+ public function getRedirectChain() {
+ global $wgMaxRedirects;
+ $title = $this->getRedirectTarget();
+ if ( is_null( $title ) ) {
+ return null;
+ }
+ // recursive check to follow double redirects
+ $recurse = $wgMaxRedirects;
+ $titles = [ $title ];
+ while ( --$recurse > 0 ) {
+ if ( $title->isRedirect() ) {
+ $page = WikiPage::factory( $title );
+ $newtitle = $page->getRedirectTarget();
+ } else {
+ break;
+ }
+ // Redirects to some special pages are not permitted
+ if ( $newtitle instanceof Title && $newtitle->isValidRedirectTarget() ) {
+ // The new title passes the checks, so make that our current
+ // title so that further recursion can be checked
+ $title = $newtitle;
+ $titles[] = $newtitle;
+ } else {
+ break;
+ }
+ }
+
+ return $titles;
+ }
+
+ /**
+ * Subclasses that implement redirects should override this.
+ *
+ * @since 1.21
+ *
+ * @return Title|null
+ *
+ * @see Content::getRedirectTarget
+ */
+ public function getRedirectTarget() {
+ return null;
+ }
+
+ /**
+ * @note Migrated here from Title::newFromRedirectRecurse.
+ *
+ * @since 1.21
+ *
+ * @return Title|null
+ *
+ * @see Content::getUltimateRedirectTarget
+ */
+ public function getUltimateRedirectTarget() {
+ $titles = $this->getRedirectChain();
+
+ return $titles ? array_pop( $titles ) : null;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @return bool
+ *
+ * @see Content::isRedirect
+ */
+ public function isRedirect() {
+ return $this->getRedirectTarget() !== null;
+ }
+
+ /**
+ * This default implementation always returns $this.
+ * Subclasses that implement redirects should override this.
+ *
+ * @since 1.21
+ *
+ * @param Title $target
+ *
+ * @return Content $this
+ *
+ * @see Content::updateRedirect
+ */
+ public function updateRedirect( Title $target ) {
+ return $this;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string|int $sectionId
+ * @return null
+ *
+ * @see Content::getSection
+ */
+ public function getSection( $sectionId ) {
+ return null;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string|int|null|bool $sectionId
+ * @param Content $with
+ * @param string $sectionTitle
+ * @return null
+ *
+ * @see Content::replaceSection
+ */
+ public function replaceSection( $sectionId, Content $with, $sectionTitle = '' ) {
+ return null;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param Title $title
+ * @param User $user
+ * @param ParserOptions $popts
+ * @return Content $this
+ *
+ * @see Content::preSaveTransform
+ */
+ public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
+ return $this;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string $header
+ * @return Content $this
+ *
+ * @see Content::addSectionHeader
+ */
+ public function addSectionHeader( $header ) {
+ return $this;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param Title $title
+ * @param ParserOptions $popts
+ * @param array $params
+ * @return Content $this
+ *
+ * @see Content::preloadTransform
+ */
+ public function preloadTransform( Title $title, ParserOptions $popts, $params = [] ) {
+ return $this;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param WikiPage $page
+ * @param int $flags
+ * @param int $parentRevId
+ * @param User $user
+ * @return Status
+ *
+ * @see Content::prepareSave
+ */
+ public function prepareSave( WikiPage $page, $flags, $parentRevId, User $user ) {
+ if ( $this->isValid() ) {
+ return Status::newGood();
+ } else {
+ return Status::newFatal( "invalid-content-data" );
+ }
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param WikiPage $page
+ * @param ParserOutput|null $parserOutput
+ *
+ * @return LinksDeletionUpdate[]
+ *
+ * @see Content::getDeletionUpdates
+ */
+ public function getDeletionUpdates( WikiPage $page, ParserOutput $parserOutput = null ) {
+ return [
+ new LinksDeletionUpdate( $page ),
+ ];
+ }
+
+ /**
+ * This default implementation always returns false. Subclasses may override
+ * this to supply matching logic.
+ *
+ * @since 1.21
+ *
+ * @param MagicWord $word
+ *
+ * @return bool Always false.
+ *
+ * @see Content::matchMagicWord
+ */
+ public function matchMagicWord( MagicWord $word ) {
+ return false;
+ }
+
+ /**
+ * This base implementation calls the hook ConvertContent to enable custom conversions.
+ * Subclasses may override this to implement conversion for "their" content model.
+ *
+ * @param string $toModel
+ * @param string $lossy
+ *
+ * @return Content|bool
+ *
+ * @see Content::convert()
+ */
+ public function convert( $toModel, $lossy = '' ) {
+ if ( $this->getModel() === $toModel ) {
+ // nothing to do, shorten out.
+ return $this;
+ }
+
+ $lossy = ( $lossy === 'lossy' ); // string flag, convert to boolean for convenience
+ $result = false;
+
+ Hooks::run( 'ConvertContent', [ $this, $toModel, $lossy, &$result ] );
+
+ return $result;
+ }
+
+ /**
+ * Returns a ParserOutput object containing information derived from this content.
+ * Most importantly, unless $generateHtml was false, the return value contains an
+ * HTML representation of the content.
+ *
+ * Subclasses that want to control the parser output may override this, but it is
+ * preferred to override fillParserOutput() instead.
+ *
+ * Subclasses that override getParserOutput() itself should take care to call the
+ * ContentGetParserOutput hook.
+ *
+ * @since 1.24
+ *
+ * @param Title $title Context title for parsing
+ * @param int|null $revId Revision ID (for {{REVISIONID}})
+ * @param ParserOptions|null $options Parser options
+ * @param bool $generateHtml Whether or not to generate HTML
+ *
+ * @return ParserOutput Containing information derived from this content.
+ */
+ public function getParserOutput( Title $title, $revId = null,
+ ParserOptions $options = null, $generateHtml = true
+ ) {
+ if ( $options === null ) {
+ $options = $this->getContentHandler()->makeParserOptions( 'canonical' );
+ }
+
+ $po = new ParserOutput();
+
+ if ( Hooks::run( 'ContentGetParserOutput',
+ [ $this, $title, $revId, $options, $generateHtml, &$po ] )
+ ) {
+ // Save and restore the old value, just in case something is reusing
+ // the ParserOptions object in some weird way.
+ $oldRedir = $options->getRedirectTarget();
+ $options->setRedirectTarget( $this->getRedirectTarget() );
+ $this->fillParserOutput( $title, $revId, $options, $generateHtml, $po );
+ $options->setRedirectTarget( $oldRedir );
+ }
+
+ Hooks::run( 'ContentAlterParserOutput', [ $this, $title, $po ] );
+
+ return $po;
+ }
+
+ /**
+ * Fills the provided ParserOutput with information derived from the content.
+ * Unless $generateHtml was false, this includes an HTML representation of the content.
+ *
+ * This is called by getParserOutput() after consulting the ContentGetParserOutput hook.
+ * Subclasses are expected to override this method (or getParserOutput(), if need be).
+ * Subclasses of TextContent should generally override getHtml() instead.
+ *
+ * This placeholder implementation always throws an exception.
+ *
+ * @since 1.24
+ *
+ * @param Title $title Context title for parsing
+ * @param int|null $revId Revision ID (for {{REVISIONID}})
+ * @param ParserOptions $options Parser options
+ * @param bool $generateHtml Whether or not to generate HTML
+ * @param ParserOutput &$output The output object to fill (reference).
+ *
+ * @throws MWException
+ */
+ protected function fillParserOutput( Title $title, $revId,
+ ParserOptions $options, $generateHtml, ParserOutput &$output
+ ) {
+ // Don't make abstract, so subclasses that override getParserOutput() directly don't fail.
+ throw new MWException( 'Subclasses of AbstractContent must override fillParserOutput!' );
+ }
+}
diff --git a/www/wiki/includes/content/CodeContentHandler.php b/www/wiki/includes/content/CodeContentHandler.php
new file mode 100644
index 00000000..dfd46c8f
--- /dev/null
+++ b/www/wiki/includes/content/CodeContentHandler.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Content handler for the pages with code, such as CSS, JavaScript, JSON.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Content
+ */
+
+/**
+ * Content handler for code content such as CSS, JavaScript, JSON, etc
+ * @since 1.24
+ * @ingroup Content
+ */
+abstract class CodeContentHandler extends TextContentHandler {
+
+ /**
+ * Returns the English language, because code is English, and should be handled as such.
+ *
+ * @param Title $title
+ * @param Content $content
+ *
+ * @return Language
+ *
+ * @see ContentHandler::getPageLanguage()
+ */
+ public function getPageLanguage( Title $title, Content $content = null ) {
+ return Language::factory( 'en' );
+ }
+
+ /**
+ * Returns the English language, because code is English, and should be handled as such.
+ *
+ * @param Title $title
+ * @param Content $content
+ *
+ * @return Language
+ *
+ * @see ContentHandler::getPageViewLanguage()
+ */
+ public function getPageViewLanguage( Title $title, Content $content = null ) {
+ return Language::factory( 'en' );
+ }
+
+ /**
+ * @return string
+ * @throws MWException
+ */
+ protected function getContentClass() {
+ throw new MWException( 'Subclass must override' );
+ }
+
+}
diff --git a/www/wiki/includes/content/Content.php b/www/wiki/includes/content/Content.php
new file mode 100644
index 00000000..6a0a63bf
--- /dev/null
+++ b/www/wiki/includes/content/Content.php
@@ -0,0 +1,526 @@
+<?php
+/**
+ * A content object represents page content, e.g. the text to show on a page.
+ * Content objects have no knowledge about how they relate to wiki pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ *
+ * @author Daniel Kinzler
+ */
+
+/**
+ * Base interface for content objects.
+ *
+ * @ingroup Content
+ */
+interface Content {
+
+ /**
+ * @since 1.21
+ *
+ * @return string A string representing the content in a way useful for
+ * building a full text search index. If no useful representation exists,
+ * this method returns an empty string.
+ *
+ * @todo Test that this actually works
+ * @todo Make sure this also works with LuceneSearch / WikiSearch
+ */
+ public function getTextForSearchIndex();
+
+ /**
+ * @since 1.21
+ *
+ * @return string|bool The wikitext to include when another page includes this
+ * content, or false if the content is not includable in a wikitext page.
+ *
+ * @todo Allow native handling, bypassing wikitext representation, like
+ * for includable special pages.
+ * @todo Allow transclusion into other content models than Wikitext!
+ * @todo Used in WikiPage and MessageCache to get message text. Not so
+ * nice. What should we use instead?!
+ */
+ public function getWikitextForTransclusion();
+
+ /**
+ * Returns a textual representation of the content suitable for use in edit
+ * summaries and log messages.
+ *
+ * @since 1.21
+ *
+ * @param int $maxLength Maximum length of the summary text.
+ *
+ * @return string The summary text.
+ */
+ public function getTextForSummary( $maxLength = 250 );
+
+ /**
+ * Returns native representation of the data. Interpretation depends on
+ * the data model used, as given by getDataModel().
+ *
+ * @since 1.21
+ *
+ * @return mixed The native representation of the content. Could be a
+ * string, a nested array structure, an object, a binary blob...
+ * anything, really.
+ *
+ * @note Caller must be aware of content model!
+ */
+ public function getNativeData();
+
+ /**
+ * Returns the content's nominal size in "bogo-bytes".
+ *
+ * @return int
+ */
+ public function getSize();
+
+ /**
+ * Returns the ID of the content model used by this Content object.
+ * Corresponds to the CONTENT_MODEL_XXX constants.
+ *
+ * @since 1.21
+ *
+ * @return string The model id
+ */
+ public function getModel();
+
+ /**
+ * Convenience method that returns the ContentHandler singleton for handling
+ * the content model that this Content object uses.
+ *
+ * Shorthand for ContentHandler::getForContent( $this )
+ *
+ * @since 1.21
+ *
+ * @return ContentHandler
+ */
+ public function getContentHandler();
+
+ /**
+ * Convenience method that returns the default serialization format for the
+ * content model that this Content object uses.
+ *
+ * Shorthand for $this->getContentHandler()->getDefaultFormat()
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getDefaultFormat();
+
+ /**
+ * Convenience method that returns the list of serialization formats
+ * supported for the content model that this Content object uses.
+ *
+ * Shorthand for $this->getContentHandler()->getSupportedFormats()
+ *
+ * @since 1.21
+ *
+ * @return string[] List of supported serialization formats
+ */
+ public function getSupportedFormats();
+
+ /**
+ * Returns true if $format is a supported serialization format for this
+ * Content object, false if it isn't.
+ *
+ * Note that this should always return true if $format is null, because null
+ * stands for the default serialization.
+ *
+ * Shorthand for $this->getContentHandler()->isSupportedFormat( $format )
+ *
+ * @since 1.21
+ *
+ * @param string $format The serialization format to check.
+ *
+ * @return bool Whether the format is supported
+ */
+ public function isSupportedFormat( $format );
+
+ /**
+ * Convenience method for serializing this Content object.
+ *
+ * Shorthand for $this->getContentHandler()->serializeContent( $this, $format )
+ *
+ * @since 1.21
+ *
+ * @param string $format The desired serialization format, or null for the default format.
+ *
+ * @return string Serialized form of this Content object.
+ */
+ public function serialize( $format = null );
+
+ /**
+ * Returns true if this Content object represents empty content.
+ *
+ * @since 1.21
+ *
+ * @return bool Whether this Content object is empty
+ */
+ public function isEmpty();
+
+ /**
+ * Returns whether the content is valid. This is intended for local validity
+ * checks, not considering global consistency.
+ *
+ * Content needs to be valid before it can be saved.
+ *
+ * This default implementation always returns true.
+ *
+ * @since 1.21
+ *
+ * @return bool
+ */
+ public function isValid();
+
+ /**
+ * Returns true if this Content objects is conceptually equivalent to the
+ * given Content object.
+ *
+ * Contract:
+ *
+ * - Will return false if $that is null.
+ * - Will return true if $that === $this.
+ * - Will return false if $that->getModel() != $this->getModel().
+ * - Will return false if $that->getNativeData() is not equal to $this->getNativeData(),
+ * where the meaning of "equal" depends on the actual data model.
+ *
+ * Implementations should be careful to make equals() transitive and reflexive:
+ *
+ * - $a->equals( $b ) <=> $b->equals( $a )
+ * - $a->equals( $b ) && $b->equals( $c ) ==> $a->equals( $c )
+ *
+ * @since 1.21
+ *
+ * @param Content $that The Content object to compare to.
+ *
+ * @return bool True if this Content object is equal to $that, false otherwise.
+ */
+ public function equals( Content $that = null );
+
+ /**
+ * Return a copy of this Content object. The following must be true for the
+ * object returned:
+ *
+ * if $copy = $original->copy()
+ *
+ * - get_class($original) === get_class($copy)
+ * - $original->getModel() === $copy->getModel()
+ * - $original->equals( $copy )
+ *
+ * If and only if the Content object is immutable, the copy() method can and
+ * should return $this. That is, $copy === $original may be true, but only
+ * for immutable content objects.
+ *
+ * @since 1.21
+ *
+ * @return Content A copy of this object
+ */
+ public function copy();
+
+ /**
+ * Returns true if this content is countable as a "real" wiki page, provided
+ * that it's also in a countable location (e.g. a current revision in the
+ * main namespace).
+ *
+ * @since 1.21
+ *
+ * @param bool|null $hasLinks If it is known whether this content contains
+ * links, provide this information here, to avoid redundant parsing to
+ * find out.
+ *
+ * @return bool
+ */
+ public function isCountable( $hasLinks = null );
+
+ /**
+ * Parse the Content object and generate a ParserOutput from the result.
+ * $result->getText() can be used to obtain the generated HTML. If no HTML
+ * is needed, $generateHtml can be set to false; in that case,
+ * $result->getText() may return null.
+ *
+ * @note To control which options are used in the cache key for the
+ * generated parser output, implementations of this method
+ * may call ParserOutput::recordOption() on the output object.
+ *
+ * @param Title $title The page title to use as a context for rendering.
+ * @param int $revId Optional revision ID being rendered.
+ * @param ParserOptions $options Any parser options.
+ * @param bool $generateHtml Whether to generate HTML (default: true). If false,
+ * the result of calling getText() on the ParserOutput object returned by
+ * this method is undefined.
+ *
+ * @since 1.21
+ *
+ * @return ParserOutput
+ */
+ public function getParserOutput( Title $title, $revId = null,
+ ParserOptions $options = null, $generateHtml = true );
+
+ // TODO: make RenderOutput and RenderOptions base classes
+
+ /**
+ * Returns a list of DataUpdate objects for recording information about this
+ * Content in some secondary data store. If the optional second argument,
+ * $old, is given, the updates may model only the changes that need to be
+ * made to replace information about the old content with information about
+ * the new content.
+ *
+ * This default implementation calls
+ * $this->getParserOutput( $content, $title, null, null, false ),
+ * and then calls getSecondaryDataUpdates( $title, $recursive ) on the
+ * resulting ParserOutput object.
+ *
+ * Subclasses may implement this to determine the necessary updates more
+ * efficiently, or make use of information about the old content.
+ *
+ * @note Implementations should call the SecondaryDataUpdates hook, like
+ * AbstractContent does.
+ *
+ * @param Title $title The context for determining the necessary updates
+ * @param Content $old An optional Content object representing the
+ * previous content, i.e. the content being replaced by this Content
+ * object.
+ * @param bool $recursive Whether to include recursive updates (default:
+ * false).
+ * @param ParserOutput $parserOutput Optional ParserOutput object.
+ * Provide if you have one handy, to avoid re-parsing of the content.
+ *
+ * @return DataUpdate[] A list of DataUpdate objects for putting information
+ * about this content object somewhere.
+ *
+ * @since 1.21
+ */
+ public function getSecondaryDataUpdates( Title $title, Content $old = null,
+ $recursive = true, ParserOutput $parserOutput = null );
+
+ /**
+ * Construct the redirect destination from this content and return an
+ * array of Titles, or null if this content doesn't represent a redirect.
+ * The last element in the array is the final destination after all redirects
+ * have been resolved (up to $wgMaxRedirects times).
+ *
+ * @since 1.21
+ *
+ * @return Title[]|null List of Titles, with the destination last.
+ */
+ public function getRedirectChain();
+
+ /**
+ * Construct the redirect destination from this content and return a Title,
+ * or null if this content doesn't represent a redirect.
+ * This will only return the immediate redirect target, useful for
+ * the redirect table and other checks that don't need full recursion.
+ *
+ * @since 1.21
+ *
+ * @return Title|null The corresponding Title.
+ */
+ public function getRedirectTarget();
+
+ /**
+ * Construct the redirect destination from this content and return the
+ * Title, or null if this content doesn't represent a redirect.
+ *
+ * This will recurse down $wgMaxRedirects times or until a non-redirect
+ * target is hit in order to provide (hopefully) the Title of the final
+ * destination instead of another redirect.
+ *
+ * There is usually no need to override the default behavior, subclasses that
+ * want to implement redirects should override getRedirectTarget().
+ *
+ * @since 1.21
+ *
+ * @return Title|null
+ */
+ public function getUltimateRedirectTarget();
+
+ /**
+ * Returns whether this Content represents a redirect.
+ * Shorthand for getRedirectTarget() !== null.
+ *
+ * @since 1.21
+ *
+ * @return bool
+ */
+ public function isRedirect();
+
+ /**
+ * If this Content object is a redirect, this method updates the redirect target.
+ * Otherwise, it does nothing.
+ *
+ * @since 1.21
+ *
+ * @param Title $target The new redirect target
+ *
+ * @return Content A new Content object with the updated redirect (or $this
+ * if this Content object isn't a redirect)
+ */
+ public function updateRedirect( Title $target );
+
+ /**
+ * Returns the section with the given ID.
+ *
+ * @since 1.21
+ *
+ * @param string|int $sectionId Section identifier as a number or string
+ * (e.g. 0, 1 or 'T-1'). The ID "0" retrieves the section before the first heading, "1" the
+ * text between the first heading (included) and the second heading (excluded), etc.
+ *
+ * @return Content|bool|null The section, or false if no such section
+ * exist, or null if sections are not supported.
+ */
+ public function getSection( $sectionId );
+
+ /**
+ * Replaces a section of the content and returns a Content object with the
+ * section replaced.
+ *
+ * @since 1.21
+ *
+ * @param string|int|null|bool $sectionId Section identifier as a number or string
+ * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
+ * or 'new' for a new section.
+ * @param Content $with New content of the section
+ * @param string $sectionTitle New section's subject, only if $section is 'new'
+ *
+ * @return string|null Complete article text, or null if error
+ */
+ public function replaceSection( $sectionId, Content $with, $sectionTitle = '' );
+
+ /**
+ * Returns a Content object with pre-save transformations applied (or this
+ * object if no transformations apply).
+ *
+ * @since 1.21
+ *
+ * @param Title $title
+ * @param User $user
+ * @param ParserOptions $parserOptions
+ *
+ * @return Content
+ */
+ public function preSaveTransform( Title $title, User $user, ParserOptions $parserOptions );
+
+ /**
+ * Returns a new WikitextContent object with the given section heading
+ * prepended, if supported. The default implementation just returns this
+ * Content object unmodified, ignoring the section header.
+ *
+ * @since 1.21
+ *
+ * @param string $header
+ *
+ * @return Content
+ */
+ public function addSectionHeader( $header );
+
+ /**
+ * Returns a Content object with preload transformations applied (or this
+ * object if no transformations apply).
+ *
+ * @since 1.21
+ *
+ * @param Title $title
+ * @param ParserOptions $parserOptions
+ * @param array $params
+ *
+ * @return Content
+ */
+ public function preloadTransform( Title $title, ParserOptions $parserOptions, $params = [] );
+
+ /**
+ * Prepare Content for saving. Called before Content is saved by WikiPage::doEditContent() and in
+ * similar places.
+ *
+ * This may be used to check the content's consistency with global state. This function should
+ * NOT write any information to the database.
+ *
+ * Note that this method will usually be called inside the same transaction
+ * bracket that will be used to save the new revision.
+ *
+ * Note that this method is called before any update to the page table is
+ * performed. This means that $page may not yet know a page ID.
+ *
+ * @since 1.21
+ *
+ * @param WikiPage $page The page to be saved.
+ * @param int $flags Bitfield for use with EDIT_XXX constants, see WikiPage::doEditContent()
+ * @param int $parentRevId The ID of the current revision
+ * @param User $user
+ *
+ * @return Status A status object indicating whether the content was
+ * successfully prepared for saving. If the returned status indicates
+ * an error, a rollback will be performed and the transaction aborted.
+ *
+ * @see WikiPage::doEditContent()
+ */
+ public function prepareSave( WikiPage $page, $flags, $parentRevId, User $user );
+
+ /**
+ * Returns a list of updates to perform when this content is deleted.
+ * The necessary updates may be taken from the Content object, or depend on
+ * the current state of the database.
+ *
+ * @since 1.21
+ *
+ * @param WikiPage $page The deleted page
+ * @param ParserOutput $parserOutput Optional parser output object
+ * for efficient access to meta-information about the content object.
+ * Provide if you have one handy.
+ *
+ * @return DataUpdate[] A list of DataUpdate instances that will clean up the
+ * database after deletion.
+ */
+ public function getDeletionUpdates( WikiPage $page,
+ ParserOutput $parserOutput = null );
+
+ /**
+ * Returns true if this Content object matches the given magic word.
+ *
+ * @since 1.21
+ *
+ * @param MagicWord $word The magic word to match
+ *
+ * @return bool Whether this Content object matches the given magic word.
+ */
+ public function matchMagicWord( MagicWord $word );
+
+ /**
+ * Converts this content object into another content object with the given content model,
+ * if that is possible.
+ *
+ * @param string $toModel The desired content model, use the CONTENT_MODEL_XXX flags.
+ * @param string $lossy Optional flag, set to "lossy" to allow lossy conversion. If lossy
+ * conversion is not allowed, full round-trip conversion is expected to work without losing
+ * information.
+ *
+ * @return Content|bool A content object with the content model $toModel, or false if
+ * that conversion is not supported.
+ */
+ public function convert( $toModel, $lossy = '' );
+ // @todo ImagePage and CategoryPage interfere with per-content action handlers
+ // @todo nice&sane integration of GeSHi syntax highlighting
+ // [11:59] <vvv> Hooks are ugly; make CodeHighlighter interface and a
+ // config to set the class which handles syntax highlighting
+ // [12:00] <vvv> And default it to a DummyHighlighter
+
+}
diff --git a/www/wiki/includes/content/ContentHandler.php b/www/wiki/includes/content/ContentHandler.php
new file mode 100644
index 00000000..0509e292
--- /dev/null
+++ b/www/wiki/includes/content/ContentHandler.php
@@ -0,0 +1,1213 @@
+<?php
+
+use MediaWiki\Search\ParserOutputSearchDataExtractor;
+
+/**
+ * Base class for content handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ *
+ * @author Daniel Kinzler
+ */
+/**
+ * A content handler knows how do deal with a specific type of content on a wiki
+ * page. Content is stored in the database in a serialized form (using a
+ * serialization format a.k.a. MIME type) and is unserialized into its native
+ * PHP representation (the content model), which is wrapped in an instance of
+ * the appropriate subclass of Content.
+ *
+ * ContentHandler instances are stateless singletons that serve, among other
+ * things, as a factory for Content objects. Generally, there is one subclass
+ * of ContentHandler and one subclass of Content for every type of content model.
+ *
+ * Some content types have a flat model, that is, their native representation
+ * is the same as their serialized form. Examples would be JavaScript and CSS
+ * code. As of now, this also applies to wikitext (MediaWiki's default content
+ * type), but wikitext content may be represented by a DOM or AST structure in
+ * the future.
+ *
+ * @ingroup Content
+ */
+abstract class ContentHandler {
+ /**
+ * Convenience function for getting flat text from a Content object. This
+ * should only be used in the context of backwards compatibility with code
+ * that is not yet able to handle Content objects!
+ *
+ * If $content is null, this method returns the empty string.
+ *
+ * If $content is an instance of TextContent, this method returns the flat
+ * text as returned by $content->getNativeData().
+ *
+ * If $content is not a TextContent object, the behavior of this method
+ * depends on the global $wgContentHandlerTextFallback:
+ * - If $wgContentHandlerTextFallback is 'fail' and $content is not a
+ * TextContent object, an MWException is thrown.
+ * - If $wgContentHandlerTextFallback is 'serialize' and $content is not a
+ * TextContent object, $content->serialize() is called to get a string
+ * form of the content.
+ * - If $wgContentHandlerTextFallback is 'ignore' and $content is not a
+ * TextContent object, this method returns null.
+ * - otherwise, the behavior is undefined.
+ *
+ * @since 1.21
+ *
+ * @param Content $content
+ *
+ * @throws MWException If the content is not an instance of TextContent and
+ * wgContentHandlerTextFallback was set to 'fail'.
+ * @return string|null Textual form of the content, if available.
+ */
+ public static function getContentText( Content $content = null ) {
+ global $wgContentHandlerTextFallback;
+
+ if ( is_null( $content ) ) {
+ return '';
+ }
+
+ if ( $content instanceof TextContent ) {
+ return $content->getNativeData();
+ }
+
+ wfDebugLog( 'ContentHandler', 'Accessing ' . $content->getModel() . ' content as text!' );
+
+ if ( $wgContentHandlerTextFallback == 'fail' ) {
+ throw new MWException(
+ "Attempt to get text from Content with model " .
+ $content->getModel()
+ );
+ }
+
+ if ( $wgContentHandlerTextFallback == 'serialize' ) {
+ return $content->serialize();
+ }
+
+ return null;
+ }
+
+ /**
+ * Convenience function for creating a Content object from a given textual
+ * representation.
+ *
+ * $text will be deserialized into a Content object of the model specified
+ * by $modelId (or, if that is not given, $title->getContentModel()) using
+ * the given format.
+ *
+ * @since 1.21
+ *
+ * @param string $text The textual representation, will be
+ * unserialized to create the Content object
+ * @param Title $title The title of the page this text belongs to.
+ * Required if $modelId is not provided.
+ * @param string $modelId The model to deserialize to. If not provided,
+ * $title->getContentModel() is used.
+ * @param string $format The format to use for deserialization. If not
+ * given, the model's default format is used.
+ *
+ * @throws MWException If model ID or format is not supported or if the text can not be
+ * unserialized using the format.
+ * @return Content A Content object representing the text.
+ */
+ public static function makeContent( $text, Title $title = null,
+ $modelId = null, $format = null ) {
+ if ( is_null( $modelId ) ) {
+ if ( is_null( $title ) ) {
+ throw new MWException( "Must provide a Title object or a content model ID." );
+ }
+
+ $modelId = $title->getContentModel();
+ }
+
+ $handler = self::getForModelID( $modelId );
+
+ return $handler->unserializeContent( $text, $format );
+ }
+
+ /**
+ * Returns the name of the default content model to be used for the page
+ * with the given title.
+ *
+ * Note: There should rarely be need to call this method directly.
+ * To determine the actual content model for a given page, use
+ * Title::getContentModel().
+ *
+ * Which model is to be used by default for the page is determined based
+ * on several factors:
+ * - The global setting $wgNamespaceContentModels specifies a content model
+ * per namespace.
+ * - The hook ContentHandlerDefaultModelFor may be used to override the page's default
+ * model.
+ * - Pages in NS_MEDIAWIKI and NS_USER default to the CSS or JavaScript
+ * model if they end in .js or .css, respectively.
+ * - Pages in NS_MEDIAWIKI default to the wikitext model otherwise.
+ * - The hook TitleIsCssOrJsPage may be used to force a page to use the CSS
+ * or JavaScript model. This is a compatibility feature. The ContentHandlerDefaultModelFor
+ * hook should be used instead if possible.
+ * - The hook TitleIsWikitextPage may be used to force a page to use the
+ * wikitext model. This is a compatibility feature. The ContentHandlerDefaultModelFor
+ * hook should be used instead if possible.
+ *
+ * If none of the above applies, the wikitext model is used.
+ *
+ * Note: this is used by, and may thus not use, Title::getContentModel()
+ *
+ * @since 1.21
+ *
+ * @param Title $title
+ *
+ * @return string Default model name for the page given by $title
+ */
+ public static function getDefaultModelFor( Title $title ) {
+ // NOTE: this method must not rely on $title->getContentModel() directly or indirectly,
+ // because it is used to initialize the mContentModel member.
+
+ $ns = $title->getNamespace();
+
+ $ext = false;
+ $m = null;
+ $model = MWNamespace::getNamespaceContentModel( $ns );
+
+ // Hook can determine default model
+ if ( !Hooks::run( 'ContentHandlerDefaultModelFor', [ $title, &$model ] ) ) {
+ if ( !is_null( $model ) ) {
+ return $model;
+ }
+ }
+
+ // Could this page contain code based on the title?
+ $isCodePage = NS_MEDIAWIKI == $ns && preg_match( '!\.(css|js|json)$!u', $title->getText(), $m );
+ if ( $isCodePage ) {
+ $ext = $m[1];
+ }
+
+ // Is this a user subpage containing code?
+ $isCodeSubpage = NS_USER == $ns
+ && !$isCodePage
+ && preg_match( "/\\/.*\\.(js|css|json)$/", $title->getText(), $m );
+ if ( $isCodeSubpage ) {
+ $ext = $m[1];
+ }
+
+ // Is this wikitext, according to $wgNamespaceContentModels or the DefaultModelFor hook?
+ $isWikitext = is_null( $model ) || $model == CONTENT_MODEL_WIKITEXT;
+ $isWikitext = $isWikitext && !$isCodePage && !$isCodeSubpage;
+
+ if ( !$isWikitext ) {
+ switch ( $ext ) {
+ case 'js':
+ return CONTENT_MODEL_JAVASCRIPT;
+ case 'css':
+ return CONTENT_MODEL_CSS;
+ case 'json':
+ return CONTENT_MODEL_JSON;
+ default:
+ return is_null( $model ) ? CONTENT_MODEL_TEXT : $model;
+ }
+ }
+
+ // We established that it must be wikitext
+
+ return CONTENT_MODEL_WIKITEXT;
+ }
+
+ /**
+ * Returns the appropriate ContentHandler singleton for the given title.
+ *
+ * @since 1.21
+ *
+ * @param Title $title
+ *
+ * @return ContentHandler
+ */
+ public static function getForTitle( Title $title ) {
+ $modelId = $title->getContentModel();
+
+ return self::getForModelID( $modelId );
+ }
+
+ /**
+ * Returns the appropriate ContentHandler singleton for the given Content
+ * object.
+ *
+ * @since 1.21
+ *
+ * @param Content $content
+ *
+ * @return ContentHandler
+ */
+ public static function getForContent( Content $content ) {
+ $modelId = $content->getModel();
+
+ return self::getForModelID( $modelId );
+ }
+
+ /**
+ * @var array A Cache of ContentHandler instances by model id
+ */
+ protected static $handlers;
+
+ /**
+ * Returns the ContentHandler singleton for the given model ID. Use the
+ * CONTENT_MODEL_XXX constants to identify the desired content model.
+ *
+ * ContentHandler singletons are taken from the global $wgContentHandlers
+ * array. Keys in that array are model names, the values are either
+ * ContentHandler singleton objects, or strings specifying the appropriate
+ * subclass of ContentHandler.
+ *
+ * If a class name is encountered when looking up the singleton for a given
+ * model name, the class is instantiated and the class name is replaced by
+ * the resulting singleton in $wgContentHandlers.
+ *
+ * If no ContentHandler is defined for the desired $modelId, the
+ * ContentHandler may be provided by the ContentHandlerForModelID hook.
+ * If no ContentHandler can be determined, an MWException is raised.
+ *
+ * @since 1.21
+ *
+ * @param string $modelId The ID of the content model for which to get a
+ * handler. Use CONTENT_MODEL_XXX constants.
+ *
+ * @throws MWException For internal errors and problems in the configuration.
+ * @throws MWUnknownContentModelException If no handler is known for the model ID.
+ * @return ContentHandler The ContentHandler singleton for handling the model given by the ID.
+ */
+ public static function getForModelID( $modelId ) {
+ global $wgContentHandlers;
+
+ if ( isset( self::$handlers[$modelId] ) ) {
+ return self::$handlers[$modelId];
+ }
+
+ if ( empty( $wgContentHandlers[$modelId] ) ) {
+ $handler = null;
+
+ Hooks::run( 'ContentHandlerForModelID', [ $modelId, &$handler ] );
+
+ if ( $handler === null ) {
+ throw new MWUnknownContentModelException( $modelId );
+ }
+
+ if ( !( $handler instanceof ContentHandler ) ) {
+ throw new MWException( "ContentHandlerForModelID must supply a ContentHandler instance" );
+ }
+ } else {
+ $classOrCallback = $wgContentHandlers[$modelId];
+
+ if ( is_callable( $classOrCallback ) ) {
+ $handler = call_user_func( $classOrCallback, $modelId );
+ } else {
+ $handler = new $classOrCallback( $modelId );
+ }
+
+ if ( !( $handler instanceof ContentHandler ) ) {
+ throw new MWException( "$classOrCallback from \$wgContentHandlers is not " .
+ "compatible with ContentHandler" );
+ }
+ }
+
+ wfDebugLog( 'ContentHandler', 'Created handler for ' . $modelId
+ . ': ' . get_class( $handler ) );
+
+ self::$handlers[$modelId] = $handler;
+
+ return self::$handlers[$modelId];
+ }
+
+ /**
+ * Returns the localized name for a given content model.
+ *
+ * Model names are localized using system messages. Message keys
+ * have the form content-model-$name, where $name is getContentModelName( $id ).
+ *
+ * @param string $name The content model ID, as given by a CONTENT_MODEL_XXX
+ * constant or returned by Revision::getContentModel().
+ * @param Language|null $lang The language to parse the message in (since 1.26)
+ *
+ * @throws MWException If the model ID isn't known.
+ * @return string The content model's localized name.
+ */
+ public static function getLocalizedName( $name, Language $lang = null ) {
+ // Messages: content-model-wikitext, content-model-text,
+ // content-model-javascript, content-model-css
+ $key = "content-model-$name";
+
+ $msg = wfMessage( $key );
+ if ( $lang ) {
+ $msg->inLanguage( $lang );
+ }
+
+ return $msg->exists() ? $msg->plain() : $name;
+ }
+
+ public static function getContentModels() {
+ global $wgContentHandlers;
+
+ $models = array_keys( $wgContentHandlers );
+ Hooks::run( 'GetContentModels', [ &$models ] );
+ return $models;
+ }
+
+ public static function getAllContentFormats() {
+ global $wgContentHandlers;
+
+ $formats = [];
+
+ foreach ( $wgContentHandlers as $model => $class ) {
+ $handler = self::getForModelID( $model );
+ $formats = array_merge( $formats, $handler->getSupportedFormats() );
+ }
+
+ $formats = array_unique( $formats );
+
+ return $formats;
+ }
+
+ // ------------------------------------------------------------------------
+
+ /**
+ * @var string
+ */
+ protected $mModelID;
+
+ /**
+ * @var string[]
+ */
+ protected $mSupportedFormats;
+
+ /**
+ * Constructor, initializing the ContentHandler instance with its model ID
+ * and a list of supported formats. Values for the parameters are typically
+ * provided as literals by subclass's constructors.
+ *
+ * @param string $modelId (use CONTENT_MODEL_XXX constants).
+ * @param string[] $formats List for supported serialization formats
+ * (typically as MIME types)
+ */
+ public function __construct( $modelId, $formats ) {
+ $this->mModelID = $modelId;
+ $this->mSupportedFormats = $formats;
+ }
+
+ /**
+ * Serializes a Content object of the type supported by this ContentHandler.
+ *
+ * @since 1.21
+ *
+ * @param Content $content The Content object to serialize
+ * @param string $format The desired serialization format
+ *
+ * @return string Serialized form of the content
+ */
+ abstract public function serializeContent( Content $content, $format = null );
+
+ /**
+ * Applies transformations on export (returns the blob unchanged per default).
+ * Subclasses may override this to perform transformations such as conversion
+ * of legacy formats or filtering of internal meta-data.
+ *
+ * @param string $blob The blob to be exported
+ * @param string|null $format The blob's serialization format
+ *
+ * @return string
+ */
+ public function exportTransform( $blob, $format = null ) {
+ return $blob;
+ }
+
+ /**
+ * Unserializes a Content object of the type supported by this ContentHandler.
+ *
+ * @since 1.21
+ *
+ * @param string $blob Serialized form of the content
+ * @param string $format The format used for serialization
+ *
+ * @return Content The Content object created by deserializing $blob
+ */
+ abstract public function unserializeContent( $blob, $format = null );
+
+ /**
+ * Apply import transformation (per default, returns $blob unchanged).
+ * This gives subclasses an opportunity to transform data blobs on import.
+ *
+ * @since 1.24
+ *
+ * @param string $blob
+ * @param string|null $format
+ *
+ * @return string
+ */
+ public function importTransform( $blob, $format = null ) {
+ return $blob;
+ }
+
+ /**
+ * Creates an empty Content object of the type supported by this
+ * ContentHandler.
+ *
+ * @since 1.21
+ *
+ * @return Content
+ */
+ abstract public function makeEmptyContent();
+
+ /**
+ * Creates a new Content object that acts as a redirect to the given page,
+ * or null if redirects are not supported by this content model.
+ *
+ * This default implementation always returns null. Subclasses supporting redirects
+ * must override this method.
+ *
+ * Note that subclasses that override this method to return a Content object
+ * should also override supportsRedirects() to return true.
+ *
+ * @since 1.21
+ *
+ * @param Title $destination The page to redirect to.
+ * @param string $text Text to include in the redirect, if possible.
+ *
+ * @return Content Always null.
+ */
+ public function makeRedirectContent( Title $destination, $text = '' ) {
+ return null;
+ }
+
+ /**
+ * Returns the model id that identifies the content model this
+ * ContentHandler can handle. Use with the CONTENT_MODEL_XXX constants.
+ *
+ * @since 1.21
+ *
+ * @return string The model ID
+ */
+ public function getModelID() {
+ return $this->mModelID;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string $model_id The model to check
+ *
+ * @throws MWException If the model ID is not the ID of the content model supported by this
+ * ContentHandler.
+ */
+ protected function checkModelID( $model_id ) {
+ if ( $model_id !== $this->mModelID ) {
+ throw new MWException( "Bad content model: " .
+ "expected {$this->mModelID} " .
+ "but got $model_id." );
+ }
+ }
+
+ /**
+ * Returns a list of serialization formats supported by the
+ * serializeContent() and unserializeContent() methods of this
+ * ContentHandler.
+ *
+ * @since 1.21
+ *
+ * @return string[] List of serialization formats as MIME type like strings
+ */
+ public function getSupportedFormats() {
+ return $this->mSupportedFormats;
+ }
+
+ /**
+ * The format used for serialization/deserialization by default by this
+ * ContentHandler.
+ *
+ * This default implementation will return the first element of the array
+ * of formats that was passed to the constructor.
+ *
+ * @since 1.21
+ *
+ * @return string The name of the default serialization format as a MIME type
+ */
+ public function getDefaultFormat() {
+ return $this->mSupportedFormats[0];
+ }
+
+ /**
+ * Returns true if $format is a serialization format supported by this
+ * ContentHandler, and false otherwise.
+ *
+ * Note that if $format is null, this method always returns true, because
+ * null means "use the default format".
+ *
+ * @since 1.21
+ *
+ * @param string $format The serialization format to check
+ *
+ * @return bool
+ */
+ public function isSupportedFormat( $format ) {
+ if ( !$format ) {
+ return true; // this means "use the default"
+ }
+
+ return in_array( $format, $this->mSupportedFormats );
+ }
+
+ /**
+ * Convenient for checking whether a format provided as a parameter is actually supported.
+ *
+ * @param string $format The serialization format to check
+ *
+ * @throws MWException If the format is not supported by this content handler.
+ */
+ protected function checkFormat( $format ) {
+ if ( !$this->isSupportedFormat( $format ) ) {
+ throw new MWException(
+ "Format $format is not supported for content model "
+ . $this->getModelID()
+ );
+ }
+ }
+
+ /**
+ * Returns overrides for action handlers.
+ * Classes listed here will be used instead of the default one when
+ * (and only when) $wgActions[$action] === true. This allows subclasses
+ * to override the default action handlers.
+ *
+ * @since 1.21
+ *
+ * @return array An array mapping action names (typically "view", "edit", "history" etc.) to
+ * either the full qualified class name of an Action class, a callable taking ( Page $page,
+ * IContextSource $context = null ) as parameters and returning an Action object, or an actual
+ * Action object. An empty array in this default implementation.
+ *
+ * @see Action::factory
+ */
+ public function getActionOverrides() {
+ return [];
+ }
+
+ /**
+ * Factory for creating an appropriate DifferenceEngine for this content model.
+ *
+ * @since 1.21
+ *
+ * @param IContextSource $context Context to use, anything else will be ignored.
+ * @param int $old Revision ID we want to show and diff with.
+ * @param int|string $new Either a revision ID or one of the strings 'cur', 'prev' or 'next'.
+ * @param int $rcid FIXME: Deprecated, no longer used. Defaults to 0.
+ * @param bool $refreshCache If set, refreshes the diff cache. Defaults to false.
+ * @param bool $unhide If set, allow viewing deleted revs. Defaults to false.
+ *
+ * @return DifferenceEngine
+ */
+ public function createDifferenceEngine( IContextSource $context, $old = 0, $new = 0,
+ $rcid = 0, // FIXME: Deprecated, no longer used
+ $refreshCache = false, $unhide = false
+ ) {
+ // hook: get difference engine
+ $differenceEngine = null;
+ if ( !Hooks::run( 'GetDifferenceEngine',
+ [ $context, $old, $new, $refreshCache, $unhide, &$differenceEngine ]
+ ) ) {
+ return $differenceEngine;
+ }
+ $diffEngineClass = $this->getDiffEngineClass();
+ return new $diffEngineClass( $context, $old, $new, $rcid, $refreshCache, $unhide );
+ }
+
+ /**
+ * Get the language in which the content of the given page is written.
+ *
+ * This default implementation just returns $wgContLang (except for pages
+ * in the MediaWiki namespace)
+ *
+ * Note that the pages language is not cacheable, since it may in some
+ * cases depend on user settings.
+ *
+ * Also note that the page language may or may not depend on the actual content of the page,
+ * that is, this method may load the content in order to determine the language.
+ *
+ * @since 1.21
+ *
+ * @param Title $title The page to determine the language for.
+ * @param Content $content The page's content, if you have it handy, to avoid reloading it.
+ *
+ * @return Language The page's language
+ */
+ public function getPageLanguage( Title $title, Content $content = null ) {
+ global $wgContLang, $wgLang;
+ $pageLang = $wgContLang;
+
+ if ( $title->getNamespace() == NS_MEDIAWIKI ) {
+ // Parse mediawiki messages with correct target language
+ list( /* $unused */, $lang ) = MessageCache::singleton()->figureMessage( $title->getText() );
+ $pageLang = Language::factory( $lang );
+ }
+
+ Hooks::run( 'PageContentLanguage', [ $title, &$pageLang, $wgLang ] );
+
+ return wfGetLangObj( $pageLang );
+ }
+
+ /**
+ * Get the language in which the content of this page is written when
+ * viewed by user. Defaults to $this->getPageLanguage(), but if the user
+ * specified a preferred variant, the variant will be used.
+ *
+ * This default implementation just returns $this->getPageLanguage( $title, $content ) unless
+ * the user specified a preferred variant.
+ *
+ * Note that the pages view language is not cacheable, since it depends on user settings.
+ *
+ * Also note that the page language may or may not depend on the actual content of the page,
+ * that is, this method may load the content in order to determine the language.
+ *
+ * @since 1.21
+ *
+ * @param Title $title The page to determine the language for.
+ * @param Content $content The page's content, if you have it handy, to avoid reloading it.
+ *
+ * @return Language The page's language for viewing
+ */
+ public function getPageViewLanguage( Title $title, Content $content = null ) {
+ $pageLang = $this->getPageLanguage( $title, $content );
+
+ if ( $title->getNamespace() !== NS_MEDIAWIKI ) {
+ // If the user chooses a variant, the content is actually
+ // in a language whose code is the variant code.
+ $variant = $pageLang->getPreferredVariant();
+ if ( $pageLang->getCode() !== $variant ) {
+ $pageLang = Language::factory( $variant );
+ }
+ }
+
+ return $pageLang;
+ }
+
+ /**
+ * Determines whether the content type handled by this ContentHandler
+ * can be used on the given page.
+ *
+ * This default implementation always returns true.
+ * Subclasses may override this to restrict the use of this content model to specific locations,
+ * typically based on the namespace or some other aspect of the title, such as a special suffix
+ * (e.g. ".svg" for SVG content).
+ *
+ * @note this calls the ContentHandlerCanBeUsedOn hook which may be used to override which
+ * content model can be used where.
+ *
+ * @param Title $title The page's title.
+ *
+ * @return bool True if content of this kind can be used on the given page, false otherwise.
+ */
+ public function canBeUsedOn( Title $title ) {
+ $ok = true;
+
+ Hooks::run( 'ContentModelCanBeUsedOn', [ $this->getModelID(), $title, &$ok ] );
+
+ return $ok;
+ }
+
+ /**
+ * Returns the name of the diff engine to use.
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ protected function getDiffEngineClass() {
+ return DifferenceEngine::class;
+ }
+
+ /**
+ * Attempts to merge differences between three versions. Returns a new
+ * Content object for a clean merge and false for failure or a conflict.
+ *
+ * This default implementation always returns false.
+ *
+ * @since 1.21
+ *
+ * @param Content $oldContent The page's previous content.
+ * @param Content $myContent One of the page's conflicting contents.
+ * @param Content $yourContent One of the page's conflicting contents.
+ *
+ * @return Content|bool Always false.
+ */
+ public function merge3( Content $oldContent, Content $myContent, Content $yourContent ) {
+ return false;
+ }
+
+ /**
+ * Return an applicable auto-summary if one exists for the given edit.
+ *
+ * @since 1.21
+ *
+ * @param Content $oldContent The previous text of the page.
+ * @param Content $newContent The submitted text of the page.
+ * @param int $flags Bit mask: a bit mask of flags submitted for the edit.
+ *
+ * @return string An appropriate auto-summary, or an empty string.
+ */
+ public function getAutosummary( Content $oldContent = null, Content $newContent = null,
+ $flags ) {
+ // Decide what kind of auto-summary is needed.
+
+ // Redirect auto-summaries
+
+ /**
+ * @var $ot Title
+ * @var $rt Title
+ */
+
+ $ot = !is_null( $oldContent ) ? $oldContent->getRedirectTarget() : null;
+ $rt = !is_null( $newContent ) ? $newContent->getRedirectTarget() : null;
+
+ if ( is_object( $rt ) ) {
+ if ( !is_object( $ot )
+ || !$rt->equals( $ot )
+ || $ot->getFragment() != $rt->getFragment()
+ ) {
+ $truncatedtext = $newContent->getTextForSummary(
+ 250
+ - strlen( wfMessage( 'autoredircomment' )->inContentLanguage()->text() )
+ - strlen( $rt->getFullText() ) );
+
+ return wfMessage( 'autoredircomment', $rt->getFullText() )
+ ->rawParams( $truncatedtext )->inContentLanguage()->text();
+ }
+ }
+
+ // New page auto-summaries
+ if ( $flags & EDIT_NEW && $newContent->getSize() > 0 ) {
+ // If they're making a new article, give its text, truncated, in
+ // the summary.
+
+ $truncatedtext = $newContent->getTextForSummary(
+ 200 - strlen( wfMessage( 'autosumm-new' )->inContentLanguage()->text() ) );
+
+ return wfMessage( 'autosumm-new' )->rawParams( $truncatedtext )
+ ->inContentLanguage()->text();
+ }
+
+ // Blanking auto-summaries
+ if ( !empty( $oldContent ) && $oldContent->getSize() > 0 && $newContent->getSize() == 0 ) {
+ return wfMessage( 'autosumm-blank' )->inContentLanguage()->text();
+ } elseif ( !empty( $oldContent )
+ && $oldContent->getSize() > 10 * $newContent->getSize()
+ && $newContent->getSize() < 500
+ ) {
+ // Removing more than 90% of the article
+
+ $truncatedtext = $newContent->getTextForSummary(
+ 200 - strlen( wfMessage( 'autosumm-replace' )->inContentLanguage()->text() ) );
+
+ return wfMessage( 'autosumm-replace' )->rawParams( $truncatedtext )
+ ->inContentLanguage()->text();
+ }
+
+ // New blank article auto-summary
+ if ( $flags & EDIT_NEW && $newContent->isEmpty() ) {
+ return wfMessage( 'autosumm-newblank' )->inContentLanguage()->text();
+ }
+
+ // If we reach this point, there's no applicable auto-summary for our
+ // case, so our auto-summary is empty.
+ return '';
+ }
+
+ /**
+ * Auto-generates a deletion reason
+ *
+ * @since 1.21
+ *
+ * @param Title $title The page's title
+ * @param bool &$hasHistory Whether the page has a history
+ *
+ * @return mixed String containing deletion reason or empty string, or
+ * boolean false if no revision occurred
+ *
+ * @todo &$hasHistory is extremely ugly, it's here because
+ * WikiPage::getAutoDeleteReason() and Article::generateReason()
+ * have it / want it.
+ */
+ public function getAutoDeleteReason( Title $title, &$hasHistory ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ // Get the last revision
+ $rev = Revision::newFromTitle( $title );
+
+ if ( is_null( $rev ) ) {
+ return false;
+ }
+
+ // Get the article's contents
+ $content = $rev->getContent();
+ $blank = false;
+
+ // If the page is blank, use the text from the previous revision,
+ // which can only be blank if there's a move/import/protect dummy
+ // revision involved
+ if ( !$content || $content->isEmpty() ) {
+ $prev = $rev->getPrevious();
+
+ if ( $prev ) {
+ $rev = $prev;
+ $content = $rev->getContent();
+ $blank = true;
+ }
+ }
+
+ $this->checkModelID( $rev->getContentModel() );
+
+ // Find out if there was only one contributor
+ // Only scan the last 20 revisions
+ $res = $dbr->select( 'revision', 'rev_user_text',
+ [
+ 'rev_page' => $title->getArticleID(),
+ $dbr->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0'
+ ],
+ __METHOD__,
+ [ 'LIMIT' => 20 ]
+ );
+
+ if ( $res === false ) {
+ // This page has no revisions, which is very weird
+ return false;
+ }
+
+ $hasHistory = ( $res->numRows() > 1 );
+ $row = $dbr->fetchObject( $res );
+
+ if ( $row ) { // $row is false if the only contributor is hidden
+ $onlyAuthor = $row->rev_user_text;
+ // Try to find a second contributor
+ foreach ( $res as $row ) {
+ if ( $row->rev_user_text != $onlyAuthor ) { // T24999
+ $onlyAuthor = false;
+ break;
+ }
+ }
+ } else {
+ $onlyAuthor = false;
+ }
+
+ // Generate the summary with a '$1' placeholder
+ if ( $blank ) {
+ // The current revision is blank and the one before is also
+ // blank. It's just not our lucky day
+ $reason = wfMessage( 'exbeforeblank', '$1' )->inContentLanguage()->text();
+ } else {
+ if ( $onlyAuthor ) {
+ $reason = wfMessage(
+ 'excontentauthor',
+ '$1',
+ $onlyAuthor
+ )->inContentLanguage()->text();
+ } else {
+ $reason = wfMessage( 'excontent', '$1' )->inContentLanguage()->text();
+ }
+ }
+
+ if ( $reason == '-' ) {
+ // Allow these UI messages to be blanked out cleanly
+ return '';
+ }
+
+ // Max content length = max comment length - length of the comment (excl. $1)
+ $text = $content ? $content->getTextForSummary( 255 - ( strlen( $reason ) - 2 ) ) : '';
+
+ // Now replace the '$1' placeholder
+ $reason = str_replace( '$1', $text, $reason );
+
+ return $reason;
+ }
+
+ /**
+ * Get the Content object that needs to be saved in order to undo all revisions
+ * between $undo and $undoafter. Revisions must belong to the same page,
+ * must exist and must not be deleted.
+ *
+ * @since 1.21
+ *
+ * @param Revision $current The current text
+ * @param Revision $undo The revision to undo
+ * @param Revision $undoafter Must be an earlier revision than $undo
+ *
+ * @return mixed String on success, false on failure
+ */
+ public function getUndoContent( Revision $current, Revision $undo, Revision $undoafter ) {
+ $cur_content = $current->getContent();
+
+ if ( empty( $cur_content ) ) {
+ return false; // no page
+ }
+
+ $undo_content = $undo->getContent();
+ $undoafter_content = $undoafter->getContent();
+
+ if ( !$undo_content || !$undoafter_content ) {
+ return false; // no content to undo
+ }
+
+ try {
+ $this->checkModelID( $cur_content->getModel() );
+ $this->checkModelID( $undo_content->getModel() );
+ if ( $current->getId() !== $undo->getId() ) {
+ // If we are undoing the most recent revision,
+ // its ok to revert content model changes. However
+ // if we are undoing a revision in the middle, then
+ // doing that will be confusing.
+ $this->checkModelID( $undoafter_content->getModel() );
+ }
+ } catch ( MWException $e ) {
+ // If the revisions have different content models
+ // just return false
+ return false;
+ }
+
+ if ( $cur_content->equals( $undo_content ) ) {
+ // No use doing a merge if it's just a straight revert.
+ return $undoafter_content;
+ }
+
+ $undone_content = $this->merge3( $undo_content, $undoafter_content, $cur_content );
+
+ return $undone_content;
+ }
+
+ /**
+ * Get parser options suitable for rendering and caching the article
+ *
+ * @param IContextSource|User|string $context One of the following:
+ * - IContextSource: Use the User and the Language of the provided
+ * context
+ * - User: Use the provided User object and $wgLang for the language,
+ * so use an IContextSource object if possible.
+ * - 'canonical': Canonical options (anonymous user with default
+ * preferences and content language).
+ *
+ * @throws MWException
+ * @return ParserOptions
+ */
+ public function makeParserOptions( $context ) {
+ global $wgContLang;
+
+ if ( $context instanceof IContextSource ) {
+ $user = $context->getUser();
+ $lang = $context->getLanguage();
+ } elseif ( $context instanceof User ) { // settings per user (even anons)
+ $user = $context;
+ $lang = null;
+ } elseif ( $context === 'canonical' ) { // canonical settings
+ $user = new User;
+ $lang = $wgContLang;
+ } else {
+ throw new MWException( "Bad context for parser options: $context" );
+ }
+
+ return ParserOptions::newCanonical( $user, $lang );
+ }
+
+ /**
+ * Returns true for content models that support caching using the
+ * ParserCache mechanism. See WikiPage::shouldCheckParserCache().
+ *
+ * @since 1.21
+ *
+ * @return bool Always false.
+ */
+ public function isParserCacheSupported() {
+ return false;
+ }
+
+ /**
+ * Returns true if this content model supports sections.
+ * This default implementation returns false.
+ *
+ * Content models that return true here should also implement
+ * Content::getSection, Content::replaceSection, etc. to handle sections..
+ *
+ * @return bool Always false.
+ */
+ public function supportsSections() {
+ return false;
+ }
+
+ /**
+ * Returns true if this content model supports categories.
+ * The default implementation returns true.
+ *
+ * @return bool Always true.
+ */
+ public function supportsCategories() {
+ return true;
+ }
+
+ /**
+ * Returns true if this content model supports redirects.
+ * This default implementation returns false.
+ *
+ * Content models that return true here should also implement
+ * ContentHandler::makeRedirectContent to return a Content object.
+ *
+ * @return bool Always false.
+ */
+ public function supportsRedirects() {
+ return false;
+ }
+
+ /**
+ * Return true if this content model supports direct editing, such as via EditPage.
+ *
+ * @return bool Default is false, and true for TextContent and it's derivatives.
+ */
+ public function supportsDirectEditing() {
+ return false;
+ }
+
+ /**
+ * Whether or not this content model supports direct editing via ApiEditPage
+ *
+ * @return bool Default is false, and true for TextContent and derivatives.
+ */
+ public function supportsDirectApiEditing() {
+ return $this->supportsDirectEditing();
+ }
+
+ /**
+ * Get fields definition for search index
+ *
+ * @todo Expose title, redirect, namespace, text, source_text, text_bytes
+ * field mappings here. (see T142670 and T143409)
+ *
+ * @param SearchEngine $engine
+ * @return SearchIndexField[] List of fields this content handler can provide.
+ * @since 1.28
+ */
+ public function getFieldsForSearchIndex( SearchEngine $engine ) {
+ $fields['category'] = $engine->makeSearchFieldMapping(
+ 'category',
+ SearchIndexField::INDEX_TYPE_TEXT
+ );
+ $fields['category']->setFlag( SearchIndexField::FLAG_CASEFOLD );
+
+ $fields['external_link'] = $engine->makeSearchFieldMapping(
+ 'external_link',
+ SearchIndexField::INDEX_TYPE_KEYWORD
+ );
+
+ $fields['outgoing_link'] = $engine->makeSearchFieldMapping(
+ 'outgoing_link',
+ SearchIndexField::INDEX_TYPE_KEYWORD
+ );
+
+ $fields['template'] = $engine->makeSearchFieldMapping(
+ 'template',
+ SearchIndexField::INDEX_TYPE_KEYWORD
+ );
+ $fields['template']->setFlag( SearchIndexField::FLAG_CASEFOLD );
+
+ $fields['content_model'] = $engine->makeSearchFieldMapping(
+ 'content_model',
+ SearchIndexField::INDEX_TYPE_KEYWORD
+ );
+
+ return $fields;
+ }
+
+ /**
+ * Add new field definition to array.
+ * @param SearchIndexField[] &$fields
+ * @param SearchEngine $engine
+ * @param string $name
+ * @param int $type
+ * @return SearchIndexField[] new field defs
+ * @since 1.28
+ */
+ protected function addSearchField( &$fields, SearchEngine $engine, $name, $type ) {
+ $fields[$name] = $engine->makeSearchFieldMapping( $name, $type );
+ return $fields;
+ }
+
+ /**
+ * Return fields to be indexed by search engine
+ * as representation of this document.
+ * Overriding class should call parent function or take care of calling
+ * the SearchDataForIndex hook.
+ * @param WikiPage $page Page to index
+ * @param ParserOutput $output
+ * @param SearchEngine $engine Search engine for which we are indexing
+ * @return array Map of name=>value for fields
+ * @since 1.28
+ */
+ public function getDataForSearchIndex(
+ WikiPage $page,
+ ParserOutput $output,
+ SearchEngine $engine
+ ) {
+ $fieldData = [];
+ $content = $page->getContent();
+
+ if ( $content ) {
+ $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+ $fieldData['category'] = $searchDataExtractor->getCategories( $output );
+ $fieldData['external_link'] = $searchDataExtractor->getExternalLinks( $output );
+ $fieldData['outgoing_link'] = $searchDataExtractor->getOutgoingLinks( $output );
+ $fieldData['template'] = $searchDataExtractor->getTemplates( $output );
+
+ $text = $content->getTextForSearchIndex();
+
+ $fieldData['text'] = $text;
+ $fieldData['source_text'] = $text;
+ $fieldData['text_bytes'] = $content->getSize();
+ $fieldData['content_model'] = $content->getModel();
+ }
+
+ Hooks::run( 'SearchDataForIndex', [ &$fieldData, $this, $page, $output, $engine ] );
+ return $fieldData;
+ }
+
+ /**
+ * Produce page output suitable for indexing.
+ *
+ * Specific content handlers may override it if they need different content handling.
+ *
+ * @param WikiPage $page
+ * @param ParserCache $cache
+ * @return ParserOutput
+ */
+ public function getParserOutputForIndexing( WikiPage $page, ParserCache $cache = null ) {
+ $parserOptions = $page->makeParserOptions( 'canonical' );
+ $revId = $page->getRevision()->getId();
+ if ( $cache ) {
+ $parserOutput = $cache->get( $page, $parserOptions );
+ }
+ if ( empty( $parserOutput ) ) {
+ $parserOutput =
+ $page->getContent()->getParserOutput( $page->getTitle(), $revId, $parserOptions );
+ if ( $cache ) {
+ $cache->save( $parserOutput, $page, $parserOptions );
+ }
+ }
+ return $parserOutput;
+ }
+
+}
diff --git a/www/wiki/includes/content/CssContent.php b/www/wiki/includes/content/CssContent.php
new file mode 100644
index 00000000..b4f5196d
--- /dev/null
+++ b/www/wiki/includes/content/CssContent.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * Content object for CSS pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ *
+ * @author Daniel Kinzler
+ */
+
+/**
+ * Content object for CSS pages.
+ *
+ * @ingroup Content
+ */
+class CssContent extends TextContent {
+
+ /**
+ * @var bool|Title|null
+ */
+ private $redirectTarget = false;
+
+ /**
+ * @param string $text CSS code.
+ * @param string $modelId the content content model
+ */
+ public function __construct( $text, $modelId = CONTENT_MODEL_CSS ) {
+ parent::__construct( $text, $modelId );
+ }
+
+ /**
+ * Returns a Content object with pre-save transformations applied using
+ * Parser::preSaveTransform().
+ *
+ * @param Title $title
+ * @param User $user
+ * @param ParserOptions $popts
+ *
+ * @return CssContent
+ *
+ * @see TextContent::preSaveTransform
+ */
+ public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
+ global $wgParser;
+ // @todo Make pre-save transformation optional for script pages
+
+ $text = $this->getNativeData();
+ $pst = $wgParser->preSaveTransform( $text, $title, $user, $popts );
+
+ return new static( $pst );
+ }
+
+ /**
+ * @return string CSS wrapped in a <pre> tag.
+ */
+ protected function getHtml() {
+ $html = "";
+ $html .= "<pre class=\"mw-code mw-css\" dir=\"ltr\">\n";
+ $html .= htmlspecialchars( $this->getNativeData() );
+ $html .= "\n</pre>\n";
+
+ return $html;
+ }
+
+ /**
+ * @param Title $target
+ * @return CssContent
+ */
+ public function updateRedirect( Title $target ) {
+ if ( !$this->isRedirect() ) {
+ return $this;
+ }
+
+ return $this->getContentHandler()->makeRedirectContent( $target );
+ }
+
+ /**
+ * @return Title|null
+ */
+ public function getRedirectTarget() {
+ if ( $this->redirectTarget !== false ) {
+ return $this->redirectTarget;
+ }
+ $this->redirectTarget = null;
+ $text = $this->getNativeData();
+ if ( strpos( $text, '/* #REDIRECT */' ) === 0 ) {
+ // Extract the title from the url
+ preg_match( '/title=(.*?)&action=raw/', $text, $matches );
+ if ( isset( $matches[1] ) ) {
+ $title = Title::newFromText( $matches[1] );
+ if ( $title ) {
+ // Have a title, check that the current content equals what
+ // the redirect content should be
+ if ( $this->equals( $this->getContentHandler()->makeRedirectContent( $title ) ) ) {
+ $this->redirectTarget = $title;
+ }
+ }
+ }
+ }
+
+ return $this->redirectTarget;
+ }
+
+}
diff --git a/www/wiki/includes/content/CssContentHandler.php b/www/wiki/includes/content/CssContentHandler.php
new file mode 100644
index 00000000..9c110353
--- /dev/null
+++ b/www/wiki/includes/content/CssContentHandler.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Content handler for CSS pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Content
+ */
+
+/**
+ * Content handler for CSS pages.
+ *
+ * @since 1.21
+ * @ingroup Content
+ */
+class CssContentHandler extends CodeContentHandler {
+
+ /**
+ * @param string $modelId
+ */
+ public function __construct( $modelId = CONTENT_MODEL_CSS ) {
+ parent::__construct( $modelId, [ CONTENT_FORMAT_CSS ] );
+ }
+
+ protected function getContentClass() {
+ return CssContent::class;
+ }
+
+ public function supportsRedirects() {
+ return true;
+ }
+
+ /**
+ * Create a redirect that is also valid CSS
+ *
+ * @param Title $destination
+ * @param string $text ignored
+ * @return CssContent
+ */
+ public function makeRedirectContent( Title $destination, $text = '' ) {
+ // The parameters are passed as a string so the / is not url-encoded by wfArrayToCgi
+ $url = $destination->getFullURL( 'action=raw&ctype=text/css', false, PROTO_RELATIVE );
+ $class = $this->getContentClass();
+ return new $class( '/* #REDIRECT */@import ' . CSSMin::buildUrlValue( $url ) . ';' );
+ }
+
+}
diff --git a/www/wiki/includes/content/FileContentHandler.php b/www/wiki/includes/content/FileContentHandler.php
new file mode 100644
index 00000000..3028dfda
--- /dev/null
+++ b/www/wiki/includes/content/FileContentHandler.php
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * Content handler for File: files
+ * TODO: this handler s not used directly now,
+ * but instead manually called by WikitextHandler.
+ * This should be fixed in the future.
+ */
+class FileContentHandler extends WikitextContentHandler {
+
+ public function getFieldsForSearchIndex( SearchEngine $engine ) {
+ $fields['file_media_type'] =
+ $engine->makeSearchFieldMapping( 'file_media_type', SearchIndexField::INDEX_TYPE_KEYWORD );
+ $fields['file_media_type']->setFlag( SearchIndexField::FLAG_CASEFOLD );
+ $fields['file_mime'] =
+ $engine->makeSearchFieldMapping( 'file_mime', SearchIndexField::INDEX_TYPE_SHORT_TEXT );
+ $fields['file_mime']->setFlag( SearchIndexField::FLAG_CASEFOLD );
+ $fields['file_size'] =
+ $engine->makeSearchFieldMapping( 'file_size', SearchIndexField::INDEX_TYPE_INTEGER );
+ $fields['file_width'] =
+ $engine->makeSearchFieldMapping( 'file_width', SearchIndexField::INDEX_TYPE_INTEGER );
+ $fields['file_height'] =
+ $engine->makeSearchFieldMapping( 'file_height', SearchIndexField::INDEX_TYPE_INTEGER );
+ $fields['file_bits'] =
+ $engine->makeSearchFieldMapping( 'file_bits', SearchIndexField::INDEX_TYPE_INTEGER );
+ $fields['file_resolution'] =
+ $engine->makeSearchFieldMapping( 'file_resolution', SearchIndexField::INDEX_TYPE_INTEGER );
+ $fields['file_text'] =
+ $engine->makeSearchFieldMapping( 'file_text', SearchIndexField::INDEX_TYPE_TEXT );
+ return $fields;
+ }
+
+ public function getDataForSearchIndex(
+ WikiPage $page,
+ ParserOutput $parserOutput,
+ SearchEngine $engine
+ ) {
+ $fields = [];
+
+ $title = $page->getTitle();
+ if ( NS_FILE != $title->getNamespace() ) {
+ return [];
+ }
+ $file = wfLocalFile( $title );
+ if ( !$file || !$file->exists() ) {
+ return [];
+ }
+
+ $handler = $file->getHandler();
+ if ( $handler ) {
+ $fields['file_text'] = $handler->getEntireText( $file );
+ }
+ $fields['file_media_type'] = $file->getMediaType();
+ $fields['file_mime'] = $file->getMimeType();
+ $fields['file_size'] = $file->getSize();
+ $fields['file_width'] = $file->getWidth();
+ $fields['file_height'] = $file->getHeight();
+ $fields['file_bits'] = $file->getBitDepth();
+ $fields['file_resolution'] =
+ (int)floor( sqrt( $fields['file_width'] * $fields['file_height'] ) );
+
+ return $fields;
+ }
+
+}
diff --git a/www/wiki/includes/content/JavaScriptContent.php b/www/wiki/includes/content/JavaScriptContent.php
new file mode 100644
index 00000000..6d236560
--- /dev/null
+++ b/www/wiki/includes/content/JavaScriptContent.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * Content for JavaScript pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ *
+ * @author Daniel Kinzler
+ */
+
+/**
+ * Content for JavaScript pages.
+ *
+ * @ingroup Content
+ */
+class JavaScriptContent extends TextContent {
+
+ /**
+ * @var bool|Title|null
+ */
+ private $redirectTarget = false;
+
+ /**
+ * @param string $text JavaScript code.
+ * @param string $modelId the content model name
+ */
+ public function __construct( $text, $modelId = CONTENT_MODEL_JAVASCRIPT ) {
+ parent::__construct( $text, $modelId );
+ }
+
+ /**
+ * Returns a Content object with pre-save transformations applied using
+ * Parser::preSaveTransform().
+ *
+ * @param Title $title
+ * @param User $user
+ * @param ParserOptions $popts
+ *
+ * @return JavaScriptContent
+ */
+ public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
+ global $wgParser;
+ // @todo Make pre-save transformation optional for script pages
+ // See bug #32858
+
+ $text = $this->getNativeData();
+ $pst = $wgParser->preSaveTransform( $text, $title, $user, $popts );
+
+ return new static( $pst );
+ }
+
+ /**
+ * @return string JavaScript wrapped in a <pre> tag.
+ */
+ protected function getHtml() {
+ $html = "";
+ $html .= "<pre class=\"mw-code mw-js\" dir=\"ltr\">\n";
+ $html .= htmlspecialchars( $this->getNativeData() );
+ $html .= "\n</pre>\n";
+
+ return $html;
+ }
+
+ /**
+ * If this page is a redirect, return the content
+ * if it should redirect to $target instead
+ *
+ * @param Title $target
+ * @return JavaScriptContent
+ */
+ public function updateRedirect( Title $target ) {
+ if ( !$this->isRedirect() ) {
+ return $this;
+ }
+
+ return $this->getContentHandler()->makeRedirectContent( $target );
+ }
+
+ /**
+ * @return Title|null
+ */
+ public function getRedirectTarget() {
+ if ( $this->redirectTarget !== false ) {
+ return $this->redirectTarget;
+ }
+ $this->redirectTarget = null;
+ $text = $this->getNativeData();
+ if ( strpos( $text, '/* #REDIRECT */' ) === 0 ) {
+ // Extract the title from the url
+ preg_match( '/title=(.*?)\\\\u0026action=raw/', $text, $matches );
+ if ( isset( $matches[1] ) ) {
+ $title = Title::newFromText( $matches[1] );
+ if ( $title ) {
+ // Have a title, check that the current content equals what
+ // the redirect content should be
+ if ( $this->equals( $this->getContentHandler()->makeRedirectContent( $title ) ) ) {
+ $this->redirectTarget = $title;
+ }
+ }
+ }
+ }
+
+ return $this->redirectTarget;
+ }
+
+}
diff --git a/www/wiki/includes/content/JavaScriptContentHandler.php b/www/wiki/includes/content/JavaScriptContentHandler.php
new file mode 100644
index 00000000..9abad3e2
--- /dev/null
+++ b/www/wiki/includes/content/JavaScriptContentHandler.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Content handler for JavaScript pages.
+ *
+ * @todo Create a ScriptContentHandler base class, do highlighting stuff there?
+ *
+ * @since 1.21
+ * @ingroup Content
+ */
+class JavaScriptContentHandler extends CodeContentHandler {
+
+ /**
+ * @param string $modelId
+ */
+ public function __construct( $modelId = CONTENT_MODEL_JAVASCRIPT ) {
+ parent::__construct( $modelId, [ CONTENT_FORMAT_JAVASCRIPT ] );
+ }
+
+ /**
+ * @return string
+ */
+ protected function getContentClass() {
+ return JavaScriptContent::class;
+ }
+
+ public function supportsRedirects() {
+ return true;
+ }
+
+ /**
+ * Create a redirect that is also valid JavaScript
+ *
+ * @param Title $destination
+ * @param string $text ignored
+ * @return JavaScriptContent
+ */
+ public function makeRedirectContent( Title $destination, $text = '' ) {
+ // The parameters are passed as a string so the / is not url-encoded by wfArrayToCgi
+ $url = $destination->getFullURL( 'action=raw&ctype=text/javascript', false, PROTO_RELATIVE );
+ $class = $this->getContentClass();
+ return new $class( '/* #REDIRECT */' . Xml::encodeJsCall( 'mw.loader.load', [ $url ] ) );
+ }
+}
diff --git a/www/wiki/includes/content/JsonContent.php b/www/wiki/includes/content/JsonContent.php
new file mode 100644
index 00000000..2b94f3f7
--- /dev/null
+++ b/www/wiki/includes/content/JsonContent.php
@@ -0,0 +1,251 @@
+<?php
+/**
+ * JSON Content Model
+ *
+ * @file
+ *
+ * @author Ori Livneh <ori@wikimedia.org>
+ * @author Kunal Mehta <legoktm@gmail.com>
+ */
+
+/**
+ * Represents the content of a JSON content.
+ * @since 1.24
+ */
+class JsonContent extends TextContent {
+
+ /**
+ * @since 1.25
+ * @var Status
+ */
+ protected $jsonParse;
+
+ /**
+ * @param string $text JSON
+ * @param string $modelId
+ */
+ public function __construct( $text, $modelId = CONTENT_MODEL_JSON ) {
+ parent::__construct( $text, $modelId );
+ }
+
+ /**
+ * Decodes the JSON into a PHP associative array.
+ *
+ * @deprecated since 1.25 Use getData instead.
+ * @return array|null
+ */
+ public function getJsonData() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return FormatJson::decode( $this->getNativeData(), true );
+ }
+
+ /**
+ * Decodes the JSON string.
+ *
+ * Note that this parses it without casting objects to associative arrays.
+ * Objects and arrays are kept as distinguishable types in the PHP values.
+ *
+ * @return Status
+ */
+ public function getData() {
+ if ( $this->jsonParse === null ) {
+ $this->jsonParse = FormatJson::parse( $this->getNativeData() );
+ }
+ return $this->jsonParse;
+ }
+
+ /**
+ * @return bool Whether content is valid.
+ */
+ public function isValid() {
+ return $this->getData()->isGood();
+ }
+
+ /**
+ * Pretty-print JSON.
+ *
+ * If called before validation, it may return JSON "null".
+ *
+ * @return string
+ */
+ public function beautifyJSON() {
+ return FormatJson::encode( $this->getData()->getValue(), true, FormatJson::UTF8_OK );
+ }
+
+ /**
+ * Beautifies JSON prior to save.
+ *
+ * @param Title $title Title
+ * @param User $user User
+ * @param ParserOptions $popts
+ * @return JsonContent
+ */
+ public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
+ // FIXME: WikiPage::doEditContent invokes PST before validation. As such, native data
+ // may be invalid (though PST result is discarded later in that case).
+ if ( !$this->isValid() ) {
+ return $this;
+ }
+
+ return new static( self::normalizeLineEndings( $this->beautifyJSON() ) );
+ }
+
+ /**
+ * Set the HTML and add the appropriate styles.
+ *
+ * @param Title $title
+ * @param int $revId
+ * @param ParserOptions $options
+ * @param bool $generateHtml
+ * @param ParserOutput &$output
+ */
+ protected function fillParserOutput( Title $title, $revId,
+ ParserOptions $options, $generateHtml, ParserOutput &$output
+ ) {
+ // FIXME: WikiPage::doEditContent generates parser output before validation.
+ // As such, native data may be invalid (though output is discarded later in that case).
+ if ( $generateHtml && $this->isValid() ) {
+ $output->setText( $this->rootValueTable( $this->getData()->getValue() ) );
+ $output->addModuleStyles( 'mediawiki.content.json' );
+ } else {
+ $output->setText( '' );
+ }
+ }
+
+ /**
+ * Construct HTML table representation of any JSON value.
+ *
+ * See also valueCell, which is similar.
+ *
+ * @param mixed $val
+ * @return string HTML.
+ */
+ protected function rootValueTable( $val ) {
+ if ( is_object( $val ) ) {
+ return $this->objectTable( $val );
+ }
+
+ if ( is_array( $val ) ) {
+ // Wrap arrays in another array so that they're visually boxed in a container.
+ // Otherwise they are visually indistinguishable from a single value.
+ return $this->arrayTable( [ $val ] );
+ }
+
+ return Html::rawElement( 'table', [ 'class' => 'mw-json mw-json-single-value' ],
+ Html::rawElement( 'tbody', [],
+ Html::rawElement( 'tr', [],
+ Html::element( 'td', [], $this->primitiveValue( $val ) )
+ )
+ )
+ );
+ }
+
+ /**
+ * Create HTML table representing a JSON object.
+ *
+ * @param stdClass $mapping
+ * @return string HTML
+ */
+ protected function objectTable( $mapping ) {
+ $rows = [];
+ $empty = true;
+
+ foreach ( $mapping as $key => $val ) {
+ $rows[] = $this->objectRow( $key, $val );
+ $empty = false;
+ }
+ if ( $empty ) {
+ $rows[] = Html::rawElement( 'tr', [],
+ Html::element( 'td', [ 'class' => 'mw-json-empty' ],
+ wfMessage( 'content-json-empty-object' )->text()
+ )
+ );
+ }
+ return Html::rawElement( 'table', [ 'class' => 'mw-json' ],
+ Html::rawElement( 'tbody', [], implode( '', $rows ) )
+ );
+ }
+
+ /**
+ * Create HTML table row representing one object property.
+ *
+ * @param string $key
+ * @param mixed $val
+ * @return string HTML.
+ */
+ protected function objectRow( $key, $val ) {
+ $th = Html::element( 'th', [], $key );
+ $td = $this->valueCell( $val );
+ return Html::rawElement( 'tr', [], $th . $td );
+ }
+
+ /**
+ * Create HTML table representing a JSON array.
+ *
+ * @param array $mapping
+ * @return string HTML
+ */
+ protected function arrayTable( $mapping ) {
+ $rows = [];
+ $empty = true;
+
+ foreach ( $mapping as $val ) {
+ $rows[] = $this->arrayRow( $val );
+ $empty = false;
+ }
+ if ( $empty ) {
+ $rows[] = Html::rawElement( 'tr', [],
+ Html::element( 'td', [ 'class' => 'mw-json-empty' ],
+ wfMessage( 'content-json-empty-array' )->text()
+ )
+ );
+ }
+ return Html::rawElement( 'table', [ 'class' => 'mw-json' ],
+ Html::rawElement( 'tbody', [], implode( "\n", $rows ) )
+ );
+ }
+
+ /**
+ * Create HTML table row representing the value in an array.
+ *
+ * @param mixed $val
+ * @return string HTML.
+ */
+ protected function arrayRow( $val ) {
+ $td = $this->valueCell( $val );
+ return Html::rawElement( 'tr', [], $td );
+ }
+
+ /**
+ * Construct HTML table cell representing any JSON value.
+ *
+ * @param mixed $val
+ * @return string HTML.
+ */
+ protected function valueCell( $val ) {
+ if ( is_object( $val ) ) {
+ return Html::rawElement( 'td', [], $this->objectTable( $val ) );
+ }
+
+ if ( is_array( $val ) ) {
+ return Html::rawElement( 'td', [], $this->arrayTable( $val ) );
+ }
+
+ return Html::element( 'td', [ 'class' => 'value' ], $this->primitiveValue( $val ) );
+ }
+
+ /**
+ * Construct text representing a JSON primitive value.
+ *
+ * @param mixed $val
+ * @return string Text.
+ */
+ protected function primitiveValue( $val ) {
+ if ( is_string( $val ) ) {
+ // Don't FormatJson::encode for strings since we want quotes
+ // and new lines to render visually instead of escaped.
+ return '"' . $val . '"';
+ }
+ return FormatJson::encode( $val );
+ }
+}
diff --git a/www/wiki/includes/content/JsonContentHandler.php b/www/wiki/includes/content/JsonContentHandler.php
new file mode 100644
index 00000000..edb21f68
--- /dev/null
+++ b/www/wiki/includes/content/JsonContentHandler.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Content handler for JSON.
+ *
+ * @author Ori Livneh <ori@wikimedia.org>
+ * @author Kunal Mehta <legoktm@gmail.com>
+ *
+ * @since 1.24
+ * @ingroup Content
+ */
+class JsonContentHandler extends CodeContentHandler {
+
+ public function __construct( $modelId = CONTENT_MODEL_JSON ) {
+ parent::__construct( $modelId, [ CONTENT_FORMAT_JSON ] );
+ }
+
+ /**
+ * @return string
+ */
+ protected function getContentClass() {
+ return JsonContent::class;
+ }
+
+ public function makeEmptyContent() {
+ $class = $this->getContentClass();
+ return new $class( '{}' );
+ }
+}
diff --git a/www/wiki/includes/content/MessageContent.php b/www/wiki/includes/content/MessageContent.php
new file mode 100644
index 00000000..4b589893
--- /dev/null
+++ b/www/wiki/includes/content/MessageContent.php
@@ -0,0 +1,174 @@
+<?php
+/**
+ * Wrapper content object allowing to handle a system message as a Content object.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ *
+ * @author Daniel Kinzler
+ */
+
+/**
+ * Wrapper allowing us to handle a system message as a Content object.
+ * Note that this is generally *not* used to represent content from the
+ * MediaWiki namespace, and that there is no MessageContentHandler.
+ * MessageContent is just intended as glue for wrapping a message programmatically.
+ *
+ * @ingroup Content
+ */
+class MessageContent extends AbstractContent {
+
+ /**
+ * @var Message
+ */
+ protected $mMessage;
+
+ /**
+ * @param Message|string $msg A Message object, or a message key.
+ * @param string[] $params An optional array of message parameters.
+ */
+ public function __construct( $msg, $params = null ) {
+ # XXX: messages may be wikitext, html or plain text! and maybe even something else entirely.
+ parent::__construct( CONTENT_MODEL_WIKITEXT );
+
+ if ( is_string( $msg ) ) {
+ $this->mMessage = wfMessage( $msg );
+ } else {
+ $this->mMessage = clone $msg;
+ }
+
+ if ( $params ) {
+ $this->mMessage = $this->mMessage->params( $params );
+ }
+ }
+
+ /**
+ * Fully parse the text from wikitext to HTML.
+ *
+ * @return string Parsed HTML.
+ */
+ public function getHtml() {
+ return $this->mMessage->parse();
+ }
+
+ /**
+ * Returns the message text. {{-transformation is done.
+ *
+ * @return string Unescaped message text.
+ */
+ public function getWikitext() {
+ return $this->mMessage->text();
+ }
+
+ /**
+ * Returns the message object, with any parameters already substituted.
+ *
+ * @return Message The message object.
+ */
+ public function getNativeData() {
+ // NOTE: Message objects are mutable. Cloning here makes MessageContent immutable.
+ return clone $this->mMessage;
+ }
+
+ /**
+ * @return string
+ *
+ * @see Content::getTextForSearchIndex
+ */
+ public function getTextForSearchIndex() {
+ return $this->mMessage->plain();
+ }
+
+ /**
+ * @return string
+ *
+ * @see Content::getWikitextForTransclusion
+ */
+ public function getWikitextForTransclusion() {
+ return $this->getWikitext();
+ }
+
+ /**
+ * @param int $maxlength Maximum length of the summary text, defaults to 250.
+ *
+ * @return string The summary text.
+ *
+ * @see Content::getTextForSummary
+ */
+ public function getTextForSummary( $maxlength = 250 ) {
+ return substr( $this->mMessage->plain(), 0, $maxlength );
+ }
+
+ /**
+ * @return int
+ *
+ * @see Content::getSize
+ */
+ public function getSize() {
+ return strlen( $this->mMessage->plain() );
+ }
+
+ /**
+ * @return Content A copy of this object
+ *
+ * @see Content::copy
+ */
+ public function copy() {
+ // MessageContent is immutable (because getNativeData() returns a clone of the Message object)
+ return $this;
+ }
+
+ /**
+ * @param bool|null $hasLinks
+ *
+ * @return bool Always false.
+ *
+ * @see Content::isCountable
+ */
+ public function isCountable( $hasLinks = null ) {
+ return false;
+ }
+
+ /**
+ * @param Title $title Unused.
+ * @param int $revId Unused.
+ * @param ParserOptions $options Unused.
+ * @param bool $generateHtml Whether to generate HTML (default: true).
+ *
+ * @return ParserOutput
+ *
+ * @see Content::getParserOutput
+ */
+ public function getParserOutput( Title $title, $revId = null,
+ ParserOptions $options = null, $generateHtml = true ) {
+ if ( $generateHtml ) {
+ $html = $this->getHtml();
+ } else {
+ $html = '';
+ }
+
+ $po = new ParserOutput( $html );
+ // Message objects are in the user language.
+ $po->recordOption( 'userlang' );
+
+ return $po;
+ }
+
+}
diff --git a/www/wiki/includes/content/TextContent.php b/www/wiki/includes/content/TextContent.php
new file mode 100644
index 00000000..5f585bc9
--- /dev/null
+++ b/www/wiki/includes/content/TextContent.php
@@ -0,0 +1,325 @@
+<?php
+/**
+ * Content object implementation for representing flat text.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ *
+ * @author Daniel Kinzler
+ */
+
+/**
+ * Content object implementation for representing flat text.
+ *
+ * TextContent instances are immutable
+ *
+ * @ingroup Content
+ */
+class TextContent extends AbstractContent {
+
+ /**
+ * @param string $text
+ * @param string $model_id
+ * @throws MWException
+ */
+ public function __construct( $text, $model_id = CONTENT_MODEL_TEXT ) {
+ parent::__construct( $model_id );
+
+ if ( $text === null || $text === false ) {
+ wfWarn( "TextContent constructed with \$text = " . var_export( $text, true ) . "! "
+ . "This may indicate an error in the caller's scope.", 2 );
+
+ $text = '';
+ }
+
+ if ( !is_string( $text ) ) {
+ throw new MWException( "TextContent expects a string in the constructor." );
+ }
+
+ $this->mText = $text;
+ }
+
+ /**
+ * @note Mutable subclasses MUST override this to return a copy!
+ *
+ * @return Content $this
+ */
+ public function copy() {
+ return $this; # NOTE: this is ok since TextContent are immutable.
+ }
+
+ public function getTextForSummary( $maxlength = 250 ) {
+ global $wgContLang;
+
+ $text = $this->getNativeData();
+
+ $truncatedtext = $wgContLang->truncate(
+ preg_replace( "/[\n\r]/", ' ', $text ),
+ max( 0, $maxlength ) );
+
+ return $truncatedtext;
+ }
+
+ /**
+ * Returns the text's size in bytes.
+ *
+ * @return int
+ */
+ public function getSize() {
+ $text = $this->getNativeData();
+
+ return strlen( $text );
+ }
+
+ /**
+ * Returns true if this content is not a redirect, and $wgArticleCountMethod
+ * is "any".
+ *
+ * @param bool|null $hasLinks If it is known whether this content contains links,
+ * provide this information here, to avoid redundant parsing to find out.
+ *
+ * @return bool
+ */
+ public function isCountable( $hasLinks = null ) {
+ global $wgArticleCountMethod;
+
+ if ( $this->isRedirect() ) {
+ return false;
+ }
+
+ if ( $wgArticleCountMethod === 'any' ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the text represented by this Content object, as a string.
+ *
+ * @return string The raw text.
+ */
+ public function getNativeData() {
+ return $this->mText;
+ }
+
+ /**
+ * Returns the text represented by this Content object, as a string.
+ *
+ * @return string The raw text.
+ */
+ public function getTextForSearchIndex() {
+ return $this->getNativeData();
+ }
+
+ /**
+ * Returns attempts to convert this content object to wikitext,
+ * and then returns the text string. The conversion may be lossy.
+ *
+ * @note this allows any text-based content to be transcluded as if it was wikitext.
+ *
+ * @return string|bool The raw text, or false if the conversion failed.
+ */
+ public function getWikitextForTransclusion() {
+ $wikitext = $this->convert( CONTENT_MODEL_WIKITEXT, 'lossy' );
+
+ if ( $wikitext ) {
+ return $wikitext->getNativeData();
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Do a "\r\n" -> "\n" and "\r" -> "\n" transformation
+ * as well as trim trailing whitespace
+ *
+ * This was formerly part of Parser::preSaveTransform, but
+ * for non-wikitext content models they probably still want
+ * to normalize line endings without all of the other PST
+ * changes.
+ *
+ * @since 1.28
+ * @param string $text
+ * @return string
+ */
+ public static function normalizeLineEndings( $text ) {
+ return str_replace( [ "\r\n", "\r" ], "\n", rtrim( $text ) );
+ }
+
+ /**
+ * Returns a Content object with pre-save transformations applied.
+ *
+ * At a minimum, subclasses should make sure to call TextContent::normalizeLineEndings()
+ * either directly or part of Parser::preSaveTransform().
+ *
+ * @param Title $title
+ * @param User $user
+ * @param ParserOptions $popts
+ *
+ * @return Content
+ */
+ public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
+ $text = $this->getNativeData();
+ $pst = self::normalizeLineEndings( $text );
+
+ return ( $text === $pst ) ? $this : new static( $pst, $this->getModel() );
+ }
+
+ /**
+ * Diff this content object with another content object.
+ *
+ * @since 1.21
+ *
+ * @param Content $that The other content object to compare this content object to.
+ * @param Language $lang The language object to use for text segmentation.
+ * If not given, $wgContentLang is used.
+ *
+ * @return Diff A diff representing the changes that would have to be
+ * made to this content object to make it equal to $that.
+ */
+ public function diff( Content $that, Language $lang = null ) {
+ global $wgContLang;
+
+ $this->checkModelID( $that->getModel() );
+
+ // @todo could implement this in DifferenceEngine and just delegate here?
+
+ if ( !$lang ) {
+ $lang = $wgContLang;
+ }
+
+ $otext = $this->getNativeData();
+ $ntext = $that->getNativeData();
+
+ # Note: Use native PHP diff, external engines don't give us abstract output
+ $ota = explode( "\n", $lang->segmentForDiff( $otext ) );
+ $nta = explode( "\n", $lang->segmentForDiff( $ntext ) );
+
+ $diff = new Diff( $ota, $nta );
+
+ return $diff;
+ }
+
+ /**
+ * Fills the provided ParserOutput object with information derived from the content.
+ * Unless $generateHtml was false, this includes an HTML representation of the content
+ * provided by getHtml().
+ *
+ * For content models listed in $wgTextModelsToParse, this method will call the MediaWiki
+ * wikitext parser on the text to extract any (wikitext) links, magic words, etc.
+ *
+ * Subclasses may override this to provide custom content processing.
+ * For custom HTML generation alone, it is sufficient to override getHtml().
+ *
+ * @param Title $title Context title for parsing
+ * @param int $revId Revision ID (for {{REVISIONID}})
+ * @param ParserOptions $options Parser options
+ * @param bool $generateHtml Whether or not to generate HTML
+ * @param ParserOutput &$output The output object to fill (reference).
+ */
+ protected function fillParserOutput( Title $title, $revId,
+ ParserOptions $options, $generateHtml, ParserOutput &$output
+ ) {
+ global $wgParser, $wgTextModelsToParse;
+
+ if ( in_array( $this->getModel(), $wgTextModelsToParse ) ) {
+ // parse just to get links etc into the database, HTML is replaced below.
+ $output = $wgParser->parse( $this->getNativeData(), $title, $options, true, true, $revId );
+ }
+
+ if ( $generateHtml ) {
+ $html = $this->getHtml();
+ } else {
+ $html = '';
+ }
+
+ $output->setText( $html );
+ }
+
+ /**
+ * Generates an HTML version of the content, for display. Used by
+ * fillParserOutput() to provide HTML for the ParserOutput object.
+ *
+ * Subclasses may override this to provide a custom HTML rendering.
+ * If further information is to be derived from the content (such as
+ * categories), the fillParserOutput() method can be overridden instead.
+ *
+ * For backwards-compatibility, this default implementation just calls
+ * getHighlightHtml().
+ *
+ * @return string An HTML representation of the content
+ */
+ protected function getHtml() {
+ return $this->getHighlightHtml();
+ }
+
+ /**
+ * Generates an HTML version of the content, for display.
+ *
+ * This default implementation returns an HTML-escaped version
+ * of the raw text content.
+ *
+ * @note The functionality of this method should really be implemented
+ * in getHtml(), and subclasses should override getHtml() if needed.
+ * getHighlightHtml() is kept around for backward compatibility with
+ * extensions that already override it.
+ *
+ * @deprecated since 1.24. Use getHtml() instead. In particular, subclasses overriding
+ * getHighlightHtml() should override getHtml() instead.
+ *
+ * @return string An HTML representation of the content
+ */
+ protected function getHighlightHtml() {
+ return htmlspecialchars( $this->getNativeData() );
+ }
+
+ /**
+ * This implementation provides lossless conversion between content models based
+ * on TextContent.
+ *
+ * @param string $toModel The desired content model, use the CONTENT_MODEL_XXX flags.
+ * @param string $lossy Flag, set to "lossy" to allow lossy conversion. If lossy conversion is not
+ * allowed, full round-trip conversion is expected to work without losing information.
+ *
+ * @return Content|bool A content object with the content model $toModel, or false if that
+ * conversion is not supported.
+ *
+ * @see Content::convert()
+ */
+ public function convert( $toModel, $lossy = '' ) {
+ $converted = parent::convert( $toModel, $lossy );
+
+ if ( $converted !== false ) {
+ return $converted;
+ }
+
+ $toHandler = ContentHandler::getForModelID( $toModel );
+
+ if ( $toHandler instanceof TextContentHandler ) {
+ // NOTE: ignore content serialization format - it's just text anyway.
+ $text = $this->getNativeData();
+ $converted = $toHandler->unserializeContent( $text );
+ }
+
+ return $converted;
+ }
+
+}
diff --git a/www/wiki/includes/content/TextContentHandler.php b/www/wiki/includes/content/TextContentHandler.php
new file mode 100644
index 00000000..ced2a665
--- /dev/null
+++ b/www/wiki/includes/content/TextContentHandler.php
@@ -0,0 +1,164 @@
+<?php
+/**
+ * Base content handler class for flat text contents.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ */
+
+/**
+ * Base content handler implementation for flat text contents.
+ *
+ * @ingroup Content
+ */
+class TextContentHandler extends ContentHandler {
+
+ // @codingStandardsIgnoreStart T59585
+ public function __construct( $modelId = CONTENT_MODEL_TEXT, $formats = [ CONTENT_FORMAT_TEXT ] ) {
+ parent::__construct( $modelId, $formats );
+ }
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * Returns the content's text as-is.
+ *
+ * @param Content $content
+ * @param string $format The serialization format to check
+ *
+ * @return mixed
+ */
+ public function serializeContent( Content $content, $format = null ) {
+ $this->checkFormat( $format );
+
+ return $content->getNativeData();
+ }
+
+ /**
+ * Attempts to merge differences between three versions. Returns a new
+ * Content object for a clean merge and false for failure or a conflict.
+ *
+ * All three Content objects passed as parameters must have the same
+ * content model.
+ *
+ * This text-based implementation uses wfMerge().
+ *
+ * @param Content $oldContent The page's previous content.
+ * @param Content $myContent One of the page's conflicting contents.
+ * @param Content $yourContent One of the page's conflicting contents.
+ *
+ * @return Content|bool
+ */
+ public function merge3( Content $oldContent, Content $myContent, Content $yourContent ) {
+ $this->checkModelID( $oldContent->getModel() );
+ $this->checkModelID( $myContent->getModel() );
+ $this->checkModelID( $yourContent->getModel() );
+
+ $format = $this->getDefaultFormat();
+
+ $old = $this->serializeContent( $oldContent, $format );
+ $mine = $this->serializeContent( $myContent, $format );
+ $yours = $this->serializeContent( $yourContent, $format );
+
+ $ok = wfMerge( $old, $mine, $yours, $result );
+
+ if ( !$ok ) {
+ return false;
+ }
+
+ if ( !$result ) {
+ return $this->makeEmptyContent();
+ }
+
+ $mergedContent = $this->unserializeContent( $result, $format );
+
+ return $mergedContent;
+ }
+
+ /**
+ * Returns the name of the associated Content class, to
+ * be used when creating new objects. Override expected
+ * by subclasses.
+ *
+ * @since 1.24
+ *
+ * @return string
+ */
+ protected function getContentClass() {
+ return TextContent::class;
+ }
+
+ /**
+ * Unserializes a Content object of the type supported by this ContentHandler.
+ *
+ * @since 1.21
+ *
+ * @param string $text Serialized form of the content
+ * @param string $format The format used for serialization
+ *
+ * @return Content The TextContent object wrapping $text
+ */
+ public function unserializeContent( $text, $format = null ) {
+ $this->checkFormat( $format );
+
+ $class = $this->getContentClass();
+ return new $class( $text );
+ }
+
+ /**
+ * Creates an empty TextContent object.
+ *
+ * @since 1.21
+ *
+ * @return Content A new TextContent object with empty text.
+ */
+ public function makeEmptyContent() {
+ $class = $this->getContentClass();
+ return new $class( '' );
+ }
+
+ /**
+ * @see ContentHandler::supportsDirectEditing
+ *
+ * @return bool Default is true for TextContent and derivatives.
+ */
+ public function supportsDirectEditing() {
+ return true;
+ }
+
+ public function getFieldsForSearchIndex( SearchEngine $engine ) {
+ $fields = parent::getFieldsForSearchIndex( $engine );
+ $fields['language'] =
+ $engine->makeSearchFieldMapping( 'language', SearchIndexField::INDEX_TYPE_KEYWORD );
+
+ return $fields;
+ }
+
+ public function getDataForSearchIndex(
+ WikiPage $page,
+ ParserOutput $output,
+ SearchEngine $engine
+ ) {
+ $fields = parent::getDataForSearchIndex( $page, $output, $engine );
+ $fields['language'] =
+ $this->getPageLanguage( $page->getTitle(), $page->getContent() )->getCode();
+ return $fields;
+ }
+
+}
diff --git a/www/wiki/includes/content/WikiTextStructure.php b/www/wiki/includes/content/WikiTextStructure.php
new file mode 100644
index 00000000..aeb96b65
--- /dev/null
+++ b/www/wiki/includes/content/WikiTextStructure.php
@@ -0,0 +1,251 @@
+<?php
+
+use HtmlFormatter\HtmlFormatter;
+
+/**
+ * Class allowing to explore structure of parsed wikitext.
+ */
+class WikiTextStructure {
+ /**
+ * @var string
+ */
+ private $openingText;
+ /**
+ * @var string
+ */
+ private $allText;
+ /**
+ * @var string[]
+ */
+ private $auxText = [];
+ /**
+ * @var ParserOutput
+ */
+ private $parserOutput;
+
+ /**
+ * @var string[] selectors to elements that are excluded entirely from search
+ */
+ private $excludedElementSelectors = [
+ // "it looks like you don't have javascript enabled..." – do not need to index
+ 'audio', 'video',
+ // The [1] for references
+ 'sup.reference',
+ // The ↑ next to references in the references section
+ '.mw-cite-backlink',
+ // Headings are already indexed in their own field.
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ // Collapsed fields are hidden by default so we don't want them showing up.
+ '.autocollapse',
+ // Content explicitly decided to be not searchable by editors such
+ // as custom navigation templates.
+ '.navigation-not-searchable'
+ ];
+
+ /**
+ * @var string[] selectors to elements that are considered auxiliary to article text for search
+ */
+ private $auxiliaryElementSelectors = [
+ // Thumbnail captions aren't really part of the text proper
+ '.thumbcaption',
+ // Neither are tables
+ 'table',
+ // Common style for "See also:".
+ '.rellink',
+ // Common style for calling out helpful links at the top of the article.
+ '.dablink',
+ // New class users can use to mark stuff as auxiliary to searches.
+ '.searchaux',
+ ];
+
+ /**
+ * @param ParserOutput $parserOutput
+ */
+ public function __construct( ParserOutput $parserOutput ) {
+ $this->parserOutput = $parserOutput;
+ }
+
+ /**
+ * Get headings on the page.
+ * @return string[]
+ * First strip out things that look like references. We can't use HTML filtering because
+ * the references come back as <sup> tags without a class. To keep from breaking stuff like
+ * ==Applicability of the strict mass–energy equivalence formula, ''E'' = ''mc''<sup>2</sup>==
+ * we don't remove the whole <sup> tag. We also don't want to strip the <sup> tag and remove
+ * everything that looks like [2] because, I dunno, maybe there is a band named Word [2] Foo
+ * or something. Whatever. So we only strip things that look like <sup> tags wrapping a
+ * reference. And since the data looks like:
+ * Reference in heading <sup>&#91;1&#93;</sup><sup>&#91;2&#93;</sup>
+ * we can not really use HtmlFormatter as we have no suitable selector.
+ */
+ public function headings() {
+ $headings = [];
+ $ignoredHeadings = $this->getIgnoredHeadings();
+ foreach ( $this->parserOutput->getSections() as $heading ) {
+ $heading = $heading[ 'line' ];
+
+ // Some wikis wrap the brackets in a span:
+ // https://en.wikipedia.org/wiki/MediaWiki:Cite_reference_link
+ $heading = preg_replace( '/<\/?span>/', '', $heading );
+ // Normalize [] so the following regexp would work.
+ $heading = preg_replace( [ '/&#91;/', '/&#93;/' ], [ '[', ']' ], $heading );
+ $heading = preg_replace( '/<sup>\s*\[\s*\d+\s*\]\s*<\/sup>/is', '', $heading );
+
+ // Strip tags from the heading or else we'll display them (escaped) in search results
+ $heading = trim( Sanitizer::stripAllTags( $heading ) );
+
+ // Note that we don't take the level of the heading into account - all headings are equal.
+ // Except the ones we ignore.
+ if ( !in_array( $heading, $ignoredHeadings ) ) {
+ $headings[] = $heading;
+ }
+ }
+ return $headings;
+ }
+
+ /**
+ * Parse a message content into an array. This function is generally used to
+ * parse settings stored as i18n messages (see search-ignored-headings).
+ *
+ * @param string $message
+ * @return string[]
+ */
+ public static function parseSettingsInMessage( $message ) {
+ $lines = explode( "\n", $message );
+ $lines = preg_replace( '/#.*$/', '', $lines ); // Remove comments
+ $lines = array_map( 'trim', $lines ); // Remove extra spaces
+ $lines = array_filter( $lines ); // Remove empty lines
+ return $lines;
+ }
+
+ /**
+ * Get list of heading to ignore.
+ * @return string[]
+ */
+ private function getIgnoredHeadings() {
+ static $ignoredHeadings = null;
+ if ( $ignoredHeadings === null ) {
+ $ignoredHeadings = [];
+ $source = wfMessage( 'search-ignored-headings' )->inContentLanguage();
+ if ( $source->isBlank() ) {
+ // Try old version too, just in case
+ $source = wfMessage( 'cirrussearch-ignored-headings' )->inContentLanguage();
+ }
+ if ( !$source->isDisabled() ) {
+ $lines = self::parseSettingsInMessage( $source->plain() );
+ $ignoredHeadings = $lines; // Now we just have headings!
+ }
+ }
+ return $ignoredHeadings;
+ }
+
+ /**
+ * Extract parts of the text - opening, main and auxiliary.
+ */
+ private function extractWikitextParts() {
+ if ( !is_null( $this->allText ) ) {
+ return;
+ }
+ $this->parserOutput->setEditSectionTokens( false );
+ $this->parserOutput->setTOCEnabled( false );
+ $text = $this->parserOutput->getText();
+ if ( strlen( $text ) == 0 ) {
+ $this->allText = "";
+ // empty text - nothing to seek here
+ return;
+ }
+ $opening = null;
+
+ $this->openingText = $this->extractHeadingBeforeFirstHeading( $text );
+
+ // Add extra spacing around break tags so text crammed together like<br>this
+ // doesn't make one word.
+ $text = str_replace( '<br', "\n<br", $text );
+
+ $formatter = new HtmlFormatter( $text );
+
+ // Strip elements from the page that we never want in the search text.
+ $formatter->remove( $this->excludedElementSelectors );
+ $formatter->filterContent();
+
+ // Strip elements from the page that are auxiliary text. These will still be
+ // searched but matches will be ranked lower and non-auxiliary matches will be
+ // preferred in highlighting.
+ $formatter->remove( $this->auxiliaryElementSelectors );
+ $auxiliaryElements = $formatter->filterContent();
+ $this->allText = trim( Sanitizer::stripAllTags( $formatter->getText() ) );
+ foreach ( $auxiliaryElements as $auxiliaryElement ) {
+ $this->auxText[] =
+ trim( Sanitizer::stripAllTags( $formatter->getText( $auxiliaryElement ) ) );
+ }
+ }
+
+ /**
+ * Get text before first heading.
+ * @param string $text
+ * @return string|null
+ */
+ private function extractHeadingBeforeFirstHeading( $text ) {
+ $matches = [];
+ if ( !preg_match( '/<h[123456]>/', $text, $matches, PREG_OFFSET_CAPTURE ) ) {
+ // There isn't a first heading so we interpret this as the article
+ // being entirely without heading.
+ return null;
+ }
+ $text = substr( $text, 0, $matches[ 0 ][ 1 ] );
+ if ( !$text ) {
+ // There isn't any text before the first heading so we declare there isn't
+ // a first heading.
+ return null;
+ }
+
+ $formatter = new HtmlFormatter( $text );
+ $formatter->remove( $this->excludedElementSelectors );
+ $formatter->remove( $this->auxiliaryElementSelectors );
+ $formatter->filterContent();
+ $text = trim( Sanitizer::stripAllTags( $formatter->getText() ) );
+
+ if ( !$text ) {
+ // There isn't any text after filtering before the first heading so we declare
+ // that there isn't a first heading.
+ return null;
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get opening text
+ * @return string
+ */
+ public function getOpeningText() {
+ $this->extractWikitextParts();
+ return $this->openingText;
+ }
+
+ /**
+ * Get main text
+ * @return string
+ */
+ public function getMainText() {
+ $this->extractWikitextParts();
+ return $this->allText;
+ }
+
+ /**
+ * Get auxiliary text
+ * @return string[]
+ */
+ public function getAuxiliaryText() {
+ $this->extractWikitextParts();
+ return $this->auxText;
+ }
+
+ /**
+ * Get the defaultsort property
+ * @return string|null
+ */
+ public function getDefaultSort() {
+ return $this->parserOutput->getProperty( 'defaultsort' );
+ }
+}
diff --git a/www/wiki/includes/content/WikitextContent.php b/www/wiki/includes/content/WikitextContent.php
new file mode 100644
index 00000000..942390f6
--- /dev/null
+++ b/www/wiki/includes/content/WikitextContent.php
@@ -0,0 +1,369 @@
+<?php
+/**
+ * Content object for wiki text pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ *
+ * @author Daniel Kinzler
+ */
+
+/**
+ * Content object for wiki text pages.
+ *
+ * @ingroup Content
+ */
+class WikitextContent extends TextContent {
+ private $redirectTargetAndText = null;
+
+ public function __construct( $text ) {
+ parent::__construct( $text, CONTENT_MODEL_WIKITEXT );
+ }
+
+ /**
+ * @param string|int $sectionId
+ *
+ * @return Content|bool|null
+ *
+ * @see Content::getSection()
+ */
+ public function getSection( $sectionId ) {
+ global $wgParser;
+
+ $text = $this->getNativeData();
+ $sect = $wgParser->getSection( $text, $sectionId, false );
+
+ if ( $sect === false ) {
+ return false;
+ } else {
+ return new static( $sect );
+ }
+ }
+
+ /**
+ * @param string|int|null|bool $sectionId
+ * @param Content $with
+ * @param string $sectionTitle
+ *
+ * @throws MWException
+ * @return Content
+ *
+ * @see Content::replaceSection()
+ */
+ public function replaceSection( $sectionId, Content $with, $sectionTitle = '' ) {
+ $myModelId = $this->getModel();
+ $sectionModelId = $with->getModel();
+
+ if ( $sectionModelId != $myModelId ) {
+ throw new MWException( "Incompatible content model for section: " .
+ "document uses $myModelId but " .
+ "section uses $sectionModelId." );
+ }
+
+ $oldtext = $this->getNativeData();
+ $text = $with->getNativeData();
+
+ if ( strval( $sectionId ) === '' ) {
+ return $with; # XXX: copy first?
+ }
+
+ if ( $sectionId === 'new' ) {
+ # Inserting a new section
+ $subject = $sectionTitle ? wfMessage( 'newsectionheaderdefaultlevel' )
+ ->rawParams( $sectionTitle )->inContentLanguage()->text() . "\n\n" : '';
+ if ( Hooks::run( 'PlaceNewSection', [ $this, $oldtext, $subject, &$text ] ) ) {
+ $text = strlen( trim( $oldtext ) ) > 0
+ ? "{$oldtext}\n\n{$subject}{$text}"
+ : "{$subject}{$text}";
+ }
+ } else {
+ # Replacing an existing section; roll out the big guns
+ global $wgParser;
+
+ $text = $wgParser->replaceSection( $oldtext, $sectionId, $text );
+ }
+
+ $newContent = new static( $text );
+
+ return $newContent;
+ }
+
+ /**
+ * Returns a new WikitextContent object with the given section heading
+ * prepended.
+ *
+ * @param string $header
+ *
+ * @return Content
+ */
+ public function addSectionHeader( $header ) {
+ $text = wfMessage( 'newsectionheaderdefaultlevel' )
+ ->rawParams( $header )->inContentLanguage()->text();
+ $text .= "\n\n";
+ $text .= $this->getNativeData();
+
+ return new static( $text );
+ }
+
+ /**
+ * Returns a Content object with pre-save transformations applied using
+ * Parser::preSaveTransform().
+ *
+ * @param Title $title
+ * @param User $user
+ * @param ParserOptions $popts
+ *
+ * @return Content
+ */
+ public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
+ global $wgParser;
+
+ $text = $this->getNativeData();
+ $pst = $wgParser->preSaveTransform( $text, $title, $user, $popts );
+
+ return ( $text === $pst ) ? $this : new static( $pst );
+ }
+
+ /**
+ * Returns a Content object with preload transformations applied (or this
+ * object if no transformations apply).
+ *
+ * @param Title $title
+ * @param ParserOptions $popts
+ * @param array $params
+ *
+ * @return Content
+ */
+ public function preloadTransform( Title $title, ParserOptions $popts, $params = [] ) {
+ global $wgParser;
+
+ $text = $this->getNativeData();
+ $plt = $wgParser->getPreloadText( $text, $title, $popts, $params );
+
+ return new static( $plt );
+ }
+
+ /**
+ * Extract the redirect target and the remaining text on the page.
+ *
+ * @note migrated here from Title::newFromRedirectInternal()
+ *
+ * @since 1.23
+ *
+ * @return array List of two elements: Title|null and string.
+ */
+ protected function getRedirectTargetAndText() {
+ global $wgMaxRedirects;
+
+ if ( $this->redirectTargetAndText !== null ) {
+ return $this->redirectTargetAndText;
+ }
+
+ if ( $wgMaxRedirects < 1 ) {
+ // redirects are disabled, so quit early
+ $this->redirectTargetAndText = [ null, $this->getNativeData() ];
+ return $this->redirectTargetAndText;
+ }
+
+ $redir = MagicWord::get( 'redirect' );
+ $text = ltrim( $this->getNativeData() );
+ if ( $redir->matchStartAndRemove( $text ) ) {
+ // Extract the first link and see if it's usable
+ // Ensure that it really does come directly after #REDIRECT
+ // Some older redirects included a colon, so don't freak about that!
+ $m = [];
+ if ( preg_match( '!^\s*:?\s*\[{2}(.*?)(?:\|.*?)?\]{2}\s*!', $text, $m ) ) {
+ // Strip preceding colon used to "escape" categories, etc.
+ // and URL-decode links
+ if ( strpos( $m[1], '%' ) !== false ) {
+ // Match behavior of inline link parsing here;
+ $m[1] = rawurldecode( ltrim( $m[1], ':' ) );
+ }
+ $title = Title::newFromText( $m[1] );
+ // If the title is a redirect to bad special pages or is invalid, return null
+ if ( !$title instanceof Title || !$title->isValidRedirectTarget() ) {
+ $this->redirectTargetAndText = [ null, $this->getNativeData() ];
+ return $this->redirectTargetAndText;
+ }
+
+ $this->redirectTargetAndText = [ $title, substr( $text, strlen( $m[0] ) ) ];
+ return $this->redirectTargetAndText;
+ }
+ }
+
+ $this->redirectTargetAndText = [ null, $this->getNativeData() ];
+ return $this->redirectTargetAndText;
+ }
+
+ /**
+ * Implement redirect extraction for wikitext.
+ *
+ * @return Title|null
+ *
+ * @see Content::getRedirectTarget
+ */
+ public function getRedirectTarget() {
+ list( $title, ) = $this->getRedirectTargetAndText();
+
+ return $title;
+ }
+
+ /**
+ * This implementation replaces the first link on the page with the given new target
+ * if this Content object is a redirect. Otherwise, this method returns $this.
+ *
+ * @since 1.21
+ *
+ * @param Title $target
+ *
+ * @return Content
+ *
+ * @see Content::updateRedirect()
+ */
+ public function updateRedirect( Title $target ) {
+ if ( !$this->isRedirect() ) {
+ return $this;
+ }
+
+ # Fix the text
+ # Remember that redirect pages can have categories, templates, etc.,
+ # so the regex has to be fairly general
+ $newText = preg_replace( '/ \[ \[ [^\]]* \] \] /x',
+ '[[' . $target->getFullText() . ']]',
+ $this->getNativeData(), 1 );
+
+ return new static( $newText );
+ }
+
+ /**
+ * Returns true if this content is not a redirect, and this content's text
+ * is countable according to the criteria defined by $wgArticleCountMethod.
+ *
+ * @param bool|null $hasLinks If it is known whether this content contains
+ * links, provide this information here, to avoid redundant parsing to
+ * find out (default: null).
+ * @param Title|null $title Optional title, defaults to the title from the current main request.
+ *
+ * @return bool
+ */
+ public function isCountable( $hasLinks = null, Title $title = null ) {
+ global $wgArticleCountMethod;
+
+ if ( $this->isRedirect() ) {
+ return false;
+ }
+
+ switch ( $wgArticleCountMethod ) {
+ case 'any':
+ return true;
+ case 'comma':
+ $text = $this->getNativeData();
+ return strpos( $text, ',' ) !== false;
+ case 'link':
+ if ( $hasLinks === null ) { # not known, find out
+ if ( !$title ) {
+ $context = RequestContext::getMain();
+ $title = $context->getTitle();
+ }
+
+ $po = $this->getParserOutput( $title, null, null, false );
+ $links = $po->getLinks();
+ $hasLinks = !empty( $links );
+ }
+
+ return $hasLinks;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param int $maxlength
+ * @return string
+ */
+ public function getTextForSummary( $maxlength = 250 ) {
+ $truncatedtext = parent::getTextForSummary( $maxlength );
+
+ # clean up unfinished links
+ # XXX: make this optional? wasn't there in autosummary, but required for
+ # deletion summary.
+ $truncatedtext = preg_replace( '/\[\[([^\]]*)\]?$/', '$1', $truncatedtext );
+
+ return $truncatedtext;
+ }
+
+ /**
+ * Returns a ParserOutput object resulting from parsing the content's text
+ * using $wgParser.
+ *
+ * @param Title $title
+ * @param int $revId Revision to pass to the parser (default: null)
+ * @param ParserOptions $options (default: null)
+ * @param bool $generateHtml (default: true)
+ * @param ParserOutput &$output ParserOutput representing the HTML form of the text,
+ * may be manipulated or replaced.
+ */
+ protected function fillParserOutput( Title $title, $revId,
+ ParserOptions $options, $generateHtml, ParserOutput &$output
+ ) {
+ global $wgParser;
+
+ list( $redir, $text ) = $this->getRedirectTargetAndText();
+ $output = $wgParser->parse( $text, $title, $options, true, true, $revId );
+
+ // Add redirect indicator at the top
+ if ( $redir ) {
+ // Make sure to include the redirect link in pagelinks
+ $output->addLink( $redir );
+ if ( $generateHtml ) {
+ $chain = $this->getRedirectChain();
+ $output->setText(
+ Article::getRedirectHeaderHtml( $title->getPageLanguage(), $chain, false ) .
+ $output->getRawText()
+ );
+ $output->addModuleStyles( 'mediawiki.action.view.redirectPage' );
+ }
+ }
+ }
+
+ /**
+ * @throws MWException
+ */
+ protected function getHtml() {
+ throw new MWException(
+ "getHtml() not implemented for wikitext. "
+ . "Use getParserOutput()->getText()."
+ );
+ }
+
+ /**
+ * This implementation calls $word->match() on the this TextContent object's text.
+ *
+ * @param MagicWord $word
+ *
+ * @return bool
+ *
+ * @see Content::matchMagicWord()
+ */
+ public function matchMagicWord( MagicWord $word ) {
+ return $word->match( $this->getNativeData() );
+ }
+
+}
diff --git a/www/wiki/includes/content/WikitextContentHandler.php b/www/wiki/includes/content/WikitextContentHandler.php
new file mode 100644
index 00000000..9c26ae15
--- /dev/null
+++ b/www/wiki/includes/content/WikitextContentHandler.php
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Content handler for wiki text pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Content
+ */
+
+/**
+ * Content handler for wiki text pages.
+ *
+ * @ingroup Content
+ */
+class WikitextContentHandler extends TextContentHandler {
+
+ public function __construct( $modelId = CONTENT_MODEL_WIKITEXT ) {
+ parent::__construct( $modelId, [ CONTENT_FORMAT_WIKITEXT ] );
+ }
+
+ protected function getContentClass() {
+ return WikitextContent::class;
+ }
+
+ /**
+ * Returns a WikitextContent object representing a redirect to the given destination page.
+ *
+ * @param Title $destination The page to redirect to.
+ * @param string $text Text to include in the redirect, if possible.
+ *
+ * @return Content
+ *
+ * @see ContentHandler::makeRedirectContent
+ */
+ public function makeRedirectContent( Title $destination, $text = '' ) {
+ $optionalColon = '';
+
+ if ( $destination->getNamespace() == NS_CATEGORY ) {
+ $optionalColon = ':';
+ } else {
+ $iw = $destination->getInterwiki();
+ if ( $iw && Language::fetchLanguageName( $iw, null, 'mw' ) ) {
+ $optionalColon = ':';
+ }
+ }
+
+ $mwRedir = MagicWord::get( 'redirect' );
+ $redirectText = $mwRedir->getSynonym( 0 ) .
+ ' [[' . $optionalColon . $destination->getFullText() . ']]';
+
+ if ( $text != '' ) {
+ $redirectText .= "\n" . $text;
+ }
+
+ $class = $this->getContentClass();
+ return new $class( $redirectText );
+ }
+
+ /**
+ * Returns true because wikitext supports redirects.
+ *
+ * @return bool Always true.
+ *
+ * @see ContentHandler::supportsRedirects
+ */
+ public function supportsRedirects() {
+ return true;
+ }
+
+ /**
+ * Returns true because wikitext supports sections.
+ *
+ * @return bool Always true.
+ *
+ * @see ContentHandler::supportsSections
+ */
+ public function supportsSections() {
+ return true;
+ }
+
+ /**
+ * Returns true, because wikitext supports caching using the
+ * ParserCache mechanism.
+ *
+ * @since 1.21
+ *
+ * @return bool Always true.
+ *
+ * @see ContentHandler::isParserCacheSupported
+ */
+ public function isParserCacheSupported() {
+ return true;
+ }
+
+ /**
+ * Get file handler
+ * @return FileContentHandler
+ */
+ protected function getFileHandler() {
+ return new FileContentHandler();
+ }
+
+ public function getFieldsForSearchIndex( SearchEngine $engine ) {
+ $fields = parent::getFieldsForSearchIndex( $engine );
+
+ $fields['heading'] =
+ $engine->makeSearchFieldMapping( 'heading', SearchIndexField::INDEX_TYPE_TEXT );
+ $fields['heading']->setFlag( SearchIndexField::FLAG_SCORING );
+
+ $fields['auxiliary_text'] =
+ $engine->makeSearchFieldMapping( 'auxiliary_text', SearchIndexField::INDEX_TYPE_TEXT );
+
+ $fields['opening_text'] =
+ $engine->makeSearchFieldMapping( 'opening_text', SearchIndexField::INDEX_TYPE_TEXT );
+ $fields['opening_text']->setFlag(
+ SearchIndexField::FLAG_SCORING | SearchIndexField::FLAG_NO_HIGHLIGHT
+ );
+ // Until we have full first-class content handler for files, we invoke it explicitly here
+ $fields = array_merge( $fields, $this->getFileHandler()->getFieldsForSearchIndex( $engine ) );
+
+ return $fields;
+ }
+
+ public function getDataForSearchIndex(
+ WikiPage $page,
+ ParserOutput $parserOutput,
+ SearchEngine $engine
+ ) {
+ $fields = parent::getDataForSearchIndex( $page, $parserOutput, $engine );
+
+ $structure = new WikiTextStructure( $parserOutput );
+ $fields['heading'] = $structure->headings();
+ // text fields
+ $fields['opening_text'] = $structure->getOpeningText();
+ $fields['text'] = $structure->getMainText(); // overwrites one from ContentHandler
+ $fields['auxiliary_text'] = $structure->getAuxiliaryText();
+ $fields['defaultsort'] = $structure->getDefaultSort();
+
+ // Until we have full first-class content handler for files, we invoke it explicitly here
+ if ( NS_FILE == $page->getTitle()->getNamespace() ) {
+ $fields = array_merge( $fields,
+ $this->getFileHandler()->getDataForSearchIndex( $page, $parserOutput, $engine ) );
+ }
+ return $fields;
+ }
+
+}
diff --git a/www/wiki/includes/context/ContextSource.php b/www/wiki/includes/context/ContextSource.php
new file mode 100644
index 00000000..cea84605
--- /dev/null
+++ b/www/wiki/includes/context/ContextSource.php
@@ -0,0 +1,205 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @author Happy-melon
+ * @file
+ */
+use Liuggio\StatsdClient\Factory\StatsdDataFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * The simplest way of implementing IContextSource is to hold a RequestContext as a
+ * member variable and provide accessors to it.
+ *
+ * @since 1.18
+ */
+abstract class ContextSource implements IContextSource {
+ /**
+ * @var IContextSource
+ */
+ private $context;
+
+ /**
+ * Get the base IContextSource object
+ * @since 1.18
+ * @return IContextSource
+ */
+ public function getContext() {
+ if ( $this->context === null ) {
+ $class = static::class;
+ wfDebug( __METHOD__ . " ($class): called and \$context is null. " .
+ "Using RequestContext::getMain() for sanity\n" );
+ $this->context = RequestContext::getMain();
+ }
+
+ return $this->context;
+ }
+
+ /**
+ * Set the IContextSource object
+ *
+ * @since 1.18
+ * @param IContextSource $context
+ */
+ public function setContext( IContextSource $context ) {
+ $this->context = $context;
+ }
+
+ /**
+ * Get the Config object
+ *
+ * @since 1.23
+ * @return Config
+ */
+ public function getConfig() {
+ return $this->getContext()->getConfig();
+ }
+
+ /**
+ * Get the WebRequest object
+ *
+ * @since 1.18
+ * @return WebRequest
+ */
+ public function getRequest() {
+ return $this->getContext()->getRequest();
+ }
+
+ /**
+ * Get the Title object
+ *
+ * @since 1.18
+ * @return Title|null
+ */
+ public function getTitle() {
+ return $this->getContext()->getTitle();
+ }
+
+ /**
+ * Check whether a WikiPage object can be get with getWikiPage().
+ * Callers should expect that an exception is thrown from getWikiPage()
+ * if this method returns false.
+ *
+ * @since 1.19
+ * @return bool
+ */
+ public function canUseWikiPage() {
+ return $this->getContext()->canUseWikiPage();
+ }
+
+ /**
+ * Get the WikiPage object.
+ * May throw an exception if there's no Title object set or the Title object
+ * belongs to a special namespace that doesn't have WikiPage, so use first
+ * canUseWikiPage() to check whether this method can be called safely.
+ *
+ * @since 1.19
+ * @return WikiPage
+ */
+ public function getWikiPage() {
+ return $this->getContext()->getWikiPage();
+ }
+
+ /**
+ * Get the OutputPage object
+ *
+ * @since 1.18
+ * @return OutputPage
+ */
+ public function getOutput() {
+ return $this->getContext()->getOutput();
+ }
+
+ /**
+ * Get the User object
+ *
+ * @since 1.18
+ * @return User
+ */
+ public function getUser() {
+ return $this->getContext()->getUser();
+ }
+
+ /**
+ * Get the Language object
+ *
+ * @since 1.19
+ * @return Language
+ */
+ public function getLanguage() {
+ return $this->getContext()->getLanguage();
+ }
+
+ /**
+ * Get the Skin object
+ *
+ * @since 1.18
+ * @return Skin
+ */
+ public function getSkin() {
+ return $this->getContext()->getSkin();
+ }
+
+ /**
+ * Get the Timing object
+ *
+ * @since 1.27
+ * @return Timing
+ */
+ public function getTiming() {
+ return $this->getContext()->getTiming();
+ }
+
+ /**
+ * Get the Stats object
+ *
+ * @deprecated since 1.27 use a StatsdDataFactory from MediaWikiServices (preferably injected)
+ *
+ * @since 1.25
+ * @return IBufferingStatsdDataFactory
+ */
+ public function getStats() {
+ return MediaWikiServices::getInstance()->getStatsdDataFactory();
+ }
+
+ /**
+ * Get a Message object with context set
+ * Parameters are the same as wfMessage()
+ *
+ * @since 1.18
+ * @param string|string[]|MessageSpecifier $key Message key, or array of keys,
+ * or a MessageSpecifier.
+ * @param mixed $args,...
+ * @return Message
+ */
+ public function msg( $key /* $args */ ) {
+ $args = func_get_args();
+
+ return call_user_func_array( [ $this->getContext(), 'msg' ], $args );
+ }
+
+ /**
+ * Export the resolved user IP, HTTP headers, user ID, and session ID.
+ * The result will be reasonably sized to allow for serialization.
+ *
+ * @return array
+ * @since 1.21
+ */
+ public function exportSession() {
+ return $this->getContext()->exportSession();
+ }
+}
diff --git a/www/wiki/includes/context/DerivativeContext.php b/www/wiki/includes/context/DerivativeContext.php
new file mode 100644
index 00000000..6e3eda6f
--- /dev/null
+++ b/www/wiki/includes/context/DerivativeContext.php
@@ -0,0 +1,336 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @author Daniel Friesen
+ * @file
+ */
+use Liuggio\StatsdClient\Factory\StatsdDataFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * An IContextSource implementation which will inherit context from another source
+ * but allow individual pieces of context to be changed locally
+ * eg: A ContextSource that can inherit from the main RequestContext but have
+ * a different Title instance set on it.
+ * @since 1.19
+ */
+class DerivativeContext extends ContextSource implements MutableContext {
+ /**
+ * @var WebRequest
+ */
+ private $request;
+
+ /**
+ * @var Title
+ */
+ private $title;
+
+ /**
+ * @var WikiPage
+ */
+ private $wikipage;
+
+ /**
+ * @var OutputPage
+ */
+ private $output;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var Language
+ */
+ private $lang;
+
+ /**
+ * @var Skin
+ */
+ private $skin;
+
+ /**
+ * @var Config
+ */
+ private $config;
+
+ /**
+ * @var Timing
+ */
+ private $timing;
+
+ /**
+ * @param IContextSource $context Context to inherit from
+ */
+ public function __construct( IContextSource $context ) {
+ $this->setContext( $context );
+ }
+
+ /**
+ * Set the SiteConfiguration object
+ *
+ * @param Config $s
+ */
+ public function setConfig( Config $s ) {
+ $this->config = $s;
+ }
+
+ /**
+ * Get the Config object
+ *
+ * @return Config
+ */
+ public function getConfig() {
+ if ( !is_null( $this->config ) ) {
+ return $this->config;
+ } else {
+ return $this->getContext()->getConfig();
+ }
+ }
+
+ /**
+ * Get the stats object
+ *
+ * @deprecated since 1.27 use a StatsdDataFactory from MediaWikiServices (preferably injected)
+ *
+ * @return IBufferingStatsdDataFactory
+ */
+ public function getStats() {
+ return MediaWikiServices::getInstance()->getStatsdDataFactory();
+ }
+
+ /**
+ * Get the timing object
+ *
+ * @return Timing
+ */
+ public function getTiming() {
+ if ( !is_null( $this->timing ) ) {
+ return $this->timing;
+ } else {
+ return $this->getContext()->getTiming();
+ }
+ }
+
+ /**
+ * Set the WebRequest object
+ *
+ * @param WebRequest $r
+ */
+ public function setRequest( WebRequest $r ) {
+ $this->request = $r;
+ }
+
+ /**
+ * Get the WebRequest object
+ *
+ * @return WebRequest
+ */
+ public function getRequest() {
+ if ( !is_null( $this->request ) ) {
+ return $this->request;
+ } else {
+ return $this->getContext()->getRequest();
+ }
+ }
+
+ /**
+ * Set the Title object
+ *
+ * @param Title $t
+ */
+ public function setTitle( Title $t ) {
+ $this->title = $t;
+ }
+
+ /**
+ * Get the Title object
+ *
+ * @return Title|null
+ */
+ public function getTitle() {
+ if ( !is_null( $this->title ) ) {
+ return $this->title;
+ } else {
+ return $this->getContext()->getTitle();
+ }
+ }
+
+ /**
+ * Check whether a WikiPage object can be get with getWikiPage().
+ * Callers should expect that an exception is thrown from getWikiPage()
+ * if this method returns false.
+ *
+ * @since 1.19
+ * @return bool
+ */
+ public function canUseWikiPage() {
+ if ( $this->wikipage !== null ) {
+ return true;
+ } elseif ( $this->title !== null ) {
+ return $this->title->canExist();
+ } else {
+ return $this->getContext()->canUseWikiPage();
+ }
+ }
+
+ /**
+ * Set the WikiPage object
+ *
+ * @since 1.19
+ * @param WikiPage $p
+ */
+ public function setWikiPage( WikiPage $p ) {
+ $this->wikipage = $p;
+ }
+
+ /**
+ * Get the WikiPage object.
+ * May throw an exception if there's no Title object set or the Title object
+ * belongs to a special namespace that doesn't have WikiPage, so use first
+ * canUseWikiPage() to check whether this method can be called safely.
+ *
+ * @since 1.19
+ * @return WikiPage
+ */
+ public function getWikiPage() {
+ if ( !is_null( $this->wikipage ) ) {
+ return $this->wikipage;
+ } else {
+ return $this->getContext()->getWikiPage();
+ }
+ }
+
+ /**
+ * Set the OutputPage object
+ *
+ * @param OutputPage $o
+ */
+ public function setOutput( OutputPage $o ) {
+ $this->output = $o;
+ }
+
+ /**
+ * Get the OutputPage object
+ *
+ * @return OutputPage
+ */
+ public function getOutput() {
+ if ( !is_null( $this->output ) ) {
+ return $this->output;
+ } else {
+ return $this->getContext()->getOutput();
+ }
+ }
+
+ /**
+ * Set the User object
+ *
+ * @param User $u
+ */
+ public function setUser( User $u ) {
+ $this->user = $u;
+ }
+
+ /**
+ * Get the User object
+ *
+ * @return User
+ */
+ public function getUser() {
+ if ( !is_null( $this->user ) ) {
+ return $this->user;
+ } else {
+ return $this->getContext()->getUser();
+ }
+ }
+
+ /**
+ * Set the Language object
+ *
+ * @param Language|string $l Language instance or language code
+ * @throws MWException
+ * @since 1.19
+ */
+ public function setLanguage( $l ) {
+ if ( $l instanceof Language ) {
+ $this->lang = $l;
+ } elseif ( is_string( $l ) ) {
+ $l = RequestContext::sanitizeLangCode( $l );
+ $obj = Language::factory( $l );
+ $this->lang = $obj;
+ } else {
+ throw new MWException( __METHOD__ . " was passed an invalid type of data." );
+ }
+ }
+
+ /**
+ * Get the Language object
+ *
+ * @return Language
+ * @since 1.19
+ */
+ public function getLanguage() {
+ if ( !is_null( $this->lang ) ) {
+ return $this->lang;
+ } else {
+ return $this->getContext()->getLanguage();
+ }
+ }
+
+ /**
+ * Set the Skin object
+ *
+ * @param Skin $s
+ */
+ public function setSkin( Skin $s ) {
+ $this->skin = clone $s;
+ $this->skin->setContext( $this );
+ }
+
+ /**
+ * Get the Skin object
+ *
+ * @return Skin
+ */
+ public function getSkin() {
+ if ( !is_null( $this->skin ) ) {
+ return $this->skin;
+ } else {
+ return $this->getContext()->getSkin();
+ }
+ }
+
+ /**
+ * Get a message using the current context.
+ *
+ * This can't just inherit from ContextSource, since then
+ * it would set only the original context, and not take
+ * into account any changes.
+ *
+ * @param string|string[]|MessageSpecifier $key Message key, or array of keys,
+ * or a MessageSpecifier.
+ * @param mixed $args,... Arguments to wfMessage
+ * @return Message
+ */
+ public function msg( $key ) {
+ $args = func_get_args();
+
+ return call_user_func_array( 'wfMessage', $args )->setContext( $this );
+ }
+}
diff --git a/www/wiki/includes/context/IContextSource.php b/www/wiki/includes/context/IContextSource.php
new file mode 100644
index 00000000..895e9e4b
--- /dev/null
+++ b/www/wiki/includes/context/IContextSource.php
@@ -0,0 +1,154 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.18
+ *
+ * @author Happy-melon
+ * @file
+ */
+
+use Liuggio\StatsdClient\Factory\StatsdDataFactory;
+
+/**
+ * Interface for objects which can provide a MediaWiki context on request
+ *
+ * Context objects contain request-dependent objects that manage the core
+ * web request/response logic for essentially all requests to MediaWiki.
+ * The contained objects include:
+ * a) Key objects that depend (for construction/loading) on the HTTP request
+ * b) Key objects used for response building and PHP session state control
+ * c) Performance metric deltas accumulated from request execution
+ * d) The site configuration object
+ * All of the objects are useful for the vast majority of MediaWiki requests.
+ * The site configuration object is included on grounds of extreme
+ * utility, even though it should not actually depend on the web request.
+ *
+ * More specifically, the scope of the context includes:
+ * a) Objects that represent the HTTP request/response and PHP session state
+ * b) Object representing the MediaWiki user (as determined by the HTTP request)
+ * c) Primary MediaWiki output builder objects (OutputPage, user skin object)
+ * d) The language object for the user/request
+ * e) The title and wiki page objects requested via URL (if any)
+ * f) Performance metric deltas accumulated from request execution
+ * g) The site configuration object
+ *
+ * This class is not intended as a service-locator nor a service singleton.
+ * Objects that only depend on site configuration do not belong here (aside
+ * from Config itself). Objects that represent persistent data stores do not
+ * belong here either. Session state changes should only be propagated on
+ * shutdown by separate persistence handler objects, for example.
+ */
+interface IContextSource extends MessageLocalizer {
+ /**
+ * Get the WebRequest object
+ *
+ * @return WebRequest
+ */
+ public function getRequest();
+
+ /**
+ * Get the Title object
+ *
+ * @return Title|null
+ */
+ public function getTitle();
+
+ /**
+ * Check whether a WikiPage object can be get with getWikiPage().
+ * Callers should expect that an exception is thrown from getWikiPage()
+ * if this method returns false.
+ *
+ * @since 1.19
+ * @return bool
+ */
+ public function canUseWikiPage();
+
+ /**
+ * Get the WikiPage object.
+ * May throw an exception if there's no Title object set or the Title object
+ * belongs to a special namespace that doesn't have WikiPage, so use first
+ * canUseWikiPage() to check whether this method can be called safely.
+ *
+ * @since 1.19
+ * @return WikiPage
+ */
+ public function getWikiPage();
+
+ /**
+ * Get the OutputPage object
+ *
+ * @return OutputPage
+ */
+ public function getOutput();
+
+ /**
+ * Get the User object
+ *
+ * @return User
+ */
+ public function getUser();
+
+ /**
+ * Get the Language object
+ *
+ * @return Language
+ * @since 1.19
+ */
+ public function getLanguage();
+
+ /**
+ * Get the Skin object
+ *
+ * @return Skin
+ */
+ public function getSkin();
+
+ /**
+ * Get the site configuration
+ *
+ * @since 1.23
+ * @return Config
+ */
+ public function getConfig();
+
+ /**
+ * Get the stats object
+ *
+ * @deprecated since 1.27 use a StatsdDataFactory from MediaWikiServices (preferably injected)
+ *
+ * @since 1.25
+ * @return IBufferingStatsdDataFactory
+ */
+ public function getStats();
+
+ /**
+ * Get the timing object
+ *
+ * @since 1.27
+ * @return Timing
+ */
+ public function getTiming();
+
+ /**
+ * Export the resolved user IP, HTTP headers, user ID, and session ID.
+ * The result will be reasonably sized to allow for serialization.
+ *
+ * @return array
+ * @since 1.21
+ */
+ public function exportSession();
+}
diff --git a/www/wiki/includes/context/MutableContext.php b/www/wiki/includes/context/MutableContext.php
new file mode 100644
index 00000000..6358f11c
--- /dev/null
+++ b/www/wiki/includes/context/MutableContext.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * Request-dependant objects containers.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.26
+ *
+ * @file
+ */
+
+interface MutableContext {
+ /**
+ * Set the Config object
+ *
+ * @param Config $c
+ */
+ public function setConfig( Config $c );
+
+ /**
+ * Set the WebRequest object
+ *
+ * @param WebRequest $r
+ */
+ public function setRequest( WebRequest $r );
+
+ /**
+ * Set the Title object
+ *
+ * @param Title $t
+ */
+ public function setTitle( Title $t );
+
+ /**
+ * Set the WikiPage object
+ *
+ * @param WikiPage $p
+ */
+ public function setWikiPage( WikiPage $p );
+
+ /**
+ * Set the OutputPage object
+ *
+ * @param OutputPage $o
+ */
+ public function setOutput( OutputPage $o );
+
+ /**
+ * Set the User object
+ *
+ * @param User $u
+ */
+ public function setUser( User $u );
+
+ /**
+ * Set the Language object
+ *
+ * @param Language|string $l Language instance or language code
+ */
+ public function setLanguage( $l );
+
+ /**
+ * Set the Skin object
+ *
+ * @param Skin $s
+ */
+ public function setSkin( Skin $s );
+
+}
diff --git a/www/wiki/includes/context/RequestContext.php b/www/wiki/includes/context/RequestContext.php
new file mode 100644
index 00000000..4a772eec
--- /dev/null
+++ b/www/wiki/includes/context/RequestContext.php
@@ -0,0 +1,652 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.18
+ *
+ * @author Alexandre Emsenhuber
+ * @author Daniel Friesen
+ * @file
+ */
+
+use Liuggio\StatsdClient\Factory\StatsdDataFactory;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Group all the pieces relevant to the context of a request into one instance
+ */
+class RequestContext implements IContextSource, MutableContext {
+ /**
+ * @var WebRequest
+ */
+ private $request;
+
+ /**
+ * @var Title
+ */
+ private $title;
+
+ /**
+ * @var WikiPage
+ */
+ private $wikipage;
+
+ /**
+ * @var OutputPage
+ */
+ private $output;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var Language
+ */
+ private $lang;
+
+ /**
+ * @var Skin
+ */
+ private $skin;
+
+ /**
+ * @var Timing
+ */
+ private $timing;
+
+ /**
+ * @var Config
+ */
+ private $config;
+
+ /**
+ * @var RequestContext
+ */
+ private static $instance = null;
+
+ /**
+ * Set the Config object
+ *
+ * @param Config $c
+ */
+ public function setConfig( Config $c ) {
+ $this->config = $c;
+ }
+
+ /**
+ * Get the Config object
+ *
+ * @return Config
+ */
+ public function getConfig() {
+ if ( $this->config === null ) {
+ // @todo In the future, we could move this to WebStart.php so
+ // the Config object is ready for when initialization happens
+ $this->config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+
+ return $this->config;
+ }
+
+ /**
+ * Set the WebRequest object
+ *
+ * @param WebRequest $r
+ */
+ public function setRequest( WebRequest $r ) {
+ $this->request = $r;
+ }
+
+ /**
+ * Get the WebRequest object
+ *
+ * @return WebRequest
+ */
+ public function getRequest() {
+ if ( $this->request === null ) {
+ global $wgCommandLineMode;
+ // create the WebRequest object on the fly
+ if ( $wgCommandLineMode ) {
+ $this->request = new FauxRequest( [] );
+ } else {
+ $this->request = new WebRequest();
+ }
+ }
+
+ return $this->request;
+ }
+
+ /**
+ * Get the Stats object
+ *
+ * @deprecated since 1.27 use a StatsdDataFactory from MediaWikiServices (preferably injected)
+ *
+ * @return IBufferingStatsdDataFactory
+ */
+ public function getStats() {
+ return MediaWikiServices::getInstance()->getStatsdDataFactory();
+ }
+
+ /**
+ * Get the timing object
+ *
+ * @return Timing
+ */
+ public function getTiming() {
+ if ( $this->timing === null ) {
+ $this->timing = new Timing( [
+ 'logger' => LoggerFactory::getInstance( 'Timing' )
+ ] );
+ }
+ return $this->timing;
+ }
+
+ /**
+ * Set the Title object
+ *
+ * @param Title|null $title
+ */
+ public function setTitle( Title $title = null ) {
+ $this->title = $title;
+ // Erase the WikiPage so a new one with the new title gets created.
+ $this->wikipage = null;
+ }
+
+ /**
+ * Get the Title object
+ *
+ * @return Title|null
+ */
+ public function getTitle() {
+ if ( $this->title === null ) {
+ global $wgTitle; # fallback to $wg till we can improve this
+ $this->title = $wgTitle;
+ wfDebugLog(
+ 'GlobalTitleFail',
+ __METHOD__ . ' called by ' . wfGetAllCallers( 5 ) . ' with no title set.'
+ );
+ }
+
+ return $this->title;
+ }
+
+ /**
+ * Check, if a Title object is set
+ *
+ * @since 1.25
+ * @return bool
+ */
+ public function hasTitle() {
+ return $this->title !== null;
+ }
+
+ /**
+ * Check whether a WikiPage object can be get with getWikiPage().
+ * Callers should expect that an exception is thrown from getWikiPage()
+ * if this method returns false.
+ *
+ * @since 1.19
+ * @return bool
+ */
+ public function canUseWikiPage() {
+ if ( $this->wikipage ) {
+ // If there's a WikiPage object set, we can for sure get it
+ return true;
+ }
+ // Only pages with legitimate titles can have WikiPages.
+ // That usually means pages in non-virtual namespaces.
+ $title = $this->getTitle();
+ return $title ? $title->canExist() : false;
+ }
+
+ /**
+ * Set the WikiPage object
+ *
+ * @since 1.19
+ * @param WikiPage $p
+ */
+ public function setWikiPage( WikiPage $p ) {
+ $pageTitle = $p->getTitle();
+ if ( !$this->hasTitle() || !$pageTitle->equals( $this->getTitle() ) ) {
+ $this->setTitle( $pageTitle );
+ }
+ // Defer this to the end since setTitle sets it to null.
+ $this->wikipage = $p;
+ }
+
+ /**
+ * Get the WikiPage object.
+ * May throw an exception if there's no Title object set or the Title object
+ * belongs to a special namespace that doesn't have WikiPage, so use first
+ * canUseWikiPage() to check whether this method can be called safely.
+ *
+ * @since 1.19
+ * @throws MWException
+ * @return WikiPage
+ */
+ public function getWikiPage() {
+ if ( $this->wikipage === null ) {
+ $title = $this->getTitle();
+ if ( $title === null ) {
+ throw new MWException( __METHOD__ . ' called without Title object set' );
+ }
+ $this->wikipage = WikiPage::factory( $title );
+ }
+
+ return $this->wikipage;
+ }
+
+ /**
+ * @param OutputPage $o
+ */
+ public function setOutput( OutputPage $o ) {
+ $this->output = $o;
+ }
+
+ /**
+ * Get the OutputPage object
+ *
+ * @return OutputPage
+ */
+ public function getOutput() {
+ if ( $this->output === null ) {
+ $this->output = new OutputPage( $this );
+ }
+
+ return $this->output;
+ }
+
+ /**
+ * Set the User object
+ *
+ * @param User $u
+ */
+ public function setUser( User $u ) {
+ $this->user = $u;
+ }
+
+ /**
+ * Get the User object
+ *
+ * @return User
+ */
+ public function getUser() {
+ if ( $this->user === null ) {
+ $this->user = User::newFromSession( $this->getRequest() );
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * Accepts a language code and ensures it's sane. Outputs a cleaned up language
+ * code and replaces with $wgLanguageCode if not sane.
+ * @param string $code Language code
+ * @return string
+ */
+ public static function sanitizeLangCode( $code ) {
+ global $wgLanguageCode;
+
+ // BCP 47 - letter case MUST NOT carry meaning
+ $code = strtolower( $code );
+
+ # Validate $code
+ if ( !$code || !Language::isValidCode( $code ) || $code === 'qqq' ) {
+ wfDebug( "Invalid user language code\n" );
+ $code = $wgLanguageCode;
+ }
+
+ return $code;
+ }
+
+ /**
+ * Set the Language object
+ *
+ * @param Language|string $l Language instance or language code
+ * @throws MWException
+ * @since 1.19
+ */
+ public function setLanguage( $l ) {
+ if ( $l instanceof Language ) {
+ $this->lang = $l;
+ } elseif ( is_string( $l ) ) {
+ $l = self::sanitizeLangCode( $l );
+ $obj = Language::factory( $l );
+ $this->lang = $obj;
+ } else {
+ throw new MWException( __METHOD__ . " was passed an invalid type of data." );
+ }
+ }
+
+ /**
+ * Get the Language object.
+ * Initialization of user or request objects can depend on this.
+ * @return Language
+ * @throws Exception
+ * @since 1.19
+ */
+ public function getLanguage() {
+ if ( isset( $this->recursion ) ) {
+ trigger_error( "Recursion detected in " . __METHOD__, E_USER_WARNING );
+ $e = new Exception;
+ wfDebugLog( 'recursion-guard', "Recursion detected:\n" . $e->getTraceAsString() );
+
+ $code = $this->getConfig()->get( 'LanguageCode' ) ?: 'en';
+ $this->lang = Language::factory( $code );
+ } elseif ( $this->lang === null ) {
+ $this->recursion = true;
+
+ global $wgContLang;
+
+ try {
+ $request = $this->getRequest();
+ $user = $this->getUser();
+
+ $code = $request->getVal( 'uselang', 'user' );
+ if ( $code === 'user' ) {
+ $code = $user->getOption( 'language' );
+ }
+ $code = self::sanitizeLangCode( $code );
+
+ Hooks::run( 'UserGetLanguageObject', [ $user, &$code, $this ] );
+
+ if ( $code === $this->getConfig()->get( 'LanguageCode' ) ) {
+ $this->lang = $wgContLang;
+ } else {
+ $obj = Language::factory( $code );
+ $this->lang = $obj;
+ }
+
+ unset( $this->recursion );
+ }
+ catch ( Exception $ex ) {
+ unset( $this->recursion );
+ throw $ex;
+ }
+ }
+
+ return $this->lang;
+ }
+
+ /**
+ * Set the Skin object
+ *
+ * @param Skin $s
+ */
+ public function setSkin( Skin $s ) {
+ $this->skin = clone $s;
+ $this->skin->setContext( $this );
+ }
+
+ /**
+ * Get the Skin object
+ *
+ * @return Skin
+ */
+ public function getSkin() {
+ if ( $this->skin === null ) {
+ $skin = null;
+ Hooks::run( 'RequestContextCreateSkin', [ $this, &$skin ] );
+ $factory = SkinFactory::getDefaultInstance();
+
+ // If the hook worked try to set a skin from it
+ if ( $skin instanceof Skin ) {
+ $this->skin = $skin;
+ } elseif ( is_string( $skin ) ) {
+ // Normalize the key, just in case the hook did something weird.
+ $normalized = Skin::normalizeKey( $skin );
+ $this->skin = $factory->makeSkin( $normalized );
+ }
+
+ // If this is still null (the hook didn't run or didn't work)
+ // then go through the normal processing to load a skin
+ if ( $this->skin === null ) {
+ if ( !in_array( 'skin', $this->getConfig()->get( 'HiddenPrefs' ) ) ) {
+ # get the user skin
+ $userSkin = $this->getUser()->getOption( 'skin' );
+ $userSkin = $this->getRequest()->getVal( 'useskin', $userSkin );
+ } else {
+ # if we're not allowing users to override, then use the default
+ $userSkin = $this->getConfig()->get( 'DefaultSkin' );
+ }
+
+ // Normalize the key in case the user is passing gibberish
+ // or has old preferences (T71566).
+ $normalized = Skin::normalizeKey( $userSkin );
+
+ // Skin::normalizeKey will also validate it, so
+ // this won't throw an exception
+ $this->skin = $factory->makeSkin( $normalized );
+ }
+
+ // After all that set a context on whatever skin got created
+ $this->skin->setContext( $this );
+ }
+
+ return $this->skin;
+ }
+
+ /** Helpful methods **/
+
+ /**
+ * Get a Message object with context set
+ * Parameters are the same as wfMessage()
+ *
+ * @param string|string[]|MessageSpecifier $key Message key, or array of keys,
+ * or a MessageSpecifier.
+ * @param mixed $args,...
+ * @return Message
+ */
+ public function msg( $key ) {
+ $args = func_get_args();
+
+ return call_user_func_array( 'wfMessage', $args )->setContext( $this );
+ }
+
+ /** Static methods **/
+
+ /**
+ * Get the RequestContext object associated with the main request
+ *
+ * @return RequestContext
+ */
+ public static function getMain() {
+ if ( self::$instance === null ) {
+ self::$instance = new self;
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get the RequestContext object associated with the main request
+ * and gives a warning to the log, to find places, where a context maybe is missing.
+ *
+ * @param string $func
+ * @return RequestContext
+ * @since 1.24
+ */
+ public static function getMainAndWarn( $func = __METHOD__ ) {
+ wfDebug( $func . ' called without context. ' .
+ "Using RequestContext::getMain() for sanity\n" );
+
+ return self::getMain();
+ }
+
+ /**
+ * Resets singleton returned by getMain(). Should be called only from unit tests.
+ */
+ public static function resetMain() {
+ if ( !( defined( 'MW_PHPUNIT_TEST' ) || defined( 'MW_PARSER_TEST' ) ) ) {
+ throw new MWException( __METHOD__ . '() should be called only from unit tests!' );
+ }
+ self::$instance = null;
+ }
+
+ /**
+ * Export the resolved user IP, HTTP headers, user ID, and session ID.
+ * The result will be reasonably sized to allow for serialization.
+ *
+ * @return array
+ * @since 1.21
+ */
+ public function exportSession() {
+ $session = MediaWiki\Session\SessionManager::getGlobalSession();
+ return [
+ 'ip' => $this->getRequest()->getIP(),
+ 'headers' => $this->getRequest()->getAllHeaders(),
+ 'sessionId' => $session->isPersistent() ? $session->getId() : '',
+ 'userId' => $this->getUser()->getId()
+ ];
+ }
+
+ /**
+ * Import an client IP address, HTTP headers, user ID, and session ID
+ *
+ * This sets the current session, $wgUser, and $wgRequest from $params.
+ * Once the return value falls out of scope, the old context is restored.
+ * This method should only be called in contexts where there is no session
+ * ID or end user receiving the response (CLI or HTTP job runners). This
+ * is partly enforced, and is done so to avoid leaking cookies if certain
+ * error conditions arise.
+ *
+ * This is useful when background scripts inherit context when acting on
+ * behalf of a user. In general the 'sessionId' parameter should be set
+ * to an empty string unless session importing is *truly* needed. This
+ * feature is somewhat deprecated.
+ *
+ * @note suhosin.session.encrypt may interfere with this method.
+ *
+ * @param array $params Result of RequestContext::exportSession()
+ * @return ScopedCallback
+ * @throws MWException
+ * @since 1.21
+ */
+ public static function importScopedSession( array $params ) {
+ if ( strlen( $params['sessionId'] ) &&
+ MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent()
+ ) {
+ // Sanity check to avoid sending random cookies for the wrong users.
+ // This method should only called by CLI scripts or by HTTP job runners.
+ throw new MWException( "Sessions can only be imported when none is active." );
+ } elseif ( !IP::isValid( $params['ip'] ) ) {
+ throw new MWException( "Invalid client IP address '{$params['ip']}'." );
+ }
+
+ if ( $params['userId'] ) { // logged-in user
+ $user = User::newFromId( $params['userId'] );
+ $user->load();
+ if ( !$user->getId() ) {
+ throw new MWException( "No user with ID '{$params['userId']}'." );
+ }
+ } else { // anon user
+ $user = User::newFromName( $params['ip'], false );
+ }
+
+ $importSessionFunc = function ( User $user, array $params ) {
+ global $wgRequest, $wgUser;
+
+ $context = RequestContext::getMain();
+
+ // Commit and close any current session
+ if ( MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
+ session_write_close(); // persist
+ session_id( '' ); // detach
+ $_SESSION = []; // clear in-memory array
+ }
+
+ // Get new session, if applicable
+ $session = null;
+ if ( strlen( $params['sessionId'] ) ) { // don't make a new random ID
+ $manager = MediaWiki\Session\SessionManager::singleton();
+ $session = $manager->getSessionById( $params['sessionId'], true )
+ ?: $manager->getEmptySession();
+ }
+
+ // Remove any user IP or agent information, and attach the request
+ // with the new session.
+ $context->setRequest( new FauxRequest( [], false, $session ) );
+ $wgRequest = $context->getRequest(); // b/c
+
+ // Now that all private information is detached from the user, it should
+ // be safe to load the new user. If errors occur or an exception is thrown
+ // and caught (leaving the main context in a mixed state), there is no risk
+ // of the User object being attached to the wrong IP, headers, or session.
+ $context->setUser( $user );
+ $wgUser = $context->getUser(); // b/c
+ if ( $session && MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
+ session_id( $session->getId() );
+ MediaWiki\quietCall( 'session_start' );
+ }
+ $request = new FauxRequest( [], false, $session );
+ $request->setIP( $params['ip'] );
+ foreach ( $params['headers'] as $name => $value ) {
+ $request->setHeader( $name, $value );
+ }
+ // Set the current context to use the new WebRequest
+ $context->setRequest( $request );
+ $wgRequest = $context->getRequest(); // b/c
+ };
+
+ // Stash the old session and load in the new one
+ $oUser = self::getMain()->getUser();
+ $oParams = self::getMain()->exportSession();
+ $oRequest = self::getMain()->getRequest();
+ $importSessionFunc( $user, $params );
+
+ // Set callback to save and close the new session and reload the old one
+ return new ScopedCallback(
+ function () use ( $importSessionFunc, $oUser, $oParams, $oRequest ) {
+ global $wgRequest;
+ $importSessionFunc( $oUser, $oParams );
+ // Restore the exact previous Request object (instead of leaving FauxRequest)
+ RequestContext::getMain()->setRequest( $oRequest );
+ $wgRequest = RequestContext::getMain()->getRequest(); // b/c
+ }
+ );
+ }
+
+ /**
+ * Create a new extraneous context. The context is filled with information
+ * external to the current session.
+ * - Title is specified by argument
+ * - Request is a FauxRequest, or a FauxRequest can be specified by argument
+ * - User is an anonymous user, for separation IPv4 localhost is used
+ * - Language will be based on the anonymous user and request, may be content
+ * language or a uselang param in the fauxrequest data may change the lang
+ * - Skin will be based on the anonymous user, should be the wiki's default skin
+ *
+ * @param Title $title Title to use for the extraneous request
+ * @param WebRequest|array $request A WebRequest or data to use for a FauxRequest
+ * @return RequestContext
+ */
+ public static function newExtraneousContext( Title $title, $request = [] ) {
+ $context = new self;
+ $context->setTitle( $title );
+ if ( $request instanceof WebRequest ) {
+ $context->setRequest( $request );
+ } else {
+ $context->setRequest( new FauxRequest( $request ) );
+ }
+ $context->user = User::newFromName( '127.0.0.1', false );
+
+ return $context;
+ }
+}
diff --git a/www/wiki/includes/dao/DBAccessBase.php b/www/wiki/includes/dao/DBAccessBase.php
new file mode 100644
index 00000000..0f8d7f73
--- /dev/null
+++ b/www/wiki/includes/dao/DBAccessBase.php
@@ -0,0 +1,95 @@
+<?php
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Base class for objects that allow access to other wiki's databases using
+ * the foreign database access mechanism implemented by LBFactoryMulti.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Database
+ *
+ * @licence GNU GPL v2+
+ * @author Daniel Kinzler
+ */
+abstract class DBAccessBase implements IDBAccessObject {
+ /**
+ * @var string|bool $wiki The target wiki's name. This must be an ID
+ * that LBFactory can understand.
+ */
+ protected $wiki = false;
+
+ /**
+ * @param string|bool $wiki The target wiki's name. This must be an ID
+ * that LBFactory can understand.
+ */
+ public function __construct( $wiki = false ) {
+ $this->wiki = $wiki;
+ }
+
+ /**
+ * Returns a database connection.
+ *
+ * @see wfGetDB()
+ * @see LoadBalancer::getConnection()
+ *
+ * @since 1.21
+ *
+ * @param int $id Which connection to use
+ * @param array $groups Query groups
+ *
+ * @return Database
+ */
+ protected function getConnection( $id, $groups = [] ) {
+ $loadBalancer = wfGetLB( $this->wiki );
+
+ return $loadBalancer->getConnection( $id, $groups, $this->wiki );
+ }
+
+ /**
+ * Releases a database connection and makes it available for recycling.
+ *
+ * @see LoadBalancer::reuseConnection()
+ *
+ * @since 1.21
+ *
+ * @param Database $db The database connection to release.
+ */
+ protected function releaseConnection( Database $db ) {
+ if ( $this->wiki !== false ) {
+ $loadBalancer = $this->getLoadBalancer();
+ $loadBalancer->reuseConnection( $db );
+ }
+ }
+
+ /**
+ * Get the database type used for read operations.
+ *
+ * @see wfGetLB
+ *
+ * @since 1.21
+ *
+ * @return LoadBalancer The database load balancer object
+ */
+ public function getLoadBalancer() {
+ return wfGetLB( $this->wiki );
+ }
+}
diff --git a/www/wiki/includes/dao/DBAccessObjectUtils.php b/www/wiki/includes/dao/DBAccessObjectUtils.php
new file mode 100644
index 00000000..ee103685
--- /dev/null
+++ b/www/wiki/includes/dao/DBAccessObjectUtils.php
@@ -0,0 +1,81 @@
+<?php
+/**
+ * This file contains database access object related constants.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Helper class for DAO classes
+ *
+ * @since 1.26
+ */
+class DBAccessObjectUtils implements IDBAccessObject {
+ /**
+ * @param int $bitfield
+ * @param int $flags IDBAccessObject::READ_* constant
+ * @return bool Bitfield has flag $flag set
+ */
+ public static function hasFlags( $bitfield, $flags ) {
+ return ( $bitfield & $flags ) == $flags;
+ }
+
+ /**
+ * Get an appropriate DB index, options, and fallback DB index for a query
+ *
+ * The fallback DB index and options are to be used if the entity is not found
+ * with the initial DB index, typically querying the master DB to avoid lag
+ *
+ * @param int $bitfield Bitfield of IDBAccessObject::READ_* constants
+ * @return array List of DB indexes and options in this order:
+ * - DB_MASTER or DB_REPLICA constant for the initial query
+ * - SELECT options array for the initial query
+ * - DB_MASTER constant for the fallback query; null if no fallback should happen
+ * - SELECT options array for the fallback query; empty if no fallback should happen
+ */
+ public static function getDBOptions( $bitfield ) {
+ if ( self::hasFlags( $bitfield, self::READ_LATEST_IMMUTABLE ) ) {
+ $index = DB_REPLICA; // override READ_LATEST if set
+ $fallbackIndex = DB_MASTER;
+ } elseif ( self::hasFlags( $bitfield, self::READ_LATEST ) ) {
+ $index = DB_MASTER;
+ $fallbackIndex = null;
+ } else {
+ $index = DB_REPLICA;
+ $fallbackIndex = null;
+ }
+
+ $lockingOptions = [];
+ if ( self::hasFlags( $bitfield, self::READ_EXCLUSIVE ) ) {
+ $lockingOptions[] = 'FOR UPDATE';
+ } elseif ( self::hasFlags( $bitfield, self::READ_LOCKING ) ) {
+ $lockingOptions[] = 'LOCK IN SHARE MODE';
+ }
+
+ if ( $fallbackIndex !== null ) {
+ $options = []; // locks on DB_REPLICA make no sense
+ $fallbackOptions = $lockingOptions;
+ } else {
+ $options = $lockingOptions;
+ $fallbackOptions = []; // no fallback
+ }
+
+ return [ $index, $options, $fallbackIndex, $fallbackOptions ];
+ }
+}
diff --git a/www/wiki/includes/dao/IDBAccessObject.php b/www/wiki/includes/dao/IDBAccessObject.php
new file mode 100644
index 00000000..e18a090b
--- /dev/null
+++ b/www/wiki/includes/dao/IDBAccessObject.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * This file contains database access object related constants.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Interface for database access objects.
+ *
+ * Classes using this support a set of constants in a bitfield argument to their data loading
+ * functions. In general, objects should assume READ_NORMAL if no flags are explicitly given,
+ * though certain objects may assume READ_LATEST for common use case or legacy reasons.
+ *
+ * There are four types of reads:
+ * - READ_NORMAL : Potentially cached read of data (e.g. from a replica DB or stale replica)
+ * - READ_LATEST : Up-to-date read as of transaction start (e.g. from master or a quorum read)
+ * - READ_LOCKING : Up-to-date read as of now, that locks (shared) the records
+ * - READ_EXCLUSIVE : Up-to-date read as of now, that locks (exclusive) the records
+ * All record locks persist for the duration of the transaction.
+ *
+ * A special constant READ_LATEST_IMMUTABLE can be used for fetching append-only data. Such
+ * data is either (a) on a replica DB and up-to-date or (b) not yet there, but on the master/quorum.
+ * Because the data is append-only, it can never be stale on a replica DB if present.
+ *
+ * Callers should use READ_NORMAL (or pass in no flags) unless the read determines a write.
+ * In theory, such cases may require READ_LOCKING, though to avoid contention, READ_LATEST is
+ * often good enough. If UPDATE race condition checks are required on a row and expensive code
+ * must run after the row is fetched to determine the UPDATE, it may help to do something like:
+ * - a) Start transaction
+ * - b) Read the current row with READ_LATEST
+ * - c) Determine the new row (expensive, so we don't want to hold locks now)
+ * - d) Re-read the current row with READ_LOCKING; if it changed then bail out
+ * - e) otherwise, do the updates
+ * - f) Commit transaction
+ *
+ * @since 1.20
+ */
+interface IDBAccessObject {
+ /** Constants for object loading bitfield flags (higher => higher QoS) */
+ /** @var int Read from a replica DB/non-quorum */
+ const READ_NORMAL = 0;
+ /** @var int Read from the master/quorum */
+ const READ_LATEST = 1;
+ /* @var int Read from the master/quorum and lock out other writers */
+ const READ_LOCKING = 3; // READ_LATEST (1) and "LOCK IN SHARE MODE" (2)
+ /** @var int Read from the master/quorum and lock out other writers and locking readers */
+ const READ_EXCLUSIVE = 7; // READ_LOCKING (3) and "FOR UPDATE" (4)
+
+ /** @var int Read from a replica DB or without a quorum, using the master/quorum on miss */
+ const READ_LATEST_IMMUTABLE = 8;
+
+ // Convenience constant for tracking how data was loaded (higher => higher QoS)
+ const READ_NONE = -1; // not loaded yet (or the object was cleared)
+}
diff --git a/www/wiki/includes/db/ChronologyProtector.php b/www/wiki/includes/db/ChronologyProtector.php
new file mode 100644
index 00000000..cc359996
--- /dev/null
+++ b/www/wiki/includes/db/ChronologyProtector.php
@@ -0,0 +1,209 @@
+<?php
+/**
+ * Generator of database load balancing objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Class for ensuring a consistent ordering of events as seen by the user, despite replication.
+ * Kind of like Hawking's [[Chronology Protection Agency]].
+ */
+class ChronologyProtector {
+ /** @var BagOStuff */
+ protected $store;
+
+ /** @var string Storage key name */
+ protected $key;
+ /** @var array Map of (ip: <IP>, agent: <user-agent>) */
+ protected $client;
+ /** @var bool Whether to no-op all method calls */
+ protected $enabled = true;
+ /** @var bool Whether to check and wait on positions */
+ protected $wait = true;
+
+ /** @var bool Whether the client data was loaded */
+ protected $initialized = false;
+ /** @var DBMasterPos[] Map of (DB master name => position) */
+ protected $startupPositions = [];
+ /** @var DBMasterPos[] Map of (DB master name => position) */
+ protected $shutdownPositions = [];
+
+ /**
+ * @param BagOStuff $store
+ * @param array $client Map of (ip: <IP>, agent: <user-agent>)
+ * @since 1.27
+ */
+ public function __construct( BagOStuff $store, array $client ) {
+ $this->store = $store;
+ $this->client = $client;
+ $this->key = $store->makeGlobalKey(
+ 'ChronologyProtector',
+ md5( $client['ip'] . "\n" . $client['agent'] )
+ );
+ }
+
+ /**
+ * @param bool $enabled Whether to no-op all method calls
+ * @since 1.27
+ */
+ public function setEnabled( $enabled ) {
+ $this->enabled = $enabled;
+ }
+
+ /**
+ * @param bool $enabled Whether to check and wait on positions
+ * @since 1.27
+ */
+ public function setWaitEnabled( $enabled ) {
+ $this->wait = $enabled;
+ }
+
+ /**
+ * Initialise a LoadBalancer to give it appropriate chronology protection.
+ *
+ * If the stash has a previous master position recorded, this will try to
+ * make sure that the next query to a slave of that master will see changes up
+ * to that position by delaying execution. The delay may timeout and allow stale
+ * data if no non-lagged slaves are available.
+ *
+ * @param LoadBalancer $lb
+ * @return void
+ */
+ public function initLB( LoadBalancer $lb ) {
+ if ( !$this->enabled || $lb->getServerCount() <= 1 ) {
+ return; // non-replicated setup or disabled
+ }
+
+ $this->initPositions();
+
+ $masterName = $lb->getServerName( $lb->getWriterIndex() );
+ if ( !empty( $this->startupPositions[$masterName] ) ) {
+ $info = $lb->parentInfo();
+ $pos = $this->startupPositions[$masterName];
+ wfDebugLog( 'replication', __METHOD__ .
+ ": LB '" . $info['id'] . "' waiting for master pos $pos\n" );
+ $lb->waitFor( $pos );
+ }
+ }
+
+ /**
+ * Notify the ChronologyProtector that the LoadBalancer is about to shut
+ * down. Saves replication positions.
+ *
+ * @param LoadBalancer $lb
+ * @return void
+ */
+ public function shutdownLB( LoadBalancer $lb ) {
+ if ( !$this->enabled || $lb->getServerCount() <= 1 ) {
+ return; // non-replicated setup or disabled
+ }
+
+ $info = $lb->parentInfo();
+ $masterName = $lb->getServerName( $lb->getWriterIndex() );
+
+ // Only save the position if writes have been done on the connection
+ $db = $lb->getAnyOpenConnection( $lb->getWriterIndex() );
+ if ( !$db || !$db->doneWrites() ) {
+ wfDebugLog( 'replication', __METHOD__ . ": LB {$info['id']}, no writes done\n" );
+
+ return; // nothing to do
+ }
+
+ $pos = $db->getMasterPos();
+ wfDebugLog( 'replication', __METHOD__ . ": LB {$info['id']} has master pos $pos\n" );
+ $this->shutdownPositions[$masterName] = $pos;
+ }
+
+ /**
+ * Notify the ChronologyProtector that the LBFactory is done calling shutdownLB() for now.
+ * May commit chronology data to persistent storage.
+ *
+ * @return array Empty on success; returns the (db name => position) map on failure
+ */
+ public function shutdown() {
+ if ( !$this->enabled || !count( $this->shutdownPositions ) ) {
+ return true; // nothing to save
+ }
+
+ wfDebugLog( 'replication',
+ __METHOD__ . ": saving master pos for " .
+ implode( ', ', array_keys( $this->shutdownPositions ) ) . "\n"
+ );
+
+ $shutdownPositions = $this->shutdownPositions;
+ $ok = $this->store->merge(
+ $this->key,
+ function ( $store, $key, $curValue ) use ( $shutdownPositions ) {
+ /** @var $curPositions DBMasterPos[] */
+ if ( $curValue === false ) {
+ $curPositions = $shutdownPositions;
+ } else {
+ $curPositions = $curValue['positions'];
+ // Use the newest positions for each DB master
+ foreach ( $shutdownPositions as $db => $pos ) {
+ if ( !isset( $curPositions[$db] )
+ || $pos->asOfTime() > $curPositions[$db]->asOfTime()
+ ) {
+ $curPositions[$db] = $pos;
+ }
+ }
+ }
+
+ return [ 'positions' => $curPositions ];
+ },
+ BagOStuff::TTL_MINUTE,
+ 10,
+ BagOStuff::WRITE_SYNC // visible in all datacenters
+ );
+
+ if ( !$ok ) {
+ // Raced out too many times or stash is down
+ wfDebugLog( 'replication',
+ __METHOD__ . ": failed to save master pos for " .
+ implode( ', ', array_keys( $this->shutdownPositions ) ) . "\n"
+ );
+
+ return $this->shutdownPositions;
+ }
+
+ return [];
+ }
+
+ /**
+ * Load in previous master positions for the client
+ */
+ protected function initPositions() {
+ if ( $this->initialized ) {
+ return;
+ }
+
+ $this->initialized = true;
+ if ( $this->wait ) {
+ $data = $this->store->get( $this->key );
+ $this->startupPositions = $data ? $data['positions'] : [];
+
+ wfDebugLog( 'replication', __METHOD__ . ": key is {$this->key} (read)\n" );
+ } else {
+ $this->startupPositions = [];
+
+ wfDebugLog( 'replication', __METHOD__ . ": key is {$this->key} (unread)\n" );
+ }
+ }
+}
diff --git a/www/wiki/includes/db/CloneDatabase.php b/www/wiki/includes/db/CloneDatabase.php
new file mode 100644
index 00000000..3d22c037
--- /dev/null
+++ b/www/wiki/includes/db/CloneDatabase.php
@@ -0,0 +1,143 @@
+<?php
+/**
+ * Helper class for making a copy of the database, mostly for unit testing.
+ *
+ * Copyright © 2010 Chad Horohoe <chad@anyonecanedit.org>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IMaintainableDatabase;
+
+class CloneDatabase {
+ /** @var string Table prefix for cloning */
+ private $newTablePrefix = '';
+
+ /** @var string Current table prefix */
+ private $oldTablePrefix = '';
+
+ /** @var array List of tables to be cloned */
+ private $tablesToClone = [];
+
+ /** @var bool Should we DROP tables containing the new names? */
+ private $dropCurrentTables = true;
+
+ /** @var bool Whether to use temporary tables or not */
+ private $useTemporaryTables = true;
+
+ /** @var IMaintainableDatabase */
+ private $db;
+
+ /**
+ * @param IMaintainableDatabase $db A database subclass
+ * @param array $tablesToClone An array of tables to clone, unprefixed
+ * @param string $newTablePrefix Prefix to assign to the tables
+ * @param string $oldTablePrefix Prefix on current tables, if not $wgDBprefix
+ * @param bool $dropCurrentTables
+ */
+ public function __construct( IMaintainableDatabase $db, array $tablesToClone,
+ $newTablePrefix, $oldTablePrefix = '', $dropCurrentTables = true
+ ) {
+ $this->db = $db;
+ $this->tablesToClone = $tablesToClone;
+ $this->newTablePrefix = $newTablePrefix;
+ $this->oldTablePrefix = $oldTablePrefix ? $oldTablePrefix : $this->db->tablePrefix();
+ $this->dropCurrentTables = $dropCurrentTables;
+ }
+
+ /**
+ * Set whether to use temporary tables or not
+ * @param bool $u Use temporary tables when cloning the structure
+ */
+ public function useTemporaryTables( $u = true ) {
+ $this->useTemporaryTables = $u;
+ }
+
+ /**
+ * Clone the table structure
+ */
+ public function cloneTableStructure() {
+ global $wgSharedTables, $wgSharedDB;
+ foreach ( $this->tablesToClone as $tbl ) {
+ if ( $wgSharedDB && in_array( $tbl, $wgSharedTables, true ) ) {
+ // Shared tables don't work properly when cloning due to
+ // how prefixes are handled (T67654)
+ throw new RuntimeException( "Cannot clone shared table $tbl." );
+ }
+ # Clean up from previous aborted run. So that table escaping
+ # works correctly across DB engines, we need to change the pre-
+ # fix back and forth so tableName() works right.
+
+ self::changePrefix( $this->oldTablePrefix );
+ $oldTableName = $this->db->tableName( $tbl, 'raw' );
+
+ self::changePrefix( $this->newTablePrefix );
+ $newTableName = $this->db->tableName( $tbl, 'raw' );
+
+ // Postgres: Temp tables are automatically deleted upon end of session
+ // Same Temp table name hides existing table for current session
+ if ( $this->dropCurrentTables
+ && !in_array( $this->db->getType(), [ 'oracle' ] )
+ ) {
+ if ( $oldTableName === $newTableName ) {
+ // Last ditch check to avoid data loss
+ throw new LogicException( "Not dropping new table, as '$newTableName'"
+ . " is name of both the old and the new table." );
+ }
+ $this->db->dropTable( $tbl, __METHOD__ );
+ wfDebug( __METHOD__ . " dropping {$newTableName}\n" );
+ // Dropping the oldTable because the prefix was changed
+ }
+
+ # Create new table
+ wfDebug( __METHOD__ . " duplicating $oldTableName to $newTableName\n" );
+ $this->db->duplicateTableStructure(
+ $oldTableName, $newTableName, $this->useTemporaryTables );
+ }
+ }
+
+ /**
+ * Change the prefix back to the original.
+ * @param bool $dropTables Optionally drop the tables we created
+ */
+ public function destroy( $dropTables = false ) {
+ if ( $dropTables ) {
+ self::changePrefix( $this->newTablePrefix );
+ foreach ( $this->tablesToClone as $tbl ) {
+ $this->db->dropTable( $tbl );
+ }
+ }
+ self::changePrefix( $this->oldTablePrefix );
+ }
+
+ /**
+ * Change the table prefix on all open DB connections/
+ *
+ * @param string $prefix
+ * @return void
+ */
+ public static function changePrefix( $prefix ) {
+ global $wgDBprefix;
+
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->setDomainPrefix( $prefix );
+ $wgDBprefix = $prefix;
+ }
+}
diff --git a/www/wiki/includes/db/DBConnRef.php b/www/wiki/includes/db/DBConnRef.php
new file mode 100644
index 00000000..d73ba85f
--- /dev/null
+++ b/www/wiki/includes/db/DBConnRef.php
@@ -0,0 +1,544 @@
+<?php
+/**
+ * Helper class to handle automatically marking connections as reusable (via RAII pattern)
+ * as well handling deferring the actual network connection until the handle is used
+ *
+ * @note: proxy methods are defined explicity to avoid interface errors
+ * @ingroup Database
+ * @since 1.22
+ */
+class DBConnRef implements IDatabase {
+ /** @var LoadBalancer */
+ private $lb;
+
+ /** @var DatabaseBase|null */
+ private $conn;
+
+ /** @var array|null */
+ private $params;
+
+ /**
+ * @param LoadBalancer $lb
+ * @param DatabaseBase|array $conn Connection or (server index, group, wiki ID) array
+ */
+ public function __construct( LoadBalancer $lb, $conn ) {
+ $this->lb = $lb;
+ if ( $conn instanceof DatabaseBase ) {
+ $this->conn = $conn;
+ } else {
+ $this->params = $conn;
+ }
+ }
+
+ function __call( $name, array $arguments ) {
+ if ( $this->conn === null ) {
+ list( $db, $groups, $wiki ) = $this->params;
+ $this->conn = $this->lb->getConnection( $db, $groups, $wiki );
+ }
+
+ return call_user_func_array( [ $this->conn, $name ], $arguments );
+ }
+
+ public function getServerInfo() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function bufferResults( $buffer = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function trxLevel() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function trxTimestamp() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function tablePrefix( $prefix = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function dbSchema( $schema = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getLBInfo( $name = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setLBInfo( $name, $value = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function implicitGroupby() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function implicitOrderby() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lastQuery() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function doneWrites() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lastDoneWrites() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function writesPending() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function writesOrCallbacksPending() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function pendingWriteQueryDuration() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function pendingWriteCallers() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function isOpen() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setFlag( $flag ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function clearFlag( $flag ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getFlag( $flag ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getProperty( $name ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getWikiID() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getType() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function open( $server, $user, $password, $dbName ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fetchObject( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fetchRow( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function numRows( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function numFields( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fieldName( $res, $n ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function insertId() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function dataSeek( $res, $row ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lastErrno() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lastError() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fieldInfo( $table, $field ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function affectedRows() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getSoftwareLink() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getServerVersion() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function close() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function reportConnectionError( $error = 'Unknown error' ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function freeResult( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectField(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectFieldValues(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function select(
+ $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectSQLText(
+ $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectRow(
+ $table, $vars, $conds, $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function estimateRowCount(
+ $table, $vars = '*', $conds = '', $fname = __METHOD__, $options = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectRowCount(
+ $tables, $vars = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fieldExists( $table, $field, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function indexExists( $table, $index, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function tableExists( $table, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function indexUnique( $table, $index ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function update( $table, $values, $conds, $fname = __METHOD__, $options = [] ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function makeList( $a, $mode = LIST_COMMA ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function bitNot( $field ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function bitAnd( $fieldLeft, $fieldRight ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function bitOr( $fieldLeft, $fieldRight ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function buildConcat( $stringList ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectDB( $db ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getDBname() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getServer() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function addQuotes( $s ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function buildLike() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function anyChar() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function anyString() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function nextSequenceValue( $seqName ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function upsert(
+ $table, array $rows, array $uniqueIndexes, array $set, $fname = __METHOD__
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function deleteJoin(
+ $delTable, $joinTable, $delVar, $joinVar, $conds, $fname = __METHOD__
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function delete( $table, $conds, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function insertSelect(
+ $destTable, $srcTable, $varMap, $conds,
+ $fname = __METHOD__, $insertOptions = [], $selectOptions = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function unionSupportsOrderAndLimit() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function unionQueries( $sqls, $all ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function conditional( $cond, $trueVal, $falseVal ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function strreplace( $orig, $old, $new ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getServerUptime() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function wasDeadlock() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function wasLockTimeout() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function wasErrorReissuable() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function wasReadOnlyError() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function masterPosWait( DBMasterPos $pos, $timeout ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getSlavePos() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getMasterPos() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function onTransactionIdle( $callback ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function onTransactionPreCommitOrIdle( $callback ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function startAtomic( $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function endAtomic( $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function doAtomicSection( $fname, $callback ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function begin( $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function commit( $fname = __METHOD__, $flush = '' ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function rollback( $fname = __METHOD__, $flush = '' ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function listTables( $prefix = null, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function timestamp( $ts = 0 ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function timestampOrNull( $ts = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function ping() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getLag() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getSessionLagStatus() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function maxListLen() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function encodeBlob( $b ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function decodeBlob( $b ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setSessionOptions( array $options ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setSchemaVars( $vars ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lockIsFree( $lockName, $method ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function unlock( $lockName, $method ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getScopedLockAndFlush( $lockKey, $fname, $timeout ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function namedLocksEnqueue() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getInfinity() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function encodeExpiry( $expiry ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function decodeExpiry( $expiry, $format = TS_MW ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setBigSelects( $value = true ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function isReadOnly() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ /**
+ * Clean up the connection when out of scope
+ */
+ function __destruct() {
+ if ( $this->conn !== null ) {
+ $this->lb->reuseConnection( $this->conn );
+ }
+ }
+}
diff --git a/www/wiki/includes/db/Database.php b/www/wiki/includes/db/Database.php
new file mode 100644
index 00000000..9daead3d
--- /dev/null
+++ b/www/wiki/includes/db/Database.php
@@ -0,0 +1,3325 @@
+<?php
+
+/**
+ * @defgroup Database Database
+ *
+ * This file deals with database interface functions
+ * and query specifics/optimisations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Database abstraction object
+ * @ingroup Database
+ */
+abstract class DatabaseBase implements IDatabase {
+ /** Number of times to re-try an operation in case of deadlock */
+ const DEADLOCK_TRIES = 4;
+
+ /** Minimum time to wait before retry, in microseconds */
+ const DEADLOCK_DELAY_MIN = 500000;
+
+ /** Maximum time to wait before retry */
+ const DEADLOCK_DELAY_MAX = 1500000;
+
+ protected $mLastQuery = '';
+ protected $mDoneWrites = false;
+ protected $mPHPError = false;
+
+ protected $mServer, $mUser, $mPassword, $mDBname;
+
+ /** @var BagOStuff APC cache */
+ protected $srvCache;
+
+ /** @var resource Database connection */
+ protected $mConn = null;
+ protected $mOpened = false;
+
+ /** @var callable[] */
+ protected $mTrxIdleCallbacks = [];
+ /** @var callable[] */
+ protected $mTrxPreCommitCallbacks = [];
+
+ protected $mTablePrefix;
+ protected $mSchema;
+ protected $mFlags;
+ protected $mForeign;
+ protected $mLBInfo = [];
+ protected $mDefaultBigSelects = null;
+ protected $mSchemaVars = false;
+ /** @var array */
+ protected $mSessionVars = [];
+
+ protected $preparedArgs;
+
+ protected $htmlErrors;
+
+ protected $delimiter = ';';
+
+ /**
+ * Either 1 if a transaction is active or 0 otherwise.
+ * The other Trx fields may not be meaningfull if this is 0.
+ *
+ * @var int
+ */
+ protected $mTrxLevel = 0;
+
+ /**
+ * Either a short hexidecimal string if a transaction is active or ""
+ *
+ * @var string
+ * @see DatabaseBase::mTrxLevel
+ */
+ protected $mTrxShortId = '';
+
+ /**
+ * The UNIX time that the transaction started. Callers can assume that if
+ * snapshot isolation is used, then the data is *at least* up to date to that
+ * point (possibly more up-to-date since the first SELECT defines the snapshot).
+ *
+ * @var float|null
+ * @see DatabaseBase::mTrxLevel
+ */
+ private $mTrxTimestamp = null;
+
+ /** @var float Lag estimate at the time of BEGIN */
+ private $mTrxSlaveLag = null;
+
+ /**
+ * Remembers the function name given for starting the most recent transaction via begin().
+ * Used to provide additional context for error reporting.
+ *
+ * @var string
+ * @see DatabaseBase::mTrxLevel
+ */
+ private $mTrxFname = null;
+
+ /**
+ * Record if possible write queries were done in the last transaction started
+ *
+ * @var bool
+ * @see DatabaseBase::mTrxLevel
+ */
+ private $mTrxDoneWrites = false;
+
+ /**
+ * Record if the current transaction was started implicitly due to DBO_TRX being set.
+ *
+ * @var bool
+ * @see DatabaseBase::mTrxLevel
+ */
+ private $mTrxAutomatic = false;
+
+ /**
+ * Array of levels of atomicity within transactions
+ *
+ * @var array
+ */
+ private $mTrxAtomicLevels = [];
+
+ /**
+ * Record if the current transaction was started implicitly by DatabaseBase::startAtomic
+ *
+ * @var bool
+ */
+ private $mTrxAutomaticAtomic = false;
+
+ /**
+ * Track the write query callers of the current transaction
+ *
+ * @var string[]
+ */
+ private $mTrxWriteCallers = [];
+
+ /**
+ * Track the seconds spent in write queries for the current transaction
+ *
+ * @var float
+ */
+ private $mTrxWriteDuration = 0.0;
+
+ /** @var array Map of (name => 1) for locks obtained via lock() */
+ private $mNamedLocksHeld = [];
+
+ /** @var IDatabase|null Lazy handle to the master DB this server replicates from */
+ private $lazyMasterHandle;
+
+ /**
+ * @since 1.21
+ * @var resource File handle for upgrade
+ */
+ protected $fileHandle = null;
+
+ /**
+ * @since 1.22
+ * @var string[] Process cache of VIEWs names in the database
+ */
+ protected $allViews = null;
+
+ /** @var TransactionProfiler */
+ protected $trxProfiler;
+
+ public function getServerInfo() {
+ return $this->getServerVersion();
+ }
+
+ /**
+ * @return string Command delimiter used by this database engine
+ */
+ public function getDelimiter() {
+ return $this->delimiter;
+ }
+
+ /**
+ * Boolean, controls output of large amounts of debug information.
+ * @param bool|null $debug
+ * - true to enable debugging
+ * - false to disable debugging
+ * - omitted or null to do nothing
+ *
+ * @return bool|null Previous value of the flag
+ */
+ public function debug( $debug = null ) {
+ return wfSetBit( $this->mFlags, DBO_DEBUG, $debug );
+ }
+
+ public function bufferResults( $buffer = null ) {
+ if ( is_null( $buffer ) ) {
+ return !(bool)( $this->mFlags & DBO_NOBUFFER );
+ } else {
+ return !wfSetBit( $this->mFlags, DBO_NOBUFFER, !$buffer );
+ }
+ }
+
+ /**
+ * Turns on (false) or off (true) the automatic generation and sending
+ * of a "we're sorry, but there has been a database error" page on
+ * database errors. Default is on (false). When turned off, the
+ * code should use lastErrno() and lastError() to handle the
+ * situation as appropriate.
+ *
+ * Do not use this function outside of the Database classes.
+ *
+ * @param null|bool $ignoreErrors
+ * @return bool The previous value of the flag.
+ */
+ protected function ignoreErrors( $ignoreErrors = null ) {
+ return wfSetBit( $this->mFlags, DBO_IGNORE, $ignoreErrors );
+ }
+
+ public function trxLevel() {
+ return $this->mTrxLevel;
+ }
+
+ public function trxTimestamp() {
+ return $this->mTrxLevel ? $this->mTrxTimestamp : null;
+ }
+
+ public function tablePrefix( $prefix = null ) {
+ return wfSetVar( $this->mTablePrefix, $prefix );
+ }
+
+ public function dbSchema( $schema = null ) {
+ return wfSetVar( $this->mSchema, $schema );
+ }
+
+ /**
+ * Set the filehandle to copy write statements to.
+ *
+ * @param resource $fh File handle
+ */
+ public function setFileHandle( $fh ) {
+ $this->fileHandle = $fh;
+ }
+
+ public function getLBInfo( $name = null ) {
+ if ( is_null( $name ) ) {
+ return $this->mLBInfo;
+ } else {
+ if ( array_key_exists( $name, $this->mLBInfo ) ) {
+ return $this->mLBInfo[$name];
+ } else {
+ return null;
+ }
+ }
+ }
+
+ public function setLBInfo( $name, $value = null ) {
+ if ( is_null( $value ) ) {
+ $this->mLBInfo = $name;
+ } else {
+ $this->mLBInfo[$name] = $value;
+ }
+ }
+
+ /**
+ * Set a lazy-connecting DB handle to the master DB (for replication status purposes)
+ *
+ * @param IDatabase $conn
+ * @since 1.27
+ */
+ public function setLazyMasterHandle( IDatabase $conn ) {
+ $this->lazyMasterHandle = $conn;
+ }
+
+ /**
+ * @return IDatabase|null
+ * @see setLazyMasterHandle()
+ * @since 1.27
+ */
+ public function getLazyMasterHandle() {
+ return $this->lazyMasterHandle;
+ }
+
+ /**
+ * @return TransactionProfiler
+ */
+ protected function getTransactionProfiler() {
+ if ( !$this->trxProfiler ) {
+ $this->trxProfiler = new TransactionProfiler();
+ }
+
+ return $this->trxProfiler;
+ }
+
+ /**
+ * @param TransactionProfiler $profiler
+ * @since 1.27
+ */
+ public function setTransactionProfiler( TransactionProfiler $profiler ) {
+ $this->trxProfiler = $profiler;
+ }
+
+ /**
+ * Returns true if this database supports (and uses) cascading deletes
+ *
+ * @return bool
+ */
+ public function cascadingDeletes() {
+ return false;
+ }
+
+ /**
+ * Returns true if this database supports (and uses) triggers (e.g. on the page table)
+ *
+ * @return bool
+ */
+ public function cleanupTriggers() {
+ return false;
+ }
+
+ /**
+ * Returns true if this database is strict about what can be put into an IP field.
+ * Specifically, it uses a NULL value instead of an empty string.
+ *
+ * @return bool
+ */
+ public function strictIPs() {
+ return false;
+ }
+
+ /**
+ * Returns true if this database uses timestamps rather than integers
+ *
+ * @return bool
+ */
+ public function realTimestamps() {
+ return false;
+ }
+
+ public function implicitGroupby() {
+ return true;
+ }
+
+ public function implicitOrderby() {
+ return true;
+ }
+
+ /**
+ * Returns true if this database can do a native search on IP columns
+ * e.g. this works as expected: .. WHERE rc_ip = '127.42.12.102/32';
+ *
+ * @return bool
+ */
+ public function searchableIPs() {
+ return false;
+ }
+
+ /**
+ * Returns true if this database can use functional indexes
+ *
+ * @return bool
+ */
+ public function functionalIndexes() {
+ return false;
+ }
+
+ public function lastQuery() {
+ return $this->mLastQuery;
+ }
+
+ public function doneWrites() {
+ return (bool)$this->mDoneWrites;
+ }
+
+ public function lastDoneWrites() {
+ return $this->mDoneWrites ?: false;
+ }
+
+ public function writesPending() {
+ return $this->mTrxLevel && $this->mTrxDoneWrites;
+ }
+
+ public function writesOrCallbacksPending() {
+ return $this->mTrxLevel && (
+ $this->mTrxDoneWrites || $this->mTrxIdleCallbacks || $this->mTrxPreCommitCallbacks
+ );
+ }
+
+ public function pendingWriteQueryDuration() {
+ return $this->mTrxLevel ? $this->mTrxWriteDuration : false;
+ }
+
+ public function pendingWriteCallers() {
+ return $this->mTrxLevel ? $this->mTrxWriteCallers : [];
+ }
+
+ public function isOpen() {
+ return $this->mOpened;
+ }
+
+ public function setFlag( $flag ) {
+ $this->mFlags |= $flag;
+ }
+
+ public function clearFlag( $flag ) {
+ $this->mFlags &= ~$flag;
+ }
+
+ public function getFlag( $flag ) {
+ return !!( $this->mFlags & $flag );
+ }
+
+ public function getProperty( $name ) {
+ return $this->$name;
+ }
+
+ public function getWikiID() {
+ if ( $this->mTablePrefix ) {
+ return "{$this->mDBname}-{$this->mTablePrefix}";
+ } else {
+ return $this->mDBname;
+ }
+ }
+
+ /**
+ * Return a path to the DBMS-specific SQL file if it exists,
+ * otherwise default SQL file
+ *
+ * @param string $filename
+ * @return string
+ */
+ private function getSqlFilePath( $filename ) {
+ global $IP;
+ $dbmsSpecificFilePath = "$IP/maintenance/" . $this->getType() . "/$filename";
+ if ( file_exists( $dbmsSpecificFilePath ) ) {
+ return $dbmsSpecificFilePath;
+ } else {
+ return "$IP/maintenance/$filename";
+ }
+ }
+
+ /**
+ * Return a path to the DBMS-specific schema file,
+ * otherwise default to tables.sql
+ *
+ * @return string
+ */
+ public function getSchemaPath() {
+ return $this->getSqlFilePath( 'tables.sql' );
+ }
+
+ /**
+ * Return a path to the DBMS-specific update key file,
+ * otherwise default to update-keys.sql
+ *
+ * @return string
+ */
+ public function getUpdateKeysPath() {
+ return $this->getSqlFilePath( 'update-keys.sql' );
+ }
+
+ /**
+ * Get information about an index into an object
+ * @param string $table Table name
+ * @param string $index Index name
+ * @param string $fname Calling function name
+ * @return mixed Database-specific index description class or false if the index does not exist
+ */
+ abstract function indexInfo( $table, $index, $fname = __METHOD__ );
+
+ /**
+ * Wrapper for addslashes()
+ *
+ * @param string $s String to be slashed.
+ * @return string Slashed string.
+ */
+ abstract function strencode( $s );
+
+ /**
+ * Constructor.
+ *
+ * FIXME: It is possible to construct a Database object with no associated
+ * connection object, by specifying no parameters to __construct(). This
+ * feature is deprecated and should be removed.
+ *
+ * DatabaseBase subclasses should not be constructed directly in external
+ * code. DatabaseBase::factory() should be used instead.
+ *
+ * @param array $params Parameters passed from DatabaseBase::factory()
+ */
+ function __construct( array $params ) {
+ global $wgDBprefix, $wgDBmwschema, $wgCommandLineMode;
+
+ $this->srvCache = ObjectCache::getLocalServerInstance( 'hash' );
+
+ $server = $params['host'];
+ $user = $params['user'];
+ $password = $params['password'];
+ $dbName = $params['dbname'];
+ $flags = $params['flags'];
+ $tablePrefix = $params['tablePrefix'];
+ $schema = $params['schema'];
+ $foreign = $params['foreign'];
+
+ $this->mFlags = $flags;
+ if ( $this->mFlags & DBO_DEFAULT ) {
+ if ( $wgCommandLineMode ) {
+ $this->mFlags &= ~DBO_TRX;
+ } else {
+ $this->mFlags |= DBO_TRX;
+ }
+ }
+
+ $this->mSessionVars = $params['variables'];
+
+ /** Get the default table prefix*/
+ if ( $tablePrefix === 'get from global' ) {
+ $this->mTablePrefix = $wgDBprefix;
+ } else {
+ $this->mTablePrefix = $tablePrefix;
+ }
+
+ /** Get the database schema*/
+ if ( $schema === 'get from global' ) {
+ $this->mSchema = $wgDBmwschema;
+ } else {
+ $this->mSchema = $schema;
+ }
+
+ $this->mForeign = $foreign;
+
+ if ( isset( $params['trxProfiler'] ) ) {
+ $this->trxProfiler = $params['trxProfiler']; // override
+ }
+
+ if ( $user ) {
+ $this->open( $server, $user, $password, $dbName );
+ }
+ }
+
+ /**
+ * Called by serialize. Throw an exception when DB connection is serialized.
+ * This causes problems on some database engines because the connection is
+ * not restored on unserialize.
+ */
+ public function __sleep() {
+ throw new MWException( 'Database serialization may cause problems, since ' .
+ 'the connection is not restored on wakeup.' );
+ }
+
+ /**
+ * Given a DB type, construct the name of the appropriate child class of
+ * DatabaseBase. This is designed to replace all of the manual stuff like:
+ * $class = 'Database' . ucfirst( strtolower( $dbType ) );
+ * as well as validate against the canonical list of DB types we have
+ *
+ * This factory function is mostly useful for when you need to connect to a
+ * database other than the MediaWiki default (such as for external auth,
+ * an extension, et cetera). Do not use this to connect to the MediaWiki
+ * database. Example uses in core:
+ * @see LoadBalancer::reallyOpenConnection()
+ * @see ForeignDBRepo::getMasterDB()
+ * @see WebInstallerDBConnect::execute()
+ *
+ * @since 1.18
+ *
+ * @param string $dbType A possible DB type
+ * @param array $p An array of options to pass to the constructor.
+ * Valid options are: host, user, password, dbname, flags, tablePrefix, schema, driver
+ * @throws MWException If the database driver or extension cannot be found
+ * @return DatabaseBase|null DatabaseBase subclass or null
+ */
+ final public static function factory( $dbType, $p = [] ) {
+ $canonicalDBTypes = [
+ 'mysql' => [ 'mysqli', 'mysql' ],
+ 'postgres' => [],
+ 'sqlite' => [],
+ 'oracle' => [],
+ 'mssql' => [],
+ ];
+
+ $driver = false;
+ $dbType = strtolower( $dbType );
+ if ( isset( $canonicalDBTypes[$dbType] ) && $canonicalDBTypes[$dbType] ) {
+ $possibleDrivers = $canonicalDBTypes[$dbType];
+ if ( !empty( $p['driver'] ) ) {
+ if ( in_array( $p['driver'], $possibleDrivers ) ) {
+ $driver = $p['driver'];
+ } else {
+ throw new MWException( __METHOD__ .
+ " cannot construct Database with type '$dbType' and driver '{$p['driver']}'" );
+ }
+ } else {
+ foreach ( $possibleDrivers as $posDriver ) {
+ if ( extension_loaded( $posDriver ) ) {
+ $driver = $posDriver;
+ break;
+ }
+ }
+ }
+ } else {
+ $driver = $dbType;
+ }
+ if ( $driver === false ) {
+ throw new MWException( __METHOD__ .
+ " no viable database extension found for type '$dbType'" );
+ }
+
+ // Determine schema defaults. Currently Microsoft SQL Server uses $wgDBmwschema,
+ // and everything else doesn't use a schema (e.g. null)
+ // Although postgres and oracle support schemas, we don't use them (yet)
+ // to maintain backwards compatibility
+ $defaultSchemas = [
+ 'mssql' => 'get from global',
+ ];
+
+ $class = 'Database' . ucfirst( $driver );
+ if ( class_exists( $class ) && is_subclass_of( $class, 'DatabaseBase' ) ) {
+ // Resolve some defaults for b/c
+ $p['host'] = isset( $p['host'] ) ? $p['host'] : false;
+ $p['user'] = isset( $p['user'] ) ? $p['user'] : false;
+ $p['password'] = isset( $p['password'] ) ? $p['password'] : false;
+ $p['dbname'] = isset( $p['dbname'] ) ? $p['dbname'] : false;
+ $p['flags'] = isset( $p['flags'] ) ? $p['flags'] : 0;
+ $p['variables'] = isset( $p['variables'] ) ? $p['variables'] : [];
+ $p['tablePrefix'] = isset( $p['tablePrefix'] ) ? $p['tablePrefix'] : 'get from global';
+ if ( !isset( $p['schema'] ) ) {
+ $p['schema'] = isset( $defaultSchemas[$dbType] ) ? $defaultSchemas[$dbType] : null;
+ }
+ $p['foreign'] = isset( $p['foreign'] ) ? $p['foreign'] : false;
+
+ return new $class( $p );
+ } else {
+ return null;
+ }
+ }
+
+ protected function installErrorHandler() {
+ $this->mPHPError = false;
+ $this->htmlErrors = ini_set( 'html_errors', '0' );
+ set_error_handler( [ $this, 'connectionErrorHandler' ] );
+ }
+
+ /**
+ * @return bool|string
+ */
+ protected function restoreErrorHandler() {
+ restore_error_handler();
+ if ( $this->htmlErrors !== false ) {
+ ini_set( 'html_errors', $this->htmlErrors );
+ }
+ if ( $this->mPHPError ) {
+ $error = preg_replace( '!\[<a.*</a>\]!', '', $this->mPHPError );
+ $error = preg_replace( '!^.*?:\s?(.*)$!', '$1', $error );
+
+ return $error;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param int $errno
+ * @param string $errstr
+ */
+ public function connectionErrorHandler( $errno, $errstr ) {
+ $this->mPHPError = $errstr;
+ }
+
+ /**
+ * Create a log context to pass to wfLogDBError or other logging functions.
+ *
+ * @param array $extras Additional data to add to context
+ * @return array
+ */
+ protected function getLogContext( array $extras = [] ) {
+ return array_merge(
+ [
+ 'db_server' => $this->mServer,
+ 'db_name' => $this->mDBname,
+ 'db_user' => $this->mUser,
+ ],
+ $extras
+ );
+ }
+
+ public function close() {
+ if ( count( $this->mTrxIdleCallbacks ) ) { // sanity
+ throw new MWException( "Transaction idle callbacks still pending." );
+ }
+ if ( $this->mConn ) {
+ if ( $this->trxLevel() ) {
+ if ( !$this->mTrxAutomatic ) {
+ wfWarn( "Transaction still in progress (from {$this->mTrxFname}), " .
+ " performing implicit commit before closing connection!" );
+ }
+
+ $this->commit( __METHOD__, 'flush' );
+ }
+
+ $closed = $this->closeConnection();
+ $this->mConn = false;
+ } else {
+ $closed = true;
+ }
+ $this->mOpened = false;
+
+ return $closed;
+ }
+
+ /**
+ * Make sure isOpen() returns true as a sanity check
+ *
+ * @throws DBUnexpectedError
+ */
+ protected function assertOpen() {
+ if ( !$this->isOpen() ) {
+ throw new DBUnexpectedError( $this, "DB connection was already closed." );
+ }
+ }
+
+ /**
+ * Closes underlying database connection
+ * @since 1.20
+ * @return bool Whether connection was closed successfully
+ */
+ abstract protected function closeConnection();
+
+ function reportConnectionError( $error = 'Unknown error' ) {
+ $myError = $this->lastError();
+ if ( $myError ) {
+ $error = $myError;
+ }
+
+ # New method
+ throw new DBConnectionError( $this, $error );
+ }
+
+ /**
+ * The DBMS-dependent part of query()
+ *
+ * @param string $sql SQL query.
+ * @return ResultWrapper|bool Result object to feed to fetchObject,
+ * fetchRow, ...; or false on failure
+ */
+ abstract protected function doQuery( $sql );
+
+ /**
+ * Determine whether a query writes to the DB.
+ * Should return true if unsure.
+ *
+ * @param string $sql
+ * @return bool
+ */
+ protected function isWriteQuery( $sql ) {
+ return !preg_match( '/^(?:SELECT|BEGIN|ROLLBACK|COMMIT|SET|SHOW|EXPLAIN|\(SELECT)\b/i', $sql );
+ }
+
+ /**
+ * Determine whether a SQL statement is sensitive to isolation level.
+ * A SQL statement is considered transactable if its result could vary
+ * depending on the transaction isolation level. Operational commands
+ * such as 'SET' and 'SHOW' are not considered to be transactable.
+ *
+ * @param string $sql
+ * @return bool
+ */
+ protected function isTransactableQuery( $sql ) {
+ $verb = substr( $sql, 0, strcspn( $sql, " \t\r\n" ) );
+ return !in_array( $verb, [ 'BEGIN', 'COMMIT', 'ROLLBACK', 'SHOW', 'SET' ] );
+ }
+
+ public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) {
+ global $wgUser;
+
+ $this->mLastQuery = $sql;
+
+ $isWriteQuery = $this->isWriteQuery( $sql );
+ if ( $isWriteQuery ) {
+ $reason = $this->getReadOnlyReason();
+ if ( $reason !== false ) {
+ throw new DBReadOnlyError( $this, "Database is read-only: $reason" );
+ }
+ # Set a flag indicating that writes have been done
+ $this->mDoneWrites = microtime( true );
+ }
+
+ # Add a comment for easy SHOW PROCESSLIST interpretation
+ if ( is_object( $wgUser ) && $wgUser->isItemLoaded( 'name' ) ) {
+ $userName = $wgUser->getName();
+ if ( mb_strlen( $userName ) > 15 ) {
+ $userName = mb_substr( $userName, 0, 15 ) . '...';
+ }
+ $userName = str_replace( '/', '', $userName );
+ } else {
+ $userName = '';
+ }
+
+ // Add trace comment to the begin of the sql string, right after the operator.
+ // Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (bug 42598)
+ $commentedSql = preg_replace( '/\s|$/', " /* $fname $userName */ ", $sql, 1 );
+
+ if ( !$this->mTrxLevel && $this->getFlag( DBO_TRX ) && $this->isTransactableQuery( $sql ) ) {
+ $this->begin( __METHOD__ . " ($fname)" );
+ $this->mTrxAutomatic = true;
+ }
+
+ # Keep track of whether the transaction has write queries pending
+ if ( $this->mTrxLevel && !$this->mTrxDoneWrites && $isWriteQuery ) {
+ $this->mTrxDoneWrites = true;
+ $this->getTransactionProfiler()->transactionWritingIn(
+ $this->mServer, $this->mDBname, $this->mTrxShortId );
+ }
+
+ $isMaster = !is_null( $this->getLBInfo( 'master' ) );
+ # generalizeSQL will probably cut down the query to reasonable
+ # logging size most of the time. The substr is really just a sanity check.
+ if ( $isMaster ) {
+ $queryProf = 'query-m: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
+ $totalProf = 'DatabaseBase::query-master';
+ } else {
+ $queryProf = 'query: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
+ $totalProf = 'DatabaseBase::query';
+ }
+ # Include query transaction state
+ $queryProf .= $this->mTrxShortId ? " [TRX#{$this->mTrxShortId}]" : "";
+
+ $profiler = Profiler::instance();
+ if ( !$profiler instanceof ProfilerStub ) {
+ $totalProfSection = $profiler->scopedProfileIn( $totalProf );
+ $queryProfSection = $profiler->scopedProfileIn( $queryProf );
+ }
+
+ if ( $this->debug() ) {
+ wfDebugLog( 'queries', sprintf( "%s: %s", $this->mDBname, $commentedSql ) );
+ }
+
+ $queryId = MWDebug::query( $sql, $fname, $isMaster );
+
+ # Avoid fatals if close() was called
+ $this->assertOpen();
+
+ # Do the query and handle errors
+ $startTime = microtime( true );
+ $ret = $this->doQuery( $commentedSql );
+ $queryRuntime = microtime( true ) - $startTime;
+ # Log the query time and feed it into the DB trx profiler
+ $this->getTransactionProfiler()->recordQueryCompletion(
+ $queryProf, $startTime, $isWriteQuery, $this->affectedRows() );
+
+ MWDebug::queryTime( $queryId );
+
+ # Try reconnecting if the connection was lost
+ if ( false === $ret && $this->wasErrorReissuable() ) {
+ # Transaction is gone; this can mean lost writes or REPEATABLE-READ snapshots
+ $hadTrx = $this->mTrxLevel;
+ # T127428: for non-write transactions, a disconnect and a COMMIT are similar:
+ # neither changed data and in both cases any read snapshots are reset anyway.
+ $isNoopCommit = ( !$this->writesOrCallbacksPending() && $sql === 'COMMIT' );
+ # Update state tracking to reflect transaction loss
+ $this->mTrxLevel = 0;
+ $this->mTrxIdleCallbacks = []; // bug 65263
+ $this->mTrxPreCommitCallbacks = []; // bug 65263
+ wfDebug( "Connection lost, reconnecting...\n" );
+ # Stash the last error values since ping() might clear them
+ $lastError = $this->lastError();
+ $lastErrno = $this->lastErrno();
+ if ( $this->ping() ) {
+ wfDebug( "Reconnected\n" );
+ $server = $this->getServer();
+ $msg = __METHOD__ . ": lost connection to $server; reconnected";
+ wfDebugLog( 'DBPerformance', "$msg:\n" . wfBacktrace( true ) );
+
+ if ( ( $hadTrx && !$isNoopCommit ) || $this->mNamedLocksHeld ) {
+ # Leave $ret as false and let an error be reported.
+ # Callers may catch the exception and continue to use the DB.
+ $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $tempIgnore );
+ } else {
+ # Should be safe to silently retry (no trx/callbacks/locks)
+ $startTime = microtime( true );
+ $ret = $this->doQuery( $commentedSql );
+ $queryRuntime = microtime( true ) - $startTime;
+ # Log the query time and feed it into the DB trx profiler
+ $this->getTransactionProfiler()->recordQueryCompletion(
+ $queryProf, $startTime, $isWriteQuery, $this->affectedRows() );
+ }
+ } else {
+ wfDebug( "Failed\n" );
+ }
+ }
+
+ if ( false === $ret ) {
+ $this->reportQueryError(
+ $this->lastError(), $this->lastErrno(), $sql, $fname, $tempIgnore );
+ }
+
+ $res = $this->resultObject( $ret );
+
+ // Destroy profile sections in the opposite order to their creation
+ ScopedCallback::consume( $queryProfSection );
+ ScopedCallback::consume( $totalProfSection );
+
+ if ( $isWriteQuery && $this->mTrxLevel ) {
+ $this->mTrxWriteDuration += $queryRuntime;
+ $this->mTrxWriteCallers[] = $fname;
+ }
+
+ return $res;
+ }
+
+ public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
+ if ( $this->ignoreErrors() || $tempIgnore ) {
+ wfDebug( "SQL ERROR (ignored): $error\n" );
+ } else {
+ $sql1line = mb_substr( str_replace( "\n", "\\n", $sql ), 0, 5 * 1024 );
+ wfLogDBError(
+ "{fname}\t{db_server}\t{errno}\t{error}\t{sql1line}",
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ 'errno' => $errno,
+ 'error' => $error,
+ 'sql1line' => $sql1line,
+ 'fname' => $fname,
+ ] )
+ );
+ wfDebug( "SQL ERROR: " . $error . "\n" );
+ throw new DBQueryError( $this, $error, $errno, $sql, $fname );
+ }
+ }
+
+ /**
+ * Intended to be compatible with the PEAR::DB wrapper functions.
+ * http://pear.php.net/manual/en/package.database.db.intro-execute.php
+ *
+ * ? = scalar value, quoted as necessary
+ * ! = raw SQL bit (a function for instance)
+ * & = filename; reads the file and inserts as a blob
+ * (we don't use this though...)
+ *
+ * @param string $sql
+ * @param string $func
+ *
+ * @return array
+ */
+ protected function prepare( $sql, $func = 'DatabaseBase::prepare' ) {
+ /* MySQL doesn't support prepared statements (yet), so just
+ * pack up the query for reference. We'll manually replace
+ * the bits later.
+ */
+ return [ 'query' => $sql, 'func' => $func ];
+ }
+
+ /**
+ * Free a prepared query, generated by prepare().
+ * @param string $prepared
+ */
+ protected function freePrepared( $prepared ) {
+ /* No-op by default */
+ }
+
+ /**
+ * Execute a prepared query with the various arguments
+ * @param string $prepared The prepared sql
+ * @param mixed $args Either an array here, or put scalars as varargs
+ *
+ * @return ResultWrapper
+ */
+ public function execute( $prepared, $args = null ) {
+ if ( !is_array( $args ) ) {
+ # Pull the var args
+ $args = func_get_args();
+ array_shift( $args );
+ }
+
+ $sql = $this->fillPrepared( $prepared['query'], $args );
+
+ return $this->query( $sql, $prepared['func'] );
+ }
+
+ /**
+ * For faking prepared SQL statements on DBs that don't support it directly.
+ *
+ * @param string $preparedQuery A 'preparable' SQL statement
+ * @param array $args Array of Arguments to fill it with
+ * @return string Executable SQL
+ */
+ public function fillPrepared( $preparedQuery, $args ) {
+ reset( $args );
+ $this->preparedArgs =& $args;
+
+ return preg_replace_callback( '/(\\\\[?!&]|[?!&])/',
+ [ &$this, 'fillPreparedArg' ], $preparedQuery );
+ }
+
+ /**
+ * preg_callback func for fillPrepared()
+ * The arguments should be in $this->preparedArgs and must not be touched
+ * while we're doing this.
+ *
+ * @param array $matches
+ * @throws DBUnexpectedError
+ * @return string
+ */
+ protected function fillPreparedArg( $matches ) {
+ switch ( $matches[1] ) {
+ case '\\?':
+ return '?';
+ case '\\!':
+ return '!';
+ case '\\&':
+ return '&';
+ }
+
+ list( /* $n */, $arg ) = each( $this->preparedArgs );
+
+ switch ( $matches[1] ) {
+ case '?':
+ return $this->addQuotes( $arg );
+ case '!':
+ return $arg;
+ case '&':
+ # return $this->addQuotes( file_get_contents( $arg ) );
+ throw new DBUnexpectedError(
+ $this,
+ '& mode is not implemented. If it\'s really needed, uncomment the line above.'
+ );
+ default:
+ throw new DBUnexpectedError(
+ $this,
+ 'Received invalid match. This should never happen!'
+ );
+ }
+ }
+
+ public function freeResult( $res ) {
+ }
+
+ public function selectField(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = []
+ ) {
+ if ( $var === '*' ) { // sanity
+ throw new DBUnexpectedError( $this, "Cannot use a * field: got '$var'" );
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $options['LIMIT'] = 1;
+
+ $res = $this->select( $table, $var, $cond, $fname, $options );
+ if ( $res === false || !$this->numRows( $res ) ) {
+ return false;
+ }
+
+ $row = $this->fetchRow( $res );
+
+ if ( $row !== false ) {
+ return reset( $row );
+ } else {
+ return false;
+ }
+ }
+
+ public function selectFieldValues(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ if ( $var === '*' ) { // sanity
+ throw new DBUnexpectedError( $this, "Cannot use a * field" );
+ } elseif ( !is_string( $var ) ) { // sanity
+ throw new DBUnexpectedError( $this, "Cannot use an array of fields" );
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $res = $this->select( $table, $var, $cond, $fname, $options, $join_conds );
+ if ( $res === false ) {
+ return false;
+ }
+
+ $values = [];
+ foreach ( $res as $row ) {
+ $values[] = $row->$var;
+ }
+
+ return $values;
+ }
+
+ /**
+ * Returns an optional USE INDEX clause to go after the table, and a
+ * string to go at the end of the query.
+ *
+ * @param array $options Associative array of options to be turned into
+ * an SQL query, valid keys are listed in the function.
+ * @return array
+ * @see DatabaseBase::select()
+ */
+ public function makeSelectOptions( $options ) {
+ $preLimitTail = $postLimitTail = '';
+ $startOpts = '';
+
+ $noKeyOptions = [];
+
+ foreach ( $options as $key => $option ) {
+ if ( is_numeric( $key ) ) {
+ $noKeyOptions[$option] = true;
+ }
+ }
+
+ $preLimitTail .= $this->makeGroupByWithHaving( $options );
+
+ $preLimitTail .= $this->makeOrderBy( $options );
+
+ // if (isset($options['LIMIT'])) {
+ // $tailOpts .= $this->limitResult('', $options['LIMIT'],
+ // isset($options['OFFSET']) ? $options['OFFSET']
+ // : false);
+ // }
+
+ if ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
+ $postLimitTail .= ' FOR UPDATE';
+ }
+
+ if ( isset( $noKeyOptions['LOCK IN SHARE MODE'] ) ) {
+ $postLimitTail .= ' LOCK IN SHARE MODE';
+ }
+
+ if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
+ $startOpts .= 'DISTINCT';
+ }
+
+ # Various MySQL extensions
+ if ( isset( $noKeyOptions['STRAIGHT_JOIN'] ) ) {
+ $startOpts .= ' /*! STRAIGHT_JOIN */';
+ }
+
+ if ( isset( $noKeyOptions['HIGH_PRIORITY'] ) ) {
+ $startOpts .= ' HIGH_PRIORITY';
+ }
+
+ if ( isset( $noKeyOptions['SQL_BIG_RESULT'] ) ) {
+ $startOpts .= ' SQL_BIG_RESULT';
+ }
+
+ if ( isset( $noKeyOptions['SQL_BUFFER_RESULT'] ) ) {
+ $startOpts .= ' SQL_BUFFER_RESULT';
+ }
+
+ if ( isset( $noKeyOptions['SQL_SMALL_RESULT'] ) ) {
+ $startOpts .= ' SQL_SMALL_RESULT';
+ }
+
+ if ( isset( $noKeyOptions['SQL_CALC_FOUND_ROWS'] ) ) {
+ $startOpts .= ' SQL_CALC_FOUND_ROWS';
+ }
+
+ if ( isset( $noKeyOptions['SQL_CACHE'] ) ) {
+ $startOpts .= ' SQL_CACHE';
+ }
+
+ if ( isset( $noKeyOptions['SQL_NO_CACHE'] ) ) {
+ $startOpts .= ' SQL_NO_CACHE';
+ }
+
+ if ( isset( $options['USE INDEX'] ) && is_string( $options['USE INDEX'] ) ) {
+ $useIndex = $this->useIndexClause( $options['USE INDEX'] );
+ } else {
+ $useIndex = '';
+ }
+
+ return [ $startOpts, $useIndex, $preLimitTail, $postLimitTail ];
+ }
+
+ /**
+ * Returns an optional GROUP BY with an optional HAVING
+ *
+ * @param array $options Associative array of options
+ * @return string
+ * @see DatabaseBase::select()
+ * @since 1.21
+ */
+ public function makeGroupByWithHaving( $options ) {
+ $sql = '';
+ if ( isset( $options['GROUP BY'] ) ) {
+ $gb = is_array( $options['GROUP BY'] )
+ ? implode( ',', $options['GROUP BY'] )
+ : $options['GROUP BY'];
+ $sql .= ' GROUP BY ' . $gb;
+ }
+ if ( isset( $options['HAVING'] ) ) {
+ $having = is_array( $options['HAVING'] )
+ ? $this->makeList( $options['HAVING'], LIST_AND )
+ : $options['HAVING'];
+ $sql .= ' HAVING ' . $having;
+ }
+
+ return $sql;
+ }
+
+ /**
+ * Returns an optional ORDER BY
+ *
+ * @param array $options Associative array of options
+ * @return string
+ * @see DatabaseBase::select()
+ * @since 1.21
+ */
+ public function makeOrderBy( $options ) {
+ if ( isset( $options['ORDER BY'] ) ) {
+ $ob = is_array( $options['ORDER BY'] )
+ ? implode( ',', $options['ORDER BY'] )
+ : $options['ORDER BY'];
+
+ return ' ORDER BY ' . $ob;
+ }
+
+ return '';
+ }
+
+ // See IDatabase::select for the docs for this function
+ public function select( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = [] ) {
+ $sql = $this->selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
+
+ return $this->query( $sql, $fname );
+ }
+
+ public function selectSQLText( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ if ( is_array( $vars ) ) {
+ $vars = implode( ',', $this->fieldNamesWithAlias( $vars ) );
+ }
+
+ $options = (array)$options;
+ $useIndexes = ( isset( $options['USE INDEX'] ) && is_array( $options['USE INDEX'] ) )
+ ? $options['USE INDEX']
+ : [];
+
+ if ( is_array( $table ) ) {
+ $from = ' FROM ' .
+ $this->tableNamesWithUseIndexOrJOIN( $table, $useIndexes, $join_conds );
+ } elseif ( $table != '' ) {
+ if ( $table[0] == ' ' ) {
+ $from = ' FROM ' . $table;
+ } else {
+ $from = ' FROM ' .
+ $this->tableNamesWithUseIndexOrJOIN( [ $table ], $useIndexes, [] );
+ }
+ } else {
+ $from = '';
+ }
+
+ list( $startOpts, $useIndex, $preLimitTail, $postLimitTail ) =
+ $this->makeSelectOptions( $options );
+
+ if ( !empty( $conds ) ) {
+ if ( is_array( $conds ) ) {
+ $conds = $this->makeList( $conds, LIST_AND );
+ }
+ $sql = "SELECT $startOpts $vars $from $useIndex WHERE $conds $preLimitTail";
+ } else {
+ $sql = "SELECT $startOpts $vars $from $useIndex $preLimitTail";
+ }
+
+ if ( isset( $options['LIMIT'] ) ) {
+ $sql = $this->limitResult( $sql, $options['LIMIT'],
+ isset( $options['OFFSET'] ) ? $options['OFFSET'] : false );
+ }
+ $sql = "$sql $postLimitTail";
+
+ if ( isset( $options['EXPLAIN'] ) ) {
+ $sql = 'EXPLAIN ' . $sql;
+ }
+
+ return $sql;
+ }
+
+ public function selectRow( $table, $vars, $conds, $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ $options = (array)$options;
+ $options['LIMIT'] = 1;
+ $res = $this->select( $table, $vars, $conds, $fname, $options, $join_conds );
+
+ if ( $res === false ) {
+ return false;
+ }
+
+ if ( !$this->numRows( $res ) ) {
+ return false;
+ }
+
+ $obj = $this->fetchObject( $res );
+
+ return $obj;
+ }
+
+ public function estimateRowCount(
+ $table, $vars = '*', $conds = '', $fname = __METHOD__, $options = []
+ ) {
+ $rows = 0;
+ $res = $this->select( $table, [ 'rowcount' => 'COUNT(*)' ], $conds, $fname, $options );
+
+ if ( $res ) {
+ $row = $this->fetchRow( $res );
+ $rows = ( isset( $row['rowcount'] ) ) ? (int)$row['rowcount'] : 0;
+ }
+
+ return $rows;
+ }
+
+ public function selectRowCount(
+ $tables, $vars = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ $rows = 0;
+ $sql = $this->selectSQLText( $tables, '1', $conds, $fname, $options, $join_conds );
+ // The identifier quotes is primarily for MSSQL.
+ $rowCountCol = $this->addIdentifierQuotes( "rowcount" );
+ $tableName = $this->addIdentifierQuotes( "tmp_count" );
+ $res = $this->query( "SELECT COUNT(*) AS $rowCountCol FROM ($sql) $tableName", $fname );
+
+ if ( $res ) {
+ $row = $this->fetchRow( $res );
+ $rows = ( isset( $row['rowcount'] ) ) ? (int)$row['rowcount'] : 0;
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Removes most variables from an SQL query and replaces them with X or N for numbers.
+ * It's only slightly flawed. Don't use for anything important.
+ *
+ * @param string $sql A SQL Query
+ *
+ * @return string
+ */
+ protected static function generalizeSQL( $sql ) {
+ # This does the same as the regexp below would do, but in such a way
+ # as to avoid crashing php on some large strings.
+ # $sql = preg_replace( "/'([^\\\\']|\\\\.)*'|\"([^\\\\\"]|\\\\.)*\"/", "'X'", $sql );
+
+ $sql = str_replace( "\\\\", '', $sql );
+ $sql = str_replace( "\\'", '', $sql );
+ $sql = str_replace( "\\\"", '', $sql );
+ $sql = preg_replace( "/'.*'/s", "'X'", $sql );
+ $sql = preg_replace( '/".*"/s', "'X'", $sql );
+
+ # All newlines, tabs, etc replaced by single space
+ $sql = preg_replace( '/\s+/', ' ', $sql );
+
+ # All numbers => N,
+ # except the ones surrounded by characters, e.g. l10n
+ $sql = preg_replace( '/-?\d+(,-?\d+)+/s', 'N,...,N', $sql );
+ $sql = preg_replace( '/(?<![a-zA-Z])-?\d+(?![a-zA-Z])/s', 'N', $sql );
+
+ return $sql;
+ }
+
+ public function fieldExists( $table, $field, $fname = __METHOD__ ) {
+ $info = $this->fieldInfo( $table, $field );
+
+ return (bool)$info;
+ }
+
+ public function indexExists( $table, $index, $fname = __METHOD__ ) {
+ if ( !$this->tableExists( $table ) ) {
+ return null;
+ }
+
+ $info = $this->indexInfo( $table, $index, $fname );
+ if ( is_null( $info ) ) {
+ return null;
+ } else {
+ return $info !== false;
+ }
+ }
+
+ public function tableExists( $table, $fname = __METHOD__ ) {
+ $table = $this->tableName( $table );
+ $old = $this->ignoreErrors( true );
+ $res = $this->query( "SELECT 1 FROM $table LIMIT 1", $fname );
+ $this->ignoreErrors( $old );
+
+ return (bool)$res;
+ }
+
+ public function indexUnique( $table, $index ) {
+ $indexInfo = $this->indexInfo( $table, $index );
+
+ if ( !$indexInfo ) {
+ return null;
+ }
+
+ return !$indexInfo[0]->Non_unique;
+ }
+
+ /**
+ * Helper for DatabaseBase::insert().
+ *
+ * @param array $options
+ * @return string
+ */
+ protected function makeInsertOptions( $options ) {
+ return implode( ' ', $options );
+ }
+
+ public function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
+ # No rows to insert, easy just return now
+ if ( !count( $a ) ) {
+ return true;
+ }
+
+ $table = $this->tableName( $table );
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $fh = null;
+ if ( isset( $options['fileHandle'] ) ) {
+ $fh = $options['fileHandle'];
+ }
+ $options = $this->makeInsertOptions( $options );
+
+ if ( isset( $a[0] ) && is_array( $a[0] ) ) {
+ $multi = true;
+ $keys = array_keys( $a[0] );
+ } else {
+ $multi = false;
+ $keys = array_keys( $a );
+ }
+
+ $sql = 'INSERT ' . $options .
+ " INTO $table (" . implode( ',', $keys ) . ') VALUES ';
+
+ if ( $multi ) {
+ $first = true;
+ foreach ( $a as $row ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $sql .= ',';
+ }
+ $sql .= '(' . $this->makeList( $row ) . ')';
+ }
+ } else {
+ $sql .= '(' . $this->makeList( $a ) . ')';
+ }
+
+ if ( $fh !== null && false === fwrite( $fh, $sql ) ) {
+ return false;
+ } elseif ( $fh !== null ) {
+ return true;
+ }
+
+ return (bool)$this->query( $sql, $fname );
+ }
+
+ /**
+ * Make UPDATE options array for DatabaseBase::makeUpdateOptions
+ *
+ * @param array $options
+ * @return array
+ */
+ protected function makeUpdateOptionsArray( $options ) {
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $opts = [];
+
+ if ( in_array( 'LOW_PRIORITY', $options ) ) {
+ $opts[] = $this->lowPriorityOption();
+ }
+
+ if ( in_array( 'IGNORE', $options ) ) {
+ $opts[] = 'IGNORE';
+ }
+
+ return $opts;
+ }
+
+ /**
+ * Make UPDATE options for the DatabaseBase::update function
+ *
+ * @param array $options The options passed to DatabaseBase::update
+ * @return string
+ */
+ protected function makeUpdateOptions( $options ) {
+ $opts = $this->makeUpdateOptionsArray( $options );
+
+ return implode( ' ', $opts );
+ }
+
+ function update( $table, $values, $conds, $fname = __METHOD__, $options = [] ) {
+ $table = $this->tableName( $table );
+ $opts = $this->makeUpdateOptions( $options );
+ $sql = "UPDATE $opts $table SET " . $this->makeList( $values, LIST_SET );
+
+ if ( $conds !== [] && $conds !== '*' ) {
+ $sql .= " WHERE " . $this->makeList( $conds, LIST_AND );
+ }
+
+ return $this->query( $sql, $fname );
+ }
+
+ public function makeList( $a, $mode = LIST_COMMA ) {
+ if ( !is_array( $a ) ) {
+ throw new DBUnexpectedError( $this, 'DatabaseBase::makeList called with incorrect parameters' );
+ }
+
+ $first = true;
+ $list = '';
+
+ foreach ( $a as $field => $value ) {
+ if ( !$first ) {
+ if ( $mode == LIST_AND ) {
+ $list .= ' AND ';
+ } elseif ( $mode == LIST_OR ) {
+ $list .= ' OR ';
+ } else {
+ $list .= ',';
+ }
+ } else {
+ $first = false;
+ }
+
+ if ( ( $mode == LIST_AND || $mode == LIST_OR ) && is_numeric( $field ) ) {
+ $list .= "($value)";
+ } elseif ( ( $mode == LIST_SET ) && is_numeric( $field ) ) {
+ $list .= "$value";
+ } elseif ( ( $mode == LIST_AND || $mode == LIST_OR ) && is_array( $value ) ) {
+ // Remove null from array to be handled separately if found
+ $includeNull = false;
+ foreach ( array_keys( $value, null, true ) as $nullKey ) {
+ $includeNull = true;
+ unset( $value[$nullKey] );
+ }
+ if ( count( $value ) == 0 && !$includeNull ) {
+ throw new MWException( __METHOD__ . ": empty input for field $field" );
+ } elseif ( count( $value ) == 0 ) {
+ // only check if $field is null
+ $list .= "$field IS NULL";
+ } else {
+ // IN clause contains at least one valid element
+ if ( $includeNull ) {
+ // Group subconditions to ensure correct precedence
+ $list .= '(';
+ }
+ if ( count( $value ) == 1 ) {
+ // Special-case single values, as IN isn't terribly efficient
+ // Don't necessarily assume the single key is 0; we don't
+ // enforce linear numeric ordering on other arrays here.
+ $value = array_values( $value )[0];
+ $list .= $field . " = " . $this->addQuotes( $value );
+ } else {
+ $list .= $field . " IN (" . $this->makeList( $value ) . ") ";
+ }
+ // if null present in array, append IS NULL
+ if ( $includeNull ) {
+ $list .= " OR $field IS NULL)";
+ }
+ }
+ } elseif ( $value === null ) {
+ if ( $mode == LIST_AND || $mode == LIST_OR ) {
+ $list .= "$field IS ";
+ } elseif ( $mode == LIST_SET ) {
+ $list .= "$field = ";
+ }
+ $list .= 'NULL';
+ } else {
+ if ( $mode == LIST_AND || $mode == LIST_OR || $mode == LIST_SET ) {
+ $list .= "$field = ";
+ }
+ $list .= $mode == LIST_NAMES ? $value : $this->addQuotes( $value );
+ }
+ }
+
+ return $list;
+ }
+
+ public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
+ $conds = [];
+
+ foreach ( $data as $base => $sub ) {
+ if ( count( $sub ) ) {
+ $conds[] = $this->makeList(
+ [ $baseKey => $base, $subKey => array_keys( $sub ) ],
+ LIST_AND );
+ }
+ }
+
+ if ( $conds ) {
+ return $this->makeList( $conds, LIST_OR );
+ } else {
+ // Nothing to search for...
+ return false;
+ }
+ }
+
+ /**
+ * Return aggregated value alias
+ *
+ * @param array $valuedata
+ * @param string $valuename
+ *
+ * @return string
+ */
+ public function aggregateValue( $valuedata, $valuename = 'value' ) {
+ return $valuename;
+ }
+
+ public function bitNot( $field ) {
+ return "(~$field)";
+ }
+
+ public function bitAnd( $fieldLeft, $fieldRight ) {
+ return "($fieldLeft & $fieldRight)";
+ }
+
+ public function bitOr( $fieldLeft, $fieldRight ) {
+ return "($fieldLeft | $fieldRight)";
+ }
+
+ public function buildConcat( $stringList ) {
+ return 'CONCAT(' . implode( ',', $stringList ) . ')';
+ }
+
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ ) {
+ $fld = "GROUP_CONCAT($field SEPARATOR " . $this->addQuotes( $delim ) . ')';
+
+ return '(' . $this->selectSQLText( $table, $fld, $conds, null, [], $join_conds ) . ')';
+ }
+
+ public function selectDB( $db ) {
+ # Stub. Shouldn't cause serious problems if it's not overridden, but
+ # if your database engine supports a concept similar to MySQL's
+ # databases you may as well.
+ $this->mDBname = $db;
+
+ return true;
+ }
+
+ public function getDBname() {
+ return $this->mDBname;
+ }
+
+ public function getServer() {
+ return $this->mServer;
+ }
+
+ /**
+ * Format a table name ready for use in constructing an SQL query
+ *
+ * This does two important things: it quotes the table names to clean them up,
+ * and it adds a table prefix if only given a table name with no quotes.
+ *
+ * All functions of this object which require a table name call this function
+ * themselves. Pass the canonical name to such functions. This is only needed
+ * when calling query() directly.
+ *
+ * @note This function does not sanitize user input. It is not safe to use
+ * this function to escape user input.
+ * @param string $name Database table name
+ * @param string $format One of:
+ * quoted - Automatically pass the table name through addIdentifierQuotes()
+ * so that it can be used in a query.
+ * raw - Do not add identifier quotes to the table name
+ * @return string Full database name
+ */
+ public function tableName( $name, $format = 'quoted' ) {
+ global $wgSharedDB, $wgSharedPrefix, $wgSharedTables, $wgSharedSchema;
+ # Skip the entire process when we have a string quoted on both ends.
+ # Note that we check the end so that we will still quote any use of
+ # use of `database`.table. But won't break things if someone wants
+ # to query a database table with a dot in the name.
+ if ( $this->isQuotedIdentifier( $name ) ) {
+ return $name;
+ }
+
+ # Lets test for any bits of text that should never show up in a table
+ # name. Basically anything like JOIN or ON which are actually part of
+ # SQL queries, but may end up inside of the table value to combine
+ # sql. Such as how the API is doing.
+ # Note that we use a whitespace test rather than a \b test to avoid
+ # any remote case where a word like on may be inside of a table name
+ # surrounded by symbols which may be considered word breaks.
+ if ( preg_match( '/(^|\s)(DISTINCT|JOIN|ON|AS)(\s|$)/i', $name ) !== 0 ) {
+ return $name;
+ }
+
+ # Split database and table into proper variables.
+ # We reverse the explode so that database.table and table both output
+ # the correct table.
+ $dbDetails = explode( '.', $name, 3 );
+ if ( count( $dbDetails ) == 3 ) {
+ list( $database, $schema, $table ) = $dbDetails;
+ # We don't want any prefix added in this case
+ $prefix = '';
+ } elseif ( count( $dbDetails ) == 2 ) {
+ list( $database, $table ) = $dbDetails;
+ # We don't want any prefix added in this case
+ # In dbs that support it, $database may actually be the schema
+ # but that doesn't affect any of the functionality here
+ $prefix = '';
+ $schema = null;
+ } else {
+ list( $table ) = $dbDetails;
+ if ( $wgSharedDB !== null # We have a shared database
+ && $this->mForeign == false # We're not working on a foreign database
+ && !$this->isQuotedIdentifier( $table ) # Prevent shared tables listing '`table`'
+ && in_array( $table, $wgSharedTables ) # A shared table is selected
+ ) {
+ $database = $wgSharedDB;
+ $schema = $wgSharedSchema === null ? $this->mSchema : $wgSharedSchema;
+ $prefix = $wgSharedPrefix === null ? $this->mTablePrefix : $wgSharedPrefix;
+ } else {
+ $database = null;
+ $schema = $this->mSchema; # Default schema
+ $prefix = $this->mTablePrefix; # Default prefix
+ }
+ }
+
+ # Quote $table and apply the prefix if not quoted.
+ # $tableName might be empty if this is called from Database::replaceVars()
+ $tableName = "{$prefix}{$table}";
+ if ( $format == 'quoted' && !$this->isQuotedIdentifier( $tableName ) && $tableName !== '' ) {
+ $tableName = $this->addIdentifierQuotes( $tableName );
+ }
+
+ # Quote $schema and merge it with the table name if needed
+ if ( strlen( $schema ) ) {
+ if ( $format == 'quoted' && !$this->isQuotedIdentifier( $schema ) ) {
+ $schema = $this->addIdentifierQuotes( $schema );
+ }
+ $tableName = $schema . '.' . $tableName;
+ }
+
+ # Quote $database and merge it with the table name if needed
+ if ( $database !== null ) {
+ if ( $format == 'quoted' && !$this->isQuotedIdentifier( $database ) ) {
+ $database = $this->addIdentifierQuotes( $database );
+ }
+ $tableName = $database . '.' . $tableName;
+ }
+
+ return $tableName;
+ }
+
+ /**
+ * Fetch a number of table names into an array
+ * This is handy when you need to construct SQL for joins
+ *
+ * Example:
+ * extract( $dbr->tableNames( 'user', 'watchlist' ) );
+ * $sql = "SELECT wl_namespace,wl_title FROM $watchlist,$user
+ * WHERE wl_user=user_id AND wl_user=$nameWithQuotes";
+ *
+ * @return array
+ */
+ public function tableNames() {
+ $inArray = func_get_args();
+ $retVal = [];
+
+ foreach ( $inArray as $name ) {
+ $retVal[$name] = $this->tableName( $name );
+ }
+
+ return $retVal;
+ }
+
+ /**
+ * Fetch a number of table names into an zero-indexed numerical array
+ * This is handy when you need to construct SQL for joins
+ *
+ * Example:
+ * list( $user, $watchlist ) = $dbr->tableNamesN( 'user', 'watchlist' );
+ * $sql = "SELECT wl_namespace,wl_title FROM $watchlist,$user
+ * WHERE wl_user=user_id AND wl_user=$nameWithQuotes";
+ *
+ * @return array
+ */
+ public function tableNamesN() {
+ $inArray = func_get_args();
+ $retVal = [];
+
+ foreach ( $inArray as $name ) {
+ $retVal[] = $this->tableName( $name );
+ }
+
+ return $retVal;
+ }
+
+ /**
+ * Get an aliased table name
+ * e.g. tableName AS newTableName
+ *
+ * @param string $name Table name, see tableName()
+ * @param string|bool $alias Alias (optional)
+ * @return string SQL name for aliased table. Will not alias a table to its own name
+ */
+ public function tableNameWithAlias( $name, $alias = false ) {
+ if ( !$alias || $alias == $name ) {
+ return $this->tableName( $name );
+ } else {
+ return $this->tableName( $name ) . ' ' . $this->addIdentifierQuotes( $alias );
+ }
+ }
+
+ /**
+ * Gets an array of aliased table names
+ *
+ * @param array $tables Array( [alias] => table )
+ * @return string[] See tableNameWithAlias()
+ */
+ public function tableNamesWithAlias( $tables ) {
+ $retval = [];
+ foreach ( $tables as $alias => $table ) {
+ if ( is_numeric( $alias ) ) {
+ $alias = $table;
+ }
+ $retval[] = $this->tableNameWithAlias( $table, $alias );
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Get an aliased field name
+ * e.g. fieldName AS newFieldName
+ *
+ * @param string $name Field name
+ * @param string|bool $alias Alias (optional)
+ * @return string SQL name for aliased field. Will not alias a field to its own name
+ */
+ public function fieldNameWithAlias( $name, $alias = false ) {
+ if ( !$alias || (string)$alias === (string)$name ) {
+ return $name;
+ } else {
+ return $name . ' AS ' . $this->addIdentifierQuotes( $alias ); // PostgreSQL needs AS
+ }
+ }
+
+ /**
+ * Gets an array of aliased field names
+ *
+ * @param array $fields Array( [alias] => field )
+ * @return string[] See fieldNameWithAlias()
+ */
+ public function fieldNamesWithAlias( $fields ) {
+ $retval = [];
+ foreach ( $fields as $alias => $field ) {
+ if ( is_numeric( $alias ) ) {
+ $alias = $field;
+ }
+ $retval[] = $this->fieldNameWithAlias( $field, $alias );
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Get the aliased table name clause for a FROM clause
+ * which might have a JOIN and/or USE INDEX clause
+ *
+ * @param array $tables ( [alias] => table )
+ * @param array $use_index Same as for select()
+ * @param array $join_conds Same as for select()
+ * @return string
+ */
+ protected function tableNamesWithUseIndexOrJOIN(
+ $tables, $use_index = [], $join_conds = []
+ ) {
+ $ret = [];
+ $retJOIN = [];
+ $use_index = (array)$use_index;
+ $join_conds = (array)$join_conds;
+
+ foreach ( $tables as $alias => $table ) {
+ if ( !is_string( $alias ) ) {
+ // No alias? Set it equal to the table name
+ $alias = $table;
+ }
+ // Is there a JOIN clause for this table?
+ if ( isset( $join_conds[$alias] ) ) {
+ list( $joinType, $conds ) = $join_conds[$alias];
+ $tableClause = $joinType;
+ $tableClause .= ' ' . $this->tableNameWithAlias( $table, $alias );
+ if ( isset( $use_index[$alias] ) ) { // has USE INDEX?
+ $use = $this->useIndexClause( implode( ',', (array)$use_index[$alias] ) );
+ if ( $use != '' ) {
+ $tableClause .= ' ' . $use;
+ }
+ }
+ $on = $this->makeList( (array)$conds, LIST_AND );
+ if ( $on != '' ) {
+ $tableClause .= ' ON (' . $on . ')';
+ }
+
+ $retJOIN[] = $tableClause;
+ } elseif ( isset( $use_index[$alias] ) ) {
+ // Is there an INDEX clause for this table?
+ $tableClause = $this->tableNameWithAlias( $table, $alias );
+ $tableClause .= ' ' . $this->useIndexClause(
+ implode( ',', (array)$use_index[$alias] )
+ );
+
+ $ret[] = $tableClause;
+ } else {
+ $tableClause = $this->tableNameWithAlias( $table, $alias );
+
+ $ret[] = $tableClause;
+ }
+ }
+
+ // We can't separate explicit JOIN clauses with ',', use ' ' for those
+ $implicitJoins = !empty( $ret ) ? implode( ',', $ret ) : "";
+ $explicitJoins = !empty( $retJOIN ) ? implode( ' ', $retJOIN ) : "";
+
+ // Compile our final table clause
+ return implode( ' ', [ $implicitJoins, $explicitJoins ] );
+ }
+
+ /**
+ * Get the name of an index in a given table.
+ *
+ * @param string $index
+ * @return string
+ */
+ protected function indexName( $index ) {
+ // Backwards-compatibility hack
+ $renamed = [
+ 'ar_usertext_timestamp' => 'usertext_timestamp',
+ 'un_user_id' => 'user_id',
+ 'un_user_ip' => 'user_ip',
+ ];
+
+ if ( isset( $renamed[$index] ) ) {
+ return $renamed[$index];
+ } else {
+ return $index;
+ }
+ }
+
+ public function addQuotes( $s ) {
+ if ( $s instanceof Blob ) {
+ $s = $s->fetch();
+ }
+ if ( $s === null ) {
+ return 'NULL';
+ } else {
+ # This will also quote numeric values. This should be harmless,
+ # and protects against weird problems that occur when they really
+ # _are_ strings such as article titles and string->number->string
+ # conversion is not 1:1.
+ return "'" . $this->strencode( $s ) . "'";
+ }
+ }
+
+ /**
+ * Quotes an identifier using `backticks` or "double quotes" depending on the database type.
+ * MySQL uses `backticks` while basically everything else uses double quotes.
+ * Since MySQL is the odd one out here the double quotes are our generic
+ * and we implement backticks in DatabaseMysql.
+ *
+ * @param string $s
+ * @return string
+ */
+ public function addIdentifierQuotes( $s ) {
+ return '"' . str_replace( '"', '""', $s ) . '"';
+ }
+
+ /**
+ * Returns if the given identifier looks quoted or not according to
+ * the database convention for quoting identifiers .
+ *
+ * @note Do not use this to determine if untrusted input is safe.
+ * A malicious user can trick this function.
+ * @param string $name
+ * @return bool
+ */
+ public function isQuotedIdentifier( $name ) {
+ return $name[0] == '"' && substr( $name, -1, 1 ) == '"';
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ protected function escapeLikeInternal( $s ) {
+ return addcslashes( $s, '\%_' );
+ }
+
+ public function buildLike() {
+ $params = func_get_args();
+
+ if ( count( $params ) > 0 && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+
+ $s = '';
+
+ foreach ( $params as $value ) {
+ if ( $value instanceof LikeMatch ) {
+ $s .= $value->toString();
+ } else {
+ $s .= $this->escapeLikeInternal( $value );
+ }
+ }
+
+ return " LIKE {$this->addQuotes( $s )} ";
+ }
+
+ public function anyChar() {
+ return new LikeMatch( '_' );
+ }
+
+ public function anyString() {
+ return new LikeMatch( '%' );
+ }
+
+ public function nextSequenceValue( $seqName ) {
+ return null;
+ }
+
+ /**
+ * USE INDEX clause. Unlikely to be useful for anything but MySQL. This
+ * is only needed because a) MySQL must be as efficient as possible due to
+ * its use on Wikipedia, and b) MySQL 4.0 is kind of dumb sometimes about
+ * which index to pick. Anyway, other databases might have different
+ * indexes on a given table. So don't bother overriding this unless you're
+ * MySQL.
+ * @param string $index
+ * @return string
+ */
+ public function useIndexClause( $index ) {
+ return '';
+ }
+
+ public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+ $quotedTable = $this->tableName( $table );
+
+ if ( count( $rows ) == 0 ) {
+ return;
+ }
+
+ # Single row case
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ // @FXIME: this is not atomic, but a trx would break affectedRows()
+ foreach ( $rows as $row ) {
+ # Delete rows which collide
+ if ( $uniqueIndexes ) {
+ $sql = "DELETE FROM $quotedTable WHERE ";
+ $first = true;
+ foreach ( $uniqueIndexes as $index ) {
+ if ( $first ) {
+ $first = false;
+ $sql .= '( ';
+ } else {
+ $sql .= ' ) OR ( ';
+ }
+ if ( is_array( $index ) ) {
+ $first2 = true;
+ foreach ( $index as $col ) {
+ if ( $first2 ) {
+ $first2 = false;
+ } else {
+ $sql .= ' AND ';
+ }
+ $sql .= $col . '=' . $this->addQuotes( $row[$col] );
+ }
+ } else {
+ $sql .= $index . '=' . $this->addQuotes( $row[$index] );
+ }
+ }
+ $sql .= ' )';
+ $this->query( $sql, $fname );
+ }
+
+ # Now insert the row
+ $this->insert( $table, $row, $fname );
+ }
+ }
+
+ /**
+ * REPLACE query wrapper for MySQL and SQLite, which have a native REPLACE
+ * statement.
+ *
+ * @param string $table Table name
+ * @param array|string $rows Row(s) to insert
+ * @param string $fname Caller function name
+ *
+ * @return ResultWrapper
+ */
+ protected function nativeReplace( $table, $rows, $fname ) {
+ $table = $this->tableName( $table );
+
+ # Single row case
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ $sql = "REPLACE INTO $table (" . implode( ',', array_keys( $rows[0] ) ) . ') VALUES ';
+ $first = true;
+
+ foreach ( $rows as $row ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $sql .= ',';
+ }
+
+ $sql .= '(' . $this->makeList( $row ) . ')';
+ }
+
+ return $this->query( $sql, $fname );
+ }
+
+ public function upsert( $table, array $rows, array $uniqueIndexes, array $set,
+ $fname = __METHOD__
+ ) {
+ if ( !count( $rows ) ) {
+ return true; // nothing to do
+ }
+
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ if ( count( $uniqueIndexes ) ) {
+ $clauses = []; // list WHERE clauses that each identify a single row
+ foreach ( $rows as $row ) {
+ foreach ( $uniqueIndexes as $index ) {
+ $index = is_array( $index ) ? $index : [ $index ]; // columns
+ $rowKey = []; // unique key to this row
+ foreach ( $index as $column ) {
+ $rowKey[$column] = $row[$column];
+ }
+ $clauses[] = $this->makeList( $rowKey, LIST_AND );
+ }
+ }
+ $where = [ $this->makeList( $clauses, LIST_OR ) ];
+ } else {
+ $where = false;
+ }
+
+ $useTrx = !$this->mTrxLevel;
+ if ( $useTrx ) {
+ $this->begin( $fname );
+ }
+ try {
+ # Update any existing conflicting row(s)
+ if ( $where !== false ) {
+ $ok = $this->update( $table, $set, $where, $fname );
+ } else {
+ $ok = true;
+ }
+ # Now insert any non-conflicting row(s)
+ $ok = $this->insert( $table, $rows, $fname, [ 'IGNORE' ] ) && $ok;
+ } catch ( Exception $e ) {
+ if ( $useTrx ) {
+ $this->rollback( $fname );
+ }
+ throw $e;
+ }
+ if ( $useTrx ) {
+ $this->commit( $fname );
+ }
+
+ return $ok;
+ }
+
+ public function deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds,
+ $fname = __METHOD__
+ ) {
+ if ( !$conds ) {
+ throw new DBUnexpectedError( $this,
+ 'DatabaseBase::deleteJoin() called with empty $conds' );
+ }
+
+ $delTable = $this->tableName( $delTable );
+ $joinTable = $this->tableName( $joinTable );
+ $sql = "DELETE FROM $delTable WHERE $delVar IN (SELECT $joinVar FROM $joinTable ";
+ if ( $conds != '*' ) {
+ $sql .= 'WHERE ' . $this->makeList( $conds, LIST_AND );
+ }
+ $sql .= ')';
+
+ $this->query( $sql, $fname );
+ }
+
+ /**
+ * Returns the size of a text field, or -1 for "unlimited"
+ *
+ * @param string $table
+ * @param string $field
+ * @return int
+ */
+ public function textFieldSize( $table, $field ) {
+ $table = $this->tableName( $table );
+ $sql = "SHOW COLUMNS FROM $table LIKE \"$field\";";
+ $res = $this->query( $sql, 'DatabaseBase::textFieldSize' );
+ $row = $this->fetchObject( $res );
+
+ $m = [];
+
+ if ( preg_match( '/\((.*)\)/', $row->Type, $m ) ) {
+ $size = $m[1];
+ } else {
+ $size = -1;
+ }
+
+ return $size;
+ }
+
+ /**
+ * A string to insert into queries to show that they're low-priority, like
+ * MySQL's LOW_PRIORITY. If no such feature exists, return an empty
+ * string and nothing bad should happen.
+ *
+ * @return string Returns the text of the low priority option if it is
+ * supported, or a blank string otherwise
+ */
+ public function lowPriorityOption() {
+ return '';
+ }
+
+ public function delete( $table, $conds, $fname = __METHOD__ ) {
+ if ( !$conds ) {
+ throw new DBUnexpectedError( $this, 'DatabaseBase::delete() called with no conditions' );
+ }
+
+ $table = $this->tableName( $table );
+ $sql = "DELETE FROM $table";
+
+ if ( $conds != '*' ) {
+ if ( is_array( $conds ) ) {
+ $conds = $this->makeList( $conds, LIST_AND );
+ }
+ $sql .= ' WHERE ' . $conds;
+ }
+
+ return $this->query( $sql, $fname );
+ }
+
+ public function insertSelect( $destTable, $srcTable, $varMap, $conds,
+ $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = []
+ ) {
+ $destTable = $this->tableName( $destTable );
+
+ if ( !is_array( $insertOptions ) ) {
+ $insertOptions = [ $insertOptions ];
+ }
+
+ $insertOptions = $this->makeInsertOptions( $insertOptions );
+
+ if ( !is_array( $selectOptions ) ) {
+ $selectOptions = [ $selectOptions ];
+ }
+
+ list( $startOpts, $useIndex, $tailOpts ) = $this->makeSelectOptions( $selectOptions );
+
+ if ( is_array( $srcTable ) ) {
+ $srcTable = implode( ',', array_map( [ $this, 'tableName' ], $srcTable ) );
+ } else {
+ $srcTable = $this->tableName( $srcTable );
+ }
+
+ $sql = "INSERT $insertOptions INTO $destTable (" . implode( ',', array_keys( $varMap ) ) . ')' .
+ " SELECT $startOpts " . implode( ',', $varMap ) .
+ " FROM $srcTable $useIndex ";
+
+ if ( $conds != '*' ) {
+ if ( is_array( $conds ) ) {
+ $conds = $this->makeList( $conds, LIST_AND );
+ }
+ $sql .= " WHERE $conds";
+ }
+
+ $sql .= " $tailOpts";
+
+ return $this->query( $sql, $fname );
+ }
+
+ /**
+ * Construct a LIMIT query with optional offset. This is used for query
+ * pages. The SQL should be adjusted so that only the first $limit rows
+ * are returned. If $offset is provided as well, then the first $offset
+ * rows should be discarded, and the next $limit rows should be returned.
+ * If the result of the query is not ordered, then the rows to be returned
+ * are theoretically arbitrary.
+ *
+ * $sql is expected to be a SELECT, if that makes a difference.
+ *
+ * The version provided by default works in MySQL and SQLite. It will very
+ * likely need to be overridden for most other DBMSes.
+ *
+ * @param string $sql SQL query we will append the limit too
+ * @param int $limit The SQL limit
+ * @param int|bool $offset The SQL offset (default false)
+ * @throws DBUnexpectedError
+ * @return string
+ */
+ public function limitResult( $sql, $limit, $offset = false ) {
+ if ( !is_numeric( $limit ) ) {
+ throw new DBUnexpectedError( $this, "Invalid non-numeric limit passed to limitResult()\n" );
+ }
+
+ return "$sql LIMIT "
+ . ( ( is_numeric( $offset ) && $offset != 0 ) ? "{$offset}," : "" )
+ . "{$limit} ";
+ }
+
+ public function unionSupportsOrderAndLimit() {
+ return true; // True for almost every DB supported
+ }
+
+ public function unionQueries( $sqls, $all ) {
+ $glue = $all ? ') UNION ALL (' : ') UNION (';
+
+ return '(' . implode( $glue, $sqls ) . ')';
+ }
+
+ public function conditional( $cond, $trueVal, $falseVal ) {
+ if ( is_array( $cond ) ) {
+ $cond = $this->makeList( $cond, LIST_AND );
+ }
+
+ return " (CASE WHEN $cond THEN $trueVal ELSE $falseVal END) ";
+ }
+
+ public function strreplace( $orig, $old, $new ) {
+ return "REPLACE({$orig}, {$old}, {$new})";
+ }
+
+ public function getServerUptime() {
+ return 0;
+ }
+
+ public function wasDeadlock() {
+ return false;
+ }
+
+ public function wasLockTimeout() {
+ return false;
+ }
+
+ public function wasErrorReissuable() {
+ return false;
+ }
+
+ public function wasReadOnlyError() {
+ return false;
+ }
+
+ /**
+ * Determines if the given query error was a connection drop
+ * STUB
+ *
+ * @param integer|string $errno
+ * @return bool
+ */
+ public function wasConnectionError( $errno ) {
+ return false;
+ }
+
+ /**
+ * Perform a deadlock-prone transaction.
+ *
+ * This function invokes a callback function to perform a set of write
+ * queries. If a deadlock occurs during the processing, the transaction
+ * will be rolled back and the callback function will be called again.
+ *
+ * Usage:
+ * $dbw->deadlockLoop( callback, ... );
+ *
+ * Extra arguments are passed through to the specified callback function.
+ *
+ * Returns whatever the callback function returned on its successful,
+ * iteration, or false on error, for example if the retry limit was
+ * reached.
+ * @return mixed
+ * @throws DBUnexpectedError
+ * @throws Exception
+ */
+ public function deadlockLoop() {
+ $args = func_get_args();
+ $function = array_shift( $args );
+ $tries = self::DEADLOCK_TRIES;
+
+ $this->begin( __METHOD__ );
+
+ $retVal = null;
+ /** @var Exception $e */
+ $e = null;
+ do {
+ try {
+ $retVal = call_user_func_array( $function, $args );
+ break;
+ } catch ( DBQueryError $e ) {
+ if ( $this->wasDeadlock() ) {
+ // Retry after a randomized delay
+ usleep( mt_rand( self::DEADLOCK_DELAY_MIN, self::DEADLOCK_DELAY_MAX ) );
+ } else {
+ // Throw the error back up
+ throw $e;
+ }
+ }
+ } while ( --$tries > 0 );
+
+ if ( $tries <= 0 ) {
+ // Too many deadlocks; give up
+ $this->rollback( __METHOD__ );
+ throw $e;
+ } else {
+ $this->commit( __METHOD__ );
+
+ return $retVal;
+ }
+ }
+
+ public function masterPosWait( DBMasterPos $pos, $timeout ) {
+ # Real waits are implemented in the subclass.
+ return 0;
+ }
+
+ public function getSlavePos() {
+ # Stub
+ return false;
+ }
+
+ public function getMasterPos() {
+ # Stub
+ return false;
+ }
+
+ final public function onTransactionIdle( $callback ) {
+ $this->mTrxIdleCallbacks[] = [ $callback, wfGetCaller() ];
+ if ( !$this->mTrxLevel ) {
+ $this->runOnTransactionIdleCallbacks();
+ }
+ }
+
+ final public function onTransactionPreCommitOrIdle( $callback ) {
+ if ( $this->mTrxLevel ) {
+ $this->mTrxPreCommitCallbacks[] = [ $callback, wfGetCaller() ];
+ } else {
+ $this->onTransactionIdle( $callback ); // this will trigger immediately
+ }
+ }
+
+ /**
+ * Actually any "on transaction idle" callbacks.
+ *
+ * @since 1.20
+ */
+ protected function runOnTransactionIdleCallbacks() {
+ $autoTrx = $this->getFlag( DBO_TRX ); // automatic begin() enabled?
+
+ $e = $ePrior = null; // last exception
+ do { // callbacks may add callbacks :)
+ $callbacks = $this->mTrxIdleCallbacks;
+ $this->mTrxIdleCallbacks = []; // recursion guard
+ foreach ( $callbacks as $callback ) {
+ try {
+ list( $phpCallback ) = $callback;
+ $this->clearFlag( DBO_TRX ); // make each query its own transaction
+ call_user_func( $phpCallback );
+ if ( $autoTrx ) {
+ $this->setFlag( DBO_TRX ); // restore automatic begin()
+ } else {
+ $this->clearFlag( DBO_TRX ); // restore auto-commit
+ }
+ } catch ( Exception $e ) {
+ if ( $ePrior ) {
+ MWExceptionHandler::logException( $ePrior );
+ }
+ $ePrior = $e;
+ // Some callbacks may use startAtomic/endAtomic, so make sure
+ // their transactions are ended so other callbacks don't fail
+ if ( $this->trxLevel() ) {
+ $this->rollback( __METHOD__ );
+ }
+ }
+ }
+ } while ( count( $this->mTrxIdleCallbacks ) );
+
+ if ( $e instanceof Exception ) {
+ throw $e; // re-throw any last exception
+ }
+ }
+
+ /**
+ * Actually any "on transaction pre-commit" callbacks.
+ *
+ * @since 1.22
+ */
+ protected function runOnTransactionPreCommitCallbacks() {
+ $e = $ePrior = null; // last exception
+ do { // callbacks may add callbacks :)
+ $callbacks = $this->mTrxPreCommitCallbacks;
+ $this->mTrxPreCommitCallbacks = []; // recursion guard
+ foreach ( $callbacks as $callback ) {
+ try {
+ list( $phpCallback ) = $callback;
+ call_user_func( $phpCallback );
+ } catch ( Exception $e ) {
+ if ( $ePrior ) {
+ MWExceptionHandler::logException( $ePrior );
+ }
+ $ePrior = $e;
+ }
+ }
+ } while ( count( $this->mTrxPreCommitCallbacks ) );
+
+ if ( $e instanceof Exception ) {
+ throw $e; // re-throw any last exception
+ }
+ }
+
+ final public function startAtomic( $fname = __METHOD__ ) {
+ if ( !$this->mTrxLevel ) {
+ $this->begin( $fname );
+ $this->mTrxAutomatic = true;
+ // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result
+ // in all changes being in one transaction to keep requests transactional.
+ if ( !$this->getFlag( DBO_TRX ) ) {
+ $this->mTrxAutomaticAtomic = true;
+ }
+ }
+
+ $this->mTrxAtomicLevels[] = $fname;
+ }
+
+ final public function endAtomic( $fname = __METHOD__ ) {
+ if ( !$this->mTrxLevel ) {
+ throw new DBUnexpectedError( $this, 'No atomic transaction is open.' );
+ }
+ if ( !$this->mTrxAtomicLevels ||
+ array_pop( $this->mTrxAtomicLevels ) !== $fname
+ ) {
+ throw new DBUnexpectedError( $this, 'Invalid atomic section ended.' );
+ }
+
+ if ( !$this->mTrxAtomicLevels && $this->mTrxAutomaticAtomic ) {
+ $this->commit( $fname, 'flush' );
+ }
+ }
+
+ final public function doAtomicSection( $fname, $callback ) {
+ if ( !is_callable( $callback ) ) {
+ throw new UnexpectedValueException( "Invalid callback." );
+ };
+
+ $this->startAtomic( $fname );
+ try {
+ call_user_func_array( $callback, [ $this, $fname ] );
+ } catch ( Exception $e ) {
+ $this->rollback( $fname );
+ throw $e;
+ }
+ $this->endAtomic( $fname );
+ }
+
+ final public function begin( $fname = __METHOD__ ) {
+ if ( $this->mTrxLevel ) { // implicit commit
+ if ( $this->mTrxAtomicLevels ) {
+ // If the current transaction was an automatic atomic one, then we definitely have
+ // a problem. Same if there is any unclosed atomic level.
+ $levels = implode( ', ', $this->mTrxAtomicLevels );
+ throw new DBUnexpectedError(
+ $this,
+ "Got explicit BEGIN from $fname while atomic section(s) $levels are open."
+ );
+ } elseif ( !$this->mTrxAutomatic ) {
+ // We want to warn about inadvertently nested begin/commit pairs, but not about
+ // auto-committing implicit transactions that were started by query() via DBO_TRX
+ $msg = "$fname: Transaction already in progress (from {$this->mTrxFname}), " .
+ " performing implicit commit!";
+ wfWarn( $msg );
+ wfLogDBError( $msg,
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ 'fname' => $fname,
+ ] )
+ );
+ } else {
+ // if the transaction was automatic and has done write operations
+ if ( $this->mTrxDoneWrites ) {
+ wfDebug( "$fname: Automatic transaction with writes in progress" .
+ " (from {$this->mTrxFname}), performing implicit commit!\n"
+ );
+ }
+ }
+
+ $this->runOnTransactionPreCommitCallbacks();
+ $writeTime = $this->pendingWriteQueryDuration();
+ $this->doCommit( $fname );
+ if ( $this->mTrxDoneWrites ) {
+ $this->mDoneWrites = microtime( true );
+ $this->getTransactionProfiler()->transactionWritingOut(
+ $this->mServer, $this->mDBname, $this->mTrxShortId, $writeTime );
+ }
+ $this->runOnTransactionIdleCallbacks();
+ }
+
+ # Avoid fatals if close() was called
+ $this->assertOpen();
+
+ $this->doBegin( $fname );
+ $this->mTrxTimestamp = microtime( true );
+ $this->mTrxFname = $fname;
+ $this->mTrxDoneWrites = false;
+ $this->mTrxAutomatic = false;
+ $this->mTrxAutomaticAtomic = false;
+ $this->mTrxAtomicLevels = [];
+ $this->mTrxIdleCallbacks = [];
+ $this->mTrxPreCommitCallbacks = [];
+ $this->mTrxShortId = wfRandomString( 12 );
+ $this->mTrxWriteDuration = 0.0;
+ $this->mTrxWriteCallers = [];
+ // First SELECT after BEGIN will establish the snapshot in REPEATABLE-READ.
+ // Get an estimate of the slave lag before then, treating estimate staleness
+ // as lag itself just to be safe
+ $status = $this->getApproximateLagStatus();
+ $this->mTrxSlaveLag = $status['lag'] + ( microtime( true ) - $status['since'] );
+ }
+
+ /**
+ * Issues the BEGIN command to the database server.
+ *
+ * @see DatabaseBase::begin()
+ * @param string $fname
+ */
+ protected function doBegin( $fname ) {
+ $this->query( 'BEGIN', $fname );
+ $this->mTrxLevel = 1;
+ }
+
+ final public function commit( $fname = __METHOD__, $flush = '' ) {
+ if ( $this->mTrxLevel && $this->mTrxAtomicLevels ) {
+ // There are still atomic sections open. This cannot be ignored
+ $levels = implode( ', ', $this->mTrxAtomicLevels );
+ throw new DBUnexpectedError(
+ $this,
+ "Got COMMIT while atomic sections $levels are still open"
+ );
+ }
+
+ if ( $flush === 'flush' ) {
+ if ( !$this->mTrxLevel ) {
+ return; // nothing to do
+ } elseif ( !$this->mTrxAutomatic ) {
+ throw new DBUnexpectedError(
+ $this,
+ "$fname: Flushing an explicit transaction, getting out of sync!"
+ );
+ }
+ } else {
+ if ( !$this->mTrxLevel ) {
+ wfWarn( "$fname: No transaction to commit, something got out of sync!" );
+ return; // nothing to do
+ } elseif ( $this->mTrxAutomatic ) {
+ wfWarn( "$fname: Explicit commit of implicit transaction. Something may be out of sync!" );
+ }
+ }
+
+ # Avoid fatals if close() was called
+ $this->assertOpen();
+
+ $this->runOnTransactionPreCommitCallbacks();
+ $writeTime = $this->pendingWriteQueryDuration();
+ $this->doCommit( $fname );
+ if ( $this->mTrxDoneWrites ) {
+ $this->mDoneWrites = microtime( true );
+ $this->getTransactionProfiler()->transactionWritingOut(
+ $this->mServer, $this->mDBname, $this->mTrxShortId, $writeTime );
+ }
+ $this->runOnTransactionIdleCallbacks();
+ }
+
+ /**
+ * Issues the COMMIT command to the database server.
+ *
+ * @see DatabaseBase::commit()
+ * @param string $fname
+ */
+ protected function doCommit( $fname ) {
+ if ( $this->mTrxLevel ) {
+ $this->query( 'COMMIT', $fname );
+ $this->mTrxLevel = 0;
+ }
+ }
+
+ final public function rollback( $fname = __METHOD__, $flush = '' ) {
+ if ( $flush !== 'flush' ) {
+ if ( !$this->mTrxLevel ) {
+ wfWarn( "$fname: No transaction to rollback, something got out of sync!" );
+ return; // nothing to do
+ }
+ } else {
+ if ( !$this->mTrxLevel ) {
+ return; // nothing to do
+ }
+ }
+
+ # Avoid fatals if close() was called
+ $this->assertOpen();
+
+ $this->doRollback( $fname );
+ $this->mTrxIdleCallbacks = []; // cancel
+ $this->mTrxPreCommitCallbacks = []; // cancel
+ $this->mTrxAtomicLevels = [];
+ if ( $this->mTrxDoneWrites ) {
+ $this->getTransactionProfiler()->transactionWritingOut(
+ $this->mServer, $this->mDBname, $this->mTrxShortId );
+ }
+ }
+
+ /**
+ * Issues the ROLLBACK command to the database server.
+ *
+ * @see DatabaseBase::rollback()
+ * @param string $fname
+ */
+ protected function doRollback( $fname ) {
+ if ( $this->mTrxLevel ) {
+ $this->query( 'ROLLBACK', $fname, true );
+ $this->mTrxLevel = 0;
+ }
+ }
+
+ /**
+ * Creates a new table with structure copied from existing table
+ * Note that unlike most database abstraction functions, this function does not
+ * automatically append database prefix, because it works at a lower
+ * abstraction level.
+ * The table names passed to this function shall not be quoted (this
+ * function calls addIdentifierQuotes when needed).
+ *
+ * @param string $oldName Name of table whose structure should be copied
+ * @param string $newName Name of table to be created
+ * @param bool $temporary Whether the new table should be temporary
+ * @param string $fname Calling function name
+ * @throws MWException
+ * @return bool True if operation was successful
+ */
+ public function duplicateTableStructure( $oldName, $newName, $temporary = false,
+ $fname = __METHOD__
+ ) {
+ throw new MWException(
+ 'DatabaseBase::duplicateTableStructure is not implemented in descendant class' );
+ }
+
+ function listTables( $prefix = null, $fname = __METHOD__ ) {
+ throw new MWException( 'DatabaseBase::listTables is not implemented in descendant class' );
+ }
+
+ /**
+ * Reset the views process cache set by listViews()
+ * @since 1.22
+ */
+ final public function clearViewsCache() {
+ $this->allViews = null;
+ }
+
+ /**
+ * Lists all the VIEWs in the database
+ *
+ * For caching purposes the list of all views should be stored in
+ * $this->allViews. The process cache can be cleared with clearViewsCache()
+ *
+ * @param string $prefix Only show VIEWs with this prefix, eg. unit_test_
+ * @param string $fname Name of calling function
+ * @throws MWException
+ * @return array
+ * @since 1.22
+ */
+ public function listViews( $prefix = null, $fname = __METHOD__ ) {
+ throw new MWException( 'DatabaseBase::listViews is not implemented in descendant class' );
+ }
+
+ /**
+ * Differentiates between a TABLE and a VIEW
+ *
+ * @param string $name Name of the database-structure to test.
+ * @throws MWException
+ * @return bool
+ * @since 1.22
+ */
+ public function isView( $name ) {
+ throw new MWException( 'DatabaseBase::isView is not implemented in descendant class' );
+ }
+
+ public function timestamp( $ts = 0 ) {
+ return wfTimestamp( TS_MW, $ts );
+ }
+
+ public function timestampOrNull( $ts = null ) {
+ if ( is_null( $ts ) ) {
+ return null;
+ } else {
+ return $this->timestamp( $ts );
+ }
+ }
+
+ /**
+ * Take the result from a query, and wrap it in a ResultWrapper if
+ * necessary. Boolean values are passed through as is, to indicate success
+ * of write queries or failure.
+ *
+ * Once upon a time, DatabaseBase::query() returned a bare MySQL result
+ * resource, and it was necessary to call this function to convert it to
+ * a wrapper. Nowadays, raw database objects are never exposed to external
+ * callers, so this is unnecessary in external code.
+ *
+ * @param bool|ResultWrapper|resource|object $result
+ * @return bool|ResultWrapper
+ */
+ protected function resultObject( $result ) {
+ if ( !$result ) {
+ return false;
+ } elseif ( $result instanceof ResultWrapper ) {
+ return $result;
+ } elseif ( $result === true ) {
+ // Successful write query
+ return $result;
+ } else {
+ return new ResultWrapper( $this, $result );
+ }
+ }
+
+ public function ping() {
+ # Stub. Not essential to override.
+ return true;
+ }
+
+ public function getSessionLagStatus() {
+ return $this->getTransactionLagStatus() ?: $this->getApproximateLagStatus();
+ }
+
+ /**
+ * Get the slave lag when the current transaction started
+ *
+ * This is useful when transactions might use snapshot isolation
+ * (e.g. REPEATABLE-READ in innodb), so the "real" lag of that data
+ * is this lag plus transaction duration. If they don't, it is still
+ * safe to be pessimistic. This returns null if there is no transaction.
+ *
+ * @return array|null ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN)
+ * @since 1.27
+ */
+ public function getTransactionLagStatus() {
+ return $this->mTrxLevel
+ ? [ 'lag' => $this->mTrxSlaveLag, 'since' => $this->trxTimestamp() ]
+ : null;
+ }
+
+ /**
+ * Get a slave lag estimate for this server
+ *
+ * @return array ('lag': seconds or false on error, 'since': UNIX timestamp of estimate)
+ * @since 1.27
+ */
+ public function getApproximateLagStatus() {
+ return [
+ 'lag' => $this->getLBInfo( 'slave' ) ? $this->getLag() : 0,
+ 'since' => microtime( true )
+ ];
+ }
+
+ /**
+ * Merge the result of getSessionLagStatus() for several DBs
+ * using the most pessimistic values to estimate the lag of
+ * any data derived from them in combination
+ *
+ * This is information is useful for caching modules
+ *
+ * @see WANObjectCache::set()
+ * @see WANObjectCache::getWithSetCallback()
+ *
+ * @param IDatabase $db1
+ * @param IDatabase ...
+ * @return array Map of values:
+ * - lag: highest lag of any of the DBs or false on error (e.g. replication stopped)
+ * - since: oldest UNIX timestamp of any of the DB lag estimates
+ * - pending: whether any of the DBs have uncommitted changes
+ * @since 1.27
+ */
+ public static function getCacheSetOptions( IDatabase $db1 ) {
+ $res = [ 'lag' => 0, 'since' => INF, 'pending' => false ];
+ foreach ( func_get_args() as $db ) {
+ /** @var IDatabase $db */
+ $status = $db->getSessionLagStatus();
+ if ( $status['lag'] === false ) {
+ $res['lag'] = false;
+ } elseif ( $res['lag'] !== false ) {
+ $res['lag'] = max( $res['lag'], $status['lag'] );
+ }
+ $res['since'] = min( $res['since'], $status['since'] );
+ $res['pending'] = $res['pending'] ?: $db->writesPending();
+ }
+
+ return $res;
+ }
+
+ public function getLag() {
+ return 0;
+ }
+
+ function maxListLen() {
+ return 0;
+ }
+
+ public function encodeBlob( $b ) {
+ return $b;
+ }
+
+ public function decodeBlob( $b ) {
+ if ( $b instanceof Blob ) {
+ $b = $b->fetch();
+ }
+ return $b;
+ }
+
+ public function setSessionOptions( array $options ) {
+ }
+
+ /**
+ * Read and execute SQL commands from a file.
+ *
+ * Returns true on success, error string or exception on failure (depending
+ * on object's error ignore settings).
+ *
+ * @param string $filename File name to open
+ * @param bool|callable $lineCallback Optional function called before reading each line
+ * @param bool|callable $resultCallback Optional function called for each MySQL result
+ * @param bool|string $fname Calling function name or false if name should be
+ * generated dynamically using $filename
+ * @param bool|callable $inputCallback Optional function called for each
+ * complete line sent
+ * @throws Exception|MWException
+ * @return bool|string
+ */
+ public function sourceFile(
+ $filename, $lineCallback = false, $resultCallback = false, $fname = false, $inputCallback = false
+ ) {
+ MediaWiki\suppressWarnings();
+ $fp = fopen( $filename, 'r' );
+ MediaWiki\restoreWarnings();
+
+ if ( false === $fp ) {
+ throw new MWException( "Could not open \"{$filename}\".\n" );
+ }
+
+ if ( !$fname ) {
+ $fname = __METHOD__ . "( $filename )";
+ }
+
+ try {
+ $error = $this->sourceStream( $fp, $lineCallback, $resultCallback, $fname, $inputCallback );
+ } catch ( Exception $e ) {
+ fclose( $fp );
+ throw $e;
+ }
+
+ fclose( $fp );
+
+ return $error;
+ }
+
+ /**
+ * Get the full path of a patch file. Originally based on archive()
+ * from updaters.inc. Keep in mind this always returns a patch, as
+ * it fails back to MySQL if no DB-specific patch can be found
+ *
+ * @param string $patch The name of the patch, like patch-something.sql
+ * @return string Full path to patch file
+ */
+ public function patchPath( $patch ) {
+ global $IP;
+
+ $dbType = $this->getType();
+ if ( file_exists( "$IP/maintenance/$dbType/archives/$patch" ) ) {
+ return "$IP/maintenance/$dbType/archives/$patch";
+ } else {
+ return "$IP/maintenance/archives/$patch";
+ }
+ }
+
+ public function setSchemaVars( $vars ) {
+ $this->mSchemaVars = $vars;
+ }
+
+ /**
+ * Read and execute commands from an open file handle.
+ *
+ * Returns true on success, error string or exception on failure (depending
+ * on object's error ignore settings).
+ *
+ * @param resource $fp File handle
+ * @param bool|callable $lineCallback Optional function called before reading each query
+ * @param bool|callable $resultCallback Optional function called for each MySQL result
+ * @param string $fname Calling function name
+ * @param bool|callable $inputCallback Optional function called for each complete query sent
+ * @return bool|string
+ */
+ public function sourceStream( $fp, $lineCallback = false, $resultCallback = false,
+ $fname = __METHOD__, $inputCallback = false
+ ) {
+ $cmd = '';
+
+ while ( !feof( $fp ) ) {
+ if ( $lineCallback ) {
+ call_user_func( $lineCallback );
+ }
+
+ $line = trim( fgets( $fp ) );
+
+ if ( $line == '' ) {
+ continue;
+ }
+
+ if ( '-' == $line[0] && '-' == $line[1] ) {
+ continue;
+ }
+
+ if ( $cmd != '' ) {
+ $cmd .= ' ';
+ }
+
+ $done = $this->streamStatementEnd( $cmd, $line );
+
+ $cmd .= "$line\n";
+
+ if ( $done || feof( $fp ) ) {
+ $cmd = $this->replaceVars( $cmd );
+
+ if ( ( $inputCallback && call_user_func( $inputCallback, $cmd ) ) || !$inputCallback ) {
+ $res = $this->query( $cmd, $fname );
+
+ if ( $resultCallback ) {
+ call_user_func( $resultCallback, $res, $this );
+ }
+
+ if ( false === $res ) {
+ $err = $this->lastError();
+
+ return "Query \"{$cmd}\" failed with error code \"$err\".\n";
+ }
+ }
+ $cmd = '';
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Called by sourceStream() to check if we've reached a statement end
+ *
+ * @param string $sql SQL assembled so far
+ * @param string $newLine New line about to be added to $sql
+ * @return bool Whether $newLine contains end of the statement
+ */
+ public function streamStatementEnd( &$sql, &$newLine ) {
+ if ( $this->delimiter ) {
+ $prev = $newLine;
+ $newLine = preg_replace( '/' . preg_quote( $this->delimiter, '/' ) . '$/', '', $newLine );
+ if ( $newLine != $prev ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Database independent variable replacement. Replaces a set of variables
+ * in an SQL statement with their contents as given by $this->getSchemaVars().
+ *
+ * Supports '{$var}' `{$var}` and / *$var* / (without the spaces) style variables.
+ *
+ * - '{$var}' should be used for text and is passed through the database's
+ * addQuotes method.
+ * - `{$var}` should be used for identifiers (e.g. table and database names).
+ * It is passed through the database's addIdentifierQuotes method which
+ * can be overridden if the database uses something other than backticks.
+ * - / *_* / or / *$wgDBprefix* / passes the name that follows through the
+ * database's tableName method.
+ * - / *i* / passes the name that follows through the database's indexName method.
+ * - In all other cases, / *$var* / is left unencoded. Except for table options,
+ * its use should be avoided. In 1.24 and older, string encoding was applied.
+ *
+ * @param string $ins SQL statement to replace variables in
+ * @return string The new SQL statement with variables replaced
+ */
+ protected function replaceVars( $ins ) {
+ $vars = $this->getSchemaVars();
+ return preg_replace_callback(
+ '!
+ /\* (\$wgDBprefix|[_i]) \*/ (\w*) | # 1-2. tableName, indexName
+ \'\{\$ (\w+) }\' | # 3. addQuotes
+ `\{\$ (\w+) }` | # 4. addIdentifierQuotes
+ /\*\$ (\w+) \*/ # 5. leave unencoded
+ !x',
+ function ( $m ) use ( $vars ) {
+ // Note: Because of <https://bugs.php.net/bug.php?id=51881>,
+ // check for both nonexistent keys *and* the empty string.
+ if ( isset( $m[1] ) && $m[1] !== '' ) {
+ if ( $m[1] === 'i' ) {
+ return $this->indexName( $m[2] );
+ } else {
+ return $this->tableName( $m[2] );
+ }
+ } elseif ( isset( $m[3] ) && $m[3] !== '' && array_key_exists( $m[3], $vars ) ) {
+ return $this->addQuotes( $vars[$m[3]] );
+ } elseif ( isset( $m[4] ) && $m[4] !== '' && array_key_exists( $m[4], $vars ) ) {
+ return $this->addIdentifierQuotes( $vars[$m[4]] );
+ } elseif ( isset( $m[5] ) && $m[5] !== '' && array_key_exists( $m[5], $vars ) ) {
+ return $vars[$m[5]];
+ } else {
+ return $m[0];
+ }
+ },
+ $ins
+ );
+ }
+
+ /**
+ * Get schema variables. If none have been set via setSchemaVars(), then
+ * use some defaults from the current object.
+ *
+ * @return array
+ */
+ protected function getSchemaVars() {
+ if ( $this->mSchemaVars ) {
+ return $this->mSchemaVars;
+ } else {
+ return $this->getDefaultSchemaVars();
+ }
+ }
+
+ /**
+ * Get schema variables to use if none have been set via setSchemaVars().
+ *
+ * Override this in derived classes to provide variables for tables.sql
+ * and SQL patch files.
+ *
+ * @return array
+ */
+ protected function getDefaultSchemaVars() {
+ return [];
+ }
+
+ public function lockIsFree( $lockName, $method ) {
+ return true;
+ }
+
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ $this->mNamedLocksHeld[$lockName] = 1;
+
+ return true;
+ }
+
+ public function unlock( $lockName, $method ) {
+ unset( $this->mNamedLocksHeld[$lockName] );
+
+ return true;
+ }
+
+ public function getScopedLockAndFlush( $lockKey, $fname, $timeout ) {
+ if ( !$this->lock( $lockKey, $fname, $timeout ) ) {
+ return null;
+ }
+
+ $unlocker = new ScopedCallback( function () use ( $lockKey, $fname ) {
+ $this->commit( __METHOD__, 'flush' );
+ $this->unlock( $lockKey, $fname );
+ } );
+
+ $this->commit( __METHOD__, 'flush' );
+
+ return $unlocker;
+ }
+
+ public function namedLocksEnqueue() {
+ return false;
+ }
+
+ /**
+ * Lock specific tables
+ *
+ * @param array $read Array of tables to lock for read access
+ * @param array $write Array of tables to lock for write access
+ * @param string $method Name of caller
+ * @param bool $lowPriority Whether to indicate writes to be LOW PRIORITY
+ * @return bool
+ */
+ public function lockTables( $read, $write, $method, $lowPriority = true ) {
+ return true;
+ }
+
+ /**
+ * Unlock specific tables
+ *
+ * @param string $method The caller
+ * @return bool
+ */
+ public function unlockTables( $method ) {
+ return true;
+ }
+
+ /**
+ * Delete a table
+ * @param string $tableName
+ * @param string $fName
+ * @return bool|ResultWrapper
+ * @since 1.18
+ */
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ if ( !$this->tableExists( $tableName, $fName ) ) {
+ return false;
+ }
+ $sql = "DROP TABLE " . $this->tableName( $tableName );
+ if ( $this->cascadingDeletes() ) {
+ $sql .= " CASCADE";
+ }
+
+ return $this->query( $sql, $fName );
+ }
+
+ /**
+ * Get search engine class. All subclasses of this need to implement this
+ * if they wish to use searching.
+ *
+ * @return string
+ */
+ public function getSearchEngine() {
+ return 'SearchEngineDummy';
+ }
+
+ public function getInfinity() {
+ return 'infinity';
+ }
+
+ public function encodeExpiry( $expiry ) {
+ return ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() )
+ ? $this->getInfinity()
+ : $this->timestamp( $expiry );
+ }
+
+ public function decodeExpiry( $expiry, $format = TS_MW ) {
+ return ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() )
+ ? 'infinity'
+ : wfTimestamp( $format, $expiry );
+ }
+
+ public function setBigSelects( $value = true ) {
+ // no-op
+ }
+
+ public function isReadOnly() {
+ return ( $this->getReadOnlyReason() !== false );
+ }
+
+ /**
+ * @return string|bool Reason this DB is read-only or false if it is not
+ */
+ protected function getReadOnlyReason() {
+ $reason = $this->getLBInfo( 'readOnlyReason' );
+
+ return is_string( $reason ) ? $reason : false;
+ }
+
+ /**
+ * @since 1.19
+ * @return string
+ */
+ public function __toString() {
+ return (string)$this->mConn;
+ }
+
+ /**
+ * Run a few simple sanity checks
+ */
+ public function __destruct() {
+ if ( $this->mTrxLevel && $this->mTrxDoneWrites ) {
+ trigger_error( "Uncommitted DB writes (transaction from {$this->mTrxFname})." );
+ }
+ if ( count( $this->mTrxIdleCallbacks ) || count( $this->mTrxPreCommitCallbacks ) ) {
+ $callers = [];
+ foreach ( $this->mTrxIdleCallbacks as $callbackInfo ) {
+ $callers[] = $callbackInfo[1];
+ }
+ $callers = implode( ', ', $callers );
+ trigger_error( "DB transaction callbacks still pending (from $callers)." );
+ }
+ }
+}
+
+/**
+ * @since 1.27
+ */
+abstract class Database extends DatabaseBase {
+ // B/C until nothing type hints for DatabaseBase
+ // @TODO: finish renaming DatabaseBase => Database
+}
diff --git a/www/wiki/includes/db/DatabaseError.php b/www/wiki/includes/db/DatabaseError.php
new file mode 100644
index 00000000..4cd02b1f
--- /dev/null
+++ b/www/wiki/includes/db/DatabaseError.php
@@ -0,0 +1,472 @@
+<?php
+/**
+ * This file contains database error classes.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Database error base class
+ * @ingroup Database
+ */
+class DBError extends MWException {
+ /** @var DatabaseBase */
+ public $db;
+
+ /**
+ * Construct a database error
+ * @param DatabaseBase $db Object which threw the error
+ * @param string $error A simple error message to be used for debugging
+ */
+ function __construct( DatabaseBase $db = null, $error ) {
+ $this->db = $db;
+ parent::__construct( $error );
+ }
+}
+
+/**
+ * Base class for the more common types of database errors. These are known to occur
+ * frequently, so we try to give friendly error messages for them.
+ *
+ * @ingroup Database
+ * @since 1.23
+ */
+class DBExpectedError extends DBError {
+ /**
+ * @return string
+ */
+ function getText() {
+ global $wgShowDBErrorBacktrace;
+
+ $s = $this->getTextContent() . "\n";
+
+ if ( $wgShowDBErrorBacktrace ) {
+ $s .= "Backtrace:\n" . $this->getTraceAsString() . "\n";
+ }
+
+ return $s;
+ }
+
+ /**
+ * @return string
+ */
+ function getHTML() {
+ global $wgShowDBErrorBacktrace;
+
+ $s = $this->getHTMLContent();
+
+ if ( $wgShowDBErrorBacktrace ) {
+ $s .= '<p>Backtrace:</p><pre>' . htmlspecialchars( $this->getTraceAsString() ) . '</pre>';
+ }
+
+ return $s;
+ }
+
+ function getPageTitle() {
+ return $this->msg( 'databaseerror', 'Database error' );
+ }
+
+ /**
+ * @return string
+ */
+ protected function getTextContent() {
+ return $this->getMessage();
+ }
+
+ /**
+ * @return string
+ */
+ protected function getHTMLContent() {
+ return '<p>' . nl2br( htmlspecialchars( $this->getTextContent() ) ) . '</p>';
+ }
+}
+
+/**
+ * @ingroup Database
+ */
+class DBConnectionError extends DBExpectedError {
+ /** @var string Error text */
+ public $error;
+
+ /**
+ * @param DatabaseBase $db Object throwing the error
+ * @param string $error Error text
+ */
+ function __construct( DatabaseBase $db = null, $error = 'unknown error' ) {
+ $msg = 'DB connection error';
+
+ if ( trim( $error ) != '' ) {
+ $msg .= ": $error";
+ } elseif ( $db ) {
+ $error = $this->db->getServer();
+ }
+
+ parent::__construct( $db, $msg );
+ $this->error = $error;
+ }
+
+ /**
+ * @return bool
+ */
+ function useOutputPage() {
+ // Not likely to work
+ return false;
+ }
+
+ /**
+ * @param string $key
+ * @param string $fallback Unescaped alternative error text in case the
+ * message cache cannot be used. Can contain parameters as in regular
+ * messages, that should be passed as additional parameters.
+ * @return string Unprocessed plain error text with parameters replaced
+ */
+ function msg( $key, $fallback /*[, params...] */ ) {
+ $args = array_slice( func_get_args(), 2 );
+
+ if ( $this->useMessageCache() ) {
+ return wfMessage( $key, $args )->useDatabase( false )->text();
+ } else {
+ return wfMsgReplaceArgs( $fallback, $args );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ function isLoggable() {
+ // Don't send to the exception log, already in dberror log
+ return false;
+ }
+
+ /**
+ * @return string Safe HTML
+ */
+ function getHTML() {
+ global $wgShowDBErrorBacktrace, $wgShowHostnames, $wgShowSQLErrors;
+
+ $sorry = htmlspecialchars( $this->msg(
+ 'dberr-problems',
+ 'Sorry! This site is experiencing technical difficulties.'
+ ) );
+ $again = htmlspecialchars( $this->msg(
+ 'dberr-again',
+ 'Try waiting a few minutes and reloading.'
+ ) );
+
+ if ( $wgShowHostnames || $wgShowSQLErrors ) {
+ $info = str_replace(
+ '$1', Html::element( 'span', [ 'dir' => 'ltr' ], $this->error ),
+ htmlspecialchars( $this->msg( 'dberr-info', '(Cannot access the database: $1)' ) )
+ );
+ } else {
+ $info = htmlspecialchars( $this->msg(
+ 'dberr-info-hidden',
+ '(Cannot access the database)'
+ ) );
+ }
+
+ # No database access
+ MessageCache::singleton()->disable();
+
+ $html = "<h1>$sorry</h1><p>$again</p><p><small>$info</small></p>";
+
+ if ( $wgShowDBErrorBacktrace ) {
+ $html .= '<p>Backtrace:</p><pre>' . htmlspecialchars( $this->getTraceAsString() ) . '</pre>';
+ }
+
+ $html .= '<hr />';
+ $html .= $this->searchForm();
+
+ return $html;
+ }
+
+ protected function getTextContent() {
+ global $wgShowHostnames, $wgShowSQLErrors;
+
+ if ( $wgShowHostnames || $wgShowSQLErrors ) {
+ return $this->getMessage();
+ } else {
+ return 'DB connection error';
+ }
+ }
+
+ /**
+ * Output the exception report using HTML.
+ *
+ * @return void
+ */
+ public function reportHTML() {
+ global $wgUseFileCache;
+
+ // Check whether we can serve a file-cached copy of the page with the error underneath
+ if ( $wgUseFileCache ) {
+ try {
+ $cache = $this->fileCachedPage();
+ // Cached version on file system?
+ if ( $cache !== null ) {
+ // Hack: extend the body for error messages
+ $cache = str_replace( [ '</html>', '</body>' ], '', $cache );
+ // Add cache notice...
+ $cache .= '<div style="border:1px solid #ffd0d0;padding:1em;">' .
+ htmlspecialchars( $this->msg( 'dberr-cachederror',
+ 'This is a cached copy of the requested page, and may not be up to date.' ) ) .
+ '</div>';
+
+ // Output cached page with notices on bottom and re-close body
+ echo "{$cache}<hr />{$this->getHTML()}</body></html>";
+
+ return;
+ }
+ } catch ( Exception $e ) {
+ // Do nothing, just use the default page
+ }
+ }
+
+ // We can't, cough and die in the usual fashion
+ parent::reportHTML();
+ }
+
+ /**
+ * @return string
+ */
+ function searchForm() {
+ global $wgSitename, $wgCanonicalServer, $wgRequest;
+
+ $usegoogle = htmlspecialchars( $this->msg(
+ 'dberr-usegoogle',
+ 'You can try searching via Google in the meantime.'
+ ) );
+ $outofdate = htmlspecialchars( $this->msg(
+ 'dberr-outofdate',
+ 'Note that their indexes of our content may be out of date.'
+ ) );
+ $googlesearch = htmlspecialchars( $this->msg( 'searchbutton', 'Search' ) );
+
+ $search = htmlspecialchars( $wgRequest->getVal( 'search' ) );
+
+ $server = htmlspecialchars( $wgCanonicalServer );
+ $sitename = htmlspecialchars( $wgSitename );
+
+ $trygoogle = <<<EOT
+<div style="margin: 1.5em">$usegoogle<br />
+<small>$outofdate</small>
+</div>
+<form method="get" action="//www.google.com/search" id="googlesearch">
+ <input type="hidden" name="domains" value="$server" />
+ <input type="hidden" name="num" value="50" />
+ <input type="hidden" name="ie" value="UTF-8" />
+ <input type="hidden" name="oe" value="UTF-8" />
+
+ <input type="text" name="q" size="31" maxlength="255" value="$search" />
+ <input type="submit" name="btnG" value="$googlesearch" />
+ <p>
+ <label><input type="radio" name="sitesearch" value="$server" checked="checked" />$sitename</label>
+ <label><input type="radio" name="sitesearch" value="" />WWW</label>
+ </p>
+</form>
+EOT;
+
+ return $trygoogle;
+ }
+
+ /**
+ * @return string
+ */
+ private function fileCachedPage() {
+ $context = RequestContext::getMain();
+
+ if ( $context->getOutput()->isDisabled() ) {
+ // Done already?
+ return '';
+ }
+
+ if ( $context->getTitle() ) {
+ // Use the main context's title if we managed to set it
+ $t = $context->getTitle()->getPrefixedDBkey();
+ } else {
+ // Fallback to the raw title URL param. We can't use the Title
+ // class is it may hit the interwiki table and give a DB error.
+ // We may get a cache miss due to not sanitizing the title though.
+ $t = str_replace( ' ', '_', $context->getRequest()->getVal( 'title' ) );
+ if ( $t == '' ) { // fallback to main page
+ $t = Title::newFromText(
+ $this->msg( 'mainpage', 'Main Page' ) )->getPrefixedDBkey();
+ }
+ }
+
+ $cache = new HTMLFileCache( $t, 'view' );
+ if ( $cache->isCached() ) {
+ return $cache->fetchText();
+ } else {
+ return '';
+ }
+ }
+}
+
+/**
+ * @ingroup Database
+ */
+class DBQueryError extends DBExpectedError {
+ public $error, $errno, $sql, $fname;
+
+ /**
+ * @param DatabaseBase $db
+ * @param string $error
+ * @param int|string $errno
+ * @param string $sql
+ * @param string $fname
+ */
+ function __construct( DatabaseBase $db, $error, $errno, $sql, $fname ) {
+ if ( $db->wasConnectionError( $errno ) ) {
+ $message = "A connection error occured. \n" .
+ "Query: $sql\n" .
+ "Function: $fname\n" .
+ "Error: $errno $error\n";
+ } else {
+ $message = "A database error has occurred. Did you forget to run " .
+ "maintenance/update.php after upgrading? See: " .
+ "https://www.mediawiki.org/wiki/Manual:Upgrading#Run_the_update_script\n" .
+ "Query: $sql\n" .
+ "Function: $fname\n" .
+ "Error: $errno $error\n";
+ }
+ parent::__construct( $db, $message );
+
+ $this->error = $error;
+ $this->errno = $errno;
+ $this->sql = $sql;
+ $this->fname = $fname;
+ }
+
+ /**
+ * @return string
+ */
+ function getPageTitle() {
+ return $this->msg( 'databaseerror', 'Database error' );
+ }
+
+ /**
+ * @return string
+ */
+ protected function getHTMLContent() {
+ $key = 'databaseerror-text';
+ $s = Html::element( 'p', [], $this->msg( $key, $this->getFallbackMessage( $key ) ) );
+
+ $details = $this->getTechnicalDetails();
+ if ( $details ) {
+ $s .= '<ul>';
+ foreach ( $details as $key => $detail ) {
+ $s .= str_replace(
+ '$1', call_user_func_array( 'Html::element', $detail ),
+ Html::element( 'li', [],
+ $this->msg( $key, $this->getFallbackMessage( $key ) )
+ )
+ );
+ }
+ $s .= '</ul>';
+ }
+
+ return $s;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getTextContent() {
+ $key = 'databaseerror-textcl';
+ $s = $this->msg( $key, $this->getFallbackMessage( $key ) ) . "\n";
+
+ foreach ( $this->getTechnicalDetails() as $key => $detail ) {
+ $s .= $this->msg( $key, $this->getFallbackMessage( $key ), $detail[2] ) . "\n";
+ }
+
+ return $s;
+ }
+
+ /**
+ * Make a list of technical details that can be shown to the user. This information can
+ * aid in debugging yet may be useful to an attacker trying to exploit a security weakness
+ * in the software or server configuration.
+ *
+ * Thus no such details are shown by default, though if $wgShowHostnames is true, only the
+ * full SQL query is hidden; in fact, the error message often does contain a hostname, and
+ * sites using this option probably don't care much about "security by obscurity". Of course,
+ * if $wgShowSQLErrors is true, the SQL query *is* shown.
+ *
+ * @return array Keys are message keys; values are arrays of arguments for Html::element().
+ * Array will be empty if users are not allowed to see any of these details at all.
+ */
+ protected function getTechnicalDetails() {
+ global $wgShowHostnames, $wgShowSQLErrors;
+
+ $attribs = [ 'dir' => 'ltr' ];
+ $details = [];
+
+ if ( $wgShowSQLErrors ) {
+ $details['databaseerror-query'] = [
+ 'div', [ 'class' => 'mw-code' ] + $attribs, $this->sql ];
+ }
+
+ if ( $wgShowHostnames || $wgShowSQLErrors ) {
+ $errorMessage = $this->errno . ' ' . $this->error;
+ $details['databaseerror-function'] = [ 'code', $attribs, $this->fname ];
+ $details['databaseerror-error'] = [ 'samp', $attribs, $errorMessage ];
+ }
+
+ return $details;
+ }
+
+ /**
+ * @param string $key Message key
+ * @return string English message text
+ */
+ private function getFallbackMessage( $key ) {
+ $messages = [
+ 'databaseerror-text' => 'A database query error has occurred.
+This may indicate a bug in the software.',
+ 'databaseerror-textcl' => 'A database query error has occurred.',
+ 'databaseerror-query' => 'Query: $1',
+ 'databaseerror-function' => 'Function: $1',
+ 'databaseerror-error' => 'Error: $1',
+ ];
+
+ return $messages[$key];
+ }
+}
+
+/**
+ * @ingroup Database
+ */
+class DBUnexpectedError extends DBError {
+}
+
+/**
+ * @ingroup Database
+ */
+class DBReadOnlyError extends DBExpectedError {
+ function getPageTitle() {
+ return $this->msg( 'readonly', 'Database is locked' );
+ }
+}
+
+/**
+ * @ingroup Database
+ */
+class DBTransactionError extends DBExpectedError {
+}
diff --git a/www/wiki/includes/db/DatabaseMssql.php b/www/wiki/includes/db/DatabaseMssql.php
new file mode 100644
index 00000000..33f81623
--- /dev/null
+++ b/www/wiki/includes/db/DatabaseMssql.php
@@ -0,0 +1,1558 @@
+<?php
+/**
+ * This is the MS SQL Server Native database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ * @author Joel Penner <a-joelpe at microsoft dot com>
+ * @author Chris Pucci <a-cpucci at microsoft dot com>
+ * @author Ryan Biesemeyer <v-ryanbi at microsoft dot com>
+ * @author Ryan Schmidt <skizzerz at gmail dot com>
+ */
+
+/**
+ * @ingroup Database
+ */
+class DatabaseMssql extends Database {
+ protected $mInsertId = null;
+ protected $mLastResult = null;
+ protected $mAffectedRows = null;
+ protected $mSubqueryId = 0;
+ protected $mScrollableCursor = true;
+ protected $mPrepareStatements = true;
+ protected $mBinaryColumnCache = null;
+ protected $mBitColumnCache = null;
+ protected $mIgnoreDupKeyErrors = false;
+ protected $mIgnoreErrors = [];
+
+ protected $mPort;
+
+ public function cascadingDeletes() {
+ return true;
+ }
+
+ public function cleanupTriggers() {
+ return false;
+ }
+
+ public function strictIPs() {
+ return false;
+ }
+
+ public function realTimestamps() {
+ return false;
+ }
+
+ public function implicitGroupby() {
+ return false;
+ }
+
+ public function implicitOrderby() {
+ return false;
+ }
+
+ public function functionalIndexes() {
+ return true;
+ }
+
+ public function unionSupportsOrderAndLimit() {
+ return false;
+ }
+
+ /**
+ * Usually aborts on failure
+ * @param string $server
+ * @param string $user
+ * @param string $password
+ * @param string $dbName
+ * @throws DBConnectionError
+ * @return bool|DatabaseBase|null
+ */
+ public function open( $server, $user, $password, $dbName ) {
+ # Test for driver support, to avoid suppressed fatal error
+ if ( !function_exists( 'sqlsrv_connect' ) ) {
+ throw new DBConnectionError(
+ $this,
+ "Microsoft SQL Server Native (sqlsrv) functions missing.
+ You can download the driver from: http://go.microsoft.com/fwlink/?LinkId=123470\n"
+ );
+ }
+
+ global $wgDBport, $wgDBWindowsAuthentication;
+
+ # e.g. the class is being loaded
+ if ( !strlen( $user ) ) {
+ return null;
+ }
+
+ $this->close();
+ $this->mServer = $server;
+ $this->mPort = $wgDBport;
+ $this->mUser = $user;
+ $this->mPassword = $password;
+ $this->mDBname = $dbName;
+
+ $connectionInfo = [];
+
+ if ( $dbName ) {
+ $connectionInfo['Database'] = $dbName;
+ }
+
+ // Decide which auth scenerio to use
+ // if we are using Windows auth, don't add credentials to $connectionInfo
+ if ( !$wgDBWindowsAuthentication ) {
+ $connectionInfo['UID'] = $user;
+ $connectionInfo['PWD'] = $password;
+ }
+
+ MediaWiki\suppressWarnings();
+ $this->mConn = sqlsrv_connect( $server, $connectionInfo );
+ MediaWiki\restoreWarnings();
+
+ if ( $this->mConn === false ) {
+ throw new DBConnectionError( $this, $this->lastError() );
+ }
+
+ $this->mOpened = true;
+
+ return $this->mConn;
+ }
+
+ /**
+ * Closes a database connection, if it is open
+ * Returns success, true if already closed
+ * @return bool
+ */
+ protected function closeConnection() {
+ return sqlsrv_close( $this->mConn );
+ }
+
+ /**
+ * @param bool|MssqlResultWrapper|resource $result
+ * @return bool|MssqlResultWrapper
+ */
+ protected function resultObject( $result ) {
+ if ( !$result ) {
+ return false;
+ } elseif ( $result instanceof MssqlResultWrapper ) {
+ return $result;
+ } elseif ( $result === true ) {
+ // Successful write query
+ return $result;
+ } else {
+ return new MssqlResultWrapper( $this, $result );
+ }
+ }
+
+ /**
+ * @param string $sql
+ * @return bool|MssqlResult
+ * @throws DBUnexpectedError
+ */
+ protected function doQuery( $sql ) {
+ if ( $this->debug() ) {
+ wfDebug( "SQL: [$sql]\n" );
+ }
+ $this->offset = 0;
+
+ // several extensions seem to think that all databases support limits
+ // via LIMIT N after the WHERE clause well, MSSQL uses SELECT TOP N,
+ // so to catch any of those extensions we'll do a quick check for a
+ // LIMIT clause and pass $sql through $this->LimitToTopN() which parses
+ // the limit clause and passes the result to $this->limitResult();
+ if ( preg_match( '/\bLIMIT\s*/i', $sql ) ) {
+ // massage LIMIT -> TopN
+ $sql = $this->LimitToTopN( $sql );
+ }
+
+ // MSSQL doesn't have EXTRACT(epoch FROM XXX)
+ if ( preg_match( '#\bEXTRACT\s*?\(\s*?EPOCH\s+FROM\b#i', $sql, $matches ) ) {
+ // This is same as UNIX_TIMESTAMP, we need to calc # of seconds from 1970
+ $sql = str_replace( $matches[0], "DATEDIFF(s,CONVERT(datetime,'1/1/1970'),", $sql );
+ }
+
+ // perform query
+
+ // SQLSRV_CURSOR_STATIC is slower than SQLSRV_CURSOR_CLIENT_BUFFERED (one of the two is
+ // needed if we want to be able to seek around the result set), however CLIENT_BUFFERED
+ // has a bug in the sqlsrv driver where wchar_t types (such as nvarchar) that are empty
+ // strings make php throw a fatal error "Severe error translating Unicode"
+ if ( $this->mScrollableCursor ) {
+ $scrollArr = [ 'Scrollable' => SQLSRV_CURSOR_STATIC ];
+ } else {
+ $scrollArr = [];
+ }
+
+ if ( $this->mPrepareStatements ) {
+ // we do prepare + execute so we can get its field metadata for later usage if desired
+ $stmt = sqlsrv_prepare( $this->mConn, $sql, [], $scrollArr );
+ $success = sqlsrv_execute( $stmt );
+ } else {
+ $stmt = sqlsrv_query( $this->mConn, $sql, [], $scrollArr );
+ $success = (bool)$stmt;
+ }
+
+ // make a copy so that anything we add below does not get reflected in future queries
+ $ignoreErrors = $this->mIgnoreErrors;
+
+ if ( $this->mIgnoreDupKeyErrors ) {
+ // ignore duplicate key errors
+ // this emulates INSERT IGNORE in MySQL
+ $ignoreErrors[] = '2601'; // duplicate key error caused by unique index
+ $ignoreErrors[] = '2627'; // duplicate key error caused by primary key
+ $ignoreErrors[] = '3621'; // generic "the statement has been terminated" error
+ }
+
+ if ( $success === false ) {
+ $errors = sqlsrv_errors();
+ $success = true;
+
+ foreach ( $errors as $err ) {
+ if ( !in_array( $err['code'], $ignoreErrors ) ) {
+ $success = false;
+ break;
+ }
+ }
+
+ if ( $success === false ) {
+ return false;
+ }
+ }
+ // remember number of rows affected
+ $this->mAffectedRows = sqlsrv_rows_affected( $stmt );
+
+ return $stmt;
+ }
+
+ public function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ sqlsrv_free_stmt( $res );
+ }
+
+ /**
+ * @param MssqlResultWrapper $res
+ * @return stdClass
+ */
+ public function fetchObject( $res ) {
+ // $res is expected to be an instance of MssqlResultWrapper here
+ return $res->fetchObject();
+ }
+
+ /**
+ * @param MssqlResultWrapper $res
+ * @return array
+ */
+ public function fetchRow( $res ) {
+ return $res->fetchRow();
+ }
+
+ /**
+ * @param mixed $res
+ * @return int
+ */
+ public function numRows( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ $ret = sqlsrv_num_rows( $res );
+
+ if ( $ret === false ) {
+ // we cannot get an amount of rows from this cursor type
+ // has_rows returns bool true/false if the result has rows
+ $ret = (int)sqlsrv_has_rows( $res );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param mixed $res
+ * @return int
+ */
+ public function numFields( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return sqlsrv_num_fields( $res );
+ }
+
+ /**
+ * @param mixed $res
+ * @param int $n
+ * @return int
+ */
+ public function fieldName( $res, $n ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return sqlsrv_field_metadata( $res )[$n]['Name'];
+ }
+
+ /**
+ * This must be called after nextSequenceVal
+ * @return int|null
+ */
+ public function insertId() {
+ return $this->mInsertId;
+ }
+
+ /**
+ * @param MssqlResultWrapper $res
+ * @param int $row
+ * @return bool
+ */
+ public function dataSeek( $res, $row ) {
+ return $res->seek( $row );
+ }
+
+ /**
+ * @return string
+ */
+ public function lastError() {
+ $strRet = '';
+ $retErrors = sqlsrv_errors( SQLSRV_ERR_ALL );
+ if ( $retErrors != null ) {
+ foreach ( $retErrors as $arrError ) {
+ $strRet .= $this->formatError( $arrError ) . "\n";
+ }
+ } else {
+ $strRet = "No errors found";
+ }
+
+ return $strRet;
+ }
+
+ /**
+ * @param array $err
+ * @return string
+ */
+ private function formatError( $err ) {
+ return '[SQLSTATE ' . $err['SQLSTATE'] . '][Error Code ' . $err['code'] . ']' . $err['message'];
+ }
+
+ /**
+ * @return string
+ */
+ public function lastErrno() {
+ $err = sqlsrv_errors( SQLSRV_ERR_ALL );
+ if ( $err !== null && isset( $err[0] ) ) {
+ return $err[0]['code'];
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * @return int
+ */
+ public function affectedRows() {
+ return $this->mAffectedRows;
+ }
+
+ /**
+ * SELECT wrapper
+ *
+ * @param mixed $table Array or string, table name(s) (prefix auto-added)
+ * @param mixed $vars Array or string, field name(s) to be retrieved
+ * @param mixed $conds Array or string, condition(s) for WHERE
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @param array $options Associative array of options (e.g.
+ * array('GROUP BY' => 'page_title')), see Database::makeSelectOptions
+ * code for list of supported stuff
+ * @param array $join_conds Associative array of table join conditions
+ * (optional) (e.g. array( 'page' => array('LEFT JOIN','page_latest=rev_id') )
+ * @return mixed Database result resource (feed to Database::fetchObject
+ * or whatever), or false on failure
+ * @throws DBQueryError
+ * @throws DBUnexpectedError
+ * @throws Exception
+ */
+ public function select( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ $sql = $this->selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
+ if ( isset( $options['EXPLAIN'] ) ) {
+ try {
+ $this->mScrollableCursor = false;
+ $this->mPrepareStatements = false;
+ $this->query( "SET SHOWPLAN_ALL ON" );
+ $ret = $this->query( $sql, $fname );
+ $this->query( "SET SHOWPLAN_ALL OFF" );
+ } catch ( DBQueryError $dqe ) {
+ if ( isset( $options['FOR COUNT'] ) ) {
+ // likely don't have privs for SHOWPLAN, so run a select count instead
+ $this->query( "SET SHOWPLAN_ALL OFF" );
+ unset( $options['EXPLAIN'] );
+ $ret = $this->select(
+ $table,
+ 'COUNT(*) AS EstimateRows',
+ $conds,
+ $fname,
+ $options,
+ $join_conds
+ );
+ } else {
+ // someone actually wanted the query plan instead of an est row count
+ // let them know of the error
+ $this->mScrollableCursor = true;
+ $this->mPrepareStatements = true;
+ throw $dqe;
+ }
+ }
+ $this->mScrollableCursor = true;
+ $this->mPrepareStatements = true;
+ return $ret;
+ }
+ return $this->query( $sql, $fname );
+ }
+
+ /**
+ * SELECT wrapper
+ *
+ * @param mixed $table Array or string, table name(s) (prefix auto-added)
+ * @param mixed $vars Array or string, field name(s) to be retrieved
+ * @param mixed $conds Array or string, condition(s) for WHERE
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @param array $options Associative array of options (e.g. array('GROUP BY' => 'page_title')),
+ * see Database::makeSelectOptions code for list of supported stuff
+ * @param array $join_conds Associative array of table join conditions (optional)
+ * (e.g. array( 'page' => array('LEFT JOIN','page_latest=rev_id') )
+ * @return string The SQL text
+ */
+ public function selectSQLText( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ if ( isset( $options['EXPLAIN'] ) ) {
+ unset( $options['EXPLAIN'] );
+ }
+
+ $sql = parent::selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
+
+ // try to rewrite aggregations of bit columns (currently MAX and MIN)
+ if ( strpos( $sql, 'MAX(' ) !== false || strpos( $sql, 'MIN(' ) !== false ) {
+ $bitColumns = [];
+ if ( is_array( $table ) ) {
+ foreach ( $table as $t ) {
+ $bitColumns += $this->getBitColumns( $this->tableName( $t ) );
+ }
+ } else {
+ $bitColumns = $this->getBitColumns( $this->tableName( $table ) );
+ }
+
+ foreach ( $bitColumns as $col => $info ) {
+ $replace = [
+ "MAX({$col})" => "MAX(CAST({$col} AS tinyint))",
+ "MIN({$col})" => "MIN(CAST({$col} AS tinyint))",
+ ];
+ $sql = str_replace( array_keys( $replace ), array_values( $replace ), $sql );
+ }
+ }
+
+ return $sql;
+ }
+
+ public function deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds,
+ $fname = __METHOD__
+ ) {
+ $this->mScrollableCursor = false;
+ try {
+ parent::deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds, $fname );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+ }
+
+ public function delete( $table, $conds, $fname = __METHOD__ ) {
+ $this->mScrollableCursor = false;
+ try {
+ parent::delete( $table, $conds, $fname );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+ }
+
+ /**
+ * Estimate rows in dataset
+ * Returns estimated count, based on SHOWPLAN_ALL output
+ * This is not necessarily an accurate estimate, so use sparingly
+ * Returns -1 if count cannot be found
+ * Takes same arguments as Database::select()
+ * @param string $table
+ * @param string $vars
+ * @param string $conds
+ * @param string $fname
+ * @param array $options
+ * @return int
+ */
+ public function estimateRowCount( $table, $vars = '*', $conds = '',
+ $fname = __METHOD__, $options = []
+ ) {
+ // http://msdn2.microsoft.com/en-us/library/aa259203.aspx
+ $options['EXPLAIN'] = true;
+ $options['FOR COUNT'] = true;
+ $res = $this->select( $table, $vars, $conds, $fname, $options );
+
+ $rows = -1;
+ if ( $res ) {
+ $row = $this->fetchRow( $res );
+
+ if ( isset( $row['EstimateRows'] ) ) {
+ $rows = (int)$row['EstimateRows'];
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Returns information about an index
+ * If errors are explicitly ignored, returns NULL on failure
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return array|bool|null
+ */
+ public function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ # This does not return the same info as MYSQL would, but that's OK
+ # because MediaWiki never uses the returned value except to check for
+ # the existance of indexes.
+ $sql = "sp_helpindex '" . $this->tableName( $table ) . "'";
+ $res = $this->query( $sql, $fname );
+
+ if ( !$res ) {
+ return null;
+ }
+
+ $result = [];
+ foreach ( $res as $row ) {
+ if ( $row->index_name == $index ) {
+ $row->Non_unique = !stristr( $row->index_description, "unique" );
+ $cols = explode( ", ", $row->index_keys );
+ foreach ( $cols as $col ) {
+ $row->Column_name = trim( $col );
+ $result[] = clone $row;
+ }
+ } elseif ( $index == 'PRIMARY' && stristr( $row->index_description, 'PRIMARY' ) ) {
+ $row->Non_unique = 0;
+ $cols = explode( ", ", $row->index_keys );
+ foreach ( $cols as $col ) {
+ $row->Column_name = trim( $col );
+ $result[] = clone $row;
+ }
+ }
+ }
+
+ return empty( $result ) ? false : $result;
+ }
+
+ /**
+ * INSERT wrapper, inserts an array into a table
+ *
+ * $arrToInsert may be a single associative array, or an array of these with numeric keys, for
+ * multi-row insert.
+ *
+ * Usually aborts on failure
+ * If errors are explicitly ignored, returns success
+ * @param string $table
+ * @param array $arrToInsert
+ * @param string $fname
+ * @param array $options
+ * @return bool
+ * @throws Exception
+ */
+ public function insert( $table, $arrToInsert, $fname = __METHOD__, $options = [] ) {
+ # No rows to insert, easy just return now
+ if ( !count( $arrToInsert ) ) {
+ return true;
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $table = $this->tableName( $table );
+
+ if ( !( isset( $arrToInsert[0] ) && is_array( $arrToInsert[0] ) ) ) { // Not multi row
+ $arrToInsert = [ 0 => $arrToInsert ]; // make everything multi row compatible
+ }
+
+ // We know the table we're inserting into, get its identity column
+ $identity = null;
+ // strip matching square brackets and the db/schema from table name
+ $tableRawArr = explode( '.', preg_replace( '#\[([^\]]*)\]#', '$1', $table ) );
+ $tableRaw = array_pop( $tableRawArr );
+ $res = $this->doQuery(
+ "SELECT NAME AS idColumn FROM SYS.IDENTITY_COLUMNS " .
+ "WHERE OBJECT_NAME(OBJECT_ID)='{$tableRaw}'"
+ );
+ if ( $res && sqlsrv_has_rows( $res ) ) {
+ // There is an identity for this table.
+ $identityArr = sqlsrv_fetch_array( $res, SQLSRV_FETCH_ASSOC );
+ $identity = array_pop( $identityArr );
+ }
+ sqlsrv_free_stmt( $res );
+
+ // Determine binary/varbinary fields so we can encode data as a hex string like 0xABCDEF
+ $binaryColumns = $this->getBinaryColumns( $table );
+
+ // INSERT IGNORE is not supported by SQL Server
+ // remove IGNORE from options list and set ignore flag to true
+ if ( in_array( 'IGNORE', $options ) ) {
+ $options = array_diff( $options, [ 'IGNORE' ] );
+ $this->mIgnoreDupKeyErrors = true;
+ }
+
+ foreach ( $arrToInsert as $a ) {
+ // start out with empty identity column, this is so we can return
+ // it as a result of the insert logic
+ $sqlPre = '';
+ $sqlPost = '';
+ $identityClause = '';
+
+ // if we have an identity column
+ if ( $identity ) {
+ // iterate through
+ foreach ( $a as $k => $v ) {
+ if ( $k == $identity ) {
+ if ( !is_null( $v ) ) {
+ // there is a value being passed to us,
+ // we need to turn on and off inserted identity
+ $sqlPre = "SET IDENTITY_INSERT $table ON;";
+ $sqlPost = ";SET IDENTITY_INSERT $table OFF;";
+ } else {
+ // we can't insert NULL into an identity column,
+ // so remove the column from the insert.
+ unset( $a[$k] );
+ }
+ }
+ }
+
+ // we want to output an identity column as result
+ $identityClause = "OUTPUT INSERTED.$identity ";
+ }
+
+ $keys = array_keys( $a );
+
+ // Build the actual query
+ $sql = $sqlPre . 'INSERT ' . implode( ' ', $options ) .
+ " INTO $table (" . implode( ',', $keys ) . ") $identityClause VALUES (";
+
+ $first = true;
+ foreach ( $a as $key => $value ) {
+ if ( isset( $binaryColumns[$key] ) ) {
+ $value = new MssqlBlob( $value );
+ }
+ if ( $first ) {
+ $first = false;
+ } else {
+ $sql .= ',';
+ }
+ if ( is_null( $value ) ) {
+ $sql .= 'null';
+ } elseif ( is_array( $value ) || is_object( $value ) ) {
+ if ( is_object( $value ) && $value instanceof Blob ) {
+ $sql .= $this->addQuotes( $value );
+ } else {
+ $sql .= $this->addQuotes( serialize( $value ) );
+ }
+ } else {
+ $sql .= $this->addQuotes( $value );
+ }
+ }
+ $sql .= ')' . $sqlPost;
+
+ // Run the query
+ $this->mScrollableCursor = false;
+ try {
+ $ret = $this->query( $sql );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ $this->mIgnoreDupKeyErrors = false;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+
+ if ( !is_null( $identity ) ) {
+ // then we want to get the identity column value we were assigned and save it off
+ $row = $ret->fetchObject();
+ if ( is_object( $row ) ) {
+ $this->mInsertId = $row->$identity;
+
+ // it seems that mAffectedRows is -1 sometimes when OUTPUT INSERTED.identity is used
+ // if we got an identity back, we know for sure a row was affected, so adjust that here
+ if ( $this->mAffectedRows == -1 ) {
+ $this->mAffectedRows = 1;
+ }
+ }
+ }
+ }
+ $this->mIgnoreDupKeyErrors = false;
+ return $ret;
+ }
+
+ /**
+ * INSERT SELECT wrapper
+ * $varMap must be an associative array of the form array( 'dest1' => 'source1', ...)
+ * Source items may be literals rather than field names, but strings should
+ * be quoted with Database::addQuotes().
+ * @param string $destTable
+ * @param array|string $srcTable May be an array of tables.
+ * @param array $varMap
+ * @param array $conds May be "*" to copy the whole table.
+ * @param string $fname
+ * @param array $insertOptions
+ * @param array $selectOptions
+ * @return null|ResultWrapper
+ * @throws Exception
+ */
+ public function insertSelect( $destTable, $srcTable, $varMap, $conds, $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = []
+ ) {
+ $this->mScrollableCursor = false;
+ try {
+ $ret = parent::insertSelect(
+ $destTable,
+ $srcTable,
+ $varMap,
+ $conds,
+ $fname,
+ $insertOptions,
+ $selectOptions
+ );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+
+ return $ret;
+ }
+
+ /**
+ * UPDATE wrapper. Takes a condition array and a SET array.
+ *
+ * @param string $table Name of the table to UPDATE. This will be passed through
+ * DatabaseBase::tableName().
+ *
+ * @param array $values An array of values to SET. For each array element,
+ * the key gives the field name, and the value gives the data
+ * to set that field to. The data will be quoted by
+ * DatabaseBase::addQuotes().
+ *
+ * @param array $conds An array of conditions (WHERE). See
+ * DatabaseBase::select() for the details of the format of
+ * condition arrays. Use '*' to update all rows.
+ *
+ * @param string $fname The function name of the caller (from __METHOD__),
+ * for logging and profiling.
+ *
+ * @param array $options An array of UPDATE options, can be:
+ * - IGNORE: Ignore unique key conflicts
+ * - LOW_PRIORITY: MySQL-specific, see MySQL manual.
+ * @return bool
+ * @throws DBUnexpectedError
+ * @throws Exception
+ * @throws MWException
+ */
+ function update( $table, $values, $conds, $fname = __METHOD__, $options = [] ) {
+ $table = $this->tableName( $table );
+ $binaryColumns = $this->getBinaryColumns( $table );
+
+ $opts = $this->makeUpdateOptions( $options );
+ $sql = "UPDATE $opts $table SET " . $this->makeList( $values, LIST_SET, $binaryColumns );
+
+ if ( $conds !== [] && $conds !== '*' ) {
+ $sql .= " WHERE " . $this->makeList( $conds, LIST_AND, $binaryColumns );
+ }
+
+ $this->mScrollableCursor = false;
+ try {
+ $ret = $this->query( $sql );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+ return true;
+ }
+
+ /**
+ * Makes an encoded list of strings from an array
+ * @param array $a Containing the data
+ * @param int $mode Constant
+ * - LIST_COMMA: comma separated, no field names
+ * - LIST_AND: ANDed WHERE clause (without the WHERE). See
+ * the documentation for $conds in DatabaseBase::select().
+ * - LIST_OR: ORed WHERE clause (without the WHERE)
+ * - LIST_SET: comma separated with field names, like a SET clause
+ * - LIST_NAMES: comma separated field names
+ * @param array $binaryColumns Contains a list of column names that are binary types
+ * This is a custom parameter only present for MS SQL.
+ *
+ * @throws MWException|DBUnexpectedError
+ * @return string
+ */
+ public function makeList( $a, $mode = LIST_COMMA, $binaryColumns = [] ) {
+ if ( !is_array( $a ) ) {
+ throw new DBUnexpectedError( $this,
+ 'DatabaseBase::makeList called with incorrect parameters' );
+ }
+
+ if ( $mode != LIST_NAMES ) {
+ // In MS SQL, values need to be specially encoded when they are
+ // inserted into binary fields. Perform this necessary encoding
+ // for the specified set of columns.
+ foreach ( array_keys( $a ) as $field ) {
+ if ( !isset( $binaryColumns[$field] ) ) {
+ continue;
+ }
+
+ if ( is_array( $a[$field] ) ) {
+ foreach ( $a[$field] as &$v ) {
+ $v = new MssqlBlob( $v );
+ }
+ unset( $v );
+ } else {
+ $a[$field] = new MssqlBlob( $a[$field] );
+ }
+ }
+ }
+
+ return parent::makeList( $a, $mode );
+ }
+
+ /**
+ * @param string $table
+ * @param string $field
+ * @return int Returns the size of a text field, or -1 for "unlimited"
+ */
+ public function textFieldSize( $table, $field ) {
+ $table = $this->tableName( $table );
+ $sql = "SELECT CHARACTER_MAXIMUM_LENGTH,DATA_TYPE FROM INFORMATION_SCHEMA.Columns
+ WHERE TABLE_NAME = '$table' AND COLUMN_NAME = '$field'";
+ $res = $this->query( $sql );
+ $row = $this->fetchRow( $res );
+ $size = -1;
+ if ( strtolower( $row['DATA_TYPE'] ) != 'text' ) {
+ $size = $row['CHARACTER_MAXIMUM_LENGTH'];
+ }
+
+ return $size;
+ }
+
+ /**
+ * Construct a LIMIT query with optional offset
+ * This is used for query pages
+ *
+ * @param string $sql SQL query we will append the limit too
+ * @param int $limit The SQL limit
+ * @param bool|int $offset The SQL offset (default false)
+ * @return array|string
+ * @throws DBUnexpectedError
+ */
+ public function limitResult( $sql, $limit, $offset = false ) {
+ if ( $offset === false || $offset == 0 ) {
+ if ( strpos( $sql, "SELECT" ) === false ) {
+ return "TOP {$limit} " . $sql;
+ } else {
+ return preg_replace( '/\bSELECT(\s+DISTINCT)?\b/Dsi',
+ 'SELECT$1 TOP ' . $limit, $sql, 1 );
+ }
+ } else {
+ // This one is fun, we need to pull out the select list as well as any ORDER BY clause
+ $select = $orderby = [];
+ $s1 = preg_match( '#SELECT\s+(.+?)\s+FROM#Dis', $sql, $select );
+ $s2 = preg_match( '#(ORDER BY\s+.+?)(\s*FOR XML .*)?$#Dis', $sql, $orderby );
+ $overOrder = $postOrder = '';
+ $first = $offset + 1;
+ $last = $offset + $limit;
+ $sub1 = 'sub_' . $this->mSubqueryId;
+ $sub2 = 'sub_' . ( $this->mSubqueryId + 1 );
+ $this->mSubqueryId += 2;
+ if ( !$s1 ) {
+ // wat
+ throw new DBUnexpectedError( $this, "Attempting to LIMIT a non-SELECT query\n" );
+ }
+ if ( !$s2 ) {
+ // no ORDER BY
+ $overOrder = 'ORDER BY (SELECT 1)';
+ } else {
+ if ( !isset( $orderby[2] ) || !$orderby[2] ) {
+ // don't need to strip it out if we're using a FOR XML clause
+ $sql = str_replace( $orderby[1], '', $sql );
+ }
+ $overOrder = $orderby[1];
+ $postOrder = ' ' . $overOrder;
+ }
+ $sql = "SELECT {$select[1]}
+ FROM (
+ SELECT ROW_NUMBER() OVER({$overOrder}) AS rowNumber, *
+ FROM ({$sql}) {$sub1}
+ ) {$sub2}
+ WHERE rowNumber BETWEEN {$first} AND {$last}{$postOrder}";
+
+ return $sql;
+ }
+ }
+
+ /**
+ * If there is a limit clause, parse it, strip it, and pass the remaining
+ * SQL through limitResult() with the appropriate parameters. Not the
+ * prettiest solution, but better than building a whole new parser. This
+ * exists becase there are still too many extensions that don't use dynamic
+ * sql generation.
+ *
+ * @param string $sql
+ * @return array|mixed|string
+ */
+ public function LimitToTopN( $sql ) {
+ // Matches: LIMIT {[offset,] row_count | row_count OFFSET offset}
+ $pattern = '/\bLIMIT\s+((([0-9]+)\s*,\s*)?([0-9]+)(\s+OFFSET\s+([0-9]+))?)/i';
+ if ( preg_match( $pattern, $sql, $matches ) ) {
+ $row_count = $matches[4];
+ $offset = $matches[3] ?: $matches[6] ?: false;
+
+ // strip the matching LIMIT clause out
+ $sql = str_replace( $matches[0], '', $sql );
+
+ return $this->limitResult( $sql, $row_count, $offset );
+ }
+
+ return $sql;
+ }
+
+ /**
+ * @return string Wikitext of a link to the server software's web site
+ */
+ public function getSoftwareLink() {
+ return "[{{int:version-db-mssql-url}} MS SQL Server]";
+ }
+
+ /**
+ * @return string Version information from the database
+ */
+ public function getServerVersion() {
+ $server_info = sqlsrv_server_info( $this->mConn );
+ $version = 'Error';
+ if ( isset( $server_info['SQLServerVersion'] ) ) {
+ $version = $server_info['SQLServerVersion'];
+ }
+
+ return $version;
+ }
+
+ /**
+ * @param string $table
+ * @param string $fname
+ * @return bool
+ */
+ public function tableExists( $table, $fname = __METHOD__ ) {
+ list( $db, $schema, $table ) = $this->tableName( $table, 'split' );
+
+ if ( $db !== false ) {
+ // remote database
+ wfDebug( "Attempting to call tableExists on a remote table" );
+ return false;
+ }
+
+ if ( $schema === false ) {
+ global $wgDBmwschema;
+ $schema = $wgDBmwschema;
+ }
+
+ $res = $this->query( "SELECT 1 FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_TYPE = 'BASE TABLE'
+ AND TABLE_SCHEMA = '$schema' AND TABLE_NAME = '$table'" );
+
+ if ( $res->numRows() ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Query whether a given column exists in the mediawiki schema
+ * @param string $table
+ * @param string $field
+ * @param string $fname
+ * @return bool
+ */
+ public function fieldExists( $table, $field, $fname = __METHOD__ ) {
+ list( $db, $schema, $table ) = $this->tableName( $table, 'split' );
+
+ if ( $db !== false ) {
+ // remote database
+ wfDebug( "Attempting to call fieldExists on a remote table" );
+ return false;
+ }
+
+ $res = $this->query( "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = '$schema' AND TABLE_NAME = '$table' AND COLUMN_NAME = '$field'" );
+
+ if ( $res->numRows() ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public function fieldInfo( $table, $field ) {
+ list( $db, $schema, $table ) = $this->tableName( $table, 'split' );
+
+ if ( $db !== false ) {
+ // remote database
+ wfDebug( "Attempting to call fieldInfo on a remote table" );
+ return false;
+ }
+
+ $res = $this->query( "SELECT * FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = '$schema' AND TABLE_NAME = '$table' AND COLUMN_NAME = '$field'" );
+
+ $meta = $res->fetchRow();
+ if ( $meta ) {
+ return new MssqlField( $meta );
+ }
+
+ return false;
+ }
+
+ /**
+ * Begin a transaction, committing any previously open transaction
+ * @param string $fname
+ */
+ protected function doBegin( $fname = __METHOD__ ) {
+ sqlsrv_begin_transaction( $this->mConn );
+ $this->mTrxLevel = 1;
+ }
+
+ /**
+ * End a transaction
+ * @param string $fname
+ */
+ protected function doCommit( $fname = __METHOD__ ) {
+ sqlsrv_commit( $this->mConn );
+ $this->mTrxLevel = 0;
+ }
+
+ /**
+ * Rollback a transaction.
+ * No-op on non-transactional databases.
+ * @param string $fname
+ */
+ protected function doRollback( $fname = __METHOD__ ) {
+ sqlsrv_rollback( $this->mConn );
+ $this->mTrxLevel = 0;
+ }
+
+ /**
+ * Escapes a identifier for use inm SQL.
+ * Throws an exception if it is invalid.
+ * Reference: http://msdn.microsoft.com/en-us/library/aa224033%28v=SQL.80%29.aspx
+ * @param string $identifier
+ * @throws MWException
+ * @return string
+ */
+ private function escapeIdentifier( $identifier ) {
+ if ( strlen( $identifier ) == 0 ) {
+ throw new MWException( "An identifier must not be empty" );
+ }
+ if ( strlen( $identifier ) > 128 ) {
+ throw new MWException( "The identifier '$identifier' is too long (max. 128)" );
+ }
+ if ( ( strpos( $identifier, '[' ) !== false )
+ || ( strpos( $identifier, ']' ) !== false )
+ ) {
+ // It may be allowed if you quoted with double quotation marks, but
+ // that would break if QUOTED_IDENTIFIER is OFF
+ throw new MWException( "Square brackets are not allowed in '$identifier'" );
+ }
+
+ return "[$identifier]";
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ public function strencode( $s ) {
+ // Should not be called by us
+
+ return str_replace( "'", "''", $s );
+ }
+
+ /**
+ * @param string|Blob $s
+ * @return string
+ */
+ public function addQuotes( $s ) {
+ if ( $s instanceof MssqlBlob ) {
+ return $s->fetch();
+ } elseif ( $s instanceof Blob ) {
+ // this shouldn't really ever be called, but it's here if needed
+ // (and will quite possibly make the SQL error out)
+ $blob = new MssqlBlob( $s->fetch() );
+ return $blob->fetch();
+ } else {
+ if ( is_bool( $s ) ) {
+ $s = $s ? 1 : 0;
+ }
+ return parent::addQuotes( $s );
+ }
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ public function addIdentifierQuotes( $s ) {
+ // http://msdn.microsoft.com/en-us/library/aa223962.aspx
+ return '[' . $s . ']';
+ }
+
+ /**
+ * @param string $name
+ * @return bool
+ */
+ public function isQuotedIdentifier( $name ) {
+ return strlen( $name ) && $name[0] == '[' && substr( $name, -1, 1 ) == ']';
+ }
+
+ /**
+ * MS SQL supports more pattern operators than other databases (ex: [,],^)
+ *
+ * @param string $s
+ * @return string
+ */
+ protected function escapeLikeInternal( $s ) {
+ return addcslashes( $s, '\%_[]^' );
+ }
+
+ /**
+ * MS SQL requires specifying the escape character used in a LIKE query
+ * or using Square brackets to surround characters that are to be escaped
+ * http://msdn.microsoft.com/en-us/library/ms179859.aspx
+ * Here we take the Specify-Escape-Character approach since it's less
+ * invasive, renders a query that is closer to other DB's and better at
+ * handling square bracket escaping
+ *
+ * @return string Fully built LIKE statement
+ */
+ public function buildLike() {
+ $params = func_get_args();
+ if ( count( $params ) > 0 && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+
+ return parent::buildLike( $params ) . " ESCAPE '\' ";
+ }
+
+ /**
+ * @param string $db
+ * @return bool
+ */
+ public function selectDB( $db ) {
+ try {
+ $this->mDBname = $db;
+ $this->query( "USE $db" );
+ return true;
+ } catch ( Exception $e ) {
+ return false;
+ }
+ }
+
+ /**
+ * @param array $options An associative array of options to be turned into
+ * an SQL query, valid keys are listed in the function.
+ * @return array
+ */
+ public function makeSelectOptions( $options ) {
+ $tailOpts = '';
+ $startOpts = '';
+
+ $noKeyOptions = [];
+ foreach ( $options as $key => $option ) {
+ if ( is_numeric( $key ) ) {
+ $noKeyOptions[$option] = true;
+ }
+ }
+
+ $tailOpts .= $this->makeGroupByWithHaving( $options );
+
+ $tailOpts .= $this->makeOrderBy( $options );
+
+ if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
+ $startOpts .= 'DISTINCT';
+ }
+
+ if ( isset( $noKeyOptions['FOR XML'] ) ) {
+ // used in group concat field emulation
+ $tailOpts .= " FOR XML PATH('')";
+ }
+
+ // we want this to be compatible with the output of parent::makeSelectOptions()
+ return [ $startOpts, '', $tailOpts, '' ];
+ }
+
+ /**
+ * Get the type of the DBMS, as it appears in $wgDBtype.
+ * @return string
+ */
+ public function getType() {
+ return 'mssql';
+ }
+
+ /**
+ * @param array $stringList
+ * @return string
+ */
+ public function buildConcat( $stringList ) {
+ return implode( ' + ', $stringList );
+ }
+
+ /**
+ * Build a GROUP_CONCAT or equivalent statement for a query.
+ * MS SQL doesn't have GROUP_CONCAT so we emulate it with other stuff (and boy is it nasty)
+ *
+ * This is useful for combining a field for several rows into a single string.
+ * NULL values will not appear in the output, duplicated values will appear,
+ * and the resulting delimiter-separated values have no defined sort order.
+ * Code using the results may need to use the PHP unique() or sort() methods.
+ *
+ * @param string $delim Glue to bind the results together
+ * @param string|array $table Table name
+ * @param string $field Field name
+ * @param string|array $conds Conditions
+ * @param string|array $join_conds Join conditions
+ * @return string SQL text
+ * @since 1.23
+ */
+ public function buildGroupConcatField( $delim, $table, $field, $conds = '',
+ $join_conds = []
+ ) {
+ $gcsq = 'gcsq_' . $this->mSubqueryId;
+ $this->mSubqueryId++;
+
+ $delimLen = strlen( $delim );
+ $fld = "{$field} + {$this->addQuotes( $delim )}";
+ $sql = "(SELECT LEFT({$field}, LEN({$field}) - {$delimLen}) FROM ("
+ . $this->selectSQLText( $table, $fld, $conds, null, [ 'FOR XML' ], $join_conds )
+ . ") {$gcsq} ({$field}))";
+
+ return $sql;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSearchEngine() {
+ return "SearchMssql";
+ }
+
+ /**
+ * Returns an associative array for fields that are of type varbinary, binary, or image
+ * $table can be either a raw table name or passed through tableName() first
+ * @param string $table
+ * @return array
+ */
+ private function getBinaryColumns( $table ) {
+ $tableRawArr = explode( '.', preg_replace( '#\[([^\]]*)\]#', '$1', $table ) );
+ $tableRaw = array_pop( $tableRawArr );
+
+ if ( $this->mBinaryColumnCache === null ) {
+ $this->populateColumnCaches();
+ }
+
+ return isset( $this->mBinaryColumnCache[$tableRaw] )
+ ? $this->mBinaryColumnCache[$tableRaw]
+ : [];
+ }
+
+ /**
+ * @param string $table
+ * @return array
+ */
+ private function getBitColumns( $table ) {
+ $tableRawArr = explode( '.', preg_replace( '#\[([^\]]*)\]#', '$1', $table ) );
+ $tableRaw = array_pop( $tableRawArr );
+
+ if ( $this->mBitColumnCache === null ) {
+ $this->populateColumnCaches();
+ }
+
+ return isset( $this->mBitColumnCache[$tableRaw] )
+ ? $this->mBitColumnCache[$tableRaw]
+ : [];
+ }
+
+ private function populateColumnCaches() {
+ $res = $this->select( 'INFORMATION_SCHEMA.COLUMNS', '*',
+ [
+ 'TABLE_CATALOG' => $this->mDBname,
+ 'TABLE_SCHEMA' => $this->mSchema,
+ 'DATA_TYPE' => [ 'varbinary', 'binary', 'image', 'bit' ]
+ ] );
+
+ $this->mBinaryColumnCache = [];
+ $this->mBitColumnCache = [];
+ foreach ( $res as $row ) {
+ if ( $row->DATA_TYPE == 'bit' ) {
+ $this->mBitColumnCache[$row->TABLE_NAME][$row->COLUMN_NAME] = $row;
+ } else {
+ $this->mBinaryColumnCache[$row->TABLE_NAME][$row->COLUMN_NAME] = $row;
+ }
+ }
+ }
+
+ /**
+ * @param string $name
+ * @param string $format
+ * @return string
+ */
+ function tableName( $name, $format = 'quoted' ) {
+ # Replace reserved words with better ones
+ switch ( $name ) {
+ case 'user':
+ return $this->realTableName( 'mwuser', $format );
+ default:
+ return $this->realTableName( $name, $format );
+ }
+ }
+
+ /**
+ * call this instead of tableName() in the updater when renaming tables
+ * @param string $name
+ * @param string $format One of quoted, raw, or split
+ * @return string
+ */
+ function realTableName( $name, $format = 'quoted' ) {
+ $table = parent::tableName( $name, $format );
+ if ( $format == 'split' ) {
+ // Used internally, we want the schema split off from the table name and returned
+ // as a list with 3 elements (database, schema, table)
+ $table = explode( '.', $table );
+ while ( count( $table ) < 3 ) {
+ array_unshift( $table, false );
+ }
+ }
+ return $table;
+ }
+
+ /**
+ * Delete a table
+ * @param string $tableName
+ * @param string $fName
+ * @return bool|ResultWrapper
+ * @since 1.18
+ */
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ if ( !$this->tableExists( $tableName, $fName ) ) {
+ return false;
+ }
+
+ // parent function incorrectly appends CASCADE, which we don't want
+ $sql = "DROP TABLE " . $this->tableName( $tableName );
+
+ return $this->query( $sql, $fName );
+ }
+
+ /**
+ * Called in the installer and updater.
+ * Probably doesn't need to be called anywhere else in the codebase.
+ * @param bool|null $value
+ * @return bool|null
+ */
+ public function prepareStatements( $value = null ) {
+ return wfSetVar( $this->mPrepareStatements, $value );
+ }
+
+ /**
+ * Called in the installer and updater.
+ * Probably doesn't need to be called anywhere else in the codebase.
+ * @param bool|null $value
+ * @return bool|null
+ */
+ public function scrollableCursor( $value = null ) {
+ return wfSetVar( $this->mScrollableCursor, $value );
+ }
+
+ /**
+ * Called in the installer and updater.
+ * Probably doesn't need to be called anywhere else in the codebase.
+ * @param array|null $value
+ * @return array|null
+ */
+ public function ignoreErrors( array $value = null ) {
+ return wfSetVar( $this->mIgnoreErrors, $value );
+ }
+} // end DatabaseMssql class
+
+/**
+ * Utility class.
+ *
+ * @ingroup Database
+ */
+class MssqlField implements Field {
+ private $name, $tableName, $default, $max_length, $nullable, $type;
+
+ function __construct( $info ) {
+ $this->name = $info['COLUMN_NAME'];
+ $this->tableName = $info['TABLE_NAME'];
+ $this->default = $info['COLUMN_DEFAULT'];
+ $this->max_length = $info['CHARACTER_MAXIMUM_LENGTH'];
+ $this->nullable = !( strtolower( $info['IS_NULLABLE'] ) == 'no' );
+ $this->type = $info['DATA_TYPE'];
+ }
+
+ function name() {
+ return $this->name;
+ }
+
+ function tableName() {
+ return $this->tableName;
+ }
+
+ function defaultValue() {
+ return $this->default;
+ }
+
+ function maxLength() {
+ return $this->max_length;
+ }
+
+ function isNullable() {
+ return $this->nullable;
+ }
+
+ function type() {
+ return $this->type;
+ }
+}
+
+class MssqlBlob extends Blob {
+ public function __construct( $data ) {
+ if ( $data instanceof MssqlBlob ) {
+ return $data;
+ } elseif ( $data instanceof Blob ) {
+ $this->mData = $data->fetch();
+ } elseif ( is_array( $data ) && is_object( $data ) ) {
+ $this->mData = serialize( $data );
+ } else {
+ $this->mData = $data;
+ }
+ }
+
+ /**
+ * Returns an unquoted hex representation of a binary string
+ * for insertion into varbinary-type fields
+ * @return string
+ */
+ public function fetch() {
+ if ( $this->mData === null ) {
+ return 'null';
+ }
+
+ $ret = '0x';
+ $dataLength = strlen( $this->mData );
+ for ( $i = 0; $i < $dataLength; $i++ ) {
+ $ret .= bin2hex( pack( 'C', ord( $this->mData[$i] ) ) );
+ }
+
+ return $ret;
+ }
+}
+
+class MssqlResultWrapper extends ResultWrapper {
+ private $mSeekTo = null;
+
+ /**
+ * @return stdClass|bool
+ */
+ public function fetchObject() {
+ $res = $this->result;
+
+ if ( $this->mSeekTo !== null ) {
+ $result = sqlsrv_fetch_object( $res, 'stdClass', [],
+ SQLSRV_SCROLL_ABSOLUTE, $this->mSeekTo );
+ $this->mSeekTo = null;
+ } else {
+ $result = sqlsrv_fetch_object( $res );
+ }
+
+ // MediaWiki expects us to return boolean false when there are no more rows instead of null
+ if ( $result === null ) {
+ return false;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array|bool
+ */
+ public function fetchRow() {
+ $res = $this->result;
+
+ if ( $this->mSeekTo !== null ) {
+ $result = sqlsrv_fetch_array( $res, SQLSRV_FETCH_BOTH,
+ SQLSRV_SCROLL_ABSOLUTE, $this->mSeekTo );
+ $this->mSeekTo = null;
+ } else {
+ $result = sqlsrv_fetch_array( $res );
+ }
+
+ // MediaWiki expects us to return boolean false when there are no more rows instead of null
+ if ( $result === null ) {
+ return false;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param int $row
+ * @return bool
+ */
+ public function seek( $row ) {
+ $res = $this->result;
+
+ // check bounds
+ $numRows = $this->db->numRows( $res );
+ $row = intval( $row );
+
+ if ( $numRows === 0 ) {
+ return false;
+ } elseif ( $row < 0 || $row > $numRows - 1 ) {
+ return false;
+ }
+
+ // Unlike MySQL, the seek actually happens on the next access
+ $this->mSeekTo = $row;
+ return true;
+ }
+}
diff --git a/www/wiki/includes/db/DatabaseMysql.php b/www/wiki/includes/db/DatabaseMysql.php
new file mode 100644
index 00000000..5b151477
--- /dev/null
+++ b/www/wiki/includes/db/DatabaseMysql.php
@@ -0,0 +1,210 @@
+<?php
+/**
+ * This is the MySQL database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Database abstraction object for PHP extension mysql.
+ *
+ * @ingroup Database
+ * @see Database
+ */
+class DatabaseMysql extends DatabaseMysqlBase {
+ /**
+ * @param string $sql
+ * @return resource False on error
+ */
+ protected function doQuery( $sql ) {
+ $conn = $this->getBindingHandle();
+
+ if ( $this->bufferResults() ) {
+ $ret = mysql_query( $sql, $conn );
+ } else {
+ $ret = mysql_unbuffered_query( $sql, $conn );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $realServer
+ * @return bool|resource MySQL Database connection or false on failure to connect
+ * @throws DBConnectionError
+ */
+ protected function mysqlConnect( $realServer ) {
+ # Avoid a suppressed fatal error, which is very hard to track down
+ if ( !extension_loaded( 'mysql' ) ) {
+ throw new DBConnectionError(
+ $this,
+ "MySQL functions missing, have you compiled PHP with the --with-mysql option?\n"
+ );
+ }
+
+ $connFlags = 0;
+ if ( $this->mFlags & DBO_SSL ) {
+ $connFlags |= MYSQL_CLIENT_SSL;
+ }
+ if ( $this->mFlags & DBO_COMPRESS ) {
+ $connFlags |= MYSQL_CLIENT_COMPRESS;
+ }
+
+ if ( ini_get( 'mysql.connect_timeout' ) <= 3 ) {
+ $numAttempts = 2;
+ } else {
+ $numAttempts = 1;
+ }
+
+ $conn = false;
+
+ # The kernel's default SYN retransmission period is far too slow for us,
+ # so we use a short timeout plus a manual retry. Retrying means that a small
+ # but finite rate of SYN packet loss won't cause user-visible errors.
+ for ( $i = 0; $i < $numAttempts && !$conn; $i++ ) {
+ if ( $i > 1 ) {
+ usleep( 1000 );
+ }
+ if ( $this->mFlags & DBO_PERSISTENT ) {
+ $conn = mysql_pconnect( $realServer, $this->mUser, $this->mPassword, $connFlags );
+ } else {
+ # Create a new connection...
+ $conn = mysql_connect( $realServer, $this->mUser, $this->mPassword, true, $connFlags );
+ }
+ }
+
+ return $conn;
+ }
+
+ /**
+ * @param string $charset
+ * @return bool
+ */
+ protected function mysqlSetCharset( $charset ) {
+ $conn = $this->getBindingHandle();
+
+ if ( function_exists( 'mysql_set_charset' ) ) {
+ return mysql_set_charset( $charset, $conn );
+ } else {
+ return $this->query( 'SET NAMES ' . $charset, __METHOD__ );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ protected function closeConnection() {
+ $conn = $this->getBindingHandle();
+
+ return mysql_close( $conn );
+ }
+
+ /**
+ * @return int
+ */
+ function insertId() {
+ $conn = $this->getBindingHandle();
+
+ return mysql_insert_id( $conn );
+ }
+
+ /**
+ * @return int
+ */
+ function lastErrno() {
+ if ( $this->mConn ) {
+ return mysql_errno( $this->mConn );
+ } else {
+ return mysql_errno();
+ }
+ }
+
+ /**
+ * @return int
+ */
+ function affectedRows() {
+ $conn = $this->getBindingHandle();
+
+ return mysql_affected_rows( $conn );
+ }
+
+ /**
+ * @param string $db
+ * @return bool
+ */
+ function selectDB( $db ) {
+ $conn = $this->getBindingHandle();
+
+ $this->mDBname = $db;
+
+ return mysql_select_db( $db, $conn );
+ }
+
+ protected function mysqlFreeResult( $res ) {
+ return mysql_free_result( $res );
+ }
+
+ protected function mysqlFetchObject( $res ) {
+ return mysql_fetch_object( $res );
+ }
+
+ protected function mysqlFetchArray( $res ) {
+ return mysql_fetch_array( $res );
+ }
+
+ protected function mysqlNumRows( $res ) {
+ return mysql_num_rows( $res );
+ }
+
+ protected function mysqlNumFields( $res ) {
+ return mysql_num_fields( $res );
+ }
+
+ protected function mysqlFetchField( $res, $n ) {
+ return mysql_fetch_field( $res, $n );
+ }
+
+ protected function mysqlFieldName( $res, $n ) {
+ return mysql_field_name( $res, $n );
+ }
+
+ protected function mysqlFieldType( $res, $n ) {
+ return mysql_field_type( $res, $n );
+ }
+
+ protected function mysqlDataSeek( $res, $row ) {
+ return mysql_data_seek( $res, $row );
+ }
+
+ protected function mysqlError( $conn = null ) {
+ return ( $conn !== null ) ? mysql_error( $conn ) : mysql_error(); // avoid warning
+ }
+
+ protected function mysqlRealEscapeString( $s ) {
+ $conn = $this->getBindingHandle();
+
+ return mysql_real_escape_string( $s, $conn );
+ }
+
+ protected function mysqlPing() {
+ $conn = $this->getBindingHandle();
+
+ return mysql_ping( $conn );
+ }
+}
diff --git a/www/wiki/includes/db/DatabaseMysqlBase.php b/www/wiki/includes/db/DatabaseMysqlBase.php
new file mode 100644
index 00000000..13be9116
--- /dev/null
+++ b/www/wiki/includes/db/DatabaseMysqlBase.php
@@ -0,0 +1,1512 @@
+<?php
+/**
+ * This is the MySQL database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Database abstraction object for MySQL.
+ * Defines methods independent on used MySQL extension.
+ *
+ * @ingroup Database
+ * @since 1.22
+ * @see Database
+ */
+abstract class DatabaseMysqlBase extends Database {
+ /** @var MysqlMasterPos */
+ protected $lastKnownSlavePos;
+ /** @var string Method to detect slave lag */
+ protected $lagDetectionMethod;
+ /** @var array Method to detect slave lag */
+ protected $lagDetectionOptions = [];
+
+ /** @var string|null */
+ private $serverVersion = null;
+
+ /**
+ * Additional $params include:
+ * - lagDetectionMethod : set to one of (Seconds_Behind_Master,pt-heartbeat).
+ * pt-heartbeat assumes the table is at heartbeat.heartbeat
+ * and uses UTC timestamps in the heartbeat.ts column.
+ * (https://www.percona.com/doc/percona-toolkit/2.2/pt-heartbeat.html)
+ * - lagDetectionOptions : if using pt-heartbeat, this can be set to an array map to change
+ * the default behavior. Normally, the heartbeat row with the server
+ * ID of this server's master will be used. Set the "conds" field to
+ * override the query conditions, e.g. ['shard' => 's1'].
+ * @param array $params
+ */
+ function __construct( array $params ) {
+ parent::__construct( $params );
+
+ $this->lagDetectionMethod = isset( $params['lagDetectionMethod'] )
+ ? $params['lagDetectionMethod']
+ : 'Seconds_Behind_Master';
+ $this->lagDetectionOptions = isset( $params['lagDetectionOptions'] )
+ ? $params['lagDetectionOptions']
+ : [];
+ }
+
+ /**
+ * @return string
+ */
+ function getType() {
+ return 'mysql';
+ }
+
+ /**
+ * @param string $server
+ * @param string $user
+ * @param string $password
+ * @param string $dbName
+ * @throws Exception|DBConnectionError
+ * @return bool
+ */
+ function open( $server, $user, $password, $dbName ) {
+ global $wgAllDBsAreLocalhost, $wgSQLMode;
+
+ # Close/unset connection handle
+ $this->close();
+
+ # Debugging hack -- fake cluster
+ $realServer = $wgAllDBsAreLocalhost ? 'localhost' : $server;
+ $this->mServer = $server;
+ $this->mUser = $user;
+ $this->mPassword = $password;
+ $this->mDBname = $dbName;
+
+ $this->installErrorHandler();
+ try {
+ $this->mConn = $this->mysqlConnect( $realServer );
+ } catch ( Exception $ex ) {
+ $this->restoreErrorHandler();
+ throw $ex;
+ }
+ $error = $this->restoreErrorHandler();
+
+ # Always log connection errors
+ if ( !$this->mConn ) {
+ if ( !$error ) {
+ $error = $this->lastError();
+ }
+ wfLogDBError(
+ "Error connecting to {db_server}: {error}",
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ 'error' => $error,
+ ] )
+ );
+ wfDebug( "DB connection error\n" .
+ "Server: $server, User: $user, Password: " .
+ substr( $password, 0, 3 ) . "..., error: " . $error . "\n" );
+
+ $this->reportConnectionError( $error );
+ }
+
+ if ( $dbName != '' ) {
+ MediaWiki\suppressWarnings();
+ $success = $this->selectDB( $dbName );
+ MediaWiki\restoreWarnings();
+ if ( !$success ) {
+ wfLogDBError(
+ "Error selecting database {db_name} on server {db_server}",
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ ] )
+ );
+ wfDebug( "Error selecting database $dbName on server {$this->mServer} " .
+ "from client host " . wfHostname() . "\n" );
+
+ $this->reportConnectionError( "Error selecting database $dbName" );
+ }
+ }
+
+ // Tell the server what we're communicating with
+ if ( !$this->connectInitCharset() ) {
+ $this->reportConnectionError( "Error setting character set" );
+ }
+
+ // Abstract over any insane MySQL defaults
+ $set = [ 'group_concat_max_len = 262144' ];
+ // Set SQL mode, default is turning them all off, can be overridden or skipped with null
+ if ( is_string( $wgSQLMode ) ) {
+ $set[] = 'sql_mode = ' . $this->addQuotes( $wgSQLMode );
+ }
+ // Set any custom settings defined by site config
+ // (e.g. https://dev.mysql.com/doc/refman/4.1/en/innodb-parameters.html)
+ foreach ( $this->mSessionVars as $var => $val ) {
+ // Escape strings but not numbers to avoid MySQL complaining
+ if ( !is_int( $val ) && !is_float( $val ) ) {
+ $val = $this->addQuotes( $val );
+ }
+ $set[] = $this->addIdentifierQuotes( $var ) . ' = ' . $val;
+ }
+
+ if ( $set ) {
+ // Use doQuery() to avoid opening implicit transactions (DBO_TRX)
+ $success = $this->doQuery( 'SET ' . implode( ', ', $set ) );
+ if ( !$success ) {
+ wfLogDBError(
+ 'Error setting MySQL variables on server {db_server} (check $wgSQLMode)',
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ ] )
+ );
+ $this->reportConnectionError(
+ 'Error setting MySQL variables on server {db_server} (check $wgSQLMode)' );
+ }
+ }
+
+ $this->mOpened = true;
+
+ return true;
+ }
+
+ /**
+ * Set the character set information right after connection
+ * @return bool
+ */
+ protected function connectInitCharset() {
+ global $wgDBmysql5;
+
+ if ( $wgDBmysql5 ) {
+ // Tell the server we're communicating with it in UTF-8.
+ // This may engage various charset conversions.
+ return $this->mysqlSetCharset( 'utf8' );
+ } else {
+ return $this->mysqlSetCharset( 'binary' );
+ }
+ }
+
+ /**
+ * Open a connection to a MySQL server
+ *
+ * @param string $realServer
+ * @return mixed Raw connection
+ * @throws DBConnectionError
+ */
+ abstract protected function mysqlConnect( $realServer );
+
+ /**
+ * Set the character set of the MySQL link
+ *
+ * @param string $charset
+ * @return bool
+ */
+ abstract protected function mysqlSetCharset( $charset );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @throws DBUnexpectedError
+ */
+ function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $ok = $this->mysqlFreeResult( $res );
+ MediaWiki\restoreWarnings();
+ if ( !$ok ) {
+ throw new DBUnexpectedError( $this, "Unable to free MySQL result" );
+ }
+ }
+
+ /**
+ * Free result memory
+ *
+ * @param resource $res Raw result
+ * @return bool
+ */
+ abstract protected function mysqlFreeResult( $res );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @return stdClass|bool
+ * @throws DBUnexpectedError
+ */
+ function fetchObject( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $row = $this->mysqlFetchObject( $res );
+ MediaWiki\restoreWarnings();
+
+ $errno = $this->lastErrno();
+ // Unfortunately, mysql_fetch_object does not reset the last errno.
+ // Only check for CR_SERVER_LOST and CR_UNKNOWN_ERROR, as
+ // these are the only errors mysql_fetch_object can cause.
+ // See http://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
+ if ( $errno == 2000 || $errno == 2013 ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'Error in fetchObject(): ' . htmlspecialchars( $this->lastError() )
+ );
+ }
+
+ return $row;
+ }
+
+ /**
+ * Fetch a result row as an object
+ *
+ * @param resource $res Raw result
+ * @return stdClass
+ */
+ abstract protected function mysqlFetchObject( $res );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @return array|bool
+ * @throws DBUnexpectedError
+ */
+ function fetchRow( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $row = $this->mysqlFetchArray( $res );
+ MediaWiki\restoreWarnings();
+
+ $errno = $this->lastErrno();
+ // Unfortunately, mysql_fetch_array does not reset the last errno.
+ // Only check for CR_SERVER_LOST and CR_UNKNOWN_ERROR, as
+ // these are the only errors mysql_fetch_array can cause.
+ // See http://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
+ if ( $errno == 2000 || $errno == 2013 ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'Error in fetchRow(): ' . htmlspecialchars( $this->lastError() )
+ );
+ }
+
+ return $row;
+ }
+
+ /**
+ * Fetch a result row as an associative and numeric array
+ *
+ * @param resource $res Raw result
+ * @return array
+ */
+ abstract protected function mysqlFetchArray( $res );
+
+ /**
+ * @throws DBUnexpectedError
+ * @param ResultWrapper|resource $res
+ * @return int
+ */
+ function numRows( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $n = $this->mysqlNumRows( $res );
+ MediaWiki\restoreWarnings();
+
+ // Unfortunately, mysql_num_rows does not reset the last errno.
+ // We are not checking for any errors here, since
+ // these are no errors mysql_num_rows can cause.
+ // See http://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
+ // See https://phabricator.wikimedia.org/T44430
+ return $n;
+ }
+
+ /**
+ * Get number of rows in result
+ *
+ * @param resource $res Raw result
+ * @return int
+ */
+ abstract protected function mysqlNumRows( $res );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @return int
+ */
+ function numFields( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $this->mysqlNumFields( $res );
+ }
+
+ /**
+ * Get number of fields in result
+ *
+ * @param resource $res Raw result
+ * @return int
+ */
+ abstract protected function mysqlNumFields( $res );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @param int $n
+ * @return string
+ */
+ function fieldName( $res, $n ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $this->mysqlFieldName( $res, $n );
+ }
+
+ /**
+ * Get the name of the specified field in a result
+ *
+ * @param ResultWrapper|resource $res
+ * @param int $n
+ * @return string
+ */
+ abstract protected function mysqlFieldName( $res, $n );
+
+ /**
+ * mysql_field_type() wrapper
+ * @param ResultWrapper|resource $res
+ * @param int $n
+ * @return string
+ */
+ public function fieldType( $res, $n ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $this->mysqlFieldType( $res, $n );
+ }
+
+ /**
+ * Get the type of the specified field in a result
+ *
+ * @param ResultWrapper|resource $res
+ * @param int $n
+ * @return string
+ */
+ abstract protected function mysqlFieldType( $res, $n );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @param int $row
+ * @return bool
+ */
+ function dataSeek( $res, $row ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $this->mysqlDataSeek( $res, $row );
+ }
+
+ /**
+ * Move internal result pointer
+ *
+ * @param ResultWrapper|resource $res
+ * @param int $row
+ * @return bool
+ */
+ abstract protected function mysqlDataSeek( $res, $row );
+
+ /**
+ * @return string
+ */
+ function lastError() {
+ if ( $this->mConn ) {
+ # Even if it's non-zero, it can still be invalid
+ MediaWiki\suppressWarnings();
+ $error = $this->mysqlError( $this->mConn );
+ if ( !$error ) {
+ $error = $this->mysqlError();
+ }
+ MediaWiki\restoreWarnings();
+ } else {
+ $error = $this->mysqlError();
+ }
+ if ( $error ) {
+ $error .= ' (' . $this->mServer . ')';
+ }
+
+ return $error;
+ }
+
+ /**
+ * Returns the text of the error message from previous MySQL operation
+ *
+ * @param resource $conn Raw connection
+ * @return string
+ */
+ abstract protected function mysqlError( $conn = null );
+
+ /**
+ * @param string $table
+ * @param array $uniqueIndexes
+ * @param array $rows
+ * @param string $fname
+ * @return ResultWrapper
+ */
+ function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+ return $this->nativeReplace( $table, $rows, $fname );
+ }
+
+ /**
+ * Estimate rows in dataset
+ * Returns estimated count, based on EXPLAIN output
+ * Takes same arguments as Database::select()
+ *
+ * @param string|array $table
+ * @param string|array $vars
+ * @param string|array $conds
+ * @param string $fname
+ * @param string|array $options
+ * @return bool|int
+ */
+ public function estimateRowCount( $table, $vars = '*', $conds = '',
+ $fname = __METHOD__, $options = []
+ ) {
+ $options['EXPLAIN'] = true;
+ $res = $this->select( $table, $vars, $conds, $fname, $options );
+ if ( $res === false ) {
+ return false;
+ }
+ if ( !$this->numRows( $res ) ) {
+ return 0;
+ }
+
+ $rows = 1;
+ foreach ( $res as $plan ) {
+ $rows *= $plan->rows > 0 ? $plan->rows : 1; // avoid resetting to zero
+ }
+
+ return (int)$rows;
+ }
+
+ /**
+ * @param string $table
+ * @param string $field
+ * @return bool|MySQLField
+ */
+ function fieldInfo( $table, $field ) {
+ $table = $this->tableName( $table );
+ $res = $this->query( "SELECT * FROM $table LIMIT 1", __METHOD__, true );
+ if ( !$res ) {
+ return false;
+ }
+ $n = $this->mysqlNumFields( $res->result );
+ for ( $i = 0; $i < $n; $i++ ) {
+ $meta = $this->mysqlFetchField( $res->result, $i );
+ if ( $field == $meta->name ) {
+ return new MySQLField( $meta );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get column information from a result
+ *
+ * @param resource $res Raw result
+ * @param int $n
+ * @return stdClass
+ */
+ abstract protected function mysqlFetchField( $res, $n );
+
+ /**
+ * Get information about an index into an object
+ * Returns false if the index does not exist
+ *
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool|array|null False or null on failure
+ */
+ function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ # SHOW INDEX works in MySQL 3.23.58, but SHOW INDEXES does not.
+ # SHOW INDEX should work for 3.x and up:
+ # http://dev.mysql.com/doc/mysql/en/SHOW_INDEX.html
+ $table = $this->tableName( $table );
+ $index = $this->indexName( $index );
+
+ $sql = 'SHOW INDEX FROM ' . $table;
+ $res = $this->query( $sql, $fname );
+
+ if ( !$res ) {
+ return null;
+ }
+
+ $result = [];
+
+ foreach ( $res as $row ) {
+ if ( $row->Key_name == $index ) {
+ $result[] = $row;
+ }
+ }
+
+ return empty( $result ) ? false : $result;
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ function strencode( $s ) {
+ $sQuoted = $this->mysqlRealEscapeString( $s );
+
+ if ( $sQuoted === false ) {
+ $this->ping();
+ $sQuoted = $this->mysqlRealEscapeString( $s );
+ }
+
+ return $sQuoted;
+ }
+
+ /**
+ * @param string $s
+ * @return mixed
+ */
+ abstract protected function mysqlRealEscapeString( $s );
+
+ /**
+ * MySQL uses `backticks` for identifier quoting instead of the sql standard "double quotes".
+ *
+ * @param string $s
+ * @return string
+ */
+ public function addIdentifierQuotes( $s ) {
+ // Characters in the range \u0001-\uFFFF are valid in a quoted identifier
+ // Remove NUL bytes and escape backticks by doubling
+ return '`' . str_replace( [ "\0", '`' ], [ '', '``' ], $s ) . '`';
+ }
+
+ /**
+ * @param string $name
+ * @return bool
+ */
+ public function isQuotedIdentifier( $name ) {
+ return strlen( $name ) && $name[0] == '`' && substr( $name, -1, 1 ) == '`';
+ }
+
+ /**
+ * @return bool
+ */
+ function ping() {
+ $ping = $this->mysqlPing();
+ if ( $ping ) {
+ // Connection was good or lost but reconnected...
+ // @note: mysqlnd (php 5.6+) does not support this (PHP bug 52561)
+ return true;
+ }
+
+ // Try a full disconnect/reconnect cycle if ping() failed
+ $this->closeConnection();
+ $this->mOpened = false;
+ $this->mConn = false;
+ $this->open( $this->mServer, $this->mUser, $this->mPassword, $this->mDBname );
+
+ return true;
+ }
+
+ /**
+ * Ping a server connection or reconnect if there is no connection
+ *
+ * @return bool
+ */
+ abstract protected function mysqlPing();
+
+ function getLag() {
+ if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
+ return $this->getLagFromPtHeartbeat();
+ } else {
+ return $this->getLagFromSlaveStatus();
+ }
+ }
+
+ /**
+ * @return string
+ */
+ protected function getLagDetectionMethod() {
+ return $this->lagDetectionMethod;
+ }
+
+ /**
+ * @return bool|int
+ */
+ protected function getLagFromSlaveStatus() {
+ $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ );
+ $row = $res ? $res->fetchObject() : false;
+ if ( $row && strval( $row->Seconds_Behind_Master ) !== '' ) {
+ return intval( $row->Seconds_Behind_Master );
+ }
+
+ return false;
+ }
+
+ /**
+ * @return bool|float
+ */
+ protected function getLagFromPtHeartbeat() {
+ $options = $this->lagDetectionOptions;
+
+ if ( isset( $options['conds'] ) ) {
+ // Best method for multi-DC setups: use logical channel names
+ $data = $this->getHeartbeatData( $options['conds'] );
+ } else {
+ // Standard method: use master server ID (works with stock pt-heartbeat)
+ $masterInfo = $this->getMasterServerInfo();
+ if ( !$masterInfo ) {
+ wfLogDBError(
+ "Unable to query master of {db_server} for server ID",
+ $this->getLogContext( [
+ 'method' => __METHOD__
+ ] )
+ );
+
+ return false; // could not get master server ID
+ }
+
+ $conds = [ 'server_id' => intval( $masterInfo['serverId'] ) ];
+ $data = $this->getHeartbeatData( $conds );
+ }
+
+ list( $time, $nowUnix ) = $data;
+ if ( $time !== null ) {
+ // @time is in ISO format like "2015-09-25T16:48:10.000510"
+ $dateTime = new DateTime( $time, new DateTimeZone( 'UTC' ) );
+ $timeUnix = (int)$dateTime->format( 'U' ) + $dateTime->format( 'u' ) / 1e6;
+
+ return max( $nowUnix - $timeUnix, 0.0 );
+ }
+
+ wfLogDBError(
+ "Unable to find pt-heartbeat row for {db_server}",
+ $this->getLogContext( [
+ 'method' => __METHOD__
+ ] )
+ );
+
+ return false;
+ }
+
+ protected function getMasterServerInfo() {
+ $cache = $this->srvCache;
+ $key = $cache->makeGlobalKey(
+ 'mysql',
+ 'master-info',
+ // Using one key for all cluster slaves is preferable
+ $this->getLBInfo( 'clusterMasterHost' ) ?: $this->getServer()
+ );
+
+ return $cache->getWithSetCallback(
+ $key,
+ $cache::TTL_INDEFINITE,
+ function () use ( $cache, $key ) {
+ // Get and leave a lock key in place for a short period
+ if ( !$cache->lock( $key, 0, 10 ) ) {
+ return false; // avoid master connection spike slams
+ }
+
+ $conn = $this->getLazyMasterHandle();
+ if ( !$conn ) {
+ return false; // something is misconfigured
+ }
+
+ // Connect to and query the master; catch errors to avoid outages
+ try {
+ $res = $conn->query( 'SELECT @@server_id AS id', __METHOD__ );
+ $row = $res ? $res->fetchObject() : false;
+ $id = $row ? (int)$row->id : 0;
+ } catch ( DBError $e ) {
+ $id = 0;
+ }
+
+ // Cache the ID if it was retrieved
+ return $id ? [ 'serverId' => $id, 'asOf' => time() ] : false;
+ }
+ );
+ }
+
+ /**
+ * @param array $conds WHERE clause conditions to find a row
+ * @return array (heartbeat `ts` column value or null, UNIX timestamp) for the newest beat
+ * @see https://www.percona.com/doc/percona-toolkit/2.1/pt-heartbeat.html
+ */
+ protected function getHeartbeatData( array $conds ) {
+ $whereSQL = $this->makeList( $conds, LIST_AND );
+ // Use ORDER BY for channel based queries since that field might not be UNIQUE.
+ // Note: this would use "TIMESTAMPDIFF(MICROSECOND,ts,UTC_TIMESTAMP(6))" but the
+ // percision field is not supported in MySQL <= 5.5.
+ $res = $this->query(
+ "SELECT ts FROM heartbeat.heartbeat WHERE $whereSQL ORDER BY ts DESC LIMIT 1"
+ );
+ $row = $res ? $res->fetchObject() : false;
+
+ return [ $row ? $row->ts : null, microtime( true ) ];
+ }
+
+ public function getApproximateLagStatus() {
+ if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
+ // Disable caching since this is fast enough and we don't wan't
+ // to be *too* pessimistic by having both the cache TTL and the
+ // pt-heartbeat interval count as lag in getSessionLagStatus()
+ return parent::getApproximateLagStatus();
+ }
+
+ $key = $this->srvCache->makeGlobalKey( 'mysql-lag', $this->getServer() );
+ $approxLag = $this->srvCache->get( $key );
+ if ( !$approxLag ) {
+ $approxLag = parent::getApproximateLagStatus();
+ $this->srvCache->set( $key, $approxLag, 1 );
+ }
+
+ return $approxLag;
+ }
+
+ function masterPosWait( DBMasterPos $pos, $timeout ) {
+ if ( !( $pos instanceof MySQLMasterPos ) ) {
+ throw new InvalidArgumentException( "Position not an instance of MySQLMasterPos" );
+ }
+
+ if ( $this->lastKnownSlavePos && $this->lastKnownSlavePos->hasReached( $pos ) ) {
+ return 0;
+ }
+
+ # Commit any open transactions
+ $this->commit( __METHOD__, 'flush' );
+
+ # Call doQuery() directly, to avoid opening a transaction if DBO_TRX is set
+ $encFile = $this->addQuotes( $pos->file );
+ $encPos = intval( $pos->pos );
+ $res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" );
+
+ $row = $res ? $this->fetchRow( $res ) : false;
+ if ( !$row ) {
+ throw new DBExpectedError( $this, "Failed to query MASTER_POS_WAIT()" );
+ }
+
+ // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
+ $status = ( $row[0] !== null ) ? intval( $row[0] ) : null;
+ if ( $status === null ) {
+ // T126436: jobs programmed to wait on master positions might be referencing binlogs
+ // with an old master hostname. Such calls make MASTER_POS_WAIT() return null. Try
+ // to detect this and treat the slave as having reached the position; a proper master
+ // switchover already requires that the new master be caught up before the switch.
+ $slavePos = $this->getSlavePos();
+ if ( $slavePos && !$slavePos->channelsMatch( $pos ) ) {
+ $this->lastKnownSlavePos = $slavePos;
+ $status = 0;
+ }
+ } elseif ( $status >= 0 ) {
+ // Remember that this position was reached to save queries next time
+ $this->lastKnownSlavePos = $pos;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get the position of the master from SHOW SLAVE STATUS
+ *
+ * @return MySQLMasterPos|bool
+ */
+ function getSlavePos() {
+ $res = $this->query( 'SHOW SLAVE STATUS', 'DatabaseBase::getSlavePos' );
+ $row = $this->fetchObject( $res );
+
+ if ( $row ) {
+ $pos = isset( $row->Exec_master_log_pos )
+ ? $row->Exec_master_log_pos
+ : $row->Exec_Master_Log_Pos;
+
+ return new MySQLMasterPos( $row->Relay_Master_Log_File, $pos );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get the position of the master from SHOW MASTER STATUS
+ *
+ * @return MySQLMasterPos|bool
+ */
+ function getMasterPos() {
+ $res = $this->query( 'SHOW MASTER STATUS', 'DatabaseBase::getMasterPos' );
+ $row = $this->fetchObject( $res );
+
+ if ( $row ) {
+ return new MySQLMasterPos( $row->File, $row->Position );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param string $index
+ * @return string
+ */
+ function useIndexClause( $index ) {
+ return "FORCE INDEX (" . $this->indexName( $index ) . ")";
+ }
+
+ /**
+ * @return string
+ */
+ function lowPriorityOption() {
+ return 'LOW_PRIORITY';
+ }
+
+ /**
+ * @return string
+ */
+ public function getSoftwareLink() {
+ // MariaDB includes its name in its version string; this is how MariaDB's version of
+ // the mysql command-line client identifies MariaDB servers (see mariadb_connection()
+ // in libmysql/libmysql.c).
+ $version = $this->getServerVersion();
+ if ( strpos( $version, 'MariaDB' ) !== false || strpos( $version, '-maria-' ) !== false ) {
+ return '[{{int:version-db-mariadb-url}} MariaDB]';
+ }
+
+ // Percona Server's version suffix is not very distinctive, and @@version_comment
+ // doesn't give the necessary info for source builds, so assume the server is MySQL.
+ // (Even Percona's version of mysql doesn't try to make the distinction.)
+ return '[{{int:version-db-mysql-url}} MySQL]';
+ }
+
+ /**
+ * @return string
+ */
+ public function getServerVersion() {
+ // Not using mysql_get_server_info() or similar for consistency: in the handshake,
+ // MariaDB 10 adds the prefix "5.5.5-", and only some newer client libraries strip
+ // it off (see RPL_VERSION_HACK in include/mysql_com.h).
+ if ( $this->serverVersion === null ) {
+ $this->serverVersion = $this->selectField( '', 'VERSION()', '', __METHOD__ );
+ }
+ return $this->serverVersion;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function setSessionOptions( array $options ) {
+ if ( isset( $options['connTimeout'] ) ) {
+ $timeout = (int)$options['connTimeout'];
+ $this->query( "SET net_read_timeout=$timeout" );
+ $this->query( "SET net_write_timeout=$timeout" );
+ }
+ }
+
+ /**
+ * @param string $sql
+ * @param string $newLine
+ * @return bool
+ */
+ public function streamStatementEnd( &$sql, &$newLine ) {
+ if ( strtoupper( substr( $newLine, 0, 9 ) ) == 'DELIMITER' ) {
+ preg_match( '/^DELIMITER\s+(\S+)/', $newLine, $m );
+ $this->delimiter = $m[1];
+ $newLine = '';
+ }
+
+ return parent::streamStatementEnd( $sql, $newLine );
+ }
+
+ /**
+ * Check to see if a named lock is available. This is non-blocking.
+ *
+ * @param string $lockName Name of lock to poll
+ * @param string $method Name of method calling us
+ * @return bool
+ * @since 1.20
+ */
+ public function lockIsFree( $lockName, $method ) {
+ $lockName = $this->addQuotes( $this->makeLockName( $lockName ) );
+ $result = $this->query( "SELECT IS_FREE_LOCK($lockName) AS lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ return ( $row->lockstatus == 1 );
+ }
+
+ /**
+ * @param string $lockName
+ * @param string $method
+ * @param int $timeout
+ * @return bool
+ */
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ $lockName = $this->addQuotes( $this->makeLockName( $lockName ) );
+ $result = $this->query( "SELECT GET_LOCK($lockName, $timeout) AS lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ if ( $row->lockstatus == 1 ) {
+ parent::lock( $lockName, $method, $timeout ); // record
+ return true;
+ }
+
+ wfDebug( __METHOD__ . " failed to acquire lock\n" );
+
+ return false;
+ }
+
+ /**
+ * FROM MYSQL DOCS:
+ * http://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_release-lock
+ * @param string $lockName
+ * @param string $method
+ * @return bool
+ */
+ public function unlock( $lockName, $method ) {
+ $lockName = $this->addQuotes( $this->makeLockName( $lockName ) );
+ $result = $this->query( "SELECT RELEASE_LOCK($lockName) as lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ if ( $row->lockstatus == 1 ) {
+ parent::unlock( $lockName, $method ); // record
+ return true;
+ }
+
+ wfDebug( __METHOD__ . " failed to release lock\n" );
+
+ return false;
+ }
+
+ private function makeLockName( $lockName ) {
+ // http://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
+ // Newer version enforce a 64 char length limit.
+ return ( strlen( $lockName ) > 64 ) ? sha1( $lockName ) : $lockName;
+ }
+
+ public function namedLocksEnqueue() {
+ return true;
+ }
+
+ /**
+ * @param array $read
+ * @param array $write
+ * @param string $method
+ * @param bool $lowPriority
+ * @return bool
+ */
+ public function lockTables( $read, $write, $method, $lowPriority = true ) {
+ $items = [];
+
+ foreach ( $write as $table ) {
+ $tbl = $this->tableName( $table ) .
+ ( $lowPriority ? ' LOW_PRIORITY' : '' ) .
+ ' WRITE';
+ $items[] = $tbl;
+ }
+ foreach ( $read as $table ) {
+ $items[] = $this->tableName( $table ) . ' READ';
+ }
+ $sql = "LOCK TABLES " . implode( ',', $items );
+ $this->query( $sql, $method );
+
+ return true;
+ }
+
+ /**
+ * @param string $method
+ * @return bool
+ */
+ public function unlockTables( $method ) {
+ $this->query( "UNLOCK TABLES", $method );
+
+ return true;
+ }
+
+ /**
+ * Get search engine class. All subclasses of this
+ * need to implement this if they wish to use searching.
+ *
+ * @return string
+ */
+ public function getSearchEngine() {
+ return 'SearchMySQL';
+ }
+
+ /**
+ * @param bool $value
+ */
+ public function setBigSelects( $value = true ) {
+ if ( $value === 'default' ) {
+ if ( $this->mDefaultBigSelects === null ) {
+ # Function hasn't been called before so it must already be set to the default
+ return;
+ } else {
+ $value = $this->mDefaultBigSelects;
+ }
+ } elseif ( $this->mDefaultBigSelects === null ) {
+ $this->mDefaultBigSelects =
+ (bool)$this->selectField( false, '@@sql_big_selects', '', __METHOD__ );
+ }
+ $encValue = $value ? '1' : '0';
+ $this->query( "SET sql_big_selects=$encValue", __METHOD__ );
+ }
+
+ /**
+ * DELETE where the condition is a join. MySql uses multi-table deletes.
+ * @param string $delTable
+ * @param string $joinTable
+ * @param string $delVar
+ * @param string $joinVar
+ * @param array|string $conds
+ * @param bool|string $fname
+ * @throws DBUnexpectedError
+ * @return bool|ResultWrapper
+ */
+ function deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds, $fname = __METHOD__ ) {
+ if ( !$conds ) {
+ throw new DBUnexpectedError( $this, 'DatabaseBase::deleteJoin() called with empty $conds' );
+ }
+
+ $delTable = $this->tableName( $delTable );
+ $joinTable = $this->tableName( $joinTable );
+ $sql = "DELETE $delTable FROM $delTable, $joinTable WHERE $delVar=$joinVar ";
+
+ if ( $conds != '*' ) {
+ $sql .= ' AND ' . $this->makeList( $conds, LIST_AND );
+ }
+
+ return $this->query( $sql, $fname );
+ }
+
+ /**
+ * @param string $table
+ * @param array $rows
+ * @param array $uniqueIndexes
+ * @param array $set
+ * @param string $fname
+ * @return bool
+ */
+ public function upsert( $table, array $rows, array $uniqueIndexes,
+ array $set, $fname = __METHOD__
+ ) {
+ if ( !count( $rows ) ) {
+ return true; // nothing to do
+ }
+
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ $table = $this->tableName( $table );
+ $columns = array_keys( $rows[0] );
+
+ $sql = "INSERT INTO $table (" . implode( ',', $columns ) . ') VALUES ';
+ $rowTuples = [];
+ foreach ( $rows as $row ) {
+ $rowTuples[] = '(' . $this->makeList( $row ) . ')';
+ }
+ $sql .= implode( ',', $rowTuples );
+ $sql .= " ON DUPLICATE KEY UPDATE " . $this->makeList( $set, LIST_SET );
+
+ return (bool)$this->query( $sql, $fname );
+ }
+
+ /**
+ * Determines how long the server has been up
+ *
+ * @return int
+ */
+ function getServerUptime() {
+ $vars = $this->getMysqlStatus( 'Uptime' );
+
+ return (int)$vars['Uptime'];
+ }
+
+ /**
+ * Determines if the last failure was due to a deadlock
+ *
+ * @return bool
+ */
+ function wasDeadlock() {
+ return $this->lastErrno() == 1213;
+ }
+
+ /**
+ * Determines if the last failure was due to a lock timeout
+ *
+ * @return bool
+ */
+ function wasLockTimeout() {
+ return $this->lastErrno() == 1205;
+ }
+
+ /**
+ * Determines if the last query error was something that should be dealt
+ * with by pinging the connection and reissuing the query
+ *
+ * @return bool
+ */
+ function wasErrorReissuable() {
+ return $this->lastErrno() == 2013 || $this->lastErrno() == 2006;
+ }
+
+ /**
+ * Determines if the last failure was due to the database being read-only.
+ *
+ * @return bool
+ */
+ function wasReadOnlyError() {
+ return $this->lastErrno() == 1223 ||
+ ( $this->lastErrno() == 1290 && strpos( $this->lastError(), '--read-only' ) !== false );
+ }
+
+ function wasConnectionError( $errno ) {
+ return $errno == 2013 || $errno == 2006;
+ }
+
+ /**
+ * Get the underlying binding handle, mConn
+ *
+ * Makes sure that mConn is set (disconnects and ping() failure can unset it).
+ * This catches broken callers than catch and ignore disconnection exceptions.
+ * Unlike checking isOpen(), this is safe to call inside of open().
+ *
+ * @return resource|object
+ * @throws DBUnexpectedError
+ * @since 1.26
+ */
+ protected function getBindingHandle() {
+ if ( !$this->mConn ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'DB connection was already closed or the connection dropped.'
+ );
+ }
+
+ return $this->mConn;
+ }
+
+ /**
+ * @param string $oldName
+ * @param string $newName
+ * @param bool $temporary
+ * @param string $fname
+ * @return bool
+ */
+ function duplicateTableStructure( $oldName, $newName, $temporary = false, $fname = __METHOD__ ) {
+ $tmp = $temporary ? 'TEMPORARY ' : '';
+ $newName = $this->addIdentifierQuotes( $newName );
+ $oldName = $this->addIdentifierQuotes( $oldName );
+ $query = "CREATE $tmp TABLE $newName (LIKE $oldName)";
+
+ return $this->query( $query, $fname );
+ }
+
+ /**
+ * List all tables on the database
+ *
+ * @param string $prefix Only show tables with this prefix, e.g. mw_
+ * @param string $fname Calling function name
+ * @return array
+ */
+ function listTables( $prefix = null, $fname = __METHOD__ ) {
+ $result = $this->query( "SHOW TABLES", $fname );
+
+ $endArray = [];
+
+ foreach ( $result as $table ) {
+ $vars = get_object_vars( $table );
+ $table = array_pop( $vars );
+
+ if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
+ $endArray[] = $table;
+ }
+ }
+
+ return $endArray;
+ }
+
+ /**
+ * @param string $tableName
+ * @param string $fName
+ * @return bool|ResultWrapper
+ */
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ if ( !$this->tableExists( $tableName, $fName ) ) {
+ return false;
+ }
+
+ return $this->query( "DROP TABLE IF EXISTS " . $this->tableName( $tableName ), $fName );
+ }
+
+ /**
+ * @return array
+ */
+ protected function getDefaultSchemaVars() {
+ $vars = parent::getDefaultSchemaVars();
+ $vars['wgDBTableOptions'] = str_replace( 'TYPE', 'ENGINE', $GLOBALS['wgDBTableOptions'] );
+ $vars['wgDBTableOptions'] = str_replace(
+ 'CHARSET=mysql4',
+ 'CHARSET=binary',
+ $vars['wgDBTableOptions']
+ );
+
+ return $vars;
+ }
+
+ /**
+ * Get status information from SHOW STATUS in an associative array
+ *
+ * @param string $which
+ * @return array
+ */
+ function getMysqlStatus( $which = "%" ) {
+ $res = $this->query( "SHOW STATUS LIKE '{$which}'" );
+ $status = [];
+
+ foreach ( $res as $row ) {
+ $status[$row->Variable_name] = $row->Value;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Lists VIEWs in the database
+ *
+ * @param string $prefix Only show VIEWs with this prefix, eg.
+ * unit_test_, or $wgDBprefix. Default: null, would return all views.
+ * @param string $fname Name of calling function
+ * @return array
+ * @since 1.22
+ */
+ public function listViews( $prefix = null, $fname = __METHOD__ ) {
+
+ if ( !isset( $this->allViews ) ) {
+
+ // The name of the column containing the name of the VIEW
+ $propertyName = 'Tables_in_' . $this->mDBname;
+
+ // Query for the VIEWS
+ $result = $this->query( 'SHOW FULL TABLES WHERE TABLE_TYPE = "VIEW"' );
+ $this->allViews = [];
+ while ( ( $row = $this->fetchRow( $result ) ) !== false ) {
+ array_push( $this->allViews, $row[$propertyName] );
+ }
+ }
+
+ if ( is_null( $prefix ) || $prefix === '' ) {
+ return $this->allViews;
+ }
+
+ $filteredViews = [];
+ foreach ( $this->allViews as $viewName ) {
+ // Does the name of this VIEW start with the table-prefix?
+ if ( strpos( $viewName, $prefix ) === 0 ) {
+ array_push( $filteredViews, $viewName );
+ }
+ }
+
+ return $filteredViews;
+ }
+
+ /**
+ * Differentiates between a TABLE and a VIEW.
+ *
+ * @param string $name Name of the TABLE/VIEW to test
+ * @param string $prefix
+ * @return bool
+ * @since 1.22
+ */
+ public function isView( $name, $prefix = null ) {
+ return in_array( $name, $this->listViews( $prefix ) );
+ }
+}
+
+/**
+ * Utility class.
+ * @ingroup Database
+ */
+class MySQLField implements Field {
+ private $name, $tablename, $default, $max_length, $nullable,
+ $is_pk, $is_unique, $is_multiple, $is_key, $type, $binary,
+ $is_numeric, $is_blob, $is_unsigned, $is_zerofill;
+
+ function __construct( $info ) {
+ $this->name = $info->name;
+ $this->tablename = $info->table;
+ $this->default = $info->def;
+ $this->max_length = $info->max_length;
+ $this->nullable = !$info->not_null;
+ $this->is_pk = $info->primary_key;
+ $this->is_unique = $info->unique_key;
+ $this->is_multiple = $info->multiple_key;
+ $this->is_key = ( $this->is_pk || $this->is_unique || $this->is_multiple );
+ $this->type = $info->type;
+ $this->binary = isset( $info->binary ) ? $info->binary : false;
+ $this->is_numeric = isset( $info->numeric ) ? $info->numeric : false;
+ $this->is_blob = isset( $info->blob ) ? $info->blob : false;
+ $this->is_unsigned = isset( $info->unsigned ) ? $info->unsigned : false;
+ $this->is_zerofill = isset( $info->zerofill ) ? $info->zerofill : false;
+ }
+
+ /**
+ * @return string
+ */
+ function name() {
+ return $this->name;
+ }
+
+ /**
+ * @return string
+ */
+ function tableName() {
+ return $this->tablename;
+ }
+
+ /**
+ * @return string
+ */
+ function type() {
+ return $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ function isNullable() {
+ return $this->nullable;
+ }
+
+ function defaultValue() {
+ return $this->default;
+ }
+
+ /**
+ * @return bool
+ */
+ function isKey() {
+ return $this->is_key;
+ }
+
+ /**
+ * @return bool
+ */
+ function isMultipleKey() {
+ return $this->is_multiple;
+ }
+
+ /**
+ * @return bool
+ */
+ function isBinary() {
+ return $this->binary;
+ }
+
+ /**
+ * @return bool
+ */
+ function isNumeric() {
+ return $this->is_numeric;
+ }
+
+ /**
+ * @return bool
+ */
+ function isBlob() {
+ return $this->is_blob;
+ }
+
+ /**
+ * @return bool
+ */
+ function isUnsigned() {
+ return $this->is_unsigned;
+ }
+
+ /**
+ * @return bool
+ */
+ function isZerofill() {
+ return $this->is_zerofill;
+ }
+}
+
+class MySQLMasterPos implements DBMasterPos {
+ /** @var string */
+ public $file;
+ /** @var int Position */
+ public $pos;
+ /** @var float UNIX timestamp */
+ public $asOfTime = 0.0;
+
+ function __construct( $file, $pos ) {
+ $this->file = $file;
+ $this->pos = $pos;
+ $this->asOfTime = microtime( true );
+ }
+
+ function asOfTime() {
+ return $this->asOfTime;
+ }
+
+ function hasReached( DBMasterPos $pos ) {
+ if ( !( $pos instanceof self ) ) {
+ throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
+ }
+
+ $thisPos = $this->getCoordinates();
+ $thatPos = $pos->getCoordinates();
+
+ return ( $thisPos && $thatPos && $thisPos >= $thatPos );
+ }
+
+ function channelsMatch( DBMasterPos $pos ) {
+ if ( !( $pos instanceof self ) ) {
+ throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
+ }
+
+ $thisBinlog = $this->getBinlogName();
+ $thatBinlog = $pos->getBinlogName();
+
+ return ( $thisBinlog !== false && $thisBinlog === $thatBinlog );
+ }
+
+ function __toString() {
+ // e.g db1034-bin.000976/843431247
+ return "{$this->file}/{$this->pos}";
+ }
+
+ /**
+ * @return string|bool
+ */
+ protected function getBinlogName() {
+ $m = [];
+ if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', (string)$this, $m ) ) {
+ return $m[1];
+ }
+
+ return false;
+ }
+
+ /**
+ * @return array|bool (int, int)
+ */
+ protected function getCoordinates() {
+ $m = [];
+ if ( preg_match( '!\.(\d+)/(\d+)$!', (string)$this, $m ) ) {
+ return [ (int)$m[1], (int)$m[2] ];
+ }
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/db/DatabaseMysqli.php b/www/wiki/includes/db/DatabaseMysqli.php
new file mode 100644
index 00000000..d45805ad
--- /dev/null
+++ b/www/wiki/includes/db/DatabaseMysqli.php
@@ -0,0 +1,332 @@
+<?php
+/**
+ * This is the MySQLi database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Database abstraction object for PHP extension mysqli.
+ *
+ * @ingroup Database
+ * @since 1.22
+ * @see Database
+ */
+class DatabaseMysqli extends DatabaseMysqlBase {
+ /** @var mysqli */
+ protected $mConn;
+
+ /**
+ * @param string $sql
+ * @return resource
+ */
+ protected function doQuery( $sql ) {
+ $conn = $this->getBindingHandle();
+
+ if ( $this->bufferResults() ) {
+ $ret = $conn->query( $sql );
+ } else {
+ $ret = $conn->query( $sql, MYSQLI_USE_RESULT );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $realServer
+ * @return bool|mysqli
+ * @throws DBConnectionError
+ */
+ protected function mysqlConnect( $realServer ) {
+ global $wgDBmysql5;
+
+ # Avoid suppressed fatal error, which is very hard to track down
+ if ( !function_exists( 'mysqli_init' ) ) {
+ throw new DBConnectionError( $this, "MySQLi functions missing,"
+ . " have you compiled PHP with the --with-mysqli option?\n" );
+ }
+
+ // Other than mysql_connect, mysqli_real_connect expects an explicit port
+ // and socket parameters. So we need to parse the port and socket out of
+ // $realServer
+ $port = null;
+ $socket = null;
+ $hostAndPort = IP::splitHostAndPort( $realServer );
+ if ( $hostAndPort ) {
+ $realServer = $hostAndPort[0];
+ if ( $hostAndPort[1] ) {
+ $port = $hostAndPort[1];
+ }
+ } elseif ( substr_count( $realServer, ':' ) == 1 ) {
+ // If we have a colon and something that's not a port number
+ // inside the hostname, assume it's the socket location
+ $hostAndSocket = explode( ':', $realServer );
+ $realServer = $hostAndSocket[0];
+ $socket = $hostAndSocket[1];
+ }
+
+ $connFlags = 0;
+ if ( $this->mFlags & DBO_SSL ) {
+ $connFlags |= MYSQLI_CLIENT_SSL;
+ }
+ if ( $this->mFlags & DBO_COMPRESS ) {
+ $connFlags |= MYSQLI_CLIENT_COMPRESS;
+ }
+ if ( $this->mFlags & DBO_PERSISTENT ) {
+ $realServer = 'p:' . $realServer;
+ }
+
+ $mysqli = mysqli_init();
+ if ( $wgDBmysql5 ) {
+ // Tell the server we're communicating with it in UTF-8.
+ // This may engage various charset conversions.
+ $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'utf8' );
+ } else {
+ $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'binary' );
+ }
+ $mysqli->options( MYSQLI_OPT_CONNECT_TIMEOUT, 3 );
+
+ if ( $mysqli->real_connect( $realServer, $this->mUser,
+ $this->mPassword, $this->mDBname, $port, $socket, $connFlags )
+ ) {
+ return $mysqli;
+ }
+
+ return false;
+ }
+
+ protected function connectInitCharset() {
+ // already done in mysqlConnect()
+ return true;
+ }
+
+ /**
+ * @param string $charset
+ * @return bool
+ */
+ protected function mysqlSetCharset( $charset ) {
+ $conn = $this->getBindingHandle();
+
+ if ( method_exists( $conn, 'set_charset' ) ) {
+ return $conn->set_charset( $charset );
+ } else {
+ return $this->query( 'SET NAMES ' . $charset, __METHOD__ );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ protected function closeConnection() {
+ $conn = $this->getBindingHandle();
+
+ return $conn->close();
+ }
+
+ /**
+ * @return int
+ */
+ function insertId() {
+ $conn = $this->getBindingHandle();
+
+ return (int)$conn->insert_id;
+ }
+
+ /**
+ * @return int
+ */
+ function lastErrno() {
+ if ( $this->mConn ) {
+ return $this->mConn->errno;
+ } else {
+ return mysqli_connect_errno();
+ }
+ }
+
+ /**
+ * @return int
+ */
+ function affectedRows() {
+ $conn = $this->getBindingHandle();
+
+ return $conn->affected_rows;
+ }
+
+ /**
+ * @param string $db
+ * @return bool
+ */
+ function selectDB( $db ) {
+ $conn = $this->getBindingHandle();
+
+ $this->mDBname = $db;
+
+ return $conn->select_db( $db );
+ }
+
+ /**
+ * @param mysqli $res
+ * @return bool
+ */
+ protected function mysqlFreeResult( $res ) {
+ $res->free_result();
+
+ return true;
+ }
+
+ /**
+ * @param mysqli $res
+ * @return bool
+ */
+ protected function mysqlFetchObject( $res ) {
+ $object = $res->fetch_object();
+ if ( $object === null ) {
+ return false;
+ }
+
+ return $object;
+ }
+
+ /**
+ * @param mysqli $res
+ * @return bool
+ */
+ protected function mysqlFetchArray( $res ) {
+ $array = $res->fetch_array();
+ if ( $array === null ) {
+ return false;
+ }
+
+ return $array;
+ }
+
+ /**
+ * @param mysqli $res
+ * @return mixed
+ */
+ protected function mysqlNumRows( $res ) {
+ return $res->num_rows;
+ }
+
+ /**
+ * @param mysqli $res
+ * @return mixed
+ */
+ protected function mysqlNumFields( $res ) {
+ return $res->field_count;
+ }
+
+ /**
+ * @param mysqli $res
+ * @param int $n
+ * @return mixed
+ */
+ protected function mysqlFetchField( $res, $n ) {
+ $field = $res->fetch_field_direct( $n );
+
+ // Add missing properties to result (using flags property)
+ // which will be part of function mysql-fetch-field for backward compatibility
+ $field->not_null = $field->flags & MYSQLI_NOT_NULL_FLAG;
+ $field->primary_key = $field->flags & MYSQLI_PRI_KEY_FLAG;
+ $field->unique_key = $field->flags & MYSQLI_UNIQUE_KEY_FLAG;
+ $field->multiple_key = $field->flags & MYSQLI_MULTIPLE_KEY_FLAG;
+ $field->binary = $field->flags & MYSQLI_BINARY_FLAG;
+ $field->numeric = $field->flags & MYSQLI_NUM_FLAG;
+ $field->blob = $field->flags & MYSQLI_BLOB_FLAG;
+ $field->unsigned = $field->flags & MYSQLI_UNSIGNED_FLAG;
+ $field->zerofill = $field->flags & MYSQLI_ZEROFILL_FLAG;
+
+ return $field;
+ }
+
+ /**
+ * @param resource|ResultWrapper $res
+ * @param int $n
+ * @return mixed
+ */
+ protected function mysqlFieldName( $res, $n ) {
+ $field = $res->fetch_field_direct( $n );
+
+ return $field->name;
+ }
+
+ /**
+ * @param resource|ResultWrapper $res
+ * @param int $n
+ * @return mixed
+ */
+ protected function mysqlFieldType( $res, $n ) {
+ $field = $res->fetch_field_direct( $n );
+
+ return $field->type;
+ }
+
+ /**
+ * @param resource|ResultWrapper $res
+ * @param int $row
+ * @return mixed
+ */
+ protected function mysqlDataSeek( $res, $row ) {
+ return $res->data_seek( $row );
+ }
+
+ /**
+ * @param mysqli $conn Optional connection object
+ * @return string
+ */
+ protected function mysqlError( $conn = null ) {
+ if ( $conn === null ) {
+ return mysqli_connect_error();
+ } else {
+ return $conn->error;
+ }
+ }
+
+ /**
+ * Escapes special characters in a string for use in an SQL statement
+ * @param string $s
+ * @return string
+ */
+ protected function mysqlRealEscapeString( $s ) {
+ $conn = $this->getBindingHandle();
+
+ return $conn->real_escape_string( $s );
+ }
+
+ protected function mysqlPing() {
+ $conn = $this->getBindingHandle();
+
+ return $conn->ping();
+ }
+
+ /**
+ * Give an id for the connection
+ *
+ * mysql driver used resource id, but mysqli objects cannot be cast to string.
+ * @return string
+ */
+ public function __toString() {
+ if ( $this->mConn instanceof mysqli ) {
+ return (string)$this->mConn->thread_id;
+ } else {
+ // mConn might be false or something.
+ return (string)$this->mConn;
+ }
+ }
+}
diff --git a/www/wiki/includes/db/DatabaseOracle.php b/www/wiki/includes/db/DatabaseOracle.php
new file mode 100644
index 00000000..e2feb1fa
--- /dev/null
+++ b/www/wiki/includes/db/DatabaseOracle.php
@@ -0,0 +1,1370 @@
+<?php
+/**
+ * This is the Oracle database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\Blob;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\DBConnectionError;
+use Wikimedia\Rdbms\DBUnexpectedError;
+
+/**
+ * @ingroup Database
+ */
+class DatabaseOracle extends Database {
+ /** @var resource */
+ protected $mLastResult = null;
+
+ /** @var int The number of rows affected as an integer */
+ protected $mAffectedRows;
+
+ /** @var bool */
+ private $ignoreDupValOnIndex = false;
+
+ /** @var bool|array */
+ private $sequenceData = null;
+
+ /** @var string Character set for Oracle database */
+ private $defaultCharset = 'AL32UTF8';
+
+ /** @var array */
+ private $mFieldInfoCache = [];
+
+ function __construct( array $p ) {
+ global $wgDBprefix;
+
+ if ( $p['tablePrefix'] == 'get from global' ) {
+ $p['tablePrefix'] = $wgDBprefix;
+ }
+ $p['tablePrefix'] = strtoupper( $p['tablePrefix'] );
+ parent::__construct( $p );
+ Hooks::run( 'DatabaseOraclePostInit', [ $this ] );
+ }
+
+ function __destruct() {
+ if ( $this->mOpened ) {
+ MediaWiki\suppressWarnings();
+ $this->close();
+ MediaWiki\restoreWarnings();
+ }
+ }
+
+ function getType() {
+ return 'oracle';
+ }
+
+ function implicitGroupby() {
+ return false;
+ }
+
+ function implicitOrderby() {
+ return false;
+ }
+
+ /**
+ * Usually aborts on failure
+ * @param string $server
+ * @param string $user
+ * @param string $password
+ * @param string $dbName
+ * @throws DBConnectionError
+ * @return resource|null
+ */
+ function open( $server, $user, $password, $dbName ) {
+ global $wgDBOracleDRCP;
+ if ( !function_exists( 'oci_connect' ) ) {
+ throw new DBConnectionError(
+ $this,
+ "Oracle functions missing, have you compiled PHP with the --with-oci8 option?\n " .
+ "(Note: if you recently installed PHP, you may need to restart your webserver\n " .
+ "and database)\n" );
+ }
+
+ $this->close();
+ $this->mUser = $user;
+ $this->mPassword = $password;
+ // changed internal variables functions
+ // mServer now holds the TNS endpoint
+ // mDBname is schema name if different from username
+ if ( !$server ) {
+ // backward compatibillity (server used to be null and TNS was supplied in dbname)
+ $this->mServer = $dbName;
+ $this->mDBname = $user;
+ } else {
+ $this->mServer = $server;
+ if ( !$dbName ) {
+ $this->mDBname = $user;
+ } else {
+ $this->mDBname = $dbName;
+ }
+ }
+
+ if ( !strlen( $user ) ) { # e.g. the class is being loaded
+ return null;
+ }
+
+ if ( $wgDBOracleDRCP ) {
+ $this->setFlag( DBO_PERSISTENT );
+ }
+
+ $session_mode = $this->mFlags & DBO_SYSDBA ? OCI_SYSDBA : OCI_DEFAULT;
+
+ MediaWiki\suppressWarnings();
+ if ( $this->mFlags & DBO_PERSISTENT ) {
+ $this->mConn = oci_pconnect(
+ $this->mUser,
+ $this->mPassword,
+ $this->mServer,
+ $this->defaultCharset,
+ $session_mode
+ );
+ } elseif ( $this->mFlags & DBO_DEFAULT ) {
+ $this->mConn = oci_new_connect(
+ $this->mUser,
+ $this->mPassword,
+ $this->mServer,
+ $this->defaultCharset,
+ $session_mode
+ );
+ } else {
+ $this->mConn = oci_connect(
+ $this->mUser,
+ $this->mPassword,
+ $this->mServer,
+ $this->defaultCharset,
+ $session_mode
+ );
+ }
+ MediaWiki\restoreWarnings();
+
+ if ( $this->mUser != $this->mDBname ) {
+ // change current schema in session
+ $this->selectDB( $this->mDBname );
+ }
+
+ if ( !$this->mConn ) {
+ throw new DBConnectionError( $this, $this->lastError() );
+ }
+
+ $this->mOpened = true;
+
+ # removed putenv calls because they interfere with the system globaly
+ $this->doQuery( 'ALTER SESSION SET NLS_TIMESTAMP_FORMAT=\'DD-MM-YYYY HH24:MI:SS.FF6\'' );
+ $this->doQuery( 'ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT=\'DD-MM-YYYY HH24:MI:SS.FF6\'' );
+ $this->doQuery( 'ALTER SESSION SET NLS_NUMERIC_CHARACTERS=\'.,\'' );
+
+ return $this->mConn;
+ }
+
+ /**
+ * Closes a database connection, if it is open
+ * Returns success, true if already closed
+ * @return bool
+ */
+ protected function closeConnection() {
+ return oci_close( $this->mConn );
+ }
+
+ function execFlags() {
+ return $this->mTrxLevel ? OCI_NO_AUTO_COMMIT : OCI_COMMIT_ON_SUCCESS;
+ }
+
+ protected function doQuery( $sql ) {
+ wfDebug( "SQL: [$sql]\n" );
+ if ( !StringUtils::isUtf8( $sql ) ) {
+ throw new InvalidArgumentException( "SQL encoding is invalid\n$sql" );
+ }
+
+ // handle some oracle specifics
+ // remove AS column/table/subquery namings
+ if ( !$this->getFlag( DBO_DDLMODE ) ) {
+ $sql = preg_replace( '/ as /i', ' ', $sql );
+ }
+
+ // Oracle has issues with UNION clause if the statement includes LOB fields
+ // So we do a UNION ALL and then filter the results array with array_unique
+ $union_unique = ( preg_match( '/\/\* UNION_UNIQUE \*\/ /', $sql ) != 0 );
+ // EXPLAIN syntax in Oracle is EXPLAIN PLAN FOR and it return nothing
+ // you have to select data from plan table after explain
+ $explain_id = MWTimestamp::getLocalInstance()->format( 'dmYHis' );
+
+ $sql = preg_replace(
+ '/^EXPLAIN /',
+ 'EXPLAIN PLAN SET STATEMENT_ID = \'' . $explain_id . '\' FOR',
+ $sql,
+ 1,
+ $explain_count
+ );
+
+ MediaWiki\suppressWarnings();
+
+ $this->mLastResult = $stmt = oci_parse( $this->mConn, $sql );
+ if ( $stmt === false ) {
+ $e = oci_error( $this->mConn );
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+
+ return false;
+ }
+
+ if ( !oci_execute( $stmt, $this->execFlags() ) ) {
+ $e = oci_error( $stmt );
+ if ( !$this->ignoreDupValOnIndex || $e['code'] != '1' ) {
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+
+ return false;
+ }
+ }
+
+ MediaWiki\restoreWarnings();
+
+ if ( $explain_count > 0 ) {
+ return $this->doQuery( 'SELECT id, cardinality "ROWS" FROM plan_table ' .
+ 'WHERE statement_id = \'' . $explain_id . '\'' );
+ } elseif ( oci_statement_type( $stmt ) == 'SELECT' ) {
+ return new ORAResult( $this, $stmt, $union_unique );
+ } else {
+ $this->mAffectedRows = oci_num_rows( $stmt );
+
+ return true;
+ }
+ }
+
+ function queryIgnore( $sql, $fname = '' ) {
+ return $this->query( $sql, $fname, true );
+ }
+
+ /**
+ * Frees resources associated with the LOB descriptor
+ * @param ResultWrapper|ORAResult $res
+ */
+ function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ $res->free();
+ }
+
+ /**
+ * @param ResultWrapper|ORAResult $res
+ * @return mixed
+ */
+ function fetchObject( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $res->fetchObject();
+ }
+
+ /**
+ * @param ResultWrapper|ORAResult $res
+ * @return mixed
+ */
+ function fetchRow( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $res->fetchRow();
+ }
+
+ /**
+ * @param ResultWrapper|ORAResult $res
+ * @return int
+ */
+ function numRows( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $res->numRows();
+ }
+
+ /**
+ * @param ResultWrapper|ORAResult $res
+ * @return int
+ */
+ function numFields( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $res->numFields();
+ }
+
+ function fieldName( $stmt, $n ) {
+ return oci_field_name( $stmt, $n );
+ }
+
+ function insertId() {
+ $res = $this->query( "SELECT lastval_pkg.getLastval FROM dual" );
+ $row = $this->fetchRow( $res );
+ return is_null( $row[0] ) ? null : (int)$row[0];
+ }
+
+ /**
+ * @param mixed $res
+ * @param int $row
+ */
+ function dataSeek( $res, $row ) {
+ if ( $res instanceof ORAResult ) {
+ $res->seek( $row );
+ } else {
+ $res->result->seek( $row );
+ }
+ }
+
+ function lastError() {
+ if ( $this->mConn === false ) {
+ $e = oci_error();
+ } else {
+ $e = oci_error( $this->mConn );
+ }
+
+ return $e['message'];
+ }
+
+ function lastErrno() {
+ if ( $this->mConn === false ) {
+ $e = oci_error();
+ } else {
+ $e = oci_error( $this->mConn );
+ }
+
+ return $e['code'];
+ }
+
+ function affectedRows() {
+ return $this->mAffectedRows;
+ }
+
+ /**
+ * Returns information about an index
+ * If errors are explicitly ignored, returns NULL on failure
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool
+ */
+ function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ return false;
+ }
+
+ function indexUnique( $table, $index, $fname = __METHOD__ ) {
+ return false;
+ }
+
+ function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
+ if ( !count( $a ) ) {
+ return true;
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ if ( in_array( 'IGNORE', $options ) ) {
+ $this->ignoreDupValOnIndex = true;
+ }
+
+ if ( !is_array( reset( $a ) ) ) {
+ $a = [ $a ];
+ }
+
+ foreach ( $a as &$row ) {
+ $this->insertOneRow( $table, $row, $fname );
+ }
+ $retVal = true;
+
+ if ( in_array( 'IGNORE', $options ) ) {
+ $this->ignoreDupValOnIndex = false;
+ }
+
+ return $retVal;
+ }
+
+ private function fieldBindStatement( $table, $col, &$val, $includeCol = false ) {
+ $col_info = $this->fieldInfoMulti( $table, $col );
+ $col_type = $col_info != false ? $col_info->type() : 'CONSTANT';
+
+ $bind = '';
+ if ( is_numeric( $col ) ) {
+ $bind = $val;
+ $val = null;
+
+ return $bind;
+ } elseif ( $includeCol ) {
+ $bind = "$col = ";
+ }
+
+ if ( $val == '' && $val !== 0 && $col_type != 'BLOB' && $col_type != 'CLOB' ) {
+ $val = null;
+ }
+
+ if ( $val === 'NULL' ) {
+ $val = null;
+ }
+
+ if ( $val === null ) {
+ if ( $col_info != false && $col_info->isNullable() == 0 && $col_info->defaultValue() != null ) {
+ $bind .= 'DEFAULT';
+ } else {
+ $bind .= 'NULL';
+ }
+ } else {
+ $bind .= ':' . $col;
+ }
+
+ return $bind;
+ }
+
+ /**
+ * @param string $table
+ * @param array $row
+ * @param string $fname
+ * @return bool
+ * @throws DBUnexpectedError
+ */
+ private function insertOneRow( $table, $row, $fname ) {
+ global $wgContLang;
+
+ $table = $this->tableName( $table );
+ // "INSERT INTO tables (a, b, c)"
+ $sql = "INSERT INTO " . $table . " (" . implode( ',', array_keys( $row ) ) . ')';
+ $sql .= " VALUES (";
+
+ // for each value, append ":key"
+ $first = true;
+ foreach ( $row as $col => &$val ) {
+ if ( !$first ) {
+ $sql .= ', ';
+ } else {
+ $first = false;
+ }
+ if ( $this->isQuotedIdentifier( $val ) ) {
+ $sql .= $this->removeIdentifierQuotes( $val );
+ unset( $row[$col] );
+ } else {
+ $sql .= $this->fieldBindStatement( $table, $col, $val );
+ }
+ }
+ $sql .= ')';
+
+ $this->mLastResult = $stmt = oci_parse( $this->mConn, $sql );
+ if ( $stmt === false ) {
+ $e = oci_error( $this->mConn );
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+
+ return false;
+ }
+ foreach ( $row as $col => &$val ) {
+ $col_info = $this->fieldInfoMulti( $table, $col );
+ $col_type = $col_info != false ? $col_info->type() : 'CONSTANT';
+
+ if ( $val === null ) {
+ // do nothing ... null was inserted in statement creation
+ } elseif ( $col_type != 'BLOB' && $col_type != 'CLOB' ) {
+ if ( is_object( $val ) ) {
+ $val = $val->fetch();
+ }
+
+ // backward compatibility
+ if ( preg_match( '/^timestamp.*/i', $col_type ) == 1 && strtolower( $val ) == 'infinity' ) {
+ $val = $this->getInfinity();
+ }
+
+ $val = ( $wgContLang != null ) ? $wgContLang->checkTitleEncoding( $val ) : $val;
+ if ( oci_bind_by_name( $stmt, ":$col", $val, -1, SQLT_CHR ) === false ) {
+ $e = oci_error( $stmt );
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+
+ return false;
+ }
+ } else {
+ /** @var OCI_Lob[] $lob */
+ $lob[$col] = oci_new_descriptor( $this->mConn, OCI_D_LOB );
+ if ( $lob[$col] === false ) {
+ $e = oci_error( $stmt );
+ throw new DBUnexpectedError( $this, "Cannot create LOB descriptor: " . $e['message'] );
+ }
+
+ if ( is_object( $val ) ) {
+ $val = $val->fetch();
+ }
+
+ if ( $col_type == 'BLOB' ) {
+ $lob[$col]->writeTemporary( $val, OCI_TEMP_BLOB );
+ oci_bind_by_name( $stmt, ":$col", $lob[$col], -1, OCI_B_BLOB );
+ } else {
+ $lob[$col]->writeTemporary( $val, OCI_TEMP_CLOB );
+ oci_bind_by_name( $stmt, ":$col", $lob[$col], -1, OCI_B_CLOB );
+ }
+ }
+ }
+
+ MediaWiki\suppressWarnings();
+
+ if ( oci_execute( $stmt, $this->execFlags() ) === false ) {
+ $e = oci_error( $stmt );
+ if ( !$this->ignoreDupValOnIndex || $e['code'] != '1' ) {
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+
+ return false;
+ } else {
+ $this->mAffectedRows = oci_num_rows( $stmt );
+ }
+ } else {
+ $this->mAffectedRows = oci_num_rows( $stmt );
+ }
+
+ MediaWiki\restoreWarnings();
+
+ if ( isset( $lob ) ) {
+ foreach ( $lob as $lob_v ) {
+ $lob_v->free();
+ }
+ }
+
+ if ( !$this->mTrxLevel ) {
+ oci_commit( $this->mConn );
+ }
+
+ return oci_free_statement( $stmt );
+ }
+
+ function nativeInsertSelect( $destTable, $srcTable, $varMap, $conds, $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = [], $selectJoinConds = []
+ ) {
+ $destTable = $this->tableName( $destTable );
+
+ $sequenceData = $this->getSequenceData( $destTable );
+ if ( $sequenceData !== false &&
+ !isset( $varMap[$sequenceData['column']] )
+ ) {
+ $varMap[$sequenceData['column']] = 'GET_SEQUENCE_VALUE(\'' . $sequenceData['sequence'] . '\')';
+ }
+
+ // count-alias subselect fields to avoid abigious definition errors
+ $i = 0;
+ foreach ( $varMap as &$val ) {
+ $val = $val . ' field' . ( $i++ );
+ }
+
+ $selectSql = $this->selectSQLText(
+ $srcTable,
+ array_values( $varMap ),
+ $conds,
+ $fname,
+ $selectOptions,
+ $selectJoinConds
+ );
+
+ $sql = "INSERT INTO $destTable (" . implode( ',', array_keys( $varMap ) ) . ') ' . $selectSql;
+
+ if ( in_array( 'IGNORE', $insertOptions ) ) {
+ $this->ignoreDupValOnIndex = true;
+ }
+
+ $retval = $this->query( $sql, $fname );
+
+ if ( in_array( 'IGNORE', $insertOptions ) ) {
+ $this->ignoreDupValOnIndex = false;
+ }
+
+ return $retval;
+ }
+
+ public function upsert( $table, array $rows, array $uniqueIndexes, array $set,
+ $fname = __METHOD__
+ ) {
+ if ( !count( $rows ) ) {
+ return true; // nothing to do
+ }
+
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ $sequenceData = $this->getSequenceData( $table );
+ if ( $sequenceData !== false ) {
+ // add sequence column to each list of columns, when not set
+ foreach ( $rows as &$row ) {
+ if ( !isset( $row[$sequenceData['column']] ) ) {
+ $row[$sequenceData['column']] =
+ $this->addIdentifierQuotes( 'GET_SEQUENCE_VALUE(\'' .
+ $sequenceData['sequence'] . '\')' );
+ }
+ }
+ }
+
+ return parent::upsert( $table, $rows, $uniqueIndexes, $set, $fname );
+ }
+
+ function tableName( $name, $format = 'quoted' ) {
+ /*
+ Replace reserved words with better ones
+ Using uppercase because that's the only way Oracle can handle
+ quoted tablenames
+ */
+ switch ( $name ) {
+ case 'user':
+ $name = 'MWUSER';
+ break;
+ case 'text':
+ $name = 'PAGECONTENT';
+ break;
+ }
+
+ return strtoupper( parent::tableName( $name, $format ) );
+ }
+
+ function tableNameInternal( $name ) {
+ $name = $this->tableName( $name );
+
+ return preg_replace( '/.*\.(.*)/', '$1', $name );
+ }
+
+ /**
+ * Return sequence_name if table has a sequence
+ *
+ * @param string $table
+ * @return bool
+ */
+ private function getSequenceData( $table ) {
+ if ( $this->sequenceData == null ) {
+ $result = $this->doQuery( "SELECT lower(asq.sequence_name),
+ lower(atc.table_name),
+ lower(atc.column_name)
+ FROM all_sequences asq, all_tab_columns atc
+ WHERE decode(
+ atc.table_name,
+ '{$this->mTablePrefix}MWUSER',
+ '{$this->mTablePrefix}USER',
+ atc.table_name
+ ) || '_' ||
+ atc.column_name || '_SEQ' = '{$this->mTablePrefix}' || asq.sequence_name
+ AND asq.sequence_owner = upper('{$this->mDBname}')
+ AND atc.owner = upper('{$this->mDBname}')" );
+
+ while ( ( $row = $result->fetchRow() ) !== false ) {
+ $this->sequenceData[$row[1]] = [
+ 'sequence' => $row[0],
+ 'column' => $row[2]
+ ];
+ }
+ }
+ $table = strtolower( $this->removeIdentifierQuotes( $this->tableName( $table ) ) );
+
+ return ( isset( $this->sequenceData[$table] ) ) ? $this->sequenceData[$table] : false;
+ }
+
+ /**
+ * Returns the size of a text field, or -1 for "unlimited"
+ *
+ * @param string $table
+ * @param string $field
+ * @return mixed
+ */
+ function textFieldSize( $table, $field ) {
+ $fieldInfoData = $this->fieldInfo( $table, $field );
+
+ return $fieldInfoData->maxLength();
+ }
+
+ function limitResult( $sql, $limit, $offset = false ) {
+ if ( $offset === false ) {
+ $offset = 0;
+ }
+
+ return "SELECT * FROM ($sql) WHERE rownum >= (1 + $offset) AND rownum < (1 + $limit + $offset)";
+ }
+
+ function encodeBlob( $b ) {
+ return new Blob( $b );
+ }
+
+ function decodeBlob( $b ) {
+ if ( $b instanceof Blob ) {
+ $b = $b->fetch();
+ }
+
+ return $b;
+ }
+
+ function unionQueries( $sqls, $all ) {
+ $glue = ' UNION ALL ';
+
+ return 'SELECT * ' . ( $all ? '' : '/* UNION_UNIQUE */ ' ) .
+ 'FROM (' . implode( $glue, $sqls ) . ')';
+ }
+
+ function wasDeadlock() {
+ return $this->lastErrno() == 'OCI-00060';
+ }
+
+ function duplicateTableStructure( $oldName, $newName, $temporary = false,
+ $fname = __METHOD__
+ ) {
+ $temporary = $temporary ? 'TRUE' : 'FALSE';
+
+ $newName = strtoupper( $newName );
+ $oldName = strtoupper( $oldName );
+
+ $tabName = substr( $newName, strlen( $this->mTablePrefix ) );
+ $oldPrefix = substr( $oldName, 0, strlen( $oldName ) - strlen( $tabName ) );
+ $newPrefix = strtoupper( $this->mTablePrefix );
+
+ return $this->doQuery( "BEGIN DUPLICATE_TABLE( '$tabName', " .
+ "'$oldPrefix', '$newPrefix', $temporary ); END;" );
+ }
+
+ function listTables( $prefix = null, $fname = __METHOD__ ) {
+ $listWhere = '';
+ if ( !empty( $prefix ) ) {
+ $listWhere = ' AND table_name LIKE \'' . strtoupper( $prefix ) . '%\'';
+ }
+
+ $owner = strtoupper( $this->mDBname );
+ $result = $this->doQuery( "SELECT table_name FROM all_tables " .
+ "WHERE owner='$owner' AND table_name NOT LIKE '%!_IDX\$_' ESCAPE '!' $listWhere" );
+
+ // dirty code ... i know
+ $endArray = [];
+ $endArray[] = strtoupper( $prefix . 'MWUSER' );
+ $endArray[] = strtoupper( $prefix . 'PAGE' );
+ $endArray[] = strtoupper( $prefix . 'IMAGE' );
+ $fixedOrderTabs = $endArray;
+ while ( ( $row = $result->fetchRow() ) !== false ) {
+ if ( !in_array( $row['table_name'], $fixedOrderTabs ) ) {
+ $endArray[] = $row['table_name'];
+ }
+ }
+
+ return $endArray;
+ }
+
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ $tableName = $this->tableName( $tableName );
+ if ( !$this->tableExists( $tableName ) ) {
+ return false;
+ }
+
+ return $this->doQuery( "DROP TABLE $tableName CASCADE CONSTRAINTS PURGE" );
+ }
+
+ function timestamp( $ts = 0 ) {
+ return wfTimestamp( TS_ORACLE, $ts );
+ }
+
+ /**
+ * Return aggregated value function call
+ *
+ * @param array $valuedata
+ * @param string $valuename
+ * @return mixed
+ */
+ public function aggregateValue( $valuedata, $valuename = 'value' ) {
+ return $valuedata;
+ }
+
+ /**
+ * @return string Wikitext of a link to the server software's web site
+ */
+ public function getSoftwareLink() {
+ return '[{{int:version-db-oracle-url}} Oracle]';
+ }
+
+ /**
+ * @return string Version information from the database
+ */
+ function getServerVersion() {
+ // better version number, fallback on driver
+ $rset = $this->doQuery(
+ 'SELECT version FROM product_component_version ' .
+ 'WHERE UPPER(product) LIKE \'ORACLE DATABASE%\''
+ );
+ $row = $rset->fetchRow();
+ if ( !$row ) {
+ return oci_server_version( $this->mConn );
+ }
+
+ return $row['version'];
+ }
+
+ /**
+ * Query whether a given index exists
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool
+ */
+ function indexExists( $table, $index, $fname = __METHOD__ ) {
+ $table = $this->tableName( $table );
+ $table = strtoupper( $this->removeIdentifierQuotes( $table ) );
+ $index = strtoupper( $index );
+ $owner = strtoupper( $this->mDBname );
+ $sql = "SELECT 1 FROM all_indexes WHERE owner='$owner' AND index_name='{$table}_{$index}'";
+ $res = $this->doQuery( $sql );
+ if ( $res ) {
+ $count = $res->numRows();
+ $res->free();
+ } else {
+ $count = 0;
+ }
+
+ return $count != 0;
+ }
+
+ /**
+ * Query whether a given table exists (in the given schema, or the default mw one if not given)
+ * @param string $table
+ * @param string $fname
+ * @return bool
+ */
+ function tableExists( $table, $fname = __METHOD__ ) {
+ $table = $this->tableName( $table );
+ $table = $this->addQuotes( strtoupper( $this->removeIdentifierQuotes( $table ) ) );
+ $owner = $this->addQuotes( strtoupper( $this->mDBname ) );
+ $sql = "SELECT 1 FROM all_tables WHERE owner=$owner AND table_name=$table";
+ $res = $this->doQuery( $sql );
+ if ( $res && $res->numRows() > 0 ) {
+ $exists = true;
+ } else {
+ $exists = false;
+ }
+
+ $res->free();
+
+ return $exists;
+ }
+
+ /**
+ * Function translates mysql_fetch_field() functionality on ORACLE.
+ * Caching is present for reducing query time.
+ * For internal calls. Use fieldInfo for normal usage.
+ * Returns false if the field doesn't exist
+ *
+ * @param array|string $table
+ * @param string $field
+ * @return ORAField|ORAResult|false
+ */
+ private function fieldInfoMulti( $table, $field ) {
+ $field = strtoupper( $field );
+ if ( is_array( $table ) ) {
+ $table = array_map( [ $this, 'tableNameInternal' ], $table );
+ $tableWhere = 'IN (';
+ foreach ( $table as &$singleTable ) {
+ $singleTable = $this->removeIdentifierQuotes( $singleTable );
+ if ( isset( $this->mFieldInfoCache["$singleTable.$field"] ) ) {
+ return $this->mFieldInfoCache["$singleTable.$field"];
+ }
+ $tableWhere .= '\'' . $singleTable . '\',';
+ }
+ $tableWhere = rtrim( $tableWhere, ',' ) . ')';
+ } else {
+ $table = $this->removeIdentifierQuotes( $this->tableNameInternal( $table ) );
+ if ( isset( $this->mFieldInfoCache["$table.$field"] ) ) {
+ return $this->mFieldInfoCache["$table.$field"];
+ }
+ $tableWhere = '= \'' . $table . '\'';
+ }
+
+ $fieldInfoStmt = oci_parse(
+ $this->mConn,
+ 'SELECT * FROM wiki_field_info_full WHERE table_name ' .
+ $tableWhere . ' and column_name = \'' . $field . '\''
+ );
+ if ( oci_execute( $fieldInfoStmt, $this->execFlags() ) === false ) {
+ $e = oci_error( $fieldInfoStmt );
+ $this->reportQueryError( $e['message'], $e['code'], 'fieldInfo QUERY', __METHOD__ );
+
+ return false;
+ }
+ $res = new ORAResult( $this, $fieldInfoStmt );
+ if ( $res->numRows() == 0 ) {
+ if ( is_array( $table ) ) {
+ foreach ( $table as &$singleTable ) {
+ $this->mFieldInfoCache["$singleTable.$field"] = false;
+ }
+ } else {
+ $this->mFieldInfoCache["$table.$field"] = false;
+ }
+ $fieldInfoTemp = null;
+ } else {
+ $fieldInfoTemp = new ORAField( $res->fetchRow() );
+ $table = $fieldInfoTemp->tableName();
+ $this->mFieldInfoCache["$table.$field"] = $fieldInfoTemp;
+ }
+ $res->free();
+
+ return $fieldInfoTemp;
+ }
+
+ /**
+ * @throws DBUnexpectedError
+ * @param string $table
+ * @param string $field
+ * @return ORAField
+ */
+ function fieldInfo( $table, $field ) {
+ if ( is_array( $table ) ) {
+ throw new DBUnexpectedError( $this, 'DatabaseOracle::fieldInfo called with table array!' );
+ }
+
+ return $this->fieldInfoMulti( $table, $field );
+ }
+
+ protected function doBegin( $fname = __METHOD__ ) {
+ $this->mTrxLevel = 1;
+ $this->doQuery( 'SET CONSTRAINTS ALL DEFERRED' );
+ }
+
+ protected function doCommit( $fname = __METHOD__ ) {
+ if ( $this->mTrxLevel ) {
+ $ret = oci_commit( $this->mConn );
+ if ( !$ret ) {
+ throw new DBUnexpectedError( $this, $this->lastError() );
+ }
+ $this->mTrxLevel = 0;
+ $this->doQuery( 'SET CONSTRAINTS ALL IMMEDIATE' );
+ }
+ }
+
+ protected function doRollback( $fname = __METHOD__ ) {
+ if ( $this->mTrxLevel ) {
+ oci_rollback( $this->mConn );
+ $this->mTrxLevel = 0;
+ $this->doQuery( 'SET CONSTRAINTS ALL IMMEDIATE' );
+ }
+ }
+
+ function sourceStream(
+ $fp,
+ callable $lineCallback = null,
+ callable $resultCallback = null,
+ $fname = __METHOD__, callable $inputCallback = null
+ ) {
+ $cmd = '';
+ $done = false;
+ $dollarquote = false;
+
+ $replacements = [];
+ // Defines must comply with ^define\s*([^\s=]*)\s*=\s?'\{\$([^\}]*)\}';
+ while ( !feof( $fp ) ) {
+ if ( $lineCallback ) {
+ call_user_func( $lineCallback );
+ }
+ $line = trim( fgets( $fp, 1024 ) );
+ $sl = strlen( $line ) - 1;
+
+ if ( $sl < 0 ) {
+ continue;
+ }
+ if ( '-' == $line[0] && '-' == $line[1] ) {
+ continue;
+ }
+
+ // Allow dollar quoting for function declarations
+ if ( substr( $line, 0, 8 ) == '/*$mw$*/' ) {
+ if ( $dollarquote ) {
+ $dollarquote = false;
+ $line = str_replace( '/*$mw$*/', '', $line ); // remove dollarquotes
+ $done = true;
+ } else {
+ $dollarquote = true;
+ }
+ } elseif ( !$dollarquote ) {
+ if ( ';' == $line[$sl] && ( $sl < 2 || ';' != $line[$sl - 1] ) ) {
+ $done = true;
+ $line = substr( $line, 0, $sl );
+ }
+ }
+
+ if ( $cmd != '' ) {
+ $cmd .= ' ';
+ }
+ $cmd .= "$line\n";
+
+ if ( $done ) {
+ $cmd = str_replace( ';;', ";", $cmd );
+ if ( strtolower( substr( $cmd, 0, 6 ) ) == 'define' ) {
+ if ( preg_match( '/^define\s*([^\s=]*)\s*=\s*\'\{\$([^\}]*)\}\'/', $cmd, $defines ) ) {
+ $replacements[$defines[2]] = $defines[1];
+ }
+ } else {
+ foreach ( $replacements as $mwVar => $scVar ) {
+ $cmd = str_replace( '&' . $scVar . '.', '`{$' . $mwVar . '}`', $cmd );
+ }
+
+ $cmd = $this->replaceVars( $cmd );
+ if ( $inputCallback ) {
+ call_user_func( $inputCallback, $cmd );
+ }
+ $res = $this->doQuery( $cmd );
+ if ( $resultCallback ) {
+ call_user_func( $resultCallback, $res, $this );
+ }
+
+ if ( false === $res ) {
+ $err = $this->lastError();
+
+ return "Query \"{$cmd}\" failed with error code \"$err\".\n";
+ }
+ }
+
+ $cmd = '';
+ $done = false;
+ }
+ }
+
+ return true;
+ }
+
+ function selectDB( $db ) {
+ $this->mDBname = $db;
+ if ( $db == null || $db == $this->mUser ) {
+ return true;
+ }
+ $sql = 'ALTER SESSION SET CURRENT_SCHEMA=' . strtoupper( $db );
+ $stmt = oci_parse( $this->mConn, $sql );
+ MediaWiki\suppressWarnings();
+ $success = oci_execute( $stmt );
+ MediaWiki\restoreWarnings();
+ if ( !$success ) {
+ $e = oci_error( $stmt );
+ if ( $e['code'] != '1435' ) {
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ function strencode( $s ) {
+ return str_replace( "'", "''", $s );
+ }
+
+ function addQuotes( $s ) {
+ global $wgContLang;
+ if ( isset( $wgContLang->mLoaded ) && $wgContLang->mLoaded ) {
+ $s = $wgContLang->checkTitleEncoding( $s );
+ }
+
+ return "'" . $this->strencode( $s ) . "'";
+ }
+
+ public function addIdentifierQuotes( $s ) {
+ if ( !$this->getFlag( DBO_DDLMODE ) ) {
+ $s = '/*Q*/' . $s;
+ }
+
+ return $s;
+ }
+
+ public function removeIdentifierQuotes( $s ) {
+ return strpos( $s, '/*Q*/' ) === false ? $s : substr( $s, 5 );
+ }
+
+ public function isQuotedIdentifier( $s ) {
+ return strpos( $s, '/*Q*/' ) !== false;
+ }
+
+ private function wrapFieldForWhere( $table, &$col, &$val ) {
+ global $wgContLang;
+
+ $col_info = $this->fieldInfoMulti( $table, $col );
+ $col_type = $col_info != false ? $col_info->type() : 'CONSTANT';
+ if ( $col_type == 'CLOB' ) {
+ $col = 'TO_CHAR(' . $col . ')';
+ $val = $wgContLang->checkTitleEncoding( $val );
+ } elseif ( $col_type == 'VARCHAR2' ) {
+ $val = $wgContLang->checkTitleEncoding( $val );
+ }
+ }
+
+ private function wrapConditionsForWhere( $table, $conds, $parentCol = null ) {
+ $conds2 = [];
+ foreach ( $conds as $col => $val ) {
+ if ( is_array( $val ) ) {
+ $conds2[$col] = $this->wrapConditionsForWhere( $table, $val, $col );
+ } else {
+ if ( is_numeric( $col ) && $parentCol != null ) {
+ $this->wrapFieldForWhere( $table, $parentCol, $val );
+ } else {
+ $this->wrapFieldForWhere( $table, $col, $val );
+ }
+ $conds2[$col] = $val;
+ }
+ }
+
+ return $conds2;
+ }
+
+ function selectRow( $table, $vars, $conds, $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ if ( is_array( $conds ) ) {
+ $conds = $this->wrapConditionsForWhere( $table, $conds );
+ }
+
+ return parent::selectRow( $table, $vars, $conds, $fname, $options, $join_conds );
+ }
+
+ /**
+ * Returns an optional USE INDEX clause to go after the table, and a
+ * string to go at the end of the query
+ *
+ * @param array $options An associative array of options to be turned into
+ * an SQL query, valid keys are listed in the function.
+ * @return array
+ */
+ function makeSelectOptions( $options ) {
+ $preLimitTail = $postLimitTail = '';
+ $startOpts = '';
+
+ $noKeyOptions = [];
+ foreach ( $options as $key => $option ) {
+ if ( is_numeric( $key ) ) {
+ $noKeyOptions[$option] = true;
+ }
+ }
+
+ $preLimitTail .= $this->makeGroupByWithHaving( $options );
+
+ $preLimitTail .= $this->makeOrderBy( $options );
+
+ if ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
+ $postLimitTail .= ' FOR UPDATE';
+ }
+
+ if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
+ $startOpts .= 'DISTINCT';
+ }
+
+ if ( isset( $options['USE INDEX'] ) && !is_array( $options['USE INDEX'] ) ) {
+ $useIndex = $this->useIndexClause( $options['USE INDEX'] );
+ } else {
+ $useIndex = '';
+ }
+
+ if ( isset( $options['IGNORE INDEX'] ) && !is_array( $options['IGNORE INDEX'] ) ) {
+ $ignoreIndex = $this->ignoreIndexClause( $options['IGNORE INDEX'] );
+ } else {
+ $ignoreIndex = '';
+ }
+
+ return [ $startOpts, $useIndex, $preLimitTail, $postLimitTail, $ignoreIndex ];
+ }
+
+ public function delete( $table, $conds, $fname = __METHOD__ ) {
+ if ( is_array( $conds ) ) {
+ $conds = $this->wrapConditionsForWhere( $table, $conds );
+ }
+ // a hack for deleting pages, users and images (which have non-nullable FKs)
+ // all deletions on these tables have transactions so final failure rollbacks these updates
+ $table = $this->tableName( $table );
+ if ( $table == $this->tableName( 'user' ) ) {
+ $this->update( 'archive', [ 'ar_user' => 0 ],
+ [ 'ar_user' => $conds['user_id'] ], $fname );
+ $this->update( 'ipblocks', [ 'ipb_user' => 0 ],
+ [ 'ipb_user' => $conds['user_id'] ], $fname );
+ $this->update( 'image', [ 'img_user' => 0 ],
+ [ 'img_user' => $conds['user_id'] ], $fname );
+ $this->update( 'oldimage', [ 'oi_user' => 0 ],
+ [ 'oi_user' => $conds['user_id'] ], $fname );
+ $this->update( 'filearchive', [ 'fa_deleted_user' => 0 ],
+ [ 'fa_deleted_user' => $conds['user_id'] ], $fname );
+ $this->update( 'filearchive', [ 'fa_user' => 0 ],
+ [ 'fa_user' => $conds['user_id'] ], $fname );
+ $this->update( 'uploadstash', [ 'us_user' => 0 ],
+ [ 'us_user' => $conds['user_id'] ], $fname );
+ $this->update( 'recentchanges', [ 'rc_user' => 0 ],
+ [ 'rc_user' => $conds['user_id'] ], $fname );
+ $this->update( 'logging', [ 'log_user' => 0 ],
+ [ 'log_user' => $conds['user_id'] ], $fname );
+ } elseif ( $table == $this->tableName( 'image' ) ) {
+ $this->update( 'oldimage', [ 'oi_name' => 0 ],
+ [ 'oi_name' => $conds['img_name'] ], $fname );
+ }
+
+ return parent::delete( $table, $conds, $fname );
+ }
+
+ /**
+ * @param string $table
+ * @param array $values
+ * @param array $conds
+ * @param string $fname
+ * @param array $options
+ * @return bool
+ * @throws DBUnexpectedError
+ */
+ function update( $table, $values, $conds, $fname = __METHOD__, $options = [] ) {
+ global $wgContLang;
+
+ $table = $this->tableName( $table );
+ $opts = $this->makeUpdateOptions( $options );
+ $sql = "UPDATE $opts $table SET ";
+
+ $first = true;
+ foreach ( $values as $col => &$val ) {
+ $sqlSet = $this->fieldBindStatement( $table, $col, $val, true );
+
+ if ( !$first ) {
+ $sqlSet = ', ' . $sqlSet;
+ } else {
+ $first = false;
+ }
+ $sql .= $sqlSet;
+ }
+
+ if ( $conds !== [] && $conds !== '*' ) {
+ $conds = $this->wrapConditionsForWhere( $table, $conds );
+ $sql .= ' WHERE ' . $this->makeList( $conds, LIST_AND );
+ }
+
+ $this->mLastResult = $stmt = oci_parse( $this->mConn, $sql );
+ if ( $stmt === false ) {
+ $e = oci_error( $this->mConn );
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+
+ return false;
+ }
+ foreach ( $values as $col => &$val ) {
+ $col_info = $this->fieldInfoMulti( $table, $col );
+ $col_type = $col_info != false ? $col_info->type() : 'CONSTANT';
+
+ if ( $val === null ) {
+ // do nothing ... null was inserted in statement creation
+ } elseif ( $col_type != 'BLOB' && $col_type != 'CLOB' ) {
+ if ( is_object( $val ) ) {
+ $val = $val->getData();
+ }
+
+ if ( preg_match( '/^timestamp.*/i', $col_type ) == 1 && strtolower( $val ) == 'infinity' ) {
+ $val = '31-12-2030 12:00:00.000000';
+ }
+
+ $val = ( $wgContLang != null ) ? $wgContLang->checkTitleEncoding( $val ) : $val;
+ if ( oci_bind_by_name( $stmt, ":$col", $val ) === false ) {
+ $e = oci_error( $stmt );
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+
+ return false;
+ }
+ } else {
+ /** @var OCI_Lob[] $lob */
+ $lob[$col] = oci_new_descriptor( $this->mConn, OCI_D_LOB );
+ if ( $lob[$col] === false ) {
+ $e = oci_error( $stmt );
+ throw new DBUnexpectedError( $this, "Cannot create LOB descriptor: " . $e['message'] );
+ }
+
+ if ( is_object( $val ) ) {
+ $val = $val->getData();
+ }
+
+ if ( $col_type == 'BLOB' ) {
+ $lob[$col]->writeTemporary( $val );
+ oci_bind_by_name( $stmt, ":$col", $lob[$col], -1, SQLT_BLOB );
+ } else {
+ $lob[$col]->writeTemporary( $val );
+ oci_bind_by_name( $stmt, ":$col", $lob[$col], -1, OCI_B_CLOB );
+ }
+ }
+ }
+
+ MediaWiki\suppressWarnings();
+
+ if ( oci_execute( $stmt, $this->execFlags() ) === false ) {
+ $e = oci_error( $stmt );
+ if ( !$this->ignoreDupValOnIndex || $e['code'] != '1' ) {
+ $this->reportQueryError( $e['message'], $e['code'], $sql, __METHOD__ );
+
+ return false;
+ } else {
+ $this->mAffectedRows = oci_num_rows( $stmt );
+ }
+ } else {
+ $this->mAffectedRows = oci_num_rows( $stmt );
+ }
+
+ MediaWiki\restoreWarnings();
+
+ if ( isset( $lob ) ) {
+ foreach ( $lob as $lob_v ) {
+ $lob_v->free();
+ }
+ }
+
+ if ( !$this->mTrxLevel ) {
+ oci_commit( $this->mConn );
+ }
+
+ return oci_free_statement( $stmt );
+ }
+
+ function bitNot( $field ) {
+ // expecting bit-fields smaller than 4bytes
+ return 'BITNOT(' . $field . ')';
+ }
+
+ function bitAnd( $fieldLeft, $fieldRight ) {
+ return 'BITAND(' . $fieldLeft . ', ' . $fieldRight . ')';
+ }
+
+ function bitOr( $fieldLeft, $fieldRight ) {
+ return 'BITOR(' . $fieldLeft . ', ' . $fieldRight . ')';
+ }
+
+ function getDBname() {
+ return $this->mDBname;
+ }
+
+ function getServer() {
+ return $this->mServer;
+ }
+
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ ) {
+ $fld = "LISTAGG($field," . $this->addQuotes( $delim ) . ") WITHIN GROUP (ORDER BY $field)";
+
+ return '(' . $this->selectSQLText( $table, $fld, $conds, null, [], $join_conds ) . ')';
+ }
+
+ /**
+ * @param string $field Field or column to cast
+ * @return string
+ * @since 1.28
+ */
+ public function buildStringCast( $field ) {
+ return 'CAST ( ' . $field . ' AS VARCHAR2 )';
+ }
+
+ public function getInfinity() {
+ return '31-12-2030 12:00:00.000000';
+ }
+}
diff --git a/www/wiki/includes/db/DatabasePostgres.php b/www/wiki/includes/db/DatabasePostgres.php
new file mode 100644
index 00000000..6e76cdbf
--- /dev/null
+++ b/www/wiki/includes/db/DatabasePostgres.php
@@ -0,0 +1,1626 @@
+<?php
+/**
+ * This is the Postgres database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+class PostgresField implements Field {
+ private $name, $tablename, $type, $nullable, $max_length, $deferred, $deferrable, $conname,
+ $has_default, $default;
+
+ /**
+ * @param DatabaseBase $db
+ * @param string $table
+ * @param string $field
+ * @return null|PostgresField
+ */
+ static function fromText( $db, $table, $field ) {
+ $q = <<<SQL
+SELECT
+ attnotnull, attlen, conname AS conname,
+ atthasdef,
+ adsrc,
+ COALESCE(condeferred, 'f') AS deferred,
+ COALESCE(condeferrable, 'f') AS deferrable,
+ CASE WHEN typname = 'int2' THEN 'smallint'
+ WHEN typname = 'int4' THEN 'integer'
+ WHEN typname = 'int8' THEN 'bigint'
+ WHEN typname = 'bpchar' THEN 'char'
+ ELSE typname END AS typname
+FROM pg_class c
+JOIN pg_namespace n ON (n.oid = c.relnamespace)
+JOIN pg_attribute a ON (a.attrelid = c.oid)
+JOIN pg_type t ON (t.oid = a.atttypid)
+LEFT JOIN pg_constraint o ON (o.conrelid = c.oid AND a.attnum = ANY(o.conkey) AND o.contype = 'f')
+LEFT JOIN pg_attrdef d on c.oid=d.adrelid and a.attnum=d.adnum
+WHERE relkind = 'r'
+AND nspname=%s
+AND relname=%s
+AND attname=%s;
+SQL;
+
+ $table = $db->tableName( $table, 'raw' );
+ $res = $db->query(
+ sprintf( $q,
+ $db->addQuotes( $db->getCoreSchema() ),
+ $db->addQuotes( $table ),
+ $db->addQuotes( $field )
+ )
+ );
+ $row = $db->fetchObject( $res );
+ if ( !$row ) {
+ return null;
+ }
+ $n = new PostgresField;
+ $n->type = $row->typname;
+ $n->nullable = ( $row->attnotnull == 'f' );
+ $n->name = $field;
+ $n->tablename = $table;
+ $n->max_length = $row->attlen;
+ $n->deferrable = ( $row->deferrable == 't' );
+ $n->deferred = ( $row->deferred == 't' );
+ $n->conname = $row->conname;
+ $n->has_default = ( $row->atthasdef === 't' );
+ $n->default = $row->adsrc;
+
+ return $n;
+ }
+
+ function name() {
+ return $this->name;
+ }
+
+ function tableName() {
+ return $this->tablename;
+ }
+
+ function type() {
+ return $this->type;
+ }
+
+ function isNullable() {
+ return $this->nullable;
+ }
+
+ function maxLength() {
+ return $this->max_length;
+ }
+
+ function is_deferrable() {
+ return $this->deferrable;
+ }
+
+ function is_deferred() {
+ return $this->deferred;
+ }
+
+ function conname() {
+ return $this->conname;
+ }
+
+ /**
+ * @since 1.19
+ * @return bool|mixed
+ */
+ function defaultValue() {
+ if ( $this->has_default ) {
+ return $this->default;
+ } else {
+ return false;
+ }
+ }
+}
+
+/**
+ * Manage savepoints within a transaction
+ * @ingroup Database
+ * @since 1.19
+ */
+class SavepointPostgres {
+ /** @var DatabasePostgres Establish a savepoint within a transaction */
+ protected $dbw;
+ protected $id;
+ protected $didbegin;
+
+ /**
+ * @param DatabaseBase $dbw
+ * @param int $id
+ */
+ public function __construct( $dbw, $id ) {
+ $this->dbw = $dbw;
+ $this->id = $id;
+ $this->didbegin = false;
+ /* If we are not in a transaction, we need to be for savepoint trickery */
+ if ( !$dbw->trxLevel() ) {
+ $dbw->begin( "FOR SAVEPOINT" );
+ $this->didbegin = true;
+ }
+ }
+
+ public function __destruct() {
+ if ( $this->didbegin ) {
+ $this->dbw->rollback();
+ $this->didbegin = false;
+ }
+ }
+
+ public function commit() {
+ if ( $this->didbegin ) {
+ $this->dbw->commit();
+ $this->didbegin = false;
+ }
+ }
+
+ protected function query( $keyword, $msg_ok, $msg_failed ) {
+ if ( $this->dbw->doQuery( $keyword . " " . $this->id ) !== false ) {
+ } else {
+ wfDebug( sprintf( $msg_failed, $this->id ) );
+ }
+ }
+
+ public function savepoint() {
+ $this->query( "SAVEPOINT",
+ "Transaction state: savepoint \"%s\" established.\n",
+ "Transaction state: establishment of savepoint \"%s\" FAILED.\n"
+ );
+ }
+
+ public function release() {
+ $this->query( "RELEASE",
+ "Transaction state: savepoint \"%s\" released.\n",
+ "Transaction state: release of savepoint \"%s\" FAILED.\n"
+ );
+ }
+
+ public function rollback() {
+ $this->query( "ROLLBACK TO",
+ "Transaction state: savepoint \"%s\" rolled back.\n",
+ "Transaction state: rollback of savepoint \"%s\" FAILED.\n"
+ );
+ }
+
+ public function __toString() {
+ return (string)$this->id;
+ }
+}
+
+/**
+ * @ingroup Database
+ */
+class DatabasePostgres extends Database {
+ /** @var resource */
+ protected $mLastResult = null;
+
+ /** @var int The number of rows affected as an integer */
+ protected $mAffectedRows = null;
+
+ /** @var int */
+ private $mInsertId = null;
+
+ /** @var float|string */
+ private $numericVersion = null;
+
+ /** @var string Connect string to open a PostgreSQL connection */
+ private $connectString;
+
+ /** @var string */
+ private $mCoreSchema;
+
+ function getType() {
+ return 'postgres';
+ }
+
+ function cascadingDeletes() {
+ return true;
+ }
+
+ function cleanupTriggers() {
+ return true;
+ }
+
+ function strictIPs() {
+ return true;
+ }
+
+ function realTimestamps() {
+ return true;
+ }
+
+ function implicitGroupby() {
+ return false;
+ }
+
+ function implicitOrderby() {
+ return false;
+ }
+
+ function searchableIPs() {
+ return true;
+ }
+
+ function functionalIndexes() {
+ return true;
+ }
+
+ function hasConstraint( $name ) {
+ $sql = "SELECT 1 FROM pg_catalog.pg_constraint c, pg_catalog.pg_namespace n " .
+ "WHERE c.connamespace = n.oid AND conname = '" .
+ pg_escape_string( $this->mConn, $name ) . "' AND n.nspname = '" .
+ pg_escape_string( $this->mConn, $this->getCoreSchema() ) . "'";
+ $res = $this->doQuery( $sql );
+
+ return $this->numRows( $res );
+ }
+
+ /**
+ * Usually aborts on failure
+ * @param string $server
+ * @param string $user
+ * @param string $password
+ * @param string $dbName
+ * @throws DBConnectionError|Exception
+ * @return DatabaseBase|null
+ */
+ function open( $server, $user, $password, $dbName ) {
+ # Test for Postgres support, to avoid suppressed fatal error
+ if ( !function_exists( 'pg_connect' ) ) {
+ throw new DBConnectionError(
+ $this,
+ "Postgres functions missing, have you compiled PHP with the --with-pgsql\n" .
+ "option? (Note: if you recently installed PHP, you may need to restart your\n" .
+ "webserver and database)\n"
+ );
+ }
+
+ global $wgDBport;
+
+ if ( !strlen( $user ) ) { # e.g. the class is being loaded
+ return null;
+ }
+
+ $this->mServer = $server;
+ $port = $wgDBport;
+ $this->mUser = $user;
+ $this->mPassword = $password;
+ $this->mDBname = $dbName;
+
+ $connectVars = [
+ 'dbname' => $dbName,
+ 'user' => $user,
+ 'password' => $password
+ ];
+ if ( $server != false && $server != '' ) {
+ $connectVars['host'] = $server;
+ }
+ if ( $port != false && $port != '' ) {
+ $connectVars['port'] = $port;
+ }
+ if ( $this->mFlags & DBO_SSL ) {
+ $connectVars['sslmode'] = 1;
+ }
+
+ $this->connectString = $this->makeConnectionString( $connectVars, PGSQL_CONNECT_FORCE_NEW );
+ $this->close();
+ $this->installErrorHandler();
+
+ try {
+ $this->mConn = pg_connect( $this->connectString );
+ } catch ( Exception $ex ) {
+ $this->restoreErrorHandler();
+ throw $ex;
+ }
+
+ $phpError = $this->restoreErrorHandler();
+
+ if ( !$this->mConn ) {
+ wfDebug( "DB connection error\n" );
+ wfDebug( "Server: $server, Database: $dbName, User: $user, Password: " .
+ substr( $password, 0, 3 ) . "...\n" );
+ wfDebug( $this->lastError() . "\n" );
+ throw new DBConnectionError( $this, str_replace( "\n", ' ', $phpError ) );
+ }
+
+ $this->mOpened = true;
+
+ global $wgCommandLineMode;
+ # If called from the command-line (e.g. importDump), only show errors
+ if ( $wgCommandLineMode ) {
+ $this->doQuery( "SET client_min_messages = 'ERROR'" );
+ }
+
+ $this->query( "SET client_encoding='UTF8'", __METHOD__ );
+ $this->query( "SET datestyle = 'ISO, YMD'", __METHOD__ );
+ $this->query( "SET timezone = 'GMT'", __METHOD__ );
+ $this->query( "SET standard_conforming_strings = on", __METHOD__ );
+ if ( $this->getServerVersion() >= 9.0 ) {
+ $this->query( "SET bytea_output = 'escape'", __METHOD__ ); // PHP bug 53127
+ }
+
+ global $wgDBmwschema;
+ $this->determineCoreSchema( $wgDBmwschema );
+
+ return $this->mConn;
+ }
+
+ /**
+ * Postgres doesn't support selectDB in the same way MySQL does. So if the
+ * DB name doesn't match the open connection, open a new one
+ * @param string $db
+ * @return bool
+ */
+ function selectDB( $db ) {
+ if ( $this->mDBname !== $db ) {
+ return (bool)$this->open( $this->mServer, $this->mUser, $this->mPassword, $db );
+ } else {
+ return true;
+ }
+ }
+
+ function makeConnectionString( $vars ) {
+ $s = '';
+ foreach ( $vars as $name => $value ) {
+ $s .= "$name='" . str_replace( "'", "\\'", $value ) . "' ";
+ }
+
+ return $s;
+ }
+
+ /**
+ * Closes a database connection, if it is open
+ * Returns success, true if already closed
+ * @return bool
+ */
+ protected function closeConnection() {
+ return pg_close( $this->mConn );
+ }
+
+ public function doQuery( $sql ) {
+ $sql = mb_convert_encoding( $sql, 'UTF-8' );
+ // Clear previously left over PQresult
+ while ( $res = pg_get_result( $this->mConn ) ) {
+ pg_free_result( $res );
+ }
+ if ( pg_send_query( $this->mConn, $sql ) === false ) {
+ throw new DBUnexpectedError( $this, "Unable to post new query to PostgreSQL\n" );
+ }
+ $this->mLastResult = pg_get_result( $this->mConn );
+ $this->mAffectedRows = null;
+ if ( pg_result_error( $this->mLastResult ) ) {
+ return false;
+ }
+
+ return $this->mLastResult;
+ }
+
+ protected function dumpError() {
+ $diags = [
+ PGSQL_DIAG_SEVERITY,
+ PGSQL_DIAG_SQLSTATE,
+ PGSQL_DIAG_MESSAGE_PRIMARY,
+ PGSQL_DIAG_MESSAGE_DETAIL,
+ PGSQL_DIAG_MESSAGE_HINT,
+ PGSQL_DIAG_STATEMENT_POSITION,
+ PGSQL_DIAG_INTERNAL_POSITION,
+ PGSQL_DIAG_INTERNAL_QUERY,
+ PGSQL_DIAG_CONTEXT,
+ PGSQL_DIAG_SOURCE_FILE,
+ PGSQL_DIAG_SOURCE_LINE,
+ PGSQL_DIAG_SOURCE_FUNCTION
+ ];
+ foreach ( $diags as $d ) {
+ wfDebug( sprintf( "PgSQL ERROR(%d): %s\n",
+ $d, pg_result_error_field( $this->mLastResult, $d ) ) );
+ }
+ }
+
+ function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
+ if ( $tempIgnore ) {
+ /* Check for constraint violation */
+ if ( $errno === '23505' ) {
+ parent::reportQueryError( $error, $errno, $sql, $fname, $tempIgnore );
+
+ return;
+ }
+ }
+ /* Transaction stays in the ERROR state until rolled back */
+ if ( $this->mTrxLevel ) {
+ $ignore = $this->ignoreErrors( true );
+ $this->rollback( __METHOD__ );
+ $this->ignoreErrors( $ignore );
+ }
+ parent::reportQueryError( $error, $errno, $sql, $fname, false );
+ }
+
+ function queryIgnore( $sql, $fname = __METHOD__ ) {
+ return $this->query( $sql, $fname, true );
+ }
+
+ /**
+ * @param stdClass|ResultWrapper $res
+ * @throws DBUnexpectedError
+ */
+ function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $ok = pg_free_result( $res );
+ MediaWiki\restoreWarnings();
+ if ( !$ok ) {
+ throw new DBUnexpectedError( $this, "Unable to free Postgres result\n" );
+ }
+ }
+
+ /**
+ * @param ResultWrapper|stdClass $res
+ * @return stdClass
+ * @throws DBUnexpectedError
+ */
+ function fetchObject( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $row = pg_fetch_object( $res );
+ MediaWiki\restoreWarnings();
+ # @todo FIXME: HACK HACK HACK HACK debug
+
+ # @todo hashar: not sure if the following test really trigger if the object
+ # fetching failed.
+ if ( pg_last_error( $this->mConn ) ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'SQL error: ' . htmlspecialchars( pg_last_error( $this->mConn ) )
+ );
+ }
+
+ return $row;
+ }
+
+ function fetchRow( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $row = pg_fetch_array( $res );
+ MediaWiki\restoreWarnings();
+ if ( pg_last_error( $this->mConn ) ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'SQL error: ' . htmlspecialchars( pg_last_error( $this->mConn ) )
+ );
+ }
+
+ return $row;
+ }
+
+ function numRows( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $n = pg_num_rows( $res );
+ MediaWiki\restoreWarnings();
+ if ( pg_last_error( $this->mConn ) ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'SQL error: ' . htmlspecialchars( pg_last_error( $this->mConn ) )
+ );
+ }
+
+ return $n;
+ }
+
+ function numFields( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return pg_num_fields( $res );
+ }
+
+ function fieldName( $res, $n ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return pg_field_name( $res, $n );
+ }
+
+ /**
+ * Return the result of the last call to nextSequenceValue();
+ * This must be called after nextSequenceValue().
+ *
+ * @return int|null
+ */
+ function insertId() {
+ return $this->mInsertId;
+ }
+
+ /**
+ * @param mixed $res
+ * @param int $row
+ * @return bool
+ */
+ function dataSeek( $res, $row ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return pg_result_seek( $res, $row );
+ }
+
+ function lastError() {
+ if ( $this->mConn ) {
+ if ( $this->mLastResult ) {
+ return pg_result_error( $this->mLastResult );
+ } else {
+ return pg_last_error();
+ }
+ } else {
+ return 'No database connection';
+ }
+ }
+
+ function lastErrno() {
+ if ( $this->mLastResult ) {
+ return pg_result_error_field( $this->mLastResult, PGSQL_DIAG_SQLSTATE );
+ } else {
+ return false;
+ }
+ }
+
+ function affectedRows() {
+ if ( !is_null( $this->mAffectedRows ) ) {
+ // Forced result for simulated queries
+ return $this->mAffectedRows;
+ }
+ if ( empty( $this->mLastResult ) ) {
+ return 0;
+ }
+
+ return pg_affected_rows( $this->mLastResult );
+ }
+
+ /**
+ * Estimate rows in dataset
+ * Returns estimated count, based on EXPLAIN output
+ * This is not necessarily an accurate estimate, so use sparingly
+ * Returns -1 if count cannot be found
+ * Takes same arguments as Database::select()
+ *
+ * @param string $table
+ * @param string $vars
+ * @param string $conds
+ * @param string $fname
+ * @param array $options
+ * @return int
+ */
+ function estimateRowCount( $table, $vars = '*', $conds = '',
+ $fname = __METHOD__, $options = []
+ ) {
+ $options['EXPLAIN'] = true;
+ $res = $this->select( $table, $vars, $conds, $fname, $options );
+ $rows = -1;
+ if ( $res ) {
+ $row = $this->fetchRow( $res );
+ $count = [];
+ if ( preg_match( '/rows=(\d+)/', $row[0], $count ) ) {
+ $rows = (int)$count[1];
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Returns information about an index
+ * If errors are explicitly ignored, returns NULL on failure
+ *
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool|null
+ */
+ function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ $sql = "SELECT indexname FROM pg_indexes WHERE tablename='$table'";
+ $res = $this->query( $sql, $fname );
+ if ( !$res ) {
+ return null;
+ }
+ foreach ( $res as $row ) {
+ if ( $row->indexname == $this->indexName( $index ) ) {
+ return $row;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns is of attributes used in index
+ *
+ * @since 1.19
+ * @param string $index
+ * @param bool|string $schema
+ * @return array
+ */
+ function indexAttributes( $index, $schema = false ) {
+ if ( $schema === false ) {
+ $schema = $this->getCoreSchema();
+ }
+ /*
+ * A subquery would be not needed if we didn't care about the order
+ * of attributes, but we do
+ */
+ $sql = <<<__INDEXATTR__
+
+ SELECT opcname,
+ attname,
+ i.indoption[s.g] as option,
+ pg_am.amname
+ FROM
+ (SELECT generate_series(array_lower(isub.indkey,1), array_upper(isub.indkey,1)) AS g
+ FROM
+ pg_index isub
+ JOIN pg_class cis
+ ON cis.oid=isub.indexrelid
+ JOIN pg_namespace ns
+ ON cis.relnamespace = ns.oid
+ WHERE cis.relname='$index' AND ns.nspname='$schema') AS s,
+ pg_attribute,
+ pg_opclass opcls,
+ pg_am,
+ pg_class ci
+ JOIN pg_index i
+ ON ci.oid=i.indexrelid
+ JOIN pg_class ct
+ ON ct.oid = i.indrelid
+ JOIN pg_namespace n
+ ON ci.relnamespace = n.oid
+ WHERE
+ ci.relname='$index' AND n.nspname='$schema'
+ AND attrelid = ct.oid
+ AND i.indkey[s.g] = attnum
+ AND i.indclass[s.g] = opcls.oid
+ AND pg_am.oid = opcls.opcmethod
+__INDEXATTR__;
+ $res = $this->query( $sql, __METHOD__ );
+ $a = [];
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $a[] = [
+ $row->attname,
+ $row->opcname,
+ $row->amname,
+ $row->option ];
+ }
+ } else {
+ return null;
+ }
+
+ return $a;
+ }
+
+ function indexUnique( $table, $index, $fname = __METHOD__ ) {
+ $sql = "SELECT indexname FROM pg_indexes WHERE tablename='{$table}'" .
+ " AND indexdef LIKE 'CREATE UNIQUE%(" .
+ $this->strencode( $this->indexName( $index ) ) .
+ ")'";
+ $res = $this->query( $sql, $fname );
+ if ( !$res ) {
+ return null;
+ }
+
+ return $res->numRows() > 0;
+ }
+
+ /**
+ * Change the FOR UPDATE option as necessary based on the join conditions. Then pass
+ * to the parent function to get the actual SQL text.
+ *
+ * In Postgres when using FOR UPDATE, only the main table and tables that are inner joined
+ * can be locked. That means tables in an outer join cannot be FOR UPDATE locked. Trying to do
+ * so causes a DB error. This wrapper checks which tables can be locked and adjusts it accordingly.
+ *
+ * MySQL uses "ORDER BY NULL" as an optimization hint, but that syntax is illegal in PostgreSQL.
+ * @see DatabaseBase::selectSQLText
+ */
+ function selectSQLText( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ if ( is_array( $options ) ) {
+ $forUpdateKey = array_search( 'FOR UPDATE', $options, true );
+ if ( $forUpdateKey !== false && $join_conds ) {
+ unset( $options[$forUpdateKey] );
+
+ foreach ( $join_conds as $table_cond => $join_cond ) {
+ if ( 0 === preg_match( '/^(?:LEFT|RIGHT|FULL)(?: OUTER)? JOIN$/i', $join_cond[0] ) ) {
+ $options['FOR UPDATE'][] = $table_cond;
+ }
+ }
+ }
+
+ if ( isset( $options['ORDER BY'] ) && $options['ORDER BY'] == 'NULL' ) {
+ unset( $options['ORDER BY'] );
+ }
+ }
+
+ return parent::selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
+ }
+
+ /**
+ * INSERT wrapper, inserts an array into a table
+ *
+ * $args may be a single associative array, or an array of these with numeric keys,
+ * for multi-row insert (Postgres version 8.2 and above only).
+ *
+ * @param string $table Name of the table to insert to.
+ * @param array $args Items to insert into the table.
+ * @param string $fname Name of the function, for profiling
+ * @param array|string $options String or array. Valid options: IGNORE
+ * @return bool Success of insert operation. IGNORE always returns true.
+ */
+ function insert( $table, $args, $fname = __METHOD__, $options = [] ) {
+ if ( !count( $args ) ) {
+ return true;
+ }
+
+ $table = $this->tableName( $table );
+ if ( !isset( $this->numericVersion ) ) {
+ $this->getServerVersion();
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ if ( isset( $args[0] ) && is_array( $args[0] ) ) {
+ $multi = true;
+ $keys = array_keys( $args[0] );
+ } else {
+ $multi = false;
+ $keys = array_keys( $args );
+ }
+
+ // If IGNORE is set, we use savepoints to emulate mysql's behavior
+ $savepoint = null;
+ if ( in_array( 'IGNORE', $options ) ) {
+ $savepoint = new SavepointPostgres( $this, 'mw' );
+ $olde = error_reporting( 0 );
+ // For future use, we may want to track the number of actual inserts
+ // Right now, insert (all writes) simply return true/false
+ $numrowsinserted = 0;
+ }
+
+ $sql = "INSERT INTO $table (" . implode( ',', $keys ) . ') VALUES ';
+
+ if ( $multi ) {
+ if ( $this->numericVersion >= 8.2 && !$savepoint ) {
+ $first = true;
+ foreach ( $args as $row ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $sql .= ',';
+ }
+ $sql .= '(' . $this->makeList( $row ) . ')';
+ }
+ $res = (bool)$this->query( $sql, $fname, $savepoint );
+ } else {
+ $res = true;
+ $origsql = $sql;
+ foreach ( $args as $row ) {
+ $tempsql = $origsql;
+ $tempsql .= '(' . $this->makeList( $row ) . ')';
+
+ if ( $savepoint ) {
+ $savepoint->savepoint();
+ }
+
+ $tempres = (bool)$this->query( $tempsql, $fname, $savepoint );
+
+ if ( $savepoint ) {
+ $bar = pg_result_error( $this->mLastResult );
+ if ( $bar != false ) {
+ $savepoint->rollback();
+ } else {
+ $savepoint->release();
+ $numrowsinserted++;
+ }
+ }
+
+ // If any of them fail, we fail overall for this function call
+ // Note that this will be ignored if IGNORE is set
+ if ( !$tempres ) {
+ $res = false;
+ }
+ }
+ }
+ } else {
+ // Not multi, just a lone insert
+ if ( $savepoint ) {
+ $savepoint->savepoint();
+ }
+
+ $sql .= '(' . $this->makeList( $args ) . ')';
+ $res = (bool)$this->query( $sql, $fname, $savepoint );
+ if ( $savepoint ) {
+ $bar = pg_result_error( $this->mLastResult );
+ if ( $bar != false ) {
+ $savepoint->rollback();
+ } else {
+ $savepoint->release();
+ $numrowsinserted++;
+ }
+ }
+ }
+ if ( $savepoint ) {
+ error_reporting( $olde );
+ $savepoint->commit();
+
+ // Set the affected row count for the whole operation
+ $this->mAffectedRows = $numrowsinserted;
+
+ // IGNORE always returns true
+ return true;
+ }
+
+ return $res;
+ }
+
+ /**
+ * INSERT SELECT wrapper
+ * $varMap must be an associative array of the form array( 'dest1' => 'source1', ...)
+ * Source items may be literals rather then field names, but strings should
+ * be quoted with Database::addQuotes()
+ * $conds may be "*" to copy the whole table
+ * srcTable may be an array of tables.
+ * @todo FIXME: Implement this a little better (seperate select/insert)?
+ *
+ * @param string $destTable
+ * @param array|string $srcTable
+ * @param array $varMap
+ * @param array $conds
+ * @param string $fname
+ * @param array $insertOptions
+ * @param array $selectOptions
+ * @return bool
+ */
+ function insertSelect( $destTable, $srcTable, $varMap, $conds, $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = [] ) {
+ $destTable = $this->tableName( $destTable );
+
+ if ( !is_array( $insertOptions ) ) {
+ $insertOptions = [ $insertOptions ];
+ }
+
+ /*
+ * If IGNORE is set, we use savepoints to emulate mysql's behavior
+ * Ignore LOW PRIORITY option, since it is MySQL-specific
+ */
+ $savepoint = null;
+ if ( in_array( 'IGNORE', $insertOptions ) ) {
+ $savepoint = new SavepointPostgres( $this, 'mw' );
+ $olde = error_reporting( 0 );
+ $numrowsinserted = 0;
+ $savepoint->savepoint();
+ }
+
+ if ( !is_array( $selectOptions ) ) {
+ $selectOptions = [ $selectOptions ];
+ }
+ list( $startOpts, $useIndex, $tailOpts ) = $this->makeSelectOptions( $selectOptions );
+ if ( is_array( $srcTable ) ) {
+ $srcTable = implode( ',', array_map( [ $this, 'tableName' ], $srcTable ) );
+ } else {
+ $srcTable = $this->tableName( $srcTable );
+ }
+
+ $sql = "INSERT INTO $destTable (" . implode( ',', array_keys( $varMap ) ) . ')' .
+ " SELECT $startOpts " . implode( ',', $varMap ) .
+ " FROM $srcTable $useIndex";
+
+ if ( $conds != '*' ) {
+ $sql .= ' WHERE ' . $this->makeList( $conds, LIST_AND );
+ }
+
+ $sql .= " $tailOpts";
+
+ $res = (bool)$this->query( $sql, $fname, $savepoint );
+ if ( $savepoint ) {
+ $bar = pg_result_error( $this->mLastResult );
+ if ( $bar != false ) {
+ $savepoint->rollback();
+ } else {
+ $savepoint->release();
+ $numrowsinserted++;
+ }
+ error_reporting( $olde );
+ $savepoint->commit();
+
+ // Set the affected row count for the whole operation
+ $this->mAffectedRows = $numrowsinserted;
+
+ // IGNORE always returns true
+ return true;
+ }
+
+ return $res;
+ }
+
+ function tableName( $name, $format = 'quoted' ) {
+ # Replace reserved words with better ones
+ switch ( $name ) {
+ case 'user':
+ return $this->realTableName( 'mwuser', $format );
+ case 'text':
+ return $this->realTableName( 'pagecontent', $format );
+ default:
+ return $this->realTableName( $name, $format );
+ }
+ }
+
+ /* Don't cheat on installer */
+ function realTableName( $name, $format = 'quoted' ) {
+ return parent::tableName( $name, $format );
+ }
+
+ /**
+ * Return the next in a sequence, save the value for retrieval via insertId()
+ *
+ * @param string $seqName
+ * @return int|null
+ */
+ function nextSequenceValue( $seqName ) {
+ $safeseq = str_replace( "'", "''", $seqName );
+ $res = $this->query( "SELECT nextval('$safeseq')" );
+ $row = $this->fetchRow( $res );
+ $this->mInsertId = $row[0];
+
+ return $this->mInsertId;
+ }
+
+ /**
+ * Return the current value of a sequence. Assumes it has been nextval'ed in this session.
+ *
+ * @param string $seqName
+ * @return int
+ */
+ function currentSequenceValue( $seqName ) {
+ $safeseq = str_replace( "'", "''", $seqName );
+ $res = $this->query( "SELECT currval('$safeseq')" );
+ $row = $this->fetchRow( $res );
+ $currval = $row[0];
+
+ return $currval;
+ }
+
+ # Returns the size of a text field, or -1 for "unlimited"
+ function textFieldSize( $table, $field ) {
+ $table = $this->tableName( $table );
+ $sql = "SELECT t.typname as ftype,a.atttypmod as size
+ FROM pg_class c, pg_attribute a, pg_type t
+ WHERE relname='$table' AND a.attrelid=c.oid AND
+ a.atttypid=t.oid and a.attname='$field'";
+ $res = $this->query( $sql );
+ $row = $this->fetchObject( $res );
+ if ( $row->ftype == 'varchar' ) {
+ $size = $row->size - 4;
+ } else {
+ $size = $row->size;
+ }
+
+ return $size;
+ }
+
+ function limitResult( $sql, $limit, $offset = false ) {
+ return "$sql LIMIT $limit " . ( is_numeric( $offset ) ? " OFFSET {$offset} " : '' );
+ }
+
+ function wasDeadlock() {
+ return $this->lastErrno() == '40P01';
+ }
+
+ function duplicateTableStructure( $oldName, $newName, $temporary = false, $fname = __METHOD__ ) {
+ $newName = $this->addIdentifierQuotes( $newName );
+ $oldName = $this->addIdentifierQuotes( $oldName );
+
+ return $this->query( 'CREATE ' . ( $temporary ? 'TEMPORARY ' : '' ) . " TABLE $newName " .
+ "(LIKE $oldName INCLUDING DEFAULTS)", $fname );
+ }
+
+ function listTables( $prefix = null, $fname = __METHOD__ ) {
+ $eschema = $this->addQuotes( $this->getCoreSchema() );
+ $result = $this->query( "SELECT tablename FROM pg_tables WHERE schemaname = $eschema", $fname );
+ $endArray = [];
+
+ foreach ( $result as $table ) {
+ $vars = get_object_vars( $table );
+ $table = array_pop( $vars );
+ if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
+ $endArray[] = $table;
+ }
+ }
+
+ return $endArray;
+ }
+
+ function timestamp( $ts = 0 ) {
+ return wfTimestamp( TS_POSTGRES, $ts );
+ }
+
+ /**
+ * Posted by cc[plus]php[at]c2se[dot]com on 25-Mar-2009 09:12
+ * to http://www.php.net/manual/en/ref.pgsql.php
+ *
+ * Parsing a postgres array can be a tricky problem, he's my
+ * take on this, it handles multi-dimensional arrays plus
+ * escaping using a nasty regexp to determine the limits of each
+ * data-item.
+ *
+ * This should really be handled by PHP PostgreSQL module
+ *
+ * @since 1.19
+ * @param string $text Postgreql array returned in a text form like {a,b}
+ * @param string $output
+ * @param int $limit
+ * @param int $offset
+ * @return string
+ */
+ function pg_array_parse( $text, &$output, $limit = false, $offset = 1 ) {
+ if ( false === $limit ) {
+ $limit = strlen( $text ) - 1;
+ $output = [];
+ }
+ if ( '{}' == $text ) {
+ return $output;
+ }
+ do {
+ if ( '{' != $text[$offset] ) {
+ preg_match( "/(\\{?\"([^\"\\\\]|\\\\.)*\"|[^,{}]+)+([,}]+)/",
+ $text, $match, 0, $offset );
+ $offset += strlen( $match[0] );
+ $output[] = ( '"' != $match[1][0]
+ ? $match[1]
+ : stripcslashes( substr( $match[1], 1, -1 ) ) );
+ if ( '},' == $match[3] ) {
+ return $output;
+ }
+ } else {
+ $offset = $this->pg_array_parse( $text, $output, $limit, $offset + 1 );
+ }
+ } while ( $limit > $offset );
+
+ return $output;
+ }
+
+ /**
+ * Return aggregated value function call
+ * @param array $valuedata
+ * @param string $valuename
+ * @return array
+ */
+ public function aggregateValue( $valuedata, $valuename = 'value' ) {
+ return $valuedata;
+ }
+
+ /**
+ * @return string Wikitext of a link to the server software's web site
+ */
+ public function getSoftwareLink() {
+ return '[{{int:version-db-postgres-url}} PostgreSQL]';
+ }
+
+ /**
+ * Return current schema (executes SELECT current_schema())
+ * Needs transaction
+ *
+ * @since 1.19
+ * @return string Default schema for the current session
+ */
+ function getCurrentSchema() {
+ $res = $this->query( "SELECT current_schema()", __METHOD__ );
+ $row = $this->fetchRow( $res );
+
+ return $row[0];
+ }
+
+ /**
+ * Return list of schemas which are accessible without schema name
+ * This is list does not contain magic keywords like "$user"
+ * Needs transaction
+ *
+ * @see getSearchPath()
+ * @see setSearchPath()
+ * @since 1.19
+ * @return array List of actual schemas for the current sesson
+ */
+ function getSchemas() {
+ $res = $this->query( "SELECT current_schemas(false)", __METHOD__ );
+ $row = $this->fetchRow( $res );
+ $schemas = [];
+
+ /* PHP pgsql support does not support array type, "{a,b}" string is returned */
+
+ return $this->pg_array_parse( $row[0], $schemas );
+ }
+
+ /**
+ * Return search patch for schemas
+ * This is different from getSchemas() since it contain magic keywords
+ * (like "$user").
+ * Needs transaction
+ *
+ * @since 1.19
+ * @return array How to search for table names schemas for the current user
+ */
+ function getSearchPath() {
+ $res = $this->query( "SHOW search_path", __METHOD__ );
+ $row = $this->fetchRow( $res );
+
+ /* PostgreSQL returns SHOW values as strings */
+
+ return explode( ",", $row[0] );
+ }
+
+ /**
+ * Update search_path, values should already be sanitized
+ * Values may contain magic keywords like "$user"
+ * @since 1.19
+ *
+ * @param array $search_path List of schemas to be searched by default
+ */
+ function setSearchPath( $search_path ) {
+ $this->query( "SET search_path = " . implode( ", ", $search_path ) );
+ }
+
+ /**
+ * Determine default schema for MediaWiki core
+ * Adjust this session schema search path if desired schema exists
+ * and is not alread there.
+ *
+ * We need to have name of the core schema stored to be able
+ * to query database metadata.
+ *
+ * This will be also called by the installer after the schema is created
+ *
+ * @since 1.19
+ *
+ * @param string $desiredSchema
+ */
+ function determineCoreSchema( $desiredSchema ) {
+ $this->begin( __METHOD__ );
+ if ( $this->schemaExists( $desiredSchema ) ) {
+ if ( in_array( $desiredSchema, $this->getSchemas() ) ) {
+ $this->mCoreSchema = $desiredSchema;
+ wfDebug( "Schema \"" . $desiredSchema . "\" already in the search path\n" );
+ } else {
+ /**
+ * Prepend our schema (e.g. 'mediawiki') in front
+ * of the search path
+ * Fixes bug 15816
+ */
+ $search_path = $this->getSearchPath();
+ array_unshift( $search_path,
+ $this->addIdentifierQuotes( $desiredSchema ) );
+ $this->setSearchPath( $search_path );
+ $this->mCoreSchema = $desiredSchema;
+ wfDebug( "Schema \"" . $desiredSchema . "\" added to the search path\n" );
+ }
+ } else {
+ $this->mCoreSchema = $this->getCurrentSchema();
+ wfDebug( "Schema \"" . $desiredSchema . "\" not found, using current \"" .
+ $this->mCoreSchema . "\"\n" );
+ }
+ /* Commit SET otherwise it will be rollbacked on error or IGNORE SELECT */
+ $this->commit( __METHOD__ );
+ }
+
+ /**
+ * Return schema name fore core MediaWiki tables
+ *
+ * @since 1.19
+ * @return string Core schema name
+ */
+ function getCoreSchema() {
+ return $this->mCoreSchema;
+ }
+
+ /**
+ * @return string Version information from the database
+ */
+ function getServerVersion() {
+ if ( !isset( $this->numericVersion ) ) {
+ $versionInfo = pg_version( $this->mConn );
+ if ( version_compare( $versionInfo['client'], '7.4.0', 'lt' ) ) {
+ // Old client, abort install
+ $this->numericVersion = '7.3 or earlier';
+ } elseif ( isset( $versionInfo['server'] ) ) {
+ // Normal client
+ $this->numericVersion = $versionInfo['server'];
+ } else {
+ // Bug 16937: broken pgsql extension from PHP<5.3
+ $this->numericVersion = pg_parameter_status( $this->mConn, 'server_version' );
+ }
+ }
+
+ return $this->numericVersion;
+ }
+
+ /**
+ * Query whether a given relation exists (in the given schema, or the
+ * default mw one if not given)
+ * @param string $table
+ * @param array|string $types
+ * @param bool|string $schema
+ * @return bool
+ */
+ function relationExists( $table, $types, $schema = false ) {
+ if ( !is_array( $types ) ) {
+ $types = [ $types ];
+ }
+ if ( !$schema ) {
+ $schema = $this->getCoreSchema();
+ }
+ $table = $this->realTableName( $table, 'raw' );
+ $etable = $this->addQuotes( $table );
+ $eschema = $this->addQuotes( $schema );
+ $sql = "SELECT 1 FROM pg_catalog.pg_class c, pg_catalog.pg_namespace n "
+ . "WHERE c.relnamespace = n.oid AND c.relname = $etable AND n.nspname = $eschema "
+ . "AND c.relkind IN ('" . implode( "','", $types ) . "')";
+ $res = $this->query( $sql );
+ $count = $res ? $res->numRows() : 0;
+
+ return (bool)$count;
+ }
+
+ /**
+ * For backward compatibility, this function checks both tables and
+ * views.
+ * @param string $table
+ * @param string $fname
+ * @param bool|string $schema
+ * @return bool
+ */
+ function tableExists( $table, $fname = __METHOD__, $schema = false ) {
+ return $this->relationExists( $table, [ 'r', 'v' ], $schema );
+ }
+
+ function sequenceExists( $sequence, $schema = false ) {
+ return $this->relationExists( $sequence, 'S', $schema );
+ }
+
+ function triggerExists( $table, $trigger ) {
+ $q = <<<SQL
+ SELECT 1 FROM pg_class, pg_namespace, pg_trigger
+ WHERE relnamespace=pg_namespace.oid AND relkind='r'
+ AND tgrelid=pg_class.oid
+ AND nspname=%s AND relname=%s AND tgname=%s
+SQL;
+ $res = $this->query(
+ sprintf(
+ $q,
+ $this->addQuotes( $this->getCoreSchema() ),
+ $this->addQuotes( $table ),
+ $this->addQuotes( $trigger )
+ )
+ );
+ if ( !$res ) {
+ return null;
+ }
+ $rows = $res->numRows();
+
+ return $rows;
+ }
+
+ function ruleExists( $table, $rule ) {
+ $exists = $this->selectField( 'pg_rules', 'rulename',
+ [
+ 'rulename' => $rule,
+ 'tablename' => $table,
+ 'schemaname' => $this->getCoreSchema()
+ ]
+ );
+
+ return $exists === $rule;
+ }
+
+ function constraintExists( $table, $constraint ) {
+ $sql = sprintf( "SELECT 1 FROM information_schema.table_constraints " .
+ "WHERE constraint_schema = %s AND table_name = %s AND constraint_name = %s",
+ $this->addQuotes( $this->getCoreSchema() ),
+ $this->addQuotes( $table ),
+ $this->addQuotes( $constraint )
+ );
+ $res = $this->query( $sql );
+ if ( !$res ) {
+ return null;
+ }
+ $rows = $res->numRows();
+
+ return $rows;
+ }
+
+ /**
+ * Query whether a given schema exists. Returns true if it does, false if it doesn't.
+ * @param string $schema
+ * @return bool
+ */
+ function schemaExists( $schema ) {
+ $exists = $this->selectField( '"pg_catalog"."pg_namespace"', 1,
+ [ 'nspname' => $schema ], __METHOD__ );
+
+ return (bool)$exists;
+ }
+
+ /**
+ * Returns true if a given role (i.e. user) exists, false otherwise.
+ * @param string $roleName
+ * @return bool
+ */
+ function roleExists( $roleName ) {
+ $exists = $this->selectField( '"pg_catalog"."pg_roles"', 1,
+ [ 'rolname' => $roleName ], __METHOD__ );
+
+ return (bool)$exists;
+ }
+
+ function fieldInfo( $table, $field ) {
+ return PostgresField::fromText( $this, $table, $field );
+ }
+
+ /**
+ * pg_field_type() wrapper
+ * @param ResultWrapper|resource $res ResultWrapper or PostgreSQL query result resource
+ * @param int $index Field number, starting from 0
+ * @return string
+ */
+ function fieldType( $res, $index ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return pg_field_type( $res, $index );
+ }
+
+ /**
+ * @param string $b
+ * @return Blob
+ */
+ function encodeBlob( $b ) {
+ return new PostgresBlob( pg_escape_bytea( $b ) );
+ }
+
+ function decodeBlob( $b ) {
+ if ( $b instanceof PostgresBlob ) {
+ $b = $b->fetch();
+ } elseif ( $b instanceof Blob ) {
+ return $b->fetch();
+ }
+
+ return pg_unescape_bytea( $b );
+ }
+
+ function strencode( $s ) {
+ // Should not be called by us
+
+ return pg_escape_string( $this->mConn, $s );
+ }
+
+ /**
+ * @param null|bool|Blob $s
+ * @return int|string
+ */
+ function addQuotes( $s ) {
+ if ( is_null( $s ) ) {
+ return 'NULL';
+ } elseif ( is_bool( $s ) ) {
+ return intval( $s );
+ } elseif ( $s instanceof Blob ) {
+ if ( $s instanceof PostgresBlob ) {
+ $s = $s->fetch();
+ } else {
+ $s = pg_escape_bytea( $this->mConn, $s->fetch() );
+ }
+ return "'$s'";
+ }
+
+ return "'" . pg_escape_string( $this->mConn, $s ) . "'";
+ }
+
+ /**
+ * Postgres specific version of replaceVars.
+ * Calls the parent version in Database.php
+ *
+ * @param string $ins SQL string, read from a stream (usually tables.sql)
+ * @return string SQL string
+ */
+ protected function replaceVars( $ins ) {
+ $ins = parent::replaceVars( $ins );
+
+ if ( $this->numericVersion >= 8.3 ) {
+ // Thanks for not providing backwards-compatibility, 8.3
+ $ins = preg_replace( "/to_tsvector\s*\(\s*'default'\s*,/", 'to_tsvector(', $ins );
+ }
+
+ if ( $this->numericVersion <= 8.1 ) { // Our minimum version
+ $ins = str_replace( 'USING gin', 'USING gist', $ins );
+ }
+
+ return $ins;
+ }
+
+ /**
+ * Various select options
+ *
+ * @param array $options An associative array of options to be turned into
+ * an SQL query, valid keys are listed in the function.
+ * @return array
+ */
+ function makeSelectOptions( $options ) {
+ $preLimitTail = $postLimitTail = '';
+ $startOpts = $useIndex = '';
+
+ $noKeyOptions = [];
+ foreach ( $options as $key => $option ) {
+ if ( is_numeric( $key ) ) {
+ $noKeyOptions[$option] = true;
+ }
+ }
+
+ $preLimitTail .= $this->makeGroupByWithHaving( $options );
+
+ $preLimitTail .= $this->makeOrderBy( $options );
+
+ // if ( isset( $options['LIMIT'] ) ) {
+ // $tailOpts .= $this->limitResult( '', $options['LIMIT'],
+ // isset( $options['OFFSET'] ) ? $options['OFFSET']
+ // : false );
+ // }
+
+ if ( isset( $options['FOR UPDATE'] ) ) {
+ $postLimitTail .= ' FOR UPDATE OF ' .
+ implode( ', ', array_map( [ $this, 'tableName' ], $options['FOR UPDATE'] ) );
+ } elseif ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
+ $postLimitTail .= ' FOR UPDATE';
+ }
+
+ if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
+ $startOpts .= 'DISTINCT';
+ }
+
+ return [ $startOpts, $useIndex, $preLimitTail, $postLimitTail ];
+ }
+
+ function getDBname() {
+ return $this->mDBname;
+ }
+
+ function getServer() {
+ return $this->mServer;
+ }
+
+ function buildConcat( $stringList ) {
+ return implode( ' || ', $stringList );
+ }
+
+ public function buildGroupConcatField(
+ $delimiter, $table, $field, $conds = '', $options = [], $join_conds = []
+ ) {
+ $fld = "array_to_string(array_agg($field)," . $this->addQuotes( $delimiter ) . ')';
+
+ return '(' . $this->selectSQLText( $table, $fld, $conds, null, [], $join_conds ) . ')';
+ }
+
+ public function getSearchEngine() {
+ return 'SearchPostgres';
+ }
+
+ public function streamStatementEnd( &$sql, &$newLine ) {
+ # Allow dollar quoting for function declarations
+ if ( substr( $newLine, 0, 4 ) == '$mw$' ) {
+ if ( $this->delimiter ) {
+ $this->delimiter = false;
+ } else {
+ $this->delimiter = ';';
+ }
+ }
+
+ return parent::streamStatementEnd( $sql, $newLine );
+ }
+
+ /**
+ * Check to see if a named lock is available. This is non-blocking.
+ * See http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
+ *
+ * @param string $lockName Name of lock to poll
+ * @param string $method Name of method calling us
+ * @return bool
+ * @since 1.20
+ */
+ public function lockIsFree( $lockName, $method ) {
+ $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) );
+ $result = $this->query( "SELECT (CASE(pg_try_advisory_lock($key))
+ WHEN 'f' THEN 'f' ELSE pg_advisory_unlock($key) END) AS lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ return ( $row->lockstatus === 't' );
+ }
+
+ /**
+ * See http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
+ * @param string $lockName
+ * @param string $method
+ * @param int $timeout
+ * @return bool
+ */
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) );
+ for ( $attempts = 1; $attempts <= $timeout; ++$attempts ) {
+ $result = $this->query(
+ "SELECT pg_try_advisory_lock($key) AS lockstatus", $method );
+ $row = $this->fetchObject( $result );
+ if ( $row->lockstatus === 't' ) {
+ parent::lock( $lockName, $method, $timeout ); // record
+ return true;
+ } else {
+ sleep( 1 );
+ }
+ }
+
+ wfDebug( __METHOD__ . " failed to acquire lock\n" );
+
+ return false;
+ }
+
+ /**
+ * See http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKSFROM
+ * PG DOCS: http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
+ * @param string $lockName
+ * @param string $method
+ * @return bool
+ */
+ public function unlock( $lockName, $method ) {
+ $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) );
+ $result = $this->query( "SELECT pg_advisory_unlock($key) as lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ if ( $row->lockstatus === 't' ) {
+ parent::unlock( $lockName, $method ); // record
+ return true;
+ }
+
+ wfDebug( __METHOD__ . " failed to release lock\n" );
+
+ return false;
+ }
+
+ /**
+ * @param string $lockName
+ * @return string Integer
+ */
+ private function bigintFromLockName( $lockName ) {
+ return Wikimedia\base_convert( substr( sha1( $lockName ), 0, 15 ), 16, 10 );
+ }
+} // end DatabasePostgres class
+
+class PostgresBlob extends Blob {
+}
diff --git a/www/wiki/includes/db/DatabaseSqlite.php b/www/wiki/includes/db/DatabaseSqlite.php
new file mode 100644
index 00000000..9d0a0f71
--- /dev/null
+++ b/www/wiki/includes/db/DatabaseSqlite.php
@@ -0,0 +1,1085 @@
+<?php
+/**
+ * This is the SQLite database abstraction layer.
+ * See maintenance/sqlite/README for development notes and other specific information
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * @ingroup Database
+ */
+class DatabaseSqlite extends Database {
+ /** @var bool Whether full text is enabled */
+ private static $fulltextEnabled = null;
+
+ /** @var string Directory */
+ protected $dbDir;
+
+ /** @var string File name for SQLite database file */
+ protected $dbPath;
+
+ /** @var string Transaction mode */
+ protected $trxMode;
+
+ /** @var int The number of rows affected as an integer */
+ protected $mAffectedRows;
+
+ /** @var resource */
+ protected $mLastResult;
+
+ /** @var PDO */
+ protected $mConn;
+
+ /** @var FSLockManager (hopefully on the same server as the DB) */
+ protected $lockMgr;
+
+ /**
+ * Additional params include:
+ * - dbDirectory : directory containing the DB and the lock file directory
+ * [defaults to $wgSQLiteDataDir]
+ * - dbFilePath : use this to force the path of the DB file
+ * - trxMode : one of (deferred, immediate, exclusive)
+ * @param array $p
+ */
+ function __construct( array $p ) {
+ global $wgSharedDB, $wgSQLiteDataDir;
+
+ $this->dbDir = isset( $p['dbDirectory'] ) ? $p['dbDirectory'] : $wgSQLiteDataDir;
+
+ if ( isset( $p['dbFilePath'] ) ) {
+ parent::__construct( $p );
+ // Standalone .sqlite file mode.
+ // Super doesn't open when $user is false, but we can work with $dbName,
+ // which is derived from the file path in this case.
+ $this->openFile( $p['dbFilePath'] );
+ } else {
+ $this->mDBname = $p['dbname'];
+ // Stock wiki mode using standard file names per DB.
+ parent::__construct( $p );
+ // Super doesn't open when $user is false, but we can work with $dbName
+ if ( $p['dbname'] && !$this->isOpen() ) {
+ if ( $this->open( $p['host'], $p['user'], $p['password'], $p['dbname'] ) ) {
+ if ( $wgSharedDB ) {
+ $this->attachDatabase( $wgSharedDB );
+ }
+ }
+ }
+ }
+
+ $this->trxMode = isset( $p['trxMode'] ) ? strtoupper( $p['trxMode'] ) : null;
+ if ( $this->trxMode &&
+ !in_array( $this->trxMode, [ 'DEFERRED', 'IMMEDIATE', 'EXCLUSIVE' ] )
+ ) {
+ $this->trxMode = null;
+ wfWarn( "Invalid SQLite transaction mode provided." );
+ }
+
+ $this->lockMgr = new FSLockManager( [ 'lockDirectory' => "{$this->dbDir}/locks" ] );
+ }
+
+ /**
+ * @param string $filename
+ * @param array $p Options map; supports:
+ * - flags : (same as __construct counterpart)
+ * - trxMode : (same as __construct counterpart)
+ * - dbDirectory : (same as __construct counterpart)
+ * @return DatabaseSqlite
+ * @since 1.25
+ */
+ public static function newStandaloneInstance( $filename, array $p = [] ) {
+ $p['dbFilePath'] = $filename;
+ $p['schema'] = false;
+ $p['tablePrefix'] = '';
+
+ return DatabaseBase::factory( 'sqlite', $p );
+ }
+
+ /**
+ * @return string
+ */
+ function getType() {
+ return 'sqlite';
+ }
+
+ /**
+ * @todo Check if it should be true like parent class
+ *
+ * @return bool
+ */
+ function implicitGroupby() {
+ return false;
+ }
+
+ /** Open an SQLite database and return a resource handle to it
+ * NOTE: only $dbName is used, the other parameters are irrelevant for SQLite databases
+ *
+ * @param string $server
+ * @param string $user
+ * @param string $pass
+ * @param string $dbName
+ *
+ * @throws DBConnectionError
+ * @return PDO
+ */
+ function open( $server, $user, $pass, $dbName ) {
+ $this->close();
+ $fileName = self::generateFileName( $this->dbDir, $dbName );
+ if ( !is_readable( $fileName ) ) {
+ $this->mConn = false;
+ throw new DBConnectionError( $this, "SQLite database not accessible" );
+ }
+ $this->openFile( $fileName );
+
+ return $this->mConn;
+ }
+
+ /**
+ * Opens a database file
+ *
+ * @param string $fileName
+ * @throws DBConnectionError
+ * @return PDO|bool SQL connection or false if failed
+ */
+ protected function openFile( $fileName ) {
+ $err = false;
+
+ $this->dbPath = $fileName;
+ try {
+ if ( $this->mFlags & DBO_PERSISTENT ) {
+ $this->mConn = new PDO( "sqlite:$fileName", '', '',
+ [ PDO::ATTR_PERSISTENT => true ] );
+ } else {
+ $this->mConn = new PDO( "sqlite:$fileName", '', '' );
+ }
+ } catch ( PDOException $e ) {
+ $err = $e->getMessage();
+ }
+
+ if ( !$this->mConn ) {
+ wfDebug( "DB connection error: $err\n" );
+ throw new DBConnectionError( $this, $err );
+ }
+
+ $this->mOpened = !!$this->mConn;
+ if ( $this->mOpened ) {
+ # Set error codes only, don't raise exceptions
+ $this->mConn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT );
+ # Enforce LIKE to be case sensitive, just like MySQL
+ $this->query( 'PRAGMA case_sensitive_like = 1' );
+
+ return $this->mConn;
+ }
+
+ return false;
+ }
+
+ /**
+ * @return string SQLite DB file path
+ * @since 1.25
+ */
+ public function getDbFilePath() {
+ return $this->dbPath;
+ }
+
+ /**
+ * Does not actually close the connection, just destroys the reference for GC to do its work
+ * @return bool
+ */
+ protected function closeConnection() {
+ $this->mConn = null;
+
+ return true;
+ }
+
+ /**
+ * Generates a database file name. Explicitly public for installer.
+ * @param string $dir Directory where database resides
+ * @param string $dbName Database name
+ * @return string
+ */
+ public static function generateFileName( $dir, $dbName ) {
+ return "$dir/$dbName.sqlite";
+ }
+
+ /**
+ * Check if the searchindext table is FTS enabled.
+ * @return bool False if not enabled.
+ */
+ function checkForEnabledSearch() {
+ if ( self::$fulltextEnabled === null ) {
+ self::$fulltextEnabled = false;
+ $table = $this->tableName( 'searchindex' );
+ $res = $this->query( "SELECT sql FROM sqlite_master WHERE tbl_name = '$table'", __METHOD__ );
+ if ( $res ) {
+ $row = $res->fetchRow();
+ self::$fulltextEnabled = stristr( $row['sql'], 'fts' ) !== false;
+ }
+ }
+
+ return self::$fulltextEnabled;
+ }
+
+ /**
+ * Returns version of currently supported SQLite fulltext search module or false if none present.
+ * @return string
+ */
+ static function getFulltextSearchModule() {
+ static $cachedResult = null;
+ if ( $cachedResult !== null ) {
+ return $cachedResult;
+ }
+ $cachedResult = false;
+ $table = 'dummy_search_test';
+
+ $db = self::newStandaloneInstance( ':memory:' );
+ if ( $db->query( "CREATE VIRTUAL TABLE $table USING FTS3(dummy_field)", __METHOD__, true ) ) {
+ $cachedResult = 'FTS3';
+ }
+ $db->close();
+
+ return $cachedResult;
+ }
+
+ /**
+ * Attaches external database to our connection, see http://sqlite.org/lang_attach.html
+ * for details.
+ *
+ * @param string $name Database name to be used in queries like
+ * SELECT foo FROM dbname.table
+ * @param bool|string $file Database file name. If omitted, will be generated
+ * using $name and configured data directory
+ * @param string $fname Calling function name
+ * @return ResultWrapper
+ */
+ function attachDatabase( $name, $file = false, $fname = __METHOD__ ) {
+ if ( !$file ) {
+ $file = self::generateFileName( $this->dbDir, $name );
+ }
+ $file = $this->addQuotes( $file );
+
+ return $this->query( "ATTACH DATABASE $file AS $name", $fname );
+ }
+
+ /**
+ * @see DatabaseBase::isWriteQuery()
+ *
+ * @param string $sql
+ * @return bool
+ */
+ function isWriteQuery( $sql ) {
+ return parent::isWriteQuery( $sql ) && !preg_match( '/^(ATTACH|PRAGMA)\b/i', $sql );
+ }
+
+ /**
+ * SQLite doesn't allow buffered results or data seeking etc, so we'll use fetchAll as the result
+ *
+ * @param string $sql
+ * @return bool|ResultWrapper
+ */
+ protected function doQuery( $sql ) {
+ $res = $this->mConn->query( $sql );
+ if ( $res === false ) {
+ return false;
+ } else {
+ $r = $res instanceof ResultWrapper ? $res->result : $res;
+ $this->mAffectedRows = $r->rowCount();
+ $res = new ResultWrapper( $this, $r->fetchAll() );
+ }
+
+ return $res;
+ }
+
+ /**
+ * @param ResultWrapper|mixed $res
+ */
+ function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res->result = null;
+ } else {
+ $res = null;
+ }
+ }
+
+ /**
+ * @param ResultWrapper|array $res
+ * @return stdClass|bool
+ */
+ function fetchObject( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $r =& $res->result;
+ } else {
+ $r =& $res;
+ }
+
+ $cur = current( $r );
+ if ( is_array( $cur ) ) {
+ next( $r );
+ $obj = new stdClass;
+ foreach ( $cur as $k => $v ) {
+ if ( !is_numeric( $k ) ) {
+ $obj->$k = $v;
+ }
+ }
+
+ return $obj;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param ResultWrapper|mixed $res
+ * @return array|bool
+ */
+ function fetchRow( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $r =& $res->result;
+ } else {
+ $r =& $res;
+ }
+ $cur = current( $r );
+ if ( is_array( $cur ) ) {
+ next( $r );
+
+ return $cur;
+ }
+
+ return false;
+ }
+
+ /**
+ * The PDO::Statement class implements the array interface so count() will work
+ *
+ * @param ResultWrapper|array $res
+ * @return int
+ */
+ function numRows( $res ) {
+ $r = $res instanceof ResultWrapper ? $res->result : $res;
+
+ return count( $r );
+ }
+
+ /**
+ * @param ResultWrapper $res
+ * @return int
+ */
+ function numFields( $res ) {
+ $r = $res instanceof ResultWrapper ? $res->result : $res;
+ if ( is_array( $r ) && count( $r ) > 0 ) {
+ // The size of the result array is twice the number of fields. (Bug: 65578)
+ return count( $r[0] ) / 2;
+ } else {
+ // If the result is empty return 0
+ return 0;
+ }
+ }
+
+ /**
+ * @param ResultWrapper $res
+ * @param int $n
+ * @return bool
+ */
+ function fieldName( $res, $n ) {
+ $r = $res instanceof ResultWrapper ? $res->result : $res;
+ if ( is_array( $r ) ) {
+ $keys = array_keys( $r[0] );
+
+ return $keys[$n];
+ }
+
+ return false;
+ }
+
+ /**
+ * Use MySQL's naming (accounts for prefix etc) but remove surrounding backticks
+ *
+ * @param string $name
+ * @param string $format
+ * @return string
+ */
+ function tableName( $name, $format = 'quoted' ) {
+ // table names starting with sqlite_ are reserved
+ if ( strpos( $name, 'sqlite_' ) === 0 ) {
+ return $name;
+ }
+
+ return str_replace( '"', '', parent::tableName( $name, $format ) );
+ }
+
+ /**
+ * Index names have DB scope
+ *
+ * @param string $index
+ * @return string
+ */
+ protected function indexName( $index ) {
+ return $index;
+ }
+
+ /**
+ * This must be called after nextSequenceVal
+ *
+ * @return int
+ */
+ function insertId() {
+ // PDO::lastInsertId yields a string :(
+ return intval( $this->mConn->lastInsertId() );
+ }
+
+ /**
+ * @param ResultWrapper|array $res
+ * @param int $row
+ */
+ function dataSeek( $res, $row ) {
+ if ( $res instanceof ResultWrapper ) {
+ $r =& $res->result;
+ } else {
+ $r =& $res;
+ }
+ reset( $r );
+ if ( $row > 0 ) {
+ for ( $i = 0; $i < $row; $i++ ) {
+ next( $r );
+ }
+ }
+ }
+
+ /**
+ * @return string
+ */
+ function lastError() {
+ if ( !is_object( $this->mConn ) ) {
+ return "Cannot return last error, no db connection";
+ }
+ $e = $this->mConn->errorInfo();
+
+ return isset( $e[2] ) ? $e[2] : '';
+ }
+
+ /**
+ * @return string
+ */
+ function lastErrno() {
+ if ( !is_object( $this->mConn ) ) {
+ return "Cannot return last error, no db connection";
+ } else {
+ $info = $this->mConn->errorInfo();
+
+ return $info[1];
+ }
+ }
+
+ /**
+ * @return int
+ */
+ function affectedRows() {
+ return $this->mAffectedRows;
+ }
+
+ /**
+ * Returns information about an index
+ * Returns false if the index does not exist
+ * - if errors are explicitly ignored, returns NULL on failure
+ *
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return array
+ */
+ function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ $sql = 'PRAGMA index_info(' . $this->addQuotes( $this->indexName( $index ) ) . ')';
+ $res = $this->query( $sql, $fname );
+ if ( !$res ) {
+ return null;
+ }
+ if ( $res->numRows() == 0 ) {
+ return false;
+ }
+ $info = [];
+ foreach ( $res as $row ) {
+ $info[] = $row->name;
+ }
+
+ return $info;
+ }
+
+ /**
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool|null
+ */
+ function indexUnique( $table, $index, $fname = __METHOD__ ) {
+ $row = $this->selectRow( 'sqlite_master', '*',
+ [
+ 'type' => 'index',
+ 'name' => $this->indexName( $index ),
+ ], $fname );
+ if ( !$row || !isset( $row->sql ) ) {
+ return null;
+ }
+
+ // $row->sql will be of the form CREATE [UNIQUE] INDEX ...
+ $indexPos = strpos( $row->sql, 'INDEX' );
+ if ( $indexPos === false ) {
+ return null;
+ }
+ $firstPart = substr( $row->sql, 0, $indexPos );
+ $options = explode( ' ', $firstPart );
+
+ return in_array( 'UNIQUE', $options );
+ }
+
+ /**
+ * Filter the options used in SELECT statements
+ *
+ * @param array $options
+ * @return array
+ */
+ function makeSelectOptions( $options ) {
+ foreach ( $options as $k => $v ) {
+ if ( is_numeric( $k ) && ( $v == 'FOR UPDATE' || $v == 'LOCK IN SHARE MODE' ) ) {
+ $options[$k] = '';
+ }
+ }
+
+ return parent::makeSelectOptions( $options );
+ }
+
+ /**
+ * @param array $options
+ * @return string
+ */
+ protected function makeUpdateOptionsArray( $options ) {
+ $options = parent::makeUpdateOptionsArray( $options );
+ $options = self::fixIgnore( $options );
+
+ return $options;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ static function fixIgnore( $options ) {
+ # SQLite uses OR IGNORE not just IGNORE
+ foreach ( $options as $k => $v ) {
+ if ( $v == 'IGNORE' ) {
+ $options[$k] = 'OR IGNORE';
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * @param array $options
+ * @return string
+ */
+ function makeInsertOptions( $options ) {
+ $options = self::fixIgnore( $options );
+
+ return parent::makeInsertOptions( $options );
+ }
+
+ /**
+ * Based on generic method (parent) with some prior SQLite-sepcific adjustments
+ * @param string $table
+ * @param array $a
+ * @param string $fname
+ * @param array $options
+ * @return bool
+ */
+ function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
+ if ( !count( $a ) ) {
+ return true;
+ }
+
+ # SQLite can't handle multi-row inserts, so divide up into multiple single-row inserts
+ if ( isset( $a[0] ) && is_array( $a[0] ) ) {
+ $ret = true;
+ foreach ( $a as $v ) {
+ if ( !parent::insert( $table, $v, "$fname/multi-row", $options ) ) {
+ $ret = false;
+ }
+ }
+ } else {
+ $ret = parent::insert( $table, $a, "$fname/single-row", $options );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $table
+ * @param array $uniqueIndexes Unused
+ * @param string|array $rows
+ * @param string $fname
+ * @return bool|ResultWrapper
+ */
+ function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+ if ( !count( $rows ) ) {
+ return true;
+ }
+
+ # SQLite can't handle multi-row replaces, so divide up into multiple single-row queries
+ if ( isset( $rows[0] ) && is_array( $rows[0] ) ) {
+ $ret = true;
+ foreach ( $rows as $v ) {
+ if ( !$this->nativeReplace( $table, $v, "$fname/multi-row" ) ) {
+ $ret = false;
+ }
+ }
+ } else {
+ $ret = $this->nativeReplace( $table, $rows, "$fname/single-row" );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Returns the size of a text field, or -1 for "unlimited"
+ * In SQLite this is SQLITE_MAX_LENGTH, by default 1GB. No way to query it though.
+ *
+ * @param string $table
+ * @param string $field
+ * @return int
+ */
+ function textFieldSize( $table, $field ) {
+ return -1;
+ }
+
+ /**
+ * @return bool
+ */
+ function unionSupportsOrderAndLimit() {
+ return false;
+ }
+
+ /**
+ * @param string $sqls
+ * @param bool $all Whether to "UNION ALL" or not
+ * @return string
+ */
+ function unionQueries( $sqls, $all ) {
+ $glue = $all ? ' UNION ALL ' : ' UNION ';
+
+ return implode( $glue, $sqls );
+ }
+
+ /**
+ * @return bool
+ */
+ function wasDeadlock() {
+ return $this->lastErrno() == 5; // SQLITE_BUSY
+ }
+
+ /**
+ * @return bool
+ */
+ function wasErrorReissuable() {
+ return $this->lastErrno() == 17; // SQLITE_SCHEMA;
+ }
+
+ /**
+ * @return bool
+ */
+ function wasReadOnlyError() {
+ return $this->lastErrno() == 8; // SQLITE_READONLY;
+ }
+
+ /**
+ * @return string Wikitext of a link to the server software's web site
+ */
+ public function getSoftwareLink() {
+ return "[{{int:version-db-sqlite-url}} SQLite]";
+ }
+
+ /**
+ * @return string Version information from the database
+ */
+ function getServerVersion() {
+ $ver = $this->mConn->getAttribute( PDO::ATTR_SERVER_VERSION );
+
+ return $ver;
+ }
+
+ /**
+ * @return string User-friendly database information
+ */
+ public function getServerInfo() {
+ return wfMessage( self::getFulltextSearchModule()
+ ? 'sqlite-has-fts'
+ : 'sqlite-no-fts', $this->getServerVersion() )->text();
+ }
+
+ /**
+ * Get information about a given field
+ * Returns false if the field does not exist.
+ *
+ * @param string $table
+ * @param string $field
+ * @return SQLiteField|bool False on failure
+ */
+ function fieldInfo( $table, $field ) {
+ $tableName = $this->tableName( $table );
+ $sql = 'PRAGMA table_info(' . $this->addQuotes( $tableName ) . ')';
+ $res = $this->query( $sql, __METHOD__ );
+ foreach ( $res as $row ) {
+ if ( $row->name == $field ) {
+ return new SQLiteField( $row, $tableName );
+ }
+ }
+
+ return false;
+ }
+
+ protected function doBegin( $fname = '' ) {
+ if ( $this->trxMode ) {
+ $this->query( "BEGIN {$this->trxMode}", $fname );
+ } else {
+ $this->query( 'BEGIN', $fname );
+ }
+ $this->mTrxLevel = 1;
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ function strencode( $s ) {
+ return substr( $this->addQuotes( $s ), 1, -1 );
+ }
+
+ /**
+ * @param string $b
+ * @return Blob
+ */
+ function encodeBlob( $b ) {
+ return new Blob( $b );
+ }
+
+ /**
+ * @param Blob|string $b
+ * @return string
+ */
+ function decodeBlob( $b ) {
+ if ( $b instanceof Blob ) {
+ $b = $b->fetch();
+ }
+
+ return $b;
+ }
+
+ /**
+ * @param Blob|string $s
+ * @return string
+ */
+ function addQuotes( $s ) {
+ if ( $s instanceof Blob ) {
+ return "x'" . bin2hex( $s->fetch() ) . "'";
+ } elseif ( is_bool( $s ) ) {
+ return (int)$s;
+ } elseif ( strpos( $s, "\0" ) !== false ) {
+ // SQLite doesn't support \0 in strings, so use the hex representation as a workaround.
+ // This is a known limitation of SQLite's mprintf function which PDO
+ // should work around, but doesn't. I have reported this to php.net as bug #63419:
+ // https://bugs.php.net/bug.php?id=63419
+ // There was already a similar report for SQLite3::escapeString, bug #62361:
+ // https://bugs.php.net/bug.php?id=62361
+ // There is an additional bug regarding sorting this data after insert
+ // on older versions of sqlite shipped with ubuntu 12.04
+ // https://phabricator.wikimedia.org/T74367
+ wfDebugLog(
+ __CLASS__,
+ __FUNCTION__ .
+ ': Quoting value containing null byte. ' .
+ 'For consistency all binary data should have been ' .
+ 'first processed with self::encodeBlob()'
+ );
+ return "x'" . bin2hex( $s ) . "'";
+ } else {
+ return $this->mConn->quote( $s );
+ }
+ }
+
+ /**
+ * @return string
+ */
+ function buildLike() {
+ $params = func_get_args();
+ if ( count( $params ) > 0 && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+
+ return parent::buildLike( $params ) . "ESCAPE '\' ";
+ }
+
+ /**
+ * @return string
+ */
+ public function getSearchEngine() {
+ return "SearchSqlite";
+ }
+
+ /**
+ * No-op version of deadlockLoop
+ *
+ * @return mixed
+ */
+ public function deadlockLoop( /*...*/ ) {
+ $args = func_get_args();
+ $function = array_shift( $args );
+
+ return call_user_func_array( $function, $args );
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ protected function replaceVars( $s ) {
+ $s = parent::replaceVars( $s );
+ if ( preg_match( '/^\s*(CREATE|ALTER) TABLE/i', $s ) ) {
+ // CREATE TABLE hacks to allow schema file sharing with MySQL
+
+ // binary/varbinary column type -> blob
+ $s = preg_replace( '/\b(var)?binary(\(\d+\))/i', 'BLOB', $s );
+ // no such thing as unsigned
+ $s = preg_replace( '/\b(un)?signed\b/i', '', $s );
+ // INT -> INTEGER
+ $s = preg_replace( '/\b(tiny|small|medium|big|)int(\s*\(\s*\d+\s*\)|\b)/i', 'INTEGER', $s );
+ // floating point types -> REAL
+ $s = preg_replace(
+ '/\b(float|double(\s+precision)?)(\s*\(\s*\d+\s*(,\s*\d+\s*)?\)|\b)/i',
+ 'REAL',
+ $s
+ );
+ // varchar -> TEXT
+ $s = preg_replace( '/\b(var)?char\s*\(.*?\)/i', 'TEXT', $s );
+ // TEXT normalization
+ $s = preg_replace( '/\b(tiny|medium|long)text\b/i', 'TEXT', $s );
+ // BLOB normalization
+ $s = preg_replace( '/\b(tiny|small|medium|long|)blob\b/i', 'BLOB', $s );
+ // BOOL -> INTEGER
+ $s = preg_replace( '/\bbool(ean)?\b/i', 'INTEGER', $s );
+ // DATETIME -> TEXT
+ $s = preg_replace( '/\b(datetime|timestamp)\b/i', 'TEXT', $s );
+ // No ENUM type
+ $s = preg_replace( '/\benum\s*\([^)]*\)/i', 'TEXT', $s );
+ // binary collation type -> nothing
+ $s = preg_replace( '/\bbinary\b/i', '', $s );
+ // auto_increment -> autoincrement
+ $s = preg_replace( '/\bauto_increment\b/i', 'AUTOINCREMENT', $s );
+ // No explicit options
+ $s = preg_replace( '/\)[^);]*(;?)\s*$/', ')\1', $s );
+ // AUTOINCREMENT should immedidately follow PRIMARY KEY
+ $s = preg_replace( '/primary key (.*?) autoincrement/i', 'PRIMARY KEY AUTOINCREMENT $1', $s );
+ } elseif ( preg_match( '/^\s*CREATE (\s*(?:UNIQUE|FULLTEXT)\s+)?INDEX/i', $s ) ) {
+ // No truncated indexes
+ $s = preg_replace( '/\(\d+\)/', '', $s );
+ // No FULLTEXT
+ $s = preg_replace( '/\bfulltext\b/i', '', $s );
+ } elseif ( preg_match( '/^\s*DROP INDEX/i', $s ) ) {
+ // DROP INDEX is database-wide, not table-specific, so no ON <table> clause.
+ $s = preg_replace( '/\sON\s+[^\s]*/i', '', $s );
+ } elseif ( preg_match( '/^\s*INSERT IGNORE\b/i', $s ) ) {
+ // INSERT IGNORE --> INSERT OR IGNORE
+ $s = preg_replace( '/^\s*INSERT IGNORE\b/i', 'INSERT OR IGNORE', $s );
+ }
+
+ return $s;
+ }
+
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ if ( !is_dir( "{$this->dbDir}/locks" ) ) { // create dir as needed
+ if ( !is_writable( $this->dbDir ) || !mkdir( "{$this->dbDir}/locks" ) ) {
+ throw new DBError( "Cannot create directory \"{$this->dbDir}/locks\"." );
+ }
+ }
+
+ return $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout )->isOK();
+ }
+
+ public function unlock( $lockName, $method ) {
+ return $this->lockMgr->unlock( [ $lockName ], LockManager::LOCK_EX )->isOK();
+ }
+
+ /**
+ * Build a concatenation list to feed into a SQL query
+ *
+ * @param string[] $stringList
+ * @return string
+ */
+ function buildConcat( $stringList ) {
+ return '(' . implode( ') || (', $stringList ) . ')';
+ }
+
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ ) {
+ $fld = "group_concat($field," . $this->addQuotes( $delim ) . ')';
+
+ return '(' . $this->selectSQLText( $table, $fld, $conds, null, [], $join_conds ) . ')';
+ }
+
+ /**
+ * @throws MWException
+ * @param string $oldName
+ * @param string $newName
+ * @param bool $temporary
+ * @param string $fname
+ * @return bool|ResultWrapper
+ */
+ function duplicateTableStructure( $oldName, $newName, $temporary = false, $fname = __METHOD__ ) {
+ $res = $this->query( "SELECT sql FROM sqlite_master WHERE tbl_name=" .
+ $this->addQuotes( $oldName ) . " AND type='table'", $fname );
+ $obj = $this->fetchObject( $res );
+ if ( !$obj ) {
+ throw new MWException( "Couldn't retrieve structure for table $oldName" );
+ }
+ $sql = $obj->sql;
+ $sql = preg_replace(
+ '/(?<=\W)"?' . preg_quote( trim( $this->addIdentifierQuotes( $oldName ), '"' ) ) . '"?(?=\W)/',
+ $this->addIdentifierQuotes( $newName ),
+ $sql,
+ 1
+ );
+ if ( $temporary ) {
+ if ( preg_match( '/^\\s*CREATE\\s+VIRTUAL\\s+TABLE\b/i', $sql ) ) {
+ wfDebug( "Table $oldName is virtual, can't create a temporary duplicate.\n" );
+ } else {
+ $sql = str_replace( 'CREATE TABLE', 'CREATE TEMPORARY TABLE', $sql );
+ }
+ }
+
+ $res = $this->query( $sql, $fname );
+
+ // Take over indexes
+ $indexList = $this->query( 'PRAGMA INDEX_LIST(' . $this->addQuotes( $oldName ) . ')' );
+ foreach ( $indexList as $index ) {
+ if ( strpos( $index->name, 'sqlite_autoindex' ) === 0 ) {
+ continue;
+ }
+
+ if ( $index->unique ) {
+ $sql = 'CREATE UNIQUE INDEX';
+ } else {
+ $sql = 'CREATE INDEX';
+ }
+ // Try to come up with a new index name, given indexes have database scope in SQLite
+ $indexName = $newName . '_' . $index->name;
+ $sql .= ' ' . $indexName . ' ON ' . $newName;
+
+ $indexInfo = $this->query( 'PRAGMA INDEX_INFO(' . $this->addQuotes( $index->name ) . ')' );
+ $fields = [];
+ foreach ( $indexInfo as $indexInfoRow ) {
+ $fields[$indexInfoRow->seqno] = $indexInfoRow->name;
+ }
+
+ $sql .= '(' . implode( ',', $fields ) . ')';
+
+ $this->query( $sql );
+ }
+
+ return $res;
+ }
+
+ /**
+ * List all tables on the database
+ *
+ * @param string $prefix Only show tables with this prefix, e.g. mw_
+ * @param string $fname Calling function name
+ *
+ * @return array
+ */
+ function listTables( $prefix = null, $fname = __METHOD__ ) {
+ $result = $this->select(
+ 'sqlite_master',
+ 'name',
+ "type='table'"
+ );
+
+ $endArray = [];
+
+ foreach ( $result as $table ) {
+ $vars = get_object_vars( $table );
+ $table = array_pop( $vars );
+
+ if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
+ if ( strpos( $table, 'sqlite_' ) !== 0 ) {
+ $endArray[] = $table;
+ }
+ }
+ }
+
+ return $endArray;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString() {
+ return 'SQLite ' . (string)$this->mConn->getAttribute( PDO::ATTR_SERVER_VERSION );
+ }
+
+} // end DatabaseSqlite class
+
+/**
+ * @ingroup Database
+ */
+class SQLiteField implements Field {
+ private $info, $tableName;
+
+ function __construct( $info, $tableName ) {
+ $this->info = $info;
+ $this->tableName = $tableName;
+ }
+
+ function name() {
+ return $this->info->name;
+ }
+
+ function tableName() {
+ return $this->tableName;
+ }
+
+ function defaultValue() {
+ if ( is_string( $this->info->dflt_value ) ) {
+ // Typically quoted
+ if ( preg_match( '/^\'(.*)\'$', $this->info->dflt_value ) ) {
+ return str_replace( "''", "'", $this->info->dflt_value );
+ }
+ }
+
+ return $this->info->dflt_value;
+ }
+
+ /**
+ * @return bool
+ */
+ function isNullable() {
+ return !$this->info->notnull;
+ }
+
+ function type() {
+ return $this->info->type;
+ }
+} // end SQLiteField
diff --git a/www/wiki/includes/db/DatabaseUtility.php b/www/wiki/includes/db/DatabaseUtility.php
new file mode 100644
index 00000000..b6c37ee7
--- /dev/null
+++ b/www/wiki/includes/db/DatabaseUtility.php
@@ -0,0 +1,347 @@
+<?php
+/**
+ * This file contains database-related utility classes.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Utility class
+ * @ingroup Database
+ *
+ * This allows us to distinguish a blob from a normal string and an array of strings
+ */
+class Blob {
+ /** @var string */
+ protected $mData;
+
+ function __construct( $data ) {
+ $this->mData = $data;
+ }
+
+ function fetch() {
+ return $this->mData;
+ }
+}
+
+/**
+ * Base for all database-specific classes representing information about database fields
+ * @ingroup Database
+ */
+interface Field {
+ /**
+ * Field name
+ * @return string
+ */
+ function name();
+
+ /**
+ * Name of table this field belongs to
+ * @return string
+ */
+ function tableName();
+
+ /**
+ * Database type
+ * @return string
+ */
+ function type();
+
+ /**
+ * Whether this field can store NULL values
+ * @return bool
+ */
+ function isNullable();
+}
+
+/**
+ * Result wrapper for grabbing data queried by someone else
+ * @ingroup Database
+ */
+class ResultWrapper implements Iterator {
+ /** @var resource */
+ public $result;
+
+ /** @var DatabaseBase */
+ protected $db;
+
+ /** @var int */
+ protected $pos = 0;
+
+ /** @var object|null */
+ protected $currentRow = null;
+
+ /**
+ * Create a new result object from a result resource and a Database object
+ *
+ * @param DatabaseBase $database
+ * @param resource|ResultWrapper $result
+ */
+ function __construct( $database, $result ) {
+ $this->db = $database;
+
+ if ( $result instanceof ResultWrapper ) {
+ $this->result = $result->result;
+ } else {
+ $this->result = $result;
+ }
+ }
+
+ /**
+ * Get the number of rows in a result object
+ *
+ * @return int
+ */
+ function numRows() {
+ return $this->db->numRows( $this );
+ }
+
+ /**
+ * Fetch the next row from the given result object, in object form. Fields can be retrieved with
+ * $row->fieldname, with fields acting like member variables. If no more rows are available,
+ * false is returned.
+ *
+ * @return stdClass|bool
+ * @throws DBUnexpectedError Thrown if the database returns an error
+ */
+ function fetchObject() {
+ return $this->db->fetchObject( $this );
+ }
+
+ /**
+ * Fetch the next row from the given result object, in associative array form. Fields are
+ * retrieved with $row['fieldname']. If no more rows are available, false is returned.
+ *
+ * @return array|bool
+ * @throws DBUnexpectedError Thrown if the database returns an error
+ */
+ function fetchRow() {
+ return $this->db->fetchRow( $this );
+ }
+
+ /**
+ * Free a result object
+ */
+ function free() {
+ $this->db->freeResult( $this );
+ unset( $this->result );
+ unset( $this->db );
+ }
+
+ /**
+ * Change the position of the cursor in a result object.
+ * See mysql_data_seek()
+ *
+ * @param int $row
+ */
+ function seek( $row ) {
+ $this->db->dataSeek( $this, $row );
+ }
+
+ /*
+ * ======= Iterator functions =======
+ * Note that using these in combination with the non-iterator functions
+ * above may cause rows to be skipped or repeated.
+ */
+
+ function rewind() {
+ if ( $this->numRows() ) {
+ $this->db->dataSeek( $this, 0 );
+ }
+ $this->pos = 0;
+ $this->currentRow = null;
+ }
+
+ /**
+ * @return stdClass|array|bool
+ */
+ function current() {
+ if ( is_null( $this->currentRow ) ) {
+ $this->next();
+ }
+
+ return $this->currentRow;
+ }
+
+ /**
+ * @return int
+ */
+ function key() {
+ return $this->pos;
+ }
+
+ /**
+ * @return stdClass
+ */
+ function next() {
+ $this->pos++;
+ $this->currentRow = $this->fetchObject();
+
+ return $this->currentRow;
+ }
+
+ /**
+ * @return bool
+ */
+ function valid() {
+ return $this->current() !== false;
+ }
+}
+
+/**
+ * Overloads the relevant methods of the real ResultsWrapper so it
+ * doesn't go anywhere near an actual database.
+ */
+class FakeResultWrapper extends ResultWrapper {
+ /** @var array */
+ public $result = [];
+
+ /** @var null And it's going to stay that way :D */
+ protected $db = null;
+
+ /** @var int */
+ protected $pos = 0;
+
+ /** @var array|stdClass|bool */
+ protected $currentRow = null;
+
+ /**
+ * @param array $array
+ */
+ function __construct( $array ) {
+ $this->result = $array;
+ }
+
+ /**
+ * @return int
+ */
+ function numRows() {
+ return count( $this->result );
+ }
+
+ /**
+ * @return array|bool
+ */
+ function fetchRow() {
+ if ( $this->pos < count( $this->result ) ) {
+ $this->currentRow = $this->result[$this->pos];
+ } else {
+ $this->currentRow = false;
+ }
+ $this->pos++;
+ if ( is_object( $this->currentRow ) ) {
+ return get_object_vars( $this->currentRow );
+ } else {
+ return $this->currentRow;
+ }
+ }
+
+ function seek( $row ) {
+ $this->pos = $row;
+ }
+
+ function free() {
+ }
+
+ /**
+ * Callers want to be able to access fields with $this->fieldName
+ * @return bool|stdClass
+ */
+ function fetchObject() {
+ $this->fetchRow();
+ if ( $this->currentRow ) {
+ return (object)$this->currentRow;
+ } else {
+ return false;
+ }
+ }
+
+ function rewind() {
+ $this->pos = 0;
+ $this->currentRow = null;
+ }
+
+ /**
+ * @return bool|stdClass
+ */
+ function next() {
+ return $this->fetchObject();
+ }
+}
+
+/**
+ * Used by DatabaseBase::buildLike() to represent characters that have special
+ * meaning in SQL LIKE clauses and thus need no escaping. Don't instantiate it
+ * manually, use DatabaseBase::anyChar() and anyString() instead.
+ */
+class LikeMatch {
+ /** @var string */
+ private $str;
+
+ /**
+ * Store a string into a LikeMatch marker object.
+ *
+ * @param string $s
+ */
+ public function __construct( $s ) {
+ $this->str = $s;
+ }
+
+ /**
+ * Return the original stored string.
+ *
+ * @return string
+ */
+ public function toString() {
+ return $this->str;
+ }
+}
+
+/**
+ * An object representing a master or slave position in a replicated setup.
+ *
+ * The implementation details of this opaque type are up to the database subclass.
+ */
+interface DBMasterPos {
+ /**
+ * @return float UNIX timestamp
+ * @since 1.25
+ */
+ public function asOfTime();
+
+ /**
+ * @param DBMasterPos $pos
+ * @return bool Whether this position is at or higher than $pos
+ * @since 1.27
+ */
+ public function hasReached( DBMasterPos $pos );
+
+ /**
+ * @param DBMasterPos $pos
+ * @return bool Whether this position appears to be for the same channel as another
+ * @since 1.27
+ */
+ public function channelsMatch( DBMasterPos $pos );
+
+ /**
+ * @return string
+ * @since 1.27
+ */
+ public function __toString();
+}
diff --git a/www/wiki/includes/db/IDatabase.php b/www/wiki/includes/db/IDatabase.php
new file mode 100644
index 00000000..710efb2c
--- /dev/null
+++ b/www/wiki/includes/db/IDatabase.php
@@ -0,0 +1,1596 @@
+<?php
+
+/**
+ * @defgroup Database Database
+ *
+ * This file deals with database interface functions
+ * and query specifics/optimisations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Basic database interface for live and lazy-loaded DB handles
+ *
+ * @todo: loosen up DB classes from MWException
+ * @note: IDatabase and DBConnRef should be updated to reflect any changes
+ * @ingroup Database
+ */
+interface IDatabase {
+ /**
+ * A string describing the current software version, and possibly
+ * other details in a user-friendly way. Will be listed on Special:Version, etc.
+ * Use getServerVersion() to get machine-friendly information.
+ *
+ * @return string Version information from the database server
+ */
+ public function getServerInfo();
+
+ /**
+ * Turns buffering of SQL result sets on (true) or off (false). Default is
+ * "on".
+ *
+ * Unbuffered queries are very troublesome in MySQL:
+ *
+ * - If another query is executed while the first query is being read
+ * out, the first query is killed. This means you can't call normal
+ * MediaWiki functions while you are reading an unbuffered query result
+ * from a normal wfGetDB() connection.
+ *
+ * - Unbuffered queries cause the MySQL server to use large amounts of
+ * memory and to hold broad locks which block other queries.
+ *
+ * If you want to limit client-side memory, it's almost always better to
+ * split up queries into batches using a LIMIT clause than to switch off
+ * buffering.
+ *
+ * @param null|bool $buffer
+ * @return null|bool The previous value of the flag
+ */
+ public function bufferResults( $buffer = null );
+
+ /**
+ * Gets the current transaction level.
+ *
+ * Historically, transactions were allowed to be "nested". This is no
+ * longer supported, so this function really only returns a boolean.
+ *
+ * @return int The previous value
+ */
+ public function trxLevel();
+
+ /**
+ * Get the UNIX timestamp of the time that the transaction was established
+ *
+ * This can be used to reason about the staleness of SELECT data
+ * in REPEATABLE-READ transaction isolation level.
+ *
+ * @return float|null Returns null if there is not active transaction
+ * @since 1.25
+ */
+ public function trxTimestamp();
+
+ /**
+ * Get/set the table prefix.
+ * @param string $prefix The table prefix to set, or omitted to leave it unchanged.
+ * @return string The previous table prefix.
+ */
+ public function tablePrefix( $prefix = null );
+
+ /**
+ * Get/set the db schema.
+ * @param string $schema The database schema to set, or omitted to leave it unchanged.
+ * @return string The previous db schema.
+ */
+ public function dbSchema( $schema = null );
+
+ /**
+ * Get properties passed down from the server info array of the load
+ * balancer.
+ *
+ * @param string $name The entry of the info array to get, or null to get the
+ * whole array
+ *
+ * @return array|mixed|null
+ */
+ public function getLBInfo( $name = null );
+
+ /**
+ * Set the LB info array, or a member of it. If called with one parameter,
+ * the LB info array is set to that parameter. If it is called with two
+ * parameters, the member with the given name is set to the given value.
+ *
+ * @param string $name
+ * @param array $value
+ */
+ public function setLBInfo( $name, $value = null );
+
+ /**
+ * Returns true if this database does an implicit sort when doing GROUP BY
+ *
+ * @return bool
+ */
+ public function implicitGroupby();
+
+ /**
+ * Returns true if this database does an implicit order by when the column has an index
+ * For example: SELECT page_title FROM page LIMIT 1
+ *
+ * @return bool
+ */
+ public function implicitOrderby();
+
+ /**
+ * Return the last query that went through IDatabase::query()
+ * @return string
+ */
+ public function lastQuery();
+
+ /**
+ * Returns true if the connection may have been used for write queries.
+ * Should return true if unsure.
+ *
+ * @return bool
+ */
+ public function doneWrites();
+
+ /**
+ * Returns the last time the connection may have been used for write queries.
+ * Should return a timestamp if unsure.
+ *
+ * @return int|float UNIX timestamp or false
+ * @since 1.24
+ */
+ public function lastDoneWrites();
+
+ /**
+ * @return bool Whether there is a transaction open with possible write queries
+ * @since 1.27
+ */
+ public function writesPending();
+
+ /**
+ * Returns true if there is a transaction open with possible write
+ * queries or transaction pre-commit/idle callbacks waiting on it to finish.
+ *
+ * @return bool
+ */
+ public function writesOrCallbacksPending();
+
+ /**
+ * Get the time spend running write queries for this transaction
+ *
+ * High times could be due to scanning, updates, locking, and such
+ *
+ * @return float|bool Returns false if not transaction is active
+ * @since 1.26
+ */
+ public function pendingWriteQueryDuration();
+
+ /**
+ * Get the list of method names that did write queries for this transaction
+ *
+ * @return array
+ * @since 1.27
+ */
+ public function pendingWriteCallers();
+
+ /**
+ * Is a connection to the database open?
+ * @return bool
+ */
+ public function isOpen();
+
+ /**
+ * Set a flag for this connection
+ *
+ * @param int $flag DBO_* constants from Defines.php:
+ * - DBO_DEBUG: output some debug info (same as debug())
+ * - DBO_NOBUFFER: don't buffer results (inverse of bufferResults())
+ * - DBO_TRX: automatically start transactions
+ * - DBO_DEFAULT: automatically sets DBO_TRX if not in command line mode
+ * and removes it in command line mode
+ * - DBO_PERSISTENT: use persistant database connection
+ */
+ public function setFlag( $flag );
+
+ /**
+ * Clear a flag for this connection
+ *
+ * @param int $flag DBO_* constants from Defines.php:
+ * - DBO_DEBUG: output some debug info (same as debug())
+ * - DBO_NOBUFFER: don't buffer results (inverse of bufferResults())
+ * - DBO_TRX: automatically start transactions
+ * - DBO_DEFAULT: automatically sets DBO_TRX if not in command line mode
+ * and removes it in command line mode
+ * - DBO_PERSISTENT: use persistant database connection
+ */
+ public function clearFlag( $flag );
+
+ /**
+ * Returns a boolean whether the flag $flag is set for this connection
+ *
+ * @param int $flag DBO_* constants from Defines.php:
+ * - DBO_DEBUG: output some debug info (same as debug())
+ * - DBO_NOBUFFER: don't buffer results (inverse of bufferResults())
+ * - DBO_TRX: automatically start transactions
+ * - DBO_PERSISTENT: use persistant database connection
+ * @return bool
+ */
+ public function getFlag( $flag );
+
+ /**
+ * General read-only accessor
+ *
+ * @param string $name
+ * @return string
+ */
+ public function getProperty( $name );
+
+ /**
+ * @return string
+ */
+ public function getWikiID();
+
+ /**
+ * Get the type of the DBMS, as it appears in $wgDBtype.
+ *
+ * @return string
+ */
+ public function getType();
+
+ /**
+ * Open a connection to the database. Usually aborts on failure
+ *
+ * @param string $server Database server host
+ * @param string $user Database user name
+ * @param string $password Database user password
+ * @param string $dbName Database name
+ * @return bool
+ * @throws DBConnectionError
+ */
+ public function open( $server, $user, $password, $dbName );
+
+ /**
+ * Fetch the next row from the given result object, in object form.
+ * Fields can be retrieved with $row->fieldname, with fields acting like
+ * member variables.
+ * If no more rows are available, false is returned.
+ *
+ * @param ResultWrapper|stdClass $res Object as returned from IDatabase::query(), etc.
+ * @return stdClass|bool
+ * @throws DBUnexpectedError Thrown if the database returns an error
+ */
+ public function fetchObject( $res );
+
+ /**
+ * Fetch the next row from the given result object, in associative array
+ * form. Fields are retrieved with $row['fieldname'].
+ * If no more rows are available, false is returned.
+ *
+ * @param ResultWrapper $res Result object as returned from IDatabase::query(), etc.
+ * @return array|bool
+ * @throws DBUnexpectedError Thrown if the database returns an error
+ */
+ public function fetchRow( $res );
+
+ /**
+ * Get the number of rows in a result object
+ *
+ * @param mixed $res A SQL result
+ * @return int
+ */
+ public function numRows( $res );
+
+ /**
+ * Get the number of fields in a result object
+ * @see http://www.php.net/mysql_num_fields
+ *
+ * @param mixed $res A SQL result
+ * @return int
+ */
+ public function numFields( $res );
+
+ /**
+ * Get a field name in a result object
+ * @see http://www.php.net/mysql_field_name
+ *
+ * @param mixed $res A SQL result
+ * @param int $n
+ * @return string
+ */
+ public function fieldName( $res, $n );
+
+ /**
+ * Get the inserted value of an auto-increment row
+ *
+ * The value inserted should be fetched from nextSequenceValue()
+ *
+ * Example:
+ * $id = $dbw->nextSequenceValue( 'page_page_id_seq' );
+ * $dbw->insert( 'page', array( 'page_id' => $id ) );
+ * $id = $dbw->insertId();
+ *
+ * @return int
+ */
+ public function insertId();
+
+ /**
+ * Change the position of the cursor in a result object
+ * @see http://www.php.net/mysql_data_seek
+ *
+ * @param mixed $res A SQL result
+ * @param int $row
+ */
+ public function dataSeek( $res, $row );
+
+ /**
+ * Get the last error number
+ * @see http://www.php.net/mysql_errno
+ *
+ * @return int
+ */
+ public function lastErrno();
+
+ /**
+ * Get a description of the last error
+ * @see http://www.php.net/mysql_error
+ *
+ * @return string
+ */
+ public function lastError();
+
+ /**
+ * mysql_fetch_field() wrapper
+ * Returns false if the field doesn't exist
+ *
+ * @param string $table Table name
+ * @param string $field Field name
+ *
+ * @return Field
+ */
+ public function fieldInfo( $table, $field );
+
+ /**
+ * Get the number of rows affected by the last write query
+ * @see http://www.php.net/mysql_affected_rows
+ *
+ * @return int
+ */
+ public function affectedRows();
+
+ /**
+ * Returns a wikitext link to the DB's website, e.g.,
+ * return "[http://www.mysql.com/ MySQL]";
+ * Should at least contain plain text, if for some reason
+ * your database has no website.
+ *
+ * @return string Wikitext of a link to the server software's web site
+ */
+ public function getSoftwareLink();
+
+ /**
+ * A string describing the current software version, like from
+ * mysql_get_server_info().
+ *
+ * @return string Version information from the database server.
+ */
+ public function getServerVersion();
+
+ /**
+ * Closes a database connection.
+ * if it is open : commits any open transactions
+ *
+ * @throws MWException
+ * @return bool Operation success. true if already closed.
+ */
+ public function close();
+
+ /**
+ * @param string $error Fallback error message, used if none is given by DB
+ * @throws DBConnectionError
+ */
+ public function reportConnectionError( $error = 'Unknown error' );
+
+ /**
+ * Run an SQL query and return the result. Normally throws a DBQueryError
+ * on failure. If errors are ignored, returns false instead.
+ *
+ * In new code, the query wrappers select(), insert(), update(), delete(),
+ * etc. should be used where possible, since they give much better DBMS
+ * independence and automatically quote or validate user input in a variety
+ * of contexts. This function is generally only useful for queries which are
+ * explicitly DBMS-dependent and are unsupported by the query wrappers, such
+ * as CREATE TABLE.
+ *
+ * However, the query wrappers themselves should call this function.
+ *
+ * @param string $sql SQL query
+ * @param string $fname Name of the calling function, for profiling/SHOW PROCESSLIST
+ * comment (you can use __METHOD__ or add some extra info)
+ * @param bool $tempIgnore Whether to avoid throwing an exception on errors...
+ * maybe best to catch the exception instead?
+ * @throws MWException
+ * @return bool|ResultWrapper True for a successful write query, ResultWrapper object
+ * for a successful read query, or false on failure if $tempIgnore set
+ */
+ public function query( $sql, $fname = __METHOD__, $tempIgnore = false );
+
+ /**
+ * Report a query error. Log the error, and if neither the object ignore
+ * flag nor the $tempIgnore flag is set, throw a DBQueryError.
+ *
+ * @param string $error
+ * @param int $errno
+ * @param string $sql
+ * @param string $fname
+ * @param bool $tempIgnore
+ * @throws DBQueryError
+ */
+ public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false );
+
+ /**
+ * Free a result object returned by query() or select(). It's usually not
+ * necessary to call this, just use unset() or let the variable holding
+ * the result object go out of scope.
+ *
+ * @param mixed $res A SQL result
+ */
+ public function freeResult( $res );
+
+ /**
+ * A SELECT wrapper which returns a single field from a single result row.
+ *
+ * Usually throws a DBQueryError on failure. If errors are explicitly
+ * ignored, returns false on failure.
+ *
+ * If no result rows are returned from the query, false is returned.
+ *
+ * @param string|array $table Table name. See IDatabase::select() for details.
+ * @param string $var The field name to select. This must be a valid SQL
+ * fragment: do not use unvalidated user input.
+ * @param string|array $cond The condition array. See IDatabase::select() for details.
+ * @param string $fname The function name of the caller.
+ * @param string|array $options The query options. See IDatabase::select() for details.
+ *
+ * @return bool|mixed The value from the field, or false on failure.
+ */
+ public function selectField(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = []
+ );
+
+ /**
+ * A SELECT wrapper which returns a list of single field values from result rows.
+ *
+ * Usually throws a DBQueryError on failure. If errors are explicitly
+ * ignored, returns false on failure.
+ *
+ * If no result rows are returned from the query, false is returned.
+ *
+ * @param string|array $table Table name. See IDatabase::select() for details.
+ * @param string $var The field name to select. This must be a valid SQL
+ * fragment: do not use unvalidated user input.
+ * @param string|array $cond The condition array. See IDatabase::select() for details.
+ * @param string $fname The function name of the caller.
+ * @param string|array $options The query options. See IDatabase::select() for details.
+ *
+ * @return bool|array The values from the field, or false on failure
+ * @since 1.25
+ */
+ public function selectFieldValues(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = []
+ );
+
+ /**
+ * Execute a SELECT query constructed using the various parameters provided.
+ * See below for full details of the parameters.
+ *
+ * @param string|array $table Table name
+ * @param string|array $vars Field names
+ * @param string|array $conds Conditions
+ * @param string $fname Caller function name
+ * @param array $options Query options
+ * @param array $join_conds Join conditions
+ *
+ *
+ * @param string|array $table
+ *
+ * May be either an array of table names, or a single string holding a table
+ * name. If an array is given, table aliases can be specified, for example:
+ *
+ * array( 'a' => 'user' )
+ *
+ * This includes the user table in the query, with the alias "a" available
+ * for use in field names (e.g. a.user_name).
+ *
+ * All of the table names given here are automatically run through
+ * DatabaseBase::tableName(), which causes the table prefix (if any) to be
+ * added, and various other table name mappings to be performed.
+ *
+ * Do not use untrusted user input as a table name. Alias names should
+ * not have characters outside of the Basic multilingual plane.
+ *
+ * @param string|array $vars
+ *
+ * May be either a field name or an array of field names. The field names
+ * can be complete fragments of SQL, for direct inclusion into the SELECT
+ * query. If an array is given, field aliases can be specified, for example:
+ *
+ * array( 'maxrev' => 'MAX(rev_id)' )
+ *
+ * This includes an expression with the alias "maxrev" in the query.
+ *
+ * If an expression is given, care must be taken to ensure that it is
+ * DBMS-independent.
+ *
+ * Untrusted user input must not be passed to this parameter.
+ *
+ * @param string|array $conds
+ *
+ * May be either a string containing a single condition, or an array of
+ * conditions. If an array is given, the conditions constructed from each
+ * element are combined with AND.
+ *
+ * Array elements may take one of two forms:
+ *
+ * - Elements with a numeric key are interpreted as raw SQL fragments.
+ * - Elements with a string key are interpreted as equality conditions,
+ * where the key is the field name.
+ * - If the value of such an array element is a scalar (such as a
+ * string), it will be treated as data and thus quoted appropriately.
+ * If it is null, an IS NULL clause will be added.
+ * - If the value is an array, an IN (...) clause will be constructed
+ * from its non-null elements, and an IS NULL clause will be added
+ * if null is present, such that the field may match any of the
+ * elements in the array. The non-null elements will be quoted.
+ *
+ * Note that expressions are often DBMS-dependent in their syntax.
+ * DBMS-independent wrappers are provided for constructing several types of
+ * expression commonly used in condition queries. See:
+ * - IDatabase::buildLike()
+ * - IDatabase::conditional()
+ *
+ * Untrusted user input is safe in the values of string keys, however untrusted
+ * input must not be used in the array key names or in the values of numeric keys.
+ * Escaping of untrusted input used in values of numeric keys should be done via
+ * IDatabase::addQuotes()
+ *
+ * @param string|array $options
+ *
+ * Optional: Array of query options. Boolean options are specified by
+ * including them in the array as a string value with a numeric key, for
+ * example:
+ *
+ * array( 'FOR UPDATE' )
+ *
+ * The supported options are:
+ *
+ * - OFFSET: Skip this many rows at the start of the result set. OFFSET
+ * with LIMIT can theoretically be used for paging through a result set,
+ * but this is discouraged in MediaWiki for performance reasons.
+ *
+ * - LIMIT: Integer: return at most this many rows. The rows are sorted
+ * and then the first rows are taken until the limit is reached. LIMIT
+ * is applied to a result set after OFFSET.
+ *
+ * - FOR UPDATE: Boolean: lock the returned rows so that they can't be
+ * changed until the next COMMIT.
+ *
+ * - DISTINCT: Boolean: return only unique result rows.
+ *
+ * - GROUP BY: May be either an SQL fragment string naming a field or
+ * expression to group by, or an array of such SQL fragments.
+ *
+ * - HAVING: May be either an string containing a HAVING clause or an array of
+ * conditions building the HAVING clause. If an array is given, the conditions
+ * constructed from each element are combined with AND.
+ *
+ * - ORDER BY: May be either an SQL fragment giving a field name or
+ * expression to order by, or an array of such SQL fragments.
+ *
+ * - USE INDEX: This may be either a string giving the index name to use
+ * for the query, or an array. If it is an associative array, each key
+ * gives the table name (or alias), each value gives the index name to
+ * use for that table. All strings are SQL fragments and so should be
+ * validated by the caller.
+ *
+ * - EXPLAIN: In MySQL, this causes an EXPLAIN SELECT query to be run,
+ * instead of SELECT.
+ *
+ * And also the following boolean MySQL extensions, see the MySQL manual
+ * for documentation:
+ *
+ * - LOCK IN SHARE MODE
+ * - STRAIGHT_JOIN
+ * - HIGH_PRIORITY
+ * - SQL_BIG_RESULT
+ * - SQL_BUFFER_RESULT
+ * - SQL_SMALL_RESULT
+ * - SQL_CALC_FOUND_ROWS
+ * - SQL_CACHE
+ * - SQL_NO_CACHE
+ *
+ *
+ * @param string|array $join_conds
+ *
+ * Optional associative array of table-specific join conditions. In the
+ * most common case, this is unnecessary, since the join condition can be
+ * in $conds. However, it is useful for doing a LEFT JOIN.
+ *
+ * The key of the array contains the table name or alias. The value is an
+ * array with two elements, numbered 0 and 1. The first gives the type of
+ * join, the second is the same as the $conds parameter. Thus it can be
+ * an SQL fragment, or an array where the string keys are equality and the
+ * numeric keys are SQL fragments all AND'd together. For example:
+ *
+ * array( 'page' => array( 'LEFT JOIN', 'page_latest=rev_id' ) )
+ *
+ * @return ResultWrapper|bool If the query returned no rows, a ResultWrapper
+ * with no rows in it will be returned. If there was a query error, a
+ * DBQueryError exception will be thrown, except if the "ignore errors"
+ * option was set, in which case false will be returned.
+ */
+ public function select(
+ $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ );
+
+ /**
+ * The equivalent of IDatabase::select() except that the constructed SQL
+ * is returned, instead of being immediately executed. This can be useful for
+ * doing UNION queries, where the SQL text of each query is needed. In general,
+ * however, callers outside of Database classes should just use select().
+ *
+ * @param string|array $table Table name
+ * @param string|array $vars Field names
+ * @param string|array $conds Conditions
+ * @param string $fname Caller function name
+ * @param string|array $options Query options
+ * @param string|array $join_conds Join conditions
+ *
+ * @return string SQL query string.
+ * @see IDatabase::select()
+ */
+ public function selectSQLText(
+ $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ );
+
+ /**
+ * Single row SELECT wrapper. Equivalent to IDatabase::select(), except
+ * that a single row object is returned. If the query returns no rows,
+ * false is returned.
+ *
+ * @param string|array $table Table name
+ * @param string|array $vars Field names
+ * @param array $conds Conditions
+ * @param string $fname Caller function name
+ * @param string|array $options Query options
+ * @param array|string $join_conds Join conditions
+ *
+ * @return stdClass|bool
+ */
+ public function selectRow( $table, $vars, $conds, $fname = __METHOD__,
+ $options = [], $join_conds = []
+ );
+
+ /**
+ * Estimate the number of rows in dataset
+ *
+ * MySQL allows you to estimate the number of rows that would be returned
+ * by a SELECT query, using EXPLAIN SELECT. The estimate is provided using
+ * index cardinality statistics, and is notoriously inaccurate, especially
+ * when large numbers of rows have recently been added or deleted.
+ *
+ * For DBMSs that don't support fast result size estimation, this function
+ * will actually perform the SELECT COUNT(*).
+ *
+ * Takes the same arguments as IDatabase::select().
+ *
+ * @param string $table Table name
+ * @param string $vars Unused
+ * @param array|string $conds Filters on the table
+ * @param string $fname Function name for profiling
+ * @param array $options Options for select
+ * @return int Row count
+ */
+ public function estimateRowCount(
+ $table, $vars = '*', $conds = '', $fname = __METHOD__, $options = []
+ );
+
+ /**
+ * Get the number of rows in dataset
+ *
+ * This is useful when trying to do COUNT(*) but with a LIMIT for performance.
+ *
+ * Takes the same arguments as IDatabase::select().
+ *
+ * @since 1.27 Added $join_conds parameter
+ *
+ * @param array|string $tables Table names
+ * @param string $vars Unused
+ * @param array|string $conds Filters on the table
+ * @param string $fname Function name for profiling
+ * @param array $options Options for select
+ * @param array $join_conds Join conditions (since 1.27)
+ * @return int Row count
+ */
+ public function selectRowCount(
+ $tables, $vars = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+ );
+
+ /**
+ * Determines whether a field exists in a table
+ *
+ * @param string $table Table name
+ * @param string $field Filed to check on that table
+ * @param string $fname Calling function name (optional)
+ * @return bool Whether $table has filed $field
+ */
+ public function fieldExists( $table, $field, $fname = __METHOD__ );
+
+ /**
+ * Determines whether an index exists
+ * Usually throws a DBQueryError on failure
+ * If errors are explicitly ignored, returns NULL on failure
+ *
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool|null
+ */
+ public function indexExists( $table, $index, $fname = __METHOD__ );
+
+ /**
+ * Query whether a given table exists
+ *
+ * @param string $table
+ * @param string $fname
+ * @return bool
+ */
+ public function tableExists( $table, $fname = __METHOD__ );
+
+ /**
+ * Determines if a given index is unique
+ *
+ * @param string $table
+ * @param string $index
+ *
+ * @return bool
+ */
+ public function indexUnique( $table, $index );
+
+ /**
+ * INSERT wrapper, inserts an array into a table.
+ *
+ * $a may be either:
+ *
+ * - A single associative array. The array keys are the field names, and
+ * the values are the values to insert. The values are treated as data
+ * and will be quoted appropriately. If NULL is inserted, this will be
+ * converted to a database NULL.
+ * - An array with numeric keys, holding a list of associative arrays.
+ * This causes a multi-row INSERT on DBMSs that support it. The keys in
+ * each subarray must be identical to each other, and in the same order.
+ *
+ * Usually throws a DBQueryError on failure. If errors are explicitly ignored,
+ * returns success.
+ *
+ * $options is an array of options, with boolean options encoded as values
+ * with numeric keys, in the same style as $options in
+ * IDatabase::select(). Supported options are:
+ *
+ * - IGNORE: Boolean: if present, duplicate key errors are ignored, and
+ * any rows which cause duplicate key errors are not inserted. It's
+ * possible to determine how many rows were successfully inserted using
+ * IDatabase::affectedRows().
+ *
+ * @param string $table Table name. This will be passed through
+ * DatabaseBase::tableName().
+ * @param array $a Array of rows to insert
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @param array $options Array of options
+ *
+ * @return bool
+ */
+ public function insert( $table, $a, $fname = __METHOD__, $options = [] );
+
+ /**
+ * UPDATE wrapper. Takes a condition array and a SET array.
+ *
+ * @param string $table Name of the table to UPDATE. This will be passed through
+ * DatabaseBase::tableName().
+ * @param array $values An array of values to SET. For each array element,
+ * the key gives the field name, and the value gives the data to set
+ * that field to. The data will be quoted by IDatabase::addQuotes().
+ * @param array $conds An array of conditions (WHERE). See
+ * IDatabase::select() for the details of the format of condition
+ * arrays. Use '*' to update all rows.
+ * @param string $fname The function name of the caller (from __METHOD__),
+ * for logging and profiling.
+ * @param array $options An array of UPDATE options, can be:
+ * - IGNORE: Ignore unique key conflicts
+ * - LOW_PRIORITY: MySQL-specific, see MySQL manual.
+ * @return bool
+ */
+ public function update( $table, $values, $conds, $fname = __METHOD__, $options = [] );
+
+ /**
+ * Makes an encoded list of strings from an array
+ *
+ * @param array $a Containing the data
+ * @param int $mode Constant
+ * - LIST_COMMA: Comma separated, no field names
+ * - LIST_AND: ANDed WHERE clause (without the WHERE). See the
+ * documentation for $conds in IDatabase::select().
+ * - LIST_OR: ORed WHERE clause (without the WHERE)
+ * - LIST_SET: Comma separated with field names, like a SET clause
+ * - LIST_NAMES: Comma separated field names
+ * @throws MWException|DBUnexpectedError
+ * @return string
+ */
+ public function makeList( $a, $mode = LIST_COMMA );
+
+ /**
+ * Build a partial where clause from a 2-d array such as used for LinkBatch.
+ * The keys on each level may be either integers or strings.
+ *
+ * @param array $data Organized as 2-d
+ * array(baseKeyVal => array(subKeyVal => [ignored], ...), ...)
+ * @param string $baseKey Field name to match the base-level keys to (eg 'pl_namespace')
+ * @param string $subKey Field name to match the sub-level keys to (eg 'pl_title')
+ * @return string|bool SQL fragment, or false if no items in array
+ */
+ public function makeWhereFrom2d( $data, $baseKey, $subKey );
+
+ /**
+ * @param string $field
+ * @return string
+ */
+ public function bitNot( $field );
+
+ /**
+ * @param string $fieldLeft
+ * @param string $fieldRight
+ * @return string
+ */
+ public function bitAnd( $fieldLeft, $fieldRight );
+
+ /**
+ * @param string $fieldLeft
+ * @param string $fieldRight
+ * @return string
+ */
+ public function bitOr( $fieldLeft, $fieldRight );
+
+ /**
+ * Build a concatenation list to feed into a SQL query
+ * @param array $stringList List of raw SQL expressions; caller is
+ * responsible for any quoting
+ * @return string
+ */
+ public function buildConcat( $stringList );
+
+ /**
+ * Build a GROUP_CONCAT or equivalent statement for a query.
+ *
+ * This is useful for combining a field for several rows into a single string.
+ * NULL values will not appear in the output, duplicated values will appear,
+ * and the resulting delimiter-separated values have no defined sort order.
+ * Code using the results may need to use the PHP unique() or sort() methods.
+ *
+ * @param string $delim Glue to bind the results together
+ * @param string|array $table Table name
+ * @param string $field Field name
+ * @param string|array $conds Conditions
+ * @param string|array $join_conds Join conditions
+ * @return string SQL text
+ * @since 1.23
+ */
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ );
+
+ /**
+ * Change the current database
+ *
+ * @param string $db
+ * @return bool Success or failure
+ */
+ public function selectDB( $db );
+
+ /**
+ * Get the current DB name
+ * @return string
+ */
+ public function getDBname();
+
+ /**
+ * Get the server hostname or IP address
+ * @return string
+ */
+ public function getServer();
+
+ /**
+ * Adds quotes and backslashes.
+ *
+ * @param string|Blob $s
+ * @return string
+ */
+ public function addQuotes( $s );
+
+ /**
+ * LIKE statement wrapper, receives a variable-length argument list with
+ * parts of pattern to match containing either string literals that will be
+ * escaped or tokens returned by anyChar() or anyString(). Alternatively,
+ * the function could be provided with an array of aforementioned
+ * parameters.
+ *
+ * Example: $dbr->buildLike( 'My_page_title/', $dbr->anyString() ) returns
+ * a LIKE clause that searches for subpages of 'My page title'.
+ * Alternatively:
+ * $pattern = array( 'My_page_title/', $dbr->anyString() );
+ * $query .= $dbr->buildLike( $pattern );
+ *
+ * @since 1.16
+ * @return string Fully built LIKE statement
+ */
+ public function buildLike();
+
+ /**
+ * Returns a token for buildLike() that denotes a '_' to be used in a LIKE query
+ *
+ * @return LikeMatch
+ */
+ public function anyChar();
+
+ /**
+ * Returns a token for buildLike() that denotes a '%' to be used in a LIKE query
+ *
+ * @return LikeMatch
+ */
+ public function anyString();
+
+ /**
+ * Returns an appropriately quoted sequence value for inserting a new row.
+ * MySQL has autoincrement fields, so this is just NULL. But the PostgreSQL
+ * subclass will return an integer, and save the value for insertId()
+ *
+ * Any implementation of this function should *not* involve reusing
+ * sequence numbers created for rolled-back transactions.
+ * See http://bugs.mysql.com/bug.php?id=30767 for details.
+ * @param string $seqName
+ * @return null|int
+ */
+ public function nextSequenceValue( $seqName );
+
+ /**
+ * REPLACE query wrapper.
+ *
+ * REPLACE is a very handy MySQL extension, which functions like an INSERT
+ * except that when there is a duplicate key error, the old row is deleted
+ * and the new row is inserted in its place.
+ *
+ * We simulate this with standard SQL with a DELETE followed by INSERT. To
+ * perform the delete, we need to know what the unique indexes are so that
+ * we know how to find the conflicting rows.
+ *
+ * It may be more efficient to leave off unique indexes which are unlikely
+ * to collide. However if you do this, you run the risk of encountering
+ * errors which wouldn't have occurred in MySQL.
+ *
+ * @param string $table The table to replace the row(s) in.
+ * @param array $uniqueIndexes Is an array of indexes. Each element may be either
+ * a field name or an array of field names
+ * @param array $rows Can be either a single row to insert, or multiple rows,
+ * in the same format as for IDatabase::insert()
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ */
+ public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ );
+
+ /**
+ * INSERT ON DUPLICATE KEY UPDATE wrapper, upserts an array into a table.
+ *
+ * This updates any conflicting rows (according to the unique indexes) using
+ * the provided SET clause and inserts any remaining (non-conflicted) rows.
+ *
+ * $rows may be either:
+ * - A single associative array. The array keys are the field names, and
+ * the values are the values to insert. The values are treated as data
+ * and will be quoted appropriately. If NULL is inserted, this will be
+ * converted to a database NULL.
+ * - An array with numeric keys, holding a list of associative arrays.
+ * This causes a multi-row INSERT on DBMSs that support it. The keys in
+ * each subarray must be identical to each other, and in the same order.
+ *
+ * It may be more efficient to leave off unique indexes which are unlikely
+ * to collide. However if you do this, you run the risk of encountering
+ * errors which wouldn't have occurred in MySQL.
+ *
+ * Usually throws a DBQueryError on failure. If errors are explicitly ignored,
+ * returns success.
+ *
+ * @since 1.22
+ *
+ * @param string $table Table name. This will be passed through DatabaseBase::tableName().
+ * @param array $rows A single row or list of rows to insert
+ * @param array $uniqueIndexes List of single field names or field name tuples
+ * @param array $set An array of values to SET. For each array element, the
+ * key gives the field name, and the value gives the data to set that
+ * field to. The data will be quoted by IDatabase::addQuotes().
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @throws Exception
+ * @return bool
+ */
+ public function upsert(
+ $table, array $rows, array $uniqueIndexes, array $set, $fname = __METHOD__
+ );
+
+ /**
+ * DELETE where the condition is a join.
+ *
+ * MySQL overrides this to use a multi-table DELETE syntax, in other databases
+ * we use sub-selects
+ *
+ * For safety, an empty $conds will not delete everything. If you want to
+ * delete all rows where the join condition matches, set $conds='*'.
+ *
+ * DO NOT put the join condition in $conds.
+ *
+ * @param string $delTable The table to delete from.
+ * @param string $joinTable The other table.
+ * @param string $delVar The variable to join on, in the first table.
+ * @param string $joinVar The variable to join on, in the second table.
+ * @param array $conds Condition array of field names mapped to variables,
+ * ANDed together in the WHERE clause
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @throws DBUnexpectedError
+ */
+ public function deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds,
+ $fname = __METHOD__
+ );
+
+ /**
+ * DELETE query wrapper.
+ *
+ * @param array $table Table name
+ * @param string|array $conds Array of conditions. See $conds in IDatabase::select()
+ * for the format. Use $conds == "*" to delete all rows
+ * @param string $fname Name of the calling function
+ * @throws DBUnexpectedError
+ * @return bool|ResultWrapper
+ */
+ public function delete( $table, $conds, $fname = __METHOD__ );
+
+ /**
+ * INSERT SELECT wrapper. Takes data from a SELECT query and inserts it
+ * into another table.
+ *
+ * @param string $destTable The table name to insert into
+ * @param string|array $srcTable May be either a table name, or an array of table names
+ * to include in a join.
+ *
+ * @param array $varMap Must be an associative array of the form
+ * array( 'dest1' => 'source1', ...). Source items may be literals
+ * rather than field names, but strings should be quoted with
+ * IDatabase::addQuotes()
+ *
+ * @param array $conds Condition array. See $conds in IDatabase::select() for
+ * the details of the format of condition arrays. May be "*" to copy the
+ * whole table.
+ *
+ * @param string $fname The function name of the caller, from __METHOD__
+ *
+ * @param array $insertOptions Options for the INSERT part of the query, see
+ * IDatabase::insert() for details.
+ * @param array $selectOptions Options for the SELECT part of the query, see
+ * IDatabase::select() for details.
+ *
+ * @return ResultWrapper
+ */
+ public function insertSelect( $destTable, $srcTable, $varMap, $conds,
+ $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = []
+ );
+
+ /**
+ * Returns true if current database backend supports ORDER BY or LIMIT for separate subqueries
+ * within the UNION construct.
+ * @return bool
+ */
+ public function unionSupportsOrderAndLimit();
+
+ /**
+ * Construct a UNION query
+ * This is used for providing overload point for other DB abstractions
+ * not compatible with the MySQL syntax.
+ * @param array $sqls SQL statements to combine
+ * @param bool $all Use UNION ALL
+ * @return string SQL fragment
+ */
+ public function unionQueries( $sqls, $all );
+
+ /**
+ * Returns an SQL expression for a simple conditional. This doesn't need
+ * to be overridden unless CASE isn't supported in your DBMS.
+ *
+ * @param string|array $cond SQL expression which will result in a boolean value
+ * @param string $trueVal SQL expression to return if true
+ * @param string $falseVal SQL expression to return if false
+ * @return string SQL fragment
+ */
+ public function conditional( $cond, $trueVal, $falseVal );
+
+ /**
+ * Returns a comand for str_replace function in SQL query.
+ * Uses REPLACE() in MySQL
+ *
+ * @param string $orig Column to modify
+ * @param string $old Column to seek
+ * @param string $new Column to replace with
+ *
+ * @return string
+ */
+ public function strreplace( $orig, $old, $new );
+
+ /**
+ * Determines how long the server has been up
+ * STUB
+ *
+ * @return int
+ */
+ public function getServerUptime();
+
+ /**
+ * Determines if the last failure was due to a deadlock
+ * STUB
+ *
+ * @return bool
+ */
+ public function wasDeadlock();
+
+ /**
+ * Determines if the last failure was due to a lock timeout
+ * STUB
+ *
+ * @return bool
+ */
+ public function wasLockTimeout();
+
+ /**
+ * Determines if the last query error was something that should be dealt
+ * with by pinging the connection and reissuing the query.
+ * STUB
+ *
+ * @return bool
+ */
+ public function wasErrorReissuable();
+
+ /**
+ * Determines if the last failure was due to the database being read-only.
+ * STUB
+ *
+ * @return bool
+ */
+ public function wasReadOnlyError();
+
+ /**
+ * Wait for the slave to catch up to a given master position
+ *
+ * @param DBMasterPos $pos
+ * @param int $timeout The maximum number of seconds to wait for synchronisation
+ * @return int|null Zero if the slave was past that position already,
+ * greater than zero if we waited for some period of time, less than
+ * zero if it timed out, and null on error
+ */
+ public function masterPosWait( DBMasterPos $pos, $timeout );
+
+ /**
+ * Get the replication position of this slave
+ *
+ * @return DBMasterPos|bool False if this is not a slave.
+ */
+ public function getSlavePos();
+
+ /**
+ * Get the position of this master
+ *
+ * @return DBMasterPos|bool False if this is not a master
+ */
+ public function getMasterPos();
+
+ /**
+ * Run an anonymous function as soon as there is no transaction pending.
+ * If there is a transaction and it is rolled back, then the callback is cancelled.
+ * Queries in the function will run in AUTO-COMMIT mode unless there are begin() calls.
+ * Callbacks must commit any transactions that they begin.
+ *
+ * This is useful for updates to different systems or when separate transactions are needed.
+ * For example, one might want to enqueue jobs into a system outside the database, but only
+ * after the database is updated so that the jobs will see the data when they actually run.
+ * It can also be used for updates that easily cause deadlocks if locks are held too long.
+ *
+ * Updates will execute in the order they were enqueued.
+ *
+ * @param callable $callback
+ * @since 1.20
+ */
+ public function onTransactionIdle( $callback );
+
+ /**
+ * Run an anonymous function before the current transaction commits or now if there is none.
+ * If there is a transaction and it is rolled back, then the callback is cancelled.
+ * Callbacks must not start nor commit any transactions.
+ *
+ * This is useful for updates that easily cause deadlocks if locks are held too long
+ * but where atomicity is strongly desired for these updates and some related updates.
+ *
+ * Updates will execute in the order they were enqueued.
+ *
+ * @param callable $callback
+ * @since 1.22
+ */
+ public function onTransactionPreCommitOrIdle( $callback );
+
+ /**
+ * Begin an atomic section of statements
+ *
+ * If a transaction has been started already, just keep track of the given
+ * section name to make sure the transaction is not committed pre-maturely.
+ * This function can be used in layers (with sub-sections), so use a stack
+ * to keep track of the different atomic sections. If there is no transaction,
+ * start one implicitly.
+ *
+ * The goal of this function is to create an atomic section of SQL queries
+ * without having to start a new transaction if it already exists.
+ *
+ * Atomic sections are more strict than transactions. With transactions,
+ * attempting to begin a new transaction when one is already running results
+ * in MediaWiki issuing a brief warning and doing an implicit commit. All
+ * atomic levels *must* be explicitly closed using IDatabase::endAtomic(),
+ * and any database transactions cannot be began or committed until all atomic
+ * levels are closed. There is no such thing as implicitly opening or closing
+ * an atomic section.
+ *
+ * @since 1.23
+ * @param string $fname
+ * @throws DBError
+ */
+ public function startAtomic( $fname = __METHOD__ );
+
+ /**
+ * Ends an atomic section of SQL statements
+ *
+ * Ends the next section of atomic SQL statements and commits the transaction
+ * if necessary.
+ *
+ * @since 1.23
+ * @see IDatabase::startAtomic
+ * @param string $fname
+ * @throws DBError
+ */
+ public function endAtomic( $fname = __METHOD__ );
+
+ /**
+ * Run a callback to do an atomic set of updates for this database
+ *
+ * The $callback takes the following arguments:
+ * - This database object
+ * - The value of $fname
+ *
+ * If any exception occurs in the callback, then rollback() will be called and the error will
+ * be re-thrown. It may also be that the rollback itself fails with an exception before then.
+ * In any case, such errors are expected to terminate the request, without any outside caller
+ * attempting to catch errors and commit anyway. Note that any rollback undoes all prior
+ * atomic section and uncommitted updates, which trashes the current request, requiring an
+ * error to be displayed.
+ *
+ * This can be an alternative to explicit startAtomic()/endAtomic() calls.
+ *
+ * @see DatabaseBase::startAtomic
+ * @see DatabaseBase::endAtomic
+ *
+ * @param string $fname Caller name (usually __METHOD__)
+ * @param callable $callback Callback that issues DB updates
+ * @throws DBError
+ * @throws RuntimeException
+ * @throws UnexpectedValueException
+ * @since 1.27
+ */
+ public function doAtomicSection( $fname, $callback );
+
+ /**
+ * Begin a transaction. If a transaction is already in progress,
+ * that transaction will be committed before the new transaction is started.
+ *
+ * Note that when the DBO_TRX flag is set (which is usually the case for web
+ * requests, but not for maintenance scripts), any previous database query
+ * will have started a transaction automatically.
+ *
+ * Nesting of transactions is not supported. Attempts to nest transactions
+ * will cause a warning, unless the current transaction was started
+ * automatically because of the DBO_TRX flag.
+ *
+ * @param string $fname
+ * @throws DBError
+ */
+ public function begin( $fname = __METHOD__ );
+
+ /**
+ * Commits a transaction previously started using begin().
+ * If no transaction is in progress, a warning is issued.
+ *
+ * Nesting of transactions is not supported.
+ *
+ * @param string $fname
+ * @param string $flush Flush flag, set to 'flush' to disable warnings about
+ * explicitly committing implicit transactions, or calling commit when no
+ * transaction is in progress.
+ *
+ * This will trigger an exception if there is an ongoing explicit transaction.
+ *
+ * Only set the flush flag if you are sure that these warnings are not applicable,
+ * and no explicit transactions are open.
+ *
+ * @throws DBUnexpectedError
+ */
+ public function commit( $fname = __METHOD__, $flush = '' );
+
+ /**
+ * Rollback a transaction previously started using begin().
+ * If no transaction is in progress, a warning is issued.
+ *
+ * No-op on non-transactional databases.
+ *
+ * @param string $fname
+ * @param string $flush Flush flag, set to 'flush' to disable warnings about
+ * calling rollback when no transaction is in progress. This will silently
+ * break any ongoing explicit transaction. Only set the flush flag if you
+ * are sure that it is safe to ignore these warnings in your context.
+ * @throws DBUnexpectedError
+ * @since 1.23 Added $flush parameter
+ */
+ public function rollback( $fname = __METHOD__, $flush = '' );
+
+ /**
+ * List all tables on the database
+ *
+ * @param string $prefix Only show tables with this prefix, e.g. mw_
+ * @param string $fname Calling function name
+ * @throws MWException
+ * @return array
+ */
+ public function listTables( $prefix = null, $fname = __METHOD__ );
+
+ /**
+ * Convert a timestamp in one of the formats accepted by wfTimestamp()
+ * to the format used for inserting into timestamp fields in this DBMS.
+ *
+ * The result is unquoted, and needs to be passed through addQuotes()
+ * before it can be included in raw SQL.
+ *
+ * @param string|int $ts
+ *
+ * @return string
+ */
+ public function timestamp( $ts = 0 );
+
+ /**
+ * Convert a timestamp in one of the formats accepted by wfTimestamp()
+ * to the format used for inserting into timestamp fields in this DBMS. If
+ * NULL is input, it is passed through, allowing NULL values to be inserted
+ * into timestamp fields.
+ *
+ * The result is unquoted, and needs to be passed through addQuotes()
+ * before it can be included in raw SQL.
+ *
+ * @param string|int $ts
+ *
+ * @return string
+ */
+ public function timestampOrNull( $ts = null );
+
+ /**
+ * Ping the server and try to reconnect if it there is no connection
+ *
+ * @return bool Success or failure
+ */
+ public function ping();
+
+ /**
+ * Get slave lag. Currently supported only by MySQL.
+ *
+ * Note that this function will generate a fatal error on many
+ * installations. Most callers should use LoadBalancer::safeGetLag()
+ * instead.
+ *
+ * @return int|bool Database replication lag in seconds or false on error
+ */
+ public function getLag();
+
+ /**
+ * Get the slave lag when the current transaction started
+ * or a general lag estimate if not transaction is active
+ *
+ * This is useful when transactions might use snapshot isolation
+ * (e.g. REPEATABLE-READ in innodb), so the "real" lag of that data
+ * is this lag plus transaction duration. If they don't, it is still
+ * safe to be pessimistic. In AUTO-COMMIT mode, this still gives an
+ * indication of the staleness of subsequent reads.
+ *
+ * @return array ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN)
+ * @since 1.27
+ */
+ public function getSessionLagStatus();
+
+ /**
+ * Return the maximum number of items allowed in a list, or 0 for unlimited.
+ *
+ * @return int
+ */
+ public function maxListLen();
+
+ /**
+ * Some DBMSs have a special format for inserting into blob fields, they
+ * don't allow simple quoted strings to be inserted. To insert into such
+ * a field, pass the data through this function before passing it to
+ * IDatabase::insert().
+ *
+ * @param string $b
+ * @return string
+ */
+ public function encodeBlob( $b );
+
+ /**
+ * Some DBMSs return a special placeholder object representing blob fields
+ * in result objects. Pass the object through this function to return the
+ * original string.
+ *
+ * @param string|Blob $b
+ * @return string
+ */
+ public function decodeBlob( $b );
+
+ /**
+ * Override database's default behavior. $options include:
+ * 'connTimeout' : Set the connection timeout value in seconds.
+ * May be useful for very long batch queries such as
+ * full-wiki dumps, where a single query reads out over
+ * hours or days.
+ *
+ * @param array $options
+ * @return void
+ */
+ public function setSessionOptions( array $options );
+
+ /**
+ * Set variables to be used in sourceFile/sourceStream, in preference to the
+ * ones in $GLOBALS. If an array is set here, $GLOBALS will not be used at
+ * all. If it's set to false, $GLOBALS will be used.
+ *
+ * @param bool|array $vars Mapping variable name to value.
+ */
+ public function setSchemaVars( $vars );
+
+ /**
+ * Check to see if a named lock is available (non-blocking)
+ *
+ * @param string $lockName Name of lock to poll
+ * @param string $method Name of method calling us
+ * @return bool
+ * @since 1.20
+ */
+ public function lockIsFree( $lockName, $method );
+
+ /**
+ * Acquire a named lock
+ *
+ * Named locks are not related to transactions
+ *
+ * @param string $lockName Name of lock to aquire
+ * @param string $method Name of the calling method
+ * @param int $timeout Acquisition timeout in seconds
+ * @return bool
+ */
+ public function lock( $lockName, $method, $timeout = 5 );
+
+ /**
+ * Release a lock
+ *
+ * Named locks are not related to transactions
+ *
+ * @param string $lockName Name of lock to release
+ * @param string $method Name of the calling method
+ *
+ * @return int Returns 1 if the lock was released, 0 if the lock was not established
+ * by this thread (in which case the lock is not released), and NULL if the named
+ * lock did not exist
+ */
+ public function unlock( $lockName, $method );
+
+ /**
+ * Acquire a named lock, flush any transaction, and return an RAII style unlocker object
+ *
+ * This is suitiable for transactions that need to be serialized using cooperative locks,
+ * where each transaction can see each others' changes. Any transaction is flushed to clear
+ * out stale REPEATABLE-READ snapshot data. Once the returned object falls out of PHP scope,
+ * any transaction will be committed and the lock will be released.
+ *
+ * If the lock acquisition failed, then no transaction flush happens, and null is returned.
+ *
+ * @param string $lockKey Name of lock to release
+ * @param string $fname Name of the calling method
+ * @param int $timeout Acquisition timeout in seconds
+ * @return ScopedCallback|null
+ * @throws DBUnexpectedError
+ * @since 1.27
+ */
+ public function getScopedLockAndFlush( $lockKey, $fname, $timeout );
+
+ /**
+ * Check to see if a named lock used by lock() use blocking queues
+ *
+ * @return bool
+ * @since 1.26
+ */
+ public function namedLocksEnqueue();
+
+ /**
+ * Find out when 'infinity' is. Most DBMSes support this. This is a special
+ * keyword for timestamps in PostgreSQL, and works with CHAR(14) as well
+ * because "i" sorts after all numbers.
+ *
+ * @return string
+ */
+ public function getInfinity();
+
+ /**
+ * Encode an expiry time into the DBMS dependent format
+ *
+ * @param string $expiry Timestamp for expiry, or the 'infinity' string
+ * @return string
+ */
+ public function encodeExpiry( $expiry );
+
+ /**
+ * Decode an expiry time into a DBMS independent format
+ *
+ * @param string $expiry DB timestamp field value for expiry
+ * @param int $format TS_* constant, defaults to TS_MW
+ * @return string
+ */
+ public function decodeExpiry( $expiry, $format = TS_MW );
+
+ /**
+ * Allow or deny "big selects" for this session only. This is done by setting
+ * the sql_big_selects session variable.
+ *
+ * This is a MySQL-specific feature.
+ *
+ * @param bool|string $value True for allow, false for deny, or "default" to
+ * restore the initial value
+ */
+ public function setBigSelects( $value = true );
+
+ /**
+ * @return bool Whether this DB is read-only
+ * @since 1.27
+ */
+ public function isReadOnly();
+}
diff --git a/www/wiki/includes/db/MWLBFactory.php b/www/wiki/includes/db/MWLBFactory.php
new file mode 100644
index 00000000..5196ac2d
--- /dev/null
+++ b/www/wiki/includes/db/MWLBFactory.php
@@ -0,0 +1,202 @@
+<?php
+/**
+ * Generator of database load balancing objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\DatabaseDomain;
+
+/**
+ * MediaWiki-specific class for generating database load balancers
+ * @ingroup Database
+ */
+abstract class MWLBFactory {
+ /**
+ * @param array $lbConf Config for LBFactory::__construct()
+ * @param Config $mainConfig Main config object from MediaWikiServices
+ * @param ConfiguredReadOnlyMode $readOnlyMode
+ * @return array
+ */
+ public static function applyDefaultConfig( array $lbConf, Config $mainConfig,
+ ConfiguredReadOnlyMode $readOnlyMode
+ ) {
+ global $wgCommandLineMode;
+
+ static $typesWithSchema = [ 'postgres', 'msssql' ];
+
+ $lbConf += [
+ 'localDomain' => new DatabaseDomain(
+ $mainConfig->get( 'DBname' ),
+ null,
+ $mainConfig->get( 'DBprefix' )
+ ),
+ 'profiler' => Profiler::instance(),
+ 'trxProfiler' => Profiler::instance()->getTransactionProfiler(),
+ 'replLogger' => LoggerFactory::getInstance( 'DBReplication' ),
+ 'queryLogger' => LoggerFactory::getInstance( 'DBQuery' ),
+ 'connLogger' => LoggerFactory::getInstance( 'DBConnection' ),
+ 'perfLogger' => LoggerFactory::getInstance( 'DBPerformance' ),
+ 'errorLogger' => [ MWExceptionHandler::class, 'logException' ],
+ 'cliMode' => $wgCommandLineMode,
+ 'hostname' => wfHostname(),
+ 'readOnlyReason' => $readOnlyMode->getReason(),
+ ];
+
+ // When making changes here, remember to also specify MediaWiki-specific options
+ // for Database classes in the relevant Installer subclass.
+ // Such as MysqlInstaller::openConnection and PostgresInstaller::openConnectionWithParams.
+ if ( $lbConf['class'] === 'LBFactorySimple' ) {
+ if ( isset( $lbConf['servers'] ) ) {
+ // Server array is already explicitly configured; leave alone
+ } elseif ( is_array( $mainConfig->get( 'DBservers' ) ) ) {
+ foreach ( $mainConfig->get( 'DBservers' ) as $i => $server ) {
+ if ( $server['type'] === 'sqlite' ) {
+ $server += [ 'dbDirectory' => $mainConfig->get( 'SQLiteDataDir' ) ];
+ } elseif ( $server['type'] === 'postgres' ) {
+ $server += [
+ 'port' => $mainConfig->get( 'DBport' ),
+ // Work around the reserved word usage in MediaWiki schema
+ 'keywordTableMap' => [ 'user' => 'mwuser', 'text' => 'pagecontent' ]
+ ];
+ } elseif ( $server['type'] === 'mssql' ) {
+ $server += [
+ 'port' => $mainConfig->get( 'DBport' ),
+ 'useWindowsAuth' => $mainConfig->get( 'DBWindowsAuthentication' )
+ ];
+ }
+
+ if ( in_array( $server['type'], $typesWithSchema, true ) ) {
+ $server += [ 'schema' => $mainConfig->get( 'DBmwschema' ) ];
+ }
+
+ $server += [
+ 'tablePrefix' => $mainConfig->get( 'DBprefix' ),
+ 'flags' => DBO_DEFAULT,
+ 'sqlMode' => $mainConfig->get( 'SQLMode' ),
+ 'utf8Mode' => $mainConfig->get( 'DBmysql5' )
+ ];
+
+ $lbConf['servers'][$i] = $server;
+ }
+ } else {
+ $flags = DBO_DEFAULT;
+ $flags |= $mainConfig->get( 'DebugDumpSql' ) ? DBO_DEBUG : 0;
+ $flags |= $mainConfig->get( 'DBssl' ) ? DBO_SSL : 0;
+ $flags |= $mainConfig->get( 'DBcompress' ) ? DBO_COMPRESS : 0;
+ $server = [
+ 'host' => $mainConfig->get( 'DBserver' ),
+ 'user' => $mainConfig->get( 'DBuser' ),
+ 'password' => $mainConfig->get( 'DBpassword' ),
+ 'dbname' => $mainConfig->get( 'DBname' ),
+ 'tablePrefix' => $mainConfig->get( 'DBprefix' ),
+ 'type' => $mainConfig->get( 'DBtype' ),
+ 'load' => 1,
+ 'flags' => $flags,
+ 'sqlMode' => $mainConfig->get( 'SQLMode' ),
+ 'utf8Mode' => $mainConfig->get( 'DBmysql5' )
+ ];
+ if ( in_array( $server['type'], $typesWithSchema, true ) ) {
+ $server += [ 'schema' => $mainConfig->get( 'DBmwschema' ) ];
+ }
+ if ( $server['type'] === 'sqlite' ) {
+ $server[ 'dbDirectory'] = $mainConfig->get( 'SQLiteDataDir' );
+ } elseif ( $server['type'] === 'postgres' ) {
+ $server['port'] = $mainConfig->get( 'DBport' );
+ // Work around the reserved word usage in MediaWiki schema
+ $server['keywordTableMap'] = [ 'user' => 'mwuser', 'text' => 'pagecontent' ];
+ } elseif ( $server['type'] === 'mssql' ) {
+ $server['port'] = $mainConfig->get( 'DBport' );
+ $server['useWindowsAuth'] = $mainConfig->get( 'DBWindowsAuthentication' );
+ }
+ $lbConf['servers'] = [ $server ];
+ }
+ if ( !isset( $lbConf['externalClusters'] ) ) {
+ $lbConf['externalClusters'] = $mainConfig->get( 'ExternalServers' );
+ }
+ } elseif ( $lbConf['class'] === 'LBFactoryMulti' ) {
+ if ( isset( $lbConf['serverTemplate'] ) ) {
+ if ( in_array( $lbConf['serverTemplate']['type'], $typesWithSchema, true ) ) {
+ $lbConf['serverTemplate']['schema'] = $mainConfig->get( 'DBmwschema' );
+ }
+ $lbConf['serverTemplate']['sqlMode'] = $mainConfig->get( 'SQLMode' );
+ $lbConf['serverTemplate']['utf8Mode'] = $mainConfig->get( 'DBmysql5' );
+ }
+ }
+
+ // Use APC/memcached style caching, but avoids loops with CACHE_DB (T141804)
+ $sCache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
+ if ( $sCache->getQoS( $sCache::ATTR_EMULATION ) > $sCache::QOS_EMULATION_SQL ) {
+ $lbConf['srvCache'] = $sCache;
+ }
+ $cCache = ObjectCache::getLocalClusterInstance();
+ if ( $cCache->getQoS( $cCache::ATTR_EMULATION ) > $cCache::QOS_EMULATION_SQL ) {
+ $lbConf['memStash'] = $cCache;
+ }
+ $wCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ if ( $wCache->getQoS( $wCache::ATTR_EMULATION ) > $wCache::QOS_EMULATION_SQL ) {
+ $lbConf['wanCache'] = $wCache;
+ }
+
+ return $lbConf;
+ }
+
+ /**
+ * Returns the LBFactory class to use and the load balancer configuration.
+ *
+ * @todo instead of this, use a ServiceContainer for managing the different implementations.
+ *
+ * @param array $config (e.g. $wgLBFactoryConf)
+ * @return string Class name
+ */
+ public static function getLBFactoryClass( array $config ) {
+ // For configuration backward compatibility after removing
+ // underscores from class names in MediaWiki 1.23.
+ $bcClasses = [
+ 'LBFactory_Simple' => 'LBFactorySimple',
+ 'LBFactory_Single' => 'LBFactorySingle',
+ 'LBFactory_Multi' => 'LBFactoryMulti'
+ ];
+
+ $class = $config['class'];
+
+ if ( isset( $bcClasses[$class] ) ) {
+ $class = $bcClasses[$class];
+ wfDeprecated(
+ '$wgLBFactoryConf must be updated. See RELEASE-NOTES for details',
+ '1.23'
+ );
+ }
+
+ // For configuration backward compatibility after moving classes to namespaces (1.29)
+ $compat = [
+ 'LBFactorySingle' => Wikimedia\Rdbms\LBFactorySingle::class,
+ 'LBFactorySimple' => Wikimedia\Rdbms\LBFactorySimple::class,
+ 'LBFactoryMulti' => Wikimedia\Rdbms\LBFactoryMulti::class
+ ];
+
+ if ( isset( $compat[$class] ) ) {
+ $class = $compat[$class];
+ }
+
+ return $class;
+ }
+}
diff --git a/www/wiki/includes/db/ORAField.php b/www/wiki/includes/db/ORAField.php
new file mode 100644
index 00000000..df310003
--- /dev/null
+++ b/www/wiki/includes/db/ORAField.php
@@ -0,0 +1,53 @@
+<?php
+
+use Wikimedia\Rdbms\Field;
+
+class ORAField implements Field {
+ private $name, $tablename, $default, $max_length, $nullable,
+ $is_pk, $is_unique, $is_multiple, $is_key, $type;
+
+ function __construct( $info ) {
+ $this->name = $info['column_name'];
+ $this->tablename = $info['table_name'];
+ $this->default = $info['data_default'];
+ $this->max_length = $info['data_length'];
+ $this->nullable = $info['not_null'];
+ $this->is_pk = isset( $info['prim'] ) && $info['prim'] == 1 ? 1 : 0;
+ $this->is_unique = isset( $info['uniq'] ) && $info['uniq'] == 1 ? 1 : 0;
+ $this->is_multiple = isset( $info['nonuniq'] ) && $info['nonuniq'] == 1 ? 1 : 0;
+ $this->is_key = ( $this->is_pk || $this->is_unique || $this->is_multiple );
+ $this->type = $info['data_type'];
+ }
+
+ function name() {
+ return $this->name;
+ }
+
+ function tableName() {
+ return $this->tablename;
+ }
+
+ function defaultValue() {
+ return $this->default;
+ }
+
+ function maxLength() {
+ return $this->max_length;
+ }
+
+ function isNullable() {
+ return $this->nullable;
+ }
+
+ function isKey() {
+ return $this->is_key;
+ }
+
+ function isMultipleKey() {
+ return $this->is_multiple;
+ }
+
+ function type() {
+ return $this->type;
+ }
+}
diff --git a/www/wiki/includes/db/ORAResult.php b/www/wiki/includes/db/ORAResult.php
new file mode 100644
index 00000000..aafd3861
--- /dev/null
+++ b/www/wiki/includes/db/ORAResult.php
@@ -0,0 +1,110 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * The oci8 extension is fairly weak and doesn't support oci_num_rows, among
+ * other things. We use a wrapper class to handle that and other
+ * Oracle-specific bits, like converting column names back to lowercase.
+ * @ingroup Database
+ */
+class ORAResult {
+ private $rows;
+ private $cursor;
+ private $nrows;
+
+ private $columns = [];
+
+ private function array_unique_md( $array_in ) {
+ $array_out = [];
+ $array_hashes = [];
+
+ foreach ( $array_in as $item ) {
+ $hash = md5( serialize( $item ) );
+ if ( !isset( $array_hashes[$hash] ) ) {
+ $array_hashes[$hash] = $hash;
+ $array_out[] = $item;
+ }
+ }
+
+ return $array_out;
+ }
+
+ /**
+ * @param IDatabase &$db
+ * @param resource $stmt A valid OCI statement identifier
+ * @param bool $unique
+ */
+ function __construct( &$db, $stmt, $unique = false ) {
+ $this->db =& $db;
+
+ $this->nrows = oci_fetch_all( $stmt, $this->rows, 0, -1, OCI_FETCHSTATEMENT_BY_ROW | OCI_NUM );
+ if ( $this->nrows === false ) {
+ $e = oci_error( $stmt );
+ $db->reportQueryError( $e['message'], $e['code'], '', __METHOD__ );
+ $this->free();
+
+ return;
+ }
+
+ if ( $unique ) {
+ $this->rows = $this->array_unique_md( $this->rows );
+ $this->nrows = count( $this->rows );
+ }
+
+ if ( $this->nrows > 0 ) {
+ foreach ( $this->rows[0] as $k => $v ) {
+ $this->columns[$k] = strtolower( oci_field_name( $stmt, $k + 1 ) );
+ }
+ }
+
+ $this->cursor = 0;
+ oci_free_statement( $stmt );
+ }
+
+ public function free() {
+ unset( $this->db );
+ }
+
+ public function seek( $row ) {
+ $this->cursor = min( $row, $this->nrows );
+ }
+
+ public function numRows() {
+ return $this->nrows;
+ }
+
+ public function numFields() {
+ return count( $this->columns );
+ }
+
+ public function fetchObject() {
+ if ( $this->cursor >= $this->nrows ) {
+ return false;
+ }
+ $row = $this->rows[$this->cursor++];
+ $ret = new stdClass();
+ foreach ( $row as $k => $v ) {
+ $lc = $this->columns[$k];
+ $ret->$lc = $v;
+ }
+
+ return $ret;
+ }
+
+ public function fetchRow() {
+ if ( $this->cursor >= $this->nrows ) {
+ return false;
+ }
+
+ $row = $this->rows[$this->cursor++];
+ $ret = [];
+ foreach ( $row as $k => $v ) {
+ $lc = $this->columns[$k];
+ $ret[$lc] = $v;
+ $ret[$k] = $v;
+ }
+
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/db/loadbalancer/LBFactory.php b/www/wiki/includes/db/loadbalancer/LBFactory.php
new file mode 100644
index 00000000..f39596b7
--- /dev/null
+++ b/www/wiki/includes/db/loadbalancer/LBFactory.php
@@ -0,0 +1,481 @@
+<?php
+/**
+ * Generator of database load balancing objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+use Psr\Log\LoggerInterface;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * An interface for generating database load balancers
+ * @ingroup Database
+ */
+abstract class LBFactory {
+ /** @var ChronologyProtector */
+ protected $chronProt;
+
+ /** @var TransactionProfiler */
+ protected $trxProfiler;
+
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var LBFactory */
+ private static $instance;
+
+ /** @var string|bool Reason all LBs are read-only or false if not */
+ protected $readOnlyReason = false;
+
+ const SHUTDOWN_NO_CHRONPROT = 1; // don't save ChronologyProtector positions (for async code)
+
+ /**
+ * Construct a factory based on a configuration array (typically from $wgLBFactoryConf)
+ * @param array $conf
+ */
+ public function __construct( array $conf ) {
+ if ( isset( $conf['readOnlyReason'] ) && is_string( $conf['readOnlyReason'] ) ) {
+ $this->readOnlyReason = $conf['readOnlyReason'];
+ }
+
+ $this->chronProt = $this->newChronologyProtector();
+ $this->trxProfiler = Profiler::instance()->getTransactionProfiler();
+ $this->logger = LoggerFactory::getInstance( 'DBTransaction' );
+ }
+
+ /**
+ * Disables all access to the load balancer, will cause all database access
+ * to throw a DBAccessError
+ */
+ public static function disableBackend() {
+ global $wgLBFactoryConf;
+ self::$instance = new LBFactoryFake( $wgLBFactoryConf );
+ }
+
+ /**
+ * Get an LBFactory instance
+ *
+ * @return LBFactory
+ */
+ public static function singleton() {
+ global $wgLBFactoryConf;
+
+ if ( is_null( self::$instance ) ) {
+ $class = self::getLBFactoryClass( $wgLBFactoryConf );
+ $config = $wgLBFactoryConf;
+ if ( !isset( $config['readOnlyReason'] ) ) {
+ $config['readOnlyReason'] = wfConfiguredReadOnlyReason();
+ }
+ self::$instance = new $class( $config );
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Returns the LBFactory class to use and the load balancer configuration.
+ *
+ * @param array $config (e.g. $wgLBFactoryConf)
+ * @return string Class name
+ */
+ public static function getLBFactoryClass( array $config ) {
+ // For configuration backward compatibility after removing
+ // underscores from class names in MediaWiki 1.23.
+ $bcClasses = [
+ 'LBFactory_Simple' => 'LBFactorySimple',
+ 'LBFactory_Single' => 'LBFactorySingle',
+ 'LBFactory_Multi' => 'LBFactoryMulti',
+ 'LBFactory_Fake' => 'LBFactoryFake',
+ ];
+
+ $class = $config['class'];
+
+ if ( isset( $bcClasses[$class] ) ) {
+ $class = $bcClasses[$class];
+ wfDeprecated(
+ '$wgLBFactoryConf must be updated. See RELEASE-NOTES for details',
+ '1.23'
+ );
+ }
+
+ return $class;
+ }
+
+ /**
+ * Shut down, close connections and destroy the cached instance.
+ */
+ public static function destroyInstance() {
+ if ( self::$instance ) {
+ self::$instance->shutdown();
+ self::$instance->forEachLBCallMethod( 'closeAll' );
+ self::$instance = null;
+ }
+ }
+
+ /**
+ * Set the instance to be the given object
+ *
+ * @param LBFactory $instance
+ */
+ public static function setInstance( $instance ) {
+ self::destroyInstance();
+ self::$instance = $instance;
+ }
+
+ /**
+ * Create a new load balancer object. The resulting object will be untracked,
+ * not chronology-protected, and the caller is responsible for cleaning it up.
+ *
+ * @param bool|string $wiki Wiki ID, or false for the current wiki
+ * @return LoadBalancer
+ */
+ abstract public function newMainLB( $wiki = false );
+
+ /**
+ * Get a cached (tracked) load balancer object.
+ *
+ * @param bool|string $wiki Wiki ID, or false for the current wiki
+ * @return LoadBalancer
+ */
+ abstract public function getMainLB( $wiki = false );
+
+ /**
+ * Create a new load balancer for external storage. The resulting object will be
+ * untracked, not chronology-protected, and the caller is responsible for
+ * cleaning it up.
+ *
+ * @param string $cluster External storage cluster, or false for core
+ * @param bool|string $wiki Wiki ID, or false for the current wiki
+ * @return LoadBalancer
+ */
+ abstract protected function newExternalLB( $cluster, $wiki = false );
+
+ /**
+ * Get a cached (tracked) load balancer for external storage
+ *
+ * @param string $cluster External storage cluster, or false for core
+ * @param bool|string $wiki Wiki ID, or false for the current wiki
+ * @return LoadBalancer
+ */
+ abstract public function &getExternalLB( $cluster, $wiki = false );
+
+ /**
+ * Execute a function for each tracked load balancer
+ * The callback is called with the load balancer as the first parameter,
+ * and $params passed as the subsequent parameters.
+ *
+ * @param callable $callback
+ * @param array $params
+ */
+ abstract public function forEachLB( $callback, array $params = [] );
+
+ /**
+ * Prepare all tracked load balancers for shutdown
+ * @param integer $flags Supports SHUTDOWN_* flags
+ * STUB
+ */
+ public function shutdown( $flags = 0 ) {
+ }
+
+ /**
+ * Call a method of each tracked load balancer
+ *
+ * @param string $methodName
+ * @param array $args
+ */
+ private function forEachLBCallMethod( $methodName, array $args = [] ) {
+ $this->forEachLB(
+ function ( LoadBalancer $loadBalancer, $methodName, array $args ) {
+ call_user_func_array( [ $loadBalancer, $methodName ], $args );
+ },
+ [ $methodName, $args ]
+ );
+ }
+
+ /**
+ * Commit on all connections. Done for two reasons:
+ * 1. To commit changes to the masters.
+ * 2. To release the snapshot on all connections, master and slave.
+ * @param string $fname Caller name
+ */
+ public function commitAll( $fname = __METHOD__ ) {
+ $this->logMultiDbTransaction();
+
+ $start = microtime( true );
+ $this->forEachLBCallMethod( 'commitAll', [ $fname ] );
+ $timeMs = 1000 * ( microtime( true ) - $start );
+
+ RequestContext::getMain()->getStats()->timing( "db.commit-all", $timeMs );
+ }
+
+ /**
+ * Commit changes on all master connections
+ * @param string $fname Caller name
+ * @param array $options Options map:
+ * - maxWriteDuration: abort if more than this much time was spent in write queries
+ */
+ public function commitMasterChanges( $fname = __METHOD__, array $options = [] ) {
+ $limit = isset( $options['maxWriteDuration'] ) ? $options['maxWriteDuration'] : 0;
+
+ $this->logMultiDbTransaction();
+ $this->forEachLB( function ( LoadBalancer $lb ) use ( $limit ) {
+ $lb->forEachOpenConnection( function ( IDatabase $db ) use ( $limit ) {
+ $time = $db->pendingWriteQueryDuration();
+ if ( $limit > 0 && $time > $limit ) {
+ throw new DBTransactionError(
+ $db,
+ wfMessage( 'transaction-duration-limit-exceeded', $time, $limit )->text()
+ );
+ }
+ } );
+ } );
+
+ $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] );
+ }
+
+ /**
+ * Rollback changes on all master connections
+ * @param string $fname Caller name
+ * @since 1.23
+ */
+ public function rollbackMasterChanges( $fname = __METHOD__ ) {
+ $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname ] );
+ }
+
+ /**
+ * Log query info if multi DB transactions are going to be committed now
+ */
+ private function logMultiDbTransaction() {
+ $callersByDB = [];
+ $this->forEachLB( function ( LoadBalancer $lb ) use ( &$callersByDB ) {
+ $masterName = $lb->getServerName( $lb->getWriterIndex() );
+ $callers = $lb->pendingMasterChangeCallers();
+ if ( $callers ) {
+ $callersByDB[$masterName] = $callers;
+ }
+ } );
+
+ if ( count( $callersByDB ) >= 2 ) {
+ $dbs = implode( ', ', array_keys( $callersByDB ) );
+ $msg = "Multi-DB transaction [{$dbs}]:\n";
+ foreach ( $callersByDB as $db => $callers ) {
+ $msg .= "$db: " . implode( '; ', $callers ) . "\n";
+ }
+ $this->logger->info( $msg );
+ }
+ }
+
+ /**
+ * Determine if any master connection has pending changes
+ * @return bool
+ * @since 1.23
+ */
+ public function hasMasterChanges() {
+ $ret = false;
+ $this->forEachLB( function ( LoadBalancer $lb ) use ( &$ret ) {
+ $ret = $ret || $lb->hasMasterChanges();
+ } );
+
+ return $ret;
+ }
+
+ /**
+ * Detemine if any lagged slave connection was used
+ * @since 1.27
+ * @return bool
+ */
+ public function laggedSlaveUsed() {
+ $ret = false;
+ $this->forEachLB( function ( LoadBalancer $lb ) use ( &$ret ) {
+ $ret = $ret || $lb->laggedSlaveUsed();
+ } );
+
+ return $ret;
+ }
+
+ /**
+ * Determine if any master connection has pending/written changes from this request
+ * @return bool
+ * @since 1.27
+ */
+ public function hasOrMadeRecentMasterChanges() {
+ $ret = false;
+ $this->forEachLB( function ( LoadBalancer $lb ) use ( &$ret ) {
+ $ret = $ret || $lb->hasOrMadeRecentMasterChanges();
+ } );
+ return $ret;
+ }
+
+ /**
+ * Waits for the slave DBs to catch up to the current master position
+ *
+ * Use this when updating very large numbers of rows, as in maintenance scripts,
+ * to avoid causing too much lag. Of course, this is a no-op if there are no slaves.
+ *
+ * By default this waits on all DB clusters actually used in this request.
+ * This makes sense when lag being waiting on is caused by the code that does this check.
+ * In that case, setting "ifWritesSince" can avoid the overhead of waiting for clusters
+ * that were not changed since the last wait check. To forcefully wait on a specific cluster
+ * for a given wiki, use the 'wiki' parameter. To forcefully wait on an "external" cluster,
+ * use the "cluster" parameter.
+ *
+ * Never call this function after a large DB write that is *still* in a transaction.
+ * It only makes sense to call this after the possible lag inducing changes were committed.
+ *
+ * @param array $opts Optional fields that include:
+ * - wiki : wait on the load balancer DBs that handles the given wiki
+ * - cluster : wait on the given external load balancer DBs
+ * - timeout : Max wait time. Default: ~60 seconds
+ * - ifWritesSince: Only wait if writes were done since this UNIX timestamp
+ * @throws DBReplicationWaitError If a timeout or error occured waiting on a DB cluster
+ * @since 1.27
+ */
+ public function waitForReplication( array $opts = [] ) {
+ $opts += [
+ 'wiki' => false,
+ 'cluster' => false,
+ 'timeout' => 60,
+ 'ifWritesSince' => null
+ ];
+
+ // Figure out which clusters need to be checked
+ /** @var LoadBalancer[] $lbs */
+ $lbs = [];
+ if ( $opts['cluster'] !== false ) {
+ $lbs[] = $this->getExternalLB( $opts['cluster'] );
+ } elseif ( $opts['wiki'] !== false ) {
+ $lbs[] = $this->getMainLB( $opts['wiki'] );
+ } else {
+ $this->forEachLB( function ( LoadBalancer $lb ) use ( &$lbs ) {
+ $lbs[] = $lb;
+ } );
+ if ( !$lbs ) {
+ return; // nothing actually used
+ }
+ }
+
+ // Get all the master positions of applicable DBs right now.
+ // This can be faster since waiting on one cluster reduces the
+ // time needed to wait on the next clusters.
+ $masterPositions = array_fill( 0, count( $lbs ), false );
+ foreach ( $lbs as $i => $lb ) {
+ if ( $lb->getServerCount() <= 1 ) {
+ // Bug 27975 - Don't try to wait for slaves if there are none
+ // Prevents permission error when getting master position
+ continue;
+ } elseif ( $opts['ifWritesSince']
+ && $lb->lastMasterChangeTimestamp() < $opts['ifWritesSince']
+ ) {
+ continue; // no writes since the last wait
+ }
+ $masterPositions[$i] = $lb->getMasterPos();
+ }
+
+ $failed = [];
+ foreach ( $lbs as $i => $lb ) {
+ if ( $masterPositions[$i] ) {
+ // The DBMS may not support getMasterPos() or the whole
+ // load balancer might be fake (e.g. $wgAllDBsAreLocalhost).
+ if ( !$lb->waitForAll( $masterPositions[$i], $opts['timeout'] ) ) {
+ $failed[] = $lb->getServerName( $lb->getWriterIndex() );
+ }
+ }
+ }
+
+ if ( $failed ) {
+ throw new DBReplicationWaitError(
+ "Could not wait for slaves to catch up to " .
+ implode( ', ', $failed )
+ );
+ }
+ }
+
+ /**
+ * Disable the ChronologyProtector for all load balancers
+ *
+ * This can be called at the start of special API entry points
+ *
+ * @since 1.27
+ */
+ public function disableChronologyProtection() {
+ $this->chronProt->setEnabled( false );
+ }
+
+ /**
+ * @return ChronologyProtector
+ */
+ protected function newChronologyProtector() {
+ $request = RequestContext::getMain()->getRequest();
+ $chronProt = new ChronologyProtector(
+ ObjectCache::getMainStashInstance(),
+ [
+ 'ip' => $request->getIP(),
+ 'agent' => $request->getHeader( 'User-Agent' )
+ ]
+ );
+ if ( PHP_SAPI === 'cli' ) {
+ $chronProt->setEnabled( false );
+ } elseif ( $request->getHeader( 'ChronologyProtection' ) === 'false' ) {
+ // Request opted out of using position wait logic. This is useful for requests
+ // done by the job queue or background ETL that do not have a meaningful session.
+ $chronProt->setWaitEnabled( false );
+ }
+
+ return $chronProt;
+ }
+
+ /**
+ * @param ChronologyProtector $cp
+ */
+ protected function shutdownChronologyProtector( ChronologyProtector $cp ) {
+ // Get all the master positions needed
+ $this->forEachLB( function ( LoadBalancer $lb ) use ( $cp ) {
+ $cp->shutdownLB( $lb );
+ } );
+ // Write them to the stash
+ $unsavedPositions = $cp->shutdown();
+ // If the positions failed to write to the stash, at least wait on local datacenter
+ // slaves to catch up before responding. Even if there are several DCs, this increases
+ // the chance that the user will see their own changes immediately afterwards. As long
+ // as the sticky DC cookie applies (same domain), this is not even an issue.
+ $this->forEachLB( function ( LoadBalancer $lb ) use ( $unsavedPositions ) {
+ $masterName = $lb->getServerName( $lb->getWriterIndex() );
+ if ( isset( $unsavedPositions[$masterName] ) ) {
+ $lb->waitForAll( $unsavedPositions[$masterName] );
+ }
+ } );
+ }
+}
+
+/**
+ * Exception class for attempted DB access
+ */
+class DBAccessError extends MWException {
+ public function __construct() {
+ parent::__construct( "Mediawiki tried to access the database via wfGetDB(). " .
+ "This is not allowed." );
+ }
+}
+
+/**
+ * Exception class for replica DB wait timeouts
+ */
+class DBReplicationWaitError extends Exception {
+}
diff --git a/www/wiki/includes/db/loadbalancer/LBFactoryFake.php b/www/wiki/includes/db/loadbalancer/LBFactoryFake.php
new file mode 100644
index 00000000..33ee2504
--- /dev/null
+++ b/www/wiki/includes/db/loadbalancer/LBFactoryFake.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * Generator of database load balancing objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * LBFactory class that throws an error on any attempt to use it.
+ * This will typically be done via wfGetDB().
+ * Call LBFactory::disableBackend() to start using this, and
+ * LBFactory::enableBackend() to return to normal behavior
+ */
+class LBFactoryFake extends LBFactory {
+ public function newMainLB( $wiki = false ) {
+ throw new DBAccessError;
+ }
+
+ public function getMainLB( $wiki = false ) {
+ throw new DBAccessError;
+ }
+
+ protected function newExternalLB( $cluster, $wiki = false ) {
+ throw new DBAccessError;
+ }
+
+ public function &getExternalLB( $cluster, $wiki = false ) {
+ throw new DBAccessError;
+ }
+
+ public function forEachLB( $callback, array $params = [] ) {
+ }
+}
diff --git a/www/wiki/includes/db/loadbalancer/LBFactoryMulti.php b/www/wiki/includes/db/loadbalancer/LBFactoryMulti.php
new file mode 100644
index 00000000..3a543acc
--- /dev/null
+++ b/www/wiki/includes/db/loadbalancer/LBFactoryMulti.php
@@ -0,0 +1,424 @@
+<?php
+/**
+ * Advanced generator of database load balancing objects for wiki farms.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * A multi-wiki, multi-master factory for Wikimedia and similar installations.
+ * Ignores the old configuration globals.
+ *
+ * Template override precedence (highest => lowest):
+ * - templateOverridesByServer
+ * - masterTemplateOverrides
+ * - templateOverridesBySection/templateOverridesByCluster
+ * - externalTemplateOverrides
+ * - serverTemplate
+ * Overrides only work on top level keys (so nested values will not be merged).
+ *
+ * Configuration:
+ * sectionsByDB A map of database names to section names.
+ *
+ * sectionLoads A 2-d map. For each section, gives a map of server names to
+ * load ratios. For example:
+ * array(
+ * 'section1' => array(
+ * 'db1' => 100,
+ * 'db2' => 100
+ * )
+ * )
+ *
+ * serverTemplate A server info associative array as documented for $wgDBservers.
+ * The host, hostName and load entries will be overridden.
+ *
+ * groupLoadsBySection A 3-d map giving server load ratios for each section and group.
+ * For example:
+ * array(
+ * 'section1' => array(
+ * 'group1' => array(
+ * 'db1' => 100,
+ * 'db2' => 100
+ * )
+ * )
+ * )
+ *
+ * groupLoadsByDB A 3-d map giving server load ratios by DB name.
+ *
+ * hostsByName A map of hostname to IP address.
+ *
+ * externalLoads A map of external storage cluster name to server load map.
+ *
+ * externalTemplateOverrides A set of server info keys overriding serverTemplate for external
+ * storage.
+ *
+ * templateOverridesByServer A 2-d map overriding serverTemplate and
+ * externalTemplateOverrides on a server-by-server basis. Applies
+ * to both core and external storage.
+ * templateOverridesBySection A 2-d map overriding the server info by section.
+ * templateOverridesByCluster A 2-d map overriding the server info by external storage cluster.
+ *
+ * masterTemplateOverrides An override array for all master servers.
+ *
+ * loadMonitorClass Name of the LoadMonitor class to always use.
+ *
+ * readOnlyBySection A map of section name to read-only message.
+ * Missing or false for read/write.
+ *
+ * @ingroup Database
+ */
+class LBFactoryMulti extends LBFactory {
+ /** @var array A map of database names to section names */
+ private $sectionsByDB;
+
+ /**
+ * @var array A 2-d map. For each section, gives a map of server names to
+ * load ratios
+ */
+ private $sectionLoads;
+
+ /**
+ * @var array A server info associative array as documented for
+ * $wgDBservers. The host, hostName and load entries will be
+ * overridden
+ */
+ private $serverTemplate;
+
+ // Optional settings
+
+ /** @var array A 3-d map giving server load ratios for each section and group */
+ private $groupLoadsBySection = [];
+
+ /** @var array A 3-d map giving server load ratios by DB name */
+ private $groupLoadsByDB = [];
+
+ /** @var array A map of hostname to IP address */
+ private $hostsByName = [];
+
+ /** @var array A map of external storage cluster name to server load map */
+ private $externalLoads = [];
+
+ /**
+ * @var array A set of server info keys overriding serverTemplate for
+ * external storage
+ */
+ private $externalTemplateOverrides;
+
+ /**
+ * @var array A 2-d map overriding serverTemplate and
+ * externalTemplateOverrides on a server-by-server basis. Applies to both
+ * core and external storage
+ */
+ private $templateOverridesByServer;
+
+ /** @var array A 2-d map overriding the server info by section */
+ private $templateOverridesBySection;
+
+ /** @var array A 2-d map overriding the server info by external storage cluster */
+ private $templateOverridesByCluster;
+
+ /** @var array An override array for all master servers */
+ private $masterTemplateOverrides;
+
+ /**
+ * @var array|bool A map of section name to read-only message. Missing or
+ * false for read/write
+ */
+ private $readOnlyBySection = [];
+
+ // Other stuff
+
+ /** @var array Load balancer factory configuration */
+ private $conf;
+
+ /** @var LoadBalancer[] */
+ private $mainLBs = [];
+
+ /** @var LoadBalancer[] */
+ private $extLBs = [];
+
+ /** @var string */
+ private $loadMonitorClass;
+
+ /** @var string */
+ private $lastWiki;
+
+ /** @var string */
+ private $lastSection;
+
+ /**
+ * @param array $conf
+ * @throws MWException
+ */
+ public function __construct( array $conf ) {
+ parent::__construct( $conf );
+
+ $this->conf = $conf;
+ $required = [ 'sectionsByDB', 'sectionLoads', 'serverTemplate' ];
+ $optional = [ 'groupLoadsBySection', 'groupLoadsByDB', 'hostsByName',
+ 'externalLoads', 'externalTemplateOverrides', 'templateOverridesByServer',
+ 'templateOverridesByCluster', 'templateOverridesBySection', 'masterTemplateOverrides',
+ 'readOnlyBySection', 'loadMonitorClass' ];
+
+ foreach ( $required as $key ) {
+ if ( !isset( $conf[$key] ) ) {
+ throw new MWException( __CLASS__ . ": $key is required in configuration" );
+ }
+ $this->$key = $conf[$key];
+ }
+
+ foreach ( $optional as $key ) {
+ if ( isset( $conf[$key] ) ) {
+ $this->$key = $conf[$key];
+ }
+ }
+ }
+
+ /**
+ * @param bool|string $wiki
+ * @return string
+ */
+ private function getSectionForWiki( $wiki = false ) {
+ if ( $this->lastWiki === $wiki ) {
+ return $this->lastSection;
+ }
+ list( $dbName, ) = $this->getDBNameAndPrefix( $wiki );
+ if ( isset( $this->sectionsByDB[$dbName] ) ) {
+ $section = $this->sectionsByDB[$dbName];
+ } else {
+ $section = 'DEFAULT';
+ }
+ $this->lastSection = $section;
+ $this->lastWiki = $wiki;
+
+ return $section;
+ }
+
+ /**
+ * @param bool|string $wiki
+ * @return LoadBalancer
+ */
+ public function newMainLB( $wiki = false ) {
+ list( $dbName, ) = $this->getDBNameAndPrefix( $wiki );
+ $section = $this->getSectionForWiki( $wiki );
+ if ( isset( $this->groupLoadsByDB[$dbName] ) ) {
+ $groupLoads = $this->groupLoadsByDB[$dbName];
+ } else {
+ $groupLoads = [];
+ }
+
+ if ( isset( $this->groupLoadsBySection[$section] ) ) {
+ $groupLoads = array_merge_recursive( $groupLoads, $this->groupLoadsBySection[$section] );
+ }
+
+ $readOnlyReason = $this->readOnlyReason;
+ // Use the LB-specific read-only reason if everything isn't already read-only
+ if ( $readOnlyReason === false && isset( $this->readOnlyBySection[$section] ) ) {
+ $readOnlyReason = $this->readOnlyBySection[$section];
+ }
+
+ $template = $this->serverTemplate;
+ if ( isset( $this->templateOverridesBySection[$section] ) ) {
+ $template = $this->templateOverridesBySection[$section] + $template;
+ }
+
+ return $this->newLoadBalancer(
+ $template,
+ $this->sectionLoads[$section],
+ $groupLoads,
+ $readOnlyReason
+ );
+ }
+
+ /**
+ * @param bool|string $wiki
+ * @return LoadBalancer
+ */
+ public function getMainLB( $wiki = false ) {
+ $section = $this->getSectionForWiki( $wiki );
+ if ( !isset( $this->mainLBs[$section] ) ) {
+ $lb = $this->newMainLB( $wiki );
+ $lb->parentInfo( [ 'id' => "main-$section" ] );
+ $this->chronProt->initLB( $lb );
+ $this->mainLBs[$section] = $lb;
+ }
+
+ return $this->mainLBs[$section];
+ }
+
+ /**
+ * @param string $cluster
+ * @param bool|string $wiki
+ * @throws MWException
+ * @return LoadBalancer
+ */
+ protected function newExternalLB( $cluster, $wiki = false ) {
+ if ( !isset( $this->externalLoads[$cluster] ) ) {
+ throw new MWException( __METHOD__ . ": Unknown cluster \"$cluster\"" );
+ }
+ $template = $this->serverTemplate;
+ if ( isset( $this->externalTemplateOverrides ) ) {
+ $template = $this->externalTemplateOverrides + $template;
+ }
+ if ( isset( $this->templateOverridesByCluster[$cluster] ) ) {
+ $template = $this->templateOverridesByCluster[$cluster] + $template;
+ }
+
+ return $this->newLoadBalancer(
+ $template,
+ $this->externalLoads[$cluster],
+ [],
+ $this->readOnlyReason
+ );
+ }
+
+ /**
+ * @param string $cluster External storage cluster, or false for core
+ * @param bool|string $wiki Wiki ID, or false for the current wiki
+ * @return LoadBalancer
+ */
+ public function &getExternalLB( $cluster, $wiki = false ) {
+ if ( !isset( $this->extLBs[$cluster] ) ) {
+ $this->extLBs[$cluster] = $this->newExternalLB( $cluster, $wiki );
+ $this->extLBs[$cluster]->parentInfo( [ 'id' => "ext-$cluster" ] );
+ $this->chronProt->initLB( $this->extLBs[$cluster] );
+ }
+
+ return $this->extLBs[$cluster];
+ }
+
+ /**
+ * Make a new load balancer object based on template and load array
+ *
+ * @param array $template
+ * @param array $loads
+ * @param array $groupLoads
+ * @param string|bool $readOnlyReason
+ * @return LoadBalancer
+ */
+ private function newLoadBalancer( $template, $loads, $groupLoads, $readOnlyReason ) {
+ return new LoadBalancer( [
+ 'servers' => $this->makeServerArray( $template, $loads, $groupLoads ),
+ 'loadMonitor' => $this->loadMonitorClass,
+ 'readOnlyReason' => $readOnlyReason,
+ 'trxProfiler' => $this->trxProfiler
+ ] );
+ }
+
+ /**
+ * Make a server array as expected by LoadBalancer::__construct, using a template and load array
+ *
+ * @param array $template
+ * @param array $loads
+ * @param array $groupLoads
+ * @return array
+ */
+ private function makeServerArray( $template, $loads, $groupLoads ) {
+ $servers = [];
+ $master = true;
+ $groupLoadsByServer = $this->reindexGroupLoads( $groupLoads );
+ foreach ( $groupLoadsByServer as $server => $stuff ) {
+ if ( !isset( $loads[$server] ) ) {
+ $loads[$server] = 0;
+ }
+ }
+ foreach ( $loads as $serverName => $load ) {
+ $serverInfo = $template;
+ if ( $master ) {
+ $serverInfo['master'] = true;
+ if ( isset( $this->masterTemplateOverrides ) ) {
+ $serverInfo = $this->masterTemplateOverrides + $serverInfo;
+ }
+ $master = false;
+ } else {
+ $serverInfo['slave'] = true;
+ }
+ if ( isset( $this->templateOverridesByServer[$serverName] ) ) {
+ $serverInfo = $this->templateOverridesByServer[$serverName] + $serverInfo;
+ }
+ if ( isset( $groupLoadsByServer[$serverName] ) ) {
+ $serverInfo['groupLoads'] = $groupLoadsByServer[$serverName];
+ }
+ if ( isset( $this->hostsByName[$serverName] ) ) {
+ $serverInfo['host'] = $this->hostsByName[$serverName];
+ } else {
+ $serverInfo['host'] = $serverName;
+ }
+ $serverInfo['hostName'] = $serverName;
+ $serverInfo['load'] = $load;
+ $servers[] = $serverInfo;
+ }
+
+ return $servers;
+ }
+
+ /**
+ * Take a group load array indexed by group then server, and reindex it by server then group
+ * @param array $groupLoads
+ * @return array
+ */
+ private function reindexGroupLoads( $groupLoads ) {
+ $reindexed = [];
+ foreach ( $groupLoads as $group => $loads ) {
+ foreach ( $loads as $server => $load ) {
+ $reindexed[$server][$group] = $load;
+ }
+ }
+
+ return $reindexed;
+ }
+
+ /**
+ * Get the database name and prefix based on the wiki ID
+ * @param bool|string $wiki
+ * @return array
+ */
+ private function getDBNameAndPrefix( $wiki = false ) {
+ if ( $wiki === false ) {
+ global $wgDBname, $wgDBprefix;
+
+ return [ $wgDBname, $wgDBprefix ];
+ } else {
+ return wfSplitWikiID( $wiki );
+ }
+ }
+
+ /**
+ * Execute a function for each tracked load balancer
+ * The callback is called with the load balancer as the first parameter,
+ * and $params passed as the subsequent parameters.
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachLB( $callback, array $params = [] ) {
+ foreach ( $this->mainLBs as $lb ) {
+ call_user_func_array( $callback, array_merge( [ $lb ], $params ) );
+ }
+ foreach ( $this->extLBs as $lb ) {
+ call_user_func_array( $callback, array_merge( [ $lb ], $params ) );
+ }
+ }
+
+ public function shutdown( $flags = 0 ) {
+ if ( !( $flags & self::SHUTDOWN_NO_CHRONPROT ) ) {
+ $this->shutdownChronologyProtector( $this->chronProt );
+ }
+ $this->commitMasterChanges( __METHOD__ ); // sanity
+ }
+}
diff --git a/www/wiki/includes/db/loadbalancer/LBFactorySimple.php b/www/wiki/includes/db/loadbalancer/LBFactorySimple.php
new file mode 100644
index 00000000..1b0a1f3f
--- /dev/null
+++ b/www/wiki/includes/db/loadbalancer/LBFactorySimple.php
@@ -0,0 +1,167 @@
+<?php
+/**
+ * Generator of database load balancing objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * A simple single-master LBFactory that gets its configuration from the b/c globals
+ */
+class LBFactorySimple extends LBFactory {
+ /** @var LoadBalancer */
+ private $mainLB;
+ /** @var LoadBalancer[] */
+ private $extLBs = [];
+
+ /** @var string */
+ private $loadMonitorClass;
+
+ public function __construct( array $conf ) {
+ parent::__construct( $conf );
+
+ $this->loadMonitorClass = isset( $conf['loadMonitorClass'] )
+ ? $conf['loadMonitorClass']
+ : null;
+ }
+
+ /**
+ * @param bool|string $wiki
+ * @return LoadBalancer
+ */
+ public function newMainLB( $wiki = false ) {
+ global $wgDBservers;
+
+ if ( is_array( $wgDBservers ) ) {
+ $servers = $wgDBservers;
+ foreach ( $servers as $i => &$server ) {
+ if ( $i == 0 ) {
+ $server['master'] = true;
+ } else {
+ $server['slave'] = true;
+ }
+ }
+ } else {
+ global $wgDBserver, $wgDBuser, $wgDBpassword, $wgDBname, $wgDBtype, $wgDebugDumpSql;
+ global $wgDBssl, $wgDBcompress;
+
+ $flags = DBO_DEFAULT;
+ if ( $wgDebugDumpSql ) {
+ $flags |= DBO_DEBUG;
+ }
+ if ( $wgDBssl ) {
+ $flags |= DBO_SSL;
+ }
+ if ( $wgDBcompress ) {
+ $flags |= DBO_COMPRESS;
+ }
+
+ $servers = [ [
+ 'host' => $wgDBserver,
+ 'user' => $wgDBuser,
+ 'password' => $wgDBpassword,
+ 'dbname' => $wgDBname,
+ 'type' => $wgDBtype,
+ 'load' => 1,
+ 'flags' => $flags,
+ 'master' => true
+ ] ];
+ }
+
+ return new LoadBalancer( [
+ 'servers' => $servers,
+ 'loadMonitor' => $this->loadMonitorClass,
+ 'readOnlyReason' => $this->readOnlyReason,
+ 'trxProfiler' => $this->trxProfiler
+ ] );
+ }
+
+ /**
+ * @param bool|string $wiki
+ * @return LoadBalancer
+ */
+ public function getMainLB( $wiki = false ) {
+ if ( !isset( $this->mainLB ) ) {
+ $this->mainLB = $this->newMainLB( $wiki );
+ $this->mainLB->parentInfo( [ 'id' => 'main' ] );
+ $this->chronProt->initLB( $this->mainLB );
+ }
+
+ return $this->mainLB;
+ }
+
+ /**
+ * @throws MWException
+ * @param string $cluster
+ * @param bool|string $wiki
+ * @return LoadBalancer
+ */
+ protected function newExternalLB( $cluster, $wiki = false ) {
+ global $wgExternalServers;
+ if ( !isset( $wgExternalServers[$cluster] ) ) {
+ throw new MWException( __METHOD__ . ": Unknown cluster \"$cluster\"" );
+ }
+
+ return new LoadBalancer( [
+ 'servers' => $wgExternalServers[$cluster],
+ 'loadMonitor' => $this->loadMonitorClass,
+ 'readOnlyReason' => $this->readOnlyReason,
+ 'trxProfiler' => $this->trxProfiler
+ ] );
+ }
+
+ /**
+ * @param string $cluster
+ * @param bool|string $wiki
+ * @return array
+ */
+ public function &getExternalLB( $cluster, $wiki = false ) {
+ if ( !isset( $this->extLBs[$cluster] ) ) {
+ $this->extLBs[$cluster] = $this->newExternalLB( $cluster, $wiki );
+ $this->extLBs[$cluster]->parentInfo( [ 'id' => "ext-$cluster" ] );
+ $this->chronProt->initLB( $this->extLBs[$cluster] );
+ }
+
+ return $this->extLBs[$cluster];
+ }
+
+ /**
+ * Execute a function for each tracked load balancer
+ * The callback is called with the load balancer as the first parameter,
+ * and $params passed as the subsequent parameters.
+ *
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachLB( $callback, array $params = [] ) {
+ if ( isset( $this->mainLB ) ) {
+ call_user_func_array( $callback, array_merge( [ $this->mainLB ], $params ) );
+ }
+ foreach ( $this->extLBs as $lb ) {
+ call_user_func_array( $callback, array_merge( [ $lb ], $params ) );
+ }
+ }
+
+ public function shutdown( $flags = 0 ) {
+ if ( !( $flags & self::SHUTDOWN_NO_CHRONPROT ) ) {
+ $this->shutdownChronologyProtector( $this->chronProt );
+ }
+ $this->commitMasterChanges( __METHOD__ ); // sanity
+ }
+}
diff --git a/www/wiki/includes/db/loadbalancer/LBFactorySingle.php b/www/wiki/includes/db/loadbalancer/LBFactorySingle.php
new file mode 100644
index 00000000..79ca3a70
--- /dev/null
+++ b/www/wiki/includes/db/loadbalancer/LBFactorySingle.php
@@ -0,0 +1,127 @@
+<?php
+/**
+ * Simple generator of database connections that always returns the same object.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * An LBFactory class that always returns a single database object.
+ */
+class LBFactorySingle extends LBFactory {
+ /** @var LoadBalancerSingle */
+ private $lb;
+
+ /**
+ * @param array $conf An associative array with one member:
+ * - connection: The DatabaseBase connection object
+ */
+ public function __construct( array $conf ) {
+ parent::__construct( $conf );
+
+ $this->lb = new LoadBalancerSingle( [
+ 'readOnlyReason' => $this->readOnlyReason,
+ 'trxProfiler' => $this->trxProfiler
+ ] + $conf );
+ }
+
+ /**
+ * @param bool|string $wiki
+ * @return LoadBalancerSingle
+ */
+ public function newMainLB( $wiki = false ) {
+ return $this->lb;
+ }
+
+ /**
+ * @param bool|string $wiki
+ * @return LoadBalancerSingle
+ */
+ public function getMainLB( $wiki = false ) {
+ return $this->lb;
+ }
+
+ /**
+ * @param string $cluster External storage cluster, or false for core
+ * @param bool|string $wiki Wiki ID, or false for the current wiki
+ * @return LoadBalancerSingle
+ */
+ protected function newExternalLB( $cluster, $wiki = false ) {
+ return $this->lb;
+ }
+
+ /**
+ * @param string $cluster External storage cluster, or false for core
+ * @param bool|string $wiki Wiki ID, or false for the current wiki
+ * @return LoadBalancerSingle
+ */
+ public function &getExternalLB( $cluster, $wiki = false ) {
+ return $this->lb;
+ }
+
+ /**
+ * @param string|callable $callback
+ * @param array $params
+ */
+ public function forEachLB( $callback, array $params = [] ) {
+ call_user_func_array( $callback, array_merge( [ $this->lb ], $params ) );
+ }
+}
+
+/**
+ * Helper class for LBFactorySingle.
+ */
+class LoadBalancerSingle extends LoadBalancer {
+ /** @var DatabaseBase */
+ private $db;
+
+ /**
+ * @param array $params
+ */
+ public function __construct( array $params ) {
+ $this->db = $params['connection'];
+
+ parent::__construct( [
+ 'servers' => [
+ [
+ 'type' => $this->db->getType(),
+ 'host' => $this->db->getServer(),
+ 'dbname' => $this->db->getDBname(),
+ 'load' => 1,
+ ]
+ ],
+ 'trxProfiler' => $this->trxProfiler
+ ] );
+
+ if ( isset( $params['readOnlyReason'] ) ) {
+ $this->db->setLBInfo( 'readOnlyReason', $params['readOnlyReason'] );
+ }
+ }
+
+ /**
+ *
+ * @param string $server
+ * @param bool $dbNameOverride
+ *
+ * @return DatabaseBase
+ */
+ protected function reallyOpenConnection( $server, $dbNameOverride = false ) {
+ return $this->db;
+ }
+}
diff --git a/www/wiki/includes/db/loadbalancer/LoadBalancer.php b/www/wiki/includes/db/loadbalancer/LoadBalancer.php
new file mode 100644
index 00000000..741999c5
--- /dev/null
+++ b/www/wiki/includes/db/loadbalancer/LoadBalancer.php
@@ -0,0 +1,1407 @@
+<?php
+/**
+ * Database load balancing.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Database load balancing object
+ *
+ * @todo document
+ * @ingroup Database
+ */
+class LoadBalancer {
+ /** @var array[] Map of (server index => server config array) */
+ private $mServers;
+ /** @var array[] Map of (local/foreignUsed/foreignFree => server index => DatabaseBase array) */
+ private $mConns;
+ /** @var array Map of (server index => weight) */
+ private $mLoads;
+ /** @var array[] Map of (group => server index => weight) */
+ private $mGroupLoads;
+ /** @var bool Whether to disregard slave lag as a factor in slave selection */
+ private $mAllowLagged;
+ /** @var integer Seconds to spend waiting on slave lag to resolve */
+ private $mWaitTimeout;
+ /** @var array LBFactory information */
+ private $mParentInfo;
+
+ /** @var string The LoadMonitor subclass name */
+ private $mLoadMonitorClass;
+ /** @var LoadMonitor */
+ private $mLoadMonitor;
+ /** @var BagOStuff */
+ private $srvCache;
+
+ /** @var bool|DatabaseBase Database connection that caused a problem */
+ private $mErrorConnection;
+ /** @var integer The generic (not query grouped) slave index (of $mServers) */
+ private $mReadIndex;
+ /** @var bool|DBMasterPos False if not set */
+ private $mWaitForPos;
+ /** @var bool Whether the generic reader fell back to a lagged slave */
+ private $laggedSlaveMode = false;
+ /** @var bool Whether the generic reader fell back to a lagged slave */
+ private $slavesDownMode = false;
+ /** @var string The last DB selection or connection error */
+ private $mLastError = 'Unknown error';
+ /** @var string|bool Reason the LB is read-only or false if not */
+ private $readOnlyReason = false;
+ /** @var integer Total connections opened */
+ private $connsOpened = 0;
+
+ /** @var TransactionProfiler */
+ protected $trxProfiler;
+
+ /** @var integer Warn when this many connection are held */
+ const CONN_HELD_WARN_THRESHOLD = 10;
+ /** @var integer Default 'max lag' when unspecified */
+ const MAX_LAG = 10;
+ /** @var integer Max time to wait for a slave to catch up (e.g. ChronologyProtector) */
+ const POS_WAIT_TIMEOUT = 10;
+
+ /**
+ * @param array $params Array with keys:
+ * - servers : Required. Array of server info structures.
+ * - loadMonitor : Name of a class used to fetch server lag and load.
+ * - readOnlyReason : Reason the master DB is read-only if so [optional]
+ * @throws MWException
+ */
+ public function __construct( array $params ) {
+ if ( !isset( $params['servers'] ) ) {
+ throw new MWException( __CLASS__ . ': missing servers parameter' );
+ }
+ $this->mServers = $params['servers'];
+ $this->mWaitTimeout = self::POS_WAIT_TIMEOUT;
+
+ $this->mReadIndex = -1;
+ $this->mWriteIndex = -1;
+ $this->mConns = [
+ 'local' => [],
+ 'foreignUsed' => [],
+ 'foreignFree' => [] ];
+ $this->mLoads = [];
+ $this->mWaitForPos = false;
+ $this->mErrorConnection = false;
+ $this->mAllowLagged = false;
+
+ if ( isset( $params['readOnlyReason'] ) && is_string( $params['readOnlyReason'] ) ) {
+ $this->readOnlyReason = $params['readOnlyReason'];
+ }
+
+ if ( isset( $params['loadMonitor'] ) ) {
+ $this->mLoadMonitorClass = $params['loadMonitor'];
+ } else {
+ $master = reset( $params['servers'] );
+ if ( isset( $master['type'] ) && $master['type'] === 'mysql' ) {
+ $this->mLoadMonitorClass = 'LoadMonitorMySQL';
+ } else {
+ $this->mLoadMonitorClass = 'LoadMonitorNull';
+ }
+ }
+
+ foreach ( $params['servers'] as $i => $server ) {
+ $this->mLoads[$i] = $server['load'];
+ if ( isset( $server['groupLoads'] ) ) {
+ foreach ( $server['groupLoads'] as $group => $ratio ) {
+ if ( !isset( $this->mGroupLoads[$group] ) ) {
+ $this->mGroupLoads[$group] = [];
+ }
+ $this->mGroupLoads[$group][$i] = $ratio;
+ }
+ }
+ }
+
+ $this->srvCache = ObjectCache::getLocalServerInstance();
+
+ if ( isset( $params['trxProfiler'] ) ) {
+ $this->trxProfiler = $params['trxProfiler'];
+ } else {
+ $this->trxProfiler = new TransactionProfiler();
+ }
+ }
+
+ /**
+ * Get a LoadMonitor instance
+ *
+ * @return LoadMonitor
+ */
+ private function getLoadMonitor() {
+ if ( !isset( $this->mLoadMonitor ) ) {
+ $class = $this->mLoadMonitorClass;
+ $this->mLoadMonitor = new $class( $this );
+ }
+
+ return $this->mLoadMonitor;
+ }
+
+ /**
+ * Get or set arbitrary data used by the parent object, usually an LBFactory
+ * @param mixed $x
+ * @return mixed
+ */
+ public function parentInfo( $x = null ) {
+ return wfSetVar( $this->mParentInfo, $x );
+ }
+
+ /**
+ * @param array $loads
+ * @param bool|string $wiki Wiki to get non-lagged for
+ * @param int $maxLag Restrict the maximum allowed lag to this many seconds
+ * @return bool|int|string
+ */
+ private function getRandomNonLagged( array $loads, $wiki = false, $maxLag = self::MAX_LAG ) {
+ $lags = $this->getLagTimes( $wiki );
+
+ # Unset excessively lagged servers
+ foreach ( $lags as $i => $lag ) {
+ if ( $i != 0 ) {
+ $maxServerLag = $maxLag;
+ if ( isset( $this->mServers[$i]['max lag'] ) ) {
+ $maxServerLag = min( $maxServerLag, $this->mServers[$i]['max lag'] );
+ }
+
+ $host = $this->getServerName( $i );
+ if ( $lag === false ) {
+ wfDebugLog( 'replication', "Server $host (#$i) is not replicating?" );
+ unset( $loads[$i] );
+ } elseif ( $lag > $maxServerLag ) {
+ wfDebugLog( 'replication', "Server $host (#$i) has >= $lag seconds of lag" );
+ unset( $loads[$i] );
+ }
+ }
+ }
+
+ # Find out if all the slaves with non-zero load are lagged
+ $sum = 0;
+ foreach ( $loads as $load ) {
+ $sum += $load;
+ }
+ if ( $sum == 0 ) {
+ # No appropriate DB servers except maybe the master and some slaves with zero load
+ # Do NOT use the master
+ # Instead, this function will return false, triggering read-only mode,
+ # and a lagged slave will be used instead.
+ return false;
+ }
+
+ if ( count( $loads ) == 0 ) {
+ return false;
+ }
+
+ # Return a random representative of the remainder
+ return ArrayUtils::pickRandom( $loads );
+ }
+
+ /**
+ * Get the index of the reader connection, which may be a slave
+ * This takes into account load ratios and lag times. It should
+ * always return a consistent index during a given invocation
+ *
+ * Side effect: opens connections to databases
+ * @param string|bool $group Query group, or false for the generic reader
+ * @param string|bool $wiki Wiki ID, or false for the current wiki
+ * @throws MWException
+ * @return bool|int|string
+ */
+ public function getReaderIndex( $group = false, $wiki = false ) {
+ global $wgDBtype;
+
+ # @todo FIXME: For now, only go through all this for mysql databases
+ if ( $wgDBtype != 'mysql' ) {
+ return $this->getWriterIndex();
+ }
+
+ if ( count( $this->mServers ) == 1 ) {
+ # Skip the load balancing if there's only one server
+ return 0;
+ } elseif ( $group === false && $this->mReadIndex >= 0 ) {
+ # Shortcut if generic reader exists already
+ return $this->mReadIndex;
+ }
+
+ # Find the relevant load array
+ if ( $group !== false ) {
+ if ( isset( $this->mGroupLoads[$group] ) ) {
+ $nonErrorLoads = $this->mGroupLoads[$group];
+ } else {
+ # No loads for this group, return false and the caller can use some other group
+ wfDebugLog( 'connect', __METHOD__ . ": no loads for group $group\n" );
+
+ return false;
+ }
+ } else {
+ $nonErrorLoads = $this->mLoads;
+ }
+
+ if ( !count( $nonErrorLoads ) ) {
+ throw new MWException( "Empty server array given to LoadBalancer" );
+ }
+
+ # Scale the configured load ratios according to the dynamic load (if the load monitor supports it)
+ $this->getLoadMonitor()->scaleLoads( $nonErrorLoads, $group, $wiki );
+
+ $laggedSlaveMode = false;
+
+ # No server found yet
+ $i = false;
+ $conn = false;
+ # First try quickly looking through the available servers for a server that
+ # meets our criteria
+ $currentLoads = $nonErrorLoads;
+ while ( count( $currentLoads ) ) {
+ if ( $this->mAllowLagged || $laggedSlaveMode ) {
+ $i = ArrayUtils::pickRandom( $currentLoads );
+ } else {
+ $i = false;
+ if ( $this->mWaitForPos && $this->mWaitForPos->asOfTime() ) {
+ # ChronologyProtecter causes mWaitForPos to be set via sessions.
+ # This triggers doWait() after connect, so it's especially good to
+ # avoid lagged servers so as to avoid just blocking in that method.
+ $ago = microtime( true ) - $this->mWaitForPos->asOfTime();
+ # Aim for <= 1 second of waiting (being too picky can backfire)
+ $i = $this->getRandomNonLagged( $currentLoads, $wiki, $ago + 1 );
+ }
+ if ( $i === false ) {
+ # Any server with less lag than it's 'max lag' param is preferable
+ $i = $this->getRandomNonLagged( $currentLoads, $wiki );
+ }
+ if ( $i === false && count( $currentLoads ) != 0 ) {
+ # All slaves lagged. Switch to read-only mode
+ wfDebugLog( 'replication', "All slaves lagged. Switch to read-only mode" );
+ $i = ArrayUtils::pickRandom( $currentLoads );
+ $laggedSlaveMode = true;
+ }
+ }
+
+ if ( $i === false ) {
+ # pickRandom() returned false
+ # This is permanent and means the configuration or the load monitor
+ # wants us to return false.
+ wfDebugLog( 'connect', __METHOD__ . ": pickRandom() returned false" );
+
+ return false;
+ }
+
+ $serverName = $this->getServerName( $i );
+ wfDebugLog( 'connect', __METHOD__ . ": Using reader #$i: $serverName..." );
+
+ $conn = $this->openConnection( $i, $wiki );
+ if ( !$conn ) {
+ wfDebugLog( 'connect', __METHOD__ . ": Failed connecting to $i/$wiki" );
+ unset( $nonErrorLoads[$i] );
+ unset( $currentLoads[$i] );
+ $i = false;
+ continue;
+ }
+
+ // Decrement reference counter, we are finished with this connection.
+ // It will be incremented for the caller later.
+ if ( $wiki !== false ) {
+ $this->reuseConnection( $conn );
+ }
+
+ # Return this server
+ break;
+ }
+
+ # If all servers were down, quit now
+ if ( !count( $nonErrorLoads ) ) {
+ wfDebugLog( 'connect', "All servers down" );
+ }
+
+ if ( $i !== false ) {
+ # Slave connection successful
+ # Wait for the session master pos for a short time
+ if ( $this->mWaitForPos && $i > 0 ) {
+ if ( !$this->doWait( $i ) ) {
+ $this->mServers[$i]['slave pos'] = $conn->getSlavePos();
+ }
+ }
+ if ( $this->mReadIndex <= 0 && $this->mLoads[$i] > 0 && $group === false ) {
+ $this->mReadIndex = $i;
+ # Record if the generic reader index is in "lagged slave" mode
+ if ( $laggedSlaveMode ) {
+ $this->laggedSlaveMode = true;
+ }
+ }
+ $serverName = $this->getServerName( $i );
+ wfDebugLog( 'connect', __METHOD__ .
+ ": using server $serverName for group '$group'\n" );
+ }
+
+ return $i;
+ }
+
+ /**
+ * Set the master wait position
+ * If a DB_SLAVE connection has been opened already, waits
+ * Otherwise sets a variable telling it to wait if such a connection is opened
+ * @param DBMasterPos $pos
+ */
+ public function waitFor( $pos ) {
+ $this->mWaitForPos = $pos;
+ $i = $this->mReadIndex;
+
+ if ( $i > 0 ) {
+ if ( !$this->doWait( $i ) ) {
+ $this->mServers[$i]['slave pos'] = $this->getAnyOpenConnection( $i )->getSlavePos();
+ $this->laggedSlaveMode = true;
+ }
+ }
+ }
+
+ /**
+ * Set the master wait position and wait for a "generic" slave to catch up to it
+ *
+ * This can be used a faster proxy for waitForAll()
+ *
+ * @param DBMasterPos $pos
+ * @param int $timeout Max seconds to wait; default is mWaitTimeout
+ * @return bool Success (able to connect and no timeouts reached)
+ * @since 1.26
+ */
+ public function waitForOne( $pos, $timeout = null ) {
+ $this->mWaitForPos = $pos;
+
+ $i = $this->mReadIndex;
+ if ( $i <= 0 ) {
+ // Pick a generic slave if there isn't one yet
+ $readLoads = $this->mLoads;
+ unset( $readLoads[$this->getWriterIndex()] ); // slaves only
+ $readLoads = array_filter( $readLoads ); // with non-zero load
+ $i = ArrayUtils::pickRandom( $readLoads );
+ }
+
+ if ( $i > 0 ) {
+ $ok = $this->doWait( $i, true, $timeout );
+ } else {
+ $ok = true; // no applicable loads
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Set the master wait position and wait for ALL slaves to catch up to it
+ * @param DBMasterPos $pos
+ * @param int $timeout Max seconds to wait; default is mWaitTimeout
+ * @return bool Success (able to connect and no timeouts reached)
+ */
+ public function waitForAll( $pos, $timeout = null ) {
+ $this->mWaitForPos = $pos;
+ $serverCount = count( $this->mServers );
+
+ $ok = true;
+ for ( $i = 1; $i < $serverCount; $i++ ) {
+ if ( $this->mLoads[$i] > 0 ) {
+ $ok = $this->doWait( $i, true, $timeout ) && $ok;
+ }
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Get any open connection to a given server index, local or foreign
+ * Returns false if there is no connection open
+ *
+ * @param int $i
+ * @return DatabaseBase|bool False on failure
+ */
+ public function getAnyOpenConnection( $i ) {
+ foreach ( $this->mConns as $conns ) {
+ if ( !empty( $conns[$i] ) ) {
+ return reset( $conns[$i] );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Wait for a given slave to catch up to the master pos stored in $this
+ * @param int $index Server index
+ * @param bool $open Check the server even if a new connection has to be made
+ * @param int $timeout Max seconds to wait; default is mWaitTimeout
+ * @return bool
+ */
+ protected function doWait( $index, $open = false, $timeout = null ) {
+ $close = false; // close the connection afterwards
+
+ // Check if we already know that the DB has reached this point
+ $server = $this->getServerName( $index );
+ $key = $this->srvCache->makeGlobalKey( __CLASS__, 'last-known-pos', $server );
+ /** @var DBMasterPos $knownReachedPos */
+ $knownReachedPos = $this->srvCache->get( $key );
+ if ( $knownReachedPos && $knownReachedPos->hasReached( $this->mWaitForPos ) ) {
+ wfDebugLog( 'replication', __METHOD__ .
+ ": slave $server known to be caught up (pos >= $knownReachedPos).\n" );
+ return true;
+ }
+
+ // Find a connection to wait on, creating one if needed and allowed
+ $conn = $this->getAnyOpenConnection( $index );
+ if ( !$conn ) {
+ if ( !$open ) {
+ wfDebugLog( 'replication', __METHOD__ . ": no connection open for $server\n" );
+
+ return false;
+ } else {
+ $conn = $this->openConnection( $index, '' );
+ if ( !$conn ) {
+ wfDebugLog( 'replication', __METHOD__ . ": failed to connect to $server\n" );
+
+ return false;
+ }
+ // Avoid connection spam in waitForAll() when connections
+ // are made just for the sake of doing this lag check.
+ $close = true;
+ }
+ }
+
+ wfDebugLog( 'replication', __METHOD__ . ": Waiting for slave $server to catch up...\n" );
+ $timeout = $timeout ?: $this->mWaitTimeout;
+ $result = $conn->masterPosWait( $this->mWaitForPos, $timeout );
+
+ if ( $result == -1 || is_null( $result ) ) {
+ // Timed out waiting for slave, use master instead
+ $msg = __METHOD__ . ": Timed out waiting on $server pos {$this->mWaitForPos}";
+ wfDebugLog( 'replication', "$msg\n" );
+ wfDebugLog( 'DBPerformance', "$msg:\n" . wfBacktrace( true ) );
+ $ok = false;
+ } else {
+ wfDebugLog( 'replication', __METHOD__ . ": Done\n" );
+ $ok = true;
+ // Remember that the DB reached this point
+ $this->srvCache->set( $key, $this->mWaitForPos, BagOStuff::TTL_DAY );
+ }
+
+ if ( $close ) {
+ $this->closeConnection( $conn );
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Get a connection by index
+ * This is the main entry point for this class.
+ *
+ * @param int $i Server index
+ * @param array|string|bool $groups Query group(s), or false for the generic reader
+ * @param string|bool $wiki Wiki ID, or false for the current wiki
+ *
+ * @throws MWException
+ * @return DatabaseBase
+ */
+ public function getConnection( $i, $groups = [], $wiki = false ) {
+ if ( $i === null || $i === false ) {
+ throw new MWException( 'Attempt to call ' . __METHOD__ .
+ ' with invalid server index' );
+ }
+
+ if ( $wiki === wfWikiID() ) {
+ $wiki = false;
+ }
+
+ $groups = ( $groups === false || $groups === [] )
+ ? [ false ] // check one "group": the generic pool
+ : (array)$groups;
+
+ $masterOnly = ( $i == DB_MASTER || $i == $this->getWriterIndex() );
+ $oldConnsOpened = $this->connsOpened; // connections open now
+
+ if ( $i == DB_MASTER ) {
+ $i = $this->getWriterIndex();
+ } else {
+ # Try to find an available server in any the query groups (in order)
+ foreach ( $groups as $group ) {
+ $groupIndex = $this->getReaderIndex( $group, $wiki );
+ if ( $groupIndex !== false ) {
+ $i = $groupIndex;
+ break;
+ }
+ }
+ }
+
+ # Operation-based index
+ if ( $i == DB_SLAVE ) {
+ $this->mLastError = 'Unknown error'; // reset error string
+ # Try the general server pool if $groups are unavailable.
+ $i = in_array( false, $groups, true )
+ ? false // don't bother with this if that is what was tried above
+ : $this->getReaderIndex( false, $wiki );
+ # Couldn't find a working server in getReaderIndex()?
+ if ( $i === false ) {
+ $this->mLastError = 'No working slave server: ' . $this->mLastError;
+
+ return $this->reportConnectionError();
+ }
+ }
+
+ # Now we have an explicit index into the servers array
+ $conn = $this->openConnection( $i, $wiki );
+ if ( !$conn ) {
+ return $this->reportConnectionError();
+ }
+
+ # Profile any new connections that happen
+ if ( $this->connsOpened > $oldConnsOpened ) {
+ $host = $conn->getServer();
+ $dbname = $conn->getDBname();
+ $trxProf = Profiler::instance()->getTransactionProfiler();
+ $trxProf->recordConnection( $host, $dbname, $masterOnly );
+ }
+
+ if ( $masterOnly ) {
+ # Make master-requested DB handles inherit any read-only mode setting
+ $conn->setLBInfo( 'readOnlyReason', $this->getReadOnlyReason( $wiki ) );
+ }
+
+ return $conn;
+ }
+
+ /**
+ * Mark a foreign connection as being available for reuse under a different
+ * DB name or prefix. This mechanism is reference-counted, and must be called
+ * the same number of times as getConnection() to work.
+ *
+ * @param DatabaseBase $conn
+ * @throws MWException
+ */
+ public function reuseConnection( $conn ) {
+ $serverIndex = $conn->getLBInfo( 'serverIndex' );
+ $refCount = $conn->getLBInfo( 'foreignPoolRefCount' );
+ if ( $serverIndex === null || $refCount === null ) {
+ wfDebug( __METHOD__ . ": this connection was not opened as a foreign connection\n" );
+ /**
+ * This can happen in code like:
+ * foreach ( $dbs as $db ) {
+ * $conn = $lb->getConnection( DB_SLAVE, array(), $db );
+ * ...
+ * $lb->reuseConnection( $conn );
+ * }
+ * When a connection to the local DB is opened in this way, reuseConnection()
+ * should be ignored
+ */
+ return;
+ }
+
+ $dbName = $conn->getDBname();
+ $prefix = $conn->tablePrefix();
+ if ( strval( $prefix ) !== '' ) {
+ $wiki = "$dbName-$prefix";
+ } else {
+ $wiki = $dbName;
+ }
+ if ( $this->mConns['foreignUsed'][$serverIndex][$wiki] !== $conn ) {
+ throw new MWException( __METHOD__ . ": connection not found, has " .
+ "the connection been freed already?" );
+ }
+ $conn->setLBInfo( 'foreignPoolRefCount', --$refCount );
+ if ( $refCount <= 0 ) {
+ $this->mConns['foreignFree'][$serverIndex][$wiki] = $conn;
+ unset( $this->mConns['foreignUsed'][$serverIndex][$wiki] );
+ wfDebug( __METHOD__ . ": freed connection $serverIndex/$wiki\n" );
+ } else {
+ wfDebug( __METHOD__ . ": reference count for $serverIndex/$wiki reduced to $refCount\n" );
+ }
+ }
+
+ /**
+ * Get a database connection handle reference
+ *
+ * The handle's methods wrap simply wrap those of a DatabaseBase handle
+ *
+ * @see LoadBalancer::getConnection() for parameter information
+ *
+ * @param int $db
+ * @param array|string|bool $groups Query group(s), or false for the generic reader
+ * @param string|bool $wiki Wiki ID, or false for the current wiki
+ * @return DBConnRef
+ */
+ public function getConnectionRef( $db, $groups = [], $wiki = false ) {
+ return new DBConnRef( $this, $this->getConnection( $db, $groups, $wiki ) );
+ }
+
+ /**
+ * Get a database connection handle reference without connecting yet
+ *
+ * The handle's methods wrap simply wrap those of a DatabaseBase handle
+ *
+ * @see LoadBalancer::getConnection() for parameter information
+ *
+ * @param int $db
+ * @param array|string|bool $groups Query group(s), or false for the generic reader
+ * @param string|bool $wiki Wiki ID, or false for the current wiki
+ * @return DBConnRef
+ */
+ public function getLazyConnectionRef( $db, $groups = [], $wiki = false ) {
+ return new DBConnRef( $this, [ $db, $groups, $wiki ] );
+ }
+
+ /**
+ * Open a connection to the server given by the specified index
+ * Index must be an actual index into the array.
+ * If the server is already open, returns it.
+ *
+ * On error, returns false, and the connection which caused the
+ * error will be available via $this->mErrorConnection.
+ *
+ * @param int $i Server index
+ * @param string|bool $wiki Wiki ID, or false for the current wiki
+ * @return DatabaseBase|bool Returns false on errors
+ */
+ public function openConnection( $i, $wiki = false ) {
+ if ( $wiki !== false ) {
+ $conn = $this->openForeignConnection( $i, $wiki );
+ } elseif ( isset( $this->mConns['local'][$i][0] ) ) {
+ $conn = $this->mConns['local'][$i][0];
+ } else {
+ $server = $this->mServers[$i];
+ $server['serverIndex'] = $i;
+ $conn = $this->reallyOpenConnection( $server, false );
+ $serverName = $this->getServerName( $i );
+ if ( $conn->isOpen() ) {
+ wfDebugLog( 'connect', "Connected to database $i at $serverName\n" );
+ $this->mConns['local'][$i][0] = $conn;
+ } else {
+ wfDebugLog( 'connect', "Failed to connect to database $i at $serverName\n" );
+ $this->mErrorConnection = $conn;
+ $conn = false;
+ }
+ }
+
+ if ( $conn && !$conn->isOpen() ) {
+ // Connection was made but later unrecoverably lost for some reason.
+ // Do not return a handle that will just throw exceptions on use,
+ // but let the calling code (e.g. getReaderIndex) try another server.
+ // See DatabaseMyslBase::ping() for how this can happen.
+ $this->mErrorConnection = $conn;
+ $conn = false;
+ }
+
+ return $conn;
+ }
+
+ /**
+ * Open a connection to a foreign DB, or return one if it is already open.
+ *
+ * Increments a reference count on the returned connection which locks the
+ * connection to the requested wiki. This reference count can be
+ * decremented by calling reuseConnection().
+ *
+ * If a connection is open to the appropriate server already, but with the wrong
+ * database, it will be switched to the right database and returned, as long as
+ * it has been freed first with reuseConnection().
+ *
+ * On error, returns false, and the connection which caused the
+ * error will be available via $this->mErrorConnection.
+ *
+ * @param int $i Server index
+ * @param string $wiki Wiki ID to open
+ * @return DatabaseBase
+ */
+ private function openForeignConnection( $i, $wiki ) {
+ list( $dbName, $prefix ) = wfSplitWikiID( $wiki );
+ if ( isset( $this->mConns['foreignUsed'][$i][$wiki] ) ) {
+ // Reuse an already-used connection
+ $conn = $this->mConns['foreignUsed'][$i][$wiki];
+ wfDebug( __METHOD__ . ": reusing connection $i/$wiki\n" );
+ } elseif ( isset( $this->mConns['foreignFree'][$i][$wiki] ) ) {
+ // Reuse a free connection for the same wiki
+ $conn = $this->mConns['foreignFree'][$i][$wiki];
+ unset( $this->mConns['foreignFree'][$i][$wiki] );
+ $this->mConns['foreignUsed'][$i][$wiki] = $conn;
+ wfDebug( __METHOD__ . ": reusing free connection $i/$wiki\n" );
+ } elseif ( !empty( $this->mConns['foreignFree'][$i] ) ) {
+ // Reuse a connection from another wiki
+ $conn = reset( $this->mConns['foreignFree'][$i] );
+ $oldWiki = key( $this->mConns['foreignFree'][$i] );
+
+ // The empty string as a DB name means "don't care".
+ // DatabaseMysqlBase::open() already handle this on connection.
+ if ( $dbName !== '' && !$conn->selectDB( $dbName ) ) {
+ $this->mLastError = "Error selecting database $dbName on server " .
+ $conn->getServer() . " from client host " . wfHostname() . "\n";
+ $this->mErrorConnection = $conn;
+ $conn = false;
+ } else {
+ $conn->tablePrefix( $prefix );
+ unset( $this->mConns['foreignFree'][$i][$oldWiki] );
+ $this->mConns['foreignUsed'][$i][$wiki] = $conn;
+ wfDebug( __METHOD__ . ": reusing free connection from $oldWiki for $wiki\n" );
+ }
+ } else {
+ // Open a new connection
+ $server = $this->mServers[$i];
+ $server['serverIndex'] = $i;
+ $server['foreignPoolRefCount'] = 0;
+ $server['foreign'] = true;
+ $conn = $this->reallyOpenConnection( $server, $dbName );
+ if ( !$conn->isOpen() ) {
+ wfDebug( __METHOD__ . ": error opening connection for $i/$wiki\n" );
+ $this->mErrorConnection = $conn;
+ $conn = false;
+ } else {
+ $conn->tablePrefix( $prefix );
+ $this->mConns['foreignUsed'][$i][$wiki] = $conn;
+ wfDebug( __METHOD__ . ": opened new connection for $i/$wiki\n" );
+ }
+ }
+
+ // Increment reference count
+ if ( $conn ) {
+ $refCount = $conn->getLBInfo( 'foreignPoolRefCount' );
+ $conn->setLBInfo( 'foreignPoolRefCount', $refCount + 1 );
+ }
+
+ return $conn;
+ }
+
+ /**
+ * Test if the specified index represents an open connection
+ *
+ * @param int $index Server index
+ * @access private
+ * @return bool
+ */
+ private function isOpen( $index ) {
+ if ( !is_integer( $index ) ) {
+ return false;
+ }
+
+ return (bool)$this->getAnyOpenConnection( $index );
+ }
+
+ /**
+ * Really opens a connection. Uncached.
+ * Returns a Database object whether or not the connection was successful.
+ * @access private
+ *
+ * @param array $server
+ * @param bool $dbNameOverride
+ * @throws MWException
+ * @return DatabaseBase
+ */
+ protected function reallyOpenConnection( $server, $dbNameOverride = false ) {
+ if ( !is_array( $server ) ) {
+ throw new MWException( 'You must update your load-balancing configuration. ' .
+ 'See DefaultSettings.php entry for $wgDBservers.' );
+ }
+
+ if ( $dbNameOverride !== false ) {
+ $server['dbname'] = $dbNameOverride;
+ }
+
+ // Let the handle know what the cluster master is (e.g. "db1052")
+ $masterName = $this->getServerName( 0 );
+ $server['clusterMasterHost'] = $masterName;
+
+ // Log when many connection are made on requests
+ if ( ++$this->connsOpened >= self::CONN_HELD_WARN_THRESHOLD ) {
+ wfDebugLog( 'DBPerformance', __METHOD__ . ": " .
+ "{$this->connsOpened}+ connections made (master=$masterName)\n" .
+ wfBacktrace( true ) );
+ }
+
+ # Create object
+ try {
+ $db = DatabaseBase::factory( $server['type'], $server );
+ } catch ( DBConnectionError $e ) {
+ // FIXME: This is probably the ugliest thing I have ever done to
+ // PHP. I'm half-expecting it to segfault, just out of disgust. -- TS
+ $db = $e->db;
+ }
+
+ $db->setLBInfo( $server );
+ $db->setLazyMasterHandle(
+ $this->getLazyConnectionRef( DB_MASTER, [], $db->getWikiID() )
+ );
+ $db->setTransactionProfiler( $this->trxProfiler );
+
+ return $db;
+ }
+
+ /**
+ * @throws DBConnectionError
+ * @return bool
+ */
+ private function reportConnectionError() {
+ $conn = $this->mErrorConnection; // The connection which caused the error
+ $context = [
+ 'method' => __METHOD__,
+ 'last_error' => $this->mLastError,
+ ];
+
+ if ( !is_object( $conn ) ) {
+ // No last connection, probably due to all servers being too busy
+ wfLogDBError(
+ "LB failure with no last connection. Connection error: {last_error}",
+ $context
+ );
+
+ // If all servers were busy, mLastError will contain something sensible
+ throw new DBConnectionError( null, $this->mLastError );
+ } else {
+ $context['db_server'] = $conn->getProperty( 'mServer' );
+ wfLogDBError(
+ "Connection error: {last_error} ({db_server})",
+ $context
+ );
+
+ // throws DBConnectionError
+ $conn->reportConnectionError( "{$this->mLastError} ({$context['db_server']})" );
+ }
+
+ return false; /* not reached */
+ }
+
+ /**
+ * @return int
+ * @since 1.26
+ */
+ public function getWriterIndex() {
+ return 0;
+ }
+
+ /**
+ * Returns true if the specified index is a valid server index
+ *
+ * @param string $i
+ * @return bool
+ */
+ public function haveIndex( $i ) {
+ return array_key_exists( $i, $this->mServers );
+ }
+
+ /**
+ * Returns true if the specified index is valid and has non-zero load
+ *
+ * @param string $i
+ * @return bool
+ */
+ public function isNonZeroLoad( $i ) {
+ return array_key_exists( $i, $this->mServers ) && $this->mLoads[$i] != 0;
+ }
+
+ /**
+ * Get the number of defined servers (not the number of open connections)
+ *
+ * @return int
+ */
+ public function getServerCount() {
+ return count( $this->mServers );
+ }
+
+ /**
+ * Get the host name or IP address of the server with the specified index
+ * Prefer a readable name if available.
+ * @param string $i
+ * @return string
+ */
+ public function getServerName( $i ) {
+ if ( isset( $this->mServers[$i]['hostName'] ) ) {
+ $name = $this->mServers[$i]['hostName'];
+ } elseif ( isset( $this->mServers[$i]['host'] ) ) {
+ $name = $this->mServers[$i]['host'];
+ } else {
+ $name = '';
+ }
+
+ return ( $name != '' ) ? $name : 'localhost';
+ }
+
+ /**
+ * Return the server info structure for a given index, or false if the index is invalid.
+ * @param int $i
+ * @return array|bool
+ */
+ public function getServerInfo( $i ) {
+ if ( isset( $this->mServers[$i] ) ) {
+ return $this->mServers[$i];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Sets the server info structure for the given index. Entry at index $i
+ * is created if it doesn't exist
+ * @param int $i
+ * @param array $serverInfo
+ */
+ public function setServerInfo( $i, array $serverInfo ) {
+ $this->mServers[$i] = $serverInfo;
+ }
+
+ /**
+ * Get the current master position for chronology control purposes
+ * @return mixed
+ */
+ public function getMasterPos() {
+ # If this entire request was served from a slave without opening a connection to the
+ # master (however unlikely that may be), then we can fetch the position from the slave.
+ $masterConn = $this->getAnyOpenConnection( 0 );
+ if ( !$masterConn ) {
+ $serverCount = count( $this->mServers );
+ for ( $i = 1; $i < $serverCount; $i++ ) {
+ $conn = $this->getAnyOpenConnection( $i );
+ if ( $conn ) {
+ return $conn->getSlavePos();
+ }
+ }
+ } else {
+ return $masterConn->getMasterPos();
+ }
+
+ return false;
+ }
+
+ /**
+ * Close all open connections
+ */
+ public function closeAll() {
+ foreach ( $this->mConns as $conns2 ) {
+ foreach ( $conns2 as $conns3 ) {
+ /** @var DatabaseBase $conn */
+ foreach ( $conns3 as $conn ) {
+ $conn->close();
+ }
+ }
+ }
+ $this->mConns = [
+ 'local' => [],
+ 'foreignFree' => [],
+ 'foreignUsed' => [],
+ ];
+ $this->connsOpened = 0;
+ }
+
+ /**
+ * Close a connection
+ * Using this function makes sure the LoadBalancer knows the connection is closed.
+ * If you use $conn->close() directly, the load balancer won't update its state.
+ * @param DatabaseBase $conn
+ */
+ public function closeConnection( $conn ) {
+ $done = false;
+ foreach ( $this->mConns as $i1 => $conns2 ) {
+ foreach ( $conns2 as $i2 => $conns3 ) {
+ foreach ( $conns3 as $i3 => $candidateConn ) {
+ if ( $conn === $candidateConn ) {
+ $conn->close();
+ unset( $this->mConns[$i1][$i2][$i3] );
+ --$this->connsOpened;
+ $done = true;
+ break;
+ }
+ }
+ }
+ }
+ if ( !$done ) {
+ $conn->close();
+ }
+ }
+
+ /**
+ * Commit transactions on all open connections
+ * @param string $fname Caller name
+ */
+ public function commitAll( $fname = __METHOD__ ) {
+ foreach ( $this->mConns as $conns2 ) {
+ foreach ( $conns2 as $conns3 ) {
+ /** @var DatabaseBase[] $conns3 */
+ foreach ( $conns3 as $conn ) {
+ if ( $conn->trxLevel() ) {
+ $conn->commit( $fname, 'flush' );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Issue COMMIT only on master, only if queries were done on connection
+ * @param string $fname Caller name
+ */
+ public function commitMasterChanges( $fname = __METHOD__ ) {
+ $masterIndex = $this->getWriterIndex();
+ foreach ( $this->mConns as $conns2 ) {
+ if ( empty( $conns2[$masterIndex] ) ) {
+ continue;
+ }
+ /** @var DatabaseBase $conn */
+ foreach ( $conns2[$masterIndex] as $conn ) {
+ if ( $conn->trxLevel() && $conn->writesOrCallbacksPending() ) {
+ $conn->commit( $fname, 'flush' );
+ }
+ }
+ }
+ }
+
+ /**
+ * Issue ROLLBACK only on master, only if queries were done on connection
+ * @param string $fname Caller name
+ * @throws DBExpectedError
+ * @since 1.23
+ */
+ public function rollbackMasterChanges( $fname = __METHOD__ ) {
+ $failedServers = [];
+
+ $masterIndex = $this->getWriterIndex();
+ foreach ( $this->mConns as $conns2 ) {
+ if ( empty( $conns2[$masterIndex] ) ) {
+ continue;
+ }
+ /** @var DatabaseBase $conn */
+ foreach ( $conns2[$masterIndex] as $conn ) {
+ if ( $conn->trxLevel() && $conn->writesOrCallbacksPending() ) {
+ try {
+ $conn->rollback( $fname, 'flush' );
+ } catch ( DBError $e ) {
+ MWExceptionHandler::logException( $e );
+ $failedServers[] = $conn->getServer();
+ }
+ }
+ }
+ }
+
+ if ( $failedServers ) {
+ throw new DBExpectedError( null, "Rollback failed on server(s) " .
+ implode( ', ', array_unique( $failedServers ) ) );
+ }
+ }
+
+ /**
+ * @return bool Whether a master connection is already open
+ * @since 1.24
+ */
+ public function hasMasterConnection() {
+ return $this->isOpen( $this->getWriterIndex() );
+ }
+
+ /**
+ * Determine if there are pending changes in a transaction by this thread
+ * @since 1.23
+ * @return bool
+ */
+ public function hasMasterChanges() {
+ $masterIndex = $this->getWriterIndex();
+ foreach ( $this->mConns as $conns2 ) {
+ if ( empty( $conns2[$masterIndex] ) ) {
+ continue;
+ }
+ /** @var DatabaseBase $conn */
+ foreach ( $conns2[$masterIndex] as $conn ) {
+ if ( $conn->trxLevel() && $conn->writesOrCallbacksPending() ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get the timestamp of the latest write query done by this thread
+ * @since 1.25
+ * @return float|bool UNIX timestamp or false
+ */
+ public function lastMasterChangeTimestamp() {
+ $lastTime = false;
+ $masterIndex = $this->getWriterIndex();
+ foreach ( $this->mConns as $conns2 ) {
+ if ( empty( $conns2[$masterIndex] ) ) {
+ continue;
+ }
+ /** @var DatabaseBase $conn */
+ foreach ( $conns2[$masterIndex] as $conn ) {
+ $lastTime = max( $lastTime, $conn->lastDoneWrites() );
+ }
+ }
+ return $lastTime;
+ }
+
+ /**
+ * Check if this load balancer object had any recent or still
+ * pending writes issued against it by this PHP thread
+ *
+ * @param float $age How many seconds ago is "recent" [defaults to mWaitTimeout]
+ * @return bool
+ * @since 1.25
+ */
+ public function hasOrMadeRecentMasterChanges( $age = null ) {
+ $age = ( $age === null ) ? $this->mWaitTimeout : $age;
+
+ return ( $this->hasMasterChanges()
+ || $this->lastMasterChangeTimestamp() > microtime( true ) - $age );
+ }
+
+ /**
+ * Get the list of callers that have pending master changes
+ *
+ * @return array
+ * @since 1.27
+ */
+ public function pendingMasterChangeCallers() {
+ $fnames = [];
+
+ $masterIndex = $this->getWriterIndex();
+ foreach ( $this->mConns as $conns2 ) {
+ if ( empty( $conns2[$masterIndex] ) ) {
+ continue;
+ }
+ /** @var DatabaseBase $conn */
+ foreach ( $conns2[$masterIndex] as $conn ) {
+ $fnames = array_merge( $fnames, $conn->pendingWriteCallers() );
+ }
+ }
+
+ return $fnames;
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ public function waitTimeout( $value = null ) {
+ return wfSetVar( $this->mWaitTimeout, $value );
+ }
+
+ /**
+ * @note This method will trigger a DB connection if not yet done
+ *
+ * @param string|bool $wiki Wiki ID, or false for the current wiki
+ * @return bool Whether the generic connection for reads is highly "lagged"
+ */
+ public function getLaggedSlaveMode( $wiki = false ) {
+ // No-op if there is only one DB (also avoids recursion)
+ if ( !$this->laggedSlaveMode && $this->getServerCount() > 1 ) {
+ try {
+ // See if laggedSlaveMode gets set
+ $conn = $this->getConnection( DB_SLAVE, false, $wiki );
+ $this->reuseConnection( $conn );
+ } catch ( DBConnectionError $e ) {
+ // Avoid expensive re-connect attempts and failures
+ $this->slavesDownMode = true;
+ $this->laggedSlaveMode = true;
+ }
+ }
+
+ return $this->laggedSlaveMode;
+ }
+
+ /**
+ * @note This method will never cause a new DB connection
+ * @return bool Whether any generic connection used for reads was highly "lagged"
+ * @since 1.27
+ */
+ public function laggedSlaveUsed() {
+ return $this->laggedSlaveMode;
+ }
+
+ /**
+ * @note This method may trigger a DB connection if not yet done
+ * @param string|bool $wiki Wiki ID, or false for the current wiki
+ * @return string|bool Reason the master is read-only or false if it is not
+ * @since 1.27
+ */
+ public function getReadOnlyReason( $wiki = false ) {
+ if ( $this->readOnlyReason !== false ) {
+ return $this->readOnlyReason;
+ } elseif ( $this->getLaggedSlaveMode( $wiki ) ) {
+ if ( $this->slavesDownMode ) {
+ return 'The database has been automatically locked ' .
+ 'until the slave database servers become available';
+ } else {
+ return 'The database has been automatically locked ' .
+ 'while the slave database servers catch up to the master.';
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Disables/enables lag checks
+ * @param null|bool $mode
+ * @return bool
+ */
+ public function allowLagged( $mode = null ) {
+ if ( $mode === null ) {
+ return $this->mAllowLagged;
+ }
+ $this->mAllowLagged = $mode;
+
+ return $this->mAllowLagged;
+ }
+
+ /**
+ * @return bool
+ */
+ public function pingAll() {
+ $success = true;
+ foreach ( $this->mConns as $conns2 ) {
+ foreach ( $conns2 as $conns3 ) {
+ /** @var DatabaseBase[] $conns3 */
+ foreach ( $conns3 as $conn ) {
+ if ( !$conn->ping() ) {
+ $success = false;
+ }
+ }
+ }
+ }
+
+ return $success;
+ }
+
+ /**
+ * Call a function with each open connection object
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachOpenConnection( $callback, array $params = [] ) {
+ foreach ( $this->mConns as $conns2 ) {
+ foreach ( $conns2 as $conns3 ) {
+ foreach ( $conns3 as $conn ) {
+ $mergedParams = array_merge( [ $conn ], $params );
+ call_user_func_array( $callback, $mergedParams );
+ }
+ }
+ }
+ }
+
+ /**
+ * Get the hostname and lag time of the most-lagged slave
+ *
+ * This is useful for maintenance scripts that need to throttle their updates.
+ * May attempt to open connections to slaves on the default DB. If there is
+ * no lag, the maximum lag will be reported as -1.
+ *
+ * @param bool|string $wiki Wiki ID, or false for the default database
+ * @return array ( host, max lag, index of max lagged host )
+ */
+ public function getMaxLag( $wiki = false ) {
+ $maxLag = -1;
+ $host = '';
+ $maxIndex = 0;
+
+ if ( $this->getServerCount() <= 1 ) {
+ return [ $host, $maxLag, $maxIndex ]; // no replication = no lag
+ }
+
+ $lagTimes = $this->getLagTimes( $wiki );
+ foreach ( $lagTimes as $i => $lag ) {
+ if ( $lag > $maxLag ) {
+ $maxLag = $lag;
+ $host = $this->mServers[$i]['host'];
+ $maxIndex = $i;
+ }
+ }
+
+ return [ $host, $maxLag, $maxIndex ];
+ }
+
+ /**
+ * Get an estimate of replication lag (in seconds) for each server
+ *
+ * Results are cached for a short time in memcached/process cache
+ *
+ * Values may be "false" if replication is too broken to estimate
+ *
+ * @param string|bool $wiki
+ * @return int[] Map of (server index => float|int|bool)
+ */
+ public function getLagTimes( $wiki = false ) {
+ if ( $this->getServerCount() <= 1 ) {
+ return [ 0 => 0 ]; // no replication = no lag
+ }
+
+ # Send the request to the load monitor
+ return $this->getLoadMonitor()->getLagTimes( array_keys( $this->mServers ), $wiki );
+ }
+
+ /**
+ * Get the lag in seconds for a given connection, or zero if this load
+ * balancer does not have replication enabled.
+ *
+ * This should be used in preference to Database::getLag() in cases where
+ * replication may not be in use, since there is no way to determine if
+ * replication is in use at the connection level without running
+ * potentially restricted queries such as SHOW SLAVE STATUS. Using this
+ * function instead of Database::getLag() avoids a fatal error in this
+ * case on many installations.
+ *
+ * @param IDatabase $conn
+ * @return int|bool Returns false on error
+ */
+ public function safeGetLag( IDatabase $conn ) {
+ if ( $this->getServerCount() == 1 ) {
+ return 0;
+ } else {
+ return $conn->getLag();
+ }
+ }
+
+ /**
+ * Wait for a slave DB to reach a specified master position
+ *
+ * This will connect to the master to get an accurate position if $pos is not given
+ *
+ * @param IDatabase $conn Slave DB
+ * @param DBMasterPos|bool $pos Master position; default: current position
+ * @param integer $timeout Timeout in seconds
+ * @return bool Success
+ * @since 1.27
+ */
+ public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ) {
+ if ( $this->getServerCount() == 1 || !$conn->getLBInfo( 'slave' ) ) {
+ return true; // server is not a slave DB
+ }
+
+ $pos = $pos ?: $this->getConnection( DB_MASTER )->getMasterPos();
+ if ( !$pos ) {
+ return false; // something is misconfigured
+ }
+
+ $result = $conn->masterPosWait( $pos, $timeout );
+ if ( $result == -1 || is_null( $result ) ) {
+ $msg = __METHOD__ . ": Timed out waiting on {$conn->getServer()} pos {$pos}";
+ wfDebugLog( 'replication', "$msg\n" );
+ wfDebugLog( 'DBPerformance', "$msg:\n" . wfBacktrace( true ) );
+ $ok = false;
+ } else {
+ wfDebugLog( 'replication', __METHOD__ . ": Done\n" );
+ $ok = true;
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Clear the cache for slag lag delay times
+ *
+ * This is only used for testing
+ */
+ public function clearLagTimeCache() {
+ $this->getLoadMonitor()->clearCaches();
+ }
+}
diff --git a/www/wiki/includes/db/loadbalancer/LoadMonitor.php b/www/wiki/includes/db/loadbalancer/LoadMonitor.php
new file mode 100644
index 00000000..e68cf1a5
--- /dev/null
+++ b/www/wiki/includes/db/loadbalancer/LoadMonitor.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Database load monitoring.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * An interface for database load monitoring
+ *
+ * @ingroup Database
+ */
+interface LoadMonitor {
+ /**
+ * Construct a new LoadMonitor with a given LoadBalancer parent
+ *
+ * @param LoadBalancer $parent
+ */
+ public function __construct( $parent );
+
+ /**
+ * Perform pre-connection load ratio adjustment.
+ * @param array &$loads
+ * @param string|bool $group The selected query group. Default: false
+ * @param string|bool $wiki Default: false
+ */
+ public function scaleLoads( &$loads, $group = false, $wiki = false );
+
+ /**
+ * Get an estimate of replication lag (in seconds) for each server
+ *
+ * Values may be "false" if replication is too broken to estimate
+ *
+ * @param array $serverIndexes
+ * @param string $wiki
+ *
+ * @return array Map of (server index => float|int|bool)
+ */
+ public function getLagTimes( $serverIndexes, $wiki );
+
+ /**
+ * Clear any process and persistent cache of lag times
+ * @since 1.27
+ */
+ public function clearCaches();
+}
+
+class LoadMonitorNull implements LoadMonitor {
+ public function __construct( $parent ) {
+ }
+
+ public function scaleLoads( &$loads, $group = false, $wiki = false ) {
+ }
+
+ public function getLagTimes( $serverIndexes, $wiki ) {
+ return array_fill_keys( $serverIndexes, 0 );
+ }
+
+ public function clearCaches() {
+
+ }
+}
diff --git a/www/wiki/includes/db/loadbalancer/LoadMonitorMySQL.php b/www/wiki/includes/db/loadbalancer/LoadMonitorMySQL.php
new file mode 100644
index 00000000..444c4b4e
--- /dev/null
+++ b/www/wiki/includes/db/loadbalancer/LoadMonitorMySQL.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+/**
+ * Basic MySQL load monitor with no external dependencies
+ * Uses memcached to cache the replication lag for a short time
+ *
+ * @ingroup Database
+ */
+class LoadMonitorMySQL implements LoadMonitor {
+ /** @var LoadBalancer */
+ public $parent;
+ /** @var BagOStuff */
+ protected $srvCache;
+ /** @var BagOStuff */
+ protected $mainCache;
+
+ public function __construct( $parent ) {
+ $this->parent = $parent;
+
+ $this->srvCache = ObjectCache::getLocalServerInstance( 'hash' );
+ $this->mainCache = ObjectCache::getLocalClusterInstance();
+ }
+
+ public function scaleLoads( &$loads, $group = false, $wiki = false ) {
+ }
+
+ public function getLagTimes( $serverIndexes, $wiki ) {
+ if ( count( $serverIndexes ) == 1 && reset( $serverIndexes ) == 0 ) {
+ # Single server only, just return zero without caching
+ return [ 0 => 0 ];
+ }
+
+ $key = $this->getLagTimeCacheKey();
+ # Randomize TTLs to reduce stampedes (4.0 - 5.0 sec)
+ $ttl = mt_rand( 4e6, 5e6 ) / 1e6;
+ # Keep keys around longer as fallbacks
+ $staleTTL = 60;
+
+ # (a) Check the local APC cache
+ $value = $this->srvCache->get( $key );
+ if ( $value && $value['timestamp'] > ( microtime( true ) - $ttl ) ) {
+ wfDebugLog( 'replication', __METHOD__ . ": got lag times ($key) from local cache" );
+ return $value['lagTimes']; // cache hit
+ }
+ $staleValue = $value ?: false;
+
+ # (b) Check the shared cache and backfill APC
+ $value = $this->mainCache->get( $key );
+ if ( $value && $value['timestamp'] > ( microtime( true ) - $ttl ) ) {
+ $this->srvCache->set( $key, $value, $staleTTL );
+ wfDebugLog( 'replication', __METHOD__ . ": got lag times ($key) from main cache" );
+
+ return $value['lagTimes']; // cache hit
+ }
+ $staleValue = $value ?: $staleValue;
+
+ # (c) Cache key missing or expired; regenerate and backfill
+ if ( $this->mainCache->lock( $key, 0, 10 ) ) {
+ # Let this process alone update the cache value
+ $cache = $this->mainCache;
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $unlocker = new ScopedCallback( function () use ( $cache, $key ) {
+ $cache->unlock( $key );
+ } );
+ } elseif ( $staleValue ) {
+ # Could not acquire lock but an old cache exists, so use it
+ return $staleValue['lagTimes'];
+ }
+
+ $lagTimes = [];
+ foreach ( $serverIndexes as $i ) {
+ if ( $i == $this->parent->getWriterIndex() ) {
+ $lagTimes[$i] = 0; // master always has no lag
+ continue;
+ }
+
+ $conn = $this->parent->getAnyOpenConnection( $i );
+ if ( $conn ) {
+ $close = false; // already open
+ } else {
+ $conn = $this->parent->openConnection( $i, $wiki );
+ $close = true; // new connection
+ }
+
+ if ( !$conn ) {
+ $lagTimes[$i] = false;
+ $host = $this->parent->getServerName( $i );
+ wfDebugLog( 'replication', __METHOD__ . ": host $host (#$i) is unreachable" );
+ continue;
+ }
+
+ $lagTimes[$i] = $conn->getLag();
+ if ( $lagTimes[$i] === false ) {
+ $host = $this->parent->getServerName( $i );
+ wfDebugLog( 'replication', __METHOD__ . ": host $host (#$i) is not replicating?" );
+ }
+
+ if ( $close ) {
+ # Close the connection to avoid sleeper connections piling up.
+ # Note that the caller will pick one of these DBs and reconnect,
+ # which is slightly inefficient, but this only matters for the lag
+ # time cache miss cache, which is far less common that cache hits.
+ $this->parent->closeConnection( $conn );
+ }
+ }
+
+ # Add a timestamp key so we know when it was cached
+ $value = [ 'lagTimes' => $lagTimes, 'timestamp' => microtime( true ) ];
+ $this->mainCache->set( $key, $value, $staleTTL );
+ $this->srvCache->set( $key, $value, $staleTTL );
+ wfDebugLog( 'replication', __METHOD__ . ": re-calculated lag times ($key)" );
+
+ return $value['lagTimes'];
+ }
+
+ public function clearCaches() {
+ $key = $this->getLagTimeCacheKey();
+ $this->srvCache->delete( $key );
+ $this->mainCache->delete( $key );
+ }
+
+ private function getLagTimeCacheKey() {
+ $writerIndex = $this->parent->getWriterIndex();
+ // Lag is per-server, not per-DB, so key on the master DB name
+ return $this->srvCache->makeGlobalKey(
+ 'lag-times', $this->parent->getServerName( $writerIndex )
+ );
+ }
+}
diff --git a/www/wiki/includes/debug/MWDebug.php b/www/wiki/includes/debug/MWDebug.php
new file mode 100644
index 00000000..012837fd
--- /dev/null
+++ b/www/wiki/includes/debug/MWDebug.php
@@ -0,0 +1,558 @@
+<?php
+/**
+ * Debug toolbar related code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LegacyLogger;
+
+/**
+ * New debugger system that outputs a toolbar on page view.
+ *
+ * By default, most methods do nothing ( self::$enabled = false ). You have
+ * to explicitly call MWDebug::init() to enabled them.
+ *
+ * @since 1.19
+ */
+class MWDebug {
+ /**
+ * Log lines
+ *
+ * @var array $log
+ */
+ protected static $log = [];
+
+ /**
+ * Debug messages from wfDebug().
+ *
+ * @var array $debug
+ */
+ protected static $debug = [];
+
+ /**
+ * SQL statements of the database queries.
+ *
+ * @var array $query
+ */
+ protected static $query = [];
+
+ /**
+ * Is the debugger enabled?
+ *
+ * @var bool $enabled
+ */
+ protected static $enabled = false;
+
+ /**
+ * Array of functions that have already been warned, formatted
+ * function-caller to prevent a buttload of warnings
+ *
+ * @var array $deprecationWarnings
+ */
+ protected static $deprecationWarnings = [];
+
+ /**
+ * Enabled the debugger and load resource module.
+ * This is called by Setup.php when $wgDebugToolbar is true.
+ *
+ * @since 1.19
+ */
+ public static function init() {
+ self::$enabled = true;
+ }
+
+ /**
+ * Disable the debugger.
+ *
+ * @since 1.28
+ */
+ public static function deinit() {
+ self::$enabled = false;
+ }
+
+ /**
+ * Add ResourceLoader modules to the OutputPage object if debugging is
+ * enabled.
+ *
+ * @since 1.19
+ * @param OutputPage $out
+ */
+ public static function addModules( OutputPage $out ) {
+ if ( self::$enabled ) {
+ $out->addModules( 'mediawiki.debug' );
+ }
+ }
+
+ /**
+ * Adds a line to the log
+ *
+ * @since 1.19
+ * @param mixed $str
+ */
+ public static function log( $str ) {
+ if ( !self::$enabled ) {
+ return;
+ }
+ if ( !is_string( $str ) ) {
+ $str = print_r( $str, true );
+ }
+ self::$log[] = [
+ 'msg' => htmlspecialchars( $str ),
+ 'type' => 'log',
+ 'caller' => wfGetCaller(),
+ ];
+ }
+
+ /**
+ * Returns internal log array
+ * @since 1.19
+ * @return array
+ */
+ public static function getLog() {
+ return self::$log;
+ }
+
+ /**
+ * Clears internal log array and deprecation tracking
+ * @since 1.19
+ */
+ public static function clearLog() {
+ self::$log = [];
+ self::$deprecationWarnings = [];
+ }
+
+ /**
+ * Adds a warning entry to the log
+ *
+ * @since 1.19
+ * @param string $msg
+ * @param int $callerOffset
+ * @param int $level A PHP error level. See sendMessage()
+ * @param string $log 'production' will always trigger a php error, 'auto'
+ * will trigger an error if $wgDevelopmentWarnings is true, and 'debug'
+ * will only write to the debug log(s).
+ */
+ public static function warning( $msg, $callerOffset = 1, $level = E_USER_NOTICE, $log = 'auto' ) {
+ global $wgDevelopmentWarnings;
+
+ if ( $log === 'auto' && !$wgDevelopmentWarnings ) {
+ $log = 'debug';
+ }
+
+ if ( $log === 'debug' ) {
+ $level = false;
+ }
+
+ $callerDescription = self::getCallerDescription( $callerOffset );
+
+ self::sendMessage( $msg, $callerDescription, 'warning', $level );
+
+ if ( self::$enabled ) {
+ self::$log[] = [
+ 'msg' => htmlspecialchars( $msg ),
+ 'type' => 'warn',
+ 'caller' => $callerDescription['func'],
+ ];
+ }
+ }
+
+ /**
+ * Show a warning that $function is deprecated.
+ * This will send it to the following locations:
+ * - Debug toolbar, with one item per function and caller, if $wgDebugToolbar
+ * is set to true.
+ * - PHP's error log, with level E_USER_DEPRECATED, if $wgDevelopmentWarnings
+ * is set to true.
+ * - MediaWiki's debug log, if $wgDevelopmentWarnings is set to false.
+ *
+ * @since 1.19
+ * @param string $function Function that is deprecated.
+ * @param string|bool $version Version in which the function was deprecated.
+ * @param string|bool $component Component to which the function belongs.
+ * If false, it is assumbed the function is in MediaWiki core.
+ * @param int $callerOffset How far up the callstack is the original
+ * caller. 2 = function that called the function that called
+ * MWDebug::deprecated() (Added in 1.20).
+ */
+ public static function deprecated( $function, $version = false,
+ $component = false, $callerOffset = 2
+ ) {
+ $callerDescription = self::getCallerDescription( $callerOffset );
+ $callerFunc = $callerDescription['func'];
+
+ $sendToLog = true;
+
+ // Check to see if there already was a warning about this function
+ if ( isset( self::$deprecationWarnings[$function][$callerFunc] ) ) {
+ return;
+ } elseif ( isset( self::$deprecationWarnings[$function] ) ) {
+ if ( self::$enabled ) {
+ $sendToLog = false;
+ } else {
+ return;
+ }
+ }
+
+ self::$deprecationWarnings[$function][$callerFunc] = true;
+
+ if ( $version ) {
+ global $wgDeprecationReleaseLimit;
+ if ( $wgDeprecationReleaseLimit && $component === false ) {
+ # Strip -* off the end of $version so that branches can use the
+ # format #.##-branchname to avoid issues if the branch is merged into
+ # a version of MediaWiki later than what it was branched from
+ $comparableVersion = preg_replace( '/-.*$/', '', $version );
+
+ # If the comparableVersion is larger than our release limit then
+ # skip the warning message for the deprecation
+ if ( version_compare( $wgDeprecationReleaseLimit, $comparableVersion, '<' ) ) {
+ $sendToLog = false;
+ }
+ }
+
+ $component = $component === false ? 'MediaWiki' : $component;
+ $msg = "Use of $function was deprecated in $component $version.";
+ } else {
+ $msg = "Use of $function is deprecated.";
+ }
+
+ if ( $sendToLog ) {
+ global $wgDevelopmentWarnings; // we could have a more specific $wgDeprecationWarnings setting.
+ self::sendMessage(
+ $msg,
+ $callerDescription,
+ 'deprecated',
+ $wgDevelopmentWarnings ? E_USER_DEPRECATED : false
+ );
+ }
+
+ if ( self::$enabled ) {
+ $logMsg = htmlspecialchars( $msg ) .
+ Html::rawElement( 'div', [ 'class' => 'mw-debug-backtrace' ],
+ Html::element( 'span', [], 'Backtrace:' ) . wfBacktrace()
+ );
+
+ self::$log[] = [
+ 'msg' => $logMsg,
+ 'type' => 'deprecated',
+ 'caller' => $callerFunc,
+ ];
+ }
+ }
+
+ /**
+ * Get an array describing the calling function at a specified offset.
+ *
+ * @param int $callerOffset How far up the callstack is the original
+ * caller. 0 = function that called getCallerDescription()
+ * @return array Array with two keys: 'file' and 'func'
+ */
+ private static function getCallerDescription( $callerOffset ) {
+ $callers = wfDebugBacktrace();
+
+ if ( isset( $callers[$callerOffset] ) ) {
+ $callerfile = $callers[$callerOffset];
+ if ( isset( $callerfile['file'] ) && isset( $callerfile['line'] ) ) {
+ $file = $callerfile['file'] . ' at line ' . $callerfile['line'];
+ } else {
+ $file = '(internal function)';
+ }
+ } else {
+ $file = '(unknown location)';
+ }
+
+ if ( isset( $callers[$callerOffset + 1] ) ) {
+ $callerfunc = $callers[$callerOffset + 1];
+ $func = '';
+ if ( isset( $callerfunc['class'] ) ) {
+ $func .= $callerfunc['class'] . '::';
+ }
+ if ( isset( $callerfunc['function'] ) ) {
+ $func .= $callerfunc['function'];
+ }
+ } else {
+ $func = 'unknown';
+ }
+
+ return [ 'file' => $file, 'func' => $func ];
+ }
+
+ /**
+ * Send a message to the debug log and optionally also trigger a PHP
+ * error, depending on the $level argument.
+ *
+ * @param string $msg Message to send
+ * @param array $caller Caller description get from getCallerDescription()
+ * @param string $group Log group on which to send the message
+ * @param int|bool $level Error level to use; set to false to not trigger an error
+ */
+ private static function sendMessage( $msg, $caller, $group, $level ) {
+ $msg .= ' [Called from ' . $caller['func'] . ' in ' . $caller['file'] . ']';
+
+ if ( $level !== false ) {
+ trigger_error( $msg, $level );
+ }
+
+ wfDebugLog( $group, $msg, 'private' );
+ }
+
+ /**
+ * This is a method to pass messages from wfDebug to the pretty debugger.
+ * Do NOT use this method, use MWDebug::log or wfDebug()
+ *
+ * @since 1.19
+ * @param string $str
+ * @param array $context
+ */
+ public static function debugMsg( $str, $context = [] ) {
+ global $wgDebugComments, $wgShowDebug;
+
+ if ( self::$enabled || $wgDebugComments || $wgShowDebug ) {
+ if ( $context ) {
+ $prefix = '';
+ if ( isset( $context['prefix'] ) ) {
+ $prefix = $context['prefix'];
+ } elseif ( isset( $context['channel'] ) && $context['channel'] !== 'wfDebug' ) {
+ $prefix = "[{$context['channel']}] ";
+ }
+ if ( isset( $context['seconds_elapsed'] ) && isset( $context['memory_used'] ) ) {
+ $prefix .= "{$context['seconds_elapsed']} {$context['memory_used']} ";
+ }
+ $str = LegacyLogger::interpolate( $str, $context );
+ $str = $prefix . $str;
+ }
+ self::$debug[] = rtrim( UtfNormal\Validator::cleanUp( $str ) );
+ }
+ }
+
+ /**
+ * Begins profiling on a database query
+ *
+ * @since 1.19
+ * @param string $sql
+ * @param string $function
+ * @param bool $isMaster
+ * @param float $runTime Query run time
+ * @return int ID number of the query to pass to queryTime or -1 if the
+ * debugger is disabled
+ */
+ public static function query( $sql, $function, $isMaster, $runTime ) {
+ if ( !self::$enabled ) {
+ return -1;
+ }
+
+ // Replace invalid UTF-8 chars with a square UTF-8 character
+ // This prevents json_encode from erroring out due to binary SQL data
+ $sql = preg_replace(
+ '/(
+ [\xC0-\xC1] # Invalid UTF-8 Bytes
+ | [\xF5-\xFF] # Invalid UTF-8 Bytes
+ | \xE0[\x80-\x9F] # Overlong encoding of prior code point
+ | \xF0[\x80-\x8F] # Overlong encoding of prior code point
+ | [\xC2-\xDF](?![\x80-\xBF]) # Invalid UTF-8 Sequence Start
+ | [\xE0-\xEF](?![\x80-\xBF]{2}) # Invalid UTF-8 Sequence Start
+ | [\xF0-\xF4](?![\x80-\xBF]{3}) # Invalid UTF-8 Sequence Start
+ | (?<=[\x0-\x7F\xF5-\xFF])[\x80-\xBF] # Invalid UTF-8 Sequence Middle
+ | (?<![\xC2-\xDF]|[\xE0-\xEF]|[\xE0-\xEF][\x80-\xBF]|[\xF0-\xF4]
+ | [\xF0-\xF4][\x80-\xBF]|[\xF0-\xF4][\x80-\xBF]{2})[\x80-\xBF] # Overlong Sequence
+ | (?<=[\xE0-\xEF])[\x80-\xBF](?![\x80-\xBF]) # Short 3 byte sequence
+ | (?<=[\xF0-\xF4])[\x80-\xBF](?![\x80-\xBF]{2}) # Short 4 byte sequence
+ | (?<=[\xF0-\xF4][\x80-\xBF])[\x80-\xBF](?![\x80-\xBF]) # Short 4 byte sequence (2)
+ )/x',
+ '■',
+ $sql
+ );
+
+ // last check for invalid utf8
+ $sql = UtfNormal\Validator::cleanUp( $sql );
+
+ self::$query[] = [
+ 'sql' => $sql,
+ 'function' => $function,
+ 'master' => (bool)$isMaster,
+ 'time' => $runTime,
+ ];
+
+ return count( self::$query ) - 1;
+ }
+
+ /**
+ * Returns a list of files included, along with their size
+ *
+ * @param IContextSource $context
+ * @return array
+ */
+ protected static function getFilesIncluded( IContextSource $context ) {
+ $files = get_included_files();
+ $fileList = [];
+ foreach ( $files as $file ) {
+ $size = filesize( $file );
+ $fileList[] = [
+ 'name' => $file,
+ 'size' => $context->getLanguage()->formatSize( $size ),
+ ];
+ }
+
+ return $fileList;
+ }
+
+ /**
+ * Returns the HTML to add to the page for the toolbar
+ *
+ * @since 1.19
+ * @param IContextSource $context
+ * @return string
+ */
+ public static function getDebugHTML( IContextSource $context ) {
+ global $wgDebugComments;
+
+ $html = '';
+
+ if ( self::$enabled ) {
+ self::log( 'MWDebug output complete' );
+ $debugInfo = self::getDebugInfo( $context );
+
+ // Cannot use OutputPage::addJsConfigVars because those are already outputted
+ // by the time this method is called.
+ $html = ResourceLoader::makeInlineScript(
+ ResourceLoader::makeConfigSetScript( [ 'debugInfo' => $debugInfo ] )
+ );
+ }
+
+ if ( $wgDebugComments ) {
+ $html .= "<!-- Debug output:\n" .
+ htmlspecialchars( implode( "\n", self::$debug ), ENT_NOQUOTES ) .
+ "\n\n-->";
+ }
+
+ return $html;
+ }
+
+ /**
+ * Generate debug log in HTML for displaying at the bottom of the main
+ * content area.
+ * If $wgShowDebug is false, an empty string is always returned.
+ *
+ * @since 1.20
+ * @return string HTML fragment
+ */
+ public static function getHTMLDebugLog() {
+ global $wgShowDebug;
+
+ if ( !$wgShowDebug ) {
+ return '';
+ }
+
+ $ret = "\n<hr />\n<strong>Debug data:</strong><ul id=\"mw-debug-html\">\n";
+
+ foreach ( self::$debug as $line ) {
+ $display = nl2br( htmlspecialchars( trim( $line ) ) );
+
+ $ret .= "<li><code>$display</code></li>\n";
+ }
+
+ $ret .= '</ul>' . "\n";
+
+ return $ret;
+ }
+
+ /**
+ * Append the debug info to given ApiResult
+ *
+ * @param IContextSource $context
+ * @param ApiResult $result
+ */
+ public static function appendDebugInfoToApiResult( IContextSource $context, ApiResult $result ) {
+ if ( !self::$enabled ) {
+ return;
+ }
+
+ // output errors as debug info, when display_errors is on
+ // this is necessary for all non html output of the api, because that clears all errors first
+ $obContents = ob_get_contents();
+ if ( $obContents ) {
+ $obContentArray = explode( '<br />', $obContents );
+ foreach ( $obContentArray as $obContent ) {
+ if ( trim( $obContent ) ) {
+ self::debugMsg( Sanitizer::stripAllTags( $obContent ) );
+ }
+ }
+ }
+
+ self::log( 'MWDebug output complete' );
+ $debugInfo = self::getDebugInfo( $context );
+
+ ApiResult::setIndexedTagName( $debugInfo, 'debuginfo' );
+ ApiResult::setIndexedTagName( $debugInfo['log'], 'line' );
+ ApiResult::setIndexedTagName( $debugInfo['debugLog'], 'msg' );
+ ApiResult::setIndexedTagName( $debugInfo['queries'], 'query' );
+ ApiResult::setIndexedTagName( $debugInfo['includes'], 'queries' );
+ $result->addValue( null, 'debuginfo', $debugInfo );
+ }
+
+ /**
+ * Returns the HTML to add to the page for the toolbar
+ *
+ * @param IContextSource $context
+ * @return array
+ */
+ public static function getDebugInfo( IContextSource $context ) {
+ if ( !self::$enabled ) {
+ return [];
+ }
+
+ global $wgVersion, $wgRequestTime;
+ $request = $context->getRequest();
+
+ // HHVM's reported memory usage from memory_get_peak_usage()
+ // is not useful when passing false, but we continue passing
+ // false for consistency of historical data in zend.
+ // see: https://github.com/facebook/hhvm/issues/2257#issuecomment-39362246
+ $realMemoryUsage = wfIsHHVM();
+
+ $branch = GitInfo::currentBranch();
+ if ( GitInfo::isSHA1( $branch ) ) {
+ // If it's a detached HEAD, the SHA1 will already be
+ // included in the MW version, so don't show it.
+ $branch = false;
+ }
+
+ return [
+ 'mwVersion' => $wgVersion,
+ 'phpEngine' => wfIsHHVM() ? 'HHVM' : 'PHP',
+ 'phpVersion' => wfIsHHVM() ? HHVM_VERSION : PHP_VERSION,
+ 'gitRevision' => GitInfo::headSHA1(),
+ 'gitBranch' => $branch,
+ 'gitViewUrl' => GitInfo::headViewUrl(),
+ 'time' => microtime( true ) - $wgRequestTime,
+ 'log' => self::$log,
+ 'debugLog' => self::$debug,
+ 'queries' => self::$query,
+ 'request' => [
+ 'method' => $request->getMethod(),
+ 'url' => $request->getRequestURL(),
+ 'headers' => $request->getAllHeaders(),
+ 'params' => $request->getValues(),
+ ],
+ 'memory' => $context->getLanguage()->formatSize( memory_get_usage( $realMemoryUsage ) ),
+ 'memoryPeak' => $context->getLanguage()->formatSize( memory_get_peak_usage( $realMemoryUsage ) ),
+ 'includes' => self::getFilesIncluded( $context ),
+ ];
+ }
+}
diff --git a/www/wiki/includes/debug/logger/ConsoleLogger.php b/www/wiki/includes/debug/logger/ConsoleLogger.php
new file mode 100644
index 00000000..5a5e5071
--- /dev/null
+++ b/www/wiki/includes/debug/logger/ConsoleLogger.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace MediaWiki\Logger;
+
+use Psr\Log\AbstractLogger;
+
+/**
+ * A logger which writes to the terminal. The output is supposed to be
+ * human-readable, and should be changed as necessary to better achieve that
+ * goal.
+ */
+class ConsoleLogger extends AbstractLogger {
+ public function __construct( $channel ) {
+ $this->channel = $channel;
+ }
+
+ public function log( $level, $message, array $context = [] ) {
+ fwrite( STDERR, "[$level] " .
+ LegacyLogger::format( $this->channel, $message, $context ) );
+ }
+}
diff --git a/www/wiki/includes/debug/logger/ConsoleSpi.php b/www/wiki/includes/debug/logger/ConsoleSpi.php
new file mode 100644
index 00000000..e29b98d3
--- /dev/null
+++ b/www/wiki/includes/debug/logger/ConsoleSpi.php
@@ -0,0 +1,11 @@
+<?php
+namespace MediaWiki\Logger;
+
+class ConsoleSpi implements Spi {
+ public function __construct( $config = [] ) {
+ }
+
+ public function getLogger( $channel ) {
+ return new ConsoleLogger( $channel );
+ }
+}
diff --git a/www/wiki/includes/debug/logger/LegacyLogger.php b/www/wiki/includes/debug/logger/LegacyLogger.php
new file mode 100644
index 00000000..06ec5743
--- /dev/null
+++ b/www/wiki/includes/debug/logger/LegacyLogger.php
@@ -0,0 +1,482 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger;
+
+use DateTimeZone;
+use Exception;
+use MWDebug;
+use MWExceptionHandler;
+use Psr\Log\AbstractLogger;
+use Psr\Log\LogLevel;
+use UDPTransport;
+
+/**
+ * PSR-3 logger that mimics the historic implementation of MediaWiki's
+ * wfErrorLog logging implementation.
+ *
+ * This logger is configured by the following global configuration variables:
+ * - `$wgDebugLogFile`
+ * - `$wgDebugLogGroups`
+ * - `$wgDBerrorLog`
+ * - `$wgDBerrorLogTZ`
+ *
+ * See documentation in DefaultSettings.php for detailed explanations of each
+ * variable.
+ *
+ * @see \MediaWiki\Logger\LoggerFactory
+ * @since 1.25
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ */
+class LegacyLogger extends AbstractLogger {
+
+ /**
+ * @var string $channel
+ */
+ protected $channel;
+
+ /**
+ * Convert \Psr\Log\LogLevel constants into int for sane comparisons
+ * These are the same values that Monlog uses
+ *
+ * @var array $levelMapping
+ */
+ protected static $levelMapping = [
+ LogLevel::DEBUG => 100,
+ LogLevel::INFO => 200,
+ LogLevel::NOTICE => 250,
+ LogLevel::WARNING => 300,
+ LogLevel::ERROR => 400,
+ LogLevel::CRITICAL => 500,
+ LogLevel::ALERT => 550,
+ LogLevel::EMERGENCY => 600,
+ ];
+
+ /**
+ * @var array
+ */
+ protected static $dbChannels = [
+ 'DBQuery' => true,
+ 'DBConnection' => true
+ ];
+
+ /**
+ * @param string $channel
+ */
+ public function __construct( $channel ) {
+ $this->channel = $channel;
+ }
+
+ /**
+ * Logs with an arbitrary level.
+ *
+ * @param string|int $level
+ * @param string $message
+ * @param array $context
+ * @return null
+ */
+ public function log( $level, $message, array $context = [] ) {
+ if ( is_string( $level ) ) {
+ $level = self::$levelMapping[$level];
+ }
+ if ( $this->channel === 'DBQuery' && isset( $context['method'] )
+ && isset( $context['master'] ) && isset( $context['runtime'] )
+ ) {
+ MWDebug::query( $message, $context['method'], $context['master'], $context['runtime'] );
+ return; // only send profiling data to MWDebug profiling
+ }
+
+ if ( isset( self::$dbChannels[$this->channel] )
+ && $level >= self::$levelMapping[LogLevel::ERROR]
+ ) {
+ // Format and write DB errors to the legacy locations
+ $effectiveChannel = 'wfLogDBError';
+ } else {
+ $effectiveChannel = $this->channel;
+ }
+
+ if ( self::shouldEmit( $effectiveChannel, $message, $level, $context ) ) {
+ $text = self::format( $effectiveChannel, $message, $context );
+ $destination = self::destination( $effectiveChannel, $message, $context );
+ self::emit( $text, $destination );
+ }
+ if ( !isset( $context['private'] ) || !$context['private'] ) {
+ // Add to debug toolbar if not marked as "private"
+ MWDebug::debugMsg( $message, [ 'channel' => $this->channel ] + $context );
+ }
+ }
+
+ /**
+ * Determine if the given message should be emitted or not.
+ *
+ * @param string $channel
+ * @param string $message
+ * @param string|int $level \Psr\Log\LogEvent constant or Monolog level int
+ * @param array $context
+ * @return bool True if message should be sent to disk/network, false
+ * otherwise
+ */
+ public static function shouldEmit( $channel, $message, $level, $context ) {
+ global $wgDebugLogFile, $wgDBerrorLog, $wgDebugLogGroups;
+
+ if ( is_string( $level ) ) {
+ $level = self::$levelMapping[$level];
+ }
+
+ if ( $channel === 'wfLogDBError' ) {
+ // wfLogDBError messages are emitted if a database log location is
+ // specfied.
+ $shouldEmit = (bool)$wgDBerrorLog;
+
+ } elseif ( $channel === 'wfErrorLog' ) {
+ // All messages on the wfErrorLog channel should be emitted.
+ $shouldEmit = true;
+
+ } elseif ( $channel === 'wfDebug' ) {
+ // wfDebug messages are emitted if a catch all logging file has
+ // been specified. Checked explicitly so that 'private' flagged
+ // messages are not discarded by unset $wgDebugLogGroups channel
+ // handling below.
+ $shouldEmit = $wgDebugLogFile != '';
+
+ } elseif ( isset( $wgDebugLogGroups[$channel] ) ) {
+ $logConfig = $wgDebugLogGroups[$channel];
+
+ if ( is_array( $logConfig ) ) {
+ $shouldEmit = true;
+ if ( isset( $logConfig['sample'] ) ) {
+ // Emit randomly with a 1 in 'sample' chance for each message.
+ $shouldEmit = mt_rand( 1, $logConfig['sample'] ) === 1;
+ }
+
+ if ( isset( $logConfig['level'] ) ) {
+ $shouldEmit = $level >= self::$levelMapping[$logConfig['level']];
+ }
+ } else {
+ // Emit unless the config value is explictly false.
+ $shouldEmit = $logConfig !== false;
+ }
+
+ } elseif ( isset( $context['private'] ) && $context['private'] ) {
+ // Don't emit if the message didn't match previous checks based on
+ // the channel and the event is marked as private. This check
+ // discards messages sent via wfDebugLog() with dest == 'private'
+ // and no explicit wgDebugLogGroups configuration.
+ $shouldEmit = false;
+ } else {
+ // Default return value is the same as the historic wfDebug
+ // method: emit if $wgDebugLogFile has been set.
+ $shouldEmit = $wgDebugLogFile != '';
+ }
+
+ return $shouldEmit;
+ }
+
+ /**
+ * Format a message.
+ *
+ * Messages to the 'wfDebug', 'wfLogDBError' and 'wfErrorLog' channels
+ * receive special fomatting to mimic the historic output of the functions
+ * of the same name. All other channel values are formatted based on the
+ * historic output of the `wfDebugLog()` global function.
+ *
+ * @param string $channel
+ * @param string $message
+ * @param array $context
+ * @return string
+ */
+ public static function format( $channel, $message, $context ) {
+ global $wgDebugLogGroups, $wgLogExceptionBacktrace;
+
+ if ( $channel === 'wfDebug' ) {
+ $text = self::formatAsWfDebug( $channel, $message, $context );
+
+ } elseif ( $channel === 'wfLogDBError' ) {
+ $text = self::formatAsWfLogDBError( $channel, $message, $context );
+
+ } elseif ( $channel === 'wfErrorLog' ) {
+ $text = "{$message}\n";
+
+ } elseif ( $channel === 'profileoutput' ) {
+ // Legacy wfLogProfilingData formatitng
+ $forward = '';
+ if ( isset( $context['forwarded_for'] ) ) {
+ $forward = " forwarded for {$context['forwarded_for']}";
+ }
+ if ( isset( $context['client_ip'] ) ) {
+ $forward .= " client IP {$context['client_ip']}";
+ }
+ if ( isset( $context['from'] ) ) {
+ $forward .= " from {$context['from']}";
+ }
+ if ( $forward ) {
+ $forward = "\t(proxied via {$context['proxy']}{$forward})";
+ }
+ if ( $context['anon'] ) {
+ $forward .= ' anon';
+ }
+ if ( !isset( $context['url'] ) ) {
+ $context['url'] = 'n/a';
+ }
+
+ $log = sprintf( "%s\t%04.3f\t%s%s\n",
+ gmdate( 'YmdHis' ), $context['elapsed'], $context['url'], $forward );
+
+ $text = self::formatAsWfDebugLog(
+ $channel, $log . $context['output'], $context );
+
+ } elseif ( !isset( $wgDebugLogGroups[$channel] ) ) {
+ $text = self::formatAsWfDebug(
+ $channel, "[{$channel}] {$message}", $context );
+
+ } else {
+ // Default formatting is wfDebugLog's historic style
+ $text = self::formatAsWfDebugLog( $channel, $message, $context );
+ }
+
+ // Append stacktrace of exception if available
+ if ( $wgLogExceptionBacktrace && isset( $context['exception'] ) ) {
+ $e = $context['exception'];
+ $backtrace = false;
+
+ if ( $e instanceof Exception ) {
+ $backtrace = MWExceptionHandler::getRedactedTrace( $e );
+
+ } elseif ( is_array( $e ) && isset( $e['trace'] ) ) {
+ // Exception has already been unpacked as structured data
+ $backtrace = $e['trace'];
+ }
+
+ if ( $backtrace ) {
+ $text .= MWExceptionHandler::prettyPrintTrace( $backtrace ) .
+ "\n";
+ }
+ }
+
+ return self::interpolate( $text, $context );
+ }
+
+ /**
+ * Format a message as `wfDebug()` would have formatted it.
+ *
+ * @param string $channel
+ * @param string $message
+ * @param array $context
+ * @return string
+ */
+ protected static function formatAsWfDebug( $channel, $message, $context ) {
+ $text = preg_replace( '![\x00-\x08\x0b\x0c\x0e-\x1f]!', ' ', $message );
+ if ( isset( $context['seconds_elapsed'] ) ) {
+ // Prepend elapsed request time and real memory usage with two
+ // trailing spaces.
+ $text = "{$context['seconds_elapsed']} {$context['memory_used']} {$text}";
+ }
+ if ( isset( $context['prefix'] ) ) {
+ $text = "{$context['prefix']}{$text}";
+ }
+ return "{$text}\n";
+ }
+
+ /**
+ * Format a message as `wfLogDBError()` would have formatted it.
+ *
+ * @param string $channel
+ * @param string $message
+ * @param array $context
+ * @return string
+ */
+ protected static function formatAsWfLogDBError( $channel, $message, $context ) {
+ global $wgDBerrorLogTZ;
+ static $cachedTimezone = null;
+
+ if ( !$cachedTimezone ) {
+ $cachedTimezone = new DateTimeZone( $wgDBerrorLogTZ );
+ }
+
+ $d = date_create( 'now', $cachedTimezone );
+ $date = $d->format( 'D M j G:i:s T Y' );
+
+ $host = wfHostname();
+ $wiki = wfWikiID();
+
+ $text = "{$date}\t{$host}\t{$wiki}\t{$message}\n";
+ return $text;
+ }
+
+ /**
+ * Format a message as `wfDebugLog() would have formatted it.
+ *
+ * @param string $channel
+ * @param string $message
+ * @param array $context
+ * @return string
+ */
+ protected static function formatAsWfDebugLog( $channel, $message, $context ) {
+ $time = wfTimestamp( TS_DB );
+ $wiki = wfWikiID();
+ $host = wfHostname();
+ $text = "{$time} {$host} {$wiki}: {$message}\n";
+ return $text;
+ }
+
+ /**
+ * Interpolate placeholders in logging message.
+ *
+ * @param string $message
+ * @param array $context
+ * @return string Interpolated message
+ */
+ public static function interpolate( $message, array $context ) {
+ if ( strpos( $message, '{' ) !== false ) {
+ $replace = [];
+ foreach ( $context as $key => $val ) {
+ $replace['{' . $key . '}'] = self::flatten( $val );
+ }
+ $message = strtr( $message, $replace );
+ }
+ return $message;
+ }
+
+ /**
+ * Convert a logging context element to a string suitable for
+ * interpolation.
+ *
+ * @param mixed $item
+ * @return string
+ */
+ protected static function flatten( $item ) {
+ if ( null === $item ) {
+ return '[Null]';
+ }
+
+ if ( is_bool( $item ) ) {
+ return $item ? 'true' : 'false';
+ }
+
+ if ( is_float( $item ) ) {
+ if ( is_infinite( $item ) ) {
+ return ( $item > 0 ? '' : '-' ) . 'INF';
+ }
+ if ( is_nan( $item ) ) {
+ return 'NaN';
+ }
+ return (string)$item;
+ }
+
+ if ( is_scalar( $item ) ) {
+ return (string)$item;
+ }
+
+ if ( is_array( $item ) ) {
+ return '[Array(' . count( $item ) . ')]';
+ }
+
+ if ( $item instanceof \DateTime ) {
+ return $item->format( 'c' );
+ }
+
+ if ( $item instanceof Exception ) {
+ return '[Exception ' . get_class( $item ) . '( ' .
+ $item->getFile() . ':' . $item->getLine() . ') ' .
+ $item->getMessage() . ']';
+ }
+
+ if ( is_object( $item ) ) {
+ if ( method_exists( $item, '__toString' ) ) {
+ return (string)$item;
+ }
+
+ return '[Object ' . get_class( $item ) . ']';
+ }
+
+ if ( is_resource( $item ) ) {
+ return '[Resource ' . get_resource_type( $item ) . ']';
+ }
+
+ return '[Unknown ' . gettype( $item ) . ']';
+ }
+
+ /**
+ * Select the appropriate log output destination for the given log event.
+ *
+ * If the event context contains 'destination'
+ *
+ * @param string $channel
+ * @param string $message
+ * @param array $context
+ * @return string
+ */
+ protected static function destination( $channel, $message, $context ) {
+ global $wgDebugLogFile, $wgDBerrorLog, $wgDebugLogGroups;
+
+ // Default destination is the debug log file as historically used by
+ // the wfDebug function.
+ $destination = $wgDebugLogFile;
+
+ if ( isset( $context['destination'] ) ) {
+ // Use destination explicitly provided in context
+ $destination = $context['destination'];
+
+ } elseif ( $channel === 'wfDebug' ) {
+ $destination = $wgDebugLogFile;
+
+ } elseif ( $channel === 'wfLogDBError' ) {
+ $destination = $wgDBerrorLog;
+
+ } elseif ( isset( $wgDebugLogGroups[$channel] ) ) {
+ $logConfig = $wgDebugLogGroups[$channel];
+
+ if ( is_array( $logConfig ) ) {
+ $destination = $logConfig['destination'];
+ } else {
+ $destination = strval( $logConfig );
+ }
+ }
+
+ return $destination;
+ }
+
+ /**
+ * Log to a file without getting "file size exceeded" signals.
+ *
+ * Can also log to UDP with the syntax udp://host:port/prefix. This will send
+ * lines to the specified port, prefixed by the specified prefix and a space.
+ *
+ * @param string $text
+ * @param string $file Filename
+ */
+ public static function emit( $text, $file ) {
+ if ( substr( $file, 0, 4 ) == 'udp:' ) {
+ $transport = UDPTransport::newFromString( $file );
+ $transport->emit( $text );
+ } else {
+ \MediaWiki\suppressWarnings();
+ $exists = file_exists( $file );
+ $size = $exists ? filesize( $file ) : false;
+ if ( !$exists ||
+ ( $size !== false && $size + strlen( $text ) < 0x7fffffff )
+ ) {
+ file_put_contents( $file, $text, FILE_APPEND );
+ }
+ \MediaWiki\restoreWarnings();
+ }
+ }
+
+}
diff --git a/www/wiki/includes/debug/logger/LegacySpi.php b/www/wiki/includes/debug/logger/LegacySpi.php
new file mode 100644
index 00000000..8e750cab
--- /dev/null
+++ b/www/wiki/includes/debug/logger/LegacySpi.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger;
+
+/**
+ * LoggerFactory service provider that creates LegacyLogger instances.
+ *
+ * Usage:
+ * @code
+ * $wgMWLoggerDefaultSpi = [
+ * 'class' => '\\MediaWiki\\Logger\\LegacySpi',
+ * ];
+ * @endcode
+ *
+ * @see \MediaWiki\Logger\LoggerFactory
+ * @since 1.25
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ */
+class LegacySpi implements Spi {
+
+ /**
+ * @var array $singletons
+ */
+ protected $singletons = [];
+
+ /**
+ * Get a logger instance.
+ *
+ * @param string $channel Logging channel
+ * @return \Psr\Log\LoggerInterface Logger instance
+ */
+ public function getLogger( $channel ) {
+ if ( !isset( $this->singletons[$channel] ) ) {
+ $this->singletons[$channel] = new LegacyLogger( $channel );
+ }
+ return $this->singletons[$channel];
+ }
+
+}
diff --git a/www/wiki/includes/debug/logger/LoggerFactory.php b/www/wiki/includes/debug/logger/LoggerFactory.php
new file mode 100644
index 00000000..c183ff15
--- /dev/null
+++ b/www/wiki/includes/debug/logger/LoggerFactory.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger;
+
+use ObjectFactory;
+
+/**
+ * PSR-3 logger instance factory.
+ *
+ * Creation of \Psr\Log\LoggerInterface instances is managed via the
+ * LoggerFactory::getInstance() static method which in turn delegates to the
+ * currently registered service provider.
+ *
+ * A service provider is any class implementing the Spi interface.
+ * There are two possible methods of registering a service provider. The
+ * LoggerFactory::registerProvider() static method can be called at any time
+ * to change the service provider. If LoggerFactory::getInstance() is called
+ * before any service provider has been registered, it will attempt to use the
+ * $wgMWLoggerDefaultSpi global to bootstrap Spi registration.
+ * $wgMWLoggerDefaultSpi is expected to be an array usable by
+ * ObjectFactory::getObjectFromSpec() to create a class.
+ *
+ * @see \MediaWiki\Logger\Spi
+ * @since 1.25
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ */
+class LoggerFactory {
+
+ /**
+ * Service provider.
+ * @var \MediaWiki\Logger\Spi $spi
+ */
+ private static $spi;
+
+ /**
+ * Register a service provider to create new \Psr\Log\LoggerInterface
+ * instances.
+ *
+ * @param \MediaWiki\Logger\Spi $provider Provider to register
+ */
+ public static function registerProvider( Spi $provider ) {
+ self::$spi = $provider;
+ }
+
+ /**
+ * Get the registered service provider.
+ *
+ * If called before any service provider has been registered, it will
+ * attempt to use the $wgMWLoggerDefaultSpi global to bootstrap
+ * Spi registration. $wgMWLoggerDefaultSpi is expected to be an
+ * array usable by ObjectFactory::getObjectFromSpec() to create a class.
+ *
+ * @return \MediaWiki\Logger\Spi
+ * @see registerProvider()
+ * @see ObjectFactory::getObjectFromSpec()
+ */
+ public static function getProvider() {
+ if ( self::$spi === null ) {
+ global $wgMWLoggerDefaultSpi;
+ $provider = ObjectFactory::getObjectFromSpec(
+ $wgMWLoggerDefaultSpi
+ );
+ self::registerProvider( $provider );
+ }
+ return self::$spi;
+ }
+
+ /**
+ * Get a named logger instance from the currently configured logger factory.
+ *
+ * @param string $channel Logger channel (name)
+ * @return \Psr\Log\LoggerInterface
+ */
+ public static function getInstance( $channel ) {
+ return self::getProvider()->getLogger( $channel );
+ }
+
+ /**
+ * Construction of utility class is not allowed.
+ */
+ private function __construct() {
+ // no-op
+ }
+}
diff --git a/www/wiki/includes/debug/logger/MonologSpi.php b/www/wiki/includes/debug/logger/MonologSpi.php
new file mode 100644
index 00000000..197b269b
--- /dev/null
+++ b/www/wiki/includes/debug/logger/MonologSpi.php
@@ -0,0 +1,271 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger;
+
+use MediaWiki\Logger\Monolog\BufferHandler;
+use Monolog\Logger;
+use ObjectFactory;
+
+/**
+ * LoggerFactory service provider that creates loggers implemented by
+ * Monolog.
+ *
+ * Configured using an array of configuration data with the keys 'loggers',
+ * 'processors', 'handlers' and 'formatters'.
+ *
+ * The ['loggers']['\@default'] configuration will be used to create loggers
+ * for any channel that isn't explicitly named in the 'loggers' configuration
+ * section.
+ *
+ * Configuration will most typically be provided in the $wgMWLoggerDefaultSpi
+ * global configuration variable used by LoggerFactory to construct its
+ * default SPI provider:
+ * @code
+ * $wgMWLoggerDefaultSpi = [
+ * 'class' => '\\MediaWiki\\Logger\\MonologSpi',
+ * 'args' => [ [
+ * 'loggers' => [
+ * '@default' => [
+ * 'processors' => [ 'wiki', 'psr', 'pid', 'uid', 'web' ],
+ * 'handlers' => [ 'stream' ],
+ * ],
+ * 'runJobs' => [
+ * 'processors' => [ 'wiki', 'psr', 'pid' ],
+ * 'handlers' => [ 'stream' ],
+ * ]
+ * ],
+ * 'processors' => [
+ * 'wiki' => [
+ * 'class' => '\\MediaWiki\\Logger\\Monolog\\WikiProcessor',
+ * ],
+ * 'psr' => [
+ * 'class' => '\\Monolog\\Processor\\PsrLogMessageProcessor',
+ * ],
+ * 'pid' => [
+ * 'class' => '\\Monolog\\Processor\\ProcessIdProcessor',
+ * ],
+ * 'uid' => [
+ * 'class' => '\\Monolog\\Processor\\UidProcessor',
+ * ],
+ * 'web' => [
+ * 'class' => '\\Monolog\\Processor\\WebProcessor',
+ * ],
+ * ],
+ * 'handlers' => [
+ * 'stream' => [
+ * 'class' => '\\Monolog\\Handler\\StreamHandler',
+ * 'args' => [ 'path/to/your.log' ],
+ * 'formatter' => 'line',
+ * ],
+ * 'redis' => [
+ * 'class' => '\\Monolog\\Handler\\RedisHandler',
+ * 'args' => [ function() {
+ * $redis = new Redis();
+ * $redis->connect( '127.0.0.1', 6379 );
+ * return $redis;
+ * },
+ * 'logstash'
+ * ],
+ * 'formatter' => 'logstash',
+ * 'buffer' => true,
+ * ],
+ * 'udp2log' => [
+ * 'class' => '\\MediaWiki\\Logger\\Monolog\\LegacyHandler',
+ * 'args' => [
+ * 'udp://127.0.0.1:8420/mediawiki
+ * ],
+ * 'formatter' => 'line',
+ * ],
+ * ],
+ * 'formatters' => [
+ * 'line' => [
+ * 'class' => '\\Monolog\\Formatter\\LineFormatter',
+ * ],
+ * 'logstash' => [
+ * 'class' => '\\Monolog\\Formatter\\LogstashFormatter',
+ * 'args' => [ 'mediawiki', php_uname( 'n' ), null, '', 1 ],
+ * ],
+ * ],
+ * ] ],
+ * ];
+ * @endcode
+ *
+ * @see https://github.com/Seldaek/monolog
+ * @since 1.25
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ */
+class MonologSpi implements Spi {
+
+ /**
+ * @var array $singletons
+ */
+ protected $singletons;
+
+ /**
+ * Configuration for creating new loggers.
+ * @var array $config
+ */
+ protected $config;
+
+ /**
+ * @param array $config Configuration data.
+ */
+ public function __construct( array $config ) {
+ $this->config = [];
+ $this->mergeConfig( $config );
+ }
+
+ /**
+ * Merge additional configuration data into the configuration.
+ *
+ * @since 1.26
+ * @param array $config Configuration data.
+ */
+ public function mergeConfig( array $config ) {
+ foreach ( $config as $key => $value ) {
+ if ( isset( $this->config[$key] ) ) {
+ $this->config[$key] = array_merge( $this->config[$key], $value );
+ } else {
+ $this->config[$key] = $value;
+ }
+ }
+ $this->reset();
+ }
+
+ /**
+ * Reset internal caches.
+ *
+ * This is public for use in unit tests. Under normal operation there should
+ * be no need to flush the caches.
+ */
+ public function reset() {
+ $this->singletons = [
+ 'loggers' => [],
+ 'handlers' => [],
+ 'formatters' => [],
+ 'processors' => [],
+ ];
+ }
+
+ /**
+ * Get a logger instance.
+ *
+ * Creates and caches a logger instance based on configuration found in the
+ * $wgMWLoggerMonologSpiConfig global. Subsequent request for the same channel
+ * name will return the cached instance.
+ *
+ * @param string $channel Logging channel
+ * @return \Psr\Log\LoggerInterface Logger instance
+ */
+ public function getLogger( $channel ) {
+ if ( !isset( $this->singletons['loggers'][$channel] ) ) {
+ // Fallback to using the '@default' configuration if an explict
+ // configuration for the requested channel isn't found.
+ $spec = isset( $this->config['loggers'][$channel] ) ?
+ $this->config['loggers'][$channel] :
+ $this->config['loggers']['@default'];
+
+ $monolog = $this->createLogger( $channel, $spec );
+ $this->singletons['loggers'][$channel] = $monolog;
+ }
+
+ return $this->singletons['loggers'][$channel];
+ }
+
+ /**
+ * Create a logger.
+ * @param string $channel Logger channel
+ * @param array $spec Configuration
+ * @return \Monolog\Logger
+ */
+ protected function createLogger( $channel, $spec ) {
+ $obj = new Logger( $channel );
+
+ if ( isset( $spec['calls'] ) ) {
+ foreach ( $spec['calls'] as $method => $margs ) {
+ call_user_func_array( [ $obj, $method ], $margs );
+ }
+ }
+
+ if ( isset( $spec['processors'] ) ) {
+ foreach ( $spec['processors'] as $processor ) {
+ $obj->pushProcessor( $this->getProcessor( $processor ) );
+ }
+ }
+
+ if ( isset( $spec['handlers'] ) ) {
+ foreach ( $spec['handlers'] as $handler ) {
+ $obj->pushHandler( $this->getHandler( $handler ) );
+ }
+ }
+ return $obj;
+ }
+
+ /**
+ * Create or return cached processor.
+ * @param string $name Processor name
+ * @return callable
+ */
+ public function getProcessor( $name ) {
+ if ( !isset( $this->singletons['processors'][$name] ) ) {
+ $spec = $this->config['processors'][$name];
+ $processor = ObjectFactory::getObjectFromSpec( $spec );
+ $this->singletons['processors'][$name] = $processor;
+ }
+ return $this->singletons['processors'][$name];
+ }
+
+ /**
+ * Create or return cached handler.
+ * @param string $name Processor name
+ * @return \Monolog\Handler\HandlerInterface
+ */
+ public function getHandler( $name ) {
+ if ( !isset( $this->singletons['handlers'][$name] ) ) {
+ $spec = $this->config['handlers'][$name];
+ $handler = ObjectFactory::getObjectFromSpec( $spec );
+ if ( isset( $spec['formatter'] ) ) {
+ $handler->setFormatter(
+ $this->getFormatter( $spec['formatter'] )
+ );
+ }
+ if ( isset( $spec['buffer'] ) && $spec['buffer'] ) {
+ $handler = new BufferHandler( $handler );
+ }
+ $this->singletons['handlers'][$name] = $handler;
+ }
+ return $this->singletons['handlers'][$name];
+ }
+
+ /**
+ * Create or return cached formatter.
+ * @param string $name Formatter name
+ * @return \Monolog\Formatter\FormatterInterface
+ */
+ public function getFormatter( $name ) {
+ if ( !isset( $this->singletons['formatters'][$name] ) ) {
+ $spec = $this->config['formatters'][$name];
+ $formatter = ObjectFactory::getObjectFromSpec( $spec );
+ $this->singletons['formatters'][$name] = $formatter;
+ }
+ return $this->singletons['formatters'][$name];
+ }
+}
diff --git a/www/wiki/includes/debug/logger/NullSpi.php b/www/wiki/includes/debug/logger/NullSpi.php
new file mode 100644
index 00000000..4862157d
--- /dev/null
+++ b/www/wiki/includes/debug/logger/NullSpi.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger;
+
+use Psr\Log\NullLogger;
+
+/**
+ * LoggerFactory service provider that creates \Psr\Log\NullLogger
+ * instances. A NullLogger silently discards all log events sent to it.
+ *
+ * Usage:
+ *
+ * $wgMWLoggerDefaultSpi = [
+ * 'class' => '\\MediaWiki\\Logger\\NullSpi',
+ * ];
+ *
+ * @see \MediaWiki\Logger\LoggerFactory
+ * @since 1.25
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ */
+class NullSpi implements Spi {
+
+ /**
+ * @var \Psr\Log\NullLogger $singleton
+ */
+ protected $singleton;
+
+ public function __construct() {
+ $this->singleton = new NullLogger();
+ }
+
+ /**
+ * Get a logger instance.
+ *
+ * @param string $channel Logging channel
+ * @return \Psr\Log\NullLogger Logger instance
+ */
+ public function getLogger( $channel ) {
+ return $this->singleton;
+ }
+
+}
diff --git a/www/wiki/includes/debug/logger/Spi.php b/www/wiki/includes/debug/logger/Spi.php
new file mode 100644
index 00000000..8e0875f2
--- /dev/null
+++ b/www/wiki/includes/debug/logger/Spi.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger;
+
+/**
+ * Service provider interface for \Psr\Log\LoggerInterface implementation
+ * libraries.
+ *
+ * MediaWiki can be configured to use a class implementing this interface to
+ * create new \Psr\Log\LoggerInterface instances via either the
+ * $wgMWLoggerDefaultSpi global variable or code that constructs an instance
+ * and registers it via the LoggerFactory::registerProvider() static method.
+ *
+ * @see \MediaWiki\Logger\LoggerFactory
+ * @since 1.25
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ */
+interface Spi {
+
+ /**
+ * Get a logger instance.
+ *
+ * @param string $channel Logging channel
+ * @return \Psr\Log\LoggerInterface Logger instance
+ */
+ public function getLogger( $channel );
+
+}
diff --git a/www/wiki/includes/debug/logger/monolog/AvroFormatter.php b/www/wiki/includes/debug/logger/monolog/AvroFormatter.php
new file mode 100644
index 00000000..a395e0d0
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/AvroFormatter.php
@@ -0,0 +1,171 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use AvroIODatumWriter;
+use AvroIOBinaryEncoder;
+use AvroIOTypeException;
+use AvroSchema;
+use AvroStringIO;
+use AvroValidator;
+use Monolog\Formatter\FormatterInterface;
+
+/**
+ * Log message formatter that uses the apache Avro format.
+ *
+ * @since 1.26
+ * @author Erik Bernhardson <ebernhardson@wikimedia.org>
+ * @copyright © 2015 Erik Bernhardson and Wikimedia Foundation.
+ */
+class AvroFormatter implements FormatterInterface {
+ /**
+ * @var Magic byte to encode schema revision id.
+ */
+ const MAGIC = 0x0;
+ /**
+ * @var array Map from schema name to schema definition
+ */
+ protected $schemas;
+
+ /**
+ * @var AvroStringIO
+ */
+ protected $io;
+
+ /**
+ * @var AvroIOBinaryEncoder
+ */
+ protected $encoder;
+
+ /**
+ * @var AvroIODatumWriter
+ */
+ protected $writer;
+
+ /**
+ * @param array $schemas Map from Monolog channel to Avro schema.
+ * Each schema can be either the JSON string or decoded into PHP
+ * arrays.
+ */
+ public function __construct( array $schemas ) {
+ $this->schemas = $schemas;
+ $this->io = new AvroStringIO( '' );
+ $this->encoder = new AvroIOBinaryEncoder( $this->io );
+ $this->writer = new AvroIODatumWriter();
+ }
+
+ /**
+ * Formats the record context into a binary string per the
+ * schema configured for the records channel.
+ *
+ * @param array $record
+ * @return string|null The serialized record, or null if
+ * the record is not valid for the selected schema.
+ */
+ public function format( array $record ) {
+ $this->io->truncate();
+ $schema = $this->getSchema( $record['channel'] );
+ $revId = $this->getSchemaRevisionId( $record['channel'] );
+ if ( $schema === null || $revId === null ) {
+ trigger_error( "The schema for channel '{$record['channel']}' is not available" );
+ return null;
+ }
+ try {
+ $this->writer->write_data( $schema, $record['context'], $this->encoder );
+ } catch ( AvroIOTypeException $e ) {
+ $errors = AvroValidator::getErrors( $schema, $record['context'] );
+ $json = json_encode( $errors );
+ trigger_error( "Avro failed to serialize record for {$record['channel']} : {$json}" );
+ return null;
+ }
+ return chr( self::MAGIC ) . $this->encodeLong( $revId ) . $this->io->string();
+ }
+
+ /**
+ * Format a set of records into a list of binary strings
+ * conforming to the configured schema.
+ *
+ * @param array $records
+ * @return string[]
+ */
+ public function formatBatch( array $records ) {
+ $result = [];
+ foreach ( $records as $record ) {
+ $message = $this->format( $record );
+ if ( $message !== null ) {
+ $result[] = $message;
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * Get the writer for the named channel
+ *
+ * @param string $channel Name of the schema to fetch
+ * @return \AvroSchema|null
+ */
+ protected function getSchema( $channel ) {
+ if ( !isset( $this->schemas[$channel] ) ) {
+ return null;
+ }
+ if ( !isset( $this->schemas[$channel]['revision'], $this->schemas[$channel]['schema'] ) ) {
+ return null;
+ }
+
+ if ( !$this->schemas[$channel]['schema'] instanceof AvroSchema ) {
+ $schema = $this->schemas[$channel]['schema'];
+ if ( is_string( $schema ) ) {
+ $this->schemas[$channel]['schema'] = AvroSchema::parse( $schema );
+ } else {
+ $this->schemas[$channel]['schema'] = AvroSchema::real_parse(
+ $schema
+ );
+ }
+ }
+ return $this->schemas[$channel]['schema'];
+ }
+
+ /**
+ * Get the writer for the named channel
+ *
+ * @param string $channel Name of the schema
+ * @return int|null
+ */
+ public function getSchemaRevisionId( $channel ) {
+ if ( isset( $this->schemas[$channel]['revision'] ) ) {
+ return (int)$this->schemas[$channel]['revision'];
+ }
+ return null;
+ }
+
+ /**
+ * convert an integer to a 64bits big endian long (Java compatible)
+ * NOTE: certainly only compatible with PHP 64bits
+ * @param int $id
+ * @return string the binary representation of $id
+ */
+ private function encodeLong( $id ) {
+ $high = ( $id & 0xffffffff00000000 ) >> 32;
+ $low = $id & 0x00000000ffffffff;
+ return pack( 'NN', $high, $low );
+ }
+}
diff --git a/www/wiki/includes/debug/logger/monolog/BufferHandler.php b/www/wiki/includes/debug/logger/monolog/BufferHandler.php
new file mode 100644
index 00000000..650d0127
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/BufferHandler.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Helper class for the index.php entry point.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use DeferredUpdates;
+use Monolog\Handler\BufferHandler as BaseBufferHandler;
+
+/**
+ * Updates \Monolog\Handler\BufferHandler to use DeferredUpdates rather
+ * than register_shutdown_function. On supported platforms this will
+ * use register_postsend_function or fastcgi_finish_request() to delay
+ * until after the request has shutdown and we are no longer delaying
+ * the web request.
+ */
+class BufferHandler extends BaseBufferHandler {
+ /**
+ * @inheritDoc
+ */
+ public function handle( array $record ) {
+ if ( !$this->initialized ) {
+ DeferredUpdates::addCallableUpdate( [ $this, 'close' ] );
+ $this->initialized = true;
+ }
+ return parent::handle( $record );
+ }
+}
diff --git a/www/wiki/includes/debug/logger/monolog/KafkaHandler.php b/www/wiki/includes/debug/logger/monolog/KafkaHandler.php
new file mode 100644
index 00000000..8e711316
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/KafkaHandler.php
@@ -0,0 +1,279 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use Kafka\MetaDataFromKafka;
+use Kafka\Produce;
+use Kafka\Protocol\Decoder;
+use MediaWiki\Logger\LoggerFactory;
+use Monolog\Handler\AbstractProcessingHandler;
+use Monolog\Logger;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Log handler sends log events to a kafka server.
+ *
+ * Constructor options array arguments:
+ * * alias: map from monolog channel to kafka topic name. When no
+ * alias exists the topic "monolog_$channel" will be used.
+ * * swallowExceptions: Swallow exceptions that occur while talking to
+ * kafka. Defaults to false.
+ * * logExceptions: Log exceptions talking to kafka here. Either null,
+ * the name of a channel to log to, or an object implementing
+ * FormatterInterface. Defaults to null.
+ *
+ * Requires the nmred/kafka-php library, version >= 1.3.0
+ *
+ * @since 1.26
+ * @author Erik Bernhardson <ebernhardson@wikimedia.org>
+ * @copyright © 2015 Erik Bernhardson and Wikimedia Foundation.
+ */
+class KafkaHandler extends AbstractProcessingHandler {
+ /**
+ * @var Produce Sends requests to kafka
+ */
+ protected $produce;
+
+ /**
+ * @var array Optional handler configuration
+ */
+ protected $options;
+
+ /**
+ * @var array Map from topic name to partition this request produces to
+ */
+ protected $partitions = [];
+
+ /**
+ * @var array defaults for constructor options
+ */
+ private static $defaultOptions = [
+ 'alias' => [], // map from monolog channel to kafka topic
+ 'swallowExceptions' => false, // swallow exceptions sending records
+ 'logExceptions' => null, // A PSR3 logger to inform about errors
+ 'requireAck' => 0,
+ ];
+
+ /**
+ * @param Produce $produce Kafka instance to produce through
+ * @param array $options optional handler configuration
+ * @param int $level The minimum logging level at which this handler will be triggered
+ * @param bool $bubble Whether the messages that are handled can bubble up the stack or not
+ */
+ public function __construct(
+ Produce $produce, array $options, $level = Logger::DEBUG, $bubble = true
+ ) {
+ parent::__construct( $level, $bubble );
+ $this->produce = $produce;
+ $this->options = array_merge( self::$defaultOptions, $options );
+ }
+
+ /**
+ * Constructs the necessary support objects and returns a KafkaHandler
+ * instance.
+ *
+ * @param string[] $kafkaServers
+ * @param array $options
+ * @param int $level The minimum logging level at which this handle will be triggered
+ * @param bool $bubble Whether the messages that are handled can bubble the stack or not
+ * @return KafkaHandler
+ */
+ public static function factory(
+ $kafkaServers, array $options = [], $level = Logger::DEBUG, $bubble = true
+ ) {
+ $metadata = new MetaDataFromKafka( $kafkaServers );
+ $produce = new Produce( $metadata );
+
+ if ( isset( $options['sendTimeout'] ) ) {
+ $timeOut = $options['sendTimeout'];
+ $produce->getClient()->setStreamOption( 'SendTimeoutSec', 0 );
+ $produce->getClient()->setStreamOption( 'SendTimeoutUSec',
+ intval( $timeOut * 1000000 )
+ );
+ }
+ if ( isset( $options['recvTimeout'] ) ) {
+ $timeOut = $options['recvTimeout'];
+ $produce->getClient()->setStreamOption( 'RecvTimeoutSec', 0 );
+ $produce->getClient()->setStreamOption( 'RecvTimeoutUSec',
+ intval( $timeOut * 1000000 )
+ );
+ }
+ if ( isset( $options['logExceptions'] ) && is_string( $options['logExceptions'] ) ) {
+ $options['logExceptions'] = LoggerFactory::getInstance( $options['logExceptions'] );
+ }
+
+ if ( isset( $options['requireAck'] ) ) {
+ $produce->setRequireAck( $options['requireAck'] );
+ }
+
+ return new self( $produce, $options, $level, $bubble );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function write( array $record ) {
+ if ( $record['formatted'] !== null ) {
+ $this->addMessages( $record['channel'], [ $record['formatted'] ] );
+ $this->send();
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function handleBatch( array $batch ) {
+ $channels = [];
+ foreach ( $batch as $record ) {
+ if ( $record['level'] < $this->level ) {
+ continue;
+ }
+ $channels[$record['channel']][] = $this->processRecord( $record );
+ }
+
+ $formatter = $this->getFormatter();
+ foreach ( $channels as $channel => $records ) {
+ $messages = [];
+ foreach ( $records as $idx => $record ) {
+ $message = $formatter->format( $record );
+ if ( $message !== null ) {
+ $messages[] = $message;
+ }
+ }
+ if ( $messages ) {
+ $this->addMessages( $channel, $messages );
+ }
+ }
+
+ $this->send();
+ }
+
+ /**
+ * Send any records in the kafka client internal queue.
+ */
+ protected function send() {
+ try {
+ $response = $this->produce->send();
+ } catch ( \Kafka\Exception $e ) {
+ $ignore = $this->warning(
+ 'Error sending records to kafka: {exception}',
+ [ 'exception' => $e ] );
+ if ( !$ignore ) {
+ throw $e;
+ } else {
+ return;
+ }
+ }
+
+ if ( is_bool( $response ) ) {
+ return;
+ }
+
+ $errors = [];
+ foreach ( $response as $topicName => $partitionResponse ) {
+ foreach ( $partitionResponse as $partition => $info ) {
+ if ( $info['errCode'] === 0 ) {
+ // no error
+ continue;
+ }
+ $errors[] = sprintf(
+ 'Error producing to %s (errno %d): %s',
+ $topicName,
+ $info['errCode'],
+ Decoder::getError( $info['errCode'] )
+ );
+ }
+ }
+
+ if ( $errors ) {
+ $error = implode( "\n", $errors );
+ if ( !$this->warning( $error ) ) {
+ throw new \RuntimeException( $error );
+ }
+ }
+ }
+
+ /**
+ * @param string $topic Name of topic to get partition for
+ * @return int|null The random partition to produce to for this request,
+ * or null if a partition could not be determined.
+ */
+ protected function getRandomPartition( $topic ) {
+ if ( !array_key_exists( $topic, $this->partitions ) ) {
+ try {
+ $partitions = $this->produce->getAvailablePartitions( $topic );
+ } catch ( \Kafka\Exception $e ) {
+ $ignore = $this->warning(
+ 'Error getting metadata for kafka topic {topic}: {exception}',
+ [ 'topic' => $topic, 'exception' => $e ] );
+ if ( $ignore ) {
+ return null;
+ }
+ throw $e;
+ }
+ if ( $partitions ) {
+ $key = array_rand( $partitions );
+ $this->partitions[$topic] = $partitions[$key];
+ } else {
+ $details = $this->produce->getClient()->getTopicDetail( $topic );
+ $ignore = $this->warning(
+ 'No partitions available for kafka topic {topic}',
+ [ 'topic' => $topic, 'kafka' => $details ]
+ );
+ if ( !$ignore ) {
+ throw new \RuntimeException( "No partitions available for kafka topic $topic" );
+ }
+ $this->partitions[$topic] = null;
+ }
+ }
+ return $this->partitions[$topic];
+ }
+
+ /**
+ * Adds records for a channel to the Kafka client internal queue.
+ *
+ * @param string $channel Name of Monolog channel records belong to
+ * @param array $records List of records to append
+ */
+ protected function addMessages( $channel, array $records ) {
+ if ( isset( $this->options['alias'][$channel] ) ) {
+ $topic = $this->options['alias'][$channel];
+ } else {
+ $topic = "monolog_$channel";
+ }
+ $partition = $this->getRandomPartition( $topic );
+ if ( $partition !== null ) {
+ $this->produce->setMessages( $topic, $partition, $records );
+ }
+ }
+
+ /**
+ * @param string $message PSR3 compatible message string
+ * @param array $context PSR3 compatible log context
+ * @return bool true if caller should ignore warning
+ */
+ protected function warning( $message, array $context = [] ) {
+ if ( $this->options['logExceptions'] instanceof LoggerInterface ) {
+ $this->options['logExceptions']->warning( $message, $context );
+ }
+ return $this->options['swallowExceptions'];
+ }
+}
diff --git a/www/wiki/includes/debug/logger/monolog/LegacyFormatter.php b/www/wiki/includes/debug/logger/monolog/LegacyFormatter.php
new file mode 100644
index 00000000..92624a0b
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/LegacyFormatter.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use MediaWiki\Logger\LegacyLogger;
+use Monolog\Formatter\NormalizerFormatter;
+
+/**
+ * Log message formatter that mimics the legacy log message formatting of
+ * `wfDebug`, `wfDebugLog`, `wfLogDBError` and `wfErrorLog` global functions by
+ * delegating the formatting to \MediaWiki\Logger\LegacyLogger.
+ *
+ * @since 1.25
+ * @copyright © 2013 Wikimedia Foundation and contributors
+ * @see \MediaWiki\Logger\LegacyLogger
+ */
+class LegacyFormatter extends NormalizerFormatter {
+
+ public function __construct() {
+ parent::__construct( 'c' );
+ }
+
+ public function format( array $record ) {
+ $normalized = parent::format( $record );
+ return LegacyLogger::format(
+ $normalized['channel'], $normalized['message'], $normalized
+ );
+ }
+}
diff --git a/www/wiki/includes/debug/logger/monolog/LegacyHandler.php b/www/wiki/includes/debug/logger/monolog/LegacyHandler.php
new file mode 100644
index 00000000..dbeb1369
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/LegacyHandler.php
@@ -0,0 +1,236 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use LogicException;
+use MediaWiki\Logger\LegacyLogger;
+use Monolog\Handler\AbstractProcessingHandler;
+use Monolog\Logger;
+use UnexpectedValueException;
+
+/**
+ * Log handler that replicates the behavior of MediaWiki's wfErrorLog()
+ * logging service. Log output can be directed to a local file, a PHP stream,
+ * or a udp2log server.
+ *
+ * For udp2log output, the stream specification must have the form:
+ * "udp://HOST:PORT[/PREFIX]"
+ * where:
+ * - HOST: IPv4, IPv6 or hostname
+ * - PORT: server port
+ * - PREFIX: optional (but recommended) prefix telling udp2log how to route
+ * the log event. The special prefix "{channel}" will use the log event's
+ * channel as the prefix value.
+ *
+ * When not targeting a udp2log stream this class will act as a drop-in
+ * replacement for \Monolog\Handler\StreamHandler.
+ *
+ * @since 1.25
+ * @copyright © 2013 Wikimedia Foundation and contributors
+ */
+class LegacyHandler extends AbstractProcessingHandler {
+
+ /**
+ * Log sink descriptor
+ * @var string $uri
+ */
+ protected $uri;
+
+ /**
+ * Filter log events using legacy rules
+ * @var bool $useLegacyFilter
+ */
+ protected $useLegacyFilter;
+
+ /**
+ * Log sink
+ * @var resource $sink
+ */
+ protected $sink;
+
+ /**
+ * @var string $error
+ */
+ protected $error;
+
+ /**
+ * @var string $host
+ */
+ protected $host;
+
+ /**
+ * @var int $port
+ */
+ protected $port;
+
+ /**
+ * @var string $prefix
+ */
+ protected $prefix;
+
+ /**
+ * @param string $stream Stream URI
+ * @param bool $useLegacyFilter Filter log events using legacy rules
+ * @param int $level Minimum logging level that will trigger handler
+ * @param bool $bubble Can handled meesages bubble up the handler stack?
+ */
+ public function __construct(
+ $stream,
+ $useLegacyFilter = false,
+ $level = Logger::DEBUG,
+ $bubble = true
+ ) {
+ parent::__construct( $level, $bubble );
+ $this->uri = $stream;
+ $this->useLegacyFilter = $useLegacyFilter;
+ }
+
+ /**
+ * Open the log sink described by our stream URI.
+ */
+ protected function openSink() {
+ if ( !$this->uri ) {
+ throw new LogicException(
+ 'Missing stream uri, the stream can not be opened.' );
+ }
+ $this->error = null;
+ set_error_handler( [ $this, 'errorTrap' ] );
+
+ if ( substr( $this->uri, 0, 4 ) == 'udp:' ) {
+ $parsed = parse_url( $this->uri );
+ if ( !isset( $parsed['host'] ) ) {
+ throw new UnexpectedValueException( sprintf(
+ 'Udp transport "%s" must specify a host', $this->uri
+ ) );
+ }
+ if ( !isset( $parsed['port'] ) ) {
+ throw new UnexpectedValueException( sprintf(
+ 'Udp transport "%s" must specify a port', $this->uri
+ ) );
+ }
+
+ $this->host = $parsed['host'];
+ $this->port = $parsed['port'];
+ $this->prefix = '';
+
+ if ( isset( $parsed['path'] ) ) {
+ $this->prefix = ltrim( $parsed['path'], '/' );
+ }
+
+ if ( filter_var( $this->host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
+ $domain = AF_INET6;
+
+ } else {
+ $domain = AF_INET;
+ }
+
+ $this->sink = socket_create( $domain, SOCK_DGRAM, SOL_UDP );
+
+ } else {
+ $this->sink = fopen( $this->uri, 'a' );
+ }
+ restore_error_handler();
+
+ if ( !is_resource( $this->sink ) ) {
+ $this->sink = null;
+ throw new UnexpectedValueException( sprintf(
+ 'The stream or file "%s" could not be opened: %s',
+ $this->uri, $this->error
+ ) );
+ }
+ }
+
+ /**
+ * Custom error handler.
+ * @param int $code Error number
+ * @param string $msg Error message
+ */
+ protected function errorTrap( $code, $msg ) {
+ $this->error = $msg;
+ }
+
+ /**
+ * Should we use UDP to send messages to the sink?
+ * @return bool
+ */
+ protected function useUdp() {
+ return $this->host !== null;
+ }
+
+ protected function write( array $record ) {
+ if ( $this->useLegacyFilter &&
+ !LegacyLogger::shouldEmit(
+ $record['channel'], $record['message'],
+ $record['level'], $record
+ ) ) {
+ // Do not write record if we are enforcing legacy rules and they
+ // do not pass this message. This used to be done in isHandling(),
+ // but Monolog 1.12.0 made a breaking change that removed access
+ // to the needed channel and context information.
+ return;
+ }
+
+ if ( $this->sink === null ) {
+ $this->openSink();
+ }
+
+ $text = (string)$record['formatted'];
+ if ( $this->useUdp() ) {
+ // Clean it up for the multiplexer
+ if ( $this->prefix !== '' ) {
+ $leader = ( $this->prefix === '{channel}' ) ?
+ $record['channel'] : $this->prefix;
+ $text = preg_replace( '/^/m', "{$leader} ", $text );
+
+ // Limit to 64KB
+ if ( strlen( $text ) > 65506 ) {
+ $text = substr( $text, 0, 65506 );
+ }
+
+ if ( substr( $text, -1 ) != "\n" ) {
+ $text .= "\n";
+ }
+
+ } elseif ( strlen( $text ) > 65507 ) {
+ $text = substr( $text, 0, 65507 );
+ }
+
+ socket_sendto(
+ $this->sink, $text, strlen( $text ), 0, $this->host, $this->port
+ );
+
+ } else {
+ fwrite( $this->sink, $text );
+ }
+ }
+
+ public function close() {
+ if ( is_resource( $this->sink ) ) {
+ if ( $this->useUdp() ) {
+ socket_close( $this->sink );
+
+ } else {
+ fclose( $this->sink );
+ }
+ }
+ $this->sink = null;
+ }
+}
diff --git a/www/wiki/includes/debug/logger/monolog/LineFormatter.php b/www/wiki/includes/debug/logger/monolog/LineFormatter.php
new file mode 100644
index 00000000..cdc4da3a
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/LineFormatter.php
@@ -0,0 +1,172 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use Exception;
+use Monolog\Formatter\LineFormatter as MonologLineFormatter;
+use MWExceptionHandler;
+
+/**
+ * Formats incoming records into a one-line string.
+ *
+ * An 'exeception' in the log record's context will be treated specially.
+ * It will be output for an '%exception%' placeholder in the format and
+ * excluded from '%context%' output if the '%exception%' placeholder is
+ * present.
+ *
+ * Exceptions that are logged with this formatter will optional have their
+ * stack traces appended. If that is done, MWExceptionHandler::redactedTrace()
+ * will be used to redact the trace information.
+ *
+ * @since 1.26
+ * @copyright © 2015 Wikimedia Foundation and contributors
+ */
+class LineFormatter extends MonologLineFormatter {
+
+ /**
+ * @param string $format The format of the message
+ * @param string $dateFormat The format of the timestamp: one supported by DateTime::format
+ * @param bool $allowInlineLineBreaks Whether to allow inline line breaks in log entries
+ * @param bool $ignoreEmptyContextAndExtra
+ * @param bool $includeStacktraces
+ */
+ public function __construct(
+ $format = null, $dateFormat = null, $allowInlineLineBreaks = false,
+ $ignoreEmptyContextAndExtra = false, $includeStacktraces = false
+ ) {
+ parent::__construct(
+ $format, $dateFormat, $allowInlineLineBreaks,
+ $ignoreEmptyContextAndExtra
+ );
+ $this->includeStacktraces( $includeStacktraces );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function format( array $record ) {
+ // Drop the 'private' flag from the context
+ unset( $record['context']['private'] );
+
+ // Handle exceptions specially: pretty format and remove from context
+ // Will be output for a '%exception%' placeholder in format
+ $prettyException = '';
+ if ( isset( $record['context']['exception'] ) &&
+ strpos( $this->format, '%exception%' ) !== false
+ ) {
+ $e = $record['context']['exception'];
+ unset( $record['context']['exception'] );
+
+ if ( $e instanceof Exception ) {
+ $prettyException = $this->normalizeException( $e );
+ } elseif ( is_array( $e ) ) {
+ $prettyException = $this->normalizeExceptionArray( $e );
+ } else {
+ $prettyException = $this->stringify( $e );
+ }
+ }
+
+ $output = parent::format( $record );
+
+ if ( strpos( $output, '%exception%' ) !== false ) {
+ $output = str_replace( '%exception%', $prettyException, $output );
+ }
+ return $output;
+ }
+
+ /**
+ * Convert an Exception to a string.
+ *
+ * @param Exception $e
+ * @return string
+ */
+ protected function normalizeException( $e ) {
+ return $this->normalizeExceptionArray( $this->exceptionAsArray( $e ) );
+ }
+
+ /**
+ * Convert an exception to an array of structured data.
+ *
+ * @param Exception $e
+ * @return array
+ */
+ protected function exceptionAsArray( Exception $e ) {
+ $out = [
+ 'class' => get_class( $e ),
+ 'message' => $e->getMessage(),
+ 'code' => $e->getCode(),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'trace' => MWExceptionHandler::redactTrace( $e->getTrace() ),
+ ];
+
+ $prev = $e->getPrevious();
+ if ( $prev ) {
+ $out['previous'] = $this->exceptionAsArray( $prev );
+ }
+
+ return $out;
+ }
+
+ /**
+ * Convert an array of Exception data to a string.
+ *
+ * @param array $e
+ * @return string
+ */
+ protected function normalizeExceptionArray( array $e ) {
+ $defaults = [
+ 'class' => 'Unknown',
+ 'file' => 'unknown',
+ 'line' => null,
+ 'message' => 'unknown',
+ 'trace' => [],
+ ];
+ $e = array_merge( $defaults, $e );
+
+ $str = "\n[Exception {$e['class']}] (" .
+ "{$e['file']}:{$e['line']}) {$e['message']}";
+
+ if ( $this->includeStacktraces && $e['trace'] ) {
+ $str .= "\n" .
+ MWExceptionHandler::prettyPrintTrace( $e['trace'], ' ' );
+ }
+
+ if ( isset( $e['previous'] ) ) {
+ $prev = $e['previous'];
+ while ( $prev ) {
+ $prev = array_merge( $defaults, $prev );
+ $str .= "\nCaused by: [Exception {$prev['class']}] (" .
+ "{$prev['file']}:{$prev['line']}) {$prev['message']}";
+
+ if ( $this->includeStacktraces && $prev['trace'] ) {
+ $str .= "\n" .
+ MWExceptionHandler::prettyPrintTrace(
+ $prev['trace'], ' '
+ );
+ }
+
+ $prev = isset( $prev['previous'] ) ? $prev['previous'] : null;
+ }
+ }
+ return $str;
+ }
+}
diff --git a/www/wiki/includes/debug/logger/monolog/LogstashFormatter.php b/www/wiki/includes/debug/logger/monolog/LogstashFormatter.php
new file mode 100644
index 00000000..09ed7555
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/LogstashFormatter.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace MediaWiki\Logger\Monolog;
+
+/**
+ * LogstashFormatter squashes the base message array and the context and extras subarrays into one.
+ * This can result in unfortunately named context fields overwriting other data (T145133).
+ * This class modifies the standard LogstashFormatter to rename such fields and flag the message.
+ * Also changes exception JSON-ification which is done poorly by the standard class.
+ *
+ * Compatible with Monolog 1.x only.
+ *
+ * @since 1.29
+ */
+class LogstashFormatter extends \Monolog\Formatter\LogstashFormatter {
+ /** @var array Keys which should not be used in log context */
+ protected $reservedKeys = [
+ // from LogstashFormatter
+ 'message', 'channel', 'level', 'type',
+ // from WebProcessor
+ 'url', 'ip', 'http_method', 'server', 'referrer',
+ // from WikiProcessor
+ 'host', 'wiki', 'reqId', 'mwversion',
+ // from config magic
+ 'normalized_message',
+ ];
+
+ /**
+ * Prevent key conflicts
+ * @param array $record
+ * @return array
+ */
+ protected function formatV0( array $record ) {
+ if ( $this->contextPrefix ) {
+ return parent::formatV0( $record );
+ }
+
+ $context = !empty( $record['context'] ) ? $record['context'] : [];
+ $record['context'] = [];
+ $formatted = parent::formatV0( $record );
+
+ $formatted['@fields'] = $this->fixKeyConflicts( $formatted['@fields'], $context );
+ return $formatted;
+ }
+
+ /**
+ * Prevent key conflicts
+ * @param array $record
+ * @return array
+ */
+ protected function formatV1( array $record ) {
+ if ( $this->contextPrefix ) {
+ return parent::formatV1( $record );
+ }
+
+ $context = !empty( $record['context'] ) ? $record['context'] : [];
+ $record['context'] = [];
+ $formatted = parent::formatV1( $record );
+
+ $formatted = $this->fixKeyConflicts( $formatted, $context );
+ return $formatted;
+ }
+
+ /**
+ * Check whether some context field would overwrite another message key. If so, rename
+ * and flag.
+ * @param array $fields Fields to be sent to logstash
+ * @param array $context Copy of the original $record['context']
+ * @return array Updated version of $fields
+ */
+ protected function fixKeyConflicts( array $fields, array $context ) {
+ foreach ( $context as $key => $val ) {
+ if (
+ in_array( $key, $this->reservedKeys, true ) &&
+ isset( $fields[$key] ) && $fields[$key] !== $val
+ ) {
+ $fields['logstash_formatter_key_conflict'][] = $key;
+ $key = 'c_' . $key;
+ }
+ $fields[$key] = $val;
+ }
+ return $fields;
+ }
+
+ /**
+ * Use a more user-friendly trace format than NormalizerFormatter
+ * @param \Exception|\Throwable $e
+ * @return array
+ */
+ protected function normalizeException( $e ) {
+ if ( !$e instanceof \Exception && !$e instanceof \Throwable ) {
+ throw new \InvalidArgumentException( 'Exception/Throwable expected, got '
+ . gettype( $e ) . ' / ' . get_class( $e ) );
+ }
+
+ $data = [
+ 'class' => get_class( $e ),
+ 'message' => $e->getMessage(),
+ 'code' => $e->getCode(),
+ 'file' => $e->getFile() . ':' . $e->getLine(),
+ 'trace' => \MWExceptionHandler::getRedactedTraceAsString( $e ),
+ ];
+
+ $previous = $e->getPrevious();
+ if ( $previous ) {
+ $data['previous'] = $this->normalizeException( $previous );
+ }
+
+ return $data;
+ }
+}
diff --git a/www/wiki/includes/debug/logger/monolog/SyslogHandler.php b/www/wiki/includes/debug/logger/monolog/SyslogHandler.php
new file mode 100644
index 00000000..780ea94d
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/SyslogHandler.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+use Monolog\Handler\SyslogUdpHandler;
+use Monolog\Logger;
+
+/**
+ * Log handler that supports sending log events to a syslog server using RFC
+ * 3164 formatted UDP packets.
+ *
+ * Monolog's SyslogUdpHandler creates a partial RFC 5424 header (PRI and
+ * VERSION) and relies on the associated formatter to complete the header and
+ * message payload. This makes using it with a fixed format formatter like
+ * \Monolog\Formatter\LogstashFormatter impossible. Additionally, the
+ * direct syslog input for Logstash only handles RFC 3164 syslog packets.
+ *
+ * This Handler should work with any Formatter. The formatted message will be
+ * prepended with an RFC 3164 message header and a partial message body. The
+ * resulting packet will looks something like:
+ *
+ * <PRI>DATETIME HOSTNAME PROGRAM: MESSAGE
+ *
+ * This format works as input to rsyslog and can also be processed by the
+ * default Logstash syslog input handler.
+ *
+ * @since 1.25
+ * @copyright © 2015 Wikimedia Foundation and contributors
+ */
+class SyslogHandler extends SyslogUdpHandler {
+
+ /**
+ * @var string $appname
+ */
+ private $appname;
+
+ /**
+ * @var string $hostname
+ */
+ private $hostname;
+
+ /**
+ * @param string $appname Application name to report to syslog
+ * @param string $host Syslog host
+ * @param int $port Syslog port
+ * @param int $facility Syslog message facility
+ * @param string $level The minimum logging level at which this handler
+ * will be triggered
+ * @param bool $bubble Whether the messages that are handled can bubble up
+ * the stack or not
+ */
+ public function __construct(
+ $appname,
+ $host,
+ $port = 514,
+ $facility = LOG_USER,
+ $level = Logger::DEBUG,
+ $bubble = true
+ ) {
+ parent::__construct( $host, $port, $facility, $level, $bubble );
+ $this->appname = $appname;
+ $this->hostname = php_uname( 'n' );
+ }
+
+ protected function makeCommonSyslogHeader( $severity ) {
+ $pri = $severity + $this->facility;
+
+ // Goofy date format courtesy of RFC 3164 :(
+ // RFC 3164 actually specifies that the day of month should be space
+ // padded rather than unpadded but this seems to work with rsyslog and
+ // Logstash.
+ $timestamp = date( 'M j H:i:s' );
+
+ return "<{$pri}>{$timestamp} {$this->hostname} {$this->appname}: ";
+ }
+}
diff --git a/www/wiki/includes/debug/logger/monolog/WikiProcessor.php b/www/wiki/includes/debug/logger/monolog/WikiProcessor.php
new file mode 100644
index 00000000..e39a2c30
--- /dev/null
+++ b/www/wiki/includes/debug/logger/monolog/WikiProcessor.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Logger\Monolog;
+
+/**
+ * Annotate log records with request-global metadata, such as the hostname,
+ * wiki / request ID, and MediaWiki version.
+ *
+ * @since 1.25
+ * @copyright © 2013 Wikimedia Foundation and contributors
+ */
+class WikiProcessor {
+
+ /**
+ * @param array $record
+ * @return array
+ */
+ public function __invoke( array $record ) {
+ global $wgVersion;
+ $record['extra']['host'] = wfHostname();
+ $record['extra']['wiki'] = wfWikiID();
+ $record['extra']['mwversion'] = $wgVersion;
+ $record['extra']['reqId'] = \WebRequest::getRequestId();
+ if ( PHP_SAPI === 'cli' && isset( $_SERVER['argv'] ) ) {
+ $record['extra']['cli_argv'] = implode( ' ', $_SERVER['argv'] );
+ }
+ return $record;
+ }
+
+}
diff --git a/www/wiki/includes/deferred/AtomicSectionUpdate.php b/www/wiki/includes/deferred/AtomicSectionUpdate.php
new file mode 100644
index 00000000..8b62989b
--- /dev/null
+++ b/www/wiki/includes/deferred/AtomicSectionUpdate.php
@@ -0,0 +1,48 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Deferrable Update for closure/callback updates via IDatabase::doAtomicSection()
+ * @since 1.27
+ */
+class AtomicSectionUpdate implements DeferrableUpdate, DeferrableCallback {
+ /** @var IDatabase */
+ private $dbw;
+ /** @var string */
+ private $fname;
+ /** @var callable|null */
+ private $callback;
+
+ /**
+ * @param IDatabase $dbw
+ * @param string $fname Caller name (usually __METHOD__)
+ * @param callable $callback
+ * @see IDatabase::doAtomicSection()
+ */
+ public function __construct( IDatabase $dbw, $fname, callable $callback ) {
+ $this->dbw = $dbw;
+ $this->fname = $fname;
+ $this->callback = $callback;
+
+ if ( $this->dbw->trxLevel() ) {
+ $this->dbw->onTransactionResolution( [ $this, 'cancelOnRollback' ], $fname );
+ }
+ }
+
+ public function doUpdate() {
+ if ( $this->callback ) {
+ $this->dbw->doAtomicSection( $this->fname, $this->callback );
+ }
+ }
+
+ public function cancelOnRollback( $trigger ) {
+ if ( $trigger === IDatabase::TRIGGER_ROLLBACK ) {
+ $this->callback = null;
+ }
+ }
+
+ public function getOrigin() {
+ return $this->fname;
+ }
+}
diff --git a/www/wiki/includes/deferred/AutoCommitUpdate.php b/www/wiki/includes/deferred/AutoCommitUpdate.php
new file mode 100644
index 00000000..f9297af5
--- /dev/null
+++ b/www/wiki/includes/deferred/AutoCommitUpdate.php
@@ -0,0 +1,62 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Deferrable Update for closure/callback updates that should use auto-commit mode
+ * @since 1.28
+ */
+class AutoCommitUpdate implements DeferrableUpdate, DeferrableCallback {
+ /** @var IDatabase */
+ private $dbw;
+ /** @var string */
+ private $fname;
+ /** @var callable|null */
+ private $callback;
+
+ /**
+ * @param IDatabase $dbw
+ * @param string $fname Caller name (usually __METHOD__)
+ * @param callable $callback Callback that takes (IDatabase, method name string)
+ */
+ public function __construct( IDatabase $dbw, $fname, callable $callback ) {
+ $this->dbw = $dbw;
+ $this->fname = $fname;
+ $this->callback = $callback;
+
+ if ( $this->dbw->trxLevel() ) {
+ $this->dbw->onTransactionResolution( [ $this, 'cancelOnRollback' ], $fname );
+ }
+ }
+
+ public function doUpdate() {
+ if ( !$this->callback ) {
+ return;
+ }
+
+ $autoTrx = $this->dbw->getFlag( DBO_TRX );
+ $this->dbw->clearFlag( DBO_TRX );
+ try {
+ /** @var Exception $e */
+ $e = null;
+ call_user_func_array( $this->callback, [ $this->dbw, $this->fname ] );
+ } catch ( Exception $e ) {
+ }
+ if ( $autoTrx ) {
+ $this->dbw->setFlag( DBO_TRX );
+ }
+ if ( $e ) {
+ throw $e;
+ }
+ }
+
+ public function cancelOnRollback( $trigger ) {
+ if ( $trigger === IDatabase::TRIGGER_ROLLBACK ) {
+ $this->callback = null;
+ }
+ }
+
+ public function getOrigin() {
+ return $this->fname;
+ }
+}
diff --git a/www/wiki/includes/deferred/CallableUpdate.php b/www/wiki/includes/deferred/CallableUpdate.php
new file mode 100644
index 00000000..4b19c200
--- /dev/null
+++ b/www/wiki/includes/deferred/CallableUpdate.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * Deferrable Update for closure/callback
+ */
+class MWCallableUpdate implements DeferrableUpdate {
+ /** @var Closure|callable */
+ private $callback;
+
+ /**
+ * @param callable $callback
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $callback ) {
+ if ( !is_callable( $callback ) ) {
+ throw new InvalidArgumentException( 'Not a valid callback/closure!' );
+ }
+ $this->callback = $callback;
+ }
+
+ public function doUpdate() {
+ call_user_func( $this->callback );
+ }
+}
diff --git a/www/wiki/includes/deferred/CdnCacheUpdate.php b/www/wiki/includes/deferred/CdnCacheUpdate.php
new file mode 100644
index 00000000..7fafc0eb
--- /dev/null
+++ b/www/wiki/includes/deferred/CdnCacheUpdate.php
@@ -0,0 +1,296 @@
+<?php
+/**
+ * CDN cache purging.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+use Wikimedia\Assert\Assert;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Handles purging appropriate CDN URLs given a title (or titles)
+ * @ingroup Cache
+ */
+class CdnCacheUpdate implements DeferrableUpdate, MergeableUpdate {
+ /** @var string[] Collection of URLs to purge */
+ protected $urls = [];
+
+ /**
+ * @param string[] $urlArr Collection of URLs to purge
+ */
+ public function __construct( array $urlArr ) {
+ $this->urls = $urlArr;
+ }
+
+ public function merge( MergeableUpdate $update ) {
+ /** @var CdnCacheUpdate $update */
+ Assert::parameterType( __CLASS__, $update, '$update' );
+
+ $this->urls = array_merge( $this->urls, $update->urls );
+ }
+
+ /**
+ * Create an update object from an array of Title objects, or a TitleArray object
+ *
+ * @param Traversable|Title[] $titles
+ * @param string[] $urlArr
+ * @return CdnCacheUpdate
+ */
+ public static function newFromTitles( $titles, $urlArr = [] ) {
+ ( new LinkBatch( $titles ) )->execute();
+ /** @var Title $title */
+ foreach ( $titles as $title ) {
+ $urlArr = array_merge( $urlArr, $title->getCdnUrls() );
+ }
+
+ return new CdnCacheUpdate( $urlArr );
+ }
+
+ /**
+ * @param Title $title
+ * @return CdnCacheUpdate
+ * @deprecated since 1.27
+ */
+ public static function newSimplePurge( Title $title ) {
+ return new CdnCacheUpdate( $title->getCdnUrls() );
+ }
+
+ /**
+ * Purges the list of URLs passed to the constructor.
+ */
+ public function doUpdate() {
+ global $wgCdnReboundPurgeDelay;
+
+ self::purge( $this->urls );
+
+ if ( $wgCdnReboundPurgeDelay > 0 ) {
+ JobQueueGroup::singleton()->lazyPush( new CdnPurgeJob(
+ Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __CLASS__ ),
+ [
+ 'urls' => $this->urls,
+ 'jobReleaseTimestamp' => time() + $wgCdnReboundPurgeDelay
+ ]
+ ) );
+ }
+ }
+
+ /**
+ * Purges a list of CDN nodes defined in $wgSquidServers.
+ * $urlArr should contain the full URLs to purge as values
+ * (example: $urlArr[] = 'http://my.host/something')
+ *
+ * @param string[] $urlArr List of full URLs to purge
+ */
+ public static function purge( array $urlArr ) {
+ global $wgSquidServers, $wgHTCPRouting;
+
+ if ( !$urlArr ) {
+ return;
+ }
+
+ // Remove duplicate URLs from list
+ $urlArr = array_unique( $urlArr );
+
+ wfDebugLog( 'squid', __METHOD__ . ': ' . implode( ' ', $urlArr ) );
+
+ // Reliably broadcast the purge to all edge nodes
+ $relayer = MediaWikiServices::getInstance()->getEventRelayerGroup()
+ ->getRelayer( 'cdn-url-purges' );
+ $ts = microtime( true );
+ $relayer->notifyMulti(
+ 'cdn-url-purges',
+ array_map(
+ function ( $url ) use ( $ts ) {
+ return [
+ 'url' => $url,
+ 'timestamp' => $ts,
+ ];
+ },
+ $urlArr
+ )
+ );
+
+ // Send lossy UDP broadcasting if enabled
+ if ( $wgHTCPRouting ) {
+ self::HTCPPurge( $urlArr );
+ }
+
+ // Do direct server purges if enabled (this does not scale very well)
+ if ( $wgSquidServers ) {
+ // Maximum number of parallel connections per squid
+ $maxSocketsPerSquid = 8;
+ // Number of requests to send per socket
+ // 400 seems to be a good tradeoff, opening a socket takes a while
+ $urlsPerSocket = 400;
+ $socketsPerSquid = ceil( count( $urlArr ) / $urlsPerSocket );
+ if ( $socketsPerSquid > $maxSocketsPerSquid ) {
+ $socketsPerSquid = $maxSocketsPerSquid;
+ }
+
+ $pool = new SquidPurgeClientPool;
+ $chunks = array_chunk( $urlArr, ceil( count( $urlArr ) / $socketsPerSquid ) );
+ foreach ( $wgSquidServers as $server ) {
+ foreach ( $chunks as $chunk ) {
+ $client = new SquidPurgeClient( $server );
+ foreach ( $chunk as $url ) {
+ $client->queuePurge( $url );
+ }
+ $pool->addClient( $client );
+ }
+ }
+
+ $pool->run();
+ }
+ }
+
+ /**
+ * Send Hyper Text Caching Protocol (HTCP) CLR requests.
+ *
+ * @throws MWException
+ * @param string[] $urlArr Collection of URLs to purge
+ */
+ private static function HTCPPurge( array $urlArr ) {
+ global $wgHTCPRouting, $wgHTCPMulticastTTL;
+
+ // HTCP CLR operation
+ $htcpOpCLR = 4;
+
+ // @todo FIXME: PHP doesn't support these socket constants (include/linux/in.h)
+ if ( !defined( "IPPROTO_IP" ) ) {
+ define( "IPPROTO_IP", 0 );
+ define( "IP_MULTICAST_LOOP", 34 );
+ define( "IP_MULTICAST_TTL", 33 );
+ }
+
+ // pfsockopen doesn't work because we need set_sock_opt
+ $conn = socket_create( AF_INET, SOCK_DGRAM, SOL_UDP );
+ if ( !$conn ) {
+ $errstr = socket_strerror( socket_last_error() );
+ wfDebugLog( 'squid', __METHOD__ .
+ ": Error opening UDP socket: $errstr" );
+
+ return;
+ }
+
+ // Set socket options
+ socket_set_option( $conn, IPPROTO_IP, IP_MULTICAST_LOOP, 0 );
+ if ( $wgHTCPMulticastTTL != 1 ) {
+ // Set multicast time to live (hop count) option on socket
+ socket_set_option( $conn, IPPROTO_IP, IP_MULTICAST_TTL,
+ $wgHTCPMulticastTTL );
+ }
+
+ // Get sequential trx IDs for packet loss counting
+ $ids = UIDGenerator::newSequentialPerNodeIDs(
+ 'squidhtcppurge', 32, count( $urlArr ), UIDGenerator::QUICK_VOLATILE
+ );
+
+ foreach ( $urlArr as $url ) {
+ if ( !is_string( $url ) ) {
+ throw new MWException( 'Bad purge URL' );
+ }
+ $url = self::expand( $url );
+ $conf = self::getRuleForURL( $url, $wgHTCPRouting );
+ if ( !$conf ) {
+ wfDebugLog( 'squid', __METHOD__ .
+ "No HTCP rule configured for URL {$url} , skipping" );
+ continue;
+ }
+
+ if ( isset( $conf['host'] ) && isset( $conf['port'] ) ) {
+ // Normalize single entries
+ $conf = [ $conf ];
+ }
+ foreach ( $conf as $subconf ) {
+ if ( !isset( $subconf['host'] ) || !isset( $subconf['port'] ) ) {
+ throw new MWException( "Invalid HTCP rule for URL $url\n" );
+ }
+ }
+
+ // Construct a minimal HTCP request diagram
+ // as per RFC 2756
+ // Opcode 'CLR', no response desired, no auth
+ $htcpTransID = current( $ids );
+ next( $ids );
+
+ $htcpSpecifier = pack( 'na4na*na8n',
+ 4, 'HEAD', strlen( $url ), $url,
+ 8, 'HTTP/1.0', 0 );
+
+ $htcpDataLen = 8 + 2 + strlen( $htcpSpecifier );
+ $htcpLen = 4 + $htcpDataLen + 2;
+
+ // Note! Squid gets the bit order of the first
+ // word wrong, wrt the RFC. Apparently no other
+ // implementation exists, so adapt to Squid
+ $htcpPacket = pack( 'nxxnCxNxxa*n',
+ $htcpLen, $htcpDataLen, $htcpOpCLR,
+ $htcpTransID, $htcpSpecifier, 2 );
+
+ wfDebugLog( 'squid', __METHOD__ .
+ "Purging URL $url via HTCP" );
+ foreach ( $conf as $subconf ) {
+ socket_sendto( $conn, $htcpPacket, $htcpLen, 0,
+ $subconf['host'], $subconf['port'] );
+ }
+ }
+ }
+
+ /**
+ * Expand local URLs to fully-qualified URLs using the internal protocol
+ * and host defined in $wgInternalServer. Input that's already fully-
+ * qualified will be passed through unchanged.
+ *
+ * This is used to generate purge URLs that may be either local to the
+ * main wiki or include a non-native host, such as images hosted on a
+ * second internal server.
+ *
+ * Client functions should not need to call this.
+ *
+ * @param string $url
+ * @return string
+ */
+ public static function expand( $url ) {
+ return wfExpandUrl( $url, PROTO_INTERNAL );
+ }
+
+ /**
+ * Find the HTCP routing rule to use for a given URL.
+ * @param string $url URL to match
+ * @param array $rules Array of rules, see $wgHTCPRouting for format and behavior
+ * @return mixed Element of $rules that matched, or false if nothing matched
+ */
+ private static function getRuleForURL( $url, $rules ) {
+ foreach ( $rules as $regex => $routing ) {
+ if ( $regex === '' || preg_match( $regex, $url ) ) {
+ return $routing;
+ }
+ }
+
+ return false;
+ }
+}
+
+/**
+ * @deprecated since 1.27
+ */
+class SquidUpdate extends CdnCacheUpdate {
+ // Keep class name for b/c
+}
diff --git a/www/wiki/includes/deferred/DataUpdate.php b/www/wiki/includes/deferred/DataUpdate.php
new file mode 100644
index 00000000..d2d8bd7a
--- /dev/null
+++ b/www/wiki/includes/deferred/DataUpdate.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * Base code for update jobs that do something with some secondary
+ * data extracted from article.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Abstract base class for update jobs that do something with some secondary
+ * data extracted from article.
+ */
+abstract class DataUpdate implements DeferrableUpdate {
+ /** @var mixed Result from LBFactory::getEmptyTransactionTicket() */
+ protected $ticket;
+
+ public function __construct() {
+ // noop
+ }
+
+ /**
+ * @param mixed $ticket Result of getEmptyTransactionTicket()
+ * @since 1.28
+ */
+ public function setTransactionTicket( $ticket ) {
+ $this->ticket = $ticket;
+ }
+
+ /**
+ * Convenience method, calls doUpdate() on every DataUpdate in the array.
+ *
+ * @param DataUpdate[] $updates A list of DataUpdate instances
+ * @throws Exception
+ * @deprecated Since 1.28 Use DeferredUpdates::execute()
+ */
+ public static function runUpdates( array $updates ) {
+ foreach ( $updates as $update ) {
+ $update->doUpdate();
+ }
+ }
+}
diff --git a/www/wiki/includes/deferred/DeferrableCallback.php b/www/wiki/includes/deferred/DeferrableCallback.php
new file mode 100644
index 00000000..2eb0d5df
--- /dev/null
+++ b/www/wiki/includes/deferred/DeferrableCallback.php
@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * Callback wrapper that has an originating method
+ *
+ * @since 1.28
+ */
+interface DeferrableCallback {
+ /**
+ * @return string Originating method name
+ */
+ function getOrigin();
+}
diff --git a/www/wiki/includes/deferred/DeferrableUpdate.php b/www/wiki/includes/deferred/DeferrableUpdate.php
new file mode 100644
index 00000000..5f4d8210
--- /dev/null
+++ b/www/wiki/includes/deferred/DeferrableUpdate.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * Interface that deferrable updates should implement. Basically required so we
+ * can validate input on DeferredUpdates::addUpdate()
+ *
+ * @since 1.19
+ */
+interface DeferrableUpdate {
+ /**
+ * Perform the actual work
+ */
+ function doUpdate();
+}
diff --git a/www/wiki/includes/deferred/DeferredUpdates.php b/www/wiki/includes/deferred/DeferredUpdates.php
new file mode 100644
index 00000000..e8e250b5
--- /dev/null
+++ b/www/wiki/includes/deferred/DeferredUpdates.php
@@ -0,0 +1,377 @@
+<?php
+/**
+ * Interface and manager for deferred updates.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\LBFactory;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Class for managing the deferred updates
+ *
+ * In web request mode, deferred updates can be run at the end of the request, either before or
+ * after the HTTP response has been sent. In either case, they run after the DB commit step. If
+ * an update runs after the response is sent, it will not block clients. If sent before, it will
+ * run synchronously. These two modes are defined via PRESEND and POSTSEND constants, the latter
+ * being the default for addUpdate() and addCallableUpdate().
+ *
+ * Updates that work through this system will be more likely to complete by the time the client
+ * makes their next request after this one than with the JobQueue system.
+ *
+ * In CLI mode, updates run immediately if no DB writes are pending. Otherwise, they run when:
+ * - a) Any waitForReplication() call if no writes are pending on any DB
+ * - b) A commit happens on Maintenance::getDB( DB_MASTER ) if no writes are pending on any DB
+ * - c) EnqueueableDataUpdate tasks may enqueue on commit of Maintenance::getDB( DB_MASTER )
+ * - d) At the completion of Maintenance::execute()
+ *
+ * When updates are deferred, they go into one two FIFO "top-queues" (one for pre-send and one
+ * for post-send). Updates enqueued *during* doUpdate() of a "top" update go into the "sub-queue"
+ * for that update. After that method finishes, the sub-queue is run until drained. This continues
+ * for each top-queue job until the entire top queue is drained. This happens for the pre-send
+ * top-queue, and later on, the post-send top-queue, in execute().
+ *
+ * @since 1.19
+ */
+class DeferredUpdates {
+ /** @var DeferrableUpdate[] Updates to be deferred until before request end */
+ private static $preSendUpdates = [];
+ /** @var DeferrableUpdate[] Updates to be deferred until after request end */
+ private static $postSendUpdates = [];
+
+ const ALL = 0; // all updates; in web requests, use only after flushing the output buffer
+ const PRESEND = 1; // for updates that should run before flushing output buffer
+ const POSTSEND = 2; // for updates that should run after flushing output buffer
+
+ const BIG_QUEUE_SIZE = 100;
+
+ /** @var array|null Information about the current execute() call or null if not running */
+ private static $executeContext;
+
+ /**
+ * Add an update to the deferred list to be run later by execute()
+ *
+ * In CLI mode, callback magic will also be used to run updates when safe
+ *
+ * @param DeferrableUpdate $update Some object that implements doUpdate()
+ * @param int $stage DeferredUpdates constant (PRESEND or POSTSEND) (since 1.27)
+ */
+ public static function addUpdate( DeferrableUpdate $update, $stage = self::POSTSEND ) {
+ global $wgCommandLineMode;
+
+ if ( self::$executeContext && self::$executeContext['stage'] >= $stage ) {
+ // This is a sub-DeferredUpdate; run it right after its parent update.
+ // Also, while post-send updates are running, push any "pre-send" jobs to the
+ // active post-send queue to make sure they get run this round (or at all).
+ self::$executeContext['subqueue'][] = $update;
+
+ return;
+ }
+
+ if ( $stage === self::PRESEND ) {
+ self::push( self::$preSendUpdates, $update );
+ } else {
+ self::push( self::$postSendUpdates, $update );
+ }
+
+ // Try to run the updates now if in CLI mode and no transaction is active.
+ // This covers scripts that don't/barely use the DB but make updates to other stores.
+ if ( $wgCommandLineMode ) {
+ self::tryOpportunisticExecute( 'run' );
+ }
+ }
+
+ /**
+ * Add a callable update. In a lot of cases, we just need a callback/closure,
+ * defining a new DeferrableUpdate object is not necessary
+ *
+ * @see MWCallableUpdate::__construct()
+ *
+ * @param callable $callable
+ * @param int $stage DeferredUpdates constant (PRESEND or POSTSEND) (since 1.27)
+ * @param IDatabase|null $dbw Abort if this DB is rolled back [optional] (since 1.28)
+ */
+ public static function addCallableUpdate(
+ $callable, $stage = self::POSTSEND, IDatabase $dbw = null
+ ) {
+ self::addUpdate( new MWCallableUpdate( $callable, wfGetCaller(), $dbw ), $stage );
+ }
+
+ /**
+ * Do any deferred updates and clear the list
+ *
+ * @param string $mode Use "enqueue" to use the job queue when possible [Default: "run"]
+ * @param int $stage DeferredUpdates constant (PRESEND, POSTSEND, or ALL) (since 1.27)
+ */
+ public static function doUpdates( $mode = 'run', $stage = self::ALL ) {
+ $stageEffective = ( $stage === self::ALL ) ? self::POSTSEND : $stage;
+
+ if ( $stage === self::ALL || $stage === self::PRESEND ) {
+ self::execute( self::$preSendUpdates, $mode, $stageEffective );
+ }
+
+ if ( $stage === self::ALL || $stage == self::POSTSEND ) {
+ self::execute( self::$postSendUpdates, $mode, $stageEffective );
+ }
+ }
+
+ /**
+ * @param bool $value Whether to just immediately run updates in addUpdate()
+ * @since 1.28
+ * @deprecated 1.29 Causes issues in Web-executed jobs - see T165714 and T100085.
+ */
+ public static function setImmediateMode( $value ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ }
+
+ /**
+ * @param DeferrableUpdate[] $queue
+ * @param DeferrableUpdate $update
+ */
+ private static function push( array &$queue, DeferrableUpdate $update ) {
+ if ( $update instanceof MergeableUpdate ) {
+ $class = get_class( $update ); // fully-qualified class
+ if ( isset( $queue[$class] ) ) {
+ /** @var MergeableUpdate $existingUpdate */
+ $existingUpdate = $queue[$class];
+ $existingUpdate->merge( $update );
+ } else {
+ $queue[$class] = $update;
+ }
+ } else {
+ $queue[] = $update;
+ }
+ }
+
+ /**
+ * Immediately run/queue a list of updates
+ *
+ * @param DeferrableUpdate[] &$queue List of DeferrableUpdate objects
+ * @param string $mode Use "enqueue" to use the job queue when possible
+ * @param int $stage Class constant (PRESEND, POSTSEND) (since 1.28)
+ * @throws ErrorPageError Happens on top-level calls
+ * @throws Exception Happens on second-level calls
+ */
+ protected static function execute( array &$queue, $mode, $stage ) {
+ $services = MediaWikiServices::getInstance();
+ $stats = $services->getStatsdDataFactory();
+ $lbFactory = $services->getDBLoadBalancerFactory();
+ $method = RequestContext::getMain()->getRequest()->getMethod();
+
+ $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+
+ /** @var ErrorPageError $reportableError */
+ $reportableError = null;
+ /** @var DeferrableUpdate[] $updates Snapshot of queue */
+ $updates = $queue;
+
+ // Keep doing rounds of updates until none get enqueued...
+ while ( $updates ) {
+ $queue = []; // clear the queue
+
+ // Order will be DataUpdate followed by generic DeferrableUpdate tasks
+ $updatesByType = [ 'data' => [], 'generic' => [] ];
+ foreach ( $updates as $du ) {
+ if ( $du instanceof DataUpdate ) {
+ $du->setTransactionTicket( $ticket );
+ $updatesByType['data'][] = $du;
+ } else {
+ $updatesByType['generic'][] = $du;
+ }
+
+ $name = ( $du instanceof DeferrableCallback )
+ ? get_class( $du ) . '-' . $du->getOrigin()
+ : get_class( $du );
+ $stats->increment( 'deferred_updates.' . $method . '.' . $name );
+ }
+
+ // Execute all remaining tasks...
+ foreach ( $updatesByType as $updatesForType ) {
+ foreach ( $updatesForType as $update ) {
+ self::$executeContext = [ 'stage' => $stage, 'subqueue' => [] ];
+ /** @var DeferrableUpdate $update */
+ $guiError = self::runUpdate( $update, $lbFactory, $mode, $stage );
+ $reportableError = $reportableError ?: $guiError;
+ // Do the subqueue updates for $update until there are none
+ while ( self::$executeContext['subqueue'] ) {
+ $subUpdate = reset( self::$executeContext['subqueue'] );
+ $firstKey = key( self::$executeContext['subqueue'] );
+ unset( self::$executeContext['subqueue'][$firstKey] );
+
+ if ( $subUpdate instanceof DataUpdate ) {
+ $subUpdate->setTransactionTicket( $ticket );
+ }
+
+ $guiError = self::runUpdate( $subUpdate, $lbFactory, $mode, $stage );
+ $reportableError = $reportableError ?: $guiError;
+ }
+ self::$executeContext = null;
+ }
+ }
+
+ $updates = $queue; // new snapshot of queue (check for new entries)
+ }
+
+ if ( $reportableError ) {
+ throw $reportableError; // throw the first of any GUI errors
+ }
+ }
+
+ /**
+ * @param DeferrableUpdate $update
+ * @param LBFactory $lbFactory
+ * @param string $mode
+ * @param int $stage
+ * @return ErrorPageError|null
+ */
+ private static function runUpdate(
+ DeferrableUpdate $update, LBFactory $lbFactory, $mode, $stage
+ ) {
+ $guiError = null;
+ try {
+ if ( $mode === 'enqueue' && $update instanceof EnqueueableDataUpdate ) {
+ // Run only the job enqueue logic to complete the update later
+ $spec = $update->getAsJobSpecification();
+ JobQueueGroup::singleton( $spec['wiki'] )->push( $spec['job'] );
+ } else {
+ // Run the bulk of the update now
+ $fnameTrxOwner = get_class( $update ) . '::doUpdate';
+ $lbFactory->beginMasterChanges( $fnameTrxOwner );
+ $update->doUpdate();
+ $lbFactory->commitMasterChanges( $fnameTrxOwner );
+ }
+ } catch ( Exception $e ) {
+ // Reporting GUI exceptions does not work post-send
+ if ( $e instanceof ErrorPageError && $stage === self::PRESEND ) {
+ $guiError = $e;
+ }
+ MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+ }
+
+ return $guiError;
+ }
+
+ /**
+ * Run all deferred updates immediately if there are no DB writes active
+ *
+ * If $mode is 'run' but there are busy databates, EnqueueableDataUpdate
+ * tasks will be enqueued anyway for the sake of progress.
+ *
+ * @param string $mode Use "enqueue" to use the job queue when possible
+ * @return bool Whether updates were allowed to run
+ * @since 1.28
+ */
+ public static function tryOpportunisticExecute( $mode = 'run' ) {
+ // execute() loop is already running
+ if ( self::$executeContext ) {
+ return false;
+ }
+
+ // Avoiding running updates without them having outer scope
+ if ( !self::areDatabaseTransactionsActive() ) {
+ self::doUpdates( $mode );
+ return true;
+ }
+
+ if ( self::pendingUpdatesCount() >= self::BIG_QUEUE_SIZE ) {
+ // If we cannot run the updates with outer transaction context, try to
+ // at least enqueue all the updates that support queueing to job queue
+ self::$preSendUpdates = self::enqueueUpdates( self::$preSendUpdates );
+ self::$postSendUpdates = self::enqueueUpdates( self::$postSendUpdates );
+ }
+
+ return !self::pendingUpdatesCount();
+ }
+
+ /**
+ * Enqueue a job for each EnqueueableDataUpdate item and return the other items
+ *
+ * @param DeferrableUpdate[] $updates A list of deferred update instances
+ * @return DeferrableUpdate[] Remaining updates that do not support being queued
+ */
+ private static function enqueueUpdates( array $updates ) {
+ $remaining = [];
+
+ foreach ( $updates as $update ) {
+ if ( $update instanceof EnqueueableDataUpdate ) {
+ $spec = $update->getAsJobSpecification();
+ JobQueueGroup::singleton( $spec['wiki'] )->push( $spec['job'] );
+ } else {
+ $remaining[] = $update;
+ }
+ }
+
+ return $remaining;
+ }
+
+ /**
+ * @return int Number of enqueued updates
+ * @since 1.28
+ */
+ public static function pendingUpdatesCount() {
+ return count( self::$preSendUpdates ) + count( self::$postSendUpdates );
+ }
+
+ /**
+ * @param int $stage DeferredUpdates constant (PRESEND, POSTSEND, or ALL)
+ * @return DeferrableUpdate[]
+ * @since 1.29
+ */
+ public static function getPendingUpdates( $stage = self::ALL ) {
+ $updates = [];
+ if ( $stage === self::ALL || $stage === self::PRESEND ) {
+ $updates = array_merge( $updates, self::$preSendUpdates );
+ }
+ if ( $stage === self::ALL || $stage === self::POSTSEND ) {
+ $updates = array_merge( $updates, self::$postSendUpdates );
+ }
+ return $updates;
+ }
+
+ /**
+ * Clear all pending updates without performing them. Generally, you don't
+ * want or need to call this. Unit tests need it though.
+ */
+ public static function clearPendingUpdates() {
+ self::$preSendUpdates = [];
+ self::$postSendUpdates = [];
+ }
+
+ /**
+ * @return bool If a transaction round is active or connection is not ready for commit()
+ */
+ private static function areDatabaseTransactionsActive() {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ if ( $lbFactory->hasTransactionRound() ) {
+ return true;
+ }
+
+ $connsBusy = false;
+ $lbFactory->forEachLB( function ( LoadBalancer $lb ) use ( &$connsBusy ) {
+ $lb->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$connsBusy ) {
+ if ( $conn->writesOrCallbacksPending() || $conn->explicitTrxActive() ) {
+ $connsBusy = true;
+ }
+ } );
+ } );
+
+ return $connsBusy;
+ }
+}
diff --git a/www/wiki/includes/deferred/EnqueueableDataUpdate.php b/www/wiki/includes/deferred/EnqueueableDataUpdate.php
new file mode 100644
index 00000000..ffeb740d
--- /dev/null
+++ b/www/wiki/includes/deferred/EnqueueableDataUpdate.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Interface that marks a DataUpdate as enqueuable via the JobQueue
+ *
+ * Such updates must be representable using IJobSpecification, so that
+ * they can be serialized into jobs and enqueued for later execution
+ *
+ * @since 1.27
+ */
+interface EnqueueableDataUpdate {
+ /**
+ * @return array (wiki => wiki ID, job => IJobSpecification)
+ */
+ public function getAsJobSpecification();
+}
diff --git a/www/wiki/includes/deferred/HTMLCacheUpdate.php b/www/wiki/includes/deferred/HTMLCacheUpdate.php
new file mode 100644
index 00000000..db3790f7
--- /dev/null
+++ b/www/wiki/includes/deferred/HTMLCacheUpdate.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * HTML cache invalidation of all pages linking to a given title.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * Class to invalidate the HTML cache of all the pages linking to a given title.
+ *
+ * @ingroup Cache
+ */
+class HTMLCacheUpdate implements DeferrableUpdate {
+ /** @var Title */
+ public $mTitle;
+
+ /** @var string */
+ public $mTable;
+
+ /**
+ * @param Title $titleTo
+ * @param string $table
+ */
+ function __construct( Title $titleTo, $table ) {
+ $this->mTitle = $titleTo;
+ $this->mTable = $table;
+ }
+
+ public function doUpdate() {
+ $job = HTMLCacheUpdateJob::newForBacklinks( $this->mTitle, $this->mTable );
+
+ JobQueueGroup::singleton()->lazyPush( $job );
+ }
+}
diff --git a/www/wiki/includes/deferred/LinksDeletionUpdate.php b/www/wiki/includes/deferred/LinksDeletionUpdate.php
new file mode 100644
index 00000000..52e996a0
--- /dev/null
+++ b/www/wiki/includes/deferred/LinksDeletionUpdate.php
@@ -0,0 +1,242 @@
+<?php
+/**
+ * Updater for link tracking tables after a page edit.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Update object handling the cleanup of links tables after a page was deleted.
+ */
+class LinksDeletionUpdate extends DataUpdate implements EnqueueableDataUpdate {
+ /** @var WikiPage */
+ protected $page;
+ /** @var int */
+ protected $pageId;
+ /** @var string */
+ protected $timestamp;
+
+ /** @var IDatabase */
+ private $db;
+
+ /**
+ * @param WikiPage $page Page we are updating
+ * @param int|null $pageId ID of the page we are updating [optional]
+ * @param string|null $timestamp TS_MW timestamp of deletion
+ * @throws MWException
+ */
+ function __construct( WikiPage $page, $pageId = null, $timestamp = null ) {
+ parent::__construct();
+
+ $this->page = $page;
+ if ( $pageId ) {
+ $this->pageId = $pageId; // page ID at time of deletion
+ } elseif ( $page->exists() ) {
+ $this->pageId = $page->getId();
+ } else {
+ throw new InvalidArgumentException( "Page ID not known. Page doesn't exist?" );
+ }
+
+ $this->timestamp = $timestamp ?: wfTimestampNow();
+ }
+
+ public function doUpdate() {
+ $services = MediaWikiServices::getInstance();
+ $config = $services->getMainConfig();
+ $lbFactory = $services->getDBLoadBalancerFactory();
+ $batchSize = $config->get( 'UpdateRowsPerQuery' );
+
+ // Page may already be deleted, so don't just getId()
+ $id = $this->pageId;
+
+ if ( $this->ticket ) {
+ // Make sure all links update threads see the changes of each other.
+ // This handles the case when updates have to batched into several COMMITs.
+ $scopedLock = LinksUpdate::acquirePageLock( $this->getDB(), $id );
+ }
+
+ $title = $this->page->getTitle();
+ $dbw = $this->getDB(); // convenience
+
+ // Delete restrictions for it
+ $dbw->delete( 'page_restrictions', [ 'pr_page' => $id ], __METHOD__ );
+
+ // Fix category table counts
+ $cats = $dbw->selectFieldValues(
+ 'categorylinks',
+ 'cl_to',
+ [ 'cl_from' => $id ],
+ __METHOD__
+ );
+ $catBatches = array_chunk( $cats, $batchSize );
+ foreach ( $catBatches as $catBatch ) {
+ $this->page->updateCategoryCounts( [], $catBatch, $id );
+ if ( count( $catBatches ) > 1 ) {
+ $lbFactory->commitAndWaitForReplication(
+ __METHOD__, $this->ticket, [ 'domain' => $dbw->getDomainID() ]
+ );
+ }
+ }
+
+ // Refresh the category table entry if it seems to have no pages. Check
+ // master for the most up-to-date cat_pages count.
+ if ( $title->getNamespace() === NS_CATEGORY ) {
+ $row = $dbw->selectRow(
+ 'category',
+ [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
+ [ 'cat_title' => $title->getDBkey(), 'cat_pages <= 0' ],
+ __METHOD__
+ );
+ if ( $row ) {
+ $cat = Category::newFromRow( $row, $title );
+ // T166757: do the update after the main job DB commit
+ DeferredUpdates::addCallableUpdate( function () use ( $cat ) {
+ $cat->refreshCounts();
+ } );
+ }
+ }
+
+ $this->batchDeleteByPK(
+ 'pagelinks',
+ [ 'pl_from' => $id ],
+ [ 'pl_from', 'pl_namespace', 'pl_title' ],
+ $batchSize
+ );
+ $this->batchDeleteByPK(
+ 'imagelinks',
+ [ 'il_from' => $id ],
+ [ 'il_from', 'il_to' ],
+ $batchSize
+ );
+ $this->batchDeleteByPK(
+ 'categorylinks',
+ [ 'cl_from' => $id ],
+ [ 'cl_from', 'cl_to' ],
+ $batchSize
+ );
+ $this->batchDeleteByPK(
+ 'templatelinks',
+ [ 'tl_from' => $id ],
+ [ 'tl_from', 'tl_namespace', 'tl_title' ],
+ $batchSize
+ );
+ $this->batchDeleteByPK(
+ 'externallinks',
+ [ 'el_from' => $id ],
+ [ 'el_id' ],
+ $batchSize
+ );
+ $this->batchDeleteByPK(
+ 'langlinks',
+ [ 'll_from' => $id ],
+ [ 'll_from', 'll_lang' ],
+ $batchSize
+ );
+ $this->batchDeleteByPK(
+ 'iwlinks',
+ [ 'iwl_from' => $id ],
+ [ 'iwl_from', 'iwl_prefix', 'iwl_title' ],
+ $batchSize
+ );
+
+ // Delete any redirect entry or page props entries
+ $dbw->delete( 'redirect', [ 'rd_from' => $id ], __METHOD__ );
+ $dbw->delete( 'page_props', [ 'pp_page' => $id ], __METHOD__ );
+
+ // Find recentchanges entries to clean up...
+ $rcIdsForTitle = $dbw->selectFieldValues(
+ 'recentchanges',
+ 'rc_id',
+ [
+ 'rc_type != ' . RC_LOG,
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_title' => $title->getDBkey(),
+ 'rc_timestamp < ' .
+ $dbw->addQuotes( $dbw->timestamp( $this->timestamp ) )
+ ],
+ __METHOD__
+ );
+ $rcIdsForPage = $dbw->selectFieldValues(
+ 'recentchanges',
+ 'rc_id',
+ [ 'rc_type != ' . RC_LOG, 'rc_cur_id' => $id ],
+ __METHOD__
+ );
+
+ // T98706: delete by PK to avoid lock contention with RC delete log insertions
+ $rcIdBatches = array_chunk( array_merge( $rcIdsForTitle, $rcIdsForPage ), $batchSize );
+ foreach ( $rcIdBatches as $rcIdBatch ) {
+ $dbw->delete( 'recentchanges', [ 'rc_id' => $rcIdBatch ], __METHOD__ );
+ if ( count( $rcIdBatches ) > 1 ) {
+ $lbFactory->commitAndWaitForReplication(
+ __METHOD__, $this->ticket, [ 'domain' => $dbw->getDomainID() ]
+ );
+ }
+ }
+
+ // Commit and release the lock (if set)
+ ScopedCallback::consume( $scopedLock );
+ }
+
+ private function batchDeleteByPK( $table, array $conds, array $pk, $bSize ) {
+ $services = MediaWikiServices::getInstance();
+ $lbFactory = $services->getDBLoadBalancerFactory();
+ $dbw = $this->getDB(); // convenience
+
+ $res = $dbw->select( $table, $pk, $conds, __METHOD__ );
+
+ $pkDeleteConds = [];
+ foreach ( $res as $row ) {
+ $pkDeleteConds[] = $dbw->makeList( (array)$row, LIST_AND );
+ if ( count( $pkDeleteConds ) >= $bSize ) {
+ $dbw->delete( $table, $dbw->makeList( $pkDeleteConds, LIST_OR ), __METHOD__ );
+ $lbFactory->commitAndWaitForReplication(
+ __METHOD__, $this->ticket, [ 'domain' => $dbw->getDomainID() ]
+ );
+ $pkDeleteConds = [];
+ }
+ }
+
+ if ( $pkDeleteConds ) {
+ $dbw->delete( $table, $dbw->makeList( $pkDeleteConds, LIST_OR ), __METHOD__ );
+ }
+ }
+
+ protected function getDB() {
+ if ( !$this->db ) {
+ $this->db = wfGetDB( DB_MASTER );
+ }
+
+ return $this->db;
+ }
+
+ public function getAsJobSpecification() {
+ return [
+ 'wiki' => WikiMap::getWikiIdFromDomain( $this->getDB()->getDomainID() ),
+ 'job' => new JobSpecification(
+ 'deleteLinks',
+ [ 'pageId' => $this->pageId, 'timestamp' => $this->timestamp ],
+ [ 'removeDuplicates' => true ],
+ $this->page->getTitle()
+ )
+ ];
+ }
+}
diff --git a/www/wiki/includes/deferred/LinksUpdate.php b/www/wiki/includes/deferred/LinksUpdate.php
new file mode 100644
index 00000000..dfe89ba3
--- /dev/null
+++ b/www/wiki/includes/deferred/LinksUpdate.php
@@ -0,0 +1,1165 @@
+<?php
+/**
+ * Updater for link tracking tables after a page edit.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Class the manages updates of *_link tables as well as similar extension-managed tables
+ *
+ * @note: LinksUpdate is managed by DeferredUpdates::execute(). Do not run this in a transaction.
+ *
+ * See docs/deferred.txt
+ */
+class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
+ // @todo make members protected, but make sure extensions don't break
+
+ /** @var int Page ID of the article linked from */
+ public $mId;
+
+ /** @var Title Title object of the article linked from */
+ public $mTitle;
+
+ /** @var ParserOutput */
+ public $mParserOutput;
+
+ /** @var array Map of title strings to IDs for the links in the document */
+ public $mLinks;
+
+ /** @var array DB keys of the images used, in the array key only */
+ public $mImages;
+
+ /** @var array Map of title strings to IDs for the template references, including broken ones */
+ public $mTemplates;
+
+ /** @var array URLs of external links, array key only */
+ public $mExternals;
+
+ /** @var array Map of category names to sort keys */
+ public $mCategories;
+
+ /** @var array Map of language codes to titles */
+ public $mInterlangs;
+
+ /** @var array 2-D map of (prefix => DBK => 1) */
+ public $mInterwikis;
+
+ /** @var array Map of arbitrary name to value */
+ public $mProperties;
+
+ /** @var bool Whether to queue jobs for recursive updates */
+ public $mRecursive;
+
+ /** @var Revision Revision for which this update has been triggered */
+ private $mRevision;
+
+ /**
+ * @var null|array Added links if calculated.
+ */
+ private $linkInsertions = null;
+
+ /**
+ * @var null|array Deleted links if calculated.
+ */
+ private $linkDeletions = null;
+
+ /**
+ * @var null|array Added properties if calculated.
+ */
+ private $propertyInsertions = null;
+
+ /**
+ * @var null|array Deleted properties if calculated.
+ */
+ private $propertyDeletions = null;
+
+ /**
+ * @var User|null
+ */
+ private $user;
+
+ /** @var IDatabase */
+ private $db;
+
+ /**
+ * @param Title $title Title of the page we're updating
+ * @param ParserOutput $parserOutput Output from a full parse of this page
+ * @param bool $recursive Queue jobs for recursive updates?
+ * @throws MWException
+ */
+ function __construct( Title $title, ParserOutput $parserOutput, $recursive = true ) {
+ parent::__construct();
+
+ $this->mTitle = $title;
+ $this->mId = $title->getArticleID( Title::GAID_FOR_UPDATE );
+
+ if ( !$this->mId ) {
+ throw new InvalidArgumentException(
+ "The Title object yields no ID. Perhaps the page doesn't exist?"
+ );
+ }
+
+ $this->mParserOutput = $parserOutput;
+
+ $this->mLinks = $parserOutput->getLinks();
+ $this->mImages = $parserOutput->getImages();
+ $this->mTemplates = $parserOutput->getTemplates();
+ $this->mExternals = $parserOutput->getExternalLinks();
+ $this->mCategories = $parserOutput->getCategories();
+ $this->mProperties = $parserOutput->getProperties();
+ $this->mInterwikis = $parserOutput->getInterwikiLinks();
+
+ # Convert the format of the interlanguage links
+ # I didn't want to change it in the ParserOutput, because that array is passed all
+ # the way back to the skin, so either a skin API break would be required, or an
+ # inefficient back-conversion.
+ $ill = $parserOutput->getLanguageLinks();
+ $this->mInterlangs = [];
+ foreach ( $ill as $link ) {
+ list( $key, $title ) = explode( ':', $link, 2 );
+ $this->mInterlangs[$key] = $title;
+ }
+
+ foreach ( $this->mCategories as &$sortkey ) {
+ # If the sortkey is longer then 255 bytes,
+ # it truncated by DB, and then doesn't get
+ # matched when comparing existing vs current
+ # categories, causing T27254.
+ # Also. substr behaves weird when given "".
+ if ( $sortkey !== '' ) {
+ $sortkey = substr( $sortkey, 0, 255 );
+ }
+ }
+
+ $this->mRecursive = $recursive;
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $linksUpdate = $this;
+ Hooks::run( 'LinksUpdateConstructed', [ &$linksUpdate ] );
+ }
+
+ /**
+ * Update link tables with outgoing links from an updated article
+ *
+ * @note: this is managed by DeferredUpdates::execute(). Do not run this in a transaction.
+ */
+ public function doUpdate() {
+ if ( $this->ticket ) {
+ // Make sure all links update threads see the changes of each other.
+ // This handles the case when updates have to batched into several COMMITs.
+ $scopedLock = self::acquirePageLock( $this->getDB(), $this->mId );
+ }
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $linksUpdate = $this;
+ Hooks::run( 'LinksUpdate', [ &$linksUpdate ] );
+ $this->doIncrementalUpdate();
+
+ // Commit and release the lock (if set)
+ ScopedCallback::consume( $scopedLock );
+ // Run post-commit hooks without DBO_TRX
+ $this->getDB()->onTransactionIdle(
+ function () {
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $linksUpdate = $this;
+ Hooks::run( 'LinksUpdateComplete', [ &$linksUpdate, $this->ticket ] );
+ },
+ __METHOD__
+ );
+ }
+
+ /**
+ * Acquire a lock for performing link table updates for a page on a DB
+ *
+ * @param IDatabase $dbw
+ * @param int $pageId
+ * @param string $why One of (job, atomicity)
+ * @return ScopedCallback
+ * @throws RuntimeException
+ * @since 1.27
+ */
+ public static function acquirePageLock( IDatabase $dbw, $pageId, $why = 'atomicity' ) {
+ $key = "LinksUpdate:$why:pageid:$pageId";
+ $scopedLock = $dbw->getScopedLockAndFlush( $key, __METHOD__, 15 );
+ if ( !$scopedLock ) {
+ throw new RuntimeException( "Could not acquire lock '$key'." );
+ }
+
+ return $scopedLock;
+ }
+
+ protected function doIncrementalUpdate() {
+ # Page links
+ $existingPL = $this->getExistingLinks();
+ $this->linkDeletions = $this->getLinkDeletions( $existingPL );
+ $this->linkInsertions = $this->getLinkInsertions( $existingPL );
+ $this->incrTableUpdate( 'pagelinks', 'pl', $this->linkDeletions, $this->linkInsertions );
+
+ # Image links
+ $existingIL = $this->getExistingImages();
+ $imageDeletes = $this->getImageDeletions( $existingIL );
+ $this->incrTableUpdate(
+ 'imagelinks',
+ 'il',
+ $imageDeletes,
+ $this->getImageInsertions( $existingIL ) );
+
+ # Invalidate all image description pages which had links added or removed
+ $imageUpdates = $imageDeletes + array_diff_key( $this->mImages, $existingIL );
+ $this->invalidateImageDescriptions( $imageUpdates );
+
+ # External links
+ $existingEL = $this->getExistingExternals();
+ $this->incrTableUpdate(
+ 'externallinks',
+ 'el',
+ $this->getExternalDeletions( $existingEL ),
+ $this->getExternalInsertions( $existingEL ) );
+
+ # Language links
+ $existingLL = $this->getExistingInterlangs();
+ $this->incrTableUpdate(
+ 'langlinks',
+ 'll',
+ $this->getInterlangDeletions( $existingLL ),
+ $this->getInterlangInsertions( $existingLL ) );
+
+ # Inline interwiki links
+ $existingIW = $this->getExistingInterwikis();
+ $this->incrTableUpdate(
+ 'iwlinks',
+ 'iwl',
+ $this->getInterwikiDeletions( $existingIW ),
+ $this->getInterwikiInsertions( $existingIW ) );
+
+ # Template links
+ $existingTL = $this->getExistingTemplates();
+ $this->incrTableUpdate(
+ 'templatelinks',
+ 'tl',
+ $this->getTemplateDeletions( $existingTL ),
+ $this->getTemplateInsertions( $existingTL ) );
+
+ # Category links
+ $existingCL = $this->getExistingCategories();
+ $categoryDeletes = $this->getCategoryDeletions( $existingCL );
+ $this->incrTableUpdate(
+ 'categorylinks',
+ 'cl',
+ $categoryDeletes,
+ $this->getCategoryInsertions( $existingCL ) );
+ $categoryInserts = array_diff_assoc( $this->mCategories, $existingCL );
+ $categoryUpdates = $categoryInserts + $categoryDeletes;
+
+ # Page properties
+ $existingPP = $this->getExistingProperties();
+ $this->propertyDeletions = $this->getPropertyDeletions( $existingPP );
+ $this->incrTableUpdate(
+ 'page_props',
+ 'pp',
+ $this->propertyDeletions,
+ $this->getPropertyInsertions( $existingPP ) );
+
+ # Invalidate the necessary pages
+ $this->propertyInsertions = array_diff_assoc( $this->mProperties, $existingPP );
+ $changed = $this->propertyDeletions + $this->propertyInsertions;
+ $this->invalidateProperties( $changed );
+
+ # Invalidate all categories which were added, deleted or changed (set symmetric difference)
+ $this->invalidateCategories( $categoryUpdates );
+ $this->updateCategoryCounts( $categoryInserts, $categoryDeletes );
+
+ # Refresh links of all pages including this page
+ # This will be in a separate transaction
+ if ( $this->mRecursive ) {
+ $this->queueRecursiveJobs();
+ }
+
+ # Update the links table freshness for this title
+ $this->updateLinksTimestamp();
+ }
+
+ /**
+ * Queue recursive jobs for this page
+ *
+ * Which means do LinksUpdate on all pages that include the current page,
+ * using the job queue.
+ */
+ protected function queueRecursiveJobs() {
+ self::queueRecursiveJobsForTable( $this->mTitle, 'templatelinks' );
+ if ( $this->mTitle->getNamespace() == NS_FILE ) {
+ // Process imagelinks in case the title is or was a redirect
+ self::queueRecursiveJobsForTable( $this->mTitle, 'imagelinks' );
+ }
+
+ $bc = $this->mTitle->getBacklinkCache();
+ // Get jobs for cascade-protected backlinks for a high priority queue.
+ // If meta-templates change to using a new template, the new template
+ // should be implicitly protected as soon as possible, if applicable.
+ // These jobs duplicate a subset of the above ones, but can run sooner.
+ // Which ever runs first generally no-ops the other one.
+ $jobs = [];
+ foreach ( $bc->getCascadeProtectedLinks() as $title ) {
+ $jobs[] = RefreshLinksJob::newPrioritized( $title, [] );
+ }
+ JobQueueGroup::singleton()->push( $jobs );
+ }
+
+ /**
+ * Queue a RefreshLinks job for any table.
+ *
+ * @param Title $title Title to do job for
+ * @param string $table Table to use (e.g. 'templatelinks')
+ */
+ public static function queueRecursiveJobsForTable( Title $title, $table ) {
+ if ( $title->getBacklinkCache()->hasLinks( $table ) ) {
+ $job = new RefreshLinksJob(
+ $title,
+ [
+ 'table' => $table,
+ 'recursive' => true,
+ ] + Job::newRootJobParams( // "overall" refresh links job info
+ "refreshlinks:{$table}:{$title->getPrefixedText()}"
+ )
+ );
+
+ JobQueueGroup::singleton()->push( $job );
+ }
+ }
+
+ /**
+ * @param array $cats
+ */
+ private function invalidateCategories( $cats ) {
+ PurgeJobUtils::invalidatePages( $this->getDB(), NS_CATEGORY, array_keys( $cats ) );
+ }
+
+ /**
+ * Update all the appropriate counts in the category table.
+ * @param array $added Associative array of category name => sort key
+ * @param array $deleted Associative array of category name => sort key
+ */
+ private function updateCategoryCounts( array $added, array $deleted ) {
+ global $wgUpdateRowsPerQuery;
+
+ if ( !$added && !$deleted ) {
+ return;
+ }
+
+ $domainId = $this->getDB()->getDomainID();
+ $wp = WikiPage::factory( $this->mTitle );
+ $lbf = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ // T163801: try to release any row locks to reduce contention
+ $lbf->commitAndWaitForReplication( __METHOD__, $this->ticket, [ 'domain' => $domainId ] );
+
+ foreach ( array_chunk( array_keys( $added ), $wgUpdateRowsPerQuery ) as $addBatch ) {
+ $wp->updateCategoryCounts( $addBatch, [], $this->mId );
+ $lbf->commitAndWaitForReplication(
+ __METHOD__, $this->ticket, [ 'domain' => $domainId ] );
+ }
+
+ foreach ( array_chunk( array_keys( $deleted ), $wgUpdateRowsPerQuery ) as $deleteBatch ) {
+ $wp->updateCategoryCounts( [], $deleteBatch, $this->mId );
+ $lbf->commitAndWaitForReplication(
+ __METHOD__, $this->ticket, [ 'domain' => $domainId ] );
+ }
+ }
+
+ /**
+ * @param array $images
+ */
+ private function invalidateImageDescriptions( $images ) {
+ PurgeJobUtils::invalidatePages( $this->getDB(), NS_FILE, array_keys( $images ) );
+ }
+
+ /**
+ * Update a table by doing a delete query then an insert query
+ * @param string $table Table name
+ * @param string $prefix Field name prefix
+ * @param array $deletions
+ * @param array $insertions Rows to insert
+ */
+ private function incrTableUpdate( $table, $prefix, $deletions, $insertions ) {
+ $services = MediaWikiServices::getInstance();
+ $bSize = $services->getMainConfig()->get( 'UpdateRowsPerQuery' );
+ $lbf = $services->getDBLoadBalancerFactory();
+
+ if ( $table === 'page_props' ) {
+ $fromField = 'pp_page';
+ } else {
+ $fromField = "{$prefix}_from";
+ }
+
+ $deleteWheres = []; // list of WHERE clause arrays for each DB delete() call
+ if ( $table === 'pagelinks' || $table === 'templatelinks' || $table === 'iwlinks' ) {
+ $baseKey = ( $table === 'iwlinks' ) ? 'iwl_prefix' : "{$prefix}_namespace";
+
+ $curBatchSize = 0;
+ $curDeletionBatch = [];
+ $deletionBatches = [];
+ foreach ( $deletions as $ns => $dbKeys ) {
+ foreach ( $dbKeys as $dbKey => $unused ) {
+ $curDeletionBatch[$ns][$dbKey] = 1;
+ if ( ++$curBatchSize >= $bSize ) {
+ $deletionBatches[] = $curDeletionBatch;
+ $curDeletionBatch = [];
+ $curBatchSize = 0;
+ }
+ }
+ }
+ if ( $curDeletionBatch ) {
+ $deletionBatches[] = $curDeletionBatch;
+ }
+
+ foreach ( $deletionBatches as $deletionBatch ) {
+ $deleteWheres[] = [
+ $fromField => $this->mId,
+ $this->getDB()->makeWhereFrom2d( $deletionBatch, $baseKey, "{$prefix}_title" )
+ ];
+ }
+ } else {
+ if ( $table === 'langlinks' ) {
+ $toField = 'll_lang';
+ } elseif ( $table === 'page_props' ) {
+ $toField = 'pp_propname';
+ } else {
+ $toField = $prefix . '_to';
+ }
+
+ $deletionBatches = array_chunk( array_keys( $deletions ), $bSize );
+ foreach ( $deletionBatches as $deletionBatch ) {
+ $deleteWheres[] = [ $fromField => $this->mId, $toField => $deletionBatch ];
+ }
+ }
+
+ $domainId = $this->getDB()->getDomainID();
+
+ foreach ( $deleteWheres as $deleteWhere ) {
+ $this->getDB()->delete( $table, $deleteWhere, __METHOD__ );
+ $lbf->commitAndWaitForReplication(
+ __METHOD__, $this->ticket, [ 'domain' => $domainId ]
+ );
+ }
+
+ $insertBatches = array_chunk( $insertions, $bSize );
+ foreach ( $insertBatches as $insertBatch ) {
+ $this->getDB()->insert( $table, $insertBatch, __METHOD__, 'IGNORE' );
+ $lbf->commitAndWaitForReplication(
+ __METHOD__, $this->ticket, [ 'domain' => $domainId ]
+ );
+ }
+
+ if ( count( $insertions ) ) {
+ Hooks::run( 'LinksUpdateAfterInsert', [ $this, $table, $insertions ] );
+ }
+ }
+
+ /**
+ * Get an array of pagelinks insertions for passing to the DB
+ * Skips the titles specified by the 2-D array $existing
+ * @param array $existing
+ * @return array
+ */
+ private function getLinkInsertions( $existing = [] ) {
+ $arr = [];
+ foreach ( $this->mLinks as $ns => $dbkeys ) {
+ $diffs = isset( $existing[$ns] )
+ ? array_diff_key( $dbkeys, $existing[$ns] )
+ : $dbkeys;
+ foreach ( $diffs as $dbk => $id ) {
+ $arr[] = [
+ 'pl_from' => $this->mId,
+ 'pl_from_namespace' => $this->mTitle->getNamespace(),
+ 'pl_namespace' => $ns,
+ 'pl_title' => $dbk
+ ];
+ }
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of template insertions. Like getLinkInsertions()
+ * @param array $existing
+ * @return array
+ */
+ private function getTemplateInsertions( $existing = [] ) {
+ $arr = [];
+ foreach ( $this->mTemplates as $ns => $dbkeys ) {
+ $diffs = isset( $existing[$ns] ) ? array_diff_key( $dbkeys, $existing[$ns] ) : $dbkeys;
+ foreach ( $diffs as $dbk => $id ) {
+ $arr[] = [
+ 'tl_from' => $this->mId,
+ 'tl_from_namespace' => $this->mTitle->getNamespace(),
+ 'tl_namespace' => $ns,
+ 'tl_title' => $dbk
+ ];
+ }
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of image insertions
+ * Skips the names specified in $existing
+ * @param array $existing
+ * @return array
+ */
+ private function getImageInsertions( $existing = [] ) {
+ $arr = [];
+ $diffs = array_diff_key( $this->mImages, $existing );
+ foreach ( $diffs as $iname => $dummy ) {
+ $arr[] = [
+ 'il_from' => $this->mId,
+ 'il_from_namespace' => $this->mTitle->getNamespace(),
+ 'il_to' => $iname
+ ];
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of externallinks insertions. Skips the names specified in $existing
+ * @param array $existing
+ * @return array
+ */
+ private function getExternalInsertions( $existing = [] ) {
+ $arr = [];
+ $diffs = array_diff_key( $this->mExternals, $existing );
+ foreach ( $diffs as $url => $dummy ) {
+ foreach ( wfMakeUrlIndexes( $url ) as $index ) {
+ $arr[] = [
+ 'el_from' => $this->mId,
+ 'el_to' => $url,
+ 'el_index' => $index,
+ ];
+ }
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of category insertions
+ *
+ * @param array $existing Mapping existing category names to sort keys. If both
+ * match a link in $this, the link will be omitted from the output
+ *
+ * @return array
+ */
+ private function getCategoryInsertions( $existing = [] ) {
+ global $wgContLang, $wgCategoryCollation;
+ $diffs = array_diff_assoc( $this->mCategories, $existing );
+ $arr = [];
+ foreach ( $diffs as $name => $prefix ) {
+ $nt = Title::makeTitleSafe( NS_CATEGORY, $name );
+ $wgContLang->findVariantLink( $name, $nt, true );
+
+ if ( $this->mTitle->getNamespace() == NS_CATEGORY ) {
+ $type = 'subcat';
+ } elseif ( $this->mTitle->getNamespace() == NS_FILE ) {
+ $type = 'file';
+ } else {
+ $type = 'page';
+ }
+
+ # Treat custom sortkeys as a prefix, so that if multiple
+ # things are forced to sort as '*' or something, they'll
+ # sort properly in the category rather than in page_id
+ # order or such.
+ $sortkey = Collation::singleton()->getSortKey(
+ $this->mTitle->getCategorySortkey( $prefix ) );
+
+ $arr[] = [
+ 'cl_from' => $this->mId,
+ 'cl_to' => $name,
+ 'cl_sortkey' => $sortkey,
+ 'cl_timestamp' => $this->getDB()->timestamp(),
+ 'cl_sortkey_prefix' => $prefix,
+ 'cl_collation' => $wgCategoryCollation,
+ 'cl_type' => $type,
+ ];
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of interlanguage link insertions
+ *
+ * @param array $existing Mapping existing language codes to titles
+ *
+ * @return array
+ */
+ private function getInterlangInsertions( $existing = [] ) {
+ $diffs = array_diff_assoc( $this->mInterlangs, $existing );
+ $arr = [];
+ foreach ( $diffs as $lang => $title ) {
+ $arr[] = [
+ 'll_from' => $this->mId,
+ 'll_lang' => $lang,
+ 'll_title' => $title
+ ];
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of page property insertions
+ * @param array $existing
+ * @return array
+ */
+ function getPropertyInsertions( $existing = [] ) {
+ $diffs = array_diff_assoc( $this->mProperties, $existing );
+
+ $arr = [];
+ foreach ( array_keys( $diffs ) as $name ) {
+ $arr[] = $this->getPagePropRowData( $name );
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Returns an associative array to be used for inserting a row into
+ * the page_props table. Besides the given property name, this will
+ * include the page id from $this->mId and any property value from
+ * $this->mProperties.
+ *
+ * The array returned will include the pp_sortkey field if this
+ * is present in the database (as indicated by $wgPagePropsHaveSortkey).
+ * The sortkey value is currently determined by getPropertySortKeyValue().
+ *
+ * @note this assumes that $this->mProperties[$prop] is defined.
+ *
+ * @param string $prop The name of the property.
+ *
+ * @return array
+ */
+ private function getPagePropRowData( $prop ) {
+ global $wgPagePropsHaveSortkey;
+
+ $value = $this->mProperties[$prop];
+
+ $row = [
+ 'pp_page' => $this->mId,
+ 'pp_propname' => $prop,
+ 'pp_value' => $value,
+ ];
+
+ if ( $wgPagePropsHaveSortkey ) {
+ $row['pp_sortkey'] = $this->getPropertySortKeyValue( $value );
+ }
+
+ return $row;
+ }
+
+ /**
+ * Determines the sort key for the given property value.
+ * This will return $value if it is a float or int,
+ * 1 or resp. 0 if it is a bool, and null otherwise.
+ *
+ * @note In the future, we may allow the sortkey to be specified explicitly
+ * in ParserOutput::setProperty.
+ *
+ * @param mixed $value
+ *
+ * @return float|null
+ */
+ private function getPropertySortKeyValue( $value ) {
+ if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) ) {
+ return floatval( $value );
+ }
+
+ return null;
+ }
+
+ /**
+ * Get an array of interwiki insertions for passing to the DB
+ * Skips the titles specified by the 2-D array $existing
+ * @param array $existing
+ * @return array
+ */
+ private function getInterwikiInsertions( $existing = [] ) {
+ $arr = [];
+ foreach ( $this->mInterwikis as $prefix => $dbkeys ) {
+ $diffs = isset( $existing[$prefix] )
+ ? array_diff_key( $dbkeys, $existing[$prefix] )
+ : $dbkeys;
+
+ foreach ( $diffs as $dbk => $id ) {
+ $arr[] = [
+ 'iwl_from' => $this->mId,
+ 'iwl_prefix' => $prefix,
+ 'iwl_title' => $dbk
+ ];
+ }
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Given an array of existing links, returns those links which are not in $this
+ * and thus should be deleted.
+ * @param array $existing
+ * @return array
+ */
+ private function getLinkDeletions( $existing ) {
+ $del = [];
+ foreach ( $existing as $ns => $dbkeys ) {
+ if ( isset( $this->mLinks[$ns] ) ) {
+ $del[$ns] = array_diff_key( $existing[$ns], $this->mLinks[$ns] );
+ } else {
+ $del[$ns] = $existing[$ns];
+ }
+ }
+
+ return $del;
+ }
+
+ /**
+ * Given an array of existing templates, returns those templates which are not in $this
+ * and thus should be deleted.
+ * @param array $existing
+ * @return array
+ */
+ private function getTemplateDeletions( $existing ) {
+ $del = [];
+ foreach ( $existing as $ns => $dbkeys ) {
+ if ( isset( $this->mTemplates[$ns] ) ) {
+ $del[$ns] = array_diff_key( $existing[$ns], $this->mTemplates[$ns] );
+ } else {
+ $del[$ns] = $existing[$ns];
+ }
+ }
+
+ return $del;
+ }
+
+ /**
+ * Given an array of existing images, returns those images which are not in $this
+ * and thus should be deleted.
+ * @param array $existing
+ * @return array
+ */
+ private function getImageDeletions( $existing ) {
+ return array_diff_key( $existing, $this->mImages );
+ }
+
+ /**
+ * Given an array of existing external links, returns those links which are not
+ * in $this and thus should be deleted.
+ * @param array $existing
+ * @return array
+ */
+ private function getExternalDeletions( $existing ) {
+ return array_diff_key( $existing, $this->mExternals );
+ }
+
+ /**
+ * Given an array of existing categories, returns those categories which are not in $this
+ * and thus should be deleted.
+ * @param array $existing
+ * @return array
+ */
+ private function getCategoryDeletions( $existing ) {
+ return array_diff_assoc( $existing, $this->mCategories );
+ }
+
+ /**
+ * Given an array of existing interlanguage links, returns those links which are not
+ * in $this and thus should be deleted.
+ * @param array $existing
+ * @return array
+ */
+ private function getInterlangDeletions( $existing ) {
+ return array_diff_assoc( $existing, $this->mInterlangs );
+ }
+
+ /**
+ * Get array of properties which should be deleted.
+ * @param array $existing
+ * @return array
+ */
+ function getPropertyDeletions( $existing ) {
+ return array_diff_assoc( $existing, $this->mProperties );
+ }
+
+ /**
+ * Given an array of existing interwiki links, returns those links which are not in $this
+ * and thus should be deleted.
+ * @param array $existing
+ * @return array
+ */
+ private function getInterwikiDeletions( $existing ) {
+ $del = [];
+ foreach ( $existing as $prefix => $dbkeys ) {
+ if ( isset( $this->mInterwikis[$prefix] ) ) {
+ $del[$prefix] = array_diff_key( $existing[$prefix], $this->mInterwikis[$prefix] );
+ } else {
+ $del[$prefix] = $existing[$prefix];
+ }
+ }
+
+ return $del;
+ }
+
+ /**
+ * Get an array of existing links, as a 2-D array
+ *
+ * @return array
+ */
+ private function getExistingLinks() {
+ $res = $this->getDB()->select( 'pagelinks', [ 'pl_namespace', 'pl_title' ],
+ [ 'pl_from' => $this->mId ], __METHOD__ );
+ $arr = [];
+ foreach ( $res as $row ) {
+ if ( !isset( $arr[$row->pl_namespace] ) ) {
+ $arr[$row->pl_namespace] = [];
+ }
+ $arr[$row->pl_namespace][$row->pl_title] = 1;
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of existing templates, as a 2-D array
+ *
+ * @return array
+ */
+ private function getExistingTemplates() {
+ $res = $this->getDB()->select( 'templatelinks', [ 'tl_namespace', 'tl_title' ],
+ [ 'tl_from' => $this->mId ], __METHOD__ );
+ $arr = [];
+ foreach ( $res as $row ) {
+ if ( !isset( $arr[$row->tl_namespace] ) ) {
+ $arr[$row->tl_namespace] = [];
+ }
+ $arr[$row->tl_namespace][$row->tl_title] = 1;
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of existing images, image names in the keys
+ *
+ * @return array
+ */
+ private function getExistingImages() {
+ $res = $this->getDB()->select( 'imagelinks', [ 'il_to' ],
+ [ 'il_from' => $this->mId ], __METHOD__ );
+ $arr = [];
+ foreach ( $res as $row ) {
+ $arr[$row->il_to] = 1;
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of existing external links, URLs in the keys
+ *
+ * @return array
+ */
+ private function getExistingExternals() {
+ $res = $this->getDB()->select( 'externallinks', [ 'el_to' ],
+ [ 'el_from' => $this->mId ], __METHOD__ );
+ $arr = [];
+ foreach ( $res as $row ) {
+ $arr[$row->el_to] = 1;
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of existing categories, with the name in the key and sort key in the value.
+ *
+ * @return array
+ */
+ private function getExistingCategories() {
+ $res = $this->getDB()->select( 'categorylinks', [ 'cl_to', 'cl_sortkey_prefix' ],
+ [ 'cl_from' => $this->mId ], __METHOD__ );
+ $arr = [];
+ foreach ( $res as $row ) {
+ $arr[$row->cl_to] = $row->cl_sortkey_prefix;
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of existing interlanguage links, with the language code in the key and the
+ * title in the value.
+ *
+ * @return array
+ */
+ private function getExistingInterlangs() {
+ $res = $this->getDB()->select( 'langlinks', [ 'll_lang', 'll_title' ],
+ [ 'll_from' => $this->mId ], __METHOD__ );
+ $arr = [];
+ foreach ( $res as $row ) {
+ $arr[$row->ll_lang] = $row->ll_title;
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of existing inline interwiki links, as a 2-D array
+ * @return array (prefix => array(dbkey => 1))
+ */
+ private function getExistingInterwikis() {
+ $res = $this->getDB()->select( 'iwlinks', [ 'iwl_prefix', 'iwl_title' ],
+ [ 'iwl_from' => $this->mId ], __METHOD__ );
+ $arr = [];
+ foreach ( $res as $row ) {
+ if ( !isset( $arr[$row->iwl_prefix] ) ) {
+ $arr[$row->iwl_prefix] = [];
+ }
+ $arr[$row->iwl_prefix][$row->iwl_title] = 1;
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Get an array of existing categories, with the name in the key and sort key in the value.
+ *
+ * @return array Array of property names and values
+ */
+ private function getExistingProperties() {
+ $res = $this->getDB()->select( 'page_props', [ 'pp_propname', 'pp_value' ],
+ [ 'pp_page' => $this->mId ], __METHOD__ );
+ $arr = [];
+ foreach ( $res as $row ) {
+ $arr[$row->pp_propname] = $row->pp_value;
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Return the title object of the page being updated
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->mTitle;
+ }
+
+ /**
+ * Returns parser output
+ * @since 1.19
+ * @return ParserOutput
+ */
+ public function getParserOutput() {
+ return $this->mParserOutput;
+ }
+
+ /**
+ * Return the list of images used as generated by the parser
+ * @return array
+ */
+ public function getImages() {
+ return $this->mImages;
+ }
+
+ /**
+ * Set the revision corresponding to this LinksUpdate
+ *
+ * @since 1.27
+ *
+ * @param Revision $revision
+ */
+ public function setRevision( Revision $revision ) {
+ $this->mRevision = $revision;
+ }
+
+ /**
+ * @since 1.28
+ * @return null|Revision
+ */
+ public function getRevision() {
+ return $this->mRevision;
+ }
+
+ /**
+ * Set the User who triggered this LinksUpdate
+ *
+ * @since 1.27
+ * @param User $user
+ */
+ public function setTriggeringUser( User $user ) {
+ $this->user = $user;
+ }
+
+ /**
+ * @since 1.27
+ * @return null|User
+ */
+ public function getTriggeringUser() {
+ return $this->user;
+ }
+
+ /**
+ * Invalidate any necessary link lists related to page property changes
+ * @param array $changed
+ */
+ private function invalidateProperties( $changed ) {
+ global $wgPagePropLinkInvalidations;
+
+ foreach ( $changed as $name => $value ) {
+ if ( isset( $wgPagePropLinkInvalidations[$name] ) ) {
+ $inv = $wgPagePropLinkInvalidations[$name];
+ if ( !is_array( $inv ) ) {
+ $inv = [ $inv ];
+ }
+ foreach ( $inv as $table ) {
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this->mTitle, $table ) );
+ }
+ }
+ }
+ }
+
+ /**
+ * Fetch page links added by this LinksUpdate. Only available after the update is complete.
+ * @since 1.22
+ * @return null|array Array of Titles
+ */
+ public function getAddedLinks() {
+ if ( $this->linkInsertions === null ) {
+ return null;
+ }
+ $result = [];
+ foreach ( $this->linkInsertions as $insertion ) {
+ $result[] = Title::makeTitle( $insertion['pl_namespace'], $insertion['pl_title'] );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch page links removed by this LinksUpdate. Only available after the update is complete.
+ * @since 1.22
+ * @return null|array Array of Titles
+ */
+ public function getRemovedLinks() {
+ if ( $this->linkDeletions === null ) {
+ return null;
+ }
+ $result = [];
+ foreach ( $this->linkDeletions as $ns => $titles ) {
+ foreach ( $titles as $title => $unused ) {
+ $result[] = Title::makeTitle( $ns, $title );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch page properties added by this LinksUpdate.
+ * Only available after the update is complete.
+ * @since 1.28
+ * @return null|array
+ */
+ public function getAddedProperties() {
+ return $this->propertyInsertions;
+ }
+
+ /**
+ * Fetch page properties removed by this LinksUpdate.
+ * Only available after the update is complete.
+ * @since 1.28
+ * @return null|array
+ */
+ public function getRemovedProperties() {
+ return $this->propertyDeletions;
+ }
+
+ /**
+ * Update links table freshness
+ */
+ private function updateLinksTimestamp() {
+ if ( $this->mId ) {
+ // The link updates made here only reflect the freshness of the parser output
+ $timestamp = $this->mParserOutput->getCacheTime();
+ $this->getDB()->update( 'page',
+ [ 'page_links_updated' => $this->getDB()->timestamp( $timestamp ) ],
+ [ 'page_id' => $this->mId ],
+ __METHOD__
+ );
+ }
+ }
+
+ /**
+ * @return IDatabase
+ */
+ private function getDB() {
+ if ( !$this->db ) {
+ $this->db = wfGetDB( DB_MASTER );
+ }
+
+ return $this->db;
+ }
+
+ public function getAsJobSpecification() {
+ if ( $this->user ) {
+ $userInfo = [
+ 'userId' => $this->user->getId(),
+ 'userName' => $this->user->getName(),
+ ];
+ } else {
+ $userInfo = false;
+ }
+
+ if ( $this->mRevision ) {
+ $triggeringRevisionId = $this->mRevision->getId();
+ } else {
+ $triggeringRevisionId = false;
+ }
+
+ return [
+ 'wiki' => WikiMap::getWikiIdFromDomain( $this->getDB()->getDomainID() ),
+ 'job' => new JobSpecification(
+ 'refreshLinksPrioritized',
+ [
+ // Reuse the parser cache if it was saved
+ 'rootJobTimestamp' => $this->mParserOutput->getCacheTime(),
+ 'useRecursiveLinksUpdate' => $this->mRecursive,
+ 'triggeringUser' => $userInfo,
+ 'triggeringRevisionId' => $triggeringRevisionId,
+ ],
+ [ 'removeDuplicates' => true ],
+ $this->getTitle()
+ )
+ ];
+ }
+}
diff --git a/www/wiki/includes/deferred/MWCallableUpdate.php b/www/wiki/includes/deferred/MWCallableUpdate.php
new file mode 100644
index 00000000..5b822af4
--- /dev/null
+++ b/www/wiki/includes/deferred/MWCallableUpdate.php
@@ -0,0 +1,43 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Deferrable Update for closure/callback
+ */
+class MWCallableUpdate implements DeferrableUpdate, DeferrableCallback {
+ /** @var callable|null */
+ private $callback;
+ /** @var string */
+ private $fname;
+
+ /**
+ * @param callable $callback
+ * @param string $fname Calling method
+ * @param IDatabase|null $dbw Abort if this DB is rolled back [optional] (since 1.28)
+ */
+ public function __construct( callable $callback, $fname = 'unknown', IDatabase $dbw = null ) {
+ $this->callback = $callback;
+ $this->fname = $fname;
+
+ if ( $dbw && $dbw->trxLevel() ) {
+ $dbw->onTransactionResolution( [ $this, 'cancelOnRollback' ], $fname );
+ }
+ }
+
+ public function doUpdate() {
+ if ( $this->callback ) {
+ call_user_func( $this->callback );
+ }
+ }
+
+ public function cancelOnRollback( $trigger ) {
+ if ( $trigger === IDatabase::TRIGGER_ROLLBACK ) {
+ $this->callback = null;
+ }
+ }
+
+ public function getOrigin() {
+ return $this->fname;
+ }
+}
diff --git a/www/wiki/includes/deferred/MergeableUpdate.php b/www/wiki/includes/deferred/MergeableUpdate.php
new file mode 100644
index 00000000..70760ce4
--- /dev/null
+++ b/www/wiki/includes/deferred/MergeableUpdate.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * Interface that deferrable updates can implement. DeferredUpdates uses this to merge
+ * all pending updates of PHP class into a single update by calling merge().
+ *
+ * @since 1.27
+ */
+interface MergeableUpdate {
+ /**
+ * Merge this update with $update
+ *
+ * @param MergeableUpdate $update Update of the same class type
+ */
+ function merge( MergeableUpdate $update );
+}
diff --git a/www/wiki/includes/deferred/SearchUpdate.php b/www/wiki/includes/deferred/SearchUpdate.php
new file mode 100644
index 00000000..2766bcb1
--- /dev/null
+++ b/www/wiki/includes/deferred/SearchUpdate.php
@@ -0,0 +1,225 @@
+<?php
+/**
+ * Search index updater
+ *
+ * See deferred.txt
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Database independant search index updater
+ *
+ * @ingroup Search
+ */
+class SearchUpdate implements DeferrableUpdate {
+ /** @var int Page id being updated */
+ private $id = 0;
+
+ /** @var Title Title we're updating */
+ private $title;
+
+ /** @var Content|bool Content of the page (not text) */
+ private $content;
+
+ /** @var WikiPage **/
+ private $page;
+
+ /**
+ * @param int $id Page id to update
+ * @param Title|string $title Title of page to update
+ * @param Content|string|bool $c Content of the page to update. Default: false.
+ * If a Content object, text will be gotten from it. String is for back-compat.
+ * Passing false tells the backend to just update the title, not the content
+ */
+ public function __construct( $id, $title, $c = false ) {
+ if ( is_string( $title ) ) {
+ $nt = Title::newFromText( $title );
+ } else {
+ $nt = $title;
+ }
+
+ if ( $nt ) {
+ $this->id = $id;
+ // is_string() check is back-compat for ApprovedRevs
+ if ( is_string( $c ) ) {
+ $this->content = new TextContent( $c );
+ } else {
+ $this->content = $c ?: false;
+ }
+ $this->title = $nt;
+ } else {
+ wfDebug( "SearchUpdate object created with invalid title '$title'\n" );
+ }
+ }
+
+ /**
+ * Perform actual update for the entry
+ */
+ public function doUpdate() {
+ $config = MediaWikiServices::getInstance()->getSearchEngineConfig();
+
+ if ( $config->getConfig()->get( 'DisableSearchUpdate' ) || !$this->id ) {
+ return;
+ }
+
+ $seFactory = MediaWikiServices::getInstance()->getSearchEngineFactory();
+ foreach ( $config->getSearchTypes() as $type ) {
+ $search = $seFactory->create( $type );
+ if ( !$search->supports( 'search-update' ) ) {
+ continue;
+ }
+
+ $normalTitle = $this->getNormalizedTitle( $search );
+
+ if ( $this->getLatestPage() === null ) {
+ $search->delete( $this->id, $normalTitle );
+ continue;
+ } elseif ( $this->content === false ) {
+ $search->updateTitle( $this->id, $normalTitle );
+ continue;
+ }
+
+ $text = $search->getTextFromContent( $this->title, $this->content );
+ if ( !$search->textAlreadyUpdatedForIndex() ) {
+ $text = $this->updateText( $text, $search );
+ }
+
+ # Perform the actual update
+ $search->update( $this->id, $normalTitle, $search->normalizeText( $text ) );
+ }
+ }
+
+ /**
+ * Clean text for indexing. Only really suitable for indexing in databases.
+ * If you're using a real search engine, you'll probably want to override
+ * this behavior and do something nicer with the original wikitext.
+ * @param string $text
+ * @param SearchEngine $se Search engine
+ * @return string
+ */
+ public function updateText( $text, SearchEngine $se = null ) {
+ global $wgContLang;
+
+ # Language-specific strip/conversion
+ $text = $wgContLang->normalizeForSearch( $text );
+ $se = $se ?: MediaWikiServices::getInstance()->newSearchEngine();
+ $lc = $se->legalSearchChars() . '&#;';
+
+ $text = preg_replace( "/<\\/?\\s*[A-Za-z][^>]*?>/",
+ ' ', $wgContLang->lc( " " . $text . " " ) ); # Strip HTML markup
+ $text = preg_replace( "/(^|\\n)==\\s*([^\\n]+)\\s*==(\\s)/sD",
+ "\\1\\2 \\2 \\2\\3", $text ); # Emphasize headings
+
+ # Strip external URLs
+ $uc = "A-Za-z0-9_\\/:.,~%\\-+&;#?!=()@\\x80-\\xFF";
+ $protos = "http|https|ftp|mailto|news|gopher";
+ $pat = "/(^|[^\\[])({$protos}):[{$uc}]+([^{$uc}]|$)/";
+ $text = preg_replace( $pat, "\\1 \\3", $text );
+
+ $p1 = "/([^\\[])\\[({$protos}):[{$uc}]+]/";
+ $p2 = "/([^\\[])\\[({$protos}):[{$uc}]+\\s+([^\\]]+)]/";
+ $text = preg_replace( $p1, "\\1 ", $text );
+ $text = preg_replace( $p2, "\\1 \\3 ", $text );
+
+ # Internal image links
+ $pat2 = "/\\[\\[image:([{$uc}]+)\\.(gif|png|jpg|jpeg)([^{$uc}])/i";
+ $text = preg_replace( $pat2, " \\1 \\3", $text );
+
+ $text = preg_replace( "/([^{$lc}])([{$lc}]+)]]([a-z]+)/",
+ "\\1\\2 \\2\\3", $text ); # Handle [[game]]s
+
+ # Strip all remaining non-search characters
+ $text = preg_replace( "/[^{$lc}]+/", " ", $text );
+
+ /**
+ * Handle 's, s'
+ *
+ * $text = preg_replace( "/([{$lc}]+)'s /", "\\1 \\1's ", $text );
+ * $text = preg_replace( "/([{$lc}]+)s' /", "\\1s ", $text );
+ *
+ * These tail-anchored regexps are insanely slow. The worst case comes
+ * when Japanese or Chinese text (ie, no word spacing) is written on
+ * a wiki configured for Western UTF-8 mode. The Unicode characters are
+ * expanded to hex codes and the "words" are very long paragraph-length
+ * monstrosities. On a large page the above regexps may take over 20
+ * seconds *each* on a 1GHz-level processor.
+ *
+ * Following are reversed versions which are consistently fast
+ * (about 3 milliseconds on 1GHz-level processor).
+ */
+ $text = strrev( preg_replace( "/ s'([{$lc}]+)/", " s'\\1 \\1", strrev( $text ) ) );
+ $text = strrev( preg_replace( "/ 's([{$lc}]+)/", " s\\1", strrev( $text ) ) );
+
+ # Strip wiki '' and '''
+ $text = preg_replace( "/''[']*/", " ", $text );
+
+ return $text;
+ }
+
+ /**
+ * Get WikiPage for the SearchUpdate $id using WikiPage::READ_LATEST
+ * and ensure using the same WikiPage object if there are multiple
+ * SearchEngine types.
+ *
+ * Returns null if a page has been deleted or is not found.
+ *
+ * @return WikiPage|null
+ */
+ private function getLatestPage() {
+ if ( !isset( $this->page ) ) {
+ $this->page = WikiPage::newFromID( $this->id, WikiPage::READ_LATEST );
+ }
+
+ return $this->page;
+ }
+
+ /**
+ * Get a normalized string representation of a title suitable for
+ * including in a search index
+ *
+ * @param SearchEngine $search
+ * @return string A stripped-down title string ready for the search index
+ */
+ private function getNormalizedTitle( SearchEngine $search ) {
+ global $wgContLang;
+
+ $ns = $this->title->getNamespace();
+ $title = $this->title->getText();
+
+ $lc = $search->legalSearchChars() . '&#;';
+ $t = $wgContLang->normalizeForSearch( $title );
+ $t = preg_replace( "/[^{$lc}]+/", ' ', $t );
+ $t = $wgContLang->lc( $t );
+
+ # Handle 's, s'
+ $t = preg_replace( "/([{$lc}]+)'s( |$)/", "\\1 \\1's ", $t );
+ $t = preg_replace( "/([{$lc}]+)s'( |$)/", "\\1s ", $t );
+
+ $t = preg_replace( "/\\s+/", ' ', $t );
+
+ if ( $ns == NS_FILE ) {
+ $t = preg_replace( "/ (png|gif|jpg|jpeg|ogg)$/", "", $t );
+ }
+
+ return $search->normalizeText( trim( $t ) );
+ }
+}
diff --git a/www/wiki/includes/deferred/SiteStatsUpdate.php b/www/wiki/includes/deferred/SiteStatsUpdate.php
new file mode 100644
index 00000000..2f074ba2
--- /dev/null
+++ b/www/wiki/includes/deferred/SiteStatsUpdate.php
@@ -0,0 +1,271 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Class for handling updates to the site_stats table
+ */
+class SiteStatsUpdate implements DeferrableUpdate, MergeableUpdate {
+ /** @var int */
+ protected $edits = 0;
+ /** @var int */
+ protected $pages = 0;
+ /** @var int */
+ protected $articles = 0;
+ /** @var int */
+ protected $users = 0;
+ /** @var int */
+ protected $images = 0;
+
+ private static $counters = [ 'edits', 'pages', 'articles', 'users', 'images' ];
+
+ // @todo deprecate this constructor
+ function __construct( $views, $edits, $good, $pages = 0, $users = 0 ) {
+ $this->edits = $edits;
+ $this->articles = $good;
+ $this->pages = $pages;
+ $this->users = $users;
+ }
+
+ public function merge( MergeableUpdate $update ) {
+ /** @var SiteStatsUpdate $update */
+ Assert::parameterType( __CLASS__, $update, '$update' );
+
+ foreach ( self::$counters as $field ) {
+ $this->$field += $update->$field;
+ }
+ }
+
+ /**
+ * @param array $deltas
+ * @return SiteStatsUpdate
+ */
+ public static function factory( array $deltas ) {
+ $update = new self( 0, 0, 0 );
+
+ foreach ( self::$counters as $field ) {
+ if ( isset( $deltas[$field] ) && $deltas[$field] ) {
+ $update->$field = $deltas[$field];
+ }
+ }
+
+ return $update;
+ }
+
+ public function doUpdate() {
+ global $wgSiteStatsAsyncFactor;
+
+ $this->doUpdateContextStats();
+
+ $rate = $wgSiteStatsAsyncFactor; // convenience
+ // If set to do so, only do actual DB updates 1 every $rate times.
+ // The other times, just update "pending delta" values in memcached.
+ if ( $rate && ( $rate < 0 || mt_rand( 0, $rate - 1 ) != 0 ) ) {
+ $this->doUpdatePendingDeltas();
+ } else {
+ // Need a separate transaction because this a global lock
+ DeferredUpdates::addCallableUpdate( [ $this, 'tryDBUpdateInternal' ] );
+ }
+ }
+
+ /**
+ * Do not call this outside of SiteStatsUpdate
+ */
+ public function tryDBUpdateInternal() {
+ global $wgSiteStatsAsyncFactor;
+
+ $dbw = wfGetDB( DB_MASTER );
+ $lockKey = wfWikiID() . ':site_stats'; // prepend wiki ID
+ $pd = [];
+ if ( $wgSiteStatsAsyncFactor ) {
+ // Lock the table so we don't have double DB/memcached updates
+ if ( !$dbw->lockIsFree( $lockKey, __METHOD__ )
+ || !$dbw->lock( $lockKey, __METHOD__, 1 ) // 1 sec timeout
+ ) {
+ $this->doUpdatePendingDeltas();
+
+ return;
+ }
+ $pd = $this->getPendingDeltas();
+ // Piggy-back the async deltas onto those of this stats update....
+ $this->edits += ( $pd['ss_total_edits']['+'] - $pd['ss_total_edits']['-'] );
+ $this->articles += ( $pd['ss_good_articles']['+'] - $pd['ss_good_articles']['-'] );
+ $this->pages += ( $pd['ss_total_pages']['+'] - $pd['ss_total_pages']['-'] );
+ $this->users += ( $pd['ss_users']['+'] - $pd['ss_users']['-'] );
+ $this->images += ( $pd['ss_images']['+'] - $pd['ss_images']['-'] );
+ }
+
+ // Build up an SQL query of deltas and apply them...
+ $updates = '';
+ $this->appendUpdate( $updates, 'ss_total_edits', $this->edits );
+ $this->appendUpdate( $updates, 'ss_good_articles', $this->articles );
+ $this->appendUpdate( $updates, 'ss_total_pages', $this->pages );
+ $this->appendUpdate( $updates, 'ss_users', $this->users );
+ $this->appendUpdate( $updates, 'ss_images', $this->images );
+ if ( $updates != '' ) {
+ $dbw->update( 'site_stats', [ $updates ], [], __METHOD__ );
+ }
+
+ if ( $wgSiteStatsAsyncFactor ) {
+ // Decrement the async deltas now that we applied them
+ $this->removePendingDeltas( $pd );
+ // Commit the updates and unlock the table
+ $dbw->unlock( $lockKey, __METHOD__ );
+ }
+
+ // Invalid cache used by parser functions
+ SiteStats::unload();
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @return bool|mixed
+ */
+ public static function cacheUpdate( $dbw ) {
+ global $wgActiveUserDays;
+ $dbr = wfGetDB( DB_REPLICA, 'vslow' );
+ # Get non-bot users than did some recent action other than making accounts.
+ # If account creation is included, the number gets inflated ~20+ fold on enwiki.
+ $activeUsers = $dbr->selectField(
+ 'recentchanges',
+ 'COUNT( DISTINCT rc_user_text )',
+ [
+ 'rc_user != 0',
+ 'rc_bot' => 0,
+ 'rc_log_type != ' . $dbr->addQuotes( 'newusers' ) . ' OR rc_log_type IS NULL',
+ 'rc_timestamp >= ' . $dbr->addQuotes( $dbr->timestamp( wfTimestamp( TS_UNIX )
+ - $wgActiveUserDays * 24 * 3600 ) ),
+ ],
+ __METHOD__
+ );
+ $dbw->update(
+ 'site_stats',
+ [ 'ss_active_users' => intval( $activeUsers ) ],
+ [ 'ss_row_id' => 1 ],
+ __METHOD__
+ );
+
+ // Invalid cache used by parser functions
+ SiteStats::unload();
+
+ return $activeUsers;
+ }
+
+ protected function doUpdateContextStats() {
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ foreach ( [ 'edits', 'articles', 'pages', 'users', 'images' ] as $type ) {
+ $delta = $this->$type;
+ if ( $delta !== 0 ) {
+ $stats->updateCount( "site.$type", $delta );
+ }
+ }
+ }
+
+ protected function doUpdatePendingDeltas() {
+ $this->adjustPending( 'ss_total_edits', $this->edits );
+ $this->adjustPending( 'ss_good_articles', $this->articles );
+ $this->adjustPending( 'ss_total_pages', $this->pages );
+ $this->adjustPending( 'ss_users', $this->users );
+ $this->adjustPending( 'ss_images', $this->images );
+ }
+
+ /**
+ * @param string &$sql
+ * @param string $field
+ * @param int $delta
+ */
+ protected function appendUpdate( &$sql, $field, $delta ) {
+ if ( $delta ) {
+ if ( $sql ) {
+ $sql .= ',';
+ }
+ if ( $delta < 0 ) {
+ $sql .= "$field=$field-" . abs( $delta );
+ } else {
+ $sql .= "$field=$field+" . abs( $delta );
+ }
+ }
+ }
+
+ /**
+ * @param BagOStuff $cache
+ * @param string $type
+ * @param string $sign ('+' or '-')
+ * @return string
+ */
+ private function getTypeCacheKey( BagOStuff $cache, $type, $sign ) {
+ return $cache->makeKey( 'sitestatsupdate', 'pendingdelta', $type, $sign );
+ }
+
+ /**
+ * Adjust the pending deltas for a stat type.
+ * Each stat type has two pending counters, one for increments and decrements
+ * @param string $type
+ * @param int $delta Delta (positive or negative)
+ */
+ protected function adjustPending( $type, $delta ) {
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+ if ( $delta < 0 ) { // decrement
+ $key = $this->getTypeCacheKey( $cache, $type, '-' );
+ } else { // increment
+ $key = $this->getTypeCacheKey( $cache, $type, '+' );
+ }
+
+ $magnitude = abs( $delta );
+ $cache->incrWithInit( $key, 0, $magnitude, $magnitude );
+ }
+
+ /**
+ * Get pending delta counters for each stat type
+ * @return array Positive and negative deltas for each type
+ */
+ protected function getPendingDeltas() {
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+
+ $pending = [];
+ foreach ( [ 'ss_total_edits',
+ 'ss_good_articles', 'ss_total_pages', 'ss_users', 'ss_images' ] as $type
+ ) {
+ // Get pending increments and pending decrements
+ $flg = BagOStuff::READ_LATEST;
+ $pending[$type]['+'] = (int)$cache->get( $this->getTypeCacheKey( $cache, $type, '+' ), $flg );
+ $pending[$type]['-'] = (int)$cache->get( $this->getTypeCacheKey( $cache, $type, '-' ), $flg );
+ }
+
+ return $pending;
+ }
+
+ /**
+ * Reduce pending delta counters after updates have been applied
+ * @param array $pd Result of getPendingDeltas(), used for DB update
+ */
+ protected function removePendingDeltas( array $pd ) {
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+
+ foreach ( $pd as $type => $deltas ) {
+ foreach ( $deltas as $sign => $magnitude ) {
+ // Lower the pending counter now that we applied these changes
+ $cache->decr( $this->getTypeCacheKey( $cache, $type, $sign ), $magnitude );
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/deferred/SqlDataUpdate.php b/www/wiki/includes/deferred/SqlDataUpdate.php
new file mode 100644
index 00000000..2411beff
--- /dev/null
+++ b/www/wiki/includes/deferred/SqlDataUpdate.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Base code for update jobs that put some secondary data extracted
+ * from article content into the database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * @deprecated Since 1.28 Use DataUpdate directly, injecting the database
+ */
+abstract class SqlDataUpdate extends DataUpdate {
+ /** @var IDatabase Database connection reference */
+ protected $mDb;
+ /** @var array SELECT options to be used (array) */
+ protected $mOptions = [];
+
+ public function __construct() {
+ parent::__construct();
+
+ $this->mDb = wfGetLB()->getLazyConnectionRef( DB_MASTER );
+ }
+}
diff --git a/www/wiki/includes/deferred/WANCacheReapUpdate.php b/www/wiki/includes/deferred/WANCacheReapUpdate.php
new file mode 100644
index 00000000..5ffc9388
--- /dev/null
+++ b/www/wiki/includes/deferred/WANCacheReapUpdate.php
@@ -0,0 +1,133 @@
+<?php
+
+use Psr\Log\LoggerInterface;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Class for fixing stale WANObjectCache keys using a purge event source
+ *
+ * This is useful for expiring keys that missed fire-and-forget purges. This uses the
+ * recentchanges table as a reliable stream to make certain keys reach consistency
+ * as soon as the underlying replica database catches up. These means that critical
+ * keys will not escape getting purged simply due to brief hiccups in the network,
+ * which are more prone to happen accross datacenters.
+ *
+ * ----
+ * "I was trying to cheat death. I was only trying to surmount for a little while the
+ * darkness that all my life I surely knew was going to come rolling in on me some day
+ * and obliterate me. I was only to stay alive a little brief while longer, after I was
+ * already gone. To stay in the light, to be with the living, a little while past my time."
+ * -- Notes for "Blues of a Lifetime", by [[Cornell Woolrich]]
+ *
+ * @since 1.28
+ */
+class WANCacheReapUpdate implements DeferrableUpdate {
+ /** @var IDatabase */
+ private $db;
+ /** @var LoggerInterface */
+ private $logger;
+
+ /**
+ * @param IDatabase $db
+ * @param LoggerInterface $logger
+ */
+ public function __construct( IDatabase $db, LoggerInterface $logger ) {
+ $this->db = $db;
+ $this->logger = $logger;
+ }
+
+ function doUpdate() {
+ $reaper = new WANObjectCacheReaper(
+ ObjectCache::getMainWANInstance(),
+ ObjectCache::getLocalClusterInstance(),
+ [ $this, 'getTitleChangeEvents' ],
+ [ $this, 'getEventAffectedKeys' ],
+ [
+ 'channel' => 'table:recentchanges:' . $this->db->getDomainID(),
+ 'logger' => $this->logger
+ ]
+ );
+
+ $reaper->invoke( 100 );
+ }
+
+ /**
+ * @see WANObjectCacheRepear
+ *
+ * @param int $start
+ * @param int $id
+ * @param int $end
+ * @param int $limit
+ * @return TitleValue[]
+ */
+ public function getTitleChangeEvents( $start, $id, $end, $limit ) {
+ $db = $this->db;
+ $encStart = $db->addQuotes( $db->timestamp( $start ) );
+ $encEnd = $db->addQuotes( $db->timestamp( $end ) );
+ $id = (int)$id; // cast NULL => 0 since rc_id is an integer
+
+ $res = $db->select(
+ 'recentchanges',
+ [ 'rc_namespace', 'rc_title', 'rc_timestamp', 'rc_id' ],
+ [
+ $db->makeList( [
+ "rc_timestamp > $encStart",
+ "rc_timestamp = $encStart AND rc_id > " . $db->addQuotes( $id )
+ ], LIST_OR ),
+ "rc_timestamp < $encEnd"
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rc_timestamp ASC, rc_id ASC', 'LIMIT' => $limit ]
+ );
+
+ $events = [];
+ foreach ( $res as $row ) {
+ $events[] = [
+ 'id' => (int)$row->rc_id,
+ 'pos' => (int)wfTimestamp( TS_UNIX, $row->rc_timestamp ),
+ 'item' => new TitleValue( (int)$row->rc_namespace, $row->rc_title )
+ ];
+ }
+
+ return $events;
+ }
+
+ /**
+ * Gets a list of important cache keys associated with a title
+ *
+ * @see WANObjectCacheRepear
+ * @param WANObjectCache $cache
+ * @param TitleValue $t
+ * @return string[]
+ */
+ public function getEventAffectedKeys( WANObjectCache $cache, TitleValue $t ) {
+ /** @var WikiPage[]|LocalFile[]|User[] $entities */
+ $entities = [];
+
+ // You can't create a WikiPage for special pages (-1) or other virtual
+ // namespaces, but special pages do appear in RC sometimes, e.g. for logs
+ // of AbuseFilter filter changes.
+ if ( $t->getNamespace() >= 0 ) {
+ $entities[] = WikiPage::factory( Title::newFromTitleValue( $t ) );
+ }
+
+ if ( $t->inNamespace( NS_FILE ) ) {
+ $entities[] = wfLocalFile( $t->getText() );
+ }
+ if ( $t->inNamespace( NS_USER ) ) {
+ $entities[] = User::newFromName( $t->getText(), false );
+ }
+
+ $keys = [];
+ foreach ( $entities as $entity ) {
+ if ( $entity ) {
+ $keys = array_merge( $keys, $entity->getMutableCacheKeys( $cache ) );
+ }
+ }
+ if ( $keys ) {
+ $this->logger->debug( __CLASS__ . ': got key(s) ' . implode( ', ', $keys ) );
+ }
+
+ return $keys;
+ }
+}
diff --git a/www/wiki/includes/diff/ArrayDiffFormatter.php b/www/wiki/includes/diff/ArrayDiffFormatter.php
new file mode 100644
index 00000000..70a963ba
--- /dev/null
+++ b/www/wiki/includes/diff/ArrayDiffFormatter.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * Portions taken from phpwiki-1.3.3.
+ *
+ * Copyright © 2000, 2001 Geoffrey T. Dairiki <dairiki@dairiki.org>
+ * You may copy this code freely under the conditions of the GPL.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ */
+
+/**
+ * A pseudo-formatter that just passes along the Diff::$edits array
+ * @ingroup DifferenceEngine
+ */
+class ArrayDiffFormatter extends DiffFormatter {
+
+ /**
+ * @param Diff $diff A Diff object.
+ *
+ * @return array[] List of associative arrays, each describing a difference.
+ */
+ public function format( $diff ) {
+ $oldline = 1;
+ $newline = 1;
+ $retval = [];
+ foreach ( $diff->getEdits() as $edit ) {
+ switch ( $edit->getType() ) {
+ case 'add':
+ foreach ( $edit->getClosing() as $line ) {
+ $retval[] = [
+ 'action' => 'add',
+ 'new' => $line,
+ 'newline' => $newline++
+ ];
+ }
+ break;
+ case 'delete':
+ foreach ( $edit->getOrig() as $line ) {
+ $retval[] = [
+ 'action' => 'delete',
+ 'old' => $line,
+ 'oldline' => $oldline++,
+ ];
+ }
+ break;
+ case 'change':
+ foreach ( $edit->getOrig() as $key => $line ) {
+ $retval[] = [
+ 'action' => 'change',
+ 'old' => $line,
+ 'new' => $edit->getClosing( $key ),
+ 'oldline' => $oldline++,
+ 'newline' => $newline++,
+ ];
+ }
+ break;
+ case 'copy':
+ $oldline += count( $edit->getOrig() );
+ $newline += count( $edit->getOrig() );
+ }
+ }
+
+ return $retval;
+ }
+
+}
diff --git a/www/wiki/includes/diff/ComplexityException.php b/www/wiki/includes/diff/ComplexityException.php
new file mode 100644
index 00000000..10ca964a
--- /dev/null
+++ b/www/wiki/includes/diff/ComplexityException.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ */
+
+namespace MediaWiki\Diff;
+
+use Exception;
+
+class ComplexityException extends Exception {
+ public function __construct() {
+ parent::__construct( 'Diff is too complex to generate' );
+ }
+}
diff --git a/www/wiki/includes/diff/DairikiDiff.php b/www/wiki/includes/diff/DairikiDiff.php
new file mode 100644
index 00000000..d76af31a
--- /dev/null
+++ b/www/wiki/includes/diff/DairikiDiff.php
@@ -0,0 +1,334 @@
+<?php
+/**
+ * A PHP diff engine for phpwiki. (Taken from phpwiki-1.3.3)
+ *
+ * Copyright © 2000, 2001 Geoffrey T. Dairiki <dairiki@dairiki.org>
+ * You may copy this code freely under the conditions of the GPL.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ * @defgroup DifferenceEngine DifferenceEngine
+ */
+
+/**
+ * The base class for all other DiffOp classes.
+ *
+ * The classes that extend DiffOp are: DiffOpCopy, DiffOpDelete, DiffOpAdd and
+ * DiffOpChange. FakeDiffOp also extends DiffOp, but it is not located in this file.
+ *
+ * @private
+ * @ingroup DifferenceEngine
+ */
+abstract class DiffOp {
+
+ /**
+ * @var string
+ */
+ public $type;
+
+ /**
+ * @var string[]
+ */
+ public $orig;
+
+ /**
+ * @var string[]
+ */
+ public $closing;
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getOrig() {
+ return $this->orig;
+ }
+
+ /**
+ * @param int $i
+ * @return string[]|string|null
+ */
+ public function getClosing( $i = null ) {
+ if ( $i === null ) {
+ return $this->closing;
+ }
+ if ( array_key_exists( $i, $this->closing ) ) {
+ return $this->closing[$i];
+ }
+ return null;
+ }
+
+ abstract public function reverse();
+
+ /**
+ * @return int
+ */
+ public function norig() {
+ return $this->orig ? count( $this->orig ) : 0;
+ }
+
+ /**
+ * @return int
+ */
+ public function nclosing() {
+ return $this->closing ? count( $this->closing ) : 0;
+ }
+}
+
+/**
+ * Extends DiffOp. Used to mark strings that have been
+ * copied from one string array to the other.
+ *
+ * @private
+ * @ingroup DifferenceEngine
+ */
+class DiffOpCopy extends DiffOp {
+ public $type = 'copy';
+
+ public function __construct( $orig, $closing = false ) {
+ if ( !is_array( $closing ) ) {
+ $closing = $orig;
+ }
+ $this->orig = $orig;
+ $this->closing = $closing;
+ }
+
+ /**
+ * @return DiffOpCopy
+ */
+ public function reverse() {
+ return new DiffOpCopy( $this->closing, $this->orig );
+ }
+}
+
+/**
+ * Extends DiffOp. Used to mark strings that have been
+ * deleted from the first string array.
+ *
+ * @private
+ * @ingroup DifferenceEngine
+ */
+class DiffOpDelete extends DiffOp {
+ public $type = 'delete';
+
+ public function __construct( $lines ) {
+ $this->orig = $lines;
+ $this->closing = false;
+ }
+
+ /**
+ * @return DiffOpAdd
+ */
+ public function reverse() {
+ return new DiffOpAdd( $this->orig );
+ }
+}
+
+/**
+ * Extends DiffOp. Used to mark strings that have been
+ * added from the first string array.
+ *
+ * @private
+ * @ingroup DifferenceEngine
+ */
+class DiffOpAdd extends DiffOp {
+ public $type = 'add';
+
+ public function __construct( $lines ) {
+ $this->closing = $lines;
+ $this->orig = false;
+ }
+
+ /**
+ * @return DiffOpDelete
+ */
+ public function reverse() {
+ return new DiffOpDelete( $this->closing );
+ }
+}
+
+/**
+ * Extends DiffOp. Used to mark strings that have been
+ * changed from the first string array (both added and subtracted).
+ *
+ * @private
+ * @ingroup DifferenceEngine
+ */
+class DiffOpChange extends DiffOp {
+ public $type = 'change';
+
+ public function __construct( $orig, $closing ) {
+ $this->orig = $orig;
+ $this->closing = $closing;
+ }
+
+ /**
+ * @return DiffOpChange
+ */
+ public function reverse() {
+ return new DiffOpChange( $this->closing, $this->orig );
+ }
+}
+
+/**
+ * Class representing a 'diff' between two sequences of strings.
+ * @todo document
+ * @private
+ * @ingroup DifferenceEngine
+ */
+class Diff {
+
+ /**
+ * @var DiffOp[]
+ */
+ public $edits;
+
+ /**
+ * @var int If this diff complexity is exceeded, a ComplexityException is thrown
+ * 0 means no limit.
+ */
+ protected $bailoutComplexity = 0;
+
+ /**
+ * Computes diff between sequences of strings.
+ *
+ * @param string[] $from_lines An array of strings.
+ * Typically these are lines from a file.
+ * @param string[] $to_lines An array of strings.
+ * @throws \MediaWiki\Diff\ComplexityException
+ */
+ public function __construct( $from_lines, $to_lines ) {
+ $eng = new DiffEngine;
+ $eng->setBailoutComplexity( $this->bailoutComplexity );
+ $this->edits = $eng->diff( $from_lines, $to_lines );
+ }
+
+ /**
+ * @return DiffOp[]
+ */
+ public function getEdits() {
+ return $this->edits;
+ }
+
+ /**
+ * Compute reversed Diff.
+ *
+ * SYNOPSIS:
+ *
+ * $diff = new Diff($lines1, $lines2);
+ * $rev = $diff->reverse();
+ *
+ * @return Object A Diff object representing the inverse of the
+ * original diff.
+ */
+ public function reverse() {
+ $rev = $this;
+ $rev->edits = [];
+ /** @var DiffOp $edit */
+ foreach ( $this->edits as $edit ) {
+ $rev->edits[] = $edit->reverse();
+ }
+
+ return $rev;
+ }
+
+ /**
+ * Check for empty diff.
+ *
+ * @return bool True if two sequences were identical.
+ */
+ public function isEmpty() {
+ foreach ( $this->edits as $edit ) {
+ if ( $edit->type != 'copy' ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Compute the length of the Longest Common Subsequence (LCS).
+ *
+ * This is mostly for diagnostic purposed.
+ *
+ * @return int The length of the LCS.
+ */
+ public function lcs() {
+ $lcs = 0;
+ foreach ( $this->edits as $edit ) {
+ if ( $edit->type == 'copy' ) {
+ $lcs += count( $edit->orig );
+ }
+ }
+
+ return $lcs;
+ }
+
+ /**
+ * Get the original set of lines.
+ *
+ * This reconstructs the $from_lines parameter passed to the
+ * constructor.
+ *
+ * @return string[] The original sequence of strings.
+ */
+ public function orig() {
+ $lines = [];
+
+ foreach ( $this->edits as $edit ) {
+ if ( $edit->orig ) {
+ array_splice( $lines, count( $lines ), 0, $edit->orig );
+ }
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Get the closing set of lines.
+ *
+ * This reconstructs the $to_lines parameter passed to the
+ * constructor.
+ *
+ * @return string[] The sequence of strings.
+ */
+ public function closing() {
+ $lines = [];
+
+ foreach ( $this->edits as $edit ) {
+ if ( $edit->closing ) {
+ array_splice( $lines, count( $lines ), 0, $edit->closing );
+ }
+ }
+
+ return $lines;
+ }
+}
+
+/**
+ * @deprecated Alias for WordAccumulator, to be soon removed
+ */
+class HWLDFWordAccumulator extends MediaWiki\Diff\WordAccumulator {
+}
diff --git a/www/wiki/includes/diff/DiffEngine.php b/www/wiki/includes/diff/DiffEngine.php
new file mode 100644
index 00000000..53378e58
--- /dev/null
+++ b/www/wiki/includes/diff/DiffEngine.php
@@ -0,0 +1,841 @@
+<?php
+/**
+ * New version of the difference engine
+ *
+ * Copyright © 2008 Guy Van den Broeck <guy@guyvdb.eu>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ */
+use MediaWiki\Diff\ComplexityException;
+
+/**
+ * This diff implementation is mainly lifted from the LCS algorithm of the Eclipse project which
+ * in turn is based on Myers' "An O(ND) difference algorithm and its variations"
+ * (http://citeseer.ist.psu.edu/myers86ond.html) with range compression (see Wu et al.'s
+ * "An O(NP) Sequence Comparison Algorithm").
+ *
+ * This implementation supports an upper bound on the execution time.
+ *
+ * Some ideas (and a bit of code) are from analyze.c, from GNU
+ * diffutils-2.7, which can be found at:
+ * ftp://gnudist.gnu.org/pub/gnu/diffutils/diffutils-2.7.tar.gz
+ *
+ * Complexity: O((M + N)D) worst case time, O(M + N + D^2) expected time, O(M + N) space
+ *
+ * @author Guy Van den Broeck, Geoffrey T. Dairiki, Tim Starling
+ * @ingroup DifferenceEngine
+ */
+class DiffEngine {
+
+ // Input variables
+ private $from;
+ private $to;
+ private $m;
+ private $n;
+
+ private $tooLong;
+ private $powLimit;
+
+ protected $bailoutComplexity = 0;
+
+ // State variables
+ private $maxDifferences;
+ private $lcsLengthCorrectedForHeuristic = false;
+
+ // Output variables
+ public $length;
+ public $removed;
+ public $added;
+ public $heuristicUsed;
+
+ function __construct( $tooLong = 2000000, $powLimit = 1.45 ) {
+ $this->tooLong = $tooLong;
+ $this->powLimit = $powLimit;
+ }
+
+ /**
+ * Performs diff
+ *
+ * @param string[] $from_lines
+ * @param string[] $to_lines
+ * @throws ComplexityException
+ *
+ * @return DiffOp[]
+ */
+ public function diff( $from_lines, $to_lines ) {
+ // Diff and store locally
+ $this->diffInternal( $from_lines, $to_lines );
+
+ // Merge edits when possible
+ $this->shiftBoundaries( $from_lines, $this->removed, $this->added );
+ $this->shiftBoundaries( $to_lines, $this->added, $this->removed );
+
+ // Compute the edit operations.
+ $n_from = count( $from_lines );
+ $n_to = count( $to_lines );
+
+ $edits = [];
+ $xi = $yi = 0;
+ while ( $xi < $n_from || $yi < $n_to ) {
+ assert( $yi < $n_to || $this->removed[$xi] );
+ assert( $xi < $n_from || $this->added[$yi] );
+
+ // Skip matching "snake".
+ $copy = [];
+ while ( $xi < $n_from && $yi < $n_to
+ && !$this->removed[$xi] && !$this->added[$yi]
+ ) {
+ $copy[] = $from_lines[$xi++];
+ ++$yi;
+ }
+ if ( $copy ) {
+ $edits[] = new DiffOpCopy( $copy );
+ }
+
+ // Find deletes & adds.
+ $delete = [];
+ while ( $xi < $n_from && $this->removed[$xi] ) {
+ $delete[] = $from_lines[$xi++];
+ }
+
+ $add = [];
+ while ( $yi < $n_to && $this->added[$yi] ) {
+ $add[] = $to_lines[$yi++];
+ }
+
+ if ( $delete && $add ) {
+ $edits[] = new DiffOpChange( $delete, $add );
+ } elseif ( $delete ) {
+ $edits[] = new DiffOpDelete( $delete );
+ } elseif ( $add ) {
+ $edits[] = new DiffOpAdd( $add );
+ }
+ }
+
+ return $edits;
+ }
+
+ /**
+ * Sets the complexity (in comparison operations) that can't be exceeded
+ * @param int $value
+ */
+ public function setBailoutComplexity( $value ) {
+ $this->bailoutComplexity = $value;
+ }
+
+ /**
+ * Adjust inserts/deletes of identical lines to join changes
+ * as much as possible.
+ *
+ * We do something when a run of changed lines include a
+ * line at one end and has an excluded, identical line at the other.
+ * We are free to choose which identical line is included.
+ * `compareseq' usually chooses the one at the beginning,
+ * but usually it is cleaner to consider the following identical line
+ * to be the "change".
+ *
+ * This is extracted verbatim from analyze.c (GNU diffutils-2.7).
+ *
+ * @param string[] $lines
+ * @param string[] $changed
+ * @param string[] $other_changed
+ */
+ private function shiftBoundaries( array $lines, array &$changed, array $other_changed ) {
+ $i = 0;
+ $j = 0;
+
+ assert( count( $lines ) == count( $changed ) );
+ $len = count( $lines );
+ $other_len = count( $other_changed );
+
+ while ( 1 ) {
+ /*
+ * Scan forwards to find beginning of another run of changes.
+ * Also keep track of the corresponding point in the other file.
+ *
+ * Throughout this code, $i and $j are adjusted together so that
+ * the first $i elements of $changed and the first $j elements
+ * of $other_changed both contain the same number of zeros
+ * (unchanged lines).
+ * Furthermore, $j is always kept so that $j == $other_len or
+ * $other_changed[$j] == false.
+ */
+ while ( $j < $other_len && $other_changed[$j] ) {
+ $j++;
+ }
+
+ while ( $i < $len && !$changed[$i] ) {
+ assert( $j < $other_len && !$other_changed[$j] );
+ $i++;
+ $j++;
+ while ( $j < $other_len && $other_changed[$j] ) {
+ $j++;
+ }
+ }
+
+ if ( $i == $len ) {
+ break;
+ }
+
+ $start = $i;
+
+ // Find the end of this run of changes.
+ while ( ++$i < $len && $changed[$i] ) {
+ continue;
+ }
+
+ do {
+ /*
+ * Record the length of this run of changes, so that
+ * we can later determine whether the run has grown.
+ */
+ $runlength = $i - $start;
+
+ /*
+ * Move the changed region back, so long as the
+ * previous unchanged line matches the last changed one.
+ * This merges with previous changed regions.
+ */
+ while ( $start > 0 && $lines[$start - 1] == $lines[$i - 1] ) {
+ $changed[--$start] = 1;
+ $changed[--$i] = false;
+ while ( $start > 0 && $changed[$start - 1] ) {
+ $start--;
+ }
+ assert( $j > 0 );
+ while ( $other_changed[--$j] ) {
+ continue;
+ }
+ assert( $j >= 0 && !$other_changed[$j] );
+ }
+
+ /*
+ * Set CORRESPONDING to the end of the changed run, at the last
+ * point where it corresponds to a changed run in the other file.
+ * CORRESPONDING == LEN means no such point has been found.
+ */
+ $corresponding = $j < $other_len ? $i : $len;
+
+ /*
+ * Move the changed region forward, so long as the
+ * first changed line matches the following unchanged one.
+ * This merges with following changed regions.
+ * Do this second, so that if there are no merges,
+ * the changed region is moved forward as far as possible.
+ */
+ while ( $i < $len && $lines[$start] == $lines[$i] ) {
+ $changed[$start++] = false;
+ $changed[$i++] = 1;
+ while ( $i < $len && $changed[$i] ) {
+ $i++;
+ }
+
+ assert( $j < $other_len && !$other_changed[$j] );
+ $j++;
+ if ( $j < $other_len && $other_changed[$j] ) {
+ $corresponding = $i;
+ while ( $j < $other_len && $other_changed[$j] ) {
+ $j++;
+ }
+ }
+ }
+ } while ( $runlength != $i - $start );
+
+ /*
+ * If possible, move the fully-merged run of changes
+ * back to a corresponding run in the other file.
+ */
+ while ( $corresponding < $i ) {
+ $changed[--$start] = 1;
+ $changed[--$i] = 0;
+ assert( $j > 0 );
+ while ( $other_changed[--$j] ) {
+ continue;
+ }
+ assert( $j >= 0 && !$other_changed[$j] );
+ }
+ }
+ }
+
+ /**
+ * @param string[] $from
+ * @param string[] $to
+ * @throws ComplexityException
+ */
+ protected function diffInternal( array $from, array $to ) {
+ // remember initial lengths
+ $m = count( $from );
+ $n = count( $to );
+
+ $this->heuristicUsed = false;
+
+ // output
+ $removed = $m > 0 ? array_fill( 0, $m, true ) : [];
+ $added = $n > 0 ? array_fill( 0, $n, true ) : [];
+
+ // reduce the complexity for the next step (intentionally done twice)
+ // remove common tokens at the start
+ $i = 0;
+ while ( $i < $m && $i < $n && $from[$i] === $to[$i] ) {
+ $removed[$i] = $added[$i] = false;
+ unset( $from[$i], $to[$i] );
+ ++$i;
+ }
+
+ // remove common tokens at the end
+ $j = 1;
+ while ( $i + $j <= $m && $i + $j <= $n && $from[$m - $j] === $to[$n - $j] ) {
+ $removed[$m - $j] = $added[$n - $j] = false;
+ unset( $from[$m - $j], $to[$n - $j] );
+ ++$j;
+ }
+
+ $this->from = $newFromIndex = $this->to = $newToIndex = [];
+
+ // remove tokens not in both sequences
+ $shared = [];
+ foreach ( $from as $key ) {
+ $shared[$key] = false;
+ }
+
+ foreach ( $to as $index => &$el ) {
+ if ( array_key_exists( $el, $shared ) ) {
+ // keep it
+ $this->to[] = $el;
+ $shared[$el] = true;
+ $newToIndex[] = $index;
+ }
+ }
+ foreach ( $from as $index => &$el ) {
+ if ( $shared[$el] ) {
+ // keep it
+ $this->from[] = $el;
+ $newFromIndex[] = $index;
+ }
+ }
+
+ unset( $shared, $from, $to );
+
+ $this->m = count( $this->from );
+ $this->n = count( $this->to );
+
+ if ( $this->bailoutComplexity > 0 && $this->m * $this->n > $this->bailoutComplexity ) {
+ throw new ComplexityException();
+ }
+
+ $this->removed = $this->m > 0 ? array_fill( 0, $this->m, true ) : [];
+ $this->added = $this->n > 0 ? array_fill( 0, $this->n, true ) : [];
+
+ if ( $this->m == 0 || $this->n == 0 ) {
+ $this->length = 0;
+ } else {
+ $this->maxDifferences = ceil( ( $this->m + $this->n ) / 2.0 );
+ if ( $this->m * $this->n > $this->tooLong ) {
+ // limit complexity to D^POW_LIMIT for long sequences
+ $this->maxDifferences = floor( pow( $this->maxDifferences, $this->powLimit - 1.0 ) );
+ wfDebug( "Limiting max number of differences to $this->maxDifferences\n" );
+ }
+
+ /*
+ * The common prefixes and suffixes are always part of some LCS, include
+ * them now to reduce our search space
+ */
+ $max = min( $this->m, $this->n );
+ for ( $forwardBound = 0; $forwardBound < $max
+ && $this->from[$forwardBound] === $this->to[$forwardBound];
+ ++$forwardBound
+ ) {
+ $this->removed[$forwardBound] = $this->added[$forwardBound] = false;
+ }
+
+ $backBoundL1 = $this->m - 1;
+ $backBoundL2 = $this->n - 1;
+
+ while ( $backBoundL1 >= $forwardBound && $backBoundL2 >= $forwardBound
+ && $this->from[$backBoundL1] === $this->to[$backBoundL2]
+ ) {
+ $this->removed[$backBoundL1--] = $this->added[$backBoundL2--] = false;
+ }
+
+ $temp = array_fill( 0, $this->m + $this->n + 1, 0 );
+ $V = [ $temp, $temp ];
+ $snake = [ 0, 0, 0 ];
+
+ $this->length = $forwardBound + $this->m - $backBoundL1 - 1
+ + $this->lcs_rec(
+ $forwardBound,
+ $backBoundL1,
+ $forwardBound,
+ $backBoundL2,
+ $V,
+ $snake
+ );
+ }
+
+ $this->m = $m;
+ $this->n = $n;
+
+ $this->length += $i + $j - 1;
+
+ foreach ( $this->removed as $key => &$removed_elem ) {
+ if ( !$removed_elem ) {
+ $removed[$newFromIndex[$key]] = false;
+ }
+ }
+ foreach ( $this->added as $key => &$added_elem ) {
+ if ( !$added_elem ) {
+ $added[$newToIndex[$key]] = false;
+ }
+ }
+ $this->removed = $removed;
+ $this->added = $added;
+ }
+
+ function diff_range( $from_lines, $to_lines ) {
+ // Diff and store locally
+ $this->diff( $from_lines, $to_lines );
+ unset( $from_lines, $to_lines );
+
+ $ranges = [];
+ $xi = $yi = 0;
+ while ( $xi < $this->m || $yi < $this->n ) {
+ // Matching "snake".
+ while ( $xi < $this->m && $yi < $this->n
+ && !$this->removed[$xi]
+ && !$this->added[$yi]
+ ) {
+ ++$xi;
+ ++$yi;
+ }
+ // Find deletes & adds.
+ $xstart = $xi;
+ while ( $xi < $this->m && $this->removed[$xi] ) {
+ ++$xi;
+ }
+
+ $ystart = $yi;
+ while ( $yi < $this->n && $this->added[$yi] ) {
+ ++$yi;
+ }
+
+ if ( $xi > $xstart || $yi > $ystart ) {
+ $ranges[] = new RangeDifference( $xstart, $xi, $ystart, $yi );
+ }
+ }
+
+ return $ranges;
+ }
+
+ private function lcs_rec( $bottoml1, $topl1, $bottoml2, $topl2, &$V, &$snake ) {
+ // check that both sequences are non-empty
+ if ( $bottoml1 > $topl1 || $bottoml2 > $topl2 ) {
+ return 0;
+ }
+
+ $d = $this->find_middle_snake( $bottoml1, $topl1, $bottoml2,
+ $topl2, $V, $snake );
+
+ // need to store these so we don't lose them when they're
+ // overwritten by the recursion
+ $len = $snake[2];
+ $startx = $snake[0];
+ $starty = $snake[1];
+
+ // the middle snake is part of the LCS, store it
+ for ( $i = 0; $i < $len; ++$i ) {
+ $this->removed[$startx + $i] = $this->added[$starty + $i] = false;
+ }
+
+ if ( $d > 1 ) {
+ return $len
+ + $this->lcs_rec( $bottoml1, $startx - 1, $bottoml2,
+ $starty - 1, $V, $snake )
+ + $this->lcs_rec( $startx + $len, $topl1, $starty + $len,
+ $topl2, $V, $snake );
+ } elseif ( $d == 1 ) {
+ /*
+ * In this case the sequences differ by exactly 1 line. We have
+ * already saved all the lines after the difference in the for loop
+ * above, now we need to save all the lines before the difference.
+ */
+ $max = min( $startx - $bottoml1, $starty - $bottoml2 );
+ for ( $i = 0; $i < $max; ++$i ) {
+ $this->removed[$bottoml1 + $i] =
+ $this->added[$bottoml2 + $i] = false;
+ }
+
+ return $max + $len;
+ }
+
+ return $len;
+ }
+
+ private function find_middle_snake( $bottoml1, $topl1, $bottoml2, $topl2, &$V, &$snake ) {
+ $from = &$this->from;
+ $to = &$this->to;
+ $V0 = &$V[0];
+ $V1 = &$V[1];
+ $snake0 = &$snake[0];
+ $snake1 = &$snake[1];
+ $snake2 = &$snake[2];
+ $bottoml1_min_1 = $bottoml1 - 1;
+ $bottoml2_min_1 = $bottoml2 - 1;
+ $N = $topl1 - $bottoml1_min_1;
+ $M = $topl2 - $bottoml2_min_1;
+ $delta = $N - $M;
+ $maxabsx = $N + $bottoml1;
+ $maxabsy = $M + $bottoml2;
+ $limit = min( $this->maxDifferences, ceil( ( $N + $M ) / 2 ) );
+
+ // value_to_add_forward: a 0 or 1 that we add to the start
+ // offset to make it odd/even
+ if ( ( $M & 1 ) == 1 ) {
+ $value_to_add_forward = 1;
+ } else {
+ $value_to_add_forward = 0;
+ }
+
+ if ( ( $N & 1 ) == 1 ) {
+ $value_to_add_backward = 1;
+ } else {
+ $value_to_add_backward = 0;
+ }
+
+ $start_forward = -$M;
+ $end_forward = $N;
+ $start_backward = -$N;
+ $end_backward = $M;
+
+ $limit_min_1 = $limit - 1;
+ $limit_plus_1 = $limit + 1;
+
+ $V0[$limit_plus_1] = 0;
+ $V1[$limit_min_1] = $N;
+ $limit = min( $this->maxDifferences, ceil( ( $N + $M ) / 2 ) );
+
+ if ( ( $delta & 1 ) == 1 ) {
+ for ( $d = 0; $d <= $limit; ++$d ) {
+ $start_diag = max( $value_to_add_forward + $start_forward, -$d );
+ $end_diag = min( $end_forward, $d );
+ $value_to_add_forward = 1 - $value_to_add_forward;
+
+ // compute forward furthest reaching paths
+ for ( $k = $start_diag; $k <= $end_diag; $k += 2 ) {
+ if ( $k == -$d || ( $k < $d
+ && $V0[$limit_min_1 + $k] < $V0[$limit_plus_1 + $k] )
+ ) {
+ $x = $V0[$limit_plus_1 + $k];
+ } else {
+ $x = $V0[$limit_min_1 + $k] + 1;
+ }
+
+ $absx = $snake0 = $x + $bottoml1;
+ $absy = $snake1 = $x - $k + $bottoml2;
+
+ while ( $absx < $maxabsx && $absy < $maxabsy && $from[$absx] === $to[$absy] ) {
+ ++$absx;
+ ++$absy;
+ }
+ $x = $absx - $bottoml1;
+
+ $snake2 = $absx - $snake0;
+ $V0[$limit + $k] = $x;
+ if ( $k >= $delta - $d + 1 && $k <= $delta + $d - 1
+ && $x >= $V1[$limit + $k - $delta]
+ ) {
+ return 2 * $d - 1;
+ }
+
+ // check to see if we can cut down the diagonal range
+ if ( $x >= $N && $end_forward > $k - 1 ) {
+ $end_forward = $k - 1;
+ } elseif ( $absy - $bottoml2 >= $M ) {
+ $start_forward = $k + 1;
+ $value_to_add_forward = 0;
+ }
+ }
+
+ $start_diag = max( $value_to_add_backward + $start_backward, -$d );
+ $end_diag = min( $end_backward, $d );
+ $value_to_add_backward = 1 - $value_to_add_backward;
+
+ // compute backward furthest reaching paths
+ for ( $k = $start_diag; $k <= $end_diag; $k += 2 ) {
+ if ( $k == $d
+ || ( $k != -$d && $V1[$limit_min_1 + $k] < $V1[$limit_plus_1 + $k] )
+ ) {
+ $x = $V1[$limit_min_1 + $k];
+ } else {
+ $x = $V1[$limit_plus_1 + $k] - 1;
+ }
+
+ $y = $x - $k - $delta;
+
+ $snake2 = 0;
+ while ( $x > 0 && $y > 0
+ && $from[$x + $bottoml1_min_1] === $to[$y + $bottoml2_min_1]
+ ) {
+ --$x;
+ --$y;
+ ++$snake2;
+ }
+ $V1[$limit + $k] = $x;
+
+ // check to see if we can cut down our diagonal range
+ if ( $x <= 0 ) {
+ $start_backward = $k + 1;
+ $value_to_add_backward = 0;
+ } elseif ( $y <= 0 && $end_backward > $k - 1 ) {
+ $end_backward = $k - 1;
+ }
+ }
+ }
+ } else {
+ for ( $d = 0; $d <= $limit; ++$d ) {
+ $start_diag = max( $value_to_add_forward + $start_forward, -$d );
+ $end_diag = min( $end_forward, $d );
+ $value_to_add_forward = 1 - $value_to_add_forward;
+
+ // compute forward furthest reaching paths
+ for ( $k = $start_diag; $k <= $end_diag; $k += 2 ) {
+ if ( $k == -$d
+ || ( $k < $d && $V0[$limit_min_1 + $k] < $V0[$limit_plus_1 + $k] )
+ ) {
+ $x = $V0[$limit_plus_1 + $k];
+ } else {
+ $x = $V0[$limit_min_1 + $k] + 1;
+ }
+
+ $absx = $snake0 = $x + $bottoml1;
+ $absy = $snake1 = $x - $k + $bottoml2;
+
+ while ( $absx < $maxabsx && $absy < $maxabsy && $from[$absx] === $to[$absy] ) {
+ ++$absx;
+ ++$absy;
+ }
+ $x = $absx - $bottoml1;
+ $snake2 = $absx - $snake0;
+ $V0[$limit + $k] = $x;
+
+ // check to see if we can cut down the diagonal range
+ if ( $x >= $N && $end_forward > $k - 1 ) {
+ $end_forward = $k - 1;
+ } elseif ( $absy - $bottoml2 >= $M ) {
+ $start_forward = $k + 1;
+ $value_to_add_forward = 0;
+ }
+ }
+
+ $start_diag = max( $value_to_add_backward + $start_backward, -$d );
+ $end_diag = min( $end_backward, $d );
+ $value_to_add_backward = 1 - $value_to_add_backward;
+
+ // compute backward furthest reaching paths
+ for ( $k = $start_diag; $k <= $end_diag; $k += 2 ) {
+ if ( $k == $d
+ || ( $k != -$d && $V1[$limit_min_1 + $k] < $V1[$limit_plus_1 + $k] )
+ ) {
+ $x = $V1[$limit_min_1 + $k];
+ } else {
+ $x = $V1[$limit_plus_1 + $k] - 1;
+ }
+
+ $y = $x - $k - $delta;
+
+ $snake2 = 0;
+ while ( $x > 0 && $y > 0
+ && $from[$x + $bottoml1_min_1] === $to[$y + $bottoml2_min_1]
+ ) {
+ --$x;
+ --$y;
+ ++$snake2;
+ }
+ $V1[$limit + $k] = $x;
+
+ if ( $k >= -$delta - $d && $k <= $d - $delta
+ && $x <= $V0[$limit + $k + $delta]
+ ) {
+ $snake0 = $bottoml1 + $x;
+ $snake1 = $bottoml2 + $y;
+
+ return 2 * $d;
+ }
+
+ // check to see if we can cut down our diagonal range
+ if ( $x <= 0 ) {
+ $start_backward = $k + 1;
+ $value_to_add_backward = 0;
+ } elseif ( $y <= 0 && $end_backward > $k - 1 ) {
+ $end_backward = $k - 1;
+ }
+ }
+ }
+ }
+ /*
+ * computing the true LCS is too expensive, instead find the diagonal
+ * with the most progress and pretend a midle snake of length 0 occurs
+ * there.
+ */
+
+ $most_progress = self::findMostProgress( $M, $N, $limit, $V );
+
+ $snake0 = $bottoml1 + $most_progress[0];
+ $snake1 = $bottoml2 + $most_progress[1];
+ $snake2 = 0;
+ wfDebug( "Computing the LCS is too expensive. Using a heuristic.\n" );
+ $this->heuristicUsed = true;
+
+ return 5; /*
+ * HACK: since we didn't really finish the LCS computation
+ * we don't really know the length of the SES. We don't do
+ * anything with the result anyway, unless it's <=1. We know
+ * for a fact SES > 1 so 5 is as good a number as any to
+ * return here
+ */
+ }
+
+ private static function findMostProgress( $M, $N, $limit, $V ) {
+ $delta = $N - $M;
+
+ if ( ( $M & 1 ) == ( $limit & 1 ) ) {
+ $forward_start_diag = max( -$M, -$limit );
+ } else {
+ $forward_start_diag = max( 1 - $M, -$limit );
+ }
+
+ $forward_end_diag = min( $N, $limit );
+
+ if ( ( $N & 1 ) == ( $limit & 1 ) ) {
+ $backward_start_diag = max( -$N, -$limit );
+ } else {
+ $backward_start_diag = max( 1 - $N, -$limit );
+ }
+
+ $backward_end_diag = -min( $M, $limit );
+
+ $temp = [ 0, 0, 0 ];
+
+ $max_progress = array_fill( 0, ceil( max( $forward_end_diag - $forward_start_diag,
+ $backward_end_diag - $backward_start_diag ) / 2 ), $temp );
+ $num_progress = 0; // the 1st entry is current, it is initialized
+ // with 0s
+
+ // first search the forward diagonals
+ for ( $k = $forward_start_diag; $k <= $forward_end_diag; $k += 2 ) {
+ $x = $V[0][$limit + $k];
+ $y = $x - $k;
+ if ( $x > $N || $y > $M ) {
+ continue;
+ }
+
+ $progress = $x + $y;
+ if ( $progress > $max_progress[0][2] ) {
+ $num_progress = 0;
+ $max_progress[0][0] = $x;
+ $max_progress[0][1] = $y;
+ $max_progress[0][2] = $progress;
+ } elseif ( $progress == $max_progress[0][2] ) {
+ ++$num_progress;
+ $max_progress[$num_progress][0] = $x;
+ $max_progress[$num_progress][1] = $y;
+ $max_progress[$num_progress][2] = $progress;
+ }
+ }
+
+ $max_progress_forward = true; // initially the maximum
+ // progress is in the forward
+ // direction
+
+ // now search the backward diagonals
+ for ( $k = $backward_start_diag; $k <= $backward_end_diag; $k += 2 ) {
+ $x = $V[1][$limit + $k];
+ $y = $x - $k - $delta;
+ if ( $x < 0 || $y < 0 ) {
+ continue;
+ }
+
+ $progress = $N - $x + $M - $y;
+ if ( $progress > $max_progress[0][2] ) {
+ $num_progress = 0;
+ $max_progress_forward = false;
+ $max_progress[0][0] = $x;
+ $max_progress[0][1] = $y;
+ $max_progress[0][2] = $progress;
+ } elseif ( $progress == $max_progress[0][2] && !$max_progress_forward ) {
+ ++$num_progress;
+ $max_progress[$num_progress][0] = $x;
+ $max_progress[$num_progress][1] = $y;
+ $max_progress[$num_progress][2] = $progress;
+ }
+ }
+
+ // return the middle diagonal with maximal progress.
+ return $max_progress[(int)floor( $num_progress / 2 )];
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getLcsLength() {
+ if ( $this->heuristicUsed && !$this->lcsLengthCorrectedForHeuristic ) {
+ $this->lcsLengthCorrectedForHeuristic = true;
+ $this->length = $this->m - array_sum( $this->added );
+ }
+
+ return $this->length;
+ }
+
+}
+
+/**
+ * Alternative representation of a set of changes, by the index
+ * ranges that are changed.
+ *
+ * @ingroup DifferenceEngine
+ */
+class RangeDifference {
+
+ /** @var int */
+ public $leftstart;
+
+ /** @var int */
+ public $leftend;
+
+ /** @var int */
+ public $leftlength;
+
+ /** @var int */
+ public $rightstart;
+
+ /** @var int */
+ public $rightend;
+
+ /** @var int */
+ public $rightlength;
+
+ function __construct( $leftstart, $leftend, $rightstart, $rightend ) {
+ $this->leftstart = $leftstart;
+ $this->leftend = $leftend;
+ $this->leftlength = $leftend - $leftstart;
+ $this->rightstart = $rightstart;
+ $this->rightend = $rightend;
+ $this->rightlength = $rightend - $rightstart;
+ }
+
+}
diff --git a/www/wiki/includes/diff/DiffFormatter.php b/www/wiki/includes/diff/DiffFormatter.php
new file mode 100644
index 00000000..07124c02
--- /dev/null
+++ b/www/wiki/includes/diff/DiffFormatter.php
@@ -0,0 +1,254 @@
+<?php
+/**
+ * Base for diff rendering classes. Portions taken from phpwiki-1.3.3.
+ *
+ * Copyright © 2000, 2001 Geoffrey T. Dairiki <dairiki@dairiki.org>
+ * You may copy this code freely under the conditions of the GPL.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ */
+
+/**
+ * Base class for diff formatters
+ *
+ * This class formats the diff in classic diff format.
+ * It is intended that this class be customized via inheritance,
+ * to obtain fancier outputs.
+ * @todo document
+ * @ingroup DifferenceEngine
+ */
+abstract class DiffFormatter {
+
+ /** @var int Number of leading context "lines" to preserve.
+ *
+ * This should be left at zero for this class, but subclasses
+ * may want to set this to other values.
+ */
+ protected $leadingContextLines = 0;
+
+ /** @var int Number of trailing context "lines" to preserve.
+ *
+ * This should be left at zero for this class, but subclasses
+ * may want to set this to other values.
+ */
+ protected $trailingContextLines = 0;
+
+ /** @var string The output buffer; holds the output while it is built. */
+ private $result = '';
+
+ /**
+ * Format a diff.
+ *
+ * @param Diff $diff
+ *
+ * @return string The formatted output.
+ */
+ public function format( $diff ) {
+ $xi = $yi = 1;
+ $block = false;
+ $context = [];
+
+ $nlead = $this->leadingContextLines;
+ $ntrail = $this->trailingContextLines;
+
+ $this->startDiff();
+
+ // Initialize $x0 and $y0 to prevent IDEs from getting confused.
+ $x0 = $y0 = 0;
+ foreach ( $diff->edits as $edit ) {
+ if ( $edit->type == 'copy' ) {
+ if ( is_array( $block ) ) {
+ if ( count( $edit->orig ) <= $nlead + $ntrail ) {
+ $block[] = $edit;
+ } else {
+ if ( $ntrail ) {
+ $context = array_slice( $edit->orig, 0, $ntrail );
+ $block[] = new DiffOpCopy( $context );
+ }
+ $this->block( $x0, $ntrail + $xi - $x0,
+ $y0, $ntrail + $yi - $y0,
+ $block );
+ $block = false;
+ }
+ }
+ $context = $edit->orig;
+ } else {
+ if ( !is_array( $block ) ) {
+ $context = array_slice( $context, count( $context ) - $nlead );
+ $x0 = $xi - count( $context );
+ $y0 = $yi - count( $context );
+ $block = [];
+ if ( $context ) {
+ $block[] = new DiffOpCopy( $context );
+ }
+ }
+ $block[] = $edit;
+ }
+
+ if ( $edit->orig ) {
+ $xi += count( $edit->orig );
+ }
+ if ( $edit->closing ) {
+ $yi += count( $edit->closing );
+ }
+ }
+
+ if ( is_array( $block ) ) {
+ $this->block( $x0, $xi - $x0,
+ $y0, $yi - $y0,
+ $block );
+ }
+
+ $end = $this->endDiff();
+
+ return $end;
+ }
+
+ /**
+ * @param int $xbeg
+ * @param int $xlen
+ * @param int $ybeg
+ * @param int $ylen
+ * @param array &$edits
+ *
+ * @throws MWException If the edit type is not known.
+ */
+ protected function block( $xbeg, $xlen, $ybeg, $ylen, &$edits ) {
+ $this->startBlock( $this->blockHeader( $xbeg, $xlen, $ybeg, $ylen ) );
+ foreach ( $edits as $edit ) {
+ if ( $edit->type == 'copy' ) {
+ $this->context( $edit->orig );
+ } elseif ( $edit->type == 'add' ) {
+ $this->added( $edit->closing );
+ } elseif ( $edit->type == 'delete' ) {
+ $this->deleted( $edit->orig );
+ } elseif ( $edit->type == 'change' ) {
+ $this->changed( $edit->orig, $edit->closing );
+ } else {
+ throw new MWException( "Unknown edit type: {$edit->type}" );
+ }
+ }
+ $this->endBlock();
+ }
+
+ protected function startDiff() {
+ $this->result = '';
+ }
+
+ /**
+ * Writes a string to the output buffer.
+ *
+ * @param string $text
+ */
+ protected function writeOutput( $text ) {
+ $this->result .= $text;
+ }
+
+ /**
+ * @return string
+ */
+ protected function endDiff() {
+ $val = $this->result;
+ $this->result = '';
+
+ return $val;
+ }
+
+ /**
+ * @param int $xbeg
+ * @param int $xlen
+ * @param int $ybeg
+ * @param int $ylen
+ *
+ * @return string
+ */
+ protected function blockHeader( $xbeg, $xlen, $ybeg, $ylen ) {
+ if ( $xlen > 1 ) {
+ $xbeg .= ',' . ( $xbeg + $xlen - 1 );
+ }
+ if ( $ylen > 1 ) {
+ $ybeg .= ',' . ( $ybeg + $ylen - 1 );
+ }
+
+ return $xbeg . ( $xlen ? ( $ylen ? 'c' : 'd' ) : 'a' ) . $ybeg;
+ }
+
+ /**
+ * Called at the start of a block of connected edits.
+ * This default implementation writes the header and a newline to the output buffer.
+ *
+ * @param string $header
+ */
+ protected function startBlock( $header ) {
+ $this->writeOutput( $header . "\n" );
+ }
+
+ /**
+ * Called at the end of a block of connected edits.
+ * This default implementation does nothing.
+ */
+ protected function endBlock() {
+ }
+
+ /**
+ * Writes all (optionally prefixed) lines to the output buffer, separated by newlines.
+ *
+ * @param string[] $lines
+ * @param string $prefix
+ */
+ protected function lines( $lines, $prefix = ' ' ) {
+ foreach ( $lines as $line ) {
+ $this->writeOutput( "$prefix $line\n" );
+ }
+ }
+
+ /**
+ * @param string[] $lines
+ */
+ protected function context( $lines ) {
+ $this->lines( $lines );
+ }
+
+ /**
+ * @param string[] $lines
+ */
+ protected function added( $lines ) {
+ $this->lines( $lines, '>' );
+ }
+
+ /**
+ * @param string[] $lines
+ */
+ protected function deleted( $lines ) {
+ $this->lines( $lines, '<' );
+ }
+
+ /**
+ * Writes the two sets of lines to the output buffer, separated by "---" and a newline.
+ *
+ * @param string[] $orig
+ * @param string[] $closing
+ */
+ protected function changed( $orig, $closing ) {
+ $this->deleted( $orig );
+ $this->writeOutput( "---\n" );
+ $this->added( $closing );
+ }
+
+}
diff --git a/www/wiki/includes/diff/DifferenceEngine.php b/www/wiki/includes/diff/DifferenceEngine.php
new file mode 100644
index 00000000..a893fe88
--- /dev/null
+++ b/www/wiki/includes/diff/DifferenceEngine.php
@@ -0,0 +1,1442 @@
+<?php
+/**
+ * User interface for the difference engine.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ */
+use MediaWiki\MediaWikiServices;
+
+/** @deprecated use class constant instead */
+define( 'MW_DIFF_VERSION', '1.11a' );
+
+/**
+ * @todo document
+ * @ingroup DifferenceEngine
+ */
+class DifferenceEngine extends ContextSource {
+ /**
+ * Constant to indicate diff cache compatibility.
+ * Bump this when changing the diff formatting in a way that
+ * fixes important bugs or such to force cached diff views to
+ * clear.
+ */
+ const DIFF_VERSION = MW_DIFF_VERSION;
+
+ /** @var int */
+ public $mOldid;
+
+ /** @var int */
+ public $mNewid;
+
+ private $mOldTags;
+ private $mNewTags;
+
+ /** @var Content */
+ public $mOldContent;
+
+ /** @var Content */
+ public $mNewContent;
+
+ /** @var Language */
+ protected $mDiffLang;
+
+ /** @var Title */
+ public $mOldPage;
+
+ /** @var Title */
+ public $mNewPage;
+
+ /** @var Revision */
+ public $mOldRev;
+
+ /** @var Revision */
+ public $mNewRev;
+
+ /** @var bool Have the revisions IDs been loaded */
+ private $mRevisionsIdsLoaded = false;
+
+ /** @var bool Have the revisions been loaded */
+ public $mRevisionsLoaded = false;
+
+ /** @var int How many text blobs have been loaded, 0, 1 or 2? */
+ public $mTextLoaded = 0;
+
+ /** @var bool Was the diff fetched from cache? */
+ public $mCacheHit = false;
+
+ /**
+ * Set this to true to add debug info to the HTML output.
+ * Warning: this may cause RSS readers to spuriously mark articles as "new"
+ * (T22601)
+ */
+ public $enableDebugComment = false;
+
+ /** @var bool If true, line X is not displayed when X is 1, for example
+ * to increase readability and conserve space with many small diffs.
+ */
+ protected $mReducedLineNumbers = false;
+
+ /** @var string Link to action=markpatrolled */
+ protected $mMarkPatrolledLink = null;
+
+ /** @var bool Show rev_deleted content if allowed */
+ protected $unhide = false;
+
+ /** @var bool Refresh the diff cache */
+ protected $mRefreshCache = false;
+
+ /**#@-*/
+
+ /**
+ * @param IContextSource $context Context to use, anything else will be ignored
+ * @param int $old Old ID we want to show and diff with.
+ * @param string|int $new Either revision ID or 'prev' or 'next'. Default: 0.
+ * @param int $rcid Deprecated, no longer used!
+ * @param bool $refreshCache If set, refreshes the diff cache
+ * @param bool $unhide If set, allow viewing deleted revs
+ */
+ public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
+ $refreshCache = false, $unhide = false
+ ) {
+ if ( $context instanceof IContextSource ) {
+ $this->setContext( $context );
+ }
+
+ wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'\n" );
+
+ $this->mOldid = $old;
+ $this->mNewid = $new;
+ $this->mRefreshCache = $refreshCache;
+ $this->unhide = $unhide;
+ }
+
+ /**
+ * @param bool $value
+ */
+ public function setReducedLineNumbers( $value = true ) {
+ $this->mReducedLineNumbers = $value;
+ }
+
+ /**
+ * @return Language
+ */
+ public function getDiffLang() {
+ if ( $this->mDiffLang === null ) {
+ # Default language in which the diff text is written.
+ $this->mDiffLang = $this->getTitle()->getPageLanguage();
+ }
+
+ return $this->mDiffLang;
+ }
+
+ /**
+ * @return bool
+ */
+ public function wasCacheHit() {
+ return $this->mCacheHit;
+ }
+
+ /**
+ * @return int
+ */
+ public function getOldid() {
+ $this->loadRevisionIds();
+
+ return $this->mOldid;
+ }
+
+ /**
+ * @return bool|int
+ */
+ public function getNewid() {
+ $this->loadRevisionIds();
+
+ return $this->mNewid;
+ }
+
+ /**
+ * Look up a special:Undelete link to the given deleted revision id,
+ * as a workaround for being unable to load deleted diffs in currently.
+ *
+ * @param int $id Revision ID
+ *
+ * @return string|bool Link HTML or false
+ */
+ public function deletedLink( $id ) {
+ if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow( 'archive',
+ array_merge(
+ Revision::selectArchiveFields(),
+ [ 'ar_namespace', 'ar_title' ]
+ ),
+ [ 'ar_rev_id' => $id ],
+ __METHOD__ );
+ if ( $row ) {
+ $rev = Revision::newFromArchiveRow( $row );
+ $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
+
+ return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
+ 'target' => $title->getPrefixedText(),
+ 'timestamp' => $rev->getTimestamp()
+ ] );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Build a wikitext link toward a deleted revision, if viewable.
+ *
+ * @param int $id Revision ID
+ *
+ * @return string Wikitext fragment
+ */
+ public function deletedIdMarker( $id ) {
+ $link = $this->deletedLink( $id );
+ if ( $link ) {
+ return "[$link $id]";
+ } else {
+ return (string)$id;
+ }
+ }
+
+ private function showMissingRevision() {
+ $out = $this->getOutput();
+
+ $missing = [];
+ if ( $this->mOldRev === null ||
+ ( $this->mOldRev && $this->mOldContent === null )
+ ) {
+ $missing[] = $this->deletedIdMarker( $this->mOldid );
+ }
+ if ( $this->mNewRev === null ||
+ ( $this->mNewRev && $this->mNewContent === null )
+ ) {
+ $missing[] = $this->deletedIdMarker( $this->mNewid );
+ }
+
+ $out->setPageTitle( $this->msg( 'errorpagetitle' ) );
+ $msg = $this->msg( 'difference-missing-revision' )
+ ->params( $this->getLanguage()->listToText( $missing ) )
+ ->numParams( count( $missing ) )
+ ->parseAsBlock();
+ $out->addHTML( $msg );
+ }
+
+ public function showDiffPage( $diffOnly = false ) {
+ # Allow frames except in certain special cases
+ $out = $this->getOutput();
+ $out->allowClickjacking();
+ $out->setRobotPolicy( 'noindex,nofollow' );
+
+ // Allow extensions to add any extra output here
+ Hooks::run( 'DifferenceEngineShowDiffPage', [ $out ] );
+
+ if ( !$this->loadRevisionData() ) {
+ if ( Hooks::run( 'DifferenceEngineShowDiffPageMaybeShowMissingRevision', [ $this ] ) ) {
+ $this->showMissingRevision();
+ }
+ return;
+ }
+
+ $user = $this->getUser();
+ $permErrors = $this->mNewPage->getUserPermissionsErrors( 'read', $user );
+ if ( $this->mOldPage ) { # mOldPage might not be set, see below.
+ $permErrors = wfMergeErrorArrays( $permErrors,
+ $this->mOldPage->getUserPermissionsErrors( 'read', $user ) );
+ }
+ if ( count( $permErrors ) ) {
+ throw new PermissionsError( 'read', $permErrors );
+ }
+
+ $rollback = '';
+
+ $query = [];
+ # Carry over 'diffonly' param via navigation links
+ if ( $diffOnly != $user->getBoolOption( 'diffonly' ) ) {
+ $query['diffonly'] = $diffOnly;
+ }
+ # Cascade unhide param in links for easy deletion browsing
+ if ( $this->unhide ) {
+ $query['unhide'] = 1;
+ }
+
+ # Check if one of the revisions is deleted/suppressed
+ $deleted = $suppressed = false;
+ $allowed = $this->mNewRev->userCan( Revision::DELETED_TEXT, $user );
+
+ $revisionTools = [];
+
+ # mOldRev is false if the difference engine is called with a "vague" query for
+ # a diff between a version V and its previous version V' AND the version V
+ # is the first version of that article. In that case, V' does not exist.
+ if ( $this->mOldRev === false ) {
+ $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
+ $samePage = true;
+ $oldHeader = '';
+ // Allow extensions to change the $oldHeader variable
+ Hooks::run( 'DifferenceEngineOldHeaderNoOldRev', [ &$oldHeader ] );
+ } else {
+ Hooks::run( 'DiffViewHeader', [ $this, $this->mOldRev, $this->mNewRev ] );
+
+ if ( $this->mNewPage->equals( $this->mOldPage ) ) {
+ $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
+ $samePage = true;
+ } else {
+ $out->setPageTitle( $this->msg( 'difference-title-multipage',
+ $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
+ $out->addSubtitle( $this->msg( 'difference-multipage' ) );
+ $samePage = false;
+ }
+
+ if ( $samePage && $this->mNewPage->quickUserCan( 'edit', $user ) ) {
+ if ( $this->mNewRev->isCurrent() && $this->mNewPage->userCan( 'rollback', $user ) ) {
+ $rollbackLink = Linker::generateRollback( $this->mNewRev, $this->getContext() );
+ if ( $rollbackLink ) {
+ $out->preventClickjacking();
+ $rollback = '&#160;&#160;&#160;' . $rollbackLink;
+ }
+ }
+
+ if ( !$this->mOldRev->isDeleted( Revision::DELETED_TEXT ) &&
+ !$this->mNewRev->isDeleted( Revision::DELETED_TEXT )
+ ) {
+ $undoLink = Html::element( 'a', [
+ 'href' => $this->mNewPage->getLocalURL( [
+ 'action' => 'edit',
+ 'undoafter' => $this->mOldid,
+ 'undo' => $this->mNewid
+ ] ),
+ 'title' => Linker::titleAttrib( 'undo' ),
+ ],
+ $this->msg( 'editundo' )->text()
+ );
+ $revisionTools['mw-diff-undo'] = $undoLink;
+ }
+ }
+
+ # Make "previous revision link"
+ if ( $samePage && $this->mOldRev->getPrevious() ) {
+ $prevlink = Linker::linkKnown(
+ $this->mOldPage,
+ $this->msg( 'previousdiff' )->escaped(),
+ [ 'id' => 'differences-prevlink' ],
+ [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query
+ );
+ } else {
+ $prevlink = '&#160;';
+ }
+
+ if ( $this->mOldRev->isMinor() ) {
+ $oldminor = ChangesList::flag( 'minor' );
+ } else {
+ $oldminor = '';
+ }
+
+ $ldel = $this->revisionDeleteLink( $this->mOldRev );
+ $oldRevisionHeader = $this->getRevisionHeader( $this->mOldRev, 'complete' );
+ $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
+
+ $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
+ '<div id="mw-diff-otitle2">' .
+ Linker::revUserTools( $this->mOldRev, !$this->unhide ) . '</div>' .
+ '<div id="mw-diff-otitle3">' . $oldminor .
+ Linker::revComment( $this->mOldRev, !$diffOnly, !$this->unhide ) . $ldel . '</div>' .
+ '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
+ '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
+
+ // Allow extensions to change the $oldHeader variable
+ Hooks::run( 'DifferenceEngineOldHeader', [ $this, &$oldHeader, $prevlink, $oldminor,
+ $diffOnly, $ldel, $this->unhide ] );
+
+ if ( $this->mOldRev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $deleted = true; // old revisions text is hidden
+ if ( $this->mOldRev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
+ $suppressed = true; // also suppressed
+ }
+ }
+
+ # Check if this user can see the revisions
+ if ( !$this->mOldRev->userCan( Revision::DELETED_TEXT, $user ) ) {
+ $allowed = false;
+ }
+ }
+
+ $out->addJsConfigVars( [
+ 'wgDiffOldId' => $this->mOldid,
+ 'wgDiffNewId' => $this->mNewid,
+ ] );
+
+ # Make "next revision link"
+ # Skip next link on the top revision
+ if ( $samePage && !$this->mNewRev->isCurrent() ) {
+ $nextlink = Linker::linkKnown(
+ $this->mNewPage,
+ $this->msg( 'nextdiff' )->escaped(),
+ [ 'id' => 'differences-nextlink' ],
+ [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query
+ );
+ } else {
+ $nextlink = '&#160;';
+ }
+
+ if ( $this->mNewRev->isMinor() ) {
+ $newminor = ChangesList::flag( 'minor' );
+ } else {
+ $newminor = '';
+ }
+
+ # Handle RevisionDelete links...
+ $rdel = $this->revisionDeleteLink( $this->mNewRev );
+
+ # Allow extensions to define their own revision tools
+ Hooks::run( 'DiffRevisionTools',
+ [ $this->mNewRev, &$revisionTools, $this->mOldRev, $user ] );
+ $formattedRevisionTools = [];
+ // Put each one in parentheses (poor man's button)
+ foreach ( $revisionTools as $key => $tool ) {
+ $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
+ $element = Html::rawElement(
+ 'span',
+ [ 'class' => $toolClass ],
+ $this->msg( 'parentheses' )->rawParams( $tool )->escaped()
+ );
+ $formattedRevisionTools[] = $element;
+ }
+ $newRevisionHeader = $this->getRevisionHeader( $this->mNewRev, 'complete' ) .
+ ' ' . implode( ' ', $formattedRevisionTools );
+ $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
+
+ $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
+ '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $this->mNewRev, !$this->unhide ) .
+ " $rollback</div>" .
+ '<div id="mw-diff-ntitle3">' . $newminor .
+ Linker::revComment( $this->mNewRev, !$diffOnly, !$this->unhide ) . $rdel . '</div>' .
+ '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
+ '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
+
+ // Allow extensions to change the $newHeader variable
+ Hooks::run( 'DifferenceEngineNewHeader', [ $this, &$newHeader, $formattedRevisionTools,
+ $nextlink, $rollback, $newminor, $diffOnly, $rdel, $this->unhide ] );
+
+ if ( $this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $deleted = true; // new revisions text is hidden
+ if ( $this->mNewRev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
+ $suppressed = true; // also suppressed
+ }
+ }
+
+ # If the diff cannot be shown due to a deleted revision, then output
+ # the diff header and links to unhide (if available)...
+ if ( $deleted && ( !$this->unhide || !$allowed ) ) {
+ $this->showDiffStyle();
+ $multi = $this->getMultiNotice();
+ $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
+ if ( !$allowed ) {
+ $msg = $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff';
+ # Give explanation for why revision is not visible
+ $out->wrapWikiMsg( "<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
+ [ $msg ] );
+ } else {
+ # Give explanation and add a link to view the diff...
+ $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
+ $link = $this->getTitle()->getFullURL( $query );
+ $msg = $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff';
+ $out->wrapWikiMsg(
+ "<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
+ [ $msg, $link ]
+ );
+ }
+ # Otherwise, output a regular diff...
+ } else {
+ # Add deletion notice if the user is viewing deleted content
+ $notice = '';
+ if ( $deleted ) {
+ $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
+ $notice = "<div id='mw-$msg' class='mw-warning plainlinks'>\n" .
+ $this->msg( $msg )->parse() .
+ "</div>\n";
+ }
+ $this->showDiff( $oldHeader, $newHeader, $notice );
+ if ( !$diffOnly ) {
+ $this->renderNewRevision();
+ }
+ }
+ }
+
+ /**
+ * Build a link to mark a change as patrolled.
+ *
+ * Returns empty string if there's either no revision to patrol or the user is not allowed to.
+ * Side effect: When the patrol link is build, this method will call
+ * OutputPage::preventClickjacking() and load mediawiki.page.patrol.ajax.
+ *
+ * @return string HTML or empty string
+ */
+ public function markPatrolledLink() {
+ if ( $this->mMarkPatrolledLink === null ) {
+ $linkInfo = $this->getMarkPatrolledLinkInfo();
+ // If false, there is no patrol link needed/allowed
+ if ( !$linkInfo ) {
+ $this->mMarkPatrolledLink = '';
+ } else {
+ $this->mMarkPatrolledLink = ' <span class="patrollink" data-mw="interface">[' .
+ Linker::linkKnown(
+ $this->mNewPage,
+ $this->msg( 'markaspatrolleddiff' )->escaped(),
+ [],
+ [
+ 'action' => 'markpatrolled',
+ 'rcid' => $linkInfo['rcid'],
+ ]
+ ) . ']</span>';
+ // Allow extensions to change the markpatrolled link
+ Hooks::run( 'DifferenceEngineMarkPatrolledLink', [ $this,
+ &$this->mMarkPatrolledLink, $linkInfo['rcid'] ] );
+ }
+ }
+ return $this->mMarkPatrolledLink;
+ }
+
+ /**
+ * Returns an array of meta data needed to build a "mark as patrolled" link and
+ * adds the mediawiki.page.patrol.ajax to the output.
+ *
+ * @return array|false An array of meta data for a patrol link (rcid only)
+ * or false if no link is needed
+ */
+ protected function getMarkPatrolledLinkInfo() {
+ global $wgUseRCPatrol, $wgEnableAPI, $wgEnableWriteAPI;
+
+ $user = $this->getUser();
+
+ // Prepare a change patrol link, if applicable
+ if (
+ // Is patrolling enabled and the user allowed to?
+ $wgUseRCPatrol && $this->mNewPage->quickUserCan( 'patrol', $user ) &&
+ // Only do this if the revision isn't more than 6 hours older
+ // than the Max RC age (6h because the RC might not be cleaned out regularly)
+ RecentChange::isInRCLifespan( $this->mNewRev->getTimestamp(), 21600 )
+ ) {
+ // Look for an unpatrolled change corresponding to this diff
+ $db = wfGetDB( DB_REPLICA );
+ $change = RecentChange::newFromConds(
+ [
+ 'rc_timestamp' => $db->timestamp( $this->mNewRev->getTimestamp() ),
+ 'rc_this_oldid' => $this->mNewid,
+ 'rc_patrolled' => 0
+ ],
+ __METHOD__
+ );
+
+ if ( $change && !$change->getPerformer()->equals( $user ) ) {
+ $rcid = $change->getAttribute( 'rc_id' );
+ } else {
+ // None found or the page has been created by the current user.
+ // If the user could patrol this it already would be patrolled
+ $rcid = 0;
+ }
+
+ // Allow extensions to possibly change the rcid here
+ // For example the rcid might be set to zero due to the user
+ // being the same as the performer of the change but an extension
+ // might still want to show it under certain conditions
+ Hooks::run( 'DifferenceEngineMarkPatrolledRCID', [ &$rcid, $this, $change, $user ] );
+
+ // Build the link
+ if ( $rcid ) {
+ $this->getOutput()->preventClickjacking();
+ if ( $wgEnableAPI && $wgEnableWriteAPI
+ && $user->isAllowed( 'writeapi' )
+ ) {
+ $this->getOutput()->addModules( 'mediawiki.page.patrol.ajax' );
+ }
+
+ return [
+ 'rcid' => $rcid,
+ ];
+ }
+ }
+
+ // No mark as patrolled link applicable
+ return false;
+ }
+
+ /**
+ * @param Revision $rev
+ *
+ * @return string
+ */
+ protected function revisionDeleteLink( $rev ) {
+ $link = Linker::getRevDeleteLink( $this->getUser(), $rev, $rev->getTitle() );
+ if ( $link !== '' ) {
+ $link = '&#160;&#160;&#160;' . $link . ' ';
+ }
+
+ return $link;
+ }
+
+ /**
+ * Show the new revision of the page.
+ */
+ public function renderNewRevision() {
+ $out = $this->getOutput();
+ $revHeader = $this->getRevisionHeader( $this->mNewRev );
+ # Add "current version as of X" title
+ $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
+ <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
+ # Page content may be handled by a hooked call instead...
+ # @codingStandardsIgnoreStart Ignoring long lines.
+ if ( Hooks::run( 'ArticleContentOnDiff', [ $this, $out ] ) ) {
+ $this->loadNewText();
+ $out->setRevisionId( $this->mNewid );
+ $out->setRevisionTimestamp( $this->mNewRev->getTimestamp() );
+ $out->setArticleFlag( true );
+
+ if ( !Hooks::run( 'ArticleContentViewCustom', [ $this->mNewContent, $this->mNewPage, $out ] ) ) {
+ // Handled by extension
+ } else {
+ // Normal page
+ if ( $this->getTitle()->equals( $this->mNewPage ) ) {
+ // If the Title stored in the context is the same as the one
+ // of the new revision, we can use its associated WikiPage
+ // object.
+ $wikiPage = $this->getWikiPage();
+ } else {
+ // Otherwise we need to create our own WikiPage object
+ $wikiPage = WikiPage::factory( $this->mNewPage );
+ }
+
+ $parserOutput = $this->getParserOutput( $wikiPage, $this->mNewRev );
+
+ # WikiPage::getParserOutput() should not return false, but just in case
+ if ( $parserOutput ) {
+ // Allow extensions to change parser output here
+ if ( Hooks::run( 'DifferenceEngineRenderRevisionAddParserOutput', [ $this, $out, $parserOutput, $wikiPage ] ) ) {
+ $out->addParserOutput( $parserOutput );
+ }
+ }
+ }
+ }
+ # @codingStandardsIgnoreEnd
+
+ // Allow extensions to optionally not show the final patrolled link
+ if ( Hooks::run( 'DifferenceEngineRenderRevisionShowFinalPatrolLink' ) ) {
+ # Add redundant patrol link on bottom...
+ $out->addHTML( $this->markPatrolledLink() );
+ }
+ }
+
+ protected function getParserOutput( WikiPage $page, Revision $rev ) {
+ $parserOptions = $page->makeParserOptions( $this->getContext() );
+
+ if ( !$rev->isCurrent() || !$rev->getTitle()->quickUserCan( 'edit', $this->getUser() ) ) {
+ $parserOptions->setEditSection( false );
+ }
+
+ $parserOutput = $page->getParserOutput( $parserOptions, $rev->getId() );
+
+ return $parserOutput;
+ }
+
+ /**
+ * Get the diff text, send it to the OutputPage object
+ * Returns false if the diff could not be generated, otherwise returns true
+ *
+ * @param string|bool $otitle Header for old text or false
+ * @param string|bool $ntitle Header for new text or false
+ * @param string $notice HTML between diff header and body
+ *
+ * @return bool
+ */
+ public function showDiff( $otitle, $ntitle, $notice = '' ) {
+ // Allow extensions to affect the output here
+ Hooks::run( 'DifferenceEngineShowDiff', [ $this ] );
+
+ $diff = $this->getDiff( $otitle, $ntitle, $notice );
+ if ( $diff === false ) {
+ $this->showMissingRevision();
+
+ return false;
+ } else {
+ $this->showDiffStyle();
+ $this->getOutput()->addHTML( $diff );
+
+ return true;
+ }
+ }
+
+ /**
+ * Add style sheets for diff display.
+ */
+ public function showDiffStyle() {
+ $this->getOutput()->addModuleStyles( 'mediawiki.diff.styles' );
+ }
+
+ /**
+ * Get complete diff table, including header
+ *
+ * @param string|bool $otitle Header for old text or false
+ * @param string|bool $ntitle Header for new text or false
+ * @param string $notice HTML between diff header and body
+ *
+ * @return mixed
+ */
+ public function getDiff( $otitle, $ntitle, $notice = '' ) {
+ $body = $this->getDiffBody();
+ if ( $body === false ) {
+ return false;
+ }
+
+ $multi = $this->getMultiNotice();
+ // Display a message when the diff is empty
+ if ( $body === '' ) {
+ $notice .= '<div class="mw-diff-empty">' .
+ $this->msg( 'diff-empty' )->parse() .
+ "</div>\n";
+ }
+
+ return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
+ }
+
+ /**
+ * Get the diff table body, without header
+ *
+ * @return mixed (string/false)
+ */
+ public function getDiffBody() {
+ $this->mCacheHit = true;
+ // Check if the diff should be hidden from this user
+ if ( !$this->loadRevisionData() ) {
+ return false;
+ } elseif ( $this->mOldRev &&
+ !$this->mOldRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
+ ) {
+ return false;
+ } elseif ( $this->mNewRev &&
+ !$this->mNewRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
+ ) {
+ return false;
+ }
+ // Short-circuit
+ if ( $this->mOldRev === false || ( $this->mOldRev && $this->mNewRev
+ && $this->mOldRev->getId() == $this->mNewRev->getId() )
+ ) {
+ if ( Hooks::run( 'DifferenceEngineShowEmptyOldContent', [ $this ] ) ) {
+ return '';
+ }
+ }
+ // Cacheable?
+ $key = false;
+ $cache = ObjectCache::getMainWANInstance();
+ if ( $this->mOldid && $this->mNewid ) {
+ $key = $this->getDiffBodyCacheKey();
+
+ // Try cache
+ if ( !$this->mRefreshCache ) {
+ $difftext = $cache->get( $key );
+ if ( $difftext ) {
+ wfIncrStats( 'diff_cache.hit' );
+ $difftext = $this->localiseLineNumbers( $difftext );
+ $difftext .= "\n<!-- diff cache key $key -->\n";
+
+ return $difftext;
+ }
+ } // don't try to load but save the result
+ }
+ $this->mCacheHit = false;
+
+ // Loadtext is permission safe, this just clears out the diff
+ if ( !$this->loadText() ) {
+ return false;
+ }
+
+ $difftext = $this->generateContentDiffBody( $this->mOldContent, $this->mNewContent );
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $diffEngine = $this;
+
+ // Save to cache for 7 days
+ if ( !Hooks::run( 'AbortDiffCache', [ &$diffEngine ] ) ) {
+ wfIncrStats( 'diff_cache.uncacheable' );
+ } elseif ( $key !== false && $difftext !== false ) {
+ wfIncrStats( 'diff_cache.miss' );
+ $cache->set( $key, $difftext, 7 * 86400 );
+ } else {
+ wfIncrStats( 'diff_cache.uncacheable' );
+ }
+ // Replace line numbers with the text in the user's language
+ if ( $difftext !== false ) {
+ $difftext = $this->localiseLineNumbers( $difftext );
+ }
+
+ return $difftext;
+ }
+
+ /**
+ * Returns the cache key for diff body text or content.
+ *
+ * @since 1.23
+ *
+ * @throws MWException
+ * @return string
+ */
+ protected function getDiffBodyCacheKey() {
+ if ( !$this->mOldid || !$this->mNewid ) {
+ throw new MWException( 'mOldid and mNewid must be set to get diff cache key.' );
+ }
+
+ return wfMemcKey( 'diff', 'version', self::DIFF_VERSION,
+ 'oldid', $this->mOldid, 'newid', $this->mNewid );
+ }
+
+ /**
+ * Generate a diff, no caching.
+ *
+ * This implementation uses generateTextDiffBody() to generate a diff based on the default
+ * serialization of the given Content objects. This will fail if $old or $new are not
+ * instances of TextContent.
+ *
+ * Subclasses may override this to provide a different rendering for the diff,
+ * perhaps taking advantage of the content's native form. This is required for all content
+ * models that are not text based.
+ *
+ * @since 1.21
+ *
+ * @param Content $old Old content
+ * @param Content $new New content
+ *
+ * @throws MWException If old or new content is not an instance of TextContent.
+ * @return bool|string
+ */
+ public function generateContentDiffBody( Content $old, Content $new ) {
+ if ( !( $old instanceof TextContent ) ) {
+ throw new MWException( "Diff not implemented for " . get_class( $old ) . "; " .
+ "override generateContentDiffBody to fix this." );
+ }
+
+ if ( !( $new instanceof TextContent ) ) {
+ throw new MWException( "Diff not implemented for " . get_class( $new ) . "; "
+ . "override generateContentDiffBody to fix this." );
+ }
+
+ $otext = $old->serialize();
+ $ntext = $new->serialize();
+
+ return $this->generateTextDiffBody( $otext, $ntext );
+ }
+
+ /**
+ * Generate a diff, no caching
+ *
+ * @todo move this to TextDifferenceEngine, make DifferenceEngine abstract. At some point.
+ *
+ * @param string $otext Old text, must be already segmented
+ * @param string $ntext New text, must be already segmented
+ *
+ * @return bool|string
+ */
+ public function generateTextDiffBody( $otext, $ntext ) {
+ $diff = function () use ( $otext, $ntext ) {
+ $time = microtime( true );
+
+ $result = $this->textDiff( $otext, $ntext );
+
+ $time = intval( ( microtime( true ) - $time ) * 1000 );
+ MediaWikiServices::getInstance()->getStatsdDataFactory()->timing( 'diff_time', $time );
+ // Log requests slower than 99th percentile
+ if ( $time > 100 && $this->mOldPage && $this->mNewPage ) {
+ wfDebugLog( 'diff',
+ "$time ms diff: {$this->mOldid} -> {$this->mNewid} {$this->mNewPage}" );
+ }
+
+ return $result;
+ };
+
+ /**
+ * @param Status $status
+ * @throws FatalError
+ */
+ $error = function ( $status ) {
+ throw new FatalError( $status->getWikiText() );
+ };
+
+ // Use PoolCounter if the diff looks like it can be expensive
+ if ( strlen( $otext ) + strlen( $ntext ) > 20000 ) {
+ $work = new PoolCounterWorkViaCallback( 'diff',
+ md5( $otext ) . md5( $ntext ),
+ [ 'doWork' => $diff, 'error' => $error ]
+ );
+ return $work->execute();
+ }
+
+ return $diff();
+ }
+
+ /**
+ * Generates diff, to be wrapped internally in a logging/instrumentation
+ *
+ * @param string $otext Old text, must be already segmented
+ * @param string $ntext New text, must be already segmented
+ * @return bool|string
+ */
+ protected function textDiff( $otext, $ntext ) {
+ global $wgExternalDiffEngine, $wgContLang;
+
+ $otext = str_replace( "\r\n", "\n", $otext );
+ $ntext = str_replace( "\r\n", "\n", $ntext );
+
+ if ( $wgExternalDiffEngine == 'wikidiff' || $wgExternalDiffEngine == 'wikidiff3' ) {
+ wfDeprecated( "\$wgExternalDiffEngine = '{$wgExternalDiffEngine}'", '1.27' );
+ $wgExternalDiffEngine = false;
+ } elseif ( $wgExternalDiffEngine == 'wikidiff2' ) {
+ // Same as above, but with no deprecation warnings
+ $wgExternalDiffEngine = false;
+ } elseif ( !is_string( $wgExternalDiffEngine ) && $wgExternalDiffEngine !== false ) {
+ // And prevent people from shooting themselves in the foot...
+ wfWarn( '$wgExternalDiffEngine is set to a non-string value, forcing it to false' );
+ $wgExternalDiffEngine = false;
+ }
+
+ // Better external diff engine, the 2 may some day be dropped
+ // This one does the escaping and segmenting itself
+ if ( function_exists( 'wikidiff2_do_diff' ) && $wgExternalDiffEngine === false ) {
+ $wikidiff2Version = phpversion( 'wikidiff2' );
+ if (
+ $wikidiff2Version !== false &&
+ version_compare( $wikidiff2Version, '1.5.0', '>=' )
+ ) {
+ $text = wikidiff2_do_diff(
+ $otext,
+ $ntext,
+ 2,
+ $this->getConfig()->get( 'WikiDiff2MovedParagraphDetectionCutoff' )
+ );
+ } else {
+ // Don't pass the 4th parameter for compatibility with older versions of wikidiff2
+ $text = wikidiff2_do_diff(
+ $otext,
+ $ntext,
+ 2
+ );
+
+ // Log a warning in case the configuration value is set to not silently ignore it
+ if ( $this->getConfig()->get( 'WikiDiff2MovedParagraphDetectionCutoff' ) > 0 ) {
+ wfLogWarning( '$wgWikiDiff2MovedParagraphDetectionCutoff is set but has no
+ effect since the used version of WikiDiff2 does not support it.' );
+ }
+ }
+
+ $text .= $this->debug( 'wikidiff2' );
+
+ return $text;
+ } elseif ( $wgExternalDiffEngine !== false && is_executable( $wgExternalDiffEngine ) ) {
+ # Diff via the shell
+ $tmpDir = wfTempDir();
+ $tempName1 = tempnam( $tmpDir, 'diff_' );
+ $tempName2 = tempnam( $tmpDir, 'diff_' );
+
+ $tempFile1 = fopen( $tempName1, "w" );
+ if ( !$tempFile1 ) {
+ return false;
+ }
+ $tempFile2 = fopen( $tempName2, "w" );
+ if ( !$tempFile2 ) {
+ return false;
+ }
+ fwrite( $tempFile1, $otext );
+ fwrite( $tempFile2, $ntext );
+ fclose( $tempFile1 );
+ fclose( $tempFile2 );
+ $cmd = wfEscapeShellArg( $wgExternalDiffEngine, $tempName1, $tempName2 );
+ $difftext = wfShellExec( $cmd );
+ $difftext .= $this->debug( "external $wgExternalDiffEngine" );
+ unlink( $tempName1 );
+ unlink( $tempName2 );
+
+ return $difftext;
+ }
+
+ # Native PHP diff
+ $ota = explode( "\n", $wgContLang->segmentForDiff( $otext ) );
+ $nta = explode( "\n", $wgContLang->segmentForDiff( $ntext ) );
+ $diffs = new Diff( $ota, $nta );
+ $formatter = new TableDiffFormatter();
+ $difftext = $wgContLang->unsegmentForDiff( $formatter->format( $diffs ) );
+
+ return $difftext;
+ }
+
+ /**
+ * Generate a debug comment indicating diff generating time,
+ * server node, and generator backend.
+ *
+ * @param string $generator : What diff engine was used
+ *
+ * @return string
+ */
+ protected function debug( $generator = "internal" ) {
+ global $wgShowHostnames;
+ if ( !$this->enableDebugComment ) {
+ return '';
+ }
+ $data = [ $generator ];
+ if ( $wgShowHostnames ) {
+ $data[] = wfHostname();
+ }
+ $data[] = wfTimestamp( TS_DB );
+
+ return "<!-- diff generator: " .
+ implode( " ", array_map( "htmlspecialchars", $data ) ) .
+ " -->\n";
+ }
+
+ /**
+ * Replace line numbers with the text in the user's language
+ *
+ * @param string $text
+ *
+ * @return mixed
+ */
+ public function localiseLineNumbers( $text ) {
+ return preg_replace_callback(
+ '/<!--LINE (\d+)-->/',
+ [ $this, 'localiseLineNumbersCb' ],
+ $text
+ );
+ }
+
+ public function localiseLineNumbersCb( $matches ) {
+ if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
+ return '';
+ }
+
+ return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
+ }
+
+ /**
+ * If there are revisions between the ones being compared, return a note saying so.
+ *
+ * @return string
+ */
+ public function getMultiNotice() {
+ if ( !is_object( $this->mOldRev ) || !is_object( $this->mNewRev ) ) {
+ return '';
+ } elseif ( !$this->mOldPage->equals( $this->mNewPage ) ) {
+ // Comparing two different pages? Count would be meaningless.
+ return '';
+ }
+
+ if ( $this->mOldRev->getTimestamp() > $this->mNewRev->getTimestamp() ) {
+ $oldRev = $this->mNewRev; // flip
+ $newRev = $this->mOldRev; // flip
+ } else { // normal case
+ $oldRev = $this->mOldRev;
+ $newRev = $this->mNewRev;
+ }
+
+ // Sanity: don't show the notice if too many rows must be scanned
+ // @todo show some special message for that case
+ $nEdits = $this->mNewPage->countRevisionsBetween( $oldRev, $newRev, 1000 );
+ if ( $nEdits > 0 && $nEdits <= 1000 ) {
+ $limit = 100; // use diff-multi-manyusers if too many users
+ $users = $this->mNewPage->getAuthorsBetween( $oldRev, $newRev, $limit );
+ $numUsers = count( $users );
+
+ if ( $numUsers == 1 && $users[0] == $newRev->getUserText( Revision::RAW ) ) {
+ $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
+ }
+
+ return self::intermediateEditsMsg( $nEdits, $numUsers, $limit );
+ }
+
+ return ''; // nothing
+ }
+
+ /**
+ * Get a notice about how many intermediate edits and users there are
+ *
+ * @param int $numEdits
+ * @param int $numUsers
+ * @param int $limit
+ *
+ * @return string
+ */
+ public static function intermediateEditsMsg( $numEdits, $numUsers, $limit ) {
+ if ( $numUsers === 0 ) {
+ $msg = 'diff-multi-sameuser';
+ } elseif ( $numUsers > $limit ) {
+ $msg = 'diff-multi-manyusers';
+ $numUsers = $limit;
+ } else {
+ $msg = 'diff-multi-otherusers';
+ }
+
+ return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
+ }
+
+ /**
+ * Get a header for a specified revision.
+ *
+ * @param Revision $rev
+ * @param string $complete 'complete' to get the header wrapped depending
+ * the visibility of the revision and a link to edit the page.
+ *
+ * @return string HTML fragment
+ */
+ public function getRevisionHeader( Revision $rev, $complete = '' ) {
+ $lang = $this->getLanguage();
+ $user = $this->getUser();
+ $revtimestamp = $rev->getTimestamp();
+ $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
+ $dateofrev = $lang->userDate( $revtimestamp, $user );
+ $timeofrev = $lang->userTime( $revtimestamp, $user );
+
+ $header = $this->msg(
+ $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
+ $timestamp,
+ $dateofrev,
+ $timeofrev
+ )->escaped();
+
+ if ( $complete !== 'complete' ) {
+ return $header;
+ }
+
+ $title = $rev->getTitle();
+
+ $header = Linker::linkKnown( $title, $header, [],
+ [ 'oldid' => $rev->getId() ] );
+
+ if ( $rev->userCan( Revision::DELETED_TEXT, $user ) ) {
+ $editQuery = [ 'action' => 'edit' ];
+ if ( !$rev->isCurrent() ) {
+ $editQuery['oldid'] = $rev->getId();
+ }
+
+ $key = $title->quickUserCan( 'edit', $user ) ? 'editold' : 'viewsourceold';
+ $msg = $this->msg( $key )->escaped();
+ $editLink = $this->msg( 'parentheses' )->rawParams(
+ Linker::linkKnown( $title, $msg, [], $editQuery ) )->escaped();
+ $header .= ' ' . Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-diff-edit' ],
+ $editLink
+ );
+ if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $header = Html::rawElement(
+ 'span',
+ [ 'class' => 'history-deleted' ],
+ $header
+ );
+ }
+ } else {
+ $header = Html::rawElement( 'span', [ 'class' => 'history-deleted' ], $header );
+ }
+
+ return $header;
+ }
+
+ /**
+ * Add the header to a diff body
+ *
+ * @param string $diff Diff body
+ * @param string $otitle Old revision header
+ * @param string $ntitle New revision header
+ * @param string $multi Notice telling user that there are intermediate
+ * revisions between the ones being compared
+ * @param string $notice Other notices, e.g. that user is viewing deleted content
+ *
+ * @return string
+ */
+ public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
+ // shared.css sets diff in interface language/dir, but the actual content
+ // is often in a different language, mostly the page content language/dir
+ $header = Html::openElement( 'table', [
+ 'class' => [ 'diff', 'diff-contentalign-' . $this->getDiffLang()->alignStart() ],
+ 'data-mw' => 'interface',
+ ] );
+ $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
+
+ if ( !$diff && !$otitle ) {
+ $header .= "
+ <tr style=\"vertical-align: top;\" lang=\"{$userLang}\">
+ <td class=\"diff-ntitle\">{$ntitle}</td>
+ </tr>";
+ $multiColspan = 1;
+ } else {
+ if ( $diff ) { // Safari/Chrome show broken output if cols not used
+ $header .= "
+ <col class=\"diff-marker\" />
+ <col class=\"diff-content\" />
+ <col class=\"diff-marker\" />
+ <col class=\"diff-content\" />";
+ $colspan = 2;
+ $multiColspan = 4;
+ } else {
+ $colspan = 1;
+ $multiColspan = 2;
+ }
+ if ( $otitle || $ntitle ) {
+ $header .= "
+ <tr style=\"vertical-align: top;\" lang=\"{$userLang}\">
+ <td colspan=\"$colspan\" class=\"diff-otitle\">{$otitle}</td>
+ <td colspan=\"$colspan\" class=\"diff-ntitle\">{$ntitle}</td>
+ </tr>";
+ }
+ }
+
+ if ( $multi != '' ) {
+ $header .= "<tr><td colspan=\"{$multiColspan}\" style=\"text-align: center;\" " .
+ "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
+ }
+ if ( $notice != '' ) {
+ $header .= "<tr><td colspan=\"{$multiColspan}\" style=\"text-align: center;\" " .
+ "lang=\"{$userLang}\">{$notice}</td></tr>";
+ }
+
+ return $header . $diff . "</table>";
+ }
+
+ /**
+ * Use specified text instead of loading from the database
+ * @param Content $oldContent
+ * @param Content $newContent
+ * @since 1.21
+ */
+ public function setContent( Content $oldContent, Content $newContent ) {
+ $this->mOldContent = $oldContent;
+ $this->mNewContent = $newContent;
+
+ $this->mTextLoaded = 2;
+ $this->mRevisionsLoaded = true;
+ }
+
+ /**
+ * Set the language in which the diff text is written
+ * (Defaults to page content language).
+ * @param Language|string $lang
+ * @since 1.19
+ */
+ public function setTextLanguage( $lang ) {
+ $this->mDiffLang = wfGetLangObj( $lang );
+ }
+
+ /**
+ * Maps a revision pair definition as accepted by DifferenceEngine constructor
+ * to a pair of actual integers representing revision ids.
+ *
+ * @param int $old Revision id, e.g. from URL parameter 'oldid'
+ * @param int|string $new Revision id or strings 'next' or 'prev', e.g. from URL parameter 'diff'
+ *
+ * @return int[] List of two revision ids, older first, later second.
+ * Zero signifies invalid argument passed.
+ * false signifies that there is no previous/next revision ($old is the oldest/newest one).
+ */
+ public function mapDiffPrevNext( $old, $new ) {
+ if ( $new === 'prev' ) {
+ // Show diff between revision $old and the previous one. Get previous one from DB.
+ $newid = intval( $old );
+ $oldid = $this->getTitle()->getPreviousRevisionID( $newid );
+ } elseif ( $new === 'next' ) {
+ // Show diff between revision $old and the next one. Get next one from DB.
+ $oldid = intval( $old );
+ $newid = $this->getTitle()->getNextRevisionID( $oldid );
+ } else {
+ $oldid = intval( $old );
+ $newid = intval( $new );
+ }
+
+ return [ $oldid, $newid ];
+ }
+
+ /**
+ * Load revision IDs
+ */
+ private function loadRevisionIds() {
+ if ( $this->mRevisionsIdsLoaded ) {
+ return;
+ }
+
+ $this->mRevisionsIdsLoaded = true;
+
+ $old = $this->mOldid;
+ $new = $this->mNewid;
+
+ list( $this->mOldid, $this->mNewid ) = self::mapDiffPrevNext( $old, $new );
+ if ( $new === 'next' && $this->mNewid === false ) {
+ # if no result, NewId points to the newest old revision. The only newer
+ # revision is cur, which is "0".
+ $this->mNewid = 0;
+ }
+
+ Hooks::run(
+ 'NewDifferenceEngine',
+ [ $this->getTitle(), &$this->mOldid, &$this->mNewid, $old, $new ]
+ );
+ }
+
+ /**
+ * Load revision metadata for the specified articles. If newid is 0, then compare
+ * the old article in oldid to the current article; if oldid is 0, then
+ * compare the current article to the immediately previous one (ignoring the
+ * value of newid).
+ *
+ * If oldid is false, leave the corresponding revision object set
+ * to false. This is impossible via ordinary user input, and is provided for
+ * API convenience.
+ *
+ * @return bool
+ */
+ public function loadRevisionData() {
+ if ( $this->mRevisionsLoaded ) {
+ return true;
+ }
+
+ // Whether it succeeds or fails, we don't want to try again
+ $this->mRevisionsLoaded = true;
+
+ $this->loadRevisionIds();
+
+ // Load the new revision object
+ if ( $this->mNewid ) {
+ $this->mNewRev = Revision::newFromId( $this->mNewid );
+ } else {
+ $this->mNewRev = Revision::newFromTitle(
+ $this->getTitle(),
+ false,
+ Revision::READ_NORMAL
+ );
+ }
+
+ if ( !$this->mNewRev instanceof Revision ) {
+ return false;
+ }
+
+ // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
+ $this->mNewid = $this->mNewRev->getId();
+ $this->mNewPage = $this->mNewRev->getTitle();
+
+ // Load the old revision object
+ $this->mOldRev = false;
+ if ( $this->mOldid ) {
+ $this->mOldRev = Revision::newFromId( $this->mOldid );
+ } elseif ( $this->mOldid === 0 ) {
+ $rev = $this->mNewRev->getPrevious();
+ if ( $rev ) {
+ $this->mOldid = $rev->getId();
+ $this->mOldRev = $rev;
+ } else {
+ // No previous revision; mark to show as first-version only.
+ $this->mOldid = false;
+ $this->mOldRev = false;
+ }
+ } /* elseif ( $this->mOldid === false ) leave mOldRev false; */
+
+ if ( is_null( $this->mOldRev ) ) {
+ return false;
+ }
+
+ if ( $this->mOldRev ) {
+ $this->mOldPage = $this->mOldRev->getTitle();
+ }
+
+ // Load tags information for both revisions
+ $dbr = wfGetDB( DB_REPLICA );
+ if ( $this->mOldid !== false ) {
+ $this->mOldTags = $dbr->selectField(
+ 'tag_summary',
+ 'ts_tags',
+ [ 'ts_rev_id' => $this->mOldid ],
+ __METHOD__
+ );
+ } else {
+ $this->mOldTags = false;
+ }
+ $this->mNewTags = $dbr->selectField(
+ 'tag_summary',
+ 'ts_tags',
+ [ 'ts_rev_id' => $this->mNewid ],
+ __METHOD__
+ );
+
+ return true;
+ }
+
+ /**
+ * Load the text of the revisions, as well as revision data.
+ *
+ * @return bool
+ */
+ public function loadText() {
+ if ( $this->mTextLoaded == 2 ) {
+ return true;
+ }
+
+ // Whether it succeeds or fails, we don't want to try again
+ $this->mTextLoaded = 2;
+
+ if ( !$this->loadRevisionData() ) {
+ return false;
+ }
+
+ if ( $this->mOldRev ) {
+ $this->mOldContent = $this->mOldRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+ if ( $this->mOldContent === null ) {
+ return false;
+ }
+ }
+
+ if ( $this->mNewRev ) {
+ $this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+ Hooks::run( 'DifferenceEngineLoadTextAfterNewContentIsLoaded', [ $this ] );
+ if ( $this->mNewContent === null ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Load the text of the new revision, not the old one
+ *
+ * @return bool
+ */
+ public function loadNewText() {
+ if ( $this->mTextLoaded >= 1 ) {
+ return true;
+ }
+
+ $this->mTextLoaded = 1;
+
+ if ( !$this->loadRevisionData() ) {
+ return false;
+ }
+
+ $this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+
+ Hooks::run( 'DifferenceEngineAfterLoadNewText', [ $this ] );
+
+ return true;
+ }
+
+}
diff --git a/www/wiki/includes/diff/TableDiffFormatter.php b/www/wiki/includes/diff/TableDiffFormatter.php
new file mode 100644
index 00000000..14307b58
--- /dev/null
+++ b/www/wiki/includes/diff/TableDiffFormatter.php
@@ -0,0 +1,216 @@
+<?php
+/**
+ * Portions taken from phpwiki-1.3.3.
+ *
+ * Copyright © 2000, 2001 Geoffrey T. Dairiki <dairiki@dairiki.org>
+ * You may copy this code freely under the conditions of the GPL.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ */
+
+/**
+ * MediaWiki default table style diff formatter
+ * @todo document
+ * @private
+ * @ingroup DifferenceEngine
+ */
+class TableDiffFormatter extends DiffFormatter {
+
+ function __construct() {
+ $this->leadingContextLines = 2;
+ $this->trailingContextLines = 2;
+ }
+
+ /**
+ * @static
+ * @param string $msg
+ *
+ * @return mixed
+ */
+ public static function escapeWhiteSpace( $msg ) {
+ $msg = preg_replace( '/^ /m', '&#160; ', $msg );
+ $msg = preg_replace( '/ $/m', ' &#160;', $msg );
+ $msg = preg_replace( '/ /', '&#160; ', $msg );
+
+ return $msg;
+ }
+
+ /**
+ * @param int $xbeg
+ * @param int $xlen
+ * @param int $ybeg
+ * @param int $ylen
+ *
+ * @return string
+ */
+ protected function blockHeader( $xbeg, $xlen, $ybeg, $ylen ) {
+ // '<!--LINE \d+ -->' get replaced by a localised line number
+ // in DifferenceEngine::localiseLineNumbers
+ $r = '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l' .
+ $xbeg .
+ '" ><!--LINE ' .
+ $xbeg .
+ "--></td>\n" .
+ '<td colspan="2" class="diff-lineno"><!--LINE ' .
+ $ybeg .
+ "--></td></tr>\n";
+
+ return $r;
+ }
+
+ /**
+ * Writes the header to the output buffer.
+ *
+ * @param string $header
+ */
+ protected function startBlock( $header ) {
+ $this->writeOutput( $header );
+ }
+
+ protected function endBlock() {
+ }
+
+ /**
+ * @param string[] $lines
+ * @param string $prefix
+ * @param string $color
+ */
+ protected function lines( $lines, $prefix = ' ', $color = 'white' ) {
+ }
+
+ /**
+ * HTML-escape parameter before calling this
+ *
+ * @param string $line
+ *
+ * @return string
+ */
+ protected function addedLine( $line ) {
+ return $this->wrapLine( '+', 'diff-addedline', $line );
+ }
+
+ /**
+ * HTML-escape parameter before calling this
+ *
+ * @param string $line
+ *
+ * @return string
+ */
+ protected function deletedLine( $line ) {
+ return $this->wrapLine( '−', 'diff-deletedline', $line );
+ }
+
+ /**
+ * HTML-escape parameter before calling this
+ *
+ * @param string $line
+ *
+ * @return string
+ */
+ protected function contextLine( $line ) {
+ return $this->wrapLine( '&#160;', 'diff-context', $line );
+ }
+
+ /**
+ * @param string $marker
+ * @param string $class Unused
+ * @param string $line
+ *
+ * @return string
+ */
+ protected function wrapLine( $marker, $class, $line ) {
+ if ( $line !== '' ) {
+ // The <div> wrapper is needed for 'overflow: auto' style to scroll properly
+ $line = Xml::tags( 'div', null, $this->escapeWhiteSpace( $line ) );
+ }
+
+ return "<td class='diff-marker'>$marker</td><td class='$class'>$line</td>";
+ }
+
+ /**
+ * @return string
+ */
+ protected function emptyLine() {
+ return '<td colspan="2">&#160;</td>';
+ }
+
+ /**
+ * Writes all lines to the output buffer, each enclosed in <tr>.
+ *
+ * @param string[] $lines
+ */
+ protected function added( $lines ) {
+ foreach ( $lines as $line ) {
+ $this->writeOutput( '<tr>' . $this->emptyLine() .
+ $this->addedLine( '<ins class="diffchange">' .
+ htmlspecialchars( $line ) . '</ins>' ) . "</tr>\n" );
+ }
+ }
+
+ /**
+ * Writes all lines to the output buffer, each enclosed in <tr>.
+ *
+ * @param string[] $lines
+ */
+ protected function deleted( $lines ) {
+ foreach ( $lines as $line ) {
+ $this->writeOutput( '<tr>' . $this->deletedLine( '<del class="diffchange">' .
+ htmlspecialchars( $line ) . '</del>' ) .
+ $this->emptyLine() . "</tr>\n" );
+ }
+ }
+
+ /**
+ * Writes all lines to the output buffer, each enclosed in <tr>.
+ *
+ * @param string[] $lines
+ */
+ protected function context( $lines ) {
+ foreach ( $lines as $line ) {
+ $this->writeOutput( '<tr>' .
+ $this->contextLine( htmlspecialchars( $line ) ) .
+ $this->contextLine( htmlspecialchars( $line ) ) . "</tr>\n" );
+ }
+ }
+
+ /**
+ * Writes the two sets of lines to the output buffer, each enclosed in <tr>.
+ *
+ * @param string[] $orig
+ * @param string[] $closing
+ */
+ protected function changed( $orig, $closing ) {
+ $diff = new WordLevelDiff( $orig, $closing );
+ $del = $diff->orig();
+ $add = $diff->closing();
+
+ # Notice that WordLevelDiff returns HTML-escaped output.
+ # Hence, we will be calling addedLine/deletedLine without HTML-escaping.
+
+ $ndel = count( $del );
+ $nadd = count( $add );
+ $n = max( $ndel, $nadd );
+ for ( $i = 0; $i < $n; $i++ ) {
+ $delLine = $i < $ndel ? $this->deletedLine( $del[$i] ) : $this->emptyLine();
+ $addLine = $i < $nadd ? $this->addedLine( $add[$i] ) : $this->emptyLine();
+ $this->writeOutput( "<tr>{$delLine}{$addLine}</tr>\n" );
+ }
+ }
+
+}
diff --git a/www/wiki/includes/diff/UnifiedDiffFormatter.php b/www/wiki/includes/diff/UnifiedDiffFormatter.php
new file mode 100644
index 00000000..72f1a660
--- /dev/null
+++ b/www/wiki/includes/diff/UnifiedDiffFormatter.php
@@ -0,0 +1,84 @@
+<?php
+/**
+ * Portions taken from phpwiki-1.3.3.
+ *
+ * Copyright © 2000, 2001 Geoffrey T. Dairiki <dairiki@dairiki.org>
+ * You may copy this code freely under the conditions of the GPL.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ */
+
+/**
+ * A formatter that outputs unified diffs
+ * @ingroup DifferenceEngine
+ */
+class UnifiedDiffFormatter extends DiffFormatter {
+
+ /** @var int */
+ protected $leadingContextLines = 2;
+
+ /** @var int */
+ protected $trailingContextLines = 2;
+
+ /**
+ * @param string[] $lines
+ * @param string $prefix
+ */
+ protected function lines( $lines, $prefix = ' ' ) {
+ foreach ( $lines as $line ) {
+ $this->writeOutput( "{$prefix}{$line}\n" );
+ }
+ }
+
+ /**
+ * @param string[] $lines
+ */
+ protected function added( $lines ) {
+ $this->lines( $lines, '+' );
+ }
+
+ /**
+ * @param string[] $lines
+ */
+ protected function deleted( $lines ) {
+ $this->lines( $lines, '-' );
+ }
+
+ /**
+ * @param string[] $orig
+ * @param string[] $closing
+ */
+ protected function changed( $orig, $closing ) {
+ $this->deleted( $orig );
+ $this->added( $closing );
+ }
+
+ /**
+ * @param int $xbeg
+ * @param int $xlen
+ * @param int $ybeg
+ * @param int $ylen
+ *
+ * @return string
+ */
+ protected function blockHeader( $xbeg, $xlen, $ybeg, $ylen ) {
+ return "@@ -$xbeg,$xlen +$ybeg,$ylen @@";
+ }
+
+}
diff --git a/www/wiki/includes/diff/WikiDiff3.php b/www/wiki/includes/diff/WikiDiff3.php
new file mode 100644
index 00000000..f35e30f3
--- /dev/null
+++ b/www/wiki/includes/diff/WikiDiff3.php
@@ -0,0 +1,621 @@
+<?php
+/**
+ * New version of the difference engine
+ *
+ * Copyright © 2008 Guy Van den Broeck <guy@guyvdb.eu>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ */
+
+/**
+ * This diff implementation is mainly lifted from the LCS algorithm of the Eclipse project which
+ * in turn is based on Myers' "An O(ND) difference algorithm and its variations"
+ * (http://citeseer.ist.psu.edu/myers86ond.html) with range compression (see Wu et al.'s
+ * "An O(NP) Sequence Comparison Algorithm").
+ *
+ * This implementation supports an upper bound on the execution time.
+ *
+ * Complexity: O((M + N)D) worst case time, O(M + N + D^2) expected time, O(M + N) space
+ *
+ * @author Guy Van den Broeck
+ * @ingroup DifferenceEngine
+ */
+class WikiDiff3 {
+
+ // Input variables
+ private $from;
+ private $to;
+ private $m;
+ private $n;
+
+ private $tooLong;
+ private $powLimit;
+
+ // State variables
+ private $maxDifferences;
+ private $lcsLengthCorrectedForHeuristic = false;
+
+ // Output variables
+ public $length;
+ public $removed;
+ public $added;
+ public $heuristicUsed;
+
+ function __construct( $tooLong = 2000000, $powLimit = 1.45 ) {
+ $this->tooLong = $tooLong;
+ $this->powLimit = $powLimit;
+ }
+
+ public function diff( /*array*/ $from, /*array*/ $to ) {
+ // remember initial lengths
+ $m = count( $from );
+ $n = count( $to );
+
+ $this->heuristicUsed = false;
+
+ // output
+ $removed = $m > 0 ? array_fill( 0, $m, true ) : [];
+ $added = $n > 0 ? array_fill( 0, $n, true ) : [];
+
+ // reduce the complexity for the next step (intentionally done twice)
+ // remove common tokens at the start
+ $i = 0;
+ while ( $i < $m && $i < $n && $from[$i] === $to[$i] ) {
+ $removed[$i] = $added[$i] = false;
+ unset( $from[$i], $to[$i] );
+ ++$i;
+ }
+
+ // remove common tokens at the end
+ $j = 1;
+ while ( $i + $j <= $m && $i + $j <= $n && $from[$m - $j] === $to[$n - $j] ) {
+ $removed[$m - $j] = $added[$n - $j] = false;
+ unset( $from[$m - $j], $to[$n - $j] );
+ ++$j;
+ }
+
+ $this->from = $newFromIndex = $this->to = $newToIndex = [];
+
+ // remove tokens not in both sequences
+ $shared = [];
+ foreach ( $from as $key ) {
+ $shared[$key] = false;
+ }
+
+ foreach ( $to as $index => &$el ) {
+ if ( array_key_exists( $el, $shared ) ) {
+ // keep it
+ $this->to[] = $el;
+ $shared[$el] = true;
+ $newToIndex[] = $index;
+ }
+ }
+ foreach ( $from as $index => &$el ) {
+ if ( $shared[$el] ) {
+ // keep it
+ $this->from[] = $el;
+ $newFromIndex[] = $index;
+ }
+ }
+
+ unset( $shared, $from, $to );
+
+ $this->m = count( $this->from );
+ $this->n = count( $this->to );
+
+ $this->removed = $this->m > 0 ? array_fill( 0, $this->m, true ) : [];
+ $this->added = $this->n > 0 ? array_fill( 0, $this->n, true ) : [];
+
+ if ( $this->m == 0 || $this->n == 0 ) {
+ $this->length = 0;
+ } else {
+ $this->maxDifferences = ceil( ( $this->m + $this->n ) / 2.0 );
+ if ( $this->m * $this->n > $this->tooLong ) {
+ // limit complexity to D^POW_LIMIT for long sequences
+ $this->maxDifferences = floor( pow( $this->maxDifferences, $this->powLimit - 1.0 ) );
+ wfDebug( "Limiting max number of differences to $this->maxDifferences\n" );
+ }
+
+ /*
+ * The common prefixes and suffixes are always part of some LCS, include
+ * them now to reduce our search space
+ */
+ $max = min( $this->m, $this->n );
+ for ( $forwardBound = 0; $forwardBound < $max
+ && $this->from[$forwardBound] === $this->to[$forwardBound];
+ ++$forwardBound
+ ) {
+ $this->removed[$forwardBound] = $this->added[$forwardBound] = false;
+ }
+
+ $backBoundL1 = $this->m - 1;
+ $backBoundL2 = $this->n - 1;
+
+ while ( $backBoundL1 >= $forwardBound && $backBoundL2 >= $forwardBound
+ && $this->from[$backBoundL1] === $this->to[$backBoundL2]
+ ) {
+ $this->removed[$backBoundL1--] = $this->added[$backBoundL2--] = false;
+ }
+
+ $temp = array_fill( 0, $this->m + $this->n + 1, 0 );
+ $V = [ $temp, $temp ];
+ $snake = [ 0, 0, 0 ];
+
+ $this->length = $forwardBound + $this->m - $backBoundL1 - 1
+ + $this->lcs_rec(
+ $forwardBound,
+ $backBoundL1,
+ $forwardBound,
+ $backBoundL2,
+ $V,
+ $snake
+ );
+ }
+
+ $this->m = $m;
+ $this->n = $n;
+
+ $this->length += $i + $j - 1;
+
+ foreach ( $this->removed as $key => &$removed_elem ) {
+ if ( !$removed_elem ) {
+ $removed[$newFromIndex[$key]] = false;
+ }
+ }
+ foreach ( $this->added as $key => &$added_elem ) {
+ if ( !$added_elem ) {
+ $added[$newToIndex[$key]] = false;
+ }
+ }
+ $this->removed = $removed;
+ $this->added = $added;
+ }
+
+ function diff_range( $from_lines, $to_lines ) {
+ // Diff and store locally
+ $this->diff( $from_lines, $to_lines );
+ unset( $from_lines, $to_lines );
+
+ $ranges = [];
+ $xi = $yi = 0;
+ while ( $xi < $this->m || $yi < $this->n ) {
+ // Matching "snake".
+ while ( $xi < $this->m && $yi < $this->n
+ && !$this->removed[$xi]
+ && !$this->added[$yi]
+ ) {
+ ++$xi;
+ ++$yi;
+ }
+ // Find deletes & adds.
+ $xstart = $xi;
+ while ( $xi < $this->m && $this->removed[$xi] ) {
+ ++$xi;
+ }
+
+ $ystart = $yi;
+ while ( $yi < $this->n && $this->added[$yi] ) {
+ ++$yi;
+ }
+
+ if ( $xi > $xstart || $yi > $ystart ) {
+ $ranges[] = new RangeDifference( $xstart, $xi, $ystart, $yi );
+ }
+ }
+
+ return $ranges;
+ }
+
+ private function lcs_rec( $bottoml1, $topl1, $bottoml2, $topl2, &$V, &$snake ) {
+ // check that both sequences are non-empty
+ if ( $bottoml1 > $topl1 || $bottoml2 > $topl2 ) {
+ return 0;
+ }
+
+ $d = $this->find_middle_snake( $bottoml1, $topl1, $bottoml2,
+ $topl2, $V, $snake );
+
+ // need to store these so we don't lose them when they're
+ // overwritten by the recursion
+ $len = $snake[2];
+ $startx = $snake[0];
+ $starty = $snake[1];
+
+ // the middle snake is part of the LCS, store it
+ for ( $i = 0; $i < $len; ++$i ) {
+ $this->removed[$startx + $i] = $this->added[$starty + $i] = false;
+ }
+
+ if ( $d > 1 ) {
+ return $len
+ + $this->lcs_rec( $bottoml1, $startx - 1, $bottoml2,
+ $starty - 1, $V, $snake )
+ + $this->lcs_rec( $startx + $len, $topl1, $starty + $len,
+ $topl2, $V, $snake );
+ } elseif ( $d == 1 ) {
+ /*
+ * In this case the sequences differ by exactly 1 line. We have
+ * already saved all the lines after the difference in the for loop
+ * above, now we need to save all the lines before the difference.
+ */
+ $max = min( $startx - $bottoml1, $starty - $bottoml2 );
+ for ( $i = 0; $i < $max; ++$i ) {
+ $this->removed[$bottoml1 + $i] =
+ $this->added[$bottoml2 + $i] = false;
+ }
+
+ return $max + $len;
+ }
+
+ return $len;
+ }
+
+ private function find_middle_snake( $bottoml1, $topl1, $bottoml2, $topl2, &$V, &$snake ) {
+ $from = &$this->from;
+ $to = &$this->to;
+ $V0 = &$V[0];
+ $V1 = &$V[1];
+ $snake0 = &$snake[0];
+ $snake1 = &$snake[1];
+ $snake2 = &$snake[2];
+ $bottoml1_min_1 = $bottoml1 - 1;
+ $bottoml2_min_1 = $bottoml2 - 1;
+ $N = $topl1 - $bottoml1_min_1;
+ $M = $topl2 - $bottoml2_min_1;
+ $delta = $N - $M;
+ $maxabsx = $N + $bottoml1;
+ $maxabsy = $M + $bottoml2;
+ $limit = min( $this->maxDifferences, ceil( ( $N + $M ) / 2 ) );
+
+ // value_to_add_forward: a 0 or 1 that we add to the start
+ // offset to make it odd/even
+ if ( ( $M & 1 ) == 1 ) {
+ $value_to_add_forward = 1;
+ } else {
+ $value_to_add_forward = 0;
+ }
+
+ if ( ( $N & 1 ) == 1 ) {
+ $value_to_add_backward = 1;
+ } else {
+ $value_to_add_backward = 0;
+ }
+
+ $start_forward = -$M;
+ $end_forward = $N;
+ $start_backward = -$N;
+ $end_backward = $M;
+
+ $limit_min_1 = $limit - 1;
+ $limit_plus_1 = $limit + 1;
+
+ $V0[$limit_plus_1] = 0;
+ $V1[$limit_min_1] = $N;
+ $limit = min( $this->maxDifferences, ceil( ( $N + $M ) / 2 ) );
+
+ if ( ( $delta & 1 ) == 1 ) {
+ for ( $d = 0; $d <= $limit; ++$d ) {
+ $start_diag = max( $value_to_add_forward + $start_forward, -$d );
+ $end_diag = min( $end_forward, $d );
+ $value_to_add_forward = 1 - $value_to_add_forward;
+
+ // compute forward furthest reaching paths
+ for ( $k = $start_diag; $k <= $end_diag; $k += 2 ) {
+ if ( $k == -$d || ( $k < $d
+ && $V0[$limit_min_1 + $k] < $V0[$limit_plus_1 + $k] )
+ ) {
+ $x = $V0[$limit_plus_1 + $k];
+ } else {
+ $x = $V0[$limit_min_1 + $k] + 1;
+ }
+
+ $absx = $snake0 = $x + $bottoml1;
+ $absy = $snake1 = $x - $k + $bottoml2;
+
+ while ( $absx < $maxabsx && $absy < $maxabsy && $from[$absx] === $to[$absy] ) {
+ ++$absx;
+ ++$absy;
+ }
+ $x = $absx - $bottoml1;
+
+ $snake2 = $absx - $snake0;
+ $V0[$limit + $k] = $x;
+ if ( $k >= $delta - $d + 1 && $k <= $delta + $d - 1
+ && $x >= $V1[$limit + $k - $delta]
+ ) {
+ return 2 * $d - 1;
+ }
+
+ // check to see if we can cut down the diagonal range
+ if ( $x >= $N && $end_forward > $k - 1 ) {
+ $end_forward = $k - 1;
+ } elseif ( $absy - $bottoml2 >= $M ) {
+ $start_forward = $k + 1;
+ $value_to_add_forward = 0;
+ }
+ }
+
+ $start_diag = max( $value_to_add_backward + $start_backward, -$d );
+ $end_diag = min( $end_backward, $d );
+ $value_to_add_backward = 1 - $value_to_add_backward;
+
+ // compute backward furthest reaching paths
+ for ( $k = $start_diag; $k <= $end_diag; $k += 2 ) {
+ if ( $k == $d
+ || ( $k != -$d && $V1[$limit_min_1 + $k] < $V1[$limit_plus_1 + $k] )
+ ) {
+ $x = $V1[$limit_min_1 + $k];
+ } else {
+ $x = $V1[$limit_plus_1 + $k] - 1;
+ }
+
+ $y = $x - $k - $delta;
+
+ $snake2 = 0;
+ while ( $x > 0 && $y > 0
+ && $from[$x + $bottoml1_min_1] === $to[$y + $bottoml2_min_1]
+ ) {
+ --$x;
+ --$y;
+ ++$snake2;
+ }
+ $V1[$limit + $k] = $x;
+
+ // check to see if we can cut down our diagonal range
+ if ( $x <= 0 ) {
+ $start_backward = $k + 1;
+ $value_to_add_backward = 0;
+ } elseif ( $y <= 0 && $end_backward > $k - 1 ) {
+ $end_backward = $k - 1;
+ }
+ }
+ }
+ } else {
+ for ( $d = 0; $d <= $limit; ++$d ) {
+ $start_diag = max( $value_to_add_forward + $start_forward, -$d );
+ $end_diag = min( $end_forward, $d );
+ $value_to_add_forward = 1 - $value_to_add_forward;
+
+ // compute forward furthest reaching paths
+ for ( $k = $start_diag; $k <= $end_diag; $k += 2 ) {
+ if ( $k == -$d
+ || ( $k < $d && $V0[$limit_min_1 + $k] < $V0[$limit_plus_1 + $k] )
+ ) {
+ $x = $V0[$limit_plus_1 + $k];
+ } else {
+ $x = $V0[$limit_min_1 + $k] + 1;
+ }
+
+ $absx = $snake0 = $x + $bottoml1;
+ $absy = $snake1 = $x - $k + $bottoml2;
+
+ while ( $absx < $maxabsx && $absy < $maxabsy && $from[$absx] === $to[$absy] ) {
+ ++$absx;
+ ++$absy;
+ }
+ $x = $absx - $bottoml1;
+ $snake2 = $absx - $snake0;
+ $V0[$limit + $k] = $x;
+
+ // check to see if we can cut down the diagonal range
+ if ( $x >= $N && $end_forward > $k - 1 ) {
+ $end_forward = $k - 1;
+ } elseif ( $absy - $bottoml2 >= $M ) {
+ $start_forward = $k + 1;
+ $value_to_add_forward = 0;
+ }
+ }
+
+ $start_diag = max( $value_to_add_backward + $start_backward, -$d );
+ $end_diag = min( $end_backward, $d );
+ $value_to_add_backward = 1 - $value_to_add_backward;
+
+ // compute backward furthest reaching paths
+ for ( $k = $start_diag; $k <= $end_diag; $k += 2 ) {
+ if ( $k == $d
+ || ( $k != -$d && $V1[$limit_min_1 + $k] < $V1[$limit_plus_1 + $k] )
+ ) {
+ $x = $V1[$limit_min_1 + $k];
+ } else {
+ $x = $V1[$limit_plus_1 + $k] - 1;
+ }
+
+ $y = $x - $k - $delta;
+
+ $snake2 = 0;
+ while ( $x > 0 && $y > 0
+ && $from[$x + $bottoml1_min_1] === $to[$y + $bottoml2_min_1]
+ ) {
+ --$x;
+ --$y;
+ ++$snake2;
+ }
+ $V1[$limit + $k] = $x;
+
+ if ( $k >= -$delta - $d && $k <= $d - $delta
+ && $x <= $V0[$limit + $k + $delta]
+ ) {
+ $snake0 = $bottoml1 + $x;
+ $snake1 = $bottoml2 + $y;
+
+ return 2 * $d;
+ }
+
+ // check to see if we can cut down our diagonal range
+ if ( $x <= 0 ) {
+ $start_backward = $k + 1;
+ $value_to_add_backward = 0;
+ } elseif ( $y <= 0 && $end_backward > $k - 1 ) {
+ $end_backward = $k - 1;
+ }
+ }
+ }
+ }
+ /*
+ * computing the true LCS is too expensive, instead find the diagonal
+ * with the most progress and pretend a midle snake of length 0 occurs
+ * there.
+ */
+
+ $most_progress = self::findMostProgress( $M, $N, $limit, $V );
+
+ $snake0 = $bottoml1 + $most_progress[0];
+ $snake1 = $bottoml2 + $most_progress[1];
+ $snake2 = 0;
+ wfDebug( "Computing the LCS is too expensive. Using a heuristic.\n" );
+ $this->heuristicUsed = true;
+
+ return 5; /*
+ * HACK: since we didn't really finish the LCS computation
+ * we don't really know the length of the SES. We don't do
+ * anything with the result anyway, unless it's <=1. We know
+ * for a fact SES > 1 so 5 is as good a number as any to
+ * return here
+ */
+ }
+
+ private static function findMostProgress( $M, $N, $limit, $V ) {
+ $delta = $N - $M;
+
+ if ( ( $M & 1 ) == ( $limit & 1 ) ) {
+ $forward_start_diag = max( -$M, -$limit );
+ } else {
+ $forward_start_diag = max( 1 - $M, -$limit );
+ }
+
+ $forward_end_diag = min( $N, $limit );
+
+ if ( ( $N & 1 ) == ( $limit & 1 ) ) {
+ $backward_start_diag = max( -$N, -$limit );
+ } else {
+ $backward_start_diag = max( 1 - $N, -$limit );
+ }
+
+ $backward_end_diag = -min( $M, $limit );
+
+ $temp = [ 0, 0, 0 ];
+
+ $max_progress = array_fill( 0, ceil( max( $forward_end_diag - $forward_start_diag,
+ $backward_end_diag - $backward_start_diag ) / 2 ), $temp );
+ $num_progress = 0; // the 1st entry is current, it is initialized
+ // with 0s
+
+ // first search the forward diagonals
+ for ( $k = $forward_start_diag; $k <= $forward_end_diag; $k += 2 ) {
+ $x = $V[0][$limit + $k];
+ $y = $x - $k;
+ if ( $x > $N || $y > $M ) {
+ continue;
+ }
+
+ $progress = $x + $y;
+ if ( $progress > $max_progress[0][2] ) {
+ $num_progress = 0;
+ $max_progress[0][0] = $x;
+ $max_progress[0][1] = $y;
+ $max_progress[0][2] = $progress;
+ } elseif ( $progress == $max_progress[0][2] ) {
+ ++$num_progress;
+ $max_progress[$num_progress][0] = $x;
+ $max_progress[$num_progress][1] = $y;
+ $max_progress[$num_progress][2] = $progress;
+ }
+ }
+
+ $max_progress_forward = true; // initially the maximum
+ // progress is in the forward
+ // direction
+
+ // now search the backward diagonals
+ for ( $k = $backward_start_diag; $k <= $backward_end_diag; $k += 2 ) {
+ $x = $V[1][$limit + $k];
+ $y = $x - $k - $delta;
+ if ( $x < 0 || $y < 0 ) {
+ continue;
+ }
+
+ $progress = $N - $x + $M - $y;
+ if ( $progress > $max_progress[0][2] ) {
+ $num_progress = 0;
+ $max_progress_forward = false;
+ $max_progress[0][0] = $x;
+ $max_progress[0][1] = $y;
+ $max_progress[0][2] = $progress;
+ } elseif ( $progress == $max_progress[0][2] && !$max_progress_forward ) {
+ ++$num_progress;
+ $max_progress[$num_progress][0] = $x;
+ $max_progress[$num_progress][1] = $y;
+ $max_progress[$num_progress][2] = $progress;
+ }
+ }
+
+ // return the middle diagonal with maximal progress.
+ return $max_progress[(int)floor( $num_progress / 2 )];
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getLcsLength() {
+ if ( $this->heuristicUsed && !$this->lcsLengthCorrectedForHeuristic ) {
+ $this->lcsLengthCorrectedForHeuristic = true;
+ $this->length = $this->m - array_sum( $this->added );
+ }
+
+ return $this->length;
+ }
+
+}
+
+/**
+ * Alternative representation of a set of changes, by the index
+ * ranges that are changed.
+ *
+ * @ingroup DifferenceEngine
+ */
+class RangeDifference {
+
+ /** @var int */
+ public $leftstart;
+
+ /** @var int */
+ public $leftend;
+
+ /** @var int */
+ public $leftlength;
+
+ /** @var int */
+ public $rightstart;
+
+ /** @var int */
+ public $rightend;
+
+ /** @var int */
+ public $rightlength;
+
+ function __construct( $leftstart, $leftend, $rightstart, $rightend ) {
+ $this->leftstart = $leftstart;
+ $this->leftend = $leftend;
+ $this->leftlength = $leftend - $leftstart;
+ $this->rightstart = $rightstart;
+ $this->rightend = $rightend;
+ $this->rightlength = $rightend - $rightstart;
+ }
+
+}
diff --git a/www/wiki/includes/diff/WordAccumulator.php b/www/wiki/includes/diff/WordAccumulator.php
new file mode 100644
index 00000000..ad802756
--- /dev/null
+++ b/www/wiki/includes/diff/WordAccumulator.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Copyright © 2000, 2001 Geoffrey T. Dairiki <dairiki@dairiki.org>
+ * You may copy this code freely under the conditions of the GPL.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ * @defgroup DifferenceEngine DifferenceEngine
+ */
+
+namespace MediaWiki\Diff;
+
+/**
+ * Stores, escapes and formats the results of word-level diff
+ *
+ * @private
+ * @ingroup DifferenceEngine
+ */
+class WordAccumulator {
+ public $insClass = ' class="diffchange diffchange-inline"';
+ public $delClass = ' class="diffchange diffchange-inline"';
+
+ private $lines = [];
+ private $line = '';
+ private $group = '';
+ private $tag = '';
+
+ /**
+ * @param string $new_tag
+ */
+ private function flushGroup( $new_tag ) {
+ if ( $this->group !== '' ) {
+ if ( $this->tag == 'ins' ) {
+ $this->line .= "<ins{$this->insClass}>" . htmlspecialchars( $this->group ) . '</ins>';
+ } elseif ( $this->tag == 'del' ) {
+ $this->line .= "<del{$this->delClass}>" . htmlspecialchars( $this->group ) . '</del>';
+ } else {
+ $this->line .= htmlspecialchars( $this->group );
+ }
+ }
+ $this->group = '';
+ $this->tag = $new_tag;
+ }
+
+ /**
+ * @param string $new_tag
+ */
+ private function flushLine( $new_tag ) {
+ $this->flushGroup( $new_tag );
+ if ( $this->line != '' ) {
+ array_push( $this->lines, $this->line );
+ } else {
+ # make empty lines visible by inserting an NBSP
+ array_push( $this->lines, '&#160;' );
+ }
+ $this->line = '';
+ }
+
+ /**
+ * @param string[] $words
+ * @param string $tag
+ */
+ public function addWords( $words, $tag = '' ) {
+ if ( $tag != $this->tag ) {
+ $this->flushGroup( $tag );
+ }
+
+ foreach ( $words as $word ) {
+ // new-line should only come as first char of word.
+ if ( $word == '' ) {
+ continue;
+ }
+ if ( $word[0] == "\n" ) {
+ $this->flushLine( $tag );
+ $word = substr( $word, 1 );
+ }
+ assert( !strstr( $word, "\n" ) );
+ $this->group .= $word;
+ }
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getLines() {
+ $this->flushLine( '~done' );
+
+ return $this->lines;
+ }
+}
diff --git a/www/wiki/includes/diff/WordLevelDiff.php b/www/wiki/includes/diff/WordLevelDiff.php
new file mode 100644
index 00000000..0b318bdb
--- /dev/null
+++ b/www/wiki/includes/diff/WordLevelDiff.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Copyright © 2000, 2001 Geoffrey T. Dairiki <dairiki@dairiki.org>
+ * You may copy this code freely under the conditions of the GPL.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup DifferenceEngine
+ * @defgroup DifferenceEngine DifferenceEngine
+ */
+
+use MediaWiki\Diff\ComplexityException;
+use MediaWiki\Diff\WordAccumulator;
+
+/**
+ * Performs a word-level diff on several lines
+ *
+ * @ingroup DifferenceEngine
+ */
+class WordLevelDiff extends \Diff {
+ /**
+ * @inheritDoc
+ */
+ protected $bailoutComplexity = 40000000; // Roughly 6K x 6K words changed
+
+ /**
+ * @param string[] $linesBefore
+ * @param string[] $linesAfter
+ */
+ public function __construct( $linesBefore, $linesAfter ) {
+ list( $wordsBefore, $wordsBeforeStripped ) = $this->split( $linesBefore );
+ list( $wordsAfter, $wordsAfterStripped ) = $this->split( $linesAfter );
+
+ try {
+ parent::__construct( $wordsBeforeStripped, $wordsAfterStripped );
+ } catch ( ComplexityException $ex ) {
+ // Too hard to diff, just show whole paragraph(s) as changed
+ $this->edits = [ new DiffOpChange( $linesBefore, $linesAfter ) ];
+ }
+
+ $xi = $yi = 0;
+ $editCount = count( $this->edits );
+ for ( $i = 0; $i < $editCount; $i++ ) {
+ $orig = &$this->edits[$i]->orig;
+ if ( is_array( $orig ) ) {
+ $orig = array_slice( $wordsBefore, $xi, count( $orig ) );
+ $xi += count( $orig );
+ }
+
+ $closing = &$this->edits[$i]->closing;
+ if ( is_array( $closing ) ) {
+ $closing = array_slice( $wordsAfter, $yi, count( $closing ) );
+ $yi += count( $closing );
+ }
+ }
+ }
+
+ /**
+ * @param string[] $lines
+ *
+ * @return array[]
+ */
+ private function split( $lines ) {
+ $words = [];
+ $stripped = [];
+ $first = true;
+ foreach ( $lines as $line ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $words[] = "\n";
+ $stripped[] = "\n";
+ }
+ $m = [];
+ if ( preg_match_all( '/ ( [^\S\n]+ | [0-9_A-Za-z\x80-\xff]+ | . ) (?: (?!< \n) [^\S\n])? /xs',
+ $line, $m ) ) {
+ foreach ( $m[0] as $word ) {
+ $words[] = $word;
+ }
+ foreach ( $m[1] as $stripped_word ) {
+ $stripped[] = $stripped_word;
+ }
+ }
+ }
+
+ return [ $words, $stripped ];
+ }
+
+ /**
+ * @return string[]
+ */
+ public function orig() {
+ $orig = new WordAccumulator;
+
+ foreach ( $this->edits as $edit ) {
+ if ( $edit->type == 'copy' ) {
+ $orig->addWords( $edit->orig );
+ } elseif ( $edit->orig ) {
+ $orig->addWords( $edit->orig, 'del' );
+ }
+ }
+ $lines = $orig->getLines();
+
+ return $lines;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function closing() {
+ $closing = new WordAccumulator;
+
+ foreach ( $this->edits as $edit ) {
+ if ( $edit->type == 'copy' ) {
+ $closing->addWords( $edit->closing );
+ } elseif ( $edit->closing ) {
+ $closing->addWords( $edit->closing, 'ins' );
+ }
+ }
+ $lines = $closing->getLines();
+
+ return $lines;
+ }
+
+}
diff --git a/www/wiki/includes/edit/PreparedEdit.php b/www/wiki/includes/edit/PreparedEdit.php
new file mode 100644
index 00000000..62624f4d
--- /dev/null
+++ b/www/wiki/includes/edit/PreparedEdit.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Edit;
+
+use Content;
+use ParserOptions;
+use ParserOutput;
+
+/**
+ * Represents information returned by WikiPage::prepareContentForEdit()
+ *
+ * @since 1.30
+ */
+class PreparedEdit {
+
+ /**
+ * Time this prepared edit was made
+ *
+ * @var string
+ */
+ public $timestamp;
+
+ /**
+ * Revision ID
+ *
+ * @var int|null
+ */
+ public $revid;
+
+ /**
+ * Content after going through pre-save transform
+ *
+ * @var Content|null
+ */
+ public $pstContent;
+
+ /**
+ * Content format
+ *
+ * @var string
+ */
+ public $format;
+
+ /**
+ * Parser options used to get parser output
+ *
+ * @var ParserOptions
+ */
+ public $popts;
+
+ /**
+ * Parser output
+ *
+ * @var ParserOutput|null
+ */
+ public $output;
+
+ /**
+ * Content that is being saved (before PST)
+ *
+ * @var Content
+ */
+ public $newContent;
+
+ /**
+ * Current content of the page, if any
+ *
+ * @var Content|null
+ */
+ public $oldContent;
+
+ /**
+ * $newContent in text form
+ *
+ * @var string
+ * @deprecated since 1.21
+ */
+ public $newText;
+
+ /**
+ * $oldContent in text from
+ *
+ * @var string
+ * @deprecated since 1.21
+ */
+ public $oldText;
+
+ /**
+ * $pstContent in text form
+ *
+ * @var string
+ * @deprecated since 1.21
+ */
+ public $pst;
+}
diff --git a/www/wiki/includes/exception/BadRequestError.php b/www/wiki/includes/exception/BadRequestError.php
new file mode 100644
index 00000000..5fcf0e62
--- /dev/null
+++ b/www/wiki/includes/exception/BadRequestError.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * An error page that emits an HTTP 400 Bad Request status code.
+ *
+ * @since 1.28
+ * @ingroup Exception
+ */
+class BadRequestError extends ErrorPageError {
+
+ public function report() {
+ global $wgOut;
+ $wgOut->setStatusCode( 400 );
+ parent::report();
+ }
+}
diff --git a/www/wiki/includes/exception/BadTitleError.php b/www/wiki/includes/exception/BadTitleError.php
new file mode 100644
index 00000000..40c18a42
--- /dev/null
+++ b/www/wiki/includes/exception/BadTitleError.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Show an error page on a badtitle.
+ *
+ * Uses BadRequestError to emit a 400 HTTP error code to ensure caching proxies and
+ * mobile browsers know not to cache it as valid content. (T35646)
+ *
+ * @since 1.19
+ * @ingroup Exception
+ */
+class BadTitleError extends BadRequestError {
+ /**
+ * @param string|Message|MalformedTitleException $msg A message key (default: 'badtitletext'), or
+ * a MalformedTitleException to figure out things from
+ * @param array $params Parameter to wfMessage()
+ */
+ public function __construct( $msg = 'badtitletext', $params = [] ) {
+ if ( $msg instanceof MalformedTitleException ) {
+ $errorMessage = $msg->getErrorMessage();
+ if ( !$errorMessage ) {
+ parent::__construct( 'badtitle', 'badtitletext', [] );
+ } else {
+ $errorMessageParams = $msg->getErrorMessageParameters();
+ parent::__construct( 'badtitle', $errorMessage, $errorMessageParams );
+ }
+ } else {
+ parent::__construct( 'badtitle', $msg, $params );
+ }
+ }
+}
diff --git a/www/wiki/includes/exception/ErrorPageError.php b/www/wiki/includes/exception/ErrorPageError.php
new file mode 100644
index 00000000..4b181267
--- /dev/null
+++ b/www/wiki/includes/exception/ErrorPageError.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * An error page which can definitely be safely rendered using the OutputPage.
+ *
+ * @since 1.7
+ * @ingroup Exception
+ */
+class ErrorPageError extends MWException implements ILocalizedException {
+ public $title, $msg, $params;
+
+ /**
+ * Note: these arguments are keys into wfMessage(), not text!
+ *
+ * @param string|Message $title Message key (string) for page title, or a Message object
+ * @param string|Message $msg Message key (string) for error text, or a Message object
+ * @param array $params Array with parameters to wfMessage()
+ */
+ public function __construct( $title, $msg, $params = [] ) {
+ $this->title = $title;
+ $this->msg = $msg;
+ $this->params = $params;
+
+ // T46111: Messages in the log files should be in English and not
+ // customized by the local wiki. So get the default English version for
+ // passing to the parent constructor. Our overridden report() below
+ // makes sure that the page shown to the user is not forced to English.
+ $enMsg = $this->getMessageObject();
+ $enMsg->inLanguage( 'en' )->useDatabase( false );
+ parent::__construct( $enMsg->text() );
+ }
+
+ /**
+ * Return a Message object for this exception
+ * @since 1.29
+ * @return Message
+ */
+ public function getMessageObject() {
+ if ( $this->msg instanceof Message ) {
+ return clone $this->msg;
+ }
+ return wfMessage( $this->msg, $this->params );
+ }
+
+ public function report() {
+ if ( self::isCommandLine() || defined( 'MW_API' ) ) {
+ parent::report();
+ } else {
+ global $wgOut;
+ $wgOut->showErrorPage( $this->title, $this->msg, $this->params );
+ $wgOut->output();
+ }
+ }
+}
diff --git a/www/wiki/includes/exception/FatalError.php b/www/wiki/includes/exception/FatalError.php
new file mode 100644
index 00000000..a7d672fa
--- /dev/null
+++ b/www/wiki/includes/exception/FatalError.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Exception class which takes an HTML error message, and does not
+ * produce a backtrace. Replacement for OutputPage::fatalError().
+ *
+ * @since 1.7
+ * @ingroup Exception
+ */
+class FatalError extends MWException {
+
+ /**
+ * @return string
+ */
+ public function getHTML() {
+ return $this->getMessage();
+ }
+
+ /**
+ * @return string
+ */
+ public function getText() {
+ return $this->getMessage();
+ }
+}
diff --git a/www/wiki/includes/exception/HttpError.php b/www/wiki/includes/exception/HttpError.php
new file mode 100644
index 00000000..f464d8af
--- /dev/null
+++ b/www/wiki/includes/exception/HttpError.php
@@ -0,0 +1,129 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Show an error that looks like an HTTP server error.
+ * Replacement for wfHttpError().
+ *
+ * @since 1.19
+ * @ingroup Exception
+ */
+class HttpError extends MWException {
+ private $httpCode, $header, $content;
+
+ /**
+ * @param int $httpCode HTTP status code to send to the client
+ * @param string|Message $content Content of the message
+ * @param string|Message|null $header Content of the header (\<title\> and \<h1\>)
+ */
+ public function __construct( $httpCode, $content, $header = null ) {
+ parent::__construct( $content );
+ $this->httpCode = (int)$httpCode;
+ $this->header = $header;
+ $this->content = $content;
+ }
+
+ /**
+ * We don't want the default exception logging as we got our own logging set
+ * up in self::report.
+ *
+ * @see MWException::isLoggable
+ *
+ * @since 1.24
+ * @return bool
+ */
+ public function isLoggable() {
+ return false;
+ }
+
+ /**
+ * Returns the HTTP status code supplied to the constructor.
+ *
+ * @return int
+ */
+ public function getStatusCode() {
+ return $this->httpCode;
+ }
+
+ /**
+ * Report and log the HTTP error.
+ * Sends the appropriate HTTP status code and outputs an
+ * HTML page with an error message.
+ */
+ public function report() {
+ $this->doLog();
+
+ HttpStatus::header( $this->httpCode );
+ header( 'Content-type: text/html; charset=utf-8' );
+
+ print $this->getHTML();
+ }
+
+ private function doLog() {
+ $logger = LoggerFactory::getInstance( 'HttpError' );
+ $content = $this->content;
+
+ if ( $content instanceof Message ) {
+ $content = $content->text();
+ }
+
+ $context = [
+ 'file' => $this->getFile(),
+ 'line' => $this->getLine(),
+ 'http_code' => $this->httpCode,
+ ];
+
+ $logMsg = "$content ({http_code}) from {file}:{line}";
+
+ if ( $this->getStatusCode() < 500 ) {
+ $logger->info( $logMsg, $context );
+ } else {
+ $logger->error( $logMsg, $context );
+ }
+ }
+
+ /**
+ * Returns HTML for reporting the HTTP error.
+ * This will be a minimal but complete HTML document.
+ *
+ * @return string HTML
+ */
+ public function getHTML() {
+ if ( $this->header === null ) {
+ $titleHtml = htmlspecialchars( HttpStatus::getMessage( $this->httpCode ) );
+ } elseif ( $this->header instanceof Message ) {
+ $titleHtml = $this->header->escaped();
+ } else {
+ $titleHtml = htmlspecialchars( $this->header );
+ }
+
+ if ( $this->content instanceof Message ) {
+ $contentHtml = $this->content->escaped();
+ } else {
+ $contentHtml = nl2br( htmlspecialchars( $this->content ) );
+ }
+
+ return "<!DOCTYPE html>\n" .
+ "<html><head><title>$titleHtml</title></head>\n" .
+ "<body><h1>$titleHtml</h1><p>$contentHtml</p></body></html>\n";
+ }
+}
diff --git a/www/wiki/includes/exception/LocalizedException.php b/www/wiki/includes/exception/LocalizedException.php
new file mode 100644
index 00000000..d2cb5d17
--- /dev/null
+++ b/www/wiki/includes/exception/LocalizedException.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Interface for MediaWiki-localized exceptions
+ *
+ * @since 1.29
+ * @ingroup Exception
+ */
+interface ILocalizedException {
+ /**
+ * Return a Message object for this exception
+ * @return Message
+ */
+ public function getMessageObject();
+}
+
+/**
+ * Basic localized exception.
+ *
+ * @since 1.29
+ * @ingroup Exception
+ * @note Don't use this in a situation where MessageCache is not functional.
+ */
+class LocalizedException extends Exception implements ILocalizedException {
+ /** @var string|array|MessageSpecifier */
+ protected $messageSpec;
+
+ /**
+ * @param string|array|MessageSpecifier $messageSpec See Message::newFromSpecifier
+ * @param int $code Exception code
+ * @param Exception|Throwable $previous The previous exception used for the exception chaining.
+ */
+ public function __construct( $messageSpec, $code = 0, $previous = null ) {
+ $this->messageSpec = $messageSpec;
+
+ // Exception->getMessage() should be in plain English, not localized.
+ // So fetch the English version of the message, without local
+ // customizations, and make a basic attempt to turn markup into text.
+ $msg = $this->getMessageObject()->inLanguage( 'en' )->useDatabase( false )->text();
+ $msg = preg_replace( '!</?(var|kbd|samp|code)>!', '"', $msg );
+ $msg = Sanitizer::stripAllTags( $msg );
+ parent::__construct( $msg, $code, $previous );
+ }
+
+ public function getMessageObject() {
+ return Message::newFromSpecifier( $this->messageSpec );
+ }
+}
diff --git a/www/wiki/includes/exception/MWContentSerializationException.php b/www/wiki/includes/exception/MWContentSerializationException.php
new file mode 100644
index 00000000..500cf7ce
--- /dev/null
+++ b/www/wiki/includes/exception/MWContentSerializationException.php
@@ -0,0 +1,8 @@
+<?php
+/**
+ * Exception representing a failure to serialize or unserialize a content object.
+ *
+ * @ingroup Content
+ */
+class MWContentSerializationException extends MWException {
+}
diff --git a/www/wiki/includes/exception/MWException.php b/www/wiki/includes/exception/MWException.php
new file mode 100644
index 00000000..c3f09a6f
--- /dev/null
+++ b/www/wiki/includes/exception/MWException.php
@@ -0,0 +1,230 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * MediaWiki exception
+ *
+ * @ingroup Exception
+ */
+class MWException extends Exception {
+ /**
+ * Should the exception use $wgOut to output the error?
+ *
+ * @return bool
+ */
+ public function useOutputPage() {
+ return $this->useMessageCache() &&
+ !empty( $GLOBALS['wgFullyInitialised'] ) &&
+ !empty( $GLOBALS['wgOut'] ) &&
+ !defined( 'MEDIAWIKI_INSTALL' );
+ }
+
+ /**
+ * Whether to log this exception in the exception debug log.
+ *
+ * @since 1.23
+ * @return bool
+ */
+ public function isLoggable() {
+ return true;
+ }
+
+ /**
+ * Can the extension use the Message class/wfMessage to get i18n-ed messages?
+ *
+ * @return bool
+ */
+ public function useMessageCache() {
+ global $wgLang;
+
+ foreach ( $this->getTrace() as $frame ) {
+ if ( isset( $frame['class'] ) && $frame['class'] === 'LocalisationCache' ) {
+ return false;
+ }
+ }
+
+ return $wgLang instanceof Language;
+ }
+
+ /**
+ * Get a message from i18n
+ *
+ * @param string $key Message name
+ * @param string $fallback Default message if the message cache can't be
+ * called by the exception
+ * The function also has other parameters that are arguments for the message
+ * @return string Message with arguments replaced
+ */
+ public function msg( $key, $fallback /*[, params...] */ ) {
+ $args = array_slice( func_get_args(), 2 );
+
+ if ( $this->useMessageCache() ) {
+ try {
+ return wfMessage( $key, $args )->text();
+ } catch ( Exception $e ) {
+ }
+ }
+ return wfMsgReplaceArgs( $fallback, $args );
+ }
+
+ /**
+ * If $wgShowExceptionDetails is true, return a HTML message with a
+ * backtrace to the error, otherwise show a message to ask to set it to true
+ * to show that information.
+ *
+ * @return string Html to output
+ */
+ public function getHTML() {
+ global $wgShowExceptionDetails;
+
+ if ( $wgShowExceptionDetails ) {
+ return '<p>' . nl2br( htmlspecialchars( MWExceptionHandler::getLogMessage( $this ) ) ) .
+ '</p><p>Backtrace:</p><p>' .
+ nl2br( htmlspecialchars( MWExceptionHandler::getRedactedTraceAsString( $this ) ) ) .
+ "</p>\n";
+ } else {
+ $logId = WebRequest::getRequestId();
+ $type = static::class;
+ return "<div class=\"errorbox\">" .
+ htmlspecialchars(
+ '[' . $logId . '] ' .
+ gmdate( 'Y-m-d H:i:s' ) . ": " .
+ $this->msg( "internalerror-fatal-exception",
+ "Fatal exception of type $1",
+ $type,
+ $logId,
+ MWExceptionHandler::getURL( $this )
+ )
+ ) . "</div>\n" .
+ "<!-- Set \$wgShowExceptionDetails = true; " .
+ "at the bottom of LocalSettings.php to show detailed " .
+ "debugging information. -->";
+ }
+ }
+
+ /**
+ * Get the text to display when reporting the error on the command line.
+ * If $wgShowExceptionDetails is true, return a text message with a
+ * backtrace to the error.
+ *
+ * @return string
+ */
+ public function getText() {
+ global $wgShowExceptionDetails;
+
+ if ( $wgShowExceptionDetails ) {
+ return MWExceptionHandler::getLogMessage( $this ) .
+ "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $this ) . "\n";
+ } else {
+ return "Set \$wgShowExceptionDetails = true; " .
+ "in LocalSettings.php to show detailed debugging information.\n";
+ }
+ }
+
+ /**
+ * Return the title of the page when reporting this error in a HTTP response.
+ *
+ * @return string
+ */
+ public function getPageTitle() {
+ return $this->msg( 'internalerror', 'Internal error' );
+ }
+
+ /**
+ * Output the exception report using HTML.
+ */
+ public function reportHTML() {
+ global $wgOut, $wgSitename;
+ if ( $this->useOutputPage() ) {
+ $wgOut->prepareErrorPage( $this->getPageTitle() );
+
+ $wgOut->addHTML( $this->getHTML() );
+
+ $wgOut->output();
+ } else {
+ self::header( 'Content-Type: text/html; charset=utf-8' );
+ echo "<!DOCTYPE html>\n" .
+ '<html><head>' .
+ // Mimick OutputPage::setPageTitle behaviour
+ '<title>' .
+ htmlspecialchars( $this->msg( 'pagetitle', "$1 - $wgSitename", $this->getPageTitle() ) ) .
+ '</title>' .
+ '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' .
+ "</head><body>\n";
+
+ echo $this->getHTML();
+
+ echo "</body></html>\n";
+ }
+ }
+
+ /**
+ * Output a report about the exception and takes care of formatting.
+ * It will be either HTML or plain text based on isCommandLine().
+ */
+ public function report() {
+ global $wgMimeType;
+
+ if ( defined( 'MW_API' ) ) {
+ // Unhandled API exception, we can't be sure that format printer is alive
+ self::header( 'MediaWiki-API-Error: internal_api_error_' . static::class );
+ wfHttpError( 500, 'Internal Server Error', $this->getText() );
+ } elseif ( self::isCommandLine() ) {
+ $message = $this->getText();
+ // T17602: STDERR may not be available
+ if ( defined( 'STDERR' ) ) {
+ fwrite( STDERR, $message );
+ } else {
+ echo $message;
+ }
+ } else {
+ self::statusHeader( 500 );
+ self::header( "Content-Type: $wgMimeType; charset=utf-8" );
+
+ $this->reportHTML();
+ }
+ }
+
+ /**
+ * Check whether we are in command line mode or not to report the exception
+ * in the correct format.
+ *
+ * @return bool
+ */
+ public static function isCommandLine() {
+ return !empty( $GLOBALS['wgCommandLineMode'] );
+ }
+
+ /**
+ * Send a header, if we haven't already sent them. We shouldn't,
+ * but sometimes we might in a weird case like Export
+ * @param string $header
+ */
+ private static function header( $header ) {
+ if ( !headers_sent() ) {
+ header( $header );
+ }
+ }
+ private static function statusHeader( $code ) {
+ if ( !headers_sent() ) {
+ HttpStatus::header( $code );
+ }
+ }
+}
diff --git a/www/wiki/includes/exception/MWExceptionHandler.php b/www/wiki/includes/exception/MWExceptionHandler.php
new file mode 100644
index 00000000..a2ec391d
--- /dev/null
+++ b/www/wiki/includes/exception/MWExceptionHandler.php
@@ -0,0 +1,665 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LogLevel;
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * Handler class for MWExceptions
+ * @ingroup Exception
+ */
+class MWExceptionHandler {
+ const CAUGHT_BY_HANDLER = 'mwe_handler'; // error reported by this exception handler
+ const CAUGHT_BY_OTHER = 'other'; // error reported by direct logException() call
+
+ /**
+ * @var string $reservedMemory
+ */
+ protected static $reservedMemory;
+ /**
+ * @var array $fatalErrorTypes
+ */
+ protected static $fatalErrorTypes = [
+ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR,
+ /* HHVM's FATAL_ERROR level */ 16777217,
+ ];
+ /**
+ * @var bool $handledFatalCallback
+ */
+ protected static $handledFatalCallback = false;
+
+ /**
+ * Install handlers with PHP.
+ */
+ public static function installHandler() {
+ set_exception_handler( 'MWExceptionHandler::handleException' );
+ set_error_handler( 'MWExceptionHandler::handleError' );
+
+ // Reserve 16k of memory so we can report OOM fatals
+ self::$reservedMemory = str_repeat( ' ', 16384 );
+ register_shutdown_function( 'MWExceptionHandler::handleFatalError' );
+ }
+
+ /**
+ * Report an exception to the user
+ * @param Exception|Throwable $e
+ */
+ protected static function report( $e ) {
+ try {
+ // Try and show the exception prettily, with the normal skin infrastructure
+ if ( $e instanceof MWException ) {
+ // Delegate to MWException until all subclasses are handled by
+ // MWExceptionRenderer and MWException::report() has been
+ // removed.
+ $e->report();
+ } else {
+ MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_PRETTY );
+ }
+ } catch ( Exception $e2 ) {
+ // Exception occurred from within exception handler
+ // Show a simpler message for the original exception,
+ // don't try to invoke report()
+ MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW, $e2 );
+ }
+ }
+
+ /**
+ * Roll back any open database transactions and log the stack trace of the exception
+ *
+ * This method is used to attempt to recover from exceptions
+ *
+ * @since 1.23
+ * @param Exception|Throwable $e
+ */
+ public static function rollbackMasterChangesAndLog( $e ) {
+ $services = MediaWikiServices::getInstance();
+ if ( !$services->isServiceDisabled( 'DBLoadBalancerFactory' ) ) {
+ // Rollback DBs to avoid transaction notices. This might fail
+ // to rollback some databases due to connection issues or exceptions.
+ // However, any sane DB driver will rollback implicitly anyway.
+ try {
+ $services->getDBLoadBalancerFactory()->rollbackMasterChanges( __METHOD__ );
+ } catch ( DBError $e2 ) {
+ // If the DB is unreacheable, rollback() will throw an error
+ // and the error report() method might need messages from the DB,
+ // which would result in an exception loop. PHP may escalate such
+ // errors to "Exception thrown without a stack frame" fatals, but
+ // it's better to be explicit here.
+ self::logException( $e2, self::CAUGHT_BY_HANDLER );
+ }
+ }
+
+ self::logException( $e, self::CAUGHT_BY_HANDLER );
+ }
+
+ /**
+ * Exception handler which simulates the appropriate catch() handling:
+ *
+ * try {
+ * ...
+ * } catch ( Exception $e ) {
+ * $e->report();
+ * } catch ( Exception $e ) {
+ * echo $e->__toString();
+ * }
+ *
+ * @since 1.25
+ * @param Exception|Throwable $e
+ */
+ public static function handleException( $e ) {
+ self::rollbackMasterChangesAndLog( $e );
+ self::report( $e );
+ }
+
+ /**
+ * Handler for set_error_handler() callback notifications.
+ *
+ * Receive a callback from the interpreter for a raised error, create an
+ * ErrorException, and log the exception to the 'error' logging
+ * channel(s). If the raised error is a fatal error type (only under HHVM)
+ * delegate to handleFatalError() instead.
+ *
+ * @since 1.25
+ *
+ * @param int $level Error level raised
+ * @param string $message
+ * @param string $file
+ * @param int $line
+ * @return bool
+ *
+ * @see logError()
+ */
+ public static function handleError(
+ $level, $message, $file = null, $line = null
+ ) {
+ if ( in_array( $level, self::$fatalErrorTypes ) ) {
+ return call_user_func_array(
+ 'MWExceptionHandler::handleFatalError', func_get_args()
+ );
+ }
+
+ // Map error constant to error name (reverse-engineer PHP error
+ // reporting)
+ switch ( $level ) {
+ case E_RECOVERABLE_ERROR:
+ $levelName = 'Error';
+ $severity = LogLevel::ERROR;
+ break;
+ case E_WARNING:
+ case E_CORE_WARNING:
+ case E_COMPILE_WARNING:
+ case E_USER_WARNING:
+ $levelName = 'Warning';
+ $severity = LogLevel::WARNING;
+ break;
+ case E_NOTICE:
+ case E_USER_NOTICE:
+ $levelName = 'Notice';
+ $severity = LogLevel::INFO;
+ break;
+ case E_STRICT:
+ $levelName = 'Strict Standards';
+ $severity = LogLevel::DEBUG;
+ break;
+ case E_DEPRECATED:
+ case E_USER_DEPRECATED:
+ $levelName = 'Deprecated';
+ $severity = LogLevel::INFO;
+ break;
+ default:
+ $levelName = 'Unknown error';
+ $severity = LogLevel::ERROR;
+ break;
+ }
+
+ $e = new ErrorException( "PHP $levelName: $message", 0, $level, $file, $line );
+ self::logError( $e, 'error', $severity );
+
+ // This handler is for logging only. Return false will instruct PHP
+ // to continue regular handling.
+ return false;
+ }
+
+ /**
+ * Dual purpose callback used as both a set_error_handler() callback and
+ * a registered shutdown function. Receive a callback from the interpreter
+ * for a raised error or system shutdown, check for a fatal error, and log
+ * to the 'fatal' logging channel.
+ *
+ * Special handling is included for missing class errors as they may
+ * indicate that the user needs to install 3rd-party libraries via
+ * Composer or other means.
+ *
+ * @since 1.25
+ *
+ * @param int $level Error level raised
+ * @param string $message Error message
+ * @param string $file File that error was raised in
+ * @param int $line Line number error was raised at
+ * @param array $context Active symbol table point of error
+ * @param array $trace Backtrace at point of error (undocumented HHVM
+ * feature)
+ * @return bool Always returns false
+ */
+ public static function handleFatalError(
+ $level = null, $message = null, $file = null, $line = null,
+ $context = null, $trace = null
+ ) {
+ // Free reserved memory so that we have space to process OOM
+ // errors
+ self::$reservedMemory = null;
+
+ if ( $level === null ) {
+ // Called as a shutdown handler, get data from error_get_last()
+ if ( static::$handledFatalCallback ) {
+ // Already called once (probably as an error handler callback
+ // under HHVM) so don't log again.
+ return false;
+ }
+
+ $lastError = error_get_last();
+ if ( $lastError !== null ) {
+ $level = $lastError['type'];
+ $message = $lastError['message'];
+ $file = $lastError['file'];
+ $line = $lastError['line'];
+ } else {
+ $level = 0;
+ $message = '';
+ }
+ }
+
+ if ( !in_array( $level, self::$fatalErrorTypes ) ) {
+ // Only interested in fatal errors, others should have been
+ // handled by MWExceptionHandler::handleError
+ return false;
+ }
+
+ $msg = "[{exception_id}] PHP Fatal Error: {$message}";
+
+ // Look at message to see if this is a class not found failure
+ // HHVM: Class undefined: foo
+ // PHP5: Class 'foo' not found
+ if ( preg_match( "/Class (undefined: \w+|'\w+' not found)/", $msg ) ) {
+ // @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong
+ $msg = <<<TXT
+{$msg}
+
+MediaWiki or an installed extension requires this class but it is not embedded directly in MediaWiki's git repository and must be installed separately by the end user.
+
+Please see <a href="https://www.mediawiki.org/wiki/Download_from_Git#Fetch_external_libraries">mediawiki.org</a> for help on installing the required components.
+TXT;
+ // @codingStandardsIgnoreEnd
+ }
+
+ // We can't just create an exception and log it as it is likely that
+ // the interpreter has unwound the stack already. If that is true the
+ // stacktrace we would get would be functionally empty. If however we
+ // have been called as an error handler callback *and* HHVM is in use
+ // we will have been provided with a useful stacktrace that we can
+ // log.
+ $trace = $trace ?: debug_backtrace();
+ $logger = LoggerFactory::getInstance( 'fatal' );
+ $logger->error( $msg, [
+ 'fatal_exception' => [
+ 'class' => 'ErrorException',
+ 'message' => "PHP Fatal Error: {$message}",
+ 'code' => $level,
+ 'file' => $file,
+ 'line' => $line,
+ 'trace' => static::redactTrace( $trace ),
+ ],
+ 'exception_id' => wfRandomString( 8 ),
+ 'caught_by' => self::CAUGHT_BY_HANDLER
+ ] );
+
+ // Remember call so we don't double process via HHVM's fatal
+ // notifications and the shutdown hook behavior
+ static::$handledFatalCallback = true;
+ return false;
+ }
+
+ /**
+ * Generate a string representation of an exception's stack trace
+ *
+ * Like Exception::getTraceAsString, but replaces argument values with
+ * argument type or class name.
+ *
+ * @param Exception|Throwable $e
+ * @return string
+ * @see prettyPrintTrace()
+ */
+ public static function getRedactedTraceAsString( $e ) {
+ return self::prettyPrintTrace( self::getRedactedTrace( $e ) );
+ }
+
+ /**
+ * Generate a string representation of a stacktrace.
+ *
+ * @param array $trace
+ * @param string $pad Constant padding to add to each line of trace
+ * @return string
+ * @since 1.26
+ */
+ public static function prettyPrintTrace( array $trace, $pad = '' ) {
+ $text = '';
+
+ $level = 0;
+ foreach ( $trace as $level => $frame ) {
+ if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
+ $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): ";
+ } else {
+ // 'file' and 'line' are unset for calls via call_user_func
+ // (T57634) This matches behaviour of
+ // Exception::getTraceAsString to instead display "[internal
+ // function]".
+ $text .= "{$pad}#{$level} [internal function]: ";
+ }
+
+ if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) {
+ $text .= $frame['class'] . $frame['type'] . $frame['function'];
+ } elseif ( isset( $frame['function'] ) ) {
+ $text .= $frame['function'];
+ } else {
+ $text .= 'NO_FUNCTION_GIVEN';
+ }
+
+ if ( isset( $frame['args'] ) ) {
+ $text .= '(' . implode( ', ', $frame['args'] ) . ")\n";
+ } else {
+ $text .= "()\n";
+ }
+ }
+
+ $level = $level + 1;
+ $text .= "{$pad}#{$level} {main}";
+
+ return $text;
+ }
+
+ /**
+ * Return a copy of an exception's backtrace as an array.
+ *
+ * Like Exception::getTrace, but replaces each element in each frame's
+ * argument array with the name of its class (if the element is an object)
+ * or its type (if the element is a PHP primitive).
+ *
+ * @since 1.22
+ * @param Exception|Throwable $e
+ * @return array
+ */
+ public static function getRedactedTrace( $e ) {
+ return static::redactTrace( $e->getTrace() );
+ }
+
+ /**
+ * Redact a stacktrace generated by Exception::getTrace(),
+ * debug_backtrace() or similar means. Replaces each element in each
+ * frame's argument array with the name of its class (if the element is an
+ * object) or its type (if the element is a PHP primitive).
+ *
+ * @since 1.26
+ * @param array $trace Stacktrace
+ * @return array Stacktrace with arugment values converted to data types
+ */
+ public static function redactTrace( array $trace ) {
+ return array_map( function ( $frame ) {
+ if ( isset( $frame['args'] ) ) {
+ $frame['args'] = array_map( function ( $arg ) {
+ return is_object( $arg ) ? get_class( $arg ) : gettype( $arg );
+ }, $frame['args'] );
+ }
+ return $frame;
+ }, $trace );
+ }
+
+ /**
+ * Get the ID for this exception.
+ *
+ * The ID is saved so that one can match the one output to the user (when
+ * $wgShowExceptionDetails is set to false), to the entry in the debug log.
+ *
+ * @since 1.22
+ * @deprecated since 1.27: Exception IDs are synonymous with request IDs.
+ * @param Exception|Throwable $e
+ * @return string
+ */
+ public static function getLogId( $e ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ return WebRequest::getRequestId();
+ }
+
+ /**
+ * If the exception occurred in the course of responding to a request,
+ * returns the requested URL. Otherwise, returns false.
+ *
+ * @since 1.23
+ * @return string|false
+ */
+ public static function getURL() {
+ global $wgRequest;
+ if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) {
+ return false;
+ }
+ return $wgRequest->getRequestURL();
+ }
+
+ /**
+ * Get a message formatting the exception message and its origin.
+ *
+ * @since 1.22
+ * @param Exception|Throwable $e
+ * @return string
+ */
+ public static function getLogMessage( $e ) {
+ $id = WebRequest::getRequestId();
+ $type = get_class( $e );
+ $file = $e->getFile();
+ $line = $e->getLine();
+ $message = $e->getMessage();
+ $url = self::getURL() ?: '[no req]';
+
+ return "[$id] $url $type from line $line of $file: $message";
+ }
+
+ /**
+ * Get a normalised message for formatting with PSR-3 log event context.
+ *
+ * Must be used together with `getLogContext()` to be useful.
+ *
+ * @since 1.30
+ * @param Exception|Throwable $e
+ * @return string
+ */
+ public static function getLogNormalMessage( $e ) {
+ $type = get_class( $e );
+ $file = $e->getFile();
+ $line = $e->getLine();
+ $message = $e->getMessage();
+
+ return "[{exception_id}] {exception_url} $type from line $line of $file: $message";
+ }
+
+ /**
+ * @param Exception|Throwable $e
+ * @return string
+ */
+ public static function getPublicLogMessage( $e ) {
+ $reqId = WebRequest::getRequestId();
+ $type = get_class( $e );
+ return '[' . $reqId . '] '
+ . gmdate( 'Y-m-d H:i:s' ) . ': '
+ . 'Fatal exception of type "' . $type . '"';
+ }
+
+ /**
+ * Get a PSR-3 log event context from an Exception.
+ *
+ * Creates a structured array containing information about the provided
+ * exception that can be used to augment a log message sent to a PSR-3
+ * logger.
+ *
+ * @param Exception|Throwable $e
+ * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error
+ * @return array
+ */
+ public static function getLogContext( $e, $catcher = self::CAUGHT_BY_OTHER ) {
+ return [
+ 'exception' => $e,
+ 'exception_id' => WebRequest::getRequestId(),
+ 'exception_url' => self::getURL() ?: '[no req]',
+ 'caught_by' => $catcher
+ ];
+ }
+
+ /**
+ * Get a structured representation of an Exception.
+ *
+ * Returns an array of structured data (class, message, code, file,
+ * backtrace) derived from the given exception. The backtrace information
+ * will be redacted as per getRedactedTraceAsArray().
+ *
+ * @param Exception|Throwable $e
+ * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error
+ * @return array
+ * @since 1.26
+ */
+ public static function getStructuredExceptionData( $e, $catcher = self::CAUGHT_BY_OTHER ) {
+ global $wgLogExceptionBacktrace;
+
+ $data = [
+ 'id' => WebRequest::getRequestId(),
+ 'type' => get_class( $e ),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'message' => $e->getMessage(),
+ 'code' => $e->getCode(),
+ 'url' => self::getURL() ?: null,
+ 'caught_by' => $catcher
+ ];
+
+ if ( $e instanceof ErrorException &&
+ ( error_reporting() & $e->getSeverity() ) === 0
+ ) {
+ // Flag surpressed errors
+ $data['suppressed'] = true;
+ }
+
+ if ( $wgLogExceptionBacktrace ) {
+ $data['backtrace'] = self::getRedactedTrace( $e );
+ }
+
+ $previous = $e->getPrevious();
+ if ( $previous !== null ) {
+ $data['previous'] = self::getStructuredExceptionData( $previous, $catcher );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Serialize an Exception object to JSON.
+ *
+ * The JSON object will have keys 'id', 'file', 'line', 'message', and
+ * 'url'. These keys map to string values, with the exception of 'line',
+ * which is a number, and 'url', which may be either a string URL or or
+ * null if the exception did not occur in the context of serving a web
+ * request.
+ *
+ * If $wgLogExceptionBacktrace is true, it will also have a 'backtrace'
+ * key, mapped to the array return value of Exception::getTrace, but with
+ * each element in each frame's "args" array (if set) replaced with the
+ * argument's class name (if the argument is an object) or type name (if
+ * the argument is a PHP primitive).
+ *
+ * @par Sample JSON record ($wgLogExceptionBacktrace = false):
+ * @code
+ * {
+ * "id": "c41fb419",
+ * "type": "MWException",
+ * "file": "/var/www/mediawiki/includes/cache/MessageCache.php",
+ * "line": 704,
+ * "message": "Non-string key given",
+ * "url": "/wiki/Main_Page"
+ * }
+ * @endcode
+ *
+ * @par Sample JSON record ($wgLogExceptionBacktrace = true):
+ * @code
+ * {
+ * "id": "dc457938",
+ * "type": "MWException",
+ * "file": "/vagrant/mediawiki/includes/cache/MessageCache.php",
+ * "line": 704,
+ * "message": "Non-string key given",
+ * "url": "/wiki/Main_Page",
+ * "backtrace": [{
+ * "file": "/vagrant/mediawiki/extensions/VisualEditor/VisualEditor.hooks.php",
+ * "line": 80,
+ * "function": "get",
+ * "class": "MessageCache",
+ * "type": "->",
+ * "args": ["array"]
+ * }]
+ * }
+ * @endcode
+ *
+ * @since 1.23
+ * @param Exception|Throwable $e
+ * @param bool $pretty Add non-significant whitespace to improve readability (default: false).
+ * @param int $escaping Bitfield consisting of FormatJson::.*_OK class constants.
+ * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error
+ * @return string|false JSON string if successful; false upon failure
+ */
+ public static function jsonSerializeException(
+ $e, $pretty = false, $escaping = 0, $catcher = self::CAUGHT_BY_OTHER
+ ) {
+ return FormatJson::encode(
+ self::getStructuredExceptionData( $e, $catcher ),
+ $pretty,
+ $escaping
+ );
+ }
+
+ /**
+ * Log an exception to the exception log (if enabled).
+ *
+ * This method must not assume the exception is an MWException,
+ * it is also used to handle PHP exceptions or exceptions from other libraries.
+ *
+ * @since 1.22
+ * @param Exception|Throwable $e
+ * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error
+ */
+ public static function logException( $e, $catcher = self::CAUGHT_BY_OTHER ) {
+ if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
+ $logger = LoggerFactory::getInstance( 'exception' );
+ $logger->error(
+ self::getLogNormalMessage( $e ),
+ self::getLogContext( $e, $catcher )
+ );
+
+ $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
+ if ( $json !== false ) {
+ $logger = LoggerFactory::getInstance( 'exception-json' );
+ $logger->error( $json, [ 'private' => true ] );
+ }
+
+ Hooks::run( 'LogException', [ $e, false ] );
+ }
+ }
+
+ /**
+ * Log an exception that wasn't thrown but made to wrap an error.
+ *
+ * @since 1.25
+ * @param ErrorException $e
+ * @param string $channel
+ * @param string $level
+ */
+ protected static function logError(
+ ErrorException $e, $channel, $level = LogLevel::ERROR
+ ) {
+ $catcher = self::CAUGHT_BY_HANDLER;
+ // The set_error_handler callback is independent from error_reporting.
+ // Filter out unwanted errors manually (e.g. when
+ // MediaWiki\suppressWarnings is active).
+ $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
+ if ( !$suppressed ) {
+ $logger = LoggerFactory::getInstance( $channel );
+ $logger->log(
+ $level,
+ self::getLogNormalMessage( $e ),
+ self::getLogContext( $e, $catcher )
+ );
+ }
+
+ // Include all errors in the json log (surpressed errors will be flagged)
+ $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
+ if ( $json !== false ) {
+ $logger = LoggerFactory::getInstance( "{$channel}-json" );
+ $logger->log( $level, $json, [ 'private' => true ] );
+ }
+
+ Hooks::run( 'LogException', [ $e, $suppressed ] );
+ }
+}
diff --git a/www/wiki/includes/exception/MWExceptionRenderer.php b/www/wiki/includes/exception/MWExceptionRenderer.php
new file mode 100644
index 00000000..1ba65aa4
--- /dev/null
+++ b/www/wiki/includes/exception/MWExceptionRenderer.php
@@ -0,0 +1,372 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\DBConnectionError;
+use Wikimedia\Rdbms\DBError;
+use Wikimedia\Rdbms\DBReadOnlyError;
+use Wikimedia\Rdbms\DBExpectedError;
+
+/**
+ * Class to expose exceptions to the client (API bots, users, admins using CLI scripts)
+ * @since 1.28
+ */
+class MWExceptionRenderer {
+ const AS_RAW = 1; // show as text
+ const AS_PRETTY = 2; // show as HTML
+
+ /**
+ * @param Exception|Throwable $e Original exception
+ * @param int $mode MWExceptionExposer::AS_* constant
+ * @param Exception|Throwable|null $eNew New exception from attempting to show the first
+ */
+ public static function output( $e, $mode, $eNew = null ) {
+ global $wgMimeType;
+
+ if ( defined( 'MW_API' ) ) {
+ // Unhandled API exception, we can't be sure that format printer is alive
+ self::header( 'MediaWiki-API-Error: internal_api_error_' . get_class( $e ) );
+ wfHttpError( 500, 'Internal Server Error', self::getText( $e ) );
+ } elseif ( self::isCommandLine() ) {
+ self::printError( self::getText( $e ) );
+ } elseif ( $mode === self::AS_PRETTY ) {
+ self::statusHeader( 500 );
+ if ( $e instanceof DBConnectionError ) {
+ self::reportOutageHTML( $e );
+ } else {
+ self::header( "Content-Type: $wgMimeType; charset=utf-8" );
+ self::reportHTML( $e );
+ }
+ } else {
+ if ( $eNew ) {
+ $message = "MediaWiki internal error.\n\n";
+ if ( self::showBackTrace( $e ) ) {
+ $message .= 'Original exception: ' .
+ MWExceptionHandler::getLogMessage( $e ) .
+ "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $e ) .
+ "\n\nException caught inside exception handler: " .
+ MWExceptionHandler::getLogMessage( $eNew ) .
+ "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $eNew );
+ } else {
+ $message .= 'Original exception: ' .
+ MWExceptionHandler::getPublicLogMessage( $e );
+ $message .= "\n\nException caught inside exception handler.\n\n" .
+ self::getShowBacktraceError( $e );
+ }
+ $message .= "\n";
+ } else {
+ if ( self::showBackTrace( $e ) ) {
+ $message = MWExceptionHandler::getLogMessage( $e ) .
+ "\nBacktrace:\n" .
+ MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n";
+ } else {
+ $message = MWExceptionHandler::getPublicLogMessage( $e );
+ }
+ }
+ echo nl2br( htmlspecialchars( $message ) ) . "\n";
+ }
+ }
+
+ /**
+ * @param Exception|Throwable $e
+ * @return bool Should the exception use $wgOut to output the error?
+ */
+ private static function useOutputPage( $e ) {
+ // Can the extension use the Message class/wfMessage to get i18n-ed messages?
+ foreach ( $e->getTrace() as $frame ) {
+ if ( isset( $frame['class'] ) && $frame['class'] === 'LocalisationCache' ) {
+ return false;
+ }
+ }
+
+ // Don't even bother with OutputPage if there's no Title context set,
+ // (e.g. we're in RL code on load.php) - the Skin system (and probably
+ // most of MediaWiki) won't work.
+
+ return (
+ !empty( $GLOBALS['wgFullyInitialised'] ) &&
+ !empty( $GLOBALS['wgOut'] ) &&
+ RequestContext::getMain()->getTitle() &&
+ !defined( 'MEDIAWIKI_INSTALL' )
+ );
+ }
+
+ /**
+ * Output the exception report using HTML
+ *
+ * @param Exception|Throwable $e
+ */
+ private static function reportHTML( $e ) {
+ global $wgOut, $wgSitename;
+
+ if ( self::useOutputPage( $e ) ) {
+ if ( $e instanceof MWException ) {
+ $wgOut->prepareErrorPage( $e->getPageTitle() );
+ } elseif ( $e instanceof DBReadOnlyError ) {
+ $wgOut->prepareErrorPage( self::msg( 'readonly', 'Database is locked' ) );
+ } elseif ( $e instanceof DBExpectedError ) {
+ $wgOut->prepareErrorPage( self::msg( 'databaseerror', 'Database error' ) );
+ } else {
+ $wgOut->prepareErrorPage( self::msg( 'internalerror', 'Internal error' ) );
+ }
+
+ // Show any custom GUI message before the details
+ if ( $e instanceof MessageSpecifier ) {
+ $wgOut->addHTML( Message::newFromSpecifier( $e )->escaped() );
+ }
+ $wgOut->addHTML( self::getHTML( $e ) );
+
+ $wgOut->output();
+ } else {
+ self::header( 'Content-Type: text/html; charset=utf-8' );
+ $pageTitle = self::msg( 'internalerror', 'Internal error' );
+ echo "<!DOCTYPE html>\n" .
+ '<html><head>' .
+ // Mimick OutputPage::setPageTitle behaviour
+ '<title>' .
+ htmlspecialchars( self::msg( 'pagetitle', "$1 - $wgSitename", $pageTitle ) ) .
+ '</title>' .
+ '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' .
+ "</head><body>\n";
+
+ echo self::getHTML( $e );
+
+ echo "</body></html>\n";
+ }
+ }
+
+ /**
+ * If $wgShowExceptionDetails is true, return a HTML message with a
+ * backtrace to the error, otherwise show a message to ask to set it to true
+ * to show that information.
+ *
+ * @param Exception|Throwable $e
+ * @return string Html to output
+ */
+ public static function getHTML( $e ) {
+ if ( self::showBackTrace( $e ) ) {
+ $html = "<div class=\"errorbox mw-content-ltr\"><p>" .
+ nl2br( htmlspecialchars( MWExceptionHandler::getLogMessage( $e ) ) ) .
+ '</p><p>Backtrace:</p><p>' .
+ nl2br( htmlspecialchars( MWExceptionHandler::getRedactedTraceAsString( $e ) ) ) .
+ "</p></div>\n";
+ } else {
+ $logId = WebRequest::getRequestId();
+ $html = "<div class=\"errorbox mw-content-ltr\">" .
+ htmlspecialchars(
+ '[' . $logId . '] ' .
+ gmdate( 'Y-m-d H:i:s' ) . ": " .
+ self::msg( "internalerror-fatal-exception",
+ "Fatal exception of type $1",
+ get_class( $e ),
+ $logId,
+ MWExceptionHandler::getURL()
+ )
+ ) . "</div>\n" .
+ "<!-- " . wordwrap( self::getShowBacktraceError( $e ), 50 ) . " -->";
+ }
+
+ return $html;
+ }
+
+ /**
+ * Get a message from i18n
+ *
+ * @param string $key Message name
+ * @param string $fallback Default message if the message cache can't be
+ * called by the exception
+ * The function also has other parameters that are arguments for the message
+ * @return string Message with arguments replaced
+ */
+ private static function msg( $key, $fallback /*[, params...] */ ) {
+ $args = array_slice( func_get_args(), 2 );
+ try {
+ return wfMessage( $key, $args )->text();
+ } catch ( Exception $e ) {
+ return wfMsgReplaceArgs( $fallback, $args );
+ }
+ }
+
+ /**
+ * @param Exception|Throwable $e
+ * @return string
+ */
+ private static function getText( $e ) {
+ if ( self::showBackTrace( $e ) ) {
+ return MWExceptionHandler::getLogMessage( $e ) .
+ "\nBacktrace:\n" .
+ MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n";
+ } else {
+ return self::getShowBacktraceError( $e ) . "\n";
+ }
+ }
+
+ /**
+ * @param Exception|Throwable $e
+ * @return bool
+ */
+ private static function showBackTrace( $e ) {
+ global $wgShowExceptionDetails, $wgShowDBErrorBacktrace;
+
+ return (
+ $wgShowExceptionDetails &&
+ ( !( $e instanceof DBError ) || $wgShowDBErrorBacktrace )
+ );
+ }
+
+ /**
+ * @param Exception|Throwable $e
+ * @return string
+ */
+ private static function getShowBacktraceError( $e ) {
+ global $wgShowExceptionDetails, $wgShowDBErrorBacktrace;
+ $vars = [];
+ if ( !$wgShowExceptionDetails ) {
+ $vars[] = '$wgShowExceptionDetails = true;';
+ }
+ if ( $e instanceof DBError && !$wgShowDBErrorBacktrace ) {
+ $vars[] = '$wgShowDBErrorBacktrace = true;';
+ }
+ $vars = implode( ' and ', $vars );
+ return "Set $vars at the bottom of LocalSettings.php to show detailed debugging information.";
+ }
+
+ /**
+ * @return bool
+ */
+ private static function isCommandLine() {
+ return !empty( $GLOBALS['wgCommandLineMode'] );
+ }
+
+ /**
+ * @param string $header
+ */
+ private static function header( $header ) {
+ if ( !headers_sent() ) {
+ header( $header );
+ }
+ }
+
+ /**
+ * @param int $code
+ */
+ private static function statusHeader( $code ) {
+ if ( !headers_sent() ) {
+ HttpStatus::header( $code );
+ }
+ }
+
+ /**
+ * Print a message, if possible to STDERR.
+ * Use this in command line mode only (see isCommandLine)
+ *
+ * @param string $message Failure text
+ */
+ private static function printError( $message ) {
+ // NOTE: STDERR may not be available, especially if php-cgi is used from the
+ // command line (bug #15602). Try to produce meaningful output anyway. Using
+ // echo may corrupt output to STDOUT though.
+ if ( defined( 'STDERR' ) ) {
+ fwrite( STDERR, $message );
+ } else {
+ echo $message;
+ }
+ }
+
+ /**
+ * @param Exception|Throwable $e
+ */
+ private static function reportOutageHTML( $e ) {
+ global $wgShowDBErrorBacktrace, $wgShowHostnames, $wgShowSQLErrors;
+
+ $sorry = htmlspecialchars( self::msg(
+ 'dberr-problems',
+ 'Sorry! This site is experiencing technical difficulties.'
+ ) );
+ $again = htmlspecialchars( self::msg(
+ 'dberr-again',
+ 'Try waiting a few minutes and reloading.'
+ ) );
+
+ if ( $wgShowHostnames || $wgShowSQLErrors ) {
+ $info = str_replace(
+ '$1',
+ Html::element( 'span', [ 'dir' => 'ltr' ], $e->getMessage() ),
+ htmlspecialchars( self::msg( 'dberr-info', '($1)' ) )
+ );
+ } else {
+ $info = htmlspecialchars( self::msg(
+ 'dberr-info-hidden',
+ '(Cannot access the database)'
+ ) );
+ }
+
+ MessageCache::singleton()->disable(); // no DB access
+
+ $html = "<h1>$sorry</h1><p>$again</p><p><small>$info</small></p>";
+
+ if ( $wgShowDBErrorBacktrace ) {
+ $html .= '<p>Backtrace:</p><pre>' .
+ htmlspecialchars( $e->getTraceAsString() ) . '</pre>';
+ }
+
+ $html .= '<hr />';
+ $html .= self::googleSearchForm();
+
+ echo $html;
+ }
+
+ /**
+ * @return string
+ */
+ private static function googleSearchForm() {
+ global $wgSitename, $wgCanonicalServer, $wgRequest;
+
+ $usegoogle = htmlspecialchars( self::msg(
+ 'dberr-usegoogle',
+ 'You can try searching via Google in the meantime.'
+ ) );
+ $outofdate = htmlspecialchars( self::msg(
+ 'dberr-outofdate',
+ 'Note that their indexes of our content may be out of date.'
+ ) );
+ $googlesearch = htmlspecialchars( self::msg( 'searchbutton', 'Search' ) );
+ $search = htmlspecialchars( $wgRequest->getVal( 'search' ) );
+ $server = htmlspecialchars( $wgCanonicalServer );
+ $sitename = htmlspecialchars( $wgSitename );
+ $trygoogle = <<<EOT
+<div style="margin: 1.5em">$usegoogle<br />
+<small>$outofdate</small>
+</div>
+<form method="get" action="//www.google.com/search" id="googlesearch">
+ <input type="hidden" name="domains" value="$server" />
+ <input type="hidden" name="num" value="50" />
+ <input type="hidden" name="ie" value="UTF-8" />
+ <input type="hidden" name="oe" value="UTF-8" />
+ <input type="text" name="q" size="31" maxlength="255" value="$search" />
+ <input type="submit" name="btnG" value="$googlesearch" />
+ <p>
+ <label><input type="radio" name="sitesearch" value="$server" checked="checked" />$sitename</label>
+ <label><input type="radio" name="sitesearch" value="" />WWW</label>
+ </p>
+</form>
+EOT;
+ return $trygoogle;
+ }
+}
diff --git a/www/wiki/includes/exception/MWUnknownContentModelException.php b/www/wiki/includes/exception/MWUnknownContentModelException.php
new file mode 100644
index 00000000..df7111ac
--- /dev/null
+++ b/www/wiki/includes/exception/MWUnknownContentModelException.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * Exception thrown when an unregistered content model is requested. This error
+ * can be triggered by user input, so a separate exception class is provided so
+ * callers can substitute a context-specific, internationalised error message.
+ *
+ * @ingroup Content
+ * @since 1.27
+ */
+class MWUnknownContentModelException extends MWException {
+ /** @var string The name of the unknown content model */
+ private $modelId;
+
+ /** @param string $modelId */
+ function __construct( $modelId ) {
+ parent::__construct( "The content model '$modelId' is not registered on this wiki.\n" .
+ 'See https://www.mediawiki.org/wiki/Content_handlers to find out which extensions ' .
+ 'handle this content model.' );
+ $this->modelId = $modelId;
+ }
+ /** @return string */
+ public function getModelId() {
+ return $this->modelId;
+ }
+}
diff --git a/www/wiki/includes/exception/PermissionsError.php b/www/wiki/includes/exception/PermissionsError.php
new file mode 100644
index 00000000..cc69a762
--- /dev/null
+++ b/www/wiki/includes/exception/PermissionsError.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Show an error when a user tries to do something they do not have the necessary
+ * permissions for.
+ *
+ * @since 1.18
+ * @ingroup Exception
+ */
+class PermissionsError extends ErrorPageError {
+ public $permission, $errors;
+
+ /**
+ * @param string|null $permission A permission name or null if unknown
+ * @param array $errors Error message keys or [key, param...] arrays; must not be empty if
+ * $permission is null
+ * @throws \InvalidArgumentException
+ */
+ public function __construct( $permission, $errors = [] ) {
+ global $wgLang;
+
+ if ( $permission === null && !$errors ) {
+ throw new \InvalidArgumentException( __METHOD__ .
+ ': $permission and $errors cannot both be empty' );
+ }
+
+ $this->permission = $permission;
+
+ if ( !count( $errors ) ) {
+ $groups = [];
+ foreach ( User::getGroupsWithPermission( $this->permission ) as $group ) {
+ $groups[] = UserGroupMembership::getLink( $group, RequestContext::getMain(), 'wiki' );
+ }
+
+ if ( $groups ) {
+ $errors[] = [ 'badaccess-groups', $wgLang->commaList( $groups ), count( $groups ) ];
+ } else {
+ $errors[] = [ 'badaccess-group0' ];
+ }
+ }
+
+ $this->errors = $errors;
+
+ // Give the parent class something to work with
+ parent::__construct( 'permissionserrors', Message::newFromSpecifier( $errors[0] ) );
+ }
+
+ public function report() {
+ global $wgOut;
+
+ $wgOut->showPermissionsErrorPage( $this->errors, $this->permission );
+ $wgOut->output();
+ }
+}
diff --git a/www/wiki/includes/exception/ProcOpenError.php b/www/wiki/includes/exception/ProcOpenError.php
new file mode 100644
index 00000000..f00bcd4b
--- /dev/null
+++ b/www/wiki/includes/exception/ProcOpenError.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki;
+
+use Exception;
+
+class ProcOpenError extends Exception {
+ public function __construct() {
+ parent::__construct( 'proc_open() returned error!' );
+ }
+}
diff --git a/www/wiki/includes/exception/ReadOnlyError.php b/www/wiki/includes/exception/ReadOnlyError.php
new file mode 100644
index 00000000..de42f056
--- /dev/null
+++ b/www/wiki/includes/exception/ReadOnlyError.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Show an error when the wiki is locked/read-only and the user tries to do
+ * something that requires write access.
+ *
+ * @since 1.18
+ * @ingroup Exception
+ */
+class ReadOnlyError extends ErrorPageError {
+ public function __construct() {
+ parent::__construct(
+ 'readonly',
+ 'readonlytext',
+ wfReadOnlyReason() ?: []
+ );
+ }
+}
diff --git a/www/wiki/includes/exception/ShellDisabledError.php b/www/wiki/includes/exception/ShellDisabledError.php
new file mode 100644
index 00000000..203b58dc
--- /dev/null
+++ b/www/wiki/includes/exception/ShellDisabledError.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki;
+
+use Exception;
+
+/**
+ * @since 1.30
+ */
+class ShellDisabledError extends Exception {
+ public function __construct() {
+ parent::__construct( 'Unable to run external programs, proc_open() is disabled' );
+ }
+}
diff --git a/www/wiki/includes/exception/ThrottledError.php b/www/wiki/includes/exception/ThrottledError.php
new file mode 100644
index 00000000..bec0d904
--- /dev/null
+++ b/www/wiki/includes/exception/ThrottledError.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Show an error when the user hits a rate limit.
+ *
+ * @since 1.18
+ * @ingroup Exception
+ */
+class ThrottledError extends ErrorPageError {
+ public function __construct() {
+ parent::__construct(
+ 'actionthrottled',
+ 'actionthrottledtext'
+ );
+ }
+
+ public function report() {
+ global $wgOut;
+ $wgOut->setStatusCode( 429 );
+ parent::report();
+ }
+}
diff --git a/www/wiki/includes/exception/TimestampException.php b/www/wiki/includes/exception/TimestampException.php
new file mode 100644
index 00000000..b9c0c35c
--- /dev/null
+++ b/www/wiki/includes/exception/TimestampException.php
@@ -0,0 +1,7 @@
+<?php
+
+/**
+ * @since 1.20
+ */
+class TimestampException extends MWException {
+}
diff --git a/www/wiki/includes/exception/UserBlockedError.php b/www/wiki/includes/exception/UserBlockedError.php
new file mode 100644
index 00000000..9d19f8b6
--- /dev/null
+++ b/www/wiki/includes/exception/UserBlockedError.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Show an error when the user tries to do something whilst blocked.
+ *
+ * @since 1.18
+ * @ingroup Exception
+ */
+class UserBlockedError extends ErrorPageError {
+ public function __construct( Block $block ) {
+ // @todo FIXME: Implement a more proper way to get context here.
+ $params = $block->getPermissionsError( RequestContext::getMain() );
+ parent::__construct( 'blockedtitle', array_shift( $params ), $params );
+ }
+}
diff --git a/www/wiki/includes/exception/UserNotLoggedIn.php b/www/wiki/includes/exception/UserNotLoggedIn.php
new file mode 100644
index 00000000..6086d559
--- /dev/null
+++ b/www/wiki/includes/exception/UserNotLoggedIn.php
@@ -0,0 +1,104 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Redirect a user to the login page
+ *
+ * This is essentially an ErrorPageError exception which by default uses the
+ * 'exception-nologin' as a title and 'exception-nologin-text' for the message.
+ *
+ * @note In order for this exception to redirect, the error message passed to the
+ * constructor has to be explicitly added to LoginHelper::validErrorMessages or with
+ * the LoginFormValidErrorMessages hook. Otherwise, the user will just be shown the message
+ * rather than redirected.
+ *
+ * @par Example:
+ * @code
+ * if( $user->isAnon() ) {
+ * throw new UserNotLoggedIn();
+ * }
+ * @endcode
+ *
+ * Note the parameter order differs from ErrorPageError, this allows you to
+ * simply specify a reason without overriding the default title.
+ *
+ * @par Example:
+ * @code
+ * if( $user->isAnon() ) {
+ * throw new UserNotLoggedIn( 'action-require-loggedin' );
+ * }
+ * @endcode
+ *
+ * @see T39627
+ * @since 1.20
+ * @ingroup Exception
+ */
+class UserNotLoggedIn extends ErrorPageError {
+
+ /**
+ * @note The value of the $reasonMsg parameter must be put into LoginForm::validErrorMessages or
+ * set with the LoginFormValidErrorMessages Hook.
+ * if you want the user to be automatically redirected to the login form.
+ *
+ * @param string $reasonMsg A message key containing the reason for the error.
+ * Optional, default: 'exception-nologin-text'
+ * @param string $titleMsg A message key to set the page title.
+ * Optional, default: 'exception-nologin'
+ * @param array $params Parameters to wfMessage().
+ * Optional, default: []
+ */
+ public function __construct(
+ $reasonMsg = 'exception-nologin-text',
+ $titleMsg = 'exception-nologin',
+ $params = []
+ ) {
+ parent::__construct( $titleMsg, $reasonMsg, $params );
+ }
+
+ /**
+ * Redirect to Special:Userlogin if the specified message is compatible. Otherwise,
+ * show an error page as usual.
+ */
+ public function report() {
+ // If an unsupported message is used, don't try redirecting to Special:Userlogin,
+ // since the message may not be compatible.
+ if ( !in_array( $this->msg, LoginHelper::getValidErrorMessages() ) ) {
+ parent::report();
+ }
+
+ // Message is valid. Redirec to Special:Userlogin
+
+ $context = RequestContext::getMain();
+
+ $output = $context->getOutput();
+ $query = $context->getRequest()->getValues();
+ // Title will be overridden by returnto
+ unset( $query['title'] );
+ // Redirect to Special:Userlogin
+ $output->redirect( SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
+ // Return to this page when the user logs in
+ 'returnto' => $context->getTitle()->getFullText(),
+ 'returntoquery' => wfArrayToCgi( $query ),
+ 'warning' => $this->msg,
+ ] ) );
+
+ $output->output();
+ }
+}
diff --git a/www/wiki/includes/export/Dump7ZipOutput.php b/www/wiki/includes/export/Dump7ZipOutput.php
new file mode 100644
index 00000000..31c945c0
--- /dev/null
+++ b/www/wiki/includes/export/Dump7ZipOutput.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Sends dump output via the p7zip compressor.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class Dump7ZipOutput extends DumpPipeOutput {
+ /**
+ * @var int
+ */
+ protected $compressionLevel;
+
+ /**
+ * @param string $file
+ * @param int $cmpLevel Compression level passed to 7za command's -mx
+ */
+ function __construct( $file, $cmpLevel = 4 ) {
+ $this->compressionLevel = $cmpLevel;
+ $command = $this->setup7zCommand( $file );
+ parent::__construct( $command );
+ $this->filename = $file;
+ }
+
+ /**
+ * @param string $file
+ * @return string
+ */
+ function setup7zCommand( $file ) {
+ $command = "7za a -bd -si -mx=";
+ $command .= wfEscapeShellArg( $this->compressionLevel ) . ' ';
+ $command .= wfEscapeShellArg( $file );
+ // Suppress annoying useless crap from p7zip
+ // Unfortunately this could suppress real error messages too
+ $command .= ' >' . wfGetNull() . ' 2>&1';
+ return $command;
+ }
+
+ /**
+ * @param string $newname
+ * @param bool $open
+ */
+ function closeAndRename( $newname, $open = false ) {
+ $newname = $this->checkRenameArgCount( $newname );
+ if ( $newname ) {
+ fclose( $this->handle );
+ proc_close( $this->procOpenResource );
+ $this->renameOrException( $newname );
+ if ( $open ) {
+ $command = $this->setup7zCommand( $this->filename );
+ $this->startCommand( $command );
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/export/DumpBZip2Output.php b/www/wiki/includes/export/DumpBZip2Output.php
new file mode 100644
index 00000000..bbc1c11f
--- /dev/null
+++ b/www/wiki/includes/export/DumpBZip2Output.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Sends dump output via the bgzip2 compressor.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpBZip2Output extends DumpPipeOutput {
+ /**
+ * @param string $file
+ */
+ function __construct( $file ) {
+ parent::__construct( "bzip2", $file );
+ }
+}
diff --git a/www/wiki/includes/export/DumpDBZip2Output.php b/www/wiki/includes/export/DumpDBZip2Output.php
new file mode 100644
index 00000000..5edde8f7
--- /dev/null
+++ b/www/wiki/includes/export/DumpDBZip2Output.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Sends dump output via the bgzip2 compressor.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpDBZip2Output extends DumpPipeOutput {
+ /**
+ * @param string $file
+ */
+ function __construct( $file ) {
+ parent::__construct( "dbzip2", $file );
+ }
+}
diff --git a/www/wiki/includes/export/DumpFileOutput.php b/www/wiki/includes/export/DumpFileOutput.php
new file mode 100644
index 00000000..4bec7d45
--- /dev/null
+++ b/www/wiki/includes/export/DumpFileOutput.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Stream outputter to send data to a file.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpFileOutput extends DumpOutput {
+ protected $handle = false, $filename;
+
+ /**
+ * @param string $file
+ */
+ function __construct( $file ) {
+ $this->handle = fopen( $file, "wt" );
+ $this->filename = $file;
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeCloseStream( $string ) {
+ parent::writeCloseStream( $string );
+ if ( $this->handle ) {
+ fclose( $this->handle );
+ $this->handle = false;
+ }
+ }
+
+ /**
+ * @param string $string
+ */
+ function write( $string ) {
+ fputs( $this->handle, $string );
+ }
+
+ /**
+ * @param string $newname
+ */
+ function closeRenameAndReopen( $newname ) {
+ $this->closeAndRename( $newname, true );
+ }
+
+ /**
+ * @param string $newname
+ * @throws MWException
+ */
+ function renameOrException( $newname ) {
+ if ( !rename( $this->filename, $newname ) ) {
+ throw new MWException( __METHOD__ . ": rename of file {$this->filename} to $newname failed\n" );
+ }
+ }
+
+ /**
+ * @param array $newname
+ * @return string
+ * @throws MWException
+ */
+ function checkRenameArgCount( $newname ) {
+ if ( is_array( $newname ) ) {
+ if ( count( $newname ) > 1 ) {
+ throw new MWException( __METHOD__ . ": passed multiple arguments for rename of single file\n" );
+ } else {
+ $newname = $newname[0];
+ }
+ }
+ return $newname;
+ }
+
+ /**
+ * @param string $newname
+ * @param bool $open
+ */
+ function closeAndRename( $newname, $open = false ) {
+ $newname = $this->checkRenameArgCount( $newname );
+ if ( $newname ) {
+ if ( $this->handle ) {
+ fclose( $this->handle );
+ $this->handle = false;
+ }
+ $this->renameOrException( $newname );
+ if ( $open ) {
+ $this->handle = fopen( $this->filename, "wt" );
+ }
+ }
+ }
+
+ /**
+ * @return string|null
+ */
+ function getFilenames() {
+ return $this->filename;
+ }
+}
diff --git a/www/wiki/includes/export/DumpFilter.php b/www/wiki/includes/export/DumpFilter.php
new file mode 100644
index 00000000..1349c54b
--- /dev/null
+++ b/www/wiki/includes/export/DumpFilter.php
@@ -0,0 +1,134 @@
+<?php
+/**
+ * Dump output filter class.
+ * This just does output filtering and streaming; XML formatting is done
+ * higher up, so be careful in what you do.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpFilter {
+ /**
+ * @var DumpOutput
+ * FIXME will need to be made protected whenever legacy code
+ * is updated.
+ */
+ public $sink;
+
+ /**
+ * @var bool
+ */
+ protected $sendingThisPage;
+
+ /**
+ * @param DumpOutput &$sink
+ */
+ function __construct( &$sink ) {
+ $this->sink =& $sink;
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeOpenStream( $string ) {
+ $this->sink->writeOpenStream( $string );
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeCloseStream( $string ) {
+ $this->sink->writeCloseStream( $string );
+ }
+
+ /**
+ * @param object $page
+ * @param string $string
+ */
+ function writeOpenPage( $page, $string ) {
+ $this->sendingThisPage = $this->pass( $page, $string );
+ if ( $this->sendingThisPage ) {
+ $this->sink->writeOpenPage( $page, $string );
+ }
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeClosePage( $string ) {
+ if ( $this->sendingThisPage ) {
+ $this->sink->writeClosePage( $string );
+ $this->sendingThisPage = false;
+ }
+ }
+
+ /**
+ * @param object $rev
+ * @param string $string
+ */
+ function writeRevision( $rev, $string ) {
+ if ( $this->sendingThisPage ) {
+ $this->sink->writeRevision( $rev, $string );
+ }
+ }
+
+ /**
+ * @param object $rev
+ * @param string $string
+ */
+ function writeLogItem( $rev, $string ) {
+ $this->sink->writeRevision( $rev, $string );
+ }
+
+ /**
+ * @param string $newname
+ */
+ function closeRenameAndReopen( $newname ) {
+ $this->sink->closeRenameAndReopen( $newname );
+ }
+
+ /**
+ * @param string $newname
+ * @param bool $open
+ */
+ function closeAndRename( $newname, $open = false ) {
+ $this->sink->closeAndRename( $newname, $open );
+ }
+
+ /**
+ * @return array
+ */
+ function getFilenames() {
+ return $this->sink->getFilenames();
+ }
+
+ /**
+ * Override for page-based filter types.
+ * @param object $page
+ * @return bool
+ */
+ function pass( $page ) {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/export/DumpGZipOutput.php b/www/wiki/includes/export/DumpGZipOutput.php
new file mode 100644
index 00000000..d9e74a79
--- /dev/null
+++ b/www/wiki/includes/export/DumpGZipOutput.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Sends dump output via the gzip compressor.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpGZipOutput extends DumpPipeOutput {
+ /**
+ * @param string $file
+ */
+ function __construct( $file ) {
+ parent::__construct( "gzip", $file );
+ }
+}
diff --git a/www/wiki/includes/export/DumpLatestFilter.php b/www/wiki/includes/export/DumpLatestFilter.php
new file mode 100644
index 00000000..d3742b73
--- /dev/null
+++ b/www/wiki/includes/export/DumpLatestFilter.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Dump output filter to include only the last revision in each page sequence.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpLatestFilter extends DumpFilter {
+ public $page;
+
+ public $pageString;
+
+ public $rev;
+
+ public $revString;
+
+ /**
+ * @param object $page
+ * @param string $string
+ */
+ function writeOpenPage( $page, $string ) {
+ $this->page = $page;
+ $this->pageString = $string;
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeClosePage( $string ) {
+ if ( $this->rev ) {
+ $this->sink->writeOpenPage( $this->page, $this->pageString );
+ $this->sink->writeRevision( $this->rev, $this->revString );
+ $this->sink->writeClosePage( $string );
+ }
+ $this->rev = null;
+ $this->revString = null;
+ $this->page = null;
+ $this->pageString = null;
+ }
+
+ /**
+ * @param object $rev
+ * @param string $string
+ */
+ function writeRevision( $rev, $string ) {
+ if ( $rev->rev_id == $this->page->page_latest ) {
+ $this->rev = $rev;
+ $this->revString = $string;
+ }
+ }
+}
diff --git a/www/wiki/includes/export/DumpMultiWriter.php b/www/wiki/includes/export/DumpMultiWriter.php
new file mode 100644
index 00000000..92118fe4
--- /dev/null
+++ b/www/wiki/includes/export/DumpMultiWriter.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * Base class for output stream; prints to stdout or buffer or wherever.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpMultiWriter {
+
+ /**
+ * @param array $sinks
+ */
+ function __construct( $sinks ) {
+ $this->sinks = $sinks;
+ $this->count = count( $sinks );
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeOpenStream( $string ) {
+ for ( $i = 0; $i < $this->count; $i++ ) {
+ $this->sinks[$i]->writeOpenStream( $string );
+ }
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeCloseStream( $string ) {
+ for ( $i = 0; $i < $this->count; $i++ ) {
+ $this->sinks[$i]->writeCloseStream( $string );
+ }
+ }
+
+ /**
+ * @param object $page
+ * @param string $string
+ */
+ function writeOpenPage( $page, $string ) {
+ for ( $i = 0; $i < $this->count; $i++ ) {
+ $this->sinks[$i]->writeOpenPage( $page, $string );
+ }
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeClosePage( $string ) {
+ for ( $i = 0; $i < $this->count; $i++ ) {
+ $this->sinks[$i]->writeClosePage( $string );
+ }
+ }
+
+ /**
+ * @param object $rev
+ * @param string $string
+ */
+ function writeRevision( $rev, $string ) {
+ for ( $i = 0; $i < $this->count; $i++ ) {
+ $this->sinks[$i]->writeRevision( $rev, $string );
+ }
+ }
+
+ /**
+ * @param array $newnames
+ */
+ function closeRenameAndReopen( $newnames ) {
+ $this->closeAndRename( $newnames, true );
+ }
+
+ /**
+ * @param array $newnames
+ * @param bool $open
+ */
+ function closeAndRename( $newnames, $open = false ) {
+ for ( $i = 0; $i < $this->count; $i++ ) {
+ $this->sinks[$i]->closeAndRename( $newnames[$i], $open );
+ }
+ }
+
+ /**
+ * @return array
+ */
+ function getFilenames() {
+ $filenames = [];
+ for ( $i = 0; $i < $this->count; $i++ ) {
+ $filenames[] = $this->sinks[$i]->getFilenames();
+ }
+ return $filenames;
+ }
+}
diff --git a/www/wiki/includes/export/DumpNamespaceFilter.php b/www/wiki/includes/export/DumpNamespaceFilter.php
new file mode 100644
index 00000000..2b71db00
--- /dev/null
+++ b/www/wiki/includes/export/DumpNamespaceFilter.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * Dump output filter to include or exclude pages in a given set of namespaces.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpNamespaceFilter extends DumpFilter {
+ /** @var bool */
+ public $invert = false;
+
+ /** @var array */
+ public $namespaces = [];
+
+ /**
+ * @param DumpOutput &$sink
+ * @param array $param
+ * @throws MWException
+ */
+ function __construct( &$sink, $param ) {
+ parent::__construct( $sink );
+
+ $constants = [
+ "NS_MAIN" => NS_MAIN,
+ "NS_TALK" => NS_TALK,
+ "NS_USER" => NS_USER,
+ "NS_USER_TALK" => NS_USER_TALK,
+ "NS_PROJECT" => NS_PROJECT,
+ "NS_PROJECT_TALK" => NS_PROJECT_TALK,
+ "NS_FILE" => NS_FILE,
+ "NS_FILE_TALK" => NS_FILE_TALK,
+ "NS_IMAGE" => NS_IMAGE, // NS_IMAGE is an alias for NS_FILE
+ "NS_IMAGE_TALK" => NS_IMAGE_TALK,
+ "NS_MEDIAWIKI" => NS_MEDIAWIKI,
+ "NS_MEDIAWIKI_TALK" => NS_MEDIAWIKI_TALK,
+ "NS_TEMPLATE" => NS_TEMPLATE,
+ "NS_TEMPLATE_TALK" => NS_TEMPLATE_TALK,
+ "NS_HELP" => NS_HELP,
+ "NS_HELP_TALK" => NS_HELP_TALK,
+ "NS_CATEGORY" => NS_CATEGORY,
+ "NS_CATEGORY_TALK" => NS_CATEGORY_TALK ];
+
+ if ( $param { 0 } == '!' ) {
+ $this->invert = true;
+ $param = substr( $param, 1 );
+ }
+
+ foreach ( explode( ',', $param ) as $key ) {
+ $key = trim( $key );
+ if ( isset( $constants[$key] ) ) {
+ $ns = $constants[$key];
+ $this->namespaces[$ns] = true;
+ } elseif ( is_numeric( $key ) ) {
+ $ns = intval( $key );
+ $this->namespaces[$ns] = true;
+ } else {
+ throw new MWException( "Unrecognized namespace key '$key'\n" );
+ }
+ }
+ }
+
+ /**
+ * @param object $page
+ * @return bool
+ */
+ function pass( $page ) {
+ $match = isset( $this->namespaces[$page->page_namespace] );
+ return $this->invert xor $match;
+ }
+}
diff --git a/www/wiki/includes/export/DumpNotalkFilter.php b/www/wiki/includes/export/DumpNotalkFilter.php
new file mode 100644
index 00000000..d99b1b1d
--- /dev/null
+++ b/www/wiki/includes/export/DumpNotalkFilter.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Simple dump output filter to exclude all talk pages.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpNotalkFilter extends DumpFilter {
+ /**
+ * @param object $page
+ * @return bool
+ */
+ function pass( $page ) {
+ return !MWNamespace::isTalk( $page->page_namespace );
+ }
+}
diff --git a/www/wiki/includes/export/DumpOutput.php b/www/wiki/includes/export/DumpOutput.php
new file mode 100644
index 00000000..edd73fcf
--- /dev/null
+++ b/www/wiki/includes/export/DumpOutput.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Base class for output stream; prints to stdout or buffer or wherever.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpOutput {
+
+ /**
+ * @param string $string
+ */
+ function writeOpenStream( $string ) {
+ $this->write( $string );
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeCloseStream( $string ) {
+ $this->write( $string );
+ }
+
+ /**
+ * @param object $page
+ * @param string $string
+ */
+ function writeOpenPage( $page, $string ) {
+ $this->write( $string );
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeClosePage( $string ) {
+ $this->write( $string );
+ }
+
+ /**
+ * @param object $rev
+ * @param string $string
+ */
+ function writeRevision( $rev, $string ) {
+ $this->write( $string );
+ }
+
+ /**
+ * @param object $rev
+ * @param string $string
+ */
+ function writeLogItem( $rev, $string ) {
+ $this->write( $string );
+ }
+
+ /**
+ * Override to write to a different stream type.
+ * @param string $string
+ * @return bool
+ */
+ function write( $string ) {
+ print $string;
+ }
+
+ /**
+ * Close the old file, move it to a specified name,
+ * and reopen new file with the old name. Use this
+ * for writing out a file in multiple pieces
+ * at specified checkpoints (e.g. every n hours).
+ * @param string|array $newname File name. May be a string or an array with one element
+ */
+ function closeRenameAndReopen( $newname ) {
+ }
+
+ /**
+ * Close the old file, and move it to a specified name.
+ * Use this for the last piece of a file written out
+ * at specified checkpoints (e.g. every n hours).
+ * @param string|array $newname File name. May be a string or an array with one element
+ * @param bool $open If true, a new file with the old filename will be opened
+ * again for writing (default: false)
+ */
+ function closeAndRename( $newname, $open = false ) {
+ }
+
+ /**
+ * Returns the name of the file or files which are
+ * being written to, if there are any.
+ * @return null
+ */
+ function getFilenames() {
+ return null;
+ }
+}
diff --git a/www/wiki/includes/export/DumpPipeOutput.php b/www/wiki/includes/export/DumpPipeOutput.php
new file mode 100644
index 00000000..ce899ed3
--- /dev/null
+++ b/www/wiki/includes/export/DumpPipeOutput.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * Stream outputter to send data to a file via some filter program.
+ * Even if compression is available in a library, using a separate
+ * program can allow us to make use of a multi-processor system.
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class DumpPipeOutput extends DumpFileOutput {
+ protected $command, $filename;
+ protected $procOpenResource = false;
+
+ /**
+ * @param string $command
+ * @param string $file
+ */
+ function __construct( $command, $file = null ) {
+ if ( !is_null( $file ) ) {
+ $command .= " > " . wfEscapeShellArg( $file );
+ }
+
+ $this->startCommand( $command );
+ $this->command = $command;
+ $this->filename = $file;
+ }
+
+ /**
+ * @param string $string
+ */
+ function writeCloseStream( $string ) {
+ parent::writeCloseStream( $string );
+ if ( $this->procOpenResource ) {
+ proc_close( $this->procOpenResource );
+ $this->procOpenResource = false;
+ }
+ }
+
+ /**
+ * @param string $command
+ */
+ function startCommand( $command ) {
+ $spec = [
+ 0 => [ "pipe", "r" ],
+ ];
+ $pipes = [];
+ $this->procOpenResource = proc_open( $command, $spec, $pipes );
+ $this->handle = $pipes[0];
+ }
+
+ /**
+ * @param string $newname
+ */
+ function closeRenameAndReopen( $newname ) {
+ $this->closeAndRename( $newname, true );
+ }
+
+ /**
+ * @param string $newname
+ * @param bool $open
+ */
+ function closeAndRename( $newname, $open = false ) {
+ $newname = $this->checkRenameArgCount( $newname );
+ if ( $newname ) {
+ if ( $this->handle ) {
+ fclose( $this->handle );
+ $this->handle = false;
+ }
+ if ( $this->procOpenResource ) {
+ proc_close( $this->procOpenResource );
+ $this->procOpenResource = false;
+ }
+ $this->renameOrException( $newname );
+ if ( $open ) {
+ $command = $this->command;
+ $command .= " > " . wfEscapeShellArg( $this->filename );
+ $this->startCommand( $command );
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/export/DumpStringOutput.php b/www/wiki/includes/export/DumpStringOutput.php
new file mode 100644
index 00000000..837a62d6
--- /dev/null
+++ b/www/wiki/includes/export/DumpStringOutput.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Stream outputter that buffers and returns data as a string.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ * @since 1.28
+ */
+class DumpStringOutput extends DumpOutput {
+ private $output = '';
+
+ /**
+ * @param string $string
+ */
+ function write( $string ) {
+ $this->output .= $string;
+ }
+
+ /**
+ * Get the string containing the output.
+ *
+ * @return string
+ */
+ public function __toString() {
+ return $this->output;
+ }
+}
diff --git a/www/wiki/includes/export/WikiExporter.php b/www/wiki/includes/export/WikiExporter.php
new file mode 100644
index 00000000..6e2a5a4f
--- /dev/null
+++ b/www/wiki/includes/export/WikiExporter.php
@@ -0,0 +1,502 @@
+<?php
+/**
+ * Base class for exporting
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @defgroup Dump Dump
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * @ingroup SpecialPage Dump
+ */
+class WikiExporter {
+ /** @var bool Return distinct author list (when not returning full history) */
+ public $list_authors = false;
+
+ /** @var bool */
+ public $dumpUploads = false;
+
+ /** @var bool */
+ public $dumpUploadFileContents = false;
+
+ /** @var string */
+ public $author_list = "";
+
+ const FULL = 1;
+ const CURRENT = 2;
+ const STABLE = 4; // extension defined
+ const LOGS = 8;
+ const RANGE = 16;
+
+ const BUFFER = 0;
+ const STREAM = 1;
+
+ const TEXT = 0;
+ const STUB = 1;
+
+ /** @var int */
+ public $buffer;
+
+ /** @var int */
+ public $text;
+
+ /** @var DumpOutput */
+ public $sink;
+
+ /**
+ * Returns the export schema version.
+ * @return string
+ */
+ public static function schemaVersion() {
+ return "0.10";
+ }
+
+ /**
+ * If using WikiExporter::STREAM to stream a large amount of data,
+ * provide a database connection which is not managed by
+ * LoadBalancer to read from: some history blob types will
+ * make additional queries to pull source data while the
+ * main query is still running.
+ *
+ * @param IDatabase $db
+ * @param int|array $history One of WikiExporter::FULL, WikiExporter::CURRENT,
+ * WikiExporter::RANGE or WikiExporter::STABLE, or an associative array:
+ * - offset: non-inclusive offset at which to start the query
+ * - limit: maximum number of rows to return
+ * - dir: "asc" or "desc" timestamp order
+ * @param int $buffer One of WikiExporter::BUFFER or WikiExporter::STREAM
+ * @param int $text One of WikiExporter::TEXT or WikiExporter::STUB
+ */
+ function __construct( $db, $history = self::CURRENT,
+ $buffer = self::BUFFER, $text = self::TEXT ) {
+ $this->db = $db;
+ $this->history = $history;
+ $this->buffer = $buffer;
+ $this->writer = new XmlDumpWriter();
+ $this->sink = new DumpOutput();
+ $this->text = $text;
+ }
+
+ /**
+ * Set the DumpOutput or DumpFilter object which will receive
+ * various row objects and XML output for filtering. Filters
+ * can be chained or used as callbacks.
+ *
+ * @param DumpOutput &$sink
+ */
+ public function setOutputSink( &$sink ) {
+ $this->sink =& $sink;
+ }
+
+ public function openStream() {
+ $output = $this->writer->openStream();
+ $this->sink->writeOpenStream( $output );
+ }
+
+ public function closeStream() {
+ $output = $this->writer->closeStream();
+ $this->sink->writeCloseStream( $output );
+ }
+
+ /**
+ * Dumps a series of page and revision records for all pages
+ * in the database, either including complete history or only
+ * the most recent version.
+ */
+ public function allPages() {
+ $this->dumpFrom( '' );
+ }
+
+ /**
+ * Dumps a series of page and revision records for those pages
+ * in the database falling within the page_id range given.
+ * @param int $start Inclusive lower limit (this id is included)
+ * @param int $end Exclusive upper limit (this id is not included)
+ * If 0, no upper limit.
+ * @param bool $orderRevs order revisions within pages in ascending order
+ */
+ public function pagesByRange( $start, $end, $orderRevs ) {
+ if ( $orderRevs ) {
+ $condition = 'rev_page >= ' . intval( $start );
+ if ( $end ) {
+ $condition .= ' AND rev_page < ' . intval( $end );
+ }
+ } else {
+ $condition = 'page_id >= ' . intval( $start );
+ if ( $end ) {
+ $condition .= ' AND page_id < ' . intval( $end );
+ }
+ }
+ $this->dumpFrom( $condition, $orderRevs );
+ }
+
+ /**
+ * Dumps a series of page and revision records for those pages
+ * in the database with revisions falling within the rev_id range given.
+ * @param int $start Inclusive lower limit (this id is included)
+ * @param int $end Exclusive upper limit (this id is not included)
+ * If 0, no upper limit.
+ */
+ public function revsByRange( $start, $end ) {
+ $condition = 'rev_id >= ' . intval( $start );
+ if ( $end ) {
+ $condition .= ' AND rev_id < ' . intval( $end );
+ }
+ $this->dumpFrom( $condition );
+ }
+
+ /**
+ * @param Title $title
+ */
+ public function pageByTitle( $title ) {
+ $this->dumpFrom(
+ 'page_namespace=' . $title->getNamespace() .
+ ' AND page_title=' . $this->db->addQuotes( $title->getDBkey() ) );
+ }
+
+ /**
+ * @param string $name
+ * @throws MWException
+ */
+ public function pageByName( $name ) {
+ $title = Title::newFromText( $name );
+ if ( is_null( $title ) ) {
+ throw new MWException( "Can't export invalid title" );
+ } else {
+ $this->pageByTitle( $title );
+ }
+ }
+
+ /**
+ * @param array $names
+ */
+ public function pagesByName( $names ) {
+ foreach ( $names as $name ) {
+ $this->pageByName( $name );
+ }
+ }
+
+ public function allLogs() {
+ $this->dumpFrom( '' );
+ }
+
+ /**
+ * @param int $start
+ * @param int $end
+ */
+ public function logsByRange( $start, $end ) {
+ $condition = 'log_id >= ' . intval( $start );
+ if ( $end ) {
+ $condition .= ' AND log_id < ' . intval( $end );
+ }
+ $this->dumpFrom( $condition );
+ }
+
+ /**
+ * Generates the distinct list of authors of an article
+ * Not called by default (depends on $this->list_authors)
+ * Can be set by Special:Export when not exporting whole history
+ *
+ * @param array $cond
+ */
+ protected function do_list_authors( $cond ) {
+ $this->author_list = "<contributors>";
+ // rev_deleted
+
+ $res = $this->db->select(
+ [ 'page', 'revision' ],
+ [ 'DISTINCT rev_user_text', 'rev_user' ],
+ [
+ $this->db->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0',
+ $cond,
+ 'page_id = rev_id',
+ ],
+ __METHOD__
+ );
+
+ foreach ( $res as $row ) {
+ $this->author_list .= "<contributor>" .
+ "<username>" .
+ htmlentities( $row->rev_user_text ) .
+ "</username>" .
+ "<id>" .
+ $row->rev_user .
+ "</id>" .
+ "</contributor>";
+ }
+ $this->author_list .= "</contributors>";
+ }
+
+ /**
+ * @param string $cond
+ * @param bool $orderRevs
+ * @throws MWException
+ * @throws Exception
+ */
+ protected function dumpFrom( $cond = '', $orderRevs = false ) {
+ # For logging dumps...
+ if ( $this->history & self::LOGS ) {
+ $where = [];
+ # Hide private logs
+ $hideLogs = LogEventsList::getExcludeClause( $this->db );
+ if ( $hideLogs ) {
+ $where[] = $hideLogs;
+ }
+ # Add on any caller specified conditions
+ if ( $cond ) {
+ $where[] = $cond;
+ }
+ # Get logging table name for logging.* clause
+ $logging = $this->db->tableName( 'logging' );
+
+ if ( $this->buffer == self::STREAM ) {
+ $prev = $this->db->bufferResults( false );
+ }
+ $result = null; // Assuring $result is not undefined, if exception occurs early
+
+ $commentQuery = CommentStore::newKey( 'log_comment' )->getJoin();
+
+ try {
+ $result = $this->db->select( [ 'logging', 'user' ] + $commentQuery['tables'],
+ [ "{$logging}.*", 'user_name' ] + $commentQuery['fields'], // grab the user name
+ $where,
+ __METHOD__,
+ [ 'ORDER BY' => 'log_id', 'USE INDEX' => [ 'logging' => 'PRIMARY' ] ],
+ [ 'user' => [ 'JOIN', 'user_id = log_user' ] ] + $commentQuery['joins']
+ );
+ $this->outputLogStream( $result );
+ if ( $this->buffer == self::STREAM ) {
+ $this->db->bufferResults( $prev );
+ }
+ } catch ( Exception $e ) {
+ // Throwing the exception does not reliably free the resultset, and
+ // would also leave the connection in unbuffered mode.
+
+ // Freeing result
+ try {
+ if ( $result ) {
+ $result->free();
+ }
+ } catch ( Exception $e2 ) {
+ // Already in panic mode -> ignoring $e2 as $e has
+ // higher priority
+ }
+
+ // Putting database back in previous buffer mode
+ try {
+ if ( $this->buffer == self::STREAM ) {
+ $this->db->bufferResults( $prev );
+ }
+ } catch ( Exception $e2 ) {
+ // Already in panic mode -> ignoring $e2 as $e has
+ // higher priority
+ }
+
+ // Inform caller about problem
+ throw $e;
+ }
+ # For page dumps...
+ } else {
+ $tables = [ 'page', 'revision' ];
+ $opts = [ 'ORDER BY' => 'page_id ASC' ];
+ $opts['USE INDEX'] = [];
+ $join = [];
+ if ( is_array( $this->history ) ) {
+ # Time offset/limit for all pages/history...
+ $revJoin = 'page_id=rev_page';
+ # Set time order
+ if ( $this->history['dir'] == 'asc' ) {
+ $op = '>';
+ $opts['ORDER BY'] = 'rev_timestamp ASC';
+ } else {
+ $op = '<';
+ $opts['ORDER BY'] = 'rev_timestamp DESC';
+ }
+ # Set offset
+ if ( !empty( $this->history['offset'] ) ) {
+ $revJoin .= " AND rev_timestamp $op " .
+ $this->db->addQuotes( $this->db->timestamp( $this->history['offset'] ) );
+ }
+ $join['revision'] = [ 'INNER JOIN', $revJoin ];
+ # Set query limit
+ if ( !empty( $this->history['limit'] ) ) {
+ $opts['LIMIT'] = intval( $this->history['limit'] );
+ }
+ } elseif ( $this->history & self::FULL ) {
+ # Full history dumps...
+ # query optimization for history stub dumps
+ if ( $this->text == self::STUB && $orderRevs ) {
+ $tables = [ 'revision', 'page' ];
+ $opts[] = 'STRAIGHT_JOIN';
+ $opts['ORDER BY'] = [ 'rev_page ASC', 'rev_id ASC' ];
+ $opts['USE INDEX']['revision'] = 'rev_page_id';
+ $join['page'] = [ 'INNER JOIN', 'rev_page=page_id' ];
+ } else {
+ $join['revision'] = [ 'INNER JOIN', 'page_id=rev_page' ];
+ }
+ } elseif ( $this->history & self::CURRENT ) {
+ # Latest revision dumps...
+ if ( $this->list_authors && $cond != '' ) { // List authors, if so desired
+ $this->do_list_authors( $cond );
+ }
+ $join['revision'] = [ 'INNER JOIN', 'page_id=rev_page AND page_latest=rev_id' ];
+ } elseif ( $this->history & self::STABLE ) {
+ # "Stable" revision dumps...
+ # Default JOIN, to be overridden...
+ $join['revision'] = [ 'INNER JOIN', 'page_id=rev_page AND page_latest=rev_id' ];
+ # One, and only one hook should set this, and return false
+ if ( Hooks::run( 'WikiExporter::dumpStableQuery', [ &$tables, &$opts, &$join ] ) ) {
+ throw new MWException( __METHOD__ . " given invalid history dump type." );
+ }
+ } elseif ( $this->history & self::RANGE ) {
+ # Dump of revisions within a specified range
+ $join['revision'] = [ 'INNER JOIN', 'page_id=rev_page' ];
+ $opts['ORDER BY'] = [ 'rev_page ASC', 'rev_id ASC' ];
+ } else {
+ # Unknown history specification parameter?
+ throw new MWException( __METHOD__ . " given invalid history dump type." );
+ }
+ # Query optimization hacks
+ if ( $cond == '' ) {
+ $opts[] = 'STRAIGHT_JOIN';
+ $opts['USE INDEX']['page'] = 'PRIMARY';
+ }
+ # Build text join options
+ if ( $this->text != self::STUB ) { // 1-pass
+ $tables[] = 'text';
+ $join['text'] = [ 'INNER JOIN', 'rev_text_id=old_id' ];
+ }
+
+ if ( $this->buffer == self::STREAM ) {
+ $prev = $this->db->bufferResults( false );
+ }
+ $result = null; // Assuring $result is not undefined, if exception occurs early
+ try {
+ Hooks::run( 'ModifyExportQuery',
+ [ $this->db, &$tables, &$cond, &$opts, &$join ] );
+
+ $commentQuery = CommentStore::newKey( 'rev_comment' )->getJoin();
+
+ # Do the query!
+ $result = $this->db->select(
+ $tables + $commentQuery['tables'],
+ [ '*' ] + $commentQuery['fields'],
+ $cond,
+ __METHOD__,
+ $opts,
+ $join + $commentQuery['joins']
+ );
+ # Output dump results
+ $this->outputPageStream( $result );
+
+ if ( $this->buffer == self::STREAM ) {
+ $this->db->bufferResults( $prev );
+ }
+ } catch ( Exception $e ) {
+ // Throwing the exception does not reliably free the resultset, and
+ // would also leave the connection in unbuffered mode.
+
+ // Freeing result
+ try {
+ if ( $result ) {
+ $result->free();
+ }
+ } catch ( Exception $e2 ) {
+ // Already in panic mode -> ignoring $e2 as $e has
+ // higher priority
+ }
+
+ // Putting database back in previous buffer mode
+ try {
+ if ( $this->buffer == self::STREAM ) {
+ $this->db->bufferResults( $prev );
+ }
+ } catch ( Exception $e2 ) {
+ // Already in panic mode -> ignoring $e2 as $e has
+ // higher priority
+ }
+
+ // Inform caller about problem
+ throw $e;
+ }
+ }
+ }
+
+ /**
+ * Runs through a query result set dumping page and revision records.
+ * The result set should be sorted/grouped by page to avoid duplicate
+ * page records in the output.
+ *
+ * Should be safe for
+ * streaming (non-buffered) queries, as long as it was made on a
+ * separate database connection not managed by LoadBalancer; some
+ * blob storage types will make queries to pull source data.
+ *
+ * @param ResultWrapper $resultset
+ */
+ protected function outputPageStream( $resultset ) {
+ $last = null;
+ foreach ( $resultset as $row ) {
+ if ( $last === null ||
+ $last->page_namespace != $row->page_namespace ||
+ $last->page_title != $row->page_title ) {
+ if ( $last !== null ) {
+ $output = '';
+ if ( $this->dumpUploads ) {
+ $output .= $this->writer->writeUploads( $last, $this->dumpUploadFileContents );
+ }
+ $output .= $this->writer->closePage();
+ $this->sink->writeClosePage( $output );
+ }
+ $output = $this->writer->openPage( $row );
+ $this->sink->writeOpenPage( $row, $output );
+ $last = $row;
+ }
+ $output = $this->writer->writeRevision( $row );
+ $this->sink->writeRevision( $row, $output );
+ }
+ if ( $last !== null ) {
+ $output = '';
+ if ( $this->dumpUploads ) {
+ $output .= $this->writer->writeUploads( $last, $this->dumpUploadFileContents );
+ }
+ $output .= $this->author_list;
+ $output .= $this->writer->closePage();
+ $this->sink->writeClosePage( $output );
+ }
+ }
+
+ /**
+ * @param ResultWrapper $resultset
+ */
+ protected function outputLogStream( $resultset ) {
+ foreach ( $resultset as $row ) {
+ $output = $this->writer->writeLogItem( $row );
+ $this->sink->writeLogItem( $row, $output );
+ }
+ }
+}
diff --git a/www/wiki/includes/export/XmlDumpWriter.php b/www/wiki/includes/export/XmlDumpWriter.php
new file mode 100644
index 00000000..c46eb61c
--- /dev/null
+++ b/www/wiki/includes/export/XmlDumpWriter.php
@@ -0,0 +1,449 @@
+<?php
+/**
+ * XmlDumpWriter
+ *
+ * Copyright © 2003, 2005, 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class XmlDumpWriter {
+ /**
+ * Opens the XML output stream's root "<mediawiki>" element.
+ * This does not include an xml directive, so is safe to include
+ * as a subelement in a larger XML stream. Namespace and XML Schema
+ * references are included.
+ *
+ * Output will be encoded in UTF-8.
+ *
+ * @return string
+ */
+ function openStream() {
+ global $wgContLang;
+ $ver = WikiExporter::schemaVersion();
+ return Xml::element( 'mediawiki', [
+ 'xmlns' => "http://www.mediawiki.org/xml/export-$ver/",
+ 'xmlns:xsi' => "http://www.w3.org/2001/XMLSchema-instance",
+ /*
+ * When a new version of the schema is created, it needs staging on mediawiki.org.
+ * This requires a change in the operations/mediawiki-config git repo.
+ *
+ * Create a changeset like https://gerrit.wikimedia.org/r/#/c/149643/ in which
+ * you copy in the new xsd file.
+ *
+ * After it is reviewed, merged and deployed (sync-docroot), the index.html needs purging.
+ * echo "https://www.mediawiki.org/xml/index.html" | mwscript purgeList.php --wiki=aawiki
+ */
+ 'xsi:schemaLocation' => "http://www.mediawiki.org/xml/export-$ver/ " .
+ "http://www.mediawiki.org/xml/export-$ver.xsd",
+ 'version' => $ver,
+ 'xml:lang' => $wgContLang->getHtmlCode() ],
+ null ) .
+ "\n" .
+ $this->siteInfo();
+ }
+
+ /**
+ * @return string
+ */
+ function siteInfo() {
+ $info = [
+ $this->sitename(),
+ $this->dbname(),
+ $this->homelink(),
+ $this->generator(),
+ $this->caseSetting(),
+ $this->namespaces() ];
+ return " <siteinfo>\n " .
+ implode( "\n ", $info ) .
+ "\n </siteinfo>\n";
+ }
+
+ /**
+ * @return string
+ */
+ function sitename() {
+ global $wgSitename;
+ return Xml::element( 'sitename', [], $wgSitename );
+ }
+
+ /**
+ * @return string
+ */
+ function dbname() {
+ global $wgDBname;
+ return Xml::element( 'dbname', [], $wgDBname );
+ }
+
+ /**
+ * @return string
+ */
+ function generator() {
+ global $wgVersion;
+ return Xml::element( 'generator', [], "MediaWiki $wgVersion" );
+ }
+
+ /**
+ * @return string
+ */
+ function homelink() {
+ return Xml::element( 'base', [], Title::newMainPage()->getCanonicalURL() );
+ }
+
+ /**
+ * @return string
+ */
+ function caseSetting() {
+ global $wgCapitalLinks;
+ // "case-insensitive" option is reserved for future
+ $sensitivity = $wgCapitalLinks ? 'first-letter' : 'case-sensitive';
+ return Xml::element( 'case', [], $sensitivity );
+ }
+
+ /**
+ * @return string
+ */
+ function namespaces() {
+ global $wgContLang;
+ $spaces = "<namespaces>\n";
+ foreach ( $wgContLang->getFormattedNamespaces() as $ns => $title ) {
+ $spaces .= ' ' .
+ Xml::element( 'namespace',
+ [
+ 'key' => $ns,
+ 'case' => MWNamespace::isCapitalized( $ns ) ? 'first-letter' : 'case-sensitive',
+ ], $title ) . "\n";
+ }
+ $spaces .= " </namespaces>";
+ return $spaces;
+ }
+
+ /**
+ * Closes the output stream with the closing root element.
+ * Call when finished dumping things.
+ *
+ * @return string
+ */
+ function closeStream() {
+ return "</mediawiki>\n";
+ }
+
+ /**
+ * Opens a "<page>" section on the output stream, with data
+ * from the given database row.
+ *
+ * @param object $row
+ * @return string
+ */
+ public function openPage( $row ) {
+ $out = " <page>\n";
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ $out .= ' ' . Xml::elementClean( 'title', [], self::canonicalTitle( $title ) ) . "\n";
+ $out .= ' ' . Xml::element( 'ns', [], strval( $row->page_namespace ) ) . "\n";
+ $out .= ' ' . Xml::element( 'id', [], strval( $row->page_id ) ) . "\n";
+ if ( $row->page_is_redirect ) {
+ $page = WikiPage::factory( $title );
+ $redirect = $page->getRedirectTarget();
+ if ( $redirect instanceof Title && $redirect->isValidRedirectTarget() ) {
+ $out .= ' ';
+ $out .= Xml::element( 'redirect', [ 'title' => self::canonicalTitle( $redirect ) ] );
+ $out .= "\n";
+ }
+ }
+
+ if ( $row->page_restrictions != '' ) {
+ $out .= ' ' . Xml::element( 'restrictions', [],
+ strval( $row->page_restrictions ) ) . "\n";
+ }
+
+ Hooks::run( 'XmlDumpWriterOpenPage', [ $this, &$out, $row, $title ] );
+
+ return $out;
+ }
+
+ /**
+ * Closes a "<page>" section on the output stream.
+ *
+ * @access private
+ * @return string
+ */
+ function closePage() {
+ return " </page>\n";
+ }
+
+ /**
+ * Dumps a "<revision>" section on the output stream, with
+ * data filled in from the given database row.
+ *
+ * @param object $row
+ * @return string
+ * @access private
+ */
+ function writeRevision( $row ) {
+ $out = " <revision>\n";
+ $out .= " " . Xml::element( 'id', null, strval( $row->rev_id ) ) . "\n";
+ if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) {
+ $out .= " " . Xml::element( 'parentid', null, strval( $row->rev_parent_id ) ) . "\n";
+ }
+
+ $out .= $this->writeTimestamp( $row->rev_timestamp );
+
+ if ( isset( $row->rev_deleted ) && ( $row->rev_deleted & Revision::DELETED_USER ) ) {
+ $out .= " " . Xml::element( 'contributor', [ 'deleted' => 'deleted' ] ) . "\n";
+ } else {
+ $out .= $this->writeContributor( $row->rev_user, $row->rev_user_text );
+ }
+
+ if ( isset( $row->rev_minor_edit ) && $row->rev_minor_edit ) {
+ $out .= " <minor/>\n";
+ }
+ if ( isset( $row->rev_deleted ) && ( $row->rev_deleted & Revision::DELETED_COMMENT ) ) {
+ $out .= " " . Xml::element( 'comment', [ 'deleted' => 'deleted' ] ) . "\n";
+ } else {
+ $comment = CommentStore::newKey( 'rev_comment' )->getComment( $row )->text;
+ if ( $comment != '' ) {
+ $out .= " " . Xml::elementClean( 'comment', [], strval( $comment ) ) . "\n";
+ }
+ }
+
+ if ( isset( $row->rev_content_model ) && !is_null( $row->rev_content_model ) ) {
+ $content_model = strval( $row->rev_content_model );
+ } else {
+ // probably using $wgContentHandlerUseDB = false;
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ $content_model = ContentHandler::getDefaultModelFor( $title );
+ }
+
+ $content_handler = ContentHandler::getForModelID( $content_model );
+
+ if ( isset( $row->rev_content_format ) && !is_null( $row->rev_content_format ) ) {
+ $content_format = strval( $row->rev_content_format );
+ } else {
+ // probably using $wgContentHandlerUseDB = false;
+ $content_format = $content_handler->getDefaultFormat();
+ }
+
+ $out .= " " . Xml::element( 'model', null, strval( $content_model ) ) . "\n";
+ $out .= " " . Xml::element( 'format', null, strval( $content_format ) ) . "\n";
+
+ $text = '';
+ if ( isset( $row->rev_deleted ) && ( $row->rev_deleted & Revision::DELETED_TEXT ) ) {
+ $out .= " " . Xml::element( 'text', [ 'deleted' => 'deleted' ] ) . "\n";
+ } elseif ( isset( $row->old_text ) ) {
+ // Raw text from the database may have invalid chars
+ $text = strval( Revision::getRevisionText( $row ) );
+ $text = $content_handler->exportTransform( $text, $content_format );
+ $out .= " " . Xml::elementClean( 'text',
+ [ 'xml:space' => 'preserve', 'bytes' => intval( $row->rev_len ) ],
+ strval( $text ) ) . "\n";
+ } else {
+ // Stub output
+ $out .= " " . Xml::element( 'text',
+ [ 'id' => $row->rev_text_id, 'bytes' => intval( $row->rev_len ) ],
+ "" ) . "\n";
+ }
+
+ if ( isset( $row->rev_sha1 )
+ && $row->rev_sha1
+ && !( $row->rev_deleted & Revision::DELETED_TEXT )
+ ) {
+ $out .= " " . Xml::element( 'sha1', null, strval( $row->rev_sha1 ) ) . "\n";
+ } else {
+ $out .= " <sha1/>\n";
+ }
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $writer = $this;
+ Hooks::run( 'XmlDumpWriterWriteRevision', [ &$writer, &$out, $row, $text ] );
+
+ $out .= " </revision>\n";
+
+ return $out;
+ }
+
+ /**
+ * Dumps a "<logitem>" section on the output stream, with
+ * data filled in from the given database row.
+ *
+ * @param object $row
+ * @return string
+ * @access private
+ */
+ function writeLogItem( $row ) {
+ $out = " <logitem>\n";
+ $out .= " " . Xml::element( 'id', null, strval( $row->log_id ) ) . "\n";
+
+ $out .= $this->writeTimestamp( $row->log_timestamp, " " );
+
+ if ( $row->log_deleted & LogPage::DELETED_USER ) {
+ $out .= " " . Xml::element( 'contributor', [ 'deleted' => 'deleted' ] ) . "\n";
+ } else {
+ $out .= $this->writeContributor( $row->log_user, $row->user_name, " " );
+ }
+
+ if ( $row->log_deleted & LogPage::DELETED_COMMENT ) {
+ $out .= " " . Xml::element( 'comment', [ 'deleted' => 'deleted' ] ) . "\n";
+ } else {
+ $comment = CommentStore::newKey( 'log_comment' )->getComment( $row )->text;
+ if ( $comment != '' ) {
+ $out .= " " . Xml::elementClean( 'comment', null, strval( $comment ) ) . "\n";
+ }
+ }
+
+ $out .= " " . Xml::element( 'type', null, strval( $row->log_type ) ) . "\n";
+ $out .= " " . Xml::element( 'action', null, strval( $row->log_action ) ) . "\n";
+
+ if ( $row->log_deleted & LogPage::DELETED_ACTION ) {
+ $out .= " " . Xml::element( 'text', [ 'deleted' => 'deleted' ] ) . "\n";
+ } else {
+ $title = Title::makeTitle( $row->log_namespace, $row->log_title );
+ $out .= " " . Xml::elementClean( 'logtitle', null, self::canonicalTitle( $title ) ) . "\n";
+ $out .= " " . Xml::elementClean( 'params',
+ [ 'xml:space' => 'preserve' ],
+ strval( $row->log_params ) ) . "\n";
+ }
+
+ $out .= " </logitem>\n";
+
+ return $out;
+ }
+
+ /**
+ * @param string $timestamp
+ * @param string $indent Default to six spaces
+ * @return string
+ */
+ function writeTimestamp( $timestamp, $indent = " " ) {
+ $ts = wfTimestamp( TS_ISO_8601, $timestamp );
+ return $indent . Xml::element( 'timestamp', null, $ts ) . "\n";
+ }
+
+ /**
+ * @param int $id
+ * @param string $text
+ * @param string $indent Default to six spaces
+ * @return string
+ */
+ function writeContributor( $id, $text, $indent = " " ) {
+ $out = $indent . "<contributor>\n";
+ if ( $id || !IP::isValid( $text ) ) {
+ $out .= $indent . " " . Xml::elementClean( 'username', null, strval( $text ) ) . "\n";
+ $out .= $indent . " " . Xml::element( 'id', null, strval( $id ) ) . "\n";
+ } else {
+ $out .= $indent . " " . Xml::elementClean( 'ip', null, strval( $text ) ) . "\n";
+ }
+ $out .= $indent . "</contributor>\n";
+ return $out;
+ }
+
+ /**
+ * Warning! This data is potentially inconsistent. :(
+ * @param object $row
+ * @param bool $dumpContents
+ * @return string
+ */
+ function writeUploads( $row, $dumpContents = false ) {
+ if ( $row->page_namespace == NS_FILE ) {
+ $img = wfLocalFile( $row->page_title );
+ if ( $img && $img->exists() ) {
+ $out = '';
+ foreach ( array_reverse( $img->getHistory() ) as $ver ) {
+ $out .= $this->writeUpload( $ver, $dumpContents );
+ }
+ $out .= $this->writeUpload( $img, $dumpContents );
+ return $out;
+ }
+ }
+ return '';
+ }
+
+ /**
+ * @param File $file
+ * @param bool $dumpContents
+ * @return string
+ */
+ function writeUpload( $file, $dumpContents = false ) {
+ if ( $file->isOld() ) {
+ $archiveName = " " .
+ Xml::element( 'archivename', null, $file->getArchiveName() ) . "\n";
+ } else {
+ $archiveName = '';
+ }
+ if ( $dumpContents ) {
+ $be = $file->getRepo()->getBackend();
+ # Dump file as base64
+ # Uses only XML-safe characters, so does not need escaping
+ # @todo Too bad this loads the contents into memory (script might swap)
+ $contents = ' <contents encoding="base64">' .
+ chunk_split( base64_encode(
+ $be->getFileContents( [ 'src' => $file->getPath() ] ) ) ) .
+ " </contents>\n";
+ } else {
+ $contents = '';
+ }
+ if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
+ $comment = Xml::element( 'comment', [ 'deleted' => 'deleted' ] );
+ } else {
+ $comment = Xml::elementClean( 'comment', null, strval( $file->getDescription() ) );
+ }
+ return " <upload>\n" .
+ $this->writeTimestamp( $file->getTimestamp() ) .
+ $this->writeContributor( $file->getUser( 'id' ), $file->getUser( 'text' ) ) .
+ " " . $comment . "\n" .
+ " " . Xml::element( 'filename', null, $file->getName() ) . "\n" .
+ $archiveName .
+ " " . Xml::element( 'src', null, $file->getCanonicalUrl() ) . "\n" .
+ " " . Xml::element( 'size', null, $file->getSize() ) . "\n" .
+ " " . Xml::element( 'sha1base36', null, $file->getSha1() ) . "\n" .
+ " " . Xml::element( 'rel', null, $file->getRel() ) . "\n" .
+ $contents .
+ " </upload>\n";
+ }
+
+ /**
+ * Return prefixed text form of title, but using the content language's
+ * canonical namespace. This skips any special-casing such as gendered
+ * user namespaces -- which while useful, are not yet listed in the
+ * XML "<siteinfo>" data so are unsafe in export.
+ *
+ * @param Title $title
+ * @return string
+ * @since 1.18
+ */
+ public static function canonicalTitle( Title $title ) {
+ if ( $title->isExternal() ) {
+ return $title->getPrefixedText();
+ }
+
+ global $wgContLang;
+ $prefix = $wgContLang->getFormattedNsText( $title->getNamespace() );
+
+ // @todo Emit some kind of warning to the user if $title->getNamespace() !==
+ // NS_MAIN and $prefix === '' (viz. pages in an unregistered namespace)
+
+ if ( $prefix !== '' ) {
+ $prefix .= ':';
+ }
+
+ return $prefix . $title->getText();
+ }
+}
diff --git a/www/wiki/includes/externalstore/ExternalStore.php b/www/wiki/includes/externalstore/ExternalStore.php
new file mode 100644
index 00000000..1563baf8
--- /dev/null
+++ b/www/wiki/includes/externalstore/ExternalStore.php
@@ -0,0 +1,229 @@
+<?php
+/**
+ * @defgroup ExternalStorage ExternalStorage
+ */
+
+/**
+ * Interface for data storage in external repositories.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Constructor class for key/value blob data kept in external repositories.
+ *
+ * Objects in external stores are defined by a special URL. The URL is of
+ * the form "<store protocol>://<location>/<object name>". The protocol is used
+ * to determine what ExternalStoreMedium class is used. The location identifies
+ * particular storage instances or database clusters for store class to use.
+ *
+ * When an object is inserted into a store, the calling code uses a partial URL of
+ * the form "<store protocol>://<location>" and receives the full object URL on success.
+ * This is useful since object names can be sequential IDs, UUIDs, or hashes.
+ * Callers are not responsible for unique name generation.
+ *
+ * External repositories might be populated by maintenance/async
+ * scripts, thus partial moving of data may be possible, as well
+ * as the possibility to have any storage format (i.e. for archives).
+ *
+ * @ingroup ExternalStorage
+ */
+class ExternalStore {
+ /**
+ * Get an external store object of the given type, with the given parameters
+ *
+ * @param string $proto Type of external storage, should be a value in $wgExternalStores
+ * @param array $params Associative array of ExternalStoreMedium parameters
+ * @return ExternalStoreMedium|bool The store class or false on error
+ */
+ public static function getStoreObject( $proto, array $params = [] ) {
+ global $wgExternalStores;
+
+ if ( !$wgExternalStores || !in_array( $proto, $wgExternalStores ) ) {
+ return false; // protocol not enabled
+ }
+
+ $class = 'ExternalStore' . ucfirst( $proto );
+
+ // Any custom modules should be added to $wgAutoLoadClasses for on-demand loading
+ return class_exists( $class ) ? new $class( $params ) : false;
+ }
+
+ /**
+ * Fetch data from given URL
+ *
+ * @param string $url The URL of the text to get
+ * @param array $params Associative array of ExternalStoreMedium parameters
+ * @return string|bool The text stored or false on error
+ * @throws MWException
+ */
+ public static function fetchFromURL( $url, array $params = [] ) {
+ $parts = explode( '://', $url, 2 );
+ if ( count( $parts ) != 2 ) {
+ return false; // invalid URL
+ }
+
+ list( $proto, $path ) = $parts;
+ if ( $path == '' ) { // bad URL
+ return false;
+ }
+
+ $store = self::getStoreObject( $proto, $params );
+ if ( $store === false ) {
+ return false;
+ }
+
+ return $store->fetchFromURL( $url );
+ }
+
+ /**
+ * Fetch data from multiple URLs with a minimum of round trips
+ *
+ * @param array $urls The URLs of the text to get
+ * @return array Map from url to its data. Data is either string when found
+ * or false on failure.
+ */
+ public static function batchFetchFromURLs( array $urls ) {
+ $batches = [];
+ foreach ( $urls as $url ) {
+ $scheme = parse_url( $url, PHP_URL_SCHEME );
+ if ( $scheme ) {
+ $batches[$scheme][] = $url;
+ }
+ }
+ $retval = [];
+ foreach ( $batches as $proto => $batchedUrls ) {
+ $store = self::getStoreObject( $proto );
+ if ( $store === false ) {
+ continue;
+ }
+ $retval += $store->batchFetchFromURLs( $batchedUrls );
+ }
+ // invalid, not found, db dead, etc.
+ $missing = array_diff( $urls, array_keys( $retval ) );
+ if ( $missing ) {
+ foreach ( $missing as $url ) {
+ $retval[$url] = false;
+ }
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Store a data item to an external store, identified by a partial URL
+ * The protocol part is used to identify the class, the rest is passed to the
+ * class itself as a parameter.
+ *
+ * @param string $url A partial external store URL ("<store type>://<location>")
+ * @param string $data
+ * @param array $params Associative array of ExternalStoreMedium parameters
+ * @return string|bool The URL of the stored data item, or false on error
+ * @throws MWException
+ */
+ public static function insert( $url, $data, array $params = [] ) {
+ $parts = explode( '://', $url, 2 );
+ if ( count( $parts ) != 2 ) {
+ return false; // invalid URL
+ }
+
+ list( $proto, $path ) = $parts;
+ if ( $path == '' ) { // bad URL
+ return false;
+ }
+
+ $store = self::getStoreObject( $proto, $params );
+ if ( $store === false ) {
+ return false;
+ } else {
+ return $store->store( $path, $data );
+ }
+ }
+
+ /**
+ * Like insert() above, but does more of the work for us.
+ * This function does not need a url param, it builds it by
+ * itself. It also fails-over to the next possible clusters
+ * provided by $wgDefaultExternalStore.
+ *
+ * @param string $data
+ * @param array $params Associative array of ExternalStoreMedium parameters
+ * @return string|bool The URL of the stored data item, or false on error
+ * @throws MWException
+ */
+ public static function insertToDefault( $data, array $params = [] ) {
+ global $wgDefaultExternalStore;
+
+ return self::insertWithFallback( (array)$wgDefaultExternalStore, $data, $params );
+ }
+
+ /**
+ * Like insert() above, but does more of the work for us.
+ * This function does not need a url param, it builds it by
+ * itself. It also fails-over to the next possible clusters
+ * as provided in the first parameter.
+ *
+ * @param array $tryStores Refer to $wgDefaultExternalStore
+ * @param string $data
+ * @param array $params Associative array of ExternalStoreMedium parameters
+ * @return string|bool The URL of the stored data item, or false on error
+ * @throws MWException
+ */
+ public static function insertWithFallback( array $tryStores, $data, array $params = [] ) {
+ $error = false;
+ while ( count( $tryStores ) > 0 ) {
+ $index = mt_rand( 0, count( $tryStores ) - 1 );
+ $storeUrl = $tryStores[$index];
+ wfDebug( __METHOD__ . ": trying $storeUrl\n" );
+ list( $proto, $path ) = explode( '://', $storeUrl, 2 );
+ $store = self::getStoreObject( $proto, $params );
+ if ( $store === false ) {
+ throw new MWException( "Invalid external storage protocol - $storeUrl" );
+ }
+ try {
+ $url = $store->store( $path, $data ); // Try to save the object
+ } catch ( Exception $error ) {
+ $url = false;
+ }
+ if ( strlen( $url ) ) {
+ return $url; // Done!
+ } else {
+ unset( $tryStores[$index] ); // Don't try this one again!
+ $tryStores = array_values( $tryStores ); // Must have consecutive keys
+ wfDebugLog( 'ExternalStorage',
+ "Unable to store text to external storage $storeUrl" );
+ }
+ }
+ // All stores failed
+ if ( $error ) {
+ throw $error; // rethrow the last error
+ } else {
+ throw new MWException( "Unable to store text to external storage" );
+ }
+ }
+
+ /**
+ * @param string $data
+ * @param string $wiki
+ * @return string|bool The URL of the stored data item, or false on error
+ * @throws MWException
+ */
+ public static function insertToForeignDefault( $data, $wiki ) {
+ return self::insertToDefault( $data, [ 'wiki' => $wiki ] );
+ }
+}
diff --git a/www/wiki/includes/externalstore/ExternalStoreDB.php b/www/wiki/includes/externalstore/ExternalStoreDB.php
new file mode 100644
index 00000000..e5d36e10
--- /dev/null
+++ b/www/wiki/includes/externalstore/ExternalStoreDB.php
@@ -0,0 +1,301 @@
+<?php
+/**
+ * External storage in SQL database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBConnRef;
+use Wikimedia\Rdbms\MaintainableDBConnRef;
+
+/**
+ * DB accessable external objects.
+ *
+ * In this system, each store "location" maps to a database "cluster".
+ * The clusters must be defined in the normal LBFactory configuration.
+ *
+ * @ingroup ExternalStorage
+ */
+class ExternalStoreDB extends ExternalStoreMedium {
+ /**
+ * The provided URL is in the form of DB://cluster/id
+ * or DB://cluster/id/itemid for concatened storage.
+ *
+ * @param string $url
+ * @return string|bool False if missing
+ * @see ExternalStoreMedium::fetchFromURL()
+ */
+ public function fetchFromURL( $url ) {
+ list( $cluster, $id, $itemID ) = $this->parseURL( $url );
+ $ret = $this->fetchBlob( $cluster, $id, $itemID );
+
+ if ( $itemID !== false && $ret !== false ) {
+ return $ret->getItem( $itemID );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Fetch data from given external store URLs.
+ * The provided URLs are in the form of DB://cluster/id
+ * or DB://cluster/id/itemid for concatened storage.
+ *
+ * @param array $urls An array of external store URLs
+ * @return array A map from url to stored content. Failed results
+ * are not represented.
+ */
+ public function batchFetchFromURLs( array $urls ) {
+ $batched = $inverseUrlMap = [];
+ foreach ( $urls as $url ) {
+ list( $cluster, $id, $itemID ) = $this->parseURL( $url );
+ $batched[$cluster][$id][] = $itemID;
+ // false $itemID gets cast to int, but should be ok
+ // since we do === from the $itemID in $batched
+ $inverseUrlMap[$cluster][$id][$itemID] = $url;
+ }
+ $ret = [];
+ foreach ( $batched as $cluster => $batchByCluster ) {
+ $res = $this->batchFetchBlobs( $cluster, $batchByCluster );
+ /** @var HistoryBlob $blob */
+ foreach ( $res as $id => $blob ) {
+ foreach ( $batchByCluster[$id] as $itemID ) {
+ $url = $inverseUrlMap[$cluster][$id][$itemID];
+ if ( $itemID === false ) {
+ $ret[$url] = $blob;
+ } else {
+ $ret[$url] = $blob->getItem( $itemID );
+ }
+ }
+ }
+ }
+
+ return $ret;
+ }
+
+ public function store( $location, $data ) {
+ $dbw = $this->getMaster( $location );
+ $dbw->insert( $this->getTable( $dbw ),
+ [ 'blob_text' => $data ],
+ __METHOD__ );
+ $id = $dbw->insertId();
+ if ( !$id ) {
+ throw new MWException( __METHOD__ . ': no insert ID' );
+ }
+
+ return "DB://$location/$id";
+ }
+
+ /**
+ * Get a LoadBalancer for the specified cluster
+ *
+ * @param string $cluster Cluster name
+ * @return LoadBalancer
+ */
+ private function getLoadBalancer( $cluster ) {
+ return wfGetLBFactory()->getExternalLB( $cluster );
+ }
+
+ /**
+ * Get a replica DB connection for the specified cluster
+ *
+ * @param string $cluster Cluster name
+ * @return DBConnRef
+ */
+ public function getSlave( $cluster ) {
+ global $wgDefaultExternalStore;
+
+ $wiki = isset( $this->params['wiki'] ) ? $this->params['wiki'] : false;
+ $lb = $this->getLoadBalancer( $cluster );
+
+ if ( !in_array( "DB://" . $cluster, (array)$wgDefaultExternalStore ) ) {
+ wfDebug( "read only external store\n" );
+ $lb->allowLagged( true );
+ } else {
+ wfDebug( "writable external store\n" );
+ }
+
+ $db = $lb->getConnectionRef( DB_REPLICA, [], $wiki );
+ $db->clearFlag( DBO_TRX ); // sanity
+
+ return $db;
+ }
+
+ /**
+ * Get a master database connection for the specified cluster
+ *
+ * @param string $cluster Cluster name
+ * @return MaintainableDBConnRef
+ */
+ public function getMaster( $cluster ) {
+ $wiki = isset( $this->params['wiki'] ) ? $this->params['wiki'] : false;
+ $lb = $this->getLoadBalancer( $cluster );
+
+ $db = $lb->getMaintenanceConnectionRef( DB_MASTER, [], $wiki );
+ $db->clearFlag( DBO_TRX ); // sanity
+
+ return $db;
+ }
+
+ /**
+ * Get the 'blobs' table name for this database
+ *
+ * @param IDatabase $db
+ * @return string Table name ('blobs' by default)
+ */
+ public function getTable( $db ) {
+ $table = $db->getLBInfo( 'blobs table' );
+ if ( is_null( $table ) ) {
+ $table = 'blobs';
+ }
+
+ return $table;
+ }
+
+ /**
+ * Fetch a blob item out of the database; a cache of the last-loaded
+ * blob will be kept so that multiple loads out of a multi-item blob
+ * can avoid redundant database access and decompression.
+ * @param string $cluster
+ * @param string $id
+ * @param string $itemID
+ * @return HistoryBlob|bool Returns false if missing
+ */
+ private function fetchBlob( $cluster, $id, $itemID ) {
+ /**
+ * One-step cache variable to hold base blobs; operations that
+ * pull multiple revisions may often pull multiple times from
+ * the same blob. By keeping the last-used one open, we avoid
+ * redundant unserialization and decompression overhead.
+ */
+ static $externalBlobCache = [];
+
+ $cacheID = ( $itemID === false ) ? "$cluster/$id" : "$cluster/$id/";
+ if ( isset( $externalBlobCache[$cacheID] ) ) {
+ wfDebugLog( 'ExternalStoreDB-cache',
+ "ExternalStoreDB::fetchBlob cache hit on $cacheID" );
+
+ return $externalBlobCache[$cacheID];
+ }
+
+ wfDebugLog( 'ExternalStoreDB-cache',
+ "ExternalStoreDB::fetchBlob cache miss on $cacheID" );
+
+ $dbr = $this->getSlave( $cluster );
+ $ret = $dbr->selectField( $this->getTable( $dbr ),
+ 'blob_text', [ 'blob_id' => $id ], __METHOD__ );
+ if ( $ret === false ) {
+ wfDebugLog( 'ExternalStoreDB',
+ "ExternalStoreDB::fetchBlob master fallback on $cacheID" );
+ // Try the master
+ $dbw = $this->getMaster( $cluster );
+ $ret = $dbw->selectField( $this->getTable( $dbw ),
+ 'blob_text', [ 'blob_id' => $id ], __METHOD__ );
+ if ( $ret === false ) {
+ wfDebugLog( 'ExternalStoreDB',
+ "ExternalStoreDB::fetchBlob master failed to find $cacheID" );
+ }
+ }
+ if ( $itemID !== false && $ret !== false ) {
+ // Unserialise object; caller extracts item
+ $ret = unserialize( $ret );
+ }
+
+ $externalBlobCache = [ $cacheID => $ret ];
+
+ return $ret;
+ }
+
+ /**
+ * Fetch multiple blob items out of the database
+ *
+ * @param string $cluster A cluster name valid for use with LBFactory
+ * @param array $ids A map from the blob_id's to look for to the requested itemIDs in the blobs
+ * @return array A map from the blob_id's requested to their content.
+ * Unlocated ids are not represented
+ */
+ private function batchFetchBlobs( $cluster, array $ids ) {
+ $dbr = $this->getSlave( $cluster );
+ $res = $dbr->select( $this->getTable( $dbr ),
+ [ 'blob_id', 'blob_text' ], [ 'blob_id' => array_keys( $ids ) ], __METHOD__ );
+ $ret = [];
+ if ( $res !== false ) {
+ $this->mergeBatchResult( $ret, $ids, $res );
+ }
+ if ( $ids ) {
+ wfDebugLog( __CLASS__, __METHOD__ .
+ " master fallback on '$cluster' for: " .
+ implode( ',', array_keys( $ids ) ) );
+ // Try the master
+ $dbw = $this->getMaster( $cluster );
+ $res = $dbw->select( $this->getTable( $dbr ),
+ [ 'blob_id', 'blob_text' ],
+ [ 'blob_id' => array_keys( $ids ) ],
+ __METHOD__ );
+ if ( $res === false ) {
+ wfDebugLog( __CLASS__, __METHOD__ . " master failed on '$cluster'" );
+ } else {
+ $this->mergeBatchResult( $ret, $ids, $res );
+ }
+ }
+ if ( $ids ) {
+ wfDebugLog( __CLASS__, __METHOD__ .
+ " master on '$cluster' failed locating items: " .
+ implode( ',', array_keys( $ids ) ) );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Helper function for self::batchFetchBlobs for merging master/replica DB results
+ * @param array &$ret Current self::batchFetchBlobs return value
+ * @param array &$ids Map from blob_id to requested itemIDs
+ * @param mixed $res DB result from Database::select
+ */
+ private function mergeBatchResult( array &$ret, array &$ids, $res ) {
+ foreach ( $res as $row ) {
+ $id = $row->blob_id;
+ $itemIDs = $ids[$id];
+ unset( $ids[$id] ); // to track if everything is found
+ if ( count( $itemIDs ) === 1 && reset( $itemIDs ) === false ) {
+ // single result stored per blob
+ $ret[$id] = $row->blob_text;
+ } else {
+ // multi result stored per blob
+ $ret[$id] = unserialize( $row->blob_text );
+ }
+ }
+ }
+
+ /**
+ * @param string $url
+ * @return array
+ */
+ protected function parseURL( $url ) {
+ $path = explode( '/', $url );
+
+ return [
+ $path[2], // cluster
+ $path[3], // id
+ isset( $path[4] ) ? $path[4] : false // itemID
+ ];
+ }
+}
diff --git a/www/wiki/includes/externalstore/ExternalStoreHttp.php b/www/wiki/includes/externalstore/ExternalStoreHttp.php
new file mode 100644
index 00000000..8e1e49fa
--- /dev/null
+++ b/www/wiki/includes/externalstore/ExternalStoreHttp.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * External storage using HTTP requests.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Example class for HTTP accessable external objects.
+ * Only supports reading, not storing.
+ *
+ * @ingroup ExternalStorage
+ */
+class ExternalStoreHttp extends ExternalStoreMedium {
+ /**
+ * @see ExternalStoreMedium::fetchFromURL()
+ * @param string $url
+ * @return string|bool
+ * @throws MWException
+ */
+ public function fetchFromURL( $url ) {
+ return Http::get( $url, [], __METHOD__ );
+ }
+
+ /**
+ * @see ExternalStoreMedium::store()
+ * @param string $cluster
+ * @param string $data
+ * @return string|bool
+ * @throws MWException
+ */
+ public function store( $cluster, $data ) {
+ throw new MWException( "ExternalStoreHttp is read-only and does not support store()." );
+ }
+}
diff --git a/www/wiki/includes/externalstore/ExternalStoreMedium.php b/www/wiki/includes/externalstore/ExternalStoreMedium.php
new file mode 100644
index 00000000..6cfa0838
--- /dev/null
+++ b/www/wiki/includes/externalstore/ExternalStoreMedium.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * External storage in some particular medium.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup ExternalStorage
+ */
+
+/**
+ * Accessable external objects in a particular storage medium
+ *
+ * @ingroup ExternalStorage
+ * @since 1.21
+ */
+abstract class ExternalStoreMedium {
+ /** @var array */
+ protected $params = [];
+
+ /**
+ * @param array $params Options
+ */
+ public function __construct( array $params = [] ) {
+ $this->params = $params;
+ }
+
+ /**
+ * Fetch data from given external store URL
+ *
+ * @param string $url An external store URL
+ * @return string|bool The text stored or false on error
+ * @throws MWException
+ */
+ abstract public function fetchFromURL( $url );
+
+ /**
+ * Fetch data from given external store URLs.
+ *
+ * @param array $urls A list of external store URLs
+ * @return array Map from the url to the text stored. Unfound data is not represented
+ */
+ public function batchFetchFromURLs( array $urls ) {
+ $retval = [];
+ foreach ( $urls as $url ) {
+ $data = $this->fetchFromURL( $url );
+ // Dont return when false to allow for simpler implementations.
+ // errored urls are handled in ExternalStore::batchFetchFromURLs
+ if ( $data !== false ) {
+ $retval[$url] = $data;
+ }
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Insert a data item into a given location
+ *
+ * @param string $location The location name
+ * @param string $data The data item
+ * @return string|bool The URL of the stored data item, or false on error
+ * @throws MWException
+ */
+ abstract public function store( $location, $data );
+}
diff --git a/www/wiki/includes/externalstore/ExternalStoreMwstore.php b/www/wiki/includes/externalstore/ExternalStoreMwstore.php
new file mode 100644
index 00000000..5395f562
--- /dev/null
+++ b/www/wiki/includes/externalstore/ExternalStoreMwstore.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * External storage in a file backend.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * File backend accessable external objects.
+ *
+ * In this system, each store "location" maps to the name of a file backend.
+ * The file backends must be defined in $wgFileBackends and must be global
+ * and fully qualified with a global "wikiId" prefix in the configuration.
+ *
+ * @ingroup ExternalStorage
+ * @since 1.21
+ */
+class ExternalStoreMwstore extends ExternalStoreMedium {
+ /**
+ * The URL returned is of the form of the form mwstore://backend/container/wiki/id
+ *
+ * @see ExternalStoreMedium::fetchFromURL()
+ * @param string $url
+ * @return bool
+ */
+ public function fetchFromURL( $url ) {
+ $be = FileBackendGroup::singleton()->backendFromPath( $url );
+ if ( $be instanceof FileBackend ) {
+ // We don't need "latest" since objects are immutable and
+ // backends should at least have "read-after-create" consistency.
+ return $be->getFileContents( [ 'src' => $url ] );
+ }
+
+ return false;
+ }
+
+ /**
+ * Fetch data from given external store URLs.
+ * The URL returned is of the form of the form mwstore://backend/container/wiki/id
+ *
+ * @param array $urls An array of external store URLs
+ * @return array A map from url to stored content. Failed results are not represented.
+ */
+ public function batchFetchFromURLs( array $urls ) {
+ $pathsByBackend = [];
+ foreach ( $urls as $url ) {
+ $be = FileBackendGroup::singleton()->backendFromPath( $url );
+ if ( $be instanceof FileBackend ) {
+ $pathsByBackend[$be->getName()][] = $url;
+ }
+ }
+ $blobs = [];
+ foreach ( $pathsByBackend as $backendName => $paths ) {
+ $be = FileBackendGroup::singleton()->get( $backendName );
+ $blobs = $blobs + $be->getFileContentsMulti( [ 'srcs' => $paths ] );
+ }
+
+ return $blobs;
+ }
+
+ /**
+ * @see ExternalStoreMedium::store()
+ * @param string $backend
+ * @param string $data
+ * @return string|bool
+ * @throws MWException
+ */
+ public function store( $backend, $data ) {
+ $be = FileBackendGroup::singleton()->get( $backend );
+ if ( $be instanceof FileBackend ) {
+ // Get three random base 36 characters to act as shard directories
+ $rand = Wikimedia\base_convert( mt_rand( 0, 46655 ), 10, 36, 3 );
+ // Make sure ID is roughly lexicographically increasing for performance
+ $id = str_pad( UIDGenerator::newTimestampedUID128( 32 ), 26, '0', STR_PAD_LEFT );
+ // Segregate items by wiki ID for the sake of bookkeeping
+ $wiki = isset( $this->params['wiki'] ) ? $this->params['wiki'] : wfWikiID();
+
+ $url = $be->getContainerStoragePath( 'data' ) . '/' . rawurlencode( $wiki );
+ $url .= ( $be instanceof FSFileBackend )
+ ? "/{$rand[0]}/{$rand[1]}/{$rand[2]}/{$id}" // keep directories small
+ : "/{$rand[0]}/{$rand[1]}/{$id}"; // container sharding is only 2-levels
+
+ $be->prepare( [ 'dir' => dirname( $url ), 'noAccess' => 1, 'noListing' => 1 ] );
+ if ( $be->create( [ 'dst' => $url, 'content' => $data ] )->isOK() ) {
+ return $url;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/filebackend/FSFile.php b/www/wiki/includes/filebackend/FSFile.php
new file mode 100644
index 00000000..8aa11b65
--- /dev/null
+++ b/www/wiki/includes/filebackend/FSFile.php
@@ -0,0 +1,280 @@
+<?php
+/**
+ * Non-directory file on the file system.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Class representing a non-directory file on the file system
+ *
+ * @ingroup FileBackend
+ */
+class FSFile {
+ /** @var string Path to file */
+ protected $path;
+
+ /** @var string File SHA-1 in base 36 */
+ protected $sha1Base36;
+
+ /**
+ * Sets up the file object
+ *
+ * @param string $path Path to temporary file on local disk
+ */
+ public function __construct( $path ) {
+ $this->path = $path;
+ }
+
+ /**
+ * Returns the file system path
+ *
+ * @return string
+ */
+ public function getPath() {
+ return $this->path;
+ }
+
+ /**
+ * Checks if the file exists
+ *
+ * @return bool
+ */
+ public function exists() {
+ return is_file( $this->path );
+ }
+
+ /**
+ * Get the file size in bytes
+ *
+ * @return int|bool
+ */
+ public function getSize() {
+ return filesize( $this->path );
+ }
+
+ /**
+ * Get the file's last-modified timestamp
+ *
+ * @return string|bool TS_MW timestamp or false on failure
+ */
+ public function getTimestamp() {
+ MediaWiki\suppressWarnings();
+ $timestamp = filemtime( $this->path );
+ MediaWiki\restoreWarnings();
+ if ( $timestamp !== false ) {
+ $timestamp = wfTimestamp( TS_MW, $timestamp );
+ }
+
+ return $timestamp;
+ }
+
+ /**
+ * Guess the MIME type from the file contents alone
+ *
+ * @return string
+ */
+ public function getMimeType() {
+ return MimeMagic::singleton()->guessMimeType( $this->path, false );
+ }
+
+ /**
+ * Get an associative array containing information about
+ * a file with the given storage path.
+ *
+ * Resulting array fields include:
+ * - fileExists
+ * - size (filesize in bytes)
+ * - mime (as major/minor)
+ * - media_type (value to be used with the MEDIATYPE_xxx constants)
+ * - metadata (handler specific)
+ * - sha1 (in base 36)
+ * - width
+ * - height
+ * - bits (bitrate)
+ * - file-mime
+ * - major_mime
+ * - minor_mime
+ *
+ * @param string|bool $ext The file extension, or true to extract it from the filename.
+ * Set it to false to ignore the extension.
+ * @return array
+ */
+ public function getProps( $ext = true ) {
+ wfDebug( __METHOD__ . ": Getting file info for $this->path\n" );
+
+ $info = self::placeholderProps();
+ $info['fileExists'] = $this->exists();
+
+ if ( $info['fileExists'] ) {
+ $magic = MimeMagic::singleton();
+
+ # get the file extension
+ if ( $ext === true ) {
+ $ext = self::extensionFromPath( $this->path );
+ }
+
+ # MIME type according to file contents
+ $info['file-mime'] = $this->getMimeType();
+ # logical MIME type
+ $info['mime'] = $magic->improveTypeFromExtension( $info['file-mime'], $ext );
+
+ list( $info['major_mime'], $info['minor_mime'] ) = File::splitMime( $info['mime'] );
+ $info['media_type'] = $magic->getMediaType( $this->path, $info['mime'] );
+
+ # Get size in bytes
+ $info['size'] = $this->getSize();
+
+ # Height, width and metadata
+ $handler = MediaHandler::getHandler( $info['mime'] );
+ if ( $handler ) {
+ $tempImage = (object)[]; // XXX (hack for File object)
+ $info['metadata'] = $handler->getMetadata( $tempImage, $this->path );
+ $gis = $handler->getImageSize( $tempImage, $this->path, $info['metadata'] );
+ if ( is_array( $gis ) ) {
+ $info = $this->extractImageSizeInfo( $gis ) + $info;
+ }
+ }
+ $info['sha1'] = $this->getSha1Base36();
+
+ wfDebug( __METHOD__ . ": $this->path loaded, {$info['size']} bytes, {$info['mime']}.\n" );
+ } else {
+ wfDebug( __METHOD__ . ": $this->path NOT FOUND!\n" );
+ }
+
+ return $info;
+ }
+
+ /**
+ * Placeholder file properties to use for files that don't exist
+ *
+ * Resulting array fields include:
+ * - fileExists
+ * - mime (as major/minor)
+ * - media_type (value to be used with the MEDIATYPE_xxx constants)
+ * - metadata (handler specific)
+ * - sha1 (in base 36)
+ * - width
+ * - height
+ * - bits (bitrate)
+ *
+ * @return array
+ */
+ public static function placeholderProps() {
+ $info = [];
+ $info['fileExists'] = false;
+ $info['mime'] = null;
+ $info['media_type'] = MEDIATYPE_UNKNOWN;
+ $info['metadata'] = '';
+ $info['sha1'] = '';
+ $info['width'] = 0;
+ $info['height'] = 0;
+ $info['bits'] = 0;
+
+ return $info;
+ }
+
+ /**
+ * Exract image size information
+ *
+ * @param array $gis
+ * @return array
+ */
+ protected function extractImageSizeInfo( array $gis ) {
+ $info = [];
+ # NOTE: $gis[2] contains a code for the image type. This is no longer used.
+ $info['width'] = $gis[0];
+ $info['height'] = $gis[1];
+ if ( isset( $gis['bits'] ) ) {
+ $info['bits'] = $gis['bits'];
+ } else {
+ $info['bits'] = 0;
+ }
+
+ return $info;
+ }
+
+ /**
+ * Get a SHA-1 hash of a file in the local filesystem, in base-36 lower case
+ * encoding, zero padded to 31 digits.
+ *
+ * 160 log 2 / log 36 = 30.95, so the 160-bit hash fills 31 digits in base 36
+ * fairly neatly.
+ *
+ * @param bool $recache
+ * @return bool|string False on failure
+ */
+ public function getSha1Base36( $recache = false ) {
+ if ( $this->sha1Base36 !== null && !$recache ) {
+ return $this->sha1Base36;
+ }
+
+ MediaWiki\suppressWarnings();
+ $this->sha1Base36 = sha1_file( $this->path );
+ MediaWiki\restoreWarnings();
+
+ if ( $this->sha1Base36 !== false ) {
+ $this->sha1Base36 = Wikimedia\base_convert( $this->sha1Base36, 16, 36, 31 );
+ }
+
+ return $this->sha1Base36;
+ }
+
+ /**
+ * Get the final file extension from a file system path
+ *
+ * @param string $path
+ * @return string
+ */
+ public static function extensionFromPath( $path ) {
+ $i = strrpos( $path, '.' );
+
+ return strtolower( $i ? substr( $path, $i + 1 ) : '' );
+ }
+
+ /**
+ * Get an associative array containing information about a file in the local filesystem.
+ *
+ * @param string $path Absolute local filesystem path
+ * @param string|bool $ext The file extension, or true to extract it from the filename.
+ * Set it to false to ignore the extension.
+ * @return array
+ */
+ public static function getPropsFromPath( $path, $ext = true ) {
+ $fsFile = new self( $path );
+
+ return $fsFile->getProps( $ext );
+ }
+
+ /**
+ * Get a SHA-1 hash of a file in the local filesystem, in base-36 lower case
+ * encoding, zero padded to 31 digits.
+ *
+ * 160 log 2 / log 36 = 30.95, so the 160-bit hash fills 31 digits in base 36
+ * fairly neatly.
+ *
+ * @param string $path
+ * @return bool|string False on failure
+ */
+ public static function getSha1Base36FromPath( $path ) {
+ $fsFile = new self( $path );
+
+ return $fsFile->getSha1Base36();
+ }
+}
diff --git a/www/wiki/includes/filebackend/FSFileBackend.php b/www/wiki/includes/filebackend/FSFileBackend.php
new file mode 100644
index 00000000..efe78ee2
--- /dev/null
+++ b/www/wiki/includes/filebackend/FSFileBackend.php
@@ -0,0 +1,975 @@
+<?php
+/**
+ * File system based backend.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Aaron Schulz
+ */
+
+/**
+ * @brief Class for a file system (FS) based file backend.
+ *
+ * All "containers" each map to a directory under the backend's base directory.
+ * For backwards-compatibility, some container paths can be set to custom paths.
+ * The wiki ID will not be used in any custom paths, so this should be avoided.
+ *
+ * Having directories with thousands of files will diminish performance.
+ * Sharding can be accomplished by using FileRepo-style hash paths.
+ *
+ * Status messages should avoid mentioning the internal FS paths.
+ * PHP warnings are assumed to be logged rather than output.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+class FSFileBackend extends FileBackendStore {
+ /** @var string Directory holding the container directories */
+ protected $basePath;
+
+ /** @var array Map of container names to root paths for custom container paths */
+ protected $containerPaths = [];
+
+ /** @var int File permission mode */
+ protected $fileMode;
+
+ /** @var string Required OS username to own files */
+ protected $fileOwner;
+
+ /** @var string OS username running this script */
+ protected $currentUser;
+
+ /** @var array */
+ protected $hadWarningErrors = [];
+
+ /**
+ * @see FileBackendStore::__construct()
+ * Additional $config params include:
+ * - basePath : File system directory that holds containers.
+ * - containerPaths : Map of container names to custom file system directories.
+ * This should only be used for backwards-compatibility.
+ * - fileMode : Octal UNIX file permissions to use on files stored.
+ * @param array $config
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ // Remove any possible trailing slash from directories
+ if ( isset( $config['basePath'] ) ) {
+ $this->basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash
+ } else {
+ $this->basePath = null; // none; containers must have explicit paths
+ }
+
+ if ( isset( $config['containerPaths'] ) ) {
+ $this->containerPaths = (array)$config['containerPaths'];
+ foreach ( $this->containerPaths as &$path ) {
+ $path = rtrim( $path, '/' ); // remove trailing slash
+ }
+ }
+
+ $this->fileMode = isset( $config['fileMode'] ) ? $config['fileMode'] : 0644;
+ if ( isset( $config['fileOwner'] ) && function_exists( 'posix_getuid' ) ) {
+ $this->fileOwner = $config['fileOwner'];
+ // cache this, assuming it doesn't change
+ $this->currentUser = posix_getpwuid( posix_getuid() )['name'];
+ }
+ }
+
+ public function getFeatures() {
+ return !wfIsWindows() ? FileBackend::ATTR_UNICODE_PATHS : 0;
+ }
+
+ protected function resolveContainerPath( $container, $relStoragePath ) {
+ // Check that container has a root directory
+ if ( isset( $this->containerPaths[$container] ) || isset( $this->basePath ) ) {
+ // Check for sane relative paths (assume the base paths are OK)
+ if ( $this->isLegalRelPath( $relStoragePath ) ) {
+ return $relStoragePath;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Sanity check a relative file system path for validity
+ *
+ * @param string $path Normalized relative path
+ * @return bool
+ */
+ protected function isLegalRelPath( $path ) {
+ // Check for file names longer than 255 chars
+ if ( preg_match( '![^/]{256}!', $path ) ) { // ext3/NTFS
+ return false;
+ }
+ if ( wfIsWindows() ) { // NTFS
+ return !preg_match( '![:*?"<>|]!', $path );
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Given the short (unresolved) and full (resolved) name of
+ * a container, return the file system path of the container.
+ *
+ * @param string $shortCont
+ * @param string $fullCont
+ * @return string|null
+ */
+ protected function containerFSRoot( $shortCont, $fullCont ) {
+ if ( isset( $this->containerPaths[$shortCont] ) ) {
+ return $this->containerPaths[$shortCont];
+ } elseif ( isset( $this->basePath ) ) {
+ return "{$this->basePath}/{$fullCont}";
+ }
+
+ return null; // no container base path defined
+ }
+
+ /**
+ * Get the absolute file system path for a storage path
+ *
+ * @param string $storagePath Storage path
+ * @return string|null
+ */
+ protected function resolveToFSPath( $storagePath ) {
+ list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath );
+ if ( $relPath === null ) {
+ return null; // invalid
+ }
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $storagePath );
+ $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ if ( $relPath != '' ) {
+ $fsPath .= "/{$relPath}";
+ }
+
+ return $fsPath;
+ }
+
+ public function isPathUsableInternal( $storagePath ) {
+ $fsPath = $this->resolveToFSPath( $storagePath );
+ if ( $fsPath === null ) {
+ return false; // invalid
+ }
+ $parentDir = dirname( $fsPath );
+
+ if ( file_exists( $fsPath ) ) {
+ $ok = is_file( $fsPath ) && is_writable( $fsPath );
+ } else {
+ $ok = is_dir( $parentDir ) && is_writable( $parentDir );
+ }
+
+ if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) {
+ $ok = false;
+ trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." );
+ }
+
+ return $ok;
+ }
+
+ protected function doCreateInternal( array $params ) {
+ $status = Status::newGood();
+
+ $dest = $this->resolveToFSPath( $params['dst'] );
+ if ( $dest === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $tempFile = TempFSFile::factory( 'create_', 'tmp' );
+ if ( !$tempFile ) {
+ $status->fatal( 'backend-fail-create', $params['dst'] );
+
+ return $status;
+ }
+ $this->trapWarnings();
+ $bytes = file_put_contents( $tempFile->getPath(), $params['content'] );
+ $this->untrapWarnings();
+ if ( $bytes === false ) {
+ $status->fatal( 'backend-fail-create', $params['dst'] );
+
+ return $status;
+ }
+ $cmd = implode( ' ', [
+ wfIsWindows() ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
+ wfEscapeShellArg( $this->cleanPathSlashes( $tempFile->getPath() ) ),
+ wfEscapeShellArg( $this->cleanPathSlashes( $dest ) )
+ ] );
+ $handler = function ( $errors, Status $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-create', $params['dst'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
+ $tempFile->bind( $status->value );
+ } else { // immediate write
+ $this->trapWarnings();
+ $bytes = file_put_contents( $dest, $params['content'] );
+ $this->untrapWarnings();
+ if ( $bytes === false ) {
+ $status->fatal( 'backend-fail-create', $params['dst'] );
+
+ return $status;
+ }
+ $this->chmod( $dest );
+ }
+
+ return $status;
+ }
+
+ protected function doStoreInternal( array $params ) {
+ $status = Status::newGood();
+
+ $dest = $this->resolveToFSPath( $params['dst'] );
+ if ( $dest === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $cmd = implode( ' ', [
+ wfIsWindows() ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
+ wfEscapeShellArg( $this->cleanPathSlashes( $params['src'] ) ),
+ wfEscapeShellArg( $this->cleanPathSlashes( $dest ) )
+ ] );
+ $handler = function ( $errors, Status $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
+ } else { // immediate write
+ $this->trapWarnings();
+ $ok = copy( $params['src'], $dest );
+ $this->untrapWarnings();
+ // In some cases (at least over NFS), copy() returns true when it fails
+ if ( !$ok || ( filesize( $params['src'] ) !== filesize( $dest ) ) ) {
+ if ( $ok ) { // PHP bug
+ unlink( $dest ); // remove broken file
+ trigger_error( __METHOD__ . ": copy() failed but returned true." );
+ }
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+ $this->chmod( $dest );
+ }
+
+ return $status;
+ }
+
+ protected function doCopyInternal( array $params ) {
+ $status = Status::newGood();
+
+ $source = $this->resolveToFSPath( $params['src'] );
+ if ( $source === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $dest = $this->resolveToFSPath( $params['dst'] );
+ if ( $dest === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !is_file( $source ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-copy', $params['src'] );
+ }
+
+ return $status; // do nothing; either OK or bad status
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $cmd = implode( ' ', [
+ wfIsWindows() ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
+ wfEscapeShellArg( $this->cleanPathSlashes( $source ) ),
+ wfEscapeShellArg( $this->cleanPathSlashes( $dest ) )
+ ] );
+ $handler = function ( $errors, Status $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
+ } else { // immediate write
+ $this->trapWarnings();
+ $ok = ( $source === $dest ) ? true : copy( $source, $dest );
+ $this->untrapWarnings();
+ // In some cases (at least over NFS), copy() returns true when it fails
+ if ( !$ok || ( filesize( $source ) !== filesize( $dest ) ) ) {
+ if ( $ok ) { // PHP bug
+ $this->trapWarnings();
+ unlink( $dest ); // remove broken file
+ $this->untrapWarnings();
+ trigger_error( __METHOD__ . ": copy() failed but returned true." );
+ }
+ $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+ $this->chmod( $dest );
+ }
+
+ return $status;
+ }
+
+ protected function doMoveInternal( array $params ) {
+ $status = Status::newGood();
+
+ $source = $this->resolveToFSPath( $params['src'] );
+ if ( $source === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $dest = $this->resolveToFSPath( $params['dst'] );
+ if ( $dest === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !is_file( $source ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-move', $params['src'] );
+ }
+
+ return $status; // do nothing; either OK or bad status
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $cmd = implode( ' ', [
+ wfIsWindows() ? 'MOVE /Y' : 'mv', // (overwrite)
+ wfEscapeShellArg( $this->cleanPathSlashes( $source ) ),
+ wfEscapeShellArg( $this->cleanPathSlashes( $dest ) )
+ ] );
+ $handler = function ( $errors, Status $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
+ } else { // immediate write
+ $this->trapWarnings();
+ $ok = ( $source === $dest ) ? true : rename( $source, $dest );
+ $this->untrapWarnings();
+ clearstatcache(); // file no longer at source
+ if ( !$ok ) {
+ $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+ }
+
+ return $status;
+ }
+
+ protected function doDeleteInternal( array $params ) {
+ $status = Status::newGood();
+
+ $source = $this->resolveToFSPath( $params['src'] );
+ if ( $source === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ if ( !is_file( $source ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+ }
+
+ return $status; // do nothing; either OK or bad status
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $cmd = implode( ' ', [
+ wfIsWindows() ? 'DEL' : 'unlink',
+ wfEscapeShellArg( $this->cleanPathSlashes( $source ) )
+ ] );
+ $handler = function ( $errors, Status $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( wfIsWindows() && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
+ } else { // immediate write
+ $this->trapWarnings();
+ $ok = unlink( $source );
+ $this->untrapWarnings();
+ if ( !$ok ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+
+ return $status;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param string $fullCont
+ * @param string $dirRel
+ * @param array $params
+ * @return Status
+ */
+ protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
+ $status = Status::newGood();
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ $existed = is_dir( $dir ); // already there?
+ // Create the directory and its parents as needed...
+ $this->trapWarnings();
+ if ( !wfMkdirParents( $dir ) ) {
+ wfDebugLog( 'FSFileBackend', __METHOD__ . ": cannot create directory $dir" );
+ $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
+ } elseif ( !is_writable( $dir ) ) {
+ wfDebugLog( 'FSFileBackend', __METHOD__ . ": directory $dir is read-only" );
+ $status->fatal( 'directoryreadonlyerror', $params['dir'] );
+ } elseif ( !is_readable( $dir ) ) {
+ wfDebugLog( 'FSFileBackend', __METHOD__ . ": directory $dir is not readable" );
+ $status->fatal( 'directorynotreadableerror', $params['dir'] );
+ }
+ $this->untrapWarnings();
+ // Respect any 'noAccess' or 'noListing' flags...
+ if ( is_dir( $dir ) && !$existed ) {
+ $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
+ }
+
+ return $status;
+ }
+
+ protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
+ $status = Status::newGood();
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ // Seed new directories with a blank index.html, to prevent crawling...
+ if ( !empty( $params['noListing'] ) && !file_exists( "{$dir}/index.html" ) ) {
+ $this->trapWarnings();
+ $bytes = file_put_contents( "{$dir}/index.html", $this->indexHtmlPrivate() );
+ $this->untrapWarnings();
+ if ( $bytes === false ) {
+ $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' );
+ }
+ }
+ // Add a .htaccess file to the root of the container...
+ if ( !empty( $params['noAccess'] ) && !file_exists( "{$contRoot}/.htaccess" ) ) {
+ $this->trapWarnings();
+ $bytes = file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() );
+ $this->untrapWarnings();
+ if ( $bytes === false ) {
+ $storeDir = "mwstore://{$this->name}/{$shortCont}";
+ $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
+ }
+ }
+
+ return $status;
+ }
+
+ protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
+ $status = Status::newGood();
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ // Unseed new directories with a blank index.html, to allow crawling...
+ if ( !empty( $params['listing'] ) && is_file( "{$dir}/index.html" ) ) {
+ $exists = ( file_get_contents( "{$dir}/index.html" ) === $this->indexHtmlPrivate() );
+ $this->trapWarnings();
+ if ( $exists && !unlink( "{$dir}/index.html" ) ) { // reverse secure()
+ $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
+ }
+ $this->untrapWarnings();
+ }
+ // Remove the .htaccess file from the root of the container...
+ if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) {
+ $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() );
+ $this->trapWarnings();
+ if ( $exists && !unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
+ $storeDir = "mwstore://{$this->name}/{$shortCont}";
+ $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
+ }
+ $this->untrapWarnings();
+ }
+
+ return $status;
+ }
+
+ protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
+ $status = Status::newGood();
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ $this->trapWarnings();
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir ); // remove directory if empty
+ }
+ $this->untrapWarnings();
+
+ return $status;
+ }
+
+ protected function doGetFileStat( array $params ) {
+ $source = $this->resolveToFSPath( $params['src'] );
+ if ( $source === null ) {
+ return false; // invalid storage path
+ }
+
+ $this->trapWarnings(); // don't trust 'false' if there were errors
+ $stat = is_file( $source ) ? stat( $source ) : false; // regular files only
+ $hadError = $this->untrapWarnings();
+
+ if ( $stat ) {
+ return [
+ 'mtime' => wfTimestamp( TS_MW, $stat['mtime'] ),
+ 'size' => $stat['size']
+ ];
+ } elseif ( !$hadError ) {
+ return false; // file does not exist
+ } else {
+ return null; // failure
+ }
+ }
+
+ protected function doClearCache( array $paths = null ) {
+ clearstatcache(); // clear the PHP file stat cache
+ }
+
+ protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+
+ $this->trapWarnings(); // don't trust 'false' if there were errors
+ $exists = is_dir( $dir );
+ $hadError = $this->untrapWarnings();
+
+ return $hadError ? null : $exists;
+ }
+
+ /**
+ * @see FileBackendStore::getDirectoryListInternal()
+ * @param string $fullCont
+ * @param string $dirRel
+ * @param array $params
+ * @return array|null
+ */
+ public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ $exists = is_dir( $dir );
+ if ( !$exists ) {
+ wfDebug( __METHOD__ . "() given directory does not exist: '$dir'\n" );
+
+ return []; // nothing under this dir
+ } elseif ( !is_readable( $dir ) ) {
+ wfDebug( __METHOD__ . "() given directory is unreadable: '$dir'\n" );
+
+ return null; // bad permissions?
+ }
+
+ return new FSFileBackendDirList( $dir, $params );
+ }
+
+ /**
+ * @see FileBackendStore::getFileListInternal()
+ * @param string $fullCont
+ * @param string $dirRel
+ * @param array $params
+ * @return array|FSFileBackendFileList|null
+ */
+ public function getFileListInternal( $fullCont, $dirRel, array $params ) {
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ $exists = is_dir( $dir );
+ if ( !$exists ) {
+ wfDebug( __METHOD__ . "() given directory does not exist: '$dir'\n" );
+
+ return []; // nothing under this dir
+ } elseif ( !is_readable( $dir ) ) {
+ wfDebug( __METHOD__ . "() given directory is unreadable: '$dir'\n" );
+
+ return null; // bad permissions?
+ }
+
+ return new FSFileBackendFileList( $dir, $params );
+ }
+
+ protected function doGetLocalReferenceMulti( array $params ) {
+ $fsFiles = []; // (path => FSFile)
+
+ foreach ( $params['srcs'] as $src ) {
+ $source = $this->resolveToFSPath( $src );
+ if ( $source === null || !is_file( $source ) ) {
+ $fsFiles[$src] = null; // invalid path or file does not exist
+ } else {
+ $fsFiles[$src] = new FSFile( $source );
+ }
+ }
+
+ return $fsFiles;
+ }
+
+ protected function doGetLocalCopyMulti( array $params ) {
+ $tmpFiles = []; // (path => TempFSFile)
+
+ foreach ( $params['srcs'] as $src ) {
+ $source = $this->resolveToFSPath( $src );
+ if ( $source === null ) {
+ $tmpFiles[$src] = null; // invalid path
+ } else {
+ // Create a new temporary file with the same extension...
+ $ext = FileBackend::extensionFromPath( $src );
+ $tmpFile = TempFSFile::factory( 'localcopy_', $ext );
+ if ( !$tmpFile ) {
+ $tmpFiles[$src] = null;
+ } else {
+ $tmpPath = $tmpFile->getPath();
+ // Copy the source file over the temp file
+ $this->trapWarnings();
+ $ok = copy( $source, $tmpPath );
+ $this->untrapWarnings();
+ if ( !$ok ) {
+ $tmpFiles[$src] = null;
+ } else {
+ $this->chmod( $tmpPath );
+ $tmpFiles[$src] = $tmpFile;
+ }
+ }
+ }
+ }
+
+ return $tmpFiles;
+ }
+
+ protected function directoriesAreVirtual() {
+ return false;
+ }
+
+ /**
+ * @param FSFileOpHandle[] $fileOpHandles
+ *
+ * @return Status[]
+ */
+ protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
+ $statuses = [];
+
+ $pipes = [];
+ foreach ( $fileOpHandles as $index => $fileOpHandle ) {
+ $pipes[$index] = popen( "{$fileOpHandle->cmd} 2>&1", 'r' );
+ }
+
+ $errs = [];
+ foreach ( $pipes as $index => $pipe ) {
+ // Result will be empty on success in *NIX. On Windows,
+ // it may be something like " 1 file(s) [copied|moved].".
+ $errs[$index] = stream_get_contents( $pipe );
+ fclose( $pipe );
+ }
+
+ foreach ( $fileOpHandles as $index => $fileOpHandle ) {
+ $status = Status::newGood();
+ $function = $fileOpHandle->call;
+ $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
+ $statuses[$index] = $status;
+ if ( $status->isOK() && $fileOpHandle->chmodPath ) {
+ $this->chmod( $fileOpHandle->chmodPath );
+ }
+ }
+
+ clearstatcache(); // files changed
+ return $statuses;
+ }
+
+ /**
+ * Chmod a file, suppressing the warnings
+ *
+ * @param string $path Absolute file system path
+ * @return bool Success
+ */
+ protected function chmod( $path ) {
+ $this->trapWarnings();
+ $ok = chmod( $path, $this->fileMode );
+ $this->untrapWarnings();
+
+ return $ok;
+ }
+
+ /**
+ * Return the text of an index.html file to hide directory listings
+ *
+ * @return string
+ */
+ protected function indexHtmlPrivate() {
+ return '';
+ }
+
+ /**
+ * Return the text of a .htaccess file to make a directory private
+ *
+ * @return string
+ */
+ protected function htaccessPrivate() {
+ return "Deny from all\n";
+ }
+
+ /**
+ * Clean up directory separators for the given OS
+ *
+ * @param string $path FS path
+ * @return string
+ */
+ protected function cleanPathSlashes( $path ) {
+ return wfIsWindows() ? strtr( $path, '/', '\\' ) : $path;
+ }
+
+ /**
+ * Listen for E_WARNING errors and track whether any happen
+ */
+ protected function trapWarnings() {
+ $this->hadWarningErrors[] = false; // push to stack
+ set_error_handler( [ $this, 'handleWarning' ], E_WARNING );
+ }
+
+ /**
+ * Stop listening for E_WARNING errors and return true if any happened
+ *
+ * @return bool
+ */
+ protected function untrapWarnings() {
+ restore_error_handler(); // restore previous handler
+ return array_pop( $this->hadWarningErrors ); // pop from stack
+ }
+
+ /**
+ * @param int $errno
+ * @param string $errstr
+ * @return bool
+ * @access private
+ */
+ public function handleWarning( $errno, $errstr ) {
+ wfDebugLog( 'FSFileBackend', $errstr ); // more detailed error logging
+ $this->hadWarningErrors[count( $this->hadWarningErrors ) - 1] = true;
+
+ return true; // suppress from PHP handler
+ }
+}
+
+/**
+ * @see FileBackendStoreOpHandle
+ */
+class FSFileOpHandle extends FileBackendStoreOpHandle {
+ public $cmd; // string; shell command
+ public $chmodPath; // string; file to chmod
+
+ /**
+ * @param FSFileBackend $backend
+ * @param array $params
+ * @param callable $call
+ * @param string $cmd
+ * @param int|null $chmodPath
+ */
+ public function __construct(
+ FSFileBackend $backend, array $params, $call, $cmd, $chmodPath = null
+ ) {
+ $this->backend = $backend;
+ $this->params = $params;
+ $this->call = $call;
+ $this->cmd = $cmd;
+ $this->chmodPath = $chmodPath;
+ }
+}
+
+/**
+ * Wrapper around RecursiveDirectoryIterator/DirectoryIterator that
+ * catches exception or does any custom behavoir that we may want.
+ * Do not use this class from places outside FSFileBackend.
+ *
+ * @ingroup FileBackend
+ */
+abstract class FSFileBackendList implements Iterator {
+ /** @var Iterator */
+ protected $iter;
+
+ /** @var int */
+ protected $suffixStart;
+
+ /** @var int */
+ protected $pos = 0;
+
+ /** @var array */
+ protected $params = [];
+
+ /**
+ * @param string $dir File system directory
+ * @param array $params
+ */
+ public function __construct( $dir, array $params ) {
+ $path = realpath( $dir ); // normalize
+ if ( $path === false ) {
+ $path = $dir;
+ }
+ $this->suffixStart = strlen( $path ) + 1; // size of "path/to/dir/"
+ $this->params = $params;
+
+ try {
+ $this->iter = $this->initIterator( $path );
+ } catch ( UnexpectedValueException $e ) {
+ $this->iter = null; // bad permissions? deleted?
+ }
+ }
+
+ /**
+ * Return an appropriate iterator object to wrap
+ *
+ * @param string $dir File system directory
+ * @return Iterator
+ */
+ protected function initIterator( $dir ) {
+ if ( !empty( $this->params['topOnly'] ) ) { // non-recursive
+ # Get an iterator that will get direct sub-nodes
+ return new DirectoryIterator( $dir );
+ } else { // recursive
+ # Get an iterator that will return leaf nodes (non-directories)
+ # RecursiveDirectoryIterator extends FilesystemIterator.
+ # FilesystemIterator::SKIP_DOTS default is inconsistent in PHP 5.3.x.
+ $flags = FilesystemIterator::CURRENT_AS_SELF | FilesystemIterator::SKIP_DOTS;
+
+ return new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $dir, $flags ),
+ RecursiveIteratorIterator::CHILD_FIRST // include dirs
+ );
+ }
+ }
+
+ /**
+ * @see Iterator::key()
+ * @return int
+ */
+ public function key() {
+ return $this->pos;
+ }
+
+ /**
+ * @see Iterator::current()
+ * @return string|bool String or false
+ */
+ public function current() {
+ return $this->getRelPath( $this->iter->current()->getPathname() );
+ }
+
+ /**
+ * @see Iterator::next()
+ * @throws FileBackendError
+ */
+ public function next() {
+ try {
+ $this->iter->next();
+ $this->filterViaNext();
+ } catch ( UnexpectedValueException $e ) { // bad permissions? deleted?
+ throw new FileBackendError( "File iterator gave UnexpectedValueException." );
+ }
+ ++$this->pos;
+ }
+
+ /**
+ * @see Iterator::rewind()
+ * @throws FileBackendError
+ */
+ public function rewind() {
+ $this->pos = 0;
+ try {
+ $this->iter->rewind();
+ $this->filterViaNext();
+ } catch ( UnexpectedValueException $e ) { // bad permissions? deleted?
+ throw new FileBackendError( "File iterator gave UnexpectedValueException." );
+ }
+ }
+
+ /**
+ * @see Iterator::valid()
+ * @return bool
+ */
+ public function valid() {
+ return $this->iter && $this->iter->valid();
+ }
+
+ /**
+ * Filter out items by advancing to the next ones
+ */
+ protected function filterViaNext() {
+ }
+
+ /**
+ * Return only the relative path and normalize slashes to FileBackend-style.
+ * Uses the "real path" since the suffix is based upon that.
+ *
+ * @param string $dir
+ * @return string
+ */
+ protected function getRelPath( $dir ) {
+ $path = realpath( $dir );
+ if ( $path === false ) {
+ $path = $dir;
+ }
+
+ return strtr( substr( $path, $this->suffixStart ), '\\', '/' );
+ }
+}
+
+class FSFileBackendDirList extends FSFileBackendList {
+ protected function filterViaNext() {
+ while ( $this->iter->valid() ) {
+ if ( $this->iter->current()->isDot() || !$this->iter->current()->isDir() ) {
+ $this->iter->next(); // skip non-directories and dot files
+ } else {
+ break;
+ }
+ }
+ }
+}
+
+class FSFileBackendFileList extends FSFileBackendList {
+ protected function filterViaNext() {
+ while ( $this->iter->valid() ) {
+ if ( !$this->iter->current()->isFile() ) {
+ $this->iter->next(); // skip non-files and dot files
+ } else {
+ break;
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/filebackend/FileBackend.php b/www/wiki/includes/filebackend/FileBackend.php
new file mode 100644
index 00000000..03974f75
--- /dev/null
+++ b/www/wiki/includes/filebackend/FileBackend.php
@@ -0,0 +1,1545 @@
+<?php
+/**
+ * @defgroup FileBackend File backend
+ *
+ * File backend is used to interact with file storage systems,
+ * such as the local file system, NFS, or cloud storage systems.
+ */
+
+/**
+ * Base class for all file backends.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Aaron Schulz
+ */
+
+/**
+ * @brief Base class for all file backend classes (including multi-write backends).
+ *
+ * This class defines the methods as abstract that subclasses must implement.
+ * Outside callers can assume that all backends will have these functions.
+ *
+ * All "storage paths" are of the format "mwstore://<backend>/<container>/<path>".
+ * The "backend" portion is unique name for MediaWiki to refer to a backend, while
+ * the "container" portion is a top-level directory of the backend. The "path" portion
+ * is a relative path that uses UNIX file system (FS) notation, though any particular
+ * backend may not actually be using a local filesystem. Therefore, the relative paths
+ * are only virtual.
+ *
+ * Backend contents are stored under wiki-specific container names by default.
+ * Global (qualified) backends are achieved by configuring the "wiki ID" to a constant.
+ * For legacy reasons, the FSFileBackend class allows manually setting the paths of
+ * containers to ones that do not respect the "wiki ID".
+ *
+ * In key/value (object) stores, containers are the only hierarchy (the rest is emulated).
+ * FS-based backends are somewhat more restrictive due to the existence of real
+ * directory files; a regular file cannot have the same name as a directory. Other
+ * backends with virtual directories may not have this limitation. Callers should
+ * store files in such a way that no files and directories are under the same path.
+ *
+ * In general, this class allows for callers to access storage through the same
+ * interface, without regard to the underlying storage system. However, calling code
+ * must follow certain patterns and be aware of certain things to ensure compatibility:
+ * - a) Always call prepare() on the parent directory before trying to put a file there;
+ * key/value stores only need the container to exist first, but filesystems need
+ * all the parent directories to exist first (prepare() is aware of all this)
+ * - b) Always call clean() on a directory when it might become empty to avoid empty
+ * directory buildup on filesystems; key/value stores never have empty directories,
+ * so doing this helps preserve consistency in both cases
+ * - c) Likewise, do not rely on the existence of empty directories for anything;
+ * calling directoryExists() on a path that prepare() was previously called on
+ * will return false for key/value stores if there are no files under that path
+ * - d) Never alter the resulting FSFile returned from getLocalReference(), as it could
+ * either be a copy of the source file in /tmp or the original source file itself
+ * - e) Use a file layout that results in never attempting to store files over directories
+ * or directories over files; key/value stores allow this but filesystems do not
+ * - f) Use ASCII file names (e.g. base32, IDs, hashes) to avoid Unicode issues in Windows
+ * - g) Do not assume that move operations are atomic (difficult with key/value stores)
+ * - h) Do not assume that file stat or read operations always have immediate consistency;
+ * various methods have a "latest" flag that should always be used if up-to-date
+ * information is required (this trades performance for correctness as needed)
+ * - i) Do not assume that directory listings have immediate consistency
+ *
+ * Methods of subclasses should avoid throwing exceptions at all costs.
+ * As a corollary, external dependencies should be kept to a minimum.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+abstract class FileBackend {
+ /** @var string Unique backend name */
+ protected $name;
+
+ /** @var string Unique wiki name */
+ protected $wikiId;
+
+ /** @var string Read-only explanation message */
+ protected $readOnly;
+
+ /** @var string When to do operations in parallel */
+ protected $parallelize;
+
+ /** @var int How many operations can be done in parallel */
+ protected $concurrency;
+
+ /** @var LockManager */
+ protected $lockManager;
+
+ /** @var FileJournal */
+ protected $fileJournal;
+
+ /** Bitfield flags for supported features */
+ const ATTR_HEADERS = 1; // files can be tagged with standard HTTP headers
+ const ATTR_METADATA = 2; // files can be stored with metadata key/values
+ const ATTR_UNICODE_PATHS = 4; // files can have Unicode paths (not just ASCII)
+
+ /**
+ * Create a new backend instance from configuration.
+ * This should only be called from within FileBackendGroup.
+ *
+ * @param array $config Parameters include:
+ * - name : The unique name of this backend.
+ * This should consist of alphanumberic, '-', and '_' characters.
+ * This name should not be changed after use (e.g. with journaling).
+ * Note that the name is *not* used in actual container names.
+ * - wikiId : Prefix to container names that is unique to this backend.
+ * It should only consist of alphanumberic, '-', and '_' characters.
+ * This ID is what avoids collisions if multiple logical backends
+ * use the same storage system, so this should be set carefully.
+ * - lockManager : LockManager object to use for any file locking.
+ * If not provided, then no file locking will be enforced.
+ * - fileJournal : FileJournal object to use for logging changes to files.
+ * If not provided, then change journaling will be disabled.
+ * - readOnly : Write operations are disallowed if this is a non-empty string.
+ * It should be an explanation for the backend being read-only.
+ * - parallelize : When to do file operations in parallel (when possible).
+ * Allowed values are "implicit", "explicit" and "off".
+ * - concurrency : How many file operations can be done in parallel.
+ * @throws FileBackendException
+ */
+ public function __construct( array $config ) {
+ $this->name = $config['name'];
+ $this->wikiId = $config['wikiId']; // e.g. "my_wiki-en_"
+ if ( !preg_match( '!^[a-zA-Z0-9-_]{1,255}$!', $this->name ) ) {
+ throw new FileBackendException( "Backend name '{$this->name}' is invalid." );
+ } elseif ( !is_string( $this->wikiId ) ) {
+ throw new FileBackendException( "Backend wiki ID not provided for '{$this->name}'." );
+ }
+ $this->lockManager = isset( $config['lockManager'] )
+ ? $config['lockManager']
+ : new NullLockManager( [] );
+ $this->fileJournal = isset( $config['fileJournal'] )
+ ? $config['fileJournal']
+ : FileJournal::factory( [ 'class' => 'NullFileJournal' ], $this->name );
+ $this->readOnly = isset( $config['readOnly'] )
+ ? (string)$config['readOnly']
+ : '';
+ $this->parallelize = isset( $config['parallelize'] )
+ ? (string)$config['parallelize']
+ : 'off';
+ $this->concurrency = isset( $config['concurrency'] )
+ ? (int)$config['concurrency']
+ : 50;
+ }
+
+ /**
+ * Get the unique backend name.
+ * We may have multiple different backends of the same type.
+ * For example, we can have two Swift backends using different proxies.
+ *
+ * @return string
+ */
+ final public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Get the wiki identifier used for this backend (possibly empty).
+ * Note that this might *not* be in the same format as wfWikiID().
+ *
+ * @return string
+ * @since 1.20
+ */
+ final public function getWikiId() {
+ return $this->wikiId;
+ }
+
+ /**
+ * Check if this backend is read-only
+ *
+ * @return bool
+ */
+ final public function isReadOnly() {
+ return ( $this->readOnly != '' );
+ }
+
+ /**
+ * Get an explanatory message if this backend is read-only
+ *
+ * @return string|bool Returns false if the backend is not read-only
+ */
+ final public function getReadOnlyReason() {
+ return ( $this->readOnly != '' ) ? $this->readOnly : false;
+ }
+
+ /**
+ * Get the a bitfield of extra features supported by the backend medium
+ *
+ * @return int Bitfield of FileBackend::ATTR_* flags
+ * @since 1.23
+ */
+ public function getFeatures() {
+ return self::ATTR_UNICODE_PATHS;
+ }
+
+ /**
+ * Check if the backend medium supports a field of extra features
+ *
+ * @param int $bitfield Bitfield of FileBackend::ATTR_* flags
+ * @return bool
+ * @since 1.23
+ */
+ final public function hasFeatures( $bitfield ) {
+ return ( $this->getFeatures() & $bitfield ) === $bitfield;
+ }
+
+ /**
+ * This is the main entry point into the backend for write operations.
+ * Callers supply an ordered list of operations to perform as a transaction.
+ * Files will be locked, the stat cache cleared, and then the operations attempted.
+ * If any serious errors occur, all attempted operations will be rolled back.
+ *
+ * $ops is an array of arrays. The outer array holds a list of operations.
+ * Each inner array is a set of key value pairs that specify an operation.
+ *
+ * Supported operations and their parameters. The supported actions are:
+ * - create
+ * - store
+ * - copy
+ * - move
+ * - delete
+ * - describe (since 1.21)
+ * - null
+ *
+ * FSFile/TempFSFile object support was added in 1.27.
+ *
+ * a) Create a new file in storage with the contents of a string
+ * @code
+ * array(
+ * 'op' => 'create',
+ * 'dst' => <storage path>,
+ * 'content' => <string of new file contents>,
+ * 'overwrite' => <boolean>,
+ * 'overwriteSame' => <boolean>,
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * );
+ * @endcode
+ *
+ * b) Copy a file system file into storage
+ * @code
+ * array(
+ * 'op' => 'store',
+ * 'src' => <file system path, FSFile, or TempFSFile>,
+ * 'dst' => <storage path>,
+ * 'overwrite' => <boolean>,
+ * 'overwriteSame' => <boolean>,
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * )
+ * @endcode
+ *
+ * c) Copy a file within storage
+ * @code
+ * array(
+ * 'op' => 'copy',
+ * 'src' => <storage path>,
+ * 'dst' => <storage path>,
+ * 'overwrite' => <boolean>,
+ * 'overwriteSame' => <boolean>,
+ * 'ignoreMissingSource' => <boolean>, # since 1.21
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * )
+ * @endcode
+ *
+ * d) Move a file within storage
+ * @code
+ * array(
+ * 'op' => 'move',
+ * 'src' => <storage path>,
+ * 'dst' => <storage path>,
+ * 'overwrite' => <boolean>,
+ * 'overwriteSame' => <boolean>,
+ * 'ignoreMissingSource' => <boolean>, # since 1.21
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * )
+ * @endcode
+ *
+ * e) Delete a file within storage
+ * @code
+ * array(
+ * 'op' => 'delete',
+ * 'src' => <storage path>,
+ * 'ignoreMissingSource' => <boolean>
+ * )
+ * @endcode
+ *
+ * f) Update metadata for a file within storage
+ * @code
+ * array(
+ * 'op' => 'describe',
+ * 'src' => <storage path>,
+ * 'headers' => <HTTP header name/value map>
+ * )
+ * @endcode
+ *
+ * g) Do nothing (no-op)
+ * @code
+ * array(
+ * 'op' => 'null',
+ * )
+ * @endcode
+ *
+ * Boolean flags for operations (operation-specific):
+ * - ignoreMissingSource : The operation will simply succeed and do
+ * nothing if the source file does not exist.
+ * - overwrite : Any destination file will be overwritten.
+ * - overwriteSame : If a file already exists at the destination with the
+ * same contents, then do nothing to the destination file
+ * instead of giving an error. This does not compare headers.
+ * This option is ignored if 'overwrite' is already provided.
+ * - headers : If supplied, the result of merging these headers with any
+ * existing source file headers (replacing conflicting ones)
+ * will be set as the destination file headers. Headers are
+ * deleted if their value is set to the empty string. When a
+ * file has headers they are included in responses to GET and
+ * HEAD requests to the backing store for that file.
+ * Header values should be no larger than 255 bytes, except for
+ * Content-Disposition. The system might ignore or truncate any
+ * headers that are too long to store (exact limits will vary).
+ * Backends that don't support metadata ignore this. (since 1.21)
+ *
+ * $opts is an associative of boolean flags, including:
+ * - force : Operation precondition errors no longer trigger an abort.
+ * Any remaining operations are still attempted. Unexpected
+ * failures may still cause remaining operations to be aborted.
+ * - nonLocking : No locks are acquired for the operations.
+ * This can increase performance for non-critical writes.
+ * This has no effect unless the 'force' flag is set.
+ * - nonJournaled : Don't log this operation batch in the file journal.
+ * This limits the ability of recovery scripts.
+ * - parallelize : Try to do operations in parallel when possible.
+ * - bypassReadOnly : Allow writes in read-only mode. (since 1.20)
+ * - preserveCache : Don't clear the process cache before checking files.
+ * This should only be used if all entries in the process
+ * cache were added after the files were already locked. (since 1.20)
+ *
+ * @remarks Remarks on locking:
+ * File system paths given to operations should refer to files that are
+ * already locked or otherwise safe from modification from other processes.
+ * Normally these files will be new temp files, which should be adequate.
+ *
+ * @par Return value:
+ *
+ * This returns a Status, which contains all warnings and fatals that occurred
+ * during the operation. The 'failCount', 'successCount', and 'success' members
+ * will reflect each operation attempted.
+ *
+ * The status will be "OK" unless:
+ * - a) unexpected operation errors occurred (network partitions, disk full...)
+ * - b) significant operation errors occurred and 'force' was not set
+ *
+ * @param array $ops List of operations to execute in order
+ * @param array $opts Batch operation options
+ * @return Status
+ */
+ final public function doOperations( array $ops, array $opts = [] ) {
+ if ( empty( $opts['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ if ( !count( $ops ) ) {
+ return Status::newGood(); // nothing to do
+ }
+
+ $ops = $this->resolveFSFileObjects( $ops );
+ if ( empty( $opts['force'] ) ) { // sanity
+ unset( $opts['nonLocking'] );
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+
+ return $this->doOperationsInternal( $ops, $opts );
+ }
+
+ /**
+ * @see FileBackend::doOperations()
+ * @param array $ops
+ * @param array $opts
+ */
+ abstract protected function doOperationsInternal( array $ops, array $opts );
+
+ /**
+ * Same as doOperations() except it takes a single operation.
+ * If you are doing a batch of operations that should either
+ * all succeed or all fail, then use that function instead.
+ *
+ * @see FileBackend::doOperations()
+ *
+ * @param array $op Operation
+ * @param array $opts Operation options
+ * @return Status
+ */
+ final public function doOperation( array $op, array $opts = [] ) {
+ return $this->doOperations( [ $op ], $opts );
+ }
+
+ /**
+ * Performs a single create operation.
+ * This sets $params['op'] to 'create' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return Status
+ */
+ final public function create( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'create' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single store operation.
+ * This sets $params['op'] to 'store' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return Status
+ */
+ final public function store( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'store' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single copy operation.
+ * This sets $params['op'] to 'copy' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return Status
+ */
+ final public function copy( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'copy' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single move operation.
+ * This sets $params['op'] to 'move' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return Status
+ */
+ final public function move( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'move' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single delete operation.
+ * This sets $params['op'] to 'delete' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return Status
+ */
+ final public function delete( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'delete' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single describe operation.
+ * This sets $params['op'] to 'describe' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return Status
+ * @since 1.21
+ */
+ final public function describe( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'describe' ] + $params, $opts );
+ }
+
+ /**
+ * Perform a set of independent file operations on some files.
+ *
+ * This does no locking, nor journaling, and possibly no stat calls.
+ * Any destination files that already exist will be overwritten.
+ * This should *only* be used on non-original files, like cache files.
+ *
+ * Supported operations and their parameters:
+ * - create
+ * - store
+ * - copy
+ * - move
+ * - delete
+ * - describe (since 1.21)
+ * - null
+ *
+ * FSFile/TempFSFile object support was added in 1.27.
+ *
+ * a) Create a new file in storage with the contents of a string
+ * @code
+ * array(
+ * 'op' => 'create',
+ * 'dst' => <storage path>,
+ * 'content' => <string of new file contents>,
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * )
+ * @endcode
+ *
+ * b) Copy a file system file into storage
+ * @code
+ * array(
+ * 'op' => 'store',
+ * 'src' => <file system path, FSFile, or TempFSFile>,
+ * 'dst' => <storage path>,
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * )
+ * @endcode
+ *
+ * c) Copy a file within storage
+ * @code
+ * array(
+ * 'op' => 'copy',
+ * 'src' => <storage path>,
+ * 'dst' => <storage path>,
+ * 'ignoreMissingSource' => <boolean>, # since 1.21
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * )
+ * @endcode
+ *
+ * d) Move a file within storage
+ * @code
+ * array(
+ * 'op' => 'move',
+ * 'src' => <storage path>,
+ * 'dst' => <storage path>,
+ * 'ignoreMissingSource' => <boolean>, # since 1.21
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * )
+ * @endcode
+ *
+ * e) Delete a file within storage
+ * @code
+ * array(
+ * 'op' => 'delete',
+ * 'src' => <storage path>,
+ * 'ignoreMissingSource' => <boolean>
+ * )
+ * @endcode
+ *
+ * f) Update metadata for a file within storage
+ * @code
+ * array(
+ * 'op' => 'describe',
+ * 'src' => <storage path>,
+ * 'headers' => <HTTP header name/value map>
+ * )
+ * @endcode
+ *
+ * g) Do nothing (no-op)
+ * @code
+ * array(
+ * 'op' => 'null',
+ * )
+ * @endcode
+ *
+ * @par Boolean flags for operations (operation-specific):
+ * - ignoreMissingSource : The operation will simply succeed and do
+ * nothing if the source file does not exist.
+ * - headers : If supplied with a header name/value map, the backend will
+ * reply with these headers when GETs/HEADs of the destination
+ * file are made. Header values should be smaller than 256 bytes.
+ * Content-Disposition headers can be longer, though the system
+ * might ignore or truncate ones that are too long to store.
+ * Existing headers will remain, but these will replace any
+ * conflicting previous headers, and headers will be removed
+ * if they are set to an empty string.
+ * Backends that don't support metadata ignore this. (since 1.21)
+ *
+ * $opts is an associative of boolean flags, including:
+ * - bypassReadOnly : Allow writes in read-only mode (since 1.20)
+ *
+ * @par Return value:
+ * This returns a Status, which contains all warnings and fatals that occurred
+ * during the operation. The 'failCount', 'successCount', and 'success' members
+ * will reflect each operation attempted for the given files. The status will be
+ * considered "OK" as long as no fatal errors occurred.
+ *
+ * @param array $ops Set of operations to execute
+ * @param array $opts Batch operation options
+ * @return Status
+ * @since 1.20
+ */
+ final public function doQuickOperations( array $ops, array $opts = [] ) {
+ if ( empty( $opts['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ if ( !count( $ops ) ) {
+ return Status::newGood(); // nothing to do
+ }
+
+ $ops = $this->resolveFSFileObjects( $ops );
+ foreach ( $ops as &$op ) {
+ $op['overwrite'] = true; // avoids RTTs in key/value stores
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+
+ return $this->doQuickOperationsInternal( $ops );
+ }
+
+ /**
+ * @see FileBackend::doQuickOperations()
+ * @param array $ops
+ * @since 1.20
+ */
+ abstract protected function doQuickOperationsInternal( array $ops );
+
+ /**
+ * Same as doQuickOperations() except it takes a single operation.
+ * If you are doing a batch of operations, then use that function instead.
+ *
+ * @see FileBackend::doQuickOperations()
+ *
+ * @param array $op Operation
+ * @return Status
+ * @since 1.20
+ */
+ final public function doQuickOperation( array $op ) {
+ return $this->doQuickOperations( [ $op ] );
+ }
+
+ /**
+ * Performs a single quick create operation.
+ * This sets $params['op'] to 'create' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return Status
+ * @since 1.20
+ */
+ final public function quickCreate( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'create' ] + $params );
+ }
+
+ /**
+ * Performs a single quick store operation.
+ * This sets $params['op'] to 'store' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return Status
+ * @since 1.20
+ */
+ final public function quickStore( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'store' ] + $params );
+ }
+
+ /**
+ * Performs a single quick copy operation.
+ * This sets $params['op'] to 'copy' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return Status
+ * @since 1.20
+ */
+ final public function quickCopy( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'copy' ] + $params );
+ }
+
+ /**
+ * Performs a single quick move operation.
+ * This sets $params['op'] to 'move' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return Status
+ * @since 1.20
+ */
+ final public function quickMove( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'move' ] + $params );
+ }
+
+ /**
+ * Performs a single quick delete operation.
+ * This sets $params['op'] to 'delete' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return Status
+ * @since 1.20
+ */
+ final public function quickDelete( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'delete' ] + $params );
+ }
+
+ /**
+ * Performs a single quick describe operation.
+ * This sets $params['op'] to 'describe' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return Status
+ * @since 1.21
+ */
+ final public function quickDescribe( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'describe' ] + $params );
+ }
+
+ /**
+ * Concatenate a list of storage files into a single file system file.
+ * The target path should refer to a file that is already locked or
+ * otherwise safe from modification from other processes. Normally,
+ * the file will be a new temp file, which should be adequate.
+ *
+ * @param array $params Operation parameters, include:
+ * - srcs : ordered source storage paths (e.g. chunk1, chunk2, ...)
+ * - dst : file system path to 0-byte temp file
+ * - parallelize : try to do operations in parallel when possible
+ * @return Status
+ */
+ abstract public function concatenate( array $params );
+
+ /**
+ * Prepare a storage directory for usage.
+ * This will create any required containers and parent directories.
+ * Backends using key/value stores only need to create the container.
+ *
+ * The 'noAccess' and 'noListing' parameters works the same as in secure(),
+ * except they are only applied *if* the directory/container had to be created.
+ * These flags should always be set for directories that have private files.
+ * However, setting them is not guaranteed to actually do anything.
+ * Additional server configuration may be needed to achieve the desired effect.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - noAccess : try to deny file access (since 1.20)
+ * - noListing : try to deny file listing (since 1.20)
+ * - bypassReadOnly : allow writes in read-only mode (since 1.20)
+ * @return Status
+ */
+ final public function prepare( array $params ) {
+ if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+ return $this->doPrepare( $params );
+ }
+
+ /**
+ * @see FileBackend::prepare()
+ * @param array $params
+ */
+ abstract protected function doPrepare( array $params );
+
+ /**
+ * Take measures to block web access to a storage directory and
+ * the container it belongs to. FS backends might add .htaccess
+ * files whereas key/value store backends might revoke container
+ * access to the storage user representing end-users in web requests.
+ *
+ * This is not guaranteed to actually make files or listings publically hidden.
+ * Additional server configuration may be needed to achieve the desired effect.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - noAccess : try to deny file access
+ * - noListing : try to deny file listing
+ * - bypassReadOnly : allow writes in read-only mode (since 1.20)
+ * @return Status
+ */
+ final public function secure( array $params ) {
+ if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+ return $this->doSecure( $params );
+ }
+
+ /**
+ * @see FileBackend::secure()
+ * @param array $params
+ */
+ abstract protected function doSecure( array $params );
+
+ /**
+ * Remove measures to block web access to a storage directory and
+ * the container it belongs to. FS backends might remove .htaccess
+ * files whereas key/value store backends might grant container
+ * access to the storage user representing end-users in web requests.
+ * This essentially can undo the result of secure() calls.
+ *
+ * This is not guaranteed to actually make files or listings publically viewable.
+ * Additional server configuration may be needed to achieve the desired effect.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - access : try to allow file access
+ * - listing : try to allow file listing
+ * - bypassReadOnly : allow writes in read-only mode (since 1.20)
+ * @return Status
+ * @since 1.20
+ */
+ final public function publish( array $params ) {
+ if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+ return $this->doPublish( $params );
+ }
+
+ /**
+ * @see FileBackend::publish()
+ * @param array $params
+ */
+ abstract protected function doPublish( array $params );
+
+ /**
+ * Delete a storage directory if it is empty.
+ * Backends using key/value stores may do nothing unless the directory
+ * is that of an empty container, in which case it will be deleted.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - recursive : recursively delete empty subdirectories first (since 1.20)
+ * - bypassReadOnly : allow writes in read-only mode (since 1.20)
+ * @return Status
+ */
+ final public function clean( array $params ) {
+ if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return Status::newFatal( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+ return $this->doClean( $params );
+ }
+
+ /**
+ * @see FileBackend::clean()
+ * @param array $params
+ */
+ abstract protected function doClean( array $params );
+
+ /**
+ * Enter file operation scope.
+ * This just makes PHP ignore user aborts/disconnects until the return
+ * value leaves scope. This returns null and does nothing in CLI mode.
+ *
+ * @return ScopedCallback|null
+ */
+ final protected function getScopedPHPBehaviorForOps() {
+ if ( PHP_SAPI != 'cli' ) { // http://bugs.php.net/bug.php?id=47540
+ $old = ignore_user_abort( true ); // avoid half-finished operations
+ return new ScopedCallback( function () use ( $old ) {
+ ignore_user_abort( $old );
+ } );
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if a file exists at a storage path in the backend.
+ * This returns false if only a directory exists at the path.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return bool|null Returns null on failure
+ */
+ abstract public function fileExists( array $params );
+
+ /**
+ * Get the last-modified timestamp of the file at a storage path.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return string|bool TS_MW timestamp or false on failure
+ */
+ abstract public function getFileTimestamp( array $params );
+
+ /**
+ * Get the contents of a file at a storage path in the backend.
+ * This should be avoided for potentially large files.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return string|bool Returns false on failure
+ */
+ final public function getFileContents( array $params ) {
+ $contents = $this->getFileContentsMulti(
+ [ 'srcs' => [ $params['src'] ] ] + $params );
+
+ return $contents[$params['src']];
+ }
+
+ /**
+ * Like getFileContents() except it takes an array of storage paths
+ * and returns a map of storage paths to strings (or null on failure).
+ * The map keys (paths) are in the same order as the provided list of paths.
+ *
+ * @see FileBackend::getFileContents()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * - parallelize : try to do operations in parallel when possible
+ * @return array Map of (path name => string or false on failure)
+ * @since 1.20
+ */
+ abstract public function getFileContentsMulti( array $params );
+
+ /**
+ * Get metadata about a file at a storage path in the backend.
+ * If the file does not exist, then this returns false.
+ * Otherwise, the result is an associative array that includes:
+ * - headers : map of HTTP headers used for GET/HEAD requests (name => value)
+ * - metadata : map of file metadata (name => value)
+ * Metadata keys and headers names will be returned in all lower-case.
+ * Additional values may be included for internal use only.
+ *
+ * Use FileBackend::hasFeatures() to check how well this is supported.
+ *
+ * @param array $params
+ * $params include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return array|bool Returns false on failure
+ * @since 1.23
+ */
+ abstract public function getFileXAttributes( array $params );
+
+ /**
+ * Get the size (bytes) of a file at a storage path in the backend.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return int|bool Returns false on failure
+ */
+ abstract public function getFileSize( array $params );
+
+ /**
+ * Get quick information about a file at a storage path in the backend.
+ * If the file does not exist, then this returns false.
+ * Otherwise, the result is an associative array that includes:
+ * - mtime : the last-modified timestamp (TS_MW)
+ * - size : the file size (bytes)
+ * Additional values may be included for internal use only.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return array|bool|null Returns null on failure
+ */
+ abstract public function getFileStat( array $params );
+
+ /**
+ * Get a SHA-1 hash of the file at a storage path in the backend.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return string|bool Hash string or false on failure
+ */
+ abstract public function getFileSha1Base36( array $params );
+
+ /**
+ * Get the properties of the file at a storage path in the backend.
+ * This gives the result of FSFile::getProps() on a local copy of the file.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return array Returns FSFile::placeholderProps() on failure
+ */
+ abstract public function getFileProps( array $params );
+
+ /**
+ * Stream the file at a storage path in the backend.
+ * If the file does not exists, an HTTP 404 error will be given.
+ * Appropriate HTTP headers (Status, Content-Type, Content-Length)
+ * will be sent if streaming began, while none will be sent otherwise.
+ * Implementations should flush the output buffer before sending data.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - headers : list of additional HTTP headers to send on success
+ * - latest : use the latest available data
+ * @return Status
+ */
+ abstract public function streamFile( array $params );
+
+ /**
+ * Returns a file system file, identical to the file at a storage path.
+ * The file returned is either:
+ * - a) A local copy of the file at a storage path in the backend.
+ * The temporary copy will have the same extension as the source.
+ * - b) An original of the file at a storage path in the backend.
+ * Temporary files may be purged when the file object falls out of scope.
+ *
+ * Write operations should *never* be done on this file as some backends
+ * may do internal tracking or may be instances of FileBackendMultiWrite.
+ * In that later case, there are copies of the file that must stay in sync.
+ * Additionally, further calls to this function may return the same file.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return FSFile|null Returns null on failure
+ */
+ final public function getLocalReference( array $params ) {
+ $fsFiles = $this->getLocalReferenceMulti(
+ [ 'srcs' => [ $params['src'] ] ] + $params );
+
+ return $fsFiles[$params['src']];
+ }
+
+ /**
+ * Like getLocalReference() except it takes an array of storage paths
+ * and returns a map of storage paths to FSFile objects (or null on failure).
+ * The map keys (paths) are in the same order as the provided list of paths.
+ *
+ * @see FileBackend::getLocalReference()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * - parallelize : try to do operations in parallel when possible
+ * @return array Map of (path name => FSFile or null on failure)
+ * @since 1.20
+ */
+ abstract public function getLocalReferenceMulti( array $params );
+
+ /**
+ * Get a local copy on disk of the file at a storage path in the backend.
+ * The temporary copy will have the same file extension as the source.
+ * Temporary files may be purged when the file object falls out of scope.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return TempFSFile|null Returns null on failure
+ */
+ final public function getLocalCopy( array $params ) {
+ $tmpFiles = $this->getLocalCopyMulti(
+ [ 'srcs' => [ $params['src'] ] ] + $params );
+
+ return $tmpFiles[$params['src']];
+ }
+
+ /**
+ * Like getLocalCopy() except it takes an array of storage paths and
+ * returns a map of storage paths to TempFSFile objects (or null on failure).
+ * The map keys (paths) are in the same order as the provided list of paths.
+ *
+ * @see FileBackend::getLocalCopy()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * - parallelize : try to do operations in parallel when possible
+ * @return array Map of (path name => TempFSFile or null on failure)
+ * @since 1.20
+ */
+ abstract public function getLocalCopyMulti( array $params );
+
+ /**
+ * Return an HTTP URL to a given file that requires no authentication to use.
+ * The URL may be pre-authenticated (via some token in the URL) and temporary.
+ * This will return null if the backend cannot make an HTTP URL for the file.
+ *
+ * This is useful for key/value stores when using scripts that seek around
+ * large files and those scripts (and the backend) support HTTP Range headers.
+ * Otherwise, one would need to use getLocalReference(), which involves loading
+ * the entire file on to local disk.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - ttl : lifetime (seconds) if pre-authenticated; default is 1 day
+ * @return string|null
+ * @since 1.21
+ */
+ abstract public function getFileHttpUrl( array $params );
+
+ /**
+ * Check if a directory exists at a given storage path.
+ * Backends using key/value stores will check if the path is a
+ * virtual directory, meaning there are files under the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * @return bool|null Returns null on failure
+ * @since 1.20
+ */
+ abstract public function directoryExists( array $params );
+
+ /**
+ * Get an iterator to list *all* directories under a storage directory.
+ * If the directory is of the form "mwstore://backend/container",
+ * then all directories in the container will be listed.
+ * If the directory is of form "mwstore://backend/container/dir",
+ * then all directories directly under that directory will be listed.
+ * Results will be storage directories relative to the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * Failures during iteration can result in FileBackendError exceptions (since 1.22).
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - topOnly : only return direct child dirs of the directory
+ * @return Traversable|array|null Returns null on failure
+ * @since 1.20
+ */
+ abstract public function getDirectoryList( array $params );
+
+ /**
+ * Same as FileBackend::getDirectoryList() except only lists
+ * directories that are immediately under the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * Failures during iteration can result in FileBackendError exceptions (since 1.22).
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * @return Traversable|array|null Returns null on failure
+ * @since 1.20
+ */
+ final public function getTopDirectoryList( array $params ) {
+ return $this->getDirectoryList( [ 'topOnly' => true ] + $params );
+ }
+
+ /**
+ * Get an iterator to list *all* stored files under a storage directory.
+ * If the directory is of the form "mwstore://backend/container",
+ * then all files in the container will be listed.
+ * If the directory is of form "mwstore://backend/container/dir",
+ * then all files under that directory will be listed.
+ * Results will be storage paths relative to the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * Failures during iteration can result in FileBackendError exceptions (since 1.22).
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - topOnly : only return direct child files of the directory (since 1.20)
+ * - adviseStat : set to true if stat requests will be made on the files (since 1.22)
+ * @return Traversable|array|null Returns null on failure
+ */
+ abstract public function getFileList( array $params );
+
+ /**
+ * Same as FileBackend::getFileList() except only lists
+ * files that are immediately under the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * Failures during iteration can result in FileBackendError exceptions (since 1.22).
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - adviseStat : set to true if stat requests will be made on the files (since 1.22)
+ * @return Traversable|array|null Returns null on failure
+ * @since 1.20
+ */
+ final public function getTopFileList( array $params ) {
+ return $this->getFileList( [ 'topOnly' => true ] + $params );
+ }
+
+ /**
+ * Preload persistent file stat cache and property cache into in-process cache.
+ * This should be used when stat calls will be made on a known list of a many files.
+ *
+ * @see FileBackend::getFileStat()
+ *
+ * @param array $paths Storage paths
+ */
+ abstract public function preloadCache( array $paths );
+
+ /**
+ * Invalidate any in-process file stat and property cache.
+ * If $paths is given, then only the cache for those files will be cleared.
+ *
+ * @see FileBackend::getFileStat()
+ *
+ * @param array $paths Storage paths (optional)
+ */
+ abstract public function clearCache( array $paths = null );
+
+ /**
+ * Preload file stat information (concurrently if possible) into in-process cache.
+ *
+ * This should be used when stat calls will be made on a known list of a many files.
+ * This does not make use of the persistent file stat cache.
+ *
+ * @see FileBackend::getFileStat()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * @return bool All requests proceeded without I/O errors (since 1.24)
+ * @since 1.23
+ */
+ abstract public function preloadFileStat( array $params );
+
+ /**
+ * Lock the files at the given storage paths in the backend.
+ * This will either lock all the files or none (on failure).
+ *
+ * Callers should consider using getScopedFileLocks() instead.
+ *
+ * @param array $paths Storage paths
+ * @param int $type LockManager::LOCK_* constant
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.24)
+ * @return Status
+ */
+ final public function lockFiles( array $paths, $type, $timeout = 0 ) {
+ $paths = array_map( 'FileBackend::normalizeStoragePath', $paths );
+
+ return $this->lockManager->lock( $paths, $type, $timeout );
+ }
+
+ /**
+ * Unlock the files at the given storage paths in the backend.
+ *
+ * @param array $paths Storage paths
+ * @param int $type LockManager::LOCK_* constant
+ * @return Status
+ */
+ final public function unlockFiles( array $paths, $type ) {
+ $paths = array_map( 'FileBackend::normalizeStoragePath', $paths );
+
+ return $this->lockManager->unlock( $paths, $type );
+ }
+
+ /**
+ * Lock the files at the given storage paths in the backend.
+ * This will either lock all the files or none (on failure).
+ * On failure, the status object will be updated with errors.
+ *
+ * Once the return value goes out scope, the locks will be released and
+ * the status updated. Unlock fatals will not change the status "OK" value.
+ *
+ * @see ScopedLock::factory()
+ *
+ * @param array $paths List of storage paths or map of lock types to path lists
+ * @param int|string $type LockManager::LOCK_* constant or "mixed"
+ * @param Status $status Status to update on lock/unlock
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.24)
+ * @return ScopedLock|null Returns null on failure
+ */
+ final public function getScopedFileLocks( array $paths, $type, Status $status, $timeout = 0 ) {
+ if ( $type === 'mixed' ) {
+ foreach ( $paths as &$typePaths ) {
+ $typePaths = array_map( 'FileBackend::normalizeStoragePath', $typePaths );
+ }
+ } else {
+ $paths = array_map( 'FileBackend::normalizeStoragePath', $paths );
+ }
+
+ return ScopedLock::factory( $this->lockManager, $paths, $type, $status, $timeout );
+ }
+
+ /**
+ * Get an array of scoped locks needed for a batch of file operations.
+ *
+ * Normally, FileBackend::doOperations() handles locking, unless
+ * the 'nonLocking' param is passed in. This function is useful if you
+ * want the files to be locked for a broader scope than just when the
+ * files are changing. For example, if you need to update DB metadata,
+ * you may want to keep the files locked until finished.
+ *
+ * @see FileBackend::doOperations()
+ *
+ * @param array $ops List of file operations to FileBackend::doOperations()
+ * @param Status $status Status to update on lock/unlock
+ * @return ScopedLock|null
+ * @since 1.20
+ */
+ abstract public function getScopedLocksForOps( array $ops, Status $status );
+
+ /**
+ * Get the root storage path of this backend.
+ * All container paths are "subdirectories" of this path.
+ *
+ * @return string Storage path
+ * @since 1.20
+ */
+ final public function getRootStoragePath() {
+ return "mwstore://{$this->name}";
+ }
+
+ /**
+ * Get the storage path for the given container for this backend
+ *
+ * @param string $container Container name
+ * @return string Storage path
+ * @since 1.21
+ */
+ final public function getContainerStoragePath( $container ) {
+ return $this->getRootStoragePath() . "/{$container}";
+ }
+
+ /**
+ * Get the file journal object for this backend
+ *
+ * @return FileJournal
+ */
+ final public function getJournal() {
+ return $this->fileJournal;
+ }
+
+ /**
+ * Convert FSFile 'src' paths to string paths (with an 'srcRef' field set to the FSFile)
+ *
+ * The 'srcRef' field keeps any TempFSFile objects in scope for the backend to have it
+ * around as long it needs (which may vary greatly depending on configuration)
+ *
+ * @param array $ops File operation batch for FileBaclend::doOperations()
+ * @return array File operation batch
+ */
+ protected function resolveFSFileObjects( array $ops ) {
+ foreach ( $ops as &$op ) {
+ $src = isset( $op['src'] ) ? $op['src'] : null;
+ if ( $src instanceof FSFile ) {
+ $op['srcRef'] = $src;
+ $op['src'] = $src->getPath();
+ }
+ }
+ unset( $op );
+
+ return $ops;
+ }
+
+ /**
+ * Check if a given path is a "mwstore://" path.
+ * This does not do any further validation or any existence checks.
+ *
+ * @param string $path
+ * @return bool
+ */
+ final public static function isStoragePath( $path ) {
+ return ( strpos( $path, 'mwstore://' ) === 0 );
+ }
+
+ /**
+ * Split a storage path into a backend name, a container name,
+ * and a relative file path. The relative path may be the empty string.
+ * This does not do any path normalization or traversal checks.
+ *
+ * @param string $storagePath
+ * @return array (backend, container, rel object) or (null, null, null)
+ */
+ final public static function splitStoragePath( $storagePath ) {
+ if ( self::isStoragePath( $storagePath ) ) {
+ // Remove the "mwstore://" prefix and split the path
+ $parts = explode( '/', substr( $storagePath, 10 ), 3 );
+ if ( count( $parts ) >= 2 && $parts[0] != '' && $parts[1] != '' ) {
+ if ( count( $parts ) == 3 ) {
+ return $parts; // e.g. "backend/container/path"
+ } else {
+ return [ $parts[0], $parts[1], '' ]; // e.g. "backend/container"
+ }
+ }
+ }
+
+ return [ null, null, null ];
+ }
+
+ /**
+ * Normalize a storage path by cleaning up directory separators.
+ * Returns null if the path is not of the format of a valid storage path.
+ *
+ * @param string $storagePath
+ * @return string|null
+ */
+ final public static function normalizeStoragePath( $storagePath ) {
+ list( $backend, $container, $relPath ) = self::splitStoragePath( $storagePath );
+ if ( $relPath !== null ) { // must be for this backend
+ $relPath = self::normalizeContainerPath( $relPath );
+ if ( $relPath !== null ) {
+ return ( $relPath != '' )
+ ? "mwstore://{$backend}/{$container}/{$relPath}"
+ : "mwstore://{$backend}/{$container}";
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the parent storage directory of a storage path.
+ * This returns a path like "mwstore://backend/container",
+ * "mwstore://backend/container/...", or null if there is no parent.
+ *
+ * @param string $storagePath
+ * @return string|null
+ */
+ final public static function parentStoragePath( $storagePath ) {
+ $storagePath = dirname( $storagePath );
+ list( , , $rel ) = self::splitStoragePath( $storagePath );
+
+ return ( $rel === null ) ? null : $storagePath;
+ }
+
+ /**
+ * Get the final extension from a storage or FS path
+ *
+ * @param string $path
+ * @param string $case One of (rawcase, uppercase, lowercase) (since 1.24)
+ * @return string
+ */
+ final public static function extensionFromPath( $path, $case = 'lowercase' ) {
+ $i = strrpos( $path, '.' );
+ $ext = $i ? substr( $path, $i + 1 ) : '';
+
+ if ( $case === 'lowercase' ) {
+ $ext = strtolower( $ext );
+ } elseif ( $case === 'uppercase' ) {
+ $ext = strtoupper( $ext );
+ }
+
+ return $ext;
+ }
+
+ /**
+ * Check if a relative path has no directory traversals
+ *
+ * @param string $path
+ * @return bool
+ * @since 1.20
+ */
+ final public static function isPathTraversalFree( $path ) {
+ return ( self::normalizeContainerPath( $path ) !== null );
+ }
+
+ /**
+ * Build a Content-Disposition header value per RFC 6266.
+ *
+ * @param string $type One of (attachment, inline)
+ * @param string $filename Suggested file name (should not contain slashes)
+ * @throws FileBackendError
+ * @return string
+ * @since 1.20
+ */
+ final public static function makeContentDisposition( $type, $filename = '' ) {
+ $parts = [];
+
+ $type = strtolower( $type );
+ if ( !in_array( $type, [ 'inline', 'attachment' ] ) ) {
+ throw new FileBackendError( "Invalid Content-Disposition type '$type'." );
+ }
+ $parts[] = $type;
+
+ if ( strlen( $filename ) ) {
+ $parts[] = "filename*=UTF-8''" . rawurlencode( basename( $filename ) );
+ }
+
+ return implode( ';', $parts );
+ }
+
+ /**
+ * Validate and normalize a relative storage path.
+ * Null is returned if the path involves directory traversal.
+ * Traversal is insecure for FS backends and broken for others.
+ *
+ * This uses the same traversal protection as Title::secureAndSplit().
+ *
+ * @param string $path Storage path relative to a container
+ * @return string|null
+ */
+ final protected static function normalizeContainerPath( $path ) {
+ // Normalize directory separators
+ $path = strtr( $path, '\\', '/' );
+ // Collapse any consecutive directory separators
+ $path = preg_replace( '![/]{2,}!', '/', $path );
+ // Remove any leading directory separator
+ $path = ltrim( $path, '/' );
+ // Use the same traversal protection as Title::secureAndSplit()
+ if ( strpos( $path, '.' ) !== false ) {
+ if (
+ $path === '.' ||
+ $path === '..' ||
+ strpos( $path, './' ) === 0 ||
+ strpos( $path, '../' ) === 0 ||
+ strpos( $path, '/./' ) !== false ||
+ strpos( $path, '/../' ) !== false
+ ) {
+ return null;
+ }
+ }
+
+ return $path;
+ }
+}
+
+/**
+ * Generic file backend exception for checked and unexpected (e.g. config) exceptions
+ *
+ * @ingroup FileBackend
+ * @since 1.23
+ */
+class FileBackendException extends Exception {
+}
+
+/**
+ * File backend exception for checked exceptions (e.g. I/O errors)
+ *
+ * @ingroup FileBackend
+ * @since 1.22
+ */
+class FileBackendError extends FileBackendException {
+}
diff --git a/www/wiki/includes/filebackend/FileBackendGroup.php b/www/wiki/includes/filebackend/FileBackendGroup.php
new file mode 100644
index 00000000..5d0da6d3
--- /dev/null
+++ b/www/wiki/includes/filebackend/FileBackendGroup.php
@@ -0,0 +1,246 @@
+<?php
+/**
+ * File backend registration handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+use \MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Class to handle file backend registration
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+class FileBackendGroup {
+ /** @var FileBackendGroup */
+ protected static $instance = null;
+
+ /** @var array (name => ('class' => string, 'config' => array, 'instance' => object)) */
+ protected $backends = [];
+
+ protected function __construct() {
+ }
+
+ /**
+ * @return FileBackendGroup
+ */
+ public static function singleton() {
+ if ( self::$instance == null ) {
+ self::$instance = new self();
+ self::$instance->initFromGlobals();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Destroy the singleton instance
+ */
+ public static function destroySingleton() {
+ self::$instance = null;
+ }
+
+ /**
+ * Register file backends from the global variables
+ */
+ protected function initFromGlobals() {
+ global $wgLocalFileRepo, $wgForeignFileRepos, $wgFileBackends, $wgDirectoryMode;
+
+ // Register explicitly defined backends
+ $this->register( $wgFileBackends, wfConfiguredReadOnlyReason() );
+
+ $autoBackends = [];
+ // Automatically create b/c backends for file repos...
+ $repos = array_merge( $wgForeignFileRepos, [ $wgLocalFileRepo ] );
+ foreach ( $repos as $info ) {
+ $backendName = $info['backend'];
+ if ( is_object( $backendName ) || isset( $this->backends[$backendName] ) ) {
+ continue; // already defined (or set to the object for some reason)
+ }
+ $repoName = $info['name'];
+ // Local vars that used to be FSRepo members...
+ $directory = $info['directory'];
+ $deletedDir = isset( $info['deletedDir'] )
+ ? $info['deletedDir']
+ : false; // deletion disabled
+ $thumbDir = isset( $info['thumbDir'] )
+ ? $info['thumbDir']
+ : "{$directory}/thumb";
+ $transcodedDir = isset( $info['transcodedDir'] )
+ ? $info['transcodedDir']
+ : "{$directory}/transcoded";
+ // Get the FS backend configuration
+ $autoBackends[] = [
+ 'name' => $backendName,
+ 'class' => 'FSFileBackend',
+ 'lockManager' => 'fsLockManager',
+ 'containerPaths' => [
+ "{$repoName}-public" => "{$directory}",
+ "{$repoName}-thumb" => $thumbDir,
+ "{$repoName}-transcoded" => $transcodedDir,
+ "{$repoName}-deleted" => $deletedDir,
+ "{$repoName}-temp" => "{$directory}/temp"
+ ],
+ 'fileMode' => isset( $info['fileMode'] ) ? $info['fileMode'] : 0644,
+ 'directoryMode' => $wgDirectoryMode,
+ ];
+ }
+
+ // Register implicitly defined backends
+ $this->register( $autoBackends, wfConfiguredReadOnlyReason() );
+ }
+
+ /**
+ * Register an array of file backend configurations
+ *
+ * @param array $configs
+ * @param string|null $readOnlyReason
+ * @throws InvalidArgumentException
+ */
+ protected function register( array $configs, $readOnlyReason = null ) {
+ foreach ( $configs as $config ) {
+ if ( !isset( $config['name'] ) ) {
+ throw new InvalidArgumentException( "Cannot register a backend with no name." );
+ }
+ $name = $config['name'];
+ if ( isset( $this->backends[$name] ) ) {
+ throw new LogicException( "Backend with name `{$name}` already registered." );
+ } elseif ( !isset( $config['class'] ) ) {
+ throw new InvalidArgumentException( "Backend with name `{$name}` has no class." );
+ }
+ $class = $config['class'];
+
+ $config['readOnly'] = !empty( $config['readOnly'] )
+ ? $config['readOnly']
+ : $readOnlyReason;
+
+ unset( $config['class'] ); // backend won't need this
+ $this->backends[$name] = [
+ 'class' => $class,
+ 'config' => $config,
+ 'instance' => null
+ ];
+ }
+ }
+
+ /**
+ * Get the backend object with a given name
+ *
+ * @param string $name
+ * @return FileBackend
+ * @throws InvalidArgumentException
+ */
+ public function get( $name ) {
+ // Lazy-load the actual backend instance
+ if ( !isset( $this->backends[$name]['instance'] ) ) {
+ $config = $this->config( $name );
+
+ $class = $config['class'];
+ if ( $class === 'FileBackendMultiWrite' ) {
+ foreach ( $config['backends'] as $index => $beConfig ) {
+ if ( isset( $beConfig['template'] ) ) {
+ // Config is just a modified version of a registered backend's.
+ // This should only be used when that config is used only by this backend.
+ $config['backends'][$index] += $this->config( $beConfig['template'] );
+ }
+ }
+ }
+
+ $this->backends[$name]['instance'] = new $class( $config );
+ }
+
+ return $this->backends[$name]['instance'];
+ }
+
+ /**
+ * Get the config array for a backend object with a given name
+ *
+ * @param string $name
+ * @return array Parameters to FileBackend::__construct()
+ * @throws InvalidArgumentException
+ */
+ public function config( $name ) {
+ if ( !isset( $this->backends[$name] ) ) {
+ throw new InvalidArgumentException( "No backend defined with the name `$name`." );
+ }
+ $class = $this->backends[$name]['class'];
+
+ $config = $this->backends[$name]['config'];
+ $config['class'] = $class;
+ $config += [ // set defaults
+ 'wikiId' => wfWikiID(), // e.g. "my_wiki-en_"
+ 'mimeCallback' => [ $this, 'guessMimeInternal' ],
+ 'obResetFunc' => 'wfResetOutputBuffers',
+ 'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ],
+ 'tmpDirectory' => wfTempDir(),
+ 'statusWrapper' => [ 'Status', 'wrap' ],
+ 'wanCache' => MediaWikiServices::getInstance()->getMainWANObjectCache(),
+ 'srvCache' => ObjectCache::getLocalServerInstance( 'hash' ),
+ 'logger' => LoggerFactory::getInstance( 'FileOperation' ),
+ 'profiler' => Profiler::instance()
+ ];
+ $config['lockManager'] =
+ LockManagerGroup::singleton( $config['wikiId'] )->get( $config['lockManager'] );
+ $config['fileJournal'] = isset( $config['fileJournal'] )
+ ? FileJournal::factory( $config['fileJournal'], $name )
+ : FileJournal::factory( [ 'class' => 'NullFileJournal' ], $name );
+
+ return $config;
+ }
+
+ /**
+ * Get an appropriate backend object from a storage path
+ *
+ * @param string $storagePath
+ * @return FileBackend|null Backend or null on failure
+ */
+ public function backendFromPath( $storagePath ) {
+ list( $backend, , ) = FileBackend::splitStoragePath( $storagePath );
+ if ( $backend !== null && isset( $this->backends[$backend] ) ) {
+ return $this->get( $backend );
+ }
+
+ return null;
+ }
+
+ /**
+ * @param string $storagePath
+ * @param string|null $content
+ * @param string|null $fsPath
+ * @return string
+ * @since 1.27
+ */
+ public function guessMimeInternal( $storagePath, $content, $fsPath ) {
+ $magic = MimeMagic::singleton();
+ // Trust the extension of the storage path (caller must validate)
+ $ext = FileBackend::extensionFromPath( $storagePath );
+ $type = $magic->guessTypesForExtension( $ext );
+ // For files without a valid extension (or one at all), inspect the contents
+ if ( !$type && $fsPath ) {
+ $type = $magic->guessMimeType( $fsPath, false );
+ } elseif ( !$type && strlen( $content ) ) {
+ $tmpFile = TempFSFile::factory( 'mime_', '', wfTempDir() );
+ file_put_contents( $tmpFile->getPath(), $content );
+ $type = $magic->guessMimeType( $tmpFile->getPath(), false );
+ }
+ return $type ?: 'unknown/unknown';
+ }
+}
diff --git a/www/wiki/includes/filebackend/FileBackendMultiWrite.php b/www/wiki/includes/filebackend/FileBackendMultiWrite.php
new file mode 100644
index 00000000..3b200482
--- /dev/null
+++ b/www/wiki/includes/filebackend/FileBackendMultiWrite.php
@@ -0,0 +1,761 @@
+<?php
+/**
+ * Proxy backend that mirrors writes to several internal backends.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Aaron Schulz
+ */
+
+/**
+ * @brief Proxy backend that mirrors writes to several internal backends.
+ *
+ * This class defines a multi-write backend. Multiple backends can be
+ * registered to this proxy backend and it will act as a single backend.
+ * Use this when all access to those backends is through this proxy backend.
+ * At least one of the backends must be declared the "master" backend.
+ *
+ * Only use this class when transitioning from one storage system to another.
+ *
+ * Read operations are only done on the 'master' backend for consistency.
+ * Write operations are performed on all backends, starting with the master.
+ * This makes a best-effort to have transactional semantics, but since requests
+ * may sometimes fail, the use of "autoResync" or background scripts to fix
+ * inconsistencies is important.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+class FileBackendMultiWrite extends FileBackend {
+ /** @var FileBackendStore[] Prioritized list of FileBackendStore objects */
+ protected $backends = [];
+
+ /** @var int Index of master backend */
+ protected $masterIndex = -1;
+ /** @var int Index of read affinity backend */
+ protected $readIndex = -1;
+
+ /** @var int Bitfield */
+ protected $syncChecks = 0;
+ /** @var string|bool */
+ protected $autoResync = false;
+
+ /** @var bool */
+ protected $asyncWrites = false;
+
+ /* Possible internal backend consistency checks */
+ const CHECK_SIZE = 1;
+ const CHECK_TIME = 2;
+ const CHECK_SHA1 = 4;
+
+ /**
+ * Construct a proxy backend that consists of several internal backends.
+ * Locking, journaling, and read-only checks are handled by the proxy backend.
+ *
+ * Additional $config params include:
+ * - backends : Array of backend config and multi-backend settings.
+ * Each value is the config used in the constructor of a
+ * FileBackendStore class, but with these additional settings:
+ * - class : The name of the backend class
+ * - isMultiMaster : This must be set for one backend.
+ * - readAffinity : Use this for reads without 'latest' set.
+ * - template: : If given a backend name, this will use
+ * the config of that backend as a template.
+ * Values specified here take precedence.
+ * - syncChecks : Integer bitfield of internal backend sync checks to perform.
+ * Possible bits include the FileBackendMultiWrite::CHECK_* constants.
+ * There are constants for SIZE, TIME, and SHA1.
+ * The checks are done before allowing any file operations.
+ * - autoResync : Automatically resync the clone backends to the master backend
+ * when pre-operation sync checks fail. This should only be used
+ * if the master backend is stable and not missing any files.
+ * Use "conservative" to limit resyncing to copying newer master
+ * backend files over older (or non-existing) clone backend files.
+ * Cases that cannot be handled will result in operation abortion.
+ * - replication : Set to 'async' to defer file operations on the non-master backends.
+ * This will apply such updates post-send for web requests. Note that
+ * any checks from "syncChecks" are still synchronous.
+ *
+ * @param array $config
+ * @throws FileBackendError
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+ $this->syncChecks = isset( $config['syncChecks'] )
+ ? $config['syncChecks']
+ : self::CHECK_SIZE;
+ $this->autoResync = isset( $config['autoResync'] )
+ ? $config['autoResync']
+ : false;
+ $this->asyncWrites = isset( $config['replication'] ) && $config['replication'] === 'async';
+ // Construct backends here rather than via registration
+ // to keep these backends hidden from outside the proxy.
+ $namesUsed = [];
+ foreach ( $config['backends'] as $index => $config ) {
+ if ( isset( $config['template'] ) ) {
+ // Config is just a modified version of a registered backend's.
+ // This should only be used when that config is used only by this backend.
+ $config = $config + FileBackendGroup::singleton()->config( $config['template'] );
+ }
+ $name = $config['name'];
+ if ( isset( $namesUsed[$name] ) ) { // don't break FileOp predicates
+ throw new FileBackendError( "Two or more backends defined with the name $name." );
+ }
+ $namesUsed[$name] = 1;
+ // Alter certain sub-backend settings for sanity
+ unset( $config['readOnly'] ); // use proxy backend setting
+ unset( $config['fileJournal'] ); // use proxy backend journal
+ unset( $config['lockManager'] ); // lock under proxy backend
+ $config['wikiId'] = $this->wikiId; // use the proxy backend wiki ID
+ if ( !empty( $config['isMultiMaster'] ) ) {
+ if ( $this->masterIndex >= 0 ) {
+ throw new FileBackendError( 'More than one master backend defined.' );
+ }
+ $this->masterIndex = $index; // this is the "master"
+ $config['fileJournal'] = $this->fileJournal; // log under proxy backend
+ }
+ if ( !empty( $config['readAffinity'] ) ) {
+ $this->readIndex = $index; // prefer this for reads
+ }
+ // Create sub-backend object
+ if ( !isset( $config['class'] ) ) {
+ throw new FileBackendError( 'No class given for a backend config.' );
+ }
+ $class = $config['class'];
+ $this->backends[$index] = new $class( $config );
+ }
+ if ( $this->masterIndex < 0 ) { // need backends and must have a master
+ throw new FileBackendError( 'No master backend defined.' );
+ }
+ if ( $this->readIndex < 0 ) {
+ $this->readIndex = $this->masterIndex; // default
+ }
+ }
+
+ final protected function doOperationsInternal( array $ops, array $opts ) {
+ $status = Status::newGood();
+
+ $mbe = $this->backends[$this->masterIndex]; // convenience
+
+ // Try to lock those files for the scope of this function...
+ $scopeLock = null;
+ if ( empty( $opts['nonLocking'] ) ) {
+ // Try to lock those files for the scope of this function...
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scopeLock = $this->getScopedLocksForOps( $ops, $status );
+ if ( !$status->isOK() ) {
+ return $status; // abort
+ }
+ }
+ // Clear any cache entries (after locks acquired)
+ $this->clearCache();
+ $opts['preserveCache'] = true; // only locked files are cached
+ // Get the list of paths to read/write...
+ $relevantPaths = $this->fileStoragePathsForOps( $ops );
+ // Check if the paths are valid and accessible on all backends...
+ $status->merge( $this->accessibilityCheck( $relevantPaths ) );
+ if ( !$status->isOK() ) {
+ return $status; // abort
+ }
+ // Do a consistency check to see if the backends are consistent...
+ $syncStatus = $this->consistencyCheck( $relevantPaths );
+ if ( !$syncStatus->isOK() ) {
+ wfDebugLog( 'FileOperation', get_class( $this ) .
+ " failed sync check: " . FormatJson::encode( $relevantPaths ) );
+ // Try to resync the clone backends to the master on the spot...
+ if ( $this->autoResync === false
+ || !$this->resyncFiles( $relevantPaths, $this->autoResync )->isOK()
+ ) {
+ $status->merge( $syncStatus );
+
+ return $status; // abort
+ }
+ }
+ // Actually attempt the operation batch on the master backend...
+ $realOps = $this->substOpBatchPaths( $ops, $mbe );
+ $masterStatus = $mbe->doOperations( $realOps, $opts );
+ $status->merge( $masterStatus );
+ // Propagate the operations to the clone backends if there were no unexpected errors
+ // and if there were either no expected errors or if the 'force' option was used.
+ // However, if nothing succeeded at all, then don't replicate any of the operations.
+ // If $ops only had one operation, this might avoid backend sync inconsistencies.
+ if ( $masterStatus->isOK() && $masterStatus->successCount > 0 ) {
+ foreach ( $this->backends as $index => $backend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // done already
+ }
+
+ $realOps = $this->substOpBatchPaths( $ops, $backend );
+ if ( $this->asyncWrites && !$this->hasVolatileSources( $ops ) ) {
+ // Bind $scopeLock to the callback to preserve locks
+ DeferredUpdates::addCallableUpdate(
+ function() use ( $backend, $realOps, $opts, $scopeLock, $relevantPaths ) {
+ wfDebugLog( 'FileOperationReplication',
+ "'{$backend->getName()}' async replication; paths: " .
+ FormatJson::encode( $relevantPaths ) );
+ $backend->doOperations( $realOps, $opts );
+ }
+ );
+ } else {
+ wfDebugLog( 'FileOperationReplication',
+ "'{$backend->getName()}' sync replication; paths: " .
+ FormatJson::encode( $relevantPaths ) );
+ $status->merge( $backend->doOperations( $realOps, $opts ) );
+ }
+ }
+ }
+ // Make 'success', 'successCount', and 'failCount' fields reflect
+ // the overall operation, rather than all the batches for each backend.
+ // Do this by only using success values from the master backend's batch.
+ $status->success = $masterStatus->success;
+ $status->successCount = $masterStatus->successCount;
+ $status->failCount = $masterStatus->failCount;
+
+ return $status;
+ }
+
+ /**
+ * Check that a set of files are consistent across all internal backends
+ *
+ * @param array $paths List of storage paths
+ * @return Status
+ */
+ public function consistencyCheck( array $paths ) {
+ $status = Status::newGood();
+ if ( $this->syncChecks == 0 || count( $this->backends ) <= 1 ) {
+ return $status; // skip checks
+ }
+
+ // Preload all of the stat info in as few round trips as possible...
+ foreach ( $this->backends as $backend ) {
+ $realPaths = $this->substPaths( $paths, $backend );
+ $backend->preloadFileStat( [ 'srcs' => $realPaths, 'latest' => true ] );
+ }
+
+ $mBackend = $this->backends[$this->masterIndex];
+ foreach ( $paths as $path ) {
+ $params = [ 'src' => $path, 'latest' => true ];
+ $mParams = $this->substOpPaths( $params, $mBackend );
+ // Stat the file on the 'master' backend
+ $mStat = $mBackend->getFileStat( $mParams );
+ if ( $this->syncChecks & self::CHECK_SHA1 ) {
+ $mSha1 = $mBackend->getFileSha1Base36( $mParams );
+ } else {
+ $mSha1 = false;
+ }
+ // Check if all clone backends agree with the master...
+ foreach ( $this->backends as $index => $cBackend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // master
+ }
+ $cParams = $this->substOpPaths( $params, $cBackend );
+ $cStat = $cBackend->getFileStat( $cParams );
+ if ( $mStat ) { // file is in master
+ if ( !$cStat ) { // file should exist
+ $status->fatal( 'backend-fail-synced', $path );
+ continue;
+ }
+ if ( $this->syncChecks & self::CHECK_SIZE ) {
+ if ( $cStat['size'] != $mStat['size'] ) { // wrong size
+ $status->fatal( 'backend-fail-synced', $path );
+ continue;
+ }
+ }
+ if ( $this->syncChecks & self::CHECK_TIME ) {
+ $mTs = wfTimestamp( TS_UNIX, $mStat['mtime'] );
+ $cTs = wfTimestamp( TS_UNIX, $cStat['mtime'] );
+ if ( abs( $mTs - $cTs ) > 30 ) { // outdated file somewhere
+ $status->fatal( 'backend-fail-synced', $path );
+ continue;
+ }
+ }
+ if ( $this->syncChecks & self::CHECK_SHA1 ) {
+ if ( $cBackend->getFileSha1Base36( $cParams ) !== $mSha1 ) { // wrong SHA1
+ $status->fatal( 'backend-fail-synced', $path );
+ continue;
+ }
+ }
+ } else { // file is not in master
+ if ( $cStat ) { // file should not exist
+ $status->fatal( 'backend-fail-synced', $path );
+ }
+ }
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Check that a set of file paths are usable across all internal backends
+ *
+ * @param array $paths List of storage paths
+ * @return Status
+ */
+ public function accessibilityCheck( array $paths ) {
+ $status = Status::newGood();
+ if ( count( $this->backends ) <= 1 ) {
+ return $status; // skip checks
+ }
+
+ foreach ( $paths as $path ) {
+ foreach ( $this->backends as $backend ) {
+ $realPath = $this->substPaths( $path, $backend );
+ if ( !$backend->isPathUsableInternal( $realPath ) ) {
+ $status->fatal( 'backend-fail-usable', $path );
+ }
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Check that a set of files are consistent across all internal backends
+ * and re-synchronize those files against the "multi master" if needed.
+ *
+ * @param array $paths List of storage paths
+ * @param string|bool $resyncMode False, True, or "conservative"; see __construct()
+ * @return Status
+ */
+ public function resyncFiles( array $paths, $resyncMode = true ) {
+ $status = Status::newGood();
+
+ $mBackend = $this->backends[$this->masterIndex];
+ foreach ( $paths as $path ) {
+ $mPath = $this->substPaths( $path, $mBackend );
+ $mSha1 = $mBackend->getFileSha1Base36( [ 'src' => $mPath, 'latest' => true ] );
+ $mStat = $mBackend->getFileStat( [ 'src' => $mPath, 'latest' => true ] );
+ if ( $mStat === null || ( $mSha1 !== false && !$mStat ) ) { // sanity
+ $status->fatal( 'backend-fail-internal', $this->name );
+ wfDebugLog( 'FileOperation', __METHOD__
+ . ': File is not available on the master backend' );
+ continue; // file is not available on the master backend...
+ }
+ // Check of all clone backends agree with the master...
+ foreach ( $this->backends as $index => $cBackend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // master
+ }
+ $cPath = $this->substPaths( $path, $cBackend );
+ $cSha1 = $cBackend->getFileSha1Base36( [ 'src' => $cPath, 'latest' => true ] );
+ $cStat = $cBackend->getFileStat( [ 'src' => $cPath, 'latest' => true ] );
+ if ( $cStat === null || ( $cSha1 !== false && !$cStat ) ) { // sanity
+ $status->fatal( 'backend-fail-internal', $cBackend->getName() );
+ wfDebugLog( 'FileOperation', __METHOD__ .
+ ': File is not available on the clone backend' );
+ continue; // file is not available on the clone backend...
+ }
+ if ( $mSha1 === $cSha1 ) {
+ // already synced; nothing to do
+ } elseif ( $mSha1 !== false ) { // file is in master
+ if ( $resyncMode === 'conservative'
+ && $cStat && $cStat['mtime'] > $mStat['mtime']
+ ) {
+ $status->fatal( 'backend-fail-synced', $path );
+ continue; // don't rollback data
+ }
+ $fsFile = $mBackend->getLocalReference(
+ [ 'src' => $mPath, 'latest' => true ] );
+ $status->merge( $cBackend->quickStore(
+ [ 'src' => $fsFile->getPath(), 'dst' => $cPath ]
+ ) );
+ } elseif ( $mStat === false ) { // file is not in master
+ if ( $resyncMode === 'conservative' ) {
+ $status->fatal( 'backend-fail-synced', $path );
+ continue; // don't delete data
+ }
+ $status->merge( $cBackend->quickDelete( [ 'src' => $cPath ] ) );
+ }
+ }
+ }
+
+ if ( !$status->isOK() ) {
+ wfDebugLog( 'FileOperation', get_class( $this ) .
+ " failed to resync: " . FormatJson::encode( $paths ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get a list of file storage paths to read or write for a list of operations
+ *
+ * @param array $ops Same format as doOperations()
+ * @return array List of storage paths to files (does not include directories)
+ */
+ protected function fileStoragePathsForOps( array $ops ) {
+ $paths = [];
+ foreach ( $ops as $op ) {
+ if ( isset( $op['src'] ) ) {
+ // For things like copy/move/delete with "ignoreMissingSource" and there
+ // is no source file, nothing should happen and there should be no errors.
+ if ( empty( $op['ignoreMissingSource'] )
+ || $this->fileExists( [ 'src' => $op['src'] ] )
+ ) {
+ $paths[] = $op['src'];
+ }
+ }
+ if ( isset( $op['srcs'] ) ) {
+ $paths = array_merge( $paths, $op['srcs'] );
+ }
+ if ( isset( $op['dst'] ) ) {
+ $paths[] = $op['dst'];
+ }
+ }
+
+ return array_values( array_unique( array_filter( $paths, 'FileBackend::isStoragePath' ) ) );
+ }
+
+ /**
+ * Substitute the backend name in storage path parameters
+ * for a set of operations with that of a given internal backend.
+ *
+ * @param array $ops List of file operation arrays
+ * @param FileBackendStore $backend
+ * @return array
+ */
+ protected function substOpBatchPaths( array $ops, FileBackendStore $backend ) {
+ $newOps = []; // operations
+ foreach ( $ops as $op ) {
+ $newOp = $op; // operation
+ foreach ( [ 'src', 'srcs', 'dst', 'dir' ] as $par ) {
+ if ( isset( $newOp[$par] ) ) { // string or array
+ $newOp[$par] = $this->substPaths( $newOp[$par], $backend );
+ }
+ }
+ $newOps[] = $newOp;
+ }
+
+ return $newOps;
+ }
+
+ /**
+ * Same as substOpBatchPaths() but for a single operation
+ *
+ * @param array $ops File operation array
+ * @param FileBackendStore $backend
+ * @return array
+ */
+ protected function substOpPaths( array $ops, FileBackendStore $backend ) {
+ $newOps = $this->substOpBatchPaths( [ $ops ], $backend );
+
+ return $newOps[0];
+ }
+
+ /**
+ * Substitute the backend of storage paths with an internal backend's name
+ *
+ * @param array|string $paths List of paths or single string path
+ * @param FileBackendStore $backend
+ * @return array|string
+ */
+ protected function substPaths( $paths, FileBackendStore $backend ) {
+ return preg_replace(
+ '!^mwstore://' . preg_quote( $this->name, '!' ) . '/!',
+ StringUtils::escapeRegexReplacement( "mwstore://{$backend->getName()}/" ),
+ $paths // string or array
+ );
+ }
+
+ /**
+ * Substitute the backend of internal storage paths with the proxy backend's name
+ *
+ * @param array|string $paths List of paths or single string path
+ * @return array|string
+ */
+ protected function unsubstPaths( $paths ) {
+ return preg_replace(
+ '!^mwstore://([^/]+)!',
+ StringUtils::escapeRegexReplacement( "mwstore://{$this->name}" ),
+ $paths // string or array
+ );
+ }
+
+ /**
+ * @param array $ops File operations for FileBackend::doOperations()
+ * @return bool Whether there are file path sources with outside lifetime/ownership
+ */
+ protected function hasVolatileSources( array $ops ) {
+ foreach ( $ops as $op ) {
+ if ( $op['op'] === 'store' && !isset( $op['srcRef'] ) ) {
+ return true; // source file might be deleted anytime after do*Operations()
+ }
+ }
+
+ return false;
+ }
+
+ protected function doQuickOperationsInternal( array $ops ) {
+ $status = Status::newGood();
+ // Do the operations on the master backend; setting Status fields...
+ $realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] );
+ $masterStatus = $this->backends[$this->masterIndex]->doQuickOperations( $realOps );
+ $status->merge( $masterStatus );
+ // Propagate the operations to the clone backends...
+ foreach ( $this->backends as $index => $backend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // done already
+ }
+
+ $realOps = $this->substOpBatchPaths( $ops, $backend );
+ if ( $this->asyncWrites && !$this->hasVolatileSources( $ops ) ) {
+ DeferredUpdates::addCallableUpdate(
+ function() use ( $backend, $realOps ) {
+ $backend->doQuickOperations( $realOps );
+ }
+ );
+ } else {
+ $status->merge( $backend->doQuickOperations( $realOps ) );
+ }
+ }
+ // Make 'success', 'successCount', and 'failCount' fields reflect
+ // the overall operation, rather than all the batches for each backend.
+ // Do this by only using success values from the master backend's batch.
+ $status->success = $masterStatus->success;
+ $status->successCount = $masterStatus->successCount;
+ $status->failCount = $masterStatus->failCount;
+
+ return $status;
+ }
+
+ protected function doPrepare( array $params ) {
+ return $this->doDirectoryOp( 'prepare', $params );
+ }
+
+ protected function doSecure( array $params ) {
+ return $this->doDirectoryOp( 'secure', $params );
+ }
+
+ protected function doPublish( array $params ) {
+ return $this->doDirectoryOp( 'publish', $params );
+ }
+
+ protected function doClean( array $params ) {
+ return $this->doDirectoryOp( 'clean', $params );
+ }
+
+ /**
+ * @param string $method One of (doPrepare,doSecure,doPublish,doClean)
+ * @param array $params Method arguments
+ * @return Status
+ */
+ protected function doDirectoryOp( $method, array $params ) {
+ $status = Status::newGood();
+
+ $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
+ $masterStatus = $this->backends[$this->masterIndex]->$method( $realParams );
+ $status->merge( $masterStatus );
+
+ foreach ( $this->backends as $index => $backend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // already done
+ }
+
+ $realParams = $this->substOpPaths( $params, $backend );
+ if ( $this->asyncWrites ) {
+ DeferredUpdates::addCallableUpdate(
+ function() use ( $backend, $method, $realParams ) {
+ $backend->$method( $realParams );
+ }
+ );
+ } else {
+ $status->merge( $backend->$method( $realParams ) );
+ }
+ }
+
+ return $status;
+ }
+
+ public function concatenate( array $params ) {
+ // We are writing to an FS file, so we don't need to do this per-backend
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->concatenate( $realParams );
+ }
+
+ public function fileExists( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->fileExists( $realParams );
+ }
+
+ public function getFileTimestamp( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileTimestamp( $realParams );
+ }
+
+ public function getFileSize( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileSize( $realParams );
+ }
+
+ public function getFileStat( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileStat( $realParams );
+ }
+
+ public function getFileXAttributes( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileXAttributes( $realParams );
+ }
+
+ public function getFileContentsMulti( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ $contentsM = $this->backends[$index]->getFileContentsMulti( $realParams );
+
+ $contents = []; // (path => FSFile) mapping using the proxy backend's name
+ foreach ( $contentsM as $path => $data ) {
+ $contents[$this->unsubstPaths( $path )] = $data;
+ }
+
+ return $contents;
+ }
+
+ public function getFileSha1Base36( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileSha1Base36( $realParams );
+ }
+
+ public function getFileProps( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileProps( $realParams );
+ }
+
+ public function streamFile( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->streamFile( $realParams );
+ }
+
+ public function getLocalReferenceMulti( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ $fsFilesM = $this->backends[$index]->getLocalReferenceMulti( $realParams );
+
+ $fsFiles = []; // (path => FSFile) mapping using the proxy backend's name
+ foreach ( $fsFilesM as $path => $fsFile ) {
+ $fsFiles[$this->unsubstPaths( $path )] = $fsFile;
+ }
+
+ return $fsFiles;
+ }
+
+ public function getLocalCopyMulti( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ $tempFilesM = $this->backends[$index]->getLocalCopyMulti( $realParams );
+
+ $tempFiles = []; // (path => TempFSFile) mapping using the proxy backend's name
+ foreach ( $tempFilesM as $path => $tempFile ) {
+ $tempFiles[$this->unsubstPaths( $path )] = $tempFile;
+ }
+
+ return $tempFiles;
+ }
+
+ public function getFileHttpUrl( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileHttpUrl( $realParams );
+ }
+
+ public function directoryExists( array $params ) {
+ $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
+
+ return $this->backends[$this->masterIndex]->directoryExists( $realParams );
+ }
+
+ public function getDirectoryList( array $params ) {
+ $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
+
+ return $this->backends[$this->masterIndex]->getDirectoryList( $realParams );
+ }
+
+ public function getFileList( array $params ) {
+ $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
+
+ return $this->backends[$this->masterIndex]->getFileList( $realParams );
+ }
+
+ public function getFeatures() {
+ return $this->backends[$this->masterIndex]->getFeatures();
+ }
+
+ public function clearCache( array $paths = null ) {
+ foreach ( $this->backends as $backend ) {
+ $realPaths = is_array( $paths ) ? $this->substPaths( $paths, $backend ) : null;
+ $backend->clearCache( $realPaths );
+ }
+ }
+
+ public function preloadCache( array $paths ) {
+ $realPaths = $this->substPaths( $paths, $this->backends[$this->readIndex] );
+ $this->backends[$this->readIndex]->preloadCache( $realPaths );
+ }
+
+ public function preloadFileStat( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->preloadFileStat( $realParams );
+ }
+
+ public function getScopedLocksForOps( array $ops, Status $status ) {
+ $realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] );
+ $fileOps = $this->backends[$this->masterIndex]->getOperationsInternal( $realOps );
+ // Get the paths to lock from the master backend
+ $paths = $this->backends[$this->masterIndex]->getPathsToLockForOpsInternal( $fileOps );
+ // Get the paths under the proxy backend's name
+ $pbPaths = [
+ LockManager::LOCK_UW => $this->unsubstPaths( $paths[LockManager::LOCK_UW] ),
+ LockManager::LOCK_EX => $this->unsubstPaths( $paths[LockManager::LOCK_EX] )
+ ];
+
+ // Actually acquire the locks
+ return $this->getScopedFileLocks( $pbPaths, 'mixed', $status );
+ }
+
+ /**
+ * @param array $params
+ * @return int The master or read affinity backend index, based on $params['latest']
+ */
+ protected function getReadIndexFromParams( array $params ) {
+ return !empty( $params['latest'] ) ? $this->masterIndex : $this->readIndex;
+ }
+}
diff --git a/www/wiki/includes/filebackend/FileBackendStore.php b/www/wiki/includes/filebackend/FileBackendStore.php
new file mode 100644
index 00000000..4d9587ef
--- /dev/null
+++ b/www/wiki/includes/filebackend/FileBackendStore.php
@@ -0,0 +1,1971 @@
+<?php
+/**
+ * Base class for all backends using particular storage medium.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Aaron Schulz
+ */
+
+/**
+ * @brief Base class for all backends using particular storage medium.
+ *
+ * This class defines the methods as abstract that subclasses must implement.
+ * Outside callers should *not* use functions with "Internal" in the name.
+ *
+ * The FileBackend operations are implemented using basic functions
+ * such as storeInternal(), copyInternal(), deleteInternal() and the like.
+ * This class is also responsible for path resolution and sanitization.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+abstract class FileBackendStore extends FileBackend {
+ /** @var WANObjectCache */
+ protected $memCache;
+ /** @var ProcessCacheLRU Map of paths to small (RAM/disk) cache items */
+ protected $cheapCache;
+ /** @var ProcessCacheLRU Map of paths to large (RAM/disk) cache items */
+ protected $expensiveCache;
+
+ /** @var array Map of container names to sharding config */
+ protected $shardViaHashLevels = [];
+
+ /** @var callable Method to get the MIME type of files */
+ protected $mimeCallback;
+
+ protected $maxFileSize = 4294967296; // integer bytes (4GiB)
+
+ const CACHE_TTL = 10; // integer; TTL in seconds for process cache entries
+ const CACHE_CHEAP_SIZE = 500; // integer; max entries in "cheap cache"
+ const CACHE_EXPENSIVE_SIZE = 5; // integer; max entries in "expensive cache"
+
+ /**
+ * @see FileBackend::__construct()
+ * Additional $config params include:
+ * - wanCache : WANObjectCache object to use for persistent caching.
+ * - mimeCallback : Callback that takes (storage path, content, file system path) and
+ * returns the MIME type of the file or 'unknown/unknown'. The file
+ * system path parameter should be used if the content one is null.
+ *
+ * @param array $config
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+ $this->mimeCallback = isset( $config['mimeCallback'] )
+ ? $config['mimeCallback']
+ : null;
+ $this->memCache = WANObjectCache::newEmpty(); // disabled by default
+ $this->cheapCache = new ProcessCacheLRU( self::CACHE_CHEAP_SIZE );
+ $this->expensiveCache = new ProcessCacheLRU( self::CACHE_EXPENSIVE_SIZE );
+ }
+
+ /**
+ * Get the maximum allowable file size given backend
+ * medium restrictions and basic performance constraints.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * @return int Bytes
+ */
+ final public function maxFileSizeInternal() {
+ return $this->maxFileSize;
+ }
+
+ /**
+ * Check if a file can be created or changed at a given storage path.
+ * FS backends should check if the parent directory exists, files can be
+ * written under it, and that any file already there is writable.
+ * Backends using key/value stores should check if the container exists.
+ *
+ * @param string $storagePath
+ * @return bool
+ */
+ abstract public function isPathUsableInternal( $storagePath );
+
+ /**
+ * Create a file in the backend with the given contents.
+ * This will overwrite any file that exists at the destination.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - content : the raw file contents
+ * - dst : destination storage path
+ * - headers : HTTP header name/value map
+ * - async : Status will be returned immediately if supported.
+ * If the status is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ * - dstExists : Whether a file exists at the destination (optimization).
+ * Callers can use "false" if no existing file is being changed.
+ *
+ * @param array $params
+ * @return Status
+ */
+ final public function createInternal( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ if ( strlen( $params['content'] ) > $this->maxFileSizeInternal() ) {
+ $status = Status::newFatal( 'backend-fail-maxsize',
+ $params['dst'], $this->maxFileSizeInternal() );
+ } else {
+ $status = $this->doCreateInternal( $params );
+ $this->clearCache( [ $params['dst'] ] );
+ if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) {
+ $this->deleteFileCache( $params['dst'] ); // persistent cache
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::createInternal()
+ * @param array $params
+ * @return Status
+ */
+ abstract protected function doCreateInternal( array $params );
+
+ /**
+ * Store a file into the backend from a file on disk.
+ * This will overwrite any file that exists at the destination.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source path on disk
+ * - dst : destination storage path
+ * - headers : HTTP header name/value map
+ * - async : Status will be returned immediately if supported.
+ * If the status is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ * - dstExists : Whether a file exists at the destination (optimization).
+ * Callers can use "false" if no existing file is being changed.
+ *
+ * @param array $params
+ * @return Status
+ */
+ final public function storeInternal( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ if ( filesize( $params['src'] ) > $this->maxFileSizeInternal() ) {
+ $status = Status::newFatal( 'backend-fail-maxsize',
+ $params['dst'], $this->maxFileSizeInternal() );
+ } else {
+ $status = $this->doStoreInternal( $params );
+ $this->clearCache( [ $params['dst'] ] );
+ if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) {
+ $this->deleteFileCache( $params['dst'] ); // persistent cache
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::storeInternal()
+ * @param array $params
+ * @return Status
+ */
+ abstract protected function doStoreInternal( array $params );
+
+ /**
+ * Copy a file from one storage path to another in the backend.
+ * This will overwrite any file that exists at the destination.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source storage path
+ * - dst : destination storage path
+ * - ignoreMissingSource : do nothing if the source file does not exist
+ * - headers : HTTP header name/value map
+ * - async : Status will be returned immediately if supported.
+ * If the status is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ * - dstExists : Whether a file exists at the destination (optimization).
+ * Callers can use "false" if no existing file is being changed.
+ *
+ * @param array $params
+ * @return Status
+ */
+ final public function copyInternal( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = $this->doCopyInternal( $params );
+ $this->clearCache( [ $params['dst'] ] );
+ if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) {
+ $this->deleteFileCache( $params['dst'] ); // persistent cache
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::copyInternal()
+ * @param array $params
+ * @return Status
+ */
+ abstract protected function doCopyInternal( array $params );
+
+ /**
+ * Delete a file at the storage path.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source storage path
+ * - ignoreMissingSource : do nothing if the source file does not exist
+ * - async : Status will be returned immediately if supported.
+ * If the status is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ *
+ * @param array $params
+ * @return Status
+ */
+ final public function deleteInternal( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = $this->doDeleteInternal( $params );
+ $this->clearCache( [ $params['src'] ] );
+ $this->deleteFileCache( $params['src'] ); // persistent cache
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::deleteInternal()
+ * @param array $params
+ * @return Status
+ */
+ abstract protected function doDeleteInternal( array $params );
+
+ /**
+ * Move a file from one storage path to another in the backend.
+ * This will overwrite any file that exists at the destination.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source storage path
+ * - dst : destination storage path
+ * - ignoreMissingSource : do nothing if the source file does not exist
+ * - headers : HTTP header name/value map
+ * - async : Status will be returned immediately if supported.
+ * If the status is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ * - dstExists : Whether a file exists at the destination (optimization).
+ * Callers can use "false" if no existing file is being changed.
+ *
+ * @param array $params
+ * @return Status
+ */
+ final public function moveInternal( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = $this->doMoveInternal( $params );
+ $this->clearCache( [ $params['src'], $params['dst'] ] );
+ $this->deleteFileCache( $params['src'] ); // persistent cache
+ if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) {
+ $this->deleteFileCache( $params['dst'] ); // persistent cache
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::moveInternal()
+ * @param array $params
+ * @return Status
+ */
+ protected function doMoveInternal( array $params ) {
+ unset( $params['async'] ); // two steps, won't work here :)
+ $nsrc = FileBackend::normalizeStoragePath( $params['src'] );
+ $ndst = FileBackend::normalizeStoragePath( $params['dst'] );
+ // Copy source to dest
+ $status = $this->copyInternal( $params );
+ if ( $nsrc !== $ndst && $status->isOK() ) {
+ // Delete source (only fails due to races or network problems)
+ $status->merge( $this->deleteInternal( [ 'src' => $params['src'] ] ) );
+ $status->setResult( true, $status->value ); // ignore delete() errors
+ }
+
+ return $status;
+ }
+
+ /**
+ * Alter metadata for a file at the storage path.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source storage path
+ * - headers : HTTP header name/value map
+ * - async : Status will be returned immediately if supported.
+ * If the status is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ *
+ * @param array $params
+ * @return Status
+ */
+ final public function describeInternal( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ if ( count( $params['headers'] ) ) {
+ $status = $this->doDescribeInternal( $params );
+ $this->clearCache( [ $params['src'] ] );
+ $this->deleteFileCache( $params['src'] ); // persistent cache
+ } else {
+ $status = Status::newGood(); // nothing to do
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::describeInternal()
+ * @param array $params
+ * @return Status
+ */
+ protected function doDescribeInternal( array $params ) {
+ return Status::newGood();
+ }
+
+ /**
+ * No-op file operation that does nothing.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * @param array $params
+ * @return Status
+ */
+ final public function nullInternal( array $params ) {
+ return Status::newGood();
+ }
+
+ final public function concatenate( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = Status::newGood();
+
+ // Try to lock the source files for the scope of this function
+ $scopeLockS = $this->getScopedFileLocks( $params['srcs'], LockManager::LOCK_UW, $status );
+ if ( $status->isOK() ) {
+ // Actually do the file concatenation...
+ $start_time = microtime( true );
+ $status->merge( $this->doConcatenate( $params ) );
+ $sec = microtime( true ) - $start_time;
+ if ( !$status->isOK() ) {
+ wfDebugLog( 'FileOperation', get_class( $this ) . "-{$this->name}" .
+ " failed to concatenate " . count( $params['srcs'] ) . " file(s) [$sec sec]" );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::concatenate()
+ * @param array $params
+ * @return Status
+ */
+ protected function doConcatenate( array $params ) {
+ $status = Status::newGood();
+ $tmpPath = $params['dst']; // convenience
+ unset( $params['latest'] ); // sanity
+
+ // Check that the specified temp file is valid...
+ MediaWiki\suppressWarnings();
+ $ok = ( is_file( $tmpPath ) && filesize( $tmpPath ) == 0 );
+ MediaWiki\restoreWarnings();
+ if ( !$ok ) { // not present or not empty
+ $status->fatal( 'backend-fail-opentemp', $tmpPath );
+
+ return $status;
+ }
+
+ // Get local FS versions of the chunks needed for the concatenation...
+ $fsFiles = $this->getLocalReferenceMulti( $params );
+ foreach ( $fsFiles as $path => &$fsFile ) {
+ if ( !$fsFile ) { // chunk failed to download?
+ $fsFile = $this->getLocalReference( [ 'src' => $path ] );
+ if ( !$fsFile ) { // retry failed?
+ $status->fatal( 'backend-fail-read', $path );
+
+ return $status;
+ }
+ }
+ }
+ unset( $fsFile ); // unset reference so we can reuse $fsFile
+
+ // Get a handle for the destination temp file
+ $tmpHandle = fopen( $tmpPath, 'ab' );
+ if ( $tmpHandle === false ) {
+ $status->fatal( 'backend-fail-opentemp', $tmpPath );
+
+ return $status;
+ }
+
+ // Build up the temp file using the source chunks (in order)...
+ foreach ( $fsFiles as $virtualSource => $fsFile ) {
+ // Get a handle to the local FS version
+ $sourceHandle = fopen( $fsFile->getPath(), 'rb' );
+ if ( $sourceHandle === false ) {
+ fclose( $tmpHandle );
+ $status->fatal( 'backend-fail-read', $virtualSource );
+
+ return $status;
+ }
+ // Append chunk to file (pass chunk size to avoid magic quotes)
+ if ( !stream_copy_to_stream( $sourceHandle, $tmpHandle ) ) {
+ fclose( $sourceHandle );
+ fclose( $tmpHandle );
+ $status->fatal( 'backend-fail-writetemp', $tmpPath );
+
+ return $status;
+ }
+ fclose( $sourceHandle );
+ }
+ if ( !fclose( $tmpHandle ) ) {
+ $status->fatal( 'backend-fail-closetemp', $tmpPath );
+
+ return $status;
+ }
+
+ clearstatcache(); // temp file changed
+
+ return $status;
+ }
+
+ final protected function doPrepare( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = Status::newGood();
+
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
+
+ return $status; // invalid storage path
+ }
+
+ if ( $shard !== null ) { // confined to a single container/shard
+ $status->merge( $this->doPrepareInternal( $fullCont, $dir, $params ) );
+ } else { // directory is on several shards
+ wfDebug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $status->merge( $this->doPrepareInternal( "{$fullCont}{$suffix}", $dir, $params ) );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::doPrepare()
+ * @param string $container
+ * @param string $dir
+ * @param array $params
+ * @return Status
+ */
+ protected function doPrepareInternal( $container, $dir, array $params ) {
+ return Status::newGood();
+ }
+
+ final protected function doSecure( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = Status::newGood();
+
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
+
+ return $status; // invalid storage path
+ }
+
+ if ( $shard !== null ) { // confined to a single container/shard
+ $status->merge( $this->doSecureInternal( $fullCont, $dir, $params ) );
+ } else { // directory is on several shards
+ wfDebug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $status->merge( $this->doSecureInternal( "{$fullCont}{$suffix}", $dir, $params ) );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::doSecure()
+ * @param string $container
+ * @param string $dir
+ * @param array $params
+ * @return Status
+ */
+ protected function doSecureInternal( $container, $dir, array $params ) {
+ return Status::newGood();
+ }
+
+ final protected function doPublish( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = Status::newGood();
+
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
+
+ return $status; // invalid storage path
+ }
+
+ if ( $shard !== null ) { // confined to a single container/shard
+ $status->merge( $this->doPublishInternal( $fullCont, $dir, $params ) );
+ } else { // directory is on several shards
+ wfDebug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $status->merge( $this->doPublishInternal( "{$fullCont}{$suffix}", $dir, $params ) );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::doPublish()
+ * @param string $container
+ * @param string $dir
+ * @param array $params
+ * @return Status
+ */
+ protected function doPublishInternal( $container, $dir, array $params ) {
+ return Status::newGood();
+ }
+
+ final protected function doClean( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = Status::newGood();
+
+ // Recursive: first delete all empty subdirs recursively
+ if ( !empty( $params['recursive'] ) && !$this->directoriesAreVirtual() ) {
+ $subDirsRel = $this->getTopDirectoryList( [ 'dir' => $params['dir'] ] );
+ if ( $subDirsRel !== null ) { // no errors
+ foreach ( $subDirsRel as $subDirRel ) {
+ $subDir = $params['dir'] . "/{$subDirRel}"; // full path
+ $status->merge( $this->doClean( [ 'dir' => $subDir ] + $params ) );
+ }
+ unset( $subDirsRel ); // free directory for rmdir() on Windows (for FS backends)
+ }
+ }
+
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
+
+ return $status; // invalid storage path
+ }
+
+ // Attempt to lock this directory...
+ $filesLockEx = [ $params['dir'] ];
+ $scopedLockE = $this->getScopedFileLocks( $filesLockEx, LockManager::LOCK_EX, $status );
+ if ( !$status->isOK() ) {
+ return $status; // abort
+ }
+
+ if ( $shard !== null ) { // confined to a single container/shard
+ $status->merge( $this->doCleanInternal( $fullCont, $dir, $params ) );
+ $this->deleteContainerCache( $fullCont ); // purge cache
+ } else { // directory is on several shards
+ wfDebug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $status->merge( $this->doCleanInternal( "{$fullCont}{$suffix}", $dir, $params ) );
+ $this->deleteContainerCache( "{$fullCont}{$suffix}" ); // purge cache
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::doClean()
+ * @param string $container
+ * @param string $dir
+ * @param array $params
+ * @return Status
+ */
+ protected function doCleanInternal( $container, $dir, array $params ) {
+ return Status::newGood();
+ }
+
+ final public function fileExists( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $stat = $this->getFileStat( $params );
+
+ return ( $stat === null ) ? null : (bool)$stat; // null => failure
+ }
+
+ final public function getFileTimestamp( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $stat = $this->getFileStat( $params );
+
+ return $stat ? $stat['mtime'] : false;
+ }
+
+ final public function getFileSize( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $stat = $this->getFileStat( $params );
+
+ return $stat ? $stat['size'] : false;
+ }
+
+ final public function getFileStat( array $params ) {
+ $path = self::normalizeStoragePath( $params['src'] );
+ if ( $path === null ) {
+ return false; // invalid storage path
+ }
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $latest = !empty( $params['latest'] ); // use latest data?
+ if ( !$latest && !$this->cheapCache->has( $path, 'stat', self::CACHE_TTL ) ) {
+ $this->primeFileCache( [ $path ] ); // check persistent cache
+ }
+ if ( $this->cheapCache->has( $path, 'stat', self::CACHE_TTL ) ) {
+ $stat = $this->cheapCache->get( $path, 'stat' );
+ // If we want the latest data, check that this cached
+ // value was in fact fetched with the latest available data.
+ if ( is_array( $stat ) ) {
+ if ( !$latest || $stat['latest'] ) {
+ return $stat;
+ }
+ } elseif ( in_array( $stat, [ 'NOT_EXIST', 'NOT_EXIST_LATEST' ] ) ) {
+ if ( !$latest || $stat === 'NOT_EXIST_LATEST' ) {
+ return false;
+ }
+ }
+ }
+ $stat = $this->doGetFileStat( $params );
+ if ( is_array( $stat ) ) { // file exists
+ // Strongly consistent backends can automatically set "latest"
+ $stat['latest'] = isset( $stat['latest'] ) ? $stat['latest'] : $latest;
+ $this->cheapCache->set( $path, 'stat', $stat );
+ $this->setFileCache( $path, $stat ); // update persistent cache
+ if ( isset( $stat['sha1'] ) ) { // some backends store SHA-1 as metadata
+ $this->cheapCache->set( $path, 'sha1',
+ [ 'hash' => $stat['sha1'], 'latest' => $latest ] );
+ }
+ if ( isset( $stat['xattr'] ) ) { // some backends store headers/metadata
+ $stat['xattr'] = self::normalizeXAttributes( $stat['xattr'] );
+ $this->cheapCache->set( $path, 'xattr',
+ [ 'map' => $stat['xattr'], 'latest' => $latest ] );
+ }
+ } elseif ( $stat === false ) { // file does not exist
+ $this->cheapCache->set( $path, 'stat', $latest ? 'NOT_EXIST_LATEST' : 'NOT_EXIST' );
+ $this->cheapCache->set( $path, 'xattr', [ 'map' => false, 'latest' => $latest ] );
+ $this->cheapCache->set( $path, 'sha1', [ 'hash' => false, 'latest' => $latest ] );
+ wfDebug( __METHOD__ . ": File $path does not exist.\n" );
+ } else { // an error occurred
+ wfDebug( __METHOD__ . ": Could not stat file $path.\n" );
+ }
+
+ return $stat;
+ }
+
+ /**
+ * @see FileBackendStore::getFileStat()
+ */
+ abstract protected function doGetFileStat( array $params );
+
+ public function getFileContentsMulti( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ $params = $this->setConcurrencyFlags( $params );
+ $contents = $this->doGetFileContentsMulti( $params );
+
+ return $contents;
+ }
+
+ /**
+ * @see FileBackendStore::getFileContentsMulti()
+ * @param array $params
+ * @return array
+ */
+ protected function doGetFileContentsMulti( array $params ) {
+ $contents = [];
+ foreach ( $this->doGetLocalReferenceMulti( $params ) as $path => $fsFile ) {
+ MediaWiki\suppressWarnings();
+ $contents[$path] = $fsFile ? file_get_contents( $fsFile->getPath() ) : false;
+ MediaWiki\restoreWarnings();
+ }
+
+ return $contents;
+ }
+
+ final public function getFileXAttributes( array $params ) {
+ $path = self::normalizeStoragePath( $params['src'] );
+ if ( $path === null ) {
+ return false; // invalid storage path
+ }
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $latest = !empty( $params['latest'] ); // use latest data?
+ if ( $this->cheapCache->has( $path, 'xattr', self::CACHE_TTL ) ) {
+ $stat = $this->cheapCache->get( $path, 'xattr' );
+ // If we want the latest data, check that this cached
+ // value was in fact fetched with the latest available data.
+ if ( !$latest || $stat['latest'] ) {
+ return $stat['map'];
+ }
+ }
+ $fields = $this->doGetFileXAttributes( $params );
+ $fields = is_array( $fields ) ? self::normalizeXAttributes( $fields ) : false;
+ $this->cheapCache->set( $path, 'xattr', [ 'map' => $fields, 'latest' => $latest ] );
+
+ return $fields;
+ }
+
+ /**
+ * @see FileBackendStore::getFileXAttributes()
+ * @return bool|string
+ */
+ protected function doGetFileXAttributes( array $params ) {
+ return [ 'headers' => [], 'metadata' => [] ]; // not supported
+ }
+
+ final public function getFileSha1Base36( array $params ) {
+ $path = self::normalizeStoragePath( $params['src'] );
+ if ( $path === null ) {
+ return false; // invalid storage path
+ }
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $latest = !empty( $params['latest'] ); // use latest data?
+ if ( $this->cheapCache->has( $path, 'sha1', self::CACHE_TTL ) ) {
+ $stat = $this->cheapCache->get( $path, 'sha1' );
+ // If we want the latest data, check that this cached
+ // value was in fact fetched with the latest available data.
+ if ( !$latest || $stat['latest'] ) {
+ return $stat['hash'];
+ }
+ }
+ $hash = $this->doGetFileSha1Base36( $params );
+ $this->cheapCache->set( $path, 'sha1', [ 'hash' => $hash, 'latest' => $latest ] );
+
+ return $hash;
+ }
+
+ /**
+ * @see FileBackendStore::getFileSha1Base36()
+ * @param array $params
+ * @return bool|string
+ */
+ protected function doGetFileSha1Base36( array $params ) {
+ $fsFile = $this->getLocalReference( $params );
+ if ( !$fsFile ) {
+ return false;
+ } else {
+ return $fsFile->getSha1Base36();
+ }
+ }
+
+ final public function getFileProps( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $fsFile = $this->getLocalReference( $params );
+ $props = $fsFile ? $fsFile->getProps() : FSFile::placeholderProps();
+
+ return $props;
+ }
+
+ final public function getLocalReferenceMulti( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ $params = $this->setConcurrencyFlags( $params );
+
+ $fsFiles = []; // (path => FSFile)
+ $latest = !empty( $params['latest'] ); // use latest data?
+ // Reuse any files already in process cache...
+ foreach ( $params['srcs'] as $src ) {
+ $path = self::normalizeStoragePath( $src );
+ if ( $path === null ) {
+ $fsFiles[$src] = null; // invalid storage path
+ } elseif ( $this->expensiveCache->has( $path, 'localRef' ) ) {
+ $val = $this->expensiveCache->get( $path, 'localRef' );
+ // If we want the latest data, check that this cached
+ // value was in fact fetched with the latest available data.
+ if ( !$latest || $val['latest'] ) {
+ $fsFiles[$src] = $val['object'];
+ }
+ }
+ }
+ // Fetch local references of any remaning files...
+ $params['srcs'] = array_diff( $params['srcs'], array_keys( $fsFiles ) );
+ foreach ( $this->doGetLocalReferenceMulti( $params ) as $path => $fsFile ) {
+ $fsFiles[$path] = $fsFile;
+ if ( $fsFile ) { // update the process cache...
+ $this->expensiveCache->set( $path, 'localRef',
+ [ 'object' => $fsFile, 'latest' => $latest ] );
+ }
+ }
+
+ return $fsFiles;
+ }
+
+ /**
+ * @see FileBackendStore::getLocalReferenceMulti()
+ * @param array $params
+ * @return array
+ */
+ protected function doGetLocalReferenceMulti( array $params ) {
+ return $this->doGetLocalCopyMulti( $params );
+ }
+
+ final public function getLocalCopyMulti( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ $params = $this->setConcurrencyFlags( $params );
+ $tmpFiles = $this->doGetLocalCopyMulti( $params );
+
+ return $tmpFiles;
+ }
+
+ /**
+ * @see FileBackendStore::getLocalCopyMulti()
+ * @param array $params
+ * @return array
+ */
+ abstract protected function doGetLocalCopyMulti( array $params );
+
+ /**
+ * @see FileBackend::getFileHttpUrl()
+ * @param array $params
+ * @return string|null
+ */
+ public function getFileHttpUrl( array $params ) {
+ return null; // not supported
+ }
+
+ final public function streamFile( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = Status::newGood();
+
+ $info = $this->getFileStat( $params );
+ if ( !$info ) { // let StreamFile handle the 404
+ $status->fatal( 'backend-fail-notexists', $params['src'] );
+ }
+
+ // Set output buffer and HTTP headers for stream
+ $extraHeaders = isset( $params['headers'] ) ? $params['headers'] : [];
+ $res = StreamFile::prepareForStream( $params['src'], $info, $extraHeaders );
+ if ( $res == StreamFile::NOT_MODIFIED ) {
+ // do nothing; client cache is up to date
+ } elseif ( $res == StreamFile::READY_STREAM ) {
+ $status = $this->doStreamFile( $params );
+ if ( !$status->isOK() ) {
+ // Per bug 41113, nasty things can happen if bad cache entries get
+ // stuck in cache. It's also possible that this error can come up
+ // with simple race conditions. Clear out the stat cache to be safe.
+ $this->clearCache( [ $params['src'] ] );
+ $this->deleteFileCache( $params['src'] );
+ trigger_error( "Bad stat cache or race condition for file {$params['src']}." );
+ }
+ } else {
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::streamFile()
+ * @param array $params
+ * @return Status
+ */
+ protected function doStreamFile( array $params ) {
+ $status = Status::newGood();
+
+ $fsFile = $this->getLocalReference( $params );
+ if ( !$fsFile ) {
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+ } elseif ( !readfile( $fsFile->getPath() ) ) {
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+ }
+
+ return $status;
+ }
+
+ final public function directoryExists( array $params ) {
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ return false; // invalid storage path
+ }
+ if ( $shard !== null ) { // confined to a single container/shard
+ return $this->doDirectoryExists( $fullCont, $dir, $params );
+ } else { // directory is on several shards
+ wfDebug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ $res = false; // response
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $exists = $this->doDirectoryExists( "{$fullCont}{$suffix}", $dir, $params );
+ if ( $exists ) {
+ $res = true;
+ break; // found one!
+ } elseif ( $exists === null ) { // error?
+ $res = null; // if we don't find anything, it is indeterminate
+ }
+ }
+
+ return $res;
+ }
+ }
+
+ /**
+ * @see FileBackendStore::directoryExists()
+ *
+ * @param string $container Resolved container name
+ * @param string $dir Resolved path relative to container
+ * @param array $params
+ * @return bool|null
+ */
+ abstract protected function doDirectoryExists( $container, $dir, array $params );
+
+ final public function getDirectoryList( array $params ) {
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) { // invalid storage path
+ return null;
+ }
+ if ( $shard !== null ) {
+ // File listing is confined to a single container/shard
+ return $this->getDirectoryListInternal( $fullCont, $dir, $params );
+ } else {
+ wfDebug( __METHOD__ . ": iterating over all container shards.\n" );
+ // File listing spans multiple containers/shards
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+
+ return new FileBackendStoreShardDirIterator( $this,
+ $fullCont, $dir, $this->getContainerSuffixes( $shortCont ), $params );
+ }
+ }
+
+ /**
+ * Do not call this function from places outside FileBackend
+ *
+ * @see FileBackendStore::getDirectoryList()
+ *
+ * @param string $container Resolved container name
+ * @param string $dir Resolved path relative to container
+ * @param array $params
+ * @return Traversable|array|null Returns null on failure
+ */
+ abstract public function getDirectoryListInternal( $container, $dir, array $params );
+
+ final public function getFileList( array $params ) {
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) { // invalid storage path
+ return null;
+ }
+ if ( $shard !== null ) {
+ // File listing is confined to a single container/shard
+ return $this->getFileListInternal( $fullCont, $dir, $params );
+ } else {
+ wfDebug( __METHOD__ . ": iterating over all container shards.\n" );
+ // File listing spans multiple containers/shards
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+
+ return new FileBackendStoreShardFileIterator( $this,
+ $fullCont, $dir, $this->getContainerSuffixes( $shortCont ), $params );
+ }
+ }
+
+ /**
+ * Do not call this function from places outside FileBackend
+ *
+ * @see FileBackendStore::getFileList()
+ *
+ * @param string $container Resolved container name
+ * @param string $dir Resolved path relative to container
+ * @param array $params
+ * @return Traversable|array|null Returns null on failure
+ */
+ abstract public function getFileListInternal( $container, $dir, array $params );
+
+ /**
+ * Return a list of FileOp objects from a list of operations.
+ * Do not call this function from places outside FileBackend.
+ *
+ * The result must have the same number of items as the input.
+ * An exception is thrown if an unsupported operation is requested.
+ *
+ * @param array $ops Same format as doOperations()
+ * @return array List of FileOp objects
+ * @throws FileBackendError
+ */
+ final public function getOperationsInternal( array $ops ) {
+ $supportedOps = [
+ 'store' => 'StoreFileOp',
+ 'copy' => 'CopyFileOp',
+ 'move' => 'MoveFileOp',
+ 'delete' => 'DeleteFileOp',
+ 'create' => 'CreateFileOp',
+ 'describe' => 'DescribeFileOp',
+ 'null' => 'NullFileOp'
+ ];
+
+ $performOps = []; // array of FileOp objects
+ // Build up ordered array of FileOps...
+ foreach ( $ops as $operation ) {
+ $opName = $operation['op'];
+ if ( isset( $supportedOps[$opName] ) ) {
+ $class = $supportedOps[$opName];
+ // Get params for this operation
+ $params = $operation;
+ // Append the FileOp class
+ $performOps[] = new $class( $this, $params );
+ } else {
+ throw new FileBackendError( "Operation '$opName' is not supported." );
+ }
+ }
+
+ return $performOps;
+ }
+
+ /**
+ * Get a list of storage paths to lock for a list of operations
+ * Returns an array with LockManager::LOCK_UW (shared locks) and
+ * LockManager::LOCK_EX (exclusive locks) keys, each corresponding
+ * to a list of storage paths to be locked. All returned paths are
+ * normalized.
+ *
+ * @param array $performOps List of FileOp objects
+ * @return array (LockManager::LOCK_UW => path list, LockManager::LOCK_EX => path list)
+ */
+ final public function getPathsToLockForOpsInternal( array $performOps ) {
+ // Build up a list of files to lock...
+ $paths = [ 'sh' => [], 'ex' => [] ];
+ foreach ( $performOps as $fileOp ) {
+ $paths['sh'] = array_merge( $paths['sh'], $fileOp->storagePathsRead() );
+ $paths['ex'] = array_merge( $paths['ex'], $fileOp->storagePathsChanged() );
+ }
+ // Optimization: if doing an EX lock anyway, don't also set an SH one
+ $paths['sh'] = array_diff( $paths['sh'], $paths['ex'] );
+ // Get a shared lock on the parent directory of each path changed
+ $paths['sh'] = array_merge( $paths['sh'], array_map( 'dirname', $paths['ex'] ) );
+
+ return [
+ LockManager::LOCK_UW => $paths['sh'],
+ LockManager::LOCK_EX => $paths['ex']
+ ];
+ }
+
+ public function getScopedLocksForOps( array $ops, Status $status ) {
+ $paths = $this->getPathsToLockForOpsInternal( $this->getOperationsInternal( $ops ) );
+
+ return $this->getScopedFileLocks( $paths, 'mixed', $status );
+ }
+
+ final protected function doOperationsInternal( array $ops, array $opts ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = Status::newGood();
+
+ // Fix up custom header name/value pairs...
+ $ops = array_map( [ $this, 'sanitizeOpHeaders' ], $ops );
+
+ // Build up a list of FileOps...
+ $performOps = $this->getOperationsInternal( $ops );
+
+ // Acquire any locks as needed...
+ if ( empty( $opts['nonLocking'] ) ) {
+ // Build up a list of files to lock...
+ $paths = $this->getPathsToLockForOpsInternal( $performOps );
+ // Try to lock those files for the scope of this function...
+
+ $scopeLock = $this->getScopedFileLocks( $paths, 'mixed', $status );
+ if ( !$status->isOK() ) {
+ return $status; // abort
+ }
+ }
+
+ // Clear any file cache entries (after locks acquired)
+ if ( empty( $opts['preserveCache'] ) ) {
+ $this->clearCache();
+ }
+
+ // Build the list of paths involved
+ $paths = [];
+ foreach ( $performOps as $op ) {
+ $paths = array_merge( $paths, $op->storagePathsRead() );
+ $paths = array_merge( $paths, $op->storagePathsChanged() );
+ }
+
+ // Enlarge the cache to fit the stat entries of these files
+ $this->cheapCache->resize( max( 2 * count( $paths ), self::CACHE_CHEAP_SIZE ) );
+
+ // Load from the persistent container caches
+ $this->primeContainerCache( $paths );
+ // Get the latest stat info for all the files (having locked them)
+ $ok = $this->preloadFileStat( [ 'srcs' => $paths, 'latest' => true ] );
+
+ if ( $ok ) {
+ // Actually attempt the operation batch...
+ $opts = $this->setConcurrencyFlags( $opts );
+ $subStatus = FileOpBatch::attempt( $performOps, $opts, $this->fileJournal );
+ } else {
+ // If we could not even stat some files, then bail out...
+ $subStatus = Status::newFatal( 'backend-fail-internal', $this->name );
+ foreach ( $ops as $i => $op ) { // mark each op as failed
+ $subStatus->success[$i] = false;
+ ++$subStatus->failCount;
+ }
+ wfDebugLog( 'FileOperation', get_class( $this ) . "-{$this->name} " .
+ " stat failure; aborted operations: " . FormatJson::encode( $ops ) );
+ }
+
+ // Merge errors into status fields
+ $status->merge( $subStatus );
+ $status->success = $subStatus->success; // not done in merge()
+
+ // Shrink the stat cache back to normal size
+ $this->cheapCache->resize( self::CACHE_CHEAP_SIZE );
+
+ return $status;
+ }
+
+ final protected function doQuickOperationsInternal( array $ops ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $status = Status::newGood();
+
+ // Fix up custom header name/value pairs...
+ $ops = array_map( [ $this, 'sanitizeOpHeaders' ], $ops );
+
+ // Clear any file cache entries
+ $this->clearCache();
+
+ $supportedOps = [ 'create', 'store', 'copy', 'move', 'delete', 'describe', 'null' ];
+ // Parallel ops may be disabled in config due to dependencies (e.g. needing popen())
+ $async = ( $this->parallelize === 'implicit' && count( $ops ) > 1 );
+ $maxConcurrency = $this->concurrency; // throttle
+
+ $statuses = []; // array of (index => Status)
+ $fileOpHandles = []; // list of (index => handle) arrays
+ $curFileOpHandles = []; // current handle batch
+ // Perform the sync-only ops and build up op handles for the async ops...
+ foreach ( $ops as $index => $params ) {
+ if ( !in_array( $params['op'], $supportedOps ) ) {
+ throw new FileBackendError( "Operation '{$params['op']}' is not supported." );
+ }
+ $method = $params['op'] . 'Internal'; // e.g. "storeInternal"
+ $subStatus = $this->$method( [ 'async' => $async ] + $params );
+ if ( $subStatus->value instanceof FileBackendStoreOpHandle ) { // async
+ if ( count( $curFileOpHandles ) >= $maxConcurrency ) {
+ $fileOpHandles[] = $curFileOpHandles; // push this batch
+ $curFileOpHandles = [];
+ }
+ $curFileOpHandles[$index] = $subStatus->value; // keep index
+ } else { // error or completed
+ $statuses[$index] = $subStatus; // keep index
+ }
+ }
+ if ( count( $curFileOpHandles ) ) {
+ $fileOpHandles[] = $curFileOpHandles; // last batch
+ }
+ // Do all the async ops that can be done concurrently...
+ foreach ( $fileOpHandles as $fileHandleBatch ) {
+ $statuses = $statuses + $this->executeOpHandlesInternal( $fileHandleBatch );
+ }
+ // Marshall and merge all the responses...
+ foreach ( $statuses as $index => $subStatus ) {
+ $status->merge( $subStatus );
+ if ( $subStatus->isOK() ) {
+ $status->success[$index] = true;
+ ++$status->successCount;
+ } else {
+ $status->success[$index] = false;
+ ++$status->failCount;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Execute a list of FileBackendStoreOpHandle handles in parallel.
+ * The resulting Status object fields will correspond
+ * to the order in which the handles where given.
+ *
+ * @param FileBackendStoreOpHandle[] $fileOpHandles
+ *
+ * @throws FileBackendError
+ * @return array Map of Status objects
+ */
+ final public function executeOpHandlesInternal( array $fileOpHandles ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ foreach ( $fileOpHandles as $fileOpHandle ) {
+ if ( !( $fileOpHandle instanceof FileBackendStoreOpHandle ) ) {
+ throw new FileBackendError( "Given a non-FileBackendStoreOpHandle object." );
+ } elseif ( $fileOpHandle->backend->getName() !== $this->getName() ) {
+ throw new FileBackendError( "Given a FileBackendStoreOpHandle for the wrong backend." );
+ }
+ }
+ $res = $this->doExecuteOpHandlesInternal( $fileOpHandles );
+ foreach ( $fileOpHandles as $fileOpHandle ) {
+ $fileOpHandle->closeResources();
+ }
+
+ return $res;
+ }
+
+ /**
+ * @see FileBackendStore::executeOpHandlesInternal()
+ *
+ * @param FileBackendStoreOpHandle[] $fileOpHandles
+ *
+ * @throws FileBackendError
+ * @return Status[] List of corresponding Status objects
+ */
+ protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
+ if ( count( $fileOpHandles ) ) {
+ throw new FileBackendError( "This backend supports no asynchronous operations." );
+ }
+
+ return [];
+ }
+
+ /**
+ * Normalize and filter HTTP headers from a file operation
+ *
+ * This normalizes and strips long HTTP headers from a file operation.
+ * Most headers are just numbers, but some are allowed to be long.
+ * This function is useful for cleaning up headers and avoiding backend
+ * specific errors, especially in the middle of batch file operations.
+ *
+ * @param array $op Same format as doOperation()
+ * @return array
+ */
+ protected function sanitizeOpHeaders( array $op ) {
+ static $longs = [ 'content-disposition' ];
+
+ if ( isset( $op['headers'] ) ) { // op sets HTTP headers
+ $newHeaders = [];
+ foreach ( $op['headers'] as $name => $value ) {
+ $name = strtolower( $name );
+ $maxHVLen = in_array( $name, $longs ) ? INF : 255;
+ if ( strlen( $name ) > 255 || strlen( $value ) > $maxHVLen ) {
+ trigger_error( "Header '$name: $value' is too long." );
+ } else {
+ $newHeaders[$name] = strlen( $value ) ? $value : ''; // null/false => ""
+ }
+ }
+ $op['headers'] = $newHeaders;
+ }
+
+ return $op;
+ }
+
+ final public function preloadCache( array $paths ) {
+ $fullConts = []; // full container names
+ foreach ( $paths as $path ) {
+ list( $fullCont, , ) = $this->resolveStoragePath( $path );
+ $fullConts[] = $fullCont;
+ }
+ // Load from the persistent file and container caches
+ $this->primeContainerCache( $fullConts );
+ $this->primeFileCache( $paths );
+ }
+
+ final public function clearCache( array $paths = null ) {
+ if ( is_array( $paths ) ) {
+ $paths = array_map( 'FileBackend::normalizeStoragePath', $paths );
+ $paths = array_filter( $paths, 'strlen' ); // remove nulls
+ }
+ if ( $paths === null ) {
+ $this->cheapCache->clear();
+ $this->expensiveCache->clear();
+ } else {
+ foreach ( $paths as $path ) {
+ $this->cheapCache->clear( $path );
+ $this->expensiveCache->clear( $path );
+ }
+ }
+ $this->doClearCache( $paths );
+ }
+
+ /**
+ * Clears any additional stat caches for storage paths
+ *
+ * @see FileBackend::clearCache()
+ *
+ * @param array $paths Storage paths (optional)
+ */
+ protected function doClearCache( array $paths = null ) {
+ }
+
+ final public function preloadFileStat( array $params ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ $success = true; // no network errors
+
+ $params['concurrency'] = ( $this->parallelize !== 'off' ) ? $this->concurrency : 1;
+ $stats = $this->doGetFileStatMulti( $params );
+ if ( $stats === null ) {
+ return true; // not supported
+ }
+
+ $latest = !empty( $params['latest'] ); // use latest data?
+ foreach ( $stats as $path => $stat ) {
+ $path = FileBackend::normalizeStoragePath( $path );
+ if ( $path === null ) {
+ continue; // this shouldn't happen
+ }
+ if ( is_array( $stat ) ) { // file exists
+ // Strongly consistent backends can automatically set "latest"
+ $stat['latest'] = isset( $stat['latest'] ) ? $stat['latest'] : $latest;
+ $this->cheapCache->set( $path, 'stat', $stat );
+ $this->setFileCache( $path, $stat ); // update persistent cache
+ if ( isset( $stat['sha1'] ) ) { // some backends store SHA-1 as metadata
+ $this->cheapCache->set( $path, 'sha1',
+ [ 'hash' => $stat['sha1'], 'latest' => $latest ] );
+ }
+ if ( isset( $stat['xattr'] ) ) { // some backends store headers/metadata
+ $stat['xattr'] = self::normalizeXAttributes( $stat['xattr'] );
+ $this->cheapCache->set( $path, 'xattr',
+ [ 'map' => $stat['xattr'], 'latest' => $latest ] );
+ }
+ } elseif ( $stat === false ) { // file does not exist
+ $this->cheapCache->set( $path, 'stat',
+ $latest ? 'NOT_EXIST_LATEST' : 'NOT_EXIST' );
+ $this->cheapCache->set( $path, 'xattr',
+ [ 'map' => false, 'latest' => $latest ] );
+ $this->cheapCache->set( $path, 'sha1',
+ [ 'hash' => false, 'latest' => $latest ] );
+ wfDebug( __METHOD__ . ": File $path does not exist.\n" );
+ } else { // an error occurred
+ $success = false;
+ wfDebug( __METHOD__ . ": Could not stat file $path.\n" );
+ }
+ }
+
+ return $success;
+ }
+
+ /**
+ * Get file stat information (concurrently if possible) for several files
+ *
+ * @see FileBackend::getFileStat()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * @return array|null Map of storage paths to array|bool|null (returns null if not supported)
+ * @since 1.23
+ */
+ protected function doGetFileStatMulti( array $params ) {
+ return null; // not supported
+ }
+
+ /**
+ * Is this a key/value store where directories are just virtual?
+ * Virtual directories exists in so much as files exists that are
+ * prefixed with the directory path followed by a forward slash.
+ *
+ * @return bool
+ */
+ abstract protected function directoriesAreVirtual();
+
+ /**
+ * Check if a short container name is valid
+ *
+ * This checks for length and illegal characters.
+ * This may disallow certain characters that can appear
+ * in the prefix used to make the full container name.
+ *
+ * @param string $container
+ * @return bool
+ */
+ final protected static function isValidShortContainerName( $container ) {
+ // Suffixes like '.xxx' (hex shard chars) or '.seg' (file segments)
+ // might be used by subclasses. Reserve the dot character for sanity.
+ // The only way dots end up in containers (e.g. resolveStoragePath)
+ // is due to the wikiId container prefix or the above suffixes.
+ return self::isValidContainerName( $container ) && !preg_match( '/[.]/', $container );
+ }
+
+ /**
+ * Check if a full container name is valid
+ *
+ * This checks for length and illegal characters.
+ * Limiting the characters makes migrations to other stores easier.
+ *
+ * @param string $container
+ * @return bool
+ */
+ final protected static function isValidContainerName( $container ) {
+ // This accounts for NTFS, Swift, and Ceph restrictions
+ // and disallows directory separators or traversal characters.
+ // Note that matching strings URL encode to the same string;
+ // in Swift/Ceph, the length restriction is *after* URL encoding.
+ return (bool)preg_match( '/^[a-z0-9][a-z0-9-_.]{0,199}$/i', $container );
+ }
+
+ /**
+ * Splits a storage path into an internal container name,
+ * an internal relative file name, and a container shard suffix.
+ * Any shard suffix is already appended to the internal container name.
+ * This also checks that the storage path is valid and within this backend.
+ *
+ * If the container is sharded but a suffix could not be determined,
+ * this means that the path can only refer to a directory and can only
+ * be scanned by looking in all the container shards.
+ *
+ * @param string $storagePath
+ * @return array (container, path, container suffix) or (null, null, null) if invalid
+ */
+ final protected function resolveStoragePath( $storagePath ) {
+ list( $backend, $shortCont, $relPath ) = self::splitStoragePath( $storagePath );
+ if ( $backend === $this->name ) { // must be for this backend
+ $relPath = self::normalizeContainerPath( $relPath );
+ if ( $relPath !== null && self::isValidShortContainerName( $shortCont ) ) {
+ // Get shard for the normalized path if this container is sharded
+ $cShard = $this->getContainerShard( $shortCont, $relPath );
+ // Validate and sanitize the relative path (backend-specific)
+ $relPath = $this->resolveContainerPath( $shortCont, $relPath );
+ if ( $relPath !== null ) {
+ // Prepend any wiki ID prefix to the container name
+ $container = $this->fullContainerName( $shortCont );
+ if ( self::isValidContainerName( $container ) ) {
+ // Validate and sanitize the container name (backend-specific)
+ $container = $this->resolveContainerName( "{$container}{$cShard}" );
+ if ( $container !== null ) {
+ return [ $container, $relPath, $cShard ];
+ }
+ }
+ }
+ }
+ }
+
+ return [ null, null, null ];
+ }
+
+ /**
+ * Like resolveStoragePath() except null values are returned if
+ * the container is sharded and the shard could not be determined
+ * or if the path ends with '/'. The later case is illegal for FS
+ * backends and can confuse listings for object store backends.
+ *
+ * This function is used when resolving paths that must be valid
+ * locations for files. Directory and listing functions should
+ * generally just use resolveStoragePath() instead.
+ *
+ * @see FileBackendStore::resolveStoragePath()
+ *
+ * @param string $storagePath
+ * @return array (container, path) or (null, null) if invalid
+ */
+ final protected function resolveStoragePathReal( $storagePath ) {
+ list( $container, $relPath, $cShard ) = $this->resolveStoragePath( $storagePath );
+ if ( $cShard !== null && substr( $relPath, -1 ) !== '/' ) {
+ return [ $container, $relPath ];
+ }
+
+ return [ null, null ];
+ }
+
+ /**
+ * Get the container name shard suffix for a given path.
+ * Any empty suffix means the container is not sharded.
+ *
+ * @param string $container Container name
+ * @param string $relPath Storage path relative to the container
+ * @return string|null Returns null if shard could not be determined
+ */
+ final protected function getContainerShard( $container, $relPath ) {
+ list( $levels, $base, $repeat ) = $this->getContainerHashLevels( $container );
+ if ( $levels == 1 || $levels == 2 ) {
+ // Hash characters are either base 16 or 36
+ $char = ( $base == 36 ) ? '[0-9a-z]' : '[0-9a-f]';
+ // Get a regex that represents the shard portion of paths.
+ // The concatenation of the captures gives us the shard.
+ if ( $levels === 1 ) { // 16 or 36 shards per container
+ $hashDirRegex = '(' . $char . ')';
+ } else { // 256 or 1296 shards per container
+ if ( $repeat ) { // verbose hash dir format (e.g. "a/ab/abc")
+ $hashDirRegex = $char . '/(' . $char . '{2})';
+ } else { // short hash dir format (e.g. "a/b/c")
+ $hashDirRegex = '(' . $char . ')/(' . $char . ')';
+ }
+ }
+ // Allow certain directories to be above the hash dirs so as
+ // to work with FileRepo (e.g. "archive/a/ab" or "temp/a/ab").
+ // They must be 2+ chars to avoid any hash directory ambiguity.
+ $m = [];
+ if ( preg_match( "!^(?:[^/]{2,}/)*$hashDirRegex(?:/|$)!", $relPath, $m ) ) {
+ return '.' . implode( '', array_slice( $m, 1 ) );
+ }
+
+ return null; // failed to match
+ }
+
+ return ''; // no sharding
+ }
+
+ /**
+ * Check if a storage path maps to a single shard.
+ * Container dirs like "a", where the container shards on "x/xy",
+ * can reside on several shards. Such paths are tricky to handle.
+ *
+ * @param string $storagePath Storage path
+ * @return bool
+ */
+ final public function isSingleShardPathInternal( $storagePath ) {
+ list( , , $shard ) = $this->resolveStoragePath( $storagePath );
+
+ return ( $shard !== null );
+ }
+
+ /**
+ * Get the sharding config for a container.
+ * If greater than 0, then all file storage paths within
+ * the container are required to be hashed accordingly.
+ *
+ * @param string $container
+ * @return array (integer levels, integer base, repeat flag) or (0, 0, false)
+ */
+ final protected function getContainerHashLevels( $container ) {
+ if ( isset( $this->shardViaHashLevels[$container] ) ) {
+ $config = $this->shardViaHashLevels[$container];
+ $hashLevels = (int)$config['levels'];
+ if ( $hashLevels == 1 || $hashLevels == 2 ) {
+ $hashBase = (int)$config['base'];
+ if ( $hashBase == 16 || $hashBase == 36 ) {
+ return [ $hashLevels, $hashBase, $config['repeat'] ];
+ }
+ }
+ }
+
+ return [ 0, 0, false ]; // no sharding
+ }
+
+ /**
+ * Get a list of full container shard suffixes for a container
+ *
+ * @param string $container
+ * @return array
+ */
+ final protected function getContainerSuffixes( $container ) {
+ $shards = [];
+ list( $digits, $base ) = $this->getContainerHashLevels( $container );
+ if ( $digits > 0 ) {
+ $numShards = pow( $base, $digits );
+ for ( $index = 0; $index < $numShards; $index++ ) {
+ $shards[] = '.' . Wikimedia\base_convert( $index, 10, $base, $digits );
+ }
+ }
+
+ return $shards;
+ }
+
+ /**
+ * Get the full container name, including the wiki ID prefix
+ *
+ * @param string $container
+ * @return string
+ */
+ final protected function fullContainerName( $container ) {
+ if ( $this->wikiId != '' ) {
+ return "{$this->wikiId}-$container";
+ } else {
+ return $container;
+ }
+ }
+
+ /**
+ * Resolve a container name, checking if it's allowed by the backend.
+ * This is intended for internal use, such as encoding illegal chars.
+ * Subclasses can override this to be more restrictive.
+ *
+ * @param string $container
+ * @return string|null
+ */
+ protected function resolveContainerName( $container ) {
+ return $container;
+ }
+
+ /**
+ * Resolve a relative storage path, checking if it's allowed by the backend.
+ * This is intended for internal use, such as encoding illegal chars or perhaps
+ * getting absolute paths (e.g. FS based backends). Note that the relative path
+ * may be the empty string (e.g. the path is simply to the container).
+ *
+ * @param string $container Container name
+ * @param string $relStoragePath Storage path relative to the container
+ * @return string|null Path or null if not valid
+ */
+ protected function resolveContainerPath( $container, $relStoragePath ) {
+ return $relStoragePath;
+ }
+
+ /**
+ * Get the cache key for a container
+ *
+ * @param string $container Resolved container name
+ * @return string
+ */
+ private function containerCacheKey( $container ) {
+ return "filebackend:{$this->name}:{$this->wikiId}:container:{$container}";
+ }
+
+ /**
+ * Set the cached info for a container
+ *
+ * @param string $container Resolved container name
+ * @param array $val Information to cache
+ */
+ final protected function setContainerCache( $container, array $val ) {
+ $this->memCache->set( $this->containerCacheKey( $container ), $val, 14 * 86400 );
+ }
+
+ /**
+ * Delete the cached info for a container.
+ * The cache key is salted for a while to prevent race conditions.
+ *
+ * @param string $container Resolved container name
+ */
+ final protected function deleteContainerCache( $container ) {
+ if ( !$this->memCache->delete( $this->containerCacheKey( $container ), 300 ) ) {
+ trigger_error( "Unable to delete stat cache for container $container." );
+ }
+ }
+
+ /**
+ * Do a batch lookup from cache for container stats for all containers
+ * used in a list of container names or storage paths objects.
+ * This loads the persistent cache values into the process cache.
+ *
+ * @param array $items
+ */
+ final protected function primeContainerCache( array $items ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ $paths = []; // list of storage paths
+ $contNames = []; // (cache key => resolved container name)
+ // Get all the paths/containers from the items...
+ foreach ( $items as $item ) {
+ if ( self::isStoragePath( $item ) ) {
+ $paths[] = $item;
+ } elseif ( is_string( $item ) ) { // full container name
+ $contNames[$this->containerCacheKey( $item )] = $item;
+ }
+ }
+ // Get all the corresponding cache keys for paths...
+ foreach ( $paths as $path ) {
+ list( $fullCont, , ) = $this->resolveStoragePath( $path );
+ if ( $fullCont !== null ) { // valid path for this backend
+ $contNames[$this->containerCacheKey( $fullCont )] = $fullCont;
+ }
+ }
+
+ $contInfo = []; // (resolved container name => cache value)
+ // Get all cache entries for these container cache keys...
+ $values = $this->memCache->getMulti( array_keys( $contNames ) );
+ foreach ( $values as $cacheKey => $val ) {
+ $contInfo[$contNames[$cacheKey]] = $val;
+ }
+
+ // Populate the container process cache for the backend...
+ $this->doPrimeContainerCache( array_filter( $contInfo, 'is_array' ) );
+ }
+
+ /**
+ * Fill the backend-specific process cache given an array of
+ * resolved container names and their corresponding cached info.
+ * Only containers that actually exist should appear in the map.
+ *
+ * @param array $containerInfo Map of resolved container names to cached info
+ */
+ protected function doPrimeContainerCache( array $containerInfo ) {
+ }
+
+ /**
+ * Get the cache key for a file path
+ *
+ * @param string $path Normalized storage path
+ * @return string
+ */
+ private function fileCacheKey( $path ) {
+ return "filebackend:{$this->name}:{$this->wikiId}:file:" . sha1( $path );
+ }
+
+ /**
+ * Set the cached stat info for a file path.
+ * Negatives (404s) are not cached. By not caching negatives, we can skip cache
+ * salting for the case when a file is created at a path were there was none before.
+ *
+ * @param string $path Storage path
+ * @param array $val Stat information to cache
+ */
+ final protected function setFileCache( $path, array $val ) {
+ $path = FileBackend::normalizeStoragePath( $path );
+ if ( $path === null ) {
+ return; // invalid storage path
+ }
+ $age = time() - wfTimestamp( TS_UNIX, $val['mtime'] );
+ $ttl = min( 7 * 86400, max( 300, floor( .1 * $age ) ) );
+ $key = $this->fileCacheKey( $path );
+ // Set the cache unless it is currently salted.
+ $this->memCache->set( $key, $val, $ttl );
+ }
+
+ /**
+ * Delete the cached stat info for a file path.
+ * The cache key is salted for a while to prevent race conditions.
+ * Since negatives (404s) are not cached, this does not need to be called when
+ * a file is created at a path were there was none before.
+ *
+ * @param string $path Storage path
+ */
+ final protected function deleteFileCache( $path ) {
+ $path = FileBackend::normalizeStoragePath( $path );
+ if ( $path === null ) {
+ return; // invalid storage path
+ }
+ if ( !$this->memCache->delete( $this->fileCacheKey( $path ), 300 ) ) {
+ trigger_error( "Unable to delete stat cache for file $path." );
+ }
+ }
+
+ /**
+ * Do a batch lookup from cache for file stats for all paths
+ * used in a list of storage paths or FileOp objects.
+ * This loads the persistent cache values into the process cache.
+ *
+ * @param array $items List of storage paths
+ */
+ final protected function primeFileCache( array $items ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ $paths = []; // list of storage paths
+ $pathNames = []; // (cache key => storage path)
+ // Get all the paths/containers from the items...
+ foreach ( $items as $item ) {
+ if ( self::isStoragePath( $item ) ) {
+ $paths[] = FileBackend::normalizeStoragePath( $item );
+ }
+ }
+ // Get rid of any paths that failed normalization...
+ $paths = array_filter( $paths, 'strlen' ); // remove nulls
+ // Get all the corresponding cache keys for paths...
+ foreach ( $paths as $path ) {
+ list( , $rel, ) = $this->resolveStoragePath( $path );
+ if ( $rel !== null ) { // valid path for this backend
+ $pathNames[$this->fileCacheKey( $path )] = $path;
+ }
+ }
+ // Get all cache entries for these file cache keys...
+ $values = $this->memCache->getMulti( array_keys( $pathNames ) );
+ foreach ( $values as $cacheKey => $val ) {
+ $path = $pathNames[$cacheKey];
+ if ( is_array( $val ) ) {
+ $val['latest'] = false; // never completely trust cache
+ $this->cheapCache->set( $path, 'stat', $val );
+ if ( isset( $val['sha1'] ) ) { // some backends store SHA-1 as metadata
+ $this->cheapCache->set( $path, 'sha1',
+ [ 'hash' => $val['sha1'], 'latest' => false ] );
+ }
+ if ( isset( $val['xattr'] ) ) { // some backends store headers/metadata
+ $val['xattr'] = self::normalizeXAttributes( $val['xattr'] );
+ $this->cheapCache->set( $path, 'xattr',
+ [ 'map' => $val['xattr'], 'latest' => false ] );
+ }
+ }
+ }
+ }
+
+ /**
+ * Normalize file headers/metadata to the FileBackend::getFileXAttributes() format
+ *
+ * @param array $xattr
+ * @return array
+ * @since 1.22
+ */
+ final protected static function normalizeXAttributes( array $xattr ) {
+ $newXAttr = [ 'headers' => [], 'metadata' => [] ];
+
+ foreach ( $xattr['headers'] as $name => $value ) {
+ $newXAttr['headers'][strtolower( $name )] = $value;
+ }
+
+ foreach ( $xattr['metadata'] as $name => $value ) {
+ $newXAttr['metadata'][strtolower( $name )] = $value;
+ }
+
+ return $newXAttr;
+ }
+
+ /**
+ * Set the 'concurrency' option from a list of operation options
+ *
+ * @param array $opts Map of operation options
+ * @return array
+ */
+ final protected function setConcurrencyFlags( array $opts ) {
+ $opts['concurrency'] = 1; // off
+ if ( $this->parallelize === 'implicit' ) {
+ if ( !isset( $opts['parallelize'] ) || $opts['parallelize'] ) {
+ $opts['concurrency'] = $this->concurrency;
+ }
+ } elseif ( $this->parallelize === 'explicit' ) {
+ if ( !empty( $opts['parallelize'] ) ) {
+ $opts['concurrency'] = $this->concurrency;
+ }
+ }
+
+ return $opts;
+ }
+
+ /**
+ * Get the content type to use in HEAD/GET requests for a file
+ *
+ * @param string $storagePath
+ * @param string|null $content File data
+ * @param string|null $fsPath File system path
+ * @return string MIME type
+ */
+ protected function getContentType( $storagePath, $content, $fsPath ) {
+ if ( $this->mimeCallback ) {
+ return call_user_func_array( $this->mimeCallback, func_get_args() );
+ }
+
+ $mime = null;
+ if ( $fsPath !== null && function_exists( 'finfo_file' ) ) {
+ $finfo = finfo_open( FILEINFO_MIME_TYPE );
+ $mime = finfo_file( $finfo, $fsPath );
+ finfo_close( $finfo );
+ }
+
+ return is_string( $mime ) ? $mime : 'unknown/unknown';
+ }
+}
+
+/**
+ * FileBackendStore helper class for performing asynchronous file operations.
+ *
+ * For example, calling FileBackendStore::createInternal() with the "async"
+ * param flag may result in a Status that contains this object as a value.
+ * This class is largely backend-specific and is mostly just "magic" to be
+ * passed to FileBackendStore::executeOpHandlesInternal().
+ */
+abstract class FileBackendStoreOpHandle {
+ /** @var array */
+ public $params = []; // params to caller functions
+ /** @var FileBackendStore */
+ public $backend;
+ /** @var array */
+ public $resourcesToClose = [];
+
+ public $call; // string; name that identifies the function called
+
+ /**
+ * Close all open file handles
+ */
+ public function closeResources() {
+ array_map( 'fclose', $this->resourcesToClose );
+ }
+}
+
+/**
+ * FileBackendStore helper function to handle listings that span container shards.
+ * Do not use this class from places outside of FileBackendStore.
+ *
+ * @ingroup FileBackend
+ */
+abstract class FileBackendStoreShardListIterator extends FilterIterator {
+ /** @var FileBackendStore */
+ protected $backend;
+
+ /** @var array */
+ protected $params;
+
+ /** @var string Full container name */
+ protected $container;
+
+ /** @var string Resolved relative path */
+ protected $directory;
+
+ /** @var array */
+ protected $multiShardPaths = []; // (rel path => 1)
+
+ /**
+ * @param FileBackendStore $backend
+ * @param string $container Full storage container name
+ * @param string $dir Storage directory relative to container
+ * @param array $suffixes List of container shard suffixes
+ * @param array $params
+ */
+ public function __construct(
+ FileBackendStore $backend, $container, $dir, array $suffixes, array $params
+ ) {
+ $this->backend = $backend;
+ $this->container = $container;
+ $this->directory = $dir;
+ $this->params = $params;
+
+ $iter = new AppendIterator();
+ foreach ( $suffixes as $suffix ) {
+ $iter->append( $this->listFromShard( $this->container . $suffix ) );
+ }
+
+ parent::__construct( $iter );
+ }
+
+ public function accept() {
+ $rel = $this->getInnerIterator()->current(); // path relative to given directory
+ $path = $this->params['dir'] . "/{$rel}"; // full storage path
+ if ( $this->backend->isSingleShardPathInternal( $path ) ) {
+ return true; // path is only on one shard; no issue with duplicates
+ } elseif ( isset( $this->multiShardPaths[$rel] ) ) {
+ // Don't keep listing paths that are on multiple shards
+ return false;
+ } else {
+ $this->multiShardPaths[$rel] = 1;
+
+ return true;
+ }
+ }
+
+ public function rewind() {
+ parent::rewind();
+ $this->multiShardPaths = [];
+ }
+
+ /**
+ * Get the list for a given container shard
+ *
+ * @param string $container Resolved container name
+ * @return Iterator
+ */
+ abstract protected function listFromShard( $container );
+}
+
+/**
+ * Iterator for listing directories
+ */
+class FileBackendStoreShardDirIterator extends FileBackendStoreShardListIterator {
+ protected function listFromShard( $container ) {
+ $list = $this->backend->getDirectoryListInternal(
+ $container, $this->directory, $this->params );
+ if ( $list === null ) {
+ return new ArrayIterator( [] );
+ } else {
+ return is_array( $list ) ? new ArrayIterator( $list ) : $list;
+ }
+ }
+}
+
+/**
+ * Iterator for listing regular files
+ */
+class FileBackendStoreShardFileIterator extends FileBackendStoreShardListIterator {
+ protected function listFromShard( $container ) {
+ $list = $this->backend->getFileListInternal(
+ $container, $this->directory, $this->params );
+ if ( $list === null ) {
+ return new ArrayIterator( [] );
+ } else {
+ return is_array( $list ) ? new ArrayIterator( $list ) : $list;
+ }
+ }
+}
diff --git a/www/wiki/includes/filebackend/FileOp.php b/www/wiki/includes/filebackend/FileOp.php
new file mode 100644
index 00000000..56a40738
--- /dev/null
+++ b/www/wiki/includes/filebackend/FileOp.php
@@ -0,0 +1,848 @@
+<?php
+/**
+ * Helper class for representing operations with transaction support.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Aaron Schulz
+ */
+
+/**
+ * FileBackend helper class for representing operations.
+ * Do not use this class from places outside FileBackend.
+ *
+ * Methods called from FileOpBatch::attempt() should avoid throwing
+ * exceptions at all costs. FileOp objects should be lightweight in order
+ * to support large arrays in memory and serialization.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+abstract class FileOp {
+ /** @var array */
+ protected $params = [];
+
+ /** @var FileBackendStore */
+ protected $backend;
+
+ /** @var int */
+ protected $state = self::STATE_NEW;
+
+ /** @var bool */
+ protected $failed = false;
+
+ /** @var bool */
+ protected $async = false;
+
+ /** @var string */
+ protected $batchId;
+
+ /** @var bool Operation is not a no-op */
+ protected $doOperation = true;
+
+ /** @var string */
+ protected $sourceSha1;
+
+ /** @var bool */
+ protected $overwriteSameCase;
+
+ /** @var bool */
+ protected $destExists;
+
+ /* Object life-cycle */
+ const STATE_NEW = 1;
+ const STATE_CHECKED = 2;
+ const STATE_ATTEMPTED = 3;
+
+ /**
+ * Build a new batch file operation transaction
+ *
+ * @param FileBackendStore $backend
+ * @param array $params
+ * @throws FileBackendError
+ */
+ final public function __construct( FileBackendStore $backend, array $params ) {
+ $this->backend = $backend;
+ list( $required, $optional, $paths ) = $this->allowedParams();
+ foreach ( $required as $name ) {
+ if ( isset( $params[$name] ) ) {
+ $this->params[$name] = $params[$name];
+ } else {
+ throw new FileBackendError( "File operation missing parameter '$name'." );
+ }
+ }
+ foreach ( $optional as $name ) {
+ if ( isset( $params[$name] ) ) {
+ $this->params[$name] = $params[$name];
+ }
+ }
+ foreach ( $paths as $name ) {
+ if ( isset( $this->params[$name] ) ) {
+ // Normalize paths so the paths to the same file have the same string
+ $this->params[$name] = self::normalizeIfValidStoragePath( $this->params[$name] );
+ }
+ }
+ }
+
+ /**
+ * Normalize a string if it is a valid storage path
+ *
+ * @param string $path
+ * @return string
+ */
+ protected static function normalizeIfValidStoragePath( $path ) {
+ if ( FileBackend::isStoragePath( $path ) ) {
+ $res = FileBackend::normalizeStoragePath( $path );
+
+ return ( $res !== null ) ? $res : $path;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Set the batch UUID this operation belongs to
+ *
+ * @param string $batchId
+ */
+ final public function setBatchId( $batchId ) {
+ $this->batchId = $batchId;
+ }
+
+ /**
+ * Get the value of the parameter with the given name
+ *
+ * @param string $name
+ * @return mixed Returns null if the parameter is not set
+ */
+ final public function getParam( $name ) {
+ return isset( $this->params[$name] ) ? $this->params[$name] : null;
+ }
+
+ /**
+ * Check if this operation failed precheck() or attempt()
+ *
+ * @return bool
+ */
+ final public function failed() {
+ return $this->failed;
+ }
+
+ /**
+ * Get a new empty predicates array for precheck()
+ *
+ * @return array
+ */
+ final public static function newPredicates() {
+ return [ 'exists' => [], 'sha1' => [] ];
+ }
+
+ /**
+ * Get a new empty dependency tracking array for paths read/written to
+ *
+ * @return array
+ */
+ final public static function newDependencies() {
+ return [ 'read' => [], 'write' => [] ];
+ }
+
+ /**
+ * Update a dependency tracking array to account for this operation
+ *
+ * @param array $deps Prior path reads/writes; format of FileOp::newPredicates()
+ * @return array
+ */
+ final public function applyDependencies( array $deps ) {
+ $deps['read'] += array_fill_keys( $this->storagePathsRead(), 1 );
+ $deps['write'] += array_fill_keys( $this->storagePathsChanged(), 1 );
+
+ return $deps;
+ }
+
+ /**
+ * Check if this operation changes files listed in $paths
+ *
+ * @param array $deps Prior path reads/writes; format of FileOp::newPredicates()
+ * @return bool
+ */
+ final public function dependsOn( array $deps ) {
+ foreach ( $this->storagePathsChanged() as $path ) {
+ if ( isset( $deps['read'][$path] ) || isset( $deps['write'][$path] ) ) {
+ return true; // "output" or "anti" dependency
+ }
+ }
+ foreach ( $this->storagePathsRead() as $path ) {
+ if ( isset( $deps['write'][$path] ) ) {
+ return true; // "flow" dependency
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the file journal entries for this file operation
+ *
+ * @param array $oPredicates Pre-op info about files (format of FileOp::newPredicates)
+ * @param array $nPredicates Post-op info about files (format of FileOp::newPredicates)
+ * @return array
+ */
+ final public function getJournalEntries( array $oPredicates, array $nPredicates ) {
+ if ( !$this->doOperation ) {
+ return []; // this is a no-op
+ }
+ $nullEntries = [];
+ $updateEntries = [];
+ $deleteEntries = [];
+ $pathsUsed = array_merge( $this->storagePathsRead(), $this->storagePathsChanged() );
+ foreach ( array_unique( $pathsUsed ) as $path ) {
+ $nullEntries[] = [ // assertion for recovery
+ 'op' => 'null',
+ 'path' => $path,
+ 'newSha1' => $this->fileSha1( $path, $oPredicates )
+ ];
+ }
+ foreach ( $this->storagePathsChanged() as $path ) {
+ if ( $nPredicates['sha1'][$path] === false ) { // deleted
+ $deleteEntries[] = [
+ 'op' => 'delete',
+ 'path' => $path,
+ 'newSha1' => ''
+ ];
+ } else { // created/updated
+ $updateEntries[] = [
+ 'op' => $this->fileExists( $path, $oPredicates ) ? 'update' : 'create',
+ 'path' => $path,
+ 'newSha1' => $nPredicates['sha1'][$path]
+ ];
+ }
+ }
+
+ return array_merge( $nullEntries, $updateEntries, $deleteEntries );
+ }
+
+ /**
+ * Check preconditions of the operation without writing anything.
+ * This must update $predicates for each path that the op can change
+ * except when a failing status object is returned.
+ *
+ * @param array $predicates
+ * @return Status
+ */
+ final public function precheck( array &$predicates ) {
+ if ( $this->state !== self::STATE_NEW ) {
+ return Status::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state );
+ }
+ $this->state = self::STATE_CHECKED;
+ $status = $this->doPrecheck( $predicates );
+ if ( !$status->isOK() ) {
+ $this->failed = true;
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param array $predicates
+ * @return Status
+ */
+ protected function doPrecheck( array &$predicates ) {
+ return Status::newGood();
+ }
+
+ /**
+ * Attempt the operation
+ *
+ * @return Status
+ */
+ final public function attempt() {
+ if ( $this->state !== self::STATE_CHECKED ) {
+ return Status::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state );
+ } elseif ( $this->failed ) { // failed precheck
+ return Status::newFatal( 'fileop-fail-attempt-precheck' );
+ }
+ $this->state = self::STATE_ATTEMPTED;
+ if ( $this->doOperation ) {
+ $status = $this->doAttempt();
+ if ( !$status->isOK() ) {
+ $this->failed = true;
+ $this->logFailure( 'attempt' );
+ }
+ } else { // no-op
+ $status = Status::newGood();
+ }
+
+ return $status;
+ }
+
+ /**
+ * @return Status
+ */
+ protected function doAttempt() {
+ return Status::newGood();
+ }
+
+ /**
+ * Attempt the operation in the background
+ *
+ * @return Status
+ */
+ final public function attemptAsync() {
+ $this->async = true;
+ $result = $this->attempt();
+ $this->async = false;
+
+ return $result;
+ }
+
+ /**
+ * Get the file operation parameters
+ *
+ * @return array (required params list, optional params list, list of params that are paths)
+ */
+ protected function allowedParams() {
+ return [ [], [], [] ];
+ }
+
+ /**
+ * Adjust params to FileBackendStore internal file calls
+ *
+ * @param array $params
+ * @return array (required params list, optional params list)
+ */
+ protected function setFlags( array $params ) {
+ return [ 'async' => $this->async ] + $params;
+ }
+
+ /**
+ * Get a list of storage paths read from for this operation
+ *
+ * @return array
+ */
+ public function storagePathsRead() {
+ return [];
+ }
+
+ /**
+ * Get a list of storage paths written to for this operation
+ *
+ * @return array
+ */
+ public function storagePathsChanged() {
+ return [];
+ }
+
+ /**
+ * Check for errors with regards to the destination file already existing.
+ * Also set the destExists, overwriteSameCase and sourceSha1 member variables.
+ * A bad status will be returned if there is no chance it can be overwritten.
+ *
+ * @param array $predicates
+ * @return Status
+ */
+ protected function precheckDestExistence( array $predicates ) {
+ $status = Status::newGood();
+ // Get hash of source file/string and the destination file
+ $this->sourceSha1 = $this->getSourceSha1Base36(); // FS file or data string
+ if ( $this->sourceSha1 === null ) { // file in storage?
+ $this->sourceSha1 = $this->fileSha1( $this->params['src'], $predicates );
+ }
+ $this->overwriteSameCase = false;
+ $this->destExists = $this->fileExists( $this->params['dst'], $predicates );
+ if ( $this->destExists ) {
+ if ( $this->getParam( 'overwrite' ) ) {
+ return $status; // OK
+ } elseif ( $this->getParam( 'overwriteSame' ) ) {
+ $dhash = $this->fileSha1( $this->params['dst'], $predicates );
+ // Check if hashes are valid and match each other...
+ if ( !strlen( $this->sourceSha1 ) || !strlen( $dhash ) ) {
+ $status->fatal( 'backend-fail-hashes' );
+ } elseif ( $this->sourceSha1 !== $dhash ) {
+ // Give an error if the files are not identical
+ $status->fatal( 'backend-fail-notsame', $this->params['dst'] );
+ } else {
+ $this->overwriteSameCase = true; // OK
+ }
+
+ return $status; // do nothing; either OK or bad status
+ } else {
+ $status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] );
+
+ return $status;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * precheckDestExistence() helper function to get the source file SHA-1.
+ * Subclasses should overwride this if the source is not in storage.
+ *
+ * @return string|bool Returns false on failure
+ */
+ protected function getSourceSha1Base36() {
+ return null; // N/A
+ }
+
+ /**
+ * Check if a file will exist in storage when this operation is attempted
+ *
+ * @param string $source Storage path
+ * @param array $predicates
+ * @return bool
+ */
+ final protected function fileExists( $source, array $predicates ) {
+ if ( isset( $predicates['exists'][$source] ) ) {
+ return $predicates['exists'][$source]; // previous op assures this
+ } else {
+ $params = [ 'src' => $source, 'latest' => true ];
+
+ return $this->backend->fileExists( $params );
+ }
+ }
+
+ /**
+ * Get the SHA-1 of a file in storage when this operation is attempted
+ *
+ * @param string $source Storage path
+ * @param array $predicates
+ * @return string|bool False on failure
+ */
+ final protected function fileSha1( $source, array $predicates ) {
+ if ( isset( $predicates['sha1'][$source] ) ) {
+ return $predicates['sha1'][$source]; // previous op assures this
+ } elseif ( isset( $predicates['exists'][$source] ) && !$predicates['exists'][$source] ) {
+ return false; // previous op assures this
+ } else {
+ $params = [ 'src' => $source, 'latest' => true ];
+
+ return $this->backend->getFileSha1Base36( $params );
+ }
+ }
+
+ /**
+ * Get the backend this operation is for
+ *
+ * @return FileBackendStore
+ */
+ public function getBackend() {
+ return $this->backend;
+ }
+
+ /**
+ * Log a file operation failure and preserve any temp files
+ *
+ * @param string $action
+ */
+ final public function logFailure( $action ) {
+ $params = $this->params;
+ $params['failedAction'] = $action;
+ try {
+ wfDebugLog( 'FileOperation', get_class( $this ) .
+ " failed (batch #{$this->batchId}): " . FormatJson::encode( $params ) );
+ } catch ( Exception $e ) {
+ // bad config? debug log error?
+ }
+ }
+}
+
+/**
+ * Create a file in the backend with the given content.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class CreateFileOp extends FileOp {
+ protected function allowedParams() {
+ return [
+ [ 'content', 'dst' ],
+ [ 'overwrite', 'overwriteSame', 'headers' ],
+ [ 'dst' ]
+ ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = Status::newGood();
+ // Check if the source data is too big
+ if ( strlen( $this->getParam( 'content' ) ) > $this->backend->maxFileSizeInternal() ) {
+ $status->fatal( 'backend-fail-maxsize',
+ $this->params['dst'], $this->backend->maxFileSizeInternal() );
+ $status->fatal( 'backend-fail-create', $this->params['dst'] );
+
+ return $status;
+ // Check if a file can be placed/changed at the destination
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['dst'] );
+ $status->fatal( 'backend-fail-create', $this->params['dst'] );
+
+ return $status;
+ }
+ // Check if destination file exists
+ $status->merge( $this->precheckDestExistence( $predicates ) );
+ $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache()
+ if ( $status->isOK() ) {
+ // Update file existence predicates
+ $predicates['exists'][$this->params['dst']] = true;
+ $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
+ }
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ if ( !$this->overwriteSameCase ) {
+ // Create the file at the destination
+ return $this->backend->createInternal( $this->setFlags( $this->params ) );
+ }
+
+ return Status::newGood();
+ }
+
+ protected function getSourceSha1Base36() {
+ return Wikimedia\base_convert( sha1( $this->params['content'] ), 16, 36, 31 );
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['dst'] ];
+ }
+}
+
+/**
+ * Store a file into the backend from a file on the file system.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class StoreFileOp extends FileOp {
+ protected function allowedParams() {
+ return [
+ [ 'src', 'dst' ],
+ [ 'overwrite', 'overwriteSame', 'headers' ],
+ [ 'src', 'dst' ]
+ ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = Status::newGood();
+ // Check if the source file exists on the file system
+ if ( !is_file( $this->params['src'] ) ) {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ // Check if the source file is too big
+ } elseif ( filesize( $this->params['src'] ) > $this->backend->maxFileSizeInternal() ) {
+ $status->fatal( 'backend-fail-maxsize',
+ $this->params['dst'], $this->backend->maxFileSizeInternal() );
+ $status->fatal( 'backend-fail-store', $this->params['src'], $this->params['dst'] );
+
+ return $status;
+ // Check if a file can be placed/changed at the destination
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['dst'] );
+ $status->fatal( 'backend-fail-store', $this->params['src'], $this->params['dst'] );
+
+ return $status;
+ }
+ // Check if destination file exists
+ $status->merge( $this->precheckDestExistence( $predicates ) );
+ $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache()
+ if ( $status->isOK() ) {
+ // Update file existence predicates
+ $predicates['exists'][$this->params['dst']] = true;
+ $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
+ }
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ if ( !$this->overwriteSameCase ) {
+ // Store the file at the destination
+ return $this->backend->storeInternal( $this->setFlags( $this->params ) );
+ }
+
+ return Status::newGood();
+ }
+
+ protected function getSourceSha1Base36() {
+ MediaWiki\suppressWarnings();
+ $hash = sha1_file( $this->params['src'] );
+ MediaWiki\restoreWarnings();
+ if ( $hash !== false ) {
+ $hash = Wikimedia\base_convert( $hash, 16, 36, 31 );
+ }
+
+ return $hash;
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['dst'] ];
+ }
+}
+
+/**
+ * Copy a file from one storage path to another in the backend.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class CopyFileOp extends FileOp {
+ protected function allowedParams() {
+ return [
+ [ 'src', 'dst' ],
+ [ 'overwrite', 'overwriteSame', 'ignoreMissingSource', 'headers' ],
+ [ 'src', 'dst' ]
+ ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = Status::newGood();
+ // Check if the source file exists
+ if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
+ if ( $this->getParam( 'ignoreMissingSource' ) ) {
+ $this->doOperation = false; // no-op
+ // Update file existence predicates (cache 404s)
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+
+ return $status; // nothing to do
+ } else {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ }
+ // Check if a file can be placed/changed at the destination
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['dst'] );
+ $status->fatal( 'backend-fail-copy', $this->params['src'], $this->params['dst'] );
+
+ return $status;
+ }
+ // Check if destination file exists
+ $status->merge( $this->precheckDestExistence( $predicates ) );
+ $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache()
+ if ( $status->isOK() ) {
+ // Update file existence predicates
+ $predicates['exists'][$this->params['dst']] = true;
+ $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
+ }
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ if ( $this->overwriteSameCase ) {
+ $status = Status::newGood(); // nothing to do
+ } elseif ( $this->params['src'] === $this->params['dst'] ) {
+ // Just update the destination file headers
+ $headers = $this->getParam( 'headers' ) ?: [];
+ $status = $this->backend->describeInternal( $this->setFlags( [
+ 'src' => $this->params['dst'], 'headers' => $headers
+ ] ) );
+ } else {
+ // Copy the file to the destination
+ $status = $this->backend->copyInternal( $this->setFlags( $this->params ) );
+ }
+
+ return $status;
+ }
+
+ public function storagePathsRead() {
+ return [ $this->params['src'] ];
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['dst'] ];
+ }
+}
+
+/**
+ * Move a file from one storage path to another in the backend.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class MoveFileOp extends FileOp {
+ protected function allowedParams() {
+ return [
+ [ 'src', 'dst' ],
+ [ 'overwrite', 'overwriteSame', 'ignoreMissingSource', 'headers' ],
+ [ 'src', 'dst' ]
+ ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = Status::newGood();
+ // Check if the source file exists
+ if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
+ if ( $this->getParam( 'ignoreMissingSource' ) ) {
+ $this->doOperation = false; // no-op
+ // Update file existence predicates (cache 404s)
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+
+ return $status; // nothing to do
+ } else {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ }
+ // Check if a file can be placed/changed at the destination
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['dst'] );
+ $status->fatal( 'backend-fail-move', $this->params['src'], $this->params['dst'] );
+
+ return $status;
+ }
+ // Check if destination file exists
+ $status->merge( $this->precheckDestExistence( $predicates ) );
+ $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache()
+ if ( $status->isOK() ) {
+ // Update file existence predicates
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+ $predicates['exists'][$this->params['dst']] = true;
+ $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
+ }
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ if ( $this->overwriteSameCase ) {
+ if ( $this->params['src'] === $this->params['dst'] ) {
+ // Do nothing to the destination (which is also the source)
+ $status = Status::newGood();
+ } else {
+ // Just delete the source as the destination file needs no changes
+ $status = $this->backend->deleteInternal( $this->setFlags(
+ [ 'src' => $this->params['src'] ]
+ ) );
+ }
+ } elseif ( $this->params['src'] === $this->params['dst'] ) {
+ // Just update the destination file headers
+ $headers = $this->getParam( 'headers' ) ?: [];
+ $status = $this->backend->describeInternal( $this->setFlags(
+ [ 'src' => $this->params['dst'], 'headers' => $headers ]
+ ) );
+ } else {
+ // Move the file to the destination
+ $status = $this->backend->moveInternal( $this->setFlags( $this->params ) );
+ }
+
+ return $status;
+ }
+
+ public function storagePathsRead() {
+ return [ $this->params['src'] ];
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['src'], $this->params['dst'] ];
+ }
+}
+
+/**
+ * Delete a file at the given storage path from the backend.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class DeleteFileOp extends FileOp {
+ protected function allowedParams() {
+ return [ [ 'src' ], [ 'ignoreMissingSource' ], [ 'src' ] ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = Status::newGood();
+ // Check if the source file exists
+ if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
+ if ( $this->getParam( 'ignoreMissingSource' ) ) {
+ $this->doOperation = false; // no-op
+ // Update file existence predicates (cache 404s)
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+
+ return $status; // nothing to do
+ } else {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ }
+ // Check if a file can be placed/changed at the source
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['src'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['src'] );
+ $status->fatal( 'backend-fail-delete', $this->params['src'] );
+
+ return $status;
+ }
+ // Update file existence predicates
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ // Delete the source file
+ return $this->backend->deleteInternal( $this->setFlags( $this->params ) );
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['src'] ];
+ }
+}
+
+/**
+ * Change metadata for a file at the given storage path in the backend.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class DescribeFileOp extends FileOp {
+ protected function allowedParams() {
+ return [ [ 'src' ], [ 'headers' ], [ 'src' ] ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = Status::newGood();
+ // Check if the source file exists
+ if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ // Check if a file can be placed/changed at the source
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['src'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['src'] );
+ $status->fatal( 'backend-fail-describe', $this->params['src'] );
+
+ return $status;
+ }
+ // Update file existence predicates
+ $predicates['exists'][$this->params['src']] =
+ $this->fileExists( $this->params['src'], $predicates );
+ $predicates['sha1'][$this->params['src']] =
+ $this->fileSha1( $this->params['src'], $predicates );
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ // Update the source file's metadata
+ return $this->backend->describeInternal( $this->setFlags( $this->params ) );
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['src'] ];
+ }
+}
+
+/**
+ * Placeholder operation that has no params and does nothing
+ */
+class NullFileOp extends FileOp {
+}
diff --git a/www/wiki/includes/filebackend/FileOpBatch.php b/www/wiki/includes/filebackend/FileOpBatch.php
new file mode 100644
index 00000000..78209d8b
--- /dev/null
+++ b/www/wiki/includes/filebackend/FileOpBatch.php
@@ -0,0 +1,202 @@
+<?php
+/**
+ * Helper class for representing batch file operations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Aaron Schulz
+ */
+
+/**
+ * Helper class for representing batch file operations.
+ * Do not use this class from places outside FileBackend.
+ *
+ * Methods should avoid throwing exceptions at all costs.
+ *
+ * @ingroup FileBackend
+ * @since 1.20
+ */
+class FileOpBatch {
+ /* Timeout related parameters */
+ const MAX_BATCH_SIZE = 1000; // integer
+
+ /**
+ * Attempt to perform a series of file operations.
+ * Callers are responsible for handling file locking.
+ *
+ * $opts is an array of options, including:
+ * - force : Errors that would normally cause a rollback do not.
+ * The remaining operations are still attempted if any fail.
+ * - nonJournaled : Don't log this operation batch in the file journal.
+ * - concurrency : Try to do this many operations in parallel when possible.
+ *
+ * The resulting Status will be "OK" unless:
+ * - a) unexpected operation errors occurred (network partitions, disk full...)
+ * - b) significant operation errors occurred and 'force' was not set
+ *
+ * @param FileOp[] $performOps List of FileOp operations
+ * @param array $opts Batch operation options
+ * @param FileJournal $journal Journal to log operations to
+ * @return Status
+ */
+ public static function attempt( array $performOps, array $opts, FileJournal $journal ) {
+ $status = Status::newGood();
+
+ $n = count( $performOps );
+ if ( $n > self::MAX_BATCH_SIZE ) {
+ $status->fatal( 'backend-fail-batchsize', $n, self::MAX_BATCH_SIZE );
+
+ return $status;
+ }
+
+ $batchId = $journal->getTimestampedUUID();
+ $ignoreErrors = !empty( $opts['force'] );
+ $journaled = empty( $opts['nonJournaled'] );
+ $maxConcurrency = isset( $opts['concurrency'] ) ? $opts['concurrency'] : 1;
+
+ $entries = []; // file journal entry list
+ $predicates = FileOp::newPredicates(); // account for previous ops in prechecks
+ $curBatch = []; // concurrent FileOp sub-batch accumulation
+ $curBatchDeps = FileOp::newDependencies(); // paths used in FileOp sub-batch
+ $pPerformOps = []; // ordered list of concurrent FileOp sub-batches
+ $lastBackend = null; // last op backend name
+ // Do pre-checks for each operation; abort on failure...
+ foreach ( $performOps as $index => $fileOp ) {
+ $backendName = $fileOp->getBackend()->getName();
+ $fileOp->setBatchId( $batchId ); // transaction ID
+ // Decide if this op can be done concurrently within this sub-batch
+ // or if a new concurrent sub-batch must be started after this one...
+ if ( $fileOp->dependsOn( $curBatchDeps )
+ || count( $curBatch ) >= $maxConcurrency
+ || ( $backendName !== $lastBackend && count( $curBatch ) )
+ ) {
+ $pPerformOps[] = $curBatch; // push this batch
+ $curBatch = []; // start a new sub-batch
+ $curBatchDeps = FileOp::newDependencies();
+ }
+ $lastBackend = $backendName;
+ $curBatch[$index] = $fileOp; // keep index
+ // Update list of affected paths in this batch
+ $curBatchDeps = $fileOp->applyDependencies( $curBatchDeps );
+ // Simulate performing the operation...
+ $oldPredicates = $predicates;
+ $subStatus = $fileOp->precheck( $predicates ); // updates $predicates
+ $status->merge( $subStatus );
+ if ( $subStatus->isOK() ) {
+ if ( $journaled ) { // journal log entries
+ $entries = array_merge( $entries,
+ $fileOp->getJournalEntries( $oldPredicates, $predicates ) );
+ }
+ } else { // operation failed?
+ $status->success[$index] = false;
+ ++$status->failCount;
+ if ( !$ignoreErrors ) {
+ return $status; // abort
+ }
+ }
+ }
+ // Push the last sub-batch
+ if ( count( $curBatch ) ) {
+ $pPerformOps[] = $curBatch;
+ }
+
+ // Log the operations in the file journal...
+ if ( count( $entries ) ) {
+ $subStatus = $journal->logChangeBatch( $entries, $batchId );
+ if ( !$subStatus->isOK() ) {
+ return $subStatus; // abort
+ }
+ }
+
+ if ( $ignoreErrors ) { // treat precheck() fatals as mere warnings
+ $status->setResult( true, $status->value );
+ }
+
+ // Attempt each operation (in parallel if allowed and possible)...
+ self::runParallelBatches( $pPerformOps, $status );
+
+ return $status;
+ }
+
+ /**
+ * Attempt a list of file operations sub-batches in series.
+ *
+ * The operations *in* each sub-batch will be done in parallel.
+ * The caller is responsible for making sure the operations
+ * within any given sub-batch do not depend on each other.
+ * This will abort remaining ops on failure.
+ *
+ * @param array $pPerformOps Batches of file ops (batches use original indexes)
+ * @param Status $status
+ */
+ protected static function runParallelBatches( array $pPerformOps, Status $status ) {
+ $aborted = false; // set to true on unexpected errors
+ foreach ( $pPerformOps as $performOpsBatch ) {
+ /** @var FileOp[] $performOpsBatch */
+ if ( $aborted ) { // check batch op abort flag...
+ // We can't continue (even with $ignoreErrors) as $predicates is wrong.
+ // Log the remaining ops as failed for recovery...
+ foreach ( $performOpsBatch as $i => $fileOp ) {
+ $status->success[$i] = false;
+ ++$status->failCount;
+ $performOpsBatch[$i]->logFailure( 'attempt_aborted' );
+ }
+ continue;
+ }
+ /** @var Status[] $statuses */
+ $statuses = [];
+ $opHandles = [];
+ // Get the backend; all sub-batch ops belong to a single backend
+ $backend = reset( $performOpsBatch )->getBackend();
+ // Get the operation handles or actually do it if there is just one.
+ // If attemptAsync() returns a Status, it was either due to an error
+ // or the backend does not support async ops and did it synchronously.
+ foreach ( $performOpsBatch as $i => $fileOp ) {
+ if ( !isset( $status->success[$i] ) ) { // didn't already fail in precheck()
+ // Parallel ops may be disabled in config due to missing dependencies,
+ // (e.g. needing popen()). When they are, $performOpsBatch has size 1.
+ $subStatus = ( count( $performOpsBatch ) > 1 )
+ ? $fileOp->attemptAsync()
+ : $fileOp->attempt();
+ if ( $subStatus->value instanceof FileBackendStoreOpHandle ) {
+ $opHandles[$i] = $subStatus->value; // deferred
+ } else {
+ $statuses[$i] = $subStatus; // done already
+ }
+ }
+ }
+ // Try to do all the operations concurrently...
+ $statuses = $statuses + $backend->executeOpHandlesInternal( $opHandles );
+ // Marshall and merge all the responses (blocking)...
+ foreach ( $performOpsBatch as $i => $fileOp ) {
+ if ( !isset( $status->success[$i] ) ) { // didn't already fail in precheck()
+ $subStatus = $statuses[$i];
+ $status->merge( $subStatus );
+ if ( $subStatus->isOK() ) {
+ $status->success[$i] = true;
+ ++$status->successCount;
+ } else {
+ $status->success[$i] = false;
+ ++$status->failCount;
+ $aborted = true; // set abort flag; we can't continue
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/filebackend/MemoryFileBackend.php b/www/wiki/includes/filebackend/MemoryFileBackend.php
new file mode 100644
index 00000000..6e32c629
--- /dev/null
+++ b/www/wiki/includes/filebackend/MemoryFileBackend.php
@@ -0,0 +1,278 @@
+<?php
+/**
+ * Simulation of a backend storage in memory.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Aaron Schulz
+ */
+
+/**
+ * Simulation of a backend storage in memory.
+ *
+ * All data in the backend is automatically deleted at the end of PHP execution.
+ * Since the data stored here is volatile, this is only useful for staging or testing.
+ *
+ * @ingroup FileBackend
+ * @since 1.23
+ */
+class MemoryFileBackend extends FileBackendStore {
+ /** @var array Map of (file path => (data,mtime) */
+ protected $files = [];
+
+ public function getFeatures() {
+ return self::ATTR_UNICODE_PATHS;
+ }
+
+ public function isPathUsableInternal( $storagePath ) {
+ return true;
+ }
+
+ protected function doCreateInternal( array $params ) {
+ $status = Status::newGood();
+
+ $dst = $this->resolveHashKey( $params['dst'] );
+ if ( $dst === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ $this->files[$dst] = [
+ 'data' => $params['content'],
+ 'mtime' => wfTimestamp( TS_MW, time() )
+ ];
+
+ return $status;
+ }
+
+ protected function doStoreInternal( array $params ) {
+ $status = Status::newGood();
+
+ $dst = $this->resolveHashKey( $params['dst'] );
+ if ( $dst === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ MediaWiki\suppressWarnings();
+ $data = file_get_contents( $params['src'] );
+ MediaWiki\restoreWarnings();
+ if ( $data === false ) { // source doesn't exist?
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+
+ $this->files[$dst] = [
+ 'data' => $data,
+ 'mtime' => wfTimestamp( TS_MW, time() )
+ ];
+
+ return $status;
+ }
+
+ protected function doCopyInternal( array $params ) {
+ $status = Status::newGood();
+
+ $src = $this->resolveHashKey( $params['src'] );
+ if ( $src === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $dst = $this->resolveHashKey( $params['dst'] );
+ if ( $dst === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !isset( $this->files[$src] ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
+ }
+
+ return $status;
+ }
+
+ $this->files[$dst] = [
+ 'data' => $this->files[$src]['data'],
+ 'mtime' => wfTimestamp( TS_MW, time() )
+ ];
+
+ return $status;
+ }
+
+ protected function doDeleteInternal( array $params ) {
+ $status = Status::newGood();
+
+ $src = $this->resolveHashKey( $params['src'] );
+ if ( $src === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ if ( !isset( $this->files[$src] ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+ }
+
+ return $status;
+ }
+
+ unset( $this->files[$src] );
+
+ return $status;
+ }
+
+ protected function doGetFileStat( array $params ) {
+ $src = $this->resolveHashKey( $params['src'] );
+ if ( $src === null ) {
+ return null;
+ }
+
+ if ( isset( $this->files[$src] ) ) {
+ return [
+ 'mtime' => $this->files[$src]['mtime'],
+ 'size' => strlen( $this->files[$src]['data'] ),
+ ];
+ }
+
+ return false;
+ }
+
+ protected function doGetLocalCopyMulti( array $params ) {
+ $tmpFiles = []; // (path => TempFSFile)
+ foreach ( $params['srcs'] as $srcPath ) {
+ $src = $this->resolveHashKey( $srcPath );
+ if ( $src === null || !isset( $this->files[$src] ) ) {
+ $fsFile = null;
+ } else {
+ // Create a new temporary file with the same extension...
+ $ext = FileBackend::extensionFromPath( $src );
+ $fsFile = TempFSFile::factory( 'localcopy_', $ext );
+ if ( $fsFile ) {
+ $bytes = file_put_contents( $fsFile->getPath(), $this->files[$src]['data'] );
+ if ( $bytes !== strlen( $this->files[$src]['data'] ) ) {
+ $fsFile = null;
+ }
+ }
+ }
+ $tmpFiles[$srcPath] = $fsFile;
+ }
+
+ return $tmpFiles;
+ }
+
+ protected function doStreamFile( array $params ) {
+ $status = Status::newGood();
+
+ $src = $this->resolveHashKey( $params['src'] );
+ if ( $src === null || !isset( $this->files[$src] ) ) {
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+
+ return $status;
+ }
+
+ print $this->files[$src]['data'];
+
+ return $status;
+ }
+
+ protected function doDirectoryExists( $container, $dir, array $params ) {
+ $prefix = rtrim( "$container/$dir", '/' ) . '/';
+ foreach ( $this->files as $path => $data ) {
+ if ( strpos( $path, $prefix ) === 0 ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function getDirectoryListInternal( $container, $dir, array $params ) {
+ $dirs = [];
+ $prefix = rtrim( "$container/$dir", '/' ) . '/';
+ $prefixLen = strlen( $prefix );
+ foreach ( $this->files as $path => $data ) {
+ if ( strpos( $path, $prefix ) === 0 ) {
+ $relPath = substr( $path, $prefixLen );
+ if ( $relPath === false ) {
+ continue;
+ } elseif ( strpos( $relPath, '/' ) === false ) {
+ continue; // just a file
+ }
+ $parts = array_slice( explode( '/', $relPath ), 0, -1 ); // last part is file name
+ if ( !empty( $params['topOnly'] ) ) {
+ $dirs[$parts[0]] = 1; // top directory
+ } else {
+ $current = '';
+ foreach ( $parts as $part ) { // all directories
+ $dir = ( $current === '' ) ? $part : "$current/$part";
+ $dirs[$dir] = 1;
+ $current = $dir;
+ }
+ }
+ }
+ }
+
+ return array_keys( $dirs );
+ }
+
+ public function getFileListInternal( $container, $dir, array $params ) {
+ $files = [];
+ $prefix = rtrim( "$container/$dir", '/' ) . '/';
+ $prefixLen = strlen( $prefix );
+ foreach ( $this->files as $path => $data ) {
+ if ( strpos( $path, $prefix ) === 0 ) {
+ $relPath = substr( $path, $prefixLen );
+ if ( $relPath === false ) {
+ continue;
+ } elseif ( !empty( $params['topOnly'] ) && strpos( $relPath, '/' ) !== false ) {
+ continue;
+ }
+ $files[] = $relPath;
+ }
+ }
+
+ return $files;
+ }
+
+ protected function directoriesAreVirtual() {
+ return true;
+ }
+
+ /**
+ * Get the absolute file system path for a storage path
+ *
+ * @param string $storagePath Storage path
+ * @return string|null
+ */
+ protected function resolveHashKey( $storagePath ) {
+ list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath );
+ if ( $relPath === null ) {
+ return null; // invalid
+ }
+
+ return ( $relPath !== '' ) ? "$fullCont/$relPath" : $fullCont;
+ }
+}
diff --git a/www/wiki/includes/filebackend/README b/www/wiki/includes/filebackend/README
new file mode 100644
index 00000000..c06f6fc7
--- /dev/null
+++ b/www/wiki/includes/filebackend/README
@@ -0,0 +1,208 @@
+/*!
+\ingroup FileBackend
+\page file_backend_design File backend design
+
+Some notes on the FileBackend architecture.
+
+\section intro Introduction
+
+To abstract away the differences among different types of storage media,
+MediaWiki is providing an interface known as FileBackend. Any MediaWiki
+interaction with stored files should thus use a FileBackend object.
+
+Different types of backing storage media are supported (ranging from local
+file system to distributed object stores). The types include:
+
+* FSFileBackend (used for mounted file systems)
+* SwiftFileBackend (used for Swift or Ceph Rados+RGW object stores)
+* FileBackendMultiWrite (useful for transitioning from one backend to another)
+
+Configuration documentation for each type of backend is to be found in their
+__construct() inline documentation.
+
+
+\section setup Setup
+
+File backends are registered in LocalSettings.php via the global variable
+$wgFileBackends. To access one of those defined backends, one would use
+FileBackendStore::get( <name> ) which will bring back a FileBackend object
+handle. Such handles are reused for any subsequent get() call (via singleton).
+The FileBackends objects are caching request calls such as file stats,
+SHA1 requests or TCP connection handles.
+
+\par Note:
+Some backends may require additional PHP extensions to be enabled or can rely on a
+MediaWiki extension. This is often the case when a FileBackend subclass makes use of an
+upstream client API for communicating with the backing store.
+
+
+\section fileoperations File operations
+
+The MediaWiki FileBackend API supports various operations on either files or
+directories. See FileBackend.php for full documentation for each function.
+
+
+\subsection reading Reading
+
+The following basic operations are supported for reading from a backend:
+
+On files:
+* stat a file for basic information (timestamp, size)
+* read a file into a string or several files into a map of path names to strings
+* download a file or set of files to a temporary file (on a mounted file system)
+* get the SHA1 hash of a file
+* get various properties of a file (stat information, content time, MIME information, ...)
+
+On directories:
+* get a list of files directly under a directory
+* get a recursive list of files under a directory
+* get a list of directories directly under a directory
+* get a recursive list of directories under a directory
+
+\par Note:
+Backend handles should return directory listings as iterators, all though in some cases
+they may just be simple arrays (which can still be iterated over). Iterators allow for
+callers to traverse a large number of file listings without consuming excessive RAM in
+the process. Either the memory consumed is flatly bounded (if the iterator does paging)
+or it is proportional to the depth of the portion of the directory tree being traversed
+(if the iterator works via recursion).
+
+
+\subsection writing Writing
+
+The following basic operations are supported for writing or changing in the backend:
+
+On files:
+* store (copying a mounted file system file into storage)
+* create (creating a file within storage from a string)
+* copy (within storage)
+* move (within storage)
+* delete (within storage)
+* lock/unlock (lock or unlock a file in storage)
+
+The following operations are supported for writing directories in the backend:
+* prepare (create parent container and directories for a path)
+* secure (try to lock-down access to a container)
+* publish (try to reverse the effects of secure)
+* clean (remove empty containers or directories)
+
+
+\subsection invokingoperation Invoking an operation
+
+Generally, callers should use doOperations() or doQuickOperations() when doing
+batches of changes, rather than making a suite of single operation calls. This
+makes the system tolerate high latency much better by pipelining operations
+when possible.
+
+doOperations() should be used for working on important original data, i.e. when
+consistency is important. The former will only pipeline operations that do not
+depend on each other. It is best if the operations that do not depend on each
+other occur in consecutive groups. This function can also log file changes to
+a journal (see FileJournal), which can be used to sync two backend instances.
+One might use this function for user uploads of file for example.
+
+doQuickOperations() is more geared toward ephemeral items that can be easily
+regenerated from original data. It will always pipeline without checking for
+dependencies within the operation batch. One might use this function for
+creating and purging generated thumbnails of original files for example.
+
+
+\section consistency Consistency
+
+Not all backing stores are sequentially consistent by default. Various FileBackend
+functions offer a "latest" option that can be passed in to assure (or try to assure)
+that the latest version of the file is read. Some backing stores are consistent by
+default, but callers should always assume that without this option, stale data may
+be read. This is actually true for stores that have eventual consistency.
+
+Note that file listing functions have no "latest" flag, and thus some systems may
+return stale data. Thus callers should avoid assuming that listings contain changes
+made my the current client or any other client from a very short time ago. For example,
+creating a file under a directory and then immediately doing a file listing operation
+on that directory may result in a listing that does not include that file.
+
+
+\section locking Locking
+
+Locking is effective if and only if a proper lock manager is registered and is
+actually being used by the backend. Lock managers can be registered in LocalSettings.php
+using the $wgLockManagers global configuration variable.
+
+For object stores, locking is not generally useful for avoiding partially
+written or read objects, since most stores use Multi Version Concurrency
+Control (MVCC) to avoid this. However, locking can be important when:
+* One or more operations must be done without objects changing in the meantime.
+* It can also be useful when a file read is used to determine a file write or DB change.
+ For example, doOperations() first checks that there will be no "file already exists"
+ or "file does not exist" type errors before attempting an operation batch. This works
+ by stating the files first, and is only safe if the files are locked in the meantime.
+
+When locking, callers should use the latest available file data for reads.
+Also, one should always lock the file *before* reading it, not after. If stale data is
+used to determine a write, there will be some data corruption, even when reads of the
+original file finally start returning the updated data without needing the "latest"
+option (eventual consistency). The "scoped" lock functions are preferable since
+there is not the problem of forgetting to unlock due to early returns or exceptions.
+
+Since acquiring locks can fail, and lock managers can be non-blocking, callers should:
+* Acquire all required locks up font
+* Be prepared for the case where locks fail to be acquired
+* Possible retry acquiring certain locks
+
+MVCC is also a useful pattern to use on top of the backend interface, because operations
+are not atomic, even with doOperations(), so doing complex batch file changes or changing
+files and updating a database row can result in partially written "transactions". Thus one
+should avoid changing files once they have been stored, except perhaps with ephemeral data
+that are tolerant of some degree of inconsistency.
+
+Callers can use their own locking (e.g. SELECT FOR UPDATE) if it is more convenient, but
+note that all callers that change any of the files should then go through functions that
+acquire these locks. For example, if a caller just directly uses the file backend store()
+function, it will ignore any custom "FOR UPDATE" locks, which can cause problems.
+
+\section objectstore Object stores
+
+Support for object stores (like Amazon S3/Swift) drive much of the API and design
+decisions of FileBackend, but using any POSIX compliant file systems works fine.
+The system essentially stores "files" in "containers". For a mounted file system
+as a backing store, "files" will just be files under directories. For an object store
+as a backing store, the "files" will be objects stored in actual containers.
+
+
+\section file_obj_diffs File system and Object store differences
+
+An advantage of object stores is the reduced Round-Trip Times. This is
+achieved by avoiding the need to create each parent directory before placing a
+file somewhere. It gets worse the deeper the directory hierarchy is. Another
+advantage of object stores is that object listings tend to use databases, which
+scale better than the linked list directories that file sytems sometimes use.
+File systems like btrfs and xfs use tree structures, which scale better.
+For both object stores and file systems, using "/" in filenames will allow for the
+intuitive use of directory functions. For example, creating a file in Swift
+called "container/a/b/file1" will mean that:
+- a "directory listing" of "container/a" will contain "b",
+- and a "file listing" of "b" will contain "file1"
+
+This means that switching from an object store to a file system and vise versa
+using the FileBackend interface will generally be harmless. However, one must be
+aware of some important differences:
+
+* In a file system, you cannot have a file and a directory within the same path
+ whereas it is possible in an object stores. Calling code should avoid any layouts
+ which allow files and directories at the same path.
+* Some file systems have file name length restrictions or overall path length
+ restrictions that others do not. The same goes with object stores which might
+ have a maximum object length or a limitation regarding the number of files
+ under a container or volume.
+* Latency varies among systems, certain access patterns may not be tolerable for
+ certain backends but may hold up for others. Some backend subclasses use
+ MediaWiki's object caching for serving stat requests, which can greatly
+ reduce latency. Making sure that the backend has pipelining (see the
+ "parallelize" and "concurrency" settings) enabled can also mask latency in
+ batch operation scenarios.
+* File systems may implement directories as linked-lists or other structures
+ with poor scalability, so calling code should use layouts that shard the data.
+ Instead of storing files like "container/file.txt", one can store files like
+ "container/<x>/<y>/file.txt". It is best if "sharding" optional or configurable.
+
+*/
diff --git a/www/wiki/includes/filebackend/SwiftFileBackend.php b/www/wiki/includes/filebackend/SwiftFileBackend.php
new file mode 100644
index 00000000..0f7e4b56
--- /dev/null
+++ b/www/wiki/includes/filebackend/SwiftFileBackend.php
@@ -0,0 +1,1910 @@
+<?php
+/**
+ * OpenStack Swift based file backend.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Russ Nelson
+ * @author Aaron Schulz
+ */
+
+/**
+ * @brief Class for an OpenStack Swift (or Ceph RGW) based file backend.
+ *
+ * Status messages should avoid mentioning the Swift account name.
+ * Likewise, error suppression should be used to avoid path disclosure.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+class SwiftFileBackend extends FileBackendStore {
+ /** @var MultiHttpClient */
+ protected $http;
+
+ /** @var int TTL in seconds */
+ protected $authTTL;
+
+ /** @var string Authentication base URL (without version) */
+ protected $swiftAuthUrl;
+
+ /** @var string Swift user (account:user) to authenticate as */
+ protected $swiftUser;
+
+ /** @var string Secret key for user */
+ protected $swiftKey;
+
+ /** @var string Shared secret value for making temp URLs */
+ protected $swiftTempUrlKey;
+
+ /** @var string S3 access key (RADOS Gateway) */
+ protected $rgwS3AccessKey;
+
+ /** @var string S3 authentication key (RADOS Gateway) */
+ protected $rgwS3SecretKey;
+
+ /** @var BagOStuff */
+ protected $srvCache;
+
+ /** @var ProcessCacheLRU Container stat cache */
+ protected $containerStatCache;
+
+ /** @var array */
+ protected $authCreds;
+
+ /** @var int UNIX timestamp */
+ protected $authSessionTimestamp = 0;
+
+ /** @var int UNIX timestamp */
+ protected $authErrorTimestamp = null;
+
+ /** @var bool Whether the server is an Ceph RGW */
+ protected $isRGW = false;
+
+ /**
+ * @see FileBackendStore::__construct()
+ * Additional $config params include:
+ * - swiftAuthUrl : Swift authentication server URL
+ * - swiftUser : Swift user used by MediaWiki (account:username)
+ * - swiftKey : Swift authentication key for the above user
+ * - swiftAuthTTL : Swift authentication TTL (seconds)
+ * - swiftTempUrlKey : Swift "X-Account-Meta-Temp-URL-Key" value on the account.
+ * Do not set this until it has been set in the backend.
+ * - shardViaHashLevels : Map of container names to sharding config with:
+ * - base : base of hash characters, 16 or 36
+ * - levels : the number of hash levels (and digits)
+ * - repeat : hash subdirectories are prefixed with all the
+ * parent hash directory names (e.g. "a/ab/abc")
+ * - cacheAuthInfo : Whether to cache authentication tokens in APC, XCache, ect.
+ * If those are not available, then the main cache will be used.
+ * This is probably insecure in shared hosting environments.
+ * - rgwS3AccessKey : Rados Gateway S3 "access key" value on the account.
+ * Do not set this until it has been set in the backend.
+ * This is used for generating expiring pre-authenticated URLs.
+ * Only use this when using rgw and to work around
+ * http://tracker.newdream.net/issues/3454.
+ * - rgwS3SecretKey : Rados Gateway S3 "secret key" value on the account.
+ * Do not set this until it has been set in the backend.
+ * This is used for generating expiring pre-authenticated URLs.
+ * Only use this when using rgw and to work around
+ * http://tracker.newdream.net/issues/3454.
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+ // Required settings
+ $this->swiftAuthUrl = $config['swiftAuthUrl'];
+ $this->swiftUser = $config['swiftUser'];
+ $this->swiftKey = $config['swiftKey'];
+ // Optional settings
+ $this->authTTL = isset( $config['swiftAuthTTL'] )
+ ? $config['swiftAuthTTL']
+ : 15 * 60; // some sane number
+ $this->swiftTempUrlKey = isset( $config['swiftTempUrlKey'] )
+ ? $config['swiftTempUrlKey']
+ : '';
+ $this->shardViaHashLevels = isset( $config['shardViaHashLevels'] )
+ ? $config['shardViaHashLevels']
+ : '';
+ $this->rgwS3AccessKey = isset( $config['rgwS3AccessKey'] )
+ ? $config['rgwS3AccessKey']
+ : '';
+ $this->rgwS3SecretKey = isset( $config['rgwS3SecretKey'] )
+ ? $config['rgwS3SecretKey']
+ : '';
+ // HTTP helper client
+ $this->http = new MultiHttpClient( [] );
+ // Cache container information to mask latency
+ if ( isset( $config['wanCache'] ) && $config['wanCache'] instanceof WANObjectCache ) {
+ $this->memCache = $config['wanCache'];
+ }
+ // Process cache for container info
+ $this->containerStatCache = new ProcessCacheLRU( 300 );
+ // Cache auth token information to avoid RTTs
+ if ( !empty( $config['cacheAuthInfo'] ) ) {
+ if ( PHP_SAPI === 'cli' ) {
+ // Preferrably memcached
+ $this->srvCache = ObjectCache::getLocalClusterInstance();
+ } else {
+ // Look for APC, XCache, WinCache, ect...
+ $this->srvCache = ObjectCache::getLocalServerInstance( CACHE_NONE );
+ }
+ } else {
+ $this->srvCache = new EmptyBagOStuff();
+ }
+ }
+
+ public function getFeatures() {
+ return ( FileBackend::ATTR_UNICODE_PATHS |
+ FileBackend::ATTR_HEADERS | FileBackend::ATTR_METADATA );
+ }
+
+ protected function resolveContainerPath( $container, $relStoragePath ) {
+ if ( !mb_check_encoding( $relStoragePath, 'UTF-8' ) ) {
+ return null; // not UTF-8, makes it hard to use CF and the swift HTTP API
+ } elseif ( strlen( urlencode( $relStoragePath ) ) > 1024 ) {
+ return null; // too long for Swift
+ }
+
+ return $relStoragePath;
+ }
+
+ public function isPathUsableInternal( $storagePath ) {
+ list( $container, $rel ) = $this->resolveStoragePathReal( $storagePath );
+ if ( $rel === null ) {
+ return false; // invalid
+ }
+
+ return is_array( $this->getContainerStat( $container ) );
+ }
+
+ /**
+ * Sanitize and filter the custom headers from a $params array.
+ * Only allows certain "standard" Content- and X-Content- headers.
+ *
+ * @param array $params
+ * @return array Sanitized value of 'headers' field in $params
+ */
+ protected function sanitizeHdrs( array $params ) {
+ return isset( $params['headers'] )
+ ? $this->getCustomHeaders( $params['headers'] )
+ : [];
+
+ }
+
+ /**
+ * @param array $rawHeaders
+ * @return array Custom non-metadata HTTP headers
+ */
+ protected function getCustomHeaders( array $rawHeaders ) {
+ $headers = [];
+
+ // Normalize casing, and strip out illegal headers
+ foreach ( $rawHeaders as $name => $value ) {
+ $name = strtolower( $name );
+ if ( preg_match( '/^content-(type|length)$/', $name ) ) {
+ continue; // blacklisted
+ } elseif ( preg_match( '/^(x-)?content-/', $name ) ) {
+ $headers[$name] = $value; // allowed
+ } elseif ( preg_match( '/^content-(disposition)/', $name ) ) {
+ $headers[$name] = $value; // allowed
+ }
+ }
+ // By default, Swift has annoyingly low maximum header value limits
+ if ( isset( $headers['content-disposition'] ) ) {
+ $disposition = '';
+ // @note: assume FileBackend::makeContentDisposition() already used
+ foreach ( explode( ';', $headers['content-disposition'] ) as $part ) {
+ $part = trim( $part );
+ $new = ( $disposition === '' ) ? $part : "{$disposition};{$part}";
+ if ( strlen( $new ) <= 255 ) {
+ $disposition = $new;
+ } else {
+ break; // too long; sigh
+ }
+ }
+ $headers['content-disposition'] = $disposition;
+ }
+
+ return $headers;
+ }
+
+ /**
+ * @param array $rawHeaders
+ * @return array Custom metadata headers
+ */
+ protected function getMetadataHeaders( array $rawHeaders ) {
+ $headers = [];
+ foreach ( $rawHeaders as $name => $value ) {
+ $name = strtolower( $name );
+ if ( strpos( $name, 'x-object-meta-' ) === 0 ) {
+ $headers[$name] = $value;
+ }
+ }
+
+ return $headers;
+ }
+
+ /**
+ * @param array $rawHeaders
+ * @return array Custom metadata headers with prefix removed
+ */
+ protected function getMetadata( array $rawHeaders ) {
+ $metadata = [];
+ foreach ( $this->getMetadataHeaders( $rawHeaders ) as $name => $value ) {
+ $metadata[substr( $name, strlen( 'x-object-meta-' ) )] = $value;
+ }
+
+ return $metadata;
+ }
+
+ protected function doCreateInternal( array $params ) {
+ $status = Status::newGood();
+
+ list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] );
+ if ( $dstRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ $sha1Hash = Wikimedia\base_convert( sha1( $params['content'] ), 16, 36, 31 );
+ $contentType = isset( $params['headers']['content-type'] )
+ ? $params['headers']['content-type']
+ : $this->getContentType( $params['dst'], $params['content'], null );
+
+ $reqs = [ [
+ 'method' => 'PUT',
+ 'url' => [ $dstCont, $dstRel ],
+ 'headers' => [
+ 'content-length' => strlen( $params['content'] ),
+ 'etag' => md5( $params['content'] ),
+ 'content-type' => $contentType,
+ 'x-object-meta-sha1base36' => $sha1Hash
+ ] + $this->sanitizeHdrs( $params ),
+ 'body' => $params['content']
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, Status $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 201 ) {
+ // good
+ } elseif ( $rcode === 412 ) {
+ $status->fatal( 'backend-fail-contenttype', $params['dst'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually write the object in Swift
+ $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doStoreInternal( array $params ) {
+ $status = Status::newGood();
+
+ list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] );
+ if ( $dstRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ MediaWiki\suppressWarnings();
+ $sha1Hash = sha1_file( $params['src'] );
+ MediaWiki\restoreWarnings();
+ if ( $sha1Hash === false ) { // source doesn't exist?
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+ $sha1Hash = Wikimedia\base_convert( $sha1Hash, 16, 36, 31 );
+ $contentType = isset( $params['headers']['content-type'] )
+ ? $params['headers']['content-type']
+ : $this->getContentType( $params['dst'], null, $params['src'] );
+
+ $handle = fopen( $params['src'], 'rb' );
+ if ( $handle === false ) { // source doesn't exist?
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+
+ $reqs = [ [
+ 'method' => 'PUT',
+ 'url' => [ $dstCont, $dstRel ],
+ 'headers' => [
+ 'content-length' => filesize( $params['src'] ),
+ 'etag' => md5_file( $params['src'] ),
+ 'content-type' => $contentType,
+ 'x-object-meta-sha1base36' => $sha1Hash
+ ] + $this->sanitizeHdrs( $params ),
+ 'body' => $handle // resource
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, Status $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 201 ) {
+ // good
+ } elseif ( $rcode === 412 ) {
+ $status->fatal( 'backend-fail-contenttype', $params['dst'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually write the object in Swift
+ $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doCopyInternal( array $params ) {
+ $status = Status::newGood();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] );
+ if ( $dstRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ $reqs = [ [
+ 'method' => 'PUT',
+ 'url' => [ $dstCont, $dstRel ],
+ 'headers' => [
+ 'x-copy-from' => '/' . rawurlencode( $srcCont ) .
+ '/' . str_replace( "%2F", "/", rawurlencode( $srcRel ) )
+ ] + $this->sanitizeHdrs( $params ), // extra headers merged into object
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, Status $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 201 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually write the object in Swift
+ $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doMoveInternal( array $params ) {
+ $status = Status::newGood();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] );
+ if ( $dstRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ $reqs = [
+ [
+ 'method' => 'PUT',
+ 'url' => [ $dstCont, $dstRel ],
+ 'headers' => [
+ 'x-copy-from' => '/' . rawurlencode( $srcCont ) .
+ '/' . str_replace( "%2F", "/", rawurlencode( $srcRel ) )
+ ] + $this->sanitizeHdrs( $params ) // extra headers merged into object
+ ]
+ ];
+ if ( "{$srcCont}/{$srcRel}" !== "{$dstCont}/{$dstRel}" ) {
+ $reqs[] = [
+ 'method' => 'DELETE',
+ 'url' => [ $srcCont, $srcRel ],
+ 'headers' => []
+ ];
+ }
+
+ $method = __METHOD__;
+ $handler = function ( array $request, Status $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $request['method'] === 'PUT' && $rcode === 201 ) {
+ // good
+ } elseif ( $request['method'] === 'DELETE' && $rcode === 204 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually move the object in Swift
+ $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doDeleteInternal( array $params ) {
+ $status = Status::newGood();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $reqs = [ [
+ 'method' => 'DELETE',
+ 'url' => [ $srcCont, $srcRel ],
+ 'headers' => []
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, Status $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 204 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+ }
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually delete the object in Swift
+ $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doDescribeInternal( array $params ) {
+ $status = Status::newGood();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ // Fetch the old object headers/metadata...this should be in stat cache by now
+ $stat = $this->getFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
+ if ( $stat && !isset( $stat['xattr'] ) ) { // older cache entry
+ $stat = $this->doGetFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
+ }
+ if ( !$stat ) {
+ $status->fatal( 'backend-fail-describe', $params['src'] );
+
+ return $status;
+ }
+
+ // POST clears prior headers, so we need to merge the changes in to the old ones
+ $metaHdrs = [];
+ foreach ( $stat['xattr']['metadata'] as $name => $value ) {
+ $metaHdrs["x-object-meta-$name"] = $value;
+ }
+ $customHdrs = $this->sanitizeHdrs( $params ) + $stat['xattr']['headers'];
+
+ $reqs = [ [
+ 'method' => 'POST',
+ 'url' => [ $srcCont, $srcRel ],
+ 'headers' => $metaHdrs + $customHdrs
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, Status $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 202 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ $status->fatal( 'backend-fail-describe', $params['src'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually change the object in Swift
+ $status->merge( current( $this->doExecuteOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doPrepareInternal( $fullCont, $dir, array $params ) {
+ $status = Status::newGood();
+
+ // (a) Check if container already exists
+ $stat = $this->getContainerStat( $fullCont );
+ if ( is_array( $stat ) ) {
+ return $status; // already there
+ } elseif ( $stat === null ) {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ wfDebugLog( 'SwiftBackend', __METHOD__ . ': cannot get container stat' );
+
+ return $status;
+ }
+
+ // (b) Create container as needed with proper ACLs
+ if ( $stat === false ) {
+ $params['op'] = 'prepare';
+ $status->merge( $this->createContainer( $fullCont, $params ) );
+ }
+
+ return $status;
+ }
+
+ protected function doSecureInternal( $fullCont, $dir, array $params ) {
+ $status = Status::newGood();
+ if ( empty( $params['noAccess'] ) ) {
+ return $status; // nothing to do
+ }
+
+ $stat = $this->getContainerStat( $fullCont );
+ if ( is_array( $stat ) ) {
+ // Make container private to end-users...
+ $status->merge( $this->setContainerAccess(
+ $fullCont,
+ [ $this->swiftUser ], // read
+ [ $this->swiftUser ] // write
+ ) );
+ } elseif ( $stat === false ) {
+ $status->fatal( 'backend-fail-usable', $params['dir'] );
+ } else {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ wfDebugLog( 'SwiftBackend', __METHOD__ . ': cannot get container stat' );
+ }
+
+ return $status;
+ }
+
+ protected function doPublishInternal( $fullCont, $dir, array $params ) {
+ $status = Status::newGood();
+
+ $stat = $this->getContainerStat( $fullCont );
+ if ( is_array( $stat ) ) {
+ // Make container public to end-users...
+ $status->merge( $this->setContainerAccess(
+ $fullCont,
+ [ $this->swiftUser, '.r:*' ], // read
+ [ $this->swiftUser ] // write
+ ) );
+ } elseif ( $stat === false ) {
+ $status->fatal( 'backend-fail-usable', $params['dir'] );
+ } else {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ wfDebugLog( 'SwiftBackend', __METHOD__ . ': cannot get container stat' );
+ }
+
+ return $status;
+ }
+
+ protected function doCleanInternal( $fullCont, $dir, array $params ) {
+ $status = Status::newGood();
+
+ // Only containers themselves can be removed, all else is virtual
+ if ( $dir != '' ) {
+ return $status; // nothing to do
+ }
+
+ // (a) Check the container
+ $stat = $this->getContainerStat( $fullCont, true );
+ if ( $stat === false ) {
+ return $status; // ok, nothing to do
+ } elseif ( !is_array( $stat ) ) {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ wfDebugLog( 'SwiftBackend', __METHOD__ . ': cannot get container stat' );
+
+ return $status;
+ }
+
+ // (b) Delete the container if empty
+ if ( $stat['count'] == 0 ) {
+ $params['op'] = 'clean';
+ $status->merge( $this->deleteContainer( $fullCont, $params ) );
+ }
+
+ return $status;
+ }
+
+ protected function doGetFileStat( array $params ) {
+ $params = [ 'srcs' => [ $params['src'] ], 'concurrency' => 1 ] + $params;
+ unset( $params['src'] );
+ $stats = $this->doGetFileStatMulti( $params );
+
+ return reset( $stats );
+ }
+
+ /**
+ * Convert dates like "Tue, 03 Jan 2012 22:01:04 GMT"/"2013-05-11T07:37:27.678360Z".
+ * Dates might also come in like "2013-05-11T07:37:27.678360" from Swift listings,
+ * missing the timezone suffix (though Ceph RGW does not appear to have this bug).
+ *
+ * @param string $ts
+ * @param int $format Output format (TS_* constant)
+ * @return string
+ * @throws FileBackendError
+ */
+ protected function convertSwiftDate( $ts, $format = TS_MW ) {
+ try {
+ $timestamp = new MWTimestamp( $ts );
+
+ return $timestamp->getTimestamp( $format );
+ } catch ( Exception $e ) {
+ throw new FileBackendError( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Fill in any missing object metadata and save it to Swift
+ *
+ * @param array $objHdrs Object response headers
+ * @param string $path Storage path to object
+ * @return array New headers
+ */
+ protected function addMissingMetadata( array $objHdrs, $path ) {
+ if ( isset( $objHdrs['x-object-meta-sha1base36'] ) ) {
+ return $objHdrs; // nothing to do
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+ wfDebugLog( 'SwiftBackend', __METHOD__ . ": $path was not stored with SHA-1 metadata." );
+
+ $objHdrs['x-object-meta-sha1base36'] = false;
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ return $objHdrs; // failed
+ }
+
+ // Find prior custom HTTP headers
+ $postHeaders = $this->getCustomHeaders( $objHdrs );
+ // Find prior metadata headers
+ $postHeaders += $this->getMetadataHeaders( $objHdrs );
+
+ $status = Status::newGood();
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scopeLockS = $this->getScopedFileLocks( [ $path ], LockManager::LOCK_UW, $status );
+ if ( $status->isOK() ) {
+ $tmpFile = $this->getLocalCopy( [ 'src' => $path, 'latest' => 1 ] );
+ if ( $tmpFile ) {
+ $hash = $tmpFile->getSha1Base36();
+ if ( $hash !== false ) {
+ $objHdrs['x-object-meta-sha1base36'] = $hash;
+ // Merge new SHA1 header into the old ones
+ $postHeaders['x-object-meta-sha1base36'] = $hash;
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path );
+ list( $rcode ) = $this->http->run( [
+ 'method' => 'POST',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth ) + $postHeaders
+ ] );
+ if ( $rcode >= 200 && $rcode <= 299 ) {
+ $this->deleteFileCache( $path );
+
+ return $objHdrs; // success
+ }
+ }
+ }
+ }
+
+ wfDebugLog( 'SwiftBackend', __METHOD__ . ": unable to set SHA-1 metadata for $path" );
+
+ return $objHdrs; // failed
+ }
+
+ protected function doGetFileContentsMulti( array $params ) {
+ $contents = [];
+
+ $auth = $this->getAuthentication();
+
+ $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
+ // Blindly create tmp files and stream to them, catching any exception if the file does
+ // not exist. Doing stats here is useless and will loop infinitely in addMissingMetadata().
+ $reqs = []; // (path => op)
+
+ foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path );
+ if ( $srcRel === null || !$auth ) {
+ $contents[$path] = false;
+ continue;
+ }
+ // Create a new temporary memory file...
+ $handle = fopen( 'php://temp', 'wb' );
+ if ( $handle ) {
+ $reqs[$path] = [
+ 'method' => 'GET',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ + $this->headersFromParams( $params ),
+ 'stream' => $handle,
+ ];
+ }
+ $contents[$path] = false;
+ }
+
+ $opts = [ 'maxConnsPerHost' => $params['concurrency'] ];
+ $reqs = $this->http->runMulti( $reqs, $opts );
+ foreach ( $reqs as $path => $op ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $op['response'];
+ if ( $rcode >= 200 && $rcode <= 299 ) {
+ rewind( $op['stream'] ); // start from the beginning
+ $contents[$path] = stream_get_contents( $op['stream'] );
+ } elseif ( $rcode === 404 ) {
+ $contents[$path] = false;
+ } else {
+ $this->onError( null, __METHOD__,
+ [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
+ }
+ fclose( $op['stream'] ); // close open handle
+ }
+
+ return $contents;
+ }
+
+ protected function doDirectoryExists( $fullCont, $dir, array $params ) {
+ $prefix = ( $dir == '' ) ? null : "{$dir}/";
+ $status = $this->objectListing( $fullCont, 'names', 1, null, $prefix );
+ if ( $status->isOK() ) {
+ return ( count( $status->value ) ) > 0;
+ }
+
+ return null; // error
+ }
+
+ /**
+ * @see FileBackendStore::getDirectoryListInternal()
+ * @param string $fullCont
+ * @param string $dir
+ * @param array $params
+ * @return SwiftFileBackendDirList
+ */
+ public function getDirectoryListInternal( $fullCont, $dir, array $params ) {
+ return new SwiftFileBackendDirList( $this, $fullCont, $dir, $params );
+ }
+
+ /**
+ * @see FileBackendStore::getFileListInternal()
+ * @param string $fullCont
+ * @param string $dir
+ * @param array $params
+ * @return SwiftFileBackendFileList
+ */
+ public function getFileListInternal( $fullCont, $dir, array $params ) {
+ return new SwiftFileBackendFileList( $this, $fullCont, $dir, $params );
+ }
+
+ /**
+ * Do not call this function outside of SwiftFileBackendFileList
+ *
+ * @param string $fullCont Resolved container name
+ * @param string $dir Resolved storage directory with no trailing slash
+ * @param string|null $after Resolved container relative path to list items after
+ * @param int $limit Max number of items to list
+ * @param array $params Parameters for getDirectoryList()
+ * @return array List of container relative resolved paths of directories directly under $dir
+ * @throws FileBackendError
+ */
+ public function getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
+ $dirs = [];
+ if ( $after === INF ) {
+ return $dirs; // nothing more
+ }
+
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ $prefix = ( $dir == '' ) ? null : "{$dir}/";
+ // Non-recursive: only list dirs right under $dir
+ if ( !empty( $params['topOnly'] ) ) {
+ $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
+ if ( !$status->isOK() ) {
+ throw new FileBackendError( "Iterator page I/O error: {$status->getMessage()}" );
+ }
+ $objects = $status->value;
+ foreach ( $objects as $object ) { // files and directories
+ if ( substr( $object, -1 ) === '/' ) {
+ $dirs[] = $object; // directories end in '/'
+ }
+ }
+ } else {
+ // Recursive: list all dirs under $dir and its subdirs
+ $getParentDir = function ( $path ) {
+ return ( strpos( $path, '/' ) !== false ) ? dirname( $path ) : false;
+ };
+
+ // Get directory from last item of prior page
+ $lastDir = $getParentDir( $after ); // must be first page
+ $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
+
+ if ( !$status->isOK() ) {
+ throw new FileBackendError( "Iterator page I/O error: {$status->getMessage()}" );
+ }
+
+ $objects = $status->value;
+
+ foreach ( $objects as $object ) { // files
+ $objectDir = $getParentDir( $object ); // directory of object
+
+ if ( $objectDir !== false && $objectDir !== $dir ) {
+ // Swift stores paths in UTF-8, using binary sorting.
+ // See function "create_container_table" in common/db.py.
+ // If a directory is not "greater" than the last one,
+ // then it was already listed by the calling iterator.
+ if ( strcmp( $objectDir, $lastDir ) > 0 ) {
+ $pDir = $objectDir;
+ do { // add dir and all its parent dirs
+ $dirs[] = "{$pDir}/";
+ $pDir = $getParentDir( $pDir );
+ } while ( $pDir !== false // sanity
+ && strcmp( $pDir, $lastDir ) > 0 // not done already
+ && strlen( $pDir ) > strlen( $dir ) // within $dir
+ );
+ }
+ $lastDir = $objectDir;
+ }
+ }
+ }
+ // Page on the unfiltered directory listing (what is returned may be filtered)
+ if ( count( $objects ) < $limit ) {
+ $after = INF; // avoid a second RTT
+ } else {
+ $after = end( $objects ); // update last item
+ }
+
+ return $dirs;
+ }
+
+ /**
+ * Do not call this function outside of SwiftFileBackendFileList
+ *
+ * @param string $fullCont Resolved container name
+ * @param string $dir Resolved storage directory with no trailing slash
+ * @param string|null $after Resolved container relative path of file to list items after
+ * @param int $limit Max number of items to list
+ * @param array $params Parameters for getDirectoryList()
+ * @return array List of resolved container relative paths of files under $dir
+ * @throws FileBackendError
+ */
+ public function getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
+ $files = []; // list of (path, stat array or null) entries
+ if ( $after === INF ) {
+ return $files; // nothing more
+ }
+
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ $prefix = ( $dir == '' ) ? null : "{$dir}/";
+ // $objects will contain a list of unfiltered names or CF_Object items
+ // Non-recursive: only list files right under $dir
+ if ( !empty( $params['topOnly'] ) ) {
+ if ( !empty( $params['adviseStat'] ) ) {
+ $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix, '/' );
+ } else {
+ $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
+ }
+ } else {
+ // Recursive: list all files under $dir and its subdirs
+ if ( !empty( $params['adviseStat'] ) ) {
+ $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix );
+ } else {
+ $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
+ }
+ }
+
+ // Reformat this list into a list of (name, stat array or null) entries
+ if ( !$status->isOK() ) {
+ throw new FileBackendError( "Iterator page I/O error: {$status->getMessage()}" );
+ }
+
+ $objects = $status->value;
+ $files = $this->buildFileObjectListing( $params, $dir, $objects );
+
+ // Page on the unfiltered object listing (what is returned may be filtered)
+ if ( count( $objects ) < $limit ) {
+ $after = INF; // avoid a second RTT
+ } else {
+ $after = end( $objects ); // update last item
+ $after = is_object( $after ) ? $after->name : $after;
+ }
+
+ return $files;
+ }
+
+ /**
+ * Build a list of file objects, filtering out any directories
+ * and extracting any stat info if provided in $objects (for CF_Objects)
+ *
+ * @param array $params Parameters for getDirectoryList()
+ * @param string $dir Resolved container directory path
+ * @param array $objects List of CF_Object items or object names
+ * @return array List of (names,stat array or null) entries
+ */
+ private function buildFileObjectListing( array $params, $dir, array $objects ) {
+ $names = [];
+ foreach ( $objects as $object ) {
+ if ( is_object( $object ) ) {
+ if ( isset( $object->subdir ) || !isset( $object->name ) ) {
+ continue; // virtual directory entry; ignore
+ }
+ $stat = [
+ // Convert various random Swift dates to TS_MW
+ 'mtime' => $this->convertSwiftDate( $object->last_modified, TS_MW ),
+ 'size' => (int)$object->bytes,
+ 'sha1' => null,
+ // Note: manifiest ETags are not an MD5 of the file
+ 'md5' => ctype_xdigit( $object->hash ) ? $object->hash : null,
+ 'latest' => false // eventually consistent
+ ];
+ $names[] = [ $object->name, $stat ];
+ } elseif ( substr( $object, -1 ) !== '/' ) {
+ // Omit directories, which end in '/' in listings
+ $names[] = [ $object, null ];
+ }
+ }
+
+ return $names;
+ }
+
+ /**
+ * Do not call this function outside of SwiftFileBackendFileList
+ *
+ * @param string $path Storage path
+ * @param array $val Stat value
+ */
+ public function loadListingStatInternal( $path, array $val ) {
+ $this->cheapCache->set( $path, 'stat', $val );
+ }
+
+ protected function doGetFileXAttributes( array $params ) {
+ $stat = $this->getFileStat( $params );
+ if ( $stat ) {
+ if ( !isset( $stat['xattr'] ) ) {
+ // Stat entries filled by file listings don't include metadata/headers
+ $this->clearCache( [ $params['src'] ] );
+ $stat = $this->getFileStat( $params );
+ }
+
+ return $stat['xattr'];
+ } else {
+ return false;
+ }
+ }
+
+ protected function doGetFileSha1base36( array $params ) {
+ $stat = $this->getFileStat( $params );
+ if ( $stat ) {
+ if ( !isset( $stat['sha1'] ) ) {
+ // Stat entries filled by file listings don't include SHA1
+ $this->clearCache( [ $params['src'] ] );
+ $stat = $this->getFileStat( $params );
+ }
+
+ return $stat['sha1'];
+ } else {
+ return false;
+ }
+ }
+
+ protected function doStreamFile( array $params ) {
+ $status = Status::newGood();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+ }
+
+ $auth = $this->getAuthentication();
+ if ( !$auth || !is_array( $this->getContainerStat( $srcCont ) ) ) {
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+
+ return $status;
+ }
+
+ $handle = fopen( 'php://output', 'wb' );
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'GET',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ + $this->headersFromParams( $params ),
+ 'stream' => $handle,
+ ] );
+
+ if ( $rcode >= 200 && $rcode <= 299 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+ } else {
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+
+ return $status;
+ }
+
+ protected function doGetLocalCopyMulti( array $params ) {
+ $tmpFiles = [];
+
+ $auth = $this->getAuthentication();
+
+ $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
+ // Blindly create tmp files and stream to them, catching any exception if the file does
+ // not exist. Doing a stat here is useless causes infinite loops in addMissingMetadata().
+ $reqs = []; // (path => op)
+
+ foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path );
+ if ( $srcRel === null || !$auth ) {
+ $tmpFiles[$path] = null;
+ continue;
+ }
+ // Get source file extension
+ $ext = FileBackend::extensionFromPath( $path );
+ // Create a new temporary file...
+ $tmpFile = TempFSFile::factory( 'localcopy_', $ext );
+ if ( $tmpFile ) {
+ $handle = fopen( $tmpFile->getPath(), 'wb' );
+ if ( $handle ) {
+ $reqs[$path] = [
+ 'method' => 'GET',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ + $this->headersFromParams( $params ),
+ 'stream' => $handle,
+ ];
+ } else {
+ $tmpFile = null;
+ }
+ }
+ $tmpFiles[$path] = $tmpFile;
+ }
+
+ $isLatest = ( $this->isRGW || !empty( $params['latest'] ) );
+ $opts = [ 'maxConnsPerHost' => $params['concurrency'] ];
+ $reqs = $this->http->runMulti( $reqs, $opts );
+ foreach ( $reqs as $path => $op ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $op['response'];
+ fclose( $op['stream'] ); // close open handle
+ if ( $rcode >= 200 && $rcode <= 299 ) {
+ $size = $tmpFiles[$path] ? $tmpFiles[$path]->getSize() : 0;
+ // Double check that the disk is not full/broken
+ if ( $size != $rhdrs['content-length'] ) {
+ $tmpFiles[$path] = null;
+ $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
+ $this->onError( null, __METHOD__,
+ [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
+ }
+ // Set the file stat process cache in passing
+ $stat = $this->getStatFromHeaders( $rhdrs );
+ $stat['latest'] = $isLatest;
+ $this->cheapCache->set( $path, 'stat', $stat );
+ } elseif ( $rcode === 404 ) {
+ $tmpFiles[$path] = false;
+ } else {
+ $tmpFiles[$path] = null;
+ $this->onError( null, __METHOD__,
+ [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
+ }
+ }
+
+ return $tmpFiles;
+ }
+
+ public function getFileHttpUrl( array $params ) {
+ if ( $this->swiftTempUrlKey != '' ||
+ ( $this->rgwS3AccessKey != '' && $this->rgwS3SecretKey != '' )
+ ) {
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ return null; // invalid path
+ }
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ return null;
+ }
+
+ $ttl = isset( $params['ttl'] ) ? $params['ttl'] : 86400;
+ $expires = time() + $ttl;
+
+ if ( $this->swiftTempUrlKey != '' ) {
+ $url = $this->storageUrl( $auth, $srcCont, $srcRel );
+ // Swift wants the signature based on the unencoded object name
+ $contPath = parse_url( $this->storageUrl( $auth, $srcCont ), PHP_URL_PATH );
+ $signature = hash_hmac( 'sha1',
+ "GET\n{$expires}\n{$contPath}/{$srcRel}",
+ $this->swiftTempUrlKey
+ );
+
+ return "{$url}?temp_url_sig={$signature}&temp_url_expires={$expires}";
+ } else { // give S3 API URL for rgw
+ // Path for signature starts with the bucket
+ $spath = '/' . rawurlencode( $srcCont ) . '/' .
+ str_replace( '%2F', '/', rawurlencode( $srcRel ) );
+ // Calculate the hash
+ $signature = base64_encode( hash_hmac(
+ 'sha1',
+ "GET\n\n\n{$expires}\n{$spath}",
+ $this->rgwS3SecretKey,
+ true // raw
+ ) );
+ // See http://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html.
+ // Note: adding a newline for empty CanonicalizedAmzHeaders does not work.
+ return wfAppendQuery(
+ str_replace( '/swift/v1', '', // S3 API is the rgw default
+ $this->storageUrl( $auth ) . $spath ),
+ [
+ 'Signature' => $signature,
+ 'Expires' => $expires,
+ 'AWSAccessKeyId' => $this->rgwS3AccessKey ]
+ );
+ }
+ }
+
+ return null;
+ }
+
+ protected function directoriesAreVirtual() {
+ return true;
+ }
+
+ /**
+ * Get headers to send to Swift when reading a file based
+ * on a FileBackend params array, e.g. that of getLocalCopy().
+ * $params is currently only checked for a 'latest' flag.
+ *
+ * @param array $params
+ * @return array
+ */
+ protected function headersFromParams( array $params ) {
+ $hdrs = [];
+ if ( !empty( $params['latest'] ) ) {
+ $hdrs['x-newest'] = 'true';
+ }
+
+ return $hdrs;
+ }
+
+ /**
+ * @param FileBackendStoreOpHandle[] $fileOpHandles
+ *
+ * @return Status[]
+ */
+ protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
+ $statuses = [];
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ foreach ( $fileOpHandles as $index => $fileOpHandle ) {
+ $statuses[$index] = Status::newFatal( 'backend-fail-connect', $this->name );
+ }
+
+ return $statuses;
+ }
+
+ // Split the HTTP requests into stages that can be done concurrently
+ $httpReqsByStage = []; // map of (stage => index => HTTP request)
+ foreach ( $fileOpHandles as $index => $fileOpHandle ) {
+ $reqs = $fileOpHandle->httpOp;
+ // Convert the 'url' parameter to an actual URL using $auth
+ foreach ( $reqs as $stage => &$req ) {
+ list( $container, $relPath ) = $req['url'];
+ $req['url'] = $this->storageUrl( $auth, $container, $relPath );
+ $req['headers'] = isset( $req['headers'] ) ? $req['headers'] : [];
+ $req['headers'] = $this->authTokenHeaders( $auth ) + $req['headers'];
+ $httpReqsByStage[$stage][$index] = $req;
+ }
+ $statuses[$index] = Status::newGood();
+ }
+
+ // Run all requests for the first stage, then the next, and so on
+ $reqCount = count( $httpReqsByStage );
+ for ( $stage = 0; $stage < $reqCount; ++$stage ) {
+ $httpReqs = $this->http->runMulti( $httpReqsByStage[$stage] );
+ foreach ( $httpReqs as $index => $httpReq ) {
+ // Run the callback for each request of this operation
+ $callback = $fileOpHandles[$index]->callback;
+ call_user_func_array( $callback, [ $httpReq, $statuses[$index] ] );
+ // On failure, abort all remaining requests for this operation
+ // (e.g. abort the DELETE request if the COPY request fails for a move)
+ if ( !$statuses[$index]->isOK() ) {
+ $stages = count( $fileOpHandles[$index]->httpOp );
+ for ( $s = ( $stage + 1 ); $s < $stages; ++$s ) {
+ unset( $httpReqsByStage[$s][$index] );
+ }
+ }
+ }
+ }
+
+ return $statuses;
+ }
+
+ /**
+ * Set read/write permissions for a Swift container.
+ *
+ * @see http://swift.openstack.org/misc.html#acls
+ *
+ * In general, we don't allow listings to end-users. It's not useful, isn't well-defined
+ * (lists are truncated to 10000 item with no way to page), and is just a performance risk.
+ *
+ * @param string $container Resolved Swift container
+ * @param array $readGrps List of the possible criteria for a request to have
+ * access to read a container. Each item is one of the following formats:
+ * - account:user : Grants access if the request is by the given user
+ * - ".r:<regex>" : Grants access if the request is from a referrer host that
+ * matches the expression and the request is not for a listing.
+ * Setting this to '*' effectively makes a container public.
+ * -".rlistings:<regex>" : Grants access if the request is from a referrer host that
+ * matches the expression and the request is for a listing.
+ * @param array $writeGrps A list of the possible criteria for a request to have
+ * access to write to a container. Each item is of the following format:
+ * - account:user : Grants access if the request is by the given user
+ * @return Status
+ */
+ protected function setContainerAccess( $container, array $readGrps, array $writeGrps ) {
+ $status = Status::newGood();
+ $auth = $this->getAuthentication();
+
+ if ( !$auth ) {
+ $status->fatal( 'backend-fail-connect', $this->name );
+
+ return $status;
+ }
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'POST',
+ 'url' => $this->storageUrl( $auth, $container ),
+ 'headers' => $this->authTokenHeaders( $auth ) + [
+ 'x-container-read' => implode( ',', $readGrps ),
+ 'x-container-write' => implode( ',', $writeGrps )
+ ]
+ ] );
+
+ if ( $rcode != 204 && $rcode !== 202 ) {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ wfDebugLog( 'SwiftBackend', __METHOD__ . ': unexpected rcode value (' . $rcode . ')' );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get a Swift container stat array, possibly from process cache.
+ * Use $reCache if the file count or byte count is needed.
+ *
+ * @param string $container Container name
+ * @param bool $bypassCache Bypass all caches and load from Swift
+ * @return array|bool|null False on 404, null on failure
+ */
+ protected function getContainerStat( $container, $bypassCache = false ) {
+ $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" );
+
+ if ( $bypassCache ) { // purge cache
+ $this->containerStatCache->clear( $container );
+ } elseif ( !$this->containerStatCache->has( $container, 'stat' ) ) {
+ $this->primeContainerCache( [ $container ] ); // check persistent cache
+ }
+ if ( !$this->containerStatCache->has( $container, 'stat' ) ) {
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ return null;
+ }
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'HEAD',
+ 'url' => $this->storageUrl( $auth, $container ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ ] );
+
+ if ( $rcode === 204 ) {
+ $stat = [
+ 'count' => $rhdrs['x-container-object-count'],
+ 'bytes' => $rhdrs['x-container-bytes-used']
+ ];
+ if ( $bypassCache ) {
+ return $stat;
+ } else {
+ $this->containerStatCache->set( $container, 'stat', $stat ); // cache it
+ $this->setContainerCache( $container, $stat ); // update persistent cache
+ }
+ } elseif ( $rcode === 404 ) {
+ return false;
+ } else {
+ $this->onError( null, __METHOD__,
+ [ 'cont' => $container ], $rerr, $rcode, $rdesc );
+
+ return null;
+ }
+ }
+
+ return $this->containerStatCache->get( $container, 'stat' );
+ }
+
+ /**
+ * Create a Swift container
+ *
+ * @param string $container Container name
+ * @param array $params
+ * @return Status
+ */
+ protected function createContainer( $container, array $params ) {
+ $status = Status::newGood();
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ $status->fatal( 'backend-fail-connect', $this->name );
+
+ return $status;
+ }
+
+ // @see SwiftFileBackend::setContainerAccess()
+ if ( empty( $params['noAccess'] ) ) {
+ $readGrps = [ '.r:*', $this->swiftUser ]; // public
+ } else {
+ $readGrps = [ $this->swiftUser ]; // private
+ }
+ $writeGrps = [ $this->swiftUser ]; // sanity
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'PUT',
+ 'url' => $this->storageUrl( $auth, $container ),
+ 'headers' => $this->authTokenHeaders( $auth ) + [
+ 'x-container-read' => implode( ',', $readGrps ),
+ 'x-container-write' => implode( ',', $writeGrps )
+ ]
+ ] );
+
+ if ( $rcode === 201 ) { // new
+ // good
+ } elseif ( $rcode === 202 ) { // already there
+ // this shouldn't really happen, but is OK
+ } else {
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Delete a Swift container
+ *
+ * @param string $container Container name
+ * @param array $params
+ * @return Status
+ */
+ protected function deleteContainer( $container, array $params ) {
+ $status = Status::newGood();
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ $status->fatal( 'backend-fail-connect', $this->name );
+
+ return $status;
+ }
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'DELETE',
+ 'url' => $this->storageUrl( $auth, $container ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ ] );
+
+ if ( $rcode >= 200 && $rcode <= 299 ) { // deleted
+ $this->containerStatCache->clear( $container ); // purge
+ } elseif ( $rcode === 404 ) { // not there
+ // this shouldn't really happen, but is OK
+ } elseif ( $rcode === 409 ) { // not empty
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); // race?
+ } else {
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get a list of objects under a container.
+ * Either just the names or a list of stdClass objects with details can be returned.
+ *
+ * @param string $fullCont
+ * @param string $type ('info' for a list of object detail maps, 'names' for names only)
+ * @param int $limit
+ * @param string|null $after
+ * @param string|null $prefix
+ * @param string|null $delim
+ * @return Status With the list as value
+ */
+ private function objectListing(
+ $fullCont, $type, $limit, $after = null, $prefix = null, $delim = null
+ ) {
+ $status = Status::newGood();
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ $status->fatal( 'backend-fail-connect', $this->name );
+
+ return $status;
+ }
+
+ $query = [ 'limit' => $limit ];
+ if ( $type === 'info' ) {
+ $query['format'] = 'json';
+ }
+ if ( $after !== null ) {
+ $query['marker'] = $after;
+ }
+ if ( $prefix !== null ) {
+ $query['prefix'] = $prefix;
+ }
+ if ( $delim !== null ) {
+ $query['delimiter'] = $delim;
+ }
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'GET',
+ 'url' => $this->storageUrl( $auth, $fullCont ),
+ 'query' => $query,
+ 'headers' => $this->authTokenHeaders( $auth )
+ ] );
+
+ $params = [ 'cont' => $fullCont, 'prefix' => $prefix, 'delim' => $delim ];
+ if ( $rcode === 200 ) { // good
+ if ( $type === 'info' ) {
+ $status->value = FormatJson::decode( trim( $rbody ) );
+ } else {
+ $status->value = explode( "\n", trim( $rbody ) );
+ }
+ } elseif ( $rcode === 204 ) {
+ $status->value = []; // empty container
+ } elseif ( $rcode === 404 ) {
+ $status->value = []; // no container
+ } else {
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+
+ return $status;
+ }
+
+ protected function doPrimeContainerCache( array $containerInfo ) {
+ foreach ( $containerInfo as $container => $info ) {
+ $this->containerStatCache->set( $container, 'stat', $info );
+ }
+ }
+
+ protected function doGetFileStatMulti( array $params ) {
+ $stats = [];
+
+ $auth = $this->getAuthentication();
+
+ $reqs = [];
+ foreach ( $params['srcs'] as $path ) {
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path );
+ if ( $srcRel === null ) {
+ $stats[$path] = false;
+ continue; // invalid storage path
+ } elseif ( !$auth ) {
+ $stats[$path] = null;
+ continue;
+ }
+
+ // (a) Check the container
+ $cstat = $this->getContainerStat( $srcCont );
+ if ( $cstat === false ) {
+ $stats[$path] = false;
+ continue; // ok, nothing to do
+ } elseif ( !is_array( $cstat ) ) {
+ $stats[$path] = null;
+ continue;
+ }
+
+ $reqs[$path] = [
+ 'method' => 'HEAD',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth ) + $this->headersFromParams( $params )
+ ];
+ }
+
+ $opts = [ 'maxConnsPerHost' => $params['concurrency'] ];
+ $reqs = $this->http->runMulti( $reqs, $opts );
+
+ foreach ( $params['srcs'] as $path ) {
+ if ( array_key_exists( $path, $stats ) ) {
+ continue; // some sort of failure above
+ }
+ // (b) Check the file
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $reqs[$path]['response'];
+ if ( $rcode === 200 || $rcode === 204 ) {
+ // Update the object if it is missing some headers
+ $rhdrs = $this->addMissingMetadata( $rhdrs, $path );
+ // Load the stat array from the headers
+ $stat = $this->getStatFromHeaders( $rhdrs );
+ if ( $this->isRGW ) {
+ $stat['latest'] = true; // strong consistency
+ }
+ } elseif ( $rcode === 404 ) {
+ $stat = false;
+ } else {
+ $stat = null;
+ $this->onError( null, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+ $stats[$path] = $stat;
+ }
+
+ return $stats;
+ }
+
+ /**
+ * @param array $rhdrs
+ * @return array
+ */
+ protected function getStatFromHeaders( array $rhdrs ) {
+ // Fetch all of the custom metadata headers
+ $metadata = $this->getMetadata( $rhdrs );
+ // Fetch all of the custom raw HTTP headers
+ $headers = $this->sanitizeHdrs( [ 'headers' => $rhdrs ] );
+
+ return [
+ // Convert various random Swift dates to TS_MW
+ 'mtime' => $this->convertSwiftDate( $rhdrs['last-modified'], TS_MW ),
+ // Empty objects actually return no content-length header in Ceph
+ 'size' => isset( $rhdrs['content-length'] ) ? (int)$rhdrs['content-length'] : 0,
+ 'sha1' => isset( $metadata['sha1base36'] ) ? $metadata['sha1base36'] : null,
+ // Note: manifiest ETags are not an MD5 of the file
+ 'md5' => ctype_xdigit( $rhdrs['etag'] ) ? $rhdrs['etag'] : null,
+ 'xattr' => [ 'metadata' => $metadata, 'headers' => $headers ]
+ ];
+ }
+
+ /**
+ * @return array|null Credential map
+ */
+ protected function getAuthentication() {
+ if ( $this->authErrorTimestamp !== null ) {
+ if ( ( time() - $this->authErrorTimestamp ) < 60 ) {
+ return null; // failed last attempt; don't bother
+ } else { // actually retry this time
+ $this->authErrorTimestamp = null;
+ }
+ }
+ // Session keys expire after a while, so we renew them periodically
+ $reAuth = ( ( time() - $this->authSessionTimestamp ) > $this->authTTL );
+ // Authenticate with proxy and get a session key...
+ if ( !$this->authCreds || $reAuth ) {
+ $this->authSessionTimestamp = 0;
+ $cacheKey = $this->getCredsCacheKey( $this->swiftUser );
+ $creds = $this->srvCache->get( $cacheKey ); // credentials
+ // Try to use the credential cache
+ if ( isset( $creds['auth_token'] ) && isset( $creds['storage_url'] ) ) {
+ $this->authCreds = $creds;
+ // Skew the timestamp for worst case to avoid using stale credentials
+ $this->authSessionTimestamp = time() - ceil( $this->authTTL / 2 );
+ } else { // cache miss
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'GET',
+ 'url' => "{$this->swiftAuthUrl}/v1.0",
+ 'headers' => [
+ 'x-auth-user' => $this->swiftUser,
+ 'x-auth-key' => $this->swiftKey
+ ]
+ ] );
+
+ if ( $rcode >= 200 && $rcode <= 299 ) { // OK
+ $this->authCreds = [
+ 'auth_token' => $rhdrs['x-auth-token'],
+ 'storage_url' => $rhdrs['x-storage-url']
+ ];
+ $this->srvCache->set( $cacheKey, $this->authCreds, ceil( $this->authTTL / 2 ) );
+ $this->authSessionTimestamp = time();
+ } elseif ( $rcode === 401 ) {
+ $this->onError( null, __METHOD__, [], "Authentication failed.", $rcode );
+ $this->authErrorTimestamp = time();
+
+ return null;
+ } else {
+ $this->onError( null, __METHOD__, [], "HTTP return code: $rcode", $rcode );
+ $this->authErrorTimestamp = time();
+
+ return null;
+ }
+ }
+ // Ceph RGW does not use <account> in URLs (OpenStack Swift uses "/v1/<account>")
+ if ( substr( $this->authCreds['storage_url'], -3 ) === '/v1' ) {
+ $this->isRGW = true; // take advantage of strong consistency in Ceph
+ }
+ }
+
+ return $this->authCreds;
+ }
+
+ /**
+ * @param array $creds From getAuthentication()
+ * @param string $container
+ * @param string $object
+ * @return array
+ */
+ protected function storageUrl( array $creds, $container = null, $object = null ) {
+ $parts = [ $creds['storage_url'] ];
+ if ( strlen( $container ) ) {
+ $parts[] = rawurlencode( $container );
+ }
+ if ( strlen( $object ) ) {
+ $parts[] = str_replace( "%2F", "/", rawurlencode( $object ) );
+ }
+
+ return implode( '/', $parts );
+ }
+
+ /**
+ * @param array $creds From getAuthentication()
+ * @return array
+ */
+ protected function authTokenHeaders( array $creds ) {
+ return [ 'x-auth-token' => $creds['auth_token'] ];
+ }
+
+ /**
+ * Get the cache key for a container
+ *
+ * @param string $username
+ * @return string
+ */
+ private function getCredsCacheKey( $username ) {
+ return 'swiftcredentials:' . md5( $username . ':' . $this->swiftAuthUrl );
+ }
+
+ /**
+ * Log an unexpected exception for this backend.
+ * This also sets the Status object to have a fatal error.
+ *
+ * @param Status|null $status
+ * @param string $func
+ * @param array $params
+ * @param string $err Error string
+ * @param int $code HTTP status
+ * @param string $desc HTTP status description
+ */
+ public function onError( $status, $func, array $params, $err = '', $code = 0, $desc = '' ) {
+ if ( $status instanceof Status ) {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ }
+ if ( $code == 401 ) { // possibly a stale token
+ $this->srvCache->delete( $this->getCredsCacheKey( $this->swiftUser ) );
+ }
+ wfDebugLog( 'SwiftBackend',
+ "HTTP $code ($desc) in '{$func}' (given '" . FormatJson::encode( $params ) . "')" .
+ ( $err ? ": $err" : "" )
+ );
+ }
+}
+
+/**
+ * @see FileBackendStoreOpHandle
+ */
+class SwiftFileOpHandle extends FileBackendStoreOpHandle {
+ /** @var array List of Requests for MultiHttpClient */
+ public $httpOp;
+ /** @var Closure */
+ public $callback;
+
+ /**
+ * @param SwiftFileBackend $backend
+ * @param Closure $callback Function that takes (HTTP request array, status)
+ * @param array $httpOp MultiHttpClient op
+ */
+ public function __construct( SwiftFileBackend $backend, Closure $callback, array $httpOp ) {
+ $this->backend = $backend;
+ $this->callback = $callback;
+ $this->httpOp = $httpOp;
+ }
+}
+
+/**
+ * SwiftFileBackend helper class to page through listings.
+ * Swift also has a listing limit of 10,000 objects for sanity.
+ * Do not use this class from places outside SwiftFileBackend.
+ *
+ * @ingroup FileBackend
+ */
+abstract class SwiftFileBackendList implements Iterator {
+ /** @var array List of path or (path,stat array) entries */
+ protected $bufferIter = [];
+
+ /** @var string List items *after* this path */
+ protected $bufferAfter = null;
+
+ /** @var int */
+ protected $pos = 0;
+
+ /** @var array */
+ protected $params = [];
+
+ /** @var SwiftFileBackend */
+ protected $backend;
+
+ /** @var string Container name */
+ protected $container;
+
+ /** @var string Storage directory */
+ protected $dir;
+
+ /** @var int */
+ protected $suffixStart;
+
+ const PAGE_SIZE = 9000; // file listing buffer size
+
+ /**
+ * @param SwiftFileBackend $backend
+ * @param string $fullCont Resolved container name
+ * @param string $dir Resolved directory relative to container
+ * @param array $params
+ */
+ public function __construct( SwiftFileBackend $backend, $fullCont, $dir, array $params ) {
+ $this->backend = $backend;
+ $this->container = $fullCont;
+ $this->dir = $dir;
+ if ( substr( $this->dir, -1 ) === '/' ) {
+ $this->dir = substr( $this->dir, 0, -1 ); // remove trailing slash
+ }
+ if ( $this->dir == '' ) { // whole container
+ $this->suffixStart = 0;
+ } else { // dir within container
+ $this->suffixStart = strlen( $this->dir ) + 1; // size of "path/to/dir/"
+ }
+ $this->params = $params;
+ }
+
+ /**
+ * @see Iterator::key()
+ * @return int
+ */
+ public function key() {
+ return $this->pos;
+ }
+
+ /**
+ * @see Iterator::next()
+ */
+ public function next() {
+ // Advance to the next file in the page
+ next( $this->bufferIter );
+ ++$this->pos;
+ // Check if there are no files left in this page and
+ // advance to the next page if this page was not empty.
+ if ( !$this->valid() && count( $this->bufferIter ) ) {
+ $this->bufferIter = $this->pageFromList(
+ $this->container, $this->dir, $this->bufferAfter, self::PAGE_SIZE, $this->params
+ ); // updates $this->bufferAfter
+ }
+ }
+
+ /**
+ * @see Iterator::rewind()
+ */
+ public function rewind() {
+ $this->pos = 0;
+ $this->bufferAfter = null;
+ $this->bufferIter = $this->pageFromList(
+ $this->container, $this->dir, $this->bufferAfter, self::PAGE_SIZE, $this->params
+ ); // updates $this->bufferAfter
+ }
+
+ /**
+ * @see Iterator::valid()
+ * @return bool
+ */
+ public function valid() {
+ if ( $this->bufferIter === null ) {
+ return false; // some failure?
+ } else {
+ return ( current( $this->bufferIter ) !== false ); // no paths can have this value
+ }
+ }
+
+ /**
+ * Get the given list portion (page)
+ *
+ * @param string $container Resolved container name
+ * @param string $dir Resolved path relative to container
+ * @param string $after
+ * @param int $limit
+ * @param array $params
+ * @return Traversable|array
+ */
+ abstract protected function pageFromList( $container, $dir, &$after, $limit, array $params );
+}
+
+/**
+ * Iterator for listing directories
+ */
+class SwiftFileBackendDirList extends SwiftFileBackendList {
+ /**
+ * @see Iterator::current()
+ * @return string|bool String (relative path) or false
+ */
+ public function current() {
+ return substr( current( $this->bufferIter ), $this->suffixStart, -1 );
+ }
+
+ protected function pageFromList( $container, $dir, &$after, $limit, array $params ) {
+ return $this->backend->getDirListPageInternal( $container, $dir, $after, $limit, $params );
+ }
+}
+
+/**
+ * Iterator for listing regular files
+ */
+class SwiftFileBackendFileList extends SwiftFileBackendList {
+ /**
+ * @see Iterator::current()
+ * @return string|bool String (relative path) or false
+ */
+ public function current() {
+ list( $path, $stat ) = current( $this->bufferIter );
+ $relPath = substr( $path, $this->suffixStart );
+ if ( is_array( $stat ) ) {
+ $storageDir = rtrim( $this->params['dir'], '/' );
+ $this->backend->loadListingStatInternal( "$storageDir/$relPath", $stat );
+ }
+
+ return $relPath;
+ }
+
+ protected function pageFromList( $container, $dir, &$after, $limit, array $params ) {
+ return $this->backend->getFileListPageInternal( $container, $dir, $after, $limit, $params );
+ }
+}
diff --git a/www/wiki/includes/filebackend/TempFSFile.php b/www/wiki/includes/filebackend/TempFSFile.php
new file mode 100644
index 00000000..f5728408
--- /dev/null
+++ b/www/wiki/includes/filebackend/TempFSFile.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * Location holder of files stored temporarily
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * This class is used to hold the location and do limited manipulation
+ * of files stored temporarily (this will be whatever wfTempDir() returns)
+ *
+ * @ingroup FileBackend
+ */
+class TempFSFile extends FSFile {
+ /** @var bool Garbage collect the temp file */
+ protected $canDelete = false;
+
+ /** @var array Map of (path => 1) for paths to delete on shutdown */
+ protected static $pathsCollect = null;
+
+ public function __construct( $path ) {
+ parent::__construct( $path );
+
+ if ( self::$pathsCollect === null ) {
+ self::$pathsCollect = [];
+ register_shutdown_function( [ __CLASS__, 'purgeAllOnShutdown' ] );
+ }
+ }
+
+ /**
+ * Make a new temporary file on the file system.
+ * Temporary files may be purged when the file object falls out of scope.
+ *
+ * @param string $prefix
+ * @param string $extension
+ * @return TempFSFile|null
+ */
+ public static function factory( $prefix, $extension = '' ) {
+ $ext = ( $extension != '' ) ? ".{$extension}" : '';
+
+ $attempts = 5;
+ while ( $attempts-- ) {
+ $path = wfTempDir() . '/' . $prefix . wfRandomString( 12 ) . $ext;
+ MediaWiki\suppressWarnings();
+ $newFileHandle = fopen( $path, 'x' );
+ MediaWiki\restoreWarnings();
+ if ( $newFileHandle ) {
+ fclose( $newFileHandle );
+ $tmpFile = new self( $path );
+ $tmpFile->autocollect();
+ // Safely instantiated, end loop.
+ return $tmpFile;
+ }
+ }
+
+ // Give up
+ return null;
+ }
+
+ /**
+ * Purge this file off the file system
+ *
+ * @return bool Success
+ */
+ public function purge() {
+ $this->canDelete = false; // done
+ MediaWiki\suppressWarnings();
+ $ok = unlink( $this->path );
+ MediaWiki\restoreWarnings();
+
+ unset( self::$pathsCollect[$this->path] );
+
+ return $ok;
+ }
+
+ /**
+ * Clean up the temporary file only after an object goes out of scope
+ *
+ * @param object $object
+ * @return TempFSFile This object
+ */
+ public function bind( $object ) {
+ if ( is_object( $object ) ) {
+ if ( !isset( $object->tempFSFileReferences ) ) {
+ // Init first since $object might use __get() and return only a copy variable
+ $object->tempFSFileReferences = [];
+ }
+ $object->tempFSFileReferences[] = $this;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set flag to not clean up after the temporary file
+ *
+ * @return TempFSFile This object
+ */
+ public function preserve() {
+ $this->canDelete = false;
+
+ unset( self::$pathsCollect[$this->path] );
+
+ return $this;
+ }
+
+ /**
+ * Set flag clean up after the temporary file
+ *
+ * @return TempFSFile This object
+ */
+ public function autocollect() {
+ $this->canDelete = true;
+
+ self::$pathsCollect[$this->path] = 1;
+
+ return $this;
+ }
+
+ /**
+ * Try to make sure that all files are purged on error
+ *
+ * This method should only be called internally
+ */
+ public static function purgeAllOnShutdown() {
+ foreach ( self::$pathsCollect as $path ) {
+ MediaWiki\suppressWarnings();
+ unlink( $path );
+ MediaWiki\restoreWarnings();
+ }
+ }
+
+ /**
+ * Cleans up after the temporary file by deleting it
+ */
+ function __destruct() {
+ if ( $this->canDelete ) {
+ $this->purge();
+ }
+ }
+}
diff --git a/www/wiki/includes/filebackend/filejournal/DBFileJournal.php b/www/wiki/includes/filebackend/filejournal/DBFileJournal.php
new file mode 100644
index 00000000..4269f91e
--- /dev/null
+++ b/www/wiki/includes/filebackend/filejournal/DBFileJournal.php
@@ -0,0 +1,193 @@
+<?php
+/**
+ * Version of FileJournal that logs to a DB table.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileJournal
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * Version of FileJournal that logs to a DB table
+ * @since 1.20
+ */
+class DBFileJournal extends FileJournal {
+ /** @var IDatabase */
+ protected $dbw;
+
+ protected $wiki = false; // string; wiki DB name
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Includes:
+ * 'wiki' : wiki name to use for LoadBalancer
+ */
+ protected function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->wiki = $config['wiki'];
+ }
+
+ /**
+ * @see FileJournal::logChangeBatch()
+ * @param array $entries
+ * @param string $batchId
+ * @return StatusValue
+ */
+ protected function doLogChangeBatch( array $entries, $batchId ) {
+ $status = StatusValue::newGood();
+
+ try {
+ $dbw = $this->getMasterDB();
+ } catch ( DBError $e ) {
+ $status->fatal( 'filejournal-fail-dbconnect', $this->backend );
+
+ return $status;
+ }
+
+ $now = wfTimestamp( TS_UNIX );
+
+ $data = [];
+ foreach ( $entries as $entry ) {
+ $data[] = [
+ 'fj_batch_uuid' => $batchId,
+ 'fj_backend' => $this->backend,
+ 'fj_op' => $entry['op'],
+ 'fj_path' => $entry['path'],
+ 'fj_new_sha1' => $entry['newSha1'],
+ 'fj_timestamp' => $dbw->timestamp( $now )
+ ];
+ }
+
+ try {
+ $dbw->insert( 'filejournal', $data, __METHOD__ );
+ if ( mt_rand( 0, 99 ) == 0 ) {
+ $this->purgeOldLogs(); // occasionally delete old logs
+ }
+ } catch ( DBError $e ) {
+ $status->fatal( 'filejournal-fail-dbquery', $this->backend );
+
+ return $status;
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileJournal::doGetCurrentPosition()
+ * @return bool|mixed The value from the field, or false on failure.
+ */
+ protected function doGetCurrentPosition() {
+ $dbw = $this->getMasterDB();
+
+ return $dbw->selectField( 'filejournal', 'MAX(fj_id)',
+ [ 'fj_backend' => $this->backend ],
+ __METHOD__
+ );
+ }
+
+ /**
+ * @see FileJournal::doGetPositionAtTime()
+ * @param int|string $time Timestamp
+ * @return bool|mixed The value from the field, or false on failure.
+ */
+ protected function doGetPositionAtTime( $time ) {
+ $dbw = $this->getMasterDB();
+
+ $encTimestamp = $dbw->addQuotes( $dbw->timestamp( $time ) );
+
+ return $dbw->selectField( 'filejournal', 'fj_id',
+ [ 'fj_backend' => $this->backend, "fj_timestamp <= $encTimestamp" ],
+ __METHOD__,
+ [ 'ORDER BY' => 'fj_timestamp DESC' ]
+ );
+ }
+
+ /**
+ * @see FileJournal::doGetChangeEntries()
+ * @param int $start
+ * @param int $limit
+ * @return array
+ */
+ protected function doGetChangeEntries( $start, $limit ) {
+ $dbw = $this->getMasterDB();
+
+ $res = $dbw->select( 'filejournal', '*',
+ [
+ 'fj_backend' => $this->backend,
+ 'fj_id >= ' . $dbw->addQuotes( (int)$start ) ], // $start may be 0
+ __METHOD__,
+ array_merge( [ 'ORDER BY' => 'fj_id ASC' ],
+ $limit ? [ 'LIMIT' => $limit ] : [] )
+ );
+
+ $entries = [];
+ foreach ( $res as $row ) {
+ $item = [];
+ foreach ( (array)$row as $key => $value ) {
+ $item[substr( $key, 3 )] = $value; // "fj_op" => "op"
+ }
+ $entries[] = $item;
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @see FileJournal::purgeOldLogs()
+ * @return StatusValue
+ * @throws DBError
+ */
+ protected function doPurgeOldLogs() {
+ $status = StatusValue::newGood();
+ if ( $this->ttlDays <= 0 ) {
+ return $status; // nothing to do
+ }
+
+ $dbw = $this->getMasterDB();
+ $dbCutoff = $dbw->timestamp( time() - 86400 * $this->ttlDays );
+
+ $dbw->delete( 'filejournal',
+ [ 'fj_timestamp < ' . $dbw->addQuotes( $dbCutoff ) ],
+ __METHOD__
+ );
+
+ return $status;
+ }
+
+ /**
+ * Get a master connection to the logging DB
+ *
+ * @return IDatabase
+ * @throws DBError
+ */
+ protected function getMasterDB() {
+ if ( !$this->dbw ) {
+ // Get a separate connection in autocommit mode
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->newMainLB();
+ $this->dbw = $lb->getConnection( DB_MASTER, [], $this->wiki );
+ $this->dbw->clearFlag( DBO_TRX );
+ }
+
+ return $this->dbw;
+ }
+}
diff --git a/www/wiki/includes/filebackend/filejournal/FileJournal.php b/www/wiki/includes/filebackend/filejournal/FileJournal.php
new file mode 100644
index 00000000..b84e1959
--- /dev/null
+++ b/www/wiki/includes/filebackend/filejournal/FileJournal.php
@@ -0,0 +1,251 @@
+<?php
+/**
+ * @defgroup FileJournal File journal
+ * @ingroup FileBackend
+ */
+
+/**
+ * File operation journaling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileJournal
+ * @author Aaron Schulz
+ */
+
+/**
+ * @brief Class for handling file operation journaling.
+ *
+ * Subclasses should avoid throwing exceptions at all costs.
+ *
+ * @ingroup FileJournal
+ * @since 1.20
+ */
+abstract class FileJournal {
+ /** @var string */
+ protected $backend;
+
+ /** @var int */
+ protected $ttlDays;
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Includes:
+ * 'ttlDays' : days to keep log entries around (false means "forever")
+ */
+ protected function __construct( array $config ) {
+ $this->ttlDays = isset( $config['ttlDays'] ) ? $config['ttlDays'] : false;
+ }
+
+ /**
+ * Create an appropriate FileJournal object from config
+ *
+ * @param array $config
+ * @param string $backend A registered file backend name
+ * @throws Exception
+ * @return FileJournal
+ */
+ final public static function factory( array $config, $backend ) {
+ $class = $config['class'];
+ $jrn = new $class( $config );
+ if ( !$jrn instanceof self ) {
+ throw new Exception( "Class given is not an instance of FileJournal." );
+ }
+ $jrn->backend = $backend;
+
+ return $jrn;
+ }
+
+ /**
+ * Get a statistically unique ID string
+ *
+ * @return string <9 char TS_MW timestamp in base 36><22 random base 36 chars>
+ */
+ final public function getTimestampedUUID() {
+ $s = '';
+ for ( $i = 0; $i < 5; $i++ ) {
+ $s .= mt_rand( 0, 2147483647 );
+ }
+ $s = Wikimedia\base_convert( sha1( $s ), 16, 36, 31 );
+
+ return substr( Wikimedia\base_convert( wfTimestamp( TS_MW ), 10, 36, 9 ) . $s, 0, 31 );
+ }
+
+ /**
+ * Log changes made by a batch file operation.
+ *
+ * @param array $entries List of file operations (each an array of parameters) which contain:
+ * op : Basic operation name (create, update, delete)
+ * path : The storage path of the file
+ * newSha1 : The final base 36 SHA-1 of the file
+ * Note that 'false' should be used as the SHA-1 for non-existing files.
+ * @param string $batchId UUID string that identifies the operation batch
+ * @return Status
+ */
+ final public function logChangeBatch( array $entries, $batchId ) {
+ if ( !count( $entries ) ) {
+ return Status::newGood();
+ }
+
+ return $this->doLogChangeBatch( $entries, $batchId );
+ }
+
+ /**
+ * @see FileJournal::logChangeBatch()
+ *
+ * @param array $entries List of file operations (each an array of parameters)
+ * @param string $batchId UUID string that identifies the operation batch
+ * @return Status
+ */
+ abstract protected function doLogChangeBatch( array $entries, $batchId );
+
+ /**
+ * Get the position ID of the latest journal entry
+ *
+ * @return int|bool
+ */
+ final public function getCurrentPosition() {
+ return $this->doGetCurrentPosition();
+ }
+
+ /**
+ * @see FileJournal::getCurrentPosition()
+ * @return int|bool
+ */
+ abstract protected function doGetCurrentPosition();
+
+ /**
+ * Get the position ID of the latest journal entry at some point in time
+ *
+ * @param int|string $time Timestamp
+ * @return int|bool
+ */
+ final public function getPositionAtTime( $time ) {
+ return $this->doGetPositionAtTime( $time );
+ }
+
+ /**
+ * @see FileJournal::getPositionAtTime()
+ * @param int|string $time Timestamp
+ * @return int|bool
+ */
+ abstract protected function doGetPositionAtTime( $time );
+
+ /**
+ * Get an array of file change log entries.
+ * A starting change ID and/or limit can be specified.
+ *
+ * @param int $start Starting change ID or null
+ * @param int $limit Maximum number of items to return
+ * @param string &$next Updated to the ID of the next entry.
+ * @return array List of associative arrays, each having:
+ * id : unique, monotonic, ID for this change
+ * batch_uuid : UUID for an operation batch
+ * backend : the backend name
+ * op : primitive operation (create,update,delete,null)
+ * path : affected storage path
+ * new_sha1 : base 36 sha1 of the new file had the operation succeeded
+ * timestamp : TS_MW timestamp of the batch change
+ * Also, $next is updated to the ID of the next entry.
+ */
+ final public function getChangeEntries( $start = null, $limit = 0, &$next = null ) {
+ $entries = $this->doGetChangeEntries( $start, $limit ? $limit + 1 : 0 );
+ if ( $limit && count( $entries ) > $limit ) {
+ $last = array_pop( $entries ); // remove the extra entry
+ $next = $last['id']; // update for next call
+ } else {
+ $next = null; // end of list
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @see FileJournal::getChangeEntries()
+ * @param int $start
+ * @param int $limit
+ * @return array
+ */
+ abstract protected function doGetChangeEntries( $start, $limit );
+
+ /**
+ * Purge any old log entries
+ *
+ * @return Status
+ */
+ final public function purgeOldLogs() {
+ return $this->doPurgeOldLogs();
+ }
+
+ /**
+ * @see FileJournal::purgeOldLogs()
+ * @return Status
+ */
+ abstract protected function doPurgeOldLogs();
+}
+
+/**
+ * Simple version of FileJournal that does nothing
+ * @since 1.20
+ */
+class NullFileJournal extends FileJournal {
+ /**
+ * @see FileJournal::doLogChangeBatch()
+ * @param array $entries
+ * @param string $batchId
+ * @return Status
+ */
+ protected function doLogChangeBatch( array $entries, $batchId ) {
+ return Status::newGood();
+ }
+
+ /**
+ * @see FileJournal::doGetCurrentPosition()
+ * @return int|bool
+ */
+ protected function doGetCurrentPosition() {
+ return false;
+ }
+
+ /**
+ * @see FileJournal::doGetPositionAtTime()
+ * @param int|string $time Timestamp
+ * @return int|bool
+ */
+ protected function doGetPositionAtTime( $time ) {
+ return false;
+ }
+
+ /**
+ * @see FileJournal::doGetChangeEntries()
+ * @param int $start
+ * @param int $limit
+ * @return array
+ */
+ protected function doGetChangeEntries( $start, $limit ) {
+ return [];
+ }
+
+ /**
+ * @see FileJournal::doPurgeOldLogs()
+ * @return Status
+ */
+ protected function doPurgeOldLogs() {
+ return Status::newGood();
+ }
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/DBLockManager.php b/www/wiki/includes/filebackend/lockmanager/DBLockManager.php
new file mode 100644
index 00000000..f4410cad
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/DBLockManager.php
@@ -0,0 +1,433 @@
+<?php
+/**
+ * Version of LockManager based on using DB table locks.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Version of LockManager based on using named/row DB locks.
+ *
+ * This is meant for multi-wiki systems that may share files.
+ *
+ * All lock requests for a resource, identified by a hash string, will map
+ * to one bucket. Each bucket maps to one or several peer DBs, each on their
+ * own server, all having the filelocks.sql tables (with row-level locking).
+ * A majority of peer DBs must agree for a lock to be acquired.
+ *
+ * Caching is used to avoid hitting servers that are down.
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+abstract class DBLockManager extends QuorumLockManager {
+ /** @var array Map of DB names to server config */
+ protected $dbServers; // (DB name => server config array)
+ /** @var BagOStuff */
+ protected $statusCache;
+
+ protected $lockExpiry; // integer number of seconds
+ protected $safeDelay; // integer number of seconds
+
+ protected $session = 0; // random integer
+ /** @var array Map Database connections (DB name => Database) */
+ protected $conns = [];
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Parameters include:
+ * - dbServers : Associative array of DB names to server configuration.
+ * Configuration is an associative array that includes:
+ * - host : DB server name
+ * - dbname : DB name
+ * - type : DB type (mysql,postgres,...)
+ * - user : DB user
+ * - password : DB user password
+ * - tablePrefix : DB table prefix
+ * - flags : DB flags (see DatabaseBase)
+ * - dbsByBucket : Array of 1-16 consecutive integer keys, starting from 0,
+ * each having an odd-numbered list of DB names (peers) as values.
+ * Any DB named 'localDBMaster' will automatically use the DB master
+ * settings for this wiki (without the need for a dbServers entry).
+ * Only use 'localDBMaster' if the domain is a valid wiki ID.
+ * - lockExpiry : Lock timeout (seconds) for dropped connections. [optional]
+ * This tells the DB server how long to wait before assuming
+ * connection failure and releasing all the locks for a session.
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->dbServers = isset( $config['dbServers'] )
+ ? $config['dbServers']
+ : []; // likely just using 'localDBMaster'
+ // Sanitize srvsByBucket config to prevent PHP errors
+ $this->srvsByBucket = array_filter( $config['dbsByBucket'], 'is_array' );
+ $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive
+
+ if ( isset( $config['lockExpiry'] ) ) {
+ $this->lockExpiry = $config['lockExpiry'];
+ } else {
+ $met = ini_get( 'max_execution_time' );
+ $this->lockExpiry = $met ? $met : 60; // use some sane amount if 0
+ }
+ $this->safeDelay = ( $this->lockExpiry <= 0 )
+ ? 60 // pick a safe-ish number to match DB timeout default
+ : $this->lockExpiry; // cover worst case
+
+ foreach ( $this->srvsByBucket as $bucket ) {
+ if ( count( $bucket ) > 1 ) { // multiple peers
+ // Tracks peers that couldn't be queried recently to avoid lengthy
+ // connection timeouts. This is useless if each bucket has one peer.
+ $this->statusCache = ObjectCache::getLocalServerInstance();
+ break;
+ }
+ }
+
+ $this->session = wfRandomString( 31 );
+ }
+
+ // @todo change this code to work in one batch
+ protected function getLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = Status::newGood();
+ foreach ( $pathsByType as $type => $paths ) {
+ $status->merge( $this->doGetLocksOnServer( $lockSrv, $paths, $type ) );
+ }
+
+ return $status;
+ }
+
+ protected function freeLocksOnServer( $lockSrv, array $pathsByType ) {
+ return Status::newGood();
+ }
+
+ /**
+ * @see QuorumLockManager::isServerUp()
+ * @param string $lockSrv
+ * @return bool
+ */
+ protected function isServerUp( $lockSrv ) {
+ if ( !$this->cacheCheckFailures( $lockSrv ) ) {
+ return false; // recent failure to connect
+ }
+ try {
+ $this->getConnection( $lockSrv );
+ } catch ( DBError $e ) {
+ $this->cacheRecordFailure( $lockSrv );
+
+ return false; // failed to connect
+ }
+
+ return true;
+ }
+
+ /**
+ * Get (or reuse) a connection to a lock DB
+ *
+ * @param string $lockDb
+ * @return IDatabase
+ * @throws DBError
+ */
+ protected function getConnection( $lockDb ) {
+ if ( !isset( $this->conns[$lockDb] ) ) {
+ $db = null;
+ if ( $lockDb === 'localDBMaster' ) {
+ $lb = wfGetLBFactory()->getMainLB( $this->domain );
+ $db = $lb->getConnection( DB_MASTER, [], $this->domain );
+ } elseif ( isset( $this->dbServers[$lockDb] ) ) {
+ $config = $this->dbServers[$lockDb];
+ $db = DatabaseBase::factory( $config['type'], $config );
+ }
+ if ( !$db ) {
+ return null; // config error?
+ }
+ $this->conns[$lockDb] = $db;
+ $this->conns[$lockDb]->clearFlag( DBO_TRX );
+ # If the connection drops, try to avoid letting the DB rollback
+ # and release the locks before the file operations are finished.
+ # This won't handle the case of DB server restarts however.
+ $options = [];
+ if ( $this->lockExpiry > 0 ) {
+ $options['connTimeout'] = $this->lockExpiry;
+ }
+ $this->conns[$lockDb]->setSessionOptions( $options );
+ $this->initConnection( $lockDb, $this->conns[$lockDb] );
+ }
+ if ( !$this->conns[$lockDb]->trxLevel() ) {
+ $this->conns[$lockDb]->begin( __METHOD__ ); // start transaction
+ }
+
+ return $this->conns[$lockDb];
+ }
+
+ /**
+ * Do additional initialization for new lock DB connection
+ *
+ * @param string $lockDb
+ * @param IDatabase $db
+ * @throws DBError
+ */
+ protected function initConnection( $lockDb, IDatabase $db ) {
+ }
+
+ /**
+ * Checks if the DB has not recently had connection/query errors.
+ * This just avoids wasting time on doomed connection attempts.
+ *
+ * @param string $lockDb
+ * @return bool
+ */
+ protected function cacheCheckFailures( $lockDb ) {
+ return ( $this->statusCache && $this->safeDelay > 0 )
+ ? !$this->statusCache->get( $this->getMissKey( $lockDb ) )
+ : true;
+ }
+
+ /**
+ * Log a lock request failure to the cache
+ *
+ * @param string $lockDb
+ * @return bool Success
+ */
+ protected function cacheRecordFailure( $lockDb ) {
+ return ( $this->statusCache && $this->safeDelay > 0 )
+ ? $this->statusCache->set( $this->getMissKey( $lockDb ), 1, $this->safeDelay )
+ : true;
+ }
+
+ /**
+ * Get a cache key for recent query misses for a DB
+ *
+ * @param string $lockDb
+ * @return string
+ */
+ protected function getMissKey( $lockDb ) {
+ $lockDb = ( $lockDb === 'localDBMaster' ) ? wfWikiID() : $lockDb; // non-relative
+ return 'dblockmanager:downservers:' . str_replace( ' ', '_', $lockDb );
+ }
+
+ /**
+ * Make sure remaining locks get cleared for sanity
+ */
+ function __destruct() {
+ $this->releaseAllLocks();
+ foreach ( $this->conns as $db ) {
+ $db->close();
+ }
+ }
+}
+
+/**
+ * MySQL version of DBLockManager that supports shared locks.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * @ingroup LockManager
+ */
+class MySqlLockManager extends DBLockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ /**
+ * @param string $lockDb
+ * @param IDatabase $db
+ */
+ protected function initConnection( $lockDb, IDatabase $db ) {
+ # Let this transaction see lock rows from other transactions
+ $db->query( "SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;" );
+ }
+
+ /**
+ * Get a connection to a lock DB and acquire locks on $paths.
+ * This does not use GET_LOCK() per http://bugs.mysql.com/bug.php?id=1118.
+ *
+ * @see DBLockManager::getLocksOnServer()
+ * @param string $lockSrv
+ * @param array $paths
+ * @param string $type
+ * @return Status
+ */
+ protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
+ $status = Status::newGood();
+
+ $db = $this->getConnection( $lockSrv ); // checked in isServerUp()
+
+ $keys = []; // list of hash keys for the paths
+ $data = []; // list of rows to insert
+ $checkEXKeys = []; // list of hash keys that this has no EX lock on
+ # Build up values for INSERT clause
+ foreach ( $paths as $path ) {
+ $key = $this->sha1Base36Absolute( $path );
+ $keys[] = $key;
+ $data[] = [ 'fls_key' => $key, 'fls_session' => $this->session ];
+ if ( !isset( $this->locksHeld[$path][self::LOCK_EX] ) ) {
+ $checkEXKeys[] = $key;
+ }
+ }
+
+ # Block new writers (both EX and SH locks leave entries here)...
+ $db->insert( 'filelocks_shared', $data, __METHOD__, [ 'IGNORE' ] );
+ # Actually do the locking queries...
+ if ( $type == self::LOCK_SH ) { // reader locks
+ $blocked = false;
+ # Bail if there are any existing writers...
+ if ( count( $checkEXKeys ) ) {
+ $blocked = $db->selectField( 'filelocks_exclusive', '1',
+ [ 'fle_key' => $checkEXKeys ],
+ __METHOD__
+ );
+ }
+ # Other prospective writers that haven't yet updated filelocks_exclusive
+ # will recheck filelocks_shared after doing so and bail due to this entry.
+ } else { // writer locks
+ $encSession = $db->addQuotes( $this->session );
+ # Bail if there are any existing writers...
+ # This may detect readers, but the safe check for them is below.
+ # Note: if two writers come at the same time, both bail :)
+ $blocked = $db->selectField( 'filelocks_shared', '1',
+ [ 'fls_key' => $keys, "fls_session != $encSession" ],
+ __METHOD__
+ );
+ if ( !$blocked ) {
+ # Build up values for INSERT clause
+ $data = [];
+ foreach ( $keys as $key ) {
+ $data[] = [ 'fle_key' => $key ];
+ }
+ # Block new readers/writers...
+ $db->insert( 'filelocks_exclusive', $data, __METHOD__ );
+ # Bail if there are any existing readers...
+ $blocked = $db->selectField( 'filelocks_shared', '1',
+ [ 'fls_key' => $keys, "fls_session != $encSession" ],
+ __METHOD__
+ );
+ }
+ }
+
+ if ( $blocked ) {
+ foreach ( $paths as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see QuorumLockManager::releaseAllLocks()
+ * @return Status
+ */
+ protected function releaseAllLocks() {
+ $status = Status::newGood();
+
+ foreach ( $this->conns as $lockDb => $db ) {
+ if ( $db->trxLevel() ) { // in transaction
+ try {
+ $db->rollback( __METHOD__ ); // finish transaction and kill any rows
+ } catch ( DBError $e ) {
+ $status->fatal( 'lockmanager-fail-db-release', $lockDb );
+ }
+ }
+ }
+
+ return $status;
+ }
+}
+
+/**
+ * PostgreSQL version of DBLockManager that supports shared locks.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * @ingroup LockManager
+ */
+class PostgreSqlLockManager extends DBLockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
+ $status = Status::newGood();
+ if ( !count( $paths ) ) {
+ return $status; // nothing to lock
+ }
+
+ $db = $this->getConnection( $lockSrv ); // checked in isServerUp()
+ $bigints = array_unique( array_map(
+ function ( $key ) {
+ return Wikimedia\base_convert( substr( $key, 0, 15 ), 16, 10 );
+ },
+ array_map( [ $this, 'sha1Base16Absolute' ], $paths )
+ ) );
+
+ // Try to acquire all the locks...
+ $fields = [];
+ foreach ( $bigints as $bigint ) {
+ $fields[] = ( $type == self::LOCK_SH )
+ ? "pg_try_advisory_lock_shared({$db->addQuotes( $bigint )}) AS K$bigint"
+ : "pg_try_advisory_lock({$db->addQuotes( $bigint )}) AS K$bigint";
+ }
+ $res = $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ );
+ $row = $res->fetchRow();
+
+ if ( in_array( 'f', $row ) ) {
+ // Release any acquired locks if some could not be acquired...
+ $fields = [];
+ foreach ( $row as $kbigint => $ok ) {
+ if ( $ok === 't' ) { // locked
+ $bigint = substr( $kbigint, 1 ); // strip off the "K"
+ $fields[] = ( $type == self::LOCK_SH )
+ ? "pg_advisory_unlock_shared({$db->addQuotes( $bigint )})"
+ : "pg_advisory_unlock({$db->addQuotes( $bigint )})";
+ }
+ }
+ if ( count( $fields ) ) {
+ $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ );
+ }
+ foreach ( $paths as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see QuorumLockManager::releaseAllLocks()
+ * @return Status
+ */
+ protected function releaseAllLocks() {
+ $status = Status::newGood();
+
+ foreach ( $this->conns as $lockDb => $db ) {
+ try {
+ $db->query( "SELECT pg_advisory_unlock_all()", __METHOD__ );
+ } catch ( DBError $e ) {
+ $status->fatal( 'lockmanager-fail-db-release', $lockDb );
+ }
+ }
+
+ return $status;
+ }
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/FSLockManager.php b/www/wiki/includes/filebackend/lockmanager/FSLockManager.php
new file mode 100644
index 00000000..2b660ec7
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/FSLockManager.php
@@ -0,0 +1,248 @@
+<?php
+/**
+ * Simple version of LockManager based on using FS lock files.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Simple version of LockManager based on using FS lock files.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * This should work fine for small sites running off one server.
+ * Do not use this with 'lockDirectory' set to an NFS mount unless the
+ * NFS client is at least version 2.6.12. Otherwise, the BSD flock()
+ * locks will be ignored; see http://nfs.sourceforge.net/#section_d.
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+class FSLockManager extends LockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ protected $lockDir; // global dir for all servers
+
+ /** @var array Map of (locked key => lock file handle) */
+ protected $handles = [];
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Includes:
+ * - lockDirectory : Directory containing the lock files
+ */
+ function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->lockDir = $config['lockDirectory'];
+ }
+
+ /**
+ * @see LockManager::doLock()
+ * @param array $paths
+ * @param int $type
+ * @return Status
+ */
+ protected function doLock( array $paths, $type ) {
+ $status = Status::newGood();
+
+ $lockedPaths = []; // files locked in this attempt
+ foreach ( $paths as $path ) {
+ $status->merge( $this->doSingleLock( $path, $type ) );
+ if ( $status->isOK() ) {
+ $lockedPaths[] = $path;
+ } else {
+ // Abort and unlock everything
+ $status->merge( $this->doUnlock( $lockedPaths, $type ) );
+
+ return $status;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see LockManager::doUnlock()
+ * @param array $paths
+ * @param int $type
+ * @return Status
+ */
+ protected function doUnlock( array $paths, $type ) {
+ $status = Status::newGood();
+
+ foreach ( $paths as $path ) {
+ $status->merge( $this->doSingleUnlock( $path, $type ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Lock a single resource key
+ *
+ * @param string $path
+ * @param int $type
+ * @return Status
+ */
+ protected function doSingleLock( $path, $type ) {
+ $status = Status::newGood();
+
+ if ( isset( $this->locksHeld[$path][$type] ) ) {
+ ++$this->locksHeld[$path][$type];
+ } elseif ( isset( $this->locksHeld[$path][self::LOCK_EX] ) ) {
+ $this->locksHeld[$path][$type] = 1;
+ } else {
+ if ( isset( $this->handles[$path] ) ) {
+ $handle = $this->handles[$path];
+ } else {
+ MediaWiki\suppressWarnings();
+ $handle = fopen( $this->getLockPath( $path ), 'a+' );
+ MediaWiki\restoreWarnings();
+ if ( !$handle ) { // lock dir missing?
+ wfMkdirParents( $this->lockDir );
+ $handle = fopen( $this->getLockPath( $path ), 'a+' ); // try again
+ }
+ }
+ if ( $handle ) {
+ // Either a shared or exclusive lock
+ $lock = ( $type == self::LOCK_SH ) ? LOCK_SH : LOCK_EX;
+ if ( flock( $handle, $lock | LOCK_NB ) ) {
+ // Record this lock as active
+ $this->locksHeld[$path][$type] = 1;
+ $this->handles[$path] = $handle;
+ } else {
+ fclose( $handle );
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ } else {
+ $status->fatal( 'lockmanager-fail-openlock', $path );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Unlock a single resource key
+ *
+ * @param string $path
+ * @param int $type
+ * @return Status
+ */
+ protected function doSingleUnlock( $path, $type ) {
+ $status = Status::newGood();
+
+ if ( !isset( $this->locksHeld[$path] ) ) {
+ $status->warning( 'lockmanager-notlocked', $path );
+ } elseif ( !isset( $this->locksHeld[$path][$type] ) ) {
+ $status->warning( 'lockmanager-notlocked', $path );
+ } else {
+ $handlesToClose = [];
+ --$this->locksHeld[$path][$type];
+ if ( $this->locksHeld[$path][$type] <= 0 ) {
+ unset( $this->locksHeld[$path][$type] );
+ }
+ if ( !count( $this->locksHeld[$path] ) ) {
+ unset( $this->locksHeld[$path] ); // no locks on this path
+ if ( isset( $this->handles[$path] ) ) {
+ $handlesToClose[] = $this->handles[$path];
+ unset( $this->handles[$path] );
+ }
+ }
+ // Unlock handles to release locks and delete
+ // any lock files that end up with no locks on them...
+ if ( wfIsWindows() ) {
+ // Windows: for any process, including this one,
+ // calling unlink() on a locked file will fail
+ $status->merge( $this->closeLockHandles( $path, $handlesToClose ) );
+ $status->merge( $this->pruneKeyLockFiles( $path ) );
+ } else {
+ // Unix: unlink() can be used on files currently open by this
+ // process and we must do so in order to avoid race conditions
+ $status->merge( $this->pruneKeyLockFiles( $path ) );
+ $status->merge( $this->closeLockHandles( $path, $handlesToClose ) );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param string $path
+ * @param array $handlesToClose
+ * @return Status
+ */
+ private function closeLockHandles( $path, array $handlesToClose ) {
+ $status = Status::newGood();
+ foreach ( $handlesToClose as $handle ) {
+ if ( !flock( $handle, LOCK_UN ) ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+ if ( !fclose( $handle ) ) {
+ $status->warning( 'lockmanager-fail-closelock', $path );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param string $path
+ * @return Status
+ */
+ private function pruneKeyLockFiles( $path ) {
+ $status = Status::newGood();
+ if ( !isset( $this->locksHeld[$path] ) ) {
+ # No locks are held for the lock file anymore
+ if ( !unlink( $this->getLockPath( $path ) ) ) {
+ $status->warning( 'lockmanager-fail-deletelock', $path );
+ }
+ unset( $this->handles[$path] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get the path to the lock file for a key
+ * @param string $path
+ * @return string
+ */
+ protected function getLockPath( $path ) {
+ return "{$this->lockDir}/{$this->sha1Base36Absolute( $path )}.lock";
+ }
+
+ /**
+ * Make sure remaining locks get cleared for sanity
+ */
+ function __destruct() {
+ while ( count( $this->locksHeld ) ) {
+ foreach ( $this->locksHeld as $path => $locks ) {
+ $this->doSingleUnlock( $path, self::LOCK_EX );
+ $this->doSingleUnlock( $path, self::LOCK_SH );
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/LockManager.php b/www/wiki/includes/filebackend/lockmanager/LockManager.php
new file mode 100644
index 00000000..567a2989
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/LockManager.php
@@ -0,0 +1,258 @@
+<?php
+/**
+ * @defgroup LockManager Lock management
+ * @ingroup FileBackend
+ */
+
+/**
+ * Resource locking handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ * @author Aaron Schulz
+ */
+
+/**
+ * @brief Class for handling resource locking.
+ *
+ * Locks on resource keys can either be shared or exclusive.
+ *
+ * Implementations must keep track of what is locked by this proccess
+ * in-memory and support nested locking calls (using reference counting).
+ * At least LOCK_UW and LOCK_EX must be implemented. LOCK_SH can be a no-op.
+ * Locks should either be non-blocking or have low wait timeouts.
+ *
+ * Subclasses should avoid throwing exceptions at all costs.
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+abstract class LockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_EX, // subclasses may use self::LOCK_SH
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ /** @var array Map of (resource path => lock type => count) */
+ protected $locksHeld = [];
+
+ protected $domain; // string; domain (usually wiki ID)
+ protected $lockTTL; // integer; maximum time locks can be held
+
+ /** Lock types; stronger locks have higher values */
+ const LOCK_SH = 1; // shared lock (for reads)
+ const LOCK_UW = 2; // shared lock (for reads used to write elsewhere)
+ const LOCK_EX = 3; // exclusive lock (for writes)
+
+ /**
+ * Construct a new instance from configuration
+ *
+ * @param array $config Parameters include:
+ * - domain : Domain (usually wiki ID) that all resources are relative to [optional]
+ * - lockTTL : Age (in seconds) at which resource locks should expire.
+ * This only applies if locks are not tied to a connection/process.
+ */
+ public function __construct( array $config ) {
+ $this->domain = isset( $config['domain'] ) ? $config['domain'] : wfWikiID();
+ if ( isset( $config['lockTTL'] ) ) {
+ $this->lockTTL = max( 5, $config['lockTTL'] );
+ } elseif ( PHP_SAPI === 'cli' ) {
+ $this->lockTTL = 3600;
+ } else {
+ $met = ini_get( 'max_execution_time' ); // this is 0 in CLI mode
+ $this->lockTTL = max( 5 * 60, 2 * (int)$met );
+ }
+ }
+
+ /**
+ * Lock the resources at the given abstract paths
+ *
+ * @param array $paths List of resource names
+ * @param int $type LockManager::LOCK_* constant
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
+ * @return Status
+ */
+ final public function lock( array $paths, $type = self::LOCK_EX, $timeout = 0 ) {
+ return $this->lockByType( [ $type => $paths ], $timeout );
+ }
+
+ /**
+ * Lock the resources at the given abstract paths
+ *
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
+ * @return Status
+ * @since 1.22
+ */
+ final public function lockByType( array $pathsByType, $timeout = 0 ) {
+ $pathsByType = $this->normalizePathsByType( $pathsByType );
+ $msleep = [ 0, 50, 100, 300, 500 ]; // retry backoff times
+ $start = microtime( true );
+ do {
+ $status = $this->doLockByType( $pathsByType );
+ $elapsed = microtime( true ) - $start;
+ if ( $status->isOK() || $elapsed >= $timeout || $elapsed < 0 ) {
+ break; // success, timeout, or clock set back
+ }
+ usleep( 1e3 * ( next( $msleep ) ?: 1000 ) ); // use 1 sec after enough times
+ $elapsed = microtime( true ) - $start;
+ } while ( $elapsed < $timeout && $elapsed >= 0 );
+
+ return $status;
+ }
+
+ /**
+ * Unlock the resources at the given abstract paths
+ *
+ * @param array $paths List of paths
+ * @param int $type LockManager::LOCK_* constant
+ * @return Status
+ */
+ final public function unlock( array $paths, $type = self::LOCK_EX ) {
+ return $this->unlockByType( [ $type => $paths ] );
+ }
+
+ /**
+ * Unlock the resources at the given abstract paths
+ *
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return Status
+ * @since 1.22
+ */
+ final public function unlockByType( array $pathsByType ) {
+ $pathsByType = $this->normalizePathsByType( $pathsByType );
+ $status = $this->doUnlockByType( $pathsByType );
+
+ return $status;
+ }
+
+ /**
+ * Get the base 36 SHA-1 of a string, padded to 31 digits.
+ * Before hashing, the path will be prefixed with the domain ID.
+ * This should be used interally for lock key or file names.
+ *
+ * @param string $path
+ * @return string
+ */
+ final protected function sha1Base36Absolute( $path ) {
+ return Wikimedia\base_convert( sha1( "{$this->domain}:{$path}" ), 16, 36, 31 );
+ }
+
+ /**
+ * Get the base 16 SHA-1 of a string, padded to 31 digits.
+ * Before hashing, the path will be prefixed with the domain ID.
+ * This should be used interally for lock key or file names.
+ *
+ * @param string $path
+ * @return string
+ */
+ final protected function sha1Base16Absolute( $path ) {
+ return sha1( "{$this->domain}:{$path}" );
+ }
+
+ /**
+ * Normalize the $paths array by converting LOCK_UW locks into the
+ * appropriate type and removing any duplicated paths for each lock type.
+ *
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return array
+ * @since 1.22
+ */
+ final protected function normalizePathsByType( array $pathsByType ) {
+ $res = [];
+ foreach ( $pathsByType as $type => $paths ) {
+ $res[$this->lockTypeMap[$type]] = array_unique( $paths );
+ }
+
+ return $res;
+ }
+
+ /**
+ * @see LockManager::lockByType()
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return Status
+ * @since 1.22
+ */
+ protected function doLockByType( array $pathsByType ) {
+ $status = Status::newGood();
+ $lockedByType = []; // map of (type => paths)
+ foreach ( $pathsByType as $type => $paths ) {
+ $status->merge( $this->doLock( $paths, $type ) );
+ if ( $status->isOK() ) {
+ $lockedByType[$type] = $paths;
+ } else {
+ // Release the subset of locks that were acquired
+ foreach ( $lockedByType as $lType => $lPaths ) {
+ $status->merge( $this->doUnlock( $lPaths, $lType ) );
+ }
+ break;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Lock resources with the given keys and lock type
+ *
+ * @param array $paths List of paths
+ * @param int $type LockManager::LOCK_* constant
+ * @return Status
+ */
+ abstract protected function doLock( array $paths, $type );
+
+ /**
+ * @see LockManager::unlockByType()
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return Status
+ * @since 1.22
+ */
+ protected function doUnlockByType( array $pathsByType ) {
+ $status = Status::newGood();
+ foreach ( $pathsByType as $type => $paths ) {
+ $status->merge( $this->doUnlock( $paths, $type ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Unlock resources with the given keys and lock type
+ *
+ * @param array $paths List of paths
+ * @param int $type LockManager::LOCK_* constant
+ * @return Status
+ */
+ abstract protected function doUnlock( array $paths, $type );
+}
+
+/**
+ * Simple version of LockManager that does nothing
+ * @since 1.19
+ */
+class NullLockManager extends LockManager {
+ protected function doLock( array $paths, $type ) {
+ return Status::newGood();
+ }
+
+ protected function doUnlock( array $paths, $type ) {
+ return Status::newGood();
+ }
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/LockManagerGroup.php b/www/wiki/includes/filebackend/lockmanager/LockManagerGroup.php
new file mode 100644
index 00000000..e6f992c3
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/LockManagerGroup.php
@@ -0,0 +1,176 @@
+<?php
+/**
+ * Lock manager registration handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Class to handle file lock manager registration
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+class LockManagerGroup {
+ /** @var LockManagerGroup[] (domain => LockManagerGroup) */
+ protected static $instances = [];
+
+ protected $domain; // string; domain (usually wiki ID)
+
+ /** @var array Array of (name => ('class' => ..., 'config' => ..., 'instance' => ...)) */
+ protected $managers = [];
+
+ /**
+ * @param string $domain Domain (usually wiki ID)
+ */
+ protected function __construct( $domain ) {
+ $this->domain = $domain;
+ }
+
+ /**
+ * @param bool|string $domain Domain (usually wiki ID). Default: false.
+ * @return LockManagerGroup
+ */
+ public static function singleton( $domain = false ) {
+ $domain = ( $domain === false ) ? wfWikiID() : $domain;
+ if ( !isset( self::$instances[$domain] ) ) {
+ self::$instances[$domain] = new self( $domain );
+ self::$instances[$domain]->initFromGlobals();
+ }
+
+ return self::$instances[$domain];
+ }
+
+ /**
+ * Destroy the singleton instances
+ */
+ public static function destroySingletons() {
+ self::$instances = [];
+ }
+
+ /**
+ * Register lock managers from the global variables
+ */
+ protected function initFromGlobals() {
+ global $wgLockManagers;
+
+ $this->register( $wgLockManagers );
+ }
+
+ /**
+ * Register an array of file lock manager configurations
+ *
+ * @param array $configs
+ * @throws Exception
+ */
+ protected function register( array $configs ) {
+ foreach ( $configs as $config ) {
+ $config['domain'] = $this->domain;
+ if ( !isset( $config['name'] ) ) {
+ throw new Exception( "Cannot register a lock manager with no name." );
+ }
+ $name = $config['name'];
+ if ( !isset( $config['class'] ) ) {
+ throw new Exception( "Cannot register lock manager `{$name}` with no class." );
+ }
+ $class = $config['class'];
+ unset( $config['class'] ); // lock manager won't need this
+ $this->managers[$name] = [
+ 'class' => $class,
+ 'config' => $config,
+ 'instance' => null
+ ];
+ }
+ }
+
+ /**
+ * Get the lock manager object with a given name
+ *
+ * @param string $name
+ * @return LockManager
+ * @throws Exception
+ */
+ public function get( $name ) {
+ if ( !isset( $this->managers[$name] ) ) {
+ throw new Exception( "No lock manager defined with the name `$name`." );
+ }
+ // Lazy-load the actual lock manager instance
+ if ( !isset( $this->managers[$name]['instance'] ) ) {
+ $class = $this->managers[$name]['class'];
+ $config = $this->managers[$name]['config'];
+ if ( $class === 'DBLockManager' ) {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lb = $lbFactory->newMainLB( $config['domain'] );
+ $dbw = $lb->getLazyConnectionRef( DB_MASTER, [], $config['domain'] );
+
+ $config['dbServers']['localDBMaster'] = $dbw;
+ $config['srvCache'] = ObjectCache::getLocalServerInstance( 'hash' );
+ }
+ $config['logger'] = LoggerFactory::getInstance( 'LockManager' );
+
+ $this->managers[$name]['instance'] = new $class( $config );
+ }
+
+ return $this->managers[$name]['instance'];
+ }
+
+ /**
+ * Get the config array for a lock manager object with a given name
+ *
+ * @param string $name
+ * @return array
+ * @throws Exception
+ */
+ public function config( $name ) {
+ if ( !isset( $this->managers[$name] ) ) {
+ throw new Exception( "No lock manager defined with the name `$name`." );
+ }
+ $class = $this->managers[$name]['class'];
+
+ return [ 'class' => $class ] + $this->managers[$name]['config'];
+ }
+
+ /**
+ * Get the default lock manager configured for the site.
+ * Returns NullLockManager if no lock manager could be found.
+ *
+ * @return LockManager
+ */
+ public function getDefault() {
+ return isset( $this->managers['default'] )
+ ? $this->get( 'default' )
+ : new NullLockManager( [] );
+ }
+
+ /**
+ * Get the default lock manager configured for the site
+ * or at least some other effective configured lock manager.
+ * Throws an exception if no lock manager could be found.
+ *
+ * @return LockManager
+ * @throws Exception
+ */
+ public function getAny() {
+ return isset( $this->managers['default'] )
+ ? $this->get( 'default' )
+ : $this->get( 'fsLockManager' );
+ }
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/MemcLockManager.php b/www/wiki/includes/filebackend/lockmanager/MemcLockManager.php
new file mode 100644
index 00000000..cb5266ac
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/MemcLockManager.php
@@ -0,0 +1,384 @@
+<?php
+/**
+ * Version of LockManager based on using memcached servers.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Manage locks using memcached servers.
+ *
+ * Version of LockManager based on using memcached servers.
+ * This is meant for multi-wiki systems that may share files.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * All lock requests for a resource, identified by a hash string, will map to one
+ * bucket. Each bucket maps to one or several peer servers, each running memcached.
+ * A majority of peers must agree for a lock to be acquired.
+ *
+ * @ingroup LockManager
+ * @since 1.20
+ */
+class MemcLockManager extends QuorumLockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ /** @var array Map server names to MemcachedBagOStuff objects */
+ protected $bagOStuffs = [];
+
+ /** @var array (server name => bool) */
+ protected $serversUp = [];
+
+ /** @var string Random UUID */
+ protected $session = '';
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Parameters include:
+ * - lockServers : Associative array of server names to "<IP>:<port>" strings.
+ * - srvsByBucket : Array of 1-16 consecutive integer keys, starting from 0,
+ * each having an odd-numbered list of server names (peers) as values.
+ * - memcConfig : Configuration array for ObjectCache::newFromParams. [optional]
+ * If set, this must use one of the memcached classes.
+ * @throws Exception
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ // Sanitize srvsByBucket config to prevent PHP errors
+ $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' );
+ $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive
+
+ $memcConfig = isset( $config['memcConfig'] )
+ ? $config['memcConfig']
+ : [ 'class' => 'MemcachedPhpBagOStuff' ];
+
+ foreach ( $config['lockServers'] as $name => $address ) {
+ $params = [ 'servers' => [ $address ] ] + $memcConfig;
+ $cache = ObjectCache::newFromParams( $params );
+ if ( $cache instanceof MemcachedBagOStuff ) {
+ $this->bagOStuffs[$name] = $cache;
+ } else {
+ throw new Exception(
+ 'Only MemcachedBagOStuff classes are supported by MemcLockManager.' );
+ }
+ }
+
+ $this->session = wfRandomString( 32 );
+ }
+
+ // @todo Change this code to work in one batch
+ protected function getLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = Status::newGood();
+
+ $lockedPaths = [];
+ foreach ( $pathsByType as $type => $paths ) {
+ $status->merge( $this->doGetLocksOnServer( $lockSrv, $paths, $type ) );
+ if ( $status->isOK() ) {
+ $lockedPaths[$type] = isset( $lockedPaths[$type] )
+ ? array_merge( $lockedPaths[$type], $paths )
+ : $paths;
+ } else {
+ foreach ( $lockedPaths as $lType => $lPaths ) {
+ $status->merge( $this->doFreeLocksOnServer( $lockSrv, $lPaths, $lType ) );
+ }
+ break;
+ }
+ }
+
+ return $status;
+ }
+
+ // @todo Change this code to work in one batch
+ protected function freeLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = Status::newGood();
+
+ foreach ( $pathsByType as $type => $paths ) {
+ $status->merge( $this->doFreeLocksOnServer( $lockSrv, $paths, $type ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see QuorumLockManager::getLocksOnServer()
+ * @param string $lockSrv
+ * @param array $paths
+ * @param string $type
+ * @return Status
+ */
+ protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
+ $status = Status::newGood();
+
+ $memc = $this->getCache( $lockSrv );
+ $keys = array_map( [ $this, 'recordKeyForPath' ], $paths ); // lock records
+
+ // Lock all of the active lock record keys...
+ if ( !$this->acquireMutexes( $memc, $keys ) ) {
+ foreach ( $paths as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+
+ return $status;
+ }
+
+ // Fetch all the existing lock records...
+ $lockRecords = $memc->getMulti( $keys );
+
+ $now = time();
+ // Check if the requested locks conflict with existing ones...
+ foreach ( $paths as $path ) {
+ $locksKey = $this->recordKeyForPath( $path );
+ $locksHeld = isset( $lockRecords[$locksKey] )
+ ? self::sanitizeLockArray( $lockRecords[$locksKey] )
+ : self::newLockArray(); // init
+ foreach ( $locksHeld[self::LOCK_EX] as $session => $expiry ) {
+ if ( $expiry < $now ) { // stale?
+ unset( $locksHeld[self::LOCK_EX][$session] );
+ } elseif ( $session !== $this->session ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ }
+ if ( $type === self::LOCK_EX ) {
+ foreach ( $locksHeld[self::LOCK_SH] as $session => $expiry ) {
+ if ( $expiry < $now ) { // stale?
+ unset( $locksHeld[self::LOCK_SH][$session] );
+ } elseif ( $session !== $this->session ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ }
+ }
+ if ( $status->isOK() ) {
+ // Register the session in the lock record array
+ $locksHeld[$type][$this->session] = $now + $this->lockTTL;
+ // We will update this record if none of the other locks conflict
+ $lockRecords[$locksKey] = $locksHeld;
+ }
+ }
+
+ // If there were no lock conflicts, update all the lock records...
+ if ( $status->isOK() ) {
+ foreach ( $paths as $path ) {
+ $locksKey = $this->recordKeyForPath( $path );
+ $locksHeld = $lockRecords[$locksKey];
+ $ok = $memc->set( $locksKey, $locksHeld, 7 * 86400 );
+ if ( !$ok ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ } else {
+ wfDebug( __METHOD__ . ": acquired lock on key $locksKey.\n" );
+ }
+ }
+ }
+
+ // Unlock all of the active lock record keys...
+ $this->releaseMutexes( $memc, $keys );
+
+ return $status;
+ }
+
+ /**
+ * @see QuorumLockManager::freeLocksOnServer()
+ * @param string $lockSrv
+ * @param array $paths
+ * @param string $type
+ * @return Status
+ */
+ protected function doFreeLocksOnServer( $lockSrv, array $paths, $type ) {
+ $status = Status::newGood();
+
+ $memc = $this->getCache( $lockSrv );
+ $keys = array_map( [ $this, 'recordKeyForPath' ], $paths ); // lock records
+
+ // Lock all of the active lock record keys...
+ if ( !$this->acquireMutexes( $memc, $keys ) ) {
+ foreach ( $paths as $path ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+
+ return $status;
+ }
+
+ // Fetch all the existing lock records...
+ $lockRecords = $memc->getMulti( $keys );
+
+ // Remove the requested locks from all records...
+ foreach ( $paths as $path ) {
+ $locksKey = $this->recordKeyForPath( $path ); // lock record
+ if ( !isset( $lockRecords[$locksKey] ) ) {
+ $status->warning( 'lockmanager-fail-releaselock', $path );
+ continue; // nothing to do
+ }
+ $locksHeld = self::sanitizeLockArray( $lockRecords[$locksKey] );
+ if ( isset( $locksHeld[$type][$this->session] ) ) {
+ unset( $locksHeld[$type][$this->session] ); // unregister this session
+ if ( $locksHeld === self::newLockArray() ) {
+ $ok = $memc->delete( $locksKey );
+ } else {
+ $ok = $memc->set( $locksKey, $locksHeld );
+ }
+ if ( !$ok ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+ } else {
+ $status->warning( 'lockmanager-fail-releaselock', $path );
+ }
+ wfDebug( __METHOD__ . ": released lock on key $locksKey.\n" );
+ }
+
+ // Unlock all of the active lock record keys...
+ $this->releaseMutexes( $memc, $keys );
+
+ return $status;
+ }
+
+ /**
+ * @see QuorumLockManager::releaseAllLocks()
+ * @return Status
+ */
+ protected function releaseAllLocks() {
+ return Status::newGood(); // not supported
+ }
+
+ /**
+ * @see QuorumLockManager::isServerUp()
+ * @param string $lockSrv
+ * @return bool
+ */
+ protected function isServerUp( $lockSrv ) {
+ return (bool)$this->getCache( $lockSrv );
+ }
+
+ /**
+ * Get the MemcachedBagOStuff object for a $lockSrv
+ *
+ * @param string $lockSrv Server name
+ * @return MemcachedBagOStuff|null
+ */
+ protected function getCache( $lockSrv ) {
+ $memc = null;
+ if ( isset( $this->bagOStuffs[$lockSrv] ) ) {
+ $memc = $this->bagOStuffs[$lockSrv];
+ if ( !isset( $this->serversUp[$lockSrv] ) ) {
+ $this->serversUp[$lockSrv] = $memc->set( __CLASS__ . ':ping', 1, 1 );
+ if ( !$this->serversUp[$lockSrv] ) {
+ trigger_error( __METHOD__ . ": Could not contact $lockSrv.", E_USER_WARNING );
+ }
+ }
+ if ( !$this->serversUp[$lockSrv] ) {
+ return null; // server appears to be down
+ }
+ }
+
+ return $memc;
+ }
+
+ /**
+ * @param string $path
+ * @return string
+ */
+ protected function recordKeyForPath( $path ) {
+ return implode( ':', [ __CLASS__, 'locks', $this->sha1Base36Absolute( $path ) ] );
+ }
+
+ /**
+ * @return array An empty lock structure for a key
+ */
+ protected static function newLockArray() {
+ return [ self::LOCK_SH => [], self::LOCK_EX => [] ];
+ }
+
+ /**
+ * @param array $a
+ * @return array An empty lock structure for a key
+ */
+ protected static function sanitizeLockArray( $a ) {
+ if ( is_array( $a ) && isset( $a[self::LOCK_EX] ) && isset( $a[self::LOCK_SH] ) ) {
+ return $a;
+ } else {
+ trigger_error( __METHOD__ . ": reset invalid lock array.", E_USER_WARNING );
+
+ return self::newLockArray();
+ }
+ }
+
+ /**
+ * @param MemcachedBagOStuff $memc
+ * @param array $keys List of keys to acquire
+ * @return bool
+ */
+ protected function acquireMutexes( MemcachedBagOStuff $memc, array $keys ) {
+ $lockedKeys = [];
+
+ // Acquire the keys in lexicographical order, to avoid deadlock problems.
+ // If P1 is waiting to acquire a key P2 has, P2 can't also be waiting for a key P1 has.
+ sort( $keys );
+
+ // Try to quickly loop to acquire the keys, but back off after a few rounds.
+ // This reduces memcached spam, especially in the rare case where a server acquires
+ // some lock keys and dies without releasing them. Lock keys expire after a few minutes.
+ $rounds = 0;
+ $start = microtime( true );
+ do {
+ if ( ( ++$rounds % 4 ) == 0 ) {
+ usleep( 1000 * 50 ); // 50 ms
+ }
+ foreach ( array_diff( $keys, $lockedKeys ) as $key ) {
+ if ( $memc->add( "$key:mutex", 1, 180 ) ) { // lock record
+ $lockedKeys[] = $key;
+ } else {
+ continue; // acquire in order
+ }
+ }
+ } while ( count( $lockedKeys ) < count( $keys ) && ( microtime( true ) - $start ) <= 3 );
+
+ if ( count( $lockedKeys ) != count( $keys ) ) {
+ $this->releaseMutexes( $memc, $lockedKeys ); // failed; release what was locked
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param MemcachedBagOStuff $memc
+ * @param array $keys List of acquired keys
+ */
+ protected function releaseMutexes( MemcachedBagOStuff $memc, array $keys ) {
+ foreach ( $keys as $key ) {
+ $memc->delete( "$key:mutex" );
+ }
+ }
+
+ /**
+ * Make sure remaining locks get cleared for sanity
+ */
+ function __destruct() {
+ while ( count( $this->locksHeld ) ) {
+ foreach ( $this->locksHeld as $path => $locks ) {
+ $this->doUnlock( [ $path ], self::LOCK_EX );
+ $this->doUnlock( [ $path ], self::LOCK_SH );
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/MySqlLockManager.php b/www/wiki/includes/filebackend/lockmanager/MySqlLockManager.php
new file mode 100644
index 00000000..2108aed4
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/MySqlLockManager.php
@@ -0,0 +1,141 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * MySQL version of DBLockManager that supports shared locks.
+ *
+ * Do NOT use this on connection handles that are also being used for anything
+ * else as the transaction isolation will be wrong and all the other changes will
+ * get rolled back when the locks release!
+ *
+ * All lock servers must have the innodb table defined in maintenance/locking/filelocks.sql.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * @ingroup LockManager
+ */
+class MySqlLockManager extends DBLockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->session = substr( $this->session, 0, 31 ); // fit to field
+ }
+
+ protected function initConnection( $lockDb, IDatabase $db ) {
+ # Let this transaction see lock rows from other transactions
+ $db->query( "SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;" );
+ # Do everything in a transaction as it all gets rolled back eventually
+ $db->startAtomic( __CLASS__ );
+ }
+
+ /**
+ * Get a connection to a lock DB and acquire locks on $paths.
+ * This does not use GET_LOCK() per https://bugs.mysql.com/bug.php?id=1118.
+ *
+ * @see DBLockManager::getLocksOnServer()
+ * @param string $lockSrv
+ * @param array $paths
+ * @param string $type
+ * @return StatusValue
+ */
+ protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
+ $status = StatusValue::newGood();
+
+ $db = $this->getConnection( $lockSrv ); // checked in isServerUp()
+
+ $keys = []; // list of hash keys for the paths
+ $data = []; // list of rows to insert
+ $checkEXKeys = []; // list of hash keys that this has no EX lock on
+ # Build up values for INSERT clause
+ foreach ( $paths as $path ) {
+ $key = $this->sha1Base36Absolute( $path );
+ $keys[] = $key;
+ $data[] = [ 'fls_key' => $key, 'fls_session' => $this->session ];
+ if ( !isset( $this->locksHeld[$path][self::LOCK_EX] ) ) {
+ $checkEXKeys[] = $key; // this has no EX lock on $key itself
+ }
+ }
+
+ # Block new writers (both EX and SH locks leave entries here)...
+ $db->insert( 'filelocks_shared', $data, __METHOD__, [ 'IGNORE' ] );
+ # Actually do the locking queries...
+ if ( $type == self::LOCK_SH ) { // reader locks
+ # Bail if there are any existing writers...
+ if ( count( $checkEXKeys ) ) {
+ $blocked = $db->selectField(
+ 'filelocks_exclusive',
+ '1',
+ [ 'fle_key' => $checkEXKeys ],
+ __METHOD__
+ );
+ } else {
+ $blocked = false;
+ }
+ # Other prospective writers that haven't yet updated filelocks_exclusive
+ # will recheck filelocks_shared after doing so and bail due to this entry.
+ } else { // writer locks
+ $encSession = $db->addQuotes( $this->session );
+ # Bail if there are any existing writers...
+ # This may detect readers, but the safe check for them is below.
+ # Note: if two writers come at the same time, both bail :)
+ $blocked = $db->selectField(
+ 'filelocks_shared',
+ '1',
+ [ 'fls_key' => $keys, "fls_session != $encSession" ],
+ __METHOD__
+ );
+ if ( !$blocked ) {
+ # Build up values for INSERT clause
+ $data = [];
+ foreach ( $keys as $key ) {
+ $data[] = [ 'fle_key' => $key ];
+ }
+ # Block new readers/writers...
+ $db->insert( 'filelocks_exclusive', $data, __METHOD__ );
+ # Bail if there are any existing readers...
+ $blocked = $db->selectField(
+ 'filelocks_shared',
+ '1',
+ [ 'fls_key' => $keys, "fls_session != $encSession" ],
+ __METHOD__
+ );
+ }
+ }
+
+ if ( $blocked ) {
+ foreach ( $paths as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see QuorumLockManager::releaseAllLocks()
+ * @return StatusValue
+ */
+ protected function releaseAllLocks() {
+ $status = StatusValue::newGood();
+
+ foreach ( $this->conns as $lockDb => $db ) {
+ if ( $db->trxLevel() ) { // in transaction
+ try {
+ $db->rollback( __METHOD__ ); // finish transaction and kill any rows
+ } catch ( DBError $e ) {
+ $status->fatal( 'lockmanager-fail-db-release', $lockDb );
+ }
+ }
+ }
+
+ return $status;
+ }
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/QuorumLockManager.php b/www/wiki/includes/filebackend/lockmanager/QuorumLockManager.php
new file mode 100644
index 00000000..108b8465
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/QuorumLockManager.php
@@ -0,0 +1,248 @@
+<?php
+/**
+ * Version of LockManager that uses a quorum from peer servers for locks.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Version of LockManager that uses a quorum from peer servers for locks.
+ * The resource space can also be sharded into separate peer groups.
+ *
+ * @ingroup LockManager
+ * @since 1.20
+ */
+abstract class QuorumLockManager extends LockManager {
+ /** @var array Map of bucket indexes to peer server lists */
+ protected $srvsByBucket = []; // (bucket index => (lsrv1, lsrv2, ...))
+
+ /** @var array Map of degraded buckets */
+ protected $degradedBuckets = []; // (buckey index => UNIX timestamp)
+
+ final protected function doLock( array $paths, $type ) {
+ return $this->doLockByType( [ $type => $paths ] );
+ }
+
+ final protected function doUnlock( array $paths, $type ) {
+ return $this->doUnlockByType( [ $type => $paths ] );
+ }
+
+ protected function doLockByType( array $pathsByType ) {
+ $status = Status::newGood();
+
+ $pathsToLock = []; // (bucket => type => paths)
+ // Get locks that need to be acquired (buckets => locks)...
+ foreach ( $pathsByType as $type => $paths ) {
+ foreach ( $paths as $path ) {
+ if ( isset( $this->locksHeld[$path][$type] ) ) {
+ ++$this->locksHeld[$path][$type];
+ } else {
+ $bucket = $this->getBucketFromPath( $path );
+ $pathsToLock[$bucket][$type][] = $path;
+ }
+ }
+ }
+
+ $lockedPaths = []; // files locked in this attempt (type => paths)
+ // Attempt to acquire these locks...
+ foreach ( $pathsToLock as $bucket => $pathsToLockByType ) {
+ // Try to acquire the locks for this bucket
+ $status->merge( $this->doLockingRequestBucket( $bucket, $pathsToLockByType ) );
+ if ( !$status->isOK() ) {
+ $status->merge( $this->doUnlockByType( $lockedPaths ) );
+
+ return $status;
+ }
+ // Record these locks as active
+ foreach ( $pathsToLockByType as $type => $paths ) {
+ foreach ( $paths as $path ) {
+ $this->locksHeld[$path][$type] = 1; // locked
+ // Keep track of what locks were made in this attempt
+ $lockedPaths[$type][] = $path;
+ }
+ }
+ }
+
+ return $status;
+ }
+
+ protected function doUnlockByType( array $pathsByType ) {
+ $status = Status::newGood();
+
+ $pathsToUnlock = []; // (bucket => type => paths)
+ foreach ( $pathsByType as $type => $paths ) {
+ foreach ( $paths as $path ) {
+ if ( !isset( $this->locksHeld[$path][$type] ) ) {
+ $status->warning( 'lockmanager-notlocked', $path );
+ } else {
+ --$this->locksHeld[$path][$type];
+ // Reference count the locks held and release locks when zero
+ if ( $this->locksHeld[$path][$type] <= 0 ) {
+ unset( $this->locksHeld[$path][$type] );
+ $bucket = $this->getBucketFromPath( $path );
+ $pathsToUnlock[$bucket][$type][] = $path;
+ }
+ if ( !count( $this->locksHeld[$path] ) ) {
+ unset( $this->locksHeld[$path] ); // no SH or EX locks left for key
+ }
+ }
+ }
+ }
+
+ // Remove these specific locks if possible, or at least release
+ // all locks once this process is currently not holding any locks.
+ foreach ( $pathsToUnlock as $bucket => $pathsToUnlockByType ) {
+ $status->merge( $this->doUnlockingRequestBucket( $bucket, $pathsToUnlockByType ) );
+ }
+ if ( !count( $this->locksHeld ) ) {
+ $status->merge( $this->releaseAllLocks() );
+ $this->degradedBuckets = []; // safe to retry the normal quorum
+ }
+
+ return $status;
+ }
+
+ /**
+ * Attempt to acquire locks with the peers for a bucket.
+ * This is all or nothing; if any key is locked then this totally fails.
+ *
+ * @param int $bucket
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return Status
+ */
+ final protected function doLockingRequestBucket( $bucket, array $pathsByType ) {
+ $status = Status::newGood();
+
+ $yesVotes = 0; // locks made on trustable servers
+ $votesLeft = count( $this->srvsByBucket[$bucket] ); // remaining peers
+ $quorum = floor( $votesLeft / 2 + 1 ); // simple majority
+ // Get votes for each peer, in order, until we have enough...
+ foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) {
+ if ( !$this->isServerUp( $lockSrv ) ) {
+ --$votesLeft;
+ $status->warning( 'lockmanager-fail-svr-acquire', $lockSrv );
+ $this->degradedBuckets[$bucket] = time();
+ continue; // server down?
+ }
+ // Attempt to acquire the lock on this peer
+ $status->merge( $this->getLocksOnServer( $lockSrv, $pathsByType ) );
+ if ( !$status->isOK() ) {
+ return $status; // vetoed; resource locked
+ }
+ ++$yesVotes; // success for this peer
+ if ( $yesVotes >= $quorum ) {
+ return $status; // lock obtained
+ }
+ --$votesLeft;
+ $votesNeeded = $quorum - $yesVotes;
+ if ( $votesNeeded > $votesLeft ) {
+ break; // short-circuit
+ }
+ }
+ // At this point, we must not have met the quorum
+ $status->setResult( false );
+
+ return $status;
+ }
+
+ /**
+ * Attempt to release locks with the peers for a bucket
+ *
+ * @param int $bucket
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return Status
+ */
+ final protected function doUnlockingRequestBucket( $bucket, array $pathsByType ) {
+ $status = Status::newGood();
+
+ $yesVotes = 0; // locks freed on trustable servers
+ $votesLeft = count( $this->srvsByBucket[$bucket] ); // remaining peers
+ $quorum = floor( $votesLeft / 2 + 1 ); // simple majority
+ $isDegraded = isset( $this->degradedBuckets[$bucket] ); // not the normal quorum?
+ foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) {
+ if ( !$this->isServerUp( $lockSrv ) ) {
+ $status->warning( 'lockmanager-fail-svr-release', $lockSrv );
+ } else {
+ // Attempt to release the lock on this peer
+ $status->merge( $this->freeLocksOnServer( $lockSrv, $pathsByType ) );
+ ++$yesVotes; // success for this peer
+ // Normally the first peers form the quorum, and the others are ignored.
+ // Ignore them in this case, but not when an alternative quorum was used.
+ if ( $yesVotes >= $quorum && !$isDegraded ) {
+ break; // lock released
+ }
+ }
+ }
+ // Set a bad status if the quorum was not met.
+ // Assumes the same "up" servers as during the acquire step.
+ $status->setResult( $yesVotes >= $quorum );
+
+ return $status;
+ }
+
+ /**
+ * Get the bucket for resource path.
+ * This should avoid throwing any exceptions.
+ *
+ * @param string $path
+ * @return int
+ */
+ protected function getBucketFromPath( $path ) {
+ $prefix = substr( sha1( $path ), 0, 2 ); // first 2 hex chars (8 bits)
+ return (int)base_convert( $prefix, 16, 10 ) % count( $this->srvsByBucket );
+ }
+
+ /**
+ * Check if a lock server is up.
+ * This should process cache results to reduce RTT.
+ *
+ * @param string $lockSrv
+ * @return bool
+ */
+ abstract protected function isServerUp( $lockSrv );
+
+ /**
+ * Get a connection to a lock server and acquire locks
+ *
+ * @param string $lockSrv
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return Status
+ */
+ abstract protected function getLocksOnServer( $lockSrv, array $pathsByType );
+
+ /**
+ * Get a connection to a lock server and release locks on $paths.
+ *
+ * Subclasses must effectively implement this or releaseAllLocks().
+ *
+ * @param string $lockSrv
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return Status
+ */
+ abstract protected function freeLocksOnServer( $lockSrv, array $pathsByType );
+
+ /**
+ * Release all locks that this session is holding.
+ *
+ * Subclasses must effectively implement this or freeLocksOnServer().
+ *
+ * @return Status
+ */
+ abstract protected function releaseAllLocks();
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/RedisLockManager.php b/www/wiki/includes/filebackend/lockmanager/RedisLockManager.php
new file mode 100644
index 00000000..6095aeed
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/RedisLockManager.php
@@ -0,0 +1,272 @@
+<?php
+/**
+ * Version of LockManager based on using redis servers.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Manage locks using redis servers.
+ *
+ * Version of LockManager based on using redis servers.
+ * This is meant for multi-wiki systems that may share files.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * All lock requests for a resource, identified by a hash string, will map to one
+ * bucket. Each bucket maps to one or several peer servers, each running redis.
+ * A majority of peers must agree for a lock to be acquired.
+ *
+ * This class requires Redis 2.6 as it makes use Lua scripts for fast atomic operations.
+ *
+ * @ingroup LockManager
+ * @since 1.22
+ */
+class RedisLockManager extends QuorumLockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ /** @var RedisConnectionPool */
+ protected $redisPool;
+
+ /** @var array Map server names to hostname/IP and port numbers */
+ protected $lockServers = [];
+
+ /** @var string Random UUID */
+ protected $session = '';
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Parameters include:
+ * - lockServers : Associative array of server names to "<IP>:<port>" strings.
+ * - srvsByBucket : Array of 1-16 consecutive integer keys, starting from 0,
+ * each having an odd-numbered list of server names (peers) as values.
+ * - redisConfig : Configuration for RedisConnectionPool::__construct().
+ * @throws Exception
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->lockServers = $config['lockServers'];
+ // Sanitize srvsByBucket config to prevent PHP errors
+ $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' );
+ $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive
+
+ $config['redisConfig']['serializer'] = 'none';
+ $this->redisPool = RedisConnectionPool::singleton( $config['redisConfig'] );
+
+ $this->session = wfRandomString( 32 );
+ }
+
+ protected function getLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = Status::newGood();
+
+ $server = $this->lockServers[$lockSrv];
+ $conn = $this->redisPool->getConnection( $server );
+ if ( !$conn ) {
+ foreach ( array_merge( array_values( $pathsByType ) ) as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+
+ return $status;
+ }
+
+ $pathsByKey = []; // (type:hash => path) map
+ foreach ( $pathsByType as $type => $paths ) {
+ $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX';
+ foreach ( $paths as $path ) {
+ $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path;
+ }
+ }
+
+ try {
+ static $script =
+<<<LUA
+ local failed = {}
+ -- Load input params (e.g. session, ttl, time of request)
+ local rSession, rTTL, rTime = unpack(ARGV)
+ -- Check that all the locks can be acquired
+ for i,requestKey in ipairs(KEYS) do
+ local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
+ local keyIsFree = true
+ local currentLocks = redis.call('hKeys',resourceKey)
+ for i,lockKey in ipairs(currentLocks) do
+ -- Get the type and session of this lock
+ local _, _, type, session = string.find(lockKey,"(%w+):(%w+)")
+ -- Check any locks that are not owned by this session
+ if session ~= rSession then
+ local lockExpiry = redis.call('hGet',resourceKey,lockKey)
+ if 1*lockExpiry < 1*rTime then
+ -- Lock is stale, so just prune it out
+ redis.call('hDel',resourceKey,lockKey)
+ elseif rType == 'EX' or type == 'EX' then
+ keyIsFree = false
+ break
+ end
+ end
+ end
+ if not keyIsFree then
+ failed[#failed+1] = requestKey
+ end
+ end
+ -- If all locks could be acquired, then do so
+ if #failed == 0 then
+ for i,requestKey in ipairs(KEYS) do
+ local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
+ redis.call('hSet',resourceKey,rType .. ':' .. rSession,rTime + rTTL)
+ -- In addition to invalidation logic, be sure to garbage collect
+ redis.call('expire',resourceKey,rTTL)
+ end
+ end
+ return failed
+LUA;
+ $res = $conn->luaEval( $script,
+ array_merge(
+ array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N]
+ [
+ $this->session, // ARGV[1]
+ $this->lockTTL, // ARGV[2]
+ time() // ARGV[3]
+ ]
+ ),
+ count( $pathsByKey ) # number of first argument(s) that are keys
+ );
+ } catch ( RedisException $e ) {
+ $res = false;
+ $this->redisPool->handleError( $conn, $e );
+ }
+
+ if ( $res === false ) {
+ foreach ( array_merge( array_values( $pathsByType ) ) as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ } else {
+ foreach ( $res as $key ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $pathsByKey[$key] );
+ }
+ }
+
+ return $status;
+ }
+
+ protected function freeLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = Status::newGood();
+
+ $server = $this->lockServers[$lockSrv];
+ $conn = $this->redisPool->getConnection( $server );
+ if ( !$conn ) {
+ foreach ( array_merge( array_values( $pathsByType ) ) as $path ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+
+ return $status;
+ }
+
+ $pathsByKey = []; // (type:hash => path) map
+ foreach ( $pathsByType as $type => $paths ) {
+ $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX';
+ foreach ( $paths as $path ) {
+ $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path;
+ }
+ }
+
+ try {
+ static $script =
+<<<LUA
+ local failed = {}
+ -- Load input params (e.g. session)
+ local rSession = unpack(ARGV)
+ for i,requestKey in ipairs(KEYS) do
+ local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
+ local released = redis.call('hDel',resourceKey,rType .. ':' .. rSession)
+ if released > 0 then
+ -- Remove the whole structure if it is now empty
+ if redis.call('hLen',resourceKey) == 0 then
+ redis.call('del',resourceKey)
+ end
+ else
+ failed[#failed+1] = requestKey
+ end
+ end
+ return failed
+LUA;
+ $res = $conn->luaEval( $script,
+ array_merge(
+ array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N]
+ [
+ $this->session, // ARGV[1]
+ ]
+ ),
+ count( $pathsByKey ) # number of first argument(s) that are keys
+ );
+ } catch ( RedisException $e ) {
+ $res = false;
+ $this->redisPool->handleError( $conn, $e );
+ }
+
+ if ( $res === false ) {
+ foreach ( array_merge( array_values( $pathsByType ) ) as $path ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+ } else {
+ foreach ( $res as $key ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $pathsByKey[$key] );
+ }
+ }
+
+ return $status;
+ }
+
+ protected function releaseAllLocks() {
+ return Status::newGood(); // not supported
+ }
+
+ protected function isServerUp( $lockSrv ) {
+ return (bool)$this->redisPool->getConnection( $this->lockServers[$lockSrv] );
+ }
+
+ /**
+ * @param string $path
+ * @param string $type One of (EX,SH)
+ * @return string
+ */
+ protected function recordKeyForPath( $path, $type ) {
+ return implode( ':',
+ [ __CLASS__, 'locks', "$type:" . $this->sha1Base36Absolute( $path ) ] );
+ }
+
+ /**
+ * Make sure remaining locks get cleared for sanity
+ */
+ function __destruct() {
+ while ( count( $this->locksHeld ) ) {
+ $pathsByType = [];
+ foreach ( $this->locksHeld as $path => $locks ) {
+ foreach ( $locks as $type => $count ) {
+ $pathsByType[$type][] = $path;
+ }
+ }
+ $this->unlockByType( $pathsByType );
+ }
+ }
+}
diff --git a/www/wiki/includes/filebackend/lockmanager/ScopedLock.php b/www/wiki/includes/filebackend/lockmanager/ScopedLock.php
new file mode 100644
index 00000000..e1a600ce
--- /dev/null
+++ b/www/wiki/includes/filebackend/lockmanager/ScopedLock.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Resource locking handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ * @author Aaron Schulz
+ */
+
+/**
+ * Self-releasing locks
+ *
+ * LockManager helper class to handle scoped locks, which
+ * release when an object is destroyed or goes out of scope.
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+class ScopedLock {
+ /** @var LockManager */
+ protected $manager;
+
+ /** @var Status */
+ protected $status;
+
+ /** @var array Map of lock types to resource paths */
+ protected $pathsByType;
+
+ /**
+ * @param LockManager $manager
+ * @param array $pathsByType Map of lock types to path lists
+ * @param Status $status
+ */
+ protected function __construct( LockManager $manager, array $pathsByType, Status $status ) {
+ $this->manager = $manager;
+ $this->pathsByType = $pathsByType;
+ $this->status = $status;
+ }
+
+ /**
+ * Get a ScopedLock object representing a lock on resource paths.
+ * Any locks are released once this object goes out of scope.
+ * The status object is updated with any errors or warnings.
+ *
+ * @param LockManager $manager
+ * @param array $paths List of storage paths or map of lock types to path lists
+ * @param int|string $type LockManager::LOCK_* constant or "mixed" and $paths
+ * can be a map of types to paths (since 1.22). Otherwise $type should be an
+ * integer and $paths should be a list of paths.
+ * @param Status $status
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.22)
+ * @return ScopedLock|null Returns null on failure
+ */
+ public static function factory(
+ LockManager $manager, array $paths, $type, Status $status, $timeout = 0
+ ) {
+ $pathsByType = is_integer( $type ) ? [ $type => $paths ] : $paths;
+ $lockStatus = $manager->lockByType( $pathsByType, $timeout );
+ $status->merge( $lockStatus );
+ if ( $lockStatus->isOK() ) {
+ return new self( $manager, $pathsByType, $status );
+ }
+
+ return null;
+ }
+
+ /**
+ * Release a scoped lock and set any errors in the attatched Status object.
+ * This is useful for early release of locks before function scope is destroyed.
+ * This is the same as setting the lock object to null.
+ *
+ * @param ScopedLock $lock
+ * @since 1.21
+ */
+ public static function release( ScopedLock &$lock = null ) {
+ $lock = null;
+ }
+
+ /**
+ * Release the locks when this goes out of scope
+ */
+ function __destruct() {
+ $wasOk = $this->status->isOK();
+ $this->status->merge( $this->manager->unlockByType( $this->pathsByType ) );
+ if ( $wasOk ) {
+ // Make sure status is OK, despite any unlockFiles() fatals
+ $this->status->setResult( true, $this->status->value );
+ }
+ }
+}
diff --git a/www/wiki/includes/filerepo/FSRepo.php b/www/wiki/includes/filerepo/FSRepo.php
new file mode 100644
index 00000000..b24354dc
--- /dev/null
+++ b/www/wiki/includes/filerepo/FSRepo.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * A repository for files accessible via the local filesystem.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+/**
+ * A repository for files accessible via the local filesystem.
+ * Does not support database access or registration.
+ *
+ * This is a mostly a legacy class. New uses should not be added.
+ *
+ * @ingroup FileRepo
+ * @deprecated since 1.19
+ */
+class FSRepo extends FileRepo {
+ /**
+ * @param array $info
+ * @throws MWException
+ */
+ function __construct( array $info ) {
+ if ( !isset( $info['backend'] ) ) {
+ // B/C settings...
+ $directory = $info['directory'];
+ $deletedDir = isset( $info['deletedDir'] )
+ ? $info['deletedDir']
+ : false;
+ $thumbDir = isset( $info['thumbDir'] )
+ ? $info['thumbDir']
+ : "{$directory}/thumb";
+ $transcodedDir = isset( $info['transcodedDir'] )
+ ? $info['transcodedDir']
+ : "{$directory}/transcoded";
+ $fileMode = isset( $info['fileMode'] )
+ ? $info['fileMode']
+ : 0644;
+
+ $repoName = $info['name'];
+ // Get the FS backend configuration
+ $backend = new FSFileBackend( [
+ 'name' => $info['name'] . '-backend',
+ 'wikiId' => wfWikiID(),
+ 'lockManager' => LockManagerGroup::singleton( wfWikiID() )->get( 'fsLockManager' ),
+ 'containerPaths' => [
+ "{$repoName}-public" => "{$directory}",
+ "{$repoName}-temp" => "{$directory}/temp",
+ "{$repoName}-thumb" => $thumbDir,
+ "{$repoName}-transcoded" => $transcodedDir,
+ "{$repoName}-deleted" => $deletedDir
+ ],
+ 'fileMode' => $fileMode,
+ ] );
+ // Update repo config to use this backend
+ $info['backend'] = $backend;
+ }
+
+ parent::__construct( $info );
+
+ if ( !( $this->backend instanceof FSFileBackend ) ) {
+ throw new MWException( "FSRepo only supports FSFileBackend." );
+ }
+ }
+}
diff --git a/www/wiki/includes/filerepo/FileBackendDBRepoWrapper.php b/www/wiki/includes/filerepo/FileBackendDBRepoWrapper.php
new file mode 100644
index 00000000..21b7ac2f
--- /dev/null
+++ b/www/wiki/includes/filerepo/FileBackendDBRepoWrapper.php
@@ -0,0 +1,360 @@
+<?php
+/**
+ * Proxy backend that manages file layout rewriting for FileRepo.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ * @ingroup FileBackend
+ */
+
+use Wikimedia\Rdbms\DBConnRef;
+
+/**
+ * @brief Proxy backend that manages file layout rewriting for FileRepo.
+ *
+ * LocalRepo may be configured to store files under their title names or by SHA-1.
+ * This acts as a shim in the latter case, providing backwards compatability for
+ * most callers. All "public"/"deleted" zone files actually go in an "original"
+ * container and are never changed.
+ *
+ * This requires something like thumb_handler.php and img_auth.php for client viewing of files.
+ *
+ * @ingroup FileRepo
+ * @ingroup FileBackend
+ * @since 1.25
+ */
+class FileBackendDBRepoWrapper extends FileBackend {
+ /** @var FileBackend */
+ protected $backend;
+ /** @var string */
+ protected $repoName;
+ /** @var Closure */
+ protected $dbHandleFunc;
+ /** @var ProcessCacheLRU */
+ protected $resolvedPathCache;
+ /** @var DBConnRef[] */
+ protected $dbs;
+
+ public function __construct( array $config ) {
+ /** @var FileBackend $backend */
+ $backend = $config['backend'];
+ $config['name'] = $backend->getName();
+ $config['wikiId'] = $backend->getWikiId();
+ parent::__construct( $config );
+ $this->backend = $config['backend'];
+ $this->repoName = $config['repoName'];
+ $this->dbHandleFunc = $config['dbHandleFactory'];
+ $this->resolvedPathCache = new ProcessCacheLRU( 100 );
+ }
+
+ /**
+ * Get the underlying FileBackend that is being wrapped
+ *
+ * @return FileBackend
+ */
+ public function getInternalBackend() {
+ return $this->backend;
+ }
+
+ /**
+ * Translate a legacy "title" path to it's "sha1" counterpart
+ *
+ * E.g. mwstore://local-backend/local-public/a/ab/<name>.jpg
+ * => mwstore://local-backend/local-original/x/y/z/<sha1>.jpg
+ *
+ * @param string $path
+ * @param bool $latest
+ * @return string
+ */
+ public function getBackendPath( $path, $latest = true ) {
+ $paths = $this->getBackendPaths( [ $path ], $latest );
+ return current( $paths );
+ }
+
+ /**
+ * Translate legacy "title" paths to their "sha1" counterparts
+ *
+ * E.g. mwstore://local-backend/local-public/a/ab/<name>.jpg
+ * => mwstore://local-backend/local-original/x/y/z/<sha1>.jpg
+ *
+ * @param array $paths
+ * @param bool $latest
+ * @return array Translated paths in same order
+ */
+ public function getBackendPaths( array $paths, $latest = true ) {
+ $db = $this->getDB( $latest ? DB_MASTER : DB_REPLICA );
+
+ // @TODO: batching
+ $resolved = [];
+ foreach ( $paths as $i => $path ) {
+ if ( !$latest && $this->resolvedPathCache->has( $path, 'target', 10 ) ) {
+ $resolved[$i] = $this->resolvedPathCache->get( $path, 'target' );
+ continue;
+ }
+
+ list( , $container ) = FileBackend::splitStoragePath( $path );
+
+ if ( $container === "{$this->repoName}-public" ) {
+ $name = basename( $path );
+ if ( strpos( $path, '!' ) !== false ) {
+ $sha1 = $db->selectField( 'oldimage', 'oi_sha1',
+ [ 'oi_archive_name' => $name ],
+ __METHOD__
+ );
+ } else {
+ $sha1 = $db->selectField( 'image', 'img_sha1',
+ [ 'img_name' => $name ],
+ __METHOD__
+ );
+ }
+ if ( !strlen( $sha1 ) ) {
+ $resolved[$i] = $path; // give up
+ continue;
+ }
+ $resolved[$i] = $this->getPathForSHA1( $sha1 );
+ $this->resolvedPathCache->set( $path, 'target', $resolved[$i] );
+ } elseif ( $container === "{$this->repoName}-deleted" ) {
+ $name = basename( $path ); // <hash>.<ext>
+ $sha1 = substr( $name, 0, strpos( $name, '.' ) ); // ignore extension
+ $resolved[$i] = $this->getPathForSHA1( $sha1 );
+ $this->resolvedPathCache->set( $path, 'target', $resolved[$i] );
+ } else {
+ $resolved[$i] = $path;
+ }
+ }
+
+ $res = [];
+ foreach ( $paths as $i => $path ) {
+ $res[$i] = $resolved[$i];
+ }
+
+ return $res;
+ }
+
+ protected function doOperationsInternal( array $ops, array $opts ) {
+ return $this->backend->doOperationsInternal( $this->mungeOpPaths( $ops ), $opts );
+ }
+
+ protected function doQuickOperationsInternal( array $ops ) {
+ return $this->backend->doQuickOperationsInternal( $this->mungeOpPaths( $ops ) );
+ }
+
+ protected function doPrepare( array $params ) {
+ return $this->backend->doPrepare( $params );
+ }
+
+ protected function doSecure( array $params ) {
+ return $this->backend->doSecure( $params );
+ }
+
+ protected function doPublish( array $params ) {
+ return $this->backend->doPublish( $params );
+ }
+
+ protected function doClean( array $params ) {
+ return $this->backend->doClean( $params );
+ }
+
+ public function concatenate( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function fileExists( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function getFileTimestamp( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function getFileSize( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function getFileStat( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function getFileXAttributes( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function getFileSha1Base36( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function getFileProps( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function streamFile( array $params ) {
+ // The stream methods use the file extension to determine the
+ // Content-Type (as MediaWiki should already validate it on upload).
+ // The translated SHA1 path has no extension, so this needs to use
+ // the untranslated path extension.
+ $type = StreamFile::contentTypeFromPath( $params['src'] );
+ if ( $type && $type != 'unknown/unknown' ) {
+ $params['headers'][] = "Content-type: $type";
+ }
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function getFileContentsMulti( array $params ) {
+ return $this->translateArrayResults( __FUNCTION__, $params );
+ }
+
+ public function getLocalReferenceMulti( array $params ) {
+ return $this->translateArrayResults( __FUNCTION__, $params );
+ }
+
+ public function getLocalCopyMulti( array $params ) {
+ return $this->translateArrayResults( __FUNCTION__, $params );
+ }
+
+ public function getFileHttpUrl( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function directoryExists( array $params ) {
+ return $this->backend->directoryExists( $params );
+ }
+
+ public function getDirectoryList( array $params ) {
+ return $this->backend->getDirectoryList( $params );
+ }
+
+ public function getFileList( array $params ) {
+ return $this->backend->getFileList( $params );
+ }
+
+ public function getFeatures() {
+ return $this->backend->getFeatures();
+ }
+
+ public function clearCache( array $paths = null ) {
+ $this->backend->clearCache( null ); // clear all
+ }
+
+ public function preloadCache( array $paths ) {
+ $paths = $this->getBackendPaths( $paths );
+ $this->backend->preloadCache( $paths );
+ }
+
+ public function preloadFileStat( array $params ) {
+ return $this->translateSrcParams( __FUNCTION__, $params );
+ }
+
+ public function getScopedLocksForOps( array $ops, StatusValue $status ) {
+ return $this->backend->getScopedLocksForOps( $ops, $status );
+ }
+
+ /**
+ * Get the ultimate original storage path for a file
+ *
+ * Use this when putting a new file into the system
+ *
+ * @param string $sha1 File SHA-1 base36
+ * @return string
+ */
+ public function getPathForSHA1( $sha1 ) {
+ if ( strlen( $sha1 ) < 3 ) {
+ throw new InvalidArgumentException( "Invalid file SHA-1." );
+ }
+ return $this->backend->getContainerStoragePath( "{$this->repoName}-original" ) .
+ "/{$sha1[0]}/{$sha1[1]}/{$sha1[2]}/{$sha1}";
+ }
+
+ /**
+ * Get a connection to the repo file registry DB
+ *
+ * @param int $index
+ * @return DBConnRef
+ */
+ protected function getDB( $index ) {
+ if ( !isset( $this->dbs[$index] ) ) {
+ $func = $this->dbHandleFunc;
+ $this->dbs[$index] = $func( $index );
+ }
+ return $this->dbs[$index];
+ }
+
+ /**
+ * Translates paths found in the "src" or "srcs" keys of a params array
+ *
+ * @param string $function
+ * @param array $params
+ * @return mixed
+ */
+ protected function translateSrcParams( $function, array $params ) {
+ $latest = !empty( $params['latest'] );
+
+ if ( isset( $params['src'] ) ) {
+ $params['src'] = $this->getBackendPath( $params['src'], $latest );
+ }
+
+ if ( isset( $params['srcs'] ) ) {
+ $params['srcs'] = $this->getBackendPaths( $params['srcs'], $latest );
+ }
+
+ return $this->backend->$function( $params );
+ }
+
+ /**
+ * Translates paths when the backend function returns results keyed by paths
+ *
+ * @param string $function
+ * @param array $params
+ * @return array
+ */
+ protected function translateArrayResults( $function, array $params ) {
+ $origPaths = $params['srcs'];
+ $params['srcs'] = $this->getBackendPaths( $params['srcs'], !empty( $params['latest'] ) );
+ $pathMap = array_combine( $params['srcs'], $origPaths );
+
+ $results = $this->backend->$function( $params );
+
+ $contents = [];
+ foreach ( $results as $path => $result ) {
+ $contents[$pathMap[$path]] = $result;
+ }
+
+ return $contents;
+ }
+
+ /**
+ * Translate legacy "title" source paths to their "sha1" counterparts
+ *
+ * This leaves destination paths alone since we don't want those to mutate
+ *
+ * @param array $ops
+ * @return array
+ */
+ protected function mungeOpPaths( array $ops ) {
+ // Ops that use 'src' and do not mutate core file data there
+ static $srcRefOps = [ 'store', 'copy', 'describe' ];
+ foreach ( $ops as &$op ) {
+ if ( isset( $op['src'] ) && in_array( $op['op'], $srcRefOps ) ) {
+ $op['src'] = $this->getBackendPath( $op['src'], true );
+ }
+ if ( isset( $op['srcs'] ) ) {
+ $op['srcs'] = $this->getBackendPaths( $op['srcs'], true );
+ }
+ }
+ return $ops;
+ }
+}
diff --git a/www/wiki/includes/filerepo/FileRepo.php b/www/wiki/includes/filerepo/FileRepo.php
new file mode 100644
index 00000000..5162a04d
--- /dev/null
+++ b/www/wiki/includes/filerepo/FileRepo.php
@@ -0,0 +1,1936 @@
+<?php
+/**
+ * @defgroup FileRepo File Repository
+ *
+ * @brief This module handles how MediaWiki interacts with filesystems.
+ *
+ * @details
+ */
+
+/**
+ * Base code for file repositories.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+/**
+ * Base class for file repositories
+ *
+ * @ingroup FileRepo
+ */
+class FileRepo {
+ const DELETE_SOURCE = 1;
+ const OVERWRITE = 2;
+ const OVERWRITE_SAME = 4;
+ const SKIP_LOCKING = 8;
+
+ const NAME_AND_TIME_ONLY = 1;
+
+ /** @var bool Whether to fetch commons image description pages and display
+ * them on the local wiki */
+ public $fetchDescription;
+
+ /** @var int */
+ public $descriptionCacheExpiry;
+
+ /** @var bool */
+ protected $hasSha1Storage = false;
+
+ /** @var bool */
+ protected $supportsSha1URLs = false;
+
+ /** @var FileBackend */
+ protected $backend;
+
+ /** @var array Map of zones to config */
+ protected $zones = [];
+
+ /** @var string URL of thumb.php */
+ protected $thumbScriptUrl;
+
+ /** @var bool Whether to skip media file transformation on parse and rely
+ * on a 404 handler instead. */
+ protected $transformVia404;
+
+ /** @var string URL of image description pages, e.g.
+ * https://en.wikipedia.org/wiki/File:
+ */
+ protected $descBaseUrl;
+
+ /** @var string URL of the MediaWiki installation, equivalent to
+ * $wgScriptPath, e.g. https://en.wikipedia.org/w
+ */
+ protected $scriptDirUrl;
+
+ /** @var string Script extension of the MediaWiki installation, equivalent
+ * to the old $wgScriptExtension, e.g. .php5 defaults to .php */
+ protected $scriptExtension;
+
+ /** @var string Equivalent to $wgArticlePath, e.g. https://en.wikipedia.org/wiki/$1 */
+ protected $articleUrl;
+
+ /** @var bool Equivalent to $wgCapitalLinks (or $wgCapitalLinkOverrides[NS_FILE],
+ * determines whether filenames implicitly start with a capital letter.
+ * The current implementation may give incorrect description page links
+ * when the local $wgCapitalLinks and initialCapital are mismatched.
+ */
+ protected $initialCapital;
+
+ /** @var string May be 'paranoid' to remove all parameters from error
+ * messages, 'none' to leave the paths in unchanged, or 'simple' to
+ * replace paths with placeholders. Default for LocalRepo is
+ * 'simple'.
+ */
+ protected $pathDisclosureProtection = 'simple';
+
+ /** @var string|false Public zone URL. */
+ protected $url;
+
+ /** @var string The base thumbnail URL. Defaults to "<url>/thumb". */
+ protected $thumbUrl;
+
+ /** @var int The number of directory levels for hash-based division of files */
+ protected $hashLevels;
+
+ /** @var int The number of directory levels for hash-based division of deleted files */
+ protected $deletedHashLevels;
+
+ /** @var int File names over this size will use the short form of thumbnail
+ * names. Short thumbnail names only have the width, parameters, and the
+ * extension.
+ */
+ protected $abbrvThreshold;
+
+ /** @var string The URL of the repo's favicon, if any */
+ protected $favicon;
+
+ /** @var bool Whether all zones should be private (e.g. private wiki repo) */
+ protected $isPrivate;
+
+ /** @var array callable Override these in the base class */
+ protected $fileFactory = [ 'UnregisteredLocalFile', 'newFromTitle' ];
+ /** @var array callable|bool Override these in the base class */
+ protected $oldFileFactory = false;
+ /** @var array callable|bool Override these in the base class */
+ protected $fileFactoryKey = false;
+ /** @var array callable|bool Override these in the base class */
+ protected $oldFileFactoryKey = false;
+
+ /**
+ * @param array|null $info
+ * @throws MWException
+ */
+ public function __construct( array $info = null ) {
+ // Verify required settings presence
+ if (
+ $info === null
+ || !array_key_exists( 'name', $info )
+ || !array_key_exists( 'backend', $info )
+ ) {
+ throw new MWException( __CLASS__ .
+ " requires an array of options having both 'name' and 'backend' keys.\n" );
+ }
+
+ // Required settings
+ $this->name = $info['name'];
+ if ( $info['backend'] instanceof FileBackend ) {
+ $this->backend = $info['backend']; // useful for testing
+ } else {
+ $this->backend = FileBackendGroup::singleton()->get( $info['backend'] );
+ }
+
+ // Optional settings that can have no value
+ $optionalSettings = [
+ 'descBaseUrl', 'scriptDirUrl', 'articleUrl', 'fetchDescription',
+ 'thumbScriptUrl', 'pathDisclosureProtection', 'descriptionCacheExpiry',
+ 'scriptExtension', 'favicon'
+ ];
+ foreach ( $optionalSettings as $var ) {
+ if ( isset( $info[$var] ) ) {
+ $this->$var = $info[$var];
+ }
+ }
+
+ // Optional settings that have a default
+ $this->initialCapital = isset( $info['initialCapital'] )
+ ? $info['initialCapital']
+ : MWNamespace::isCapitalized( NS_FILE );
+ $this->url = isset( $info['url'] )
+ ? $info['url']
+ : false; // a subclass may set the URL (e.g. ForeignAPIRepo)
+ if ( isset( $info['thumbUrl'] ) ) {
+ $this->thumbUrl = $info['thumbUrl'];
+ } else {
+ $this->thumbUrl = $this->url ? "{$this->url}/thumb" : false;
+ }
+ $this->hashLevels = isset( $info['hashLevels'] )
+ ? $info['hashLevels']
+ : 2;
+ $this->deletedHashLevels = isset( $info['deletedHashLevels'] )
+ ? $info['deletedHashLevels']
+ : $this->hashLevels;
+ $this->transformVia404 = !empty( $info['transformVia404'] );
+ $this->abbrvThreshold = isset( $info['abbrvThreshold'] )
+ ? $info['abbrvThreshold']
+ : 255;
+ $this->isPrivate = !empty( $info['isPrivate'] );
+ // Give defaults for the basic zones...
+ $this->zones = isset( $info['zones'] ) ? $info['zones'] : [];
+ foreach ( [ 'public', 'thumb', 'transcoded', 'temp', 'deleted' ] as $zone ) {
+ if ( !isset( $this->zones[$zone]['container'] ) ) {
+ $this->zones[$zone]['container'] = "{$this->name}-{$zone}";
+ }
+ if ( !isset( $this->zones[$zone]['directory'] ) ) {
+ $this->zones[$zone]['directory'] = '';
+ }
+ if ( !isset( $this->zones[$zone]['urlsByExt'] ) ) {
+ $this->zones[$zone]['urlsByExt'] = [];
+ }
+ }
+
+ $this->supportsSha1URLs = !empty( $info['supportsSha1URLs'] );
+ }
+
+ /**
+ * Get the file backend instance. Use this function wisely.
+ *
+ * @return FileBackend
+ */
+ public function getBackend() {
+ return $this->backend;
+ }
+
+ /**
+ * Get an explanatory message if this repo is read-only.
+ * This checks if an administrator disabled writes to the backend.
+ *
+ * @return string|bool Returns false if the repo is not read-only
+ */
+ public function getReadOnlyReason() {
+ return $this->backend->getReadOnlyReason();
+ }
+
+ /**
+ * Check if a single zone or list of zones is defined for usage
+ *
+ * @param array $doZones Only do a particular zones
+ * @throws MWException
+ * @return Status
+ */
+ protected function initZones( $doZones = [] ) {
+ $status = $this->newGood();
+ foreach ( (array)$doZones as $zone ) {
+ $root = $this->getZonePath( $zone );
+ if ( $root === null ) {
+ throw new MWException( "No '$zone' zone defined in the {$this->name} repo." );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Determine if a string is an mwrepo:// URL
+ *
+ * @param string $url
+ * @return bool
+ */
+ public static function isVirtualUrl( $url ) {
+ return substr( $url, 0, 9 ) == 'mwrepo://';
+ }
+
+ /**
+ * Get a URL referring to this repository, with the private mwrepo protocol.
+ * The suffix, if supplied, is considered to be unencoded, and will be
+ * URL-encoded before being returned.
+ *
+ * @param string|bool $suffix
+ * @return string
+ */
+ public function getVirtualUrl( $suffix = false ) {
+ $path = 'mwrepo://' . $this->name;
+ if ( $suffix !== false ) {
+ $path .= '/' . rawurlencode( $suffix );
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get the URL corresponding to one of the four basic zones
+ *
+ * @param string $zone One of: public, deleted, temp, thumb
+ * @param string|null $ext Optional file extension
+ * @return string|bool
+ */
+ public function getZoneUrl( $zone, $ext = null ) {
+ if ( in_array( $zone, [ 'public', 'thumb', 'transcoded' ] ) ) {
+ // standard public zones
+ if ( $ext !== null && isset( $this->zones[$zone]['urlsByExt'][$ext] ) ) {
+ // custom URL for extension/zone
+ return $this->zones[$zone]['urlsByExt'][$ext];
+ } elseif ( isset( $this->zones[$zone]['url'] ) ) {
+ // custom URL for zone
+ return $this->zones[$zone]['url'];
+ }
+ }
+ switch ( $zone ) {
+ case 'public':
+ return $this->url;
+ case 'temp':
+ case 'deleted':
+ return false; // no public URL
+ case 'thumb':
+ return $this->thumbUrl;
+ case 'transcoded':
+ return "{$this->url}/transcoded";
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * @return bool Whether non-ASCII path characters are allowed
+ */
+ public function backendSupportsUnicodePaths() {
+ return (bool)( $this->getBackend()->getFeatures() & FileBackend::ATTR_UNICODE_PATHS );
+ }
+
+ /**
+ * Get the backend storage path corresponding to a virtual URL.
+ * Use this function wisely.
+ *
+ * @param string $url
+ * @throws MWException
+ * @return string
+ */
+ public function resolveVirtualUrl( $url ) {
+ if ( substr( $url, 0, 9 ) != 'mwrepo://' ) {
+ throw new MWException( __METHOD__ . ': unknown protocol' );
+ }
+ $bits = explode( '/', substr( $url, 9 ), 3 );
+ if ( count( $bits ) != 3 ) {
+ throw new MWException( __METHOD__ . ": invalid mwrepo URL: $url" );
+ }
+ list( $repo, $zone, $rel ) = $bits;
+ if ( $repo !== $this->name ) {
+ throw new MWException( __METHOD__ . ": fetching from a foreign repo is not supported" );
+ }
+ $base = $this->getZonePath( $zone );
+ if ( !$base ) {
+ throw new MWException( __METHOD__ . ": invalid zone: $zone" );
+ }
+
+ return $base . '/' . rawurldecode( $rel );
+ }
+
+ /**
+ * The the storage container and base path of a zone
+ *
+ * @param string $zone
+ * @return array (container, base path) or (null, null)
+ */
+ protected function getZoneLocation( $zone ) {
+ if ( !isset( $this->zones[$zone] ) ) {
+ return [ null, null ]; // bogus
+ }
+
+ return [ $this->zones[$zone]['container'], $this->zones[$zone]['directory'] ];
+ }
+
+ /**
+ * Get the storage path corresponding to one of the zones
+ *
+ * @param string $zone
+ * @return string|null Returns null if the zone is not defined
+ */
+ public function getZonePath( $zone ) {
+ list( $container, $base ) = $this->getZoneLocation( $zone );
+ if ( $container === null || $base === null ) {
+ return null;
+ }
+ $backendName = $this->backend->getName();
+ if ( $base != '' ) { // may not be set
+ $base = "/{$base}";
+ }
+
+ return "mwstore://$backendName/{$container}{$base}";
+ }
+
+ /**
+ * Create a new File object from the local repository
+ *
+ * @param Title|string $title Title object or string
+ * @param bool|string $time Time at which the image was uploaded. If this
+ * is specified, the returned object will be an instance of the
+ * repository's old file class instead of a current file. Repositories
+ * not supporting version control should return false if this parameter
+ * is set.
+ * @return File|null A File, or null if passed an invalid Title
+ */
+ public function newFile( $title, $time = false ) {
+ $title = File::normalizeTitle( $title );
+ if ( !$title ) {
+ return null;
+ }
+ if ( $time ) {
+ if ( $this->oldFileFactory ) {
+ return call_user_func( $this->oldFileFactory, $title, $this, $time );
+ } else {
+ return null;
+ }
+ } else {
+ return call_user_func( $this->fileFactory, $title, $this );
+ }
+ }
+
+ /**
+ * Find an instance of the named file created at the specified time
+ * Returns false if the file does not exist. Repositories not supporting
+ * version control should return false if the time is specified.
+ *
+ * @param Title|string $title Title object or string
+ * @param array $options Associative array of options:
+ * time: requested time for a specific file version, or false for the
+ * current version. An image object will be returned which was
+ * created at the specified time (which may be archived or current).
+ * ignoreRedirect: If true, do not follow file redirects
+ * private: If true, return restricted (deleted) files if the current
+ * user is allowed to view them. Otherwise, such files will not
+ * be found. If a User object, use that user instead of the current.
+ * latest: If true, load from the latest available data into File objects
+ * @return File|bool False on failure
+ */
+ public function findFile( $title, $options = [] ) {
+ $title = File::normalizeTitle( $title );
+ if ( !$title ) {
+ return false;
+ }
+ if ( isset( $options['bypassCache'] ) ) {
+ $options['latest'] = $options['bypassCache']; // b/c
+ }
+ $time = isset( $options['time'] ) ? $options['time'] : false;
+ $flags = !empty( $options['latest'] ) ? File::READ_LATEST : 0;
+ # First try the current version of the file to see if it precedes the timestamp
+ $img = $this->newFile( $title );
+ if ( !$img ) {
+ return false;
+ }
+ $img->load( $flags );
+ if ( $img->exists() && ( !$time || $img->getTimestamp() == $time ) ) {
+ return $img;
+ }
+ # Now try an old version of the file
+ if ( $time !== false ) {
+ $img = $this->newFile( $title, $time );
+ if ( $img ) {
+ $img->load( $flags );
+ if ( $img->exists() ) {
+ if ( !$img->isDeleted( File::DELETED_FILE ) ) {
+ return $img; // always OK
+ } elseif ( !empty( $options['private'] ) &&
+ $img->userCan( File::DELETED_FILE,
+ $options['private'] instanceof User ? $options['private'] : null
+ )
+ ) {
+ return $img;
+ }
+ }
+ }
+ }
+
+ # Now try redirects
+ if ( !empty( $options['ignoreRedirect'] ) ) {
+ return false;
+ }
+ $redir = $this->checkRedirect( $title );
+ if ( $redir && $title->getNamespace() == NS_FILE ) {
+ $img = $this->newFile( $redir );
+ if ( !$img ) {
+ return false;
+ }
+ $img->load( $flags );
+ if ( $img->exists() ) {
+ $img->redirectedFrom( $title->getDBkey() );
+
+ return $img;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Find many files at once.
+ *
+ * @param array $items An array of titles, or an array of findFile() options with
+ * the "title" option giving the title. Example:
+ *
+ * $findItem = [ 'title' => $title, 'private' => true ];
+ * $findBatch = [ $findItem ];
+ * $repo->findFiles( $findBatch );
+ *
+ * No title should appear in $items twice, as the result use titles as keys
+ * @param int $flags Supports:
+ * - FileRepo::NAME_AND_TIME_ONLY : return a (search title => (title,timestamp)) map.
+ * The search title uses the input titles; the other is the final post-redirect title.
+ * All titles are returned as string DB keys and the inner array is associative.
+ * @return array Map of (file name => File objects) for matches
+ */
+ public function findFiles( array $items, $flags = 0 ) {
+ $result = [];
+ foreach ( $items as $item ) {
+ if ( is_array( $item ) ) {
+ $title = $item['title'];
+ $options = $item;
+ unset( $options['title'] );
+ } else {
+ $title = $item;
+ $options = [];
+ }
+ $file = $this->findFile( $title, $options );
+ if ( $file ) {
+ $searchName = File::normalizeTitle( $title )->getDBkey(); // must be valid
+ if ( $flags & self::NAME_AND_TIME_ONLY ) {
+ $result[$searchName] = [
+ 'title' => $file->getTitle()->getDBkey(),
+ 'timestamp' => $file->getTimestamp()
+ ];
+ } else {
+ $result[$searchName] = $file;
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Find an instance of the file with this key, created at the specified time
+ * Returns false if the file does not exist. Repositories not supporting
+ * version control should return false if the time is specified.
+ *
+ * @param string $sha1 Base 36 SHA-1 hash
+ * @param array $options Option array, same as findFile().
+ * @return File|bool False on failure
+ */
+ public function findFileFromKey( $sha1, $options = [] ) {
+ $time = isset( $options['time'] ) ? $options['time'] : false;
+ # First try to find a matching current version of a file...
+ if ( !$this->fileFactoryKey ) {
+ return false; // find-by-sha1 not supported
+ }
+ $img = call_user_func( $this->fileFactoryKey, $sha1, $this, $time );
+ if ( $img && $img->exists() ) {
+ return $img;
+ }
+ # Now try to find a matching old version of a file...
+ if ( $time !== false && $this->oldFileFactoryKey ) { // find-by-sha1 supported?
+ $img = call_user_func( $this->oldFileFactoryKey, $sha1, $this, $time );
+ if ( $img && $img->exists() ) {
+ if ( !$img->isDeleted( File::DELETED_FILE ) ) {
+ return $img; // always OK
+ } elseif ( !empty( $options['private'] ) &&
+ $img->userCan( File::DELETED_FILE,
+ $options['private'] instanceof User ? $options['private'] : null
+ )
+ ) {
+ return $img;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get an array or iterator of file objects for files that have a given
+ * SHA-1 content hash.
+ *
+ * STUB
+ * @param string $hash SHA-1 hash
+ * @return File[]
+ */
+ public function findBySha1( $hash ) {
+ return [];
+ }
+
+ /**
+ * Get an array of arrays or iterators of file objects for files that
+ * have the given SHA-1 content hashes.
+ *
+ * @param array $hashes An array of hashes
+ * @return array An Array of arrays or iterators of file objects and the hash as key
+ */
+ public function findBySha1s( array $hashes ) {
+ $result = [];
+ foreach ( $hashes as $hash ) {
+ $files = $this->findBySha1( $hash );
+ if ( count( $files ) ) {
+ $result[$hash] = $files;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return an array of files where the name starts with $prefix.
+ *
+ * STUB
+ * @param string $prefix The prefix to search for
+ * @param int $limit The maximum amount of files to return
+ * @return array
+ */
+ public function findFilesByPrefix( $prefix, $limit ) {
+ return [];
+ }
+
+ /**
+ * Get the URL of thumb.php
+ *
+ * @return string
+ */
+ public function getThumbScriptUrl() {
+ return $this->thumbScriptUrl;
+ }
+
+ /**
+ * Returns true if the repository can transform files via a 404 handler
+ *
+ * @return bool
+ */
+ public function canTransformVia404() {
+ return $this->transformVia404;
+ }
+
+ /**
+ * Get the name of a file from its title object
+ *
+ * @param Title $title
+ * @return string
+ */
+ public function getNameFromTitle( Title $title ) {
+ global $wgContLang;
+ if ( $this->initialCapital != MWNamespace::isCapitalized( NS_FILE ) ) {
+ $name = $title->getUserCaseDBKey();
+ if ( $this->initialCapital ) {
+ $name = $wgContLang->ucfirst( $name );
+ }
+ } else {
+ $name = $title->getDBkey();
+ }
+
+ return $name;
+ }
+
+ /**
+ * Get the public zone root storage directory of the repository
+ *
+ * @return string
+ */
+ public function getRootDirectory() {
+ return $this->getZonePath( 'public' );
+ }
+
+ /**
+ * Get a relative path including trailing slash, e.g. f/fa/
+ * If the repo is not hashed, returns an empty string
+ *
+ * @param string $name Name of file
+ * @return string
+ */
+ public function getHashPath( $name ) {
+ return self::getHashPathForLevel( $name, $this->hashLevels );
+ }
+
+ /**
+ * Get a relative path including trailing slash, e.g. f/fa/
+ * If the repo is not hashed, returns an empty string
+ *
+ * @param string $suffix Basename of file from FileRepo::storeTemp()
+ * @return string
+ */
+ public function getTempHashPath( $suffix ) {
+ $parts = explode( '!', $suffix, 2 ); // format is <timestamp>!<name> or just <name>
+ $name = isset( $parts[1] ) ? $parts[1] : $suffix; // hash path is not based on timestamp
+ return self::getHashPathForLevel( $name, $this->hashLevels );
+ }
+
+ /**
+ * @param string $name
+ * @param int $levels
+ * @return string
+ */
+ protected static function getHashPathForLevel( $name, $levels ) {
+ if ( $levels == 0 ) {
+ return '';
+ } else {
+ $hash = md5( $name );
+ $path = '';
+ for ( $i = 1; $i <= $levels; $i++ ) {
+ $path .= substr( $hash, 0, $i ) . '/';
+ }
+
+ return $path;
+ }
+ }
+
+ /**
+ * Get the number of hash directory levels
+ *
+ * @return int
+ */
+ public function getHashLevels() {
+ return $this->hashLevels;
+ }
+
+ /**
+ * Get the name of this repository, as specified by $info['name]' to the constructor
+ *
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Make an url to this repo
+ *
+ * @param string $query Query string to append
+ * @param string $entry Entry point; defaults to index
+ * @return string|bool False on failure
+ */
+ public function makeUrl( $query = '', $entry = 'index' ) {
+ if ( isset( $this->scriptDirUrl ) ) {
+ $ext = isset( $this->scriptExtension ) ? $this->scriptExtension : '.php';
+
+ return wfAppendQuery( "{$this->scriptDirUrl}/{$entry}{$ext}", $query );
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the URL of an image description page. May return false if it is
+ * unknown or not applicable. In general this should only be called by the
+ * File class, since it may return invalid results for certain kinds of
+ * repositories. Use File::getDescriptionUrl() in user code.
+ *
+ * In particular, it uses the article paths as specified to the repository
+ * constructor, whereas local repositories use the local Title functions.
+ *
+ * @param string $name
+ * @return string|false
+ */
+ public function getDescriptionUrl( $name ) {
+ $encName = wfUrlencode( $name );
+ if ( !is_null( $this->descBaseUrl ) ) {
+ # "http://example.com/wiki/File:"
+ return $this->descBaseUrl . $encName;
+ }
+ if ( !is_null( $this->articleUrl ) ) {
+ # "http://example.com/wiki/$1"
+ # We use "Image:" as the canonical namespace for
+ # compatibility across all MediaWiki versions.
+ return str_replace( '$1',
+ "Image:$encName", $this->articleUrl );
+ }
+ if ( !is_null( $this->scriptDirUrl ) ) {
+ # "http://example.com/w"
+ # We use "Image:" as the canonical namespace for
+ # compatibility across all MediaWiki versions,
+ # and just sort of hope index.php is right. ;)
+ return $this->makeUrl( "title=Image:$encName" );
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the URL of the content-only fragment of the description page. For
+ * MediaWiki this means action=render. This should only be called by the
+ * repository's file class, since it may return invalid results. User code
+ * should use File::getDescriptionText().
+ *
+ * @param string $name Name of image to fetch
+ * @param string $lang Language to fetch it in, if any.
+ * @return string|false
+ */
+ public function getDescriptionRenderUrl( $name, $lang = null ) {
+ $query = 'action=render';
+ if ( !is_null( $lang ) ) {
+ $query .= '&uselang=' . urlencode( $lang );
+ }
+ if ( isset( $this->scriptDirUrl ) ) {
+ return $this->makeUrl(
+ 'title=' .
+ wfUrlencode( 'Image:' . $name ) .
+ "&$query" );
+ } else {
+ $descUrl = $this->getDescriptionUrl( $name );
+ if ( $descUrl ) {
+ return wfAppendQuery( $descUrl, $query );
+ } else {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Get the URL of the stylesheet to apply to description pages
+ *
+ * @return string|bool False on failure
+ */
+ public function getDescriptionStylesheetUrl() {
+ if ( isset( $this->scriptDirUrl ) ) {
+ return $this->makeUrl( 'title=MediaWiki:Filepage.css&' .
+ wfArrayToCgi( Skin::getDynamicStylesheetQuery() ) );
+ }
+
+ return false;
+ }
+
+ /**
+ * Store a file to a given destination.
+ *
+ * @param string $srcPath Source file system path, storage path, or virtual URL
+ * @param string $dstZone Destination zone
+ * @param string $dstRel Destination relative path
+ * @param int $flags Bitwise combination of the following flags:
+ * self::OVERWRITE Overwrite an existing destination file instead of failing
+ * self::OVERWRITE_SAME Overwrite the file if the destination exists and has the
+ * same contents as the source
+ * self::SKIP_LOCKING Skip any file locking when doing the store
+ * @return Status
+ */
+ public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ $status = $this->storeBatch( [ [ $srcPath, $dstZone, $dstRel ] ], $flags );
+ if ( $status->successCount == 0 ) {
+ $status->setOK( false );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Store a batch of files
+ *
+ * @param array $triplets (src, dest zone, dest rel) triplets as per store()
+ * @param int $flags Bitwise combination of the following flags:
+ * self::OVERWRITE Overwrite an existing destination file instead of failing
+ * self::OVERWRITE_SAME Overwrite the file if the destination exists and has the
+ * same contents as the source
+ * self::SKIP_LOCKING Skip any file locking when doing the store
+ * @throws MWException
+ * @return Status
+ */
+ public function storeBatch( array $triplets, $flags = 0 ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ if ( $flags & self::DELETE_SOURCE ) {
+ throw new InvalidArgumentException( "DELETE_SOURCE not supported in " . __METHOD__ );
+ }
+
+ $status = $this->newGood();
+ $backend = $this->backend; // convenience
+
+ $operations = [];
+ // Validate each triplet and get the store operation...
+ foreach ( $triplets as $triplet ) {
+ list( $srcPath, $dstZone, $dstRel ) = $triplet;
+ wfDebug( __METHOD__
+ . "( \$src='$srcPath', \$dstZone='$dstZone', \$dstRel='$dstRel' )\n"
+ );
+
+ // Resolve destination path
+ $root = $this->getZonePath( $dstZone );
+ if ( !$root ) {
+ throw new MWException( "Invalid zone: $dstZone" );
+ }
+ if ( !$this->validateFilename( $dstRel ) ) {
+ throw new MWException( 'Validation error in $dstRel' );
+ }
+ $dstPath = "$root/$dstRel";
+ $dstDir = dirname( $dstPath );
+ // Create destination directories for this triplet
+ if ( !$this->initDirectory( $dstDir )->isOK() ) {
+ return $this->newFatal( 'directorycreateerror', $dstDir );
+ }
+
+ // Resolve source to a storage path if virtual
+ $srcPath = $this->resolveToStoragePath( $srcPath );
+
+ // Get the appropriate file operation
+ if ( FileBackend::isStoragePath( $srcPath ) ) {
+ $opName = 'copy';
+ } else {
+ $opName = 'store';
+ }
+ $operations[] = [
+ 'op' => $opName,
+ 'src' => $srcPath,
+ 'dst' => $dstPath,
+ 'overwrite' => $flags & self::OVERWRITE,
+ 'overwriteSame' => $flags & self::OVERWRITE_SAME,
+ ];
+ }
+
+ // Execute the store operation for each triplet
+ $opts = [ 'force' => true ];
+ if ( $flags & self::SKIP_LOCKING ) {
+ $opts['nonLocking'] = true;
+ }
+ $status->merge( $backend->doOperations( $operations, $opts ) );
+
+ return $status;
+ }
+
+ /**
+ * Deletes a batch of files.
+ * Each file can be a (zone, rel) pair, virtual url, storage path.
+ * It will try to delete each file, but ignores any errors that may occur.
+ *
+ * @param array $files List of files to delete
+ * @param int $flags Bitwise combination of the following flags:
+ * self::SKIP_LOCKING Skip any file locking when doing the deletions
+ * @return Status
+ */
+ public function cleanupBatch( array $files, $flags = 0 ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ $status = $this->newGood();
+
+ $operations = [];
+ foreach ( $files as $path ) {
+ if ( is_array( $path ) ) {
+ // This is a pair, extract it
+ list( $zone, $rel ) = $path;
+ $path = $this->getZonePath( $zone ) . "/$rel";
+ } else {
+ // Resolve source to a storage path if virtual
+ $path = $this->resolveToStoragePath( $path );
+ }
+ $operations[] = [ 'op' => 'delete', 'src' => $path ];
+ }
+ // Actually delete files from storage...
+ $opts = [ 'force' => true ];
+ if ( $flags & self::SKIP_LOCKING ) {
+ $opts['nonLocking'] = true;
+ }
+ $status->merge( $this->backend->doOperations( $operations, $opts ) );
+
+ return $status;
+ }
+
+ /**
+ * Import a file from the local file system into the repo.
+ * This does no locking nor journaling and overrides existing files.
+ * This function can be used to write to otherwise read-only foreign repos.
+ * This is intended for copying generated thumbnails into the repo.
+ *
+ * @param string|FSFile $src Source file system path, storage path, or virtual URL
+ * @param string $dst Virtual URL or storage path
+ * @param array|string|null $options An array consisting of a key named headers
+ * listing extra headers. If a string, taken as content-disposition header.
+ * (Support for array of options new in 1.23)
+ * @return Status
+ */
+ final public function quickImport( $src, $dst, $options = null ) {
+ return $this->quickImportBatch( [ [ $src, $dst, $options ] ] );
+ }
+
+ /**
+ * Purge a file from the repo. This does no locking nor journaling.
+ * This function can be used to write to otherwise read-only foreign repos.
+ * This is intended for purging thumbnails.
+ *
+ * @param string $path Virtual URL or storage path
+ * @return Status
+ */
+ final public function quickPurge( $path ) {
+ return $this->quickPurgeBatch( [ $path ] );
+ }
+
+ /**
+ * Deletes a directory if empty.
+ * This function can be used to write to otherwise read-only foreign repos.
+ *
+ * @param string $dir Virtual URL (or storage path) of directory to clean
+ * @return Status
+ */
+ public function quickCleanDir( $dir ) {
+ $status = $this->newGood();
+ $status->merge( $this->backend->clean(
+ [ 'dir' => $this->resolveToStoragePath( $dir ) ] ) );
+
+ return $status;
+ }
+
+ /**
+ * Import a batch of files from the local file system into the repo.
+ * This does no locking nor journaling and overrides existing files.
+ * This function can be used to write to otherwise read-only foreign repos.
+ * This is intended for copying generated thumbnails into the repo.
+ *
+ * All path parameters may be a file system path, storage path, or virtual URL.
+ * When "headers" are given they are used as HTTP headers if supported.
+ *
+ * @param array $triples List of (source path or FSFile, destination path, disposition)
+ * @return Status
+ */
+ public function quickImportBatch( array $triples ) {
+ $status = $this->newGood();
+ $operations = [];
+ foreach ( $triples as $triple ) {
+ list( $src, $dst ) = $triple;
+ if ( $src instanceof FSFile ) {
+ $op = 'store';
+ } else {
+ $src = $this->resolveToStoragePath( $src );
+ $op = FileBackend::isStoragePath( $src ) ? 'copy' : 'store';
+ }
+ $dst = $this->resolveToStoragePath( $dst );
+
+ if ( !isset( $triple[2] ) ) {
+ $headers = [];
+ } elseif ( is_string( $triple[2] ) ) {
+ // back-compat
+ $headers = [ 'Content-Disposition' => $triple[2] ];
+ } elseif ( is_array( $triple[2] ) && isset( $triple[2]['headers'] ) ) {
+ $headers = $triple[2]['headers'];
+ } else {
+ $headers = [];
+ }
+
+ $operations[] = [
+ 'op' => $op,
+ 'src' => $src,
+ 'dst' => $dst,
+ 'headers' => $headers
+ ];
+ $status->merge( $this->initDirectory( dirname( $dst ) ) );
+ }
+ $status->merge( $this->backend->doQuickOperations( $operations ) );
+
+ return $status;
+ }
+
+ /**
+ * Purge a batch of files from the repo.
+ * This function can be used to write to otherwise read-only foreign repos.
+ * This does no locking nor journaling and is intended for purging thumbnails.
+ *
+ * @param array $paths List of virtual URLs or storage paths
+ * @return Status
+ */
+ public function quickPurgeBatch( array $paths ) {
+ $status = $this->newGood();
+ $operations = [];
+ foreach ( $paths as $path ) {
+ $operations[] = [
+ 'op' => 'delete',
+ 'src' => $this->resolveToStoragePath( $path ),
+ 'ignoreMissingSource' => true
+ ];
+ }
+ $status->merge( $this->backend->doQuickOperations( $operations ) );
+
+ return $status;
+ }
+
+ /**
+ * Pick a random name in the temp zone and store a file to it.
+ * Returns a Status object with the file Virtual URL in the value,
+ * file can later be disposed using FileRepo::freeTemp().
+ *
+ * @param string $originalName The base name of the file as specified
+ * by the user. The file extension will be maintained.
+ * @param string $srcPath The current location of the file.
+ * @return Status Object with the URL in the value.
+ */
+ public function storeTemp( $originalName, $srcPath ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ $date = MWTimestamp::getInstance()->format( 'YmdHis' );
+ $hashPath = $this->getHashPath( $originalName );
+ $dstUrlRel = $hashPath . $date . '!' . rawurlencode( $originalName );
+ $virtualUrl = $this->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel;
+
+ $result = $this->quickImport( $srcPath, $virtualUrl );
+ $result->value = $virtualUrl;
+
+ return $result;
+ }
+
+ /**
+ * Remove a temporary file or mark it for garbage collection
+ *
+ * @param string $virtualUrl The virtual URL returned by FileRepo::storeTemp()
+ * @return bool True on success, false on failure
+ */
+ public function freeTemp( $virtualUrl ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ $temp = $this->getVirtualUrl( 'temp' );
+ if ( substr( $virtualUrl, 0, strlen( $temp ) ) != $temp ) {
+ wfDebug( __METHOD__ . ": Invalid temp virtual URL\n" );
+
+ return false;
+ }
+
+ return $this->quickPurge( $virtualUrl )->isOK();
+ }
+
+ /**
+ * Concatenate a list of temporary files into a target file location.
+ *
+ * @param array $srcPaths Ordered list of source virtual URLs/storage paths
+ * @param string $dstPath Target file system path
+ * @param int $flags Bitwise combination of the following flags:
+ * self::DELETE_SOURCE Delete the source files on success
+ * @return Status
+ */
+ public function concatenate( array $srcPaths, $dstPath, $flags = 0 ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ $status = $this->newGood();
+
+ $sources = [];
+ foreach ( $srcPaths as $srcPath ) {
+ // Resolve source to a storage path if virtual
+ $source = $this->resolveToStoragePath( $srcPath );
+ $sources[] = $source; // chunk to merge
+ }
+
+ // Concatenate the chunks into one FS file
+ $params = [ 'srcs' => $sources, 'dst' => $dstPath ];
+ $status->merge( $this->backend->concatenate( $params ) );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // Delete the sources if required
+ if ( $flags & self::DELETE_SOURCE ) {
+ $status->merge( $this->quickPurgeBatch( $srcPaths ) );
+ }
+
+ // Make sure status is OK, despite any quickPurgeBatch() fatals
+ $status->setResult( true );
+
+ return $status;
+ }
+
+ /**
+ * Copy or move a file either from a storage path, virtual URL,
+ * or file system path, into this repository at the specified destination location.
+ *
+ * Returns a Status object. On success, the value contains "new" or
+ * "archived", to indicate whether the file was new with that name.
+ *
+ * Options to $options include:
+ * - headers : name/value map of HTTP headers to use in response to GET/HEAD requests
+ *
+ * @param string|FSFile $src The source file system path, storage path, or URL
+ * @param string $dstRel The destination relative path
+ * @param string $archiveRel The relative path where the existing file is to
+ * be archived, if there is one. Relative to the public zone root.
+ * @param int $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate
+ * that the source file should be deleted if possible
+ * @param array $options Optional additional parameters
+ * @return Status
+ */
+ public function publish(
+ $src, $dstRel, $archiveRel, $flags = 0, array $options = []
+ ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ $status = $this->publishBatch(
+ [ [ $src, $dstRel, $archiveRel, $options ] ], $flags );
+ if ( $status->successCount == 0 ) {
+ $status->setOK( false );
+ }
+ if ( isset( $status->value[0] ) ) {
+ $status->value = $status->value[0];
+ } else {
+ $status->value = false;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Publish a batch of files
+ *
+ * @param array $ntuples (source, dest, archive) triplets or
+ * (source, dest, archive, options) 4-tuples as per publish().
+ * @param int $flags Bitfield, may be FileRepo::DELETE_SOURCE to indicate
+ * that the source files should be deleted if possible
+ * @throws MWException
+ * @return Status
+ */
+ public function publishBatch( array $ntuples, $flags = 0 ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ $backend = $this->backend; // convenience
+ // Try creating directories
+ $status = $this->initZones( 'public' );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $status = $this->newGood( [] );
+
+ $operations = [];
+ $sourceFSFilesToDelete = []; // cleanup for disk source files
+ // Validate each triplet and get the store operation...
+ foreach ( $ntuples as $ntuple ) {
+ list( $src, $dstRel, $archiveRel ) = $ntuple;
+ $srcPath = ( $src instanceof FSFile ) ? $src->getPath() : $src;
+
+ $options = isset( $ntuple[3] ) ? $ntuple[3] : [];
+ // Resolve source to a storage path if virtual
+ $srcPath = $this->resolveToStoragePath( $srcPath );
+ if ( !$this->validateFilename( $dstRel ) ) {
+ throw new MWException( 'Validation error in $dstRel' );
+ }
+ if ( !$this->validateFilename( $archiveRel ) ) {
+ throw new MWException( 'Validation error in $archiveRel' );
+ }
+
+ $publicRoot = $this->getZonePath( 'public' );
+ $dstPath = "$publicRoot/$dstRel";
+ $archivePath = "$publicRoot/$archiveRel";
+
+ $dstDir = dirname( $dstPath );
+ $archiveDir = dirname( $archivePath );
+ // Abort immediately on directory creation errors since they're likely to be repetitive
+ if ( !$this->initDirectory( $dstDir )->isOK() ) {
+ return $this->newFatal( 'directorycreateerror', $dstDir );
+ }
+ if ( !$this->initDirectory( $archiveDir )->isOK() ) {
+ return $this->newFatal( 'directorycreateerror', $archiveDir );
+ }
+
+ // Set any desired headers to be use in GET/HEAD responses
+ $headers = isset( $options['headers'] ) ? $options['headers'] : [];
+
+ // Archive destination file if it exists.
+ // This will check if the archive file also exists and fail if does.
+ // This is a sanity check to avoid data loss. On Windows and Linux,
+ // copy() will overwrite, so the existence check is vulnerable to
+ // race conditions unless a functioning LockManager is used.
+ // LocalFile also uses SELECT FOR UPDATE for synchronization.
+ $operations[] = [
+ 'op' => 'copy',
+ 'src' => $dstPath,
+ 'dst' => $archivePath,
+ 'ignoreMissingSource' => true
+ ];
+
+ // Copy (or move) the source file to the destination
+ if ( FileBackend::isStoragePath( $srcPath ) ) {
+ if ( $flags & self::DELETE_SOURCE ) {
+ $operations[] = [
+ 'op' => 'move',
+ 'src' => $srcPath,
+ 'dst' => $dstPath,
+ 'overwrite' => true, // replace current
+ 'headers' => $headers
+ ];
+ } else {
+ $operations[] = [
+ 'op' => 'copy',
+ 'src' => $srcPath,
+ 'dst' => $dstPath,
+ 'overwrite' => true, // replace current
+ 'headers' => $headers
+ ];
+ }
+ } else { // FS source path
+ $operations[] = [
+ 'op' => 'store',
+ 'src' => $src, // prefer FSFile objects
+ 'dst' => $dstPath,
+ 'overwrite' => true, // replace current
+ 'headers' => $headers
+ ];
+ if ( $flags & self::DELETE_SOURCE ) {
+ $sourceFSFilesToDelete[] = $srcPath;
+ }
+ }
+ }
+
+ // Execute the operations for each triplet
+ $status->merge( $backend->doOperations( $operations ) );
+ // Find out which files were archived...
+ foreach ( $ntuples as $i => $ntuple ) {
+ list( , , $archiveRel ) = $ntuple;
+ $archivePath = $this->getZonePath( 'public' ) . "/$archiveRel";
+ if ( $this->fileExists( $archivePath ) ) {
+ $status->value[$i] = 'archived';
+ } else {
+ $status->value[$i] = 'new';
+ }
+ }
+ // Cleanup for disk source files...
+ foreach ( $sourceFSFilesToDelete as $file ) {
+ MediaWiki\suppressWarnings();
+ unlink( $file ); // FS cleanup
+ MediaWiki\restoreWarnings();
+ }
+
+ return $status;
+ }
+
+ /**
+ * Creates a directory with the appropriate zone permissions.
+ * Callers are responsible for doing read-only and "writable repo" checks.
+ *
+ * @param string $dir Virtual URL (or storage path) of directory to clean
+ * @return Status
+ */
+ protected function initDirectory( $dir ) {
+ $path = $this->resolveToStoragePath( $dir );
+ list( , $container, ) = FileBackend::splitStoragePath( $path );
+
+ $params = [ 'dir' => $path ];
+ if ( $this->isPrivate
+ || $container === $this->zones['deleted']['container']
+ || $container === $this->zones['temp']['container']
+ ) {
+ # Take all available measures to prevent web accessibility of new deleted
+ # directories, in case the user has not configured offline storage
+ $params = [ 'noAccess' => true, 'noListing' => true ] + $params;
+ }
+
+ $status = $this->newGood();
+ $status->merge( $this->backend->prepare( $params ) );
+
+ return $status;
+ }
+
+ /**
+ * Deletes a directory if empty.
+ *
+ * @param string $dir Virtual URL (or storage path) of directory to clean
+ * @return Status
+ */
+ public function cleanDir( $dir ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ $status = $this->newGood();
+ $status->merge( $this->backend->clean(
+ [ 'dir' => $this->resolveToStoragePath( $dir ) ] ) );
+
+ return $status;
+ }
+
+ /**
+ * Checks existence of a a file
+ *
+ * @param string $file Virtual URL (or storage path) of file to check
+ * @return bool
+ */
+ public function fileExists( $file ) {
+ $result = $this->fileExistsBatch( [ $file ] );
+
+ return $result[0];
+ }
+
+ /**
+ * Checks existence of an array of files.
+ *
+ * @param array $files Virtual URLs (or storage paths) of files to check
+ * @return array Map of files and existence flags, or false
+ */
+ public function fileExistsBatch( array $files ) {
+ $paths = array_map( [ $this, 'resolveToStoragePath' ], $files );
+ $this->backend->preloadFileStat( [ 'srcs' => $paths ] );
+
+ $result = [];
+ foreach ( $files as $key => $file ) {
+ $path = $this->resolveToStoragePath( $file );
+ $result[$key] = $this->backend->fileExists( [ 'src' => $path ] );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Move a file to the deletion archive.
+ * If no valid deletion archive exists, this may either delete the file
+ * or throw an exception, depending on the preference of the repository
+ *
+ * @param mixed $srcRel Relative path for the file to be deleted
+ * @param mixed $archiveRel Relative path for the archive location.
+ * Relative to a private archive directory.
+ * @return Status
+ */
+ public function delete( $srcRel, $archiveRel ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ return $this->deleteBatch( [ [ $srcRel, $archiveRel ] ] );
+ }
+
+ /**
+ * Move a group of files to the deletion archive.
+ *
+ * If no valid deletion archive is configured, this may either delete the
+ * file or throw an exception, depending on the preference of the repository.
+ *
+ * The overwrite policy is determined by the repository -- currently LocalRepo
+ * assumes a naming scheme in the deleted zone based on content hash, as
+ * opposed to the public zone which is assumed to be unique.
+ *
+ * @param array $sourceDestPairs Array of source/destination pairs. Each element
+ * is a two-element array containing the source file path relative to the
+ * public root in the first element, and the archive file path relative
+ * to the deleted zone root in the second element.
+ * @throws MWException
+ * @return Status
+ */
+ public function deleteBatch( array $sourceDestPairs ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ // Try creating directories
+ $status = $this->initZones( [ 'public', 'deleted' ] );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $status = $this->newGood();
+
+ $backend = $this->backend; // convenience
+ $operations = [];
+ // Validate filenames and create archive directories
+ foreach ( $sourceDestPairs as $pair ) {
+ list( $srcRel, $archiveRel ) = $pair;
+ if ( !$this->validateFilename( $srcRel ) ) {
+ throw new MWException( __METHOD__ . ':Validation error in $srcRel' );
+ } elseif ( !$this->validateFilename( $archiveRel ) ) {
+ throw new MWException( __METHOD__ . ':Validation error in $archiveRel' );
+ }
+
+ $publicRoot = $this->getZonePath( 'public' );
+ $srcPath = "{$publicRoot}/$srcRel";
+
+ $deletedRoot = $this->getZonePath( 'deleted' );
+ $archivePath = "{$deletedRoot}/{$archiveRel}";
+ $archiveDir = dirname( $archivePath ); // does not touch FS
+
+ // Create destination directories
+ if ( !$this->initDirectory( $archiveDir )->isOK() ) {
+ return $this->newFatal( 'directorycreateerror', $archiveDir );
+ }
+
+ $operations[] = [
+ 'op' => 'move',
+ 'src' => $srcPath,
+ 'dst' => $archivePath,
+ // We may have 2+ identical files being deleted,
+ // all of which will map to the same destination file
+ 'overwriteSame' => true // also see T33792
+ ];
+ }
+
+ // Move the files by execute the operations for each pair.
+ // We're now committed to returning an OK result, which will
+ // lead to the files being moved in the DB also.
+ $opts = [ 'force' => true ];
+ $status->merge( $backend->doOperations( $operations, $opts ) );
+
+ return $status;
+ }
+
+ /**
+ * Delete files in the deleted directory if they are not referenced in the filearchive table
+ *
+ * STUB
+ * @param array $storageKeys
+ */
+ public function cleanupDeletedBatch( array $storageKeys ) {
+ $this->assertWritableRepo();
+ }
+
+ /**
+ * Get a relative path for a deletion archive key,
+ * e.g. s/z/a/ for sza251lrxrc1jad41h5mgilp8nysje52.jpg
+ *
+ * @param string $key
+ * @throws MWException
+ * @return string
+ */
+ public function getDeletedHashPath( $key ) {
+ if ( strlen( $key ) < 31 ) {
+ throw new MWException( "Invalid storage key '$key'." );
+ }
+ $path = '';
+ for ( $i = 0; $i < $this->deletedHashLevels; $i++ ) {
+ $path .= $key[$i] . '/';
+ }
+
+ return $path;
+ }
+
+ /**
+ * If a path is a virtual URL, resolve it to a storage path.
+ * Otherwise, just return the path as it is.
+ *
+ * @param string $path
+ * @return string
+ * @throws MWException
+ */
+ protected function resolveToStoragePath( $path ) {
+ if ( $this->isVirtualUrl( $path ) ) {
+ return $this->resolveVirtualUrl( $path );
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get a local FS copy of a file with a given virtual URL/storage path.
+ * Temporary files may be purged when the file object falls out of scope.
+ *
+ * @param string $virtualUrl
+ * @return TempFSFile|null Returns null on failure
+ */
+ public function getLocalCopy( $virtualUrl ) {
+ $path = $this->resolveToStoragePath( $virtualUrl );
+
+ return $this->backend->getLocalCopy( [ 'src' => $path ] );
+ }
+
+ /**
+ * Get a local FS file with a given virtual URL/storage path.
+ * The file is either an original or a copy. It should not be changed.
+ * Temporary files may be purged when the file object falls out of scope.
+ *
+ * @param string $virtualUrl
+ * @return FSFile|null Returns null on failure.
+ */
+ public function getLocalReference( $virtualUrl ) {
+ $path = $this->resolveToStoragePath( $virtualUrl );
+
+ return $this->backend->getLocalReference( [ 'src' => $path ] );
+ }
+
+ /**
+ * Get properties of a file with a given virtual URL/storage path.
+ * Properties should ultimately be obtained via FSFile::getProps().
+ *
+ * @param string $virtualUrl
+ * @return array
+ */
+ public function getFileProps( $virtualUrl ) {
+ $fsFile = $this->getLocalReference( $virtualUrl );
+ $mwProps = new MWFileProps( MimeMagic::singleton() );
+ if ( $fsFile ) {
+ $props = $mwProps->getPropsFromPath( $fsFile->getPath(), true );
+ } else {
+ $props = $mwProps->newPlaceholderProps();
+ }
+
+ return $props;
+ }
+
+ /**
+ * Get the timestamp of a file with a given virtual URL/storage path
+ *
+ * @param string $virtualUrl
+ * @return string|bool False on failure
+ */
+ public function getFileTimestamp( $virtualUrl ) {
+ $path = $this->resolveToStoragePath( $virtualUrl );
+
+ return $this->backend->getFileTimestamp( [ 'src' => $path ] );
+ }
+
+ /**
+ * Get the size of a file with a given virtual URL/storage path
+ *
+ * @param string $virtualUrl
+ * @return int|bool False on failure
+ */
+ public function getFileSize( $virtualUrl ) {
+ $path = $this->resolveToStoragePath( $virtualUrl );
+
+ return $this->backend->getFileSize( [ 'src' => $path ] );
+ }
+
+ /**
+ * Get the sha1 (base 36) of a file with a given virtual URL/storage path
+ *
+ * @param string $virtualUrl
+ * @return string|bool
+ */
+ public function getFileSha1( $virtualUrl ) {
+ $path = $this->resolveToStoragePath( $virtualUrl );
+
+ return $this->backend->getFileSha1Base36( [ 'src' => $path ] );
+ }
+
+ /**
+ * Attempt to stream a file with the given virtual URL/storage path
+ *
+ * @param string $virtualUrl
+ * @param array $headers Additional HTTP headers to send on success
+ * @param array $optHeaders HTTP request headers (if-modified-since, range, ...)
+ * @return Status
+ * @since 1.27
+ */
+ public function streamFileWithStatus( $virtualUrl, $headers = [], $optHeaders = [] ) {
+ $path = $this->resolveToStoragePath( $virtualUrl );
+ $params = [ 'src' => $path, 'headers' => $headers, 'options' => $optHeaders ];
+
+ // T172851: HHVM does not flush the output properly, causing OOM
+ ob_start( null, 1048576 );
+ ob_implicit_flush( true );
+
+ $status = $this->newGood();
+ $status->merge( $this->backend->streamFile( $params ) );
+
+ // T186565: Close the buffer, unless it has already been closed
+ // in HTTPFileStreamer::resetOutputBuffers().
+ if ( ob_get_status() ) {
+ ob_end_flush();
+ }
+
+ return $status;
+ }
+
+ /**
+ * Attempt to stream a file with the given virtual URL/storage path
+ *
+ * @deprecated since 1.26, use streamFileWithStatus
+ * @param string $virtualUrl
+ * @param array $headers Additional HTTP headers to send on success
+ * @return bool Success
+ */
+ public function streamFile( $virtualUrl, $headers = [] ) {
+ return $this->streamFileWithStatus( $virtualUrl, $headers )->isOK();
+ }
+
+ /**
+ * Call a callback function for every public regular file in the repository.
+ * This only acts on the current version of files, not any old versions.
+ * May use either the database or the filesystem.
+ *
+ * @param callable $callback
+ * @return void
+ */
+ public function enumFiles( $callback ) {
+ $this->enumFilesInStorage( $callback );
+ }
+
+ /**
+ * Call a callback function for every public file in the repository.
+ * May use either the database or the filesystem.
+ *
+ * @param callable $callback
+ * @return void
+ */
+ protected function enumFilesInStorage( $callback ) {
+ $publicRoot = $this->getZonePath( 'public' );
+ $numDirs = 1 << ( $this->hashLevels * 4 );
+ // Use a priori assumptions about directory structure
+ // to reduce the tree height of the scanning process.
+ for ( $flatIndex = 0; $flatIndex < $numDirs; $flatIndex++ ) {
+ $hexString = sprintf( "%0{$this->hashLevels}x", $flatIndex );
+ $path = $publicRoot;
+ for ( $hexPos = 0; $hexPos < $this->hashLevels; $hexPos++ ) {
+ $path .= '/' . substr( $hexString, 0, $hexPos + 1 );
+ }
+ $iterator = $this->backend->getFileList( [ 'dir' => $path ] );
+ foreach ( $iterator as $name ) {
+ // Each item returned is a public file
+ call_user_func( $callback, "{$path}/{$name}" );
+ }
+ }
+ }
+
+ /**
+ * Determine if a relative path is valid, i.e. not blank or involving directory traveral
+ *
+ * @param string $filename
+ * @return bool
+ */
+ public function validateFilename( $filename ) {
+ if ( strval( $filename ) == '' ) {
+ return false;
+ }
+
+ return FileBackend::isPathTraversalFree( $filename );
+ }
+
+ /**
+ * Get a callback function to use for cleaning error message parameters
+ *
+ * @return array
+ */
+ function getErrorCleanupFunction() {
+ switch ( $this->pathDisclosureProtection ) {
+ case 'none':
+ case 'simple': // b/c
+ $callback = [ $this, 'passThrough' ];
+ break;
+ default: // 'paranoid'
+ $callback = [ $this, 'paranoidClean' ];
+ }
+ return $callback;
+ }
+
+ /**
+ * Path disclosure protection function
+ *
+ * @param string $param
+ * @return string
+ */
+ function paranoidClean( $param ) {
+ return '[hidden]';
+ }
+
+ /**
+ * Path disclosure protection function
+ *
+ * @param string $param
+ * @return string
+ */
+ function passThrough( $param ) {
+ return $param;
+ }
+
+ /**
+ * Create a new fatal error
+ *
+ * @param string $message
+ * @return Status
+ */
+ public function newFatal( $message /*, parameters...*/ ) {
+ $status = call_user_func_array( [ 'Status', 'newFatal' ], func_get_args() );
+ $status->cleanCallback = $this->getErrorCleanupFunction();
+
+ return $status;
+ }
+
+ /**
+ * Create a new good result
+ *
+ * @param null|string $value
+ * @return Status
+ */
+ public function newGood( $value = null ) {
+ $status = Status::newGood( $value );
+ $status->cleanCallback = $this->getErrorCleanupFunction();
+
+ return $status;
+ }
+
+ /**
+ * Checks if there is a redirect named as $title. If there is, return the
+ * title object. If not, return false.
+ * STUB
+ *
+ * @param Title $title Title of image
+ * @return bool
+ */
+ public function checkRedirect( Title $title ) {
+ return false;
+ }
+
+ /**
+ * Invalidates image redirect cache related to that image
+ * Doesn't do anything for repositories that don't support image redirects.
+ *
+ * STUB
+ * @param Title $title Title of image
+ */
+ public function invalidateImageRedirect( Title $title ) {
+ }
+
+ /**
+ * Get the human-readable name of the repo
+ *
+ * @return string
+ */
+ public function getDisplayName() {
+ global $wgSitename;
+
+ if ( $this->isLocal() ) {
+ return $wgSitename;
+ }
+
+ // 'shared-repo-name-wikimediacommons' is used when $wgUseInstantCommons = true
+ return wfMessageFallback( 'shared-repo-name-' . $this->name, 'shared-repo' )->text();
+ }
+
+ /**
+ * Get the portion of the file that contains the origin file name.
+ * If that name is too long, then the name "thumbnail.<ext>" will be given.
+ *
+ * @param string $name
+ * @return string
+ */
+ public function nameForThumb( $name ) {
+ if ( strlen( $name ) > $this->abbrvThreshold ) {
+ $ext = FileBackend::extensionFromPath( $name );
+ $name = ( $ext == '' ) ? 'thumbnail' : "thumbnail.$ext";
+ }
+
+ return $name;
+ }
+
+ /**
+ * Returns true if this the local file repository.
+ *
+ * @return bool
+ */
+ public function isLocal() {
+ return $this->getName() == 'local';
+ }
+
+ /**
+ * Get a key on the primary cache for this repository.
+ * Returns false if the repository's cache is not accessible at this site.
+ * The parameters are the parts of the key, as for wfMemcKey().
+ *
+ * STUB
+ * @return bool
+ */
+ public function getSharedCacheKey( /*...*/ ) {
+ return false;
+ }
+
+ /**
+ * Get a key for this repo in the local cache domain. These cache keys are
+ * not shared with remote instances of the repo.
+ * The parameters are the parts of the key, as for wfMemcKey().
+ *
+ * @return string
+ */
+ public function getLocalCacheKey( /*...*/ ) {
+ $args = func_get_args();
+ array_unshift( $args, 'filerepo', $this->getName() );
+
+ return call_user_func_array( 'wfMemcKey', $args );
+ }
+
+ /**
+ * Get a temporary private FileRepo associated with this repo.
+ *
+ * Files will be created in the temp zone of this repo.
+ * It will have the same backend as this repo.
+ *
+ * @return TempFileRepo
+ */
+ public function getTempRepo() {
+ return new TempFileRepo( [
+ 'name' => "{$this->name}-temp",
+ 'backend' => $this->backend,
+ 'zones' => [
+ 'public' => [
+ // Same place storeTemp() uses in the base repo, though
+ // the path hashing is mismatched, which is annoying.
+ 'container' => $this->zones['temp']['container'],
+ 'directory' => $this->zones['temp']['directory']
+ ],
+ 'thumb' => [
+ 'container' => $this->zones['temp']['container'],
+ 'directory' => $this->zones['temp']['directory'] == ''
+ ? 'thumb'
+ : $this->zones['temp']['directory'] . '/thumb'
+ ],
+ 'transcoded' => [
+ 'container' => $this->zones['temp']['container'],
+ 'directory' => $this->zones['temp']['directory'] == ''
+ ? 'transcoded'
+ : $this->zones['temp']['directory'] . '/transcoded'
+ ]
+ ],
+ 'hashLevels' => $this->hashLevels, // performance
+ 'isPrivate' => true // all in temp zone
+ ] );
+ }
+
+ /**
+ * Get an UploadStash associated with this repo.
+ *
+ * @param User $user
+ * @return UploadStash
+ */
+ public function getUploadStash( User $user = null ) {
+ return new UploadStash( $this, $user );
+ }
+
+ /**
+ * Throw an exception if this repo is read-only by design.
+ * This does not and should not check getReadOnlyReason().
+ *
+ * @return void
+ * @throws MWException
+ */
+ protected function assertWritableRepo() {
+ }
+
+ /**
+ * Return information about the repository.
+ *
+ * @return array
+ * @since 1.22
+ */
+ public function getInfo() {
+ $ret = [
+ 'name' => $this->getName(),
+ 'displayname' => $this->getDisplayName(),
+ 'rootUrl' => $this->getZoneUrl( 'public' ),
+ 'local' => $this->isLocal(),
+ ];
+
+ $optionalSettings = [
+ 'url', 'thumbUrl', 'initialCapital', 'descBaseUrl', 'scriptDirUrl', 'articleUrl',
+ 'fetchDescription', 'descriptionCacheExpiry', 'scriptExtension', 'favicon'
+ ];
+ foreach ( $optionalSettings as $k ) {
+ if ( isset( $this->$k ) ) {
+ $ret[$k] = $this->$k;
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Returns whether or not storage is SHA-1 based
+ * @return bool
+ */
+ public function hasSha1Storage() {
+ return $this->hasSha1Storage;
+ }
+
+ /**
+ * Returns whether or not repo supports having originals SHA-1s in the thumb URLs
+ * @return bool
+ */
+ public function supportsSha1URLs() {
+ return $this->supportsSha1URLs;
+ }
+}
diff --git a/www/wiki/includes/filerepo/FileRepoStatus.php b/www/wiki/includes/filerepo/FileRepoStatus.php
new file mode 100644
index 00000000..538e9bc9
--- /dev/null
+++ b/www/wiki/includes/filerepo/FileRepoStatus.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * Generic operation result for FileRepo-related operations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+/**
+ * Generic operation result class for FileRepo-related operations
+ * @ingroup FileRepo
+ * @deprecated since 1.25
+ */
+class FileRepoStatus extends Status {
+}
diff --git a/www/wiki/includes/filerepo/ForeignAPIRepo.php b/www/wiki/includes/filerepo/ForeignAPIRepo.php
new file mode 100644
index 00000000..45a5c824
--- /dev/null
+++ b/www/wiki/includes/filerepo/ForeignAPIRepo.php
@@ -0,0 +1,605 @@
+<?php
+/**
+ * Foreign repository accessible through api.php requests.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * A foreign repository with a remote MediaWiki with an API thingy
+ *
+ * Example config:
+ *
+ * $wgForeignFileRepos[] = [
+ * 'class' => 'ForeignAPIRepo',
+ * 'name' => 'shared',
+ * 'apibase' => 'https://en.wikipedia.org/w/api.php',
+ * 'fetchDescription' => true, // Optional
+ * 'descriptionCacheExpiry' => 3600,
+ * ];
+ *
+ * @ingroup FileRepo
+ */
+class ForeignAPIRepo extends FileRepo {
+ /* This version string is used in the user agent for requests and will help
+ * server maintainers in identify ForeignAPI usage.
+ * Update the version every time you make breaking or significant changes. */
+ const VERSION = "2.1";
+
+ /**
+ * List of iiprop values for the thumbnail fetch queries.
+ * @since 1.23
+ */
+ protected static $imageInfoProps = [
+ 'url',
+ 'timestamp',
+ ];
+
+ protected $fileFactory = [ 'ForeignAPIFile', 'newFromTitle' ];
+ /** @var int Check back with Commons after this expiry */
+ protected $apiThumbCacheExpiry = 86400; // 1 day (24*3600)
+
+ /** @var int Redownload thumbnail files after this expiry */
+ protected $fileCacheExpiry = 2592000; // 1 month (30*24*3600)
+
+ /** @var array */
+ protected $mFileExists = [];
+
+ /** @var string */
+ private $mApiBase;
+
+ /**
+ * @param array|null $info
+ */
+ function __construct( $info ) {
+ global $wgLocalFileRepo;
+ parent::__construct( $info );
+
+ // https://commons.wikimedia.org/w/api.php
+ $this->mApiBase = isset( $info['apibase'] ) ? $info['apibase'] : null;
+
+ if ( isset( $info['apiThumbCacheExpiry'] ) ) {
+ $this->apiThumbCacheExpiry = $info['apiThumbCacheExpiry'];
+ }
+ if ( isset( $info['fileCacheExpiry'] ) ) {
+ $this->fileCacheExpiry = $info['fileCacheExpiry'];
+ }
+ if ( !$this->scriptDirUrl ) {
+ // hack for description fetches
+ $this->scriptDirUrl = dirname( $this->mApiBase );
+ }
+ // If we can cache thumbs we can guess sane defaults for these
+ if ( $this->canCacheThumbs() && !$this->url ) {
+ $this->url = $wgLocalFileRepo['url'];
+ }
+ if ( $this->canCacheThumbs() && !$this->thumbUrl ) {
+ $this->thumbUrl = $this->url . '/thumb';
+ }
+ }
+
+ /**
+ * @return string
+ * @since 1.22
+ */
+ function getApiUrl() {
+ return $this->mApiBase;
+ }
+
+ /**
+ * Per docs in FileRepo, this needs to return false if we don't support versioned
+ * files. Well, we don't.
+ *
+ * @param Title $title
+ * @param string|bool $time
+ * @return File|false
+ */
+ function newFile( $title, $time = false ) {
+ if ( $time ) {
+ return false;
+ }
+
+ return parent::newFile( $title, $time );
+ }
+
+ /**
+ * @param array $files
+ * @return array
+ */
+ function fileExistsBatch( array $files ) {
+ $results = [];
+ foreach ( $files as $k => $f ) {
+ if ( isset( $this->mFileExists[$f] ) ) {
+ $results[$k] = $this->mFileExists[$f];
+ unset( $files[$k] );
+ } elseif ( self::isVirtualUrl( $f ) ) {
+ # @todo FIXME: We need to be able to handle virtual
+ # URLs better, at least when we know they refer to the
+ # same repo.
+ $results[$k] = false;
+ unset( $files[$k] );
+ } elseif ( FileBackend::isStoragePath( $f ) ) {
+ $results[$k] = false;
+ unset( $files[$k] );
+ wfWarn( "Got mwstore:// path '$f'." );
+ }
+ }
+
+ $data = $this->fetchImageQuery( [
+ 'titles' => implode( $files, '|' ),
+ 'prop' => 'imageinfo' ]
+ );
+
+ if ( isset( $data['query']['pages'] ) ) {
+ # First, get results from the query. Note we only care whether the image exists,
+ # not whether it has a description page.
+ foreach ( $data['query']['pages'] as $p ) {
+ $this->mFileExists[$p['title']] = ( $p['imagerepository'] !== '' );
+ }
+ # Second, copy the results to any redirects that were queried
+ if ( isset( $data['query']['redirects'] ) ) {
+ foreach ( $data['query']['redirects'] as $r ) {
+ $this->mFileExists[$r['from']] = $this->mFileExists[$r['to']];
+ }
+ }
+ # Third, copy the results to any non-normalized titles that were queried
+ if ( isset( $data['query']['normalized'] ) ) {
+ foreach ( $data['query']['normalized'] as $n ) {
+ $this->mFileExists[$n['from']] = $this->mFileExists[$n['to']];
+ }
+ }
+ # Finally, copy the results to the output
+ foreach ( $files as $key => $file ) {
+ $results[$key] = $this->mFileExists[$file];
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * @param string $virtualUrl
+ * @return bool
+ */
+ function getFileProps( $virtualUrl ) {
+ return false;
+ }
+
+ /**
+ * @param array $query
+ * @return string
+ */
+ function fetchImageQuery( $query ) {
+ global $wgLanguageCode;
+
+ $query = array_merge( $query,
+ [
+ 'format' => 'json',
+ 'action' => 'query',
+ 'redirects' => 'true'
+ ] );
+
+ if ( !isset( $query['uselang'] ) ) { // uselang is unset or null
+ $query['uselang'] = $wgLanguageCode;
+ }
+
+ $data = $this->httpGetCached( 'Metadata', $query );
+
+ if ( $data ) {
+ return FormatJson::decode( $data, true );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param array $data
+ * @return bool|array
+ */
+ function getImageInfo( $data ) {
+ if ( $data && isset( $data['query']['pages'] ) ) {
+ foreach ( $data['query']['pages'] as $info ) {
+ if ( isset( $info['imageinfo'][0] ) ) {
+ $return = $info['imageinfo'][0];
+ if ( isset( $info['pageid'] ) ) {
+ $return['pageid'] = $info['pageid'];
+ }
+ return $return;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $hash
+ * @return array
+ */
+ function findBySha1( $hash ) {
+ $results = $this->fetchImageQuery( [
+ 'aisha1base36' => $hash,
+ 'aiprop' => ForeignAPIFile::getProps(),
+ 'list' => 'allimages',
+ ] );
+ $ret = [];
+ if ( isset( $results['query']['allimages'] ) ) {
+ foreach ( $results['query']['allimages'] as $img ) {
+ // 1.14 was broken, doesn't return name attribute
+ if ( !isset( $img['name'] ) ) {
+ continue;
+ }
+ $ret[] = new ForeignAPIFile( Title::makeTitle( NS_FILE, $img['name'] ), $this, $img );
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $name
+ * @param int $width
+ * @param int $height
+ * @param array &$result
+ * @param string $otherParams
+ *
+ * @return bool
+ */
+ function getThumbUrl( $name, $width = -1, $height = -1, &$result = null, $otherParams = '' ) {
+ $data = $this->fetchImageQuery( [
+ 'titles' => 'File:' . $name,
+ 'iiprop' => self::getIIProps(),
+ 'iiurlwidth' => $width,
+ 'iiurlheight' => $height,
+ 'iiurlparam' => $otherParams,
+ 'prop' => 'imageinfo' ] );
+ $info = $this->getImageInfo( $data );
+
+ if ( $data && $info && isset( $info['thumburl'] ) ) {
+ wfDebug( __METHOD__ . " got remote thumb " . $info['thumburl'] . "\n" );
+ $result = $info;
+
+ return $info['thumburl'];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param string $name
+ * @param int $width
+ * @param int $height
+ * @param string $otherParams
+ * @param string $lang Language code for language of error
+ * @return bool|MediaTransformError
+ * @since 1.22
+ */
+ function getThumbError( $name, $width = -1, $height = -1, $otherParams = '', $lang = null ) {
+ $data = $this->fetchImageQuery( [
+ 'titles' => 'File:' . $name,
+ 'iiprop' => self::getIIProps(),
+ 'iiurlwidth' => $width,
+ 'iiurlheight' => $height,
+ 'iiurlparam' => $otherParams,
+ 'prop' => 'imageinfo',
+ 'uselang' => $lang,
+ ] );
+ $info = $this->getImageInfo( $data );
+
+ if ( $data && $info && isset( $info['thumberror'] ) ) {
+ wfDebug( __METHOD__ . " got remote thumb error " . $info['thumberror'] . "\n" );
+
+ return new MediaTransformError(
+ 'thumbnail_error_remote',
+ $width,
+ $height,
+ $this->getDisplayName(),
+ $info['thumberror'] // already parsed message from foreign repo
+ );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Return the imageurl from cache if possible
+ *
+ * If the url has been requested today, get it from cache
+ * Otherwise retrieve remote thumb url, check for local file.
+ *
+ * @param string $name Is a dbkey form of a title
+ * @param int $width
+ * @param int $height
+ * @param string $params Other rendering parameters (page number, etc)
+ * from handler's makeParamString.
+ * @return bool|string
+ */
+ function getThumbUrlFromCache( $name, $width, $height, $params = "" ) {
+ $cache = ObjectCache::getMainWANInstance();
+ // We can't check the local cache using FileRepo functions because
+ // we override fileExistsBatch(). We have to use the FileBackend directly.
+ $backend = $this->getBackend(); // convenience
+
+ if ( !$this->canCacheThumbs() ) {
+ $result = null; // can't pass "null" by reference, but it's ok as default value
+ return $this->getThumbUrl( $name, $width, $height, $result, $params );
+ }
+ $key = $this->getLocalCacheKey( 'ForeignAPIRepo', 'ThumbUrl', $name );
+ $sizekey = "$width:$height:$params";
+
+ /* Get the array of urls that we already know */
+ $knownThumbUrls = $cache->get( $key );
+ if ( !$knownThumbUrls ) {
+ /* No knownThumbUrls for this file */
+ $knownThumbUrls = [];
+ } else {
+ if ( isset( $knownThumbUrls[$sizekey] ) ) {
+ wfDebug( __METHOD__ . ': Got thumburl from local cache: ' .
+ "{$knownThumbUrls[$sizekey]} \n" );
+
+ return $knownThumbUrls[$sizekey];
+ }
+ /* This size is not yet known */
+ }
+
+ $metadata = null;
+ $foreignUrl = $this->getThumbUrl( $name, $width, $height, $metadata, $params );
+
+ if ( !$foreignUrl ) {
+ wfDebug( __METHOD__ . " Could not find thumburl\n" );
+
+ return false;
+ }
+
+ // We need the same filename as the remote one :)
+ $fileName = rawurldecode( pathinfo( $foreignUrl, PATHINFO_BASENAME ) );
+ if ( !$this->validateFilename( $fileName ) ) {
+ wfDebug( __METHOD__ . " The deduced filename $fileName is not safe\n" );
+
+ return false;
+ }
+ $localPath = $this->getZonePath( 'thumb' ) . "/" . $this->getHashPath( $name ) . $name;
+ $localFilename = $localPath . "/" . $fileName;
+ $localUrl = $this->getZoneUrl( 'thumb' ) . "/" . $this->getHashPath( $name ) .
+ rawurlencode( $name ) . "/" . rawurlencode( $fileName );
+
+ if ( $backend->fileExists( [ 'src' => $localFilename ] )
+ && isset( $metadata['timestamp'] )
+ ) {
+ wfDebug( __METHOD__ . " Thumbnail was already downloaded before\n" );
+ $modified = $backend->getFileTimestamp( [ 'src' => $localFilename ] );
+ $remoteModified = strtotime( $metadata['timestamp'] );
+ $current = time();
+ $diff = abs( $modified - $current );
+ if ( $remoteModified < $modified && $diff < $this->fileCacheExpiry ) {
+ /* Use our current and already downloaded thumbnail */
+ $knownThumbUrls[$sizekey] = $localUrl;
+ $cache->set( $key, $knownThumbUrls, $this->apiThumbCacheExpiry );
+
+ return $localUrl;
+ }
+ /* There is a new Commons file, or existing thumbnail older than a month */
+ }
+
+ $thumb = self::httpGet( $foreignUrl, 'default', [], $mtime );
+ if ( !$thumb ) {
+ wfDebug( __METHOD__ . " Could not download thumb\n" );
+
+ return false;
+ }
+
+ # @todo FIXME: Delete old thumbs that aren't being used. Maintenance script?
+ $backend->prepare( [ 'dir' => dirname( $localFilename ) ] );
+ $params = [ 'dst' => $localFilename, 'content' => $thumb ];
+ if ( !$backend->quickCreate( $params )->isOK() ) {
+ wfDebug( __METHOD__ . " could not write to thumb path '$localFilename'\n" );
+
+ return $foreignUrl;
+ }
+ $knownThumbUrls[$sizekey] = $localUrl;
+
+ $ttl = $mtime
+ ? $cache->adaptiveTTL( $mtime, $this->apiThumbCacheExpiry )
+ : $this->apiThumbCacheExpiry;
+ $cache->set( $key, $knownThumbUrls, $ttl );
+ wfDebug( __METHOD__ . " got local thumb $localUrl, saving to cache \n" );
+
+ return $localUrl;
+ }
+
+ /**
+ * @see FileRepo::getZoneUrl()
+ * @param string $zone
+ * @param string|null $ext Optional file extension
+ * @return string
+ */
+ function getZoneUrl( $zone, $ext = null ) {
+ switch ( $zone ) {
+ case 'public':
+ return $this->url;
+ case 'thumb':
+ return $this->thumbUrl;
+ default:
+ return parent::getZoneUrl( $zone, $ext );
+ }
+ }
+
+ /**
+ * Get the local directory corresponding to one of the basic zones
+ * @param string $zone
+ * @return bool|null|string
+ */
+ function getZonePath( $zone ) {
+ $supported = [ 'public', 'thumb' ];
+ if ( in_array( $zone, $supported ) ) {
+ return parent::getZonePath( $zone );
+ }
+
+ return false;
+ }
+
+ /**
+ * Are we locally caching the thumbnails?
+ * @return bool
+ */
+ public function canCacheThumbs() {
+ return ( $this->apiThumbCacheExpiry > 0 );
+ }
+
+ /**
+ * The user agent the ForeignAPIRepo will use.
+ * @return string
+ */
+ public static function getUserAgent() {
+ return Http::userAgent() . " ForeignAPIRepo/" . self::VERSION;
+ }
+
+ /**
+ * Get information about the repo - overrides/extends the parent
+ * class's information.
+ * @return array
+ * @since 1.22
+ */
+ function getInfo() {
+ $info = parent::getInfo();
+ $info['apiurl'] = $this->getApiUrl();
+
+ $query = [
+ 'format' => 'json',
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'siprop' => 'general',
+ ];
+
+ $data = $this->httpGetCached( 'SiteInfo', $query, 7200 );
+
+ if ( $data ) {
+ $siteInfo = FormatJson::decode( $data, true );
+ $general = $siteInfo['query']['general'];
+
+ $info['articlepath'] = $general['articlepath'];
+ $info['server'] = $general['server'];
+
+ if ( isset( $general['favicon'] ) ) {
+ $info['favicon'] = $general['favicon'];
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * Like a Http:get request, but with custom User-Agent.
+ * @see Http::get
+ * @param string $url
+ * @param string $timeout
+ * @param array $options
+ * @param int|bool &$mtime Resulting Last-Modified UNIX timestamp if received
+ * @return bool|string
+ */
+ public static function httpGet(
+ $url, $timeout = 'default', $options = [], &$mtime = false
+ ) {
+ $options['timeout'] = $timeout;
+ /* Http::get */
+ $url = wfExpandUrl( $url, PROTO_HTTP );
+ wfDebug( "ForeignAPIRepo: HTTP GET: $url\n" );
+ $options['method'] = "GET";
+
+ if ( !isset( $options['timeout'] ) ) {
+ $options['timeout'] = 'default';
+ }
+
+ $req = MWHttpRequest::factory( $url, $options, __METHOD__ );
+ $req->setUserAgent( self::getUserAgent() );
+ $status = $req->execute();
+
+ if ( $status->isOK() ) {
+ $lmod = $req->getResponseHeader( 'Last-Modified' );
+ $mtime = $lmod ? wfTimestamp( TS_UNIX, $lmod ) : false;
+
+ return $req->getContent();
+ } else {
+ $logger = LoggerFactory::getInstance( 'http' );
+ $logger->warning(
+ $status->getWikiText( false, false, 'en' ),
+ [ 'caller' => 'ForeignAPIRepo::httpGet' ]
+ );
+
+ return false;
+ }
+ }
+
+ /**
+ * @return string
+ * @since 1.23
+ */
+ protected static function getIIProps() {
+ return implode( '|', self::$imageInfoProps );
+ }
+
+ /**
+ * HTTP GET request to a mediawiki API (with caching)
+ * @param string $target Used in cache key creation, mostly
+ * @param array $query The query parameters for the API request
+ * @param int $cacheTTL Time to live for the memcached caching
+ * @return string|null
+ */
+ public function httpGetCached( $target, $query, $cacheTTL = 3600 ) {
+ if ( $this->mApiBase ) {
+ $url = wfAppendQuery( $this->mApiBase, $query );
+ } else {
+ $url = $this->makeUrl( $query, 'api' );
+ }
+
+ $cache = ObjectCache::getMainWANInstance();
+ return $cache->getWithSetCallback(
+ $this->getLocalCacheKey( static::class, $target, md5( $url ) ),
+ $cacheTTL,
+ function ( $curValue, &$ttl ) use ( $url, $cache ) {
+ $html = self::httpGet( $url, 'default', [], $mtime );
+ if ( $html !== false ) {
+ $ttl = $mtime ? $cache->adaptiveTTL( $mtime, $ttl ) : $ttl;
+ } else {
+ $ttl = $cache->adaptiveTTL( $mtime, $ttl );
+ $html = null; // caches negatives
+ }
+
+ return $html;
+ },
+ [ 'pcTTL' => $cache::TTL_PROC_LONG ]
+ );
+ }
+
+ /**
+ * @param callable $callback
+ * @throws MWException
+ */
+ function enumFiles( $callback ) {
+ throw new MWException( 'enumFiles is not supported by ' . static::class );
+ }
+
+ /**
+ * @throws MWException
+ */
+ protected function assertWritableRepo() {
+ throw new MWException( static::class . ': write operations are not supported.' );
+ }
+}
diff --git a/www/wiki/includes/filerepo/ForeignDBRepo.php b/www/wiki/includes/filerepo/ForeignDBRepo.php
new file mode 100644
index 00000000..bce3005c
--- /dev/null
+++ b/www/wiki/includes/filerepo/ForeignDBRepo.php
@@ -0,0 +1,156 @@
+<?php
+/**
+ * A foreign repository with an accessible MediaWiki database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A foreign repository with an accessible MediaWiki database
+ *
+ * @ingroup FileRepo
+ */
+class ForeignDBRepo extends LocalRepo {
+ /** @var string */
+ protected $dbType;
+
+ /** @var string */
+ protected $dbServer;
+
+ /** @var string */
+ protected $dbUser;
+
+ /** @var string */
+ protected $dbPassword;
+
+ /** @var string */
+ protected $dbName;
+
+ /** @var string */
+ protected $dbFlags;
+
+ /** @var string */
+ protected $tablePrefix;
+
+ /** @var bool */
+ protected $hasSharedCache;
+
+ /** @var IDatabase */
+ protected $dbConn;
+
+ /** @var callable */
+ protected $fileFactory = [ 'ForeignDBFile', 'newFromTitle' ];
+ /** @var callable */
+ protected $fileFromRowFactory = [ 'ForeignDBFile', 'newFromRow' ];
+
+ /**
+ * @param array|null $info
+ */
+ function __construct( $info ) {
+ parent::__construct( $info );
+ $this->dbType = $info['dbType'];
+ $this->dbServer = $info['dbServer'];
+ $this->dbUser = $info['dbUser'];
+ $this->dbPassword = $info['dbPassword'];
+ $this->dbName = $info['dbName'];
+ $this->dbFlags = $info['dbFlags'];
+ $this->tablePrefix = $info['tablePrefix'];
+ $this->hasSharedCache = $info['hasSharedCache'];
+ }
+
+ /**
+ * @return IDatabase
+ */
+ function getMasterDB() {
+ if ( !isset( $this->dbConn ) ) {
+ $func = $this->getDBFactory();
+ $this->dbConn = $func( DB_MASTER );
+ }
+
+ return $this->dbConn;
+ }
+
+ /**
+ * @return IDatabase
+ */
+ function getReplicaDB() {
+ return $this->getMasterDB();
+ }
+
+ /**
+ * @return Closure
+ */
+ protected function getDBFactory() {
+ $type = $this->dbType;
+ $params = [
+ 'host' => $this->dbServer,
+ 'user' => $this->dbUser,
+ 'password' => $this->dbPassword,
+ 'dbname' => $this->dbName,
+ 'flags' => $this->dbFlags,
+ 'tablePrefix' => $this->tablePrefix,
+ 'foreign' => true,
+ ];
+
+ return function ( $index ) use ( $type, $params ) {
+ return Database::factory( $type, $params );
+ };
+ }
+
+ /**
+ * @return bool
+ */
+ function hasSharedCache() {
+ return $this->hasSharedCache;
+ }
+
+ /**
+ * Get a key on the primary cache for this repository.
+ * Returns false if the repository's cache is not accessible at this site.
+ * The parameters are the parts of the key, as for wfMemcKey().
+ * @return bool|mixed
+ */
+ function getSharedCacheKey( /*...*/ ) {
+ if ( $this->hasSharedCache() ) {
+ $args = func_get_args();
+ array_unshift( $args, $this->dbName, $this->tablePrefix );
+
+ return call_user_func_array( 'wfForeignMemcKey', $args );
+ } else {
+ return false;
+ }
+ }
+
+ protected function assertWritableRepo() {
+ throw new MWException( static::class . ': write operations are not supported.' );
+ }
+
+ /**
+ * Return information about the repository.
+ *
+ * @return array
+ * @since 1.22
+ */
+ function getInfo() {
+ return FileRepo::getInfo();
+ }
+}
diff --git a/www/wiki/includes/filerepo/ForeignDBViaLBRepo.php b/www/wiki/includes/filerepo/ForeignDBViaLBRepo.php
new file mode 100644
index 00000000..bcd253fb
--- /dev/null
+++ b/www/wiki/includes/filerepo/ForeignDBViaLBRepo.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * A foreign repository with a MediaWiki database accessible via the configured LBFactory.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+/**
+ * A foreign repository with a MediaWiki database accessible via the configured LBFactory
+ *
+ * @ingroup FileRepo
+ */
+class ForeignDBViaLBRepo extends LocalRepo {
+ /** @var string */
+ protected $wiki;
+
+ /** @var string */
+ protected $dbName;
+
+ /** @var string */
+ protected $tablePrefix;
+
+ /** @var array */
+ protected $fileFactory = [ 'ForeignDBFile', 'newFromTitle' ];
+
+ /** @var array */
+ protected $fileFromRowFactory = [ 'ForeignDBFile', 'newFromRow' ];
+
+ /** @var bool */
+ protected $hasSharedCache;
+
+ /**
+ * @param array|null $info
+ */
+ function __construct( $info ) {
+ parent::__construct( $info );
+ $this->wiki = $info['wiki'];
+ list( $this->dbName, $this->tablePrefix ) = wfSplitWikiID( $this->wiki );
+ $this->hasSharedCache = $info['hasSharedCache'];
+ }
+
+ /**
+ * @return IDatabase
+ */
+ function getMasterDB() {
+ return wfGetLB( $this->wiki )->getConnectionRef( DB_MASTER, [], $this->wiki );
+ }
+
+ /**
+ * @return IDatabase
+ */
+ function getReplicaDB() {
+ return wfGetLB( $this->wiki )->getConnectionRef( DB_REPLICA, [], $this->wiki );
+ }
+
+ /**
+ * @return Closure
+ */
+ protected function getDBFactory() {
+ return function ( $index ) {
+ return wfGetLB( $this->wiki )->getConnectionRef( $index, [], $this->wiki );
+ };
+ }
+
+ function hasSharedCache() {
+ return $this->hasSharedCache;
+ }
+
+ /**
+ * Get a key on the primary cache for this repository.
+ * Returns false if the repository's cache is not accessible at this site.
+ * The parameters are the parts of the key, as for wfMemcKey().
+ * @return bool|string
+ */
+ function getSharedCacheKey( /*...*/ ) {
+ if ( $this->hasSharedCache() ) {
+ $args = func_get_args();
+ array_unshift( $args, $this->wiki );
+
+ return implode( ':', $args );
+ } else {
+ return false;
+ }
+ }
+
+ protected function assertWritableRepo() {
+ throw new MWException( static::class . ': write operations are not supported.' );
+ }
+
+ public function getInfo() {
+ return FileRepo::getInfo();
+ }
+}
diff --git a/www/wiki/includes/filerepo/LocalRepo.php b/www/wiki/includes/filerepo/LocalRepo.php
new file mode 100644
index 00000000..ed007935
--- /dev/null
+++ b/www/wiki/includes/filerepo/LocalRepo.php
@@ -0,0 +1,586 @@
+<?php
+/**
+ * Local repository that stores files in the local filesystem and registers them
+ * in the wiki's own database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A repository that stores files in the local filesystem and registers them
+ * in the wiki's own database. This is the most commonly used repository class.
+ *
+ * @ingroup FileRepo
+ */
+class LocalRepo extends FileRepo {
+ /** @var callable */
+ protected $fileFactory = [ 'LocalFile', 'newFromTitle' ];
+ /** @var callable */
+ protected $fileFactoryKey = [ 'LocalFile', 'newFromKey' ];
+ /** @var callable */
+ protected $fileFromRowFactory = [ 'LocalFile', 'newFromRow' ];
+ /** @var callable */
+ protected $oldFileFromRowFactory = [ 'OldLocalFile', 'newFromRow' ];
+ /** @var callable */
+ protected $oldFileFactory = [ 'OldLocalFile', 'newFromTitle' ];
+ /** @var callable */
+ protected $oldFileFactoryKey = [ 'OldLocalFile', 'newFromKey' ];
+
+ function __construct( array $info = null ) {
+ parent::__construct( $info );
+
+ $this->hasSha1Storage = isset( $info['storageLayout'] )
+ && $info['storageLayout'] === 'sha1';
+
+ if ( $this->hasSha1Storage() ) {
+ $this->backend = new FileBackendDBRepoWrapper( [
+ 'backend' => $this->backend,
+ 'repoName' => $this->name,
+ 'dbHandleFactory' => $this->getDBFactory()
+ ] );
+ }
+ }
+
+ /**
+ * @throws MWException
+ * @param stdClass $row
+ * @return LocalFile
+ */
+ function newFileFromRow( $row ) {
+ if ( isset( $row->img_name ) ) {
+ return call_user_func( $this->fileFromRowFactory, $row, $this );
+ } elseif ( isset( $row->oi_name ) ) {
+ return call_user_func( $this->oldFileFromRowFactory, $row, $this );
+ } else {
+ throw new MWException( __METHOD__ . ': invalid row' );
+ }
+ }
+
+ /**
+ * @param Title $title
+ * @param string $archiveName
+ * @return OldLocalFile
+ */
+ function newFromArchiveName( $title, $archiveName ) {
+ return OldLocalFile::newFromArchiveName( $title, $this, $archiveName );
+ }
+
+ /**
+ * Delete files in the deleted directory if they are not referenced in the
+ * filearchive table. This needs to be done in the repo because it needs to
+ * interleave database locks with file operations, which is potentially a
+ * remote operation.
+ *
+ * @param array $storageKeys
+ *
+ * @return Status
+ */
+ function cleanupDeletedBatch( array $storageKeys ) {
+ if ( $this->hasSha1Storage() ) {
+ wfDebug( __METHOD__ . ": skipped because storage uses sha1 paths\n" );
+ return Status::newGood();
+ }
+
+ $backend = $this->backend; // convenience
+ $root = $this->getZonePath( 'deleted' );
+ $dbw = $this->getMasterDB();
+ $status = $this->newGood();
+ $storageKeys = array_unique( $storageKeys );
+ foreach ( $storageKeys as $key ) {
+ $hashPath = $this->getDeletedHashPath( $key );
+ $path = "$root/$hashPath$key";
+ $dbw->startAtomic( __METHOD__ );
+ // Check for usage in deleted/hidden files and preemptively
+ // lock the key to avoid any future use until we are finished.
+ $deleted = $this->deletedFileHasKey( $key, 'lock' );
+ $hidden = $this->hiddenFileHasKey( $key, 'lock' );
+ if ( !$deleted && !$hidden ) { // not in use now
+ wfDebug( __METHOD__ . ": deleting $key\n" );
+ $op = [ 'op' => 'delete', 'src' => $path ];
+ if ( !$backend->doOperation( $op )->isOK() ) {
+ $status->error( 'undelete-cleanup-error', $path );
+ $status->failCount++;
+ }
+ } else {
+ wfDebug( __METHOD__ . ": $key still in use\n" );
+ $status->successCount++;
+ }
+ $dbw->endAtomic( __METHOD__ );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Check if a deleted (filearchive) file has this sha1 key
+ *
+ * @param string $key File storage key (base-36 sha1 key with file extension)
+ * @param string|null $lock Use "lock" to lock the row via FOR UPDATE
+ * @return bool File with this key is in use
+ */
+ protected function deletedFileHasKey( $key, $lock = null ) {
+ $options = ( $lock === 'lock' ) ? [ 'FOR UPDATE' ] : [];
+
+ $dbw = $this->getMasterDB();
+
+ return (bool)$dbw->selectField( 'filearchive', '1',
+ [ 'fa_storage_group' => 'deleted', 'fa_storage_key' => $key ],
+ __METHOD__, $options
+ );
+ }
+
+ /**
+ * Check if a hidden (revision delete) file has this sha1 key
+ *
+ * @param string $key File storage key (base-36 sha1 key with file extension)
+ * @param string|null $lock Use "lock" to lock the row via FOR UPDATE
+ * @return bool File with this key is in use
+ */
+ protected function hiddenFileHasKey( $key, $lock = null ) {
+ $options = ( $lock === 'lock' ) ? [ 'FOR UPDATE' ] : [];
+
+ $sha1 = self::getHashFromKey( $key );
+ $ext = File::normalizeExtension( substr( $key, strcspn( $key, '.' ) + 1 ) );
+
+ $dbw = $this->getMasterDB();
+
+ return (bool)$dbw->selectField( 'oldimage', '1',
+ [ 'oi_sha1' => $sha1,
+ 'oi_archive_name ' . $dbw->buildLike( $dbw->anyString(), ".$ext" ),
+ $dbw->bitAnd( 'oi_deleted', File::DELETED_FILE ) => File::DELETED_FILE ],
+ __METHOD__, $options
+ );
+ }
+
+ /**
+ * Gets the SHA1 hash from a storage key
+ *
+ * @param string $key
+ * @return string
+ */
+ public static function getHashFromKey( $key ) {
+ return strtok( $key, '.' );
+ }
+
+ /**
+ * Checks if there is a redirect named as $title
+ *
+ * @param Title $title Title of file
+ * @return bool|Title
+ */
+ function checkRedirect( Title $title ) {
+ $title = File::normalizeTitle( $title, 'exception' );
+
+ $memcKey = $this->getSharedCacheKey( 'image_redirect', md5( $title->getDBkey() ) );
+ if ( $memcKey === false ) {
+ $memcKey = $this->getLocalCacheKey( 'image_redirect', md5( $title->getDBkey() ) );
+ $expiry = 300; // no invalidation, 5 minutes
+ } else {
+ $expiry = 86400; // has invalidation, 1 day
+ }
+
+ $method = __METHOD__;
+ $redirDbKey = ObjectCache::getMainWANInstance()->getWithSetCallback(
+ $memcKey,
+ $expiry,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $method, $title ) {
+ $dbr = $this->getReplicaDB(); // possibly remote DB
+
+ $setOpts += Database::getCacheSetOptions( $dbr );
+
+ if ( $title instanceof Title ) {
+ $row = $dbr->selectRow(
+ [ 'page', 'redirect' ],
+ [ 'rd_namespace', 'rd_title' ],
+ [
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey(),
+ 'rd_from = page_id'
+ ],
+ $method
+ );
+ } else {
+ $row = false;
+ }
+
+ return ( $row && $row->rd_namespace == NS_FILE )
+ ? Title::makeTitle( $row->rd_namespace, $row->rd_title )->getDBkey()
+ : ''; // negative cache
+ },
+ [ 'pcTTL' => WANObjectCache::TTL_PROC_LONG ]
+ );
+
+ // @note: also checks " " for b/c
+ if ( $redirDbKey !== ' ' && strval( $redirDbKey ) !== '' ) {
+ // Page is a redirect to another file
+ return Title::newFromText( $redirDbKey, NS_FILE );
+ }
+
+ return false; // no redirect
+ }
+
+ public function findFiles( array $items, $flags = 0 ) {
+ $finalFiles = []; // map of (DB key => corresponding File) for matches
+
+ $searchSet = []; // map of (normalized DB key => search params)
+ foreach ( $items as $item ) {
+ if ( is_array( $item ) ) {
+ $title = File::normalizeTitle( $item['title'] );
+ if ( $title ) {
+ $searchSet[$title->getDBkey()] = $item;
+ }
+ } else {
+ $title = File::normalizeTitle( $item );
+ if ( $title ) {
+ $searchSet[$title->getDBkey()] = [];
+ }
+ }
+ }
+
+ $fileMatchesSearch = function ( File $file, array $search ) {
+ // Note: file name comparison done elsewhere (to handle redirects)
+ $user = ( !empty( $search['private'] ) && $search['private'] instanceof User )
+ ? $search['private']
+ : null;
+
+ return (
+ $file->exists() &&
+ (
+ ( empty( $search['time'] ) && !$file->isOld() ) ||
+ ( !empty( $search['time'] ) && $search['time'] === $file->getTimestamp() )
+ ) &&
+ ( !empty( $search['private'] ) || !$file->isDeleted( File::DELETED_FILE ) ) &&
+ $file->userCan( File::DELETED_FILE, $user )
+ );
+ };
+
+ $applyMatchingFiles = function ( ResultWrapper $res, &$searchSet, &$finalFiles )
+ use ( $fileMatchesSearch, $flags )
+ {
+ global $wgContLang;
+ $info = $this->getInfo();
+ foreach ( $res as $row ) {
+ $file = $this->newFileFromRow( $row );
+ // There must have been a search for this DB key, but this has to handle the
+ // cases were title capitalization is different on the client and repo wikis.
+ $dbKeysLook = [ strtr( $file->getName(), ' ', '_' ) ];
+ if ( !empty( $info['initialCapital'] ) ) {
+ // Search keys for "hi.png" and "Hi.png" should use the "Hi.png file"
+ $dbKeysLook[] = $wgContLang->lcfirst( $file->getName() );
+ }
+ foreach ( $dbKeysLook as $dbKey ) {
+ if ( isset( $searchSet[$dbKey] )
+ && $fileMatchesSearch( $file, $searchSet[$dbKey] )
+ ) {
+ $finalFiles[$dbKey] = ( $flags & FileRepo::NAME_AND_TIME_ONLY )
+ ? [ 'title' => $dbKey, 'timestamp' => $file->getTimestamp() ]
+ : $file;
+ unset( $searchSet[$dbKey] );
+ }
+ }
+ }
+ };
+
+ $dbr = $this->getReplicaDB();
+
+ // Query image table
+ $imgNames = [];
+ foreach ( array_keys( $searchSet ) as $dbKey ) {
+ $imgNames[] = $this->getNameFromTitle( File::normalizeTitle( $dbKey ) );
+ }
+
+ if ( count( $imgNames ) ) {
+ $res = $dbr->select( 'image',
+ LocalFile::selectFields(), [ 'img_name' => $imgNames ], __METHOD__ );
+ $applyMatchingFiles( $res, $searchSet, $finalFiles );
+ }
+
+ // Query old image table
+ $oiConds = []; // WHERE clause array for each file
+ foreach ( $searchSet as $dbKey => $search ) {
+ if ( isset( $search['time'] ) ) {
+ $oiConds[] = $dbr->makeList(
+ [
+ 'oi_name' => $this->getNameFromTitle( File::normalizeTitle( $dbKey ) ),
+ 'oi_timestamp' => $dbr->timestamp( $search['time'] )
+ ],
+ LIST_AND
+ );
+ }
+ }
+
+ if ( count( $oiConds ) ) {
+ $res = $dbr->select( 'oldimage',
+ OldLocalFile::selectFields(), $dbr->makeList( $oiConds, LIST_OR ), __METHOD__ );
+ $applyMatchingFiles( $res, $searchSet, $finalFiles );
+ }
+
+ // Check for redirects...
+ foreach ( $searchSet as $dbKey => $search ) {
+ if ( !empty( $search['ignoreRedirect'] ) ) {
+ continue;
+ }
+
+ $title = File::normalizeTitle( $dbKey );
+ $redir = $this->checkRedirect( $title ); // hopefully hits memcached
+
+ if ( $redir && $redir->getNamespace() == NS_FILE ) {
+ $file = $this->newFile( $redir );
+ if ( $file && $fileMatchesSearch( $file, $search ) ) {
+ $file->redirectedFrom( $title->getDBkey() );
+ if ( $flags & FileRepo::NAME_AND_TIME_ONLY ) {
+ $finalFiles[$dbKey] = [
+ 'title' => $file->getTitle()->getDBkey(),
+ 'timestamp' => $file->getTimestamp()
+ ];
+ } else {
+ $finalFiles[$dbKey] = $file;
+ }
+ }
+ }
+ }
+
+ return $finalFiles;
+ }
+
+ /**
+ * Get an array or iterator of file objects for files that have a given
+ * SHA-1 content hash.
+ *
+ * @param string $hash A sha1 hash to look for
+ * @return File[]
+ */
+ function findBySha1( $hash ) {
+ $dbr = $this->getReplicaDB();
+ $res = $dbr->select(
+ 'image',
+ LocalFile::selectFields(),
+ [ 'img_sha1' => $hash ],
+ __METHOD__,
+ [ 'ORDER BY' => 'img_name' ]
+ );
+
+ $result = [];
+ foreach ( $res as $row ) {
+ $result[] = $this->newFileFromRow( $row );
+ }
+ $res->free();
+
+ return $result;
+ }
+
+ /**
+ * Get an array of arrays or iterators of file objects for files that
+ * have the given SHA-1 content hashes.
+ *
+ * Overrides generic implementation in FileRepo for performance reason
+ *
+ * @param array $hashes An array of hashes
+ * @return array An Array of arrays or iterators of file objects and the hash as key
+ */
+ function findBySha1s( array $hashes ) {
+ if ( !count( $hashes ) ) {
+ return []; // empty parameter
+ }
+
+ $dbr = $this->getReplicaDB();
+ $res = $dbr->select(
+ 'image',
+ LocalFile::selectFields(),
+ [ 'img_sha1' => $hashes ],
+ __METHOD__,
+ [ 'ORDER BY' => 'img_name' ]
+ );
+
+ $result = [];
+ foreach ( $res as $row ) {
+ $file = $this->newFileFromRow( $row );
+ $result[$file->getSha1()][] = $file;
+ }
+ $res->free();
+
+ return $result;
+ }
+
+ /**
+ * Return an array of files where the name starts with $prefix.
+ *
+ * @param string $prefix The prefix to search for
+ * @param int $limit The maximum amount of files to return
+ * @return array
+ */
+ public function findFilesByPrefix( $prefix, $limit ) {
+ $selectOptions = [ 'ORDER BY' => 'img_name', 'LIMIT' => intval( $limit ) ];
+
+ // Query database
+ $dbr = $this->getReplicaDB();
+ $res = $dbr->select(
+ 'image',
+ LocalFile::selectFields(),
+ 'img_name ' . $dbr->buildLike( $prefix, $dbr->anyString() ),
+ __METHOD__,
+ $selectOptions
+ );
+
+ // Build file objects
+ $files = [];
+ foreach ( $res as $row ) {
+ $files[] = $this->newFileFromRow( $row );
+ }
+
+ return $files;
+ }
+
+ /**
+ * Get a connection to the replica DB
+ * @return IDatabase
+ */
+ function getReplicaDB() {
+ return wfGetDB( DB_REPLICA );
+ }
+
+ /**
+ * Alias for getReplicaDB()
+ *
+ * @return IDatabase
+ * @deprecated Since 1.29
+ */
+ function getSlaveDB() {
+ return $this->getReplicaDB();
+ }
+
+ /**
+ * Get a connection to the master DB
+ * @return IDatabase
+ */
+ function getMasterDB() {
+ return wfGetDB( DB_MASTER );
+ }
+
+ /**
+ * Get a callback to get a DB handle given an index (DB_REPLICA/DB_MASTER)
+ * @return Closure
+ */
+ protected function getDBFactory() {
+ return function ( $index ) {
+ return wfGetDB( $index );
+ };
+ }
+
+ /**
+ * Get a key on the primary cache for this repository.
+ * Returns false if the repository's cache is not accessible at this site.
+ * The parameters are the parts of the key, as for wfMemcKey().
+ *
+ * @return string
+ */
+ function getSharedCacheKey( /*...*/ ) {
+ $args = func_get_args();
+
+ return call_user_func_array( 'wfMemcKey', $args );
+ }
+
+ /**
+ * Invalidates image redirect cache related to that image
+ *
+ * @param Title $title Title of page
+ * @return void
+ */
+ function invalidateImageRedirect( Title $title ) {
+ $key = $this->getSharedCacheKey( 'image_redirect', md5( $title->getDBkey() ) );
+ if ( $key ) {
+ $this->getMasterDB()->onTransactionPreCommitOrIdle(
+ function () use ( $key ) {
+ ObjectCache::getMainWANInstance()->delete( $key );
+ },
+ __METHOD__
+ );
+ }
+ }
+
+ /**
+ * Return information about the repository.
+ *
+ * @return array
+ * @since 1.22
+ */
+ function getInfo() {
+ global $wgFavicon;
+
+ return array_merge( parent::getInfo(), [
+ 'favicon' => wfExpandUrl( $wgFavicon ),
+ ] );
+ }
+
+ public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) {
+ return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
+ }
+
+ public function storeBatch( array $triplets, $flags = 0 ) {
+ return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
+ }
+
+ public function cleanupBatch( array $files, $flags = 0 ) {
+ return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
+ }
+
+ public function publish(
+ $src,
+ $dstRel,
+ $archiveRel,
+ $flags = 0,
+ array $options = []
+ ) {
+ return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
+ }
+
+ public function publishBatch( array $ntuples, $flags = 0 ) {
+ return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
+ }
+
+ public function delete( $srcRel, $archiveRel ) {
+ return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
+ }
+
+ public function deleteBatch( array $sourceDestPairs ) {
+ return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
+ }
+
+ /**
+ * Skips the write operation if storage is sha1-based, executes it normally otherwise
+ *
+ * @param string $function
+ * @param array $args
+ * @return Status
+ */
+ protected function skipWriteOperationIfSha1( $function, array $args ) {
+ $this->assertWritableRepo(); // fail out if read-only
+
+ if ( $this->hasSha1Storage() ) {
+ wfDebug( __METHOD__ . ": skipped because storage uses sha1 paths\n" );
+ return Status::newGood();
+ } else {
+ return call_user_func_array( 'parent::' . $function, $args );
+ }
+ }
+}
diff --git a/www/wiki/includes/filerepo/NullRepo.php b/www/wiki/includes/filerepo/NullRepo.php
new file mode 100644
index 00000000..1c12e027
--- /dev/null
+++ b/www/wiki/includes/filerepo/NullRepo.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * File repository with no files.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+/**
+ * File repository with no files, for performance testing
+ * @ingroup FileRepo
+ */
+class NullRepo extends FileRepo {
+ /**
+ * @param array|null $info
+ */
+ function __construct( $info ) {
+ }
+
+ protected function assertWritableRepo() {
+ throw new MWException( static::class . ': write operations are not supported.' );
+ }
+}
diff --git a/www/wiki/includes/filerepo/README b/www/wiki/includes/filerepo/README
new file mode 100644
index 00000000..1423d359
--- /dev/null
+++ b/www/wiki/includes/filerepo/README
@@ -0,0 +1,41 @@
+Some quick notes on the file/repository architecture.
+
+Functionality is, as always, driven by data model.
+
+* The repository object stores configuration information about a file storage
+ method.
+
+* The file object is a process-local cache of information about a particular
+ file.
+
+Thus the file object is the primary public entry point for obtaining information
+about files, since access via the file object can be cached, whereas access via
+the repository should not be cached.
+
+Functions which can act on any file specified in their parameters typically find
+their place either in the repository object, where reference to
+repository-specific configuration is needed, or in static members of File or
+FileRepo, where no such configuration is needed.
+
+File objects are generated by a factory function from the repository. The
+repository thus has full control over the behavior of its subsidiary file
+class, since it can subclass the file class and override functionality at its
+whim. Thus there is no need for the File subclass to query its parent repository
+for information about repository-class-dependent behavior -- the file subclass
+is generally fully aware of the static preferences of its repository. Limited
+exceptions can be made to this rule to permit sharing of functions, or perhaps
+even entire classes, between repositories.
+
+These rules alone still do lead to some ambiguity -- it may not be clear whether
+to implement some functionality in a repository function with a filename
+parameter, or in the file object itself.
+
+So we introduce the following rule: the file subclass is smarter than the
+repository subclass. The repository should in general provide a minimal API
+needed to access the storage backend efficiently.
+
+In particular, note that I have not implemented any database access in
+LocalRepo.php. LocalRepo provides only file access, and LocalFile provides
+database access and higher-level functions such as cache management.
+
+Tim Starling, June 2007
diff --git a/www/wiki/includes/filerepo/RepoGroup.php b/www/wiki/includes/filerepo/RepoGroup.php
new file mode 100644
index 00000000..2edd6d09
--- /dev/null
+++ b/www/wiki/includes/filerepo/RepoGroup.php
@@ -0,0 +1,472 @@
+<?php
+/**
+ * Prioritized list of file repositories.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileRepo
+ */
+
+/**
+ * Prioritized list of file repositories
+ *
+ * @ingroup FileRepo
+ */
+class RepoGroup {
+ /** @var LocalRepo */
+ protected $localRepo;
+
+ /** @var FileRepo[] */
+ protected $foreignRepos;
+
+ /** @var bool */
+ protected $reposInitialised = false;
+
+ /** @var array */
+ protected $localInfo;
+
+ /** @var array */
+ protected $foreignInfo;
+
+ /** @var ProcessCacheLRU */
+ protected $cache;
+
+ /** @var RepoGroup */
+ protected static $instance;
+
+ /** Maximum number of cache items */
+ const MAX_CACHE_SIZE = 500;
+
+ /**
+ * Get a RepoGroup instance. At present only one instance of RepoGroup is
+ * needed in a MediaWiki invocation, this may change in the future.
+ * @return RepoGroup
+ */
+ static function singleton() {
+ if ( self::$instance ) {
+ return self::$instance;
+ }
+ global $wgLocalFileRepo, $wgForeignFileRepos;
+ self::$instance = new RepoGroup( $wgLocalFileRepo, $wgForeignFileRepos );
+
+ return self::$instance;
+ }
+
+ /**
+ * Destroy the singleton instance, so that a new one will be created next
+ * time singleton() is called.
+ */
+ static function destroySingleton() {
+ self::$instance = null;
+ }
+
+ /**
+ * Set the singleton instance to a given object
+ * Used by extensions which hook into the Repo chain.
+ * It's not enough to just create a superclass ... you have
+ * to get people to call into it even though all they know is RepoGroup::singleton()
+ *
+ * @param RepoGroup $instance
+ */
+ static function setSingleton( $instance ) {
+ self::$instance = $instance;
+ }
+
+ /**
+ * Construct a group of file repositories.
+ *
+ * @param array $localInfo Associative array for local repo's info
+ * @param array $foreignInfo Array of repository info arrays.
+ * Each info array is an associative array with the 'class' member
+ * giving the class name. The entire array is passed to the repository
+ * constructor as the first parameter.
+ */
+ function __construct( $localInfo, $foreignInfo ) {
+ $this->localInfo = $localInfo;
+ $this->foreignInfo = $foreignInfo;
+ $this->cache = new ProcessCacheLRU( self::MAX_CACHE_SIZE );
+ }
+
+ /**
+ * Search repositories for an image.
+ * You can also use wfFindFile() to do this.
+ *
+ * @param Title|string $title Title object or string
+ * @param array $options Associative array of options:
+ * time: requested time for an archived image, or false for the
+ * current version. An image object will be returned which was
+ * created at the specified time.
+ * ignoreRedirect: If true, do not follow file redirects
+ * private: If true, return restricted (deleted) files if the current
+ * user is allowed to view them. Otherwise, such files will not
+ * be found.
+ * latest: If true, load from the latest available data into File objects
+ * @return File|bool False if title is not found
+ */
+ function findFile( $title, $options = [] ) {
+ if ( !is_array( $options ) ) {
+ // MW 1.15 compat
+ $options = [ 'time' => $options ];
+ }
+ if ( isset( $options['bypassCache'] ) ) {
+ $options['latest'] = $options['bypassCache']; // b/c
+ }
+
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+ $title = File::normalizeTitle( $title );
+ if ( !$title ) {
+ return false;
+ }
+
+ # Check the cache
+ $dbkey = $title->getDBkey();
+ if ( empty( $options['ignoreRedirect'] )
+ && empty( $options['private'] )
+ && empty( $options['bypassCache'] )
+ ) {
+ $time = isset( $options['time'] ) ? $options['time'] : '';
+ if ( $this->cache->has( $dbkey, $time, 60 ) ) {
+ return $this->cache->get( $dbkey, $time );
+ }
+ $useCache = true;
+ } else {
+ $time = false;
+ $useCache = false;
+ }
+
+ # Check the local repo
+ $image = $this->localRepo->findFile( $title, $options );
+
+ # Check the foreign repos
+ if ( !$image ) {
+ foreach ( $this->foreignRepos as $repo ) {
+ $image = $repo->findFile( $title, $options );
+ if ( $image ) {
+ break;
+ }
+ }
+ }
+
+ $image = $image ? $image : false; // type sanity
+ # Cache file existence or non-existence
+ if ( $useCache && ( !$image || $image->isCacheable() ) ) {
+ $this->cache->set( $dbkey, $time, $image );
+ }
+
+ return $image;
+ }
+
+ /**
+ * Search repositories for many files at once.
+ *
+ * @param array $inputItems An array of titles, or an array of findFile() options with
+ * the "title" option giving the title. Example:
+ *
+ * $findItem = [ 'title' => $title, 'private' => true ];
+ * $findBatch = [ $findItem ];
+ * $repo->findFiles( $findBatch );
+ *
+ * No title should appear in $items twice, as the result use titles as keys
+ * @param int $flags Supports:
+ * - FileRepo::NAME_AND_TIME_ONLY : return a (search title => (title,timestamp)) map.
+ * The search title uses the input titles; the other is the final post-redirect title.
+ * All titles are returned as string DB keys and the inner array is associative.
+ * @return array Map of (file name => File objects) for matches
+ */
+ function findFiles( array $inputItems, $flags = 0 ) {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+
+ $items = [];
+ foreach ( $inputItems as $item ) {
+ if ( !is_array( $item ) ) {
+ $item = [ 'title' => $item ];
+ }
+ $item['title'] = File::normalizeTitle( $item['title'] );
+ if ( $item['title'] ) {
+ $items[$item['title']->getDBkey()] = $item;
+ }
+ }
+
+ $images = $this->localRepo->findFiles( $items, $flags );
+
+ foreach ( $this->foreignRepos as $repo ) {
+ // Remove found files from $items
+ foreach ( $images as $name => $image ) {
+ unset( $items[$name] );
+ }
+
+ $images = array_merge( $images, $repo->findFiles( $items, $flags ) );
+ }
+
+ return $images;
+ }
+
+ /**
+ * Interface for FileRepo::checkRedirect()
+ * @param Title $title
+ * @return bool|Title
+ */
+ function checkRedirect( Title $title ) {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+
+ $redir = $this->localRepo->checkRedirect( $title );
+ if ( $redir ) {
+ return $redir;
+ }
+
+ foreach ( $this->foreignRepos as $repo ) {
+ $redir = $repo->checkRedirect( $title );
+ if ( $redir ) {
+ return $redir;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Find an instance of the file with this key, created at the specified time
+ * Returns false if the file does not exist.
+ *
+ * @param string $hash Base 36 SHA-1 hash
+ * @param array $options Option array, same as findFile()
+ * @return File|bool File object or false if it is not found
+ */
+ function findFileFromKey( $hash, $options = [] ) {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+
+ $file = $this->localRepo->findFileFromKey( $hash, $options );
+ if ( !$file ) {
+ foreach ( $this->foreignRepos as $repo ) {
+ $file = $repo->findFileFromKey( $hash, $options );
+ if ( $file ) {
+ break;
+ }
+ }
+ }
+
+ return $file;
+ }
+
+ /**
+ * Find all instances of files with this key
+ *
+ * @param string $hash Base 36 SHA-1 hash
+ * @return File[]
+ */
+ function findBySha1( $hash ) {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+
+ $result = $this->localRepo->findBySha1( $hash );
+ foreach ( $this->foreignRepos as $repo ) {
+ $result = array_merge( $result, $repo->findBySha1( $hash ) );
+ }
+ usort( $result, 'File::compare' );
+
+ return $result;
+ }
+
+ /**
+ * Find all instances of files with this keys
+ *
+ * @param array $hashes Base 36 SHA-1 hashes
+ * @return array Array of array of File objects
+ */
+ function findBySha1s( array $hashes ) {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+
+ $result = $this->localRepo->findBySha1s( $hashes );
+ foreach ( $this->foreignRepos as $repo ) {
+ $result = array_merge_recursive( $result, $repo->findBySha1s( $hashes ) );
+ }
+ // sort the merged (and presorted) sublist of each hash
+ foreach ( $result as $hash => $files ) {
+ usort( $result[$hash], 'File::compare' );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the repo instance with a given key.
+ * @param string|int $index
+ * @return bool|LocalRepo
+ */
+ function getRepo( $index ) {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+ if ( $index === 'local' ) {
+ return $this->localRepo;
+ } elseif ( isset( $this->foreignRepos[$index] ) ) {
+ return $this->foreignRepos[$index];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get the repo instance by its name
+ * @param string $name
+ * @return FileRepo|bool
+ */
+ function getRepoByName( $name ) {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+ foreach ( $this->foreignRepos as $repo ) {
+ if ( $repo->name == $name ) {
+ return $repo;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the local repository, i.e. the one corresponding to the local image
+ * table. Files are typically uploaded to the local repository.
+ *
+ * @return LocalRepo
+ */
+ function getLocalRepo() {
+ return $this->getRepo( 'local' );
+ }
+
+ /**
+ * Call a function for each foreign repo, with the repo object as the
+ * first parameter.
+ *
+ * @param callable $callback The function to call
+ * @param array $params Optional additional parameters to pass to the function
+ * @return bool
+ */
+ function forEachForeignRepo( $callback, $params = [] ) {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+ foreach ( $this->foreignRepos as $repo ) {
+ $args = array_merge( [ $repo ], $params );
+ if ( call_user_func_array( $callback, $args ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Does the installation have any foreign repos set up?
+ * @return bool
+ */
+ function hasForeignRepos() {
+ if ( !$this->reposInitialised ) {
+ $this->initialiseRepos();
+ }
+ return (bool)$this->foreignRepos;
+ }
+
+ /**
+ * Initialise the $repos array
+ */
+ function initialiseRepos() {
+ if ( $this->reposInitialised ) {
+ return;
+ }
+ $this->reposInitialised = true;
+
+ $this->localRepo = $this->newRepo( $this->localInfo );
+ $this->foreignRepos = [];
+ foreach ( $this->foreignInfo as $key => $info ) {
+ $this->foreignRepos[$key] = $this->newRepo( $info );
+ }
+ }
+
+ /**
+ * Create a repo class based on an info structure
+ * @param array $info
+ * @return FileRepo
+ */
+ protected function newRepo( $info ) {
+ $class = $info['class'];
+
+ return new $class( $info );
+ }
+
+ /**
+ * Split a virtual URL into repo, zone and rel parts
+ * @param string $url
+ * @throws MWException
+ * @return array Containing repo, zone and rel
+ */
+ function splitVirtualUrl( $url ) {
+ if ( substr( $url, 0, 9 ) != 'mwrepo://' ) {
+ throw new MWException( __METHOD__ . ': unknown protocol' );
+ }
+
+ $bits = explode( '/', substr( $url, 9 ), 3 );
+ if ( count( $bits ) != 3 ) {
+ throw new MWException( __METHOD__ . ": invalid mwrepo URL: $url" );
+ }
+
+ return $bits;
+ }
+
+ /**
+ * @param string $fileName
+ * @return array
+ */
+ function getFileProps( $fileName ) {
+ if ( FileRepo::isVirtualUrl( $fileName ) ) {
+ list( $repoName, /* $zone */, /* $rel */ ) = $this->splitVirtualUrl( $fileName );
+ if ( $repoName === '' ) {
+ $repoName = 'local';
+ }
+ $repo = $this->getRepo( $repoName );
+
+ return $repo->getFileProps( $fileName );
+ } else {
+ $mwProps = new MWFileProps( MimeMagic::singleton() );
+
+ return $mwProps->getPropsFromPath( $fileName, true );
+ }
+ }
+
+ /**
+ * Clear RepoGroup process cache used for finding a file
+ * @param Title|null $title Title of the file or null to clear all files
+ */
+ public function clearCache( Title $title = null ) {
+ if ( $title == null ) {
+ $this->cache->clear();
+ } else {
+ $this->cache->clear( $title->getDBkey() );
+ }
+ }
+}
diff --git a/www/wiki/includes/filerepo/TempFileRepo.php b/www/wiki/includes/filerepo/TempFileRepo.php
new file mode 100644
index 00000000..c9a6b592
--- /dev/null
+++ b/www/wiki/includes/filerepo/TempFileRepo.php
@@ -0,0 +1,9 @@
+<?php
+/**
+ * FileRepo for temporary files created via FileRepo::getTempRepo()
+ */
+class TempFileRepo extends FileRepo {
+ public function getTempRepo() {
+ throw new MWException( "Cannot get a temp repo from a temp repo." );
+ }
+}
diff --git a/www/wiki/includes/filerepo/file/ArchivedFile.php b/www/wiki/includes/filerepo/file/ArchivedFile.php
new file mode 100644
index 00000000..758fb4b5
--- /dev/null
+++ b/www/wiki/includes/filerepo/file/ArchivedFile.php
@@ -0,0 +1,580 @@
+<?php
+/**
+ * Deleted file in the 'filearchive' table.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileAbstraction
+ */
+
+/**
+ * Class representing a row of the 'filearchive' table
+ *
+ * @ingroup FileAbstraction
+ */
+class ArchivedFile {
+ /** @var int Filearchive row ID */
+ private $id;
+
+ /** @var string File name */
+ private $name;
+
+ /** @var string FileStore storage group */
+ private $group;
+
+ /** @var string FileStore SHA-1 key */
+ private $key;
+
+ /** @var int File size in bytes */
+ private $size;
+
+ /** @var int Size in bytes */
+ private $bits;
+
+ /** @var int Width */
+ private $width;
+
+ /** @var int Height */
+ private $height;
+
+ /** @var string Metadata string */
+ private $metadata;
+
+ /** @var string MIME type */
+ private $mime;
+
+ /** @var string Media type */
+ private $media_type;
+
+ /** @var string Upload description */
+ private $description;
+
+ /** @var int User ID of uploader */
+ private $user;
+
+ /** @var string User name of uploader */
+ private $user_text;
+
+ /** @var string Time of upload */
+ private $timestamp;
+
+ /** @var bool Whether or not all this has been loaded from the database (loadFromXxx) */
+ private $dataLoaded;
+
+ /** @var int Bitfield akin to rev_deleted */
+ private $deleted;
+
+ /** @var string SHA-1 hash of file content */
+ private $sha1;
+
+ /** @var int|false Number of pages of a multipage document, or false for
+ * documents which aren't multipage documents
+ */
+ private $pageCount;
+
+ /** @var string Original base filename */
+ private $archive_name;
+
+ /** @var MediaHandler */
+ protected $handler;
+
+ /** @var Title */
+ protected $title; # image title
+
+ /**
+ * @throws MWException
+ * @param Title $title
+ * @param int $id
+ * @param string $key
+ * @param string $sha1
+ */
+ function __construct( $title, $id = 0, $key = '', $sha1 = '' ) {
+ $this->id = -1;
+ $this->title = false;
+ $this->name = false;
+ $this->group = 'deleted'; // needed for direct use of constructor
+ $this->key = '';
+ $this->size = 0;
+ $this->bits = 0;
+ $this->width = 0;
+ $this->height = 0;
+ $this->metadata = '';
+ $this->mime = "unknown/unknown";
+ $this->media_type = '';
+ $this->description = '';
+ $this->user = 0;
+ $this->user_text = '';
+ $this->timestamp = null;
+ $this->deleted = 0;
+ $this->dataLoaded = false;
+ $this->exists = false;
+ $this->sha1 = '';
+
+ if ( $title instanceof Title ) {
+ $this->title = File::normalizeTitle( $title, 'exception' );
+ $this->name = $title->getDBkey();
+ }
+
+ if ( $id ) {
+ $this->id = $id;
+ }
+
+ if ( $key ) {
+ $this->key = $key;
+ }
+
+ if ( $sha1 ) {
+ $this->sha1 = $sha1;
+ }
+
+ if ( !$id && !$key && !( $title instanceof Title ) && !$sha1 ) {
+ throw new MWException( "No specifications provided to ArchivedFile constructor." );
+ }
+ }
+
+ /**
+ * Loads a file object from the filearchive table
+ * @throws MWException
+ * @return bool|null True on success or null
+ */
+ public function load() {
+ if ( $this->dataLoaded ) {
+ return true;
+ }
+ $conds = [];
+
+ if ( $this->id > 0 ) {
+ $conds['fa_id'] = $this->id;
+ }
+ if ( $this->key ) {
+ $conds['fa_storage_group'] = $this->group;
+ $conds['fa_storage_key'] = $this->key;
+ }
+ if ( $this->title ) {
+ $conds['fa_name'] = $this->title->getDBkey();
+ }
+ if ( $this->sha1 ) {
+ $conds['fa_sha1'] = $this->sha1;
+ }
+
+ if ( !count( $conds ) ) {
+ throw new MWException( "No specific information for retrieving archived file" );
+ }
+
+ if ( !$this->title || $this->title->getNamespace() == NS_FILE ) {
+ $this->dataLoaded = true; // set it here, to have also true on miss
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'filearchive',
+ self::selectFields(),
+ $conds,
+ __METHOD__,
+ [ 'ORDER BY' => 'fa_timestamp DESC' ]
+ );
+ if ( !$row ) {
+ // this revision does not exist?
+ return null;
+ }
+
+ // initialize fields for filestore image object
+ $this->loadFromRow( $row );
+ } else {
+ throw new MWException( 'This title does not correspond to an image page.' );
+ }
+ $this->exists = true;
+
+ return true;
+ }
+
+ /**
+ * Loads a file object from the filearchive table
+ *
+ * @param stdClass $row
+ * @return ArchivedFile
+ */
+ public static function newFromRow( $row ) {
+ $file = new ArchivedFile( Title::makeTitle( NS_FILE, $row->fa_name ) );
+ $file->loadFromRow( $row );
+
+ return $file;
+ }
+
+ /**
+ * Fields in the filearchive table
+ * @todo Deprecate this in favor of a method that returns tables and joins
+ * as well, and use CommentStore::getJoin().
+ * @return array
+ */
+ static function selectFields() {
+ return [
+ 'fa_id',
+ 'fa_name',
+ 'fa_archive_name',
+ 'fa_storage_key',
+ 'fa_storage_group',
+ 'fa_size',
+ 'fa_bits',
+ 'fa_width',
+ 'fa_height',
+ 'fa_metadata',
+ 'fa_media_type',
+ 'fa_major_mime',
+ 'fa_minor_mime',
+ 'fa_user',
+ 'fa_user_text',
+ 'fa_timestamp',
+ 'fa_deleted',
+ 'fa_deleted_timestamp', /* Used by LocalFileRestoreBatch */
+ 'fa_sha1',
+ ] + CommentStore::newKey( 'fa_description' )->getFields();
+ }
+
+ /**
+ * Load ArchivedFile object fields from a DB row.
+ *
+ * @param stdClass $row Object database row
+ * @since 1.21
+ */
+ public function loadFromRow( $row ) {
+ $this->id = intval( $row->fa_id );
+ $this->name = $row->fa_name;
+ $this->archive_name = $row->fa_archive_name;
+ $this->group = $row->fa_storage_group;
+ $this->key = $row->fa_storage_key;
+ $this->size = $row->fa_size;
+ $this->bits = $row->fa_bits;
+ $this->width = $row->fa_width;
+ $this->height = $row->fa_height;
+ $this->metadata = $row->fa_metadata;
+ $this->mime = "$row->fa_major_mime/$row->fa_minor_mime";
+ $this->media_type = $row->fa_media_type;
+ $this->description = CommentStore::newKey( 'fa_description' )
+ // Legacy because $row probably came from self::selectFields()
+ ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row )->text;
+ $this->user = $row->fa_user;
+ $this->user_text = $row->fa_user_text;
+ $this->timestamp = $row->fa_timestamp;
+ $this->deleted = $row->fa_deleted;
+ if ( isset( $row->fa_sha1 ) ) {
+ $this->sha1 = $row->fa_sha1;
+ } else {
+ // old row, populate from key
+ $this->sha1 = LocalRepo::getHashFromKey( $this->key );
+ }
+ if ( !$this->title ) {
+ $this->title = Title::makeTitleSafe( NS_FILE, $row->fa_name );
+ }
+ }
+
+ /**
+ * Return the associated title object
+ *
+ * @return Title
+ */
+ public function getTitle() {
+ if ( !$this->title ) {
+ $this->load();
+ }
+ return $this->title;
+ }
+
+ /**
+ * Return the file name
+ *
+ * @return string
+ */
+ public function getName() {
+ if ( $this->name === false ) {
+ $this->load();
+ }
+
+ return $this->name;
+ }
+
+ /**
+ * @return int
+ */
+ public function getID() {
+ $this->load();
+
+ return $this->id;
+ }
+
+ /**
+ * @return bool
+ */
+ public function exists() {
+ $this->load();
+
+ return $this->exists;
+ }
+
+ /**
+ * Return the FileStore key
+ * @return string
+ */
+ public function getKey() {
+ $this->load();
+
+ return $this->key;
+ }
+
+ /**
+ * Return the FileStore key (overriding base File class)
+ * @return string
+ */
+ public function getStorageKey() {
+ return $this->getKey();
+ }
+
+ /**
+ * Return the FileStore storage group
+ * @return string
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * Return the width of the image
+ * @return int
+ */
+ public function getWidth() {
+ $this->load();
+
+ return $this->width;
+ }
+
+ /**
+ * Return the height of the image
+ * @return int
+ */
+ public function getHeight() {
+ $this->load();
+
+ return $this->height;
+ }
+
+ /**
+ * Get handler-specific metadata
+ * @return string
+ */
+ public function getMetadata() {
+ $this->load();
+
+ return $this->metadata;
+ }
+
+ /**
+ * Return the size of the image file, in bytes
+ * @return int
+ */
+ public function getSize() {
+ $this->load();
+
+ return $this->size;
+ }
+
+ /**
+ * Return the bits of the image file, in bytes
+ * @return int
+ */
+ public function getBits() {
+ $this->load();
+
+ return $this->bits;
+ }
+
+ /**
+ * Returns the MIME type of the file.
+ * @return string
+ */
+ public function getMimeType() {
+ $this->load();
+
+ return $this->mime;
+ }
+
+ /**
+ * Get a MediaHandler instance for this file
+ * @return MediaHandler
+ */
+ function getHandler() {
+ if ( !isset( $this->handler ) ) {
+ $this->handler = MediaHandler::getHandler( $this->getMimeType() );
+ }
+
+ return $this->handler;
+ }
+
+ /**
+ * Returns the number of pages of a multipage document, or false for
+ * documents which aren't multipage documents
+ * @return bool|int
+ */
+ function pageCount() {
+ if ( !isset( $this->pageCount ) ) {
+ // @FIXME: callers expect File objects
+ if ( $this->getHandler() && $this->handler->isMultiPage( $this ) ) {
+ $this->pageCount = $this->handler->pageCount( $this );
+ } else {
+ $this->pageCount = false;
+ }
+ }
+
+ return $this->pageCount;
+ }
+
+ /**
+ * Return the type of the media in the file.
+ * Use the value returned by this function with the MEDIATYPE_xxx constants.
+ * @return string
+ */
+ public function getMediaType() {
+ $this->load();
+
+ return $this->media_type;
+ }
+
+ /**
+ * Return upload timestamp.
+ *
+ * @return string
+ */
+ public function getTimestamp() {
+ $this->load();
+
+ return wfTimestamp( TS_MW, $this->timestamp );
+ }
+
+ /**
+ * Get the SHA-1 base 36 hash of the file
+ *
+ * @return string
+ * @since 1.21
+ */
+ function getSha1() {
+ $this->load();
+
+ return $this->sha1;
+ }
+
+ /**
+ * Returns ID or name of user who uploaded the file
+ *
+ * @note Prior to MediaWiki 1.23, this method always
+ * returned the user id, and was inconsistent with
+ * the rest of the file classes.
+ * @param string $type 'text' or 'id'
+ * @return int|string
+ * @throws MWException
+ */
+ public function getUser( $type = 'text' ) {
+ $this->load();
+
+ if ( $type == 'text' ) {
+ return $this->user_text;
+ } elseif ( $type == 'id' ) {
+ return (int)$this->user;
+ }
+
+ throw new MWException( "Unknown type '$type'." );
+ }
+
+ /**
+ * Return upload description.
+ *
+ * @return string|int
+ */
+ public function getDescription() {
+ $this->load();
+ if ( $this->isDeleted( File::DELETED_COMMENT ) ) {
+ return 0;
+ } else {
+ return $this->description;
+ }
+ }
+
+ /**
+ * Return the user ID of the uploader.
+ *
+ * @return int
+ */
+ public function getRawUser() {
+ $this->load();
+
+ return $this->user;
+ }
+
+ /**
+ * Return the user name of the uploader.
+ *
+ * @return string
+ */
+ public function getRawUserText() {
+ $this->load();
+
+ return $this->user_text;
+ }
+
+ /**
+ * Return upload description.
+ *
+ * @return string
+ */
+ public function getRawDescription() {
+ $this->load();
+
+ return $this->description;
+ }
+
+ /**
+ * Returns the deletion bitfield
+ * @return int
+ */
+ public function getVisibility() {
+ $this->load();
+
+ return $this->deleted;
+ }
+
+ /**
+ * for file or revision rows
+ *
+ * @param int $field One of DELETED_* bitfield constants
+ * @return bool
+ */
+ public function isDeleted( $field ) {
+ $this->load();
+
+ return ( $this->deleted & $field ) == $field;
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this FileStore image file, if it's marked as deleted.
+ * @param int $field
+ * @param null|User $user User object to check, or null to use $wgUser
+ * @return bool
+ */
+ public function userCan( $field, User $user = null ) {
+ $this->load();
+
+ $title = $this->getTitle();
+ return Revision::userCanBitfield( $this->deleted, $field, $user, $title ?: null );
+ }
+}
diff --git a/www/wiki/includes/filerepo/file/File.php b/www/wiki/includes/filerepo/file/File.php
new file mode 100644
index 00000000..32f4504b
--- /dev/null
+++ b/www/wiki/includes/filerepo/file/File.php
@@ -0,0 +1,2299 @@
+<?php
+/**
+ * @defgroup FileAbstraction File abstraction
+ * @ingroup FileRepo
+ *
+ * Represents files in a repository.
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Base code for files.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileAbstraction
+ */
+
+/**
+ * Implements some public methods and some protected utility functions which
+ * are required by multiple child classes. Contains stub functionality for
+ * unimplemented public methods.
+ *
+ * Stub functions which should be overridden are marked with STUB. Some more
+ * concrete functions are also typically overridden by child classes.
+ *
+ * Note that only the repo object knows what its file class is called. You should
+ * never name a file class explictly outside of the repo class. Instead use the
+ * repo's factory functions to generate file objects, for example:
+ *
+ * RepoGroup::singleton()->getLocalRepo()->newFile( $title );
+ *
+ * The convenience functions wfLocalFile() and wfFindFile() should be sufficient
+ * in most cases.
+ *
+ * @ingroup FileAbstraction
+ */
+abstract class File implements IDBAccessObject {
+ // Bitfield values akin to the Revision deletion constants
+ const DELETED_FILE = 1;
+ const DELETED_COMMENT = 2;
+ const DELETED_USER = 4;
+ const DELETED_RESTRICTED = 8;
+
+ /** Force rendering in the current process */
+ const RENDER_NOW = 1;
+ /**
+ * Force rendering even if thumbnail already exist and using RENDER_NOW
+ * I.e. you have to pass both flags: File::RENDER_NOW | File::RENDER_FORCE
+ */
+ const RENDER_FORCE = 2;
+
+ const DELETE_SOURCE = 1;
+
+ // Audience options for File::getDescription()
+ const FOR_PUBLIC = 1;
+ const FOR_THIS_USER = 2;
+ const RAW = 3;
+
+ // Options for File::thumbName()
+ const THUMB_FULL_NAME = 1;
+
+ /**
+ * Some member variables can be lazy-initialised using __get(). The
+ * initialisation function for these variables is always a function named
+ * like getVar(), where Var is the variable name with upper-case first
+ * letter.
+ *
+ * The following variables are initialised in this way in this base class:
+ * name, extension, handler, path, canRender, isSafeFile,
+ * transformScript, hashPath, pageCount, url
+ *
+ * Code within this class should generally use the accessor function
+ * directly, since __get() isn't re-entrant and therefore causes bugs that
+ * depend on initialisation order.
+ */
+
+ /**
+ * The following member variables are not lazy-initialised
+ */
+
+ /** @var FileRepo|LocalRepo|ForeignAPIRepo|bool */
+ public $repo;
+
+ /** @var Title|string|bool */
+ protected $title;
+
+ /** @var string Text of last error */
+ protected $lastError;
+
+ /** @var string Main part of the title, with underscores (Title::getDBkey) */
+ protected $redirected;
+
+ /** @var Title */
+ protected $redirectedTitle;
+
+ /** @var FSFile|bool False if undefined */
+ protected $fsFile;
+
+ /** @var MediaHandler */
+ protected $handler;
+
+ /** @var string The URL corresponding to one of the four basic zones */
+ protected $url;
+
+ /** @var string File extension */
+ protected $extension;
+
+ /** @var string The name of a file from its title object */
+ protected $name;
+
+ /** @var string The storage path corresponding to one of the zones */
+ protected $path;
+
+ /** @var string Relative path including trailing slash */
+ protected $hashPath;
+
+ /** @var string|false Number of pages of a multipage document, or false for
+ * documents which aren't multipage documents
+ */
+ protected $pageCount;
+
+ /** @var string URL of transformscript (for example thumb.php) */
+ protected $transformScript;
+
+ /** @var Title */
+ protected $redirectTitle;
+
+ /** @var bool Whether the output of transform() for this file is likely to be valid. */
+ protected $canRender;
+
+ /** @var bool Whether this media file is in a format that is unlikely to
+ * contain viruses or malicious content
+ */
+ protected $isSafeFile;
+
+ /** @var string Required Repository class type */
+ protected $repoClass = 'FileRepo';
+
+ /** @var array Cache of tmp filepaths pointing to generated bucket thumbnails, keyed by width */
+ protected $tmpBucketedThumbCache = [];
+
+ /**
+ * Call this constructor from child classes.
+ *
+ * Both $title and $repo are optional, though some functions
+ * may return false or throw exceptions if they are not set.
+ * Most subclasses will want to call assertRepoDefined() here.
+ *
+ * @param Title|string|bool $title
+ * @param FileRepo|bool $repo
+ */
+ function __construct( $title, $repo ) {
+ // Some subclasses do not use $title, but set name/title some other way
+ if ( $title !== false ) {
+ $title = self::normalizeTitle( $title, 'exception' );
+ }
+ $this->title = $title;
+ $this->repo = $repo;
+ }
+
+ /**
+ * Given a string or Title object return either a
+ * valid Title object with namespace NS_FILE or null
+ *
+ * @param Title|string $title
+ * @param string|bool $exception Use 'exception' to throw an error on bad titles
+ * @throws MWException
+ * @return Title|null
+ */
+ static function normalizeTitle( $title, $exception = false ) {
+ $ret = $title;
+ if ( $ret instanceof Title ) {
+ # Normalize NS_MEDIA -> NS_FILE
+ if ( $ret->getNamespace() == NS_MEDIA ) {
+ $ret = Title::makeTitleSafe( NS_FILE, $ret->getDBkey() );
+ # Sanity check the title namespace
+ } elseif ( $ret->getNamespace() !== NS_FILE ) {
+ $ret = null;
+ }
+ } else {
+ # Convert strings to Title objects
+ $ret = Title::makeTitleSafe( NS_FILE, (string)$ret );
+ }
+ if ( !$ret && $exception !== false ) {
+ throw new MWException( "`$title` is not a valid file title." );
+ }
+
+ return $ret;
+ }
+
+ function __get( $name ) {
+ $function = [ $this, 'get' . ucfirst( $name ) ];
+ if ( !is_callable( $function ) ) {
+ return null;
+ } else {
+ $this->$name = call_user_func( $function );
+
+ return $this->$name;
+ }
+ }
+
+ /**
+ * Normalize a file extension to the common form, making it lowercase and checking some synonyms,
+ * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded.
+ * Keep in sync with mw.Title.normalizeExtension() in JS.
+ *
+ * @param string $extension File extension (without the leading dot)
+ * @return string File extension in canonical form
+ */
+ static function normalizeExtension( $extension ) {
+ $lower = strtolower( $extension );
+ $squish = [
+ 'htm' => 'html',
+ 'jpeg' => 'jpg',
+ 'mpeg' => 'mpg',
+ 'tiff' => 'tif',
+ 'ogv' => 'ogg' ];
+ if ( isset( $squish[$lower] ) ) {
+ return $squish[$lower];
+ } elseif ( preg_match( '/^[0-9a-z]+$/', $lower ) ) {
+ return $lower;
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Checks if file extensions are compatible
+ *
+ * @param File $old Old file
+ * @param string $new New name
+ *
+ * @return bool|null
+ */
+ static function checkExtensionCompatibility( File $old, $new ) {
+ $oldMime = $old->getMimeType();
+ $n = strrpos( $new, '.' );
+ $newExt = self::normalizeExtension( $n ? substr( $new, $n + 1 ) : '' );
+ $mimeMagic = MimeMagic::singleton();
+
+ return $mimeMagic->isMatchingExtension( $newExt, $oldMime );
+ }
+
+ /**
+ * Upgrade the database row if there is one
+ * Called by ImagePage
+ * STUB
+ */
+ function upgradeRow() {
+ }
+
+ /**
+ * Split an internet media type into its two components; if not
+ * a two-part name, set the minor type to 'unknown'.
+ *
+ * @param string $mime "text/html" etc
+ * @return array ("text", "html") etc
+ */
+ public static function splitMime( $mime ) {
+ if ( strpos( $mime, '/' ) !== false ) {
+ return explode( '/', $mime, 2 );
+ } else {
+ return [ $mime, 'unknown' ];
+ }
+ }
+
+ /**
+ * Callback for usort() to do file sorts by name
+ *
+ * @param File $a
+ * @param File $b
+ * @return int Result of name comparison
+ */
+ public static function compare( File $a, File $b ) {
+ return strcmp( $a->getName(), $b->getName() );
+ }
+
+ /**
+ * Return the name of this file
+ *
+ * @return string
+ */
+ public function getName() {
+ if ( !isset( $this->name ) ) {
+ $this->assertRepoDefined();
+ $this->name = $this->repo->getNameFromTitle( $this->title );
+ }
+
+ return $this->name;
+ }
+
+ /**
+ * Get the file extension, e.g. "svg"
+ *
+ * @return string
+ */
+ function getExtension() {
+ if ( !isset( $this->extension ) ) {
+ $n = strrpos( $this->getName(), '.' );
+ $this->extension = self::normalizeExtension(
+ $n ? substr( $this->getName(), $n + 1 ) : '' );
+ }
+
+ return $this->extension;
+ }
+
+ /**
+ * Return the associated title object
+ *
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * Return the title used to find this file
+ *
+ * @return Title
+ */
+ public function getOriginalTitle() {
+ if ( $this->redirected ) {
+ return $this->getRedirectedTitle();
+ }
+
+ return $this->title;
+ }
+
+ /**
+ * Return the URL of the file
+ *
+ * @return string
+ */
+ public function getUrl() {
+ if ( !isset( $this->url ) ) {
+ $this->assertRepoDefined();
+ $ext = $this->getExtension();
+ $this->url = $this->repo->getZoneUrl( 'public', $ext ) . '/' . $this->getUrlRel();
+ }
+
+ return $this->url;
+ }
+
+ /**
+ * Get short description URL for a files based on the page ID
+ *
+ * @return string|null
+ * @since 1.27
+ */
+ public function getDescriptionShortUrl() {
+ return null;
+ }
+
+ /**
+ * Return a fully-qualified URL to the file.
+ * Upload URL paths _may or may not_ be fully qualified, so
+ * we check. Local paths are assumed to belong on $wgServer.
+ *
+ * @return string
+ */
+ public function getFullUrl() {
+ return wfExpandUrl( $this->getUrl(), PROTO_RELATIVE );
+ }
+
+ /**
+ * @return string
+ */
+ public function getCanonicalUrl() {
+ return wfExpandUrl( $this->getUrl(), PROTO_CANONICAL );
+ }
+
+ /**
+ * @return string
+ */
+ function getViewURL() {
+ if ( $this->mustRender() ) {
+ if ( $this->canRender() ) {
+ return $this->createThumb( $this->getWidth() );
+ } else {
+ wfDebug( __METHOD__ . ': supposed to render ' . $this->getName() .
+ ' (' . $this->getMimeType() . "), but can't!\n" );
+
+ return $this->getUrl(); # hm... return NULL?
+ }
+ } else {
+ return $this->getUrl();
+ }
+ }
+
+ /**
+ * Return the storage path to the file. Note that this does
+ * not mean that a file actually exists under that location.
+ *
+ * This path depends on whether directory hashing is active or not,
+ * i.e. whether the files are all found in the same directory,
+ * or in hashed paths like /images/3/3c.
+ *
+ * Most callers don't check the return value, but ForeignAPIFile::getPath
+ * returns false.
+ *
+ * @return string|bool ForeignAPIFile::getPath can return false
+ */
+ public function getPath() {
+ if ( !isset( $this->path ) ) {
+ $this->assertRepoDefined();
+ $this->path = $this->repo->getZonePath( 'public' ) . '/' . $this->getRel();
+ }
+
+ return $this->path;
+ }
+
+ /**
+ * Get an FS copy or original of this file and return the path.
+ * Returns false on failure. Callers must not alter the file.
+ * Temporary files are cleared automatically.
+ *
+ * @return string|bool False on failure
+ */
+ public function getLocalRefPath() {
+ $this->assertRepoDefined();
+ if ( !isset( $this->fsFile ) ) {
+ $starttime = microtime( true );
+ $this->fsFile = $this->repo->getLocalReference( $this->getPath() );
+
+ $statTiming = microtime( true ) - $starttime;
+ MediaWikiServices::getInstance()->getStatsdDataFactory()->timing(
+ 'media.thumbnail.generate.fetchoriginal', 1000 * $statTiming );
+
+ if ( !$this->fsFile ) {
+ $this->fsFile = false; // null => false; cache negative hits
+ }
+ }
+
+ return ( $this->fsFile )
+ ? $this->fsFile->getPath()
+ : false;
+ }
+
+ /**
+ * Return the width of the image. Returns false if the width is unknown
+ * or undefined.
+ *
+ * STUB
+ * Overridden by LocalFile, UnregisteredLocalFile
+ *
+ * @param int $page
+ * @return int|bool
+ */
+ public function getWidth( $page = 1 ) {
+ return false;
+ }
+
+ /**
+ * Return the height of the image. Returns false if the height is unknown
+ * or undefined
+ *
+ * STUB
+ * Overridden by LocalFile, UnregisteredLocalFile
+ *
+ * @param int $page
+ * @return bool|int False on failure
+ */
+ public function getHeight( $page = 1 ) {
+ return false;
+ }
+
+ /**
+ * Return the smallest bucket from $wgThumbnailBuckets which is at least
+ * $wgThumbnailMinimumBucketDistance larger than $desiredWidth. The returned bucket, if any,
+ * will always be bigger than $desiredWidth.
+ *
+ * @param int $desiredWidth
+ * @param int $page
+ * @return bool|int
+ */
+ public function getThumbnailBucket( $desiredWidth, $page = 1 ) {
+ global $wgThumbnailBuckets, $wgThumbnailMinimumBucketDistance;
+
+ $imageWidth = $this->getWidth( $page );
+
+ if ( $imageWidth === false ) {
+ return false;
+ }
+
+ if ( $desiredWidth > $imageWidth ) {
+ return false;
+ }
+
+ if ( !$wgThumbnailBuckets ) {
+ return false;
+ }
+
+ $sortedBuckets = $wgThumbnailBuckets;
+
+ sort( $sortedBuckets );
+
+ foreach ( $sortedBuckets as $bucket ) {
+ if ( $bucket >= $imageWidth ) {
+ return false;
+ }
+
+ if ( $bucket - $wgThumbnailMinimumBucketDistance > $desiredWidth ) {
+ return $bucket;
+ }
+ }
+
+ // Image is bigger than any available bucket
+ return false;
+ }
+
+ /**
+ * Returns ID or name of user who uploaded the file
+ * STUB
+ *
+ * @param string $type 'text' or 'id'
+ * @return string|int
+ */
+ public function getUser( $type = 'text' ) {
+ return null;
+ }
+
+ /**
+ * Get the duration of a media file in seconds
+ *
+ * @return float|int
+ */
+ public function getLength() {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ return $handler->getLength( $this );
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * Return true if the file is vectorized
+ *
+ * @return bool
+ */
+ public function isVectorized() {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ return $handler->isVectorized( $this );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Gives a (possibly empty) list of languages to render
+ * the file in.
+ *
+ * If the file doesn't have translations, or if the file
+ * format does not support that sort of thing, returns
+ * an empty array.
+ *
+ * @return array
+ * @since 1.23
+ */
+ public function getAvailableLanguages() {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ return $handler->getAvailableLanguages( $this );
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * In files that support multiple language, what is the default language
+ * to use if none specified.
+ *
+ * @return string|null Lang code, or null if filetype doesn't support multiple languages.
+ * @since 1.23
+ */
+ public function getDefaultRenderLanguage() {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ return $handler->getDefaultRenderLanguage( $this );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Will the thumbnail be animated if one would expect it to be.
+ *
+ * Currently used to add a warning to the image description page
+ *
+ * @return bool False if the main image is both animated
+ * and the thumbnail is not. In all other cases must return
+ * true. If image is not renderable whatsoever, should
+ * return true.
+ */
+ public function canAnimateThumbIfAppropriate() {
+ $handler = $this->getHandler();
+ if ( !$handler ) {
+ // We cannot handle image whatsoever, thus
+ // one would not expect it to be animated
+ // so true.
+ return true;
+ } else {
+ if ( $this->allowInlineDisplay()
+ && $handler->isAnimatedImage( $this )
+ && !$handler->canAnimateThumbnail( $this )
+ ) {
+ // Image is animated, but thumbnail isn't.
+ // This is unexpected to the user.
+ return false;
+ } else {
+ // Image is not animated, so one would
+ // not expect thumb to be
+ return true;
+ }
+ }
+ }
+
+ /**
+ * Get handler-specific metadata
+ * Overridden by LocalFile, UnregisteredLocalFile
+ * STUB
+ * @return bool|array
+ */
+ public function getMetadata() {
+ return false;
+ }
+
+ /**
+ * Like getMetadata but returns a handler independent array of common values.
+ * @see MediaHandler::getCommonMetaArray()
+ * @return array|bool Array or false if not supported
+ * @since 1.23
+ */
+ public function getCommonMetaArray() {
+ $handler = $this->getHandler();
+
+ if ( !$handler ) {
+ return false;
+ }
+
+ return $handler->getCommonMetaArray( $this );
+ }
+
+ /**
+ * get versioned metadata
+ *
+ * @param array|string $metadata Array or string of (serialized) metadata
+ * @param int $version Version number.
+ * @return array Array containing metadata, or what was passed to it on fail
+ * (unserializing if not array)
+ */
+ public function convertMetadataVersion( $metadata, $version ) {
+ $handler = $this->getHandler();
+ if ( !is_array( $metadata ) ) {
+ // Just to make the return type consistent
+ $metadata = unserialize( $metadata );
+ }
+ if ( $handler ) {
+ return $handler->convertMetadataVersion( $metadata, $version );
+ } else {
+ return $metadata;
+ }
+ }
+
+ /**
+ * Return the bit depth of the file
+ * Overridden by LocalFile
+ * STUB
+ * @return int
+ */
+ public function getBitDepth() {
+ return 0;
+ }
+
+ /**
+ * Return the size of the image file, in bytes
+ * Overridden by LocalFile, UnregisteredLocalFile
+ * STUB
+ * @return bool
+ */
+ public function getSize() {
+ return false;
+ }
+
+ /**
+ * Returns the MIME type of the file.
+ * Overridden by LocalFile, UnregisteredLocalFile
+ * STUB
+ *
+ * @return string
+ */
+ function getMimeType() {
+ return 'unknown/unknown';
+ }
+
+ /**
+ * Return the type of the media in the file.
+ * Use the value returned by this function with the MEDIATYPE_xxx constants.
+ * Overridden by LocalFile,
+ * STUB
+ * @return string
+ */
+ function getMediaType() {
+ return MEDIATYPE_UNKNOWN;
+ }
+
+ /**
+ * Checks if the output of transform() for this file is likely
+ * to be valid. If this is false, various user elements will
+ * display a placeholder instead.
+ *
+ * Currently, this checks if the file is an image format
+ * that can be converted to a format
+ * supported by all browsers (namely GIF, PNG and JPEG),
+ * or if it is an SVG image and SVG conversion is enabled.
+ *
+ * @return bool
+ */
+ function canRender() {
+ if ( !isset( $this->canRender ) ) {
+ $this->canRender = $this->getHandler() && $this->handler->canRender( $this ) && $this->exists();
+ }
+
+ return $this->canRender;
+ }
+
+ /**
+ * Accessor for __get()
+ * @return bool
+ */
+ protected function getCanRender() {
+ return $this->canRender();
+ }
+
+ /**
+ * Return true if the file is of a type that can't be directly
+ * rendered by typical browsers and needs to be re-rasterized.
+ *
+ * This returns true for everything but the bitmap types
+ * supported by all browsers, i.e. JPEG; GIF and PNG. It will
+ * also return true for any non-image formats.
+ *
+ * @return bool
+ */
+ function mustRender() {
+ return $this->getHandler() && $this->handler->mustRender( $this );
+ }
+
+ /**
+ * Alias for canRender()
+ *
+ * @return bool
+ */
+ function allowInlineDisplay() {
+ return $this->canRender();
+ }
+
+ /**
+ * Determines if this media file is in a format that is unlikely to
+ * contain viruses or malicious content. It uses the global
+ * $wgTrustedMediaFormats list to determine if the file is safe.
+ *
+ * This is used to show a warning on the description page of non-safe files.
+ * It may also be used to disallow direct [[media:...]] links to such files.
+ *
+ * Note that this function will always return true if allowInlineDisplay()
+ * or isTrustedFile() is true for this file.
+ *
+ * @return bool
+ */
+ function isSafeFile() {
+ if ( !isset( $this->isSafeFile ) ) {
+ $this->isSafeFile = $this->getIsSafeFileUncached();
+ }
+
+ return $this->isSafeFile;
+ }
+
+ /**
+ * Accessor for __get()
+ *
+ * @return bool
+ */
+ protected function getIsSafeFile() {
+ return $this->isSafeFile();
+ }
+
+ /**
+ * Uncached accessor
+ *
+ * @return bool
+ */
+ protected function getIsSafeFileUncached() {
+ global $wgTrustedMediaFormats;
+
+ if ( $this->allowInlineDisplay() ) {
+ return true;
+ }
+ if ( $this->isTrustedFile() ) {
+ return true;
+ }
+
+ $type = $this->getMediaType();
+ $mime = $this->getMimeType();
+ # wfDebug( "LocalFile::isSafeFile: type= $type, mime= $mime\n" );
+
+ if ( !$type || $type === MEDIATYPE_UNKNOWN ) {
+ return false; # unknown type, not trusted
+ }
+ if ( in_array( $type, $wgTrustedMediaFormats ) ) {
+ return true;
+ }
+
+ if ( $mime === "unknown/unknown" ) {
+ return false; # unknown type, not trusted
+ }
+ if ( in_array( $mime, $wgTrustedMediaFormats ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns true if the file is flagged as trusted. Files flagged that way
+ * can be linked to directly, even if that is not allowed for this type of
+ * file normally.
+ *
+ * This is a dummy function right now and always returns false. It could be
+ * implemented to extract a flag from the database. The trusted flag could be
+ * set on upload, if the user has sufficient privileges, to bypass script-
+ * and html-filters. It may even be coupled with cryptographics signatures
+ * or such.
+ *
+ * @return bool
+ */
+ function isTrustedFile() {
+ # this could be implemented to check a flag in the database,
+ # look for signatures, etc
+ return false;
+ }
+
+ /**
+ * Load any lazy-loaded file object fields from source
+ *
+ * This is only useful when setting $flags
+ *
+ * Overridden by LocalFile to actually query the DB
+ *
+ * @param int $flags Bitfield of File::READ_* constants
+ */
+ public function load( $flags = 0 ) {
+ }
+
+ /**
+ * Returns true if file exists in the repository.
+ *
+ * Overridden by LocalFile to avoid unnecessary stat calls.
+ *
+ * @return bool Whether file exists in the repository.
+ */
+ public function exists() {
+ return $this->getPath() && $this->repo->fileExists( $this->path );
+ }
+
+ /**
+ * Returns true if file exists in the repository and can be included in a page.
+ * It would be unsafe to include private images, making public thumbnails inadvertently
+ *
+ * @return bool Whether file exists in the repository and is includable.
+ */
+ public function isVisible() {
+ return $this->exists();
+ }
+
+ /**
+ * @return string
+ */
+ function getTransformScript() {
+ if ( !isset( $this->transformScript ) ) {
+ $this->transformScript = false;
+ if ( $this->repo ) {
+ $script = $this->repo->getThumbScriptUrl();
+ if ( $script ) {
+ $this->transformScript = wfAppendQuery( $script, [ 'f' => $this->getName() ] );
+ }
+ }
+ }
+
+ return $this->transformScript;
+ }
+
+ /**
+ * Get a ThumbnailImage which is the same size as the source
+ *
+ * @param array $handlerParams
+ *
+ * @return ThumbnailImage|MediaTransformOutput|bool False on failure
+ */
+ function getUnscaledThumb( $handlerParams = [] ) {
+ $hp =& $handlerParams;
+ $page = isset( $hp['page'] ) ? $hp['page'] : false;
+ $width = $this->getWidth( $page );
+ if ( !$width ) {
+ return $this->iconThumb();
+ }
+ $hp['width'] = $width;
+ // be sure to ignore any height specification as well (T64258)
+ unset( $hp['height'] );
+
+ return $this->transform( $hp );
+ }
+
+ /**
+ * Return the file name of a thumbnail with the specified parameters.
+ * Use File::THUMB_FULL_NAME to always get a name like "<params>-<source>".
+ * Otherwise, the format may be "<params>-<source>" or "<params>-thumbnail.<ext>".
+ *
+ * @param array $params Handler-specific parameters
+ * @param int $flags Bitfield that supports THUMB_* constants
+ * @return string|null
+ */
+ public function thumbName( $params, $flags = 0 ) {
+ $name = ( $this->repo && !( $flags & self::THUMB_FULL_NAME ) )
+ ? $this->repo->nameForThumb( $this->getName() )
+ : $this->getName();
+
+ return $this->generateThumbName( $name, $params );
+ }
+
+ /**
+ * Generate a thumbnail file name from a name and specified parameters
+ *
+ * @param string $name
+ * @param array $params Parameters which will be passed to MediaHandler::makeParamString
+ * @return string|null
+ */
+ public function generateThumbName( $name, $params ) {
+ if ( !$this->getHandler() ) {
+ return null;
+ }
+ $extension = $this->getExtension();
+ list( $thumbExt, ) = $this->getHandler()->getThumbType(
+ $extension, $this->getMimeType(), $params );
+ $thumbName = $this->getHandler()->makeParamString( $params );
+
+ if ( $this->repo->supportsSha1URLs() ) {
+ $thumbName .= '-' . $this->getSha1() . '.' . $thumbExt;
+ } else {
+ $thumbName .= '-' . $name;
+
+ if ( $thumbExt != $extension ) {
+ $thumbName .= ".$thumbExt";
+ }
+ }
+
+ return $thumbName;
+ }
+
+ /**
+ * Create a thumbnail of the image having the specified width/height.
+ * The thumbnail will not be created if the width is larger than the
+ * image's width. Let the browser do the scaling in this case.
+ * The thumbnail is stored on disk and is only computed if the thumbnail
+ * file does not exist OR if it is older than the image.
+ * Returns the URL.
+ *
+ * Keeps aspect ratio of original image. If both width and height are
+ * specified, the generated image will be no bigger than width x height,
+ * and will also have correct aspect ratio.
+ *
+ * @param int $width Maximum width of the generated thumbnail
+ * @param int $height Maximum height of the image (optional)
+ *
+ * @return string
+ */
+ public function createThumb( $width, $height = -1 ) {
+ $params = [ 'width' => $width ];
+ if ( $height != -1 ) {
+ $params['height'] = $height;
+ }
+ $thumb = $this->transform( $params );
+ if ( !$thumb || $thumb->isError() ) {
+ return '';
+ }
+
+ return $thumb->getUrl();
+ }
+
+ /**
+ * Return either a MediaTransformError or placeholder thumbnail (if $wgIgnoreImageErrors)
+ *
+ * @param string $thumbPath Thumbnail storage path
+ * @param string $thumbUrl Thumbnail URL
+ * @param array $params
+ * @param int $flags
+ * @return MediaTransformOutput
+ */
+ protected function transformErrorOutput( $thumbPath, $thumbUrl, $params, $flags ) {
+ global $wgIgnoreImageErrors;
+
+ $handler = $this->getHandler();
+ if ( $handler && $wgIgnoreImageErrors && !( $flags & self::RENDER_NOW ) ) {
+ return $handler->getTransform( $this, $thumbPath, $thumbUrl, $params );
+ } else {
+ return new MediaTransformError( 'thumbnail_error',
+ $params['width'], 0, wfMessage( 'thumbnail-dest-create' ) );
+ }
+ }
+
+ /**
+ * Transform a media file
+ *
+ * @param array $params An associative array of handler-specific parameters.
+ * Typical keys are width, height and page.
+ * @param int $flags A bitfield, may contain self::RENDER_NOW to force rendering
+ * @return ThumbnailImage|MediaTransformOutput|bool False on failure
+ */
+ function transform( $params, $flags = 0 ) {
+ global $wgThumbnailEpoch;
+
+ do {
+ if ( !$this->canRender() ) {
+ $thumb = $this->iconThumb();
+ break; // not a bitmap or renderable image, don't try
+ }
+
+ // Get the descriptionUrl to embed it as comment into the thumbnail. T21791.
+ $descriptionUrl = $this->getDescriptionUrl();
+ if ( $descriptionUrl ) {
+ $params['descriptionUrl'] = wfExpandUrl( $descriptionUrl, PROTO_CANONICAL );
+ }
+
+ $handler = $this->getHandler();
+ $script = $this->getTransformScript();
+ if ( $script && !( $flags & self::RENDER_NOW ) ) {
+ // Use a script to transform on client request, if possible
+ $thumb = $handler->getScriptedTransform( $this, $script, $params );
+ if ( $thumb ) {
+ break;
+ }
+ }
+
+ $normalisedParams = $params;
+ $handler->normaliseParams( $this, $normalisedParams );
+
+ $thumbName = $this->thumbName( $normalisedParams );
+ $thumbUrl = $this->getThumbUrl( $thumbName );
+ $thumbPath = $this->getThumbPath( $thumbName ); // final thumb path
+
+ if ( $this->repo ) {
+ // Defer rendering if a 404 handler is set up...
+ if ( $this->repo->canTransformVia404() && !( $flags & self::RENDER_NOW ) ) {
+ // XXX: Pass in the storage path even though we are not rendering anything
+ // and the path is supposed to be an FS path. This is due to getScalerType()
+ // getting called on the path and clobbering $thumb->getUrl() if it's false.
+ $thumb = $handler->getTransform( $this, $thumbPath, $thumbUrl, $params );
+ break;
+ }
+ // Check if an up-to-date thumbnail already exists...
+ wfDebug( __METHOD__ . ": Doing stat for $thumbPath\n" );
+ if ( !( $flags & self::RENDER_FORCE ) && $this->repo->fileExists( $thumbPath ) ) {
+ $timestamp = $this->repo->getFileTimestamp( $thumbPath );
+ if ( $timestamp !== false && $timestamp >= $wgThumbnailEpoch ) {
+ // XXX: Pass in the storage path even though we are not rendering anything
+ // and the path is supposed to be an FS path. This is due to getScalerType()
+ // getting called on the path and clobbering $thumb->getUrl() if it's false.
+ $thumb = $handler->getTransform( $this, $thumbPath, $thumbUrl, $params );
+ $thumb->setStoragePath( $thumbPath );
+ break;
+ }
+ } elseif ( $flags & self::RENDER_FORCE ) {
+ wfDebug( __METHOD__ . " forcing rendering per flag File::RENDER_FORCE\n" );
+ }
+
+ // If the backend is ready-only, don't keep generating thumbnails
+ // only to return transformation errors, just return the error now.
+ if ( $this->repo->getReadOnlyReason() !== false ) {
+ $thumb = $this->transformErrorOutput( $thumbPath, $thumbUrl, $params, $flags );
+ break;
+ }
+ }
+
+ $tmpFile = $this->makeTransformTmpFile( $thumbPath );
+
+ if ( !$tmpFile ) {
+ $thumb = $this->transformErrorOutput( $thumbPath, $thumbUrl, $params, $flags );
+ } else {
+ $thumb = $this->generateAndSaveThumb( $tmpFile, $params, $flags );
+ }
+ } while ( false );
+
+ return is_object( $thumb ) ? $thumb : false;
+ }
+
+ /**
+ * Generates a thumbnail according to the given parameters and saves it to storage
+ * @param TempFSFile $tmpFile Temporary file where the rendered thumbnail will be saved
+ * @param array $transformParams
+ * @param int $flags
+ * @return bool|MediaTransformOutput
+ */
+ public function generateAndSaveThumb( $tmpFile, $transformParams, $flags ) {
+ global $wgIgnoreImageErrors;
+
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+
+ $handler = $this->getHandler();
+
+ $normalisedParams = $transformParams;
+ $handler->normaliseParams( $this, $normalisedParams );
+
+ $thumbName = $this->thumbName( $normalisedParams );
+ $thumbUrl = $this->getThumbUrl( $thumbName );
+ $thumbPath = $this->getThumbPath( $thumbName ); // final thumb path
+
+ $tmpThumbPath = $tmpFile->getPath();
+
+ if ( $handler->supportsBucketing() ) {
+ $this->generateBucketsIfNeeded( $normalisedParams, $flags );
+ }
+
+ $starttime = microtime( true );
+
+ // Actually render the thumbnail...
+ $thumb = $handler->doTransform( $this, $tmpThumbPath, $thumbUrl, $transformParams );
+ $tmpFile->bind( $thumb ); // keep alive with $thumb
+
+ $statTiming = microtime( true ) - $starttime;
+ $stats->timing( 'media.thumbnail.generate.transform', 1000 * $statTiming );
+
+ if ( !$thumb ) { // bad params?
+ $thumb = false;
+ } elseif ( $thumb->isError() ) { // transform error
+ /** @var MediaTransformError $thumb */
+ $this->lastError = $thumb->toText();
+ // Ignore errors if requested
+ if ( $wgIgnoreImageErrors && !( $flags & self::RENDER_NOW ) ) {
+ $thumb = $handler->getTransform( $this, $tmpThumbPath, $thumbUrl, $transformParams );
+ }
+ } elseif ( $this->repo && $thumb->hasFile() && !$thumb->fileIsSource() ) {
+ // Copy the thumbnail from the file system into storage...
+
+ $starttime = microtime( true );
+
+ $disposition = $this->getThumbDisposition( $thumbName );
+ $status = $this->repo->quickImport( $tmpThumbPath, $thumbPath, $disposition );
+ if ( $status->isOK() ) {
+ $thumb->setStoragePath( $thumbPath );
+ } else {
+ $thumb = $this->transformErrorOutput( $thumbPath, $thumbUrl, $transformParams, $flags );
+ }
+
+ $statTiming = microtime( true ) - $starttime;
+ $stats->timing( 'media.thumbnail.generate.store', 1000 * $statTiming );
+
+ // Give extensions a chance to do something with this thumbnail...
+ Hooks::run( 'FileTransformed', [ $this, $thumb, $tmpThumbPath, $thumbPath ] );
+ }
+
+ return $thumb;
+ }
+
+ /**
+ * Generates chained bucketed thumbnails if needed
+ * @param array $params
+ * @param int $flags
+ * @return bool Whether at least one bucket was generated
+ */
+ protected function generateBucketsIfNeeded( $params, $flags = 0 ) {
+ if ( !$this->repo
+ || !isset( $params['physicalWidth'] )
+ || !isset( $params['physicalHeight'] )
+ ) {
+ return false;
+ }
+
+ $bucket = $this->getThumbnailBucket( $params['physicalWidth'] );
+
+ if ( !$bucket || $bucket == $params['physicalWidth'] ) {
+ return false;
+ }
+
+ $bucketPath = $this->getBucketThumbPath( $bucket );
+
+ if ( $this->repo->fileExists( $bucketPath ) ) {
+ return false;
+ }
+
+ $starttime = microtime( true );
+
+ $params['physicalWidth'] = $bucket;
+ $params['width'] = $bucket;
+
+ $params = $this->getHandler()->sanitizeParamsForBucketing( $params );
+
+ $tmpFile = $this->makeTransformTmpFile( $bucketPath );
+
+ if ( !$tmpFile ) {
+ return false;
+ }
+
+ $thumb = $this->generateAndSaveThumb( $tmpFile, $params, $flags );
+
+ $buckettime = microtime( true ) - $starttime;
+
+ if ( !$thumb || $thumb->isError() ) {
+ return false;
+ }
+
+ $this->tmpBucketedThumbCache[$bucket] = $tmpFile->getPath();
+ // For the caching to work, we need to make the tmp file survive as long as
+ // this object exists
+ $tmpFile->bind( $this );
+
+ MediaWikiServices::getInstance()->getStatsdDataFactory()->timing(
+ 'media.thumbnail.generate.bucket', 1000 * $buckettime );
+
+ return true;
+ }
+
+ /**
+ * Returns the most appropriate source image for the thumbnail, given a target thumbnail size
+ * @param array $params
+ * @return array Source path and width/height of the source
+ */
+ public function getThumbnailSource( $params ) {
+ if ( $this->repo
+ && $this->getHandler()->supportsBucketing()
+ && isset( $params['physicalWidth'] )
+ && $bucket = $this->getThumbnailBucket( $params['physicalWidth'] )
+ ) {
+ if ( $this->getWidth() != 0 ) {
+ $bucketHeight = round( $this->getHeight() * ( $bucket / $this->getWidth() ) );
+ } else {
+ $bucketHeight = 0;
+ }
+
+ // Try to avoid reading from storage if the file was generated by this script
+ if ( isset( $this->tmpBucketedThumbCache[$bucket] ) ) {
+ $tmpPath = $this->tmpBucketedThumbCache[$bucket];
+
+ if ( file_exists( $tmpPath ) ) {
+ return [
+ 'path' => $tmpPath,
+ 'width' => $bucket,
+ 'height' => $bucketHeight
+ ];
+ }
+ }
+
+ $bucketPath = $this->getBucketThumbPath( $bucket );
+
+ if ( $this->repo->fileExists( $bucketPath ) ) {
+ $fsFile = $this->repo->getLocalReference( $bucketPath );
+
+ if ( $fsFile ) {
+ return [
+ 'path' => $fsFile->getPath(),
+ 'width' => $bucket,
+ 'height' => $bucketHeight
+ ];
+ }
+ }
+ }
+
+ // Thumbnailing a very large file could result in network saturation if
+ // everyone does it at once.
+ if ( $this->getSize() >= 1e7 ) { // 10MB
+ $work = new PoolCounterWorkViaCallback( 'GetLocalFileCopy', sha1( $this->getName() ),
+ [
+ 'doWork' => function () {
+ return $this->getLocalRefPath();
+ }
+ ]
+ );
+ $srcPath = $work->execute();
+ } else {
+ $srcPath = $this->getLocalRefPath();
+ }
+
+ // Original file
+ return [
+ 'path' => $srcPath,
+ 'width' => $this->getWidth(),
+ 'height' => $this->getHeight()
+ ];
+ }
+
+ /**
+ * Returns the repo path of the thumb for a given bucket
+ * @param int $bucket
+ * @return string
+ */
+ protected function getBucketThumbPath( $bucket ) {
+ $thumbName = $this->getBucketThumbName( $bucket );
+ return $this->getThumbPath( $thumbName );
+ }
+
+ /**
+ * Returns the name of the thumb for a given bucket
+ * @param int $bucket
+ * @return string
+ */
+ protected function getBucketThumbName( $bucket ) {
+ return $this->thumbName( [ 'physicalWidth' => $bucket ] );
+ }
+
+ /**
+ * Creates a temp FS file with the same extension and the thumbnail
+ * @param string $thumbPath Thumbnail path
+ * @return TempFSFile|null
+ */
+ protected function makeTransformTmpFile( $thumbPath ) {
+ $thumbExt = FileBackend::extensionFromPath( $thumbPath );
+ return TempFSFile::factory( 'transform_', $thumbExt, wfTempDir() );
+ }
+
+ /**
+ * @param string $thumbName Thumbnail name
+ * @param string $dispositionType Type of disposition (either "attachment" or "inline")
+ * @return string Content-Disposition header value
+ */
+ function getThumbDisposition( $thumbName, $dispositionType = 'inline' ) {
+ $fileName = $this->name; // file name to suggest
+ $thumbExt = FileBackend::extensionFromPath( $thumbName );
+ if ( $thumbExt != '' && $thumbExt !== $this->getExtension() ) {
+ $fileName .= ".$thumbExt";
+ }
+
+ return FileBackend::makeContentDisposition( $dispositionType, $fileName );
+ }
+
+ /**
+ * Hook into transform() to allow migration of thumbnail files
+ * STUB
+ * Overridden by LocalFile
+ * @param string $thumbName
+ */
+ function migrateThumbFile( $thumbName ) {
+ }
+
+ /**
+ * Get a MediaHandler instance for this file
+ *
+ * @return MediaHandler|bool Registered MediaHandler for file's MIME type
+ * or false if none found
+ */
+ function getHandler() {
+ if ( !isset( $this->handler ) ) {
+ $this->handler = MediaHandler::getHandler( $this->getMimeType() );
+ }
+
+ return $this->handler;
+ }
+
+ /**
+ * Get a ThumbnailImage representing a file type icon
+ *
+ * @return ThumbnailImage
+ */
+ function iconThumb() {
+ global $wgResourceBasePath, $IP;
+ $assetsPath = "$wgResourceBasePath/resources/assets/file-type-icons/";
+ $assetsDirectory = "$IP/resources/assets/file-type-icons/";
+
+ $try = [ 'fileicon-' . $this->getExtension() . '.png', 'fileicon.png' ];
+ foreach ( $try as $icon ) {
+ if ( file_exists( $assetsDirectory . $icon ) ) { // always FS
+ $params = [ 'width' => 120, 'height' => 120 ];
+
+ return new ThumbnailImage( $this, $assetsPath . $icon, false, $params );
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get last thumbnailing error.
+ * Largely obsolete.
+ * @return string
+ */
+ function getLastError() {
+ return $this->lastError;
+ }
+
+ /**
+ * Get all thumbnail names previously generated for this file
+ * STUB
+ * Overridden by LocalFile
+ * @return array
+ */
+ function getThumbnails() {
+ return [];
+ }
+
+ /**
+ * Purge shared caches such as thumbnails and DB data caching
+ * STUB
+ * Overridden by LocalFile
+ * @param array $options Options, which include:
+ * 'forThumbRefresh' : The purging is only to refresh thumbnails
+ */
+ function purgeCache( $options = [] ) {
+ }
+
+ /**
+ * Purge the file description page, but don't go after
+ * pages using the file. Use when modifying file history
+ * but not the current data.
+ */
+ function purgeDescription() {
+ $title = $this->getTitle();
+ if ( $title ) {
+ $title->invalidateCache();
+ $title->purgeSquid();
+ }
+ }
+
+ /**
+ * Purge metadata and all affected pages when the file is created,
+ * deleted, or majorly updated.
+ */
+ function purgeEverything() {
+ // Delete thumbnails and refresh file metadata cache
+ $this->purgeCache();
+ $this->purgeDescription();
+
+ // Purge cache of all pages using this file
+ $title = $this->getTitle();
+ if ( $title ) {
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'imagelinks' ) );
+ }
+ }
+
+ /**
+ * Return a fragment of the history of file.
+ *
+ * STUB
+ * @param int $limit Limit of rows to return
+ * @param string $start Only revisions older than $start will be returned
+ * @param string $end Only revisions newer than $end will be returned
+ * @param bool $inc Include the endpoints of the time range
+ *
+ * @return File[]
+ */
+ function getHistory( $limit = null, $start = null, $end = null, $inc = true ) {
+ return [];
+ }
+
+ /**
+ * Return the history of this file, line by line. Starts with current version,
+ * then old versions. Should return an object similar to an image/oldimage
+ * database row.
+ *
+ * STUB
+ * Overridden in LocalFile
+ * @return bool
+ */
+ public function nextHistoryLine() {
+ return false;
+ }
+
+ /**
+ * Reset the history pointer to the first element of the history.
+ * Always call this function after using nextHistoryLine() to free db resources
+ * STUB
+ * Overridden in LocalFile.
+ */
+ public function resetHistory() {
+ }
+
+ /**
+ * Get the filename hash component of the directory including trailing slash,
+ * e.g. f/fa/
+ * If the repository is not hashed, returns an empty string.
+ *
+ * @return string
+ */
+ function getHashPath() {
+ if ( !isset( $this->hashPath ) ) {
+ $this->assertRepoDefined();
+ $this->hashPath = $this->repo->getHashPath( $this->getName() );
+ }
+
+ return $this->hashPath;
+ }
+
+ /**
+ * Get the path of the file relative to the public zone root.
+ * This function is overridden in OldLocalFile to be like getArchiveRel().
+ *
+ * @return string
+ */
+ function getRel() {
+ return $this->getHashPath() . $this->getName();
+ }
+
+ /**
+ * Get the path of an archived file relative to the public zone root
+ *
+ * @param bool|string $suffix If not false, the name of an archived thumbnail file
+ *
+ * @return string
+ */
+ function getArchiveRel( $suffix = false ) {
+ $path = 'archive/' . $this->getHashPath();
+ if ( $suffix === false ) {
+ $path = substr( $path, 0, -1 );
+ } else {
+ $path .= $suffix;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get the path, relative to the thumbnail zone root, of the
+ * thumbnail directory or a particular file if $suffix is specified
+ *
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string
+ */
+ function getThumbRel( $suffix = false ) {
+ $path = $this->getRel();
+ if ( $suffix !== false ) {
+ $path .= '/' . $suffix;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get urlencoded path of the file relative to the public zone root.
+ * This function is overridden in OldLocalFile to be like getArchiveUrl().
+ *
+ * @return string
+ */
+ function getUrlRel() {
+ return $this->getHashPath() . rawurlencode( $this->getName() );
+ }
+
+ /**
+ * Get the path, relative to the thumbnail zone root, for an archived file's thumbs directory
+ * or a specific thumb if the $suffix is given.
+ *
+ * @param string $archiveName The timestamped name of an archived image
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string
+ */
+ function getArchiveThumbRel( $archiveName, $suffix = false ) {
+ $path = 'archive/' . $this->getHashPath() . $archiveName . "/";
+ if ( $suffix === false ) {
+ $path = substr( $path, 0, -1 );
+ } else {
+ $path .= $suffix;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get the path of the archived file.
+ *
+ * @param bool|string $suffix If not false, the name of an archived file.
+ * @return string
+ */
+ function getArchivePath( $suffix = false ) {
+ $this->assertRepoDefined();
+
+ return $this->repo->getZonePath( 'public' ) . '/' . $this->getArchiveRel( $suffix );
+ }
+
+ /**
+ * Get the path of an archived file's thumbs, or a particular thumb if $suffix is specified
+ *
+ * @param string $archiveName The timestamped name of an archived image
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string
+ */
+ function getArchiveThumbPath( $archiveName, $suffix = false ) {
+ $this->assertRepoDefined();
+
+ return $this->repo->getZonePath( 'thumb' ) . '/' .
+ $this->getArchiveThumbRel( $archiveName, $suffix );
+ }
+
+ /**
+ * Get the path of the thumbnail directory, or a particular file if $suffix is specified
+ *
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string
+ */
+ function getThumbPath( $suffix = false ) {
+ $this->assertRepoDefined();
+
+ return $this->repo->getZonePath( 'thumb' ) . '/' . $this->getThumbRel( $suffix );
+ }
+
+ /**
+ * Get the path of the transcoded directory, or a particular file if $suffix is specified
+ *
+ * @param bool|string $suffix If not false, the name of a media file
+ * @return string
+ */
+ function getTranscodedPath( $suffix = false ) {
+ $this->assertRepoDefined();
+
+ return $this->repo->getZonePath( 'transcoded' ) . '/' . $this->getThumbRel( $suffix );
+ }
+
+ /**
+ * Get the URL of the archive directory, or a particular file if $suffix is specified
+ *
+ * @param bool|string $suffix If not false, the name of an archived file
+ * @return string
+ */
+ function getArchiveUrl( $suffix = false ) {
+ $this->assertRepoDefined();
+ $ext = $this->getExtension();
+ $path = $this->repo->getZoneUrl( 'public', $ext ) . '/archive/' . $this->getHashPath();
+ if ( $suffix === false ) {
+ $path = substr( $path, 0, -1 );
+ } else {
+ $path .= rawurlencode( $suffix );
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get the URL of the archived file's thumbs, or a particular thumb if $suffix is specified
+ *
+ * @param string $archiveName The timestamped name of an archived image
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string
+ */
+ function getArchiveThumbUrl( $archiveName, $suffix = false ) {
+ $this->assertRepoDefined();
+ $ext = $this->getExtension();
+ $path = $this->repo->getZoneUrl( 'thumb', $ext ) . '/archive/' .
+ $this->getHashPath() . rawurlencode( $archiveName ) . "/";
+ if ( $suffix === false ) {
+ $path = substr( $path, 0, -1 );
+ } else {
+ $path .= rawurlencode( $suffix );
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get the URL of the zone directory, or a particular file if $suffix is specified
+ *
+ * @param string $zone Name of requested zone
+ * @param bool|string $suffix If not false, the name of a file in zone
+ * @return string Path
+ */
+ function getZoneUrl( $zone, $suffix = false ) {
+ $this->assertRepoDefined();
+ $ext = $this->getExtension();
+ $path = $this->repo->getZoneUrl( $zone, $ext ) . '/' . $this->getUrlRel();
+ if ( $suffix !== false ) {
+ $path .= '/' . rawurlencode( $suffix );
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get the URL of the thumbnail directory, or a particular file if $suffix is specified
+ *
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string Path
+ */
+ function getThumbUrl( $suffix = false ) {
+ return $this->getZoneUrl( 'thumb', $suffix );
+ }
+
+ /**
+ * Get the URL of the transcoded directory, or a particular file if $suffix is specified
+ *
+ * @param bool|string $suffix If not false, the name of a media file
+ * @return string Path
+ */
+ function getTranscodedUrl( $suffix = false ) {
+ return $this->getZoneUrl( 'transcoded', $suffix );
+ }
+
+ /**
+ * Get the public zone virtual URL for a current version source file
+ *
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string
+ */
+ function getVirtualUrl( $suffix = false ) {
+ $this->assertRepoDefined();
+ $path = $this->repo->getVirtualUrl() . '/public/' . $this->getUrlRel();
+ if ( $suffix !== false ) {
+ $path .= '/' . rawurlencode( $suffix );
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get the public zone virtual URL for an archived version source file
+ *
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string
+ */
+ function getArchiveVirtualUrl( $suffix = false ) {
+ $this->assertRepoDefined();
+ $path = $this->repo->getVirtualUrl() . '/public/archive/' . $this->getHashPath();
+ if ( $suffix === false ) {
+ $path = substr( $path, 0, -1 );
+ } else {
+ $path .= rawurlencode( $suffix );
+ }
+
+ return $path;
+ }
+
+ /**
+ * Get the virtual URL for a thumbnail file or directory
+ *
+ * @param bool|string $suffix If not false, the name of a thumbnail file
+ * @return string
+ */
+ function getThumbVirtualUrl( $suffix = false ) {
+ $this->assertRepoDefined();
+ $path = $this->repo->getVirtualUrl() . '/thumb/' . $this->getUrlRel();
+ if ( $suffix !== false ) {
+ $path .= '/' . rawurlencode( $suffix );
+ }
+
+ return $path;
+ }
+
+ /**
+ * @return bool
+ */
+ function isHashed() {
+ $this->assertRepoDefined();
+
+ return (bool)$this->repo->getHashLevels();
+ }
+
+ /**
+ * @throws MWException
+ */
+ function readOnlyError() {
+ throw new MWException( static::class . ': write operations are not supported' );
+ }
+
+ /**
+ * Record a file upload in the upload log and the image table
+ * STUB
+ * Overridden by LocalFile
+ * @param string $oldver
+ * @param string $desc
+ * @param string $license
+ * @param string $copyStatus
+ * @param string $source
+ * @param bool $watch
+ * @param string|bool $timestamp
+ * @param null|User $user User object or null to use $wgUser
+ * @return bool
+ * @throws MWException
+ */
+ function recordUpload( $oldver, $desc, $license = '', $copyStatus = '', $source = '',
+ $watch = false, $timestamp = false, User $user = null
+ ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * Move or copy a file to its public location. If a file exists at the
+ * destination, move it to an archive. Returns a Status object with
+ * the archive name in the "value" member on success.
+ *
+ * The archive name should be passed through to recordUpload for database
+ * registration.
+ *
+ * Options to $options include:
+ * - headers : name/value map of HTTP headers to use in response to GET/HEAD requests
+ *
+ * @param string|FSFile $src Local filesystem path to the source image
+ * @param int $flags A bitwise combination of:
+ * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy
+ * @param array $options Optional additional parameters
+ * @return Status On success, the value member contains the
+ * archive name, or an empty string if it was a new file.
+ *
+ * STUB
+ * Overridden by LocalFile
+ */
+ function publish( $src, $flags = 0, array $options = [] ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * @param bool|IContextSource $context Context to use (optional)
+ * @return bool
+ */
+ function formatMetadata( $context = false ) {
+ if ( !$this->getHandler() ) {
+ return false;
+ }
+
+ return $this->getHandler()->formatMetadata( $this, $context );
+ }
+
+ /**
+ * Returns true if the file comes from the local file repository.
+ *
+ * @return bool
+ */
+ function isLocal() {
+ return $this->repo && $this->repo->isLocal();
+ }
+
+ /**
+ * Returns the name of the repository.
+ *
+ * @return string
+ */
+ function getRepoName() {
+ return $this->repo ? $this->repo->getName() : 'unknown';
+ }
+
+ /**
+ * Returns the repository
+ *
+ * @return FileRepo|LocalRepo|bool
+ */
+ function getRepo() {
+ return $this->repo;
+ }
+
+ /**
+ * Returns true if the image is an old version
+ * STUB
+ *
+ * @return bool
+ */
+ function isOld() {
+ return false;
+ }
+
+ /**
+ * Is this file a "deleted" file in a private archive?
+ * STUB
+ *
+ * @param int $field One of DELETED_* bitfield constants
+ * @return bool
+ */
+ function isDeleted( $field ) {
+ return false;
+ }
+
+ /**
+ * Return the deletion bitfield
+ * STUB
+ * @return int
+ */
+ function getVisibility() {
+ return 0;
+ }
+
+ /**
+ * Was this file ever deleted from the wiki?
+ *
+ * @return bool
+ */
+ function wasDeleted() {
+ $title = $this->getTitle();
+
+ return $title && $title->isDeletedQuick();
+ }
+
+ /**
+ * Move file to the new title
+ *
+ * Move current, old version and all thumbnails
+ * to the new filename. Old file is deleted.
+ *
+ * Cache purging is done; checks for validity
+ * and logging are caller's responsibility
+ *
+ * @param Title $target New file name
+ * @return Status
+ */
+ function move( $target ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * Delete all versions of the file.
+ *
+ * Moves the files into an archive directory (or deletes them)
+ * and removes the database rows.
+ *
+ * Cache purging is done; logging is caller's responsibility.
+ *
+ * @param string $reason
+ * @param bool $suppress Hide content from sysops?
+ * @param User|null $user
+ * @return Status
+ * STUB
+ * Overridden by LocalFile
+ */
+ function delete( $reason, $suppress = false, $user = null ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * Restore all or specified deleted revisions to the given file.
+ * Permissions and logging are left to the caller.
+ *
+ * May throw database exceptions on error.
+ *
+ * @param array $versions Set of record ids of deleted items to restore,
+ * or empty to restore all revisions.
+ * @param bool $unsuppress Remove restrictions on content upon restoration?
+ * @return int|bool The number of file revisions restored if successful,
+ * or false on failure
+ * STUB
+ * Overridden by LocalFile
+ */
+ function restore( $versions = [], $unsuppress = false ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * Returns 'true' if this file is a type which supports multiple pages,
+ * e.g. DJVU or PDF. Note that this may be true even if the file in
+ * question only has a single page.
+ *
+ * @return bool
+ */
+ function isMultipage() {
+ return $this->getHandler() && $this->handler->isMultiPage( $this );
+ }
+
+ /**
+ * Returns the number of pages of a multipage document, or false for
+ * documents which aren't multipage documents
+ *
+ * @return string|bool|int
+ */
+ function pageCount() {
+ if ( !isset( $this->pageCount ) ) {
+ if ( $this->getHandler() && $this->handler->isMultiPage( $this ) ) {
+ $this->pageCount = $this->handler->pageCount( $this );
+ } else {
+ $this->pageCount = false;
+ }
+ }
+
+ return $this->pageCount;
+ }
+
+ /**
+ * Calculate the height of a thumbnail using the source and destination width
+ *
+ * @param int $srcWidth
+ * @param int $srcHeight
+ * @param int $dstWidth
+ *
+ * @return int
+ */
+ static function scaleHeight( $srcWidth, $srcHeight, $dstWidth ) {
+ // Exact integer multiply followed by division
+ if ( $srcWidth == 0 ) {
+ return 0;
+ } else {
+ return (int)round( $srcHeight * $dstWidth / $srcWidth );
+ }
+ }
+
+ /**
+ * Get an image size array like that returned by getImageSize(), or false if it
+ * can't be determined. Loads the image size directly from the file ignoring caches.
+ *
+ * @note Use getWidth()/getHeight() instead of this method unless you have a
+ * a good reason. This method skips all caches.
+ *
+ * @param string $filePath The path to the file (e.g. From getLocalPathRef() )
+ * @return array|false The width, followed by height, with optionally more things after
+ */
+ function getImageSize( $filePath ) {
+ if ( !$this->getHandler() ) {
+ return false;
+ }
+
+ return $this->getHandler()->getImageSize( $this, $filePath );
+ }
+
+ /**
+ * Get the URL of the image description page. May return false if it is
+ * unknown or not applicable.
+ *
+ * @return string
+ */
+ function getDescriptionUrl() {
+ if ( $this->repo ) {
+ return $this->repo->getDescriptionUrl( $this->getName() );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get the HTML text of the description page, if available
+ *
+ * @param bool|Language $lang Optional language to fetch description in
+ * @return string|false
+ */
+ function getDescriptionText( $lang = false ) {
+ global $wgLang;
+
+ if ( !$this->repo || !$this->repo->fetchDescription ) {
+ return false;
+ }
+
+ $lang = $lang ?: $wgLang;
+
+ $renderUrl = $this->repo->getDescriptionRenderUrl( $this->getName(), $lang->getCode() );
+ if ( $renderUrl ) {
+ $cache = ObjectCache::getMainWANInstance();
+ $key = $this->repo->getLocalCacheKey(
+ 'RemoteFileDescription',
+ 'url',
+ $lang->getCode(),
+ $this->getName()
+ );
+
+ return $cache->getWithSetCallback(
+ $key,
+ $this->repo->descriptionCacheExpiry ?: $cache::TTL_UNCACHEABLE,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $renderUrl ) {
+ wfDebug( "Fetching shared description from $renderUrl\n" );
+ $res = Http::get( $renderUrl, [], __METHOD__ );
+ if ( !$res ) {
+ $ttl = WANObjectCache::TTL_UNCACHEABLE;
+ }
+
+ return $res;
+ }
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Get description of file revision
+ * STUB
+ *
+ * @param int $audience One of:
+ * File::FOR_PUBLIC to be displayed to all users
+ * File::FOR_THIS_USER to be displayed to the given user
+ * File::RAW get the description regardless of permissions
+ * @param User $user User object to check for, only if FOR_THIS_USER is
+ * passed to the $audience parameter
+ * @return string
+ */
+ function getDescription( $audience = self::FOR_PUBLIC, User $user = null ) {
+ return null;
+ }
+
+ /**
+ * Get the 14-character timestamp of the file upload
+ *
+ * @return string|bool TS_MW timestamp or false on failure
+ */
+ function getTimestamp() {
+ $this->assertRepoDefined();
+
+ return $this->repo->getFileTimestamp( $this->getPath() );
+ }
+
+ /**
+ * Returns the timestamp (in TS_MW format) of the last change of the description page.
+ * Returns false if the file does not have a description page, or retrieving the timestamp
+ * would be expensive.
+ * @since 1.25
+ * @return string|bool
+ */
+ public function getDescriptionTouched() {
+ return false;
+ }
+
+ /**
+ * Get the SHA-1 base 36 hash of the file
+ *
+ * @return string
+ */
+ function getSha1() {
+ $this->assertRepoDefined();
+
+ return $this->repo->getFileSha1( $this->getPath() );
+ }
+
+ /**
+ * Get the deletion archive key, "<sha1>.<ext>"
+ *
+ * @return string|false
+ */
+ function getStorageKey() {
+ $hash = $this->getSha1();
+ if ( !$hash ) {
+ return false;
+ }
+ $ext = $this->getExtension();
+ $dotExt = $ext === '' ? '' : ".$ext";
+
+ return $hash . $dotExt;
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this file, if it's marked as deleted.
+ * STUB
+ * @param int $field
+ * @param User $user User object to check, or null to use $wgUser
+ * @return bool
+ */
+ function userCan( $field, User $user = null ) {
+ return true;
+ }
+
+ /**
+ * @deprecated since 1.30, use File::getContentHeaders instead
+ */
+ function getStreamHeaders() {
+ wfDeprecated( __METHOD__, '1.30' );
+ return $this->getContentHeaders();
+ }
+
+ /**
+ * @return array HTTP header name/value map to use for HEAD/GET request responses
+ * @since 1.30
+ */
+ function getContentHeaders() {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ $metadata = $this->getMetadata();
+
+ if ( is_string( $metadata ) ) {
+ $metadata = MediaWiki\quietCall( 'unserialize', $metadata );
+ }
+
+ if ( !is_array( $metadata ) ) {
+ $metadata = [];
+ }
+
+ return $handler->getContentHeaders( $metadata );
+ }
+
+ return [];
+ }
+
+ /**
+ * @return string
+ */
+ function getLongDesc() {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ return $handler->getLongDesc( $this );
+ } else {
+ return MediaHandler::getGeneralLongDesc( $this );
+ }
+ }
+
+ /**
+ * @return string
+ */
+ function getShortDesc() {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ return $handler->getShortDesc( $this );
+ } else {
+ return MediaHandler::getGeneralShortDesc( $this );
+ }
+ }
+
+ /**
+ * @return string
+ */
+ function getDimensionsString() {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ return $handler->getDimensionsString( $this );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * @return string
+ */
+ function getRedirected() {
+ return $this->redirected;
+ }
+
+ /**
+ * @return Title|null
+ */
+ function getRedirectedTitle() {
+ if ( $this->redirected ) {
+ if ( !$this->redirectTitle ) {
+ $this->redirectTitle = Title::makeTitle( NS_FILE, $this->redirected );
+ }
+
+ return $this->redirectTitle;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param string $from
+ * @return void
+ */
+ function redirectedFrom( $from ) {
+ $this->redirected = $from;
+ }
+
+ /**
+ * @return bool
+ */
+ function isMissing() {
+ return false;
+ }
+
+ /**
+ * Check if this file object is small and can be cached
+ * @return bool
+ */
+ public function isCacheable() {
+ return true;
+ }
+
+ /**
+ * Assert that $this->repo is set to a valid FileRepo instance
+ * @throws MWException
+ */
+ protected function assertRepoDefined() {
+ if ( !( $this->repo instanceof $this->repoClass ) ) {
+ throw new MWException( "A {$this->repoClass} object is not set for this File.\n" );
+ }
+ }
+
+ /**
+ * Assert that $this->title is set to a Title
+ * @throws MWException
+ */
+ protected function assertTitleDefined() {
+ if ( !( $this->title instanceof Title ) ) {
+ throw new MWException( "A Title object is not set for this File.\n" );
+ }
+ }
+
+ /**
+ * True if creating thumbnails from the file is large or otherwise resource-intensive.
+ * @return bool
+ */
+ public function isExpensiveToThumbnail() {
+ $handler = $this->getHandler();
+ return $handler ? $handler->isExpensiveToThumbnail( $this ) : false;
+ }
+
+ /**
+ * Whether the thumbnails created on the same server as this code is running.
+ * @since 1.25
+ * @return bool
+ */
+ public function isTransformedLocally() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/filerepo/file/ForeignAPIFile.php b/www/wiki/includes/filerepo/file/ForeignAPIFile.php
new file mode 100644
index 00000000..43b6855f
--- /dev/null
+++ b/www/wiki/includes/filerepo/file/ForeignAPIFile.php
@@ -0,0 +1,398 @@
+<?php
+/**
+ * Foreign file accessible through api.php requests.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileAbstraction
+ */
+
+/**
+ * Foreign file accessible through api.php requests.
+ * Very hacky and inefficient, do not use :D
+ *
+ * @ingroup FileAbstraction
+ */
+class ForeignAPIFile extends File {
+ /** @var bool */
+ private $mExists;
+ /** @var array */
+ private $mInfo = [];
+
+ protected $repoClass = 'ForeignApiRepo';
+
+ /**
+ * @param Title|string|bool $title
+ * @param ForeignApiRepo $repo
+ * @param array $info
+ * @param bool $exists
+ */
+ function __construct( $title, $repo, $info, $exists = false ) {
+ parent::__construct( $title, $repo );
+
+ $this->mInfo = $info;
+ $this->mExists = $exists;
+
+ $this->assertRepoDefined();
+ }
+
+ /**
+ * @param Title $title
+ * @param ForeignApiRepo $repo
+ * @return ForeignAPIFile|null
+ */
+ static function newFromTitle( Title $title, $repo ) {
+ $data = $repo->fetchImageQuery( [
+ 'titles' => 'File:' . $title->getDBkey(),
+ 'iiprop' => self::getProps(),
+ 'prop' => 'imageinfo',
+ 'iimetadataversion' => MediaHandler::getMetadataVersion(),
+ // extmetadata is language-dependant, accessing the current language here
+ // would be problematic, so we just get them all
+ 'iiextmetadatamultilang' => 1,
+ ] );
+
+ $info = $repo->getImageInfo( $data );
+
+ if ( $info ) {
+ $lastRedirect = isset( $data['query']['redirects'] )
+ ? count( $data['query']['redirects'] ) - 1
+ : -1;
+ if ( $lastRedirect >= 0 ) {
+ $newtitle = Title::newFromText( $data['query']['redirects'][$lastRedirect]['to'] );
+ $img = new self( $newtitle, $repo, $info, true );
+ if ( $img ) {
+ $img->redirectedFrom( $title->getDBkey() );
+ }
+ } else {
+ $img = new self( $title, $repo, $info, true );
+ }
+
+ return $img;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Get the property string for iiprop and aiprop
+ * @return string
+ */
+ static function getProps() {
+ return 'timestamp|user|comment|url|size|sha1|metadata|mime|mediatype|extmetadata';
+ }
+
+ // Dummy functions...
+
+ /**
+ * @return bool
+ */
+ public function exists() {
+ return $this->mExists;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getPath() {
+ return false;
+ }
+
+ /**
+ * @param array $params
+ * @param int $flags
+ * @return bool|MediaTransformOutput
+ */
+ function transform( $params, $flags = 0 ) {
+ if ( !$this->canRender() ) {
+ // show icon
+ return parent::transform( $params, $flags );
+ }
+
+ // Note, the this->canRender() check above implies
+ // that we have a handler, and it can do makeParamString.
+ $otherParams = $this->handler->makeParamString( $params );
+ $width = isset( $params['width'] ) ? $params['width'] : -1;
+ $height = isset( $params['height'] ) ? $params['height'] : -1;
+
+ $thumbUrl = $this->repo->getThumbUrlFromCache(
+ $this->getName(),
+ $width,
+ $height,
+ $otherParams
+ );
+ if ( $thumbUrl === false ) {
+ global $wgLang;
+
+ return $this->repo->getThumbError(
+ $this->getName(),
+ $width,
+ $height,
+ $otherParams,
+ $wgLang->getCode()
+ );
+ }
+
+ return $this->handler->getTransform( $this, 'bogus', $thumbUrl, $params );
+ }
+
+ // Info we can get from API...
+
+ /**
+ * @param int $page
+ * @return int|number
+ */
+ public function getWidth( $page = 1 ) {
+ return isset( $this->mInfo['width'] ) ? intval( $this->mInfo['width'] ) : 0;
+ }
+
+ /**
+ * @param int $page
+ * @return int
+ */
+ public function getHeight( $page = 1 ) {
+ return isset( $this->mInfo['height'] ) ? intval( $this->mInfo['height'] ) : 0;
+ }
+
+ /**
+ * @return bool|null|string
+ */
+ public function getMetadata() {
+ if ( isset( $this->mInfo['metadata'] ) ) {
+ return serialize( self::parseMetadata( $this->mInfo['metadata'] ) );
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array|null Extended metadata (see imageinfo API for format) or
+ * null on error
+ */
+ public function getExtendedMetadata() {
+ if ( isset( $this->mInfo['extmetadata'] ) ) {
+ return $this->mInfo['extmetadata'];
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $metadata
+ * @return array
+ */
+ public static function parseMetadata( $metadata ) {
+ if ( !is_array( $metadata ) ) {
+ return $metadata;
+ }
+ $ret = [];
+ foreach ( $metadata as $meta ) {
+ $ret[$meta['name']] = self::parseMetadata( $meta['value'] );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @return bool|int|null
+ */
+ public function getSize() {
+ return isset( $this->mInfo['size'] ) ? intval( $this->mInfo['size'] ) : null;
+ }
+
+ /**
+ * @return null|string
+ */
+ public function getUrl() {
+ return isset( $this->mInfo['url'] ) ? strval( $this->mInfo['url'] ) : null;
+ }
+
+ /**
+ * Get short description URL for a file based on the foreign API response,
+ * or if unavailable, the short URL is constructed from the foreign page ID.
+ *
+ * @return null|string
+ * @since 1.27
+ */
+ public function getDescriptionShortUrl() {
+ if ( isset( $this->mInfo['descriptionshorturl'] ) ) {
+ return $this->mInfo['descriptionshorturl'];
+ } elseif ( isset( $this->mInfo['pageid'] ) ) {
+ $url = $this->repo->makeUrl( [ 'curid' => $this->mInfo['pageid'] ] );
+ if ( $url !== false ) {
+ return $url;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @param string $type
+ * @return int|null|string
+ */
+ public function getUser( $type = 'text' ) {
+ if ( $type == 'text' ) {
+ return isset( $this->mInfo['user'] ) ? strval( $this->mInfo['user'] ) : null;
+ } else {
+ return 0; // What makes sense here, for a remote user?
+ }
+ }
+
+ /**
+ * @param int $audience
+ * @param User $user
+ * @return null|string
+ */
+ public function getDescription( $audience = self::FOR_PUBLIC, User $user = null ) {
+ return isset( $this->mInfo['comment'] ) ? strval( $this->mInfo['comment'] ) : null;
+ }
+
+ /**
+ * @return null|string
+ */
+ function getSha1() {
+ return isset( $this->mInfo['sha1'] )
+ ? Wikimedia\base_convert( strval( $this->mInfo['sha1'] ), 16, 36, 31 )
+ : null;
+ }
+
+ /**
+ * @return bool|string
+ */
+ function getTimestamp() {
+ return wfTimestamp( TS_MW,
+ isset( $this->mInfo['timestamp'] )
+ ? strval( $this->mInfo['timestamp'] )
+ : null
+ );
+ }
+
+ /**
+ * @return string
+ */
+ function getMimeType() {
+ if ( !isset( $this->mInfo['mime'] ) ) {
+ $magic = MimeMagic::singleton();
+ $this->mInfo['mime'] = $magic->guessTypesForExtension( $this->getExtension() );
+ }
+
+ return $this->mInfo['mime'];
+ }
+
+ /**
+ * @return int|string
+ */
+ function getMediaType() {
+ if ( isset( $this->mInfo['mediatype'] ) ) {
+ return $this->mInfo['mediatype'];
+ }
+ $magic = MimeMagic::singleton();
+
+ return $magic->getMediaType( null, $this->getMimeType() );
+ }
+
+ /**
+ * @return bool|string
+ */
+ function getDescriptionUrl() {
+ return isset( $this->mInfo['descriptionurl'] )
+ ? $this->mInfo['descriptionurl']
+ : false;
+ }
+
+ /**
+ * Only useful if we're locally caching thumbs anyway...
+ * @param string $suffix
+ * @return null|string
+ */
+ function getThumbPath( $suffix = '' ) {
+ if ( $this->repo->canCacheThumbs() ) {
+ $path = $this->repo->getZonePath( 'thumb' ) . '/' . $this->getHashPath( $this->getName() );
+ if ( $suffix ) {
+ $path = $path . $suffix . '/';
+ }
+
+ return $path;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ function getThumbnails() {
+ $dir = $this->getThumbPath( $this->getName() );
+ $iter = $this->repo->getBackend()->getFileList( [ 'dir' => $dir ] );
+
+ $files = [];
+ foreach ( $iter as $file ) {
+ $files[] = $file;
+ }
+
+ return $files;
+ }
+
+ function purgeCache( $options = [] ) {
+ $this->purgeThumbnails( $options );
+ $this->purgeDescriptionPage();
+ }
+
+ function purgeDescriptionPage() {
+ global $wgContLang;
+
+ $url = $this->repo->getDescriptionRenderUrl( $this->getName(), $wgContLang->getCode() );
+ $key = $this->repo->getLocalCacheKey( 'RemoteFileDescription', 'url', md5( $url ) );
+
+ ObjectCache::getMainWANInstance()->delete( $key );
+ }
+
+ /**
+ * @param array $options
+ */
+ function purgeThumbnails( $options = [] ) {
+ $key = $this->repo->getLocalCacheKey( 'ForeignAPIRepo', 'ThumbUrl', $this->getName() );
+ ObjectCache::getMainWANInstance()->delete( $key );
+
+ $files = $this->getThumbnails();
+ // Give media handler a chance to filter the purge list
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ $handler->filterThumbnailPurgeList( $files, $options );
+ }
+
+ $dir = $this->getThumbPath( $this->getName() );
+ $purgeList = [];
+ foreach ( $files as $file ) {
+ $purgeList[] = "{$dir}{$file}";
+ }
+
+ # Delete the thumbnails
+ $this->repo->quickPurgeBatch( $purgeList );
+ # Clear out the thumbnail directory if empty
+ $this->repo->quickCleanDir( $dir );
+ }
+
+ /**
+ * The thumbnail is created on the foreign server and fetched over internet
+ * @since 1.25
+ * @return bool
+ */
+ public function isTransformedLocally() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/filerepo/file/ForeignDBFile.php b/www/wiki/includes/filerepo/file/ForeignDBFile.php
new file mode 100644
index 00000000..cf211618
--- /dev/null
+++ b/www/wiki/includes/filerepo/file/ForeignDBFile.php
@@ -0,0 +1,203 @@
+<?php
+/**
+ * Foreign file with an accessible MediaWiki database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileAbstraction
+ */
+
+use Wikimedia\Rdbms\DBUnexpectedError;
+
+/**
+ * Foreign file with an accessible MediaWiki database
+ *
+ * @ingroup FileAbstraction
+ */
+class ForeignDBFile extends LocalFile {
+ /**
+ * @param Title $title
+ * @param FileRepo $repo
+ * @param null $unused
+ * @return ForeignDBFile
+ */
+ static function newFromTitle( $title, $repo, $unused = null ) {
+ return new self( $title, $repo );
+ }
+
+ /**
+ * Create a ForeignDBFile from a title
+ * Do not call this except from inside a repo class.
+ *
+ * @param stdClass $row
+ * @param FileRepo $repo
+ * @return ForeignDBFile
+ */
+ static function newFromRow( $row, $repo ) {
+ $title = Title::makeTitle( NS_FILE, $row->img_name );
+ $file = new self( $title, $repo );
+ $file->loadFromRow( $row );
+
+ return $file;
+ }
+
+ /**
+ * @param string $srcPath
+ * @param int $flags
+ * @param array $options
+ * @return Status
+ * @throws MWException
+ */
+ function publish( $srcPath, $flags = 0, array $options = [] ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * @param string $oldver
+ * @param string $desc
+ * @param string $license
+ * @param string $copyStatus
+ * @param string $source
+ * @param bool $watch
+ * @param bool|string $timestamp
+ * @param User $user User object or null to use $wgUser
+ * @return bool
+ * @throws MWException
+ */
+ function recordUpload( $oldver, $desc, $license = '', $copyStatus = '', $source = '',
+ $watch = false, $timestamp = false, User $user = null ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * @param array $versions
+ * @param bool $unsuppress
+ * @return Status
+ * @throws MWException
+ */
+ function restore( $versions = [], $unsuppress = false ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * @param string $reason
+ * @param bool $suppress
+ * @param User|null $user
+ * @return Status
+ * @throws MWException
+ */
+ function delete( $reason, $suppress = false, $user = null ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * @param Title $target
+ * @return Status
+ * @throws MWException
+ */
+ function move( $target ) {
+ $this->readOnlyError();
+ }
+
+ /**
+ * @return string
+ */
+ function getDescriptionUrl() {
+ // Restore remote behavior
+ return File::getDescriptionUrl();
+ }
+
+ /**
+ * @param Language|null $lang Optional language to fetch description in.
+ * @return string|false
+ */
+ function getDescriptionText( $lang = null ) {
+ global $wgLang;
+
+ if ( !$this->repo->fetchDescription ) {
+ return false;
+ }
+
+ $lang = $lang ?: $wgLang;
+ $renderUrl = $this->repo->getDescriptionRenderUrl( $this->getName(), $lang->getCode() );
+ if ( !$renderUrl ) {
+ return false;
+ }
+
+ $touched = $this->repo->getReplicaDB()->selectField(
+ 'page',
+ 'page_touched',
+ [
+ 'page_namespace' => NS_FILE,
+ 'page_title' => $this->title->getDBkey()
+ ]
+ );
+ if ( $touched === false ) {
+ return false; // no description page
+ }
+
+ $cache = ObjectCache::getMainWANInstance();
+
+ return $cache->getWithSetCallback(
+ $this->repo->getLocalCacheKey(
+ 'RemoteFileDescription',
+ 'url',
+ $lang->getCode(),
+ $this->getName(),
+ $touched
+ ),
+ $this->repo->descriptionCacheExpiry ?: $cache::TTL_UNCACHEABLE,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $renderUrl ) {
+ wfDebug( "Fetching shared description from $renderUrl\n" );
+ $res = Http::get( $renderUrl, [], __METHOD__ );
+ if ( !$res ) {
+ $ttl = WANObjectCache::TTL_UNCACHEABLE;
+ }
+
+ return $res;
+ }
+ );
+ }
+
+ /**
+ * Get short description URL for a file based on the page ID.
+ *
+ * @return string
+ * @throws DBUnexpectedError
+ * @since 1.27
+ */
+ public function getDescriptionShortUrl() {
+ $dbr = $this->repo->getReplicaDB();
+ $pageId = $dbr->selectField(
+ 'page',
+ 'page_id',
+ [
+ 'page_namespace' => NS_FILE,
+ 'page_title' => $this->title->getDBkey()
+ ]
+ );
+
+ if ( $pageId !== false ) {
+ $url = $this->repo->makeUrl( [ 'curid' => $pageId ] );
+ if ( $url !== false ) {
+ return $url;
+ }
+ }
+ return null;
+ }
+
+}
diff --git a/www/wiki/includes/filerepo/file/LocalFile.php b/www/wiki/includes/filerepo/file/LocalFile.php
new file mode 100644
index 00000000..188e2ed9
--- /dev/null
+++ b/www/wiki/includes/filerepo/file/LocalFile.php
@@ -0,0 +1,3296 @@
+<?php
+/**
+ * Local file in the wiki's own database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileAbstraction
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Class to represent a local file in the wiki's own database
+ *
+ * Provides methods to retrieve paths (physical, logical, URL),
+ * to generate image thumbnails or for uploading.
+ *
+ * Note that only the repo object knows what its file class is called. You should
+ * never name a file class explictly outside of the repo class. Instead use the
+ * repo's factory functions to generate file objects, for example:
+ *
+ * RepoGroup::singleton()->getLocalRepo()->newFile( $title );
+ *
+ * The convenience functions wfLocalFile() and wfFindFile() should be sufficient
+ * in most cases.
+ *
+ * @ingroup FileAbstraction
+ */
+class LocalFile extends File {
+ const VERSION = 10; // cache version
+
+ const CACHE_FIELD_MAX_LEN = 1000;
+
+ /** @var bool Does the file exist on disk? (loadFromXxx) */
+ protected $fileExists;
+
+ /** @var int Image width */
+ protected $width;
+
+ /** @var int Image height */
+ protected $height;
+
+ /** @var int Returned by getimagesize (loadFromXxx) */
+ protected $bits;
+
+ /** @var string MEDIATYPE_xxx (bitmap, drawing, audio...) */
+ protected $media_type;
+
+ /** @var string MIME type, determined by MimeMagic::guessMimeType */
+ protected $mime;
+
+ /** @var int Size in bytes (loadFromXxx) */
+ protected $size;
+
+ /** @var string Handler-specific metadata */
+ protected $metadata;
+
+ /** @var string SHA-1 base 36 content hash */
+ protected $sha1;
+
+ /** @var bool Whether or not core data has been loaded from the database (loadFromXxx) */
+ protected $dataLoaded;
+
+ /** @var bool Whether or not lazy-loaded data has been loaded from the database */
+ protected $extraDataLoaded;
+
+ /** @var int Bitfield akin to rev_deleted */
+ protected $deleted;
+
+ /** @var string */
+ protected $repoClass = 'LocalRepo';
+
+ /** @var int Number of line to return by nextHistoryLine() (constructor) */
+ private $historyLine;
+
+ /** @var int Result of the query for the file's history (nextHistoryLine) */
+ private $historyRes;
+
+ /** @var string Major MIME type */
+ private $major_mime;
+
+ /** @var string Minor MIME type */
+ private $minor_mime;
+
+ /** @var string Upload timestamp */
+ private $timestamp;
+
+ /** @var int User ID of uploader */
+ private $user;
+
+ /** @var string User name of uploader */
+ private $user_text;
+
+ /** @var string Description of current revision of the file */
+ private $description;
+
+ /** @var string TS_MW timestamp of the last change of the file description */
+ private $descriptionTouched;
+
+ /** @var bool Whether the row was upgraded on load */
+ private $upgraded;
+
+ /** @var bool Whether the row was scheduled to upgrade on load */
+ private $upgrading;
+
+ /** @var bool True if the image row is locked */
+ private $locked;
+
+ /** @var bool True if the image row is locked with a lock initiated transaction */
+ private $lockedOwnTrx;
+
+ /** @var bool True if file is not present in file system. Not to be cached in memcached */
+ private $missing;
+
+ // @note: higher than IDBAccessObject constants
+ const LOAD_ALL = 16; // integer; load all the lazy fields too (like metadata)
+
+ const ATOMIC_SECTION_LOCK = 'LocalFile::lockingTransaction';
+
+ /**
+ * Create a LocalFile from a title
+ * Do not call this except from inside a repo class.
+ *
+ * Note: $unused param is only here to avoid an E_STRICT
+ *
+ * @param Title $title
+ * @param FileRepo $repo
+ * @param null $unused
+ *
+ * @return LocalFile
+ */
+ static function newFromTitle( $title, $repo, $unused = null ) {
+ return new self( $title, $repo );
+ }
+
+ /**
+ * Create a LocalFile from a title
+ * Do not call this except from inside a repo class.
+ *
+ * @param stdClass $row
+ * @param FileRepo $repo
+ *
+ * @return LocalFile
+ */
+ static function newFromRow( $row, $repo ) {
+ $title = Title::makeTitle( NS_FILE, $row->img_name );
+ $file = new self( $title, $repo );
+ $file->loadFromRow( $row );
+
+ return $file;
+ }
+
+ /**
+ * Create a LocalFile from a SHA-1 key
+ * Do not call this except from inside a repo class.
+ *
+ * @param string $sha1 Base-36 SHA-1
+ * @param LocalRepo $repo
+ * @param string|bool $timestamp MW_timestamp (optional)
+ * @return bool|LocalFile
+ */
+ static function newFromKey( $sha1, $repo, $timestamp = false ) {
+ $dbr = $repo->getReplicaDB();
+
+ $conds = [ 'img_sha1' => $sha1 ];
+ if ( $timestamp ) {
+ $conds['img_timestamp'] = $dbr->timestamp( $timestamp );
+ }
+
+ $row = $dbr->selectRow( 'image', self::selectFields(), $conds, __METHOD__ );
+ if ( $row ) {
+ return self::newFromRow( $row, $repo );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Fields in the image table
+ * @todo Deprecate this in favor of a method that returns tables and joins
+ * as well, and use CommentStore::getJoin().
+ * @return array
+ */
+ static function selectFields() {
+ return [
+ 'img_name',
+ 'img_size',
+ 'img_width',
+ 'img_height',
+ 'img_metadata',
+ 'img_bits',
+ 'img_media_type',
+ 'img_major_mime',
+ 'img_minor_mime',
+ 'img_user',
+ 'img_user_text',
+ 'img_timestamp',
+ 'img_sha1',
+ ] + CommentStore::newKey( 'img_description' )->getFields();
+ }
+
+ /**
+ * Do not call this except from inside a repo class.
+ * @param Title $title
+ * @param FileRepo $repo
+ */
+ function __construct( $title, $repo ) {
+ parent::__construct( $title, $repo );
+
+ $this->metadata = '';
+ $this->historyLine = 0;
+ $this->historyRes = null;
+ $this->dataLoaded = false;
+ $this->extraDataLoaded = false;
+
+ $this->assertRepoDefined();
+ $this->assertTitleDefined();
+ }
+
+ /**
+ * Get the memcached key for the main data for this file, or false if
+ * there is no access to the shared cache.
+ * @return string|bool
+ */
+ function getCacheKey() {
+ return $this->repo->getSharedCacheKey( 'file', sha1( $this->getName() ) );
+ }
+
+ /**
+ * @param WANObjectCache $cache
+ * @return string[]
+ * @since 1.28
+ */
+ public function getMutableCacheKeys( WANObjectCache $cache ) {
+ return [ $this->getCacheKey() ];
+ }
+
+ /**
+ * Try to load file metadata from memcached, falling back to the database
+ */
+ private function loadFromCache() {
+ $this->dataLoaded = false;
+ $this->extraDataLoaded = false;
+
+ $key = $this->getCacheKey();
+ if ( !$key ) {
+ $this->loadFromDB( self::READ_NORMAL );
+
+ return;
+ }
+
+ $cache = ObjectCache::getMainWANInstance();
+ $cachedValues = $cache->getWithSetCallback(
+ $key,
+ $cache::TTL_WEEK,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $cache ) {
+ $setOpts += Database::getCacheSetOptions( $this->repo->getReplicaDB() );
+
+ $this->loadFromDB( self::READ_NORMAL );
+
+ $fields = $this->getCacheFields( '' );
+ $cacheVal['fileExists'] = $this->fileExists;
+ if ( $this->fileExists ) {
+ foreach ( $fields as $field ) {
+ $cacheVal[$field] = $this->$field;
+ }
+ }
+ // Strip off excessive entries from the subset of fields that can become large.
+ // If the cache value gets to large it will not fit in memcached and nothing will
+ // get cached at all, causing master queries for any file access.
+ foreach ( $this->getLazyCacheFields( '' ) as $field ) {
+ if ( isset( $cacheVal[$field] )
+ && strlen( $cacheVal[$field] ) > 100 * 1024
+ ) {
+ unset( $cacheVal[$field] ); // don't let the value get too big
+ }
+ }
+
+ if ( $this->fileExists ) {
+ $ttl = $cache->adaptiveTTL( wfTimestamp( TS_UNIX, $this->timestamp ), $ttl );
+ } else {
+ $ttl = $cache::TTL_DAY;
+ }
+
+ return $cacheVal;
+ },
+ [ 'version' => self::VERSION ]
+ );
+
+ $this->fileExists = $cachedValues['fileExists'];
+ if ( $this->fileExists ) {
+ $this->setProps( $cachedValues );
+ }
+
+ $this->dataLoaded = true;
+ $this->extraDataLoaded = true;
+ foreach ( $this->getLazyCacheFields( '' ) as $field ) {
+ $this->extraDataLoaded = $this->extraDataLoaded && isset( $cachedValues[$field] );
+ }
+ }
+
+ /**
+ * Purge the file object/metadata cache
+ */
+ public function invalidateCache() {
+ $key = $this->getCacheKey();
+ if ( !$key ) {
+ return;
+ }
+
+ $this->repo->getMasterDB()->onTransactionPreCommitOrIdle(
+ function () use ( $key ) {
+ ObjectCache::getMainWANInstance()->delete( $key );
+ },
+ __METHOD__
+ );
+ }
+
+ /**
+ * Load metadata from the file itself
+ */
+ function loadFromFile() {
+ $props = $this->repo->getFileProps( $this->getVirtualUrl() );
+ $this->setProps( $props );
+ }
+
+ /**
+ * @param string $prefix
+ * @return array
+ */
+ function getCacheFields( $prefix = 'img_' ) {
+ static $fields = [ 'size', 'width', 'height', 'bits', 'media_type',
+ 'major_mime', 'minor_mime', 'metadata', 'timestamp', 'sha1', 'user',
+ 'user_text' ];
+ static $results = [];
+
+ if ( $prefix == '' ) {
+ return array_merge( $fields, [ 'description' ] );
+ }
+ if ( !isset( $results[$prefix] ) ) {
+ $prefixedFields = [];
+ foreach ( $fields as $field ) {
+ $prefixedFields[] = $prefix . $field;
+ }
+ $prefixedFields += CommentStore::newKey( "{$prefix}description" )->getFields();
+ $results[$prefix] = $prefixedFields;
+ }
+
+ return $results[$prefix];
+ }
+
+ /**
+ * @param string $prefix
+ * @return array
+ */
+ function getLazyCacheFields( $prefix = 'img_' ) {
+ static $fields = [ 'metadata' ];
+ static $results = [];
+
+ if ( $prefix == '' ) {
+ return $fields;
+ }
+
+ if ( !isset( $results[$prefix] ) ) {
+ $prefixedFields = [];
+ foreach ( $fields as $field ) {
+ $prefixedFields[] = $prefix . $field;
+ }
+ $results[$prefix] = $prefixedFields;
+ }
+
+ return $results[$prefix];
+ }
+
+ /**
+ * Load file metadata from the DB
+ * @param int $flags
+ */
+ function loadFromDB( $flags = 0 ) {
+ $fname = static::class . '::' . __FUNCTION__;
+
+ # Unconditionally set loaded=true, we don't want the accessors constantly rechecking
+ $this->dataLoaded = true;
+ $this->extraDataLoaded = true;
+
+ $dbr = ( $flags & self::READ_LATEST )
+ ? $this->repo->getMasterDB()
+ : $this->repo->getReplicaDB();
+
+ $row = $dbr->selectRow( 'image', $this->getCacheFields( 'img_' ),
+ [ 'img_name' => $this->getName() ], $fname );
+
+ if ( $row ) {
+ $this->loadFromRow( $row );
+ } else {
+ $this->fileExists = false;
+ }
+ }
+
+ /**
+ * Load lazy file metadata from the DB.
+ * This covers fields that are sometimes not cached.
+ */
+ protected function loadExtraFromDB() {
+ $fname = static::class . '::' . __FUNCTION__;
+
+ # Unconditionally set loaded=true, we don't want the accessors constantly rechecking
+ $this->extraDataLoaded = true;
+
+ $fieldMap = $this->loadFieldsWithTimestamp( $this->repo->getReplicaDB(), $fname );
+ if ( !$fieldMap ) {
+ $fieldMap = $this->loadFieldsWithTimestamp( $this->repo->getMasterDB(), $fname );
+ }
+
+ if ( $fieldMap ) {
+ foreach ( $fieldMap as $name => $value ) {
+ $this->$name = $value;
+ }
+ } else {
+ throw new MWException( "Could not find data for image '{$this->getName()}'." );
+ }
+ }
+
+ /**
+ * @param IDatabase $dbr
+ * @param string $fname
+ * @return array|bool
+ */
+ private function loadFieldsWithTimestamp( $dbr, $fname ) {
+ $fieldMap = false;
+
+ $row = $dbr->selectRow( 'image', $this->getLazyCacheFields( 'img_' ), [
+ 'img_name' => $this->getName(),
+ 'img_timestamp' => $dbr->timestamp( $this->getTimestamp() )
+ ], $fname );
+ if ( $row ) {
+ $fieldMap = $this->unprefixRow( $row, 'img_' );
+ } else {
+ # File may have been uploaded over in the meantime; check the old versions
+ $row = $dbr->selectRow( 'oldimage', $this->getLazyCacheFields( 'oi_' ), [
+ 'oi_name' => $this->getName(),
+ 'oi_timestamp' => $dbr->timestamp( $this->getTimestamp() )
+ ], $fname );
+ if ( $row ) {
+ $fieldMap = $this->unprefixRow( $row, 'oi_' );
+ }
+ }
+
+ return $fieldMap;
+ }
+
+ /**
+ * @param array|object $row
+ * @param string $prefix
+ * @throws MWException
+ * @return array
+ */
+ protected function unprefixRow( $row, $prefix = 'img_' ) {
+ $array = (array)$row;
+ $prefixLength = strlen( $prefix );
+
+ // Sanity check prefix once
+ if ( substr( key( $array ), 0, $prefixLength ) !== $prefix ) {
+ throw new MWException( __METHOD__ . ': incorrect $prefix parameter' );
+ }
+
+ $decoded = [];
+ foreach ( $array as $name => $value ) {
+ $decoded[substr( $name, $prefixLength )] = $value;
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * Decode a row from the database (either object or array) to an array
+ * with timestamps and MIME types decoded, and the field prefix removed.
+ * @param object $row
+ * @param string $prefix
+ * @throws MWException
+ * @return array
+ */
+ function decodeRow( $row, $prefix = 'img_' ) {
+ $decoded = $this->unprefixRow( $row, $prefix );
+
+ $decoded['timestamp'] = wfTimestamp( TS_MW, $decoded['timestamp'] );
+
+ $decoded['metadata'] = $this->repo->getReplicaDB()->decodeBlob( $decoded['metadata'] );
+
+ if ( empty( $decoded['major_mime'] ) ) {
+ $decoded['mime'] = 'unknown/unknown';
+ } else {
+ if ( !$decoded['minor_mime'] ) {
+ $decoded['minor_mime'] = 'unknown';
+ }
+ $decoded['mime'] = $decoded['major_mime'] . '/' . $decoded['minor_mime'];
+ }
+
+ // Trim zero padding from char/binary field
+ $decoded['sha1'] = rtrim( $decoded['sha1'], "\0" );
+
+ // Normalize some fields to integer type, per their database definition.
+ // Use unary + so that overflows will be upgraded to double instead of
+ // being trucated as with intval(). This is important to allow >2GB
+ // files on 32-bit systems.
+ foreach ( [ 'size', 'width', 'height', 'bits' ] as $field ) {
+ $decoded[$field] = +$decoded[$field];
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * Load file metadata from a DB result row
+ *
+ * @param object $row
+ * @param string $prefix
+ */
+ function loadFromRow( $row, $prefix = 'img_' ) {
+ $this->dataLoaded = true;
+ $this->extraDataLoaded = true;
+
+ $this->description = CommentStore::newKey( "{$prefix}description" )
+ // $row is probably using getFields() from self::getCacheFields()
+ ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row )->text;
+
+ $array = $this->decodeRow( $row, $prefix );
+
+ foreach ( $array as $name => $value ) {
+ $this->$name = $value;
+ }
+
+ $this->fileExists = true;
+ $this->maybeUpgradeRow();
+ }
+
+ /**
+ * Load file metadata from cache or DB, unless already loaded
+ * @param int $flags
+ */
+ function load( $flags = 0 ) {
+ if ( !$this->dataLoaded ) {
+ if ( $flags & self::READ_LATEST ) {
+ $this->loadFromDB( $flags );
+ } else {
+ $this->loadFromCache();
+ }
+ }
+
+ if ( ( $flags & self::LOAD_ALL ) && !$this->extraDataLoaded ) {
+ // @note: loads on name/timestamp to reduce race condition problems
+ $this->loadExtraFromDB();
+ }
+ }
+
+ /**
+ * Upgrade a row if it needs it
+ */
+ function maybeUpgradeRow() {
+ global $wgUpdateCompatibleMetadata;
+
+ if ( wfReadOnly() || $this->upgrading ) {
+ return;
+ }
+
+ $upgrade = false;
+ if ( is_null( $this->media_type ) || $this->mime == 'image/svg' ) {
+ $upgrade = true;
+ } else {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ $validity = $handler->isMetadataValid( $this, $this->getMetadata() );
+ if ( $validity === MediaHandler::METADATA_BAD ) {
+ $upgrade = true;
+ } elseif ( $validity === MediaHandler::METADATA_COMPATIBLE ) {
+ $upgrade = $wgUpdateCompatibleMetadata;
+ }
+ }
+ }
+
+ if ( $upgrade ) {
+ $this->upgrading = true;
+ // Defer updates unless in auto-commit CLI mode
+ DeferredUpdates::addCallableUpdate( function () {
+ $this->upgrading = false; // avoid duplicate updates
+ try {
+ $this->upgradeRow();
+ } catch ( LocalFileLockError $e ) {
+ // let the other process handle it (or do it next time)
+ }
+ } );
+ }
+ }
+
+ /**
+ * @return bool Whether upgradeRow() ran for this object
+ */
+ function getUpgraded() {
+ return $this->upgraded;
+ }
+
+ /**
+ * Fix assorted version-related problems with the image row by reloading it from the file
+ */
+ function upgradeRow() {
+ $this->lock(); // begin
+
+ $this->loadFromFile();
+
+ # Don't destroy file info of missing files
+ if ( !$this->fileExists ) {
+ $this->unlock();
+ wfDebug( __METHOD__ . ": file does not exist, aborting\n" );
+
+ return;
+ }
+
+ $dbw = $this->repo->getMasterDB();
+ list( $major, $minor ) = self::splitMime( $this->mime );
+
+ if ( wfReadOnly() ) {
+ $this->unlock();
+
+ return;
+ }
+ wfDebug( __METHOD__ . ': upgrading ' . $this->getName() . " to the current schema\n" );
+
+ $dbw->update( 'image',
+ [
+ 'img_size' => $this->size, // sanity
+ 'img_width' => $this->width,
+ 'img_height' => $this->height,
+ 'img_bits' => $this->bits,
+ 'img_media_type' => $this->media_type,
+ 'img_major_mime' => $major,
+ 'img_minor_mime' => $minor,
+ 'img_metadata' => $dbw->encodeBlob( $this->metadata ),
+ 'img_sha1' => $this->sha1,
+ ],
+ [ 'img_name' => $this->getName() ],
+ __METHOD__
+ );
+
+ $this->invalidateCache();
+
+ $this->unlock(); // done
+ $this->upgraded = true; // avoid rework/retries
+ }
+
+ /**
+ * Set properties in this object to be equal to those given in the
+ * associative array $info. Only cacheable fields can be set.
+ * All fields *must* be set in $info except for getLazyCacheFields().
+ *
+ * If 'mime' is given, it will be split into major_mime/minor_mime.
+ * If major_mime/minor_mime are given, $this->mime will also be set.
+ *
+ * @param array $info
+ */
+ function setProps( $info ) {
+ $this->dataLoaded = true;
+ $fields = $this->getCacheFields( '' );
+ $fields[] = 'fileExists';
+
+ foreach ( $fields as $field ) {
+ if ( isset( $info[$field] ) ) {
+ $this->$field = $info[$field];
+ }
+ }
+
+ // Fix up mime fields
+ if ( isset( $info['major_mime'] ) ) {
+ $this->mime = "{$info['major_mime']}/{$info['minor_mime']}";
+ } elseif ( isset( $info['mime'] ) ) {
+ $this->mime = $info['mime'];
+ list( $this->major_mime, $this->minor_mime ) = self::splitMime( $this->mime );
+ }
+ }
+
+ /** splitMime inherited */
+ /** getName inherited */
+ /** getTitle inherited */
+ /** getURL inherited */
+ /** getViewURL inherited */
+ /** getPath inherited */
+ /** isVisible inherited */
+
+ /**
+ * @return bool
+ */
+ function isMissing() {
+ if ( $this->missing === null ) {
+ list( $fileExists ) = $this->repo->fileExists( $this->getVirtualUrl() );
+ $this->missing = !$fileExists;
+ }
+
+ return $this->missing;
+ }
+
+ /**
+ * Return the width of the image
+ *
+ * @param int $page
+ * @return int
+ */
+ public function getWidth( $page = 1 ) {
+ $page = (int)$page;
+ if ( $page < 1 ) {
+ $page = 1;
+ }
+
+ $this->load();
+
+ if ( $this->isMultipage() ) {
+ $handler = $this->getHandler();
+ if ( !$handler ) {
+ return 0;
+ }
+ $dim = $handler->getPageDimensions( $this, $page );
+ if ( $dim ) {
+ return $dim['width'];
+ } else {
+ // For non-paged media, the false goes through an
+ // intval, turning failure into 0, so do same here.
+ return 0;
+ }
+ } else {
+ return $this->width;
+ }
+ }
+
+ /**
+ * Return the height of the image
+ *
+ * @param int $page
+ * @return int
+ */
+ public function getHeight( $page = 1 ) {
+ $page = (int)$page;
+ if ( $page < 1 ) {
+ $page = 1;
+ }
+
+ $this->load();
+
+ if ( $this->isMultipage() ) {
+ $handler = $this->getHandler();
+ if ( !$handler ) {
+ return 0;
+ }
+ $dim = $handler->getPageDimensions( $this, $page );
+ if ( $dim ) {
+ return $dim['height'];
+ } else {
+ // For non-paged media, the false goes through an
+ // intval, turning failure into 0, so do same here.
+ return 0;
+ }
+ } else {
+ return $this->height;
+ }
+ }
+
+ /**
+ * Returns ID or name of user who uploaded the file
+ *
+ * @param string $type 'text' or 'id'
+ * @return int|string
+ */
+ function getUser( $type = 'text' ) {
+ $this->load();
+
+ if ( $type == 'text' ) {
+ return $this->user_text;
+ } else { // id
+ return (int)$this->user;
+ }
+ }
+
+ /**
+ * Get short description URL for a file based on the page ID.
+ *
+ * @return string|null
+ * @throws MWException
+ * @since 1.27
+ */
+ public function getDescriptionShortUrl() {
+ $pageId = $this->title->getArticleID();
+
+ if ( $pageId !== null ) {
+ $url = $this->repo->makeUrl( [ 'curid' => $pageId ] );
+ if ( $url !== false ) {
+ return $url;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get handler-specific metadata
+ * @return string
+ */
+ function getMetadata() {
+ $this->load( self::LOAD_ALL ); // large metadata is loaded in another step
+ return $this->metadata;
+ }
+
+ /**
+ * @return int
+ */
+ function getBitDepth() {
+ $this->load();
+
+ return (int)$this->bits;
+ }
+
+ /**
+ * Returns the size of the image file, in bytes
+ * @return int
+ */
+ public function getSize() {
+ $this->load();
+
+ return $this->size;
+ }
+
+ /**
+ * Returns the MIME type of the file.
+ * @return string
+ */
+ function getMimeType() {
+ $this->load();
+
+ return $this->mime;
+ }
+
+ /**
+ * Returns the type of the media in the file.
+ * Use the value returned by this function with the MEDIATYPE_xxx constants.
+ * @return string
+ */
+ function getMediaType() {
+ $this->load();
+
+ return $this->media_type;
+ }
+
+ /** canRender inherited */
+ /** mustRender inherited */
+ /** allowInlineDisplay inherited */
+ /** isSafeFile inherited */
+ /** isTrustedFile inherited */
+
+ /**
+ * Returns true if the file exists on disk.
+ * @return bool Whether file exist on disk.
+ */
+ public function exists() {
+ $this->load();
+
+ return $this->fileExists;
+ }
+
+ /** getTransformScript inherited */
+ /** getUnscaledThumb inherited */
+ /** thumbName inherited */
+ /** createThumb inherited */
+ /** transform inherited */
+
+ /** getHandler inherited */
+ /** iconThumb inherited */
+ /** getLastError inherited */
+
+ /**
+ * Get all thumbnail names previously generated for this file
+ * @param string|bool $archiveName Name of an archive file, default false
+ * @return array First element is the base dir, then files in that base dir.
+ */
+ function getThumbnails( $archiveName = false ) {
+ if ( $archiveName ) {
+ $dir = $this->getArchiveThumbPath( $archiveName );
+ } else {
+ $dir = $this->getThumbPath();
+ }
+
+ $backend = $this->repo->getBackend();
+ $files = [ $dir ];
+ try {
+ $iterator = $backend->getFileList( [ 'dir' => $dir ] );
+ foreach ( $iterator as $file ) {
+ $files[] = $file;
+ }
+ } catch ( FileBackendError $e ) {
+ } // suppress (T56674)
+
+ return $files;
+ }
+
+ /**
+ * Refresh metadata in memcached, but don't touch thumbnails or CDN
+ */
+ function purgeMetadataCache() {
+ $this->invalidateCache();
+ }
+
+ /**
+ * Delete all previously generated thumbnails, refresh metadata in memcached and purge the CDN.
+ *
+ * @param array $options An array potentially with the key forThumbRefresh.
+ *
+ * @note This used to purge old thumbnails by default as well, but doesn't anymore.
+ */
+ function purgeCache( $options = [] ) {
+ // Refresh metadata cache
+ $this->purgeMetadataCache();
+
+ // Delete thumbnails
+ $this->purgeThumbnails( $options );
+
+ // Purge CDN cache for this file
+ DeferredUpdates::addUpdate(
+ new CdnCacheUpdate( [ $this->getUrl() ] ),
+ DeferredUpdates::PRESEND
+ );
+ }
+
+ /**
+ * Delete cached transformed files for an archived version only.
+ * @param string $archiveName Name of the archived file
+ */
+ function purgeOldThumbnails( $archiveName ) {
+ // Get a list of old thumbnails and URLs
+ $files = $this->getThumbnails( $archiveName );
+
+ // Purge any custom thumbnail caches
+ Hooks::run( 'LocalFilePurgeThumbnails', [ $this, $archiveName ] );
+
+ // Delete thumbnails
+ $dir = array_shift( $files );
+ $this->purgeThumbList( $dir, $files );
+
+ // Purge the CDN
+ $urls = [];
+ foreach ( $files as $file ) {
+ $urls[] = $this->getArchiveThumbUrl( $archiveName, $file );
+ }
+ DeferredUpdates::addUpdate( new CdnCacheUpdate( $urls ), DeferredUpdates::PRESEND );
+ }
+
+ /**
+ * Delete cached transformed files for the current version only.
+ * @param array $options
+ */
+ public function purgeThumbnails( $options = [] ) {
+ $files = $this->getThumbnails();
+ // Always purge all files from CDN regardless of handler filters
+ $urls = [];
+ foreach ( $files as $file ) {
+ $urls[] = $this->getThumbUrl( $file );
+ }
+ array_shift( $urls ); // don't purge directory
+
+ // Give media handler a chance to filter the file purge list
+ if ( !empty( $options['forThumbRefresh'] ) ) {
+ $handler = $this->getHandler();
+ if ( $handler ) {
+ $handler->filterThumbnailPurgeList( $files, $options );
+ }
+ }
+
+ // Purge any custom thumbnail caches
+ Hooks::run( 'LocalFilePurgeThumbnails', [ $this, false ] );
+
+ // Delete thumbnails
+ $dir = array_shift( $files );
+ $this->purgeThumbList( $dir, $files );
+
+ // Purge the CDN
+ DeferredUpdates::addUpdate( new CdnCacheUpdate( $urls ), DeferredUpdates::PRESEND );
+ }
+
+ /**
+ * Prerenders a configurable set of thumbnails
+ *
+ * @since 1.28
+ */
+ public function prerenderThumbnails() {
+ global $wgUploadThumbnailRenderMap;
+
+ $jobs = [];
+
+ $sizes = $wgUploadThumbnailRenderMap;
+ rsort( $sizes );
+
+ foreach ( $sizes as $size ) {
+ if ( $this->isVectorized() || $this->getWidth() > $size ) {
+ $jobs[] = new ThumbnailRenderJob(
+ $this->getTitle(),
+ [ 'transformParams' => [ 'width' => $size ] ]
+ );
+ }
+ }
+
+ if ( $jobs ) {
+ JobQueueGroup::singleton()->lazyPush( $jobs );
+ }
+ }
+
+ /**
+ * Delete a list of thumbnails visible at urls
+ * @param string $dir Base dir of the files.
+ * @param array $files Array of strings: relative filenames (to $dir)
+ */
+ protected function purgeThumbList( $dir, $files ) {
+ $fileListDebug = strtr(
+ var_export( $files, true ),
+ [ "\n" => '' ]
+ );
+ wfDebug( __METHOD__ . ": $fileListDebug\n" );
+
+ $purgeList = [];
+ foreach ( $files as $file ) {
+ if ( $this->repo->supportsSha1URLs() ) {
+ $reference = $this->getSha1();
+ } else {
+ $reference = $this->getName();
+ }
+
+ # Check that the reference (filename or sha1) is part of the thumb name
+ # This is a basic sanity check to avoid erasing unrelated directories
+ if ( strpos( $file, $reference ) !== false
+ || strpos( $file, "-thumbnail" ) !== false // "short" thumb name
+ ) {
+ $purgeList[] = "{$dir}/{$file}";
+ }
+ }
+
+ # Delete the thumbnails
+ $this->repo->quickPurgeBatch( $purgeList );
+ # Clear out the thumbnail directory if empty
+ $this->repo->quickCleanDir( $dir );
+ }
+
+ /** purgeDescription inherited */
+ /** purgeEverything inherited */
+
+ /**
+ * @param int $limit Optional: Limit to number of results
+ * @param int $start Optional: Timestamp, start from
+ * @param int $end Optional: Timestamp, end at
+ * @param bool $inc
+ * @return OldLocalFile[]
+ */
+ function getHistory( $limit = null, $start = null, $end = null, $inc = true ) {
+ $dbr = $this->repo->getReplicaDB();
+ $tables = [ 'oldimage' ];
+ $fields = OldLocalFile::selectFields();
+ $conds = $opts = $join_conds = [];
+ $eq = $inc ? '=' : '';
+ $conds[] = "oi_name = " . $dbr->addQuotes( $this->title->getDBkey() );
+
+ if ( $start ) {
+ $conds[] = "oi_timestamp <$eq " . $dbr->addQuotes( $dbr->timestamp( $start ) );
+ }
+
+ if ( $end ) {
+ $conds[] = "oi_timestamp >$eq " . $dbr->addQuotes( $dbr->timestamp( $end ) );
+ }
+
+ if ( $limit ) {
+ $opts['LIMIT'] = $limit;
+ }
+
+ // Search backwards for time > x queries
+ $order = ( !$start && $end !== null ) ? 'ASC' : 'DESC';
+ $opts['ORDER BY'] = "oi_timestamp $order";
+ $opts['USE INDEX'] = [ 'oldimage' => 'oi_name_timestamp' ];
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $localFile = $this;
+ Hooks::run( 'LocalFile::getHistory', [ &$localFile, &$tables, &$fields,
+ &$conds, &$opts, &$join_conds ] );
+
+ $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $opts, $join_conds );
+ $r = [];
+
+ foreach ( $res as $row ) {
+ $r[] = $this->repo->newFileFromRow( $row );
+ }
+
+ if ( $order == 'ASC' ) {
+ $r = array_reverse( $r ); // make sure it ends up descending
+ }
+
+ return $r;
+ }
+
+ /**
+ * Returns the history of this file, line by line.
+ * starts with current version, then old versions.
+ * uses $this->historyLine to check which line to return:
+ * 0 return line for current version
+ * 1 query for old versions, return first one
+ * 2, ... return next old version from above query
+ * @return bool
+ */
+ public function nextHistoryLine() {
+ # Polymorphic function name to distinguish foreign and local fetches
+ $fname = static::class . '::' . __FUNCTION__;
+
+ $dbr = $this->repo->getReplicaDB();
+
+ if ( $this->historyLine == 0 ) { // called for the first time, return line from cur
+ $this->historyRes = $dbr->select( 'image',
+ self::selectFields() + [
+ 'oi_archive_name' => $dbr->addQuotes( '' ),
+ 'oi_deleted' => 0,
+ ],
+ [ 'img_name' => $this->title->getDBkey() ],
+ $fname
+ );
+
+ if ( 0 == $dbr->numRows( $this->historyRes ) ) {
+ $this->historyRes = null;
+
+ return false;
+ }
+ } elseif ( $this->historyLine == 1 ) {
+ $this->historyRes = $dbr->select(
+ 'oldimage',
+ OldLocalFile::selectFields(),
+ [ 'oi_name' => $this->title->getDBkey() ],
+ $fname,
+ [ 'ORDER BY' => 'oi_timestamp DESC' ]
+ );
+ }
+ $this->historyLine++;
+
+ return $dbr->fetchObject( $this->historyRes );
+ }
+
+ /**
+ * Reset the history pointer to the first element of the history
+ */
+ public function resetHistory() {
+ $this->historyLine = 0;
+
+ if ( !is_null( $this->historyRes ) ) {
+ $this->historyRes = null;
+ }
+ }
+
+ /** getHashPath inherited */
+ /** getRel inherited */
+ /** getUrlRel inherited */
+ /** getArchiveRel inherited */
+ /** getArchivePath inherited */
+ /** getThumbPath inherited */
+ /** getArchiveUrl inherited */
+ /** getThumbUrl inherited */
+ /** getArchiveVirtualUrl inherited */
+ /** getThumbVirtualUrl inherited */
+ /** isHashed inherited */
+
+ /**
+ * Upload a file and record it in the DB
+ * @param string|FSFile $src Source storage path, virtual URL, or filesystem path
+ * @param string $comment Upload description
+ * @param string $pageText Text to use for the new description page,
+ * if a new description page is created
+ * @param int|bool $flags Flags for publish()
+ * @param array|bool $props File properties, if known. This can be used to
+ * reduce the upload time when uploading virtual URLs for which the file
+ * info is already known
+ * @param string|bool $timestamp Timestamp for img_timestamp, or false to use the
+ * current time
+ * @param User|null $user User object or null to use $wgUser
+ * @param string[] $tags Change tags to add to the log entry and page revision.
+ * (This doesn't check $user's permissions.)
+ * @return Status On success, the value member contains the
+ * archive name, or an empty string if it was a new file.
+ */
+ function upload( $src, $comment, $pageText, $flags = 0, $props = false,
+ $timestamp = false, $user = null, $tags = []
+ ) {
+ if ( $this->getRepo()->getReadOnlyReason() !== false ) {
+ return $this->readOnlyFatalStatus();
+ }
+
+ $srcPath = ( $src instanceof FSFile ) ? $src->getPath() : $src;
+ if ( !$props ) {
+ if ( $this->repo->isVirtualUrl( $srcPath )
+ || FileBackend::isStoragePath( $srcPath )
+ ) {
+ $props = $this->repo->getFileProps( $srcPath );
+ } else {
+ $mwProps = new MWFileProps( MimeMagic::singleton() );
+ $props = $mwProps->getPropsFromPath( $srcPath, true );
+ }
+ }
+
+ $options = [];
+ $handler = MediaHandler::getHandler( $props['mime'] );
+ if ( $handler ) {
+ $metadata = MediaWiki\quietCall( 'unserialize', $props['metadata'] );
+
+ if ( !is_array( $metadata ) ) {
+ $metadata = [];
+ }
+
+ $options['headers'] = $handler->getContentHeaders( $metadata );
+ } else {
+ $options['headers'] = [];
+ }
+
+ // Trim spaces on user supplied text
+ $comment = trim( $comment );
+
+ $this->lock(); // begin
+ $status = $this->publish( $src, $flags, $options );
+
+ if ( $status->successCount >= 2 ) {
+ // There will be a copy+(one of move,copy,store).
+ // The first succeeding does not commit us to updating the DB
+ // since it simply copied the current version to a timestamped file name.
+ // It is only *preferable* to avoid leaving such files orphaned.
+ // Once the second operation goes through, then the current version was
+ // updated and we must therefore update the DB too.
+ $oldver = $status->value;
+ if ( !$this->recordUpload2( $oldver, $comment, $pageText, $props, $timestamp, $user, $tags ) ) {
+ $status->fatal( 'filenotfound', $srcPath );
+ }
+ }
+
+ $this->unlock(); // done
+
+ return $status;
+ }
+
+ /**
+ * Record a file upload in the upload log and the image table
+ * @param string $oldver
+ * @param string $desc
+ * @param string $license
+ * @param string $copyStatus
+ * @param string $source
+ * @param bool $watch
+ * @param string|bool $timestamp
+ * @param User|null $user User object or null to use $wgUser
+ * @return bool
+ */
+ function recordUpload( $oldver, $desc, $license = '', $copyStatus = '', $source = '',
+ $watch = false, $timestamp = false, User $user = null ) {
+ if ( !$user ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ $pageText = SpecialUpload::getInitialPageText( $desc, $license, $copyStatus, $source );
+
+ if ( !$this->recordUpload2( $oldver, $desc, $pageText, false, $timestamp, $user ) ) {
+ return false;
+ }
+
+ if ( $watch ) {
+ $user->addWatch( $this->getTitle() );
+ }
+
+ return true;
+ }
+
+ /**
+ * Record a file upload in the upload log and the image table
+ * @param string $oldver
+ * @param string $comment
+ * @param string $pageText
+ * @param bool|array $props
+ * @param string|bool $timestamp
+ * @param null|User $user
+ * @param string[] $tags
+ * @return bool
+ */
+ function recordUpload2(
+ $oldver, $comment, $pageText, $props = false, $timestamp = false, $user = null, $tags = []
+ ) {
+ global $wgCommentTableSchemaMigrationStage;
+
+ if ( is_null( $user ) ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ $dbw = $this->repo->getMasterDB();
+
+ # Imports or such might force a certain timestamp; otherwise we generate
+ # it and can fudge it slightly to keep (name,timestamp) unique on re-upload.
+ if ( $timestamp === false ) {
+ $timestamp = $dbw->timestamp();
+ $allowTimeKludge = true;
+ } else {
+ $allowTimeKludge = false;
+ }
+
+ $props = $props ?: $this->repo->getFileProps( $this->getVirtualUrl() );
+ $props['description'] = $comment;
+ $props['user'] = $user->getId();
+ $props['user_text'] = $user->getName();
+ $props['timestamp'] = wfTimestamp( TS_MW, $timestamp ); // DB -> TS_MW
+ $this->setProps( $props );
+
+ # Fail now if the file isn't there
+ if ( !$this->fileExists ) {
+ wfDebug( __METHOD__ . ": File " . $this->getRel() . " went missing!\n" );
+
+ return false;
+ }
+
+ $dbw->startAtomic( __METHOD__ );
+
+ # Test to see if the row exists using INSERT IGNORE
+ # This avoids race conditions by locking the row until the commit, and also
+ # doesn't deadlock. SELECT FOR UPDATE causes a deadlock for every race condition.
+ $commentStore = new CommentStore( 'img_description' );
+ list( $commentFields, $commentCallback ) =
+ $commentStore->insertWithTempTable( $dbw, $comment );
+ $dbw->insert( 'image',
+ [
+ 'img_name' => $this->getName(),
+ 'img_size' => $this->size,
+ 'img_width' => intval( $this->width ),
+ 'img_height' => intval( $this->height ),
+ 'img_bits' => $this->bits,
+ 'img_media_type' => $this->media_type,
+ 'img_major_mime' => $this->major_mime,
+ 'img_minor_mime' => $this->minor_mime,
+ 'img_timestamp' => $timestamp,
+ 'img_user' => $user->getId(),
+ 'img_user_text' => $user->getName(),
+ 'img_metadata' => $dbw->encodeBlob( $this->metadata ),
+ 'img_sha1' => $this->sha1
+ ] + $commentFields,
+ __METHOD__,
+ 'IGNORE'
+ );
+ $reupload = ( $dbw->affectedRows() == 0 );
+
+ if ( $reupload ) {
+ if ( $allowTimeKludge ) {
+ # Use LOCK IN SHARE MODE to ignore any transaction snapshotting
+ $ltimestamp = $dbw->selectField(
+ 'image',
+ 'img_timestamp',
+ [ 'img_name' => $this->getName() ],
+ __METHOD__,
+ [ 'LOCK IN SHARE MODE' ]
+ );
+ $lUnixtime = $ltimestamp ? wfTimestamp( TS_UNIX, $ltimestamp ) : false;
+ # Avoid a timestamp that is not newer than the last version
+ # TODO: the image/oldimage tables should be like page/revision with an ID field
+ if ( $lUnixtime && wfTimestamp( TS_UNIX, $timestamp ) <= $lUnixtime ) {
+ sleep( 1 ); // fast enough re-uploads would go far in the future otherwise
+ $timestamp = $dbw->timestamp( $lUnixtime + 1 );
+ $this->timestamp = wfTimestamp( TS_MW, $timestamp ); // DB -> TS_MW
+ }
+ }
+
+ $tables = [ 'image' ];
+ $fields = [
+ 'oi_name' => 'img_name',
+ 'oi_archive_name' => $dbw->addQuotes( $oldver ),
+ 'oi_size' => 'img_size',
+ 'oi_width' => 'img_width',
+ 'oi_height' => 'img_height',
+ 'oi_bits' => 'img_bits',
+ 'oi_timestamp' => 'img_timestamp',
+ 'oi_user' => 'img_user',
+ 'oi_user_text' => 'img_user_text',
+ 'oi_metadata' => 'img_metadata',
+ 'oi_media_type' => 'img_media_type',
+ 'oi_major_mime' => 'img_major_mime',
+ 'oi_minor_mime' => 'img_minor_mime',
+ 'oi_sha1' => 'img_sha1',
+ ];
+ $joins = [];
+
+ if ( $wgCommentTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ $fields['oi_description'] = 'img_description';
+ }
+ if ( $wgCommentTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ $tables[] = 'image_comment_temp';
+ $fields['oi_description_id'] = 'imgcomment_description_id';
+ $joins['image_comment_temp'] = [
+ $wgCommentTableSchemaMigrationStage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN',
+ [ 'imgcomment_name = img_name' ]
+ ];
+ }
+
+ if ( $wgCommentTableSchemaMigrationStage !== MIGRATION_OLD &&
+ $wgCommentTableSchemaMigrationStage !== MIGRATION_NEW
+ ) {
+ // Upgrade any rows that are still old-style. Otherwise an upgrade
+ // might be missed if a deletion happens while the migration script
+ // is running.
+ $res = $dbw->select(
+ [ 'image', 'image_comment_temp' ],
+ [ 'img_name', 'img_description' ],
+ [ 'img_name' => $this->getName(), 'imgcomment_name' => null ],
+ __METHOD__,
+ [],
+ [ 'image_comment_temp' => [ 'LEFT JOIN', [ 'imgcomment_name = img_name' ] ] ]
+ );
+ foreach ( $res as $row ) {
+ list( , $callback ) = $commentStore->insertWithTempTable( $dbw, $row->img_description );
+ $callback( $row->img_name );
+ }
+ }
+
+ # (T36993) Note: $oldver can be empty here, if the previous
+ # version of the file was broken. Allow registration of the new
+ # version to continue anyway, because that's better than having
+ # an image that's not fixable by user operations.
+ # Collision, this is an update of a file
+ # Insert previous contents into oldimage
+ $dbw->insertSelect( 'oldimage', $tables, $fields,
+ [ 'img_name' => $this->getName() ], __METHOD__, [], [], $joins );
+
+ # Update the current image row
+ $dbw->update( 'image',
+ [
+ 'img_size' => $this->size,
+ 'img_width' => intval( $this->width ),
+ 'img_height' => intval( $this->height ),
+ 'img_bits' => $this->bits,
+ 'img_media_type' => $this->media_type,
+ 'img_major_mime' => $this->major_mime,
+ 'img_minor_mime' => $this->minor_mime,
+ 'img_timestamp' => $timestamp,
+ 'img_user' => $user->getId(),
+ 'img_user_text' => $user->getName(),
+ 'img_metadata' => $dbw->encodeBlob( $this->metadata ),
+ 'img_sha1' => $this->sha1
+ ] + $commentFields,
+ [ 'img_name' => $this->getName() ],
+ __METHOD__
+ );
+ if ( $wgCommentTableSchemaMigrationStage > MIGRATION_OLD ) {
+ // So $commentCallback can insert the new row
+ $dbw->delete( 'image_comment_temp', [ 'imgcomment_name' => $this->getName() ], __METHOD__ );
+ }
+ }
+ $commentCallback( $this->getName() );
+
+ $descTitle = $this->getTitle();
+ $descId = $descTitle->getArticleID();
+ $wikiPage = new WikiFilePage( $descTitle );
+ $wikiPage->setFile( $this );
+
+ // Add the log entry...
+ $logEntry = new ManualLogEntry( 'upload', $reupload ? 'overwrite' : 'upload' );
+ $logEntry->setTimestamp( $this->timestamp );
+ $logEntry->setPerformer( $user );
+ $logEntry->setComment( $comment );
+ $logEntry->setTarget( $descTitle );
+ // Allow people using the api to associate log entries with the upload.
+ // Log has a timestamp, but sometimes different from upload timestamp.
+ $logEntry->setParameters(
+ [
+ 'img_sha1' => $this->sha1,
+ 'img_timestamp' => $timestamp,
+ ]
+ );
+ // Note we keep $logId around since during new image
+ // creation, page doesn't exist yet, so log_page = 0
+ // but we want it to point to the page we're making,
+ // so we later modify the log entry.
+ // For a similar reason, we avoid making an RC entry
+ // now and wait until the page exists.
+ $logId = $logEntry->insert();
+
+ if ( $descTitle->exists() ) {
+ // Use own context to get the action text in content language
+ $formatter = LogFormatter::newFromEntry( $logEntry );
+ $formatter->setContext( RequestContext::newExtraneousContext( $descTitle ) );
+ $editSummary = $formatter->getPlainActionText();
+
+ $nullRevision = Revision::newNullRevision(
+ $dbw,
+ $descId,
+ $editSummary,
+ false,
+ $user
+ );
+ if ( $nullRevision ) {
+ $nullRevision->insertOn( $dbw );
+ Hooks::run(
+ 'NewRevisionFromEditComplete',
+ [ $wikiPage, $nullRevision, $nullRevision->getParentId(), $user ]
+ );
+ $wikiPage->updateRevisionOn( $dbw, $nullRevision );
+ // Associate null revision id
+ $logEntry->setAssociatedRevId( $nullRevision->getId() );
+ }
+
+ $newPageContent = null;
+ } else {
+ // Make the description page and RC log entry post-commit
+ $newPageContent = ContentHandler::makeContent( $pageText, $descTitle );
+ }
+
+ # Defer purges, page creation, and link updates in case they error out.
+ # The most important thing is that files and the DB registry stay synced.
+ $dbw->endAtomic( __METHOD__ );
+
+ # Do some cache purges after final commit so that:
+ # a) Changes are more likely to be seen post-purge
+ # b) They won't cause rollback of the log publish/update above
+ DeferredUpdates::addUpdate(
+ new AutoCommitUpdate(
+ $dbw,
+ __METHOD__,
+ function () use (
+ $reupload, $wikiPage, $newPageContent, $comment, $user,
+ $logEntry, $logId, $descId, $tags
+ ) {
+ # Update memcache after the commit
+ $this->invalidateCache();
+
+ $updateLogPage = false;
+ if ( $newPageContent ) {
+ # New file page; create the description page.
+ # There's already a log entry, so don't make a second RC entry
+ # CDN and file cache for the description page are purged by doEditContent.
+ $status = $wikiPage->doEditContent(
+ $newPageContent,
+ $comment,
+ EDIT_NEW | EDIT_SUPPRESS_RC,
+ false,
+ $user
+ );
+
+ if ( isset( $status->value['revision'] ) ) {
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+ // Associate new page revision id
+ $logEntry->setAssociatedRevId( $rev->getId() );
+ }
+ // This relies on the resetArticleID() call in WikiPage::insertOn(),
+ // which is triggered on $descTitle by doEditContent() above.
+ if ( isset( $status->value['revision'] ) ) {
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+ $updateLogPage = $rev->getPage();
+ }
+ } else {
+ # Existing file page: invalidate description page cache
+ $wikiPage->getTitle()->invalidateCache();
+ $wikiPage->getTitle()->purgeSquid();
+ # Allow the new file version to be patrolled from the page footer
+ Article::purgePatrolFooterCache( $descId );
+ }
+
+ # Update associated rev id. This should be done by $logEntry->insert() earlier,
+ # but setAssociatedRevId() wasn't called at that point yet...
+ $logParams = $logEntry->getParameters();
+ $logParams['associated_rev_id'] = $logEntry->getAssociatedRevId();
+ $update = [ 'log_params' => LogEntryBase::makeParamBlob( $logParams ) ];
+ if ( $updateLogPage ) {
+ # Also log page, in case where we just created it above
+ $update['log_page'] = $updateLogPage;
+ }
+ $this->getRepo()->getMasterDB()->update(
+ 'logging',
+ $update,
+ [ 'log_id' => $logId ],
+ __METHOD__
+ );
+ $this->getRepo()->getMasterDB()->insert(
+ 'log_search',
+ [
+ 'ls_field' => 'associated_rev_id',
+ 'ls_value' => $logEntry->getAssociatedRevId(),
+ 'ls_log_id' => $logId,
+ ],
+ __METHOD__
+ );
+
+ # Add change tags, if any
+ if ( $tags ) {
+ $logEntry->setTags( $tags );
+ }
+
+ # Uploads can be patrolled
+ $logEntry->setIsPatrollable( true );
+
+ # Now that the log entry is up-to-date, make an RC entry.
+ $logEntry->publish( $logId );
+
+ # Run hook for other updates (typically more cache purging)
+ Hooks::run( 'FileUpload', [ $this, $reupload, !$newPageContent ] );
+
+ if ( $reupload ) {
+ # Delete old thumbnails
+ $this->purgeThumbnails();
+ # Remove the old file from the CDN cache
+ DeferredUpdates::addUpdate(
+ new CdnCacheUpdate( [ $this->getUrl() ] ),
+ DeferredUpdates::PRESEND
+ );
+ } else {
+ # Update backlink pages pointing to this title if created
+ LinksUpdate::queueRecursiveJobsForTable( $this->getTitle(), 'imagelinks' );
+ }
+
+ $this->prerenderThumbnails();
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ if ( !$reupload ) {
+ # This is a new file, so update the image count
+ DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => 1 ] ) );
+ }
+
+ # Invalidate cache for all pages using this file
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this->getTitle(), 'imagelinks' ) );
+
+ return true;
+ }
+
+ /**
+ * Move or copy a file to its public location. If a file exists at the
+ * destination, move it to an archive. Returns a Status object with
+ * the archive name in the "value" member on success.
+ *
+ * The archive name should be passed through to recordUpload for database
+ * registration.
+ *
+ * @param string|FSFile $src Local filesystem path or virtual URL to the source image
+ * @param int $flags A bitwise combination of:
+ * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy
+ * @param array $options Optional additional parameters
+ * @return Status On success, the value member contains the
+ * archive name, or an empty string if it was a new file.
+ */
+ function publish( $src, $flags = 0, array $options = [] ) {
+ return $this->publishTo( $src, $this->getRel(), $flags, $options );
+ }
+
+ /**
+ * Move or copy a file to a specified location. Returns a Status
+ * object with the archive name in the "value" member on success.
+ *
+ * The archive name should be passed through to recordUpload for database
+ * registration.
+ *
+ * @param string|FSFile $src Local filesystem path or virtual URL to the source image
+ * @param string $dstRel Target relative path
+ * @param int $flags A bitwise combination of:
+ * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy
+ * @param array $options Optional additional parameters
+ * @return Status On success, the value member contains the
+ * archive name, or an empty string if it was a new file.
+ */
+ function publishTo( $src, $dstRel, $flags = 0, array $options = [] ) {
+ $srcPath = ( $src instanceof FSFile ) ? $src->getPath() : $src;
+
+ $repo = $this->getRepo();
+ if ( $repo->getReadOnlyReason() !== false ) {
+ return $this->readOnlyFatalStatus();
+ }
+
+ $this->lock(); // begin
+
+ $archiveName = wfTimestamp( TS_MW ) . '!' . $this->getName();
+ $archiveRel = 'archive/' . $this->getHashPath() . $archiveName;
+
+ if ( $repo->hasSha1Storage() ) {
+ $sha1 = $repo->isVirtualUrl( $srcPath )
+ ? $repo->getFileSha1( $srcPath )
+ : FSFile::getSha1Base36FromPath( $srcPath );
+ /** @var FileBackendDBRepoWrapper $wrapperBackend */
+ $wrapperBackend = $repo->getBackend();
+ $dst = $wrapperBackend->getPathForSHA1( $sha1 );
+ $status = $repo->quickImport( $src, $dst );
+ if ( $flags & File::DELETE_SOURCE ) {
+ unlink( $srcPath );
+ }
+
+ if ( $this->exists() ) {
+ $status->value = $archiveName;
+ }
+ } else {
+ $flags = $flags & File::DELETE_SOURCE ? LocalRepo::DELETE_SOURCE : 0;
+ $status = $repo->publish( $srcPath, $dstRel, $archiveRel, $flags, $options );
+
+ if ( $status->value == 'new' ) {
+ $status->value = '';
+ } else {
+ $status->value = $archiveName;
+ }
+ }
+
+ $this->unlock(); // done
+
+ return $status;
+ }
+
+ /** getLinksTo inherited */
+ /** getExifData inherited */
+ /** isLocal inherited */
+ /** wasDeleted inherited */
+
+ /**
+ * Move file to the new title
+ *
+ * Move current, old version and all thumbnails
+ * to the new filename. Old file is deleted.
+ *
+ * Cache purging is done; checks for validity
+ * and logging are caller's responsibility
+ *
+ * @param Title $target New file name
+ * @return Status
+ */
+ function move( $target ) {
+ if ( $this->getRepo()->getReadOnlyReason() !== false ) {
+ return $this->readOnlyFatalStatus();
+ }
+
+ wfDebugLog( 'imagemove', "Got request to move {$this->name} to " . $target->getText() );
+ $batch = new LocalFileMoveBatch( $this, $target );
+
+ $this->lock(); // begin
+ $batch->addCurrent();
+ $archiveNames = $batch->addOlds();
+ $status = $batch->execute();
+ $this->unlock(); // done
+
+ wfDebugLog( 'imagemove', "Finished moving {$this->name}" );
+
+ // Purge the source and target files...
+ $oldTitleFile = wfLocalFile( $this->title );
+ $newTitleFile = wfLocalFile( $target );
+ // To avoid slow purges in the transaction, move them outside...
+ DeferredUpdates::addUpdate(
+ new AutoCommitUpdate(
+ $this->getRepo()->getMasterDB(),
+ __METHOD__,
+ function () use ( $oldTitleFile, $newTitleFile, $archiveNames ) {
+ $oldTitleFile->purgeEverything();
+ foreach ( $archiveNames as $archiveName ) {
+ $oldTitleFile->purgeOldThumbnails( $archiveName );
+ }
+ $newTitleFile->purgeEverything();
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ if ( $status->isOK() ) {
+ // Now switch the object
+ $this->title = $target;
+ // Force regeneration of the name and hashpath
+ unset( $this->name );
+ unset( $this->hashPath );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Delete all versions of the file.
+ *
+ * Moves the files into an archive directory (or deletes them)
+ * and removes the database rows.
+ *
+ * Cache purging is done; logging is caller's responsibility.
+ *
+ * @param string $reason
+ * @param bool $suppress
+ * @param User|null $user
+ * @return Status
+ */
+ function delete( $reason, $suppress = false, $user = null ) {
+ if ( $this->getRepo()->getReadOnlyReason() !== false ) {
+ return $this->readOnlyFatalStatus();
+ }
+
+ $batch = new LocalFileDeleteBatch( $this, $reason, $suppress, $user );
+
+ $this->lock(); // begin
+ $batch->addCurrent();
+ // Get old version relative paths
+ $archiveNames = $batch->addOlds();
+ $status = $batch->execute();
+ $this->unlock(); // done
+
+ if ( $status->isOK() ) {
+ DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => -1 ] ) );
+ }
+
+ // To avoid slow purges in the transaction, move them outside...
+ DeferredUpdates::addUpdate(
+ new AutoCommitUpdate(
+ $this->getRepo()->getMasterDB(),
+ __METHOD__,
+ function () use ( $archiveNames ) {
+ $this->purgeEverything();
+ foreach ( $archiveNames as $archiveName ) {
+ $this->purgeOldThumbnails( $archiveName );
+ }
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ // Purge the CDN
+ $purgeUrls = [];
+ foreach ( $archiveNames as $archiveName ) {
+ $purgeUrls[] = $this->getArchiveUrl( $archiveName );
+ }
+ DeferredUpdates::addUpdate( new CdnCacheUpdate( $purgeUrls ), DeferredUpdates::PRESEND );
+
+ return $status;
+ }
+
+ /**
+ * Delete an old version of the file.
+ *
+ * Moves the file into an archive directory (or deletes it)
+ * and removes the database row.
+ *
+ * Cache purging is done; logging is caller's responsibility.
+ *
+ * @param string $archiveName
+ * @param string $reason
+ * @param bool $suppress
+ * @param User|null $user
+ * @throws MWException Exception on database or file store failure
+ * @return Status
+ */
+ function deleteOld( $archiveName, $reason, $suppress = false, $user = null ) {
+ if ( $this->getRepo()->getReadOnlyReason() !== false ) {
+ return $this->readOnlyFatalStatus();
+ }
+
+ $batch = new LocalFileDeleteBatch( $this, $reason, $suppress, $user );
+
+ $this->lock(); // begin
+ $batch->addOld( $archiveName );
+ $status = $batch->execute();
+ $this->unlock(); // done
+
+ $this->purgeOldThumbnails( $archiveName );
+ if ( $status->isOK() ) {
+ $this->purgeDescription();
+ }
+
+ DeferredUpdates::addUpdate(
+ new CdnCacheUpdate( [ $this->getArchiveUrl( $archiveName ) ] ),
+ DeferredUpdates::PRESEND
+ );
+
+ return $status;
+ }
+
+ /**
+ * Restore all or specified deleted revisions to the given file.
+ * Permissions and logging are left to the caller.
+ *
+ * May throw database exceptions on error.
+ *
+ * @param array $versions Set of record ids of deleted items to restore,
+ * or empty to restore all revisions.
+ * @param bool $unsuppress
+ * @return Status
+ */
+ function restore( $versions = [], $unsuppress = false ) {
+ if ( $this->getRepo()->getReadOnlyReason() !== false ) {
+ return $this->readOnlyFatalStatus();
+ }
+
+ $batch = new LocalFileRestoreBatch( $this, $unsuppress );
+
+ $this->lock(); // begin
+ if ( !$versions ) {
+ $batch->addAll();
+ } else {
+ $batch->addIds( $versions );
+ }
+ $status = $batch->execute();
+ if ( $status->isGood() ) {
+ $cleanupStatus = $batch->cleanup();
+ $cleanupStatus->successCount = 0;
+ $cleanupStatus->failCount = 0;
+ $status->merge( $cleanupStatus );
+ }
+ $this->unlock(); // done
+
+ return $status;
+ }
+
+ /** isMultipage inherited */
+ /** pageCount inherited */
+ /** scaleHeight inherited */
+ /** getImageSize inherited */
+
+ /**
+ * Get the URL of the file description page.
+ * @return string
+ */
+ function getDescriptionUrl() {
+ return $this->title->getLocalURL();
+ }
+
+ /**
+ * Get the HTML text of the description page
+ * This is not used by ImagePage for local files, since (among other things)
+ * it skips the parser cache.
+ *
+ * @param Language $lang What language to get description in (Optional)
+ * @return bool|mixed
+ */
+ function getDescriptionText( $lang = null ) {
+ $revision = Revision::newFromTitle( $this->title, false, Revision::READ_NORMAL );
+ if ( !$revision ) {
+ return false;
+ }
+ $content = $revision->getContent();
+ if ( !$content ) {
+ return false;
+ }
+ $pout = $content->getParserOutput( $this->title, null, new ParserOptions( null, $lang ) );
+
+ return $pout->getText();
+ }
+
+ /**
+ * @param int $audience
+ * @param User $user
+ * @return string
+ */
+ function getDescription( $audience = self::FOR_PUBLIC, User $user = null ) {
+ $this->load();
+ if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_COMMENT ) ) {
+ return '';
+ } elseif ( $audience == self::FOR_THIS_USER
+ && !$this->userCan( self::DELETED_COMMENT, $user )
+ ) {
+ return '';
+ } else {
+ return $this->description;
+ }
+ }
+
+ /**
+ * @return bool|string
+ */
+ function getTimestamp() {
+ $this->load();
+
+ return $this->timestamp;
+ }
+
+ /**
+ * @return bool|string
+ */
+ public function getDescriptionTouched() {
+ // The DB lookup might return false, e.g. if the file was just deleted, or the shared DB repo
+ // itself gets it from elsewhere. To avoid repeating the DB lookups in such a case, we
+ // need to differentiate between null (uninitialized) and false (failed to load).
+ if ( $this->descriptionTouched === null ) {
+ $cond = [
+ 'page_namespace' => $this->title->getNamespace(),
+ 'page_title' => $this->title->getDBkey()
+ ];
+ $touched = $this->repo->getReplicaDB()->selectField( 'page', 'page_touched', $cond, __METHOD__ );
+ $this->descriptionTouched = $touched ? wfTimestamp( TS_MW, $touched ) : false;
+ }
+
+ return $this->descriptionTouched;
+ }
+
+ /**
+ * @return string
+ */
+ function getSha1() {
+ $this->load();
+ // Initialise now if necessary
+ if ( $this->sha1 == '' && $this->fileExists ) {
+ $this->lock(); // begin
+
+ $this->sha1 = $this->repo->getFileSha1( $this->getPath() );
+ if ( !wfReadOnly() && strval( $this->sha1 ) != '' ) {
+ $dbw = $this->repo->getMasterDB();
+ $dbw->update( 'image',
+ [ 'img_sha1' => $this->sha1 ],
+ [ 'img_name' => $this->getName() ],
+ __METHOD__ );
+ $this->invalidateCache();
+ }
+
+ $this->unlock(); // done
+ }
+
+ return $this->sha1;
+ }
+
+ /**
+ * @return bool Whether to cache in RepoGroup (this avoids OOMs)
+ */
+ function isCacheable() {
+ $this->load();
+
+ // If extra data (metadata) was not loaded then it must have been large
+ return $this->extraDataLoaded
+ && strlen( serialize( $this->metadata ) ) <= self::CACHE_FIELD_MAX_LEN;
+ }
+
+ /**
+ * @return Status
+ * @since 1.28
+ */
+ public function acquireFileLock() {
+ return $this->getRepo()->getBackend()->lockFiles(
+ [ $this->getPath() ], LockManager::LOCK_EX, 10
+ );
+ }
+
+ /**
+ * @return Status
+ * @since 1.28
+ */
+ public function releaseFileLock() {
+ return $this->getRepo()->getBackend()->unlockFiles(
+ [ $this->getPath() ], LockManager::LOCK_EX
+ );
+ }
+
+ /**
+ * Start an atomic DB section and lock the image for update
+ * or increments a reference counter if the lock is already held
+ *
+ * This method should not be used outside of LocalFile/LocalFile*Batch
+ *
+ * @throws LocalFileLockError Throws an error if the lock was not acquired
+ * @return bool Whether the file lock owns/spawned the DB transaction
+ */
+ public function lock() {
+ if ( !$this->locked ) {
+ $logger = LoggerFactory::getInstance( 'LocalFile' );
+
+ $dbw = $this->repo->getMasterDB();
+ $makesTransaction = !$dbw->trxLevel();
+ $dbw->startAtomic( self::ATOMIC_SECTION_LOCK );
+ // T56736: use simple lock to handle when the file does not exist.
+ // SELECT FOR UPDATE prevents changes, not other SELECTs with FOR UPDATE.
+ // Also, that would cause contention on INSERT of similarly named rows.
+ $status = $this->acquireFileLock(); // represents all versions of the file
+ if ( !$status->isGood() ) {
+ $dbw->endAtomic( self::ATOMIC_SECTION_LOCK );
+ $logger->warning( "Failed to lock '{file}'", [ 'file' => $this->name ] );
+
+ throw new LocalFileLockError( $status );
+ }
+ // Release the lock *after* commit to avoid row-level contention.
+ // Make sure it triggers on rollback() as well as commit() (T132921).
+ $dbw->onTransactionResolution(
+ function () use ( $logger ) {
+ $status = $this->releaseFileLock();
+ if ( !$status->isGood() ) {
+ $logger->error( "Failed to unlock '{file}'", [ 'file' => $this->name ] );
+ }
+ },
+ __METHOD__
+ );
+ // Callers might care if the SELECT snapshot is safely fresh
+ $this->lockedOwnTrx = $makesTransaction;
+ }
+
+ $this->locked++;
+
+ return $this->lockedOwnTrx;
+ }
+
+ /**
+ * Decrement the lock reference count and end the atomic section if it reaches zero
+ *
+ * This method should not be used outside of LocalFile/LocalFile*Batch
+ *
+ * The commit and loc release will happen when no atomic sections are active, which
+ * may happen immediately or at some point after calling this
+ */
+ public function unlock() {
+ if ( $this->locked ) {
+ --$this->locked;
+ if ( !$this->locked ) {
+ $dbw = $this->repo->getMasterDB();
+ $dbw->endAtomic( self::ATOMIC_SECTION_LOCK );
+ $this->lockedOwnTrx = false;
+ }
+ }
+ }
+
+ /**
+ * @return Status
+ */
+ protected function readOnlyFatalStatus() {
+ return $this->getRepo()->newFatal( 'filereadonlyerror', $this->getName(),
+ $this->getRepo()->getName(), $this->getRepo()->getReadOnlyReason() );
+ }
+
+ /**
+ * Clean up any dangling locks
+ */
+ function __destruct() {
+ $this->unlock();
+ }
+} // LocalFile class
+
+# ------------------------------------------------------------------------------
+
+/**
+ * Helper class for file deletion
+ * @ingroup FileAbstraction
+ */
+class LocalFileDeleteBatch {
+ /** @var LocalFile */
+ private $file;
+
+ /** @var string */
+ private $reason;
+
+ /** @var array */
+ private $srcRels = [];
+
+ /** @var array */
+ private $archiveUrls = [];
+
+ /** @var array Items to be processed in the deletion batch */
+ private $deletionBatch;
+
+ /** @var bool Whether to suppress all suppressable fields when deleting */
+ private $suppress;
+
+ /** @var Status */
+ private $status;
+
+ /** @var User */
+ private $user;
+
+ /**
+ * @param File $file
+ * @param string $reason
+ * @param bool $suppress
+ * @param User|null $user
+ */
+ function __construct( File $file, $reason = '', $suppress = false, $user = null ) {
+ $this->file = $file;
+ $this->reason = $reason;
+ $this->suppress = $suppress;
+ if ( $user ) {
+ $this->user = $user;
+ } else {
+ global $wgUser;
+ $this->user = $wgUser;
+ }
+ $this->status = $file->repo->newGood();
+ }
+
+ public function addCurrent() {
+ $this->srcRels['.'] = $this->file->getRel();
+ }
+
+ /**
+ * @param string $oldName
+ */
+ public function addOld( $oldName ) {
+ $this->srcRels[$oldName] = $this->file->getArchiveRel( $oldName );
+ $this->archiveUrls[] = $this->file->getArchiveUrl( $oldName );
+ }
+
+ /**
+ * Add the old versions of the image to the batch
+ * @return array List of archive names from old versions
+ */
+ public function addOlds() {
+ $archiveNames = [];
+
+ $dbw = $this->file->repo->getMasterDB();
+ $result = $dbw->select( 'oldimage',
+ [ 'oi_archive_name' ],
+ [ 'oi_name' => $this->file->getName() ],
+ __METHOD__
+ );
+
+ foreach ( $result as $row ) {
+ $this->addOld( $row->oi_archive_name );
+ $archiveNames[] = $row->oi_archive_name;
+ }
+
+ return $archiveNames;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getOldRels() {
+ if ( !isset( $this->srcRels['.'] ) ) {
+ $oldRels =& $this->srcRels;
+ $deleteCurrent = false;
+ } else {
+ $oldRels = $this->srcRels;
+ unset( $oldRels['.'] );
+ $deleteCurrent = true;
+ }
+
+ return [ $oldRels, $deleteCurrent ];
+ }
+
+ /**
+ * @return array
+ */
+ protected function getHashes() {
+ $hashes = [];
+ list( $oldRels, $deleteCurrent ) = $this->getOldRels();
+
+ if ( $deleteCurrent ) {
+ $hashes['.'] = $this->file->getSha1();
+ }
+
+ if ( count( $oldRels ) ) {
+ $dbw = $this->file->repo->getMasterDB();
+ $res = $dbw->select(
+ 'oldimage',
+ [ 'oi_archive_name', 'oi_sha1' ],
+ [ 'oi_archive_name' => array_keys( $oldRels ),
+ 'oi_name' => $this->file->getName() ], // performance
+ __METHOD__
+ );
+
+ foreach ( $res as $row ) {
+ if ( rtrim( $row->oi_sha1, "\0" ) === '' ) {
+ // Get the hash from the file
+ $oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name );
+ $props = $this->file->repo->getFileProps( $oldUrl );
+
+ if ( $props['fileExists'] ) {
+ // Upgrade the oldimage row
+ $dbw->update( 'oldimage',
+ [ 'oi_sha1' => $props['sha1'] ],
+ [ 'oi_name' => $this->file->getName(), 'oi_archive_name' => $row->oi_archive_name ],
+ __METHOD__ );
+ $hashes[$row->oi_archive_name] = $props['sha1'];
+ } else {
+ $hashes[$row->oi_archive_name] = false;
+ }
+ } else {
+ $hashes[$row->oi_archive_name] = $row->oi_sha1;
+ }
+ }
+ }
+
+ $missing = array_diff_key( $this->srcRels, $hashes );
+
+ foreach ( $missing as $name => $rel ) {
+ $this->status->error( 'filedelete-old-unregistered', $name );
+ }
+
+ foreach ( $hashes as $name => $hash ) {
+ if ( !$hash ) {
+ $this->status->error( 'filedelete-missing', $this->srcRels[$name] );
+ unset( $hashes[$name] );
+ }
+ }
+
+ return $hashes;
+ }
+
+ protected function doDBInserts() {
+ global $wgCommentTableSchemaMigrationStage;
+
+ $now = time();
+ $dbw = $this->file->repo->getMasterDB();
+
+ $commentStoreImgDesc = new CommentStore( 'img_description' );
+ $commentStoreOiDesc = new CommentStore( 'oi_description' );
+ $commentStoreFaDesc = new CommentStore( 'fa_description' );
+ $commentStoreFaReason = new CommentStore( 'fa_deleted_reason' );
+
+ $encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) );
+ $encUserId = $dbw->addQuotes( $this->user->getId() );
+ $encGroup = $dbw->addQuotes( 'deleted' );
+ $ext = $this->file->getExtension();
+ $dotExt = $ext === '' ? '' : ".$ext";
+ $encExt = $dbw->addQuotes( $dotExt );
+ list( $oldRels, $deleteCurrent ) = $this->getOldRels();
+
+ // Bitfields to further suppress the content
+ if ( $this->suppress ) {
+ $bitfield = Revision::SUPPRESSED_ALL;
+ } else {
+ $bitfield = 'oi_deleted';
+ }
+
+ if ( $deleteCurrent ) {
+ $tables = [ 'image' ];
+ $fields = [
+ 'fa_storage_group' => $encGroup,
+ 'fa_storage_key' => $dbw->conditional(
+ [ 'img_sha1' => '' ],
+ $dbw->addQuotes( '' ),
+ $dbw->buildConcat( [ "img_sha1", $encExt ] )
+ ),
+ 'fa_deleted_user' => $encUserId,
+ 'fa_deleted_timestamp' => $encTimestamp,
+ 'fa_deleted' => $this->suppress ? $bitfield : 0,
+ 'fa_name' => 'img_name',
+ 'fa_archive_name' => 'NULL',
+ 'fa_size' => 'img_size',
+ 'fa_width' => 'img_width',
+ 'fa_height' => 'img_height',
+ 'fa_metadata' => 'img_metadata',
+ 'fa_bits' => 'img_bits',
+ 'fa_media_type' => 'img_media_type',
+ 'fa_major_mime' => 'img_major_mime',
+ 'fa_minor_mime' => 'img_minor_mime',
+ 'fa_user' => 'img_user',
+ 'fa_user_text' => 'img_user_text',
+ 'fa_timestamp' => 'img_timestamp',
+ 'fa_sha1' => 'img_sha1'
+ ];
+ $joins = [];
+
+ $fields += array_map(
+ [ $dbw, 'addQuotes' ],
+ $commentStoreFaReason->insert( $dbw, $this->reason )
+ );
+
+ if ( $wgCommentTableSchemaMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ $fields['fa_description'] = 'img_description';
+ }
+ if ( $wgCommentTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ $tables[] = 'image_comment_temp';
+ $fields['fa_description_id'] = 'imgcomment_description_id';
+ $joins['image_comment_temp'] = [
+ $wgCommentTableSchemaMigrationStage === MIGRATION_NEW ? 'JOIN' : 'LEFT JOIN',
+ [ 'imgcomment_name = img_name' ]
+ ];
+ }
+
+ if ( $wgCommentTableSchemaMigrationStage !== MIGRATION_OLD &&
+ $wgCommentTableSchemaMigrationStage !== MIGRATION_NEW
+ ) {
+ // Upgrade any rows that are still old-style. Otherwise an upgrade
+ // might be missed if a deletion happens while the migration script
+ // is running.
+ $res = $dbw->select(
+ [ 'image', 'image_comment_temp' ],
+ [ 'img_name', 'img_description' ],
+ [ 'img_name' => $this->file->getName(), 'imgcomment_name' => null ],
+ __METHOD__,
+ [],
+ [ 'image_comment_temp' => [ 'LEFT JOIN', [ 'imgcomment_name = img_name' ] ] ]
+ );
+ foreach ( $res as $row ) {
+ list( , $callback ) = $commentStoreImgDesc->insertWithTempTable( $dbw, $row->img_description );
+ $callback( $row->img_name );
+ }
+ }
+
+ $dbw->insertSelect( 'filearchive', $tables, $fields,
+ [ 'img_name' => $this->file->getName() ], __METHOD__, [], [], $joins );
+ }
+
+ if ( count( $oldRels ) ) {
+ $res = $dbw->select(
+ 'oldimage',
+ OldLocalFile::selectFields(),
+ [
+ 'oi_name' => $this->file->getName(),
+ 'oi_archive_name' => array_keys( $oldRels )
+ ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ $rowsInsert = [];
+ if ( $res->numRows() ) {
+ $reason = $commentStoreFaReason->createComment( $dbw, $this->reason );
+ foreach ( $res as $row ) {
+ // Legacy from OldLocalFile::selectFields() just above
+ $comment = $commentStoreOiDesc->getCommentLegacy( $dbw, $row );
+ $rowsInsert[] = [
+ // Deletion-specific fields
+ 'fa_storage_group' => 'deleted',
+ 'fa_storage_key' => ( $row->oi_sha1 === '' )
+ ? ''
+ : "{$row->oi_sha1}{$dotExt}",
+ 'fa_deleted_user' => $this->user->getId(),
+ 'fa_deleted_timestamp' => $dbw->timestamp( $now ),
+ // Counterpart fields
+ 'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted,
+ 'fa_name' => $row->oi_name,
+ 'fa_archive_name' => $row->oi_archive_name,
+ 'fa_size' => $row->oi_size,
+ 'fa_width' => $row->oi_width,
+ 'fa_height' => $row->oi_height,
+ 'fa_metadata' => $row->oi_metadata,
+ 'fa_bits' => $row->oi_bits,
+ 'fa_media_type' => $row->oi_media_type,
+ 'fa_major_mime' => $row->oi_major_mime,
+ 'fa_minor_mime' => $row->oi_minor_mime,
+ 'fa_user' => $row->oi_user,
+ 'fa_user_text' => $row->oi_user_text,
+ 'fa_timestamp' => $row->oi_timestamp,
+ 'fa_sha1' => $row->oi_sha1
+ ] + $commentStoreFaReason->insert( $dbw, $reason )
+ + $commentStoreFaDesc->insert( $dbw, $comment );
+ }
+ }
+
+ $dbw->insert( 'filearchive', $rowsInsert, __METHOD__ );
+ }
+ }
+
+ function doDBDeletes() {
+ global $wgCommentTableSchemaMigrationStage;
+
+ $dbw = $this->file->repo->getMasterDB();
+ list( $oldRels, $deleteCurrent ) = $this->getOldRels();
+
+ if ( count( $oldRels ) ) {
+ $dbw->delete( 'oldimage',
+ [
+ 'oi_name' => $this->file->getName(),
+ 'oi_archive_name' => array_keys( $oldRels )
+ ], __METHOD__ );
+ }
+
+ if ( $deleteCurrent ) {
+ $dbw->delete( 'image', [ 'img_name' => $this->file->getName() ], __METHOD__ );
+ if ( $wgCommentTableSchemaMigrationStage > MIGRATION_OLD ) {
+ $dbw->delete(
+ 'image_comment_temp', [ 'imgcomment_name' => $this->file->getName() ], __METHOD__
+ );
+ }
+ }
+ }
+
+ /**
+ * Run the transaction
+ * @return Status
+ */
+ public function execute() {
+ $repo = $this->file->getRepo();
+ $this->file->lock();
+
+ // Prepare deletion batch
+ $hashes = $this->getHashes();
+ $this->deletionBatch = [];
+ $ext = $this->file->getExtension();
+ $dotExt = $ext === '' ? '' : ".$ext";
+
+ foreach ( $this->srcRels as $name => $srcRel ) {
+ // Skip files that have no hash (e.g. missing DB record, or sha1 field and file source)
+ if ( isset( $hashes[$name] ) ) {
+ $hash = $hashes[$name];
+ $key = $hash . $dotExt;
+ $dstRel = $repo->getDeletedHashPath( $key ) . $key;
+ $this->deletionBatch[$name] = [ $srcRel, $dstRel ];
+ }
+ }
+
+ if ( !$repo->hasSha1Storage() ) {
+ // Removes non-existent file from the batch, so we don't get errors.
+ // This also handles files in the 'deleted' zone deleted via revision deletion.
+ $checkStatus = $this->removeNonexistentFiles( $this->deletionBatch );
+ if ( !$checkStatus->isGood() ) {
+ $this->status->merge( $checkStatus );
+ return $this->status;
+ }
+ $this->deletionBatch = $checkStatus->value;
+
+ // Execute the file deletion batch
+ $status = $this->file->repo->deleteBatch( $this->deletionBatch );
+ if ( !$status->isGood() ) {
+ $this->status->merge( $status );
+ }
+ }
+
+ if ( !$this->status->isOK() ) {
+ // Critical file deletion error; abort
+ $this->file->unlock();
+
+ return $this->status;
+ }
+
+ // Copy the image/oldimage rows to filearchive
+ $this->doDBInserts();
+ // Delete image/oldimage rows
+ $this->doDBDeletes();
+
+ // Commit and return
+ $this->file->unlock();
+
+ return $this->status;
+ }
+
+ /**
+ * Removes non-existent files from a deletion batch.
+ * @param array $batch
+ * @return Status
+ */
+ protected function removeNonexistentFiles( $batch ) {
+ $files = $newBatch = [];
+
+ foreach ( $batch as $batchItem ) {
+ list( $src, ) = $batchItem;
+ $files[$src] = $this->file->repo->getVirtualUrl( 'public' ) . '/' . rawurlencode( $src );
+ }
+
+ $result = $this->file->repo->fileExistsBatch( $files );
+ if ( in_array( null, $result, true ) ) {
+ return Status::newFatal( 'backend-fail-internal',
+ $this->file->repo->getBackend()->getName() );
+ }
+
+ foreach ( $batch as $batchItem ) {
+ if ( $result[$batchItem[0]] ) {
+ $newBatch[] = $batchItem;
+ }
+ }
+
+ return Status::newGood( $newBatch );
+ }
+}
+
+# ------------------------------------------------------------------------------
+
+/**
+ * Helper class for file undeletion
+ * @ingroup FileAbstraction
+ */
+class LocalFileRestoreBatch {
+ /** @var LocalFile */
+ private $file;
+
+ /** @var array List of file IDs to restore */
+ private $cleanupBatch;
+
+ /** @var array List of file IDs to restore */
+ private $ids;
+
+ /** @var bool Add all revisions of the file */
+ private $all;
+
+ /** @var bool Whether to remove all settings for suppressed fields */
+ private $unsuppress = false;
+
+ /**
+ * @param File $file
+ * @param bool $unsuppress
+ */
+ function __construct( File $file, $unsuppress = false ) {
+ $this->file = $file;
+ $this->cleanupBatch = $this->ids = [];
+ $this->ids = [];
+ $this->unsuppress = $unsuppress;
+ }
+
+ /**
+ * Add a file by ID
+ * @param int $fa_id
+ */
+ public function addId( $fa_id ) {
+ $this->ids[] = $fa_id;
+ }
+
+ /**
+ * Add a whole lot of files by ID
+ * @param int[] $ids
+ */
+ public function addIds( $ids ) {
+ $this->ids = array_merge( $this->ids, $ids );
+ }
+
+ /**
+ * Add all revisions of the file
+ */
+ public function addAll() {
+ $this->all = true;
+ }
+
+ /**
+ * Run the transaction, except the cleanup batch.
+ * The cleanup batch should be run in a separate transaction, because it locks different
+ * rows and there's no need to keep the image row locked while it's acquiring those locks
+ * The caller may have its own transaction open.
+ * So we save the batch and let the caller call cleanup()
+ * @return Status
+ */
+ public function execute() {
+ /** @var Language */
+ global $wgLang;
+
+ $repo = $this->file->getRepo();
+ if ( !$this->all && !$this->ids ) {
+ // Do nothing
+ return $repo->newGood();
+ }
+
+ $lockOwnsTrx = $this->file->lock();
+
+ $dbw = $this->file->repo->getMasterDB();
+
+ $commentStoreImgDesc = new CommentStore( 'img_description' );
+ $commentStoreOiDesc = new CommentStore( 'oi_description' );
+ $commentStoreFaDesc = new CommentStore( 'fa_description' );
+
+ $status = $this->file->repo->newGood();
+
+ $exists = (bool)$dbw->selectField( 'image', '1',
+ [ 'img_name' => $this->file->getName() ],
+ __METHOD__,
+ // The lock() should already prevents changes, but this still may need
+ // to bypass any transaction snapshot. However, if lock() started the
+ // trx (which it probably did) then snapshot is post-lock and up-to-date.
+ $lockOwnsTrx ? [] : [ 'LOCK IN SHARE MODE' ]
+ );
+
+ // Fetch all or selected archived revisions for the file,
+ // sorted from the most recent to the oldest.
+ $conditions = [ 'fa_name' => $this->file->getName() ];
+
+ if ( !$this->all ) {
+ $conditions['fa_id'] = $this->ids;
+ }
+
+ $result = $dbw->select(
+ 'filearchive',
+ ArchivedFile::selectFields(),
+ $conditions,
+ __METHOD__,
+ [ 'ORDER BY' => 'fa_timestamp DESC' ]
+ );
+
+ $idsPresent = [];
+ $storeBatch = [];
+ $insertBatch = [];
+ $insertCurrent = false;
+ $deleteIds = [];
+ $first = true;
+ $archiveNames = [];
+
+ foreach ( $result as $row ) {
+ $idsPresent[] = $row->fa_id;
+
+ if ( $row->fa_name != $this->file->getName() ) {
+ $status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp ) );
+ $status->failCount++;
+ continue;
+ }
+
+ if ( $row->fa_storage_key == '' ) {
+ // Revision was missing pre-deletion
+ $status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp ) );
+ $status->failCount++;
+ continue;
+ }
+
+ $deletedRel = $repo->getDeletedHashPath( $row->fa_storage_key ) .
+ $row->fa_storage_key;
+ $deletedUrl = $repo->getVirtualUrl() . '/deleted/' . $deletedRel;
+
+ if ( isset( $row->fa_sha1 ) ) {
+ $sha1 = $row->fa_sha1;
+ } else {
+ // old row, populate from key
+ $sha1 = LocalRepo::getHashFromKey( $row->fa_storage_key );
+ }
+
+ # Fix leading zero
+ if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) {
+ $sha1 = substr( $sha1, 1 );
+ }
+
+ if ( is_null( $row->fa_major_mime ) || $row->fa_major_mime == 'unknown'
+ || is_null( $row->fa_minor_mime ) || $row->fa_minor_mime == 'unknown'
+ || is_null( $row->fa_media_type ) || $row->fa_media_type == 'UNKNOWN'
+ || is_null( $row->fa_metadata )
+ ) {
+ // Refresh our metadata
+ // Required for a new current revision; nice for older ones too. :)
+ $props = RepoGroup::singleton()->getFileProps( $deletedUrl );
+ } else {
+ $props = [
+ 'minor_mime' => $row->fa_minor_mime,
+ 'major_mime' => $row->fa_major_mime,
+ 'media_type' => $row->fa_media_type,
+ 'metadata' => $row->fa_metadata
+ ];
+ }
+
+ // Legacy from ArchivedFile::selectFields() just above
+ $comment = $commentStoreFaDesc->getCommentLegacy( $dbw, $row );
+ if ( $first && !$exists ) {
+ // This revision will be published as the new current version
+ $destRel = $this->file->getRel();
+ list( $commentFields, $commentCallback ) =
+ $commentStoreImgDesc->insertWithTempTable( $dbw, $comment );
+ $insertCurrent = [
+ 'img_name' => $row->fa_name,
+ 'img_size' => $row->fa_size,
+ 'img_width' => $row->fa_width,
+ 'img_height' => $row->fa_height,
+ 'img_metadata' => $props['metadata'],
+ 'img_bits' => $row->fa_bits,
+ 'img_media_type' => $props['media_type'],
+ 'img_major_mime' => $props['major_mime'],
+ 'img_minor_mime' => $props['minor_mime'],
+ 'img_user' => $row->fa_user,
+ 'img_user_text' => $row->fa_user_text,
+ 'img_timestamp' => $row->fa_timestamp,
+ 'img_sha1' => $sha1
+ ] + $commentFields;
+
+ // The live (current) version cannot be hidden!
+ if ( !$this->unsuppress && $row->fa_deleted ) {
+ $status->fatal( 'undeleterevdel' );
+ $this->file->unlock();
+ return $status;
+ }
+ } else {
+ $archiveName = $row->fa_archive_name;
+
+ if ( $archiveName == '' ) {
+ // This was originally a current version; we
+ // have to devise a new archive name for it.
+ // Format is <timestamp of archiving>!<name>
+ $timestamp = wfTimestamp( TS_UNIX, $row->fa_deleted_timestamp );
+
+ do {
+ $archiveName = wfTimestamp( TS_MW, $timestamp ) . '!' . $row->fa_name;
+ $timestamp++;
+ } while ( isset( $archiveNames[$archiveName] ) );
+ }
+
+ $archiveNames[$archiveName] = true;
+ $destRel = $this->file->getArchiveRel( $archiveName );
+ $insertBatch[] = [
+ 'oi_name' => $row->fa_name,
+ 'oi_archive_name' => $archiveName,
+ 'oi_size' => $row->fa_size,
+ 'oi_width' => $row->fa_width,
+ 'oi_height' => $row->fa_height,
+ 'oi_bits' => $row->fa_bits,
+ 'oi_user' => $row->fa_user,
+ 'oi_user_text' => $row->fa_user_text,
+ 'oi_timestamp' => $row->fa_timestamp,
+ 'oi_metadata' => $props['metadata'],
+ 'oi_media_type' => $props['media_type'],
+ 'oi_major_mime' => $props['major_mime'],
+ 'oi_minor_mime' => $props['minor_mime'],
+ 'oi_deleted' => $this->unsuppress ? 0 : $row->fa_deleted,
+ 'oi_sha1' => $sha1
+ ] + $commentStoreOiDesc->insert( $dbw, $comment );
+ }
+
+ $deleteIds[] = $row->fa_id;
+
+ if ( !$this->unsuppress && $row->fa_deleted & File::DELETED_FILE ) {
+ // private files can stay where they are
+ $status->successCount++;
+ } else {
+ $storeBatch[] = [ $deletedUrl, 'public', $destRel ];
+ $this->cleanupBatch[] = $row->fa_storage_key;
+ }
+
+ $first = false;
+ }
+
+ unset( $result );
+
+ // Add a warning to the status object for missing IDs
+ $missingIds = array_diff( $this->ids, $idsPresent );
+
+ foreach ( $missingIds as $id ) {
+ $status->error( 'undelete-missing-filearchive', $id );
+ }
+
+ if ( !$repo->hasSha1Storage() ) {
+ // Remove missing files from batch, so we don't get errors when undeleting them
+ $checkStatus = $this->removeNonexistentFiles( $storeBatch );
+ if ( !$checkStatus->isGood() ) {
+ $status->merge( $checkStatus );
+ return $status;
+ }
+ $storeBatch = $checkStatus->value;
+
+ // Run the store batch
+ // Use the OVERWRITE_SAME flag to smooth over a common error
+ $storeStatus = $this->file->repo->storeBatch( $storeBatch, FileRepo::OVERWRITE_SAME );
+ $status->merge( $storeStatus );
+
+ if ( !$status->isGood() ) {
+ // Even if some files could be copied, fail entirely as that is the
+ // easiest thing to do without data loss
+ $this->cleanupFailedBatch( $storeStatus, $storeBatch );
+ $status->setOK( false );
+ $this->file->unlock();
+
+ return $status;
+ }
+ }
+
+ // Run the DB updates
+ // Because we have locked the image row, key conflicts should be rare.
+ // If they do occur, we can roll back the transaction at this time with
+ // no data loss, but leaving unregistered files scattered throughout the
+ // public zone.
+ // This is not ideal, which is why it's important to lock the image row.
+ if ( $insertCurrent ) {
+ $dbw->insert( 'image', $insertCurrent, __METHOD__ );
+ $commentCallback( $insertCurrent['img_name'] );
+ }
+
+ if ( $insertBatch ) {
+ $dbw->insert( 'oldimage', $insertBatch, __METHOD__ );
+ }
+
+ if ( $deleteIds ) {
+ $dbw->delete( 'filearchive',
+ [ 'fa_id' => $deleteIds ],
+ __METHOD__ );
+ }
+
+ // If store batch is empty (all files are missing), deletion is to be considered successful
+ if ( $status->successCount > 0 || !$storeBatch || $repo->hasSha1Storage() ) {
+ if ( !$exists ) {
+ wfDebug( __METHOD__ . " restored {$status->successCount} items, creating a new current\n" );
+
+ DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => 1 ] ) );
+
+ $this->file->purgeEverything();
+ } else {
+ wfDebug( __METHOD__ . " restored {$status->successCount} as archived versions\n" );
+ $this->file->purgeDescription();
+ }
+ }
+
+ $this->file->unlock();
+
+ return $status;
+ }
+
+ /**
+ * Removes non-existent files from a store batch.
+ * @param array $triplets
+ * @return Status
+ */
+ protected function removeNonexistentFiles( $triplets ) {
+ $files = $filteredTriplets = [];
+ foreach ( $triplets as $file ) {
+ $files[$file[0]] = $file[0];
+ }
+
+ $result = $this->file->repo->fileExistsBatch( $files );
+ if ( in_array( null, $result, true ) ) {
+ return Status::newFatal( 'backend-fail-internal',
+ $this->file->repo->getBackend()->getName() );
+ }
+
+ foreach ( $triplets as $file ) {
+ if ( $result[$file[0]] ) {
+ $filteredTriplets[] = $file;
+ }
+ }
+
+ return Status::newGood( $filteredTriplets );
+ }
+
+ /**
+ * Removes non-existent files from a cleanup batch.
+ * @param array $batch
+ * @return array
+ */
+ protected function removeNonexistentFromCleanup( $batch ) {
+ $files = $newBatch = [];
+ $repo = $this->file->repo;
+
+ foreach ( $batch as $file ) {
+ $files[$file] = $repo->getVirtualUrl( 'deleted' ) . '/' .
+ rawurlencode( $repo->getDeletedHashPath( $file ) . $file );
+ }
+
+ $result = $repo->fileExistsBatch( $files );
+
+ foreach ( $batch as $file ) {
+ if ( $result[$file] ) {
+ $newBatch[] = $file;
+ }
+ }
+
+ return $newBatch;
+ }
+
+ /**
+ * Delete unused files in the deleted zone.
+ * This should be called from outside the transaction in which execute() was called.
+ * @return Status
+ */
+ public function cleanup() {
+ if ( !$this->cleanupBatch ) {
+ return $this->file->repo->newGood();
+ }
+
+ $this->cleanupBatch = $this->removeNonexistentFromCleanup( $this->cleanupBatch );
+
+ $status = $this->file->repo->cleanupDeletedBatch( $this->cleanupBatch );
+
+ return $status;
+ }
+
+ /**
+ * Cleanup a failed batch. The batch was only partially successful, so
+ * rollback by removing all items that were succesfully copied.
+ *
+ * @param Status $storeStatus
+ * @param array $storeBatch
+ */
+ protected function cleanupFailedBatch( $storeStatus, $storeBatch ) {
+ $cleanupBatch = [];
+
+ foreach ( $storeStatus->success as $i => $success ) {
+ // Check if this item of the batch was successfully copied
+ if ( $success ) {
+ // Item was successfully copied and needs to be removed again
+ // Extract ($dstZone, $dstRel) from the batch
+ $cleanupBatch[] = [ $storeBatch[$i][1], $storeBatch[$i][2] ];
+ }
+ }
+ $this->file->repo->cleanupBatch( $cleanupBatch );
+ }
+}
+
+# ------------------------------------------------------------------------------
+
+/**
+ * Helper class for file movement
+ * @ingroup FileAbstraction
+ */
+class LocalFileMoveBatch {
+ /** @var LocalFile */
+ protected $file;
+
+ /** @var Title */
+ protected $target;
+
+ protected $cur;
+
+ protected $olds;
+
+ protected $oldCount;
+
+ protected $archive;
+
+ /** @var IDatabase */
+ protected $db;
+
+ /**
+ * @param File $file
+ * @param Title $target
+ */
+ function __construct( File $file, Title $target ) {
+ $this->file = $file;
+ $this->target = $target;
+ $this->oldHash = $this->file->repo->getHashPath( $this->file->getName() );
+ $this->newHash = $this->file->repo->getHashPath( $this->target->getDBkey() );
+ $this->oldName = $this->file->getName();
+ $this->newName = $this->file->repo->getNameFromTitle( $this->target );
+ $this->oldRel = $this->oldHash . $this->oldName;
+ $this->newRel = $this->newHash . $this->newName;
+ $this->db = $file->getRepo()->getMasterDB();
+ }
+
+ /**
+ * Add the current image to the batch
+ */
+ public function addCurrent() {
+ $this->cur = [ $this->oldRel, $this->newRel ];
+ }
+
+ /**
+ * Add the old versions of the image to the batch
+ * @return array List of archive names from old versions
+ */
+ public function addOlds() {
+ $archiveBase = 'archive';
+ $this->olds = [];
+ $this->oldCount = 0;
+ $archiveNames = [];
+
+ $result = $this->db->select( 'oldimage',
+ [ 'oi_archive_name', 'oi_deleted' ],
+ [ 'oi_name' => $this->oldName ],
+ __METHOD__,
+ [ 'LOCK IN SHARE MODE' ] // ignore snapshot
+ );
+
+ foreach ( $result as $row ) {
+ $archiveNames[] = $row->oi_archive_name;
+ $oldName = $row->oi_archive_name;
+ $bits = explode( '!', $oldName, 2 );
+
+ if ( count( $bits ) != 2 ) {
+ wfDebug( "Old file name missing !: '$oldName' \n" );
+ continue;
+ }
+
+ list( $timestamp, $filename ) = $bits;
+
+ if ( $this->oldName != $filename ) {
+ wfDebug( "Old file name doesn't match: '$oldName' \n" );
+ continue;
+ }
+
+ $this->oldCount++;
+
+ // Do we want to add those to oldCount?
+ if ( $row->oi_deleted & File::DELETED_FILE ) {
+ continue;
+ }
+
+ $this->olds[] = [
+ "{$archiveBase}/{$this->oldHash}{$oldName}",
+ "{$archiveBase}/{$this->newHash}{$timestamp}!{$this->newName}"
+ ];
+ }
+
+ return $archiveNames;
+ }
+
+ /**
+ * Perform the move.
+ * @return Status
+ */
+ public function execute() {
+ $repo = $this->file->repo;
+ $status = $repo->newGood();
+ $destFile = wfLocalFile( $this->target );
+
+ $this->file->lock(); // begin
+ $destFile->lock(); // quickly fail if destination is not available
+
+ $triplets = $this->getMoveTriplets();
+ $checkStatus = $this->removeNonexistentFiles( $triplets );
+ if ( !$checkStatus->isGood() ) {
+ $destFile->unlock();
+ $this->file->unlock();
+ $status->merge( $checkStatus ); // couldn't talk to file backend
+ return $status;
+ }
+ $triplets = $checkStatus->value;
+
+ // Verify the file versions metadata in the DB.
+ $statusDb = $this->verifyDBUpdates();
+ if ( !$statusDb->isGood() ) {
+ $destFile->unlock();
+ $this->file->unlock();
+ $statusDb->setOK( false );
+
+ return $statusDb;
+ }
+
+ if ( !$repo->hasSha1Storage() ) {
+ // Copy the files into their new location.
+ // If a prior process fataled copying or cleaning up files we tolerate any
+ // of the existing files if they are identical to the ones being stored.
+ $statusMove = $repo->storeBatch( $triplets, FileRepo::OVERWRITE_SAME );
+ wfDebugLog( 'imagemove', "Moved files for {$this->file->getName()}: " .
+ "{$statusMove->successCount} successes, {$statusMove->failCount} failures" );
+ if ( !$statusMove->isGood() ) {
+ // Delete any files copied over (while the destination is still locked)
+ $this->cleanupTarget( $triplets );
+ $destFile->unlock();
+ $this->file->unlock();
+ wfDebugLog( 'imagemove', "Error in moving files: "
+ . $statusMove->getWikiText( false, false, 'en' ) );
+ $statusMove->setOK( false );
+
+ return $statusMove;
+ }
+ $status->merge( $statusMove );
+ }
+
+ // Rename the file versions metadata in the DB.
+ $this->doDBUpdates();
+
+ wfDebugLog( 'imagemove', "Renamed {$this->file->getName()} in database: " .
+ "{$statusDb->successCount} successes, {$statusDb->failCount} failures" );
+
+ $destFile->unlock();
+ $this->file->unlock(); // done
+
+ // Everything went ok, remove the source files
+ $this->cleanupSource( $triplets );
+
+ $status->merge( $statusDb );
+
+ return $status;
+ }
+
+ /**
+ * Verify the database updates and return a new Status indicating how
+ * many rows would be updated.
+ *
+ * @return Status
+ */
+ protected function verifyDBUpdates() {
+ $repo = $this->file->repo;
+ $status = $repo->newGood();
+ $dbw = $this->db;
+
+ $hasCurrent = $dbw->selectField(
+ 'image',
+ '1',
+ [ 'img_name' => $this->oldName ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ $oldRowCount = $dbw->selectField(
+ 'oldimage',
+ 'COUNT(*)',
+ [ 'oi_name' => $this->oldName ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+
+ if ( $hasCurrent ) {
+ $status->successCount++;
+ } else {
+ $status->failCount++;
+ }
+ $status->successCount += $oldRowCount;
+ // T36934: oldCount is based on files that actually exist.
+ // There may be more DB rows than such files, in which case $affected
+ // can be greater than $total. We use max() to avoid negatives here.
+ $status->failCount += max( 0, $this->oldCount - $oldRowCount );
+ if ( $status->failCount ) {
+ $status->error( 'imageinvalidfilename' );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Do the database updates and return a new Status indicating how
+ * many rows where updated.
+ */
+ protected function doDBUpdates() {
+ $dbw = $this->db;
+
+ // Update current image
+ $dbw->update(
+ 'image',
+ [ 'img_name' => $this->newName ],
+ [ 'img_name' => $this->oldName ],
+ __METHOD__
+ );
+ // Update old images
+ $dbw->update(
+ 'oldimage',
+ [
+ 'oi_name' => $this->newName,
+ 'oi_archive_name = ' . $dbw->strreplace( 'oi_archive_name',
+ $dbw->addQuotes( $this->oldName ), $dbw->addQuotes( $this->newName ) ),
+ ],
+ [ 'oi_name' => $this->oldName ],
+ __METHOD__
+ );
+ }
+
+ /**
+ * Generate triplets for FileRepo::storeBatch().
+ * @return array
+ */
+ protected function getMoveTriplets() {
+ $moves = array_merge( [ $this->cur ], $this->olds );
+ $triplets = []; // The format is: (srcUrl, destZone, destUrl)
+
+ foreach ( $moves as $move ) {
+ // $move: (oldRelativePath, newRelativePath)
+ $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
+ $triplets[] = [ $srcUrl, 'public', $move[1] ];
+ wfDebugLog(
+ 'imagemove',
+ "Generated move triplet for {$this->file->getName()}: {$srcUrl} :: public :: {$move[1]}"
+ );
+ }
+
+ return $triplets;
+ }
+
+ /**
+ * Removes non-existent files from move batch.
+ * @param array $triplets
+ * @return Status
+ */
+ protected function removeNonexistentFiles( $triplets ) {
+ $files = [];
+
+ foreach ( $triplets as $file ) {
+ $files[$file[0]] = $file[0];
+ }
+
+ $result = $this->file->repo->fileExistsBatch( $files );
+ if ( in_array( null, $result, true ) ) {
+ return Status::newFatal( 'backend-fail-internal',
+ $this->file->repo->getBackend()->getName() );
+ }
+
+ $filteredTriplets = [];
+ foreach ( $triplets as $file ) {
+ if ( $result[$file[0]] ) {
+ $filteredTriplets[] = $file;
+ } else {
+ wfDebugLog( 'imagemove', "File {$file[0]} does not exist" );
+ }
+ }
+
+ return Status::newGood( $filteredTriplets );
+ }
+
+ /**
+ * Cleanup a partially moved array of triplets by deleting the target
+ * files. Called if something went wrong half way.
+ * @param array $triplets
+ */
+ protected function cleanupTarget( $triplets ) {
+ // Create dest pairs from the triplets
+ $pairs = [];
+ foreach ( $triplets as $triplet ) {
+ // $triplet: (old source virtual URL, dst zone, dest rel)
+ $pairs[] = [ $triplet[1], $triplet[2] ];
+ }
+
+ $this->file->repo->cleanupBatch( $pairs );
+ }
+
+ /**
+ * Cleanup a fully moved array of triplets by deleting the source files.
+ * Called at the end of the move process if everything else went ok.
+ * @param array $triplets
+ */
+ protected function cleanupSource( $triplets ) {
+ // Create source file names from the triplets
+ $files = [];
+ foreach ( $triplets as $triplet ) {
+ $files[] = $triplet[0];
+ }
+
+ $this->file->repo->cleanupBatch( $files );
+ }
+}
+
+class LocalFileLockError extends ErrorPageError {
+ public function __construct( Status $status ) {
+ parent::__construct(
+ 'actionfailed',
+ $status->getMessage()
+ );
+ }
+
+ public function report() {
+ global $wgOut;
+ $wgOut->setStatusCode( 429 );
+ parent::report();
+ }
+}
diff --git a/www/wiki/includes/filerepo/file/OldLocalFile.php b/www/wiki/includes/filerepo/file/OldLocalFile.php
new file mode 100644
index 00000000..ee172e11
--- /dev/null
+++ b/www/wiki/includes/filerepo/file/OldLocalFile.php
@@ -0,0 +1,408 @@
+<?php
+/**
+ * Old file in the oldimage table.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileAbstraction
+ */
+
+/**
+ * Class to represent a file in the oldimage table
+ *
+ * @ingroup FileAbstraction
+ */
+class OldLocalFile extends LocalFile {
+ /** @var string Timestamp */
+ protected $requestedTime;
+
+ /** @var string Archive name */
+ protected $archive_name;
+
+ const CACHE_VERSION = 1;
+ const MAX_CACHE_ROWS = 20;
+
+ /**
+ * @param Title $title
+ * @param FileRepo $repo
+ * @param null|int $time Timestamp or null
+ * @return OldLocalFile
+ * @throws MWException
+ */
+ static function newFromTitle( $title, $repo, $time = null ) {
+ # The null default value is only here to avoid an E_STRICT
+ if ( $time === null ) {
+ throw new MWException( __METHOD__ . ' got null for $time parameter' );
+ }
+
+ return new self( $title, $repo, $time, null );
+ }
+
+ /**
+ * @param Title $title
+ * @param FileRepo $repo
+ * @param string $archiveName
+ * @return OldLocalFile
+ */
+ static function newFromArchiveName( $title, $repo, $archiveName ) {
+ return new self( $title, $repo, null, $archiveName );
+ }
+
+ /**
+ * @param stdClass $row
+ * @param FileRepo $repo
+ * @return OldLocalFile
+ */
+ static function newFromRow( $row, $repo ) {
+ $title = Title::makeTitle( NS_FILE, $row->oi_name );
+ $file = new self( $title, $repo, null, $row->oi_archive_name );
+ $file->loadFromRow( $row, 'oi_' );
+
+ return $file;
+ }
+
+ /**
+ * Create a OldLocalFile from a SHA-1 key
+ * Do not call this except from inside a repo class.
+ *
+ * @param string $sha1 Base-36 SHA-1
+ * @param LocalRepo $repo
+ * @param string|bool $timestamp MW_timestamp (optional)
+ *
+ * @return bool|OldLocalFile
+ */
+ static function newFromKey( $sha1, $repo, $timestamp = false ) {
+ $dbr = $repo->getReplicaDB();
+
+ $conds = [ 'oi_sha1' => $sha1 ];
+ if ( $timestamp ) {
+ $conds['oi_timestamp'] = $dbr->timestamp( $timestamp );
+ }
+
+ $row = $dbr->selectRow( 'oldimage', self::selectFields(), $conds, __METHOD__ );
+ if ( $row ) {
+ return self::newFromRow( $row, $repo );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Fields in the oldimage table
+ * @todo Deprecate this in favor of a method that returns tables and joins
+ * as well, and use CommentStore::getJoin().
+ * @return array
+ */
+ static function selectFields() {
+ return [
+ 'oi_name',
+ 'oi_archive_name',
+ 'oi_size',
+ 'oi_width',
+ 'oi_height',
+ 'oi_metadata',
+ 'oi_bits',
+ 'oi_media_type',
+ 'oi_major_mime',
+ 'oi_minor_mime',
+ 'oi_user',
+ 'oi_user_text',
+ 'oi_timestamp',
+ 'oi_deleted',
+ 'oi_sha1',
+ ] + CommentStore::newKey( 'oi_description' )->getFields();
+ }
+
+ /**
+ * @param Title $title
+ * @param FileRepo $repo
+ * @param string $time Timestamp or null to load by archive name
+ * @param string $archiveName Archive name or null to load by timestamp
+ * @throws MWException
+ */
+ function __construct( $title, $repo, $time, $archiveName ) {
+ parent::__construct( $title, $repo );
+ $this->requestedTime = $time;
+ $this->archive_name = $archiveName;
+ if ( is_null( $time ) && is_null( $archiveName ) ) {
+ throw new MWException( __METHOD__ . ': must specify at least one of $time or $archiveName' );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ function getCacheKey() {
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ function getArchiveName() {
+ if ( !isset( $this->archive_name ) ) {
+ $this->load();
+ }
+
+ return $this->archive_name;
+ }
+
+ /**
+ * @return bool
+ */
+ function isOld() {
+ return true;
+ }
+
+ /**
+ * @return bool
+ */
+ function isVisible() {
+ return $this->exists() && !$this->isDeleted( File::DELETED_FILE );
+ }
+
+ function loadFromDB( $flags = 0 ) {
+ $this->dataLoaded = true;
+
+ $dbr = ( $flags & self::READ_LATEST )
+ ? $this->repo->getMasterDB()
+ : $this->repo->getReplicaDB();
+
+ $conds = [ 'oi_name' => $this->getName() ];
+ if ( is_null( $this->requestedTime ) ) {
+ $conds['oi_archive_name'] = $this->archive_name;
+ } else {
+ $conds['oi_timestamp'] = $dbr->timestamp( $this->requestedTime );
+ }
+ $row = $dbr->selectRow( 'oldimage', $this->getCacheFields( 'oi_' ),
+ $conds, __METHOD__, [ 'ORDER BY' => 'oi_timestamp DESC' ] );
+ if ( $row ) {
+ $this->loadFromRow( $row, 'oi_' );
+ } else {
+ $this->fileExists = false;
+ }
+ }
+
+ /**
+ * Load lazy file metadata from the DB
+ */
+ protected function loadExtraFromDB() {
+ $this->extraDataLoaded = true;
+ $dbr = $this->repo->getReplicaDB();
+ $conds = [ 'oi_name' => $this->getName() ];
+ if ( is_null( $this->requestedTime ) ) {
+ $conds['oi_archive_name'] = $this->archive_name;
+ } else {
+ $conds['oi_timestamp'] = $dbr->timestamp( $this->requestedTime );
+ }
+ // In theory the file could have just been renamed/deleted...oh well
+ $row = $dbr->selectRow( 'oldimage', $this->getLazyCacheFields( 'oi_' ),
+ $conds, __METHOD__, [ 'ORDER BY' => 'oi_timestamp DESC' ] );
+
+ if ( !$row ) { // fallback to master
+ $dbr = $this->repo->getMasterDB();
+ $row = $dbr->selectRow( 'oldimage', $this->getLazyCacheFields( 'oi_' ),
+ $conds, __METHOD__, [ 'ORDER BY' => 'oi_timestamp DESC' ] );
+ }
+
+ if ( $row ) {
+ foreach ( $this->unprefixRow( $row, 'oi_' ) as $name => $value ) {
+ $this->$name = $value;
+ }
+ } else {
+ throw new MWException( "Could not find data for image '{$this->archive_name}'." );
+ }
+ }
+
+ /**
+ * @param string $prefix
+ * @return array
+ */
+ function getCacheFields( $prefix = 'img_' ) {
+ $fields = parent::getCacheFields( $prefix );
+ $fields[] = $prefix . 'archive_name';
+ $fields[] = $prefix . 'deleted';
+
+ return $fields;
+ }
+
+ /**
+ * @return string
+ */
+ function getRel() {
+ return 'archive/' . $this->getHashPath() . $this->getArchiveName();
+ }
+
+ /**
+ * @return string
+ */
+ function getUrlRel() {
+ return 'archive/' . $this->getHashPath() . rawurlencode( $this->getArchiveName() );
+ }
+
+ function upgradeRow() {
+ $this->loadFromFile();
+
+ # Don't destroy file info of missing files
+ if ( !$this->fileExists ) {
+ wfDebug( __METHOD__ . ": file does not exist, aborting\n" );
+
+ return;
+ }
+
+ $dbw = $this->repo->getMasterDB();
+ list( $major, $minor ) = self::splitMime( $this->mime );
+
+ wfDebug( __METHOD__ . ': upgrading ' . $this->archive_name . " to the current schema\n" );
+ $dbw->update( 'oldimage',
+ [
+ 'oi_size' => $this->size, // sanity
+ 'oi_width' => $this->width,
+ 'oi_height' => $this->height,
+ 'oi_bits' => $this->bits,
+ 'oi_media_type' => $this->media_type,
+ 'oi_major_mime' => $major,
+ 'oi_minor_mime' => $minor,
+ 'oi_metadata' => $this->metadata,
+ 'oi_sha1' => $this->sha1,
+ ], [
+ 'oi_name' => $this->getName(),
+ 'oi_archive_name' => $this->archive_name ],
+ __METHOD__
+ );
+ }
+
+ /**
+ * @param int $field One of DELETED_* bitfield constants for file or
+ * revision rows
+ * @return bool
+ */
+ function isDeleted( $field ) {
+ $this->load();
+
+ return ( $this->deleted & $field ) == $field;
+ }
+
+ /**
+ * Returns bitfield value
+ * @return int
+ */
+ function getVisibility() {
+ $this->load();
+
+ return (int)$this->deleted;
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this image file, if it's marked as deleted.
+ *
+ * @param int $field
+ * @param User|null $user User object to check, or null to use $wgUser
+ * @return bool
+ */
+ function userCan( $field, User $user = null ) {
+ $this->load();
+
+ return Revision::userCanBitfield( $this->deleted, $field, $user );
+ }
+
+ /**
+ * Upload a file directly into archive. Generally for Special:Import.
+ *
+ * @param string $srcPath File system path of the source file
+ * @param string $archiveName Full archive name of the file, in the form
+ * $timestamp!$filename, where $filename must match $this->getName()
+ * @param string $timestamp
+ * @param string $comment
+ * @param User $user
+ * @return Status
+ */
+ function uploadOld( $srcPath, $archiveName, $timestamp, $comment, $user ) {
+ $this->lock();
+
+ $dstRel = 'archive/' . $this->getHashPath() . $archiveName;
+ $status = $this->publishTo( $srcPath, $dstRel );
+
+ if ( $status->isGood() ) {
+ if ( !$this->recordOldUpload( $srcPath, $archiveName, $timestamp, $comment, $user ) ) {
+ $status->fatal( 'filenotfound', $srcPath );
+ }
+ }
+
+ $this->unlock();
+
+ return $status;
+ }
+
+ /**
+ * Record a file upload in the oldimage table, without adding log entries.
+ *
+ * @param string $srcPath File system path to the source file
+ * @param string $archiveName The archive name of the file
+ * @param string $timestamp
+ * @param string $comment Upload comment
+ * @param User $user User who did this upload
+ * @return bool
+ */
+ protected function recordOldUpload( $srcPath, $archiveName, $timestamp, $comment, $user ) {
+ $dbw = $this->repo->getMasterDB();
+
+ $dstPath = $this->repo->getZonePath( 'public' ) . '/' . $this->getRel();
+ $props = $this->repo->getFileProps( $dstPath );
+ if ( !$props['fileExists'] ) {
+ return false;
+ }
+
+ $commentFields = CommentStore::newKey( 'oi_description' )->insert( $dbw, $comment );
+ $dbw->insert( 'oldimage',
+ [
+ 'oi_name' => $this->getName(),
+ 'oi_archive_name' => $archiveName,
+ 'oi_size' => $props['size'],
+ 'oi_width' => intval( $props['width'] ),
+ 'oi_height' => intval( $props['height'] ),
+ 'oi_bits' => $props['bits'],
+ 'oi_timestamp' => $dbw->timestamp( $timestamp ),
+ 'oi_user' => $user->getId(),
+ 'oi_user_text' => $user->getName(),
+ 'oi_metadata' => $props['metadata'],
+ 'oi_media_type' => $props['media_type'],
+ 'oi_major_mime' => $props['major_mime'],
+ 'oi_minor_mime' => $props['minor_mime'],
+ 'oi_sha1' => $props['sha1'],
+ ] + $commentFields, __METHOD__
+ );
+
+ return true;
+ }
+
+ /**
+ * If archive name is an empty string, then file does not "exist"
+ *
+ * This is the case for a couple files on Wikimedia servers where
+ * the old version is "lost".
+ * @return bool
+ */
+ public function exists() {
+ $archiveName = $this->getArchiveName();
+ if ( $archiveName === '' || !is_string( $archiveName ) ) {
+ return false;
+ }
+ return parent::exists();
+ }
+}
diff --git a/www/wiki/includes/filerepo/file/UnregisteredLocalFile.php b/www/wiki/includes/filerepo/file/UnregisteredLocalFile.php
new file mode 100644
index 00000000..cdad5fce
--- /dev/null
+++ b/www/wiki/includes/filerepo/file/UnregisteredLocalFile.php
@@ -0,0 +1,232 @@
+<?php
+/**
+ * File without associated database record.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileAbstraction
+ */
+
+/**
+ * A file object referring to either a standalone local file, or a file in a
+ * local repository with no database, for example an FileRepo repository.
+ *
+ * Read-only.
+ *
+ * @todo Currently it doesn't really work in the repository role, there are
+ * lots of functions missing. It is used by the WebStore extension in the
+ * standalone role.
+ *
+ * @ingroup FileAbstraction
+ */
+class UnregisteredLocalFile extends File {
+ /** @var Title */
+ protected $title;
+
+ /** @var string */
+ protected $path;
+
+ /** @var bool|string */
+ protected $mime;
+
+ /** @var array Dimension data */
+ protected $dims;
+
+ /** @var bool|string Handler-specific metadata which will be saved in the img_metadata field */
+ protected $metadata;
+
+ /** @var MediaHandler */
+ public $handler;
+
+ /**
+ * @param string $path Storage path
+ * @param string $mime
+ * @return UnregisteredLocalFile
+ */
+ static function newFromPath( $path, $mime ) {
+ return new self( false, false, $path, $mime );
+ }
+
+ /**
+ * @param Title $title
+ * @param FileRepo $repo
+ * @return UnregisteredLocalFile
+ */
+ static function newFromTitle( $title, $repo ) {
+ return new self( $title, $repo, false, false );
+ }
+
+ /**
+ * Create an UnregisteredLocalFile based on a path or a (title,repo) pair.
+ * A FileRepo object is not required here, unlike most other File classes.
+ *
+ * @throws MWException
+ * @param Title|bool $title
+ * @param FileRepo|bool $repo
+ * @param string|bool $path
+ * @param string|bool $mime
+ */
+ function __construct( $title = false, $repo = false, $path = false, $mime = false ) {
+ if ( !( $title && $repo ) && !$path ) {
+ throw new MWException( __METHOD__ .
+ ': not enough parameters, must specify title and repo, or a full path' );
+ }
+ if ( $title instanceof Title ) {
+ $this->title = File::normalizeTitle( $title, 'exception' );
+ $this->name = $repo->getNameFromTitle( $title );
+ } else {
+ $this->name = basename( $path );
+ $this->title = File::normalizeTitle( $this->name, 'exception' );
+ }
+ $this->repo = $repo;
+ if ( $path ) {
+ $this->path = $path;
+ } else {
+ $this->assertRepoDefined();
+ $this->path = $repo->getRootDirectory() . '/' .
+ $repo->getHashPath( $this->name ) . $this->name;
+ }
+ if ( $mime ) {
+ $this->mime = $mime;
+ }
+ $this->dims = [];
+ }
+
+ /**
+ * @param int $page
+ * @return bool
+ */
+ private function cachePageDimensions( $page = 1 ) {
+ $page = (int)$page;
+ if ( $page < 1 ) {
+ $page = 1;
+ }
+
+ if ( !isset( $this->dims[$page] ) ) {
+ if ( !$this->getHandler() ) {
+ return false;
+ }
+ $this->dims[$page] = $this->handler->getPageDimensions( $this, $page );
+ }
+
+ return $this->dims[$page];
+ }
+
+ /**
+ * @param int $page
+ * @return int
+ */
+ function getWidth( $page = 1 ) {
+ $dim = $this->cachePageDimensions( $page );
+
+ return $dim['width'];
+ }
+
+ /**
+ * @param int $page
+ * @return int
+ */
+ function getHeight( $page = 1 ) {
+ $dim = $this->cachePageDimensions( $page );
+
+ return $dim['height'];
+ }
+
+ /**
+ * @return bool|string
+ */
+ function getMimeType() {
+ if ( !isset( $this->mime ) ) {
+ $magic = MimeMagic::singleton();
+ $this->mime = $magic->guessMimeType( $this->getLocalRefPath() );
+ }
+
+ return $this->mime;
+ }
+
+ /**
+ * @param string $filename
+ * @return array|bool
+ */
+ function getImageSize( $filename ) {
+ if ( !$this->getHandler() ) {
+ return false;
+ }
+
+ return $this->handler->getImageSize( $this, $this->getLocalRefPath() );
+ }
+
+ /**
+ * @return int
+ */
+ function getBitDepth() {
+ $gis = $this->getImageSize( $this->getLocalRefPath() );
+
+ if ( !$gis || !isset( $gis['bits'] ) ) {
+ return 0;
+ }
+ return $gis['bits'];
+ }
+
+ /**
+ * @return bool
+ */
+ function getMetadata() {
+ if ( !isset( $this->metadata ) ) {
+ if ( !$this->getHandler() ) {
+ $this->metadata = false;
+ } else {
+ $this->metadata = $this->handler->getMetadata( $this, $this->getLocalRefPath() );
+ }
+ }
+
+ return $this->metadata;
+ }
+
+ /**
+ * @return bool|string
+ */
+ function getURL() {
+ if ( $this->repo ) {
+ return $this->repo->getZoneUrl( 'public' ) . '/' .
+ $this->repo->getHashPath( $this->name ) . rawurlencode( $this->name );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @return bool|int
+ */
+ function getSize() {
+ $this->assertRepoDefined();
+
+ return $this->repo->getFileSize( $this->path );
+ }
+
+ /**
+ * Optimize getLocalRefPath() by using an existing local reference.
+ * The file at the path of $fsFile should not be deleted (or at least
+ * not until the end of the request). This is mostly a performance hack.
+ *
+ * @param FSFile $fsFile
+ * @return void
+ */
+ public function setLocalReference( FSFile $fsFile ) {
+ $this->fsFile = $fsFile;
+ }
+}
diff --git a/www/wiki/includes/gallery/ImageGalleryBase.php b/www/wiki/includes/gallery/ImageGalleryBase.php
new file mode 100644
index 00000000..eeb8a8ff
--- /dev/null
+++ b/www/wiki/includes/gallery/ImageGalleryBase.php
@@ -0,0 +1,378 @@
+<?php
+/**
+ * Image gallery.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Image gallery
+ *
+ * Add images to the gallery using add(), then render that list to HTML using toHTML().
+ *
+ * @ingroup Media
+ */
+abstract class ImageGalleryBase extends ContextSource {
+ /**
+ * @var array Gallery images
+ */
+ protected $mImages;
+
+ /**
+ * @var bool Whether to show the filesize in bytes in categories
+ */
+ protected $mShowBytes;
+
+ /**
+ * @var bool Whether to show the dimensions in categories
+ */
+ protected $mShowDimensions;
+
+ /**
+ * @var bool Whether to show the filename. Default: true
+ */
+ protected $mShowFilename;
+
+ /**
+ * @var string Gallery mode. Default: traditional
+ */
+ protected $mMode;
+
+ /**
+ * @var bool|string Gallery caption. Default: false
+ */
+ protected $mCaption = false;
+
+ /**
+ * @var bool Hide blacklisted images?
+ */
+ protected $mHideBadImages;
+
+ /**
+ * @var Parser Registered parser object for output callbacks
+ */
+ public $mParser;
+
+ /**
+ * @var Title Contextual title, used when images are being screened against
+ * the bad image list
+ */
+ protected $contextTitle = false;
+
+ /** @var array */
+ protected $mAttribs = [];
+
+ /** @var bool */
+ static private $modeMapping = false;
+
+ /**
+ * Get a new image gallery. This is the method other callers
+ * should use to get a gallery.
+ *
+ * @param string|bool $mode Mode to use. False to use the default
+ * @param IContextSource|null $context
+ * @return ImageGalleryBase
+ * @throws MWException
+ */
+ static function factory( $mode = false, IContextSource $context = null ) {
+ global $wgContLang;
+ self::loadModes();
+ if ( !$context ) {
+ $context = RequestContext::getMainAndWarn( __METHOD__ );
+ }
+ if ( !$mode ) {
+ $galleryOptions = $context->getConfig()->get( 'GalleryOptions' );
+ $mode = $galleryOptions['mode'];
+ }
+
+ $mode = $wgContLang->lc( $mode );
+
+ if ( isset( self::$modeMapping[$mode] ) ) {
+ $class = self::$modeMapping[$mode];
+ return new $class( $mode, $context );
+ } else {
+ throw new MWException( "No gallery class registered for mode $mode" );
+ }
+ }
+
+ private static function loadModes() {
+ if ( self::$modeMapping === false ) {
+ self::$modeMapping = [
+ 'traditional' => 'TraditionalImageGallery',
+ 'nolines' => 'NolinesImageGallery',
+ 'packed' => 'PackedImageGallery',
+ 'packed-hover' => 'PackedHoverImageGallery',
+ 'packed-overlay' => 'PackedOverlayImageGallery',
+ 'slideshow' => 'SlideshowImageGallery',
+ ];
+ // Allow extensions to make a new gallery format.
+ Hooks::run( 'GalleryGetModes', [ &self::$modeMapping ] );
+ }
+ }
+
+ /**
+ * Create a new image gallery object.
+ *
+ * You should not call this directly, but instead use
+ * ImageGalleryBase::factory().
+ * @param string $mode
+ * @param IContextSource|null $context
+ */
+ function __construct( $mode = 'traditional', IContextSource $context = null ) {
+ if ( $context ) {
+ $this->setContext( $context );
+ }
+
+ $galleryOptions = $this->getConfig()->get( 'GalleryOptions' );
+ $this->mImages = [];
+ $this->mShowBytes = $galleryOptions['showBytes'];
+ $this->mShowDimensions = $galleryOptions['showDimensions'];
+ $this->mShowFilename = true;
+ $this->mParser = false;
+ $this->mHideBadImages = false;
+ $this->mPerRow = $galleryOptions['imagesPerRow'];
+ $this->mWidths = $galleryOptions['imageWidth'];
+ $this->mHeights = $galleryOptions['imageHeight'];
+ $this->mCaptionLength = $galleryOptions['captionLength'];
+ $this->mMode = $mode;
+ }
+
+ /**
+ * Register a parser object. If you do not set this
+ * and the output of this gallery ends up in parser
+ * cache, the javascript will break!
+ *
+ * @note This also triggers using the page's target
+ * language instead of the user language.
+ *
+ * @param Parser $parser
+ */
+ function setParser( $parser ) {
+ $this->mParser = $parser;
+ }
+
+ /**
+ * Set bad image flag
+ * @param bool $flag
+ */
+ function setHideBadImages( $flag = true ) {
+ $this->mHideBadImages = $flag;
+ }
+
+ /**
+ * Set the caption (as plain text)
+ *
+ * @param string $caption Caption
+ */
+ function setCaption( $caption ) {
+ $this->mCaption = htmlspecialchars( $caption );
+ }
+
+ /**
+ * Set the caption (as HTML)
+ *
+ * @param string $caption Caption
+ */
+ public function setCaptionHtml( $caption ) {
+ $this->mCaption = $caption;
+ }
+
+ /**
+ * Set how many images will be displayed per row.
+ *
+ * @param int $num Integer >= 0; If perrow=0 the gallery layout will adapt
+ * to screensize invalid numbers will be rejected
+ */
+ public function setPerRow( $num ) {
+ if ( $num >= 0 ) {
+ $this->mPerRow = (int)$num;
+ }
+ }
+
+ /**
+ * Set how wide each image will be, in pixels.
+ *
+ * @param int $num Integer > 0; invalid numbers will be ignored
+ */
+ public function setWidths( $num ) {
+ if ( $num > 0 ) {
+ $this->mWidths = (int)$num;
+ }
+ }
+
+ /**
+ * Set how high each image will be, in pixels.
+ *
+ * @param int $num Integer > 0; invalid numbers will be ignored
+ */
+ public function setHeights( $num ) {
+ if ( $num > 0 ) {
+ $this->mHeights = (int)$num;
+ }
+ }
+
+ /**
+ * Allow setting additional options. This is meant
+ * to allow extensions to add additional parameters to
+ * <gallery> parser tag.
+ *
+ * @param array $options Attributes of gallery tag
+ */
+ public function setAdditionalOptions( $options ) {
+ }
+
+ /**
+ * Add an image to the gallery.
+ *
+ * @param Title $title Title object of the image that is added to the gallery
+ * @param string $html Additional HTML text to be shown. The name and size
+ * of the image are always shown.
+ * @param string $alt Alt text for the image
+ * @param string $link Override image link (optional)
+ * @param array $handlerOpts Array of options for image handler (aka page number)
+ */
+ function add( $title, $html = '', $alt = '', $link = '', $handlerOpts = [] ) {
+ if ( $title instanceof File ) {
+ // Old calling convention
+ $title = $title->getTitle();
+ }
+ $this->mImages[] = [ $title, $html, $alt, $link, $handlerOpts ];
+ wfDebug( 'ImageGallery::add ' . $title->getText() . "\n" );
+ }
+
+ /**
+ * Add an image at the beginning of the gallery.
+ *
+ * @param Title $title Title object of the image that is added to the gallery
+ * @param string $html Additional HTML text to be shown. The name and size
+ * of the image are always shown.
+ * @param string $alt Alt text for the image
+ * @param string $link Override image link (optional)
+ * @param array $handlerOpts Array of options for image handler (aka page number)
+ */
+ function insert( $title, $html = '', $alt = '', $link = '', $handlerOpts = [] ) {
+ if ( $title instanceof File ) {
+ // Old calling convention
+ $title = $title->getTitle();
+ }
+ array_unshift( $this->mImages, [ &$title, $html, $alt, $link, $handlerOpts ] );
+ }
+
+ /**
+ * Returns the list of images this gallery contains
+ * @return array
+ */
+ public function getImages() {
+ return $this->mImages;
+ }
+
+ /**
+ * isEmpty() returns true if the gallery contains no images
+ * @return bool
+ */
+ function isEmpty() {
+ return empty( $this->mImages );
+ }
+
+ /**
+ * Enable/Disable showing of the dimensions of an image in the gallery.
+ * Enabled by default.
+ *
+ * @param bool $f Set to false to disable
+ */
+ function setShowDimensions( $f ) {
+ $this->mShowDimensions = (bool)$f;
+ }
+
+ /**
+ * Enable/Disable showing of the file size of an image in the gallery.
+ * Enabled by default.
+ *
+ * @param bool $f Set to false to disable
+ */
+ function setShowBytes( $f ) {
+ $this->mShowBytes = (bool)$f;
+ }
+
+ /**
+ * Enable/Disable showing of the filename of an image in the gallery.
+ * Enabled by default.
+ *
+ * @param bool $f Set to false to disable
+ */
+ function setShowFilename( $f ) {
+ $this->mShowFilename = (bool)$f;
+ }
+
+ /**
+ * Set arbitrary attributes to go on the HTML gallery output element.
+ * Should be suitable for a <ul> element.
+ *
+ * Note -- if taking from user input, you should probably run through
+ * Sanitizer::validateAttributes() first.
+ *
+ * @param array $attribs Array of HTML attribute pairs
+ */
+ function setAttributes( $attribs ) {
+ $this->mAttribs = $attribs;
+ }
+
+ /**
+ * Display an html representation of the gallery
+ *
+ * @return string The html
+ */
+ abstract public function toHTML();
+
+ /**
+ * @return int Number of images in the gallery
+ */
+ public function count() {
+ return count( $this->mImages );
+ }
+
+ /**
+ * Set the contextual title
+ *
+ * @param Title $title Contextual title
+ */
+ public function setContextTitle( $title ) {
+ $this->contextTitle = $title;
+ }
+
+ /**
+ * Get the contextual title, if applicable
+ *
+ * @return Title|bool Title or false
+ */
+ public function getContextTitle() {
+ return is_object( $this->contextTitle ) && $this->contextTitle instanceof Title
+ ? $this->contextTitle
+ : false;
+ }
+
+ /**
+ * Determines the correct language to be used for this image gallery
+ * @return Language
+ */
+ protected function getRenderLang() {
+ return $this->mParser
+ ? $this->mParser->getTargetLanguage()
+ : $this->getLanguage();
+ }
+}
diff --git a/www/wiki/includes/gallery/NolinesImageGallery.php b/www/wiki/includes/gallery/NolinesImageGallery.php
new file mode 100644
index 00000000..70f5bd93
--- /dev/null
+++ b/www/wiki/includes/gallery/NolinesImageGallery.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Nolines image gallery. Like "traditional" but without borders and
+ * less padding.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class NolinesImageGallery extends TraditionalImageGallery {
+ protected function getThumbPadding() {
+ return 0;
+ }
+
+ protected function getGBBorders() {
+ // This accounts for extra space between <li> elements.
+ return 4;
+ }
+
+ protected function getVPad( $boxHeight, $thumbHeight ) {
+ return 0;
+ }
+}
diff --git a/www/wiki/includes/gallery/PackedImageGallery.php b/www/wiki/includes/gallery/PackedImageGallery.php
new file mode 100644
index 00000000..2e4836a5
--- /dev/null
+++ b/www/wiki/includes/gallery/PackedImageGallery.php
@@ -0,0 +1,112 @@
+<?php
+/**
+ * Packed image gallery. All images adjusted to be same height.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class PackedImageGallery extends TraditionalImageGallery {
+ function __construct( $mode = 'traditional', IContextSource $context = null ) {
+ parent::__construct( $mode, $context );
+ // Does not support per row option.
+ $this->mPerRow = 0;
+ }
+
+ /**
+ * We artificially have 1.5 the resolution neccessary so that
+ * we can scale it up by that much on the client side, without
+ * worrying about requesting a new image.
+ */
+ const SCALE_FACTOR = 1.5;
+
+ protected function getVPad( $boxHeight, $thumbHeight ) {
+ return ( $this->getThumbPadding() + $boxHeight - $thumbHeight / self::SCALE_FACTOR ) / 2;
+ }
+
+ protected function getThumbPadding() {
+ return 0;
+ }
+
+ protected function getGBPadding() {
+ return 2;
+ }
+
+ /**
+ * @param File $img The file being transformed. May be false
+ * @return array
+ */
+ protected function getThumbParams( $img ) {
+ if ( $img && $img->getMediaType() === MEDIATYPE_AUDIO ) {
+ $width = $this->mWidths;
+ } else {
+ // We want the width not to be the constraining
+ // factor, so use random big number.
+ $width = $this->mHeights * 10 + 100;
+ }
+
+ // self::SCALE_FACTOR so the js has some room to manipulate sizes.
+ return [
+ 'width' => $width * self::SCALE_FACTOR,
+ 'height' => $this->mHeights * self::SCALE_FACTOR,
+ ];
+ }
+
+ protected function getThumbDivWidth( $thumbWidth ) {
+ // Require at least 60px wide, so caption is wide enough to work.
+ if ( $thumbWidth < 60 * self::SCALE_FACTOR ) {
+ $thumbWidth = 60 * self::SCALE_FACTOR;
+ }
+
+ return $thumbWidth / self::SCALE_FACTOR + $this->getThumbPadding();
+ }
+
+ /**
+ * @param MediaTransformOutput|bool $thumb The thumbnail, or false if no
+ * thumb (which can happen)
+ * @return float
+ */
+ protected function getGBWidth( $thumb ) {
+ $thumbWidth = $thumb ? $thumb->getWidth() : $this->mWidths * self::SCALE_FACTOR;
+
+ return $this->getThumbDivWidth( $thumbWidth ) + $this->getGBPadding();
+ }
+
+ protected function adjustImageParameters( $thumb, &$imageParameters ) {
+ // Re-adjust back to normal size.
+ $imageParameters['override-width'] = ceil( $thumb->getWidth() / self::SCALE_FACTOR );
+ $imageParameters['override-height'] = ceil( $thumb->getHeight() / self::SCALE_FACTOR );
+ }
+
+ /**
+ * Add javascript which auto-justifies the rows by manipulating the image sizes.
+ * Also ensures that the hover version of this degrades gracefully.
+ * @return array
+ */
+ protected function getModules() {
+ return [ 'mediawiki.page.gallery' ];
+ }
+
+ /**
+ * Do not support per-row on packed. It really doesn't work
+ * since the images have varying widths.
+ * @param int $num
+ */
+ public function setPerRow( $num ) {
+ return;
+ }
+}
diff --git a/www/wiki/includes/gallery/PackedOverlayImageGallery.php b/www/wiki/includes/gallery/PackedOverlayImageGallery.php
new file mode 100644
index 00000000..db8ce68b
--- /dev/null
+++ b/www/wiki/includes/gallery/PackedOverlayImageGallery.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * Packed overlay image gallery. All images adjusted to be same height and
+ * image caption being placed over top of image.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class PackedOverlayImageGallery extends PackedImageGallery {
+ /**
+ * Add the wrapper html around the thumb's caption
+ *
+ * @param string $galleryText The caption
+ * @param MediaTransformOutput|bool $thumb The thumb this caption is for
+ * or false for bad image.
+ * @return string
+ */
+ protected function wrapGalleryText( $galleryText, $thumb ) {
+ // If we have no text, do not output anything to avoid
+ // ugly white overlay.
+ if ( trim( $galleryText ) === '' ) {
+ return '';
+ }
+
+ # ATTENTION: The newline after <div class="gallerytext"> is needed to
+ # accommodate htmltidy which in version 4.8.6 generated crackpot HTML
+ # in its absence, see: https://phabricator.wikimedia.org/T3765
+ # -Ævar
+
+ $thumbWidth = $this->getGBWidth( $thumb ) - $this->getThumbPadding() - $this->getGBPadding();
+ $captionWidth = ceil( $thumbWidth - 20 );
+
+ $outerWrapper = '<div class="gallerytextwrapper" style="width: ' . $captionWidth . 'px">';
+
+ return "\n\t\t\t" . $outerWrapper . '<div class="gallerytext">' . "\n"
+ . $galleryText
+ . "\n\t\t\t</div></div>";
+ }
+}
+
+/**
+ * Same as Packed except different CSS is applied to make the
+ * caption only show up on hover. If a touch screen is detected,
+ * falls back to PackedHoverGallery. Degrades gracefully for
+ * screen readers.
+ */
+class PackedHoverImageGallery extends PackedOverlayImageGallery {
+}
diff --git a/www/wiki/includes/gallery/SlideshowImageGallery.php b/www/wiki/includes/gallery/SlideshowImageGallery.php
new file mode 100644
index 00000000..f29c565f
--- /dev/null
+++ b/www/wiki/includes/gallery/SlideshowImageGallery.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * A slideshow gallery shows one image at a time with controls to move around.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class SlideshowImageGallery extends TraditionalImageGallery {
+ function __construct( $mode = 'traditional', IContextSource $context = null ) {
+ parent::__construct( $mode, $context );
+ // Does not support per row option.
+ $this->mPerRow = 0;
+ }
+
+ /**
+ * Add javascript adds interface elements
+ * @return array
+ */
+ protected function getModules() {
+ return [ 'mediawiki.page.gallery.slideshow' ];
+ }
+
+ public function setAdditionalOptions( $params ) {
+ $this->mAttribs['data-showthumbnails'] = isset( $params['showthumbnails'] );
+ }
+}
diff --git a/www/wiki/includes/gallery/TraditionalImageGallery.php b/www/wiki/includes/gallery/TraditionalImageGallery.php
new file mode 100644
index 00000000..7a520bcb
--- /dev/null
+++ b/www/wiki/includes/gallery/TraditionalImageGallery.php
@@ -0,0 +1,355 @@
+<?php
+/**
+ * Image gallery.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class TraditionalImageGallery extends ImageGalleryBase {
+ /**
+ * Return a HTML representation of the image gallery
+ *
+ * For each image in the gallery, display
+ * - a thumbnail
+ * - the image name
+ * - the additional text provided when adding the image
+ * - the size of the image
+ *
+ * @return string
+ */
+ function toHTML() {
+ if ( $this->mPerRow > 0 ) {
+ $maxwidth = $this->mPerRow * ( $this->mWidths + $this->getAllPadding() );
+ $oldStyle = isset( $this->mAttribs['style'] ) ? $this->mAttribs['style'] : '';
+ # _width is ignored by any sane browser. IE6 doesn't know max-width
+ # so it uses _width instead
+ $this->mAttribs['style'] = "max-width: {$maxwidth}px;_width: {$maxwidth}px;" .
+ $oldStyle;
+ }
+
+ $attribs = Sanitizer::mergeAttributes(
+ [ 'class' => 'gallery mw-gallery-' . $this->mMode ], $this->mAttribs );
+
+ $modules = $this->getModules();
+
+ if ( $this->mParser ) {
+ $this->mParser->getOutput()->addModules( $modules );
+ $this->mParser->getOutput()->addModuleStyles( 'mediawiki.page.gallery.styles' );
+ } else {
+ $this->getOutput()->addModules( $modules );
+ $this->getOutput()->addModuleStyles( 'mediawiki.page.gallery.styles' );
+ }
+ $output = Xml::openElement( 'ul', $attribs );
+ if ( $this->mCaption ) {
+ $output .= "\n\t<li class='gallerycaption'>{$this->mCaption}</li>";
+ }
+
+ if ( $this->mShowFilename ) {
+ // Preload LinkCache info for when generating links
+ // of the filename below
+ $lb = new LinkBatch();
+ foreach ( $this->mImages as $img ) {
+ $lb->addObj( $img[0] );
+ }
+ $lb->execute();
+ }
+
+ $lang = $this->getRenderLang();
+ # Output each image...
+ foreach ( $this->mImages as $pair ) {
+ /** @var Title $nt */
+ $nt = $pair[0];
+ $text = $pair[1]; # "text" means "caption" here
+ $alt = $pair[2];
+ $link = $pair[3];
+
+ $descQuery = false;
+ if ( $nt->getNamespace() === NS_FILE ) {
+ # Get the file...
+ if ( $this->mParser instanceof Parser ) {
+ # Give extensions a chance to select the file revision for us
+ $options = [];
+ Hooks::run( 'BeforeParserFetchFileAndTitle',
+ [ $this->mParser, $nt, &$options, &$descQuery ] );
+ # Fetch and register the file (file title may be different via hooks)
+ list( $img, $nt ) = $this->mParser->fetchFileAndTitle( $nt, $options );
+ } else {
+ $img = wfFindFile( $nt );
+ }
+ } else {
+ $img = false;
+ }
+
+ $params = $this->getThumbParams( $img );
+ // $pair[4] is per image handler options
+ $transformOptions = $params + $pair[4];
+
+ $thumb = false;
+
+ if ( !$img ) {
+ # We're dealing with a non-image, spit out the name and be done with it.
+ $thumbhtml = "\n\t\t\t" . '<div class="thumb" style="height: '
+ . ( $this->getThumbPadding() + $this->mHeights ) . 'px;">'
+ . htmlspecialchars( $nt->getText() ) . '</div>';
+
+ if ( $this->mParser instanceof Parser ) {
+ $this->mParser->addTrackingCategory( 'broken-file-category' );
+ }
+ } elseif ( $this->mHideBadImages
+ && wfIsBadImage( $nt->getDBkey(), $this->getContextTitle() )
+ ) {
+ # The image is blacklisted, just show it as a text link.
+ $thumbhtml = "\n\t\t\t" . '<div class="thumb" style="height: ' .
+ ( $this->getThumbPadding() + $this->mHeights ) . 'px;">' .
+ Linker::linkKnown(
+ $nt,
+ htmlspecialchars( $nt->getText() )
+ ) .
+ '</div>';
+ } else {
+ $thumb = $img->transform( $transformOptions );
+ if ( !$thumb ) {
+ # Error generating thumbnail.
+ $thumbhtml = "\n\t\t\t" . '<div class="thumb" style="height: '
+ . ( $this->getThumbPadding() + $this->mHeights ) . 'px;">'
+ . htmlspecialchars( $img->getLastError() ) . '</div>';
+ } else {
+ /** @var MediaTransformOutput $thumb */
+ $vpad = $this->getVPad( $this->mHeights, $thumb->getHeight() );
+
+ $imageParameters = [
+ 'desc-link' => true,
+ 'desc-query' => $descQuery,
+ 'alt' => $alt,
+ 'custom-url-link' => $link
+ ];
+
+ // In the absence of both alt text and caption, fall back on
+ // providing screen readers with the filename as alt text
+ if ( $alt == '' && $text == '' ) {
+ $imageParameters['alt'] = $nt->getText();
+ }
+
+ $this->adjustImageParameters( $thumb, $imageParameters );
+
+ Linker::processResponsiveImages( $img, $thumb, $transformOptions );
+
+ # Set both fixed width and min-height.
+ $thumbhtml = "\n\t\t\t"
+ . '<div class="thumb" style="width: '
+ . $this->getThumbDivWidth( $thumb->getWidth() ) . 'px;">'
+ # Auto-margin centering for block-level elements. Needed
+ # now that we have video handlers since they may emit block-
+ # level elements as opposed to simple <img> tags. ref
+ # http://css-discuss.incutio.com/?page=CenteringBlockElement
+ . '<div style="margin:' . $vpad . 'px auto;">'
+ . $thumb->toHtml( $imageParameters ) . '</div></div>';
+
+ // Call parser transform hook
+ /** @var MediaHandler $handler */
+ $handler = $img->getHandler();
+ if ( $this->mParser && $handler ) {
+ $handler->parserTransformHook( $this->mParser, $img );
+ }
+ }
+ }
+
+ // @todo Code is incomplete.
+ // $linkTarget = Title::newFromText( $wgContLang->getNsText( MWNamespace::getUser() ) .
+ // ":{$ut}" );
+ // $ul = Linker::link( $linkTarget, $ut );
+
+ $meta = [];
+ if ( $img ) {
+ if ( $this->mShowDimensions ) {
+ $meta[] = $img->getDimensionsString();
+ }
+ if ( $this->mShowBytes ) {
+ $meta[] = htmlspecialchars( $lang->formatSize( $img->getSize() ) );
+ }
+ } elseif ( $this->mShowDimensions || $this->mShowBytes ) {
+ $meta[] = $this->msg( 'filemissing' )->escaped();
+ }
+ $meta = $lang->semicolonList( $meta );
+ if ( $meta ) {
+ $meta .= "<br />\n";
+ }
+
+ $textlink = $this->mShowFilename ?
+ // Preloaded into LinkCache above
+ Linker::linkKnown(
+ $nt,
+ htmlspecialchars(
+ $this->mCaptionLength !== true ?
+ $lang->truncate( $nt->getText(), $this->mCaptionLength ) :
+ $nt->getText()
+ ),
+ [
+ 'class' => 'galleryfilename' .
+ ( $this->mCaptionLength === true ? ' galleryfilename-truncate' : '' )
+ ]
+ ) . "\n" :
+ '';
+
+ $galleryText = $textlink . $text . $meta;
+ $galleryText = $this->wrapGalleryText( $galleryText, $thumb );
+
+ # Weird double wrapping (the extra div inside the li) needed due to FF2 bug
+ # Can be safely removed if FF2 falls completely out of existence
+ $output .= "\n\t\t" . '<li class="gallerybox" style="width: '
+ . $this->getGBWidth( $thumb ) . 'px">'
+ . '<div style="width: ' . $this->getGBWidth( $thumb ) . 'px">'
+ . $thumbhtml
+ . $galleryText
+ . "\n\t\t</div></li>";
+ }
+ $output .= "\n</ul>";
+
+ return $output;
+ }
+
+ /**
+ * Add the wrapper html around the thumb's caption
+ *
+ * @param string $galleryText The caption
+ * @param MediaTransformOutput|bool $thumb The thumb this caption is for
+ * or false for bad image.
+ * @return string
+ */
+ protected function wrapGalleryText( $galleryText, $thumb ) {
+ # ATTENTION: The newline after <div class="gallerytext"> is needed to
+ # accommodate htmltidy which in version 4.8.6 generated crackpot html in
+ # its absence, see: https://phabricator.wikimedia.org/T3765
+ # -Ævar
+
+ return "\n\t\t\t" . '<div class="gallerytext">' . "\n"
+ . $galleryText
+ . "\n\t\t\t</div>";
+ }
+
+ /**
+ * How much padding the thumb has between the image and the inner div
+ * that contains the border. This is for both vertical and horizontal
+ * padding. (However, it is cut in half in the vertical direction).
+ * @return int
+ */
+ protected function getThumbPadding() {
+ return 30;
+ }
+
+ /**
+ * @note GB stands for gallerybox (as in the <li class="gallerybox"> element)
+ *
+ * @return int
+ */
+ protected function getGBPadding() {
+ return 5;
+ }
+
+ /**
+ * Get how much extra space the borders around the image takes up.
+ *
+ * For this mode, it is 2px borders on each side + 2px implied padding on
+ * each side from the stylesheet, giving us 2*2+2*2 = 8.
+ * @return int
+ */
+ protected function getGBBorders() {
+ return 8;
+ }
+
+ /**
+ * Get total padding.
+ *
+ * @return int Number of pixels of whitespace surrounding the thumbnail.
+ */
+ protected function getAllPadding() {
+ return $this->getThumbPadding() + $this->getGBPadding() + $this->getGBBorders();
+ }
+
+ /**
+ * Get vertical padding for a thumbnail
+ *
+ * Generally this is the total height minus how high the thumb is.
+ *
+ * @param int $boxHeight How high we want the box to be.
+ * @param int $thumbHeight How high the thumbnail is.
+ * @return int Vertical padding to add on each side.
+ */
+ protected function getVPad( $boxHeight, $thumbHeight ) {
+ return ( $this->getThumbPadding() + $boxHeight - $thumbHeight ) / 2;
+ }
+
+ /**
+ * Get the transform parameters for a thumbnail.
+ *
+ * @param File $img The file in question. May be false for invalid image
+ * @return array
+ */
+ protected function getThumbParams( $img ) {
+ return [
+ 'width' => $this->mWidths,
+ 'height' => $this->mHeights
+ ];
+ }
+
+ /**
+ * Get the width of the inner div that contains the thumbnail in
+ * question. This is the div with the class of "thumb".
+ *
+ * @param int $thumbWidth The width of the thumbnail.
+ * @return int Width of inner thumb div.
+ */
+ protected function getThumbDivWidth( $thumbWidth ) {
+ return $this->mWidths + $this->getThumbPadding();
+ }
+
+ /**
+ * Width of gallerybox <li>.
+ *
+ * Generally is the width of the image, plus padding on image
+ * plus padding on gallerybox.
+ *
+ * @note Important: parameter will be false if no thumb used.
+ * @param MediaTransformOutput|bool $thumb MediaTransformObject object or false.
+ * @return int Width of gallerybox element
+ */
+ protected function getGBWidth( $thumb ) {
+ return $this->mWidths + $this->getThumbPadding() + $this->getGBPadding();
+ }
+
+ /**
+ * Get a list of modules to include in the page.
+ *
+ * Primarily intended for subclasses.
+ *
+ * @return array Modules to include
+ */
+ protected function getModules() {
+ return [];
+ }
+
+ /**
+ * Adjust the image parameters for a thumbnail.
+ *
+ * Used by a subclass to insert extra high resolution images.
+ * @param MediaTransformOutput $thumb The thumbnail
+ * @param array &$imageParameters Array of options
+ */
+ protected function adjustImageParameters( $thumb, &$imageParameters ) {
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLApiField.php b/www/wiki/includes/htmlform/HTMLApiField.php
new file mode 100644
index 00000000..24a253ed
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLApiField.php
@@ -0,0 +1,23 @@
+<?php
+
+class HTMLApiField extends HTMLFormField {
+ public function getTableRow( $value ) {
+ return '';
+ }
+
+ public function getDiv( $value ) {
+ return $this->getTableRow( $value );
+ }
+
+ public function getRaw( $value ) {
+ return $this->getTableRow( $value );
+ }
+
+ public function getInputHTML( $value ) {
+ return '';
+ }
+
+ public function hasVisibleOutput() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLAutoCompleteSelectField.php b/www/wiki/includes/htmlform/HTMLAutoCompleteSelectField.php
new file mode 100644
index 00000000..76a88d51
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLAutoCompleteSelectField.php
@@ -0,0 +1,177 @@
+<?php
+
+/**
+ * Text field for selecting a value from a large list of possible values, with
+ * auto-completion and optionally with a select dropdown for selecting common
+ * options.
+ *
+ * HTMLComboboxField implements most of the same functionality and should be
+ * used instead, if possible.
+ *
+ * If one of 'options-messages', 'options', or 'options-message' is provided
+ * and non-empty, the select dropdown will be shown. An 'other' key will be
+ * appended using message 'htmlform-selectorother-other' if not already
+ * present.
+ *
+ * Besides the parameters recognized by HTMLTextField, the following are
+ * recognized:
+ * options-messages - As for HTMLSelectField
+ * options - As for HTMLSelectField
+ * options-message - As for HTMLSelectField
+ * autocomplete - Associative array mapping display text to values.
+ * autocomplete-messages - Like autocomplete, but keys are message names.
+ * require-match - Boolean, if true the value must be in the options or the
+ * autocomplete.
+ * other-message - Message to use instead of htmlform-selectorother-other for
+ * the 'other' message.
+ * other - Raw text to use for the 'other' message
+ */
+class HTMLAutoCompleteSelectField extends HTMLTextField {
+ protected $autocomplete = [];
+
+ function __construct( $params ) {
+ $params += [
+ 'require-match' => false,
+ ];
+
+ parent::__construct( $params );
+
+ if ( array_key_exists( 'autocomplete-messages', $this->mParams ) ) {
+ foreach ( $this->mParams['autocomplete-messages'] as $key => $value ) {
+ $key = $this->msg( $key )->plain();
+ $this->autocomplete[$key] = strval( $value );
+ }
+ } elseif ( array_key_exists( 'autocomplete', $this->mParams ) ) {
+ foreach ( $this->mParams['autocomplete'] as $key => $value ) {
+ $this->autocomplete[$key] = strval( $value );
+ }
+ }
+ if ( !is_array( $this->autocomplete ) || !$this->autocomplete ) {
+ throw new MWException( 'HTMLAutoCompleteSelectField called without any autocompletions' );
+ }
+
+ $this->getOptions();
+ if ( $this->mOptions && !in_array( 'other', $this->mOptions, true ) ) {
+ if ( isset( $params['other-message'] ) ) {
+ $msg = $this->getMessage( $params['other-message'] )->text();
+ } elseif ( isset( $params['other'] ) ) {
+ $msg = $params['other'];
+ } else {
+ $msg = wfMessage( 'htmlform-selectorother-other' )->text();
+ }
+ $this->mOptions[$msg] = 'other';
+ }
+ }
+
+ function loadDataFromRequest( $request ) {
+ if ( $request->getCheck( $this->mName ) ) {
+ $val = $request->getText( $this->mName . '-select', 'other' );
+
+ if ( $val === 'other' ) {
+ $val = $request->getText( $this->mName );
+ if ( isset( $this->autocomplete[$val] ) ) {
+ $val = $this->autocomplete[$val];
+ }
+ }
+
+ return $val;
+ } else {
+ return $this->getDefault();
+ }
+ }
+
+ function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ $validOptions = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ if ( in_array( strval( $value ), $validOptions, true ) ) {
+ return true;
+ } elseif ( in_array( strval( $value ), $this->autocomplete, true ) ) {
+ return true;
+ } elseif ( $this->mParams['require-match'] ) {
+ return $this->msg( 'htmlform-select-badoption' )->parse();
+ }
+
+ return true;
+ }
+
+ // FIXME Ewww, this shouldn't be adding any attributes not requested in $list :(
+ public function getAttributes( array $list ) {
+ $attribs = [
+ 'type' => 'text',
+ 'data-autocomplete' => FormatJson::encode( array_keys( $this->autocomplete ) ),
+ ] + parent::getAttributes( $list );
+
+ if ( $this->getOptions() ) {
+ $attribs['data-hide-if'] = FormatJson::encode(
+ [ '!==', $this->mName . '-select', 'other' ]
+ );
+ }
+
+ return $attribs;
+ }
+
+ function getInputHTML( $value ) {
+ $oldClass = $this->mClass;
+ $this->mClass = (array)$this->mClass;
+
+ $valInSelect = false;
+ $ret = '';
+
+ if ( $this->getOptions() ) {
+ if ( $value !== false ) {
+ $value = strval( $value );
+ $valInSelect = in_array(
+ $value, HTMLFormField::flattenOptions( $this->getOptions() ), true
+ );
+ }
+
+ $selected = $valInSelect ? $value : 'other';
+ $select = new XmlSelect( $this->mName . '-select', $this->mID . '-select', $selected );
+ $select->addOptions( $this->getOptions() );
+ $select->setAttribute( 'class', 'mw-htmlform-select-or-other' );
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $select->setAttribute( 'disabled', 'disabled' );
+ }
+
+ if ( isset( $this->mParams['tabindex'] ) ) {
+ $select->setAttribute( 'tabindex', $this->mParams['tabindex'] );
+ }
+
+ $ret = $select->getHTML() . "<br />\n";
+
+ $this->mClass[] = 'mw-htmlform-hide-if';
+ }
+
+ if ( $valInSelect ) {
+ $value = '';
+ } else {
+ $key = array_search( strval( $value ), $this->autocomplete, true );
+ if ( $key !== false ) {
+ $value = $key;
+ }
+ }
+
+ $this->mClass[] = 'mw-htmlform-autocomplete';
+ $ret .= parent::getInputHTML( $valInSelect ? '' : $value );
+ $this->mClass = $oldClass;
+
+ return $ret;
+ }
+
+ /**
+ * Get the OOUI version of this input.
+ * @param string $value
+ * @return false
+ */
+ function getInputOOUI( $value ) {
+ // To be implemented, for now override the function from HTMLTextField
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLButtonField.php b/www/wiki/includes/htmlform/HTMLButtonField.php
new file mode 100644
index 00000000..64fe7eda
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLButtonField.php
@@ -0,0 +1,132 @@
+<?php
+
+/**
+ * Adds a generic button inline to the form. Does not do anything, you must add
+ * click handling code in JavaScript. Use a HTMLSubmitField if you merely
+ * wish to add a submit button to a form.
+ *
+ * Additional recognized configuration parameters include:
+ * - flags: OOUI flags for the button, see OOUI\FlaggedElement
+ * - buttonlabel-message: Message to use for the button display text, instead
+ * of the value from 'default'. Overrides 'buttonlabel' and 'buttonlabel-raw'.
+ * - buttonlabel: Text to display for the button display text, instead
+ * of the value from 'default'. Overrides 'buttonlabel-raw'.
+ * - buttonlabel-raw: HTMLto display for the button display text, instead
+ * of the value from 'default'.
+ *
+ * Note that the buttonlabel parameters are not supported on IE6 and IE7 due to
+ * bugs in those browsers. If detected, they will be served buttons using the
+ * value of 'default' as the button label.
+ *
+ * @since 1.22
+ */
+class HTMLButtonField extends HTMLFormField {
+ protected $buttonType = 'button';
+ protected $buttonLabel = null;
+
+ /** @var array $mFlags Flags to add to OOUI Button widget */
+ protected $mFlags = [];
+
+ public function __construct( $info ) {
+ $info['nodata'] = true;
+ if ( isset( $info['flags'] ) ) {
+ $this->mFlags = $info['flags'];
+ }
+
+ # Generate the label from a message, if possible
+ if ( isset( $info['buttonlabel-message'] ) ) {
+ $this->buttonLabel = $this->getMessage( $info['buttonlabel-message'] )->parse();
+ } elseif ( isset( $info['buttonlabel'] ) ) {
+ if ( $info['buttonlabel'] === '&#160;' ) {
+ // Apparently some things set &nbsp directly and in an odd format
+ $this->buttonLabel = '&#160;';
+ } else {
+ $this->buttonLabel = htmlspecialchars( $info['buttonlabel'] );
+ }
+ } elseif ( isset( $info['buttonlabel-raw'] ) ) {
+ $this->buttonLabel = $info['buttonlabel-raw'];
+ }
+
+ $this->setShowEmptyLabel( false );
+
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ $flags = '';
+ $prefix = 'mw-htmlform-';
+ if ( $this->mParent instanceof VFormHTMLForm ||
+ $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' )
+ ) {
+ $prefix = 'mw-ui-';
+ // add mw-ui-button separately, so the descriptor doesn't need to set it
+ $flags .= ' ' . $prefix . 'button';
+ }
+ foreach ( $this->mFlags as $flag ) {
+ $flags .= ' ' . $prefix . $flag;
+ }
+ $attr = [
+ 'class' => 'mw-htmlform-submit ' . $this->mClass . $flags,
+ 'id' => $this->mID,
+ 'type' => $this->buttonType,
+ 'name' => $this->mName,
+ 'value' => $this->getDefault(),
+ ] + $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ if ( $this->isBadIE() ) {
+ return Html::element( 'input', $attr );
+ } else {
+ return Html::rawElement( 'button', $attr,
+ $this->buttonLabel ?: htmlspecialchars( $this->getDefault() ) );
+ }
+ }
+
+ /**
+ * Get the OOUI widget for this field.
+ * @param string $value
+ * @return OOUI\ButtonInputWidget
+ */
+ public function getInputOOUI( $value ) {
+ return new OOUI\ButtonInputWidget( [
+ 'name' => $this->mName,
+ 'value' => $this->getDefault(),
+ 'label' => !$this->isBadIE() && $this->buttonLabel
+ ? new OOUI\HtmlSnippet( $this->buttonLabel )
+ : $this->getDefault(),
+ 'type' => $this->buttonType,
+ 'classes' => [ 'mw-htmlform-submit', $this->mClass ],
+ 'id' => $this->mID,
+ 'flags' => $this->mFlags,
+ 'useInputTag' => $this->isBadIE(),
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ ) );
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+
+ /**
+ * Button cannot be invalid
+ *
+ * @param string $value
+ * @param array $alldata
+ *
+ * @return bool
+ */
+ public function validate( $value, $alldata ) {
+ return true;
+ }
+
+ /**
+ * IE<8 has bugs with <button>, so we'll need to avoid them.
+ * @return bool Whether the request is from a bad version of IE
+ */
+ private function isBadIE() {
+ $request = $this->mParent
+ ? $this->mParent->getRequest()
+ : RequestContext::getMain()->getRequest();
+ return preg_match( '/MSIE [1-7]\./i', $request->getHeader( 'User-Agent' ) );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLCheckField.php b/www/wiki/includes/htmlform/HTMLCheckField.php
new file mode 100644
index 00000000..4a6b8047
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLCheckField.php
@@ -0,0 +1,131 @@
+<?php
+
+/**
+ * A checkbox field
+ */
+class HTMLCheckField extends HTMLFormField {
+ function getInputHTML( $value ) {
+ global $wgUseMediaWikiUIEverywhere;
+
+ if ( !empty( $this->mParams['invert'] ) ) {
+ $value = !$value;
+ }
+
+ $attr = $this->getTooltipAndAccessKey();
+ $attr['id'] = $this->mID;
+
+ $attr += $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ if ( $this->mClass !== '' ) {
+ $attr['class'] = $this->mClass;
+ }
+
+ $attrLabel = [ 'for' => $this->mID ];
+ if ( isset( $attr['title'] ) ) {
+ // propagate tooltip to label
+ $attrLabel['title'] = $attr['title'];
+ }
+
+ $chkLabel = Xml::check( $this->mName, $value, $attr ) .
+ '&#160;' .
+ Html::rawElement( 'label', $attrLabel, $this->mLabel );
+
+ if ( $wgUseMediaWikiUIEverywhere || $this->mParent instanceof VFormHTMLForm ) {
+ $chkLabel = Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-ui-checkbox' ],
+ $chkLabel
+ );
+ }
+
+ return $chkLabel;
+ }
+
+ /**
+ * Get the OOUI version of this field.
+ * @since 1.26
+ * @param string $value
+ * @return OOUI\CheckboxInputWidget The checkbox widget.
+ */
+ public function getInputOOUI( $value ) {
+ if ( !empty( $this->mParams['invert'] ) ) {
+ $value = !$value;
+ }
+
+ $attr = $this->getTooltipAndAccessKey();
+ $attr['id'] = $this->mID;
+ $attr['name'] = $this->mName;
+
+ $attr += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $attr['classes'] = [ $this->mClass ];
+ }
+
+ $attr['selected'] = $value;
+ $attr['value'] = '1'; // Nasty hack, but needed to make this work
+
+ return new OOUI\CheckboxInputWidget( $attr );
+ }
+
+ /**
+ * For a checkbox, the label goes on the right hand side, and is
+ * added in getInputHTML(), rather than HTMLFormField::getRow()
+ *
+ * ...unless OOUI is being used, in which case we actually return
+ * the label here.
+ *
+ * @return string
+ */
+ function getLabel() {
+ if ( $this->mParent instanceof OOUIHTMLForm ) {
+ return $this->mLabel;
+ } elseif (
+ $this->mParent instanceof HTMLForm &&
+ $this->mParent->getDisplayFormat() === 'div'
+ ) {
+ return '';
+ } else {
+ return '&#160;';
+ }
+ }
+
+ /**
+ * Get label alignment when generating field for OOUI.
+ * @return string 'left', 'right', 'top' or 'inline'
+ */
+ protected function getLabelAlignOOUI() {
+ return 'inline';
+ }
+
+ /**
+ * checkboxes don't need a label.
+ * @return bool
+ */
+ protected function needsLabel() {
+ return false;
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return bool
+ */
+ function loadDataFromRequest( $request ) {
+ $invert = isset( $this->mParams['invert'] ) && $this->mParams['invert'];
+
+ // GetCheck won't work like we want for checks.
+ // Fetch the value in either one of the two following case:
+ // - we have a valid token (form got posted or GET forged by the user)
+ // - checkbox name has a value (false or true), ie is not null
+ if ( $request->getCheck( 'wpEditToken' ) || $request->getVal( $this->mName ) !== null ) {
+ return $invert
+ ? !$request->getBool( $this->mName )
+ : $request->getBool( $this->mName );
+ } else {
+ return (bool)$this->getDefault();
+ }
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLCheckMatrix.php b/www/wiki/includes/htmlform/HTMLCheckMatrix.php
new file mode 100644
index 00000000..9f672336
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLCheckMatrix.php
@@ -0,0 +1,275 @@
+<?php
+
+/**
+ * A checkbox matrix
+ * Operates similarly to HTMLMultiSelectField, but instead of using an array of
+ * options, uses an array of rows and an array of columns to dynamically
+ * construct a matrix of options. The tags used to identify a particular cell
+ * are of the form "columnName-rowName"
+ *
+ * Options:
+ * - columns
+ * - Required list of columns in the matrix.
+ * - rows
+ * - Required list of rows in the matrix.
+ * - force-options-on
+ * - Accepts array of column-row tags to be displayed as enabled but unavailable to change
+ * - force-options-off
+ * - Accepts array of column-row tags to be displayed as disabled but unavailable to change.
+ * - tooltips
+ * - Optional array mapping row label to tooltip content
+ * - tooltip-class
+ * - Optional CSS class used on tooltip container span. Defaults to mw-icon-question.
+ */
+class HTMLCheckMatrix extends HTMLFormField implements HTMLNestedFilterable {
+ static private $requiredParams = [
+ // Required by underlying HTMLFormField
+ 'fieldname',
+ // Required by HTMLCheckMatrix
+ 'rows',
+ 'columns'
+ ];
+
+ public function __construct( $params ) {
+ $missing = array_diff( self::$requiredParams, array_keys( $params ) );
+ if ( $missing ) {
+ throw new HTMLFormFieldRequiredOptionsException( $this, $missing );
+ }
+ parent::__construct( $params );
+ }
+
+ function validate( $value, $alldata ) {
+ $rows = $this->mParams['rows'];
+ $columns = $this->mParams['columns'];
+
+ // Make sure user-defined validation callback is run
+ $p = parent::validate( $value, $alldata );
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ // Make sure submitted value is an array
+ if ( !is_array( $value ) ) {
+ return false;
+ }
+
+ // If all options are valid, array_intersect of the valid options
+ // and the provided options will return the provided options.
+ $validOptions = [];
+ foreach ( $rows as $rowTag ) {
+ foreach ( $columns as $columnTag ) {
+ $validOptions[] = $columnTag . '-' . $rowTag;
+ }
+ }
+ $validValues = array_intersect( $value, $validOptions );
+ if ( count( $validValues ) == count( $value ) ) {
+ return true;
+ } else {
+ return $this->msg( 'htmlform-select-badoption' )->parse();
+ }
+ }
+
+ /**
+ * Build a table containing a matrix of checkbox options.
+ * The value of each option is a combination of the row tag and column tag.
+ * mParams['rows'] is an array with row labels as keys and row tags as values.
+ * mParams['columns'] is an array with column labels as keys and column tags as values.
+ *
+ * @param array $value Array of the options that should be checked
+ *
+ * @return string
+ */
+ function getInputHTML( $value ) {
+ $html = '';
+ $tableContents = '';
+ $rows = $this->mParams['rows'];
+ $columns = $this->mParams['columns'];
+
+ $attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ // Build the column headers
+ $headerContents = Html::rawElement( 'td', [], '&#160;' );
+ foreach ( $columns as $columnLabel => $columnTag ) {
+ $headerContents .= Html::rawElement( 'td', [], $columnLabel );
+ }
+ $tableContents .= Html::rawElement( 'tr', [], "\n$headerContents\n" );
+
+ $tooltipClass = 'mw-icon-question';
+ if ( isset( $this->mParams['tooltip-class'] ) ) {
+ $tooltipClass = $this->mParams['tooltip-class'];
+ }
+
+ // Build the options matrix
+ foreach ( $rows as $rowLabel => $rowTag ) {
+ // Append tooltip if configured
+ if ( isset( $this->mParams['tooltips'][$rowLabel] ) ) {
+ $tooltipAttribs = [
+ 'class' => "mw-htmlform-tooltip $tooltipClass",
+ 'title' => $this->mParams['tooltips'][$rowLabel],
+ ];
+ $rowLabel .= ' ' . Html::element( 'span', $tooltipAttribs, '' );
+ }
+ $rowContents = Html::rawElement( 'td', [], $rowLabel );
+ foreach ( $columns as $columnTag ) {
+ $thisTag = "$columnTag-$rowTag";
+ // Construct the checkbox
+ $thisAttribs = [
+ 'id' => "{$this->mID}-$thisTag",
+ 'value' => $thisTag,
+ ];
+ $checked = in_array( $thisTag, (array)$value, true );
+ if ( $this->isTagForcedOff( $thisTag ) ) {
+ $checked = false;
+ $thisAttribs['disabled'] = 1;
+ } elseif ( $this->isTagForcedOn( $thisTag ) ) {
+ $checked = true;
+ $thisAttribs['disabled'] = 1;
+ }
+
+ $checkbox = $this->getOneCheckbox( $checked, $attribs + $thisAttribs );
+
+ $rowContents .= Html::rawElement(
+ 'td',
+ [],
+ $checkbox
+ );
+ }
+ $tableContents .= Html::rawElement( 'tr', [], "\n$rowContents\n" );
+ }
+
+ // Put it all in a table
+ $html .= Html::rawElement( 'table',
+ [ 'class' => 'mw-htmlform-matrix' ],
+ Html::rawElement( 'tbody', [], "\n$tableContents\n" ) ) . "\n";
+
+ return $html;
+ }
+
+ protected function getOneCheckbox( $checked, $attribs ) {
+ if ( $this->mParent instanceof OOUIHTMLForm ) {
+ return new OOUI\CheckboxInputWidget( [
+ 'name' => "{$this->mName}[]",
+ 'selected' => $checked,
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $attribs
+ ) );
+ } else {
+ $checkbox = Xml::check( "{$this->mName}[]", $checked, $attribs );
+ if ( $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $checkbox = Html::openElement( 'div', [ 'class' => 'mw-ui-checkbox' ] ) .
+ $checkbox .
+ Html::element( 'label', [ 'for' => $attribs['id'] ] ) .
+ Html::closeElement( 'div' );
+ }
+ return $checkbox;
+ }
+ }
+
+ protected function isTagForcedOff( $tag ) {
+ return isset( $this->mParams['force-options-off'] )
+ && in_array( $tag, $this->mParams['force-options-off'] );
+ }
+
+ protected function isTagForcedOn( $tag ) {
+ return isset( $this->mParams['force-options-on'] )
+ && in_array( $tag, $this->mParams['force-options-on'] );
+ }
+
+ /**
+ * Get the complete table row for the input, including help text,
+ * labels, and whatever.
+ * We override this function since the label should always be on a separate
+ * line above the options in the case of a checkbox matrix, i.e. it's always
+ * a "vertical-label".
+ *
+ * @param string $value The value to set the input to
+ *
+ * @return string Complete HTML table row
+ */
+ function getTableRow( $value ) {
+ list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
+ $inputHtml = $this->getInputHTML( $value );
+ $fieldType = get_class( $this );
+ $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
+ $cellAttributes = [ 'colspan' => 2 ];
+
+ $hideClass = '';
+ $hideAttributes = [];
+ if ( $this->mHideIf ) {
+ $hideAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
+ $hideClass = 'mw-htmlform-hide-if';
+ }
+
+ $label = $this->getLabelHtml( $cellAttributes );
+
+ $field = Html::rawElement(
+ 'td',
+ [ 'class' => 'mw-input' ] + $cellAttributes,
+ $inputHtml . "\n$errors"
+ );
+
+ $html = Html::rawElement( 'tr',
+ [ 'class' => "mw-htmlform-vertical-label $hideClass" ] + $hideAttributes,
+ $label );
+ $html .= Html::rawElement( 'tr',
+ [ 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $hideClass" ] +
+ $hideAttributes,
+ $field );
+
+ return $html . $helptext;
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return array
+ */
+ function loadDataFromRequest( $request ) {
+ if ( $this->mParent->getMethod() == 'post' ) {
+ if ( $request->wasPosted() ) {
+ // Checkboxes are not added to the request arrays if they're not checked,
+ // so it's perfectly possible for there not to be an entry at all
+ return $request->getArray( $this->mName, [] );
+ } else {
+ // That's ok, the user has not yet submitted the form, so show the defaults
+ return $this->getDefault();
+ }
+ } else {
+ // This is the impossible case: if we look at $_GET and see no data for our
+ // field, is it because the user has not yet submitted the form, or that they
+ // have submitted it with all the options unchecked. We will have to assume the
+ // latter, which basically means that you can't specify 'positive' defaults
+ // for GET forms.
+ return $request->getArray( $this->mName, [] );
+ }
+ }
+
+ function getDefault() {
+ if ( isset( $this->mDefault ) ) {
+ return $this->mDefault;
+ } else {
+ return [];
+ }
+ }
+
+ function filterDataForSubmit( $data ) {
+ $columns = HTMLFormField::flattenOptions( $this->mParams['columns'] );
+ $rows = HTMLFormField::flattenOptions( $this->mParams['rows'] );
+ $res = [];
+ foreach ( $columns as $column ) {
+ foreach ( $rows as $row ) {
+ // Make sure option hasn't been forced
+ $thisTag = "$column-$row";
+ if ( $this->isTagForcedOff( $thisTag ) ) {
+ $res[$thisTag] = false;
+ } elseif ( $this->isTagForcedOn( $thisTag ) ) {
+ $res[$thisTag] = true;
+ } else {
+ $res[$thisTag] = in_array( $thisTag, $data );
+ }
+ }
+ }
+
+ return $res;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLComboboxField.php b/www/wiki/includes/htmlform/HTMLComboboxField.php
new file mode 100644
index 00000000..778aedbc
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLComboboxField.php
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * A combo box field.
+ *
+ * You can think of it as a dropdown select with the ability to add custom options,
+ * or as a text field with input suggestions (autocompletion).
+ *
+ * When JavaScript is not supported or enabled, it uses HTML5 `<datalist>` element.
+ *
+ * Besides the parameters recognized by HTMLTextField, the following are
+ * recognized:
+ * options-messages - As for HTMLSelectField
+ * options - As for HTMLSelectField
+ * options-message - As for HTMLSelectField
+ */
+class HTMLComboboxField extends HTMLTextField {
+ // FIXME Ewww, this shouldn't be adding any attributes not requested in $list :(
+ public function getAttributes( array $list ) {
+ $attribs = [
+ 'type' => 'text',
+ 'list' => $this->mName . '-datalist',
+ ] + parent::getAttributes( $list );
+
+ return $attribs;
+ }
+
+ function getInputHTML( $value ) {
+ $datalist = new XmlSelect( false, $this->mName . '-datalist' );
+ $datalist->setTagName( 'datalist' );
+ $datalist->addOptions( $this->getOptions() );
+
+ return parent::getInputHTML( $value ) . $datalist->getHTML();
+ }
+
+ function getInputOOUI( $value ) {
+ $disabled = false;
+ $allowedParams = [ 'tabindex' ];
+ $attribs = OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $attribs['classes'] = [ $this->mClass ];
+ }
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $disabled = true;
+ }
+
+ return new OOUI\ComboBoxInputWidget( [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'options' => $this->getOptionsOOUI(),
+ 'value' => strval( $value ),
+ 'disabled' => $disabled,
+ ] + $attribs );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLEditTools.php b/www/wiki/includes/htmlform/HTMLEditTools.php
new file mode 100644
index 00000000..1b5d1fb4
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLEditTools.php
@@ -0,0 +1,51 @@
+<?php
+
+class HTMLEditTools extends HTMLFormField {
+ public function getInputHTML( $value ) {
+ return '';
+ }
+
+ public function getTableRow( $value ) {
+ $msg = $this->formatMsg();
+
+ return
+ '<tr><td></td><td class="mw-input">' .
+ '<div class="mw-editTools">' .
+ $msg->parseAsBlock() .
+ "</div></td></tr>\n";
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getDiv( $value ) {
+ $msg = $this->formatMsg();
+
+ return '<div class="mw-editTools">' . $msg->parseAsBlock() . '</div>';
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getRaw( $value ) {
+ return $this->getDiv( $value );
+ }
+
+ protected function formatMsg() {
+ if ( empty( $this->mParams['message'] ) ) {
+ $msg = $this->msg( 'edittools' );
+ } else {
+ $msg = $this->getMessage( $this->mParams['message'] );
+ if ( $msg->isDisabled() ) {
+ $msg = $this->msg( 'edittools' );
+ }
+ }
+ $msg->inContentLanguage();
+
+ return $msg;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLFloatField.php b/www/wiki/includes/htmlform/HTMLFloatField.php
new file mode 100644
index 00000000..2ef49789
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLFloatField.php
@@ -0,0 +1,46 @@
+<?php
+
+/**
+ * A field that will contain a numeric value
+ */
+class HTMLFloatField extends HTMLTextField {
+ function getSize() {
+ return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 20;
+ }
+
+ function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ $value = trim( $value );
+
+ # http://www.w3.org/TR/html5/infrastructure.html#floating-point-numbers
+ # with the addition that a leading '+' sign is ok.
+ if ( !preg_match( '/^((\+|\-)?\d+(\.\d+)?(E(\+|\-)?\d+)?)?$/i', $value ) ) {
+ return $this->msg( 'htmlform-float-invalid' )->parseAsBlock();
+ }
+
+ # The "int" part of these message names is rather confusing.
+ # They make equal sense for all numbers.
+ if ( isset( $this->mParams['min'] ) ) {
+ $min = $this->mParams['min'];
+
+ if ( $min > $value ) {
+ return $this->msg( 'htmlform-int-toolow', $min )->parseAsBlock();
+ }
+ }
+
+ if ( isset( $this->mParams['max'] ) ) {
+ $max = $this->mParams['max'];
+
+ if ( $max < $value ) {
+ return $this->msg( 'htmlform-int-toohigh', $max )->parseAsBlock();
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLForm.php b/www/wiki/includes/htmlform/HTMLForm.php
new file mode 100644
index 00000000..465736bb
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLForm.php
@@ -0,0 +1,1907 @@
+<?php
+
+/**
+ * HTML form generation and submission handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Object handling generic submission, CSRF protection, layout and
+ * other logic for UI forms. in a reusable manner.
+ *
+ * In order to generate the form, the HTMLForm object takes an array
+ * structure detailing the form fields available. Each element of the
+ * array is a basic property-list, including the type of field, the
+ * label it is to be given in the form, callbacks for validation and
+ * 'filtering', and other pertinent information.
+ *
+ * Field types are implemented as subclasses of the generic HTMLFormField
+ * object, and typically implement at least getInputHTML, which generates
+ * the HTML for the input field to be placed in the table.
+ *
+ * You can find extensive documentation on the www.mediawiki.org wiki:
+ * - https://www.mediawiki.org/wiki/HTMLForm
+ * - https://www.mediawiki.org/wiki/HTMLForm/tutorial
+ *
+ * The constructor input is an associative array of $fieldname => $info,
+ * where $info is an Associative Array with any of the following:
+ *
+ * 'class' -- the subclass of HTMLFormField that will be used
+ * to create the object. *NOT* the CSS class!
+ * 'type' -- roughly translates into the <select> type attribute.
+ * if 'class' is not specified, this is used as a map
+ * through HTMLForm::$typeMappings to get the class name.
+ * 'default' -- default value when the form is displayed
+ * 'id' -- HTML id attribute
+ * 'cssclass' -- CSS class
+ * 'csshelpclass' -- CSS class used to style help text
+ * 'dir' -- Direction of the element.
+ * 'options' -- associative array mapping labels to values.
+ * Some field types support multi-level arrays.
+ * 'options-messages' -- associative array mapping message keys to values.
+ * Some field types support multi-level arrays.
+ * 'options-message' -- message key or object to be parsed to extract the list of
+ * options (like 'ipbreason-dropdown').
+ * 'label-message' -- message key or object for a message to use as the label.
+ * can be an array of msg key and then parameters to
+ * the message.
+ * 'label' -- alternatively, a raw text message. Overridden by
+ * label-message
+ * 'help' -- message text for a message to use as a help text.
+ * 'help-message' -- message key or object for a message to use as a help text.
+ * can be an array of msg key and then parameters to
+ * the message.
+ * Overwrites 'help-messages' and 'help'.
+ * 'help-messages' -- array of message keys/objects. As above, each item can
+ * be an array of msg key and then parameters.
+ * Overwrites 'help'.
+ * 'notice' -- message text for a message to use as a notice in the field.
+ * Currently used by OOUI form fields only.
+ * 'notice-messages' -- array of message keys/objects to use for notice.
+ * Overrides 'notice'.
+ * 'notice-message' -- message key or object to use as a notice.
+ * 'required' -- passed through to the object, indicating that it
+ * is a required field.
+ * 'size' -- the length of text fields
+ * 'filter-callback' -- a function name to give you the chance to
+ * massage the inputted value before it's processed.
+ * @see HTMLFormField::filter()
+ * 'validation-callback' -- a function name to give you the chance
+ * to impose extra validation on the field input.
+ * @see HTMLFormField::validate()
+ * 'name' -- By default, the 'name' attribute of the input field
+ * is "wp{$fieldname}". If you want a different name
+ * (eg one without the "wp" prefix), specify it here and
+ * it will be used without modification.
+ * 'hide-if' -- expression given as an array stating when the field
+ * should be hidden. The first array value has to be the
+ * expression's logic operator. Supported expressions:
+ * 'NOT'
+ * [ 'NOT', array $expression ]
+ * To hide a field if a given expression is not true.
+ * '==='
+ * [ '===', string $fieldName, string $value ]
+ * To hide a field if another field identified by
+ * $field has the value $value.
+ * '!=='
+ * [ '!==', string $fieldName, string $value ]
+ * Same as [ 'NOT', [ '===', $fieldName, $value ]
+ * 'OR', 'AND', 'NOR', 'NAND'
+ * [ 'XXX', array $expression1, ..., array $expressionN ]
+ * To hide a field if one or more (OR), all (AND),
+ * neither (NOR) or not all (NAND) given expressions
+ * are evaluated as true.
+ * The expressions will be given to a JavaScript frontend
+ * module which will continually update the field's
+ * visibility.
+ *
+ * Since 1.20, you can chain mutators to ease the form generation:
+ * @par Example:
+ * @code
+ * $form = new HTMLForm( $someFields );
+ * $form->setMethod( 'get' )
+ * ->setWrapperLegendMsg( 'message-key' )
+ * ->prepareForm()
+ * ->displayForm( '' );
+ * @endcode
+ * Note that you will have prepareForm and displayForm at the end. Other
+ * methods call done after that would simply not be part of the form :(
+ *
+ * @todo Document 'section' / 'subsection' stuff
+ */
+class HTMLForm extends ContextSource {
+ // A mapping of 'type' inputs onto standard HTMLFormField subclasses
+ public static $typeMappings = [
+ 'api' => 'HTMLApiField',
+ 'text' => 'HTMLTextField',
+ 'textwithbutton' => 'HTMLTextFieldWithButton',
+ 'textarea' => 'HTMLTextAreaField',
+ 'select' => 'HTMLSelectField',
+ 'combobox' => 'HTMLComboboxField',
+ 'radio' => 'HTMLRadioField',
+ 'multiselect' => 'HTMLMultiSelectField',
+ 'limitselect' => 'HTMLSelectLimitField',
+ 'check' => 'HTMLCheckField',
+ 'toggle' => 'HTMLCheckField',
+ 'int' => 'HTMLIntField',
+ 'float' => 'HTMLFloatField',
+ 'info' => 'HTMLInfoField',
+ 'selectorother' => 'HTMLSelectOrOtherField',
+ 'selectandother' => 'HTMLSelectAndOtherField',
+ 'namespaceselect' => 'HTMLSelectNamespace',
+ 'namespaceselectwithbutton' => 'HTMLSelectNamespaceWithButton',
+ 'tagfilter' => 'HTMLTagFilter',
+ 'sizefilter' => 'HTMLSizeFilterField',
+ 'submit' => 'HTMLSubmitField',
+ 'hidden' => 'HTMLHiddenField',
+ 'edittools' => 'HTMLEditTools',
+ 'checkmatrix' => 'HTMLCheckMatrix',
+ 'cloner' => 'HTMLFormFieldCloner',
+ 'autocompleteselect' => 'HTMLAutoCompleteSelectField',
+ 'date' => 'HTMLDateTimeField',
+ 'time' => 'HTMLDateTimeField',
+ 'datetime' => 'HTMLDateTimeField',
+ // HTMLTextField will output the correct type="" attribute automagically.
+ // There are about four zillion other HTML5 input types, like range, but
+ // we don't use those at the moment, so no point in adding all of them.
+ 'email' => 'HTMLTextField',
+ 'password' => 'HTMLTextField',
+ 'url' => 'HTMLTextField',
+ 'title' => 'HTMLTitleTextField',
+ 'user' => 'HTMLUserTextField',
+ 'usersmultiselect' => 'HTMLUsersMultiselectField',
+ ];
+
+ public $mFieldData;
+
+ protected $mMessagePrefix;
+
+ /** @var HTMLFormField[] */
+ protected $mFlatFields;
+
+ protected $mFieldTree;
+ protected $mShowReset = false;
+ protected $mShowSubmit = true;
+ protected $mSubmitFlags = [ 'primary', 'progressive' ];
+ protected $mShowCancel = false;
+ protected $mCancelTarget;
+
+ protected $mSubmitCallback;
+ protected $mValidationErrorMessage;
+
+ protected $mPre = '';
+ protected $mHeader = '';
+ protected $mFooter = '';
+ protected $mSectionHeaders = [];
+ protected $mSectionFooters = [];
+ protected $mPost = '';
+ protected $mId;
+ protected $mName;
+ protected $mTableId = '';
+
+ protected $mSubmitID;
+ protected $mSubmitName;
+ protected $mSubmitText;
+ protected $mSubmitTooltip;
+
+ protected $mFormIdentifier;
+ protected $mTitle;
+ protected $mMethod = 'post';
+ protected $mWasSubmitted = false;
+
+ /**
+ * Form action URL. false means we will use the URL to set Title
+ * @since 1.19
+ * @var bool|string
+ */
+ protected $mAction = false;
+
+ /**
+ * Form attribute autocomplete. false does not set the attribute
+ * @since 1.27
+ * @var bool|string
+ */
+ protected $mAutocomplete = false;
+
+ protected $mUseMultipart = false;
+ protected $mHiddenFields = [];
+ protected $mButtons = [];
+
+ protected $mWrapperLegend = false;
+
+ /**
+ * Salt for the edit token.
+ * @var string|array
+ */
+ protected $mTokenSalt = '';
+
+ /**
+ * If true, sections that contain both fields and subsections will
+ * render their subsections before their fields.
+ *
+ * Subclasses may set this to false to render subsections after fields
+ * instead.
+ */
+ protected $mSubSectionBeforeFields = true;
+
+ /**
+ * Format in which to display form. For viable options,
+ * @see $availableDisplayFormats
+ * @var string
+ */
+ protected $displayFormat = 'table';
+
+ /**
+ * Available formats in which to display the form
+ * @var array
+ */
+ protected $availableDisplayFormats = [
+ 'table',
+ 'div',
+ 'raw',
+ 'inline',
+ ];
+
+ /**
+ * Available formats in which to display the form
+ * @var array
+ */
+ protected $availableSubclassDisplayFormats = [
+ 'vform',
+ 'ooui',
+ ];
+
+ /**
+ * Construct a HTMLForm object for given display type. May return a HTMLForm subclass.
+ *
+ * @param string $displayFormat
+ * @param mixed $arguments,... Additional arguments to pass to the constructor.
+ * @return HTMLForm
+ */
+ public static function factory( $displayFormat/*, $arguments...*/ ) {
+ $arguments = func_get_args();
+ array_shift( $arguments );
+
+ switch ( $displayFormat ) {
+ case 'vform':
+ return ObjectFactory::constructClassInstance( VFormHTMLForm::class, $arguments );
+ case 'ooui':
+ return ObjectFactory::constructClassInstance( OOUIHTMLForm::class, $arguments );
+ default:
+ /** @var HTMLForm $form */
+ $form = ObjectFactory::constructClassInstance( self::class, $arguments );
+ $form->setDisplayFormat( $displayFormat );
+ return $form;
+ }
+ }
+
+ /**
+ * Build a new HTMLForm from an array of field attributes
+ *
+ * @param array $descriptor Array of Field constructs, as described above
+ * @param IContextSource $context Available since 1.18, will become compulsory in 1.18.
+ * Obviates the need to call $form->setTitle()
+ * @param string $messagePrefix A prefix to go in front of default messages
+ */
+ public function __construct( $descriptor, /*IContextSource*/ $context = null,
+ $messagePrefix = ''
+ ) {
+ if ( $context instanceof IContextSource ) {
+ $this->setContext( $context );
+ $this->mTitle = false; // We don't need them to set a title
+ $this->mMessagePrefix = $messagePrefix;
+ } elseif ( $context === null && $messagePrefix !== '' ) {
+ $this->mMessagePrefix = $messagePrefix;
+ } elseif ( is_string( $context ) && $messagePrefix === '' ) {
+ // B/C since 1.18
+ // it's actually $messagePrefix
+ $this->mMessagePrefix = $context;
+ }
+
+ // Evil hack for mobile :(
+ if (
+ !$this->getConfig()->get( 'HTMLFormAllowTableFormat' )
+ && $this->displayFormat === 'table'
+ ) {
+ $this->displayFormat = 'div';
+ }
+
+ // Expand out into a tree.
+ $loadedDescriptor = [];
+ $this->mFlatFields = [];
+
+ foreach ( $descriptor as $fieldname => $info ) {
+ $section = isset( $info['section'] )
+ ? $info['section']
+ : '';
+
+ if ( isset( $info['type'] ) && $info['type'] === 'file' ) {
+ $this->mUseMultipart = true;
+ }
+
+ $field = static::loadInputFromParameters( $fieldname, $info, $this );
+
+ $setSection =& $loadedDescriptor;
+ if ( $section ) {
+ $sectionParts = explode( '/', $section );
+
+ while ( count( $sectionParts ) ) {
+ $newName = array_shift( $sectionParts );
+
+ if ( !isset( $setSection[$newName] ) ) {
+ $setSection[$newName] = [];
+ }
+
+ $setSection =& $setSection[$newName];
+ }
+ }
+
+ $setSection[$fieldname] = $field;
+ $this->mFlatFields[$fieldname] = $field;
+ }
+
+ $this->mFieldTree = $loadedDescriptor;
+ }
+
+ /**
+ * @param string $fieldname
+ * @return bool
+ */
+ public function hasField( $fieldname ) {
+ return isset( $this->mFlatFields[$fieldname] );
+ }
+
+ /**
+ * @param string $fieldname
+ * @return HTMLFormField
+ * @throws DomainException on invalid field name
+ */
+ public function getField( $fieldname ) {
+ if ( !$this->hasField( $fieldname ) ) {
+ throw new DomainException( __METHOD__ . ': no field named ' . $fieldname );
+ }
+ return $this->mFlatFields[$fieldname];
+ }
+
+ /**
+ * Set format in which to display the form
+ *
+ * @param string $format The name of the format to use, must be one of
+ * $this->availableDisplayFormats
+ *
+ * @throws MWException
+ * @since 1.20
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setDisplayFormat( $format ) {
+ if (
+ in_array( $format, $this->availableSubclassDisplayFormats, true ) ||
+ in_array( $this->displayFormat, $this->availableSubclassDisplayFormats, true )
+ ) {
+ throw new MWException( 'Cannot change display format after creation, ' .
+ 'use HTMLForm::factory() instead' );
+ }
+
+ if ( !in_array( $format, $this->availableDisplayFormats, true ) ) {
+ throw new MWException( 'Display format must be one of ' .
+ print_r(
+ array_merge(
+ $this->availableDisplayFormats,
+ $this->availableSubclassDisplayFormats
+ ),
+ true
+ ) );
+ }
+
+ // Evil hack for mobile :(
+ if ( !$this->getConfig()->get( 'HTMLFormAllowTableFormat' ) && $format === 'table' ) {
+ $format = 'div';
+ }
+
+ $this->displayFormat = $format;
+
+ return $this;
+ }
+
+ /**
+ * Getter for displayFormat
+ * @since 1.20
+ * @return string
+ */
+ public function getDisplayFormat() {
+ return $this->displayFormat;
+ }
+
+ /**
+ * Test if displayFormat is 'vform'
+ * @since 1.22
+ * @deprecated since 1.25
+ * @return bool
+ */
+ public function isVForm() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return false;
+ }
+
+ /**
+ * Get the HTMLFormField subclass for this descriptor.
+ *
+ * The descriptor can be passed either 'class' which is the name of
+ * a HTMLFormField subclass, or a shorter 'type' which is an alias.
+ * This makes sure the 'class' is always set, and also is returned by
+ * this function for ease.
+ *
+ * @since 1.23
+ *
+ * @param string $fieldname Name of the field
+ * @param array &$descriptor Input Descriptor, as described above
+ *
+ * @throws MWException
+ * @return string Name of a HTMLFormField subclass
+ */
+ public static function getClassFromDescriptor( $fieldname, &$descriptor ) {
+ if ( isset( $descriptor['class'] ) ) {
+ $class = $descriptor['class'];
+ } elseif ( isset( $descriptor['type'] ) ) {
+ $class = static::$typeMappings[$descriptor['type']];
+ $descriptor['class'] = $class;
+ } else {
+ $class = null;
+ }
+
+ if ( !$class ) {
+ throw new MWException( "Descriptor with no class for $fieldname: "
+ . print_r( $descriptor, true ) );
+ }
+
+ return $class;
+ }
+
+ /**
+ * Initialise a new Object for the field
+ *
+ * @param string $fieldname Name of the field
+ * @param array $descriptor Input Descriptor, as described above
+ * @param HTMLForm|null $parent Parent instance of HTMLForm
+ *
+ * @throws MWException
+ * @return HTMLFormField Instance of a subclass of HTMLFormField
+ */
+ public static function loadInputFromParameters( $fieldname, $descriptor,
+ HTMLForm $parent = null
+ ) {
+ $class = static::getClassFromDescriptor( $fieldname, $descriptor );
+
+ $descriptor['fieldname'] = $fieldname;
+ if ( $parent ) {
+ $descriptor['parent'] = $parent;
+ }
+
+ # @todo This will throw a fatal error whenever someone try to use
+ # 'class' to feed a CSS class instead of 'cssclass'. Would be
+ # great to avoid the fatal error and show a nice error.
+ return new $class( $descriptor );
+ }
+
+ /**
+ * Prepare form for submission.
+ *
+ * @attention When doing method chaining, that should be the very last
+ * method call before displayForm().
+ *
+ * @throws MWException
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function prepareForm() {
+ # Check if we have the info we need
+ if ( !$this->mTitle instanceof Title && $this->mTitle !== false ) {
+ throw new MWException( 'You must call setTitle() on an HTMLForm' );
+ }
+
+ # Load data from the request.
+ if (
+ $this->mFormIdentifier === null ||
+ $this->getRequest()->getVal( 'wpFormIdentifier' ) === $this->mFormIdentifier
+ ) {
+ $this->loadData();
+ } else {
+ $this->mFieldData = [];
+ }
+
+ return $this;
+ }
+
+ /**
+ * Try submitting, with edit token check first
+ * @return Status|bool
+ */
+ public function tryAuthorizedSubmit() {
+ $result = false;
+
+ $identOkay = false;
+ if ( $this->mFormIdentifier === null ) {
+ $identOkay = true;
+ } else {
+ $identOkay = $this->getRequest()->getVal( 'wpFormIdentifier' ) === $this->mFormIdentifier;
+ }
+
+ $tokenOkay = false;
+ if ( $this->getMethod() !== 'post' ) {
+ $tokenOkay = true; // no session check needed
+ } elseif ( $this->getRequest()->wasPosted() ) {
+ $editToken = $this->getRequest()->getVal( 'wpEditToken' );
+ if ( $this->getUser()->isLoggedIn() || $editToken !== null ) {
+ // Session tokens for logged-out users have no security value.
+ // However, if the user gave one, check it in order to give a nice
+ // "session expired" error instead of "permission denied" or such.
+ $tokenOkay = $this->getUser()->matchEditToken( $editToken, $this->mTokenSalt );
+ } else {
+ $tokenOkay = true;
+ }
+ }
+
+ if ( $tokenOkay && $identOkay ) {
+ $this->mWasSubmitted = true;
+ $result = $this->trySubmit();
+ }
+
+ return $result;
+ }
+
+ /**
+ * The here's-one-I-made-earlier option: do the submission if
+ * posted, or display the form with or without funky validation
+ * errors
+ * @return bool|Status Whether submission was successful.
+ */
+ public function show() {
+ $this->prepareForm();
+
+ $result = $this->tryAuthorizedSubmit();
+ if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
+ return $result;
+ }
+
+ $this->displayForm( $result );
+
+ return false;
+ }
+
+ /**
+ * Same as self::show with the difference, that the form will be
+ * added to the output, no matter, if the validation was good or not.
+ * @return bool|Status Whether submission was successful.
+ */
+ public function showAlways() {
+ $this->prepareForm();
+
+ $result = $this->tryAuthorizedSubmit();
+
+ $this->displayForm( $result );
+
+ return $result;
+ }
+
+ /**
+ * Validate all the fields, and call the submission callback
+ * function if everything is kosher.
+ * @throws MWException
+ * @return bool|string|array|Status
+ * - Bool true or a good Status object indicates success,
+ * - Bool false indicates no submission was attempted,
+ * - Anything else indicates failure. The value may be a fatal Status
+ * object, an HTML string, or an array of arrays (message keys and
+ * params) or strings (message keys)
+ */
+ public function trySubmit() {
+ $valid = true;
+ $hoistedErrors = Status::newGood();
+ if ( $this->mValidationErrorMessage ) {
+ foreach ( (array)$this->mValidationErrorMessage as $error ) {
+ call_user_func_array( [ $hoistedErrors, 'fatal' ], $error );
+ }
+ } else {
+ $hoistedErrors->fatal( 'htmlform-invalid-input' );
+ }
+
+ $this->mWasSubmitted = true;
+
+ # Check for cancelled submission
+ foreach ( $this->mFlatFields as $fieldname => $field ) {
+ if ( !array_key_exists( $fieldname, $this->mFieldData ) ) {
+ continue;
+ }
+ if ( $field->cancelSubmit( $this->mFieldData[$fieldname], $this->mFieldData ) ) {
+ $this->mWasSubmitted = false;
+ return false;
+ }
+ }
+
+ # Check for validation
+ foreach ( $this->mFlatFields as $fieldname => $field ) {
+ if ( !array_key_exists( $fieldname, $this->mFieldData ) ) {
+ continue;
+ }
+ if ( $field->isHidden( $this->mFieldData ) ) {
+ continue;
+ }
+ $res = $field->validate( $this->mFieldData[$fieldname], $this->mFieldData );
+ if ( $res !== true ) {
+ $valid = false;
+ if ( $res !== false && !$field->canDisplayErrors() ) {
+ if ( is_string( $res ) ) {
+ $hoistedErrors->fatal( 'rawmessage', $res );
+ } else {
+ $hoistedErrors->fatal( $res );
+ }
+ }
+ }
+ }
+
+ if ( !$valid ) {
+ return $hoistedErrors;
+ }
+
+ $callback = $this->mSubmitCallback;
+ if ( !is_callable( $callback ) ) {
+ throw new MWException( 'HTMLForm: no submit callback provided. Use ' .
+ 'setSubmitCallback() to set one.' );
+ }
+
+ $data = $this->filterDataForSubmit( $this->mFieldData );
+
+ $res = call_user_func( $callback, $data, $this );
+ if ( $res === false ) {
+ $this->mWasSubmitted = false;
+ }
+
+ return $res;
+ }
+
+ /**
+ * Test whether the form was considered to have been submitted or not, i.e.
+ * whether the last call to tryAuthorizedSubmit or trySubmit returned
+ * non-false.
+ *
+ * This will return false until HTMLForm::tryAuthorizedSubmit or
+ * HTMLForm::trySubmit is called.
+ *
+ * @since 1.23
+ * @return bool
+ */
+ public function wasSubmitted() {
+ return $this->mWasSubmitted;
+ }
+
+ /**
+ * Set a callback to a function to do something with the form
+ * once it's been successfully validated.
+ *
+ * @param callable $cb The function will be passed the output from
+ * HTMLForm::filterDataForSubmit and this HTMLForm object, and must
+ * return as documented for HTMLForm::trySubmit
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setSubmitCallback( $cb ) {
+ $this->mSubmitCallback = $cb;
+
+ return $this;
+ }
+
+ /**
+ * Set a message to display on a validation error.
+ *
+ * @param string|array $msg String or Array of valid inputs to wfMessage()
+ * (so each entry can be either a String or Array)
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setValidationErrorMessage( $msg ) {
+ $this->mValidationErrorMessage = $msg;
+
+ return $this;
+ }
+
+ /**
+ * Set the introductory message, overwriting any existing message.
+ *
+ * @param string $msg Complete text of message to display
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setIntro( $msg ) {
+ $this->setPreText( $msg );
+
+ return $this;
+ }
+
+ /**
+ * Set the introductory message HTML, overwriting any existing message.
+ * @since 1.19
+ *
+ * @param string $msg Complete HTML of message to display
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setPreText( $msg ) {
+ $this->mPre = $msg;
+
+ return $this;
+ }
+
+ /**
+ * Add HTML to introductory message.
+ *
+ * @param string $msg Complete HTML of message to display
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function addPreText( $msg ) {
+ $this->mPre .= $msg;
+
+ return $this;
+ }
+
+ /**
+ * Add HTML to the header, inside the form.
+ *
+ * @param string $msg Additional HTML to display in header
+ * @param string|null $section The section to add the header to
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function addHeaderText( $msg, $section = null ) {
+ if ( $section === null ) {
+ $this->mHeader .= $msg;
+ } else {
+ if ( !isset( $this->mSectionHeaders[$section] ) ) {
+ $this->mSectionHeaders[$section] = '';
+ }
+ $this->mSectionHeaders[$section] .= $msg;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set header text, inside the form.
+ * @since 1.19
+ *
+ * @param string $msg Complete HTML of header to display
+ * @param string|null $section The section to add the header to
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setHeaderText( $msg, $section = null ) {
+ if ( $section === null ) {
+ $this->mHeader = $msg;
+ } else {
+ $this->mSectionHeaders[$section] = $msg;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get header text.
+ *
+ * @param string|null $section The section to get the header text for
+ * @since 1.26
+ * @return string HTML
+ */
+ public function getHeaderText( $section = null ) {
+ if ( $section === null ) {
+ return $this->mHeader;
+ } else {
+ return isset( $this->mSectionHeaders[$section] ) ? $this->mSectionHeaders[$section] : '';
+ }
+ }
+
+ /**
+ * Add footer text, inside the form.
+ *
+ * @param string $msg Complete text of message to display
+ * @param string|null $section The section to add the footer text to
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function addFooterText( $msg, $section = null ) {
+ if ( $section === null ) {
+ $this->mFooter .= $msg;
+ } else {
+ if ( !isset( $this->mSectionFooters[$section] ) ) {
+ $this->mSectionFooters[$section] = '';
+ }
+ $this->mSectionFooters[$section] .= $msg;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set footer text, inside the form.
+ * @since 1.19
+ *
+ * @param string $msg Complete text of message to display
+ * @param string|null $section The section to add the footer text to
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setFooterText( $msg, $section = null ) {
+ if ( $section === null ) {
+ $this->mFooter = $msg;
+ } else {
+ $this->mSectionFooters[$section] = $msg;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get footer text.
+ *
+ * @param string|null $section The section to get the footer text for
+ * @since 1.26
+ * @return string
+ */
+ public function getFooterText( $section = null ) {
+ if ( $section === null ) {
+ return $this->mFooter;
+ } else {
+ return isset( $this->mSectionFooters[$section] ) ? $this->mSectionFooters[$section] : '';
+ }
+ }
+
+ /**
+ * Add text to the end of the display.
+ *
+ * @param string $msg Complete text of message to display
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function addPostText( $msg ) {
+ $this->mPost .= $msg;
+
+ return $this;
+ }
+
+ /**
+ * Set text at the end of the display.
+ *
+ * @param string $msg Complete text of message to display
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setPostText( $msg ) {
+ $this->mPost = $msg;
+
+ return $this;
+ }
+
+ /**
+ * Add a hidden field to the output
+ *
+ * @param string $name Field name. This will be used exactly as entered
+ * @param string $value Field value
+ * @param array $attribs
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function addHiddenField( $name, $value, array $attribs = [] ) {
+ $attribs += [ 'name' => $name ];
+ $this->mHiddenFields[] = [ $value, $attribs ];
+
+ return $this;
+ }
+
+ /**
+ * Add an array of hidden fields to the output
+ *
+ * @since 1.22
+ *
+ * @param array $fields Associative array of fields to add;
+ * mapping names to their values
+ *
+ * @return HTMLForm $this for chaining calls
+ */
+ public function addHiddenFields( array $fields ) {
+ foreach ( $fields as $name => $value ) {
+ $this->mHiddenFields[] = [ $value, [ 'name' => $name ] ];
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a button to the form
+ *
+ * @since 1.27 takes an array as shown. Earlier versions accepted
+ * 'name', 'value', 'id', and 'attribs' as separate parameters in that
+ * order.
+ * @note Custom labels ('label', 'label-message', 'label-raw') are not
+ * supported for IE6 and IE7 due to bugs in those browsers. If detected,
+ * they will be served buttons using 'value' as the button label.
+ * @param array $data Data to define the button:
+ * - name: (string) Button name.
+ * - value: (string) Button value.
+ * - label-message: (string, optional) Button label message key to use
+ * instead of 'value'. Overrides 'label' and 'label-raw'.
+ * - label: (string, optional) Button label text to use instead of
+ * 'value'. Overrides 'label-raw'.
+ * - label-raw: (string, optional) Button label HTML to use instead of
+ * 'value'.
+ * - id: (string, optional) DOM id for the button.
+ * - attribs: (array, optional) Additional HTML attributes.
+ * - flags: (string|string[], optional) OOUI flags.
+ * - framed: (boolean=true, optional) OOUI framed attribute.
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function addButton( $data ) {
+ if ( !is_array( $data ) ) {
+ $args = func_get_args();
+ if ( count( $args ) < 2 || count( $args ) > 4 ) {
+ throw new InvalidArgumentException(
+ 'Incorrect number of arguments for deprecated calling style'
+ );
+ }
+ $data = [
+ 'name' => $args[0],
+ 'value' => $args[1],
+ 'id' => isset( $args[2] ) ? $args[2] : null,
+ 'attribs' => isset( $args[3] ) ? $args[3] : null,
+ ];
+ } else {
+ if ( !isset( $data['name'] ) ) {
+ throw new InvalidArgumentException( 'A name is required' );
+ }
+ if ( !isset( $data['value'] ) ) {
+ throw new InvalidArgumentException( 'A value is required' );
+ }
+ }
+ $this->mButtons[] = $data + [
+ 'id' => null,
+ 'attribs' => null,
+ 'flags' => null,
+ 'framed' => true,
+ ];
+
+ return $this;
+ }
+
+ /**
+ * Set the salt for the edit token.
+ *
+ * Only useful when the method is "post".
+ *
+ * @since 1.24
+ * @param string|array $salt Salt to use
+ * @return HTMLForm $this For chaining calls
+ */
+ public function setTokenSalt( $salt ) {
+ $this->mTokenSalt = $salt;
+
+ return $this;
+ }
+
+ /**
+ * Display the form (sending to the context's OutputPage object), with an
+ * appropriate error message or stack of messages, and any validation errors, etc.
+ *
+ * @attention You should call prepareForm() before calling this function.
+ * Moreover, when doing method chaining this should be the very last method
+ * call just after prepareForm().
+ *
+ * @param bool|string|array|Status $submitResult Output from HTMLForm::trySubmit()
+ *
+ * @return void Nothing, should be last call
+ */
+ public function displayForm( $submitResult ) {
+ $this->getOutput()->addHTML( $this->getHTML( $submitResult ) );
+ }
+
+ /**
+ * Returns the raw HTML generated by the form
+ *
+ * @param bool|string|array|Status $submitResult Output from HTMLForm::trySubmit()
+ *
+ * @return string HTML
+ */
+ public function getHTML( $submitResult ) {
+ # For good measure (it is the default)
+ $this->getOutput()->preventClickjacking();
+ $this->getOutput()->addModules( 'mediawiki.htmlform' );
+ $this->getOutput()->addModuleStyles( 'mediawiki.htmlform.styles' );
+
+ $html = ''
+ . $this->getErrorsOrWarnings( $submitResult, 'error' )
+ . $this->getErrorsOrWarnings( $submitResult, 'warning' )
+ . $this->getHeaderText()
+ . $this->getBody()
+ . $this->getHiddenFields()
+ . $this->getButtons()
+ . $this->getFooterText();
+
+ $html = $this->wrapForm( $html );
+
+ return '' . $this->mPre . $html . $this->mPost;
+ }
+
+ /**
+ * Get HTML attributes for the `<form>` tag.
+ * @return array
+ */
+ protected function getFormAttributes() {
+ # Use multipart/form-data
+ $encType = $this->mUseMultipart
+ ? 'multipart/form-data'
+ : 'application/x-www-form-urlencoded';
+ # Attributes
+ $attribs = [
+ 'class' => 'mw-htmlform',
+ 'action' => $this->getAction(),
+ 'method' => $this->getMethod(),
+ 'enctype' => $encType,
+ ];
+ if ( $this->mId ) {
+ $attribs['id'] = $this->mId;
+ }
+ if ( $this->mAutocomplete ) {
+ $attribs['autocomplete'] = $this->mAutocomplete;
+ }
+ if ( $this->mName ) {
+ $attribs['name'] = $this->mName;
+ }
+ if ( $this->needsJSForHtml5FormValidation() ) {
+ $attribs['novalidate'] = true;
+ }
+ return $attribs;
+ }
+
+ /**
+ * Wrap the form innards in an actual "<form>" element
+ *
+ * @param string $html HTML contents to wrap.
+ *
+ * @return string Wrapped HTML.
+ */
+ public function wrapForm( $html ) {
+ # Include a <fieldset> wrapper for style, if requested.
+ if ( $this->mWrapperLegend !== false ) {
+ $legend = is_string( $this->mWrapperLegend ) ? $this->mWrapperLegend : false;
+ $html = Xml::fieldset( $legend, $html );
+ }
+
+ return Html::rawElement(
+ 'form',
+ $this->getFormAttributes(),
+ $html
+ );
+ }
+
+ /**
+ * Get the hidden fields that should go inside the form.
+ * @return string HTML.
+ */
+ public function getHiddenFields() {
+ $html = '';
+ if ( $this->mFormIdentifier !== null ) {
+ $html .= Html::hidden(
+ 'wpFormIdentifier',
+ $this->mFormIdentifier
+ ) . "\n";
+ }
+ if ( $this->getMethod() === 'post' ) {
+ $html .= Html::hidden(
+ 'wpEditToken',
+ $this->getUser()->getEditToken( $this->mTokenSalt ),
+ [ 'id' => 'wpEditToken' ]
+ ) . "\n";
+ $html .= Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) . "\n";
+ }
+
+ $articlePath = $this->getConfig()->get( 'ArticlePath' );
+ if ( strpos( $articlePath, '?' ) !== false && $this->getMethod() === 'get' ) {
+ $html .= Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) . "\n";
+ }
+
+ foreach ( $this->mHiddenFields as $data ) {
+ list( $value, $attribs ) = $data;
+ $html .= Html::hidden( $attribs['name'], $value, $attribs ) . "\n";
+ }
+
+ return $html;
+ }
+
+ /**
+ * Get the submit and (potentially) reset buttons.
+ * @return string HTML.
+ */
+ public function getButtons() {
+ $buttons = '';
+ $useMediaWikiUIEverywhere = $this->getConfig()->get( 'UseMediaWikiUIEverywhere' );
+
+ if ( $this->mShowSubmit ) {
+ $attribs = [];
+
+ if ( isset( $this->mSubmitID ) ) {
+ $attribs['id'] = $this->mSubmitID;
+ }
+
+ if ( isset( $this->mSubmitName ) ) {
+ $attribs['name'] = $this->mSubmitName;
+ }
+
+ if ( isset( $this->mSubmitTooltip ) ) {
+ $attribs += Linker::tooltipAndAccesskeyAttribs( $this->mSubmitTooltip );
+ }
+
+ $attribs['class'] = [ 'mw-htmlform-submit' ];
+
+ if ( $useMediaWikiUIEverywhere ) {
+ foreach ( $this->mSubmitFlags as $flag ) {
+ $attribs['class'][] = 'mw-ui-' . $flag;
+ }
+ $attribs['class'][] = 'mw-ui-button';
+ }
+
+ $buttons .= Xml::submitButton( $this->getSubmitText(), $attribs ) . "\n";
+ }
+
+ if ( $this->mShowReset ) {
+ $buttons .= Html::element(
+ 'input',
+ [
+ 'type' => 'reset',
+ 'value' => $this->msg( 'htmlform-reset' )->text(),
+ 'class' => $useMediaWikiUIEverywhere ? 'mw-ui-button' : null,
+ ]
+ ) . "\n";
+ }
+
+ if ( $this->mShowCancel ) {
+ $target = $this->mCancelTarget ?: Title::newMainPage();
+ if ( $target instanceof Title ) {
+ $target = $target->getLocalURL();
+ }
+ $buttons .= Html::element(
+ 'a',
+ [
+ 'class' => $useMediaWikiUIEverywhere ? 'mw-ui-button' : null,
+ 'href' => $target,
+ ],
+ $this->msg( 'cancel' )->text()
+ ) . "\n";
+ }
+
+ // IE<8 has bugs with <button>, so we'll need to avoid them.
+ $isBadIE = preg_match( '/MSIE [1-7]\./i', $this->getRequest()->getHeader( 'User-Agent' ) );
+
+ foreach ( $this->mButtons as $button ) {
+ $attrs = [
+ 'type' => 'submit',
+ 'name' => $button['name'],
+ 'value' => $button['value']
+ ];
+
+ if ( isset( $button['label-message'] ) ) {
+ $label = $this->getMessage( $button['label-message'] )->parse();
+ } elseif ( isset( $button['label'] ) ) {
+ $label = htmlspecialchars( $button['label'] );
+ } elseif ( isset( $button['label-raw'] ) ) {
+ $label = $button['label-raw'];
+ } else {
+ $label = htmlspecialchars( $button['value'] );
+ }
+
+ if ( $button['attribs'] ) {
+ $attrs += $button['attribs'];
+ }
+
+ if ( isset( $button['id'] ) ) {
+ $attrs['id'] = $button['id'];
+ }
+
+ if ( $useMediaWikiUIEverywhere ) {
+ $attrs['class'] = isset( $attrs['class'] ) ? (array)$attrs['class'] : [];
+ $attrs['class'][] = 'mw-ui-button';
+ }
+
+ if ( $isBadIE ) {
+ $buttons .= Html::element( 'input', $attrs ) . "\n";
+ } else {
+ $buttons .= Html::rawElement( 'button', $attrs, $label ) . "\n";
+ }
+ }
+
+ if ( !$buttons ) {
+ return '';
+ }
+
+ return Html::rawElement( 'span',
+ [ 'class' => 'mw-htmlform-submit-buttons' ], "\n$buttons" ) . "\n";
+ }
+
+ /**
+ * Get the whole body of the form.
+ * @return string
+ */
+ public function getBody() {
+ return $this->displaySection( $this->mFieldTree, $this->mTableId );
+ }
+
+ /**
+ * Format and display an error message stack.
+ *
+ * @param string|array|Status $errors
+ *
+ * @deprecated since 1.28, use getErrorsOrWarnings() instead
+ *
+ * @return string
+ */
+ public function getErrors( $errors ) {
+ wfDeprecated( __METHOD__ );
+ return $this->getErrorsOrWarnings( $errors, 'error' );
+ }
+
+ /**
+ * Returns a formatted list of errors or warnings from the given elements.
+ *
+ * @param string|array|Status $elements The set of errors/warnings to process.
+ * @param string $elementsType Should warnings or errors be returned. This is meant
+ * for Status objects, all other valid types are always considered as errors.
+ * @return string
+ */
+ public function getErrorsOrWarnings( $elements, $elementsType ) {
+ if ( !in_array( $elementsType, [ 'error', 'warning' ], true ) ) {
+ throw new DomainException( $elementsType . ' is not a valid type.' );
+ }
+ $elementstr = false;
+ if ( $elements instanceof Status ) {
+ list( $errorStatus, $warningStatus ) = $elements->splitByErrorType();
+ $status = $elementsType === 'error' ? $errorStatus : $warningStatus;
+ if ( $status->isGood() ) {
+ $elementstr = '';
+ } else {
+ $elementstr = $this->getOutput()->parse(
+ $status->getWikiText()
+ );
+ }
+ } elseif ( is_array( $elements ) && $elementsType === 'error' ) {
+ $elementstr = $this->formatErrors( $elements );
+ } elseif ( $elementsType === 'error' ) {
+ $elementstr = $elements;
+ }
+
+ return $elementstr
+ ? Html::rawElement( 'div', [ 'class' => $elementsType ], $elementstr )
+ : '';
+ }
+
+ /**
+ * Format a stack of error messages into a single HTML string
+ *
+ * @param array $errors Array of message keys/values
+ *
+ * @return string HTML, a "<ul>" list of errors
+ */
+ public function formatErrors( $errors ) {
+ $errorstr = '';
+
+ foreach ( $errors as $error ) {
+ $errorstr .= Html::rawElement(
+ 'li',
+ [],
+ $this->getMessage( $error )->parse()
+ );
+ }
+
+ $errorstr = Html::rawElement( 'ul', [], $errorstr );
+
+ return $errorstr;
+ }
+
+ /**
+ * Set the text for the submit button
+ *
+ * @param string $t Plaintext
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setSubmitText( $t ) {
+ $this->mSubmitText = $t;
+
+ return $this;
+ }
+
+ /**
+ * Identify that the submit button in the form has a destructive action
+ * @since 1.24
+ *
+ * @return HTMLForm $this for chaining calls (since 1.28)
+ */
+ public function setSubmitDestructive() {
+ $this->mSubmitFlags = [ 'destructive', 'primary' ];
+
+ return $this;
+ }
+
+ /**
+ * Identify that the submit button in the form has a progressive action
+ * @since 1.25
+ *
+ * @return HTMLForm $this for chaining calls (since 1.28)
+ */
+ public function setSubmitProgressive() {
+ $this->mSubmitFlags = [ 'progressive', 'primary' ];
+
+ return $this;
+ }
+
+ /**
+ * Set the text for the submit button to a message
+ * @since 1.19
+ *
+ * @param string|Message $msg Message key or Message object
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setSubmitTextMsg( $msg ) {
+ if ( !$msg instanceof Message ) {
+ $msg = $this->msg( $msg );
+ }
+ $this->setSubmitText( $msg->text() );
+
+ return $this;
+ }
+
+ /**
+ * Get the text for the submit button, either customised or a default.
+ * @return string
+ */
+ public function getSubmitText() {
+ return $this->mSubmitText ?: $this->msg( 'htmlform-submit' )->text();
+ }
+
+ /**
+ * @param string $name Submit button name
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setSubmitName( $name ) {
+ $this->mSubmitName = $name;
+
+ return $this;
+ }
+
+ /**
+ * @param string $name Tooltip for the submit button
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setSubmitTooltip( $name ) {
+ $this->mSubmitTooltip = $name;
+
+ return $this;
+ }
+
+ /**
+ * Set the id for the submit button.
+ *
+ * @param string $t
+ *
+ * @todo FIXME: Integrity of $t is *not* validated
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setSubmitID( $t ) {
+ $this->mSubmitID = $t;
+
+ return $this;
+ }
+
+ /**
+ * Set an internal identifier for this form. It will be submitted as a hidden form field, allowing
+ * HTMLForm to determine whether the form was submitted (or merely viewed). Setting this serves
+ * two purposes:
+ *
+ * - If you use two or more forms on one page, it allows HTMLForm to identify which of the forms
+ * was submitted, and not attempt to validate the other ones.
+ * - If you use checkbox or multiselect fields inside a form using the GET method, it allows
+ * HTMLForm to distinguish between the initial page view and a form submission with all
+ * checkboxes or select options unchecked.
+ *
+ * @since 1.28
+ * @param string $ident
+ * @return $this
+ */
+ public function setFormIdentifier( $ident ) {
+ $this->mFormIdentifier = $ident;
+
+ return $this;
+ }
+
+ /**
+ * Stop a default submit button being shown for this form. This implies that an
+ * alternate submit method must be provided manually.
+ *
+ * @since 1.22
+ *
+ * @param bool $suppressSubmit Set to false to re-enable the button again
+ *
+ * @return HTMLForm $this for chaining calls
+ */
+ public function suppressDefaultSubmit( $suppressSubmit = true ) {
+ $this->mShowSubmit = !$suppressSubmit;
+
+ return $this;
+ }
+
+ /**
+ * Show a cancel button (or prevent it). The button is not shown by default.
+ * @param bool $show
+ * @return HTMLForm $this for chaining calls
+ * @since 1.27
+ */
+ public function showCancel( $show = true ) {
+ $this->mShowCancel = $show;
+ return $this;
+ }
+
+ /**
+ * Sets the target where the user is redirected to after clicking cancel.
+ * @param Title|string $target Target as a Title object or an URL
+ * @return HTMLForm $this for chaining calls
+ * @since 1.27
+ */
+ public function setCancelTarget( $target ) {
+ $this->mCancelTarget = $target;
+ return $this;
+ }
+
+ /**
+ * Set the id of the \<table\> or outermost \<div\> element.
+ *
+ * @since 1.22
+ *
+ * @param string $id New value of the id attribute, or "" to remove
+ *
+ * @return HTMLForm $this for chaining calls
+ */
+ public function setTableId( $id ) {
+ $this->mTableId = $id;
+
+ return $this;
+ }
+
+ /**
+ * @param string $id DOM id for the form
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setId( $id ) {
+ $this->mId = $id;
+
+ return $this;
+ }
+
+ /**
+ * @param string $name 'name' attribute for the form
+ * @return HTMLForm $this for chaining calls
+ */
+ public function setName( $name ) {
+ $this->mName = $name;
+
+ return $this;
+ }
+
+ /**
+ * Prompt the whole form to be wrapped in a "<fieldset>", with
+ * this text as its "<legend>" element.
+ *
+ * @param string|bool $legend If false, no wrapper or legend will be displayed.
+ * If true, a wrapper will be displayed, but no legend.
+ * If a string, a wrapper will be displayed with that string as a legend.
+ * The string will be escaped before being output (this doesn't support HTML).
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setWrapperLegend( $legend ) {
+ $this->mWrapperLegend = $legend;
+
+ return $this;
+ }
+
+ /**
+ * Prompt the whole form to be wrapped in a "<fieldset>", with
+ * this message as its "<legend>" element.
+ * @since 1.19
+ *
+ * @param string|Message $msg Message key or Message object
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setWrapperLegendMsg( $msg ) {
+ if ( !$msg instanceof Message ) {
+ $msg = $this->msg( $msg );
+ }
+ $this->setWrapperLegend( $msg->text() );
+
+ return $this;
+ }
+
+ /**
+ * Set the prefix for various default messages
+ * @todo Currently only used for the "<fieldset>" legend on forms
+ * with multiple sections; should be used elsewhere?
+ *
+ * @param string $p
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setMessagePrefix( $p ) {
+ $this->mMessagePrefix = $p;
+
+ return $this;
+ }
+
+ /**
+ * Set the title for form submission
+ *
+ * @param Title $t Title of page the form is on/should be posted to
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setTitle( $t ) {
+ $this->mTitle = $t;
+
+ return $this;
+ }
+
+ /**
+ * Get the title
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->mTitle === false
+ ? $this->getContext()->getTitle()
+ : $this->mTitle;
+ }
+
+ /**
+ * Set the method used to submit the form
+ *
+ * @param string $method
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setMethod( $method = 'post' ) {
+ $this->mMethod = strtolower( $method );
+
+ return $this;
+ }
+
+ /**
+ * @return string Always lowercase
+ */
+ public function getMethod() {
+ return $this->mMethod;
+ }
+
+ /**
+ * Wraps the given $section into an user-visible fieldset.
+ *
+ * @param string $legend Legend text for the fieldset
+ * @param string $section The section content in plain Html
+ * @param array $attributes Additional attributes for the fieldset
+ * @return string The fieldset's Html
+ */
+ protected function wrapFieldSetSection( $legend, $section, $attributes ) {
+ return Xml::fieldset( $legend, $section, $attributes ) . "\n";
+ }
+
+ /**
+ * @todo Document
+ *
+ * @param array[]|HTMLFormField[] $fields Array of fields (either arrays or
+ * objects).
+ * @param string $sectionName ID attribute of the "<table>" tag for this
+ * section, ignored if empty.
+ * @param string $fieldsetIDPrefix ID prefix for the "<fieldset>" tag of
+ * each subsection, ignored if empty.
+ * @param bool &$hasUserVisibleFields Whether the section had user-visible fields.
+ * @throws LogicException When called on uninitialized field data, e.g. When
+ * HTMLForm::displayForm was called without calling HTMLForm::prepareForm
+ * first.
+ *
+ * @return string
+ */
+ public function displaySection( $fields,
+ $sectionName = '',
+ $fieldsetIDPrefix = '',
+ &$hasUserVisibleFields = false
+ ) {
+ if ( $this->mFieldData === null ) {
+ throw new LogicException( 'HTMLForm::displaySection() called on uninitialized field data. '
+ . 'You probably called displayForm() without calling prepareForm() first.' );
+ }
+
+ $displayFormat = $this->getDisplayFormat();
+
+ $html = [];
+ $subsectionHtml = '';
+ $hasLabel = false;
+
+ // Conveniently, PHP method names are case-insensitive.
+ // For grep: this can call getDiv, getRaw, getInline, getVForm, getOOUI
+ $getFieldHtmlMethod = $displayFormat === 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
+
+ foreach ( $fields as $key => $value ) {
+ if ( $value instanceof HTMLFormField ) {
+ $v = array_key_exists( $key, $this->mFieldData )
+ ? $this->mFieldData[$key]
+ : $value->getDefault();
+
+ $retval = $value->$getFieldHtmlMethod( $v );
+
+ // check, if the form field should be added to
+ // the output.
+ if ( $value->hasVisibleOutput() ) {
+ $html[] = $retval;
+
+ $labelValue = trim( $value->getLabel() );
+ if ( $labelValue !== '&#160;' && $labelValue !== '' ) {
+ $hasLabel = true;
+ }
+
+ $hasUserVisibleFields = true;
+ }
+ } elseif ( is_array( $value ) ) {
+ $subsectionHasVisibleFields = false;
+ $section =
+ $this->displaySection( $value,
+ "mw-htmlform-$key",
+ "$fieldsetIDPrefix$key-",
+ $subsectionHasVisibleFields );
+ $legend = null;
+
+ if ( $subsectionHasVisibleFields === true ) {
+ // Display the section with various niceties.
+ $hasUserVisibleFields = true;
+
+ $legend = $this->getLegend( $key );
+
+ $section = $this->getHeaderText( $key ) .
+ $section .
+ $this->getFooterText( $key );
+
+ $attributes = [];
+ if ( $fieldsetIDPrefix ) {
+ $attributes['id'] = Sanitizer::escapeIdForAttribute( "$fieldsetIDPrefix$key" );
+ }
+ $subsectionHtml .= $this->wrapFieldSetSection( $legend, $section, $attributes );
+ } else {
+ // Just return the inputs, nothing fancy.
+ $subsectionHtml .= $section;
+ }
+ }
+ }
+
+ $html = $this->formatSection( $html, $sectionName, $hasLabel );
+
+ if ( $subsectionHtml ) {
+ if ( $this->mSubSectionBeforeFields ) {
+ return $subsectionHtml . "\n" . $html;
+ } else {
+ return $html . "\n" . $subsectionHtml;
+ }
+ } else {
+ return $html;
+ }
+ }
+
+ /**
+ * Put a form section together from the individual fields' HTML, merging it and wrapping.
+ * @param array $fieldsHtml
+ * @param string $sectionName
+ * @param bool $anyFieldHasLabel
+ * @return string HTML
+ */
+ protected function formatSection( array $fieldsHtml, $sectionName, $anyFieldHasLabel ) {
+ $displayFormat = $this->getDisplayFormat();
+ $html = implode( '', $fieldsHtml );
+
+ if ( $displayFormat === 'raw' ) {
+ return $html;
+ }
+
+ $classes = [];
+
+ if ( !$anyFieldHasLabel ) { // Avoid strange spacing when no labels exist
+ $classes[] = 'mw-htmlform-nolabel';
+ }
+
+ $attribs = [
+ 'class' => implode( ' ', $classes ),
+ ];
+
+ if ( $sectionName ) {
+ $attribs['id'] = Sanitizer::escapeIdForAttribute( $sectionName );
+ }
+
+ if ( $displayFormat === 'table' ) {
+ return Html::rawElement( 'table',
+ $attribs,
+ Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
+ } elseif ( $displayFormat === 'inline' ) {
+ return Html::rawElement( 'span', $attribs, "\n$html\n" );
+ } else {
+ return Html::rawElement( 'div', $attribs, "\n$html\n" );
+ }
+ }
+
+ /**
+ * Construct the form fields from the Descriptor array
+ */
+ public function loadData() {
+ $fieldData = [];
+
+ foreach ( $this->mFlatFields as $fieldname => $field ) {
+ $request = $this->getRequest();
+ if ( $field->skipLoadData( $request ) ) {
+ continue;
+ } elseif ( !empty( $field->mParams['disabled'] ) ) {
+ $fieldData[$fieldname] = $field->getDefault();
+ } else {
+ $fieldData[$fieldname] = $field->loadDataFromRequest( $request );
+ }
+ }
+
+ # Filter data.
+ foreach ( $fieldData as $name => &$value ) {
+ $field = $this->mFlatFields[$name];
+ $value = $field->filter( $value, $this->mFlatFields );
+ }
+
+ $this->mFieldData = $fieldData;
+ }
+
+ /**
+ * Stop a reset button being shown for this form
+ *
+ * @param bool $suppressReset Set to false to re-enable the button again
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function suppressReset( $suppressReset = true ) {
+ $this->mShowReset = !$suppressReset;
+
+ return $this;
+ }
+
+ /**
+ * Overload this if you want to apply special filtration routines
+ * to the form as a whole, after it's submitted but before it's
+ * processed.
+ *
+ * @param array $data
+ *
+ * @return array
+ */
+ public function filterDataForSubmit( $data ) {
+ return $data;
+ }
+
+ /**
+ * Get a string to go in the "<legend>" of a section fieldset.
+ * Override this if you want something more complicated.
+ *
+ * @param string $key
+ *
+ * @return string
+ */
+ public function getLegend( $key ) {
+ return $this->msg( "{$this->mMessagePrefix}-$key" )->text();
+ }
+
+ /**
+ * Set the value for the action attribute of the form.
+ * When set to false (which is the default state), the set title is used.
+ *
+ * @since 1.19
+ *
+ * @param string|bool $action
+ *
+ * @return HTMLForm $this for chaining calls (since 1.20)
+ */
+ public function setAction( $action ) {
+ $this->mAction = $action;
+
+ return $this;
+ }
+
+ /**
+ * Get the value for the action attribute of the form.
+ *
+ * @since 1.22
+ *
+ * @return string
+ */
+ public function getAction() {
+ // If an action is alredy provided, return it
+ if ( $this->mAction !== false ) {
+ return $this->mAction;
+ }
+
+ $articlePath = $this->getConfig()->get( 'ArticlePath' );
+ // Check whether we are in GET mode and the ArticlePath contains a "?"
+ // meaning that getLocalURL() would return something like "index.php?title=...".
+ // As browser remove the query string before submitting GET forms,
+ // it means that the title would be lost. In such case use wfScript() instead
+ // and put title in an hidden field (see getHiddenFields()).
+ if ( strpos( $articlePath, '?' ) !== false && $this->getMethod() === 'get' ) {
+ return wfScript();
+ }
+
+ return $this->getTitle()->getLocalURL();
+ }
+
+ /**
+ * Set the value for the autocomplete attribute of the form.
+ * When set to false (which is the default state), the attribute get not set.
+ *
+ * @since 1.27
+ *
+ * @param string|bool $autocomplete
+ *
+ * @return HTMLForm $this for chaining calls
+ */
+ public function setAutocomplete( $autocomplete ) {
+ $this->mAutocomplete = $autocomplete;
+
+ return $this;
+ }
+
+ /**
+ * Turns a *-message parameter (which could be a MessageSpecifier, or a message name, or a
+ * name + parameters array) into a Message.
+ * @param mixed $value
+ * @return Message
+ */
+ protected function getMessage( $value ) {
+ return Message::newFromSpecifier( $value )->setContext( $this );
+ }
+
+ /**
+ * Whether this form, with its current fields, requires the user agent to have JavaScript enabled
+ * for the client-side HTML5 form validation to work correctly. If this function returns true, a
+ * 'novalidate' attribute will be added on the `<form>` element. It will be removed if the user
+ * agent has JavaScript support, in htmlform.js.
+ *
+ * @return bool
+ * @since 1.29
+ */
+ public function needsJSForHtml5FormValidation() {
+ foreach ( $this->mFlatFields as $fieldname => $field ) {
+ if ( $field->needsJSForHtml5FormValidation() ) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLFormElement.php b/www/wiki/includes/htmlform/HTMLFormElement.php
new file mode 100644
index 00000000..10db90cc
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLFormElement.php
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * Allows custom data specific to HTMLFormField to be set for OOjs UI forms. A matching JS widget
+ * (defined in htmlform.Element.js) picks up the extra config when constructed using OO.ui.infuse().
+ *
+ * Currently only supports passing 'hide-if' data.
+ */
+trait HTMLFormElement {
+
+ protected $hideIf = null;
+ protected $modules = null;
+
+ public function initializeHTMLFormElement( array $config = [] ) {
+ // Properties
+ $this->hideIf = isset( $config['hideIf'] ) ? $config['hideIf'] : null;
+ $this->modules = isset( $config['modules'] ) ? $config['modules'] : [];
+
+ // Initialization
+ if ( $this->hideIf ) {
+ $this->addClasses( [ 'mw-htmlform-hide-if' ] );
+ }
+ if ( $this->modules ) {
+ // JS code must be able to read this before infusing (before OOjs UI is even loaded),
+ // so we put this in a separate attribute (not with the rest of the config).
+ // And it's not needed anymore after infusing, so we don't put it in JS config at all.
+ $this->setAttributes( [ 'data-mw-modules' => implode( ',', $this->modules ) ] );
+ }
+ $this->registerConfigCallback( function ( &$config ) {
+ if ( $this->hideIf !== null ) {
+ $config['hideIf'] = $this->hideIf;
+ }
+ } );
+ }
+}
+
+class HTMLFormFieldLayout extends OOUI\FieldLayout {
+ use HTMLFormElement;
+
+ public function __construct( $fieldWidget, array $config = [] ) {
+ // Parent constructor
+ parent::__construct( $fieldWidget, $config );
+ // Traits
+ $this->initializeHTMLFormElement( $config );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.htmlform.FieldLayout';
+ }
+}
+
+class HTMLFormActionFieldLayout extends OOUI\ActionFieldLayout {
+ use HTMLFormElement;
+
+ public function __construct( $fieldWidget, $buttonWidget = false, array $config = [] ) {
+ // Parent constructor
+ parent::__construct( $fieldWidget, $buttonWidget, $config );
+ // Traits
+ $this->initializeHTMLFormElement( $config );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.htmlform.ActionFieldLayout';
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLFormField.php b/www/wiki/includes/htmlform/HTMLFormField.php
new file mode 100644
index 00000000..e642c2cd
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLFormField.php
@@ -0,0 +1,1199 @@
+<?php
+
+/**
+ * The parent class to generate form fields. Any field type should
+ * be a subclass of this.
+ */
+abstract class HTMLFormField {
+ public $mParams;
+
+ protected $mValidationCallback;
+ protected $mFilterCallback;
+ protected $mName;
+ protected $mDir;
+ protected $mLabel; # String label, as HTML. Set on construction.
+ protected $mID;
+ protected $mClass = '';
+ protected $mVFormClass = '';
+ protected $mHelpClass = false;
+ protected $mDefault;
+ protected $mOptions = false;
+ protected $mOptionsLabelsNotFromMessage = false;
+ protected $mHideIf = null;
+
+ /**
+ * @var bool If true will generate an empty div element with no label
+ * @since 1.22
+ */
+ protected $mShowEmptyLabels = true;
+
+ /**
+ * @var HTMLForm|null
+ */
+ public $mParent;
+
+ /**
+ * This function must be implemented to return the HTML to generate
+ * the input object itself. It should not implement the surrounding
+ * table cells/rows, or labels/help messages.
+ *
+ * @param string $value The value to set the input to; eg a default
+ * text for a text input.
+ *
+ * @return string Valid HTML.
+ */
+ abstract public function getInputHTML( $value );
+
+ /**
+ * Same as getInputHTML, but returns an OOUI object.
+ * Defaults to false, which getOOUI will interpret as "use the HTML version"
+ *
+ * @param string $value
+ * @return OOUI\Widget|false
+ */
+ public function getInputOOUI( $value ) {
+ return false;
+ }
+
+ /**
+ * True if this field type is able to display errors; false if validation errors need to be
+ * displayed in the main HTMLForm error area.
+ * @return bool
+ */
+ public function canDisplayErrors() {
+ return $this->hasVisibleOutput();
+ }
+
+ /**
+ * Get a translated interface message
+ *
+ * This is a wrapper around $this->mParent->msg() if $this->mParent is set
+ * and wfMessage() otherwise.
+ *
+ * Parameters are the same as wfMessage().
+ *
+ * @return Message
+ */
+ public function msg() {
+ $args = func_get_args();
+
+ if ( $this->mParent ) {
+ $callback = [ $this->mParent, 'msg' ];
+ } else {
+ $callback = 'wfMessage';
+ }
+
+ return call_user_func_array( $callback, $args );
+ }
+
+ /**
+ * If this field has a user-visible output or not. If not,
+ * it will not be rendered
+ *
+ * @return bool
+ */
+ public function hasVisibleOutput() {
+ return true;
+ }
+
+ /**
+ * Fetch a field value from $alldata for the closest field matching a given
+ * name.
+ *
+ * This is complex because it needs to handle array fields like the user
+ * would expect. The general algorithm is to look for $name as a sibling
+ * of $this, then a sibling of $this's parent, and so on. Keeping in mind
+ * that $name itself might be referencing an array.
+ *
+ * @param array $alldata
+ * @param string $name
+ * @return string
+ */
+ protected function getNearestFieldByName( $alldata, $name ) {
+ $tmp = $this->mName;
+ $thisKeys = [];
+ while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $tmp, $m ) ) {
+ array_unshift( $thisKeys, $m[2] );
+ $tmp = $m[1];
+ }
+ if ( substr( $tmp, 0, 2 ) == 'wp' &&
+ !array_key_exists( $tmp, $alldata ) &&
+ array_key_exists( substr( $tmp, 2 ), $alldata )
+ ) {
+ // Adjust for name mangling.
+ $tmp = substr( $tmp, 2 );
+ }
+ array_unshift( $thisKeys, $tmp );
+
+ $tmp = $name;
+ $nameKeys = [];
+ while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $tmp, $m ) ) {
+ array_unshift( $nameKeys, $m[2] );
+ $tmp = $m[1];
+ }
+ array_unshift( $nameKeys, $tmp );
+
+ $testValue = '';
+ for ( $i = count( $thisKeys ) - 1; $i >= 0; $i-- ) {
+ $keys = array_merge( array_slice( $thisKeys, 0, $i ), $nameKeys );
+ $data = $alldata;
+ while ( $keys ) {
+ $key = array_shift( $keys );
+ if ( !is_array( $data ) || !array_key_exists( $key, $data ) ) {
+ continue 2;
+ }
+ $data = $data[$key];
+ }
+ $testValue = (string)$data;
+ break;
+ }
+
+ return $testValue;
+ }
+
+ /**
+ * Helper function for isHidden to handle recursive data structures.
+ *
+ * @param array $alldata
+ * @param array $params
+ * @return bool
+ * @throws MWException
+ */
+ protected function isHiddenRecurse( array $alldata, array $params ) {
+ $origParams = $params;
+ $op = array_shift( $params );
+
+ try {
+ switch ( $op ) {
+ case 'AND':
+ foreach ( $params as $i => $p ) {
+ if ( !is_array( $p ) ) {
+ throw new MWException(
+ "Expected array, found " . gettype( $p ) . " at index $i"
+ );
+ }
+ if ( !$this->isHiddenRecurse( $alldata, $p ) ) {
+ return false;
+ }
+ }
+ return true;
+
+ case 'OR':
+ foreach ( $params as $i => $p ) {
+ if ( !is_array( $p ) ) {
+ throw new MWException(
+ "Expected array, found " . gettype( $p ) . " at index $i"
+ );
+ }
+ if ( $this->isHiddenRecurse( $alldata, $p ) ) {
+ return true;
+ }
+ }
+ return false;
+
+ case 'NAND':
+ foreach ( $params as $i => $p ) {
+ if ( !is_array( $p ) ) {
+ throw new MWException(
+ "Expected array, found " . gettype( $p ) . " at index $i"
+ );
+ }
+ if ( !$this->isHiddenRecurse( $alldata, $p ) ) {
+ return true;
+ }
+ }
+ return false;
+
+ case 'NOR':
+ foreach ( $params as $i => $p ) {
+ if ( !is_array( $p ) ) {
+ throw new MWException(
+ "Expected array, found " . gettype( $p ) . " at index $i"
+ );
+ }
+ if ( $this->isHiddenRecurse( $alldata, $p ) ) {
+ return false;
+ }
+ }
+ return true;
+
+ case 'NOT':
+ if ( count( $params ) !== 1 ) {
+ throw new MWException( "NOT takes exactly one parameter" );
+ }
+ $p = $params[0];
+ if ( !is_array( $p ) ) {
+ throw new MWException(
+ "Expected array, found " . gettype( $p ) . " at index 0"
+ );
+ }
+ return !$this->isHiddenRecurse( $alldata, $p );
+
+ case '===':
+ case '!==':
+ if ( count( $params ) !== 2 ) {
+ throw new MWException( "$op takes exactly two parameters" );
+ }
+ list( $field, $value ) = $params;
+ if ( !is_string( $field ) || !is_string( $value ) ) {
+ throw new MWException( "Parameters for $op must be strings" );
+ }
+ $testValue = $this->getNearestFieldByName( $alldata, $field );
+ switch ( $op ) {
+ case '===':
+ return ( $value === $testValue );
+ case '!==':
+ return ( $value !== $testValue );
+ }
+
+ default:
+ throw new MWException( "Unknown operation" );
+ }
+ } catch ( Exception $ex ) {
+ throw new MWException(
+ "Invalid hide-if specification for $this->mName: " .
+ $ex->getMessage() . " in " . var_export( $origParams, true ),
+ 0, $ex
+ );
+ }
+ }
+
+ /**
+ * Test whether this field is supposed to be hidden, based on the values of
+ * the other form fields.
+ *
+ * @since 1.23
+ * @param array $alldata The data collected from the form
+ * @return bool
+ */
+ public function isHidden( $alldata ) {
+ if ( !$this->mHideIf ) {
+ return false;
+ }
+
+ return $this->isHiddenRecurse( $alldata, $this->mHideIf );
+ }
+
+ /**
+ * Override this function if the control can somehow trigger a form
+ * submission that shouldn't actually submit the HTMLForm.
+ *
+ * @since 1.23
+ * @param string|array $value The value the field was submitted with
+ * @param array $alldata The data collected from the form
+ *
+ * @return bool True to cancel the submission
+ */
+ public function cancelSubmit( $value, $alldata ) {
+ return false;
+ }
+
+ /**
+ * Override this function to add specific validation checks on the
+ * field input. Don't forget to call parent::validate() to ensure
+ * that the user-defined callback mValidationCallback is still run
+ *
+ * @param string|array $value The value the field was submitted with
+ * @param array $alldata The data collected from the form
+ *
+ * @return bool|string|Message True on success, or String/Message error to display, or
+ * false to fail validation without displaying an error.
+ */
+ public function validate( $value, $alldata ) {
+ if ( $this->isHidden( $alldata ) ) {
+ return true;
+ }
+
+ if ( isset( $this->mParams['required'] )
+ && $this->mParams['required'] !== false
+ && $value === ''
+ ) {
+ return $this->msg( 'htmlform-required' );
+ }
+
+ if ( isset( $this->mValidationCallback ) ) {
+ return call_user_func( $this->mValidationCallback, $value, $alldata, $this->mParent );
+ }
+
+ return true;
+ }
+
+ public function filter( $value, $alldata ) {
+ if ( isset( $this->mFilterCallback ) ) {
+ $value = call_user_func( $this->mFilterCallback, $value, $alldata, $this->mParent );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Should this field have a label, or is there no input element with the
+ * appropriate id for the label to point to?
+ *
+ * @return bool True to output a label, false to suppress
+ */
+ protected function needsLabel() {
+ return true;
+ }
+
+ /**
+ * Tell the field whether to generate a separate label element if its label
+ * is blank.
+ *
+ * @since 1.22
+ *
+ * @param bool $show Set to false to not generate a label.
+ * @return void
+ */
+ public function setShowEmptyLabel( $show ) {
+ $this->mShowEmptyLabels = $show;
+ }
+
+ /**
+ * Can we assume that the request is an attempt to submit a HTMLForm, as opposed to an attempt to
+ * just view it? This can't normally be distinguished for e.g. checkboxes.
+ *
+ * Returns true if the request has a field for a CSRF token (wpEditToken) or a form identifier
+ * (wpFormIdentifier).
+ *
+ * @param WebRequest $request
+ * @return bool
+ */
+ protected function isSubmitAttempt( WebRequest $request ) {
+ return $request->getCheck( 'wpEditToken' ) || $request->getCheck( 'wpFormIdentifier' );
+ }
+
+ /**
+ * Get the value that this input has been set to from a posted form,
+ * or the input's default value if it has not been set.
+ *
+ * @param WebRequest $request
+ * @return string The value
+ */
+ public function loadDataFromRequest( $request ) {
+ if ( $request->getCheck( $this->mName ) ) {
+ return $request->getText( $this->mName );
+ } else {
+ return $this->getDefault();
+ }
+ }
+
+ /**
+ * Initialise the object
+ *
+ * @param array $params Associative Array. See HTMLForm doc for syntax.
+ *
+ * @since 1.22 The 'label' attribute no longer accepts raw HTML, use 'label-raw' instead
+ * @throws MWException
+ */
+ public function __construct( $params ) {
+ $this->mParams = $params;
+
+ if ( isset( $params['parent'] ) && $params['parent'] instanceof HTMLForm ) {
+ $this->mParent = $params['parent'];
+ }
+
+ # Generate the label from a message, if possible
+ if ( isset( $params['label-message'] ) ) {
+ $this->mLabel = $this->getMessage( $params['label-message'] )->parse();
+ } elseif ( isset( $params['label'] ) ) {
+ if ( $params['label'] === '&#160;' ) {
+ // Apparently some things set &nbsp directly and in an odd format
+ $this->mLabel = '&#160;';
+ } else {
+ $this->mLabel = htmlspecialchars( $params['label'] );
+ }
+ } elseif ( isset( $params['label-raw'] ) ) {
+ $this->mLabel = $params['label-raw'];
+ }
+
+ $this->mName = "wp{$params['fieldname']}";
+ if ( isset( $params['name'] ) ) {
+ $this->mName = $params['name'];
+ }
+
+ if ( isset( $params['dir'] ) ) {
+ $this->mDir = $params['dir'];
+ }
+
+ $validName = urlencode( $this->mName );
+ $validName = str_replace( [ '%5B', '%5D' ], [ '[', ']' ], $validName );
+ if ( $this->mName != $validName && !isset( $params['nodata'] ) ) {
+ throw new MWException( "Invalid name '{$this->mName}' passed to " . __METHOD__ );
+ }
+
+ $this->mID = "mw-input-{$this->mName}";
+
+ if ( isset( $params['default'] ) ) {
+ $this->mDefault = $params['default'];
+ }
+
+ if ( isset( $params['id'] ) ) {
+ $id = $params['id'];
+ $validId = urlencode( $id );
+
+ if ( $id != $validId ) {
+ throw new MWException( "Invalid id '$id' passed to " . __METHOD__ );
+ }
+
+ $this->mID = $id;
+ }
+
+ if ( isset( $params['cssclass'] ) ) {
+ $this->mClass = $params['cssclass'];
+ }
+
+ if ( isset( $params['csshelpclass'] ) ) {
+ $this->mHelpClass = $params['csshelpclass'];
+ }
+
+ if ( isset( $params['validation-callback'] ) ) {
+ $this->mValidationCallback = $params['validation-callback'];
+ }
+
+ if ( isset( $params['filter-callback'] ) ) {
+ $this->mFilterCallback = $params['filter-callback'];
+ }
+
+ if ( isset( $params['hidelabel'] ) ) {
+ $this->mShowEmptyLabels = false;
+ }
+
+ if ( isset( $params['hide-if'] ) ) {
+ $this->mHideIf = $params['hide-if'];
+ }
+ }
+
+ /**
+ * Get the complete table row for the input, including help text,
+ * labels, and whatever.
+ *
+ * @param string $value The value to set the input to.
+ *
+ * @return string Complete HTML table row.
+ */
+ public function getTableRow( $value ) {
+ list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
+ $inputHtml = $this->getInputHTML( $value );
+ $fieldType = static::class;
+ $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
+ $cellAttributes = [];
+ $rowAttributes = [];
+ $rowClasses = '';
+
+ if ( !empty( $this->mParams['vertical-label'] ) ) {
+ $cellAttributes['colspan'] = 2;
+ $verticalLabel = true;
+ } else {
+ $verticalLabel = false;
+ }
+
+ $label = $this->getLabelHtml( $cellAttributes );
+
+ $field = Html::rawElement(
+ 'td',
+ [ 'class' => 'mw-input' ] + $cellAttributes,
+ $inputHtml . "\n$errors"
+ );
+
+ if ( $this->mHideIf ) {
+ $rowAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
+ $rowClasses .= ' mw-htmlform-hide-if';
+ }
+
+ if ( $verticalLabel ) {
+ $html = Html::rawElement( 'tr',
+ $rowAttributes + [ 'class' => "mw-htmlform-vertical-label $rowClasses" ], $label );
+ $html .= Html::rawElement( 'tr',
+ $rowAttributes + [
+ 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
+ ],
+ $field );
+ } else {
+ $html =
+ Html::rawElement( 'tr',
+ $rowAttributes + [
+ 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
+ ],
+ $label . $field );
+ }
+
+ return $html . $helptext;
+ }
+
+ /**
+ * Get the complete div for the input, including help text,
+ * labels, and whatever.
+ * @since 1.20
+ *
+ * @param string $value The value to set the input to.
+ *
+ * @return string Complete HTML table row.
+ */
+ public function getDiv( $value ) {
+ list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
+ $inputHtml = $this->getInputHTML( $value );
+ $fieldType = static::class;
+ $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() );
+ $cellAttributes = [];
+ $label = $this->getLabelHtml( $cellAttributes );
+
+ $outerDivClass = [
+ 'mw-input',
+ 'mw-htmlform-nolabel' => ( $label === '' )
+ ];
+
+ $horizontalLabel = isset( $this->mParams['horizontal-label'] )
+ ? $this->mParams['horizontal-label'] : false;
+
+ if ( $horizontalLabel ) {
+ $field = '&#160;' . $inputHtml . "\n$errors";
+ } else {
+ $field = Html::rawElement(
+ 'div',
+ [ 'class' => $outerDivClass ] + $cellAttributes,
+ $inputHtml . "\n$errors"
+ );
+ }
+ $divCssClasses = [ "mw-htmlform-field-$fieldType",
+ $this->mClass, $this->mVFormClass, $errorClass ];
+
+ $wrapperAttributes = [
+ 'class' => $divCssClasses,
+ ];
+ if ( $this->mHideIf ) {
+ $wrapperAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
+ $wrapperAttributes['class'][] = ' mw-htmlform-hide-if';
+ }
+ $html = Html::rawElement( 'div', $wrapperAttributes, $label . $field );
+ $html .= $helptext;
+
+ return $html;
+ }
+
+ /**
+ * Get the OOUI version of the div. Falls back to getDiv by default.
+ * @since 1.26
+ *
+ * @param string $value The value to set the input to.
+ *
+ * @return OOUI\FieldLayout|OOUI\ActionFieldLayout
+ */
+ public function getOOUI( $value ) {
+ $inputField = $this->getInputOOUI( $value );
+
+ if ( !$inputField ) {
+ // This field doesn't have an OOUI implementation yet at all. Fall back to getDiv() to
+ // generate the whole field, label and errors and all, then wrap it in a Widget.
+ // It might look weird, but it'll work OK.
+ return $this->getFieldLayoutOOUI(
+ new OOUI\Widget( [ 'content' => new OOUI\HtmlSnippet( $this->getDiv( $value ) ) ] ),
+ [ 'infusable' => false, 'align' => 'top' ]
+ );
+ }
+
+ $infusable = true;
+ if ( is_string( $inputField ) ) {
+ // We have an OOUI implementation, but it's not proper, and we got a load of HTML.
+ // Cheat a little and wrap it in a widget. It won't be infusable, though, since client-side
+ // JavaScript doesn't know how to rebuilt the contents.
+ $inputField = new OOUI\Widget( [ 'content' => new OOUI\HtmlSnippet( $inputField ) ] );
+ $infusable = false;
+ }
+
+ $fieldType = static::class;
+ $help = $this->getHelpText();
+ $errors = $this->getErrorsRaw( $value );
+ foreach ( $errors as &$error ) {
+ $error = new OOUI\HtmlSnippet( $error );
+ }
+
+ $notices = $this->getNotices();
+ foreach ( $notices as &$notice ) {
+ $notice = new OOUI\HtmlSnippet( $notice );
+ }
+
+ $config = [
+ 'classes' => [ "mw-htmlform-field-$fieldType", $this->mClass ],
+ 'align' => $this->getLabelAlignOOUI(),
+ 'help' => ( $help !== null && $help !== '' ) ? new OOUI\HtmlSnippet( $help ) : null,
+ 'errors' => $errors,
+ 'notices' => $notices,
+ 'infusable' => $infusable,
+ ];
+
+ $preloadModules = false;
+
+ if ( $infusable && $this->shouldInfuseOOUI() ) {
+ $preloadModules = true;
+ $config['classes'][] = 'mw-htmlform-field-autoinfuse';
+ }
+
+ // the element could specify, that the label doesn't need to be added
+ $label = $this->getLabel();
+ if ( $label ) {
+ $config['label'] = new OOUI\HtmlSnippet( $label );
+ }
+
+ if ( $this->mHideIf ) {
+ $preloadModules = true;
+ $config['hideIf'] = $this->mHideIf;
+ }
+
+ $config['modules'] = $this->getOOUIModules();
+
+ if ( $preloadModules ) {
+ $this->mParent->getOutput()->addModules( 'mediawiki.htmlform.ooui' );
+ $this->mParent->getOutput()->addModules( $this->getOOUIModules() );
+ }
+
+ return $this->getFieldLayoutOOUI( $inputField, $config );
+ }
+
+ /**
+ * Get label alignment when generating field for OOUI.
+ * @return string 'left', 'right', 'top' or 'inline'
+ */
+ protected function getLabelAlignOOUI() {
+ return 'top';
+ }
+
+ /**
+ * Get a FieldLayout (or subclass thereof) to wrap this field in when using OOUI output.
+ * @param string $inputField
+ * @param array $config
+ * @return OOUI\FieldLayout|OOUI\ActionFieldLayout
+ */
+ protected function getFieldLayoutOOUI( $inputField, $config ) {
+ if ( isset( $this->mClassWithButton ) ) {
+ $buttonWidget = $this->mClassWithButton->getInputOOUI( '' );
+ return new HTMLFormActionFieldLayout( $inputField, $buttonWidget, $config );
+ }
+ return new HTMLFormFieldLayout( $inputField, $config );
+ }
+
+ /**
+ * Whether the field should be automatically infused. Note that all OOjs UI HTMLForm fields are
+ * infusable (you can call OO.ui.infuse() on them), but not all are infused by default, since
+ * there is no benefit in doing it e.g. for buttons and it's a small performance hit on page load.
+ *
+ * @return bool
+ */
+ protected function shouldInfuseOOUI() {
+ // Always infuse fields with help text, since the interface for it is nicer with JS
+ return $this->getHelpText() !== null;
+ }
+
+ /**
+ * Get the list of extra ResourceLoader modules which must be loaded client-side before it's
+ * possible to infuse this field's OOjs UI widget.
+ *
+ * @return string[]
+ */
+ protected function getOOUIModules() {
+ return [];
+ }
+
+ /**
+ * Get the complete raw fields for the input, including help text,
+ * labels, and whatever.
+ * @since 1.20
+ *
+ * @param string $value The value to set the input to.
+ *
+ * @return string Complete HTML table row.
+ */
+ public function getRaw( $value ) {
+ list( $errors, ) = $this->getErrorsAndErrorClass( $value );
+ $inputHtml = $this->getInputHTML( $value );
+ $helptext = $this->getHelpTextHtmlRaw( $this->getHelpText() );
+ $cellAttributes = [];
+ $label = $this->getLabelHtml( $cellAttributes );
+
+ $html = "\n$errors";
+ $html .= $label;
+ $html .= $inputHtml;
+ $html .= $helptext;
+
+ return $html;
+ }
+
+ /**
+ * Get the complete field for the input, including help text,
+ * labels, and whatever. Fall back from 'vform' to 'div' when not overridden.
+ *
+ * @since 1.25
+ * @param string $value The value to set the input to.
+ * @return string Complete HTML field.
+ */
+ public function getVForm( $value ) {
+ // Ewwww
+ $this->mVFormClass = ' mw-ui-vform-field';
+ return $this->getDiv( $value );
+ }
+
+ /**
+ * Get the complete field as an inline element.
+ * @since 1.25
+ * @param string $value The value to set the input to.
+ * @return string Complete HTML inline element
+ */
+ public function getInline( $value ) {
+ list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
+ $inputHtml = $this->getInputHTML( $value );
+ $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() );
+ $cellAttributes = [];
+ $label = $this->getLabelHtml( $cellAttributes );
+
+ $html = "\n" . $errors .
+ $label . '&#160;' .
+ $inputHtml .
+ $helptext;
+
+ return $html;
+ }
+
+ /**
+ * Generate help text HTML in table format
+ * @since 1.20
+ *
+ * @param string|null $helptext
+ * @return string
+ */
+ public function getHelpTextHtmlTable( $helptext ) {
+ if ( is_null( $helptext ) ) {
+ return '';
+ }
+
+ $rowAttributes = [];
+ if ( $this->mHideIf ) {
+ $rowAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
+ $rowAttributes['class'] = 'mw-htmlform-hide-if';
+ }
+
+ $tdClasses = [ 'htmlform-tip' ];
+ if ( $this->mHelpClass !== false ) {
+ $tdClasses[] = $this->mHelpClass;
+ }
+ $row = Html::rawElement( 'td', [ 'colspan' => 2, 'class' => $tdClasses ], $helptext );
+ $row = Html::rawElement( 'tr', $rowAttributes, $row );
+
+ return $row;
+ }
+
+ /**
+ * Generate help text HTML in div format
+ * @since 1.20
+ *
+ * @param string|null $helptext
+ *
+ * @return string
+ */
+ public function getHelpTextHtmlDiv( $helptext ) {
+ if ( is_null( $helptext ) ) {
+ return '';
+ }
+
+ $wrapperAttributes = [
+ 'class' => 'htmlform-tip',
+ ];
+ if ( $this->mHelpClass !== false ) {
+ $wrapperAttributes['class'] .= " {$this->mHelpClass}";
+ }
+ if ( $this->mHideIf ) {
+ $wrapperAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
+ $wrapperAttributes['class'] .= ' mw-htmlform-hide-if';
+ }
+ $div = Html::rawElement( 'div', $wrapperAttributes, $helptext );
+
+ return $div;
+ }
+
+ /**
+ * Generate help text HTML formatted for raw output
+ * @since 1.20
+ *
+ * @param string|null $helptext
+ * @return string
+ */
+ public function getHelpTextHtmlRaw( $helptext ) {
+ return $this->getHelpTextHtmlDiv( $helptext );
+ }
+
+ /**
+ * Determine the help text to display
+ * @since 1.20
+ * @return string|null HTML
+ */
+ public function getHelpText() {
+ $helptext = null;
+
+ if ( isset( $this->mParams['help-message'] ) ) {
+ $this->mParams['help-messages'] = [ $this->mParams['help-message'] ];
+ }
+
+ if ( isset( $this->mParams['help-messages'] ) ) {
+ foreach ( $this->mParams['help-messages'] as $msg ) {
+ $msg = $this->getMessage( $msg );
+
+ if ( $msg->exists() ) {
+ if ( is_null( $helptext ) ) {
+ $helptext = '';
+ } else {
+ $helptext .= $this->msg( 'word-separator' )->escaped(); // some space
+ }
+ $helptext .= $msg->parse(); // Append message
+ }
+ }
+ } elseif ( isset( $this->mParams['help'] ) ) {
+ $helptext = $this->mParams['help'];
+ }
+
+ return $helptext;
+ }
+
+ /**
+ * Determine form errors to display and their classes
+ * @since 1.20
+ *
+ * @param string $value The value of the input
+ * @return array array( $errors, $errorClass )
+ */
+ public function getErrorsAndErrorClass( $value ) {
+ $errors = $this->validate( $value, $this->mParent->mFieldData );
+
+ if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
+ $errors = '';
+ $errorClass = '';
+ } else {
+ $errors = self::formatErrors( $errors );
+ $errorClass = 'mw-htmlform-invalid-input';
+ }
+
+ return [ $errors, $errorClass ];
+ }
+
+ /**
+ * Determine form errors to display, returning them in an array.
+ *
+ * @since 1.26
+ * @param string $value The value of the input
+ * @return string[] Array of error HTML strings
+ */
+ public function getErrorsRaw( $value ) {
+ $errors = $this->validate( $value, $this->mParent->mFieldData );
+
+ if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
+ $errors = [];
+ }
+
+ if ( !is_array( $errors ) ) {
+ $errors = [ $errors ];
+ }
+ foreach ( $errors as &$error ) {
+ if ( $error instanceof Message ) {
+ $error = $error->parse();
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Determine notices to display for the field.
+ *
+ * @since 1.28
+ * @return string[]
+ */
+ public function getNotices() {
+ $notices = [];
+
+ if ( isset( $this->mParams['notice-message'] ) ) {
+ $notices[] = $this->getMessage( $this->mParams['notice-message'] )->parse();
+ }
+
+ if ( isset( $this->mParams['notice-messages'] ) ) {
+ foreach ( $this->mParams['notice-messages'] as $msg ) {
+ $notices[] = $this->getMessage( $msg )->parse();
+ }
+ } elseif ( isset( $this->mParams['notice'] ) ) {
+ $notices[] = $this->mParams['notice'];
+ }
+
+ return $notices;
+ }
+
+ /**
+ * @return string HTML
+ */
+ public function getLabel() {
+ return is_null( $this->mLabel ) ? '' : $this->mLabel;
+ }
+
+ public function getLabelHtml( $cellAttributes = [] ) {
+ # Don't output a for= attribute for labels with no associated input.
+ # Kind of hacky here, possibly we don't want these to be <label>s at all.
+ $for = [];
+
+ if ( $this->needsLabel() ) {
+ $for['for'] = $this->mID;
+ }
+
+ $labelValue = trim( $this->getLabel() );
+ $hasLabel = false;
+ if ( $labelValue !== '&#160;' && $labelValue !== '' ) {
+ $hasLabel = true;
+ }
+
+ $displayFormat = $this->mParent->getDisplayFormat();
+ $html = '';
+ $horizontalLabel = isset( $this->mParams['horizontal-label'] )
+ ? $this->mParams['horizontal-label'] : false;
+
+ if ( $displayFormat === 'table' ) {
+ $html =
+ Html::rawElement( 'td',
+ [ 'class' => 'mw-label' ] + $cellAttributes,
+ Html::rawElement( 'label', $for, $labelValue ) );
+ } elseif ( $hasLabel || $this->mShowEmptyLabels ) {
+ if ( $displayFormat === 'div' && !$horizontalLabel ) {
+ $html =
+ Html::rawElement( 'div',
+ [ 'class' => 'mw-label' ] + $cellAttributes,
+ Html::rawElement( 'label', $for, $labelValue ) );
+ } else {
+ $html = Html::rawElement( 'label', $for, $labelValue );
+ }
+ }
+
+ return $html;
+ }
+
+ public function getDefault() {
+ if ( isset( $this->mDefault ) ) {
+ return $this->mDefault;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns the attributes required for the tooltip and accesskey, for Html::element() etc.
+ *
+ * @return array Attributes
+ */
+ public function getTooltipAndAccessKey() {
+ if ( empty( $this->mParams['tooltip'] ) ) {
+ return [];
+ }
+
+ return Linker::tooltipAndAccesskeyAttribs( $this->mParams['tooltip'] );
+ }
+
+ /**
+ * Returns the attributes required for the tooltip and accesskey, for OOUI widgets' config.
+ *
+ * @return array Attributes
+ */
+ public function getTooltipAndAccessKeyOOUI() {
+ if ( empty( $this->mParams['tooltip'] ) ) {
+ return [];
+ }
+
+ return [
+ 'title' => Linker::titleAttrib( $this->mParams['tooltip'] ),
+ 'accessKey' => Linker::accesskey( $this->mParams['tooltip'] ),
+ ];
+ }
+
+ /**
+ * Returns the given attributes from the parameters
+ *
+ * @param array $list List of attributes to get
+ * @return array Attributes
+ */
+ public function getAttributes( array $list ) {
+ static $boolAttribs = [ 'disabled', 'required', 'autofocus', 'multiple', 'readonly' ];
+
+ $ret = [];
+ foreach ( $list as $key ) {
+ if ( in_array( $key, $boolAttribs ) ) {
+ if ( !empty( $this->mParams[$key] ) ) {
+ $ret[$key] = '';
+ }
+ } elseif ( isset( $this->mParams[$key] ) ) {
+ $ret[$key] = $this->mParams[$key];
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Given an array of msg-key => value mappings, returns an array with keys
+ * being the message texts. It also forces values to strings.
+ *
+ * @param array $options
+ * @return array
+ */
+ private function lookupOptionsKeys( $options ) {
+ $ret = [];
+ foreach ( $options as $key => $value ) {
+ $key = $this->msg( $key )->plain();
+ $ret[$key] = is_array( $value )
+ ? $this->lookupOptionsKeys( $value )
+ : strval( $value );
+ }
+ return $ret;
+ }
+
+ /**
+ * Recursively forces values in an array to strings, because issues arise
+ * with integer 0 as a value.
+ *
+ * @param array $array
+ * @return array|string
+ */
+ public static function forceToStringRecursive( $array ) {
+ if ( is_array( $array ) ) {
+ return array_map( [ __CLASS__, 'forceToStringRecursive' ], $array );
+ } else {
+ return strval( $array );
+ }
+ }
+
+ /**
+ * Fetch the array of options from the field's parameters. In order, this
+ * checks 'options-messages', 'options', then 'options-message'.
+ *
+ * @return array|null Options array
+ */
+ public function getOptions() {
+ if ( $this->mOptions === false ) {
+ if ( array_key_exists( 'options-messages', $this->mParams ) ) {
+ $this->mOptions = $this->lookupOptionsKeys( $this->mParams['options-messages'] );
+ } elseif ( array_key_exists( 'options', $this->mParams ) ) {
+ $this->mOptionsLabelsNotFromMessage = true;
+ $this->mOptions = self::forceToStringRecursive( $this->mParams['options'] );
+ } elseif ( array_key_exists( 'options-message', $this->mParams ) ) {
+ $message = $this->getMessage( $this->mParams['options-message'] )->inContentLanguage()->plain();
+ $this->mOptions = Xml::listDropDownOptions( $message );
+ } else {
+ $this->mOptions = null;
+ }
+ }
+
+ return $this->mOptions;
+ }
+
+ /**
+ * Get options and make them into arrays suitable for OOUI.
+ * @return array Options for inclusion in a select or whatever.
+ */
+ public function getOptionsOOUI() {
+ $oldoptions = $this->getOptions();
+
+ if ( $oldoptions === null ) {
+ return null;
+ }
+
+ return Xml::listDropDownOptionsOoui( $oldoptions );
+ }
+
+ /**
+ * flatten an array of options to a single array, for instance,
+ * a set of "<options>" inside "<optgroups>".
+ *
+ * @param array $options Associative Array with values either Strings or Arrays
+ * @return array Flattened input
+ */
+ public static function flattenOptions( $options ) {
+ $flatOpts = [];
+
+ foreach ( $options as $value ) {
+ if ( is_array( $value ) ) {
+ $flatOpts = array_merge( $flatOpts, self::flattenOptions( $value ) );
+ } else {
+ $flatOpts[] = $value;
+ }
+ }
+
+ return $flatOpts;
+ }
+
+ /**
+ * Formats one or more errors as accepted by field validation-callback.
+ *
+ * @param string|Message|array $errors Array of strings or Message instances
+ * @return string HTML
+ * @since 1.18
+ */
+ protected static function formatErrors( $errors ) {
+ // Note: If you change the logic in this method, change
+ // htmlform.Checker.js to match.
+
+ if ( is_array( $errors ) && count( $errors ) === 1 ) {
+ $errors = array_shift( $errors );
+ }
+
+ if ( is_array( $errors ) ) {
+ $lines = [];
+ foreach ( $errors as $error ) {
+ if ( $error instanceof Message ) {
+ $lines[] = Html::rawElement( 'li', [], $error->parse() );
+ } else {
+ $lines[] = Html::rawElement( 'li', [], $error );
+ }
+ }
+
+ return Html::rawElement( 'ul', [ 'class' => 'error' ], implode( "\n", $lines ) );
+ } else {
+ if ( $errors instanceof Message ) {
+ $errors = $errors->parse();
+ }
+
+ return Html::rawElement( 'span', [ 'class' => 'error' ], $errors );
+ }
+ }
+
+ /**
+ * Turns a *-message parameter (which could be a MessageSpecifier, or a message name, or a
+ * name + parameters array) into a Message.
+ * @param mixed $value
+ * @return Message
+ */
+ protected function getMessage( $value ) {
+ $message = Message::newFromSpecifier( $value );
+
+ if ( $this->mParent ) {
+ $message->setContext( $this->mParent );
+ }
+
+ return $message;
+ }
+
+ /**
+ * Skip this field when collecting data.
+ * @param WebRequest $request
+ * @return bool
+ * @since 1.27
+ */
+ public function skipLoadData( $request ) {
+ return !empty( $this->mParams['nodata'] );
+ }
+
+ /**
+ * Whether this field requires the user agent to have JavaScript enabled for the client-side HTML5
+ * form validation to work correctly.
+ *
+ * @return bool
+ * @since 1.29
+ */
+ public function needsJSForHtml5FormValidation() {
+ if ( $this->mHideIf ) {
+ // This is probably more restrictive than it needs to be, but better safe than sorry
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLFormFieldCloner.php b/www/wiki/includes/htmlform/HTMLFormFieldCloner.php
new file mode 100644
index 00000000..ec1bd842
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLFormFieldCloner.php
@@ -0,0 +1,380 @@
+<?php
+
+/**
+ * A container for HTMLFormFields that allows for multiple copies of the set of
+ * fields to be displayed to and entered by the user.
+ *
+ * Recognized parameters, besides the general ones, include:
+ * fields - HTMLFormField descriptors for the subfields this cloner manages.
+ * The format is just like for the HTMLForm. A field with key 'delete' is
+ * special: it must have type = submit and will serve to delete the group
+ * of fields.
+ * required - If specified, at least one group of fields must be submitted.
+ * format - HTMLForm display format to use when displaying the subfields:
+ * 'table', 'div', or 'raw'.
+ * row-legend - If non-empty, each group of subfields will be enclosed in a
+ * fieldset. The value is the name of a message key to use as the legend.
+ * create-button-message - Message to use as the text of the button to
+ * add an additional group of fields.
+ * delete-button-message - Message to use as the text of automatically-
+ * generated 'delete' button. Ignored if 'delete' is included in 'fields'.
+ *
+ * In the generated HTML, the subfields will be named along the lines of
+ * "clonerName[index][fieldname]", with ids "clonerId--index--fieldid". 'index'
+ * may be a number or an arbitrary string, and may likely change when the page
+ * is resubmitted. Cloners may be nested, resulting in field names along the
+ * lines of "cloner1Name[index1][cloner2Name][index2][fieldname]" and
+ * corresponding ids.
+ *
+ * Use of cloner may result in submissions of the page that are not submissions
+ * of the HTMLForm, when non-JavaScript clients use the create or remove buttons.
+ *
+ * The result is an array, with values being arrays mapping subfield names to
+ * their values. On non-HTMLForm-submission page loads, there may also be
+ * additional (string) keys present with other types of values.
+ *
+ * @since 1.23
+ */
+class HTMLFormFieldCloner extends HTMLFormField {
+ private static $counter = 0;
+
+ /**
+ * @var string String uniquely identifying this cloner instance and
+ * unlikely to exist otherwise in the generated HTML, while still being
+ * valid as part of an HTML id.
+ */
+ protected $uniqueId;
+
+ public function __construct( $params ) {
+ $this->uniqueId = get_class( $this ) . ++self::$counter . 'x';
+ parent::__construct( $params );
+
+ if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
+ throw new MWException( 'HTMLFormFieldCloner called without any fields' );
+ }
+
+ // Make sure the delete button, if explicitly specified, is sane
+ if ( isset( $this->mParams['fields']['delete'] ) ) {
+ $class = 'mw-htmlform-cloner-delete-button';
+ $info = $this->mParams['fields']['delete'] + [
+ 'cssclass' => $class
+ ];
+ unset( $info['name'], $info['class'] );
+
+ if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
+ throw new MWException(
+ 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
+ );
+ }
+
+ if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
+ $info['cssclass'] .= " $class";
+ }
+
+ $this->mParams['fields']['delete'] = $info;
+ }
+ }
+
+ /**
+ * Create the HTMLFormFields that go inside this element, using the
+ * specified key.
+ *
+ * @param string $key Array key under which these fields should be named
+ * @return HTMLFormField[]
+ */
+ protected function createFieldsForKey( $key ) {
+ $fields = [];
+ foreach ( $this->mParams['fields'] as $fieldname => $info ) {
+ $name = "{$this->mName}[$key][$fieldname]";
+ if ( isset( $info['name'] ) ) {
+ $info['name'] = "{$this->mName}[$key][{$info['name']}]";
+ } else {
+ $info['name'] = $name;
+ }
+ if ( isset( $info['id'] ) ) {
+ $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--{$info['id']}" );
+ } else {
+ $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--$fieldname" );
+ }
+ $field = HTMLForm::loadInputFromParameters( $name, $info, $this->mParent );
+ $fields[$fieldname] = $field;
+ }
+ return $fields;
+ }
+
+ /**
+ * Re-key the specified values array to match the names applied by
+ * createFieldsForKey().
+ *
+ * @param string $key Array key under which these fields should be named
+ * @param array $values Values array from the request
+ * @return array
+ */
+ protected function rekeyValuesArray( $key, $values ) {
+ $data = [];
+ foreach ( $values as $fieldname => $value ) {
+ $name = "{$this->mName}[$key][$fieldname]";
+ $data[$name] = $value;
+ }
+ return $data;
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+
+ public function loadDataFromRequest( $request ) {
+ // It's possible that this might be posted with no fields. Detect that
+ // by looking for an edit token.
+ if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
+ return $this->getDefault();
+ }
+
+ $values = $request->getArray( $this->mName );
+ if ( $values === null ) {
+ $values = [];
+ }
+
+ $ret = [];
+ foreach ( $values as $key => $value ) {
+ if ( $key === 'create' || isset( $value['delete'] ) ) {
+ $ret['nonjs'] = 1;
+ continue;
+ }
+
+ // Add back in $request->getValues() so things that look for e.g.
+ // wpEditToken don't fail.
+ $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
+
+ $fields = $this->createFieldsForKey( $key );
+ $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
+ $row = [];
+ foreach ( $fields as $fieldname => $field ) {
+ if ( $field->skipLoadData( $subrequest ) ) {
+ continue;
+ } elseif ( !empty( $field->mParams['disabled'] ) ) {
+ $row[$fieldname] = $field->getDefault();
+ } else {
+ $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
+ }
+ }
+ $ret[] = $row;
+ }
+
+ if ( isset( $values['create'] ) ) {
+ // Non-JS client clicked the "create" button.
+ $fields = $this->createFieldsForKey( $this->uniqueId );
+ $row = [];
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !empty( $field->mParams['nodata'] ) ) {
+ continue;
+ } else {
+ $row[$fieldname] = $field->getDefault();
+ }
+ }
+ $ret[] = $row;
+ }
+
+ return $ret;
+ }
+
+ public function getDefault() {
+ $ret = parent::getDefault();
+
+ // The default default is one entry with all subfields at their
+ // defaults.
+ if ( $ret === null ) {
+ $fields = $this->createFieldsForKey( $this->uniqueId );
+ $row = [];
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !empty( $field->mParams['nodata'] ) ) {
+ continue;
+ } else {
+ $row[$fieldname] = $field->getDefault();
+ }
+ }
+ $ret = [ $row ];
+ }
+
+ return $ret;
+ }
+
+ public function cancelSubmit( $values, $alldata ) {
+ if ( isset( $values['nonjs'] ) ) {
+ return true;
+ }
+
+ foreach ( $values as $key => $value ) {
+ $fields = $this->createFieldsForKey( $key );
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !array_key_exists( $fieldname, $value ) ) {
+ continue;
+ }
+ if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
+ return true;
+ }
+ }
+ }
+
+ return parent::cancelSubmit( $values, $alldata );
+ }
+
+ public function validate( $values, $alldata ) {
+ if ( isset( $this->mParams['required'] )
+ && $this->mParams['required'] !== false
+ && !$values
+ ) {
+ return $this->msg( 'htmlform-cloner-required' )->parseAsBlock();
+ }
+
+ if ( isset( $values['nonjs'] ) ) {
+ // The submission was a non-JS create/delete click, so fail
+ // validation in case cancelSubmit() somehow didn't already handle
+ // it.
+ return false;
+ }
+
+ foreach ( $values as $key => $value ) {
+ $fields = $this->createFieldsForKey( $key );
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !array_key_exists( $fieldname, $value ) ) {
+ continue;
+ }
+ $ok = $field->validate( $value[$fieldname], $alldata );
+ if ( $ok !== true ) {
+ return false;
+ }
+ }
+ }
+
+ return parent::validate( $values, $alldata );
+ }
+
+ /**
+ * Get the input HTML for the specified key.
+ *
+ * @param string $key Array key under which the fields should be named
+ * @param array $values
+ * @return string
+ */
+ protected function getInputHTMLForKey( $key, $values ) {
+ $displayFormat = isset( $this->mParams['format'] )
+ ? $this->mParams['format']
+ : $this->mParent->getDisplayFormat();
+
+ // Conveniently, PHP method names are case-insensitive.
+ $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
+
+ $html = '';
+ $hidden = '';
+ $hasLabel = false;
+
+ $fields = $this->createFieldsForKey( $key );
+ foreach ( $fields as $fieldname => $field ) {
+ $v = array_key_exists( $fieldname, $values )
+ ? $values[$fieldname]
+ : $field->getDefault();
+
+ if ( $field instanceof HTMLHiddenField ) {
+ // HTMLHiddenField doesn't generate its own HTML
+ list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
+ $hidden .= Html::hidden( $name, $value, $params ) . "\n";
+ } else {
+ $html .= $field->$getFieldHtmlMethod( $v );
+
+ $labelValue = trim( $field->getLabel() );
+ if ( $labelValue != '&#160;' && $labelValue !== '' ) {
+ $hasLabel = true;
+ }
+ }
+ }
+
+ if ( !isset( $fields['delete'] ) ) {
+ $name = "{$this->mName}[$key][delete]";
+ $label = isset( $this->mParams['delete-button-message'] )
+ ? $this->mParams['delete-button-message']
+ : 'htmlform-cloner-delete';
+ $field = HTMLForm::loadInputFromParameters( $name, [
+ 'type' => 'submit',
+ 'name' => $name,
+ 'id' => Sanitizer::escapeId( "{$this->mID}--$key--delete" ),
+ 'cssclass' => 'mw-htmlform-cloner-delete-button',
+ 'default' => $this->getMessage( $label )->text(),
+ ], $this->mParent );
+ $v = $field->getDefault();
+
+ if ( $displayFormat === 'table' ) {
+ $html .= $field->$getFieldHtmlMethod( $v );
+ } else {
+ $html .= $field->getInputHTML( $v );
+ }
+ }
+
+ if ( $displayFormat !== 'raw' ) {
+ $classes = [
+ 'mw-htmlform-cloner-row',
+ ];
+
+ if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
+ $classes[] = 'mw-htmlform-nolabel';
+ }
+
+ $attribs = [
+ 'class' => implode( ' ', $classes ),
+ ];
+
+ if ( $displayFormat === 'table' ) {
+ $html = Html::rawElement( 'table',
+ $attribs,
+ Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
+ } else {
+ $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
+ }
+ }
+
+ $html .= $hidden;
+
+ if ( !empty( $this->mParams['row-legend'] ) ) {
+ $legend = $this->msg( $this->mParams['row-legend'] )->text();
+ $html = Xml::fieldset( $legend, $html );
+ }
+
+ return $html;
+ }
+
+ public function getInputHTML( $values ) {
+ $html = '';
+
+ foreach ( (array)$values as $key => $value ) {
+ if ( $key === 'nonjs' ) {
+ continue;
+ }
+ $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
+ $this->getInputHTMLForKey( $key, $value )
+ );
+ }
+
+ $template = $this->getInputHTMLForKey( $this->uniqueId, null );
+ $html = Html::rawElement( 'ul', [
+ 'id' => "mw-htmlform-cloner-list-{$this->mID}",
+ 'class' => 'mw-htmlform-cloner-ul',
+ 'data-template' => $template,
+ 'data-unique-id' => $this->uniqueId,
+ ], $html );
+
+ $name = "{$this->mName}[create]";
+ $label = isset( $this->mParams['create-button-message'] )
+ ? $this->mParams['create-button-message']
+ : 'htmlform-cloner-create';
+ $field = HTMLForm::loadInputFromParameters( $name, [
+ 'type' => 'submit',
+ 'name' => $name,
+ 'id' => Sanitizer::escapeId( "{$this->mID}--create" ),
+ 'cssclass' => 'mw-htmlform-cloner-create-button',
+ 'default' => $this->getMessage( $label )->text(),
+ ], $this->mParent );
+ $html .= $field->getInputHTML( $field->getDefault() );
+
+ return $html;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLFormFieldRequiredOptionsException.php b/www/wiki/includes/htmlform/HTMLFormFieldRequiredOptionsException.php
new file mode 100644
index 00000000..76f52866
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLFormFieldRequiredOptionsException.php
@@ -0,0 +1,9 @@
+<?php
+
+class HTMLFormFieldRequiredOptionsException extends MWException {
+ public function __construct( HTMLFormField $field, array $missing ) {
+ parent::__construct( sprintf( "Form type `%s` expected the following parameters to be set: %s",
+ get_class( $field ),
+ implode( ', ', $missing ) ) );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLFormFieldWithButton.php b/www/wiki/includes/htmlform/HTMLFormFieldWithButton.php
new file mode 100644
index 00000000..bcb07bd1
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLFormFieldWithButton.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * Enables HTMLFormField elements to be build with a button.
+ */
+class HTMLFormFieldWithButton extends HTMLFormField {
+ /** @var string $mButtonClass CSS class for the button in this field */
+ protected $mButtonClass = '';
+
+ /** @var string|integer $mButtonId Element ID for the button in this field */
+ protected $mButtonId = '';
+
+ /** @var string $mButtonName Name the button in this field */
+ protected $mButtonName = '';
+
+ /** @var string $mButtonType Type of the button in this field (e.g. button or submit) */
+ protected $mButtonType = 'submit';
+
+ /** @var string $mButtonType Value for the button in this field */
+ protected $mButtonValue;
+
+ /** @var string $mButtonType Value for the button in this field */
+ protected $mButtonFlags = [ 'progressive' ];
+
+ public function __construct( $info ) {
+ if ( isset( $info['buttonclass'] ) ) {
+ $this->mButtonClass = $info['buttonclass'];
+ }
+ if ( isset( $info['buttonid'] ) ) {
+ $this->mButtonId = $info['buttonid'];
+ }
+ if ( isset( $info['buttonname'] ) ) {
+ $this->mButtonName = $info['buttonname'];
+ }
+ if ( isset( $info['buttondefault'] ) ) {
+ $this->mButtonValue = $info['buttondefault'];
+ }
+ if ( isset( $info['buttontype'] ) ) {
+ $this->mButtonType = $info['buttontype'];
+ }
+ if ( isset( $info['buttonflags'] ) ) {
+ $this->mButtonFlags = $info['buttonflags'];
+ }
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ $attr = [
+ 'class' => 'mw-htmlform-submit ' . $this->mButtonClass,
+ 'id' => $this->mButtonId,
+ ] + $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ return Html::input( $this->mButtonName, $this->mButtonValue, $this->mButtonType, $attr );
+ }
+
+ public function getInputOOUI( $value ) {
+ return new OOUI\ButtonInputWidget( [
+ 'name' => $this->mButtonName,
+ 'value' => $this->mButtonValue,
+ 'type' => $this->mButtonType,
+ 'label' => $this->mButtonValue,
+ 'flags' => $this->mButtonFlags,
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ ) );
+ }
+
+ /**
+ * Combines the passed element with a button.
+ * @param String $element Element to combine the button with.
+ * @return String
+ */
+ public function getElement( $element ) {
+ return $element . '&#160;' . $this->getInputHTML( '' );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLHiddenField.php b/www/wiki/includes/htmlform/HTMLHiddenField.php
new file mode 100644
index 00000000..c0fce2ba
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLHiddenField.php
@@ -0,0 +1,66 @@
+<?php
+
+class HTMLHiddenField extends HTMLFormField {
+ protected $outputAsDefault = true;
+
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( isset( $this->mParams['output-as-default'] ) ) {
+ $this->outputAsDefault = (bool)$this->mParams['output-as-default'];
+ }
+
+ # Per HTML5 spec, hidden fields cannot be 'required'
+ # http://www.w3.org/TR/html5/forms.html#hidden-state-%28type=hidden%29
+ unset( $this->mParams['required'] );
+ }
+
+ public function getHiddenFieldData( $value ) {
+ $params = [];
+ if ( $this->mID ) {
+ $params['id'] = $this->mID;
+ }
+
+ if ( $this->outputAsDefault ) {
+ $value = $this->mDefault;
+ }
+
+ return [ $this->mName, $value, $params ];
+ }
+
+ public function getTableRow( $value ) {
+ list( $name, $value, $params ) = $this->getHiddenFieldData( $value );
+ $this->mParent->addHiddenField( $name, $value, $params );
+ return '';
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getDiv( $value ) {
+ return $this->getTableRow( $value );
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getRaw( $value ) {
+ return $this->getTableRow( $value );
+ }
+
+ public function getInputHTML( $value ) {
+ return '';
+ }
+
+ public function canDisplayErrors() {
+ return false;
+ }
+
+ public function hasVisibleOutput() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLInfoField.php b/www/wiki/includes/htmlform/HTMLInfoField.php
new file mode 100644
index 00000000..ada4fb67
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLInfoField.php
@@ -0,0 +1,64 @@
+<?php
+
+/**
+ * An information field (text blob), not a proper input.
+ */
+class HTMLInfoField extends HTMLFormField {
+ public function __construct( $info ) {
+ $info['nodata'] = true;
+
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ return !empty( $this->mParams['raw'] ) ? $value : htmlspecialchars( $value );
+ }
+
+ public function getInputOOUI( $value ) {
+ if ( !empty( $this->mParams['raw'] ) ) {
+ $value = new OOUI\HtmlSnippet( $value );
+ }
+
+ return new OOUI\LabelWidget( [
+ 'label' => $value,
+ ] );
+ }
+
+ public function getTableRow( $value ) {
+ if ( !empty( $this->mParams['rawrow'] ) ) {
+ return $value;
+ }
+
+ return parent::getTableRow( $value );
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getDiv( $value ) {
+ if ( !empty( $this->mParams['rawrow'] ) ) {
+ return $value;
+ }
+
+ return parent::getDiv( $value );
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getRaw( $value ) {
+ if ( !empty( $this->mParams['rawrow'] ) ) {
+ return $value;
+ }
+
+ return parent::getRaw( $value );
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLIntField.php b/www/wiki/includes/htmlform/HTMLIntField.php
new file mode 100644
index 00000000..b0148d98
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLIntField.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * A field that must contain a number
+ */
+class HTMLIntField extends HTMLFloatField {
+ function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ # http://www.w3.org/TR/html5/infrastructure.html#signed-integers
+ # with the addition that a leading '+' sign is ok. Note that leading zeros
+ # are fine, and will be left in the input, which is useful for things like
+ # phone numbers when you know that they are integers (the HTML5 type=tel
+ # input does not require its value to be numeric). If you want a tidier
+ # value to, eg, save in the DB, clean it up with intval().
+ if ( !preg_match( '/^((\+|\-)?\d+)?$/', trim( $value ) ) ) {
+ return $this->msg( 'htmlform-int-invalid' )->parseAsBlock();
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLMultiSelectField.php b/www/wiki/includes/htmlform/HTMLMultiSelectField.php
new file mode 100644
index 00000000..1aaa3e88
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLMultiSelectField.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * Multi-select field
+ */
+class HTMLMultiSelectField extends HTMLFormField implements HTMLNestedFilterable {
+ function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ if ( !is_array( $value ) ) {
+ return false;
+ }
+
+ # If all options are valid, array_intersect of the valid options
+ # and the provided options will return the provided options.
+ $validOptions = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ $validValues = array_intersect( $value, $validOptions );
+ if ( count( $validValues ) == count( $value ) ) {
+ return true;
+ } else {
+ return $this->msg( 'htmlform-select-badoption' )->parse();
+ }
+ }
+
+ function getInputHTML( $value ) {
+ $value = HTMLFormField::forceToStringRecursive( $value );
+ $html = $this->formatOptions( $this->getOptions(), $value );
+
+ return $html;
+ }
+
+ function formatOptions( $options, $value ) {
+ $html = '';
+
+ $attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ foreach ( $options as $label => $info ) {
+ if ( is_array( $info ) ) {
+ $html .= Html::rawElement( 'h1', [], $label ) . "\n";
+ $html .= $this->formatOptions( $info, $value );
+ } else {
+ $thisAttribs = [
+ 'id' => "{$this->mID}-$info",
+ 'value' => $info,
+ ];
+ $checked = in_array( $info, $value, true );
+
+ $checkbox = $this->getOneCheckbox( $checked, $attribs + $thisAttribs, $label );
+
+ $html .= ' ' . Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-htmlform-flatlist-item' ],
+ $checkbox
+ );
+ }
+ }
+
+ return $html;
+ }
+
+ protected function getOneCheckbox( $checked, $attribs, $label ) {
+ if ( $this->mParent instanceof OOUIHTMLForm ) {
+ if ( $this->mOptionsLabelsNotFromMessage ) {
+ $label = new OOUI\HtmlSnippet( $label );
+ }
+ return new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => "{$this->mName}[]",
+ 'selected' => $checked,
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $attribs
+ ) ),
+ [
+ 'label' => $label,
+ 'align' => 'inline',
+ ]
+ );
+ } else {
+ $elementFunc = [ 'Html', $this->mOptionsLabelsNotFromMessage ? 'rawElement' : 'element' ];
+ $checkbox =
+ Xml::check( "{$this->mName}[]", $checked, $attribs ) .
+ '&#160;' .
+ call_user_func( $elementFunc,
+ 'label',
+ [ 'for' => $attribs['id'] ],
+ $label
+ );
+ if ( $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $checkbox = Html::openElement( 'div', [ 'class' => 'mw-ui-checkbox' ] ) .
+ $checkbox .
+ Html::closeElement( 'div' );
+ }
+ return $checkbox;
+ }
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return string
+ */
+ function loadDataFromRequest( $request ) {
+ if ( $this->mParent->getMethod() == 'post' ) {
+ if ( $request->wasPosted() ) {
+ # Checkboxes are just not added to the request arrays if they're not checked,
+ # so it's perfectly possible for there not to be an entry at all
+ return $request->getArray( $this->mName, [] );
+ } else {
+ # That's ok, the user has not yet submitted the form, so show the defaults
+ return $this->getDefault();
+ }
+ } else {
+ # This is the impossible case: if we look at $_GET and see no data for our
+ # field, is it because the user has not yet submitted the form, or that they
+ # have submitted it with all the options unchecked? We will have to assume the
+ # latter, which basically means that you can't specify 'positive' defaults
+ # for GET forms.
+ # @todo FIXME...
+ return $request->getArray( $this->mName, [] );
+ }
+ }
+
+ function getDefault() {
+ if ( isset( $this->mDefault ) ) {
+ return $this->mDefault;
+ } else {
+ return [];
+ }
+ }
+
+ function filterDataForSubmit( $data ) {
+ $data = HTMLFormField::forceToStringRecursive( $data );
+ $options = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ $res = [];
+ foreach ( $options as $opt ) {
+ $res["$opt"] = in_array( $opt, $data, true );
+ }
+
+ return $res;
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLNestedFilterable.php b/www/wiki/includes/htmlform/HTMLNestedFilterable.php
new file mode 100644
index 00000000..d44fc60a
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLNestedFilterable.php
@@ -0,0 +1,11 @@
+<?php
+
+interface HTMLNestedFilterable {
+ /**
+ * Support for seperating multi-option preferences into multiple preferences
+ * Due to lack of array support.
+ *
+ * @param array $data
+ */
+ public function filterDataForSubmit( $data );
+}
diff --git a/www/wiki/includes/htmlform/HTMLRadioField.php b/www/wiki/includes/htmlform/HTMLRadioField.php
new file mode 100644
index 00000000..12a8a1fd
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLRadioField.php
@@ -0,0 +1,91 @@
+<?php
+
+/**
+ * Radio checkbox fields.
+ */
+class HTMLRadioField extends HTMLFormField {
+ function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ if ( !is_string( $value ) && !is_int( $value ) ) {
+ return false;
+ }
+
+ $validOptions = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ if ( in_array( strval( $value ), $validOptions, true ) ) {
+ return true;
+ } else {
+ return $this->msg( 'htmlform-select-badoption' )->parse();
+ }
+ }
+
+ /**
+ * This returns a block of all the radio options, in one cell.
+ * @see includes/HTMLFormField#getInputHTML()
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ function getInputHTML( $value ) {
+ $html = $this->formatOptions( $this->getOptions(), strval( $value ) );
+
+ return $html;
+ }
+
+ function getInputOOUI( $value ) {
+ $options = [];
+ foreach ( $this->getOptions() as $label => $data ) {
+ $options[] = [
+ 'data' => $data,
+ 'label' => $this->mOptionsLabelsNotFromMessage ? new OOUI\HtmlSnippet( $label ) : $label,
+ ];
+ }
+
+ return new OOUI\RadioSelectInputWidget( [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'value' => $value,
+ 'options' => $options,
+ 'classes' => 'mw-htmlform-flatlist-item',
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ ) );
+ }
+
+ function formatOptions( $options, $value ) {
+ $html = '';
+
+ $attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
+ $elementFunc = [ 'Html', $this->mOptionsLabelsNotFromMessage ? 'rawElement' : 'element' ];
+
+ # @todo Should this produce an unordered list perhaps?
+ foreach ( $options as $label => $info ) {
+ if ( is_array( $info ) ) {
+ $html .= Html::rawElement( 'h1', [], $label ) . "\n";
+ $html .= $this->formatOptions( $info, $value );
+ } else {
+ $id = Sanitizer::escapeId( $this->mID . "-$info" );
+ $radio = Xml::radio( $this->mName, $info, $info === $value, $attribs + [ 'id' => $id ] );
+ $radio .= '&#160;' . call_user_func( $elementFunc, 'label', [ 'for' => $id ], $label );
+
+ $html .= ' ' . Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-htmlform-flatlist-item mw-ui-radio' ],
+ $radio
+ );
+ }
+ }
+
+ return $html;
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLSelectAndOtherField.php b/www/wiki/includes/htmlform/HTMLSelectAndOtherField.php
new file mode 100644
index 00000000..e75c2b25
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLSelectAndOtherField.php
@@ -0,0 +1,134 @@
+<?php
+
+/**
+ * Double field with a dropdown list constructed from a system message in the format
+ * * Optgroup header
+ * ** <option value>
+ * * New Optgroup header
+ * Plus a text field underneath for an additional reason. The 'value' of the field is
+ * "<select>: <extra reason>", or "<extra reason>" if nothing has been selected in the
+ * select dropdown.
+ * @todo FIXME: If made 'required', only the text field should be compulsory.
+ */
+class HTMLSelectAndOtherField extends HTMLSelectField {
+ function __construct( $params ) {
+ if ( array_key_exists( 'other', $params ) ) {
+ // Do nothing
+ } elseif ( array_key_exists( 'other-message', $params ) ) {
+ $params['other'] = $this->getMessage( $params['other-message'] )->plain();
+ } else {
+ $params['other'] = $this->msg( 'htmlform-selectorother-other' )->plain();
+ }
+
+ parent::__construct( $params );
+
+ if ( $this->getOptions() === null ) {
+ // Sulk
+ throw new MWException( 'HTMLSelectAndOtherField called without any options' );
+ }
+ if ( !in_array( 'other', $this->mOptions, true ) ) {
+ // Have 'other' always as first element
+ $this->mOptions = [ $params['other'] => 'other' ] + $this->mOptions;
+ }
+ $this->mFlatOptions = self::flattenOptions( $this->getOptions() );
+
+ }
+
+ function getInputHTML( $value ) {
+ $select = parent::getInputHTML( $value[1] );
+
+ $textAttribs = [
+ 'id' => $this->mID . '-other',
+ 'size' => $this->getSize(),
+ 'class' => [ 'mw-htmlform-select-and-other-field' ],
+ 'data-id-select' => $this->mID,
+ ];
+
+ if ( $this->mClass !== '' ) {
+ $textAttribs['class'][] = $this->mClass;
+ }
+
+ $allowedParams = [
+ 'required',
+ 'autofocus',
+ 'multiple',
+ 'disabled',
+ 'tabindex',
+ 'maxlength', // gets dynamic with javascript, see mediawiki.htmlform.js
+ ];
+
+ $textAttribs += $this->getAttributes( $allowedParams );
+
+ $textbox = Html::input( $this->mName . '-other', $value[2], 'text', $textAttribs );
+
+ return "$select<br />\n$textbox";
+ }
+
+ function getInputOOUI( $value ) {
+ return false;
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return array("<overall message>","<select value>","<text field value>")
+ */
+ function loadDataFromRequest( $request ) {
+ if ( $request->getCheck( $this->mName ) ) {
+ $list = $request->getText( $this->mName );
+ $text = $request->getText( $this->mName . '-other' );
+
+ // Should be built the same as in mediawiki.htmlform.js
+ if ( $list == 'other' ) {
+ $final = $text;
+ } elseif ( !in_array( $list, $this->mFlatOptions, true ) ) {
+ # User has spoofed the select form to give an option which wasn't
+ # in the original offer. Sulk...
+ $final = $text;
+ } elseif ( $text == '' ) {
+ $final = $list;
+ } else {
+ $final = $list . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $text;
+ }
+ } else {
+ $final = $this->getDefault();
+
+ $list = 'other';
+ $text = $final;
+ foreach ( $this->mFlatOptions as $option ) {
+ $match = $option . $this->msg( 'colon-separator' )->inContentLanguage()->text();
+ if ( strpos( $text, $match ) === 0 ) {
+ $list = $option;
+ $text = substr( $text, strlen( $match ) );
+ break;
+ }
+ }
+ }
+
+ return [ $final, $list, $text ];
+ }
+
+ function getSize() {
+ return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 45;
+ }
+
+ function validate( $value, $alldata ) {
+ # HTMLSelectField forces $value to be one of the options in the select
+ # field, which is not useful here. But we do want the validation further up
+ # the chain
+ $p = parent::validate( $value[1], $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ if ( isset( $this->mParams['required'] )
+ && $this->mParams['required'] !== false
+ && $value[1] === ''
+ ) {
+ return $this->msg( 'htmlform-required' )->parse();
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLSelectField.php b/www/wiki/includes/htmlform/HTMLSelectField.php
new file mode 100644
index 00000000..b6ad46c5
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLSelectField.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * A select dropdown field. Basically a wrapper for Xmlselect class
+ */
+class HTMLSelectField extends HTMLFormField {
+ function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ $validOptions = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ if ( in_array( strval( $value ), $validOptions, true ) ) {
+ return true;
+ } else {
+ return $this->msg( 'htmlform-select-badoption' )->parse();
+ }
+ }
+
+ function getInputHTML( $value ) {
+ $select = new XmlSelect( $this->mName, $this->mID, strval( $value ) );
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $select->setAttribute( 'disabled', 'disabled' );
+ }
+
+ $allowedParams = [ 'tabindex', 'size' ];
+ $customParams = $this->getAttributes( $allowedParams );
+ foreach ( $customParams as $name => $value ) {
+ $select->setAttribute( $name, $value );
+ }
+
+ if ( $this->mClass !== '' ) {
+ $select->setAttribute( 'class', $this->mClass );
+ }
+
+ $select->addOptions( $this->getOptions() );
+
+ return $select->getHTML();
+ }
+
+ function getInputOOUI( $value ) {
+ $disabled = false;
+ $allowedParams = [ 'tabindex' ];
+ $attribs = OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $attribs['classes'] = [ $this->mClass ];
+ }
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $disabled = true;
+ }
+
+ return new OOUI\DropdownInputWidget( [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'options' => $this->getOptionsOOUI(),
+ 'value' => strval( $value ),
+ 'disabled' => $disabled,
+ ] + $attribs );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLSelectLimitField.php b/www/wiki/includes/htmlform/HTMLSelectLimitField.php
new file mode 100644
index 00000000..e7f1c047
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLSelectLimitField.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * A limit dropdown, which accepts any valid number
+ */
+class HTMLSelectLimitField extends HTMLSelectField {
+ /**
+ * Basically don't do any validation. If it's a number that's fine. Also,
+ * add it to the list if it's not there already
+ *
+ * @param string $value
+ * @param array $alldata
+ * @return bool
+ */
+ function validate( $value, $alldata ) {
+ if ( $value == '' ) {
+ return true;
+ }
+
+ // Let folks pick an explicit limit not from our list, as long as it's a real numbr.
+ if ( !in_array( $value, $this->mParams['options'] )
+ && $value == intval( $value )
+ && $value > 0
+ ) {
+ // This adds the explicitly requested limit value to the drop-down,
+ // then makes sure it's sorted correctly so when we output the list
+ // later, the custom option doesn't just show up last.
+ $this->mParams['options'][$this->mParent->getLanguage()->formatNum( $value )] =
+ intval( $value );
+ asort( $this->mParams['options'] );
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLSelectNamespace.php b/www/wiki/includes/htmlform/HTMLSelectNamespace.php
new file mode 100644
index 00000000..ef219690
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLSelectNamespace.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Wrapper for Html::namespaceSelector to use in HTMLForm
+ */
+class HTMLSelectNamespace extends HTMLFormField {
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ $this->mAllValue = array_key_exists( 'all', $params )
+ ? $params['all']
+ : 'all';
+
+ }
+
+ function getInputHTML( $value ) {
+ return Html::namespaceSelector(
+ [
+ 'selected' => $value,
+ 'all' => $this->mAllValue
+ ], [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'class' => 'namespaceselector',
+ ]
+ );
+ }
+
+ public function getInputOOUI( $value ) {
+ return new MediaWiki\Widget\NamespaceInputWidget( [
+ 'value' => $value,
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'includeAllValue' => $this->mAllValue,
+ ] );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLSelectNamespaceWithButton.php b/www/wiki/includes/htmlform/HTMLSelectNamespaceWithButton.php
new file mode 100644
index 00000000..24b15bd7
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLSelectNamespaceWithButton.php
@@ -0,0 +1,17 @@
+<?php
+/**
+ * Creates a Html::namespaceSelector input field with a button assigned to the input field.
+ */
+class HTMLSelectNamespaceWithButton extends HTMLSelectNamespace {
+ /** @var HTMLFormClassWithButton $mClassWithButton */
+ protected $mClassWithButton = null;
+
+ public function __construct( $info ) {
+ $this->mClassWithButton = new HTMLFormFieldWithButton( $info );
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ return $this->mClassWithButton->getElement( parent::getInputHTML( $value ) );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLSelectOrOtherField.php b/www/wiki/includes/htmlform/HTMLSelectOrOtherField.php
new file mode 100644
index 00000000..8f7750c0
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLSelectOrOtherField.php
@@ -0,0 +1,90 @@
+<?php
+
+/**
+ * Select dropdown field, with an additional "other" textbox.
+ *
+ * HTMLComboboxField implements the same functionality using a single form field
+ * and should be used instead.
+ */
+class HTMLSelectOrOtherField extends HTMLTextField {
+ function __construct( $params ) {
+ parent::__construct( $params );
+ $this->getOptions();
+ if ( !in_array( 'other', $this->mOptions, true ) ) {
+ $msg =
+ isset( $params['other'] )
+ ? $params['other']
+ : wfMessage( 'htmlform-selectorother-other' )->text();
+ // Have 'other' always as first element
+ $this->mOptions = [ $msg => 'other' ] + $this->mOptions;
+ }
+
+ }
+
+ function getInputHTML( $value ) {
+ $valInSelect = false;
+
+ if ( $value !== false ) {
+ $value = strval( $value );
+ $valInSelect = in_array(
+ $value, HTMLFormField::flattenOptions( $this->getOptions() ), true
+ );
+ }
+
+ $selected = $valInSelect ? $value : 'other';
+
+ $select = new XmlSelect( $this->mName, $this->mID, $selected );
+ $select->addOptions( $this->getOptions() );
+
+ $select->setAttribute( 'class', 'mw-htmlform-select-or-other' );
+
+ $tbAttribs = [ 'id' => $this->mID . '-other', 'size' => $this->getSize() ];
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $select->setAttribute( 'disabled', 'disabled' );
+ $tbAttribs['disabled'] = 'disabled';
+ }
+
+ if ( isset( $this->mParams['tabindex'] ) ) {
+ $select->setAttribute( 'tabindex', $this->mParams['tabindex'] );
+ $tbAttribs['tabindex'] = $this->mParams['tabindex'];
+ }
+
+ $select = $select->getHTML();
+
+ if ( isset( $this->mParams['maxlength'] ) ) {
+ $tbAttribs['maxlength'] = $this->mParams['maxlength'];
+ }
+
+ if ( $this->mClass !== '' ) {
+ $tbAttribs['class'] = $this->mClass;
+ }
+
+ $textbox = Html::input( $this->mName . '-other', $valInSelect ? '' : $value, 'text', $tbAttribs );
+
+ return "$select<br />\n$textbox";
+ }
+
+ function getInputOOUI( $value ) {
+ return false;
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return string
+ */
+ function loadDataFromRequest( $request ) {
+ if ( $request->getCheck( $this->mName ) ) {
+ $val = $request->getText( $this->mName );
+
+ if ( $val === 'other' ) {
+ $val = $request->getText( $this->mName . '-other' );
+ }
+
+ return $val;
+ } else {
+ return $this->getDefault();
+ }
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLSubmitField.php b/www/wiki/includes/htmlform/HTMLSubmitField.php
new file mode 100644
index 00000000..cb98549e
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLSubmitField.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * Add a submit button inline in the form (as opposed to
+ * HTMLForm::addButton(), which will add it at the end).
+ */
+class HTMLSubmitField extends HTMLButtonField {
+ protected $buttonType = 'submit';
+
+ protected $mFlags = [ 'primary', 'constructive' ];
+
+ public function skipLoadData( $request ) {
+ return !$request->getCheck( $this->mName );
+ }
+
+ public function loadDataFromRequest( $request ) {
+ return $request->getCheck( $this->mName );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLTagFilter.php b/www/wiki/includes/htmlform/HTMLTagFilter.php
new file mode 100644
index 00000000..8075de5a
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLTagFilter.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * Wrapper for ChangeTags::buildTagFilterSelector to use in HTMLForm
+ */
+class HTMLTagFilter extends HTMLFormField {
+ protected $tagFilter;
+
+ function getTableRow( $value ) {
+ $this->tagFilter = ChangeTags::buildTagFilterSelector( $value );
+ if ( $this->tagFilter ) {
+ return parent::getTableRow( $value );
+ }
+ return '';
+ }
+
+ function getDiv( $value ) {
+ $this->tagFilter = ChangeTags::buildTagFilterSelector( $value );
+ if ( $this->tagFilter ) {
+ return parent::getDiv( $value );
+ }
+ return '';
+ }
+
+ function getInputHTML( $value ) {
+ if ( $this->tagFilter ) {
+ // we only need the select field, HTMLForm should handle the label
+ return $this->tagFilter[1];
+ }
+ return '';
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLTextAreaField.php b/www/wiki/includes/htmlform/HTMLTextAreaField.php
new file mode 100644
index 00000000..8ffff438
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLTextAreaField.php
@@ -0,0 +1,103 @@
+<?php
+
+class HTMLTextAreaField extends HTMLFormField {
+ const DEFAULT_COLS = 80;
+ const DEFAULT_ROWS = 25;
+
+ protected $mPlaceholder = '';
+
+ /**
+ * @param array $params
+ * - cols, rows: textarea size
+ * - placeholder/placeholder-message: set HTML placeholder attribute
+ * - spellcheck: set HTML spellcheck attribute
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( isset( $params['placeholder-message'] ) ) {
+ $this->mPlaceholder = $this->getMessage( $params['placeholder-message'] )->parse();
+ } elseif ( isset( $params['placeholder'] ) ) {
+ $this->mPlaceholder = $params['placeholder'];
+ }
+ }
+
+ function getCols() {
+ return isset( $this->mParams['cols'] ) ? $this->mParams['cols'] : static::DEFAULT_COLS;
+ }
+
+ function getRows() {
+ return isset( $this->mParams['rows'] ) ? $this->mParams['rows'] : static::DEFAULT_ROWS;
+ }
+
+ function getSpellCheck() {
+ $val = isset( $this->mParams['spellcheck'] ) ? $this->mParams['spellcheck'] : null;
+ if ( is_bool( $val ) ) {
+ // "spellcheck" attribute literally requires "true" or "false" to work.
+ return $val === true ? 'true' : 'false';
+ }
+ return null;
+ }
+
+ function getInputHTML( $value ) {
+ $attribs = [
+ 'id' => $this->mID,
+ 'cols' => $this->getCols(),
+ 'rows' => $this->getRows(),
+ 'spellcheck' => $this->getSpellCheck(),
+ ] + $this->getTooltipAndAccessKey();
+
+ if ( $this->mClass !== '' ) {
+ $attribs['class'] = $this->mClass;
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $attribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ $allowedParams = [
+ 'tabindex',
+ 'disabled',
+ 'readonly',
+ 'required',
+ 'autofocus'
+ ];
+
+ $attribs += $this->getAttributes( $allowedParams );
+ return Html::textarea( $this->mName, $value, $attribs );
+ }
+
+ function getInputOOUI( $value ) {
+ if ( isset( $this->mParams['cols'] ) ) {
+ throw new Exception( "OOUIHTMLForm does not support the 'cols' parameter for textareas" );
+ }
+
+ $attribs = $this->getTooltipAndAccessKey();
+
+ if ( $this->mClass !== '' ) {
+ $attribs['classes'] = [ $this->mClass ];
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $attribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ $allowedParams = [
+ 'tabindex',
+ 'disabled',
+ 'readonly',
+ 'required',
+ 'autofocus',
+ ];
+
+ $attribs += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ return new OOUI\TextInputWidget( [
+ 'id' => $this->mID,
+ 'name' => $this->mName,
+ 'multiline' => true,
+ 'value' => $value,
+ 'rows' => $this->getRows(),
+ ] + $attribs );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLTextField.php b/www/wiki/includes/htmlform/HTMLTextField.php
new file mode 100644
index 00000000..3ab71766
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLTextField.php
@@ -0,0 +1,177 @@
+<?php
+
+class HTMLTextField extends HTMLFormField {
+ protected $mPlaceholder = '';
+
+ /**
+ * @param array $params
+ * - type: HTML textfield type
+ * - size: field size in characters (defaults to 45)
+ * - placeholder/placeholder-message: set HTML placeholder attribute
+ * - spellcheck: set HTML spellcheck attribute
+ * - persistent: upon unsuccessful requests, retain the value (defaults to true, except
+ * for password fields)
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( isset( $params['placeholder-message'] ) ) {
+ $this->mPlaceholder = $this->getMessage( $params['placeholder-message'] )->parse();
+ } elseif ( isset( $params['placeholder'] ) ) {
+ $this->mPlaceholder = $params['placeholder'];
+ }
+ }
+
+ function getSize() {
+ return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 45;
+ }
+
+ function getSpellCheck() {
+ $val = isset( $this->mParams['spellcheck'] ) ? $this->mParams['spellcheck'] : null;
+ if ( is_bool( $val ) ) {
+ // "spellcheck" attribute literally requires "true" or "false" to work.
+ return $val === true ? 'true' : 'false';
+ }
+ return null;
+ }
+
+ public function isPersistent() {
+ if ( isset( $this->mParams['persistent'] ) ) {
+ return $this->mParams['persistent'];
+ }
+ // don't put passwords into the HTML body, they could get cached or otherwise leaked
+ return !( isset( $this->mParams['type'] ) && $this->mParams['type'] === 'password' );
+ }
+
+ function getInputHTML( $value ) {
+ if ( !$this->isPersistent() ) {
+ $value = '';
+ }
+
+ $attribs = [
+ 'id' => $this->mID,
+ 'name' => $this->mName,
+ 'size' => $this->getSize(),
+ 'value' => $value,
+ 'dir' => $this->mDir,
+ 'spellcheck' => $this->getSpellCheck(),
+ ] + $this->getTooltipAndAccessKey() + $this->getDataAttribs();
+
+ if ( $this->mClass !== '' ) {
+ $attribs['class'] = $this->mClass;
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $attribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ # @todo Enforce pattern, step, required, readonly on the server side as
+ # well
+ $allowedParams = [
+ 'type',
+ 'min',
+ 'max',
+ 'pattern',
+ 'title',
+ 'step',
+ 'list',
+ 'maxlength',
+ 'tabindex',
+ 'disabled',
+ 'required',
+ 'autofocus',
+ 'multiple',
+ 'readonly'
+ ];
+
+ $attribs += $this->getAttributes( $allowedParams );
+
+ # Extract 'type'
+ $type = $this->getType( $attribs );
+ return Html::input( $this->mName, $value, $type, $attribs );
+ }
+
+ protected function getType( &$attribs ) {
+ $type = isset( $attribs['type'] ) ? $attribs['type'] : 'text';
+ unset( $attribs['type'] );
+
+ # Implement tiny differences between some field variants
+ # here, rather than creating a new class for each one which
+ # is essentially just a clone of this one.
+ if ( isset( $this->mParams['type'] ) ) {
+ switch ( $this->mParams['type'] ) {
+ case 'int':
+ $type = 'number';
+ break;
+ case 'float':
+ $type = 'number';
+ $attribs['step'] = 'any';
+ break;
+ # Pass through
+ case 'email':
+ case 'password':
+ case 'file':
+ case 'url':
+ $type = $this->mParams['type'];
+ break;
+ }
+ }
+
+ return $type;
+ }
+
+ function getInputOOUI( $value ) {
+ if ( !$this->isPersistent() ) {
+ $value = '';
+ }
+
+ $attribs = $this->getTooltipAndAccessKey();
+
+ if ( $this->mClass !== '' ) {
+ $attribs['classes'] = [ $this->mClass ];
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $attribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ # @todo Enforce pattern, step, required, readonly on the server side as
+ # well
+ $allowedParams = [
+ 'autofocus',
+ 'autosize',
+ 'disabled',
+ 'flags',
+ 'indicator',
+ 'maxlength',
+ 'readonly',
+ 'required',
+ 'tabindex',
+ 'type',
+ ];
+
+ $attribs += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ $type = $this->getType( $attribs );
+
+ return $this->getInputWidget( [
+ 'id' => $this->mID,
+ 'name' => $this->mName,
+ 'value' => $value,
+ 'type' => $type,
+ ] + $attribs );
+ }
+
+ protected function getInputWidget( $params ) {
+ return new OOUI\TextInputWidget( $params );
+ }
+
+ /**
+ * Returns an array of data-* attributes to add to the field.
+ *
+ * @return array
+ */
+ protected function getDataAttribs() {
+ return [];
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLTextFieldWithButton.php b/www/wiki/includes/htmlform/HTMLTextFieldWithButton.php
new file mode 100644
index 00000000..c6dac322
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLTextFieldWithButton.php
@@ -0,0 +1,17 @@
+<?php
+/**
+ * Creates a text input field with a button assigned to the input field.
+ */
+class HTMLTextFieldWithButton extends HTMLTextField {
+ /** @var HTMLFormClassWithButton $mClassWithButton */
+ protected $mClassWithButton = null;
+
+ public function __construct( $info ) {
+ $this->mClassWithButton = new HTMLFormFieldWithButton( $info );
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ return $this->mClassWithButton->getElement( parent::getInputHTML( $value ) );
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLTitleTextField.php b/www/wiki/includes/htmlform/HTMLTitleTextField.php
new file mode 100644
index 00000000..fcf721a5
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLTitleTextField.php
@@ -0,0 +1,99 @@
+<?php
+
+use MediaWiki\Widget\TitleInputWidget;
+
+/**
+ * Implements a text input field for page titles.
+ * Automatically does validation that the title is valid,
+ * as well as autocompletion if using the OOUI display format.
+ *
+ * Note: Forms using GET requests will need to make sure the title value is not
+ * an empty string.
+ *
+ * Optional parameters:
+ * 'namespace' - Namespace the page must be in
+ * 'relative' - If true and 'namespace' given, strip/add the namespace from/to the title as needed
+ * 'creatable' - Whether to validate the title is creatable (not a special page)
+ * 'exists' - Whether to validate that the title already exists
+ *
+ * @since 1.26
+ */
+class HTMLTitleTextField extends HTMLTextField {
+ public function __construct( $params ) {
+ $params += [
+ 'namespace' => false,
+ 'relative' => false,
+ 'creatable' => false,
+ 'exists' => false,
+ ];
+
+ parent::__construct( $params );
+ }
+
+ public function validate( $value, $alldata ) {
+ if ( $this->mParent->getMethod() === 'get' && $value === '' ) {
+ // If the form is a GET form and has no value, assume it hasn't been
+ // submitted yet, and skip validation
+ return parent::validate( $value, $alldata );
+ }
+ try {
+ if ( !$this->mParams['relative'] ) {
+ $title = Title::newFromTextThrow( $value );
+ } else {
+ // Can't use Title::makeTitleSafe(), because it doesn't throw useful exceptions
+ global $wgContLang;
+ $namespaceName = $wgContLang->getNsText( $this->mParams['namespace'] );
+ $title = Title::newFromTextThrow( $namespaceName . ':' . $value );
+ }
+ } catch ( MalformedTitleException $e ) {
+ $msg = $this->msg( $e->getErrorMessage() );
+ $params = $e->getErrorMessageParameters();
+ if ( $params ) {
+ $msg->params( $params );
+ }
+ return $msg->parse();
+ }
+
+ $text = $title->getPrefixedText();
+ if ( $this->mParams['namespace'] !== false &&
+ !$title->inNamespace( $this->mParams['namespace'] )
+ ) {
+ return $this->msg( 'htmlform-title-badnamespace', $this->mParams['namespace'], $text )->parse();
+ }
+
+ if ( $this->mParams['creatable'] && !$title->canExist() ) {
+ return $this->msg( 'htmlform-title-not-creatable', $text )->escaped();
+ }
+
+ if ( $this->mParams['exists'] && !$title->exists() ) {
+ return $this->msg( 'htmlform-title-not-exists', $text )->parse();
+ }
+
+ return parent::validate( $value, $alldata );
+ }
+
+ protected function getInputWidget( $params ) {
+ $this->mParent->getOutput()->addModules( 'mediawiki.widgets' );
+ if ( $this->mParams['namespace'] !== false ) {
+ $params['namespace'] = $this->mParams['namespace'];
+ }
+ $params['relative'] = $this->mParams['relative'];
+ return new TitleInputWidget( $params );
+ }
+
+ public function getInputHtml( $value ) {
+ // add mw-searchInput class to enable search suggestions for non-OOUI, too
+ $this->mClass .= 'mw-searchInput';
+
+ // return the HTMLTextField html
+ return parent::getInputHTML( $value );
+ }
+
+ protected function getDataAttribs() {
+ return [
+ 'data-mw-searchsuggest' => FormatJson::encode( [
+ 'wrapAsLink' => false,
+ ] ),
+ ];
+ }
+}
diff --git a/www/wiki/includes/htmlform/HTMLUserTextField.php b/www/wiki/includes/htmlform/HTMLUserTextField.php
new file mode 100644
index 00000000..5a7e0b9b
--- /dev/null
+++ b/www/wiki/includes/htmlform/HTMLUserTextField.php
@@ -0,0 +1,56 @@
+<?php
+
+use MediaWiki\Widget\UserInputWidget;
+
+/**
+ * Implements a text input field for user names.
+ * Automatically auto-completes if using the OOUI display format.
+ *
+ * FIXME: Does not work for forms that support GET requests.
+ *
+ * Optional parameters:
+ * 'exists' - Whether to validate that the user already exists
+ *
+ * @since 1.26
+ */
+class HTMLUserTextField extends HTMLTextField {
+ public function __construct( $params ) {
+ $params += [
+ 'exists' => false,
+ 'ipallowed' => false,
+ ];
+
+ parent::__construct( $params );
+ }
+
+ public function validate( $value, $alldata ) {
+ // check, if a user exists with the given username
+ $user = User::newFromName( $value, false );
+
+ if ( !$user ) {
+ return $this->msg( 'htmlform-user-not-valid', $value )->parse();
+ } elseif (
+ ( $this->mParams['exists'] && $user->getId() === 0 ) &&
+ !( $this->mParams['ipallowed'] && User::isIP( $value ) )
+ ) {
+ return $this->msg( 'htmlform-user-not-exists', $user->getName() )->parse();
+ }
+
+ return parent::validate( $value, $alldata );
+ }
+
+ protected function getInputWidget( $params ) {
+ $this->mParent->getOutput()->addModules( 'mediawiki.widgets.UserInputWidget' );
+
+ return new UserInputWidget( $params );
+ }
+
+ public function getInputHtml( $value ) {
+ // add the required module and css class for user suggestions in non-OOUI mode
+ $this->mParent->getOutput()->addModules( 'mediawiki.userSuggest' );
+ $this->mClass .= ' mw-autocomplete-user';
+
+ // return parent html
+ return parent::getInputHTML( $value );
+ }
+}
diff --git a/www/wiki/includes/htmlform/OOUIHTMLForm.php b/www/wiki/includes/htmlform/OOUIHTMLForm.php
new file mode 100644
index 00000000..e47de61a
--- /dev/null
+++ b/www/wiki/includes/htmlform/OOUIHTMLForm.php
@@ -0,0 +1,296 @@
+<?php
+
+/**
+ * HTML form generation and submission handling, OOUI style.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Compact stacked vertical format for forms, implemented using OOUI widgets.
+ */
+class OOUIHTMLForm extends HTMLForm {
+ private $oouiErrors;
+ private $oouiWarnings;
+
+ public function __construct( $descriptor, $context = null, $messagePrefix = '' ) {
+ parent::__construct( $descriptor, $context, $messagePrefix );
+ $this->getOutput()->enableOOUI();
+ $this->getOutput()->addModuleStyles( 'mediawiki.htmlform.ooui.styles' );
+ }
+
+ /**
+ * Symbolic display format name.
+ * @var string
+ */
+ protected $displayFormat = 'ooui';
+
+ public static function loadInputFromParameters( $fieldname, $descriptor,
+ HTMLForm $parent = null
+ ) {
+ $field = parent::loadInputFromParameters( $fieldname, $descriptor, $parent );
+ $field->setShowEmptyLabel( false );
+ return $field;
+ }
+
+ public function getButtons() {
+ $buttons = '';
+
+ // IE<8 has bugs with <button>, so we'll need to avoid them.
+ $isBadIE = preg_match( '/MSIE [1-7]\./i', $this->getRequest()->getHeader( 'User-Agent' ) );
+
+ if ( $this->mShowSubmit ) {
+ $attribs = [ 'infusable' => true ];
+
+ if ( isset( $this->mSubmitID ) ) {
+ $attribs['id'] = $this->mSubmitID;
+ }
+
+ if ( isset( $this->mSubmitName ) ) {
+ $attribs['name'] = $this->mSubmitName;
+ }
+
+ if ( isset( $this->mSubmitTooltip ) ) {
+ $attribs += [
+ 'title' => Linker::titleAttrib( $this->mSubmitTooltip ),
+ 'accessKey' => Linker::accesskey( $this->mSubmitTooltip ),
+ ];
+ }
+
+ $attribs['classes'] = [ 'mw-htmlform-submit' ];
+ $attribs['type'] = 'submit';
+ $attribs['label'] = $this->getSubmitText();
+ $attribs['value'] = $this->getSubmitText();
+ $attribs['flags'] = $this->mSubmitFlags;
+ $attribs['useInputTag'] = $isBadIE;
+
+ $buttons .= new OOUI\ButtonInputWidget( $attribs );
+ }
+
+ if ( $this->mShowReset ) {
+ $buttons .= new OOUI\ButtonInputWidget( [
+ 'type' => 'reset',
+ 'label' => $this->msg( 'htmlform-reset' )->text(),
+ 'useInputTag' => $isBadIE,
+ ] );
+ }
+
+ if ( $this->mShowCancel ) {
+ $target = $this->mCancelTarget ?: Title::newMainPage();
+ if ( $target instanceof Title ) {
+ $target = $target->getLocalURL();
+ }
+ $buttons .= new OOUI\ButtonWidget( [
+ 'label' => $this->msg( 'cancel' )->text(),
+ 'href' => $target,
+ ] );
+ }
+
+ foreach ( $this->mButtons as $button ) {
+ $attrs = [];
+
+ if ( $button['attribs'] ) {
+ $attrs += $button['attribs'];
+ }
+
+ if ( isset( $button['id'] ) ) {
+ $attrs['id'] = $button['id'];
+ }
+
+ if ( $isBadIE ) {
+ $label = $button['value'];
+ } elseif ( isset( $button['label-message'] ) ) {
+ $label = new OOUI\HtmlSnippet( $this->getMessage( $button['label-message'] )->parse() );
+ } elseif ( isset( $button['label'] ) ) {
+ $label = $button['label'];
+ } elseif ( isset( $button['label-raw'] ) ) {
+ $label = new OOUI\HtmlSnippet( $button['label-raw'] );
+ } else {
+ $label = $button['value'];
+ }
+
+ $attrs['classes'] = isset( $attrs['class'] ) ? (array)$attrs['class'] : [];
+
+ $buttons .= new OOUI\ButtonInputWidget( [
+ 'type' => 'submit',
+ 'name' => $button['name'],
+ 'value' => $button['value'],
+ 'label' => $label,
+ 'flags' => $button['flags'],
+ 'framed' => $button['framed'],
+ 'useInputTag' => $isBadIE,
+ ] + $attrs );
+ }
+
+ if ( !$buttons ) {
+ return '';
+ }
+
+ return Html::rawElement( 'div',
+ [ 'class' => 'mw-htmlform-submit-buttons' ], "\n$buttons" ) . "\n";
+ }
+
+ protected function wrapFieldSetSection( $legend, $section, $attributes ) {
+ // to get a user visible effect, wrap the fieldset into a framed panel layout
+ $layout = new OOUI\PanelLayout( [
+ 'expanded' => false,
+ 'padded' => true,
+ 'framed' => true,
+ 'infusable' => false,
+ ] );
+
+ $layout->appendContent(
+ new OOUI\FieldsetLayout( [
+ 'label' => $legend,
+ 'infusable' => false,
+ 'items' => [
+ new OOUI\Widget( [
+ 'content' => new OOUI\HtmlSnippet( $section )
+ ] ),
+ ],
+ ] + $attributes )
+ );
+ return $layout;
+ }
+
+ /**
+ * Put a form section together from the individual fields' HTML, merging it and wrapping.
+ * @param OOUI\FieldLayout[] $fieldsHtml
+ * @param string $sectionName
+ * @param bool $anyFieldHasLabel Unused
+ * @return string HTML
+ */
+ protected function formatSection( array $fieldsHtml, $sectionName, $anyFieldHasLabel ) {
+ $config = [
+ 'items' => $fieldsHtml,
+ ];
+ if ( $sectionName ) {
+ $config['id'] = Sanitizer::escapeIdForAttribute( $sectionName );
+ }
+ if ( is_string( $this->mWrapperLegend ) ) {
+ $config['label'] = $this->mWrapperLegend;
+ }
+ return new OOUI\FieldsetLayout( $config );
+ }
+
+ /**
+ * @param string|array|Status $elements
+ * @param string $elementsType
+ * @return string
+ */
+ public function getErrorsOrWarnings( $elements, $elementsType ) {
+ if ( $elements === '' ) {
+ return '';
+ }
+
+ if ( !in_array( $elementsType, [ 'error', 'warning' ], true ) ) {
+ throw new DomainException( $elementsType . ' is not a valid type.' );
+ }
+ $errors = [];
+ if ( $elements instanceof Status ) {
+ if ( !$elements->isGood() ) {
+ $errors = $elements->getErrorsByType( $elementsType );
+ foreach ( $errors as &$error ) {
+ // Input: [ 'message' => 'foo', 'errors' => [ 'a', 'b', 'c' ] ]
+ // Output: [ 'foo', 'a', 'b', 'c' ]
+ $error = array_merge( [ $error['message'] ], $error['params'] );
+ }
+ }
+ } elseif ( $elementsType === 'error' ) {
+ if ( is_array( $elements ) ) {
+ $errors = $elements;
+ } elseif ( is_string( $elements ) ) {
+ $errors = [ $elements ];
+ }
+ }
+
+ foreach ( $errors as &$error ) {
+ $error = $this->getMessage( $error )->parse();
+ $error = new OOUI\HtmlSnippet( $error );
+ }
+
+ // Used in getBody()
+ if ( $elementsType === 'error' ) {
+ $this->oouiErrors = $errors;
+ } else {
+ $this->oouiWarnings = $errors;
+ }
+ return '';
+ }
+
+ public function getHeaderText( $section = null ) {
+ if ( is_null( $section ) ) {
+ // We handle $this->mHeader elsewhere, in getBody()
+ return '';
+ } else {
+ return parent::getHeaderText( $section );
+ }
+ }
+
+ public function getBody() {
+ $fieldset = parent::getBody();
+ // FIXME This only works for forms with no subsections
+ if ( $fieldset instanceof OOUI\FieldsetLayout ) {
+ $classes = [ 'mw-htmlform-ooui-header' ];
+ if ( $this->oouiErrors ) {
+ $classes[] = 'mw-htmlform-ooui-header-errors';
+ }
+ if ( $this->oouiWarnings ) {
+ $classes[] = 'mw-htmlform-ooui-header-warnings';
+ }
+ if ( $this->mHeader || $this->oouiErrors || $this->oouiWarnings ) {
+ // if there's no header, don't create an (empty) LabelWidget, simply use a placeholder
+ if ( $this->mHeader ) {
+ $element = new OOUI\LabelWidget( [ 'label' => new OOUI\HtmlSnippet( $this->mHeader ) ] );
+ } else {
+ $element = new OOUI\Widget( [] );
+ }
+ $fieldset->addItems( [
+ new OOUI\FieldLayout(
+ $element,
+ [
+ 'align' => 'top',
+ 'errors' => $this->oouiErrors,
+ 'notices' => $this->oouiWarnings,
+ 'classes' => $classes,
+ ]
+ )
+ ], 0 );
+ }
+ }
+ return $fieldset;
+ }
+
+ public function wrapForm( $html ) {
+ $form = new OOUI\FormLayout( $this->getFormAttributes() + [
+ 'classes' => [ 'mw-htmlform', 'mw-htmlform-ooui' ],
+ 'content' => new OOUI\HtmlSnippet( $html ),
+ ] );
+
+ // Include a wrapper for style, if requested.
+ $form = new OOUI\PanelLayout( [
+ 'classes' => [ 'mw-htmlform-ooui-wrapper' ],
+ 'expanded' => false,
+ 'padded' => $this->mWrapperLegend !== false,
+ 'framed' => $this->mWrapperLegend !== false,
+ 'content' => $form,
+ ] );
+
+ return $form;
+ }
+}
diff --git a/www/wiki/includes/htmlform/VFormHTMLForm.php b/www/wiki/includes/htmlform/VFormHTMLForm.php
new file mode 100644
index 00000000..325526ba
--- /dev/null
+++ b/www/wiki/includes/htmlform/VFormHTMLForm.php
@@ -0,0 +1,162 @@
+<?php
+
+/**
+ * HTML form generation and submission handling, vertical-form style.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Compact stacked vertical format for forms.
+ */
+class VFormHTMLForm extends HTMLForm {
+ /**
+ * Wrapper and its legend are never generated in VForm mode.
+ * @var bool
+ */
+ protected $mWrapperLegend = false;
+
+ /**
+ * Symbolic display format name.
+ * @var string
+ */
+ protected $displayFormat = 'vform';
+
+ public function isVForm() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return true;
+ }
+
+ public static function loadInputFromParameters( $fieldname, $descriptor,
+ HTMLForm $parent = null
+ ) {
+ $field = parent::loadInputFromParameters( $fieldname, $descriptor, $parent );
+ $field->setShowEmptyLabel( false );
+ return $field;
+ }
+
+ public function getHTML( $submitResult ) {
+ // This is required for VForm HTMLForms that use that style regardless
+ // of wgUseMediaWikiUIEverywhere (since they pre-date it).
+ // When wgUseMediaWikiUIEverywhere is removed, this should be consolidated
+ // with the addModuleStyles in SpecialPage->setHeaders.
+ $this->getOutput()->addModuleStyles( [
+ 'mediawiki.ui',
+ 'mediawiki.ui.button',
+ 'mediawiki.ui.input',
+ 'mediawiki.ui.checkbox',
+ ] );
+
+ return parent::getHTML( $submitResult );
+ }
+
+ protected function getFormAttributes() {
+ $attribs = parent::getFormAttributes();
+ $attribs['class'] = [ 'mw-htmlform', 'mw-ui-vform', 'mw-ui-container' ];
+ return $attribs;
+ }
+
+ public function wrapForm( $html ) {
+ // Always discard $this->mWrapperLegend
+ return Html::rawElement( 'form', $this->getFormAttributes(), $html );
+ }
+
+ public function getButtons() {
+ $buttons = '';
+
+ if ( $this->mShowSubmit ) {
+ $attribs = [];
+
+ if ( isset( $this->mSubmitID ) ) {
+ $attribs['id'] = $this->mSubmitID;
+ }
+
+ if ( isset( $this->mSubmitName ) ) {
+ $attribs['name'] = $this->mSubmitName;
+ }
+
+ if ( isset( $this->mSubmitTooltip ) ) {
+ $attribs += Linker::tooltipAndAccesskeyAttribs( $this->mSubmitTooltip );
+ }
+
+ $attribs['class'] = [
+ 'mw-htmlform-submit',
+ 'mw-ui-button mw-ui-big mw-ui-block',
+ ];
+ foreach ( $this->mSubmitFlags as $flag ) {
+ $attribs['class'][] = 'mw-ui-' . $flag;
+ }
+
+ $buttons .= Xml::submitButton( $this->getSubmitText(), $attribs ) . "\n";
+ }
+
+ if ( $this->mShowReset ) {
+ $buttons .= Html::element(
+ 'input',
+ [
+ 'type' => 'reset',
+ 'value' => $this->msg( 'htmlform-reset' )->text(),
+ 'class' => 'mw-ui-button mw-ui-big mw-ui-block',
+ ]
+ ) . "\n";
+ }
+
+ if ( $this->mShowCancel ) {
+ $target = $this->mCancelTarget ?: Title::newMainPage();
+ if ( $target instanceof Title ) {
+ $target = $target->getLocalURL();
+ }
+ $buttons .= Html::element(
+ 'a',
+ [
+ 'class' => 'mw-ui-button mw-ui-big mw-ui-block',
+ 'href' => $target,
+ ],
+ $this->msg( 'cancel' )->text()
+ ) . "\n";
+ }
+
+ foreach ( $this->mButtons as $button ) {
+ $attrs = [
+ 'type' => 'submit',
+ 'name' => $button['name'],
+ 'value' => $button['value']
+ ];
+
+ if ( $button['attribs'] ) {
+ $attrs += $button['attribs'];
+ }
+
+ if ( isset( $button['id'] ) ) {
+ $attrs['id'] = $button['id'];
+ }
+
+ $attrs['class'] = isset( $attrs['class'] ) ? (array)$attrs['class'] : [];
+ $attrs['class'][] = 'mw-ui-button mw-ui-big mw-ui-block';
+
+ $buttons .= Html::element( 'input', $attrs ) . "\n";
+ }
+
+ if ( !$buttons ) {
+ return '';
+ }
+
+ return Html::rawElement( 'div',
+ [ 'class' => 'mw-htmlform-submit-buttons' ], "\n$buttons" ) . "\n";
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLApiField.php b/www/wiki/includes/htmlform/fields/HTMLApiField.php
new file mode 100644
index 00000000..24a253ed
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLApiField.php
@@ -0,0 +1,23 @@
+<?php
+
+class HTMLApiField extends HTMLFormField {
+ public function getTableRow( $value ) {
+ return '';
+ }
+
+ public function getDiv( $value ) {
+ return $this->getTableRow( $value );
+ }
+
+ public function getRaw( $value ) {
+ return $this->getTableRow( $value );
+ }
+
+ public function getInputHTML( $value ) {
+ return '';
+ }
+
+ public function hasVisibleOutput() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLAutoCompleteSelectField.php b/www/wiki/includes/htmlform/fields/HTMLAutoCompleteSelectField.php
new file mode 100644
index 00000000..63e77cec
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLAutoCompleteSelectField.php
@@ -0,0 +1,197 @@
+<?php
+
+/**
+ * Text field for selecting a value from a large list of possible values, with
+ * auto-completion and optionally with a select dropdown for selecting common
+ * options.
+ *
+ * HTMLComboboxField implements most of the same functionality and should be
+ * used instead, if possible.
+ *
+ * If one of 'options-messages', 'options', or 'options-message' is provided
+ * and non-empty, the select dropdown will be shown. An 'other' key will be
+ * appended using message 'htmlform-selectorother-other' if not already
+ * present.
+ *
+ * Besides the parameters recognized by HTMLTextField, the following are
+ * recognized:
+ * options-messages - As for HTMLSelectField
+ * options - As for HTMLSelectField
+ * options-message - As for HTMLSelectField
+ * autocomplete-data - Associative array mapping display text to values.
+ * autocomplete-data-messages - Like autocomplete, but keys are message names.
+ * require-match - Boolean, if true the value must be in the options or the
+ * autocomplete.
+ * other-message - Message to use instead of htmlform-selectorother-other for
+ * the 'other' message.
+ * other - Raw text to use for the 'other' message
+ *
+ * The old name of autocomplete-data[-messages] was autocomplete[-messages] which is still
+ * recognized but deprecated since MediaWiki 1.29 since it conflicts with how autocomplete is
+ * used in HTMLTextField.
+ */
+class HTMLAutoCompleteSelectField extends HTMLTextField {
+ protected $autocompleteData = [];
+
+ public function __construct( $params ) {
+ $params += [
+ 'require-match' => false,
+ ];
+
+ // FIXME B/C, remove in 1.30
+ if (
+ array_key_exists( 'autocomplete', $params )
+ && !array_key_exists( 'autocomplete-data', $params )
+ ) {
+ $params['autocomplete-data'] = $params['autocomplete'];
+ unset( $params['autocomplete'] );
+ }
+ if (
+ array_key_exists( 'autocomplete-messages', $params )
+ && !array_key_exists( 'autocomplete-data-messages', $params )
+ ) {
+ $params['autocomplete-data-messages'] = $params['autocomplete-messages'];
+ unset( $params['autocomplete-messages'] );
+ }
+
+ parent::__construct( $params );
+
+ if ( array_key_exists( 'autocomplete-data-messages', $this->mParams ) ) {
+ foreach ( $this->mParams['autocomplete-data-messages'] as $key => $value ) {
+ $key = $this->msg( $key )->plain();
+ $this->autocompleteData[$key] = strval( $value );
+ }
+ } elseif ( array_key_exists( 'autocomplete-data', $this->mParams ) ) {
+ foreach ( $this->mParams['autocomplete-data'] as $key => $value ) {
+ $this->autocompleteData[$key] = strval( $value );
+ }
+ }
+ if ( !is_array( $this->autocompleteData ) || !$this->autocompleteData ) {
+ throw new MWException( 'HTMLAutoCompleteSelectField called without any autocompletions' );
+ }
+
+ $this->getOptions();
+ if ( $this->mOptions && !in_array( 'other', $this->mOptions, true ) ) {
+ if ( isset( $params['other-message'] ) ) {
+ $msg = $this->getMessage( $params['other-message'] )->text();
+ } elseif ( isset( $params['other'] ) ) {
+ $msg = $params['other'];
+ } else {
+ $msg = wfMessage( 'htmlform-selectorother-other' )->text();
+ }
+ $this->mOptions[$msg] = 'other';
+ }
+ }
+
+ public function loadDataFromRequest( $request ) {
+ if ( $request->getCheck( $this->mName ) ) {
+ $val = $request->getText( $this->mName . '-select', 'other' );
+
+ if ( $val === 'other' ) {
+ $val = $request->getText( $this->mName );
+ if ( isset( $this->autocompleteData[$val] ) ) {
+ $val = $this->autocompleteData[$val];
+ }
+ }
+
+ return $val;
+ } else {
+ return $this->getDefault();
+ }
+ }
+
+ public function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ $validOptions = HTMLFormField::flattenOptions( $this->getOptions() ?: [] );
+
+ if ( in_array( strval( $value ), $validOptions, true ) ) {
+ return true;
+ } elseif ( in_array( strval( $value ), $this->autocompleteData, true ) ) {
+ return true;
+ } elseif ( $this->mParams['require-match'] ) {
+ return $this->msg( 'htmlform-select-badoption' );
+ }
+
+ return true;
+ }
+
+ // FIXME Ewww, this shouldn't be adding any attributes not requested in $list :(
+ public function getAttributes( array $list ) {
+ $attribs = [
+ 'type' => 'text',
+ 'data-autocomplete' => FormatJson::encode( array_keys( $this->autocompleteData ) ),
+ ] + parent::getAttributes( $list );
+
+ if ( $this->getOptions() ) {
+ $attribs['data-hide-if'] = FormatJson::encode(
+ [ '!==', $this->mName . '-select', 'other' ]
+ );
+ }
+
+ return $attribs;
+ }
+
+ public function getInputHTML( $value ) {
+ $oldClass = $this->mClass;
+ $this->mClass = (array)$this->mClass;
+
+ $valInSelect = false;
+ $ret = '';
+
+ if ( $this->getOptions() ) {
+ if ( $value !== false ) {
+ $value = strval( $value );
+ $valInSelect = in_array(
+ $value, HTMLFormField::flattenOptions( $this->getOptions() ), true
+ );
+ }
+
+ $selected = $valInSelect ? $value : 'other';
+ $select = new XmlSelect( $this->mName . '-select', $this->mID . '-select', $selected );
+ $select->addOptions( $this->getOptions() );
+ $select->setAttribute( 'class', 'mw-htmlform-select-or-other' );
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $select->setAttribute( 'disabled', 'disabled' );
+ }
+
+ if ( isset( $this->mParams['tabindex'] ) ) {
+ $select->setAttribute( 'tabindex', $this->mParams['tabindex'] );
+ }
+
+ $ret = $select->getHTML() . "<br />\n";
+
+ $this->mClass[] = 'mw-htmlform-hide-if';
+ }
+
+ if ( $valInSelect ) {
+ $value = '';
+ } else {
+ $key = array_search( strval( $value ), $this->autocompleteData, true );
+ if ( $key !== false ) {
+ $value = $key;
+ }
+ }
+
+ $this->mClass[] = 'mw-htmlform-autocomplete';
+ $ret .= parent::getInputHTML( $valInSelect ? '' : $value );
+ $this->mClass = $oldClass;
+
+ return $ret;
+ }
+
+ /**
+ * Get the OOUI version of this input.
+ * @param string $value
+ * @return false
+ */
+ public function getInputOOUI( $value ) {
+ // To be implemented, for now override the function from HTMLTextField
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLButtonField.php b/www/wiki/includes/htmlform/fields/HTMLButtonField.php
new file mode 100644
index 00000000..a19bd5a1
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLButtonField.php
@@ -0,0 +1,142 @@
+<?php
+
+/**
+ * Adds a generic button inline to the form. Does not do anything, you must add
+ * click handling code in JavaScript. Use a HTMLSubmitField if you merely
+ * wish to add a submit button to a form.
+ *
+ * Additional recognized configuration parameters include:
+ * - flags: OOUI flags for the button, see OOUI\FlaggedElement
+ * - buttonlabel-message: Message to use for the button display text, instead
+ * of the value from 'default'. Overrides 'buttonlabel' and 'buttonlabel-raw'.
+ * - buttonlabel: Text to display for the button display text, instead
+ * of the value from 'default'. Overrides 'buttonlabel-raw'.
+ * - buttonlabel-raw: HTMLto display for the button display text, instead
+ * of the value from 'default'.
+ * - formnovalidate: Set to true if clicking this button should suppress
+ * client-side form validation. Used in HTMLFormFieldCloner for add/remove
+ * buttons.
+ *
+ * Note that the buttonlabel parameters are not supported on IE6 and IE7 due to
+ * bugs in those browsers. If detected, they will be served buttons using the
+ * value of 'default' as the button label.
+ *
+ * @since 1.22
+ */
+class HTMLButtonField extends HTMLFormField {
+ protected $buttonType = 'button';
+ protected $buttonLabel = null;
+
+ /** @var array $mFlags Flags to add to OOUI Button widget */
+ protected $mFlags = [];
+
+ protected $mFormnovalidate = false;
+
+ public function __construct( $info ) {
+ $info['nodata'] = true;
+ if ( isset( $info['flags'] ) ) {
+ $this->mFlags = $info['flags'];
+ }
+
+ if ( isset( $info['formnovalidate'] ) ) {
+ $this->mFormnovalidate = $info['formnovalidate'];
+ }
+
+ # Generate the label from a message, if possible
+ if ( isset( $info['buttonlabel-message'] ) ) {
+ $this->buttonLabel = $this->getMessage( $info['buttonlabel-message'] )->parse();
+ } elseif ( isset( $info['buttonlabel'] ) ) {
+ if ( $info['buttonlabel'] === '&#160;' ) {
+ // Apparently some things set &nbsp directly and in an odd format
+ $this->buttonLabel = '&#160;';
+ } else {
+ $this->buttonLabel = htmlspecialchars( $info['buttonlabel'] );
+ }
+ } elseif ( isset( $info['buttonlabel-raw'] ) ) {
+ $this->buttonLabel = $info['buttonlabel-raw'];
+ }
+
+ $this->setShowEmptyLabel( false );
+
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ $flags = '';
+ $prefix = 'mw-htmlform-';
+ if ( $this->mParent instanceof VFormHTMLForm ||
+ $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' )
+ ) {
+ $prefix = 'mw-ui-';
+ // add mw-ui-button separately, so the descriptor doesn't need to set it
+ $flags .= ' ' . $prefix . 'button';
+ }
+ foreach ( $this->mFlags as $flag ) {
+ $flags .= ' ' . $prefix . $flag;
+ }
+ $attr = [
+ 'class' => 'mw-htmlform-submit ' . $this->mClass . $flags,
+ 'id' => $this->mID,
+ 'type' => $this->buttonType,
+ 'name' => $this->mName,
+ 'value' => $this->getDefault(),
+ 'formnovalidate' => $this->mFormnovalidate,
+ ] + $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ if ( $this->isBadIE() ) {
+ return Html::element( 'input', $attr );
+ } else {
+ return Html::rawElement( 'button', $attr,
+ $this->buttonLabel ?: htmlspecialchars( $this->getDefault() ) );
+ }
+ }
+
+ /**
+ * Get the OOUI widget for this field.
+ * @param string $value
+ * @return OOUI\ButtonInputWidget
+ */
+ public function getInputOOUI( $value ) {
+ return new OOUI\ButtonInputWidget( [
+ 'name' => $this->mName,
+ 'value' => $this->getDefault(),
+ 'label' => !$this->isBadIE() && $this->buttonLabel
+ ? new OOUI\HtmlSnippet( $this->buttonLabel )
+ : $this->getDefault(),
+ 'type' => $this->buttonType,
+ 'classes' => [ 'mw-htmlform-submit', $this->mClass ],
+ 'id' => $this->mID,
+ 'flags' => $this->mFlags,
+ 'useInputTag' => $this->isBadIE(),
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ ) );
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+
+ /**
+ * Button cannot be invalid
+ *
+ * @param string $value
+ * @param array $alldata
+ *
+ * @return bool|string|Message
+ */
+ public function validate( $value, $alldata ) {
+ return true;
+ }
+
+ /**
+ * IE<8 has bugs with <button>, so we'll need to avoid them.
+ * @return bool Whether the request is from a bad version of IE
+ */
+ private function isBadIE() {
+ $request = $this->mParent
+ ? $this->mParent->getRequest()
+ : RequestContext::getMain()->getRequest();
+ return (bool)preg_match( '/MSIE [1-7]\./i', $request->getHeader( 'User-Agent' ) );
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLCheckField.php b/www/wiki/includes/htmlform/fields/HTMLCheckField.php
new file mode 100644
index 00000000..9a956fbf
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLCheckField.php
@@ -0,0 +1,131 @@
+<?php
+
+/**
+ * A checkbox field
+ */
+class HTMLCheckField extends HTMLFormField {
+ public function getInputHTML( $value ) {
+ global $wgUseMediaWikiUIEverywhere;
+
+ if ( !empty( $this->mParams['invert'] ) ) {
+ $value = !$value;
+ }
+
+ $attr = $this->getTooltipAndAccessKey();
+ $attr['id'] = $this->mID;
+
+ $attr += $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ if ( $this->mClass !== '' ) {
+ $attr['class'] = $this->mClass;
+ }
+
+ $attrLabel = [ 'for' => $this->mID ];
+ if ( isset( $attr['title'] ) ) {
+ // propagate tooltip to label
+ $attrLabel['title'] = $attr['title'];
+ }
+
+ $chkLabel = Xml::check( $this->mName, $value, $attr ) .
+ '&#160;' .
+ Html::rawElement( 'label', $attrLabel, $this->mLabel );
+
+ if ( $wgUseMediaWikiUIEverywhere || $this->mParent instanceof VFormHTMLForm ) {
+ $chkLabel = Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-ui-checkbox' ],
+ $chkLabel
+ );
+ }
+
+ return $chkLabel;
+ }
+
+ /**
+ * Get the OOUI version of this field.
+ * @since 1.26
+ * @param string $value
+ * @return OOUI\CheckboxInputWidget The checkbox widget.
+ */
+ public function getInputOOUI( $value ) {
+ if ( !empty( $this->mParams['invert'] ) ) {
+ $value = !$value;
+ }
+
+ $attr = $this->getTooltipAndAccessKeyOOUI();
+ $attr['id'] = $this->mID;
+ $attr['name'] = $this->mName;
+
+ $attr += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $attr['classes'] = [ $this->mClass ];
+ }
+
+ $attr['selected'] = $value;
+ $attr['value'] = '1'; // Nasty hack, but needed to make this work
+
+ return new OOUI\CheckboxInputWidget( $attr );
+ }
+
+ /**
+ * For a checkbox, the label goes on the right hand side, and is
+ * added in getInputHTML(), rather than HTMLFormField::getRow()
+ *
+ * ...unless OOUI is being used, in which case we actually return
+ * the label here.
+ *
+ * @return string
+ */
+ public function getLabel() {
+ if ( $this->mParent instanceof OOUIHTMLForm ) {
+ return $this->mLabel;
+ } elseif (
+ $this->mParent instanceof HTMLForm &&
+ $this->mParent->getDisplayFormat() === 'div'
+ ) {
+ return '';
+ } else {
+ return '&#160;';
+ }
+ }
+
+ /**
+ * Get label alignment when generating field for OOUI.
+ * @return string 'left', 'right', 'top' or 'inline'
+ */
+ protected function getLabelAlignOOUI() {
+ return 'inline';
+ }
+
+ /**
+ * checkboxes don't need a label.
+ * @return bool
+ */
+ protected function needsLabel() {
+ return false;
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return bool
+ */
+ public function loadDataFromRequest( $request ) {
+ $invert = isset( $this->mParams['invert'] ) && $this->mParams['invert'];
+
+ // GetCheck won't work like we want for checks.
+ // Fetch the value in either one of the two following case:
+ // - we have a valid submit attempt (form was just submitted, or a GET URL forged by the user)
+ // - checkbox name has a value (false or true), ie is not null
+ if ( $this->isSubmitAttempt( $request ) || $request->getVal( $this->mName ) !== null ) {
+ return $invert
+ ? !$request->getBool( $this->mName )
+ : $request->getBool( $this->mName );
+ } else {
+ return (bool)$this->getDefault();
+ }
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLCheckMatrix.php b/www/wiki/includes/htmlform/fields/HTMLCheckMatrix.php
new file mode 100644
index 00000000..fa18a3cd
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLCheckMatrix.php
@@ -0,0 +1,266 @@
+<?php
+
+/**
+ * A checkbox matrix
+ * Operates similarly to HTMLMultiSelectField, but instead of using an array of
+ * options, uses an array of rows and an array of columns to dynamically
+ * construct a matrix of options. The tags used to identify a particular cell
+ * are of the form "columnName-rowName"
+ *
+ * Options:
+ * - columns
+ * - Required list of columns in the matrix.
+ * - rows
+ * - Required list of rows in the matrix.
+ * - force-options-on
+ * - Accepts array of column-row tags to be displayed as enabled but unavailable to change
+ * - force-options-off
+ * - Accepts array of column-row tags to be displayed as disabled but unavailable to change.
+ * - tooltips
+ * - Optional array mapping row label to tooltip content
+ * - tooltip-class
+ * - Optional CSS class used on tooltip container span. Defaults to mw-icon-question.
+ */
+class HTMLCheckMatrix extends HTMLFormField implements HTMLNestedFilterable {
+ static private $requiredParams = [
+ // Required by underlying HTMLFormField
+ 'fieldname',
+ // Required by HTMLCheckMatrix
+ 'rows',
+ 'columns'
+ ];
+
+ public function __construct( $params ) {
+ $missing = array_diff( self::$requiredParams, array_keys( $params ) );
+ if ( $missing ) {
+ throw new HTMLFormFieldRequiredOptionsException( $this, $missing );
+ }
+ parent::__construct( $params );
+ }
+
+ public function validate( $value, $alldata ) {
+ $rows = $this->mParams['rows'];
+ $columns = $this->mParams['columns'];
+
+ // Make sure user-defined validation callback is run
+ $p = parent::validate( $value, $alldata );
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ // Make sure submitted value is an array
+ if ( !is_array( $value ) ) {
+ return false;
+ }
+
+ // If all options are valid, array_intersect of the valid options
+ // and the provided options will return the provided options.
+ $validOptions = [];
+ foreach ( $rows as $rowTag ) {
+ foreach ( $columns as $columnTag ) {
+ $validOptions[] = $columnTag . '-' . $rowTag;
+ }
+ }
+ $validValues = array_intersect( $value, $validOptions );
+ if ( count( $validValues ) == count( $value ) ) {
+ return true;
+ } else {
+ return $this->msg( 'htmlform-select-badoption' );
+ }
+ }
+
+ /**
+ * Build a table containing a matrix of checkbox options.
+ * The value of each option is a combination of the row tag and column tag.
+ * mParams['rows'] is an array with row labels as keys and row tags as values.
+ * mParams['columns'] is an array with column labels as keys and column tags as values.
+ *
+ * @param array $value Array of the options that should be checked
+ *
+ * @return string
+ */
+ public function getInputHTML( $value ) {
+ $html = '';
+ $tableContents = '';
+ $rows = $this->mParams['rows'];
+ $columns = $this->mParams['columns'];
+
+ $attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ // Build the column headers
+ $headerContents = Html::rawElement( 'td', [], '&#160;' );
+ foreach ( $columns as $columnLabel => $columnTag ) {
+ $headerContents .= Html::rawElement( 'td', [], $columnLabel );
+ }
+ $tableContents .= Html::rawElement( 'tr', [], "\n$headerContents\n" );
+
+ $tooltipClass = 'mw-icon-question';
+ if ( isset( $this->mParams['tooltip-class'] ) ) {
+ $tooltipClass = $this->mParams['tooltip-class'];
+ }
+
+ // Build the options matrix
+ foreach ( $rows as $rowLabel => $rowTag ) {
+ // Append tooltip if configured
+ if ( isset( $this->mParams['tooltips'][$rowLabel] ) ) {
+ $tooltipAttribs = [
+ 'class' => "mw-htmlform-tooltip $tooltipClass",
+ 'title' => $this->mParams['tooltips'][$rowLabel],
+ ];
+ $rowLabel .= ' ' . Html::element( 'span', $tooltipAttribs, '' );
+ }
+ $rowContents = Html::rawElement( 'td', [], $rowLabel );
+ foreach ( $columns as $columnTag ) {
+ $thisTag = "$columnTag-$rowTag";
+ // Construct the checkbox
+ $thisAttribs = [
+ 'id' => "{$this->mID}-$thisTag",
+ 'value' => $thisTag,
+ ];
+ $checked = in_array( $thisTag, (array)$value, true );
+ if ( $this->isTagForcedOff( $thisTag ) ) {
+ $checked = false;
+ $thisAttribs['disabled'] = 1;
+ } elseif ( $this->isTagForcedOn( $thisTag ) ) {
+ $checked = true;
+ $thisAttribs['disabled'] = 1;
+ }
+
+ $checkbox = $this->getOneCheckbox( $checked, $attribs + $thisAttribs );
+
+ $rowContents .= Html::rawElement(
+ 'td',
+ [],
+ $checkbox
+ );
+ }
+ $tableContents .= Html::rawElement( 'tr', [], "\n$rowContents\n" );
+ }
+
+ // Put it all in a table
+ $html .= Html::rawElement( 'table',
+ [ 'class' => 'mw-htmlform-matrix' ],
+ Html::rawElement( 'tbody', [], "\n$tableContents\n" ) ) . "\n";
+
+ return $html;
+ }
+
+ protected function getOneCheckbox( $checked, $attribs ) {
+ if ( $this->mParent instanceof OOUIHTMLForm ) {
+ return new OOUI\CheckboxInputWidget( [
+ 'name' => "{$this->mName}[]",
+ 'selected' => $checked,
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $attribs
+ ) );
+ } else {
+ $checkbox = Xml::check( "{$this->mName}[]", $checked, $attribs );
+ if ( $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $checkbox = Html::openElement( 'div', [ 'class' => 'mw-ui-checkbox' ] ) .
+ $checkbox .
+ Html::element( 'label', [ 'for' => $attribs['id'] ] ) .
+ Html::closeElement( 'div' );
+ }
+ return $checkbox;
+ }
+ }
+
+ protected function isTagForcedOff( $tag ) {
+ return isset( $this->mParams['force-options-off'] )
+ && in_array( $tag, $this->mParams['force-options-off'] );
+ }
+
+ protected function isTagForcedOn( $tag ) {
+ return isset( $this->mParams['force-options-on'] )
+ && in_array( $tag, $this->mParams['force-options-on'] );
+ }
+
+ /**
+ * Get the complete table row for the input, including help text,
+ * labels, and whatever.
+ * We override this function since the label should always be on a separate
+ * line above the options in the case of a checkbox matrix, i.e. it's always
+ * a "vertical-label".
+ *
+ * @param string $value The value to set the input to
+ *
+ * @return string Complete HTML table row
+ */
+ public function getTableRow( $value ) {
+ list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
+ $inputHtml = $this->getInputHTML( $value );
+ $fieldType = static::class;
+ $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
+ $cellAttributes = [ 'colspan' => 2 ];
+
+ $hideClass = '';
+ $hideAttributes = [];
+ if ( $this->mHideIf ) {
+ $hideAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
+ $hideClass = 'mw-htmlform-hide-if';
+ }
+
+ $label = $this->getLabelHtml( $cellAttributes );
+
+ $field = Html::rawElement(
+ 'td',
+ [ 'class' => 'mw-input' ] + $cellAttributes,
+ $inputHtml . "\n$errors"
+ );
+
+ $html = Html::rawElement( 'tr',
+ [ 'class' => "mw-htmlform-vertical-label $hideClass" ] + $hideAttributes,
+ $label );
+ $html .= Html::rawElement( 'tr',
+ [ 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $hideClass" ] +
+ $hideAttributes,
+ $field );
+
+ return $html . $helptext;
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return array
+ */
+ public function loadDataFromRequest( $request ) {
+ if ( $this->isSubmitAttempt( $request ) ) {
+ // Checkboxes are just not added to the request arrays if they're not checked,
+ // so it's perfectly possible for there not to be an entry at all
+ return $request->getArray( $this->mName, [] );
+ } else {
+ // That's ok, the user has not yet submitted the form, so show the defaults
+ return $this->getDefault();
+ }
+ }
+
+ public function getDefault() {
+ if ( isset( $this->mDefault ) ) {
+ return $this->mDefault;
+ } else {
+ return [];
+ }
+ }
+
+ public function filterDataForSubmit( $data ) {
+ $columns = HTMLFormField::flattenOptions( $this->mParams['columns'] );
+ $rows = HTMLFormField::flattenOptions( $this->mParams['rows'] );
+ $res = [];
+ foreach ( $columns as $column ) {
+ foreach ( $rows as $row ) {
+ // Make sure option hasn't been forced
+ $thisTag = "$column-$row";
+ if ( $this->isTagForcedOff( $thisTag ) ) {
+ $res[$thisTag] = false;
+ } elseif ( $this->isTagForcedOn( $thisTag ) ) {
+ $res[$thisTag] = true;
+ } else {
+ $res[$thisTag] = in_array( $thisTag, $data );
+ }
+ }
+ }
+
+ return $res;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLComboboxField.php b/www/wiki/includes/htmlform/fields/HTMLComboboxField.php
new file mode 100644
index 00000000..3f63c18e
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLComboboxField.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * A combo box field.
+ *
+ * You can think of it as a dropdown select with the ability to add custom options,
+ * or as a text field with input suggestions (autocompletion).
+ *
+ * When JavaScript is not supported or enabled, it uses HTML5 `<datalist>` element.
+ *
+ * Besides the parameters recognized by HTMLTextField, the following are
+ * recognized:
+ * options-messages - As for HTMLSelectField
+ * options - As for HTMLSelectField
+ * options-message - As for HTMLSelectField
+ */
+class HTMLComboboxField extends HTMLTextField {
+ // FIXME Ewww, this shouldn't be adding any attributes not requested in $list :(
+ public function getAttributes( array $list ) {
+ $attribs = [
+ 'type' => 'text',
+ 'list' => $this->mName . '-datalist',
+ ] + parent::getAttributes( $list );
+
+ return $attribs;
+ }
+
+ public function getInputHTML( $value ) {
+ $datalist = new XmlSelect( false, $this->mName . '-datalist' );
+ $datalist->setTagName( 'datalist' );
+ $datalist->addOptions( $this->getOptions() );
+
+ return parent::getInputHTML( $value ) . $datalist->getHTML();
+ }
+
+ public function getInputOOUI( $value ) {
+ $disabled = false;
+ $allowedParams = [ 'tabindex' ];
+ $attribs = OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $attribs['classes'] = [ $this->mClass ];
+ }
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $disabled = true;
+ }
+
+ return new OOUI\ComboBoxInputWidget( [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'options' => $this->getOptionsOOUI(),
+ 'value' => strval( $value ),
+ 'disabled' => $disabled,
+ ] + $attribs );
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLDateTimeField.php b/www/wiki/includes/htmlform/fields/HTMLDateTimeField.php
new file mode 100644
index 00000000..7b59a1d6
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLDateTimeField.php
@@ -0,0 +1,185 @@
+<?php
+
+/**
+ * A field that will contain a date and/or time
+ *
+ * Currently recognizes only {YYYY}-{MM}-{DD}T{HH}:{MM}:{SS.S*}Z formatted dates.
+ *
+ * Besides the parameters recognized by HTMLTextField, additional recognized
+ * parameters in the field descriptor array include:
+ * type - 'date', 'time', or 'datetime'
+ * min - The minimum date to allow, in any recognized format.
+ * max - The maximum date to allow, in any recognized format.
+ * placeholder - The default comes from the htmlform-(date|time|datetime)-placeholder message.
+ *
+ * The result is a formatted date.
+ *
+ * @note This widget is not likely to work well in non-OOUI forms.
+ */
+class HTMLDateTimeField extends HTMLTextField {
+ protected static $patterns = [
+ 'date' => '[0-9]{4}-[01][0-9]-[0-3][0-9]',
+ 'time' => '[0-2][0-9]:[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?',
+ 'datetime' => '[0-9]{4}-[01][0-9]-[0-3][0-9][T ][0-2][0-9]:[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?Z?',
+ ];
+
+ protected $mType = 'datetime';
+
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ $this->mType = array_key_exists( 'type', $params )
+ ? $params['type']
+ : 'datetime';
+
+ if ( !in_array( $this->mType, [ 'date', 'time', 'datetime' ] ) ) {
+ throw new InvalidArgumentException( "Invalid type '$this->mType'" );
+ }
+
+ if ( $this->mPlaceholder === '' ) {
+ // Messages: htmlform-date-placeholder htmlform-time-placeholder htmlform-datetime-placeholder
+ $this->mPlaceholder = $this->msg( "htmlform-{$this->mType}-placeholder" )->text();
+ }
+
+ $this->mClass .= ' mw-htmlform-datetime-field';
+ }
+
+ public function getAttributes( array $list ) {
+ $parentList = array_diff( $list, [ 'min', 'max' ] );
+ $ret = parent::getAttributes( $parentList );
+
+ if ( in_array( 'min', $list ) && isset( $this->mParams['min'] ) ) {
+ $min = $this->parseDate( $this->mParams['min'] );
+ if ( $min ) {
+ $ret['min'] = $this->formatDate( $min );
+ }
+ }
+ if ( in_array( 'max', $list ) && isset( $this->mParams['max'] ) ) {
+ $max = $this->parseDate( $this->mParams['max'] );
+ if ( $max ) {
+ $ret['max'] = $this->formatDate( $max );
+ }
+ }
+
+ $ret['step'] = 1;
+
+ $ret['type'] = $this->mType;
+ $ret['pattern'] = static::$patterns[$this->mType];
+
+ return $ret;
+ }
+
+ public function loadDataFromRequest( $request ) {
+ if ( !$request->getCheck( $this->mName ) ) {
+ return $this->getDefault();
+ }
+
+ $value = $request->getText( $this->mName );
+ $date = $this->parseDate( $value );
+ return $date ? $this->formatDate( $date ) : $value;
+ }
+
+ public function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ if ( $value === '' ) {
+ // required was already checked by parent::validate
+ return true;
+ }
+
+ $date = $this->parseDate( $value );
+ if ( !$date ) {
+ // Messages: htmlform-date-invalid htmlform-time-invalid htmlform-datetime-invalid
+ return $this->msg( "htmlform-{$this->mType}-invalid" );
+ }
+
+ if ( isset( $this->mParams['min'] ) ) {
+ $min = $this->parseDate( $this->mParams['min'] );
+ if ( $min && $date < $min ) {
+ // Messages: htmlform-date-toolow htmlform-time-toolow htmlform-datetime-toolow
+ return $this->msg( "htmlform-{$this->mType}-toolow", $this->formatDate( $min ) );
+ }
+ }
+
+ if ( isset( $this->mParams['max'] ) ) {
+ $max = $this->parseDate( $this->mParams['max'] );
+ if ( $max && $date > $max ) {
+ // Messages: htmlform-date-toohigh htmlform-time-toohigh htmlform-datetime-toohigh
+ return $this->msg( "htmlform-{$this->mType}-toohigh", $this->formatDate( $max ) );
+ }
+ }
+
+ return true;
+ }
+
+ protected function parseDate( $value ) {
+ $value = trim( $value );
+ if ( $value === '' ) {
+ return false;
+ }
+
+ if ( $this->mType === 'date' ) {
+ $value .= ' T00:00:00+0000';
+ }
+ if ( $this->mType === 'time' ) {
+ $value = '1970-01-01 ' . $value . '+0000';
+ }
+
+ try {
+ $date = new DateTime( $value, new DateTimeZone( 'GMT' ) );
+ return $date->getTimestamp();
+ } catch ( Exception $ex ) {
+ return false;
+ }
+ }
+
+ protected function formatDate( $value ) {
+ switch ( $this->mType ) {
+ case 'date':
+ return gmdate( 'Y-m-d', $value );
+
+ case 'time':
+ return gmdate( 'H:i:s', $value );
+
+ case 'datetime':
+ return gmdate( 'Y-m-d\\TH:i:s\\Z', $value );
+ }
+ }
+
+ public function getInputOOUI( $value ) {
+ $params = [
+ 'type' => $this->mType,
+ 'value' => $value,
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ ];
+
+ if ( isset( $this->mParams['min'] ) ) {
+ $min = $this->parseDate( $this->mParams['min'] );
+ if ( $min ) {
+ $params['min'] = $this->formatDate( $min );
+ }
+ }
+ if ( isset( $this->mParams['max'] ) ) {
+ $max = $this->parseDate( $this->mParams['max'] );
+ if ( $max ) {
+ $params['max'] = $this->formatDate( $max );
+ }
+ }
+
+ return new MediaWiki\Widget\DateTimeInputWidget( $params );
+ }
+
+ protected function getOOUIModules() {
+ return [ 'mediawiki.widgets.datetime' ];
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLEditTools.php b/www/wiki/includes/htmlform/fields/HTMLEditTools.php
new file mode 100644
index 00000000..1b5d1fb4
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLEditTools.php
@@ -0,0 +1,51 @@
+<?php
+
+class HTMLEditTools extends HTMLFormField {
+ public function getInputHTML( $value ) {
+ return '';
+ }
+
+ public function getTableRow( $value ) {
+ $msg = $this->formatMsg();
+
+ return
+ '<tr><td></td><td class="mw-input">' .
+ '<div class="mw-editTools">' .
+ $msg->parseAsBlock() .
+ "</div></td></tr>\n";
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getDiv( $value ) {
+ $msg = $this->formatMsg();
+
+ return '<div class="mw-editTools">' . $msg->parseAsBlock() . '</div>';
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getRaw( $value ) {
+ return $this->getDiv( $value );
+ }
+
+ protected function formatMsg() {
+ if ( empty( $this->mParams['message'] ) ) {
+ $msg = $this->msg( 'edittools' );
+ } else {
+ $msg = $this->getMessage( $this->mParams['message'] );
+ if ( $msg->isDisabled() ) {
+ $msg = $this->msg( 'edittools' );
+ }
+ }
+ $msg->inContentLanguage();
+
+ return $msg;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLFloatField.php b/www/wiki/includes/htmlform/fields/HTMLFloatField.php
new file mode 100644
index 00000000..d2d54e28
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLFloatField.php
@@ -0,0 +1,46 @@
+<?php
+
+/**
+ * A field that will contain a numeric value
+ */
+class HTMLFloatField extends HTMLTextField {
+ public function getSize() {
+ return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 20;
+ }
+
+ public function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ $value = trim( $value );
+
+ # https://www.w3.org/TR/html5/infrastructure.html#floating-point-numbers
+ # with the addition that a leading '+' sign is ok.
+ if ( !preg_match( '/^((\+|\-)?\d+(\.\d+)?(E(\+|\-)?\d+)?)?$/i', $value ) ) {
+ return $this->msg( 'htmlform-float-invalid' );
+ }
+
+ # The "int" part of these message names is rather confusing.
+ # They make equal sense for all numbers.
+ if ( isset( $this->mParams['min'] ) ) {
+ $min = $this->mParams['min'];
+
+ if ( $min > $value ) {
+ return $this->msg( 'htmlform-int-toolow', $min );
+ }
+ }
+
+ if ( isset( $this->mParams['max'] ) ) {
+ $max = $this->mParams['max'];
+
+ if ( $max < $value ) {
+ return $this->msg( 'htmlform-int-toohigh', $max );
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLFormFieldCloner.php b/www/wiki/includes/htmlform/fields/HTMLFormFieldCloner.php
new file mode 100644
index 00000000..53c68359
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLFormFieldCloner.php
@@ -0,0 +1,397 @@
+<?php
+
+/**
+ * A container for HTMLFormFields that allows for multiple copies of the set of
+ * fields to be displayed to and entered by the user.
+ *
+ * Recognized parameters, besides the general ones, include:
+ * fields - HTMLFormField descriptors for the subfields this cloner manages.
+ * The format is just like for the HTMLForm. A field with key 'delete' is
+ * special: it must have type = submit and will serve to delete the group
+ * of fields.
+ * required - If specified, at least one group of fields must be submitted.
+ * format - HTMLForm display format to use when displaying the subfields:
+ * 'table', 'div', or 'raw'.
+ * row-legend - If non-empty, each group of subfields will be enclosed in a
+ * fieldset. The value is the name of a message key to use as the legend.
+ * create-button-message - Message to use as the text of the button to
+ * add an additional group of fields.
+ * delete-button-message - Message to use as the text of automatically-
+ * generated 'delete' button. Ignored if 'delete' is included in 'fields'.
+ *
+ * In the generated HTML, the subfields will be named along the lines of
+ * "clonerName[index][fieldname]", with ids "clonerId--index--fieldid". 'index'
+ * may be a number or an arbitrary string, and may likely change when the page
+ * is resubmitted. Cloners may be nested, resulting in field names along the
+ * lines of "cloner1Name[index1][cloner2Name][index2][fieldname]" and
+ * corresponding ids.
+ *
+ * Use of cloner may result in submissions of the page that are not submissions
+ * of the HTMLForm, when non-JavaScript clients use the create or remove buttons.
+ *
+ * The result is an array, with values being arrays mapping subfield names to
+ * their values. On non-HTMLForm-submission page loads, there may also be
+ * additional (string) keys present with other types of values.
+ *
+ * @since 1.23
+ */
+class HTMLFormFieldCloner extends HTMLFormField {
+ private static $counter = 0;
+
+ /**
+ * @var string String uniquely identifying this cloner instance and
+ * unlikely to exist otherwise in the generated HTML, while still being
+ * valid as part of an HTML id.
+ */
+ protected $uniqueId;
+
+ public function __construct( $params ) {
+ $this->uniqueId = static::class . ++self::$counter . 'x';
+ parent::__construct( $params );
+
+ if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
+ throw new MWException( 'HTMLFormFieldCloner called without any fields' );
+ }
+
+ // Make sure the delete button, if explicitly specified, is sane
+ if ( isset( $this->mParams['fields']['delete'] ) ) {
+ $class = 'mw-htmlform-cloner-delete-button';
+ $info = $this->mParams['fields']['delete'] + [
+ 'formnovalidate' => true,
+ 'cssclass' => $class
+ ];
+ unset( $info['name'], $info['class'] );
+
+ if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
+ throw new MWException(
+ 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
+ );
+ }
+
+ if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
+ $info['cssclass'] .= " $class";
+ }
+
+ $this->mParams['fields']['delete'] = $info;
+ }
+ }
+
+ /**
+ * Create the HTMLFormFields that go inside this element, using the
+ * specified key.
+ *
+ * @param string $key Array key under which these fields should be named
+ * @return HTMLFormField[]
+ */
+ protected function createFieldsForKey( $key ) {
+ $fields = [];
+ foreach ( $this->mParams['fields'] as $fieldname => $info ) {
+ $name = "{$this->mName}[$key][$fieldname]";
+ if ( isset( $info['name'] ) ) {
+ $info['name'] = "{$this->mName}[$key][{$info['name']}]";
+ } else {
+ $info['name'] = $name;
+ }
+ if ( isset( $info['id'] ) ) {
+ $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
+ } else {
+ $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
+ }
+ // Copy the hide-if rules to "child" fields, so that the JavaScript code handling them
+ // (resources/src/mediawiki/htmlform/hide-if.js) doesn't have to handle nested fields.
+ if ( $this->mHideIf ) {
+ if ( isset( $info['hide-if'] ) ) {
+ // Hide child field if either its rules say it's hidden, or parent's rules say it's hidden
+ $info['hide-if'] = [ 'OR', $info['hide-if'], $this->mHideIf ];
+ } else {
+ // Hide child field if parent's rules say it's hidden
+ $info['hide-if'] = $this->mHideIf;
+ }
+ }
+ $field = HTMLForm::loadInputFromParameters( $name, $info, $this->mParent );
+ $fields[$fieldname] = $field;
+ }
+ return $fields;
+ }
+
+ /**
+ * Re-key the specified values array to match the names applied by
+ * createFieldsForKey().
+ *
+ * @param string $key Array key under which these fields should be named
+ * @param array $values Values array from the request
+ * @return array
+ */
+ protected function rekeyValuesArray( $key, $values ) {
+ $data = [];
+ foreach ( $values as $fieldname => $value ) {
+ $name = "{$this->mName}[$key][$fieldname]";
+ $data[$name] = $value;
+ }
+ return $data;
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+
+ public function loadDataFromRequest( $request ) {
+ // It's possible that this might be posted with no fields. Detect that
+ // by looking for an edit token.
+ if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
+ return $this->getDefault();
+ }
+
+ $values = $request->getArray( $this->mName );
+ if ( $values === null ) {
+ $values = [];
+ }
+
+ $ret = [];
+ foreach ( $values as $key => $value ) {
+ if ( $key === 'create' || isset( $value['delete'] ) ) {
+ $ret['nonjs'] = 1;
+ continue;
+ }
+
+ // Add back in $request->getValues() so things that look for e.g.
+ // wpEditToken don't fail.
+ $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
+
+ $fields = $this->createFieldsForKey( $key );
+ $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
+ $row = [];
+ foreach ( $fields as $fieldname => $field ) {
+ if ( $field->skipLoadData( $subrequest ) ) {
+ continue;
+ } elseif ( !empty( $field->mParams['disabled'] ) ) {
+ $row[$fieldname] = $field->getDefault();
+ } else {
+ $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
+ }
+ }
+ $ret[] = $row;
+ }
+
+ if ( isset( $values['create'] ) ) {
+ // Non-JS client clicked the "create" button.
+ $fields = $this->createFieldsForKey( $this->uniqueId );
+ $row = [];
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !empty( $field->mParams['nodata'] ) ) {
+ continue;
+ } else {
+ $row[$fieldname] = $field->getDefault();
+ }
+ }
+ $ret[] = $row;
+ }
+
+ return $ret;
+ }
+
+ public function getDefault() {
+ $ret = parent::getDefault();
+
+ // The default default is one entry with all subfields at their
+ // defaults.
+ if ( $ret === null ) {
+ $fields = $this->createFieldsForKey( $this->uniqueId );
+ $row = [];
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !empty( $field->mParams['nodata'] ) ) {
+ continue;
+ } else {
+ $row[$fieldname] = $field->getDefault();
+ }
+ }
+ $ret = [ $row ];
+ }
+
+ return $ret;
+ }
+
+ public function cancelSubmit( $values, $alldata ) {
+ if ( isset( $values['nonjs'] ) ) {
+ return true;
+ }
+
+ foreach ( $values as $key => $value ) {
+ $fields = $this->createFieldsForKey( $key );
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !array_key_exists( $fieldname, $value ) ) {
+ continue;
+ }
+ if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
+ return true;
+ }
+ }
+ }
+
+ return parent::cancelSubmit( $values, $alldata );
+ }
+
+ public function validate( $values, $alldata ) {
+ if ( isset( $this->mParams['required'] )
+ && $this->mParams['required'] !== false
+ && !$values
+ ) {
+ return $this->msg( 'htmlform-cloner-required' );
+ }
+
+ if ( isset( $values['nonjs'] ) ) {
+ // The submission was a non-JS create/delete click, so fail
+ // validation in case cancelSubmit() somehow didn't already handle
+ // it.
+ return false;
+ }
+
+ foreach ( $values as $key => $value ) {
+ $fields = $this->createFieldsForKey( $key );
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !array_key_exists( $fieldname, $value ) ) {
+ continue;
+ }
+ if ( $field->isHidden( $alldata ) ) {
+ continue;
+ }
+ $ok = $field->validate( $value[$fieldname], $alldata );
+ if ( $ok !== true ) {
+ return false;
+ }
+ }
+ }
+
+ return parent::validate( $values, $alldata );
+ }
+
+ /**
+ * Get the input HTML for the specified key.
+ *
+ * @param string $key Array key under which the fields should be named
+ * @param array $values
+ * @return string
+ */
+ protected function getInputHTMLForKey( $key, array $values ) {
+ $displayFormat = isset( $this->mParams['format'] )
+ ? $this->mParams['format']
+ : $this->mParent->getDisplayFormat();
+
+ // Conveniently, PHP method names are case-insensitive.
+ $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
+
+ $html = '';
+ $hidden = '';
+ $hasLabel = false;
+
+ $fields = $this->createFieldsForKey( $key );
+ foreach ( $fields as $fieldname => $field ) {
+ $v = array_key_exists( $fieldname, $values )
+ ? $values[$fieldname]
+ : $field->getDefault();
+
+ if ( $field instanceof HTMLHiddenField ) {
+ // HTMLHiddenField doesn't generate its own HTML
+ list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
+ $hidden .= Html::hidden( $name, $value, $params ) . "\n";
+ } else {
+ $html .= $field->$getFieldHtmlMethod( $v );
+
+ $labelValue = trim( $field->getLabel() );
+ if ( $labelValue != '&#160;' && $labelValue !== '' ) {
+ $hasLabel = true;
+ }
+ }
+ }
+
+ if ( !isset( $fields['delete'] ) ) {
+ $name = "{$this->mName}[$key][delete]";
+ $label = isset( $this->mParams['delete-button-message'] )
+ ? $this->mParams['delete-button-message']
+ : 'htmlform-cloner-delete';
+ $field = HTMLForm::loadInputFromParameters( $name, [
+ 'type' => 'submit',
+ 'formnovalidate' => true,
+ 'name' => $name,
+ 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
+ 'cssclass' => 'mw-htmlform-cloner-delete-button',
+ 'default' => $this->getMessage( $label )->text(),
+ ], $this->mParent );
+ $v = $field->getDefault();
+
+ if ( $displayFormat === 'table' ) {
+ $html .= $field->$getFieldHtmlMethod( $v );
+ } else {
+ $html .= $field->getInputHTML( $v );
+ }
+ }
+
+ if ( $displayFormat !== 'raw' ) {
+ $classes = [
+ 'mw-htmlform-cloner-row',
+ ];
+
+ if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
+ $classes[] = 'mw-htmlform-nolabel';
+ }
+
+ $attribs = [
+ 'class' => implode( ' ', $classes ),
+ ];
+
+ if ( $displayFormat === 'table' ) {
+ $html = Html::rawElement( 'table',
+ $attribs,
+ Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
+ } else {
+ $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
+ }
+ }
+
+ $html .= $hidden;
+
+ if ( !empty( $this->mParams['row-legend'] ) ) {
+ $legend = $this->msg( $this->mParams['row-legend'] )->text();
+ $html = Xml::fieldset( $legend, $html );
+ }
+
+ return $html;
+ }
+
+ public function getInputHTML( $values ) {
+ $html = '';
+
+ foreach ( (array)$values as $key => $value ) {
+ if ( $key === 'nonjs' ) {
+ continue;
+ }
+ $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
+ $this->getInputHTMLForKey( $key, $value )
+ );
+ }
+
+ $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
+ $html = Html::rawElement( 'ul', [
+ 'id' => "mw-htmlform-cloner-list-{$this->mID}",
+ 'class' => 'mw-htmlform-cloner-ul',
+ 'data-template' => $template,
+ 'data-unique-id' => $this->uniqueId,
+ ], $html );
+
+ $name = "{$this->mName}[create]";
+ $label = isset( $this->mParams['create-button-message'] )
+ ? $this->mParams['create-button-message']
+ : 'htmlform-cloner-create';
+ $field = HTMLForm::loadInputFromParameters( $name, [
+ 'type' => 'submit',
+ 'formnovalidate' => true,
+ 'name' => $name,
+ 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
+ 'cssclass' => 'mw-htmlform-cloner-create-button',
+ 'default' => $this->getMessage( $label )->text(),
+ ], $this->mParent );
+ $html .= $field->getInputHTML( $field->getDefault() );
+
+ return $html;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLFormFieldWithButton.php b/www/wiki/includes/htmlform/fields/HTMLFormFieldWithButton.php
new file mode 100644
index 00000000..b2290ce3
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLFormFieldWithButton.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * Enables HTMLFormField elements to be build with a button.
+ */
+class HTMLFormFieldWithButton extends HTMLFormField {
+ /** @var string $mButtonClass CSS class for the button in this field */
+ protected $mButtonClass = '';
+
+ /** @var string|int $mButtonId Element ID for the button in this field */
+ protected $mButtonId = '';
+
+ /** @var string $mButtonName Name the button in this field */
+ protected $mButtonName = '';
+
+ /** @var string $mButtonType Type of the button in this field (e.g. button or submit) */
+ protected $mButtonType = 'submit';
+
+ /** @var string $mButtonType Value for the button in this field */
+ protected $mButtonValue;
+
+ /** @var string $mButtonType Value for the button in this field */
+ protected $mButtonFlags = [ 'progressive' ];
+
+ public function __construct( $info ) {
+ if ( isset( $info['buttonclass'] ) ) {
+ $this->mButtonClass = $info['buttonclass'];
+ }
+ if ( isset( $info['buttonid'] ) ) {
+ $this->mButtonId = $info['buttonid'];
+ }
+ if ( isset( $info['buttonname'] ) ) {
+ $this->mButtonName = $info['buttonname'];
+ }
+ if ( isset( $info['buttondefault'] ) ) {
+ $this->mButtonValue = $info['buttondefault'];
+ }
+ if ( isset( $info['buttontype'] ) ) {
+ $this->mButtonType = $info['buttontype'];
+ }
+ if ( isset( $info['buttonflags'] ) ) {
+ $this->mButtonFlags = $info['buttonflags'];
+ }
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ $attr = [
+ 'class' => 'mw-htmlform-submit ' . $this->mButtonClass,
+ 'id' => $this->mButtonId,
+ ] + $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ return Html::input( $this->mButtonName, $this->mButtonValue, $this->mButtonType, $attr );
+ }
+
+ public function getInputOOUI( $value ) {
+ return new OOUI\ButtonInputWidget( [
+ 'name' => $this->mButtonName,
+ 'value' => $this->mButtonValue,
+ 'type' => $this->mButtonType,
+ 'label' => $this->mButtonValue,
+ 'flags' => $this->mButtonFlags,
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ ) );
+ }
+
+ /**
+ * Combines the passed element with a button.
+ * @param String $element Element to combine the button with.
+ * @return String
+ */
+ public function getElement( $element ) {
+ return $element . '&#160;' . $this->getInputHTML( '' );
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLHiddenField.php b/www/wiki/includes/htmlform/fields/HTMLHiddenField.php
new file mode 100644
index 00000000..02562c4a
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLHiddenField.php
@@ -0,0 +1,66 @@
+<?php
+
+class HTMLHiddenField extends HTMLFormField {
+ protected $outputAsDefault = true;
+
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( isset( $this->mParams['output-as-default'] ) ) {
+ $this->outputAsDefault = (bool)$this->mParams['output-as-default'];
+ }
+
+ # Per HTML5 spec, hidden fields cannot be 'required'
+ # https://www.w3.org/TR/html5/forms.html#hidden-state-%28type=hidden%29
+ unset( $this->mParams['required'] );
+ }
+
+ public function getHiddenFieldData( $value ) {
+ $params = [];
+ if ( $this->mID ) {
+ $params['id'] = $this->mID;
+ }
+
+ if ( $this->outputAsDefault ) {
+ $value = $this->mDefault;
+ }
+
+ return [ $this->mName, $value, $params ];
+ }
+
+ public function getTableRow( $value ) {
+ list( $name, $value, $params ) = $this->getHiddenFieldData( $value );
+ $this->mParent->addHiddenField( $name, $value, $params );
+ return '';
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getDiv( $value ) {
+ return $this->getTableRow( $value );
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getRaw( $value ) {
+ return $this->getTableRow( $value );
+ }
+
+ public function getInputHTML( $value ) {
+ return '';
+ }
+
+ public function canDisplayErrors() {
+ return false;
+ }
+
+ public function hasVisibleOutput() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLInfoField.php b/www/wiki/includes/htmlform/fields/HTMLInfoField.php
new file mode 100644
index 00000000..1376d0c8
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLInfoField.php
@@ -0,0 +1,81 @@
+<?php
+
+/**
+ * An information field (text blob), not a proper input.
+ */
+class HTMLInfoField extends HTMLFormField {
+ /**
+ * @param array $info
+ * In adition to the usual HTMLFormField parameters, this can take the following fields:
+ * - default: the value (text) of the field. Unlike other form field types, HTMLInfoField can
+ * take a closure as a default value, which will be evaluated with $info as its only parameter.
+ * - raw: if true, the value won't be escaped.
+ * - rawrow: if true, the usual wrapping of form fields (e.g. into a table row + cell when
+ * display mode is table) will not happen and the value must contain it already.
+ */
+ public function __construct( $info ) {
+ $info['nodata'] = true;
+
+ parent::__construct( $info );
+ }
+
+ public function getDefault() {
+ $default = parent::getDefault();
+ if ( $default instanceof Closure ) {
+ $default = call_user_func( $default, $this->mParams );
+ }
+ return $default;
+ }
+
+ public function getInputHTML( $value ) {
+ return !empty( $this->mParams['raw'] ) ? $value : htmlspecialchars( $value );
+ }
+
+ public function getInputOOUI( $value ) {
+ if ( !empty( $this->mParams['raw'] ) ) {
+ $value = new OOUI\HtmlSnippet( $value );
+ }
+
+ return new OOUI\LabelWidget( [
+ 'label' => $value,
+ ] );
+ }
+
+ public function getTableRow( $value ) {
+ if ( !empty( $this->mParams['rawrow'] ) ) {
+ return $value;
+ }
+
+ return parent::getTableRow( $value );
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getDiv( $value ) {
+ if ( !empty( $this->mParams['rawrow'] ) ) {
+ return $value;
+ }
+
+ return parent::getDiv( $value );
+ }
+
+ /**
+ * @param string $value
+ * @return string
+ * @since 1.20
+ */
+ public function getRaw( $value ) {
+ if ( !empty( $this->mParams['rawrow'] ) ) {
+ return $value;
+ }
+
+ return parent::getRaw( $value );
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLIntField.php b/www/wiki/includes/htmlform/fields/HTMLIntField.php
new file mode 100644
index 00000000..02af7de9
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLIntField.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * A field that must contain a number
+ */
+class HTMLIntField extends HTMLFloatField {
+ public function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ # https://www.w3.org/TR/html5/infrastructure.html#signed-integers
+ # with the addition that a leading '+' sign is ok. Note that leading zeros
+ # are fine, and will be left in the input, which is useful for things like
+ # phone numbers when you know that they are integers (the HTML5 type=tel
+ # input does not require its value to be numeric). If you want a tidier
+ # value to, eg, save in the DB, clean it up with intval().
+ if ( !preg_match( '/^((\+|\-)?\d+)?$/', trim( $value ) ) ) {
+ return $this->msg( 'htmlform-int-invalid' );
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLMultiSelectField.php b/www/wiki/includes/htmlform/fields/HTMLMultiSelectField.php
new file mode 100644
index 00000000..0d5eeba9
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLMultiSelectField.php
@@ -0,0 +1,208 @@
+<?php
+
+/**
+ * Multi-select field
+ */
+class HTMLMultiSelectField extends HTMLFormField implements HTMLNestedFilterable {
+ /**
+ * @param array $params
+ * In adition to the usual HTMLFormField parameters, this can take the following fields:
+ * - dropdown: If given, the options will be displayed inside a dropdown with a text field that
+ * can be used to filter them. This is desirable mostly for very long lists of options.
+ * This only works for users with JavaScript support and falls back to the list of checkboxes.
+ * - flatlist: If given, the options will be displayed on a single line (wrapping to following
+ * lines if necessary), rather than each one on a line of its own. This is desirable mostly
+ * for very short lists of concisely labelled options.
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ // If the disabled-options parameter is not provided, use an empty array
+ if ( isset( $this->mParams['disabled-options'] ) === false ) {
+ $this->mParams['disabled-options'] = [];
+ }
+
+ // For backwards compatibility, also handle the old way with 'cssclass' => 'mw-chosen'
+ if ( isset( $params['dropdown'] ) || strpos( $this->mClass, 'mw-chosen' ) !== false ) {
+ $this->mClass .= ' mw-htmlform-dropdown';
+ }
+
+ if ( isset( $params['flatlist'] ) ) {
+ $this->mClass .= ' mw-htmlform-flatlist';
+ }
+ }
+
+ public function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ if ( !is_array( $value ) ) {
+ return false;
+ }
+
+ # If all options are valid, array_intersect of the valid options
+ # and the provided options will return the provided options.
+ $validOptions = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ $validValues = array_intersect( $value, $validOptions );
+ if ( count( $validValues ) == count( $value ) ) {
+ return true;
+ } else {
+ return $this->msg( 'htmlform-select-badoption' );
+ }
+ }
+
+ public function getInputHTML( $value ) {
+ if ( isset( $this->mParams['dropdown'] ) ) {
+ $this->mParent->getOutput()->addModules( 'jquery.chosen' );
+ }
+
+ $value = HTMLFormField::forceToStringRecursive( $value );
+ $html = $this->formatOptions( $this->getOptions(), $value );
+
+ return $html;
+ }
+
+ public function formatOptions( $options, $value ) {
+ $html = '';
+
+ $attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+ foreach ( $options as $label => $info ) {
+ if ( is_array( $info ) ) {
+ $html .= Html::rawElement( 'h1', [], $label ) . "\n";
+ $html .= $this->formatOptions( $info, $value );
+ } else {
+ $thisAttribs = [
+ 'id' => "{$this->mID}-$info",
+ 'value' => $info,
+ ];
+ if ( in_array( $info, $this->mParams['disabled-options'], true ) ) {
+ $thisAttribs['disabled'] = 'disabled';
+ }
+ $checked = in_array( $info, $value, true );
+
+ $checkbox = $this->getOneCheckbox( $checked, $attribs + $thisAttribs, $label );
+
+ $html .= ' ' . Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-htmlform-flatlist-item' ],
+ $checkbox
+ );
+ }
+ }
+
+ return $html;
+ }
+
+ protected function getOneCheckbox( $checked, $attribs, $label ) {
+ if ( $this->mParent instanceof OOUIHTMLForm ) {
+ throw new MWException( 'HTMLMultiSelectField#getOneCheckbox() is not supported' );
+ } else {
+ $elementFunc = [ 'Html', $this->mOptionsLabelsNotFromMessage ? 'rawElement' : 'element' ];
+ $checkbox =
+ Xml::check( "{$this->mName}[]", $checked, $attribs ) .
+ '&#160;' .
+ call_user_func( $elementFunc,
+ 'label',
+ [ 'for' => $attribs['id'] ],
+ $label
+ );
+ if ( $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $checkbox = Html::openElement( 'div', [ 'class' => 'mw-ui-checkbox' ] ) .
+ $checkbox .
+ Html::closeElement( 'div' );
+ }
+ return $checkbox;
+ }
+ }
+
+ /**
+ * Get options and make them into arrays suitable for OOUI.
+ * @return array Options for inclusion in a select or whatever.
+ */
+ public function getOptionsOOUI() {
+ $options = parent::getOptionsOOUI();
+ foreach ( $options as &$option ) {
+ $option['disabled'] = in_array( $option['data'], $this->mParams['disabled-options'], true );
+ }
+ return $options;
+ }
+
+ /**
+ * Get the OOUI version of this field.
+ *
+ * @since 1.28
+ * @param string[] $value
+ * @return OOUI\CheckboxMultiselectInputWidget
+ */
+ public function getInputOOUI( $value ) {
+ $this->mParent->getOutput()->addModules( 'oojs-ui-widgets' );
+
+ $attr = [];
+ $attr['id'] = $this->mID;
+ $attr['name'] = "{$this->mName}[]";
+
+ $attr['value'] = $value;
+ $attr['options'] = $this->getOptionsOOUI();
+
+ if ( $this->mOptionsLabelsNotFromMessage ) {
+ foreach ( $attr['options'] as &$option ) {
+ $option['label'] = new OOUI\HtmlSnippet( $option['label'] );
+ }
+ }
+
+ $attr += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $attr['classes'] = [ $this->mClass ];
+ }
+
+ return new OOUI\CheckboxMultiselectInputWidget( $attr );
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return string|array
+ */
+ public function loadDataFromRequest( $request ) {
+ if ( $this->isSubmitAttempt( $request ) ) {
+ // Checkboxes are just not added to the request arrays if they're not checked,
+ // so it's perfectly possible for there not to be an entry at all
+ return $request->getArray( $this->mName, [] );
+ } else {
+ // That's ok, the user has not yet submitted the form, so show the defaults
+ return $this->getDefault();
+ }
+ }
+
+ public function getDefault() {
+ if ( isset( $this->mDefault ) ) {
+ return $this->mDefault;
+ } else {
+ return [];
+ }
+ }
+
+ public function filterDataForSubmit( $data ) {
+ $data = HTMLFormField::forceToStringRecursive( $data );
+ $options = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ $res = [];
+ foreach ( $options as $opt ) {
+ $res["$opt"] = in_array( $opt, $data, true );
+ }
+
+ return $res;
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLRadioField.php b/www/wiki/includes/htmlform/fields/HTMLRadioField.php
new file mode 100644
index 00000000..77ea7cd2
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLRadioField.php
@@ -0,0 +1,115 @@
+<?php
+
+/**
+ * Radio checkbox fields.
+ */
+class HTMLRadioField extends HTMLFormField {
+ /**
+ * @param array $params
+ * In adition to the usual HTMLFormField parameters, this can take the following fields:
+ * - flatlist: If given, the options will be displayed on a single line (wrapping to following
+ * lines if necessary), rather than each one on a line of its own. This is desirable mostly
+ * for very short lists of concisely labelled options.
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( isset( $params['flatlist'] ) ) {
+ $this->mClass .= ' mw-htmlform-flatlist';
+ }
+ }
+
+ public function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ if ( !is_string( $value ) && !is_int( $value ) ) {
+ return $this->msg( 'htmlform-required' );
+ }
+
+ $validOptions = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ if ( in_array( strval( $value ), $validOptions, true ) ) {
+ return true;
+ } else {
+ return $this->msg( 'htmlform-select-badoption' );
+ }
+ }
+
+ /**
+ * This returns a block of all the radio options, in one cell.
+ * @see includes/HTMLFormField#getInputHTML()
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ public function getInputHTML( $value ) {
+ $html = $this->formatOptions( $this->getOptions(), strval( $value ) );
+
+ return $html;
+ }
+
+ public function getInputOOUI( $value ) {
+ $options = [];
+ foreach ( $this->getOptions() as $label => $data ) {
+ $options[] = [
+ 'data' => $data,
+ 'label' => $this->mOptionsLabelsNotFromMessage ? new OOUI\HtmlSnippet( $label ) : $label,
+ ];
+ }
+
+ return new OOUI\RadioSelectInputWidget( [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'value' => $value,
+ 'options' => $options,
+ ] + OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( [ 'disabled', 'tabindex' ] )
+ ) );
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+
+ public function formatOptions( $options, $value ) {
+ global $wgUseMediaWikiUIEverywhere;
+
+ $html = '';
+
+ $attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
+ $elementFunc = [ 'Html', $this->mOptionsLabelsNotFromMessage ? 'rawElement' : 'element' ];
+
+ # @todo Should this produce an unordered list perhaps?
+ foreach ( $options as $label => $info ) {
+ if ( is_array( $info ) ) {
+ $html .= Html::rawElement( 'h1', [], $label ) . "\n";
+ $html .= $this->formatOptions( $info, $value );
+ } else {
+ $id = Sanitizer::escapeIdForAttribute( $this->mID . "-$info" );
+ $classes = [ 'mw-htmlform-flatlist-item' ];
+ if ( $wgUseMediaWikiUIEverywhere || $this->mParent instanceof VFormHTMLForm ) {
+ $classes[] = 'mw-ui-radio';
+ }
+ $radio = Xml::radio( $this->mName, $info, $info === $value, $attribs + [ 'id' => $id ] );
+ $radio .= '&#160;' . call_user_func( $elementFunc, 'label', [ 'for' => $id ], $label );
+
+ $html .= ' ' . Html::rawElement(
+ 'div',
+ [ 'class' => $classes ],
+ $radio
+ );
+ }
+ }
+
+ return $html;
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLRestrictionsField.php b/www/wiki/includes/htmlform/fields/HTMLRestrictionsField.php
new file mode 100644
index 00000000..dbf2c8f6
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLRestrictionsField.php
@@ -0,0 +1,121 @@
+<?php
+
+/**
+ * Class for updating an MWRestrictions value (which is, currently, basically just an IP address
+ * list).
+ *
+ * Will be represented as a textarea with one address per line, with intelligent defaults for
+ * label, help text and row count.
+ *
+ * The value returned will be an MWRestrictions or the input string if it was not a list of
+ * valid IP ranges.
+ */
+class HTMLRestrictionsField extends HTMLTextAreaField {
+ const DEFAULT_ROWS = 5;
+
+ public function __construct( array $params ) {
+ parent::__construct( $params );
+ if ( !$this->mLabel ) {
+ $this->mLabel = $this->msg( 'restrictionsfield-label' )->parse();
+ }
+ }
+
+ public function getHelpText() {
+ $helpText = parent::getHelpText();
+ if ( $helpText === null ) {
+ $helpText = $this->msg( 'restrictionsfield-help' )->parse();
+ }
+ return $helpText;
+ }
+
+ /**
+ * @param WebRequest $request
+ * @return string|MWRestrictions Restrictions object or original string if invalid
+ */
+ public function loadDataFromRequest( $request ) {
+ if ( !$request->getCheck( $this->mName ) ) {
+ return $this->getDefault();
+ }
+
+ $value = rtrim( $request->getText( $this->mName ), "\r\n" );
+ $ips = $value === '' ? [] : explode( PHP_EOL, $value );
+ try {
+ return MWRestrictions::newFromArray( [ 'IPAddresses' => $ips ] );
+ } catch ( InvalidArgumentException $e ) {
+ return $value;
+ }
+ }
+
+ /**
+ * @return MWRestrictions
+ */
+ public function getDefault() {
+ $default = parent::getDefault();
+ if ( $default === null ) {
+ $default = MWRestrictions::newDefault();
+ }
+ return $default;
+ }
+
+ /**
+ * @param string|MWRestrictions $value The value the field was submitted with
+ * @param array $alldata The data collected from the form
+ *
+ * @return bool|string|Message True on success, or String/Message error to display, or
+ * false to fail validation without displaying an error.
+ */
+ public function validate( $value, $alldata ) {
+ if ( $this->isHidden( $alldata ) ) {
+ return true;
+ }
+
+ if (
+ isset( $this->mParams['required'] ) && $this->mParams['required'] !== false
+ && $value instanceof MWRestrictions && !$value->toArray()['IPAddresses']
+ ) {
+ return $this->msg( 'htmlform-required' );
+ }
+
+ if ( is_string( $value ) ) {
+ // MWRestrictions::newFromArray failed; one of the IP ranges must be invalid
+ $status = Status::newGood();
+ foreach ( explode( PHP_EOL, $value ) as $range ) {
+ if ( !\IP::isIPAddress( $range ) ) {
+ $status->fatal( 'restrictionsfield-badip', $range );
+ }
+ }
+ if ( $status->isOK() ) {
+ $status->fatal( 'unknown-error' );
+ }
+ return $status->getMessage();
+ }
+
+ if ( isset( $this->mValidationCallback ) ) {
+ return call_user_func( $this->mValidationCallback, $value, $alldata, $this->mParent );
+ }
+
+ return true;
+ }
+
+ /**
+ * @param string|MWRestrictions $value
+ * @return string
+ */
+ public function getInputHTML( $value ) {
+ if ( $value instanceof MWRestrictions ) {
+ $value = implode( PHP_EOL, $value->toArray()['IPAddresses'] );
+ }
+ return parent::getInputHTML( $value );
+ }
+
+ /**
+ * @param MWRestrictions $value
+ * @return string
+ */
+ public function getInputOOUI( $value ) {
+ if ( $value instanceof MWRestrictions ) {
+ $value = implode( PHP_EOL, $value->toArray()['IPAddresses'] );
+ }
+ return parent::getInputOOUI( $value );
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLSelectAndOtherField.php b/www/wiki/includes/htmlform/fields/HTMLSelectAndOtherField.php
new file mode 100644
index 00000000..38b487af
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLSelectAndOtherField.php
@@ -0,0 +1,195 @@
+<?php
+
+/**
+ * Double field with a dropdown list constructed from a system message in the format
+ * * Optgroup header
+ * ** <option value>
+ * * New Optgroup header
+ * Plus a text field underneath for an additional reason. The 'value' of the field is
+ * "<select>: <extra reason>", or "<extra reason>" if nothing has been selected in the
+ * select dropdown.
+ * @todo FIXME: If made 'required', only the text field should be compulsory.
+ */
+class HTMLSelectAndOtherField extends HTMLSelectField {
+ public function __construct( $params ) {
+ if ( array_key_exists( 'other', $params ) ) {
+ // Do nothing
+ } elseif ( array_key_exists( 'other-message', $params ) ) {
+ $params['other'] = $this->getMessage( $params['other-message'] )->plain();
+ } else {
+ $params['other'] = $this->msg( 'htmlform-selectorother-other' )->plain();
+ }
+
+ parent::__construct( $params );
+
+ if ( $this->getOptions() === null ) {
+ // Sulk
+ throw new MWException( 'HTMLSelectAndOtherField called without any options' );
+ }
+ if ( !in_array( 'other', $this->mOptions, true ) ) {
+ // Have 'other' always as first element
+ $this->mOptions = [ $params['other'] => 'other' ] + $this->mOptions;
+ }
+ $this->mFlatOptions = self::flattenOptions( $this->getOptions() );
+ }
+
+ public function getInputHTML( $value ) {
+ $select = parent::getInputHTML( $value[1] );
+
+ $textAttribs = [
+ 'id' => $this->mID . '-other',
+ 'size' => $this->getSize(),
+ 'class' => [ 'mw-htmlform-select-and-other-field' ],
+ 'data-id-select' => $this->mID,
+ ];
+
+ if ( $this->mClass !== '' ) {
+ $textAttribs['class'][] = $this->mClass;
+ }
+
+ $allowedParams = [
+ 'required',
+ 'autofocus',
+ 'multiple',
+ 'disabled',
+ 'tabindex',
+ 'maxlength', // gets dynamic with javascript, see mediawiki.htmlform.js
+ ];
+
+ $textAttribs += $this->getAttributes( $allowedParams );
+
+ $textbox = Html::input( $this->mName . '-other', $value[2], 'text', $textAttribs );
+
+ return "$select<br />\n$textbox";
+ }
+
+ protected function getOOUIModules() {
+ return [ 'mediawiki.widgets.SelectWithInputWidget' ];
+ }
+
+ public function getInputOOUI( $value ) {
+ $this->mParent->getOutput()->addModuleStyles( 'mediawiki.widgets.SelectWithInputWidget.styles' );
+
+ # TextInput
+ $textAttribs = [
+ 'id' => $this->mID . '-other',
+ 'name' => $this->mName . '-other',
+ 'size' => $this->getSize(),
+ 'class' => [ 'mw-htmlform-select-and-other-field' ],
+ 'data-id-select' => $this->mID,
+ 'value' => $value[2],
+ ];
+
+ $allowedParams = [
+ 'required',
+ 'autofocus',
+ 'multiple',
+ 'disabled',
+ 'tabindex',
+ 'maxlength',
+ ];
+
+ $textAttribs += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $textAttribs['classes'] = [ $this->mClass ];
+ }
+
+ # DropdownInput
+ $dropdownInputAttribs = [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'options' => $this->getOptionsOOUI(),
+ 'value' => $value[1],
+ ];
+
+ $allowedParams = [
+ 'tabindex',
+ 'disabled',
+ ];
+
+ $dropdownInputAttribs += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $dropdownInputAttribs['classes'] = [ $this->mClass ];
+ }
+
+ return $this->getInputWidget( [
+ 'textinput' => $textAttribs,
+ 'dropdowninput' => $dropdownInputAttribs,
+ 'or' => false,
+ ] );
+ }
+
+ public function getInputWidget( $params ) {
+ return new Mediawiki\Widget\SelectWithInputWidget( $params );
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return array("<overall message>","<select value>","<text field value>")
+ */
+ public function loadDataFromRequest( $request ) {
+ if ( $request->getCheck( $this->mName ) ) {
+ $list = $request->getText( $this->mName );
+ $text = $request->getText( $this->mName . '-other' );
+
+ // Should be built the same as in mediawiki.htmlform.js
+ if ( $list == 'other' ) {
+ $final = $text;
+ } elseif ( !in_array( $list, $this->mFlatOptions, true ) ) {
+ # User has spoofed the select form to give an option which wasn't
+ # in the original offer. Sulk...
+ $final = $text;
+ } elseif ( $text == '' ) {
+ $final = $list;
+ } else {
+ $final = $list . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $text;
+ }
+ } else {
+ $final = $this->getDefault();
+
+ $list = 'other';
+ $text = $final;
+ foreach ( $this->mFlatOptions as $option ) {
+ $match = $option . $this->msg( 'colon-separator' )->inContentLanguage()->text();
+ if ( strpos( $text, $match ) === 0 ) {
+ $list = $option;
+ $text = substr( $text, strlen( $match ) );
+ break;
+ }
+ }
+ }
+
+ return [ $final, $list, $text ];
+ }
+
+ public function getSize() {
+ return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 45;
+ }
+
+ public function validate( $value, $alldata ) {
+ # HTMLSelectField forces $value to be one of the options in the select
+ # field, which is not useful here. But we do want the validation further up
+ # the chain
+ $p = parent::validate( $value[1], $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ if ( isset( $this->mParams['required'] )
+ && $this->mParams['required'] !== false
+ && $value[1] === ''
+ ) {
+ return $this->msg( 'htmlform-required' );
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLSelectField.php b/www/wiki/includes/htmlform/fields/HTMLSelectField.php
new file mode 100644
index 00000000..18c741b7
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLSelectField.php
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * A select dropdown field. Basically a wrapper for Xmlselect class
+ */
+class HTMLSelectField extends HTMLFormField {
+ public function validate( $value, $alldata ) {
+ $p = parent::validate( $value, $alldata );
+
+ if ( $p !== true ) {
+ return $p;
+ }
+
+ $validOptions = HTMLFormField::flattenOptions( $this->getOptions() );
+
+ if ( in_array( strval( $value ), $validOptions, true ) ) {
+ return true;
+ } else {
+ return $this->msg( 'htmlform-select-badoption' );
+ }
+ }
+
+ public function getInputHTML( $value ) {
+ $select = new XmlSelect( $this->mName, $this->mID, strval( $value ) );
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $select->setAttribute( 'disabled', 'disabled' );
+ }
+
+ $allowedParams = [ 'tabindex', 'size' ];
+ $customParams = $this->getAttributes( $allowedParams );
+ foreach ( $customParams as $name => $value ) {
+ $select->setAttribute( $name, $value );
+ }
+
+ if ( $this->mClass !== '' ) {
+ $select->setAttribute( 'class', $this->mClass );
+ }
+
+ $select->addOptions( $this->getOptions() );
+
+ return $select->getHTML();
+ }
+
+ public function getInputOOUI( $value ) {
+ $disabled = false;
+ $allowedParams = [ 'tabindex' ];
+ $attribs = OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $attribs['classes'] = [ $this->mClass ];
+ }
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $disabled = true;
+ }
+
+ return new OOUI\DropdownInputWidget( [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'options' => $this->getOptionsOOUI(),
+ 'value' => strval( $value ),
+ 'disabled' => $disabled,
+ ] + $attribs );
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLSelectLimitField.php b/www/wiki/includes/htmlform/fields/HTMLSelectLimitField.php
new file mode 100644
index 00000000..45191d03
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLSelectLimitField.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * A limit dropdown, which accepts any valid number
+ */
+class HTMLSelectLimitField extends HTMLSelectField {
+ /**
+ * Basically don't do any validation. If it's a number that's fine. Also,
+ * add it to the list if it's not there already
+ *
+ * @param string $value
+ * @param array $alldata
+ * @return bool
+ */
+ public function validate( $value, $alldata ) {
+ if ( $value == '' ) {
+ return true;
+ }
+
+ // Let folks pick an explicit limit not from our list, as long as it's a real numbr.
+ if ( !in_array( $value, $this->mParams['options'] )
+ && $value == intval( $value )
+ && $value > 0
+ ) {
+ // This adds the explicitly requested limit value to the drop-down,
+ // then makes sure it's sorted correctly so when we output the list
+ // later, the custom option doesn't just show up last.
+ $this->mParams['options'][$this->mParent->getLanguage()->formatNum( $value )] =
+ intval( $value );
+ asort( $this->mParams['options'] );
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLSelectNamespace.php b/www/wiki/includes/htmlform/fields/HTMLSelectNamespace.php
new file mode 100644
index 00000000..f13aa177
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLSelectNamespace.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * Wrapper for Html::namespaceSelector to use in HTMLForm
+ */
+class HTMLSelectNamespace extends HTMLFormField {
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ $this->mAllValue = array_key_exists( 'all', $params )
+ ? $params['all']
+ : 'all';
+ }
+
+ public function getInputHTML( $value ) {
+ return Html::namespaceSelector(
+ [
+ 'selected' => $value,
+ 'all' => $this->mAllValue
+ ], [
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'class' => 'namespaceselector',
+ ]
+ );
+ }
+
+ public function getInputOOUI( $value ) {
+ return new MediaWiki\Widget\NamespaceInputWidget( [
+ 'value' => $value,
+ 'name' => $this->mName,
+ 'id' => $this->mID,
+ 'includeAllValue' => $this->mAllValue,
+ ] );
+ }
+
+ protected function getOOUIModules() {
+ // FIXME: NamespaceInputWidget should be in its own module (probably?)
+ return [ 'mediawiki.widgets' ];
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLSelectNamespaceWithButton.php b/www/wiki/includes/htmlform/fields/HTMLSelectNamespaceWithButton.php
new file mode 100644
index 00000000..52259836
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLSelectNamespaceWithButton.php
@@ -0,0 +1,17 @@
+<?php
+/**
+ * Creates a Html::namespaceSelector input field with a button assigned to the input field.
+ */
+class HTMLSelectNamespaceWithButton extends HTMLSelectNamespace {
+ /** @var HTMLFormFieldWithButton $mClassWithButton */
+ protected $mClassWithButton = null;
+
+ public function __construct( $info ) {
+ $this->mClassWithButton = new HTMLFormFieldWithButton( $info );
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ return $this->mClassWithButton->getElement( parent::getInputHTML( $value ) );
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLSelectOrOtherField.php b/www/wiki/includes/htmlform/fields/HTMLSelectOrOtherField.php
new file mode 100644
index 00000000..a009b287
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLSelectOrOtherField.php
@@ -0,0 +1,161 @@
+<?php
+
+/**
+ * Select dropdown field, with an additional "other" textbox.
+ *
+ * HTMLComboboxField implements the same functionality using a single form field
+ * and should be used instead.
+ */
+class HTMLSelectOrOtherField extends HTMLTextField {
+ public function __construct( $params ) {
+ parent::__construct( $params );
+ $this->getOptions();
+ if ( !in_array( 'other', $this->mOptions, true ) ) {
+ $msg =
+ isset( $params['other'] )
+ ? $params['other']
+ : wfMessage( 'htmlform-selectorother-other' )->text();
+ // Have 'other' always as first element
+ $this->mOptions = [ $msg => 'other' ] + $this->mOptions;
+ }
+ }
+
+ public function getInputHTML( $value ) {
+ $valInSelect = false;
+
+ if ( $value !== false ) {
+ $value = strval( $value );
+ $valInSelect = in_array(
+ $value, HTMLFormField::flattenOptions( $this->getOptions() ), true
+ );
+ }
+
+ $selected = $valInSelect ? $value : 'other';
+
+ $select = new XmlSelect( $this->mName, $this->mID, $selected );
+ $select->addOptions( $this->getOptions() );
+
+ $select->setAttribute( 'class', 'mw-htmlform-select-or-other' );
+
+ $tbAttribs = [ 'id' => $this->mID . '-other', 'size' => $this->getSize() ];
+
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $select->setAttribute( 'disabled', 'disabled' );
+ $tbAttribs['disabled'] = 'disabled';
+ }
+
+ if ( isset( $this->mParams['tabindex'] ) ) {
+ $select->setAttribute( 'tabindex', $this->mParams['tabindex'] );
+ $tbAttribs['tabindex'] = $this->mParams['tabindex'];
+ }
+
+ $select = $select->getHTML();
+
+ if ( isset( $this->mParams['maxlength'] ) ) {
+ $tbAttribs['maxlength'] = $this->mParams['maxlength'];
+ }
+
+ if ( $this->mClass !== '' ) {
+ $tbAttribs['class'] = $this->mClass;
+ }
+
+ $textbox = Html::input( $this->mName . '-other', $valInSelect ? '' : $value, 'text', $tbAttribs );
+
+ return "$select<br />\n$textbox";
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+
+ protected function getOOUIModules() {
+ return [ 'mediawiki.widgets.SelectWithInputWidget' ];
+ }
+
+ public function getInputOOUI( $value ) {
+ $this->mParent->getOutput()->addModuleStyles( 'mediawiki.widgets.SelectWithInputWidget.styles' );
+
+ $valInSelect = false;
+ if ( $value !== false ) {
+ $value = strval( $value );
+ $valInSelect = in_array(
+ $value, HTMLFormField::flattenOptions( $this->getOptions() ), true
+ );
+ }
+
+ # DropdownInput
+ $dropdownAttribs = [
+ 'id' => $this->mID,
+ 'name' => $this->mName,
+ 'options' => $this->getOptionsOOUI(),
+ 'value' => $valInSelect ? $value : 'other',
+ 'class' => [ 'mw-htmlform-select-or-other' ],
+ ];
+
+ $allowedParams = [
+ 'disabled',
+ 'tabindex',
+ ];
+
+ $dropdownAttribs += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ # TextInput
+ $textAttribs = [
+ 'id' => $this->mID . '-other',
+ 'name' => $this->mName . '-other',
+ 'size' => $this->getSize(),
+ 'value' => $valInSelect ? '' : $value,
+ ];
+
+ $allowedParams = [
+ 'required',
+ 'autofocus',
+ 'multiple',
+ 'disabled',
+ 'tabindex',
+ 'maxlength',
+ ];
+
+ $textAttribs += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ if ( $this->mClass !== '' ) {
+ $textAttribs['classes'] = [ $this->mClass ];
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $textAttribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ return $this->getInputWidget( [
+ 'textinput' => $textAttribs,
+ 'dropdowninput' => $dropdownAttribs,
+ 'or' => true,
+ ] );
+ }
+
+ public function getInputWidget( $params ) {
+ return new Mediawiki\Widget\SelectWithInputWidget( $params );
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return string
+ */
+ public function loadDataFromRequest( $request ) {
+ if ( $request->getCheck( $this->mName ) ) {
+ $val = $request->getText( $this->mName );
+
+ if ( $val === 'other' ) {
+ $val = $request->getText( $this->mName . '-other' );
+ }
+
+ return $val;
+ } else {
+ return $this->getDefault();
+ }
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLSizeFilterField.php b/www/wiki/includes/htmlform/fields/HTMLSizeFilterField.php
new file mode 100644
index 00000000..5ad7ee34
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLSizeFilterField.php
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * A size filter field for use on query-type special pages. It looks a bit like:
+ *
+ * (o) Min size ( ) Max size: [ ] bytes
+ *
+ * Minimum size limits are represented using a positive integer, while maximum
+ * size limits are represented using a negative integer.
+ */
+class HTMLSizeFilterField extends HTMLIntField {
+ public function getSize() {
+ return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 9;
+ }
+
+ public function getInputHTML( $value ) {
+ $attribs = [];
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $attribs['disabled'] = 'disabled';
+ }
+
+ $html = Xml::radioLabel(
+ $this->msg( 'minimum-size' )->text(),
+ $this->mName . '-mode',
+ 'min',
+ $this->mID . '-mode-min',
+ $value >= 0,
+ $attribs
+ );
+ $html .= '&#160;' . Xml::radioLabel(
+ $this->msg( 'maximum-size' )->text(),
+ $this->mName . '-mode',
+ 'max',
+ $this->mID . '-mode-max',
+ $value < 0,
+ $attribs
+ );
+ $html .= '&#160;' . parent::getInputHTML( $value ? abs( $value ) : '' );
+ $html .= '&#160;' . $this->msg( 'pagesize' )->parse();
+
+ return $html;
+ }
+
+ // No OOUI yet
+ public function getInputOOUI( $value ) {
+ return false;
+ }
+
+ /**
+ * @param WebRequest $request
+ *
+ * @return string|int
+ */
+ public function loadDataFromRequest( $request ) {
+ $size = $request->getInt( $this->mName );
+ if ( !$size ) {
+ return $this->getDefault();
+ }
+ $size = abs( $size );
+
+ // negative numbers represent "max", positive numbers represent "min"
+ if ( $request->getVal( $this->mName . '-mode' ) === 'max' ) {
+ return -$size;
+ } else {
+ return $size;
+ }
+ }
+
+ protected function needsLabel() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLSubmitField.php b/www/wiki/includes/htmlform/fields/HTMLSubmitField.php
new file mode 100644
index 00000000..0c33ad94
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLSubmitField.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * Add a submit button inline in the form (as opposed to
+ * HTMLForm::addButton(), which will add it at the end).
+ */
+class HTMLSubmitField extends HTMLButtonField {
+ protected $buttonType = 'submit';
+
+ protected $mFlags = [ 'primary', 'progressive' ];
+
+ public function skipLoadData( $request ) {
+ return !$request->getCheck( $this->mName );
+ }
+
+ public function loadDataFromRequest( $request ) {
+ return $request->getCheck( $this->mName );
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLTagFilter.php b/www/wiki/includes/htmlform/fields/HTMLTagFilter.php
new file mode 100644
index 00000000..38f9a0a1
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLTagFilter.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * Wrapper for ChangeTags::buildTagFilterSelector to use in HTMLForm
+ */
+class HTMLTagFilter extends HTMLFormField {
+ protected $tagFilter;
+
+ public function getTableRow( $value ) {
+ $this->tagFilter = ChangeTags::buildTagFilterSelector(
+ $value, false, $this->mParent->getContext() );
+ if ( $this->tagFilter ) {
+ return parent::getTableRow( $value );
+ }
+ return '';
+ }
+
+ public function getDiv( $value ) {
+ $this->tagFilter = ChangeTags::buildTagFilterSelector(
+ $value, false, $this->mParent->getContext() );
+ if ( $this->tagFilter ) {
+ return parent::getDiv( $value );
+ }
+ return '';
+ }
+
+ public function getOOUI( $value ) {
+ $this->tagFilter = ChangeTags::buildTagFilterSelector(
+ $value, true, $this->mParent->getContext() );
+ if ( $this->tagFilter ) {
+ return parent::getOOUI( $value );
+ }
+ return new OOUI\FieldLayout( new OOUI\Widget() );
+ }
+
+ public function getInputHTML( $value ) {
+ if ( $this->tagFilter ) {
+ // we only need the select field, HTMLForm should handle the label
+ return $this->tagFilter[1];
+ }
+ return '';
+ }
+
+ public function getInputOOUI( $value ) {
+ if ( $this->tagFilter ) {
+ // we only need the select field, HTMLForm should handle the label
+ return $this->tagFilter[1];
+ }
+ return '';
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLTextAreaField.php b/www/wiki/includes/htmlform/fields/HTMLTextAreaField.php
new file mode 100644
index 00000000..480c5bb9
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLTextAreaField.php
@@ -0,0 +1,103 @@
+<?php
+
+class HTMLTextAreaField extends HTMLFormField {
+ const DEFAULT_COLS = 80;
+ const DEFAULT_ROWS = 25;
+
+ protected $mPlaceholder = '';
+
+ /**
+ * @param array $params
+ * - cols, rows: textarea size
+ * - placeholder/placeholder-message: set HTML placeholder attribute
+ * - spellcheck: set HTML spellcheck attribute
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( isset( $params['placeholder-message'] ) ) {
+ $this->mPlaceholder = $this->getMessage( $params['placeholder-message'] )->parse();
+ } elseif ( isset( $params['placeholder'] ) ) {
+ $this->mPlaceholder = $params['placeholder'];
+ }
+ }
+
+ public function getCols() {
+ return isset( $this->mParams['cols'] ) ? $this->mParams['cols'] : static::DEFAULT_COLS;
+ }
+
+ public function getRows() {
+ return isset( $this->mParams['rows'] ) ? $this->mParams['rows'] : static::DEFAULT_ROWS;
+ }
+
+ public function getSpellCheck() {
+ $val = isset( $this->mParams['spellcheck'] ) ? $this->mParams['spellcheck'] : null;
+ if ( is_bool( $val ) ) {
+ // "spellcheck" attribute literally requires "true" or "false" to work.
+ return $val === true ? 'true' : 'false';
+ }
+ return null;
+ }
+
+ public function getInputHTML( $value ) {
+ $attribs = [
+ 'id' => $this->mID,
+ 'cols' => $this->getCols(),
+ 'rows' => $this->getRows(),
+ 'spellcheck' => $this->getSpellCheck(),
+ ] + $this->getTooltipAndAccessKey();
+
+ if ( $this->mClass !== '' ) {
+ $attribs['class'] = $this->mClass;
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $attribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ $allowedParams = [
+ 'tabindex',
+ 'disabled',
+ 'readonly',
+ 'required',
+ 'autofocus'
+ ];
+
+ $attribs += $this->getAttributes( $allowedParams );
+ return Html::textarea( $this->mName, $value, $attribs );
+ }
+
+ function getInputOOUI( $value ) {
+ if ( isset( $this->mParams['cols'] ) ) {
+ throw new Exception( "OOUIHTMLForm does not support the 'cols' parameter for textareas" );
+ }
+
+ $attribs = $this->getTooltipAndAccessKeyOOUI();
+
+ if ( $this->mClass !== '' ) {
+ $attribs['classes'] = [ $this->mClass ];
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $attribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ $allowedParams = [
+ 'tabindex',
+ 'disabled',
+ 'readonly',
+ 'required',
+ 'autofocus',
+ ];
+
+ $attribs += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ return new OOUI\TextInputWidget( [
+ 'id' => $this->mID,
+ 'name' => $this->mName,
+ 'multiline' => true,
+ 'value' => $value,
+ 'rows' => $this->getRows(),
+ ] + $attribs );
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLTextField.php b/www/wiki/includes/htmlform/fields/HTMLTextField.php
new file mode 100644
index 00000000..1c5a43dd
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLTextField.php
@@ -0,0 +1,206 @@
+<?php
+
+/**
+ * <input> field.
+ *
+ * Besides the parameters recognized by HTMLFormField, the following are
+ * recognized:
+ * autocomplete - HTML autocomplete value (a boolean for on/off or a string according to
+ * https://html.spec.whatwg.org/multipage/forms.html#autofill )
+ */
+class HTMLTextField extends HTMLFormField {
+ protected $mPlaceholder = '';
+
+ /** @var bool HTML autocomplete attribute */
+ protected $autocomplete;
+
+ /**
+ * @param array $params
+ * - type: HTML textfield type
+ * - size: field size in characters (defaults to 45)
+ * - placeholder/placeholder-message: set HTML placeholder attribute
+ * - spellcheck: set HTML spellcheck attribute
+ * - persistent: upon unsuccessful requests, retain the value (defaults to true, except
+ * for password fields)
+ */
+ public function __construct( $params ) {
+ if ( isset( $params['autocomplete'] ) && is_bool( $params['autocomplete'] ) ) {
+ $params['autocomplete'] = $params['autocomplete'] ? 'on' : 'off';
+ }
+
+ parent::__construct( $params );
+
+ if ( isset( $params['placeholder-message'] ) ) {
+ $this->mPlaceholder = $this->getMessage( $params['placeholder-message'] )->parse();
+ } elseif ( isset( $params['placeholder'] ) ) {
+ $this->mPlaceholder = $params['placeholder'];
+ }
+ }
+
+ public function getSize() {
+ return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 45;
+ }
+
+ public function getSpellCheck() {
+ $val = isset( $this->mParams['spellcheck'] ) ? $this->mParams['spellcheck'] : null;
+ if ( is_bool( $val ) ) {
+ // "spellcheck" attribute literally requires "true" or "false" to work.
+ return $val === true ? 'true' : 'false';
+ }
+ return null;
+ }
+
+ public function isPersistent() {
+ if ( isset( $this->mParams['persistent'] ) ) {
+ return $this->mParams['persistent'];
+ }
+ // don't put passwords into the HTML body, they could get cached or otherwise leaked
+ return !( isset( $this->mParams['type'] ) && $this->mParams['type'] === 'password' );
+ }
+
+ public function getInputHTML( $value ) {
+ if ( !$this->isPersistent() ) {
+ $value = '';
+ }
+
+ $attribs = [
+ 'id' => $this->mID,
+ 'name' => $this->mName,
+ 'size' => $this->getSize(),
+ 'value' => $value,
+ 'dir' => $this->mDir,
+ 'spellcheck' => $this->getSpellCheck(),
+ ] + $this->getTooltipAndAccessKey() + $this->getDataAttribs();
+
+ if ( $this->mClass !== '' ) {
+ $attribs['class'] = $this->mClass;
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $attribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ # @todo Enforce pattern, step, required, readonly on the server side as
+ # well
+ $allowedParams = [
+ 'type',
+ 'min',
+ 'max',
+ 'pattern',
+ 'title',
+ 'step',
+ 'list',
+ 'maxlength',
+ 'tabindex',
+ 'disabled',
+ 'required',
+ 'autofocus',
+ 'multiple',
+ 'readonly',
+ 'autocomplete',
+ ];
+
+ $attribs += $this->getAttributes( $allowedParams );
+
+ # Extract 'type'
+ $type = $this->getType( $attribs );
+ return Html::input( $this->mName, $value, $type, $attribs );
+ }
+
+ protected function getType( &$attribs ) {
+ $type = isset( $attribs['type'] ) ? $attribs['type'] : 'text';
+ unset( $attribs['type'] );
+
+ # Implement tiny differences between some field variants
+ # here, rather than creating a new class for each one which
+ # is essentially just a clone of this one.
+ if ( isset( $this->mParams['type'] ) ) {
+ switch ( $this->mParams['type'] ) {
+ case 'int':
+ $type = 'number';
+ break;
+ case 'float':
+ $type = 'number';
+ $attribs['step'] = 'any';
+ break;
+ # Pass through
+ case 'email':
+ case 'password':
+ case 'file':
+ case 'url':
+ $type = $this->mParams['type'];
+ break;
+ }
+ }
+
+ return $type;
+ }
+
+ public function getInputOOUI( $value ) {
+ if ( !$this->isPersistent() ) {
+ $value = '';
+ }
+
+ $attribs = $this->getTooltipAndAccessKeyOOUI();
+
+ if ( $this->mClass !== '' ) {
+ $attribs['classes'] = [ $this->mClass ];
+ }
+ if ( $this->mPlaceholder !== '' ) {
+ $attribs['placeholder'] = $this->mPlaceholder;
+ }
+
+ # @todo Enforce pattern, step, required, readonly on the server side as
+ # well
+ $allowedParams = [
+ 'autofocus',
+ 'autosize',
+ 'disabled',
+ 'flags',
+ 'indicator',
+ 'maxlength',
+ 'readonly',
+ 'required',
+ 'tabindex',
+ 'type',
+ 'autocomplete',
+ ];
+
+ $attribs += OOUI\Element::configFromHtmlAttributes(
+ $this->getAttributes( $allowedParams )
+ );
+
+ // FIXME T150983 downgrade autocomplete
+ if ( isset( $attribs['autocomplete'] ) ) {
+ if ( $attribs['autocomplete'] === 'on' ) {
+ $attribs['autocomplete'] = true;
+ } elseif ( $attribs['autocomplete'] === 'off' ) {
+ $attribs['autocomplete'] = false;
+ } else {
+ unset( $attribs['autocomplete'] );
+ }
+ }
+
+ $type = $this->getType( $attribs );
+
+ return $this->getInputWidget( [
+ 'id' => $this->mID,
+ 'name' => $this->mName,
+ 'value' => $value,
+ 'type' => $type,
+ 'dir' => $this->mDir,
+ ] + $attribs );
+ }
+
+ protected function getInputWidget( $params ) {
+ return new OOUI\TextInputWidget( $params );
+ }
+
+ /**
+ * Returns an array of data-* attributes to add to the field.
+ *
+ * @return array
+ */
+ protected function getDataAttribs() {
+ return [];
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLTextFieldWithButton.php b/www/wiki/includes/htmlform/fields/HTMLTextFieldWithButton.php
new file mode 100644
index 00000000..7c1c6739
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLTextFieldWithButton.php
@@ -0,0 +1,17 @@
+<?php
+/**
+ * Creates a text input field with a button assigned to the input field.
+ */
+class HTMLTextFieldWithButton extends HTMLTextField {
+ /** @var HTMLFormFieldWithButton $mClassWithButton */
+ protected $mClassWithButton = null;
+
+ public function __construct( $info ) {
+ $this->mClassWithButton = new HTMLFormFieldWithButton( $info );
+ parent::__construct( $info );
+ }
+
+ public function getInputHTML( $value ) {
+ return $this->mClassWithButton->getElement( parent::getInputHTML( $value ) );
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLTitleTextField.php b/www/wiki/includes/htmlform/fields/HTMLTitleTextField.php
new file mode 100644
index 00000000..3eb3f5df
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLTitleTextField.php
@@ -0,0 +1,107 @@
+<?php
+
+use MediaWiki\Widget\TitleInputWidget;
+
+/**
+ * Implements a text input field for page titles.
+ * Automatically does validation that the title is valid,
+ * as well as autocompletion if using the OOUI display format.
+ *
+ * Note: Forms using GET requests will need to make sure the title value is not
+ * an empty string.
+ *
+ * Optional parameters:
+ * 'namespace' - Namespace the page must be in
+ * 'relative' - If true and 'namespace' given, strip/add the namespace from/to the title as needed
+ * 'creatable' - Whether to validate the title is creatable (not a special page)
+ * 'exists' - Whether to validate that the title already exists
+ *
+ * @since 1.26
+ */
+class HTMLTitleTextField extends HTMLTextField {
+ public function __construct( $params ) {
+ $params += [
+ 'namespace' => false,
+ 'relative' => false,
+ 'creatable' => false,
+ 'exists' => false,
+ ];
+
+ parent::__construct( $params );
+ }
+
+ public function validate( $value, $alldata ) {
+ if ( $this->mParent->getMethod() === 'get' && $value === '' ) {
+ // If the form is a GET form and has no value, assume it hasn't been
+ // submitted yet, and skip validation
+ return parent::validate( $value, $alldata );
+ }
+ try {
+ if ( !$this->mParams['relative'] ) {
+ $title = Title::newFromTextThrow( $value );
+ } else {
+ // Can't use Title::makeTitleSafe(), because it doesn't throw useful exceptions
+ global $wgContLang;
+ $namespaceName = $wgContLang->getNsText( $this->mParams['namespace'] );
+ $title = Title::newFromTextThrow( $namespaceName . ':' . $value );
+ }
+ } catch ( MalformedTitleException $e ) {
+ $msg = $this->msg( $e->getErrorMessage() );
+ $params = $e->getErrorMessageParameters();
+ if ( $params ) {
+ $msg->params( $params );
+ }
+ return $msg;
+ }
+
+ $text = $title->getPrefixedText();
+ if ( $this->mParams['namespace'] !== false &&
+ !$title->inNamespace( $this->mParams['namespace'] )
+ ) {
+ return $this->msg( 'htmlform-title-badnamespace', $this->mParams['namespace'], $text );
+ }
+
+ if ( $this->mParams['creatable'] && !$title->canExist() ) {
+ return $this->msg( 'htmlform-title-not-creatable', $text );
+ }
+
+ if ( $this->mParams['exists'] && !$title->exists() ) {
+ return $this->msg( 'htmlform-title-not-exists', $text );
+ }
+
+ return parent::validate( $value, $alldata );
+ }
+
+ protected function getInputWidget( $params ) {
+ if ( $this->mParams['namespace'] !== false ) {
+ $params['namespace'] = $this->mParams['namespace'];
+ }
+ $params['relative'] = $this->mParams['relative'];
+ return new TitleInputWidget( $params );
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+
+ protected function getOOUIModules() {
+ // FIXME: TitleInputWidget should be in its own module
+ return [ 'mediawiki.widgets' ];
+ }
+
+ public function getInputHtml( $value ) {
+ // add mw-searchInput class to enable search suggestions for non-OOUI, too
+ $this->mClass .= 'mw-searchInput';
+
+ // return the HTMLTextField html
+ return parent::getInputHTML( $value );
+ }
+
+ protected function getDataAttribs() {
+ return [
+ 'data-mw-searchsuggest' => FormatJson::encode( [
+ 'wrapAsLink' => false,
+ ] ),
+ ];
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLUserTextField.php b/www/wiki/includes/htmlform/fields/HTMLUserTextField.php
new file mode 100644
index 00000000..12c09c1d
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLUserTextField.php
@@ -0,0 +1,62 @@
+<?php
+
+use MediaWiki\Widget\UserInputWidget;
+
+/**
+ * Implements a text input field for user names.
+ * Automatically auto-completes if using the OOUI display format.
+ *
+ * FIXME: Does not work for forms that support GET requests.
+ *
+ * Optional parameters:
+ * 'exists' - Whether to validate that the user already exists
+ *
+ * @since 1.26
+ */
+class HTMLUserTextField extends HTMLTextField {
+ public function __construct( $params ) {
+ $params += [
+ 'exists' => false,
+ 'ipallowed' => false,
+ ];
+
+ parent::__construct( $params );
+ }
+
+ public function validate( $value, $alldata ) {
+ // check, if a user exists with the given username
+ $user = User::newFromName( $value, false );
+
+ if ( !$user ) {
+ return $this->msg( 'htmlform-user-not-valid', $value );
+ } elseif (
+ ( $this->mParams['exists'] && $user->getId() === 0 ) &&
+ !( $this->mParams['ipallowed'] && User::isIP( $value ) )
+ ) {
+ return $this->msg( 'htmlform-user-not-exists', $user->getName() );
+ }
+
+ return parent::validate( $value, $alldata );
+ }
+
+ protected function getInputWidget( $params ) {
+ return new UserInputWidget( $params );
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+
+ protected function getOOUIModules() {
+ return [ 'mediawiki.widgets.UserInputWidget' ];
+ }
+
+ public function getInputHtml( $value ) {
+ // add the required module and css class for user suggestions in non-OOUI mode
+ $this->mParent->getOutput()->addModules( 'mediawiki.userSuggest' );
+ $this->mClass .= ' mw-autocomplete-user';
+
+ // return parent html
+ return parent::getInputHTML( $value );
+ }
+}
diff --git a/www/wiki/includes/htmlform/fields/HTMLUsersMultiselectField.php b/www/wiki/includes/htmlform/fields/HTMLUsersMultiselectField.php
new file mode 100644
index 00000000..f094745f
--- /dev/null
+++ b/www/wiki/includes/htmlform/fields/HTMLUsersMultiselectField.php
@@ -0,0 +1,91 @@
+<?php
+
+use MediaWiki\Widget\UsersMultiselectWidget;
+
+/**
+ * Implements a capsule multiselect input field for user names.
+ *
+ * Besides the parameters recognized by HTMLUserTextField, additional recognized
+ * parameters are:
+ * default - (optional) Array of usernames to use as preset data
+ * placeholder - (optional) Custom placeholder message for input
+ *
+ * The result is the array of usernames
+ *
+ * @note This widget is not likely to remain functional in non-OOUI forms.
+ */
+class HTMLUsersMultiselectField extends HTMLUserTextField {
+ public function loadDataFromRequest( $request ) {
+ $value = $request->getText( $this->mName, $this->getDefault() );
+
+ $usersArray = explode( "\n", $value );
+ // Remove empty lines
+ $usersArray = array_values( array_filter( $usersArray, function ( $username ) {
+ return trim( $username ) !== '';
+ } ) );
+ // This function is expected to return a string
+ return implode( "\n", $usersArray );
+ }
+
+ public function validate( $value, $alldata ) {
+ if ( !$this->mParams['exists'] ) {
+ return true;
+ }
+
+ if ( is_null( $value ) ) {
+ return false;
+ }
+
+ // $value is a string, because HTMLForm fields store their values as strings
+ $usersArray = explode( "\n", $value );
+ foreach ( $usersArray as $username ) {
+ $result = parent::validate( $username, $alldata );
+ if ( $result !== true ) {
+ return $result;
+ }
+ }
+
+ return true;
+ }
+
+ public function getInputHTML( $value ) {
+ $this->mParent->getOutput()->enableOOUI();
+ return $this->getInputOOUI( $value );
+ }
+
+ public function getInputOOUI( $value ) {
+ $params = [ 'name' => $this->mName ];
+
+ if ( isset( $this->mParams['default'] ) ) {
+ $params['default'] = $this->mParams['default'];
+ }
+
+ if ( isset( $this->mParams['placeholder'] ) ) {
+ $params['placeholder'] = $this->mParams['placeholder'];
+ } else {
+ $params['placeholder'] = $this->msg( 'mw-widgets-usersmultiselect-placeholder' )->plain();
+ }
+
+ if ( !is_null( $value ) ) {
+ // $value is a string, but the widget expects an array
+ $params['default'] = $value === '' ? [] : explode( "\n", $value );
+ }
+
+ // Make the field auto-infusable when it's used inside a legacy HTMLForm rather than OOUIHTMLForm
+ $params['infusable'] = true;
+ $params['classes'] = [ 'mw-htmlform-field-autoinfuse' ];
+ $widget = new UsersMultiselectWidget( $params );
+ $widget->setAttributes( [ 'data-mw-modules' => implode( ',', $this->getOOUIModules() ) ] );
+
+ return $widget;
+ }
+
+ protected function shouldInfuseOOUI() {
+ return true;
+ }
+
+ protected function getOOUIModules() {
+ return [ 'mediawiki.widgets.UsersMultiselectWidget' ];
+ }
+
+}
diff --git a/www/wiki/includes/http/CurlHttpRequest.php b/www/wiki/includes/http/CurlHttpRequest.php
new file mode 100644
index 00000000..3da3eb32
--- /dev/null
+++ b/www/wiki/includes/http/CurlHttpRequest.php
@@ -0,0 +1,170 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * MWHttpRequest implemented using internal curl compiled into PHP
+ */
+class CurlHttpRequest extends MWHttpRequest {
+ const SUPPORTS_FILE_POSTS = true;
+
+ protected $curlOptions = [];
+ protected $headerText = "";
+
+ /**
+ * @param resource $fh
+ * @param string $content
+ * @return int
+ */
+ protected function readHeader( $fh, $content ) {
+ $this->headerText .= $content;
+ return strlen( $content );
+ }
+
+ /**
+ * @see MWHttpRequest::execute
+ *
+ * @throws MWException
+ * @return Status
+ */
+ public function execute() {
+ $this->prepare();
+
+ if ( !$this->status->isOK() ) {
+ return Status::wrap( $this->status ); // TODO B/C; move this to callers
+ }
+
+ $this->curlOptions[CURLOPT_PROXY] = $this->proxy;
+ $this->curlOptions[CURLOPT_TIMEOUT] = $this->timeout;
+
+ // Only supported in curl >= 7.16.2
+ if ( defined( 'CURLOPT_CONNECTTIMEOUT_MS' ) ) {
+ $this->curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = $this->connectTimeout * 1000;
+ }
+
+ $this->curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
+ $this->curlOptions[CURLOPT_WRITEFUNCTION] = $this->callback;
+ $this->curlOptions[CURLOPT_HEADERFUNCTION] = [ $this, "readHeader" ];
+ $this->curlOptions[CURLOPT_MAXREDIRS] = $this->maxRedirects;
+ $this->curlOptions[CURLOPT_ENCODING] = ""; # Enable compression
+
+ $this->curlOptions[CURLOPT_USERAGENT] = $this->reqHeaders['User-Agent'];
+
+ $this->curlOptions[CURLOPT_SSL_VERIFYHOST] = $this->sslVerifyHost ? 2 : 0;
+ $this->curlOptions[CURLOPT_SSL_VERIFYPEER] = $this->sslVerifyCert;
+
+ if ( $this->caInfo ) {
+ $this->curlOptions[CURLOPT_CAINFO] = $this->caInfo;
+ }
+
+ if ( $this->headersOnly ) {
+ $this->curlOptions[CURLOPT_NOBODY] = true;
+ $this->curlOptions[CURLOPT_HEADER] = true;
+ } elseif ( $this->method == 'POST' ) {
+ $this->curlOptions[CURLOPT_POST] = true;
+ $postData = $this->postData;
+ // Don't interpret POST parameters starting with '@' as file uploads, because this
+ // makes it impossible to POST plain values starting with '@' (and causes security
+ // issues potentially exposing the contents of local files).
+ // The PHP manual says this option was introduced in PHP 5.5 defaults to true in PHP 5.6,
+ // but we support lower versions, and the option doesn't exist in HHVM 5.6.99.
+ if ( defined( 'CURLOPT_SAFE_UPLOAD' ) ) {
+ $this->curlOptions[CURLOPT_SAFE_UPLOAD] = true;
+ } elseif ( is_array( $postData ) ) {
+ // In PHP 5.2 and later, '@' is interpreted as a file upload if POSTFIELDS
+ // is an array, but not if it's a string. So convert $req['body'] to a string
+ // for safety.
+ $postData = wfArrayToCgi( $postData );
+ }
+ $this->curlOptions[CURLOPT_POSTFIELDS] = $postData;
+
+ // Suppress 'Expect: 100-continue' header, as some servers
+ // will reject it with a 417 and Curl won't auto retry
+ // with HTTP 1.0 fallback
+ $this->reqHeaders['Expect'] = '';
+ } else {
+ $this->curlOptions[CURLOPT_CUSTOMREQUEST] = $this->method;
+ }
+
+ $this->curlOptions[CURLOPT_HTTPHEADER] = $this->getHeaderList();
+
+ $curlHandle = curl_init( $this->url );
+
+ if ( !curl_setopt_array( $curlHandle, $this->curlOptions ) ) {
+ throw new InvalidArgumentException( "Error setting curl options." );
+ }
+
+ if ( $this->followRedirects && $this->canFollowRedirects() ) {
+ MediaWiki\suppressWarnings();
+ if ( !curl_setopt( $curlHandle, CURLOPT_FOLLOWLOCATION, true ) ) {
+ $this->logger->debug( __METHOD__ . ": Couldn't set CURLOPT_FOLLOWLOCATION. " .
+ "Probably open_basedir is set.\n" );
+ // Continue the processing. If it were in curl_setopt_array,
+ // processing would have halted on its entry
+ }
+ MediaWiki\restoreWarnings();
+ }
+
+ if ( $this->profiler ) {
+ $profileSection = $this->profiler->scopedProfileIn(
+ __METHOD__ . '-' . $this->profileName
+ );
+ }
+
+ $curlRes = curl_exec( $curlHandle );
+ if ( curl_errno( $curlHandle ) == CURLE_OPERATION_TIMEOUTED ) {
+ $this->status->fatal( 'http-timed-out', $this->url );
+ } elseif ( $curlRes === false ) {
+ $this->status->fatal( 'http-curl-error', curl_error( $curlHandle ) );
+ } else {
+ $this->headerList = explode( "\r\n", $this->headerText );
+ }
+
+ curl_close( $curlHandle );
+
+ if ( $this->profiler ) {
+ $this->profiler->scopedProfileOut( $profileSection );
+ }
+
+ $this->parseHeader();
+ $this->setStatus();
+
+ return Status::wrap( $this->status ); // TODO B/C; move this to callers
+ }
+
+ /**
+ * @return bool
+ */
+ public function canFollowRedirects() {
+ $curlVersionInfo = curl_version();
+ if ( $curlVersionInfo['version_number'] < 0x071304 ) {
+ $this->logger->debug( "Cannot follow redirects with libcurl < 7.19.4 due to CVE-2009-0037\n" );
+ return false;
+ }
+
+ if ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) {
+ if ( strval( ini_get( 'open_basedir' ) ) !== '' ) {
+ $this->logger->debug( "Cannot follow redirects when open_basedir is set\n" );
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/http/Http.php b/www/wiki/includes/http/Http.php
new file mode 100644
index 00000000..6eff6c9c
--- /dev/null
+++ b/www/wiki/includes/http/Http.php
@@ -0,0 +1,184 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Various HTTP related functions
+ * @ingroup HTTP
+ */
+class Http {
+ static public $httpEngine = false;
+
+ /**
+ * Perform an HTTP request
+ *
+ * @param string $method HTTP method. Usually GET/POST
+ * @param string $url Full URL to act on. If protocol-relative, will be expanded to an http:// URL
+ * @param array $options Options to pass to MWHttpRequest object.
+ * Possible keys for the array:
+ * - timeout Timeout length in seconds
+ * - connectTimeout Timeout for connection, in seconds (curl only)
+ * - postData An array of key-value pairs or a url-encoded form data
+ * - proxy The proxy to use.
+ * Otherwise it will use $wgHTTPProxy (if set)
+ * Otherwise it will use the environment variable "http_proxy" (if set)
+ * - noProxy Don't use any proxy at all. Takes precedence over proxy value(s).
+ * - sslVerifyHost Verify hostname against certificate
+ * - sslVerifyCert Verify SSL certificate
+ * - caInfo Provide CA information
+ * - maxRedirects Maximum number of redirects to follow (defaults to 5)
+ * - followRedirects Whether to follow redirects (defaults to false).
+ * Note: this should only be used when the target URL is trusted,
+ * to avoid attacks on intranet services accessible by HTTP.
+ * - userAgent A user agent, if you want to override the default
+ * MediaWiki/$wgVersion
+ * - logger A \Psr\Logger\LoggerInterface instance for debug logging
+ * - username Username for HTTP Basic Authentication
+ * - password Password for HTTP Basic Authentication
+ * - originalRequest Information about the original request (as a WebRequest object or
+ * an associative array with 'ip' and 'userAgent').
+ * @param string $caller The method making this request, for profiling
+ * @return string|bool (bool)false on failure or a string on success
+ */
+ public static function request( $method, $url, $options = [], $caller = __METHOD__ ) {
+ $logger = LoggerFactory::getInstance( 'http' );
+ $logger->debug( "$method: $url" );
+
+ $options['method'] = strtoupper( $method );
+
+ if ( !isset( $options['timeout'] ) ) {
+ $options['timeout'] = 'default';
+ }
+ if ( !isset( $options['connectTimeout'] ) ) {
+ $options['connectTimeout'] = 'default';
+ }
+
+ $req = MWHttpRequest::factory( $url, $options, $caller );
+ $status = $req->execute();
+
+ if ( $status->isOK() ) {
+ return $req->getContent();
+ } else {
+ $errors = $status->getErrorsByType( 'error' );
+ $logger->warning( Status::wrap( $status )->getWikiText( false, false, 'en' ),
+ [ 'error' => $errors, 'caller' => $caller, 'content' => $req->getContent() ] );
+ return false;
+ }
+ }
+
+ /**
+ * Simple wrapper for Http::request( 'GET' )
+ * @see Http::request()
+ * @since 1.25 Second parameter $timeout removed. Second parameter
+ * is now $options which can be given a 'timeout'
+ *
+ * @param string $url
+ * @param array $options
+ * @param string $caller The method making this request, for profiling
+ * @return string|bool false on error
+ */
+ public static function get( $url, $options = [], $caller = __METHOD__ ) {
+ $args = func_get_args();
+ if ( isset( $args[1] ) && ( is_string( $args[1] ) || is_numeric( $args[1] ) ) ) {
+ // Second was used to be the timeout
+ // And third parameter used to be $options
+ wfWarn( "Second parameter should not be a timeout.", 2 );
+ $options = isset( $args[2] ) && is_array( $args[2] ) ?
+ $args[2] : [];
+ $options['timeout'] = $args[1];
+ $caller = __METHOD__;
+ }
+ return self::request( 'GET', $url, $options, $caller );
+ }
+
+ /**
+ * Simple wrapper for Http::request( 'POST' )
+ * @see Http::request()
+ *
+ * @param string $url
+ * @param array $options
+ * @param string $caller The method making this request, for profiling
+ * @return string|bool false on error
+ */
+ public static function post( $url, $options = [], $caller = __METHOD__ ) {
+ return self::request( 'POST', $url, $options, $caller );
+ }
+
+ /**
+ * A standard user-agent we can use for external requests.
+ * @return string
+ */
+ public static function userAgent() {
+ global $wgVersion;
+ return "MediaWiki/$wgVersion";
+ }
+
+ /**
+ * Checks that the given URI is a valid one. Hardcoding the
+ * protocols, because we only want protocols that both cURL
+ * and php support.
+ *
+ * file:// should not be allowed here for security purpose (r67684)
+ *
+ * @todo FIXME this is wildly inaccurate and fails to actually check most stuff
+ *
+ * @param string $uri URI to check for validity
+ * @return bool
+ */
+ public static function isValidURI( $uri ) {
+ return (bool)preg_match(
+ '/^https?:\/\/[^\/\s]\S*$/D',
+ $uri
+ );
+ }
+
+ /**
+ * Gets the relevant proxy from $wgHTTPProxy
+ *
+ * @return mixed The proxy address or an empty string if not set.
+ */
+ public static function getProxy() {
+ global $wgHTTPProxy;
+
+ if ( $wgHTTPProxy ) {
+ return $wgHTTPProxy;
+ }
+
+ return "";
+ }
+
+ /**
+ * Get a configured MultiHttpClient
+ * @param array $options
+ * @return MultiHttpClient
+ */
+ public static function createMultiClient( $options = [] ) {
+ global $wgHTTPConnectTimeout, $wgHTTPTimeout, $wgHTTPProxy;
+
+ return new MultiHttpClient( $options + [
+ 'connTimeout' => $wgHTTPConnectTimeout,
+ 'reqTimeout' => $wgHTTPTimeout,
+ 'userAgent' => self::userAgent(),
+ 'proxy' => $wgHTTPProxy,
+ 'logger' => LoggerFactory::getInstance( 'http' )
+ ] );
+ }
+}
diff --git a/www/wiki/includes/http/MWHttpRequest.php b/www/wiki/includes/http/MWHttpRequest.php
new file mode 100644
index 00000000..0f0118ce
--- /dev/null
+++ b/www/wiki/includes/http/MWHttpRequest.php
@@ -0,0 +1,670 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * This wrapper class will call out to curl (if available) or fallback
+ * to regular PHP if necessary for handling internal HTTP requests.
+ *
+ * Renamed from HttpRequest to MWHttpRequest to avoid conflict with
+ * PHP's HTTP extension.
+ */
+class MWHttpRequest implements LoggerAwareInterface {
+ const SUPPORTS_FILE_POSTS = false;
+
+ /**
+ * @var int|string
+ */
+ protected $timeout = 'default';
+
+ protected $content;
+ protected $headersOnly = null;
+ protected $postData = null;
+ protected $proxy = null;
+ protected $noProxy = false;
+ protected $sslVerifyHost = true;
+ protected $sslVerifyCert = true;
+ protected $caInfo = null;
+ protected $method = "GET";
+ protected $reqHeaders = [];
+ protected $url;
+ protected $parsedUrl;
+ /** @var callable */
+ protected $callback;
+ protected $maxRedirects = 5;
+ protected $followRedirects = false;
+ protected $connectTimeout;
+
+ /**
+ * @var CookieJar
+ */
+ protected $cookieJar;
+
+ protected $headerList = [];
+ protected $respVersion = "0.9";
+ protected $respStatus = "200 Ok";
+ protected $respHeaders = [];
+
+ /** @var StatusValue */
+ protected $status;
+
+ /**
+ * @var Profiler
+ */
+ protected $profiler;
+
+ /**
+ * @var string
+ */
+ protected $profileName;
+
+ /**
+ * @var LoggerInterface;
+ */
+ protected $logger;
+
+ /**
+ * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL
+ * @param array $options (optional) extra params to pass (see Http::request())
+ * @param string $caller The method making this request, for profiling
+ * @param Profiler $profiler An instance of the profiler for profiling, or null
+ */
+ protected function __construct(
+ $url, $options = [], $caller = __METHOD__, $profiler = null
+ ) {
+ global $wgHTTPTimeout, $wgHTTPConnectTimeout;
+
+ $this->url = wfExpandUrl( $url, PROTO_HTTP );
+ $this->parsedUrl = wfParseUrl( $this->url );
+
+ if ( isset( $options['logger'] ) ) {
+ $this->logger = $options['logger'];
+ } else {
+ $this->logger = new NullLogger();
+ }
+
+ if ( !$this->parsedUrl || !Http::isValidURI( $this->url ) ) {
+ $this->status = StatusValue::newFatal( 'http-invalid-url', $url );
+ } else {
+ $this->status = StatusValue::newGood( 100 ); // continue
+ }
+
+ if ( isset( $options['timeout'] ) && $options['timeout'] != 'default' ) {
+ $this->timeout = $options['timeout'];
+ } else {
+ $this->timeout = $wgHTTPTimeout;
+ }
+ if ( isset( $options['connectTimeout'] ) && $options['connectTimeout'] != 'default' ) {
+ $this->connectTimeout = $options['connectTimeout'];
+ } else {
+ $this->connectTimeout = $wgHTTPConnectTimeout;
+ }
+ if ( isset( $options['userAgent'] ) ) {
+ $this->setUserAgent( $options['userAgent'] );
+ }
+ if ( isset( $options['username'] ) && isset( $options['password'] ) ) {
+ $this->setHeader(
+ 'Authorization',
+ 'Basic ' . base64_encode( $options['username'] . ':' . $options['password'] )
+ );
+ }
+ if ( isset( $options['originalRequest'] ) ) {
+ $this->setOriginalRequest( $options['originalRequest'] );
+ }
+
+ $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
+ "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
+
+ foreach ( $members as $o ) {
+ if ( isset( $options[$o] ) ) {
+ // ensure that MWHttpRequest::method is always
+ // uppercased. T38137
+ if ( $o == 'method' ) {
+ $options[$o] = strtoupper( $options[$o] );
+ }
+ $this->$o = $options[$o];
+ }
+ }
+
+ if ( $this->noProxy ) {
+ $this->proxy = ''; // noProxy takes precedence
+ }
+
+ // Profile based on what's calling us
+ $this->profiler = $profiler;
+ $this->profileName = $caller;
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Simple function to test if we can make any sort of requests at all, using
+ * cURL or fopen()
+ * @return bool
+ */
+ public static function canMakeRequests() {
+ return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
+ }
+
+ /**
+ * Generate a new request object
+ * @param string $url Url to use
+ * @param array $options (optional) extra params to pass (see Http::request())
+ * @param string $caller The method making this request, for profiling
+ * @throws DomainException
+ * @return CurlHttpRequest|PhpHttpRequest
+ * @see MWHttpRequest::__construct
+ */
+ public static function factory( $url, $options = null, $caller = __METHOD__ ) {
+ if ( !Http::$httpEngine ) {
+ Http::$httpEngine = function_exists( 'curl_init' ) ? 'curl' : 'php';
+ } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) {
+ throw new DomainException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' .
+ ' Http::$httpEngine is set to "curl"' );
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [];
+ }
+
+ if ( !isset( $options['logger'] ) ) {
+ $options['logger'] = LoggerFactory::getInstance( 'http' );
+ }
+
+ switch ( Http::$httpEngine ) {
+ case 'curl':
+ return new CurlHttpRequest( $url, $options, $caller, Profiler::instance() );
+ case 'php':
+ if ( !wfIniGetBool( 'allow_url_fopen' ) ) {
+ throw new DomainException( __METHOD__ . ': allow_url_fopen ' .
+ 'needs to be enabled for pure PHP http requests to ' .
+ 'work. If possible, curl should be used instead. See ' .
+ 'http://php.net/curl.'
+ );
+ }
+ return new PhpHttpRequest( $url, $options, $caller, Profiler::instance() );
+ default:
+ throw new DomainException( __METHOD__ . ': The setting of Http::$httpEngine is not valid.' );
+ }
+ }
+
+ /**
+ * Get the body, or content, of the response to the request
+ *
+ * @return string
+ */
+ public function getContent() {
+ return $this->content;
+ }
+
+ /**
+ * Set the parameters of the request
+ *
+ * @param array $args
+ * @todo overload the args param
+ */
+ public function setData( $args ) {
+ $this->postData = $args;
+ }
+
+ /**
+ * Take care of setting up the proxy (do nothing if "noProxy" is set)
+ *
+ * @return void
+ */
+ protected function proxySetup() {
+ // If there is an explicit proxy set and proxies are not disabled, then use it
+ if ( $this->proxy && !$this->noProxy ) {
+ return;
+ }
+
+ // Otherwise, fallback to $wgHTTPProxy if this is not a machine
+ // local URL and proxies are not disabled
+ if ( self::isLocalURL( $this->url ) || $this->noProxy ) {
+ $this->proxy = '';
+ } else {
+ $this->proxy = Http::getProxy();
+ }
+ }
+
+ /**
+ * Check if the URL can be served by localhost
+ *
+ * @param string $url Full url to check
+ * @return bool
+ */
+ private static function isLocalURL( $url ) {
+ global $wgCommandLineMode, $wgLocalVirtualHosts;
+
+ if ( $wgCommandLineMode ) {
+ return false;
+ }
+
+ // Extract host part
+ $matches = [];
+ if ( preg_match( '!^https?://([\w.-]+)[/:].*$!', $url, $matches ) ) {
+ $host = $matches[1];
+ // Split up dotwise
+ $domainParts = explode( '.', $host );
+ // Check if this domain or any superdomain is listed as a local virtual host
+ $domainParts = array_reverse( $domainParts );
+
+ $domain = '';
+ $countParts = count( $domainParts );
+ for ( $i = 0; $i < $countParts; $i++ ) {
+ $domainPart = $domainParts[$i];
+ if ( $i == 0 ) {
+ $domain = $domainPart;
+ } else {
+ $domain = $domainPart . '.' . $domain;
+ }
+
+ if ( in_array( $domain, $wgLocalVirtualHosts ) ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Set the user agent
+ * @param string $UA
+ */
+ public function setUserAgent( $UA ) {
+ $this->setHeader( 'User-Agent', $UA );
+ }
+
+ /**
+ * Set an arbitrary header
+ * @param string $name
+ * @param string $value
+ */
+ public function setHeader( $name, $value ) {
+ // I feel like I should normalize the case here...
+ $this->reqHeaders[$name] = $value;
+ }
+
+ /**
+ * Get an array of the headers
+ * @return array
+ */
+ protected function getHeaderList() {
+ $list = [];
+
+ if ( $this->cookieJar ) {
+ $this->reqHeaders['Cookie'] =
+ $this->cookieJar->serializeToHttpRequest(
+ $this->parsedUrl['path'],
+ $this->parsedUrl['host']
+ );
+ }
+
+ foreach ( $this->reqHeaders as $name => $value ) {
+ $list[] = "$name: $value";
+ }
+
+ return $list;
+ }
+
+ /**
+ * Set a read callback to accept data read from the HTTP request.
+ * By default, data is appended to an internal buffer which can be
+ * retrieved through $req->getContent().
+ *
+ * To handle data as it comes in -- especially for large files that
+ * would not fit in memory -- you can instead set your own callback,
+ * in the form function($resource, $buffer) where the first parameter
+ * is the low-level resource being read (implementation specific),
+ * and the second parameter is the data buffer.
+ *
+ * You MUST return the number of bytes handled in the buffer; if fewer
+ * bytes are reported handled than were passed to you, the HTTP fetch
+ * will be aborted.
+ *
+ * @param callable|null $callback
+ * @throws InvalidArgumentException
+ */
+ public function setCallback( $callback ) {
+ if ( is_null( $callback ) ) {
+ $callback = [ $this, 'read' ];
+ } elseif ( !is_callable( $callback ) ) {
+ throw new InvalidArgumentException( __METHOD__ . ': invalid callback' );
+ }
+ $this->callback = $callback;
+ }
+
+ /**
+ * A generic callback to read the body of the response from a remote
+ * server.
+ *
+ * @param resource $fh
+ * @param string $content
+ * @return int
+ * @internal
+ */
+ public function read( $fh, $content ) {
+ $this->content .= $content;
+ return strlen( $content );
+ }
+
+ /**
+ * Take care of whatever is necessary to perform the URI request.
+ *
+ * @return StatusValue
+ * @note currently returns Status for B/C
+ */
+ public function execute() {
+ throw new LogicException( 'children must override this' );
+ }
+
+ protected function prepare() {
+ $this->content = "";
+
+ if ( strtoupper( $this->method ) == "HEAD" ) {
+ $this->headersOnly = true;
+ }
+
+ $this->proxySetup(); // set up any proxy as needed
+
+ if ( !$this->callback ) {
+ $this->setCallback( null );
+ }
+
+ if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
+ $this->setUserAgent( Http::userAgent() );
+ }
+ }
+
+ /**
+ * Parses the headers, including the HTTP status code and any
+ * Set-Cookie headers. This function expects the headers to be
+ * found in an array in the member variable headerList.
+ */
+ protected function parseHeader() {
+ $lastname = "";
+
+ foreach ( $this->headerList as $header ) {
+ if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
+ $this->respVersion = $match[1];
+ $this->respStatus = $match[2];
+ } elseif ( preg_match( "#^[ \t]#", $header ) ) {
+ $last = count( $this->respHeaders[$lastname] ) - 1;
+ $this->respHeaders[$lastname][$last] .= "\r\n$header";
+ } elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
+ $this->respHeaders[strtolower( $match[1] )][] = $match[2];
+ $lastname = strtolower( $match[1] );
+ }
+ }
+
+ $this->parseCookies();
+ }
+
+ /**
+ * Sets HTTPRequest status member to a fatal value with the error
+ * message if the returned integer value of the status code was
+ * not successful (< 300) or a redirect (>=300 and < 400). (see
+ * RFC2616, section 10,
+ * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html for a
+ * list of status codes.)
+ */
+ protected function setStatus() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ if ( (int)$this->respStatus > 399 ) {
+ list( $code, $message ) = explode( " ", $this->respStatus, 2 );
+ $this->status->fatal( "http-bad-status", $code, $message );
+ }
+ }
+
+ /**
+ * Get the integer value of the HTTP status code (e.g. 200 for "200 Ok")
+ * (see RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
+ * for a list of status codes.)
+ *
+ * @return int
+ */
+ public function getStatus() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ return (int)$this->respStatus;
+ }
+
+ /**
+ * Returns true if the last status code was a redirect.
+ *
+ * @return bool
+ */
+ public function isRedirect() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ $status = (int)$this->respStatus;
+
+ if ( $status >= 300 && $status <= 303 ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns an associative array of response headers after the
+ * request has been executed. Because some headers
+ * (e.g. Set-Cookie) can appear more than once the, each value of
+ * the associative array is an array of the values given.
+ *
+ * @return array
+ */
+ public function getResponseHeaders() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ return $this->respHeaders;
+ }
+
+ /**
+ * Returns the value of the given response header.
+ *
+ * @param string $header
+ * @return string|null
+ */
+ public function getResponseHeader( $header ) {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
+ $v = $this->respHeaders[strtolower( $header )];
+ return $v[count( $v ) - 1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Tells the MWHttpRequest object to use this pre-loaded CookieJar.
+ *
+ * To read response cookies from the jar, getCookieJar must be called first.
+ *
+ * @param CookieJar $jar
+ */
+ public function setCookieJar( $jar ) {
+ $this->cookieJar = $jar;
+ }
+
+ /**
+ * Returns the cookie jar in use.
+ *
+ * @return CookieJar
+ */
+ public function getCookieJar() {
+ if ( !$this->respHeaders ) {
+ $this->parseHeader();
+ }
+
+ return $this->cookieJar;
+ }
+
+ /**
+ * Sets a cookie. Used before a request to set up any individual
+ * cookies. Used internally after a request to parse the
+ * Set-Cookie headers.
+ * @see Cookie::set
+ * @param string $name
+ * @param string $value
+ * @param array $attr
+ */
+ public function setCookie( $name, $value, $attr = [] ) {
+ if ( !$this->cookieJar ) {
+ $this->cookieJar = new CookieJar;
+ }
+
+ if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
+ $attr['domain'] = $this->parsedUrl['host'];
+ }
+
+ $this->cookieJar->setCookie( $name, $value, $attr );
+ }
+
+ /**
+ * Parse the cookies in the response headers and store them in the cookie jar.
+ */
+ protected function parseCookies() {
+ if ( !$this->cookieJar ) {
+ $this->cookieJar = new CookieJar;
+ }
+
+ if ( isset( $this->respHeaders['set-cookie'] ) ) {
+ $url = parse_url( $this->getFinalUrl() );
+ foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
+ $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
+ }
+ }
+ }
+
+ /**
+ * Returns the final URL after all redirections.
+ *
+ * Relative values of the "Location" header are incorrect as
+ * stated in RFC, however they do happen and modern browsers
+ * support them. This function loops backwards through all
+ * locations in order to build the proper absolute URI - Marooned
+ * at wikia-inc.com
+ *
+ * Note that the multiple Location: headers are an artifact of
+ * CURL -- they shouldn't actually get returned this way. Rewrite
+ * this when T31232 is taken care of (high-level redirect
+ * handling rewrite).
+ *
+ * @return string
+ */
+ public function getFinalUrl() {
+ $headers = $this->getResponseHeaders();
+
+ // return full url (fix for incorrect but handled relative location)
+ if ( isset( $headers['location'] ) ) {
+ $locations = $headers['location'];
+ $domain = '';
+ $foundRelativeURI = false;
+ $countLocations = count( $locations );
+
+ for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
+ $url = parse_url( $locations[$i] );
+
+ if ( isset( $url['host'] ) ) {
+ $domain = $url['scheme'] . '://' . $url['host'];
+ break; // found correct URI (with host)
+ } else {
+ $foundRelativeURI = true;
+ }
+ }
+
+ if ( !$foundRelativeURI ) {
+ return $locations[$countLocations - 1];
+ }
+ if ( $domain ) {
+ return $domain . $locations[$countLocations - 1];
+ }
+ $url = parse_url( $this->url );
+ if ( isset( $url['host'] ) ) {
+ return $url['scheme'] . '://' . $url['host'] .
+ $locations[$countLocations - 1];
+ }
+ }
+
+ return $this->url;
+ }
+
+ /**
+ * Returns true if the backend can follow redirects. Overridden by the
+ * child classes.
+ * @return bool
+ */
+ public function canFollowRedirects() {
+ return true;
+ }
+
+ /**
+ * Set information about the original request. This can be useful for
+ * endpoints/API modules which act as a proxy for some service, and
+ * throttling etc. needs to happen in that service.
+ * Calling this will result in the X-Forwarded-For and X-Original-User-Agent
+ * headers being set.
+ * @param WebRequest|array $originalRequest When in array form, it's
+ * expected to have the keys 'ip' and 'userAgent'.
+ * @note IP/user agent is personally identifiable information, and should
+ * only be set when the privacy policy of the request target is
+ * compatible with that of the MediaWiki installation.
+ */
+ public function setOriginalRequest( $originalRequest ) {
+ if ( $originalRequest instanceof WebRequest ) {
+ $originalRequest = [
+ 'ip' => $originalRequest->getIP(),
+ 'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
+ ];
+ } elseif (
+ !is_array( $originalRequest )
+ || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
+ ) {
+ throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
+ . "WebRequest or an array with 'ip' and 'userAgent' keys" );
+ }
+
+ $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
+ $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
+ }
+}
diff --git a/www/wiki/includes/http/PhpHttpRequest.php b/www/wiki/includes/http/PhpHttpRequest.php
new file mode 100644
index 00000000..0c5d1623
--- /dev/null
+++ b/www/wiki/includes/http/PhpHttpRequest.php
@@ -0,0 +1,265 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class PhpHttpRequest extends MWHttpRequest {
+
+ private $fopenErrors = [];
+
+ /**
+ * @param string $url
+ * @return string
+ */
+ protected function urlToTcp( $url ) {
+ $parsedUrl = parse_url( $url );
+
+ return 'tcp://' . $parsedUrl['host'] . ':' . $parsedUrl['port'];
+ }
+
+ /**
+ * Returns an array with a 'capath' or 'cafile' key
+ * that is suitable to be merged into the 'ssl' sub-array of
+ * a stream context options array.
+ * Uses the 'caInfo' option of the class if it is provided, otherwise uses the system
+ * default CA bundle if PHP supports that, or searches a few standard locations.
+ * @return array
+ * @throws DomainException
+ */
+ protected function getCertOptions() {
+ $certOptions = [];
+ $certLocations = [];
+ if ( $this->caInfo ) {
+ $certLocations = [ 'manual' => $this->caInfo ];
+ } elseif ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) {
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ // Default locations, based on
+ // https://www.happyassassin.net/2015/01/12/a-note-about-ssltls-trusted-certificate-stores-and-platforms/
+ // PHP 5.5 and older doesn't have any defaults, so we try to guess ourselves.
+ // PHP 5.6+ gets the CA location from OpenSSL as long as it is not set manually,
+ // so we should leave capath/cafile empty there.
+ // @codingStandardsIgnoreEnd
+ $certLocations = array_filter( [
+ getenv( 'SSL_CERT_DIR' ),
+ getenv( 'SSL_CERT_PATH' ),
+ '/etc/pki/tls/certs/ca-bundle.crt', # Fedora et al
+ '/etc/ssl/certs', # Debian et al
+ '/etc/pki/tls/certs/ca-bundle.trust.crt',
+ '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem',
+ '/System/Library/OpenSSL', # OSX
+ ] );
+ }
+
+ foreach ( $certLocations as $key => $cert ) {
+ if ( is_dir( $cert ) ) {
+ $certOptions['capath'] = $cert;
+ break;
+ } elseif ( is_file( $cert ) ) {
+ $certOptions['cafile'] = $cert;
+ break;
+ } elseif ( $key === 'manual' ) {
+ // fail more loudly if a cert path was manually configured and it is not valid
+ throw new DomainException( "Invalid CA info passed: $cert" );
+ }
+ }
+
+ return $certOptions;
+ }
+
+ /**
+ * Custom error handler for dealing with fopen() errors.
+ * fopen() tends to fire multiple errors in succession, and the last one
+ * is completely useless (something like "fopen: failed to open stream")
+ * so normal methods of handling errors programmatically
+ * like get_last_error() don't work.
+ * @internal
+ * @param int $errno
+ * @param string $errstr
+ */
+ public function errorHandler( $errno, $errstr ) {
+ $n = count( $this->fopenErrors ) + 1;
+ $this->fopenErrors += [ "errno$n" => $errno, "errstr$n" => $errstr ];
+ }
+
+ /**
+ * @see MWHttpRequest::execute
+ *
+ * @return Status
+ */
+ public function execute() {
+ $this->prepare();
+
+ if ( is_array( $this->postData ) ) {
+ $this->postData = wfArrayToCgi( $this->postData );
+ }
+
+ if ( $this->parsedUrl['scheme'] != 'http'
+ && $this->parsedUrl['scheme'] != 'https' ) {
+ $this->status->fatal( 'http-invalid-scheme', $this->parsedUrl['scheme'] );
+ }
+
+ $this->reqHeaders['Accept'] = "*/*";
+ $this->reqHeaders['Connection'] = 'Close';
+ if ( $this->method == 'POST' ) {
+ // Required for HTTP 1.0 POSTs
+ $this->reqHeaders['Content-Length'] = strlen( $this->postData );
+ if ( !isset( $this->reqHeaders['Content-Type'] ) ) {
+ $this->reqHeaders['Content-Type'] = "application/x-www-form-urlencoded";
+ }
+ }
+
+ // Set up PHP stream context
+ $options = [
+ 'http' => [
+ 'method' => $this->method,
+ 'header' => implode( "\r\n", $this->getHeaderList() ),
+ 'protocol_version' => '1.1',
+ 'max_redirects' => $this->followRedirects ? $this->maxRedirects : 0,
+ 'ignore_errors' => true,
+ 'timeout' => $this->timeout,
+ // Curl options in case curlwrappers are installed
+ 'curl_verify_ssl_host' => $this->sslVerifyHost ? 2 : 0,
+ 'curl_verify_ssl_peer' => $this->sslVerifyCert,
+ ],
+ 'ssl' => [
+ 'verify_peer' => $this->sslVerifyCert,
+ 'SNI_enabled' => true,
+ 'ciphers' => 'HIGH:!SSLv2:!SSLv3:-ADH:-kDH:-kECDH:-DSS',
+ 'disable_compression' => true,
+ ],
+ ];
+
+ if ( $this->proxy ) {
+ $options['http']['proxy'] = $this->urlToTcp( $this->proxy );
+ $options['http']['request_fulluri'] = true;
+ }
+
+ if ( $this->postData ) {
+ $options['http']['content'] = $this->postData;
+ }
+
+ if ( $this->sslVerifyHost ) {
+ // PHP 5.6.0 deprecates CN_match, in favour of peer_name which
+ // actually checks SubjectAltName properly.
+ if ( version_compare( PHP_VERSION, '5.6.0', '>=' ) ) {
+ $options['ssl']['peer_name'] = $this->parsedUrl['host'];
+ } else {
+ $options['ssl']['CN_match'] = $this->parsedUrl['host'];
+ }
+ }
+
+ $options['ssl'] += $this->getCertOptions();
+
+ $context = stream_context_create( $options );
+
+ $this->headerList = [];
+ $reqCount = 0;
+ $url = $this->url;
+
+ $result = [];
+
+ if ( $this->profiler ) {
+ $profileSection = $this->profiler->scopedProfileIn(
+ __METHOD__ . '-' . $this->profileName
+ );
+ }
+ do {
+ $reqCount++;
+ $this->fopenErrors = [];
+ set_error_handler( [ $this, 'errorHandler' ] );
+ $fh = fopen( $url, "r", false, $context );
+ restore_error_handler();
+
+ if ( !$fh ) {
+ // HACK for instant commons.
+ // If we are contacting (commons|upload).wikimedia.org
+ // try again with CN_match for en.wikipedia.org
+ // as php does not handle SubjectAltName properly
+ // prior to "peer_name" option in php 5.6
+ if ( isset( $options['ssl']['CN_match'] )
+ && ( $options['ssl']['CN_match'] === 'commons.wikimedia.org'
+ || $options['ssl']['CN_match'] === 'upload.wikimedia.org' )
+ ) {
+ $options['ssl']['CN_match'] = 'en.wikipedia.org';
+ $context = stream_context_create( $options );
+ continue;
+ }
+ break;
+ }
+
+ $result = stream_get_meta_data( $fh );
+ $this->headerList = $result['wrapper_data'];
+ $this->parseHeader();
+
+ if ( !$this->followRedirects ) {
+ break;
+ }
+
+ # Handle manual redirection
+ if ( !$this->isRedirect() || $reqCount > $this->maxRedirects ) {
+ break;
+ }
+ # Check security of URL
+ $url = $this->getResponseHeader( "Location" );
+
+ if ( !Http::isValidURI( $url ) ) {
+ $this->logger->debug( __METHOD__ . ": insecure redirection\n" );
+ break;
+ }
+ } while ( true );
+ if ( $this->profiler ) {
+ $this->profiler->scopedProfileOut( $profileSection );
+ }
+
+ $this->setStatus();
+
+ if ( $fh === false ) {
+ if ( $this->fopenErrors ) {
+ $this->logger->warning( __CLASS__
+ . ': error opening connection: {errstr1}', $this->fopenErrors );
+ }
+ $this->status->fatal( 'http-request-error' );
+ return Status::wrap( $this->status ); // TODO B/C; move this to callers
+ }
+
+ if ( $result['timed_out'] ) {
+ $this->status->fatal( 'http-timed-out', $this->url );
+ return Status::wrap( $this->status ); // TODO B/C; move this to callers
+ }
+
+ // If everything went OK, or we received some error code
+ // get the response body content.
+ if ( $this->status->isOK() || (int)$this->respStatus >= 300 ) {
+ while ( !feof( $fh ) ) {
+ $buf = fread( $fh, 8192 );
+
+ if ( $buf === false ) {
+ $this->status->fatal( 'http-read-error' );
+ break;
+ }
+
+ if ( strlen( $buf ) ) {
+ call_user_func( $this->callback, $fh, $buf );
+ }
+ }
+ }
+ fclose( $fh );
+
+ return Status::wrap( $this->status ); // TODO B/C; move this to callers
+ }
+}
diff --git a/www/wiki/includes/import/ImportSource.php b/www/wiki/includes/import/ImportSource.php
new file mode 100644
index 00000000..75d20b4e
--- /dev/null
+++ b/www/wiki/includes/import/ImportSource.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Source interface for XML import.
+ *
+ * Copyright © 2003,2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Source interface for XML import.
+ *
+ * @ingroup SpecialPage
+ */
+interface ImportSource {
+
+ /**
+ * Indicates whether the end of the input has been reached.
+ * Will return true after a finite number of calls to readChunk.
+ *
+ * @return bool true if there is no more input, false otherwise.
+ */
+ function atEnd();
+
+ /**
+ * Return a chunk of the input, as a (possibly empty) string.
+ * When the end of input is reached, readChunk() returns false.
+ * If atEnd() returns false, readChunk() will return a string.
+ * If atEnd() returns true, readChunk() will return false.
+ *
+ * @return bool|string
+ */
+ function readChunk();
+}
diff --git a/www/wiki/includes/import/ImportStreamSource.php b/www/wiki/includes/import/ImportStreamSource.php
new file mode 100644
index 00000000..94a2b937
--- /dev/null
+++ b/www/wiki/includes/import/ImportStreamSource.php
@@ -0,0 +1,183 @@
+<?php
+/**
+ * MediaWiki page data importer.
+ *
+ * Copyright © 2003,2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Imports a XML dump from a file (either from file upload, files on disk, or HTTP)
+ * @ingroup SpecialPage
+ */
+class ImportStreamSource implements ImportSource {
+ function __construct( $handle ) {
+ $this->mHandle = $handle;
+ }
+
+ /**
+ * @return bool
+ */
+ function atEnd() {
+ return feof( $this->mHandle );
+ }
+
+ /**
+ * @return string
+ */
+ function readChunk() {
+ return fread( $this->mHandle, 32768 );
+ }
+
+ /**
+ * @param string $filename
+ * @return Status
+ */
+ static function newFromFile( $filename ) {
+ MediaWiki\suppressWarnings();
+ $file = fopen( $filename, 'rt' );
+ MediaWiki\restoreWarnings();
+ if ( !$file ) {
+ return Status::newFatal( "importcantopen" );
+ }
+ return Status::newGood( new ImportStreamSource( $file ) );
+ }
+
+ /**
+ * @param string $fieldname
+ * @return Status
+ */
+ static function newFromUpload( $fieldname = "xmlimport" ) {
+ $upload =& $_FILES[$fieldname];
+
+ if ( $upload === null || !$upload['name'] ) {
+ return Status::newFatal( 'importnofile' );
+ }
+ if ( !empty( $upload['error'] ) ) {
+ switch ( $upload['error'] ) {
+ case 1:
+ # The uploaded file exceeds the upload_max_filesize directive in php.ini.
+ return Status::newFatal( 'importuploaderrorsize' );
+ case 2:
+ # The uploaded file exceeds the MAX_FILE_SIZE directive that
+ # was specified in the HTML form.
+ return Status::newFatal( 'importuploaderrorsize' );
+ case 3:
+ # The uploaded file was only partially uploaded
+ return Status::newFatal( 'importuploaderrorpartial' );
+ case 6:
+ # Missing a temporary folder.
+ return Status::newFatal( 'importuploaderrortemp' );
+ # case else: # Currently impossible
+ }
+
+ }
+ $fname = $upload['tmp_name'];
+ if ( is_uploaded_file( $fname ) ) {
+ return self::newFromFile( $fname );
+ } else {
+ return Status::newFatal( 'importnofile' );
+ }
+ }
+
+ /**
+ * @param string $url
+ * @param string $method
+ * @return Status
+ */
+ static function newFromURL( $url, $method = 'GET' ) {
+ global $wgHTTPImportTimeout;
+ wfDebug( __METHOD__ . ": opening $url\n" );
+ # Use the standard HTTP fetch function; it times out
+ # quicker and sorts out user-agent problems which might
+ # otherwise prevent importing from large sites, such
+ # as the Wikimedia cluster, etc.
+ $data = Http::request(
+ $method,
+ $url,
+ [
+ 'followRedirects' => true,
+ 'timeout' => $wgHTTPImportTimeout
+ ],
+ __METHOD__
+ );
+ if ( $data !== false ) {
+ $file = tmpfile();
+ fwrite( $file, $data );
+ fflush( $file );
+ fseek( $file, 0 );
+ return Status::newGood( new ImportStreamSource( $file ) );
+ } else {
+ return Status::newFatal( 'importcantopen' );
+ }
+ }
+
+ /**
+ * @param string $interwiki
+ * @param string $page
+ * @param bool $history
+ * @param bool $templates
+ * @param int $pageLinkDepth
+ * @return Status
+ */
+ public static function newFromInterwiki( $interwiki, $page, $history = false,
+ $templates = false, $pageLinkDepth = 0
+ ) {
+ if ( $page == '' ) {
+ return Status::newFatal( 'import-noarticle' );
+ }
+
+ # Look up the first interwiki prefix, and let the foreign site handle
+ # subsequent interwiki prefixes
+ $firstIwPrefix = strtok( $interwiki, ':' );
+ $interwikiLookup = MediaWikiServices::getInstance()->getInterwikiLookup();
+ $firstIw = $interwikiLookup->fetch( $firstIwPrefix );
+ if ( !$firstIw ) {
+ return Status::newFatal( 'importbadinterwiki' );
+ }
+
+ $additionalIwPrefixes = strtok( '' );
+ if ( $additionalIwPrefixes ) {
+ $additionalIwPrefixes .= ':';
+ }
+ # Have to do a DB-key replacement ourselves; otherwise spaces get
+ # URL-encoded to +, which is wrong in this case. Similar to logic in
+ # Title::getLocalURL
+ $link = $firstIw->getURL( strtr( "${additionalIwPrefixes}Special:Export/$page",
+ ' ', '_' ) );
+
+ $params = [];
+ if ( $history ) {
+ $params['history'] = 1;
+ }
+ if ( $templates ) {
+ $params['templates'] = 1;
+ }
+ if ( $pageLinkDepth ) {
+ $params['pagelink-depth'] = $pageLinkDepth;
+ }
+
+ $url = wfAppendQuery( $link, $params );
+ # For interwikis, use POST to avoid redirects.
+ return self::newFromURL( $url, "POST" );
+ }
+}
diff --git a/www/wiki/includes/import/ImportStringSource.php b/www/wiki/includes/import/ImportStringSource.php
new file mode 100644
index 00000000..85983b1a
--- /dev/null
+++ b/www/wiki/includes/import/ImportStringSource.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * MediaWiki page data importer.
+ *
+ * Copyright © 2003,2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Used for importing XML dumps where the content of the dump is in a string.
+ * This class is ineffecient, and should only be used for small dumps.
+ * For larger dumps, ImportStreamSource should be used instead.
+ *
+ * @ingroup SpecialPage
+ */
+class ImportStringSource implements ImportSource {
+ function __construct( $string ) {
+ $this->mString = $string;
+ $this->mRead = false;
+ }
+
+ /**
+ * @return bool
+ */
+ function atEnd() {
+ return $this->mRead;
+ }
+
+ /**
+ * @return bool|string
+ */
+ function readChunk() {
+ if ( $this->atEnd() ) {
+ return false;
+ }
+ $this->mRead = true;
+ return $this->mString;
+ }
+}
diff --git a/www/wiki/includes/import/UploadSourceAdapter.php b/www/wiki/includes/import/UploadSourceAdapter.php
new file mode 100644
index 00000000..ccacbe4a
--- /dev/null
+++ b/www/wiki/includes/import/UploadSourceAdapter.php
@@ -0,0 +1,149 @@
+<?php
+/**
+ * MediaWiki page data importer.
+ *
+ * Copyright © 2003,2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * This is a horrible hack used to keep source compatibility.
+ * @ingroup SpecialPage
+ */
+class UploadSourceAdapter {
+ /** @var array */
+ public static $sourceRegistrations = [];
+
+ /** @var string */
+ private $mSource;
+
+ /** @var string */
+ private $mBuffer;
+
+ /** @var int */
+ private $mPosition;
+
+ /**
+ * @param ImportSource $source
+ * @return string
+ */
+ static function registerSource( ImportSource $source ) {
+ $id = wfRandomString();
+
+ self::$sourceRegistrations[$id] = $source;
+
+ return $id;
+ }
+
+ /**
+ * @param string $path
+ * @param string $mode
+ * @param array $options
+ * @param string &$opened_path
+ * @return bool
+ */
+ function stream_open( $path, $mode, $options, &$opened_path ) {
+ $url = parse_url( $path );
+ $id = $url['host'];
+
+ if ( !isset( self::$sourceRegistrations[$id] ) ) {
+ return false;
+ }
+
+ $this->mSource = self::$sourceRegistrations[$id];
+
+ return true;
+ }
+
+ /**
+ * @param int $count
+ * @return string
+ */
+ function stream_read( $count ) {
+ $return = '';
+ $leave = false;
+
+ while ( !$leave && !$this->mSource->atEnd() &&
+ strlen( $this->mBuffer ) < $count ) {
+ $read = $this->mSource->readChunk();
+
+ if ( !strlen( $read ) ) {
+ $leave = true;
+ }
+
+ $this->mBuffer .= $read;
+ }
+
+ if ( strlen( $this->mBuffer ) ) {
+ $return = substr( $this->mBuffer, 0, $count );
+ $this->mBuffer = substr( $this->mBuffer, $count );
+ }
+
+ $this->mPosition += strlen( $return );
+
+ return $return;
+ }
+
+ /**
+ * @param string $data
+ * @return bool
+ */
+ function stream_write( $data ) {
+ return false;
+ }
+
+ /**
+ * @return mixed
+ */
+ function stream_tell() {
+ return $this->mPosition;
+ }
+
+ /**
+ * @return bool
+ */
+ function stream_eof() {
+ return $this->mSource->atEnd();
+ }
+
+ /**
+ * @return array
+ */
+ function url_stat() {
+ $result = [];
+
+ $result['dev'] = $result[0] = 0;
+ $result['ino'] = $result[1] = 0;
+ $result['mode'] = $result[2] = 0;
+ $result['nlink'] = $result[3] = 0;
+ $result['uid'] = $result[4] = 0;
+ $result['gid'] = $result[5] = 0;
+ $result['rdev'] = $result[6] = 0;
+ $result['size'] = $result[7] = 0;
+ $result['atime'] = $result[8] = 0;
+ $result['mtime'] = $result[9] = 0;
+ $result['ctime'] = $result[10] = 0;
+ $result['blksize'] = $result[11] = 0;
+ $result['blocks'] = $result[12] = 0;
+
+ return $result;
+ }
+}
diff --git a/www/wiki/includes/import/WikiImporter.php b/www/wiki/includes/import/WikiImporter.php
new file mode 100644
index 00000000..90660797
--- /dev/null
+++ b/www/wiki/includes/import/WikiImporter.php
@@ -0,0 +1,1099 @@
+<?php
+/**
+ * MediaWiki page data importer.
+ *
+ * Copyright © 2003,2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * XML file reader for the page data importer.
+ *
+ * implements Special:Import
+ * @ingroup SpecialPage
+ */
+class WikiImporter {
+ private $reader = null;
+ private $foreignNamespaces = null;
+ private $mLogItemCallback, $mUploadCallback, $mRevisionCallback, $mPageCallback;
+ private $mSiteInfoCallback, $mPageOutCallback;
+ private $mNoticeCallback, $mDebug;
+ private $mImportUploads, $mImageBasePath;
+ private $mNoUpdates = false;
+ private $pageOffset = 0;
+ /** @var Config */
+ private $config;
+ /** @var ImportTitleFactory */
+ private $importTitleFactory;
+ /** @var array */
+ private $countableCache = [];
+ /** @var bool */
+ private $disableStatisticsUpdate = false;
+
+ /**
+ * Creates an ImportXMLReader drawing from the source provided
+ * @param ImportSource $source
+ * @param Config $config
+ * @throws Exception
+ */
+ function __construct( ImportSource $source, Config $config ) {
+ if ( !class_exists( 'XMLReader' ) ) {
+ throw new Exception( 'Import requires PHP to have been compiled with libxml support' );
+ }
+
+ $this->reader = new XMLReader();
+ $this->config = $config;
+
+ if ( !in_array( 'uploadsource', stream_get_wrappers() ) ) {
+ stream_wrapper_register( 'uploadsource', 'UploadSourceAdapter' );
+ }
+ $id = UploadSourceAdapter::registerSource( $source );
+
+ // Enable the entity loader, as it is needed for loading external URLs via
+ // XMLReader::open (T86036)
+ $oldDisable = libxml_disable_entity_loader( false );
+ if ( defined( 'LIBXML_PARSEHUGE' ) ) {
+ $status = $this->reader->open( "uploadsource://$id", null, LIBXML_PARSEHUGE );
+ } else {
+ $status = $this->reader->open( "uploadsource://$id" );
+ }
+ if ( !$status ) {
+ $error = libxml_get_last_error();
+ libxml_disable_entity_loader( $oldDisable );
+ throw new MWException( 'Encountered an internal error while initializing WikiImporter object: ' .
+ $error->message );
+ }
+ libxml_disable_entity_loader( $oldDisable );
+
+ // Default callbacks
+ $this->setPageCallback( [ $this, 'beforeImportPage' ] );
+ $this->setRevisionCallback( [ $this, "importRevision" ] );
+ $this->setUploadCallback( [ $this, 'importUpload' ] );
+ $this->setLogItemCallback( [ $this, 'importLogItem' ] );
+ $this->setPageOutCallback( [ $this, 'finishImportPage' ] );
+
+ $this->importTitleFactory = new NaiveImportTitleFactory();
+ }
+
+ /**
+ * @return null|XMLReader
+ */
+ public function getReader() {
+ return $this->reader;
+ }
+
+ public function throwXmlError( $err ) {
+ $this->debug( "FAILURE: $err" );
+ wfDebug( "WikiImporter XML error: $err\n" );
+ }
+
+ public function debug( $data ) {
+ if ( $this->mDebug ) {
+ wfDebug( "IMPORT: $data\n" );
+ }
+ }
+
+ public function warn( $data ) {
+ wfDebug( "IMPORT: $data\n" );
+ }
+
+ public function notice( $msg /*, $param, ...*/ ) {
+ $params = func_get_args();
+ array_shift( $params );
+
+ if ( is_callable( $this->mNoticeCallback ) ) {
+ call_user_func( $this->mNoticeCallback, $msg, $params );
+ } else { # No ImportReporter -> CLI
+ echo wfMessage( $msg, $params )->text() . "\n";
+ }
+ }
+
+ /**
+ * Set debug mode...
+ * @param bool $debug
+ */
+ function setDebug( $debug ) {
+ $this->mDebug = $debug;
+ }
+
+ /**
+ * Set 'no updates' mode. In this mode, the link tables will not be updated by the importer
+ * @param bool $noupdates
+ */
+ function setNoUpdates( $noupdates ) {
+ $this->mNoUpdates = $noupdates;
+ }
+
+ /**
+ * Sets 'pageOffset' value. So it will skip the first n-1 pages
+ * and start from the nth page. It's 1-based indexing.
+ * @param int $nthPage
+ * @since 1.29
+ */
+ function setPageOffset( $nthPage ) {
+ $this->pageOffset = $nthPage;
+ }
+
+ /**
+ * Set a callback that displays notice messages
+ *
+ * @param callable $callback
+ * @return callable
+ */
+ public function setNoticeCallback( $callback ) {
+ return wfSetVar( $this->mNoticeCallback, $callback );
+ }
+
+ /**
+ * Sets the action to perform as each new page in the stream is reached.
+ * @param callable $callback
+ * @return callable
+ */
+ public function setPageCallback( $callback ) {
+ $previous = $this->mPageCallback;
+ $this->mPageCallback = $callback;
+ return $previous;
+ }
+
+ /**
+ * Sets the action to perform as each page in the stream is completed.
+ * Callback accepts the page title (as a Title object), a second object
+ * with the original title form (in case it's been overridden into a
+ * local namespace), and a count of revisions.
+ *
+ * @param callable $callback
+ * @return callable
+ */
+ public function setPageOutCallback( $callback ) {
+ $previous = $this->mPageOutCallback;
+ $this->mPageOutCallback = $callback;
+ return $previous;
+ }
+
+ /**
+ * Sets the action to perform as each page revision is reached.
+ * @param callable $callback
+ * @return callable
+ */
+ public function setRevisionCallback( $callback ) {
+ $previous = $this->mRevisionCallback;
+ $this->mRevisionCallback = $callback;
+ return $previous;
+ }
+
+ /**
+ * Sets the action to perform as each file upload version is reached.
+ * @param callable $callback
+ * @return callable
+ */
+ public function setUploadCallback( $callback ) {
+ $previous = $this->mUploadCallback;
+ $this->mUploadCallback = $callback;
+ return $previous;
+ }
+
+ /**
+ * Sets the action to perform as each log item reached.
+ * @param callable $callback
+ * @return callable
+ */
+ public function setLogItemCallback( $callback ) {
+ $previous = $this->mLogItemCallback;
+ $this->mLogItemCallback = $callback;
+ return $previous;
+ }
+
+ /**
+ * Sets the action to perform when site info is encountered
+ * @param callable $callback
+ * @return callable
+ */
+ public function setSiteInfoCallback( $callback ) {
+ $previous = $this->mSiteInfoCallback;
+ $this->mSiteInfoCallback = $callback;
+ return $previous;
+ }
+
+ /**
+ * Sets the factory object to use to convert ForeignTitle objects into local
+ * Title objects
+ * @param ImportTitleFactory $factory
+ */
+ public function setImportTitleFactory( $factory ) {
+ $this->importTitleFactory = $factory;
+ }
+
+ /**
+ * Set a target namespace to override the defaults
+ * @param null|int $namespace
+ * @return bool
+ */
+ public function setTargetNamespace( $namespace ) {
+ if ( is_null( $namespace ) ) {
+ // Don't override namespaces
+ $this->setImportTitleFactory( new NaiveImportTitleFactory() );
+ return true;
+ } elseif (
+ $namespace >= 0 &&
+ MWNamespace::exists( intval( $namespace ) )
+ ) {
+ $namespace = intval( $namespace );
+ $this->setImportTitleFactory( new NamespaceImportTitleFactory( $namespace ) );
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Set a target root page under which all pages are imported
+ * @param null|string $rootpage
+ * @return Status
+ */
+ public function setTargetRootPage( $rootpage ) {
+ $status = Status::newGood();
+ if ( is_null( $rootpage ) ) {
+ // No rootpage
+ $this->setImportTitleFactory( new NaiveImportTitleFactory() );
+ } elseif ( $rootpage !== '' ) {
+ $rootpage = rtrim( $rootpage, '/' ); // avoid double slashes
+ $title = Title::newFromText( $rootpage );
+
+ if ( !$title || $title->isExternal() ) {
+ $status->fatal( 'import-rootpage-invalid' );
+ } else {
+ if ( !MWNamespace::hasSubpages( $title->getNamespace() ) ) {
+ global $wgContLang;
+
+ $displayNSText = $title->getNamespace() == NS_MAIN
+ ? wfMessage( 'blanknamespace' )->text()
+ : $wgContLang->getNsText( $title->getNamespace() );
+ $status->fatal( 'import-rootpage-nosubpage', $displayNSText );
+ } else {
+ // set namespace to 'all', so the namespace check in processTitle() can pass
+ $this->setTargetNamespace( null );
+ $this->setImportTitleFactory( new SubpageImportTitleFactory( $title ) );
+ }
+ }
+ }
+ return $status;
+ }
+
+ /**
+ * @param string $dir
+ */
+ public function setImageBasePath( $dir ) {
+ $this->mImageBasePath = $dir;
+ }
+
+ /**
+ * @param bool $import
+ */
+ public function setImportUploads( $import ) {
+ $this->mImportUploads = $import;
+ }
+
+ /**
+ * Statistics update can cause a lot of time
+ * @since 1.29
+ */
+ public function disableStatisticsUpdate() {
+ $this->disableStatisticsUpdate = true;
+ }
+
+ /**
+ * Default per-page callback. Sets up some things related to site statistics
+ * @param array $titleAndForeignTitle Two-element array, with Title object at
+ * index 0 and ForeignTitle object at index 1
+ * @return bool
+ */
+ public function beforeImportPage( $titleAndForeignTitle ) {
+ $title = $titleAndForeignTitle[0];
+ $page = WikiPage::factory( $title );
+ $this->countableCache['title_' . $title->getPrefixedText()] = $page->isCountable();
+ return true;
+ }
+
+ /**
+ * Default per-revision callback, performs the import.
+ * @param WikiRevision $revision
+ * @return bool
+ */
+ public function importRevision( $revision ) {
+ if ( !$revision->getContentHandler()->canBeUsedOn( $revision->getTitle() ) ) {
+ $this->notice( 'import-error-bad-location',
+ $revision->getTitle()->getPrefixedText(),
+ $revision->getID(),
+ $revision->getModel(),
+ $revision->getFormat() );
+
+ return false;
+ }
+
+ try {
+ return $revision->importOldRevision();
+ } catch ( MWContentSerializationException $ex ) {
+ $this->notice( 'import-error-unserialize',
+ $revision->getTitle()->getPrefixedText(),
+ $revision->getID(),
+ $revision->getModel(),
+ $revision->getFormat() );
+ }
+
+ return false;
+ }
+
+ /**
+ * Default per-revision callback, performs the import.
+ * @param WikiRevision $revision
+ * @return bool
+ */
+ public function importLogItem( $revision ) {
+ return $revision->importLogItem();
+ }
+
+ /**
+ * Dummy for now...
+ * @param WikiRevision $revision
+ * @return bool
+ */
+ public function importUpload( $revision ) {
+ return $revision->importUpload();
+ }
+
+ /**
+ * Mostly for hook use
+ * @param Title $title
+ * @param ForeignTitle $foreignTitle
+ * @param int $revCount
+ * @param int $sRevCount
+ * @param array $pageInfo
+ * @return bool
+ */
+ public function finishImportPage( $title, $foreignTitle, $revCount,
+ $sRevCount, $pageInfo
+ ) {
+ // Update article count statistics (T42009)
+ // The normal counting logic in WikiPage->doEditUpdates() is designed for
+ // one-revision-at-a-time editing, not bulk imports. In this situation it
+ // suffers from issues of replica DB lag. We let WikiPage handle the total page
+ // and revision count, and we implement our own custom logic for the
+ // article (content page) count.
+ if ( !$this->disableStatisticsUpdate ) {
+ $page = WikiPage::factory( $title );
+ $page->loadPageData( 'fromdbmaster' );
+ $content = $page->getContent();
+ if ( $content === null ) {
+ wfDebug( __METHOD__ . ': Skipping article count adjustment for ' . $title .
+ ' because WikiPage::getContent() returned null' );
+ } else {
+ $editInfo = $page->prepareContentForEdit( $content );
+ $countKey = 'title_' . $title->getPrefixedText();
+ $countable = $page->isCountable( $editInfo );
+ if ( array_key_exists( $countKey, $this->countableCache ) &&
+ $countable != $this->countableCache[$countKey] ) {
+ DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [
+ 'articles' => ( (int)$countable - (int)$this->countableCache[$countKey] )
+ ] ) );
+ }
+ }
+ }
+
+ $args = func_get_args();
+ return Hooks::run( 'AfterImportPage', $args );
+ }
+
+ /**
+ * Alternate per-revision callback, for debugging.
+ * @param WikiRevision &$revision
+ */
+ public function debugRevisionHandler( &$revision ) {
+ $this->debug( "Got revision:" );
+ if ( is_object( $revision->title ) ) {
+ $this->debug( "-- Title: " . $revision->title->getPrefixedText() );
+ } else {
+ $this->debug( "-- Title: <invalid>" );
+ }
+ $this->debug( "-- User: " . $revision->user_text );
+ $this->debug( "-- Timestamp: " . $revision->timestamp );
+ $this->debug( "-- Comment: " . $revision->comment );
+ $this->debug( "-- Text: " . $revision->text );
+ }
+
+ /**
+ * Notify the callback function of site info
+ * @param array $siteInfo
+ * @return bool|mixed
+ */
+ private function siteInfoCallback( $siteInfo ) {
+ if ( isset( $this->mSiteInfoCallback ) ) {
+ return call_user_func_array( $this->mSiteInfoCallback,
+ [ $siteInfo, $this ] );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Notify the callback function when a new "<page>" is reached.
+ * @param Title $title
+ */
+ function pageCallback( $title ) {
+ if ( isset( $this->mPageCallback ) ) {
+ call_user_func( $this->mPageCallback, $title );
+ }
+ }
+
+ /**
+ * Notify the callback function when a "</page>" is closed.
+ * @param Title $title
+ * @param ForeignTitle $foreignTitle
+ * @param int $revCount
+ * @param int $sucCount Number of revisions for which callback returned true
+ * @param array $pageInfo Associative array of page information
+ */
+ private function pageOutCallback( $title, $foreignTitle, $revCount,
+ $sucCount, $pageInfo ) {
+ if ( isset( $this->mPageOutCallback ) ) {
+ $args = func_get_args();
+ call_user_func_array( $this->mPageOutCallback, $args );
+ }
+ }
+
+ /**
+ * Notify the callback function of a revision
+ * @param WikiRevision $revision
+ * @return bool|mixed
+ */
+ private function revisionCallback( $revision ) {
+ if ( isset( $this->mRevisionCallback ) ) {
+ return call_user_func_array( $this->mRevisionCallback,
+ [ $revision, $this ] );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Notify the callback function of a new log item
+ * @param WikiRevision $revision
+ * @return bool|mixed
+ */
+ private function logItemCallback( $revision ) {
+ if ( isset( $this->mLogItemCallback ) ) {
+ return call_user_func_array( $this->mLogItemCallback,
+ [ $revision, $this ] );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Retrieves the contents of the named attribute of the current element.
+ * @param string $attr The name of the attribute
+ * @return string The value of the attribute or an empty string if it is not set in the current
+ * element.
+ */
+ public function nodeAttribute( $attr ) {
+ return $this->reader->getAttribute( $attr );
+ }
+
+ /**
+ * Shouldn't something like this be built-in to XMLReader?
+ * Fetches text contents of the current element, assuming
+ * no sub-elements or such scary things.
+ * @return string
+ * @access private
+ */
+ public function nodeContents() {
+ if ( $this->reader->isEmptyElement ) {
+ return "";
+ }
+ $buffer = "";
+ while ( $this->reader->read() ) {
+ switch ( $this->reader->nodeType ) {
+ case XMLReader::TEXT:
+ case XMLReader::CDATA:
+ case XMLReader::SIGNIFICANT_WHITESPACE:
+ $buffer .= $this->reader->value;
+ break;
+ case XMLReader::END_ELEMENT:
+ return $buffer;
+ }
+ }
+
+ $this->reader->close();
+ return '';
+ }
+
+ /**
+ * Primary entry point
+ * @throws MWException
+ * @return bool
+ */
+ public function doImport() {
+ // Calls to reader->read need to be wrapped in calls to
+ // libxml_disable_entity_loader() to avoid local file
+ // inclusion attacks (T48932).
+ $oldDisable = libxml_disable_entity_loader( true );
+ $this->reader->read();
+
+ if ( $this->reader->localName != 'mediawiki' ) {
+ libxml_disable_entity_loader( $oldDisable );
+ throw new MWException( "Expected <mediawiki> tag, got " .
+ $this->reader->localName );
+ }
+ $this->debug( "<mediawiki> tag is correct." );
+
+ $this->debug( "Starting primary dump processing loop." );
+
+ $keepReading = $this->reader->read();
+ $skip = false;
+ $rethrow = null;
+ $pageCount = 0;
+ try {
+ while ( $keepReading ) {
+ $tag = $this->reader->localName;
+ if ( $this->pageOffset ) {
+ if ( $tag === 'page' ) {
+ $pageCount++;
+ }
+ if ( $pageCount < $this->pageOffset ) {
+ $keepReading = $this->reader->next();
+ continue;
+ }
+ }
+ $type = $this->reader->nodeType;
+
+ if ( !Hooks::run( 'ImportHandleToplevelXMLTag', [ $this ] ) ) {
+ // Do nothing
+ } elseif ( $tag == 'mediawiki' && $type == XMLReader::END_ELEMENT ) {
+ break;
+ } elseif ( $tag == 'siteinfo' ) {
+ $this->handleSiteInfo();
+ } elseif ( $tag == 'page' ) {
+ $this->handlePage();
+ } elseif ( $tag == 'logitem' ) {
+ $this->handleLogItem();
+ } elseif ( $tag != '#text' ) {
+ $this->warn( "Unhandled top-level XML tag $tag" );
+
+ $skip = true;
+ }
+
+ if ( $skip ) {
+ $keepReading = $this->reader->next();
+ $skip = false;
+ $this->debug( "Skip" );
+ } else {
+ $keepReading = $this->reader->read();
+ }
+ }
+ } catch ( Exception $ex ) {
+ $rethrow = $ex;
+ }
+
+ // finally
+ libxml_disable_entity_loader( $oldDisable );
+ $this->reader->close();
+
+ if ( $rethrow ) {
+ throw $rethrow;
+ }
+
+ return true;
+ }
+
+ private function handleSiteInfo() {
+ $this->debug( "Enter site info handler." );
+ $siteInfo = [];
+
+ // Fields that can just be stuffed in the siteInfo object
+ $normalFields = [ 'sitename', 'base', 'generator', 'case' ];
+
+ while ( $this->reader->read() ) {
+ if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
+ $this->reader->localName == 'siteinfo' ) {
+ break;
+ }
+
+ $tag = $this->reader->localName;
+
+ if ( $tag == 'namespace' ) {
+ $this->foreignNamespaces[$this->nodeAttribute( 'key' )] =
+ $this->nodeContents();
+ } elseif ( in_array( $tag, $normalFields ) ) {
+ $siteInfo[$tag] = $this->nodeContents();
+ }
+ }
+
+ $siteInfo['_namespaces'] = $this->foreignNamespaces;
+ $this->siteInfoCallback( $siteInfo );
+ }
+
+ private function handleLogItem() {
+ $this->debug( "Enter log item handler." );
+ $logInfo = [];
+
+ // Fields that can just be stuffed in the pageInfo object
+ $normalFields = [ 'id', 'comment', 'type', 'action', 'timestamp',
+ 'logtitle', 'params' ];
+
+ while ( $this->reader->read() ) {
+ if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
+ $this->reader->localName == 'logitem' ) {
+ break;
+ }
+
+ $tag = $this->reader->localName;
+
+ if ( !Hooks::run( 'ImportHandleLogItemXMLTag', [
+ $this, $logInfo
+ ] ) ) {
+ // Do nothing
+ } elseif ( in_array( $tag, $normalFields ) ) {
+ $logInfo[$tag] = $this->nodeContents();
+ } elseif ( $tag == 'contributor' ) {
+ $logInfo['contributor'] = $this->handleContributor();
+ } elseif ( $tag != '#text' ) {
+ $this->warn( "Unhandled log-item XML tag $tag" );
+ }
+ }
+
+ $this->processLogItem( $logInfo );
+ }
+
+ /**
+ * @param array $logInfo
+ * @return bool|mixed
+ */
+ private function processLogItem( $logInfo ) {
+ $revision = new WikiRevision( $this->config );
+
+ if ( isset( $logInfo['id'] ) ) {
+ $revision->setID( $logInfo['id'] );
+ }
+ $revision->setType( $logInfo['type'] );
+ $revision->setAction( $logInfo['action'] );
+ if ( isset( $logInfo['timestamp'] ) ) {
+ $revision->setTimestamp( $logInfo['timestamp'] );
+ }
+ if ( isset( $logInfo['params'] ) ) {
+ $revision->setParams( $logInfo['params'] );
+ }
+ if ( isset( $logInfo['logtitle'] ) ) {
+ // @todo Using Title for non-local titles is a recipe for disaster.
+ // We should use ForeignTitle here instead.
+ $revision->setTitle( Title::newFromText( $logInfo['logtitle'] ) );
+ }
+
+ $revision->setNoUpdates( $this->mNoUpdates );
+
+ if ( isset( $logInfo['comment'] ) ) {
+ $revision->setComment( $logInfo['comment'] );
+ }
+
+ if ( isset( $logInfo['contributor']['ip'] ) ) {
+ $revision->setUserIP( $logInfo['contributor']['ip'] );
+ }
+
+ if ( !isset( $logInfo['contributor']['username'] ) ) {
+ $revision->setUsername( 'Unknown user' );
+ } else {
+ $revision->setUsername( $logInfo['contributor']['username'] );
+ }
+
+ return $this->logItemCallback( $revision );
+ }
+
+ private function handlePage() {
+ // Handle page data.
+ $this->debug( "Enter page handler." );
+ $pageInfo = [ 'revisionCount' => 0, 'successfulRevisionCount' => 0 ];
+
+ // Fields that can just be stuffed in the pageInfo object
+ $normalFields = [ 'title', 'ns', 'id', 'redirect', 'restrictions' ];
+
+ $skip = false;
+ $badTitle = false;
+
+ while ( $skip ? $this->reader->next() : $this->reader->read() ) {
+ if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
+ $this->reader->localName == 'page' ) {
+ break;
+ }
+
+ $skip = false;
+
+ $tag = $this->reader->localName;
+
+ if ( $badTitle ) {
+ // The title is invalid, bail out of this page
+ $skip = true;
+ } elseif ( !Hooks::run( 'ImportHandlePageXMLTag', [ $this,
+ &$pageInfo ] ) ) {
+ // Do nothing
+ } elseif ( in_array( $tag, $normalFields ) ) {
+ // An XML snippet:
+ // <page>
+ // <id>123</id>
+ // <title>Page</title>
+ // <redirect title="NewTitle"/>
+ // ...
+ // Because the redirect tag is built differently, we need special handling for that case.
+ if ( $tag == 'redirect' ) {
+ $pageInfo[$tag] = $this->nodeAttribute( 'title' );
+ } else {
+ $pageInfo[$tag] = $this->nodeContents();
+ }
+ } elseif ( $tag == 'revision' || $tag == 'upload' ) {
+ if ( !isset( $title ) ) {
+ $title = $this->processTitle( $pageInfo['title'],
+ isset( $pageInfo['ns'] ) ? $pageInfo['ns'] : null );
+
+ // $title is either an array of two titles or false.
+ if ( is_array( $title ) ) {
+ $this->pageCallback( $title );
+ list( $pageInfo['_title'], $foreignTitle ) = $title;
+ } else {
+ $badTitle = true;
+ $skip = true;
+ }
+ }
+
+ if ( $title ) {
+ if ( $tag == 'revision' ) {
+ $this->handleRevision( $pageInfo );
+ } else {
+ $this->handleUpload( $pageInfo );
+ }
+ }
+ } elseif ( $tag != '#text' ) {
+ $this->warn( "Unhandled page XML tag $tag" );
+ $skip = true;
+ }
+ }
+
+ // @note $pageInfo is only set if a valid $title is processed above with
+ // no error. If we have a valid $title, then pageCallback is called
+ // above, $pageInfo['title'] is set and we do pageOutCallback here.
+ // If $pageInfo['_title'] is not set, then $foreignTitle is also not
+ // set since they both come from $title above.
+ if ( array_key_exists( '_title', $pageInfo ) ) {
+ $this->pageOutCallback( $pageInfo['_title'], $foreignTitle,
+ $pageInfo['revisionCount'],
+ $pageInfo['successfulRevisionCount'],
+ $pageInfo );
+ }
+ }
+
+ /**
+ * @param array $pageInfo
+ */
+ private function handleRevision( &$pageInfo ) {
+ $this->debug( "Enter revision handler" );
+ $revisionInfo = [];
+
+ $normalFields = [ 'id', 'timestamp', 'comment', 'minor', 'model', 'format', 'text', 'sha1' ];
+
+ $skip = false;
+
+ while ( $skip ? $this->reader->next() : $this->reader->read() ) {
+ if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
+ $this->reader->localName == 'revision' ) {
+ break;
+ }
+
+ $tag = $this->reader->localName;
+
+ if ( !Hooks::run( 'ImportHandleRevisionXMLTag', [
+ $this, $pageInfo, $revisionInfo
+ ] ) ) {
+ // Do nothing
+ } elseif ( in_array( $tag, $normalFields ) ) {
+ $revisionInfo[$tag] = $this->nodeContents();
+ } elseif ( $tag == 'contributor' ) {
+ $revisionInfo['contributor'] = $this->handleContributor();
+ } elseif ( $tag != '#text' ) {
+ $this->warn( "Unhandled revision XML tag $tag" );
+ $skip = true;
+ }
+ }
+
+ $pageInfo['revisionCount']++;
+ if ( $this->processRevision( $pageInfo, $revisionInfo ) ) {
+ $pageInfo['successfulRevisionCount']++;
+ }
+ }
+
+ /**
+ * @param array $pageInfo
+ * @param array $revisionInfo
+ * @return bool|mixed
+ */
+ private function processRevision( $pageInfo, $revisionInfo ) {
+ global $wgMaxArticleSize;
+
+ // Make sure revisions won't violate $wgMaxArticleSize, which could lead to
+ // database errors and instability. Testing for revisions with only listed
+ // content models, as other content models might use serialization formats
+ // which aren't checked against $wgMaxArticleSize.
+ if ( ( !isset( $revisionInfo['model'] ) ||
+ in_array( $revisionInfo['model'], [
+ 'wikitext',
+ 'css',
+ 'json',
+ 'javascript',
+ 'text',
+ ''
+ ] ) ) &&
+ strlen( $revisionInfo['text'] ) > $wgMaxArticleSize * 1024
+ ) {
+ throw new MWException( 'The text of ' .
+ ( isset( $revisionInfo['id'] ) ?
+ "the revision with ID $revisionInfo[id]" :
+ 'a revision'
+ ) . " exceeds the maximum allowable size ($wgMaxArticleSize KB)" );
+ }
+
+ $revision = new WikiRevision( $this->config );
+
+ if ( isset( $revisionInfo['id'] ) ) {
+ $revision->setID( $revisionInfo['id'] );
+ }
+ if ( isset( $revisionInfo['model'] ) ) {
+ $revision->setModel( $revisionInfo['model'] );
+ }
+ if ( isset( $revisionInfo['format'] ) ) {
+ $revision->setFormat( $revisionInfo['format'] );
+ }
+ $revision->setTitle( $pageInfo['_title'] );
+
+ if ( isset( $revisionInfo['text'] ) ) {
+ $handler = $revision->getContentHandler();
+ $text = $handler->importTransform(
+ $revisionInfo['text'],
+ $revision->getFormat() );
+
+ $revision->setText( $text );
+ }
+ if ( isset( $revisionInfo['timestamp'] ) ) {
+ $revision->setTimestamp( $revisionInfo['timestamp'] );
+ } else {
+ $revision->setTimestamp( wfTimestampNow() );
+ }
+
+ if ( isset( $revisionInfo['comment'] ) ) {
+ $revision->setComment( $revisionInfo['comment'] );
+ }
+
+ if ( isset( $revisionInfo['minor'] ) ) {
+ $revision->setMinor( true );
+ }
+ if ( isset( $revisionInfo['contributor']['ip'] ) ) {
+ $revision->setUserIP( $revisionInfo['contributor']['ip'] );
+ } elseif ( isset( $revisionInfo['contributor']['username'] ) ) {
+ $revision->setUsername( $revisionInfo['contributor']['username'] );
+ } else {
+ $revision->setUsername( 'Unknown user' );
+ }
+ if ( isset( $revisionInfo['sha1'] ) ) {
+ $revision->setSha1Base36( $revisionInfo['sha1'] );
+ }
+ $revision->setNoUpdates( $this->mNoUpdates );
+
+ return $this->revisionCallback( $revision );
+ }
+
+ /**
+ * @param array $pageInfo
+ * @return mixed
+ */
+ private function handleUpload( &$pageInfo ) {
+ $this->debug( "Enter upload handler" );
+ $uploadInfo = [];
+
+ $normalFields = [ 'timestamp', 'comment', 'filename', 'text',
+ 'src', 'size', 'sha1base36', 'archivename', 'rel' ];
+
+ $skip = false;
+
+ while ( $skip ? $this->reader->next() : $this->reader->read() ) {
+ if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
+ $this->reader->localName == 'upload' ) {
+ break;
+ }
+
+ $tag = $this->reader->localName;
+
+ if ( !Hooks::run( 'ImportHandleUploadXMLTag', [
+ $this, $pageInfo
+ ] ) ) {
+ // Do nothing
+ } elseif ( in_array( $tag, $normalFields ) ) {
+ $uploadInfo[$tag] = $this->nodeContents();
+ } elseif ( $tag == 'contributor' ) {
+ $uploadInfo['contributor'] = $this->handleContributor();
+ } elseif ( $tag == 'contents' ) {
+ $contents = $this->nodeContents();
+ $encoding = $this->reader->getAttribute( 'encoding' );
+ if ( $encoding === 'base64' ) {
+ $uploadInfo['fileSrc'] = $this->dumpTemp( base64_decode( $contents ) );
+ $uploadInfo['isTempSrc'] = true;
+ }
+ } elseif ( $tag != '#text' ) {
+ $this->warn( "Unhandled upload XML tag $tag" );
+ $skip = true;
+ }
+ }
+
+ if ( $this->mImageBasePath && isset( $uploadInfo['rel'] ) ) {
+ $path = "{$this->mImageBasePath}/{$uploadInfo['rel']}";
+ if ( file_exists( $path ) ) {
+ $uploadInfo['fileSrc'] = $path;
+ $uploadInfo['isTempSrc'] = false;
+ }
+ }
+
+ if ( $this->mImportUploads ) {
+ return $this->processUpload( $pageInfo, $uploadInfo );
+ }
+ }
+
+ /**
+ * @param string $contents
+ * @return string
+ */
+ private function dumpTemp( $contents ) {
+ $filename = tempnam( wfTempDir(), 'importupload' );
+ file_put_contents( $filename, $contents );
+ return $filename;
+ }
+
+ /**
+ * @param array $pageInfo
+ * @param array $uploadInfo
+ * @return mixed
+ */
+ private function processUpload( $pageInfo, $uploadInfo ) {
+ $revision = new WikiRevision( $this->config );
+ $text = isset( $uploadInfo['text'] ) ? $uploadInfo['text'] : '';
+
+ $revision->setTitle( $pageInfo['_title'] );
+ $revision->setID( $pageInfo['id'] );
+ $revision->setTimestamp( $uploadInfo['timestamp'] );
+ $revision->setText( $text );
+ $revision->setFilename( $uploadInfo['filename'] );
+ if ( isset( $uploadInfo['archivename'] ) ) {
+ $revision->setArchiveName( $uploadInfo['archivename'] );
+ }
+ $revision->setSrc( $uploadInfo['src'] );
+ if ( isset( $uploadInfo['fileSrc'] ) ) {
+ $revision->setFileSrc( $uploadInfo['fileSrc'],
+ !empty( $uploadInfo['isTempSrc'] ) );
+ }
+ if ( isset( $uploadInfo['sha1base36'] ) ) {
+ $revision->setSha1Base36( $uploadInfo['sha1base36'] );
+ }
+ $revision->setSize( intval( $uploadInfo['size'] ) );
+ $revision->setComment( $uploadInfo['comment'] );
+
+ if ( isset( $uploadInfo['contributor']['ip'] ) ) {
+ $revision->setUserIP( $uploadInfo['contributor']['ip'] );
+ }
+ if ( isset( $uploadInfo['contributor']['username'] ) ) {
+ $revision->setUsername( $uploadInfo['contributor']['username'] );
+ }
+ $revision->setNoUpdates( $this->mNoUpdates );
+
+ return call_user_func( $this->mUploadCallback, $revision );
+ }
+
+ /**
+ * @return array
+ */
+ private function handleContributor() {
+ $fields = [ 'id', 'ip', 'username' ];
+ $info = [];
+
+ if ( $this->reader->isEmptyElement ) {
+ return $info;
+ }
+ while ( $this->reader->read() ) {
+ if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
+ $this->reader->localName == 'contributor' ) {
+ break;
+ }
+
+ $tag = $this->reader->localName;
+
+ if ( in_array( $tag, $fields ) ) {
+ $info[$tag] = $this->nodeContents();
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * @param string $text
+ * @param string|null $ns
+ * @return array|bool
+ */
+ private function processTitle( $text, $ns = null ) {
+ if ( is_null( $this->foreignNamespaces ) ) {
+ $foreignTitleFactory = new NaiveForeignTitleFactory();
+ } else {
+ $foreignTitleFactory = new NamespaceAwareForeignTitleFactory(
+ $this->foreignNamespaces );
+ }
+
+ $foreignTitle = $foreignTitleFactory->createForeignTitle( $text,
+ intval( $ns ) );
+
+ $title = $this->importTitleFactory->createTitleFromForeignTitle(
+ $foreignTitle );
+
+ $commandLineMode = $this->config->get( 'CommandLineMode' );
+ if ( is_null( $title ) ) {
+ # Invalid page title? Ignore the page
+ $this->notice( 'import-error-invalid', $foreignTitle->getFullText() );
+ return false;
+ } elseif ( $title->isExternal() ) {
+ $this->notice( 'import-error-interwiki', $title->getPrefixedText() );
+ return false;
+ } elseif ( !$title->canExist() ) {
+ $this->notice( 'import-error-special', $title->getPrefixedText() );
+ return false;
+ } elseif ( !$title->userCan( 'edit' ) && !$commandLineMode ) {
+ # Do not import if the importing wiki user cannot edit this page
+ $this->notice( 'import-error-edit', $title->getPrefixedText() );
+ return false;
+ } elseif ( !$title->exists() && !$title->userCan( 'create' ) && !$commandLineMode ) {
+ # Do not import if the importing wiki user cannot create this page
+ $this->notice( 'import-error-create', $title->getPrefixedText() );
+ return false;
+ }
+
+ return [ $title, $foreignTitle ];
+ }
+}
diff --git a/www/wiki/includes/import/WikiRevision.php b/www/wiki/includes/import/WikiRevision.php
new file mode 100644
index 00000000..edb0c9af
--- /dev/null
+++ b/www/wiki/includes/import/WikiRevision.php
@@ -0,0 +1,842 @@
+<?php
+/**
+ * MediaWiki page data importer.
+ *
+ * Copyright © 2003,2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Represents a revision, log entry or upload during the import process.
+ * This class sticks closely to the structure of the XML dump.
+ *
+ * @since 1.2
+ *
+ * @ingroup SpecialPage
+ */
+class WikiRevision {
+
+ /**
+ * @since 1.17
+ * @deprecated in 1.29. Unused.
+ * @note Introduced in 9b3128eb2b654761f21fd4ca1d5a1a4b796dc912, unused there, unused now.
+ */
+ public $importer = null;
+
+ /**
+ * @since 1.2
+ * @var Title
+ */
+ public $title = null;
+
+ /**
+ * @since 1.6.4
+ * @var int
+ */
+ public $id = 0;
+
+ /**
+ * @since 1.2
+ * @var string
+ */
+ public $timestamp = "20010115000000";
+
+ /**
+ * @since 1.2
+ * @var int
+ * @deprecated in 1.29. Unused.
+ * @note Introduced in 436a028086fb3f01c4605c5ad2964d56f9306aca, unused there, unused now.
+ */
+ public $user = 0;
+
+ /**
+ * @since 1.2
+ * @var string
+ */
+ public $user_text = "";
+
+ /**
+ * @since 1.27
+ * @var User
+ */
+ public $userObj = null;
+
+ /**
+ * @since 1.21
+ * @var string
+ */
+ public $model = null;
+
+ /**
+ * @since 1.21
+ * @var string
+ */
+ public $format = null;
+
+ /**
+ * @since 1.2
+ * @var string
+ */
+ public $text = "";
+
+ /**
+ * @since 1.12.2
+ * @var int
+ */
+ protected $size;
+
+ /**
+ * @since 1.21
+ * @var Content
+ */
+ public $content = null;
+
+ /**
+ * @since 1.24
+ * @var ContentHandler
+ */
+ protected $contentHandler = null;
+
+ /**
+ * @since 1.2.6
+ * @var string
+ */
+ public $comment = "";
+
+ /**
+ * @since 1.5.7
+ * @var bool
+ */
+ public $minor = false;
+
+ /**
+ * @since 1.12.2
+ * @var string
+ */
+ public $type = "";
+
+ /**
+ * @since 1.12.2
+ * @var string
+ */
+ public $action = "";
+
+ /**
+ * @since 1.12.2
+ * @var string
+ */
+ public $params = "";
+
+ /**
+ * @since 1.17
+ * @var string
+ */
+ public $fileSrc = '';
+
+ /**
+ * @since 1.17
+ * @var bool|string
+ */
+ public $sha1base36 = false;
+
+ /**
+ * @since 1.17
+ * @var string
+ */
+ public $archiveName = '';
+
+ /**
+ * @since 1.12.2
+ */
+ protected $filename;
+
+ /**
+ * @since 1.12.2
+ * @var mixed
+ */
+ protected $src;
+
+ /**
+ * @since 1.18
+ * @var bool
+ * @todo Unused?
+ */
+ public $isTemp = false;
+
+ /**
+ * @since 1.18
+ * @deprecated 1.29 use Wikirevision::isTempSrc()
+ * First written to in 43d5d3b682cc1733ad01a837d11af4a402d57e6a
+ * Actually introduced in 52cd34acf590e5be946b7885ffdc13a157c1c6cf
+ */
+ public $fileIsTemp;
+
+ /** @var bool */
+ private $mNoUpdates = false;
+
+ /** @var Config $config */
+ private $config;
+
+ public function __construct( Config $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * @since 1.7 taking a Title object (string before)
+ * @param Title $title
+ * @throws MWException
+ */
+ public function setTitle( $title ) {
+ if ( is_object( $title ) ) {
+ $this->title = $title;
+ } elseif ( is_null( $title ) ) {
+ throw new MWException( "WikiRevision given a null title in import. "
+ . "You may need to adjust \$wgLegalTitleChars." );
+ } else {
+ throw new MWException( "WikiRevision given non-object title in import." );
+ }
+ }
+
+ /**
+ * @since 1.6.4
+ * @param int $id
+ */
+ public function setID( $id ) {
+ $this->id = $id;
+ }
+
+ /**
+ * @since 1.2
+ * @param string $ts
+ */
+ public function setTimestamp( $ts ) {
+ # 2003-08-05T18:30:02Z
+ $this->timestamp = wfTimestamp( TS_MW, $ts );
+ }
+
+ /**
+ * @since 1.2
+ * @param string $user
+ */
+ public function setUsername( $user ) {
+ $this->user_text = $user;
+ }
+
+ /**
+ * @since 1.27
+ * @param User $user
+ */
+ public function setUserObj( $user ) {
+ $this->userObj = $user;
+ }
+
+ /**
+ * @since 1.2
+ * @param string $ip
+ */
+ public function setUserIP( $ip ) {
+ $this->user_text = $ip;
+ }
+
+ /**
+ * @since 1.21
+ * @param string $model
+ */
+ public function setModel( $model ) {
+ $this->model = $model;
+ }
+
+ /**
+ * @since 1.21
+ * @param string $format
+ */
+ public function setFormat( $format ) {
+ $this->format = $format;
+ }
+
+ /**
+ * @since 1.2
+ * @param string $text
+ */
+ public function setText( $text ) {
+ $this->text = $text;
+ }
+
+ /**
+ * @since 1.2.6
+ * @param string $text
+ */
+ public function setComment( $text ) {
+ $this->comment = $text;
+ }
+
+ /**
+ * @since 1.5.7
+ * @param bool $minor
+ */
+ public function setMinor( $minor ) {
+ $this->minor = (bool)$minor;
+ }
+
+ /**
+ * @since 1.12.2
+ * @param mixed $src
+ */
+ public function setSrc( $src ) {
+ $this->src = $src;
+ }
+
+ /**
+ * @since 1.17
+ * @param string $src
+ * @param bool $isTemp
+ */
+ public function setFileSrc( $src, $isTemp ) {
+ $this->fileSrc = $src;
+ $this->fileIsTemp = $isTemp;
+ $this->isTemp = $isTemp;
+ }
+
+ /**
+ * @since 1.17
+ * @param string $sha1base36
+ */
+ public function setSha1Base36( $sha1base36 ) {
+ $this->sha1base36 = $sha1base36;
+ }
+
+ /**
+ * @since 1.12.2
+ * @param string $filename
+ */
+ public function setFilename( $filename ) {
+ $this->filename = $filename;
+ }
+
+ /**
+ * @since 1.17
+ * @param string $archiveName
+ */
+ public function setArchiveName( $archiveName ) {
+ $this->archiveName = $archiveName;
+ }
+
+ /**
+ * @since 1.12.2
+ * @param int $size
+ */
+ public function setSize( $size ) {
+ $this->size = intval( $size );
+ }
+
+ /**
+ * @since 1.12.2
+ * @param string $type
+ */
+ public function setType( $type ) {
+ $this->type = $type;
+ }
+
+ /**
+ * @since 1.12.2
+ * @param string $action
+ */
+ public function setAction( $action ) {
+ $this->action = $action;
+ }
+
+ /**
+ * @since 1.12.2
+ * @param array $params
+ */
+ public function setParams( $params ) {
+ $this->params = $params;
+ }
+
+ /**
+ * @since 1.18
+ * @param bool $noupdates
+ */
+ public function setNoUpdates( $noupdates ) {
+ $this->mNoUpdates = $noupdates;
+ }
+
+ /**
+ * @since 1.2
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * @since 1.6.4
+ * @return int
+ */
+ public function getID() {
+ return $this->id;
+ }
+
+ /**
+ * @since 1.2
+ * @return string
+ */
+ public function getTimestamp() {
+ return $this->timestamp;
+ }
+
+ /**
+ * @since 1.2
+ * @return string
+ */
+ public function getUser() {
+ return $this->user_text;
+ }
+
+ /**
+ * @since 1.27
+ * @return User
+ */
+ public function getUserObj() {
+ return $this->userObj;
+ }
+
+ /**
+ * @since 1.2
+ * @return string
+ */
+ public function getText() {
+ return $this->text;
+ }
+
+ /**
+ * @since 1.24
+ * @return ContentHandler
+ */
+ public function getContentHandler() {
+ if ( is_null( $this->contentHandler ) ) {
+ $this->contentHandler = ContentHandler::getForModelID( $this->getModel() );
+ }
+
+ return $this->contentHandler;
+ }
+
+ /**
+ * @since 1.21
+ * @return Content
+ */
+ public function getContent() {
+ if ( is_null( $this->content ) ) {
+ $handler = $this->getContentHandler();
+ $this->content = $handler->unserializeContent( $this->text, $this->getFormat() );
+ }
+
+ return $this->content;
+ }
+
+ /**
+ * @since 1.21
+ * @return string
+ */
+ public function getModel() {
+ if ( is_null( $this->model ) ) {
+ $this->model = $this->getTitle()->getContentModel();
+ }
+
+ return $this->model;
+ }
+
+ /**
+ * @since 1.21
+ * @return string
+ */
+ public function getFormat() {
+ if ( is_null( $this->format ) ) {
+ $this->format = $this->getContentHandler()->getDefaultFormat();
+ }
+
+ return $this->format;
+ }
+
+ /**
+ * @since 1.2.6
+ * @return string
+ */
+ public function getComment() {
+ return $this->comment;
+ }
+
+ /**
+ * @since 1.5.7
+ * @return bool
+ */
+ public function getMinor() {
+ return $this->minor;
+ }
+
+ /**
+ * @since 1.12.2
+ * @return mixed
+ */
+ public function getSrc() {
+ return $this->src;
+ }
+
+ /**
+ * @since 1.17
+ * @return bool|string
+ */
+ public function getSha1() {
+ if ( $this->sha1base36 ) {
+ return Wikimedia\base_convert( $this->sha1base36, 36, 16 );
+ }
+ return false;
+ }
+
+ /**
+ * @since 1.17
+ * @return string
+ */
+ public function getFileSrc() {
+ return $this->fileSrc;
+ }
+
+ /**
+ * @since 1.17
+ * @return bool
+ */
+ public function isTempSrc() {
+ return $this->isTemp;
+ }
+
+ /**
+ * @since 1.12.2
+ * @return mixed
+ */
+ public function getFilename() {
+ return $this->filename;
+ }
+
+ /**
+ * @since 1.17
+ * @return string
+ */
+ public function getArchiveName() {
+ return $this->archiveName;
+ }
+
+ /**
+ * @since 1.12.2
+ * @return mixed
+ */
+ public function getSize() {
+ return $this->size;
+ }
+
+ /**
+ * @since 1.12.2
+ * @return string
+ */
+ public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * @since 1.12.2
+ * @return string
+ */
+ public function getAction() {
+ return $this->action;
+ }
+
+ /**
+ * @since 1.12.2
+ * @return string
+ */
+ public function getParams() {
+ return $this->params;
+ }
+
+ /**
+ * @since 1.4.1
+ * @return bool
+ */
+ public function importOldRevision() {
+ $dbw = wfGetDB( DB_MASTER );
+
+ # Sneak a single revision into place
+ $user = $this->getUserObj() ?: User::newFromName( $this->getUser() );
+ if ( $user ) {
+ $userId = intval( $user->getId() );
+ $userText = $user->getName();
+ } else {
+ $userId = 0;
+ $userText = $this->getUser();
+ $user = new User;
+ }
+
+ // avoid memory leak...?
+ Title::clearCaches();
+
+ $page = WikiPage::factory( $this->title );
+ $page->loadPageData( 'fromdbmaster' );
+ if ( !$page->exists() ) {
+ // must create the page...
+ $pageId = $page->insertOn( $dbw );
+ $created = true;
+ $oldcountable = null;
+ } else {
+ $pageId = $page->getId();
+ $created = false;
+
+ // Note: sha1 has been in XML dumps since 2012. If you have an
+ // older dump, the duplicate detection here won't work.
+ $prior = $dbw->selectField( 'revision', '1',
+ [ 'rev_page' => $pageId,
+ 'rev_timestamp' => $dbw->timestamp( $this->timestamp ),
+ 'rev_sha1' => $this->sha1base36 ],
+ __METHOD__
+ );
+ if ( $prior ) {
+ // @todo FIXME: This could fail slightly for multiple matches :P
+ wfDebug( __METHOD__ . ": skipping existing revision for [[" .
+ $this->title->getPrefixedText() . "]], timestamp " . $this->timestamp . "\n" );
+ return false;
+ }
+ }
+
+ if ( !$pageId ) {
+ // This seems to happen if two clients simultaneously try to import the
+ // same page
+ wfDebug( __METHOD__ . ': got invalid $pageId when importing revision of [[' .
+ $this->title->getPrefixedText() . ']], timestamp ' . $this->timestamp . "\n" );
+ return false;
+ }
+
+ // Select previous version to make size diffs correct
+ // @todo This assumes that multiple revisions of the same page are imported
+ // in order from oldest to newest.
+ $prevId = $dbw->selectField( 'revision', 'rev_id',
+ [
+ 'rev_page' => $pageId,
+ 'rev_timestamp <= ' . $dbw->addQuotes( $dbw->timestamp( $this->timestamp ) ),
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => [
+ 'rev_timestamp DESC',
+ 'rev_id DESC', // timestamp is not unique per page
+ ]
+ ]
+ );
+
+ # @todo FIXME: Use original rev_id optionally (better for backups)
+ # Insert the row
+ $revision = new Revision( [
+ 'title' => $this->title,
+ 'page' => $pageId,
+ 'content_model' => $this->getModel(),
+ 'content_format' => $this->getFormat(),
+ // XXX: just set 'content' => $this->getContent()?
+ 'text' => $this->getContent()->serialize( $this->getFormat() ),
+ 'comment' => $this->getComment(),
+ 'user' => $userId,
+ 'user_text' => $userText,
+ 'timestamp' => $this->timestamp,
+ 'minor_edit' => $this->minor,
+ 'parent_id' => $prevId,
+ ] );
+ $revision->insertOn( $dbw );
+ $changed = $page->updateIfNewerOn( $dbw, $revision );
+
+ if ( $changed !== false && !$this->mNoUpdates ) {
+ wfDebug( __METHOD__ . ": running updates\n" );
+ // countable/oldcountable stuff is handled in WikiImporter::finishImportPage
+ $page->doEditUpdates(
+ $revision,
+ $user,
+ [ 'created' => $created, 'oldcountable' => 'no-change' ]
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * @since 1.12.2
+ * @return bool
+ */
+ public function importLogItem() {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $user = $this->getUserObj() ?: User::newFromName( $this->getUser() );
+ if ( $user ) {
+ $userId = intval( $user->getId() );
+ $userText = $user->getName();
+ } else {
+ $userId = 0;
+ $userText = $this->getUser();
+ }
+
+ # @todo FIXME: This will not record autoblocks
+ if ( !$this->getTitle() ) {
+ wfDebug( __METHOD__ . ": skipping invalid {$this->type}/{$this->action} log time, timestamp " .
+ $this->timestamp . "\n" );
+ return false;
+ }
+ # Check if it exists already
+ // @todo FIXME: Use original log ID (better for backups)
+ $prior = $dbw->selectField( 'logging', '1',
+ [ 'log_type' => $this->getType(),
+ 'log_action' => $this->getAction(),
+ 'log_timestamp' => $dbw->timestamp( $this->timestamp ),
+ 'log_namespace' => $this->getTitle()->getNamespace(),
+ 'log_title' => $this->getTitle()->getDBkey(),
+ # 'log_user_text' => $this->user_text,
+ 'log_params' => $this->params ],
+ __METHOD__
+ );
+ // @todo FIXME: This could fail slightly for multiple matches :P
+ if ( $prior ) {
+ wfDebug( __METHOD__
+ . ": skipping existing item for Log:{$this->type}/{$this->action}, timestamp "
+ . $this->timestamp . "\n" );
+ return false;
+ }
+ $data = [
+ 'log_type' => $this->type,
+ 'log_action' => $this->action,
+ 'log_timestamp' => $dbw->timestamp( $this->timestamp ),
+ 'log_user' => $userId,
+ 'log_user_text' => $userText,
+ 'log_namespace' => $this->getTitle()->getNamespace(),
+ 'log_title' => $this->getTitle()->getDBkey(),
+ 'log_params' => $this->params
+ ] + CommentStore::newKey( 'log_comment' )->insert( $dbw, $this->getComment() );
+ $dbw->insert( 'logging', $data, __METHOD__ );
+
+ return true;
+ }
+
+ /**
+ * @since 1.12.2
+ * @return bool
+ */
+ public function importUpload() {
+ # Construct a file
+ $archiveName = $this->getArchiveName();
+ if ( $archiveName ) {
+ wfDebug( __METHOD__ . "Importing archived file as $archiveName\n" );
+ $file = OldLocalFile::newFromArchiveName( $this->getTitle(),
+ RepoGroup::singleton()->getLocalRepo(), $archiveName );
+ } else {
+ $file = wfLocalFile( $this->getTitle() );
+ $file->load( File::READ_LATEST );
+ wfDebug( __METHOD__ . 'Importing new file as ' . $file->getName() . "\n" );
+ if ( $file->exists() && $file->getTimestamp() > $this->getTimestamp() ) {
+ $archiveName = $file->getTimestamp() . '!' . $file->getName();
+ $file = OldLocalFile::newFromArchiveName( $this->getTitle(),
+ RepoGroup::singleton()->getLocalRepo(), $archiveName );
+ wfDebug( __METHOD__ . "File already exists; importing as $archiveName\n" );
+ }
+ }
+ if ( !$file ) {
+ wfDebug( __METHOD__ . ': Bad file for ' . $this->getTitle() . "\n" );
+ return false;
+ }
+
+ # Get the file source or download if necessary
+ $source = $this->getFileSrc();
+ $autoDeleteSource = $this->isTempSrc();
+ if ( !strlen( $source ) ) {
+ $source = $this->downloadSource();
+ $autoDeleteSource = true;
+ }
+ if ( !strlen( $source ) ) {
+ wfDebug( __METHOD__ . ": Could not fetch remote file.\n" );
+ return false;
+ }
+
+ $tmpFile = new TempFSFile( $source );
+ if ( $autoDeleteSource ) {
+ $tmpFile->autocollect();
+ }
+
+ $sha1File = ltrim( sha1_file( $source ), '0' );
+ $sha1 = $this->getSha1();
+ if ( $sha1 && ( $sha1 !== $sha1File ) ) {
+ wfDebug( __METHOD__ . ": Corrupt file $source.\n" );
+ return false;
+ }
+
+ $user = $this->getUserObj() ?: User::newFromName( $this->getUser() );
+
+ # Do the actual upload
+ if ( $archiveName ) {
+ $status = $file->uploadOld( $source, $archiveName,
+ $this->getTimestamp(), $this->getComment(), $user );
+ } else {
+ $flags = 0;
+ $status = $file->upload( $source, $this->getComment(), $this->getComment(),
+ $flags, false, $this->getTimestamp(), $user );
+ }
+
+ if ( $status->isGood() ) {
+ wfDebug( __METHOD__ . ": Successful\n" );
+ return true;
+ } else {
+ wfDebug( __METHOD__ . ': failed: ' . $status->getHTML() . "\n" );
+ return false;
+ }
+ }
+
+ /**
+ * @since 1.12.2
+ * @return bool|string
+ */
+ public function downloadSource() {
+ if ( !$this->config->get( 'EnableUploads' ) ) {
+ return false;
+ }
+
+ $tempo = tempnam( wfTempDir(), 'download' );
+ $f = fopen( $tempo, 'wb' );
+ if ( !$f ) {
+ wfDebug( "IMPORT: couldn't write to temp file $tempo\n" );
+ return false;
+ }
+
+ // @todo FIXME!
+ $src = $this->getSrc();
+ $data = Http::get( $src, [], __METHOD__ );
+ if ( !$data ) {
+ wfDebug( "IMPORT: couldn't fetch source $src\n" );
+ fclose( $f );
+ unlink( $tempo );
+ return false;
+ }
+
+ fwrite( $f, $data );
+ fclose( $f );
+
+ return $tempo;
+ }
+
+}
diff --git a/www/wiki/includes/installer/CliInstaller.php b/www/wiki/includes/installer/CliInstaller.php
new file mode 100644
index 00000000..715f5dff
--- /dev/null
+++ b/www/wiki/includes/installer/CliInstaller.php
@@ -0,0 +1,228 @@
+<?php
+/**
+ * Core installer command line interface.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+/**
+ * Class for the core installer command line interface.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class CliInstaller extends Installer {
+ private $specifiedScriptPath = false;
+
+ private $optionMap = [
+ 'dbtype' => 'wgDBtype',
+ 'dbserver' => 'wgDBserver',
+ 'dbname' => 'wgDBname',
+ 'dbuser' => 'wgDBuser',
+ 'dbpass' => 'wgDBpassword',
+ 'dbprefix' => 'wgDBprefix',
+ 'dbtableoptions' => 'wgDBTableOptions',
+ 'dbmysql5' => 'wgDBmysql5',
+ 'dbport' => 'wgDBport',
+ 'dbschema' => 'wgDBmwschema',
+ 'dbpath' => 'wgSQLiteDataDir',
+ 'server' => 'wgServer',
+ 'scriptpath' => 'wgScriptPath',
+ ];
+
+ /**
+ * @param string $siteName
+ * @param string $admin
+ * @param array $option
+ */
+ function __construct( $siteName, $admin = null, array $option = [] ) {
+ global $wgContLang;
+
+ parent::__construct();
+
+ if ( isset( $option['scriptpath'] ) ) {
+ $this->specifiedScriptPath = true;
+ }
+
+ foreach ( $this->optionMap as $opt => $global ) {
+ if ( isset( $option[$opt] ) ) {
+ $GLOBALS[$global] = $option[$opt];
+ $this->setVar( $global, $option[$opt] );
+ }
+ }
+
+ if ( isset( $option['lang'] ) ) {
+ global $wgLang, $wgLanguageCode;
+ $this->setVar( '_UserLang', $option['lang'] );
+ $wgContLang = Language::factory( $option['lang'] );
+ $wgLang = Language::factory( $option['lang'] );
+ $wgLanguageCode = $option['lang'];
+ RequestContext::getMain()->setLanguage( $wgLang );
+ }
+
+ $this->setVar( 'wgSitename', $siteName );
+
+ $metaNS = $wgContLang->ucfirst( str_replace( ' ', '_', $siteName ) );
+ if ( $metaNS == 'MediaWiki' ) {
+ $metaNS = 'Project';
+ }
+ $this->setVar( 'wgMetaNamespace', $metaNS );
+
+ if ( $admin ) {
+ $this->setVar( '_AdminName', $admin );
+ }
+
+ if ( !isset( $option['installdbuser'] ) ) {
+ $this->setVar( '_InstallUser',
+ $this->getVar( 'wgDBuser' ) );
+ $this->setVar( '_InstallPassword',
+ $this->getVar( 'wgDBpassword' ) );
+ } else {
+ $this->setVar( '_InstallUser',
+ $option['installdbuser'] );
+ $this->setVar( '_InstallPassword',
+ isset( $option['installdbpass'] ) ? $option['installdbpass'] : "" );
+
+ // Assume that if we're given the installer user, we'll create the account.
+ $this->setVar( '_CreateDBAccount', true );
+ }
+
+ if ( isset( $option['pass'] ) ) {
+ $this->setVar( '_AdminPassword', $option['pass'] );
+ }
+
+ // Detect and inject any extension found
+ if ( isset( $option['with-extensions'] ) ) {
+ $this->setVar( '_Extensions', array_keys( $this->findExtensions() ) );
+ }
+
+ // Set up the default skins
+ $skins = array_keys( $this->findExtensions( 'skins' ) );
+ $this->setVar( '_Skins', $skins );
+
+ if ( $skins ) {
+ $skinNames = array_map( 'strtolower', $skins );
+ $this->setVar( 'wgDefaultSkin', $this->getDefaultSkin( $skinNames ) );
+ }
+ }
+
+ /**
+ * Main entry point.
+ */
+ public function execute() {
+ $vars = Installer::getExistingLocalSettings();
+ if ( $vars ) {
+ $this->showStatusMessage(
+ Status::newFatal( "config-localsettings-cli-upgrade" )
+ );
+ }
+
+ $this->performInstallation(
+ [ $this, 'startStage' ],
+ [ $this, 'endStage' ]
+ );
+ }
+
+ /**
+ * Write LocalSettings.php to a given path
+ *
+ * @param string $path Full path to write LocalSettings.php to
+ */
+ public function writeConfigurationFile( $path ) {
+ $ls = InstallerOverrides::getLocalSettingsGenerator( $this );
+ $ls->writeFile( "$path/LocalSettings.php" );
+ }
+
+ public function startStage( $step ) {
+ // Messages: config-install-database, config-install-tables, config-install-interwiki,
+ // config-install-stats, config-install-keys, config-install-sysop, config-install-mainpage,
+ // config-install-extensions
+ $this->showMessage( "config-install-$step" );
+ }
+
+ public function endStage( $step, $status ) {
+ $this->showStatusMessage( $status );
+ $this->showMessage( 'config-install-step-done' );
+ }
+
+ public function showMessage( $msg /*, ... */ ) {
+ echo $this->getMessageText( func_get_args() ) . "\n";
+ flush();
+ }
+
+ public function showError( $msg /*, ... */ ) {
+ echo "***{$this->getMessageText( func_get_args() )}***\n";
+ flush();
+ }
+
+ /**
+ * @param array $params
+ *
+ * @return string
+ */
+ protected function getMessageText( $params ) {
+ $msg = array_shift( $params );
+
+ $text = wfMessage( $msg, $params )->parse();
+
+ $text = preg_replace( '/<a href="(.*?)".*?>(.*?)<\/a>/', '$2 &lt;$1&gt;', $text );
+
+ return Sanitizer::stripAllTags( $text );
+ }
+
+ /**
+ * Dummy
+ */
+ public function showHelpBox( $msg /*, ... */ ) {
+ }
+
+ public function showStatusMessage( Status $status ) {
+ $warnings = array_merge( $status->getWarningsArray(),
+ $status->getErrorsArray() );
+
+ if ( count( $warnings ) !== 0 ) {
+ foreach ( $warnings as $w ) {
+ call_user_func_array( [ $this, 'showMessage' ], $w );
+ }
+ }
+
+ if ( !$status->isOK() ) {
+ echo "\n";
+ exit( 1 );
+ }
+ }
+
+ public function envCheckPath() {
+ if ( !$this->specifiedScriptPath ) {
+ $this->showMessage( 'config-no-cli-uri', $this->getVar( "wgScriptPath" ) );
+ }
+
+ return parent::envCheckPath();
+ }
+
+ protected function envGetDefaultServer() {
+ return null; // Do not guess if installing from CLI
+ }
+
+ public function dirIsExecutable( $dir, $url ) {
+ $this->showMessage( 'config-no-cli-uploads-check', $dir );
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/installer/DatabaseInstaller.php b/www/wiki/includes/installer/DatabaseInstaller.php
new file mode 100644
index 00000000..925d991d
--- /dev/null
+++ b/www/wiki/includes/installer/DatabaseInstaller.php
@@ -0,0 +1,760 @@
+<?php
+/**
+ * DBMS-specific installation helper.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+use Wikimedia\Rdbms\LBFactorySingle;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Base class for DBMS-specific installation helper classes.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+abstract class DatabaseInstaller {
+
+ /**
+ * The Installer object.
+ *
+ * @todo Naming this parent is confusing, 'installer' would be clearer.
+ *
+ * @var WebInstaller
+ */
+ public $parent;
+
+ /**
+ * @var string Set by subclasses
+ */
+ public static $minimumVersion;
+
+ /**
+ * @var string Set by subclasses
+ */
+ protected static $notMiniumumVerisonMessage;
+
+ /**
+ * The database connection.
+ *
+ * @var Database
+ */
+ public $db = null;
+
+ /**
+ * Internal variables for installation.
+ *
+ * @var array
+ */
+ protected $internalDefaults = [];
+
+ /**
+ * Array of MW configuration globals this class uses.
+ *
+ * @var array
+ */
+ protected $globalNames = [];
+
+ /**
+ * Whether the provided version meets the necessary requirements for this type
+ *
+ * @param string $serverVersion Output of Database::getServerVersion()
+ * @return Status
+ * @since 1.30
+ */
+ public static function meetsMinimumRequirement( $serverVersion ) {
+ if ( version_compare( $serverVersion, static::$minimumVersion ) < 0 ) {
+ return Status::newFatal(
+ static::$notMiniumumVerisonMessage, static::$minimumVersion, $serverVersion
+ );
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Return the internal name, e.g. 'mysql', or 'sqlite'.
+ */
+ abstract public function getName();
+
+ /**
+ * @return bool Returns true if the client library is compiled in.
+ */
+ abstract public function isCompiled();
+
+ /**
+ * Checks for installation prerequisites other than those checked by isCompiled()
+ * @since 1.19
+ * @return Status
+ */
+ public function checkPrerequisites() {
+ return Status::newGood();
+ }
+
+ /**
+ * Get HTML for a web form that configures this database. Configuration
+ * at this time should be the minimum needed to connect and test
+ * whether install or upgrade is required.
+ *
+ * If this is called, $this->parent can be assumed to be a WebInstaller.
+ */
+ abstract public function getConnectForm();
+
+ /**
+ * Set variables based on the request array, assuming it was submitted
+ * via the form returned by getConnectForm(). Validate the connection
+ * settings by attempting to connect with them.
+ *
+ * If this is called, $this->parent can be assumed to be a WebInstaller.
+ *
+ * @return Status
+ */
+ abstract public function submitConnectForm();
+
+ /**
+ * Get HTML for a web form that retrieves settings used for installation.
+ * $this->parent can be assumed to be a WebInstaller.
+ * If the DB type has no settings beyond those already configured with
+ * getConnectForm(), this should return false.
+ * @return bool
+ */
+ public function getSettingsForm() {
+ return false;
+ }
+
+ /**
+ * Set variables based on the request array, assuming it was submitted via
+ * the form return by getSettingsForm().
+ *
+ * @return Status
+ */
+ public function submitSettingsForm() {
+ return Status::newGood();
+ }
+
+ /**
+ * Open a connection to the database using the administrative user/password
+ * currently defined in the session, without any caching. Returns a status
+ * object. On success, the status object will contain a Database object in
+ * its value member.
+ *
+ * @return Status
+ */
+ abstract public function openConnection();
+
+ /**
+ * Create the database and return a Status object indicating success or
+ * failure.
+ *
+ * @return Status
+ */
+ abstract public function setupDatabase();
+
+ /**
+ * Connect to the database using the administrative user/password currently
+ * defined in the session. Returns a status object. On success, the status
+ * object will contain a Database object in its value member.
+ *
+ * This will return a cached connection if one is available.
+ *
+ * @return Status
+ */
+ public function getConnection() {
+ if ( $this->db ) {
+ return Status::newGood( $this->db );
+ }
+
+ $status = $this->openConnection();
+ if ( $status->isOK() ) {
+ $this->db = $status->value;
+ // Enable autocommit
+ $this->db->clearFlag( DBO_TRX );
+ $this->db->commit( __METHOD__ );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Apply a SQL source file to the database as part of running an installation step.
+ *
+ * @param string $sourceFileMethod
+ * @param string $stepName
+ * @param bool $archiveTableMustNotExist
+ * @return Status
+ */
+ private function stepApplySourceFile(
+ $sourceFileMethod,
+ $stepName,
+ $archiveTableMustNotExist = false
+ ) {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $this->db->selectDB( $this->getVar( 'wgDBname' ) );
+
+ if ( $archiveTableMustNotExist && $this->db->tableExists( 'archive', __METHOD__ ) ) {
+ $status->warning( "config-$stepName-tables-exist" );
+ $this->enableLB();
+
+ return $status;
+ }
+
+ $this->db->setFlag( DBO_DDLMODE ); // For Oracle's handling of schema files
+ $this->db->begin( __METHOD__ );
+
+ $error = $this->db->sourceFile(
+ call_user_func( [ $this, $sourceFileMethod ], $this->db )
+ );
+ if ( $error !== true ) {
+ $this->db->reportQueryError( $error, 0, '', __METHOD__ );
+ $this->db->rollback( __METHOD__ );
+ $status->fatal( "config-$stepName-tables-failed", $error );
+ } else {
+ $this->db->commit( __METHOD__ );
+ }
+ // Resume normal operations
+ if ( $status->isOK() ) {
+ $this->enableLB();
+ }
+
+ return $status;
+ }
+
+ /**
+ * Create database tables from scratch.
+ *
+ * @return Status
+ */
+ public function createTables() {
+ return $this->stepApplySourceFile( 'getSchemaPath', 'install', true );
+ }
+
+ /**
+ * Insert update keys into table to prevent running unneded updates.
+ *
+ * @return Status
+ */
+ public function insertUpdateKeys() {
+ return $this->stepApplySourceFile( 'getUpdateKeysPath', 'updates', false );
+ }
+
+ /**
+ * Return a path to the DBMS-specific SQL file if it exists,
+ * otherwise default SQL file
+ *
+ * @param IDatabase $db
+ * @param string $filename
+ * @return string
+ */
+ private function getSqlFilePath( $db, $filename ) {
+ global $IP;
+
+ $dbmsSpecificFilePath = "$IP/maintenance/" . $db->getType() . "/$filename";
+ if ( file_exists( $dbmsSpecificFilePath ) ) {
+ return $dbmsSpecificFilePath;
+ } else {
+ return "$IP/maintenance/$filename";
+ }
+ }
+
+ /**
+ * Return a path to the DBMS-specific schema file,
+ * otherwise default to tables.sql
+ *
+ * @param IDatabase $db
+ * @return string
+ */
+ public function getSchemaPath( $db ) {
+ return $this->getSqlFilePath( $db, 'tables.sql' );
+ }
+
+ /**
+ * Return a path to the DBMS-specific update key file,
+ * otherwise default to update-keys.sql
+ *
+ * @param IDatabase $db
+ * @return string
+ */
+ public function getUpdateKeysPath( $db ) {
+ return $this->getSqlFilePath( $db, 'update-keys.sql' );
+ }
+
+ /**
+ * Create the tables for each extension the user enabled
+ * @return Status
+ */
+ public function createExtensionTables() {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // Now run updates to create tables for old extensions
+ DatabaseUpdater::newForDB( $this->db )->doUpdates( [ 'extensions' ] );
+
+ return $status;
+ }
+
+ /**
+ * Get the DBMS-specific options for LocalSettings.php generation.
+ *
+ * @return string
+ */
+ abstract public function getLocalSettings();
+
+ /**
+ * Override this to provide DBMS-specific schema variables, to be
+ * substituted into tables.sql and other schema files.
+ * @return array
+ */
+ public function getSchemaVars() {
+ return [];
+ }
+
+ /**
+ * Set appropriate schema variables in the current database connection.
+ *
+ * This should be called after any request data has been imported, but before
+ * any write operations to the database.
+ */
+ public function setupSchemaVars() {
+ $status = $this->getConnection();
+ if ( $status->isOK() ) {
+ $status->value->setSchemaVars( $this->getSchemaVars() );
+ } else {
+ $msg = __METHOD__ . ': unexpected error while establishing'
+ . ' a database connection with message: '
+ . $status->getMessage()->plain();
+ throw new MWException( $msg );
+ }
+ }
+
+ /**
+ * Set up LBFactory so that wfGetDB() etc. works.
+ * We set up a special LBFactory instance which returns the current
+ * installer connection.
+ */
+ public function enableLB() {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ throw new MWException( __METHOD__ . ': unexpected DB connection error' );
+ }
+
+ \MediaWiki\MediaWikiServices::resetGlobalInstance();
+ $services = \MediaWiki\MediaWikiServices::getInstance();
+
+ $connection = $status->value;
+ $services->redefineService( 'DBLoadBalancerFactory', function () use ( $connection ) {
+ return LBFactorySingle::newFromConnection( $connection );
+ } );
+ }
+
+ /**
+ * Perform database upgrades
+ *
+ * @return bool
+ */
+ public function doUpgrade() {
+ $this->setupSchemaVars();
+ $this->enableLB();
+
+ $ret = true;
+ ob_start( [ $this, 'outputHandler' ] );
+ $up = DatabaseUpdater::newForDB( $this->db );
+ try {
+ $up->doUpdates();
+ } catch ( MWException $e ) {
+ echo "\nAn error occurred:\n";
+ echo $e->getText();
+ $ret = false;
+ } catch ( Exception $e ) {
+ echo "\nAn error occurred:\n";
+ echo $e->getMessage();
+ $ret = false;
+ }
+ $up->purgeCache();
+ ob_end_flush();
+
+ return $ret;
+ }
+
+ /**
+ * Allow DB installers a chance to make last-minute changes before installation
+ * occurs. This happens before setupDatabase() or createTables() is called, but
+ * long after the constructor. Helpful for things like modifying setup steps :)
+ */
+ public function preInstall() {
+ }
+
+ /**
+ * Allow DB installers a chance to make checks before upgrade.
+ */
+ public function preUpgrade() {
+ }
+
+ /**
+ * Get an array of MW configuration globals that will be configured by this class.
+ * @return array
+ */
+ public function getGlobalNames() {
+ return $this->globalNames;
+ }
+
+ /**
+ * Construct and initialise parent.
+ * This is typically only called from Installer::getDBInstaller()
+ * @param WebInstaller $parent
+ */
+ public function __construct( $parent ) {
+ $this->parent = $parent;
+ }
+
+ /**
+ * Convenience function.
+ * Check if a named extension is present.
+ *
+ * @param string $name
+ * @return bool
+ */
+ protected static function checkExtension( $name ) {
+ return extension_loaded( $name );
+ }
+
+ /**
+ * Get the internationalised name for this DBMS.
+ * @return string
+ */
+ public function getReadableName() {
+ // Messages: config-type-mysql, config-type-postgres, config-type-sqlite,
+ // config-type-oracle
+ return wfMessage( 'config-type-' . $this->getName() )->text();
+ }
+
+ /**
+ * Get a name=>value map of MW configuration globals for the default values.
+ * @return array
+ */
+ public function getGlobalDefaults() {
+ $defaults = [];
+ foreach ( $this->getGlobalNames() as $var ) {
+ if ( isset( $GLOBALS[$var] ) ) {
+ $defaults[$var] = $GLOBALS[$var];
+ }
+ }
+ return $defaults;
+ }
+
+ /**
+ * Get a name=>value map of internal variables used during installation.
+ * @return array
+ */
+ public function getInternalDefaults() {
+ return $this->internalDefaults;
+ }
+
+ /**
+ * Get a variable, taking local defaults into account.
+ * @param string $var
+ * @param mixed|null $default
+ * @return mixed
+ */
+ public function getVar( $var, $default = null ) {
+ $defaults = $this->getGlobalDefaults();
+ $internal = $this->getInternalDefaults();
+ if ( isset( $defaults[$var] ) ) {
+ $default = $defaults[$var];
+ } elseif ( isset( $internal[$var] ) ) {
+ $default = $internal[$var];
+ }
+
+ return $this->parent->getVar( $var, $default );
+ }
+
+ /**
+ * Convenience alias for $this->parent->setVar()
+ * @param string $name
+ * @param mixed $value
+ */
+ public function setVar( $name, $value ) {
+ $this->parent->setVar( $name, $value );
+ }
+
+ /**
+ * Get a labelled text box to configure a local variable.
+ *
+ * @param string $var
+ * @param string $label
+ * @param array $attribs
+ * @param string $helpData
+ * @return string
+ */
+ public function getTextBox( $var, $label, $attribs = [], $helpData = "" ) {
+ $name = $this->getName() . '_' . $var;
+ $value = $this->getVar( $var );
+ if ( !isset( $attribs ) ) {
+ $attribs = [];
+ }
+
+ return $this->parent->getTextBox( [
+ 'var' => $var,
+ 'label' => $label,
+ 'attribs' => $attribs,
+ 'controlName' => $name,
+ 'value' => $value,
+ 'help' => $helpData
+ ] );
+ }
+
+ /**
+ * Get a labelled password box to configure a local variable.
+ * Implements password hiding.
+ *
+ * @param string $var
+ * @param string $label
+ * @param array $attribs
+ * @param string $helpData
+ * @return string
+ */
+ public function getPasswordBox( $var, $label, $attribs = [], $helpData = "" ) {
+ $name = $this->getName() . '_' . $var;
+ $value = $this->getVar( $var );
+ if ( !isset( $attribs ) ) {
+ $attribs = [];
+ }
+
+ return $this->parent->getPasswordBox( [
+ 'var' => $var,
+ 'label' => $label,
+ 'attribs' => $attribs,
+ 'controlName' => $name,
+ 'value' => $value,
+ 'help' => $helpData
+ ] );
+ }
+
+ /**
+ * Get a labelled checkbox to configure a local boolean variable.
+ *
+ * @param string $var
+ * @param string $label
+ * @param array $attribs Optional.
+ * @param string $helpData Optional.
+ * @return string
+ */
+ public function getCheckBox( $var, $label, $attribs = [], $helpData = "" ) {
+ $name = $this->getName() . '_' . $var;
+ $value = $this->getVar( $var );
+
+ return $this->parent->getCheckBox( [
+ 'var' => $var,
+ 'label' => $label,
+ 'attribs' => $attribs,
+ 'controlName' => $name,
+ 'value' => $value,
+ 'help' => $helpData
+ ] );
+ }
+
+ /**
+ * Get a set of labelled radio buttons.
+ *
+ * @param array $params Parameters are:
+ * var: The variable to be configured (required)
+ * label: The message name for the label (required)
+ * itemLabelPrefix: The message name prefix for the item labels (required)
+ * values: List of allowed values (required)
+ * itemAttribs Array of attribute arrays, outer key is the value name (optional)
+ *
+ * @return string
+ */
+ public function getRadioSet( $params ) {
+ $params['controlName'] = $this->getName() . '_' . $params['var'];
+ $params['value'] = $this->getVar( $params['var'] );
+
+ return $this->parent->getRadioSet( $params );
+ }
+
+ /**
+ * Convenience function to set variables based on form data.
+ * Assumes that variables containing "password" in the name are (potentially
+ * fake) passwords.
+ * @param array $varNames
+ * @return array
+ */
+ public function setVarsFromRequest( $varNames ) {
+ return $this->parent->setVarsFromRequest( $varNames, $this->getName() . '_' );
+ }
+
+ /**
+ * Determine whether an existing installation of MediaWiki is present in
+ * the configured administrative connection. Returns true if there is
+ * such a wiki, false if the database doesn't exist.
+ *
+ * Traditionally, this is done by testing for the existence of either
+ * the revision table or the cur table.
+ *
+ * @return bool
+ */
+ public function needsUpgrade() {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return false;
+ }
+
+ if ( !$this->db->selectDB( $this->getVar( 'wgDBname' ) ) ) {
+ return false;
+ }
+
+ return $this->db->tableExists( 'cur', __METHOD__ ) ||
+ $this->db->tableExists( 'revision', __METHOD__ );
+ }
+
+ /**
+ * Get a standard install-user fieldset.
+ *
+ * @return string
+ */
+ public function getInstallUserBox() {
+ return Html::openElement( 'fieldset' ) .
+ Html::element( 'legend', [], wfMessage( 'config-db-install-account' )->text() ) .
+ $this->getTextBox(
+ '_InstallUser',
+ 'config-db-username',
+ [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-install-username' )
+ ) .
+ $this->getPasswordBox(
+ '_InstallPassword',
+ 'config-db-password',
+ [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-install-password' )
+ ) .
+ Html::closeElement( 'fieldset' );
+ }
+
+ /**
+ * Submit a standard install user fieldset.
+ * @return Status
+ */
+ public function submitInstallUserBox() {
+ $this->setVarsFromRequest( [ '_InstallUser', '_InstallPassword' ] );
+
+ return Status::newGood();
+ }
+
+ /**
+ * Get a standard web-user fieldset
+ * @param string|bool $noCreateMsg Message to display instead of the creation checkbox.
+ * Set this to false to show a creation checkbox (default).
+ *
+ * @return string
+ */
+ public function getWebUserBox( $noCreateMsg = false ) {
+ $wrapperStyle = $this->getVar( '_SameAccount' ) ? 'display: none' : '';
+ $s = Html::openElement( 'fieldset' ) .
+ Html::element( 'legend', [], wfMessage( 'config-db-web-account' )->text() ) .
+ $this->getCheckBox(
+ '_SameAccount', 'config-db-web-account-same',
+ [ 'class' => 'hideShowRadio', 'rel' => 'dbOtherAccount' ]
+ ) .
+ Html::openElement( 'div', [ 'id' => 'dbOtherAccount', 'style' => $wrapperStyle ] ) .
+ $this->getTextBox( 'wgDBuser', 'config-db-username' ) .
+ $this->getPasswordBox( 'wgDBpassword', 'config-db-password' ) .
+ $this->parent->getHelpBox( 'config-db-web-help' );
+ if ( $noCreateMsg ) {
+ $s .= $this->parent->getWarningBox( wfMessage( $noCreateMsg )->plain() );
+ } else {
+ $s .= $this->getCheckBox( '_CreateDBAccount', 'config-db-web-create' );
+ }
+ $s .= Html::closeElement( 'div' ) . Html::closeElement( 'fieldset' );
+
+ return $s;
+ }
+
+ /**
+ * Submit the form from getWebUserBox().
+ *
+ * @return Status
+ */
+ public function submitWebUserBox() {
+ $this->setVarsFromRequest(
+ [ 'wgDBuser', 'wgDBpassword', '_SameAccount', '_CreateDBAccount' ]
+ );
+
+ if ( $this->getVar( '_SameAccount' ) ) {
+ $this->setVar( 'wgDBuser', $this->getVar( '_InstallUser' ) );
+ $this->setVar( 'wgDBpassword', $this->getVar( '_InstallPassword' ) );
+ }
+
+ if ( $this->getVar( '_CreateDBAccount' ) && strval( $this->getVar( 'wgDBpassword' ) ) == '' ) {
+ return Status::newFatal( 'config-db-password-empty', $this->getVar( 'wgDBuser' ) );
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Common function for databases that don't understand the MySQLish syntax of interwiki.sql.
+ *
+ * @return Status
+ */
+ public function populateInterwikiTable() {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $this->db->selectDB( $this->getVar( 'wgDBname' ) );
+
+ if ( $this->db->selectRow( 'interwiki', '*', [], __METHOD__ ) ) {
+ $status->warning( 'config-install-interwiki-exists' );
+
+ return $status;
+ }
+ global $IP;
+ MediaWiki\suppressWarnings();
+ $rows = file( "$IP/maintenance/interwiki.list",
+ FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
+ MediaWiki\restoreWarnings();
+ $interwikis = [];
+ if ( !$rows ) {
+ return Status::newFatal( 'config-install-interwiki-list' );
+ }
+ foreach ( $rows as $row ) {
+ $row = preg_replace( '/^\s*([^#]*?)\s*(#.*)?$/', '\\1', $row ); // strip comments - whee
+ if ( $row == "" ) {
+ continue;
+ }
+ $row .= "|";
+ $interwikis[] = array_combine(
+ [ 'iw_prefix', 'iw_url', 'iw_local', 'iw_api', 'iw_wikiid' ],
+ explode( '|', $row )
+ );
+ }
+ $this->db->insert( 'interwiki', $interwikis, __METHOD__ );
+
+ return Status::newGood();
+ }
+
+ public function outputHandler( $string ) {
+ return htmlspecialchars( $string );
+ }
+}
diff --git a/www/wiki/includes/installer/DatabaseUpdater.php b/www/wiki/includes/installer/DatabaseUpdater.php
new file mode 100644
index 00000000..752bc544
--- /dev/null
+++ b/www/wiki/includes/installer/DatabaseUpdater.php
@@ -0,0 +1,1215 @@
+<?php
+/**
+ * DBMS-specific updater helper.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\MediaWikiServices;
+
+require_once __DIR__ . '/../../maintenance/Maintenance.php';
+
+/**
+ * Class for handling database updates. Roughly based off of updaters.inc, with
+ * a few improvements :)
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+abstract class DatabaseUpdater {
+ /**
+ * Array of updates to perform on the database
+ *
+ * @var array
+ */
+ protected $updates = [];
+
+ /**
+ * Array of updates that were skipped
+ *
+ * @var array
+ */
+ protected $updatesSkipped = [];
+
+ /**
+ * List of extension-provided database updates
+ * @var array
+ */
+ protected $extensionUpdates = [];
+
+ /**
+ * Handle to the database subclass
+ *
+ * @var Database
+ */
+ protected $db;
+
+ /**
+ * @var Maintenance
+ */
+ protected $maintenance;
+
+ protected $shared = false;
+
+ /**
+ * @var string[] Scripts to run after database update
+ * Should be a subclass of LoggedUpdateMaintenance
+ */
+ protected $postDatabaseUpdateMaintenance = [
+ DeleteDefaultMessages::class,
+ PopulateRevisionLength::class,
+ PopulateRevisionSha1::class,
+ PopulateImageSha1::class,
+ FixExtLinksProtocolRelative::class,
+ PopulateFilearchiveSha1::class,
+ PopulateBacklinkNamespace::class,
+ FixDefaultJsonContentPages::class,
+ CleanupEmptyCategories::class,
+ AddRFCAndPMIDInterwiki::class,
+ PopulatePPSortKey::class,
+ PopulateIpChanges::class,
+ ];
+
+ /**
+ * File handle for SQL output.
+ *
+ * @var resource
+ */
+ protected $fileHandle = null;
+
+ /**
+ * Flag specifying whether or not to skip schema (e.g. SQL-only) updates.
+ *
+ * @var bool
+ */
+ protected $skipSchema = false;
+
+ /**
+ * Hold the value of $wgContentHandlerUseDB during the upgrade.
+ */
+ protected $holdContentHandlerUseDB = true;
+
+ /**
+ * @param Database &$db To perform updates on
+ * @param bool $shared Whether to perform updates on shared tables
+ * @param Maintenance $maintenance Maintenance object which created us
+ */
+ protected function __construct( Database &$db, $shared, Maintenance $maintenance = null ) {
+ $this->db = $db;
+ $this->db->setFlag( DBO_DDLMODE ); // For Oracle's handling of schema files
+ $this->shared = $shared;
+ if ( $maintenance ) {
+ $this->maintenance = $maintenance;
+ $this->fileHandle = $maintenance->fileHandle;
+ } else {
+ $this->maintenance = new FakeMaintenance;
+ }
+ $this->maintenance->setDB( $db );
+ $this->initOldGlobals();
+ $this->loadExtensions();
+ Hooks::run( 'LoadExtensionSchemaUpdates', [ $this ] );
+ }
+
+ /**
+ * Initialize all of the old globals. One day this should all become
+ * something much nicer
+ */
+ private function initOldGlobals() {
+ global $wgExtNewTables, $wgExtNewFields, $wgExtPGNewFields,
+ $wgExtPGAlteredFields, $wgExtNewIndexes, $wgExtModifiedFields;
+
+ # For extensions only, should be populated via hooks
+ # $wgDBtype should be checked to specifiy the proper file
+ $wgExtNewTables = []; // table, dir
+ $wgExtNewFields = []; // table, column, dir
+ $wgExtPGNewFields = []; // table, column, column attributes; for PostgreSQL
+ $wgExtPGAlteredFields = []; // table, column, new type, conversion method; for PostgreSQL
+ $wgExtNewIndexes = []; // table, index, dir
+ $wgExtModifiedFields = []; // table, index, dir
+ }
+
+ /**
+ * Loads LocalSettings.php, if needed, and initialises everything needed for
+ * LoadExtensionSchemaUpdates hook.
+ */
+ private function loadExtensions() {
+ if ( !defined( 'MEDIAWIKI_INSTALL' ) ) {
+ return; // already loaded
+ }
+ $vars = Installer::getExistingLocalSettings();
+
+ $registry = ExtensionRegistry::getInstance();
+ $queue = $registry->getQueue();
+ // Don't accidentally load extensions in the future
+ $registry->clearQueue();
+
+ // This will automatically add "AutoloadClasses" to $wgAutoloadClasses
+ $data = $registry->readFromQueue( $queue );
+ $hooks = [ 'wgHooks' => [ 'LoadExtensionSchemaUpdates' => [] ] ];
+ if ( isset( $data['globals']['wgHooks']['LoadExtensionSchemaUpdates'] ) ) {
+ $hooks = $data['globals']['wgHooks']['LoadExtensionSchemaUpdates'];
+ }
+ if ( $vars && isset( $vars['wgHooks']['LoadExtensionSchemaUpdates'] ) ) {
+ $hooks = array_merge_recursive( $hooks, $vars['wgHooks']['LoadExtensionSchemaUpdates'] );
+ }
+ global $wgHooks, $wgAutoloadClasses;
+ $wgHooks['LoadExtensionSchemaUpdates'] = $hooks;
+ if ( $vars && isset( $vars['wgAutoloadClasses'] ) ) {
+ $wgAutoloadClasses += $vars['wgAutoloadClasses'];
+ }
+ }
+
+ /**
+ * @param Database $db
+ * @param bool $shared
+ * @param Maintenance $maintenance
+ *
+ * @throws MWException
+ * @return DatabaseUpdater
+ */
+ public static function newForDB( Database $db, $shared = false, $maintenance = null ) {
+ $type = $db->getType();
+ if ( in_array( $type, Installer::getDBTypes() ) ) {
+ $class = ucfirst( $type ) . 'Updater';
+
+ return new $class( $db, $shared, $maintenance );
+ } else {
+ throw new MWException( __METHOD__ . ' called for unsupported $wgDBtype' );
+ }
+ }
+
+ /**
+ * Get a database connection to run updates
+ *
+ * @return Database
+ */
+ public function getDB() {
+ return $this->db;
+ }
+
+ /**
+ * Output some text. If we're running from web, escape the text first.
+ *
+ * @param string $str Text to output
+ */
+ public function output( $str ) {
+ if ( $this->maintenance->isQuiet() ) {
+ return;
+ }
+ global $wgCommandLineMode;
+ if ( !$wgCommandLineMode ) {
+ $str = htmlspecialchars( $str );
+ }
+ echo $str;
+ flush();
+ }
+
+ /**
+ * Add a new update coming from an extension. This should be called by
+ * extensions while executing the LoadExtensionSchemaUpdates hook.
+ *
+ * @since 1.17
+ *
+ * @param array $update The update to run. Format is [ $callback, $params... ]
+ * $callback is the method to call; either a DatabaseUpdater method name or a callable.
+ * Must be serializable (ie. no anonymous functions allowed). The rest of the parameters
+ * (if any) will be passed to the callback. The first parameter passed to the callback
+ * is always this object.
+ */
+ public function addExtensionUpdate( array $update ) {
+ $this->extensionUpdates[] = $update;
+ }
+
+ /**
+ * Convenience wrapper for addExtensionUpdate() when adding a new table (which
+ * is the most common usage of updaters in an extension)
+ *
+ * @since 1.18
+ *
+ * @param string $tableName Name of table to create
+ * @param string $sqlPath Full path to the schema file
+ */
+ public function addExtensionTable( $tableName, $sqlPath ) {
+ $this->extensionUpdates[] = [ 'addTable', $tableName, $sqlPath, true ];
+ }
+
+ /**
+ * @since 1.19
+ *
+ * @param string $tableName
+ * @param string $indexName
+ * @param string $sqlPath
+ */
+ public function addExtensionIndex( $tableName, $indexName, $sqlPath ) {
+ $this->extensionUpdates[] = [ 'addIndex', $tableName, $indexName, $sqlPath, true ];
+ }
+
+ /**
+ *
+ * @since 1.19
+ *
+ * @param string $tableName
+ * @param string $columnName
+ * @param string $sqlPath
+ */
+ public function addExtensionField( $tableName, $columnName, $sqlPath ) {
+ $this->extensionUpdates[] = [ 'addField', $tableName, $columnName, $sqlPath, true ];
+ }
+
+ /**
+ *
+ * @since 1.20
+ *
+ * @param string $tableName
+ * @param string $columnName
+ * @param string $sqlPath
+ */
+ public function dropExtensionField( $tableName, $columnName, $sqlPath ) {
+ $this->extensionUpdates[] = [ 'dropField', $tableName, $columnName, $sqlPath, true ];
+ }
+
+ /**
+ * Drop an index from an extension table
+ *
+ * @since 1.21
+ *
+ * @param string $tableName The table name
+ * @param string $indexName The index name
+ * @param string $sqlPath The path to the SQL change path
+ */
+ public function dropExtensionIndex( $tableName, $indexName, $sqlPath ) {
+ $this->extensionUpdates[] = [ 'dropIndex', $tableName, $indexName, $sqlPath, true ];
+ }
+
+ /**
+ *
+ * @since 1.20
+ *
+ * @param string $tableName
+ * @param string $sqlPath
+ */
+ public function dropExtensionTable( $tableName, $sqlPath ) {
+ $this->extensionUpdates[] = [ 'dropTable', $tableName, $sqlPath, true ];
+ }
+
+ /**
+ * Rename an index on an extension table
+ *
+ * @since 1.21
+ *
+ * @param string $tableName The table name
+ * @param string $oldIndexName The old index name
+ * @param string $newIndexName The new index name
+ * @param string $sqlPath The path to the SQL change path
+ * @param bool $skipBothIndexExistWarning Whether to warn if both the old
+ * and the new indexes exist. [facultative; by default, false]
+ */
+ public function renameExtensionIndex( $tableName, $oldIndexName, $newIndexName,
+ $sqlPath, $skipBothIndexExistWarning = false
+ ) {
+ $this->extensionUpdates[] = [
+ 'renameIndex',
+ $tableName,
+ $oldIndexName,
+ $newIndexName,
+ $skipBothIndexExistWarning,
+ $sqlPath,
+ true
+ ];
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string $tableName The table name
+ * @param string $fieldName The field to be modified
+ * @param string $sqlPath The path to the SQL change path
+ */
+ public function modifyExtensionField( $tableName, $fieldName, $sqlPath ) {
+ $this->extensionUpdates[] = [ 'modifyField', $tableName, $fieldName, $sqlPath, true ];
+ }
+
+ /**
+ *
+ * @since 1.20
+ *
+ * @param string $tableName
+ * @return bool
+ */
+ public function tableExists( $tableName ) {
+ return ( $this->db->tableExists( $tableName, __METHOD__ ) );
+ }
+
+ /**
+ * Add a maintenance script to be run after the database updates are complete.
+ *
+ * Script should subclass LoggedUpdateMaintenance
+ *
+ * @since 1.19
+ *
+ * @param string $class Name of a Maintenance subclass
+ */
+ public function addPostDatabaseUpdateMaintenance( $class ) {
+ $this->postDatabaseUpdateMaintenance[] = $class;
+ }
+
+ /**
+ * Get the list of extension-defined updates
+ *
+ * @return array
+ */
+ protected function getExtensionUpdates() {
+ return $this->extensionUpdates;
+ }
+
+ /**
+ * @since 1.17
+ *
+ * @return string[]
+ */
+ public function getPostDatabaseUpdateMaintenance() {
+ return $this->postDatabaseUpdateMaintenance;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * Writes the schema updates desired to a file for the DB Admin to run.
+ * @param array $schemaUpdate
+ */
+ private function writeSchemaUpdateFile( array $schemaUpdate = [] ) {
+ $updates = $this->updatesSkipped;
+ $this->updatesSkipped = [];
+
+ foreach ( $updates as $funcList ) {
+ $func = $funcList[0];
+ $arg = $funcList[1];
+ $origParams = $funcList[2];
+ call_user_func_array( $func, $arg );
+ flush();
+ $this->updatesSkipped[] = $origParams;
+ }
+ }
+
+ /**
+ * Get appropriate schema variables in the current database connection.
+ *
+ * This should be called after any request data has been imported, but before
+ * any write operations to the database. The result should be passed to the DB
+ * setSchemaVars() method.
+ *
+ * @return array
+ * @since 1.28
+ */
+ public function getSchemaVars() {
+ return []; // DB-type specific
+ }
+
+ /**
+ * Do all the updates
+ *
+ * @param array $what What updates to perform
+ */
+ public function doUpdates( array $what = [ 'core', 'extensions', 'stats' ] ) {
+ $this->db->setSchemaVars( $this->getSchemaVars() );
+
+ $what = array_flip( $what );
+ $this->skipSchema = isset( $what['noschema'] ) || $this->fileHandle !== null;
+ if ( isset( $what['core'] ) ) {
+ $this->runUpdates( $this->getCoreUpdateList(), false );
+ }
+ if ( isset( $what['extensions'] ) ) {
+ $this->runUpdates( $this->getOldGlobalUpdates(), false );
+ $this->runUpdates( $this->getExtensionUpdates(), true );
+ }
+
+ if ( isset( $what['stats'] ) ) {
+ $this->checkStats();
+ }
+
+ if ( $this->fileHandle ) {
+ $this->skipSchema = false;
+ $this->writeSchemaUpdateFile();
+ }
+ }
+
+ /**
+ * Helper function for doUpdates()
+ *
+ * @param array $updates Array of updates to run
+ * @param bool $passSelf Whether to pass this object we calling external functions
+ */
+ private function runUpdates( array $updates, $passSelf ) {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+
+ $updatesDone = [];
+ $updatesSkipped = [];
+ foreach ( $updates as $params ) {
+ $origParams = $params;
+ $func = array_shift( $params );
+ if ( !is_array( $func ) && method_exists( $this, $func ) ) {
+ $func = [ $this, $func ];
+ } elseif ( $passSelf ) {
+ array_unshift( $params, $this );
+ }
+ $ret = call_user_func_array( $func, $params );
+ flush();
+ if ( $ret !== false ) {
+ $updatesDone[] = $origParams;
+ $lbFactory->waitForReplication();
+ } else {
+ $updatesSkipped[] = [ $func, $params, $origParams ];
+ }
+ }
+ $this->updatesSkipped = array_merge( $this->updatesSkipped, $updatesSkipped );
+ $this->updates = array_merge( $this->updates, $updatesDone );
+ }
+
+ /**
+ * Helper function: check if the given key is present in the updatelog table.
+ * Obviously, only use this for updates that occur after the updatelog table was
+ * created!
+ * @param string $key Name of the key to check for
+ * @return bool
+ */
+ public function updateRowExists( $key ) {
+ $row = $this->db->selectRow(
+ 'updatelog',
+ # T67813
+ '1 AS X',
+ [ 'ul_key' => $key ],
+ __METHOD__
+ );
+
+ return (bool)$row;
+ }
+
+ /**
+ * Helper function: Add a key to the updatelog table
+ * Obviously, only use this for updates that occur after the updatelog table was
+ * created!
+ * @param string $key Name of key to insert
+ * @param string $val [optional] Value to insert along with the key
+ */
+ public function insertUpdateRow( $key, $val = null ) {
+ $this->db->clearFlag( DBO_DDLMODE );
+ $values = [ 'ul_key' => $key ];
+ if ( $val && $this->canUseNewUpdatelog() ) {
+ $values['ul_value'] = $val;
+ }
+ $this->db->insert( 'updatelog', $values, __METHOD__, 'IGNORE' );
+ $this->db->setFlag( DBO_DDLMODE );
+ }
+
+ /**
+ * Updatelog was changed in 1.17 to have a ul_value column so we can record
+ * more information about what kind of updates we've done (that's what this
+ * class does). Pre-1.17 wikis won't have this column, and really old wikis
+ * might not even have updatelog at all
+ *
+ * @return bool
+ */
+ protected function canUseNewUpdatelog() {
+ return $this->db->tableExists( 'updatelog', __METHOD__ ) &&
+ $this->db->fieldExists( 'updatelog', 'ul_value', __METHOD__ );
+ }
+
+ /**
+ * Returns whether updates should be executed on the database table $name.
+ * Updates will be prevented if the table is a shared table and it is not
+ * specified to run updates on shared tables.
+ *
+ * @param string $name Table name
+ * @return bool
+ */
+ protected function doTable( $name ) {
+ global $wgSharedDB, $wgSharedTables;
+
+ // Don't bother to check $wgSharedTables if there isn't a shared database
+ // or the user actually also wants to do updates on the shared database.
+ if ( $wgSharedDB === null || $this->shared ) {
+ return true;
+ }
+
+ if ( in_array( $name, $wgSharedTables ) ) {
+ $this->output( "...skipping update to shared table $name.\n" );
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Before 1.17, we used to handle updates via stuff like
+ * $wgExtNewTables/Fields/Indexes. This is nasty :) We refactored a lot
+ * of this in 1.17 but we want to remain back-compatible for a while. So
+ * load up these old global-based things into our update list.
+ *
+ * @return array
+ */
+ protected function getOldGlobalUpdates() {
+ global $wgExtNewFields, $wgExtNewTables, $wgExtModifiedFields,
+ $wgExtNewIndexes;
+
+ $updates = [];
+
+ foreach ( $wgExtNewTables as $tableRecord ) {
+ $updates[] = [
+ 'addTable', $tableRecord[0], $tableRecord[1], true
+ ];
+ }
+
+ foreach ( $wgExtNewFields as $fieldRecord ) {
+ $updates[] = [
+ 'addField', $fieldRecord[0], $fieldRecord[1],
+ $fieldRecord[2], true
+ ];
+ }
+
+ foreach ( $wgExtNewIndexes as $fieldRecord ) {
+ $updates[] = [
+ 'addIndex', $fieldRecord[0], $fieldRecord[1],
+ $fieldRecord[2], true
+ ];
+ }
+
+ foreach ( $wgExtModifiedFields as $fieldRecord ) {
+ $updates[] = [
+ 'modifyField', $fieldRecord[0], $fieldRecord[1],
+ $fieldRecord[2], true
+ ];
+ }
+
+ return $updates;
+ }
+
+ /**
+ * Get an array of updates to perform on the database. Should return a
+ * multi-dimensional array. The main key is the MediaWiki version (1.12,
+ * 1.13...) with the values being arrays of updates, identical to how
+ * updaters.inc did it (for now)
+ *
+ * @return array
+ */
+ abstract protected function getCoreUpdateList();
+
+ /**
+ * Append an SQL fragment to the open file handle.
+ *
+ * @param string $filename File name to open
+ */
+ public function copyFile( $filename ) {
+ $this->db->sourceFile(
+ $filename,
+ null,
+ null,
+ __METHOD__,
+ [ $this, 'appendLine' ]
+ );
+ }
+
+ /**
+ * Append a line to the open filehandle. The line is assumed to
+ * be a complete SQL statement.
+ *
+ * This is used as a callback for sourceLine().
+ *
+ * @param string $line Text to append to the file
+ * @return bool False to skip actually executing the file
+ * @throws MWException
+ */
+ public function appendLine( $line ) {
+ $line = rtrim( $line ) . ";\n";
+ if ( fwrite( $this->fileHandle, $line ) === false ) {
+ throw new MWException( "trouble writing file" );
+ }
+
+ return false;
+ }
+
+ /**
+ * Applies a SQL patch
+ *
+ * @param string $path Path to the patch file
+ * @param bool $isFullPath Whether to treat $path as a relative or not
+ * @param string $msg Description of the patch
+ * @return bool False if patch is skipped.
+ */
+ protected function applyPatch( $path, $isFullPath = false, $msg = null ) {
+ if ( $msg === null ) {
+ $msg = "Applying $path patch";
+ }
+ if ( $this->skipSchema ) {
+ $this->output( "...skipping schema change ($msg).\n" );
+
+ return false;
+ }
+
+ $this->output( "$msg ..." );
+
+ if ( !$isFullPath ) {
+ $path = $this->patchPath( $this->db, $path );
+ }
+ if ( $this->fileHandle !== null ) {
+ $this->copyFile( $path );
+ } else {
+ $this->db->sourceFile( $path );
+ }
+ $this->output( "done.\n" );
+
+ return true;
+ }
+
+ /**
+ * Get the full path of a patch file. Originally based on archive()
+ * from updaters.inc. Keep in mind this always returns a patch, as
+ * it fails back to MySQL if no DB-specific patch can be found
+ *
+ * @param IDatabase $db
+ * @param string $patch The name of the patch, like patch-something.sql
+ * @return string Full path to patch file
+ */
+ public function patchPath( IDatabase $db, $patch ) {
+ global $IP;
+
+ $dbType = $db->getType();
+ if ( file_exists( "$IP/maintenance/$dbType/archives/$patch" ) ) {
+ return "$IP/maintenance/$dbType/archives/$patch";
+ } else {
+ return "$IP/maintenance/archives/$patch";
+ }
+ }
+
+ /**
+ * Add a new table to the database
+ *
+ * @param string $name Name of the new table
+ * @param string $patch Path to the patch file
+ * @param bool $fullpath Whether to treat $patch path as a relative or not
+ * @return bool False if this was skipped because schema changes are skipped
+ */
+ protected function addTable( $name, $patch, $fullpath = false ) {
+ if ( !$this->doTable( $name ) ) {
+ return true;
+ }
+
+ if ( $this->db->tableExists( $name, __METHOD__ ) ) {
+ $this->output( "...$name table already exists.\n" );
+ } else {
+ return $this->applyPatch( $patch, $fullpath, "Creating $name table" );
+ }
+
+ return true;
+ }
+
+ /**
+ * Add a new field to an existing table
+ *
+ * @param string $table Name of the table to modify
+ * @param string $field Name of the new field
+ * @param string $patch Path to the patch file
+ * @param bool $fullpath Whether to treat $patch path as a relative or not
+ * @return bool False if this was skipped because schema changes are skipped
+ */
+ protected function addField( $table, $field, $patch, $fullpath = false ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
+ $this->output( "...$table table does not exist, skipping new field patch.\n" );
+ } elseif ( $this->db->fieldExists( $table, $field, __METHOD__ ) ) {
+ $this->output( "...have $field field in $table table.\n" );
+ } else {
+ return $this->applyPatch( $patch, $fullpath, "Adding $field field to table $table" );
+ }
+
+ return true;
+ }
+
+ /**
+ * Add a new index to an existing table
+ *
+ * @param string $table Name of the table to modify
+ * @param string $index Name of the new index
+ * @param string $patch Path to the patch file
+ * @param bool $fullpath Whether to treat $patch path as a relative or not
+ * @return bool False if this was skipped because schema changes are skipped
+ */
+ protected function addIndex( $table, $index, $patch, $fullpath = false ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
+ $this->output( "...skipping: '$table' table doesn't exist yet.\n" );
+ } elseif ( $this->db->indexExists( $table, $index, __METHOD__ ) ) {
+ $this->output( "...index $index already set on $table table.\n" );
+ } else {
+ return $this->applyPatch( $patch, $fullpath, "Adding index $index to table $table" );
+ }
+
+ return true;
+ }
+
+ /**
+ * Drop a field from an existing table
+ *
+ * @param string $table Name of the table to modify
+ * @param string $field Name of the old field
+ * @param string $patch Path to the patch file
+ * @param bool $fullpath Whether to treat $patch path as a relative or not
+ * @return bool False if this was skipped because schema changes are skipped
+ */
+ protected function dropField( $table, $field, $patch, $fullpath = false ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ if ( $this->db->fieldExists( $table, $field, __METHOD__ ) ) {
+ return $this->applyPatch( $patch, $fullpath, "Table $table contains $field field. Dropping" );
+ } else {
+ $this->output( "...$table table does not contain $field field.\n" );
+ }
+
+ return true;
+ }
+
+ /**
+ * Drop an index from an existing table
+ *
+ * @param string $table Name of the table to modify
+ * @param string $index Name of the index
+ * @param string $patch Path to the patch file
+ * @param bool $fullpath Whether to treat $patch path as a relative or not
+ * @return bool False if this was skipped because schema changes are skipped
+ */
+ protected function dropIndex( $table, $index, $patch, $fullpath = false ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ if ( $this->db->indexExists( $table, $index, __METHOD__ ) ) {
+ return $this->applyPatch( $patch, $fullpath, "Dropping $index index from table $table" );
+ } else {
+ $this->output( "...$index key doesn't exist.\n" );
+ }
+
+ return true;
+ }
+
+ /**
+ * Rename an index from an existing table
+ *
+ * @param string $table Name of the table to modify
+ * @param string $oldIndex Old name of the index
+ * @param string $newIndex New name of the index
+ * @param bool $skipBothIndexExistWarning Whether to warn if both the
+ * old and the new indexes exist.
+ * @param string $patch Path to the patch file
+ * @param bool $fullpath Whether to treat $patch path as a relative or not
+ * @return bool False if this was skipped because schema changes are skipped
+ */
+ protected function renameIndex( $table, $oldIndex, $newIndex,
+ $skipBothIndexExistWarning, $patch, $fullpath = false
+ ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ // First requirement: the table must exist
+ if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
+ $this->output( "...skipping: '$table' table doesn't exist yet.\n" );
+
+ return true;
+ }
+
+ // Second requirement: the new index must be missing
+ if ( $this->db->indexExists( $table, $newIndex, __METHOD__ ) ) {
+ $this->output( "...index $newIndex already set on $table table.\n" );
+ if ( !$skipBothIndexExistWarning &&
+ $this->db->indexExists( $table, $oldIndex, __METHOD__ )
+ ) {
+ $this->output( "...WARNING: $oldIndex still exists, despite it has " .
+ "been renamed into $newIndex (which also exists).\n" .
+ " $oldIndex should be manually removed if not needed anymore.\n" );
+ }
+
+ return true;
+ }
+
+ // Third requirement: the old index must exist
+ if ( !$this->db->indexExists( $table, $oldIndex, __METHOD__ ) ) {
+ $this->output( "...skipping: index $oldIndex doesn't exist.\n" );
+
+ return true;
+ }
+
+ // Requirements have been satisfied, patch can be applied
+ return $this->applyPatch(
+ $patch,
+ $fullpath,
+ "Renaming index $oldIndex into $newIndex to table $table"
+ );
+ }
+
+ /**
+ * If the specified table exists, drop it, or execute the
+ * patch if one is provided.
+ *
+ * Public @since 1.20
+ *
+ * @param string $table Table to drop.
+ * @param string|bool $patch String of patch file that will drop the table. Default: false.
+ * @param bool $fullpath Whether $patch is a full path. Default: false.
+ * @return bool False if this was skipped because schema changes are skipped
+ */
+ public function dropTable( $table, $patch = false, $fullpath = false ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ if ( $this->db->tableExists( $table, __METHOD__ ) ) {
+ $msg = "Dropping table $table";
+
+ if ( $patch === false ) {
+ $this->output( "$msg ..." );
+ $this->db->dropTable( $table, __METHOD__ );
+ $this->output( "done.\n" );
+ } else {
+ return $this->applyPatch( $patch, $fullpath, $msg );
+ }
+ } else {
+ $this->output( "...$table doesn't exist.\n" );
+ }
+
+ return true;
+ }
+
+ /**
+ * Modify an existing field
+ *
+ * @param string $table Name of the table to which the field belongs
+ * @param string $field Name of the field to modify
+ * @param string $patch Path to the patch file
+ * @param bool $fullpath Whether to treat $patch path as a relative or not
+ * @return bool False if this was skipped because schema changes are skipped
+ */
+ public function modifyField( $table, $field, $patch, $fullpath = false ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ $updateKey = "$table-$field-$patch";
+ if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
+ $this->output( "...$table table does not exist, skipping modify field patch.\n" );
+ } elseif ( !$this->db->fieldExists( $table, $field, __METHOD__ ) ) {
+ $this->output( "...$field field does not exist in $table table, " .
+ "skipping modify field patch.\n" );
+ } elseif ( $this->updateRowExists( $updateKey ) ) {
+ $this->output( "...$field in table $table already modified by patch $patch.\n" );
+ } else {
+ $apply = $this->applyPatch( $patch, $fullpath, "Modifying $field field of table $table" );
+ if ( $apply ) {
+ $this->insertUpdateRow( $updateKey );
+ }
+ return $apply;
+ }
+ return true;
+ }
+
+ /**
+ * Modify an existing table, similar to modifyField. Intended for changes that
+ * touch more than one column on a table.
+ *
+ * @param string $table Name of the table to modify
+ * @param string $patch Name of the patch file to apply
+ * @param string|bool $fullpath Whether to treat $patch path as relative or not, defaults to false
+ * @return bool False if this was skipped because of schema changes being skipped
+ */
+ public function modifyTable( $table, $patch, $fullpath = false ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ $updateKey = "$table-$patch";
+ if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
+ $this->output( "...$table table does not exist, skipping modify table patch.\n" );
+ } elseif ( $this->updateRowExists( $updateKey ) ) {
+ $this->output( "...table $table already modified by patch $patch.\n" );
+ } else {
+ $apply = $this->applyPatch( $patch, $fullpath, "Modifying table $table" );
+ if ( $apply ) {
+ $this->insertUpdateRow( $updateKey );
+ }
+ return $apply;
+ }
+ return true;
+ }
+
+ /**
+ * Set any .htaccess files or equivilent for storage repos
+ *
+ * Some zones (e.g. "temp") used to be public and may have been initialized as such
+ */
+ public function setFileAccess() {
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ $zonePath = $repo->getZonePath( 'temp' );
+ if ( $repo->getBackend()->directoryExists( [ 'dir' => $zonePath ] ) ) {
+ // If the directory was never made, then it will have the right ACLs when it is made
+ $status = $repo->getBackend()->secure( [
+ 'dir' => $zonePath,
+ 'noAccess' => true,
+ 'noListing' => true
+ ] );
+ if ( $status->isOK() ) {
+ $this->output( "Set the local repo temp zone container to be private.\n" );
+ } else {
+ $this->output( "Failed to set the local repo temp zone container to be private.\n" );
+ }
+ }
+ }
+
+ /**
+ * Purge the objectcache table
+ */
+ public function purgeCache() {
+ global $wgLocalisationCacheConf;
+ # We can't guarantee that the user will be able to use TRUNCATE,
+ # but we know that DELETE is available to us
+ $this->output( "Purging caches..." );
+ $this->db->delete( 'objectcache', '*', __METHOD__ );
+ if ( $wgLocalisationCacheConf['manualRecache'] ) {
+ $this->rebuildLocalisationCache();
+ }
+ $blobStore = new MessageBlobStore();
+ $blobStore->clear();
+ $this->db->delete( 'module_deps', '*', __METHOD__ );
+ $this->output( "done.\n" );
+ }
+
+ /**
+ * Check the site_stats table is not properly populated.
+ */
+ protected function checkStats() {
+ $this->output( "...site_stats is populated..." );
+ $row = $this->db->selectRow( 'site_stats', '*', [ 'ss_row_id' => 1 ], __METHOD__ );
+ if ( $row === false ) {
+ $this->output( "data is missing! rebuilding...\n" );
+ } elseif ( isset( $row->site_stats ) && $row->ss_total_pages == -1 ) {
+ $this->output( "missing ss_total_pages, rebuilding...\n" );
+ } else {
+ $this->output( "done.\n" );
+
+ return;
+ }
+ SiteStatsInit::doAllAndCommit( $this->db );
+ }
+
+ # Common updater functions
+
+ /**
+ * Sets the number of active users in the site_stats table
+ */
+ protected function doActiveUsersInit() {
+ $activeUsers = $this->db->selectField( 'site_stats', 'ss_active_users', false, __METHOD__ );
+ if ( $activeUsers == -1 ) {
+ $activeUsers = $this->db->selectField( 'recentchanges',
+ 'COUNT( DISTINCT rc_user_text )',
+ [ 'rc_user != 0', 'rc_bot' => 0, "rc_log_type != 'newusers'" ], __METHOD__
+ );
+ $this->db->update( 'site_stats',
+ [ 'ss_active_users' => intval( $activeUsers ) ],
+ [ 'ss_row_id' => 1 ], __METHOD__, [ 'LIMIT' => 1 ]
+ );
+ }
+ $this->output( "...ss_active_users user count set...\n" );
+ }
+
+ /**
+ * Populates the log_user_text field in the logging table
+ */
+ protected function doLogUsertextPopulation() {
+ if ( !$this->updateRowExists( 'populate log_usertext' ) ) {
+ $this->output(
+ "Populating log_user_text field, printing progress markers. For large\n" .
+ "databases, you may want to hit Ctrl-C and do this manually with\n" .
+ "maintenance/populateLogUsertext.php.\n"
+ );
+
+ $task = $this->maintenance->runChild( 'PopulateLogUsertext' );
+ $task->execute();
+ $this->output( "done.\n" );
+ }
+ }
+
+ /**
+ * Migrate log params to new table and index for searching
+ */
+ protected function doLogSearchPopulation() {
+ if ( !$this->updateRowExists( 'populate log_search' ) ) {
+ $this->output(
+ "Populating log_search table, printing progress markers. For large\n" .
+ "databases, you may want to hit Ctrl-C and do this manually with\n" .
+ "maintenance/populateLogSearch.php.\n" );
+
+ $task = $this->maintenance->runChild( 'PopulateLogSearch' );
+ $task->execute();
+ $this->output( "done.\n" );
+ }
+ }
+
+ /**
+ * Updates the timestamps in the transcache table
+ * @return bool
+ */
+ protected function doUpdateTranscacheField() {
+ if ( $this->updateRowExists( 'convert transcache field' ) ) {
+ $this->output( "...transcache tc_time already converted.\n" );
+
+ return true;
+ }
+
+ return $this->applyPatch( 'patch-tc-timestamp.sql', false,
+ "Converting tc_time from UNIX epoch to MediaWiki timestamp" );
+ }
+
+ /**
+ * Update CategoryLinks collation
+ */
+ protected function doCollationUpdate() {
+ global $wgCategoryCollation;
+ if ( $this->db->fieldExists( 'categorylinks', 'cl_collation', __METHOD__ ) ) {
+ if ( $this->db->selectField(
+ 'categorylinks',
+ 'COUNT(*)',
+ 'cl_collation != ' . $this->db->addQuotes( $wgCategoryCollation ),
+ __METHOD__
+ ) == 0
+ ) {
+ $this->output( "...collations up-to-date.\n" );
+
+ return;
+ }
+
+ $this->output( "Updating category collations..." );
+ $task = $this->maintenance->runChild( 'UpdateCollation' );
+ $task->execute();
+ $this->output( "...done.\n" );
+ }
+ }
+
+ /**
+ * Migrates user options from the user table blob to user_properties
+ */
+ protected function doMigrateUserOptions() {
+ if ( $this->db->tableExists( 'user_properties' ) ) {
+ $cl = $this->maintenance->runChild( 'ConvertUserOptions', 'convertUserOptions.php' );
+ $cl->execute();
+ $this->output( "done.\n" );
+ }
+ }
+
+ /**
+ * Enable profiling table when it's turned on
+ */
+ protected function doEnableProfiling() {
+ global $wgProfiler;
+
+ if ( !$this->doTable( 'profiling' ) ) {
+ return;
+ }
+
+ $profileToDb = false;
+ if ( isset( $wgProfiler['output'] ) ) {
+ $out = $wgProfiler['output'];
+ if ( $out === 'db' ) {
+ $profileToDb = true;
+ } elseif ( is_array( $out ) && in_array( 'db', $out ) ) {
+ $profileToDb = true;
+ }
+ }
+
+ if ( $profileToDb && !$this->db->tableExists( 'profiling', __METHOD__ ) ) {
+ $this->applyPatch( 'patch-profiling.sql', false, 'Add profiling table' );
+ }
+ }
+
+ /**
+ * Rebuilds the localisation cache
+ */
+ protected function rebuildLocalisationCache() {
+ /**
+ * @var $cl RebuildLocalisationCache
+ */
+ $cl = $this->maintenance->runChild( 'RebuildLocalisationCache', 'rebuildLocalisationCache.php' );
+ $this->output( "Rebuilding localisation cache...\n" );
+ $cl->setForce();
+ $cl->execute();
+ $this->output( "done.\n" );
+ }
+
+ /**
+ * Turns off content handler fields during parts of the upgrade
+ * where they aren't available.
+ */
+ protected function disableContentHandlerUseDB() {
+ global $wgContentHandlerUseDB;
+
+ if ( $wgContentHandlerUseDB ) {
+ $this->output( "Turning off Content Handler DB fields for this part of upgrade.\n" );
+ $this->holdContentHandlerUseDB = $wgContentHandlerUseDB;
+ $wgContentHandlerUseDB = false;
+ }
+ }
+
+ /**
+ * Turns content handler fields back on.
+ */
+ protected function enableContentHandlerUseDB() {
+ global $wgContentHandlerUseDB;
+
+ if ( $this->holdContentHandlerUseDB ) {
+ $this->output( "Content Handler DB fields should be usable now.\n" );
+ $wgContentHandlerUseDB = $this->holdContentHandlerUseDB;
+ }
+ }
+
+ /**
+ * Migrate comments to the new 'comment' table
+ * @since 1.30
+ */
+ protected function migrateComments() {
+ global $wgCommentTableSchemaMigrationStage;
+ if ( $wgCommentTableSchemaMigrationStage >= MIGRATION_WRITE_NEW &&
+ !$this->updateRowExists( 'MigrateComments' )
+ ) {
+ $this->output(
+ "Migrating comments to the 'comments' table, printing progress markers. For large\n" .
+ "databases, you may want to hit Ctrl-C and do this manually with\n" .
+ "maintenance/migrateComments.php.\n"
+ );
+ $task = $this->maintenance->runChild( 'MigrateComments', 'migrateComments.php' );
+ $task->execute();
+ $this->output( "done.\n" );
+ }
+ }
+
+}
diff --git a/www/wiki/includes/installer/InstallDocFormatter.php b/www/wiki/includes/installer/InstallDocFormatter.php
new file mode 100644
index 00000000..4163e2f9
--- /dev/null
+++ b/www/wiki/includes/installer/InstallDocFormatter.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Installer-specific wikitext formatting.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class InstallDocFormatter {
+ static function format( $text ) {
+ $obj = new self( $text );
+
+ return $obj->execute();
+ }
+
+ protected function __construct( $text ) {
+ $this->text = $text;
+ }
+
+ protected function execute() {
+ $text = $this->text;
+ // Use Unix line endings, escape some wikitext stuff
+ $text = str_replace( [ '<', '{{', '[[', '__', "\r" ],
+ [ '&lt;', '&#123;&#123;', '&#91;&#91;', '&#95;&#95;', '' ], $text );
+ // join word-wrapped lines into one
+ do {
+ $prev = $text;
+ $text = preg_replace( "/\n([\\*#\t])([^\n]*?)\n([^\n#\\*:]+)/", "\n\\1\\2 \\3", $text );
+ } while ( $text != $prev );
+ // Replace tab indents with colons
+ $text = preg_replace( '/^\t\t/m', '::', $text );
+ $text = preg_replace( '/^\t/m', ':', $text );
+
+ $linkStart = '<span class="config-plainlink">[';
+ $linkEnd = ' $0]</span>';
+
+ // turn (Tnnnn) into links
+ $text = preg_replace(
+ '/T\d+/',
+ "{$linkStart}https://phabricator.wikimedia.org/$0{$linkEnd}",
+ $text
+ );
+
+ // turn (bug nnnn) into links
+ $text = preg_replace(
+ '/bug (\d+)/',
+ "{$linkStart}https://bugzilla.wikimedia.org/$1{$linkEnd}",
+ $text
+ );
+
+ // add links to manual to every global variable mentioned
+ $text = preg_replace(
+ '/\$wg[a-z0-9_]+/i',
+ "{$linkStart}https://www.mediawiki.org/wiki/Manual:$0{$linkEnd}",
+ $text
+ );
+
+ return $text;
+ }
+}
diff --git a/www/wiki/includes/installer/Installer.php b/www/wiki/includes/installer/Installer.php
new file mode 100644
index 00000000..012b4775
--- /dev/null
+++ b/www/wiki/includes/installer/Installer.php
@@ -0,0 +1,1788 @@
+<?php
+/**
+ * Base code for MediaWiki installer.
+ *
+ * DO NOT PATCH THIS FILE IF YOU NEED TO CHANGE INSTALLER BEHAVIOR IN YOUR PACKAGE!
+ * See mw-config/overrides/README for details.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This documentation group collects source code files with deployment functionality.
+ *
+ * @defgroup Deployment Deployment
+ */
+
+/**
+ * Base installer class.
+ *
+ * This class provides the base for installation and update functionality
+ * for both MediaWiki core and extensions.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+abstract class Installer {
+
+ /**
+ * The oldest version of PCRE we can support.
+ *
+ * Defining this is necessary because PHP may be linked with a system version
+ * of PCRE, which may be older than that bundled with the minimum PHP version.
+ */
+ const MINIMUM_PCRE_VERSION = '7.2';
+
+ /**
+ * @var array
+ */
+ protected $settings;
+
+ /**
+ * List of detected DBs, access using getCompiledDBs().
+ *
+ * @var array
+ */
+ protected $compiledDBs;
+
+ /**
+ * Cached DB installer instances, access using getDBInstaller().
+ *
+ * @var array
+ */
+ protected $dbInstallers = [];
+
+ /**
+ * Minimum memory size in MB.
+ *
+ * @var int
+ */
+ protected $minMemorySize = 50;
+
+ /**
+ * Cached Title, used by parse().
+ *
+ * @var Title
+ */
+ protected $parserTitle;
+
+ /**
+ * Cached ParserOptions, used by parse().
+ *
+ * @var ParserOptions
+ */
+ protected $parserOptions;
+
+ /**
+ * Known database types. These correspond to the class names <type>Installer,
+ * and are also MediaWiki database types valid for $wgDBtype.
+ *
+ * To add a new type, create a <type>Installer class and a Database<type>
+ * class, and add a config-type-<type> message to MessagesEn.php.
+ *
+ * @var array
+ */
+ protected static $dbTypes = [
+ 'mysql',
+ 'postgres',
+ 'oracle',
+ 'mssql',
+ 'sqlite',
+ ];
+
+ /**
+ * A list of environment check methods called by doEnvironmentChecks().
+ * These may output warnings using showMessage(), and/or abort the
+ * installation process by returning false.
+ *
+ * For the WebInstaller these are only called on the Welcome page,
+ * if these methods have side-effects that should affect later page loads
+ * (as well as the generated stylesheet), use envPreps instead.
+ *
+ * @var array
+ */
+ protected $envChecks = [
+ 'envCheckDB',
+ 'envCheckBrokenXML',
+ 'envCheckPCRE',
+ 'envCheckMemory',
+ 'envCheckCache',
+ 'envCheckModSecurity',
+ 'envCheckDiff3',
+ 'envCheckGraphics',
+ 'envCheckGit',
+ 'envCheckServer',
+ 'envCheckPath',
+ 'envCheckShellLocale',
+ 'envCheckUploadsDirectory',
+ 'envCheckLibicu',
+ 'envCheckSuhosinMaxValueLength',
+ 'envCheck64Bit',
+ ];
+
+ /**
+ * A list of environment preparation methods called by doEnvironmentPreps().
+ *
+ * @var array
+ */
+ protected $envPreps = [
+ 'envPrepServer',
+ 'envPrepPath',
+ ];
+
+ /**
+ * MediaWiki configuration globals that will eventually be passed through
+ * to LocalSettings.php. The names only are given here, the defaults
+ * typically come from DefaultSettings.php.
+ *
+ * @var array
+ */
+ protected $defaultVarNames = [
+ 'wgSitename',
+ 'wgPasswordSender',
+ 'wgLanguageCode',
+ 'wgRightsIcon',
+ 'wgRightsText',
+ 'wgRightsUrl',
+ 'wgEnableEmail',
+ 'wgEnableUserEmail',
+ 'wgEnotifUserTalk',
+ 'wgEnotifWatchlist',
+ 'wgEmailAuthentication',
+ 'wgDBname',
+ 'wgDBtype',
+ 'wgDiff3',
+ 'wgImageMagickConvertCommand',
+ 'wgGitBin',
+ 'IP',
+ 'wgScriptPath',
+ 'wgMetaNamespace',
+ 'wgDeletedDirectory',
+ 'wgEnableUploads',
+ 'wgShellLocale',
+ 'wgSecretKey',
+ 'wgUseInstantCommons',
+ 'wgUpgradeKey',
+ 'wgDefaultSkin',
+ 'wgPingback',
+ ];
+
+ /**
+ * Variables that are stored alongside globals, and are used for any
+ * configuration of the installation process aside from the MediaWiki
+ * configuration. Map of names to defaults.
+ *
+ * @var array
+ */
+ protected $internalDefaults = [
+ '_UserLang' => 'en',
+ '_Environment' => false,
+ '_RaiseMemory' => false,
+ '_UpgradeDone' => false,
+ '_InstallDone' => false,
+ '_Caches' => [],
+ '_InstallPassword' => '',
+ '_SameAccount' => true,
+ '_CreateDBAccount' => false,
+ '_NamespaceType' => 'site-name',
+ '_AdminName' => '', // will be set later, when the user selects language
+ '_AdminPassword' => '',
+ '_AdminPasswordConfirm' => '',
+ '_AdminEmail' => '',
+ '_Subscribe' => false,
+ '_SkipOptional' => 'continue',
+ '_RightsProfile' => 'wiki',
+ '_LicenseCode' => 'none',
+ '_CCDone' => false,
+ '_Extensions' => [],
+ '_Skins' => [],
+ '_MemCachedServers' => '',
+ '_UpgradeKeySupplied' => false,
+ '_ExistingDBSettings' => false,
+
+ // $wgLogo is probably wrong (T50084); set something that will work.
+ // Single quotes work fine here, as LocalSettingsGenerator outputs this unescaped.
+ 'wgLogo' => '$wgResourceBasePath/resources/assets/wiki.png',
+ 'wgAuthenticationTokenVersion' => 1,
+ ];
+
+ /**
+ * The actual list of installation steps. This will be initialized by getInstallSteps()
+ *
+ * @var array
+ */
+ private $installSteps = [];
+
+ /**
+ * Extra steps for installation, for things like DatabaseInstallers to modify
+ *
+ * @var array
+ */
+ protected $extraInstallSteps = [];
+
+ /**
+ * Known object cache types and the functions used to test for their existence.
+ *
+ * @var array
+ */
+ protected $objectCaches = [
+ 'xcache' => 'xcache_get',
+ 'apc' => 'apc_fetch',
+ 'apcu' => 'apcu_fetch',
+ 'wincache' => 'wincache_ucache_get'
+ ];
+
+ /**
+ * User rights profiles.
+ *
+ * @var array
+ */
+ public $rightsProfiles = [
+ 'wiki' => [],
+ 'no-anon' => [
+ '*' => [ 'edit' => false ]
+ ],
+ 'fishbowl' => [
+ '*' => [
+ 'createaccount' => false,
+ 'edit' => false,
+ ],
+ ],
+ 'private' => [
+ '*' => [
+ 'createaccount' => false,
+ 'edit' => false,
+ 'read' => false,
+ ],
+ ],
+ ];
+
+ /**
+ * License types.
+ *
+ * @var array
+ */
+ public $licenses = [
+ 'cc-by' => [
+ 'url' => 'https://creativecommons.org/licenses/by/4.0/',
+ 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by.png',
+ ],
+ 'cc-by-sa' => [
+ 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
+ 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by-sa.png',
+ ],
+ 'cc-by-nc-sa' => [
+ 'url' => 'https://creativecommons.org/licenses/by-nc-sa/4.0/',
+ 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-by-nc-sa.png',
+ ],
+ 'cc-0' => [
+ 'url' => 'https://creativecommons.org/publicdomain/zero/1.0/',
+ 'icon' => '$wgResourceBasePath/resources/assets/licenses/cc-0.png',
+ ],
+ 'gfdl' => [
+ 'url' => 'https://www.gnu.org/copyleft/fdl.html',
+ 'icon' => '$wgResourceBasePath/resources/assets/licenses/gnu-fdl.png',
+ ],
+ 'none' => [
+ 'url' => '',
+ 'icon' => '',
+ 'text' => ''
+ ],
+ 'cc-choose' => [
+ // Details will be filled in by the selector.
+ 'url' => '',
+ 'icon' => '',
+ 'text' => '',
+ ],
+ ];
+
+ /**
+ * URL to mediawiki-announce subscription
+ */
+ protected $mediaWikiAnnounceUrl =
+ 'https://lists.wikimedia.org/mailman/subscribe/mediawiki-announce';
+
+ /**
+ * Supported language codes for Mailman
+ */
+ protected $mediaWikiAnnounceLanguages = [
+ 'ca', 'cs', 'da', 'de', 'en', 'es', 'et', 'eu', 'fi', 'fr', 'hr', 'hu',
+ 'it', 'ja', 'ko', 'lt', 'nl', 'no', 'pl', 'pt', 'pt-br', 'ro', 'ru',
+ 'sl', 'sr', 'sv', 'tr', 'uk'
+ ];
+
+ /**
+ * UI interface for displaying a short message
+ * The parameters are like parameters to wfMessage().
+ * The messages will be in wikitext format, which will be converted to an
+ * output format such as HTML or text before being sent to the user.
+ * @param string $msg
+ */
+ abstract public function showMessage( $msg /*, ... */ );
+
+ /**
+ * Same as showMessage(), but for displaying errors
+ * @param string $msg
+ */
+ abstract public function showError( $msg /*, ... */ );
+
+ /**
+ * Show a message to the installing user by using a Status object
+ * @param Status $status
+ */
+ abstract public function showStatusMessage( Status $status );
+
+ /**
+ * Constructs a Config object that contains configuration settings that should be
+ * overwritten for the installation process.
+ *
+ * @since 1.27
+ *
+ * @param Config $baseConfig
+ *
+ * @return Config The config to use during installation.
+ */
+ public static function getInstallerConfig( Config $baseConfig ) {
+ $configOverrides = new HashConfig();
+
+ // disable (problematic) object cache types explicitly, preserving all other (working) ones
+ // bug T113843
+ $emptyCache = [ 'class' => 'EmptyBagOStuff' ];
+
+ $objectCaches = [
+ CACHE_NONE => $emptyCache,
+ CACHE_DB => $emptyCache,
+ CACHE_ANYTHING => $emptyCache,
+ CACHE_MEMCACHED => $emptyCache,
+ ] + $baseConfig->get( 'ObjectCaches' );
+
+ $configOverrides->set( 'ObjectCaches', $objectCaches );
+
+ // Load the installer's i18n.
+ $messageDirs = $baseConfig->get( 'MessagesDirs' );
+ $messageDirs['MediawikiInstaller'] = __DIR__ . '/i18n';
+
+ $configOverrides->set( 'MessagesDirs', $messageDirs );
+
+ $installerConfig = new MultiConfig( [ $configOverrides, $baseConfig ] );
+
+ // make sure we use the installer config as the main config
+ $configRegistry = $baseConfig->get( 'ConfigRegistry' );
+ $configRegistry['main'] = function () use ( $installerConfig ) {
+ return $installerConfig;
+ };
+
+ $configOverrides->set( 'ConfigRegistry', $configRegistry );
+
+ return $installerConfig;
+ }
+
+ /**
+ * Constructor, always call this from child classes.
+ */
+ public function __construct() {
+ global $wgMemc, $wgUser, $wgObjectCaches;
+
+ $defaultConfig = new GlobalVarConfig(); // all the stuff from DefaultSettings.php
+ $installerConfig = self::getInstallerConfig( $defaultConfig );
+
+ // Reset all services and inject config overrides
+ MediaWiki\MediaWikiServices::resetGlobalInstance( $installerConfig );
+
+ // Don't attempt to load user language options (T126177)
+ // This will be overridden in the web installer with the user-specified language
+ RequestContext::getMain()->setLanguage( 'en' );
+
+ // Disable the i18n cache
+ // TODO: manage LocalisationCache singleton in MediaWikiServices
+ Language::getLocalisationCache()->disableBackend();
+
+ // Disable all global services, since we don't have any configuration yet!
+ MediaWiki\MediaWikiServices::disableStorageBackend();
+
+ // Disable object cache (otherwise CACHE_ANYTHING will try CACHE_DB and
+ // SqlBagOStuff will then throw since we just disabled wfGetDB)
+ $wgObjectCaches = MediaWikiServices::getInstance()->getMainConfig()->get( 'ObjectCaches' );
+ $wgMemc = ObjectCache::getInstance( CACHE_NONE );
+
+ // Having a user with id = 0 safeguards us from DB access via User::loadOptions().
+ $wgUser = User::newFromId( 0 );
+ RequestContext::getMain()->setUser( $wgUser );
+
+ $this->settings = $this->internalDefaults;
+
+ foreach ( $this->defaultVarNames as $var ) {
+ $this->settings[$var] = $GLOBALS[$var];
+ }
+
+ $this->doEnvironmentPreps();
+
+ $this->compiledDBs = [];
+ foreach ( self::getDBTypes() as $type ) {
+ $installer = $this->getDBInstaller( $type );
+
+ if ( !$installer->isCompiled() ) {
+ continue;
+ }
+ $this->compiledDBs[] = $type;
+ }
+
+ $this->parserTitle = Title::newFromText( 'Installer' );
+ $this->parserOptions = new ParserOptions( $wgUser ); // language will be wrong :(
+ $this->parserOptions->setEditSection( false );
+ $this->parserOptions->setWrapOutputClass( false );
+ // Don't try to access DB before user language is initialised
+ $this->setParserLanguage( Language::factory( 'en' ) );
+ }
+
+ /**
+ * Get a list of known DB types.
+ *
+ * @return array
+ */
+ public static function getDBTypes() {
+ return self::$dbTypes;
+ }
+
+ /**
+ * Do initial checks of the PHP environment. Set variables according to
+ * the observed environment.
+ *
+ * It's possible that this may be called under the CLI SAPI, not the SAPI
+ * that the wiki will primarily run under. In that case, the subclass should
+ * initialise variables such as wgScriptPath, before calling this function.
+ *
+ * Under the web subclass, it can already be assumed that PHP 5+ is in use
+ * and that sessions are working.
+ *
+ * @return Status
+ */
+ public function doEnvironmentChecks() {
+ // Php version has already been checked by entry scripts
+ // Show message here for information purposes
+ if ( wfIsHHVM() ) {
+ $this->showMessage( 'config-env-hhvm', HHVM_VERSION );
+ } else {
+ $this->showMessage( 'config-env-php', PHP_VERSION );
+ }
+
+ $good = true;
+ // Must go here because an old version of PCRE can prevent other checks from completing
+ list( $pcreVersion ) = explode( ' ', PCRE_VERSION, 2 );
+ if ( version_compare( $pcreVersion, self::MINIMUM_PCRE_VERSION, '<' ) ) {
+ $this->showError( 'config-pcre-old', self::MINIMUM_PCRE_VERSION, $pcreVersion );
+ $good = false;
+ } else {
+ foreach ( $this->envChecks as $check ) {
+ $status = $this->$check();
+ if ( $status === false ) {
+ $good = false;
+ }
+ }
+ }
+
+ $this->setVar( '_Environment', $good );
+
+ return $good ? Status::newGood() : Status::newFatal( 'config-env-bad' );
+ }
+
+ public function doEnvironmentPreps() {
+ foreach ( $this->envPreps as $prep ) {
+ $this->$prep();
+ }
+ }
+
+ /**
+ * Set a MW configuration variable, or internal installer configuration variable.
+ *
+ * @param string $name
+ * @param mixed $value
+ */
+ public function setVar( $name, $value ) {
+ $this->settings[$name] = $value;
+ }
+
+ /**
+ * Get an MW configuration variable, or internal installer configuration variable.
+ * The defaults come from $GLOBALS (ultimately DefaultSettings.php).
+ * Installer variables are typically prefixed by an underscore.
+ *
+ * @param string $name
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function getVar( $name, $default = null ) {
+ if ( !isset( $this->settings[$name] ) ) {
+ return $default;
+ } else {
+ return $this->settings[$name];
+ }
+ }
+
+ /**
+ * Get a list of DBs supported by current PHP setup
+ *
+ * @return array
+ */
+ public function getCompiledDBs() {
+ return $this->compiledDBs;
+ }
+
+ /**
+ * Get the DatabaseInstaller class name for this type
+ *
+ * @param string $type database type ($wgDBtype)
+ * @return string Class name
+ * @since 1.30
+ */
+ public static function getDBInstallerClass( $type ) {
+ return ucfirst( $type ) . 'Installer';
+ }
+
+ /**
+ * Get an instance of DatabaseInstaller for the specified DB type.
+ *
+ * @param mixed $type DB installer for which is needed, false to use default.
+ *
+ * @return DatabaseInstaller
+ */
+ public function getDBInstaller( $type = false ) {
+ if ( !$type ) {
+ $type = $this->getVar( 'wgDBtype' );
+ }
+
+ $type = strtolower( $type );
+
+ if ( !isset( $this->dbInstallers[$type] ) ) {
+ $class = self::getDBInstallerClass( $type );
+ $this->dbInstallers[$type] = new $class( $this );
+ }
+
+ return $this->dbInstallers[$type];
+ }
+
+ /**
+ * Determine if LocalSettings.php exists. If it does, return its variables.
+ *
+ * @return array|false
+ */
+ public static function getExistingLocalSettings() {
+ global $IP;
+
+ // You might be wondering why this is here. Well if you don't do this
+ // then some poorly-formed extensions try to call their own classes
+ // after immediately registering them. We really need to get extension
+ // registration out of the global scope and into a real format.
+ // @see https://phabricator.wikimedia.org/T69440
+ global $wgAutoloadClasses;
+ $wgAutoloadClasses = [];
+
+ // @codingStandardsIgnoreStart
+ // LocalSettings.php should not call functions, except wfLoadSkin/wfLoadExtensions
+ // Define the required globals here, to ensure, the functions can do it work correctly.
+ global $wgExtensionDirectory, $wgStyleDirectory;
+ // @codingStandardsIgnoreEnd
+
+ MediaWiki\suppressWarnings();
+ $_lsExists = file_exists( "$IP/LocalSettings.php" );
+ MediaWiki\restoreWarnings();
+
+ if ( !$_lsExists ) {
+ return false;
+ }
+ unset( $_lsExists );
+
+ require "$IP/includes/DefaultSettings.php";
+ require "$IP/LocalSettings.php";
+
+ return get_defined_vars();
+ }
+
+ /**
+ * Get a fake password for sending back to the user in HTML.
+ * This is a security mechanism to avoid compromise of the password in the
+ * event of session ID compromise.
+ *
+ * @param string $realPassword
+ *
+ * @return string
+ */
+ public function getFakePassword( $realPassword ) {
+ return str_repeat( '*', strlen( $realPassword ) );
+ }
+
+ /**
+ * Set a variable which stores a password, except if the new value is a
+ * fake password in which case leave it as it is.
+ *
+ * @param string $name
+ * @param mixed $value
+ */
+ public function setPassword( $name, $value ) {
+ if ( !preg_match( '/^\*+$/', $value ) ) {
+ $this->setVar( $name, $value );
+ }
+ }
+
+ /**
+ * On POSIX systems return the primary group of the webserver we're running under.
+ * On other systems just returns null.
+ *
+ * This is used to advice the user that he should chgrp his mw-config/data/images directory as the
+ * webserver user before he can install.
+ *
+ * Public because SqliteInstaller needs it, and doesn't subclass Installer.
+ *
+ * @return mixed
+ */
+ public static function maybeGetWebserverPrimaryGroup() {
+ if ( !function_exists( 'posix_getegid' ) || !function_exists( 'posix_getpwuid' ) ) {
+ # I don't know this, this isn't UNIX.
+ return null;
+ }
+
+ # posix_getegid() *not* getmygid() because we want the group of the webserver,
+ # not whoever owns the current script.
+ $gid = posix_getegid();
+ $group = posix_getpwuid( $gid )['name'];
+
+ return $group;
+ }
+
+ /**
+ * Convert wikitext $text to HTML.
+ *
+ * This is potentially error prone since many parser features require a complete
+ * installed MW database. The solution is to just not use those features when you
+ * write your messages. This appears to work well enough. Basic formatting and
+ * external links work just fine.
+ *
+ * But in case a translator decides to throw in a "#ifexist" or internal link or
+ * whatever, this function is guarded to catch the attempted DB access and to present
+ * some fallback text.
+ *
+ * @param string $text
+ * @param bool $lineStart
+ * @return string
+ */
+ public function parse( $text, $lineStart = false ) {
+ global $wgParser;
+
+ try {
+ $out = $wgParser->parse( $text, $this->parserTitle, $this->parserOptions, $lineStart );
+ $html = $out->getText();
+ } catch ( MediaWiki\Services\ServiceDisabledException $e ) {
+ $html = '<!--DB access attempted during parse--> ' . htmlspecialchars( $text );
+
+ if ( !empty( $this->debug ) ) {
+ $html .= "<!--\n" . $e->getTraceAsString() . "\n-->";
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * @return ParserOptions
+ */
+ public function getParserOptions() {
+ return $this->parserOptions;
+ }
+
+ public function disableLinkPopups() {
+ $this->parserOptions->setExternalLinkTarget( false );
+ }
+
+ public function restoreLinkPopups() {
+ global $wgExternalLinkTarget;
+ $this->parserOptions->setExternalLinkTarget( $wgExternalLinkTarget );
+ }
+
+ /**
+ * Install step which adds a row to the site_stats table with appropriate
+ * initial values.
+ *
+ * @param DatabaseInstaller $installer
+ *
+ * @return Status
+ */
+ public function populateSiteStats( DatabaseInstaller $installer ) {
+ $status = $installer->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $status->value->insert(
+ 'site_stats',
+ [
+ 'ss_row_id' => 1,
+ 'ss_total_edits' => 0,
+ 'ss_good_articles' => 0,
+ 'ss_total_pages' => 0,
+ 'ss_users' => 0,
+ 'ss_active_users' => 0,
+ 'ss_images' => 0
+ ],
+ __METHOD__, 'IGNORE'
+ );
+
+ return Status::newGood();
+ }
+
+ /**
+ * Environment check for DB types.
+ * @return bool
+ */
+ protected function envCheckDB() {
+ global $wgLang;
+
+ $allNames = [];
+
+ // Messages: config-type-mysql, config-type-postgres, config-type-oracle,
+ // config-type-sqlite
+ foreach ( self::getDBTypes() as $name ) {
+ $allNames[] = wfMessage( "config-type-$name" )->text();
+ }
+
+ $databases = $this->getCompiledDBs();
+
+ $databases = array_flip( $databases );
+ foreach ( array_keys( $databases ) as $db ) {
+ $installer = $this->getDBInstaller( $db );
+ $status = $installer->checkPrerequisites();
+ if ( !$status->isGood() ) {
+ $this->showStatusMessage( $status );
+ }
+ if ( !$status->isOK() ) {
+ unset( $databases[$db] );
+ }
+ }
+ $databases = array_flip( $databases );
+ if ( !$databases ) {
+ $this->showError( 'config-no-db', $wgLang->commaList( $allNames ), count( $allNames ) );
+
+ // @todo FIXME: This only works for the web installer!
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Some versions of libxml+PHP break < and > encoding horribly
+ * @return bool
+ */
+ protected function envCheckBrokenXML() {
+ $test = new PhpXmlBugTester();
+ if ( !$test->ok ) {
+ $this->showError( 'config-brokenlibxml' );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Environment check for the PCRE module.
+ *
+ * @note If this check were to fail, the parser would
+ * probably throw an exception before the result
+ * of this check is shown to the user.
+ * @return bool
+ */
+ protected function envCheckPCRE() {
+ MediaWiki\suppressWarnings();
+ $regexd = preg_replace( '/[\x{0430}-\x{04FF}]/iu', '', '-АБВГД-' );
+ // Need to check for \p support too, as PCRE can be compiled
+ // with utf8 support, but not unicode property support.
+ // check that \p{Zs} (space separators) matches
+ // U+3000 (Ideographic space)
+ $regexprop = preg_replace( '/\p{Zs}/u', '', "-\xE3\x80\x80-" );
+ MediaWiki\restoreWarnings();
+ if ( $regexd != '--' || $regexprop != '--' ) {
+ $this->showError( 'config-pcre-no-utf8' );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Environment check for available memory.
+ * @return bool
+ */
+ protected function envCheckMemory() {
+ $limit = ini_get( 'memory_limit' );
+
+ if ( !$limit || $limit == -1 ) {
+ return true;
+ }
+
+ $n = wfShorthandToInteger( $limit );
+
+ if ( $n < $this->minMemorySize * 1024 * 1024 ) {
+ $newLimit = "{$this->minMemorySize}M";
+
+ if ( ini_set( "memory_limit", $newLimit ) === false ) {
+ $this->showMessage( 'config-memory-bad', $limit );
+ } else {
+ $this->showMessage( 'config-memory-raised', $limit, $newLimit );
+ $this->setVar( '_RaiseMemory', true );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Environment check for compiled object cache types.
+ */
+ protected function envCheckCache() {
+ $caches = [];
+ foreach ( $this->objectCaches as $name => $function ) {
+ if ( function_exists( $function ) ) {
+ if ( $name == 'xcache' && !wfIniGetBool( 'xcache.var_size' ) ) {
+ continue;
+ }
+ $caches[$name] = true;
+ }
+ }
+
+ if ( !$caches ) {
+ $key = 'config-no-cache-apcu';
+ $this->showMessage( $key );
+ }
+
+ $this->setVar( '_Caches', $caches );
+ }
+
+ /**
+ * Scare user to death if they have mod_security or mod_security2
+ * @return bool
+ */
+ protected function envCheckModSecurity() {
+ if ( self::apacheModulePresent( 'mod_security' )
+ || self::apacheModulePresent( 'mod_security2' ) ) {
+ $this->showMessage( 'config-mod-security' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Search for GNU diff3.
+ * @return bool
+ */
+ protected function envCheckDiff3() {
+ $names = [ "gdiff3", "diff3", "diff3.exe" ];
+ $versionInfo = [ '$1 --version 2>&1', 'GNU diffutils' ];
+
+ $diff3 = self::locateExecutableInDefaultPaths( $names, $versionInfo );
+
+ if ( $diff3 ) {
+ $this->setVar( 'wgDiff3', $diff3 );
+ } else {
+ $this->setVar( 'wgDiff3', false );
+ $this->showMessage( 'config-diff3-bad' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Environment check for ImageMagick and GD.
+ * @return bool
+ */
+ protected function envCheckGraphics() {
+ $names = [ wfIsWindows() ? 'convert.exe' : 'convert' ];
+ $versionInfo = [ '$1 -version', 'ImageMagick' ];
+ $convert = self::locateExecutableInDefaultPaths( $names, $versionInfo );
+
+ $this->setVar( 'wgImageMagickConvertCommand', '' );
+ if ( $convert ) {
+ $this->setVar( 'wgImageMagickConvertCommand', $convert );
+ $this->showMessage( 'config-imagemagick', $convert );
+
+ return true;
+ } elseif ( function_exists( 'imagejpeg' ) ) {
+ $this->showMessage( 'config-gd' );
+ } else {
+ $this->showMessage( 'config-no-scaling' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Search for git.
+ *
+ * @since 1.22
+ * @return bool
+ */
+ protected function envCheckGit() {
+ $names = [ wfIsWindows() ? 'git.exe' : 'git' ];
+ $versionInfo = [ '$1 --version', 'git version' ];
+
+ $git = self::locateExecutableInDefaultPaths( $names, $versionInfo );
+
+ if ( $git ) {
+ $this->setVar( 'wgGitBin', $git );
+ $this->showMessage( 'config-git', $git );
+ } else {
+ $this->setVar( 'wgGitBin', false );
+ $this->showMessage( 'config-git-bad' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Environment check to inform user which server we've assumed.
+ *
+ * @return bool
+ */
+ protected function envCheckServer() {
+ $server = $this->envGetDefaultServer();
+ if ( $server !== null ) {
+ $this->showMessage( 'config-using-server', $server );
+ }
+ return true;
+ }
+
+ /**
+ * Environment check to inform user which paths we've assumed.
+ *
+ * @return bool
+ */
+ protected function envCheckPath() {
+ $this->showMessage(
+ 'config-using-uri',
+ $this->getVar( 'wgServer' ),
+ $this->getVar( 'wgScriptPath' )
+ );
+ return true;
+ }
+
+ /**
+ * Environment check for preferred locale in shell
+ * @return bool
+ */
+ protected function envCheckShellLocale() {
+ $os = php_uname( 's' );
+ $supported = [ 'Linux', 'SunOS', 'HP-UX', 'Darwin' ]; # Tested these
+
+ if ( !in_array( $os, $supported ) ) {
+ return true;
+ }
+
+ # Get a list of available locales.
+ $ret = false;
+ $lines = wfShellExec( '/usr/bin/locale -a', $ret );
+
+ if ( $ret ) {
+ return true;
+ }
+
+ $lines = array_map( 'trim', explode( "\n", $lines ) );
+ $candidatesByLocale = [];
+ $candidatesByLang = [];
+
+ foreach ( $lines as $line ) {
+ if ( $line === '' ) {
+ continue;
+ }
+
+ if ( !preg_match( '/^([a-zA-Z]+)(_[a-zA-Z]+|)\.(utf8|UTF-8)(@[a-zA-Z_]*|)$/i', $line, $m ) ) {
+ continue;
+ }
+
+ list( , $lang, , , ) = $m;
+
+ $candidatesByLocale[$m[0]] = $m;
+ $candidatesByLang[$lang][] = $m;
+ }
+
+ # Try the current value of LANG.
+ if ( isset( $candidatesByLocale[getenv( 'LANG' )] ) ) {
+ $this->setVar( 'wgShellLocale', getenv( 'LANG' ) );
+
+ return true;
+ }
+
+ # Try the most common ones.
+ $commonLocales = [ 'C.UTF-8', 'en_US.UTF-8', 'en_US.utf8', 'de_DE.UTF-8', 'de_DE.utf8' ];
+ foreach ( $commonLocales as $commonLocale ) {
+ if ( isset( $candidatesByLocale[$commonLocale] ) ) {
+ $this->setVar( 'wgShellLocale', $commonLocale );
+
+ return true;
+ }
+ }
+
+ # Is there an available locale in the Wiki's language?
+ $wikiLang = $this->getVar( 'wgLanguageCode' );
+
+ if ( isset( $candidatesByLang[$wikiLang] ) ) {
+ $m = reset( $candidatesByLang[$wikiLang] );
+ $this->setVar( 'wgShellLocale', $m[0] );
+
+ return true;
+ }
+
+ # Are there any at all?
+ if ( count( $candidatesByLocale ) ) {
+ $m = reset( $candidatesByLocale );
+ $this->setVar( 'wgShellLocale', $m[0] );
+
+ return true;
+ }
+
+ # Give up.
+ return true;
+ }
+
+ /**
+ * Environment check for the permissions of the uploads directory
+ * @return bool
+ */
+ protected function envCheckUploadsDirectory() {
+ global $IP;
+
+ $dir = $IP . '/images/';
+ $url = $this->getVar( 'wgServer' ) . $this->getVar( 'wgScriptPath' ) . '/images/';
+ $safe = !$this->dirIsExecutable( $dir, $url );
+
+ if ( !$safe ) {
+ $this->showMessage( 'config-uploads-not-safe', $dir );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if suhosin.get.max_value_length is set, and if so generate
+ * a warning because it decreases ResourceLoader performance.
+ * @return bool
+ */
+ protected function envCheckSuhosinMaxValueLength() {
+ $maxValueLength = ini_get( 'suhosin.get.max_value_length' );
+ if ( $maxValueLength > 0 && $maxValueLength < 1024 ) {
+ // Only warn if the value is below the sane 1024
+ $this->showMessage( 'config-suhosin-max-value-length', $maxValueLength );
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if we're running on 64 bit or not. 32 bit is becoming increasingly
+ * hard to support, so let's at least warn people.
+ *
+ * @return bool
+ */
+ protected function envCheck64Bit() {
+ if ( PHP_INT_SIZE == 4 ) {
+ $this->showMessage( 'config-using-32bit' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Convert a hex string representing a Unicode code point to that code point.
+ * @param string $c
+ * @return string|false
+ */
+ protected function unicodeChar( $c ) {
+ $c = hexdec( $c );
+ if ( $c <= 0x7F ) {
+ return chr( $c );
+ } elseif ( $c <= 0x7FF ) {
+ return chr( 0xC0 | $c >> 6 ) . chr( 0x80 | $c & 0x3F );
+ } elseif ( $c <= 0xFFFF ) {
+ return chr( 0xE0 | $c >> 12 ) . chr( 0x80 | $c >> 6 & 0x3F ) .
+ chr( 0x80 | $c & 0x3F );
+ } elseif ( $c <= 0x10FFFF ) {
+ return chr( 0xF0 | $c >> 18 ) . chr( 0x80 | $c >> 12 & 0x3F ) .
+ chr( 0x80 | $c >> 6 & 0x3F ) .
+ chr( 0x80 | $c & 0x3F );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Check the libicu version
+ */
+ protected function envCheckLibicu() {
+ /**
+ * This needs to be updated something that the latest libicu
+ * will properly normalize. This normalization was found at
+ * http://www.unicode.org/versions/Unicode5.2.0/#Character_Additions
+ * Note that we use the hex representation to create the code
+ * points in order to avoid any Unicode-destroying during transit.
+ */
+ $not_normal_c = $this->unicodeChar( "FA6C" );
+ $normal_c = $this->unicodeChar( "242EE" );
+
+ $useNormalizer = 'php';
+ $needsUpdate = false;
+
+ if ( function_exists( 'normalizer_normalize' ) ) {
+ $useNormalizer = 'intl';
+ $intl = normalizer_normalize( $not_normal_c, Normalizer::FORM_C );
+ if ( $intl !== $normal_c ) {
+ $needsUpdate = true;
+ }
+ }
+
+ // Uses messages 'config-unicode-using-php' and 'config-unicode-using-intl'
+ if ( $useNormalizer === 'php' ) {
+ $this->showMessage( 'config-unicode-pure-php-warning' );
+ } else {
+ $this->showMessage( 'config-unicode-using-' . $useNormalizer );
+ if ( $needsUpdate ) {
+ $this->showMessage( 'config-unicode-update-warning' );
+ }
+ }
+ }
+
+ /**
+ * Environment prep for the server hostname.
+ */
+ protected function envPrepServer() {
+ $server = $this->envGetDefaultServer();
+ if ( $server !== null ) {
+ $this->setVar( 'wgServer', $server );
+ }
+ }
+
+ /**
+ * Helper function to be called from envPrepServer()
+ * @return string
+ */
+ abstract protected function envGetDefaultServer();
+
+ /**
+ * Environment prep for setting $IP and $wgScriptPath.
+ */
+ protected function envPrepPath() {
+ global $IP;
+ $IP = dirname( dirname( __DIR__ ) );
+ $this->setVar( 'IP', $IP );
+ }
+
+ /**
+ * Get an array of likely places we can find executables. Check a bunch
+ * of known Unix-like defaults, as well as the PATH environment variable
+ * (which should maybe make it work for Windows?)
+ *
+ * @return array
+ */
+ protected static function getPossibleBinPaths() {
+ return array_merge(
+ [ '/usr/bin', '/usr/local/bin', '/opt/csw/bin',
+ '/usr/gnu/bin', '/usr/sfw/bin', '/sw/bin', '/opt/local/bin' ],
+ explode( PATH_SEPARATOR, getenv( 'PATH' ) )
+ );
+ }
+
+ /**
+ * Search a path for any of the given executable names. Returns the
+ * executable name if found. Also checks the version string returned
+ * by each executable.
+ *
+ * Used only by environment checks.
+ *
+ * @param string $path Path to search
+ * @param array $names Array of executable names
+ * @param array|bool $versionInfo False or array with two members:
+ * 0 => Command to run for version check, with $1 for the full executable name
+ * 1 => String to compare the output with
+ *
+ * If $versionInfo is not false, only executables with a version
+ * matching $versionInfo[1] will be returned.
+ * @return bool|string
+ */
+ public static function locateExecutable( $path, $names, $versionInfo = false ) {
+ if ( !is_array( $names ) ) {
+ $names = [ $names ];
+ }
+
+ foreach ( $names as $name ) {
+ $command = $path . DIRECTORY_SEPARATOR . $name;
+
+ MediaWiki\suppressWarnings();
+ $file_exists = is_executable( $command );
+ MediaWiki\restoreWarnings();
+
+ if ( $file_exists ) {
+ if ( !$versionInfo ) {
+ return $command;
+ }
+
+ $file = str_replace( '$1', wfEscapeShellArg( $command ), $versionInfo[0] );
+ if ( strstr( wfShellExec( $file ), $versionInfo[1] ) !== false ) {
+ return $command;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Same as locateExecutable(), but checks in getPossibleBinPaths() by default
+ * @see locateExecutable()
+ * @param array $names Array of possible names.
+ * @param array|bool $versionInfo Default: false or array with two members:
+ * 0 => Command to run for version check, with $1 for the full executable name
+ * 1 => String to compare the output with
+ *
+ * If $versionInfo is not false, only executables with a version
+ * matching $versionInfo[1] will be returned.
+ * @return bool|string
+ */
+ public static function locateExecutableInDefaultPaths( $names, $versionInfo = false ) {
+ foreach ( self::getPossibleBinPaths() as $path ) {
+ $exe = self::locateExecutable( $path, $names, $versionInfo );
+ if ( $exe !== false ) {
+ return $exe;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if scripts located in the given directory can be executed via the given URL.
+ *
+ * Used only by environment checks.
+ * @param string $dir
+ * @param string $url
+ * @return bool|int|string
+ */
+ public function dirIsExecutable( $dir, $url ) {
+ $scriptTypes = [
+ 'php' => [
+ "<?php echo 'ex' . 'ec';",
+ "#!/var/env php5\n<?php echo 'ex' . 'ec';",
+ ],
+ ];
+
+ // it would be good to check other popular languages here, but it'll be slow.
+
+ MediaWiki\suppressWarnings();
+
+ foreach ( $scriptTypes as $ext => $contents ) {
+ foreach ( $contents as $source ) {
+ $file = 'exectest.' . $ext;
+
+ if ( !file_put_contents( $dir . $file, $source ) ) {
+ break;
+ }
+
+ try {
+ $text = Http::get( $url . $file, [ 'timeout' => 3 ], __METHOD__ );
+ } catch ( Exception $e ) {
+ // Http::get throws with allow_url_fopen = false and no curl extension.
+ $text = null;
+ }
+ unlink( $dir . $file );
+
+ if ( $text == 'exec' ) {
+ MediaWiki\restoreWarnings();
+
+ return $ext;
+ }
+ }
+ }
+
+ MediaWiki\restoreWarnings();
+
+ return false;
+ }
+
+ /**
+ * Checks for presence of an Apache module. Works only if PHP is running as an Apache module, too.
+ *
+ * @param string $moduleName Name of module to check.
+ * @return bool
+ */
+ public static function apacheModulePresent( $moduleName ) {
+ if ( function_exists( 'apache_get_modules' ) && in_array( $moduleName, apache_get_modules() ) ) {
+ return true;
+ }
+ // try it the hard way
+ ob_start();
+ phpinfo( INFO_MODULES );
+ $info = ob_get_clean();
+
+ return strpos( $info, $moduleName ) !== false;
+ }
+
+ /**
+ * ParserOptions are constructed before we determined the language, so fix it
+ *
+ * @param Language $lang
+ */
+ public function setParserLanguage( $lang ) {
+ $this->parserOptions->setTargetLanguage( $lang );
+ $this->parserOptions->setUserLang( $lang );
+ }
+
+ /**
+ * Overridden by WebInstaller to provide lastPage parameters.
+ * @param string $page
+ * @return string
+ */
+ protected function getDocUrl( $page ) {
+ return "{$_SERVER['PHP_SELF']}?page=" . urlencode( $page );
+ }
+
+ /**
+ * Finds extensions that follow the format /$directory/Name/Name.php,
+ * and returns an array containing the value for 'Name' for each found extension.
+ *
+ * Reasonable values for $directory include 'extensions' (the default) and 'skins'.
+ *
+ * @param string $directory Directory to search in
+ * @return array [ $extName => [ 'screenshots' => [ '...' ] ]
+ */
+ public function findExtensions( $directory = 'extensions' ) {
+ if ( $this->getVar( 'IP' ) === null ) {
+ return [];
+ }
+
+ $extDir = $this->getVar( 'IP' ) . '/' . $directory;
+ if ( !is_readable( $extDir ) || !is_dir( $extDir ) ) {
+ return [];
+ }
+
+ // extensions -> extension.json, skins -> skin.json
+ $jsonFile = substr( $directory, 0, strlen( $directory ) - 1 ) . '.json';
+
+ $dh = opendir( $extDir );
+ $exts = [];
+ while ( ( $file = readdir( $dh ) ) !== false ) {
+ if ( !is_dir( "$extDir/$file" ) ) {
+ continue;
+ }
+ if ( file_exists( "$extDir/$file/$jsonFile" ) || file_exists( "$extDir/$file/$file.php" ) ) {
+ // Extension exists. Now see if there are screenshots
+ $exts[$file] = [];
+ if ( is_dir( "$extDir/$file/screenshots" ) ) {
+ $paths = glob( "$extDir/$file/screenshots/*.png" );
+ foreach ( $paths as $path ) {
+ $exts[$file]['screenshots'][] = str_replace( $extDir, "../$directory", $path );
+ }
+
+ }
+ }
+ }
+ closedir( $dh );
+ uksort( $exts, 'strnatcasecmp' );
+
+ return $exts;
+ }
+
+ /**
+ * Returns a default value to be used for $wgDefaultSkin: normally the one set in DefaultSettings,
+ * but will fall back to another if the default skin is missing and some other one is present
+ * instead.
+ *
+ * @param string[] $skinNames Names of installed skins.
+ * @return string
+ */
+ public function getDefaultSkin( array $skinNames ) {
+ $defaultSkin = $GLOBALS['wgDefaultSkin'];
+ if ( !$skinNames || in_array( $defaultSkin, $skinNames ) ) {
+ return $defaultSkin;
+ } else {
+ return $skinNames[0];
+ }
+ }
+
+ /**
+ * Installs the auto-detected extensions.
+ *
+ * @return Status
+ */
+ protected function includeExtensions() {
+ global $IP;
+ $exts = $this->getVar( '_Extensions' );
+ $IP = $this->getVar( 'IP' );
+
+ /**
+ * We need to include DefaultSettings before including extensions to avoid
+ * warnings about unset variables. However, the only thing we really
+ * want here is $wgHooks['LoadExtensionSchemaUpdates']. This won't work
+ * if the extension has hidden hook registration in $wgExtensionFunctions,
+ * but we're not opening that can of worms
+ * @see https://phabricator.wikimedia.org/T28857
+ */
+ global $wgAutoloadClasses;
+ $wgAutoloadClasses = [];
+ $queue = [];
+
+ require "$IP/includes/DefaultSettings.php";
+
+ foreach ( $exts as $e ) {
+ if ( file_exists( "$IP/extensions/$e/extension.json" ) ) {
+ $queue["$IP/extensions/$e/extension.json"] = 1;
+ } else {
+ require_once "$IP/extensions/$e/$e.php";
+ }
+ }
+
+ $registry = new ExtensionRegistry();
+ $data = $registry->readFromQueue( $queue );
+ $wgAutoloadClasses += $data['autoload'];
+
+ $hooksWeWant = isset( $wgHooks['LoadExtensionSchemaUpdates'] ) ?
+ /** @suppress PhanUndeclaredVariable $wgHooks is set by DefaultSettings */
+ $wgHooks['LoadExtensionSchemaUpdates'] : [];
+
+ if ( isset( $data['globals']['wgHooks']['LoadExtensionSchemaUpdates'] ) ) {
+ $hooksWeWant = array_merge_recursive(
+ $hooksWeWant,
+ $data['globals']['wgHooks']['LoadExtensionSchemaUpdates']
+ );
+ }
+ // Unset everyone else's hooks. Lord knows what someone might be doing
+ // in ParserFirstCallInit (see T29171)
+ $GLOBALS['wgHooks'] = [ 'LoadExtensionSchemaUpdates' => $hooksWeWant ];
+
+ return Status::newGood();
+ }
+
+ /**
+ * Get an array of install steps. Should always be in the format of
+ * [
+ * 'name' => 'someuniquename',
+ * 'callback' => [ $obj, 'method' ],
+ * ]
+ * There must be a config-install-$name message defined per step, which will
+ * be shown on install.
+ *
+ * @param DatabaseInstaller $installer DatabaseInstaller so we can make callbacks
+ * @return array
+ */
+ protected function getInstallSteps( DatabaseInstaller $installer ) {
+ $coreInstallSteps = [
+ [ 'name' => 'database', 'callback' => [ $installer, 'setupDatabase' ] ],
+ [ 'name' => 'tables', 'callback' => [ $installer, 'createTables' ] ],
+ [ 'name' => 'interwiki', 'callback' => [ $installer, 'populateInterwikiTable' ] ],
+ [ 'name' => 'stats', 'callback' => [ $this, 'populateSiteStats' ] ],
+ [ 'name' => 'keys', 'callback' => [ $this, 'generateKeys' ] ],
+ [ 'name' => 'updates', 'callback' => [ $installer, 'insertUpdateKeys' ] ],
+ [ 'name' => 'sysop', 'callback' => [ $this, 'createSysop' ] ],
+ [ 'name' => 'mainpage', 'callback' => [ $this, 'createMainpage' ] ],
+ ];
+
+ // Build the array of install steps starting from the core install list,
+ // then adding any callbacks that wanted to attach after a given step
+ foreach ( $coreInstallSteps as $step ) {
+ $this->installSteps[] = $step;
+ if ( isset( $this->extraInstallSteps[$step['name']] ) ) {
+ $this->installSteps = array_merge(
+ $this->installSteps,
+ $this->extraInstallSteps[$step['name']]
+ );
+ }
+ }
+
+ // Prepend any steps that want to be at the beginning
+ if ( isset( $this->extraInstallSteps['BEGINNING'] ) ) {
+ $this->installSteps = array_merge(
+ $this->extraInstallSteps['BEGINNING'],
+ $this->installSteps
+ );
+ }
+
+ // Extensions should always go first, chance to tie into hooks and such
+ if ( count( $this->getVar( '_Extensions' ) ) ) {
+ array_unshift( $this->installSteps,
+ [ 'name' => 'extensions', 'callback' => [ $this, 'includeExtensions' ] ]
+ );
+ $this->installSteps[] = [
+ 'name' => 'extension-tables',
+ 'callback' => [ $installer, 'createExtensionTables' ]
+ ];
+ }
+
+ return $this->installSteps;
+ }
+
+ /**
+ * Actually perform the installation.
+ *
+ * @param callable $startCB A callback array for the beginning of each step
+ * @param callable $endCB A callback array for the end of each step
+ *
+ * @return array Array of Status objects
+ */
+ public function performInstallation( $startCB, $endCB ) {
+ $installResults = [];
+ $installer = $this->getDBInstaller();
+ $installer->preInstall();
+ $steps = $this->getInstallSteps( $installer );
+ foreach ( $steps as $stepObj ) {
+ $name = $stepObj['name'];
+ call_user_func_array( $startCB, [ $name ] );
+
+ // Perform the callback step
+ $status = call_user_func( $stepObj['callback'], $installer );
+
+ // Output and save the results
+ call_user_func( $endCB, $name, $status );
+ $installResults[$name] = $status;
+
+ // If we've hit some sort of fatal, we need to bail.
+ // Callback already had a chance to do output above.
+ if ( !$status->isOk() ) {
+ break;
+ }
+ }
+ if ( $status->isOk() ) {
+ $this->setVar( '_InstallDone', true );
+ }
+
+ return $installResults;
+ }
+
+ /**
+ * Generate $wgSecretKey. Will warn if we had to use an insecure random source.
+ *
+ * @return Status
+ */
+ public function generateKeys() {
+ $keys = [ 'wgSecretKey' => 64 ];
+ if ( strval( $this->getVar( 'wgUpgradeKey' ) ) === '' ) {
+ $keys['wgUpgradeKey'] = 16;
+ }
+
+ return $this->doGenerateKeys( $keys );
+ }
+
+ /**
+ * Generate a secret value for variables using our CryptRand generator.
+ * Produce a warning if the random source was insecure.
+ *
+ * @param array $keys
+ * @return Status
+ */
+ protected function doGenerateKeys( $keys ) {
+ $status = Status::newGood();
+
+ $strong = true;
+ foreach ( $keys as $name => $length ) {
+ $secretKey = MWCryptRand::generateHex( $length, true );
+ if ( !MWCryptRand::wasStrong() ) {
+ $strong = false;
+ }
+
+ $this->setVar( $name, $secretKey );
+ }
+
+ if ( !$strong ) {
+ $names = array_keys( $keys );
+ $names = preg_replace( '/^(.*)$/', '\$$1', $names );
+ global $wgLang;
+ $status->warning( 'config-insecure-keys', $wgLang->listToText( $names ), count( $names ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Create the first user account, grant it sysop and bureaucrat rights
+ *
+ * @return Status
+ */
+ protected function createSysop() {
+ $name = $this->getVar( '_AdminName' );
+ $user = User::newFromName( $name );
+
+ if ( !$user ) {
+ // We should've validated this earlier anyway!
+ return Status::newFatal( 'config-admin-error-user', $name );
+ }
+
+ if ( $user->idForName() == 0 ) {
+ $user->addToDatabase();
+
+ try {
+ $user->setPassword( $this->getVar( '_AdminPassword' ) );
+ } catch ( PasswordError $pwe ) {
+ return Status::newFatal( 'config-admin-error-password', $name, $pwe->getMessage() );
+ }
+
+ $user->addGroup( 'sysop' );
+ $user->addGroup( 'bureaucrat' );
+ if ( $this->getVar( '_AdminEmail' ) ) {
+ $user->setEmail( $this->getVar( '_AdminEmail' ) );
+ }
+ $user->saveSettings();
+
+ // Update user count
+ $ssUpdate = new SiteStatsUpdate( 0, 0, 0, 0, 1 );
+ $ssUpdate->doUpdate();
+ }
+ $status = Status::newGood();
+
+ if ( $this->getVar( '_Subscribe' ) && $this->getVar( '_AdminEmail' ) ) {
+ $this->subscribeToMediaWikiAnnounce( $status );
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param Status $s
+ */
+ private function subscribeToMediaWikiAnnounce( Status $s ) {
+ $params = [
+ 'email' => $this->getVar( '_AdminEmail' ),
+ 'language' => 'en',
+ 'digest' => 0
+ ];
+
+ // Mailman doesn't support as many languages as we do, so check to make
+ // sure their selected language is available
+ $myLang = $this->getVar( '_UserLang' );
+ if ( in_array( $myLang, $this->mediaWikiAnnounceLanguages ) ) {
+ $myLang = $myLang == 'pt-br' ? 'pt_BR' : $myLang; // rewrite to Mailman's pt_BR
+ $params['language'] = $myLang;
+ }
+
+ if ( MWHttpRequest::canMakeRequests() ) {
+ $res = MWHttpRequest::factory( $this->mediaWikiAnnounceUrl,
+ [ 'method' => 'POST', 'postData' => $params ], __METHOD__ )->execute();
+ if ( !$res->isOK() ) {
+ $s->warning( 'config-install-subscribe-fail', $res->getMessage() );
+ }
+ } else {
+ $s->warning( 'config-install-subscribe-notpossible' );
+ }
+ }
+
+ /**
+ * Insert Main Page with default content.
+ *
+ * @param DatabaseInstaller $installer
+ * @return Status
+ */
+ protected function createMainpage( DatabaseInstaller $installer ) {
+ $status = Status::newGood();
+ $title = Title::newMainPage();
+ if ( $title->exists() ) {
+ $status->warning( 'config-install-mainpage-exists' );
+ return $status;
+ }
+ try {
+ $page = WikiPage::factory( $title );
+ $content = new WikitextContent(
+ wfMessage( 'mainpagetext' )->inContentLanguage()->text() . "\n\n" .
+ wfMessage( 'mainpagedocfooter' )->inContentLanguage()->text()
+ );
+
+ $status = $page->doEditContent( $content,
+ '',
+ EDIT_NEW,
+ false,
+ User::newFromName( 'MediaWiki default' )
+ );
+ } catch ( Exception $e ) {
+ // using raw, because $wgShowExceptionDetails can not be set yet
+ $status->fatal( 'config-install-mainpage-failed', $e->getMessage() );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Override the necessary bits of the config to run an installation.
+ */
+ public static function overrideConfig() {
+ // Use PHP's built-in session handling, since MediaWiki's
+ // SessionHandler can't work before we have an object cache set up.
+ define( 'MW_NO_SESSION_HANDLER', 1 );
+
+ // Don't access the database
+ $GLOBALS['wgUseDatabaseMessages'] = false;
+ // Don't cache langconv tables
+ $GLOBALS['wgLanguageConverterCacheType'] = CACHE_NONE;
+ // Debug-friendly
+ $GLOBALS['wgShowExceptionDetails'] = true;
+ // Don't break forms
+ $GLOBALS['wgExternalLinkTarget'] = '_blank';
+
+ // Extended debugging
+ $GLOBALS['wgShowSQLErrors'] = true;
+ $GLOBALS['wgShowDBErrorBacktrace'] = true;
+
+ // Allow multiple ob_flush() calls
+ $GLOBALS['wgDisableOutputCompression'] = true;
+
+ // Use a sensible cookie prefix (not my_wiki)
+ $GLOBALS['wgCookiePrefix'] = 'mw_installer';
+
+ // Some of the environment checks make shell requests, remove limits
+ $GLOBALS['wgMaxShellMemory'] = 0;
+
+ // Override the default CookieSessionProvider with a dummy
+ // implementation that won't stomp on PHP's cookies.
+ $GLOBALS['wgSessionProviders'] = [
+ [
+ 'class' => 'InstallerSessionProvider',
+ 'args' => [ [
+ 'priority' => 1,
+ ] ]
+ ]
+ ];
+
+ // Don't try to use any object cache for SessionManager either.
+ $GLOBALS['wgSessionCacheType'] = CACHE_NONE;
+ }
+
+ /**
+ * Add an installation step following the given step.
+ *
+ * @param callable $callback A valid installation callback array, in this form:
+ * [ 'name' => 'some-unique-name', 'callback' => [ $obj, 'function' ] ];
+ * @param string $findStep The step to find. Omit to put the step at the beginning
+ */
+ public function addInstallStep( $callback, $findStep = 'BEGINNING' ) {
+ $this->extraInstallSteps[$findStep][] = $callback;
+ }
+
+ /**
+ * Disable the time limit for execution.
+ * Some long-running pages (Install, Upgrade) will want to do this
+ */
+ protected function disableTimeLimit() {
+ MediaWiki\suppressWarnings();
+ set_time_limit( 0 );
+ MediaWiki\restoreWarnings();
+ }
+}
diff --git a/www/wiki/includes/installer/InstallerOverrides.php b/www/wiki/includes/installer/InstallerOverrides.php
new file mode 100644
index 00000000..eba3a20d
--- /dev/null
+++ b/www/wiki/includes/installer/InstallerOverrides.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * MediaWiki installer overrides. See mw-config/overrides/README for details.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @since 1.20
+ */
+class InstallerOverrides {
+ private static function getOverrides() {
+ global $IP;
+ static $overrides;
+
+ if ( !$overrides ) {
+ $overrides = [
+ 'LocalSettingsGenerator' => 'LocalSettingsGenerator',
+ 'WebInstaller' => 'WebInstaller',
+ 'CliInstaller' => 'CliInstaller',
+ ];
+ foreach ( glob( "$IP/mw-config/overrides/*.php" ) as $file ) {
+ require $file;
+ }
+ }
+
+ return $overrides;
+ }
+
+ /**
+ * Instantiates and returns an instance of LocalSettingsGenerator or its descendant classes
+ * @param Installer $installer
+ * @return LocalSettingsGenerator
+ */
+ public static function getLocalSettingsGenerator( Installer $installer ) {
+ $className = self::getOverrides()['LocalSettingsGenerator'];
+ return new $className( $installer );
+ }
+
+ /**
+ * Instantiates and returns an instance of WebInstaller or its descendant classes
+ * @param WebRequest $request
+ * @return WebInstaller
+ */
+ public static function getWebInstaller( WebRequest $request ) {
+ $className = self::getOverrides()['WebInstaller'];
+ return new $className( $request );
+ }
+
+ /**
+ * Instantiates and returns an instance of CliInstaller or its descendant classes
+ * @param string $siteName
+ * @param string|null $admin
+ * @param array $options
+ * @return CliInstaller
+ */
+ public static function getCliInstaller( $siteName, $admin = null, array $options = [] ) {
+ $className = self::getOverrides()['CliInstaller'];
+ return new $className( $siteName, $admin, $options );
+ }
+}
diff --git a/www/wiki/includes/installer/InstallerSessionProvider.php b/www/wiki/includes/installer/InstallerSessionProvider.php
new file mode 100644
index 00000000..568ef516
--- /dev/null
+++ b/www/wiki/includes/installer/InstallerSessionProvider.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Session provider which always provides the same session ID and doesn't
+ * persist the session. For use in the installer when ObjectCache doesn't
+ * work anyway.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use MediaWiki\Session\SessionProvider;
+use MediaWiki\Session\SessionBackend;
+use MediaWiki\Session\SessionInfo;
+
+class InstallerSessionProvider extends SessionProvider {
+ /**
+ * Pretend there is a session, to avoid MWCryptRand overhead
+ * @param WebRequest $request
+ * @return SessionInfo
+ */
+ public function provideSessionInfo( WebRequest $request ) {
+ return new SessionInfo( 1, [
+ 'provider' => $this,
+ 'id' => str_repeat( 'x', 32 ),
+ ] );
+ }
+
+ /**
+ * Yes we will treat your data with great care!
+ * @return bool
+ */
+ public function persistsSessionId() {
+ return true;
+ }
+
+ /**
+ * Sure, you can be whoever you want, as long as you have ID 0
+ * @return bool
+ */
+ public function canChangeUser() {
+ return true;
+ }
+
+ public function persistSession( SessionBackend $session, WebRequest $request ) {
+ }
+
+ public function unpersistSession( WebRequest $request ) {
+ }
+}
diff --git a/www/wiki/includes/installer/LocalSettingsGenerator.php b/www/wiki/includes/installer/LocalSettingsGenerator.php
new file mode 100644
index 00000000..bdaeaca8
--- /dev/null
+++ b/www/wiki/includes/installer/LocalSettingsGenerator.php
@@ -0,0 +1,417 @@
+<?php
+/**
+ * Generator for LocalSettings.php file.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+/**
+ * Class for generating LocalSettings.php file.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class LocalSettingsGenerator {
+
+ protected $extensions = [];
+ protected $values = [];
+ protected $groupPermissions = [];
+ protected $dbSettings = '';
+ protected $IP;
+
+ /**
+ * @var Installer
+ */
+ protected $installer;
+
+ /**
+ * @param Installer $installer
+ */
+ public function __construct( Installer $installer ) {
+ $this->installer = $installer;
+
+ $this->extensions = $installer->getVar( '_Extensions' );
+ $this->skins = $installer->getVar( '_Skins' );
+ $this->IP = $installer->getVar( 'IP' );
+
+ $db = $installer->getDBInstaller( $installer->getVar( 'wgDBtype' ) );
+
+ $confItems = array_merge(
+ [
+ 'wgServer', 'wgScriptPath',
+ 'wgPasswordSender', 'wgImageMagickConvertCommand', 'wgShellLocale',
+ 'wgLanguageCode', 'wgEnableEmail', 'wgEnableUserEmail', 'wgDiff3',
+ 'wgEnotifUserTalk', 'wgEnotifWatchlist', 'wgEmailAuthentication',
+ 'wgDBtype', 'wgSecretKey', 'wgRightsUrl', 'wgSitename', 'wgRightsIcon',
+ 'wgRightsText', '_MainCacheType', 'wgEnableUploads',
+ '_MemCachedServers', 'wgDBserver', 'wgDBuser',
+ 'wgDBpassword', 'wgUseInstantCommons', 'wgUpgradeKey', 'wgDefaultSkin',
+ 'wgMetaNamespace', 'wgLogo', 'wgAuthenticationTokenVersion', 'wgPingback',
+ ],
+ $db->getGlobalNames()
+ );
+
+ $unescaped = [ 'wgRightsIcon', 'wgLogo', '_Caches' ];
+ $boolItems = [
+ 'wgEnableEmail', 'wgEnableUserEmail', 'wgEnotifUserTalk',
+ 'wgEnotifWatchlist', 'wgEmailAuthentication', 'wgEnableUploads', 'wgUseInstantCommons',
+ 'wgPingback',
+ ];
+
+ foreach ( $confItems as $c ) {
+ $val = $installer->getVar( $c );
+
+ if ( in_array( $c, $boolItems ) ) {
+ $val = wfBoolToStr( $val );
+ }
+
+ if ( !in_array( $c, $unescaped ) && $val !== null ) {
+ $val = self::escapePhpString( $val );
+ }
+
+ $this->values[$c] = $val;
+ }
+
+ $this->dbSettings = $db->getLocalSettings();
+ $this->values['wgEmergencyContact'] = $this->values['wgPasswordSender'];
+ }
+
+ /**
+ * For $wgGroupPermissions, set a given ['group']['permission'] value.
+ * @param string $group Group name
+ * @param array $rightsArr An array of permissions, in the form of:
+ * [ 'right' => true, 'right2' => false ]
+ */
+ public function setGroupRights( $group, $rightsArr ) {
+ $this->groupPermissions[$group] = $rightsArr;
+ }
+
+ /**
+ * Returns the escaped version of a string of php code.
+ *
+ * @param string $string
+ *
+ * @return string|false
+ */
+ public static function escapePhpString( $string ) {
+ if ( is_array( $string ) || is_object( $string ) ) {
+ return false;
+ }
+
+ return strtr(
+ $string,
+ [
+ "\n" => "\\n",
+ "\r" => "\\r",
+ "\t" => "\\t",
+ "\\" => "\\\\",
+ "\$" => "\\\$",
+ "\"" => "\\\""
+ ]
+ );
+ }
+
+ /**
+ * Return the full text of the generated LocalSettings.php file,
+ * including the extensions and skins.
+ *
+ * @return string
+ */
+ public function getText() {
+ $localSettings = $this->getDefaultText();
+
+ if ( count( $this->skins ) ) {
+ $localSettings .= "
+# Enabled skins.
+# The following skins were automatically enabled:\n";
+
+ foreach ( $this->skins as $skinName ) {
+ $localSettings .= $this->generateExtEnableLine( 'skins', $skinName );
+ }
+
+ $localSettings .= "\n";
+ }
+
+ if ( count( $this->extensions ) ) {
+ $localSettings .= "
+# Enabled extensions. Most of the extensions are enabled by adding
+# wfLoadExtensions('ExtensionName');
+# to LocalSettings.php. Check specific extension documentation for more details.
+# The following extensions were automatically enabled:\n";
+
+ foreach ( $this->extensions as $extName ) {
+ $localSettings .= $this->generateExtEnableLine( 'extensions', $extName );
+ }
+
+ $localSettings .= "\n";
+ }
+
+ $localSettings .= "
+# End of automatically generated settings.
+# Add more configuration options below.\n\n";
+
+ return $localSettings;
+ }
+
+ /**
+ * Generate the appropriate line to enable the given extension or skin
+ *
+ * @param string $dir Either "extensions" or "skins"
+ * @param string $name Name of extension/skin
+ * @throws InvalidArgumentException
+ * @return string
+ */
+ private function generateExtEnableLine( $dir, $name ) {
+ if ( $dir === 'extensions' ) {
+ $jsonFile = 'extension.json';
+ $function = 'wfLoadExtension';
+ } elseif ( $dir === 'skins' ) {
+ $jsonFile = 'skin.json';
+ $function = 'wfLoadSkin';
+ } else {
+ throw new InvalidArgumentException( '$dir was not "extensions" or "skins' );
+ }
+
+ $encName = self::escapePhpString( $name );
+
+ if ( file_exists( "{$this->IP}/$dir/$encName/$jsonFile" ) ) {
+ return "$function( '$encName' );\n";
+ } else {
+ return "require_once \"\$IP/$dir/$encName/$encName.php\";\n";
+ }
+ }
+
+ /**
+ * Write the generated LocalSettings to a file
+ *
+ * @param string $fileName Full path to filename to write to
+ */
+ public function writeFile( $fileName ) {
+ file_put_contents( $fileName, $this->getText() );
+ }
+
+ /**
+ * @return string
+ */
+ protected function buildMemcachedServerList() {
+ $servers = $this->values['_MemCachedServers'];
+
+ if ( !$servers ) {
+ return '[]';
+ } else {
+ $ret = '[ ';
+ $servers = explode( ',', $servers );
+
+ foreach ( $servers as $srv ) {
+ $srv = trim( $srv );
+ $ret .= "'$srv', ";
+ }
+
+ return rtrim( $ret, ', ' ) . ' ]';
+ }
+ }
+
+ /**
+ * @return string
+ */
+ protected function getDefaultText() {
+ if ( !$this->values['wgImageMagickConvertCommand'] ) {
+ $this->values['wgImageMagickConvertCommand'] = '/usr/bin/convert';
+ $magic = '#';
+ } else {
+ $magic = '';
+ }
+
+ if ( !$this->values['wgShellLocale'] ) {
+ $this->values['wgShellLocale'] = 'C.UTF-8';
+ $locale = '#';
+ } else {
+ $locale = '';
+ }
+
+ $metaNamespace = '';
+ if ( $this->values['wgMetaNamespace'] !== $this->values['wgSitename'] ) {
+ $metaNamespace = "\$wgMetaNamespace = \"{$this->values['wgMetaNamespace']}\";\n";
+ }
+
+ $groupRights = '';
+ $noFollow = '';
+ if ( $this->groupPermissions ) {
+ $groupRights .= "# The following permissions were set based on your choice in the installer\n";
+ foreach ( $this->groupPermissions as $group => $rightArr ) {
+ $group = self::escapePhpString( $group );
+ foreach ( $rightArr as $right => $perm ) {
+ $right = self::escapePhpString( $right );
+ $groupRights .= "\$wgGroupPermissions['$group']['$right'] = " .
+ wfBoolToStr( $perm ) . ";\n";
+ }
+ }
+ $groupRights .= "\n";
+
+ if ( ( isset( $this->groupPermissions['*']['edit'] ) &&
+ $this->groupPermissions['*']['edit'] === false )
+ && ( isset( $this->groupPermissions['*']['createaccount'] ) &&
+ $this->groupPermissions['*']['createaccount'] === false )
+ && ( isset( $this->groupPermissions['*']['read'] ) &&
+ $this->groupPermissions['*']['read'] !== false )
+ ) {
+ $noFollow = "# Set \$wgNoFollowLinks to true if you open up your wiki to editing by\n"
+ . "# the general public and wish to apply nofollow to external links as a\n"
+ . "# deterrent to spammers. Nofollow is not a comprehensive anti-spam solution\n"
+ . "# and open wikis will generally require other anti-spam measures; for more\n"
+ . "# information, see https://www.mediawiki.org/wiki/Manual:Combating_spam\n"
+ . "\$wgNoFollowLinks = false;\n\n";
+ }
+ }
+
+ $serverSetting = "";
+ if ( array_key_exists( 'wgServer', $this->values ) && $this->values['wgServer'] !== null ) {
+ $serverSetting = "\n## The protocol and server name to use in fully-qualified URLs\n";
+ $serverSetting .= "\$wgServer = \"{$this->values['wgServer']}\";";
+ }
+
+ switch ( $this->values['_MainCacheType'] ) {
+ case 'anything':
+ case 'db':
+ case 'memcached':
+ case 'accel':
+ $cacheType = 'CACHE_' . strtoupper( $this->values['_MainCacheType'] );
+ break;
+ case 'none':
+ default:
+ $cacheType = 'CACHE_NONE';
+ }
+
+ $mcservers = $this->buildMemcachedServerList();
+
+ return "<?php
+# This file was automatically generated by the MediaWiki {$GLOBALS['wgVersion']}
+# installer. If you make manual changes, please keep track in case you
+# need to recreate them later.
+#
+# See includes/DefaultSettings.php for all configurable settings
+# and their default values, but don't forget to make changes in _this_
+# file, not there.
+#
+# Further documentation for configuration settings may be found at:
+# https://www.mediawiki.org/wiki/Manual:Configuration_settings
+
+# Protect against web entry
+if ( !defined( 'MEDIAWIKI' ) ) {
+ exit;
+}
+
+## Uncomment this to disable output compression
+# \$wgDisableOutputCompression = true;
+
+\$wgSitename = \"{$this->values['wgSitename']}\";
+{$metaNamespace}
+## The URL base path to the directory containing the wiki;
+## defaults for all runtime URL paths are based off of this.
+## For more information on customizing the URLs
+## (like /w/index.php/Page_title to /wiki/Page_title) please see:
+## https://www.mediawiki.org/wiki/Manual:Short_URL
+\$wgScriptPath = \"{$this->values['wgScriptPath']}\";
+${serverSetting}
+
+## The URL path to static resources (images, scripts, etc.)
+\$wgResourceBasePath = \$wgScriptPath;
+
+## The URL path to the logo. Make sure you change this from the default,
+## or else you'll overwrite your logo when you upgrade!
+\$wgLogo = \"{$this->values['wgLogo']}\";
+
+## UPO means: this is also a user preference option
+
+\$wgEnableEmail = {$this->values['wgEnableEmail']};
+\$wgEnableUserEmail = {$this->values['wgEnableUserEmail']}; # UPO
+
+\$wgEmergencyContact = \"{$this->values['wgEmergencyContact']}\";
+\$wgPasswordSender = \"{$this->values['wgPasswordSender']}\";
+
+\$wgEnotifUserTalk = {$this->values['wgEnotifUserTalk']}; # UPO
+\$wgEnotifWatchlist = {$this->values['wgEnotifWatchlist']}; # UPO
+\$wgEmailAuthentication = {$this->values['wgEmailAuthentication']};
+
+## Database settings
+\$wgDBtype = \"{$this->values['wgDBtype']}\";
+\$wgDBserver = \"{$this->values['wgDBserver']}\";
+\$wgDBname = \"{$this->values['wgDBname']}\";
+\$wgDBuser = \"{$this->values['wgDBuser']}\";
+\$wgDBpassword = \"{$this->values['wgDBpassword']}\";
+
+{$this->dbSettings}
+
+## Shared memory settings
+\$wgMainCacheType = $cacheType;
+\$wgMemCachedServers = $mcservers;
+
+## To enable image uploads, make sure the 'images' directory
+## is writable, then set this to true:
+\$wgEnableUploads = {$this->values['wgEnableUploads']};
+{$magic}\$wgUseImageMagick = true;
+{$magic}\$wgImageMagickConvertCommand = \"{$this->values['wgImageMagickConvertCommand']}\";
+
+# InstantCommons allows wiki to use images from https://commons.wikimedia.org
+\$wgUseInstantCommons = {$this->values['wgUseInstantCommons']};
+
+# Periodically send a pingback to https://www.mediawiki.org/ with basic data
+# about this MediaWiki instance. The Wikimedia Foundation shares this data
+# with MediaWiki developers to help guide future development efforts.
+\$wgPingback = {$this->values['wgPingback']};
+
+## If you use ImageMagick (or any other shell command) on a
+## Linux server, this will need to be set to the name of an
+## available UTF-8 locale
+{$locale}\$wgShellLocale = \"{$this->values['wgShellLocale']}\";
+
+## Set \$wgCacheDirectory to a writable directory on the web server
+## to make your wiki go slightly faster. The directory should not
+## be publically accessible from the web.
+#\$wgCacheDirectory = \"\$IP/cache\";
+
+# Site language code, should be one of the list in ./languages/data/Names.php
+\$wgLanguageCode = \"{$this->values['wgLanguageCode']}\";
+
+\$wgSecretKey = \"{$this->values['wgSecretKey']}\";
+
+# Changing this will log out all existing sessions.
+\$wgAuthenticationTokenVersion = \"{$this->values['wgAuthenticationTokenVersion']}\";
+
+# Site upgrade key. Must be set to a string (default provided) to turn on the
+# web installer while LocalSettings.php is in place
+\$wgUpgradeKey = \"{$this->values['wgUpgradeKey']}\";
+
+## For attaching licensing metadata to pages, and displaying an
+## appropriate copyright notice / icon. GNU Free Documentation
+## License and Creative Commons licenses are supported so far.
+\$wgRightsPage = \"\"; # Set to the title of a wiki page that describes your license/copyright
+\$wgRightsUrl = \"{$this->values['wgRightsUrl']}\";
+\$wgRightsText = \"{$this->values['wgRightsText']}\";
+\$wgRightsIcon = \"{$this->values['wgRightsIcon']}\";
+
+# Path to the GNU diff3 utility. Used for conflict resolution.
+\$wgDiff3 = \"{$this->values['wgDiff3']}\";
+
+{$groupRights}{$noFollow}## Default skin: you can change the default skin. Use the internal symbolic
+## names, ie 'vector', 'monobook':
+\$wgDefaultSkin = \"{$this->values['wgDefaultSkin']}\";
+";
+ }
+}
diff --git a/www/wiki/includes/installer/MssqlInstaller.php b/www/wiki/includes/installer/MssqlInstaller.php
new file mode 100644
index 00000000..e4622207
--- /dev/null
+++ b/www/wiki/includes/installer/MssqlInstaller.php
@@ -0,0 +1,737 @@
+<?php
+/**
+ * Microsoft SQL Server-specific installer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DBQueryError;
+use Wikimedia\Rdbms\DBConnectionError;
+
+/**
+ * Class for setting up the MediaWiki database using Microsoft SQL Server.
+ *
+ * @ingroup Deployment
+ * @since 1.23
+ */
+class MssqlInstaller extends DatabaseInstaller {
+
+ protected $globalNames = [
+ 'wgDBserver',
+ 'wgDBname',
+ 'wgDBuser',
+ 'wgDBpassword',
+ 'wgDBmwschema',
+ 'wgDBprefix',
+ 'wgDBWindowsAuthentication',
+ ];
+
+ protected $internalDefaults = [
+ '_InstallUser' => 'sa',
+ '_InstallWindowsAuthentication' => 'sqlauth',
+ '_WebWindowsAuthentication' => 'sqlauth',
+ ];
+
+ // SQL Server 2005 RTM
+ // @todo Are SQL Express version numbers different?)
+ public static $minimumVersion = '9.00.1399';
+ protected static $notMiniumumVerisonMessage = 'config-mssql-old';
+
+ // These are schema-level privs
+ // Note: the web user will be created will full permissions if possible, this permission
+ // list is only used if we are unable to grant full permissions.
+ public $webUserPrivs = [
+ 'DELETE',
+ 'INSERT',
+ 'SELECT',
+ 'UPDATE',
+ 'EXECUTE',
+ ];
+
+ /**
+ * @return string
+ */
+ public function getName() {
+ return 'mssql';
+ }
+
+ /**
+ * @return bool
+ */
+ public function isCompiled() {
+ return self::checkExtension( 'sqlsrv' );
+ }
+
+ /**
+ * @return string
+ */
+ public function getConnectForm() {
+ if ( $this->getVar( '_InstallWindowsAuthentication' ) == 'windowsauth' ) {
+ $displayStyle = 'display: none;';
+ } else {
+ $displayStyle = 'display: block;';
+ }
+
+ return $this->getTextBox(
+ 'wgDBserver',
+ 'config-db-host',
+ [],
+ $this->parent->getHelpBox( 'config-db-host-help' )
+ ) .
+ Html::openElement( 'fieldset' ) .
+ Html::element( 'legend', [], wfMessage( 'config-db-wiki-settings' )->text() ) .
+ $this->getTextBox( 'wgDBname', 'config-db-name', [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-name-help' ) ) .
+ $this->getTextBox( 'wgDBmwschema', 'config-db-schema', [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-schema-help' ) ) .
+ $this->getTextBox( 'wgDBprefix', 'config-db-prefix', [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-prefix-help' ) ) .
+ Html::closeElement( 'fieldset' ) .
+ Html::openElement( 'fieldset' ) .
+ Html::element( 'legend', [], wfMessage( 'config-db-install-account' )->text() ) .
+ $this->getRadioSet( [
+ 'var' => '_InstallWindowsAuthentication',
+ 'label' => 'config-mssql-auth',
+ 'itemLabelPrefix' => 'config-mssql-',
+ 'values' => [ 'sqlauth', 'windowsauth' ],
+ 'itemAttribs' => [
+ 'sqlauth' => [
+ 'class' => 'showHideRadio',
+ 'rel' => 'dbCredentialBox',
+ ],
+ 'windowsauth' => [
+ 'class' => 'hideShowRadio',
+ 'rel' => 'dbCredentialBox',
+ ]
+ ],
+ 'help' => $this->parent->getHelpBox( 'config-mssql-install-auth' )
+ ] ) .
+ Html::openElement( 'div', [ 'id' => 'dbCredentialBox', 'style' => $displayStyle ] ) .
+ $this->getTextBox(
+ '_InstallUser',
+ 'config-db-username',
+ [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-install-username' )
+ ) .
+ $this->getPasswordBox(
+ '_InstallPassword',
+ 'config-db-password',
+ [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-install-password' )
+ ) .
+ Html::closeElement( 'div' ) .
+ Html::closeElement( 'fieldset' );
+ }
+
+ public function submitConnectForm() {
+ // Get variables from the request.
+ $newValues = $this->setVarsFromRequest( [
+ 'wgDBserver',
+ 'wgDBname',
+ 'wgDBmwschema',
+ 'wgDBprefix'
+ ] );
+
+ // Validate them.
+ $status = Status::newGood();
+ if ( !strlen( $newValues['wgDBserver'] ) ) {
+ $status->fatal( 'config-missing-db-host' );
+ }
+ if ( !strlen( $newValues['wgDBname'] ) ) {
+ $status->fatal( 'config-missing-db-name' );
+ } elseif ( !preg_match( '/^[a-z0-9_]+$/i', $newValues['wgDBname'] ) ) {
+ $status->fatal( 'config-invalid-db-name', $newValues['wgDBname'] );
+ }
+ if ( !preg_match( '/^[a-z0-9_]*$/i', $newValues['wgDBmwschema'] ) ) {
+ $status->fatal( 'config-invalid-schema', $newValues['wgDBmwschema'] );
+ }
+ if ( !preg_match( '/^[a-z0-9_]*$/i', $newValues['wgDBprefix'] ) ) {
+ $status->fatal( 'config-invalid-db-prefix', $newValues['wgDBprefix'] );
+ }
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // Check for blank schema and remap to dbo
+ if ( $newValues['wgDBmwschema'] === '' ) {
+ $this->setVar( 'wgDBmwschema', 'dbo' );
+ }
+
+ // User box
+ $this->setVarsFromRequest( [
+ '_InstallUser',
+ '_InstallPassword',
+ '_InstallWindowsAuthentication'
+ ] );
+
+ // Try to connect
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+
+ // Check version
+ return static::meetsMinimumRequirement( $conn->getServerVersion() );
+ }
+
+ /**
+ * @return Status
+ */
+ public function openConnection() {
+ global $wgDBWindowsAuthentication;
+ $status = Status::newGood();
+ $user = $this->getVar( '_InstallUser' );
+ $password = $this->getVar( '_InstallPassword' );
+
+ if ( $this->getVar( '_InstallWindowsAuthentication' ) == 'windowsauth' ) {
+ // Use Windows authentication for this connection
+ $wgDBWindowsAuthentication = true;
+ } else {
+ $wgDBWindowsAuthentication = false;
+ }
+
+ try {
+ $db = Database::factory( 'mssql', [
+ 'host' => $this->getVar( 'wgDBserver' ),
+ 'port' => $this->getVar( 'wgDBport' ),
+ 'user' => $user,
+ 'password' => $password,
+ 'dbname' => false,
+ 'flags' => 0,
+ 'schema' => $this->getVar( 'wgDBmwschema' ),
+ 'tablePrefix' => $this->getVar( 'wgDBprefix' ) ] );
+ $db->prepareStatements( false );
+ $db->scrollableCursor( false );
+ $status->value = $db;
+ } catch ( DBConnectionError $e ) {
+ $status->fatal( 'config-connection-error', $e->getMessage() );
+ }
+
+ return $status;
+ }
+
+ public function preUpgrade() {
+ global $wgDBuser, $wgDBpassword;
+
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ $this->parent->showStatusError( $status );
+
+ return;
+ }
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+ $conn->selectDB( $this->getVar( 'wgDBname' ) );
+
+ # Normal user and password are selected after this step, so for now
+ # just copy these two
+ $wgDBuser = $this->getVar( '_InstallUser' );
+ $wgDBpassword = $this->getVar( '_InstallPassword' );
+ }
+
+ /**
+ * Return true if the install user can create accounts
+ *
+ * @return bool
+ */
+ public function canCreateAccounts() {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return false;
+ }
+ /** @var Database $conn */
+ $conn = $status->value;
+
+ // We need the server-level ALTER ANY LOGIN permission to create new accounts
+ $res = $conn->query( "SELECT permission_name FROM sys.fn_my_permissions( NULL, 'SERVER' )" );
+ $serverPrivs = [
+ 'ALTER ANY LOGIN' => false,
+ 'CONTROL SERVER' => false,
+ ];
+
+ foreach ( $res as $row ) {
+ $serverPrivs[$row->permission_name] = true;
+ }
+
+ if ( !$serverPrivs['ALTER ANY LOGIN'] ) {
+ return false;
+ }
+
+ // Check to ensure we can grant everything needed as well
+ // We can't actually tell if we have WITH GRANT OPTION for a given permission, so we assume we do
+ // and just check for the permission
+ // https://technet.microsoft.com/en-us/library/ms178569.aspx
+ // The following array sets up which permissions imply whatever permissions we specify
+ $implied = [
+ // schema database server
+ 'DELETE' => [ 'DELETE', 'CONTROL SERVER' ],
+ 'EXECUTE' => [ 'EXECUTE', 'CONTROL SERVER' ],
+ 'INSERT' => [ 'INSERT', 'CONTROL SERVER' ],
+ 'SELECT' => [ 'SELECT', 'CONTROL SERVER' ],
+ 'UPDATE' => [ 'UPDATE', 'CONTROL SERVER' ],
+ ];
+
+ $grantOptions = array_flip( $this->webUserPrivs );
+
+ // Check for schema and db-level permissions, but only if the schema/db exists
+ $schemaPrivs = $dbPrivs = [
+ 'DELETE' => false,
+ 'EXECUTE' => false,
+ 'INSERT' => false,
+ 'SELECT' => false,
+ 'UPDATE' => false,
+ ];
+
+ $dbPrivs['ALTER ANY USER'] = false;
+
+ if ( $this->databaseExists( $this->getVar( 'wgDBname' ) ) ) {
+ $conn->selectDB( $this->getVar( 'wgDBname' ) );
+ $res = $conn->query( "SELECT permission_name FROM sys.fn_my_permissions( NULL, 'DATABASE' )" );
+
+ foreach ( $res as $row ) {
+ $dbPrivs[$row->permission_name] = true;
+ }
+
+ // If the db exists, we need ALTER ANY USER privs on it to make a new user
+ if ( !$dbPrivs['ALTER ANY USER'] ) {
+ return false;
+ }
+
+ if ( $this->schemaExists( $this->getVar( 'wgDBmwschema' ) ) ) {
+ // wgDBmwschema is validated to only contain alphanumeric + underscore, so this is safe
+ $res = $conn->query( "SELECT permission_name FROM sys.fn_my_permissions( "
+ . "'{$this->getVar( 'wgDBmwschema' )}', 'SCHEMA' )" );
+
+ foreach ( $res as $row ) {
+ $schemaPrivs[$row->permission_name] = true;
+ }
+ }
+ }
+
+ // Now check all the grants we'll need to be doing to see if we can
+ foreach ( $this->webUserPrivs as $permission ) {
+ if ( ( isset( $schemaPrivs[$permission] ) && $schemaPrivs[$permission] )
+ || ( isset( $dbPrivs[$implied[$permission][0]] )
+ && $dbPrivs[$implied[$permission][0]] )
+ || ( isset( $serverPrivs[$implied[$permission][1]] )
+ && $serverPrivs[$implied[$permission][1]] )
+ ) {
+ unset( $grantOptions[$permission] );
+ }
+ }
+
+ if ( count( $grantOptions ) ) {
+ // Can't grant everything
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSettingsForm() {
+ if ( $this->canCreateAccounts() ) {
+ $noCreateMsg = false;
+ } else {
+ $noCreateMsg = 'config-db-web-no-create-privs';
+ }
+
+ $wrapperStyle = $this->getVar( '_SameAccount' ) ? 'display: none' : '';
+ $displayStyle = $this->getVar( '_WebWindowsAuthentication' ) == 'windowsauth'
+ ? 'display: none'
+ : '';
+ $s = Html::openElement( 'fieldset' ) .
+ Html::element( 'legend', [], wfMessage( 'config-db-web-account' )->text() ) .
+ $this->getCheckBox(
+ '_SameAccount', 'config-db-web-account-same',
+ [ 'class' => 'hideShowRadio', 'rel' => 'dbOtherAccount' ]
+ ) .
+ Html::openElement( 'div', [ 'id' => 'dbOtherAccount', 'style' => $wrapperStyle ] ) .
+ $this->getRadioSet( [
+ 'var' => '_WebWindowsAuthentication',
+ 'label' => 'config-mssql-auth',
+ 'itemLabelPrefix' => 'config-mssql-',
+ 'values' => [ 'sqlauth', 'windowsauth' ],
+ 'itemAttribs' => [
+ 'sqlauth' => [
+ 'class' => 'showHideRadio',
+ 'rel' => 'dbCredentialBox',
+ ],
+ 'windowsauth' => [
+ 'class' => 'hideShowRadio',
+ 'rel' => 'dbCredentialBox',
+ ]
+ ],
+ 'help' => $this->parent->getHelpBox( 'config-mssql-web-auth' )
+ ] ) .
+ Html::openElement( 'div', [ 'id' => 'dbCredentialBox', 'style' => $displayStyle ] ) .
+ $this->getTextBox( 'wgDBuser', 'config-db-username' ) .
+ $this->getPasswordBox( 'wgDBpassword', 'config-db-password' ) .
+ Html::closeElement( 'div' );
+
+ if ( $noCreateMsg ) {
+ $s .= $this->parent->getWarningBox( wfMessage( $noCreateMsg )->plain() );
+ } else {
+ $s .= $this->getCheckBox( '_CreateDBAccount', 'config-db-web-create' );
+ }
+
+ $s .= Html::closeElement( 'div' ) . Html::closeElement( 'fieldset' );
+
+ return $s;
+ }
+
+ /**
+ * @return Status
+ */
+ public function submitSettingsForm() {
+ $this->setVarsFromRequest( [
+ 'wgDBuser',
+ 'wgDBpassword',
+ '_SameAccount',
+ '_CreateDBAccount',
+ '_WebWindowsAuthentication'
+ ] );
+
+ if ( $this->getVar( '_SameAccount' ) ) {
+ $this->setVar( '_WebWindowsAuthentication', $this->getVar( '_InstallWindowsAuthentication' ) );
+ $this->setVar( 'wgDBuser', $this->getVar( '_InstallUser' ) );
+ $this->setVar( 'wgDBpassword', $this->getVar( '_InstallPassword' ) );
+ }
+
+ if ( $this->getVar( '_WebWindowsAuthentication' ) == 'windowsauth' ) {
+ $this->setVar( 'wgDBuser', '' );
+ $this->setVar( 'wgDBpassword', '' );
+ $this->setVar( 'wgDBWindowsAuthentication', true );
+ } else {
+ $this->setVar( 'wgDBWindowsAuthentication', false );
+ }
+
+ if ( $this->getVar( '_CreateDBAccount' )
+ && $this->getVar( '_WebWindowsAuthentication' ) == 'sqlauth'
+ && strval( $this->getVar( 'wgDBpassword' ) ) == ''
+ ) {
+ return Status::newFatal( 'config-db-password-empty', $this->getVar( 'wgDBuser' ) );
+ }
+
+ // Validate the create checkbox
+ $canCreate = $this->canCreateAccounts();
+ if ( !$canCreate ) {
+ $this->setVar( '_CreateDBAccount', false );
+ $create = false;
+ } else {
+ $create = $this->getVar( '_CreateDBAccount' );
+ }
+
+ if ( !$create ) {
+ // Test the web account
+ $user = $this->getVar( 'wgDBuser' );
+ $password = $this->getVar( 'wgDBpassword' );
+
+ if ( $this->getVar( '_WebWindowsAuthentication' ) == 'windowsauth' ) {
+ $user = 'windowsauth';
+ $password = 'windowsauth';
+ }
+
+ try {
+ Database::factory( 'mssql', [
+ 'host' => $this->getVar( 'wgDBserver' ),
+ 'user' => $user,
+ 'password' => $password,
+ 'dbname' => false,
+ 'flags' => 0,
+ 'tablePrefix' => $this->getVar( 'wgDBprefix' ),
+ 'schema' => $this->getVar( 'wgDBmwschema' ),
+ ] );
+ } catch ( DBConnectionError $e ) {
+ return Status::newFatal( 'config-connection-error', $e->getMessage() );
+ }
+ }
+
+ return Status::newGood();
+ }
+
+ public function preInstall() {
+ # Add our user callback to installSteps, right before the tables are created.
+ $callback = [
+ 'name' => 'user',
+ 'callback' => [ $this, 'setupUser' ],
+ ];
+ $this->parent->addInstallStep( $callback, 'tables' );
+ }
+
+ /**
+ * @return Status
+ */
+ public function setupDatabase() {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ /** @var Database $conn */
+ $conn = $status->value;
+ $dbName = $this->getVar( 'wgDBname' );
+ $schemaName = $this->getVar( 'wgDBmwschema' );
+ if ( !$this->databaseExists( $dbName ) ) {
+ $conn->query(
+ "CREATE DATABASE " . $conn->addIdentifierQuotes( $dbName ),
+ __METHOD__
+ );
+ }
+ $conn->selectDB( $dbName );
+ if ( !$this->schemaExists( $schemaName ) ) {
+ $conn->query(
+ "CREATE SCHEMA " . $conn->addIdentifierQuotes( $schemaName ),
+ __METHOD__
+ );
+ }
+ if ( !$this->catalogExists( $schemaName ) ) {
+ $conn->query(
+ "CREATE FULLTEXT CATALOG " . $conn->addIdentifierQuotes( $schemaName ),
+ __METHOD__
+ );
+ }
+ $this->setupSchemaVars();
+
+ return $status;
+ }
+
+ /**
+ * @return Status
+ */
+ public function setupUser() {
+ $dbUser = $this->getVar( 'wgDBuser' );
+ if ( $dbUser == $this->getVar( '_InstallUser' )
+ || ( $this->getVar( '_InstallWindowsAuthentication' ) == 'windowsauth'
+ && $this->getVar( '_WebWindowsAuthentication' ) == 'windowsauth' ) ) {
+ return Status::newGood();
+ }
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $this->setupSchemaVars();
+ $dbName = $this->getVar( 'wgDBname' );
+ $this->db->selectDB( $dbName );
+ $password = $this->getVar( 'wgDBpassword' );
+ $schemaName = $this->getVar( 'wgDBmwschema' );
+
+ if ( $this->getVar( '_WebWindowsAuthentication' ) == 'windowsauth' ) {
+ $dbUser = 'windowsauth';
+ $password = 'windowsauth';
+ }
+
+ if ( $this->getVar( '_CreateDBAccount' ) ) {
+ $tryToCreate = true;
+ } else {
+ $tryToCreate = false;
+ }
+
+ $escUser = $this->db->addIdentifierQuotes( $dbUser );
+ $escDb = $this->db->addIdentifierQuotes( $dbName );
+ $escSchema = $this->db->addIdentifierQuotes( $schemaName );
+ $grantableNames = [];
+ if ( $tryToCreate ) {
+ $escPass = $this->db->addQuotes( $password );
+
+ if ( !$this->loginExists( $dbUser ) ) {
+ try {
+ $this->db->begin();
+ $this->db->selectDB( 'master' );
+ $logintype = $this->getVar( '_WebWindowsAuthentication' ) == 'windowsauth'
+ ? 'FROM WINDOWS'
+ : "WITH PASSWORD = $escPass";
+ $this->db->query( "CREATE LOGIN $escUser $logintype" );
+ $this->db->selectDB( $dbName );
+ $this->db->query( "CREATE USER $escUser FOR LOGIN $escUser WITH DEFAULT_SCHEMA = $escSchema" );
+ $this->db->commit();
+ $grantableNames[] = $dbUser;
+ } catch ( DBQueryError $dqe ) {
+ $this->db->rollback();
+ $status->warning( 'config-install-user-create-failed', $dbUser, $dqe->getMessage() );
+ }
+ } elseif ( !$this->userExists( $dbUser ) ) {
+ try {
+ $this->db->begin();
+ $this->db->selectDB( $dbName );
+ $this->db->query( "CREATE USER $escUser FOR LOGIN $escUser WITH DEFAULT_SCHEMA = $escSchema" );
+ $this->db->commit();
+ $grantableNames[] = $dbUser;
+ } catch ( DBQueryError $dqe ) {
+ $this->db->rollback();
+ $status->warning( 'config-install-user-create-failed', $dbUser, $dqe->getMessage() );
+ }
+ } else {
+ $status->warning( 'config-install-user-alreadyexists', $dbUser );
+ $grantableNames[] = $dbUser;
+ }
+ }
+
+ // Try to grant to all the users we know exist or we were able to create
+ $this->db->selectDB( $dbName );
+ foreach ( $grantableNames as $name ) {
+ try {
+ // First try to grant full permissions
+ $fullPrivArr = [
+ 'BACKUP DATABASE', 'BACKUP LOG', 'CREATE FUNCTION', 'CREATE PROCEDURE',
+ 'CREATE TABLE', 'CREATE VIEW', 'CREATE FULLTEXT CATALOG', 'SHOWPLAN'
+ ];
+ $fullPrivList = implode( ', ', $fullPrivArr );
+ $this->db->begin();
+ $this->db->query( "GRANT $fullPrivList ON DATABASE :: $escDb TO $escUser", __METHOD__ );
+ $this->db->query( "GRANT CONTROL ON SCHEMA :: $escSchema TO $escUser", __METHOD__ );
+ $this->db->commit();
+ } catch ( DBQueryError $dqe ) {
+ // If that fails, try to grant the limited subset specified in $this->webUserPrivs
+ try {
+ $privList = implode( ', ', $this->webUserPrivs );
+ $this->db->rollback();
+ $this->db->begin();
+ $this->db->query( "GRANT $privList ON SCHEMA :: $escSchema TO $escUser", __METHOD__ );
+ $this->db->commit();
+ } catch ( DBQueryError $dqe ) {
+ $this->db->rollback();
+ $status->fatal( 'config-install-user-grant-failed', $dbUser, $dqe->getMessage() );
+ }
+ // Also try to grant SHOWPLAN on the db, but don't fail if we can't
+ // (just makes a couple things in mediawiki run slower since
+ // we have to run SELECT COUNT(*) instead of getting the query plan)
+ try {
+ $this->db->query( "GRANT SHOWPLAN ON DATABASE :: $escDb TO $escUser", __METHOD__ );
+ } catch ( DBQueryError $dqe ) {
+ }
+ }
+ }
+
+ return $status;
+ }
+
+ public function createTables() {
+ $status = parent::createTables();
+
+ // Do last-minute stuff like fulltext indexes (since they can't be inside a transaction)
+ if ( $status->isOK() ) {
+ $searchindex = $this->db->tableName( 'searchindex' );
+ $schema = $this->db->addIdentifierQuotes( $this->getVar( 'wgDBmwschema' ) );
+ try {
+ $this->db->query( "CREATE FULLTEXT INDEX ON $searchindex (si_title, si_text) "
+ . "KEY INDEX si_page ON $schema" );
+ } catch ( DBQueryError $dqe ) {
+ $status->fatal( 'config-install-tables-failed', $dqe->getMessage() );
+ }
+ }
+
+ return $status;
+ }
+
+ public function getGlobalDefaults() {
+ // The default $wgDBmwschema is null, which breaks Postgres and other DBMSes that require
+ // the use of a schema, so we need to set it here
+ return array_merge( parent::getGlobalDefaults(), [
+ 'wgDBmwschema' => 'mediawiki',
+ ] );
+ }
+
+ /**
+ * Try to see if the login exists
+ * @param string $user Username to check
+ * @return bool
+ */
+ private function loginExists( $user ) {
+ $res = $this->db->selectField( 'sys.sql_logins', 1, [ 'name' => $user ] );
+ return (bool)$res;
+ }
+
+ /**
+ * Try to see if the user account exists
+ * We assume we already have the appropriate database selected
+ * @param string $user Username to check
+ * @return bool
+ */
+ private function userExists( $user ) {
+ $res = $this->db->selectField( 'sys.sysusers', 1, [ 'name' => $user ] );
+ return (bool)$res;
+ }
+
+ /**
+ * Try to see if a given database exists
+ * @param string $dbName Database name to check
+ * @return bool
+ */
+ private function databaseExists( $dbName ) {
+ $res = $this->db->selectField( 'sys.databases', 1, [ 'name' => $dbName ] );
+ return (bool)$res;
+ }
+
+ /**
+ * Try to see if a given schema exists
+ * We assume we already have the appropriate database selected
+ * @param string $schemaName Schema name to check
+ * @return bool
+ */
+ private function schemaExists( $schemaName ) {
+ $res = $this->db->selectField( 'sys.schemas', 1, [ 'name' => $schemaName ] );
+ return (bool)$res;
+ }
+
+ /**
+ * Try to see if a given fulltext catalog exists
+ * We assume we already have the appropriate database selected
+ * @param string $catalogName Catalog name to check
+ * @return bool
+ */
+ private function catalogExists( $catalogName ) {
+ $res = $this->db->selectField( 'sys.fulltext_catalogs', 1, [ 'name' => $catalogName ] );
+ return (bool)$res;
+ }
+
+ /**
+ * Get variables to substitute into tables.sql and the SQL patch files.
+ *
+ * @return array
+ */
+ public function getSchemaVars() {
+ return [
+ 'wgDBname' => $this->getVar( 'wgDBname' ),
+ 'wgDBmwschema' => $this->getVar( 'wgDBmwschema' ),
+ 'wgDBuser' => $this->getVar( 'wgDBuser' ),
+ 'wgDBpassword' => $this->getVar( 'wgDBpassword' ),
+ ];
+ }
+
+ public function getLocalSettings() {
+ $schema = LocalSettingsGenerator::escapePhpString( $this->getVar( 'wgDBmwschema' ) );
+ $prefix = LocalSettingsGenerator::escapePhpString( $this->getVar( 'wgDBprefix' ) );
+ $windowsauth = $this->getVar( 'wgDBWindowsAuthentication' ) ? 'true' : 'false';
+
+ return "# MSSQL specific settings
+\$wgDBWindowsAuthentication = {$windowsauth};
+\$wgDBmwschema = \"{$schema}\";
+\$wgDBprefix = \"{$prefix}\";";
+ }
+}
diff --git a/www/wiki/includes/installer/MssqlUpdater.php b/www/wiki/includes/installer/MssqlUpdater.php
new file mode 100644
index 00000000..411d2c8c
--- /dev/null
+++ b/www/wiki/includes/installer/MssqlUpdater.php
@@ -0,0 +1,146 @@
+<?php
+/**
+ * Microsoft SQL Server-specific installer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use Wikimedia\Rdbms\DatabaseMssql;
+
+/**
+ * Class for setting up the MediaWiki database using Microsoft SQL Server.
+ *
+ * @ingroup Deployment
+ * @since 1.23
+ */
+
+class MssqlUpdater extends DatabaseUpdater {
+
+ /**
+ * @var DatabaseMssql
+ */
+ protected $db;
+
+ protected function getCoreUpdateList() {
+ return [
+ // 1.23
+ [ 'addField', 'mwuser', 'user_password_expires', 'patch-user_password_expires.sql' ],
+
+ // 1.24
+ [ 'addField', 'page', 'page_lang', 'patch-page_page_lang.sql' ],
+
+ // 1.25
+ [ 'dropTable', 'hitcounter' ],
+ [ 'dropField', 'site_stats', 'ss_total_views', 'patch-drop-ss_total_views.sql' ],
+ [ 'dropField', 'page', 'page_counter', 'patch-drop-page_counter.sql' ],
+ // scripts were updated in 1.27 due to SQL errors; retaining old updatekeys so that people
+ // updating from 1.23->1.25->1.27 do not execute these scripts twice even though the
+ // updatekeys no longer make sense as they are.
+ [ 'updateSchema', 'categorylinks', 'cl_type-category_types-ck',
+ 'patch-categorylinks-constraints.sql' ],
+ [ 'updateSchema', 'filearchive', 'fa_major_mime-major_mime-ck',
+ 'patch-filearchive-constraints.sql' ],
+ [ 'updateSchema', 'oldimage', 'oi_major_mime-major_mime-ck',
+ 'patch-oldimage-constraints.sql' ],
+ [ 'updateSchema', 'image', 'img_major_mime-major_mime-ck', 'patch-image-constraints.sql' ],
+ [ 'updateSchema', 'uploadstash', 'us_media_type-media_type-ck',
+ 'patch-uploadstash-constraints.sql' ],
+
+ [ 'modifyField', 'image', 'img_major_mime',
+ 'patch-img_major_mime-chemical.sql' ],
+ [ 'modifyField', 'oldimage', 'oi_major_mime',
+ 'patch-oi_major_mime-chemical.sql' ],
+ [ 'modifyField', 'filearchive', 'fa_major_mime',
+ 'patch-fa_major_mime-chemical.sql' ],
+
+ // 1.27
+ [ 'dropTable', 'msg_resource_links' ],
+ [ 'dropTable', 'msg_resource' ],
+ [ 'addField', 'watchlist', 'wl_id', 'patch-watchlist-wl_id.sql' ],
+ [ 'dropField', 'mwuser', 'user_options', 'patch-drop-user_options.sql' ],
+ [ 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ],
+ [ 'addField', 'pagelinks', 'pl_from_namespace', 'patch-pl_from_namespace.sql' ],
+ [ 'addField', 'templatelinks', 'tl_from_namespace', 'patch-tl_from_namespace.sql' ],
+ [ 'addField', 'imagelinks', 'il_from_namespace', 'patch-il_from_namespace.sql' ],
+ [ 'dropIndex', 'categorylinks', 'cl_collation', 'patch-kill-cl_collation_index.sql' ],
+ [ 'addIndex', 'categorylinks', 'cl_collation_ext',
+ 'patch-add-cl_collation_ext_index.sql' ],
+ [ 'dropField', 'recentchanges', 'rc_cur_time', 'patch-drop-rc_cur_time.sql' ],
+ [ 'addField', 'page_props', 'pp_sortkey', 'patch-pp_sortkey.sql' ],
+ [ 'updateSchema', 'oldimage', 'oldimage varchar', 'patch-oldimage-schema.sql' ],
+ [ 'updateSchema', 'filearchive', 'filearchive varchar', 'patch-filearchive-schema.sql' ],
+ [ 'updateSchema', 'image', 'image varchar', 'patch-image-schema.sql' ],
+ [ 'updateSchema', 'recentchanges', 'recentchanges-drop-fks',
+ 'patch-recentchanges-drop-fks.sql' ],
+ [ 'updateSchema', 'logging', 'logging-drop-fks', 'patch-logging-drop-fks.sql' ],
+ [ 'updateSchema', 'archive', 'archive-drop-fks', 'patch-archive-drop-fks.sql' ],
+
+ // 1.28
+ [ 'addIndex', 'recentchanges', 'rc_name_type_patrolled_timestamp',
+ 'patch-add-rc_name_type_patrolled_timestamp_index.sql' ],
+ [ 'addField', 'change_tag', 'ct_id', 'patch-change_tag-ct_id.sql' ],
+ [ 'addField', 'tag_summary', 'ts_id', 'patch-tag_summary-ts_id.sql' ],
+
+ // 1.29
+ [ 'addField', 'externallinks', 'el_index_60', 'patch-externallinks-el_index_60.sql' ],
+ [ 'dropIndex', 'oldimage', 'oi_name_archive_name',
+ 'patch-alter-table-oldimage.sql' ],
+
+ // 1.30
+ [ 'modifyField', 'image', 'img_media_type', 'patch-add-3d.sql' ],
+ [ 'addIndex', 'site_stats', 'PRIMARY', 'patch-site_stats-pk.sql' ],
+ ];
+ }
+
+ protected function applyPatch( $path, $isFullPath = false, $msg = null ) {
+ $prevScroll = $this->db->scrollableCursor( false );
+ $prevPrep = $this->db->prepareStatements( false );
+ parent::applyPatch( $path, $isFullPath, $msg );
+ $this->db->scrollableCursor( $prevScroll );
+ $this->db->prepareStatements( $prevPrep );
+ }
+
+ /**
+ * General schema update for a table that touches more than one field or requires
+ * destructive actions (such as dropping and recreating the table). NOTE: Usage of
+ * this function is highly discouraged, use it's successor DatabaseUpdater::modifyTable
+ * instead.
+ *
+ * @param string $table
+ * @param string $updatekey
+ * @param string $patch
+ * @param bool $fullpath
+ * @return bool
+ */
+ protected function updateSchema( $table, $updatekey, $patch, $fullpath = false ) {
+ if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
+ $this->output( "...$table table does not exist, skipping schema update patch.\n" );
+ } elseif ( $this->updateRowExists( $updatekey ) ) {
+ $this->output( "...$table already had schema updated by $patch.\n" );
+ } else {
+ $apply = $this->applyPatch( $patch, $fullpath, "Updating schema of table $table" );
+ if ( $apply ) {
+ $this->insertUpdateRow( $updatekey );
+ }
+ return $apply;
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/installer/MysqlInstaller.php b/www/wiki/includes/installer/MysqlInstaller.php
new file mode 100644
index 00000000..ab5701a8
--- /dev/null
+++ b/www/wiki/includes/installer/MysqlInstaller.php
@@ -0,0 +1,687 @@
+<?php
+/**
+ * MySQL-specific installer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DBQueryError;
+use Wikimedia\Rdbms\DBConnectionError;
+
+/**
+ * Class for setting up the MediaWiki database using MySQL.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class MysqlInstaller extends DatabaseInstaller {
+
+ protected $globalNames = [
+ 'wgDBserver',
+ 'wgDBname',
+ 'wgDBuser',
+ 'wgDBpassword',
+ 'wgDBprefix',
+ 'wgDBTableOptions',
+ 'wgDBmysql5',
+ ];
+
+ protected $internalDefaults = [
+ '_MysqlEngine' => 'InnoDB',
+ '_MysqlCharset' => 'binary',
+ '_InstallUser' => 'root',
+ ];
+
+ public $supportedEngines = [ 'InnoDB', 'MyISAM' ];
+
+ public static $minimumVersion = '5.5.8';
+ protected static $notMiniumumVerisonMessage = 'config-mysql-old';
+
+ public $webUserPrivs = [
+ 'DELETE',
+ 'INSERT',
+ 'SELECT',
+ 'UPDATE',
+ 'CREATE TEMPORARY TABLES',
+ ];
+
+ /**
+ * @return string
+ */
+ public function getName() {
+ return 'mysql';
+ }
+
+ /**
+ * @return bool
+ */
+ public function isCompiled() {
+ return self::checkExtension( 'mysql' ) || self::checkExtension( 'mysqli' );
+ }
+
+ /**
+ * @return string
+ */
+ public function getConnectForm() {
+ return $this->getTextBox(
+ 'wgDBserver',
+ 'config-db-host',
+ [],
+ $this->parent->getHelpBox( 'config-db-host-help' )
+ ) .
+ Html::openElement( 'fieldset' ) .
+ Html::element( 'legend', [], wfMessage( 'config-db-wiki-settings' )->text() ) .
+ $this->getTextBox( 'wgDBname', 'config-db-name', [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-name-help' ) ) .
+ $this->getTextBox( 'wgDBprefix', 'config-db-prefix', [ 'dir' => 'ltr' ],
+ $this->parent->getHelpBox( 'config-db-prefix-help' ) ) .
+ Html::closeElement( 'fieldset' ) .
+ $this->getInstallUserBox();
+ }
+
+ public function submitConnectForm() {
+ // Get variables from the request.
+ $newValues = $this->setVarsFromRequest( [ 'wgDBserver', 'wgDBname', 'wgDBprefix' ] );
+
+ // Validate them.
+ $status = Status::newGood();
+ if ( !strlen( $newValues['wgDBserver'] ) ) {
+ $status->fatal( 'config-missing-db-host' );
+ }
+ if ( !strlen( $newValues['wgDBname'] ) ) {
+ $status->fatal( 'config-missing-db-name' );
+ } elseif ( !preg_match( '/^[a-z0-9+_-]+$/i', $newValues['wgDBname'] ) ) {
+ $status->fatal( 'config-invalid-db-name', $newValues['wgDBname'] );
+ }
+ if ( !preg_match( '/^[a-z0-9_-]*$/i', $newValues['wgDBprefix'] ) ) {
+ $status->fatal( 'config-invalid-db-prefix', $newValues['wgDBprefix'] );
+ }
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // Submit user box
+ $status = $this->submitInstallUserBox();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // Try to connect
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+
+ // Check version
+ return static::meetsMinimumRequirement( $conn->getServerVersion() );
+ }
+
+ /**
+ * @return Status
+ */
+ public function openConnection() {
+ $status = Status::newGood();
+ try {
+ $db = Database::factory( 'mysql', [
+ 'host' => $this->getVar( 'wgDBserver' ),
+ 'user' => $this->getVar( '_InstallUser' ),
+ 'password' => $this->getVar( '_InstallPassword' ),
+ 'dbname' => false,
+ 'flags' => 0,
+ 'tablePrefix' => $this->getVar( 'wgDBprefix' ) ] );
+ $status->value = $db;
+ } catch ( DBConnectionError $e ) {
+ $status->fatal( 'config-connection-error', $e->getMessage() );
+ }
+
+ return $status;
+ }
+
+ public function preUpgrade() {
+ global $wgDBuser, $wgDBpassword;
+
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ $this->parent->showStatusError( $status );
+
+ return;
+ }
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+ $conn->selectDB( $this->getVar( 'wgDBname' ) );
+
+ # Determine existing default character set
+ if ( $conn->tableExists( "revision", __METHOD__ ) ) {
+ $revision = $this->escapeLikeInternal( $this->getVar( 'wgDBprefix' ) . 'revision', '\\' );
+ $res = $conn->query( "SHOW TABLE STATUS LIKE '$revision'", __METHOD__ );
+ $row = $conn->fetchObject( $res );
+ if ( !$row ) {
+ $this->parent->showMessage( 'config-show-table-status' );
+ $existingSchema = false;
+ $existingEngine = false;
+ } else {
+ if ( preg_match( '/^latin1/', $row->Collation ) ) {
+ $existingSchema = 'latin1';
+ } elseif ( preg_match( '/^utf8/', $row->Collation ) ) {
+ $existingSchema = 'utf8';
+ } elseif ( preg_match( '/^binary/', $row->Collation ) ) {
+ $existingSchema = 'binary';
+ } else {
+ $existingSchema = false;
+ $this->parent->showMessage( 'config-unknown-collation' );
+ }
+ if ( isset( $row->Engine ) ) {
+ $existingEngine = $row->Engine;
+ } else {
+ $existingEngine = $row->Type;
+ }
+ }
+ } else {
+ $existingSchema = false;
+ $existingEngine = false;
+ }
+
+ if ( $existingSchema && $existingSchema != $this->getVar( '_MysqlCharset' ) ) {
+ $this->setVar( '_MysqlCharset', $existingSchema );
+ }
+ if ( $existingEngine && $existingEngine != $this->getVar( '_MysqlEngine' ) ) {
+ $this->setVar( '_MysqlEngine', $existingEngine );
+ }
+
+ # Normal user and password are selected after this step, so for now
+ # just copy these two
+ $wgDBuser = $this->getVar( '_InstallUser' );
+ $wgDBpassword = $this->getVar( '_InstallPassword' );
+ }
+
+ /**
+ * @param string $s
+ * @param string $escapeChar
+ * @return string
+ */
+ protected function escapeLikeInternal( $s, $escapeChar = '`' ) {
+ return str_replace( [ $escapeChar, '%', '_' ],
+ [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
+ $s );
+ }
+
+ /**
+ * Get a list of storage engines that are available and supported
+ *
+ * @return array
+ */
+ public function getEngines() {
+ $status = $this->getConnection();
+
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+
+ $engines = [];
+ $res = $conn->query( 'SHOW ENGINES', __METHOD__ );
+ foreach ( $res as $row ) {
+ if ( $row->Support == 'YES' || $row->Support == 'DEFAULT' ) {
+ $engines[] = $row->Engine;
+ }
+ }
+ $engines = array_intersect( $this->supportedEngines, $engines );
+
+ return $engines;
+ }
+
+ /**
+ * Get a list of character sets that are available and supported
+ *
+ * @return array
+ */
+ public function getCharsets() {
+ return [ 'binary', 'utf8' ];
+ }
+
+ /**
+ * Return true if the install user can create accounts
+ *
+ * @return bool
+ */
+ public function canCreateAccounts() {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return false;
+ }
+ /** @var Database $conn */
+ $conn = $status->value;
+
+ // Get current account name
+ $currentName = $conn->selectField( '', 'CURRENT_USER()', '', __METHOD__ );
+ $parts = explode( '@', $currentName );
+ if ( count( $parts ) != 2 ) {
+ return false;
+ }
+ $quotedUser = $conn->addQuotes( $parts[0] ) .
+ '@' . $conn->addQuotes( $parts[1] );
+
+ // The user needs to have INSERT on mysql.* to be able to CREATE USER
+ // The grantee will be double-quoted in this query, as required
+ $res = $conn->select( 'INFORMATION_SCHEMA.USER_PRIVILEGES', '*',
+ [ 'GRANTEE' => $quotedUser ], __METHOD__ );
+ $insertMysql = false;
+ $grantOptions = array_flip( $this->webUserPrivs );
+ foreach ( $res as $row ) {
+ if ( $row->PRIVILEGE_TYPE == 'INSERT' ) {
+ $insertMysql = true;
+ }
+ if ( $row->IS_GRANTABLE ) {
+ unset( $grantOptions[$row->PRIVILEGE_TYPE] );
+ }
+ }
+
+ // Check for DB-specific privs for mysql.*
+ if ( !$insertMysql ) {
+ $row = $conn->selectRow( 'INFORMATION_SCHEMA.SCHEMA_PRIVILEGES', '*',
+ [
+ 'GRANTEE' => $quotedUser,
+ 'TABLE_SCHEMA' => 'mysql',
+ 'PRIVILEGE_TYPE' => 'INSERT',
+ ], __METHOD__ );
+ if ( $row ) {
+ $insertMysql = true;
+ }
+ }
+
+ if ( !$insertMysql ) {
+ return false;
+ }
+
+ // Check for DB-level grant options
+ $res = $conn->select( 'INFORMATION_SCHEMA.SCHEMA_PRIVILEGES', '*',
+ [
+ 'GRANTEE' => $quotedUser,
+ 'IS_GRANTABLE' => 1,
+ ], __METHOD__ );
+ foreach ( $res as $row ) {
+ $regex = $this->likeToRegex( $row->TABLE_SCHEMA );
+ if ( preg_match( $regex, $this->getVar( 'wgDBname' ) ) ) {
+ unset( $grantOptions[$row->PRIVILEGE_TYPE] );
+ }
+ }
+ if ( count( $grantOptions ) ) {
+ // Can't grant everything
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Convert a wildcard (as used in LIKE) to a regex
+ * Slashes are escaped, slash terminators included
+ * @param string $wildcard
+ * @return string
+ */
+ protected function likeToRegex( $wildcard ) {
+ $r = preg_quote( $wildcard, '/' );
+ $r = strtr( $r, [
+ '%' => '.*',
+ '_' => '.'
+ ] );
+ return "/$r/s";
+ }
+
+ /**
+ * @return string
+ */
+ public function getSettingsForm() {
+ if ( $this->canCreateAccounts() ) {
+ $noCreateMsg = false;
+ } else {
+ $noCreateMsg = 'config-db-web-no-create-privs';
+ }
+ $s = $this->getWebUserBox( $noCreateMsg );
+
+ // Do engine selector
+ $engines = $this->getEngines();
+ // If the current default engine is not supported, use an engine that is
+ if ( !in_array( $this->getVar( '_MysqlEngine' ), $engines ) ) {
+ $this->setVar( '_MysqlEngine', reset( $engines ) );
+ }
+
+ $s .= Xml::openElement( 'div', [
+ 'id' => 'dbMyisamWarning'
+ ] );
+ $myisamWarning = 'config-mysql-myisam-dep';
+ if ( count( $engines ) === 1 ) {
+ $myisamWarning = 'config-mysql-only-myisam-dep';
+ }
+ $s .= $this->parent->getWarningBox( wfMessage( $myisamWarning )->text() );
+ $s .= Xml::closeElement( 'div' );
+
+ if ( $this->getVar( '_MysqlEngine' ) != 'MyISAM' ) {
+ $s .= Xml::openElement( 'script' );
+ $s .= '$(\'#dbMyisamWarning\').hide();';
+ $s .= Xml::closeElement( 'script' );
+ }
+
+ if ( count( $engines ) >= 2 ) {
+ // getRadioSet() builds a set of labeled radio buttons.
+ // For grep: The following messages are used as the item labels:
+ // config-mysql-innodb, config-mysql-myisam
+ $s .= $this->getRadioSet( [
+ 'var' => '_MysqlEngine',
+ 'label' => 'config-mysql-engine',
+ 'itemLabelPrefix' => 'config-mysql-',
+ 'values' => $engines,
+ 'itemAttribs' => [
+ 'MyISAM' => [
+ 'class' => 'showHideRadio',
+ 'rel' => 'dbMyisamWarning'
+ ],
+ 'InnoDB' => [
+ 'class' => 'hideShowRadio',
+ 'rel' => 'dbMyisamWarning'
+ ]
+ ]
+ ] );
+ $s .= $this->parent->getHelpBox( 'config-mysql-engine-help' );
+ }
+
+ // If the current default charset is not supported, use a charset that is
+ $charsets = $this->getCharsets();
+ if ( !in_array( $this->getVar( '_MysqlCharset' ), $charsets ) ) {
+ $this->setVar( '_MysqlCharset', reset( $charsets ) );
+ }
+
+ // Do charset selector
+ if ( count( $charsets ) >= 2 ) {
+ // getRadioSet() builds a set of labeled radio buttons.
+ // For grep: The following messages are used as the item labels:
+ // config-mysql-binary, config-mysql-utf8
+ $s .= $this->getRadioSet( [
+ 'var' => '_MysqlCharset',
+ 'label' => 'config-mysql-charset',
+ 'itemLabelPrefix' => 'config-mysql-',
+ 'values' => $charsets
+ ] );
+ $s .= $this->parent->getHelpBox( 'config-mysql-charset-help' );
+ }
+
+ return $s;
+ }
+
+ /**
+ * @return Status
+ */
+ public function submitSettingsForm() {
+ $this->setVarsFromRequest( [ '_MysqlEngine', '_MysqlCharset' ] );
+ $status = $this->submitWebUserBox();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // Validate the create checkbox
+ $canCreate = $this->canCreateAccounts();
+ if ( !$canCreate ) {
+ $this->setVar( '_CreateDBAccount', false );
+ $create = false;
+ } else {
+ $create = $this->getVar( '_CreateDBAccount' );
+ }
+
+ if ( !$create ) {
+ // Test the web account
+ try {
+ Database::factory( 'mysql', [
+ 'host' => $this->getVar( 'wgDBserver' ),
+ 'user' => $this->getVar( 'wgDBuser' ),
+ 'password' => $this->getVar( 'wgDBpassword' ),
+ 'dbname' => false,
+ 'flags' => 0,
+ 'tablePrefix' => $this->getVar( 'wgDBprefix' )
+ ] );
+ } catch ( DBConnectionError $e ) {
+ return Status::newFatal( 'config-connection-error', $e->getMessage() );
+ }
+ }
+
+ // Validate engines and charsets
+ // This is done pre-submit already so it's just for security
+ $engines = $this->getEngines();
+ if ( !in_array( $this->getVar( '_MysqlEngine' ), $engines ) ) {
+ $this->setVar( '_MysqlEngine', reset( $engines ) );
+ }
+ $charsets = $this->getCharsets();
+ if ( !in_array( $this->getVar( '_MysqlCharset' ), $charsets ) ) {
+ $this->setVar( '_MysqlCharset', reset( $charsets ) );
+ }
+
+ return Status::newGood();
+ }
+
+ public function preInstall() {
+ # Add our user callback to installSteps, right before the tables are created.
+ $callback = [
+ 'name' => 'user',
+ 'callback' => [ $this, 'setupUser' ],
+ ];
+ $this->parent->addInstallStep( $callback, 'tables' );
+ }
+
+ /**
+ * @return Status
+ */
+ public function setupDatabase() {
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ /** @var Database $conn */
+ $conn = $status->value;
+ $dbName = $this->getVar( 'wgDBname' );
+ if ( !$conn->selectDB( $dbName ) ) {
+ $conn->query(
+ "CREATE DATABASE " . $conn->addIdentifierQuotes( $dbName ) . "CHARACTER SET utf8",
+ __METHOD__
+ );
+ $conn->selectDB( $dbName );
+ }
+ $this->setupSchemaVars();
+
+ return $status;
+ }
+
+ /**
+ * @return Status
+ */
+ public function setupUser() {
+ $dbUser = $this->getVar( 'wgDBuser' );
+ if ( $dbUser == $this->getVar( '_InstallUser' ) ) {
+ return Status::newGood();
+ }
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $this->setupSchemaVars();
+ $dbName = $this->getVar( 'wgDBname' );
+ $this->db->selectDB( $dbName );
+ $server = $this->getVar( 'wgDBserver' );
+ $password = $this->getVar( 'wgDBpassword' );
+ $grantableNames = [];
+
+ if ( $this->getVar( '_CreateDBAccount' ) ) {
+ // Before we blindly try to create a user that already has access,
+ try { // first attempt to connect to the database
+ Database::factory( 'mysql', [
+ 'host' => $server,
+ 'user' => $dbUser,
+ 'password' => $password,
+ 'dbname' => false,
+ 'flags' => 0,
+ 'tablePrefix' => $this->getVar( 'wgDBprefix' )
+ ] );
+ $grantableNames[] = $this->buildFullUserName( $dbUser, $server );
+ $tryToCreate = false;
+ } catch ( DBConnectionError $e ) {
+ $tryToCreate = true;
+ }
+ } else {
+ $grantableNames[] = $this->buildFullUserName( $dbUser, $server );
+ $tryToCreate = false;
+ }
+
+ if ( $tryToCreate ) {
+ $createHostList = [
+ $server,
+ 'localhost',
+ 'localhost.localdomain',
+ '%'
+ ];
+
+ $createHostList = array_unique( $createHostList );
+ $escPass = $this->db->addQuotes( $password );
+
+ foreach ( $createHostList as $host ) {
+ $fullName = $this->buildFullUserName( $dbUser, $host );
+ if ( !$this->userDefinitelyExists( $host, $dbUser ) ) {
+ try {
+ $this->db->begin( __METHOD__ );
+ $this->db->query( "CREATE USER $fullName IDENTIFIED BY $escPass", __METHOD__ );
+ $this->db->commit( __METHOD__ );
+ $grantableNames[] = $fullName;
+ } catch ( DBQueryError $dqe ) {
+ if ( $this->db->lastErrno() == 1396 /* ER_CANNOT_USER */ ) {
+ // User (probably) already exists
+ $this->db->rollback( __METHOD__ );
+ $status->warning( 'config-install-user-alreadyexists', $dbUser );
+ $grantableNames[] = $fullName;
+ break;
+ } else {
+ // If we couldn't create for some bizzare reason and the
+ // user probably doesn't exist, skip the grant
+ $this->db->rollback( __METHOD__ );
+ $status->warning( 'config-install-user-create-failed', $dbUser, $dqe->getMessage() );
+ }
+ }
+ } else {
+ $status->warning( 'config-install-user-alreadyexists', $dbUser );
+ $grantableNames[] = $fullName;
+ break;
+ }
+ }
+ }
+
+ // Try to grant to all the users we know exist or we were able to create
+ $dbAllTables = $this->db->addIdentifierQuotes( $dbName ) . '.*';
+ foreach ( $grantableNames as $name ) {
+ try {
+ $this->db->begin( __METHOD__ );
+ $this->db->query( "GRANT ALL PRIVILEGES ON $dbAllTables TO $name", __METHOD__ );
+ $this->db->commit( __METHOD__ );
+ } catch ( DBQueryError $dqe ) {
+ $this->db->rollback( __METHOD__ );
+ $status->fatal( 'config-install-user-grant-failed', $dbUser, $dqe->getMessage() );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Return a formal 'User'@'Host' username for use in queries
+ * @param string $name Username, quotes will be added
+ * @param string $host Hostname, quotes will be added
+ * @return string
+ */
+ private function buildFullUserName( $name, $host ) {
+ return $this->db->addQuotes( $name ) . '@' . $this->db->addQuotes( $host );
+ }
+
+ /**
+ * Try to see if the user account exists. Our "superuser" may not have
+ * access to mysql.user, so false means "no" or "maybe"
+ * @param string $host Hostname to check
+ * @param string $user Username to check
+ * @return bool
+ */
+ private function userDefinitelyExists( $host, $user ) {
+ try {
+ $res = $this->db->selectRow( 'mysql.user', [ 'Host', 'User' ],
+ [ 'Host' => $host, 'User' => $user ], __METHOD__ );
+
+ return (bool)$res;
+ } catch ( DBQueryError $dqe ) {
+ return false;
+ }
+ }
+
+ /**
+ * Return any table options to be applied to all tables that don't
+ * override them.
+ *
+ * @return string
+ */
+ protected function getTableOptions() {
+ $options = [];
+ if ( $this->getVar( '_MysqlEngine' ) !== null ) {
+ $options[] = "ENGINE=" . $this->getVar( '_MysqlEngine' );
+ }
+ if ( $this->getVar( '_MysqlCharset' ) !== null ) {
+ $options[] = 'DEFAULT CHARSET=' . $this->getVar( '_MysqlCharset' );
+ }
+
+ return implode( ', ', $options );
+ }
+
+ /**
+ * Get variables to substitute into tables.sql and the SQL patch files.
+ *
+ * @return array
+ */
+ public function getSchemaVars() {
+ return [
+ 'wgDBTableOptions' => $this->getTableOptions(),
+ 'wgDBname' => $this->getVar( 'wgDBname' ),
+ 'wgDBuser' => $this->getVar( 'wgDBuser' ),
+ 'wgDBpassword' => $this->getVar( 'wgDBpassword' ),
+ ];
+ }
+
+ public function getLocalSettings() {
+ $dbmysql5 = wfBoolToStr( $this->getVar( 'wgDBmysql5', true ) );
+ $prefix = LocalSettingsGenerator::escapePhpString( $this->getVar( 'wgDBprefix' ) );
+ $tblOpts = LocalSettingsGenerator::escapePhpString( $this->getTableOptions() );
+
+ return "# MySQL specific settings
+\$wgDBprefix = \"{$prefix}\";
+
+# MySQL table options to use during installation or update
+\$wgDBTableOptions = \"{$tblOpts}\";
+
+# Experimental charset support for MySQL 5.0.
+\$wgDBmysql5 = {$dbmysql5};";
+ }
+}
diff --git a/www/wiki/includes/installer/MysqlUpdater.php b/www/wiki/includes/installer/MysqlUpdater.php
new file mode 100644
index 00000000..e2ff9604
--- /dev/null
+++ b/www/wiki/includes/installer/MysqlUpdater.php
@@ -0,0 +1,1213 @@
+<?php
+/**
+ * MySQL-specific updater.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+use Wikimedia\Rdbms\Field;
+use Wikimedia\Rdbms\MySQLField;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Mysql update list and mysql-specific update functions.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class MysqlUpdater extends DatabaseUpdater {
+ protected function getCoreUpdateList() {
+ return [
+ [ 'disableContentHandlerUseDB' ],
+
+ // 1.2
+ [ 'addField', 'ipblocks', 'ipb_id', 'patch-ipblocks.sql' ],
+ [ 'addField', 'ipblocks', 'ipb_expiry', 'patch-ipb_expiry.sql' ],
+ [ 'doInterwikiUpdate' ],
+ [ 'doIndexUpdate' ],
+ [ 'addField', 'recentchanges', 'rc_type', 'patch-rc_type.sql' ],
+ [ 'addIndex', 'recentchanges', 'new_name_timestamp', 'patch-rc-newindex.sql' ],
+
+ // 1.3
+ [ 'addField', 'user', 'user_real_name', 'patch-user-realname.sql' ],
+ [ 'addTable', 'querycache', 'patch-querycache.sql' ],
+ [ 'addTable', 'objectcache', 'patch-objectcache.sql' ],
+ [ 'addTable', 'categorylinks', 'patch-categorylinks.sql' ],
+ [ 'doOldLinksUpdate' ],
+ [ 'doFixAncientImagelinks' ],
+ [ 'addField', 'recentchanges', 'rc_ip', 'patch-rc_ip.sql' ],
+
+ // 1.4
+ [ 'addIndex', 'image', 'PRIMARY', 'patch-image_name_primary.sql' ],
+ [ 'addField', 'recentchanges', 'rc_id', 'patch-rc_id.sql' ],
+ [ 'addField', 'recentchanges', 'rc_patrolled', 'patch-rc-patrol.sql' ],
+ [ 'addTable', 'logging', 'patch-logging.sql' ],
+ [ 'addField', 'user', 'user_token', 'patch-user_token.sql' ],
+ [ 'addField', 'watchlist', 'wl_notificationtimestamp', 'patch-email-notification.sql' ],
+ [ 'doWatchlistUpdate' ],
+ [ 'dropField', 'user', 'user_emailauthenticationtimestamp',
+ 'patch-email-authentication.sql' ],
+
+ // 1.5
+ [ 'doSchemaRestructuring' ],
+ [ 'addField', 'logging', 'log_params', 'patch-log_params.sql' ],
+ [ 'checkBin', 'logging', 'log_title', 'patch-logging-title.sql', ],
+ [ 'addField', 'archive', 'ar_rev_id', 'patch-archive-rev_id.sql' ],
+ [ 'addField', 'page', 'page_len', 'patch-page_len.sql' ],
+ [ 'dropField', 'revision', 'inverse_timestamp', 'patch-inverse_timestamp.sql' ],
+ [ 'addField', 'revision', 'rev_text_id', 'patch-rev_text_id.sql' ],
+ [ 'addField', 'revision', 'rev_deleted', 'patch-rev_deleted.sql' ],
+ [ 'addField', 'image', 'img_width', 'patch-img_width.sql' ],
+ [ 'addField', 'image', 'img_metadata', 'patch-img_metadata.sql' ],
+ [ 'addField', 'user', 'user_email_token', 'patch-user_email_token.sql' ],
+ [ 'addField', 'archive', 'ar_text_id', 'patch-archive-text_id.sql' ],
+ [ 'doNamespaceSize' ],
+ [ 'addField', 'image', 'img_media_type', 'patch-img_media_type.sql' ],
+ [ 'doPagelinksUpdate' ],
+ [ 'dropField', 'image', 'img_type', 'patch-drop_img_type.sql' ],
+ [ 'doUserUniqueUpdate' ],
+ [ 'doUserGroupsUpdate' ],
+ [ 'addField', 'site_stats', 'ss_total_pages', 'patch-ss_total_articles.sql' ],
+ [ 'addTable', 'user_newtalk', 'patch-usernewtalk2.sql' ],
+ [ 'addTable', 'transcache', 'patch-transcache.sql' ],
+ [ 'addField', 'interwiki', 'iw_trans', 'patch-interwiki-trans.sql' ],
+
+ // 1.6
+ [ 'doWatchlistNull' ],
+ [ 'addIndex', 'logging', 'times', 'patch-logging-times-index.sql' ],
+ [ 'addField', 'ipblocks', 'ipb_range_start', 'patch-ipb_range_start.sql' ],
+ [ 'doPageRandomUpdate' ],
+ [ 'addField', 'user', 'user_registration', 'patch-user_registration.sql' ],
+ [ 'doTemplatelinksUpdate' ],
+ [ 'addTable', 'externallinks', 'patch-externallinks.sql' ],
+ [ 'addTable', 'job', 'patch-job.sql' ],
+ [ 'addField', 'site_stats', 'ss_images', 'patch-ss_images.sql' ],
+ [ 'addTable', 'langlinks', 'patch-langlinks.sql' ],
+ [ 'addTable', 'querycache_info', 'patch-querycacheinfo.sql' ],
+ [ 'addTable', 'filearchive', 'patch-filearchive.sql' ],
+ [ 'addField', 'ipblocks', 'ipb_anon_only', 'patch-ipb_anon_only.sql' ],
+ [ 'addIndex', 'recentchanges', 'rc_ns_usertext', 'patch-recentchanges-utindex.sql' ],
+ [ 'addIndex', 'recentchanges', 'rc_user_text', 'patch-rc_user_text-index.sql' ],
+
+ // 1.9
+ [ 'addField', 'user', 'user_newpass_time', 'patch-user_newpass_time.sql' ],
+ [ 'addTable', 'redirect', 'patch-redirect.sql' ],
+ [ 'addTable', 'querycachetwo', 'patch-querycachetwo.sql' ],
+ [ 'addField', 'ipblocks', 'ipb_enable_autoblock', 'patch-ipb_optional_autoblock.sql' ],
+ [ 'doBacklinkingIndicesUpdate' ],
+ [ 'addField', 'recentchanges', 'rc_old_len', 'patch-rc_len.sql' ],
+ [ 'addField', 'user', 'user_editcount', 'patch-user_editcount.sql' ],
+
+ // 1.10
+ [ 'doRestrictionsUpdate' ],
+ [ 'addField', 'logging', 'log_id', 'patch-log_id.sql' ],
+ [ 'addField', 'revision', 'rev_parent_id', 'patch-rev_parent_id.sql' ],
+ [ 'addField', 'page_restrictions', 'pr_id', 'patch-page_restrictions_sortkey.sql' ],
+ [ 'addField', 'revision', 'rev_len', 'patch-rev_len.sql' ],
+ [ 'addField', 'recentchanges', 'rc_deleted', 'patch-rc_deleted.sql' ],
+ [ 'addField', 'logging', 'log_deleted', 'patch-log_deleted.sql' ],
+ [ 'addField', 'archive', 'ar_deleted', 'patch-ar_deleted.sql' ],
+ [ 'addField', 'ipblocks', 'ipb_deleted', 'patch-ipb_deleted.sql' ],
+ [ 'addField', 'filearchive', 'fa_deleted', 'patch-fa_deleted.sql' ],
+ [ 'addField', 'archive', 'ar_len', 'patch-ar_len.sql' ],
+
+ // 1.11
+ [ 'addField', 'ipblocks', 'ipb_block_email', 'patch-ipb_emailban.sql' ],
+ [ 'doCategorylinksIndicesUpdate' ],
+ [ 'addField', 'oldimage', 'oi_metadata', 'patch-oi_metadata.sql' ],
+ [ 'addIndex', 'archive', 'usertext_timestamp', 'patch-archive-user-index.sql' ],
+ [ 'addIndex', 'image', 'img_usertext_timestamp', 'patch-image-user-index.sql' ],
+ [ 'addIndex', 'oldimage', 'oi_usertext_timestamp', 'patch-oldimage-user-index.sql' ],
+ [ 'addField', 'archive', 'ar_page_id', 'patch-archive-page_id.sql' ],
+ [ 'addField', 'image', 'img_sha1', 'patch-img_sha1.sql' ],
+
+ // 1.12
+ [ 'addTable', 'protected_titles', 'patch-protected_titles.sql' ],
+
+ // 1.13
+ [ 'addField', 'ipblocks', 'ipb_by_text', 'patch-ipb_by_text.sql' ],
+ [ 'addTable', 'page_props', 'patch-page_props.sql' ],
+ [ 'addTable', 'updatelog', 'patch-updatelog.sql' ],
+ [ 'addTable', 'category', 'patch-category.sql' ],
+ [ 'doCategoryPopulation' ],
+ [ 'addField', 'archive', 'ar_parent_id', 'patch-ar_parent_id.sql' ],
+ [ 'addField', 'user_newtalk', 'user_last_timestamp', 'patch-user_last_timestamp.sql' ],
+ [ 'doPopulateParentId' ],
+ [ 'checkBin', 'protected_titles', 'pt_title', 'patch-pt_title-encoding.sql', ],
+ [ 'doMaybeProfilingMemoryUpdate' ],
+ [ 'doFilearchiveIndicesUpdate' ],
+
+ // 1.14
+ [ 'addField', 'site_stats', 'ss_active_users', 'patch-ss_active_users.sql' ],
+ [ 'doActiveUsersInit' ],
+ [ 'addField', 'ipblocks', 'ipb_allow_usertalk', 'patch-ipb_allow_usertalk.sql' ],
+
+ // 1.15
+ [ 'addTable', 'change_tag', 'patch-change_tag.sql' ],
+ [ 'addTable', 'tag_summary', 'patch-tag_summary.sql' ],
+ [ 'addTable', 'valid_tag', 'patch-valid_tag.sql' ],
+
+ // 1.16
+ [ 'addTable', 'user_properties', 'patch-user_properties.sql' ],
+ [ 'addTable', 'log_search', 'patch-log_search.sql' ],
+ [ 'addField', 'logging', 'log_user_text', 'patch-log_user_text.sql' ],
+ # listed separately from the previous update because 1.16 was released without this update
+ [ 'doLogUsertextPopulation' ],
+ [ 'doLogSearchPopulation' ],
+ [ 'addTable', 'l10n_cache', 'patch-l10n_cache.sql' ],
+ [ 'addIndex', 'change_tag', 'change_tag_rc_tag', 'patch-change_tag-indexes.sql' ],
+ [ 'addField', 'redirect', 'rd_interwiki', 'patch-rd_interwiki.sql' ],
+ [ 'doUpdateTranscacheField' ],
+ [ 'doUpdateMimeMinorField' ],
+
+ // 1.17
+ [ 'addTable', 'iwlinks', 'patch-iwlinks.sql' ],
+ [ 'addIndex', 'iwlinks', 'iwl_prefix_title_from', 'patch-rename-iwl_prefix.sql' ],
+ [ 'addField', 'updatelog', 'ul_value', 'patch-ul_value.sql' ],
+ [ 'addField', 'interwiki', 'iw_api', 'patch-iw_api_and_wikiid.sql' ],
+ [ 'dropIndex', 'iwlinks', 'iwl_prefix', 'patch-kill-iwl_prefix.sql' ],
+ [ 'addField', 'categorylinks', 'cl_collation', 'patch-categorylinks-better-collation.sql' ],
+ [ 'doClFieldsUpdate' ],
+ [ 'addTable', 'module_deps', 'patch-module_deps.sql' ],
+ [ 'dropIndex', 'archive', 'ar_page_revid', 'patch-archive_kill_ar_page_revid.sql' ],
+ [ 'addIndex', 'archive', 'ar_revid', 'patch-archive_ar_revid.sql' ],
+ [ 'doLangLinksLengthUpdate' ],
+
+ // 1.18
+ [ 'doUserNewTalkTimestampNotNull' ],
+ [ 'addIndex', 'user', 'user_email', 'patch-user_email_index.sql' ],
+ [ 'modifyField', 'user_properties', 'up_property', 'patch-up_property.sql' ],
+ [ 'addTable', 'uploadstash', 'patch-uploadstash.sql' ],
+ [ 'addTable', 'user_former_groups', 'patch-user_former_groups.sql' ],
+
+ // 1.19
+ [ 'addIndex', 'logging', 'type_action', 'patch-logging-type-action-index.sql' ],
+ [ 'addField', 'revision', 'rev_sha1', 'patch-rev_sha1.sql' ],
+ [ 'doMigrateUserOptions' ],
+ [ 'dropField', 'user', 'user_options', 'patch-drop-user_options.sql' ],
+ [ 'addField', 'archive', 'ar_sha1', 'patch-ar_sha1.sql' ],
+ [ 'addIndex', 'page', 'page_redirect_namespace_len',
+ 'patch-page_redirect_namespace_len.sql' ],
+ [ 'addField', 'uploadstash', 'us_chunk_inx', 'patch-uploadstash_chunk.sql' ],
+ [ 'addfield', 'job', 'job_timestamp', 'patch-jobs-add-timestamp.sql' ],
+
+ // 1.20
+ [ 'addIndex', 'revision', 'page_user_timestamp', 'patch-revision-user-page-index.sql' ],
+ [ 'addField', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id.sql' ],
+ [ 'addIndex', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id-index.sql' ],
+ [ 'dropField', 'category', 'cat_hidden', 'patch-cat_hidden.sql' ],
+
+ // 1.21
+ [ 'addField', 'revision', 'rev_content_format', 'patch-revision-rev_content_format.sql' ],
+ [ 'addField', 'revision', 'rev_content_model', 'patch-revision-rev_content_model.sql' ],
+ [ 'addField', 'archive', 'ar_content_format', 'patch-archive-ar_content_format.sql' ],
+ [ 'addField', 'archive', 'ar_content_model', 'patch-archive-ar_content_model.sql' ],
+ [ 'addField', 'page', 'page_content_model', 'patch-page-page_content_model.sql' ],
+ [ 'enableContentHandlerUseDB' ],
+ [ 'dropField', 'site_stats', 'ss_admins', 'patch-drop-ss_admins.sql' ],
+ [ 'dropField', 'recentchanges', 'rc_moved_to_title', 'patch-rc_moved.sql' ],
+ [ 'addTable', 'sites', 'patch-sites.sql' ],
+ [ 'addField', 'filearchive', 'fa_sha1', 'patch-fa_sha1.sql' ],
+ [ 'addField', 'job', 'job_token', 'patch-job_token.sql' ],
+ [ 'addField', 'job', 'job_attempts', 'patch-job_attempts.sql' ],
+ [ 'doEnableProfiling' ],
+ [ 'addField', 'uploadstash', 'us_props', 'patch-uploadstash-us_props.sql' ],
+ [ 'modifyField', 'user_groups', 'ug_group', 'patch-ug_group-length-increase-255.sql' ],
+ [ 'modifyField', 'user_former_groups', 'ufg_group',
+ 'patch-ufg_group-length-increase-255.sql' ],
+ [ 'addIndex', 'page_props', 'pp_propname_page',
+ 'patch-page_props-propname-page-index.sql' ],
+ [ 'addIndex', 'image', 'img_media_mime', 'patch-img_media_mime-index.sql' ],
+
+ // 1.22
+ [ 'doIwlinksIndexNonUnique' ],
+ [ 'addIndex', 'iwlinks', 'iwl_prefix_from_title',
+ 'patch-iwlinks-from-title-index.sql' ],
+ [ 'addField', 'archive', 'ar_id', 'patch-archive-ar_id.sql' ],
+ [ 'addField', 'externallinks', 'el_id', 'patch-externallinks-el_id.sql' ],
+
+ // 1.23
+ [ 'addField', 'recentchanges', 'rc_source', 'patch-rc_source.sql' ],
+ [ 'addIndex', 'logging', 'log_user_text_type_time',
+ 'patch-logging_user_text_type_time_index.sql' ],
+ [ 'addIndex', 'logging', 'log_user_text_time', 'patch-logging_user_text_time_index.sql' ],
+ [ 'addField', 'page', 'page_links_updated', 'patch-page_links_updated.sql' ],
+ [ 'addField', 'user', 'user_password_expires', 'patch-user_password_expire.sql' ],
+
+ // 1.24
+ [ 'addField', 'page_props', 'pp_sortkey', 'patch-pp_sortkey.sql' ],
+ [ 'dropField', 'recentchanges', 'rc_cur_time', 'patch-drop-rc_cur_time.sql' ],
+ [ 'addIndex', 'watchlist', 'wl_user_notificationtimestamp',
+ 'patch-watchlist-user-notificationtimestamp-index.sql' ],
+ [ 'addField', 'page', 'page_lang', 'patch-page_lang.sql' ],
+ [ 'addField', 'pagelinks', 'pl_from_namespace', 'patch-pl_from_namespace.sql' ],
+ [ 'addField', 'templatelinks', 'tl_from_namespace', 'patch-tl_from_namespace.sql' ],
+ [ 'addField', 'imagelinks', 'il_from_namespace', 'patch-il_from_namespace.sql' ],
+ [ 'modifyField', 'image', 'img_major_mime',
+ 'patch-img_major_mime-chemical.sql' ],
+ [ 'modifyField', 'oldimage', 'oi_major_mime',
+ 'patch-oi_major_mime-chemical.sql' ],
+ [ 'modifyField', 'filearchive', 'fa_major_mime',
+ 'patch-fa_major_mime-chemical.sql' ],
+
+ // 1.25
+ // note this patch covers other _comment and _description fields too
+ [ 'doExtendCommentLengths' ],
+
+ // 1.26
+ [ 'dropTable', 'hitcounter' ],
+ [ 'dropField', 'site_stats', 'ss_total_views', 'patch-drop-ss_total_views.sql' ],
+ [ 'dropField', 'page', 'page_counter', 'patch-drop-page_counter.sql' ],
+
+ // 1.27
+ [ 'dropTable', 'msg_resource_links' ],
+ [ 'dropTable', 'msg_resource' ],
+ [ 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ],
+ [ 'addField', 'watchlist', 'wl_id', 'patch-watchlist-wl_id.sql' ],
+ [ 'dropIndex', 'categorylinks', 'cl_collation', 'patch-kill-cl_collation_index.sql' ],
+ [ 'addIndex', 'categorylinks', 'cl_collation_ext',
+ 'patch-add-cl_collation_ext_index.sql' ],
+ [ 'doCollationUpdate' ],
+
+ // 1.28
+ [ 'addIndex', 'recentchanges', 'rc_name_type_patrolled_timestamp',
+ 'patch-add-rc_name_type_patrolled_timestamp_index.sql' ],
+ [ 'doRevisionPageRevIndexNonUnique' ],
+ [ 'doNonUniquePlTlIl' ],
+ [ 'addField', 'change_tag', 'ct_id', 'patch-change_tag-ct_id.sql' ],
+ [ 'addField', 'tag_summary', 'ts_id', 'patch-tag_summary-ts_id.sql' ],
+ [ 'modifyField', 'recentchanges', 'rc_ip', 'patch-rc_ip_modify.sql' ],
+ [ 'addIndex', 'archive', 'usertext_timestamp', 'patch-rename-ar_usertext_timestamp.sql' ],
+
+ // 1.29
+ [ 'addField', 'externallinks', 'el_index_60', 'patch-externallinks-el_index_60.sql' ],
+ [ 'dropIndex', 'user_groups', 'ug_user_group', 'patch-user_groups-primary-key.sql' ],
+ [ 'addField', 'user_groups', 'ug_expiry', 'patch-user_groups-ug_expiry.sql' ],
+ [ 'addIndex', 'image', 'img_user_timestamp', 'patch-image-user-index-2.sql' ],
+
+ // 1.30
+ [ 'modifyField', 'image', 'img_media_type', 'patch-add-3d.sql' ],
+ [ 'addTable', 'ip_changes', 'patch-ip_changes.sql' ],
+ [ 'renameIndex', 'categorylinks', 'cl_from', 'PRIMARY', false,
+ 'patch-categorylinks-fix-pk.sql' ],
+ [ 'renameIndex', 'templatelinks', 'tl_from', 'PRIMARY', false,
+ 'patch-templatelinks-fix-pk.sql' ],
+ [ 'renameIndex', 'pagelinks', 'pl_from', 'PRIMARY', false, 'patch-pagelinks-fix-pk.sql' ],
+ [ 'renameIndex', 'text', 'old_id', 'PRIMARY', false, 'patch-text-fix-pk.sql' ],
+ [ 'renameIndex', 'imagelinks', 'il_from', 'PRIMARY', false, 'patch-imagelinks-fix-pk.sql' ],
+ [ 'renameIndex', 'iwlinks', 'iwl_from', 'PRIMARY', false, 'patch-iwlinks-fix-pk.sql' ],
+ [ 'renameIndex', 'langlinks', 'll_from', 'PRIMARY', false, 'patch-langlinks-fix-pk.sql' ],
+ [ 'renameIndex', 'log_search', 'ls_field_val', 'PRIMARY', false, 'patch-log_search-fix-pk.sql' ],
+ [ 'renameIndex', 'module_deps', 'md_module_skin', 'PRIMARY', false,
+ 'patch-module_deps-fix-pk.sql' ],
+ [ 'renameIndex', 'objectcache', 'keyname', 'PRIMARY', false, 'patch-objectcache-fix-pk.sql' ],
+ [ 'renameIndex', 'querycache_info', 'qci_type', 'PRIMARY', false,
+ 'patch-querycache_info-fix-pk.sql' ],
+ [ 'renameIndex', 'site_stats', 'ss_row_id', 'PRIMARY', false, 'patch-site_stats-fix-pk.sql' ],
+ [ 'renameIndex', 'transcache', 'tc_url_idx', 'PRIMARY', false, 'patch-transcache-fix-pk.sql' ],
+ [ 'renameIndex', 'user_former_groups', 'ufg_user_group', 'PRIMARY', false,
+ 'patch-user_former_groups-fix-pk.sql' ],
+ [ 'renameIndex', 'user_properties', 'user_properties_user_property', 'PRIMARY', false,
+ 'patch-user_properties-fix-pk.sql' ],
+ [ 'addTable', 'comment', 'patch-comment-table.sql' ],
+ [ 'migrateComments' ],
+ [ 'renameIndex', 'l10n_cache', 'lc_lang_key', 'PRIMARY', false,
+ 'patch-l10n_cache-primary-key.sql' ],
+ [ 'doUnsignedSyncronisation' ],
+ ];
+ }
+
+ /**
+ * 1.4 betas were missing the 'binary' marker from logging.log_title,
+ * which causes a collation mismatch error on joins in MySQL 4.1.
+ *
+ * @param string $table Table name
+ * @param string $field Field name to check
+ * @param string $patchFile Path to the patch to correct the field
+ * @return bool
+ */
+ protected function checkBin( $table, $field, $patchFile ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ /** @var MySQLField $fieldInfo */
+ $fieldInfo = $this->db->fieldInfo( $table, $field );
+ if ( $fieldInfo->isBinary() ) {
+ $this->output( "...$table table has correct $field encoding.\n" );
+ } else {
+ $this->applyPatch( $patchFile, false, "Fixing $field encoding on $table table" );
+ }
+ }
+
+ /**
+ * Check whether an index contain a field
+ *
+ * @param string $table Table name
+ * @param string $index Index name to check
+ * @param string $field Field that should be in the index
+ * @return bool
+ */
+ protected function indexHasField( $table, $index, $field ) {
+ if ( !$this->doTable( $table ) ) {
+ return true;
+ }
+
+ $info = $this->db->indexInfo( $table, $index, __METHOD__ );
+ if ( $info ) {
+ foreach ( $info as $row ) {
+ if ( $row->Column_name == $field ) {
+ $this->output( "...index $index on table $table includes field $field.\n" );
+
+ return true;
+ }
+ }
+ }
+ $this->output( "...index $index on table $table has no field $field; added.\n" );
+
+ return false;
+ }
+
+ /**
+ * Check that interwiki table exists; if it doesn't source it
+ */
+ protected function doInterwikiUpdate() {
+ global $IP;
+
+ if ( !$this->doTable( 'interwiki' ) ) {
+ return true;
+ }
+
+ if ( $this->db->tableExists( "interwiki", __METHOD__ ) ) {
+ $this->output( "...already have interwiki table\n" );
+
+ return;
+ }
+
+ $this->applyPatch( 'patch-interwiki.sql', false, 'Creating interwiki table' );
+ $this->applyPatch(
+ "$IP/maintenance/interwiki.sql",
+ true,
+ 'Adding default interwiki definitions'
+ );
+ }
+
+ /**
+ * Check that proper indexes are in place
+ */
+ protected function doIndexUpdate() {
+ $meta = $this->db->fieldInfo( 'recentchanges', 'rc_timestamp' );
+ if ( $meta === false ) {
+ throw new MWException( 'Missing rc_timestamp field of recentchanges table. Should not happen.' );
+ }
+ if ( $meta->isMultipleKey() ) {
+ $this->output( "...indexes seem up to 20031107 standards.\n" );
+
+ return;
+ }
+
+ $this->applyPatch( 'patch-indexes.sql', true, "Updating indexes to 20031107" );
+ }
+
+ protected function doOldLinksUpdate() {
+ $cl = $this->maintenance->runChild( 'ConvertLinks' );
+ $cl->execute();
+ }
+
+ protected function doFixAncientImagelinks() {
+ $info = $this->db->fieldInfo( 'imagelinks', 'il_from' );
+ if ( !$info || $info->type() !== 'string' ) {
+ $this->output( "...il_from OK\n" );
+
+ return;
+ }
+
+ $applied = $this->applyPatch(
+ 'patch-fix-il_from.sql',
+ false,
+ 'Fixing ancient broken imagelinks table.'
+ );
+
+ if ( $applied ) {
+ $this->output( "NOTE: you will have to run maintenance/refreshLinks.php after this." );
+ }
+ }
+
+ /**
+ * Check if we need to add talk page rows to the watchlist
+ */
+ function doWatchlistUpdate() {
+ $talk = $this->db->selectField( 'watchlist', 'count(*)', 'wl_namespace & 1', __METHOD__ );
+ $nontalk = $this->db->selectField(
+ 'watchlist',
+ 'count(*)',
+ 'NOT (wl_namespace & 1)',
+ __METHOD__
+ );
+ if ( $talk == $nontalk ) {
+ $this->output( "...watchlist talk page rows already present.\n" );
+
+ return;
+ }
+
+ $this->output( "Adding missing watchlist talk page rows... " );
+ $this->db->insertSelect( 'watchlist', 'watchlist',
+ [
+ 'wl_user' => 'wl_user',
+ 'wl_namespace' => 'wl_namespace | 1',
+ 'wl_title' => 'wl_title',
+ 'wl_notificationtimestamp' => 'wl_notificationtimestamp'
+ ], [ 'NOT (wl_namespace & 1)' ], __METHOD__, 'IGNORE' );
+ $this->output( "done.\n" );
+
+ $this->output( "Adding missing watchlist subject page rows... " );
+ $this->db->insertSelect( 'watchlist', 'watchlist',
+ [
+ 'wl_user' => 'wl_user',
+ 'wl_namespace' => 'wl_namespace & ~1',
+ 'wl_title' => 'wl_title',
+ 'wl_notificationtimestamp' => 'wl_notificationtimestamp'
+ ], [ 'wl_namespace & 1' ], __METHOD__, 'IGNORE' );
+ $this->output( "done.\n" );
+ }
+
+ function doSchemaRestructuring() {
+ if ( $this->db->tableExists( 'page', __METHOD__ ) ) {
+ $this->output( "...page table already exists.\n" );
+
+ return;
+ }
+
+ $this->output( "...converting from cur/old to page/revision/text DB structure.\n" );
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......checking for duplicate entries.\n" );
+
+ list( $cur, $old, $page, $revision, $text ) = $this->db->tableNamesN(
+ 'cur',
+ 'old',
+ 'page',
+ 'revision',
+ 'text'
+ );
+
+ $rows = $this->db->query( "
+ SELECT cur_title, cur_namespace, COUNT(cur_namespace) AS c
+ FROM $cur
+ GROUP BY cur_title, cur_namespace
+ HAVING c>1",
+ __METHOD__
+ );
+
+ if ( $rows->numRows() > 0 ) {
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......<b>Found duplicate entries</b>\n" );
+ $this->output( sprintf( "<b> %-60s %3s %5s</b>\n", 'Title', 'NS', 'Count' ) );
+ $duplicate = [];
+ foreach ( $rows as $row ) {
+ if ( !isset( $duplicate[$row->cur_namespace] ) ) {
+ $duplicate[$row->cur_namespace] = [];
+ }
+
+ $duplicate[$row->cur_namespace][] = $row->cur_title;
+ $this->output( sprintf(
+ " %-60s %3s %5s\n",
+ $row->cur_title, $row->cur_namespace,
+ $row->c
+ ) );
+ }
+ $sql = "SELECT cur_title, cur_namespace, cur_id, cur_timestamp FROM $cur WHERE ";
+ $firstCond = true;
+ foreach ( $duplicate as $ns => $titles ) {
+ if ( $firstCond ) {
+ $firstCond = false;
+ } else {
+ $sql .= ' OR ';
+ }
+ $sql .= "( cur_namespace = {$ns} AND cur_title in (";
+ $first = true;
+ foreach ( $titles as $t ) {
+ if ( $first ) {
+ $sql .= $this->db->addQuotes( $t );
+ $first = false;
+ } else {
+ $sql .= ', ' . $this->db->addQuotes( $t );
+ }
+ }
+ $sql .= ") ) \n";
+ }
+ # By sorting descending, the most recent entry will be the first in the list.
+ # All following entries will be deleted by the next while-loop.
+ $sql .= 'ORDER BY cur_namespace, cur_title, cur_timestamp DESC';
+
+ $rows = $this->db->query( $sql, __METHOD__ );
+
+ $prev_title = $prev_namespace = false;
+ $deleteId = [];
+
+ foreach ( $rows as $row ) {
+ if ( $prev_title == $row->cur_title && $prev_namespace == $row->cur_namespace ) {
+ $deleteId[] = $row->cur_id;
+ }
+ $prev_title = $row->cur_title;
+ $prev_namespace = $row->cur_namespace;
+ }
+ $sql = "DELETE FROM $cur WHERE cur_id IN ( " . implode( ',', $deleteId ) . ')';
+ $this->db->query( $sql, __METHOD__ );
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......<b>Deleted</b> " . $this->db->affectedRows() . " records.\n" );
+ }
+
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......Creating tables.\n" );
+ $this->db->query( "CREATE TABLE $page (
+ page_id int(8) unsigned NOT NULL auto_increment,
+ page_namespace int NOT NULL,
+ page_title varchar(255) binary NOT NULL,
+ page_restrictions tinyblob NOT NULL,
+ page_is_redirect tinyint(1) unsigned NOT NULL default '0',
+ page_is_new tinyint(1) unsigned NOT NULL default '0',
+ page_random real unsigned NOT NULL,
+ page_touched char(14) binary NOT NULL default '',
+ page_latest int(8) unsigned NOT NULL,
+ page_len int(8) unsigned NOT NULL,
+
+ PRIMARY KEY page_id (page_id),
+ UNIQUE INDEX name_title (page_namespace,page_title),
+ INDEX (page_random),
+ INDEX (page_len)
+ ) ENGINE=InnoDB", __METHOD__ );
+ $this->db->query( "CREATE TABLE $revision (
+ rev_id int(8) unsigned NOT NULL auto_increment,
+ rev_page int(8) unsigned NOT NULL,
+ rev_comment tinyblob NOT NULL,
+ rev_user int(5) unsigned NOT NULL default '0',
+ rev_user_text varchar(255) binary NOT NULL default '',
+ rev_timestamp char(14) binary NOT NULL default '',
+ rev_minor_edit tinyint(1) unsigned NOT NULL default '0',
+ rev_deleted tinyint(1) unsigned NOT NULL default '0',
+ rev_len int(8) unsigned,
+ rev_parent_id int(8) unsigned default NULL,
+ PRIMARY KEY rev_page_id (rev_page, rev_id),
+ UNIQUE INDEX rev_id (rev_id),
+ INDEX rev_timestamp (rev_timestamp),
+ INDEX page_timestamp (rev_page,rev_timestamp),
+ INDEX user_timestamp (rev_user,rev_timestamp),
+ INDEX usertext_timestamp (rev_user_text,rev_timestamp)
+ ) ENGINE=InnoDB", __METHOD__ );
+
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......Locking tables.\n" );
+ $this->db->query(
+ "LOCK TABLES $page WRITE, $revision WRITE, $old WRITE, $cur WRITE",
+ __METHOD__
+ );
+
+ $maxold = intval( $this->db->selectField( 'old', 'max(old_id)', '', __METHOD__ ) );
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......maxold is {$maxold}\n" );
+
+ $this->output( wfTimestamp( TS_DB ) );
+ global $wgLegacySchemaConversion;
+ if ( $wgLegacySchemaConversion ) {
+ // Create HistoryBlobCurStub entries.
+ // Text will be pulled from the leftover 'cur' table at runtime.
+ $this->output( "......Moving metadata from cur; using blob references to text in cur table.\n" );
+ $cur_text = "concat('O:18:\"historyblobcurstub\":1:{s:6:\"mCurId\";i:',cur_id,';}')";
+ $cur_flags = "'object'";
+ } else {
+ // Copy all cur text in immediately: this may take longer but avoids
+ // having to keep an extra table around.
+ $this->output( "......Moving text from cur.\n" );
+ $cur_text = 'cur_text';
+ $cur_flags = "''";
+ }
+ $this->db->query(
+ "INSERT INTO $old (old_namespace, old_title, old_text, old_comment, old_user,
+ old_user_text, old_timestamp, old_minor_edit, old_flags)
+ SELECT cur_namespace, cur_title, $cur_text, cur_comment, cur_user, cur_user_text,
+ cur_timestamp, cur_minor_edit, $cur_flags
+ FROM $cur",
+ __METHOD__
+ );
+
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......Setting up revision table.\n" );
+ $this->db->query(
+ "INSERT INTO $revision (rev_id, rev_page, rev_comment, rev_user,
+ rev_user_text, rev_timestamp, rev_minor_edit)
+ SELECT old_id, cur_id, old_comment, old_user, old_user_text,
+ old_timestamp, old_minor_edit
+ FROM $old,$cur WHERE old_namespace=cur_namespace AND old_title=cur_title",
+ __METHOD__
+ );
+
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......Setting up page table.\n" );
+ $this->db->query(
+ "INSERT INTO $page (page_id, page_namespace, page_title,
+ page_restrictions, page_is_redirect, page_is_new, page_random,
+ page_touched, page_latest, page_len)
+ SELECT cur_id, cur_namespace, cur_title, cur_restrictions,
+ cur_is_redirect, cur_is_new, cur_random, cur_touched, rev_id, LENGTH(cur_text)
+ FROM $cur,$revision
+ WHERE cur_id=rev_page AND rev_timestamp=cur_timestamp AND rev_id > {$maxold}",
+ __METHOD__
+ );
+
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......Unlocking tables.\n" );
+ $this->db->query( "UNLOCK TABLES", __METHOD__ );
+
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "......Renaming old.\n" );
+ $this->db->query( "ALTER TABLE $old RENAME TO $text", __METHOD__ );
+
+ $this->output( wfTimestamp( TS_DB ) );
+ $this->output( "...done.\n" );
+ }
+
+ protected function doNamespaceSize() {
+ $tables = [
+ 'page' => 'page',
+ 'archive' => 'ar',
+ 'recentchanges' => 'rc',
+ 'watchlist' => 'wl',
+ 'querycache' => 'qc',
+ 'logging' => 'log',
+ ];
+ foreach ( $tables as $table => $prefix ) {
+ $field = $prefix . '_namespace';
+
+ $tablename = $this->db->tableName( $table );
+ $result = $this->db->query( "SHOW COLUMNS FROM $tablename LIKE '$field'", __METHOD__ );
+ $info = $this->db->fetchObject( $result );
+
+ if ( substr( $info->Type, 0, 3 ) == 'int' ) {
+ $this->output( "...$field is already a full int ($info->Type).\n" );
+ } else {
+ $this->output( "Promoting $field from $info->Type to int... " );
+ $this->db->query( "ALTER TABLE $tablename MODIFY $field int NOT NULL", __METHOD__ );
+ $this->output( "done.\n" );
+ }
+ }
+ }
+
+ protected function doPagelinksUpdate() {
+ if ( $this->db->tableExists( 'pagelinks', __METHOD__ ) ) {
+ $this->output( "...already have pagelinks table.\n" );
+
+ return;
+ }
+
+ $this->applyPatch(
+ 'patch-pagelinks.sql',
+ false,
+ 'Converting links and brokenlinks tables to pagelinks'
+ );
+
+ global $wgContLang;
+ foreach ( $wgContLang->getNamespaces() as $ns => $name ) {
+ if ( $ns == 0 ) {
+ continue;
+ }
+
+ $this->output( "Cleaning up broken links for namespace $ns... " );
+ $this->db->update( 'pagelinks',
+ [
+ 'pl_namespace' => $ns,
+ "pl_title = TRIM(LEADING {$this->db->addQuotes( "$name:" )} FROM pl_title)",
+ ],
+ [
+ 'pl_namespace' => 0,
+ 'pl_title' . $this->db->buildLike( "$name:", $this->db->anyString() ),
+ ],
+ __METHOD__
+ );
+ $this->output( "done.\n" );
+ }
+ }
+
+ protected function doUserUniqueUpdate() {
+ if ( !$this->doTable( 'user' ) ) {
+ return true;
+ }
+
+ $duper = new UserDupes( $this->db, [ $this, 'output' ] );
+ if ( $duper->hasUniqueIndex() ) {
+ $this->output( "...already have unique user_name index.\n" );
+
+ return;
+ }
+
+ if ( !$duper->clearDupes() ) {
+ $this->output( "WARNING: This next step will probably fail due to unfixed duplicates...\n" );
+ }
+ $this->applyPatch( 'patch-user_nameindex.sql', false, "Adding unique index on user_name" );
+ }
+
+ protected function doUserGroupsUpdate() {
+ if ( !$this->doTable( 'user_groups' ) ) {
+ return true;
+ }
+
+ if ( $this->db->tableExists( 'user_groups', __METHOD__ ) ) {
+ $info = $this->db->fieldInfo( 'user_groups', 'ug_group' );
+ if ( $info->type() == 'int' ) {
+ $oldug = $this->db->tableName( 'user_groups' );
+ $newug = $this->db->tableName( 'user_groups_bogus' );
+ $this->output( "user_groups table exists but is in bogus intermediate " .
+ "format. Renaming to $newug... " );
+ $this->db->query( "ALTER TABLE $oldug RENAME TO $newug", __METHOD__ );
+ $this->output( "done.\n" );
+
+ $this->applyPatch( 'patch-user_groups.sql', false, "Re-adding fresh user_groups table" );
+
+ $this->output( "***\n" );
+ $this->output( "*** WARNING: You will need to manually fix up user " .
+ "permissions in the user_groups\n" );
+ $this->output( "*** table. Old 1.5 alpha versions did some pretty funky stuff...\n" );
+ $this->output( "***\n" );
+ } else {
+ $this->output( "...user_groups table exists and is in current format.\n" );
+ }
+
+ return;
+ }
+
+ $this->applyPatch( 'patch-user_groups.sql', false, "Adding user_groups table" );
+
+ if ( !$this->db->tableExists( 'user_rights', __METHOD__ ) ) {
+ if ( $this->db->fieldExists( 'user', 'user_rights', __METHOD__ ) ) {
+ $this->applyPatch(
+ 'patch-user_rights.sql',
+ false,
+ 'Upgrading from a 1.3 or older database? Breaking out user_rights for conversion'
+ );
+ } else {
+ $this->output( "*** WARNING: couldn't locate user_rights table or field for upgrade.\n" );
+ $this->output( "*** You may need to manually configure some sysops by manipulating\n" );
+ $this->output( "*** the user_groups table.\n" );
+
+ return;
+ }
+ }
+
+ $this->output( "Converting user_rights table to user_groups... " );
+ $result = $this->db->select( 'user_rights',
+ [ 'ur_user', 'ur_rights' ],
+ [ "ur_rights != ''" ],
+ __METHOD__ );
+
+ foreach ( $result as $row ) {
+ $groups = array_unique(
+ array_map( 'trim',
+ explode( ',', $row->ur_rights ) ) );
+
+ foreach ( $groups as $group ) {
+ $this->db->insert( 'user_groups',
+ [
+ 'ug_user' => $row->ur_user,
+ 'ug_group' => $group ],
+ __METHOD__ );
+ }
+ }
+ $this->output( "done.\n" );
+ }
+
+ /**
+ * Make sure wl_notificationtimestamp can be NULL,
+ * and update old broken items.
+ */
+ protected function doWatchlistNull() {
+ $info = $this->db->fieldInfo( 'watchlist', 'wl_notificationtimestamp' );
+ if ( !$info ) {
+ return;
+ }
+ if ( $info->isNullable() ) {
+ $this->output( "...wl_notificationtimestamp is already nullable.\n" );
+
+ return;
+ }
+
+ $this->applyPatch(
+ 'patch-watchlist-null.sql',
+ false,
+ 'Making wl_notificationtimestamp nullable'
+ );
+ }
+
+ /**
+ * Set page_random field to a random value where it is equals to 0.
+ *
+ * @see T5946
+ */
+ protected function doPageRandomUpdate() {
+ $page = $this->db->tableName( 'page' );
+ $this->db->query( "UPDATE $page SET page_random = RAND() WHERE page_random = 0", __METHOD__ );
+ $rows = $this->db->affectedRows();
+
+ if ( $rows ) {
+ $this->output( "Set page_random to a random value on $rows rows where it was set to 0\n" );
+ } else {
+ $this->output( "...no page_random rows needed to be set\n" );
+ }
+ }
+
+ protected function doTemplatelinksUpdate() {
+ if ( $this->db->tableExists( 'templatelinks', __METHOD__ ) ) {
+ $this->output( "...templatelinks table already exists\n" );
+
+ return;
+ }
+
+ $this->applyPatch( 'patch-templatelinks.sql', false, "Creating templatelinks table" );
+
+ $this->output( "Populating...\n" );
+ if ( wfGetLB()->getServerCount() > 1 ) {
+ // Slow, replication-friendly update
+ $res = $this->db->select( 'pagelinks', [ 'pl_from', 'pl_namespace', 'pl_title' ],
+ [ 'pl_namespace' => NS_TEMPLATE ], __METHOD__ );
+ $count = 0;
+ foreach ( $res as $row ) {
+ $count = ( $count + 1 ) % 100;
+ if ( $count == 0 ) {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->waitForReplication( [ 'wiki' => wfWikiID() ] );
+ }
+ $this->db->insert( 'templatelinks',
+ [
+ 'tl_from' => $row->pl_from,
+ 'tl_namespace' => $row->pl_namespace,
+ 'tl_title' => $row->pl_title,
+ ], __METHOD__
+ );
+ }
+ } else {
+ // Fast update
+ $this->db->insertSelect( 'templatelinks', 'pagelinks',
+ [
+ 'tl_from' => 'pl_from',
+ 'tl_namespace' => 'pl_namespace',
+ 'tl_title' => 'pl_title'
+ ], [
+ 'pl_namespace' => 10
+ ], __METHOD__
+ );
+ }
+ $this->output( "Done. Please run maintenance/refreshLinks.php for a more " .
+ "thorough templatelinks update.\n" );
+ }
+
+ protected function doBacklinkingIndicesUpdate() {
+ if ( !$this->indexHasField( 'pagelinks', 'pl_namespace', 'pl_from' ) ||
+ !$this->indexHasField( 'templatelinks', 'tl_namespace', 'tl_from' ) ||
+ !$this->indexHasField( 'imagelinks', 'il_to', 'il_from' )
+ ) {
+ $this->applyPatch( 'patch-backlinkindexes.sql', false, "Updating backlinking indices" );
+ }
+ }
+
+ /**
+ * Adding page_restrictions table, obsoleting page.page_restrictions.
+ * Migrating old restrictions to new table
+ * -- Andrew Garrett, January 2007.
+ */
+ protected function doRestrictionsUpdate() {
+ if ( $this->db->tableExists( 'page_restrictions', __METHOD__ ) ) {
+ $this->output( "...page_restrictions table already exists.\n" );
+
+ return;
+ }
+
+ $this->applyPatch(
+ 'patch-page_restrictions.sql',
+ false,
+ 'Creating page_restrictions table (1/2)'
+ );
+ $this->applyPatch(
+ 'patch-page_restrictions_sortkey.sql',
+ false,
+ 'Creating page_restrictions table (2/2)'
+ );
+ $this->output( "done.\n" );
+
+ $this->output( "Migrating old restrictions to new table...\n" );
+ $task = $this->maintenance->runChild( 'UpdateRestrictions' );
+ $task->execute();
+ }
+
+ protected function doCategorylinksIndicesUpdate() {
+ if ( !$this->indexHasField( 'categorylinks', 'cl_sortkey', 'cl_from' ) ) {
+ $this->applyPatch( 'patch-categorylinksindex.sql', false, "Updating categorylinks Indices" );
+ }
+ }
+
+ protected function doCategoryPopulation() {
+ if ( $this->updateRowExists( 'populate category' ) ) {
+ $this->output( "...category table already populated.\n" );
+
+ return;
+ }
+
+ $this->output(
+ "Populating category table, printing progress markers. " .
+ "For large databases, you\n" .
+ "may want to hit Ctrl-C and do this manually with maintenance/\n" .
+ "populateCategory.php.\n"
+ );
+ $task = $this->maintenance->runChild( 'PopulateCategory' );
+ $task->execute();
+ $this->output( "Done populating category table.\n" );
+ }
+
+ protected function doPopulateParentId() {
+ if ( !$this->updateRowExists( 'populate rev_parent_id' ) ) {
+ $this->output(
+ "Populating rev_parent_id fields, printing progress markers. For large\n" .
+ "databases, you may want to hit Ctrl-C and do this manually with\n" .
+ "maintenance/populateParentId.php.\n" );
+
+ $task = $this->maintenance->runChild( 'PopulateParentId' );
+ $task->execute();
+ }
+ }
+
+ protected function doMaybeProfilingMemoryUpdate() {
+ if ( !$this->doTable( 'profiling' ) ) {
+ return true;
+ }
+
+ if ( !$this->db->tableExists( 'profiling', __METHOD__ ) ) {
+ return true;
+ } elseif ( $this->db->fieldExists( 'profiling', 'pf_memory', __METHOD__ ) ) {
+ $this->output( "...profiling table has pf_memory field.\n" );
+
+ return true;
+ }
+
+ return $this->applyPatch(
+ 'patch-profiling-memory.sql',
+ false,
+ 'Adding pf_memory field to table profiling'
+ );
+ }
+
+ protected function doFilearchiveIndicesUpdate() {
+ $info = $this->db->indexInfo( 'filearchive', 'fa_user_timestamp', __METHOD__ );
+ if ( !$info ) {
+ $this->applyPatch( 'patch-filearchive-user-index.sql', false, "Updating filearchive indices" );
+ }
+
+ return true;
+ }
+
+ protected function doNonUniquePlTlIl() {
+ $info = $this->db->indexInfo( 'pagelinks', 'pl_namespace' );
+ if ( is_array( $info ) && $info[0]->Non_unique ) {
+ $this->output( "...pl_namespace, tl_namespace, il_to indices are already non-UNIQUE.\n" );
+
+ return true;
+ }
+ if ( $this->skipSchema ) {
+ $this->output( "...skipping schema change (making pl_namespace, tl_namespace " .
+ "and il_to indices non-UNIQUE).\n" );
+
+ return false;
+ }
+
+ return $this->applyPatch(
+ 'patch-pl-tl-il-nonunique.sql',
+ false,
+ 'Making pl_namespace, tl_namespace and il_to indices non-UNIQUE'
+ );
+ }
+
+ protected function doUpdateMimeMinorField() {
+ if ( $this->updateRowExists( 'mime_minor_length' ) ) {
+ $this->output( "...*_mime_minor fields are already long enough.\n" );
+
+ return;
+ }
+
+ $this->applyPatch(
+ 'patch-mime_minor_length.sql',
+ false,
+ 'Altering all *_mime_minor fields to 100 bytes in size'
+ );
+ }
+
+ protected function doClFieldsUpdate() {
+ if ( $this->updateRowExists( 'cl_fields_update' ) ) {
+ $this->output( "...categorylinks up-to-date.\n" );
+
+ return;
+ }
+
+ $this->applyPatch(
+ 'patch-categorylinks-better-collation2.sql',
+ false,
+ 'Updating categorylinks (again)'
+ );
+ }
+
+ protected function doLangLinksLengthUpdate() {
+ $langlinks = $this->db->tableName( 'langlinks' );
+ $res = $this->db->query( "SHOW COLUMNS FROM $langlinks LIKE 'll_lang'" );
+ $row = $this->db->fetchObject( $res );
+
+ if ( $row && $row->Type == "varbinary(10)" ) {
+ $this->applyPatch(
+ 'patch-langlinks-ll_lang-20.sql',
+ false,
+ 'Updating length of ll_lang in langlinks'
+ );
+ } else {
+ $this->output( "...ll_lang is up-to-date.\n" );
+ }
+ }
+
+ protected function doUserNewTalkTimestampNotNull() {
+ if ( !$this->doTable( 'user_newtalk' ) ) {
+ return true;
+ }
+
+ $info = $this->db->fieldInfo( 'user_newtalk', 'user_last_timestamp' );
+ if ( $info === false ) {
+ return;
+ }
+ if ( $info->isNullable() ) {
+ $this->output( "...user_last_timestamp is already nullable.\n" );
+
+ return;
+ }
+
+ $this->applyPatch(
+ 'patch-user-newtalk-timestamp-null.sql',
+ false,
+ 'Making user_last_timestamp nullable'
+ );
+ }
+
+ protected function doIwlinksIndexNonUnique() {
+ $info = $this->db->indexInfo( 'iwlinks', 'iwl_prefix_title_from' );
+ if ( is_array( $info ) && $info[0]->Non_unique ) {
+ $this->output( "...iwl_prefix_title_from index is already non-UNIQUE.\n" );
+
+ return true;
+ }
+ if ( $this->skipSchema ) {
+ $this->output( "...skipping schema change (making iwl_prefix_title_from index non-UNIQUE).\n" );
+
+ return false;
+ }
+
+ return $this->applyPatch(
+ 'patch-iwl_prefix_title_from-non-unique.sql',
+ false,
+ 'Making iwl_prefix_title_from index non-UNIQUE'
+ );
+ }
+
+ protected function doUnsignedSyncronisation() {
+ $sync = [
+ [ 'table' => 'bot_passwords', 'field' => 'bp_user' ],
+ [ 'table' => 'change_tag', 'field' => 'ct_log_id' ],
+ [ 'table' => 'change_tag', 'field' => 'ct_rev_id' ],
+ [ 'table' => 'page_restrictions', 'field' => 'pr_user' ],
+ [ 'table' => 'tag_summary', 'field' => 'ts_log_id' ],
+ [ 'table' => 'tag_summary', 'field' => 'ts_rev_id' ],
+ [ 'table' => 'user_newtalk', 'field' => 'user_id' ],
+ [ 'table' => 'user_properties', 'field' => 'up_user' ],
+ ];
+
+ foreach ( $sync as $s ) {
+ if ( !$this->doTable( $s['table'] ) ) {
+ continue;
+ }
+
+ $info = $this->db->fieldInfo( $s['table'], $s['field'] );
+ if ( $info === false ) {
+ continue;
+ }
+ $fullName = "{$s['table']}.{$s['field']}";
+ if ( $info->isUnsigned() ) {
+ $this->output( "...$fullName is already unsigned int.\n" );
+
+ continue;
+ }
+
+ $this->applyPatch(
+ "patch-{$s['table']}-{$s['field']}-unsigned.sql",
+ false,
+ "Making $fullName into an unsigned int"
+ );
+ }
+
+ return true;
+ }
+
+ protected function doRevisionPageRevIndexNonUnique() {
+ if ( !$this->doTable( 'revision' ) ) {
+ return true;
+ } elseif ( !$this->db->indexExists( 'revision', 'rev_page_id' ) ) {
+ $this->output( "...rev_page_id index not found on revision.\n" );
+ return true;
+ }
+
+ if ( !$this->db->indexUnique( 'revision', 'rev_page_id' ) ) {
+ $this->output( "...rev_page_id index already non-unique.\n" );
+ return true;
+ }
+
+ return $this->applyPatch(
+ 'patch-revision-page-rev-index-nonunique.sql',
+ false,
+ 'Making rev_page_id index non-unique'
+ );
+ }
+
+ protected function doExtendCommentLengths() {
+ $table = $this->db->tableName( 'revision' );
+ $res = $this->db->query( "SHOW COLUMNS FROM $table LIKE 'rev_comment'" );
+ $row = $this->db->fetchObject( $res );
+
+ if ( $row && ( $row->Type !== "varbinary(767)" || $row->Default !== "" ) ) {
+ $this->applyPatch(
+ 'patch-editsummary-length.sql',
+ false,
+ 'Extending edit summary lengths (and setting defaults)'
+ );
+ } else {
+ $this->output( '...comment fields are up to date' );
+ }
+ }
+
+ public function getSchemaVars() {
+ global $wgDBTableOptions;
+
+ $vars = [];
+ $vars['wgDBTableOptions'] = str_replace( 'TYPE', 'ENGINE', $wgDBTableOptions );
+ $vars['wgDBTableOptions'] = str_replace(
+ 'CHARSET=mysql4',
+ 'CHARSET=binary',
+ $vars['wgDBTableOptions']
+ );
+
+ return $vars;
+ }
+}
diff --git a/www/wiki/includes/installer/OracleInstaller.php b/www/wiki/includes/installer/OracleInstaller.php
new file mode 100644
index 00000000..e0fbe1f9
--- /dev/null
+++ b/www/wiki/includes/installer/OracleInstaller.php
@@ -0,0 +1,340 @@
+<?php
+/**
+ * Oracle-specific installer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DBConnectionError;
+
+/**
+ * Class for setting up the MediaWiki database using Oracle.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class OracleInstaller extends DatabaseInstaller {
+
+ protected $globalNames = [
+ 'wgDBserver',
+ 'wgDBname',
+ 'wgDBuser',
+ 'wgDBpassword',
+ 'wgDBprefix',
+ ];
+
+ protected $internalDefaults = [
+ '_OracleDefTS' => 'USERS',
+ '_OracleTempTS' => 'TEMP',
+ '_InstallUser' => 'SYSTEM',
+ ];
+
+ public static $minimumVersion = '9.0.1'; // 9iR1
+ protected static $notMiniumumVerisonMessage = 'config-oracle-old';
+
+ protected $connError = null;
+
+ public function getName() {
+ return 'oracle';
+ }
+
+ public function isCompiled() {
+ return self::checkExtension( 'oci8' );
+ }
+
+ public function getConnectForm() {
+ if ( $this->getVar( 'wgDBserver' ) == 'localhost' ) {
+ $this->parent->setVar( 'wgDBserver', '' );
+ }
+
+ return $this->getTextBox(
+ 'wgDBserver',
+ 'config-db-host-oracle',
+ [],
+ $this->parent->getHelpBox( 'config-db-host-oracle-help' )
+ ) .
+ Html::openElement( 'fieldset' ) .
+ Html::element( 'legend', [], wfMessage( 'config-db-wiki-settings' )->text() ) .
+ $this->getTextBox( 'wgDBprefix', 'config-db-prefix' ) .
+ $this->getTextBox( '_OracleDefTS', 'config-oracle-def-ts' ) .
+ $this->getTextBox(
+ '_OracleTempTS',
+ 'config-oracle-temp-ts',
+ [],
+ $this->parent->getHelpBox( 'config-db-oracle-help' )
+ ) .
+ Html::closeElement( 'fieldset' ) .
+ $this->parent->getWarningBox( wfMessage( 'config-db-account-oracle-warn' )->text() ) .
+ $this->getInstallUserBox() .
+ $this->getWebUserBox();
+ }
+
+ public function submitInstallUserBox() {
+ parent::submitInstallUserBox();
+ $this->parent->setVar( '_InstallDBname', $this->getVar( '_InstallUser' ) );
+
+ return Status::newGood();
+ }
+
+ public function submitConnectForm() {
+ // Get variables from the request
+ $newValues = $this->setVarsFromRequest( [
+ 'wgDBserver',
+ 'wgDBprefix',
+ 'wgDBuser',
+ 'wgDBpassword'
+ ] );
+ $this->parent->setVar( 'wgDBname', $this->getVar( 'wgDBuser' ) );
+
+ // Validate them
+ $status = Status::newGood();
+ if ( !strlen( $newValues['wgDBserver'] ) ) {
+ $status->fatal( 'config-missing-db-server-oracle' );
+ } elseif ( !self::checkConnectStringFormat( $newValues['wgDBserver'] ) ) {
+ $status->fatal( 'config-invalid-db-server-oracle', $newValues['wgDBserver'] );
+ }
+ if ( !preg_match( '/^[a-zA-Z0-9_]*$/', $newValues['wgDBprefix'] ) ) {
+ $status->fatal( 'config-invalid-schema', $newValues['wgDBprefix'] );
+ }
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // Submit user box
+ $status = $this->submitInstallUserBox();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // Try to connect trough multiple scenarios
+ // Scenario 1: Install with a manually created account
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ if ( $this->connError == 28009 ) {
+ // _InstallUser seems to be a SYSDBA
+ // Scenario 2: Create user with SYSDBA and install with new user
+ $status = $this->submitWebUserBox();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $status = $this->openSYSDBAConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ if ( !$this->getVar( '_CreateDBAccount' ) ) {
+ $status->fatal( 'config-db-sys-create-oracle' );
+ }
+ } else {
+ return $status;
+ }
+ } else {
+ // check for web user credentials
+ // Scenario 3: Install with a priviliged user but use a restricted user
+ $statusIS3 = $this->submitWebUserBox();
+ if ( !$statusIS3->isOK() ) {
+ return $statusIS3;
+ }
+ }
+
+ /**
+ * @var Database $conn
+ */
+ $conn = $status->value;
+
+ // Check version
+ $status->merge( static::meetsMinimumRequirement( $conn->getServerVersion() ) );
+
+ return $status;
+ }
+
+ public function openConnection() {
+ return $this->doOpenConnection();
+ }
+
+ public function openSYSDBAConnection() {
+ return $this->doOpenConnection( DatabaseOracle::DBO_SYSDBA );
+ }
+
+ /**
+ * @param int $flags
+ * @return Status Status with DatabaseOracle or null as the value
+ */
+ private function doOpenConnection( $flags = 0 ) {
+ $status = Status::newGood();
+ try {
+ $db = Database::factory(
+ 'oracle',
+ [
+ 'host' => $this->getVar( 'wgDBserver' ),
+ 'user' => $this->getVar( '_InstallUser' ),
+ 'password' => $this->getVar( '_InstallPassword' ),
+ 'dbname' => $this->getVar( '_InstallDBname' ),
+ 'tablePrefix' => $this->getVar( 'wgDBprefix' ),
+ 'flags' => $flags
+ ]
+ );
+ $status->value = $db;
+ } catch ( DBConnectionError $e ) {
+ $this->connError = $e->db->lastErrno();
+ $status->fatal( 'config-connection-error', $e->getMessage() );
+ }
+
+ return $status;
+ }
+
+ public function needsUpgrade() {
+ $tempDBname = $this->getVar( 'wgDBname' );
+ $this->parent->setVar( 'wgDBname', $this->getVar( 'wgDBuser' ) );
+ $retVal = parent::needsUpgrade();
+ $this->parent->setVar( 'wgDBname', $tempDBname );
+
+ return $retVal;
+ }
+
+ public function preInstall() {
+ # Add our user callback to installSteps, right before the tables are created.
+ $callback = [
+ 'name' => 'user',
+ 'callback' => [ $this, 'setupUser' ]
+ ];
+ $this->parent->addInstallStep( $callback, 'database' );
+ }
+
+ public function setupDatabase() {
+ $status = Status::newGood();
+
+ return $status;
+ }
+
+ public function setupUser() {
+ global $IP;
+
+ if ( !$this->getVar( '_CreateDBAccount' ) ) {
+ return Status::newGood();
+ }
+
+ // normaly only SYSDBA users can create accounts
+ $status = $this->openSYSDBAConnection();
+ if ( !$status->isOK() ) {
+ if ( $this->connError == 1031 ) {
+ // insufficient privileges (looks like a normal user)
+ $status = $this->openConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ } else {
+ return $status;
+ }
+ }
+
+ $this->db = $status->value;
+ $this->setupSchemaVars();
+
+ if ( !$this->db->selectDB( $this->getVar( 'wgDBuser' ) ) ) {
+ $this->db->setFlag( DBO_DDLMODE );
+ $error = $this->db->sourceFile( "$IP/maintenance/oracle/user.sql" );
+ if ( $error !== true || !$this->db->selectDB( $this->getVar( 'wgDBuser' ) ) ) {
+ $status->fatal( 'config-install-user-failed', $this->getVar( 'wgDBuser' ), $error );
+ }
+ } elseif ( $this->db->getFlag( DBO_SYSDBA ) ) {
+ $status->fatal( 'config-db-sys-user-exists-oracle', $this->getVar( 'wgDBuser' ) );
+ }
+
+ if ( $status->isOK() ) {
+ // user created or already existing, switching back to a normal connection
+ // as the new user has all needed privileges to setup the rest of the schema
+ // i will be using that user as _InstallUser from this point on
+ $this->db->close();
+ $this->db = false;
+ $this->parent->setVar( '_InstallUser', $this->getVar( 'wgDBuser' ) );
+ $this->parent->setVar( '_InstallPassword', $this->getVar( 'wgDBpassword' ) );
+ $this->parent->setVar( '_InstallDBname', $this->getVar( 'wgDBuser' ) );
+ $status = $this->getConnection();
+ }
+
+ return $status;
+ }
+
+ /**
+ * Overload: after this action field info table has to be rebuilt
+ * @return Status
+ */
+ public function createTables() {
+ $this->setupSchemaVars();
+ $this->db->setFlag( DBO_DDLMODE );
+ $this->parent->setVar( 'wgDBname', $this->getVar( 'wgDBuser' ) );
+ $status = parent::createTables();
+ $this->db->clearFlag( DBO_DDLMODE );
+
+ $this->db->query( 'BEGIN fill_wiki_info; END;' );
+
+ return $status;
+ }
+
+ public function getSchemaVars() {
+ $varNames = [
+ # These variables are used by maintenance/oracle/user.sql
+ '_OracleDefTS',
+ '_OracleTempTS',
+ 'wgDBuser',
+ 'wgDBpassword',
+
+ # These are used by tables.sql
+ 'wgDBprefix',
+ ];
+ $vars = [];
+ foreach ( $varNames as $name ) {
+ $vars[$name] = $this->getVar( $name );
+ }
+
+ return $vars;
+ }
+
+ public function getLocalSettings() {
+ $prefix = $this->getVar( 'wgDBprefix' );
+
+ return "# Oracle specific settings
+\$wgDBprefix = \"{$prefix}\";
+";
+ }
+
+ /**
+ * Function checks the format of Oracle connect string
+ * The actual validity of the string is checked by attempting to connect
+ *
+ * Regex should be able to validate all connect string formats
+ * [//](host|tns_name)[:port][/service_name][:POOLED]
+ * http://www.orafaq.com/wiki/EZCONNECT
+ *
+ * @since 1.22
+ *
+ * @param string $connect_string
+ *
+ * @return bool Whether the connection string is valid.
+ */
+ public static function checkConnectStringFormat( $connect_string ) {
+ // @@codingStandardsIgnoreStart Long lines with regular expressions.
+ // @todo Very long regular expression. Make more readable?
+ $isValid = preg_match( '/^[[:alpha:]][\w\-]*(?:\.[[:alpha:]][\w\-]*){0,2}$/', $connect_string ); // TNS name
+ $isValid |= preg_match( '/^(?:\/\/)?[\w\-\.]+(?::[\d]+)?(?:\/(?:[\w\-\.]+(?::(pooled|dedicated|shared))?)?(?:\/[\w\-\.]+)?)?$/', $connect_string ); // EZConnect
+ // @@codingStandardsIgnoreEnd
+ return (bool)$isValid;
+ }
+}
diff --git a/www/wiki/includes/installer/OracleUpdater.php b/www/wiki/includes/installer/OracleUpdater.php
new file mode 100644
index 00000000..040b54a1
--- /dev/null
+++ b/www/wiki/includes/installer/OracleUpdater.php
@@ -0,0 +1,332 @@
+<?php
+/**
+ * Oracle-specific updater.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+/**
+ * Class for handling updates to Oracle databases.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class OracleUpdater extends DatabaseUpdater {
+
+ /**
+ * Handle to the database subclass
+ *
+ * @var DatabaseOracle
+ */
+ protected $db;
+
+ protected function getCoreUpdateList() {
+ return [
+ [ 'disableContentHandlerUseDB' ],
+
+ // 1.17
+ [ 'doNamespaceDefaults' ],
+ [ 'doFKRenameDeferr' ],
+ [ 'doFunctions17' ],
+ [ 'doSchemaUpgrade17' ],
+ [ 'doInsertPage0' ],
+ [ 'doRemoveNotNullEmptyDefaults' ],
+ [ 'addTable', 'user_former_groups', 'patch-user_former_groups.sql' ],
+
+ // 1.18
+ [ 'addIndex', 'user', 'i02', 'patch-user_email_index.sql' ],
+ [ 'modifyField', 'user_properties', 'up_property', 'patch-up_property.sql' ],
+ [ 'addTable', 'uploadstash', 'patch-uploadstash.sql' ],
+ [ 'doRecentchangesFK2Cascade' ],
+
+ // 1.19
+ [ 'addIndex', 'logging', 'i05', 'patch-logging_type_action_index.sql' ],
+ [ 'addField', 'revision', 'rev_sha1', 'patch-rev_sha1_field.sql' ],
+ [ 'addField', 'archive', 'ar_sha1', 'patch-ar_sha1_field.sql' ],
+ [ 'doRemoveNotNullEmptyDefaults2' ],
+ [ 'addIndex', 'page', 'i03', 'patch-page_redirect_namespace_len.sql' ],
+ [ 'addField', 'uploadstash', 'us_chunk_inx', 'patch-us_chunk_inx_field.sql' ],
+ [ 'addField', 'job', 'job_timestamp', 'patch-job_timestamp_field.sql' ],
+ [ 'addIndex', 'job', 'i02', 'patch-job_timestamp_index.sql' ],
+ [ 'doPageRestrictionsPKUKFix' ],
+
+ // 1.20
+ [ 'addIndex', 'ipblocks', 'i05', 'patch-ipblocks_i05_index.sql' ],
+ [ 'addIndex', 'revision', 'i05', 'patch-revision_i05_index.sql' ],
+ [ 'dropField', 'category', 'cat_hidden', 'patch-cat_hidden.sql' ],
+
+ // 1.21
+ [ 'addField', 'revision', 'rev_content_format',
+ 'patch-revision-rev_content_format.sql' ],
+ [ 'addField', 'revision', 'rev_content_model',
+ 'patch-revision-rev_content_model.sql' ],
+ [ 'addField', 'archive', 'ar_content_format', 'patch-archive-ar_content_format.sql' ],
+ [ 'addField', 'archive', 'ar_content_model', 'patch-archive-ar_content_model.sql' ],
+ [ 'addField', 'archive', 'ar_id', 'patch-archive-ar_id.sql' ],
+ [ 'addField', 'externallinks', 'el_id', 'patch-externallinks-el_id.sql' ],
+ [ 'addField', 'page', 'page_content_model', 'patch-page-page_content_model.sql' ],
+ [ 'enableContentHandlerUseDB' ],
+ [ 'dropField', 'site_stats', 'ss_admins', 'patch-ss_admins.sql' ],
+ [ 'dropField', 'recentchanges', 'rc_moved_to_title', 'patch-rc_moved.sql' ],
+ [ 'addTable', 'sites', 'patch-sites.sql' ],
+ [ 'addField', 'filearchive', 'fa_sha1', 'patch-fa_sha1.sql' ],
+ [ 'addField', 'job', 'job_token', 'patch-job_token.sql' ],
+ [ 'addField', 'job', 'job_attempts', 'patch-job_attempts.sql' ],
+ [ 'addField', 'uploadstash', 'us_props', 'patch-uploadstash-us_props.sql' ],
+ [ 'modifyField', 'user_groups', 'ug_group', 'patch-ug_group-length-increase-255.sql' ],
+ [ 'modifyField', 'user_former_groups', 'ufg_group',
+ 'patch-ufg_group-length-increase-255.sql' ],
+
+ // 1.23
+ [ 'addIndex', 'logging', 'i06', 'patch-logging_user_text_type_time_index.sql' ],
+ [ 'addIndex', 'logging', 'i07', 'patch-logging_user_text_time_index.sql' ],
+ [ 'addField', 'user', 'user_password_expires', 'patch-user_password_expire.sql' ],
+ [ 'addField', 'page', 'page_links_updated', 'patch-page_links_updated.sql' ],
+ [ 'addField', 'recentchanges', 'rc_source', 'patch-rc_source.sql' ],
+
+ // 1.24
+ [ 'addField', 'page', 'page_lang', 'patch-page-page_lang.sql' ],
+
+ // 1.25
+ [ 'dropTable', 'hitcounter' ],
+ [ 'dropField', 'site_stats', 'ss_total_views', 'patch-drop-ss_total_views.sql' ],
+ [ 'dropField', 'page', 'page_counter', 'patch-drop-page_counter.sql' ],
+
+ // 1.27
+ [ 'dropTable', 'msg_resource_links' ],
+ [ 'dropTable', 'msg_resource' ],
+ [ 'addField', 'watchlist', 'wl_id', 'patch-watchlist-wl_id.sql' ],
+
+ // 1.28
+ [ 'addIndex', 'recentchanges', 'rc_name_type_patrolled_timestamp',
+ 'patch-add-rc_name_type_patrolled_timestamp_index.sql' ],
+ [ 'addField', 'change_tag', 'ct_id', 'patch-change_tag-ct_id.sql' ],
+ [ 'addField', 'tag_summary', 'ts_id', 'patch-tag_summary-ts_id.sql' ],
+
+ // 1.29
+ [ 'addField', 'externallinks', 'el_index_60', 'patch-externallinks-el_index_60.sql' ],
+ [ 'addField', 'user_groups', 'ug_expiry', 'patch-user_groups-ug_expiry.sql' ],
+
+ // 1.30
+ [ 'doAutoIncrementTriggers' ],
+ [ 'addIndex', 'site_stats', 'PRIMARY', 'patch-site_stats-pk.sql' ],
+
+ // KEEP THIS AT THE BOTTOM!!
+ [ 'doRebuildDuplicateFunction' ],
+
+ ];
+ }
+
+ /**
+ * MySQL uses datatype defaults for NULL inserted into NOT NULL fields
+ * In namespace case that results into insert of 0 which is default namespace
+ * Oracle inserts NULL, so namespace fields should have a default value
+ */
+ protected function doNamespaceDefaults() {
+ $meta = $this->db->fieldInfo( 'page', 'page_namespace' );
+ if ( $meta->defaultValue() != null ) {
+ return;
+ }
+
+ $this->applyPatch(
+ 'patch_namespace_defaults.sql',
+ false,
+ 'Altering namespace fields with default value'
+ );
+ }
+
+ /**
+ * Uniform FK names + deferrable state
+ */
+ protected function doFKRenameDeferr() {
+ $meta = $this->db->query( '
+ SELECT COUNT(*) cnt
+ FROM user_constraints
+ WHERE constraint_type = \'R\' AND deferrable = \'DEFERRABLE\''
+ );
+ $row = $meta->fetchRow();
+ if ( $row && $row['cnt'] > 0 ) {
+ return;
+ }
+
+ $this->applyPatch( 'patch_fk_rename_deferred.sql', false, "Altering foreign keys ... " );
+ }
+
+ /**
+ * Recreate functions to 17 schema layout
+ */
+ protected function doFunctions17() {
+ $this->applyPatch( 'patch_create_17_functions.sql', false, "Recreating functions" );
+ }
+
+ /**
+ * Schema upgrade 16->17
+ * there are no incremental patches prior to this
+ */
+ protected function doSchemaUpgrade17() {
+ // check if iwlinks table exists which was added in 1.17
+ if ( $this->db->tableExists( 'iwlinks' ) ) {
+ return;
+ }
+ $this->applyPatch( 'patch_16_17_schema_changes.sql', false, "Updating schema to 17" );
+ }
+
+ /**
+ * Insert page (page_id = 0) to prevent FK constraint violation
+ */
+ protected function doInsertPage0() {
+ $this->output( "Inserting page 0 if missing ... " );
+ $row = [
+ 'page_id' => 0,
+ 'page_namespace' => 0,
+ 'page_title' => ' ',
+ 'page_is_redirect' => 0,
+ 'page_is_new' => 0,
+ 'page_random' => 0,
+ 'page_touched' => $this->db->timestamp(),
+ 'page_latest' => 0,
+ 'page_len' => 0
+ ];
+ $this->db->insert( 'page', $row, 'OracleUpdater:doInserPage0', [ 'IGNORE' ] );
+ $this->output( "ok\n" );
+ }
+
+ /**
+ * Remove DEFAULT '' NOT NULL constraints from fields as '' is internally
+ * converted to NULL in Oracle
+ */
+ protected function doRemoveNotNullEmptyDefaults() {
+ $meta = $this->db->fieldInfo( 'categorylinks', 'cl_sortkey_prefix' );
+ if ( $meta->isNullable() ) {
+ return;
+ }
+ $this->applyPatch(
+ 'patch_remove_not_null_empty_defs.sql',
+ false,
+ 'Removing not null empty constraints'
+ );
+ }
+
+ protected function doRemoveNotNullEmptyDefaults2() {
+ $meta = $this->db->fieldInfo( 'ipblocks', 'ipb_by_text' );
+ if ( $meta->isNullable() ) {
+ return;
+ }
+ $this->applyPatch(
+ 'patch_remove_not_null_empty_defs2.sql',
+ false,
+ 'Removing not null empty constraints'
+ );
+ }
+
+ /**
+ * Removed forcing of invalid state on recentchanges_fk2.
+ * cascading taken in account in the deleting function
+ */
+ protected function doRecentchangesFK2Cascade() {
+ $meta = $this->db->query( 'SELECT 1 FROM all_constraints WHERE owner = \'' .
+ strtoupper( $this->db->getDBname() ) .
+ '\' AND constraint_name = \'' .
+ $this->db->tablePrefix() .
+ 'RECENTCHANGES_FK2\' AND delete_rule = \'CASCADE\''
+ );
+ $row = $meta->fetchRow();
+ if ( $row ) {
+ return;
+ }
+
+ $this->applyPatch( 'patch_recentchanges_fk2_cascade.sql', false, "Altering RECENTCHANGES_FK2" );
+ }
+
+ /**
+ * Fixed wrong PK, UK definition
+ */
+ protected function doPageRestrictionsPKUKFix() {
+ $this->output( "Altering PAGE_RESTRICTIONS keys ... " );
+
+ $meta = $this->db->query( 'SELECT column_name FROM all_cons_columns WHERE owner = \'' .
+ strtoupper( $this->db->getDBname() ) .
+ '\' AND constraint_name = \'' .
+ $this->db->tablePrefix() .
+ 'PAGE_RESTRICTIONS_PK\' AND rownum = 1'
+ );
+ $row = $meta->fetchRow();
+ if ( $row['column_name'] == 'PR_ID' ) {
+ $this->output( "seems to be up to date.\n" );
+
+ return;
+ }
+
+ $this->applyPatch( 'patch-page_restrictions_pkuk_fix.sql', false );
+ $this->output( "ok\n" );
+ }
+
+ /**
+ * Add auto-increment triggers
+ */
+ protected function doAutoIncrementTriggers() {
+ $this->output( "Adding auto-increment triggers ... " );
+
+ $meta = $this->db->query( 'SELECT trigger_name FROM user_triggers WHERE table_owner = \'' .
+ strtoupper( $this->db->getDBname() ) .
+ '\' AND trigger_name = \'' .
+ $this->db->tablePrefix() .
+ 'PAGE_DEFAULT_PAGE_ID\''
+ );
+ $row = $meta->fetchRow();
+ if ( $row['column_name'] ) {
+ $this->output( "seems to be up to date.\n" );
+
+ return;
+ }
+
+ $this->applyPatch( 'patch-auto_increment_triggers.sql', false );
+
+ $this->output( "ok\n" );
+ }
+
+ /**
+ * rebuilding of the function that duplicates tables for tests
+ */
+ protected function doRebuildDuplicateFunction() {
+ $this->applyPatch( 'patch_rebuild_dupfunc.sql', false, "Rebuilding duplicate function" );
+ }
+
+ /**
+ * Overload: after this action field info table has to be rebuilt
+ *
+ * @param array $what
+ */
+ public function doUpdates( array $what = [ 'core', 'extensions', 'purge', 'stats' ] ) {
+ parent::doUpdates( $what );
+
+ $this->db->query( 'BEGIN fill_wiki_info; END;' );
+ }
+
+ /**
+ * Overload: because of the DDL_MODE tablename escaping is a bit dodgy
+ */
+ public function purgeCache() {
+ # We can't guarantee that the user will be able to use TRUNCATE,
+ # but we know that DELETE is available to us
+ $this->output( "Purging caches..." );
+ $this->db->delete( '/*Q*/' . $this->db->tableName( 'objectcache' ), '*', __METHOD__ );
+ $this->output( "done.\n" );
+ }
+}
diff --git a/www/wiki/includes/installer/PhpBugTests.php b/www/wiki/includes/installer/PhpBugTests.php
new file mode 100644
index 00000000..d412216a
--- /dev/null
+++ b/www/wiki/includes/installer/PhpBugTests.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Classes for self-contained tests for known bugs in PHP.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @defgroup PHPBugTests PHP known bugs tests
+ */
+
+/**
+ * Test for PHP+libxml2 bug which breaks XML input subtly with certain versions.
+ * Known fixed with PHP 5.2.9 + libxml2-2.7.3
+ * @see https://bugs.php.net/bug.php?id=45996
+ * @ingroup PHPBugTests
+ */
+class PhpXmlBugTester {
+ private $parsedData = '';
+ public $ok = false;
+
+ public function __construct() {
+ $charData = '<b>c</b>';
+ $xml = '<a>' . htmlspecialchars( $charData ) . '</a>';
+
+ $parser = xml_parser_create();
+ xml_set_character_data_handler( $parser, [ $this, 'chardata' ] );
+ $parsedOk = xml_parse( $parser, $xml, true );
+ $this->ok = $parsedOk && ( $this->parsedData == $charData );
+ }
+
+ public function chardata( $parser, $data ) {
+ $this->parsedData .= $data;
+ }
+}
diff --git a/www/wiki/includes/installer/PostgresInstaller.php b/www/wiki/includes/installer/PostgresInstaller.php
new file mode 100644
index 00000000..1869689f
--- /dev/null
+++ b/www/wiki/includes/installer/PostgresInstaller.php
@@ -0,0 +1,682 @@
+<?php
+/**
+ * PostgreSQL-specific installer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DBQueryError;
+use Wikimedia\Rdbms\DBConnectionError;
+
+/**
+ * Class for setting up the MediaWiki database using Postgres.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class PostgresInstaller extends DatabaseInstaller {
+
+ protected $globalNames = [
+ 'wgDBserver',
+ 'wgDBport',
+ 'wgDBname',
+ 'wgDBuser',
+ 'wgDBpassword',
+ 'wgDBmwschema',
+ ];
+
+ protected $internalDefaults = [
+ '_InstallUser' => 'postgres',
+ ];
+
+ public static $minimumVersion = '9.1';
+ protected static $notMiniumumVerisonMessage = 'config-postgres-old';
+ public $maxRoleSearchDepth = 5;
+
+ protected $pgConns = [];
+
+ function getName() {
+ return 'postgres';
+ }
+
+ public function isCompiled() {
+ return self::checkExtension( 'pgsql' );
+ }
+
+ function getConnectForm() {
+ return $this->getTextBox(
+ 'wgDBserver',
+ 'config-db-host',
+ [],
+ $this->parent->getHelpBox( 'config-db-host-help' )
+ ) .
+ $this->getTextBox( 'wgDBport', 'config-db-port' ) .
+ Html::openElement( 'fieldset' ) .
+ Html::element( 'legend', [], wfMessage( 'config-db-wiki-settings' )->text() ) .
+ $this->getTextBox(
+ 'wgDBname',
+ 'config-db-name',
+ [],
+ $this->parent->getHelpBox( 'config-db-name-help' )
+ ) .
+ $this->getTextBox(
+ 'wgDBmwschema',
+ 'config-db-schema',
+ [],
+ $this->parent->getHelpBox( 'config-db-schema-help' )
+ ) .
+ Html::closeElement( 'fieldset' ) .
+ $this->getInstallUserBox();
+ }
+
+ function submitConnectForm() {
+ // Get variables from the request
+ $newValues = $this->setVarsFromRequest( [
+ 'wgDBserver',
+ 'wgDBport',
+ 'wgDBname',
+ 'wgDBmwschema'
+ ] );
+
+ // Validate them
+ $status = Status::newGood();
+ if ( !strlen( $newValues['wgDBname'] ) ) {
+ $status->fatal( 'config-missing-db-name' );
+ } elseif ( !preg_match( '/^[a-zA-Z0-9_]+$/', $newValues['wgDBname'] ) ) {
+ $status->fatal( 'config-invalid-db-name', $newValues['wgDBname'] );
+ }
+ if ( !preg_match( '/^[a-zA-Z0-9_]*$/', $newValues['wgDBmwschema'] ) ) {
+ $status->fatal( 'config-invalid-schema', $newValues['wgDBmwschema'] );
+ }
+
+ // Submit user box
+ if ( $status->isOK() ) {
+ $status->merge( $this->submitInstallUserBox() );
+ }
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $status = $this->getPgConnection( 'create-db' );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+
+ // Check version
+ $version = $conn->getServerVersion();
+ $status = static::meetsMinimumRequirement( $conn->getServerVersion() );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $this->setVar( 'wgDBuser', $this->getVar( '_InstallUser' ) );
+ $this->setVar( 'wgDBpassword', $this->getVar( '_InstallPassword' ) );
+
+ return Status::newGood();
+ }
+
+ public function getConnection() {
+ $status = $this->getPgConnection( 'create-tables' );
+ if ( $status->isOK() ) {
+ $this->db = $status->value;
+ }
+
+ return $status;
+ }
+
+ public function openConnection() {
+ return $this->openPgConnection( 'create-tables' );
+ }
+
+ /**
+ * Open a PG connection with given parameters
+ * @param string $user User name
+ * @param string $password Password
+ * @param string $dbName Database name
+ * @param string $schema Database schema
+ * @return Status
+ */
+ protected function openConnectionWithParams( $user, $password, $dbName, $schema ) {
+ $status = Status::newGood();
+ try {
+ $db = Database::factory( 'postgres', [
+ 'host' => $this->getVar( 'wgDBserver' ),
+ 'port' => $this->getVar( 'wgDBport' ),
+ 'user' => $user,
+ 'password' => $password,
+ 'dbname' => $dbName,
+ 'schema' => $schema,
+ 'keywordTableMap' => [ 'user' => 'mwuser', 'text' => 'pagecontent' ],
+ ] );
+ $status->value = $db;
+ } catch ( DBConnectionError $e ) {
+ $status->fatal( 'config-connection-error', $e->getMessage() );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get a special type of connection
+ * @param string $type See openPgConnection() for details.
+ * @return Status
+ */
+ protected function getPgConnection( $type ) {
+ if ( isset( $this->pgConns[$type] ) ) {
+ return Status::newGood( $this->pgConns[$type] );
+ }
+ $status = $this->openPgConnection( $type );
+
+ if ( $status->isOK() ) {
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+ $conn->clearFlag( DBO_TRX );
+ $conn->commit( __METHOD__ );
+ $this->pgConns[$type] = $conn;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get a connection of a specific PostgreSQL-specific type. Connections
+ * of a given type are cached.
+ *
+ * PostgreSQL lacks cross-database operations, so after the new database is
+ * created, you need to make a separate connection to connect to that
+ * database and add tables to it.
+ *
+ * New tables are owned by the user that creates them, and MediaWiki's
+ * PostgreSQL support has always assumed that the table owner will be
+ * $wgDBuser. So before we create new tables, we either need to either
+ * connect as the other user or to execute a SET ROLE command. Using a
+ * separate connection for this allows us to avoid accidental cross-module
+ * dependencies.
+ *
+ * @param string $type The type of connection to get:
+ * - create-db: A connection for creating DBs, suitable for pre-
+ * installation.
+ * - create-schema: A connection to the new DB, for creating schemas and
+ * other similar objects in the new DB.
+ * - create-tables: A connection with a role suitable for creating tables.
+ *
+ * @throws MWException
+ * @return Status On success, a connection object will be in the value member.
+ */
+ protected function openPgConnection( $type ) {
+ switch ( $type ) {
+ case 'create-db':
+ return $this->openConnectionToAnyDB(
+ $this->getVar( '_InstallUser' ),
+ $this->getVar( '_InstallPassword' ) );
+ case 'create-schema':
+ return $this->openConnectionWithParams(
+ $this->getVar( '_InstallUser' ),
+ $this->getVar( '_InstallPassword' ),
+ $this->getVar( 'wgDBname' ),
+ $this->getVar( 'wgDBmwschema' ) );
+ case 'create-tables':
+ $status = $this->openPgConnection( 'create-schema' );
+ if ( $status->isOK() ) {
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+ $safeRole = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
+ $conn->query( "SET ROLE $safeRole" );
+ }
+
+ return $status;
+ default:
+ throw new MWException( "Invalid special connection type: \"$type\"" );
+ }
+ }
+
+ public function openConnectionToAnyDB( $user, $password ) {
+ $dbs = [
+ 'template1',
+ 'postgres',
+ ];
+ if ( !in_array( $this->getVar( 'wgDBname' ), $dbs ) ) {
+ array_unshift( $dbs, $this->getVar( 'wgDBname' ) );
+ }
+ $conn = false;
+ $status = Status::newGood();
+ foreach ( $dbs as $db ) {
+ try {
+ $p = [
+ 'host' => $this->getVar( 'wgDBserver' ),
+ 'user' => $user,
+ 'password' => $password,
+ 'dbname' => $db
+ ];
+ $conn = Database::factory( 'postgres', $p );
+ } catch ( DBConnectionError $error ) {
+ $conn = false;
+ $status->fatal( 'config-pg-test-error', $db,
+ $error->getMessage() );
+ }
+ if ( $conn !== false ) {
+ break;
+ }
+ }
+ if ( $conn !== false ) {
+ return Status::newGood( $conn );
+ } else {
+ return $status;
+ }
+ }
+
+ protected function getInstallUserPermissions() {
+ $status = $this->getPgConnection( 'create-db' );
+ if ( !$status->isOK() ) {
+ return false;
+ }
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+ $superuser = $this->getVar( '_InstallUser' );
+
+ $row = $conn->selectRow( '"pg_catalog"."pg_roles"', '*',
+ [ 'rolname' => $superuser ], __METHOD__ );
+
+ return $row;
+ }
+
+ protected function canCreateAccounts() {
+ $perms = $this->getInstallUserPermissions();
+ if ( !$perms ) {
+ return false;
+ }
+
+ return $perms->rolsuper === 't' || $perms->rolcreaterole === 't';
+ }
+
+ protected function isSuperUser() {
+ $perms = $this->getInstallUserPermissions();
+ if ( !$perms ) {
+ return false;
+ }
+
+ return $perms->rolsuper === 't';
+ }
+
+ public function getSettingsForm() {
+ if ( $this->canCreateAccounts() ) {
+ $noCreateMsg = false;
+ } else {
+ $noCreateMsg = 'config-db-web-no-create-privs';
+ }
+ $s = $this->getWebUserBox( $noCreateMsg );
+
+ return $s;
+ }
+
+ public function submitSettingsForm() {
+ $status = $this->submitWebUserBox();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $same = $this->getVar( 'wgDBuser' ) === $this->getVar( '_InstallUser' );
+
+ if ( $same ) {
+ $exists = true;
+ } else {
+ // Check if the web user exists
+ // Connect to the database with the install user
+ $status = $this->getPgConnection( 'create-db' );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $exists = $status->value->roleExists( $this->getVar( 'wgDBuser' ) );
+ }
+
+ // Validate the create checkbox
+ if ( $this->canCreateAccounts() && !$same && !$exists ) {
+ $create = $this->getVar( '_CreateDBAccount' );
+ } else {
+ $this->setVar( '_CreateDBAccount', false );
+ $create = false;
+ }
+
+ if ( !$create && !$exists ) {
+ if ( $this->canCreateAccounts() ) {
+ $msg = 'config-install-user-missing-create';
+ } else {
+ $msg = 'config-install-user-missing';
+ }
+
+ return Status::newFatal( $msg, $this->getVar( 'wgDBuser' ) );
+ }
+
+ if ( !$exists ) {
+ // No more checks to do
+ return Status::newGood();
+ }
+
+ // Existing web account. Test the connection.
+ $status = $this->openConnectionToAnyDB(
+ $this->getVar( 'wgDBuser' ),
+ $this->getVar( 'wgDBpassword' ) );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // The web user is conventionally the table owner in PostgreSQL
+ // installations. Make sure the install user is able to create
+ // objects on behalf of the web user.
+ if ( $same || $this->canCreateObjectsForWebUser() ) {
+ return Status::newGood();
+ } else {
+ return Status::newFatal( 'config-pg-not-in-role' );
+ }
+ }
+
+ /**
+ * Returns true if the install user is able to create objects owned
+ * by the web user, false otherwise.
+ * @return bool
+ */
+ protected function canCreateObjectsForWebUser() {
+ if ( $this->isSuperUser() ) {
+ return true;
+ }
+
+ $status = $this->getPgConnection( 'create-db' );
+ if ( !$status->isOK() ) {
+ return false;
+ }
+ $conn = $status->value;
+ $installerId = $conn->selectField( '"pg_catalog"."pg_roles"', 'oid',
+ [ 'rolname' => $this->getVar( '_InstallUser' ) ], __METHOD__ );
+ $webId = $conn->selectField( '"pg_catalog"."pg_roles"', 'oid',
+ [ 'rolname' => $this->getVar( 'wgDBuser' ) ], __METHOD__ );
+
+ return $this->isRoleMember( $conn, $installerId, $webId, $this->maxRoleSearchDepth );
+ }
+
+ /**
+ * Recursive helper for canCreateObjectsForWebUser().
+ * @param Database $conn
+ * @param int $targetMember Role ID of the member to look for
+ * @param int $group Role ID of the group to look for
+ * @param int $maxDepth Maximum recursive search depth
+ * @return bool
+ */
+ protected function isRoleMember( $conn, $targetMember, $group, $maxDepth ) {
+ if ( $targetMember === $group ) {
+ // A role is always a member of itself
+ return true;
+ }
+ // Get all members of the given group
+ $res = $conn->select( '"pg_catalog"."pg_auth_members"', [ 'member' ],
+ [ 'roleid' => $group ], __METHOD__ );
+ foreach ( $res as $row ) {
+ if ( $row->member == $targetMember ) {
+ // Found target member
+ return true;
+ }
+ // Recursively search each member of the group to see if the target
+ // is a member of it, up to the given maximum depth.
+ if ( $maxDepth > 0 ) {
+ if ( $this->isRoleMember( $conn, $targetMember, $row->member, $maxDepth - 1 ) ) {
+ // Found member of member
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public function preInstall() {
+ $createDbAccount = [
+ 'name' => 'user',
+ 'callback' => [ $this, 'setupUser' ],
+ ];
+ $commitCB = [
+ 'name' => 'pg-commit',
+ 'callback' => [ $this, 'commitChanges' ],
+ ];
+ $plpgCB = [
+ 'name' => 'pg-plpgsql',
+ 'callback' => [ $this, 'setupPLpgSQL' ],
+ ];
+ $schemaCB = [
+ 'name' => 'schema',
+ 'callback' => [ $this, 'setupSchema' ]
+ ];
+
+ if ( $this->getVar( '_CreateDBAccount' ) ) {
+ $this->parent->addInstallStep( $createDbAccount, 'database' );
+ }
+ $this->parent->addInstallStep( $commitCB, 'interwiki' );
+ $this->parent->addInstallStep( $plpgCB, 'database' );
+ $this->parent->addInstallStep( $schemaCB, 'database' );
+ }
+
+ function setupDatabase() {
+ $status = $this->getPgConnection( 'create-db' );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $conn = $status->value;
+
+ $dbName = $this->getVar( 'wgDBname' );
+
+ $exists = $conn->selectField( '"pg_catalog"."pg_database"', '1',
+ [ 'datname' => $dbName ], __METHOD__ );
+ if ( !$exists ) {
+ $safedb = $conn->addIdentifierQuotes( $dbName );
+ $conn->query( "CREATE DATABASE $safedb", __METHOD__ );
+ }
+
+ return Status::newGood();
+ }
+
+ function setupSchema() {
+ // Get a connection to the target database
+ $status = $this->getPgConnection( 'create-schema' );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $conn = $status->value;
+
+ // Create the schema if necessary
+ $schema = $this->getVar( 'wgDBmwschema' );
+ $safeschema = $conn->addIdentifierQuotes( $schema );
+ $safeuser = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
+ if ( !$conn->schemaExists( $schema ) ) {
+ try {
+ $conn->query( "CREATE SCHEMA $safeschema AUTHORIZATION $safeuser" );
+ } catch ( DBQueryError $e ) {
+ return Status::newFatal( 'config-install-pg-schema-failed',
+ $this->getVar( '_InstallUser' ), $schema );
+ }
+ }
+
+ // Select the new schema in the current connection
+ $conn->determineCoreSchema( $schema );
+
+ return Status::newGood();
+ }
+
+ function commitChanges() {
+ $this->db->commit( __METHOD__ );
+
+ return Status::newGood();
+ }
+
+ function setupUser() {
+ if ( !$this->getVar( '_CreateDBAccount' ) ) {
+ return Status::newGood();
+ }
+
+ $status = $this->getPgConnection( 'create-db' );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $conn = $status->value;
+
+ $safeuser = $conn->addIdentifierQuotes( $this->getVar( 'wgDBuser' ) );
+ $safepass = $conn->addQuotes( $this->getVar( 'wgDBpassword' ) );
+
+ // Check if the user already exists
+ $userExists = $conn->roleExists( $this->getVar( 'wgDBuser' ) );
+ if ( !$userExists ) {
+ // Create the user
+ try {
+ $sql = "CREATE ROLE $safeuser NOCREATEDB LOGIN PASSWORD $safepass";
+
+ // If the install user is not a superuser, we need to make the install
+ // user a member of the new user's group, so that the install user will
+ // be able to create a schema and other objects on behalf of the new user.
+ if ( !$this->isSuperUser() ) {
+ $sql .= ' ROLE' . $conn->addIdentifierQuotes( $this->getVar( '_InstallUser' ) );
+ }
+
+ $conn->query( $sql, __METHOD__ );
+ } catch ( DBQueryError $e ) {
+ return Status::newFatal( 'config-install-user-create-failed',
+ $this->getVar( 'wgDBuser' ), $e->getMessage() );
+ }
+ }
+
+ return Status::newGood();
+ }
+
+ function getLocalSettings() {
+ $port = $this->getVar( 'wgDBport' );
+ $schema = $this->getVar( 'wgDBmwschema' );
+
+ return "# Postgres specific settings
+\$wgDBport = \"{$port}\";
+\$wgDBmwschema = \"{$schema}\";";
+ }
+
+ public function preUpgrade() {
+ global $wgDBuser, $wgDBpassword;
+
+ # Normal user and password are selected after this step, so for now
+ # just copy these two
+ $wgDBuser = $this->getVar( '_InstallUser' );
+ $wgDBpassword = $this->getVar( '_InstallPassword' );
+ }
+
+ public function createTables() {
+ $schema = $this->getVar( 'wgDBmwschema' );
+
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ /** @var DatabasePostgres $conn */
+ $conn = $status->value;
+
+ if ( $conn->tableExists( 'archive' ) ) {
+ $status->warning( 'config-install-tables-exist' );
+ $this->enableLB();
+
+ return $status;
+ }
+
+ $conn->begin( __METHOD__ );
+
+ if ( !$conn->schemaExists( $schema ) ) {
+ $status->fatal( 'config-install-pg-schema-not-exist' );
+
+ return $status;
+ }
+ $error = $conn->sourceFile( $this->getSchemaPath( $conn ) );
+ if ( $error !== true ) {
+ $conn->reportQueryError( $error, 0, '', __METHOD__ );
+ $conn->rollback( __METHOD__ );
+ $status->fatal( 'config-install-tables-failed', $error );
+ } else {
+ $conn->commit( __METHOD__ );
+ }
+ // Resume normal operations
+ if ( $status->isOK() ) {
+ $this->enableLB();
+ }
+
+ return $status;
+ }
+
+ public function getGlobalDefaults() {
+ // The default $wgDBmwschema is null, which breaks Postgres and other DBMSes that require
+ // the use of a schema, so we need to set it here
+ return array_merge( parent::getGlobalDefaults(), [
+ 'wgDBmwschema' => 'mediawiki',
+ ] );
+ }
+
+ public function setupPLpgSQL() {
+ // Connect as the install user, since it owns the database and so is
+ // the user that needs to run "CREATE LANGAUGE"
+ $status = $this->getPgConnection( 'create-schema' );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ /**
+ * @var $conn Database
+ */
+ $conn = $status->value;
+
+ $exists = $conn->selectField( '"pg_catalog"."pg_language"', 1,
+ [ 'lanname' => 'plpgsql' ], __METHOD__ );
+ if ( $exists ) {
+ // Already exists, nothing to do
+ return Status::newGood();
+ }
+
+ // plpgsql is not installed, but if we have a pg_pltemplate table, we
+ // should be able to create it
+ $exists = $conn->selectField(
+ [ '"pg_catalog"."pg_class"', '"pg_catalog"."pg_namespace"' ],
+ 1,
+ [
+ 'pg_namespace.oid=relnamespace',
+ 'nspname' => 'pg_catalog',
+ 'relname' => 'pg_pltemplate',
+ ],
+ __METHOD__ );
+ if ( $exists ) {
+ try {
+ $conn->query( 'CREATE LANGUAGE plpgsql' );
+ } catch ( DBQueryError $e ) {
+ return Status::newFatal( 'config-pg-no-plpgsql', $this->getVar( 'wgDBname' ) );
+ }
+ } else {
+ return Status::newFatal( 'config-pg-no-plpgsql', $this->getVar( 'wgDBname' ) );
+ }
+
+ return Status::newGood();
+ }
+}
diff --git a/www/wiki/includes/installer/PostgresUpdater.php b/www/wiki/includes/installer/PostgresUpdater.php
new file mode 100644
index 00000000..c38eb6aa
--- /dev/null
+++ b/www/wiki/includes/installer/PostgresUpdater.php
@@ -0,0 +1,1066 @@
+<?php
+/**
+ * PostgreSQL-specific updater.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use Wikimedia\Rdbms\DatabasePostgres;
+
+/**
+ * Class for handling updates to Postgres databases.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class PostgresUpdater extends DatabaseUpdater {
+
+ /**
+ * @var DatabasePostgres
+ */
+ protected $db;
+
+ /**
+ * @todo FIXME: Postgres should use sequential updates like Mysql, Sqlite
+ * and everybody else. It never got refactored like it should've.
+ * @return array
+ */
+ protected function getCoreUpdateList() {
+ return [
+ # rename tables 1.7.3
+ # r15791 Change reserved word table names "user" and "text"
+ [ 'renameTable', 'user', 'mwuser' ],
+ [ 'renameTable', 'text', 'pagecontent' ],
+ [ 'renameIndex', 'mwuser', 'user_pkey', 'mwuser_pkey' ],
+ [ 'renameIndex', 'mwuser', 'user_user_name_key', 'mwuser_user_name_key' ],
+ [ 'renameIndex', 'pagecontent', 'text_pkey', 'pagecontent_pkey' ],
+
+ # renamed sequences
+ [ 'renameSequence', 'ipblocks_ipb_id_val', 'ipblocks_ipb_id_seq' ],
+ [ 'renameSequence', 'rev_rev_id_val', 'revision_rev_id_seq' ],
+ [ 'renameSequence', 'text_old_id_val', 'text_old_id_seq' ],
+ [ 'renameSequence', 'rc_rc_id_seq', 'recentchanges_rc_id_seq' ],
+ [ 'renameSequence', 'log_log_id_seq', 'logging_log_id_seq' ],
+ [ 'renameSequence', 'pr_id_val', 'page_restrictions_pr_id_seq' ],
+ [ 'renameSequence', 'us_id_seq', 'uploadstash_us_id_seq' ],
+
+ # since r58263
+ [ 'renameSequence', 'category_id_seq', 'category_cat_id_seq' ],
+
+ # new sequences if not renamed above
+ [ 'addSequence', 'logging', false, 'logging_log_id_seq' ],
+ [ 'addSequence', 'page_restrictions', false, 'page_restrictions_pr_id_seq' ],
+ [ 'addSequence', 'filearchive', 'fa_id', 'filearchive_fa_id_seq' ],
+ [ 'addSequence', 'archive', false, 'archive_ar_id_seq' ],
+ [ 'addSequence', 'externallinks', false, 'externallinks_el_id_seq' ],
+ [ 'addSequence', 'watchlist', false, 'watchlist_wl_id_seq' ],
+ [ 'addSequence', 'change_tag', false, 'change_tag_ct_id_seq' ],
+ [ 'addSequence', 'tag_summary', false, 'tag_summary_ts_id_seq' ],
+
+ # new tables
+ [ 'addTable', 'category', 'patch-category.sql' ],
+ [ 'addTable', 'page', 'patch-page.sql' ],
+ [ 'addTable', 'querycachetwo', 'patch-querycachetwo.sql' ],
+ [ 'addTable', 'page_props', 'patch-page_props.sql' ],
+ [ 'addTable', 'page_restrictions', 'patch-page_restrictions.sql' ],
+ [ 'addTable', 'profiling', 'patch-profiling.sql' ],
+ [ 'addTable', 'protected_titles', 'patch-protected_titles.sql' ],
+ [ 'addTable', 'redirect', 'patch-redirect.sql' ],
+ [ 'addTable', 'updatelog', 'patch-updatelog.sql' ],
+ [ 'addTable', 'change_tag', 'patch-change_tag.sql' ],
+ [ 'addTable', 'tag_summary', 'patch-tag_summary.sql' ],
+ [ 'addTable', 'valid_tag', 'patch-valid_tag.sql' ],
+ [ 'addTable', 'user_properties', 'patch-user_properties.sql' ],
+ [ 'addTable', 'log_search', 'patch-log_search.sql' ],
+ [ 'addTable', 'l10n_cache', 'patch-l10n_cache.sql' ],
+ [ 'addTable', 'iwlinks', 'patch-iwlinks.sql' ],
+ [ 'addTable', 'module_deps', 'patch-module_deps.sql' ],
+ [ 'addTable', 'uploadstash', 'patch-uploadstash.sql' ],
+ [ 'addTable', 'user_former_groups', 'patch-user_former_groups.sql' ],
+ [ 'addTable', 'sites', 'patch-sites.sql' ],
+ [ 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ],
+
+ # Needed before new field
+ [ 'convertArchive2' ],
+
+ # new fields
+ [ 'addPgField', 'updatelog', 'ul_value', 'TEXT' ],
+ [ 'addPgField', 'archive', 'ar_deleted', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'archive', 'ar_len', 'INTEGER' ],
+ [ 'addPgField', 'archive', 'ar_page_id', 'INTEGER' ],
+ [ 'addPgField', 'archive', 'ar_parent_id', 'INTEGER' ],
+ [ 'addPgField', 'archive', 'ar_content_model', 'TEXT' ],
+ [ 'addPgField', 'archive', 'ar_content_format', 'TEXT' ],
+ [ 'addPgField', 'categorylinks', 'cl_sortkey_prefix', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'categorylinks', 'cl_collation', "TEXT NOT NULL DEFAULT 0" ],
+ [ 'addPgField', 'categorylinks', 'cl_type', "TEXT NOT NULL DEFAULT 'page'" ],
+ [ 'addPgField', 'image', 'img_sha1', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'ipblocks', 'ipb_allow_usertalk', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'ipblocks', 'ipb_anon_only', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'ipblocks', 'ipb_by_text', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'ipblocks', 'ipb_block_email', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'ipblocks', 'ipb_create_account', 'SMALLINT NOT NULL DEFAULT 1' ],
+ [ 'addPgField', 'ipblocks', 'ipb_deleted', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'ipblocks', 'ipb_enable_autoblock', 'SMALLINT NOT NULL DEFAULT 1' ],
+ [ 'addPgField', 'ipblocks', 'ipb_parent_block_id',
+ 'INTEGER DEFAULT NULL REFERENCES ipblocks(ipb_id) ' .
+ 'ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED' ],
+ [ 'addPgField', 'filearchive', 'fa_deleted', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'filearchive', 'fa_sha1', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'logging', 'log_deleted', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'logging', 'log_id',
+ "INTEGER NOT NULL PRIMARY KEY DEFAULT nextval('logging_log_id_seq')" ],
+ [ 'addPgField', 'logging', 'log_params', 'TEXT' ],
+ [ 'addPgField', 'mwuser', 'user_editcount', 'INTEGER' ],
+ [ 'addPgField', 'mwuser', 'user_newpass_time', 'TIMESTAMPTZ' ],
+ [ 'addPgField', 'oldimage', 'oi_deleted', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'oldimage', 'oi_major_mime', "TEXT NOT NULL DEFAULT 'unknown'" ],
+ [ 'addPgField', 'oldimage', 'oi_media_type', 'TEXT' ],
+ [ 'addPgField', 'oldimage', 'oi_metadata', "BYTEA NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'oldimage', 'oi_minor_mime', "TEXT NOT NULL DEFAULT 'unknown'" ],
+ [ 'addPgField', 'oldimage', 'oi_sha1', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'page', 'page_content_model', 'TEXT' ],
+ [ 'addPgField', 'page_restrictions', 'pr_id',
+ "INTEGER NOT NULL UNIQUE DEFAULT nextval('page_restrictions_pr_id_seq')" ],
+ [ 'addPgField', 'profiling', 'pf_memory', 'NUMERIC(18,10) NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'recentchanges', 'rc_deleted', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'recentchanges', 'rc_log_action', 'TEXT' ],
+ [ 'addPgField', 'recentchanges', 'rc_log_type', 'TEXT' ],
+ [ 'addPgField', 'recentchanges', 'rc_logid', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'recentchanges', 'rc_new_len', 'INTEGER' ],
+ [ 'addPgField', 'recentchanges', 'rc_old_len', 'INTEGER' ],
+ [ 'addPgField', 'recentchanges', 'rc_params', 'TEXT' ],
+ [ 'addPgField', 'redirect', 'rd_interwiki', 'TEXT NULL' ],
+ [ 'addPgField', 'redirect', 'rd_fragment', 'TEXT NULL' ],
+ [ 'addPgField', 'revision', 'rev_deleted', 'SMALLINT NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'revision', 'rev_len', 'INTEGER' ],
+ [ 'addPgField', 'revision', 'rev_parent_id', 'INTEGER DEFAULT NULL' ],
+ [ 'addPgField', 'revision', 'rev_content_model', 'TEXT' ],
+ [ 'addPgField', 'revision', 'rev_content_format', 'TEXT' ],
+ [ 'addPgField', 'site_stats', 'ss_active_users', "INTEGER DEFAULT '-1'" ],
+ [ 'addPgField', 'user_newtalk', 'user_last_timestamp', 'TIMESTAMPTZ' ],
+ [ 'addPgField', 'logging', 'log_user_text', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'logging', 'log_page', 'INTEGER' ],
+ [ 'addPgField', 'interwiki', 'iw_api', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'interwiki', 'iw_wikiid', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'revision', 'rev_sha1', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'archive', 'ar_sha1', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'uploadstash', 'us_chunk_inx', "INTEGER NULL" ],
+ [ 'addPgField', 'job', 'job_timestamp', "TIMESTAMPTZ" ],
+ [ 'addPgField', 'job', 'job_random', "INTEGER NOT NULL DEFAULT 0" ],
+ [ 'addPgField', 'job', 'job_attempts', "INTEGER NOT NULL DEFAULT 0" ],
+ [ 'addPgField', 'job', 'job_token', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'job', 'job_token_timestamp', "TIMESTAMPTZ" ],
+ [ 'addPgField', 'job', 'job_sha1', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'archive', 'ar_id',
+ "INTEGER NOT NULL PRIMARY KEY DEFAULT nextval('archive_ar_id_seq')" ],
+ [ 'addPgField', 'externallinks', 'el_id',
+ "INTEGER NOT NULL PRIMARY KEY DEFAULT nextval('externallinks_el_id_seq')" ],
+ [ 'addPgField', 'uploadstash', 'us_props', "BYTEA" ],
+
+ # type changes
+ [ 'changeField', 'archive', 'ar_deleted', 'smallint', '' ],
+ [ 'changeField', 'archive', 'ar_minor_edit', 'smallint',
+ 'ar_minor_edit::smallint DEFAULT 0' ],
+ [ 'changeField', 'filearchive', 'fa_deleted', 'smallint', '' ],
+ [ 'changeField', 'filearchive', 'fa_height', 'integer', '' ],
+ [ 'changeField', 'filearchive', 'fa_metadata', 'bytea', "decode(fa_metadata,'escape')" ],
+ [ 'changeField', 'filearchive', 'fa_size', 'integer', '' ],
+ [ 'changeField', 'filearchive', 'fa_width', 'integer', '' ],
+ [ 'changeField', 'filearchive', 'fa_storage_group', 'text', '' ],
+ [ 'changeField', 'filearchive', 'fa_storage_key', 'text', '' ],
+ [ 'changeField', 'image', 'img_metadata', 'bytea', "decode(img_metadata,'escape')" ],
+ [ 'changeField', 'image', 'img_size', 'integer', '' ],
+ [ 'changeField', 'image', 'img_width', 'integer', '' ],
+ [ 'changeField', 'image', 'img_height', 'integer', '' ],
+ [ 'changeField', 'interwiki', 'iw_local', 'smallint', 'iw_local::smallint' ],
+ [ 'changeField', 'interwiki', 'iw_trans', 'smallint', 'iw_trans::smallint DEFAULT 0' ],
+ [ 'changeField', 'ipblocks', 'ipb_auto', 'smallint', 'ipb_auto::smallint DEFAULT 0' ],
+ [ 'changeField', 'ipblocks', 'ipb_anon_only', 'smallint',
+ "CASE WHEN ipb_anon_only=' ' THEN 0 ELSE ipb_anon_only::smallint END DEFAULT 0" ],
+ [ 'changeField', 'ipblocks', 'ipb_create_account', 'smallint',
+ "CASE WHEN ipb_create_account=' ' THEN 0 ELSE ipb_create_account::smallint END DEFAULT 1" ],
+ [ 'changeField', 'ipblocks', 'ipb_enable_autoblock', 'smallint',
+ "CASE WHEN ipb_enable_autoblock=' ' THEN 0 ELSE ipb_enable_autoblock::smallint END DEFAULT 1" ],
+ [ 'changeField', 'ipblocks', 'ipb_block_email', 'smallint',
+ "CASE WHEN ipb_block_email=' ' THEN 0 ELSE ipb_block_email::smallint END DEFAULT 0" ],
+ [ 'changeField', 'ipblocks', 'ipb_address', 'text', 'ipb_address::text' ],
+ [ 'changeField', 'ipblocks', 'ipb_deleted', 'smallint', 'ipb_deleted::smallint DEFAULT 0' ],
+ [ 'changeField', 'mwuser', 'user_token', 'text', '' ],
+ [ 'changeField', 'mwuser', 'user_email_token', 'text', '' ],
+ [ 'changeField', 'objectcache', 'keyname', 'text', '' ],
+ [ 'changeField', 'oldimage', 'oi_height', 'integer', '' ],
+ [ 'changeField', 'oldimage', 'oi_metadata', 'bytea', "decode(img_metadata,'escape')" ],
+ [ 'changeField', 'oldimage', 'oi_size', 'integer', '' ],
+ [ 'changeField', 'oldimage', 'oi_width', 'integer', '' ],
+ [ 'changeField', 'page', 'page_is_redirect', 'smallint',
+ 'page_is_redirect::smallint DEFAULT 0' ],
+ [ 'changeField', 'page', 'page_is_new', 'smallint', 'page_is_new::smallint DEFAULT 0' ],
+ [ 'changeField', 'querycache', 'qc_value', 'integer', '' ],
+ [ 'changeField', 'querycachetwo', 'qcc_value', 'integer', '' ],
+ [ 'changeField', 'recentchanges', 'rc_bot', 'smallint', 'rc_bot::smallint DEFAULT 0' ],
+ [ 'changeField', 'recentchanges', 'rc_deleted', 'smallint', '' ],
+ [ 'changeField', 'recentchanges', 'rc_minor', 'smallint', 'rc_minor::smallint DEFAULT 0' ],
+ [ 'changeField', 'recentchanges', 'rc_new', 'smallint', 'rc_new::smallint DEFAULT 0' ],
+ [ 'changeField', 'recentchanges', 'rc_type', 'smallint', 'rc_type::smallint DEFAULT 0' ],
+ [ 'changeField', 'recentchanges', 'rc_patrolled', 'smallint',
+ 'rc_patrolled::smallint DEFAULT 0' ],
+ [ 'changeField', 'revision', 'rev_deleted', 'smallint', 'rev_deleted::smallint DEFAULT 0' ],
+ [ 'changeField', 'revision', 'rev_minor_edit', 'smallint',
+ 'rev_minor_edit::smallint DEFAULT 0' ],
+ [ 'changeField', 'templatelinks', 'tl_namespace', 'smallint', 'tl_namespace::smallint' ],
+ [ 'changeField', 'user_newtalk', 'user_ip', 'text', 'host(user_ip)' ],
+ [ 'changeField', 'uploadstash', 'us_image_bits', 'smallint', '' ],
+ [ 'changeField', 'profiling', 'pf_time', 'float', '' ],
+ [ 'changeField', 'profiling', 'pf_memory', 'float', '' ],
+
+ # null changes
+ [ 'changeNullableField', 'oldimage', 'oi_bits', 'NULL' ],
+ [ 'changeNullableField', 'oldimage', 'oi_timestamp', 'NULL' ],
+ [ 'changeNullableField', 'oldimage', 'oi_major_mime', 'NULL' ],
+ [ 'changeNullableField', 'oldimage', 'oi_minor_mime', 'NULL' ],
+ [ 'changeNullableField', 'image', 'img_metadata', 'NOT NULL' ],
+ [ 'changeNullableField', 'filearchive', 'fa_metadata', 'NOT NULL' ],
+ [ 'changeNullableField', 'recentchanges', 'rc_cur_id', 'NULL' ],
+ [ 'changeNullableField', 'recentchanges', 'rc_cur_time', 'NULL' ],
+
+ [ 'checkOiDeleted' ],
+
+ # New indexes
+ [ 'addPgIndex', 'archive', 'archive_user_text', '(ar_user_text)' ],
+ [ 'addPgIndex', 'image', 'img_sha1', '(img_sha1)' ],
+ [ 'addPgIndex', 'ipblocks', 'ipb_parent_block_id', '(ipb_parent_block_id)' ],
+ [ 'addPgIndex', 'oldimage', 'oi_sha1', '(oi_sha1)' ],
+ [ 'addPgIndex', 'page', 'page_mediawiki_title', '(page_title) WHERE page_namespace = 8' ],
+ [ 'addPgIndex', 'pagelinks', 'pagelinks_title', '(pl_title)' ],
+ [ 'addPgIndex', 'page_props', 'pp_propname_page', '(pp_propname, pp_page)' ],
+ [ 'addPgIndex', 'revision', 'rev_text_id_idx', '(rev_text_id)' ],
+ [ 'addPgIndex', 'recentchanges', 'rc_timestamp_bot', '(rc_timestamp) WHERE rc_bot = 0' ],
+ [ 'addPgIndex', 'templatelinks', 'templatelinks_from', '(tl_from)' ],
+ [ 'addPgIndex', 'watchlist', 'wl_user', '(wl_user)' ],
+ [ 'addPgIndex', 'watchlist', 'wl_user_notificationtimestamp',
+ '(wl_user, wl_notificationtimestamp)' ],
+ [ 'addPgIndex', 'logging', 'logging_user_type_time',
+ '(log_user, log_type, log_timestamp)' ],
+ [ 'addPgIndex', 'logging', 'logging_page_id_time', '(log_page,log_timestamp)' ],
+ [ 'addPgIndex', 'iwlinks', 'iwl_prefix_from_title', '(iwl_prefix, iwl_from, iwl_title)' ],
+ [ 'addPgIndex', 'iwlinks', 'iwl_prefix_title_from', '(iwl_prefix, iwl_title, iwl_from)' ],
+ [ 'addPgIndex', 'job', 'job_timestamp_idx', '(job_timestamp)' ],
+ [ 'addPgIndex', 'job', 'job_sha1', '(job_sha1)' ],
+ [ 'addPgIndex', 'job', 'job_cmd_token', '(job_cmd, job_token, job_random)' ],
+ [ 'addPgIndex', 'job', 'job_cmd_token_id', '(job_cmd, job_token, job_id)' ],
+ [ 'addPgIndex', 'filearchive', 'fa_sha1', '(fa_sha1)' ],
+ [ 'addPgIndex', 'logging', 'logging_user_text_type_time',
+ '(log_user_text, log_type, log_timestamp)' ],
+ [ 'addPgIndex', 'logging', 'logging_user_text_time', '(log_user_text, log_timestamp)' ],
+
+ [ 'checkIndex', 'pagelink_unique', [
+ [ 'pl_from', 'int4_ops', 'btree', 0 ],
+ [ 'pl_namespace', 'int2_ops', 'btree', 0 ],
+ [ 'pl_title', 'text_ops', 'btree', 0 ],
+ ],
+ 'CREATE UNIQUE INDEX pagelink_unique ON pagelinks (pl_from,pl_namespace,pl_title)' ],
+ [ 'checkIndex', 'cl_sortkey', [
+ [ 'cl_to', 'text_ops', 'btree', 0 ],
+ [ 'cl_sortkey', 'text_ops', 'btree', 0 ],
+ [ 'cl_from', 'int4_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX cl_sortkey ON "categorylinks" ' .
+ 'USING "btree" ("cl_to", "cl_sortkey", "cl_from")' ],
+ [ 'checkIndex', 'iwl_prefix_title_from', [
+ [ 'iwl_prefix', 'text_ops', 'btree', 0 ],
+ [ 'iwl_title', 'text_ops', 'btree', 0 ],
+ [ 'iwl_from', 'int4_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX iwl_prefix_title_from ON "iwlinks" ' .
+ 'USING "btree" ("iwl_prefix", "iwl_title", "iwl_from")' ],
+ [ 'checkIndex', 'logging_times', [
+ [ 'log_timestamp', 'timestamptz_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "logging_times" ON "logging" USING "btree" ("log_timestamp")' ],
+ [ 'dropIndex', 'oldimage', 'oi_name' ],
+ [ 'checkIndex', 'oi_name_archive_name', [
+ [ 'oi_name', 'text_ops', 'btree', 0 ],
+ [ 'oi_archive_name', 'text_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "oi_name_archive_name" ON "oldimage" ' .
+ 'USING "btree" ("oi_name", "oi_archive_name")' ],
+ [ 'checkIndex', 'oi_name_timestamp', [
+ [ 'oi_name', 'text_ops', 'btree', 0 ],
+ [ 'oi_timestamp', 'timestamptz_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "oi_name_timestamp" ON "oldimage" ' .
+ 'USING "btree" ("oi_name", "oi_timestamp")' ],
+ [ 'checkIndex', 'page_main_title', [
+ [ 'page_title', 'text_pattern_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "page_main_title" ON "page" ' .
+ 'USING "btree" ("page_title" "text_pattern_ops") WHERE ("page_namespace" = 0)' ],
+ [ 'checkIndex', 'page_mediawiki_title', [
+ [ 'page_title', 'text_pattern_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "page_mediawiki_title" ON "page" ' .
+ 'USING "btree" ("page_title" "text_pattern_ops") WHERE ("page_namespace" = 8)' ],
+ [ 'checkIndex', 'page_project_title', [
+ [ 'page_title', 'text_pattern_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "page_project_title" ON "page" ' .
+ 'USING "btree" ("page_title" "text_pattern_ops") ' .
+ 'WHERE ("page_namespace" = 4)' ],
+ [ 'checkIndex', 'page_talk_title', [
+ [ 'page_title', 'text_pattern_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "page_talk_title" ON "page" ' .
+ 'USING "btree" ("page_title" "text_pattern_ops") ' .
+ 'WHERE ("page_namespace" = 1)' ],
+ [ 'checkIndex', 'page_user_title', [
+ [ 'page_title', 'text_pattern_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "page_user_title" ON "page" ' .
+ 'USING "btree" ("page_title" "text_pattern_ops") WHERE ' .
+ '("page_namespace" = 2)' ],
+ [ 'checkIndex', 'page_utalk_title', [
+ [ 'page_title', 'text_pattern_ops', 'btree', 0 ],
+ ],
+ 'CREATE INDEX "page_utalk_title" ON "page" ' .
+ 'USING "btree" ("page_title" "text_pattern_ops") ' .
+ 'WHERE ("page_namespace" = 3)' ],
+ [ 'checkIndex', 'ts2_page_text', [
+ [ 'textvector', 'tsvector_ops', 'gist', 0 ],
+ ],
+ 'CREATE INDEX "ts2_page_text" ON "pagecontent" USING "gist" ("textvector")' ],
+ [ 'checkIndex', 'ts2_page_title', [
+ [ 'titlevector', 'tsvector_ops', 'gist', 0 ],
+ ],
+ 'CREATE INDEX "ts2_page_title" ON "page" USING "gist" ("titlevector")' ],
+
+ [ 'checkOiNameConstraint' ],
+ [ 'checkPageDeletedTrigger' ],
+ [ 'checkRevUserFkey' ],
+ [ 'dropIndex', 'ipblocks', 'ipb_address' ],
+ [ 'checkIndex', 'ipb_address_unique', [
+ [ 'ipb_address', 'text_ops', 'btree', 0 ],
+ [ 'ipb_user', 'int4_ops', 'btree', 0 ],
+ [ 'ipb_auto', 'int2_ops', 'btree', 0 ],
+ [ 'ipb_anon_only', 'int2_ops', 'btree', 0 ],
+ ],
+ 'CREATE UNIQUE INDEX ipb_address_unique ' .
+ 'ON ipblocks (ipb_address,ipb_user,ipb_auto,ipb_anon_only)' ],
+
+ [ 'checkIwlPrefix' ],
+
+ # All FK columns should be deferred
+ [ 'changeFkeyDeferrable', 'archive', 'ar_user', 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'categorylinks', 'cl_from', 'page(page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'externallinks', 'el_from', 'page(page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'filearchive', 'fa_deleted_user',
+ 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'filearchive', 'fa_user', 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'image', 'img_user', 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'imagelinks', 'il_from', 'page(page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'ipblocks', 'ipb_by', 'mwuser(user_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'ipblocks', 'ipb_user', 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'ipblocks', 'ipb_parent_block_id',
+ 'ipblocks(ipb_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'langlinks', 'll_from', 'page(page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'logging', 'log_user', 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'oldimage', 'oi_name',
+ 'image(img_name) ON DELETE CASCADE ON UPDATE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'oldimage', 'oi_user', 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'pagelinks', 'pl_from', 'page(page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'page_props', 'pp_page', 'page (page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'page_restrictions', 'pr_page',
+ 'page(page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'protected_titles', 'pt_user',
+ 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'recentchanges', 'rc_user',
+ 'mwuser(user_id) ON DELETE SET NULL' ],
+ [ 'changeFkeyDeferrable', 'redirect', 'rd_from', 'page(page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'revision', 'rev_page', 'page (page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'revision', 'rev_user', 'mwuser(user_id) ON DELETE RESTRICT' ],
+ [ 'changeFkeyDeferrable', 'templatelinks', 'tl_from', 'page(page_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'user_groups', 'ug_user', 'mwuser(user_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'user_newtalk', 'user_id', 'mwuser(user_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'user_properties', 'up_user',
+ 'mwuser(user_id) ON DELETE CASCADE' ],
+ [ 'changeFkeyDeferrable', 'watchlist', 'wl_user', 'mwuser(user_id) ON DELETE CASCADE' ],
+
+ # r81574
+ [ 'addInterwikiType' ],
+ # end
+ [ 'tsearchFixes' ],
+
+ // 1.23
+ [ 'addPgField', 'recentchanges', 'rc_source', "TEXT NOT NULL DEFAULT ''" ],
+ [ 'addPgField', 'page', 'page_links_updated', "TIMESTAMPTZ NULL" ],
+ [ 'addPgField', 'mwuser', 'user_password_expires', 'TIMESTAMPTZ NULL' ],
+ [ 'changeFieldPurgeTable', 'l10n_cache', 'lc_value', 'bytea',
+ "replace(lc_value,'\','\\\\')::bytea" ],
+ // 1.23.9
+ [ 'rebuildTextSearch' ],
+
+ // 1.24
+ [ 'addPgField', 'page_props', 'pp_sortkey', 'float NULL' ],
+ [ 'addPgIndex', 'page_props', 'pp_propname_sortkey_page',
+ '( pp_propname, pp_sortkey, pp_page ) WHERE ( pp_sortkey IS NOT NULL )' ],
+ [ 'addPgField', 'page', 'page_lang', 'TEXT default NULL' ],
+ [ 'addPgField', 'pagelinks', 'pl_from_namespace', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'templatelinks', 'tl_from_namespace', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'addPgField', 'imagelinks', 'il_from_namespace', 'INTEGER NOT NULL DEFAULT 0' ],
+
+ // 1.25
+ [ 'dropTable', 'hitcounter' ],
+ [ 'dropField', 'site_stats', 'ss_total_views', 'patch-drop-ss_total_views.sql' ],
+ [ 'dropField', 'page', 'page_counter', 'patch-drop-page_counter.sql' ],
+ [ 'dropFkey', 'recentchanges', 'rc_cur_id' ],
+
+ // 1.27
+ [ 'dropTable', 'msg_resource_links' ],
+ [ 'dropTable', 'msg_resource' ],
+ [
+ 'addPgField', 'watchlist', 'wl_id',
+ "INTEGER NOT NULL PRIMARY KEY DEFAULT nextval('watchlist_wl_id_seq')"
+ ],
+
+ // 1.28
+ [ 'addPgIndex', 'recentchanges', 'rc_name_type_patrolled_timestamp',
+ '( rc_namespace, rc_type, rc_patrolled, rc_timestamp )' ],
+ [ 'addPgField', 'change_tag', 'ct_id',
+ "INTEGER NOT NULL PRIMARY KEY DEFAULT nextval('change_tag_ct_id_seq')" ],
+ [ 'addPgField', 'tag_summary', 'ts_id',
+ "INTEGER NOT NULL PRIMARY KEY DEFAULT nextval('tag_summary_ts_id_seq')" ],
+
+ // 1.29
+ [ 'addPgField', 'externallinks', 'el_index_60', "BYTEA NOT NULL DEFAULT ''" ],
+ [ 'addPgIndex', 'externallinks', 'el_index_60', '( el_index_60, el_id )' ],
+ [ 'addPgIndex', 'externallinks', 'el_from_index_60', '( el_from, el_index_60, el_id )' ],
+ [ 'addPgField', 'user_groups', 'ug_expiry', "TIMESTAMPTZ NULL" ],
+ [ 'addPgIndex', 'user_groups', 'user_groups_expiry', '( ug_expiry )' ],
+
+ // 1.30
+ [ 'addPgEnumValue', 'media_type', '3D' ],
+ [ 'setDefault', 'revision', 'rev_comment', '' ],
+ [ 'changeNullableField', 'revision', 'rev_comment', 'NOT NULL', true ],
+ [ 'setDefault', 'archive', 'ar_comment', '' ],
+ [ 'changeNullableField', 'archive', 'ar_comment', 'NOT NULL', true ],
+ [ 'addPgField', 'archive', 'ar_comment_id', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'setDefault', 'ipblocks', 'ipb_reason', '' ],
+ [ 'addPgField', 'ipblocks', 'ipb_reason_id', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'setDefault', 'image', 'img_description', '' ],
+ [ 'setDefault', 'oldimage', 'oi_description', '' ],
+ [ 'changeNullableField', 'oldimage', 'oi_description', 'NOT NULL', true ],
+ [ 'addPgField', 'oldimage', 'oi_description_id', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'setDefault', 'filearchive', 'fa_deleted_reason', '' ],
+ [ 'changeNullableField', 'filearchive', 'fa_deleted_reason', 'NOT NULL', true ],
+ [ 'addPgField', 'filearchive', 'fa_deleted_reason_id', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'setDefault', 'filearchive', 'fa_description', '' ],
+ [ 'addPgField', 'filearchive', 'fa_description_id', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'setDefault', 'recentchanges', 'rc_comment', '' ],
+ [ 'changeNullableField', 'recentchanges', 'rc_comment', 'NOT NULL', true ],
+ [ 'addPgField', 'recentchanges', 'rc_comment_id', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'setDefault', 'logging', 'log_comment', '' ],
+ [ 'changeNullableField', 'logging', 'log_comment', 'NOT NULL', true ],
+ [ 'addPgField', 'logging', 'log_comment_id', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'setDefault', 'protected_titles', 'pt_reason', '' ],
+ [ 'changeNullableField', 'protected_titles', 'pt_reason', 'NOT NULL', true ],
+ [ 'addPgField', 'protected_titles', 'pt_reason_id', 'INTEGER NOT NULL DEFAULT 0' ],
+ [ 'addTable', 'comment', 'patch-comment-table.sql' ],
+ [ 'addIndex', 'site_stats', 'site_stats_pkey', 'patch-site_stats-pk.sql' ],
+ [ 'addTable', 'ip_changes', 'patch-ip_changes.sql' ],
+ ];
+ }
+
+ protected function getOldGlobalUpdates() {
+ global $wgExtNewTables, $wgExtPGNewFields, $wgExtPGAlteredFields, $wgExtNewIndexes;
+
+ $updates = [];
+
+ # Add missing extension tables
+ foreach ( $wgExtNewTables as $tableRecord ) {
+ $updates[] = [
+ 'addTable', $tableRecord[0], $tableRecord[1], true
+ ];
+ }
+
+ # Add missing extension fields
+ foreach ( $wgExtPGNewFields as $fieldRecord ) {
+ $updates[] = [
+ 'addPgField', $fieldRecord[0], $fieldRecord[1],
+ $fieldRecord[2]
+ ];
+ }
+
+ # Change altered columns
+ foreach ( $wgExtPGAlteredFields as $fieldRecord ) {
+ $updates[] = [
+ 'changeField', $fieldRecord[0], $fieldRecord[1],
+ $fieldRecord[2]
+ ];
+ }
+
+ # Add missing extension indexes
+ foreach ( $wgExtNewIndexes as $fieldRecord ) {
+ $updates[] = [
+ 'addPgExtIndex', $fieldRecord[0], $fieldRecord[1],
+ $fieldRecord[2]
+ ];
+ }
+
+ return $updates;
+ }
+
+ protected function describeTable( $table ) {
+ $q = <<<END
+SELECT attname, attnum FROM pg_namespace, pg_class, pg_attribute
+ WHERE pg_class.relnamespace = pg_namespace.oid
+ AND attrelid=pg_class.oid AND attnum > 0
+ AND relname=%s AND nspname=%s
+END;
+ $res = $this->db->query( sprintf( $q,
+ $this->db->addQuotes( $table ),
+ $this->db->addQuotes( $this->db->getCoreSchema() ) ) );
+ if ( !$res ) {
+ return null;
+ }
+
+ $cols = [];
+ foreach ( $res as $r ) {
+ $cols[] = [
+ "name" => $r[0],
+ "ord" => $r[1],
+ ];
+ }
+
+ return $cols;
+ }
+
+ function describeIndex( $idx ) {
+ // first fetch the key (which is a list of columns ords) and
+ // the table the index applies to (an oid)
+ $q = <<<END
+SELECT indkey, indrelid FROM pg_namespace, pg_class, pg_index
+ WHERE nspname=%s
+ AND pg_class.relnamespace = pg_namespace.oid
+ AND relname=%s
+ AND indexrelid=pg_class.oid
+END;
+ $res = $this->db->query(
+ sprintf(
+ $q,
+ $this->db->addQuotes( $this->db->getCoreSchema() ),
+ $this->db->addQuotes( $idx )
+ )
+ );
+ if ( !$res ) {
+ return null;
+ }
+ $r = $this->db->fetchRow( $res );
+ if ( !$r ) {
+ return null;
+ }
+
+ $indkey = $r[0];
+ $relid = intval( $r[1] );
+ $indkeys = explode( ' ', $indkey );
+
+ $colnames = [];
+ foreach ( $indkeys as $rid ) {
+ $query = <<<END
+SELECT attname FROM pg_class, pg_attribute
+ WHERE attrelid=$relid
+ AND attnum=%d
+ AND attrelid=pg_class.oid
+END;
+ $r2 = $this->db->query( sprintf( $query, $rid ) );
+ if ( !$r2 ) {
+ return null;
+ }
+ $row2 = $this->db->fetchRow( $r2 );
+ if ( !$row2 ) {
+ return null;
+ }
+ $colnames[] = $row2[0];
+ }
+
+ return $colnames;
+ }
+
+ function fkeyDeltype( $fkey ) {
+ $q = <<<END
+SELECT confdeltype FROM pg_constraint, pg_namespace
+ WHERE connamespace=pg_namespace.oid
+ AND nspname=%s
+ AND conname=%s;
+END;
+ $r = $this->db->query(
+ sprintf(
+ $q,
+ $this->db->addQuotes( $this->db->getCoreSchema() ),
+ $this->db->addQuotes( $fkey )
+ )
+ );
+ $row = $this->db->fetchRow( $r );
+ if ( !$row ) {
+ return null;
+ }
+
+ return $row[0];
+ }
+
+ function ruleDef( $table, $rule ) {
+ $q = <<<END
+SELECT definition FROM pg_rules
+ WHERE schemaname = %s
+ AND tablename = %s
+ AND rulename = %s
+END;
+ $r = $this->db->query(
+ sprintf(
+ $q,
+ $this->db->addQuotes( $this->db->getCoreSchema() ),
+ $this->db->addQuotes( $table ),
+ $this->db->addQuotes( $rule )
+ )
+ );
+ $row = $this->db->fetchRow( $r );
+ if ( !$row ) {
+ return null;
+ }
+ $d = $row[0];
+
+ return $d;
+ }
+
+ protected function addSequence( $table, $pkey, $ns ) {
+ if ( !$this->db->sequenceExists( $ns ) ) {
+ $this->output( "Creating sequence $ns\n" );
+ $this->db->query( "CREATE SEQUENCE $ns" );
+ if ( $pkey !== false ) {
+ $this->setDefault( $table, $pkey, '"nextval"(\'"' . $ns . '"\'::"regclass")' );
+ }
+ }
+ }
+
+ protected function renameSequence( $old, $new ) {
+ if ( $this->db->sequenceExists( $new ) ) {
+ $this->output( "...sequence $new already exists.\n" );
+
+ return;
+ }
+ if ( $this->db->sequenceExists( $old ) ) {
+ $this->output( "Renaming sequence $old to $new\n" );
+ $this->db->query( "ALTER SEQUENCE $old RENAME TO $new" );
+ }
+ }
+
+ protected function renameTable( $old, $new, $patch = false ) {
+ if ( $this->db->tableExists( $old ) ) {
+ $this->output( "Renaming table $old to $new\n" );
+ $old = $this->db->realTableName( $old, "quoted" );
+ $new = $this->db->realTableName( $new, "quoted" );
+ $this->db->query( "ALTER TABLE $old RENAME TO $new" );
+ if ( $patch !== false ) {
+ $this->applyPatch( $patch );
+ }
+ }
+ }
+
+ protected function renameIndex(
+ $table, $old, $new, $skipBothIndexExistWarning = false, $a = false, $b = false
+ ) {
+ // First requirement: the table must exist
+ if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
+ $this->output( "...skipping: '$table' table doesn't exist yet.\n" );
+
+ return;
+ }
+
+ // Second requirement: the new index must be missing
+ if ( $this->db->indexExists( $table, $new, __METHOD__ ) ) {
+ $this->output( "...index $new already set on $table table.\n" );
+ if ( !$skipBothIndexExistWarning
+ && $this->db->indexExists( $table, $old, __METHOD__ )
+ ) {
+ $this->output( "...WARNING: $old still exists, despite it has been " .
+ "renamed into $new (which also exists).\n" .
+ " $old should be manually removed if not needed anymore.\n" );
+ }
+
+ return;
+ }
+
+ // Third requirement: the old index must exist
+ if ( !$this->db->indexExists( $table, $old, __METHOD__ ) ) {
+ $this->output( "...skipping: index $old doesn't exist.\n" );
+
+ return;
+ }
+
+ $this->db->query( "ALTER INDEX $old RENAME TO $new" );
+ }
+
+ protected function addPgField( $table, $field, $type ) {
+ $fi = $this->db->fieldInfo( $table, $field );
+ if ( !is_null( $fi ) ) {
+ $this->output( "...column '$table.$field' already exists\n" );
+
+ return;
+ } else {
+ $this->output( "Adding column '$table.$field'\n" );
+ $this->db->query( "ALTER TABLE $table ADD $field $type" );
+ }
+ }
+
+ protected function changeField( $table, $field, $newtype, $default ) {
+ $fi = $this->db->fieldInfo( $table, $field );
+ if ( is_null( $fi ) ) {
+ $this->output( "...ERROR: expected column $table.$field to exist\n" );
+ exit( 1 );
+ }
+
+ if ( $fi->type() === $newtype ) {
+ $this->output( "...column '$table.$field' is already of type '$newtype'\n" );
+ } else {
+ $this->output( "Changing column type of '$table.$field' from '{$fi->type()}' to '$newtype'\n" );
+ $sql = "ALTER TABLE $table ALTER $field TYPE $newtype";
+ if ( strlen( $default ) ) {
+ $res = [];
+ if ( preg_match( '/DEFAULT (.+)/', $default, $res ) ) {
+ $sqldef = "ALTER TABLE $table ALTER $field SET DEFAULT $res[1]";
+ $this->db->query( $sqldef );
+ $default = preg_replace( '/\s*DEFAULT .+/', '', $default );
+ }
+ $sql .= " USING $default";
+ }
+ $this->db->query( $sql );
+ }
+ }
+
+ protected function changeFieldPurgeTable( $table, $field, $newtype, $default ) {
+ # # For a cache table, empty it if the field needs to be changed, because the old contents
+ # # may be corrupted. If the column is already the desired type, refrain from purging.
+ $fi = $this->db->fieldInfo( $table, $field );
+ if ( is_null( $fi ) ) {
+ $this->output( "...ERROR: expected column $table.$field to exist\n" );
+ exit( 1 );
+ }
+
+ if ( $fi->type() === $newtype ) {
+ $this->output( "...column '$table.$field' is already of type '$newtype'\n" );
+ } else {
+ $this->output( "Purging data from cache table '$table'\n" );
+ $this->db->query( "DELETE from $table" );
+ $this->output( "Changing column type of '$table.$field' from '{$fi->type()}' to '$newtype'\n" );
+ $sql = "ALTER TABLE $table ALTER $field TYPE $newtype";
+ if ( strlen( $default ) ) {
+ $res = [];
+ if ( preg_match( '/DEFAULT (.+)/', $default, $res ) ) {
+ $sqldef = "ALTER TABLE $table ALTER $field SET DEFAULT $res[1]";
+ $this->db->query( $sqldef );
+ $default = preg_replace( '/\s*DEFAULT .+/', '', $default );
+ }
+ $sql .= " USING $default";
+ }
+ $this->db->query( $sql );
+ }
+ }
+
+ protected function setDefault( $table, $field, $default ) {
+ $info = $this->db->fieldInfo( $table, $field );
+ if ( $info->defaultValue() !== $default ) {
+ $this->output( "Changing '$table.$field' default value\n" );
+ $this->db->query( "ALTER TABLE $table ALTER $field SET DEFAULT "
+ . $this->db->addQuotes( $default ) );
+ }
+ }
+
+ protected function changeNullableField( $table, $field, $null, $update = false ) {
+ $fi = $this->db->fieldInfo( $table, $field );
+ if ( is_null( $fi ) ) {
+ $this->output( "...ERROR: expected column $table.$field to exist\n" );
+ exit( 1 );
+ }
+ if ( $fi->isNullable() ) {
+ # # It's NULL - does it need to be NOT NULL?
+ if ( 'NOT NULL' === $null ) {
+ $this->output( "Changing '$table.$field' to not allow NULLs\n" );
+ if ( $update ) {
+ $this->db->query( "UPDATE $table SET $field = DEFAULT WHERE $field IS NULL" );
+ }
+ $this->db->query( "ALTER TABLE $table ALTER $field SET NOT NULL" );
+ } else {
+ $this->output( "...column '$table.$field' is already set as NULL\n" );
+ }
+ } else {
+ # # It's NOT NULL - does it need to be NULL?
+ if ( 'NULL' === $null ) {
+ $this->output( "Changing '$table.$field' to allow NULLs\n" );
+ $this->db->query( "ALTER TABLE $table ALTER $field DROP NOT NULL" );
+ } else {
+ $this->output( "...column '$table.$field' is already set as NOT NULL\n" );
+ }
+ }
+ }
+
+ public function addPgIndex( $table, $index, $type ) {
+ if ( $this->db->indexExists( $table, $index ) ) {
+ $this->output( "...index '$index' on table '$table' already exists\n" );
+ } else {
+ $this->output( "Creating index '$index' on table '$table' $type\n" );
+ $this->db->query( "CREATE INDEX $index ON $table $type" );
+ }
+ }
+
+ public function addPgExtIndex( $table, $index, $type ) {
+ if ( $this->db->indexExists( $table, $index ) ) {
+ $this->output( "...index '$index' on table '$table' already exists\n" );
+ } else {
+ if ( preg_match( '/^\(/', $type ) ) {
+ $this->output( "Creating index '$index' on table '$table'\n" );
+ $this->db->query( "CREATE INDEX $index ON $table $type" );
+ } else {
+ $this->applyPatch( $type, true, "Creating index '$index' on table '$table'" );
+ }
+ }
+ }
+
+ /**
+ * Add a value to an existing PostgreSQL enum type
+ * @since 1.31
+ * @param string $type Type name. Must be in the core schema.
+ * @param string $value Value to add.
+ */
+ public function addPgEnumValue( $type, $value ) {
+ $row = $this->db->selectRow(
+ [
+ 't' => 'pg_catalog.pg_type',
+ 'n' => 'pg_catalog.pg_namespace',
+ 'e' => 'pg_catalog.pg_enum',
+ ],
+ [ 't.typname', 't.typtype', 'e.enumlabel' ],
+ [
+ 't.typname' => $type,
+ 'n.nspname' => $this->db->getCoreSchema(),
+ ],
+ __METHOD__,
+ [],
+ [
+ 'n' => [ 'JOIN', 't.typnamespace = n.oid' ],
+ 'e' => [ 'LEFT JOIN', [ 'e.enumtypid = t.oid', 'e.enumlabel' => $value ] ],
+ ]
+ );
+
+ if ( !$row ) {
+ $this->output( "...Type $type does not exist, skipping modify enum.\n" );
+ } elseif ( $row->typtype !== 'e' ) {
+ $this->output( "...Type $type does not seem to be an enum, skipping modify enum.\n" );
+ } elseif ( $row->enumlabel === $value ) {
+ $this->output( "...Enum type $type already contains value '$value'.\n" );
+ } else {
+ $this->output( "...Adding value '$value' to enum type $type.\n" );
+ $etype = $this->db->addIdentifierQuotes( $type );
+ $evalue = $this->db->addQuotes( $value );
+ $this->db->query( "ALTER TYPE $etype ADD VALUE $evalue" );
+ }
+ }
+
+ protected function dropFkey( $table, $field ) {
+ $fi = $this->db->fieldInfo( $table, $field );
+ if ( is_null( $fi ) ) {
+ $this->output( "WARNING! Column '$table.$field' does not exist but it should! " .
+ "Please report this.\n" );
+ return;
+ }
+ $conname = $fi->conname();
+ if ( $fi->conname() ) {
+ $this->output( "Dropping foreign key constraint on '$table.$field'\n" );
+ $conclause = "CONSTRAINT \"$conname\"";
+ $command = "ALTER TABLE $table DROP CONSTRAINT $conname";
+ $this->db->query( $command );
+ } else {
+ $this->output( "...foreign key constraint on '$table.$field' already does not exist\n" );
+ };
+ }
+
+ protected function changeFkeyDeferrable( $table, $field, $clause ) {
+ $fi = $this->db->fieldInfo( $table, $field );
+ if ( is_null( $fi ) ) {
+ $this->output( "WARNING! Column '$table.$field' does not exist but it should! " .
+ "Please report this.\n" );
+
+ return;
+ }
+ if ( $fi->is_deferred() && $fi->is_deferrable() ) {
+ return;
+ }
+ $this->output( "Altering column '$table.$field' to be DEFERRABLE INITIALLY DEFERRED\n" );
+ $conname = $fi->conname();
+ if ( $fi->conname() ) {
+ $conclause = "CONSTRAINT \"$conname\"";
+ $command = "ALTER TABLE $table DROP CONSTRAINT $conname";
+ $this->db->query( $command );
+ } else {
+ $this->output( "Column '$table.$field' does not have a foreign key " .
+ "constraint, will be added\n" );
+ $conclause = "";
+ }
+ $command =
+ "ALTER TABLE $table ADD $conclause " .
+ "FOREIGN KEY ($field) REFERENCES $clause DEFERRABLE INITIALLY DEFERRED";
+ $this->db->query( $command );
+ }
+
+ protected function convertArchive2() {
+ if ( $this->db->tableExists( "archive2" ) ) {
+ if ( $this->db->ruleExists( 'archive', 'archive_insert' ) ) {
+ $this->output( "Dropping rule 'archive_insert'\n" );
+ $this->db->query( 'DROP RULE archive_insert ON archive' );
+ }
+ if ( $this->db->ruleExists( 'archive', 'archive_delete' ) ) {
+ $this->output( "Dropping rule 'archive_delete'\n" );
+ $this->db->query( 'DROP RULE archive_delete ON archive' );
+ }
+ $this->applyPatch(
+ 'patch-remove-archive2.sql',
+ false,
+ "Converting 'archive2' back to normal archive table"
+ );
+ } else {
+ $this->output( "...obsolete table 'archive2' does not exist\n" );
+ }
+ }
+
+ protected function checkOiDeleted() {
+ if ( $this->db->fieldInfo( 'oldimage', 'oi_deleted' )->type() !== 'smallint' ) {
+ $this->output( "Changing 'oldimage.oi_deleted' to type 'smallint'\n" );
+ $this->db->query( "ALTER TABLE oldimage ALTER oi_deleted DROP DEFAULT" );
+ $this->db->query(
+ "ALTER TABLE oldimage ALTER oi_deleted TYPE SMALLINT USING (oi_deleted::smallint)" );
+ $this->db->query( "ALTER TABLE oldimage ALTER oi_deleted SET DEFAULT 0" );
+ } else {
+ $this->output( "...column 'oldimage.oi_deleted' is already of type 'smallint'\n" );
+ }
+ }
+
+ protected function checkOiNameConstraint() {
+ if ( $this->db->hasConstraint( "oldimage_oi_name_fkey_cascaded" ) ) {
+ $this->output( "...table 'oldimage' has correct cascading delete/update " .
+ "foreign key to image\n" );
+ } else {
+ if ( $this->db->hasConstraint( "oldimage_oi_name_fkey" ) ) {
+ $this->db->query(
+ "ALTER TABLE oldimage DROP CONSTRAINT oldimage_oi_name_fkey" );
+ }
+ if ( $this->db->hasConstraint( "oldimage_oi_name_fkey_cascade" ) ) {
+ $this->db->query(
+ "ALTER TABLE oldimage DROP CONSTRAINT oldimage_oi_name_fkey_cascade" );
+ }
+ $this->output( "Making foreign key on table 'oldimage' (to image) a cascade delete/update\n" );
+ $this->db->query(
+ "ALTER TABLE oldimage ADD CONSTRAINT oldimage_oi_name_fkey_cascaded " .
+ "FOREIGN KEY (oi_name) REFERENCES image(img_name) " .
+ "ON DELETE CASCADE ON UPDATE CASCADE" );
+ }
+ }
+
+ protected function checkPageDeletedTrigger() {
+ if ( !$this->db->triggerExists( 'page', 'page_deleted' ) ) {
+ $this->applyPatch(
+ 'patch-page_deleted.sql',
+ false,
+ "Adding function and trigger 'page_deleted' to table 'page'"
+ );
+ } else {
+ $this->output( "...table 'page' has 'page_deleted' trigger\n" );
+ }
+ }
+
+ protected function dropIndex( $table, $index, $patch = '', $fullpath = false ) {
+ if ( $this->db->indexExists( $table, $index ) ) {
+ $this->output( "Dropping obsolete index '$index'\n" );
+ $this->db->query( "DROP INDEX \"" . $index . "\"" );
+ }
+ }
+
+ protected function checkIndex( $index, $should_be, $good_def ) {
+ $pu = $this->db->indexAttributes( $index );
+ if ( !empty( $pu ) && $pu != $should_be ) {
+ $this->output( "Dropping obsolete version of index '$index'\n" );
+ $this->db->query( "DROP INDEX \"" . $index . "\"" );
+ $pu = [];
+ } else {
+ $this->output( "...no need to drop index '$index'\n" );
+ }
+
+ if ( empty( $pu ) ) {
+ $this->output( "Creating index '$index'\n" );
+ $this->db->query( $good_def );
+ } else {
+ $this->output( "...index '$index' exists\n" );
+ }
+ }
+
+ protected function checkRevUserFkey() {
+ if ( $this->fkeyDeltype( 'revision_rev_user_fkey' ) == 'r' ) {
+ $this->output( "...constraint 'revision_rev_user_fkey' is ON DELETE RESTRICT\n" );
+ } else {
+ $this->applyPatch(
+ 'patch-revision_rev_user_fkey.sql',
+ false,
+ "Changing constraint 'revision_rev_user_fkey' to ON DELETE RESTRICT"
+ );
+ }
+ }
+
+ protected function checkIwlPrefix() {
+ if ( $this->db->indexExists( 'iwlinks', 'iwl_prefix' ) ) {
+ $this->applyPatch(
+ 'patch-rename-iwl_prefix.sql',
+ false,
+ "Replacing index 'iwl_prefix' with 'iwl_prefix_title_from'"
+ );
+ }
+ }
+
+ protected function addInterwikiType() {
+ $this->applyPatch( 'patch-add_interwiki.sql', false, "Refreshing add_interwiki()" );
+ }
+
+ protected function tsearchFixes() {
+ # Tweak the page_title tsearch2 trigger to filter out slashes
+ # This is create or replace, so harmless to call if not needed
+ $this->applyPatch( 'patch-ts2pagetitle.sql', false, "Refreshing ts2_page_title()" );
+
+ # If the server is 8.3 or higher, rewrite the tsearch2 triggers
+ # in case they have the old 'default' versions
+ # Gather version numbers in case we need them
+ if ( $this->db->getServerVersion() >= 8.3 ) {
+ $this->applyPatch( 'patch-tsearch2funcs.sql', false, "Rewriting tsearch2 triggers" );
+ }
+ }
+
+ protected function rebuildTextSearch() {
+ if ( $this->updateRowExists( 'patch-textsearch_bug66650.sql' ) ) {
+ $this->output( "...T68650 already fixed or not applicable.\n" );
+ return;
+ };
+ $this->applyPatch( 'patch-textsearch_bug66650.sql', false,
+ 'Rebuilding text search for T68650' );
+ }
+}
diff --git a/www/wiki/includes/installer/SqliteInstaller.php b/www/wiki/includes/installer/SqliteInstaller.php
new file mode 100644
index 00000000..d5909f4e
--- /dev/null
+++ b/www/wiki/includes/installer/SqliteInstaller.php
@@ -0,0 +1,335 @@
+<?php
+/**
+ * Sqlite-specific installer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DatabaseSqlite;
+use Wikimedia\Rdbms\DBConnectionError;
+
+/**
+ * Class for setting up the MediaWiki database using SQLLite.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class SqliteInstaller extends DatabaseInstaller {
+
+ public static $minimumVersion = '3.3.7';
+ protected static $notMiniumumVerisonMessage = 'config-outdated-sqlite';
+
+ /**
+ * @var DatabaseSqlite
+ */
+ public $db;
+
+ protected $globalNames = [
+ 'wgDBname',
+ 'wgSQLiteDataDir',
+ ];
+
+ public function getName() {
+ return 'sqlite';
+ }
+
+ public function isCompiled() {
+ return self::checkExtension( 'pdo_sqlite' );
+ }
+
+ /**
+ *
+ * @return Status
+ */
+ public function checkPrerequisites() {
+ // Bail out if SQLite is too old
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $result = static::meetsMinimumRequirement( $db->getServerVersion() );
+ // Check for FTS3 full-text search module
+ if ( DatabaseSqlite::getFulltextSearchModule() != 'FTS3' ) {
+ $result->warning( 'config-no-fts3' );
+ }
+
+ return $result;
+ }
+
+ public function getGlobalDefaults() {
+ $defaults = parent::getGlobalDefaults();
+ if ( isset( $_SERVER['DOCUMENT_ROOT'] ) ) {
+ $path = str_replace(
+ [ '/', '\\' ],
+ DIRECTORY_SEPARATOR,
+ dirname( $_SERVER['DOCUMENT_ROOT'] ) . '/data'
+ );
+
+ $defaults['wgSQLiteDataDir'] = $path;
+ }
+ return $defaults;
+ }
+
+ public function getConnectForm() {
+ return $this->getTextBox(
+ 'wgSQLiteDataDir',
+ 'config-sqlite-dir', [],
+ $this->parent->getHelpBox( 'config-sqlite-dir-help' )
+ ) .
+ $this->getTextBox(
+ 'wgDBname',
+ 'config-db-name',
+ [],
+ $this->parent->getHelpBox( 'config-sqlite-name-help' )
+ );
+ }
+
+ /**
+ * Safe wrapper for PHP's realpath() that fails gracefully if it's unable to canonicalize the path.
+ *
+ * @param string $path
+ *
+ * @return string
+ */
+ private static function realpath( $path ) {
+ $result = realpath( $path );
+ if ( !$result ) {
+ return $path;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return Status
+ */
+ public function submitConnectForm() {
+ $this->setVarsFromRequest( [ 'wgSQLiteDataDir', 'wgDBname' ] );
+
+ # Try realpath() if the directory already exists
+ $dir = self::realpath( $this->getVar( 'wgSQLiteDataDir' ) );
+ $result = self::dataDirOKmaybeCreate( $dir, true /* create? */ );
+ if ( $result->isOK() ) {
+ # Try expanding again in case we've just created it
+ $dir = self::realpath( $dir );
+ $this->setVar( 'wgSQLiteDataDir', $dir );
+ }
+ # Table prefix is not used on SQLite, keep it empty
+ $this->setVar( 'wgDBprefix', '' );
+
+ return $result;
+ }
+
+ /**
+ * @param string $dir
+ * @param bool $create
+ * @return Status
+ */
+ private static function dataDirOKmaybeCreate( $dir, $create = false ) {
+ if ( !is_dir( $dir ) ) {
+ if ( !is_writable( dirname( $dir ) ) ) {
+ $webserverGroup = Installer::maybeGetWebserverPrimaryGroup();
+ if ( $webserverGroup !== null ) {
+ return Status::newFatal(
+ 'config-sqlite-parent-unwritable-group',
+ $dir, dirname( $dir ), basename( $dir ),
+ $webserverGroup
+ );
+ } else {
+ return Status::newFatal(
+ 'config-sqlite-parent-unwritable-nogroup',
+ $dir, dirname( $dir ), basename( $dir )
+ );
+ }
+ }
+
+ # Called early on in the installer, later we just want to sanity check
+ # if it's still writable
+ if ( $create ) {
+ MediaWiki\suppressWarnings();
+ $ok = wfMkdirParents( $dir, 0700, __METHOD__ );
+ MediaWiki\restoreWarnings();
+ if ( !$ok ) {
+ return Status::newFatal( 'config-sqlite-mkdir-error', $dir );
+ }
+ # Put a .htaccess file in in case the user didn't take our advice
+ file_put_contents( "$dir/.htaccess", "Deny from all\n" );
+ }
+ }
+ if ( !is_writable( $dir ) ) {
+ return Status::newFatal( 'config-sqlite-dir-unwritable', $dir );
+ }
+
+ # We haven't blown up yet, fall through
+ return Status::newGood();
+ }
+
+ /**
+ * @return Status
+ */
+ public function openConnection() {
+ $status = Status::newGood();
+ $dir = $this->getVar( 'wgSQLiteDataDir' );
+ $dbName = $this->getVar( 'wgDBname' );
+ try {
+ # @todo FIXME: Need more sensible constructor parameters, e.g. single associative array
+ $db = Database::factory( 'sqlite', [ 'dbname' => $dbName, 'dbDirectory' => $dir ] );
+ $status->value = $db;
+ } catch ( DBConnectionError $e ) {
+ $status->fatal( 'config-sqlite-connection-error', $e->getMessage() );
+ }
+
+ return $status;
+ }
+
+ /**
+ * @return bool
+ */
+ public function needsUpgrade() {
+ $dir = $this->getVar( 'wgSQLiteDataDir' );
+ $dbName = $this->getVar( 'wgDBname' );
+ // Don't create the data file yet
+ if ( !file_exists( DatabaseSqlite::generateFileName( $dir, $dbName ) ) ) {
+ return false;
+ }
+
+ // If the data file exists, look inside it
+ return parent::needsUpgrade();
+ }
+
+ /**
+ * @return Status
+ */
+ public function setupDatabase() {
+ $dir = $this->getVar( 'wgSQLiteDataDir' );
+
+ # Sanity check. We checked this before but maybe someone deleted the
+ # data dir between then and now
+ $dir_status = self::dataDirOKmaybeCreate( $dir, false /* create? */ );
+ if ( !$dir_status->isOK() ) {
+ return $dir_status;
+ }
+
+ $db = $this->getVar( 'wgDBname' );
+
+ # Make the main and cache stub DB files
+ $status = Status::newGood();
+ $status->merge( $this->makeStubDBFile( $dir, $db ) );
+ $status->merge( $this->makeStubDBFile( $dir, "wikicache" ) );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ # Nuke the unused settings for clarity
+ $this->setVar( 'wgDBserver', '' );
+ $this->setVar( 'wgDBuser', '' );
+ $this->setVar( 'wgDBpassword', '' );
+ $this->setupSchemaVars();
+
+ # Create the global cache DB
+ try {
+ $conn = Database::factory( 'sqlite', [ 'dbname' => 'wikicache', 'dbDirectory' => $dir ] );
+ # @todo: don't duplicate objectcache definition, though it's very simple
+ $sql =
+<<<EOT
+ CREATE TABLE IF NOT EXISTS objectcache (
+ keyname BLOB NOT NULL default '' PRIMARY KEY,
+ value BLOB,
+ exptime TEXT
+ )
+EOT;
+ $conn->query( $sql );
+ $conn->query( "CREATE INDEX IF NOT EXISTS exptime ON objectcache (exptime)" );
+ $conn->query( "PRAGMA journal_mode=WAL" ); // this is permanent
+ $conn->close();
+ } catch ( DBConnectionError $e ) {
+ return Status::newFatal( 'config-sqlite-connection-error', $e->getMessage() );
+ }
+
+ # Open the main DB
+ return $this->getConnection();
+ }
+
+ /**
+ * @param string $dir
+ * @param string $db
+ * @return Status
+ */
+ protected function makeStubDBFile( $dir, $db ) {
+ $file = DatabaseSqlite::generateFileName( $dir, $db );
+ if ( file_exists( $file ) ) {
+ if ( !is_writable( $file ) ) {
+ return Status::newFatal( 'config-sqlite-readonly', $file );
+ }
+ } else {
+ if ( file_put_contents( $file, '' ) === false ) {
+ return Status::newFatal( 'config-sqlite-cant-create-db', $file );
+ }
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * @return Status
+ */
+ public function createTables() {
+ $status = parent::createTables();
+
+ return $this->setupSearchIndex( $status );
+ }
+
+ /**
+ * @param Status &$status
+ * @return Status
+ */
+ public function setupSearchIndex( &$status ) {
+ global $IP;
+
+ $module = DatabaseSqlite::getFulltextSearchModule();
+ $fts3tTable = $this->db->checkForEnabledSearch();
+ if ( $fts3tTable && !$module ) {
+ $status->warning( 'config-sqlite-fts3-downgrade' );
+ $this->db->sourceFile( "$IP/maintenance/sqlite/archives/searchindex-no-fts.sql" );
+ } elseif ( !$fts3tTable && $module == 'FTS3' ) {
+ $this->db->sourceFile( "$IP/maintenance/sqlite/archives/searchindex-fts3.sql" );
+ }
+
+ return $status;
+ }
+
+ /**
+ * @return string
+ */
+ public function getLocalSettings() {
+ $dir = LocalSettingsGenerator::escapePhpString( $this->getVar( 'wgSQLiteDataDir' ) );
+
+ return "# SQLite-specific settings
+\$wgSQLiteDataDir = \"{$dir}\";
+\$wgObjectCaches[CACHE_DB] = [
+ 'class' => 'SqlBagOStuff',
+ 'loggroup' => 'SQLBagOStuff',
+ 'server' => [
+ 'type' => 'sqlite',
+ 'dbname' => 'wikicache',
+ 'tablePrefix' => '',
+ 'dbDirectory' => \$wgSQLiteDataDir,
+ 'flags' => 0
+ ]
+];";
+ }
+}
diff --git a/www/wiki/includes/installer/SqliteUpdater.php b/www/wiki/includes/installer/SqliteUpdater.php
new file mode 100644
index 00000000..9f710014
--- /dev/null
+++ b/www/wiki/includes/installer/SqliteUpdater.php
@@ -0,0 +1,227 @@
+<?php
+/**
+ * Sqlite-specific updater.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+use Wikimedia\Rdbms\DatabaseSqlite;
+
+/**
+ * Class for handling updates to Sqlite databases.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class SqliteUpdater extends DatabaseUpdater {
+
+ protected function getCoreUpdateList() {
+ return [
+ [ 'disableContentHandlerUseDB' ],
+
+ // 1.14
+ [ 'addField', 'site_stats', 'ss_active_users', 'patch-ss_active_users.sql' ],
+ [ 'doActiveUsersInit' ],
+ [ 'addField', 'ipblocks', 'ipb_allow_usertalk', 'patch-ipb_allow_usertalk.sql' ],
+ [ 'sqliteInitialIndexes' ],
+
+ // 1.15
+ [ 'addTable', 'change_tag', 'patch-change_tag.sql' ],
+ [ 'addTable', 'tag_summary', 'patch-tag_summary.sql' ],
+ [ 'addTable', 'valid_tag', 'patch-valid_tag.sql' ],
+
+ // 1.16
+ [ 'addTable', 'user_properties', 'patch-user_properties.sql' ],
+ [ 'addTable', 'log_search', 'patch-log_search.sql' ],
+ [ 'addField', 'logging', 'log_user_text', 'patch-log_user_text.sql' ],
+ # listed separately from the previous update because 1.16 was released without this update
+ [ 'doLogUsertextPopulation' ],
+ [ 'doLogSearchPopulation' ],
+ [ 'addTable', 'l10n_cache', 'patch-l10n_cache.sql' ],
+ [ 'addIndex', 'change_tag', 'change_tag_rc_tag', 'patch-change_tag-indexes.sql' ],
+ [ 'addField', 'redirect', 'rd_interwiki', 'patch-rd_interwiki.sql' ],
+ [ 'doUpdateTranscacheField' ],
+ [ 'sqliteSetupSearchindex' ],
+
+ // 1.17
+ [ 'addTable', 'iwlinks', 'patch-iwlinks.sql' ],
+ [ 'addIndex', 'iwlinks', 'iwl_prefix_title_from', 'patch-rename-iwl_prefix.sql' ],
+ [ 'addField', 'updatelog', 'ul_value', 'patch-ul_value.sql' ],
+ [ 'addField', 'interwiki', 'iw_api', 'patch-iw_api_and_wikiid.sql' ],
+ [ 'dropIndex', 'iwlinks', 'iwl_prefix', 'patch-kill-iwl_prefix.sql' ],
+ [ 'addField', 'categorylinks', 'cl_collation', 'patch-categorylinks-better-collation.sql' ],
+ [ 'addTable', 'module_deps', 'patch-module_deps.sql' ],
+ [ 'dropIndex', 'archive', 'ar_page_revid', 'patch-archive_kill_ar_page_revid.sql' ],
+ [ 'addIndex', 'archive', 'ar_revid', 'patch-archive_ar_revid.sql' ],
+
+ // 1.18
+ [ 'addIndex', 'user', 'user_email', 'patch-user_email_index.sql' ],
+ [ 'addTable', 'uploadstash', 'patch-uploadstash.sql' ],
+ [ 'addTable', 'user_former_groups', 'patch-user_former_groups.sql' ],
+
+ // 1.19
+ [ 'addIndex', 'logging', 'type_action', 'patch-logging-type-action-index.sql' ],
+ [ 'doMigrateUserOptions' ],
+ [ 'dropField', 'user', 'user_options', 'patch-drop-user_options.sql' ],
+ [ 'addField', 'revision', 'rev_sha1', 'patch-rev_sha1.sql' ],
+ [ 'addField', 'archive', 'ar_sha1', 'patch-ar_sha1.sql' ],
+ [ 'addIndex', 'page', 'page_redirect_namespace_len',
+ 'patch-page_redirect_namespace_len.sql' ],
+ [ 'addField', 'uploadstash', 'us_chunk_inx', 'patch-uploadstash_chunk.sql' ],
+ [ 'addfield', 'job', 'job_timestamp', 'patch-jobs-add-timestamp.sql' ],
+
+ // 1.20
+ [ 'addIndex', 'revision', 'page_user_timestamp', 'patch-revision-user-page-index.sql' ],
+ [ 'addField', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id.sql' ],
+ [ 'addIndex', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id-index.sql' ],
+ [ 'dropField', 'category', 'cat_hidden', 'patch-cat_hidden.sql' ],
+
+ // 1.21
+ [ 'addField', 'revision', 'rev_content_format', 'patch-revision-rev_content_format.sql' ],
+ [ 'addField', 'revision', 'rev_content_model', 'patch-revision-rev_content_model.sql' ],
+ [ 'addField', 'archive', 'ar_content_format', 'patch-archive-ar_content_format.sql' ],
+ [ 'addField', 'archive', 'ar_content_model', 'patch-archive-ar_content_model.sql' ],
+ [ 'addField', 'page', 'page_content_model', 'patch-page-page_content_model.sql' ],
+ [ 'enableContentHandlerUseDB' ],
+
+ [ 'dropField', 'site_stats', 'ss_admins', 'patch-drop-ss_admins.sql' ],
+ [ 'dropField', 'recentchanges', 'rc_moved_to_title', 'patch-rc_moved.sql' ],
+ [ 'addTable', 'sites', 'patch-sites.sql' ],
+ [ 'addField', 'filearchive', 'fa_sha1', 'patch-fa_sha1.sql' ],
+ [ 'addField', 'job', 'job_token', 'patch-job_token.sql' ],
+ [ 'addField', 'job', 'job_attempts', 'patch-job_attempts.sql' ],
+ [ 'doEnableProfiling' ],
+ [ 'addField', 'uploadstash', 'us_props', 'patch-uploadstash-us_props.sql' ],
+ [ 'modifyField', 'user_groups', 'ug_group', 'patch-ug_group-length-increase-255.sql' ],
+ [ 'modifyField', 'user_former_groups', 'ufg_group',
+ 'patch-ufg_group-length-increase-255.sql' ],
+ [ 'addIndex', 'page_props', 'pp_propname_page',
+ 'patch-page_props-propname-page-index.sql' ],
+ [ 'addIndex', 'image', 'img_media_mime', 'patch-img_media_mime-index.sql' ],
+
+ // 1.22
+ [ 'addIndex', 'iwlinks', 'iwl_prefix_from_title', 'patch-iwlinks-from-title-index.sql' ],
+ [ 'addField', 'archive', 'ar_id', 'patch-archive-ar_id.sql' ],
+ [ 'addField', 'externallinks', 'el_id', 'patch-externallinks-el_id.sql' ],
+
+ // 1.23
+ [ 'addField', 'recentchanges', 'rc_source', 'patch-rc_source.sql' ],
+ [ 'addIndex', 'logging', 'log_user_text_type_time',
+ 'patch-logging_user_text_type_time_index.sql' ],
+ [ 'addIndex', 'logging', 'log_user_text_time', 'patch-logging_user_text_time_index.sql' ],
+ [ 'addField', 'page', 'page_links_updated', 'patch-page_links_updated.sql' ],
+ [ 'addField', 'user', 'user_password_expires', 'patch-user_password_expire.sql' ],
+
+ // 1.24
+ [ 'addField', 'page_props', 'pp_sortkey', 'patch-pp_sortkey.sql' ],
+ [ 'dropField', 'recentchanges', 'rc_cur_time', 'patch-drop-rc_cur_time.sql' ],
+ [ 'addIndex', 'watchlist', 'wl_user_notificationtimestamp',
+ 'patch-watchlist-user-notificationtimestamp-index.sql' ],
+ [ 'addField', 'page', 'page_lang', 'patch-page-page_lang.sql' ],
+ [ 'addField', 'pagelinks', 'pl_from_namespace', 'patch-pl_from_namespace.sql' ],
+ [ 'addField', 'templatelinks', 'tl_from_namespace', 'patch-tl_from_namespace.sql' ],
+ [ 'addField', 'imagelinks', 'il_from_namespace', 'patch-il_from_namespace.sql' ],
+
+ // 1.25
+ [ 'dropTable', 'hitcounter' ],
+ [ 'dropField', 'site_stats', 'ss_total_views', 'patch-drop-ss_total_views.sql' ],
+ [ 'dropField', 'page', 'page_counter', 'patch-drop-page_counter.sql' ],
+ [ 'modifyField', 'filearchive', 'fa_deleted_reason', 'patch-editsummary-length.sql' ],
+
+ // 1.27
+ [ 'dropTable', 'msg_resource_links' ],
+ [ 'dropTable', 'msg_resource' ],
+ [ 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ],
+ [ 'addField', 'watchlist', 'wl_id', 'patch-watchlist-wl_id.sql' ],
+ [ 'dropIndex', 'categorylinks', 'cl_collation', 'patch-kill-cl_collation_index.sql' ],
+ [ 'addIndex', 'categorylinks', 'cl_collation_ext',
+ 'patch-add-cl_collation_ext_index.sql' ],
+ [ 'doCollationUpdate' ],
+
+ // 1.28
+ [ 'addIndex', 'recentchanges', 'rc_name_type_patrolled_timestamp',
+ 'patch-add-rc_name_type_patrolled_timestamp_index.sql' ],
+ [ 'addField', 'change_tag', 'ct_id', 'patch-change_tag-ct_id.sql' ],
+ [ 'addField', 'tag_summary', 'ts_id', 'patch-tag_summary-ts_id.sql' ],
+
+ // 1.29
+ [ 'addField', 'externallinks', 'el_index_60', 'patch-externallinks-el_index_60.sql' ],
+ [ 'addField', 'user_groups', 'ug_expiry', 'patch-user_groups-ug_expiry.sql' ],
+ [ 'addIndex', 'image', 'img_user_timestamp', 'patch-image-user-index-2.sql' ],
+
+ // 1.30
+ [ 'modifyField', 'image', 'img_media_type', 'patch-add-3d.sql' ],
+ [ 'addTable', 'ip_changes', 'patch-ip_changes.sql' ],
+ [ 'renameIndex', 'categorylinks', 'cl_from', 'PRIMARY', false,
+ 'patch-categorylinks-fix-pk.sql' ],
+ [ 'renameIndex', 'templatelinks', 'tl_from', 'PRIMARY', false,
+ 'patch-templatelinks-fix-pk.sql' ],
+ [ 'renameIndex', 'pagelinks', 'pl_from', 'PRIMARY', false, 'patch-pagelinks-fix-pk.sql' ],
+ [ 'renameIndex', 'text', 'old_id', 'PRIMARY', false, 'patch-text-fix-pk.sql' ],
+ [ 'renameIndex', 'imagelinks', 'il_from', 'PRIMARY', false, 'patch-imagelinks-fix-pk.sql' ],
+ [ 'renameIndex', 'iwlinks', 'iwl_from', 'PRIMARY', false, 'patch-iwlinks-fix-pk.sql' ],
+ [ 'renameIndex', 'langlinks', 'll_from', 'PRIMARY', false, 'patch-langlinks-fix-pk.sql' ],
+ [ 'renameIndex', 'log_search', 'ls_field_val', 'PRIMARY', false, 'patch-log_search-fix-pk.sql' ],
+ [ 'renameIndex', 'module_deps', 'md_module_skin', 'PRIMARY', false,
+ 'patch-module_deps-fix-pk.sql' ],
+ [ 'renameIndex', 'objectcache', 'keyname', 'PRIMARY', false, 'patch-objectcache-fix-pk.sql' ],
+ [ 'renameIndex', 'querycache_info', 'qci_type', 'PRIMARY', false,
+ 'patch-querycache_info-fix-pk.sql' ],
+ [ 'renameIndex', 'site_stats', 'ss_row_id', 'PRIMARY', false, 'patch-site_stats-fix-pk.sql' ],
+ [ 'renameIndex', 'transcache', 'tc_url_idx', 'PRIMARY', false, 'patch-transcache-fix-pk.sql' ],
+ [ 'renameIndex', 'user_former_groups', 'ufg_user_group', 'PRIMARY', false,
+ 'patch-user_former_groups-fix-pk.sql' ],
+ [ 'renameIndex', 'user_properties', 'user_properties_user_property', 'PRIMARY', false,
+ 'patch-user_properties-fix-pk.sql' ],
+ [ 'addTable', 'comment', 'patch-comment-table.sql' ],
+ [ 'migrateComments' ],
+ [ 'renameIndex', 'l10n_cache', 'lc_lang_key', 'PRIMARY', false,
+ 'patch-l10n_cache-primary-key.sql' ],
+ ];
+ }
+
+ protected function sqliteInitialIndexes() {
+ // initial-indexes.sql fails if the indexes are already present,
+ // so we perform a quick check if our database is newer.
+ if ( $this->updateRowExists( 'initial_indexes' ) ||
+ $this->db->indexExists( 'user', 'user_name', __METHOD__ )
+ ) {
+ $this->output( "...have initial indexes\n" );
+
+ return;
+ }
+ $this->applyPatch( 'initial-indexes.sql', false, "Adding initial indexes" );
+ }
+
+ protected function sqliteSetupSearchindex() {
+ $module = DatabaseSqlite::getFulltextSearchModule();
+ $fts3tTable = $this->updateRowExists( 'fts3' );
+ if ( $fts3tTable && !$module ) {
+ $this->applyPatch(
+ 'searchindex-no-fts.sql',
+ false,
+ 'PHP is missing FTS3 support, downgrading tables'
+ );
+ } elseif ( !$fts3tTable && $module == 'FTS3' ) {
+ $this->applyPatch( 'searchindex-fts3.sql', false, "Adding FTS3 search capabilities" );
+ } else {
+ $this->output( "...fulltext search table appears to be in order.\n" );
+ }
+ }
+}
diff --git a/www/wiki/includes/installer/WebInstaller.php b/www/wiki/includes/installer/WebInstaller.php
new file mode 100644
index 00000000..e0e54c84
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstaller.php
@@ -0,0 +1,1241 @@
+<?php
+/**
+ * Core installer web interface.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+/**
+ * Class for the core installer web interface.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class WebInstaller extends Installer {
+
+ /**
+ * @var WebInstallerOutput
+ */
+ public $output;
+
+ /**
+ * WebRequest object.
+ *
+ * @var WebRequest
+ */
+ public $request;
+
+ /**
+ * Cached session array.
+ *
+ * @var array[]
+ */
+ protected $session;
+
+ /**
+ * Captured PHP error text. Temporary.
+ *
+ * @var string[]
+ */
+ protected $phpErrors;
+
+ /**
+ * The main sequence of page names. These will be displayed in turn.
+ *
+ * To add a new installer page:
+ * * Add it to this WebInstaller::$pageSequence property
+ * * Add a "config-page-<name>" message
+ * * Add a "WebInstaller<name>" class
+ *
+ * @var string[]
+ */
+ public $pageSequence = [
+ 'Language',
+ 'ExistingWiki',
+ 'Welcome',
+ 'DBConnect',
+ 'Upgrade',
+ 'DBSettings',
+ 'Name',
+ 'Options',
+ 'Install',
+ 'Complete',
+ ];
+
+ /**
+ * Out of sequence pages, selectable by the user at any time.
+ *
+ * @var string[]
+ */
+ protected $otherPages = [
+ 'Restart',
+ 'Readme',
+ 'ReleaseNotes',
+ 'Copying',
+ 'UpgradeDoc', // Can't use Upgrade due to Upgrade step
+ ];
+
+ /**
+ * Array of pages which have declared that they have been submitted, have validated
+ * their input, and need no further processing.
+ *
+ * @var bool[]
+ */
+ protected $happyPages;
+
+ /**
+ * List of "skipped" pages. These are pages that will automatically continue
+ * to the next page on any GET request. To avoid breaking the "back" button,
+ * they need to be skipped during a back operation.
+ *
+ * @var bool[]
+ */
+ protected $skippedPages;
+
+ /**
+ * Flag indicating that session data may have been lost.
+ *
+ * @var bool
+ */
+ public $showSessionWarning = false;
+
+ /**
+ * Numeric index of the page we're on
+ *
+ * @var int
+ */
+ protected $tabIndex = 1;
+
+ /**
+ * Name of the page we're on
+ *
+ * @var string
+ */
+ protected $currentPageName;
+
+ /**
+ * @param WebRequest $request
+ */
+ public function __construct( WebRequest $request ) {
+ parent::__construct();
+ $this->output = new WebInstallerOutput( $this );
+ $this->request = $request;
+
+ // Add parser hooks
+ global $wgParser;
+ $wgParser->setHook( 'downloadlink', [ $this, 'downloadLinkHook' ] );
+ $wgParser->setHook( 'doclink', [ $this, 'docLink' ] );
+ }
+
+ /**
+ * Main entry point.
+ *
+ * @param array[] $session Initial session array
+ *
+ * @return array[] New session array
+ */
+ public function execute( array $session ) {
+ $this->session = $session;
+
+ if ( isset( $session['settings'] ) ) {
+ $this->settings = $session['settings'] + $this->settings;
+ }
+
+ $this->setupLanguage();
+
+ if ( ( $this->getVar( '_InstallDone' ) || $this->getVar( '_UpgradeDone' ) )
+ && $this->request->getVal( 'localsettings' )
+ ) {
+ $this->request->response()->header( 'Content-type: application/x-httpd-php' );
+ $this->request->response()->header(
+ 'Content-Disposition: attachment; filename="LocalSettings.php"'
+ );
+
+ $ls = InstallerOverrides::getLocalSettingsGenerator( $this );
+ $rightsProfile = $this->rightsProfiles[$this->getVar( '_RightsProfile' )];
+ foreach ( $rightsProfile as $group => $rightsArr ) {
+ $ls->setGroupRights( $group, $rightsArr );
+ }
+ echo $ls->getText();
+
+ return $this->session;
+ }
+
+ $isCSS = $this->request->getVal( 'css' );
+ if ( $isCSS ) {
+ $this->outputCss();
+ return $this->session;
+ }
+
+ if ( isset( $session['happyPages'] ) ) {
+ $this->happyPages = $session['happyPages'];
+ } else {
+ $this->happyPages = [];
+ }
+
+ if ( isset( $session['skippedPages'] ) ) {
+ $this->skippedPages = $session['skippedPages'];
+ } else {
+ $this->skippedPages = [];
+ }
+
+ $lowestUnhappy = $this->getLowestUnhappy();
+
+ # Special case for Creative Commons partner chooser box.
+ if ( $this->request->getVal( 'SubmitCC' ) ) {
+ $page = $this->getPageByName( 'Options' );
+ $this->output->useShortHeader();
+ $this->output->allowFrames();
+ $page->submitCC();
+
+ return $this->finish();
+ }
+
+ if ( $this->request->getVal( 'ShowCC' ) ) {
+ $page = $this->getPageByName( 'Options' );
+ $this->output->useShortHeader();
+ $this->output->allowFrames();
+ $this->output->addHTML( $page->getCCDoneBox() );
+
+ return $this->finish();
+ }
+
+ # Get the page name.
+ $pageName = $this->request->getVal( 'page' );
+
+ if ( in_array( $pageName, $this->otherPages ) ) {
+ # Out of sequence
+ $pageId = false;
+ $page = $this->getPageByName( $pageName );
+ } else {
+ # Main sequence
+ if ( !$pageName || !in_array( $pageName, $this->pageSequence ) ) {
+ $pageId = $lowestUnhappy;
+ } else {
+ $pageId = array_search( $pageName, $this->pageSequence );
+ }
+
+ # If necessary, move back to the lowest-numbered unhappy page
+ if ( $pageId > $lowestUnhappy ) {
+ $pageId = $lowestUnhappy;
+ if ( $lowestUnhappy == 0 ) {
+ # Knocked back to start, possible loss of session data.
+ $this->showSessionWarning = true;
+ }
+ }
+
+ $pageName = $this->pageSequence[$pageId];
+ $page = $this->getPageByName( $pageName );
+ }
+
+ # If a back button was submitted, go back without submitting the form data.
+ if ( $this->request->wasPosted() && $this->request->getBool( 'submit-back' ) ) {
+ if ( $this->request->getVal( 'lastPage' ) ) {
+ $nextPage = $this->request->getVal( 'lastPage' );
+ } elseif ( $pageId !== false ) {
+ # Main sequence page
+ # Skip the skipped pages
+ $nextPageId = $pageId;
+
+ do {
+ $nextPageId--;
+ $nextPage = $this->pageSequence[$nextPageId];
+ } while ( isset( $this->skippedPages[$nextPage] ) );
+ } else {
+ $nextPage = $this->pageSequence[$lowestUnhappy];
+ }
+
+ $this->output->redirect( $this->getUrl( [ 'page' => $nextPage ] ) );
+
+ return $this->finish();
+ }
+
+ # Execute the page.
+ $this->currentPageName = $page->getName();
+ $this->startPageWrapper( $pageName );
+
+ if ( $page->isSlow() ) {
+ $this->disableTimeLimit();
+ }
+
+ $result = $page->execute();
+
+ $this->endPageWrapper();
+
+ if ( $result == 'skip' ) {
+ # Page skipped without explicit submission.
+ # Skip it when we click "back" so that we don't just go forward again.
+ $this->skippedPages[$pageName] = true;
+ $result = 'continue';
+ } else {
+ unset( $this->skippedPages[$pageName] );
+ }
+
+ # If it was posted, the page can request a continue to the next page.
+ if ( $result === 'continue' && !$this->output->headerDone() ) {
+ if ( $pageId !== false ) {
+ $this->happyPages[$pageId] = true;
+ }
+
+ $lowestUnhappy = $this->getLowestUnhappy();
+
+ if ( $this->request->getVal( 'lastPage' ) ) {
+ $nextPage = $this->request->getVal( 'lastPage' );
+ } elseif ( $pageId !== false ) {
+ $nextPage = $this->pageSequence[$pageId + 1];
+ } else {
+ $nextPage = $this->pageSequence[$lowestUnhappy];
+ }
+
+ if ( array_search( $nextPage, $this->pageSequence ) > $lowestUnhappy ) {
+ $nextPage = $this->pageSequence[$lowestUnhappy];
+ }
+
+ $this->output->redirect( $this->getUrl( [ 'page' => $nextPage ] ) );
+ }
+
+ return $this->finish();
+ }
+
+ /**
+ * Find the next page in sequence that hasn't been completed
+ * @return int
+ */
+ public function getLowestUnhappy() {
+ if ( count( $this->happyPages ) == 0 ) {
+ return 0;
+ } else {
+ return max( array_keys( $this->happyPages ) ) + 1;
+ }
+ }
+
+ /**
+ * Start the PHP session. This may be called before execute() to start the PHP session.
+ *
+ * @throws Exception
+ * @return bool
+ */
+ public function startSession() {
+ if ( wfIniGetBool( 'session.auto_start' ) || session_id() ) {
+ // Done already
+ return true;
+ }
+
+ $this->phpErrors = [];
+ set_error_handler( [ $this, 'errorHandler' ] );
+ try {
+ session_name( 'mw_installer_session' );
+ session_start();
+ } catch ( Exception $e ) {
+ restore_error_handler();
+ throw $e;
+ }
+ restore_error_handler();
+
+ if ( $this->phpErrors ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a hash of data identifying this MW installation.
+ *
+ * This is used by mw-config/index.php to prevent multiple installations of MW
+ * on the same cookie domain from interfering with each other.
+ *
+ * @return string
+ */
+ public function getFingerprint() {
+ // Get the base URL of the installation
+ $url = $this->request->getFullRequestURL();
+ if ( preg_match( '!^(.*\?)!', $url, $m ) ) {
+ // Trim query string
+ $url = $m[1];
+ }
+ if ( preg_match( '!^(.*)/[^/]*/[^/]*$!', $url, $m ) ) {
+ // This... seems to try to get the base path from
+ // the /mw-config/index.php. Kinda scary though?
+ $url = $m[1];
+ }
+
+ return md5( serialize( [
+ 'local path' => dirname( __DIR__ ),
+ 'url' => $url,
+ 'version' => $GLOBALS['wgVersion']
+ ] ) );
+ }
+
+ /**
+ * Show an error message in a box. Parameters are like wfMessage(), or
+ * alternatively, pass a Message object in.
+ * @param string|Message $msg
+ */
+ public function showError( $msg /*...*/ ) {
+ if ( !( $msg instanceof Message ) ) {
+ $args = func_get_args();
+ array_shift( $args );
+ $args = array_map( 'htmlspecialchars', $args );
+ $msg = wfMessage( $msg, $args );
+ }
+ $text = $msg->useDatabase( false )->plain();
+ $this->output->addHTML( $this->getErrorBox( $text ) );
+ }
+
+ /**
+ * Temporary error handler for session start debugging.
+ *
+ * @param int $errno Unused
+ * @param string $errstr
+ */
+ public function errorHandler( $errno, $errstr ) {
+ $this->phpErrors[] = $errstr;
+ }
+
+ /**
+ * Clean up from execute()
+ *
+ * @return array[]
+ */
+ public function finish() {
+ $this->output->output();
+
+ $this->session['happyPages'] = $this->happyPages;
+ $this->session['skippedPages'] = $this->skippedPages;
+ $this->session['settings'] = $this->settings;
+
+ return $this->session;
+ }
+
+ /**
+ * We're restarting the installation, reset the session, happyPages, etc
+ */
+ public function reset() {
+ $this->session = [];
+ $this->happyPages = [];
+ $this->settings = [];
+ }
+
+ /**
+ * Get a URL for submission back to the same script.
+ *
+ * @param string[] $query
+ *
+ * @return string
+ */
+ public function getUrl( $query = [] ) {
+ $url = $this->request->getRequestURL();
+ # Remove existing query
+ $url = preg_replace( '/\?.*$/', '', $url );
+
+ if ( $query ) {
+ $url .= '?' . wfArrayToCgi( $query );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get a WebInstallerPage by name.
+ *
+ * @param string $pageName
+ * @return WebInstallerPage
+ */
+ public function getPageByName( $pageName ) {
+ $pageClass = 'WebInstaller' . $pageName;
+
+ return new $pageClass( $this );
+ }
+
+ /**
+ * Get a session variable.
+ *
+ * @param string $name
+ * @param array $default
+ *
+ * @return array
+ */
+ public function getSession( $name, $default = null ) {
+ if ( !isset( $this->session[$name] ) ) {
+ return $default;
+ } else {
+ return $this->session[$name];
+ }
+ }
+
+ /**
+ * Set a session variable.
+ *
+ * @param string $name Key for the variable
+ * @param mixed $value
+ */
+ public function setSession( $name, $value ) {
+ $this->session[$name] = $value;
+ }
+
+ /**
+ * Get the next tabindex attribute value.
+ *
+ * @return int
+ */
+ public function nextTabIndex() {
+ return $this->tabIndex++;
+ }
+
+ /**
+ * Initializes language-related variables.
+ */
+ public function setupLanguage() {
+ global $wgLang, $wgContLang, $wgLanguageCode;
+
+ if ( $this->getSession( 'test' ) === null && !$this->request->wasPosted() ) {
+ $wgLanguageCode = $this->getAcceptLanguage();
+ $wgLang = $wgContLang = Language::factory( $wgLanguageCode );
+ RequestContext::getMain()->setLanguage( $wgLang );
+ $this->setVar( 'wgLanguageCode', $wgLanguageCode );
+ $this->setVar( '_UserLang', $wgLanguageCode );
+ } else {
+ $wgLanguageCode = $this->getVar( 'wgLanguageCode' );
+ $wgContLang = Language::factory( $wgLanguageCode );
+ }
+ }
+
+ /**
+ * Retrieves MediaWiki language from Accept-Language HTTP header.
+ *
+ * @return string
+ */
+ public function getAcceptLanguage() {
+ global $wgLanguageCode, $wgRequest;
+
+ $mwLanguages = Language::fetchLanguageNames();
+ $headerLanguages = array_keys( $wgRequest->getAcceptLang() );
+
+ foreach ( $headerLanguages as $lang ) {
+ if ( isset( $mwLanguages[$lang] ) ) {
+ return $lang;
+ }
+ }
+
+ return $wgLanguageCode;
+ }
+
+ /**
+ * Called by execute() before page output starts, to show a page list.
+ *
+ * @param string $currentPageName
+ */
+ private function startPageWrapper( $currentPageName ) {
+ $s = "<div class=\"config-page-wrapper\">\n";
+ $s .= "<div class=\"config-page\">\n";
+ $s .= "<div class=\"config-page-list\"><ul>\n";
+ $lastHappy = -1;
+
+ foreach ( $this->pageSequence as $id => $pageName ) {
+ $happy = !empty( $this->happyPages[$id] );
+ $s .= $this->getPageListItem(
+ $pageName,
+ $happy || $lastHappy == $id - 1,
+ $currentPageName
+ );
+
+ if ( $happy ) {
+ $lastHappy = $id;
+ }
+ }
+
+ $s .= "</ul><br/><ul>\n";
+ $s .= $this->getPageListItem( 'Restart', true, $currentPageName );
+ // End list pane
+ $s .= "</ul></div>\n";
+
+ // Messages:
+ // config-page-language, config-page-welcome, config-page-dbconnect, config-page-upgrade,
+ // config-page-dbsettings, config-page-name, config-page-options, config-page-install,
+ // config-page-complete, config-page-restart, config-page-readme, config-page-releasenotes,
+ // config-page-copying, config-page-upgradedoc, config-page-existingwiki
+ $s .= Html::element( 'h2', [],
+ wfMessage( 'config-page-' . strtolower( $currentPageName ) )->text() );
+
+ $this->output->addHTMLNoFlush( $s );
+ }
+
+ /**
+ * Get a list item for the page list.
+ *
+ * @param string $pageName
+ * @param bool $enabled
+ * @param string $currentPageName
+ *
+ * @return string
+ */
+ private function getPageListItem( $pageName, $enabled, $currentPageName ) {
+ $s = "<li class=\"config-page-list-item\">";
+
+ // Messages:
+ // config-page-language, config-page-welcome, config-page-dbconnect, config-page-upgrade,
+ // config-page-dbsettings, config-page-name, config-page-options, config-page-install,
+ // config-page-complete, config-page-restart, config-page-readme, config-page-releasenotes,
+ // config-page-copying, config-page-upgradedoc, config-page-existingwiki
+ $name = wfMessage( 'config-page-' . strtolower( $pageName ) )->text();
+
+ if ( $enabled ) {
+ $query = [ 'page' => $pageName ];
+
+ if ( !in_array( $pageName, $this->pageSequence ) ) {
+ if ( in_array( $currentPageName, $this->pageSequence ) ) {
+ $query['lastPage'] = $currentPageName;
+ }
+
+ $link = Html::element( 'a',
+ [
+ 'href' => $this->getUrl( $query )
+ ],
+ $name
+ );
+ } else {
+ $link = htmlspecialchars( $name );
+ }
+
+ if ( $pageName == $currentPageName ) {
+ $s .= "<span class=\"config-page-current\">$link</span>";
+ } else {
+ $s .= $link;
+ }
+ } else {
+ $s .= Html::element( 'span',
+ [
+ 'class' => 'config-page-disabled'
+ ],
+ $name
+ );
+ }
+
+ $s .= "</li>\n";
+
+ return $s;
+ }
+
+ /**
+ * Output some stuff after a page is finished.
+ */
+ private function endPageWrapper() {
+ $this->output->addHTMLNoFlush(
+ "<div class=\"visualClear\"></div>\n" .
+ "</div>\n" .
+ "<div class=\"visualClear\"></div>\n" .
+ "</div>" );
+ }
+
+ /**
+ * Get HTML for an error box with an icon.
+ *
+ * @param string $text Wikitext, get this with wfMessage()->plain()
+ *
+ * @return string
+ */
+ public function getErrorBox( $text ) {
+ return $this->getInfoBox( $text, 'critical-32.png', 'config-error-box' );
+ }
+
+ /**
+ * Get HTML for a warning box with an icon.
+ *
+ * @param string $text Wikitext, get this with wfMessage()->plain()
+ *
+ * @return string
+ */
+ public function getWarningBox( $text ) {
+ return $this->getInfoBox( $text, 'warning-32.png', 'config-warning-box' );
+ }
+
+ /**
+ * Get HTML for an info box with an icon.
+ *
+ * @param string $text Wikitext, get this with wfMessage()->plain()
+ * @param string|bool $icon Icon name, file in mw-config/images. Default: false
+ * @param string|bool $class Additional class name to add to the wrapper div. Default: false.
+ *
+ * @return string
+ */
+ public function getInfoBox( $text, $icon = false, $class = false ) {
+ $text = $this->parse( $text, true );
+ $icon = ( $icon == false ) ?
+ 'images/info-32.png' :
+ 'images/' . $icon;
+ $alt = wfMessage( 'config-information' )->text();
+
+ return Html::infoBox( $text, $icon, $alt, $class );
+ }
+
+ /**
+ * Get small text indented help for a preceding form field.
+ * Parameters like wfMessage().
+ *
+ * @param string $msg
+ * @return string
+ */
+ public function getHelpBox( $msg /*, ... */ ) {
+ $args = func_get_args();
+ array_shift( $args );
+ $args = array_map( 'htmlspecialchars', $args );
+ $text = wfMessage( $msg, $args )->useDatabase( false )->plain();
+ $html = $this->parse( $text, true );
+
+ return "<div class=\"config-help-field-container\">\n" .
+ "<span class=\"config-help-field-hint\" title=\"" .
+ wfMessage( 'config-help-tooltip' )->escaped() . "\">" .
+ wfMessage( 'config-help' )->escaped() . "</span>\n" .
+ "<div class=\"config-help-field-data\">" . $html . "</div>\n" .
+ "</div>\n";
+ }
+
+ /**
+ * Output a help box.
+ * @param string $msg Key for wfMessage()
+ */
+ public function showHelpBox( $msg /*, ... */ ) {
+ $args = func_get_args();
+ $html = call_user_func_array( [ $this, 'getHelpBox' ], $args );
+ $this->output->addHTML( $html );
+ }
+
+ /**
+ * Show a short informational message.
+ * Output looks like a list.
+ *
+ * @param string $msg
+ */
+ public function showMessage( $msg /*, ... */ ) {
+ $args = func_get_args();
+ array_shift( $args );
+ $html = '<div class="config-message">' .
+ $this->parse( wfMessage( $msg, $args )->useDatabase( false )->plain() ) .
+ "</div>\n";
+ $this->output->addHTML( $html );
+ }
+
+ /**
+ * @param Status $status
+ */
+ public function showStatusMessage( Status $status ) {
+ $errors = array_merge( $status->getErrorsArray(), $status->getWarningsArray() );
+ foreach ( $errors as $error ) {
+ call_user_func_array( [ $this, 'showMessage' ], $error );
+ }
+ }
+
+ /**
+ * Label a control by wrapping a config-input div around it and putting a
+ * label before it.
+ *
+ * @param string $msg
+ * @param string $forId
+ * @param string $contents
+ * @param string $helpData
+ * @return string
+ */
+ public function label( $msg, $forId, $contents, $helpData = "" ) {
+ if ( strval( $msg ) == '' ) {
+ $labelText = '&#160;';
+ } else {
+ $labelText = wfMessage( $msg )->escaped();
+ }
+
+ $attributes = [ 'class' => 'config-label' ];
+
+ if ( $forId ) {
+ $attributes['for'] = $forId;
+ }
+
+ return "<div class=\"config-block\">\n" .
+ " <div class=\"config-block-label\">\n" .
+ Xml::tags( 'label',
+ $attributes,
+ $labelText
+ ) . "\n" .
+ $helpData .
+ " </div>\n" .
+ " <div class=\"config-block-elements\">\n" .
+ $contents .
+ " </div>\n" .
+ "</div>\n";
+ }
+
+ /**
+ * Get a labelled text box to configure a variable.
+ *
+ * @param mixed[] $params
+ * Parameters are:
+ * var: The variable to be configured (required)
+ * label: The message name for the label (required)
+ * attribs: Additional attributes for the input element (optional)
+ * controlName: The name for the input element (optional)
+ * value: The current value of the variable (optional)
+ * help: The html for the help text (optional)
+ *
+ * @return string
+ */
+ public function getTextBox( $params ) {
+ if ( !isset( $params['controlName'] ) ) {
+ $params['controlName'] = 'config_' . $params['var'];
+ }
+
+ if ( !isset( $params['value'] ) ) {
+ $params['value'] = $this->getVar( $params['var'] );
+ }
+
+ if ( !isset( $params['attribs'] ) ) {
+ $params['attribs'] = [];
+ }
+ if ( !isset( $params['help'] ) ) {
+ $params['help'] = "";
+ }
+
+ return $this->label(
+ $params['label'],
+ $params['controlName'],
+ Xml::input(
+ $params['controlName'],
+ 30, // intended to be overridden by CSS
+ $params['value'],
+ $params['attribs'] + [
+ 'id' => $params['controlName'],
+ 'class' => 'config-input-text',
+ 'tabindex' => $this->nextTabIndex()
+ ]
+ ),
+ $params['help']
+ );
+ }
+
+ /**
+ * Get a labelled textarea to configure a variable
+ *
+ * @param mixed[] $params
+ * Parameters are:
+ * var: The variable to be configured (required)
+ * label: The message name for the label (required)
+ * attribs: Additional attributes for the input element (optional)
+ * controlName: The name for the input element (optional)
+ * value: The current value of the variable (optional)
+ * help: The html for the help text (optional)
+ *
+ * @return string
+ */
+ public function getTextArea( $params ) {
+ if ( !isset( $params['controlName'] ) ) {
+ $params['controlName'] = 'config_' . $params['var'];
+ }
+
+ if ( !isset( $params['value'] ) ) {
+ $params['value'] = $this->getVar( $params['var'] );
+ }
+
+ if ( !isset( $params['attribs'] ) ) {
+ $params['attribs'] = [];
+ }
+ if ( !isset( $params['help'] ) ) {
+ $params['help'] = "";
+ }
+
+ return $this->label(
+ $params['label'],
+ $params['controlName'],
+ Xml::textarea(
+ $params['controlName'],
+ $params['value'],
+ 30,
+ 5,
+ $params['attribs'] + [
+ 'id' => $params['controlName'],
+ 'class' => 'config-input-text',
+ 'tabindex' => $this->nextTabIndex()
+ ]
+ ),
+ $params['help']
+ );
+ }
+
+ /**
+ * Get a labelled password box to configure a variable.
+ *
+ * Implements password hiding
+ * @param mixed[] $params
+ * Parameters are:
+ * var: The variable to be configured (required)
+ * label: The message name for the label (required)
+ * attribs: Additional attributes for the input element (optional)
+ * controlName: The name for the input element (optional)
+ * value: The current value of the variable (optional)
+ * help: The html for the help text (optional)
+ *
+ * @return string
+ */
+ public function getPasswordBox( $params ) {
+ if ( !isset( $params['value'] ) ) {
+ $params['value'] = $this->getVar( $params['var'] );
+ }
+
+ if ( !isset( $params['attribs'] ) ) {
+ $params['attribs'] = [];
+ }
+
+ $params['value'] = $this->getFakePassword( $params['value'] );
+ $params['attribs']['type'] = 'password';
+
+ return $this->getTextBox( $params );
+ }
+
+ /**
+ * Get a labelled checkbox to configure a boolean variable.
+ *
+ * @param mixed[] $params
+ * Parameters are:
+ * var: The variable to be configured (required)
+ * label: The message name for the label (required)
+ * attribs: Additional attributes for the input element (optional)
+ * controlName: The name for the input element (optional)
+ * value: The current value of the variable (optional)
+ * help: The html for the help text (optional)
+ *
+ * @return string
+ */
+ public function getCheckBox( $params ) {
+ if ( !isset( $params['controlName'] ) ) {
+ $params['controlName'] = 'config_' . $params['var'];
+ }
+
+ if ( !isset( $params['value'] ) ) {
+ $params['value'] = $this->getVar( $params['var'] );
+ }
+
+ if ( !isset( $params['attribs'] ) ) {
+ $params['attribs'] = [];
+ }
+ if ( !isset( $params['help'] ) ) {
+ $params['help'] = "";
+ }
+ if ( isset( $params['rawtext'] ) ) {
+ $labelText = $params['rawtext'];
+ } else {
+ $labelText = $this->parse( wfMessage( $params['label'] )->text() );
+ }
+
+ return "<div class=\"config-input-check\">\n" .
+ $params['help'] .
+ "<label>\n" .
+ Xml::check(
+ $params['controlName'],
+ $params['value'],
+ $params['attribs'] + [
+ 'id' => $params['controlName'],
+ 'tabindex' => $this->nextTabIndex(),
+ ]
+ ) .
+ $labelText . "\n" .
+ "</label>\n" .
+ "</div>\n";
+ }
+
+ /**
+ * Get a set of labelled radio buttons.
+ *
+ * @param mixed[] $params
+ * Parameters are:
+ * var: The variable to be configured (required)
+ * label: The message name for the label (required)
+ * itemLabelPrefix: The message name prefix for the item labels (required)
+ * itemLabels: List of message names to use for the item labels instead
+ * of itemLabelPrefix, keyed by values
+ * values: List of allowed values (required)
+ * itemAttribs: Array of attribute arrays, outer key is the value name (optional)
+ * commonAttribs: Attribute array applied to all items
+ * controlName: The name for the input element (optional)
+ * value: The current value of the variable (optional)
+ * help: The html for the help text (optional)
+ *
+ * @return string
+ */
+ public function getRadioSet( $params ) {
+ $items = $this->getRadioElements( $params );
+
+ if ( !isset( $params['label'] ) ) {
+ $label = '';
+ } else {
+ $label = $params['label'];
+ }
+
+ if ( !isset( $params['controlName'] ) ) {
+ $params['controlName'] = 'config_' . $params['var'];
+ }
+
+ if ( !isset( $params['help'] ) ) {
+ $params['help'] = "";
+ }
+
+ $s = "<ul>\n";
+ foreach ( $items as $value => $item ) {
+ $s .= "<li>$item</li>\n";
+ }
+ $s .= "</ul>\n";
+
+ return $this->label( $label, $params['controlName'], $s, $params['help'] );
+ }
+
+ /**
+ * Get a set of labelled radio buttons. You probably want to use getRadioSet(), not this.
+ *
+ * @see getRadioSet
+ *
+ * @param mixed[] $params
+ * @return array
+ */
+ public function getRadioElements( $params ) {
+ if ( !isset( $params['controlName'] ) ) {
+ $params['controlName'] = 'config_' . $params['var'];
+ }
+
+ if ( !isset( $params['value'] ) ) {
+ $params['value'] = $this->getVar( $params['var'] );
+ }
+
+ $items = [];
+
+ foreach ( $params['values'] as $value ) {
+ $itemAttribs = [];
+
+ if ( isset( $params['commonAttribs'] ) ) {
+ $itemAttribs = $params['commonAttribs'];
+ }
+
+ if ( isset( $params['itemAttribs'][$value] ) ) {
+ $itemAttribs = $params['itemAttribs'][$value] + $itemAttribs;
+ }
+
+ $checked = $value == $params['value'];
+ $id = $params['controlName'] . '_' . $value;
+ $itemAttribs['id'] = $id;
+ $itemAttribs['tabindex'] = $this->nextTabIndex();
+
+ $items[$value] =
+ Xml::radio( $params['controlName'], $value, $checked, $itemAttribs ) .
+ '&#160;' .
+ Xml::tags( 'label', [ 'for' => $id ], $this->parse(
+ isset( $params['itemLabels'] ) ?
+ wfMessage( $params['itemLabels'][$value] )->plain() :
+ wfMessage( $params['itemLabelPrefix'] . strtolower( $value ) )->plain()
+ ) );
+ }
+
+ return $items;
+ }
+
+ /**
+ * Output an error or warning box using a Status object.
+ *
+ * @param Status $status
+ */
+ public function showStatusBox( $status ) {
+ if ( !$status->isGood() ) {
+ $text = $status->getWikiText();
+
+ if ( $status->isOK() ) {
+ $box = $this->getWarningBox( $text );
+ } else {
+ $box = $this->getErrorBox( $text );
+ }
+
+ $this->output->addHTML( $box );
+ }
+ }
+
+ /**
+ * Convenience function to set variables based on form data.
+ * Assumes that variables containing "password" in the name are (potentially
+ * fake) passwords.
+ *
+ * @param string[] $varNames
+ * @param string $prefix The prefix added to variables to obtain form names
+ *
+ * @return string[]
+ */
+ public function setVarsFromRequest( $varNames, $prefix = 'config_' ) {
+ $newValues = [];
+
+ foreach ( $varNames as $name ) {
+ $value = $this->request->getVal( $prefix . $name );
+ // T32524, do not trim passwords
+ if ( stripos( $name, 'password' ) === false ) {
+ $value = trim( $value );
+ }
+ $newValues[$name] = $value;
+
+ if ( $value === null ) {
+ // Checkbox?
+ $this->setVar( $name, false );
+ } else {
+ if ( stripos( $name, 'password' ) !== false ) {
+ $this->setPassword( $name, $value );
+ } else {
+ $this->setVar( $name, $value );
+ }
+ }
+ }
+
+ return $newValues;
+ }
+
+ /**
+ * Helper for Installer::docLink()
+ *
+ * @param string $page
+ *
+ * @return string
+ */
+ protected function getDocUrl( $page ) {
+ $url = "{$_SERVER['PHP_SELF']}?page=" . urlencode( $page );
+
+ if ( in_array( $this->currentPageName, $this->pageSequence ) ) {
+ $url .= '&lastPage=' . urlencode( $this->currentPageName );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Extension tag hook for a documentation link.
+ *
+ * @param string $linkText
+ * @param string[] $attribs
+ * @param Parser $parser Unused
+ *
+ * @return string
+ */
+ public function docLink( $linkText, $attribs, $parser ) {
+ $url = $this->getDocUrl( $attribs['href'] );
+
+ return '<a href="' . htmlspecialchars( $url ) . '">' .
+ htmlspecialchars( $linkText ) .
+ '</a>';
+ }
+
+ /**
+ * Helper for "Download LocalSettings" link on WebInstall_Complete
+ *
+ * @param string $text Unused
+ * @param string[] $attribs Unused
+ * @param Parser $parser Unused
+ *
+ * @return string Html for download link
+ */
+ public function downloadLinkHook( $text, $attribs, $parser ) {
+ $anchor = Html::rawElement( 'a',
+ [ 'href' => $this->getUrl( [ 'localsettings' => 1 ] ) ],
+ wfMessage( 'config-download-localsettings' )->parse()
+ );
+
+ return Html::rawElement( 'div', [ 'class' => 'config-download-link' ], $anchor );
+ }
+
+ /**
+ * If the software package wants the LocalSettings.php file
+ * to be placed in a specific location, override this function
+ * (see mw-config/overrides/README) to return the path of
+ * where the file should be saved, or false for a generic
+ * "in the base of your install"
+ *
+ * @since 1.27
+ * @return string|bool
+ */
+ public function getLocalSettingsLocation() {
+ return false;
+ }
+
+ /**
+ * @return bool
+ */
+ public function envCheckPath() {
+ // PHP_SELF isn't available sometimes, such as when PHP is CGI but
+ // cgi.fix_pathinfo is disabled. In that case, fall back to SCRIPT_NAME
+ // to get the path to the current script... hopefully it's reliable. SIGH
+ $path = false;
+ if ( !empty( $_SERVER['PHP_SELF'] ) ) {
+ $path = $_SERVER['PHP_SELF'];
+ } elseif ( !empty( $_SERVER['SCRIPT_NAME'] ) ) {
+ $path = $_SERVER['SCRIPT_NAME'];
+ }
+ if ( $path === false ) {
+ $this->showError( 'config-no-uri' );
+ return false;
+ }
+
+ return parent::envCheckPath();
+ }
+
+ public function envPrepPath() {
+ parent::envPrepPath();
+ // PHP_SELF isn't available sometimes, such as when PHP is CGI but
+ // cgi.fix_pathinfo is disabled. In that case, fall back to SCRIPT_NAME
+ // to get the path to the current script... hopefully it's reliable. SIGH
+ $path = false;
+ if ( !empty( $_SERVER['PHP_SELF'] ) ) {
+ $path = $_SERVER['PHP_SELF'];
+ } elseif ( !empty( $_SERVER['SCRIPT_NAME'] ) ) {
+ $path = $_SERVER['SCRIPT_NAME'];
+ }
+ if ( $path !== false ) {
+ $scriptPath = preg_replace( '{^(.*)/(mw-)?config.*$}', '$1', $path );
+
+ $this->setVar( 'wgScriptPath', "$scriptPath" );
+ // Update variables set from Setup.php that are derived from wgScriptPath
+ $this->setVar( 'wgScript', "$scriptPath/index.php" );
+ $this->setVar( 'wgLoadScript', "$scriptPath/load.php" );
+ $this->setVar( 'wgStylePath', "$scriptPath/skins" );
+ $this->setVar( 'wgLocalStylePath', "$scriptPath/skins" );
+ $this->setVar( 'wgExtensionAssetsPath', "$scriptPath/extensions" );
+ $this->setVar( 'wgUploadPath', "$scriptPath/images" );
+ $this->setVar( 'wgResourceBasePath', "$scriptPath" );
+ }
+ }
+
+ /**
+ * @return string
+ */
+ protected function envGetDefaultServer() {
+ return WebRequest::detectServer();
+ }
+
+ /**
+ * Output stylesheet for web installer pages
+ */
+ public function outputCss() {
+ $this->request->response()->header( 'Content-type: text/css' );
+ echo $this->output->getCSS();
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getPhpErrors() {
+ return $this->phpErrors;
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerComplete.php b/www/wiki/includes/installer/WebInstallerComplete.php
new file mode 100644
index 00000000..456058e4
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerComplete.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerComplete extends WebInstallerPage {
+
+ public function execute() {
+ // Pop up a dialog box, to make it difficult for the user to forget
+ // to download the file
+ $lsUrl = $this->getVar( 'wgServer' ) . $this->parent->getUrl( [ 'localsettings' => 1 ] );
+ if ( isset( $_SERVER['HTTP_USER_AGENT'] ) &&
+ strpos( $_SERVER['HTTP_USER_AGENT'], 'MSIE' ) !== false
+ ) {
+ // JS appears to be the only method that works consistently with IE7+
+ $this->addHTML( "\n<script>jQuery( function () { location.href = " .
+ Xml::encodeJsVar( $lsUrl ) . "; } );</script>\n" );
+ } else {
+ $this->parent->request->response()->header( "Refresh: 0;url=$lsUrl" );
+ }
+
+ $this->startForm();
+ $this->parent->disableLinkPopups();
+ $location = $this->parent->getLocalSettingsLocation();
+ $msg = 'config-install-done';
+ if ( $location !== false ) {
+ // config-install-done-path
+ $msg .= '-path';
+ }
+ $this->addHTML(
+ $this->parent->getInfoBox(
+ wfMessage( $msg,
+ $lsUrl,
+ $this->getVar( 'wgServer' ) .
+ $this->getVar( 'wgScriptPath' ) . '/index.php',
+ '<downloadlink/>',
+ $location ?: ''
+ )->plain(), 'tick-32.png'
+ )
+ );
+ $this->addHTML( $this->parent->getInfoBox(
+ wfMessage( 'config-extension-link' )->text() ) );
+
+ $this->parent->restoreLinkPopups();
+ $this->endForm( false, false );
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerCopying.php b/www/wiki/includes/installer/WebInstallerCopying.php
new file mode 100644
index 00000000..36fec86a
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerCopying.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerCopying extends WebInstallerDocument {
+
+ /**
+ * @return string
+ */
+ protected function getFileName() {
+ return 'COPYING';
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerDBConnect.php b/www/wiki/includes/installer/WebInstallerDBConnect.php
new file mode 100644
index 00000000..eb3a52f6
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerDBConnect.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerDBConnect extends WebInstallerPage {
+
+ /**
+ * @return string|null When string, "skip" or "continue"
+ */
+ public function execute() {
+ if ( $this->getVar( '_ExistingDBSettings' ) ) {
+ return 'skip';
+ }
+
+ $r = $this->parent->request;
+ if ( $r->wasPosted() ) {
+ $status = $this->submit();
+
+ if ( $status->isGood() ) {
+ $this->setVar( '_UpgradeDone', false );
+
+ return 'continue';
+ } else {
+ $this->parent->showStatusBox( $status );
+ }
+ }
+
+ $this->startForm();
+
+ $types = "<ul class=\"config-settings-block\">\n";
+ $settings = '';
+ $defaultType = $this->getVar( 'wgDBtype' );
+
+ // Messages: config-dbsupport-mysql, config-dbsupport-postgres, config-dbsupport-oracle,
+ // config-dbsupport-sqlite, config-dbsupport-mssql
+ $dbSupport = '';
+ foreach ( Installer::getDBTypes() as $type ) {
+ $dbSupport .= wfMessage( "config-dbsupport-$type" )->plain() . "\n";
+ }
+ $this->addHTML( $this->parent->getInfoBox(
+ wfMessage( 'config-support-info', trim( $dbSupport ) )->text() ) );
+
+ // It's possible that the library for the default DB type is not compiled in.
+ // In that case, instead select the first supported DB type in the list.
+ $compiledDBs = $this->parent->getCompiledDBs();
+ if ( !in_array( $defaultType, $compiledDBs ) ) {
+ $defaultType = $compiledDBs[0];
+ }
+
+ foreach ( $compiledDBs as $type ) {
+ $installer = $this->parent->getDBInstaller( $type );
+ $types .=
+ '<li>' .
+ Xml::radioLabel(
+ $installer->getReadableName(),
+ 'DBType',
+ $type,
+ "DBType_$type",
+ $type == $defaultType,
+ [ 'class' => 'dbRadio', 'rel' => "DB_wrapper_$type" ]
+ ) .
+ "</li>\n";
+
+ // Messages: config-header-mysql, config-header-postgres, config-header-oracle,
+ // config-header-sqlite
+ $settings .= Html::openElement(
+ 'div',
+ [
+ 'id' => 'DB_wrapper_' . $type,
+ 'class' => 'dbWrapper'
+ ]
+ ) .
+ Html::element( 'h3', [], wfMessage( 'config-header-' . $type )->text() ) .
+ $installer->getConnectForm() .
+ "</div>\n";
+ }
+
+ $types .= "</ul><br style=\"clear: left\"/>\n";
+
+ $this->addHTML( $this->parent->label( 'config-db-type', false, $types ) . $settings );
+ $this->endForm();
+
+ return null;
+ }
+
+ /**
+ * @return Status
+ */
+ public function submit() {
+ $r = $this->parent->request;
+ $type = $r->getVal( 'DBType' );
+ if ( !$type ) {
+ return Status::newFatal( 'config-invalid-db-type' );
+ }
+ $this->setVar( 'wgDBtype', $type );
+ $installer = $this->parent->getDBInstaller( $type );
+ if ( !$installer ) {
+ return Status::newFatal( 'config-invalid-db-type' );
+ }
+
+ return $installer->submitConnectForm();
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerDBSettings.php b/www/wiki/includes/installer/WebInstallerDBSettings.php
new file mode 100644
index 00000000..f214663a
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerDBSettings.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerDBSettings extends WebInstallerPage {
+
+ /**
+ * @return string|null
+ */
+ public function execute() {
+ $installer = $this->parent->getDBInstaller( $this->getVar( 'wgDBtype' ) );
+
+ $r = $this->parent->request;
+ if ( $r->wasPosted() ) {
+ $status = $installer->submitSettingsForm();
+ if ( $status === false ) {
+ return 'skip';
+ } elseif ( $status->isGood() ) {
+ return 'continue';
+ } else {
+ $this->parent->showStatusBox( $status );
+ }
+ }
+
+ $form = $installer->getSettingsForm();
+ if ( $form === false ) {
+ return 'skip';
+ }
+
+ $this->startForm();
+ $this->addHTML( $form );
+ $this->endForm();
+
+ return null;
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerDocument.php b/www/wiki/includes/installer/WebInstallerDocument.php
new file mode 100644
index 00000000..fc1c33f9
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerDocument.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+abstract class WebInstallerDocument extends WebInstallerPage {
+
+ /**
+ * @return string
+ */
+ abstract protected function getFileName();
+
+ public function execute() {
+ $text = $this->getFileContents();
+ $text = InstallDocFormatter::format( $text );
+ $this->parent->output->addWikiText( $text );
+ $this->startForm();
+ $this->endForm( false );
+ }
+
+ /**
+ * @return string
+ */
+ public function getFileContents() {
+ $file = __DIR__ . '/../../' . $this->getFileName();
+ if ( !file_exists( $file ) ) {
+ return wfMessage( 'config-nofile', $file )->plain();
+ }
+
+ return file_get_contents( $file );
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerExistingWiki.php b/www/wiki/includes/installer/WebInstallerExistingWiki.php
new file mode 100644
index 00000000..df68be8e
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerExistingWiki.php
@@ -0,0 +1,191 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerExistingWiki extends WebInstallerPage {
+
+ /**
+ * @return string
+ */
+ public function execute() {
+ // If there is no LocalSettings.php, continue to the installer welcome page
+ $vars = Installer::getExistingLocalSettings();
+ if ( !$vars ) {
+ return 'skip';
+ }
+
+ // Check if the upgrade key supplied to the user has appeared in LocalSettings.php
+ if ( $vars['wgUpgradeKey'] !== false
+ && $this->getVar( '_UpgradeKeySupplied' )
+ && $this->getVar( 'wgUpgradeKey' ) === $vars['wgUpgradeKey']
+ ) {
+ // It's there, so the user is authorized
+ $status = $this->handleExistingUpgrade( $vars );
+ if ( $status->isOK() ) {
+ return 'skip';
+ } else {
+ $this->startForm();
+ $this->parent->showStatusBox( $status );
+ $this->endForm( 'continue' );
+
+ return 'output';
+ }
+ }
+
+ // If there is no $wgUpgradeKey, tell the user to add one to LocalSettings.php
+ if ( $vars['wgUpgradeKey'] === false ) {
+ if ( $this->getVar( 'wgUpgradeKey', false ) === false ) {
+ $secretKey = $this->getVar( 'wgSecretKey' ); // preserve $wgSecretKey
+ $this->parent->generateKeys();
+ $this->setVar( 'wgSecretKey', $secretKey );
+ $this->setVar( '_UpgradeKeySupplied', true );
+ }
+ $this->startForm();
+ $this->addHTML( $this->parent->getInfoBox(
+ wfMessage( 'config-upgrade-key-missing', "<pre dir=\"ltr\">\$wgUpgradeKey = '" .
+ $this->getVar( 'wgUpgradeKey' ) . "';</pre>" )->plain()
+ ) );
+ $this->endForm( 'continue' );
+
+ return 'output';
+ }
+
+ // If there is an upgrade key, but it wasn't supplied, prompt the user to enter it
+
+ $r = $this->parent->request;
+ if ( $r->wasPosted() ) {
+ $key = $r->getText( 'config_wgUpgradeKey' );
+ if ( !$key || $key !== $vars['wgUpgradeKey'] ) {
+ $this->parent->showError( 'config-localsettings-badkey' );
+ $this->showKeyForm();
+
+ return 'output';
+ }
+ // Key was OK
+ $status = $this->handleExistingUpgrade( $vars );
+ if ( $status->isOK() ) {
+ return 'continue';
+ } else {
+ $this->parent->showStatusBox( $status );
+ $this->showKeyForm();
+
+ return 'output';
+ }
+ } else {
+ $this->showKeyForm();
+
+ return 'output';
+ }
+ }
+
+ /**
+ * Show the "enter key" form
+ */
+ protected function showKeyForm() {
+ $this->startForm();
+ $this->addHTML(
+ $this->parent->getInfoBox( wfMessage( 'config-localsettings-upgrade' )->plain() ) .
+ '<br />' .
+ $this->parent->getTextBox( [
+ 'var' => 'wgUpgradeKey',
+ 'label' => 'config-localsettings-key',
+ 'attribs' => [ 'autocomplete' => 'off' ],
+ ] )
+ );
+ $this->endForm( 'continue' );
+ }
+
+ /**
+ * @param string[] $names
+ * @param mixed[] $vars
+ *
+ * @return Status
+ */
+ protected function importVariables( $names, $vars ) {
+ $status = Status::newGood();
+ foreach ( $names as $name ) {
+ if ( !isset( $vars[$name] ) ) {
+ $status->fatal( 'config-localsettings-incomplete', $name );
+ }
+ $this->setVar( $name, $vars[$name] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Initiate an upgrade of the existing database
+ *
+ * @param mixed[] $vars Variables from LocalSettings.php
+ *
+ * @return Status
+ */
+ protected function handleExistingUpgrade( $vars ) {
+ // Check $wgDBtype
+ if ( !isset( $vars['wgDBtype'] ) ||
+ !in_array( $vars['wgDBtype'], Installer::getDBTypes() )
+ ) {
+ return Status::newFatal( 'config-localsettings-connection-error', '' );
+ }
+
+ // Set the relevant variables from LocalSettings.php
+ $requiredVars = [ 'wgDBtype' ];
+ $status = $this->importVariables( $requiredVars, $vars );
+ $installer = $this->parent->getDBInstaller();
+ $status->merge( $this->importVariables( $installer->getGlobalNames(), $vars ) );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ if ( isset( $vars['wgDBadminuser'] ) ) {
+ $this->setVar( '_InstallUser', $vars['wgDBadminuser'] );
+ } else {
+ $this->setVar( '_InstallUser', $vars['wgDBuser'] );
+ }
+ if ( isset( $vars['wgDBadminpassword'] ) ) {
+ $this->setVar( '_InstallPassword', $vars['wgDBadminpassword'] );
+ } else {
+ $this->setVar( '_InstallPassword', $vars['wgDBpassword'] );
+ }
+
+ // Test the database connection
+ $status = $installer->getConnection();
+ if ( !$status->isOK() ) {
+ // Adjust the error message to explain things correctly
+ $status->replaceMessage( 'config-connection-error',
+ 'config-localsettings-connection-error' );
+
+ return $status;
+ }
+
+ // All good
+ $this->setVar( '_ExistingDBSettings', true );
+
+ // Copy $wgAuthenticationTokenVersion too, if it exists
+ $this->setVar( 'wgAuthenticationTokenVersion',
+ isset( $vars['wgAuthenticationTokenVersion'] )
+ ? $vars['wgAuthenticationTokenVersion']
+ : null
+ );
+
+ return $status;
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerInstall.php b/www/wiki/includes/installer/WebInstallerInstall.php
new file mode 100644
index 00000000..63740e38
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerInstall.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerInstall extends WebInstallerPage {
+
+ /**
+ * @return bool Always true.
+ */
+ public function isSlow() {
+ return true;
+ }
+
+ /**
+ * @return string|bool
+ */
+ public function execute() {
+ if ( $this->getVar( '_UpgradeDone' ) ) {
+ return 'skip';
+ } elseif ( $this->getVar( '_InstallDone' ) ) {
+ return 'continue';
+ } elseif ( $this->parent->request->wasPosted() ) {
+ $this->startForm();
+ $this->addHTML( "<ul>" );
+ $results = $this->parent->performInstallation(
+ [ $this, 'startStage' ],
+ [ $this, 'endStage' ]
+ );
+ $this->addHTML( "</ul>" );
+ // PerformInstallation bails on a fatal, so make sure the last item
+ // completed before giving 'next.' Likewise, only provide back on failure
+ $lastStep = end( $results );
+ $continue = $lastStep->isOK() ? 'continue' : false;
+ $back = $lastStep->isOK() ? false : 'back';
+ $this->endForm( $continue, $back );
+ } else {
+ $this->startForm();
+ $this->addHTML( $this->parent->getInfoBox( wfMessage( 'config-install-begin' )->plain() ) );
+ $this->endForm();
+ }
+
+ return true;
+ }
+
+ /**
+ * @param string $step
+ */
+ public function startStage( $step ) {
+ // Messages: config-install-database, config-install-tables, config-install-interwiki,
+ // config-install-stats, config-install-keys, config-install-sysop, config-install-mainpage
+ $this->addHTML( "<li>" . wfMessage( "config-install-$step" )->escaped() .
+ wfMessage( 'ellipsis' )->escaped() );
+
+ if ( $step == 'extension-tables' ) {
+ $this->startLiveBox();
+ }
+ }
+
+ /**
+ * @param string $step
+ * @param Status $status
+ */
+ public function endStage( $step, $status ) {
+ if ( $step == 'extension-tables' ) {
+ $this->endLiveBox();
+ }
+ $msg = $status->isOK() ? 'config-install-step-done' : 'config-install-step-failed';
+ $html = wfMessage( 'word-separator' )->escaped() . wfMessage( $msg )->escaped();
+ if ( !$status->isOK() ) {
+ $html = "<span class=\"error\">$html</span>";
+ }
+ $this->addHTML( $html . "</li>\n" );
+ if ( !$status->isGood() ) {
+ $this->parent->showStatusBox( $status );
+ }
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerLanguage.php b/www/wiki/includes/installer/WebInstallerLanguage.php
new file mode 100644
index 00000000..bce07d31
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerLanguage.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerLanguage extends WebInstallerPage {
+
+ /**
+ * @return string|null
+ */
+ public function execute() {
+ global $wgLang;
+ $r = $this->parent->request;
+ $userLang = $r->getVal( 'uselang' );
+ $contLang = $r->getVal( 'ContLang' );
+
+ $languages = Language::fetchLanguageNames();
+ $lifetime = intval( ini_get( 'session.gc_maxlifetime' ) );
+ if ( !$lifetime ) {
+ $lifetime = 1440; // PHP default
+ }
+
+ if ( $r->wasPosted() ) {
+ # Do session test
+ if ( $this->parent->getSession( 'test' ) === null ) {
+ $requestTime = $r->getVal( 'LanguageRequestTime' );
+ if ( !$requestTime ) {
+ // The most likely explanation is that the user was knocked back
+ // from another page on POST due to session expiry
+ $msg = 'config-session-expired';
+ } elseif ( time() - $requestTime > $lifetime ) {
+ $msg = 'config-session-expired';
+ } else {
+ $msg = 'config-no-session';
+ }
+ $this->parent->showError( $msg, $wgLang->formatTimePeriod( $lifetime ) );
+ } else {
+ if ( isset( $languages[$userLang] ) ) {
+ $this->setVar( '_UserLang', $userLang );
+ }
+ if ( isset( $languages[$contLang] ) ) {
+ $this->setVar( 'wgLanguageCode', $contLang );
+ }
+
+ return 'continue';
+ }
+ } elseif ( $this->parent->showSessionWarning ) {
+ # The user was knocked back from another page to the start
+ # This probably indicates a session expiry
+ $this->parent->showError( 'config-session-expired',
+ $wgLang->formatTimePeriod( $lifetime ) );
+ }
+
+ $this->parent->setSession( 'test', true );
+
+ if ( !isset( $languages[$userLang] ) ) {
+ $userLang = $this->getVar( '_UserLang', 'en' );
+ }
+ if ( !isset( $languages[$contLang] ) ) {
+ $contLang = $this->getVar( 'wgLanguageCode', 'en' );
+ }
+ $this->startForm();
+ $s = Html::hidden( 'LanguageRequestTime', time() ) .
+ $this->getLanguageSelector( 'uselang', 'config-your-language', $userLang,
+ $this->parent->getHelpBox( 'config-your-language-help' ) ) .
+ $this->getLanguageSelector( 'ContLang', 'config-wiki-language', $contLang,
+ $this->parent->getHelpBox( 'config-wiki-language-help' ) );
+ $this->addHTML( $s );
+ $this->endForm( 'continue', false );
+
+ return null;
+ }
+
+ /**
+ * Get a "<select>" for selecting languages.
+ *
+ * @param string $name
+ * @param string $label
+ * @param string $selectedCode
+ * @param string $helpHtml
+ *
+ * @return string
+ */
+ public function getLanguageSelector( $name, $label, $selectedCode, $helpHtml = '' ) {
+ global $wgExtraLanguageCodes;
+
+ $output = $helpHtml;
+
+ $select = new XmlSelect( $name, $name, $selectedCode );
+ $select->setAttribute( 'tabindex', $this->parent->nextTabIndex() );
+
+ $unwantedLanguageCodes = $wgExtraLanguageCodes +
+ LanguageCode::getDeprecatedCodeMapping();
+ $languages = Language::fetchLanguageNames();
+ ksort( $languages );
+ foreach ( $languages as $code => $lang ) {
+ if ( isset( $unwantedLanguageCodes[$code] ) ) {
+ continue;
+ }
+ $select->addOption( "$code - $lang", $code );
+ }
+
+ $output .= $select->getHTML();
+ return $this->parent->label( $label, $name, $output );
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerName.php b/www/wiki/includes/installer/WebInstallerName.php
new file mode 100644
index 00000000..81a107de
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerName.php
@@ -0,0 +1,263 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerName extends WebInstallerPage {
+
+ /**
+ * @return string
+ */
+ public function execute() {
+ $r = $this->parent->request;
+ if ( $r->wasPosted() ) {
+ if ( $this->submit() ) {
+ return 'continue';
+ }
+ }
+
+ $this->startForm();
+
+ // Encourage people to not name their site 'MediaWiki' by blanking the
+ // field. I think that was the intent with the original $GLOBALS['wgSitename']
+ // but these two always were the same so had the effect of making the
+ // installer forget $wgSitename when navigating back to this page.
+ if ( $this->getVar( 'wgSitename' ) == 'MediaWiki' ) {
+ $this->setVar( 'wgSitename', '' );
+ }
+
+ // Set wgMetaNamespace to something valid before we show the form.
+ // $wgMetaNamespace defaults to $wgSiteName which is 'MediaWiki'
+ $metaNS = $this->getVar( 'wgMetaNamespace' );
+ $this->setVar(
+ 'wgMetaNamespace',
+ wfMessage( 'config-ns-other-default' )->inContentLanguage()->text()
+ );
+
+ $pingbackInfo = ( new Pingback() )->getSystemInfo();
+ // Database isn't available in config yet, so take it
+ // from the installer
+ $pingbackInfo['database'] = $this->getVar( 'wgDBtype' );
+
+ $this->addHTML(
+ $this->parent->getTextBox( [
+ 'var' => 'wgSitename',
+ 'label' => 'config-site-name',
+ 'help' => $this->parent->getHelpBox( 'config-site-name-help' )
+ ] ) .
+ // getRadioSet() builds a set of labeled radio buttons.
+ // For grep: The following messages are used as the item labels:
+ // config-ns-site-name, config-ns-generic, config-ns-other
+ $this->parent->getRadioSet( [
+ 'var' => '_NamespaceType',
+ 'label' => 'config-project-namespace',
+ 'itemLabelPrefix' => 'config-ns-',
+ 'values' => [ 'site-name', 'generic', 'other' ],
+ 'commonAttribs' => [ 'class' => 'enableForOther',
+ 'rel' => 'config_wgMetaNamespace' ],
+ 'help' => $this->parent->getHelpBox( 'config-project-namespace-help' )
+ ] ) .
+ $this->parent->getTextBox( [
+ 'var' => 'wgMetaNamespace',
+ 'label' => '', // @todo Needs a label?
+ 'attribs' => [ 'readonly' => 'readonly', 'class' => 'enabledByOther' ]
+ ] ) .
+ $this->getFieldsetStart( 'config-admin-box' ) .
+ $this->parent->getTextBox( [
+ 'var' => '_AdminName',
+ 'label' => 'config-admin-name',
+ 'help' => $this->parent->getHelpBox( 'config-admin-help' )
+ ] ) .
+ $this->parent->getPasswordBox( [
+ 'var' => '_AdminPassword',
+ 'label' => 'config-admin-password',
+ ] ) .
+ $this->parent->getPasswordBox( [
+ 'var' => '_AdminPasswordConfirm',
+ 'label' => 'config-admin-password-confirm'
+ ] ) .
+ $this->parent->getTextBox( [
+ 'var' => '_AdminEmail',
+ 'attribs' => [
+ 'dir' => 'ltr',
+ ],
+ 'label' => 'config-admin-email',
+ 'help' => $this->parent->getHelpBox( 'config-admin-email-help' )
+ ] ) .
+ $this->parent->getCheckBox( [
+ 'var' => '_Subscribe',
+ 'label' => 'config-subscribe',
+ 'help' => $this->parent->getHelpBox( 'config-subscribe-help' )
+ ] ) .
+ $this->parent->getCheckBox( [
+ 'var' => 'wgPingback',
+ 'label' => 'config-pingback',
+ 'help' => $this->parent->getHelpBox(
+ 'config-pingback-help',
+ FormatJson::encode( $pingbackInfo, true )
+ ),
+ 'value' => true,
+ ] ) .
+ $this->getFieldsetEnd() .
+ $this->parent->getInfoBox( wfMessage( 'config-almost-done' )->text() ) .
+ // getRadioSet() builds a set of labeled radio buttons.
+ // For grep: The following messages are used as the item labels:
+ // config-optional-continue, config-optional-skip
+ $this->parent->getRadioSet( [
+ 'var' => '_SkipOptional',
+ 'itemLabelPrefix' => 'config-optional-',
+ 'values' => [ 'continue', 'skip' ]
+ ] )
+ );
+
+ // Restore the default value
+ $this->setVar( 'wgMetaNamespace', $metaNS );
+
+ $this->endForm();
+
+ return 'output';
+ }
+
+ /**
+ * @return bool
+ */
+ public function submit() {
+ global $wgPasswordPolicy;
+
+ $retVal = true;
+ $this->parent->setVarsFromRequest( [ 'wgSitename', '_NamespaceType',
+ '_AdminName', '_AdminPassword', '_AdminPasswordConfirm', '_AdminEmail',
+ '_Subscribe', '_SkipOptional', 'wgMetaNamespace', 'wgPingback' ] );
+
+ // Validate site name
+ if ( strval( $this->getVar( 'wgSitename' ) ) === '' ) {
+ $this->parent->showError( 'config-site-name-blank' );
+ $retVal = false;
+ }
+
+ // Fetch namespace
+ $nsType = $this->getVar( '_NamespaceType' );
+ if ( $nsType == 'site-name' ) {
+ $name = $this->getVar( 'wgSitename' );
+ // Sanitize for namespace
+ // This algorithm should match the JS one in WebInstallerOutput.php
+ $name = preg_replace( '/[\[\]\{\}|#<>%+? ]/', '_', $name );
+ $name = str_replace( '&', '&amp;', $name );
+ $name = preg_replace( '/__+/', '_', $name );
+ $name = ucfirst( trim( $name, '_' ) );
+ } elseif ( $nsType == 'generic' ) {
+ $name = wfMessage( 'config-ns-generic' )->text();
+ } else { // other
+ $name = $this->getVar( 'wgMetaNamespace' );
+ }
+
+ // Validate namespace
+ if ( strpos( $name, ':' ) !== false ) {
+ $good = false;
+ } else {
+ // Title-style validation
+ $title = Title::newFromText( $name );
+ if ( !$title ) {
+ $good = $nsType == 'site-name';
+ } else {
+ $name = $title->getDBkey();
+ $good = true;
+ }
+ }
+ if ( !$good ) {
+ $this->parent->showError( 'config-ns-invalid', $name );
+ $retVal = false;
+ }
+
+ // Make sure it won't conflict with any existing namespaces
+ global $wgContLang;
+ $nsIndex = $wgContLang->getNsIndex( $name );
+ if ( $nsIndex !== false && $nsIndex !== NS_PROJECT ) {
+ $this->parent->showError( 'config-ns-conflict', $name );
+ $retVal = false;
+ }
+
+ $this->setVar( 'wgMetaNamespace', $name );
+
+ // Validate username for creation
+ $name = $this->getVar( '_AdminName' );
+ if ( strval( $name ) === '' ) {
+ $this->parent->showError( 'config-admin-name-blank' );
+ $cname = $name;
+ $retVal = false;
+ } else {
+ $cname = User::getCanonicalName( $name, 'creatable' );
+ if ( $cname === false ) {
+ $this->parent->showError( 'config-admin-name-invalid', $name );
+ $retVal = false;
+ } else {
+ $this->setVar( '_AdminName', $cname );
+ }
+ }
+
+ // Validate password
+ $msg = false;
+ $pwd = $this->getVar( '_AdminPassword' );
+ $user = User::newFromName( $cname );
+ if ( $user ) {
+ $upp = new UserPasswordPolicy(
+ $wgPasswordPolicy['policies'],
+ $wgPasswordPolicy['checks']
+ );
+ $status = $upp->checkUserPasswordForGroups(
+ $user,
+ $pwd,
+ [ 'bureaucrat', 'sysop' ] // per Installer::createSysop()
+ );
+ $valid = $status->isGood() ? true : $status->getMessage();
+ } else {
+ $valid = 'config-admin-name-invalid';
+ }
+ if ( strval( $pwd ) === '' ) {
+ // Provide a more specific and helpful message if password field is left blank
+ $msg = 'config-admin-password-blank';
+ } elseif ( $pwd !== $this->getVar( '_AdminPasswordConfirm' ) ) {
+ $msg = 'config-admin-password-mismatch';
+ } elseif ( $valid !== true ) {
+ $msg = $valid;
+ }
+ if ( $msg !== false ) {
+ call_user_func( [ $this->parent, 'showError' ], $msg );
+ $this->setVar( '_AdminPassword', '' );
+ $this->setVar( '_AdminPasswordConfirm', '' );
+ $retVal = false;
+ }
+
+ // Validate e-mail if provided
+ $email = $this->getVar( '_AdminEmail' );
+ if ( $email && !Sanitizer::validateEmail( $email ) ) {
+ $this->parent->showError( 'config-admin-error-bademail' );
+ $retVal = false;
+ }
+ // If they asked to subscribe to mediawiki-announce but didn't give
+ // an e-mail, show an error. T31332
+ if ( !$email && $this->getVar( '_Subscribe' ) ) {
+ $this->parent->showError( 'config-subscribe-noemail' );
+ $retVal = false;
+ }
+
+ return $retVal;
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerOptions.php b/www/wiki/includes/installer/WebInstallerOptions.php
new file mode 100644
index 00000000..07378ab3
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerOptions.php
@@ -0,0 +1,490 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerOptions extends WebInstallerPage {
+
+ /**
+ * @return string|null
+ */
+ public function execute() {
+ if ( $this->getVar( '_SkipOptional' ) == 'skip' ) {
+ $this->submitSkins();
+ return 'skip';
+ }
+ if ( $this->parent->request->wasPosted() ) {
+ if ( $this->submit() ) {
+ return 'continue';
+ }
+ }
+
+ $emailwrapperStyle = $this->getVar( 'wgEnableEmail' ) ? '' : 'display: none';
+ $this->startForm();
+ $this->addHTML(
+ # User Rights
+ // getRadioSet() builds a set of labeled radio buttons.
+ // For grep: The following messages are used as the item labels:
+ // config-profile-wiki, config-profile-no-anon, config-profile-fishbowl, config-profile-private
+ $this->parent->getRadioSet( [
+ 'var' => '_RightsProfile',
+ 'label' => 'config-profile',
+ 'itemLabelPrefix' => 'config-profile-',
+ 'values' => array_keys( $this->parent->rightsProfiles ),
+ ] ) .
+ $this->parent->getInfoBox( wfMessage( 'config-profile-help' )->plain() ) .
+
+ # Licensing
+ // getRadioSet() builds a set of labeled radio buttons.
+ // For grep: The following messages are used as the item labels:
+ // config-license-cc-by, config-license-cc-by-sa, config-license-cc-by-nc-sa,
+ // config-license-cc-0, config-license-pd, config-license-gfdl,
+ // config-license-none, config-license-cc-choose
+ $this->parent->getRadioSet( [
+ 'var' => '_LicenseCode',
+ 'label' => 'config-license',
+ 'itemLabelPrefix' => 'config-license-',
+ 'values' => array_keys( $this->parent->licenses ),
+ 'commonAttribs' => [ 'class' => 'licenseRadio' ],
+ ] ) .
+ $this->getCCChooser() .
+ $this->parent->getHelpBox( 'config-license-help' ) .
+
+ # E-mail
+ $this->getFieldsetStart( 'config-email-settings' ) .
+ $this->parent->getCheckBox( [
+ 'var' => 'wgEnableEmail',
+ 'label' => 'config-enable-email',
+ 'attribs' => [ 'class' => 'showHideRadio', 'rel' => 'emailwrapper' ],
+ ] ) .
+ $this->parent->getHelpBox( 'config-enable-email-help' ) .
+ "<div id=\"emailwrapper\" style=\"$emailwrapperStyle\">" .
+ $this->parent->getTextBox( [
+ 'var' => 'wgPasswordSender',
+ 'label' => 'config-email-sender'
+ ] ) .
+ $this->parent->getHelpBox( 'config-email-sender-help' ) .
+ $this->parent->getCheckBox( [
+ 'var' => 'wgEnableUserEmail',
+ 'label' => 'config-email-user',
+ ] ) .
+ $this->parent->getHelpBox( 'config-email-user-help' ) .
+ $this->parent->getCheckBox( [
+ 'var' => 'wgEnotifUserTalk',
+ 'label' => 'config-email-usertalk',
+ ] ) .
+ $this->parent->getHelpBox( 'config-email-usertalk-help' ) .
+ $this->parent->getCheckBox( [
+ 'var' => 'wgEnotifWatchlist',
+ 'label' => 'config-email-watchlist',
+ ] ) .
+ $this->parent->getHelpBox( 'config-email-watchlist-help' ) .
+ $this->parent->getCheckBox( [
+ 'var' => 'wgEmailAuthentication',
+ 'label' => 'config-email-auth',
+ ] ) .
+ $this->parent->getHelpBox( 'config-email-auth-help' ) .
+ "</div>" .
+ $this->getFieldsetEnd()
+ );
+
+ $skins = $this->parent->findExtensions( 'skins' );
+ $skinHtml = $this->getFieldsetStart( 'config-skins' );
+
+ $skinNames = array_map( 'strtolower', array_keys( $skins ) );
+ $chosenSkinName = $this->getVar( 'wgDefaultSkin', $this->parent->getDefaultSkin( $skinNames ) );
+
+ if ( $skins ) {
+ $radioButtons = $this->parent->getRadioElements( [
+ 'var' => 'wgDefaultSkin',
+ 'itemLabels' => array_fill_keys( $skinNames, 'config-skins-use-as-default' ),
+ 'values' => $skinNames,
+ 'value' => $chosenSkinName,
+ ] );
+
+ foreach ( $skins as $skin => $info ) {
+ if ( isset( $info['screenshots'] ) ) {
+ $screenshotText = $this->makeScreenshotsLink( $skin, $info['screenshots'] );
+ } else {
+ $screenshotText = htmlspecialchars( $skin );
+ }
+ $skinHtml .=
+ '<div class="config-skins-item">' .
+ $this->parent->getCheckBox( [
+ 'var' => "skin-$skin",
+ 'rawtext' => $screenshotText,
+ 'value' => $this->getVar( "skin-$skin", true ), // all found skins enabled by default
+ ] ) .
+ '<div class="config-skins-use-as-default">' . $radioButtons[strtolower( $skin )] . '</div>' .
+ '</div>';
+ }
+ } else {
+ $skinHtml .=
+ $this->parent->getWarningBox( wfMessage( 'config-skins-missing' )->plain() ) .
+ Html::hidden( 'config_wgDefaultSkin', $chosenSkinName );
+ }
+
+ $skinHtml .= $this->parent->getHelpBox( 'config-skins-help' ) .
+ $this->getFieldsetEnd();
+ $this->addHTML( $skinHtml );
+
+ $extensions = $this->parent->findExtensions();
+
+ if ( $extensions ) {
+ $extHtml = $this->getFieldsetStart( 'config-extensions' );
+
+ foreach ( $extensions as $ext => $info ) {
+ $extHtml .= $this->parent->getCheckBox( [
+ 'var' => "ext-$ext",
+ 'rawtext' => $ext,
+ ] );
+ }
+
+ $extHtml .= $this->parent->getHelpBox( 'config-extensions-help' ) .
+ $this->getFieldsetEnd();
+ $this->addHTML( $extHtml );
+ }
+
+ // Having / in paths in Windows looks funny :)
+ $this->setVar( 'wgDeletedDirectory',
+ str_replace(
+ '/', DIRECTORY_SEPARATOR,
+ $this->getVar( 'wgDeletedDirectory' )
+ )
+ );
+
+ $uploadwrapperStyle = $this->getVar( 'wgEnableUploads' ) ? '' : 'display: none';
+ $this->addHTML(
+ # Uploading
+ $this->getFieldsetStart( 'config-upload-settings' ) .
+ $this->parent->getCheckBox( [
+ 'var' => 'wgEnableUploads',
+ 'label' => 'config-upload-enable',
+ 'attribs' => [ 'class' => 'showHideRadio', 'rel' => 'uploadwrapper' ],
+ 'help' => $this->parent->getHelpBox( 'config-upload-help' )
+ ] ) .
+ '<div id="uploadwrapper" style="' . $uploadwrapperStyle . '">' .
+ $this->parent->getTextBox( [
+ 'var' => 'wgDeletedDirectory',
+ 'label' => 'config-upload-deleted',
+ 'attribs' => [ 'dir' => 'ltr' ],
+ 'help' => $this->parent->getHelpBox( 'config-upload-deleted-help' )
+ ] ) .
+ '</div>' .
+ $this->parent->getTextBox( [
+ 'var' => 'wgLogo',
+ 'label' => 'config-logo',
+ 'attribs' => [ 'dir' => 'ltr' ],
+ 'help' => $this->parent->getHelpBox( 'config-logo-help' )
+ ] )
+ );
+ $this->addHTML(
+ $this->parent->getCheckBox( [
+ 'var' => 'wgUseInstantCommons',
+ 'label' => 'config-instantcommons',
+ 'help' => $this->parent->getHelpBox( 'config-instantcommons-help' )
+ ] ) .
+ $this->getFieldsetEnd()
+ );
+
+ $caches = [ 'none' ];
+ $cachevalDefault = 'none';
+
+ if ( count( $this->getVar( '_Caches' ) ) ) {
+ // A CACHE_ACCEL implementation is available
+ $caches[] = 'accel';
+ $cachevalDefault = 'accel';
+ }
+ $caches[] = 'memcached';
+
+ // We'll hide/show this on demand when the value changes, see config.js.
+ $cacheval = $this->getVar( '_MainCacheType' );
+ if ( !$cacheval ) {
+ // We need to set a default here; but don't hardcode it
+ // or we lose it every time we reload the page for validation
+ // or going back!
+ $cacheval = $cachevalDefault;
+ }
+ $hidden = ( $cacheval == 'memcached' ) ? '' : 'display: none';
+ $this->addHTML(
+ # Advanced settings
+ $this->getFieldsetStart( 'config-advanced-settings' ) .
+ # Object cache settings
+ // getRadioSet() builds a set of labeled radio buttons.
+ // For grep: The following messages are used as the item labels:
+ // config-cache-none, config-cache-accel, config-cache-memcached
+ $this->parent->getRadioSet( [
+ 'var' => '_MainCacheType',
+ 'label' => 'config-cache-options',
+ 'itemLabelPrefix' => 'config-cache-',
+ 'values' => $caches,
+ 'value' => $cacheval,
+ ] ) .
+ $this->parent->getHelpBox( 'config-cache-help' ) .
+ "<div id=\"config-memcachewrapper\" style=\"$hidden\">" .
+ $this->parent->getTextArea( [
+ 'var' => '_MemCachedServers',
+ 'label' => 'config-memcached-servers',
+ 'help' => $this->parent->getHelpBox( 'config-memcached-help' )
+ ] ) .
+ '</div>' .
+ $this->getFieldsetEnd()
+ );
+ $this->endForm();
+
+ return null;
+ }
+
+ private function makeScreenshotsLink( $name, $screenshots ) {
+ global $wgLang;
+ if ( count( $screenshots ) > 1 ) {
+ $links = [];
+ $counter = 1;
+ foreach ( $screenshots as $shot ) {
+ $links[] = Html::element(
+ 'a',
+ [ 'href' => $shot ],
+ $wgLang->formatNum( $counter++ )
+ );
+ }
+ return wfMessage( 'config-skins-screenshots' )
+ ->rawParams( $name, $wgLang->commaList( $links ) )
+ ->escaped();
+ } else {
+ $link = Html::element(
+ 'a',
+ [ 'href' => $screenshots[0] ],
+ wfMessage( 'config-screenshot' )->text()
+ );
+ return wfMessage( 'config-skins-screenshot', $name )->rawParams( $link )->escaped();
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getCCPartnerUrl() {
+ $server = $this->getVar( 'wgServer' );
+ $exitUrl = $server . $this->parent->getUrl( [
+ 'page' => 'Options',
+ 'SubmitCC' => 'indeed',
+ 'config__LicenseCode' => 'cc',
+ 'config_wgRightsUrl' => '[license_url]',
+ 'config_wgRightsText' => '[license_name]',
+ 'config_wgRightsIcon' => '[license_button]',
+ ] );
+ $styleUrl = $server . dirname( dirname( $this->parent->getUrl() ) ) .
+ '/mw-config/config-cc.css';
+ $iframeUrl = '//creativecommons.org/license/?' .
+ wfArrayToCgi( [
+ 'partner' => 'MediaWiki',
+ 'exit_url' => $exitUrl,
+ 'lang' => $this->getVar( '_UserLang' ),
+ 'stylesheet' => $styleUrl,
+ ] );
+
+ return $iframeUrl;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCCChooser() {
+ $iframeAttribs = [
+ 'class' => 'config-cc-iframe',
+ 'name' => 'config-cc-iframe',
+ 'id' => 'config-cc-iframe',
+ 'frameborder' => 0,
+ 'width' => '100%',
+ 'height' => '100%',
+ ];
+ if ( $this->getVar( '_CCDone' ) ) {
+ $iframeAttribs['src'] = $this->parent->getUrl( [ 'ShowCC' => 'yes' ] );
+ } else {
+ $iframeAttribs['src'] = $this->getCCPartnerUrl();
+ }
+ $wrapperStyle = ( $this->getVar( '_LicenseCode' ) == 'cc-choose' ) ? '' : 'display: none';
+
+ return "<div class=\"config-cc-wrapper\" id=\"config-cc-wrapper\" style=\"$wrapperStyle\">\n" .
+ Html::element( 'iframe', $iframeAttribs, '', false /* not short */ ) .
+ "</div>\n";
+ }
+
+ /**
+ * @return string
+ */
+ public function getCCDoneBox() {
+ $js = "parent.document.getElementById('config-cc-wrapper').style.height = '$1';";
+ // If you change this height, also change it in config.css
+ $expandJs = str_replace( '$1', '54em', $js );
+ $reduceJs = str_replace( '$1', '70px', $js );
+
+ return '<p>' .
+ Html::element( 'img', [ 'src' => $this->getVar( 'wgRightsIcon' ) ] ) .
+ '&#160;&#160;' .
+ htmlspecialchars( $this->getVar( 'wgRightsText' ) ) .
+ "</p>\n" .
+ "<p style=\"text-align: center;\">" .
+ Html::element( 'a',
+ [
+ 'href' => $this->getCCPartnerUrl(),
+ 'onclick' => $expandJs,
+ ],
+ wfMessage( 'config-cc-again' )->text()
+ ) .
+ "</p>\n" .
+ "<script>\n" .
+ # Reduce the wrapper div height
+ htmlspecialchars( $reduceJs ) .
+ "\n" .
+ "</script>\n";
+ }
+
+ public function submitCC() {
+ $newValues = $this->parent->setVarsFromRequest(
+ [ 'wgRightsUrl', 'wgRightsText', 'wgRightsIcon' ] );
+ if ( count( $newValues ) != 3 ) {
+ $this->parent->showError( 'config-cc-error' );
+
+ return;
+ }
+ $this->setVar( '_CCDone', true );
+ $this->addHTML( $this->getCCDoneBox() );
+ }
+
+ /**
+ * If the user skips this installer page, we still need to set up the default skins, but ignore
+ * everything else.
+ *
+ * @return bool
+ */
+ public function submitSkins() {
+ $skins = array_keys( $this->parent->findExtensions( 'skins' ) );
+ $this->parent->setVar( '_Skins', $skins );
+
+ if ( $skins ) {
+ $skinNames = array_map( 'strtolower', $skins );
+ $this->parent->setVar( 'wgDefaultSkin', $this->parent->getDefaultSkin( $skinNames ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * @return bool
+ */
+ public function submit() {
+ $this->parent->setVarsFromRequest( [ '_RightsProfile', '_LicenseCode',
+ 'wgEnableEmail', 'wgPasswordSender', 'wgEnableUploads', 'wgLogo',
+ 'wgEnableUserEmail', 'wgEnotifUserTalk', 'wgEnotifWatchlist',
+ 'wgEmailAuthentication', '_MainCacheType', '_MemCachedServers',
+ 'wgUseInstantCommons', 'wgDefaultSkin' ] );
+
+ $retVal = true;
+
+ if ( !array_key_exists( $this->getVar( '_RightsProfile' ), $this->parent->rightsProfiles ) ) {
+ reset( $this->parent->rightsProfiles );
+ $this->setVar( '_RightsProfile', key( $this->parent->rightsProfiles ) );
+ }
+
+ $code = $this->getVar( '_LicenseCode' );
+ if ( $code == 'cc-choose' ) {
+ if ( !$this->getVar( '_CCDone' ) ) {
+ $this->parent->showError( 'config-cc-not-chosen' );
+ $retVal = false;
+ }
+ } elseif ( array_key_exists( $code, $this->parent->licenses ) ) {
+ // Messages:
+ // config-license-cc-by, config-license-cc-by-sa, config-license-cc-by-nc-sa,
+ // config-license-cc-0, config-license-pd, config-license-gfdl, config-license-none,
+ // config-license-cc-choose
+ $entry = $this->parent->licenses[$code];
+ if ( isset( $entry['text'] ) ) {
+ $this->setVar( 'wgRightsText', $entry['text'] );
+ } else {
+ $this->setVar( 'wgRightsText', wfMessage( 'config-license-' . $code )->text() );
+ }
+ $this->setVar( 'wgRightsUrl', $entry['url'] );
+ $this->setVar( 'wgRightsIcon', $entry['icon'] );
+ } else {
+ $this->setVar( 'wgRightsText', '' );
+ $this->setVar( 'wgRightsUrl', '' );
+ $this->setVar( 'wgRightsIcon', '' );
+ }
+
+ $skinsAvailable = array_keys( $this->parent->findExtensions( 'skins' ) );
+ $skinsToInstall = [];
+ foreach ( $skinsAvailable as $skin ) {
+ $this->parent->setVarsFromRequest( [ "skin-$skin" ] );
+ if ( $this->getVar( "skin-$skin" ) ) {
+ $skinsToInstall[] = $skin;
+ }
+ }
+ $this->parent->setVar( '_Skins', $skinsToInstall );
+
+ if ( !$skinsToInstall && $skinsAvailable ) {
+ $this->parent->showError( 'config-skins-must-enable-some' );
+ $retVal = false;
+ }
+ $defaultSkin = $this->getVar( 'wgDefaultSkin' );
+ $skinsToInstallLowercase = array_map( 'strtolower', $skinsToInstall );
+ if ( $skinsToInstall && array_search( $defaultSkin, $skinsToInstallLowercase ) === false ) {
+ $this->parent->showError( 'config-skins-must-enable-default' );
+ $retVal = false;
+ }
+
+ $extsAvailable = array_keys( $this->parent->findExtensions() );
+ $extsToInstall = [];
+ foreach ( $extsAvailable as $ext ) {
+ $this->parent->setVarsFromRequest( [ "ext-$ext" ] );
+ if ( $this->getVar( "ext-$ext" ) ) {
+ $extsToInstall[] = $ext;
+ }
+ }
+ $this->parent->setVar( '_Extensions', $extsToInstall );
+
+ if ( $this->getVar( '_MainCacheType' ) == 'memcached' ) {
+ $memcServers = explode( "\n", $this->getVar( '_MemCachedServers' ) );
+ if ( !$memcServers ) {
+ $this->parent->showError( 'config-memcache-needservers' );
+ $retVal = false;
+ }
+
+ foreach ( $memcServers as $server ) {
+ $memcParts = explode( ":", $server, 2 );
+ if ( !isset( $memcParts[0] )
+ || ( !IP::isValid( $memcParts[0] )
+ && ( gethostbyname( $memcParts[0] ) == $memcParts[0] ) )
+ ) {
+ $this->parent->showError( 'config-memcache-badip', $memcParts[0] );
+ $retVal = false;
+ } elseif ( !isset( $memcParts[1] ) ) {
+ $this->parent->showError( 'config-memcache-noport', $memcParts[0] );
+ $retVal = false;
+ } elseif ( $memcParts[1] < 1 || $memcParts[1] > 65535 ) {
+ $this->parent->showError( 'config-memcache-badport', 1, 65535 );
+ $retVal = false;
+ }
+ }
+ }
+
+ return $retVal;
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerOutput.php b/www/wiki/includes/installer/WebInstallerOutput.php
new file mode 100644
index 00000000..e4eb255b
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerOutput.php
@@ -0,0 +1,348 @@
+<?php
+/**
+ * Output handler for the web installer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+/**
+ * Output class modelled on OutputPage.
+ *
+ * I've opted to use a distinct class rather than derive from OutputPage here in
+ * the interests of separation of concerns: if we used a subclass, there would be
+ * quite a lot of things you could do in OutputPage that would break the installer,
+ * that wouldn't be immediately obvious.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+class WebInstallerOutput {
+
+ /**
+ * The WebInstaller object this WebInstallerOutput is used by.
+ *
+ * @var WebInstaller
+ */
+ public $parent;
+
+ /**
+ * Buffered contents that haven't been output yet
+ * @var string
+ */
+ private $contents = '';
+
+ /**
+ * Has the header (or short header) been output?
+ * @var bool
+ */
+ private $headerDone = false;
+
+ /**
+ * @var string
+ */
+ public $redirectTarget;
+
+ /**
+ * Does the current page need to allow being used as a frame?
+ * If not, X-Frame-Options will be output to forbid it.
+ *
+ * @var bool
+ */
+ public $allowFrames = false;
+
+ /**
+ * Whether to use the limited header (used during CC license callbacks)
+ * @var bool
+ */
+ private $useShortHeader = false;
+
+ /**
+ * @param WebInstaller $parent
+ */
+ public function __construct( WebInstaller $parent ) {
+ $this->parent = $parent;
+ }
+
+ /**
+ * @param string $html
+ */
+ public function addHTML( $html ) {
+ $this->contents .= $html;
+ $this->flush();
+ }
+
+ /**
+ * @param string $text
+ */
+ public function addWikiText( $text ) {
+ $this->addHTML( $this->parent->parse( $text ) );
+ }
+
+ /**
+ * @param string $html
+ */
+ public function addHTMLNoFlush( $html ) {
+ $this->contents .= $html;
+ }
+
+ /**
+ * @param string $url
+ *
+ * @throws MWException
+ */
+ public function redirect( $url ) {
+ if ( $this->headerDone ) {
+ throw new MWException( __METHOD__ . ' called after sending headers' );
+ }
+ $this->redirectTarget = $url;
+ }
+
+ public function output() {
+ $this->flush();
+
+ if ( !$this->redirectTarget ) {
+ $this->outputFooter();
+ }
+ }
+
+ /**
+ * Get the stylesheet of the MediaWiki skin.
+ *
+ * @return string
+ */
+ public function getCSS() {
+ global $wgStyleDirectory;
+
+ $moduleNames = [
+ // See SkinTemplate::setupSkinUserCss
+ 'mediawiki.legacy.shared',
+ // See Vector::setupSkinUserCss
+ 'mediawiki.skinning.interface',
+ ];
+
+ $resourceLoader = new ResourceLoader();
+
+ if ( file_exists( "$wgStyleDirectory/Vector/skin.json" ) ) {
+ // Force loading Vector skin if available as a fallback skin
+ // for whatever ResourceLoader wants to have as the default.
+ $registry = new ExtensionRegistry();
+ $data = $registry->readFromQueue( [
+ "$wgStyleDirectory/Vector/skin.json" => 1,
+ ] );
+ if ( isset( $data['globals']['wgResourceModules'] ) ) {
+ $resourceLoader->register( $data['globals']['wgResourceModules'] );
+ }
+
+ $moduleNames[] = 'skins.vector.styles';
+ }
+
+ $moduleNames[] = 'mediawiki.legacy.config';
+
+ $rlContext = new ResourceLoaderContext( $resourceLoader, new FauxRequest( [
+ 'debug' => 'true',
+ 'lang' => $this->getLanguageCode(),
+ 'only' => 'styles',
+ ] ) );
+
+ $styles = [];
+ foreach ( $moduleNames as $moduleName ) {
+ /** @var ResourceLoaderFileModule $module */
+ $module = $resourceLoader->getModule( $moduleName );
+ if ( !$module ) {
+ // T98043: Don't fatal, but it won't look as pretty.
+ continue;
+ }
+
+ // Based on: ResourceLoaderFileModule::getStyles (without the DB query)
+ $styles = array_merge( $styles, ResourceLoader::makeCombinedStyles(
+ $module->readStyleFiles(
+ $module->getStyleFiles( $rlContext ),
+ $module->getFlip( $rlContext ),
+ $rlContext
+ ) ) );
+ }
+
+ return implode( "\n", $styles );
+ }
+
+ /**
+ * "<link>" to index.php?css=1 for the "<head>"
+ *
+ * @return string
+ */
+ private function getCssUrl() {
+ return Html::linkedStyle( $_SERVER['PHP_SELF'] . '?css=1' );
+ }
+
+ public function useShortHeader( $use = true ) {
+ $this->useShortHeader = $use;
+ }
+
+ public function allowFrames( $allow = true ) {
+ $this->allowFrames = $allow;
+ }
+
+ public function flush() {
+ if ( !$this->headerDone ) {
+ $this->outputHeader();
+ }
+ if ( !$this->redirectTarget && strlen( $this->contents ) ) {
+ echo $this->contents;
+ flush();
+ $this->contents = '';
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function getDir() {
+ global $wgLang;
+
+ return is_object( $wgLang ) ? $wgLang->getDir() : 'ltr';
+ }
+
+ /**
+ * @return string
+ */
+ public function getLanguageCode() {
+ global $wgLang;
+
+ return is_object( $wgLang ) ? $wgLang->getCode() : 'en';
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getHeadAttribs() {
+ return [
+ 'dir' => $this->getDir(),
+ 'lang' => wfBCP47( $this->getLanguageCode() ),
+ ];
+ }
+
+ /**
+ * Get whether the header has been output
+ *
+ * @return bool
+ */
+ public function headerDone() {
+ return $this->headerDone;
+ }
+
+ public function outputHeader() {
+ $this->headerDone = true;
+ $this->parent->request->response()->header( 'Content-Type: text/html; charset=utf-8' );
+
+ if ( !$this->allowFrames ) {
+ $this->parent->request->response()->header( 'X-Frame-Options: DENY' );
+ }
+
+ if ( $this->redirectTarget ) {
+ $this->parent->request->response()->header( 'Location: ' . $this->redirectTarget );
+
+ return;
+ }
+
+ if ( $this->useShortHeader ) {
+ $this->outputShortHeader();
+
+ return;
+ }
+?>
+<?php echo Html::htmlHeader( $this->getHeadAttribs() ); ?>
+
+<head>
+ <meta name="robots" content="noindex, nofollow" />
+ <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
+ <title><?php $this->outputTitle(); ?></title>
+ <?php echo $this->getCssUrl() . "\n"; ?>
+ <?php echo $this->getJQuery() . "\n"; ?>
+ <?php echo Html::linkedScript( 'config.js' ) . "\n"; ?>
+</head>
+
+<?php echo Html::openElement( 'body', [ 'class' => $this->getDir() ] ) . "\n"; ?>
+<div id="mw-page-base"></div>
+<div id="mw-head-base"></div>
+<div id="content" class="mw-body">
+<div id="bodyContent" class="mw-body-content">
+
+<h1><?php $this->outputTitle(); ?></h1>
+<?php
+ }
+
+ public function outputFooter() {
+ if ( $this->useShortHeader ) {
+ echo Html::closeElement( 'body' ) . Html::closeElement( 'html' );
+
+ return;
+ }
+?>
+
+</div></div>
+
+<div id="mw-panel">
+ <div class="portal" id="p-logo">
+ <a style="background-image: url(images/installer-logo.png);"
+ href="https://www.mediawiki.org/"
+ title="Main Page"></a>
+ </div>
+<?php
+ $message = wfMessage( 'config-sidebar' )->plain();
+ foreach ( explode( '----', $message ) as $section ) {
+ echo '<div class="portal"><div class="body">';
+ echo $this->parent->parse( $section, true );
+ echo '</div></div>';
+ }
+?>
+</div>
+
+<?php
+ echo Html::closeElement( 'body' ) . Html::closeElement( 'html' );
+ }
+
+ public function outputShortHeader() {
+?>
+<?php echo Html::htmlHeader( $this->getHeadAttribs() ); ?>
+<head>
+ <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
+ <meta name="robots" content="noindex, nofollow" />
+ <title><?php $this->outputTitle(); ?></title>
+ <?php echo $this->getCssUrl() . "\n"; ?>
+ <?php echo $this->getJQuery(); ?>
+ <?php echo Html::linkedScript( 'config.js' ); ?>
+</head>
+
+<body style="background-image: none">
+<?php
+ }
+
+ public function outputTitle() {
+ global $wgVersion;
+ echo wfMessage( 'config-title', $wgVersion )->escaped();
+ }
+
+ /**
+ * @return string
+ */
+ public function getJQuery() {
+ return Html::linkedScript( "../resources/lib/jquery/jquery.js" );
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerPage.php b/www/wiki/includes/installer/WebInstallerPage.php
new file mode 100644
index 00000000..3aad6f87
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerPage.php
@@ -0,0 +1,207 @@
+<?php
+/**
+ * Base code for web installer pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+/**
+ * Abstract class to define pages for the web installer.
+ *
+ * @ingroup Deployment
+ * @since 1.17
+ */
+abstract class WebInstallerPage {
+
+ /**
+ * The WebInstaller object this WebInstallerPage belongs to.
+ *
+ * @var WebInstaller
+ */
+ public $parent;
+
+ /**
+ * @return string
+ */
+ abstract public function execute();
+
+ /**
+ * @param WebInstaller $parent
+ */
+ public function __construct( WebInstaller $parent ) {
+ $this->parent = $parent;
+ }
+
+ /**
+ * Is this a slow-running page in the installer? If so, WebInstaller will
+ * set_time_limit(0) before calling execute(). Right now this only applies
+ * to Install and Upgrade pages
+ *
+ * @return bool Always false in this default implementation.
+ */
+ public function isSlow() {
+ return false;
+ }
+
+ /**
+ * @param string $html
+ */
+ public function addHTML( $html ) {
+ $this->parent->output->addHTML( $html );
+ }
+
+ public function startForm() {
+ $this->addHTML(
+ "<div class=\"config-section\">\n" .
+ Html::openElement(
+ 'form',
+ [
+ 'method' => 'post',
+ 'action' => $this->parent->getUrl( [ 'page' => $this->getName() ] )
+ ]
+ ) . "\n"
+ );
+ }
+
+ /**
+ * @param string|bool $continue
+ * @param string|bool $back
+ */
+ public function endForm( $continue = 'continue', $back = 'back' ) {
+ $s = "<div class=\"config-submit\">\n";
+ $id = $this->getId();
+
+ if ( $id === false ) {
+ $s .= Html::hidden( 'lastPage', $this->parent->request->getVal( 'lastPage' ) );
+ }
+
+ if ( $continue ) {
+ // Fake submit button for enter keypress (T28267)
+ // Messages: config-continue, config-restart, config-regenerate
+ $s .= Xml::submitButton(
+ wfMessage( "config-$continue" )->text(),
+ [
+ 'name' => "enter-$continue",
+ 'style' => 'width:0;border:0;height:0;padding:0'
+ ]
+ ) . "\n";
+ }
+
+ if ( $back ) {
+ // Message: config-back
+ $s .= Xml::submitButton(
+ wfMessage( "config-$back" )->text(),
+ [
+ 'name' => "submit-$back",
+ 'tabindex' => $this->parent->nextTabIndex()
+ ]
+ ) . "\n";
+ }
+
+ if ( $continue ) {
+ // Messages: config-continue, config-restart, config-regenerate
+ $s .= Xml::submitButton(
+ wfMessage( "config-$continue" )->text(),
+ [
+ 'name' => "submit-$continue",
+ 'tabindex' => $this->parent->nextTabIndex(),
+ ]
+ ) . "\n";
+ }
+
+ $s .= "</div></form></div>\n";
+ $this->addHTML( $s );
+ }
+
+ /**
+ * @return string
+ */
+ public function getName() {
+ return str_replace( 'WebInstaller', '', static::class );
+ }
+
+ /**
+ * @return string
+ */
+ protected function getId() {
+ return array_search( $this->getName(), $this->parent->pageSequence );
+ }
+
+ /**
+ * @param string $var
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function getVar( $var, $default = null ) {
+ return $this->parent->getVar( $var, $default );
+ }
+
+ /**
+ * @param string $name
+ * @param mixed $value
+ */
+ public function setVar( $name, $value ) {
+ $this->parent->setVar( $name, $value );
+ }
+
+ /**
+ * Get the starting tags of a fieldset.
+ *
+ * @param string $legend Message name
+ *
+ * @return string
+ */
+ protected function getFieldsetStart( $legend ) {
+ return "\n<fieldset><legend>" . wfMessage( $legend )->escaped() . "</legend>\n";
+ }
+
+ /**
+ * Get the end tag of a fieldset.
+ *
+ * @return string
+ */
+ protected function getFieldsetEnd() {
+ return "</fieldset>\n";
+ }
+
+ /**
+ * Opens a textarea used to display the progress of a long operation
+ */
+ protected function startLiveBox() {
+ $this->addHTML(
+ '<div id="config-spinner" style="display:none;">' .
+ '<img src="images/ajax-loader.gif" /></div>' .
+ '<script>jQuery( "#config-spinner" ).show();</script>' .
+ '<div id="config-live-log">' .
+ '<textarea name="LiveLog" rows="10" cols="30" readonly="readonly">'
+ );
+ $this->parent->output->flush();
+ }
+
+ /**
+ * Opposite to WebInstallerPage::startLiveBox
+ */
+ protected function endLiveBox() {
+ $this->addHTML( '</textarea></div>
+<script>jQuery( "#config-spinner" ).hide()</script>' );
+ $this->parent->output->flush();
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerReadme.php b/www/wiki/includes/installer/WebInstallerReadme.php
new file mode 100644
index 00000000..97c9f834
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerReadme.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerReadme extends WebInstallerDocument {
+
+ /**
+ * @return string
+ */
+ protected function getFileName() {
+ return 'README';
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerReleaseNotes.php b/www/wiki/includes/installer/WebInstallerReleaseNotes.php
new file mode 100644
index 00000000..c0a8d71f
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerReleaseNotes.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerReleaseNotes extends WebInstallerDocument {
+
+ /**
+ * @throws MWException
+ * @return string
+ */
+ protected function getFileName() {
+ global $wgVersion;
+
+ if ( !preg_match( '/^(\d+)\.(\d+).*/i', $wgVersion, $result ) ) {
+ throw new MWException( 'Variable $wgVersion has an invalid value.' );
+ }
+
+ return 'RELEASE-NOTES-' . $result[1] . '.' . $result[2];
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerRestart.php b/www/wiki/includes/installer/WebInstallerRestart.php
new file mode 100644
index 00000000..be55c32f
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerRestart.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerRestart extends WebInstallerPage {
+
+ /**
+ * @return string|null
+ */
+ public function execute() {
+ $r = $this->parent->request;
+ if ( $r->wasPosted() ) {
+ $really = $r->getVal( 'submit-restart' );
+ if ( $really ) {
+ $this->parent->reset();
+ }
+
+ return 'continue';
+ }
+
+ $this->startForm();
+ $s = $this->parent->getWarningBox( wfMessage( 'config-help-restart' )->plain() );
+ $this->addHTML( $s );
+ $this->endForm( 'restart' );
+
+ return null;
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerUpgrade.php b/www/wiki/includes/installer/WebInstallerUpgrade.php
new file mode 100644
index 00000000..bf732a4b
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerUpgrade.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerUpgrade extends WebInstallerPage {
+
+ /**
+ * @return bool Always true.
+ */
+ public function isSlow() {
+ return true;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function execute() {
+ if ( $this->getVar( '_UpgradeDone' ) ) {
+ // Allow regeneration of LocalSettings.php, unless we are working
+ // from a pre-existing LocalSettings.php file and we want to avoid
+ // leaking its contents
+ if ( $this->parent->request->wasPosted() && !$this->getVar( '_ExistingDBSettings' ) ) {
+ // Done message acknowledged
+ return 'continue';
+ } else {
+ // Back button click
+ // Show the done message again
+ // Make them click back again if they want to do the upgrade again
+ $this->showDoneMessage();
+
+ return 'output';
+ }
+ }
+
+ // wgDBtype is generally valid here because otherwise the previous page
+ // (connect) wouldn't have declared its happiness
+ $type = $this->getVar( 'wgDBtype' );
+ $installer = $this->parent->getDBInstaller( $type );
+
+ if ( !$installer->needsUpgrade() ) {
+ return 'skip';
+ }
+
+ if ( $this->parent->request->wasPosted() ) {
+ $installer->preUpgrade();
+
+ $this->startLiveBox();
+ $result = $installer->doUpgrade();
+ $this->endLiveBox();
+
+ if ( $result ) {
+ // If they're going to possibly regenerate LocalSettings, we
+ // need to create the upgrade/secret keys. T28481
+ if ( !$this->getVar( '_ExistingDBSettings' ) ) {
+ $this->parent->generateKeys();
+ }
+ $this->setVar( '_UpgradeDone', true );
+ $this->showDoneMessage();
+
+ return 'output';
+ }
+ }
+
+ $this->startForm();
+ $this->addHTML( $this->parent->getInfoBox(
+ wfMessage( 'config-can-upgrade', $GLOBALS['wgVersion'] )->plain() ) );
+ $this->endForm();
+
+ return null;
+ }
+
+ public function showDoneMessage() {
+ $this->startForm();
+ $regenerate = !$this->getVar( '_ExistingDBSettings' );
+ if ( $regenerate ) {
+ $msg = 'config-upgrade-done';
+ } else {
+ $msg = 'config-upgrade-done-no-regenerate';
+ }
+ $this->parent->disableLinkPopups();
+ $this->addHTML(
+ $this->parent->getInfoBox(
+ wfMessage( $msg,
+ $this->getVar( 'wgServer' ) .
+ $this->getVar( 'wgScriptPath' ) . '/index.php'
+ )->plain(), 'tick-32.png'
+ )
+ );
+ $this->parent->restoreLinkPopups();
+ $this->endForm( $regenerate ? 'regenerate' : false, false );
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerUpgradeDoc.php b/www/wiki/includes/installer/WebInstallerUpgradeDoc.php
new file mode 100644
index 00000000..f8fa7363
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerUpgradeDoc.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerUpgradeDoc extends WebInstallerDocument {
+
+ /**
+ * @return string
+ */
+ protected function getFileName() {
+ return 'UPGRADE';
+ }
+
+}
diff --git a/www/wiki/includes/installer/WebInstallerWelcome.php b/www/wiki/includes/installer/WebInstallerWelcome.php
new file mode 100644
index 00000000..44ff0bb2
--- /dev/null
+++ b/www/wiki/includes/installer/WebInstallerWelcome.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Deployment
+ */
+
+class WebInstallerWelcome extends WebInstallerPage {
+
+ /**
+ * @return string
+ */
+ public function execute() {
+ if ( $this->parent->request->wasPosted() ) {
+ if ( $this->getVar( '_Environment' ) ) {
+ return 'continue';
+ }
+ }
+ $this->parent->output->addWikiText( wfMessage( 'config-welcome' )->plain() );
+ $status = $this->parent->doEnvironmentChecks();
+ if ( $status->isGood() ) {
+ $this->parent->output->addHTML( '<span class="success-message">' .
+ wfMessage( 'config-env-good' )->escaped() . '</span>' );
+ $this->parent->output->addWikiText( wfMessage( 'config-copyright',
+ SpecialVersion::getCopyrightAndAuthorList() )->plain() );
+ $this->startForm();
+ $this->endForm();
+ } else {
+ $this->parent->showStatusMessage( $status );
+ }
+
+ return '';
+ }
+
+}
diff --git a/www/wiki/includes/installer/i18n/af.json b/www/wiki/includes/installer/i18n/af.json
new file mode 100644
index 00000000..6b337931
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/af.json
@@ -0,0 +1,151 @@
+{
+ "@metadata": {
+ "authors": [
+ "Naudefj",
+ "Winstonza"
+ ]
+ },
+ "config-desc": "Die Installasieprogram vir MediaWiki",
+ "config-title": "Installasie MediaWiki $1",
+ "config-information": "Inligting",
+ "config-localsettings-key": "Opgradeer-sleutel:",
+ "config-localsettings-badkey": "Die sleutel wat u verskaf het is verkeerd.",
+ "config-session-error": "Fout met begin van sessie: $1",
+ "config-no-session": "U sessiedata is verlore!\nKontroleer u php.ini en maak seker dat <code>session.save_path</code> na 'n geldige gids wys.",
+ "config-your-language": "U taal:",
+ "config-your-language-help": "Kies 'n taal om tydens die installasieproses te gebruik.",
+ "config-wiki-language": "Wiki se taal:",
+ "config-wiki-language-help": "Kies die taal waarin die wiki hoofsaaklik geskryf sal word.",
+ "config-back": "← Terug",
+ "config-continue": "Gaan voort →",
+ "config-page-language": "Taal",
+ "config-page-welcome": "Welkom by MediaWiki!",
+ "config-page-dbconnect": "Konnekteer na die databasis",
+ "config-page-upgrade": "Opgradeer 'n bestaande installasie",
+ "config-page-dbsettings": "Databasis-instellings",
+ "config-page-name": "Naam",
+ "config-page-options": "Opsies",
+ "config-page-install": "Installeer",
+ "config-page-complete": "Voltooi!",
+ "config-page-restart": "Herbegin installasie",
+ "config-page-readme": "Lees my",
+ "config-page-releasenotes": "Vrystellingsnotas",
+ "config-page-copying": "Besig met kopiëring",
+ "config-page-upgradedoc": "Besig met opgradering",
+ "config-page-existingwiki": "Bestaande wiki",
+ "config-restart": "Ja, herbegin dit",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki tuisblad]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Gebruikershandleiding] (Engelstalig)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrateurshandleiding] (Engelstalig)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Algemene vrae] (Engelstalig)\n----\n* <doclink href=Readme>Lees my</doclink>\n* <doclink href=ReleaseNotes>Vrystellingsnotas</doclink>\n* <doclink href=Copying>Kopiëring</doclink>\n* <doclink href=UpgradeDoc>Opgradering</doclink>",
+ "config-env-good": "Die omgewing is gekontroleer.\nU kan MediaWiki installeer.",
+ "config-env-bad": "Die omgewing is gekontroleer.\nU kan nie MediaWiki installeer nie.</span>",
+ "config-env-php": "PHP $1 is tans geïnstalleer.",
+ "config-no-db": "Kon nie 'n geskikte databasisdrywer vind nie!",
+ "config-memory-raised": "PHP se <code>memory_limit</code> is $1, en is verhoog tot $2.",
+ "config-memory-bad": "'''Waarskuwing:''' PHP se <code>memory_limit</code> is $1.\nDit is waarskynlik te laag.\nDie installasie mag moontlik faal!",
+ "config-xcache": "[Http://trac.lighttpd.net/xcache/ XCache] is geïnstalleer",
+ "config-apc": "[Http://www.php.net/apc APC] is geïnstalleer",
+ "config-wincache": "[Http://www.iis.net/download/WinCacheForPhp WinCache] is geïnstalleer",
+ "config-diff3-bad": "GNU diff3 nie gevind nie.",
+ "config-db-type": "Databasistipe:",
+ "config-db-host": "Databasisbediener:",
+ "config-db-host-oracle": "Databasis-TNS:",
+ "config-db-wiki-settings": "Identifiseer hierdie wiki",
+ "config-db-name": "Databasisnaam:",
+ "config-db-name-oracle": "Databasis-skema:",
+ "config-db-install-account": "Gebruiker vir die installasie",
+ "config-db-username": "Databasis gebruikersnaam:",
+ "config-db-password": "Databasis wagwoord:",
+ "config-db-prefix": "Voorvoegsel vir databasistabelle:",
+ "config-mysql-old": "U moet MySQL $1 of later gebruik.\nU gebruik tans $2.",
+ "config-db-port": "Databasispoort:",
+ "config-db-schema": "Skema vir MediaWiki",
+ "config-sqlite-dir": "Gids vir SQLite se data:",
+ "config-oracle-def-ts": "Standaard tabelruimte:",
+ "config-oracle-temp-ts": "Tydelike tabelruimte:",
+ "config-header-mysql": "MySQL-instellings",
+ "config-header-postgres": "PostgreSQL-instellings",
+ "config-header-sqlite": "SQLite-instellings",
+ "config-header-oracle": "Oracle-instellings",
+ "config-invalid-db-type": "Ongeldige databasistipe",
+ "config-missing-db-name": "U moet 'n waarde vir \"Databasnaam\" verskaf",
+ "config-sqlite-readonly": "Die lêer <code>$1</code> kan nie geskryf word nie.",
+ "config-sqlite-cant-create-db": "Kon nie databasislêer <code>$1</code> skep nie.",
+ "config-upgrade-done-no-regenerate": "Opgradering is voltooi.\n\nU kan nou [$1 u wiki gebruik].",
+ "config-regenerate": "Herskep LocalSettings.php →",
+ "config-show-table-status": "Die uitvoer van <code>SHOW TABLE STATUS</code> het gefaal!",
+ "config-db-web-account": "Databasisgebruiker vir toegang tot die web",
+ "config-mysql-engine": "Stoor-enjin:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Karakterstelsel vir databasis:",
+ "config-mysql-binary": "Binêr",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Naam van die wiki:",
+ "config-site-name-blank": "Verskaf 'n naam vir u webwerf.",
+ "config-project-namespace": "Projeknaamruimte:",
+ "config-ns-generic": "Projek",
+ "config-ns-site-name": "Dieselfde as die wiki: $1",
+ "config-ns-other": "Ander (spesifiseer)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "Administrateur se gebruiker",
+ "config-admin-name": "U gebruikersnaam:",
+ "config-admin-password": "Wagwoord:",
+ "config-admin-password-confirm": "Wagwoord weer:",
+ "config-admin-password-blank": "Verskaf 'n wagwoord vir die administrateur in.",
+ "config-admin-password-mismatch": "Die twee wagwoorde wat u ingetik het stem nie ooreen nie.",
+ "config-admin-email": "E-posadres:",
+ "config-optional-continue": "Vra my meer vrae.",
+ "config-optional-skip": "Ek is reeds verveeld, installeer maar net die wiki.",
+ "config-profile-wiki": "Tradisionele wiki",
+ "config-profile-no-anon": "Skep van gebruiker is verpligtend",
+ "config-profile-fishbowl": "Slegs vir gemagtigde redaksie",
+ "config-profile-private": "Privaat wiki",
+ "config-license": "Kopiereg en lisensie:",
+ "config-license-none": "Geen lisensie in die onderskrif",
+ "config-license-pd": "Publieke Domein",
+ "config-license-cc-choose": "Kies 'n Creative Commons-lisensie",
+ "config-email-settings": "E-posinstellings",
+ "config-email-user": "Laat e-pos tussen gebruikers toe",
+ "config-email-user-help": "Stel alle gebruikers in staat om aan mekaar e-pos te stuur indien dit so in hul voorkeure aangedui is.",
+ "config-email-usertalk": "Laat kennisgewings op gebruikersbesprekingsblad toe.",
+ "config-email-auth": "Laat e-pos-verifikasie toe",
+ "config-email-sender": "E-posadres vir antwoorde:",
+ "config-upload-settings": "Oplaai van beelde en lêer",
+ "config-upload-enable": "Aktiveer die oplaai van lêers",
+ "config-upload-deleted": "Gids vir verwyderde lêers:",
+ "config-logo": "URL vir logo:",
+ "config-cc-again": "Kies weer...",
+ "config-advanced-settings": "Gevorderde konfigurasie",
+ "config-memcached-servers": "Memcached-bedieners:",
+ "config-extensions": "Uitbreidings",
+ "config-install-step-done": "gedoen",
+ "config-install-step-failed": "het misluk",
+ "config-install-extensions": "Insluitende uitbreidings",
+ "config-install-database": "Stel die databasis op",
+ "config-install-pg-schema-not-exist": "Die skema vir PostgreSQL bestaan ​​nie.",
+ "config-install-pg-schema-failed": "Die skep van tabelle het gefaal.\nMaak seker dat die gebruiker \"$1\" na skema \"$2\" mag skryf.",
+ "config-install-pg-commit": "Wysigings word gestoor",
+ "config-install-pg-plpgsql": "Kontroleer vir taal PL/pgSQL",
+ "config-pg-no-plpgsql": "U moet die taal PL/pgSQL in die database $1 installeer",
+ "config-install-user": "Besig om die databasisgebruiker te skep",
+ "config-install-user-alreadyexists": "Gebruiker \"$1\" bestaan al reeds",
+ "config-install-user-create-failed": "Skep van gebruiker \"$1\" het gefaal: $2",
+ "config-install-user-grant-failed": "Die toekenning van regte aan gebruiker \"$1\" het gefaal: $2",
+ "config-install-tables": "Skep tabelle",
+ "config-install-tables-exist": "'''Waarskuwing''': Dit lyk of MediaWiki se tabelle reeds bestaan.\nDie skep van tabelle word oorgeslaan.",
+ "config-install-tables-failed": "'''Fout''': die skep van 'n tabel het gefaal met die volgende fout: $1",
+ "config-install-interwiki": "Besig om data in die interwiki-tabel in te laai",
+ "config-install-interwiki-list": "Kon nie die lêer <code>interwiki.list</code> vind nie.",
+ "config-install-interwiki-exists": "'''Waarskuwing''': Die interwiki-tabel bevat reeds inskrywings.\nDie standaardlys word oorgeslaan.",
+ "config-install-stats": "Inisialiseer statistieke",
+ "config-install-keys": "Genereer geheime sleutel",
+ "config-install-sysop": "Skep 'n gebruiker vir die administrateur",
+ "config-install-subscribe-fail": "Kon nie vir MediaWiki-announce inskryf nie: $1",
+ "config-install-mainpage": "Skep die hoofblad met standaard inhoud",
+ "config-install-extension-tables": "Skep tabelle vir aangeskakel uitbreidings",
+ "config-install-mainpage-failed": "Kon nie die hoofblad laai nie: $1",
+ "config-install-done": "'''Veels geluk!'''\nU het MediaWiki suksesvol geïnstalleer.\n\nDie installeerder het 'n <code>LocalSettings.php</code> lêer opgestel.\nDit bevat al u instellings.\n\nU sal dit moet [$1 aflaai] en dit in die hoofgids van u wiki-installasie plaas; in dieselfde gids as index.php.\n'''Let wel''': As u dit nie nou doen nie, sal die gegenereerde konfigurasielêer nie later meer beskikbaar wees nadat u die installasie afgesluit het nie.\n\nAs dit gedoen is, kan u '''[u $2 wiki besoek]'''.",
+ "config-download-localsettings": "Laai <code>LocalSettings.php</code> af",
+ "config-help": "hulp",
+ "mainpagetext": "'''MediaWiki is suksesvol geïnstalleer.'''",
+ "mainpagedocfooter": "Konsulteer '''[https://meta.wikimedia.org/wiki/Help:Contents User's Guide]''' vir inligting oor hoe om die wikisagteware te gebruik.\n\n== Hoe om te Begin ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/aln.json b/www/wiki/includes/installer/i18n/aln.json
new file mode 100644
index 00000000..680e8ff8
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/aln.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bresta"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki software u instalue me sukses.'''",
+ "mainpagedocfooter": "Për mâ shumë informata rreth përdorimit të softwareit wiki, ju lutem shikoni [https://meta.wikimedia.org/wiki/Help:Contents dokumentacionin].\n\n\n== Për fillim ==\n\n* [https://www.mediawiki.org/wiki/Help:Configuration_settings Konfigurimi i MediaWikit]\n* [https://www.mediawiki.org/wiki/Help:FAQ Pyetjet e shpeshta rreth MediaWikit]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Njoftime rreth MediaWikit]"
+}
diff --git a/www/wiki/includes/installer/i18n/am.json b/www/wiki/includes/installer/i18n/am.json
new file mode 100644
index 00000000..f539f563
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/am.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki በትክክል ማስገባቱ ተከናወነ።'''",
+ "mainpagedocfooter": "ስለ ዊኪ ሶፍትዌር ጥቅም ለመረዳት፣ [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] ያንብቡ።\n\n== ለመጀመር ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/an.json b/www/wiki/includes/installer/i18n/an.json
new file mode 100644
index 00000000..67da0cbf
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/an.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Juanpabl"
+ ]
+ },
+ "mainpagetext": "'''O programa MediaWiki s'ha instalato correctament.'''",
+ "mainpagedocfooter": "Consulta a [https://meta.wikimedia.org/wiki/Help:Contents Guía d'usuario] ta mirar información sobre cómo usar o software wiki.\n\n== Ta prencipiar ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de caracteristicas confegurables]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Preguntas cutianas sobre MediaWiki (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de correu sobre ta anuncios de MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/ang.json b/www/wiki/includes/installer/i18n/ang.json
new file mode 100644
index 00000000..94d41e0d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ang.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gott wisst"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki hafaþ geworden spēdige inseted.'''",
+ "mainpagedocfooter": "Þeahta þone [https://meta.wikimedia.org/wiki/Help:Contents Brūcenda Lǣdend] on helpe mid þǣre nytte of ƿikisōftƿare.\n\n== Beȝinnunȝ ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Onfæstnunȝa ȝesetednessa ȝetæl]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Ȝetæl oft ascodra ascunȝa ymb MediaǷiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Ǣrendunȝȝetæl nīƿra MediaǷiki forþsendnessa]"
+}
diff --git a/www/wiki/includes/installer/i18n/anp.json b/www/wiki/includes/installer/i18n/anp.json
new file mode 100644
index 00000000..a55ba75d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/anp.json
@@ -0,0 +1,13 @@
+{
+ "@metadata": {
+ "authors": [
+ "Angpradesh"
+ ]
+ },
+ "config-desc": "मिडियाविकि लेली इंस्टॉलर",
+ "config-title": "मीडियाविकी $1 इंस्टॉलेशन",
+ "config-information": "जानकारी",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code> फ़ाइल पैलऽ गेलै ।\nई स्थापना क॑ अपग्रेड करै लेली , नीचाँ देलऽ गेलऽ बॉक्स म॑ <code>$wgUpgradeKey</code> के मान दर्ज करऽ।\nआपन॑ क॑ <code>LocalSettings.php</code> म॑ मिली जैतै ।",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code> फ़ाइल पैलऽ गेलऽ छै ।\nई स्थापना क॑ अपग्रेड करै लेली , बदला म॑ कृपया करी क॑ ई चलाबऽ <code>update.php</code>",
+ "config-localsettings-key": "नवीनीकरण कुंजी"
+}
diff --git a/www/wiki/includes/installer/i18n/ar.json b/www/wiki/includes/installer/i18n/ar.json
new file mode 100644
index 00000000..69d1fcf8
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ar.json
@@ -0,0 +1,255 @@
+{
+ "@metadata": {
+ "authors": [
+ "Meno25",
+ "Mido",
+ "OsamaK",
+ "روخو",
+ "Claw eg",
+ "Kuwaity26",
+ "محمد أحمد عبد الفتاح",
+ "Maroen1990",
+ "Super ninja2",
+ "Zpizza",
+ "ديفيد"
+ ]
+ },
+ "config-desc": "مثبت لميدياويكي",
+ "config-title": "تثبيت ميدياويكي $1",
+ "config-information": "معلومات",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code> قد تم كشف ملف.\nلترقية هذا التنصيب، رجاء أدخل قيمة <code>$wgUpgradeKey</code> في الصندوق أدناه.\nستجده في <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code> قد تم كشف ملف.\nلترقية هذا التنصيب، رجاء قم بتفعيل <code>update.php</code> عوضًا عن ذلك",
+ "config-localsettings-key": "مفتاح ترقية:",
+ "config-localsettings-badkey": "مفتاح الترقية الذي قدمته غير صحيح.",
+ "config-upgrade-key-missing": "تنصيب موجود للميدياويكي قد تم اكتشافه.\nلترقية هذا التنصيب، الرجاء وضع السطر أسفل <code>LocalSettings.php</code> الخاصة بك:\n\n$1",
+ "config-localsettings-incomplete": "صفحة <code>LocalSettings.php</code> يبدو أنها ناقصة.\nالمتغير $1 لم يتم تعيينه.\nالرجاء تغيير <code>LocalSettings.php</code> لكي يتم تعيين المتغير، ثم اضغط على \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "تمت مصادفة خطأ أثناء الاتصال بقاعدة البيانات باستخدام الإعدادات المحددة في <code>LocalSettings.php</code> أو <code>LocalSettings.php</code>. الرجاء إصلاح هذه الإعدادات وحاول مجددًا.\n\n$1",
+ "config-session-error": "خطأ في بدء الجلسة: $1",
+ "config-session-expired": "يبدو أن بيانات جلستك قد انتهت صلاحيتها.\nالجلسات مكونة مدى الحياة من $1.\nيمكنك زيادة هذه بتعيين <code>session.gc_maxlifetime</code> في php.ini.\nأعد تشغيل عميل التثبيت.",
+ "config-no-session": "بيانات جلستك قد ضاعت!\nتحقق من php.ini للتأكد أن <code>session.save_path</code> تم تعيينه كدليل مناسب.",
+ "config-your-language": "لغتك:",
+ "config-your-language-help": "حدد لغة لاستخدامها أثناء عملية التثبيت.",
+ "config-wiki-language": "لغة ويكي:",
+ "config-wiki-language-help": "قم بتحديد اللغة التي ستُكتب بها الويكي غالبًا.",
+ "config-back": "→ ارجع",
+ "config-continue": "استمر ←",
+ "config-page-language": "اللغة",
+ "config-page-welcome": "مرحبًا في ميدياويكي!",
+ "config-page-dbconnect": "الاتصال بقاعدة البيانات",
+ "config-page-upgrade": "قم بترقية التثبيت الموجود",
+ "config-page-dbsettings": "إعدادات قاعدة البيانات",
+ "config-page-name": "الاسم",
+ "config-page-options": "خيارات",
+ "config-page-install": "تنصيب",
+ "config-page-complete": "اكتمل!",
+ "config-page-restart": "قم بإعادة تشغيل التثبيت",
+ "config-page-readme": "اقرأني",
+ "config-page-releasenotes": "ملاحظات الإصدار",
+ "config-page-copying": "نسخ",
+ "config-page-upgradedoc": "ترقية",
+ "config-page-existingwiki": "ويكي موجودة",
+ "config-help-restart": "هل تريد إزالة البيانات المحفوظة التي قد قمت بإدخالها وإعادة تشغيل عملية التثبيت؟",
+ "config-restart": "نعم، إعادة التشغيل",
+ "config-welcome": "=== التحقق من البيئة ===\nسوف يتم الآن التحقق من أن البيئة مناسبة لتنصيب ميديا ويكي.\nتذكر تضمين هذه المعلومات اذا اردت طلب المساعدة عن كيفية إكمال التنصيب.",
+ "config-copyright": "=== حقوق النسخ والشروط ===\n\n$1\n\nهذا البرنامج هو برنامج حر؛ يمكنك إعادة توزيعه و/أو تعديله تحت شروط رخصة جنو العامة على أن هذا البرنامج قد نُشر من قِبل مؤسسة البرمجيات الحرة؛ إما النسخة 2 من الرخصة، أو أي نسخة أخرى بعدها (من إختيارك)\n\nتم توزيع هذا البرنامج على أمل ان يكون مفيدًا ولكن <strong> دون أية ضمانات</strong>؛ دون حتى أية ضمانات مفهومة ضمنيًا أو رواجات أو أية أسباب محددة.\nأنظر رخصة جنو العامة لمزيد من المعلومات.\n\nمن المفترض أنك إستملت <doclink href=Copying> نسخة عن رخصة جنو العامة </doclink> مع هذا البرنامج؛ اذا لم تقعل إكتب رسالة إلى مؤسسة البرمجيات الحرة المحدودة، شارع 51 فرانكلين الطابق الخامس، بوسطن MA 02110-1301 الولايات المتخدة أو [http://www.gnu.org/copyleft/gpl.html read it online].",
+ "config-sidebar": "* [https://www.mediawiki.org موقع ميدياويكي]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents دليل المستخدم]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents دليل الإداري]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ الأسئلة المتكررة]\n----\n* <doclink href=Readme>إقراءني</doclink>\n* <doclink href=ReleaseNotes>ملاحظات الإصدار</doclink>\n* <doclink href=Copying>النسخ</doclink>\n* <doclink href=UpgradeDoc>الترقية</doclink>",
+ "config-env-good": "جرى التحقق من البيئة. يمكنك تنصيب ميدياويكي.",
+ "config-env-bad": "جرى التحقق من البيئة. لا يمكنك تنصيب ميدياويكي.",
+ "config-env-php": "بي إتش بي $1 مثبت.",
+ "config-env-hhvm": "نصبت HHVM $1.",
+ "config-outdated-sqlite": "<strong>تحذير:</strong> لديك SQLite $1, which وهو أقل من الحد الأدنى المطلوب للنسخة $2. SQLite سوف يكون غير متوفر.",
+ "config-xcache": "تثبيت [http://xcache.lighttpd.net/ XCache]",
+ "config-apc": "تثبيت [http://www.php.net/apc APC]",
+ "config-apcu": "تثبيت [http://www.php.net/apcu APCu]",
+ "config-wincache": "تثبيت [http://www.iis.net/download/WinCacheForPhp WinCache]",
+ "config-diff3-bad": "جنو diff3 غير موجود.",
+ "config-imagemagick": "تم العثور على ImageMagick: <code>$1</code>.\nسيتم تمكين تصغير الصور إذا قمت بتمكين التحميل.",
+ "config-no-scaling": "لا يمكن أن تجد مكتبة GD أو ImageMagick; سيتم تعطيل تصغير الصور.",
+ "config-no-uri": "<strong>خطأ:</strong> لا يمكن أن تحدد URI الحالي.تم إحباط التثبيت.",
+ "config-using-server": "باستخدام اسم الخادم \"<nowiki>$1</nowiki\".",
+ "config-using-uri": "باستخدام URL الخادم \"<nowiki>$1$2</nowiki>\".",
+ "config-db-type": "نوع قاعدة البيانات:",
+ "config-db-host": "مضيف قاعدة البيانات:",
+ "config-db-host-oracle": "قاعدة بيانات TNS:",
+ "config-db-wiki-settings": "حدِّد هذا الويكي",
+ "config-db-name": "اسم قاعدة البيانات",
+ "config-db-name-help": "اختر الاسم الذي يعرف الويكي الخاص بك. لا يجب أن يحتوي على مسافات. إذا كنت تستخدم استضافة المواقع المشتركة، مزود الاستضافة إما سيعطيك اسم قاعدة بيانات محددة لاستخدامها أو سيتيح لك إنشاء قواعد بيانات عن طريق لوحة التحكم.",
+ "config-db-name-oracle": "سكيما قاعدة البيانات:",
+ "config-db-install-account": "حساب المستخدم للتنصيب",
+ "config-db-username": "اسم مستخدم قاعدة البيانات:",
+ "config-db-password": "كلمة سر قاعدة البيانات:",
+ "config-db-install-username": "أدخل اسم المستخدم الذي سيتم استخدامه للاتصال بقاعدة البيانات أثناء عملية التثبيت. هذا ليس اسم مستخدم لحساب ميدياويكي. هذا اسم مستخدم لقاعدة البيانات الخاصة بك.",
+ "config-db-install-password": "أدخل كلمة المرور التي سيتم استخدامها للاتصال بقاعدة البيانات أثناء عملية التثبيت. ليست هذه كلمة مرور لحساب ميدياويكي. هذه كلمة مرور لقاعدة البيانات الخاصة بك.",
+ "config-db-install-help": "أدخل اسم المستخدم وكلمة المرور الذين سيتم استخدامهما للاتصال بقاعدة البيانات أثناء عملية التثبيت.",
+ "config-db-account-lock": "استخدم نفس اسم المستخدم وكلمة المرور أثناء التشغيل العادي",
+ "config-db-wiki-account": "حساب المستخدم للتشغيل العادي",
+ "config-db-wiki-help": "أدخل اسم المستخدم وكلمة المرور التي سيتم استخدامهما للاتصال بقاعدة البيانات أثناء عملية الويكي العادية. في حالة عدم وجود حساب وتثبيت الحساب لديه امتيازات كافية، سيتم إنشاء حساب المستخدم هذا مع الحد الأدنى من الامتيازات المطلوبة لتشغيل الويكي.",
+ "config-db-prefix": "بادئة جدول قاعدة البيانات:",
+ "config-db-prefix-help": "إذا كنت بحاجة إلى مشاركة قاعدة بيانات واحدة بين ويكيات متعددة، أو بين ميدياويكي وتطبيق آخر على شبكة الإنترنت، يمكنك الاختيار لإضافة بادئة لجميع أسماء الجداول لتجنب النزاعات. لا تستخدم مسافات. وعادة ما يتم ترك هذا الحقل فارغا.",
+ "config-mysql-old": "MySQL $1 أو لاحق مطلوب.لديك $2.",
+ "config-db-port": "منفذ قاعدة البيانات:",
+ "config-db-schema": "سكيما لميدياويكي",
+ "config-db-schema-help": "هذا المخطط عادة يكون على ما يرام. غيره إذا كنت تعرف أنك في حاجة إلى هذا فقط.",
+ "config-pg-test-error": "لا يمكن الاتصال بقاعدة البيانات <strong>$1</strong>: $2",
+ "config-sqlite-dir": "دليل بيانات SQLite:",
+ "config-oracle-def-ts": "جدولية افتراضية:",
+ "config-oracle-temp-ts": "جدولية مؤقتة:",
+ "config-type-mysql": "MySQL (أو متوافق)",
+ "config-type-postgres": "بوستجر إس كيو إل",
+ "config-type-sqlite": "إس كيو لايت",
+ "config-type-oracle": "أوراكل",
+ "config-type-mssql": "خادم SQL لميكروسوفت",
+ "config-support-info": "ميدياويكي يدعم نظم قواعد البيانات التالية: $1 إذا كنت لا ترى نظام قاعدة البيانات الذي تحاول استخدامه مدرجًا أدناه، اتبع الإرشادات المرتبطة فوق لتمكين الدعم.",
+ "config-header-mysql": "إعدادات MySQL",
+ "config-header-postgres": "إعدادات PostgreSQL",
+ "config-header-sqlite": "إعدادات SQLite",
+ "config-header-oracle": "إعدادات أوراكل",
+ "config-header-mssql": "إعدادات خادم Microsoft SQL",
+ "config-invalid-db-type": "نوع قاعدة بيانات غير صحيح",
+ "config-missing-db-name": "يجب عليك إدخال قيمة ل\"{{int:config-db-name}}\".",
+ "config-missing-db-server-oracle": "يجب عليك إدخال قيمة ل\"{{int:config-db-host-oracle}}\".",
+ "config-connection-error": "$1.\nتحقق من المضيف، واسم المستخدم وكلمة المرور وحاول مرة أخرى.",
+ "config-db-sys-create-oracle": "المثبت يعتمد باستخدام حساب SYSDBA فقط لإنشاء حساب جديد.",
+ "config-db-sys-user-exists-oracle": "حساب المستخدم \"$1\" موجود بالفعل; يمكن استخدام SYSDBA لإنشاء حساب جديد فقط!",
+ "config-postgres-old": "PostgreSQL $1 أو لاحق مطلوب. لديك $2.",
+ "config-mssql-old": "خادم Microsoft SQL $1 أو لاحق مطلوب. لديك $2.",
+ "config-sqlite-mkdir-error": "خطأ في إنشاء دليل البيانات \"$1\". تحقق من الموقع وحاول مرة أخرى.",
+ "config-sqlite-connection-error": "$1.\nتحقق من اسم دليل البيانات وقواعد البيانات أدناه وحاول مرة أخرى.",
+ "config-sqlite-readonly": "الملف <code>$1</code> غير قابل للكتابة.",
+ "config-sqlite-cant-create-db": "لا يمكن إنشاء ملف قاعدة البيانات <code>$1</code>.",
+ "config-can-upgrade": "هناك جداول ميدياويكي في قاعدة البيانات هذه. للارتقاء بها إلى ميدياويكي $1; انقر على <strong>متابعة</strong>.",
+ "config-regenerate": "إعادة تكوين LocalSettings.php ←",
+ "config-show-table-status": "<code> إظهار جدول الحالة </code> فشل الاستعلام!",
+ "config-unknown-collation": "<strong>تحذير:</strong> قاعدة بيانات يستخدم ترتيبا غير معروف.",
+ "config-db-web-account": "حساب قاعدة البيانات للوصول عبر الوب",
+ "config-db-web-help": "حدد اسم المستخدم وكلمة المرور التي سيستخدمهما خادم الويب للاتصال بخادم قاعدة البيانات، أثناء عملية الويكي العادية.",
+ "config-db-web-account-same": "استعمل نفس الحساب للتنصيب",
+ "config-db-web-create": "إنشئ حساب إذا لم يكن موجودا بالفعل",
+ "config-db-web-no-create-privs": "الحساب الذي حددته لتركيب ليس لديه امتيازات كافية لإنشاء حساب.\nالحساب الذي حددته هنا موجود بالفعل.",
+ "config-mysql-engine": "محرك التخزين",
+ "config-mysql-innodb": "إنو دي بي",
+ "config-mysql-myisam": "ماي إسام",
+ "config-mysql-charset": "مجموعة محارف قاعدة البيانات",
+ "config-mysql-binary": "ثنائي",
+ "config-mysql-utf8": "يو تي إف-8",
+ "config-mssql-auth": "نوع الاستيثاق:",
+ "config-mssql-sqlauth": "مصادقة خادم SQL",
+ "config-mssql-windowsauth": "مصادقة ويندوز",
+ "config-site-name": "اسم الويكي:",
+ "config-site-name-blank": "أدخل اسم موقع.",
+ "config-project-namespace": "نطاق المشروع:",
+ "config-ns-generic": "المشروع",
+ "config-ns-site-name": "مثل اسم الويكي: $1",
+ "config-ns-other": "أخرى (حدد)",
+ "config-ns-other-default": "ماي ويكي",
+ "config-ns-invalid": "النطاق المحدد \"<nowiki>$1</nowiki>\" غير صالح.\nحدد نطاق مشروع مختلف.",
+ "config-ns-conflict": "النطاق المحدد \"<nowiki>$1</ nowiki>\" يتعارض مع نطاق ميدياويكي الافتراضي. حدد نطاق مشروع مختلف.",
+ "config-admin-box": "حساب إداري",
+ "config-admin-name": "اسم المستخدم:",
+ "config-admin-password": "كلمة السر:",
+ "config-admin-password-confirm": "كلمة المرور مرة أخرى:",
+ "config-admin-help": "أدخل اسم المستخدم المفضل لديك هنا، على سبيل المثال \"جو أشرف فاروق\". هذا هو الاسم الذي ستستخدمه لتسجيل الدخول إلى الويكي.",
+ "config-admin-name-blank": "أدخل اسم مستخدم لإداري.",
+ "config-admin-name-invalid": "اسم المستخدم المحدد \"<nowiki>$1</ nowiki>\" غير صالح. حدد اسم مستخدم مختلفا.",
+ "config-admin-password-blank": "أدخل كلمة مرور حساب الإداري.",
+ "config-admin-password-mismatch": "كلمات السر اثنين التي أدخلتها لا تتطابق.",
+ "config-admin-email": "عنوان البريد الإلكتروني:",
+ "config-admin-email-help": "إدخال عنوان البريد الإلكتروني هنا ليسمح لك لتلقي البريد الإلكتروني من المستخدمين الآخرين على ويكي، إعادة تعيين كلمة المرور الخاصة بك، ويتم إخطار من التغييرات للصفحات في قائمة مراقبتك. يمكنك ترك هذا الحقل فارغا.",
+ "config-admin-error-user": "خطأ داخلي عند إنشاء إداري باسم \"<nowiki>$1</ nowiki>\".",
+ "config-admin-error-password": "خطأ داخلي عند عند وضع كلمة مرور للإداري \"<nowiki>$1</nowiki>\": <pre>$2</pre>.",
+ "config-admin-error-bademail": "لقد قمت بإدخال عنوان البريد الإلكتروني غير صالح.",
+ "config-subscribe": "اشترك في [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce نشر إعلانات القائمة البريدية].",
+ "config-subscribe-noemail": "حاولت الاشتراك في القائمة البريدية الخاصة بإصدار إعلانات دون تقديم عنوان بريد إلكتروني. يُرجَى إدخال عنوان بريد إلكتروني إذا كنت ترغب في الاشتراك في القائمة البريدية.",
+ "config-pingback": "تبادل البيانات حول هذا التثبيت مع مطوري ميدياويكي.",
+ "config-almost-done": "لقد شارفت على الانتهاء! يمكنك الآن تخطي التكوين المتبقي وتثبيت الويكي الآن.",
+ "config-optional-continue": "اسألني المزيد من الأسئلة",
+ "config-optional-skip": "إنني أشعر بالملل بالفعل، فقط قم بتثبيت الويكي",
+ "config-profile": "ملف صلاحيات المستخدم:",
+ "config-profile-wiki": "ويكي مفتوحة",
+ "config-profile-no-anon": "إنشاء الحساب مطلوب",
+ "config-profile-fishbowl": "المحررون المخولون فقط",
+ "config-profile-private": "ويكي خاص",
+ "config-license": "حقوق النسخ والترخيص:",
+ "config-license-none": "لا تذييل ترخيص",
+ "config-license-cc-by-sa": "المشاع الإبداعي النسبة للمؤلف المشاركة بالمثل",
+ "config-license-cc-by": "المشاع الإبداعي النسبة للمؤلف",
+ "config-license-cc-by-nc-sa": "المشاع الإبداعي النسبة للمؤلف غير تجاري المشاركة بالمثل",
+ "config-license-cc-0": "المشاع الإبداعي صفر (ملكية عامة)",
+ "config-license-gfdl": "رخصة جنو للوثائق الحرة 1.3 أو لاحقة",
+ "config-license-pd": "ملكية عامة",
+ "config-license-cc-choose": "اختر ترخيص مشاع إبداعي مخصص",
+ "config-email-settings": "إعدادات البريد الإلكتروني",
+ "config-enable-email": "تمكين البريد الإلكتروني الصادرة",
+ "config-enable-email-help": "إذا كنت تريد إرسال بريد إلكتروني إلى العمل، [http://www.php.net/manual/en/mail.configuration.php إعدات بريد PHP's] تحتاج لأن يتم تكوينها بشكل صحيح. إذا كنت لا تريد أيا من ميزات البريد الإلكتروني، يمكنك تعطيلها هنا.",
+ "config-email-user": "تفعيل البريد الإلكتروني من المستخدم إلى مستخدم آخر",
+ "config-email-user-help": "يتيح لكل المستخدمين إرسال رسائل بريد إلكتروني إلى بعضهم البعض إذا فعَّلوا هذا الخيار في تفضيلاتهم.",
+ "config-email-usertalk": "فعل إخطارات صفحات نقاش المستخدمين",
+ "config-email-usertalk-help": "السماح للمستخدمين بتلقي الإخطارات بشأن تغييرات صفحة نقاش المستخدم، إذا كانوا قد مكنوها في تفضيلاتهم.",
+ "config-email-watchlist": "تمكين إشعارات قائمة المراقبة",
+ "config-email-watchlist-help": "السماح للمستخدمين بالحصول على إشعارات حول صفحاتهم المراقبة إذا كانوا قد مكنوها في تفضيلاتهم.",
+ "config-email-auth": "تمكين مصادقة البريد الإلكتروني",
+ "config-email-sender": "عنوان البريد الإلكتروني المُرسِل:",
+ "config-upload-settings": "الصور وتحميل الملفات",
+ "config-upload-enable": "تمكين تحميل الملفات",
+ "config-upload-deleted": "المجلد للملفات المحذوفة:",
+ "config-logo": "مسار الشعار:",
+ "config-instantcommons": "تمكين الاستخدام الفوري لويكيميديا كومنز InstantCommons",
+ "config-cc-error": "لم يعطِ منتقي رخصة المشاع الإبداعي أية نتيجة; أدخل اسم الترخيص يدويا.",
+ "config-cc-again": "اختر مجددًا",
+ "config-cc-not-chosen": "اختر أي ترخيص تريده ثم اضغط على \"متابعة\".",
+ "config-advanced-settings": "ضبط متقدم",
+ "config-cache-options": "إعدادات التخزين المؤقت الكائن:",
+ "config-cache-none": "لا يوجد تخزين مؤقت (هذا لا يزيل أية وظيفة، لكن قد تتأثر السرعة على مواقع الويكي الكبيرة)",
+ "config-cache-accel": "كائن التخزين المؤقت PHP (APC أو APCu أو XCache أو WinCache)",
+ "config-cache-memcached": "استخدم Memcached (يتطلب إعدادت إضافية)",
+ "config-memcached-servers": "خوادم Memcached:",
+ "config-extensions": "امتدادات",
+ "config-extensions-help": "تم الكشف عن الملحقات المذكورة أعلاه في دليل <code>./ملحقاتك</code>، ويمكن أن يتطلب تكوينا إضافيا، ولكن يمكنك تمكينها الآن.",
+ "config-skins": "الواجهات",
+ "config-skins-use-as-default": "استخدم هذه الواجهة كافتراضية",
+ "config-install-alreadydone": "<strong>تحذير:</strong> يبدو أنك قد قمت بالفعل بتثبيت ميدياويكي وتحاول تثبيته مرة أخرى. الرجاء التوجه إلى الصفحة التالية.",
+ "config-install-begin": "عن طريق الضغط على \"{{int:config-continue}}\"، سوف تبدأ تثبيت ميدياويكي. إذا كنت لا تزال ترغب في إجراء تغييرات، اضغط على \"{{int:config-back}}\".",
+ "config-install-step-done": "تم بنجاح",
+ "config-install-step-failed": "فشل",
+ "config-install-extensions": "متضمنا الامتدادات",
+ "config-install-database": "إنشاء قاعدة البيانات",
+ "config-install-schema": "إنشاء السكيما",
+ "config-install-pg-schema-not-exist": "مخطط PostgreSQL غير موجود.",
+ "config-install-pg-commit": "تنفيذ التغييرات",
+ "config-install-pg-plpgsql": "التحقق من لغة PL/pgSQL",
+ "config-pg-no-plpgsql": "تحتاج إلى تثبيت لغة PL/pgSQL في قاعدة البيانات $1",
+ "config-pg-no-create-privs": "الحساب الذي حددته للتنزيل ليست لديه امتيازات كافية لإنشاء حساب.",
+ "config-install-user": "إنشاء مستخدم قاعدة البيانات",
+ "config-install-user-alreadyexists": "المستخدم \"$1\" موجود بالفعل",
+ "config-install-user-create-failed": "إنشاء مستخدم \"$1\" فشل:$2",
+ "config-install-user-grant-failed": "منح الصلاحية للمستخدم \"$1\" فشل: $2",
+ "config-install-user-missing": "المستخدم المحدد \"$1\" غير موجود",
+ "config-install-user-missing-create": "المستخدم المحدد \"$1\" غير موجود. يُرجَى النقر على مربع \"إنشاء حساب\" أدناه إذا كنت تريد إنشاءه.",
+ "config-install-tables": "إنشاء الجداول",
+ "config-install-tables-exist": "<strong>تحذير:</strong> يبدو أن جداول ميدياويكي موجودة بالفعل; تخطي الإنشاء.",
+ "config-install-tables-failed": "<strong>خطأ:</strong> فشل إنشاء الجدول بسبب الخطأ التالي: $1",
+ "config-install-interwiki": "ملء جدول الإنترويكي الإفتراضي",
+ "config-install-interwiki-list": "لا يمكن قراءة الملف <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>تحذير:</strong> يبدو أن جدول الإنترويكي به إدخالات بالفعل. تخطي القائمة الافتراضي.",
+ "config-install-stats": "بدء الإحصاءات",
+ "config-install-keys": "توليد المفاتيح السرية",
+ "config-insecure-keys": "<strong>تحذير:</strong> {{PLURAL:$2|مفتاح الأمان|مفاتيح الأمان}} ($1) التي تم إنشاؤها أثناء التثبيت ليست آمنة تماما; جرب تغيير{{PLURAL:$2|ه|هم}} يدويا.",
+ "config-install-updates": "منع تشغيل التحديثات غير الضرورية",
+ "config-install-updates-failed": "<strong>خطأ:</strong> إدخال مفاتيح التحديث إلى الجداول فشلت بسبب الخطأ التالي: $1",
+ "config-install-sysop": "إنشاء حساب مستخدم إداري",
+ "config-install-subscribe-fail": "غير قادر على الاشتراك في ميدياويكي-إعلان: $1",
+ "config-install-subscribe-notpossible": "لم يتم تثبيت cURL و <code>allow_url_fopen</code> غير متوفر.",
+ "config-install-mainpage": "إنشاء صفحة رئيسية بالمحتوى الافتراضي",
+ "config-install-mainpage-exists": "الصفحة الرئيسية موجودة بالفعل، تم تجاهل هذا الأمر",
+ "config-install-extension-tables": "إنشاء جداول للامتدادات المفعلة",
+ "config-install-mainpage-failed": "لم يتمكن من إدراج الصفحة الرئيسية: $1",
+ "config-install-done": "<strong>مبروك!</strong>\nلقد قمت بتثبيت ميدياوكي.\n\nقام المثبت بتوليد ملف <code>LocalSettings.php</code>.\nيحتوي هذا الملف على كل تضبيطاتك.\n\nسيتطلب تشغيل الويكي منك تنزيل هذا الملف ووضعه في مجلد التثبيت الخاص بالويكي (نفس المجلد المحتوي على <code>index.php</code>). سيبدأ التنزيل تلقائيا.\n\nلو لم يُعرض عليك التنزيل أو قمت أنت بالغائه، يمكنك تنزيله بالضغط على الوصلة أدناه:\n\n$3\n\n<strong>تنبيه:</strong> لو لم تقم بهذا الآن، لن يكن ملف الضبط متاحا لك لاحقا إذا غادرت التثبيت بدون تنزيله.\n\nعندما تنتهي من وضع الملف بمكانه، يمكنك <strong>[$2 دخول الويكي]</strong>.",
+ "config-install-done-path": "<strong>مبروك!</strong>\nلقد قمت بتثبيت ميدياوكي.\n\nقام المثبت بتوليد ملف <code>LocalSettings.php</code>.\nيحتوي هذا الملف على كل تضبيطاتك.\n\nسيتطلب تشغيل الويكي منك تنزيل هذا الملف ووضعه في <code>$4</code> (نفس المجلد المحتوي على index.php). سيبدأ التنزيل تلقائيا.\n\nلو لم يُعرض عليك التنزيل أو قمت أنت بالغائه، يمكنك تنزيله بالضغط على الوصلة أدناه:\n\n$3\n\n<strong>تنبيه:</strong> لو لم تقم بهذا الآن، لن يكن ملف الضبط متاحا لك لاحقا إذا غادرت التثبيت بدون تنزيله.\n\nعندما تنتهي من وضع الملف بمكانه، يمكنك <code>[$2 دخول الويكي]</strong>.",
+ "config-download-localsettings": "تنزيل <code>LocalSettings.php</code>",
+ "config-help": "مساعدة",
+ "config-help-tooltip": "اضغط للتوسيع",
+ "config-nofile": "لا يمكن العثور على الملف \"$1\". هل حُذف؟",
+ "config-extension-link": "هل كنت تعلم أن الويكي الخاصة بك تدعم [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions الامتدادات]؟\n\nيمكنك تصفح [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category الامتدادات حسب التصنيف] أو [https://www.mediawiki.org/wiki/Extension_Matrix مصفوفة الامتدادت] لترى القائمة الكاملة للامتدادات.",
+ "mainpagetext": "<strong>تم تثبيت ميدياويكي بنجاح.</strong>",
+ "mainpagedocfooter": "استشر [https://meta.wikimedia.org/wiki/Help:Contents دليل المستخدم] لمعلومات حول استخدام برنامج الويكي.\n\n== البداية ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings قائمة إعدادات الضبط]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ أسئلة متكررة حول ميدياويكي]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce القائمة البريدية الخاصة بإصدار ميدياويكي]"
+}
diff --git a/www/wiki/includes/installer/i18n/arc.json b/www/wiki/includes/installer/i18n/arc.json
new file mode 100644
index 00000000..27ad2de7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/arc.json
@@ -0,0 +1,22 @@
+{
+ "@metadata": {
+ "authors": [
+ "Basharh"
+ ]
+ },
+ "config-information": "ܝܕ̈ܥܬܐ",
+ "config-your-language": "ܠܫܢܐ ܕܝܠܟ:",
+ "config-wiki-language": "ܠܫܢܐ ܕܘܝܩܝ:",
+ "config-page-language": "ܠܫܢܐ",
+ "config-page-name": "ܫܡܐ",
+ "config-page-options": "ܓܒܝܬ̈ܐ",
+ "config-page-install": "ܢܨܘܒ",
+ "config-ns-other-default": "ܘܝܩܝ ܕܝܠܝ",
+ "config-admin-box": "ܚܘܫܒܢܐ ܕܡܕܒܪܢܐ",
+ "config-admin-name": "ܫܡܐ ܕܟ܆ܛ܆ܡܦܠܚܢܐ ܕܝܠܟ:",
+ "config-admin-password": "ܡܠܬܐ ܕܥܠܠܐ:",
+ "config-admin-password-confirm": "ܡܠܬܐ ܕܥܠܠܐ ܙܒܢܬܐ ܐܚܪܬܐ:",
+ "config-admin-email": "ܡܘܢܥܐ ܕܒܝܠܕܪܐ ܐܠܩܛܪܘܢܝܐ:",
+ "config-profile-private": "ܘܝܩܝ ܦܪܨܘܦܝܐ",
+ "config-email-settings": "ܛܘܝܒ̈ܐ ܕܒܝܠܕܪܐ ܐܠܩܛܪܘܢܝܐ"
+}
diff --git a/www/wiki/includes/installer/i18n/ary.json b/www/wiki/includes/installer/i18n/ary.json
new file mode 100644
index 00000000..660c470f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ary.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Enzoreg",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki ṫ'instala be najaḫ.'''",
+ "mainpagedocfooter": "Ila bġiṫiw meĝlomaṫ ĥrin baċ ṫesṫeĝmlo had l-lojisyél siro ċofo [https://meta.wikimedia.org/wiki/Help:Contents/fr Gid dyal l-mosṫeĥdim]\n\n== L-bdaya mĝa MediaWiki ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista dyal l-paramétraṫ dyal l-konfigurasyon]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/fr FAQ fe MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista dyal l-modakaraṫ ĝla versyonaṫ jdad dyal MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/arz.json b/www/wiki/includes/installer/i18n/arz.json
new file mode 100644
index 00000000..4c34ee81
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/arz.json
@@ -0,0 +1,18 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ghaly"
+ ]
+ },
+ "config-your-language": "اللغه بتاعتك:",
+ "config-your-language-help": " إختار لغة البرنامج بتاعتك:",
+ "config-wiki-language": " ويكى لانجواج :",
+ "config-back": "→ ارجع",
+ "config-continue": "استمر",
+ "config-page-language": "اللغه",
+ "config-page-welcome": "اهلا ف ميديا ويكى",
+ "config-page-name": "الاسم:",
+ "config-page-install": "تركيب",
+ "mainpagetext": "''' ميدياويكى اتنزلت بنجاح.'''",
+ "mainpagedocfooter": "اسال [https://meta.wikimedia.org/wiki/Help:Contents دليل اليوزر] للمعلومات حوالين استخدام برنامج الويكى.\n\n== البداية ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings لستة اعدادات الضبط]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ أسئلة بتكرر حوالين الميدياويكى]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce لستة الايميلات بتاعة اعلانات الميدياويكى]"
+}
diff --git a/www/wiki/includes/installer/i18n/as.json b/www/wiki/includes/installer/i18n/as.json
new file mode 100644
index 00000000..312cf4e9
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/as.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chaipau",
+ "Gitartha.bordoloi"
+ ]
+ },
+ "mainpagetext": "'''মিডিয়াৱিকি সফলভাবে ইন্সটল কৰা হ'ল ।'''",
+ "mainpagedocfooter": "ৱিকি চ'ফটৱেৰ কেনেকৈ ব্যৱহাৰ কৰিব [https://meta.wikimedia.org/wiki/Help:Contents সদস্যৰ সহায়িকা] চাওঁক ।\n\n== আৰম্ভণি কৰিবলৈ ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/ast.json b/www/wiki/includes/installer/i18n/ast.json
new file mode 100644
index 00000000..1b2831fa
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ast.json
@@ -0,0 +1,156 @@
+{
+ "@metadata": {
+ "authors": [
+ "Xuacu",
+ "Fitoschido",
+ "Enolp",
+ "Crucifunked"
+ ]
+ },
+ "config-desc": "L'instalador pa MediaWiki",
+ "config-title": "Instalación de MediaWiki $1",
+ "config-information": "Información",
+ "config-localsettings-upgrade": "Detectose un ficheru <code>LocalSettings.php</code>.\nP'anovar esta instalación, escriba'l valor de\n<code>$wgUpgradeKey</code> nel cuadru d'abaxo.\nAlcontraralu en <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Deteutose un ficheru <code>LocalSettings.php</code>.\nP'anovar esta instalación, execute <code>update.php</code>",
+ "config-localsettings-key": "Clave d'anovamientu:",
+ "config-localsettings-badkey": "La clave d'anovamientu que disti ye incorreuta.",
+ "config-upgrade-key-missing": "Deteutose una instalación esistente de MediaWiki.\nP'anovar esta instalación, ponga la llinia siguiente al final del ficheru <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Paez que'l ficheru <code>LocalSettings.php</code> esistente ta incompletu.\nLa variable $1 nun ta definida.\nCamude'l ficheru <code>LocalSettings.php</code> pa qu'esta variable quede definida y calque \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Alcontróse un error al conectar cola base de datos usando la configuración especificada en <code>LocalSettings.php</code>. Corrixa esta configuración y vuelva a intentalo.\n\n$1",
+ "config-session-error": "Error al aniciar sesión: $1",
+ "config-session-expired": "Paez que caducaron los sos datos de sesión.\nLes sesiones tan configuraes pa tener una duración de $1.\nPue incrementar esto configurando <code>session.gc_maxlifetime</code> en php.ini.\nReanicie'l procesu d'instalación.",
+ "config-no-session": "¡Perdiéronse los sos datos de sesión!\nCompruebe php.ini y asegúrese de qu'en <code>session.save_path</code> ta definíu un direutoriu correutu.",
+ "config-your-language": "La so llingua:",
+ "config-your-language-help": "Seleicione la llingua a emplegar nel procesu d'instalación.",
+ "config-wiki-language": "Llingua de la wiki:",
+ "config-wiki-language-help": "Seleicione la llingua que s'usará preferentemente na wiki.",
+ "config-back": "← Atrás",
+ "config-continue": "Siguir →",
+ "config-page-language": "Llingua",
+ "config-page-welcome": "¡Bienveníu a MediaWiki!",
+ "config-page-dbconnect": "Conectar cola base de datos",
+ "config-page-upgrade": "Anovar instalación esistente",
+ "config-page-dbsettings": "Configuración de la base de datos",
+ "config-page-name": "Nome",
+ "config-page-options": "Opciones",
+ "config-page-install": "Instalar",
+ "config-page-complete": "¡Completo!",
+ "config-page-restart": "Reaniciar la instalación",
+ "config-page-readme": "Llei-me",
+ "config-page-releasenotes": "Notes de la versión",
+ "config-page-copying": "Copiar",
+ "config-page-upgradedoc": "Anovando",
+ "config-page-existingwiki": "Wiki esistente",
+ "config-help-restart": "¿Quier llimpiar tolos datos guardaos qu'escribió y reaniciar el procesu d'instalación?",
+ "config-restart": "Sí, reanicialu",
+ "config-welcome": "=== Comprobaciones del entornu ===\nAgora van facese unes comprobaciones básiques para ver si l'entornu ye afayadizu pa la instalación de MediaWiki.\nAlcuérdese d'incluir esta información si necesita encontu pa completar la instalación.",
+ "config-copyright": "=== Drechos d'autor y condiciones d'usu ===\n\n$1\n\nEsti programa ye software llibre; pue redistribuilu y/o camudalu baxo les condiciones de la llicencia pública xeneral GNU tal como la publica la Free Software Foundation; versión 2 o (como prefiera) cualquier versión posterior.\n\nEsti programa distribúise cola esperanza de que pueda ser útil, pero '''ensin garantía denguna'''; nin siquiera la garantía implícita de '''comercialidá''' o '''adautación a un fin determináu'''.\nVea la Llicencia pública xeneral GNU pa más detalles.\n\nHabría de tener recibío <doclink href=Copying>una copia de la llicencia pública xeneral GNU</doclink> xunto con esti programa; sinón, escriba a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [http://www.gnu.org/copyleft/gpl.html lléala en llinia].",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/gl Páxina principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guía del usuariu]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guía del alministrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Entrugues frecuentes]\n----\n* <doclink href=Readme>Lléame</doclink>\n* <doclink href=ReleaseNotes>Notes de llanzamientu</doclink>\n* <doclink href=Copying>Copia</doclink>\n* <doclink href=UpgradeDoc>Anovamientu</doclink>",
+ "config-env-good": "Comprobóse l'entornu.\nPue instalar MediaWiki.",
+ "config-env-bad": "Comprobóse l'entornu.\nNun pue instalar MediaWiki.",
+ "config-env-php": "PHP $1 ta instaláu.",
+ "config-env-hhvm": "HHVM $1 ta instaláu.",
+ "config-unicode-using-intl": "Usando la [http://pecl.php.net/intl estensión intl PECL] pa la normalización Unicode.",
+ "config-unicode-pure-php-warning": "'''Avisu:''' La [http://pecl.php.net/intl estensión intl PECL] nun ta disponible pa xestionar la normalización Unicode; volviendo a la implementación lenta en PHP puru.\nSi xestiona un sitiu con un tráficu altu, tendría de lleer una migaya sobro la [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalización Unicode].",
+ "config-unicode-update-warning": "'''Avisu:''' La versión instalada del envoltoriu de normalización Unicode usa una versión antigua de la biblioteca [http://site.icu-project.org/ de los proyeutos ICU].\nTendría [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations d'anovala] si ye importante pa vusté usar Unicode.",
+ "config-no-db": "¡Nun pudo alcontrase un controlador de base de datos afayadizu! Necesites instalar un controlador de base de datos pa PHP.\n{{PLURAL:$2|Tien sofitu el tipu de base de datos siguiente|Tienen sofitu los tipos de base de datos siguientes}}: $1.\n\nSi compilasti PHP tu mesmu, reconfigúralu con un cliente de base de datos activáu, por exemplu, usando <code>./configure --with-mysqli</code>.\nSi instalasti PHP dende un paquete de Debian o Ubuntu, necesites instalar tamién,por exemplu, el paquete <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Avisu:''' tien SQLite $1, que ye inferior a la versión mínima necesaria $2. SQLite nun tará disponible.",
+ "config-no-fts3": "'''Avisu:''' SQLite ta compiláu ensin el [//sqlite.org/fts3.html módulu FTS3]; les funciones de gueta nun tarán disponibles nesti sistema.",
+ "config-pcre-old": "<strong>Fatal:</strong> Ríquese PCRE $1 o posterior.\nEl binariu de PHP ta enllazáu con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Más información].",
+ "config-pcre-no-utf8": "<strong>Erru fatal:</strong> Paez que'l módulu PCRE de PHP foi compiláu ensin el soporte PCRE_UTF8.\nMediaWiki requier compatibilidá con UTF_8 pa furrular correutamente.",
+ "config-memory-raised": "El parámetru <code>memory_limit</code> de PHP ye $1. Auméntase a $2.",
+ "config-memory-bad": "<strong>Alvertencia:</strong>: el parámetru <code>memory_limit</code> de PHP ye $1.\nProbablemente sía demasiáu baxu.\n¡La instalación puede fallar!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] ta instaláu",
+ "config-apc": "[http://www.php.net/apc APC] ta instaláu",
+ "config-apcu": "[http://www.php.net/apcu APCu] ta instaláu",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] ta instaláu",
+ "config-no-cache-apcu": "<strong>Warning:</strong> Non pudo atopase[http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] o [http://www.iis.net/download/WinCacheForPhp WinCache].\nEl caxé d'oxetos nun ta activáu.",
+ "config-mod-security": "<strong>Alvertencia:</strong> El to servidor web tien activáu [http://modsecurity.org/mod_security]/mod_security2 .Munches de les sos configuraciones comunes pueden causar problemes a MediaWiki o otru software que dexe a los usuarios publicar conteníu arbitrario. De ser posible, tendríes de desactivalo. Si non, consulta la [http://modsecurity.org/documentation/ mod_security documentation] o contacta col alministrador del to servidor si atopes erros aleatorios.",
+ "config-diff3-bad": "Nun s'alcontró GNU diff3.",
+ "config-git": "Alcontróse'l software de control de versiones Git: <code>$1</code>.",
+ "config-git-bad": "Nun s'alcontró el software de control de versiones Git.",
+ "config-imagemagick": "ImageMagick atopáu: <code>$1</code>.\nLa miniaturización d'imaxes habilitaráse si habilites les cargues.",
+ "config-gd": "Atopóse una biblioteca de gráficos GD integrada.\nLa miniaturización d'imaxes habilitaráse si habilites les xubíes.",
+ "config-no-scaling": "Nun s'atopó la biblioteca GD o ImageMagik.\nVa desactivase la miniaturización d'imaxes.",
+ "config-no-uri": "<strong>Erru:</strong> non pudo determinase el URI actual. Atayóse la instalación.",
+ "config-no-cli-uri": "<strong>Alvertencia:</strong> Nun s'especificó <code>--scriptpath</code>, úsase'l valor predetermináu <code>$1</code>.",
+ "config-using-server": "Utilizando'l nome de servidor \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Utilizando la URL del servidor \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Alvertencia:</strong> El to directoriu predetermináu pa les cargues <code>$1</code> ye vulnerable a la execución de scripts arbitrarios.\nAnque MediaWiki comprueba tolos archivos cargaos por si hubiera amenaces de seguridá, ye altamente recomendable [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security close this security vulnerability] enantes d'activar les cargues.",
+ "config-no-cli-uploads-check": "<strong>Alvertencia:</strong> el to directoriu predetermináu pa cargues <code>$1</code> nun tá comprobáu contra la vulnerabilidá d'execución arbitraria de scripts mientres la instalación per llínea de comandos.",
+ "config-brokenlibxml": "El sistema tien una combinación de versiones de PHP y de libxml2 que ye pocu confiable y puede provocar corrupción oculta nos datos de MediaWiki y otres aplicaciones web. Actualiza a libxml2 2.7.3 o posterior ([https://bugs.php.net/bug.php?díi=45996 bug reportáu con PHP]). Instalación albortada.",
+ "config-suhosin-max-value-length": "Suhosin ta instaláu y llinda el parámetru <code>length</code> GET a $1 bytes.\nEl componente ResourceLoader (xestor de recursos) de MediaWiki va trabayar nesta llende, pero eso va perxudicar el rendimientu.\nSi ye posible, tendríes d'establecer <code>suhosin.get.max_value_length</code> nel valor 1024 o superior en <code>php.ini</code> y establecer <code>$wgResourceLoaderMaxQueryLength</code> nel mesmu valor en <code>LocalSettings.php</code>.",
+ "config-db-type": "Tipu de base de datos:",
+ "config-db-host": "Servidor de la base de datos:",
+ "config-db-host-help": "Si'l to servidor de base de datos ta n'otru servidor, escribe'l nome del equipu o la so dirección IP equí.\n\nSi tas utilizando alojamiento web compartíu, el to provisor tendría de date'l nome correctu del servidor na so documentación.\n\nSi vas instalar nun servidor Windows y a utilizar MySQL, l'usu de \"localhost\" como nome del servidor puede nun #funcionar. Si ye asina, intenta poner \"127.0.0.1\" como dirección IP local.\n\nSi utilices PostgreSQL, dexa esti campu vacío pa conectase al traviés d'un socket de Unix.",
+ "config-db-host-oracle": "TNS de la base de datos:",
+ "config-db-host-oracle-help": "Escribe un [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm nome de conexón local] válidu; un archivu tnsnames.ora ten de ser visible pa esta instalación.<br />Si tas utilizando biblioteques de veceru 10g o más recién tamién puedes utilizar el métodu de asignación de nomes [http://download.oracle.com/docs/cd/Y11882_01/network.112/y10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifica esta wiki",
+ "config-db-name": "Nome de base de datos:",
+ "config-db-name-help": "Escueye un nome qu'identifique la to wiki. Nun tien de contener espacios. \nSi tas utilizando agospiamientu web compartíu, el to provisor va date un nome específicu de base de datos por que lu utilices, o bien va dexate crear bases de datos al traviés d'un panel de control.",
+ "config-db-name-oracle": "Esquema de la base de datos:",
+ "config-db-account-oracle-warn": "Hai tres escenarios compatibles pa la instalación de Oracle como motor de base de datos:\n\nSi desees crear una cuenta de base de datos como parte del procesu d'instalación, por favor apurre una cuenta con rol SYSDBA como cuenta de base de datos pa la instalación y especifica les credenciales que quies tener pal accesu a la web a la cuenta; d'otra miente, puedes crear manualmente la cuenta d'accesu a la web y suministrar namái esa cuenta (si tien los permisos necesarios pa crear los oxetos d'esquema) o dar dos cuentes distintos, una con privilexos de creación y otra con accesu acutáu a la web\n\nLa secuencia de comandos (script) pa crear una cuenta colos privilexos necesarios puede atopase nel direutoriu \"maintenance/oracle/\" d'esta instalación. Ten en cuenta qu'utilizar una cuenta acutada va desactivar toles capacidaes de caltenimientu cola cuenta predeterminada.",
+ "config-db-install-account": "Cuenta d'usuariu pa la instalación",
+ "config-db-username": "Nome d'usuariu de base de datos:",
+ "config-db-password": "Contraseña de base de datos:",
+ "config-db-install-username": "Introduz un nome d'usuariu que s'usará pa coneutase cola base de datos nel procesu d'instalación. Esti nun ye'l nome d'usuariu de la cuenta MediaWiki, ye'l nome d'usuariu de la to base de datos.",
+ "config-db-install-password": "Escribe la contraseña que se va utilizar pa conectase a la base de datos mientres el procesu d'instalación.\nEsta nun ye la contraseña de la cuenta de MediaWiki, sinón la contraseña de la base de datos.",
+ "config-db-install-help": "Escribe'l nome d'usuariu y la contraseña que se van utilizar pa conectase a la base de datos mientres el procesu d'instalación.",
+ "config-db-account-lock": "Usar el mesmu nome d'usuariu y contraseña demientres la operación normal",
+ "config-db-wiki-account": "Cuenta d'usuariu pa operar normalmente",
+ "config-db-wiki-help": "Escribe'l nome d'usuariu y la contraseña que se van utilizar p'aportar a la base de datos mientres la operación normal de la wiki.\nSi esta cuenta nun esiste y la cuenta d'instalación tien permisos bastante, va crease esta cuenta d'usuariu colos mínimos permisos necesarios pa operar normalmente la wiki.",
+ "config-db-prefix": "Prefixu de tables de la base de datos:",
+ "config-db-prefix-help": "Si precises compartir una base de datos ente múltiples wikis, o ente MediaWiki y otra aplicación web, puedes optar por amestar un prefixu a tolos nomes de tabla pa evitar conflictos.\nNun utilices espacios.\n\nDe normal déxase esti campu vacío.",
+ "config-mysql-old": "Precísase MySQL $1 o posterior. Tienes $2.",
+ "config-db-port": "Puertu de la base de datos:",
+ "config-db-schema": "Esquema pa MediaWiki:",
+ "config-db-schema-help": "Esti esquema de vezu va tar bien.\nCamúdalos solo si sabes que lo precises.",
+ "config-pg-test-error": "Nun puede coneutase cola base de datos <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Direutoriu de datos SQLite:",
+ "config-oracle-def-ts": "Espaciu de tables predetermináu:",
+ "config-oracle-temp-ts": "Espaciu de tables temporal:",
+ "config-type-mysql": "MySQL (o compatible)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki ye compatible colos siguientes sistemes de bases de datos:\n\n$1\n\nSi nun atopes na llista el sistema de base de datos que tas intentando utilizar, sigue les instrucciones enllazaes enriba p'activar la compatibilidá.",
+ "config-header-mysql": "Configuración de MySQL",
+ "config-header-postgres": "Configuración de PostgreSQL",
+ "config-header-sqlite": "Configuración de SQLite",
+ "config-header-oracle": "Configuración d'Oracle",
+ "config-header-mssql": "Configuración de Microsoft SQL Server",
+ "config-invalid-db-type": "Triba non válida de base de datos.",
+ "config-missing-db-name": "Tienes d'introducir un valor pa «{{int:config-db-name}}».",
+ "config-missing-db-host": "Tienes d'escribir un valor pa «{{int:config-db-host}}».",
+ "config-missing-db-server-oracle": "Tienes d'escribir un valor pa «{{int:config-db-host-oracle}}».",
+ "config-invalid-db-server-oracle": "TNS inválidu pa la base de datos «$1».\nUsa una cadena «TNS Name» o «Easy Connect» ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Métodos de nomenclatura d'Oracle]).",
+ "config-invalid-db-name": "Nome inválidu de la base de datos «$1».\nUsa sólo lletres ASCII (a-z, A-Z), númberos (0-9), guiones baxos (_) y guiones (-).",
+ "config-invalid-db-prefix": "Prefixu inválidu pa la base de datos «$1».\nUsa sólo lletres ASCII (a-z, A-Z), númberos (0-9), guiones baxos (_) y guiones (-).",
+ "config-connection-error": "$1.\n\nComprueba'l sirvidor, el nome d'usuariu y la contraseña, y tenta nuevamente.",
+ "config-invalid-schema": "Esquema inválidu «$1» pa MediaWiki.\nUsa sólo lletres ASCII (a-z, A-Z), númberos (0-9) y guiones baxos (_).",
+ "config-db-sys-create-oracle": "L'instalador sólo almite l'emplegu d'una cuenta SYSDBA pa crear una cuenta nueva.",
+ "config-db-sys-user-exists-oracle": "La cuenta d'usuariu «$1» yá esiste. ¡SYSDBA sólo puede utilizase pa crear una nueva cuenta!",
+ "config-postgres-old": "Ríquese PostgreSQL $1 o posterior. Tienes la versión $2.",
+ "config-mssql-old": "Ríquese Microsoft SQL Server $1 o posterior. Tienes la versión $2.",
+ "config-sqlite-name-help": "Escueye'l nome qu'identifica la to wiki.\nNun uses espacios o guiones.\nEsti va usase como nome del ficheru de datos pa SQLite.",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Triba d'autenticación:",
+ "config-site-name": "Nome de la wiki:",
+ "config-site-name-help": "Esto apaecerá na barra de títulos del navegador y en dellos sitios más.",
+ "config-site-name-blank": "Escriba un nome pal sitiu.",
+ "config-project-namespace": "Espaciu de nomes del proyeutu:",
+ "config-ns-generic": "Proyeutu",
+ "config-ns-site-name": "Igual que'l nome de la wiki: $1",
+ "config-ns-other": "Otru (especificar)",
+ "config-ns-other-default": "MioWiki",
+ "config-admin-name": "El to nome d'usuariu:",
+ "config-admin-password": "Contraseña:",
+ "config-optional-skip": "Yá toi aburríu, namái instala la wiki.",
+ "config-profile-private": "Wiki privada",
+ "config-extensions": "Estensiones",
+ "config-download-localsettings": "Descargar <code>LocalSettings.php</code>",
+ "config-help": "Ayuda",
+ "config-nofile": "Nun pudo atopase'l ficheru \"$1\". ¿Desaniciose?",
+ "mainpagetext": "<strong>Instalóse MediaWiki.</strong>",
+ "mainpagedocfooter": "Consulta la [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guía del usuariu] pa saber cómo usar el software wiki.\n\n== Primeros pasos ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Llista de les opciones de configuración]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ EMF de MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Llista de corréu de llanzamientos de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Llocaliza MediaWiki na to llingua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Depriende como combatir la puxarra na to wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/av.json b/www/wiki/includes/installer/i18n/av.json
new file mode 100644
index 00000000..3568258e
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/av.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gazimagomedov"
+ ]
+ },
+ "config-page-welcome": "ЛъикI щварал МедиаВикиялда!",
+ "config-page-name": "ЦӀар",
+ "config-page-options": "Рекъезаби",
+ "config-page-complete": "ЛъугӀана!"
+}
diff --git a/www/wiki/includes/installer/i18n/avk.json b/www/wiki/includes/installer/i18n/avk.json
new file mode 100644
index 00000000..6d6b80c8
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/avk.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki inkeyen talpeyot.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/az.json b/www/wiki/includes/installer/i18n/az.json
new file mode 100644
index 00000000..15473a80
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/az.json
@@ -0,0 +1,32 @@
+{
+ "@metadata": {
+ "authors": [
+ "Cekli829",
+ "Vago",
+ "Wertuose",
+ "Khan27"
+ ]
+ },
+ "config-desc": "MediaWiki yükləyicisi",
+ "config-information": "Məlumat",
+ "config-back": "← Geri",
+ "config-continue": "Davam et →",
+ "config-page-language": "Dil",
+ "config-page-welcome": "MediaWiki-yə xoş gəlmişsiniz!",
+ "config-page-dbconnect": "Verilənlər bazasına birləşdir",
+ "config-page-dbsettings": "Verilənlər bazasının nizamlanması",
+ "config-page-name": "Ad",
+ "config-page-options": "Nizamlamalar:",
+ "config-page-install": "Nizamlama",
+ "config-page-complete": "Komplektləşdir!",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-utf8": "UTF-8",
+ "config-ns-generic": "Layihə",
+ "config-admin-name": "Sizin istifadəçi adınız:",
+ "config-admin-password": "Parol:",
+ "config-admin-email": "E-poçt ünvanı",
+ "config-license-pd": "İctimai istifadə",
+ "config-help": "kömək",
+ "mainpagetext": "'''MediaWiki müvəffəqiyyətlə quraşdırıldı.'''",
+ "mainpagedocfooter": "Bu vikinin istifadəsi ilə bağlı məlumat almaq üçün [https://meta.wikimedia.org/wiki/Help:Contents İstifadəçi məlumat səhifəsinə] baxın.\n\n== Faydalı keçidlər ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Tənzimləmələrin siyahısı]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki haqqında tez-tez soruşulan suallar]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki e-poçt siyahısı]"
+}
diff --git a/www/wiki/includes/installer/i18n/azb.json b/www/wiki/includes/installer/i18n/azb.json
new file mode 100644
index 00000000..0e94a501
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/azb.json
@@ -0,0 +1,40 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mousa",
+ "Koroğlu",
+ "Ebrahimi-amir",
+ "Alp Er Tunqa"
+ ]
+ },
+ "config-desc": "مئدیاویکی قوروجوسو",
+ "config-title": "مئدیاویکی $1 قورماغی",
+ "config-information": "بیلگیلر",
+ "config-localsettings-key": "یوکسلتمک کلیدی:",
+ "config-localsettings-badkey": "وئردیگینیز کیلید دوغرو دئییل.",
+ "config-your-language": "دیلینیز:",
+ "config-wiki-language": "ویکی دیلی:",
+ "config-back": "→ دالی",
+ "config-continue": "سونرا ←",
+ "config-page-language": "دیل",
+ "config-page-welcome": "مئدیاویکی‌یه خوش گلدینیز!",
+ "config-page-name": "آد",
+ "config-page-options": "سئچیملر",
+ "config-page-install": "قور",
+ "config-page-complete": "قورتاردی!",
+ "config-page-restart": "قورماغی یئنی‌دن باشلات",
+ "config-page-readme": "منی اوخو",
+ "config-env-php": "PHP $1 قورولوبدور.",
+ "config-env-hhvm": "HHVM $1 قورولوبدور.",
+ "config-using-server": "«<nowiki>$1</nowiki>» سِروِر آدی ایشلنیر.",
+ "config-using-uri": "«<nowiki>$1$2</nowiki>» سِروِر آدرسی ایشلنیر.",
+ "config-site-name": "ویکی آدی:",
+ "config-ns-generic": "پروژه",
+ "config-admin-box": "ایداره‌چی حسابی",
+ "config-admin-name": "ایشلدن آدینیز:",
+ "config-admin-password": "رمز:",
+ "config-admin-email": "ایمیل آدرسی:",
+ "config-help": "کؤمک",
+ "config-help-tooltip": "گئنیشلتمک اوچون کلیک ائدین",
+ "config-nofile": "«$1» فایلی تاپیلانمادی. سیلینیبدیرمی؟"
+}
diff --git a/www/wiki/includes/installer/i18n/ba.json b/www/wiki/includes/installer/i18n/ba.json
new file mode 100644
index 00000000..909a1a2e
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ba.json
@@ -0,0 +1,317 @@
+{
+ "@metadata": {
+ "authors": [
+ "Haqmar",
+ "Seb35",
+ "Рустам Нурыев",
+ "Sagan",
+ "Азат Хәлилов",
+ "Айсар",
+ "Янмурза Баки",
+ "Гульчатай",
+ "Вильданова Гюзель",
+ "З. ӘЙЛЕ",
+ "Ләйсән"
+ ]
+ },
+ "config-desc": "MediaWiki йөкләүсе",
+ "config-title": "MediaWiki $1 йөкләмеше",
+ "config-information": "Мәғлүмәт",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code> файлы бар. \nБыл күрһәтмәне яңыртыу өсөн <code>$wgUpgradeKey</code> мәғәнәһен яҙығыҙ.\nУны <code>LocalSettings.php</code> файлында табырға була.",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code> файлы бар. \nБыл күрһәтмәне яңыртыу өсөн <code>update.php</code> эшләтеп ебәрегеҙ.",
+ "config-localsettings-key": "Яңыртыу асҡысы:",
+ "config-localsettings-badkey": "Дөрөҫ булмаған асҡыс күрһәттегеҙ",
+ "config-upgrade-key-missing": "Ҡуйылған MediaWiki копияһы булыуы асыҡланды. Яңыртыу өсөн файл аҙағында ҡуйығыҙ: <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Ғәмәлдәге <code>LocalSettings.php</code> файла тулған. $1 билдәләнмәгән. Зинһар өсөн <code>LocalSettings.php</code> кодын үҙгәртегеҙ. Һуңынан ошонда сиртегеҙ «{{int:Config-continue}}».",
+ "config-localsettings-connection-error": "<code>LocalSettings.php</code> көйләүҙәрендә хата китте йәки <code>LocalSettings.php</code> көйләүҙәрендә хата. Зинһар өсөн, көйләүҙәрҙе тәҙәтегеҙ, яңынан эшләп ҡарағыҙ\n$1",
+ "config-session-error": "Эш башлағанда сыҡҡан хата: $1",
+ "config-session-expired": "Һеҙҙең ваҡыт үтте. Сессия $1 оҙонлоғона көйләнгән. Уны оҙайтыу өсөн php.ini. эсендә <code>session.gc_maxlifetime</code> кодын үҙгәртегеҙ.\nУрынлаштырыу процессын яңынан башлағыҙ.",
+ "config-no-session": "Был сессия юғалған!\nҮҙегеҙҙең php.ini тикшерегеҙ, \n<code>session.save_path</code> коды тейешле каталогҡа урынлаштырылған икәненә ышынғыҙ.",
+ "config-your-language": "Һеҙҙең тел:",
+ "config-your-language-help": "Урынлаштырыу процессы ҡулланған телде һайлағыҙ.",
+ "config-wiki-language": "Вики телдәре:",
+ "config-wiki-language-help": "Викила ҡулланылған телде һайлағыҙ.",
+ "config-back": "← Кире",
+ "config-continue": "Дауам итергә →",
+ "config-page-language": "Тел",
+ "config-page-welcome": "MediaWiki-ға рәхим итегеҙ!",
+ "config-page-dbconnect": "Мәғлүмәт болона тоташыу",
+ "config-page-upgrade": "Ғәмәлдәге урынлаштырғанды яңыртыу.",
+ "config-page-dbsettings": "Мәғлүмәт болон көйләү",
+ "config-page-name": "Исем",
+ "config-page-options": "Көйләүҙәр",
+ "config-page-install": "Урынлаштырыу",
+ "config-page-complete": "Тамам!",
+ "config-page-restart": "Урынлаштырыуҙы яңынан башларға",
+ "config-page-readme": "Мине уҡы",
+ "config-page-releasenotes": "Өлгө тураһында мәғлүмәт",
+ "config-page-copying": "Рөхсәтнамә",
+ "config-page-upgradedoc": "Яңыртыу",
+ "config-page-existingwiki": "Ғәмәлдәге вики",
+ "config-help-restart": "Һеҙ үҙегеҙ индергән һәм һаҡланған әлеге мәғлүмәттәрҙе юйып, урынлаштырыуҙың яңы процессын ебәрергә теләйһегеҙме?",
+ "config-restart": "Эйе, яңынан башларға",
+ "config-welcome": "=== Даирәне тикшереү ===",
+ "config-copyright": "=== Авторлыҡ хоҡуҡтары һәм шарттар ===\n\n$1\n\nMediaWiki - ирекле программа тьәминәте. Һеҙ уны ирекле программа тьәминәте фонды баҫып сығарған GNU General Public License лицензия талаптарына ярашлы рәүештә тарата һәм/йәки үҙгәртә алаһығыҙ;икенсе версияһына йәки ниндәйҙә булһа һуңғы версияһына ярашлы рәүештә.\nMediaWiki - файҙалы булыу өмөтө менән таратыла, ләкин <strong> бер ниндәй ҙә гарантияларһыҙ</strong>, хатта күҙ уңында тотолған гарантияларһыҙ <strong> коммерция ҡимәтенән тыш </strong> йәки </strong> ниндәй ҙә булһа маҡсатҡа яраҡһыҙ </strong>. Ҡара. тулыраҡ мәғлүмәт алыу өсөн GNU General Public License лицезияһы. \nҺеҙ <doclink href=Copying> копияһын GNU General Public License</doclink>ошо программа менән бергә алырға тейеш инегеҙ, әгәр алмаһағыҙ, Free Software Foundation, Inc. ошо адрес буйынса яҙығыҙ:51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA йәки [http://www.gnu.org/copyleft/gpl.html уны онлайнда уҡығыҙ].",
+ "config-sidebar": "* [https://www.mediawiki.org Сайт MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/ru Ҡулланыусылар өсөн белешмә]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/ru Администраторҙар өсөн белешмә]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/ru FAQ]\n----\n* <doclink href=Readme>Readme-файл</doclink>\n* <doclink href=ReleaseNotes>Сығарылыш тураһында мәғлүмәт</doclink>\n* <doclink href=Copying>Лицензия</doclink>\n* <doclink href=UpgradeDoc>Яңыртыуҙар</doclink>",
+ "config-env-good": "Мөхитте тикшереү уңышлы тамамланды. MediaWiki урынлаштырырға мөмкин.",
+ "config-env-bad": "Мөхит тикшерелде. Һеҙ MediaWiki урынлаштыра алмайһығыҙ.",
+ "config-env-php": "PHP: $1 өлгөһө урынлаштырылды.",
+ "config-env-hhvm": "HHVM $1 урынлаштырылды.",
+ "config-unicode-using-intl": " [http://pecl.php.net/intl ҡушылмаһы файҙаланасаҡ, «intl» для PECL] Юникод нормаль эшләһен өсөн.",
+ "config-unicode-pure-php-warning": "'''Иғтибар!''': [http://pecl.php.net/intl ҡушылмаһы intl из PECL] Юникод өсөн рөхсәт ителмәгән PHP менән бик әкрен эшләйәсәк.\nҺеҙҙең сайт бик көсөргәнешле эшләһә [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализации Юникодты нормалләштереү] өсөн уҡығыҙ.",
+ "config-unicode-update-warning": "\"Иҫкәртеү\". Ҡуйылған тышлыҡ Юникодты нормаға килтереүҙең иҫке китапхана версияһын ҡуллана[http://site.icu-project.org/ проекта ICU].Әгәр Юникодты тулы мәғәнәһендә ҡулланырға теләһәгеҙ, һеҙ [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations версияны яңыртырға] тейешһегеҙ.",
+ "config-no-db": "Мәғлүмәттәр базаһына тура килгән драйверҙарҙы табып булманы!Һеҙгә PHP өсөн мәғлүмәттәр базаһының драйверҙарын ҡуйырға кәрәк.{{PLURAL:$2|сираттағы төр ҡулланыла|сираттағы төрҙәр ҡулланыла}}мәғлүмәттәр базалары:$1.\nӘгәр һеҙ үҙегеҙ PHP -ға компиляция яһаған булһағыҙ, мәғлүмәттәр базаһына клиентты индереп уны яңынан, мәҫәлән, <code>./configure --with-mysqli</code> ярҙамы менән көйләгеҙ. Әгәр ҙә һеҙ PHP -ны Debian йәки Ubuntu пакеттарынан ҡуйһағыҙ, һеҙгә, мәҫәлән, <code>php5-mysql</code> пакетын да ҡуйырға кәрәк булыр.",
+ "config-outdated-sqlite": "'''Киҫәтеү''': Һеҙҙә SQLite $1 ҡуйылған, $2 тейешле өлгөнән түбән . SQLite асылмаясаҡ.",
+ "config-no-fts3": "'''Иғтибар''': SQLite модулһыҙ йыйлған [//sqlite.org/fts3.html FTS3] — был мәғлүмәт базаһы өсөн эҙләү мөмкин булмаясаҡ.",
+ "config-pcre-old": "'''Фаталь хата:''' PCRE версияһы йәки яңырағы талап ителә $1.\nБашҡарылыусы файл PHP менән бәйләнгән PCRE $2версияһы.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Ентекләберәк].",
+ "config-pcre-no-utf8": "'''Фаталь хата'''. PHP өсөн PCRE модуле PCRE_UTF8 менән яраҡлыштырылмаған.\nMediaWiki дөрөҫ эшләһен өсөн UTF-8 талап ителә.",
+ "config-memory-raised": "Хәтер сикләнгән PHP (<code>memory_limit</code>) $1 $2 тиклем арттырылған.",
+ "config-memory-bad": "'''Иғтибар:''' PHP күләме <code>memory_limit</code> $1 тәшкил итә.\nБәлки, был саманан тыш аҙҙыр. \nҠуйылыштың уңышһыҙлыҡҡа осрауы бар!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] урынлаштырылды",
+ "config-apc": "[http://www.php.net/apc APC] урынлаштырылды",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] урынлыштырылды",
+ "config-no-cache-apcu": "'''Иғтибар:''' [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] табылманы йәки [http://www.iis.net/download/WinCacheForPhp WinCache].\nОбъекттарҙы кэшлау һүндереләсәк..",
+ "config-mod-security": "<strong>Иғтибар</strong>: һеҙҙең веб-серверығыҙҙа [http://modsecurity.org/ mod_security]/mod_security2 ҡабыҙылған. Уның күп кенә стандарт көйләүҙәре MediaWiki йәки бүтән ПО ҡулланыусыларға серверға ирекле контент ебәрегрә мөмкинлек буйынса проблемалар тыуҙырыуы мөмкин.\nКөтөлмәгән хаталарға тап булһағыҙ, ошонда [http://modsecurity.org/documentation/ документации mod_security]йәки үҙегеҙҙең хостинг-провайдерығыҙға мөрәжәғәт итегеҙ.",
+ "config-diff3-bad": "GNU diff3 табылманы.",
+ "config-git": "Git өлгөләрҙе контролләү системаһы табылды: <code>$1</code>.",
+ "config-git-bad": "Git өлгөләре менән идара итеү программаһы табылды?",
+ "config-imagemagick": "ImageMagick: <code>$1</code> табылды.\nФайлдарҙы тейәргә рөхсәт итһәгеҙ, рәсемдәрҙе миниатюр итеп күһәтеү мөминлеге бар.",
+ "config-gd": "Found GD graphics library built-in.\nImage thumbnailing will be enabled if you enable uploads.",
+ "config-no-scaling": "Эске китапхананы GD йәки ImageMagick табып булманы.\nМиниатюр рәсемдәр ҡарау мөмкин булмаясаҡ.",
+ "config-no-uri": "'''Хата:''' Ағымдағы URI билдәләп булмай.\nУрынлаштырыу өҙөлдө.",
+ "config-no-cli-uri": "'''Киҫәтеү''': параметр күрһәтелмәгән <code>--scriptpath</code>, килешеү байынса: <code>$1</code> .",
+ "config-using-server": "«<nowiki>$1</nowiki>» сервер исеме файҙаланыла.",
+ "config-using-uri": " \"<nowiki>$1$2</nowiki>\" сервер исеме файҙаланыла.",
+ "config-uploads-not-safe": "'''Иғтибар:''' (<code>$1</code>) күсереүҙәре өсөе ҡулланылған директория ирекле скриптар яһау өсөн бармай. \nMediaWiki барлыҡ күсерелгән файлдарҙы тикшерһә лә, файлды күсереүҙән алда [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security бирешеүсәнлекте ябыу] тәҡдим ителә.",
+ "config-no-cli-uploads-check": "\"Иғтибар\" әйтмәйенсә тейәлеү өсөн каталог (<code>$1</code>) CLI урынлаштырған мәлдә тиҙ боҙололоусанлыҡҡа, ирекле сценарийҙы үтәүгә тикшерелмәне.",
+ "config-brokenlibxml": "Системағыҙҙа MediaWiki һәм башҡа веб-ҡушымталар мәғлүмәттәрен йәшерен щарралауы ихтимал булған PHP һәм libxml2 версиялары бар. \nlibxml2 2.7.3 йәки юғарыраҡ версияға ([https://bugs.php.net/bug.php?id=45996 хата тураһында мәғлүмәттәр]) тиклем яңыртығыҙ.\nБәйләнеш өҙөлдө.",
+ "config-suhosin-max-value-length": "Suhosin ҡуйылған һәм GET <code>length</code> параметрын $1 байтҡаса кәметә. MediaWiki-ның ResourceLoader компоненты был сикләүҙе урап үтә, әммә был етештереүсәнлекте кәметә. Әгәр мөмкин булһа, 1024 асылындағы <code>suhosin.get.max_value_length</code> йәки унан юғарыраҡ булған <code>php.ini</code>, шулай уҡ <code>$wgResourceLoaderMaxQueryLength</code> өсөн шундай уҡ LocalSettings.php ҡуйырға була.",
+ "config-db-type": "Мәғлүмәт базаһы төрө:",
+ "config-db-host": "Мәғлүмәт базаһы хосты:",
+ "config-db-host-help": "Әгәр ҙә серверҙың база мәғлүмәттәре икенсе серверҙа урынлашһа, бында уның исемен йәки IP-адресын индерегеҙ.\nӘгәр ҙә һеҙ виртуаль хостингты ҡулланһағыҙ, һеҙҙең провайдерығыҙ хостың дөрөҫ исемен үҙенең документацияһында күрһәтергә тейеш.\nӘгәр ҙә һеҙ системаны Windows аҫтына ҡуяһығыҙ һәм MySQL - ды ҡулланаһығыҙ икән, «localhost» исемле сервер эшләй алмаясаҡ. Был осраҡта 127.0.0.1 локаль IP-адресығыҙҙы күрһәтергә тырышығыҙ.\nӘгәр ҙә һеҙ PostgreSQL-ды ҡулланаһығыҙ икән, был шаҡмаҡты сокет Unix аша инеү өсөн буш ҡалдырығыҙ.",
+ "config-db-host-oracle": "TNS мәғлүмәт базаһы:",
+ "config-db-host-oracle-help": "Ғәмәлдәге [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name] индерегеҙ; tnsnames.ora файлы был инсталляция өсөн күренергә тейеш. <br /> Клиенттарҙың 10g версияһындағы һәм юғарыраҡ китапханаһын ҡулланғанда шулай уҡ атама биреү ысулын файҙаланыу мөмкинлеге бар [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Был викиҙың идентификацияһы",
+ "config-db-name": "Мәғлүмәт базаһы исеме:",
+ "config-db-name-help": "Үҙегеҙҙең вики өсөн исем - идентификатор һайлағыҙ.\nИсемдә тултырылмаған урын булмаҫҡа тейеш.\nӘгәр һеҙ виртуаль хостингты ҡулланаһығыҙ икән, провайдер һеҙгә мәғлүмәттәр базаһының конкрет исемен бирер йәки идара итеү панеле ярҙамы менән мәғлүмәттәр базаһын булдырырға мөмкинлек бирер.",
+ "config-db-name-oracle": "Мәғлүмәт базаһы схемаһы",
+ "config-db-account-oracle-warn": "Oracle мәғлүмәттәр базаһы итеп ҡуйыуҙың өс юлы бар:\nӘгәр иҫәп яҙмаһын ҡуйыу процесында булдырырға теләһәгеҙ, зинһар, SYSDBA ҡуйыу өсөн иҫәп алыу ролен һәм веб-күҙәтеү мөмкинлеге булған иҫәп алыуҙың теләгән вәкәләттәрен күрһәтегеҙ. Шулай уҡ веб-күҙәтеү мөмкинлеге булған иҫәпте ҡулдан эшләргә һәм уны (әгәр схема объекттарын төҙөүгә кәрәкле рөхсәте бар икән) йәки ике иҫәп яҙмаһын, береһен - объекттар төҙөү хоҡуғы менән, икенсеһен веб-күҙәтеүҙе сикләүсе, күрһәтәһегеҙ. \nТейешле өҫтөнлөктәр менән иҫәп яҙмаһын булдырыу сценарийын ошо ҡоролма программаһының «maintenance/oracle/» папкаһында табырға мөмкин. Сикләнгән иҫәп яҙмаһын файҫаланыу килешеү буйынса иҫәп яҙмаларының барлыҡ мөмкинлектәрен һүндереүгә килтереү ихтималлығын күҙ уңында тотоғоҙ.",
+ "config-db-install-account": "Көйләү өсөн иҫәп яҙмаһы",
+ "config-db-username": "Мәғлүмәт базаһын ҡулланыусы исеме",
+ "config-db-password": "Мәғлүмәт базаһының серһүҙе",
+ "config-db-install-username": "Ҡуйылыш процесында мәғлүмәттәр базаһына тоташтырыу өсөн файҙаланасаҡ ҡулланыусы исемен индерегеҙ.\nБыл исем MediaWiki ҡулланыусыныҡы түгел, был мәғлүмәттәр базаһы өсөн ҡулланыусы исеме.",
+ "config-db-install-password": "Ҡуйылыш процесында мәғлүмәттәр базаһына тоташтырыу өсөн файҙаланасаҡ ҡулланыусы исемен индерегеҙ.\nБыл исем MediaWiki ҡулланыусыныҡы түгел, ә мәғлүмәттәр базаһы өсөн ҡулланыусы исеме.",
+ "config-db-install-help": "Ҡуйылыш процесын көйләгәндә мәғлүмәттәр базаһына тоташтырыу өсөн файҙаланасаҡ ҡулланыусы исемен һәм паролен индерегеҙ.",
+ "config-db-account-lock": "Ғәҙәти эш өсөн шул уҡ ҡулланыусы исемен һәм серһүҙен файҙаланырға",
+ "config-db-wiki-account": "Ғәҙәти эш өсөн иҫәп яҙмаһы",
+ "config-db-wiki-help": "Викиҙың ғәҙәттәге эше ваҡытында мәғлүмәт базаһына инеү өсөн файҙаланылған ҡулланыусы исемен һәм серһүҙен индерегеҙ. Әгәр бындай иҫәп яҙмаһы юҡ икән, ә ваҡытлыса яҙма етерлек өҫтөнлөктәргә эйә икән, ғәҙәттәге иҫәп яҙмаһы викиҙа эшләү өсөн кәрәкле минималь өҫтөнлөктәр менән булдырыласаҡ.",
+ "config-db-prefix": "Мәғлүмәт базаһы таблицаларының префиксы",
+ "config-db-prefix-help": "Әгәр һеҙгә бер мәғлүмәт базаһын бер нисә вики йәки MediaWiki һәм башҡа веб-ҡушымталар араһында бүлергә тура килһә, таблицалағы барлыҡ исемдәр өсөн перфикс өҫтәй алаһығыҙ. Ара ҡулланмағыҙ.\nБыл урын ғәҙәттә буш ҡала.",
+ "config-mysql-old": "PostgreSQL $1 йәки тағы ла һуңыраҡ булған версия кәрәк. Һеҙҙә PostgreSQL $2 ҡуйылған.",
+ "config-db-port": "Мәғлүмәт базаһы порты:",
+ "config-db-schema": "MediaWiki өсөн схема:",
+ "config-db-schema-help": "Был схема ғәҙәттә яҡшы эшләй.\nУны, үҙегеҙгә кәрәк булһа ғына, үҙгәртегеҙ",
+ "config-pg-test-error": "Мәғлүмәт базаһына инеп булманы<strong>$1</strong>: $2",
+ "config-sqlite-dir": "SQLite мәғлүмәттәре директориһы:",
+ "config-sqlite-dir-help": "SQLite бөтә мәғлүмәттәрҙе бер файлда һаҡлай. \nҠуйған ваҡытта веб-сервер һеҙ күрһәткән директорияны уҡый алырға тейеш. \n\nУға Интернет аша инеү '''мөмкин түгел''', шуға ул PHP файлдар һаҡланған файл менән тап килмәҫкә тейеш.\nҠуйыусы бал директорияны <code>.htaccess</code> файлына яҙасаҡ, әгәр ул эшләмәһә, кемдер бөтөн мәғлүмәт базаһына инә аласаҡ. Был базала шулай уҡ ҡулланыусылар тураһында мәғлүмәт тә (электрон почта адрестары, серһүҙ хештары), шулай уҡ юйылған биттәр һәм вики тураһында башҡа йәшерен мәғлүмәттәр һаҡлана. \n\nБыл базаны, мөмкин булһа, ситтәрәк, мәҫәлән, <code>/var/lib/mediawiki/yourwiki</code> һаҡлағыҙ.",
+ "config-oracle-def-ts": "Килешеү буйынса таблица арауығы:",
+ "config-oracle-temp-ts": "Таблицаларҙың ваҡытлы киңлеге:",
+ "config-type-mysql": "MySQL (йәки тура килгән)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki -ла түбәндәге СУБД бар:\n\n$1\n\nӘгәр мәғлүмәт һаҡлау системаһын исемлектә күрмәһәгеҙ, рөхсәт алыу өсөн өҫтәге һылтанмалағы инструкция буйынса эш итегеҙ.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] — MediaWiki-ҙың иҫ яҡшы эшләгән төп мәғлүмәттәр базаһы. MediaWiki шулай уҡ MySQL-тап килгән [{{int:version-db-mariadb-url}} MariaDB] һәм [{{int:version-db-percona-url}} Percona Server] менән эшләй. ([http://www.php.net/manual/ru/mysql.installation.php MySQL-ярҙамында PHP туплау инструкцияһы])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] — СУБД-ның популяр открыткаһы, MySQL өсөн альтернатива.\nТөҙәтелмәгән хаталар булыуы мөмкин, эш схемаһында ҡулланыу тәҡдим ителмәй. ([http://www.php.net/manual/ru/pgsql.installation.php PostgreSQL рөхсәт ителгән РНР йыйыу инструкцияһы]).",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] — яҡшы һәм еңел мәғлүмәт базаһы системаһы. ([http://www.php.net/manual/ru/pdo.installation.php собрать PHP SQLite] PDO менән эшләй торған инструкция)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] — предприятие масштабындаға коммерция базыһы. ([http://www.php.net/manual/ru/oci8.installation.php OCI8 ярҙамындағы РНР нисек йыйырға])",
+ "config-dbsupport-mssql": "* [{{int:version-db-oracle-url}} Oracle] — предприятие масштабындаға Windows өсөн коммерция базыһы. ([http://www.php.net/manual/ru/oci8.installation.php OCI8 ярҙамындағы РНР нисек йыйырға])",
+ "config-header-mysql": "MySQL көйләү",
+ "config-header-postgres": "PostgreSQL көйләү",
+ "config-header-sqlite": "SQLite көйләү",
+ "config-header-oracle": "Оракул көйләү",
+ "config-header-mssql": "Microsoft SQL Серверенең билдәле дәүмәлдәре",
+ "config-invalid-db-type": "Нигеҙ тибтарының дөрөҫ булмаған күрһәткестәре",
+ "config-missing-db-name": "Һеҙ мәғәнәне индерергә тейешһегеҙ «{{int:config-db-name}}».",
+ "config-missing-db-host": "Параметр мәғәнәһен индереү мотлаҡ «{{int:config-db-host}}».",
+ "config-missing-db-server-oracle": "Һеҙ бында мәғәнәне индерергә тейешһегеҙ «{{int:config-db-host-oracle}}».",
+ "config-invalid-db-server-oracle": "«$1» мәғлүмәттәр базаһының дөрөҫ булмаған TNS.\nЙә «TNS Name», йә «Easy Connect» ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle атамалары ысулы]) ҡулланығыҙ.",
+ "config-invalid-db-name": "«$1» мәғлүмәттәр базаһының дөрөҫ булмаған префиксы. Тик ASCII символдарын: (a-z, A-Z) хәрефтәрен, (0-9) һандарын, (_) аҫтына һыҙыу билдәһен һәм (-)дефисты ҡулланығыҙ.",
+ "config-invalid-db-prefix": "«$1» мәғлүмәттәр базаһының дөрөҫ булмаған префиксы. Тик ASCII (a-z, A-Z) хәрефтәрен, (0-9) һандарын, (_) аҫтына һыҙыу билдәһен һәм (-)дефисты ҡулланығыҙ.",
+ "config-connection-error": "$1.\n\nХостығыҙҙы, ҡулланыусы исемен һәм паролде тикшерегеҙ ҙә яңынан инеп ҡарағыҙ.",
+ "config-invalid-schema": "MediaWiki «$1» өсөн схема дөрөҫ түгел.\nБары тик ASCII символдарын (a-z, A-Z), цифрҙарҙы (0-9) һәм аҫҡы һыҙыҡты (_) ғына ҡулланығыҙ.",
+ "config-db-sys-create-oracle": "Яңы иҫәп-хисап яҙмаһын булдырыу өсөн урынлаштырыу программаһы тик SYSDBA ҡулланыу хуплана",
+ "config-db-sys-user-exists-oracle": "Иҫәп яҙмаһы \"$1\". SYSDBA яңы иҫәп-хисап яҙмаһын булдырыу өсөн генә ҡулланыла",
+ "config-postgres-old": "PostgreSQL $1 йәки тағы ла һуңыраҡ булған версия кәрәк. Һеҙҙә PostgreSQL $2 ҡуйылған.",
+ "config-mssql-old": "$1 йә һуңыраҡ версиянан Microsoft SQL Server кәрәк. Һеҙҙә $2 версияһы ҡуйылған.",
+ "config-sqlite-name-help": "Үҙегеҙҙең вики өсөн исем-идентификатор һайлағыҙ.\nДефисы һәм буш урын ҡалдырмағыҙ.\nЬыл юл SQLite файлының исемендә ҡулланыласаҡ.",
+ "config-sqlite-parent-unwritable-group": "<nowiki><code>$1</code></nowiki> мәғлүмәт директорияһын эшләп булманы, веб-серверҙың төп директорияны яҙырға хоҡуғы юҡ <nowiki><code>$2</code></nowiki>.\n\nУрынлаштырыусы ҡатнашыусының веб-серверын билдәләне.\n<nowiki><code>$3</code></nowiki> яҙма мөмкин булған директория эшләгеҙ һәм дауам итегеҙ.\nUnix/Linux системаһында түбәндәгене башҡарығыҙ:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "<nowiki><code>$1</code></nowiki> мәғлүмәт директорияһын эшләп булманы, веб-серверҙың төп директорияны яҙырға хоҡуғы юҡ <nowiki><code>$2</code></nowiki>.\n\nУрынлаштырыусы ҡатнашыусының веб-серверын билдәләй алманы.\n<nowiki><code>$3</code></nowiki> яҙма мөмкин булған директория эшләгеҙ һәм дауам итегеҙ.\nUnix/Linux системаһында түбәндәгене башҡарығыҙ:\n\n<pre>cd $2 mkdir $3 chmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "«$1» мәғлүмәттәре өсөн директорий яһауҙа хата.\nУрынлашыуын тикшерегеҙ һәм тағы ла эшләп ҡарағыҙ.",
+ "config-sqlite-dir-unwritable": " «$1» каталогына яҙыу булдырыу мөмкин түгел. Веб - сервер был каталогка яҙа алһын өсөн, инеү юлы көйләгесен үҙгәртегеҙ һәм тағы ла бер мәртәбә ҡабатлап ҡарағыҙ.",
+ "config-sqlite-connection-error": "$1.\n\nМәғлүмәт базаһының исемен һәм мәғлүмәт директорияһын тикшерегеҙ ҙә яңынан эшләп ҡарағыҙ.",
+ "config-sqlite-readonly": "<code>$1</code> файлы яҙыу өсөн ябыҡ.",
+ "config-sqlite-cant-create-db": "<code>$1</code> мәғлүмәт базаһы файлын яһап булмай.",
+ "config-sqlite-fts3-downgrade": "PHP өсөн FTS3 булышлығы юҡ — таблицаларҙы алып ташлайбыҙ",
+ "config-can-upgrade": "Мәғлүмәттәр базаһында MediaWiki таблицалары бар.\nУларҙы MediaWiki $1 итеп яңыртыу өсөн '''«Дауам итергә»''' төймәһенә баҫығыҙ.",
+ "config-upgrade-done": "Яңыртыу тамамланды.\n\nХәҙер [$1 викины ҡуллана башларға] мөмкин.\n\nӘгәр ҙә <code>LocalSettings.php</code> файлын яңынан яһарға теләһәгеҙ, аҫтағы төймәгә баҫығыҙ. Ҡуйғанда проблемалар булмаһа, был '''тәҡдим ителмәй'''.",
+ "config-upgrade-done-no-regenerate": "Яңыртыу тамамланды.\nХәҙер [$1 вики менән эш башлай] алаһығыҙ.",
+ "config-regenerate": "LocalSettings.php яңынан төҙөргә →",
+ "config-show-table-status": "«<code>SHOW TABLE STATUS</code>» һорауы эшләнмәне!",
+ "config-unknown-collation": "'''Иғтибар:''' Мәғлүмәт базаһы сортировканың танылмаған ҡағиҙәләрен ҡуллана.",
+ "config-db-web-account": "Веб-серверҙан мәғлүмәт базаһына инеү өсөн иҫәп яҙмаһы",
+ "config-db-web-help": "Викиҙың ғәҙәттәге эшендә веб - сервер файҙалана торған мәғлүмәттәр базаһының серверына тоташтырыу өсөн ҡулланыусының исемен һәм серһүҙен һайлағыҙ.",
+ "config-db-web-account-same": "Ҡуйыу өсөн булған иҫәп яҙмаһын ҡулланырға",
+ "config-db-web-create": "Иҫәп яҙмаһы булмаһа - яһарға",
+ "config-db-web-no-create-privs": "Ҡуйылыш өсөн күрһәтелгән иҫәп яҙмағыҙҙың уны барлыҡҡа килтереү өсөн етерлек хоҡуҡтары юҡ. \nКүрһәтелгән иҫәп яҙма бында булырға тейеш инде.",
+ "config-mysql-engine": "Мәғлүмәт базаһы шыуҙырмаһы",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "\"Иғтибар\" Һеҙ MySQL мәғлүмәтен һаҡлау өсөн MyISAM механизмын һайланығыҙ. Түбәндәге сәбәптәр арҡаһында уны ҡулланыу тәҡдим ителмәй:\n* параллелизмда эшләп булмай;\n* башҡа механизмдар менән сағыштырғанда мәғлүмәттәр юғала;\n* MediaWiki коды MyISAM үҙенсәләген иҫәпкә алмай.\n\nҺеҙҙең MySQL InnoDB менән яраҡлы эшләһә ошо механизмды һайларға тәҡдим итебеҙ.\n\nҺеҙҙең MySQL InnoDB менән яраҡһыҙ эшләһә механизмды яңыртырға тәҡдим итебеҙ.",
+ "config-mysql-only-myisam-dep": "<strong>Иҫкәртеү:</strong> MyISAM — был компьютерҙә MySQL өсөн берҙән-бер асыҡ мәғлүмәттәр һаҡлау системаһы, һәм MediaWiki менән берлектә ҡулланырға рөхсәт ителмәй,сөнки:\n* таблицаларҙы блокировкалау һөҙөмтәһендә параллелизмды көсһөҙ тота;\n* башҡа системаларға ҡарағанда, ватылыуға күберәк дусар ителгән;\n* MediaWiki код базаһы MyISAM -ды ғәҙәттәгесә эшкәртеп бөтә алмай\nҺеҙҙең MySQL InnoDB -ды тотмай, бәлки, яңыртыу ваҡыты еткәндер.",
+ "config-mysql-engine-help": "Параллель рәүештә яҡшыраҡ эшләгәне өсөн '''InnoDB''' өҫтөнлөрәк.\n\nБер ҡулланыусы йәки төҙәтеүҙәр әҙ булғанда вики өсөн '''MyISAM'''тың тиҙлеге шәберәк, әммә унда мәғлүмәт базаһы InnoDB-ҡа ҡарағанда йышыраҡ сафтан сыға.",
+ "config-mysql-charset": "Мәғлүмәт базаһын кодлау",
+ "config-mysql-binary": "Икеле",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "'''Ике режим'''да MediaWiki UTF-8 тексын мәғлүмәт базаһының бинарныхҡырында һаҡлай.\nБыл MySQL-дың''UTF-8 режим''ына ҡарағанда һөҙөмтәлерәк һәм Unicode символдарының тулы тупланмаһын ҡулланыу мөмкинлеген бирә. \n\n'''UTF-8 режимы'''нда MySQL мәғлүмәттәрегеҙҙең ниндәй кодировкала ятҡанын беләсәк һәм уларҙы тейешенсә сағылдырасаҡ, үҙгәртәсәк, әммә был символдарҙы юғарыраҡ [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes База күптеллелек киңлегендә] һаәлау мөмкинлеген бирәсәк.",
+ "config-mssql-auth": "Аутентификация төрө :",
+ "config-mssql-install-auth": "Ҡуйыу процесында мәғлүмәт базаһына инеү өсөн файҙаланылған төп нөсхәне тикшереү тибын һайлағыҙ. \n\nӘгәр «{{int:config-mssql-windowsauth}}» һайлаһығыҙ, ҡулланыусының веб-сервер эшләгән иҫәп яҙмаһы файҙаланыласаҡ.",
+ "config-mssql-web-auth": "Викиҙың ғәҙәттәге эше ваҡытында мәғлүмәттәр базаһы серверына инеү өсөн веб-сервер файҙаланған төп нөсхәне тикшереү тибын һайлағыҙ. \n\nӘгәр «{{int:config-mssql-windowsauth}}» һайлаһығыҙ, ҡулланыусының веб-сервер эшләгән иҫәп яҙмаһы файҙаланыласаҡ.",
+ "config-mssql-sqlauth": "SQL Server ысынлығын тикшереү",
+ "config-mssql-windowsauth": "Windows нөсхәһен тикшереү",
+ "config-site-name": "Вики атамаһы:",
+ "config-site-name-help": "Исеме браузерҙың баш һүҙендә һәм башҡа урындарҙа күрәнәсәк.",
+ "config-site-name-blank": "Сайт исемен яҙығыҙ",
+ "config-project-namespace": "Проекттың исемдәр арауығы:",
+ "config-ns-generic": "Проект",
+ "config-ns-site-name": "Викилағы кеүек исем: $1",
+ "config-ns-other": "Башҡа (күрһәтегеҙ)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Вмкмпедия өлгөһөнә эйәреп, күп викиҙар үҙ ҡағиҙәләре биттәрен төп йөкмәтке битенән айырым, '''«проект атамалары киңлегендә»''' һаҡлай.\nБыл киңлектәге барлыҡ биттәр атамалары һеҙ бында һорай алған билдәле перфикстан башлана.\nҒәҙәттә, был префикс вики исеменән барлыҡҡа килә, әммә тыныш билдәләре, «#» йәки «:» символдары була алмай.",
+ "config-ns-invalid": "Күрһәтелгән исемдәр арауығы <nowiki>$1</nowiki> ярамай.\nПроекттың икенсе исемдәр арауығын күрһәтергә.",
+ "config-ns-conflict": "Күрһәтелгән исемдәр арауығы «<nowiki>$1</nowiki>» стандарт MediaWiki исемдәр арауығы менән бәхәстә.\nПроекттың икенсе исемдәр арауығын күрһәтегеҙ.",
+ "config-admin-box": "Администраторҙың иҫәп яҙмаһы",
+ "config-admin-name": "Һеҙҙең ҡуланыусы исеме",
+ "config-admin-password": "Серһүҙ:",
+ "config-admin-password-confirm": "Серһүҙҙе ҡабатлағыҙ",
+ "config-admin-help": "Бында үҙегеҙҙең ҡулланыусы исемегеҙҙе яҙығыҙ, мәҫәлән, «Азат Азатов». \nБыл исем викиға инеү өсөн буласаҡ.",
+ "config-admin-name-blank": "Администраторҙың ҡулланыусы исемен яҙығыҙ",
+ "config-admin-name-invalid": "Ҡулланыусының күрһәтелгән «<nowiki>$1</nowiki>» исеме рөхсәт ителмәй. Уның икенсе исемен яҙығыҙ.",
+ "config-admin-password-blank": "Администраторҙың иҫәп яҙмаһы өсөн серһүҙҙе яҙығыҙ",
+ "config-admin-password-mismatch": "Һеҙ яҙған серһүҙҙәр тап килмәй.",
+ "config-admin-email": "Электрон почта адресығыҙ:",
+ "config-admin-email-help": "Электрон почтағыҙҙың адресын яҙығыҙ: һеҙҙең башҡа ҡулланыусыларҙан хәбәрҙәр алыу, серһүҙҙе тергеҙеү, шулай уҡ күҙәтеү исемлеге биттәрендәге үҙгәрештәр хаҡында белдереүҙәр алыу мөмкинлеге буласаҡ. Был юлды буш ҡалдырыға ла ярай.",
+ "config-admin-error-user": "«<nowiki>$1</nowiki>» исеме менән администраторҙың иҫәп яҙмаһы төҙөгәндә эске хата.",
+ "config-admin-error-password": "Хакимдың иҫәп яҙмаһы өсөн серһүҙ ҡуйғанда эске хата «<nowiki>$1</nowiki>»: <pre>$2</pre>",
+ "config-admin-error-bademail": "Электрон почта адресы дөрөҫ түгел",
+ "config-subscribe": "[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki яңы версиялары барлыҡҡа килеүен таратыу яңылыҡтарына] яҙылырға.",
+ "config-subscribe-help": "Был хәбәрҙәре аҙ булған таратыу исемлеге хәүефһеҙлек проблемалары тураһында хәбәрҙәр һәм яғы сығарылыштар өсөн анонс булараҡ файҙаланыла. \nҺеҙгә уға яҙылырға һәм яңы версиялар сыҡҡан һайын MediaWiki-ҙы яңыртып торорға кәрәк.",
+ "config-subscribe-noemail": "Һеҙ яңы сығарылыштар тураһында хәбәр ебәреү исемлегенә электрон адресығыҙҙы күрһәтмәй генә яҙылырға тырыштығыҙ. \nӘгәр хәбәр ебәреү теҙмәһенә яҙылырға теләһәгеҙ,электрон адресығыҙҙы күрһәтегеҙ.",
+ "config-almost-done": "Һеҙ маҡсатҡа яҡын!\nҠалған көйләүҙәрҙе төшөрөп ҡалдырып, вики ҡуя алаһығыҙ.",
+ "config-optional-continue": "Төплөрәк көйләргә",
+ "config-optional-skip": "Етәр, вики ҡуйығыҙ",
+ "config-profile": "Ҡулланыусылар хоҡуҡтары профиле:",
+ "config-profile-wiki": "Асыҡ вики",
+ "config-profile-no-anon": "Иҫәп яҙмаһы булдырырға",
+ "config-profile-fishbowl": "Бары тик авторлашҡан мәхәррирҙәр өсөн",
+ "config-profile-private": "Ябыҡ вики",
+ "config-profile-help": "Ләкин, MediaWiki шыуҙырмаһын икенсе ысул менән файҙаланырға мөмкин, һәм асыҡ вики-эштең өҫтөн икәненә барыһын да ышандырып бөтөп булмай.\nҺеҙҙең һайлап алырға мөмкинселек бар.\nСайтта теркәлеү үтмәйенсә лә, модель '''«{{int:config-profile-wiki}}»''' һәр кемгә биттәрҙә үҙгәртеү эшләргә мөмкинселек бирә. Конфигурация '''{{int:config-profile-no-anon}}''' өҫтәлмә хисап тәьмин итә, ләкин осраҡлы ҡатнашыусыларҙы ябыуы ихтимал.\nСценарий '''«{{int:config-profile-fishbowl}}»''' аныҡланған ҡатнашыусыларға мөхәррирләүҙе рөхсәт итә, ләкин һәр кем алырлыҡ битте ҡарау ҡала, шул иҫәптән үҙгәртеҙәр тарихын ҡарау. '''«{{int:config-profile-private}}»''' режимында биттәрҙе ҡарарға айырым ҡулланыусыларға ғына рөхсәт ителә, ҡайһы бер өлөштәренең мөхәррирләү хоҡуҡтары булыуы мөмкин. \n[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights Ярашлы ҡулланма] урынлаштырғас, хоҡуҡтарҙы сикләүсе ҡатмарлыраҡ схемаларҙы көйләргә була.",
+ "config-license": "Автор хоҡуҡтары һәм лицензиялар:",
+ "config-license-none": "Лицензияны аҫта яҙмағыҙ",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Ижади лицензия, атрибутикалар",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (йәмәғәт милке)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 йәки яңырағы",
+ "config-license-pd": "Йәмәғәт милке",
+ "config-license-cc-choose": "Creative Commons бер лицензияны һайлағыҙ",
+ "config-license-help": "Күпселек дөйөм ҡулланыуҙағы викиҙар үҙ материалдарын [http://freedomdefined.org/Definition/Ru ирекле лицензия] шарттарында файҙаланыуға рөхсәт бирә.\nБыл берҙәмлек тойғоһон булдыррыға ярҙам итә, ҡатнашыу ваҡытын оҙайтыуға дәртләндерә. Әммә шәхси йәки корпоратив викиҙар өсөн бындай ихтыяж юҡ. \n\nӘгәр һеҙ Википедия текстарын файҙаланырға йәки Википедияға үҙ викиғыҙҙан текстар күсереү мөмкинлеге булыуын теләһәгеҙ, \n<strong>{{int:config-license-cc-by-sa}}</strong> һайлағыҙ.\nВикипедия элек GNU Free Documentation License лицензияһын файҙалана ине.\nGFDL файҙаланыла ала, әммә ул аңлау өсөн ҡатмарлы һәм материалдарҙы ҡабатлап ҡулланыуҙы ауырлаштыра.",
+ "config-email-settings": "Электрон почта көйләүҙәре",
+ "config-enable-email": "e-mail сығыусы почтаны рәхсәт итергә",
+ "config-enable-email-help": "Электрон почта эшләһен өсөн, [http://www.php.net/manual/ru/mail.configuration.php PHP көйләүҙәрен] башҡарырға кәрәк.\nӘгәр электрон почта мөмкинлектәре кәрәкмәһә, һүндерергә була.",
+ "config-email-user": "Ҡатнашыусынан ҡатнашыусыға почтаны рөхсәт итергә",
+ "config-email-user-help": "Әгәр профилдә тейешле көйләү булһа, бөтә ҡатнашыусыларға электрон хат ебәрергә рөхсәт итергә.",
+ "config-email-usertalk": "Ҡулланыусыларҙы уларҙың фекерләшеү битендәге хәбәрҙәр хаҡында белдереүҙәрҙе файҙаланыу",
+ "config-email-usertalk-help": "Ҡулланыусылар үҙ көйләүҙәрендә рөхсәт бирһә, уларға фекерләү биттәрендәге үҙгәрештәр хаҡында белдереүҙәр алырға рөхсәт итеү.",
+ "config-email-watchlist": "Күҙәтеү исемлеген үҙгәртеү хаҡында электрон почтаға белдереү ебәрергә",
+ "config-email-watchlist-help": "Ҡулланыусылар үҙ көйләүҙәрендә рөхсәт бирһә, уларға фекерләү биттәрендәге үҙгәрештәр хаҡында белдереүҙәр алырға рөхсәт итеү.",
+ "config-email-auth": "Электрон почта аша аутентификация (ҡулланыусы тәҡдим иткән идентификаторҙы тикшереү) үткәреү",
+ "config-email-auth-help": "Был опция ҡабыҙылған булһа, ҡатнашыусылар үҙ адресын раҫлап, e-mail адресындағы һылтанма буйынса күсергә тейеш. Электрон йәшникте алыштырған осраҡта раҫлау талап ителә.Тик почта йәшнигенә раҫланған ҡатнашыусылар ғына хат ала.\nБыл опцияны почтаны урынһыҙ ҡулланыуҙарҙы булдырмаҫ өсөн ҡулланырға \"тәҡдим\" ителә.",
+ "config-email-sender": "Электрон почта адресығыҙ",
+ "config-email-sender-help": "Баһалама алыу өсөн электрон почта адресын яҙығыҙ. Унда кире ҡағылған баһаламалар ебәреләсәк.Почта серверы домен исемен дөрөҫ яҙыуҙы талап ите.",
+ "config-upload-settings": "Рәсем-һүрәттәрҙе һәм файлдарҙы тултырыу",
+ "config-upload-enable": "Файл тултырырға рөхсәт биреү",
+ "config-upload-help": "Файлды тейәргә рөхсәт итеү серверҙың хәүефһеҙлегенә янай. Өҫтәмә мәғлүмәт алыу өсөн Ҡулланманың [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security хәүефһеҙлек бүлеген] уҡығыҙ.",
+ "config-upload-deleted": "Юйылған файлдарға директория",
+ "config-upload-deleted-help": "Юйылған файлдар архивы һаҡланасаҡ каталогты һайлағыҙ.\nИң шәп осраҡта, интернет селтәренән был каталогҡа инеү рөхсәте булырға тейеш түгел.",
+ "config-logo": "Логотип URL-ы :",
+ "config-logo-help": "MediaWiki стантарт биҙәү темаһының ситтәге панелендә 135x160 пикселдән торған логотип урынлаштырыла. Шул ҙурлыҡтағы рәсемде тейәгеҙ, һәм URL адресын яҙығыҙ.\nЛогитип сағыштырмаса ошо юлдарҙа ятһа, <code>$wgStylePath</code> йәки <code>$wgScriptPath</code> кодын файҙалана алаһығыҙ.\nӘгәр логотип кәрәк булмаһа, был урында буш ҡалдырығыҙ.",
+ "config-instantcommons": "Instant Commons-ты тоҡандырырға",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] — Викимилектәге рәсем, тауыш һәм башҡа медиафайлдарҙы файҙаланыу функцияһы ([https://commons.wikimedia.org/ Wikimedia Commons]).\n MediaWiki функцияһы менән эшләү өсөн интернетҡа инеү мөмкинлеге кәрәк.\n\nInstant Commons тураһында өҫтәмә мәғлүмәтте, һәм башҡа көйләүҙәрҙе [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos Ҡулланмала] табырға була.",
+ "config-cc-error": "Creative Commons лицензияһын һайлау механизмы нәтижә бирмәне.\nЛицензия исемен ҡулдан яҙығыҙ.",
+ "config-cc-again": "Ҡабаттан һайлағыҙ",
+ "config-cc-not-chosen": "Creative Commons лицензияһының ҡулланырға теләгәнен һайлағыҙ һәм \"proceed\" төймәһенә баҫыҡыҙ.",
+ "config-advanced-settings": "Өҫтәлмә көйләүҙәр",
+ "config-cache-options": "Объекттарҙы кэшлау параматры",
+ "config-cache-help": "Объекттарҙы кэшлау MediaWiki-ның тиҙлеген арттырыу өсөн ҡулланыла. Ҙур һәм уртаса сайттар өсөн кэшлау ҡәтғи тәҡдим ителә, белекәй сайттар өсөн өҫтөнлөк бирелә.",
+ "config-cache-none": "Кэш ҡулланмайынса (фуккция юғалмай, әммә эре вики-сайттар әкренерәк эшләйәсәк)",
+ "config-cache-accel": "Объекттарҙы PHP кэшлау (APC, XCache йәки WinCache)",
+ "config-cache-memcached": "Memcached ҡулланырға (өҫтәлмә көйләү талап итә)",
+ "config-memcached-servers": "Memcached серверҙары:",
+ "config-memcached-help": "Memcached ҡулланған IP-адрестар исемлеге.\nҺәр юлға бер генә адрес яҙып һанап сығығыҙ. \nМәҫәлән:\n\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Һеҙ Memcached кэшлауҙы һайланыҡыҙ, әммә сервер адресын яҙманығыҙ.",
+ "config-memcache-badip": "Һеҙ Memcached өсөн хата IP-адрес яҙҙығыҙ: $1.",
+ "config-memcache-noport": "Memcached: $1 өсөн порт күрһәтелмәгән.\nНиндәй порт икәнән белмәһәгеҙ, килешеү буйынса 11211.",
+ "config-memcache-badport": "Memcached порттары $1 һәм $2 араһында ярырға тейеш.",
+ "config-extensions": "Киңәйтеүҙәр",
+ "config-extensions-help": "Расширения MediaWiki, перечисленные выше, были найдены в каталоге <code>./extensions</code>.\n\nОни могут потребовать дополнительные настройки, но их можно включить прямо сейчас",
+ "config-skins": "Биҙәлеш темалары",
+ "config-skins-help": "Өҫтә һаналған биҙәү темалары һеҙҙең <code>./skins</code> каталогында табылды. Һеҙгә уларҙың берәүһен булһа ла эшләтергә һәм өндәшмәү буйынса ҡулланыласағын һайларға кәрәк.",
+ "config-skins-use-as-default": "Махсус әйтмәгәндә был биҙәлеү темаһын ҡулланырға",
+ "config-skins-missing": "Биҙәү темалары табылманы. Һеҙ яраҡлыһын ҡуйғансы, MediaWiki резерв теманы ҡулланасаҡ.",
+ "config-skins-must-enable-some": "Һеҙ иң кәмендә бер биҙәлеү темаһын эш өҫтөндә ҡалдырырға тейеш",
+ "config-skins-must-enable-default": "\n\nҺүҙһеҙ һайланған биҙәлеш темаһы теркәлергә тейеш.",
+ "config-install-alreadydone": "'''Иҫкәртеү:''' Һеҙ MediaWiki ҡуйҙығыҙ шикелле, әле быны икенсегә эшләйһегеҙ. Алдағы биткә күсегеҙ.",
+ "config-install-begin": "«{{int:config-continue}}» - бында баҫыу MediaWiki ҡуйыуҙы башлай.\nӘгәр һеҙ үҙгәреш индерергә теләһәгеҙ, баҫығыҙ: «{{int:config-back}}».",
+ "config-install-step-done": "Юҡ",
+ "config-install-step-failed": "килеп сыҡманы",
+ "config-install-extensions": "Шул иҫәптән киңәйтеүҙәр",
+ "config-install-database": "Мәғлүмәттәр базаһын көйләү",
+ "config-install-schema": "Схемаға һалыу",
+ "config-install-pg-schema-not-exist": "PostgreSQL схемалары юҡ",
+ "config-install-pg-schema-failed": "Таблица эшләп булманы. Ҡулланыусы «$1» «$2»-се схема яҙа алыуына ышанырға.",
+ "config-install-pg-commit": "Үҙгәртеүҙәр индереү",
+ "config-install-pg-plpgsql": " PL/pgSQL телен тикшереү",
+ "config-pg-no-plpgsql": "Һеҙгә $1 мәғлүмәт базаһы өсөн PL/pgSQL тел яҡлауын ҡуйырға кәрәк",
+ "config-pg-no-create-privs": "Ҡуйыу өсөн күрһәтелгән иҫәп яҙмаһының иҫәп яҙмаһы булдырыу өсөн етерлек өҫтөнлөгө юҡ.",
+ "config-pg-not-in-role": "Веб-ҡулланыусының күрһәтелгән иҫәп яҙмаһы инде бар. Һеҙ ҡуйыу өсөн һайлаған иҫәп яҙмаһы супер ҡулланыусы яҙмаһы түгел һәм веб-ҡулланыусы роленә инмәй; шуға ла веб-ҡулланыусыныҡы булған объекттар төҙөп булмай\n\nMediaWiki хәҙерге ваҡытта таблицалар хужаһы веб-ҡулланыусы булыуын талап итә. Зинһар, веб-ҡулланыусы иҫәп яҙмаһы өсөн башҡа исем күрһәтегеҙ йәки, «артҡа» төймәһенә баҫып, ҡуйыу өсөн етерлек хоҡуҡтары булған ҡулланыусыны күрһәтегеҙ.",
+ "config-install-user": "Ҡулланыусының база дәүмәлдәрен теркәү",
+ "config-install-user-alreadyexists": "Ҡатнашыусы $1 бар инде",
+ "config-install-user-create-failed": "Ҡатнашыусы «$1» эшләү килеп сыҡманы: $2",
+ "config-install-user-grant-failed": "Ҡатнашыусы «$1»-гә хоҡуҡ биреү хата: $2",
+ "config-install-user-missing": "Күрһәтелгән ҡатнашыусы «$1» юҡ.",
+ "config-install-user-missing-create": "Күрһәтелгән ҡатнашыусы «$1» юҡ.\nӘгәр ҙә яһарға теләгәгеҙ булһа,зинһар, аҫта «Иҫәп яҙыуы булдырырға» билдәһе ҡуйығыҙ.",
+ "config-install-tables": "Таблица төҙөү",
+ "config-install-tables-exist": "'''Иҫкәртеү''': MediaWiki таблицаларының булыуы ихтимал. Икенсегә яһауҙы булдырмау.",
+ "config-install-tables-failed": "'''Хата''': Хата булыу сәбәпле таблица эшләнмәне: $1",
+ "config-install-interwiki": "Килешеү буйынса интервики таблицаларын тултырыу",
+ "config-install-interwiki-list": "Файл табылманы <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Киҫәтеү''': интервики-таблицала яҙма бар.\nСтандарт исемлек төҙөү төшөп ҡалды.",
+ "config-install-stats": "Инициализация статистикаһы",
+ "config-install-keys": "Серле асҡыстар төҙөү",
+ "config-insecure-keys": "'''Киҫәтеү''' {{PLURAL:$2|1=Ҡатнашыусы булдырған хәүефһеҙлек асҡысы $1 ышаныслы түгел}}. Асҡысты үҙгәртеү мөмкинлеген {{PLURAL:$2|1=}} ҡарағыҙ.",
+ "config-install-updates": "Кәрәкмәген яңыртыуҙар туҡтатылды",
+ "config-install-updates-failed": "<strong>Хата:</strong> Яңыртыуға асҡыс ҡуйыу түбәндәге хата менән тамамланды: $1",
+ "config-install-sysop": "Администратор иҫәп яҙмаһын булдырыу",
+ "config-install-subscribe-fail": "mediawiki-announce яҙылып булманы: $1",
+ "config-install-subscribe-notpossible": "cURL урынлаштырылмаған һәм опция асылмай<code>allow_url_fopen</code>.",
+ "config-install-mainpage": "Килешеү буйынса эстәлекле баш битте эшләү",
+ "config-install-extension-tables": "Ҡушымталар өсөн таблица эшләү",
+ "config-install-mainpage-failed": "Баш битте ҡуйып булмай:$1",
+ "config-install-done": "<strong>Ҡотлайбыҙ!</strong>\nMediaWiki уңышлы урынлаштырылды.\n\nФайл булдырылды <code>LocalSettings.php</code>.\nБыл файлда һеҙҙеү бөтә көәләүҙәр бар.\n\n\nАвтоматик тейәү башланмаһа йәки үҙегеҙ өҙһәгеҙ түбәндәге һылтанма буйынса тейәргә була:\n\n$3\n\n<strong>Иҫкәртмә</strong>: Файлда тейәмәйенсә сыҡһағыҙ киләсәктә бына эшләй алмайһығыҙ.\n\n\nӨҫтә яҙылғандарҙы эшләгәс<strong>[$2 беҙҙеү викиҙа инегеҙ]</strong>.",
+ "config-download-localsettings": "<code>LocalSettings.php</code> тейәргә",
+ "config-help": "белешмә",
+ "config-help-tooltip": "асыр өсөн сиртегеҙ",
+ "config-nofile": "\"$1\" файлын табып булмай, ул юйылған.",
+ "config-extension-link": "Беҙҙең вики-проектта [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions ҡушымта] барлығын беләһегеҙме??\n\n[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category категориялар буйынса ҡушымта] йәки [https://www.mediawiki.org/wiki/Extension_Matrix матрица ҡсн ҡушымтаның] тулы исемлеген ҡарай алаһығыҙ.",
+ "mainpagetext": "«MediaWiki» уңышлы рәүештә ҡоролдо.",
+ "mainpagedocfooter": "Был вики менән эшләү тураһында мәғлүмәтте [https://meta.wikimedia.org/wiki/Help:Contents ошонда] табып була.\n\n== Файҙалы сығанаҡтар ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Көйләүҙәр исемлеге (инг.)];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki тураһында йыш бирелгән һорауҙар һәм яуаптар (инг.)];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki-ның яңы версиялары тураһында хәбәрҙәр алып тороу].\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/bar.json b/www/wiki/includes/installer/i18n/bar.json
new file mode 100644
index 00000000..c156083c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bar.json
@@ -0,0 +1,13 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mucalexx",
+ "Matthias Klostermayr"
+ ]
+ },
+ "config-desc": "As Installationsprogramm vo MediaWiki",
+ "config-title": "Installation vo MediaWiki $1",
+ "config-information": "Information",
+ "mainpagetext": "'''MediaWiki is erfoigreich installird worn.'''",
+ "mainpagedocfooter": "A Hüf zur da Benützung und Konfigurazion voh da Wiki-Software findst auf [https://meta.wikimedia.org/wiki/Help:Contents Benützerhåndbuach].\n\n== Starthüfe ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Listen voh de Konfigurazionsvariaablen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailinglisten voh de neichen MediaWiki-Versionen]"
+}
diff --git a/www/wiki/includes/installer/i18n/bcc.json b/www/wiki/includes/installer/i18n/bcc.json
new file mode 100644
index 00000000..99c2b9c5
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bcc.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''مدیا وی کی گون موفقیت نصب بوت.'''",
+ "mainpagedocfooter": "مشورت کنیت گون [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] په گشیترین اطلاعات په استفاده چه برنامه ویکی.\n\n== شروع بیت ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/bcl.json b/www/wiki/includes/installer/i18n/bcl.json
new file mode 100644
index 00000000..0f8bf2c3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bcl.json
@@ -0,0 +1,36 @@
+{
+ "@metadata": {
+ "authors": [
+ "Geopoet"
+ ]
+ },
+ "config-desc": "An tagapagkaag para sa MediaWiki",
+ "config-title": "MediaWiki $1 na pagkakaag",
+ "config-information": "Impormasyon",
+ "config-localsettings-key": "Ipag-angat an susi:",
+ "config-localsettings-badkey": "The susi na saimong pinagtao bakong tama.",
+ "config-your-language": "An Saimong lengguwahe:",
+ "config-wiki-language": "Lengguwahe sa Wiki:",
+ "config-back": "← Ibuwelta",
+ "config-continue": "Ipadagos →",
+ "config-page-language": "Lengguwahe",
+ "config-page-welcome": "Maogmang pag-abot sa MediaWiki!",
+ "config-page-dbsettings": "Mga panuytoy nin datos-sarayan",
+ "config-page-name": "Pangaran",
+ "config-page-options": "Mga Pagpipilian",
+ "config-page-install": "Kaagon",
+ "config-page-complete": "Kumpleto!",
+ "config-page-restart": "Poonan otro an pagkakaag",
+ "config-page-readme": "Basaha ako",
+ "config-page-releasenotes": "Buhian an mga katalaanan",
+ "config-page-copying": "Pinagkokopya",
+ "config-page-upgradedoc": "Ipinagpapalangkaw",
+ "config-page-existingwiki": "Eksistidong wiki",
+ "config-restart": "Iyo, pakipoon kaini otro",
+ "config-db-wiki-settings": "Bistohon ining wiki",
+ "config-db-name": "Pangaran kan datos-sarayan:",
+ "config-db-username": "Ngaran-paragamit nin datos-sarayan:",
+ "config-db-password": "Pasa-taramon nin datos-sarayan:",
+ "mainpagetext": "'''Instalado na an MediaWiki.'''",
+ "mainpagedocfooter": "Konsultarón tabì an [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] para sa impormasyon sa paggamit nin progama kaining wiki.\n\n== Pagpopoon ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/be-tarask.json b/www/wiki/includes/installer/i18n/be-tarask.json
new file mode 100644
index 00000000..b427c195
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/be-tarask.json
@@ -0,0 +1,324 @@
+{
+ "@metadata": {
+ "authors": [
+ "EugeneZelenko",
+ "Jim-by",
+ "Wizardist",
+ "Zedlik",
+ "아라",
+ "Red Winged Duck",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Праграма ўсталяваньня MediaWiki",
+ "config-title": "Усталяваньне MediaWiki $1",
+ "config-information": "Інфармацыя",
+ "config-localsettings-upgrade": "Выяўлены файл <code>LocalSettings.php</code>.\nКаб абнавіць гэтае ўсталяваньне, калі ласка, увядзіце значэньне <code>$wgUpgradeKey</code> у полі ніжэй.\nЯго можна знайсьці ў <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Быў знойдзены файл <code>LocalSettings.php</code>.\nКаб зьмяніць гэтае ўсталяваньне, калі ласка, запусьціце <code>update.php</code>",
+ "config-localsettings-key": "Ключ паляпшэньня:",
+ "config-localsettings-badkey": "Пададзены Вамі ключ абнаўленьня зьяўляецца няслушным.",
+ "config-upgrade-key-missing": "Знойдзена ўсталяваная MediaWiki.\nКаб абнавіць гэтае ўсталяваньне, калі ласка, устаўце наступны радок у канец Вашага <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Выглядае, што актуальны <code>LocalSettings.php</code> зьяўляецца няпоўным.\nНе зададзеная зьменная $1.\nКалі ласка, зьмяніце <code>LocalSettings.php</code> так, каб прысутнічала гэтая зьменная, і націсьніце «{{int:Config-continue}}».",
+ "config-localsettings-connection-error": "Адбылася памылка падчас злучэньня з базай зьвестак з выкарыстаньнем наладаў, пазначаных у <code>LocalSettings.php</code>. Калі ласка, выпраўце гэтыя налады і паспрабуйце яшчэ раз.\n\n$1",
+ "config-session-error": "Памылка стварэньня сэсіі: $1",
+ "config-session-expired": "Скончыўся тэрмін дзеяньня зьвестак сэсіі.\nСэсія мае абмежаваны тэрмін у $1.\nВы можаце павялічыць яго, зьмяніўшы парамэтар <code>session.gc_maxlifetime</code> ў php.ini.\nПеразапусьціце працэс усталяваньня.",
+ "config-no-session": "Зьвесткі сэсіі згубленыя!\nПраверце php.ini і ўпэўніцеся, што ўстаноўлены слушны шлях у <code>session.save_path</code>.",
+ "config-your-language": "Вашая мова:",
+ "config-your-language-help": "Выберыце мову для выкарыстаньня падчас усталяваньня.",
+ "config-wiki-language": "Мова вікі:",
+ "config-wiki-language-help": "Выберыце мову, на якой пераважна будзе пісацца зьмест у вікі.",
+ "config-back": "← Назад",
+ "config-continue": "Далей →",
+ "config-page-language": "Мова",
+ "config-page-welcome": "Вітаем у MediaWiki!",
+ "config-page-dbconnect": "Падключэньне да базы зьвестак",
+ "config-page-upgrade": "Абнавіць існуючую ўстаноўку",
+ "config-page-dbsettings": "Налады базы зьвестак",
+ "config-page-name": "Назва",
+ "config-page-options": "Налады",
+ "config-page-install": "Усталяваць",
+ "config-page-complete": "Зроблена!",
+ "config-page-restart": "Пачаць усталяваньне зноў",
+ "config-page-readme": "Дадатковыя зьвесткі",
+ "config-page-releasenotes": "Заўвагі да выпуску",
+ "config-page-copying": "Капіяваньне",
+ "config-page-upgradedoc": "Абнаўленьне",
+ "config-page-existingwiki": "Існуючая вікі",
+ "config-help-restart": "Ці жадаеце выдаліць усе ўведзеныя зьвесткі і пачаць працэс усталяваньня зноў?",
+ "config-restart": "Так, пачаць зноў",
+ "config-welcome": "== Праверка асяродзьдзя ==\nЗараз будуць праведзеныя праверкі для запэўніваньня, што гэтае асяродзьдзе адпаведнае для ўсталяваньня MediaWiki.\nНе забудзьце далучыць гэтую інфармацыю, калі вам спатрэбіцца дапамога для завяршэньня ўсталяваньня.",
+ "config-copyright": "== Аўтарскае права і ўмовы ==\n\n$1\n\nThis 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.\n\nThis 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'''.\nSee the GNU General Public License for more details.\n\nYou should have received <doclink href=Copying>a copy of the GNU General Public License</doclink> along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. or [http://www.gnu.org/copyleft/gpl.html read it online].",
+ "config-sidebar": "* [https://www.mediawiki.org Хатняя старонка MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Даведка для ўдзельнікаў]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Даведка для адміністратараў]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Адказы на частыя пытаньні]\n----\n* <doclink href=Readme>Прачытайце</doclink>\n* <doclink href=ReleaseNotes>Паляпшэньні ў вэрсіі</doclink>\n* <doclink href=Copying>Капіяваньне</doclink>\n* <doclink href=UpgradeDoc>Абнаўленьне</doclink>",
+ "config-env-good": "Асяродзьдзе было праверанае.\nВы можаце ўсталёўваць MediaWiki.",
+ "config-env-bad": "Асяродзьдзе было праверанае.\nУсталяваньне MediaWiki немагчымае.",
+ "config-env-php": "Усталяваны PHP $1.",
+ "config-env-hhvm": "HHVM $1 усталяваная.",
+ "config-unicode-using-intl": "Выкарыстоўваецца [http://pecl.php.net/intl intl пашырэньне з PECL] для Unicode-нармалізацыі",
+ "config-unicode-pure-php-warning": "'''Папярэджаньне''': [http://pecl.php.net/intl Пашырэньне intl з PECL] — ня слушнае для Unicode-нармалізацыі, цяпер выкарыстоўваецца марудная PHP-рэалізацыя.\nКалі ў Вас сайт з высокай наведвальнасьцю, раім пачытаць пра [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-нармалізацыю].",
+ "config-unicode-update-warning": "'''Папярэджаньне''': усталяваная вэрсія бібліятэкі для Unicode-нармалізацыі выкарыстоўвае састарэлую вэрсію бібліятэкі з [http://site.icu-project.org/ праекту ICU].\nРаім [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations абнавіць], калі ваш сайт будзе працаваць з Unicode.",
+ "config-no-db": "Немагчыма знайсьці адпаведны драйвэр базы зьвестак. Вам неабходна ўсталяваць драйвэр базы зьвестак для PHP.\n{{PLURAL:$2|Падтрымліваецца наступны тып базы|Падтрымліваюцца наступныя тыпы базаў}} зьвестак: $1.\n\nКалі вы скампілявалі PHP самастойна, зьмяніце канфігурацыю, каб уключыць кліента базы зьвестак, напрыклад, з дапамогай <code>./configure --with-mysqli</code>.\nКалі вы ўсталявалі PHP з пакунку Debian або Ubuntu, тады вам трэба дадаткова ўсталяваць, напрыклад, пакунак <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Папярэджаньне''': усталяваны SQLite $1, у той час, калі мінімальная сумяшчальная вэрсія — $2. SQLite ня будзе даступны.",
+ "config-no-fts3": "'''Папярэджаньне''': SQLite створаны без модуля [//sqlite.org/fts3.html FTS3], для гэтага ўнутранага інтэрфэйсу ня будзе даступная магчымасьць пошуку.",
+ "config-pcre-old": "<strong>Крытычная памылка:</strong> патрэбны PCRE вэрсіі $1 або пазьнейшай.\nPHP-файл, які выконваецца, зьвязаны з PCRE вэрсіі $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Больш інфармацыі].",
+ "config-pcre-no-utf8": "'''Фатальная памылка''': модуль PCRE для PHP скампіляваны без падтрымкі PCRE_UTF8.\nMediaWiki патрабуе падтрымкі UTF-8 для слушнай працы.",
+ "config-memory-raised": "Абмежаваньне на даступную для PHP памяць <code>memory_limit</code> было падвышанае з $1 да $2.",
+ "config-memory-bad": "'''Папярэджаньне:''' памер PHP <code>memory_limit</code> складае $1.\nВерагодна, гэта вельмі мала.\nУсталяваньне можа быць няўдалым!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] усталяваны",
+ "config-apc": "[http://www.php.net/apc APC] усталяваны",
+ "config-apcu": "[http://www.php.net/apcu APCu] ўсталяваны",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] усталяваны",
+ "config-no-cache-apcu": "<strong>Папярэджаньне:</strong> ня знойдзеныя [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] ці [http://www.iis.net/download/WinCacheForPhp WinCache]. Кэшаваньне аб’ектаў адключанае.",
+ "config-mod-security": "'''Папярэджаньне''': на Вашым ўэб-сэрверы ўключаны [http://modsecurity.org/ mod_security]. У выпадку няслушнай наладцы, ён можа стаць прычынай праблемаў для MediaWiki ці іншага праграмнага забесьпячэньня, якое дазваляе ўдзельнікам дасылаць на сэрвэр любы зьмест.\nГлядзіце [http://modsecurity.org/documentation/ дакумэнтацыю mod_security] ці зьвярніцеся ў падтрымку Вашага хосту, калі ў Вас узьнікаюць выпадковыя праблемы.",
+ "config-diff3-bad": "GNU diff3 ня знойдзены.",
+ "config-git": "Знойдзеная сыстэма канстролю вэрсіяў Git: <code>$1</code>",
+ "config-git-bad": "Сыстэма кантролю вэрсіяў Git ня знойдзеная.",
+ "config-imagemagick": "Знойдзены ImageMagick: <code>$1</code>.\nПасьля ўключэньня загрузак будзе ўключанае маштабаваньне выяваў.",
+ "config-gd": "GD падтрымліваецца ўбудавана.\nПасьля ўключэньня загрузак будзе ўключанае маштабаваньне выяваў.",
+ "config-no-scaling": "Ні GD, ні ImageMagick ня знойдзеныя.\nМаштабаваньне выяваў будзе адключанае.",
+ "config-no-uri": "'''Памылка:''' Не магчыма вызначыць цяперашні URI.\nУсталяваньне спыненае.",
+ "config-no-cli-uri": "'''Папярэджаньне''': Не пазначаны <code>--scriptpath</code>, па змоўчваньні выкарыстоўваецца: <code>$1</code>.",
+ "config-using-server": "Выкарыстоўваецца назва сэрвэра «<nowiki>$1</nowiki>».",
+ "config-using-uri": "Выкарыстоўваецца URL-спасылка сэрвэра «<nowiki>$1$2</nowiki>».",
+ "config-uploads-not-safe": "'''Папярэджаньне:''' дырэкторыя для загрузак па змоўчваньні <code>$1</code> уразьлівая да выкананьня адвольнага коду.\nХоць MediaWiki і правярае ўсе файлы перад захаваньнем, вельмі рэкамэндуецца [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security закрыць гэтую ўразьлівасьць] перад уключэньнем магчымасьці загрузкі файлаў.",
+ "config-no-cli-uploads-check": "'''Папярэджаньне:''' Вашая дырэкторыя для загрузак па змоўчваньні (<code>$1</code>), не правераная на ўразьлівасьць да выкананьня адвольных скрыптоў падчас усталяваньня CLI.\n.",
+ "config-brokenlibxml": "У Вашай сыстэме ўсталяваныя PHP і libxml2 зь несумяшчальнымі вэрсіямі, што можа прывесьці да пашкоджаньня зьвестак MediaWiki і іншых вэб-дастасаваньняў.\nАбнавіце libxml2 да вэрсіі 2.7.3 ці больш позьняй ([https://bugs.php.net/bug.php?id=45996 паведамленьне пра памылку на сайце PHP]).\nУсталяваньне перарванае.",
+ "config-suhosin-max-value-length": "Suhosin усталяваны і абмяжоўвае <code>даўжыню</code> парамэтру GET да $1 {{PLURAL:$1|1=байта|байтаў}}.\nResourceLoader, складнік MediaWiki, будзе абходзіць гэтае абмежаваньне, што адаб’ецца на прадукцыйнасьці.\nКалі магчыма, варта ўсталяваць у <code>php.ini</code> значэньне <code>suhosin.get.max_value_length</code> роўным 1024 ці больш, а таксама вызначыць тое ж значэньне для <code>$wgResourceLoaderMaxQueryLength</code> у <code>LocalSettings.php</code>.",
+ "config-using-32bit": "<strong>Папярэджаньне:</strong> падобна, што вашая сыстэма выкарыстоўвае 32-бітавыя цэлыя лікі. Гэта [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit не рэкамэндуецца].",
+ "config-db-type": "Тып базы зьвестак:",
+ "config-db-host": "Хост базы зьвестак:",
+ "config-db-host-help": "Калі сэрвэр Вашай базы зьвестак знаходзіцца на іншым сэрвэры, увядзіце тут імя хоста ці IP-адрас.\n\nКалі Вы карыстаецеся shared-хостынгам, Ваш хостынг-правайдэр мусіць даць Вам слушнае імя хоста базы зьвестак у сваёй дакумэнтацыі.\n\nКалі Вы усталёўваеце сэрвэр Windows з выкарыстаньнем MySQL, выкарыстаньне «localhost» можа не працаваць для назвы сэрвэра. У гэтым выпадку паспрабуйце пазначыць «127.0.0.1» для лякальнага IP-адраса.\n\nКалі Вы выкарыстоўваеце PostgreSQL, пакіньце поле пустым, каб далучыцца праз Unix-сокет.",
+ "config-db-host-oracle": "TNS базы зьвестак:",
+ "config-db-host-oracle-help": "Увядзіце слушнае [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm лякальнае імя злучэньня]; файл tnsnames.ora павінен быць бачным для гэтага ўсталяваньня.<br />Калі Вы выкарыстоўваеце кліенцкія бібліятэкі 10g ці больш новыя, Вы можаце таксама выкарыстоўваць мэтад наданьня назваў [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm лёгкае злучэньне].",
+ "config-db-wiki-settings": "Ідэнтыфікацыя гэтай вікі",
+ "config-db-name": "Назва базы зьвестак:",
+ "config-db-name-help": "Выберыце імя для вызначэньня Вашай вікі.\nЯно ня мусіць зьмяшчаць прагалаў.\n\nКалі Вы набываеце shared-хостынг, Ваш хостынг-правайдэр мусіць надаць Вам ці пэўнае імя базы зьвестак для выкарыстаньня, ці магчымасьць ствараць базы зьвестак праз кантрольную панэль.",
+ "config-db-name-oracle": "Схема базы зьвестак:",
+ "config-db-account-oracle-warn": "Існуюць тры сцэнары ўсталяваньня Oracle як базы зьвестак для MediaWiki:\n\nКалі Вы жадаеце стварыць рахунак базы зьвестак як частку працэсу ўсталяваньня, калі ласка, падайце рахунак з роляй SYSDBA як рахунак базы зьвестак для ўсталяваньня і пазначце пажаданыя правы рахунку з доступам да Інтэрнэту, у адваротным выпадку Вы можаце таксама стварыць рахунак з доступам да Інтэрнэту ўручную і падаць толькі гэты рахунак (калі патрабуюцца правы для стварэньня схемы аб’ектаў) ці падайце два розных рахункі, адзін з правамі на стварэньне і адзін з абмежаваньнямі для доступу да Інтэрнэту.\n\nСкрыпт для стварэньня рахунку з патрабуемымі правамі можна знайсьці ў дырэкторыі гэтага ўсталяваньня «maintenance/oracle/». Памятайце, што выкарыстаньне рахунку з абмежаваньнямі адключыць усе падтрымліваемыя магчымасьці даступныя па змоўчваньні.",
+ "config-db-install-account": "Імя карыстальніка для ўсталяваньня",
+ "config-db-username": "Імя карыстальніка базы зьвестак:",
+ "config-db-password": "Пароль базы зьвестак:",
+ "config-db-install-username": "Увядзіце імя карыстальніка, якое будзе выкарыстоўвацца для злучэньня з базай зьвестак падчас усталяваньня. Гэта не назва рахунку MediaWiki; гэта імя карыстальніка Вашай базы зьвестак.",
+ "config-db-install-password": "Увядзіце пароль, які будзе выкарыстоўвацца для злучэньня з базай зьвестак падчас усталяваньня. Гэта не пароль рахунку MediaWiki; гэта пароль Вашай базы зьвестак.",
+ "config-db-install-help": "Увядзіце імя карыстальніка і пароль, якія будуць выкарыстаныя для далучэньня да базы зьвестак падчас працэсу ўсталяваньня.",
+ "config-db-account-lock": "Выкарыстоўваць тыя ж імя карыстальніка і пароль пасьля ўсталяваньня",
+ "config-db-wiki-account": "Імя карыстальніка для працы",
+ "config-db-wiki-help": "Увядзіце імя карыстальніка і пароль, якія будуць выкарыстаныя для далучэньня да базы зьвестак падчас працы (пасьля ўсталяваньня).\nКалі рахунак ня створаны, а рахунак для ўсталяваньня мае значныя правы, гэты рахунак будзе створаны зь мінімальна патрэбнымі для працы вікі правамі.",
+ "config-db-prefix": "Прэфікс табліцаў базы зьвестак:",
+ "config-db-prefix-help": "Калі Вы разьдзяляеце адну базу зьвестак паміж некалькімі вікі, ці паміж MediaWiki і іншым вэб-дастасаваньнем, можаце вызначыць прэфікс назваў табліцаў для пазьбяганьня канфліктаў.\nПазьбягайце прагалаў.\n\nГэтае поле звычайна пакідаецца пустым.",
+ "config-mysql-old": "Патрабуецца MySQL $1 ці навейшая, усталяваная вэрсія $2.",
+ "config-db-port": "Порт базы зьвестак:",
+ "config-db-schema": "Схема для MediaWiki",
+ "config-db-schema-help": "Гэтая схема слушная ў большасьці выпадкаў.\nЗьмяняйце яе толькі тады, калі Вы ведаеце, што гэта неабходна.",
+ "config-pg-test-error": "Немагчыма далучыцца да базы зьвестак '''$1''': $2",
+ "config-sqlite-dir": "Дырэкторыя зьвестак SQLite:",
+ "config-sqlite-dir-help": "SQLite захоўвае ўсе зьвесткі ў адзіным файле.\n\nПададзеная Вамі дырэкторыя павінна быць даступнай да запісу вэб-сэрвэрам падчас усталяваньня.\n\nЯна '''ня''' мусіць быць даступнай праз Сеціва, вось чаму мы не захоўваем яе ў адным месцы з файламі PHP.\n\nПраграма ўсталяваньня дадаткова створыць файл <code>.htaccess</code>, але калі ён не выкарыстоўваецца, хто заўгодна зможа атрымаць зьвесткі з базы зьвестак.\nГэта ўключае як прыватныя зьвесткі ўдзельнікаў (адрасы электроннай пошты, хэшы пароляў), гэтак і выдаленыя вэрсіі старонак і іншыя зьвесткі, доступ да якіх маецца абмежаваны.\n\nПадумайце над тым, каб зьмяшчаць базу зьвестак у іншым месцы, напрыклад у <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Прастора табліцаў па змоўчваньні:",
+ "config-oracle-temp-ts": "Часовая прастора табліцаў:",
+ "config-type-mysql": "MySQL (або сумяшчальная)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki падтрымлівае наступныя сыстэмы базаў зьвестак:\n\n$1\n\nКалі Вы ня бачыце сыстэму базаў зьвестак, якую Вы спрабуеце выкарыстоўваць ў сьпісе ніжэй, перайдзіце па спасылцы інструкцыі, якая знаходзіцца ніжэй, каб уключыць падтрымку.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] зьяўляецца галоўнай мэтай MediaWiki і падтрымліваецца лепей за ўсё. MediaWiki таксама працуе з [{{int:version-db-mariadb-url}} MariaDB] і [{{int:version-db-percona-url}} Percona Server], якія сумяшчальныя з MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Як скампіляваць PHP з падтрымкай MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] — папулярная сыстэма базы зьвестак з адкрытым кодам, якая зьяўляецца альтэрнатывай MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Як кампіляваць PHP з падтрымкай PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] — невялікая сыстэма базы зьвестак, якая мае вельмі добрую падтрымку. ([http://www.php.net/manual/en/pdo.installation.php Як кампіляваць PHP з падтрымкай SQLite], выкарыстоўвае PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] зьяўляецца камэрцыйнай прафэсійнай базай зьвестак. ([http://www.php.net/manual/en/oci8.installation.php Як скампіляваць PHP з падтрымкай OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] — камэрцыйная база зьвестак для Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Як скампіляваць PHP з падтрымкай SQLSRV])",
+ "config-header-mysql": "Налады MySQL",
+ "config-header-postgres": "Налады PostgreSQL",
+ "config-header-sqlite": "Налады SQLite",
+ "config-header-oracle": "Налады Oracle",
+ "config-header-mssql": "Налады Microsoft SQL Server",
+ "config-invalid-db-type": "Няслушны тып базы зьвестак",
+ "config-missing-db-name": "Вы мусіце ўвесьці значэньне парамэтру «{{int:config-db-name}}».",
+ "config-missing-db-host": "Вы мусіце ўвесьці значэньне парамэтру «{{int:config-db-host}}».",
+ "config-missing-db-server-oracle": "Вы мусіце ўвесьці значэньне парамэтру «{{int:config-db-host-oracle}}».",
+ "config-invalid-db-server-oracle": "Няслушнае TNS базы зьвестак «$1».\nВыкарыстоўвайце або «TNS Name», або радок «Easy Connect» ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Мэтады найменьня Oracle])",
+ "config-invalid-db-name": "Няслушная назва базы зьвестак «$1».\nНазва можа ўтрымліваць толькі ASCII-літары (a-z, A-Z), лічбы (0-9), сымбалі падкрэсьліваньня(_) і працяжнікі (-).",
+ "config-invalid-db-prefix": "Няслушны прэфікс базы зьвестак «$1».\nЁн можа зьмяшчаць толькі ASCII-літары (a-z, A-Z), лічбы (0-9), сымбалі падкрэсьліваньня (_) і працяжнікі (-).",
+ "config-connection-error": "$1.\n\nПраверце хост, імя карыстальніка і пароль ніжэй і паспрабуйце зноў.",
+ "config-invalid-schema": "Няслушная схема для MediaWiki «$1».\nВыкарыстоўвайце толькі ASCII-літары (a-z, A-Z), лічбы (0-9) і сымбалі падкрэсьліваньня (_).",
+ "config-db-sys-create-oracle": "Праграма ўсталяваньня падтрымлівае толькі выкарыстаньне рахунку SYSDBA для стварэньня новага рахунку.",
+ "config-db-sys-user-exists-oracle": "Рахунак карыстальніка «$1» ужо існуе. SYSDBA можа выкарыстоўвацца толькі для стварэньня новых рахункаў!",
+ "config-postgres-old": "Патрабуецца PostgreSQL $1 ці навейшая, усталяваная вэрсія $2.",
+ "config-mssql-old": "Патрабуецца Microsoft SQL Server вэрсіі $1 ці больш позьняй. У вас усталяваная вэрсія $2.",
+ "config-sqlite-name-help": "Выберыце назву, якая будзе ідэнтыфікаваць Вашую вікі.\nНе выкарыстоўвайце прагалы ці злучкі.\nНазва будзе выкарыстоўвацца ў назьве файла зьвестак SQLite.",
+ "config-sqlite-parent-unwritable-group": "Немагчыма стварыць дырэкторыю зьвестак <code><nowiki>$1</nowiki></code>, таму што бацькоўская дырэкторыя <code><nowiki>$2</nowiki></code> абароненая ад запісаў вэб-сэрвэра.\n\nПраграма ўсталяваньня вызначыла карыстальніка, які запусьціў вэб-сэрвэр.\nДазвольце запісы ў дырэкторыю <code><nowiki>$3</nowiki></code> для працягу.\nУ сыстэме Unix/Linux зрабіце:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Немагчыма стварыць дырэкторыю зьвестак <code><nowiki>$1</nowiki></code>, таму што бацькоўская дырэкторыя <code><nowiki>$2</nowiki></code> абароненая ад запісаў вэб-сэрвэра.\n\nПраграма ўсталяваньня вызначыла карыстальніка, які запусьціў вэб-сэрвэр.\nДазвольце яму (і іншым) запісы ў дырэкторыю <code><nowiki>$3</nowiki></code> для працягу.\nУ сыстэме Unix/Linux зрабіце:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Памылка падчас стварэньня дырэкторыі «$1».\nПраверце шлях і паспрабуйце зноў.",
+ "config-sqlite-dir-unwritable": "Запіс у дырэкторыю «$1» немагчымы.\nЗьмяніце налады доступу, каб вэб-сэрвэр меў правы на запіс, і паспрабуйце зноў.",
+ "config-sqlite-connection-error": "$1.\n\nПраверце дырэкторыю для зьвестак, назву базы зьвестак і паспрабуйце зноў.",
+ "config-sqlite-readonly": "Файл <code>$1</code> недаступны для запісу.",
+ "config-sqlite-cant-create-db": "Немагчыма стварыць файл базы зьвестак <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP бракуе падтрымкі FTS3 — табліцы пагаршаюцца",
+ "config-can-upgrade": "У гэтай базе зьвестак ёсьць табліцы MediaWiki.\nКаб абнавіць іх да MediaWiki $1, націсьніце '''Працягнуць'''.",
+ "config-upgrade-done": "Абнаўленьне завершанае.\n\nЦяпер Вы можаце [$1 пачаць выкарыстаньне вікі].\n\nКалі Вы жадаеце рэгенэраваць <code>LocalSettings.php</code>, націсьніце кнопку ніжэй.\nГэтае дзеяньне '''не рэкамэндуецца''', калі Вы ня маеце праблемаў у працы вікі.",
+ "config-upgrade-done-no-regenerate": "Абнаўленьне скончанае.\n\nЦяпер Вы можаце [$1 пачаць працу з вікі].",
+ "config-regenerate": "Рэгенэраваць LocalSettings.php →",
+ "config-show-table-status": "Запыт '<code>SHOW TABLE STATUS</code>' не атрымаўся!",
+ "config-unknown-collation": "'''Папярэджаньне:''' база зьвестак выкарыстоўвае нераспазнанае супастаўленьне.",
+ "config-db-web-account": "Рахунак базы зьвестак для вэб-доступу",
+ "config-db-web-help": "Выберыце імя карыстальніка і пароль, які выкарыстоўваецца вэб-сэрвэрам для злучэньня з сэрвэрам базы зьвестак, падчас звычайных апэрацыяў вікі.",
+ "config-db-web-account-same": "Выкарыстоўваць той жа рахунак, што для ўсталяваньня",
+ "config-db-web-create": "Стварыць рахунак, калі ён яшчэ не існуе",
+ "config-db-web-no-create-privs": "Рахунак, які Вы пазначылі для ўсталяваньня ня мае правоў для стварэньня рахунку.\nРахунак, які Вы пазначылі тут, мусіць ужо існаваць.",
+ "config-mysql-engine": "Рухавік сховішча:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Папярэджаньне''': Вы выбралі MyISAM у якасьці рухавіка для захоўваньня зьвестак у MySQL, які не рэкамэндуецца да выкарыстаньня з MediaWiki па прычынах:\n* кепская падтрымка паралельнай апрацоўкі з-за таблічных блякаваньняў;\n* большая імавернасьць пашкоджаньня зьвестак у параўнаньні зь іншымі рухавікамі;\n* код MediaWiki не ва ўсіх выпадках улічвае асаблівасьці MyISAM.\n\nКалі Ваш MySQL-сэрвэр падтрымлівае InnoDB, вельмі рэкамэндуецца выкарыстаньне менавіта гэтага рухавіка.\nКалі MySQL-сэрвэр не падтрымлівае InnoDB, пэўна, настаў час абнавіць яго.",
+ "config-mysql-only-myisam-dep": "<strong>Папярэджаньне:</strong> MyISAM — адзіная даступная сыстэма захоўваньня зьвестак для MySQL на гэтым кампутары, яна не рэкамэндуецца для ўжываньня з MediaWiki, таму што:\n* слаба падтрымлівае паралельнасьць праз блякаваньне табліцаў\n* больш за іншыя сыстэмы схільная да пашкоджаньняў\n* кодавая база MediaWiki не заўсёды належна апрацоўвае MyISAM\n\nВашае ўсталяваньне MySQL не падтрымлівае InnoDB, магчыма, час для абнаўленьня.",
+ "config-mysql-engine-help": "'''InnoDB''' — звычайна найбольш слушны варыянт, таму што добра падтрымлівае паралелізм.\n\n'''MyISAM''' можа быць хутчэйшай у вікі з адным удзельнікам, ці толькі для чытаньня.\nБазы зьвестак на MyISAM вядомыя тым, што ў іх зьвесткі шкодзяцца нашмат часьцей за InnoDB.",
+ "config-mysql-charset": "Кадаваньне базы зьвестак:",
+ "config-mysql-binary": "Двайковае",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "У '''двайковым рэжыме''', MediaWiki захоўвае тэкст у кадаваньні UTF-8 у базе зьвестак у двайковых палях.\nГэта болей эфэктыўна за рэжым MySQL UTF-8, і дазваляе Вам выкарыстоўваць увесь дыяпазон сымбаляў Unicode.\n\nУ '''рэжыме UTF-8''', MySQL ведае, якая табліцы сымбаляў выкарыстоўваецца ў Вашых зьвестках, і можа адпаведна прадстаўляць і канвэртаваць іх, але гэта не дазволіць Вам захоўваць сымбалі па-за межамі [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Базавага шматмоўнага дыяпазону].",
+ "config-mssql-auth": "Тып аўтэнтыфікацыі:",
+ "config-mssql-install-auth": "Абярыце тып аўтэнтыфікацыі, які будзе выкарыстаны для злучэньня з базай зьвестак падчас працэсу ўсталяваньня.\nКалі вы абярэце «{{int:config-mssql-windowsauth}}», будуць выкарыстаныя ўліковыя зьвесткі карыстальніка, пад якім працуе вэб-сэрвэр.",
+ "config-mssql-web-auth": "Абярыце тып аўтэнтыфікацыі, які вэб-сэрвэр будзе выкарыстоўваць для злучэньня з базай зьвестак падчас звычайнага функцыянаваньня вікі.\nКалі вы абярэце «{{int:config-mssql-windowsauth}}», будуць выкарыстаныя ўліковыя зьвесткі карыстальніка, пад якім працуе вэб-сэрвэр.",
+ "config-mssql-sqlauth": "Аўтэнтыфікацыя SQL-сэрвэра",
+ "config-mssql-windowsauth": "Windows-аўтэнтыфікацыя",
+ "config-site-name": "Назва вікі:",
+ "config-site-name-help": "Назва будзе паказвацца ў загалоўку браўзэра і ў некаторых іншых месцах.",
+ "config-site-name-blank": "Увядзіце назву сайта.",
+ "config-project-namespace": "Прастора назваў праекту:",
+ "config-ns-generic": "Праект",
+ "config-ns-site-name": "Такая ж, як і назва вікі: $1",
+ "config-ns-other": "Іншая (вызначце)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "На ўзор Вікіпэдыі, шматлікія вікі трымаюць уласныя старонкі з правіламі асобна ад старонак са зьместам, у «'''прасторы назваў праекту'''».\nУсе назвы старонак у гэтай прасторы назваў пачынаюцца з прыстаўкі, якую Вы можаце пазначыць тут.\nЗвычайна гэтая прыстаўка — вытворная ад назвы вікі, яле яна ня можа ўтрымліваць некаторыя сымбалі, такія як «#» ці «:».",
+ "config-ns-invalid": "Пададзеная няслушная прастора назваў «<nowiki>$1</nowiki>».\nПадайце іншую прастору назваў праекту.",
+ "config-ns-conflict": "Пазначаная прастора назваў «<nowiki>$1</nowiki>» канфліктуе з прасторай назваў MediaWiki па змоўчваньні.\nПазначце іншую прастору назваў праекту.",
+ "config-admin-box": "Рахунак адміністратара",
+ "config-admin-name": "Вашае імя карыстальніка:",
+ "config-admin-password": "Пароль:",
+ "config-admin-password-confirm": "Пароль яшчэ раз:",
+ "config-admin-help": "Увядзіце тут Вашае імя ўдзельніка, напрыклад «Янка Кавалевіч».\nГэтае імя будзе выкарыстоўвацца для ўваходу ў вікі.",
+ "config-admin-name-blank": "Увядзіце імя адміністратара.",
+ "config-admin-name-invalid": "Пададзенае няслушнае імя ўдзельніка «<nowiki>$1</nowiki>».\nПадайце іншае імя ўдзельніка.",
+ "config-admin-password-blank": "Увядзіце пароль рахунку адміністратара.",
+ "config-admin-password-mismatch": "Уведзеныя Вамі паролі не супадаюць.",
+ "config-admin-email": "Адрас электроннай пошты:",
+ "config-admin-email-help": "Увядзіце тут адрас электроннай пошты, каб атрымліваць электронныя лісты ад іншых удзельнікаў вікі, скідваць Ваш пароль і атрымліваць абвешчаньні пра зьмены старонак, якія знаходзяцца ў Вашым сьпісе назіраньня. Вы можаце пакінуць гэтае поле пустым.",
+ "config-admin-error-user": "Унутраная памылка падчас стварэньня рахунку адміністратара зь іменем «<nowiki>$1</nowiki>».",
+ "config-admin-error-password": "Унутраная памылка падчас устаноўкі паролю для адміністратара «<nowiki>$1</nowiki>»: <pre>$2</pre>",
+ "config-admin-error-bademail": "Вы ўвялі няслушны адрас электроннай пошты",
+ "config-subscribe": "Падпісацца на [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce сьпіс распаўсюджаньня навінаў пра зьяўленьне новых вэрсіяў].",
+ "config-subscribe-help": "Гэта ня вельмі актыўны сьпіс распаўсюджаньня навінаў пра зьяўленьне новых вэрсіяў, які ўключаючы важныя навіны пра бясьпеку.\nВам неабходна падпісацца на яго і абнавіць Вашае ўсталяваньне MediaWiki, калі зьявяцца новыя вэрсіі.",
+ "config-subscribe-noemail": "Вы спрабавалі падпісацца на рассылку паведамленьняў пра выхад новых вэрсіяў, не пазначыўшы адрас электроннай пошты.\nКалі ласка, падайце слушны адрас, калі Вы жадаеце падпісацца на рассылку.",
+ "config-pingback": "Дзяліцца зьвесткамі пра гэтую ўсталёўку з распрацоўнікамі MediaWiki.",
+ "config-pingback-help": "Калі вы абярэце гэтую наладу, MediaWiki будзе час ад часу дасылаць базавыя зьвесткі пра гэтую ўсталёўку на https://www.mediawiki.org. Гэтыя зьвесткі ўключаюць, напрыклад, тып сыстэмы, вэрсію PHP і абраную базу зьвестак. Фундацыя «Вікімэдыя» дзеліцца гэтымі зьвесткамі з распрацоўнікамі MediaWiki, каб скіраваць далейшыя шляхі распрацоўкі. Наступныя зьвесткі будуць дасланыя для вашай сыстэмы:\n<pre>$1</pre>",
+ "config-almost-done": "Вы амаль што скончылі!\nАстатнія налады можна прапусьціць і пачаць усталяваньне вікі.",
+ "config-optional-continue": "Задаць болей пытаньняў.",
+ "config-optional-skip": "Хопіць, проста ўсталяваць вікі.",
+ "config-profile": "Профіль правоў удзельніка:",
+ "config-profile-wiki": "Адкрытая вікі",
+ "config-profile-no-anon": "Патрэбнае стварэньне рахунку",
+ "config-profile-fishbowl": "Толькі для аўтарызаваных рэдактараў",
+ "config-profile-private": "Прыватная вікі",
+ "config-profile-help": "Вікі працуюць лепей, калі Вы дазваляеце як мага большай колькасьці людзей рэдагаваць яе.\nУ MediaWiki вельмі лёгка праглядаць апошнія зьмены і выпраўляць любыя памылкі зробленыя недасьведчанымі ўдзельнікамі альбо вандаламі.\n\nТым ня менш, многія лічаць, што MediaWiki можа быць карыснай у шматлікіх іншых ролях, і часта вельмі нялёгка растлумачыць усім перавагі выкарыстаньня тэхналёгіяў вікі.\nТаму Вы маеце выбар.\n\n<strong>{{int:config-profile-wiki}}</strong> дазваляе рэдагаваць усім, нават без уваходу ў сыстэму.\nВікі з <strong>{{int:config-profile-no-anon}}</strong> дазваляе дадатковую адказнасьць, але можа адштурхнуць некаторых патэнцыйных удзельнікаў.\n\nСцэнар <strong>{{int:config-profile-fishbowl}}</strong> дазваляе рэдагаваць зацьверджаным удзельнікам, але ўсе могуць праглядаць старонкі іх гісторыю.\n<strong>{{int:config-profile-private}}</strong> дазваляе праглядаць і рэдагаваць старонкі толькі зацьверджаным удзельнікам.\n\nБольш складаныя правы ўдзельнікаў даступныя пасьля ўсталяваньня, глядзіце [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights адпаведную старонку дакумэнтацыі].",
+ "config-license": "Аўтарскія правы і ліцэнзія:",
+ "config-license-none": "Без інфармацыі пра ліцэнзію",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (грамадзкі набытак)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 ці болей позьняя",
+ "config-license-pd": "Грамадзкі набытак",
+ "config-license-cc-choose": "Выберыце іншую ліцэнзію Creative Commons",
+ "config-license-help": "Шматлікія адкрытыя вікі публікуюць увесь унёсак у праект на ўмовах [http://freedomdefined.org/Definition вольнай ліцэнзіі].\nГэта дазваляе ствараць эфэкт супольнай уласнасьці і садзейнічае доўгатэрміноваму ўнёску.\nДля прыватных і карпаратыўных вікі гэта не зьяўляецца неабходнасьцю.\n\nКалі Вы жадаеце выкарыстоўваць тэкст зь Вікіпэдыі, і жадаеце, каб Вікіпэдыя магла прымаць тэксты, скапіяваныя з Вашай вікі, Вам неабходна выбраць ліцэнзію <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nРаней Вікіпэдыя выкарыстоўвала ліцэнзію GNU Free Documentation.\nЯна ўсё яшчэ дзейнічае, але яна ўтрымлівае некаторыя моманты, якія ўскладняюць паўторнае выкарыстаньне і інтэрпрэтацыю матэрыялаў.",
+ "config-email-settings": "Налады электроннай пошты",
+ "config-enable-email": "Дазволіць выходзячыя электронныя лісты",
+ "config-enable-email-help": "Калі Вы жадаеце, каб працавала электронная пошта, неабходна сканфігураваць PHP [http://www.php.net/manual/en/mail.configuration.php адпаведным чынам].\nКалі Вы не жадаеце выкарыстоўваць магчымасьці электроннай пошты, Вы можаце яе адключыць.",
+ "config-email-user": "Дазволіць электронную пошту для сувязі паміж удзельнікамі",
+ "config-email-user-help": "Дазволіць усім удзельнікам дасылаць адзін аднаму электронныя лісты, калі ўключаная адпаведная магчымасьць ў іх наладах.",
+ "config-email-usertalk": "Уключыць абвяшчэньні пра паведамленьні на старонцы абмеркаваньня",
+ "config-email-usertalk-help": "Дазваляе ўдзельнікам атрымліваць абвяшчэньні пра зьмены на старонцы абмеркаваньня, калі гэтая магчымасьць уключаная ў іх наладах.",
+ "config-email-watchlist": "Уключыць абвяшчэньні пра зьмены ў сьпісе назіраньня",
+ "config-email-watchlist-help": "Дазваляе ўдзельнікам атрымліваць абвяшчэньні пра зьмены ў іх сьпісе назіраньня, калі гэтая магчымасьць уключаная ў іх наладах.",
+ "config-email-auth": "Уключыць аўтэнтыфікацыю праз электронную пошту",
+ "config-email-auth-help": "Калі гэтая магчымасьць уключаная, удзельнікі павінны пацьвердзіць іх адрас электроннай пошты праз спасылку, якая дасылаецца ім праз электронную пошту. Яна дасылаецца і падчас зьмены адрасу электроннай пошты.\nТолькі аўтэнтыфікаваныя адрасы электроннай пошты могуць атрымліваць электронныя лісты ад іншых удзельнікаў, ці зьмяняць абвяшчэньні дасылаемыя праз электронную пошту.\nУключэньне гэтай магчымасьці '''рэкамэндуецца''' для адкрытых вікі, з-за магчымых злоўжываньняў магчымасьцямі электроннай пошты.",
+ "config-email-sender": "Адрас электроннай пошты для вяртаньня:",
+ "config-email-sender-help": "Увядзіце адрас электроннай пошты для вяртаньня ў якасьці адрасу дасылаемых электронных лістоў.\nСюды будуць дасылацца неатрыманыя электронныя лісты.\nШматлікія паштовыя сэрвэры патрабуюць, каб хаця б назва дамэну была слушнай.",
+ "config-upload-settings": "Загрузкі выяваў і файлаў",
+ "config-upload-enable": "Дазволіць загрузку файлаў",
+ "config-upload-help": "Дазвол загрузкі файлаў можа патэнцыйна пагражаць бясьпецы сэрвэра.\nДадатковую інфармацыю можна атрымаць ў [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security разьдзеле бясьпекі].\n\nКаб дазволіць загрузку файлаў, зьмяніце рэжым падкаталёгу <code>images</code> у карэннай дырэкторыі MediaWiki так, каб вэб-сэрвэр меў доступ на запіс.\nПотым дазвольце гэтую магчымасьць.",
+ "config-upload-deleted": "Дырэкторыя для выдаленых файлаў:",
+ "config-upload-deleted-help": "Выберыце дырэкторыю, у якой будуць захоўвацца выдаленыя файлы.\nУ ідэальным выпадку, яна не павінна мець доступу з Інтэрнэту.",
+ "config-logo": "URL-адрас лягатыпу:",
+ "config-logo-help": "Афармленьне MediaWiki па змоўчваньні ўключае прастору для лягатыпу памерам 135×160 піксэляў у верхнім левым куце.\nЗагрузіце выяву адпаведнага памеру і ўвядзіце тут URL-адрас.\n\nВы можаце ўжыць <code>$wgStylePath</code> або <code>$wgScriptPath</code>, калі ваш лягатып знаходзіцца адносна гэтых шляхоў.\n\nКалі Вы не жадаеце мець ніякага лягатыпу, пакіньце поле пустым.",
+ "config-instantcommons": "Дазволіць Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] — магчымасьць, якая дазваляе вікі выкарыстоўваць выявы, гукі і іншыя мэдыя, якія знаходзяцца на сайце [https://commons.wikimedia.org/ Wikimedia Commons].\nКаб гэта зрабіць, MediaWiki патрабуе доступу да Інтэрнэту.\n\nКаб даведацца болей пра гэтую магчымасьць, у тым ліку пра інструкцыю, як яе ўсталяваць для іншых вікі, акрамя Wikimedia Commons, глядзіце [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos дакумэнтацыю].",
+ "config-cc-error": "Выбар ліцэнзіі Creative Commons ня даў вынікаў.\nУвядзіце назву ліцэнзіі ўручную.",
+ "config-cc-again": "Выберыце яшчэ раз…",
+ "config-cc-not-chosen": "Выберыце, якую ліцэнзію Creative Commons Вы жадаеце выкарыстоўваць і націсьніце «proceed».",
+ "config-advanced-settings": "Дадатковыя налады",
+ "config-cache-options": "Налады кэшаваньня аб’ектаў:",
+ "config-cache-help": "Кэшаваньне аб’ектаў павялічвае хуткасьць працы MediaWiki праз кэшаваньне зьвестак, якія часта выкарыстоўваюцца.\nВельмі рэкамэндуем уключыць гэта для сярэдніх і буйных сайтаў, таксама будзе карысна для дробных сайтаў.",
+ "config-cache-none": "Без кэшаваньня (ніякія магчымасьці не страчваюцца, але хуткасьць працы буйных сайтаў можа зьнізіцца)",
+ "config-cache-accel": "Кэшаваньне аб’ектаў PHP (APC, APCu, XCache ці WinCache)",
+ "config-cache-memcached": "Выкарыстоўваць Memcached (патрабуе дадатковай канфігурацыі)",
+ "config-memcached-servers": "Сэрвэры memcached:",
+ "config-memcached-help": "Сьпіс IP-адрасоў, якія будуць выкарыстоўвацца Memcached.\nАдрасы павінны быць у асобным радку з пазначэньнем порту, які будзе выкарыстоўвацца. Напрыклад:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Вы выбралі Memcached у якасьці тыпу Вашага кэша, але не пазначылі ніякага сэрвэра",
+ "config-memcache-badip": "Вы ўвялі няслушны IP-адрас для Memcached: $1",
+ "config-memcache-noport": "Вы не пазначылі порт для выкарыстаньня сэрвэрам Memcached: $1.\nКалі Вы ня ведаеце порт, то па змоўчваньні выкарыстоўваецца 11211",
+ "config-memcache-badport": "Нумар порту Memcached павінен быць паміж $1 і $2",
+ "config-extensions": "Пашырэньні",
+ "config-extensions-help": "Пашырэньні пададзеныя вышэй, былі знойдзеныя ў Вашай дырэкторыі <code>./extensions</code>.\n\nЯны могуць патрабаваць дадатковых наладаў, але іх можна ўключыць зараз",
+ "config-skins": "Тэмы афармленьня",
+ "config-skins-help": "Пералічаныя вышэй тэмы афармленьня знойдзеныя ў вашай тэчцы <code>./skins</code>. Вы мусіце ўключыць як мінімум адну, а таксама абраць тэму па змоўчаньні.",
+ "config-skins-use-as-default": "Выкарыстоўваць па змоўчаньні гэтую тэму афармленьня",
+ "config-skins-missing": "Тэмы афармленьня ня знойдзеныя; MediaWiki будзе ўжываць рэзэрвовую тэму афармленьня, пакуль вы не ўсталюеце нешта адпаведнае.",
+ "config-skins-must-enable-some": "Вы павінны ўключыць як мінімум адну тэму афармленьня.",
+ "config-skins-must-enable-default": "Тэма афармленьня, абраная па змоўчаньні, мусіць быць уключаная.",
+ "config-install-alreadydone": "'''Папярэджаньне:''' здаецца, што Вы ўжо ўсталёўвалі MediaWiki і спрабуеце зрабіць гэтай зноў.\nКалі ласка, перайдзіце на наступную старонку.",
+ "config-install-begin": "Пасьля націску кнопкі «{{int:config-continue}}» пачнецца ўсталяваньне MediaWiki.\nКалі Вы жадаеце што-небудзь зьмяніць, націсьніце кнопку «{{int:config-back}}».",
+ "config-install-step-done": "зроблена",
+ "config-install-step-failed": "не атрымалася",
+ "config-install-extensions": "Уключаючы пашырэньні",
+ "config-install-database": "Налада базы зьвестак",
+ "config-install-schema": "Стварэньне схемы",
+ "config-install-pg-schema-not-exist": "Схема PostgreSQL не існуе",
+ "config-install-pg-schema-failed": "Немагчыма стварыць табліцу.\nУпэўніцеся, што карыстальнік «$1» можа пісаць у схему «$2».",
+ "config-install-pg-commit": "Захаваньне зьменаў",
+ "config-install-pg-plpgsql": "Праверка падтрымкі мовы PL/pgSQL",
+ "config-pg-no-plpgsql": "Вам неабходна ўсталяваць падтрымку мовы PL/pgSQL у базе зьвестак $1",
+ "config-pg-no-create-privs": "Рахунак, які Вы пазначылі для ўсталяваньня ня мае дастаткова правоў для стварэньня рахунку.",
+ "config-pg-not-in-role": "Пазначаны Вамі рахунак для ўэб-карыстальніка ўжо існуе.\nПазначаны Вамі рахунак для ўсталяваньня ня мае правоў і не зьяўляецца сябрам ролі ўэб-карыстальніка, таму немагчыма стварыць аб’екты, якія належаць ўэб-карыстальніку.\n\nЦяпер MediaWiki патрабуе, каб табліцы належалі да ўэб-карыстальніку. Калі ласка, пазначце іншы рахунак, ці націсьніце кнопку «Вярнуцца» і пазначце карыстальніка з неабходнымі для ўсталяваньня правамі.",
+ "config-install-user": "Стварэньне карыстальніка базы зьвестак",
+ "config-install-user-alreadyexists": "Удзельнік «$1» ужо існуе",
+ "config-install-user-create-failed": "Немагчыма стварыць ўдзельніка «$1»: $2",
+ "config-install-user-grant-failed": "Немагчыма даць правы удзельніку «$1»: $2",
+ "config-install-user-missing": "Пазначаны карыстальнік «$1» не існуе.",
+ "config-install-user-missing-create": "Пазначаны карыстальнік «$1» не існуе.\nКалі ласка, пазначце «стварыць рахунак», калі Вы жадаеце яго стварыць.",
+ "config-install-tables": "Стварэньне табліцаў",
+ "config-install-tables-exist": "'''Папярэджаньне''': Выглядае, што табліцы MediaWiki ужо існуюць.\nСтварэньне прапушчанае.",
+ "config-install-tables-failed": "'''Памылка''': немагчыма стварыць табліцы з-за наступнай памылкі: $1",
+ "config-install-interwiki": "Запаўненьне табліцы інтэрвікі па змоўчваньні",
+ "config-install-interwiki-list": "Немагчыма знайсьці файл <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Папярэджаньне''': выглядае, што табліца інтэрвікі ўжо запоўненая.\nСьпіс па змоўчваньні прапушчаны.",
+ "config-install-stats": "Ініцыялізацыі статыстыкі",
+ "config-install-keys": "Стварэньне сакрэтных ключоў",
+ "config-insecure-keys": "<strong>Папярэджаньне:</strong> {{PLURAL:$2|1=Ключ бясьпекі $1 створаны|Ключы бясьпекі $1 створаныя}} падчас усталяваньня, {{PLURAL:$2|1=не зьяўляецца паўнасьцю бясьпечным|не зьяўляюцца поўнасьцю бясьпечнымі}}. Рэкамэндуецца зьмяніць {{PLURAL:$2|1=яго ўручную|іх уручную}}.",
+ "config-install-updates": "Прадухіленьне запуску непатрэбных абнаўленьняў",
+ "config-install-updates-failed": "<strong>Памылка</strong>: устаўка ключоў абнаўленьня ў табліцы завершылася наступнай памылкай: $1",
+ "config-install-sysop": "Стварэньне рахунку адміністратара",
+ "config-install-subscribe-fail": "Немагчыма падпісацца на «mediawiki-announce»: $1",
+ "config-install-subscribe-notpossible": "cURL не ўсталяваны, <code>allow_url_fopen</code> недаступны.",
+ "config-install-mainpage": "Стварэньне галоўнай старонкі са зьместам па змоўчваньні",
+ "config-install-mainpage-exists": "Галоўная старонка ўжо існуе, прапускаем",
+ "config-install-extension-tables": "Стварэньне табліцаў для ўключаных пашырэньняў",
+ "config-install-mainpage-failed": "Немагчыма ўставіць галоўную старонку: $1",
+ "config-install-done": "<strong>Віншуем!</strong>\nВы ўсталявалі MediaWiki.\n\nПраграма ўсталяваньня стварыла файл <code>LocalSettings.php</code>.\nЁн утрымлівае ўсе Вашыя налады.\n\nВам неабходна загрузіць яго і захаваць у карэнную дырэкторыю Вашай вікі (у тую ж самую дырэкторыю, дзе знаходзіцца index.php). Загрузка павінна пачацца аўтаматычна.\n\nКалі загрузка не пачалася, ці Вы яе адмянілі, Вы можаце перазапусьціць яе націснуўшы на спасылку ніжэй:\n\n$3\n\n<strong>Заўвага</strong>: калі Вы гэтага ня зробіце зараз, то створаны файл ня будзе даступны Вам потым, калі Вы выйдзеце з праграмы ўсталяваньня безь яго загрузкі.\n\nКалі Вы гэта зробіце, Вы можаце <strong>[$2 ўвайсьці ў Вашую вікі]</strong>.",
+ "config-install-done-path": "<strong>Віншуем!</strong>\nВы ўсталявалі MediaWiki.\n\nПраграма ўсталёўкі стварыла файл <code>LocalSettings.php</code>. Ён утрымлівае ўсе вашыя налады.\n\nВам трэба спампаваць яго і пакласьці ў <code>$4</code>. Спампоўка павінна пачацца аўтаматычна.\n\nКалі спампоўка не пачалася або вы адмянілі яе, вы можаце пачаць яе наноў, калі націсьніце на наступную спасылку:\n\n$3\n\n<strong>Заўвага:</strong> Калі вы ня зробіце гэта зараз, то створаны файл ня будзе даступны вам па выхадзе з праграмы безь яго спампоўкі.\n\nКалі вы зробіце гэта, вы можаце <strong>[$2 ўвайсьці ў вашую вікі]</strong>.",
+ "config-download-localsettings": "Загрузіць <code>LocalSettings.php</code>",
+ "config-help": "дапамога",
+ "config-help-tooltip": "націсьніце, каб разгарнуць",
+ "config-nofile": "Файл «$1» ня знойдзены. Ці быў ён выдалены?",
+ "config-extension-link": "Ці ведаеце вы, што вашая вікі падтрымлівае [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions пашырэньні]?\n\nВы можаце праглядзець [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category пашырэньні паводле катэгорыяў] або [https://www.mediawiki.org/wiki/Extension_Matrix матрыцу пашырэньняў], каб пабачыць поўны сьпіс.",
+ "config-skins-screenshots": "$1 (здымкі экрану: $2)",
+ "config-screenshot": "здымак экрану",
+ "mainpagetext": "<strong>MediaWiki была ўсталяваная.</strong>",
+ "mainpagedocfooter": "Глядзіце [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents дапаможнік карыстальніка] для атрыманьня інфармацыі па карыстаньні вікі-праграмамі.\n\n== З чаго пачаць ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Сьпіс парамэтраў канфігурацыі]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Частыя пытаньні MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Рассылка паведамленьняў пра зьяўленьне новых вэрсіяў MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Пераклад MediaWiki на вашую мову]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Даведайцеся, як змагацца з спамам у вашай вікі]"
+}
diff --git a/www/wiki/includes/installer/i18n/be.json b/www/wiki/includes/installer/i18n/be.json
new file mode 100644
index 00000000..a4558796
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/be.json
@@ -0,0 +1,21 @@
+{
+ "@metadata": {
+ "authors": [
+ "Чаховіч Уладзіслаў",
+ "Mechanizatar"
+ ]
+ },
+ "config-desc": "Інсталятар MediaWiki",
+ "config-information": "Інфармацыя",
+ "config-localsettings-key": "Ключ абнаўлення:",
+ "config-your-language": "Ваша мова:",
+ "config-wiki-language": "Мова Вікі:",
+ "config-back": "← Назад",
+ "config-page-language": "Мова",
+ "config-page-welcome": "Сардэчна запрашаем у MediaWiki!",
+ "config-page-name": "Назва",
+ "config-page-options": "Настройкі",
+ "config-upload-settings": "Загрузка выяў і файлаў",
+ "mainpagetext": "<strong>MediaWiki паспяхова ўсталяваная.</strong>",
+ "mainpagedocfooter": "Гл. [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Дапаможнік карыстальніка (англ.)] для атрымання інфармацыі аб карыстанні вікі-праграмамі.\n\n== З чаго пачаць ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Пералік параметраў канфігурацыі (англ.)]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ЧАПЫ MediaWiki (англ.)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Ліставанне аб выпусках MediaWiki (англ.)]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Пераклад MediaWiki на Вашу мову]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Даведайцеся, як змагацца са спамам у Вашай вікі]"
+}
diff --git a/www/wiki/includes/installer/i18n/bg.json b/www/wiki/includes/installer/i18n/bg.json
new file mode 100644
index 00000000..6ecb874b
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bg.json
@@ -0,0 +1,316 @@
+{
+ "@metadata": {
+ "authors": [
+ "DCLXVI",
+ "아라",
+ "StanProg",
+ "Vodnokon4e",
+ "Seb35"
+ ]
+ },
+ "config-desc": "Инсталатор на МедияУики",
+ "config-title": "Инсталиране на МедияУики $1",
+ "config-information": "Информация",
+ "config-localsettings-upgrade": "Беше открит файл <code>LocalSettings.php</code>.\nЗа надграждане на съществуващата инсталация, необходимо е в кутията по-долу да се въведе стойността на <code>$wgUpgradeKey</code>.\nТази информация е налична в <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Беше открит файл <code>LocalSettings.php</code>.\nЗа надграждане на наличната инсталация, необходимо е да се стартира <code>update.php</code>",
+ "config-localsettings-key": "Ключ за надграждане:",
+ "config-localsettings-badkey": "Предоставеният ключ за надграждане е неправилен.",
+ "config-upgrade-key-missing": "Беше открита съществуваща инсталация на МедияУики.\nЗа надграждане на съществуващата инсталация, необходимо е да се постави следният ред в края на файла <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Съществуващият файл <code>LocalSettings.php</code> изглежда непълен.\nПроменливата $1 не е зададена.\nНеобходимо е да се редактира файлът <code>LocalSettings.php</code> и да се зададе променливата, след което да се натисне „{{int:Config-continue}}“.",
+ "config-localsettings-connection-error": "Възникна грешка при свързване с базата данни чрез посочените настройки в <code>LocalSettings.php</code>. Преди следващ опит за свързване, необходимо е настройките да бъдат коригирани.\n\n$1",
+ "config-session-error": "Грешка при създаване на сесия: $1",
+ "config-session-expired": "Срокът на валидност на данните от сесията са изтекли.\nПродължителността на сесиите е настроена на $1.\nТова може да бъде увеличено чрез настройване на <code>session.gc_maxlifetime</code> в php.ini.\nНеобходимо е рестартиране на инсталационния процес.",
+ "config-no-session": "Данните за сесията бяха загубени!\nПроверете вашия php.ini и се уверете, че на <code>session.save_path</code> е настроена подходящата директория.",
+ "config-your-language": "Вашият език:",
+ "config-your-language-help": "Избиране на език за използване по време на инсталацията.",
+ "config-wiki-language": "Език на уикито:",
+ "config-wiki-language-help": "Избиране на език, на който ще е основното съдържание на уикито.",
+ "config-back": "← Връщане",
+ "config-continue": "Продължаване →",
+ "config-page-language": "Език",
+ "config-page-welcome": "Добре дошли в МедияУики!",
+ "config-page-dbconnect": "Свързване с базата от данни",
+ "config-page-upgrade": "Надграждане на съществуваща инсталация",
+ "config-page-dbsettings": "Настройки на базата от данни",
+ "config-page-name": "Име",
+ "config-page-options": "Настройки",
+ "config-page-install": "Инсталиране",
+ "config-page-complete": "Готово!",
+ "config-page-restart": "Рестартиране на инсталацията",
+ "config-page-readme": "Информация за софтуера",
+ "config-page-releasenotes": "Бележки за версията",
+ "config-page-copying": "Лицензно споразумение",
+ "config-page-upgradedoc": "Надграждане",
+ "config-page-existingwiki": "Съществуващо уики",
+ "config-help-restart": "Необходимо е потвърждение за изтриване на всички въведени и съхранени данни и започване отначало на процеса по инсталация.",
+ "config-restart": "Да, започване отначало",
+ "config-welcome": "=== Проверка на условията ===\nЩе бъдат извършени основни проверки, които да установят дали условията са подходящи за инсталиране на МедияУики.\nАко е необходима помощ по време на инсталацията, резултатите от направените проверки трябва също да бъдат предоставени.",
+ "config-copyright": "=== Авторски права и условия ===\n\n$1\n\nТази програма е свободен софтуер, който може да се променя и/или разпространява според Общия публичен лиценз на GNU, както е публикуван от Free Software Foundation във версия на Лиценза 2 или по-късна версия.\n\nТази програма се разпространява с надеждата, че ще е полезна, но <strong>без каквито и да е гаранции</strong>; без дори косвена гаранция за <strong>продаваемост</strong> или <strong>пригодност за конкретна употреба</strong> .\nЗа повече подробности се препоръчва преглеждането на Общия публичен лиценз на GNU.\n\nКъм програмата трябва да е приложено <doclink href=Copying>копие на Общия публичен лиценз на GNU</doclink>; ако не, можете да пишете на Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, или да [http://www.gnu.org/copyleft/gpl.html го прочетете онлайн].",
+ "config-sidebar": "* [https://www.mediawiki.org Сайт на МедияУики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Наръчник на потребителя]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Наръчник на администратора]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ЧЗВ]\n----\n* <doclink href=Readme>Документация</doclink>\n* <doclink href=ReleaseNotes>Бележки за версията</doclink>\n* <doclink href=Copying>Авторски права</doclink>\n* <doclink href=UpgradeDoc>Обновяване</doclink>",
+ "config-env-good": "Средата беше проверена.\nИнсталирането на МедияУики е възможно.",
+ "config-env-bad": "Средата беше проверена.\nНе е възможна инсталация на МедияУики.",
+ "config-env-php": "Инсталирана е версия на PHP $1.",
+ "config-env-hhvm": "HHVM $1 е инсталиран.",
+ "config-unicode-using-intl": "Използване на разширението [http://pecl.php.net/intl intl PECL] за нормализация на Уникод.",
+ "config-unicode-pure-php-warning": "<strong>Внимание:</strong> [http://pecl.php.net/intl Разширението intl PECL] не е налично за справяне с нормализацията на Уникод, превключване към по-бавното изпълнение на чист PHP.\nАко сайтът е с голям трафик, препоръчително е запознаването с [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализацията на Уникод].",
+ "config-unicode-update-warning": "<strong>Предупреждение</strong>: Инсталираната версия на Обвивката за нормализация на Unicode използва по-старата версия на библиотеката на [http://site.icu-project.org/ проекта ICU].\nНеобходимо е да [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations инсталирате по-нова версия], в случай че сте загрижени за използването на Unicode.",
+ "config-no-db": "Не може да бъде открит подходящ драйвер за база данни! Необходимо е да инсталирате драйвер за база данни за PHP.\n{{PLURAL:$2|Поддържа се следния тип|Поддържат се следните типове}} бази от данни: $1.\n\nАко сами сте компилирали PHP, преконфигурирайте го с включен клиент за база данни, например чрез използване на <code>./configure --with-mysqli</code>.\nАко сте инсталирали PHP от пакет за Debian или Ubuntu, необходимо е също така да инсталирате и модула <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Внимание:</strong> имате инсталиран SQLite $1, а минималната допустима версия е $2. SQLite ще бъде недостъпна за ползване.",
+ "config-no-fts3": "'''Предупреждение''': SQLite е компилирана без [//sqlite.org/fts3.html модула FTS3], затова възможностите за търсене няма да са достъпни.",
+ "config-pcre-old": "<strong>Фатална грешка:</strong> Изисква се PCRE версия $1 или по-нова.\nИзпълнимият файл на PHP е свързан с PCRE версия $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/Повече информация за PCRE].",
+ "config-pcre-no-utf8": "'''Фатално''': Модулът PCRE на PHP изглежда е компилиран без поддръжка на PCRE_UTF8.\nЗа да функционира правилно, МедияУики изисква поддръжка на UTF-8.",
+ "config-memory-raised": "<code>memory_limit</code> на PHP е $1, увеличаване до $2.",
+ "config-memory-bad": "<strong>Внимание:</strong> <code>memory_limit</code> на PHP е $1.\nСтойността вероятно е твърде ниска.\nВъзможно е инсталацията да се провали!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] е инсталиран",
+ "config-apc": "[http://www.php.net/apc APC] е инсталиран",
+ "config-apcu": "[http://www.php.net/apc APC] е инсталиран",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] е инсталиран",
+ "config-no-cache-apcu": "<strong>Внимание:</strong> [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] и [http://www.iis.net/download/WinCacheForPhp WinCache] не могат да бъдат открити.\nКеширането на обекти не е активирано.",
+ "config-mod-security": "<strong>Предупреждение:</strong> [http://modsecurity.org/ mod_security]/mod_security2 е включено на вашия уеб сървър. Много от обичайните му конфигурации пораждат проблеми с МедияУики и друг софтуер, който позволява публикуване на произволно съдържание.\nАко е възможно, моля изключете го. В противен случай се обърнете към [http://modsecurity.org/documentation/ документацията на mod_security] или се свържете с поддръжката на хостинга си, ако се сблъскате със случайни грешки.",
+ "config-diff3-bad": "GNU diff3 не беше намерен.",
+ "config-git": "Налична е системата за контрол на версиите Git: <code>$1</code>.",
+ "config-git-bad": "Не е намерен софтуер за контрол на версиите Git.",
+ "config-imagemagick": "Открит е ImageMagick: <code>$1</code>.\nПреоразмеряването на картинки ще бъде включено ако качването на файлове бъде разрешено.",
+ "config-gd": "Открита е вградена графичната библиотека GD.\nАко качването на файлове бъде включено, ще бъде включена възможността за преоразмеряване на картинки.",
+ "config-no-scaling": "Не са открити библиотеките GD или ImageMagick.\nПреоразмеряването на картинки ще бъде изключено.",
+ "config-no-uri": "'''Грешка:''' Не може да се определи текущия адрес.\nИнсталация беше прекратена.",
+ "config-no-cli-uri": "<strong>Внимание:</strong> Не е зададен параметър <code>--scriptpath</code>, стойност по подразбиране: <code>$1</code>.",
+ "config-using-server": "Използване на сървърното име \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Използване на сървърния адрес (URL) \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Внимание:</strong> Папката по подразбиране за качване <code>$1</code> е уязвима от изпълнение на зловредни скриптове.\nВъпреки че МедияУики извършва проверка за заплахи в сигурността на всички качени файлове, силно препоръчително е да се [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security затвори тази уязвимост в сигурността] преди разрешаване за качване на файлове.",
+ "config-no-cli-uploads-check": "<strong>Предупреждение:</strong> Директорията по подразбиране за качване на файлове (<code>$1</code>) не е проверена за уязвимости при изпълнение на произволен скрипт по време на инсталацията от командния ред.",
+ "config-brokenlibxml": "Вашата система използва комбинация от версии на PHP и libxml2, които са с много грешки и могат да причинят скрити повреди на данните в МедияУики или други уеб приложения.\nНеобходимо е обновяване до libxml2 2.7.3 или по-нова версия ([https://bugs.php.net/bug.php?id=45996 докладвана грешка при PHP]).\nИнсталацията беше прекратена.",
+ "config-suhosin-max-value-length": "Suhosin е инсталиран и ограничава дължината GET параметъра <code>length</code> на $1 байта. Компонентът на МедияУики ResourceLoader ще може да пренебрегне частично това ограничение, но това ще намали производителността. По възможност е препоръчително да се настрои <code>suhosin.get.max_value_length</code> на 1024 или по-голяма стойност в <code>php.ini</code> и в LocalSettings.php да се настрои <code>$wgResourceLoaderMaxQueryLength</code> със същата стойност.",
+ "config-db-type": "Тип на базата от данни:",
+ "config-db-host": "Хост на базата от данни:",
+ "config-db-host-help": "Ако базата от данни е на друг сървър, в кутията се въвежда името на хоста или IP адреса.\n\nАко се използва споделен уеб хостинг, доставчикът на услугата би трябвало да е предоставил в документацията си коректния хост.\n\nАко инсталацията протича на Windows-сървър и се използва MySQL, използването на \"localhost\" може да е неприемливо. В такива случаи се използва \"127.0.0.1\" за локален IP адрес.\n\nПри използване на PostgreSQL, това поле се оставя празно, за свързване чрез Unix socket.",
+ "config-db-host-oracle": "TNS на базата данни:",
+ "config-db-host-oracle-help": "Въведете валидно [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; файлът tnsnames.ora трябва да бъде видим за инсталацията.<br />Ако използвате клиентска библиотека версия 10g или по-нова можете да използвате метода [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Идентифициране на това уики",
+ "config-db-name": "Име на базата от данни:",
+ "config-db-name-help": "Избира се име, което да идентифицира уикито.\nТо не трябва да съдържа интервали.\n\nАко се използва споделен хостинг, доставчикът на услугата би трябвало да е предоставил или име на базата от данни, която да бъде използвана, или да позволява създаването на бази от данни чрез контролния панел.",
+ "config-db-name-oracle": "Схема на базата от данни:",
+ "config-db-account-oracle-warn": "Има три поддържани сценария за инсталиране на Oracle като бекенд база данни:\n\nАко искате да създадете профил в базата данни като част от процеса на инсталиране, моля, посочете профил със SYSDBA като профил в базата данни за инсталиране и посочете желаните данни за влизане (име и парола) за профил с уеб достъп; в противен случай можете да създадете профил с уеб достъп ръчно и предоставите само него (ако той има необходимите права за създаване на схематични обекти), или да предоставите два различни профила - един с привилегии за създаване на обекти, и друг - с ограничения за уеб достъп.\n\nСкрипт за създаването на профил с необходимите привилегии може да се намери в папката „maintenance/oracle/“ на тази инсталация. Имайте в предвид, че използването на ограничен профил ще деактивира всички възможности за обслужване на профила по подразбиране.",
+ "config-db-install-account": "Потребителска сметка за инсталацията",
+ "config-db-username": "Потребителско име за базата от данни:",
+ "config-db-password": "Парола за базата от данни:",
+ "config-db-install-username": "Въвежда се потребителско име, което ще се използва за свързване с базата от данни по време на процеса по инсталация.\nТова не е потребителско име за сметка в МедияУики; това е потребителско име за базата от данни.",
+ "config-db-install-password": "Въвежда се парола, която ще бъде използвана за свързване с базата от данни по време на инсталационния процес.\nТова не е парола за сметка в МедияУики; това е парола за базата от данни.",
+ "config-db-install-help": "Въвеждат се потребителско име и парола, които ще бъдат използвани за свързване с базата от данни по време на инсталационния процес.",
+ "config-db-account-lock": "Използване на същото потребителско име и парола по време на нормална работа",
+ "config-db-wiki-account": "Потребителска сметка за нормална работа",
+ "config-db-wiki-help": "Въвежда се потребителско име и парола, които ще се използват при нормалното функциониране на уикито.\nАко сметката не съществува и използваната при инсталацията сметка има необходимите права, тази потребителска сметка ще бъде създадена с минималните необходими права за работа с уикито.",
+ "config-db-prefix": "Представка за таблиците в базата от данни:",
+ "config-db-prefix-help": "Ако е необходимо да се сподели базата от данни между няколко уикита или между МедияУики и друго уеб приложение, може да се добави представка пред имената на таблиците, за да се избегнат конфликти.\nНе се използват интервали.\n\nТова поле обикновено се оставя празно.",
+ "config-mysql-old": "Изисква се MySQL $1 или по-нова версия, наличната версия е $2.",
+ "config-db-port": "Порт на базата от данни:",
+ "config-db-schema": "Схема за МедияУики",
+ "config-db-schema-help": "Схемата по-горе обикновено е коректна.\nПромени се извършват ако наистина е необходимо.",
+ "config-pg-test-error": "Невъзможно свързване с базата данни '''$1''': $2",
+ "config-sqlite-dir": "Директория за данни на SQLite:",
+ "config-sqlite-dir-help": "SQLite съхранява всички данни в един файл.\n\nПо време на инсталацията уеб сървърът трябва да има права за писане в посочената директория.\n\nТя <strong>не трябва</strong> да е достъпна през уеб, затова не е там, където са PHP файловете.\n\nИнсталаторът ще съхрани заедно с нея файл <code>.htaccess</code>, но ако този метод пропадне, някой може да придобие достъп до суровите данни от базата от данни.\nТова включва сурови данни за потребителите (адреси за е-поща, хеширани пароли), както и изтрити версии на страници и друга чувствителна и с ограничен достъп информация от и за уикито.\n\nБазата от данни е препоръчително да се разположи на друго място, например в <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Таблично пространство по подразбиране:",
+ "config-oracle-temp-ts": "Временно таблично пространство:",
+ "config-type-mysql": "MySQL (или съвместима)",
+ "config-type-mssql": "Microsoft SQL сървър",
+ "config-support-info": "МедияУики поддържа следните системи за бази от данни:\n\n$1\n\nАко не виждате желаната за използване система в списъка по-долу, следвайте инструкциите за активиране на поддръжка по-горе.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] е най-важна за МедияУики и се поддържа най-добре. МедияУики работи също така с [{{int:version-db-mariadb-url}} MariaDB] и [{{int:version-db-percona-url}} Percona Server], които са съвместими с MySQL.\n([http://www.php.net/manual/bg/mysqli.installation.php Как се компилира PHP с поддръжка на MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] е популярна система за управление на бази от данни, алтернатива на MySQL. ([http://www.php.net/manual/bg/pgsql.installation.php Как се компилира PHP с поддръжка на PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] е олекотена система за бази от данни, която е много добре поддържана. ([http://www.php.net/manual/bg/pdo.installation.php Как се компилира PHP с поддръжка на SQLite], използва PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] е комерсиална корпоративна база от данни. ([http://www.php.net/manual/en/oci8.installation.php Как се компилира PHP с поддръжка на OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] е комерсиална корпоративна база от данни за Windows. ([http://www.php.net/manual/bg/sqlsrv.installation.php Как да се компилира PHP с поддръжка на SQLSRV])",
+ "config-header-mysql": "Настройки за MySQL",
+ "config-header-postgres": "Настройки за PostgreSQL",
+ "config-header-sqlite": "Настройки за SQLite",
+ "config-header-oracle": "Настройки за Oracle",
+ "config-header-mssql": "Настройки за Microsoft SQL сървър",
+ "config-invalid-db-type": "Невалиден тип база от данни",
+ "config-missing-db-name": "Необходимо е да се въведе стойност за „{{int:config-db-name}}“.",
+ "config-missing-db-host": "Необходимо е да се въведе стойност за „{{int:config-db-host}}“.",
+ "config-missing-db-server-oracle": "Необходимо е да се въведе стойност за „{{int:config-db-host-oracle}}“.",
+ "config-invalid-db-server-oracle": "Невалиден TNS на базата от данни „$1“.\nИзползвайте „TNS Name“ или „Easy Connect“ ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Методи за именуване на Oracle])",
+ "config-invalid-db-name": "Невалидно име на базата от данни „$1“.\nИзползват се само ASCII букви (a-z, A-Z), цифри (0-9), долни черти (_) и тирета (-).",
+ "config-invalid-db-prefix": "Невалидна представка за базата от данни „$1“.\nПозволени са само ASCII букви (a-z, A-Z), цифри (0-9), долни черти (_) и тирета (-).",
+ "config-connection-error": "$1.\n\nНеобходимо е да се проверят хостът, потребителското име и паролата, след което да се опита отново.",
+ "config-invalid-schema": "Невалидна схема за МедияУики „$1“.\nДопустими са само ASCII букви (a-z, A-Z), цифри (0-9) и долни черти (_).",
+ "config-db-sys-create-oracle": "Инсталаторът поддържа само сметка SYSDBA за създаване на нова сметка.",
+ "config-db-sys-user-exists-oracle": "Потребителската сметка „$1“ вече съществува. SYSDBA може да се използва само за създаване на нова сметка!",
+ "config-postgres-old": "Изисква се PostgreSQL $1 или по-нова версия, наличната версия е $2.",
+ "config-mssql-old": "Изисква се Microsoft SQL Server версия $1 или по-нова. Вашата версия е $2.",
+ "config-sqlite-name-help": "Избира се име, което да идентифицира уикито.\nНе се използват интервали или тирета.\nТова име ще се използва за име на файла за данни на SQLite.",
+ "config-sqlite-parent-unwritable-group": "Директорията за данни <code><nowiki>$1</nowiki></code> не може да бъде създадена, тъй като уеб сървърът няма права за писане в родителската директория <code><nowiki>$2</nowiki></code>.\n\nИнсталаторът разпознава потребителското име, с което работи уеб сървърът.\nУверете се, че той притежава права за писане в директорията <code><nowiki>$3</nowiki></code> преди да продължите.\nВ Unix/Линукс системи можете да използвате:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Директорията за данни <code><nowiki>$1</nowiki></code> не може да бъде създадена, тъй като уеб сървърът няма права за писане в родителската директория <code><nowiki>$2</nowiki></code>.\n\nИнсталаторът не може да определи потребителското име, с което работи уеб сървърът.\nУверете се, че в директория <code><nowiki>$3</nowiki></code> може да бъде писано от уеб сървъра (или от други потребители!) преди да продължите.\nНа Unix/Линукс системи можете да използвате:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Грешка при създаване на директорията за данни „$1“.\nПроверете местоположението ѝ и опитайте отново.",
+ "config-sqlite-dir-unwritable": "Уеб сървърът няма права за писане в директория „$1“.\nПроменете правата му така, че да може да пише в нея, и опитайте отново.",
+ "config-sqlite-connection-error": "$1.\n\nПроверете директорията за данни и името на базата от данни по-долу и опитайте отново.",
+ "config-sqlite-readonly": "Файлът <code>$1</code> няма права за писане.",
+ "config-sqlite-cant-create-db": "Файлът за базата от данни <code>$1</code> не може да бъде създаден.",
+ "config-sqlite-fts3-downgrade": "Липсва поддръжката на FTS3 за PHP, извършен беше downgradе на таблиците.",
+ "config-can-upgrade": "В базата от данни има таблици за МедияУики.\nЗа надграждането им за MediaWiki $1, натиска се <strong>Продължаване</strong>.",
+ "config-upgrade-done": "Обновяването приключи.\n\nВече е възможно [$1 да използвате уикито].\n\nАко е необходимо, възможно е файлът <code>LocalSettings.php</code> да бъде създаден отново чрез натискане на бутона по-долу.\nТова <strong>не е препоръчително действие</strong>, освен ако не срещате затруднения с уикито.",
+ "config-upgrade-done-no-regenerate": "Обновяването приключи.\n\nВече е възможно [$1 да използвате уикито].",
+ "config-regenerate": "Създаване на LocalSettings.php →",
+ "config-show-table-status": "Заявката <code>SHOW TABLE STATUS</code> не сполучи!",
+ "config-unknown-collation": "'''Предупреждение:''' Базата от данни използва неразпозната колация.",
+ "config-db-web-account": "Сметка за уеб достъп до базата от данни",
+ "config-db-web-help": "Избиране на потребителско име и парола, които уеб сървърът ще използва да се свързва с базата от данни при обичайната работа на уикито.",
+ "config-db-web-account-same": "Използване на същата сметка като при инсталацията.",
+ "config-db-web-create": "Създаване на сметката, ако все още не съществува",
+ "config-db-web-no-create-privs": "Посочената сметка за инсталацията не разполага с достатъчно права за създаване на нова сметка.\nНеобходимо е посочената сметка вече да съществува.",
+ "config-mysql-engine": "Хранилище на данни:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Внимание:</strong> Избрана е MyISAM като система за складиране в MySQL, която не се препоръчва за използване с МедияУики, защото:\n* почти не поддържа паралелност заради заключване на таблиците\n* е по-податлива на повреди в сравнение с други системи\n* кодът на МедияУики не винаги поддържа MyISAM коректно\n\nАко инсталацията на MySQL поддържа InnoDB, силно е препоръчително да се използва тя.\nАко инсталацията на MySQL не поддържа InnoDB, вероятно е време за обновяване.",
+ "config-mysql-only-myisam-dep": "<strong>Внимание:</strong> MyISAM e единственият наличен на тази машина тип на таблиците за MySQL и не е препоръчителен за употреба при МедияУики защото:\n* има слаба поддръжка на конкурентност на заявките, поради закючването на таблиците\n* е много по-податлив на грешки в базите от данни от другите типове таблици\n* кодът на МедияУики не винаги работи с MyISAM както трябва\n\nВашият MySQL не поддържа InnoDB, така че може би е дошло време за актуализиране.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> почти винаги е най-добрата възможност заради навременната си поддръжка.\n\n<strong>MyISAM</strong> може да е по-бърза при инсталации с един потребител или само за четене.\nБазите от данни MyISAM се повреждат по-често от InnoDB.",
+ "config-mysql-charset": "Набор от знаци на базата от данни:",
+ "config-mysql-binary": "Двоичен",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "В <strong>двоичен режим</strong> МедияУики съхранява текстовете в UTF-8 в бинарни полета в базата от данни.\nТова е по-ефективно от UTF-8 режима на MySQL и позволява използването на пълния набор от символи в Уникод.\n\nВ <strong>UTF-8 режим</strong> MySQL ще знае в кой набор от символи са данните от уикито и ще може да ги показва и променя по подходящ начин, но няма да позволява складиране на символи извън [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Основния многоезичен набор].",
+ "config-mssql-auth": "Тип на удостоверяването:",
+ "config-mssql-install-auth": "Изберете начин за удостоверяване, който ще бъде използван за връзка с базата от данни по време на инсталацията.\nАко изберете \"{{int:config-mssql-windowsauth}}\", ще се използват идентификационните данни на потребителя под който работи уеб сървъра.",
+ "config-mssql-web-auth": "Изберете начина за удостоверяване, който ще се използва от уеб сървъра за връзка със сървъра за бази от данни по време на нормалните операции на уикито.\nАко изберете \"{{int:config-mssql-windowsauth}}\", ще се използват идентификационните данни на потребителя под който работи уеб сървъра.",
+ "config-mssql-sqlauth": "Удостоверяване чрез SQL Server",
+ "config-mssql-windowsauth": "Удостоверяване чрез Windows",
+ "config-site-name": "Име на уикито:",
+ "config-site-name-help": "Това име ще се показва в заглавната лента на браузъра и на различни други места.",
+ "config-site-name-blank": "Необходимо е да се въведе име на уикито.",
+ "config-project-namespace": "Именно пространство на проекта:",
+ "config-ns-generic": "Проект",
+ "config-ns-site-name": "Същото като името на уикито: $1",
+ "config-ns-other": "Друго (уточняване)",
+ "config-ns-other-default": "МоетоУики",
+ "config-project-namespace-help": "Следвайки примера на Уикипедия, много уикита съхраняват страниците си с правила в '''именно пространство на проекта''', отделно от основното съдържание.\nВсички заглавия на страниците в това именно пространство започват с определена представка, която може да бъде зададена тук.\nОбикновено представката произлиза от името на уикито, но не може да съдържа символи като \"#\" или \":\".",
+ "config-ns-invalid": "Посоченото именно пространство „<nowiki>$1</nowiki>“ е невалидно.\nНеобходимо е да бъде посочено друго.",
+ "config-ns-conflict": "Посоченото именно пространство „<nowiki>$1</nowiki>“ е в конфликт с използваното по подразбиране именно пространство MediaWiki.\nНеобходимо е да се посочи друго именно пространство.",
+ "config-admin-box": "Администраторска сметка",
+ "config-admin-name": "Вашето потребителско име:",
+ "config-admin-password": "Парола:",
+ "config-admin-password-confirm": "Парола (повторно):",
+ "config-admin-help": "Въвежда се предпочитаното потребителско име, например „Иванчо Иванчев“.\nТова ще е потребителското име, което администраторът ще използва за влизане в уикито.",
+ "config-admin-name-blank": "Необходимо е да бъде въведено потребителско име на администратора.",
+ "config-admin-name-invalid": "Посоченото потребителско име \"<nowiki>$1</nowiki>\" е невалидно.\nНеобходимо е да се посочи друго.",
+ "config-admin-password-blank": "Въведете парола за администраторската сметка.",
+ "config-admin-password-mismatch": "Двете въведени пароли не съвпадат.",
+ "config-admin-email": "Адрес за електронна поща:",
+ "config-admin-email-help": "Въвеждането на адрес за е-поща позволява получаване на е-писма от другите потребители на уикито, възстановяване на изгубена или забравена парола, оповестяване при промени в страниците от списъка за наблюдение. Това поле може да бъде оставено празно.",
+ "config-admin-error-user": "Възникна вътрешна грешка при създаване на администратор с името „<nowiki>$1</nowiki>“.",
+ "config-admin-error-password": "Възникна вътрешна грешка при задаване на парола за администратора „<nowiki>$1</nowiki>“: <pre>$2</pre>",
+ "config-admin-error-bademail": "Въведен е невалиден адрес за електронна поща.",
+ "config-subscribe": "Абониране за [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce пощенския списък за нови версии].",
+ "config-subscribe-help": "Това е пощенски списък с малко трафик, който се използва за съобщения при излизане на нови версии, както и за важни проблеми със сигурността.\nАбонирането е препоръчително, както и надграждането на инсталацията на МедияУики при излизането на нова версия.",
+ "config-subscribe-noemail": "Опитахте да се абонирате за пощенския списък за нови версии без да посочите адрес за електронна поща.\nНеобходимо е да се предостави адрес за електронна поща, в случай че желаете да се абонирате за пощенския списък.",
+ "config-pingback": "Споделяне на данни за инсталацията с разработчиците на МедияУики.",
+ "config-pingback-help": "Ако изберете тази настройка, МедияУики периодично ще пингва https://www.mediawiki.org с основна информация за тази инсталация на МедияУики. Информацията включва например, тип на операционната система, версията на PHP и избраната СУБД. Фондация Уикимедия споделя тези данни с разработчиците на МедияУики, за да им помогне в бъдещото развитие на софтуера. Следните данни ще бъдат изпратени за вашата система:\n<pre>$1</pre>",
+ "config-almost-done": "Инсталацията е почти готова!\nВъзможно е пропускане на оставащата конфигурация и моментално инсталиране на уикито.",
+ "config-optional-continue": "Задаване на допълнителни въпроси.",
+ "config-optional-skip": "Достатъчно, инсталиране на уикито.",
+ "config-profile": "Профил на потребителските права:",
+ "config-profile-wiki": "Отворено уики",
+ "config-profile-no-anon": "Необходимо е създаване на сметка",
+ "config-profile-fishbowl": "Само одобрени редактори",
+ "config-profile-private": "Затворено уики",
+ "config-profile-help": "Уикитата функционират най-добре, когато позволяват на възможно най-много хора да ги редактират.\nВ МедияУики лесно се преглеждат последните промени и се възстановяват поражения от недобронамерени потребители.\n\nВъпреки това мнозина смятат МедияУики за полезен софтуер по различни начини и често е трудно да се убедят всички от предимствата на уики модела.\nЗатова се предоставя възможност за избор.\n\nУикитата от типа <strong>{{int:config-profile-wiki}}</strong> позволяват на всички потребители да редактират, дори и без регистрация.\nУикитата от типа <strong>{{int:config-profile-no-anon}}</strong> позволяват достъп до страниците и редактирането им само след създаване на потребителска сметка.\n\nУики, което е <strong>{{int:config-profile-fishbowl}}</strong> позволява на всички да преглеждат страниците, но само предварително одобрени редактори могат да редактират съдържанието.\nВ <strong>{{int:config-profile-private}}</strong> само предварително одобрени потребители могат да четат и редактират съдържанието.\n\nДетайлно обяснение на конфигурациите на потребителските права е достъпно след инсталацията в [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights Наръчника за потребителски права].",
+ "config-license": "Авторски права и лиценз:",
+ "config-license-none": "Без лиценз",
+ "config-license-cc-by-sa": "Криейтив Комънс Признание-Споделяне на споделеното",
+ "config-license-cc-by": "Криейтив Комънс Признание",
+ "config-license-cc-by-nc-sa": "Криейтив Комънс Признание-Некомерсиално-Споделяне на споделеното",
+ "config-license-cc-0": "Криейтив Комънс Нула (обществено достояние)",
+ "config-license-gfdl": "Лиценз за свободна документация на GNU 1.3 или по-нов",
+ "config-license-pd": "Обществено достояние",
+ "config-license-cc-choose": "Избиране на друг лиценз от Криейтив Комънс",
+ "config-license-help": "Много публични уикита поставят всички приноси под [http://freedomdefined.org/Definition/Bg свободен лиценз].\nТова помага за създаването на усещане за общност и насърчава дългосрочните приноси. \nТова не е необходимо като цяло за частно или корпоративно уики.\n\nАко искате да използвате текстове от Уикипедия, и искате Уикипедия да може да приема текстове, копирани от вашето уики, трябва да изберете лиценз <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nЛицензът за свободна документация на GNU е старият лиценз на съдържанието на Уикипедия.\nТой все още е валиден лиценз, но някои негови условия са трудни за разбиране и правят по-сложни повторното използване и интерпретацията.",
+ "config-email-settings": "Настройки за е-поща",
+ "config-enable-email": "Разрешаване на изходящи е-писма",
+ "config-enable-email-help": "За да работят възможностите за използване на е-поща, необходимо е [http://www.php.net/manual/en/mail.configuration.php настройките за поща на PHP] да бъдат конфигурирани правилно.\nАко няма да се използват услугите за е-поща в уикито, те могат да бъдат изключени тук.",
+ "config-email-user": "Позволяване на потребителите да си изпращат е-писма през уикито",
+ "config-email-user-help": "Позволяване на потребителите да си изпращат е-писма ако са разрешили това в настройките си.",
+ "config-email-usertalk": "Оповестяване при промяна на потребителската беседа",
+ "config-email-usertalk-help": "Позволява на потребителите да получават оповестяване при промяна на беседата им, ако това е разрешено в настройките им.",
+ "config-email-watchlist": "Оповестяване за списъка за наблюдение",
+ "config-email-watchlist-help": "Позволява на потребителите да получават оповестяване за техните наблюдавани страници, ако това е разрешено в настройките им.",
+ "config-email-auth": "Потвърждаване на адреса за електронна поща",
+ "config-email-auth-help": "Ако тази настройка е включена, потребителите трябва да потвърдят адреса си за е-поща чрез препратка, която им се изпраща при настройване или промяна.\nСамо валидните адреси могат да получават е-писма от други потребители или да променят писмата за оповестяване.\nНастройването на това е <strong>препоръчително</strong> за публични уикита заради потенциални злоупотреби с възможностите за електронна поща.",
+ "config-email-sender": "Адрес за обратна връзка:",
+ "config-email-sender-help": "Въвежда се адрес за електронна поща, който ще се използва за обратен адрес при изходящи е-писма.\nТова е адресът, на който ще се получават върнатите и неполучени писма.\nМного е-пощенски сървъри изискват поне домейн името да е валидно.",
+ "config-upload-settings": "Картинки и качване на файлове",
+ "config-upload-enable": "Позволяне качването на файлове",
+ "config-upload-help": "Качването на файлове е възможно да доведе до пробели със сигурността на сървъра.\nПовече информация по темата има в [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security раздела за сигурност] в Наръчника.\n\nЗа позволяване качването на файлове, необходимо е уебсървърът да може да записва в поддиректорията на МедияУики <code>images</code>.\nСлед като това условие е изпълнено, функционалността може да бъде активирана.",
+ "config-upload-deleted": "Директория за изтритите файлове:",
+ "config-upload-deleted-help": "Избиране на директория, в която да се складират изтритите файлове.\nВ най-добрия случай тя не трябва да е достъпна през уеб.",
+ "config-logo": "URL адрес на логото:",
+ "config-logo-help": "Обликът по подразбиране на МедияУики включва място с размери 135х160 пиксела за лого над страничното меню.\nАко има наличен файл с подходящ размер, неговият адрес може да бъде посочен тук.\n\nМоже да се използва <code>$wgStylePath</code> или <code>$wgScriptPath</code> ако логото е относително към тези пътища.\n\nАко не е необходимо лого, полето може да се остави празно.",
+ "config-instantcommons": "Включване на Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] е функционалност, която позволява на уикитата да използват картинки, звуци и друга медия от сайта на Уикимедия [https://commons.wikimedia.org/ Общомедия].\nЗа да е възможно това, МедияУики изисква достъп до Интернет.\n\nПовече информация за тази функционалност, както и инструкции за настройване за други уикита, различни от Общомедия, е налична в [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos наръчника].",
+ "config-cc-error": "Избирането на лиценз на Криейтив Комънс не даде резултат.\nНеобходимо е името на лиценза да бъде въведено ръчно.",
+ "config-cc-again": "Повторно избиране...",
+ "config-cc-not-chosen": "Изберете кой лиценз на Криейтив Комънс желаете и щракнете „proceed“.",
+ "config-advanced-settings": "Разширена конфигурация",
+ "config-cache-options": "Настройки за обектното кеширане:",
+ "config-cache-help": "Обектното кеширане се използва за подобряване на скоростта на МедияУики чрез кеширане на често използваните данни.\nСилно препоръчително е на средните и големите сайтове да включат тази настройка, но малките също могат да се възползват от нея.",
+ "config-cache-none": "Без кеширане (не се премахва от функционалността, но това влияе на скоростта на по-големи уикита)",
+ "config-cache-accel": "PHP обектно кеширане (APC, APCu, XCache или WinCache)",
+ "config-cache-memcached": "Използване на Memcached (изисква допълнителни настройки и конфигуриране)",
+ "config-memcached-servers": "Memcached сървъри:",
+ "config-memcached-help": "Списък с IP адреси за използване за Memcached.\nНеобходимо е да бъдат разделени по един на ред, както и да е посочен порта. Пример:\n127.0.0.1:11211\n192.168.1.25:1234",
+ "config-memcache-needservers": "Избран е Memcached като складиращ тип, но не са посочени сървъри.",
+ "config-memcache-badip": "Беше въведен невалиден IP адрес за Memcached: $1.",
+ "config-memcache-noport": "Не е посочен порт за използване за Memcached сървъра: $1.\nВ случай, че не знаете порта, този по подразбиране е 11211.",
+ "config-memcache-badport": "Портовете за Memcached трябва да бъдат между $1 и $2.",
+ "config-extensions": "Разширения",
+ "config-extensions-help": "Разширенията от списъка по-горе бяха открити в директорията <code>./extensions</code>.\n\nВъзможно е те да изискват допълнително конфигуриране, но сега могат да бъдат включени.",
+ "config-skins": "Облици",
+ "config-skins-help": "По-горе са посочени облиците, които са открити във вашата директория <code>./skins</code>. Необходимо е да изберете поне един, който да се използва по подразбиране.",
+ "config-skins-use-as-default": "Използване на този облик по подразбиране",
+ "config-skins-missing": "Не са открити облици; МедияУики ще използва авариен облик, докато инсталирате подходящ.",
+ "config-skins-must-enable-some": "Трябва да изберете поне един облик.",
+ "config-skins-must-enable-default": "Обликът по-подразбиране трябва да бъде включен.",
+ "config-install-alreadydone": "<strong>Внимание:</strong> Изглежда вече сте инсталирали МедияУики и се опитвате да го инсталирате отново.\nПродължете към следващата страница.",
+ "config-install-begin": "Инсталацията на МедияУики ще започне след натискане на бутона „{{int:config-continue}}“.\nВ случай, че е необходимо да се направят промени, използва се бутона „{{int:config-back}}“.",
+ "config-install-step-done": "готово",
+ "config-install-step-failed": "неуспешно",
+ "config-install-extensions": "Добавяне на разширенията",
+ "config-install-database": "Създаване на базата от данни",
+ "config-install-schema": "Създаване на схема",
+ "config-install-pg-schema-not-exist": "PostgreSQL схемата не съществува",
+ "config-install-pg-schema-failed": "Създаването на таблиците пропадна.\nНеобходимо е потребител „$1“ да има права за писане в схемата „$2“.",
+ "config-install-pg-commit": "Извършване на промени",
+ "config-install-pg-plpgsql": "Проверяване за езика PL/pgSQL",
+ "config-pg-no-plpgsql": "Необходимо е да се инсталира езикът PL/pgSQL в базата от данни $1",
+ "config-pg-no-create-privs": "Посочената сметка за инсталацията не притежава достатъчно права за създаване на сметка.",
+ "config-pg-not-in-role": "Посочената сметка за уеб потребител вече съществува.\nПосочената сметка за инсталация не с права на суперпотребител и не е член на ролите на уеб потребителя и не може да създава обекти, собственост на уеб потребителя.\n\nТекущо МедияУики изисква таблиците да са собственост на уеб потребителя. Необходимо е да се посочи друго потребителско име за уеб или да се натисне „връщане“ и да се избере друг потребител за инсталацията с подходящите права.",
+ "config-install-user": "Създаване на потребител за базата от данни",
+ "config-install-user-alreadyexists": "Потребител „$1“ вече съществува",
+ "config-install-user-create-failed": "Създаването на потребител „$1“ беше неуспешно: $2",
+ "config-install-user-grant-failed": "Предоставянето на права на потребител „$1“ беше неуспешно: $2",
+ "config-install-user-missing": "Посоченият потребител „$1“ не съществува.",
+ "config-install-user-missing-create": "Посоченият потребител „$1“ не съществува.\nАко желаете да го създадете, поставете отметка на „създаване на сметка“.",
+ "config-install-tables": "Създаване на таблиците",
+ "config-install-tables-exist": "<strong>Внимание:</strong> Таблиците за МедияУики изглежда вече съществуват.\nПропускане на създаването им.",
+ "config-install-tables-failed": "<strong>Грешка</strong>: Създаването на таблиците пропадна и върна следната грешка: $1",
+ "config-install-interwiki": "Попълване на таблицата с междууикитата по подразбиране",
+ "config-install-interwiki-list": "Файлът <code>interwiki.list</code> не можа да бъде прочетен.",
+ "config-install-interwiki-exists": "<strong>Внимание:</strong> Таблицата с междууикита изглежда вече съдържа данни.\nПропускане на списъка по подразбиране.",
+ "config-install-stats": "Инициализиране на статистиките",
+ "config-install-keys": "Генериране на тайни ключове",
+ "config-insecure-keys": "<strong>Внимание:</strong> {{PLURAL:$2|Сигурният ключ, създаден по време на инсталацията, не е напълно надежден|Сигурните ключове, създадени по време на инсталацията, не са напълно надеждни}} $1 . Обмислете да {{PLURAL:$2|го|ги}} смените ръчно.",
+ "config-install-updates": "Предотвратяване стартирането на ненужни актуализации",
+ "config-install-updates-failed": "<strong>Грешка:</strong> Вмъкването на обновяващи ключове в таблиците се провали по следната причина: $1",
+ "config-install-sysop": "Създаване на администраторска сметка",
+ "config-install-subscribe-fail": "Невъзможно е абонирането за mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "Не е инсталиран cURL и <code>allow_url_fopen</code> не е налична.",
+ "config-install-mainpage": "Създаване на Началната страница със съдържание по подразбиране",
+ "config-install-mainpage-exists": "Главната страница вече съществува, преминаване напред",
+ "config-install-extension-tables": "Създаване на таблици за включените разширения",
+ "config-install-mainpage-failed": "Вмъкването на Началната страница беше невъзможно: $1",
+ "config-install-done": "<strong>Поздравления!</strong>\nИнсталирането на МедияУики приключи успешно.\n\nИнсталаторът създаде файл <code>LocalSettings.php</code>.\nТой съдържа всичката необходима основна конфигурация на уикито.\n\nНеобходимо е той да бъде изтеглен и поставен в основната директория на уикито (директорията, в която е и index.php). Изтеглянето би трябвало да започне автоматично.\n\nАко изтеглянето не започне автоматично или е било прекратено, файлът може да бъде изтеглен чрез щракване на препратката по-долу:\n\n$3\n\n<strong>Забележка:</strong> Ако това не бъде извършено сега, генерираният конфигурационен файл няма да е достъпен на по-късен етап ако не бъде изтеглен сега или инсталацията приключи без изтеглянето му.\n\nКогато файлът вече е в основната директория, <strong>[$2 уикито ще е достъпно на този адрес]</strong>.",
+ "config-install-done-path": "<strong>Поздравления!</strong>\nИнсталирането на МедияУики приключи успешно.\n\nИнсталаторът създаде файл <code>LocalSettings.php</code>.\nТой съдържа всички ваши настройки.\n\nНеобходимо е той да бъде изтеглен и поставен в <code>$4</code>. Изтеглянето би трябвало да започне автоматично.\n\nАко изтеглянето не започне автоматично или е било прекратено, файлът може да бъде изтеглен чрез щракване на препратката по-долу:\n\n$3\n\n<strong>Забележка:</strong> Ако това не бъде направено сега, генерираният конфигурационен файл няма да е достъпен на по-късен етап ако не бъде изтеглен сега или инсталацията приключи без изтеглянето му.\n\nКогато файлът вече е в основната директория, <strong>[$2 уикито ще е достъпно на този адрес]</strong>.",
+ "config-download-localsettings": "Изтегляне на <code>LocalSettings.php</code>",
+ "config-help": "помощ",
+ "config-help-tooltip": "щракнете за разгръщане",
+ "config-nofile": "Файлът „$1“ не може да бъде открит. Да не е бил изтрит?",
+ "config-extension-link": "Знаете ли, че това уики поддържа [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions разширения]?\n\nМожете да разгледате [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category разширенията по категория] или [https://www.mediawiki.org/wiki/Extension_Matrix Матрицата на разширенията] за пълен списък на разширенията.",
+ "mainpagetext": "<strong>МедияУики беше успешно инсталирано.</strong>",
+ "mainpagedocfooter": "Разгледайте [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents ръководството] за подробна информация относно използването на уики софтуера.\n\n== Първи стъпки ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Настройки за конфигуриране]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ЧЗВ за МедияУики]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Пощенски списък относно нови версии на МедияУики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Локализиране на МедияУики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Научете как да се справяте със спама във вашето уики]"
+}
diff --git a/www/wiki/includes/installer/i18n/bgn.json b/www/wiki/includes/installer/i18n/bgn.json
new file mode 100644
index 00000000..18c503ca
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bgn.json
@@ -0,0 +1,33 @@
+{
+ "@metadata": {
+ "authors": [
+ "Baloch Afghanistan",
+ "Ibrahim khashrowdi"
+ ]
+ },
+ "config-information": "مالومات",
+ "config-your-language": "شمی زبان:",
+ "config-your-language-help": "یک زبانی ئا په ایستیپاده ئی خاتیرا انتخاب بکنیت.",
+ "config-wiki-language": "ویکی ئی زبان:",
+ "config-wiki-language-help": "زبانی ئا انتخاب کنیت که گیشتیر ویکی بئ آیی تا نیویشته ئه به ینت.",
+ "config-back": "→ بیئرگشت",
+ "config-continue": "دیم ره وگ ←",
+ "config-page-language": "زبان",
+ "config-page-welcome": "بئ میڈیاویکی ئا وش آتیت!",
+ "config-page-dbconnect": "وسل بوتین بئ مالوماتین بانکا",
+ "config-page-dbsettings": "مالوماتی بانکی تنزیمات",
+ "config-page-name": "نام",
+ "config-page-options": "تنزیمات",
+ "config-page-install": "لچینتین",
+ "config-page-complete": "کامل!",
+ "config-page-readme": "نا بوان",
+ "config-sidebar": "* [https://www.mediawiki.org میڈیاویکی ئی بُنیادین تاکدیم]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents کار زوروکانی کومک و رهنمایی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents مدیر ئی رهنمایی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ رواجین سوج و سوالان]\n----\n* <doclink href=Readme>نا بووان</doclink>\n* <doclink href=ReleaseNotes>شینک بوته ئین یاداشتان</doclink>\n* <doclink href=Copying>نسخه برداری</doclink>\n* <doclink href=UpgradeDoc>ارتقا</doclink>",
+ "config-env-good": "محیط بررسی بوته.\nشما ئه توانیت میڈیاویکی ئا نصب کنیت.",
+ "config-apc": "[http://www.php.net/apc APC] نصب بوت.",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] نصب بوت.",
+ "config-ns-generic": "پروژه",
+ "config-ns-other-default": "نی ویکی",
+ "config-admin-password": "چیهر گال یا پاسورد:",
+ "config-profile-wiki": "ویکی یی پاچ کورتین",
+ "config-help": "کومک"
+}
diff --git a/www/wiki/includes/installer/i18n/bjn.json b/www/wiki/includes/installer/i18n/bjn.json
new file mode 100644
index 00000000..4a4dfdd2
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bjn.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ezagren",
+ "J Subhi"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki sudah tapasang awan sukses'''.",
+ "mainpagedocfooter": "Carii panjalasan [https://meta.wikimedia.org/wiki/Help:Contents Panduan Pamuruk] gasan mamuruk parangkat lunak wiki\n\n== Gasan bamula ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Daptar konpigurasi setélan]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki nang rancak ditakunakan]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki rilis milis]"
+}
diff --git a/www/wiki/includes/installer/i18n/bn.json b/www/wiki/includes/installer/i18n/bn.json
new file mode 100644
index 00000000..dc423a64
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bn.json
@@ -0,0 +1,148 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bellayet",
+ "Wikitanvir",
+ "Aftab1995",
+ "Tauhid16",
+ "Aftabuzzaman",
+ "Hasive",
+ "আজিজ",
+ "Elias Ahmmad"
+ ]
+ },
+ "config-desc": "মিডিয়াউইকির জন্য ইন্সটলার",
+ "config-title": "মিডিয়াউইকি $1 ইন্সটলেশন",
+ "config-information": "তথ্য",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code> ফাইলটি মুছে ফেলা হয়েছে। এই ইন্সটলেশনটি আরো উন্নত করতে দয়া করে <code>$wgUpgradeKey</code> কোডটি বক্সে দিন। আপনি এটি <code>LocalSettings.php</code> -এ পাবেন।",
+ "config-localsettings-key": "হালনাগাদ কি",
+ "config-localsettings-badkey": "আপনি হালনাগাদের যেই চাবিটি দিয়েছেন তা সঠিক নয়।",
+ "config-upgrade-key-missing": "মিডিয়াউইকির একটি বিদ্যমান ইনস্টলেশন সনাক্ত করা হয়েছে। \nএই ইনস্টলেশন হালনাগাদ করার জন্য, দয়া করে নিম্নলিখিত লাইন আপনার <code>LocalSettings.php</code> -এর নিচে স্থাপন করুন:\n\n$1",
+ "config-session-error": "সেশন শুরুতে ত্রুটি: $1",
+ "config-your-language": "আপনার ভাষা:",
+ "config-your-language-help": "ইন্সটল করা সময় ব্যবহারের জন্য ভাষা নির্বাচন করুন।",
+ "config-wiki-language": "উইকি ভাষা:",
+ "config-back": "← পেছনে",
+ "config-continue": "অব্যাহত →",
+ "config-page-language": "ভাষা",
+ "config-page-welcome": "মিডিয়াউইকিতে স্বাগতম!",
+ "config-page-dbconnect": "ডেটাবেসে সংযোগ দিন",
+ "config-page-upgrade": "ইতিমধ্যেই থাকা ইন্সটলেশন হালনাগাদ করুন",
+ "config-page-dbsettings": "ডেটাবেস সেটিংস",
+ "config-page-name": "নাম",
+ "config-page-options": "অপশন",
+ "config-page-install": "ইন্সটল",
+ "config-page-complete": "সম্পূর্ণ!",
+ "config-page-restart": "পুনরায় ইন্সটল প্রক্রিয়া চালু করুন",
+ "config-page-readme": "এটি পড়ুন",
+ "config-page-releasenotes": "প্রকাশ সংক্রান্ত বার্তা",
+ "config-page-copying": "অনুলেপন",
+ "config-page-upgradedoc": "হালনাগাদকরণ",
+ "config-page-existingwiki": "ইতিমধ্যেই থাকা উইকি",
+ "config-help-restart": "আপনি কী সকল সংরক্ষিত উপাত্ত পরিষ্কার করতে যা আপনি প্রবেশ করিয়েছিলেন এবং ইন্সটালেশন ব্যবস্থা পুনরায় আরম্ভ করতে চান?",
+ "config-restart": "হ্যাঁ, পুনরায় চালু করুন",
+ "config-env-php": "পিএইচপি $1 ইন্সটল করা হয়েছে।",
+ "config-env-hhvm": "HHVM $1 ইনস্টল করা হয়েছে।",
+ "config-memory-raised": "পিএইচপির <code>memory_limit</code> হচ্ছে $1, বৃদ্ধি পেয়ে $2 হয়েছে।",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] ইনস্টল করা হয়েছে",
+ "config-apc": "[http://www.php.net/apc এপিসি] ইনস্টল হয়েছে",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] ইনস্টল করা হয়েছে",
+ "config-diff3-bad": "GNU diff3 পাওয়া যায়নি।",
+ "config-git": "Git সংস্করণের নিয়ন্ত্রণ সফটওয়্যার পাওয়া গেছে: <code>$1</code>।",
+ "config-git-bad": "Git সংস্করণের নিয়ন্ত্রণ সফটওয়্যার পাওয়া যায়নি।",
+ "config-db-type": "ডেটাবেসের ধরন:",
+ "config-db-host": "ডেটাবেজের হোস্ট:",
+ "config-db-wiki-settings": "এই উইকি সনাক্ত করুন",
+ "config-db-name": "ডেটাবেসের নামঃ",
+ "config-db-install-account": "ইন্সটলের জন্য ব্যবহারকারী অ্যাকাউন্ট",
+ "config-db-username": "ডেটাবেজের ব্যবহারকারী নাম:",
+ "config-db-password": "ডেটাবেজের পাসওয়ার্ড:",
+ "config-db-wiki-account": "সাধারণ অভিযানের জন্য ব্যবহারকারী একাউন্ট",
+ "config-db-prefix": "উপাত্তশালা ছক প্রিফিক্স:",
+ "config-db-port": "ডেটাবেজ পোর্ট:",
+ "config-db-schema": "মিডিয়াউইকির স্কিমা",
+ "config-pg-test-error": "উপাত্তশালা $1-এর সাথে সংযোগ দেয়া সম্ভব হয়নি। কারন:$2",
+ "config-sqlite-dir": "এসকিউলাইট ডেটা ডিরেক্টরি:",
+ "config-oracle-def-ts": "পূর্বনির্ধারিত টেবিলস্পেস",
+ "config-oracle-temp-ts": "সাময়কি টেবিলস্পেস:",
+ "config-type-mssql": "মাইক্রোসফট এসকিউএল সার্ভার",
+ "config-dbsupport-postgres": "* MySQL-এর বিকল্প হিসেবে [{{int:version-db-postgres-url}} PostgreSQL] হচ্ছে একটি জনপ্রিয় ওপেন সোর্স ডাটাবেস ব্যবস্থা। ([http://www.php.net/manual/en/pgsql.installation.php PostgreSQL সমর্থনসহ কিভাবে PHP সঙ্কলন করবেন])",
+ "config-header-mysql": "মাইএসকিউএল সেটিংস",
+ "config-header-postgres": "পোস্টগ্রেএসকিউএল সেটিংস",
+ "config-header-sqlite": "এসকিউলাইট সেটিংস",
+ "config-header-oracle": "ওরাকল সেটিংস",
+ "config-invalid-db-type": "ডেটাবেজের ধরন অগ্রহযোগ্য",
+ "config-missing-db-name": "আপনাকে অবশ্যই \"{{int:config-db-name}}\"-এর জন্য একটি মান প্রবেশ করাতে হবে।",
+ "config-missing-db-host": "আপনাকে অবশ্যই \"{{int:config-db-host}}\"-এর জন্য একটি মান প্রবেশ করাতে হবে।",
+ "config-missing-db-server-oracle": "আপনাকে অবশ্যই \"{{int:config-db-host-oracle}}\"-এর জন্য একটি মান প্রবেশ করাতে হবে।",
+ "config-connection-error": "$1।\n\n\nদয়া করে প্রস্তাবকারী, ব্যবহারকারী নাম ও পাসওয়ার্ড দেখুন এবং পুনরায় চেষ্টা করুন।",
+ "config-sqlite-readonly": "ফাইল <code>$1</code> লিখনযোগ্য নয়।",
+ "config-sqlite-cant-create-db": "ডাটাবেজ ফাইল <code>$1</code> তৈরি করা যায়নি।",
+ "config-regenerate": "LocalSettings.php পুনরূত্পাদিত করুন →",
+ "config-mysql-engine": "সংরক্ষণ ইঞ্জিন:",
+ "config-mysql-innodb": "ইনোডিবি",
+ "config-mysql-myisam": "মাইআইএসএএম",
+ "config-mysql-charset": "ডেটাবেজের অক্ষর সেট",
+ "config-mysql-binary": "বাইনারি",
+ "config-mysql-utf8": "ইউটিএফ-৮",
+ "config-mssql-windowsauth": "উইন্ডোজ প্রমাণীকরণ",
+ "config-site-name": "উইকির নাম:",
+ "config-site-name-blank": "একটি সাইটের নাম প্রবেশ করান।",
+ "config-project-namespace": "প্রকল্প নামস্থান:",
+ "config-ns-generic": "প্রকল্প",
+ "config-ns-site-name": "উইকি নামের অনুরুপ: $1",
+ "config-ns-other": "অন্যান্য (নির্দিষ্ট করুন)",
+ "config-ns-other-default": "মাইউইকি",
+ "config-admin-box": "প্রশাসক অ্যাকাউন্ট",
+ "config-admin-name": "আপনার ব্যবহারকারী নাম:",
+ "config-admin-password": "পাসওয়ার্ড:",
+ "config-admin-password-confirm": "পাসওয়ার্ড আবারও প্রবেশ করান:",
+ "config-admin-help": "এখানে আপনার পছন্দের ব্যবহারকারী নাম লিখুন, উদাহরণস্বরূপ \"আজিজ\"। এই নামটি উইকিতে প্রবেশের সময় ব্যবহার করতে হবে।",
+ "config-admin-name-blank": "একটি প্রশাসক ব্যবহারকারী নাম প্রবেশ করান",
+ "config-admin-name-invalid": "উল্লেখিত ব্যবহারকারী নাম \"<nowiki>$1</nowiki>\" অবৈধ। একটি ভিন্ন নাম উল্লেখ করুন।",
+ "config-admin-password-blank": "প্রশাসক অ্যাকাউন্টের জন্য পাসওয়ার্ড প্রবেশ করান।",
+ "config-admin-password-mismatch": "আপনি যে দুটি পাসওয়ার্ড দিয়েছেন তারা পরস্পর মেলেনি।",
+ "config-admin-email": "ইমেইল ঠিকানা:",
+ "config-admin-error-bademail": "আপনি একটি অবৈধ ইমেল ঠিকানা দিয়েছেন।",
+ "config-optional-continue": "আরও প্রশ্ন জিজ্ঞেস করুন।",
+ "config-optional-skip": "আমি ইতিমধ্যেই বিরক্ত হয়ে গেছি, উইকিটি ইন্সটল করো।",
+ "config-profile": "ব্যবহারকারী অধিকার প্রোফাইল:",
+ "config-profile-wiki": "উন্মুক্ত উইকি",
+ "config-profile-no-anon": "অ্যাকাউন্ট তৈরি করা বাধ্যতামূলক",
+ "config-profile-fishbowl": "শুধুমাত্র নির্ধারিত সম্পাদকদের জন্যই",
+ "config-profile-private": "ব্যক্তিগত উইকি",
+ "config-license": "কপিরাইট ও লাইসেন্স:",
+ "config-license-none": "কোনো লাইসেন্স ফুটার নেই",
+ "config-license-cc-by-sa": "ক্রিয়েটিভ কমন্স অ্যাট্রিবিউশন শেয়ার অ্যালাইক",
+ "config-license-cc-by": "ক্রিয়েটিভ কমন্স অ্যাট্রিবিউশন",
+ "config-license-cc-by-nc-sa": "ক্রিয়েটিভ কমন্স অ্যাট্রিবিউশন নন-কমার্শিয়াল শেয়ার অ্যালাইক",
+ "config-license-cc-0": "ক্রিয়েটিভ কমন্স জিরো (পাবলিক ডোমেইন)",
+ "config-license-pd": "পাবলিক ডোমেইন",
+ "config-license-cc-choose": "একটি স্বনির্ধারিত ক্রিয়েটিভ কমন্স লাইসেন্ট নির্বাচন করুন",
+ "config-email-settings": "ই-মেইল সেটিংস",
+ "config-email-user": "ব্যবহারকারী-থেকে-ব্যবহারকারী ই-মেইল সুবিধা সক্রিয় করো",
+ "config-email-usertalk": "ব্যবহারকারী আলাপ পাতার বিজ্ঞপ্তি সক্রিয় করো",
+ "config-upload-settings": "চিত্র এবং ফাইল আপলোড",
+ "config-upload-enable": "ফাইল আপলোড সক্রিয় করো",
+ "config-upload-deleted": "অপসারণকৃত ফাইলের ডিরেক্টরি:",
+ "config-logo": "লোগো ইউআরএল:",
+ "config-advanced-settings": "উন্নত কনফিগারেশন",
+ "config-memcached-servers": "মেমক্যাশেকৃত সার্ভারসমূহ:",
+ "config-extensions": "এক্সটেনশন",
+ "config-skins": "আবরণ",
+ "config-install-step-done": "সম্পন্ন",
+ "config-install-step-failed": "ব্যর্থ",
+ "config-install-extensions": "এক্সটেনশন সহকারে",
+ "config-install-database": "ডেটাবেজ সেটআপ",
+ "config-install-pg-schema-not-exist": "পোস্টগ্রেএসকিউএল স্কিমা খুঁজে পাওয়া যায়নি।",
+ "config-install-user-alreadyexists": "ব্যবহারকারী \"$1\" ইতিমধ্যে বিদ্যমান আছে",
+ "config-install-tables": "টেবিল তৈরি",
+ "config-install-keys": "গোপন কি তৈরি",
+ "config-install-mainpage-exists": "প্রধান পাতা ইতিমধ্যেই বিদ্যমান, এডিয়ে যাওয়া হচ্ছে",
+ "config-help": "সাহায্য",
+ "config-help-tooltip": "প্রসারিত করতে ক্লিক করুন",
+ "config-skins-screenshots": "$1 (স্ক্রিনশট: $2)",
+ "config-screenshot": "স্ক্রিনশট",
+ "mainpagetext": "<strong>মিডিয়াউইকি ইনস্টল করা হয়েছে।</strong>",
+ "mainpagedocfooter": "কীভাবে উইকি সফটওয়্যারটি ব্যবহারকার করবেন, তা জানতে [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents ব্যবহারকারী সহায়িকা] দেখুন।\n\n== কোথা থেকে শুরু করবেন ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings কনফিগারেশন সেটিং তালিকা]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ প্রশ্নোত্তরে মিডিয়াউইকি]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce মিডিয়াউইকি মুক্তির মেইলিং লিস্ট]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources আপনার ভাষার জন্য মিডিয়াউইকি স্থানীয়করণ করুন]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam আপনার উইকিতে স্প্যামের সাথে লড়াই করার উপায় সম্পর্কে জানুন]"
+}
diff --git a/www/wiki/includes/installer/i18n/bpy.json b/www/wiki/includes/installer/i18n/bpy.json
new file mode 100644
index 00000000..bbcb429a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bpy.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''মিডিয়াউইকি হবাবালা ইয়া ইন্সটল ইল.'''",
+ "mainpagedocfooter": "উইকি সফটৱ্যার এহান আতানির বারে দরকার ইলে [https://meta.wikimedia.org/wiki/Help:Contents আতাকুরার গাইড]হানর পাঙলাক নেগা।\n\n== অকরানিহান ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings কনফিগারেশন সেটিংর তালিকাহান]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ মিডিয়া উইকি আঙলাক]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce মিডিয়া উইকির ফঙপার বারে মেইলর তালিকাহান]"
+}
diff --git a/www/wiki/includes/installer/i18n/br.json b/www/wiki/includes/installer/i18n/br.json
new file mode 100644
index 00000000..f38c7695
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/br.json
@@ -0,0 +1,301 @@
+{
+ "@metadata": {
+ "authors": [
+ "Fohanno",
+ "Fulup",
+ "Gwendal",
+ "Y-M D",
+ "아라",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Poellad staliañ MediaWIki",
+ "config-title": "Staliadur MediaWiki $1",
+ "config-information": "Titouroù",
+ "config-localsettings-upgrade": "Kavet ez eus bet ur restr <code>LocalSettings.php</code>.\nEvit hizivaat ar staliadur-se, merkit an talvoud <code>$wgUpgradeKey</code> er voest dindan.\nE gavout a rit e <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Dinoet ez eus bet ur restr <code>LocalSettings.php</code>.\nEvit lakaat ar staliadur-mañ a-live, implijit <code>update.php</code> e plas",
+ "config-localsettings-key": "Alc'hwez hizivaat :",
+ "config-localsettings-badkey": "Direizh eo an alc'hwez hizivaat lakaet ganeoc'h.",
+ "config-upgrade-key-missing": "Kavet ez eus bet ur staliadur kent eus MediaWiki.\nEvit hizivaat ar staliadur-se, ouzhpennit al linenn da-heul e traoñ ho restr <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Diglok e seblant bezañ ar restr <code>LocalSettings.php</code> zo anezhi dija.\nAn argemmenn $1 n'eo ket termenet.\nKemmit <code>LocalSettings.php</code> evit ma vo termenet an argemmenn-se, ha klikit war « {{int:Config-continue}} ».",
+ "config-localsettings-connection-error": "C'hoarvezet ez eus ur fazi en ur gevreañ ouzh an diaz roadennoù oc'h implijout an arventennoù diferet e <code>LocalSettings.php</code>. Reizhit an arventennoù-se hag esaeit en-dro.\n\n$1",
+ "config-session-error": "Fazi e-ser loc'hañ an dalc'h : $1",
+ "config-session-expired": "Kloz eo an dalc'h evit doare.\nKefluniet eo an dalc'hoù evit padout $1.\nKreskiñ ar pad-mañ a c'hallit dre e arventenniñ <code>session.gc_maxlifetime</code> e php.ini.\nAdgrogit gant ar staliadur.",
+ "config-no-session": "Kolle teo bet roadennoù ho talc'h !\nGwiriit ar restr php.ini ha bezit sur emañ staliet <code>session.save_path</code> en ur c'havlec'h a zere.",
+ "config-your-language": "Ho yezh :",
+ "config-your-language-help": "Dibabit ur yezh da implijout e-pad an argerzh staliañ.",
+ "config-wiki-language": "Yezh ar wiki :",
+ "config-wiki-language-help": "Diuzañ ar yezh a vo implijet ar muiañ er wiki.",
+ "config-back": "← Distreiñ",
+ "config-continue": "Kenderc'hel →",
+ "config-page-language": "Yezh",
+ "config-page-welcome": "Degemer mat e MediaWiki !",
+ "config-page-dbconnect": "Kevreañ d'an diaz roadennoù",
+ "config-page-upgrade": "Hizivaat ar staliadur a zo dioutañ",
+ "config-page-dbsettings": "Arventennoù an diaz roadennoù",
+ "config-page-name": "Anv",
+ "config-page-options": "Dibarzhioù",
+ "config-page-install": "Staliañ",
+ "config-page-complete": "Graet !",
+ "config-page-restart": "Adlañsañ ar staliadur",
+ "config-page-readme": "Lennit-me",
+ "config-page-releasenotes": "Notennoù stumm",
+ "config-page-copying": "O eilañ",
+ "config-page-upgradedoc": "O hizivaat",
+ "config-page-existingwiki": "Wiki zo anezhañ dija",
+ "config-help-restart": "Ha c'hoant hoc'h eus da ziverkañ an holl roadennoù hoc'h eus ebarzhet ha da adlañsañ an argerzh staliañ ?",
+ "config-restart": "Ya, adloc'hañ anezhañ",
+ "config-welcome": "=== Gwiriadennoù a denn d'an endro ===\nRekis eo un nebeud gwiriadennoù diazez da welet hag azas eo an endro evit gallout staliañ MediaWiki.\nHo pet soñj merkañ disoc'hoù ar gwiriadennoù-se m'ho pez ezhomm skoazell e-pad ar staliadenn.",
+ "config-copyright": "=== Gwiriañ aozer ha Termenoù implijout ===\n\n$1\n\nUr meziant frank eo ar programm-mañ; gallout a rit skignañ anezhañ ha/pe kemmañ anezhañ dindan termenoù ar GNU Aotre-implijout Foran Hollek evel m'emañ embannet gant Diazezadur ar Meziantoù Frank; pe diouzh stumm 2 an aotre-implijout, pe (evel mar karit) diouzh ne vern pe stumm nevesoc'h.\n\nIngalet eo ar programm gant ar spi e vo talvoudus met n'eus '''tamm gwarant ebet'''; hep zoken gwarant empleg ar '''varc'hadusted''' pe an '''azaster ouzh ur pal bennak'''. Gwelet ar GNU Aotre-Implijout Foran Hollek evit muioc'h a ditouroù.\n\nSañset oc'h bezañ resevet <doclink href=Copying>un eilskrid eus ar GNU Aotre-implijout Foran Hollek</doclink> a-gevret gant ar programm-mañ; ma n'hoc'h eus ket, skrivit da Diazezadur ar Meziantoù Frank/Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, SUA pe [http://www.gnu.org/licenses/old-licenses/gpl-2.0.html lennit anezhañ enlinenn].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki degemer]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Sturlevr an implijerien]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Sturlevr ar verourien]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAG]\n----\n* <doclink href=Readme>Lennit-me</doclink>\n* <doclink href=ReleaseNotes>Notennoù embann</doclink>\n* <doclink href=Copying>Oc'h eilañ</doclink>\n* <doclink href=UpgradeDoc>O hizivaat</doclink>",
+ "config-env-good": "Gwiriet eo bet an endro.\nGallout a rit staliañ MediaWiki.",
+ "config-env-bad": "Gwiriet eo bet an endro.\nNe c'hallit ket staliañ MediaWiki.",
+ "config-env-php": "Staliet eo PHP $1.",
+ "config-env-hhvm": "HHVM $1 zo staliet.",
+ "config-unicode-using-intl": "Oc'h implijout [http://pecl.php.net/intl an astenn PECL intl] evit ar reolata Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Diwallit</strong> : N'haller ket ober gant an [http://pecl.php.net/intl intl astenn PECL] evit merañ reoladur Unicode; distreiñ d'ar stumm gorrek emplementet e PHP-rik.\nMa rit war-dro ul lec'hienn darempredet-stank e vo mat deoc'h lenn un tammig bihan diwar-benn se war [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations reolennadur Unicode].",
+ "config-unicode-update-warning": "'''Diwallit''': ober a ra stumm staliet endalc'her skoueriekaat Unicode gant ur stumm kozh eus [http://site.icu-project.org/ levraoueg meziantoù ar raktres ICU].\nDleout a rafec'h [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations hizivaat] ma seblant deoc'h bezañ pouezus ober gant Unicode.",
+ "config-no-db": "N'eus ket bet gallet kavout ur sturier diazoù roadennoù a zere ! Ret eo deoc'h staliañ ur sturier diazoù roadennoù evit PHP.\nSkoret eo {{PLURAL:$2|ar seurt|ar seurtoù}} diazoù roadennoù da-heul : $1.\n\nMard eo bet kempunet PHP ganeoc'h-c'hwi hoc'h-unan, adkeflugnit-eñ en ur weredekaat un arval diaz roadennoù, da skouer en ur ober gant <code>/configure --with-mysqli</code>.\nM'hoc'h eus staliet PHP adalek ur pakad Debian pe Ubuntu, eo ret deoc'h staliañ, da skouer, ar pakad <code>php5-mysql</code> ivez.",
+ "config-outdated-sqlite": "<strong>Taolit pled :</strong> ober a rit gant SQLite $1, hag a zo izeloc'h eget ar stumm $2 ret bihanañ. Ne vo ket posupl ober gant SQLite.",
+ "config-no-fts3": "'''Diwallit ''': Kempunet eo SQLite hep ar [//sqlite.org/fts3.html vodulenn FTS3]; ne vo ket posupl ober gant an arc'hwelioù klask er staliadur-mañ",
+ "config-pcre-old": "<strong>Fazo groñs :</strong> rekis eo ober gant PCRE $1 pe nevesoc'h.\nLiammet eo ho PHP binarel gant PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Gouzout hiroc'h].",
+ "config-pcre-no-utf8": "'''Fazi groñs ''': evit doare eo bet kempunet modulenn PCRE PHP hep ar skor PCRE_UTF8.\nEzhomm en deus MediaWiki eus UTF-8 evit mont plaen en-dro.",
+ "config-memory-raised": "<code>memory_limit</code> ar PHP zo $1, kemmet e $2.",
+ "config-memory-bad": "'''Diwallit :''' Da $1 emañ arventenn <code>memory_limit</code> PHP.\nRe izel eo moarvat.\nMarteze e c'hwito ar staliadenn !",
+ "config-xcache": "Staliet eo [http://xcache.lighttpd.net/ XCache]",
+ "config-apc": "Staliet eo [http://www.php.net/apc APC]",
+ "config-apcu": "Staliet eo [http://www.php.net/apcu APCu]",
+ "config-wincache": "Staliet eo [http://www.iis.net/download/WinCacheForPhp WinCache]",
+ "config-no-cache-apcu": "<strong>Taolit pled :</strong> N'eus ket bet gallet kavout [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] pe [http://www.iis.net/download/WinCacheForPhp WinCache].\nN'eo ket gweredekaet ar c'hrubuilhañ traezoù.",
+ "config-mod-security": "<strong>Taolit pled :</strong> Gweredekaet eo [http://modsecurity.org/ mod_security]/mod_security2 gant ho servijer web. Ma n'eo ket kfluniet mat e c'hall tegas trubuilhoù da MediaWiki ha meziantoù all a aotre implijerien da ouzhpennañ danvez evel ma karont.\nE kement ha m'eo posupl e tlefe bezañ diweredekaet. A-hend-all, sellit ouzh [http://modsecurity.org/documentation/ mod_security an teuliadur] pe kit e darempred gant skoazell ho herberc'hier m'en em gavit gant fazioù dargouezhek.",
+ "config-diff3-bad": "N'eo ket bet kavet GNU diff3.",
+ "config-git": "Kavet eo bet ar meziant kontrolliñ adstummoù Git : <code>$1</code>.",
+ "config-git-bad": "N'eo ket bet kavet ar meziant kontrolliñ stummoù Git.",
+ "config-imagemagick": "ImageMagick kavet : <code>$1</code>.\nGweredekaet e vo ar bihanaat skeudennoù ma vez gweredekaet ganeoc'h ar pellgargañ restroù.",
+ "config-gd": "Kavet eo bet al levraoueg c'hrafek GD enframmet.\nGweredekaet e vo ar bihanaat skeudennoù ma vez gweredekaet an enporzhiañ restroù.",
+ "config-no-scaling": "N'eus ket bet gallet kavout al levraoueg GD pe ImageMagick.\nDiweredekaet e vo ar bihanaat skeudennoù.",
+ "config-no-uri": "'''Fazi :''' N'eus ket tu da anavezout URI ar skript red.\nStaliadur nullet.",
+ "config-no-cli-uri": "<strong>Diwallit :</strong> N'eus bet spisaet <code>--scriptpath</code> ebet, graet e vo, dre ziouer, gant : <code>$1</code>.",
+ "config-using-server": "Oc'h implijout an anv servijer \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Oc'h implijout ar servijour URL \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "'''Diwallit :'''Bresk eo ho kavlec'h pellgargañ dre ziouer <code>$1</code> rak gallout a ra erounit ne vern pe skript.\nha pa vefe gwiriet gant MediaWiki an holl restroù pellgarget eo erbedet-groñs da [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security serriñ ar breskter surentez-mañ] a-rao gweredekaat ar pellgargañ.",
+ "config-no-cli-uploads-check": "<strong>Diwallit :</strong> N'eo ket bet gwiriet ho kavlec'h enporzhiañ dre ziouer (<code>$1</code>) e-keñver breskted erounezadur skriptoù tidek e-pad staliadur CLI.",
+ "config-brokenlibxml": "Ur meskad stummoù PHP ha libxml2 dreinek a vez implijet gant ho reizhiad. Gallout a ra breinañ ar roadennoù e MediaWiki hag en arloadoù web all.\nHizivait da libxml2 2.7.3 pe nevesoc'h ([https://bugs.php.net/bug.php?id=45996 draen renablet gant PHP]).\nStaliadur c'hwitet.",
+ "config-suhosin-max-value-length": "Staliet eo Suhosin ha bevennin a ra <code>hirder</code> an arventenn GET da $1 okted.\nParzh ResourceLoader Mediawiki a zoujo d'ar vevenn-se met se a zisteray ar varregezh. \nMa c'hallit e tlefec'h spisaat <code>suhosin.get.max_value_length</code> da 1024 pe uheloc'h e <code>php.ini</code>, ha merkañ <code>$wgResourceLoaderMaxQueryLength</code> gant an hevelep talvoud e <code>LocalSettings.php</code>.",
+ "config-db-type": "Doare an diaz roadennoù :",
+ "config-db-host": "Anv implijer an diaz roadennoù :",
+ "config-db-host-help": "M'emañ ho servijer roadennoù war ur servijer disheñvel, merkit amañ anv an ostiz pe ar chomlec'h IP.\n\nMa rit gant un herberc'hiañ kenrannet, e tlefe ho herberc'hier bezañ pourchaset deoc'h an anv ostiz reizh en teulioù titouriñ.\n\nM'emaoc'h o staliañ ur servijer Windows ha ma rit gant MySQL, marteze ne'z aio ket en-dro \"localhost\" evel anv servijer. Ma ne dro ket, klaskit ober gant \"127.0.0.1\" da chomlec'h IP lechel.",
+ "config-db-host-oracle": "TNS an diaz roadennoù :",
+ "config-db-host-oracle-help": "Merkit un [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm anv kevreañ lec'hel] reizh; dleout a ra ur restr tnsnames.ora bezañ hewel e-pad ar staliadur.<br /> Ma rit gant al levraouegoù arval 10g pe nevesoc'h e c'hallit ivez ober gant an hentenn envel [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Anavezout ar wiki-mañ",
+ "config-db-name": "Anv an diaz roadennoù :",
+ "config-db-name-help": "Dibabit un anv evit ho wiki.\nNa lakait ket a esaouennoù ennañ.\n\nMa ri gant un herberc'hiañ kenrannet e vo pourchaset deoc'h un anv diaz roadennoù dibar da vezañ graet gantañ gant ho herberc'hier pe e lezo ac'hanoc'h da grouiñ diazoù roadennoù dre ur banell gontrolliñ.",
+ "config-db-name-oracle": "Brastres diaz roadennoù :",
+ "config-db-account-oracle-warn": "Skoret ez eus tri doare evit staliañ Oracle da v/backend diaz roadennoù :\n\nMar fell deoc'h krouiñ ur gont diaz roadennoù e-ser an argerzh staliañ eo rekis pourchas ur gont gant ur roll SYSDBA evel kont diaz roadennoù evit ar staliañ, ha spisaat an titouroù anaout a fell deoc'h evit ar gont moned ouzh ar web. A-hend-all, e c'hallit krouiñ ar gont moned ouzh ar web gant an dorn ha pourchas hepken ar gont-se (ma'z eus bet ranket diskouez aotreoù ret evit krouiñ traezoù ar brastres) pe pourveziñ div gont disheñvel, unan gant dreistwirioù krouiñ hag eben, gant gwirioù strishaet, evit moned ouzh ar web.\n\nGallout a reer kaout ar skript evit kouiñ ur gont a zo rekis dreistwirioù eviti e kavlec'h \"trezalc'h/oracle/\" ar staliadur-mañ. Na zisoñjit ket e vo diweredekaet holl varregezhioù trezalc'h ar gont dre ziouer ma rit gant ur gont strishaet he gwirioù.",
+ "config-db-install-account": "Kont implijer evit ar staliadur",
+ "config-db-username": "Anv implijer an diaz roadennoù :",
+ "config-db-password": "Ger-tremen an diaz roadennoù :",
+ "config-db-install-username": "Ebarzhit an anv implijer a vo implijet da gevreañ ouzh an diaz roadennoù e-pad an argerzh staliañ.\nN'eo ket anv implijer ar gont MediaWiki, an anv implijer evit ho tiaz roadennoù eo.",
+ "config-db-install-password": "Ebarzhit ar ger-tremen a vo implijet da gevreañ ouzh an diaz roadennoù e-pad an argerzh staliañ.\nN'eo ket ar ger-tremen evit ar gont MediaWiki, ar ger-tremen evit ho tiaz roadennoù eo.",
+ "config-db-install-help": "Merkañ anv an implijer hag ar ger-tremen a vo implijet evit kevreañ ouzh an diaz roadennoù e-pad an argerzh staliañ.",
+ "config-db-account-lock": "Implijout ar memes anv implijer ha ger-tremen e-kerzh oberiadurioù boutin",
+ "config-db-wiki-account": "Kont implijer evit oberiadurioù boutin",
+ "config-db-wiki-help": "Merkañ an anv-implijer hag ar ger-tremen a vo implijet evit kevreañ ouzh an diaz roadennoù e-pad oberiadurioù normal ar wiki.\nMa n'eus ket eus ar gont ha ma'z eus gwirioù a-walc'h gant ar gont staliañ, e vo krouet ar gont implijer-mañ gant al live gwirioù rekis izelañ evit gallout lakaat ar wiki da vont en-dro.",
+ "config-db-prefix": "Rakrann taolennoù an diaz roadennoù :",
+ "config-db-prefix-help": "Mard eo ret deoc'h rannañ un diaz roadennoù gant meur a wiki, pe etre MediaWiki hag un arload benak all e c'hallit dibab ouzhpennañ ur rakger da holl anvioù an taolennoù kuit na vije tabutoù.\nArabat ober gant esaouennoù.\n\nPeurliesañ e vez laosket goullo ar vaezienn-mañ.",
+ "config-mysql-old": "Rekis eo MySQL $1 pe ur stumm nevesoc'h; ober a rit gant $2.",
+ "config-db-port": "Porzh an diaz roadennoù :",
+ "config-db-schema": "Brastres evit MediaWiki",
+ "config-db-schema-help": "Peurliesañ e vo digudenn ar chema-mañ.\nArabat cheñch anezho ma n'hoc'h eus ket ezhomm d'en ober.",
+ "config-pg-test-error": "N'haller ket kevreañ ouzh an diaz-titouroù '''$1''' : $2",
+ "config-sqlite-dir": "Kavlec'h roadennoù SQLite :",
+ "config-sqlite-dir-help": "Stokañ a ra SQLite an holl roadennoù en ur restr nemetken.\n\nE-pad ar staliañ, rankout a ra ar servijer web gallout skrivañ er c'havlec'h pourchaset ganeoc'h.\n\nNe zlefe <strong>ket</strong> bezañ tizhadus dre ar web; setu perak ne lakaomp ket anezhañ el lec'h m'emañ ho restroù PHP.\n\nSkivañ a raio ar stalier ur restr <code>.htaccess</code> war un dro gantañ met ma c'hoarvez ur fazi e c'hallfe unan bennak tapout krog en ho roadennoù.\nKement-se a sell ouzh ar roadennoù implijer (chomlec'hioù postel, gerioù-tremen hachet) hag ouzh an adweladennoù diverket ha takadoù gwarzeet all eus ar wiki.\n\nEn em soñjit ha ne vefe ket gwelloc'h lakaat an diaz roadennoù en un tu bennak all, da skouer e <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Esaouenn stokañ (\"tablespace\") dre ziouer :",
+ "config-oracle-temp-ts": "Esaouenn stokañ (''tablespace'') da c'hortoz :",
+ "config-type-mysql": "MySQL (pe kenglotus)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "Skoret eo ar reizhiadoù diaz titouroù da-heul gant MediaWiki :\n\n$1\n\nMa ne welit ket amañ dindan ar reizhiad diaz titouroù a fell deoc'h ober ganti, heuilhit an titouroù a-us (s.o. al liammoù) evit gweredekaat ar skorañ.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] eo an dibab kentañ evit MediaWiki hag an hini skoret ar gwellañ. Mont a ra MediaWiki en-dro gant [{{int:version-db-mariadb-url}} MariaDB] ha [{{int:version-db-percona-url}} Percona Server] ivez, kenglotus o-daou gant MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Penaos kempunañ PHP gant skor MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] zo anezhi ur reizhiad diaz roadennoù frank a wirioù brudet-mat a c'haller ober gantañ e plas MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Penaos kempunañ PHP gant skor PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] zo anezhi ur reizhiad diaz roadennoù skañv skoret eus ar c'hentañ. ([http://www.php.net/manual/en/pdo.installation.php Penaos kempunañ PHP gant skor SQLite], implijout a ra PDO)",
+ "config-dbsupport-oracle": "* Un embregerezh kenwerzhel diaz roadennoù eo [{{int:version-db-oracle-url}} Oracle]. ([http://www.php.net/manual/en/oci8.installation.php Penaos kempunañ PHP gant skor OCI8])",
+ "config-dbsupport-mssql": "* Un embregerezh kenwerzhel diaz roadennoù evit Windows eo [{{int:version-db-mssql-url}} Microsoft SQL Server]. ([http://www.php.net/manual/en/sqlsrv.installation.php Penaos kempunañ PHP gant skor SQLSRV])",
+ "config-header-mysql": "Arventennoù MySQL",
+ "config-header-postgres": "Arventennoù PostgreSQL",
+ "config-header-sqlite": "Arventennoù SQLite",
+ "config-header-oracle": "Arventennoù Oracle",
+ "config-header-mssql": "Arventennoù Microsoft SQL Server",
+ "config-invalid-db-type": "Direizh eo ar seurt diaz roadennoù",
+ "config-missing-db-name": "Ret eo deoc'h merkañ un dalvoudenn evit \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Ret eo deoc'h merkañ un dalvoudenn evit \"{{int:config-db-host}}\"",
+ "config-missing-db-server-oracle": "Ret eo deoc'h merkañ un dalvoudenn evit \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "Direizh eo anv TNS an diaz roadennoù \"$1\".\nOber gant an neudennad \"TNS Name\" pe c'hoazh gant \"Easy Connect ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Hentennoù envel Oracle]).",
+ "config-invalid-db-name": "Direizh eo anv an diaz titouroù \"$1\".\nOber hepken gant lizherennoù ASCII (a-z, A-Z), sifroù (0-9), arouezennoù islinennañ (_) ha tiredoù (-).",
+ "config-invalid-db-prefix": "Direizh eo rakger an diaz titouroù \"$1\".\nOber hepken gant lizherennoù ASCII (a-z, A-Z), sifroù (0-9), arouezennoù islinennañ (_) ha tiredoù (-).",
+ "config-connection-error": "$1.\n\nGwiriit anv an ostiz, an anv implijer, ar ger-tremen ha klaskit en-dro.",
+ "config-invalid-schema": "Chema direizh evit MediaWiki \"$1\".\nGrit hepken gant lizherennoù ASCII (a-z, A-Z), sifroù (0-9) hag arouezennoù islinennañ (_).",
+ "config-db-sys-create-oracle": "N'anavez ar stalier nemet ar c'hontoù SYSDBA evit krouiñ kontoù nevez.",
+ "config-db-sys-user-exists-oracle": "Bez' ez eus eus ar gont \"$1\" c'hoazh. N'haller ober gant SYSDBA nemet evit krouiñ kontoù nevez !",
+ "config-postgres-old": "Rekis eo PostgreSQL $1 pe ur stumm nevesoc'h; ober a rit gant $2.",
+ "config-mssql-old": "Stumm $1 Microsoft SQL Server, pe unan nevesoc'h, zo rekis. Ganeoc'h emañ ar stumm $2.",
+ "config-sqlite-name-help": "Dibabit un anv dibar d'ho wiki.\nArabat ober gant esaouennoù pe barrennigoù-stagañ.\nImplijet e vo evit ar restr roadennoù SQLite.",
+ "config-sqlite-parent-unwritable-group": "N'haller ket krouiñ ar c'havlec'h roadennoù <code><nowiki>$1</nowiki></code> peogwir n'hall ket ar servijer Web skrivañ war ar c'havlec'h kar <code><nowiki>$2</nowiki></code>.\n\nKavet eo bet gant ar stalier an anv implijer m'eo oberiant ar servijer drezañ. Evit gallout kenderc'hel, lakait ar c'havlec'h <code><nowiki>$3</nowiki></code> da vezañ tizhus evit ar skrivañ.\nWar ur reizhiad Unix/Linux system ober :\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "N'haller ket krouiñ ar c'havlec'h roadennoù <code><nowiki>$1</nowiki></code> peogwir n'hall ket ar servijer Web skrivañ war ar c'havlec'h kar <code><nowiki>$2</nowiki></code>.\n\nN'eo ket bet ar servijer evit kavout anv an implijer ma tro ar servijer. Evit kenderc'hel, lakaat ar c'havlec'h <code><nowiki>$3</nowiki></code> da vezañ tizhus evit ar skrivañ dre vras.\nWar ur reizhiad Unix/Linux merkañ :\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Ur fazi zo bet e-ser krouiñ ar c'havlec'h roadennoù \"$1\".\nGwiriañ al lec'hiadur ha klask en-dro.",
+ "config-sqlite-dir-unwritable": "Dibosupl skrivañ er c'havlec'h \"$1\".\nCheñchit ar aotreoù evit ma c'hallfe ar servijer web skrivañ ennañ ha klaskit en-dro.",
+ "config-sqlite-connection-error": "$1.\n\nGwiriañ ar c'havlec'h roadennoù hag anv an diaz roadennoù a-is ha klaskit en-dro.",
+ "config-sqlite-readonly": "N'haller ket skrivañ er restr <code>$1</code>.",
+ "config-sqlite-cant-create-db": "N'haller ket krouiñ restr an diaz roadennoù <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "N'eo ket kenglotus ar PHP gant FTS3, o lakaat an taolennoù da glotañ gant ur stumm koshoc'h",
+ "config-can-upgrade": "Taolennoù MediaWiki zo en diaz titouroù.\nDa hizivaat anezho da VediaWiki $1, klikañ war '''Kenderc'hel'''.",
+ "config-upgrade-done": "Hizivadenn bet kaset da benn vat.\n\nGallout a rit [$1 kregiñ da implijout ho wiki].\n\nMar fell deoc'h adc'henel ho restr <code>LocalSettings.php</code>, klikit war ar bouton dindan.\n<strong>N'eo ket un dra erbedet</strong> nemet ho pefe kudennoù gant ho wiki.",
+ "config-upgrade-done-no-regenerate": "Hizivadenn kaset da benn.\n\nGallout a rit [$1 kregiñ da implijout ho wiki].",
+ "config-regenerate": "Adgenel LocalSettings.php →",
+ "config-show-table-status": "C'hwitet ar reked <code>SHOW TABLE STATUS</code> !",
+ "config-unknown-collation": "'''Diwallit :''' Emañ an diaz roadennoù o renkañ an traoù diouzh un urzh lizherennek dianav.",
+ "config-db-web-account": "Kont an diaz roadennoù evit ar voned Kenrouedad",
+ "config-db-web-help": "Diuzañ an anv implijer hag ar ger-tremen a vo implijet gant ar servijer web evit kevreañ ouzh ar servijer diaz roadennoù pa vez ar wiki o vont en-dro war ar pemdez.",
+ "config-db-web-account-same": "Ober gant an hevelep kont hag an hini implijet evit ar staliañ",
+ "config-db-web-create": "Krouiñ ar gont ma n'eus ket anezhi c'hoazh",
+ "config-db-web-no-create-privs": "Ar gont ho peus diferet evit ar staliañ n'he deus ket gwirioù a-walc'h evit krouiñ ur gont.\nRet eo d'ar gont diferet amañ bezañ anezhi dija.",
+ "config-mysql-engine": "Lusker stokañ :",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Diwallit :</strong> Diuzet eo bet ganeoc'h MyISAM evel keflusker stokañ evit MySQL, ar pezh n'eo ket erbedet evit implijout gant MediaWiki, rak :\n* a-boan m'eo skoret gantañ ober meur a dra war un dro peogwir eo prennet an taolennoù\n* techetoc'h eo d'ar gwastoù eget kefluskerioù all\n* kod diazez MediaWiki n'eo ket atav embreget MyISAM gantañ evel m'eo dleet\n\nM'eo skoret InnoDB gant ho staliadur MySQL, ez eo kuzuliet c'hwek deoc'h dibab hennezh kentoc'h.\nMa n'eo ket skoret InnoDB gant ho staliadur MySQL, e c'hallfe bezañ poent deoc'h ober un hizivadenn.",
+ "config-mysql-only-myisam-dep": "<strong>Taolit evezh :</strong> MyISAM eo ar c'heflusker stokañ nemetañ a c'haller ober gantañ war ar mekanik-mañ evit MySQL, padal n'eo ket erbedet e implij gant MediaWiki, rak :\n* a-boan ma skor ar c'hevezerezh abalamour m'eo prennet an taolennoù\n* aesoc'h eo e wastañ eget kefluskerioù all\n* n'eo ket atav embreget MyIsam evel ma tlefe bezañ gant kod diazez MediaWiki\n\nN'eo ket skoret InnoDB gant ho staliadur MySQL. Poent eo hizivaat anezhañ marteze.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> eo an dibab gwellañ koulz lavaret atav, kemer a ra e kont ar monedoù kevezus.\n\n<strong>MyISAM</strong> a c'hall bezañ fonnusoc'h evit ar staliadurioù unpost pe ar re lenn hepken.\nDiazoù roadennoù MyISAM zo techet da vezañ gwastet aliesoc'h eget re InnoDB.",
+ "config-mysql-charset": "Strobad arouezennoù an diaz roadennoù :",
+ "config-mysql-binary": "Binarel",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Er <strong>mod binarel</strong> eo stoket an destenn UTF-8 gant MediaWiki en diaz roadennoù e maeziennoù binarel.\nEfedusoc'h eo an dra-se eget mod UTF-8 MySQL; leuskel a ra ac'hanoc'h da implijout skalfad klok arouezennoù Unicode.\n\nEr <strong>mod UTF-8</strong>, MySQL a ouio anavezout ar rikoù arouezennoù emañ ho roadennoù ennañ hag e c'hallo o c'hinnig hag o amdreiñ en doare a zere met ne aotreo ket ac'hanoc'h da stokañ arouezennoù a-us d'ar [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Steuñv liesyezhek diazez] (e saozneg).",
+ "config-mssql-auth": "Seut anaoudadur :",
+ "config-mssql-install-auth": "Diuzañ ar seurt dilesa a vo implijet evit kevreañ ouzh an diaz roadennoù e-pad ar staliañ.\nMa tibabit \"{{int:config-mssql-windowsauth}}\", e vo implijet titouroù anaout an implijer a laka ar servijer da dreiñ.",
+ "config-mssql-web-auth": "Diuzañ ar seurt dilesa a vo implijet gant ar servijer web evit kevreañ ouzh diaz roadennoù ar servijer e-pad oberiadennoù boas ar wiki.\nMa tibabit \"{{int:config-mssql-windowsauth}}\", e vo implijet titouroù anaout an implijer a laka ar servijer da dreiñ.",
+ "config-mssql-sqlauth": "Anaoudadur SQL Server",
+ "config-mssql-windowsauth": "Anaoudadur Windows",
+ "config-site-name": "Anv ar wiki :",
+ "config-site-name-help": "Dont a raio war wel e barrenn ditl ar merdeer hag e meur a lec'h all c'hoazh.",
+ "config-site-name-blank": "Lakait anv ul lec'hienn .",
+ "config-project-namespace": "Esaouenn anv ar raktres :",
+ "config-ns-generic": "Raktres",
+ "config-ns-site-name": "Hevelep anv hag hini ar wiki : $1",
+ "config-ns-other": "All (spisaat)",
+ "config-ns-other-default": "MaWiki",
+ "config-ns-invalid": "Direizh eo an esaouenn anv \"<nowiki>$1</nowiki>\" spisaet.\nMerkit un esaouenn anv disheñvel evit ar raktres.",
+ "config-ns-conflict": "Tabut zo etre an esaouenn anv spisaet \"<nowiki>$1</nowiki>\" hag un esaouenn anv dre ziouer eus MediaWiki.\nSpisait un anv raktres esaouenn anv all.",
+ "config-admin-box": "Kont merour",
+ "config-admin-name": "Hoc'h anv implijer :",
+ "config-admin-password": "Ger-tremen :",
+ "config-admin-password-confirm": "Adskrivañ ar ger-tremen :",
+ "config-admin-help": "Merkit hoc'h anv implijer amañ, da skouer \"Yann Vlog\".\nHemañ eo an anv a implijot evit kevreañ d'ar wiki-mañ.",
+ "config-admin-name-blank": "Lakait anv ur merour.",
+ "config-admin-name-invalid": "Direizh eo an anv implijer spisaet \"<nowiki>$1</nowiki>\".\nMerkit un anv implijer all.",
+ "config-admin-password-blank": "Reiñ ur ger-tremen evit kont ar merour.",
+ "config-admin-password-mismatch": "Ne glot ket ar gerioù-tremen hoc'h eus merket an eil gant egile.",
+ "config-admin-email": "Chomlec'h postel :",
+ "config-admin-email-help": "Merkit ur chomlec'h postel amañ evit gallout resev posteloù a-berzh implijerien all eus ar wiki, adderaouekaat ho ker-tremen ha bezañ kelaouet eus ar c'hemmoù degaset d'ar pajennoù zo en ho roll evezhiañ. Gallout a rit lezel ar vaezienn-mañ goullo.",
+ "config-admin-error-user": "Fazi diabarzh en ur grouiñ ur merer gant an anv \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Fazi diabarzh o lakaat ur ger-tremen evit ar merour « <nowiki>$1</nowiki> » : <pre>$2</pre>",
+ "config-admin-error-bademail": "Ebarzhet hoc'h eus ur chomlec'h postel direizh.",
+ "config-subscribe": "Koumanantit d'ar [https://lists.wikimedia.org/mailman/listinfo/mediawiki-roll kemennoù evit ar stummoù nevez].",
+ "config-pingback": "Rannañ roadennoù diwar-benn ar staliadur-mañ gant diorroerien Mediawiki.",
+ "config-almost-done": "Kazi echu eo !\nGellout a rit tremen ar c'hefluniadur nevez ha staliañ ar wiki war-eeun.",
+ "config-optional-continue": "Sevel muioc'h a goulennoù ouzhin.",
+ "config-optional-skip": "Aet on skuizh, staliañ ar wiki hepken.",
+ "config-profile": "Profil ar gwirioù implijer :",
+ "config-profile-wiki": "Wiki digor",
+ "config-profile-no-anon": "Krouidigezh ur gont ret",
+ "config-profile-fishbowl": "Embanner aotreet hepken",
+ "config-profile-private": "Wiki prevez",
+ "config-license": "Copyright hag aotre-implijout:",
+ "config-license-none": "Aotre ebet en traoñ pajenn",
+ "config-license-cc-by-sa": "Creative Commons Deroadenn Kenrannañ heñvel",
+ "config-license-cc-by": "Creative Commons Deroadenn",
+ "config-license-cc-by-nc-sa": "Creative Commons Deroadenn Angenwerzhel Kenrannañ heñvel",
+ "config-license-cc-0": "Creative Commons Zero (Domani foran)",
+ "config-license-gfdl": "Aotre implijout teuliadur frank GNU 1.3 pe nevesoc'h",
+ "config-license-pd": "Domani foran",
+ "config-license-cc-choose": "Dibabit un aotre-implijout Creative Commons personelaet",
+ "config-email-settings": "Arventennoù ar postel",
+ "config-enable-email": "Gweredekaat ar posteloù a ya kuit",
+ "config-enable-email-help": "Mar fell deoc'h ober gant ar posteler eo ret deoc'h [http://www.php.net/manual/en/mail.configuration.php kefluniañ arventennoù postel PHP] ervat.\nMar ne fell ket deoc'h ober gant ar servij posteloù e c'hall bezañ diweredekaet amañ.",
+ "config-email-user": "Gweredekaat ar posteloù a implijer da implijer",
+ "config-email-user-help": "Aotren a ra an holl implijerien da gas posteloù an eil d'egile mard eo bet gweredekaet an arc'hwel ganto en ho penndibaboù.",
+ "config-email-usertalk": "Gweredekaat kemennadur pajennoù kaozeal an implijerien",
+ "config-email-usertalk-help": "Talvezout a ra d'an implijerien da resev kemennoù ma vez kemmet o fajennoù kaozeal, gant ma vo gweredekaet en o fenndibaboù.",
+ "config-email-watchlist": "Gweredekaat ar c'hemenn listenn evezhiañ",
+ "config-email-watchlist-help": "Talvezout a ra d'an implijerien da resev kemennoù diwar-benn ar pajennoù evezhiet ganto, gant ma vo gweredekaet en o fenndibaboù.",
+ "config-email-auth": "Gweredekaat an dilesadur dre bostel",
+ "config-email-sender": "Chomlec'h postel respont :",
+ "config-email-sender-help": "Merkit ar chomlec'h postel da vezañ implijet da chomlec'h distreiñ ar posteloù a ya er-maez.\nDi e vo kaset ar posteloù distaolet.\nNiverus eo ar servijerioù postel a c'houlenn da nebeutañ un [http://fr.wikipedia.org/wiki/Nom_de_domaine anv domani] reizh.",
+ "config-upload-settings": "Pellgargañ skeudennoù ha restroù",
+ "config-upload-enable": "Gweredekaat ar pellgargañ restroù",
+ "config-upload-deleted": "Kavlec'h evit ar restroù dilamet :",
+ "config-upload-deleted-help": "Dibab ur c'havlec'h da ziellaouiñ ar restroù diverket.\nAr pep gwellañ e vije ma ne vije ket tu d'e dizhout adalek ar Genrouedad.",
+ "config-logo": "URL al logo :",
+ "config-instantcommons": "Gweredekaat ''InstantCommons''",
+ "config-cc-error": "N'eus deuet disoc'h ebet gant dibaber aotreoù-implijout Creative Commons.\nMerkit anv an aotre-implijout gant an dorn.",
+ "config-cc-again": "Dibabit adarre...",
+ "config-cc-not-chosen": "Dibabit an aotre-implijout Creative Commons a fell deoc'h ober gantañ ha klikit war \"proceed\".",
+ "config-advanced-settings": "Kefluniadur araokaet",
+ "config-cache-options": "Arventennoù evit krubuilhañ traezoù :",
+ "config-cache-accel": "Krubuilhañ traezoù PHP (APC, APCu, XCache pe WinCache)",
+ "config-cache-memcached": "Implijout Memcached (en deus ezhomm bezañ staliet ha kefluniet)",
+ "config-memcached-servers": "Servijerioù Memcached :",
+ "config-memcached-help": "Roll ar chomlec'hioù IP da implijout evit Memcached.\nRet eo spisaat unan dre linenn ha spisaat ar porzh da vezañ implijet. Da skouer :\n127.0.0.1:11211\n192.168.1.25:1234",
+ "config-memcache-needservers": "Diuzet hoc'h eus Memcached evel seurt krubuilh met n'hoc'h eus spisaet servijer ebet.",
+ "config-memcache-badip": "Ur chomlec'h IP direizh hoc'h eus lakaet evit Memcached : $1.",
+ "config-memcache-noport": "N'eus ket bet spisaet porzh ebet ganeoc'h evit servijer Memcached : $1.\nMa n'anavezit ket ar porzh, setu an hini dre ziouer : 11211.",
+ "config-memcache-badport": "Niverennoù porzh Memcached a zlefe bezañ etre $1 ha $2.",
+ "config-extensions": "Astennoù",
+ "config-extensions-help": "N'eo ket bet detektet an astennoù rollet a-us en ho kavlec'h <code>./astennoù</code>.\n\nMarteze e vo ezhomm kefluniañ pelloc'h met gallout a rit o gweredekaat bremañ.",
+ "config-skins": "Gwiskadurioù",
+ "config-skins-help": "Kavet eo bet ar gwiskadurioù renablet a-us en ho kavlec'h <code>./skins</code>. Ret eo deoc'h gweredekaat unan da nebeutañ, ha dibab an hini dre ziouer.",
+ "config-skins-use-as-default": "Implijout ar gwiskadur-mañ dre ziouer",
+ "config-skins-missing": "N'eus bet kavet gwiskadur ebet : ober a raio MediaWiki gant unan dre ziouer betek ma vo staliet reoù a zegouezh.",
+ "config-skins-must-enable-some": "Ret eo deoc'h dibab da nebautañ ur gwiskadur da weredekaat.",
+ "config-skins-must-enable-default": "Ar gwiskadur dre ziouer dibabet a rank bezañ gweredekaet.",
+ "config-install-alreadydone": "'''Diwallit''': Staliet hoc'h eus MediaWiki dija war a seblant hag emaoc'h o klask e staliañ c'hoazh.\nKit d'ar bajenn war-lerc'h, mar plij.",
+ "config-install-begin": "Pa vo bet pouezet ganeoc'h war \"{{int:config-continue}}\" e krogo staliadur MediaWiki.\nPouezit war \"{{int:config-back}}\" mar fell deoc'h cheñch tra pe dra.",
+ "config-install-step-done": "graet",
+ "config-install-step-failed": "c'hwitet",
+ "config-install-extensions": "En ur gontañ an astennoù",
+ "config-install-database": "Krouiñ an diaz roadennoù",
+ "config-install-schema": "O krouiñ ar chema",
+ "config-install-pg-schema-not-exist": "N'eus ket eus chema PostgreSQL.",
+ "config-install-pg-schema-failed": "C'hwitet eo krouidigezh an taolennoù.\nGwiriit hag-eñ e c'hall an implijer « $1 » skrivañ er brastres « $2 ».",
+ "config-install-pg-commit": "O wiriekaat ar c'hemmoù",
+ "config-install-pg-plpgsql": "O wiriañ ar yezh PL/pgSQL",
+ "config-pg-no-plpgsql": "Ret eo deoc'h staliañ ar yezh PL/pgSQL en diaz roadennoù $1",
+ "config-pg-no-create-privs": "N'eus ket gwirioù a-walc'h gant ar gont hoc'h eus merket evit ar staliadur evit gallout krouiñ ur gont.",
+ "config-install-user": "O krouiñ an diaz roadennoù implijer",
+ "config-install-user-alreadyexists": "An implijer \"$1\" zo anezhañ dija",
+ "config-install-user-create-failed": "Fazi e-ser krouiñ an implijer \"$1\" : $2",
+ "config-install-user-grant-failed": "N'eus ket bet gallet reiñ an aotre d'an implijer \"$1\" : $2",
+ "config-install-user-missing": "N'eus ket eus an implijer \"$1\"",
+ "config-install-user-missing-create": "N'eus ket eus an implijer \"$1\".\nMa fell deoc'h krouiñ anezhañ, klikit war ar voest \"krouiñ ur gont\" amañ dindan.",
+ "config-install-tables": "Krouiñ taolennoù",
+ "config-install-tables-exist": "<strong>Diwallit :</strong> An taolennoù MediaWiki zo anezho dija war a seblant.\nN'int ket bet adkrouet.",
+ "config-install-tables-failed": "'''Fazi :''' c'hwitet eo krouidigezh an daolenn gant ar fazi-mañ : $1",
+ "config-install-interwiki": "O leuniañ dre ziouer an daolenn etrewiki",
+ "config-install-interwiki-list": "Ne c'haller ket kavout ar restr <code>interwiki.list</code>.",
+ "config-install-stats": "O sevel ar stadegoù",
+ "config-install-keys": "Genel an alc'hwezioù kuzh",
+ "config-install-updates": "Mirout da lakaat hizivadennoù diezhomm da vont en-dro",
+ "config-install-sysop": "Krouidigezh kont ar merour",
+ "config-install-subscribe-fail": "N'haller ket koumanantiñ da mediawiki-announce : $1",
+ "config-install-subscribe-notpossible": "cURL n'eo ket staliet ha ne c'haller ket ober gant <code>allow_url_fopen</code>.",
+ "config-install-mainpage": "O krouiñ ar bajenn bennañ gant un endalc'had dre ziouer",
+ "config-install-mainpage-exists": "Bez' ez eus eus ar bajenn bennañ c'hoazh, lezel a-gostez",
+ "config-install-extension-tables": "O krouiñ taolennoù evit an astennoù gweredekaet",
+ "config-install-mainpage-failed": "Ne c'haller ket ensoc'hañ ar bajenn bennañ: $1",
+ "config-download-localsettings": "Pellgargañ <code>LocalSettings.php</code>",
+ "config-help": "skoazell",
+ "config-help-tooltip": "klikañ evit astenn",
+ "config-nofile": "N'eus ket bet gallet kavout ar restr \"$1\". Daoust ha dilamet eo bet ?",
+ "mainpagetext": "<strong>Staliet eo bet MediaWiki.</strong>",
+ "mainpagedocfooter": "Sellit ouzh [https://meta.wikimedia.org/wiki/Help:Contents Sturlevr an implijerien] evit gouzout hiroc'h war an doare da implijout ar meziant wiki.\n\n== Kregiñ ganti ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Roll an arventennoù kefluniañ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAG MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Roll ar c'haozeadennoù diwar-benn dasparzhoù MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lec'hiañ MediaWiki en ho yezh"
+}
diff --git a/www/wiki/includes/installer/i18n/bs.json b/www/wiki/includes/installer/i18n/bs.json
new file mode 100644
index 00000000..66139728
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bs.json
@@ -0,0 +1,185 @@
+{
+ "@metadata": {
+ "authors": [
+ "CERminator",
+ "Palapa",
+ "Emir Mujadzic",
+ "Semso98",
+ "Srdjan m"
+ ]
+ },
+ "config-desc": "Instalacija za MediaWiki",
+ "config-title": "Instalacija MediaWikija $1",
+ "config-information": "Informacija",
+ "config-localsettings-upgrade": "Otkrivena je datoteka <code>LocalSettings.php</code>.\nDa biste nadogradili instalaciju, upišite vrijednost od <code>$wgUpgradeKey</code> u okvir ispod.\nNaći ćete ga u <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Otkrivena je datoteka <code>LocalSettings.php</code>.\nDa biste nadogradili instalaciju, pokrenite <code>update.php</code> umjesto toga",
+ "config-localsettings-key": "Ključ za nadogradnju:",
+ "config-localsettings-badkey": "Ključ za nadogradnju koji ste naveli je pogrešan.",
+ "config-upgrade-key-missing": "Pronađena je postojeća instalacija MediaWikija.\nDa biste je nadogradili, umetnite sljedeći red na dno datoteke <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Postojeća datoteka <code>LocalSettings.php</code> djeluje nepotpuna.\nNije postavljena varijabla $1.\nIzmijenite datoteku <code>LocalSettings.php</code> tako što ćete varijabli zadati vrijednost, pa kliknite na \"{{int:Config-continue}}\".",
+ "config-session-error": "Greška pri pokretanju sesije: $1",
+ "config-no-session": "Vaši podaci o sesiji su izgubljeni!\nProvjerite je li <code>session.save_path</code> postavljen na pravilni direktorijum u php.ini.",
+ "config-your-language": "Vaš jezik:",
+ "config-your-language-help": "Izaberite jezik koji želite koristiti tokom instalacije.",
+ "config-wiki-language": "Jezik wikija:",
+ "config-wiki-language-help": "Izaberite jezik na kojem će biti sadržaj wikija.",
+ "config-back": "← Nazad",
+ "config-continue": "Nastavi →",
+ "config-page-language": "Jezik",
+ "config-page-welcome": "Dobro došli na MediaWiki!",
+ "config-page-dbconnect": "Povezivanje s bazom podataka",
+ "config-page-upgrade": "Nadogradnja postojeće instalacije",
+ "config-page-dbsettings": "Postavke baze podataka",
+ "config-page-name": "Naziv",
+ "config-page-options": "Opcije",
+ "config-page-install": "Instaliraj",
+ "config-page-complete": "Završeno!",
+ "config-page-restart": "Ponovno pokretanje instalacije",
+ "config-page-readme": "Pročitaj me",
+ "config-page-releasenotes": "Napomene o izdanju",
+ "config-page-copying": "Kopiranje",
+ "config-page-upgradedoc": "Nadogradnja",
+ "config-page-existingwiki": "Postojeća wiki",
+ "config-help-restart": "Želite li obrisati sve sačuvane podatke koje ste unijeli i ponovo pokrenuti instalaciju?",
+ "config-restart": "Da, pokreni ponovo",
+ "config-sidebar": "* [https://www.mediawiki.org Početna strana MediaWikija]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Vodič za korisnike]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Vodič za administratore]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ČPP]\n----\n* <doclink href=Readme>Pročitaj me</doclink>\n* <doclink href=ReleaseNotes>Napomene o izdanju</doclink>\n* <doclink href=Copying>Kopiranje</doclink>\n* <doclink href=UpgradeDoc>Nadogradnja</doclink>",
+ "config-env-good": "Okruženje je provjereno.\nMožete instalirati MediaWiki.",
+ "config-env-bad": "Okruženje je provjereno.\nNe možete instalirati MediaWiki.",
+ "config-env-php": "PHP $1 je instaliran.",
+ "config-env-hhvm": "HHVM $1 je instaliran.",
+ "config-no-db": "Ne mogu pronaći pogodan upravljački program za bazu podataka! Morate ga instalirati za PHP-bazu.\n{{PLURAL:$2|Podržana je sljedeća vrsta|Podržane su sljedeće vrste}} baze podataka: $1.\n\nAko se sami kompajlirali PHP, omogućite klijent baze podataka u postavkama koristeći, naprimjer, <code>./configure --with-mysqli</code>.\nAko ste instalirali PHP iz paketa za Debian ili Ubuntu, onda također morate instalirati, naprimjer, paket <code>php5-mysql</code>.",
+ "config-memory-raised": "<code>memory_limit</code> za PHP iznosi $1, povišen na $2.",
+ "config-memory-bad": "<strong>Upozorenje:</strong> <code>memory_limit</code> za PHP iznosi $1.\nOvo je vjerovatno premalo.\nInstalacija možda neće uspjeti!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] je instaliran",
+ "config-apc": "[http://www.php.net/apc APC] je instaliran",
+ "config-apcu": "[http://www.php.net/apcu APCu] je instaliran",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] je instaliran",
+ "config-diff3-bad": "GNU diff3 nije pronađen.",
+ "config-git": "Pronađen je Git program za kontrolu verzija: <code>$1</code>.",
+ "config-git-bad": "Nije pronađen Git program za kontrolu verzija.",
+ "config-imagemagick": "Pronađen je ImageMagick: <code>$1</code>.\nAko omogućite postavljanje, bit će omogućena minijaturizacija slika.",
+ "config-gd": "Utvrđeno je da je ugrađena grafička biblioteka GD.\nAko omogućite postavljanje, bit će omogućena minijaturizacija slika.",
+ "config-no-scaling": "Ne mogu pronaći biblioteku GD niti ImageMagick.\nMinijaturizacija slika bit će onemogućena.",
+ "config-no-uri": "<strong>Greška:</strong> Ne mogu utvrditi trenutni URI.\nInstalacija je prekinuta.",
+ "config-db-type": "Vrsta baze podataka:",
+ "config-db-host": "Domaćin baze podataka:",
+ "config-db-wiki-settings": "Identificiraj ovu wiki",
+ "config-db-name": "Naziv baze podataka:",
+ "config-db-name-oracle": "Šema baze podataka:",
+ "config-db-install-account": "Korisnički račun za instalaciju",
+ "config-db-username": "Korisničko ime baze podataka:",
+ "config-db-password": "Lozinka baze podataka:",
+ "config-db-wiki-account": "Korisnički račun za redovan rad",
+ "config-db-prefix": "Prefiks tabele u bazi podataka:",
+ "config-mysql-old": "Zahtijeva se MySQL $1 ili noviji. Vi imate $2.",
+ "config-db-port": "Port baze podataka:",
+ "config-db-schema": "Šema za MediaWiki:",
+ "config-pg-test-error": "Ne mogu se povezati na bazu podataka <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Folder za SQLite-podatke:",
+ "config-oracle-def-ts": "Predodređeni tabelarni prostor:",
+ "config-oracle-temp-ts": "Privremeni tabelarni prostor:",
+ "config-type-mysql": "MySQL (ili kompaktibilan)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "Postavke MySQL-a",
+ "config-header-postgres": "Postavke PostgreSQL-a",
+ "config-header-sqlite": "Postavke SQLite-a",
+ "config-header-oracle": "Postavke Oraclea",
+ "config-header-mssql": "Postavke Microsoft SQL Servera",
+ "config-invalid-db-type": "Nevažeća vrsta baze podataka.",
+ "config-missing-db-name": "Morate unijeti vrijednost za \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Morate unijeti vrijednost za \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Morate unijeti vrijednost za \"{{int:config-db-host-oracle}}\".",
+ "config-db-sys-create-oracle": "Program za instalaciju podržava samo upotrebu SYSDBA-računa za pravljenje novih računa.",
+ "config-postgres-old": "Zahtijeva se PostgreSQL $1 ili noviji. Vi imate $2.",
+ "config-mssql-old": "Zahtijeva se Microsoft SQL Server $1 ili noviji. Vi imate $2.",
+ "config-sqlite-name-help": "Izaberite ime koje će predstavljati Vaš wiki.\nNemojte koristiti razmake i crte.\nOvo će se koristiti za ime datoteke SQLite-podataka.",
+ "config-sqlite-readonly": "Datoteka <code>$1</code> nije zapisiva.",
+ "config-sqlite-cant-create-db": "Ne mogu napraviti datoteku <code>$1</code> za bazu podataka.",
+ "config-sqlite-fts3-downgrade": "PHP ne podržava FTS3, poništavam nadogradnju tabela.",
+ "config-upgrade-done": "Nadogradnja završena.\n\nSada možete [$1 početi koristiti Vaš wiki].\n\nAko želite regenerirati Vašu datoteku <code>LocalSettings.php</code>, kliknite na dugme ispod.\nOvo <strong>nije preporučeno</strong> osim ako nemate problema s Vašim wikijem.",
+ "config-upgrade-done-no-regenerate": "Nadogradnja završena.\n\nSad možete [$1 početi da koristite svoj wiki].",
+ "config-regenerate": "Regeneriraj LocalSettings.php →",
+ "config-unknown-collation": "<strong>Upozorenje:</strong> Baza podataka koristi nepoznatu kolaciju.",
+ "config-db-web-account": "Račun baze podataka za mrežni pristup",
+ "config-db-web-account-same": "Koristi isti račun kao i za instalaciju",
+ "config-db-web-create": "Napravi račun ako već ne postoji",
+ "config-mysql-engine": "Skladišni pogon:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Binarni",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Vrsta autentifikacije:",
+ "config-site-name": "Ime wikija:",
+ "config-site-name-blank": "Upišite ime sajta.",
+ "config-project-namespace": "Imenski prostor projekta:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Isti kao ime wikija: $1",
+ "config-ns-other": "Drugo (navedite)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "Administratorski račun",
+ "config-admin-name": "Vaše korisničko ime:",
+ "config-admin-password": "Lozinka:",
+ "config-admin-password-confirm": "Ponovo unesite lozinku:",
+ "config-admin-help": "Ovdje upišite željeno korisničko ime; naprimjer, \"Petar Petrović\".\nOvo ćete ime koristiti za prijavu na wiki.",
+ "config-admin-name-blank": "Upišite administratorsko korisničko ime.",
+ "config-admin-name-invalid": "Navedeno korisničko ime \"<nowiki>$1</nowiki>\" nije ispravno.\nNavedite drugo.",
+ "config-admin-password-blank": "Upišite lozinku za administratorski račun.",
+ "config-admin-password-mismatch": "Lozinke koje ste upisali se ne poklapaju.",
+ "config-admin-email": "Adresa e-pošte:",
+ "config-admin-error-bademail": "Upisali ste neispravnu adresu e-pošte.",
+ "config-pingback": "Podijeli podatke o instalaciji s programerima MediaWikija.",
+ "config-optional-continue": "Postavi mi još pitanja.",
+ "config-optional-skip": "Već mi je dosadilo, daj samo instaliraj wiki.",
+ "config-profile": "Profil korisničkih prava:",
+ "config-profile-wiki": "Otvoren wiki",
+ "config-profile-no-anon": "Potrebno je napraviti račun",
+ "config-profile-fishbowl": "Samo ovlašteni korisnici",
+ "config-profile-private": "Privatni wiki",
+ "config-license": "Autorska prava i licenca:",
+ "config-license-none": "Bez podnožja za licencu",
+ "config-license-pd": "Javno vlasništvo",
+ "config-email-settings": "Postavke e-pošte",
+ "config-enable-email": "Omogući odlaznu e-poštu",
+ "config-upload-enable": "Omogući postavljanje datoteka",
+ "config-upload-deleted": "Folder za obrisane datoteke:",
+ "config-logo": "URL logotipa:",
+ "config-instantcommons": "Omogući Instant Commons",
+ "config-cc-again": "Izaberite ponovo...",
+ "config-advanced-settings": "Napredna konfiguracija",
+ "config-extensions": "Proširenja",
+ "config-skins": "Teme",
+ "config-skins-use-as-default": "Koristi temu kao predodređenu",
+ "config-skins-missing": "Nije pronađena nijedna tema. MediaWiki će koristiti rezervnu temu dok ne instalirate druge.",
+ "config-skins-must-enable-some": "Morate izabrati barem jednu temu.",
+ "config-skins-must-enable-default": "Tema koju ste izabrali za predodređenu mora se omogućiti.",
+ "config-install-alreadydone": "<strong>Upozorenje:</strong> Izgleda da već imate instaliran MediaWiki i da ga ponovo pokušavate instalirati.\nIdite na sljedeću stranicu.",
+ "config-install-step-done": "završeno",
+ "config-install-step-failed": "neuspješno",
+ "config-install-extensions": "Uključujem proširenja",
+ "config-install-database": "Postavljam bazu podataka",
+ "config-install-schema": "Pravim šemu",
+ "config-install-pg-plpgsql": "Provjeravam jezik PL/pgSQL",
+ "config-pg-no-plpgsql": "Morate instalirati jezik PL/pgSQL u bazu podataka $1",
+ "config-install-user": "Pravim korisnika baze podataka",
+ "config-install-user-alreadyexists": "Korisnik \"$1\" već postoji",
+ "config-install-user-create-failed": "Pravljenje korisnika \"$1\" nije uspjelo: $2",
+ "config-install-user-grant-failed": "Dodjeljivanje dozvola korisniku \"$1\" nije uspjelo: $2",
+ "config-install-user-missing": "Navedeni korisnik \"$1\" ne postoji.",
+ "config-install-tables": "Pravim tabele",
+ "config-install-interwiki": "Popunjavam predodređenu međuprojektnu tabelu",
+ "config-install-stats": "Pokrećem statistiku",
+ "config-install-keys": "Stvaram tajne ključeve",
+ "config-install-updates": "Spriječi pokretanje nepotrebnih ažuriranja",
+ "config-install-sysop": "Pravim administratorski korisnički račun",
+ "config-install-subscribe-fail": "Ne mogu Vas pretplatiti na mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL nije instaliran, a <code>allow_url_fopen</code> nije dostupno.",
+ "config-install-mainpage": "Pravim početnu stranicu sa standardnim sadržajem",
+ "config-install-mainpage-exists": "Početna strana već postoji. Prelazim na sljedeće.",
+ "config-install-mainpage-failed": "Ne mogu umetnuti početnu stranu: $1",
+ "config-download-localsettings": "Preuzmi <code>LocalSettings.php</code>",
+ "config-help": "pomoć",
+ "config-help-tooltip": "kliknite da proširite",
+ "config-nofile": "Datoteka \"$1\" nije pronađena. Da nije obrisana?",
+ "mainpagetext": "<strong>MediaWiki je instaliran.</strong>",
+ "mainpagedocfooter": "Pogledajte [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents korisnički vodič] za uputstva o upotrebi wiki softvera.\n\n== Prvi koraci ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Spisak postavki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Često postavljana pitanja o MediaWikiju]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Dopisna lista o izdanjima MediaWikija]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokalizirajte MediaWiki za svoj jezik]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Naučite kako se boriti protiv neželjenog sadržaja na svom wikiju]"
+}
diff --git a/www/wiki/includes/installer/i18n/bto.json b/www/wiki/includes/installer/i18n/bto.json
new file mode 100644
index 00000000..468bcebd
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/bto.json
@@ -0,0 +1,65 @@
+{
+ "@metadata": {
+ "authors": [
+ "Filipinayzd"
+ ]
+ },
+ "config-title": "Pabutang ka MediaWiki $1",
+ "config-information": "Impormasyon",
+ "config-your-language": "A kanimong sarita:",
+ "config-wiki-language": "Sarita ka Wiki:",
+ "config-back": "← Bumalik",
+ "config-continue": "Magpadagos →",
+ "config-page-language": "Sarita",
+ "config-page-welcome": "Dagos sa MediaWiki!",
+ "config-page-name": "Ngaran",
+ "config-page-options": "Mga pipilian",
+ "config-page-install": "Ikabit",
+ "config-page-complete": "Tapos!",
+ "config-page-readme": "Basahon ako",
+ "config-page-copying": "Kinokopya",
+ "config-restart": "Amo, uliton adi",
+ "config-diff3-bad": "Diri nataurakan a GNU diff3.",
+ "config-db-type": "Klase ka database:",
+ "config-db-host": "Host ka database:",
+ "config-db-host-oracle": "Database ka TNS:",
+ "config-db-wiki-settings": "Mibdiron adin wiki",
+ "config-db-name": "Ngaran ka database:",
+ "config-charset-mysql5-binary": "MySQL 4.1/5.0 binary",
+ "config-charset-mysql5": "MySQL 4.1/5.0 UTF-8",
+ "config-charset-mysql4": "MySQL 4.0 backwards-compatible UTF-8",
+ "config-db-port": "Port ka database:",
+ "config-db-schema": "Skema para sa MediaWiki:",
+ "config-sqlite-dir": "Direktoryo ka data sa SQLite:",
+ "config-oracle-def-ts": "Dating tablescape:",
+ "config-oracle-temp-ts": "Temporaryong tablescape:",
+ "config-type-mysql": "MySQL (o compatible)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "MySQL settings",
+ "config-header-postgres": "PostgreSQL settings",
+ "config-header-sqlite": "SQLite settings",
+ "config-header-oracle": "Oracle settings",
+ "config-header-mssql": "Microsoft SQL Server settings",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Binary",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Ngaran ka wiki",
+ "config-site-name-blank": "Ibutang a ngaran ka site.",
+ "config-project-namespace": "Bibutangan ka proyekto:",
+ "config-ns-generic": "Proyekto",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-password": "Password:",
+ "config-admin-password-confirm": "Password ulit:",
+ "config-admin-email": "Email address:",
+ "config-profile-wiki": "Bukas na wiki",
+ "config-profile-private": "Pribadong wiki",
+ "config-license-pd": "Pampublikong Domain",
+ "config-email-sender": "Pabalik na email adres:",
+ "config-logo": "URL ko logo:",
+ "config-cc-again": "Pumili dayday...",
+ "config-install-step-done": "tapus na",
+ "config-install-user-alreadyexists": "Agko na ka user na \"$1\"",
+ "config-install-user-create-failed": "Sala a ginigibong user na \"$1\": $2",
+ "config-help": "tabang"
+}
diff --git a/www/wiki/includes/installer/i18n/ca.json b/www/wiki/includes/installer/i18n/ca.json
new file mode 100644
index 00000000..c86b8244
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ca.json
@@ -0,0 +1,262 @@
+{
+ "@metadata": {
+ "authors": [
+ "Pitort",
+ "පසිඳු කාවින්ද",
+ "Kippelboy",
+ "Toniher",
+ "Fitoschido",
+ "Jmarchn",
+ "Alvaro Vidal-Abarca",
+ "ESM",
+ "Xavier Dengra",
+ "Jaumeortola",
+ "Macofe"
+ ]
+ },
+ "config-desc": "L'instal·lador del MediaWiki",
+ "config-title": "Instal·lació del MediaWiki $1",
+ "config-information": "Informació",
+ "config-localsettings-upgrade": "S'ha detectat un fitxer <code>LocalSettings.php</code>. \nPer tal d'actualitzar la instal·lació, introduïu el valor de <code>$wgUpgradeKey</code> en el quadre a continuació. El trobareu a <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "S'ha detectat un fitxer <code>LocalSettings.php</code>.\nPer a actualitzar la instal·lació, executeu <code>update.php</code>.",
+ "config-localsettings-key": "Clau d'actualització:",
+ "config-localsettings-badkey": "La clau d'actualització que heu proporcionat no és correcta.",
+ "config-upgrade-key-missing": "S'ha detectat una instal·lació ja existent del MediaWiki.\nPer actualitzar-la, poseu la línia següent al final de <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "El <code>LocalSettings.php</code> que hi ha sembla incomplet.\nLa variable $1 no està definida.\nCanvieu <code>LocalSettings.php</code> perquè la variable estigui definida i feu clic a «{{int:Config-continue}}».",
+ "config-localsettings-connection-error": "S'ha trobat un error en connectar-se amb la base de dades fent servir els paràmetres especificats a <code>LocalSettings.php</code>. Corregiu aquests paràmetres i torneu-ho a provar.\n\n$1",
+ "config-session-error": "Error en iniciar la sessió: $1",
+ "config-session-expired": "Les dades de la vostra sessió sembla que han caducat.\nLes sessions estan configurades per a un temps de $1.\nPodeu augmentar-lo posant <code>session.gc_maxlifetime</code> en php.ini.\nReprengueu el procés d'instal·lació.",
+ "config-no-session": "Les dades de la vostra sessió s'han perdut!\nComprovar el vostre php.ini i assegureu-vos que <code>session.save_path</code> està assignat a un directori apropiat.",
+ "config-your-language": "La vostra llengua:",
+ "config-your-language-help": "Seleccioneu la llengua que s'utilitzarà durant el procés d'instal·lació.",
+ "config-wiki-language": "Llengua del wiki:",
+ "config-wiki-language-help": "Seleccioneu la llengua principal del wiki.",
+ "config-back": "← Enrere",
+ "config-continue": "Continua →",
+ "config-page-language": "Llengua",
+ "config-page-welcome": "Us donem la benvinguda al MediaWiki!",
+ "config-page-dbconnect": "Connecta a la base de dades",
+ "config-page-upgrade": "Actualitza una instal·lació ja existent",
+ "config-page-dbsettings": "Paràmetres de la base de dades",
+ "config-page-name": "Nom",
+ "config-page-options": "Opcions",
+ "config-page-install": "Instal·la",
+ "config-page-complete": "S'ha completat!",
+ "config-page-restart": "Reinicia la instal·lació",
+ "config-page-readme": "Llegeix-me",
+ "config-page-releasenotes": "Notes de la versió",
+ "config-page-copying": "S'està copiant",
+ "config-page-upgradedoc": "S'està actualitzant",
+ "config-page-existingwiki": "Wiki ja existent",
+ "config-help-restart": "Voleu esborrar totes les dades que heu introduït i tornar a començar el procés d'instal·lació?",
+ "config-restart": "Sí, torna a començar",
+ "config-welcome": "=== Comprovacions de l'entorn ===\nS'efectuaran comprovacions bàsiques per veure si l'entorn és adequat per a la instal·lació del MediaWiki.\nRecordeu d'incloure aquesta informació si heu de demanar ajuda sobre com completar la instal·lació.",
+ "config-copyright": "=== Drets d'autor i condicions ===\n\n$1\n\nAquest programa és de programari lliure; podeu redistribuir-lo i/o modificar-lo sota les condicions de la Llicència Pública General GNU com es publicada per la Free Software Foundation; qualsevol versió 2 de la llicència, o (opcionalment) qualsevol versió posterior.\n\nAquest programa és distribueix amb l'esperança que serà útil, però <strong>sense cap garantia</strong>; sense ni tan sols la garantia implícita de <strong>\ncomerciabilitat</strong> o <strong>idoneïtat per a un propòsit particular</strong>.\nConsulteu la Llicència Pública General GNU, per a més detalls.\n\nHauríeu d'haver rebut <doclink href=\"Copying\">una còpia de la Llicència Pública General GNU</doclink> amb aquest programa; si no, escriviu a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA o [http://www.gnu.org/copyleft/gpl.html per llegir-lo en línia].",
+ "config-sidebar": "* [https://www.mediawiki.org la Pàgina d'inici]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guia de l'usuari]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guia de l'administrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ PMF]\n----\n* <doclink href=Readme>Llegeix-me</doclink>\n* <doclink href=ReleaseNotes>Notes de la versió</doclink>\n* <doclink href=Còpia>Còpia</doclink>\n* <doclink href=UpgradeDoc>Actualització</doclink>",
+ "config-env-good": "S'ha comprovat l'entorn.\nPodeu instal·lar el MediaWiki.",
+ "config-env-bad": "S'ha comprovat l'entorn.\nNo podeu instal·lar el MediaWiki.",
+ "config-env-php": "El PHP $1 està instal·lat.",
+ "config-env-hhvm": "L’HHVM $1 és instal·lat.",
+ "config-unicode-using-intl": "S'utilitza l'[http://pecl.php.net/intl extensió intl PECL] per a la normalització de l'Unicode.",
+ "config-memory-raised": "El <code>memory_limit</code> del PHP és $1 i s'ha aixecat a $2.",
+ "config-memory-bad": "<strong>Avís:</strong> El <code>memory_limit</code> del PHP és $1.\nAixò és probablement massa baix.\nLa instal·lació pot fallar!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] està instal·lat",
+ "config-apc": "L’[http://www.php.net/apc APC] està instal·lat",
+ "config-apcu": "[http://www.php.net/apcu APCu] està instal·lat",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] està instal·lat",
+ "config-diff3-bad": "No s'ha trobat el GNU diff3.",
+ "config-git": "S'ha trobat el programari de control de versions Git: <code>$1</code>.",
+ "config-git-bad": "No s'ha trobat el programari de control de versions Git.",
+ "config-no-scaling": "No s'ha pogut trobar la biblioteca GD o ImageMagick.\nS'inhabilitaran les miniatures de les imatges.",
+ "config-no-uri": "'''Error:''' No s'ha pogut determinar l'URI actual. S'ha interromput la instal·lació.",
+ "config-no-cli-uri": "'''Avís:''' No s'ha especificat un <code>--scriptpath</code>. S'utilitza el valor per defecte: <code>$1</code>.",
+ "config-using-server": "S'utilitza el nom del servidor «<nowiki>$1</nowiki>».",
+ "config-using-uri": "S'utilitza l'URL del servidor «<nowiki>$1$2</nowiki>».",
+ "config-uploads-not-safe": "<strong>Avís:</strong> El directori de càrregues per defecte <code>$1</code> és vulnerable a l'execució d'scripts arbitraris.\nEncara que el MediaWiki comprova tots els fitxers que es carreguen davant d'amenaces de seguretat, és molt recomanable [https://www.mediawiki.org/ wiki/Special:MyLanguage/Manual:Security#Upload_security tancar aquesta vulnerabilitat de seguretat] abans d'habilitar les càrregues.",
+ "config-db-type": "Tipus de base de dades:",
+ "config-db-host": "Servidor de la base de dades:",
+ "config-db-host-oracle": "TNS de la base de dades:",
+ "config-db-wiki-settings": "Identifica aquest wiki",
+ "config-db-name": "Nom de la base de dades:",
+ "config-db-name-help": "Trieu un nom que identifiqui el wiki.\nNo ha de contenir espais.\n\nSi esteu fent servir un hostatge web compartit, el vostre proveïdor us proporcionarà un nom específic per a la base de dades o us permetrà crear base de dades des d'un tauler de control.",
+ "config-db-name-oracle": "Esquema de la base de dades:",
+ "config-db-install-account": "Compte d'usuari per a la instal·lació",
+ "config-db-username": "Nom d'usuari de la base de dades:",
+ "config-db-password": "Contrasenya de la base de dades:",
+ "config-db-install-username": "Introduïu el nom d'usuari que s'utilitzarà per connectar a la base de dades durant el procés d'instal·lació. Aquest no és el nom d'usuari de MediaWiki, és el nom d'usuari de la vostra base de dades.",
+ "config-db-install-password": "Introduïu la contrasenya que s'utilitzarà per connectar a la base de dades durant el procés d'instal·lació. Aquesta no és la contrasenya del vostre compte a MediaWiki, és la contrasenya de la vostra base de dades.",
+ "config-db-install-help": "Introduïu el nom d'usuari i la contrasenya que s'empraran per connectar a la base de dades durant el procés d'instal·lació.",
+ "config-db-account-lock": "Utilitzeu el mateix nom d'usuari i contrasenya durant una operació normal",
+ "config-db-wiki-account": "Compte d'usuari per al funcionament normal",
+ "config-db-wiki-help": "Introduïu el nom d'usuari i la contrasenya que s'utilitzarà per connectar-se a la base de dades durant l'operació normal del wiki.\nSi el compte no existeix, i el compte d'instal·lació té prou privilegis, es crearà aquest compte d'usuari amb els privilegis mínims necessaris per operar el wiki.",
+ "config-db-prefix": "Prefix de la base de dades:",
+ "config-db-prefix-help": "Si heu de compartir una base de dades entre diversos wikis, o entre el MediaWiki i una altra aplicació web, podeu afegir un prefix al tots els noms de taula per a evitar conflictes.\nNo utilitzeu espais.\n\nAquest camp acostuma a quedar en blanc.",
+ "config-mysql-old": "Cal el MySQL $1 o posterior. Teniu el $2.",
+ "config-db-port": "Port de la base de dades:",
+ "config-db-schema": "Esquema per a MediaWiki:",
+ "config-db-schema-help": "Aquest esquema normalment ja serveix.\nNomés canvieu-lo si sabeu què us feu.",
+ "config-pg-test-error": "No es pot connectar a la base de dades '''$1''': $2",
+ "config-sqlite-dir": "Directori de dades de l'SQLite",
+ "config-oracle-def-ts": "Espai de taules per defecte:",
+ "config-oracle-temp-ts": "Espai de taules temporal:",
+ "config-type-mysql": "MySQL (o compatible)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki és compatible amb els següents sistemes de bases de dades:\n$1\nSi el sistema de bases de dades que intenteu utilitzar no apareix a la llista, seguiu les instruccions enllaçades més amunt per habilitar el suport.",
+ "config-header-mysql": "Paràmetres de MySQL",
+ "config-header-postgres": "Paràmetres del PostgreSQL",
+ "config-header-sqlite": "Paràmetres de l'SQLite",
+ "config-header-oracle": "Paràmetres de l'Oracle",
+ "config-header-mssql": "Paràmetres del Microsoft SQL Server",
+ "config-invalid-db-type": "Tipus de base de dades no vàlid",
+ "config-missing-db-name": "Heu d'introduir un valor per a «{{int:config-db-name}}».",
+ "config-missing-db-host": "Heu d'introduir un valor per a «{{int:config-db-host}}».",
+ "config-missing-db-server-oracle": "Heu d’introduir un valor per a «{{int:config-db-host-oracle}}».",
+ "config-invalid-db-name": "El nom de la base de dades, «$1», no és vàlid.\nUtilitzeu només lletres de l’ASCII (a-z, A-Z), xifres (0-9), guions baixos (_) i guionets (-).",
+ "config-invalid-db-prefix": "El prefix de la base de dades, «$1», no és vàlid.\nUtilitzeu només lletres de l’ASCII (a-z, A-Z), xifres (0-9), guions baixos (_) i guionets (-).",
+ "config-connection-error": "$1.\n\nComproveu el servidor central, el nom d'usuari i la contrasenya i torneu-ho a provar.",
+ "config-invalid-schema": "L’esquema «$1» no és vàlid per al MediaWiki.\nUtilitzeu només lletres de l’ASCII (a-z, A-Z), xifres (0-9), guions baixos (_) i guionets (-).",
+ "config-db-sys-create-oracle": "L'instal·lador només accepta emprar un compte SYSDBA per a la creació d'un nou compte.",
+ "config-db-sys-user-exists-oracle": "El compte d’usuari «$1» ja existeix. SYSDBA només es pot fer servir per crear comptes nous.",
+ "config-postgres-old": "Cal el PostgreSQL $1 o posterior. Teniu el $2.",
+ "config-mssql-old": "Cal utilitzar el Microsoft SQL Server $1 o posterior. Teniu la versió $2.",
+ "config-sqlite-name-help": "Trieu un nom per identificar el wiki.\nNo feu servir espais ni guionets.\nAquest nom s’utilitzarà per a denominar el fitxer de les dades de l’SQLite.",
+ "config-sqlite-mkdir-error": "S'ha produït un error en crear el directori de dades «$1».\nComproveu la ubicació i torneu-ho a provar.",
+ "config-sqlite-dir-unwritable": "No s'ha pogut escriure al directori «$1».\nCanvieu els permisos perquè el servidor web pugui escriure-hi i torneu-ho a provar.",
+ "config-sqlite-connection-error": "$1. \n\nComproveu el directori de dades i el nom de la base de dades a continuació i torneu-ho a provar.",
+ "config-sqlite-readonly": "El fitxer <code>$1</code> no es pot escriure.",
+ "config-sqlite-cant-create-db": "No s'ha pogut crear el fitxer de base de dades <code>$1</code>.",
+ "config-can-upgrade": "Hi ha taules del MediaWiki en aquesta base de dades.\nPer actualitzar-les al MediaWiki $1, feu clic a <strong>Continua</strong>.",
+ "config-upgrade-done-no-regenerate": "S'ha completat l'actualització.\n\nJa podeu [$1 començar a utilitzar el wiki].",
+ "config-regenerate": "Torna a generar el LocalSettings.php →",
+ "config-show-table-status": "La consulta <code>SHOW TABLE STATUS</code> ha fallat!",
+ "config-db-web-account": "Compte de la base de dades per a l'accés web",
+ "config-db-web-help": "Seleccioneu el nom d'usuari i la contrasenya que el servidor web utilitzarà per a connectar-se al servidor de base de dades durant el funcionament normal del wiki.",
+ "config-db-web-account-same": "Utilitza el mateix compte que a la instal·lació",
+ "config-db-web-create": "Crea el compte si no existeix encara",
+ "config-db-web-no-create-privs": "El compte que heu especificat a la instal·lació no té suficients privilegis per crear un compte. El compte que especifiqueu aquí ja ha d'existir.",
+ "config-mysql-engine": "Motor d'emmagatzemament:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Joc de caràcters de la base de dades:",
+ "config-mysql-binary": "Binari",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Tipus d'autenticació:",
+ "config-mssql-sqlauth": "Autenticació de l’SQL Server",
+ "config-mssql-windowsauth": "Autenticació del Windows",
+ "config-site-name": "Nom del wiki:",
+ "config-site-name-help": "Això apareixerà en la barra de títol del navegador i en altres llocs diferents.",
+ "config-site-name-blank": "Introduïu un nom per al lloc.",
+ "config-project-namespace": "Espai de noms del projecte:",
+ "config-ns-generic": "Projecte",
+ "config-ns-site-name": "El mateix que el nom del wiki: $1",
+ "config-ns-other": "Un altre (especifiqueu-lo)",
+ "config-ns-other-default": "MonWiki",
+ "config-admin-box": "Compte de l'administrador",
+ "config-admin-name": "El vostre nom d'usuari:",
+ "config-admin-password": "Contrasenya:",
+ "config-admin-password-confirm": "Repetiu la contrasenya:",
+ "config-admin-help": "Introduïu el vostre nom d'usuari preferit, per exemple «Pep Bloggs».\nAquest és el nom que fareu servir per iniciar una sessió al wiki.",
+ "config-admin-name-blank": "Introduïu un nom d'usuari d'administrador.",
+ "config-admin-name-invalid": "El nom d'usuari especificat «<nowiki>$1</nowiki>» no és vàlid.\nEspecifiqueu un nom d'usuari diferent.",
+ "config-admin-password-blank": "Introduïu una contrasenya per al compte d'administrador.",
+ "config-admin-password-mismatch": "Les dues contrasenyes que heu introduït no coincideixen.",
+ "config-admin-email": "Adreça electrònica:",
+ "config-admin-error-user": "S'ha produït un error intern en crear un administrador amb el nom «<nowiki>$1</nowiki>».",
+ "config-admin-error-password": "S'ha produït un error intern en definir una contrasenya per a l'administrador «<nowiki>$1</nowiki>»: <pre>$2</pre>",
+ "config-admin-error-bademail": "Heu introduït una adreça electrònica no vàlida.",
+ "config-subscribe": "Subscriu a la [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce llista de correu d'anunci de noves versions].",
+ "config-pingback": "Comparteix dades d'aquesta instal·lació amb els desenvolupadors de MediaWiki.",
+ "config-almost-done": "Gairebé ja heu acabat!\nPodeu ometre el que queda de la configuració i procedir amb la instal·lació del wiki.",
+ "config-optional-continue": "Fes-me més preguntes.",
+ "config-optional-skip": "Ja estic avorrit. Simplement instal·leu el wiki.",
+ "config-profile": "Perfil de permisos d'usuari:",
+ "config-profile-wiki": "Wiki públic",
+ "config-profile-no-anon": "Cal la creació d'un compte",
+ "config-profile-fishbowl": "Només editors autoritzats",
+ "config-profile-private": "Wiki privat",
+ "config-license": "Copyright i llicència:",
+ "config-license-none": "Sense llicència al peu de pàgina",
+ "config-license-cc-by-sa": "Creative Commons Reconeixement-CompartirIgual",
+ "config-license-cc-by": "Creative Commons Reconeixement",
+ "config-license-cc-by-nc-sa": "Creative Commons Reconeixement-NoComercial-CompartirIgual",
+ "config-license-cc-0": "Creative Commons Zero (Domini Públic)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 o posterior",
+ "config-license-pd": "Domini públic",
+ "config-license-cc-choose": "Selecció d'una llicència personalitzada de Creative Commons",
+ "config-email-settings": "Paràmetres del correu electrònic",
+ "config-email-user": "Habilita el correu electrònic usuari-a-usuari",
+ "config-email-user-help": "Permet que tots els usuaris puguin enviar-se correu si ho han habilitat a les preferències.",
+ "config-email-usertalk": "Habilita la notificació a la pàgina de discussió de l'usuari",
+ "config-email-watchlist": "Habilita la notificació de la llista de seguiment",
+ "config-email-watchlist-help": "Permet als usuaris rebre notificacions de les pàgines que segueixen si ho han habilitat a les preferències.",
+ "config-email-auth": "Habilita l'autenticació per correu electrònic",
+ "config-email-sender": "Adreça electrònica de retorn:",
+ "config-upload-settings": "Imatges i càrregues de fitxers",
+ "config-upload-enable": "Habilita la càrrega de fitxers",
+ "config-upload-deleted": "Directori pels arxius suprimits:",
+ "config-upload-deleted-help": "Trieu un directori per a arxivar els fitxers suprimits.\nIdealment no hauria de ser accessible des del web.",
+ "config-logo": "URL del logo:",
+ "config-instantcommons": "Habilita Instant Commons",
+ "config-cc-error": "El selector de llicència Creative Commons no ha donat cap resultat.\nIntroduïu la llicència manualment.",
+ "config-cc-again": "Torneu-ho a triar...",
+ "config-cc-not-chosen": "Trieu quina llicència Creative Commons voleu i feu clic a «proceed».",
+ "config-advanced-settings": "Configuració avançada",
+ "config-cache-options": "Configuració per a la memòria cau dels objectes:",
+ "config-cache-help": "L'encauament d'objectes s'utilitza per a millorar la rapidesa del MediaWiki afegint a la memòria cau les dades que s'utilitzen de forma freqüent. És recomanable que els llocs web mitjans o grans ho habilitin. També els llocs web petits en veuran els beneficis.",
+ "config-cache-none": "Sense encauament (no se suprimeix cap funcionalitat, però la velocitat pot veure's afectada en els llocs wiki més grans)",
+ "config-cache-accel": "Emmagatzemament en memòria cau d'objectes de PHP (APC, APCu, XCache o WinCache)",
+ "config-cache-memcached": "Utilitza Memcached (requereix una instal·lació i configuració addicionals)",
+ "config-memcached-servers": "Servidors de Memcache:",
+ "config-memcache-badip": "Heu introduït una adreça IP no vàlida per al Memcached: $1.",
+ "config-memcache-noport": "No heu especificat un port per utilitzar el servidor Memcached: $1.\nSi no coneixeu el port, per defecte és 11211.",
+ "config-memcache-badport": "Els números de port de Memcached han de ser entre $1 i $2.",
+ "config-extensions": "Extensions",
+ "config-skins": "Aparences",
+ "config-skins-help": "S'han detectat els temes llistats a dalt en el directori <code>./skins</code>. Heu d'habilitar-ne com a mínim un i trieu-ne el predeterminat.",
+ "config-skins-use-as-default": "Utilitza aquest tema per defecte",
+ "config-skins-missing": "No s'ha trobat cap tema; MediaWiki utilitzarà el tema per defecte fins que hi instal·leu alguns adequats.",
+ "config-skins-must-enable-some": "Heu de triar com a mínim un tema per habilitar.",
+ "config-skins-must-enable-default": "Cal habilitar el tema triat per defecte.",
+ "config-install-alreadydone": "<strong>Avís:</strong> Sembla que ja havíeu instal·lat MediaWiki i esteu provant d'instal·lar-lo de nou.\nProcediu a la pàgina següent.",
+ "config-install-begin": "En fer clic a «{{int:config-continue}}» s’iniciarà la instal·lació del MediaWiki. Si encara voleu fer canvis, feu clic a «{{int:config-back}}».",
+ "config-install-step-done": "fet",
+ "config-install-step-failed": "ha fallat",
+ "config-install-extensions": "S'estan incloent les extensions",
+ "config-install-database": "S'està configurant la base de dades",
+ "config-install-schema": "S'està creant l'esquema",
+ "config-install-pg-schema-not-exist": "No existeix un esquema PostgreSQL.",
+ "config-install-pg-schema-failed": "La creació de les taules ha fallat.\nAssegureu-vos que l'usuari «$1» pot escriure a l'esquema «$2».",
+ "config-install-pg-commit": "S'estan trametent els canvis",
+ "config-pg-no-plpgsql": "Necessiteu instal·lar l'idioma PL/pgSQL a la base de dades $1",
+ "config-pg-no-create-privs": "El compte que heu especificat per a la instal·lació no té suficients permisos per crear un compte.",
+ "config-install-user": "S'està creant l'usuari de la base de dades",
+ "config-install-user-alreadyexists": "L'usuari «$1» ja existeix",
+ "config-install-user-create-failed": "La creació de l'usuari «$1» ha fallat: $2",
+ "config-install-user-grant-failed": "Ha fallat la concessió de permisos a l'usuari «$1»: $2",
+ "config-install-user-missing": "L'usuari «$1» especificat no existeix.",
+ "config-install-user-missing-create": "L'usuari «$1» especificat no existeix.\nFeu clic a la casella «Crea un compte» a continuació si voleu crear-lo.",
+ "config-install-tables": "S'estan creant les taules",
+ "config-install-tables-exist": "'''Avís:''' sembla que les taules del MediaWiki tables ja existeixen. Se n'omet la creació.",
+ "config-install-tables-failed": "'''Error:''' la creació de la taula ha fallat amb l'error següent: $1",
+ "config-install-interwiki": "S'està emplenant la taula per defecte d'interwiki",
+ "config-install-interwiki-list": "No s'ha pogut llegir el fitxer <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Avís:''' La taula d'interwiki sembla que ja té entrades. S'omet la llista per defecte.",
+ "config-install-stats": "S'estan inicialitzant les estadístiques",
+ "config-install-keys": "S'estan generant les claus secretes",
+ "config-install-updates": "Evita que s'executin actualitzacions no necessàries",
+ "config-install-sysop": "S'està creant un compte d'usuari d'administrador",
+ "config-install-subscribe-fail": "No s'ha pogut subscriure a mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "El cURL no està instal·lat i <code>allow_url_fopen</code> no està disponible.",
+ "config-install-mainpage": "S'està creant la pàgina principal amb el contingut per defecte",
+ "config-install-mainpage-exists": "La pàgina principal ja existeix, per tant s'omet",
+ "config-install-extension-tables": "S'estan creant taules de les extensions habilitades",
+ "config-install-mainpage-failed": "No s'ha pogut inserir la pàgina principal: $1",
+ "config-install-done": "<strong>Enhorabona!</strong>\nHeu instal·lat MediaWiki.\n\nL'instal·lador a generat un fitxer <code>LocalSettings.php</code>.\nConté tota la configuració.\n\nCaldrà que el baixeu i el poseu al directori base on heu instal·lat al wiki (el mateix directori on es troba index.php). La baixada hauria d'haver començat automàticament.\n\nSi la baixada no comença, o si l'heu cancel·lat, podeu reiniciar-la fent clic a l'enllaç de sota:\n\n$3\n\n<strong>Nota:</strong> Si no ho feu ara, no podreu accedir a aquest fitxer de configuració més endavant si no l'heu baixat abans.\n\nUna vegada tot això fet, podeu <strong>[$2 entrar al vostre wiki]</strong>.",
+ "config-download-localsettings": "Baixa <code>LocalSettings.php</code>",
+ "config-help": "ajuda",
+ "config-help-tooltip": "feu clic per ampliar",
+ "config-nofile": "No s'ha pogut trobar el fitxer «$1». S'ha suprimit?",
+ "config-extension-link": "Sabíeu que el vostre wiki permet l'ús d'[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensions]?\n\nPodeu navegar les [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensions per categoria] o la [https://www.mediawiki.org/wiki/Extension_Matrix matriu d'extensions] per a veure'n una llista sencera.",
+ "mainpagetext": "<strong>MediaWiki s'ha instal·lat.</strong>",
+ "mainpagedocfooter": "Consulteu la [https://meta.wikimedia.org/wiki/Help:Contents Guia d'Usuari] per a més informació sobre com utilitzar aquest programari wiki.\n\n== Primers passos ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Llista de paràmetres configurables]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ PMF del MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Llista de correu per a anuncis del MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Traducció de MediaWiki en la vostra llengua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Aprengueu com combatre la brossa que pot atacar el vostre wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/ce.json b/www/wiki/includes/installer/i18n/ce.json
new file mode 100644
index 00000000..e11fae23
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ce.json
@@ -0,0 +1,93 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sasan700",
+ "Умар",
+ "Seb35"
+ ]
+ },
+ "config-desc": "MediaWiki инсталлятор",
+ "config-title": "ХӀоттор MediaWiki $1",
+ "config-information": "Хаам",
+ "config-localsettings-key": "Карлаяккхаран догӀа:",
+ "config-localsettings-badkey": "Ахьа яздина нийса доцу догӀа",
+ "config-your-language": "Хьан мотт:",
+ "config-wiki-language": "Вики чохь лелор болу мотт",
+ "config-back": "← Юха",
+ "config-continue": "Кхин дӀа →",
+ "config-page-language": "Мотт",
+ "config-page-welcome": "Марша догӀийла MediaWiki чу!",
+ "config-page-name": "ЦӀе",
+ "config-page-options": "Параметраш",
+ "config-page-install": "ХӀоттор",
+ "config-page-complete": "Кийчча ю!",
+ "config-page-restart": "Юху доладе дӀахӀоттор",
+ "config-page-readme": "Еша со",
+ "config-page-releasenotes": "Версех лаьцна хаам",
+ "config-page-copying": "Лицензи",
+ "config-page-upgradedoc": "Карлаяккхар",
+ "config-page-existingwiki": "Йолуш йолу вики",
+ "config-copyright": "=== Авторан бакъонаш а хьал а ===\n\n$1\nMediaWiki ю маьрша программин латораг, шу йиш ю фондас арахецна йолу GNU General Public License лицензица и яржо я хийца а.\n\nMediaWiki яржош ю и шуна пайдане хир яц те аьлла, амма цхьа юкъарахилар доцуш. Хь. кхин. лицензи мадарра GNU General Public License .\n\nШоьга кхача езаш яра [{{SERVER}}{{SCRIPTPATH}}/COPYING копи GNU General Public License] хӀокху программица, кхаьчна яцахь язъе Free Software Foundation, Inc., адрес тӀе: 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA я [//www.gnu.org/licenses/old-licenses/gpl-2.0.html еша и онлайнехь].",
+ "config-no-fts3": "'''Тергам бе''': SQLite гулйина хуттург йоцуш [//sqlite.org/fts3.html FTS3] — лахар болхбеш хир дац оцу бухца.",
+ "config-no-cli-uri": "'''ДӀахьедар''': <code>--scriptpath</code> параметр язйина яц, иза Ӏадйитаран кепаца лелош ю: <code>$1</code> .",
+ "config-db-name": "Хаамийн базан цӀе:",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mssql": "Microsoft SQL Server параметраш",
+ "config-invalid-db-type": "Хаамийн базан нийса йоцу тайп",
+ "config-missing-db-name": "Ахьа «{{int:config-db-name}}» маьӀна даздан дезаш ду.",
+ "config-missing-db-host": "Ахьа «{{int:config-db-host}}» параметран маьӀна даздан дезаш ду.",
+ "config-missing-db-server-oracle": "Ахьа тӀеюза езаш ю «{{int:config-db-host-oracle}}»",
+ "config-invalid-db-server-oracle": "Хаамийн базан «$1» нийса йоцу TNS.\nЛелае «TNS Name», я могӀа «Easy Connect» ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm ЦӀерш техкаран кеп Oracle])",
+ "config-sqlite-fts3-downgrade": "PHPн гӀо до FTS3 яц — кхуссу таблицаш",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Аутентификацин тайп:",
+ "config-site-name": "Викин цӀе:",
+ "config-site-name-blank": "Язъе сайтан цӀе.",
+ "config-project-namespace": "Проектан цӀерийн меттиг:",
+ "config-ns-generic": "Проект",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-password": "Пароль:",
+ "config-admin-password-confirm": "Кхин цӀа пароль:",
+ "config-profile-wiki": "Елин вики",
+ "config-profile-no-anon": "ДӀаяздар кхолла деза",
+ "config-profile-fishbowl": "ДӀаяздарш долу тадархошна бен",
+ "config-profile-private": "ДӀачӀаьгӀна вики",
+ "config-license": "Авторан бакъонаш а, лицензи а:",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (юкъаралин рицӀкъ)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 я кхин хьалха хиларг",
+ "config-license-pd": "Юкъараллин хьал",
+ "config-license-cc-choose": "Харжа цхьа лицензи Creative Commons",
+ "config-email-settings": "Электронан пошт нисяр",
+ "config-enable-email": "Латае дӀайохьуьйту e-mail",
+ "config-upload-deleted": "ДӀаяхна файлийн директори:",
+ "config-logo": "Логотипан URL:",
+ "config-cc-again": "Хьаржа кхин цӀа…",
+ "config-skins": "Кечяран тема",
+ "config-skins-use-as-default": "ХӀара тема Ӏадйитаран кепара лелае",
+ "config-skins-must-enable-some": "Ахьа цхьаъ мукъа тема латина йита езаш ю.",
+ "config-skins-must-enable-default": "Ӏадйитаран кепаца йолу тема латина хила еза.",
+ "config-install-step-done": "кхочушдина",
+ "config-install-step-failed": "тар цаделира",
+ "config-install-user": "Декъашхочун хаамийн база кхоллар",
+ "config-install-user-alreadyexists": "Декъашхо «$1» хӀинцале волуш ву",
+ "config-install-user-create-failed": "Декъашхо «$1» кхолла цаделира: $2",
+ "config-install-user-grant-failed": "Декъашхочун «$1» бакъонаш яларан гӀалат: $2",
+ "config-install-user-missing": "Билгалвина декъашхо «$1» вац.",
+ "config-install-tables": "Таблицаш кхоллар",
+ "config-install-tables-exist": "'''ДӀахьедар''': MediaWiki таблицаш, йолуш хила там бу.\nЮха кхоллар чекхдалийтар.",
+ "config-install-tables-failed": "'''ГӀалат''': Таблица кхолла таро яц гӀалат бахьнехь: $1",
+ "config-install-interwiki-list": "Файл <code>interwiki.list</code> каро цаелира.",
+ "config-install-stats": "Инициализацин статистика",
+ "config-install-keys": "Къайлаха долу догӀанаш кхоллар",
+ "config-install-sysop": "Куьйгалхочун дӀаяздар кхоллар",
+ "config-install-subscribe-notpossible": "cURL дӀахӀоттийна яц я тӀекхочехь яц опци <code>allow_url_fopen</code>.",
+ "config-install-mainpage-failed": "Коьрта агӀо йилла цатарло: $1",
+ "config-download-localsettings": "Чуяккха <code>LocalSettings.php</code>",
+ "config-help": "гӀо",
+ "config-nofile": "Файл \"$1\" каро цаелира. И дӀаяьккхина ярий?",
+ "mainpagetext": "'''Вики-белхан гӀирс «MediaWiki» кхочуш дика дӀахӀоттийна.'''",
+ "mainpagedocfooter": "Викийца болх бан хаамаш карор бу хӀокху чохь [https://meta.wikimedia.org/wiki/Help:Contents нисвохааман куьйгаллица].\n\n== Цхьаболу пайде гӀирсаш ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings ГӀирс нисбан тарлушболу могӀам];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Сих сиха лушдолу хаттарш а жоьпаш оцу MediaWiki];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Хаам бохьуьйту араяларца башхонца керла MediaWiki].\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/ceb.json b/www/wiki/includes/installer/i18n/ceb.json
new file mode 100644
index 00000000..9d0c7d5c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ceb.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''Malamposon ang pag-instalar sa MediaWiki.'''",
+ "mainpagedocfooter": "Konsultaha ang [https://meta.wikimedia.org/wiki/Help:Contents Giya sa mga gumagamit] alang sa impormasyon unsaon paggamit niining wiki nga software.\n\n== Pagsugod ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Listahan sa mga setting sa kompigurasyon]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ sa MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list sa mga release sa MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/ckb.json b/www/wiki/includes/installer/i18n/ckb.json
new file mode 100644
index 00000000..3bfa8a6f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ckb.json
@@ -0,0 +1,59 @@
+{
+ "@metadata": {
+ "authors": [
+ "Asoxor",
+ "Calak",
+ "Muhammed taha",
+ "Lost Whispers"
+ ]
+ },
+ "config-desc": "دامەزرێنەرەکە بۆ میدیاویکی",
+ "config-title": "دامەزرانی میدیاویکی $1",
+ "config-information": "زانیاری",
+ "config-your-language": "زمانەکەت:",
+ "config-wiki-language": "زمانی ویکی:",
+ "config-back": "→ گەڕانەوە",
+ "config-continue": "بەردەوام بە ←",
+ "config-page-language": "زمان",
+ "config-page-welcome": "بەخێربێیت بۆ میدیاویکی!",
+ "config-page-dbconnect": "پەیوەندی دەکات بەبنکەی زانیارییەکان",
+ "config-page-upgrade": "نويکردنەوەی دابەزاندنی پێشوو",
+ "config-page-dbsettings": "ڕێکخستنەکانی بنکەی زانیارییەکان",
+ "config-page-name": "ناو",
+ "config-page-options": "ھەڵبژاردەکان",
+ "config-page-install": "دابەزاندن",
+ "config-page-complete": "تەواو!",
+ "config-page-restart": "دەست پێکردنەوە بەدابەزاندن",
+ "config-page-readme": "بمخوێنەوە",
+ "config-page-copying": "لەبەردەگیرێتەوە",
+ "config-page-upgradedoc": "نوێدەکرێتەوە",
+ "config-page-existingwiki": "ویکی پێشوو",
+ "config-restart": "بەڵێ، دەستی پێ بکەرەوە",
+ "config-env-php": "PHP $1 دامەزراوە.",
+ "config-apc": "[http://www.php.net/apc APC] دامەزراوە",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] دامەزراوە",
+ "config-db-type": "جۆری داتابەیس:",
+ "config-db-host": "خانەخوێی داتابەیس:",
+ "config-db-name": "ناوی بنکەدراوە:",
+ "config-db-install-account": "ھەژماری بەکارھێنەری بۆ دامەزراندن",
+ "config-db-username": "ناوی بەکارھێنەری بنکەدراوە:",
+ "config-db-password": "تێپەڕوشەی بنکەدراوە",
+ "config-site-name": "ناوی ویکی:",
+ "config-site-name-blank": "ناوی پێگە داخڵ بکە.",
+ "config-ns-generic": "پرۆژە",
+ "config-admin-name": "ناوی بەکارھێنەرییەکەت:",
+ "config-admin-password": "تێپەڕوشە:",
+ "config-admin-password-confirm": "دووبارە تێپەڕوشە:",
+ "config-admin-email": "ناونیشانی ئیمەیل:",
+ "config-admin-email-help": "ناونیشانی ئیمەیڵەکەت لێرەدا دابنێ بۆئەوەی بتوانیت ئیمەیڵت لە بەکارھێنەرانی ترەوە پێ بگات، تێپەڕ وشە ڕێک بخەیتەوە و ئاگادار بکرێیتەوە لەو گۆڕانکاریانەی کە لەو پەڕانەدا دەکرێن کە چاودێرییان دەکەیت. دەتوانیت ئەم بۆشاییە بە بەتاڵی جێبھێڵیت.",
+ "config-admin-error-bademail": "تۆ ناونیشانی ئیمەیڵێکی ھەڵەت داخڵ کردووە.",
+ "config-profile-wiki": "ویکیی کراوە",
+ "config-profile-no-anon": "دروستکردنی ھەژمارە پێویستە",
+ "config-profile-fishbowl": "تەنھا دەستکاریکەری ڕێگەپێدراوە",
+ "config-license-pd": "پاوانی گشتی",
+ "config-email-settings": "ڕێکخستنەکانی ئیمەیڵ",
+ "config-install-step-done": "کرا",
+ "config-help": "یارمەتی",
+ "mainpagetext": "<strong>میدیاویکی بە سەرکەوتوویی دامەزرا.</strong>",
+ "mainpagedocfooter": "لە [https://meta.wikimedia.org/wiki/Help:Contents ڕێنوێنیی بەکارھێنەران] بۆ زانیاری سەبارەت بە بەکارھێنانی نەرمامێری ویکی کەڵک وەربگرە.\n\n== دەستپێکردن ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings پێرستی ڕێکخستنەکانی شێوەپێدان]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ پرسیارە دووپاتکراوەکانی میدیاویکی (MediaWiki FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce پێرستی ئیمەیلی وەشانەکانی میدیاویکی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources خۆماڵیکردنی ویکیمیدیا بۆ زمانەکەت]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam فێربە چۆن ڕووبەڕووى ئیمەیڵە بێزارکەرەکانی ویکییەکەت دەبیتەوە]"
+}
diff --git a/www/wiki/includes/installer/i18n/cps.json b/www/wiki/includes/installer/i18n/cps.json
new file mode 100644
index 00000000..561d03ec
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/cps.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Oxyzen",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''Madalag-on nga na-install ang MediaWiki.'''",
+ "mainpagedocfooter": "Kunsultahon ang [https://meta.wikimedia.org/wiki/Help:Contents sa Manug-usar] para sa impormasyon sa paggamit sang wiki nga \"software\".\n\n==Pag-umpisa==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista sang mga setting sang konpigurayon]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Mga perme napangkot sa MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista sang mga ginapadal-an sang sulat sang MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/crh-cyrl.json b/www/wiki/includes/installer/i18n/crh-cyrl.json
new file mode 100644
index 00000000..b3f5a694
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/crh-cyrl.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki мувафакъиетнен къурулды.'''",
+ "mainpagedocfooter": "Бу викининъ ёл-ёругъыны [https://meta.wikimedia.org/wiki/Help:Contents User's Guide къулланыджы къылавузындан] огренип оласынъыз.\n\n== Базы файдалы сайтлар ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Олуджы сазламалар джедвели];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki боюнджа сыкъ берильген суаллернен джеваплар];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki-нинъ янъы версияларынынъ чыкъувындан хабер йиберюв]."
+}
diff --git a/www/wiki/includes/installer/i18n/crh-latn.json b/www/wiki/includes/installer/i18n/crh-latn.json
new file mode 100644
index 00000000..517d1e1a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/crh-latn.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki muvafaqiyetnen quruldı.'''",
+ "mainpagedocfooter": "Bu vikiniñ yol-yoruğını [https://meta.wikimedia.org/wiki/Help:Contents User's Guide qullanıcı qılavuzından] ögrenip olasıñız.\n\n== Bazı faydalı saytlar ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Olucı sazlamalar cedveli];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki boyunca sıq berilgen suallernen cevaplar];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki-niñ yañı versiyalarınıñ çıquvından haber yiberüv]."
+}
diff --git a/www/wiki/includes/installer/i18n/cs.json b/www/wiki/includes/installer/i18n/cs.json
new file mode 100644
index 00000000..2ff49fcb
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/cs.json
@@ -0,0 +1,326 @@
+{
+ "@metadata": {
+ "authors": [
+ "Danny B.",
+ "Jezevec",
+ "Mormegil",
+ "아라",
+ "Matěj Grabovský",
+ "Paxt",
+ "Matěj Suchánek",
+ "LordMsz",
+ "Seb35"
+ ]
+ },
+ "config-desc": "Instalační program pro MediaWiki",
+ "config-title": "Instalace MediaWiki $1",
+ "config-information": "Informace",
+ "config-localsettings-upgrade": "Byl nalezen soubor <code>LocalSettings.php</code>.\nPokud chcete stávající instalaci aktualizovat, zadejte hodnotu <code>$wgUpgradeKey</code>, kterou naleznete v souboru <code>LocalSettings.php</code>, do následujícího rámečku.",
+ "config-localsettings-cli-upgrade": "Byl detekován soubor <code><code>LocalSettings.php</code></code>\nPro aktualizaci spusťte místo instalace skript <code>update.php</code>.",
+ "config-localsettings-key": "Klíč pro aktualizaci:",
+ "config-localsettings-badkey": "Zadaný klíč pro aktualizaci je nesprávný.",
+ "config-upgrade-key-missing": "Byla detekována existující instalace MediaWiki.\nPokud ji chcete aktualizovat, přidejte následující řádku na konec souboru <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Existující soubor <code>LocalSettings.php</code> vypadá neúplný.\nNení nastavena proměnná $1.\nUpravte soubor <code>LocalSettings.php</code> tak, aby tuto proměnnou obsahoval, a klikněte na „{{int:Config-continue}}“.",
+ "config-localsettings-connection-error": "Při připojování k databázi s využitím nastavení uvedených v <code>LocalSettings.php</code> došlo k chybě. Opravte tato nastavení a zkuste to znovu.\n\n$1",
+ "config-session-error": "Nepodařilo se inicializovat relaci: $1",
+ "config-session-expired": "Platnost dat vašeho sezení patrně vypršela.\nSezení má nastavenu životnost $1.\nProdloužit ji můžete nastavením <code>session.gc_maxlifetime</code> v php.ini.\nSpusťte instalační proces od začátku.",
+ "config-no-session": "Data vašeho sezení se ztratila!\nZkontrolujte svůj soubor php.ini a ujistěte se, že <code>session.save_path</code> je nastaveno na odpovídající adresář.",
+ "config-your-language": "Váš jazyk:",
+ "config-your-language-help": "Zvolte jazyk, který se má použít v průběhu instalace.",
+ "config-wiki-language": "Jazyk wiki:",
+ "config-wiki-language-help": "Zvolte jazyk, ve kterém bude většina obsahu wiki.",
+ "config-back": "← Zpět",
+ "config-continue": "Pokračovat →",
+ "config-page-language": "Jazyk",
+ "config-page-welcome": "Vítejte v MediaWiki!",
+ "config-page-dbconnect": "Připojení k databázi",
+ "config-page-upgrade": "Aktualizace existující instalace",
+ "config-page-dbsettings": "Nastavení databáze",
+ "config-page-name": "Název",
+ "config-page-options": "Nastavení",
+ "config-page-install": "Instalovat",
+ "config-page-complete": "Hotovo!",
+ "config-page-restart": "Restartovat instalaci",
+ "config-page-readme": "Soubor Čti mě",
+ "config-page-releasenotes": "Poznámky k vydání",
+ "config-page-copying": "Licence",
+ "config-page-upgradedoc": "Upgrade",
+ "config-page-existingwiki": "Existující wiki",
+ "config-help-restart": "Chcete smazat všechny údaje, které jste zadali, a spustit proces instalace znovu od začátku?",
+ "config-restart": "Ano, restartovat",
+ "config-welcome": "=== Kontrola prostředí ===\nNyní se provedou základní kontroly, aby se zjistilo, zda je toto prostředí použitelné k instalaci MediaWiki.\nPokud budete potřebovat k dokončení instalace pomoc, nezapomeňte sdělit výsledky těchto testů.",
+ "config-copyright": "=== Licence a podmínky ===\n$1\n\nTento program je svobodný software; můžete jej šířit nebo modifikovat podle podmínek GNU General Public License, vydávané Free Software Foundation; buď verze 2 této licence anebo (podle vašeho uvážení) kterékoli pozdější verze.\n\nTento program je distribuován v naději, že bude užitečný, avšak '''bez jakékoli záruky'''; neposkytují se ani odvozené záruky '''prodejnosti''' anebo '''vhodnosti pro určitý účel'''.\nPodrobnosti se dočtete v textu GNU General Public License.\n\n<doclink href=Copying>Kopii GNU General Public License</doclink> jste měli obdržet spolu s tímto programem; pokud ne, napište na Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA nebo [http://www.gnu.org/copyleft/gpl.html si ji přečtěte online].",
+ "config-sidebar": "* [https://www.mediawiki.org Oficiální web MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Uživatelská příručka]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrátorská příručka]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Čti mě</doclink>\n* <doclink href=ReleaseNotes>Poznámky k vydání</doclink>\n* <doclink href=Copying>Licence</doclink>\n* <doclink href=UpgradeDoc>Upgrade</doclink>",
+ "config-env-good": "Prostředí bylo zkontrolováno.\nMůžete nainstalovat MediaWiki.",
+ "config-env-bad": "Prostředí bylo zkontrolováno.\nMediaWiki nelze nainstalovat.",
+ "config-env-php": "Je nainstalováno PHP $1.",
+ "config-env-hhvm": "Je nainstalováno HHVM $1.",
+ "config-unicode-using-intl": "Pro normalizaci Unicode se používá [http://pecl.php.net/intl PECL rozšíření intl].",
+ "config-unicode-pure-php-warning": "<strong>Upozornění:</strong> Není dostupné [http://pecl.php.net/intl PECL rozšíření intl] pro normalizaci Unicode, bude se využívat pomalá implementace v čistém PHP.\nPokud provozujete wiki s velkou návštěvností, měli byste si přečíst něco o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizaci Unicode].",
+ "config-unicode-update-warning": "<strong>Upozornění:</strong> Nainstalovaná verze vrstvy pro normalizaci Unicode používá starší verzi knihovny [http://site.icu-project.org/ projektu ICU].\nPokud vám aspoň trochu záleží na používání Unicode, měli byste [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ji aktualizovat].",
+ "config-no-db": "Nepodařilo se nalézt vhodný databázový ovladač! Musíte nainstalovat databázový ovladač pro PHP.\n{{PLURAL:$2|Je podporován následující typ databáze|Jsou podporovány následující typy databází}}: $1.\n\nPokud jste si PHP přeložili sami, překonfigurujte ho se zapnutým databázovým klientem, například pomocí <code>./configure --with-mysqli</code>.\nPokud jste PHP nainstalovali z balíčku Debian či Ubuntu, potřebujete nainstalovat také modul <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Upozornění:</strong> Máte SQLite $1, které je starší než minimálně vyžadovaná verze $2. SQLite nebude dostupné.",
+ "config-no-fts3": "<strong>Upozornění:</strong> SQLite bylo přeloženo bez [//sqlite.org/fts3.html modulu FTS3], funkce pro vyhledávání zde nebudou dostupné.",
+ "config-pcre-old": "<strong>Kritická chyba:</strong> Je vyžadováno PCRE verze $1 nebo novější.\nVaše binárka PHP obsahuje PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Více informací.]",
+ "config-pcre-no-utf8": "<strong>Kritická chyba:</strong> PHP modul PCRE byl zřejmě přeložen bez podpory PCRE_UTF8.\nMediaWiki vyžaduje ke správné funkci podporu UTF-8.",
+ "config-memory-raised": "<code>memory_limit</code> v PHP byl nastaven na $1, zvýšen na $2.",
+ "config-memory-bad": "<strong>Upozornění:</strong> <code>memory_limit</code> je v PHP nastaven na $1.\nTo je pravděpodobně příliš málo.\nInstalace může selhat!",
+ "config-xcache": "Je nainstalována [http://xcache.lighttpd.net/ XCache]",
+ "config-apc": "Je nainstalováno [http://www.php.net/apc APC]",
+ "config-apcu": "Je nainstalováno [http://www.php.net/apcu APCu]",
+ "config-wincache": "Je nainstalována [http://www.iis.net/download/WinCacheForPhp WinCache]",
+ "config-no-cache-apcu": "<strong>Upozornění:</strong> Nebylo nalezeno [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache], ani [http://www.iis.net/download/WinCacheForPhp WinCache].\nKešování objektů bude vypnuto.",
+ "config-mod-security": "<strong>Upozornění:</strong> váš webový server má zapnuto [http://modsecurity.org/ mod_security]/mod_security2. Mnoho běžných konfigurací bude způsobovat potíže MediaWiki a dalším programům, které umožňují ukládat libovolný obsah.\nPokud je to možné, mělo by se to vypnout. Jinak se v případě, že narazíte na náhodné chyby, podívejte do [http://modsecurity.org/documentation/ dokumentace mod_security] nebo kontaktujte technickou podporu vašeho poskytovatele.",
+ "config-diff3-bad": "Nebyl nalezen GNU diff3.",
+ "config-git": "Nalezen software pro správu verzí Git: <code>$1</code>.",
+ "config-git-bad": "Software pro správu verzí Git nebyl nalezen.",
+ "config-imagemagick": "Nalezen ImageMagick: <code>$1</code>.\nPokud povolíte načítání souborů, bude zapnuto vytváření náhledů.",
+ "config-gd": "Nalezena vestavěná grafická knihovna GD.\nPokud povolíte načítání souborů, bude zapnuto vytváření náhledů.",
+ "config-no-scaling": "Nebyla nalezena knihovna GD ani ImageMagick.\nVytváření náhledů bude vypnuto.",
+ "config-no-uri": "<strong>Chyba:</strong> Nepodařilo se určit aktuální URI.\nInstalace přerušena.",
+ "config-no-cli-uri": "<strong>Upozornění:</strong> Nebylo uvedeno <code>--scriptpath</code>, používá se implicitní hodnota: <code>$1</code>.",
+ "config-using-server": "Použito jméno serveru „<nowiki>$1</nowiki>“.",
+ "config-using-uri": "Použito URL serveru „<nowiki>$1$2</nowiki>“.",
+ "config-uploads-not-safe": "<strong>Upozornění:</strong> Váš výchozí adresář pro načítání souborů <code>$1</code> umožňuje spouštění libovolných skriptů.\nPřestože MediaWiki všechny načítané soubory kontroluje proti bezpečnostním hrozbám, je důrazně doporučeno [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security tuto bezpečnostní díru zacelit] před povolením načítání souborů.",
+ "config-no-cli-uploads-check": "<strong>Upozornění:</strong> Váš výchozí adresář pro načítané soubory (<code>$1</code>) se při instalaci z příkazového řádku nekontroluje na bezpečnostní hrozbu provádění libovolných skriptů.",
+ "config-brokenlibxml": "Váš systém obsahuje kombinaci verzí PHP a libxml2, která je chybná a může v MediaWiki a dalších webových aplikacích způsobovat skryté poškozování dat.\nAktualizujte na libxml2 2.7.3 nebo novější ([https://bugs.php.net/bug.php?id=45996 chyba evidovaná u PHP]).\nInstalace přerušena.",
+ "config-suhosin-max-value-length": "Je nainstalován Suhosin, který omezuje délku parametrů GET na $1 bajtů.\nKomponenta ResourceLoader z MediaWiki dokáže s tímto omezením pracovat, ale sníží to výkon.\nPokud to je alespoň trochu možné, měli byste v <code>php.ini</code> nastavit <code>suhosin.get.max_value_length</code> na 1024 nebo vyšší a na stejnou hodnotu nastavit v <code>LocalSettings.php</code> proměnnou <code>$wgResourceLoaderMaxQueryLength</code>.",
+ "config-using-32bit": "<strong>Upozornění:</strong> Vypadá to, že váš systém běží s 32bitovými celými čísly. To [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit se nedoporučuje].",
+ "config-db-type": "Typ databáze:",
+ "config-db-host": "Databázový server:",
+ "config-db-host-help": "Pokud je váš databázový server na jiném počítači, zadejte zde jméno stroje nebo IP adresu.\n\nPokud používáte sdílený webový hosting, váš poskytovatel by vám měl v dokumentaci sdělit správné jméno stroje.\n\nPokud instalujete na server běžící na Windows a používáte MySQL, jméno „localhost“ nemusí fungovat. V takovém případě zkuste jako místní IP adresu zadat „127.0.0.1“.\n\nPokud používáte PostgreSQL, můžete se připojit Unixovými sockety tak, že toto pole necháte prázdné.",
+ "config-db-host-oracle": "Databázové TNS:",
+ "config-db-host-oracle-help": "Zadejte platné [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; tato instalace musí vidět soubor tnsnames.ora.<br />Pokud používáte klientské knihovny verze 10g nebo novější, můžete také používat názvy [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifikace této wiki",
+ "config-db-name": "Jméno databáze:",
+ "config-db-name-help": "Zvolte jméno, které označuje vaši wiki.\nNemělo by obsahovat mezery.\n\nPokud používáte sdílený webový hosting, váš poskytovatel vám buď sdělí konkrétní jméno databáze, nebo vás nechá vytvářet databáze pomocí nějakého ovládacího panelu.",
+ "config-db-name-oracle": "Databázové schéma:",
+ "config-db-account-oracle-warn": "Existují tři podporované možnosti pro instalaci s použitím databáze Oracle.\n\nPokud chcete v rámci instalace založit databázový účet, zadejte jako databázový účet pro instalaci účet s rolí SYSDBA a uveďte požadované údaje pro účet pro webový přístup, jinak můžete vytvořit účet pro webový přístup ručně a zadat pouze tento účet (pokud má dostatečná oprávnění k zakládání objektů schématu) nebo poskytnout dva různé účty, jeden s oprávněními k zakládání, druhý omezený pro webový přístup.\n\nSkript pro založení účtu s potřebnými privilegii můžete v této instalaci nalézt v adresáři „maintenance/oracle/“. Nezapomeňte, že použití omezeného účtu znepřístupní veškeré možnosti údržby přes implicitní účet.",
+ "config-db-install-account": "Uživatelský účet pro instalaci",
+ "config-db-username": "Databázové uživatelské jméno:",
+ "config-db-password": "Databázové heslo:",
+ "config-db-install-username": "Zadejte uživatelské jméno, které se použije pro připojení k databázi v průběhu instalace.\nToto není jméno uživatelského účtu MediaWiki; toto je uživatelské jméno k vaší databázi.",
+ "config-db-install-password": "Zadejte heslo, které se použije pro připojení k databázi v průběhu instalace.\nToto není heslo uživatelského účtu MediaWiki; toto je heslo k vaší databázi.",
+ "config-db-install-help": "Zadejte uživatelské jméno a heslo, které se použijí pro připojení k databázi v průběhu instalace.",
+ "config-db-account-lock": "Použít stejné uživatelské jméno a heslo pro běžnou činnost",
+ "config-db-wiki-account": "Uživatelský účet pro běžnou činnost",
+ "config-db-wiki-help": "Zadejte uživatelské jméno a heslo, které se bude používat pro připojení k databázi za běžného provozu wiki.\nPokud účet neexistuje a instalační účet má dostatečná oprávnění, bude tento uživatelský účet založen s minimálními oprávněními potřebnými k provozu wiki.",
+ "config-db-prefix": "Prefix databázových tabulek:",
+ "config-db-prefix-help": "Pokud potřebujete sdílet jednu databázi mezi vícero wiki, případně mezi MediaWiki a další webovou aplikací, můžete přidat k názvu každé tabulky prefix, abyste se vyhnuli konfliktům.\nNepoužívejte mezery.\n\nToto pole se zpravidla ponechává prázdné.",
+ "config-mysql-old": "Je vyžadováno MySQL $1 nebo novější. Vy máte $2.",
+ "config-db-port": "Databázový port:",
+ "config-db-schema": "Schéma pro MediaWiki:",
+ "config-db-schema-help": "Toto schéma zpravidla stačí.\nMěňte ho, jen pokud víte, že je to potřeba.",
+ "config-pg-test-error": "Nelze se připojit k databázi '''$1''': $2",
+ "config-sqlite-dir": "Adresář pro data SQLite:",
+ "config-sqlite-dir-help": "SQLite ukládá veškerá data v jediném souboru.\n\nZadaný adresář musí být v průběhu instalace být přístupný pro zápis.\n\n'''Neměl by''' být dostupný z webu, proto ho nedáváme tam, kde jsou vaše PHP soubory.\n\nInstalátor do adresáře přidá soubor <code>.htaccess</code>, ale pokud to selže, mohl by někdo získat přístup k vaší holé databázi.\nTo zahrnuje syrová uživatelská data (e-mailové adresy, hašovaná hesla), jako i smazané revize a další data s omezeným přístupem z vaší wiki.\n\nZvažte umístění databáze někam zcela jinam, například do <code>/var/lib/mediawiki/mojewiki</code>.",
+ "config-oracle-def-ts": "Implicitní tabulkový prostor:",
+ "config-oracle-temp-ts": "Dočasný tabulkový prostor:",
+ "config-type-mysql": "MySQL (nebo kompatibilní)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki podporuje následující databázové systémy:\n\n$1\n\nPokud v nabídce níže nevidíte databázový systém, který chcete použít, musíte pro zapnutí podpory následovat instrukce odkázané výše.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] je pro MediaWiki hlavní platformou a je podporováno nejlépe. MediaWiki pracuje také s [{{int:version-db-mariadb-url}} MariaDB] a [{{int:version-db-percona-url}} Percona Server], které jsou s MySQL kompatibilní. ([http://www.php.net/manual/en/mysql.installation.php Jak zkompilovat PHP s podporou MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] je populární otevřený databázový systém používaný jako alternativa k MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Jak přeložit PHP s podporou PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] je velmi dobře podporovaný odlehčený databázový systém. ([http://www.php.net/manual/en/pdo.installation.php Jak přeložit PHP s podporou SQLite], používá PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] je komerční podniková databáze. ([http://www.php.net/manual/en/oci8.installation.php Jak přeložit PHP s podporou OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] je komerční podniková databáze pro Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Jak přeložit PHP s podporou SQLSRV])",
+ "config-header-mysql": "Nastavení MySQL",
+ "config-header-postgres": "Nastavení PostgreSQL",
+ "config-header-sqlite": "Nastavení SQLite",
+ "config-header-oracle": "Nastavení Oracle",
+ "config-header-mssql": "Nastavení Microsoft SQL Serveru",
+ "config-invalid-db-type": "Chybný typ databáze",
+ "config-missing-db-name": "Musíte zadat hodnotu pro „{{int:config-db-name}}“.",
+ "config-missing-db-host": "Musíte zadat hodnotu pro „{{int:config-db-host}}“.",
+ "config-missing-db-server-oracle": "Musíte zadat hodnotu pro „{{int:config-db-host-oracle}}“.",
+ "config-invalid-db-server-oracle": "Chybné databázové TNS „$1“.\nPoužívejte buď „TNS Name“ nebo „Easy Connect“ (vizte [http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).",
+ "config-invalid-db-name": "Chybné jméno databáze „$1“.\nPoužívejte pouze ASCII písmena (a-z, A-Z), čísla (0-9), podtržítko (_) a spojovník (-).",
+ "config-invalid-db-prefix": "Chybný databázový prefix „$1“.\nPoužívejte pouze ASCII písmena (a-z, A-Z), čísla (0-9), podtržítko (_) a spojovník (-).",
+ "config-connection-error": "$1.\n\nZkontrolujte server, uživatelské jméno a heslo a zkuste to znovu.",
+ "config-invalid-schema": "Neplatné schéma pro MediaWiki „$1“.\nPoužívejte pouze ASCII písmena (a-z, A-Z), čísla (0-9) a podtržítko (_).",
+ "config-db-sys-create-oracle": "Instalátor podporuje zakládání nového účtu pouze prostřednictvím účtu SYSDBA.",
+ "config-db-sys-user-exists-oracle": "Uživatelský účet „$1“ již existuje. SYSDBA lze použít pouze pro založení nového účtu!",
+ "config-postgres-old": "Je vyžadován PostgreSQL $1 nebo novější, vy máte $2.",
+ "config-mssql-old": "Je vyžadován Microsoft SQL Server $1 nebo novější. Vy máte $2.",
+ "config-sqlite-name-help": "Zvolte jméno, které označuje vaši wiki.\nNepoužívejte mezery a spojovníky.\nPoužije se jako název souboru s daty SQLite.",
+ "config-sqlite-parent-unwritable-group": "Nelze vytvořit datový adresář <code><nowiki>$1</nowiki></code>, protože do nadřazeného adresáře <code><nowiki>$2</nowiki></code> nemá webový server právo zapisovat.\n\nInstalátor zjistil uživatele, pod kterým váš webový server běží.\nAbyste mohli pokračovat, umožněte mu zapisovat do adresáře <code><nowiki>$3</nowiki></code>.\nNa systémech Unix/Linux proveďte:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Nelze vytvořit datový adresář <code><nowiki>$1</nowiki></code>, protože do nadřazeného adresáře <code><nowiki>$2</nowiki></code> nemá webový server právo zapisovat.\n\nInstalátoru se nepodařilo zjistit uživatele, pod kterým váš webový server běží.\nAbyste mohli pokračovat, umožněte zápis do <code><nowiki>$3</nowiki></code> všem uživatelům.\nNa systémech Unix/Linux proveďte:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Chyba při vytváření datového adresáře „$1“.\nZkontrolujte umístění a zkuste to znovu.",
+ "config-sqlite-dir-unwritable": "Nelze zapisovat do adresáře „$1“.\nZměňte na něm oprávnění, aby do něj mohl webový server zapisovat, a zkuste to znovu.",
+ "config-sqlite-connection-error": "$1.\n\nZkontrolujte datový adresář a jméno databáze níže a zkuste to znovu.",
+ "config-sqlite-readonly": "Do souboru <code>$1</code> nelze zapisovat.",
+ "config-sqlite-cant-create-db": "Nepodařilo se vytvořit databázový soubor <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP neobsahuje podporu FTS3, downgradují se tabulky",
+ "config-can-upgrade": "V této databázi jsou tabulky MediaWiki.\nPokud je chcete aktualizovat na MediaWiki $1, klikněte na '''Pokračovat'''.",
+ "config-upgrade-done": "Aktualizace byla dokončena.\n\nSvou wiki teď můžete [$1 začít používat].\n\nPokud chcete přegenerovat soubor <code>LocalSettings.php</code>, klikněte na tlačítko níže.\nTo se ale '''nedoporučuje''', pokud s wiki nemáte problémy.",
+ "config-upgrade-done-no-regenerate": "Aktualizace byla dokončena.\n\nSvou wiki teď můžete [$1 začít používat].",
+ "config-regenerate": "Přegenerovat LocalSettings.php →",
+ "config-show-table-status": "Dotaz <code>SHOW TABLE STATUS</code> se nezdařil!",
+ "config-unknown-collation": "<strong>Upozornění:</strong> Databáze používá nerozpoznané řazení.",
+ "config-db-web-account": "Databázový účet pro webový přístup",
+ "config-db-web-help": "Zvolte uživatelské jméno a heslo, které bude webový server používat pro připojení k databázovému serveru při běžném provozu wiki.",
+ "config-db-web-account-same": "Použít stejný účet jako pro instalaci",
+ "config-db-web-create": "Založit účet, pokud zatím neexistuje",
+ "config-db-web-no-create-privs": "Účet uvedený pro instalaci nemá oprávnění dostatečná pro založení nového účtu.\nÚčet, který zde uvedete, již musí existovat.",
+ "config-mysql-engine": "Typ úložiště:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Upozornění:</strong> Jako typ úložiště pro MySQL jste zvolili MyISAM, které není pro použití v MediaWiki doporučeno, neboť:\n* stěží podporuje současný přístup kvůli zamykání tabulek,\n* je náchylnější na poškození dat než jiná úložiště,\n* kód MediaWiki nepodporuje MyISAM vždy tak dobře, jak by měl.\n\nPokud vaše instalace MySQL podporuje InnoDB, důrazně doporučujeme použít spíše to.\nPokud vaše instalace MySQL InnoDB nepodporuje, možná je čas na aktualizaci.",
+ "config-mysql-only-myisam-dep": "<strong>Upozornění:</strong> Jediným dostupným úložištěm dat pro MySQL je MyISAM, který se k užití s MediaWiki nedoporučuje, neboť:\n* téměř nepodporuje paralelní přístup kvůli zamykání tabulek,\n* oproti jiným formátům je náchylnější k poškození,\n* MediaWiki nepodporuje MyISAM tak dobře, jak by bylo třeba.\n\nVaše instalace MySQL nepodporuje InnoDB, možná je na čase upgradovat.",
+ "config-mysql-engine-help": "'''InnoDB''' je téměř vždy nejlepší volba, neboť má dobrou podporu současného přístupu.\n\n'''MyISAM''' může být rychlejší u instalací pro jednoho uživatele nebo jen pro čtení.\nDatabáze MyISAM bývají poškozeny častěji než databáze InnoDB.",
+ "config-mysql-charset": "Znaková sada databáze:",
+ "config-mysql-binary": "Binární",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "V '''binárním režimu''' ukládá MediaWiki text v UTF-8 do databáze v binárních sloupcích.\nTo je výkonnější než UTF-8 režim MySQL a umožňuje využít plný rozsah znaků Unicode.\n\nV '''režimu UTF-8''' bude MySQL znát znakovou sadu vašich dat a může je příslušně zobrazovat a převádět, ale neumožní vám uložit znaky mimo [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane].",
+ "config-mssql-auth": "Typ autentizace:",
+ "config-mssql-install-auth": "Zvolte si typ autentizace, který se bude používat pro připojení k databázi v průběhu instalace.\nPokud zvolíte možnost „{{int:config-mssql-windowsauth}}“, použijí se přihlašovací údaje uživatele, pod kterým běží webový server.",
+ "config-mssql-web-auth": "Zvolte si typ autentizace, který se bude používat pro připojení k databázi za běžného provozu wiki.\nPokud zvolíte možnost „{{int:config-mssql-windowsauth}}“, použijí se přihlašovací údaje uživatele, pod kterým běží webový server.",
+ "config-mssql-sqlauth": "Autentizace SQL serveru",
+ "config-mssql-windowsauth": "Windows autentizace",
+ "config-site-name": "Název wiki:",
+ "config-site-name-help": "Bude se zobrazovat v titulku prohlížeče a na dalších místech.",
+ "config-site-name-blank": "Zadejte název serveru.",
+ "config-project-namespace": "Jmenný prostor projektu:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Stejný jako název wiki: $1",
+ "config-ns-other": "Jiný (uveďte)",
+ "config-ns-other-default": "MojeWiki",
+ "config-project-namespace-help": "Po vzoru Wikipedie udržuje mnoho wiki stránky se svými pravidly odděleně od stránek s vlastním obsahem, v '''jmenném prostoru projektu'''.\nNázvy všech stránek v tomto jmenném prostoru začínají jistým prefixem, který zde můžete nastavit.\nZvykem je odvozovat tento prefix z názvu wiki, ale nesmí obsahovat jisté interpunkční znaky jako „#“ nebo „:“.",
+ "config-ns-invalid": "Uvedený jmenný prostor „<nowiki>$1</nowiki>“ je neplatný.\nZadejte jiný jmenný prostor projektu.",
+ "config-ns-conflict": "Uvedený jmenný prostor „<nowiki>$1</nowiki>“ koliduje se standardním jmenným prostorem MediaWiki.\nZadejte jiný jmenný prostor projektu.",
+ "config-admin-box": "Správcovský účet",
+ "config-admin-name": "Vaše uživatelské jméno:",
+ "config-admin-password": "Heslo:",
+ "config-admin-password-confirm": "Heslo ještě jednou:",
+ "config-admin-help": "Zde zadejte své požadované uživatelské jméno, například „Pepa Novák“.\nTímto jménem se budete do wiki hlásit.",
+ "config-admin-name-blank": "Zadejte uživatelské jméno správce.",
+ "config-admin-name-invalid": "Uvedené uživatelské jméno „<nowiki>$1</nowiki>“ není platné.\nZadejte jiné uživatelské jméno.",
+ "config-admin-password-blank": "Zadejte heslo ke správcovskému účtu.",
+ "config-admin-password-mismatch": "Uvedená hesla se neshodují.",
+ "config-admin-email": "E-mailová adresa:",
+ "config-admin-email-help": "Zde zadejte e-mailovou adresu, která vám umožní přijímat e-maily od ostatních uživatelů wiki, získat nové heslo a přijímat notifikace o změnách sledovaných stránek. Tohle pole můžete nechat prázdné.",
+ "config-admin-error-user": "Vnitřní chyba při vytváření správce se jménem „<nowiki>$1</nowiki>“.",
+ "config-admin-error-password": "Vnitřní chyba při nastavování hesla správci se jménem „<nowiki>$1</nowiki>“: <pre>$2</pre>",
+ "config-admin-error-bademail": "Zadali jste neplatnou e-mailovou adresu.",
+ "config-subscribe": "Přihlásit se k odběru [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce e-mailové konference pro oznamování nových verzí].",
+ "config-subscribe-help": "Tohle je e-mailová konference s nízkým provozem, na které se oznamují nové verze, včetně důležitých bezpečnostních oznámení.\nMěli byste se do ní přihlásit a při vydání nových verzí aktualizovat svou instalaci MediaWiki.",
+ "config-subscribe-noemail": "Pokusili jste se přihlásit k odběru e-mailové konference pro oznamování nových verzí, aniž byste poskytli e-mailovou adresu.\nPokud se chcete přihlásit k odběru, zadejte e-mailovou adresu.",
+ "config-pingback": "Sdílet údaje o této instalaci s vývojáři MediaWiki.",
+ "config-pingback-help": "Pokud zaškrtnete tuto volbu, bude MediaWiki pravidelně zasílat základní údaje této instance MediaWiki na https://www.mediawiki.org. Tyto údaje zahrnují například typ systému, verzi PHP a zvolené databázové úložiště. Nadace Wikimedia sdílí tato data s vývojáři MediaWiki, aby pomohla směrovat budoucí rozvoj. Pro váš systém budou zaslány tyto údaje:\n<pre>$1</pre>",
+ "config-almost-done": "Už jsme skoro hotovi!\nZbývající konfiguraci už můžete přeskočit a nainstalovat wiki hned teď.",
+ "config-optional-continue": "Ptejte se mě dál.",
+ "config-optional-skip": "Už mě to nudí, prostě nainstalujte wiki.",
+ "config-profile": "Profil uživatelských práv:",
+ "config-profile-wiki": "Otevřená wiki",
+ "config-profile-no-anon": "Vyžadována registrace uživatelů",
+ "config-profile-fishbowl": "Editace jen pro vybrané",
+ "config-profile-private": "Soukromá wiki",
+ "config-profile-help": "Wiki fungují nejlépe, když je necháte editovat co největším možným počtem lidí.\nV MediaWiki můžete snadno kontrolovat poslední změny a vracet zpět libovolnou škodu způsobenou hloupými nebo zlými uživateli.\n\nMnoho lidí však zjistilo, že je MediaWiki užitečné v širokém spektru rolí a někdy není snadné všechny přesvědčit o výhodách wikizvyklostí.\nTakže si můžete vybrat.\n\nModel '''{{int:config-profile-wiki}}''' dovoluje editovat všem, aniž by se museli přihlašovat.\nNa wiki, kde je '''{{int:config-profile-no-anon}}''', se lépe řídí zodpovědnost, ale může to odradit náhodné přispěvatele.\n\nProfil '''{{int:config-profile-fishbowl}}''' umožňuje schváleným uživatelům editovat, ale veřejnost si může stránky prohlížet včetně jejich historie.\n'''{{int:config-profile-private}}''' dovoluje stránky prohlížet jen schváleným uživatelům, kteří je i mohou editovat.\n\nPo instalaci je možná komplexní konfigurace uživatelských práv; vizte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights odpovídající stránku příručky].",
+ "config-license": "Autorská práva a licence:",
+ "config-license-none": "Bez patičky s licencí",
+ "config-license-cc-by-sa": "Creative Commons Uveďte autora-Zachovejte licenci",
+ "config-license-cc-by": "Creative Commons Uveďte autora",
+ "config-license-cc-by-nc-sa": "Creative Commons Uveďte autora-Nevyužívejte dílo komerčně-Zachovejte licenci",
+ "config-license-cc-0": "Creative Commons Zero (volné dílo)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 nebo novější",
+ "config-license-pd": "Volné dílo",
+ "config-license-cc-choose": "Zvolit vlastní licenci Creative Commons",
+ "config-license-help": "Mnoho veřejných wiki všechny příspěvky zveřejňuje pod některou [http://freedomdefined.org/Definition/Cs svobodnou licencí].\nTo pomáhá vytvořit duch komunitního vlastnictví a povzbuzuje dlouhodobé přispívání.\nTo obecně není potřeba u soukromé nebo firemní wiki.\n\nPokud chcete být schopni používat text z Wikipedie a chcete, aby Wikipedie byla schopna přijímat text okopírovaný z vaší wiki, měli byste zvolit <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nDříve Wikipedie používala GNU Free Documentation License.\nGFDL je platná licence, ale složité jí porozumět.\nTaké je komplikované používat obsah licencovaný pod GFDL.",
+ "config-email-settings": "Nastavení e-mailu",
+ "config-enable-email": "Zapnout odchozí e-mail",
+ "config-enable-email-help": "Pokud chcete, aby e-mail fungoval, je potřeba správně nakonfigurovat [http://www.php.net/manual/en/mail.configuration.php e-mailová nastavení PHP].\nPokud nechcete žádné e-mailové funkce, můžete je zde vypnout.",
+ "config-email-user": "Umožnit vzájemné e-maily mezi uživateli",
+ "config-email-user-help": "Umožní všem uživatelům posílat si navzájem e-maily, pokud si to zapnout v uživatelském nastavení.",
+ "config-email-usertalk": "Umožnit notifikace k uživatelským diskusím",
+ "config-email-usertalk-help": "Umožní uživatelům přijímat notifikace o změnách uživatelských diskusí, pokud si to zapnou v nastavení.",
+ "config-email-watchlist": "Umožnit notifikace ke sledovaným stránkám",
+ "config-email-watchlist-help": "Umožní uživatelům přijímat notifikace o změnách sledovaných stránek, pokud si to zapnou v nastavení.",
+ "config-email-auth": "Zapnout ověřování e-mailů",
+ "config-email-auth-help": "Pokud je tato volba vybrána, uživatelé musí potvrdit svou e-mailovou adresu pomocí odkazu, který je jim poslán, kdykoli si ji nastaví nebo změní.\nJen potvrzené e-mailové adresy mohou přijímat e-maily od ostatních uživatelů a e-maily s notifikacemi o změnách.\nNastavení této volby je '''doporučeno''' pro veřejné wiki kvůli možnosti zneužití e-mailových funkcí.",
+ "config-email-sender": "Návratová e-mailová adresa:",
+ "config-email-sender-help": "Zadejte e-mailovou adresu, která se má použít jako návratová na odchozích e-mailech.\nSem budou zasílány nedoručitelné zprávy.\nMnoho mailových serverů vyžaduje, aby byla přinejmenším část s doménovým jménem platná.",
+ "config-upload-settings": "Obrázky a načítání souborů",
+ "config-upload-enable": "Povolit načítání souborů",
+ "config-upload-help": "Načítání souborů potenciálně vystavuje váš server bezpečnostním rizikům.\nVíce informací naleznete v [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security části o bezpečnosti] v příručce.\n\nPro umožnění načítání souborů změňte práva na podadresáři <code>images</code> pod kořenovým adresářem MediaWiki, aby do něj mohl webový server zapisovat.\nPoté zapněte tuto volbu.",
+ "config-upload-deleted": "Adresář pro smazané soubory:",
+ "config-upload-deleted-help": "Zvolte adresář, do kterého se mají archivovat smazané soubory.\nTento adresář by ideálně neměl být dostupný z webu.",
+ "config-logo": "URL loga:",
+ "config-logo-help": "Základní vzhled MediaWiki zahrnuje místo pro logo o velikosti 135×160 pixelů nad bočním menu.\nNačtěte obrázek odpovídající velikosti a zadejte sem jeho URL.\n\nPokud je vaše logo umístěno relativně vůči <code>$wgStylePath</code> nebo <code>$wgScriptPath</code>, můžete zde tyto proměnné použít.\n\nPokud logo nechcete, ponechte toto pole prázdné.",
+ "config-instantcommons": "Zapnout Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] je funkce, která umožňuje wiki používat obrázky, zvuky a další mediální soubory ze serveru [https://commons.wikimedia.org/wiki/Hlavn%C3%AD_strana Wikimedia Commons].\nAby to bylo možné, potřebuje mít MediaWiki přístup k internetu.\n\nVíce informací o této funkci, včetně instrukcí, jak ji nastavit pro jiné wiki než Wikimedia Commons, najdete v [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos příručce].",
+ "config-cc-error": "Volič licence Creative Commons nevrátil žádný výsledek.\nZadejte název licence ručně.",
+ "config-cc-again": "Zvolit znovu…",
+ "config-cc-not-chosen": "Zvolte si požadovanou licenci Creative Commons a klikněte na tlačítko.",
+ "config-advanced-settings": "Pokročilá konfigurace",
+ "config-cache-options": "Nastavení cachování objektů:",
+ "config-cache-help": "Cachování objektů se používá pro vylepšení rychlosti MediaWiki tím, že se cachují často používaná data.\nStředním až velkým serverům se jeho zapnutí důrazně doporučuje, i menší servery pocítí jeho výhody.",
+ "config-cache-none": "Bez cachování (o žádnou funkcionalitu nepřijdete, na větších wiki však může dojít ke zhoršení rychlosti)",
+ "config-cache-accel": "Cachování PHP objektů (APC, APCu, XCache nebo WinCache)",
+ "config-cache-memcached": "Použít Memcached (vyžaduje další nastavení a konfiguraci)",
+ "config-memcached-servers": "Servery Memcached:",
+ "config-memcached-help": "Seznam IP adres, které se mají používat pro Memcached.\nUveďte jednu na řádek spolu s portem. Například:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Jako typ cache jste zvolili Memcached, ale neuvedli jste žádné servery.",
+ "config-memcache-badip": "Zadali jste neplatnou IP adresu pro Memcached: $1.",
+ "config-memcache-noport": "Nezadali jste port serveru Memcached: $1.\nPokud port neznáte, implicitní je 11211.",
+ "config-memcache-badport": "Čísla portů pro Memcached by měla být mezi $1 a $2.",
+ "config-extensions": "Rozšíření",
+ "config-extensions-help": "Výše uvedená rozšíření byla nalezena ve vašem adresáři <code>./extensions</code>.\n\nMohou vyžadovat dodatečnou konfiguraci, ale teď je můžete povolit.",
+ "config-skins": "Vzhledy",
+ "config-skins-help": "Ve vašem adresáři <code>./skins</code> byly nalezeny výše uvedené vzhledy. Musíte nejméně jeden z nich povolit a některý vybrat jako výchozí.",
+ "config-skins-use-as-default": "Tento vzhled používat jako výchozí",
+ "config-skins-missing": "Nebyly nalezeny žádné vzhledy; MediaWiki bude používat nouzový vzhled, dokud nenainstalujete nějaké plnohodnotné.",
+ "config-skins-must-enable-some": "Musíte povolit alespoň jeden vzhled.",
+ "config-skins-must-enable-default": "Vzhled vybraný jako výchozí musí být povolen.",
+ "config-install-alreadydone": "'''Upozornění:''' Vypadá to, že jste MediaWiki již nainstalovali a teď se o to pokoušíte znovu.\nPokračujte na další stránku.",
+ "config-install-begin": "Stisknutím „{{int:config-continue}}“ spustíte instalaci MediaWiki.\nPokud ještě chcete udělat nějaké změny, stiskněte „{{int:config-back}}“.",
+ "config-install-step-done": "hotovo",
+ "config-install-step-failed": "selhaly",
+ "config-install-extensions": "Vkládají se rozšíření",
+ "config-install-database": "Připravuje se databáze",
+ "config-install-schema": "Vytváří se schéma",
+ "config-install-pg-schema-not-exist": "Schéma PostgreSQL neexistuje.",
+ "config-install-pg-schema-failed": "Založení tabulek se nezdařilo.\nUjistěte se, že uživatel „$1“ může zapisovat do schématu „$2“.",
+ "config-install-pg-commit": "Potvrzují se změny",
+ "config-install-pg-plpgsql": "Kontroluje se jazyk PL/pgSQL",
+ "config-pg-no-plpgsql": "Musíte do databáze $1 nainstalovat jazyk PL/pgSQL",
+ "config-pg-no-create-privs": "Účet zadaný pro instalaci nemá oprávnění k založení uživatelského účtu.",
+ "config-pg-not-in-role": "Účet zadaný pro webového uživatele již existuje\nÚčet zadaný pro instalaci není superuživatelský a není členem role webového uživatele, takže nemůže zakládat objekty vlastněné webovým uživatelem.\n\nMediaWiki v současné době vyžaduje, aby byl vlastníkem tabulek webový uživatel. Uveďte jiný název účtu webového uživatele nebo klikněte na „zpět“ a zadejte instalačního uživatele s odpovídajícími oprávněními.",
+ "config-install-user": "Vytváří se databázový uživatel",
+ "config-install-user-alreadyexists": "Uživatel „$1“ už existuje",
+ "config-install-user-create-failed": "Vytváření uživatele „$1“ selhalo: $2",
+ "config-install-user-grant-failed": "Uživateli „$1“ se nepodařilo přidělit oprávnění: $2",
+ "config-install-user-missing": "Zadaný uživatel „$1“ neexistuje.",
+ "config-install-user-missing-create": "Zadaný uživatel „$1“ neexistuje.\nPokud ho chcete založit, zaškrtněte možnost „založit účet“ níže.",
+ "config-install-tables": "Vytvářejí se tabulky",
+ "config-install-tables-exist": "'''Upozornění''': Vypadá to, že tabulky MediaWiki již existují.\nPřeskakuje se jejich zakládání.",
+ "config-install-tables-failed": "'''Chyba''': Vytvoření tabulek selhalo s následující chybou: $1",
+ "config-install-interwiki": "Tabulka interwiki se plní implicitními položkami",
+ "config-install-interwiki-list": "Nelze přečíst soubor <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Upozornění''': Vypadá to, že tabulka interwiki již obsahuje nějaké záznamy.\nPřeskakuje se implicitní seznam.",
+ "config-install-stats": "Inicializují se statistiky",
+ "config-install-keys": "Vytvářejí se tajné klíče",
+ "config-insecure-keys": "'''Upozornění:''' {{PLURAL:$2|Tajný klíč|Tajné klíče}} ($1) vytvořené v průběhu instalace {{PLURAL:$2|není|nejsou}} zcela {{PLURAL:$2|bezpečný|bezpečné}}. Zvažte {{PLURAL:$2|jeho|jejich}} ruční změnu.",
+ "config-install-updates": "Ruší se spuštění nepotřebných aktualizací",
+ "config-install-updates-failed": "<strong>Chyba:</strong> Vložení aktualizačních klíčů do tabulek selhalo s následující chybou: $1",
+ "config-install-sysop": "Zakládá se uživatelský účet správce",
+ "config-install-subscribe-fail": "Nelze se přihlásit k odběru mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "Není nainstalován cURL a není dostupné <code>allow_url_fopen</code>.",
+ "config-install-mainpage": "Vytváří se počáteční obsah hlavní strany",
+ "config-install-mainpage-exists": "Hlavní strana již existuje, přeskakuji.",
+ "config-install-extension-tables": "Vytvářejí se tabulky pro zapnutá rozšíření",
+ "config-install-mainpage-failed": "Nepodařilo se vložit hlavní stranu: $1",
+ "config-install-done": "<strong>Gratulujeme!</strong>\nNainstalovali jste MediaWiki.\n\nInstalátor vytvořil soubor <code>LocalSettings.php</code>.\nTen obsahuje veškerou vaši konfiguraci.\n\nBudete si ho muset stáhnout a uložit do základního adresáře vaší instalace wiki (do stejného adresáře jako soubor index.php). Stažení souboru se mělo spustit automaticky.\n\nPokud se vám stažení nenabídlo nebo jste ho zrušili, můžete ho spustit znovu kliknutím na následující odkaz:\n\n$3\n\n<strong>Poznámka</strong>: Pokud to neuděláte hned, tento vygenerovaný konfigurační soubor nebude později dostupný, pokud instalaci opustíte, aniž byste si ho stáhli.\n\nAž to dokončíte, můžete <strong>[$2 vstoupit do své wiki]</strong>.",
+ "config-install-done-path": "<strong>Gratulujeme!</strong>\nNainstalovali jste MediaWiki.\n\nInstalátor vytvořil soubor <code>LocalSettings.php</code>.\nTen obsahuje veškerou vaši konfiguraci.\n\nBudete si ho muset stáhnout a uložit do <code>$4</code>. Stažení souboru se mělo spustit automaticky.\n\nPokud se vám stažení nenabídlo nebo jste ho zrušili, můžete ho spustit znovu kliknutím na následující odkaz:\n\n$3\n\n<strong>Poznámka:</strong> Pokud to neuděláte hned, tento vygenerovaný konfigurační soubor nebude později dostupný, pokud instalaci opustíte, aniž byste si ho stáhli.\n\nAž to dokončíte, můžete <strong>[$2 vstoupit do své wiki]</strong>.",
+ "config-download-localsettings": "Stáhnout <code>LocalSettings.php</code>",
+ "config-help": "nápověda",
+ "config-help-tooltip": "rozbalíte kliknutím",
+ "config-nofile": "Soubor „$1“ nelze nalézt. Byl smazán?",
+ "config-extension-link": "Věděli jste, že vaše wiki podporuje [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions rozšíření]?\n\nMůžete si prohlédnout [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category seznam rozšíření po kategoriích].",
+ "config-skins-screenshots": "$1 (snímky obrazovky: $2)",
+ "config-screenshot": "snímek obrazovky",
+ "mainpagetext": "<strong>MediaWiki byla úspěšně nainstalována.</strong>",
+ "mainpagedocfooter": "[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Uživatelská příručka] vám napoví, jak používat MediaWiki.\n\n== Začínáme ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Nastavení konfigurace]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Často kladené otázky o MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce E-mailová konference oznámení MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Překlad MediaWiki do vašeho jazyka]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Naučte se bojovat se spamem na vaší wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/csb.json b/www/wiki/includes/installer/i18n/csb.json
new file mode 100644
index 00000000..defaf1bb
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/csb.json
@@ -0,0 +1,64 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kaszeba"
+ ]
+ },
+ "config-desc": "Jinstalownik MediaWiki",
+ "config-title": "Jinstalowanié MediaWiki $1",
+ "config-information": "Wëdowiédzô",
+ "config-localsettings-key": "Klucz aktualizacëji:",
+ "config-localsettings-badkey": "Lëchi klucz aktualizacëji.",
+ "config-session-error": "Fela zrëszeniô sesëji – $1",
+ "config-your-language": "Twój jãzëk:",
+ "config-your-language-help": "Wybierzë jãzëk procesu jinstalacëji.",
+ "config-wiki-language": "Jãzëk wiki:",
+ "config-back": "← Nazôd",
+ "config-continue": "Dali →",
+ "config-page-language": "Jãzëk",
+ "config-page-welcome": "Witómë w MediaWiki!",
+ "config-page-dbconnect": "Sparłãczë z bazą pòdôwków",
+ "config-page-upgrade": "Zaktualnienié jinstalacëji",
+ "config-page-dbsettings": "Nastôw bazë pòdôwków",
+ "config-page-name": "Miono",
+ "config-page-options": "Òptacëje",
+ "config-page-install": "Wjinstalëjë",
+ "config-page-complete": "Fardich!",
+ "config-page-restart": "Zrëszë jinstalacëjã znowa",
+ "config-page-readme": "Spòdlowô wëdowiédzô",
+ "config-page-releasenotes": "Wëdowiédzô ò wersëji",
+ "config-page-copying": "Kòpérowanié",
+ "config-page-upgradedoc": "Zaktualnienié",
+ "config-page-existingwiki": "Egyzstëjącô wiki",
+ "config-restart": "Jo, zrëszë znowa",
+ "config-env-php": "PHP $1 je wjinastalowóné",
+ "config-env-hhvm": "HHVM $1 je wjinastalowóné",
+ "config-memory-raised": "Paraméter PHP <code>memory_limit</code> $1 òstôł zwikszony do $2.",
+ "config-xcache": "[Http://trac.lighttpd.net/xcache/ XCache] je wjinstalowóny",
+ "config-apc": "[Http://www.php.net/apc APC] je wjinstalowóny",
+ "config-apcu": "[http://www.php.net/apcu APCu] je wjinstalowóny",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] je wjinstalowóny",
+ "config-diff3-bad": "Felënk GNU diff3.",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "binarny",
+ "config-mysql-utf8": "UTF‐8",
+ "config-site-name": "Miono wiki:",
+ "config-site-name-blank": "Wpiszë miono starnów.",
+ "config-ns-other-default": "MòjôWiki",
+ "config-admin-box": "Kònto sprôwnika",
+ "config-admin-name": "Twòjé miono brëkòwnika:",
+ "config-admin-password": "Parola:",
+ "config-admin-password-confirm": "Pòwtórzë parolã:",
+ "config-admin-name-blank": "Wpiszë miono brëkòwnika, chtëren bãdze sprôwnikã.",
+ "config-admin-email": "E-mailowô adresa:",
+ "config-license-none": "Felënk stopczi z licencëją",
+ "config-email-settings": "Nastôwë e-mail",
+ "config-logo": "Adresa URL logo:",
+ "config-skins": "Wëzdrzatk",
+ "config-skins-use-as-default": "Ùżëjë tegò wëzdrzatkù za domëslny",
+ "config-install-step-done": "fardich",
+ "config-install-step-failed": "nie dzrzëło sã",
+ "config-help": "pòmòc",
+ "mainpagetext": "<strong>MediaWiki òsta wjinstalowónô.<strong>"
+}
diff --git a/www/wiki/includes/installer/i18n/cu.json b/www/wiki/includes/installer/i18n/cu.json
new file mode 100644
index 00000000..0c69d275
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/cu.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "ОйЛ"
+ ]
+ },
+ "config-information": "плирофорїꙗ",
+ "config-your-language": "твои ѩꙁꙑкъ :",
+ "config-page-language": "ѩꙁꙑкъ",
+ "config-page-name": "имѧ",
+ "config-page-options": "строи",
+ "config-page-complete": "ꙁаврьшєно ѥстъ",
+ "config-help": "помощь"
+}
diff --git a/www/wiki/includes/installer/i18n/cv.json b/www/wiki/includes/installer/i18n/cv.json
new file mode 100644
index 00000000..9d84f95c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/cv.json
@@ -0,0 +1,17 @@
+{
+ "@metadata": {
+ "authors": [
+ "Seb35",
+ "Chuvash2014"
+ ]
+ },
+ "config-information": "Информаци",
+ "config-your-language": "Сирĕн чĕлхӳ:",
+ "config-wiki-language": "Вики чĕлхе:",
+ "config-back": "← Кутăн",
+ "config-continue": "Малалла →",
+ "config-page-language": "Чĕлхе",
+ "config-page-name": "Ят",
+ "mainpagetext": "'''«MediaWiki» вики-движока лартасси ăнăçлă вĕçленчĕ.'''",
+ "mainpagedocfooter": "Ку википе ĕçлеме пулăшакан информацине [https://meta.wikimedia.org/wiki/Help:Contents/ru усăç руководствинче] тупма пултаратăр.\n\n== Пулăшма пултарĕç ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Ĕнерлевсен списокĕ];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki тăрăх час-часах ыйтакан ыйтусемпе хуравсем];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki çĕнĕ верси тухнине пĕлтерекен рассылка].\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/cy.json b/www/wiki/includes/installer/i18n/cy.json
new file mode 100644
index 00000000..2a3a560a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/cy.json
@@ -0,0 +1,15 @@
+{
+ "@metadata": {
+ "authors": [
+ "Lloffiwr",
+ "Xxglennxx",
+ "Robin Owain"
+ ]
+ },
+ "config-desc": "Y gosodwr ar gyfer MediaWiki",
+ "config-title": "Gosod MediaWiki $1",
+ "config-information": "Gwybodaeth",
+ "config-localsettings-upgrade": "Rydym wedi canfod ffeil <code>LocalSettings.php</code>.\nI uwchraddio'r gosodiad yma, rhowch fanylion y<code>$wgUpgradeKey</code> yn y blwch isod.\nFe'i cewch yn <code>LocalSettings.php</code>.",
+ "mainpagetext": "'''Wedi llwyddo gosod meddalwedd MediaWiki yma'''",
+ "mainpagedocfooter": "\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Rhestr osodiadau wrth gyflunio]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Cwestiynau poblogaidd ar MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lleoleiddiwch MediaWiki ar gyfer eich iaith]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Dysgwch sut i ymladd sbam ar eich wici]"
+}
diff --git a/www/wiki/includes/installer/i18n/da.json b/www/wiki/includes/installer/i18n/da.json
new file mode 100644
index 00000000..2afd2ca5
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/da.json
@@ -0,0 +1,83 @@
+{
+ "@metadata": {
+ "authors": [
+ "Peter Alberti",
+ "Christian List",
+ "Tjernobyl",
+ "Thomsen",
+ "MGA73",
+ "Mads Haupt",
+ "Joedalton"
+ ]
+ },
+ "config-desc": "Installationsprogrammet til MediaWiki",
+ "config-title": "Installation af MediaWiki $1",
+ "config-information": "Information",
+ "config-localsettings-upgrade": "En <code>LocalSettings.php</code>-fil er blevet fundet.\nFor at opgradere imstallationen, skriv venligst værdien af <code>$wgUpgradeKey</code> i boksen nedenfor.\nDu finder denne i <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "En <code>LocalSettings.php</code>-fil er blevet fundet.\nFor at opgradere installationen skal du køre <code>update.php</code> i stedet for",
+ "config-localsettings-key": "Opgraderingsnøgle:",
+ "config-localsettings-badkey": "Den indtastede opgraderingsnøgle er forkert.",
+ "config-upgrade-key-missing": "En eksisterende installation af MediaWiki er blevet fundet.\nFor at opgradere denne installation skal du tilføje følgende linje i bunden af din <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Den eksisterende <code>LocalSettings.php</code> ser ud til at være ufuldstændig.\nVariablen $1 er ikke angivet.\nÆndr venligst <code>LocalSettings.php</code> så denne variabel er angivet, og klik på »{{int:Config-continue}}«.",
+ "config-localsettings-connection-error": "Der opstod en fejl under tilslutningen til databasen med indstillingerne angivet i <code>LocalSettings.php</code>. Ret venligst disse indstillinger og prøv igen.\n\n$1",
+ "config-session-error": "Der opstod en fejl under start af session: $1",
+ "config-your-language": "Dit sprog:",
+ "config-your-language-help": "Vælg et sprog som du vil bruge under installationen.",
+ "config-wiki-language": "Wiki-sprog:",
+ "config-back": "← Tilbage",
+ "config-continue": "Fortsæt →",
+ "config-page-language": "Sprog",
+ "config-page-welcome": "Velkommen til MediaWiki!",
+ "config-page-dbconnect": "Forbind til database",
+ "config-page-upgrade": "Opgrader eksisterende installation",
+ "config-page-dbsettings": "Databaseindstillinger",
+ "config-page-name": "Navn",
+ "config-page-options": "Indstillinger",
+ "config-page-install": "Installer",
+ "config-page-complete": "Færdig!",
+ "config-page-restart": "Genstarte installation",
+ "config-page-readme": "Læs mig",
+ "config-page-releasenotes": "Udgivelsesnoter",
+ "config-page-copying": "Kopiering",
+ "config-page-upgradedoc": "Opgraderer",
+ "config-page-existingwiki": "Eksisterende wiki",
+ "config-help-restart": "Vil du rydde alle gemte data, du har indtastet og genstarte installationen?",
+ "config-restart": "Ja, genstart den",
+ "config-env-php": "PHP $1 er installeret.",
+ "config-db-type": "Databasetype:",
+ "config-db-host": "Databasevært:",
+ "config-db-name": "Databasenavn:",
+ "config-db-install-account": "Brugerkonto for installation",
+ "config-db-username": "Databasens brugernavn:",
+ "config-db-password": "Databasens adgangskode:",
+ "config-db-install-username": "Indtast brugernavnet som vil blive brugt til at forbinde til databasen under installationsprocessen.\nDette er ikke brugernavnet for MediaWiki-kontoen; det er brugernavnet på din database.",
+ "config-mysql-old": "MySQL $1 eller nyere kræves. Du har $2.",
+ "config-type-mssql": "Microsoft SQL-server",
+ "config-header-mysql": "MySQL-indstillinger",
+ "config-header-postgres": "PostgreSQL-indstillinger",
+ "config-header-sqlite": "SQLite-indstillinger",
+ "config-header-oracle": "Oracle-indstillinger",
+ "config-invalid-db-type": "Ugyldig databasetype",
+ "config-mssql-windowsauth": "Windows-godkendelse",
+ "config-site-name": "Navn på wiki:",
+ "config-site-name-blank": "Indtast et hjemmesidenavn.",
+ "config-ns-generic": "Projekt",
+ "config-admin-box": "Administratorkonto",
+ "config-admin-name": "Dit brugernavn:",
+ "config-admin-password": "Adgangskode:",
+ "config-admin-password-confirm": "Tast adgangskoden igen:",
+ "config-admin-email": "E-postadresse:",
+ "config-optional-continue": "Stil mig flere spørgsmål.",
+ "config-profile-wiki": "Åbn wiki",
+ "config-profile-no-anon": "Kontooprettelse er krævet",
+ "config-profile-fishbowl": "Kun godkendte redaktører",
+ "config-profile-private": "Privat wiki",
+ "config-license": "Ophavsret og licens:",
+ "config-license-pd": "Offentlig ejendom",
+ "config-email-usertalk": "Aktiver notifikationer for brugerdiskussionsside",
+ "config-upload-deleted": "Mappe for slettede filer:",
+ "config-help": "hjælp",
+ "config-help-tooltip": "klik for at udvide",
+ "mainpagetext": "'''MediaWiki er nu installeret.'''",
+ "mainpagedocfooter": "Se [https://meta.wikimedia.org/wiki/Help:Contents brugervejledningen] for oplysninger om brugen af wikiprogrammellet.\n\n== At komme i gang ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Listen over opsætningsmuligheder]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki ofte stillede spørgsmål]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Postliste angående udgivelser af MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Oversæt MediaWiki til dit sprog]"
+}
diff --git a/www/wiki/includes/installer/i18n/de-ch.json b/www/wiki/includes/installer/i18n/de-ch.json
new file mode 100644
index 00000000..fe4aa4c1
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/de-ch.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "Geitost",
+ "Das Schäfchen"
+ ]
+ },
+ "config-copyright": "=== Lizenz und Nutzungsbedingungen ===\n\n$1\n\nDieses Programm ist freie Software, d. h. es kann, gemäss den Bedingungen der von der Free Software Foundation veröffentlichten ''GNU General Public License'', weiterverteilt und/oder modifiziert werden. Dabei kann die Version 2, oder nach eigenem Ermessen, jede neuere Version der Lizenz verwendet werden.\n\nDieses Programm wird in der Hoffnung verteilt, dass es nützlich sein wird, allerdings '''ohne jegliche Garantie''' und sogar ohne die implizierte Garantie einer '''Marktgängigkeit''' oder '''Eignung für einen bestimmten Zweck'''. Hierzu sind weitere Hinweise in der ''GNU General Public License'' enthalten.\n\nEine <doclink href=Copying>Kopie der GNU General Public License</doclink> sollte zusammen mit diesem Programm verteilt worden sein. Sofern dies nicht der Fall war, kann eine Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich angefordert oder auf deren Website [http://www.gnu.org/copyleft/gpl.html online gelesen] werden.",
+ "config-unicode-pure-php-warning": "'''Warnung:''' Die [http://pecl.php.net/intl PECL-Erweiterung intl] ist für die Unicode-Normalisierung nicht verfügbar, so dass stattdessen die langsame pure-PHP-Implementierung genutzt wird.\nSofern eine Website mit grosser Benutzeranzahl betrieben wird, sollten weitere Informationen auf der Webseite [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-Normalisierung (en)] gelesen werden.",
+ "config-uploads-not-safe": "'''Warnung:''' Das Standardverzeichnis für hochgeladene Dateien <code>$1</code> ist für die willkürliche Ausführung von Skripten anfällig.\nObwohl MediaWiki die hochgeladenen Dateien auf Sicherheitsrisiken überprüft, wird dennoch dringend empfohlen, diese [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security Sicherheitslücke] zu schliessen, bevor das Hochladen von Dateien aktiviert wird.",
+ "config-license-help": "Viele öffentliche Wikis publizieren alle Beiträge unter einer [http://freedomdefined.org/Definition/De freien Lizenz.]\nDies trägt dazu bei, ein Gefühl von Gemeinschaft zu schaffen, und ermutigt zu längerfristiger Mitarbeit.\nHingegen ist im Allgemeinen eine freie Lizenz auf geschlossenen Wikis nicht notwendig.\n\nSofern man Texte aus der Wikipedia verwenden möchte und umgekehrt, sollte die ''Creative-Commons''-Lizenz „Namensnennung – Weitergabe unter gleichen Bedingungen“ gewählt werden.\n\nDie Wikipedia nutzte vormals die GNU-Lizenz für freie Dokumentation (GFDL).\nDie GFDL ist eine gültige Lizenz, die allerdings schwer zu verstehen ist.\nEs ist zudem schwierig, gemäss dieser Lizenz lizenzierte Inhalte wiederzuverwenden."
+}
diff --git a/www/wiki/includes/installer/i18n/de-formal.json b/www/wiki/includes/installer/i18n/de-formal.json
new file mode 100644
index 00000000..573e8b25
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/de-formal.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "MichaelFrey",
+ "Kghbln",
+ "Umherirrender"
+ ]
+ },
+ "config-welcome": "=== Prüfung der Installationsumgebung ===\nDie Basisprüfungen werden jetzt durchgeführt, um festzustellen, ob die Installationsumgebung für MediaWiki geeignet ist.\nNotieren Sie diese Informationen und geben Sie sie an, sofern Sie Hilfe beim Installieren benötigen.",
+ "config-extension-link": "Wussten Sie, dass Ihr Wiki die Nutzung von [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions Erweiterungen] unterstützt?\n\nSie können [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category Erweiterungen nach Kategorie] durchsuchen oder die [https://www.mediawiki.org/wiki/Extension_Matrix Matrix der Erweiterungen] ansehen, um eine Übersicht zu verfügbaren Erweiterungen zu erhalten.",
+ "mainpagedocfooter": "Hilfe zur Benutzung und Konfiguration der Wiki-Software finden Sie im [https://meta.wikimedia.org/wiki/Help:Contents Benutzerhandbuch].\n\n== Starthilfen ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste der Konfigurationsvariablen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailingliste neuer MediaWiki-Versionen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokalisieren Sie MediaWiki für Ihre Sprache]"
+}
diff --git a/www/wiki/includes/installer/i18n/de.json b/www/wiki/includes/installer/i18n/de.json
new file mode 100644
index 00000000..ca649dca
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/de.json
@@ -0,0 +1,333 @@
+{
+ "@metadata": {
+ "authors": [
+ "Geitost",
+ "Kghbln",
+ "LWChris",
+ "Metalhead64",
+ "Purodha",
+ "Rillke",
+ "The Evil IP address",
+ "Umherirrender",
+ "Wikinaut",
+ "아라",
+ "Se4598",
+ "Suriyaa Kudo",
+ "Das Schäfchen",
+ "Florian",
+ "Macofe",
+ "ThePiscin"
+ ]
+ },
+ "config-desc": "Das MediaWiki-Installationsprogramm",
+ "config-title": "Installation von MediaWiki $1",
+ "config-information": "Informationen",
+ "config-localsettings-upgrade": "Eine Datei <code>LocalSettings.php</code> wurde gefunden.\nUm die vorhandene Installation aktualisieren zu können, muss der Wert des Parameters <code>$wgUpgradeKey</code> im folgenden Eingabefeld angegeben werden.\nDer Parameterwert befindet sich in der Datei <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Eine Datei <code>LocalSettings.php</code> wurde gefunden.\nUm die vorhandene Installation zu aktualisieren, muss die Datei <code>update.php</code> ausgeführt werden.",
+ "config-localsettings-key": "Aktualisierungsschlüssel:",
+ "config-localsettings-badkey": "Der angegebene Aktualisierungsschlüssel ist falsch.",
+ "config-upgrade-key-missing": "Eine MediaWiki-Installation wurde gefunden.\nUm die vorhandene Installation aktualisieren zu können, muss die unten angegebene Codezeile an das Ende der Datei <code>LocalSettings.php</code> eingefügt werden:\n\n$1",
+ "config-localsettings-incomplete": "Die vorhandene Datei <code>LocalSettings.php</code> scheint unvollständig zu sein.\nDie Variable <code>$1</code> wurde nicht definiert.\nDie Datei <code>LocalSettings.php</code> muss entsprechend geändert werden, so dass sie definiert ist. Klicke danach auf „{{int:Config-continue}}“.",
+ "config-localsettings-connection-error": "Beim Verbindungsversuch zur Datenbank ist, unter Verwendung der in der Datei <code>LocalSettings.php</code> hinterlegten Einstellungen, ein Fehler aufgetreten. Diese Einstellungen müssen korrigiert werden. Danach kann ein erneuter Versuch unternommen werden.\n\n$1",
+ "config-session-error": "Fehler beim Starten der Sitzung: $1",
+ "config-session-expired": "Die Sitzungsdaten scheinen abgelaufen zu sein.\nSitzungen sind für einen Zeitraum von $1 konfiguriert.\nDieser kann durch Anhebung des Parameters <code>session.gc_maxlifetime</code> in der Datei <code>php.ini</code> erhöht werden.\nDen Installationsvorgang erneut starten.",
+ "config-no-session": "Die Sitzungsdaten sind verloren gegangen!\nDie Datei <code>php.ini</code> muss geprüft und es muss dabei sichergestellt werden, dass der Parameter <code>session.save_path</code> auf das richtige Verzeichnis verweist.",
+ "config-your-language": "Sprache während des Installierens:",
+ "config-your-language-help": "Bitte die Sprache auswählen, die während des Installationsvorgangs verwendet werden soll.",
+ "config-wiki-language": "Sprache des Wikis:",
+ "config-wiki-language-help": "Bitte die Sprache auswählen, die überwiegend beim Erstellen der Inhalte verwendet werden soll.",
+ "config-back": "← Zurück",
+ "config-continue": "Weiter →",
+ "config-page-language": "Sprache",
+ "config-page-welcome": "Willkommen bei MediaWiki!",
+ "config-page-dbconnect": "Mit der Datenbank verbinden",
+ "config-page-upgrade": "Eine vorhandene Installation aktualisieren",
+ "config-page-dbsettings": "Einstellungen zur Datenbank",
+ "config-page-name": "Name",
+ "config-page-options": "Optionen",
+ "config-page-install": "Installieren",
+ "config-page-complete": "Fertig!",
+ "config-page-restart": "Installationsvorgang erneut starten",
+ "config-page-readme": "Lies mich",
+ "config-page-releasenotes": "Versionsinfos (en)",
+ "config-page-copying": "Kopie der Lizenz",
+ "config-page-upgradedoc": "Aktualisiere",
+ "config-page-existingwiki": "Vorhandenes Wiki",
+ "config-help-restart": "Sollen alle bereits eingegebenen Daten gelöscht und der Installationsvorgang erneut gestartet werden?",
+ "config-restart": "Ja, erneut starten",
+ "config-welcome": "=== Prüfung der Installationsumgebung ===\nDie Basisprüfungen werden jetzt durchgeführt, um festzustellen, ob die Installationsumgebung für MediaWiki geeignet ist.\nNotiere diese Informationen und gib sie an, sofern du Hilfe beim Installieren benötigst.",
+ "config-copyright": "=== Lizenz und Nutzungsbedingungen ===\n\n$1\n\nDieses Programm ist freie Software, d. h. es kann, gemäß den Bedingungen der von der Free Software Foundation veröffentlichten ''GNU General Public License'', weiterverteilt und/oder modifiziert werden. Dabei kann die Version 2, oder nach eigenem Ermessen, jede neuere Version der Lizenz verwendet werden.\n\nDieses Programm wird in der Hoffnung verteilt, dass es nützlich sein wird, allerdings '''ohne jegliche Garantie''' und sogar ohne die implizierte Garantie einer '''Marktgängigkeit''' oder '''Eignung für einen bestimmten Zweck'''. Hierzu sind weitere Hinweise in der ''GNU General Public License'' enthalten.\n\nEine <doclink href=Copying>Kopie der GNU General Public License</doclink> sollte zusammen mit diesem Programm verteilt worden sein. Sofern dies nicht der Fall war, kann eine Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich angefordert oder auf deren Website [http://www.gnu.org/copyleft/gpl.html online gelesen] werden.",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/de Website von MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/de Benutzer­anleitung]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/de Administratoren­anleitung]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/de Häufig gestellte Fragen]\n----\n* <doclink href=Readme>Lies mich</doclink>\n* <doclink href=ReleaseNotes>Versions­informationen</doclink>\n* <doclink href=Copying>Lizenz­bestimmungen</doclink>\n* <doclink href=UpgradeDoc>Aktualisierung</doclink>",
+ "config-env-good": "Die Installationsumgebung wurde geprüft.\nMediaWiki kann installiert werden.",
+ "config-env-bad": "Die Installationsumgebung wurde geprüft.\nMediaWiki kann nicht installiert werden.",
+ "config-env-php": "Die Skriptsprache „PHP“ ($1) ist installiert.",
+ "config-env-hhvm": "HHVM $1 ist installiert.",
+ "config-unicode-using-intl": "Zur Unicode-Normalisierung wird die [http://pecl.php.net/intl PECL-Erweiterung intl] eingesetzt.",
+ "config-unicode-pure-php-warning": "'''Warnung:''' Die [http://pecl.php.net/intl PECL-Erweiterung intl] ist für die Unicode-Normalisierung nicht verfügbar, so dass stattdessen die langsame pure-PHP-Implementierung genutzt wird.\nSofern eine Website mit großer Benutzeranzahl betrieben wird, sollten weitere Informationen auf der Webseite [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-Normalisierung (en)] gelesen werden.",
+ "config-unicode-update-warning": "'''Warnung:''' Die installierte Version des Unicode-Normalisierungswrappers nutzt einer ältere Version der Bibliothek des [http://site.icu-project.org/ ICU-Projekts].\nDiese sollte [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations aktualisiert] werden, sofern auf die Verwendung von Unicode Wert gelegt wird.",
+ "config-no-db": "Es konnte kein adäquater Datenbanktreiber gefunden werden. Es muss daher ein Datenbanktreiber für PHP installiert werden.\n{{PLURAL:$2|Das folgende Datenbanksystem wird|Die folgenden Datenbanksysteme werden}} unterstützt: $1\n\nWenn du PHP selbst kompiliert hast, konfiguriere es erneut mit einem aktivierten Datenbankclient, zum Beispiel durch Verwendung von <code>./configure --with-mysqli</code>.\nWenn du PHP von einem Debian- oder Ubuntu-Paket installiert hast, dann musst du auch beispielsweise das <code>php5-mysql</code>-Paket installieren.",
+ "config-outdated-sqlite": "'''Warnung:''' SQLite $1 ist installiert. Allerdings benötigt MediaWiki SQLite $2 oder höher. SQLite wird daher nicht verfügbar sein.",
+ "config-no-fts3": "'''Warnung:''' SQLite wurde ohne das [//sqlite.org/fts3.html FTS3-Modul] kompiliert, sodass keine Suchfunktionen für dieses Datenbanksystem zur Verfügung stehen werden.",
+ "config-pcre-old": "<strong>Fataler Fehler:</strong> PCRE $1 oder neuer ist erforderlich!\nDie vorhandene PHP-Binärdatei ist mit PCRE $2 verknüpft.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Weitere Informationen].",
+ "config-pcre-no-utf8": "'''Fataler Fehler:''' Das PHP-Modul PCRE scheint ohne PCRE_UTF8-Unterstützung kompiliert worden zu sein.\nMediaWiki benötigt die UTF-8-Unterstützung, um fehlerfrei lauffähig zu sein.",
+ "config-memory-raised": "Der PHP-Parameter <code>memory_limit</code> betrug $1 und wurde auf $2 erhöht.",
+ "config-memory-bad": "'''Warnung:''' Der PHP-Parameter <code>memory_limit</code> beträgt $1.\nDieser Wert ist wahrscheinlich zu niedrig.\nDer Installationsvorgang könnte eventuell scheitern!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] ist installiert",
+ "config-apc": "[http://www.php.net/apc APC] ist installiert",
+ "config-apcu": "[http://www.php.net/apcu APCu] ist installiert",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] ist installiert",
+ "config-no-cache-apcu": "<strong>Warnung:</strong> [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] oder [http://www.iis.net/download/WinCacheForPhp WinCache] konnten nicht gefunden werden.\nDer Objektcache ist nicht aktiviert.",
+ "config-mod-security": "'''Warnung:''' Auf dem Webserver wurde [http://modsecurity.org/ ModSecurity] aktiviert. Sofern falsch konfiguriert, kann dies zu Problemen mit MediaWiki sowie anderer Software auf dem Server führen und es Benutzern ermöglichen, beliebige Inhalte im Wiki einzustellen.\nFür weitere Informationen empfehlen wir die [http://modsecurity.org/documentation/ Dokumentation zu ModSecurity] oder den Kontakt zum Hoster, sofern Fehler auftreten.",
+ "config-diff3-bad": "GNU diff3 wurde nicht gefunden.",
+ "config-git": "Die Versionsverwaltungssoftware „Git“ wurde gefunden: <code>$1</code>.",
+ "config-git-bad": "Die Versionsverwaltungssoftware „Git“ wurde nicht gefunden.",
+ "config-imagemagick": "Die Bildverarbeitungssoftware „ImageMagick“ wurde gefunden: <code>$1</code>.\nMiniaturansichten von Bildern werden möglich sein, sobald das Hochladen von Dateien aktiviert wurde.",
+ "config-gd": "Die im System integrierte GD-Grafikbibliothek wurde gefunden.\nMiniaturansichten von Bildern werden möglich sein, sobald das Hochladen von Dateien aktiviert wurde.",
+ "config-no-scaling": "Weder die GD-Grafikbibliothek noch ImageMagick wurden gefunden.\nMiniaturansichten von Bildern sind daher nicht möglich.",
+ "config-no-uri": "'''Fehler:''' Die aktuelle URL konnte nicht ermittelt werden.\nDer Installationsvorgang wurde daher abgebrochen.",
+ "config-no-cli-uri": "'''Warnung''': Es wurde kein Pfad zum Skipt (<code>--scriptpath</code>) angegeben. Daher wird der Standardpfad genutzt: <code>$1</code>.",
+ "config-using-server": "Der Servername „<nowiki>$1</nowiki>“ wird verwendet.",
+ "config-using-uri": "Die Server-URL „<nowiki>$1$2</nowiki>“ wird verwendet.",
+ "config-uploads-not-safe": "'''Warnung:''' Das Standardverzeichnis für hochgeladene Dateien <code>$1</code> ist für die willkürliche Ausführung von Skripten anfällig.\nObwohl MediaWiki die hochgeladenen Dateien auf Sicherheitsrisiken überprüft, wird dennoch dringend empfohlen, diese [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security Sicherheitslücke] zu schließen, bevor das Hochladen von Dateien aktiviert wird.",
+ "config-no-cli-uploads-check": "'''Warnung''': Das Standardverzeichnis für hochgeladene Dateien (<code>$1</code>) wird, während der Installation über die Kommandozeile, nicht auf Sicherheitsanfälligkeiten hinsichtlich willkürlicher Skriptausführungen geprüft.",
+ "config-brokenlibxml": "Das System nutzt eine Kombination aus PHP- und libxml2-Versionen, die fehleranfällig ist und versteckte Datenfehler bei MediaWiki und anderen Webanwendungen verursachen kann.\nAktualisiere auf libxml2 2.7.3 oder später, um das Problem zu lösen. Installationsabbruch ([https://bugs.php.net/bug.php?id=45996 siehe hierzu die Fehlermeldung bei PHP]).",
+ "config-suhosin-max-value-length": "Suhosin ist installiert und beschränkt die Länge des GET-Parameters auf $1 Bytes.\nDer ResouceLoader von MediaWiki wird zwar unter diesen Bedingungen funktionieren, allerdings nur mit verminderter Leistungsfähigkeit.\nSofern möglich, sollte der Parameter <code>suhosin.get.max_value_length</code> in der Datei <code>php.ini</code> auf 1024 oder höher festgelegt werden.\nGleichzeitig muss der Parameter <code>$wgResourceLoaderMaxQueryLength</code> in der Datei <code>LocalSettings.php</code> auf den selben Wert eingestellt werden.",
+ "config-using-32bit": "<strong>Warnung:</strong> Es scheint, als ob dein System mit 32-Bit-Ganzzahlen läuft. Dies wird [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit nicht empfohlen].",
+ "config-db-type": "Datenbanksystem:",
+ "config-db-host": "Datenbankserver:",
+ "config-db-host-help": "Sofern sich die Datenbank auf einem anderen Server befindet, ist hier der Servername oder die entsprechende IP-Adresse anzugeben.\n\nSofern ein gemeinschaftlich genutzter Server verwendet wird, sollte der Hoster den zutreffenden Servernamen in seiner Dokumentation angegeben haben.\n\nSofern auf einem Windows-Server installiert und MySQL genutzt wird, funktioniert der Servername „localhost“ voraussichtlich nicht. Wenn nicht, sollte „127.0.0.1“ oder die lokale IP-Adresse angegeben werden.\n\nSofern PostgresQL genutzt wird, muss dieses Feld leer gelassen werden, um über ein Unix-Socket zu verbinden.",
+ "config-db-host-oracle": "Datenbank-TNS:",
+ "config-db-host-oracle-help": "Einen gültigen [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm „Local Connect“-Namen] angeben. Die „tnsnames.ora“-Datei muss von dieser Installation erkannt werden können.<br />Sofern die Client-Bibliotheken für Version 10g oder neuer verwendet werden, kann auch [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm „Easy Connect“] zur Namensgebung genutzt werden.",
+ "config-db-wiki-settings": "Bitte Daten zur eindeutigen Identifikation dieses Wikis angeben",
+ "config-db-name": "Datenbankname:",
+ "config-db-name-help": "Bitte einen Namen angeben, mit dem das Wiki identifiziert werden kann.\nDabei sollten keine Leerzeichen verwendet werden.\n\nSofern ein gemeinschaftlich genutzter Server verwendet wird, sollte der Hoster den Datenbanknamen angegeben oder aber die Erstellung einer Datenbank über ein entsprechendes Interface gestattet haben.",
+ "config-db-name-oracle": "Datenbankschema:",
+ "config-db-account-oracle-warn": "Es gibt drei von MediaWiki unterstützte Möglichkeiten, Oracle als Datenbank einzurichten:\n\nSofern das Datenbankbenutzerkonto während des Installationsvorgangs erstellt werden soll, muss ein Datenbankbenutzerkonto mit der SYSDBA-Berechtigung zusammen mit den entsprechenden Anmeldeinformationen angegeben werden, mit dem dann über das Web auf die Datenbank zugegriffen werden kann. Alternativ kann man auch lediglich ein einzelnes manuell angelegtes Datenbankbenutzerkonto angeben, mit dem über das Web auf die Datenbank zugegriffen werden kann, sofern dieses über die Berechtigung zur Erstellung von Datenbankschemen verfügt. Zudem ist es möglich, zwei Datenbankbenutzerkonten anzugeben, von denen eines die Berechtigung zur Erstellung von Datenbankschemen hat und das andere, um mit ihm über das Web auf die Datenbank zuzugreifen.\n\nEin Skript zum Anlegen eines Datenbankbenutzerkontos mit den notwendigen Berechtigungen findet man unter dem Pfad „…/maintenance/oracle/“ dieser MediaWiki-Installation. Es ist dabei zu bedenken, dass die Verwendung eines Datenbankbenutzerkontos mit beschränkten Berechtigungen die Nutzung der Wartungsfunktionen für das Standarddatenbankbenutzerkonto deaktiviert.",
+ "config-db-install-account": "Benutzerkonto für die Installation",
+ "config-db-username": "Name des Datenbankbenutzers:",
+ "config-db-password": "Passwort des Datenbankbenutzers:",
+ "config-db-install-username": "Den Benutzernamen angeben, der für die Verbindung mit der Datenbank während des Installationsvorgangs genutzt werden soll. Es handelt sich dabei nicht um den Benutzernamen für das MediaWiki-Konto, sondern um den Benutzernamen der vorgesehenen Datenbank.",
+ "config-db-install-password": "Das Passwort angeben, das für die Verbindung mit der Datenbank während des Installationsvorgangs genutzt werden soll. Es handelt sich dabei nicht um das Passwort für das MediaWiki-Konto, sondern um das Passwort der vorgesehenen Datenbank.",
+ "config-db-install-help": "Benutzername und Passwort, die während des Installationsvorgangs, für die Verbindung mit der Datenbank, genutzt werden sollen, sind nun anzugeben.",
+ "config-db-account-lock": "Derselbe Benutzername und das Passwort müssen während des Normalbetriebs des Wikis verwendet werden.",
+ "config-db-wiki-account": "Benutzerkonto für den normalen Betrieb",
+ "config-db-wiki-help": "Bitte Benutzernamen und Passwort angeben, die der Webserver während des Normalbetriebes dazu verwenden soll, eine Verbindung zum Datenbankserver herzustellen.\nSofern ein entsprechendes Benutzerkonto nicht vorhanden ist und das Benutzerkonto für den Installationsvorgang über ausreichende Berechtigungen verfügt, wird dieses Benutzerkonto automatisch mit den Mindestberechtigungen zum Normalbetrieb des Wikis angelegt.",
+ "config-db-prefix": "Datenbanktabellenpräfix:",
+ "config-db-prefix-help": "Sofern eine Datenbank für mehrere Wikiinstallationen oder eine Wikiinstallation und eine andere Programminstallation genutzt werden soll, muss ein Datenbanktabellenpräfix angegeben werden, um Datenbankprobleme zu vermeiden.\nEs können keine Leerzeichen verwendet werden.\n\nGewöhnlich bleibt dieses Feld leer.",
+ "config-mysql-old": "MySQL $1 oder höher wird benötigt. MySQL $2 ist momentan vorhanden.",
+ "config-db-port": "Datenbankport:",
+ "config-db-schema": "Datenschema für MediaWiki",
+ "config-db-schema-help": "Dieses Datenschema ist in der Regel allgemein verwendbar.\nNur Änderungen daran vornehmen, sofern es gute Gründe dafür gibt.",
+ "config-pg-test-error": "Es kann keine Verbindung zur Datenbank '''$1''' hergestellt werden: $2",
+ "config-sqlite-dir": "SQLite-Datenverzeichnis:",
+ "config-sqlite-dir-help": "SQLite speichert alle Daten in einer einzigen Datei.\n\nDas für sie vorgesehene Verzeichnis muss während des Installationsvorgangs beschreibbar sein.\n\nEs sollte '''nicht''' über das Web zugänglich sein, was der Grund ist, warum die Datei nicht dort abgelegt wird, wo sich die PHP-Dateien befinden.\n\nDas Installationsprogramm wird mit der Datei zusammen eine zusätzliche <code>.htaccess</code>-Datei erstellen. Sofern dies scheitert, können Dritte auf die Datendatei zugreifen.\nDies umfasst die Nutzerdaten (E-Mail-Adressen, Passwörter, etc.) wie auch gelöschte Seitenversionen und andere vertrauliche Daten, die im Wiki gespeichert sind.\n\nEs ist daher zu erwägen, die Datendatei an gänzlich anderer Stelle abzulegen, beispielsweise im Verzeichnis <code>./var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Standardtabellenraum:",
+ "config-oracle-temp-ts": "Temporärer Tabellenraum:",
+ "config-type-mysql": "MySQL (oder kompatible Datenbanksysteme)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki unterstützt die folgenden Datenbanksysteme:\n\n$1\n\nSofern unterhalb nicht das Datenbanksystem angezeigt wird, das verwendet werden soll, muss dieses noch verfügbar gemacht werden. Oben ist zu jedem unterstützten Datenbanksystem ein Link zur entsprechenden Anleitung vorhanden.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] ist das von MediaWiki primär unterstützte Datenbanksystem. MediaWiki funktioniert auch mit [{{int:version-db-mariadb-url}} MariaDB] und [{{int:version-db-percona-url}} Percona Server], die MySQL-kompatibel sind. ([https://www.php.net/manual/en/mysqli.installation.php Anleitung zur Kompilierung von PHP mit MySQL-Unterstützung])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] ist ein beliebtes Open-Source-Datenbanksystem und eine Alternative zu MySQL. ([https://www.php.net/manual/de/pgsql.installation.php Anleitung zur Kompilierung von PHP mit PostgreSQL-Unterstützung])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] ist ein verschlanktes Datenbanksystem, das auch gut unterstützt wird ([https://www.php.net/manual/de/pdo.installation.php Anleitung zur Kompilierung von PHP mit SQLite-Unterstützung], verwendet PHP Data Objects (PDO))",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] ist eine kommerzielle Unternehmensdatenbank ([https://www.php.net/manual/en/oci8.installation.php Anleitung zur Kompilierung von PHP mit OCI8-Unterstützung])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] ist eine gewerbliche Unternehmensdatenbank für Windows. ([https://www.php.net/manual/de/sqlsrv.installation.php Anleitung zur Kompilierung von PHP mit SQLSRV-Unterstützung])",
+ "config-header-mysql": "MySQL-Einstellungen",
+ "config-header-postgres": "PostgreSQL-Einstellungen",
+ "config-header-sqlite": "SQLite-Einstellungen",
+ "config-header-oracle": "Oracle-Einstellungen",
+ "config-header-mssql": "Einstellungen von Microsoft SQL Server",
+ "config-invalid-db-type": "Unzulässiges Datenbanksystem",
+ "config-missing-db-name": "Bei „{{int:config-db-name}}“ muss ein Wert angegeben werden.",
+ "config-missing-db-host": "Bei „{{int:config-db-host}}“ muss ein Wert angegeben werden.",
+ "config-missing-db-server-oracle": "Für „{{int:config-db-host-oracle}}“ muss ein Wert eingegeben werden.",
+ "config-invalid-db-server-oracle": "Ungültiges Datenbank-TNS „$1“.\nEntweder „TNS Name“ oder eine „Easy Connect“-Zeichenfolge verwenden ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle-Benennungsmethoden])",
+ "config-invalid-db-name": "Ungültiger Datenbankname „$1“.\nEs dürfen nur ASCII-codierte Buchstaben (a-z, A-Z), Zahlen (0-9), Unter- (_) sowie Bindestriche (-) verwendet werden.",
+ "config-invalid-db-prefix": "Ungültiger Datenbanktabellenpräfix „$1“.\nEs dürfen nur ASCII-codierte Buchstaben (a-z, A-Z), Zahlen (0-9), Unter- (_) sowie Bindestriche (-) verwendet werden.",
+ "config-connection-error": "$1.\n\nBitte unten angegebenen Servernamen, Benutzernamen sowie das Passwort überprüfen und es danach erneut versuchen.",
+ "config-invalid-schema": "Ungültiges Datenschema für MediaWiki „$1“.\nEs dürfen nur ASCII-codierte Buchstaben (a-z, A-Z), Zahlen (0-9) und Unterstriche (_) verwendet werden.",
+ "config-db-sys-create-oracle": "Das Installationsprogramm unterstützt nur die Verwendung eines Datenbankbenutzerkontos mit SYSDBA-Berechtigung zum Anlegen eines neuen Datenbankbenutzerkontos.",
+ "config-db-sys-user-exists-oracle": "Das Datenbankbenutzerkonto „$1“ ist bereits vorhanden. Ein Datenbankbenutzerkontos mit SYSDBA-Berechtigung kann nur zum Anlegen eines neuen Datenbankbenutzerkontos genutzt werden.",
+ "config-postgres-old": "PostgreSQL $1 oder höher wird benötigt. PostgreSQL $2 ist momentan vorhanden.",
+ "config-mssql-old": "Es wird Microsoft SQL Server $1 oder später benötigt. Deine Version ist $2.",
+ "config-sqlite-name-help": "Bitten einen Namen angeben, mit dem das Wiki identifiziert werden kann.\nDabei bitte keine Leerzeichen oder Bindestriche verwenden.\nDieser Name wird für die SQLite-Datendateinamen genutzt.",
+ "config-sqlite-parent-unwritable-group": "Das Datenverzeichnis <code><nowiki>$1</nowiki></code> kann nicht erzeugt werden, da das übergeordnete Verzeichnis <code><nowiki>$2</nowiki></code> nicht für den Webserver beschreibbar ist.\n\nDas Installationsprogramm konnte den Benutzer bestimmen, mit dem Webserver ausgeführt wird.\nSchreibzugriff auf das <code><nowiki>$3</nowiki></code>-Verzeichnis muss für diesen ermöglicht werden, um den Installationsvorgang fortsetzen zu können.\n\nAuf einem Unix- oder Linux-System:\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Das Datenverzeichnis <code><nowiki>$1</nowiki></code> kann nicht erzeugt werden, da das übergeordnete Verzeichnis <code><nowiki>$2</nowiki></code> nicht für den Webserver beschreibbar ist.\n\nDas Installationsprogramm konnte den Benutzer bestimmen, mit dem Webserver ausgeführt wird.\nSchreibzugriff auf das <code><nowiki>$3</nowiki></code>-Verzeichnis muss global für diesen und andere Benutzer ermöglicht werden, um den Installationsvorgang fortsetzen zu können.\n\nAuf einem Unix- oder Linux-System:\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Fehler beim Erstellen des Datenverzeichnisses „$1“.\n\nBitte den Speicherort überprüfen und es danach erneut versuchen.",
+ "config-sqlite-dir-unwritable": "Das Verzeichnis „$1“ ist nicht beschreibbar.\nBitte die Zugriffsberechtigungen so ändern, dass dieses Verzeichnis für den Webserver beschreibbar ist und es danach erneut versuchen.",
+ "config-sqlite-connection-error": "$1.\n\nBitte unten angegebenes Datenverzeichnis sowie den Datenbanknamen überprüfen und es danach erneut versuchen.",
+ "config-sqlite-readonly": "Die Datei <code>$1</code> ist nicht beschreibbar.",
+ "config-sqlite-cant-create-db": "Die Datenbankdatei <code>$1</code> konnte nicht erzeugt werden.",
+ "config-sqlite-fts3-downgrade": "PHP verfügt nicht über FTS3-Unterstützung. Die Tabellen wurden zurückgestuft.",
+ "config-can-upgrade": "Es wurden MediaWiki-Tabellen in dieser Datenbank gefunden.\nUm sie auf MediaWiki $1 zu aktualisieren, bitte auf '''Weiter''' klicken.",
+ "config-upgrade-done": "Die Aktualisierung ist nun abgeschlossen.\n\nDas Wiki kann nun [$1 genutzt werden].\n\nSofern die Datei <code>LocalSettings.php</code> neu erzeugt werden soll, bitte auf die Schaltfläche unten klicken.\nDies wird '''nicht empfohlen''', es sei denn, es treten Probleme mit dem Wiki auf.",
+ "config-upgrade-done-no-regenerate": "Die Aktualisierung ist abgeschlossen.\n\nDas Wiki kann nun [$1 genutzt werden].",
+ "config-regenerate": "LocalSettings.php neu erstellen →",
+ "config-show-table-status": "Die Abfrage <code>SHOW TABLE STATUS</code> ist gescheitert!",
+ "config-unknown-collation": "'''Warnung:''' Die Datenbank nutzt eine unbekannte Kollation.",
+ "config-db-web-account": "Datenbankkonto für den Webzugriff",
+ "config-db-web-help": "Bitte Benutzernamen und Passwort auswählen, die der Webserver während des Normalbetriebes dazu verwenden soll, eine Verbindung zum Datenbankserver herzustellen.",
+ "config-db-web-account-same": "Dasselbe Datenbankkonto wie während des Installationsvorgangs verwenden",
+ "config-db-web-create": "Sofern nicht bereits vorhanden, muss nun das Konto erstellt werden",
+ "config-db-web-no-create-privs": "Das angegebene und für den Installationsvorgang vorgesehene Datenbankkonto verfügt nicht über ausreichend Berechtigungen, um ein weiteres Datenbankkonto zu erstellen.\nDas hier angegebene Datenbankkonto muss daher bereits vorhanden sein.",
+ "config-mysql-engine": "Datenbanksystem:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Warnung:</strong> Es wurde MyISAM als Speichersubsystem für das Datenbanksystem MySQL ausgewählt. Aus folgenden Gründen wird es nicht für den Einsatz mit MediaWiki empfohlen:\n* Es unterstützt aufgrund von Tabellensperrungen kaum die nebenläufige Ausführung von Aktionen.\n* Es ist anfälliger für Datenprobleme.\n* Es wird von MediaWiki nicht immer adäquat unterstützt.\n\nSofern die vorhandene MySQL-Installation das Speichersubsystem InnoDB unterstützt, wird deren Verwendung eindringlich empfohlen.\nSofern sie es nicht unterstützt, sollte nunmehr eine entsprechende Aktualisierung in Erwägung gezogen werden.",
+ "config-mysql-only-myisam-dep": "<strong>Warnung:</strong> MyISAM ist das einzige verfügbare Speichersubsystem für das Datenbanksystem MySQL auf diesem Server. Es wird nicht für die Verwendung mit MediaWiki empfohlen, da es\n* aufgrund von Tabellensperrungen kaum die nebenläufige Ausführung von Aktionen unterstützt,\n* anfälliger für Datenprobleme ist und\n* von MediaWiki nicht immer adäquat unterstützt wird.\n\nDeine MySQL-Installation unterstützt nicht das Speichersubsystem InnoDB. Eine Aktualisierung wird nunmehr empfohlen.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> als Speichersubsystem für das Datenbanksystem MySQL ist fast immer die bessere Wahl, da es gleichzeitige Zugriffe gut unterstützt.\n\n<strong>MyISAM</strong> als Speichersubsystem für das Datenbanksystem MySQL ist hingegen in Einzelnutzerumgebungen oder bei schreibgeschützten Wikis schneller.\nDatenbanken, die MyISAM verwenden, sind indes tendenziell fehleranfälliger als solche, die InnoDB verwenden.",
+ "config-mysql-charset": "Datenbankzeichensatz:",
+ "config-mysql-binary": "binär",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Im '''binären Modus''' speichert MediaWiki UTF-8 Texte in der Datenbank in binär kodierte Datenfelder.\nDies ist effizienter als der UTF-8-Modus von MySQL und ermöglicht so die Verwendung jeglicher Unicode-Zeichen.\n\nIm '''UTF-8-Modus''' wird MySQL den Zeichensatz der Daten erkennen und sie richtig anzeigen und konvertieren,\nallerdings können keine Zeichen außerhalb des [https://de.wikipedia.org/wiki/Basic_Multilingual_Plane#Gliederung_in_Ebenen_und_Bl.C3.B6cke ''Basic Multilingual Plane'' (BMP)] gespeichert werden.",
+ "config-mssql-auth": "Authentifikationstyp:",
+ "config-mssql-install-auth": "Wähle den Authentifikationstyp aus, der zur Verbindung mit der Datenbank während des Installationsprozesses verwendet wird.\nFalls du „{{int:config-mssql-windowsauth}}“ auswählst, werden die Anmeldeinformationen eines beliebigen Benutzers verwendet, der den Webserver ausführt.",
+ "config-mssql-web-auth": "Wähle den Authentifikationstyp aus, der vom Webserver zur Verbindung mit dem Datenbankserver während des gewöhnlichen Betriebs des Wikis verwendet wird.\nFalls du „{{int:config-mssql-windowsauth}}“ auswählst, werden die Anmeldeinformationen eines beliebigen Benutzers verwendet, der den Webserver ausführt.",
+ "config-mssql-sqlauth": "SQL-Server-Authentifikation",
+ "config-mssql-windowsauth": "Windows-Authentifikation",
+ "config-site-name": "Name des Wikis:",
+ "config-site-name-help": "Er wird in der Titelleiste des Browsers, wie auch verschiedenen anderen Stellen, genutzt.",
+ "config-site-name-blank": "Den Namen des Wikis angeben.",
+ "config-project-namespace": "Name des Projektnamensraums:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Entspricht dem Namen des Wikis: $1",
+ "config-ns-other": "Anderer Name (bitte angeben)",
+ "config-ns-other-default": "MeinWiki",
+ "config-project-namespace-help": "Dem Beispiel von Wikipedia folgend, unterscheiden viele Wikis zwischen den Seiten für Inhalte und denen für Richtlinien. Letztere werden im '''Projektnamensraum''' hinterlegt.\nAlle Seiten dieses Namensraumes verfügen über ein Seitenpräfix, das nun an dieser Stelle angegeben werden kann.\nNormalerweise steht dieses Seitenpräfix mit dem Namen des Wikis in einem engen Zusammenhang. Dabei können bestimmte Sonderzeichen wie „#“ oder „:“ nicht verwendet werden.",
+ "config-ns-invalid": "Der angegebene Namensraum „<nowiki>$1</nowiki>“ ist ungültig.\nBitte einen abweichenden Projektnamensraum angeben.",
+ "config-ns-conflict": "Der angegebene Namensraum „<nowiki>$1</nowiki>“ verursacht Problem mit dem Standardnamensraum von MediaWiki.\nBitte einen abweichenden Projektnamensraum angeben.",
+ "config-admin-box": "Administratorkonto",
+ "config-admin-name": "Dein Benutzername:",
+ "config-admin-password": "Passwort:",
+ "config-admin-password-confirm": "Passwort wiederholen:",
+ "config-admin-help": "Bitte den bevorzugten Benutzernamen angeben, beispielsweise „Knut Wuchtig“.\nDies ist der Name, der benötigt wird, um sich im Wiki anzumelden.",
+ "config-admin-name-blank": "Bitte den Benutzernamen für den Administratoren angeben.",
+ "config-admin-name-invalid": "Der angegebene Benutzername „<nowiki>$1</nowiki>“ ist ungültig.\nBitte einen abweichenden Benutzernamen angeben.",
+ "config-admin-password-blank": "Bitte das Passwort für das Administratorkonto angeben.",
+ "config-admin-password-mismatch": "Die beiden Passwörter stimmen nicht überein.",
+ "config-admin-email": "E-Mail-Adresse:",
+ "config-admin-email-help": "Bitte hier eine E-Mail-Adresse angeben, die den E-Mail-Empfang von anderen Benutzern des Wikis, das Zurücksetzen des Passwortes sowie Benachrichtigungen zu Änderungen an beobachteten Seiten ermöglicht. Diese Feld kann leer gelassen werden.",
+ "config-admin-error-user": "Es ist beim Erstellen des Administrators mit dem Namen „<nowiki>$1</nowiki>“ ein interner Fehler aufgetreten.",
+ "config-admin-error-password": "Es ist beim Setzen des Passworts für den Administrator „<nowiki>$1</nowiki>“ ein interner Fehler aufgetreten: <pre>$2</pre>",
+ "config-admin-error-bademail": "Es wurde eine ungültige E-Mail-Adresse angegeben.",
+ "config-subscribe": "Bitte die Mailingliste [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mitteilungen zu Versionsveröffentlichungen] abonnieren.",
+ "config-subscribe-help": "Es handelt sich hierbei um eine Mailingliste mit wenigen Aussendungen, die für Mitteilungen zu Versionsveröffentlichungen, einschließlich wichtiger Sicherheitsveröffentlichungen, genutzt wird.\nDiese Mailingliste sollte abonniert werden. Zudem sollte die MediaWiki-Installation stets aktualisiert werden, sobald eine neue Programmversion veröffentlicht wurde.",
+ "config-subscribe-noemail": "Beim Abonnieren der Mailingliste mit Mitteilungen zu Versionsveröffentlichungen wurde keine E-Mail-Adresse angegeben.\nBitte eine E-Mail-Adresse angeben, sofern die Mailingliste abonniert werden soll.",
+ "config-pingback": "Daten über diese Installation mit den MediaWiki-Entwicklern teilen.",
+ "config-pingback-help": "Sofern diese Option ausgewählt wird, meldet MediaWiki regelmäßig die Basisdaten dieser MediaWiki-Installation an https://www.mediawiki.org. Diese Daten enthalten beispielsweise den Betriebssystemtyp, die PHP-Version sowie das genutzte Datenbanksystem. Die Wikimedia Foundation teilt diese Daten mit den MediaWiki-Entwicklern, um Entscheidungen zur künftigen Softwareentwicklung zu verbessern. Die folgenden Daten werden für diese Installation gesendet:\n<pre>$1</pre>",
+ "config-almost-done": "Der Vorgang ist fast abgeschlossen!\nDie verbleibenden Konfigurationseinstellungen können übersprungen und das Wiki umgehend installiert werden.",
+ "config-optional-continue": "Ja, es sollen weitere Konfigurationseinstellungen vorgenommen werden.",
+ "config-optional-skip": "Nein, das Wiki soll nun installiert werden.",
+ "config-profile": "Profil der Benutzerberechtigungen:",
+ "config-profile-wiki": "offenes Wiki",
+ "config-profile-no-anon": "Erstellung eines Benutzerkontos erforderlich",
+ "config-profile-fishbowl": "ausschließlich berechtigte Bearbeiter",
+ "config-profile-private": "geschlossenes Wiki",
+ "config-profile-help": "Wikis sind am nützlichsten, wenn so viele Menschen als möglich Bearbeitungen vornehmen können.\nMit MediaWiki ist es einfach die letzten Änderungen nachzuvollziehen und unbrauchbare Bearbeitungen, beispielsweise von unbedarften oder böswilligen Benutzern, rückgängig zu machen.\n\nAllerdings finden etliche Menschen Wikis auch mit anderen Bearbeitungskonzepten sinnvoll. Manchmal ist es zudem nicht einfach alle Beteiligten von den Vorteilen des „Wiki-Prinzips” zu überzeugen. Darum ist diese Auswahl möglich.\n\nDas Modell „'''{{int:config-profile-wiki}}'''“ ermöglicht es jedermann, sogar ohne über ein Benutzerkonto zu verfügen, Bearbeitungen vorzunehmen.\nEin Wiki bei dem die '''{{int:config-profile-no-anon}}''' ist, fordert von den Benutzern eine höhere Verantwortung für ihre Bearbeitungen ein, könnte allerdings Personen abschrecken, die nur gelegentlich Bearbeitungen vornehmen wollen. Ein Wiki für '''{{int:config-profile-fishbowl}}''' gestattet es nur bestimmten Benutzern, Bearbeitungen vorzunehmen. Allerdings kann dabei die Allgemeinheit die Seiten immer noch betrachten und Änderungen nachvollziehen. Ein '''{{int:config-profile-private}}''' gestattet es nur ausgewählten Benutzern, Seiten zu betrachten sowie zu bearbeiten.\n\nKomplexere Konzepte zur Zugriffssteuerung können erst nach abgeschlossenem Installationsvorgang eingerichtet werden. Hierzu gibt es weitere Informationen auf der Website mit der [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights entsprechenden Anleitung].",
+ "config-license": "Lizenz:",
+ "config-license-none": "Keine Lizenzangabe in der Fußzeile",
+ "config-license-cc-by-sa": "''Creative Commons'' „Namensnennung – Weitergabe unter gleichen Bedingungen“",
+ "config-license-cc-by": "''Creative Commons'' „Namensnennung“",
+ "config-license-cc-by-nc-sa": "''Creative Commons'' „Namensnennung – nicht kommerziell – Weitergabe unter gleichen Bedingungen“",
+ "config-license-cc-0": "''Creative Commons'' „Zero“ (Gemeinfreiheit)",
+ "config-license-gfdl": "GNU-Lizenz für freie Dokumentation 1.3 oder höher",
+ "config-license-pd": "Gemeinfreiheit",
+ "config-license-cc-choose": "Eine benutzerdefinierte Creative-Commons-Lizenz auswählen",
+ "config-license-help": "Viele öffentliche Wikis publizieren alle Beiträge unter einer [http://freedomdefined.org/Definition/De freien Lizenz.]\nDies trägt dazu bei, ein Gefühl von Gemeinschaft zu schaffen, und ermutigt zu längerfristiger Mitarbeit.\nHingegen ist im Allgemeinen eine freie Lizenz auf geschlossenen Wikis nicht notwendig.\n\nSofern man Texte aus der Wikipedia verwenden möchte und umgekehrt, sollte die Lizenz {{int:config-license-cc-by-sa}} gewählt werden.\n\nDie Wikipedia nutzte vormals die GNU-Lizenz für freie Dokumentation (GFDL).\nDie GFDL ist eine gültige Lizenz, die allerdings schwer zu verstehen ist.\nEs ist zudem schwierig, gemäß dieser Lizenz lizenzierte Inhalte wiederzuverwenden.",
+ "config-email-settings": "E-Mail-Einstellungen",
+ "config-enable-email": "Ausgehende E-Mails ermöglichen",
+ "config-enable-email-help": "Sofern die E-Mail-Funktionen genutzt werden sollen, müssen die entsprechenden [http://www.php.net/manual/en/mail.configuration.php PHP-E-Mail-Einstellungen] richtig konfiguriert werden.\nFür den Fall, dass die E-Mail-Funktionen nicht benötigt werden, können sie hier deaktiviert werden.",
+ "config-email-user": "E-Mail-Versand von Benutzer zu Benutzer aktivieren",
+ "config-email-user-help": "Allen Benutzern ermöglichen, sich gegenseitig E-Mails zu schicken, sofern sie es in ihren Einstellungen aktiviert haben.",
+ "config-email-usertalk": "Benachrichtigungen zu Änderungen an Benutzerdiskussionsseiten ermöglichen",
+ "config-email-usertalk-help": "Ermöglicht es Benutzern, Benachrichtigungen zu Änderungen an ihren Benutzerdiskussionsseiten zu erhalten, sofern sie dies in ihren Einstellungen aktiviert haben.",
+ "config-email-watchlist": "Benachrichtigungen zu Änderungen an Seiten auf der Beobachtungsliste ermöglichen",
+ "config-email-watchlist-help": "Ermöglicht es Benutzern, Benachrichtigungen zu Änderungen an Seiten auf ihrer Beobachtungsliste zu erhalten, sofern sie dies in ihren Einstellungen aktiviert haben.",
+ "config-email-auth": "E-Mail-Authentifizierung ermöglichen",
+ "config-email-auth-help": "Sofern diese Funktion aktiviert ist, müssen Benutzer ihre E-Mail-Adresse bestätigen, indem sie den Bestätigungslink nutzen, der ihnen immer dann zugesandt wird, wenn sie ihre E-Mail-Adresse angeben oder ändern.\nNur bestätigte E-Mail-Adressen können Nachrichten von anderen Benutzer oder Benachrichtigungsmitteilungen erhalten.\nDie Aktivierung dieser Funktion wird bei offenen Wikis, mit Hinblick auf möglichen Missbrauch der E-Mail-Funktionen, '''empfohlen.'''",
+ "config-email-sender": "E-Mail-Adresse für Antworten:",
+ "config-email-sender-help": "Bitte hier die E-Mail-Adresse angeben, die als Absenderadresse bei ausgehenden E-Mails eingesetzt werden soll.\nRücklaufende E-Mails werden an diese E-Mail-Adresse gesandt.\nBei vielen E-Mail-Servern muss der Teil der E-Mail-Adresse mit der Domainangabe korrekt sein.",
+ "config-upload-settings": "Hochladen von Bildern und Dateien",
+ "config-upload-enable": "Das Hochladen von Dateien ermöglichen",
+ "config-upload-help": "Das Hochladen von Dateien macht den Server für potentielle Sicherheitsprobleme anfällig.\nWeitere Informationen hierzu können im [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security Abschnitt Sicherheit] der Anleitung nachgelesen werden.\n\nUm das Hochladen von Dateien zu ermöglichen, muss der Zugriff auf das Unterverzeichnis <code>./images</code> so geändert werden, das dieses für den Webserver beschreibbar ist.\nHernach kann diese Option aktiviert werden.",
+ "config-upload-deleted": "Verzeichnis für gelöschte Dateien:",
+ "config-upload-deleted-help": "Bitte ein Verzeichnis auswählen, in dem gelöschte Dateien archiviert werden sollen.\nIdealerweise sollte es nicht über das Internet zugänglich sein.",
+ "config-logo": "URL des Logos:",
+ "config-logo-help": "Die Standardoberfläche von MediaWiki verfügt links oberhalb der Seitenleiste über Platz für ein Logo mit den Maßen 135x160 Pixel.\nBitte ein Logo in entsprechender Größe hochladen und die zugehörige URL an dieser Stelle angeben.\n\nDu kannst <code>$wgStylePath</code> oder <code>$wgScriptPath</code> verwenden, falls dein Logo relativ zu diesen Pfaden ist.\n\nSofern kein Logo benötigt wird, kann dieses Datenfeld leer bleiben.",
+ "config-instantcommons": "„InstantCommons“ aktivieren",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons InstantCommons] ist eine Funktion, die es Wikis ermöglicht, Bild-, Klang- und andere Mediendateien zu nutzen, die auf der Website [https://commons.wikimedia.org/ Wikimedia Commons] verfügbar sind.\nUm diese Funktion nutzen zu können, muss das Wiki über eine Verbindung zum Internet verfügen.\n\nWeitere Informationen zu dieser Funktion, einschließlich der Anleitung, wie hierfür andere Wikis als Wikimedia Commons eingerichtet werden können, gibt es im [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos Handbuch].",
+ "config-cc-error": "Der Creativ-Commons-Lizenzassistent konnte keine Lizenz ermitteln.\nDie Lizenz ist daher jetzt manuell einzugeben.",
+ "config-cc-again": "Erneut auswählen …",
+ "config-cc-not-chosen": "Die gewünschte Creative-Commons-Lizenz auswählen und dann auf „proceed“ klicken.",
+ "config-advanced-settings": "Erweiterte Konfiguration",
+ "config-cache-options": "Einstellungen für die Zwischenspeicherung von Objekten:",
+ "config-cache-help": "Das Objektcaching wird dazu genutzt, die Geschwindigkeit von MediaWiki zu verbessern, indem häufig genutzte Daten zwischengespeichert werden.\nEs wird sehr empfohlen, es für mittelgroße bis große Wikis zu nutzen, aber auch für kleine Wikis ergeben sich erkennbare Geschwindigkeitsverbesserungen.",
+ "config-cache-none": "Kein Objektcaching (es wird keine Funktion entfernt, allerdings kann dies die Leistungsfähigkeit größerer Wikis negativ beeinflussen)",
+ "config-cache-accel": "Objektcaching von PHP (APC, APCu, XCache oder WinCache)",
+ "config-cache-memcached": "Memcached Cacheserver (erfordert einen zusätzlichen Installationsvorgang mitsamt Konfiguration)",
+ "config-memcached-servers": "Memcached Cacheserver",
+ "config-memcached-help": "Liste der für Memcached nutzbaren IP-Adressen.\nEs sollte eine je Zeile mitsamt des vorgesehenen Ports angegeben werden. Beispiele:\n127.0.0.1:11211 oder\n192.168.1.25:1234 usw.",
+ "config-memcache-needservers": "Memcached wurde als Cacheserver ausgewählt. Dabei wurde allerdings kein Server angegeben.",
+ "config-memcache-badip": "Es wurde für Memcached eine ungültige IP-Adresse angegeben: $1",
+ "config-memcache-noport": "Es wurde kein Port zur Nutzung durch den Memcached Cacheserver angegeben: $1\nSofern der Port unbekannt ist, ist 11211 die Standardangabe.",
+ "config-memcache-badport": "Der Ports für den Memcached Cacheserver sollten zwischen $1 und $2 liegen",
+ "config-extensions": "Erweiterungen",
+ "config-extensions-help": "Die obig angegebenen Erweiterungen wurden im Verzeichnis <code>./extensions</code> gefunden.\n\nEs könnten zusätzliche Konfigurierungen zu einzelnen Erweiterungen erforderlich sein, dennoch können sie aber bereits jetzt aktiviert werden.",
+ "config-skins": "Benutzeroberflächen",
+ "config-skins-help": "Die oben aufgeführten Benutzeroberflächen wurden im Verzeichnis <code>./skins</code> gefunden. Du musst mindestens eine aktivieren und als Standard auswählen.",
+ "config-skins-use-as-default": "Diese Benutzeroberfläche als Standard verwenden",
+ "config-skins-missing": "Es wurden keine Benutzeroberflächen gefunden. MediaWiki wird eine Fallback-Benutzeroberfläche verwenden, bis du andere Benutzeroberflächen installierst.",
+ "config-skins-must-enable-some": "Du musst mindestens eine zu aktivierende Benutzeroberfläche auswählen.",
+ "config-skins-must-enable-default": "Die ausgewählte Standard-Benutzeroberfläche muss aktiviert sein.",
+ "config-install-alreadydone": "'''Warnung:''' Es wurde eine vorhandene MediaWiki-Installation gefunden.\nEs muss daher mit den nächsten Seite weitergemacht werden.",
+ "config-install-begin": "Durch Drücken von „{{int:config-continue}}“ wird die Installation von MediaWiki gestartet.\nSofern Änderungen vorgenommen werden sollen, kann man auf „{{int:config-back}}“ klicken.",
+ "config-install-step-done": "erledigt",
+ "config-install-step-failed": "gescheitert",
+ "config-install-extensions": "Programmerweiterungen",
+ "config-install-database": "Datenbank wird eingerichtet",
+ "config-install-schema": "Datenschema wird erstellt",
+ "config-install-pg-schema-not-exist": "Das PostgesSQL-Datenschema ist nicht vorhanden",
+ "config-install-pg-schema-failed": "Das Erstellen der Datentabellen ist gescheitert.\nEs muss sichergestellt sein, dass der Benutzer „$1“ Schreibzugriff auf das Datenschema „$2“ hat.",
+ "config-install-pg-commit": "Änderungen anwenden",
+ "config-install-pg-plpgsql": "Suche nach der Datenbanksprache PL/pgSQL",
+ "config-pg-no-plpgsql": "Für Datenbank $1 muss die Datenbanksprache PL/pgSQL installiert werden",
+ "config-pg-no-create-privs": "Das für die Installation angegeben Konto verfügt nicht über ausreichende Berechtigungen, um ein Datenbanknutzerkonto zu erstellen.",
+ "config-pg-not-in-role": "Das für den Webbenutzer angegebene Benutzerkonto ist bereits vorhanden.\nDas für den Installationsvorgang angegebene Benutzerkonto ist kein Superbenutzer und nicht Mitglied der Benutzergruppe der Webbenutzer, so dass keine dem Webbenutzer zugeordneten Datenobjekte erstellt werden können.\n\nFür MediaWiki ist es momentan erforderlich, dass die Tabellen dem Webbenutzer rechtemäßig zugeordnet sind. Bitte einen anderen Namen für den Wikibenutzer angeben oder „← Zurück“ anklicken, um einen ausreichend berechtigten Benutzer für den Installationsvorgang anzugeben.",
+ "config-install-user": "Datenbankbenutzer wird erstellt",
+ "config-install-user-alreadyexists": "Datenbankbenutzer „$1“ ist bereits vorhanden",
+ "config-install-user-create-failed": "Das Anlegen des Datenbankbenutzers „$1“ ist gescheitert: $2",
+ "config-install-user-grant-failed": "Die Gewährung der Berechtigung für Datenbankbenutzer „$1“ ist gescheitert: $2",
+ "config-install-user-missing": "Der angegebene Benutzer „$1“ ist nicht vorhanden.",
+ "config-install-user-missing-create": "Der angegebene Benutzer „$1“ ist nicht vorhanden.\nBitte das Auswahlkästchen „Benutzerkonto erstellen“ anklicken, sofern dieser erstellt werden soll.",
+ "config-install-tables": "Datentabellen werden erstellt",
+ "config-install-tables-exist": "'''Warnung:''' Es wurden MediaWiki-Datentabellen gefunden.\nDie Erstellung wurde übersprungen.",
+ "config-install-tables-failed": "'''Fehler:''' Die Erstellung der Datentabellen ist aufgrund des folgenden Fehlers gescheitert: $1",
+ "config-install-interwiki": "Interwikitabellen werden eingerichtet",
+ "config-install-interwiki-list": "Die Datei <code>interwiki.list</code> konnte nicht gelesen werden.",
+ "config-install-interwiki-exists": "'''Warnung:''' Es wurden Interwikitabellen mit Daten gefunden.\nDie Standardliste wird übersprungen.",
+ "config-install-stats": "Statistiken werden initialisiert",
+ "config-install-keys": "Geheimschlüssel werden erstellt",
+ "config-insecure-keys": "'''Warnung:''' {{PLURAL:$2|Der Geheimschlüssel|Die Geheimschlüssel}} $1, {{PLURAL:$2|der|die}} während des Installationsvorgangs generiert {{PLURAL:$2|wurde, ist|wurden, sind}} nicht sehr sicher. {{PLURAL:$2|Er sollte|Sie sollten}} manuell geändert werden.",
+ "config-install-updates": "Unnötige Aktualisierungen nicht ausführen",
+ "config-install-updates-failed": "<strong>Fehler:</strong> Das Einfügen von Aktualisierungsschlüssel in die Tabellen ist mit dem folgenden Fehler fehlgeschlagen: $1",
+ "config-install-sysop": "Administratorkonto wird erstellt",
+ "config-install-subscribe-fail": "Abonnieren von „mediawiki-announce“ ist gescheitert: $1",
+ "config-install-subscribe-notpossible": "cURL ist nicht installiert und <code>allow_url_fopen</code> ist nicht verfügbar.",
+ "config-install-mainpage": "Erstellung der Hauptseite mit Standardinhalten",
+ "config-install-mainpage-exists": "Die Hauptseite ist bereits vorhanden. Überspringe.",
+ "config-install-extension-tables": "Erstellung der Tabellen für die aktivierten Erweiterungen",
+ "config-install-mainpage-failed": "Die Hauptseite konnte nicht erstellt werden: $1",
+ "config-install-done": "'''Herzlichen Glückwunsch!'''\nMediaWiki wurde erfolgreich installiert.\n\nDas Installationsprogramm hat die Datei <code>LocalSettings.php</code> erzeugt.\nSie enthält alle vorgenommenen Konfigurationseinstellungen.\n\nDiese Datei muss nun heruntergeladen und anschließend in das Stammverzeichnis der MediaWiki-Installation hochgeladen werden. Dies ist dasselbe Verzeichnis, in dem sich auch die Datei <code>index.php</code> befindet. Das Herunterladen sollte inzwischen automatisch gestartet worden sein.\n\nSofern dies nicht der Fall war, oder das Herunterladen unterbrochen wurde, kann der Vorgang durch einen Klick auf den folgenden Link erneut gestartet werden:\n\n$3\n\n'''Hinweis:''' Die Konfigurationsdatei sollte jetzt unbedingt heruntergeladen werden. Sie wird nach Beenden des Installationsprogramms, nicht mehr zur Verfügung stehen.\n\nSobald alles erledigt wurde, kann auf das '''[$2 Wiki zugegriffen werden]'''. Wir wünschen viel Spaß und Erfolg mit dem Wiki.",
+ "config-install-done-path": "<strong>Herzlichen Glückwunsch!</strong>\nDu hast MediaWiki installiert.\n\nDas Installationsprogramm hat eine Datei „<code>LocalSettings.php</code>“ erzeugt.\nSie enthält deine gesamte Konfiguration.\n\nDu musst sie herunterladen und unter <code>$4</code> ablegen. Der Download sollte automatisch gestartet sein.\n\nFalls der Download nicht angeboten wird oder du ihn abgebrochen hast, kannst du ihn durch Anklicken des folgenden Links neu starten:\n\n$3\n\n<strong>Hinweis:</strong> Falls du dies jetzt nicht tust, wird die erzeugte Konfigurationsdatei später nicht verfügbar sein, wenn du die Installation ohne Herunterladen verlässt.\n\nBei Fertigstellung kannst du <strong>[$2 dein Wiki aufrufen]</strong>.",
+ "config-download-localsettings": "<code>LocalSettings.php</code> herunterladen",
+ "config-help": "Hilfe",
+ "config-help-tooltip": "Zum Expandieren klicken",
+ "config-nofile": "Die Datei „$1“ konnte nicht gefunden werden. Wurde sie gelöscht?",
+ "config-extension-link": "Wusstest du, dass dein Wiki die Nutzung von [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions Erweiterungen] unterstützt?\n\nDu kannst die [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category Erweiterungen nach Kategorie] anzeigen oder die [https://www.mediawiki.org/wiki/Extension_Matrix Erweiterungs-Matrix] aufrufen, um eine vollständige Liste der Erweiterungen zu sehen.",
+ "config-skins-screenshots": "$1 (Bildschirmfotos: $2)",
+ "config-screenshot": "Bildschirmfoto",
+ "mainpagetext": "<strong>MediaWiki wurde installiert.</strong>",
+ "mainpagedocfooter": "Hilfe zur Benutzung und Konfiguration der Wiki-Software findest du im [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Benutzerhandbuch].\n\n== Starthilfen ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste der Konfigurationsvariablen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailingliste neuer MediaWiki-Versionen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Übersetze MediaWiki für deine Sprache]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Erfahre, wie du Spam auf deinem Wiki bekämpfen kannst]"
+}
diff --git a/www/wiki/includes/installer/i18n/diq.json b/www/wiki/includes/installer/i18n/diq.json
new file mode 100644
index 00000000..68b47206
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/diq.json
@@ -0,0 +1,96 @@
+{
+ "@metadata": {
+ "authors": [
+ "Erdemaslancan",
+ "Mirzali",
+ "Marmase",
+ "Kumkumuk",
+ "Gambollar"
+ ]
+ },
+ "config-desc": "Qandé MediaWiki sazi",
+ "config-title": "MediaWiki $1 sazkerdış",
+ "config-information": "Melumat",
+ "config-localsettings-key": "Kesay berzkerdin:",
+ "config-your-language": "Zıwanê şıma:",
+ "config-wiki-language": "Wiki zıwan:",
+ "config-wiki-language-help": "Degmesi zıwanê kı wiki do tey bınusi yo",
+ "config-back": "← Peyser",
+ "config-continue": "Dewam ke",
+ "config-page-language": "Zıwan",
+ "config-page-welcome": "Şıma xeyr ameyê MediaWiki!",
+ "config-page-dbconnect": "Database rê grêdey",
+ "config-page-upgrade": "Est bıyaye versiyoni berz kı",
+ "config-page-dbsettings": "Sazê Database",
+ "config-page-name": "Name",
+ "config-page-options": "Weçinegi",
+ "config-page-install": "Bar ke",
+ "config-page-complete": "Temamyayo",
+ "config-page-restart": "Barkerdışi fına ser kı",
+ "config-page-readme": "Mı bıwan",
+ "config-page-releasenotes": "Notë versiyoni",
+ "config-page-copying": "Kopyayeno",
+ "config-page-upgradedoc": "Berzkerdış",
+ "config-page-existingwiki": "Mewcud wiki",
+ "config-restart": "E, fına dest pekê",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki keye]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Şınasiya Karberi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Şınasiya İdarekaran]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Peşti]\n----\n* <doclink href=Readme>Mı buwanê</doclink>\n* <doclink href=ReleaseNotes>Notê elaqeyıni</doclink>\n* <doclink href=Copying>Kopyakerdış</doclink>\n* <doclink href=UpgradeDoc>Zêdekerdış</doclink>",
+ "config-env-php": "PHP $1 i biyo saz.",
+ "config-env-hhvm": "HHVM $1 saz bi ya.",
+ "config-db-type": "Database tipe:",
+ "config-db-host": "Database host:",
+ "config-db-host-oracle": "Database TNS:",
+ "config-db-wiki-settings": "Ena wikiyer akernë",
+ "config-db-name": "Database name:",
+ "config-db-name-oracle": "Şemaya hardata:",
+ "config-db-username": "Database nameykarberi:",
+ "config-db-password": "Database parola :",
+ "config-db-port": "Portê database:",
+ "config-oracle-def-ts": "Hesıbyaye caytabloy:",
+ "config-oracle-temp-ts": "İdareten caytabloy:",
+ "config-type-mysql": "MySQL (yana hewlın)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "Eyarê MySQL",
+ "config-header-sqlite": "SQLite sazi",
+ "config-header-oracle": "Orqcle sazi",
+ "config-header-mssql": "Sazë Microsoft SQL Serveri",
+ "config-missing-db-name": "\"{{int:config-db-name}}\"nrë jew erc dekerdış gerek keno.",
+ "config-missing-db-host": "\"{{int:config-db-host}}\" rë jew erc gerek keno",
+ "config-missing-db-server-oracle": "\"{{int:config-db-host-oracle}}\" rë jew erc gerek keno",
+ "config-mysql-engine": "Motorë depok kerdışi",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Dılet",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-sqlauth": "SQL Server araştnayış",
+ "config-mssql-windowsauth": "Windows kamiye araştnayış",
+ "config-site-name": "Namey wiki:",
+ "config-site-name-blank": "Yew nameyê sita cıkewe.",
+ "config-project-namespace": "Wareyê nameyê proceyi:",
+ "config-ns-generic": "Proce",
+ "config-ns-other": "Zewbi (keyfiyo)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "Hesabê Administratori",
+ "config-admin-name": "Nameyê şımayê karberi:",
+ "config-admin-password": "Parola:",
+ "config-admin-password-confirm": "Fına parola:",
+ "config-admin-email": "Adresa e-postey:",
+ "config-profile-wiki": "Wiki Ak",
+ "config-profile-private": "Bexse wiki",
+ "config-license": "Heqa telifi û lisans:",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-pd": "Malê Şari",
+ "config-email-settings": "Sazê e-posta",
+ "config-logo": "URL'ey Logoy:",
+ "config-extensions": "Olekeni",
+ "config-skins": "Temey",
+ "config-install-step-done": "qeyd ke",
+ "config-install-step-failed": "nêbı",
+ "config-install-schema": "Şema dek",
+ "config-install-pg-commit": "Vırnayışa cemaati",
+ "config-install-tables": "Tabloy dek",
+ "config-help": "peşti",
+ "mainpagetext": "<strong >MediaWiki vıst ra ser, vıraziya.</strong >",
+ "mainpagedocfooter": "Qandé ğebtiyayışi u sazkerdeışi Wiki-Softwarey [https://meta.wikimedia.org/wiki/Help:Contents İdarê karberi] de mıracaet ke.\n\n== Destega sergendi ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista eyaranê vıraştışi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki de ÇZP]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki ra lista serbest-dayışê postey]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Zıwandé şıma de Lokal MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/dsb.json b/www/wiki/includes/installer/i18n/dsb.json
new file mode 100644
index 00000000..93d90285
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/dsb.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Michawiki"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki jo se wuspěšnje instalěrowało.'''",
+ "mainpagedocfooter": "Pomoc pśi wužywanju softwary wiki namakajoš pód [https://meta.wikimedia.org/wiki/Help:Contents User's Guide].\n\nPomoc pśi wužywanju softwary wiki namakajoš pód [https://meta.wikimedia.org/wiki/Help:Contents User's Guide].\n\n== Na zachopjenje ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Konfiguracija lisćiny połoženjow]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ (pšašanja a wótegrona)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lisćina e-mailowych nakładow MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources MediaWiki za twóju rěc lokalizěrowaś]"
+}
diff --git a/www/wiki/includes/installer/i18n/dtp.json b/www/wiki/includes/installer/i18n/dtp.json
new file mode 100644
index 00000000..675cbcc7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/dtp.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "FRANELYA"
+ ]
+ },
+ "mainpagetext": "'''Nopongo no do popodokot ot ModiaWiki.'''",
+ "mainpagedocfooter": "Rujuko hilo [https://meta.wikimedia.org/wiki/Help:Contents Ponudukan Momomoguno] kokomoi koilaan do momoguno posusuang-suangon wiki.\n\n== Kopotimpuunan ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lis papatantu nuludan]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Ponguhatan Koinsoruan om Simbar ModiaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lis pininsuratan pinolabus do ModiaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/dty.json b/www/wiki/includes/installer/i18n/dty.json
new file mode 100644
index 00000000..a986e009
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/dty.json
@@ -0,0 +1,52 @@
+{
+ "@metadata": {
+ "authors": [
+ "राम प्रसाद जोशी"
+ ]
+ },
+ "config-desc": "मेडियाविकिको लागि स्थापक",
+ "config-title": "मेडिया विकि $1 स्थापना",
+ "config-information": "जानकारी",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code> फाइल पाइयो ।\nयै स्थापनालाई अपग्रेड गर्न, तलीखाइ दिया बक्समी <code>$wgUpgradeKey</code> को मान दर्ज गर ।\nतम <code>LocalSettings.php</code> मा भेट्टे हौ ।",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code> फाइल पाइयो ।\nयै स्थापनालाई अपग्रेड गद्द, बदलामी कृपया यैलाई चलाओ <code>update.php</code>",
+ "config-localsettings-key": "नवीनीकरण साँचो",
+ "config-localsettings-badkey": "तमले प्रोभाइट गर्याको नवनिकरण साँचो मान्य छैन",
+ "config-upgrade-key-missing": "An existing installation of MediaWiki has been detected.\nTo upgrade this installation, please put the following line at the bottom of your <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "The existing <code>LocalSettings.php</code> appears to be incomplete.\nThe $1 variable is not set.\nPlease change <code>LocalSettings.php</code> so that this variable is set, and click \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "An error was encountered when connecting to the database using the settings specified in <code>LocalSettings.php</code>. Please fix these settings and try again.\n\n$1",
+ "config-session-error": "Error starting session: $1",
+ "config-session-expired": "Your session data seems to have expired.\nSessions are configured for a lifetime of $1.\nYou can increase this by setting <code>session.gc_maxlifetime</code> in php.ini.\nRestart the installation process.",
+ "config-no-session": "तमरो सेसन डाटा मेटिया छ!\nतमरो php चेक गर। ini र make sure <code>session.save_path</code> is set to an appropriate directory.",
+ "config-your-language": "तमरो भाषा:",
+ "config-your-language-help": "इन्स्टाल गर्दा उपयोग गद्दे भाषा छान ।",
+ "config-wiki-language": "विकि भाषाहरू",
+ "config-wiki-language-help": "Select the language that the wiki will predominantly be written in.",
+ "config-back": "← पछाडी",
+ "config-continue": "जारी राख्या",
+ "config-page-language": "भाषा",
+ "config-page-welcome": "मिडीयाविकिमी तमलाई स्वागत छ!",
+ "config-page-dbconnect": "डेटाबेससँग सम्बन्ध बनाउन्या",
+ "config-page-upgrade": "मौजूदा स्थापनाको नवीनीकरण",
+ "config-page-dbsettings": "डेटावेस सेटिङ",
+ "config-page-name": "नाऊ",
+ "config-page-options": "विकल्पहरू",
+ "config-page-install": "स्थापना गद्दे",
+ "config-page-complete": "पूरा भयो !",
+ "config-page-restart": "स्थापना फेरि सुरु गद्दे",
+ "config-page-readme": "थप पड्डे",
+ "config-page-releasenotes": "प्रकाशन टिप्पणी",
+ "config-page-copying": "कपि हून लाग्याको छ",
+ "config-page-upgradedoc": "अद्यावधिक गरिदै",
+ "config-page-existingwiki": "विकि बन्द हुँदै",
+ "config-help-restart": "Do you want to clear all saved data that you have entered and restart the installation process?",
+ "config-restart": "हुन्छ, पुनः सुचारू गद्दे",
+ "config-welcome": "=== Environmental checks ===\nBasic checks will now be performed to see if this environment is suitable for MediaWiki installation.\nRemember to include this information if you seek support on how to complete the installation.",
+ "config-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis 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.\n\nThis program is distributed in the hope that it will be useful, but <strong>without any warranty</strong>; without even the implied warranty of <strong>merchantability</strong> or <strong>fitness for a particular purpose</strong>.\nSee the GNU General Public License for more details.\n\nYou should have received <doclink href=Copying>a copy of the GNU General Public License</doclink> along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, or [http://www.gnu.org/copyleft/gpl.html read it online].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki home]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Read me</doclink>\n* <doclink href=ReleaseNotes>Release notes</doclink>\n* <doclink href=Copying>Copying</doclink>\n* <doclink href=UpgradeDoc>Upgrading</doclink>",
+ "config-env-good": "The environment has been checked.\nYou can install MediaWiki.",
+ "config-env-bad": "The environment has been checked.\nYou cannot install MediaWiki.",
+ "config-env-php": "PHP $1 is installed.",
+ "config-env-hhvm": "HHVM $1 स्थापना गरिएको छ ।",
+ "config-unicode-using-intl": "Using the [http://pecl.php.net/intl intl PECL extension] for Unicode normalization.",
+ "config-unicode-pure-php-warning": "<strong>Warning:</strong> The [http://pecl.php.net/intl intl PECL extension] is not available to handle Unicode normalization, falling back to slow pure-PHP implementation.\nIf you run a high-traffic site, you should read a little on [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization]."
+}
diff --git a/www/wiki/includes/installer/i18n/el.json b/www/wiki/includes/installer/i18n/el.json
new file mode 100644
index 00000000..f9bab3ed
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/el.json
@@ -0,0 +1,237 @@
+{
+ "@metadata": {
+ "authors": [
+ "Glavkos",
+ "Protnet",
+ "ZaDiak",
+ "Astralnet",
+ "Geraki",
+ "Stam.nikos",
+ "Giorgos456",
+ "Badseed",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Το πρόγραμμα εγκατάστασης για το MediaWiki",
+ "config-title": "Εγκατάσταση MediaWiki $1",
+ "config-information": "Πληροφορίες",
+ "config-localsettings-upgrade": "Εντοπίστηκε αρχείο <code>LocalSettings.php</code>.\nΓια να αναβαθμίσετε αυτή την εγκατάσταση, παρακαλούμε να εισαγάγετε την τιμή του <code>$wgUpgradeKey</code> στο παρακάτω πλαίσιο.\nΘα το βρείτε στο <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Ένα αρχείο <code>LocalSettings.php</code> έχει εντοπιστεί.\nΓια να αναβαθμίσετε αυτή την εγκατάσταση, εκτελέστε το <code>update.php</code> αντ' αυτού.",
+ "config-localsettings-key": "Κλειδί αναβάθμισης:",
+ "config-localsettings-badkey": "Το κλειδί αναβάθμισης που δώσατε είναι εσφαλμένο.",
+ "config-upgrade-key-missing": "Έχει εντοπιστεί μια υπάρχουσα εγκατάσταση του MediaWiki.\nΓια να αναβαθμίσετε αυτήν την εγκατάσταση, παρακαλούμε να βάλετε την ακόλουθη γραμμή στο κάτω μέρος του <code>LocalSettings.php</code> σας:\n\n$1",
+ "config-localsettings-incomplete": "Το υπάρχον <code>LocalSettings.php</code> φαίνεται να είναι ελλιπές.\nΤο $1 μεταβλητή δεν έχει οριστεί.\nΠαρακαλούμε να αλλάξετε το <code>LocalSettings.php</code> έτσι ώστε αυτή η μεταβλητή έχει οριστεί, και κάντε κλικ στο \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Ένα σφάλμα παρουσιάστηκε κατά τη σύνδεση με τη βάση δεδομένων και με τη χρήση των ρυθμίσεων που ορίστηκαν στο <code>LocalSettings.php</code>. Παρακαλούμε διορθώστε αυτές τις ρυθμίσεις και δοκιμάστε ξανά.\n\n$1",
+ "config-session-error": "Σφάλμα κατά την εκκίνηση συνεδρίας: $1",
+ "config-session-expired": "Τα δεδομένα συνόδου φαίνεται να έχουν λήξει.\nΣυνεδρίες έχουν ρυθμιστεί για μια διάρκεια ζωής $1.\nΜπορείτε να αυξήσετε αυτό βάζοντας <code>session.gc_maxlifetime</code> στο php.ini.\nΚάντε επανεκκίνηση της διαδικασίας εγκατάστασης.",
+ "config-no-session": "Η συνεδρία δεδομένων σας έχει χαθεί!Ελέγξτε το αρχείο php.ini και βεβαιωθείτε ότι το <code>session.save_path</code> έχει μπει στον κατάλληλο κατάλογο.",
+ "config-your-language": "Η γλώσσα σας:",
+ "config-your-language-help": "Επιλέξτε μία γλώσσα για τη διαδικασία της εγκατάστασης.",
+ "config-wiki-language": "Γλώσσα του wiki:",
+ "config-wiki-language-help": "Επιλέξτε τη γλώσσα στην οποία θα γράφεται κυρίως το wiki.",
+ "config-back": "← Πίσω",
+ "config-continue": "Συνέχεια →",
+ "config-page-language": "Γλώσσα",
+ "config-page-welcome": "Καλώς ήλθατε στο MediaWiki!",
+ "config-page-dbconnect": "Σύνδεση με βάση δεδομένων",
+ "config-page-upgrade": "Αναβάθμιση υπάρχουσας εγκατάστασης",
+ "config-page-dbsettings": "Ρυθμίσεις της βάσης δεδομένων",
+ "config-page-name": "Όνομα",
+ "config-page-options": "Επιλογές",
+ "config-page-install": "Εγκατάσταση",
+ "config-page-complete": "Ολοκληρώθηκε!",
+ "config-page-restart": "Επανεκκίνηση εγκατάστασης",
+ "config-page-readme": "Διαβάστε με",
+ "config-page-releasenotes": "Σημειώσεις έκδοσης",
+ "config-page-copying": "Αντιγραφή",
+ "config-page-upgradedoc": "Αναβάθμιση",
+ "config-page-existingwiki": "Υπάρχον wiki",
+ "config-help-restart": "Θέλετε να καταργήσετε όλα τα αποθηκευμένα δεδομένα που έχετε εισαγάγει και να επανεκκινήσετε τη διαδικασία εγκατάστασης;",
+ "config-restart": "Ναι, επανεκκίνηση",
+ "config-welcome": "=== Περιβαλλοντικοί έλεγχοι ===\nΤώρα θα γίνουν βασικοί έλεγχοι για να δούμε αν αυτό το περιβάλλον είναι κατάλληλο για την εγκατάσταση του MediaWiki.\nΘυμηθείτε να συμπεριλάβετε αυτές τις πληροφορίες εάν αναζητήσετε υποστήριξη για το πώς να ολοκληρώσετε την εγκατάσταση.",
+ "config-copyright": "=== Πνευματικά δικαιώματα και Όροι ===\n\n$1\n\nΑυτό το πρόγραμμα είναι ελεύθερο λογισμικό• μπορείτε να το αναδιανείμετε ή και να το τροποποιήσετε υπό τους όρους της Γενικής Άδειας Δημόσιας Χρήσης GNU, όπως αυτή δημοσιεύεται από το Ίδρυμα Ελεύθερου Λογισμικού• είτε της έκδοσης 2 της Άδειας, είτε (κατά την επιλογή σας) οποιασδήποτε μεταγενέστερης έκδοσης.\n\nΑυτό το πρόγραμμα διανέμεται με την ελπίδα ότι θα είναι χρήσιμο, αλλά <strong>χωρίς καμία εγγύηση</strong>• χωρίς καν την υπονοούμενη εγγύηση της <strong>εμπορευσιμότητας</strong> ή της <strong>καταλληλοτότητας για συγκεκριμένο σκοπό</strong>.\nΔείτε την Γενική Άδεια Δημόσιας Χρήσης GNU για περισσότερες λεπτομέρειες.\n\nΘα πρέπει να έχετε λάβει <doclink href=\"Copying\">ένα αντίγραφο της Γενικής Άδειας Δημόσιας Χρήσης GNU</doclink> μαζί με αυτό το πρόγραμμα• αν όχι, γράψτε στο Free Software Foundation,\n51 Franklin Street, Fifth Floor,\nBoston, MA 02110-1335\nUSA ή [http://www.gnu.org/copyleft/gpl.html διαβάστε online].",
+ "config-sidebar": "* [https://www.mediawiki.org Αρχική MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Οδηγός Χρήστη]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Οδηγός Διαχειριστή]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Συχνές ερωτήσεις]\n----\n* <doclink href=\"Readme\">Διαβάστε με</doclink>\n* <doclink href=\"ReleaseNotes\">Σημειώσεις έκδοσης</doclink>\n* <doclink href=\"Copying\">Αντιγραφή</doclink>\n* <doclink href=\"UpgradeDoc\">Αναβάθμιση</doclink>",
+ "config-env-good": "Το περιβάλλον έχει ελεγχθεί.\nΜπορείτε να εγκαταστήσετε το MediaWiki.",
+ "config-env-bad": "Το περιβάλλον έχει ελεγχθεί.\nΔεν μπορείτε να εγκαταστήσετε το MediaWiki.",
+ "config-env-php": "H PHP $1 είναι εγκατεστημένη.",
+ "config-env-hhvm": "Το HHVM $1 είναι εγκατεστημένο.",
+ "config-unicode-using-intl": "Χρησιμοποιώντας την [http://pecl.php.net/intl επέκταση intl PECL] για κανονικοποίηση Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Προειδοποίηση:</strong> Η [http://pecl.php.net/intl επέκταση intl PECL] δεν είναι διαθέσιμη για να χειριστεί την κανονικοποίηση Unicode, επιστρέφουμε στην αργή αμιγώς PHP εφαρμογή.\nΕάν λειτουργείτε έναν ιστότοπο υψηλής επισκεψιμότητας, θα πρέπει να ρίξετε μια ματιά στην [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations κανονικοποίηση Unicode].",
+ "config-no-db": "Δεν βρέθηκε κάποιο κατάλληλο πρόγραμμα οδήγησης βάσης δεδομένων! Θα πρέπει να εγκαταστήσετε ένα πρόγραμμα οδήγησης βάσης δεδομένων για PHP.\nΟ παρακάτω {{PLURAL:$2|τύπος βάσης δεδομένων|τύποι βάσεων δεδομένων}} υποστηρίζονται: $1.\n\nΑν κάνετε compile την PHP μόνοι σας, ρυθμίστε ξανά τις παραμέτρους με κάποιον ενεργοποιημένο εξυπηρετητή βάσεων δεδομένων, για παράδειγμα, χρησιμοποιώντας την εντολή <code>./configure --with-mysqli</code>.\nΕάν έχετε εγκαταστήσει την PHP από κάποιο πακέτο στο Debian ή στο Ubuntu, τότε θα πρέπει να εγκαταστήσετε επίσης, για παράδειγμα, το πακέτο <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Προειδοποίηση:</strong> έχετε την SQLite έκδοση $1, που είναι χαμηλότερη από την ελάχιστη απαιτούμενη έκδοση $2. Η SQLite δεν θα είναι διαθέσιμη.",
+ "config-pcre-no-utf8": "<strong>Κρίσιμο:</strong> Το PCRE module της PHP φαίνεται να έχει μεταγλωττιστεί χωρίς υποστήριξη PCRE_UTF8.\nΓια τη σωστή λειτουργία του MediaWiki απαιτείται υποστήριξη UTF-8.",
+ "config-memory-raised": "Το <code>memory_limit</code> της PHP είναι $1 και αυξήθηκε σε $2.",
+ "config-memory-bad": "<strong>Προειδοποίηση:</strong> το <code>memory_limit</code> της PHP είναι $1.\nΑυτή η τιμή είναι πιθανώς πολύ χαμηλή.\n\nΗ εγκατάσταση ενδέχεται να αποτύχει!",
+ "config-xcache": "[http://xcache.lighttpd.net/ Το XCache] είναι εγκατεστημένο",
+ "config-apc": "Το [http://www.php.net/apc APC] είναι εγκατεστημένο",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp Το WinCache] είναι εγκατεστημένο",
+ "config-diff3-bad": "Το GNU diff3 δεν βρέθηκε.",
+ "config-git": "Βρέθηκε η Git έκδοση λογισμικού ελέγχου: <code>$1</code>.",
+ "config-git-bad": "Η Git έκδοση του λογισμικού ελέγχου δεν βρέθηκε.",
+ "config-no-uri": "<strong>Σφάλμα:</strong> Δεν ήταν δυνατό να καθοριστεί το τρέχον URI.\nΗ εγκατάσταση ματαιώθηκε.",
+ "config-using-server": "Χρησιμοποιείται το όνομα διακομιστή \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Χρησιμοποιώντας την διεύθυνση URL του διακομιστή \"<nowiki>$1$2</nowiki>\".",
+ "config-brokenlibxml": "Το σύστημά σας έχει έναν συνδυασμό εκδόσεων της PHP και της libxml2 που είναι προβληματικός και μπορεί να προκαλέσει καταστροφή κρυμμένων στοιχείων στο MediaWiki και σε άλλες εφαρμογές ιστού.\nΑναβαθμίστε σε libxml2 2.7.3 ή μεταγενέστερη έκδοση ([https://bugs.php.net/bug.php?id=45996 bug που έχει καταχωριστεί για την PHP]).\nΗ εγκατάσταση ματαιώθηκε.",
+ "config-db-type": "Τύπος βάσης δεδομένων:",
+ "config-db-host": "Φιλοξενία βάσης δεδομένων:",
+ "config-db-host-oracle": "Βάση δεδομένων TNS:",
+ "config-db-wiki-settings": "Αναγνώριση αυτού του wiki",
+ "config-db-name": "Όνομα βάσης δεδομένων:",
+ "config-db-name-help": "Επιλέξτε ένα όνομα που ταιριάζει στο wiki σας. Δεν πρέπει να περιέχει κενά διαστήματα.\n\nΕάν χρησιμοποιείτε κοινόχρηστο web hosting, ο πάροχος φιλοξενίας είτε θα σας δώσει ένα συγκεκριμένο όνομα βάσης δεδομένων για να χρησιμοποιήσετε ή θα σας δώσει τη δυνατότητα να δημιουργήσετε βάσεις δεδομένων μέσω ενός πίνακα ελέγχου.",
+ "config-db-name-oracle": "Σχήμα βάσης δεδομένων:",
+ "config-db-install-account": "Λογαριασμός χρήστη για την εγκατάσταση",
+ "config-db-username": "Όνομα χρήστη βάσης δεδομένων:",
+ "config-db-password": "Κωδικός πρόσβασης βάσης δεδομένων:",
+ "config-db-install-help": "Εισαγάγετε το όνομα χρήστη και τον κωδικό πρόσβασης που θα χρησιμοποιηθεί για τη σύνδεση με τη βάση δεδομένων κατά τη διάρκεια της διαδικασίας εγκατάστασης.",
+ "config-db-account-lock": "Χρησιμοποιήστε το ίδιο όνομα χρήστη και συνθηματικό κατά τη διάρκεια της κανονικής λειτουργίας",
+ "config-db-wiki-account": "Λογαριασμός χρήστη για κανονική λειτουργία",
+ "config-db-wiki-help": "Πληκτρολογήστε το όνομα χρήστη και τον κωδικό πρόσβασης που θα χρησιμοποιηθεί για τη σύνδεση με τη βάση δεδομένων κατά τη διάρκεια της κανονικής λειτουργίας του wiki.\nΕάν ο λογαριασμός δεν υπάρχει και o λογαριασμός εγκατάστασης έχει επαρκή δικαιώματα, αυτός ο λογαριασμός χρήστη θα δημιουργηθεί με τα ελάχιστα δικαιώματα που απαιτούνται για τη λειτουργία του wiki.",
+ "config-db-prefix": "Πρόθεμα πίνακα βάσης δεδομένων:",
+ "config-db-prefix-help": "Εάν χρειάζεται να μοιραστείτε μία βάση δεδομένων μεταξύ πολλαπλών wikis, ή μεταξύ του MediaWiki και μιας άλλης web εφαρμογής, μπορείτε να προσθέσετε ένα πρόθεμα σε όλα τα ονόματα πίνακα για να αποφεύγονται οι διενέξεις.\nΜην χρησιμοποιείτε κενά διαστήματα.\n\nΑυτό το πεδίο αφήνεται συνήθως κενό.",
+ "config-mysql-old": "Απαιτείται Microsoft SQL Server $1 ή νεότερο. Εσείς έχετε $2.",
+ "config-db-port": "Θύρα βάσης δεδομένων:",
+ "config-db-schema": "Σχήμα για MediaWiki:",
+ "config-db-schema-help": "Αυτό το σχήμα συνήθως αρκεί.\nΑλλάξτε το μόνο αν είστε βέβαιοι ότι χρειάζεται.",
+ "config-pg-test-error": "Δεν μπορεί να συνδεθεί στη βάση δεδομένων <strong>$1</strong>: $2",
+ "config-sqlite-dir": "SQLite κατάλογος δεδομένων:",
+ "config-oracle-temp-ts": "Προσωρινό tablespace:",
+ "config-type-mysql": "MySQL (ή συμβατό)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "To MediaWiki υποστηρίζει τα ακόλουθα συστήματα βάσεων δεδομένων:\n\n$1\n\nΑν δεν εμφανίζεται παρακάτω το σύστημα βάσης δεδομένων που θέλετε να χρησιμοποιήσετε, τότε ακολουθήστε τις οδηγίες στον παραπάνω σύνδεσμο για να ενεργοποιήσετε την υποστήριξη.",
+ "config-header-mysql": "Ρυθμίσεις MySQL",
+ "config-header-postgres": "Ρυθμίσεις PostgreSQL",
+ "config-header-sqlite": "Ρυθμίσεις SQLite",
+ "config-header-oracle": "Ρυθμίσεις Oracle",
+ "config-header-mssql": "Ρυθμίσεις του Microsoft SQL Server",
+ "config-invalid-db-type": "Μη έγκυρος τύπος βάσης δεδομένων",
+ "config-missing-db-name": "Πρέπει να εισαγάγετε μια τιμή για \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Πρέπει να εισαγάγετε μια τιμή για \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Πρέπει να εισαγάγετε μια τιμή για \"{{int:config-db-host-oracle}}\".",
+ "config-connection-error": "$1.\n\nΕλέγξτε τη διεύθυνση της βάσης δεδομένων, το όνομα χρήστη και το συνθηματικό και προσπαθήστε ξανά.",
+ "config-db-sys-user-exists-oracle": "Ο λογαριασμός χρήστη \"$1\" υπάρχει ήδη. Το SYSDBA μπορεί να χρησιμοποιηθεί μόνο για τη δημιουργία ενός νέου λογαριασμού!",
+ "config-postgres-old": "Απαιτείται PostgreSQL $1 ή νεότερο. Εσείς έχετε $2.",
+ "config-mssql-old": "Απαιτείται Microsoft SQL Server $1 ή νεότερο. Εσείς έχετε $2.",
+ "config-sqlite-readonly": "Το αρχείο <code>$1</code> δεν είναι εγγράψιμο.",
+ "config-sqlite-cant-create-db": "Δεν ήταν δυνατή η δημιουργία του αρχείου βάσης δεδομένων <code>$1</code>.",
+ "config-upgrade-done-no-regenerate": "Η αναβάθμιση ολοκληρώθηκε.\n\nΜπορείτε τώρα να [$1 ξεκινήσετε να χρησιμοποιείτε το wiki σας].",
+ "config-regenerate": "Αναδημιουργία LocalSettings.php →",
+ "config-db-web-account": "Λογαριασμός βάσης δεδομένων για πρόσβαση ιστού",
+ "config-db-web-account-same": "Χρήση του ίδιου λογαριασμού για την εγκατάσταση",
+ "config-mysql-engine": "Μηχανή αποθήκευσης:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Σύνολο χαρακτήρων βάσης δεδομένων:",
+ "config-mysql-binary": "Δυαδικό",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Τύπος ελέγχου ταυτότητας:",
+ "config-mssql-sqlauth": "Έλεγχος ταυτότητας του SQL Server",
+ "config-mssql-windowsauth": "Έλεγχος ταυτότητας των Windows",
+ "config-site-name": "Όνομα του wiki:",
+ "config-site-name-help": "Αυτό θα εμφανίζεται στη γραμμή τίτλου του προγράμματος περιήγησης και σε διάφορα άλλα μέρη.",
+ "config-site-name-blank": "Εισαγάγετε όνομα ιστοχώρου.",
+ "config-project-namespace": "Ονοματοχώρος εγχειρήματος:",
+ "config-ns-generic": "Εγχείρημα",
+ "config-ns-site-name": "Ίδιο με το όνομα του wiki: $1",
+ "config-ns-other": "Άλλο (προσδιορίστε)",
+ "config-ns-other-default": "ΤοWikiμου",
+ "config-admin-box": "Λογαριασμός διαχειριστή",
+ "config-admin-name": "Το όνομα χρήστη σας:",
+ "config-admin-password": "Κωδικός πρόσβασης:",
+ "config-admin-password-confirm": "Επανάληψη κωδικού πρόσβασης:",
+ "config-admin-name-blank": "Εισαγάγετε όνομα χρήστη διαχειριστή.",
+ "config-admin-name-invalid": "Το συγκεκριμένο όνομα χρήστη \"<nowiki>$1</nowiki>\" δεν είναι έγκυρο. Δώστε ένα διαφορετικό όνομα χρήστη.",
+ "config-admin-password-blank": "Εισαγάγετε κωδικό για το λογαριασμό διαχειριστή.",
+ "config-admin-password-mismatch": "Οι δύο κωδικοί πρόσβασης που εισηγάγατε δεν ταιριάζουν.",
+ "config-admin-email": "Διεύθυνση ηλεκτρονικού ταχυδρομείου:",
+ "config-admin-error-bademail": "Έχετε εισαγάγει μη έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου.",
+ "config-optional-continue": "Να ερωτηθώ περισσότερες ερωτήσεις.",
+ "config-optional-skip": "Βαρέθηκα ήδη, απλά εγκαταστήστε το wiki.",
+ "config-profile": "Προφίλ δικαιωμάτων χρήστη:",
+ "config-profile-wiki": "Ανοικτό wiki",
+ "config-profile-no-anon": "Απαιτείται η δημιουργία λογαριασμού",
+ "config-profile-fishbowl": "Εξουσιοδοτημένοι συντάκτες μόνο",
+ "config-profile-private": "Ιδιωτικό wiki",
+ "config-license": "Πνευματικά δικαιώματα και άδεια χρήσης:",
+ "config-license-none": "Χωρίς άδεια χρήσης στο υποσέλιδο",
+ "config-license-cc-by-sa": "Creative Commons Αναφορά Δημιουργού-Παρόμοια Διανομή",
+ "config-license-cc-by": "Creative Commons Αναφορά Δημιουργού",
+ "config-license-cc-by-nc-sa": "Creative Commons Αναφορά Δημιουργού-Μη Εμπορική Χρήση-Παρόμοια Διανομή",
+ "config-license-cc-0": "Creative Commons Μηδέν (Κοινό Κτήμα)",
+ "config-license-gfdl": "Αδειοδότηση Ελεύθερης Τεκμηρίωσης GNU 1.3 ή μεταγενέστερη",
+ "config-license-pd": "Κοινό Κτήμα",
+ "config-license-cc-choose": "Επιλέξτε μια προσαρμοσμένη άδεια Creative Commons",
+ "config-email-settings": "Ρυθμίσεις ηλεκτρονικού ταχυδρομείου",
+ "config-email-user": "Ενεργοποίηση ηλεκτρονικού ταχυδρομείου από χρήστη σε χρήστη",
+ "config-email-usertalk": "Ενεργοποίηση ειδοποίησης σελίδας συζήτησης χρήστη",
+ "config-email-watchlist": "Ενεργοποίηση ειδοποίησης λίστας παρακολούθησης",
+ "config-email-watchlist-help": "Επιτρέψτε στους χρήστες να λαμβάνουν ειδοποιήσεις για τις σελίδες που παρακολουθούν αν το έχουν ενεργοποιήσει στις προτιμήσεις τους.",
+ "config-email-auth": "Ενεργοποίηση ταυτοποίησης μέσω ηλεκτρονικού ταχυδρομείου",
+ "config-email-sender": "Διεύθυνση ηλεκτρονικού ταχυδρομείου επιστροφής:",
+ "config-upload-settings": "Ανέβασμα εικόνων και άλλων αρχείων",
+ "config-upload-enable": "Ενεργοποιήστε το ανέβασμα αρχείων",
+ "config-upload-help": "Το ανέβασμα αρχείων εκθέτει πιθανώς το διακομιστή σας σε κινδύνους ασφαλείας.\nΓια περισσότερες πληροφορίες, διαβάστε την [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security ενότητα περί ασφάλειας] στο εγχειρίδιο.\n\nΓια να ενεργοποιήσετε το ανέβασμα αρχείων, αλλάξτε την κατάσταση του υποκαταλόγου <code>εικόνες</code> που βρίσκεται κάτω από τον ριζικό κατάλογο του MediaWiki έτσι ώστε ο διακομιστής ιστού να μπορεί να γράψει σε αυτόν.\nΣτη συνέχεια ενεργοποιήσετε αυτή την επιλογή.",
+ "config-upload-deleted": "Καταλόγος για διαγραφέντα αρχεία:",
+ "config-logo": "Διεύθυνση URL λογότυπου:",
+ "config-instantcommons": "Ενεργοποίηση Instant Commons",
+ "config-cc-error": "Ο επιλογέας αδειών Creative Commons επιλογέα δεν έδωσε κανένα αποτέλεσμα.\nΕισάγετε το όνομα της άδειας χειροκίνητα.",
+ "config-cc-again": "Επιλέξτε ξανά...",
+ "config-cc-not-chosen": "Επιλέξτε την άδεια Creative Commons που θέλετε και κάντε κλικ στο κουμπί \"proceed\".",
+ "config-advanced-settings": "Προηγμένες ρυθμίσεις παραμέτρων",
+ "config-cache-options": "Ρυθμίσεις για την προσωρινή αποθήκευση αντικειμένου:",
+ "config-memcache-badip": "Έχετε εισάγει μια μη έγκυρη διεύθυνση IP για το Memcached: $1.",
+ "config-memcache-noport": "Δεν καθορίσατε μια θύρα για να χρησιμοποιήσετε για το Memcached server: $1.\nΑν δεν ξέρετε τη θύρα, η προεπιλογή είναι 11211.",
+ "config-memcache-badport": "Οι Memcached αριθμοί θύρας θα πρέπει να είναι μεταξύ $1 και $2.",
+ "config-extensions": "Επεκτάσεις",
+ "config-extensions-help": "Οι επεκτάσεις που αναφέρονται ανωτέρω εντοπίστηκαν στο φακελό σας <code>./extensions</code>.\n\nΜπορεί να απαιτούν επιπλέον παραμετροποιήσεις, αλλά μπορείτε να τις ενεργοποιήσετε τώρα.",
+ "config-skins": "Θέματα εμφάνισης",
+ "config-skins-help": "Τα θέματα εμφάνισης που αναφέρονται παραπάνω εντοπίστηκαν στον κατάλογο <code>./skins</code>. Πρέπει να ενεργοποιήσετε τουλάχιστον ένα και να επιλέξτε ποιο θα είναι το προεπιλεγμένο.",
+ "config-skins-use-as-default": "Χρήση αυτού του θέματος εμφάνισης ως προεπιλογή",
+ "config-skins-missing": "Κανένα θέμα εμφάνισης δεν βρέθηκε: Το MediaWiki θα χρησιμοποιήσει ένα προγενέστερο θέμα εμφάνισης μέχρι να εγκαταστήσετε κάποια κανονικά.",
+ "config-skins-must-enable-some": "Πρέπει να επιλέξετε τουλάχιστον μία εμφάνιση να την ενεργοποιήσετε.",
+ "config-skins-must-enable-default": "Το θέμα εμφάνισης που επιλέχθηκε ως προεπιλεγμένο πρέπει να είναι ενεργοποιημένο.",
+ "config-install-alreadydone": "<strong>Προειδοποίηση:</strong> Φαίνεται πως έχετε ήδη εγκατεστημένο το MediaWiki και προσπαθείτε να το εγκαταστήσετε ξανά.\nΠαρακαλώ προχωρήστε στην επόμενη σελίδα.",
+ "config-install-begin": "Πατώντας «{{int:config-continue}}» θα ξεκινήσει η εγκατάσταση του MediaWiki.\nΕάν θέλετε ακόμα να κάνετε αλλαγές, πατήστε «{{int:config-back}}».",
+ "config-install-step-done": "έγινε",
+ "config-install-step-failed": "απέτυχε",
+ "config-install-extensions": "Γίνεται συμπερίληψη των επεκτάσεων",
+ "config-install-database": "Ρύθμιση βάσης δεδομένων",
+ "config-install-schema": "Γίνεται δημιουργία του σχήματος της βάσης δεδομένων",
+ "config-install-pg-schema-not-exist": "PostgreSQL σχήμα δεν υφίσταται.",
+ "config-install-pg-schema-failed": "Η δημιουργία πινάκων απέτυχε.\nΒεβαιωθείτε ότι ο χρήστης \"$1\" μπορεί να γράψει στο σχήμα \"$2\".",
+ "config-install-pg-commit": "Γίνονται οι αλλαγές",
+ "config-install-pg-plpgsql": "Γίνεται έλεγχος για τη γλώσσα PL/pgSQL",
+ "config-pg-no-plpgsql": "Πρέπει να εγκαταστήσετε τη γλώσσα PL/pgSQL στη βάση δεδομένων $1",
+ "config-install-user": "Γίνεται η δημιουργία του χρήστη της βάσης δεδομένων",
+ "config-install-user-alreadyexists": "Ο χρήστης \"$1\" υπάρχει ήδη",
+ "config-install-user-create-failed": "Η δημιουργία του χρήστη «$1» απέτυχε: $2",
+ "config-install-user-grant-failed": "Η παροχή άδειας στο χρήστη «$1» απέτυχε: $2",
+ "config-install-user-missing": "Ο χρήστης «$1» που καθορίστηκε δεν υπάρχει.",
+ "config-install-tables": "Γίνεται δημιουργία πινάκων",
+ "config-install-tables-exist": "<strong>Προειδοποίηση:</strong> Οι πίνακες MediaWiki φαίνεται να υπάρχουν ήδη.\nΠαρακάμπτοντας τη δημιουργία.",
+ "config-install-tables-failed": "<strong>Σφάλμα:</strong>Η δημιουργία πινάκων απέτυχε με το ακόλουθο μήνυμα λάθους: $1",
+ "config-install-interwiki": "Γίνεται συμπλήρωση του προεπιλεγμένου πίνακα interwiki",
+ "config-install-interwiki-list": "Αδυναμία ανάγνωσης του αρχείου <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Προειδοποίηση:</strong> O πίνακας interwiki φαίνεται να έχει ήδη καταχωρηθεί.\nΠαρακάμπτοντας προεπιλεγμένη λίστα.",
+ "config-install-stats": "Γίνεται αρχικοποίηση των στατιστικών",
+ "config-install-keys": "Γίνεται δημιουργία των μυστικών κλειδιών",
+ "config-install-updates": "Αποτρέψτε αχρειάστες ενημερώσεις",
+ "config-install-updates-failed": "<strong>Σφάλμα:</strong> Η εισαγωγή κλειδιών ενημέρωσης σε πίνακες απέτυχε με το ακόλουθο σφάλμα: $1",
+ "config-install-sysop": "Γίνεται δημιουργία του λογαριασμού χρήστη του διαχειριστή",
+ "config-install-subscribe-fail": "Ανίκανος να εγγραφείτε στο mediawiki-ανακοινώση: $1",
+ "config-install-subscribe-notpossible": "Το cURL δεν είναι εγκατεστημένο και το <code>allow_url_fopen</code> δεν είναι διαθέσιμο.",
+ "config-install-mainpage": "Γίνεται δημιουργία της αρχικής σελίδας με προεπιλεγμένο περιεχόμενο",
+ "config-install-mainpage-exists": "Κύρια σελίδα ήδη υπάρχει, παρακάμπτεται",
+ "config-install-extension-tables": "Γίνεται δημιουργία πινάκων για τις εγκατεστημένες επεκτάσεις",
+ "config-install-mainpage-failed": "Δεν ήταν δυνατή η εισαγωγή της αρχικής σελίδας: $1",
+ "config-install-done": "<strong>Συγχαρητήρια!</strong>\nΈχετε εγκαταστήσει με επιτυχία το MediaWiki.\n\nΤο πρόγραμμα εγκατάστασης έχει δημιουργήσει το αρχείο <code>LocalSettings.php</code>.\nΠεριέχει όλες τις ρυθμίσεις παραμέτρων σας.\n\nΘα πρέπει να το κατεβάσετε και να το βάλετε στη βάση της εγκατάστασης του wiki σας (στον ίδιο κατάλογο όπως το index.php). Η λήψη θα αρχίσει αυτόματα.\n\nΑν η λήψη δεν προσφέφθηκε, ή αν την ακυρώσατε, μπορείτε να επανεκκινήσετε τη λήψη κάνοντας κλικ στο παρακάτω link:\n\n$3\n\n<strong>Σημείωση:</strong> Εάν δεν το κάνετε αυτό τώρα, αυτό το αρχείο ρύθμισης παραμέτρων δεν θα είναι διαθέσιμο για σας αργότερα, αν βγείτε από την εγκατάσταση, χωρίς να το κατεβάσετε!\n\nΌταν γίνει αυτό, μπορείτε να <strong>[$2 μπείτε στο wiki σας]</strong>.",
+ "config-download-localsettings": "Λήψη του <code>LocalSettings.php</code>",
+ "config-help": "βοήθεια",
+ "config-help-tooltip": "κλικ για ανάπτυξη",
+ "config-nofile": "Το αρχείο «$1» δεν μπορεί να βρεθεί. Μήπως έχει διαγραφεί;",
+ "config-extension-link": "Γνωρίζατε ότι το wiki σας υποστηρίζει [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions επεκτάσεις];\n\nΜπορείτε να περιηγηθείτε [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category επεκτάσεις ανά κατηγορία] ή το [https://www.mediawiki.org/wiki/Extension_Matrix Extension Matrix] για να δείτε την πλήρη λίστα των επεκτάσεων.",
+ "mainpagetext": "<strong>To MediaWiki εγκαταστάθηκε με επιτυχία.</strong>",
+ "mainpagedocfooter": "Συμβουλευτείτε το [https://meta.wikimedia.org/wiki/Help:Contents Οδηγός Χρήστη] για πληροφορίες σχετικά με το λογισμικό wiki.\n\n== Ξεκινώντας ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings ρυθμίσεις Διαμόρφωσης λίστα]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ το MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce το MediaWiki απελευθέρωση mailing list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Έχουν MediaWiki για τη γλώσσα σας]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Μάθετε πώς να καταπολεμήσετε το spam στο wiki σας]"
+}
diff --git a/www/wiki/includes/installer/i18n/eml.json b/www/wiki/includes/installer/i18n/eml.json
new file mode 100644
index 00000000..37fefefa
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/eml.json
@@ -0,0 +1,16 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gloria sah"
+ ]
+ },
+ "config-information": "Infurmasiòun",
+ "config-your-language": "La tó lengva:",
+ "config-page-language": "Lengva",
+ "config-charset-mysql5-binary": "binàri MySQL 4.1/5.0",
+ "config-charset-mysql5": "MySQL 4.1/5.0 UTF-8",
+ "config-admin-password-mismatch": "El dó paró li cêv 't ê pruvê i n'vàn mia bèin.",
+ "config-admin-email": "Indirìs e-mail:",
+ "config-optional-continue": "Edmànd-em de piò.",
+ "config-license-cc-by": "Atribusiòun Creative Commons"
+}
diff --git a/www/wiki/includes/installer/i18n/en-gb.json b/www/wiki/includes/installer/i18n/en-gb.json
new file mode 100644
index 00000000..30796e3d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/en-gb.json
@@ -0,0 +1,25 @@
+{
+ "@metadata": {
+ "authors": [
+ "Shirayuki",
+ "Caliburn"
+ ]
+ },
+ "config-desc": "The installer for MediaWiki",
+ "config-title": "MediaWiki $1 installation",
+ "config-information": "Information",
+ "config-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public Licence as published by the Free Software Foundation; either version 2 of the Licence, or (at your option) any later version.\n\nThis 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'''.\nSee the GNU General Public Licence for more details.\n\nYou should have received <doclink href=Copying>a copy of the GNU General Public Licence</doclink> along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. or [http://www.gnu.org/copyleft/gpl.html read it online].",
+ "config-unicode-using-intl": "Using the [http://pecl.php.net/intl intl PECL extension] for Unicode normalisation.",
+ "config-unicode-pure-php-warning": "'''Warning:''' The [http://pecl.php.net/intl intl PECL extension] is not available to handle Unicode normalisation, falling back to slow pure-PHP implementation.\nIf you run a high-traffic site, you should read a little on [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalisation].",
+ "config-unicode-update-warning": "'''Warning:''' The installed version of the Unicode normalisation wrapper uses an older version of [http://site.icu-project.org/ the ICU project's] library.\nYou should [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations upgrade] if you are at all concerned about using Unicode.",
+ "config-unknown-collation": "'''Warning:''' Database is using unrecognised collation.",
+ "config-profile-fishbowl": "Authorised editors only",
+ "config-license": "Copyright and licence:",
+ "config-license-none": "No licence footer",
+ "config-license-gfdl": "GNU Free Documentation Licence 1.3 or later",
+ "config-license-cc-choose": "Select a custom Creative Commons licence",
+ "config-license-help": "Many public wikis put all contributions under a [http://freedomdefined.org/Definition free licence].\nThis helps to create a sense of community ownership and encourages long-term contribution.\nIt is not generally necessary for a private or corporate wiki.\n\nIf you want to be able to use text from Wikipedia, and you want Wikipedia to be able to accept text copied from your wiki, you should choose <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia previously used the GNU Free Documentation Licence.\nThe GFDL is a valid licence, but it is difficult to understand.\nIt is also difficult to reuse content licenced under the GFDL.",
+ "config-cc-error": "The Creative Commons licence chooser gave no result.\nEnter the licence name manually.",
+ "config-cc-not-chosen": "Choose which Creative Commons licence you want and click \"proceed\".",
+ "config-install-stats": "Initialising statistics"
+}
diff --git a/www/wiki/includes/installer/i18n/en.json b/www/wiki/includes/installer/i18n/en.json
new file mode 100644
index 00000000..6319b76d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/en.json
@@ -0,0 +1,317 @@
+{
+ "@metadata": {
+ "authors": []
+ },
+ "config-desc": "The installer for MediaWiki",
+ "config-title": "MediaWiki $1 installation",
+ "config-information": "Information",
+ "config-localsettings-upgrade": "A <code>LocalSettings.php</code> file has been detected.\nTo upgrade this installation, please enter the value of <code>$wgUpgradeKey</code> in the box below.\nYou will find it in <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "A <code>LocalSettings.php</code> file has been detected.\nTo upgrade this installation, please run <code>update.php</code> instead",
+ "config-localsettings-key": "Upgrade key:",
+ "config-localsettings-badkey": "The upgrade key you provided is incorrect.",
+ "config-upgrade-key-missing": "An existing installation of MediaWiki has been detected.\nTo upgrade this installation, please put the following line at the bottom of your <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "The existing <code>LocalSettings.php</code> appears to be incomplete.\nThe $1 variable is not set.\nPlease change <code>LocalSettings.php</code> so that this variable is set, and click \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "An error was encountered when connecting to the database using the settings specified in <code>LocalSettings.php</code>. Please fix these settings and try again.\n\n$1",
+ "config-session-error": "Error starting session: $1",
+ "config-session-expired": "Your session data seems to have expired.\nSessions are configured for a lifetime of $1.\nYou can increase this by setting <code>session.gc_maxlifetime</code> in php.ini.\nRestart the installation process.",
+ "config-no-session": "Your session data was lost!\nCheck your php.ini and make sure <code>session.save_path</code> is set to an appropriate directory.",
+ "config-your-language": "Your language:",
+ "config-your-language-help": "Select a language to use during the installation process.",
+ "config-wiki-language": "Wiki language:",
+ "config-wiki-language-help": "Select the language that the wiki will predominantly be written in.",
+ "config-back": "← Back",
+ "config-continue": "Continue →",
+ "config-page-language": "Language",
+ "config-page-welcome": "Welcome to MediaWiki!",
+ "config-page-dbconnect": "Connect to database",
+ "config-page-upgrade": "Upgrade existing installation",
+ "config-page-dbsettings": "Database settings",
+ "config-page-name": "Name",
+ "config-page-options": "Options",
+ "config-page-install": "Install",
+ "config-page-complete": "Complete!",
+ "config-page-restart": "Restart installation",
+ "config-page-readme": "Read me",
+ "config-page-releasenotes": "Release notes",
+ "config-page-copying": "Copying",
+ "config-page-upgradedoc": "Upgrading",
+ "config-page-existingwiki": "Existing wiki",
+ "config-help-restart": "Do you want to clear all saved data that you have entered and restart the installation process?",
+ "config-restart": "Yes, restart it",
+ "config-welcome": "=== Environmental checks ===\nBasic checks will now be performed to see if this environment is suitable for MediaWiki installation.\nRemember to include this information if you seek support on how to complete the installation.",
+ "config-copyright": "=== Copyright and Terms ===\n\n$1\n\nThis 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.\n\nThis program is distributed in the hope that it will be useful, but <strong>without any warranty</strong>; without even the implied warranty of <strong>merchantability</strong> or <strong>fitness for a particular purpose</strong>.\nSee the GNU General Public License for more details.\n\nYou should have received <doclink href=Copying>a copy of the GNU General Public License</doclink> along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, or [http://www.gnu.org/copyleft/gpl.html read it online].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki home]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Read me</doclink>\n* <doclink href=ReleaseNotes>Release notes</doclink>\n* <doclink href=Copying>Copying</doclink>\n* <doclink href=UpgradeDoc>Upgrading</doclink>",
+ "config-env-good": "The environment has been checked.\nYou can install MediaWiki.",
+ "config-env-bad": "The environment has been checked.\nYou cannot install MediaWiki.",
+ "config-env-php": "PHP $1 is installed.",
+ "config-env-hhvm": "HHVM $1 is installed.",
+ "config-unicode-using-intl": "Using the [http://pecl.php.net/intl intl PECL extension] for Unicode normalization.",
+ "config-unicode-pure-php-warning": "<strong>Warning:</strong> The [http://pecl.php.net/intl intl PECL extension] is not available to handle Unicode normalization, falling back to slow pure-PHP implementation.\nIf you run a high-traffic site, you should read a little on [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].",
+ "config-unicode-update-warning": "<strong>Warning:</strong> The installed version of the Unicode normalization wrapper uses an older version of [http://site.icu-project.org/ the ICU project's] library.\nYou should [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations upgrade] if you are at all concerned about using Unicode.",
+ "config-no-db": "Could not find a suitable database driver! You need to install a database driver for PHP.\nThe following database {{PLURAL:$2|type is|types are}} supported: $1.\n\nIf you compiled PHP yourself, reconfigure it with a database client enabled, for example, using <code>./configure --with-mysqli</code>.\nIf you installed PHP from a Debian or Ubuntu package, then you also need to install, for example, the <code>php5-mysql</code> package.",
+ "config-outdated-sqlite": "<strong>Warning:</strong> you have SQLite $1, which is lower than minimum required version $2. SQLite will be unavailable.",
+ "config-no-fts3": "<strong>Warning:</strong> SQLite is compiled without the [//sqlite.org/fts3.html FTS3 module], search features will be unavailable on this backend.",
+ "config-pcre-old": "<strong>Fatal:</strong> PCRE $1 or later is required.\nYour PHP binary is linked with PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE More information].",
+ "config-pcre-no-utf8": "<strong>Fatal:</strong> PHP's PCRE module seems to be compiled without PCRE_UTF8 support.\nMediaWiki requires UTF-8 support to function correctly.",
+ "config-memory-raised": "PHP's <code>memory_limit</code> is $1, raised to $2.",
+ "config-memory-bad": "<strong>Warning:</strong> PHP's <code>memory_limit</code> is $1.\nThis is probably too low.\nThe installation may fail!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] is installed",
+ "config-apc": "[http://www.php.net/apc APC] is installed",
+ "config-apcu": "[http://www.php.net/apcu APCu] is installed",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] is installed",
+ "config-no-cache-apcu": "<strong>Warning:</strong> Could not find [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] or [http://www.iis.net/download/WinCacheForPhp WinCache].\nObject caching is not enabled.",
+ "config-mod-security": "<strong>Warning:</strong> Your web server has [http://modsecurity.org/ mod_security]/mod_security2 enabled. Many common configurations of this will cause problems for MediaWiki and other software that allows users to post arbitrary content.\nIf possible, this should be disabled. Otherwise, refer to [http://modsecurity.org/documentation/ mod_security documentation] or contact your host's support if you encounter random errors.",
+ "config-diff3-bad": "GNU diff3 not found.",
+ "config-git": "Found the Git version control software: <code>$1</code>.",
+ "config-git-bad": "Git version control software not found.",
+ "config-imagemagick": "Found ImageMagick: <code>$1</code>.\nImage thumbnailing will be enabled if you enable uploads.",
+ "config-gd": "Found GD graphics library built-in.\nImage thumbnailing will be enabled if you enable uploads.",
+ "config-no-scaling": "Could not find GD library or ImageMagick.\nImage thumbnailing will be disabled.",
+ "config-no-uri": "<strong>Error:</strong> Could not determine the current URI.\nInstallation aborted.",
+ "config-no-cli-uri": "<strong>Warning:</strong> No <code>--scriptpath</code> specified, using default: <code>$1</code>.",
+ "config-using-server": "Using server name \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Using server URL \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Warning:</strong> Your default directory for uploads <code>$1</code> is vulnerable to arbitrary scripts execution.\nAlthough MediaWiki checks all uploaded files for security threats, it is highly recommended to [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security close this security vulnerability] before enabling uploads.",
+ "config-no-cli-uploads-check": "<strong>Warning:</strong> Your default directory for uploads (<code>$1</code>) is not checked for vulnerability\nto arbitrary script execution during the CLI install.",
+ "config-brokenlibxml": "Your system has a combination of PHP and libxml2 versions that is buggy and can cause hidden data corruption in MediaWiki and other web applications.\nUpgrade to libxml2 2.7.3 or later ([https://bugs.php.net/bug.php?id=45996 bug filed with PHP]).\nInstallation aborted.",
+ "config-suhosin-max-value-length": "Suhosin is installed and limits the GET parameter <code>length</code> to $1 bytes.\nMediaWiki's ResourceLoader component will work around this limit, but that will degrade performance.\nIf at all possible, you should set <code>suhosin.get.max_value_length</code> to 1024 or higher in <code>php.ini</code>, and set <code>$wgResourceLoaderMaxQueryLength</code> to the same value in <code>LocalSettings.php</code>.",
+ "config-using-32bit": "<strong>Warning:</strong> your system appears to be running with 32-bit integers. This is [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit not advised].",
+ "config-db-type": "Database type:",
+ "config-db-host": "Database host:",
+ "config-db-host-help": "If your database server is on different server, enter the host name or IP address here.\n\nIf you are using shared web hosting, your hosting provider should give you the correct host name in their documentation.\n\nIf you are installing on a Windows server and using MySQL, using \"localhost\" may not work for the server name. If it does not, try \"127.0.0.1\" for the local IP address.\n\nIf you are using PostgreSQL, leave this field blank to connect via a Unix socket.",
+ "config-db-host-oracle": "Database TNS:",
+ "config-db-host-oracle-help": "Enter a valid [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; a tnsnames.ora file must be visible to this installation.<br />If you are using client libraries 10g or newer you can also use the [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect] naming method.",
+ "config-db-wiki-settings": "Identify this wiki",
+ "config-db-name": "Database name:",
+ "config-db-name-help": "Choose a name that identifies your wiki.\nIt should not contain spaces.\n\nIf you are using shared web hosting, your hosting provider will either give you a specific database name to use or let you create databases via a control panel.",
+ "config-db-name-oracle": "Database schema:",
+ "config-db-account-oracle-warn": "There are three supported scenarios for installing Oracle as database backend:\n\nIf you wish to create database account as part of the installation process, please supply an account with SYSDBA role as database account for installation and specify the desired credentials for the web-access account, otherwise you can either create the web-access account manually and supply only that account (if it has required permissions to create the schema objects) or supply two different accounts, one with create privileges and a restricted one for web access.\n\nScript for creating an account with required privileges can be found in \"maintenance/oracle/\" directory of this installation. Keep in mind that using a restricted account will disable all maintenance capabilities with the default account.",
+ "config-db-install-account": "User account for installation",
+ "config-db-username": "Database username:",
+ "config-db-password": "Database password:",
+ "config-db-install-username": "Enter the username that will be used to connect to the database during the installation process.\nThis is not the username of the MediaWiki account; this is the username for your database.",
+ "config-db-install-password": "Enter the password that will be used to connect to the database during the installation process.\nThis is not the password for the MediaWiki account; this is the password for your database.",
+ "config-db-install-help": "Enter the username and password that will be used to connect to the database during the installation process.",
+ "config-db-account-lock": "Use the same username and password during normal operation",
+ "config-db-wiki-account": "User account for normal operation",
+ "config-db-wiki-help": "Enter the username and password that will be used to connect to the database during normal wiki operation.\nIf the account does not exist, and the installation account has sufficient privileges, this user account will be created with the minimum privileges required to operate the wiki.",
+ "config-db-prefix": "Database table prefix:",
+ "config-db-prefix-help": "If you need to share one database between multiple wikis, or between MediaWiki and another web application, you may choose to add a prefix to all the table names to avoid conflicts.\nDo not use spaces.\n\nThis field is usually left empty.",
+ "config-mysql-old": "MySQL $1 or later is required. You have $2.",
+ "config-db-port": "Database port:",
+ "config-db-schema": "Schema for MediaWiki:",
+ "config-db-schema-help": "This schema will usually be fine.\nOnly change it if you know you need to.",
+ "config-pg-test-error": "Cannot connect to database <strong>$1</strong>: $2",
+ "config-sqlite-dir": "SQLite data directory:",
+ "config-sqlite-dir-help": "SQLite stores all data in a single file.\n\nThe directory you provide must be writable by the webserver during installation.\n\nIt should <strong>not</strong> be accessible via the web; this is why we're not putting it where your PHP files are.\n\nThe installer will write a <code>.htaccess</code> file along with it, but if that fails someone can gain access to your raw database.\nThat includes raw user data (email addresses, hashed passwords) as well as deleted revisions and other restricted data on the wiki.\n\nConsider putting the database somewhere else altogether, for example in <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Default tablespace:",
+ "config-oracle-temp-ts": "Temporary tablespace:",
+ "config-type-mysql": "MySQL (or compatible)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki supports the following database systems:\n\n$1\n\nIf you do not see the database system you are trying to use listed below, then follow the instructions linked above to enable support.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] is the primary target for MediaWiki and is best supported. MediaWiki also works with [{{int:version-db-mariadb-url}} MariaDB] and [{{int:version-db-percona-url}} Percona Server], which are MySQL compatible. ([http://www.php.net/manual/en/mysqli.installation.php How to compile PHP with MySQL support])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] is a popular open source database system as an alternative to MySQL. ([http://www.php.net/manual/en/pgsql.installation.php How to compile PHP with PostgreSQL support])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] is a lightweight database system that is very well supported. ([http://www.php.net/manual/en/pdo.installation.php How to compile PHP with SQLite support], uses PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] is a commercial enterprise database. ([http://www.php.net/manual/en/oci8.installation.php How to compile PHP with OCI8 support])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] is a commercial enterprise database for Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php How to compile PHP with SQLSRV support])",
+ "config-header-mysql": "MySQL settings",
+ "config-header-postgres": "PostgreSQL settings",
+ "config-header-sqlite": "SQLite settings",
+ "config-header-oracle": "Oracle settings",
+ "config-header-mssql": "Microsoft SQL Server settings",
+ "config-invalid-db-type": "Invalid database type.",
+ "config-missing-db-name": "You must enter a value for \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "You must enter a value for \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "You must enter a value for \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "Invalid database TNS \"$1\".\nUse either \"TNS Name\" or an \"Easy Connect\" string ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).",
+ "config-invalid-db-name": "Invalid database name \"$1\".\nUse only ASCII letters (a-z, A-Z), numbers (0-9), underscores (_) and hyphens (-).",
+ "config-invalid-db-prefix": "Invalid database prefix \"$1\".\nUse only ASCII letters (a-z, A-Z), numbers (0-9), underscores (_) and hyphens (-).",
+ "config-connection-error": "$1.\n\nCheck the host, username and password and try again.",
+ "config-invalid-schema": "Invalid schema for MediaWiki \"$1\".\nUse only ASCII letters (a-z, A-Z), numbers (0-9) and underscores (_).",
+ "config-db-sys-create-oracle": "Installer only supports using a SYSDBA account for creating a new account.",
+ "config-db-sys-user-exists-oracle": "User account \"$1\" already exists. SYSDBA can only be used for creating of a new account!",
+ "config-postgres-old": "PostgreSQL $1 or later is required. You have $2.",
+ "config-mssql-old": "Microsoft SQL Server $1 or later is required. You have $2.",
+ "config-sqlite-name-help": "Choose a name that identifies your wiki.\nDo not use spaces or hyphens.\nThis will be used for the SQLite data filename.",
+ "config-sqlite-parent-unwritable-group": "Cannot create the data directory <code><nowiki>$1</nowiki></code>, because the parent directory <code><nowiki>$2</nowiki></code> is not writable by the webserver.\n\nThe installer has determined the user your webserver is running as.\nMake the <code><nowiki>$3</nowiki></code> directory writable by it to continue.\nOn a Unix/Linux system do:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Cannot create the data directory <code><nowiki>$1</nowiki></code>, because the parent directory <code><nowiki>$2</nowiki></code> is not writable by the webserver.\n\nThe installer could not determine the user your webserver is running as.\nMake the <code><nowiki>$3</nowiki></code> directory globally writable by it (and others!) to continue.\nOn a Unix/Linux system do:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Error creating the data directory \"$1\".\nCheck the location and try again.",
+ "config-sqlite-dir-unwritable": "Unable to write to the directory \"$1\".\nChange its permissions so that the webserver can write to it, and try again.",
+ "config-sqlite-connection-error": "$1.\n\nCheck the data directory and database name below and try again.",
+ "config-sqlite-readonly": "The file <code>$1</code> is not writeable.",
+ "config-sqlite-cant-create-db": "Could not create database file <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP is missing FTS3 support, downgrading tables.",
+ "config-can-upgrade": "There are MediaWiki tables in this database.\nTo upgrade them to MediaWiki $1, click <strong>Continue</strong>.",
+ "config-upgrade-done": "Upgrade complete.\n\nYou can now [$1 start using your wiki].\n\nIf you want to regenerate your <code>LocalSettings.php</code> file, click the button below.\nThis is <strong>not recommended</strong> unless you are having problems with your wiki.",
+ "config-upgrade-done-no-regenerate": "Upgrade complete.\n\nYou can now [$1 start using your wiki].",
+ "config-regenerate": "Regenerate LocalSettings.php →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code> query failed!",
+ "config-unknown-collation": "<strong>Warning:</strong> Database is using unrecognized collation.",
+ "config-db-web-account": "Database account for web access",
+ "config-db-web-help": "Select the username and password that the web server will use to connect to the database server, during ordinary operation of the wiki.",
+ "config-db-web-account-same": "Use the same account as for installation",
+ "config-db-web-create": "Create the account if it does not already exist",
+ "config-db-web-no-create-privs": "The account you specified for installation does not have enough privileges to create an account.\nThe account you specify here must already exist.",
+ "config-mysql-engine": "Storage engine:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Warning:</strong> You have selected MyISAM as storage engine for MySQL, which is not recommended for use with MediaWiki, because:\n* it barely supports concurrency due to table locking\n* it is more prone to corruption than other engines\n* the MediaWiki codebase does not always handle MyISAM as it should\n\nIf your MySQL installation supports InnoDB, it is highly recommended that you choose that instead.\nIf your MySQL installation does not support InnoDB, maybe it's time for an upgrade.",
+ "config-mysql-only-myisam-dep": "<strong>Warning:</strong> MyISAM is the only available storage engine for MySQL on this machine, and this is not recommended for use with MediaWiki, because:\n* it barely supports concurrency due to table locking\n* it is more prone to corruption than other engines\n* the MediaWiki codebase does not always handle MyISAM as it should\n\nYour MySQL installation does not support InnoDB, maybe it's time for an upgrade.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> is almost always the best option, since it has good concurrency support.\n\n<strong>MyISAM</strong> may be faster in single-user or read-only installations.\nMyISAM databases tend to get corrupted more often than InnoDB databases.",
+ "config-mysql-charset": "Database character set:",
+ "config-mysql-binary": "Binary",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "In <strong>binary mode</strong>, MediaWiki stores UTF-8 text to the database in binary fields.\nThis is more efficient than MySQL's UTF-8 mode, and allows you to use the full range of Unicode characters.\n\nIn <strong>UTF-8 mode</strong>, MySQL will know what character set your data is in, and can present and convert it appropriately, but it will not let you store characters above the [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane].",
+ "config-mssql-auth": "Authentication type:",
+ "config-mssql-install-auth": "Select the authentication type that will be used to connect to the database during the installation process.\nIf you select \"{{int:config-mssql-windowsauth}}\", the credentials of whatever user the webserver is running as will be used.",
+ "config-mssql-web-auth": "Select the authentication type that the web server will use to connect to the database server, during ordinary operation of the wiki.\nIf you select \"{{int:config-mssql-windowsauth}}\", the credentials of whatever user the webserver is running as will be used.",
+ "config-mssql-sqlauth": "SQL Server Authentication",
+ "config-mssql-windowsauth": "Windows Authentication",
+ "config-site-name": "Name of wiki:",
+ "config-site-name-help": "This will appear in the title bar of the browser and in various other places.",
+ "config-site-name-blank": "Enter a site name.",
+ "config-project-namespace": "Project namespace:",
+ "config-ns-generic": "Project",
+ "config-ns-site-name": "Same as the wiki name: $1",
+ "config-ns-other": "Other (specify)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Following Wikipedia's example, many wikis keep their policy pages separate from their content pages, in a '''project namespace'''.\nAll page titles in this namespace start with a certain prefix, which you can specify here.\nUsually, this prefix is derived from the name of the wiki, but it cannot contain punctuation characters such as \"#\" or \":\".",
+ "config-ns-invalid": "The specified namespace \"<nowiki>$1</nowiki>\" is invalid.\nSpecify a different project namespace.",
+ "config-ns-conflict": "The specified namespace \"<nowiki>$1</nowiki>\" conflicts with a default MediaWiki namespace.\nSpecify a different project namespace.",
+ "config-admin-box": "Administrator account",
+ "config-admin-name": "Your username:",
+ "config-admin-password": "Password:",
+ "config-admin-password-confirm": "Password again:",
+ "config-admin-help": "Enter your preferred username here, for example \"Joe Bloggs\".\nThis is the name you will use to log in to the wiki.",
+ "config-admin-name-blank": "Enter an administrator username.",
+ "config-admin-name-invalid": "The specified username \"<nowiki>$1</nowiki>\" is invalid.\nSpecify a different username.",
+ "config-admin-password-blank": "Enter a password for the administrator account.",
+ "config-admin-password-mismatch": "The two passwords you entered do not match.",
+ "config-admin-email": "Email address:",
+ "config-admin-email-help": "Enter an email address here to allow you to receive email from other users on the wiki, reset your password, and be notified of changes to pages on your watchlist. You can leave this field empty.",
+ "config-admin-error-user": "Internal error when creating an admin with the name \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Internal error when setting a password for the admin \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "You have entered an invalid email address.",
+ "config-subscribe": "Subscribe to the [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce release announcements mailing list].",
+ "config-subscribe-help": "This is a low-volume mailing list used for release announcements, including important security announcements.\nYou should subscribe to it and update your MediaWiki installation when new versions come out.",
+ "config-subscribe-noemail": "You tried to subscribe to the release announcements mailing list without providing an email address.\nPlease provide an email address if you wish to subscribe to the mailing list.",
+ "config-pingback": "Share data about this installation with MediaWiki developers.",
+ "config-pingback-help": "If you select this option, MediaWiki will periodically ping https://www.mediawiki.org with basic data about this MediaWiki instance. This data includes, for example, the type of system, PHP version, and chosen database backend. The Wikimedia Foundation shares this data with MediaWiki developers to help guide future development efforts. The following data will be sent for your system:\n<pre>$1</pre>",
+ "config-almost-done": "You are almost done!\nYou can now skip the remaining configuration and install the wiki right now.",
+ "config-optional-continue": "Ask me more questions.",
+ "config-optional-skip": "I'm bored already, just install the wiki.",
+ "config-profile": "User rights profile:",
+ "config-profile-wiki": "Open wiki",
+ "config-profile-no-anon": "Account creation required",
+ "config-profile-fishbowl": "Authorized editors only",
+ "config-profile-private": "Private wiki",
+ "config-profile-help": "Wikis work best when you let as many people edit them as possible.\nIn MediaWiki, it is easy to review the recent changes, and to revert any damage that is done by naive or malicious users.\n\nHowever, many have found MediaWiki to be useful in a wide variety of roles, and sometimes it is not easy to convince everyone of the benefits of the wiki way.\nSo you have the choice.\n\nThe <strong>{{int:config-profile-wiki}}</strong> model allows anyone to edit, without even logging in.\nA wiki with <strong>{{int:config-profile-no-anon}}</strong> provides extra accountability, but may deter casual contributors.\n\nThe <strong>{{int:config-profile-fishbowl}}</strong> scenario allows approved users to edit, but the public can view the pages, including history.\nA <strong>{{int:config-profile-private}}</strong> only allows approved users to view pages, with the same group allowed to edit.\n\nMore complex user rights configurations are available after installation, see the [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights relevant manual entry].",
+ "config-license": "Copyright and license:",
+ "config-license-none": "No license footer",
+ "config-license-cc-by-sa": "Creative Commons Attribution-ShareAlike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution-NonCommercial-ShareAlike",
+ "config-license-cc-0": "Creative Commons Zero (Public Domain)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 or later",
+ "config-license-pd": "Public Domain",
+ "config-license-cc-choose": "Select a custom Creative Commons license",
+ "config-license-help": "Many public wikis put all contributions under a [http://freedomdefined.org/Definition free license].\nThis helps to create a sense of community ownership and encourages long-term contribution.\nIt is not generally necessary for a private or corporate wiki.\n\nIf you want to be able to use text from Wikipedia, and you want Wikipedia to be able to accept text copied from your wiki, you should choose <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia previously used the GNU Free Documentation License.\nThe GFDL is a valid license, but it is difficult to understand.\nIt is also difficult to reuse content licensed under the GFDL.",
+ "config-email-settings": "Email settings",
+ "config-enable-email": "Enable outbound email",
+ "config-enable-email-help": "If you want email to work, [http://www.php.net/manual/en/mail.configuration.php PHP's mail settings] need to be configured correctly.\nIf you do not want any email features, you can disable them here.",
+ "config-email-user": "Enable user-to-user email",
+ "config-email-user-help": "Allow all users to send each other email if they have enabled it in their preferences.",
+ "config-email-usertalk": "Enable user talk page notification",
+ "config-email-usertalk-help": "Allow users to receive notifications on user talk page changes, if they have enabled it in their preferences.",
+ "config-email-watchlist": "Enable watchlist notification",
+ "config-email-watchlist-help": "Allow users to receive notifications about their watched pages if they have enabled it in their preferences.",
+ "config-email-auth": "Enable email authentication",
+ "config-email-auth-help": "If this option is enabled, users have to confirm their email address using a link sent to them whenever they set or change it.\nOnly authenticated email addresses can receive emails from other users or change notification emails.\nSetting this option is <strong>recommended</strong> for public wikis because of potential abuse of the email features.",
+ "config-email-sender": "Return email address:",
+ "config-email-sender-help": "Enter the email address to use as the return address on outbound email.\nThis is where bounces will be sent.\nMany mail servers require at least the domain name part to be valid.",
+ "config-upload-settings": "Images and file uploads",
+ "config-upload-enable": "Enable file uploads",
+ "config-upload-help": "File uploads potentially expose your server to security risks.\nFor more information, read the [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security security section] in the manual.\n\nTo enable file uploads, change the mode on the <code>images</code> subdirectory under MediaWiki's root directory so that the web server can write to it.\nThen enable this option.",
+ "config-upload-deleted": "Directory for deleted files:",
+ "config-upload-deleted-help": "Choose a directory in which to archive deleted files.\nIdeally, this should not be accessible from the web.",
+ "config-logo": "Logo URL:",
+ "config-logo-help": "MediaWiki's default skin includes space for a 135x160 pixel logo above the sidebar menu.\nUpload an image of the appropriate size, and enter the URL here.\n\nYou can use <code>$wgStylePath</code> or <code>$wgScriptPath</code> if your logo is relative to those paths.\n\nIf you do not want a logo, leave this box blank.",
+ "config-instantcommons": "Enable Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] is a feature that allows wikis to use images, sounds and other media found on the [https://commons.wikimedia.org/ Wikimedia Commons] site.\nIn order to do this, MediaWiki requires access to the Internet.\n\nFor more information on this feature, including instructions on how to set it up for wikis other than the Wikimedia Commons, consult [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos the manual].",
+ "config-cc-error": "The Creative Commons license chooser gave no result.\nEnter the license name manually.",
+ "config-cc-again": "Pick again...",
+ "config-cc-not-chosen": "Choose which Creative Commons license you want and click \"proceed\".",
+ "config-advanced-settings": "Advanced configuration",
+ "config-cache-options": "Settings for object caching:",
+ "config-cache-help": "Object caching is used to improve the speed of MediaWiki by caching frequently used data.\nMedium to large sites are highly encouraged to enable this, and small sites will see benefits as well.",
+ "config-cache-none": "No caching (no functionality is removed, but speed may be impacted on larger wiki sites)",
+ "config-cache-accel": "PHP object caching (APC, APCu, XCache or WinCache)",
+ "config-cache-memcached": "Use Memcached (requires additional setup and configuration)",
+ "config-memcached-servers": "Memcached servers:",
+ "config-memcached-help": "List of IP addresses to use for Memcached.\nShould specify one per line and specify the port to be used. For example:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "You selected Memcached as your cache type but did not specify any servers.",
+ "config-memcache-badip": "You have entered an invalid IP address for Memcached: $1.",
+ "config-memcache-noport": "You did not specify a port to use for Memcached server: $1.\nIf you do not know the port, the default is 11211.",
+ "config-memcache-badport": "Memcached port numbers should be between $1 and $2.",
+ "config-extensions": "Extensions",
+ "config-extensions-help": "The extensions listed above were detected in your <code>./extensions</code> directory.\n\nThey may require additional configuration, but you can enable them now.",
+ "config-skins": "Skins",
+ "config-skins-help": "The skins listed above were detected in your <code>./skins</code> directory. You must enable at least one, and choose the default.",
+ "config-skins-use-as-default": "Use this skin as default",
+ "config-skins-missing": "No skins were found; MediaWiki will use a fallback skin until you install some proper ones.",
+ "config-skins-must-enable-some": "You must choose at least one skin to enable.",
+ "config-skins-must-enable-default": "The skin chosen as default must be enabled.",
+ "config-install-alreadydone": "<strong>Warning:</strong> You seem to have already installed MediaWiki and are trying to install it again.\nPlease proceed to the next page.",
+ "config-install-begin": "By pressing \"{{int:config-continue}}\", you will begin the installation of MediaWiki.\nIf you still want to make changes, press \"{{int:config-back}}\".",
+ "config-install-step-done": "done",
+ "config-install-step-failed": "failed",
+ "config-install-extensions": "Including extensions",
+ "config-install-database": "Setting up database",
+ "config-install-schema": "Creating schema",
+ "config-install-pg-schema-not-exist": "PostgreSQL schema does not exist.",
+ "config-install-pg-schema-failed": "Tables creation failed.\nMake sure that the user \"$1\" can write to the schema \"$2\".",
+ "config-install-pg-commit": "Committing changes",
+ "config-install-pg-plpgsql": "Checking for language PL/pgSQL",
+ "config-pg-no-plpgsql": "You need to install the language PL/pgSQL in the database $1",
+ "config-pg-no-create-privs": "The account you specified for installation does not have enough privileges to create an account.",
+ "config-pg-not-in-role": "The account you specified for the web user already exists.\nThe account you specified for installation is not a superuser and is not a member of the web user's role, so it is unable to create objects owned by the web user.\n\nMediaWiki currently requires that the tables be owned by the web user. Please specify another web account name, or click \"back\" and specify a suitably privileged install user.",
+ "config-install-user": "Creating database user",
+ "config-install-user-alreadyexists": "User \"$1\" already exists",
+ "config-install-user-create-failed": "Creating user \"$1\" failed: $2",
+ "config-install-user-grant-failed": "Granting permission to user \"$1\" failed: $2",
+ "config-install-user-missing": "The specified user \"$1\" does not exist.",
+ "config-install-user-missing-create": "The specified user \"$1\" does not exist.\nPlease click the \"create account\" checkbox below if you want to create it.",
+ "config-install-tables": "Creating tables",
+ "config-install-tables-exist": "<strong>Warning:</strong> MediaWiki tables seem to already exist.\nSkipping creation.",
+ "config-install-tables-failed": "<strong>Error:</strong> Table creation failed with the following error: $1",
+ "config-install-interwiki": "Populating default interwiki table",
+ "config-install-interwiki-list": "Could not read file <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Warning:</strong> The interwiki table seems to already have entries.\nSkipping default list.",
+ "config-install-stats": "Initializing statistics",
+ "config-install-keys": "Generating secret keys",
+ "config-insecure-keys": "<strong>Warning:</strong> {{PLURAL:$2|A secure key|Secure keys}} ($1) generated during installation {{PLURAL:$2|is|are}} not completely safe. Consider changing {{PLURAL:$2|it|them}} manually.",
+ "config-install-updates": "Prevent running unneeded updates",
+ "config-install-updates-failed": "<strong>Error:</strong> Inserting update keys into tables failed with the following error: $1",
+ "config-install-sysop": "Creating administrator user account",
+ "config-install-subscribe-fail": "Unable to subscribe to mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL is not installed and <code>allow_url_fopen</code> is not available.",
+ "config-install-mainpage": "Creating main page with default content",
+ "config-install-mainpage-exists": "Main page already exists, skipping",
+ "config-install-extension-tables": "Creating tables for enabled extensions",
+ "config-install-mainpage-failed": "Could not insert main page: $1",
+ "config-install-done": "<strong>Congratulations!</strong>\nYou have installed MediaWiki.\n\nThe installer has generated a <code>LocalSettings.php</code> file.\nIt contains all your configuration.\n\nYou will need to download it and put it in the base of your wiki installation (the same directory as index.php). The download should have started automatically.\n\nIf the download was not offered, or if you cancelled it, you can restart the download by clicking the link below:\n\n$3\n\n<strong>Note:</strong> If you do not do this now, this generated configuration file will not be available to you later if you exit the installation without downloading it.\n\nWhen that has been done, you can <strong>[$2 enter your wiki]</strong>.",
+ "config-install-done-path": "<strong>Congratulations!</strong>\nYou have installed MediaWiki.\n\nThe installer has generated a <code>LocalSettings.php</code> file.\nIt contains all your configuration.\n\nYou will need to download it and put it at <code>$4</code>. The download should have started automatically.\n\nIf the download was not offered, or if you cancelled it, you can restart the download by clicking the link below:\n\n$3\n\n<strong>Note:</strong> If you do not do this now, this generated configuration file will not be available to you later if you exit the installation without downloading it.\n\nWhen that has been done, you can <strong>[$2 enter your wiki]</strong>.",
+ "config-download-localsettings": "Download <code>LocalSettings.php</code>",
+ "config-help": "help",
+ "config-help-tooltip": "click to expand",
+ "config-nofile": "File \"$1\" could not be found. Has it been deleted?",
+ "config-extension-link": "Did you know that your wiki supports [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensions]?\n\nYou can browse [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensions by category] or the [https://www.mediawiki.org/wiki/Extension_Matrix Extension Matrix] to see the full list of extensions.",
+ "config-skins-screenshots": "$1 (screenshots: $2)",
+ "config-skins-screenshot": "$1 ($2)",
+ "config-screenshot": "screenshot",
+ "mainpagetext": "<strong>MediaWiki has been installed.</strong>",
+ "mainpagedocfooter": "Consult the [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide] for information on using the wiki software.\n\n== Getting started ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Learn how to combat spam on your wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/eo.json b/www/wiki/includes/installer/i18n/eo.json
new file mode 100644
index 00000000..50c0ec04
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/eo.json
@@ -0,0 +1,65 @@
+{
+ "@metadata": {
+ "authors": [
+ "Airon90",
+ "Yekrats",
+ "KuboF",
+ "Fitoschido",
+ "Ochilov",
+ "Tlustulimu",
+ "Robin van der Vliet"
+ ]
+ },
+ "config-desc": "Instalilo de MediaWiki",
+ "config-title": "Instalado de MediaWiki $1",
+ "config-information": "Informo",
+ "config-localsettings-badkey": "Provizita ŝlosilo estas malĝusta.",
+ "config-your-language": "Via lingvo:",
+ "config-your-language-help": "Elekti lingvon uzi dum la instalada procezo.",
+ "config-wiki-language": "Lingvo de la vikio:",
+ "config-wiki-language-help": "Elekti la ĉefe skribotan lingvon de la vikio.",
+ "config-back": "← Reen",
+ "config-continue": "Daŭrigi →",
+ "config-page-language": "Lingvo",
+ "config-page-welcome": "Bonvenon al MediaWiki!",
+ "config-page-dbconnect": "Konekto al datumbazo",
+ "config-page-dbsettings": "Agordoj de la datumbazo",
+ "config-page-name": "Nomo",
+ "config-page-options": "Agordoj",
+ "config-page-install": "Instali",
+ "config-page-complete": "Farita!",
+ "config-page-restart": "Restarti instaladon",
+ "config-page-readme": "Legu min",
+ "config-page-releasenotes": "Eldonaj notoj",
+ "config-page-existingwiki": "Ekzistanta vikio",
+ "config-restart": "Jes, restarti ĝin",
+ "config-env-good": "La medio estis kontrolita.\nVi povas instali MediaWiki.",
+ "config-env-bad": "La medio estis kontrolita.\nNe eblas instali MediaWiki.",
+ "config-env-php": "PHP $1 estas instalita.",
+ "config-env-hhvm": "HHVM $1 instalatas.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] estas instalita.",
+ "config-apc": "[http://www.php.net/apc APC] estas instalita",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] estas instalita",
+ "config-diff3-bad": "GNU diff3 ne estis trovita.",
+ "config-db-type": "Tipo de datumbazo:",
+ "config-db-wiki-settings": "Identigu ĉi tiun vikion",
+ "config-db-name": "Nomo de datumbazo:",
+ "config-type-mysql": "MySQL (aŭ kongrua)",
+ "config-header-mysql": "MySQL-agordoj",
+ "config-header-postgres": "PostgreSQL-agordoj",
+ "config-header-sqlite": "SQLite-agordoj",
+ "config-header-oracle": "Oracle-agordoj",
+ "config-header-mssql": "Microsoft SQL Server-agordoj",
+ "config-mysql-binary": "Duuma",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Nomo de vikio:",
+ "config-ns-generic": "Projekto",
+ "config-admin-name": "Via uzantonomo:",
+ "config-admin-password": "Pasvorto:",
+ "config-admin-password-confirm": "Retajpu pasvorton:",
+ "config-admin-name-blank": "Enigu salutnomon de administranto.",
+ "config-admin-email": "Retpoŝtadreso:",
+ "config-profile-private": "Privata vikio",
+ "mainpagetext": "'''MediaWiki estis sukcese instalita.'''",
+ "mainpagedocfooter": "Konsultu la [https://meta.wikimedia.org/wiki/MediaWiki_User%27s_Guide Gvidilon por uzantoj de MediaWiki] por informoj pri uzado de vikia programaro.\n\n==Kiel komenci==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Listo de konfiguraĵoj] (angle)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki Oftaj Demandoj] (angle)\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Anonco-dissendolisto pri MediaWiki] (angle)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Preklad MediaWiki do tvojho jazyka]"
+}
diff --git a/www/wiki/includes/installer/i18n/es-formal.json b/www/wiki/includes/installer/i18n/es-formal.json
new file mode 100644
index 00000000..b15c7f25
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/es-formal.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Dferg",
+ "Seb35"
+ ]
+ },
+ "mainpagedocfooter": "Consulte usted la [https://meta.wikimedia.org/wiki/Help:Contents/es Guía de usuario] para obtener información sobre el uso del software wiki.\n\n== Empezando ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de ajustes de configuración]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/es FAQ de MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de correo de anuncios de distribución de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Regionalizar MediaWiki para su idioma]"
+}
diff --git a/www/wiki/includes/installer/i18n/es.json b/www/wiki/includes/installer/i18n/es.json
new file mode 100644
index 00000000..be2e65a5
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/es.json
@@ -0,0 +1,350 @@
+{
+ "@metadata": {
+ "authors": [
+ "Armando-Martin",
+ "Ciencia Al Poder",
+ "Crazymadlover",
+ "Danke7",
+ "Fitoschido",
+ "Locos epraix",
+ "Od1n",
+ "Platonides",
+ "Sanbec",
+ "Translationista",
+ "Vivaelcelta",
+ "아라",
+ "Amire80",
+ "Benfutbol10",
+ "Carlitosag",
+ "Chocolate con galleta",
+ "Csbotero",
+ "Sporeunai",
+ "Ihojose",
+ "Seb35",
+ "McDutchie",
+ "Miguel2706",
+ "Macofe",
+ "AVIADOR",
+ "FuzzyDice",
+ "Legoktm",
+ "Matiia",
+ "AlvaroMolina",
+ "Indiralena",
+ "Peter Bowman",
+ "Dgstranz",
+ "Irus",
+ "Tinss"
+ ]
+ },
+ "config-desc": "El instalador de MediaWiki",
+ "config-title": "Instalación de MediaWiki $1",
+ "config-information": "Información",
+ "config-localsettings-upgrade": "Se ha encontrado un archivo <code>LocalSettings.php</code>.\nPara actualizar esta instalación, escribe el valor de <code>$wgUpgradeKey</code> en el cuadro de abajo.\nLo encontrarás en <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Se ha detectado un archivo <code>LocalSettings.php</code>.\nPara actualizar la instalación, en su lugar ejecuta <code>update.php</code>",
+ "config-localsettings-key": "Clave de actualización:",
+ "config-localsettings-badkey": "La clave de actualización proporcionada es incorrecta.",
+ "config-upgrade-key-missing": "Se ha detectado una instalación existente de MediaWiki.\nPara actualizar la instalación, añade la siguiente línea al final de tu <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "El archivo <code>LocalSettings.php</code> existente parece estar incompleto.\nLa variable $1 no está definida.\nCambia <code>LocalSettings.php</code> para que esta variable quede establecida y haz clic en \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Se ha producido un error al conectar con la base de datos a través de la configuración especificada en <code>LocalSettings.php</code>. Corrige estos ajustes e inténtalo de nuevo.\n\n$1",
+ "config-session-error": "Error al iniciar la sesión: $1",
+ "config-session-expired": "Tus datos de sesión parecen haber expirado.\nLas sesiones están configuradas por una duración de $1.\nPuedes incrementar esto configurando <code>session.gc_maxlifetime</code> en php.ini.\nReiniciar el proceso de instalación.",
+ "config-no-session": "Se han perdido los datos de sesión.\nVerifica tu php.ini y comprueba que <code>session.save_path</code> está establecido en un directorio apropiado.",
+ "config-your-language": "Tu idioma:",
+ "config-your-language-help": "Selecciona un idioma para usar durante el proceso de instalación.",
+ "config-wiki-language": "Idioma del wiki:",
+ "config-wiki-language-help": "Selecciona el idioma en el que se escribirá predominantemente el wiki.",
+ "config-back": "← Atrás",
+ "config-continue": "Continuar →",
+ "config-page-language": "Idioma",
+ "config-page-welcome": "Te damos la bienvenida a MediaWiki.",
+ "config-page-dbconnect": "Conectar con la base de datos",
+ "config-page-upgrade": "Actualizar instalación existente",
+ "config-page-dbsettings": "Configuración de la base de datos",
+ "config-page-name": "Nombre",
+ "config-page-options": "Opciones",
+ "config-page-install": "Instalar",
+ "config-page-complete": "Hecho.",
+ "config-page-restart": "Reiniciar instalación",
+ "config-page-readme": "Léeme",
+ "config-page-releasenotes": "Notas de la versión",
+ "config-page-copying": "Copia",
+ "config-page-upgradedoc": "Actualización",
+ "config-page-existingwiki": "Wiki existente",
+ "config-help-restart": "¿Deseas borrar todos los datos guardados que has escrito y reiniciar el proceso de instalación?",
+ "config-restart": "Sí, reiniciarlo",
+ "config-welcome": "=== Comprobación del entorno ===\nAhora se van a realizar comprobaciones básicas para ver si el entorno es adecuado para la instalación de MediaWiki.\nRecuerda suministrar los resultados de tales comprobaciones si necesitas ayuda para completar la instalación.",
+ "config-copyright": "=== Derechos de autor y Términos de uso ===\n\n$1\n\nEste programa es software libre; puedes redistribuirlo y/o modificarlo en los términos de la Licencia Pública General de GNU, tal como aparece publicada por la Fundación para el Software Libre, tanto la versión 2 de la Licencia, como cualquier versión posterior (según prefieras).\n\nEste programa es distribuido con la esperanza de que sea útil, pero <strong>sin ninguna garantía</strong>; inclusive, sin la garantía implícita de la <strong>posibilidad de ser comercializado</strong> o de <strong>idoneidad para cualquier finalidad específica</strong>.\nConsulta la Licencia Pública General de GNU para más detalles.\n\nEn conjunto con este programa debes haber recibido <doclink href=Copying>una copia de la Licencia Pública General de GNU</doclink>; caso contrario, pídela por escrito a la Fundación para el Software Libre, Inc., 51 Franklin Street, Fifth Floor, Boston, ME La 02110-1301, USA o [http://www.gnu.org/copyleft/gpl.html léela en Internet].",
+ "config-sidebar": "* [https://www.mediawiki.org Página principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guía del usuario]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guía del administrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Preguntas frecuentes]\n----\n* <doclink href=Readme>Léeme</doclink>\n* <doclink href=ReleaseNotes>Notas de la versión</doclink>\n* <doclink href=Copying>Copia</doclink>\n* <doclink href=UpgradeDoc>Actualización</doclink>",
+ "config-env-good": "El entorno ha sido comprobado.\nPuedes instalar MediaWiki.",
+ "config-env-bad": "El entorno ha sido comprobado.\nNo puedes instalar MediaWiki.",
+ "config-env-php": "PHP $1 está instalado.",
+ "config-env-hhvm": "HHVM $1 está instalado.",
+ "config-unicode-using-intl": "Usando la [http://pecl.php.net/intl extensión intl PECL] para la normalización Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Advertencia:</strong> la [http://pecl.php.net/intl extensión intl] no está disponible para efectuar la normalización Unicode. Se utilizará la implementación más lenta en PHP puro.\nSi tu web tiene mucho tráfico, te recomendamos leer acerca de la [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalización Unicode].",
+ "config-unicode-update-warning": "<strong>Warning:</strong> la versión instalada del contenedor de normalización Unicode usa una versión antigua de la biblioteca del [http://site.icu-project.org/ proyecto ICU].\nDeberás [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations actualizar] si realmente deseas usar Unicode.",
+ "config-no-db": "No se encontró un controlador adecuado para la base de datos. Necesitas instalar un controlador de base de datos para PHP.\n{{PLURAL:$2|El siguiente gestor de bases de datos está soportado|Los siguientes gestores de bases de datos están soportados}}: $1.\n\nSi compilaste PHP tú mismo, debes reconfigurarlo habilitando un cliente de base de datos, por ejemplo, usando <code>./configure --with-mysqli</code>.\nSi instalaste PHP desde un paquete Debian o Ubuntu, entonces también necesitas instalar, por ejemplo, el paquete <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Advertencia:</strong> tienes SQLite $1, que es inferior a la mínima versión requerida: $2. SQLite no estará disponible.",
+ "config-no-fts3": "<strong>Advertencia:</strong> SQLite está compilado sin el [//sqlite.org/fts3.html módulo FTS3]. Las funcionalidades de búsqueda no estarán disponibles en esta instalación.",
+ "config-pcre-old": "'''Fatal:''' Se requiere PCRE $1 o posterior.\nSu PHP binario está enlazado con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Más información].",
+ "config-pcre-no-utf8": "'''Error fatal ''': Parece que el módulo PCRE de PHP fue compilado sin el soporte PCRE_UTF8.\nMediaWiki requiere compatibilidad con UTF-8 para funcionar correctamente.",
+ "config-memory-raised": "El parámetro <code>memory_limit</code> de PHP es $1. Se aumenta a $2.",
+ "config-memory-bad": "<strong>Advertencia:</strong> el parámetro <code>memory_limit</code> de PHP es $1.\nProbablemente sea demasiado bajo.\n¡La instalación puede fallar!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] está instalado",
+ "config-apc": "[http://www.php.net/apc APC] está instalado",
+ "config-apcu": "[http://www.php.net/apcu APCu] está instalado",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] está instalado",
+ "config-no-cache-apcu": "<strong>Advertencia:</strong> No se pudo encontrar [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] o [http://www.iis.net/download/WinCacheForPhp WinCache].\nEl caché de objetos no está activado.",
+ "config-mod-security": "<strong>Advertencia:</strong> tu servidor web tiene activado [http://modsecurity.org/ mod_security]/mod_security2. Muchas de sus configuraciones comunes pueden causar problemas a MediaWiki u otro software que permita a los usuarios publicar contenido arbitrario. De ser posible, deberías desactivarlo. Si no, consulta la [http://modsecurity.org/documentation/ documentación de mod_security] o contacta con el administrador de tu servidor si encuentras errores aleatorios.",
+ "config-diff3-bad": "GNU diff3 no se encuentra.",
+ "config-git": "Se encontró el software de control de versiones Git: <code>$1</code>.",
+ "config-git-bad": "No se encontró el software de control de versiones Git.",
+ "config-imagemagick": "ImageMagick encontrado: <code>$1</code>.\nLa miniaturización de imágenes se habilitará si habilitas la subida de archivos.",
+ "config-gd": "Se ha encontrado una biblioteca de gráficos GD integrada.\nLa miniaturización de imágenes se habilitará si habilitas las subidas.",
+ "config-no-scaling": "No se ha encontrado la biblioteca GD o ImageMagik.\nSe desactivará la miniaturización de imágenes.",
+ "config-no-uri": "<strong>Error:</strong> no se pudo determinar el URI actual.\nSe interrumpió la instalación.",
+ "config-no-cli-uri": "<strong>Aviso:</strong> No se especificó <code>--scriptpath</code>; se usa el valor predeterminado: <code>$1</code>.",
+ "config-using-server": "Utilizando el nombre de servidor \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Utilizando la URL del servidor \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Advertencia:</strong> tu directorio predeterminado para las cargas, <code>$1</code>, es vulnerable a la ejecución de scripts arbitrarios.\nAunque MediaWiki comprueba todos los archivos cargados por si hubiese amenazas de seguridad, es altamente recomendable [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security cerrar esta vulnerabilidad de seguridad] antes de activar las cargas.",
+ "config-no-cli-uploads-check": "<strong>Advertencia:</strong> tu directorio predeterminado para cargas (<code>$1</code>) no está comprobado contra la vulnerabilidad\n de ejecución arbitraria de \"scripts\" durante la instalación por línea de comandos.",
+ "config-brokenlibxml": "El sistema tiene una combinación de versiones de PHP y de libxml2 que es poco confiable y puede provocar corrupción oculta en los datos de MediaWiki y otras aplicaciones web.\nActualiza a libxml2 2.7.3 o posterior ([https://bugs.php.net/bug.php?id=45996 bug reportado con PHP]).\nInstalación abortada.",
+ "config-suhosin-max-value-length": "Suhosin está instalado y limita el parámetro <code>length</code> GET a $1 bytes.\nEl componente ResourceLoader (gestor de recursos) de MediaWiki trabajará en este límite, pero eso perjudicará el rendimiento.\nSi es posible, deberías establecer <code>suhosin.get.max_value_length</code> en el valor 1024 o superior en <code>php.ini</code> y establecer <code>$wgResourceLoaderMaxQueryLength</code> en el mismo valor en <code>php.ini</code>.",
+ "config-using-32bit": "<strong>Atención:</strong> parece que el sistema funciona con enteros de 32 bits. Esto está [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit desaconsejado].",
+ "config-db-type": "Tipo de base de datos:",
+ "config-db-host": "Servidor de la base de datos:",
+ "config-db-host-help": "Si tu servidor de base de datos está en otro servidor, escribe el nombre del equipo o su dirección IP aquí.\n\nSi estás utilizando alojamiento web compartido, tu proveedor debería darte el nombre correcto del servidor en su documentación.\n\nSi vas a instalar en un servidor Windows y a utilizar MySQL, el uso de \"localhost\" como nombre del servidor puede no funcionar. Si es así, intenta poner \"127.0.0.1\" como dirección IP local.\n\nSi utilizas PostgreSQL, deja este campo vacío para conectarse a través de un socket de Unix.",
+ "config-db-host-oracle": "TNS de la base de datos:",
+ "config-db-host-oracle-help": "Escribe un [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm nombre de conexión local] válido; un archivo tnsnames.ora debe ser visible para esta instalación.<br />Si estás utilizando bibliotecas de cliente 10g o más recientes también puedes utilizar el método de asignación de nombres [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifica este wiki",
+ "config-db-name": "Nombre de la base de datos:",
+ "config-db-name-help": "Elige un nombre que identifique tu wiki.\nNo debe contener espacios.\n\nSi estás utilizando alojamiento web compartido, tu proveedor te dará un nombre específico de base de datos para que lo utilices, o bien te permitirá crear bases de datos a través de un panel de control.",
+ "config-db-name-oracle": "Esquema de la base de datos:",
+ "config-db-account-oracle-warn": "Hay tres escenarios compatibles para la instalación de Oracle como base de datos back-end:\n\nSi desea crear una cuenta de base de datos como parte del proceso de instalación, por favor suministre una cuenta con función SYSDBA como cuenta de base de datos para la instalación y especifique las credenciales deseadas de la cuenta de acceso al web, de lo contrario puede crear manualmente la cuenta de acceso al web y suministrar sólo esa cuenta (si tiene los permisos necesarios para crear los objetos de esquema) o suministrar dos cuentas diferentes, una con privilegios de creación y otra con acceso restringido a la web\n\nLa secuencia de comandos (script) para crear una cuenta con los privilegios necesarios puede encontrarse en el directorio \"maintenance/oracle/\" de esta instalación. Tenga en cuenta que utilizando una cuenta restringida desactivará todas las capacidades de mantenimiento con la cuenta predeterminada.",
+ "config-db-install-account": "Cuenta de usuario para instalación",
+ "config-db-username": "Nombre de usuario de la base de datos:",
+ "config-db-password": "Contraseña de la base de datos:",
+ "config-db-install-username": "Escribe el nombre de usuario que se utilizará para conectarse a la base de datos durante el proceso de instalación.\nEste no es el nombre de usuario de la cuenta de MediaWiki, sino el nombre de usuario para la base de datos.",
+ "config-db-install-password": "Escribe la contraseña que se utilizará para conectarse a la base de datos durante el proceso de instalación.\nEsta no es la contraseña para la cuenta de MediaWiki, sino la contraseña para la base de datos.",
+ "config-db-install-help": "Escribe el nombre de usuario y la contraseña que se utilizarán para conectarse a la base de datos durante el proceso de instalación.",
+ "config-db-account-lock": "Usar el mismo nombre de usuario y contraseña durante operación normal",
+ "config-db-wiki-account": "Cuenta de usuario para operación normal",
+ "config-db-wiki-help": "Escribe el nombre de usuario y la contraseña que se utilizarán para acceder a la base de datos durante la operación normal del wiki.\nSi esta cuenta no existe y la cuenta de instalación tiene suficientes privilegios, se creará esta cuenta de usuario con los privilegios mínimos necesarios para la operación normal del wiki.",
+ "config-db-prefix": "Prefijo de tablas de la base de datos:",
+ "config-db-prefix-help": "Si necesitas compartir una base de datos entre múltiples wikis, o entre MediaWiki y otra aplicación web, puedes optar por agregar un prefijo a todos los nombres de tabla para evitar conflictos.\nNo utilices espacios.\n\nNormalmente se deja este campo vacío.",
+ "config-mysql-old": "Se necesita MySQL $1 o posterior. Tienes $2.",
+ "config-db-port": "Puerto de la base de datos:",
+ "config-db-schema": "Esquema para MediaWiki",
+ "config-db-schema-help": "Este esquema usualmente estará bien.\nCámbialos solo si lo necesitas.",
+ "config-pg-test-error": "No se puede conectar con la base de datos <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Directorio de datos SQLite:",
+ "config-sqlite-dir-help": "SQLite almacena todos los datos en un único archivo.\n\nEl directorio que proporciones debe poder escribirse por el servidor web durante la instalación.\n\n'''No''' debería ser accesible a través de Internet. Por eso no vamos a ponerlo en el sitio donde están los archivos PHP.\n\nEl instalador escribirá un archivo <code>.htaccess</code> junto con él, pero si falla alguien podría tener acceso a la base de datos en bloque.\nEso incluye los datos de usuario en bloque (direcciones de correo electrónico, las contraseñas con hash) así como revisiones eliminadas y otros datos restringidos del wiki.\n\nConsidera poner la base de datos en algún otro sitio, por ejemplo en <code>/var/lib/mediawiki/tuwiki</code> .",
+ "config-oracle-def-ts": "Espacio de tablas predeterminado:",
+ "config-oracle-temp-ts": "Espacio de tablas temporal:",
+ "config-type-mysql": "MySQL (o compatible)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki es compatible con los siguientes sistemas de bases de datos:\n\n$1\n\nSi no encuentras en el listado el sistema de base de datos que estás intentando utilizar, sigue las instrucciones enlazadas arriba para activar la compatibilidad.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] es la base de datos mayoritaria para MediaWiki y la que goza de mayor compatibilidad. MediaWiki también funciona con [{{int:version-db-mariadb-url}} MariaDB] y [{{int:version-db-percona-url}} Percona Server], que son compatibles con MySQL. ([http://www.php.net/manual/es/mysql.installation.php Cómo compilar PHP con compatibilidad MySQL])",
+ "config-dbsupport-postgres": "[{{int:version-db-postgres-url}} PostgreSQL] es un sistema de base de datos popular de código abierto, alternativa a MySQL. ([http://www.php.net/manual/es/pgsql.installation.php Cómo compilar PHP con compatibilidad PostgreSQL]).",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] es un sistema de base de datos ligero con gran compatibilidad con MediaWiki. ([http://www.php.net/manual/es/pdo.installation.php Cómo compilar PHP con compatibilidad SQLite], usando PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] es una base de datos comercial a nivel empresarial. ([http://www.php.net/manual/es/oci8.installation.php Cómo compilar PHP con compatibilidad con OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] es un sistema comercial de base de datos empresariales para Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Cómo compilar PHP con compatibilidad con SQLSRV])",
+ "config-header-mysql": "Configuración de MySQL",
+ "config-header-postgres": "Configuración de PostgreSQL",
+ "config-header-sqlite": "Configuración de SQLite",
+ "config-header-oracle": "Configuración de Oracle",
+ "config-header-mssql": "Configuración de Microsoft SQL Server",
+ "config-invalid-db-type": "El tipo de base de datos no es válido",
+ "config-missing-db-name": "Debes escribir un valor para \"{{int:config-db-nombre}}\".",
+ "config-missing-db-host": "Debes escribir un valor para \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Debes escribir un valor para \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "El TNS de la base de datos «$1» es inválido.\nDebes usar un \"TNS Name\" o una cadena \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Nomenclatura de Oracle]).",
+ "config-invalid-db-name": "El nombre de la base de datos \"$1\" no es válido.\nUsa sólo caracteres ASCII: letras (a-z, A-Z), números (0-9), guiones bajos (_) y guiones (-).",
+ "config-invalid-db-prefix": "El prefijo de la base de datos \"$1\" no es válido.\nUsa sólo caracteres ASCII: letras (a-z, A-Z), números (0-9), guiones bajos (_) y guiones (-).",
+ "config-connection-error": "$1.\n\nVerifica el servidor, el nombre de usuario y la contraseña, e intenta de nuevo.",
+ "config-invalid-schema": "El esquema de la base de datos \"$1\" es inválido.\nUse sólo carateres ASCII: letras (a-z, A-Z), guarismos (0-9) y guiones bajos (_).",
+ "config-db-sys-create-oracle": "El instalador sólo admite el empleo de cuentas SYSDBA como método para crear una cuenta nueva.",
+ "config-db-sys-user-exists-oracle": "La cuenta de usuario «$1» ya existe. SYSDBA solo puede utilizarse para crear cuentas nuevas.",
+ "config-postgres-old": "Se requiere PostgreSQL $1 o posterior. Tienes la versión $2.",
+ "config-mssql-old": "Se requiere Microsoft SQL Server $1 o posterior. Tienes la versión $2.",
+ "config-sqlite-name-help": "Elige el nombre que identificará a tu wiki.\nNo uses espacios o guiones.\nEste nombre se usará como nombre del archivo de datos de SQLite.",
+ "config-sqlite-parent-unwritable-group": "No se puede crear el directorio de datos <code><nowiki>$1</nowiki></code>, porque el servidor web no tiene permiso de escribir en el directorio padre <code><nowiki>$2</nowiki></code>.\n\nEl instalador ha determinado el usuario con el que se ejecuta tu servidor web.\nConcede permisos de escritura a él en el directorio <code><nowiki>$3</nowiki></code> para continuar.\nEn un sistema Unix/Linux haz:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "No se puede crear el directorio de datos <code><nowiki>$1</nowiki></code>, porque el servidor web no tiene permiso de escribir en el directorio padre <code><nowiki>$2</nowiki></code>.\n\nEl instalador no pudo determinar el usuario con el que se ejecuta tu servidor web.\nConcede permisos de escritura a él (¡y a otros!) en el directorio <code><nowiki>$3</nowiki></code> para continuar.\nEn un sistema Unix/Linux haz:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Error al crear el directorio de datos \"$1\".\nComprueba la ubicación e inténtalo de nuevo.",
+ "config-sqlite-dir-unwritable": "No se puede escribir en el directorio \"$1\".\nModifica sus permisos para que el servidor web pueda escribir en él, y vuelve a intentarlo.",
+ "config-sqlite-connection-error": "$1.\n\nVerifica el directorio de datos y el nombre de la base de datos mostrada a continuación, e inténtalo nuevamente.",
+ "config-sqlite-readonly": "El archivo <code>$1</code> no se puede escribir.",
+ "config-sqlite-cant-create-db": "No fue posible crear el archivo de la base de datos <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "El PHP no tiene compatibilidad FTS3. actualizando tablas a una versión anterior",
+ "config-can-upgrade": "Esta base de datos contiene tablas de MediaWiki.\nPara actualizarlas a MediaWiki $1, haz clic en <strong>Continuar</strong>.",
+ "config-upgrade-done": "Actualización completa.\n\nYa puedes [$1 empezar a usar tu wiki].\n\nSi quieres regenerar tu archivo <code>LocalSettings.php</code>, haz clic en el botón de abajo.\nEsto <strong>no se recomienda</strong> a menos que estés teniendo problemas con tu wiki.",
+ "config-upgrade-done-no-regenerate": "Actualización completa.\n\nYa puedes [$1 empezar a usar tu wiki].",
+ "config-regenerate": "Regenerar LocalSettings.php →",
+ "config-show-table-status": "¡Falló la consulta <code>SHOW TABLE STATUS</code>!",
+ "config-unknown-collation": "<strong>Advertencia:</strong> la base de datos está utilizando una intercalación no reconocida.",
+ "config-db-web-account": "Cuenta de la base de datos para acceso web",
+ "config-db-web-help": "Elige el usuario y contraseña que el servidor web usará para conectarse al servidor de la base de datos durante el funcionamiento normal del wiki.",
+ "config-db-web-account-same": "Utilizar la misma cuenta que en la instalación",
+ "config-db-web-create": "Crear la cuenta si no existe",
+ "config-db-web-no-create-privs": "La cuenta que has especificado para la instalación no tiene privilegios suficientes para crear una cuenta.\nLa cuenta que especifiques aquí ya debe existir.",
+ "config-mysql-engine": "Motor de almacenamiento:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Advertencia:</strong> has seleccionado MyISAM como motor de almacenamiento de MySQL, el cual no está recomendado para usarse con MediaWiki, porque:\n* apenas soporta concurrencia debido al bloqueo de tablas\n* es más propenso a la corrupción que otros motores\n* el código MediaWiki no siempre controla MyISAM como debiera\n\nSi tu instalación de MySQL soporta InnoDB, es muy recomendable que lo elijas en su lugar.\nSi tu instalación de MySQL no soporta InnoDB, quizás es el momento de una actualización.",
+ "config-mysql-only-myisam-dep": "<strong>Advertencia:</strong> solo se ha encontrado el motor de almacenamiento MyISAM para MySQL en esta máquina, y no se recomienda su uso con MediaWiki, porque:\n* apenas admite la concurrencia debido al bloqueo de tablas\n* es más propenso a daños que otros motores\n* el código de MediaWiki no siempre controla MyISAM como debería\n\nTu instalación de MySQL no admite InnoDB; quizás es el momento de una actualización.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> es casi siempre la mejor opción, dado que soporta bien los accesos simultáneos.\n\n<strong>MyISAM</strong> puede ser más rápido en instalaciones con usuario único o de sólo lectura.\nLas bases de datos MyISAM tienden a corromperse más a menudo que las bases de datos InnoDB.",
+ "config-mysql-charset": "Conjunto de caracteres de la base de datos:",
+ "config-mysql-binary": "Binario",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "En <strong>modo binario</strong>, MediaWiki almacena texto UTF-8 para la base de datos en campos binarios.\nEsto es más eficiente que el modo UTF-8 de MySQL y permite utilizar la gama completa de caracteres Unicode.\n\nEn <strong>modo UTF-8</strong>, MySQL sabrá qué conjunto de caracteres emplean sus datos y puede presentarlos y convertirlos adecuadamente, pero no permitirá almacenar caracteres por encima del [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes plano multilingüe básico].",
+ "config-mssql-auth": "Tipo de autenticación:",
+ "config-mssql-install-auth": "Selecciona el tipo de autenticación que se utilizará para conectarse a la base de datos durante el proceso de instalación.\nSi seleccionas \"{{int:config-mssql-windowsauth}}\", se usarán las credenciales del usuario con el que se ejecuta el servidor web.",
+ "config-mssql-web-auth": "Selecciona el tipo de autenticación que utilizará el servidor web para conectarse al servidor de base de datos, durante el funcionamiento normal de la wiki.\nSi seleccionas \"{{int:config-mssql-windowsauth}}\", se usarán las credenciales del usuario con el cual se ejecuta el servidor web.",
+ "config-mssql-sqlauth": "Autenticación de SQL Server",
+ "config-mssql-windowsauth": "Autenticación de Windows",
+ "config-site-name": "Nombre del wiki:",
+ "config-site-name-help": "Esto aparecerá en la barra de título del navegador y en varios otros lugares.",
+ "config-site-name-blank": "Escribe un nombre de sitio.",
+ "config-project-namespace": "Espacio de nombres del proyecto:",
+ "config-ns-generic": "Proyecto",
+ "config-ns-site-name": "Igual al nombre del wiki: $1",
+ "config-ns-other": "Otro (especificar)",
+ "config-ns-other-default": "MiWiki",
+ "config-project-namespace-help": "Siguiendo el ejemplo de Wikipedia, muchos wikis mantienen sus páginas de políticas separadas de sus páginas de contenido, en un '''espacio de nombres del proyecto'''.\n\nTodos los títulos de página en este espacio de nombres comienzan con un determinado prefijo, que puedes especificar aquí.\nUsualmente, este prefijo se deriva del nombre del wiki, pero no puede contener caracteres de puntuación como \"#\" o \":\".",
+ "config-ns-invalid": "El espacio de nombres especificado \"<nowiki>$1</nowiki>\" no es válido.\nEspecifica uno diferente.",
+ "config-ns-conflict": "El espacio de nombres especificado \"<nowiki>$1</nowiki>\" entra en conflicto con uno predeterminado de MediaWiki.\nEspecifica uno diferente.",
+ "config-admin-box": "Cuenta de administrador",
+ "config-admin-name": "Tu nombre de usuario:",
+ "config-admin-password": "Contraseña:",
+ "config-admin-password-confirm": "Repite la contraseña:",
+ "config-admin-help": "Escribe aquí el nombre de usuario que desees, como por ejemplo \"Pedro Bloggs\".\nEste es el nombre que usarás para entrar al wiki.",
+ "config-admin-name-blank": "Escribe un nombre de usuario de administrador.",
+ "config-admin-name-invalid": "El nombre de usuario especificado \"<nowiki>$1</nowiki>\" no es válido.\nEspecifica un nombre de usuario diferente.",
+ "config-admin-password-blank": "Escribe una contraseña para la cuenta de administrador.",
+ "config-admin-password-mismatch": "Las dos contraseñas que ingresaste no coinciden.",
+ "config-admin-email": "Dirección de correo electrónico:",
+ "config-admin-email-help": "Escribe aquí una dirección de correo electrónico para que te permita recibir mensajes de otros usuarios del wiki, restablecer tu contraseña y recibir notificaciones de cambios a tus páginas vigiladas. Puedes dejar este campo vacío.",
+ "config-admin-error-user": "Error interno al crear un administrador con el nombre \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Error interno al establecer una contraseña para el administrador \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Has escrito una dirección de correo electrónico no válida.",
+ "config-subscribe": "Suscribirse a la [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce lista de correo de anuncios de versiones].",
+ "config-subscribe-help": "Esta es una lista de divulgación de bajo volumen para anuncios de lanzamiento de versiones nuevas, incluyendo anuncios de seguridad importantes.\nTe recomendamos suscribirte y actualizar tu instalación MediaWiki cada vez que se lance una nueva versión.",
+ "config-subscribe-noemail": "Has intentado suscribirte a la lista de correo de anuncios de nuevos lanzamientos sin proporcionar una dirección de correo electrónico.\nProporciona una dirección de correo electrónico si quieres suscribirte a la lista de correo.",
+ "config-pingback": "Compartir datos sobre esta instalación con los desarrolladores de MediaWiki.",
+ "config-pingback-help": "Si seleccionas esta opción, MediaWiki enviará periódicamente a https://www.mediawiki.org datos básicos sobre esta instancia de MediaWiki. Se trata de datos tales como el tipo de sistema, la versión de PHP y la base de datos elegida. La Fundación Wikimedia comparte estos datos con los desarrolladores de MediaWiki para ayudar a guiar el desarrollo futuro. Se enviarán los siguientes datos para tu sistema:\n<pre>$1</pre>",
+ "config-almost-done": "¡Ya casi has terminado!\nAhora puedes saltarte el resto de los pasos e instalar el wiki ya.",
+ "config-optional-continue": "Hazme más preguntas.",
+ "config-optional-skip": "Ya me aburrí. Tan solo instala el wiki.",
+ "config-profile": "Perfil de derechos de usuario:",
+ "config-profile-wiki": "Wiki abierto",
+ "config-profile-no-anon": "Creación de cuenta requerida",
+ "config-profile-fishbowl": "Solo editores autorizados",
+ "config-profile-private": "Wiki privado",
+ "config-profile-help": "Los wikis funcionan mejor cuando dejas que los edite tanta gente como sea posible.\nEn MediaWiki, es fácil revisar los cambios recientes y revertir los daños realizados por usuarios malintencionados o novatos.\nSin embargo, muchos han encontrado que MediaWiki es útil para una amplia variedad de funciones, y a veces no es fácil convencer a todos de los beneficios de la forma wiki.\nPor lo tanto tienes la elección.\n\nEl modelo <strong>{{int:config-profile-wiki}}</strong> permite que cualquiera pueda editar, sin siquiera iniciar sesión.\nUn wiki con <strong>{{int:config-profile-no-anon}}</strong> ofrece rendición de cuentas adicional, pero puede disuadir a colaboradores casuales.\n\nEl modelo <strong>{{int:config-profile-fishbowl}}</strong> permite editar a los usuarios autorizados, pero el público puede ver las páginas, incluyendo el historial.\nUn <strong>{{int:config-profile-private}}</strong> sólo permite ver páginas a los usuarios autorizados, el mismo grupo al que le está permitido editar.\n\nConfiguraciones más complejas de permisos de usuario están disponibles después de la instalación. Consulta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights la entrada correspondiente del manual].",
+ "config-license": "Derechos de autor y licencia:",
+ "config-license-none": "Pie sin licencia",
+ "config-license-cc-by-sa": "Creative Commons Atribución-CompartirIgual",
+ "config-license-cc-by": "Creative Commons Atribución",
+ "config-license-cc-by-nc-sa": "Creative Commons Atribución-NoComercial-CompartirIgual",
+ "config-license-cc-0": "Creative Commons Zero (dominio público)",
+ "config-license-gfdl": "Licencia de documentación libre de GNU 1.3 o posterior",
+ "config-license-pd": "Dominio público",
+ "config-license-cc-choose": "Selecciona una licencia personalizada de Creative Commons",
+ "config-license-help": "Muchos wikis públicos ponen todas las contribuciones bajo una [http://freedomdefined.org/Definition licencia libre].\nEsto ayuda a crear un sentido de propiedad comunitaria y alienta la contribución a largo plazo.\nEsto no es generalmente necesario para un wiki privado o corporativo.\n\nSi deseas poder utilizar texto de Wikipedia, y deseas que Wikipedia pueda aceptar el texto copiado de tu wiki, debes elegir <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia utilizaba anteriormente la licencia de documentación libre de GNU (GFDL).\nLa GFDL es una licencia válida, pero es difícil de entender.\nTambién es difícil reutilizar el contenido licenciado bajo la GFDL.",
+ "config-email-settings": "Configuración de correo electrónico",
+ "config-enable-email": "Activar el envío de correos electrónicos",
+ "config-enable-email-help": "Si quieres que el correo electrónico funcione, la [http://www.php.net/manual/en/mail.configuration.php configuración PHP de correo electrónico] debe ser la correcta.\nSi no quieres la funcionalidad de correo electrónico, puedes desactivarla aquí.",
+ "config-email-user": "Habilitar correo electrónico entre usuarios",
+ "config-email-user-help": "Permitir que todos los usuarios intercambien correos electrónicos si lo han activado en sus preferencias.",
+ "config-email-usertalk": "Activar notificaciones de páginas de discusión de usuarios",
+ "config-email-usertalk-help": "Permitir a los usuarios recibir notificaciones de cambios en la página de discusión de usuario, si lo han activado en sus preferencias.",
+ "config-email-watchlist": "Activar la notificación de la lista de seguimiento",
+ "config-email-watchlist-help": "Permitir a los usuarios recibir notificaciones de cambios en la páginas que vigilan, si lo han activado en sus preferencias.",
+ "config-email-auth": "Activar autenticación del correo electrónico",
+ "config-email-auth-help": "Si esta opción está habilitada, los usuarios tienen que confirmar su dirección de correo electrónico mediante un enlace que se les envía a ellos cuando éstos lo establecen o lo cambian.\nSolo las direcciones de correo electrónico autenticadas pueden recibir correos electrónicos de otros usuarios o correos electrónicos de notificación de cambios.\nEsta opción está '''recomendada''' para wikis públicos debido a posibles abusos de las características del correo electrónico.",
+ "config-email-sender": "Dirección de correo electrónico de retorno:",
+ "config-email-sender-help": "Escribe la dirección de correo electrónico que se usará como dirección de retorno en los mensajes electrónicos de salida.\nAquí llegarán los correos electrónicos que no lleguen a su destino.\nMuchos servidores de correo electrónico exigen que por lo menos la parte del nombre del dominio sea válida.",
+ "config-upload-settings": "Subidas de imágenes y archivos",
+ "config-upload-enable": "Habilitar la subida de archivos",
+ "config-upload-help": "La subida de archivos potencialmente expone tu servidor a riesgos de seguridad.\nPara obtener más información, consulta la [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security sección de seguridad] en el manual.\n\nPara activar la subida de archivos, cambia el modo en el subdirectorio <code>images</code> bajo el directorio raíz de MediaWiki para que el servidor web pueda escribir en él.\nLuego, activa esta opción.",
+ "config-upload-deleted": "Directorio para los archivos eliminados:",
+ "config-upload-deleted-help": "Elige un directorio en el que guardar los archivos eliminados.\nLo ideal es una carpeta no accesible desde la red.",
+ "config-logo": "URL del logo :",
+ "config-logo-help": "La apariencia predeterminada de MediaWiki incluye espacio para un logotipo de 135x160 píxeles encima del menú de la barra lateral.\nCarga una imagen de tamaño adecuado y escribe la dirección URL aquí.\n\nPuedes usar <code>$wgStylePath</code> o <code>$wgScriptPath</code> si tu logotipo es relativo a esas rutas.\n\nSi no deseas un logotipo, deja esta casilla en blanco.",
+ "config-instantcommons": "Habilitar Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] es una característica que permite que los wikis puedan utilizar imágenes, sonidos y otros archivos multimedia que se encuentran en el sitio [https://commons.wikimedia.org/ Wikimedia Commons].\nPara ello, MediaWiki requiere acceso a Internet.\n\nPara obtener más información sobre esta función, incluidas las instrucciones sobre cómo configurarlo para otras wikis distintas de Wikimedia Commons, consulta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos el manual].",
+ "config-cc-error": "El selector de licencia de Creative Commons no dio resultado.\nEscribe el nombre de la licencia manualmente.",
+ "config-cc-again": "Elegir otra vez...",
+ "config-cc-not-chosen": "Elige la licencia Creative Commons que desees y haz clic en \"proceed\".",
+ "config-advanced-settings": "Configuración avanzada",
+ "config-cache-options": "Configuración de la caché de objetos:",
+ "config-cache-help": "El almacenamiento en caché de objetos se utiliza para mejorar la velocidad de MediaWiki mediante el almacenamiento en caché los datos usados más frecuentemente.\nA los sitios medianos y grandes se les recomienda que permitirlo. También es beneficioso para los sitios pequeños.",
+ "config-cache-none": "Sin almacenamiento en caché (no se pierde ninguna funcionalidad, pero la velocidad puede resentirse en sitios grandes)",
+ "config-cache-accel": "Almacenamiento en caché de objetos PHP (APC, APCu, XCache o WinCache)",
+ "config-cache-memcached": "Utilizar Memcached (necesita ser instalado y configurado aparte)",
+ "config-memcached-servers": "Servidores Memcached:",
+ "config-memcached-help": "Lista de direcciones IP que serán usadas por Memcached.\nDeben especificarse una por cada línea y especificar el puerto a utilizar. Por ejemplo:\n127.0.0.1:11211\n192.168.1.25:1234",
+ "config-memcache-needservers": "Ha seleccionado Memcached como su tipo de caché pero no especificó ninguno de los servidores.",
+ "config-memcache-badip": "Ha introducido una dirección IP no válida para Memcached: $1 .",
+ "config-memcache-noport": "No ha especificado un puerto a usar en el servidor Memcached: $1 .\nSi no conoce el puerto, el valor predeterminado es 11211.",
+ "config-memcache-badport": "Los números de puerto de Memcached deben estar entre $1 y $2.",
+ "config-extensions": "Extensiones",
+ "config-extensions-help": "Se ha detectado en tu directorio <code>./extensions</code> las extensiones listadas arriba.\n\nPuede que necesiten configuraciones adicionales, pero puedes habilitarlas ahora.",
+ "config-skins": "Apariencias",
+ "config-skins-help": "Las apariencias mencionadas anteriormente fueron detectadas en tu directorio <code>./skins</code>. Debes habilitar al menos una y elegir la predeterminada.",
+ "config-skins-use-as-default": "Utilizar esta apariencia como predeterminada",
+ "config-skins-missing": "No se encontró ninguna apariencia; MediaWiki utilizará una apariencia anterior hasta que instales unas apariencias adecuadas.",
+ "config-skins-must-enable-some": "Debes seleccionar al menos una apariencia para activar.",
+ "config-skins-must-enable-default": "La apariencia elegida como predeterminada debe estar habilitada.",
+ "config-install-alreadydone": "<strong>Advertencia:</strong> parece que ya habías instalado MediaWiki y estás intentando instalarlo nuevamente.\nPasa a la próxima página.",
+ "config-install-begin": "Al pulsar en «{{int:config-continue}}» comenzará el proceso de instalación de MediaWiki.\nSi quieres realizar algún cambio, pulsa en «{{int:config-back}}».",
+ "config-install-step-done": "hecho",
+ "config-install-step-failed": "falló",
+ "config-install-extensions": "Incluyendo extensiones",
+ "config-install-database": "Configurando la base de datos",
+ "config-install-schema": "Creando el esquema",
+ "config-install-pg-schema-not-exist": "El esquema PostgreSQL no existe.",
+ "config-install-pg-schema-failed": "Falló la creación de las tablas.\nAsegúrate de que la cuenta «$1» tiene permiso de escritura para el esquema «$2».",
+ "config-install-pg-commit": "Validando los cambios",
+ "config-install-pg-plpgsql": "Comprobación de lenguaje PL/pgSQL",
+ "config-pg-no-plpgsql": "Necesitas instalar el lenguaje PL/pgSQL en la base de datos $1",
+ "config-pg-no-create-privs": "La cuenta especificada para la instalación no tiene suficientes privilegios para crear una cuenta.",
+ "config-pg-not-in-role": "La cuenta especificada para el usuario web ya existe.\nLa cuenta especificada para la instalación no es de un superusuario y no es miembro del grupo de usuarios con acceso a la web, por lo que es incapaz de crear objetos pertenecientes al usuario web.\n\nMediaWiki requiere actualmente que las tablas sean propiedad del usuario web. Especifica otro nombre de cuenta web, o haz clic en \"atrás\" y especifica un usuario de instalación con los privilegios convenientes.",
+ "config-install-user": "Creando el usuario de la base de datos",
+ "config-install-user-alreadyexists": "El usuario \"$1\" ya existe",
+ "config-install-user-create-failed": "La creación del usuario \"$1\" falló: $2",
+ "config-install-user-grant-failed": "La concesión de permisos al usuario \"$1\" falló: $2",
+ "config-install-user-missing": "El usuario especificado \"$1\" no existe.",
+ "config-install-user-missing-create": "El usuario especificado \"$1\" no existe.\nHaz clic en la casilla \"Crear cuenta\" debajo si quieres crearlo.",
+ "config-install-tables": "Creando tablas",
+ "config-install-tables-exist": "<strong>Advertencia:</strong> al parecer, las tablas de MediaWiki ya existen. Saltándose su creación.",
+ "config-install-tables-failed": "<strong>Error:</strong> la creación de las tablas falló con el siguiente error: $1",
+ "config-install-interwiki": "Llenando la tabla interwiki predeterminada",
+ "config-install-interwiki-list": "No se pudo leer el archivo <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Advertencia:</strong> la tabla de interwikis parece ya contener entradas.\nSe omitirá la lista predeterminada.",
+ "config-install-stats": "Iniciando las estadísticas",
+ "config-install-keys": "Generando claves secretas",
+ "config-insecure-keys": "<strong>Advertencia:</strong> {{PLURAL:$2|una clave de seguridad generada|las claves de seguridad generadas}} ($1) durante la instalación no {{PLURAL:$2|es totalmente segura|son totalmente seguras}}. Considera {{PLURAL:$2|cambiarla|cambiarlas}} manualmente.",
+ "config-install-updates": "Evitar ejecutar actualizaciones innecesarias",
+ "config-install-updates-failed": "<strong>Error:</strong> falló la inserción de claves de actualización en las tablas con el siguiente error: $1",
+ "config-install-sysop": "Creando la cuenta de usuario del administrador",
+ "config-install-subscribe-fail": "No se ha podido suscribir a mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL no está instalado y <code>allow_url_fopen</code> no está disponible.",
+ "config-install-mainpage": "Creando página principal con contenido predeterminado",
+ "config-install-mainpage-exists": "La página principal ya existe, se omite",
+ "config-install-extension-tables": "Creando las tablas para las extensiones habilitadas",
+ "config-install-mainpage-failed": "No se pudo insertar la página principal: $1",
+ "config-install-done": "<strong>¡Felicidades!</strong>\nHas instalado MediaWiki.\n\nEl instalador ha generado un archivo <code>LocalSettings.php</code>.\nEste contiene toda su configuración.\n\nDeberás descargarlo y ponerlo en la base de la instalación de wiki (el mismo directorio que index.php). La descarga debería haber comenzado automáticamente.\n\nSi no comenzó la descarga, o si se ha cancelado, puedes reiniciar la descarga haciendo clic en el siguiente enlace:\n\n$3\n\n<strong>Nota</strong>: si no haces esto ahora, este archivo de configuración generado no estará disponible más tarde si sales de la instalación sin descargarlo.\n\nCuando lo hayas hecho, podrás <strong>[$2 entrar en tu wiki]</strong>.",
+ "config-install-done-path": "<strong>¡Felicidades!</strong>\nHas instalado MediaWiki.\n\nEl instalador ha generado un archivo <code>LocalSettings.php</code>.\nEste contiene toda su configuración.\n\nDeberás descargarlo y ponerlo en <code>$4</code>. La descarga debería haber comenzado automáticamente.\n\nSi no comenzó la descarga, o si se ha cancelado, puedes reiniciar la descarga haciendo clic en el siguiente enlace:\n\n$3\n\n<strong>Nota</strong>: si no haces esto ahora, este archivo de configuración generado no estará disponible más tarde si sales de la instalación sin descargarlo.\n\nCuando lo hayas hecho, podrás <strong>[$2 entrar en tu wiki]</strong>.",
+ "config-download-localsettings": "Descargar <code>LocalSettings.php</code>",
+ "config-help": "ayuda",
+ "config-help-tooltip": "haz clic para ampliar",
+ "config-nofile": "El archivo \"$1\" no se pudo encontrar. ¿Se ha eliminado?",
+ "config-extension-link": "¿Sabías que tu wiki admite [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensiones]?\n\nPuedes navegar por las [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category categorías] o visitar el [https://www.mediawiki.org/wiki/Extension_Matrix centro de extensiones] para ver una lista completa.",
+ "config-skins-screenshots": "$1 (capturas de pantalla: $2)",
+ "config-screenshot": "captura de pantalla",
+ "mainpagetext": "<strong>MediaWiki se ha instalado.</strong>",
+ "mainpagedocfooter": "Consulta la [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents guía] para obtener información sobre el uso del software wiki.\n\n== Primeros pasos ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de ajustes de configuración]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Preguntas frecuentes sobre MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de correo de anuncios de publicación de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Traducir MediaWiki a tu idioma]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Aprende a combatir el spam en tu wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/et.json b/www/wiki/includes/installer/i18n/et.json
new file mode 100644
index 00000000..c9d28861
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/et.json
@@ -0,0 +1,83 @@
+{
+ "@metadata": {
+ "authors": [
+ "Avjoska",
+ "Pikne",
+ "Boxmein",
+ "Cumbril",
+ "Roland",
+ "Postituvi"
+ ]
+ },
+ "config-desc": "MediaWiki paigaldaja",
+ "config-title": "MediaWiki $1 install",
+ "config-information": "Teave",
+ "config-upgrade-key-missing": "Tuvastati olemasolev MediaWiki install.\nSelle installi täiendamiseks lisa palun järgmine rida faili <code>LocalSettings.php</code> lõppu:\n\n$1",
+ "config-session-error": "Tõrge seansi alustamisel: $1",
+ "config-your-language": "Oma keel:",
+ "config-wiki-language": "Viki keel:",
+ "config-back": "← Tagasi",
+ "config-continue": "Jätka →",
+ "config-page-language": "Keel",
+ "config-page-welcome": "Tere tulemast MediaWikisse!",
+ "config-page-dbconnect": "Andmebaasiga ühendamine",
+ "config-page-upgrade": "Olemasoleva installi uuendus",
+ "config-page-dbsettings": "Andmebaasi sätted",
+ "config-page-name": "Nimi",
+ "config-page-options": "Seaded",
+ "config-page-install": "Paigaldamine",
+ "config-page-complete": "Valmis!",
+ "config-page-restart": "Alusta installimist uuesti",
+ "config-page-readme": "Loe mind",
+ "config-page-releasenotes": "Redaktsioonimärkmed",
+ "config-page-copying": "Kopeerimine",
+ "config-page-upgradedoc": "Uuendamine",
+ "config-page-existingwiki": "Olemasolev viki",
+ "config-restart": "Jah, tee taaskäivitus",
+ "config-env-php": "PHP $1 on paigaldatud.",
+ "config-env-hhvm": "HHVM $1 on installitud.",
+ "config-diff3-bad": "GNU diff3 ei leitud.",
+ "config-db-type": "Andmebaasi tüüp:",
+ "config-db-name": "Andmebaasi nimi:",
+ "config-db-name-oracle": "Andmebaasi skeem:",
+ "config-db-username": "Andmebaasi kasutajanimi:",
+ "config-db-password": "Andmebaasi parool:",
+ "config-db-port": "Andmebaasi port:",
+ "config-invalid-db-type": "Vigane andmebaasi tüüp",
+ "config-site-name": "Viki nimi:",
+ "config-site-name-blank": "Sisesta võrgukoha nimi.",
+ "config-project-namespace": "Projekti nimeruum:",
+ "config-ns-generic": "Projekt",
+ "config-admin-box": "Administraatorikonto",
+ "config-admin-name": "Sinu kasutajanimi:",
+ "config-admin-password": "Parool:",
+ "config-admin-password-confirm": "Parool uuesti:",
+ "config-admin-name-blank": "Sisesta administraatori kasutajanimi.",
+ "config-admin-password-blank": "Sisesta administraatorikonto parool.",
+ "config-admin-password-mismatch": "Sisestatud kaks parooli ei lange kokku.",
+ "config-admin-email": "E-posti aadress:",
+ "config-admin-error-bademail": "Sisestasid vigase e-posti aadressi.",
+ "config-optional-continue": "Küsi minult veel küsimusi.",
+ "config-profile-wiki": "Avalik viki",
+ "config-profile-no-anon": "Registreerumine nõutav",
+ "config-profile-fishbowl": "Ainult volitatud kasutajad",
+ "config-profile-private": "Eraviki",
+ "config-profile-help": "Vikid toimivad kõige paremini siis, kui lased neid redigeerida nii paljudel inimestel kui võimalik.\nMediaWikis on lihtne viimaseid muudatusi üle vaadata ja pöörata tagasi oskamatute või pahatahtlike kasutajate tehtud kahju.\n\nEnt inimesed on leidnud MediaWikile mitmesuguseid erinevaid kasutusvõimalusi ja mõnikord pole lihtne kõiki veenda viki meetodi kasulikkuses. \nSeega on sul valik.\n\n\"<strong>{{int:config-profile-wiki}}</strong>\" annab kõigile redigeerimisvõimaluse isegi sisse logimata.\nMudeli \"<strong>{{int:config-profile-no-anon}}</strong>\" viki tagab lisavastutuse, kuid võib juhuslikud kaastöölised eemale peletada.\n\nStsenaarium \"<strong>{{int:config-profile-fishbowl}}</strong>\" võimaldab redigeerida heaks kiidetud kasutajatel, kuid avalikkus saab lehekülgi ja nende ajalugu vaadata.\n\"<strong>{{int:config-profile-private}}</strong>\" laseb vaid heaks kiidetud kasutajatel lehekülgi vaadata ja samadel kasutajatel on õigus lehekülgi redigeerida.\n\nPärast paigaldamist on kasutajaõigusi võimalik täpsemalt häälestada, vaata [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights kasutusjuhendi vastavat kohta].",
+ "config-license": "Autoriõigus ja litsents:",
+ "config-license-none": "Litsentsijaluseta",
+ "config-license-cc-by-sa": "Creative Commonsi litsents \"Autorile viitamine + jagamine samadel tingimustel\"",
+ "config-license-cc-by": "Creative Commonsi litsents \"Autorile viitamine\"",
+ "config-license-cc-by-nc-sa": "Creative Commonsi litsents \"Autorile viitamine + mitteäriline eesmärk + jagamine samadel tingimustel\"",
+ "config-email-settings": "E-posti sätted",
+ "config-email-sender": "Saatja e-posti aadress:",
+ "config-logo": "Logo internetiaadress:",
+ "config-cc-again": "Vali uuesti...",
+ "config-extensions": "Lisad",
+ "config-install-step-done": "valmis",
+ "config-install-step-failed": "ebaõnnestus",
+ "config-install-user-alreadyexists": "Kasutaja \"$1\" on juba olemas",
+ "config-install-tables": "Tabelite loomine",
+ "config-help": "abi",
+ "mainpagetext": "<strong>MediaWiki tarkvara on paigaldatud.</strong>",
+ "mainpagedocfooter": "Vikitarkvara kasutamise kohta leiad lisateavet [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents kasutaja teatmikust].\n\n== Alustamine ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Häälestussätete loend]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki KKK]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki versiooniuuenduste postiloend]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources MediaWiki lokaliseerimine]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Loe, kuidas vikis rämpspostitusi tõrjuda]"
+}
diff --git a/www/wiki/includes/installer/i18n/eu.json b/www/wiki/includes/installer/i18n/eu.json
new file mode 100644
index 00000000..0591cbb0
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/eu.json
@@ -0,0 +1,131 @@
+{
+ "@metadata": {
+ "authors": [
+ "An13sa",
+ "පසිඳු කාවින්ද",
+ "Subi",
+ "Sator",
+ "Mikel Ibaiba"
+ ]
+ },
+ "config-desc": "MediaWiki instalatzailea",
+ "config-title": "MediaWiki $1 instalazioa",
+ "config-information": "Informazioa",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code> fitxategi bat detektatu da.\nInstalazioa eguneratzeko, mesedez, sar ezazu <code>$wgUpgradeKey</code> balioa beheko koadroan.\n<code>LocalSettings.php</code> fitxategian aurkituko duzu.",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code> fitxategi bat detektatu da.\nInstalazioa eguneratzeko, exekuta ezazu <code>update.php</code>, mesedez",
+ "config-localsettings-key": "Eguneratze-gakoa:",
+ "config-session-error": "Saio hasierako errorea: $1",
+ "config-your-language": "Zure hizkuntza:",
+ "config-your-language-help": "Aukeratu instalazio prozesuan erabiliko den hizkuntza",
+ "config-wiki-language": "Wiki hizkuntza:",
+ "config-back": "← Atzera",
+ "config-continue": "Jarraitu →",
+ "config-page-language": "Hizkuntza",
+ "config-page-welcome": "Ongi etorri MediaWikira!",
+ "config-page-dbconnect": "Datu-basera konektatu",
+ "config-page-upgrade": "Oraingo instalazioa eguneratu",
+ "config-page-dbsettings": "Datu-basearen ezarpenak",
+ "config-page-name": "Izena",
+ "config-page-options": "Aukerak",
+ "config-page-install": "Instalatu",
+ "config-page-complete": "Bukatua!",
+ "config-page-restart": "Instalazioa berriz hasi",
+ "config-page-readme": "Irakur nazazu",
+ "config-page-releasenotes": "Bertsioko oharrak",
+ "config-page-copying": "Kopiatzea",
+ "config-page-upgradedoc": "Eguneratu",
+ "config-page-existingwiki": "Existitzen den wikia",
+ "config-restart": "Bai, berriz hasi",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki nagusia]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Erabiltzaileentzako Gida]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administratzaileentzako Gida]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MEG]\n----\n* <doclink href=Readme>Irakur nazazu</doclink>\n* <doclink href=ReleaseNotes>Oharren argitalpena</doclink>\n* <doclink href=Copying>Kopiaketa</doclink>\n* <doclink href=UpgradeDoc>Eguneratzea</doclink>",
+ "config-env-php": "PHP $1 instalatuta dago.",
+ "config-env-hhvm": "HHVM $1 instalatuta dago.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] instalatuta dago",
+ "config-apc": "[http://www.php.net/apc APC] instalatuta dago",
+ "config-apcu": "[http://www.php.net/apcu APCu] instalatuta dago",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] instalatuta dago",
+ "config-diff3-bad": "GNU diff3 ez da aurkitu.",
+ "config-using-server": "\"<nowiki>$1</nowiki>\" zerbitzari-izena erabiltzen.",
+ "config-using-uri": "\"<nowiki>$1$2</nowiki>\" zerbitzariaren URLa erabiltzen.",
+ "config-db-type": "Datu-base mota:",
+ "config-db-host": "Datu-basearen zerbitzaria:",
+ "config-db-host-oracle": "Datu-baseko TNS:",
+ "config-db-wiki-settings": "Wiki hau identifikatu",
+ "config-db-name": "Datu-base izena:",
+ "config-db-name-oracle": "Datu-baseko eskema:",
+ "config-db-username": "Datu-base lankide izena:",
+ "config-db-password": "Datu-base pasahitza:",
+ "config-db-port": "Datu-basearen ataka:",
+ "config-db-schema": "MediaWikirako eskema:",
+ "config-type-mysql": "MySQL (edo bateragarria)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "MySQL hobespenak",
+ "config-header-postgres": "PostgreSQL hobespenak",
+ "config-header-sqlite": "SQLite hobespenak",
+ "config-header-oracle": "Oracle hobespenak",
+ "config-header-mssql": "Microsoft SQL Server-en ezarpenak",
+ "config-invalid-db-type": "Datu-base mota baliogabea.",
+ "config-db-sys-user-exists-oracle": "$1 erabiltzaile kontua dagoeneko existitzen da. SYSDBA kontu berri bat sortzeko erabili daiteke soilik!",
+ "config-sqlite-readonly": "Ezin da idatzi <code>$1</code> fitxategian.",
+ "config-regenerate": "Birsortu LocalSettings.php →",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Bitarra",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Wikiaren izena:",
+ "config-project-namespace": "Proiektuaren izen-tartea:",
+ "config-ns-generic": "Proiektua",
+ "config-ns-other": "Bestelakoa (zehaztu)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "Administratzaile kontua",
+ "config-admin-name": "Zure erabiltzaile-izena:",
+ "config-admin-password": "Pasahitza:",
+ "config-admin-password-confirm": "Pasahitza berriz:",
+ "config-admin-password-mismatch": "Sartutako bi pasahitzak ez datoz bat.",
+ "config-admin-email": "E-posta helbidea:",
+ "config-admin-error-bademail": "Helbide elektroniko okerra idatzi duzu.",
+ "config-optional-continue": "Galdera gehiago egin.",
+ "config-optional-skip": "Aspertuta nago, wikia instalatu bakarrik.",
+ "config-profile-wiki": "Wikia ireki",
+ "config-profile-no-anon": "Kontua sortzea beharrezkoa da",
+ "config-profile-fishbowl": "Baimendutako editoreak bakarrik",
+ "config-profile-private": "Wiki pribatua",
+ "config-license": "Copyright eta lizentzia:",
+ "config-license-cc-by": "Creative Commons Aitorpena",
+ "config-license-cc-0": "Creative Commons Zero (Jabari Publikoa)",
+ "config-license-pd": "Domeinu Askea",
+ "config-license-cc-choose": "Aukeratu Creative Commons lizentzia pertsonalizatua",
+ "config-email-settings": "E-posta hobespenak",
+ "config-email-sender": "Itzuli helbide elektronikoa:",
+ "config-upload-settings": "Irudi eta fitxategi igoerak",
+ "config-upload-enable": "Fitxategi igoera gaitu",
+ "config-upload-deleted": "Ezabatutako artxiboentzako direktorioa:",
+ "config-logo": "Logo URL:",
+ "config-instantcommons": "Instant Commons gaitu",
+ "config-cc-again": "Berriz aukeratu...",
+ "config-advanced-settings": "Konfigurazio aurreratua",
+ "config-extensions": "Luzapenak",
+ "config-skins": "Itxurak",
+ "config-install-step-done": "egina",
+ "config-install-step-failed": "Huts egin du",
+ "config-install-extensions": "Luzapenak barne",
+ "config-install-database": "Datu-basea konfiguratu",
+ "config-install-schema": "Eskema sortu",
+ "config-install-user": "Datubase erabiltzailea sortzen",
+ "config-install-user-alreadyexists": "\"$1\" erabiltzailea badago.",
+ "config-install-user-create-failed": "$1 erabiltzailea sortzerakoan huts egin du: $2",
+ "config-install-user-grant-failed": "$1ri baimena emateak huts egin du: $2",
+ "config-install-tables": "Taulak sortzen",
+ "config-install-interwiki-list": "Ezin izan da <code>interwiki.list</code> fitxategia irakurri.",
+ "config-install-stats": "Estatistikak hasten",
+ "config-install-keys": "Gako sekretuak sortzen",
+ "config-install-sysop": "Administratzaile kontua sortzen",
+ "config-download-localsettings": "Jaitsi <code>LocalSettings.php</code>",
+ "config-help": "Laguntza",
+ "config-help-tooltip": "sakatu zabaltzeko",
+ "config-nofile": "Ezin da \"$1\" fitxategia aurkitu. Ezabatua izan da?",
+ "mainpagetext": "<strong>MediaWiki instalatu da.</strong>",
+ "mainpagedocfooter": "Ikus [https://meta.wikimedia.org/wiki/Help:Contents Erabiltzaile Gida] wiki softwarea erabiltzen hasteko informazio gehiagorako.\n\n== Nola hasi ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Konfigurazio balioen zerrenda]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ (Maiz egindako galderak)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWikiren argitalpenen posta zerrenda]"
+}
diff --git a/www/wiki/includes/installer/i18n/ext.json b/www/wiki/includes/installer/i18n/ext.json
new file mode 100644
index 00000000..ef99035a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ext.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MeyaGüiqui s'á istalau satihatoriamenti.'''",
+ "mainpagedocfooter": "Consurta la [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] pa sabel mas al tentu el huncionamientu el software güiqui.\n\n== Esminciandu ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/fa.json b/www/wiki/includes/installer/i18n/fa.json
new file mode 100644
index 00000000..97a09a3e
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/fa.json
@@ -0,0 +1,329 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mjbmr",
+ "Armin1392",
+ "Ebraminio",
+ "Omidh",
+ "Pouyana",
+ "Reza1615",
+ "Alirezaaa",
+ "Danialbehzadi",
+ "Leyth",
+ "Huji",
+ "Macofe",
+ "درفش کاویانی",
+ "Hamisun",
+ "Alifakoor",
+ "Seb35"
+ ]
+ },
+ "config-desc": "نصب کنندهٔ مدیاویکی",
+ "config-title": "نصب مدیاویکی $1",
+ "config-information": "اطلاعات",
+ "config-localsettings-upgrade": "یک پرونده <code>LocalSettings.php</code> شناسایی شده‌است.\nبرای ارتقاء این نصب لطفاً مقدار <code>$wgUpgradeKey</code> در جعبه زیر وارد کنید.\nشما می‌توانید آن را در <code>LocalSettings.php</code> پیدا کنید.",
+ "config-localsettings-cli-upgrade": "یک پرونده <code>LocalSettings.php</code> شناسایی شده است.\nبرای ارتقاء این نصب، لطفاً <code>update.php</code> را اجرا کنید.",
+ "config-localsettings-key": "کلید ارتقا:",
+ "config-localsettings-badkey": "کلید به‌روزرسانی‌ای که شما ارائه کردید نادرست است.",
+ "config-upgrade-key-missing": "نصب موجود مدیاویکی شناسایی شده‌است.\nبرای بروزرسانی این نصب، لطفاً خط زیر را در آخر کد \nقرار دادن به نصب ارتقاء داده شده، به خط زیر لطفاً در پایین خود را <code>LocalSettings.php</code> قرار دهید:\n\n$1",
+ "config-localsettings-incomplete": "وجود <code>LocalSettings.php</code> به نظر ناقص می‌رسد.\nمتغیر $1 تنظیم نشده‌است.\nبرای اینکه این متغیر تنظیم شود لطفاً <code>LocalSettings.php</code> را تغییر دهید، و \"{{int:Config-continue}}\" را کلیک کنید.",
+ "config-localsettings-connection-error": "هنگام اتصال به پایگاه اطلاعاتی که ازتنظیمات مشخص شده در<code>LocalSettings.php</code> استفاده می‌کند، خطایی رخ داد. لطفاً این تنظیمات را نصب کنید و دوباره تلاش کنید.\n$1",
+ "config-session-error": "خطا در شروع جلسه: $1",
+ "config-session-expired": "به نظر می‌رسد اطلاعات جلسهٔ شما منقضی شده‌است.\nجلسات برای مادام العمر $1 پیکربندی شده‌اند.\nشما می‌توانید این پیکربندی را با تنظیم <code>session.gc_maxlifetime</code> در php.ini افزایش دهید.\nروند نصب را دوباره شروع کنید.",
+ "config-no-session": "اطلاعات دورهٔ شما از دست رفته‌ است!\nphp.ini خود را بررسی کنید و مطمئن شوید <code>session.save_path</code> برای یک فهرست مناسب تنظیم شده‌است.",
+ "config-your-language": "زبان شما:",
+ "config-your-language-help": "یک زبان را برای استفاده در طی روند نصب انتخاب کنید.",
+ "config-wiki-language": "زبان ویکی:",
+ "config-wiki-language-help": "زبانی را انتخاب کنید که ویکی بیشتر در آن نوشته خواهد شد.",
+ "config-back": "→ بازگشت",
+ "config-continue": "ادامه ←",
+ "config-page-language": "زبان",
+ "config-page-welcome": "به مدیاویکی خوش آمدید!",
+ "config-page-dbconnect": "اتصال به پایگاه داده",
+ "config-page-upgrade": "ارتقای نصب موجود",
+ "config-page-dbsettings": "تنظیمات پایگاه داده",
+ "config-page-name": "نام",
+ "config-page-options": "گزینه‌ها",
+ "config-page-install": "نصب",
+ "config-page-complete": "کامل!",
+ "config-page-restart": "شروع دوبارهٔ نصب",
+ "config-page-readme": "مرا بخوان",
+ "config-page-releasenotes": "یادداشت‌های انتشار",
+ "config-page-copying": "تکثیر",
+ "config-page-upgradedoc": "ارتقاء",
+ "config-page-existingwiki": "ویکی موجود",
+ "config-help-restart": "آیا می‌خواهید همهٔ اطلاعات ذخیره شده‌ای که وارد کرده‌اید را پاک کنید و دوباره روند نصب را شروع کنید؟",
+ "config-restart": "بله، دوباره شروع کن",
+ "config-welcome": "===بررسی‌های محیطی===\nبرای فهمیدن اینکه این محیط برای نصب مدیاویکی مناسب است، اکنون بررسی‌های اساسی انجام خواهد‌شد.\nاگر به دنبال پشتیبانی در چگونگی تکمیل نصب هستید،به یاد داشته باشید این اطلاعات را بگنجانید.",
+ "config-copyright": "=== حق رونوشت و شرایط ===\n\n$1\n\nاین برنامه، نرم‌افزاری آزاد است. می‌توانید تحت شرایط نگارش ۲ یا (بنا به نظر خود) هر نگارش جدیدتری از پروانهٔ جامع همگانی گنو که توسط بنیاد نرم‌افزار آزاد منتشر شده، بازنشرش کرده و/یا تغییرش دهید.\n\n\nاین برنامه با این امید توزیع شده که مفید باشد، ولی <strong>بدون هیچ ضمانتی</strong>، حتا ضمانت ضمنی <strong>معامله‌پذیری</strong> یا <strong>تناسب برای کاربردی خاص </strong>.\n\nبرای جزئیات بیشتر، پروانهٔ جامع همگانی گنو را ببینید.\n\n\nباید همراه این برنامه، <doclink href=Copying>نگارشی از پروانهٔ جامع همگانی گنو</doclink> را گرفته باشید. اگر چنین نیست، با بنیاد نرم‌افزار آزاد به نشانی 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA مکاتبه کرده یا [http://www.gnu.org/copyleft/gpl.html پروانه را برخط بخوانید].",
+ "config-sidebar": "* [https://www.mediawiki.org صفحهٔ اصلی مدیاویکی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents راهنمای کاربر]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents راهنمای مدیر]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ پرسش‌های رایج]\n----\n* <doclink href=Readme>مرا بخوان</doclink>\n* <doclink href=ReleaseNotes>یادداشت‌های انتشار</doclink>\n* <doclink href=Copying>نسخه برداری</doclink>\n* <doclink href=UpgradeDoc>ارتقا</doclink>",
+ "config-env-good": "محیط بررسی شده‌است.\nشما می‌توانید مدیاویکی را نصب کنید.",
+ "config-env-bad": "محیط بررسی شده‌است.\nشما نمی‌توانید مدیاویکی را نصب کنید.",
+ "config-env-php": "پی‌اچ‌پی $1 نصب شده‌است.",
+ "config-env-hhvm": "اچ‌اچ‌وی‌ام $1 نصب شده‌است.",
+ "config-unicode-using-intl": "برای یونیکد عادی از [http://pecl.php.net/intl افزونهٔ intl برای PECL] استفاده کنید.",
+ "config-unicode-pure-php-warning": "'''هشدار:''' [http://pecl.php.net/intl intl PECL extension] برای کنترل یونیکد عادی در دسترس نیست،اجرای کاملاً آهسته به تعویق می‌افتد.\n\nاگر شما یک سایت پر‌ ترافیک را اجرا می‌کنید، باید کمی [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization] را بخوانید.",
+ "config-unicode-update-warning": "'''هشدار:''' نسخهٔ نصب شدهٔ پوشهٔ یونیکد عادی از ورژن قدیمی‌تر کتابخانه [http://site.icu-project.org/ the ICU project's] استفاده می‌کند.\n\nاگر کلاً علاقه‌مند به استفاده از یونیکد هستید باید [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations upgrade].",
+ "config-no-db": "درایور پایگاه اطلاعاتی مناسب پیدا نشد! شما لازم دارید یک درایور پایگاه اطلاعاتی برای پی‌اچ‌پی نصب کنید.انواع پایگاه اطلاعاتی زیر پشتیبانی شده‌اند:$1.\nاگر شما در گروه اشتراک‌گذاری هستید، از تهیه کنندهٔ گروه خود برای نصب یک درایور پایگاه اطلاعاتی مناسب {{PLURAL:$2|سوأل کنید.|سوأل کنید.}}\nاگر خود، پی‌اچ‌پی را تهیه کرده‌اید، با یک پردازشگر فعال دوباره پیکربندی کنید، برای مثال از <code>./configure --with-mysqli</code> استفاده کنید.\nاگر پی‌اچ‌پی را از یک بستهٔ دبیان یا آبونتو نصب کرده‌اید، بنابراین لازم دارید بخش php5-mysql را نصب کنید.",
+ "config-outdated-sqlite": "''' هشدار:''' شما اس‌کیولایت $1 دارید، که پایین‌تر از حداقل نسخهٔ $2 مورد نیاز است.اس‌کیولایت در دسترس نخواهد بود.",
+ "config-no-fts3": "'''هشدار:''' اس‌کیولایت بدون [//sqlite.org/fts3.html FTS3 module] تهیه شده‌است ، جستجوی ویژگی‌ها در این بخش پیشین در دسترس نخواهد‌بود.",
+ "config-pcre-old": "''' خطای اساسی:'' ' PCRE $1 یا بعدا مورد نیاز است.\nکد باینری پی‌اچ‌پی‌تان با PCRE $2 پیوند دارد.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE اطلاعات بیشتر].",
+ "config-pcre-no-utf8": "<strong>مخرب:</strong> به‌ نظر می‌رسد پودمان پی‌سی‌آراییِ پی‌اچ‌پی بدون پشتیبانی پی‌سی‌آرایی_یو‌تی‌اف۸ تهیه شده‌است.\nمدیاویکی برای درست عمل کردن نیازمند پشتیبانی یوتی‌اف-۸ است.",
+ "config-memory-raised": "PHP's <code>memory_limit</code>, نسخهٔ $1 است، به نسخهٔ $2 ارتقاء داده شده‌است.",
+ "config-memory-bad": "'''هشدار:''' PHP's <code>memory_limit</code> نسخهٔ $1 است.\nاین ممکن است خیلی پایین باشد.\nممکن است نصب با مشکل رو‌به‌رو شود.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] نصب شده‌است.",
+ "config-apc": "[http://www.php.net/apc APC] نصب شده‌است.",
+ "config-apcu": "[http://www.php.net/apcu APCu] نصب شده‌است",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] نصب شده‌است.",
+ "config-no-cache-apcu": "<strong>هشدار:</strong> پیوند [http://www.php.net/apcu APCu]، [http://xcache.lighttpd.net/ XCache] یا [http://www.iis.net/download/WinCacheForPhp WinCache] یافت نشد. ذخیره شی فعال نیست.",
+ "config-mod-security": "'''هشدار:''' وب سرور شما [http://modsecurity.org/ mod_security] فعال است.اگر اشتباه پیکربندی شده‌‌ باشد،می تواند باعث ایجاد مشکلاتی برای مدیاویکی یا دیگر نرم‌افزاری شود که به کاربران اجازه می‌دهد پیام دلخواه ارسال کنند.\nبه [http://modsecurity.org/documentation/ mod_security documentation] مراجعه کنید یا اگر با خطاهای اتفاقی مواجه شدید با پشتیبانی میزبان خود در تماس باشید.",
+ "config-diff3-bad": "جی‌ان‌یو دیف۳ پیدا نشد.",
+ "config-git": "کنترل نسخهٔ نرم‌افزار گیت پیدا شد: <code>$1</code>.",
+ "config-git-bad": "کنترل نسخهٔ نرم‌افزار گیت پیدا نشد.",
+ "config-imagemagick": "ایمیج‌مجیک پیدا شد: <code>$1</code>.\nاگر ارسال‌ها را فعال کنید،تصویر کوچک فعال خواهد‌شد.",
+ "config-gd": "گرافیک‌های جی‌دی ساخته‌‌ شده در کتابخانه پیدا شد.\nاگر ارسال‌ها را فعال کنید تصویر کوچک فعال خواهد‌شد.",
+ "config-no-scaling": "کتابخانهٔ جی‌دی یا ایمیج‌مجیک نتوانست پیدا شود.\nتصویر کوچک غیر‌فعال خواهد‌شد.",
+ "config-no-uri": "'''خطا:''' یوآرآی فعلی را نتوانست مشخص کند.\nنصب شکست خورد.",
+ "config-no-cli-uri": "<strong>هشدار:</strong> هیچ اسکریپت‌پتی تعیین نشده، از پیش‌فرض استفاده کنید:\n<code>$1</code>.",
+ "config-using-server": "از اسم سرور \"<nowiki>$1</nowiki>\" استفاده کنید.",
+ "config-using-uri": "از اسم سرور \"<nowiki>$1$2</nowiki>\" استفاده کنید.",
+ "config-uploads-not-safe": "'''هشدار:''' فهرست پیش‌فرض ارسال‌های <code>$1</code> شما برای اجرای متون دلخواه آسیب‌پذیر است.\nاگرچه مدیاویکی همهٔ پوشه‌های ارسال‌ شده را برای خطرات امنیتی بررسی می‌کند، پیش از فعال‌سازی ارسال‌ها، بسیار به [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security close this security vulnerability] توصیه شده‌است.",
+ "config-no-cli-uploads-check": "'''هشدار:''' فهرست پیش‌فرض ارسال‌های شما (<code>$1</code>) برای آسیب‌پذیری اجرای متن دلخواه در طول نصب سی‌ال آی بررسی نشده‌است.",
+ "config-brokenlibxml": "دستگاه شما دارای تلفیقی از نسخه‌های پی‌اچ‌پی و لیبکسمل۲ است که ناقص است و می‌تواند دلیل از بین رفتن اطلاعات مخفی در مدیاویکی و دیگر برنامه‌های کاربردی شبکه باشد.\nارتقاء به لیبکسمل۲ ۲.۷.۳ یا بالاتر ([https://bugs.php.net/bug.php?id=45996 bug filed with PHP]) ارتفاء دهید.\nنصب شکست خورد ماند.",
+ "config-suhosin-max-value-length": "سوهُسین نصب شده‌است و پارامتر جت <code>length</code> را به $1 بایت محدود می‌کند.\n قسمت بارکنندهٔ منبع مدیاویکی پیرامون این محدوده کار خواهد‌کرد، اما عملکرد آن را پایین می‌آورد. اگر به هیچ وجه ممکن نیست، باید <code>suhosin.get.max_value_length</code> را به ۱۰۲۴ یا بالاتر در <code>php.ini</code> تنظیم کنید، و \n<code>$wgResourceLoaderMaxQueryLength</code> را به مقدار مشابه در <code>LocalSettings.php</code> تنظیم کنید.",
+ "config-using-32bit": "<strong>هشدار:</strong> سیستم شما به‌نظر می‌آید با اعداد صحیح ۳۲ بیت اجرا شده باشد. [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit توصیه نمی‌شود].",
+ "config-db-type": "نوع پایگاه اطلاعات:",
+ "config-db-host": "میزبان پایگاه اطلاعات:",
+ "config-db-host-help": "اگر سرور پایگاه اطلاعاتی شما در سرور دیگری است، نام گروه و آدرس آی‌پی را اینجا وارد کنید.\nاگر از گروه شبکهٔ اشتراک گذاری استفاده می‌کنید، تهیه‌کنندهٔ گروه‌تان باید نام گروه صحیح در اسنادومدارک را به شما بدهد.\nاگر در حال نصب یک سرور ویندوز هستید و از مای‌اس‌کیو‌ال استفاده می‌کنید، ممکن است استفاده از \"گروه داخلی\" برای نام سرور کار نکند.اگر کار نکرد، \"۱۲۷.۰.۰.۱\" را برای آدرس آی‌پی داخلی امتحان کنید.\nاگر از پستگِرِاس‌کیوال استفاده می‌کنید،برای اتصال از طریق یک سوکت یونیکس این زمینه را خالی رها کنید.",
+ "config-db-host-oracle": "پایگاه اطلاعاتی تی‌ان‌اس:",
+ "config-db-host-oracle-help": "یک [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name] معتبر وارد کنید؛ پوشهٔ tnsnames.ora باید برای این نصب نمایان باشد.<br /> اگر از کتابخانه‌های پردازشگر ۱۰جی یا جدیدتر استفاده می‌کنید،همچنین می‌توانید از روش نامبردهٔ [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect] استفاده کنید.",
+ "config-db-wiki-settings": "این ویکی را شناسایی کنید.",
+ "config-db-name": "نام پایگاه اطلاعاتی:",
+ "config-db-name-help": "نامی را انتخاب کنید که ویکی شما را شناسایی کند.\nنباید شامل فاصله باشد.\nاگر از گروه شبکهٔ اشتراک‌گذاری استفاده می‌کنید، تهیه‌کنندهٔ گروهتان یا باید به شما نام یک پایگاه اطلاعاتی مشخص برای استفاده بدهد یا برای ایجاد پایگاه‌های اطلاعاتی از طریق یک کنترل پنل به شما اجازه بدهد.",
+ "config-db-name-oracle": "طرح کلی پایگاه اطلاعاتی:",
+ "config-db-account-oracle-warn": "برای نصب برنامهٔ اوراکل به عنوان پایگاه اطلاعاتی در بخش گذشته،سه سناریو پشتیبانی شده است:\nاگر مایل به ایجاد حساب پایگاه اطلاعاتی به عنوان بخشی از روند نصب هستید، لطفاً یک حساب با نقش اس‌وای‌اس‌دی‌بی‌ای به عنوان حساب پایگاه اطلاعاتی برای نصب تهیه کنید و اعتبارنامه‌های مطلوبی را برای حساب دردسترس شبکه تعیین کنید، به عبارتی دیگر یا می‌توانید حساب دردسترس شبکه را به طور دستی ایجاد کنید و تنها آن حساب را تهیه کنید (اگر مستلزم مجوزهایی برای ایجاد موضوعات طرح کلی باشد) یا دو حساب دیگر تهیه کنید،یکی با ایجاد مزایا و یک حساب محدود برای دسترسی شبکه.\nمتنی برای ایجاد یک حساب با مزایای لازم بنویسید که می‌تواند در فهرست\"نگهداری/برنامهٔ اوراکل\" این نصب یافت شود. به یاد داشته باشید که استفاده از یک حساب محدود،همهٔ قابلیت‌های نگهداری با حساب پیش‌فرض را غیرفعال خواهد کرد.",
+ "config-db-install-account": "حساب کاربری برای نصب",
+ "config-db-username": "نام کاربری پایگاه اطلاعات:",
+ "config-db-password": "گذرواژه پایگاه‌های داده:",
+ "config-db-install-username": "نام کاربری را وارد کنید که برای اتصال به پایگاه اطلاعاتی در طول روند نصب استفاده خواهد‌شد.\nاین نام کاربری حساب مدیاویکی نیست; نام کاربری برای پایگاه اطلاعاتی شما است.",
+ "config-db-install-password": "رمز عبوری را وارد کنید که برای اتصال به پایگاه اطلاعاتی در طول روند نصب استفاده خواهد‌شد.\nاین رمز عبور برای حساب مدیاویکی نیست;رمز عبور برای پایگاه اطلاعاتی شما است.",
+ "config-db-install-help": "نام کاربری و رمز عبوری را وارد کنید که برای اتصال به پایگاه اطلاعاتی در طول روند نصب استفاده خواهد‌ٰشد.",
+ "config-db-account-lock": "در طی عملیات عادی از نام کاربری و رمز عبور یکسان استفاده کنید",
+ "config-db-wiki-account": "حساب کاربری برای عملیات عادی",
+ "config-db-wiki-help": "نام کاربری و رمز عبوری را وارد کنید که برای اتصال به پایگاه اطلاعاتی در طی عملیات عادی ویکی استفاده خواهید‌کرد.\nاگر حساب وجود ندارد، و نصب حساب مزایای کافی را داراست، این حساب کاربر با حداقل مزایای مورد نیاز برای عمل کردن ویکی به‌وجود خواهد‌آمد.",
+ "config-db-prefix": "جدول پیشوند پایگاه اطلاعاتی",
+ "config-db-prefix-help": "اگر نیاز دارید یک اطلاعات پایگاهی را بین چندین ویکی یا بین مدیاویکی و برنامهٔ کاربردی وب دیگری به اشتراک بگذارید،مجاز به انتخاب برای اضافه کردن یک پیشوند به همهٔ نام‌های جدول برای جلوگیری از اختلاف‌ها هستید.\nاز فاصله‌ها استفاده نکنید.\nاغلب این زمینه خالی مانده‌است.",
+ "config-mysql-old": "مای‌اس‌کیو‌ال نسخهٔ $1 و یا بالاتر نیاز است، شما نسخهٔ $2 را دارید.",
+ "config-db-port": "درگاه پایگاه‌داده:",
+ "config-db-schema": "طرح کلی برای مدیاویکی:",
+ "config-db-schema-help": "طرح کلی اغلب بی‌نقص خواهد بود.\nاگر می‌دانید نیاز دارید که تغییرش دهید،آن را تغییر دهید.",
+ "config-pg-test-error": "نمی‌توان به پایگاه اطلاعاتی '''$1''' وصل شد: $2",
+ "config-sqlite-dir": "فهرست اطلاعات اس‌کیو‌لایت:",
+ "config-sqlite-dir-help": "اس‌کیولایت همهٔ اطلاعات را در یک پوشهٔ جداگانه ذخیره می‌کند.\nفهرستی را که به وجود‌ آوردید باید در طی نصب به‌ وسیلهٔ وب‌سرور قابل نوشتن باشد.\n<strong>نباید</strong> از طریق وب در دسترس باشد، به همین دلیل ما آن را در جایی که پوشه‌های پی‌اچ‌پی شما هست، قرار نمی‌دهیم.\nنصب کننده یک پوشهٔ <code>.htaccess</code> همراه آن خواهدآورد،اما اگر این کار را انجام ندهد،کسی می‌تواند به پایگاه اطلاعاتی شما دسترسی پیدا کند.\nاطلاعات خام کاربر شامل (آدرس‌های ایمیل، علامت‌‌ها با شماره‌های رمز عبور) به خوبی پاک کردن تغییرات و دیگر اطلاعات محرمانه در ویکی.\nقرار دادن پایگاه اطلاعاتی باهم را در جایی دیگر در نظر بگیرید، برای مثال در <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "جدول پیش فرض:",
+ "config-oracle-temp-ts": "جدول موقت:",
+ "config-type-mysql": "مای‌اس‌کیو‌ال (یا سازگار)",
+ "config-type-mssql": "سرور مایکروسافت اس‌کیو‌ال",
+ "config-support-info": "مدیاویکی سامانه‌های پایگاه اطلاعاتی زیر را حمایت می‌کند:\n$1\nاگر متوجه سامانه پایگاه اطلاعاتی که سعی دارید از فهرست زیر استفاده کنید، نمی‌شوید، بنابراین دستورالعمل‌های مرتبط در بالا را برای فعال کردن پشتیبانی دنبال کنید.",
+ "config-dbsupport-mysql": "*[{{int:version-db-mysql-url}} MySQL] مهم‌ترین هدف برای مدیاویکی است و بهترین پشتیبانی. مدیاویکی همچنین کار می‌کند با [{{int:version-db-mariadb-url}} MariaDB] و [{{int:version-db-percona-url}} Percona Server] که با MySQL سازگار هستند.([http://www.php.net/manual/en/mysqli.installation.php چگونه php را با MySQL کامپایل کنیم])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} پستگرس‌کیوال] یک سامانه پایگاه اطلاعات متن‌باز پر‌طرفدار است که جایگزینی برای مای‌اس‌کیوال است. ([http://www.php.net/manual/en/pgsql.installation.php راهنمای تنظیم کردن پی‌اچ‌پی به همراه پستگرس‌کیوال])",
+ "config-dbsupport-sqlite": "*[{{int:version-db-sqlite-url}} اس‌کیولایت] یک سامانه پایگاه اطلاعاتی کم حجمی است که بسیار خوب پشتیبانی شده‌است.\n([http://www.php.net/manual/en/pdo.installation.php چگونگی کامپایل پی‌اچ‌پی با اس‌کیولایت]، از PDO استفاده می‌کند)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] یک پایگاه اطلاعاتی کار تبلیغاتی است.\n([http://www.php.net/manual/en/oci8.installation.php How to compile PHP with OCI8 support])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] یک پایگاه اطلاعاتی موسسهٔ تبلیغاتی برای وینذوز است. ([http://www.php.net/manual/en/sqlsrv.installation.php How to compile PHP with SQLSRV support])",
+ "config-header-mysql": "تنظیمات مای‌اس‌کیو‌ال",
+ "config-header-postgres": "تنظیمات پست‌گر‌اس‌کیو‌ال",
+ "config-header-sqlite": "تنظیمات اس‌کیو‌لایت",
+ "config-header-oracle": "تنظیمات اوراکل",
+ "config-header-mssql": "تنظیمات سرور مایکرپسافت اس‌کیو‌ال",
+ "config-invalid-db-type": "نوع پایگاه اطلاعاتی نامعتبر",
+ "config-missing-db-name": "شما باید یک مقدار برای \"نام {{int:config-db-name}}\" وارد کنید",
+ "config-missing-db-host": "شما باید یک مقدار برای \"گروه {{int:config-db-host}}\" وارد کنید",
+ "config-missing-db-server-oracle": "شما باید یک مقدار برای \"تی‌ان‌اس {{int:config-db-host-oracle}}\" وارد کنید",
+ "config-invalid-db-server-oracle": "تی‌ان‌اس پایگاه اطلاعاتی $1 نامعتبر.\nیا از \"نام تی‌ان‌اس\" یا یک سلسله \"ارتباط آسان\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]) استفاده کنید.",
+ "config-invalid-db-name": "نام پایگاه اطلاعاتی نامعتبر \"$1\".\nفقط حروف اِی‌اس‌سی‌آی‌آی بزرگ (اِی-زدکوچک،اِی-زد بزرگ)،اعداد (۰-۹)،آندرلاین (_) و خط تیره کوتاه (-) استفاده کنید.",
+ "config-invalid-db-prefix": "پیشوند پایگاه اطلاعاتی نامعتبر \"$1\".\nفقط حروف اِی‌اس‌سی‌آی‌آی بزرگ (اِی-زدکوچک،اِی-زد بزرگ)،اعداد (۰-۹)،آندرلاین (_) و خط تیره کوتاه (-) استفاده کنید.",
+ "config-connection-error": "$1.\n\nمیزبان، نام کاربری و گذرواژه را بررسی کنید و دوباره امتحان کنید.",
+ "config-invalid-schema": "طرح‌کلی برای مدیاویکی نامعتبر \"$1\".\nفقط حروف اِی‌اس‌سی‌آی‌آی بزرگ (اِی-زدکوچک،اِی-زد بزرگ)،اعداد (۰-۹)،آندرلاین (_) و خط تیره کوتاه (-) استفاده کنید.",
+ "config-db-sys-create-oracle": "نصب‌کننده تنها از استفادهٔ یک حساب اس‌وای‌اس‌دی‌بی‌اِی برای ایجاد یک حساب جدید حمایت می‌کند.",
+ "config-db-sys-user-exists-oracle": "حساب کاربری \"$1\" در‌حال‌حاضر وجود دارد.تنها اس‌وای‌اس‌دی‌بی‌اِی می‌تواند برای ایجاد یک حساب جدید استفاده شود!",
+ "config-postgres-old": "پستگِرِاس‌کیو‌ال نسخهٔ $1 یا بالاتر لازم است. شما نسخهٔ $2 را دارید.",
+ "config-mssql-old": "سرور مایکروسافت اس‌کیو‌ال $1 یا اخیر آن لازم است. شما $2 را دارید.",
+ "config-sqlite-name-help": "نامی را انتخاب کنید که ویکی شما را شناسایی می‌کند.\nاز فاصله‌ها یا خط‌های تیره کوتاه استفاده نکنید.\nاین برای نام پوشهٔ اطلاعات اس‌کیولایت استفاده خواهد‌شد.",
+ "config-sqlite-parent-unwritable-group": "فهرست اطلاعات <code><nowiki>$1</nowiki></code> نمی‌تواند ایجاد شود، چون فهرست منشأ <code><nowiki>$2</nowiki></code> توسط سرور شبکه قابل نوشتن نیست.\nنصب کننده، کاربری را که سرور شبکه شما را اجرا می‌کند، مشخص کرده‌است.\nبرای ادامه دادن،فهرستی قابل نوشتن <code><nowiki>$3</nowiki></code> توسط آن ایجاد کنید.\nدر یک سامانه یونیکس/لینوکس انجام می‌دهد:\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "فهرست اطلاعات <code><nowiki>$1</nowiki></code> نمی‌تواند ایجاد شود، چون فهرست منشأ <code><nowiki>$2</nowiki></code> توسط کارساز شبکه قابل نوشتن نیست.\nنصب کننده، کاربری را که سرور شبکه شما را اجرا می‌کند، نتوانست مشخص کند.\nفهرست کلی قابل نوشتن <code><nowiki>$3</nowiki></code> توسط آن (و دیگران!) برای ادامه دادن،ایجاد کنید.\nدر یک سامانه یونیکس/لینوکس انجام می‌دهد:\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "خطا در ایجاد فهرست اطلاعات \"$1\".\nمکان حافظهٔ رایانه را بررسی کنید و دوباره سعی کنید.",
+ "config-sqlite-dir-unwritable": "قادر نبودن در نوشتن فهرست \"$1\".\nمجوز‌هایی را که وب‌سرور می‌تواند برای آن مطرح کند را تغییر دهید، و دوباره سعی کنید.",
+ "config-sqlite-connection-error": "$1\nفهرست اطلا‌عات‌ و نام پایگاه اطلا‌عاتی زیر را بررسی کنید و دوباره سعی کنید.",
+ "config-sqlite-readonly": "پوشهٔ <code>$1</code> قابل نوشتن نیست.",
+ "config-sqlite-cant-create-db": "پوشه‌ٔ پایگاه اطلا‌عاتی <code>$1</code>نتوانست ایجاد شود.",
+ "config-sqlite-fts3-downgrade": "پی‌اچ‌پی، پشتیبانی اف‌تی‌اس۳ کار نمی‌کند، کاهش جدول‌ها",
+ "config-can-upgrade": "جدول‌های مدیاویکی در این پایگاه اطلاعاتی وجود دارند.\nبرای ارتقاء دادن آنها به مدیاویکی $1، '''ادامه''' را کلیک کنید.",
+ "config-upgrade-done": "تکمیل ارتقاء.\nاکنون شما می‌توانید [$1 start using your wiki].\nاگر می‌خواهید پوشهٔ <code>LocalSettings.php</code> را احیا کنید،دکمهٔ زیر را کلیک کنید.\nاین ''' توصیه نمی‌شود ''' مگر اینکه با ویکی خود مشکل داشته باشید.",
+ "config-upgrade-done-no-regenerate": "ارتقاء کامل شد.\nاکنون شما می‌توانید [$1 start using your wiki].",
+ "config-regenerate": "بازسازی LocalSettings.php ←",
+ "config-show-table-status": "سوأل <code>SHOW TABLE STATUS</code> مردود شد!",
+ "config-unknown-collation": "'''هشدار:''' پایگاه اطلاعاتی درحال استفاده از صفحه‌بندی ناشناخته.",
+ "config-db-web-account": "حساب پایگاه اطلاعاتی برای دسترسی وب",
+ "config-db-web-help": "در طی عملیات عادی ویکی،نام کاربری و رمز عبوری را انتخاب کنیدکه وب سرور برای اتصال به سرور پایگاه اطلاعاتی استفاده کنید.",
+ "config-db-web-account-same": "هنگام نصب از حساب یکسان استفاده کنید",
+ "config-db-web-create": "اگر در حال‌حاضر وجود ندارد،حساب ایجاد کنید",
+ "config-db-web-no-create-privs": "حسابی که شما برای نصب تعیین کردید،مزایای کافی برای ایجاد یک حساب را ندارد.\nحسابی که شما اینجا تعیین کرده‌اید باید در حال حاضر وجود داشته باشد.",
+ "config-mysql-engine": "موتور ذخیره سازی:",
+ "config-mysql-innodb": "اینودی‌بی",
+ "config-mysql-myisam": "می‌ای‌سم",
+ "config-mysql-myisam-dep": "'''هشدار:''' شما مای‌آی‌اس‌ای‌ام را به عنوان موتور ذخیره برای مای‌آی‌اس‌ای‌ام انتخاب کرده‌اید، که برای استفاده با مدیاویکی توصیه نمی‌شود زیرا:\n* به‌علت قفل شدن جدول اجمالاً به طور همزمان پشتیبانی می کند\n* بیشتر از دیگر موتورها برای از بین‌ رفتن مستعد است.\n* مبنای رمز مدیاویکی همیشه مای‌آی‌اس‌ای‌ام را همان طور که باید باشد،کنترل نمی‌کند\nاگر نصب مای‌اس‌کیو‌ال شما اینودی‌بی را پشتیبانی می‌کند،بسیار توصیه می‌شود که در عوض ،آن را انتخاب کنید.\nاگر نصب مای‌اس‌کیو‌ال شما، اینودی‌بی را پشتیبانی نمی‌کند، ممکن است زمان ارتقاء رسیده باشد.",
+ "config-mysql-only-myisam-dep": "'''هشدار:''' مای‌آی‌اس‌ای‌ام تنها موتور ذخیره‌سازی اطلاعات برای مای‌اس‌کیو‌ال در این دستگاه است، و برای استفاده با مدیاویکی توصیه نمی‌شود، زیرا:\n* به‌علت قفل شدن جدول اجمالاً به طور همزمان پشتیبانی می کند\n* بیشتر از دیگر موتورها برای از بین‌ رفتن مستعد است.\n* مبنای رمز مدیاویکی همیشه مای‌آی‌اس‌ای‌ام را همان طور که باید باشد،کنترل نمی‌کند\nنصب مای‌اس‌کیو‌ال شما اینودی‌بی را پشتیبانی نمی‌کند،ممکن است زمان یک ارتقاء رسیده باشد.",
+ "config-mysql-engine-help": "'''اینودی‌بی''' تقریباً همیشه بهترین گزینه است،زیرا پشتیبانی همزمان خوبی دارد.\n'''مای‌آی‌اس‌ای‌ام''' ممکن است در نصب‌های کاربر جداگانه یا فقط خواندنی سریع‌تر باشد.\nپایگاه‌های اطلاعاتی مای‌آی‌اس‌ای‌ام اغلب بیشتر از پایگاه‌های اطلاعاتی اینودی‌بی مستعد ازبین رفتن هستند.",
+ "config-mysql-charset": "مجموعه‌ٔ خصوصیات پایگاه اطلاعاتی:",
+ "config-mysql-binary": "دودویی",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "در حالت باینری مدیاویکی متن را به صورت UTF-8 در پایگاه داده باینری ذخیره می‌کند.\nاین روش بسیار به صرفه‌تر از حالت UTF-8 برای MySQL هست و به شما اجازهٔ استفاده از همه بازهٔ نویسه‌های یونیکد را می دهد.\n\nدر حالت UTF-8 برنامه MySQL به شما اجازه می‌دهد که کدام نویسه‌ها در داده‌های شما باشند و اجازهٔ تبدیل و حضور آنها را به صورت مطلوبی می‌دهد ولی به شما اجازهٔ ذخیرهٔ نویسه‌های بالای [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes نقشه ابتدائی چندزبانی] را می‌دهد.",
+ "config-mssql-auth": "نوع تأیید:",
+ "config-mssql-install-auth": "نوع تأییدی را که برای اتصال به پایگاه اطلاعاتی حین فرآیند نصب مورد استفاده قرار گیرد را انتخاب کنید.\nاگر \"{{int:config-mssql-windowsauth}}\" را انتخاب می‌کنید، اعتبارات هرآنچه کاربر وب سرور به عنوان آن مورد استفاده قرار می‌دهد مورد استفاده قرار خواهد گرفت.",
+ "config-mssql-web-auth": "نوع تأییدی را که کارساز وب به‌وسیلهٔ آن برای کارهای معمولی به پایگاه اطلاعاتی متصل خواهد شد را انتخاب کنید.\nاگر «{{int:config-mssql-windowsauth}}» را انتخاب می‌کنید، اعتبارات هرآنچه کاربر وب سرور به عنوان آن مورد استفاده قرار می‌دهد مورد استفاده قرار خواهد گرفت.",
+ "config-mssql-sqlauth": "تأیید سرور اس‌کیوال",
+ "config-mssql-windowsauth": "تأیید ویندوز",
+ "config-site-name": "نام ویکی:",
+ "config-site-name-help": "این در نوار عنوان مرورگر و در دیگر جاهای مختلف ظاهر خواهد‌شد.",
+ "config-site-name-blank": "نام تارنما را وارد کنید.",
+ "config-project-namespace": "فضای نام پروژه:",
+ "config-ns-generic": "پروژه",
+ "config-ns-site-name": "مشابه نام ویکی: $1",
+ "config-ns-other": "دیگر ( تعیین کنید)",
+ "config-ns-other-default": "ویکی‌من",
+ "config-project-namespace-help": "مثال‌های ویکی‌پدیا. بسیاری از ویکی‌ها سیاست‌هایشان را در فضای نام غیر پروژه ذخیره می‌کنند.\n\nهمه عنوان‌های صفحات در این فضای نام توسط پیشوند متفاوت جدا می‌شوند که شما می‌توانید اینجا مشخص کنید.\nمعمولاً این پیشوند برگرفته از نام ویکی هستند ولی نمی تواند حروف سجاوندی در نام آن باشد مانند \"#\" یا \":\".",
+ "config-ns-invalid": "فضای نامی تعیین شدهٔ \"<nowiki>$1</nowiki>\" نامعتبر است.\nیک برنامهٔ فضای نامی دیگری را تعیین کنید.",
+ "config-ns-conflict": "فضای نامی تعیین شدهٔ \"<nowiki>$1</nowiki>\" با یک فضای نامی پیش‌فرض مدیاویکی مغایرت دارد.\nیک برنامهٔ فضای نامی دیگری را تعیین کنید.",
+ "config-admin-box": "حساب مدیر سامانه",
+ "config-admin-name": "نام کاربری شما:",
+ "config-admin-password": "گذرواژه:",
+ "config-admin-password-confirm": "دوباره گذرواژه:",
+ "config-admin-help": "نام کاربری مورد علاقهٔ خود را اینجا وارد کنید، برای مثال \" جو بلاگز \".\nاین نامی است که شما برای ورود به ویکی استفاده خواهید‌کرد.",
+ "config-admin-name-blank": "نام کاربری سرپرست را وارد کنید.",
+ "config-admin-name-invalid": "نام کاربری تعیین شدهٔ \"<nowiki>$1</nowiki>\" نامعتبر است.\nیک نام کاربری دیگر تعیین کنید.",
+ "config-admin-password-blank": "برای حساب سرپرست یک رمز عبور وارد کنید.",
+ "config-admin-password-mismatch": "دو رمز عبوری که وارد کرده‌اید با هم مطابقت ندارند.",
+ "config-admin-email": "نشانی ایمیل:",
+ "config-admin-email-help": "یک آدرس ایمیل برای اجازهٔ دریافت ایمیل از دیگر کاربران ویکی، اینجا وارد کنید، رمز عبور خود را دوباره تنظیم کنید، و از تغییرات صفحه در فهرست پیگیری‌ها مطلع باشید. می‌توانید این بخش را خالی بگذارید.",
+ "config-admin-error-user": "خطای داخلی هنگام ایجاد یک مدیر با نام \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "خطای داخلی هنگام تنظیم یک رمز عبور برای مدیر \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "شما آدرس ایمیل نامعتبر وارد کرده‌اید.",
+ "config-subscribe": "عضویت در [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce release announcements mailing list].",
+ "config-subscribe-help": "این یک میلینگ لیست کم حجم است که برای اطلاع‌رسانی کاربرد دارد و شامل اطلاعیه‌های امنیتی می‌شود.\nشما باید در آن ثبت نام کنید و زمانی که نسخهٔ جدید مدیاویکی ارائه شد آن را به‌روز نمائید.",
+ "config-subscribe-noemail": "شما بدون ثبت نشانی پست الکترونیکی قصد داشتید در فهرست اطلاع‌رسانی پخش نرم‌افزار ثبت‌نام کنید.\nاگر قصد ثبت‌نام دارید لطفاً یک نشانی پست الکترونیکی مشخص کنید.",
+ "config-pingback": "اشتراک گذاری داده‌های نصب با توسعه‌دهندگان مدیاویکی",
+ "config-pingback-help": "اگر این گزینه را انتخاب کنید، ویکی‌مدیا به صورت مداوم به وب‌گاه https://www.mediawiki.org برای ارسال اطلاعات ابتدایی نصب این مدیاویکی ارتباط برقرار می‌کند. اطلاعات شامل نوع سامانه، نسخهٔ پی‌اچ‌پی، دیتابیس انتخاب شده می‌باشد. بنیاد مدیاویکی برای توسعه‌های آینده نرم‌افزار اطلاعات را با توسعه دهندگان مدیاویکی به اشتراک می‌گذارد. اطلاعاتی که از سامانه شما ارسال خواهد شد موارد زیر هستند:\n<pre>$1</pre>",
+ "config-almost-done": "شما تقریباً عملیات را کامل کرده‌اید.\nاکنون می‌توانید پیکربندی باقیمانده را نخوانده رها کنید و درحال‌حاضر ویکی را نصب کنید.",
+ "config-optional-continue": "سوال‌های بیشتری از من بپرسید.",
+ "config-optional-skip": "درحال‌حاضر خسته‌ام،سریعاً ویکی را نصب کنید.",
+ "config-profile": "شرح‌حال حقوق کاربر:",
+ "config-profile-wiki": "بازکردن ویکی",
+ "config-profile-no-anon": "ساخت کاربری مورد نیاز است",
+ "config-profile-fishbowl": "فقط کاربر مجاز",
+ "config-profile-private": "ویکی خصوصی",
+ "config-profile-help": "زمانی ویکی درست کار می کند که شما اجازه دهید تعداد زیادی از مردم آن را ویرایش کنند.\nدر مدیاویکی امکان مشاهدهٔ تغییرات اخیر و واگردانی ویرایش‌های خرابکاری به آسانی وجود دارد.\n\nبا وجودی که مدیا ویکی منافع بسیاری برای مردم دارد ولی متقاعد کردن خیلی از مردم درباره روش کار ویکی‌ها کار آسانی نیست.\n\nدر نتیجه شما دو انتخاب دارید.\n\n'''{{int:config-profile-wiki}}''' به همه کاربرها اجازهٔ ویرایش می دهد حتی بدون ثبت‌نام.\n\nیک ویکی که دارای '''{{int:config-profile-no-anon}}''' باشد امکانات کاربری بیشتری ارائه می‌دهد ولی امکان دارد ویرایشگران عادی را نگران کند.\n\nسناریوی '''{{int:config-profile-fishbowl}}''' به کاربرها اجازهٔ ویرایش می دهد ولی همه می توانند متن و تاریخچه را ببیند.\n\n'''{{int:config-profile-private}}''' فقط به کاربران اجازهٔ مشاهدهٔ مطالب را می‌دهد و فقط آنها می توانند ویرایش کنند.\n\nدسترسی‌های بیشتر کاربری بعد از نصب در [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights راهنماهای مرتبط] موجود است.",
+ "config-license": "حق تکثیر و مجوز:",
+ "config-license-none": "بدون پاورقی مجوز",
+ "config-license-cc-by-sa": "اشتراک گذاری یکجور استناد رایج سازنده",
+ "config-license-cc-by": "استناد رایج سازنده",
+ "config-license-cc-by-nc-sa": "اشتراک گذاری یکجور استناد رایج سازندهٔ غیر تجاری",
+ "config-license-cc-0": "مبداء عوام سازنده (حیطهٔ عمومی)",
+ "config-license-gfdl": "مجوز اسنادومدارک آزاد جی‌ان‌یو ۱.۳ یا بالاتر",
+ "config-license-pd": "مالکیت عمومی",
+ "config-license-cc-choose": "انتخاب یک مجوز سفارشی عوام خلاق",
+ "config-license-help": "بسیاری از وبگاه‌ها ویرایش‌های ها را با [http://freedomdefined.org/Definition اجازه‌نامهٔ آزاد] منتشر می‌کنند.\nاین کار به داشتن حس مالکیت جمعی کمک می‌کند و ویرایش‌های طولانی مدت را اشاعه می‌دهد.\nاین برای ویکی‌های خصوصی یا سازمانی الزامی نیست.\n\nاگر شما می‌خواهید از متون ویکی‌پدیا استفاده کنید، یا اینکه به ویکی‌پدیا اجازه دهید از متون شما استفاده کند باید متون خود را با <strong>{{int:config-license-cc-by-sa}}</strong> منتشر کنید.\n\nویکی‌پدیا در گذشته از اجازه‌نامهٔ داده‌های آزاد گنو استفاده می‌کرد.\nاین اجازه‌نامه مورد قبول است، ولی فهم آن آسان نیست.\nهمچنین استفادهٔ دوباره از متون تحت اجازه‌نامهٔ داده‌های آزاد گنو به سختی انجام می‌گیرد.",
+ "config-email-settings": "تنظیمات ایمیل",
+ "config-enable-email": "فعال‌سازی ایمیل خروجی",
+ "config-enable-email-help": "اگر می‌خواهید ارسال ایمیل کار کند، [http://www.php.net/manual/en/mail.configuration.php PHP's mail settings] نیازمند پیکربندی صحیح است.\nاگر هیچ قابلیت ایمیلی نمی‌خواهید، می‌توانید آنها را اینجا غیر‌فعال کنید.",
+ "config-email-user": "فعال کردن ایمیل کاربر به کاربر",
+ "config-email-user-help": "به همهٔ کاربرانی که ارسال ایمیل را در ترجیحات خود فعال کرده‌اند، اجازه داده خواهد شد که به یکدیگر ایمیل ارسال کنند.",
+ "config-email-usertalk": "فعال کردن اطلاع‌رسانی صفحهٔ بحث کاربر",
+ "config-email-usertalk-help": "به همهٔ کاربرانی که دریافت اطلاعیه را در اولویت‌های خود فعال کرده‌اند،اجازه خواهد داده‌شد که اطلاعیه‌ها را در صفحهٔ تغییر گفت‌وگوی کاربر دریافت کنند.",
+ "config-email-watchlist": "فعال کردن اطلاع‌رسانی فهرست پیگیری‌ها",
+ "config-email-watchlist-help": "به همهٔ کاربرانی که مشاهدهٔ صفحه را در اولویت‌های خود فعال کرده‌اند،اجازه خواهد داده‌شد که اطلاعیه‌های در رابطه با صفحات مشاهده شده را دریافت کنند.",
+ "config-email-auth": "فعال کردن احراز هویت توسط ایمیل",
+ "config-email-auth-help": "اگر این گزینه را فعال کنید، کاربران باید ایمیل خود را با استفاده از پیوند تأیید که به ایمیلشان ارسال می‌شود، تأیید کنند. \nدر این صورت تنها ایمیل‌هایی که تأیید شده باشند، می‌توانند از سیستم در هنگام تغییرات، ایمیل دریافت کنند.\nبرای ویکی‌هایی که به صورت عمومی استفاده می‌شوند، فعال کردن این گزینه پیشنهاد می‌شود.",
+ "config-email-sender": "آدرس ایمیل بازگشت:",
+ "config-email-sender-help": "آدرس ایمیلی را وارد کنید که هنگام ارسال ایمیل خارج از محدوده از آن به عنوان ایمیل بازگشت استفاده شود.\nبه جایی که پیام‌ها برگشت داده می‌شوند، فرستاده خواهد شد.\nبسیاری از سرورهای پستی حداقل به بخش نام عمومی معتبر نیاز دارند.",
+ "config-upload-settings": "بارگذاری‌های پرونده و تصویر",
+ "config-upload-enable": "فعال‌سازی بارگذاری پرونده",
+ "config-upload-help": "بارگذاری پرونده بصورت بالقوه می‌تواند کارساز شما در معرض خطرات امنیتی قرار بدهد. برای کسب اطلاعات بیشتر لطفاً [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security قسمت امنیتی] را مطالعه کنید.\n\nبرای اجازه‌دادن به بارگذاری پرونده‌ها٬ حالت زیر مجموعه <code>images</code> در پوشه ریشه مدیاویکی را تغییر دهید که کارسازهای وب قادر به نوشتن بر روی آن باشند. سپس این قابلیت را فعال کنید.",
+ "config-upload-deleted": "فهرست برای پوشه‌های حذف شده:",
+ "config-upload-deleted-help": "فهرستی برای بایگانی کردن پوشه‌های حذف شده انتخاب کنید.\nبه طور مطلوب،از شبکه نباید در دسترس باشد.",
+ "config-logo": "نشانی نامواره:",
+ "config-logo-help": "پوستهٔ پیش‌فرض مدیاویکی شامل مکانی برای یک آرم ۱۳۵x۱۶۰ پیکسلی بالای منوی نوارکناری است.\nیک عکس با اندازهٔ مناسب ارسال کنید، و یوآرال را اینجا وارد کنید.\nاگر آرم شما با آن راه‌ها مزتبط است،می‌توانید از <code>$wgStylePath</code> یا <code>$wgScriptPath</code> استفاده کنید.\nاگر آرم نمی‌خواهید، این جعبه را خالی رها کنید.",
+ "config-instantcommons": "فعال کردن فوری ویکی‌انبار",
+ "config-instantcommons-help": "[https://www.mediawiki.org/ ویکی و InstantCommons ویکی‌انبار فوری] یک ویژگی‌است که به شما اجازه می‌دهد تا تصاویر، صداها یا سایر رسانه‌های یافته شده بر روی [https://commons.wikimedia.org/ انبار ویکی مدیا] را استفاده کنید.\n\nبرای استفاده از این ویژگی مدیاویکی نیازمند دسترسی به اینترنت است.\n\nبرای کسب اطلاعات بیشتر درباره این ویژگی٬ شامل دستورالعمل‌های برای چگونگی نصب آن برای سایر ویکی‌های بجز ویکی‌انبار لطفاً از [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos the نصب دستی] استفاده کنید.",
+ "config-cc-error": "مجوز چوزر عوام سازنده بی‌نتیجه ماند.\nنام مجوز را دستی وارد کنید.",
+ "config-cc-again": "انتخاب دوباره...",
+ "config-cc-not-chosen": "مجوز عوام سازنده‌ای را که می‌خواهید انتخاب کنید و \"proceed\" را کلیک کنید.",
+ "config-advanced-settings": "تنظیمات پیشرفته",
+ "config-cache-options": "تنظیمات برای ذخیره شی:",
+ "config-cache-help": "کش شی برای بهتر شدن سرعت مدیا ویکی، از طریق کش کردن داده‌های با بیشترین استفاده انجام می‌گیرد.\nوبگاه‌های متوسط تا بزرگ به انجام این کار شدیدا توصیه می‌شوند، در عین حال وبگاه‌های کوچک نیز می‌توانند از مزایای ایم مورد بهره ببرند.",
+ "config-cache-none": "بدون ذخیره (هیچ کارآمدی پاک نشده‌است، اما ممکن است سرعت در سایت‌های بزرگتر ویکی تأثیر داشته باشد)",
+ "config-cache-accel": "کاشهٔ اشیای پی‌اچ‌پی (APC یا APCu یا XCache یا WinCahe)",
+ "config-cache-memcached": "از ممکچد (که نیازمند تنظیمات اضافی و پیکربندی) استفاده کنید",
+ "config-memcached-servers": "سرورهای دریافت حافظه:",
+ "config-memcached-help": "فهرست آدرس‌های آی‌پی برای استفاده از ممکچد.\nباید هر یک خط را تعیین کند و درگاه مورد استفاده را تعیین کند.برای مثال: \n۱۲۷.۰.۰.۱:۱۱۲۱۱\n۱۹۲.۱۶۸.۱.۲۵:۱۲۳۴",
+ "config-memcache-needservers": "شما ممکچد را به عنوان نوع ذخیرهٔ خود انتخاب کرده‌اید اما هیچ سروری تعیین نشده‌.",
+ "config-memcache-badip": "آدرس آی‌پی نامعتبر برای مِمکَچد وارد کرده‌اید: $1.",
+ "config-memcache-noport": "شما درگاهی برای استفاده از سرور ممکچد تعیین نکرده بودید: $1\nاگر از درگاه مطلع نیستید، پیش‌فرض ۱۱۲۱۱ است.",
+ "config-memcache-badport": "اعداد درگاه ممکچد باید بین $1 و $2 باشد.",
+ "config-extensions": "افزونه‌ها",
+ "config-extensions-help": "لیست وسیع بالا در فهرست <code>./extensions</code> شما یافت شد.\nممکن است نیازمند پیکربندی اضافه باشند، اما اکنون می‌توانید آنها را فعال کنید.",
+ "config-skins": "پوسته‌ها",
+ "config-skins-help": "پوسته های ذکر شده در بالا در <code>./skins</code> پوشهٔ شما شناسایی شده است. شما باید حداقل یکی را فعال و پیش فرض کنید.",
+ "config-skins-use-as-default": "این پوست را به عنوان پیش فرض استفاده کنید",
+ "config-skins-missing": "هیچ پوسته‌ای انتخاب نشده‌است‌‌، تا زمانی که یک پوستهٔ مناسب نصب کنید مدیاویکی از پوسته ذخیره‌شده استفاده می‌کند",
+ "config-skins-must-enable-some": "شما باید حداقل یک پوست برای فعال کردن انتخاب کنید.",
+ "config-skins-must-enable-default": "پوست انتخاب شده به عنوان پیش فرض باید فعال شده باشد.",
+ "config-install-alreadydone": "'''هشدار:''' به نظر می‌رسد در حال حاضر شما مدیاویکی را نصب کرده‌اید و دوباره سعی میکنید آن را نصب کنید.\nلطفاً به صفحهٔ بعدی بروید.",
+ "config-install-begin": "با فشاردادن \"{{int:config-continue}}\"، نصب مدیاویکی را آغاز خواهید‌کرد.\nاگر هنوز می‌خواهید تغییرات ایجاد کنید، \"{{int:config-back}}\" را فشار دهید.",
+ "config-install-step-done": "انجام شد",
+ "config-install-step-failed": "ناموفق بود",
+ "config-install-extensions": "افزونه‌های مشمول",
+ "config-install-database": "نصب پایگاه داده",
+ "config-install-schema": "ایجاد طرح کلی",
+ "config-install-pg-schema-not-exist": "طرح کلی PostgreSQL وجود ندارد.",
+ "config-install-pg-schema-failed": "ایجاد جدول‌ها با شکست روبه‌رو شد.\nمطمئن شوید که کاربر \"$1\" می‌تواند طرح کلی \"$2\" را بنویسد.",
+ "config-install-pg-commit": "ارسال تغییرات",
+ "config-install-pg-plpgsql": "بررسی زبان پی‌ال/پی‌جی‌اس‌کیو‌ال",
+ "config-pg-no-plpgsql": "شما ملزم به نصب زبان پی‌ال/پی‌جی‌اس‌کیو‌ال در پایگاه اطلاعاتی $1 هستید",
+ "config-pg-no-create-privs": "حسابی که شما برای نصب تعیین کردید، مزایای کافی برای ایجاد یک حساب را ندارد.",
+ "config-pg-not-in-role": "حسابی که شما برای کاربر وب مشخص نموده‌اید در حال حاضر موجود است.\nحسابی که شما مشخص نموده‌اید جزو گروه کاربران سوپریوزر نیست و همچین عضو نقش کار وب نیست بنابراین قادر به ایجاد اشیایی تحت مالکیت کاربر وب نیست.\n\nجداول در مدیاویکی همکنون باید تحت مالکیت کاربر وب در بیاییند. لطفاً حساب کاربری وب دیگری را معین کنید و یا بر روی «برگشت» کلیک و کاربری با صحت امتیاز مناسب برای نصب انتخاب کنید.",
+ "config-install-user": "ایجاد بانک اطلاعاتی کاربر",
+ "config-install-user-alreadyexists": "کاربر \"$1\" در حال حاضر موجود است",
+ "config-install-user-create-failed": "ایجاد کاربر \"$1\" انجام نشد:$2",
+ "config-install-user-grant-failed": "اعطای مجوز به کاربر \"$1\" با شکست روبه‌رو شد: $2",
+ "config-install-user-missing": "کاربر تعیین شدهٔ \"$1\" وجود ندارد.",
+ "config-install-user-missing-create": "کاربر تعیین شدهٔ \"$1\" وجود ندارد.\nاگر می‌خواهید حساب ایجاد کنید،لطفاً جعبهٔ بررسی \"ایجاد حساب\" را کلیک کنید.",
+ "config-install-tables": "ایجاد جداول",
+ "config-install-tables-exist": "'''هشدار:''' جداول مدیاویکی به نظر می‌رسد در حال حاضر وجود دارد.\nصرف نظر از ایجاد.",
+ "config-install-tables-failed": "'''خطا:''' ایجاد جدول با خطای زیر به شکست مواجه شد: $1",
+ "config-install-interwiki": "قرار دادن پیش‌فرض جدول ویکی داخلی",
+ "config-install-interwiki-list": "پوشهٔ <code>interwiki.list</code> نتوانست خوانده شود.",
+ "config-install-interwiki-exists": "'''هشدار:''' به نظر می‌رسد جدول ویکی داخلی در حال حاضر دارای مقداری اطلاعات است.\nنادیده گرفتن فهرست پیش‌فرض.",
+ "config-install-stats": "شروع آمار",
+ "config-install-keys": "تولید کلیدهای مخفی",
+ "config-insecure-keys": "'''هشدار:''' {{PLURAL:$2|کلید امن|کلیدهای امن}} ($1) در طی نصب کاملاً ایمن {{PLURAL:$2|نیست|نیستند}}. تغییر دستی {{PLURAL:$2|آن|آنها}} را در نظر بگیرید.",
+ "config-install-updates": "جلوگیری از به روز رسانی‌های غیر ضروری در حال اجرا",
+ "config-install-updates-failed": "<strong>خطا:</strong> قراردادن کلیدهای به روز رسانی به داخل جداول با خطای روبرو مواجه شد: $1",
+ "config-install-sysop": "ایجاد حساب کاربری مدیر",
+ "config-install-subscribe-fail": "قادر تصدیق اعلام مدیاویکی نیست:$1",
+ "config-install-subscribe-notpossible": "سی‌یوآر‌ال نصب نشده‌است و <code>allow_url_fopen</code> در دسترس نیست.",
+ "config-install-mainpage": "ایجاد صفحهٔ اصلی با محتوای پیش‌فرض",
+ "config-install-mainpage-exists": "صفحهٔ اصلی موجود است، رها شد",
+ "config-install-extension-tables": "ایجاد جداول برای افزونه‌های فعال",
+ "config-install-mainpage-failed": "قادر به درج صفحهٔ اصلی نمی‌باشد:$1",
+ "config-install-done": "'''تبریک!'''\nبا موفقیت مدیاویکی را نصب کردید.\nبرنامه نصب‌کننده پرونده <code>LocalSettings.php</code> را درست کرد.\nکه شامل تمام تنظیمات می‌باشد.\n\nشما نیاز به دریافت آن دارید و آن را در پایهٔ نصب ویکی قرار دهید (همان پوشهٔ index.php). دریافت باید به صورت خودکار شروع شده‌باشد.\n\nاگر دریافت شروع نشد یا اگر آن را لغو کردید با کلیک روی پیوند زیر می‌توانید آن را دریافت کنید:\n\n$3\n\n'''توجه داشته باشید:''' اگر این را الآن انجام ندهید، این پرونده تولیدشده در صورتی که نصب را بدون دریافت آن تمام کردید بعداً در اختیار شما قرار نخواهد گرفت.\n\nوقتی انجام شد شما می‌توانید '''[$2 وارد ویکی شوید]'''.",
+ "config-install-done-path": "<strong>تبریک!</strong>\nمدیاویکی با موفقیت نصب گردید.\nبرنامه نصب‌کننده یک پرونده <code>LocalSettings.php</code> ایجاد کرده است که شامل تمام تنظیمات می‌باشد.\n\nلازم است شما آن را دریافت کرده و در <code>$4</code> قرار دهید. اِن دریافت می باِست به صورت خودکار شروع شده‌باشد.\n\nاگر دریافت شروع نشده بود و یا آن را لغو کرده اید با کلیک روی پیوند زیر می‌توانید آن را دریافت کنید:\n\n$3\n\n<strong>توجه:</strong> اگر این کار را هم اکنون انجام ندهید و بدون دریافت آن از برنامه نصب خارج شويد، این پرونده تنظیمات تولیدشده در آینده در اختیار شما قرار نخواهد داشت.\n\nوقتی که آن کار را انجام داديد، می‌توانید <strong>[$2 وارد ويکی خودتان شويد]</strong>.",
+ "config-download-localsettings": "دریافت <code>LocalSettings.php</code>",
+ "config-help": "راهنما",
+ "config-help-tooltip": "برای گسترش کلیک کنید",
+ "config-nofile": "پروندهٔ «$1» یافت نشد. آیا حذف شده‌است؟",
+ "config-extension-link": "آیا می‌دانستید که ویکی شما [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensions] را پشتیبانی می‌کند؟\nشما می‌توانید [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensions by category]",
+ "config-skins-screenshots": "$1 (تصاویر: $2)",
+ "config-screenshot": "تصویر",
+ "mainpagetext": "'''مدیاویکی با موفقیت نصب شد.'''",
+ "mainpagedocfooter": "از [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents راهنمای کاربری]\nبرای اطلاعات بیشتر در مورد به‌کارگیری نرم‌افزار ویکی استفاده کنید.\n\n== آغاز به کار ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings فهرست تنظیمات پیکربندی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ پرسش‌های متداول مدیاویکی]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce فهرست ایمیلی نسخه‌های مدیاویکی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources محلی‌سازی مدیاویکی به زبان شما]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam یادگیری روش‌های مقابله با هرزنگاری در ویکی]"
+}
diff --git a/www/wiki/includes/installer/i18n/fi.json b/www/wiki/includes/installer/i18n/fi.json
new file mode 100644
index 00000000..6c99a423
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/fi.json
@@ -0,0 +1,310 @@
+{
+ "@metadata": {
+ "authors": [
+ "Beluga",
+ "Centerlink",
+ "Crt",
+ "Nike",
+ "Olli",
+ "Silvonen",
+ "Str4nd",
+ "VezonThunder",
+ "아라",
+ "Elseweyr",
+ "Lliehu",
+ "Syreeni",
+ "Stryn",
+ "SMAUG",
+ "SuperPete",
+ "McSalama",
+ "Jaakkoh",
+ "Mikahama",
+ "Olimar",
+ "01miki10",
+ "Pyscowicz"
+ ]
+ },
+ "config-desc": "MediaWiki-asennin",
+ "config-title": "MediaWikin version $1 asennus",
+ "config-information": "Tiedot",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code>-tiedosto havaittiin.\nKirjoita muuttujan <code>$wgUpgradeKey</code> arvo alla olevaan kenttään päivittääksesi asennuksen.\nLöydät sen <code>LocalSettings.php</code>-tiedostosta.",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code>-tiedosto havaittiin.\nPäivitä asennus suorittamalla <code>update.php</code>.",
+ "config-localsettings-key": "Päivitysavain",
+ "config-localsettings-badkey": "Antamasi päivitysavain on virheellinen.",
+ "config-upgrade-key-missing": "Havaittiin aiempi MediaWiki-asennus.\nPäivittääksesi tämän asennuksen lisää <code>LocalSettings.php</code>-tiedostosi loppuun seuraava rivi:\n\n$1",
+ "config-localsettings-incomplete": "Nykyinen <code>LocalSettings.php</code>-tiedosto näyttää olevan puutteellinen.\nMuuttujaa $1 ei ole asetettu.\nMuuta <code>LocalSettings.php</code>-tiedostoa siten, että muuttuja on asetettu ja napsauta »{{int:Config-continue}}».",
+ "config-localsettings-connection-error": "Yhteyden muodostaminen tietokantaan epäonnistui tiedostossa <code>LocalSettings.php</code> olevien asetusten takia. Korjaa asetukset ja yritä uudelleen.\n\n$1",
+ "config-session-error": "Istunnon aloittaminen epäonnistui: $1",
+ "config-session-expired": "Istuntotietosi näyttävät olevan vanhentuneita.\nIstuntojen elinajaksi on määritelty $1.\nVoit muuttaa tätä asetusta vaihtamalla kohtaa <code>session.gc_maxlifetime</code> php.ini-tiedostossa.\nKäynnistä asennusprosessi uudelleen.",
+ "config-no-session": "Istuntosi tiedot menetettiin!\nTarkista php.ini-tiedostosi ja varmista, että <code>session.save_path</code> on asetettu sopivaan kansioon.",
+ "config-your-language": "Asennuksen kieli",
+ "config-your-language-help": "Valitse kieli, jota haluat käyttää asennuksen ajan.",
+ "config-wiki-language": "Wikin kieli",
+ "config-wiki-language-help": "Valitse kieli, jota wikissä tullaan etupäässä käyttämään.",
+ "config-back": "← Takaisin",
+ "config-continue": "Jatka →",
+ "config-page-language": "Kieli",
+ "config-page-welcome": "Tervetuloa MediaWikiin!",
+ "config-page-dbconnect": "Tietokantaan yhdistäminen",
+ "config-page-upgrade": "Olemassa olevan asennuksen päivitys",
+ "config-page-dbsettings": "Tietokannan asetukset",
+ "config-page-name": "Nimi",
+ "config-page-options": "Asetukset",
+ "config-page-install": "Asenna",
+ "config-page-complete": "Valmis!",
+ "config-page-restart": "Aloita asennus alusta",
+ "config-page-readme": "Lue minut",
+ "config-page-releasenotes": "Julkaisutiedot",
+ "config-page-copying": "Kopiointi",
+ "config-page-upgradedoc": "Päivittäminen",
+ "config-page-existingwiki": "Aikaisempi asennus",
+ "config-help-restart": "Haluatko poistaa kaikki annetut tiedot ja aloittaa asennuksen alusta?",
+ "config-restart": "Kyllä",
+ "config-welcome": "=== Ympäristön tarkistukset ===\nVarmistetaan MediaWikin asennettavuus tähän ympäristöön.\nMuista antaa nämä tiedot, jos tarvitset apua asennuksen aikana.",
+ "config-copyright": "=== Tekijänoikeudet ja käyttöehdot ===\n\n$1\n\nTämä ohjelma on vapaa ohjelmisto; voit levittää sitä ja/tai muokata sitä Free Software Foundationin GNU General Public Licensen ehdoilla, joko version 2 tai (halutessasi) minkä tahansa myöhemmän version mukaisesti.\n\nTätä ohjelmaa levitetään siinä toivossa, että se olisi hyödyllinen, mutta <strong>ilman mitään takuuta</strong>; ilman edes hiljaista takuuta <strong>kaupallisesti hyväksyttävästä laadusta</strong> tai <strong>soveltuvuudesta tiettyyn tarkoitukseen.</strong\nKatso GNU Generel Public Licensestä lisää yksityiskohtia.\n\nSinun olisi pitänyt saada <doclink href=Copying>kopio GNU General Public Licensestä</doclink> tämän ohjelman mukana; jos et, kirjoita siitä osoitteeseen Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA tai [http://www.gnu.org/copyleft/gpl.html lue se verkossa].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWikin kotisivu]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Käyttöopas]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Hallintaopas]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ UKK]\n----\n* <doclink href=Readme>Lue minut</doclink>\n* <doclink href=ReleaseNotes>Julkaisutiedot</doclink>\n* <doclink href=Copying>Kopiointi</doclink>\n* <doclink href=UpgradeDoc>Päivittäminen</doclink>",
+ "config-env-good": "Asennusympäristö on tarkastettu.\nVoit asentaa MediaWikin.",
+ "config-env-bad": "Asennusympäristö on tarkastettu.\nEt voi asentaa MediaWikiä.",
+ "config-env-php": "PHP $1 on asennettu.",
+ "config-env-hhvm": "HHVM $1 on asennettu.",
+ "config-unicode-using-intl": "Käyttää [http://pecl.php.net/intl intl PECL-laajennusta] Unicode-normalisaatioon.",
+ "config-no-db": "Sopivaa tietokanta-ajuria ei löytynyt! Sinun täytyy asentaa tietokanta-ajuri PHP:lle.\n{{PLURAL:$2|Seuraava tietokantatyyppi on tuettu|Seuraavat tietokantatyypit ovat tuettuja}}: $1.\n\nJos koostit PHP:n itse, määritä se uudelleen tietokanta-asiakkaan ollessa käytössä, esimerkiksi koodilla <code>./configure --with-mysqli</code>.\nJos asensit PHP:n Debian- tai Ubuntu-pakkauksesta, sinun on myös asennettava esimerkiksi <code>php5-mysql</code>-pakkaus.",
+ "config-outdated-sqlite": "<strong>Varoitus:</strong> sinulla on käytössä SQLite $1, joke on vanhempi kuin vähintään vaadittava versio $2. SQLite ei ole saatavilla.",
+ "config-no-fts3": "<strong>Varoitus:</strong> SQLite on koostettu ilman [//sqlite.org/fts3.html FTS3-moduulia], hakuominaisuudet eivät ole käytössä tässä taustajärjestelmässä.",
+ "config-pcre-old": "<strong>Tärkeää:</strong> PCRE $1 tai uudempi versio tarvitaan.\nPHP-binäärisi on linkitetty versiolla PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Lisätietoja].",
+ "config-memory-raised": "PHP:n <code>memory_limit</code> on $1, nostetaan arvoon $2.",
+ "config-memory-bad": "'''Varoitus:''' PHP:n <code>memory_limit</code> on $1.\nTämä on luultavasti liian alhainen.\nAsennus saattaa epäonnistua!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] on asennettu",
+ "config-apc": "[http://www.php.net/apc APC] on asennettu.",
+ "config-apcu": "[http://www.php.net/apcu APCu] on asennettu",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] on asennettu",
+ "config-diff3-bad": "GNU diff3:a ei löytynyt.",
+ "config-git": "Löydetty Git versionhallintaohjelmisto: <code>$1</code>",
+ "config-git-bad": "Git versionhallintaohjelmistoa ei löydy.",
+ "config-imagemagick": "Löydettiin ImageMagick: <code>$1</code>.\nKuvien esikatselukuvat otetaan samalla käyttöön jos otetaan tiedostojen tallennus.",
+ "config-gd": "Löydettiin sisäänrakennettu GD-grafiikkakirjasto.\nKuvista luodaan esikatseluversiot automaattisesti, jos otat käyttöön tiedostojen lähettämisen.",
+ "config-no-scaling": "GD-kirjastoa tai ImageMagick-ohjelmaa ei löydy. \nKuvista ei luoda esikatseluversioita.",
+ "config-no-uri": "Virhe: Tämänhetkistä URIa ei tunnisteta. Asennus keskeytetään.",
+ "config-no-cli-uri": "<strong>Varoitus:</strong> <code>--scriptpath</code> määrittämättä, käytetään oletusta: <code>$1</code>",
+ "config-using-server": "Palvelimen nimenä käytetään \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Palvelinen URL-osoitteena käytetään \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Varoitus:</strong> Tiedostojen lähetyshakemistoa <code>$1</code> ei ole suojattu haitalliselta koodilta. MediaWiki tarkistaa kaikki lähetetyt tiedostot, mutta suosittelemme toimimaan ohjeen [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security close this security vulnerability] mukaan ennen kuin tiedostojen lähetys otetaan käyttöön.",
+ "config-no-cli-uploads-check": "<strong>Varoitus:</strong> Tiedostojen lähetyshakemistoa (<code>$1</code>) ei ole tarkistettu haavoittuvuuksien varalta komentoriviasennuksen aikana.",
+ "config-brokenlibxml": "Järjestelmässäsi on käytössä PHP:n ja libxml2:n versioyhdistelmä, joka ei toimi kunnolla ja voi aiheuttaa tiedon vahingoittumista MediaWikissä ja muissa web-sovelluksissa.\nPäivitä libxml2 versioon 2.7.3 tai uudempaan ([https://bugs.php.net/bug.php?id=45996 bug filed with PHP]).\nAsennus keskeytetty.",
+ "config-suhosin-max-value-length": "Suhosin on asennettu ja se rajoittaa GET-parametrin <code>length</code> $1 tavuun.\nMediaWikin ResourceLoader-komponentti pystyy toimimaan tämän kanssa, mutta ohjelmiston suorituskyky heikkenee.\nMikäli mahdollista, aseta muuttuja <code>suhosin.get.max_value_length</code> arvoon 1024 (tai suurempaan) tiedostossa <code>php.ini</code> ja aseta myös <code>$wgResourceLoaderMaxQueryLength</code> samaksi arvoksi tiedostossa <code>LocalSettings.php</code>.",
+ "config-db-type": "Tietokannan tyyppi",
+ "config-db-host": "Tietokantapalvelin",
+ "config-db-host-help": "Jos tietokantapalvelimesi sijaitsee eri palvelimella, syötä palvelimen nimi tai ip-osoite tähän.\n\nJos käytössäsi on ulkoinen palveluntarjoaja, pitäisi palvelimen nimen löytyä yrityksen ohjesivuilta.\n\nJos asennat MediaWikiä Windows-palvelimelle ja käytät MySQL:ää ei palvelimen nimi \"localhost\" välttämättä toimi. Tässä tapauksessa koita käyttää osoitetta 127.0.0.1.\n\nJos käytät PostgreSQL:ää jätä tämä kenttä tyhjäksi.",
+ "config-db-host-oracle": "Tietokannan TNS:",
+ "config-db-wiki-settings": "Identifioi tämä wiki",
+ "config-db-name": "Tietokannan nimi",
+ "config-db-name-help": "Valitse wikiäsi kuvaava nimi.\nNimessä ei saa olla välilyöntejä.\n\nMikäli et pysty itse hallitsemaan tietokantojasi, pyydä palveluntarjoajaasi luomaan tietokanta tai tee se palveluntarjoajasi hallintapaneelissa.",
+ "config-db-name-oracle": "Tietokannan rakenne:",
+ "config-db-install-account": "Asennuksessa käytettävä käyttäjätili",
+ "config-db-username": "Tietokannan käyttäjätunnus",
+ "config-db-password": "Tietokannan salasana",
+ "config-db-install-username": "Syötä käyttäjänimi jota käytetään muodostettaessa yhteys tietokantaan asennuksen aikana.\nTämä ei ole MediaWiki tilin käyttäjänimi; tämä on tietokannan käyttäjänimi.",
+ "config-db-install-password": "Syötä salasana jota käytetään muodostettaessa yhteys tietokantaan asennuksen aikana.\nTämä ei ole MediaWiki tilin salasana; tämä on tietokannan salasana.",
+ "config-db-install-help": "Anna käyttäjätunnus ja salasana, joita käytetään asennuksen aikana.",
+ "config-db-account-lock": "Käytä samaa tunnusta ja salasanaa myös asennuksen jälkeen",
+ "config-db-wiki-account": "Käyttäjätili normaaliin käyttöön",
+ "config-db-wiki-help": "Syötä käyttäjänimi ja salasana joita käytetään muodostettaessa yhteys tietokantaan käytettäessä wikiä normaalisti.\nJos tiliä ei ole olemassa ja asennuksessa käytettävällä tilillä on riittävät käyttöoikeudet, tämä käyttäjätili luodaan käyttöoikeuksilla jotka vähintään tarvitaan wikiä varten.",
+ "config-db-prefix": "Tietokantataulujen etuliite",
+ "config-db-prefix-help": "Jos tietokantaa käytetään useammalle wikille tai MediaWikille ja muille sovelluksille, suositellaan käytettäväksi tauluissa etuliitettä, joilla ne erotetaan toisistaan ja vältetään näin virheitä.\nÄlä käytä välilyöntejä.\n\nYleensä tämä kenttä jätetään tyhjäksi.",
+ "config-mysql-old": "MediaWiki tarvitsee MySQL:n version $1 tai uudemman. Nykyinen versio on $2.",
+ "config-db-port": "Tietokannan portti:",
+ "config-db-schema": "MediaWikin rakenne:",
+ "config-db-schema-help": "Tämä rakenne on normaalisti toimiva.\nMuuta rakennetta vain, mikäli on pakko ja tiedät, mitä teet.",
+ "config-pg-test-error": "Tietokantaan <strong>$1 ei voida muodostaa yhteyttä</strong>: $2",
+ "config-sqlite-dir": "SQLiten datahakemisto:",
+ "config-sqlite-dir-help": "SQLite tallentaa kaiken sisällön yhteen tiedostoon.\n\nPalvelimen pitää pystyä kirjoittamaan tietoa hakemistoon asennuksen aikana.\n\nHakemiston <strong>ei</strong> tulisi olla nähtävissä www-selaimella. Siksi hakemisto on eri kuin missä PHP-tiedostot sijaitsevat.\n\nAsennusohjelma luo <code>.htaccess</code>-tiedoston, mutta jos sen luomisessa ilmenee ongelmia joku voi päästä käsiksi tietokantaasi. \nTietokannassa on kaikki sähköpostiosoitteet, salasanat, poistetut versiot ja kaikki muu tieto, joka ei näy wikissä.\n\nSuosittelemme tallentamaan tietokannan eri hakemistoon, esimerkiksi <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Oletus taulukkotila:",
+ "config-oracle-temp-ts": "Väliaikainen taulukkotila:",
+ "config-type-mysql": "MySQL (tai yhteensopiva)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki tukee seuraavia tietokantajärjestelmiä:\n\n$1\n\nJos et näe tietokantajärjestelmää, jota yrität käyttää, listattuna alhaalla, seuraa yläpuolella olevia ohjeita tuen aktivoimiseksi.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] on MediaWikin ensisijainen kohde ja se on myös parhaiten tuettu. MediaWiki voi myös käyttää [{{int:version-db-mariadb-url}} MariaDB]- sekä [{{int:version-db-percona-url}} Percona Server]-järjestelmiä, jotka ovat MySQL-yhteensopivia. ([http://www.php.net/manual/en/mysqli.installation.php Miten käännetään PHP MySQL-tuella])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] on suosittu avoimen lähdekoodin tietokantajärjestelmä vaihtoehtona MySQL:lle. ([http://www.php.net/manual/en/pgsql.installation.php Kuinka käännetään PHP PostgreSQL-tuella])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] on kevyt tietokantajärjestelmä, jota tuetaan hyvin. ([http://www.php.net/manual/en/pdo.installation.php Miten käännetään PHP SQLite-tuella], käyttää PDO:ta)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] on kaupallinen yritystietokanta. ([http://www.php.net/manual/en/oci8.installation.php Kuinka käännetään PHP OCI8-tuella])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] on kaupallinen yritystietokanta Windowsille. ([http://www.php.net/manual/en/sqlsrv.installation.php Miten käännetään PHP SQLSRV-tuella])",
+ "config-header-mysql": "MySQL-asetukset",
+ "config-header-postgres": "PostgreSQL-asetukset",
+ "config-header-sqlite": "SQLite-asetukset",
+ "config-header-oracle": "Oracle-asetukset",
+ "config-header-mssql": "Microsoft SQL Server asetukset",
+ "config-invalid-db-type": "Virheellinen tietokantatyyppi",
+ "config-missing-db-name": "\"{{int:config-db-name}}\" on pakollinen.",
+ "config-missing-db-host": "\"{{int:config-db-host}}\" on pakollinen.",
+ "config-missing-db-server-oracle": "\"{{int:config-db-host-oracle}}\" on pakollinen.",
+ "config-invalid-db-server-oracle": "Virheellinen tietokanta TNS \"$1\".\nKäytä joko \"TNS Name\"- tai \"Easy Connect\" -tekstiä\n([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle metodien nimeäminen]).",
+ "config-invalid-db-name": "”$1” ei kelpaa tietokannan nimeksi.\nKäytä ainoastaan kirjaimia (a-z, A-Z), numeroita (0-9), alaviivoja (_) ja tavuviivoja (-).",
+ "config-invalid-db-prefix": "”$1” ei kelpaa tietokannan etuliitteeksi.\nKäytä ainoastaan kirjaimia (a-z, A-Z), numeroita (0-9), alaviivoja (_) ja tavuviivoja (-).",
+ "config-connection-error": "$1.\n\nTarkista isäntä, käyttäjänimi, salasana ja yritä uudestaan.",
+ "config-invalid-schema": "Virheellinen skeema MediaWikille \"$1\".\nKäytä pelkkiä ASCII-kirjaimia (a-z, A-Z), numeroita (0-9) ja alaviivoja (_).",
+ "config-db-sys-create-oracle": "Asennusohjelma tukee ainoastaan SYSDBA-tunnuksen käyttämistä uuden tunnuksen luonnissa.",
+ "config-db-sys-user-exists-oracle": "Käyttäjätunnus \"$1\" on jo olemassa. SYSDBA:ta voidaan käyttää vain uuden tunnuksen luontiin!",
+ "config-postgres-old": "MediaWiki tarvitsee PostgreSQL:n version $1 tai uudemman. Nykyinen versio on $2.",
+ "config-mssql-old": "Vaaditaan Microsoft SQL Server $1 tai uudempi. Sinulla on käytössä $2.",
+ "config-sqlite-name-help": "Valitse nimi, joka yksilöi tämän wikin.\nÄlä käytä välilyöntejä tai viivoja.\nNimeä käytetään SQLite-tietokannan tiedostonimessä.",
+ "config-sqlite-dir-unwritable": "Hakemistoon ”$1” kirjoittaminen epäonnistui.\nMuuta hakemiston käyttöoikeuksia siten, että palvelinohjelmisto voi kirjoittaa siihen ja yritä uudelleen.",
+ "config-sqlite-connection-error": "$1.\n\nTarkista tietohakemiston ja tietokannan nimi alla ja yritä uudelleen.",
+ "config-sqlite-readonly": "Tiedostoon <code>$1</code> ei voi kirjoittaa.",
+ "config-sqlite-cant-create-db": "Tietokantatiedostoa <code>$1</code> ei voitu luoda.",
+ "config-sqlite-fts3-downgrade": "PHP:stä puuttuu FTS3-tuki. Poistetaan ominaisuus käytöstä tietokantatauluista.",
+ "config-can-upgrade": "Tietokanta sisältää jo MediaWiki tauluja.\nPäivitä ne MediaWiki-versioon $1 painamalla <strong>Jatka</strong>.",
+ "config-upgrade-done": "Päivitys valmis.\n\nVoit [$1 aloittaa wikin käytön].\n\nNapsauta alla olevaa painiketta, jos haluat luoda uudelleen <code>LocalSettings.php</code>-tiedoston.\nTämä '''ei ole suositeltavaa''', jos wikissäsi ei ole ongelmia.",
+ "config-upgrade-done-no-regenerate": "Päivitys valmis.\n\nVoit [$1 aloittaa wikin käytön].",
+ "config-regenerate": "Luo LocalSettings.php uudelleen →",
+ "config-show-table-status": "Kysely <code>SHOW TABLE STATUS</code> epäonnistui!",
+ "config-unknown-collation": "<strong>Varoitus:</strong> Tietokanta käyttää tunnistamatonta lajittelua.",
+ "config-db-web-help": "Valitse käyttäjänimi ja salasana joita palvelin käyttää muodostaessaan yhteyttä tietokantapalvelimeen wikin normaalin toiminnan aikana.",
+ "config-db-web-account-same": "Käytä samaa tiliä kuin asennuksessa",
+ "config-db-web-create": "Lisää tili, jos sitä ei ole jo olemassa",
+ "config-db-web-no-create-privs": "Tilillä jota käytetään asennuksessa ei ole oikeuksia luoda uutta tiliä.\nTähän määriteltävä tili täytyy olla jo olemassa.",
+ "config-mysql-engine": "Tallennusmoottori",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Tietokannan merkistökoodaus:",
+ "config-mysql-binary": "Binääri",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Varmennuksen tyyppi:",
+ "config-mssql-install-auth": "Valitse varmennuksen tyyppi, jota käytetään yhdistäessä tietokantaan asennuksen aikana.\nJos valitset \"{{int:config-mssql-windowsauth}}\", käytetään verkkopalvelimen käyttäjän kirjautumistietoja.",
+ "config-mssql-web-auth": "Valitse varmennuksen tyyppi, jota verkkopalvelin käyttää yhdistäessään tietokantapalvelimeen wikin tavallisen toiminnan aikana.\nJos valitset \"{{int:config-mssql-windowsauth}}\", käytetään verkkopalvelimen käyttäjän kirjautumistietoja.",
+ "config-mssql-sqlauth": "SQL Server varmennus",
+ "config-mssql-windowsauth": "Windows-varmennus",
+ "config-site-name": "Wikin nimi",
+ "config-site-name-help": "Tämä näkyy selaimen otsikkona ja muissa kohdissa.",
+ "config-site-name-blank": "Kirjoita sivuston nimi.",
+ "config-project-namespace": "Projektinimiavaruus",
+ "config-ns-generic": "Projekti",
+ "config-ns-site-name": "Sama kuin wikin nimi: $1",
+ "config-ns-other": "Muu (määritä)",
+ "config-ns-other-default": "MinunWiki",
+ "config-project-namespace-help": "Wikipedian esimerkkiä seuraten monien wikien käytäntösivut ovat erillään sisältösivuista '''projektinimiavaruudessa'''.\nTämän nimiavaruuden kaikki sivujen nimet alkavat etuliitteellä, jonka voit määrittää tässä.\nYleensä tämä etuliite pohjautuu wikin nimeen, mutta siinä ei saa olla välimerkkejä kuten \"#\" tai \":\".",
+ "config-ns-invalid": "Määritelty nimiavaruus \"<nowiki>$1</nowiki>\" on virheellinen.\nSyötä joku muu nimiavaruus.",
+ "config-ns-conflict": "Määritelty nimiavaruus \"<nowiki>$1</nowiki>\" on ristiriidassa MediaWikin oletusnimiavaruuksien kanssa.\nSyötä joku muu nimiavaruus.",
+ "config-admin-box": "Ylläpitäjän tili",
+ "config-admin-name": "Käyttäjänimesi:",
+ "config-admin-password": "Salasana",
+ "config-admin-password-confirm": "Salasana uudelleen",
+ "config-admin-help": "Syötä käyttäjänimi tähän, esimerkiksi \"Matti Meikäläinen\".\nTätä nimeä käytetään kirjauduttaessa wikiin.",
+ "config-admin-name-blank": "Anna ylläpitäjän käyttäjänimi.",
+ "config-admin-name-invalid": "Annettu nimi \"<nowiki>$1</nowiki>\" on virheellinen.\nSyötä toinen nimi.",
+ "config-admin-password-blank": "Syötä ylläpitäjän salasana.",
+ "config-admin-password-mismatch": "Antamasi salasanat eivät täsmää.",
+ "config-admin-email": "Sähköpostiosoite",
+ "config-admin-email-help": "Syötä sähköpostiosoite johon vastaanotetaan viestit muilta wikin käyttäjiltä, nollataan salasana ja ilmoitetaan tarkkailulistalla olevista sivuista. Kenttä voidaan jättää myös tyhjäksi.",
+ "config-admin-error-user": "Sisäinen virhe luodessa ylläpitäjää nimellä \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Sisäinen virhe asetettaessa salasanaa ylläpitäjälle \"<nowiki>$1</nowiki>\":\n<pre>$2</pre>",
+ "config-admin-error-bademail": "Annoit virheellisen sähköpostiosoitteen.",
+ "config-subscribe": "Liity [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce päivityssähköpostilistalle].",
+ "config-subscribe-help": "Tällä harvoin käytettävällä sähköpostilistalla julkaistaan päivitysilmoituksia ja turvallisuuspäivityksiä.\nLiittymistä listalle suositellaan samoin kuin päivittämään MediaWiki kun uusi versio julkaistaan.",
+ "config-subscribe-noemail": "Yritit liittyä päivityssähköpostilistalle antamatta sähköpostiosoitetta.\nSyötä sähköpostiosoite jos haluat liittyä listalle.",
+ "config-almost-done": "Olet jo lähes valmis!\nVoit ohittaa jäljellä olevat määritykset ja asentaa wikin juuri nyt.",
+ "config-optional-continue": "Säädä lisää asetuksia.",
+ "config-optional-skip": "Nyt riittää, asenna wiki näillä tiedoilla.",
+ "config-profile": "Käyttäjäprofiilin oikeudet:",
+ "config-profile-wiki": "Avoin wiki",
+ "config-profile-no-anon": "Tunnuksen luonti vaaditaan",
+ "config-profile-fishbowl": "Vain hyväksytyt muokkaajat",
+ "config-profile-private": "Yksityinen wiki",
+ "config-license": "Tekijänoikeus ja lisenssi:",
+ "config-license-cc-by-sa": "Creative Commons Nimeä-Tarttuva",
+ "config-license-cc-by": "Creative Commons Nimeä",
+ "config-license-cc-by-nc-sa": "Creative Commons Nimeä-Epäkaupallinen-Tarttuva",
+ "config-license-cc-0": "Creative Commons Zero (Public Domain)",
+ "config-license-gfdl": "GNU Free Documentation -lisenssi 1.3 tai uudempi",
+ "config-license-pd": "Public domain",
+ "config-license-cc-choose": "Valitse mukautettu Creative Commons -lisenssi",
+ "config-license-help": "Monet julkiset wikit käyttävät muokkauksiin [http://freedomdefined.org/Definition vapaata lisenssiä].\nTämä auttaa luomaan yhteisöllisen omistajuuden tunteen ja kannustaa pitkäkestoiseen muokkaamiseen.\nSe ei ole yleensä tarpeen yksityiselle tai yrityksen wikille.\n\nJos haluat pystyä käyttämään tekstiä Wikipediasta, ja haluat Wikipedian pystyvän hyväksymään wikistäsi kopioitua tekstiä, sinun tulisi valita <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia käytti aiemmin GNU Free Documentation Licenseä.\nGFDL on kelvollinen lisenssi, mutta vaikea ymmärtää.\nOn myös vaikeaa käyttää uudelleen GFDL-lisensöityä sisältöä.",
+ "config-email-settings": "Sähköpostiasetukset",
+ "config-enable-email": "Ota käyttöön sähköpostien lähetys",
+ "config-enable-email-help": "Jotta sähköposti toimii, [http://www.php.net/manual/en/mail.configuration.php PHP:n sähköpostiasetukset] täytyy asettaa oikein.\nJos et halua käyttää sähköpostiominaisuuksia, ne voi kytkeä pois päältä tästä.",
+ "config-email-user": "Ota käyttöön käyttäjältä käyttäjälle sähköpostit",
+ "config-email-user-help": "Salli käyttäjien lähettää sähköpostia toisilleen jos he ovat ottaneet käyttöön toiminnon asetuksissaan.",
+ "config-email-usertalk": "Ota käyttöön käyttäjien keskustelusivusta ilmoittaminen",
+ "config-email-usertalk-help": "Salli käyttäjien vastaanottaa ilmoituksia käyttäjän keskustelusivulla tapahtuneista muutoksista jos he ovat ottaneet käyttöön toiminnon asetuksissaan.",
+ "config-email-watchlist": "Ota käyttöön tarkkailulistasta ilmoittaminen",
+ "config-email-watchlist-help": "Salli käyttäjien vastaanottaa ilmoituksia tarkkailulistalla olevilla sivuilla tapahtuneista muutoksista jos he ovat ottaneet käyttöön toiminnon asetuksissaan.",
+ "config-email-auth": "Ota käyttöön sähköpostin vahvistaminen",
+ "config-email-auth-help": "Kun tämä valinta otetaan käyttöön, käyttäjien tulee vahvistaa sähköpostiosoitteensa linkistä, joka lähetetään heille kun he asettavat tai muuttavat sähköpostiaan.\nVain vahvistettuihin sähköpostiosoitteisiin voidaan lähettää sähköposteja muilta käyttäjiltä tai ilmoituksia muutoksista.\nTämän valinnan käyttöönottoa <strong>suositellaan</strong> julkisissa wikeissä, koska se vähentää mahdollista sähköpostien väärinkäyttöä.",
+ "config-email-sender": "Palautusähköpostiosoite:",
+ "config-upload-settings": "Kuvien ja tiedostojen lataaminen",
+ "config-upload-enable": "Ota käyttöön tiedostojen lataaminen",
+ "config-upload-deleted": "Poistettujen tiedostojen hakemisto:",
+ "config-upload-deleted-help": "Valitse hakemisto johon poistetut tiedostot arkistoidaan.\nHakemiston ei tulisi olla käytettävissä internetverkosta.",
+ "config-logo": "Logon URL-osoite",
+ "config-logo-help": "MediaWikin oletusulkoasussa on paikka 135x160 pikselin kokoiselle logolle sivupalkin yläpuolella.\nTallenna sopivan kokoinen kuva ja lisää URL tähän.\n\nVoit käyttää muuttujia <code>$wgStylePath</code> tai <code>$wgScriptPath</code>, jos logosi on määritelty suhteessa näihin polkuihin.\n\nJos et halua logoa, jätä tämä kenttä tyhjäksi.",
+ "config-instantcommons": "Aktivoi Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] on ominaisuus, joka antaa wikien käyttää kuvia, ääniä ja muuta mediaa [https://commons.wikimedia.org/ Wikimedia Commons] -sivustolta.\nTehdäkseen tämän MediaWiki tarvitsee Internet-yhteyden.\n\nLisätietoja tästä ominaisuudesta, mukaan lukien ohjeet, kuinka sen voi asettaa muille wikeille kuin Wikimedia Commons, löytyy [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos ohjeista].",
+ "config-cc-error": "Creative Commons -lisenssinvalitsija ei antanut tulosta.\nSyötä lisenssin nimi manuaalisesti.",
+ "config-cc-again": "Valitse uudelleen...",
+ "config-cc-not-chosen": "Valitse, minkä Creative Commons -lisenssin haluat ja paina \"proceed\".",
+ "config-advanced-settings": "Lisäasetukset",
+ "config-cache-none": "Ei välimuistia (toimintoja ei poisteta, mutta voi vaikuttaa nopeuteen suuremmilla wiki-sivustoilla)",
+ "config-cache-memcached": "Käytä Memcachedia (vaatii ylimääräistä asennusta ja konfigurointia)",
+ "config-memcached-servers": "Memcached-palvelimet:",
+ "config-memcached-help": "Luettelo IP-osoitteista Memcachedin käyttöön.\nPitäisi määrittää yksi osoite riviä kohden ja käytettävä portti. Esimerkiksi:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Valitsit Memcachedin välimuistin tyypiksi, mutta et määrittänyt palvelimia.",
+ "config-memcache-badip": "Olet antanut virheellisen IP-osoitteen Memcachedille: $1.",
+ "config-memcache-noport": "Et määrittänyt porttia käytettäväksi Memcached-palvelimelle: $1\nJos et tiedä porttia, oletus on 11211.",
+ "config-memcache-badport": "Memcachedin porttien numeroiden pitäisi olla välillä $1-$2.",
+ "config-extensions": "Laajennukset",
+ "config-extensions-help": "Yllä luetellut laajennukset löytyvät <code>./extensions</code> hakemistosta.\n\nNe saattavat vaatia lisäasetuksia, mutta voit ottaa ne käyttöön nyt.",
+ "config-skins": "Ulkoasut",
+ "config-skins-help": "Seuraavat teemat löydettiin hakemistosta <code>./skins</code>. Ota käyttöön vähintään yksi teema ja aseta se oletukseksi.",
+ "config-skins-use-as-default": "Käytä tätä teemaa oletuksena.",
+ "config-skins-missing": "Teemoja ei löytynyt; MediaWiki käyttää väliaikaista teemaa, kunnes asennat toimivia.",
+ "config-skins-must-enable-some": "Sinut täytyy valita ainakin yksi ulkoasu.",
+ "config-skins-must-enable-default": "Oletusteeman pitää olla käytössä.",
+ "config-install-alreadydone": "<strong>Varoitus:</strong> MediaWiki on jo asennettu ja yrität asentaa sitä uudestaan.\nSiirry seuraavalle sivulle.",
+ "config-install-begin": "Painamalla \"{{int:config-continue}}\", aloitetaan MediaWikin asentaminen. \nJos haluat vielä tehdä muutoksia, paina \"{{int:config-back}}\".",
+ "config-install-step-done": "valmis",
+ "config-install-step-failed": "epäonnistui",
+ "config-install-extensions": "Sisällytetään laajennukset",
+ "config-install-database": "Asennetaan tietokantaa",
+ "config-install-schema": "Luodaan rakennetta",
+ "config-install-pg-schema-not-exist": "PostgreSQL-rakennetta ei ole olemassa.",
+ "config-install-pg-schema-failed": "Taulun luominen epäonnistui.\nVarmista, että käyttäjätunnus \"$1\" pystyy kirjoittamaan rakenteeseen \"$2\".",
+ "config-install-pg-commit": "Muutoksia tallennetaan",
+ "config-install-pg-plpgsql": "Tarkistetaan PL/pgSQL:n kieltä.",
+ "config-pg-no-plpgsql": "PL/pgSQL-kieli pitää asentaa tietokantaan $1",
+ "config-pg-no-create-privs": "Määrittelemälläsi tilillä ei ole riittävästi oikeuksia luoda tiliä.",
+ "config-pg-not-in-role": "Määrittelemäsi web-käyttäjän tili on jo olemassa.\nMäärittelemälläsi käyttäjätilillä ei ole pääkäyttäjäoikeuksia eikä se toimi web-käyttäjän roolissa. Käyttäjätili ei pysty luomaan tarvittavia objekteja.\n\nMediaWiki vaatii, että web-käyttäjän pitää pystyä hallitsemaan tauluja. Anna toinen web-käyttäjätunnus tai klikkaa \"takaisin\" ja määrittele käyttäjätunnus, joka toimii asennuksessa.",
+ "config-install-user": "Luodaan tietokannalle käyttäjää",
+ "config-install-user-alreadyexists": "Käyttäjä $1 on jo olemassa",
+ "config-install-user-create-failed": "Käyttäjän \"$1\" luonti epäonnistui: $2",
+ "config-install-user-grant-failed": "Käyttöoikeuksien myöntäminen käyttäjälle \"$1\" epäonnistui: $2",
+ "config-install-user-missing": "Määriteltyä käyttäjää \"$1\" ei ole olemassa.",
+ "config-install-user-missing-create": "Määriteltyä käyttäjää \"$1\" ei ole olemassa.\nValitse alapuolelta \"lisää tili\" jos haluat että se luodaan.",
+ "config-install-tables": "Luodaan tauluja",
+ "config-install-tables-exist": "<strong>Varoitus:</strong> MediaWiki taulut ovat jo olemassa.\nOhitetaan taulujen luonti.",
+ "config-install-tables-failed": "<strong>Virhe:</strong> Taulujen luominen epäonnistui seuraavaan virheen takia: $1",
+ "config-install-interwiki": "Luodaan oletustaulua interwikille",
+ "config-install-interwiki-list": "Tiedostoa <code>interwiki.list</code> ei voitu lukea.",
+ "config-install-interwiki-exists": "<strong>Varoitus:</strong> interwiki-taulussa on jo tietueita, ohitetaan oletuslista.",
+ "config-install-stats": "Alustetaan tilastoja",
+ "config-install-keys": "Muodostetaan salausavaimia",
+ "config-insecure-keys": "<strong>Varoitus:</strong> Asennuksen aikana {{PLURAL:$2|luotu turva-avain|luodut turva-avaimet}} ($1) {{PLURAL:$2|ei|eivät}} ole täysin {{PLURAL:$2|turvallinen|turvallisia}}. Harkitse {{PLURAL:$2|sen|niiden}} muuttamista manuaalisesti.",
+ "config-install-updates": "Estä tarpeettomien päivitysten asennus",
+ "config-install-sysop": "Luodaan ylläpitäjän tiliä",
+ "config-install-subscribe-fail": "Liittyminen mediawiki-announce listalle epäonnistui: $1",
+ "config-install-subscribe-notpossible": "cURL-ohjelmaa ei ole asennettu eikä <code>allow_url_fopen</code> ole saatavilla.",
+ "config-install-mainpage": "Luodaan etusivu oletussisällöllä",
+ "config-install-mainpage-exists": "Etusivu on jo olemassa, ohitetaan",
+ "config-install-extension-tables": "Luodaan tauluja käyttöönotetuille laajuennuksille",
+ "config-install-mainpage-failed": "Etusivun lisääminen ei onnistunut: $1",
+ "config-install-done": "<strong>Onnittelut!</strong>\nOlet asentanut MediaWikin.\n\nAsennusohjelma on luonut <code>LocalSettings.php</code> -tiedoston.\nSiinä on kaikki MediaWikin asetukset.\n\nLataa tiedosto ja laita se MediaWikin asennushakemistoon (sama kuin missä on index.php). Lataamisen olisi pitänyt alkaa automaattisesti.\n\nMikäli latausta ei tarjottu tai keskeytit latauksen, käynnistä se uudestaan tästä linkistä:\n\n$3\n\n<strong>Huom:</strong> Mikäli et nyt lataa tiedostoa, luotu tiedosto ei ole saatavissa myöhemmin, jos poistut asennuksesta lataamatta sitä.\n\nKun olet laittanut tiedoston oikeaan paikkaan, voit <strong>[$2 mennä wikiisi]</strong>.",
+ "config-install-done-path": "<strong>Onnittelut!</strong>\nOlet asentanut MediaWikin.\n\nAsennusohjelma on luonut <code>LocalSettings.php</code> -tiedoston.\nSiinä on kaikki MediaWikin asetukset.\n\nLataa tiedosto ja laita se sijaintiin <code>$4</code>. Lataamisen olisi pitänyt alkaa automaattisesti.\n\nMikäli latausta ei tarjottu tai keskeytit latauksen, käynnistä se uudestaan tästä linkistä:\n\n$3\n\n<strong>Huom:</strong> Mikäli et nyt lataa tiedostoa, luotu tiedosto ei ole saatavissa myöhemmin, jos poistut asennuksesta lataamatta sitä.\n\nKun olet laittanut tiedoston oikeaan paikkaan, voit <strong>[$2 mennä wikiisi]</strong>.",
+ "config-download-localsettings": "Lataa <code>LocalSettings.php</code>",
+ "config-help": "ohje",
+ "config-help-tooltip": "Klikkaa laajentaaksesi",
+ "config-nofile": "Tiedostoa \"$1\" ei löytynyt. Onko se poistettu?",
+ "config-extension-link": "Tiesitkö että wiki tukee [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions laajennuksia]?\n\nLaajennuksia voi hakea myös [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category luokittain].",
+ "mainpagetext": "<strong>MediaWiki on onnistuneesti asennettu.</strong>",
+ "mainpagedocfooter": "Lisätietoja wiki-ohjelmiston käytöstä on [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents käyttöoppaassa].\n\n=== Aloittaminen ===\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Asetusten teko-ohjeita]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWikin FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Sähköpostilista, jolla tiedotetaan MediaWikin uusista versioista]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Käännä MediaWikiä kielellesi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Katso, kuinka torjua spämmiä wikissäsi]\n\n=== Asetukset ===\n\nTarkista, että alla olevat taivutusmuodot ovat oikein. Jos eivät, tee tarvittavat muutokset tiedostoon LocalSettings.php seuraavasti:\n $wgGrammarForms['fi']['genitive']['{{SITENAME}}'] = '...';\n $wgGrammarForms['fi']['partitive']['{{SITENAME}}'] = '...';\n $wgGrammarForms['fi']['elative']['{{SITENAME}}'] = '...';\n $wgGrammarForms['fi']['inessive']['{{SITENAME}}'] = '...';\n $wgGrammarForms['fi']['illative']['{{SITENAME}}'] = '...';\nTaivutusmuodot: {{GRAMMAR:genitive|{{SITENAME}}}} (yön) – {{GRAMMAR:partitive|{{SITENAME}}}} (yötä) – {{GRAMMAR:elative|{{SITENAME}}}} (yöstä) – {{GRAMMAR:inessive|{{SITENAME}}}} (yössä) – {{GRAMMAR:illative|{{SITENAME}}}} (yöhön)."
+}
diff --git a/www/wiki/includes/installer/i18n/fo.json b/www/wiki/includes/installer/i18n/fo.json
new file mode 100644
index 00000000..e907dc2f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/fo.json
@@ -0,0 +1,53 @@
+{
+ "@metadata": {
+ "authors": [
+ "EileenSanda"
+ ]
+ },
+ "config-desc": "Innstallasjónsforrit til MediaWiki",
+ "config-title": "Innstallering av MediaWiki $1",
+ "config-information": "Kunning",
+ "config-localsettings-upgrade": "Ein <code>LocalSettings.php</code> fíla er funnin.\nFyri at uppstiga hesa innstallasjón, vinarliga skriva hetta virði <code>$wgUpgradeKey</code> í teigin niðanfyri.\nTú finnur tað í <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Ein <code>LocalSettings.php</code> fíla er funnin.\nFyri at uppstiga hesa innstallasjónina, vinarliga koyr <code>update.php</code> ístaðin",
+ "config-localsettings-key": "Uppstiganarlykil:",
+ "config-localsettings-badkey": "Lykilin ið tú skrivaði er skeivur.",
+ "config-upgrade-key-missing": "Ein núverandi installasjón av MediaWiki er funnin.\nFyri at uppstiga hesa installasjónina, vinarliga set hesa linjuna niðast í tinari <code>LocalSettings.php</code>-fílu:\n\n$1",
+ "config-your-language": "Títt mál:",
+ "config-your-language-help": "Vel eitt mál, sum tú ynskir at nýta meðan tú installerar.",
+ "config-wiki-language": "Wikimál:",
+ "config-wiki-language-help": "Vel tað málið, ið wiki'in fyrst og fremst verður skrivað á.",
+ "config-back": "← Aftur",
+ "config-continue": "Halt fram →",
+ "config-page-language": "Mál",
+ "config-page-welcome": "Vælkomin til MediaWiki!",
+ "config-page-dbconnect": "Fá samband við dátugrunnin",
+ "config-page-upgrade": "Dagfør verandi installasjón",
+ "config-page-dbsettings": "Innstillingar fyri dátugrunnin",
+ "config-page-name": "Navn",
+ "config-page-options": "Møguleikar",
+ "config-page-install": "Innstallera",
+ "config-page-complete": "Liðugt!",
+ "config-page-restart": "Byrja umaftur at installera",
+ "config-page-readme": "Les meg",
+ "config-page-copying": "Avritan",
+ "config-page-upgradedoc": "Dagføring/uppgradering",
+ "config-page-existingwiki": "Verandi wiki",
+ "config-help-restart": "Ynskir tú at sletta øll goymd dáta sum tú hevur skrivað og byrja umaftur at installera?",
+ "config-restart": "Ja, byrja umaftur",
+ "config-env-php": "PHP $1 er innstallerað.",
+ "config-env-hhvm": "HHVM $1 er lagt inn.",
+ "config-unicode-using-intl": "Brúkar [http://pecl.php.net/intl intl PECL ískoytið] til Unicode normalisering.",
+ "config-unicode-pure-php-warning": "<strong>Ávaring:</strong> [http://pecl.php.net/intl intl PECL ískoytið] er ikki tøkt at handfara Unicode normalisering, fellur aftur til eina spakuligari reina-PHP verkseting.\nUm tú koyrir eina netsíðu við høgari ferðslu, so eigur tú at lesa eitt sindur um [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalisering].",
+ "config-unicode-update-warning": "<strong>Ávaring:</strong> Tann innlagda versjónin av Unicode normalisering wrapper nýtir eina eldri versjón av [http://site.icu-project.org/ bókasavninum hjá ICU verkætlanini].\nTú eigur at [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations fremja uppstigning] um tú stúrir fyri at nýta Unicode.",
+ "config-diff3-bad": "GNU diff3 ikki funnið.",
+ "config-git": "Fann Git version control forritið: <code>$1</code>.",
+ "config-git-bad": "Git version control forritið varð ikki funnið.",
+ "config-imagemagick": "Fann ImageMagick: <code>$1</code>.\nTað at velja smámynd verður gjørt virkið um tú aktiverar møgulleikan at leggja myndir út.",
+ "config-using-server": "Brúkar servaranavnið \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Nýtir servara URL \"<nowiki>$1$2</nowiki>\".",
+ "config-db-type": "Slag av dátugrunni:",
+ "config-db-host": "Dátugrunn vertur:",
+ "config-db-username": "Dátugrunn brúkaranavn:",
+ "config-db-password": "Dátugrunn loyniorð:",
+ "mainpagetext": "'''Innlegging av Wiki-ritbúnaði væleydnað.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/fr.json b/www/wiki/includes/installer/i18n/fr.json
new file mode 100644
index 00000000..50521f69
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/fr.json
@@ -0,0 +1,345 @@
+{
+ "@metadata": {
+ "authors": [
+ "Aadri",
+ "Crochet.david",
+ "Gomoko",
+ "Grondin",
+ "Guillom",
+ "Hashar",
+ "IAlex",
+ "Jean-Frédéric",
+ "McDutchie",
+ "Peter17",
+ "Reedy",
+ "Sherbrooke",
+ "Urhixidur",
+ "Verdy p",
+ "Wyz",
+ "Yumeki",
+ "아라",
+ "Maxim21",
+ "Wladek92",
+ "Scoopfinder",
+ "Seb35",
+ "Linedwell",
+ "Orlodrim",
+ "Cl3m3n7",
+ "C13m3n7",
+ "The RedBurn",
+ "Trial",
+ "Tinss"
+ ]
+ },
+ "config-desc": "Le programme d’installation de MediaWiki",
+ "config-title": "Installation de MediaWiki $1",
+ "config-information": "Informations",
+ "config-localsettings-upgrade": "Un fichier <code>LocalSettings.php</code> a été détecté.\nPour mettre à jour cette installation, veuillez saisir la valeur de <code>$wgUpgradeKey</code> dans le champ ci-dessous.\nVous la trouverez dans <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Un fichier <code>LocalSettings.php</code> a été détecté.\nPour mettre à niveau cette installation, veuillez exécuter <code>update.php</code> à la place",
+ "config-localsettings-key": "Clé de mise à jour :",
+ "config-localsettings-badkey": "La clé de mise à jour que vous avez fournie n’est pas correcte.",
+ "config-upgrade-key-missing": "Une installation existante de MediaWiki a été détectée.\n\nPour mettre à jour cette installation, veuillez ajouter la ligne suivante à la fin de votre fichier <code>LocalSettings.php</code>\n\n$1",
+ "config-localsettings-incomplete": "Le fichier <code>LocalSettings.php</code> existant semble être incomplet.\nLa variable $1 n’est pas définie.\nVeuillez modifier <code>LocalSettings.php</code> de sorte que cette variable soit définie, puis cliquer sur « {{int:Config-continue}} ».",
+ "config-localsettings-connection-error": "Une erreur est survenue lors de la connexion à la base de données en utilisant la configuration spécifiée dans <code>LocalSettings.php</code>. Veuillez corriger cette configuration puis réessayer.\n\n$1",
+ "config-session-error": "Erreur lors du démarrage de la session : $1",
+ "config-session-expired": "Les données de votre session semblent avoir expiré.\nLes sessions sont configurées pour une durée de $1.\nVous pouvez l'augmenter en configurant <code>session.gc_maxlifetime</code> dans le fichier php.ini.\nRedémarrer le processus d'installation.",
+ "config-no-session": "Les données de votre session ont été perdues !\nVérifiez votre fichier php.ini et assurez-vous que <code>session.save_path</code> contient le chemin d’un répertoire approprié.",
+ "config-your-language": "Votre langue :",
+ "config-your-language-help": "Sélectionnez la langue à utiliser pendant le processus d'installation.",
+ "config-wiki-language": "Langue du wiki :",
+ "config-wiki-language-help": "Sélectionner la langue dans laquelle le wiki sera principalement écrit.",
+ "config-back": "← Retour",
+ "config-continue": "Continuer →",
+ "config-page-language": "Langue",
+ "config-page-welcome": "Bienvenue sur MediaWiki !",
+ "config-page-dbconnect": "Connexion à la base de données",
+ "config-page-upgrade": "Mettre à jour l’installation existante",
+ "config-page-dbsettings": "Paramètres de la base de données",
+ "config-page-name": "Nom",
+ "config-page-options": "Options",
+ "config-page-install": "Installer",
+ "config-page-complete": "Terminé !",
+ "config-page-restart": "Redémarrer l’installation",
+ "config-page-readme": "Lisez-moi",
+ "config-page-releasenotes": "Notes de version",
+ "config-page-copying": "Copie",
+ "config-page-upgradedoc": "Mise à jour",
+ "config-page-existingwiki": "Wiki existant",
+ "config-help-restart": "Voulez-vous effacer toutes les données enregistrées que vous avez entrées et relancer le processus d'installation ?",
+ "config-restart": "Oui, le relancer",
+ "config-welcome": "=== Vérifications liées à l’environnement ===\nDes vérifications de base vont maintenant être effectuées pour voir si cet environnement est adapté à l’installation de MediaWiki.\nRappelez-vous d’inclure ces informations si vous recherchez de l’aide sur la manière de terminer l’installation.",
+ "config-copyright": "=== Droit d'auteur et conditions ===\n\n$1\n\nCe programme est un logiciel libre : vous pouvez le redistribuer et/ou le modifier selon les termes de la Licence Publique Générale GNU telle que publiée par la Free Software Foundation (version 2 de la Licence, ou, à votre choix, toute version ultérieure).\n\nCe programme est distribué dans l’espoir qu’il sera utile, mais '''sans aucune garantie''' : sans même les garanties implicites de '''commerciabilité''' ou d’'''adéquation à un usage particulier'''.\nVoir la Licence Publique Générale GNU pour plus de détails.\n\nVous devriez avoir reçu <doclink href=Copying>une copie de la Licence Publique Générale GNU</doclink> avec ce programme ; dans le cas contraire, écrivez à la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ou [http://www.gnu.org/copyleft/gpl.html lisez-la en ligne].",
+ "config-sidebar": "* [https://www.mediawiki.org Accueil MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guide de l’utilisateur]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guide de l’administrateur]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Lisez-moi</doclink>\n* <doclink href=ReleaseNotes>Notes de publication</doclink>\n* <doclink href=Copying>Copie</doclink>\n* <doclink href=UpgradeDoc>Mise à jour</doclink>",
+ "config-env-good": "L’environnement a été vérifié.\nVous pouvez installer MediaWiki.",
+ "config-env-bad": "L’environnement a été vérifié.\nVous ne pouvez pas installer MediaWiki.",
+ "config-env-php": "PHP $1 est installé.",
+ "config-env-hhvm": "HHVM $1 est installé.",
+ "config-unicode-using-intl": "Utilisation de [http://pecl.php.net/intl l'extension PECL intl] pour la normalisation Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Attention :</strong> L’[http://pecl.php.net/intl extension PECL intl] n’est pas disponible pour la normalisation d’Unicode, retour à la version lente implémentée en PHP.\nSi votre site web sera très fréquenté, vous devriez lire ceci : [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ''Unicode normalization''] (en anglais).",
+ "config-unicode-update-warning": "<strong>Attention</strong>: La version installée du normalisateur Unicode utilise une ancienne version de la [http://site.icu-project.org/ bibliothèque logicielle ''ICU Project''].\nVous devriez faire une [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations mise à jour] si vous êtes concerné par l'usage d'Unicode.",
+ "config-no-db": "Impossible de trouver un pilote de base de données approprié ! Vous devez installer un pilote de base de données pour PHP. {{PLURAL:$2|Le type suivant|Les types suivants}} de bases de données {{PLURAL:$2|est reconnu|sont reconnus}} : $1.\n\nSi vous avez compilé PHP vous-même, reconfigurez-le avec un client de base de données actif, par exemple en utilisant <code>./configure --with-mysqli</code>. Si vous avez installé PHP depuis un paquet Debian ou Ubuntu, alors vous devrez aussi installer, par exemple, le paquet <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Attention''': vous avez SQLite $1, qui est inférieur à la version minimale requise $2. SQLite sera indisponible.",
+ "config-no-fts3": "'''Attention :''' SQLite est compilé sans le module [//sqlite.org/fts3.html FTS3] ; les fonctions de recherche ne seront pas disponibles sur ce moteur.",
+ "config-pcre-old": "'''Fatal :''' PCRE $1 ou ultérieur est nécessaire.\nVotre binaire PHP est lié avec PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/Plus d’information sur PCRE].",
+ "config-pcre-no-utf8": "<strong>Erreur fatale</strong>: le module PCRE de PHP semble être compilé sans la prise en charge de PCRE_UTF8.\nMédiaWiki a besoin de la gestion d’UTF-8 pour fonctionner correctement.",
+ "config-memory-raised": "Le paramètre <code>memory_limit</code> de PHP était à $1, porté à $2.",
+ "config-memory-bad": "'''Attention :''' Le paramètre <code>memory_limit</code> de PHP est à $1.\nCette valeur est probablement trop faible.\nIl est possible que l’installation échoue !",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] est installé",
+ "config-apc": "[http://www.php.net/apc APC] est installé",
+ "config-apcu": "[http://www.php.net/apcu APCu] est installé",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] est installé",
+ "config-no-cache-apcu": "<strong>Attention :</strong> Impossible de trouver [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] ou [http://www.iis.net/download/WinCacheForPhp WinCache].\nLa mise en cache d'objets n'est pas activée.",
+ "config-mod-security": "'''Attention''': Votre serveur web a [http://modsecurity.org/ mod_security] activé. S’il est mal configuré, cela peut poser des problèmes à MediaWiki ou à d’autres applications qui permettent aux utilisateurs de publier un contenu quelconque.\nReportez-vous à [http://modsecurity.org/documentation/ la documentation de mod_security] ou contactez le soutien de votre hébergeur si vous rencontrez des erreurs aléatoires.",
+ "config-diff3-bad": "GNU diff3 introuvable.",
+ "config-git": "Logiciel de contrôle de version Git trouvé : <code>$1</code>.",
+ "config-git-bad": "Logiciel de contrôle de version Git non trouvé.",
+ "config-imagemagick": "ImageMagick trouvé : <code>$1</code>.\nLa miniaturisation d'images sera activée si vous activez le téléversement de fichiers.",
+ "config-gd": "La bibliothèque graphique GD intégrée a été trouvée.\nLa miniaturisation d'images sera activée si vous activez le téléversement de fichiers.",
+ "config-no-scaling": "Impossible de trouver la bibliothèque GD ou ImageMagick.\nLa miniaturisation d'images sera désactivée.",
+ "config-no-uri": "'''Erreur :''' Impossible de déterminer l'URI du script actuel.\nInstallation interrompue.",
+ "config-no-cli-uri": "'''Attention''': Aucun <code>--scriptpath</code> n'a été spécifié; <code>$1</code> sera utilisé par défaut",
+ "config-using-server": "Utilisation du nom de serveur \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Utilisation de l'URL de serveur \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Attention :</strong> Votre répertoire par défaut pour les téléversements, <code>$1</code>, est vulnérable, car il peut exécuter n’importe quel script.\nBien que MediaWiki vérifie tous les fichiers téléversés, il est fortement recommandé de [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security fermer cette faille de sécurité] (texte en anglais) avant d’activer les téléversements.",
+ "config-no-cli-uploads-check": "'''Attention:''' Votre répertoire par défaut pour les imports(<code>$1</code>) n'est pas contrôlé concernant la vulnérabilité d'exécution de scripts arbitraires lors de l'installation CLI.",
+ "config-brokenlibxml": "Votre système utilise une combinaison de versions de PHP et libxml2 qui est boguée et peut engendrer des corruptions cachées de données dans MediaWiki et d’autres applications web.\nVeuillez mettre à jour votre système vers libxml2 2.7.3 ou plus récent ([https://bugs.php.net/bug.php?id=45996 bogue déposé auprès de PHP]).\nInstallation interrompue.",
+ "config-suhosin-max-value-length": "Suhosin est installé et limite la <code>longueur</code> du paramètre GET à $1 octets.\nLe composant ResourceLoader de MediaWiki va répondre en respectant cette limite, mais ses performances seront dégradées. Si possible, vous devriez définir <code>suhosin.get.max_value_length</code> à 1024 ou plus dans le fichier <code>php.ini</code>, et fixer <code>$wgResourceLoaderMaxQueryLength</code> à la même valeur dans <code>LocalSettings.php</code>.",
+ "config-using-32bit": "<strong>Attention:</strong> votre système semble utiliser les entiers sur 32 bits. Ceci n'est [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit pas recommandé].",
+ "config-db-type": "Type de base de données :",
+ "config-db-host": "Nom d’hôte de la base de données :",
+ "config-db-host-help": "Si votre serveur de base de données est sur un serveur différent, saisissez ici son nom d’hôte ou son adresse IP.\n\nSi vous utilisez un hébergement mutualisé, votre hébergeur doit vous avoir fourni le nom d’hôte correct dans sa documentation.\n\nSi vous installez sur un serveur Windows et utilisez MySQL, « localhost » peut ne pas fonctionner comme nom de serveur. S’il ne fonctionne pas, essayez « 127.0.0.1 » comme adresse IP locale.\n\nSi vous utilisez PostgreSQL, laissez ce champ vide pour vous connecter via un socket Unix.",
+ "config-db-host-oracle": "Nom TNS de la base de données :",
+ "config-db-host-oracle-help": "Entrez un [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm nom de connexion locale] valide ; un fichier tnsnames.ora doit être visible par cette installation.<br /> Si vous utilisez les bibliothèques clientes version 10g ou plus récentes, vous pouvez également utiliser la méthode de nommage [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifier ce wiki",
+ "config-db-name": "Nom de la base de données :",
+ "config-db-name-help": "Choisissez un nom qui identifie votre wiki.\nIl ne doit pas contenir d'espaces.\n\nSi vous utilisez un hébergement web partagé, votre hébergeur vous fournira un nom spécifique de base de données à utiliser, ou bien vous permet de créer des bases de données via un panneau de contrôle.",
+ "config-db-name-oracle": "Schéma de base de données :",
+ "config-db-account-oracle-warn": "Il existe trois scénarios pris en charge pour l’installation d'Oracle comme backend de base de données:\n\nSi vous souhaitez créer un compte de base de données dans le cadre de la procédure d’installation, veuillez fournir un compte avec le rôle de SYSDBA comme compte de base de données pour l’installation et spécifier les informations d’identification souhaitées pour le compte d'accès au web, sinon vous pouvez créer le compte d’accès web manuellement et fournir uniquement ce compte (si elle a exigé des autorisations nécessaires pour créer les objets de schéma) ou fournir deux comptes différents, l’un avec les privilèges pour créer et l'autre restreint, pour l’accès web.\n\nUn script pour créer un compte avec des privilèges requis peut être trouvé dans le répertoire « entretien/oracle/ » de cette installation. N’oubliez pas que le fait de l’utilisation d’un compte limité désactive toutes les fonctionnalités d’entretien avec le compte par défaut.",
+ "config-db-install-account": "Compte d'utilisateur pour l'installation",
+ "config-db-username": "Nom d’utilisateur de la base de données :",
+ "config-db-password": "Mot de passe de la base de données :",
+ "config-db-install-username": "Entrez le nom d’utilisateur qui sera utilisé pour se connecter à la base de données pendant le processus d'installation. Il ne s’agit pas du nom d’utilisateur du compte MediaWiki, mais du nom d’utilisateur pour votre base de données.",
+ "config-db-install-password": "Entrez le mot de passe qui sera utilisé pour se connecter à la base de données pendant le processus d'installation. Il ne s’agit pas du mot de passe du compte MediaWiki, mais du mot de passe pour votre base de données.",
+ "config-db-install-help": "Entrez le nom d'utilisateur et le mot de passe qui seront utilisés pour se connecter à la base de données pendant le processus d'installation.",
+ "config-db-account-lock": "Utiliser le même nom d'utilisateur et le même mot de passe pendant le fonctionnement habituel",
+ "config-db-wiki-account": "Compte d'utilisateur pour le fonctionnement habituel",
+ "config-db-wiki-help": "Entrez le nom d'utilisateur et le mot de passe qui seront utilisés pour se connecter à la base de données pendant le fonctionnement habituel du wiki.\nSi le compte n'existe pas, et le compte d'installation dispose de privilèges suffisants, ce compte d'utilisateur sera créé avec les privilèges minimum requis pour faire fonctionner le wiki.",
+ "config-db-prefix": "Préfixe des tables de la base de données :",
+ "config-db-prefix-help": "Si vous avez besoin de partager une base de données entre plusieurs wikis, ou entre MediaWiki et une autre application Web, vous pouvez choisir d'ajouter un préfixe à tous les noms de table pour éviter les conflits.\nNe pas utiliser d'espaces.\n\nCe champ est généralement laissé vide.",
+ "config-mysql-old": "MySQL $1 ou version ultérieure est requis. Vous avez $2.",
+ "config-db-port": "Port de la base de données :",
+ "config-db-schema": "Schéma pour MediaWiki :",
+ "config-db-schema-help": "Ce schéma est généralement correct.\nNe le changez que si vous êtes sûr que c'est nécessaire.",
+ "config-pg-test-error": "Impossible de se connecter à la base de données '''$1''' : $2",
+ "config-sqlite-dir": "Dossier des données SQLite :",
+ "config-sqlite-dir-help": "SQLite stocke toutes les données dans un fichier unique.\n\nLe répertoire que vous fournissez doit être accessible en écriture par le serveur lors de l'installation.\n\nIl '''ne faut pas''' qu'il soit accessible via le web, c'est pourquoi il n'est pas à l'endroit où sont vos fichiers PHP.\n\nL'installateur écrira un fichier <code>.htaccess</code> en même temps, mais s'il y a échec, quelqu'un peut accéder à votre base de données.\nCela comprend les données des utilisateurs (adresses de courriel, mots de passe hachés) ainsi que des révisions supprimées et d'autres données confidentielles du wiki.\n\nEnvisagez de placer la base de données ailleurs, par exemple dans <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Espace de stockage (''tablespace'') par défaut :",
+ "config-oracle-temp-ts": "Espace de stockage (''tablespace'') temporaire :",
+ "config-type-mysql": "MySQL (ou compatible)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki prend en charge ces systèmes de bases de données :\n\n$1\n\nSi vous ne voyez pas le système de base de données que vous essayez d’utiliser ci-dessous, alors suivez les instructions ci-dessus (voir liens) pour activer la prise en charge.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] est le premier choix pour MediaWiki et est le mieux pris en charge. MediaWiki fonctionne aussi avec [{{int:version-db-mariadb-url}} MariaDB] et [{{int:version-db-percona-url}} Percona Server], qui sont compatibles avec MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Comment compiler PHP avec la prise en charge de MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] est un système de base de données populaire en ''source ouverte'' qui peut être une alternative à MySQL ([http://www.php.net/manual/en/pgsql.installation.php Comment compiler PHP avec la prise en charge de PostgreSQL]).",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] est un système de base de données léger bien pris en charge. ([http://www.php.net/manual/fr/pdo.installation.php Comment compiler PHP avec la prise en charge de SQLite], en utilisant PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] est un système commercial de gestion de base de données d’entreprise. ([http://www.php.net/manual/en/oci8.installation.php Comment compiler PHP avec la prise en charge d’OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] est une base de données commerciale d’entreprise pour Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Comment compiler PHP avec la prise en charge de SQLSRV])",
+ "config-header-mysql": "Paramètres de MySQL",
+ "config-header-postgres": "Paramètres de PostgreSQL",
+ "config-header-sqlite": "Paramètres de SQLite",
+ "config-header-oracle": "Paramètres d’Oracle",
+ "config-header-mssql": "Paramètres de Microsoft SQL Server",
+ "config-invalid-db-type": "Type de base de données non valide",
+ "config-missing-db-name": "Vous devez entrer une valeur pour « {{int:config-db-name}} ».",
+ "config-missing-db-host": "Vous devez entrer une valeur pour « {{int:config-db-host}} ».",
+ "config-missing-db-server-oracle": "Vous devez entrer une valeur pour « {{int:config-db-host-oracle}} ».",
+ "config-invalid-db-server-oracle": "Le nom TNS de la base de données (« $1 ») est invalide.\nUtilisez uniquement la chaîne \"TNS Name\" ou \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Méthodes de nommage Oracle])",
+ "config-invalid-db-name": "Nom de la base de données invalide (« $1 »).\nUtiliser seulement les lettres ASCII (a-z, A-Z), les chiffres (0-9), les caractères de soulignement (_) et les tirets (-).",
+ "config-invalid-db-prefix": "Préfixe de la base de données non valide « $1 ».\nUtiliser seulement les lettres ASCII (a-z, A-Z), les chiffres (0-9), les caractères de soulignement (_) et les tirets (-).",
+ "config-connection-error": "$1.\n\nVérifier le nom d’hôte, le nom d’utilisateur et le mot de passe ci-dessous puis réessayer.",
+ "config-invalid-schema": "Schéma invalide pour MediaWiki « $1 ».\nUtiliser seulement les lettres ASCII (a-z, A-Z), les chiffres (0-9) et les caractères de soulignement (_).",
+ "config-db-sys-create-oracle": "L'installateur ne reconnaît que le compte SYSDBA lors de la création d'un nouveau compte.",
+ "config-db-sys-user-exists-oracle": "Le compte « $1 » existe déjà. Seul SYSDBA peut être utilisé pour créer un nouveau compte.",
+ "config-postgres-old": "PostgreSQL $1 ou version ultérieure est requis. Vous avez $2.",
+ "config-mssql-old": "Microsoft SQL Server version $1 ou supérieur est requis. Vous avez la version $2.",
+ "config-sqlite-name-help": "Choisir un nom qui identifie votre wiki.\nNe pas utiliser d'espaces ni de traits d'union.\nIl sera utilisé pour le fichier de données SQLite.",
+ "config-sqlite-parent-unwritable-group": "Impossible de créer le répertoire de données <nowiki><code>$1</code></nowiki>, parce que le répertoire parent <nowiki><code>$2</code></nowiki> n'est pas accessible en écriture par le serveur Web.\n\nL'installateur a détecté sous quel nom d'utilisateur, le serveur web est actif.\nRendre le répertoire <nowiki><code>$3</code></nowiki> accessible en écriture pour continuer.\nSur un système UNIX/Linux, saisir :\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Impossible de créer le répertoire de données <nowiki><code>$1</code></nowiki>, parce que le répertoire parent <nowiki><code>$2</code></nowiki> n'est pas accessible en écriture par le serveur Web.\n\nL'installateur n'a pas pu déterminer le nom de l'utilisateur sous lequel le serveur s'exécute.\nRendre le répertoire <nowiki><code>$3</code></nowiki> globalement accessible en écriture pour continuer.\nSur un système UNIX/Linux, saisir :\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Erreur de création du répertoire de données « $1 ».\nVérifiez l'emplacement et essayez à nouveau.",
+ "config-sqlite-dir-unwritable": "Impossible d'écrire dans le répertoire « $1 ».\nChanger les permissions de sorte que le serveur puisse y écrire et essayez à nouveau.",
+ "config-sqlite-connection-error": "$1.\n\nVérifier le répertoire des données et le nom de la base de données ci-dessous et réessayer.",
+ "config-sqlite-readonly": "Le fichier <code>$1</code> n'est pas accessible en écriture.",
+ "config-sqlite-cant-create-db": "Impossible de créer le fichier de base de données <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP n’a pas trouvé la prise en charge FTS3, les tables sont restreintes.",
+ "config-can-upgrade": "Il y a des tables MediaWiki dans cette base de données.\nPour les mettre au niveau de MediaWiki $1, cliquez sur '''Continuer'''.",
+ "config-upgrade-done": "Mise à jour terminée.\n\nVous pouvez maintenant [$1 commencer à utiliser votre wiki].\n\nSi vous souhaitez régénérer votre fichier <code>LocalSettings.php</code>, cliquez sur le bouton ci-dessous.\nCeci '''n'est pas recommandé''' sauf si vous rencontrez des problèmes avec votre wiki.",
+ "config-upgrade-done-no-regenerate": "Mise à jour terminée.\n\nVous pouvez maintenant [$1 commencer à utiliser votre wiki].",
+ "config-regenerate": "Regénérer LocalSettings.php →",
+ "config-show-table-status": "Échec de la requête <code>SHOW TABLE STATUS</code> !",
+ "config-unknown-collation": "'''Attention:''' La base de données utilise un classement alphabétique (''collation'') inconnu.",
+ "config-db-web-account": "Compte de la base de données pour l'accès Web",
+ "config-db-web-help": "Sélectionnez le nom d'utilisateur et le mot de passe que le serveur web utilisera pour se connecter au serveur de base de données pendant le fonctionnement habituel du wiki.",
+ "config-db-web-account-same": "Utilisez le même compte que pour l'installation",
+ "config-db-web-create": "Créez le compte s'il n'existe pas déjà",
+ "config-db-web-no-create-privs": "Le compte que vous avez spécifié pour l'installation n'a pas de privilèges suffisants pour créer un compte.\nLe compte que vous spécifiez ici doit déjà exister.",
+ "config-mysql-engine": "Moteur de stockage :",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong> Avertissement </strong>: vous avez sélectionné MyISAM comme moteur de stockage pour MySQL, ce qui n’est pas recommandé pour une utilisation avec MediaWiki, parce que :\n * il prend à peine en charge la simultanéité en raison de verrouillage de table\n * il est plus sujet à la corruption que les autres moteurs\n * le code de base MediaWiki ne gère pas toujours MyISAM comme il se doit\n\nSi votre installation MySQL prenden charge InnoDB, il est fortement recommandé que vous le choisissiez plutôt. \nSi votre installation MySQL ne prend pas en charge les tables InnoDB, il est peut-être temps de faire une mise à niveau.",
+ "config-mysql-only-myisam-dep": "<strong>Attention :</strong> MyISAM est le seul moteur de stockage disponible pour MySQL sur cette machine, et cela n’est pas recommandé pour une utilisation avec MédiaWiki, car :\n* il prend très peu en charge les accès concurrents à cause du verrouillage des tables\n* il est plus sujet à corruption que les autres moteurs\n* le code de base de MédiaWiki ne gère pas toujours MyISAM comme il faudrait\n\nVotre installation MySQL ne prend pas en charge InnoDB ; il est peut-être temps de la mettre à jour.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> est presque toujours la meilleure option, car il prend bien en charge les accès concurrents.\n\n<strong>MyISAM</strong> peut être plus rapide dans les installations monoposte ou en lecture seule.\nLes bases de données MyISAM ont tendance à se corrompre plus souvent que les bases d’InnoDB.",
+ "config-mysql-charset": "Jeu de caractères de la base de données :",
+ "config-mysql-binary": "Binaire",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "En <strong>mode binaire</strong>, MediaWiki stocke le texte au format UTF-8 dans la base de données dans des champs binaires.\nC'est plus efficace que le ''UTF-8 mode'' de MySQL, et vous permet d'utiliser toute la gamme des caractères Unicode.\n\nEn <strong>mode UTF-8</strong>, MySQL reconnaîtra le jeu de caractères dans lequel sont vos données et pourra les présenter et les convertir de manière appropriée, mais il ne vous laissera pas stocker les caractères au-dessus du [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes plan multilingue de base] (en anglais).",
+ "config-mssql-auth": "Type d’authentification :",
+ "config-mssql-install-auth": "Sélectionner le type d’authentification qui sera utilisé pour se connecter à la base de données pendant le processus d’installation.\nSi vous sélectionnez « {{int:config-mssql-windowsauth}} », les informations d’identification de l’utilisateur faisant tourner le serveur seront utilisées.",
+ "config-mssql-web-auth": "Sélectionner le type d’authentification que le serveur web utilisera pour se connecter au serveur de base de données lors des opérations habituelles du wiki.\nSi vous sélectionnez « {{int:config-mssql-windowsauth}} », les informations d’identification de l’utilisateur sous lequel tourne le serveur web seront utilisées.",
+ "config-mssql-sqlauth": "Authentification de SQL Server",
+ "config-mssql-windowsauth": "Authentification Windows",
+ "config-site-name": "Nom du wiki :",
+ "config-site-name-help": "Ceci apparaîtra dans la barre de titre du navigateur et en divers autres endroits.",
+ "config-site-name-blank": "Entrez un nom de site.",
+ "config-project-namespace": "Espace de noms du projet :",
+ "config-ns-generic": "Projet",
+ "config-ns-site-name": "Même nom que le wiki : $1",
+ "config-ns-other": "Autre (préciser)",
+ "config-ns-other-default": "MonWiki",
+ "config-project-namespace-help": "Suivant l’exemple de Wikipédia, plusieurs wikis gardent leurs pages de politique séparées de leurs pages de contenu, dans un '''espace de noms de niveau projet''' propre.\nTous les titres de page de cet espace de noms commence par un préfixe défini, que vous pouvez spécifier ici.\nTraditionnellement, ce préfixe est dérivé du nom du wiki, et ne peut contenir de caractères de ponctuation tels que « # » ou « : ».",
+ "config-ns-invalid": "L'espace de noms spécifié « <nowiki>$1</nowiki> » n'est pas valide.\nSpécifiez un espace de noms différent pour le projet.",
+ "config-ns-conflict": "L'espace de noms spécifié « <nowiki>$1</nowiki> » est en conflit avec un espace de noms par défaut de MediaWiki.\nChoisir un autre espace de noms pour le projet.",
+ "config-admin-box": "Compte administrateur",
+ "config-admin-name": "Votre nom d’utilisateur :",
+ "config-admin-password": "Mot de passe :",
+ "config-admin-password-confirm": "Saisir à nouveau le mot de passe :",
+ "config-admin-help": "Entrez votre nom d'utilisateur préféré ici, par exemple « Jean Blogue ».\nC'est le nom que vous utiliserez pour vous connecter au wiki.",
+ "config-admin-name-blank": "Entrez un nom d'administrateur.",
+ "config-admin-name-invalid": "Le nom d'utilisateur spécifié « <nowiki>$1</nowiki> » n'est pas valide.\nIndiquez un nom d'utilisateur différent.",
+ "config-admin-password-blank": "Entrez un mot de passe pour le compte administrateur.",
+ "config-admin-password-mismatch": "Les deux mots de passe que vous avez saisis ne correspondent pas.",
+ "config-admin-email": "Adresse de courriel :",
+ "config-admin-email-help": "Entrez une adresse de courriel ici pour vous permettre de recevoir des courriels d'autres utilisateurs du wiki, réinitialiser votre mot de passe, et être informé des modifications apportées aux pages de votre liste de suivi. Vous pouvez laisser ce champ vide.",
+ "config-admin-error-user": "Erreur interne lors de la création d'un administrateur avec le nom « <nowiki>$1</nowiki> ».",
+ "config-admin-error-password": "Erreur interne lors de l'inscription d'un mot de passe pour l'administrateur « <nowiki>$1</nowiki> » : <pre>$2</pre>",
+ "config-admin-error-bademail": "Vous avez entré une adresse de courriel invalide",
+ "config-subscribe": "Abonnez-vous à la [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce liste d'annonce des nouvelles versions]",
+ "config-subscribe-help": "Il s'agit d'une liste de diffusion à faible volume utilisée servant à annoncer les nouvelles versions, y compris les versions améliorant la sécurité du logiciel.\nVous devriez y souscrire et mettre à jour votre version de MediaWiki lorsque de nouvelles versions sont publiées.",
+ "config-subscribe-noemail": "Vous avez essayé de vous abonner à la liste de diffusion des communiqués, sans fournir une adresse courriel ! S'il vous plaît, fournir une adresse électronique si vous souhaitez vous abonner à la liste de diffusion.",
+ "config-pingback": "Partager des données au sujet de cette installation avec les développeurs de MediaWiki.",
+ "config-pingback-help": "Si vous sélectionnez cette option, MediaWiki fera périodiquement un ping de https://www.mediawiki.org avec les données de base sur cette instance de MediaWiki. Ces données incluent, par exemple, le type de système, la version de PHP, ainsi que la base de données arrière choisie. La Fondation Wikimedia partage ces données avec les développeurs de MediaWiki pour aider à orienter les futurs efforts de développement. Les données suivantes concernant votre système seront envoyées : <pre>$1</pre>",
+ "config-almost-done": "Vous avez presque fini !\nVous pouvez passer la configuration restante et installer immédiatement le wiki.",
+ "config-optional-continue": "Me poser davantage de questions.",
+ "config-optional-skip": "J’en ai assez, installer simplement le wiki.",
+ "config-profile": "Profil des droits d’utilisateurs :",
+ "config-profile-wiki": "Wiki ouvert",
+ "config-profile-no-anon": "Création de compte requise",
+ "config-profile-fishbowl": "Éditeurs autorisés seulement",
+ "config-profile-private": "Wiki privé",
+ "config-profile-help": "Les wikis fonctionnent au mieux lorsque vous laissez un maximum de personnes les modifier.\nAvec MediaWiki, il est facile de vérifier les modifications récentes et de révoquer tout dommage créé par des utilisateurs débutants ou mal intentionnés.\n\nCependant, MediaWiki est utilisé dans bien d’autres cas et il n’est pas toujours facile de convaincre chacun des bénéfices de l’esprit wiki.\nVous avez donc le choix.\n\nLe modèle <strong>{{int:config-profile-wiki}}</strong> autorise toute personne à modifier, y compris sans s’identifier.\n<strong>{{int:config-profile-no-anon}}</strong> fournit plus de contrôle, mais peut rebuter les contributeurs occasionnels.\n\n<strong>{{int:config-profile-fishbowl}}</strong> autorise la modification par les utilisateurs approuvés mais le public peut toujours consulter les pages et leur historique.\n<strong>{{int:config-profile-private}}</strong> n’autorise que les utilisateurs approuvés à voir et modifier les pages.\n\nDes configurations de droits d’utilisateurs plus complexes sont disponibles après l’installation, voir la [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights page correspondante du manuel].",
+ "config-license": "Droits d'auteur et licence :",
+ "config-license-none": "Aucune licence en bas de page",
+ "config-license-cc-by-sa": "Creative Commons attribution partage à l'identique",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons paternité – non commercial – partage à l’identique",
+ "config-license-cc-0": "Creative Commons Zero (domaine public)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 ou ultérieure",
+ "config-license-pd": "Domaine public",
+ "config-license-cc-choose": "Sélectionner une licence Creative Commons personnalisée",
+ "config-license-help": "Beaucoup de wikis publics mettent l’ensemble des contributions sous une [http://freedomdefined.org/Definition/Fr licence libre].\nCela contribue à créer un sentiment d’appartenance à une communauté et encourage les contributions sur le long terme.\nCe n’est généralement pas nécessaire pour un wiki privé ou d’entreprise.\n\nSi vous souhaitez utiliser des textes de Wikipédia, et souhaitez que Wikipédia puisse réutiliser des textes copiés depuis votre wiki, vous devriez choisir <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipédia utilisait auparavant la Licence de Documentation Libre GNU (GFDL).\nC’est une licence valide, mais difficile à comprendre. \nIl est aussi difficile de réutiliser du contenu sous la licence GFDL.",
+ "config-email-settings": "Paramètres de courriel",
+ "config-enable-email": "Activer les courriels sortants",
+ "config-enable-email-help": "Si vous souhaitez utiliser le courriel, vous devez [http://www.php.net/manual/en/mail.configuration.php configurer des paramètres PHP] (texte en anglais).\nSi vous ne voulez pas du service de courriel, vous pouvez le désactiver ici.",
+ "config-email-user": "Activer les courriers électroniques d'utilisateur à utilisateur",
+ "config-email-user-help": "Permet à tous les utilisateurs d'envoyer des courriels à d'autres utilisateurs si cela est activé dans leurs préférences.",
+ "config-email-usertalk": "Activer la notification des pages de discussion des utilisateurs",
+ "config-email-usertalk-help": "Permet aux utilisateurs de recevoir une notification en cas de modification de leurs pages de discussion, si cela est activé dans leurs préférences.",
+ "config-email-watchlist": "Activer la notification de la liste de suivi",
+ "config-email-watchlist-help": "Permet aux utilisateurs de recevoir des notifications à propos des pages qu'ils ont en suivi (si cette préférence est activée).",
+ "config-email-auth": "Activer l'authentification par courriel",
+ "config-email-auth-help": "Si cette option est activée, les utilisateurs doivent confirmer leur adresse de courriel en utilisant l'hyperlien envoyé à chaque fois qu'ils la définissent ou la modifient.\nSeules les adresses authentifiées peuvent recevoir des courriels des autres utilisateurs ou lorsqu'il y a des notifications de modification.\nL'activation de cette option est '''recommandée''' pour les wikis publics en raison d'abus potentiels des fonctionnalités de courriels.",
+ "config-email-sender": "Adresse de courriel de retour :",
+ "config-email-sender-help": "Entrez l'adresse de courriel à utiliser comme adresse de retour des courriels sortant.\nLes courriels rejetés y seront envoyés.\nDe nombreux serveurs de courriels exigent au moins un [http://fr.wikipedia.org/wiki/Nom_de_domaine nom de domaine] valide.",
+ "config-upload-settings": "Téléversement des images et des fichiers",
+ "config-upload-enable": "Activer le téléversement des fichiers",
+ "config-upload-help": "Le téléversement de fichiers expose votre serveur à des risques de sécurité.\nPour plus d'informations, lire la section [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security ''Security''] du manuel d'installation (en anglais).\n\nPour autoriser le téléversement de fichiers, modifier les permissions du sous-répertoire <code>images</code> qui se situe sous le répertoire racine de MediaWiki de sorte à ce que le serveur web puisse écrire dedans.\nEnsuite, activez cette option.",
+ "config-upload-deleted": "Répertoire pour les fichiers supprimés :",
+ "config-upload-deleted-help": "Choisissez un répertoire qui servira à archiver les fichiers supprimés.\nIdéalement, il ne devrait pas être accessible depuis le web.",
+ "config-logo": "URL du logo :",
+ "config-logo-help": "L’habillage par défaut de MediaWiki comprend l’espace pour un logo de 135x160 pixels au-dessus de la barre de menu latérale.\nTéléversez une image de la taille appropriée, et entrez son URL ici.\n\nVous pouvez utiliser <code>$wgStylePath</code> ou <code>$wgScriptPath</code> si votre logo est relatif à ces chemins.\n\nSi vous ne voulez pas de logo, laissez cette case vide.",
+ "config-instantcommons": "Activer ''InstantCommons''",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons/fr InstantCommons] est un service qui permet d’utiliser les images, les sons et les autres médias disponibles sur le site [https://meta.wikimedia.org/wiki/Wikimedia_Commons/fr Wikimédia Commons].\nPour ce faire, il faut que MediaWiki accède à Internet.\n\nPour plus d’informations sur ce service, y compris les instructions sur la façon de le configurer pour d’autres wikis que Wikimedia Commons, consultez le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos manuel].",
+ "config-cc-error": "Le sélection d'une licence ''Creative Commons'' n'a donné aucun résultat.\nEntrez le nom de la licence manuellement.",
+ "config-cc-again": "Choisissez à nouveau...",
+ "config-cc-not-chosen": "Choisissez la licence ''Creative Commons'' que vous désirez et cliquez sur « proceed ».",
+ "config-advanced-settings": "Configuration avancée",
+ "config-cache-options": "Paramètres pour la mise en cache des objets:",
+ "config-cache-help": "La mise en cache des objets améliore la vitesse de MediaWiki en mettant en cache les données fréquemment utilisées.\nLes sites de taille moyenne à grande sont fortement encouragés à l'activer. Les petits sites y verront également des avantages.",
+ "config-cache-none": "Pas de mise en cache (aucune fonctionnalité n'a été supprimée, mais la vitesse peut changer sur les wikis importants)",
+ "config-cache-accel": "Mise en cache des objets PHP (APC, APCu, XCache ou WinCache)",
+ "config-cache-memcached": "Utiliser Memcached (nécessite une installation et une configuration supplémentaires)",
+ "config-memcached-servers": "serveurs pour Memcached :",
+ "config-memcached-help": "Liste des adresses IP à utiliser pour Memcached.\nUne par ligne, en indiquant le port à utiliser. Par exemple :\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Vous avez sélectionné Memcached comme type de cache, mais n'avez pas précisé de serveurs.",
+ "config-memcache-badip": "Vous avez entré une adresse IP invalide pour Memcached: $1.",
+ "config-memcache-noport": "Vous n'avez pas entré un port pour le serveur Memcached : $1.\nSi vous ne le connaissez pas, la valeur par défaut est 11211.",
+ "config-memcache-badport": "Les numéros de port de Memcached sont situés entre $1 et $2.",
+ "config-extensions": "Extensions",
+ "config-extensions-help": "Les extensions énumérées ci-dessus ont été détectées dans votre répertoire <code>./extensions</code>.\n\nElles peuvent nécessiter une configuration supplémentaire, mais vous pouvez les activer maintenant",
+ "config-skins": "Habillages",
+ "config-skins-help": "Les habillages listés ci-dessous ont été détectés dans votre répertoire <code>./skins</code>. Vous devez en activer au moins un, et choisir celui par défaut.",
+ "config-skins-use-as-default": "Utiliser cet habillage par défaut",
+ "config-skins-missing": "Aucun habillage trouvé ; MédiaWiki utilisera un habillage de secours jusqu’à ce que vous en installiez un approprié.",
+ "config-skins-must-enable-some": "Vous devez choisir au moins un habillage à activer.",
+ "config-skins-must-enable-default": "L’habillage choisi par défaut doit être activé.",
+ "config-install-alreadydone": "'''Attention''': Vous semblez avoir déjà installé MediaWiki et tentez de l'installer à nouveau.\nS'il vous plaît, allez à la page suivante.",
+ "config-install-begin": "En appuyant sur {{int:config-continue}}, vous commencerez l'installation de MediaWiki.\nSi vous voulez encore apporter des modifications, appuyez sur \"{{int:config-back}}\".",
+ "config-install-step-done": "terminé",
+ "config-install-step-failed": "échec",
+ "config-install-extensions": "Inclusion des extensions",
+ "config-install-database": "Création de la base de données",
+ "config-install-schema": "Création de schéma",
+ "config-install-pg-schema-not-exist": "Le schéma PostgreSQL n'existe pas",
+ "config-install-pg-schema-failed": "Échec lors de la création des tables.\nAssurez-vous que l'utilisateur « $1 » peut écrire selon le schéma « $2 ».",
+ "config-install-pg-commit": "Validation des modifications",
+ "config-install-pg-plpgsql": "Vérification du langage PL/pgSQL",
+ "config-pg-no-plpgsql": "Vous devez installer le langage PL/pgSQL dans la base de données $1",
+ "config-pg-no-create-privs": "Le compte que vous avez spécifié pour l'installation n'a pas suffisamment de privilèges pour créer un compte.",
+ "config-pg-not-in-role": "Le compte que vous avez spécifié pour l'utilisateur web existe déjà.\nLe compte que vous avez spécifié pour l'installation n'est pas un super-utilisateur et n'est pas un membre du rôle de l'internaute, il est donc incapable de créer des objets appartenant à l'utilisateur web.\n\nMediaWiki exige actuellement que les tables soient possédés par un utilisateur web. S'il vous plaît, spécifiez un autre nom de compte web, ou cliquez sur \"retour\" et spécifiez un utilisateur ayant les privilèges suffisants pour installer.",
+ "config-install-user": "Création d'un utilisateur de la base de données",
+ "config-install-user-alreadyexists": "L'utilisateur « $1 » existe déjà.",
+ "config-install-user-create-failed": "Échec lors de la création de l'utilisateur « $1 » : $2",
+ "config-install-user-grant-failed": "Échec lors de l'ajout de permissions à l'utilisateur « $1 » : $2",
+ "config-install-user-missing": "L'utilisateur \"$1\" n'existe pas.",
+ "config-install-user-missing-create": "L'utilisateur \"$1\" n'existe pas.\nS'il vous plaît, cocher \"Compte de créer\" dans la case ci-dessous si vous voulez le créer.",
+ "config-install-tables": "Création des tables",
+ "config-install-tables-exist": "'''Avertissement:''' Les tables MediaWiki semblent déjà exister.\nCréation omise.",
+ "config-install-tables-failed": "'''Erreur:''' échec lors de la création de table avec l'erreur suivante: $1",
+ "config-install-interwiki": "Remplissage par défaut de la table des interwikis",
+ "config-install-interwiki-list": "Impossible de lire le fichier <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Attention:''' La table des interwikis semble déjà contenir des entrées.\nLa liste par défaut ne sera pas inscrite.",
+ "config-install-stats": "Initialisation des statistiques",
+ "config-install-keys": "Génération de la clé secrète",
+ "config-insecure-keys": "'''Avertissement''' : {{PLURAL:$2|Une clé de sécurité générée ($1) pendant l'installation n'est pas complètement sécuritaire. Envisagez de la modifier manuellement.|Des clés de sécurité générées ($1) pendant l'installation ne sont pas complètement sécuritaires. Envisagez de les modifier manuellement.}}",
+ "config-install-updates": "Empêcher l’exécution des mises à jour inutiles",
+ "config-install-updates-failed": "<strong>Erreur :</strong> L’insertion de clés modifiées dans les tables a échoué avec l’erreur suivante : $1",
+ "config-install-sysop": "Création du compte administrateur",
+ "config-install-subscribe-fail": "Impossible de s'abonner à mediawiki-announce : $1",
+ "config-install-subscribe-notpossible": "cURL n’est pas installé et <code>allow_url_fopen</code> n’est pas disponible.",
+ "config-install-mainpage": "Création de la page principale avec un contenu par défaut",
+ "config-install-mainpage-exists": "La page principale existe déjà, ignoré",
+ "config-install-extension-tables": "Création de tables pour les extensions activées",
+ "config-install-mainpage-failed": "Impossible d’insérer la page principale : $1",
+ "config-install-done": "<strong>Félicitations!</strong>\nVous avez installé MediaWiki.\n\nLe programme d'installation a généré un fichier <code>LocalSettings.php</code>. Il contient tous les paramètres de votre configuration.\n\nVous devrez le télécharger et le mettre à la racine de votre installation wiki (dans le même répertoire que index.php). Le téléchargement devrait démarrer automatiquement.\n\nSi le téléchargement n'a pas été proposé, ou que vous l'avez annulé, vous pouvez redémarrer le téléchargement en cliquant ce lien :\n\n$3\n\n<strong>Note :</strong> Si vous ne le faites pas maintenant, ce fichier de configuration généré ne sera pas disponible plus tard si vous quittez l'installation sans le télécharger.\n\nLorsque c'est fait, vous pouvez <strong>[$2 accéder à votre wiki]</strong> .",
+ "config-install-done-path": "<strong>Félicitations !</strong>\nVous avez installé MédiaWiki.\n\nL’installeur a généré un fichier <code>LocalSettings.php</code>.\nIl contient toute votre configuration.\n\nVous devez le télécharger et le mettre dans <code>$4</code>. Le téléchargement devrait avoir démarré automatiquement.\n\nSi le téléchargement n’a pas été proposé ou si vous l’avez annulé, vous pouvez le redémarrer en cliquant sur le lien ci-dessous :\n\n$3\n\n<strong>Note :</strong> Si vous ne le faites pas maintenant, ce fichier de configuration généré ne sera plus disponible ultérieurement si vous quittez l’installation sans le télécharger.\n\nUne fois ceci fait, vous pouvez <strong>[$2 entrer dans votre wiki]</strong>.",
+ "config-download-localsettings": "Télécharger <code>LocalSettings.php</code>",
+ "config-help": "aide",
+ "config-help-tooltip": "cliquer pour agrandir",
+ "config-nofile": "Le fichier « $1 » est introuvable. A-t-il été supprimé ?",
+ "config-extension-link": "Saviez-vous que votre wiki prend en charge [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions des extensions] ?\n\nVous pouvez consulter les [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensions par catégorie] ou la [https://www.mediawiki.org/wiki/Extension_Matrix matrice des extensions] pour voir la liste complète des extensions.",
+ "config-skins-screenshots": "$1 (captures d’écran : $2)",
+ "config-screenshot": "Captures d’écrans",
+ "mainpagetext": "<strong>MediaWiki a été installé.</strong>",
+ "mainpagedocfooter": "Consultez le [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guide de l’utilisateur du contenu] pour plus d’informations sur l’utilisation de ce logiciel de wiki.\n\n== Pour démarrer ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste des paramètres de configuration]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/fr Questions courantes sur MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Liste de discussion sur les distributions de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Adaptez MediaWiki dans votre langue]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Apprendre comment lutter contre le pourriel dans votre wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/frc.json b/www/wiki/includes/installer/i18n/frc.json
new file mode 100644
index 00000000..d07b2e98
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/frc.json
@@ -0,0 +1,79 @@
+{
+ "@metadata": {
+ "authors": [
+ "Hangmanwa7id"
+ ]
+ },
+ "config-information": "Informations",
+ "config-localsettings-key": "Clef de mise à jour:",
+ "config-localsettings-badkey": "La clef que vous avez fournie est incorrecte.",
+ "config-your-language": "Ton langue:",
+ "config-wiki-language": "Langue du wiki:",
+ "config-back": "← Retour",
+ "config-continue": "Continuer →",
+ "config-page-language": "Langue",
+ "config-page-welcome": "Bienvenue en MediaWiki!",
+ "config-page-dbconnect": "Connexion à la base de données",
+ "config-page-upgrade": "Mettre à jour l’installation existante",
+ "config-page-dbsettings": "Paramètres de la base de données",
+ "config-page-name": "Nom",
+ "config-page-options": "Options",
+ "config-page-install": "Installer",
+ "config-page-complete": "Terminé!",
+ "config-page-readme": "Lisez-moi",
+ "config-page-releasenotes": "Notes de version",
+ "config-page-copying": "Copie",
+ "config-page-upgradedoc": "Mise à jour",
+ "config-page-existingwiki": "Wiki existant",
+ "config-restart": "Oui, le relancer",
+ "config-env-php": "PHP $1 est installé.",
+ "config-env-hhvm": "HHVM $1 est installé.",
+ "config-unicode-using-intl": "Utilisation de [http://pecl.php.net/intl l'extension PECL intl] pour la normalisation Unicode.",
+ "config-diff3-bad": "GNU diff3 introuvable.",
+ "config-db-username": "Nom d’useur de la base de données:",
+ "config-db-password": "Mot de passe de la base de données:",
+ "config-db-install-username": "Entrez le nom d’useur qui sera usé pour se connecter à la base de données pendant le processus d'installation. Il s’agit pas du nom d’useur du compte MediaWiki, mais du nom d’useur pour votre base de données.",
+ "config-db-install-password": "Entrez le mot de passe qui sera usé pour se connecter à la base de données pendant le processus d'installation. Il s’agit pas du mot de passe du compte MediaWiki, mais du mot de passe pour votre base de données.",
+ "config-db-wiki-account": "Compte d'useur pour le fonctionnement normal",
+ "config-db-wiki-help": "Entrez le nom d'useur et le mot de passe qui seront usés pour se connecter à la base de données pendant le fonctionnement normal du wiki.\nSi le compte existe pas, et le compte d'installation dispose de privilèges suffisants, ce compte d'useur sera créé avec les privilèges minimum requis pour faire fonctionner le wiki.",
+ "config-db-prefix": "Préfixe des tables de la base de données:",
+ "config-oracle-def-ts": "Espace de stockage (''tablespace'') par défaut:",
+ "config-oracle-temp-ts": "Espace de stockage (''tablespace'') temporaire:",
+ "config-type-mysql": "MySQL (ou compatible)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "Paramètres de MySQL",
+ "config-header-postgres": "Paramètres de PostgreSQL",
+ "config-header-sqlite": "Paramètres de SQLite",
+ "config-header-oracle": "Paramètres d’Oracle",
+ "config-header-mssql": "Paramètres de Microsoft SQL Server",
+ "config-invalid-db-type": "Type de base de données non valide",
+ "config-sqlite-name-help": "Choisir un nom qui identifie ton wiki.\nFait user pas ni d'espaces ni des traits d'union\nIl va user pour fichier de données SQLite.",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Binaire",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Nom du wiki:",
+ "config-ns-generic": "Projet",
+ "config-ns-other-default": "MonWiki",
+ "config-admin-name": "Ton nom d'useur:",
+ "config-admin-password": "Mot de passe:",
+ "config-admin-email": "Adresse d'email:",
+ "config-profile-wiki": "Wiki ouvert",
+ "config-email-settings": "Paramètres d'email",
+ "config-enable-email": "Activer les emails sortants",
+ "config-email-user": "Activer les emails d'useur à useur",
+ "config-email-watchlist": "Activer la notification de la liste de suivi",
+ "config-cc-again": "Choisissez à nouveau...",
+ "config-install-step-done": "fait",
+ "config-install-step-failed": "échoué",
+ "config-install-extensions": "Inclusion des extensions",
+ "config-install-database": "Création de la base de données",
+ "config-install-schema": "Création de schéma",
+ "config-install-pg-schema-not-exist": "Le schéma PostgreSQL existe pas",
+ "config-install-pg-schema-failed": "Échoué lors de la création des tables.\nAssurez-vous que l'useur \"$1\" peut écrire selon le schéma \"$2\".",
+ "config-install-pg-commit": "Validation des modifications",
+ "config-help": "aide",
+ "config-help-tooltip": "cliquer pour agrandir",
+ "mainpagetext": "'''Vous avez bien installé MediaWiki.'''",
+ "mainpagedocfooter": "Lisez la [https://meta.wikimedia.org/wiki/Help:Contents Guide des Useurs] pour apprendre à user le wiki software.\n\n== Pour Commencer ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Réglage]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki: Questions Souvent Posées]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki Liste à Malle]"
+}
diff --git a/www/wiki/includes/installer/i18n/frp.json b/www/wiki/includes/installer/i18n/frp.json
new file mode 100644
index 00000000..5a2edf9c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/frp.json
@@ -0,0 +1,148 @@
+{
+ "@metadata": {
+ "authors": [
+ "ChrisPtDe"
+ ]
+ },
+ "config-desc": "La programeria d’enstalacion de MediaWiki",
+ "config-title": "Enstalacion de MediaWiki $1",
+ "config-information": "Enformacions",
+ "config-localsettings-key": "Cllâf de misa a jorn :",
+ "config-session-error": "Èrror pendent l’emmodâ de la sèance : $1",
+ "config-your-language": "Voutra lengoua :",
+ "config-wiki-language": "Lengoua du vouiqui :",
+ "config-back": "← Retôrn",
+ "config-continue": "Continuar →",
+ "config-page-language": "Lengoua",
+ "config-page-welcome": "Benvegnua dessus MediaWiki !",
+ "config-page-dbconnect": "Sè branchiér a la bâsa de balyês",
+ "config-page-upgrade": "Betar a jorn l’enstalacion ègzistenta",
+ "config-page-dbsettings": "Paramètres de la bâsa de balyês",
+ "config-page-name": "Nom",
+ "config-page-options": "Chouèx",
+ "config-page-install": "Enstalar",
+ "config-page-complete": "Chavonâ !",
+ "config-page-restart": "Tornar emmodar l’enstalacion",
+ "config-page-readme": "Liéséd-mè",
+ "config-page-releasenotes": "Notes de publecacion",
+ "config-page-copying": "Copia",
+ "config-page-upgradedoc": "Misa a jorn",
+ "config-page-existingwiki": "Vouiqui ègzistent",
+ "config-env-php": "PHP $1 est enstalâ.",
+ "config-memory-raised": "Lo paramètre <code>memory_limit</code> de PHP ére a $1, portâ a $2.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] est enstalâ",
+ "config-apc": "[http://www.php.net/apc APC] est enstalâ",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] est enstalâ",
+ "config-diff3-bad": "GNU diff3 entrovâblo.",
+ "config-db-type": "Tipo de bâsa de balyês :",
+ "config-db-host": "Hôto de la bâsa de balyês :",
+ "config-db-host-oracle": "TNS de la bâsa de balyês :",
+ "config-db-wiki-settings": "Identifiar cél vouiqui",
+ "config-db-name": "Nom de la bâsa de balyês :",
+ "config-db-name-oracle": "Plan de bâsa de balyês :",
+ "config-db-install-account": "Compto usanciér por l’enstalacion",
+ "config-db-username": "Nom d’usanciér de la bâsa de balyês :",
+ "config-db-password": "Contresegno de la bâsa de balyês :",
+ "config-db-wiki-account": "Compto usanciér por l’opèracion normala",
+ "config-db-prefix": "Prèfixo de les trâbles de la bâsa de balyês :",
+ "config-mysql-old": "MySQL $1 ou ben ples novél est nècèssèro, vos avéd $2.",
+ "config-db-port": "Pôrt de la bâsa de balyês :",
+ "config-db-schema": "Plan por MediaWiki",
+ "config-pg-test-error": "Empossiblo de sè branchiér a la bâsa de donâs '''$1''' : $2",
+ "config-sqlite-dir": "Dossiér de les balyês SQLite :",
+ "config-oracle-def-ts": "Èspâço de stocâjo (''tablespace'') per dèfôt :",
+ "config-oracle-temp-ts": "Èspâço de stocâjo (''tablespace'') temporèro :",
+ "config-header-mysql": "Paramètres de MySQL",
+ "config-header-postgres": "Paramètres de PostgreSQL",
+ "config-header-sqlite": "Paramètres de SQLite",
+ "config-header-oracle": "Paramètres d’Oracle",
+ "config-invalid-db-type": "Tipo de bâsa de balyês envalido",
+ "config-missing-db-name": "Vos dête buchiér una valor por « Nom de la bâsa de balyês »",
+ "config-missing-db-host": "Vos dête buchiér una valor por « Hôto de la bâsa de balyês »",
+ "config-missing-db-server-oracle": "Vos dête buchiér una valor por « TNS de la bâsa de balyês »",
+ "config-sqlite-readonly": "Lo fichiér <code>$1</code> est pas accèssiblo en ècritura.",
+ "config-regenerate": "Refâre LocalSettings.php →",
+ "config-show-table-status": "Falyita de la requéta <code>SHOW TABLE STATUS</code> !",
+ "config-db-web-account": "Compto de la bâsa de balyês por l’accès vouèbe",
+ "config-db-web-account-same": "Utilisâd lo mémo compto que por l’enstalacion",
+ "config-db-web-create": "Féte lo compto s’ègziste p’oncor",
+ "config-mysql-engine": "Motor de stocâjo :",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Juè de caractèros de la bâsa de balyês :",
+ "config-mysql-binary": "Binèro",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Nom du vouiqui :",
+ "config-site-name-blank": "Buchiéd un nom de seto.",
+ "config-project-namespace": "Èspâço de noms du projèt :",
+ "config-ns-generic": "Projèt",
+ "config-ns-site-name": "Mémo nom que lo vouiqui : $1",
+ "config-ns-other": "Ôtro (spècefiar)",
+ "config-ns-other-default": "MonVouiqui",
+ "config-admin-box": "Compto administrator",
+ "config-admin-name": "Voutron nom :",
+ "config-admin-password": "Contresegno :",
+ "config-admin-password-confirm": "Tornar buchiér lo contresegno :",
+ "config-admin-name-blank": "Buchiéd un nom d’administrator.",
+ "config-admin-password-blank": "Buchiéd un contresegno por lo compto administrator.",
+ "config-admin-email": "Adrèce èlèctronica :",
+ "config-optional-continue": "Mè posar més de quèstions.",
+ "config-profile": "Profil des drêts d’usanciér :",
+ "config-profile-wiki": "Vouiqui tradicionâl",
+ "config-profile-no-anon": "Crèacion de compto nècèssèra",
+ "config-profile-fishbowl": "Solament los èditors ôtorisâs",
+ "config-profile-private": "Vouiqui privâ",
+ "config-license": "Drêts d’ôtor et licence :",
+ "config-license-none": "Gins de licence d’avâl la pâge",
+ "config-license-cc-by-sa": "Creative Commons patèrnitât - partâjo a l’identico",
+ "config-license-cc-by": "Creative Commons patèrnitât",
+ "config-license-cc-by-nc-sa": "Creative Commons patèrnitât pas comèrciâla - partâjo a l’identico",
+ "config-license-cc-0": "Creative Commons Zero (domêno publico)",
+ "config-license-gfdl": "Licence de documentacion libra GNU 1.3 ou ben ples novèla",
+ "config-license-pd": "Domêno publico",
+ "config-license-cc-choose": "Chouèsir una licence Creative Commons pèrsonalisâ",
+ "config-email-settings": "Paramètres de mèssageria èlèctronica",
+ "config-enable-email": "Activar los mèssâjos que sôrtont",
+ "config-email-user": "Activar los mèssâjos d’usanciér a usanciér",
+ "config-email-usertalk": "Activar la notificacion de les pâges de discussion ux usanciérs",
+ "config-email-watchlist": "Activar la notificacion de la lista de survelyence",
+ "config-email-auth": "Activar l’ôtenticacion per mèssageria èlèctronica",
+ "config-email-sender": "Adrèce èlèctronica de retôrn :",
+ "config-upload-settings": "Tèlèchargement de les émâges et des fichiérs",
+ "config-upload-enable": "Activar lo tèlèchargement des fichiérs",
+ "config-upload-deleted": "Dossiér por los fichiérs suprimâs :",
+ "config-logo": "URL du logô :",
+ "config-instantcommons": "Activar Instant Commons",
+ "config-cc-again": "Tornâd chouèsir...",
+ "config-advanced-settings": "Configuracion avanciê",
+ "config-cache-options": "Paramètres por la misa en cache de les chouses :",
+ "config-cache-accel": "Misa en cache de les chouses PHP (APC, XCache ou ben WinCache)",
+ "config-memcached-servers": "Sèrvors por memcached :",
+ "config-extensions": "Èxtensions",
+ "config-install-step-done": "fêt",
+ "config-install-step-failed": "falyita",
+ "config-install-extensions": "Encllusion de les èxtensions",
+ "config-install-database": "Crèacion de la bâsa de balyês",
+ "config-install-schema": "Crèacion de plan",
+ "config-install-pg-schema-not-exist": "Lo plan PostgreSQL ègziste pas",
+ "config-install-pg-commit": "Validacion des changements",
+ "config-install-pg-plpgsql": "Contrôlo du lengâjo PL/pgSQL",
+ "config-install-user": "Crèacion d’un usanciér de la bâsa de balyês",
+ "config-install-user-alreadyexists": "L’usanciér « $1 » ègziste ja",
+ "config-install-user-create-failed": "Falyita pendent la crèacion de l’usanciér « $1 » : $2",
+ "config-install-user-grant-failed": "Falyita pendent l’aponsa de pèrmissions a l’usanciér « $1 » : $2",
+ "config-install-tables": "Crèacion de les trâbles",
+ "config-install-interwiki": "Remplissâjo per dèfôt de la trâbla des entèrvouiquis",
+ "config-install-interwiki-list": "Empossiblo de trovar lo fichiér <code>interwiki.list</code>.",
+ "config-install-stats": "Inicialisacion de les statistiques",
+ "config-install-keys": "G·ènèracion de les cllâfs secrètes",
+ "config-install-sysop": "Crèacion du compto administrator",
+ "config-install-subscribe-fail": "Empossiblo de s’abonar a mediawiki-announce : $1",
+ "config-install-mainpage": "Crèacion de la pâge principâla avouéc un contegnu per dèfôt",
+ "config-install-extension-tables": "Crèacion de trâbles por les èxtensions activâs",
+ "config-install-mainpage-failed": "Empossiblo d’entrebetar la pâge principâla : $1",
+ "config-download-localsettings": "Tèlèchargiér <code>LocalSettings.php</code>",
+ "config-help": "éde",
+ "mainpagetext": "'''MediaWiki at étâ enstalâ avouéc reusséta.'''",
+ "mainpagedocfooter": "Vêde lo [https://meta.wikimedia.org/wiki/Aide:Contenu guido d’usanciér] por més d’enformacions sur l’usâjo de la programeria vouiqui.\n\n== Emmodar avouéc MediaWiki ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista des paramètres de configuracion]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/fr FDQ sur MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de discussion sur les distribucions de MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/frr.json b/www/wiki/includes/installer/i18n/frr.json
new file mode 100644
index 00000000..95ccf540
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/frr.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Murma174",
+ "Pyt"
+ ]
+ },
+ "mainpagetext": "'''Det instaliarin faan MediaWiki hää loket.'''",
+ "mainpagedocfooter": "Consult the [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] for information on using the wiki software.\n\n== Getting started ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/fur.json b/www/wiki/includes/installer/i18n/fur.json
new file mode 100644
index 00000000..fa152d94
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/fur.json
@@ -0,0 +1,15 @@
+{
+ "@metadata": {
+ "authors": [
+ "Tocaibon"
+ ]
+ },
+ "config-desc": "Program di instalazion di Mediawiki",
+ "config-title": "Instalazion MediaWiki $1",
+ "config-information": "Informazions",
+ "config-localsettings-upgrade": "Al è stât cjatât un file <code>LocalSettings.php</code>.\nPar inzornâ cheste instalazion, si à di inserî il valôr di <code>$wgUpgradeKey</code> inte casele sot.\nIl valôr lu si cjate in <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Al è stât cjatât un file <code>LocalSettings.php</code>.\nPar inzornâ cheste instalazion, si à di eseguî <code>update.php</code>.",
+ "config-localsettings-key": "Clâf di inzornament.",
+ "config-localsettings-badkey": "La Clâf metude no jê juste",
+ "mainpagetext": "'''MediaWiki e je stade instalade cun sucès.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/fy.json b/www/wiki/includes/installer/i18n/fy.json
new file mode 100644
index 00000000..bb4fdfcc
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/fy.json
@@ -0,0 +1,21 @@
+{
+ "@metadata": {
+ "authors": [
+ "Seb35",
+ "Robin0van0der0vliet",
+ "Robin van der Vliet"
+ ]
+ },
+ "config-information": "Ynformaasje",
+ "config-back": "← Foarige",
+ "config-page-language": "Taal",
+ "config-page-name": "Namme",
+ "config-page-options": "Opsjes",
+ "config-mysql-binary": "Binêr",
+ "config-ns-generic": "Projekt",
+ "config-admin-password": "Wachtwurd:",
+ "config-admin-email": "E-mailadres:",
+ "config-help": "help",
+ "mainpagetext": "'''MediaWiki-program goed ynstallearre.'''",
+ "mainpagedocfooter": "Rieplachtsje de [https://meta.wikimedia.org/wiki/Help:Contents Ynhâldsopjefte hantlieding] foar ynformaasje oer it gebrûk fan 'e wikisoftware.\n\n== Mear help oer Mediawiki ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings List mei ynstellingen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Faak stelde fragen (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailinglist foar oankundigings fan nije ferzjes]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/ga.json b/www/wiki/includes/installer/i18n/ga.json
new file mode 100644
index 00000000..46fbf3f0
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ga.json
@@ -0,0 +1,13 @@
+{
+ "@metadata": {
+ "authors": [
+ "පසිඳු කාවින්ද"
+ ]
+ },
+ "config-page-language": "Teanga",
+ "config-page-name": "Ainm",
+ "config-admin-password": "D'fhocal faire:",
+ "config-help": "Cuidiú",
+ "mainpagetext": "'''D'éirigh le suiteáil MediaWiki.'''",
+ "mainpagedocfooter": "Féach ar [https://meta.wikimedia.org/wiki/MediaWiki_localisation doiciméid um conas an chomhéadán a athrú]\nagus an [https://meta.wikimedia.org/wiki/MediaWiki_User%27s_Guide Lámhleabhar úsáideora] chun cabhair úsáide agus fíoraíochta a fháil."
+}
diff --git a/www/wiki/includes/installer/i18n/gag.json b/www/wiki/includes/installer/i18n/gag.json
new file mode 100644
index 00000000..102865c5
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gag.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki başarılan kuruldu.'''",
+ "mainpagedocfooter": "Vikilän iş uurunda bilgi almaa için [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] sayfasına bakınız\n\n== Eni başlayanlar için ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/gan-hans.json b/www/wiki/includes/installer/i18n/gan-hans.json
new file mode 100644
index 00000000..0faa7ee8
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gan-hans.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''安装正MediaWiki喽。'''",
+ "mainpagedocfooter": "参看[https://meta.wikimedia.org/wiki/Help:Contents 用户指南]里头会话到啷用wiki软件\n\n== 开始使用 ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings MediaWiki 配置设定列表]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki 平常问题解答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki 发布email清单]"
+}
diff --git a/www/wiki/includes/installer/i18n/gan-hant.json b/www/wiki/includes/installer/i18n/gan-hant.json
new file mode 100644
index 00000000..7103eee3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gan-hant.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Symane"
+ ]
+ },
+ "mainpagetext": "'''安裝正MediaWiki哩。'''",
+ "mainpagedocfooter": "參看[https://meta.wikimedia.org/wiki/Help:Contents 用戶指南]裡頭會話到啷用wiki軟件\n\n== 開始使用 ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings MediaWiki 配置設定列表]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki 平常問題解答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki 發佈email清單]"
+}
diff --git a/www/wiki/includes/installer/i18n/gd.json b/www/wiki/includes/installer/i18n/gd.json
new file mode 100644
index 00000000..95f4fb24
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gd.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Akerbeltz"
+ ]
+ },
+ "mainpagetext": "'''Chaidh MediaWiki a stàladh gu soirbheachail.'''",
+ "mainpagedocfooter": "Cuir sùil air [https://meta.wikimedia.org/wiki/Help:Contents treòir nan cleachdaichean] airson fiosrachadh mu chleachdadh a' bhathar-bhog wiki.\n\n== Toiseach tòiseachaidh ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liosta suidheachadh nan roghainnean]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ CÀBHA MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Liosta puist nan sgaoilidhean MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Cuir do chànan air MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/gl.json b/www/wiki/includes/installer/i18n/gl.json
new file mode 100644
index 00000000..e6e4b674
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gl.json
@@ -0,0 +1,324 @@
+{
+ "@metadata": {
+ "authors": [
+ "Elisardojm",
+ "Toliño",
+ "아라",
+ "Vivaelcelta",
+ "Macofe",
+ "Banjo",
+ "Seb35"
+ ]
+ },
+ "config-desc": "O programa de instalación de MediaWiki",
+ "config-title": "Instalación de MediaWiki $1",
+ "config-information": "Información",
+ "config-localsettings-upgrade": "Detectouse un ficheiro <code>LocalSettings.php</code>.\nPara actualizar esta instalación, introduza o valor de <code>$wgUpgradeKey</code> na caixa.\nPode atopalo en <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Detectouse un ficheiro <code>LocalSettings.php</code>.\nPara actualizar esta instalación, execute <code>update.php</code>",
+ "config-localsettings-key": "Clave de actualización:",
+ "config-localsettings-badkey": "A clave de actualización dada é incorrecta.",
+ "config-upgrade-key-missing": "Detectouse unha instalación existente de MediaWiki.\nPara actualizar esta instalación, inclúa esta liña ao final do ficheiro <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Semella que o ficheiro <code>LocalSettings.php</code> existente está incompleto.\nA variable $1 non está establecida.\nModifique o ficheiro <code>LocalSettings.php</code> de xeito que a variable quede establecida e prema en \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Atopouse un erro ao conectar coa base de datos empregando a configuración especificada no ficheiro <code>LocalSettings.php</code>. Corrixa esta configuración e inténteo de novo.\n\n$1",
+ "config-session-error": "Erro ao iniciar a sesión: $1",
+ "config-session-expired": "Semella que os seus datos da sesión caducaron.\nAs sesións están configuradas para unha duración de $1.\nPode incrementar isto fixando <code>session.gc_maxlifetime</code> en php.ini.\nReinicie o proceso de instalación.",
+ "config-no-session": "Perdéronse os datos da súa sesión!\nComprobe o seu php.ini e asegúrese de que en <code>session.save_path</code> está definido un directorio correcto.",
+ "config-your-language": "A súa lingua:",
+ "config-your-language-help": "Seleccione a lingua que se empregará durante o proceso de instalación.",
+ "config-wiki-language": "Lingua do wiki:",
+ "config-wiki-language-help": "Seleccione a lingua que predominará no wiki.",
+ "config-back": "← Volver",
+ "config-continue": "Continuar →",
+ "config-page-language": "Lingua",
+ "config-page-welcome": "Benvido a MediaWiki!",
+ "config-page-dbconnect": "Conectarse á base de datos",
+ "config-page-upgrade": "Actualizar a instalación actual",
+ "config-page-dbsettings": "Configuración da base de datos",
+ "config-page-name": "Nome",
+ "config-page-options": "Opcións",
+ "config-page-install": "Instalar",
+ "config-page-complete": "Completo!",
+ "config-page-restart": "Reiniciar a instalación",
+ "config-page-readme": "Léame",
+ "config-page-releasenotes": "Notas de lanzamento",
+ "config-page-copying": "Copiar",
+ "config-page-upgradedoc": "Actualizar",
+ "config-page-existingwiki": "Wiki existente",
+ "config-help-restart": "Quere eliminar todos os datos gardados e reiniciar o proceso de instalación?",
+ "config-restart": "Si, reiniciala",
+ "config-welcome": "=== Comprobación da contorna ===\nCómpre realizar agora unhas comprobacións básicas para ver se a contorna é axeitada para a instalación de MediaWiki.\nLembre incluír esta información se necesita axuda para completar a instalación.",
+ "config-copyright": "=== Dereitos de autor e termos de uso ===\n\n$1\n\nEste programa é software libre; pode redistribuílo e/ou modificalo segundo os termos da licenza pública xeral GNU publicada pola Free Software Foundation; versión 2 ou (na súa escolla) calquera outra posterior.\n\nEste programa distribúese coa esperanza de que poida ser útil, pero <strong>sen garantía ningunha</strong>; nin sequera a garantía implícita de <strong>comercialización</strong> ou <strong>adecuación a unha finalidade específica</strong>.\nOlle a licenza pública xeral GNU para obter máis detalles.\n\nDebería recibir <doclink href=Copying>unha copia da licenza pública xeral GNU</doclink> xunto ao programa; se non é así, escriba á Free Software Foundation, Inc., rúa Franklin, número 51, quinto andar, Boston, Massachusetts, 02110-1301, Estados Unidos de América ou [http://www.gnu.org/copyleft/gpl.html lea a licenza en liña].",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/gl Páxina principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guía de usuario]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guía de administrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Preguntas máis frecuentes]\n----\n* <doclink href=Readme>Léame</doclink>\n* <doclink href=ReleaseNotes>Notas de lanzamento</doclink>\n* <doclink href=Copying>Copia</doclink>\n* <doclink href=UpgradeDoc>Actualizacións</doclink>",
+ "config-env-good": "Rematou a comprobación da contorna.\nPode instalar MediaWiki.",
+ "config-env-bad": "Rematou a comprobación da contorna.\nNon pode instalar MediaWiki.",
+ "config-env-php": "Está instalado o PHP $1.",
+ "config-env-hhvm": "Está instalado o HHVM $1.",
+ "config-unicode-using-intl": "Usando a [http://pecl.php.net/intl extensión intl PECL] para a normalización Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Atención:</strong> A [http://pecl.php.net/intl extensión intl PECL] non está dispoñible para manexar a normalización Unicode; volvendo á execución lenta de PHP puro.\nSe o seu sitio posúe un alto tráfico de visitantes, debería ler un chisco sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalización Unicode].",
+ "config-unicode-update-warning": "<strong>Atención:</strong> A versión instalada da envoltura de normalización Unicode emprega unha versión vella da biblioteca [http://site.icu-project.org/ do proxecto ICU].\nDebería [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations actualizar] se o uso de Unicode é importante para vostede.",
+ "config-no-db": "Non se puido atopar un controlador axeitado para a base de datos! Necesita instalar un controlador de base de datos para PHP.\n{{PLURAL:$2|Acéptase o seguinte tipo|Acéptanse os seguintes tipos}} de base de datos: $1.\n\nSe compilou o PHP vostede mesmo, reconfigúreo activando un cliente de base de datos, por exemplo, usando <code>./configure --with-mysqli</code>.\nSe instalou o PHP desde un paquete Debian ou Ubuntu, entón tamén necesita instalar, por exemplo, o módulo <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Atención:</strong> Ten o SQLite $1, que é inferior á versión mínima necesaria: $2. O SQLite non estará dispoñible.",
+ "config-no-fts3": "<strong>Atención:</strong> O SQLite está compilado sen o [//sqlite.org/fts3.html módulo FTS3]; as características de procura non estarán dispoñibles nesta instalación.",
+ "config-pcre-old": "<strong>Erro fatal:</strong> Necesítase PCRE $1 ou posterior.\nO seu PHP binario está ligado con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Máis información].",
+ "config-pcre-no-utf8": "<strong>Erro fatal:</strong> Semella que o módulo PCRE do PHP foi compilado sen o soporte PCRE_UTF8.\nMediaWiki necesita soporte UTF-8 para funcionar correctamente.",
+ "config-memory-raised": "O parámetro <code>memory_limit</code> do PHP é $1. Aumentado a $2.",
+ "config-memory-bad": "<strong>Atención:<strong> O parámetro <code>memory_limit</code> do PHP é $1.\nProbablemente é un valor baixo de máis.\nA instalación pode fallar!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] está instalado",
+ "config-apc": "[http://www.php.net/apc APC] está instalado",
+ "config-apcu": "[http://www.php.net/apcu APCu] está instalado",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] está instalado",
+ "config-no-cache-apcu": "<strong>Advertencia:</strong> Non se puido atopar [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] ou [http://www.iis.net/download/WinCacheForPhp WinCache].\nA caché de obxectos non está activada.",
+ "config-mod-security": "<strong>Atención:</strong> O seu servidor web ten o [http://modsecurity.org/ mod_security] activado. Se estivese mal configurado, pode causar problemas a MediaWiki ou calquera outro software que permita aos usuarios publicar contidos arbitrarios.\nOlle a [http://modsecurity.org/documentation/ documentación do mod_security] ou póñase en contacto co soporte do seu servidor se atopa erros aleatorios.",
+ "config-diff3-bad": "GNU diff3 non se atopou.",
+ "config-git": "Atopouse o software de control da versión de Git: <code>$1</code>.",
+ "config-git-bad": "Non se atopou o software de control da versión de Git.",
+ "config-imagemagick": "ImageMagick atopado: <code>$1</code>.\nAs miniaturas de imaxes estarán dispoñibles se activa as cargas.",
+ "config-gd": "Atopouse a biblioteca gráfica GD integrada.\nAs miniaturas de imaxes estarán dispoñibles se activa as cargas.",
+ "config-no-scaling": "Non se puido atopar a biblioteca GD ou ImageMagick.\nAs miniaturas de imaxes estarán desactivadas.",
+ "config-no-uri": "<strong>Erro:</strong> Non se puido determinar o URI actual.\nInstalación abortada.",
+ "config-no-cli-uri": "<strong>Atención:</strong> Non se especificou ningún <code>--scriptpath</code>; por defecto, usarase: <code>$1</code>.",
+ "config-using-server": "Usando o nome do servidor \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Usando o URL do servidor \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Atención:</strong> O seu directorio por defecto para as cargas, <code>$1</code>, é vulnerable a execucións arbitrarias de escrituras.\nAínda que MediaWiki comproba todos os ficheiros cargados por se houbese ameazas de seguridade, é amplamente recomendable [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security pechar esta vulnerabilidade de seguridade] antes de activar as cargas.",
+ "config-no-cli-uploads-check": "<strong>Atención:</strong> Durante a instalación CLI, o seu directorio por defecto para as cargas, <code>$1</code>, non se comproba fronte a posibles vulnerabilidades de execucións arbitrarias de escrituras.",
+ "config-brokenlibxml": "O seu sistema ten unha combinación de versións de PHP e libxml2 que pode ser problemático e causar corrupción de datos en MediaWiki e outras aplicacións web.\nActualice o sistema á versión 2.7.3 ou posterior de libxml2 ([https://bugs.php.net/bug.php?id=45996 erro presentado co PHP]).\nInstalación abortada.",
+ "config-suhosin-max-value-length": "Suhosin está instalado e limita o parámetro GET <code>length</code> a $1 bytes.\nO compoñente ResourceLoader (xestor de recursos) de MediaWiki traballa neste límite, pero este prexudica o rendemento.\nSe é posible, debería establecer <code>suhosin.get.max_value_length</code> no valor 1024 ou superior en <code>php.ini</code> e establecer <code>$wgResourceLoaderMaxQueryLength</code> no mesmo valor en <code>LocalSettings.php</code>.",
+ "config-using-32bit": "<strong>Aviso:</strong> o teu sistema semella estar funcionando con números enteiros de 32 bits. Isto é algo [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit non recomendado].",
+ "config-db-type": "Tipo de base de datos:",
+ "config-db-host": "Servidor da base de datos:",
+ "config-db-host-help": "Se o servidor da súa base de datos está nun servidor diferente, escriba o nome do servidor ou o enderezo IP aquí.\n\nSe está usando un aloxamento web compartido, o seu provedor de hospedaxe debe darlle o nome de servidor correcto na súa documentación.\n\nSe está a realizar a instalación nun servidor de Windows con MySQL, o nome \"localhost\" pode non valer como servidor. Se non funcionase, inténteo con \"127.0.0.1\" como enderezo IP local.\n\nSe está usando PostgreSQL, deixe este campo en branco para facer a conexión a través do conectador Unix.",
+ "config-db-host-oracle": "TNS da base de datos:",
+ "config-db-host-oracle-help": "Insira un [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm nome de conexión local] válido; cómpre que haxa visible un ficheiro tnsnames.ora para esta instalación.<br />Se está a empregar bibliotecas cliente versión 10g ou máis recentes, tamén pode usar o método de atribución de nomes [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identificar o wiki",
+ "config-db-name": "Nome da base de datos:",
+ "config-db-name-help": "Escolla un nome que identifique o seu wiki.\nNon debe conter espazos.\n\nSe está usando un aloxamento web compartido, o seu provedor de hospedaxe daralle un nome específico para a base de datos ou deixaralle crear unha a través do panel de control.",
+ "config-db-name-oracle": "Esquema da base de datos:",
+ "config-db-account-oracle-warn": "Existen tres escenarios soportados para a instalación de Oracle como fin da base de datos:\n\nSe quere crear unha conta para a base de datos como parte do proceso de instalación, proporcione unha conta co papel SYSDBA e especifique as credenciais desexadas para a conta; senón pode crear a conta manualmente e dar só esa conta (se ten os permisos necesarios para crear os obxectos do esquema) ou fornecer dous contas diferentes, unha con privilexios de creación e outra restrinxida para o acceso á web.\n\nA escritura para crear unha conta cos privilexios necesarios atópase no directorio \"maintenance/oracle/\" desta instalación. Teña en conta que o emprego de contas restrinxidas desactivará todas as operacións de mantemento da conta predeterminada.",
+ "config-db-install-account": "Conta de usuario para a instalación",
+ "config-db-username": "Nome de usuario da base de datos:",
+ "config-db-password": "Contrasinal da base de datos:",
+ "config-db-install-username": "Escriba o nome de usuario que empregará para conectarse á base de datos durante o proceso de instalación. Este non é o nome de usuario da conta de MediaWiki, trátase do nome de usuario para a súa base de datos.",
+ "config-db-install-password": "Escriba o contrasinal que empregará para conectarse á base de datos durante o proceso de instalación. Este non é o contrasinal da conta de MediaWiki, trátase do contrasinal para a súa base de datos.",
+ "config-db-install-help": "Introduza o nome de usuario e contrasinal que se usará para conectar á base de datos durante o proceso de instalación.",
+ "config-db-account-lock": "Use o mesmo nome de usuario e contrasinal despois do proceso de instalación",
+ "config-db-wiki-account": "Conta de usuario para despois do proceso de instalación",
+ "config-db-wiki-help": "Introduza o nome de usuario e mais o contrasinal que se usarán para conectar á base de datos durante o funcionamento habitual do wiki.\nSe a conta non existe e a conta de instalación ten privilexios suficientes, esa conta de usuario será creada cos privilexios mínimos necesarios para o funcionamento do wiki.",
+ "config-db-prefix": "Prefixo das táboas da base de datos:",
+ "config-db-prefix-help": "Se necesita compartir unha base de datos entre varios wikis ou entre MediaWiki e outra aplicación web, pode optar por engadir un prefixo a todos os nomes da táboa para evitar conflitos.\nNon utilice espazos.\n\nO normal é que este campo quede baleiro.",
+ "config-mysql-old": "Necesítase MySQL $1 ou posterior. Vostede ten a versión $2.",
+ "config-db-port": "Porto da base de datos:",
+ "config-db-schema": "Esquema para MediaWiki",
+ "config-db-schema-help": "O normal é que este esquema sexa correcto.\nCámbieo soamente se sabe que é necesario.",
+ "config-pg-test-error": "Non se pode conectar coa base de datos <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Directorio de datos SQLite:",
+ "config-sqlite-dir-help": "SQLite recolle todos os datos nun ficheiro único.\n\nO servidor web debe ter permisos sobre o directorio para que poida escribir nel durante a instalación.\n\nAdemais, o servidor <strong>non</strong> debe ser accesible a través da web, motivo polo que non está no mesmo lugar ca os ficheiros PHP.\n\nAsemade, o programa de instalación escribirá un ficheiro <code>.htaccess</code>, pero se erra alguén pode obter acceso á súa base de datos.\nIsto inclúe datos de usuario (enderezos de correo electrónico, contrasinais codificados), así como revisións borradas e outros datos restrinxidos no wiki.\n\nConsidere poñer a base de datos nun só lugar, por exemplo en <code>/var/lib/mediawiki/oseuwiki</code>.",
+ "config-oracle-def-ts": "Espazo de táboas por defecto:",
+ "config-oracle-temp-ts": "Espazo de táboas temporal:",
+ "config-type-mysql": "MySQL (ou compatible)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki soporta os seguintes sistemas de bases de datos:\n\n$1\n\nSe non ve listado a continuación o sistema de base de datos que intenta usar, siga as instrucións ligadas enriba para activar o soporte.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] é o obxectivo principal para MediaWiki e está mellor soportado. MediaWiki tamén funciona con [{{int:version-db-mariadb-url}} MariaDB] e [{{int:version-db-percona-url}} Percona Server], que son compatibles con MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Como compilar PHP con compatibilidade MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] é un sistema de base de datos popular e de código aberto como alternativa a MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Como compilar PHP con compatibilidade PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] é un sistema de base de datos lixeiro moi ben soportado. ([http://www.php.net/manual/en/pdo.installation.php Como compilar o PHP con soporte SQLite], emprega PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] é un sistema comercial de xestión de base de datos de nivel empresarial. ([http://www.php.net/manual/en/oci8.installation.php Como compilar o PHP con compatibilidade OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] é un sistema comercial de xestión de base de datos de nivel empresarial para Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Como compilar o PHP con compatibilidade SQLSRV])",
+ "config-header-mysql": "Configuración do MySQL",
+ "config-header-postgres": "Configuración do PostgreSQL",
+ "config-header-sqlite": "Configuración do SQLite",
+ "config-header-oracle": "Configuración do Oracle",
+ "config-header-mssql": "Configuración de Microsoft SQL Server",
+ "config-invalid-db-type": "Tipo de base de datos incorrecto",
+ "config-missing-db-name": "Debe introducir un valor para \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Debe introducir un valor para \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Debe introducir un valor para \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "O TNS da base de datos, \"$1\", é incorrecto.\nUtilice só \"TNS Name\" ou unha cadea de texto \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm métodos de nomeamento de Oracle])",
+ "config-invalid-db-name": "O nome da base de datos, \"$1\", é incorrecto.\nSó pode conter letras ASCII (a-z, A-Z), números (0-9), guións baixos (_) e guións (-).",
+ "config-invalid-db-prefix": "O prefixo da base de datos, \"$1\", é incorrecto.\nSó pode conter letras ASCII (a-z, A-Z), números (0-9), guións baixos (_) e guións (-).",
+ "config-connection-error": "$1.\n\nComprobe o servidor, nome de usuario e contrasinal que hai a continuación e inténteo de novo.",
+ "config-invalid-schema": "O esquema de MediaWiki, \"$1\", é incorrecto.\nSó pode conter letras ASCII (a-z, A-Z), números (0-9) e guións baixos (_).",
+ "config-db-sys-create-oracle": "O programa de instalación soamente soporta o emprego de contas SYSDBA como método para crear unha nova conta.",
+ "config-db-sys-user-exists-oracle": "A conta de usuario \"$1\" xa existe. SYSDBA soamente se pode empregar para a creación dunha nova conta!",
+ "config-postgres-old": "Necesítase PostgreSQL $1 ou posterior. Vostede ten a versión $2.",
+ "config-mssql-old": "Necesítase Microsoft SQL Server $1 ou posterior. Vostede ten a versión $2.",
+ "config-sqlite-name-help": "Escolla un nome que identifique o seu wiki.\nNon utilice espazos ou guións.\nEste nome será utilizado para o ficheiro de datos SQLite.",
+ "config-sqlite-parent-unwritable-group": "Non se puido crear o directorio de datos <code><nowiki>$1</nowiki></code>, porque o servidor web non pode escribir no directorio pai <code><nowiki>$2</nowiki></code>.\n\nO programa de instalación determinou o usuario que executa o seu servidor web.\nPara continuar, faga que se poida escribir no directorio <code><nowiki>$3</nowiki></code>.\nNun sistema Unix/Linux cómpre realizar:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Non se puido crear o directorio de datos <code><nowiki>$1</nowiki></code>, porque o servidor web non pode escribir no directorio pai <code><nowiki>$2</nowiki></code>.\n\nO programa de instalación non puido determinar o usuario que executa o seu servidor web.\nPara continuar, faga que se poida escribir globalmente no directorio <code><nowiki>$3</nowiki></code>.\nNun sistema Unix/Linux cómpre realizar:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Erro ao crear o directorio de datos \"$1\".\nComprobe a localización e inténteo de novo.",
+ "config-sqlite-dir-unwritable": "Non se puido escribir o directorio \"$1\".\nCambie os permisos para que o servidor poida escribir nel e inténteo de novo.",
+ "config-sqlite-connection-error": "$1.\n\nComprobe o directorio de datos e o nome da base de datos que hai a continuación e inténteo de novo.",
+ "config-sqlite-readonly": "Non se pode escribir no ficheiro <code>$1</code>.",
+ "config-sqlite-cant-create-db": "Non se puido crear o ficheiro da base de datos <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "Falta o soporte FTS3 para o PHP; diminuíndo as táboas",
+ "config-can-upgrade": "Existen táboas MediaWiki nesta base de datos.\nPara actualizalas a MediaWiki $1, prema sobre \"<strong>Continuar</strong>\".",
+ "config-upgrade-done": "Actualización completada.\n\nAgora pode [$1 comezar a utilizar o seu wiki].\n\nSe quere rexenerar o seu ficheiro <code>LocalSettings.php</code>, prema no botón que aparece a continuación.\nIsto <strong>non é recomendable</strong> a menos que estea a ter problemas co seu wiki.",
+ "config-upgrade-done-no-regenerate": "Actualización completada.\n\nXa pode [$1 comezar a usar o seu wiki].",
+ "config-regenerate": "Rexenerar LocalSettings.php →",
+ "config-show-table-status": "A pescuda <code>SHOW TABLE STATUS</code> fallou!",
+ "config-unknown-collation": "<strong>Atención:</strong> A base de datos está a empregar unha clasificación alfabética irrecoñecible.",
+ "config-db-web-account": "Conta na base de datos para o acceso á internet",
+ "config-db-web-help": "Seleccione o nome de usuario e contrasinal que o servidor web empregará para se conectar ao servidor da base de datos durante o funcionamento normal do wiki.",
+ "config-db-web-account-same": "Empregar a mesma conta que para a instalación",
+ "config-db-web-create": "Crear a conta se aínda non existe",
+ "config-db-web-no-create-privs": "A conta que especificou para a instalación non ten os privilexios suficientes para crear unha conta.\nA conta que se especifique aquí xa debe existir.",
+ "config-mysql-engine": "Motor de almacenamento:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Atención:</strong> Seleccionou MyISAM como o motor de almacenamento para MySQL, unha combinación non recomendada para MediaWiki, porque:\n* practicamente non soporta os accesos simultáneos debido ao bloqueo de táboas\n* é máis propenso a corromperse ca outros motores\n* o código base de MediaWiki non sempre manexa o MyISAM como debera\n\nSe a súa instalación MySQL soporta InnoDB, recoméndase elixilo no canto de MyISAM.\nSe a súa instalación MySQL non soporta InnoDB, quizais sexa boa idea realizar unha actualización.",
+ "config-mysql-only-myisam-dep": "<strong>Atención:</strong> MyISAM é o único motor de almacenamento para MySQL nesta máquina, unha combinación non recomendada para MediaWiki, porque:\n* practicamente non soporta os accesos simultáneos debido ao bloqueo de táboas\n* é máis propenso a corromperse ca outros motores\n* o código base de MediaWiki non sempre manexa o MyISAM como debera\n\nA súa instalación MySQL non soporta InnoDB, quizais sexa boa idea realizar unha actualización.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> é case sempre a mellor opción, dado que soporta ben os accesos simultáneos.\n\n<strong>MyISAM</strong> é máis rápido en instalacións de usuario único e de só lectura.\nAs bases de datos MyISAM tenden a se corromper máis a miúdo ca as bases de datos InnoDB.",
+ "config-mysql-charset": "Conxunto de caracteres da base de datos:",
+ "config-mysql-binary": "Binario",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "No <strong>modo binario</strong>, MediaWiki almacena texto UTF-8 na base de datos en campos binarios.\nIsto é máis eficaz ca o modo UTF-8 de MySQL e permítelle usar o rango completo de caracteres Unicode.\n\nNo <strong>modo UTF-8</strong>, MySQL saberá o xogo de caracteres dos seus datos e pode presentar e converter os datos de maneira axeitada,\npero non lle deixará gardar caracteres por riba do [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes plan multilingüe básico].",
+ "config-mssql-auth": "Tipo de autenticación:",
+ "config-mssql-install-auth": "Seleccione o tipo de autenticación que se utilizará para conectarse á base de datos durante o proceso de instalación.\nSe selecciona \"{{int:config-mssql-windowsauth}}\", usaranse as credenciais do usuario co que se está a executar o servidor web.",
+ "config-mssql-web-auth": "Seleccione o tipo de autenticación que utilizará o servidor web para conectarse ao servidor da base de datos durante o funcionamiento normal do wiki.\nSe selecciona \"{{int:config-mssql-windowsauth}}\", usaranse as credenciais do usuario co que se está a executar o servidor web.",
+ "config-mssql-sqlauth": "Autenticación de SQL Server",
+ "config-mssql-windowsauth": "Autenticación de Windows",
+ "config-site-name": "Nome do wiki:",
+ "config-site-name-help": "Isto aparecerá na barra de títulos do navegador e noutros lugares.",
+ "config-site-name-blank": "Escriba o nome do sitio.",
+ "config-project-namespace": "Espazo de nomes do proxecto:",
+ "config-ns-generic": "Proxecto",
+ "config-ns-site-name": "O mesmo nome que o wiki: $1",
+ "config-ns-other": "Outro (especificar)",
+ "config-ns-other-default": "OMeuWiki",
+ "config-project-namespace-help": "Seguindo o exemplo da Wikipedia, moitos wikis manteñen as súas páxinas de políticas separadas das súas páxinas de contido, nun '''espazo de nomes do proxecto'''.\nTodos os títulos presentes neste espazo de nomes comezan cun prefixo determinado, que pode especificar aquí.\nNormalmente, este prefixo deriva do nome do wiki, pero non pode conter caracteres de puntuación como \"#\" ou \":\".",
+ "config-ns-invalid": "O espazo de nomes especificado, \"<nowiki>$1</nowiki>\", é incorrecto.\nEspecifique un espazo de nomes do proxecto diferente.",
+ "config-ns-conflict": "O espazo de nomes especificado, \"<nowiki>$1</nowiki>\", entra en conflito co espazo de nomes MediaWiki por defecto.\nEspecifique un espazo de nomes do proxecto diferente.",
+ "config-admin-box": "Conta de administrador",
+ "config-admin-name": "O seu nome de usuario:",
+ "config-admin-password": "Contrasinal:",
+ "config-admin-password-confirm": "Repita o contrasinal:",
+ "config-admin-help": "Escriba o nome de usuario que queira aquí, por exemplo, \"Joe Bloggs\".\nEste é o nome que usará para acceder ao sistema do wiki.",
+ "config-admin-name-blank": "Escriba un nome de usuario para o administrador.",
+ "config-admin-name-invalid": "O nome de usuario especificado, \"<nowiki>$1</nowiki>\", é incorrecto.\nEspecifique un nome de usuario diferente.",
+ "config-admin-password-blank": "Escriba un contrasinal para a conta de administrador.",
+ "config-admin-password-mismatch": "Os contrasinais non coinciden.",
+ "config-admin-email": "Enderezo de correo electrónico:",
+ "config-admin-email-help": "Escriba aquí un enderezo de correo electrónico para que poida recibir mensaxes doutros usuarios a través do wiki, restablecer o contrasinal e ser notificado das modificacións feitas nas páxinas presentes na súa lista de vixilancia. Pode deixar este campo en branco.",
+ "config-admin-error-user": "Erro interno ao crear un administrador co nome \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Erro interno ao establecer un contrasinal para o administrador \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Escribiu un enderezo de correo electrónico non válido.",
+ "config-subscribe": "Subscríbase á [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce lista de correo de anuncios sobre lanzamentos].",
+ "config-subscribe-help": "Esta é unha lista de correos de baixo volume usada para anuncios sobre lanzamentos de novas versións, incluíndo avisos de seguridade importantes.\nDebería subscribirse a ela e actualizar a súa instalación MediaWiki cando saian as novas versións.",
+ "config-subscribe-noemail": "Intentou subscribirse á lista de correo dos anuncios de novos lanzamentos sen proporcionar o enderezo de correo electrónico.\nDea un enderezo de correo electrónico se quere efectuar a subscrición á lista de correo.",
+ "config-pingback": "Compartir datos de esta instalación cos desenvolvedores de MediaWiki",
+ "config-pingback-help": "Se seleccionas esta opción, MediaWiki enviará periodicamente unha mensaxe a https://www.mediawiki.org con datos básicos sobre esta instancia Mediawiki. Estos datos inclúen, por exemplo, o tipo de sistema, versión de PHP e a base de datos escollida. A Fundación Wikimedia comparte estos datos cos desenvolvedores de MediaWiki para axudar a guiar o traballo futuro de desenvolvemento. Serán enviados os seguintes datos do seu sistemaː\n<pre>$1</pre>",
+ "config-almost-done": "Xa case rematou!\nNeste paso pode saltar o resto da configuración e instalar o wiki agora mesmo.",
+ "config-optional-continue": "Facédeme máis preguntas.",
+ "config-optional-skip": "Xa estou canso. Instalade o wiki.",
+ "config-profile": "Perfil dos dereitos de usuario:",
+ "config-profile-wiki": "Wiki aberto",
+ "config-profile-no-anon": "Necesítase a creación dunha conta",
+ "config-profile-fishbowl": "Só os editores autorizados",
+ "config-profile-private": "Wiki privado",
+ "config-profile-help": "Os wikis funcionan mellor canta máis xente os edite.\nEn MediaWiki, é doado revisar os cambios recentes e reverter calquera dano feito por usuarios novatos ou con malas intencións.\nPorén, moita xente atopa MediaWiki útil nunha ampla variedade de papeis, e ás veces non é fácil convencer a todos dos beneficios que leva consigo o estilo wiki.\nVostede decide.\n\nO modelo <strong>{{int:config-profile-wiki}}</strong> permite a edición por parte de calquera, mesmo sen rexistro.\nA opción <strong>{{int:config-profile-no-anon}}</strong> proporciona un control maior, pero pode desalentar os colaboradores casuais.\n\nO escenario <strong>{{int:config-profile-fishbowl}}</strong> restrinxe a edición aos usuarios aprobados, pero o público pode ollar as páxinas, incluíndo os historiais.\nO tipo <strong>{{int:config-profile-private}}</strong> só deixa que os usuarios aprobados vexan e editen as páxinas.\n\nHai dispoñibles configuracións de dereitos de usuario máis complexas despois da instalación; bótelle un ollo a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights esta entrada no manual].",
+ "config-license": "Dereitos de autoría e licenza:",
+ "config-license-none": "Sen licenza ao pé",
+ "config-license-cc-by-sa": "Creative Commons recoñecemento compartir igual",
+ "config-license-cc-by": "Creative Commons recoñecemento",
+ "config-license-cc-by-nc-sa": "Creative Commons recoñecemento non comercial compartir igual",
+ "config-license-cc-0": "Creative Commons Zero (dominio público)",
+ "config-license-gfdl": "Licenza de documentación libre de GNU 1.3 ou posterior",
+ "config-license-pd": "Dominio público",
+ "config-license-cc-choose": "Seleccione unha licenza Creative Commons personalizada",
+ "config-license-help": "Moitos wikis públicos liberan todas as súas contribucións baixo unha [http://freedomdefined.org/Definition/Gl licenza libre].\nIsto axuda a crear un sentido de propiedade comunitaria e anima a seguir contribuíndo durante moito tempo.\nXeralmente, non é necesario nos wikis privados ou de empresas.\n\nSe quere poder empregar textos da Wikipedia, así como que a Wikipedia poida aceptar textos copiados do seu wiki, escolla a licenza <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nA licenza de documentación libre de GNU era a licenza anterior da Wikipedia.\nMalia aínda ser unha licenza válida, é difícil de entender.\nTamén é difícil reusar contidos baixo esta licenza.",
+ "config-email-settings": "Configuración do correo electrónico",
+ "config-enable-email": "Activar os correos electrónicos de saída",
+ "config-enable-email-help": "Se quere que o correo electrónico funcione, cómpre configurar os [http://www.php.net/manual/en/mail.configuration.php parámetros PHP] correctamente.\nSe non quere ningunha característica no correo, pode desactivalas aquí.",
+ "config-email-user": "Activar o intercambio de correos electrónicos entre usuarios",
+ "config-email-user-help": "Permitir que todos os usuarios intercambien correos electrónicos, se o teñen activado nas súas preferencias.",
+ "config-email-usertalk": "Activar a notificación da páxina de conversa de usuario",
+ "config-email-usertalk-help": "Permitir que os usuarios reciban notificacións cando a súa páxina de conversa de usuario sufra modificacións, se o teñen activado nas súas preferencias.",
+ "config-email-watchlist": "Activar a notificación da lista de vixilancia",
+ "config-email-watchlist-help": "Permitir que os usuarios reciban notificacións sobre modificacións nas páxinas que vixían, se o teñen activado nas súas preferencias.",
+ "config-email-auth": "Activar a autenticación do correo electrónico",
+ "config-email-auth-help": "Se esta opción está activada, os usuarios teñen que confirmar o seu correo electrónico mediante unha ligazón enviada ao enderezo cando o definan ou o cambien.\nSó os enderezos autenticados poden recibir correos doutros usuarios ou de notificación.\nÉ <strong>recomendable</strong> establecer esta opción nos wikis públicos para evitar abusos potenciais das características do correo.",
+ "config-email-sender": "Enderezo de correo electrónico de retorno:",
+ "config-email-sender-help": "Introduza o enderezo de correo electrónico a usar como enderezo de retorno dos correos de saída.\nAquí é onde irán parar os correos rexeitados.\nMoitos servidores de correo electrónico esixen que polo menos a parte do nome de dominio sexa válido.",
+ "config-upload-settings": "Imaxes e carga de ficheiros",
+ "config-upload-enable": "Activar a carga de ficheiros",
+ "config-upload-help": "A subida de ficheiros expón potencialmente o servidor a riscos de seguridade.\nPara obter máis información, lea a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security sección de seguridade] no manual.\n\nPara activar a carga de ficheiros, cambie o modo no subdirectorio <code>images</code> que está baixo o directorio raíz de MediaWiki, de xeito que o servidor web poida escribir nel.\nA continuación, active esta opción.",
+ "config-upload-deleted": "Directorio para os ficheiros borrados:",
+ "config-upload-deleted-help": "Escolla un directorio no que arquivar os ficheiros borrados.\nO ideal é que non sexa accesible desde a web.",
+ "config-logo": "URL do logo:",
+ "config-logo-help": "A aparencia de MediaWiki por defecto inclúe espazo para un logo de 135x160 píxeles por riba do menú lateral.\nCargue unha imaxe do tamaño axeitado e introduza o enderezo URL aquí.\n\nPode utilizar <code>$wgStylePath</code> ou <code>$wgScriptPath</code> se o seu logo está relacionado con esas rutas.\n\nSe non quere un logo, deixe esta caixa en branco.",
+ "config-instantcommons": "Activar Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons InstantCommons] é unha característica que permite aos wikis usar imaxes, sons e outros ficheiros multimedia atopados no sitio da [https://commons.wikimedia.org/wiki/Portada_galega Wikimedia Commons].\nPara facer isto, MediaWiki necesita acceso á internet.\n\nPara obter máis información sobre esta característica, incluíndo as instrucións sobre como configuralo para outros wikis que non sexan a Wikimedia Commons, consulte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos o manual].",
+ "config-cc-error": "A escolla da licenza Creative Commons non deu resultados.\nEscriba o nome da licenza manualmente.",
+ "config-cc-again": "Escolla outra vez...",
+ "config-cc-not-chosen": "Escolla a licenza Creative Commons que desexe e prema en \"proceed\".",
+ "config-advanced-settings": "Configuración avanzada",
+ "config-cache-options": "Configuración da caché de obxectos:",
+ "config-cache-help": "A caché de obxectos emprégase para mellorar a velocidade de MediaWiki mediante a memorización de datos usados con frecuencia.\nÉ amplamente recomendable a súa activación nos sitios de tamaño medio e grande; os sitios pequenos obterán tamén beneficios.",
+ "config-cache-none": "Sen caché (non se elimina ningunha funcionalidade, pero pode afectar á velocidade en wikis grandes)",
+ "config-cache-accel": "Caché de obxectos do PHP (APC, APCu, XCache ou WinCache)",
+ "config-cache-memcached": "Empregar o Memcached (necesita unha instalación e configuración adicional)",
+ "config-memcached-servers": "Servidores da memoria caché:",
+ "config-memcached-help": "Lista de enderezos IP para Memcached.\nDebe especificarse un por liña, así como o porto a usar. Por exemplo:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Seleccionou Memcached como o seu tipo de caché, pero non especificou ningún servidor.",
+ "config-memcache-badip": "Escribiu un enderezo IP inválido para Memcached: $1.",
+ "config-memcache-noport": "Non especificou o porto a usar no servidor Memcached: $1.\nSe non sabe o porto, o predeterminado é 11211.",
+ "config-memcache-badport": "Os números de porto Memcached deben estar entre $1 e $2.",
+ "config-extensions": "Extensións",
+ "config-extensions-help": "As extensións anteriores detectáronse no seu directorio <code>./extensions</code>.\n\nQuizais necesite algunha configuración adicional, pero pode activalas agora",
+ "config-skins": "Aparencias",
+ "config-skins-help": "As aparencias listadas enriba detectáronse no seu directorio <code>./skins</code>. Debe activar, polo menos, unha e elixir a predeterminada.",
+ "config-skins-use-as-default": "Utilizar esta aparencia por defecto",
+ "config-skins-missing": "Non se atopou aparencia ningunha. MediaWiki ha utilizar unha aparencia de respaldo ata que vostede instale algunha aparencia axeitada.",
+ "config-skins-must-enable-some": "Debe elixir, polo menos, unha aparencia para activala.",
+ "config-skins-must-enable-default": "A aparencia elixida como predeterminada debe estar activada.",
+ "config-install-alreadydone": "<strong>Atención:</strong> Semella que xa instalou MediaWiki e que o está a instalar de novo.\nVaia ata a seguinte páxina.",
+ "config-install-begin": "Ao premer en \"{{int:config-continue}}\", comezará a instalación de MediaWiki.\nSe aínda quere facer algún cambio, prema en \"{{int:config-back}}\".",
+ "config-install-step-done": "feito",
+ "config-install-step-failed": "erro",
+ "config-install-extensions": "Incluíndo as extensións",
+ "config-install-database": "Configurando a base de datos",
+ "config-install-schema": "Creando o esquema",
+ "config-install-pg-schema-not-exist": "O esquema PostgreSQL non existe.",
+ "config-install-pg-schema-failed": "Fallou a creación de táboas.\nAsegúrese de que o usuario \"$1\" pode escribir no esquema \"$2\".",
+ "config-install-pg-commit": "Validando os cambios",
+ "config-install-pg-plpgsql": "Comprobación da lingua PL/pgSQL",
+ "config-pg-no-plpgsql": "Cómpre instalar a lingua PL/pgSQL na base de datos $1",
+ "config-pg-no-create-privs": "A conta especificada para a instalación non ten os privilexios necesarios para crear unha conta.",
+ "config-pg-not-in-role": "A conta especificada para o usuario web xa existe.\nA conta que especificou para a instalación non é un superusuario e non pertence ao grupo de usuarios con acceso á web, polo que non pode crear obxectos pertencentes ao usuario da rede.\n\nActualmente, MediaWiki necesita que as táboas sexan propiedade do usuario da rede. Especifique outro nome de conta web ou prema no botón \"Atrás\" e dea un usuario de instalación cos privilexios axeitados.",
+ "config-install-user": "Creando o usuario da base de datos",
+ "config-install-user-alreadyexists": "O usuario \"$1\" xa existe",
+ "config-install-user-create-failed": "A creación do usuario \"$1\" fallou: $2",
+ "config-install-user-grant-failed": "Fallou a concesión de permisos ao usuario \"$1\": $2",
+ "config-install-user-missing": "O usuario especificado, \"$1\", non existe.",
+ "config-install-user-missing-create": "O usuario especificado, \"$1\", non existe.\nPrema na caixa de verificación \"crear unha conta\" que hai a continuación se quere crear unha.",
+ "config-install-tables": "Creando as táboas",
+ "config-install-tables-exist": "<strong>Atención:</strong> Semella que as táboas de MediaWiki xa existen.\nSaltando a creación.",
+ "config-install-tables-failed": "<strong>Erro:</strong> Fallou a creación da táboa. Descrición do erro: $1",
+ "config-install-interwiki": "Enchendo a táboa de interwiki por defecto",
+ "config-install-interwiki-list": "Non se puido atopar o ficheiro <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Atención:</strong> Semella que a táboa de interwiki xa contén entradas.\nSaltando a lista por defecto.",
+ "config-install-stats": "Iniciando as estatísticas",
+ "config-install-keys": "Xerando as claves secretas",
+ "config-insecure-keys": "<strong>Atención:</strong> {{PLURAL:$2|A clave de seguridade|As claves de seguridade}} ($1) {{PLURAL:$2|xerada|xeradas}} durante a instalación non {{PLURAL:$2|é|son}} completamente {{PLURAL:$2|segura|seguras}}. Considere a posibilidade de {{PLURAL:$2|cambiala|cambialas}} manualmente.",
+ "config-install-updates": "Evitar executar actualizacións innecesarias",
+ "config-install-updates-failed": "<strong>Error:</strong> a inserción de claves de actualización nas táboas fallou co seguinte erro: $1",
+ "config-install-sysop": "Creando a conta de usuario de administrador",
+ "config-install-subscribe-fail": "Non se puido subscribir á lista mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL non está instalado e <code>allow_url_fopen</code> non está dispoñible.",
+ "config-install-mainpage": "Creando a páxina principal co contido por defecto",
+ "config-install-mainpage-exists": "A páxina principal xa existe, saltando",
+ "config-install-extension-tables": "Creando as táboas para as extensións activadas",
+ "config-install-mainpage-failed": "Non se puido inserir a páxina principal: $1",
+ "config-install-done": "<strong>Parabéns!</strong>\nInstalou MediaWiki.\n\nO programa de instalación xerou un ficheiro <code>LocalSettings.php</code>.\nEste ficheiro contén toda a súa configuración.\n\nTerá que descargalo e poñelo na base da instalación do seu wiki (no mesmo directorio ca index.php). A descarga debería comezar automaticamente.\n\nSe non comezou a descarga ou se a cancelou, pode facer que comece de novo premendo na ligazón que aparece a continuación:\n\n$3\n\n<strong>Nota:</strong> Se non fai iso agora, este ficheiro de configuración xerado non estará dispoñible máis adiante se sae da instalación sen descargalo.\n\nCando faga todo isto, xa poderá <strong>[$2 entrar no seu wiki]</strong>.",
+ "config-install-done-path": "<strong>Parabéns!</strong>\nInstalou MediaWiki.\n\nO instalador xerou un ficheiro <code>LocalSettings.php</code>.\nEste contén toda a súa configuración.\n\nDeberá descargalo e poñerlo en <code>$4</code>. A descarga debería ter comezado automaticamente.\n\nSe non comenzou a descarga, ou se a cancelou, podes reiniciala descarga premendo na seguinte ligazón:\n\n$3\n\n<strong>Nota</strong>: se non fai isto agora, este ficheiro de configuración xerado non estará dispoñible máis tarde se sae da instalación sen descargarlo.\n\nCando o teña feito, poderá <strong>[$2 entrar na súa wiki]</strong>.",
+ "config-download-localsettings": "Descargar o <code>LocalSettings.php</code>",
+ "config-help": "axuda",
+ "config-help-tooltip": "prema para expandir",
+ "config-nofile": "Non se puido atopar o ficheiro \"$1\". Se cadra, foi borrado.",
+ "config-extension-link": "Sabía que o seu wiki soporta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensións]?\n\nPode explorar as [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensións por categoría] ou a [https://www.mediawiki.org/wiki/Extension_Matrix matriz de extensións] para ollar a lista completa de extensións.",
+ "config-skins-screenshots": "$1 (capturas de pantalla: $2)",
+ "config-screenshot": "captura de pantalla",
+ "mainpagetext": "<strong>Instalouse MediaWiki.</strong>",
+ "mainpagedocfooter": "Consulte a [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents guía de usuario] para obter máis información sobre como usar o software wiki.\n\n== Primeiros pasos ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista das opcións de configuración]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Preguntas máis frecuentes sobre MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de correo dos lanzamentos de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localice MediaWiki á súa lingua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Aprenda como combater a publicidade na súa wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/gom-latn.json b/www/wiki/includes/installer/i18n/gom-latn.json
new file mode 100644
index 00000000..f3f5a0bd
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gom-latn.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "The Discoverer"
+ ]
+ },
+ "config-page-language": "Bhas",
+ "mainpagedocfooter": "Wiki software uzar korpache mahiti khatir [https://meta.wikimedia.org/wiki/Help:Contents Vapurpeanchi Hath-pustok] polloi\n\n== Suru kortana ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuracaoanchi suchi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki babtint zaite pavtti vicharlele proxn]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki che novem ank bhair sorta tedna email dhadpachi suchi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources MediaWiki tujea bhasen toiar kor]"
+}
diff --git a/www/wiki/includes/installer/i18n/gor.json b/www/wiki/includes/installer/i18n/gor.json
new file mode 100644
index 00000000..be912701
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gor.json
@@ -0,0 +1,48 @@
+{
+ "@metadata": {
+ "authors": [
+ "Marwan Mohamad"
+ ]
+ },
+ "config-information": "habari",
+ "config-wiki-language": "bahasa wiki",
+ "config-back": "Mohalingo",
+ "config-continue": "Turusi",
+ "config-page-language": "Bahasa",
+ "config-page-dbconnect": "mohumbuto ode database",
+ "config-page-upgrade": "Popobohuwa umapilopohuli",
+ "config-page-dbsettings": "Aturu lo database",
+ "config-page-name": "Tanggulo",
+ "config-page-options": "Tulawotolo",
+ "config-page-install": "Mopohuli",
+ "config-page-complete": "Yilapato",
+ "config-page-restart": "Ulangiya instalasi",
+ "config-page-readme": "Pobacawa wa'u",
+ "config-page-releasenotes": "Tuladu mopolopato",
+ "config-page-copying": "Mohemi",
+ "config-page-upgradedoc": "Mopobohu",
+ "config-page-existingwiki": "Wiki uwoluwo",
+ "config-help-restart": "Yio mohuto moluluta ngaamila data utahu-tahu ma pilopomaso wawu mopobohu upasi-pasi",
+ "config-restart": "Jo, potumula ulangi",
+ "config-env-good": "Yio mowali mopopasi MediaWiki",
+ "config-env-bad": "Yio dilamowali mopopasi MediaWiki",
+ "config-env-php": "PHP$1 mayilepasi",
+ "config-env-hhvm": "HHVM $1 mayilepasi",
+ "config-apc": "[http://www.php.net/apc APC] mayilepasi",
+ "config-diff3-bad": "GNU diff3 ja yilotapu",
+ "config-using-server": "Momake tanggulo server \"<nowiki>$1<nowiki>\"",
+ "config-using-uri": "Momake server URL \"<nowiki>$1$2</nowiki>\"",
+ "config-db-host": "Tiyombu lo basis data",
+ "config-db-host-oracle": "Basis data TNS",
+ "config-db-wiki-settings": "Lolohe wiki botiye",
+ "config-db-name": "Tanggulo basis data",
+ "config-db-name-oracle": "Skema lo basis data",
+ "config-db-username": "Basis data lo tanggulo user",
+ "config-charset-mysql5": "MySQL 4.1/5.0 UFT-8",
+ "config-type-mysql": "MySQL (meyalo umopasiya)",
+ "config-header-mysql": "Aturangi lo MySQL",
+ "config-header-postgres": "Aturangi lo PostgreSQL",
+ "config-header-sqlite": "Aturangi lo SQLite",
+ "config-header-oracle": "Aturangi lo Oracle",
+ "config-header-mssql": "Aturangi lo Server Microsoft SQL"
+}
diff --git a/www/wiki/includes/installer/i18n/grc.json b/www/wiki/includes/installer/i18n/grc.json
new file mode 100644
index 00000000..52234559
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/grc.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Crazymadlover",
+ "Omnipaedista"
+ ]
+ },
+ "config-page-language": "Γλῶττα",
+ "mainpagetext": "'''Ἡ ἐγκατάστασις τῆς MediaWiki ἦν ἐπιτυχής'''",
+ "mainpagedocfooter": "Βουλευθήσεσθε τὰς [https://meta.wikimedia.org/wiki/Help:Contents βουλὰς τοῖς Χρωμένοις] ἵνα πληροφορηθῇτε περὶ τοῦ βίκιλογισμικοῦ.\n\n== Ἄρξασθε ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Διαλογή παραμέτρων διαμορφώσεως]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki: τὰ πολλάκις αἰτηθέντα]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Διαλογή διαλέξεων ἐπὶ τῶν διανομῶν τῆς MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/gsw.json b/www/wiki/includes/installer/i18n/gsw.json
new file mode 100644
index 00000000..73331d97
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gsw.json
@@ -0,0 +1,60 @@
+{
+ "@metadata": {
+ "authors": [
+ "Als-Holder"
+ ]
+ },
+ "config-desc": "S MediaWiki-Inschtallationsprogramm",
+ "config-title": "MediaWiki $1 inschtalliere",
+ "config-information": "Information",
+ "config-localsettings-upgrade": "'''Warnig:''' E Datei <code>LocalSettings.php</code> isch gfunde wore.\nFir d Aktualisierig vu dr däre Inschtallation, gib bitte dr Wärt vum Parameter <code>$wgUpgradeKey</code> im Fäld unten yy.\nDu findsch dr Wärt in dr Datei <code>LocalSettings.php</code>.",
+ "config-localsettings-key": "Aktualisierigsschlissel:",
+ "config-localsettings-badkey": "Dr Aktualisierigsschlissel, wu du aagee hesch, isch falsch.",
+ "config-session-error": "Fähler bim Starte vu dr Sitzig: $1",
+ "config-session-expired": "D Sitzigsdate sin schyns abgloffe.\nSitzige sin fir e Zytruum vu $1 konfiguriert.\nDää cha dur Aalupfe vum Parameter <code>session.gc_maxlifetime</code> in dr Datei <code>php.ini</code> greßer gmacht wäre.\nDr Inschtallationsvorgang nomol starte.",
+ "config-no-session": "Dyyni Sitzigsdate sin verlore gange!\nD Datei <code>php.ini</code> mueß prieft wäre un s mueß derby sichergstellt wäre, ass dr Parameter <code>session.save_path</code> uf s richtig Verzeichnis verwyyst.",
+ "config-your-language": "Dyy Sproch:",
+ "config-your-language-help": "Bitte d Sproch uuswehle, wu bim Inschtallationsvorgang soll brucht wäre.",
+ "config-wiki-language": "Wikisproch:",
+ "config-wiki-language-help": "Bitte d Sproch uuswehle, wu s Wiki in dr Hauptsach din gschribe wird.",
+ "config-back": "← Zruck",
+ "config-continue": "Wyter →",
+ "config-page-language": "Sproch",
+ "config-page-welcome": "Willchuu bi MediaWiki!",
+ "config-page-dbconnect": "Mit dr Datebank verbinde",
+ "config-page-upgrade": "E Inschtallition, wu s scho het, aktualisiere",
+ "config-page-dbsettings": "Datebankyystellige",
+ "config-page-name": "Name",
+ "config-page-options": "Optione",
+ "config-page-install": "Inschtalliere",
+ "config-page-complete": "Fertig!",
+ "config-page-restart": "Inschtallation nomol aafange",
+ "config-page-readme": "Liis mi",
+ "config-page-releasenotes": "Hiiwys fir d Vereffentlichung",
+ "config-page-copying": "Am Kopiere",
+ "config-page-upgradedoc": "Am Aktualisiere",
+ "config-help-restart": "Witt alli Date, wu Du yygee hesch, lesche un d Inschtallation nomol aafange?",
+ "config-restart": "Jo, nomol aafange",
+ "config-welcome": "=== Priefig vu dr Inschtallationsumgäbig ===\nBasispriefige wäre durgfiert zum Feschtstelle, eb d Inschtallationsumgäbig fir d Inschtallation vu MediaWiki geignet isch.\nDu sottsch d Ergebnis vu däre Priefig aagee, wänn Du bi dr Inschtallation Hilf bruchsch.",
+ "config-copyright": "=== Copyright un Nutzigsbedingige ===\n\n$1\n\nDes Programm isch e freji Software, d. h. s cha, no dr Bedingige vu dr GNU General Public-Lizänz, wu vu dr Free Software Foundation vereffentligt woren isch, wyterverteilt un/oder modifiziert wäre. Doderbyy cha d Version 2, oder no eigenem Ermässe, jedi nejeri Version vu dr Lizänz brucht wäre.\n\nDes Programm wird in dr Hoffnig verteilt, ass es nitzli isch, aber '''ohni jedi Garanti''' un sogar ohni di impliziert Garanti vun ere '''Märtgängigkeit''' oder '''Eignig fir e bstimmte Zwäck'''. Doderzue git meh Hiiwys in dr GNU General Public-Lizänz.\n\nE <doclink href=Copying>Kopi vu dr GNU General Public-Lizänz</doclink> sott zämme mit däm Programm verteilt wore syy. Wänn des nit eso isch, cha ne Kopi bi dr Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftli aagforderet oder [http://www.gnu.org/copyleft/gpl.html online gläse] wäre.",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki Websyte vu MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Nutzeraaleitig zue MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Adminischtratoreaaleitig zue MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Vilmol gstellti Froge zue MediaWiki]",
+ "config-env-good": "D Inschtallationsumgäbig isch prieft wore.\nDu chasch MediaWiki inschtalliere.",
+ "config-env-bad": "D Inschtallationsumgäbigisch prieft wore.\nDu chasch MediaWiki nit inschtalliere.",
+ "config-env-php": "PHP $1 isch inschtalliert.",
+ "config-unicode-using-intl": "For d Unicode-Normalisierig wird d [http://pecl.php.net/intl PECL-Erwyterig intl] yygsetzt.",
+ "config-unicode-pure-php-warning": "'''Warnig:''' D [http://pecl.php.net/intl PECL-Erwyterig intl] isch fir d Unicode-Normalisierig nit verfiegbar. Wäge däm wird di langsam pure-PHP-Implementierig brucht.\nWänn Du ne Websyte mit ere große Bsuechrzahl bedrybsch, sottsch e weng ebis läse iber [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-Normalisierig (en)].",
+ "config-unicode-update-warning": "'''Warnig:''' Di inschtalliert Version vum Unicode-Normalisierigswrapper verwändet e elteri Version vu dr Bibliothek vum [http://site.icu-project.org/ ICU-Projäkt].\nDu sottsch si [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations aktualisiere], wänn Dor d Verwändig vu Unicode wichtig isch.",
+ "config-no-db": "S isch kei adäquate Datebanktryyber gfunde wore!",
+ "config-no-fts3": "'''Warnig:''' SQLite isch ohni s [//sqlite.org/fts3.html FTS3-Modul] kumpiliert wore, s stehn kei Suechfunktione z Verfiegig.",
+ "config-pcre-no-utf8": "'''Fatale Fähler: S PHP-Modul PCRE isch schyns ohni PCRE_UTF8-Unterstitzig kompiliert wore.'''\nMediaWiki brucht d UTF-8-Unterstitzi zum fählerfrej lauffähig syy.",
+ "config-memory-raised": "Dr PHP-Parameter <code>memory_limit</code> lyt bi $1 un isch uf $2 uffegsetzt wore.",
+ "config-memory-bad": "'''Warnig:''' Dr PHP-Parameter <code>memory_limit</code> lyt bi $1.\nDää Wärt isch wahrschyns z nider.\nDr Inschtallationsvorgang chennt wäge däm fählschlaa!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] isch inschtalliert",
+ "config-apc": "[http://www.php.net/apc APC] isch inschtalliert",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] isch inschtalliert",
+ "config-diff3-bad": "GNU diff3 isch nit gfunde wore.",
+ "config-imagemagick": "ImageMagick isch gfunde wore: <code>$1</code>.\nMiniaturaasichte vu Bilder sin megli, sobald s Uffelade vu Dateie aktiviert isch.",
+ "config-help": "Hilf",
+ "mainpagetext": "'''MediaWiki isch erfolgrich inschtalliert worre.'''",
+ "mainpagedocfooter": "Lueg uf d [https://meta.wikimedia.org/wiki/MediaWiki_localisation Dokumentation fir d Aapassig vu dr Benutzeroberflächi] un s [https://meta.wikimedia.org/wiki/Help:Contents Benutzerhandbuech] fir d Hilf iber d Benutzig un s Yystelle."
+}
diff --git a/www/wiki/includes/installer/i18n/gu.json b/www/wiki/includes/installer/i18n/gu.json
new file mode 100644
index 00000000..a1e3f6c7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gu.json
@@ -0,0 +1,45 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ashok modhvadia",
+ "Dineshjk",
+ "KartikMistry"
+ ]
+ },
+ "config-desc": "મીડિઆવિકિ માટે સ્થાપક",
+ "config-title": "મીડિઆવિકિ $1 સ્થાપન",
+ "config-information": "માહિતી",
+ "config-localsettings-badkey": "તમે આપેલી કળ ખોટી છે.",
+ "config-your-language": "તમારી ભાષા:",
+ "config-wiki-language": "વિકિ ભાષા:",
+ "config-back": "← પાછળ",
+ "config-continue": "ચાલુ રાખો →",
+ "config-page-language": "ભાષા",
+ "config-page-welcome": "મિડિઆવિકિમાં તમારું સ્વાગત છે!",
+ "config-page-dbsettings": "ડેટાબેઝ ગોઠવણીઓ",
+ "config-page-name": "નામ",
+ "config-page-options": "વિકલ્પો",
+ "config-page-install": "સ્થાપિત કરો",
+ "config-page-complete": "સમાપ્ત!",
+ "config-page-restart": "સ્થાપન ફરી શરુ કરો",
+ "config-page-readme": "મને વાંચો",
+ "config-db-type": "ડેટાબેઝ પ્રકાર:",
+ "config-db-host": "ડેટાબેઝ હોસ્ટ:",
+ "config-db-name": "ડેટાબેઝ નામ:",
+ "config-db-name-oracle": "ડેટાબેઝ સ્કિમા:",
+ "config-db-username": "ડેટાબેઝ સભ્યનામ:",
+ "config-db-password": "ડેટાબેઝ પાસવર્ડ:",
+ "config-db-port": "ડેટાબેઝ પોર્ટ:",
+ "config-site-name": "વિકિનું નામ:",
+ "config-admin-name": "તમારું સભ્યનામ:",
+ "config-admin-password": "પાસવર્ડ:",
+ "config-admin-password-confirm": "પાસવર્ડ ફરીથી:",
+ "config-admin-email": "ઇમેલ સરનામું:",
+ "config-profile-private": "અંગત વિકિ",
+ "config-email-settings": "ઇમેલ ગોઠવણીઓ",
+ "config-install-step-done": "પૂર્ણ",
+ "config-install-step-failed": "નિષ્ફળ",
+ "config-help": "મદદ",
+ "mainpagetext": "'''મિડીયાવિકિ સફળતાપૂર્વક ઇન્સટોલ થયું છે.'''",
+ "mainpagedocfooter": "વિકિ સોફ્ટવેર વાપરવાની માહીતિ માટે [https://meta.wikimedia.org/wiki/Help:Contents સભ્ય માર્ગદર્શિકા] જુઓ.\n\n== શરૂઆતના તબક્કે ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings કોનફીગ્યુરેશન સેટીંગ્સની યાદી]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ વારંવાર પુછાતા પ્રશ્નો]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce મિડીયાવિકિ રીલીઝ મેઇલીંગ લીસ્ટ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/gv.json b/www/wiki/includes/installer/i18n/gv.json
new file mode 100644
index 00000000..6d9b076e
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/gv.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''Ta MediaWiki currit stiagh nish.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/hak.json b/www/wiki/includes/installer/i18n/hak.json
new file mode 100644
index 00000000..17c8288f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hak.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''Yí-kîn sṳ̀n-kûng ôn-chông MediaWiki.'''",
+ "mainpagedocfooter": "chhiáng fóng-mun [https://meta.wikimedia.org/wiki/Help:Contents Yung-fu sú-chhak] yî-khi̍p sṳ́-yung chhṳ́ wiki ngiôn-khien ke sin-sit!\n\n== Ngi̍p-mùn ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings MediaWiki Phi-chṳ sat-thin chhîn-tân]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki Phìn-sòng mun-thì kié-tap]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki fat-phu email chhîn-tân]"
+}
diff --git a/www/wiki/includes/installer/i18n/haw.json b/www/wiki/includes/installer/i18n/haw.json
new file mode 100644
index 00000000..afa7d724
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/haw.json
@@ -0,0 +1,64 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kolonahe"
+ ]
+ },
+ "config-desc": "Ka polokalamu hoʻonoho no MekiaWiki",
+ "config-title": "Ka hoʻonoho MekiaWiki $1",
+ "config-information": "ʻIke",
+ "config-localsettings-upgrade": "Ua hōʻike ʻia kekahi waihona <code>LocalSettings.php</code>.\nNo ka hoʻopuka hou ʻana o kēia hoʻonohona, e ʻoluʻolu, e kikokiko i ka helu o <code>$wgUpgradeKey</code> i loko o ka pahu i lalo.\nAia ia ma <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Ua hōʻike ʻia kekahi waihona <code>LocalSettings.php</code>.\nNo ka hoʻopuka hou ʻana o kēia hoʻonohona, e hana iā <code>update.php</code> ke ʻoluʻolu.",
+ "config-localsettings-key": "Pihi hoʻopuka hou:",
+ "config-localsettings-badkey": "Hewa ka pihi.",
+ "config-upgrade-key-missing": "Loaʻa he hoʻonohona mai mua o MīkiaWiki mai mua.\nNo ka hoʻopuka hou ʻana o kēia hoʻonohona, e ʻoluʻolu, e kau i kēia laina lalo ma lalo o kāu <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "ʻAʻole piha pono kēia <code>LocalSettings.php</code> nei.\nʻAʻole hoʻopaʻa ʻia ka hualau $1.\nE hoʻololi iā <code>LocalSettings.php</code> i hiki ke hoʻopaʻa i ka hualau, a laila e kaomi iā \"{{int:Config-continue}}\" ke ʻoluʻolu.",
+ "config-localsettings-connection-error": "Ua loaʻa i ke kuʻia i ka hoʻokuʻi ʻana i ke hōkeo ʻikepili ma muli o ka hana ʻana o ka makemake e kākau ʻia ma <code>LocalSettings.php</code>. E ʻoluʻolu e hoʻoponopono i kēia makemake a hana hou.\n\n$1",
+ "config-session-error": "Kuʻia ka wā hoʻohana hoʻomaka: $1",
+ "config-session-expired": "Ua pau kāu ʻikepili wā hoʻohana.\nOla ka wā hoʻohana no ka manawa o $1.\nHiki iā ʻoe ke hoʻonui i kēia wā ma o ka hoʻololi ʻana o <code>session.gc_maxlifetime</code> ma php.ini.\nE hana hou i ka hana hoʻoukana.",
+ "config-no-session": "Ua nalo kāu ʻikepili wā hoʻohana!\nE hōʻoia i kāu php.ini a me ka hoʻopaʻa ʻana o <code>session.save_path</code> i ke kumu kūpono.",
+ "config-your-language": "Kāu ʻōlelo:",
+ "config-your-language-help": "E koho i kekahi ʻōlelo no ka hoʻohana ʻana ma loko o ka hana hoʻonohona.",
+ "config-wiki-language": "ʻŌlelo Wiki:",
+ "config-wiki-language-help": "E koho i ka ʻōlelo e kākau pinepine ʻia i ka wiki.",
+ "config-back": "← Kele hope",
+ "config-continue": "Holomua →",
+ "config-page-language": "ʻŌlelo",
+ "config-page-welcome": "E welina mai e MikiaWiki!",
+ "config-page-dbconnect": "E hoʻokuʻi i ka hōkeo ʻikepili",
+ "config-page-upgrade": "Hoʻopuka hou i ka hoʻonohona nei",
+ "config-page-dbsettings": "Makemake hōkeo ʻikepili",
+ "config-page-name": "Inoa",
+ "config-page-options": "Nā Koho",
+ "config-page-install": "Hoʻonoho",
+ "config-page-complete": "Pau hana!",
+ "config-page-restart": "Hōʻano hou i ka hoʻonohona",
+ "config-page-readme": "Heluhelu iaʻu",
+ "config-page-releasenotes": "Noka hāʻawi",
+ "config-page-copying": "Kope",
+ "config-page-upgradedoc": "Ka Hoʻopuka ʻana",
+ "config-page-existingwiki": "Ka wiki nei",
+ "config-help-restart": "He ʻoiaʻiʻo kāu makemake no ka holoi pono o nā ʻikepili mālamalia a pau āu i kikokiko a hoʻomaka hou i ka hana hoʻouka?",
+ "config-restart": "ʻAe, e hōʻano hou",
+ "config-db-type": "ʻAno hōkeo ʻikepili:",
+ "config-db-name": "Inoa hōkeo ʻikepili",
+ "config-db-username": "Inoa hōkeo ʻikepili:",
+ "config-db-password": "ʻŌlelo hūnā hōkeo ʻikepili",
+ "config-mysql-binary": "Baineli",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Inoa wiki:",
+ "config-project-namespace": "Lewainoa papahana:",
+ "config-ns-generic": "Papahana",
+ "config-ns-other-default": "KaʻuWiki",
+ "config-admin-box": "Moʻokāki kahu",
+ "config-admin-name": "Kāu inoa mea hoʻohana:",
+ "config-admin-password": "ʻŌlelo hūnā:",
+ "config-admin-password-confirm": "Kikokiko hou i kā ʻŌlelo hūnā:",
+ "config-admin-name-blank": "E kikokiko i kekahi inoa mea hoʻohana kahu.",
+ "config-profile-private": "Wiki pilikino",
+ "config-cc-again": "Koho hou...",
+ "config-install-step-done": "pau hana",
+ "config-help": "kōkua",
+ "config-nofile": "Loaʻa ʻole ka waihona \"$1.\" Ua holoi paha ʻia?",
+ "mainpagetext": "'''Ua pono ka ho‘ouka ‘ana o MediaWiki.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/he.json b/www/wiki/includes/installer/i18n/he.json
new file mode 100644
index 00000000..75834552
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/he.json
@@ -0,0 +1,324 @@
+{
+ "@metadata": {
+ "authors": [
+ "Amire80",
+ "YaronSh",
+ "ערן",
+ "아라",
+ "Inkbug",
+ "Yona b",
+ "Rotemliss",
+ "Macofe",
+ "Guycn2",
+ "שמזן"
+ ]
+ },
+ "config-desc": "תכנית ההתקנה של מדיה־ויקי",
+ "config-title": "התקנת מדיה־ויקי $1",
+ "config-information": "מידע",
+ "config-localsettings-upgrade": "זוהה קובץ <code>LocalSettings.php</code>.\nכדי לשדרג את ההתקנה הזאת, נא להקליד את הערך של <code>$wgUpgradeKey</code> בתיבה להלן.\nאפשר למצוא אותו בקובץ <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "זוהה קובץ <code>LocalSettings.php</code>.\nכדי לשדרג את ההתקנה הזאת, יש להריץ את <code>update.php</code> ולא את התהליך הזה.",
+ "config-localsettings-key": "מפתח השדרוג:",
+ "config-localsettings-badkey": "מפתח השדרוג שהקלדת שגוי.",
+ "config-upgrade-key-missing": "זוהתה התקנה קיימת של מדיה־ויקי.\nכדי לשדרג את ההתקנה הזאת, יש לכתוב את השורה הבא בתחתית קובץ <code>LocalSettings.php</code> שלך:\n\n$1",
+ "config-localsettings-incomplete": "נראה שקובץ <code>LocalSettings.php</code> הקיים אינו שלם.\nהמשתנה $1 אינו מוגדר.\nנא לשנות את הקובץ <code>LocalSettings.php</code> כך שהמשתנה הזה יהיה מוגדר וללחוץ \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "אירעה שגיאה בעת חיבור למסד נתונים עם הגדרות ב־<code>LocalSettings.php</code>. נא לתקן את ההגדרות האלו ולנסות שוב.\n\n$1",
+ "config-session-error": "שגיאה באתחול שיחה: $1",
+ "config-session-expired": "נראה שנתוני השיחה שלכם פגו.\nהשיחות מוגדרות להיות תקפות לזמן של $1.\nאפשר להגדיל את זה ב־<code>session.gc_maxlifetime</code> בקובץ php.ini.\nיש להתחיל מחדש את תהליך ההתקנה.",
+ "config-no-session": "נתוני השיחה שלכם אבדו!\nיש לבדוק את קובץ php.ini שלכם ולוודא שתיקייה נכונה מוגדרת ב־<code>session.save_path</code>.",
+ "config-your-language": "השפה שלך:",
+ "config-your-language-help": "נא לבחור את השפה שתשמש במהלך ההתקנה.",
+ "config-wiki-language": "שפת הוויקי:",
+ "config-wiki-language-help": "נא לבחור את השפה העיקרית שבה ייכתב ויקי זה.",
+ "config-back": "→ חזרה",
+ "config-continue": "המשך ←",
+ "config-page-language": "שפה",
+ "config-page-welcome": "ברוכים הבאים למדיה־ויקי!",
+ "config-page-dbconnect": "התחברות למסד הנתונים",
+ "config-page-upgrade": "שדרוג התקנה קיימת",
+ "config-page-dbsettings": "הגדרות מסד הנתונים",
+ "config-page-name": "שם",
+ "config-page-options": "אפשרויות",
+ "config-page-install": "התקנה",
+ "config-page-complete": "הושלמה!",
+ "config-page-restart": "הפעלת ההתקנה מחדש",
+ "config-page-readme": "קרא־אותי",
+ "config-page-releasenotes": "הערות גרסה",
+ "config-page-copying": "העתקה",
+ "config-page-upgradedoc": "שדרוג",
+ "config-page-existingwiki": "ויקי קיים",
+ "config-help-restart": "האם ברצונך לנקות את כל הנתונים שהזנת ולהתחיל מחדש את תהליך ההתקנה?",
+ "config-restart": "כן, להפעיל מחדש",
+ "config-welcome": "=== בדיקות סביבה ===\nבדיקות בסיסיות תתבצענה עכשיו כדי לראות אם הסביבה הזאת מתאימה להתקנת מדיה־ויקי.\nנא לזכור לכלול את המידע הזה בעת בקשת תמיכה עם השלמת ההתקנה.",
+ "config-copyright": "=== זכויות יוצרים ותנאים ===\n\n$1\n\nתכנית זו היא תכנה חופשית; באפשרותך להפיצה מחדש ו/או לשנות אותה על פי תנאי הרישיון הציבורי הכללי של GNU כפי שפורסם על ידי קרן התכנה החופשית; בין אם גרסה 2 של הרישיון, ובין אם (לפי בחירתך) כל גרסה מאוחרת שלו.\n\nתכנית זו מופצת בתקווה שתהיה מועילה, אבל '''בלא אחריות כלשהי'''; ואפילו ללא האחריות המשתמעת בדבר '''מסחריותה''' או '''התאמתה למטרה '''מסוימת'''. לפרטים נוספים, ניתן לעיין ברישיון הציבורי הכללי של GNU.\n\nלתכנית זו אמור היה להיות מצורף <doclink href=Copying>עותק של הרישיון הציבורי הכללי של GNU</doclink>; אם לא קיבלת אותו, אפשר לכתוב ל־Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA או [http://www.gnu.org/copyleft/gpl.html לקרוא אותו דרך האינטרנט].",
+ "config-sidebar": "* [https://www.mediawiki.org אתר הבית של מדיה־ויקי]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents המדריך למשתמש]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents המדריך למנהל]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ שו״ת]\n----\n* <doclink href=Readme>קרא אותי</doclink>\n* <doclink href=ReleaseNotes>הערות גרסה</doclink>\n* <doclink href=Copying>העתקה</doclink>\n* <doclink href=UpgradeDoc>שדרוג</doclink>",
+ "config-env-good": "הסביבה שלכם נבדקה.\nאפשר להתקין מדיה־ויקי.",
+ "config-env-bad": "הסביבה שלכם נבדקה.\nאי־אפשר להתקין מדיה־ויקי.",
+ "config-env-php": "מותקנת <span dir=\"ltr\">PHP $1</span>.",
+ "config-env-hhvm": "מותקנת <span dir=\"ltr\">HHVM $1</span>.",
+ "config-unicode-using-intl": "משתמש ב[http://pecl.php.net/intl הרחבת intl PECL] לנרמול יוניקוד.",
+ "config-unicode-pure-php-warning": "<strong>אזהרה:</strong> [http://pecl.php.net/intl הרחבת intl PECL] אינה זמינה לטיפול בנרמול יוניקוד. משתמש ביישום PHP טהור ואטי יותר.\nאם זהו אתר בעל תעבורה גבוהה, כדאי לקרוא את המסמך הבא: [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].",
+ "config-unicode-update-warning": "'''אזהרה''': הגרסה המותקנת של מעטפת נרמול יוניקוד משתמשת בגרסה ישנה של הספרייה של [http://site.icu-project.org/ פרויקט ICU].\nכדאי [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations לעדכן] אם הטיפול ביוניקוד חשוב לך.",
+ "config-no-db": "לא נמצא דרייבר מסד נתונים מתאים. יש להתקין דרייבר מסד נתונים ל־PHP.\n{{PLURAL:$2|נתמך הסוג הבא של מסד נתונים|נתמכים הסוגים הבאים של מסדי נתונים}}: $1.\n\nאם קִמפלת את PHP בעצמך, יש להגדיר אותו מחדש ולהפעיל את לקוח מסד נתונים, למשל באמצעות <code dir=\"ltr\">./configure --with-mysqli</code>.\nאם התקנת את PHP מחבילה של דביאן או של אובונטו, יש להתקין, למשל, גם את המודול <code dir=\"ltr\">php5-mysql</code>.",
+ "config-outdated-sqlite": "'''אזהרה''': במערכת מתוקן SQLite $1. גרסה זו לא נתמכת ולשימוש ב־SQLite נדרשת גרסה $2 לפחות. SQLlite לא יהיה זמין.",
+ "config-no-fts3": "'''אזהרה''': SQLite מהודר ללא [//sqlite.org/fts3.html מודול FTS]. יכולות חיפוש לא יהיו זמינות בהתקנה הזאת.",
+ "config-pcre-old": "<strong>שגיאה סופנית:</strong> חובה להתקין PCRE מגרסה $1 או גרסה חדשה יותר.\nקובץ הרצת ה־PHP שלך מקושר עם PCRE מגרסה $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE מידע נוסף].",
+ "config-pcre-no-utf8": "<strong>שגיאה סופנית</strong>: נראה שמודול PCRE של PHP מהודר ללא תמיכה ב־PCRE_UTF8.\nמדיה־ויקי דורשת תמיכה ב־UTF-8 לפעילות נכונה.",
+ "config-memory-raised": "ערך האפשרות <code>memory_limit</code> של PHP הוא $1, הועלה ל־$2.",
+ "config-memory-bad": "'''אזהרה:''' ערך האפשרות <code>memory_limit</code> של PHP הוא $1.\nזה כנראה נמוך מדי.\nההתקנה עשויה להיכשל!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] מותקן",
+ "config-apc": "[http://www.php.net/apc APC] מותקן",
+ "config-apcu": "[http://www.php.net/apcu APCu] מותקן",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] מותקן",
+ "config-no-cache-apcu": "<strong>אזהרה:</strong> לא נמצא [http://www.php.net/apcu APCu]‏, [http://xcache.lighttpd.net/ XCache] או [http://www.iis.net/download/WinCacheForPhp WinCache].\nמטמון עצמים לא מופעל.",
+ "config-mod-security": "'''אזהרה''': בשרת הווב שלך מופעל [http://modsecurity.org/ mod_security]. אם הוא לא מוגדר טוב, זה יכול לגרום לבעיות במדיה־ויקי ובתכנה אחרת שמאפשרת למשתמשים לשלוח תוכן שרירותי.\nיש לקרוא את [http://modsecurity.org/documentation/ התיעוד של mod_security] או ליצור קשר עם אנשי התמיכה של שירותי האירוח שלכם אם מופיעות לך שגיאות אקראיות.",
+ "config-diff3-bad": "GNU diff3 לא נמצא.",
+ "config-git": "נמצאה Git, תכנת בקרת התצורה: <code dir=\"ltr\">$1</code>.",
+ "config-git-bad": "תכנת בקרת התצורה Git לא נמצאה.",
+ "config-imagemagick": "נמצא ImageMagick&rlm;: <code dir=\"ltr\">$1</code>.\nמזעור תמונות יופעל אם תופעל האפשרות להעלות קבצים.",
+ "config-gd": "נמצאה ספריית הגרפיקה GD המובנית.\nמזעור תמונות יופעל אם תופעל האפשרות להעלות קבצים.",
+ "config-no-scaling": "ספריית GD או ImageMagick לא נמצאו.\nמזעור תמונות לא יופעל.",
+ "config-no-uri": "'''שגיאה:''' אי־אפשר לזהות את הכתובת הנוכחית.\nההתקנה בוטלה.",
+ "config-no-cli-uri": "אזהרה: לא הוגדר <span dir=\"ltr\"><code>--scriptpath</code></span>, משתמש בבררת המחדל: <code dir=\"ltr\">$1</code>.",
+ "config-using-server": "שם השרת בשימוש: \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "נעשה שימוש בכתובת השרת \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "'''אזהרה:''' תיקיית ההעלאות ההתחלתית <code>$1</code> חשופה להרצת סקריפטים שרירותיים.\nאף שמדיה־ויקי בודקת את כל הקבצים המוּעלים לאיומי אבטחה, מומלץ מאוד למנוע את [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security פרצת האבטחה] הזאת לפני הפעלת ההעלאות.",
+ "config-no-cli-uploads-check": "'''אזהרה:''' תיקיית בררת המחדל להעלאות (<code>$1</code>) לא נבדקת לפגיעוּת להרצת תסריטים אקראיים בזמן התקנה דרך CLI.",
+ "config-brokenlibxml": "במערכת שלך יש שילוב של גרסאות של PHP ושל libxml2 שחשוף לבאגים ויכול לגרום לעיוות נתונים נסתר במדיה־ויקי וביישומי רשת אחרים.\nיש לשדרג ל־libxml2 2.7.3 או גרסה חדשה יותר ([https://bugs.php.net/bug.php?id=45996 באג מתויק ב־PHP]).\nההתקנה נעצרה.",
+ "config-suhosin-max-value-length": "מותקן פה Suhosin והוא מגביל את אורך פרמטר GET ל־$1 בתים. רכיב ResourceLoader של מדיה־ויקי יעקוף את המגלבה הזאת, אבל זה יפגע בביצועים. אם זה בכלל אפשרי, כדאי לתקן את הערך של <code>suhosin.get.max_value_length</code> ל־1024 או יותר בקובץ <code>php.ini</code> ולהגדיר את ‎<code>$wgResourceLoaderMaxQueryLength</code> לאותו הערך בקובץ LocalSettings.php.",
+ "config-using-32bit": "<strong>אזהרה:</strong> נראה שהמערכת שלך רצה עם מספרים שלמים של 32 סיביות. זה [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit לא מומלץ].",
+ "config-db-type": "סוג מסד הנתונים:",
+ "config-db-host": "שרת מסד הנתונים:",
+ "config-db-host-help": "אם שרת מסד הנתונים שלך נמצא על שרת אחר, יש להקליד את שם המחשב או את כתובת ה־IP כאן.\n\nאם זה אירוח משותף, ספק האירוח שלכם אמור לתת לך את שם השרת הנכון במסמכים.\n\nאם זוהי התקנה בשרת Windows שמשתמשת ב־MySQL, השימוש ב־localhost עשוי לא לעבוד. אם הוא לא עובד, יש לנסות את \"127.0.0.1\" בתור כתובת ה־IP המקומית.\n\nאם זה PostgreSQL, תשאירו את השדה הזה ריק כדי להתחבר דרך שקע יוניקס.",
+ "config-db-host-oracle": "TNS של מסד הנתונים:",
+ "config-db-host-oracle-help": "יש להקליד [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm שם חיבור מקומי (Local Connect Name)] תקין; הקובץ tnsnames.ora צריך להיות זמין להתקנה הזאת.<br />\nאם יש פה ב־client libraries 10g או בגרסה חדשה יותר, אפשר להשתמש גם בשיטת מתן השמות [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "זיהוי ויקי זה",
+ "config-db-name": "שם מסד הנתונים:",
+ "config-db-name-help": "נא לבחור שם שמזהה את הוויקי שלכם.\nלא צריכים להיות בו רווחים.\n\nאם זהו משתמשים באירוח משותף, ספק האירוח שלכם ייתן לכם שם מסד נתונים מסוים שתוכלו להשתמש בו או יאפשר לכם ליצור מסד נתונים דרך לוח בקרה.",
+ "config-db-name-oracle": "סכמה של מסד נתונים:",
+ "config-db-account-oracle-warn": "קיימים שלושה תרחישים נתמכים עבור התקנת אורקל בתור מסד הנתונים:\n\nאם הרצונך ליצור חשבון מסד נתונים כחלק מתהליך ההתקנה, נא לספק חשבון בעל תפקיד SYSDBA בתור חשבון מסד הנתונים עבור ההתקנה ולציין את האישורים המבוקשים עבור חשבון הגישה לאינטרנט, אחרת ניתן ליצור באופן ידני את חשבון הגישה לאינטרנט, ולספק חשבון זה בלבד (אם יש לו ההרשאות הדרושות ליצירת עצמי סכמה) או לספק שני חשבונות שונים, אחד עם הרשאות יצירה ואחד מוגבלת עבור גישה לאינטרנט.\n\nסקריפט ליצירת חשבון עם ההרשאות הנדרשות ניתן למצוא בתיקייה \"<span dir=\"ltr\">maintenance/oracle/</span>\" של ההתקנה זו. נא לזכור כי שימוש בחשבון מוגבל יגרום להשבתת כל יכולות תחזוקה עם חשבון בררת המחדל.",
+ "config-db-install-account": "חשבון משתמש להתקנה",
+ "config-db-username": "שם המשתמש במסד הנתונים:",
+ "config-db-password": "הססמה במסד הנתונים:",
+ "config-db-install-username": "יש להכניס שם משתמש שישמש לחיבור למסד נתונים במהלך ההתקנה.\nזהו לא שם משתמש לחשבון במדיה־ויקי; זהו שם משתמש בשרת מסד נתונים.",
+ "config-db-install-password": "יש להקליד ססמה שתשמש אותך לצורך חיבור למסד נתונים במהלך ההתקנה.\nזוהי לא ססמה של חשבון במדיה־ויקי; זוהי ססמה לשרת מסד נתונים.",
+ "config-db-install-help": "יש להקליד את שם המשתמש ואת הססמה להתחברות למסד הנתונים במהלך ההתקנה.",
+ "config-db-account-lock": "להשתמש באותו שם המשתמש ובאותה ססמה בזמן הפעלה רגילה",
+ "config-db-wiki-account": "חשבון משתמש להפעלה רגילה",
+ "config-db-wiki-help": "הקלידו את שם המשתמש והססמה לחיבור למסד הנתונים במהלך פעילות רגילה של הוויקי.\nאם החשבון אינו קיים ולחשבון שבו מתבצעת ההתקנה יש הרשאות מספיקות, החשבון הזה ייווצר עם ההרשאות המזעריות הנחוצות להפעלת הוויקי.",
+ "config-db-prefix": "תחילית לטבלאות של מסד נתונים (database table prefix):",
+ "config-db-prefix-help": "אם נחוץ לך לשתף מסד נתונים אחד בין אתרי ויקי שונים או בין מדיה־ויקי ויישום וב אחר, אפשר לבחור להוסיף תחילית לכל שמות הטבלאות כדי להימנע מהתנגשויות.\nאין להשתמש ברווחים.\n\nהשדה הזה בדרך כלל אמור להיות ריק.",
+ "config-mysql-old": "נדרשת גרסה <span dir=\"ltr\">$1</span> של MySQL או גרסה חדשה יותר. הגרסה הנוכחית שלכם היא $2.",
+ "config-db-port": "פִּתְחַת מסד הנתונים (database port):",
+ "config-db-schema": "סכמה למדיה־ויקי:",
+ "config-db-schema-help": "הסְכֵמָה הבאה בדרך כלל מתאימה.\nיש לשנות אותה רק אם אם זה ממש נחוץ.",
+ "config-pg-test-error": "ההתחברות למסד הנתונים '''$1''' לא מצליחה: $2",
+ "config-sqlite-dir": "תיקיית נתונים (data directory) של SQLite:",
+ "config-sqlite-dir-help": "SQLite שומר את כל הנתונים בקובץ אחד.\n\nלשרת הווב צריכה להיות הרשאה לכתוב לתיקייה שמוגדרת כאן.\n\nהיא לא צריכה נגישה לכולם דרך האינטרנט – בגלל זה איננו שמים אותה באותו מקום עם קובצי ה־PHP.\n\nתוכנת ההתקנה תכתוב קובץ <code dir=\"ltr\">.htaccess</code> יחד אִתו, אבל אם זה ייכשל, מישהו יוכל להשיג גישה למסד הנתונים שלכם.\nשם נמצא מידע מפורש של משתמשים (כתובות דוא״ל, ססמאות מגובבות) וגם גרסאות מחוקות של דפים ומידע מוגבל אחר.\n\nכדאי לשקול לשים את מסד הנתונים במקום אחר לגמרי, למשל ב־<code dir=\"ltr\">/var/lib/mediawiki/yourwik</code>.",
+ "config-oracle-def-ts": "מרחב טבלאות לפי בררת מחדל (default tablespace):",
+ "config-oracle-temp-ts": "מרחב טבלאות זמני (temporary tablespace):",
+ "config-type-mysql": "MySQL (או תואם)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "מדיה־ויקי תומכת במערכות מסדי הנתונים הבאות:\n\n$1\n\nאם אינך רואה את מסד הנתונים שלך ברשימה, יש לעקוב אחר ההוראות המקושרות לעיל כדי להפעיל את התמיכה.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] הוא היעד העיקרי עבור מדיה־ויקי ולו התמיכה הטובה ביותר. מדיה־ויקי עובדת גם עם [{{int:version-db-mariadb-url}} MariaDB] ועם [{{int:version-db-percona-url}} Percona Server], שתואמים ל־MySQL. (ר׳ [http://www.php.net/manual/en/mysql.installation.php how to compile PHP with MySQL support])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] הוא מסד נתונים נפוץ בקוד פתוח והוא נפוץ בתור חלופה ל־MySQL. (ר׳ [http://www.php.net/manual/en/pgsql.installation.php how to compile PHP with PostgreSQL support]).",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] הוא מסד נתונים קליל עם תמיכה טובה מאוד. (ר׳ [http://www.php.net/manual/en/pdo.installation.php How to compile PHP with SQLite support], משתמש ב־PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] הוא מסד נתונים עסקי מסחרי. (ר׳ [http://www.php.net/manual/en/oci8.installation.php How to compile PHP with OCI8 support])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] הוא מסד נתונים עסקי מסחרי לחלונות. ([http://www.php.net/manual/en/sqlsrv.installation.php How to compile PHP with SQLSRV support])",
+ "config-header-mysql": "הגדרות MySQL",
+ "config-header-postgres": "הגדרות PostgreSQL",
+ "config-header-sqlite": "הגדרות SQLite",
+ "config-header-oracle": "הגדרות Oracle",
+ "config-header-mssql": "הגדרות Microsoft SQL Server",
+ "config-invalid-db-type": "סוג מסד הנתונים שגוי",
+ "config-missing-db-name": "יש להזין ערך עבור \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "יש להכניס ערך לשדה \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "יש להכניס ערך לשדה \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "\"$1\" הוא TNS מסד־נתונים בלתי־‏תקין.\nיש להשתמש ב־\"TNS name\" או במחרוזת \"Easy Connect\" (ר' [http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods])",
+ "config-invalid-db-name": "\"$1\" הוא שם מסד נתונים בלתי־תקין.\nיש להשתמש רק באותיות ASCII&rlm; (a עד z&rlm;, A עד Z), סְפָרוֹת (0 עד 9), קווים תחתיים (_) ומינוסים (-).",
+ "config-invalid-db-prefix": "\"$1\" היא תחילית מסד נתונים בלתי תקינה.\nיש להשתמש רק באותיות ASCII&rlm; (a עד z&rlm;, A עד Z), סְפָרוֹת (0 עד 9), קווים תחתיים (_) ומינוסים (-).",
+ "config-connection-error": "<div dir=\"ltr\">$1.</div>\n\nיש לבדוק את שם השרת, את שם המשתמש ואת הססמה בטופס להלן ולנסות שוב.",
+ "config-invalid-schema": "\"$1\" היא סכמה לא תקינה עבור מדיה־ויקי.\nיש להשתמש רק באותיות ASCII&rlm; (a עד z&rlm;, A עד Z), סְפָרוֹת (0 עד 9) וקווים תחתיים (_).",
+ "config-db-sys-create-oracle": "תוכנית ההתקנה תומכת רק בשימוש בחשבון SYSDBA ליצירת חשבון חדש.",
+ "config-db-sys-user-exists-oracle": "חשבון המשתמש \"$1\" כבר קיים. SYSDBA יכול לשמש רק ליצירת חשבון חדש!",
+ "config-postgres-old": "נדרש PostgreSQL $1 או גרסה חדשה יותר, הגרסה הנוכחית שלכם היא $2.",
+ "config-mssql-old": "חובה להשתמש ב־Microsoft SQL Server מגרסה $1 או גרסה חדשה יותר. הגרסה שלך היא $2.",
+ "config-sqlite-name-help": "יש לבחור בשם שמזהה את הוויקי שלכם.\nאין להשתמש ברווחים או במינוסים.\nזה יהיה שם קובץ הנתונים ל־SQLite.",
+ "config-sqlite-parent-unwritable-group": "לא ניתן ליצור את תיקיית הנתונים <code><nowiki>$1</nowiki></code>, כי לשָׁרַת הווב אין הרשאות לכתוב לתיקיית האם <code><nowiki>$2</nowiki></code> .\n\nתוכנת ההתקנה זיהתה את החשבון שתחתיו רץ שרת הווב שלכם.\nיש לאפשר לשָׁרַת הווב לכתוב לתיקייה <code><nowiki>$3</nowiki></code>.\nבמערכת Unix/Linux יש לכתוב:\n\n<div dir=\"ltr\"><pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre></div>",
+ "config-sqlite-parent-unwritable-nogroup": "לא ניתן ליצור את תיקיית הנתונים <code><nowiki>$1</nowiki></code>, כי לשָׁרַת הווב אין הרשאות לכתוב לתיקיית האם <code><nowiki>$2</nowiki></code>.\n\nתוכנת ההתקנה לא זיהתה את החשבון שתחתיו רץ שרת הווב שלכם.\nיש לאפשר לכל החשבונות לכתוב לתיקייה <code><nowiki>$3</nowiki></code> כדי להמשיך.\nבמערכת Unix/Linux יש לכתוב:\n\n<div dir=\"ltr\"><pre>cd $2\nmkdir $3\nchmod a+w $3</pre></div>",
+ "config-sqlite-mkdir-error": "אירעה שגיאה בעת יצירת תיקיית הנתונים \"$1\".\nנא לבדוק את המיקום ולנסות שוב.",
+ "config-sqlite-dir-unwritable": "אי־אפשר לכתוב לתיקייה \"$1\".\nיש לשנות את ההרשאות שלה כך ששרת הווב יוכל לכתוב אליה ולנסות שוב.",
+ "config-sqlite-connection-error": "$1.\n\nיש לבדוק את תיקיית הנתונים את שם מסד הנתונים להלן ולנסות שוב.",
+ "config-sqlite-readonly": "לא ניתן לכתוב אל הקובץ <code>$1</code>.",
+ "config-sqlite-cant-create-db": "לא ניתן ליצור את קובץ מסד הנתונים <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "ב־PHP חסרה תמיכה ב־FTS3, יבתצע שנמוך טבלאות",
+ "config-can-upgrade": "יש טבלאות מדיה־ויקי במסד הנתונים.\nכדי לשדרג אותן למדיה־ויקי $1, יש ללחוץ על '''המשך'''.",
+ "config-upgrade-done": "השדרוג הושלם.\n\nעכשיו אפשר [$1 להשתמש בוויקי שלך].\n\nאם ברצונך ליצור מחדש את קובץ ה־<code>LocalSettings.php</code> שלך, יש ללחוץ על הכפתור להלן.\nזה '''לא מומלץ''', אלא אם כן יש לך בעיות עם הוויקי שלכם.",
+ "config-upgrade-done-no-regenerate": "השדרוג הושלם.\n\nעכשיו אפשר [$1 להתחיל להשתמש בוויקי שלך].",
+ "config-regenerate": "לחולל מחדש את LocalSettings.php ←",
+ "config-show-table-status": "שאילתת <code>SHOW TABLE STATUS</code> נכשלה!",
+ "config-unknown-collation": "'''אזהרה:''' מסד הנתונים משתמש בשיטת מיון שאינה מוּכּרת.",
+ "config-db-web-account": "חשבון במסד הנתונים לגישה מהרשת",
+ "config-db-web-help": "לבחור את שם המשתמש ואת הססמה ששרת הווב ישתמש בו להתחברות לשרת מסד הנתונים בזמן פעילות רגילה של הוויקי.",
+ "config-db-web-account-same": "להשתמש באותו חשבון כמו עבור ההתקנה",
+ "config-db-web-create": "ליצור חשבון אם הוא אינו קיים כבר.",
+ "config-db-web-no-create-privs": "לחשבון שהקלדת להתקנה אין מספיק הרשאות ליצירת חשבון.\nהחשבון שאתם מקלידים כאן צריך להיות קיים.",
+ "config-mysql-engine": "מנוע האחסון:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''אזהרה''': בחרתם ב־MyISAM בתור מנוע אחסון של MySQL, וזה לא מומלץ מהסיבות הבאות:\n* המנוע הזה בקושי תומך בעיבוד מקבילי בגלל נעילת טבלאות\n* הוא פחות עמיד בפני אובדן מידע ממנועים אחרים\n* הקוד של מדיה־ויקי לא תמיד מטפל ב־MyISAM כפי שצריך\n\nאם התקנת MySQL שלכם תומכת ב־InnoDB, מומלץ מאוד שתבחרו באפשרות הזאת.\nאם התקנת MySQL שלכם אינה תומכת ב־InnoDB, אולי זה הזמן לשקול לשדרג אותה.",
+ "config-mysql-only-myisam-dep": "'''אזהרה:''' MyISAM הוא מנוע האחסון היחיד שזמין ל־MySQL במכונה הזאת, וזה לא מומלץ לשימוש עם מדיה־ויקי, כי:\n* הוא כמעט שאינו תומך בחיבורים מרובים בגלל נעילת טבלאות\n* הוא פגיע יותר לקלקול ממנועים אחרים\n* הקוד של מדיה־ויקי לא תמיד מטפל ב־MyISAM כפי שצריך\n\nהתקנת MySQL אינה תומכת ב־InnoDB, ואולי הגיע הזמן לשדרג אותה.",
+ "config-mysql-engine-help": "'''InnoDB''' היא כמעט תמיד האפשרות הטובה ביותר, כי במנוע הזה יש תמיכה טובה ביותר בעיבוד מקבילי.\n\n'''MyISAM''' עשוי להיות בהתקנות שמיועדות למשתמש אחד ולהתקנות לקריאה בלבד.\nמסדי נתונים עם MyISAM נוטים להיהרס לעתים קרובות יותר מאשר מסדי נתונים עם InnoDB.",
+ "config-mysql-charset": "קידוד התווים של מסד הנתונים:",
+ "config-mysql-binary": "בינרי",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "ב'''מצב בינרי''' (binary mode) מדיה־ויקי שומרת טקסט UTF-8 במסד הנתונים בשדות בינריים.\nזה יעיל יותר ממצב UTF-8 של MySQL ומאפשר לכם להשתמש בכל הטווח של תווי יוניקוד.\n\nב'''מצב UTF-8'''&rlm; (UTF-8 mode)&rlm; MySQL יֵדַע מה קידוד התווים (character set) של הטקסט שלכם ויציג וימיר אותו בהתאם, אבל לא יאפשר לכם לשמור תווים שאינם נמצאים בטווח הרב־לשוני הבסיסי ([https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane]).",
+ "config-mssql-auth": "סוג אימות:",
+ "config-mssql-install-auth": "נא לבחור את סוג האימות שישמש להתחברות למסד הנתונים בזמן תהליך ההתקנה. בחירה ב־\"{{int:config-mssql-windowsauth}}\" תשתמש בהרשאות של החשבון שמריץ את השרת הנוכחי.",
+ "config-mssql-web-auth": "נא לבחור את סוג האימות שישמש את השרת להתחברות למסד הנתונים בזמן הריצה הרגילה של הוויקי.\nבחירה ב־\"{{int:config-mssql-windowsauth}}\" תשתמש בהרשאות של החשבון שמריץ את השרת הנוכחי.",
+ "config-mssql-sqlauth": "SQL Server Authentication",
+ "config-mssql-windowsauth": "Windows Authentication",
+ "config-site-name": "שם הוויקי:",
+ "config-site-name-help": "זה יופיע בשורת הכותרת של הדפדפן ובמקומות רבים אחרים.",
+ "config-site-name-blank": "נא להזין שם לאתר.",
+ "config-project-namespace": "מרחב שמות לדפי מיזם:",
+ "config-ns-generic": "מיזם",
+ "config-ns-site-name": "זהה לשם הוויקי: $1",
+ "config-ns-other": "אחר (לציין)",
+ "config-ns-other-default": "הוויקי שלי",
+ "config-project-namespace-help": "בהתאם לדוגמה של ויקיפדיה, אתרי ויקי רבים שומרים על דפי המדיניות שלהם בנפרד מדפי התוכן שלהם ב'''מרחב השמות של המיזם''' (באנגלית: '''project namespace''').\nכל שמות הדפים במרחב השמות הזה מתחילים בתחילית מסוימת שאפשר להגדיר כאן.\nבדרך כלל התחילית הזאת מבוססת על שם הוויקי, והיא אינה יכולה להכיל תווי פיסוק כגון \"#\" או \":\".",
+ "config-ns-invalid": "מרחב השמות \"<nowiki>$1</nowiki>\" אינו תקין.\nיש להקליד שם אחר למרחב השמות של המיזם.",
+ "config-ns-conflict": "מרחב השמות שהגדרת \"<nowiki>$1</nowiki>\" מתנגש עם מרחב שמות מובנה של מדיה־ויקי.\nהגדירו מרחב שמות מיזם שונה.",
+ "config-admin-box": "חשבון מפעיל",
+ "config-admin-name": "שם המשתמש שלך:",
+ "config-admin-password": "ססמה:",
+ "config-admin-password-confirm": "הססמה שוב:",
+ "config-admin-help": "הקלידו כאן את שם המשתמש, למשל \"שקד לוי\" או \"Joe Bloggs\".\nזה השם שישמש אותך כדי להיכנס לוויקי.",
+ "config-admin-name-blank": "נא להזין את שם המשתמש של המפעיל.",
+ "config-admin-name-invalid": "שם המשתמש שהוקלד \"<nowiki>$1</nowiki>\" אינו תקין.\nיש להקליד שם משתמש אחר.",
+ "config-admin-password-blank": "הקלידו ססמה לחשבון המפעיל.",
+ "config-admin-password-mismatch": "שתי הססמאות שהוזנו אינן מתאימות.",
+ "config-admin-email": "כתובת הדוא״ל:",
+ "config-admin-email-help": "יש להקליד כתובת דוא״ל כדי שתהיה אפשרות לקבל מכתבים ממשתמשים אחרים בוויקי, לאתחל את הססמה, ולקבל הודעות על שינויים בדפים ברשימת המעקב. אפשר להשאיר את השדה הזה ריק.",
+ "config-admin-error-user": "שגיאה פנימית ביצירת מפעיל בשם \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "שגיאה פנימית בהגדרת ססמה עבור המפעיל \"<nowiki>$1</nowiki>\"&rlm;: <pre>$2</pre>",
+ "config-admin-error-bademail": "הכנסת כתובת דוא״ל לא תקינה.",
+ "config-subscribe": "להירשם ל[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce רשימת התפוצה עם הודעות על גרסאות חדשות].",
+ "config-subscribe-help": "זוהי רשימת תפוצה עם הודעות מעטות שמשמשת להודעות על הוצאת גרסאות, כולל עדכוני אבטחה חשובים.\nמומלץ להירשם אליה ולעדכן את מדיה־ויקי כאשר יוצאות גרסאות חדשות.",
+ "config-subscribe-noemail": "ניסית להירשם לרשימת תפוצה של הודעות בלי לתת כתובת דוא\"ל.\nנא לתת כתובת דוא\"ל אם ברצונך להירשם לרשימת התפוצה.",
+ "config-pingback": "לשתף נתונים אודות ההתקנה הזו עם מפתחי מדיה־ויקי.",
+ "config-pingback-help": "אם האפשרות הזאת תיבחר, מדיה־ויקי תודיע לאתר https://www.mediawiki.org נתונים בסיסיים על מופע המדיה־ויקי הזה. הנתונים האלה כוללים, למשל, את סוג המערכת, גרסת ה־PHP, ושרת מסד הנתונים שבחרת. קרן ויקימדיה משתפת את הנתונים האלה עם מפתחי מדיה־ויקי כדי לעזור למאמצי הפיתוח העתידיים. הנתונים הבאים יישלחו מהמערכת שלך:\n<pre>$1</pre>",
+ "config-almost-done": "כמעט סיימת!\nאפשר לדלג על שאר ההגדרות ולהתקין את הוויקי כבר עכשיו.",
+ "config-optional-continue": "הצגת שאלות נוספות.",
+ "config-optional-skip": "משעמם לי, תתקינו לי כבר את הוויקי הזה.",
+ "config-profile": "תסריט הרשאות משתמשים:",
+ "config-profile-wiki": "ויקי פתוח",
+ "config-profile-no-anon": "נדרשת יצירת חשבון",
+ "config-profile-fishbowl": "עורכים מורשים בלבד",
+ "config-profile-private": "ויקי פרטי",
+ "config-profile-help": "אתרי ויקי עובדים הכי טוב כאשר הם מאפשרים לכמה שיותר אנשים לערוך אותם.\nבמדיה־ויקי קל לסקור את השינויים האחרונים ולשחזר כל נזק שעושים משתמשים תמימים או משחיתים.\n\nעם זאת, אנשים שונים מצאו למדיה־ויקי שימושים מגוּונים ולעתים לא קל לשכנע את כולם ביתרונות של \"דרך הוויקי\" המסורתית. ולכן יש לך בררה.\n\nבאתר מסוג '''{{int:config-profile-wiki}}''' – לכולם יש הרשאה לערוך, אפילו בלי להיכנס לחשבון.\nבאתר וויקי מסוג '''{{int:config-profile-no-anon}}''' יש ביטחון גדול יותר, אבל הגדרה כזאת יכולה להרתיע תורמים מזדמנים.\n\nבתסריט '''{{int:config-profile-fishbowl}}''' רק משתמשים שקיבלו אישור יכולים לערוך, אבל כל הגולשים יכולים לקרוא את הדפים ואת גרסאותיהם הקודמות.\nב'''{{int:config-profile-private}}''' רק משתמשים שקיבלו אישור יכולים לקרוא ולערוך דפים.\n\nהגדרות מורכבות של הרשאות אפשריות אחרי ההתקנה, ר׳ את [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights הפרק על הנושא הזה בספר ההדרכה].",
+ "config-license": "זכויות יוצרים ורישיון:",
+ "config-license-none": "ללא כותרת תחתית עם רישיון",
+ "config-license-cc-by-sa": "קריאייטיב קומונז–ייחוס–שיתוף זהה",
+ "config-license-cc-by": "קריאייטיב קומונז–ייחוס",
+ "config-license-cc-by-nc-sa": "קריאייטיב קומונז ייחוס–ללא שימוש מסחרי–שיתוף זהה",
+ "config-license-cc-0": "קריאייטיב קומונז אפס (נחלת הכלל)",
+ "config-license-gfdl": "רישיון חופשי למסמכים של גנו גרסה 1.3 או חדשה יותר",
+ "config-license-pd": "נחלת הכלל",
+ "config-license-cc-choose": "בחירת רישיון קריאייטיב קומונז מותאם אישית",
+ "config-license-help": "אתרי ויקי ציבוריים רבים מפרסמים את כל התרומות [http://freedomdefined.org/Definition ברישיון חופשי].\nזה עוזר ליצור תחושה של בעלות קהילתית ומעודד תרומה לאורך זמן.\nזה בדרך כלל לא נחוץ לאתר ויקי פרטי או אתר של חברה מסחרית.\n\nאם האפשרות להשתמש בטקסט מוויקיפדיה והאפשרות שוויקיפדיה תוכל תקבל עותקים של טקסטים מהוויקי שלך חשובות לך, כדאי לבחור ב<strong>{{int:config-license-cc-by-sa}}</strong>.\n\nויקיפדיה השתמשה בעבר ברישיון החופשי למסמכים של גנו (GNU FDL או GFDL).\nהוא עדיין רישיון תקין, אבל קשה להבנה.\nכמו־כן, קשה לעשות שימוש חוזר ביצירות שפורסמו לפי GFDL.",
+ "config-email-settings": "הגדרות דוא״ל",
+ "config-enable-email": "להפעיל דוא״ל יוצא",
+ "config-enable-email-help": "אם אתם רוצים שדוא״ל יעבוד, [http://www.php.net/manual/en/mail.configuration.php אפשרויות הדוא״ל של PHP] צריכות להיות מוגדרות נכון.\nאם אינכם רוצים להפעיל שום אפשרויות דוא״ל, כבו אותן כאן ועכשיו.",
+ "config-email-user": "להפעיל שליחת דוא״ל ממשתמש למשתמש",
+ "config-email-user-help": "לאפשר לכל המשתמשים לשלוח אחד לשני דוא״ל אם הם הפעילו את זה בהעדפות שלהם.",
+ "config-email-usertalk": "להפעיל הודעות על דף שיחת משתמש",
+ "config-email-usertalk-help": "לאפשר למשתמשים לקבל הודעות על שינויים בדפי המשתמש שלהם, אם הם הפעילו את זה בהעדפות שלהם.",
+ "config-email-watchlist": "הפעלת התרעה על רשימת המעקב",
+ "config-email-watchlist-help": "לאפשר למשתמשים לקבל הודעות על הדפים ברשימת המעקב שלהם אם הם הפעילו את זה בהעדפות שלהם.",
+ "config-email-auth": "הפעלת התרעה בדוא״ל",
+ "config-email-auth-help": "אם האפשרות הזאת מופעלת, משתמשים יצטרכו לאשר את כתובת הדוא״ל שלהם באמצעות קישור שיישלח אליהם בכל פעם שהם יגדירו או ישנו אותה.\nרק כתובות דוא״ל מאושרות יכולות לקבלת דוא״ל ממשתמשים אחרים או מכתבים עם הודעות על שינויים.\n'''מומלץ''' להגדיר את האפשרות הזאת לאתרי ויקי ציבוריים כי אפשר לעשות שימוש לרעה בתכונות הדוא״ל.",
+ "config-email-sender": "כתובת דוא״ל לתשובות:",
+ "config-email-sender-help": "הכניסו את כתובת הדוא״ל שתשמש ככתובת לתשובה לכל הדואר היוצא.\nלשם יישלחו תגובות שגיאה (bounce).\nשרתי דוא״ל רבים דורשים שלפחות החלק של המתחם יהיה תקין.",
+ "config-upload-settings": "העלאת קבצים ותמונות",
+ "config-upload-enable": "להפעיל העלאת קבצים",
+ "config-upload-help": "העלאת קבצים חושפת את השרת שלך לסיכוני אבטחה.\nלמידע נוסף, אפשר לקרוא את [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security המדריך הזה].\n\nכדי לאפשר העלאות של קבצים, יש לשנות את ההרשאות של התיקייה <code>images</code> תחת תיקיית השורש של מדיה־ויקי כדי ששרת ה־web יוכל לכתוב אליה.\nלאחר מכן יש להפעיל את האפשרות הזאת.",
+ "config-upload-deleted": "תיקיית לקבצים שנמחקו:",
+ "config-upload-deleted-help": "בחרו את התיקייה לארכוב קבצים מחוקים.\nכדאי שזה לא יהיה נגיש לכל העולם דרך הרשת.",
+ "config-logo": "כתובת הסמל:",
+ "config-logo-help": "המראה ההתחלתי של מדיה־ויקי מכיל מקום לסמל של 135 על 160 פיקסלים בפינה העליונה מעל תפריט הצד.\nיש להעלות תמונה בגודל מתאים ולהכניס את הכתובת כאן.\n\nבאפשרותך להשתמש ב־<code dir=\"ltr\">$wgStylePath</code> או ב־<code dir=\"ltr\">$wgScriptPath</code> אם הסמל שלך נמצא במקום יחסי לנתיבים האלה.\n\nאם אינכם רוצים סמל, השאירו את התיבה הזאת ריקה.",
+ "config-instantcommons": "להפעיל את Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] היא תכונה שמאפשרת לאתרי ויקי להשתמש בתמונות, בצלילים ובמדיה אחרת שנמצאת באתר [https://commons.wikimedia.org/ ויקישיתוף] (Wikimedia Commons).\nכדי לעשות את זה, מדיה־ויקי צריך לגשת לאינטרנט.\n\nלמידע נוסף על התכונה הזאת, כולל הוראות איך להפעיל את זה לאתרי ויקי שאינם ויקישיתוף, ר׳ [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos את ספר ההדרכה].",
+ "config-cc-error": "בורר רישיונות קריאייטיב קומונז לא החזיר שום תוצאה.\nהקלידו את שם הרישיון ידנית.",
+ "config-cc-again": "נא לבחור שוב...",
+ "config-cc-not-chosen": "בחרו באיזה רישיון קריאייטיב קומונז להשתמש ולחצו \"proceed\".",
+ "config-advanced-settings": "הגדרות מתקדמות",
+ "config-cache-options": "הגדרות למטמון עצמים (object caching):",
+ "config-cache-help": "מטמון עצמים משמש לשיפור המהירות של מדיה־ויקי על־ידי שמירה של נתונים שהשימוש בהם נפוץ במטמון.\nלאתרים בינוניים וגדולים כדאי מאוד להפעיל את זה, וגם אתרים קטנים ייהנו מזה.",
+ "config-cache-none": "ללא מטמון (שום יכולת אינה מוּסרת, אבל הביצועים באתרים גדולים ייפגעו)",
+ "config-cache-accel": "מטמון עצמים (object caching) של PHP&rlm; (APC,&rlm; APCu,&rlm; XCache או WinCache)",
+ "config-cache-memcached": "להשתמש ב־Memcached (דורש התקנות והגדרות נוספות)",
+ "config-memcached-servers": "שרתי Memcached:",
+ "config-memcached-help": "רשימת כתובות IP ש־Memcached ישתמש בהן.\nיש לרשום כתובת אחת בכל שורה ולציין את הפִּתְחָה (port), למשל:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "בחרת ב־Memcached בתתור סוג המטמון שלכם, אבל לא הגדרתם שום שרת.",
+ "config-memcache-badip": "כתובת ה־IP שהקלדת עבור Memcached בלתי־תקינה: $1.",
+ "config-memcache-noport": "לא הגדרתם פתחה לשימוש שרת Memcached&rlm;: $1.\nאם אינכם יודעים את מספר הפתחה, בררת המחדל היא 11211.",
+ "config-memcache-badport": "מספרי פתחה של Memcached צריכים להיות בין $1 ל־$2",
+ "config-extensions": "הרחבות",
+ "config-extensions-help": "ההרחבות ברשימה לעיל התגלו בתיקיית <span dir=\"ltr\"><code>./extensions</code></span> שלכם.\n\nייתכן שזה ידרוש הגדרות נוספות, אבל תוכלו להפעיל אותן עכשיו.",
+ "config-skins": "עיצובים",
+ "config-skins-help": "העיצובים לעיל נמצאו בתיקיית ה־<code dir=\"ltr\">./skins</code> שלך. חובה להפעיל לפחות אחת ולבחור בררת מחדל.",
+ "config-skins-use-as-default": "להשתמש בזה בתור בררת מחדל",
+ "config-skins-missing": "לא נמצאו עיצובים; מדיה־ויקי תשתמש בעיצוב גיבוי עד התקנת משהו מתאים.",
+ "config-skins-must-enable-some": "חובה לבחור לפחות עיצוב אחד שיופעל.",
+ "config-skins-must-enable-default": "העיצוב שנבחר בתור בררת מחדל חייב להיות מופעל.",
+ "config-install-alreadydone": "'''אזהרה:''' נראה שכבר התקנתם את מדיה־ויקי ואתם מנסים להתקין אותה שוב.\nאנה התקדמו לדף הבא.",
+ "config-install-begin": "כשתלחצו על \"{{int:config-continue}}\", תתחילו את ההתקנה של מדיה־ויקי.\nאם אתם עדיין רוצים לשנות משהו, לחצו על \"{{int:config-back}}\".",
+ "config-install-step-done": "בוצע",
+ "config-install-step-failed": "נכשל",
+ "config-install-extensions": "כולל הרחבות",
+ "config-install-database": "הקמת מסד נתונים",
+ "config-install-schema": "יצירת סכמה",
+ "config-install-pg-schema-not-exist": "סכמה של PostgreSQL אינה קיימת",
+ "config-install-pg-schema-failed": "יצירת טבלאות נכשלה.\nודאו כי המשתמש \"$1\" יכול לכתוב לסכמה \"$2\".",
+ "config-install-pg-commit": "שמירת שינויים",
+ "config-install-pg-plpgsql": "בדיקת שפת PL/pgSQL",
+ "config-pg-no-plpgsql": "צריך להתקין את שפת PL/pgSQL במסד הנתונים $1",
+ "config-pg-no-create-privs": "לחשבון שהגדרתם להתקנה אין מספיק הרשאות ליצירת חשבון.",
+ "config-pg-not-in-role": "החשבון שציינתם עבור משתמש שרת הווב כבר קיים.\nהחשבון שסיפקתם להתקנה אינו חשבון בעל הרשאות (superuser) ואינו חבר בתפקיד (role) של משתמש שרת הווב, אז אין אפשרות ליצור עצמים בבעלות משתמש שרת הווב.\n\nכעת נדרש במדיה־ויקי שהטבלאות יהיו בבעלות של משתמש שרת הווב. נא לציין שם חשבון שרת וב אחר או ללחוץ על כפתור \"אחורה\" ולציין משתמש התקנה בעל הרשאות מתאימות.",
+ "config-install-user": "יצירת חשבון במסד נתונים",
+ "config-install-user-alreadyexists": "המשתמש \"$1\" כבר קיים",
+ "config-install-user-create-failed": "יצירת משתמש \"$1\" נכשלה: $2",
+ "config-install-user-grant-failed": "מתן הרשאות למשתמש \"$1\" נכשל: $2",
+ "config-install-user-missing": "המשתמש \"$1\" שצוין אינו קיים.",
+ "config-install-user-missing-create": "המשתמש \"$1\" שצוין אינו קיים.\nנא ללחוץ על תיבת בסימון \"יצירת חשבון\" להלן אם אתם רוצים ליצור אותו.",
+ "config-install-tables": "יצירת טבלאות",
+ "config-install-tables-exist": "'''אזהרה:''' נראה שטבלאות מדיה־ויקי כבר קיימות.\nמדלג על יצירתן.",
+ "config-install-tables-failed": "'''שגיאה:''' יצירת הטבלה נכשלה עם השגיאה הבאה: $1",
+ "config-install-interwiki": "אכלוס טבלת בינוויקי התחלתית",
+ "config-install-interwiki-list": "קריאת הקובץ <code>interwiki.list</code> לא הצליחה.",
+ "config-install-interwiki-exists": "'''אזהרה:''' נראה שבטבלת הבינוויקי כבר יש רשומות.\nמדלג על הרשומה ההתחלתית.",
+ "config-install-stats": "אתחול סטטיסטיקות",
+ "config-install-keys": "יצירת מפתחות סודיים",
+ "config-insecure-keys": "'''אזהרה''': {{PLURAL:$2|מפתח|מפתחות}} אבטחה ($1) {{PLURAL:$2|שנוצר|שנוצרו}} במהלך ההתקנה {{PLURAL:$2|אינו בטוח|אינם בטוחים}} מספיק. מומלץ לשקול לשנות {{PLURAL:$2|אותו|אותם}} ידנית.",
+ "config-install-updates": "למנוע הרצת עדכונים מיותרים",
+ "config-install-updates-failed": "<strong>שגיאה:</strong> הוספת מפתחות עדכון לטבלאות נכשל עם השגיאה הבאה: $1",
+ "config-install-sysop": "יצירת חשבון מפעיל",
+ "config-install-subscribe-fail": "הרישום ל־mediawiki-announce לא הצליח: $1",
+ "config-install-subscribe-notpossible": "cURL אינה מותקנת ו־<code>allow_url_fopen</code> אינה זמינה.",
+ "config-install-mainpage": "יצירת דף ראשי עם תוכן התחלתי",
+ "config-install-mainpage-exists": "העמוד הראשי כבר קיים, לדלג",
+ "config-install-extension-tables": "יצירת טבלאות להרחבות מופעלות",
+ "config-install-mainpage-failed": "לא הצליחה הכנסת דף ראשי: $1.",
+ "config-install-done": "<strong>מזל טוב!</strong>\nהתקנת את תוכנת מדיה־ויקי.\n\nתוכנת ההתקנה יצרה את הקובץ <code>LocalSettings.php</code>.\nהוא מכיל את כל ההגדרות שלך.\n\nיש להוריד אותו ולהכניס אותו לתיקיית הבסיס שבה הותקן הוויקי שלך (אותה התיקייה שבה נמצא הקובץ index.php). ההורדה אמורה להתחיל באופן אוטומטי.\n\nאם ההורדה לא התחילה, או אם ביטלת אותה, אפשר להתחיל אותה מחדש באמצעות לחיצה על הקישור הבא:\n\n$3\n\n<strong>לתשומת לבך:</strong> אם ההורדה לא תבוצע כעת, קובץ ההגדרות <strong>לא</strong> יהיה זמין מאוחר יותר אם תוכנת ההתקנה תיסגר לפני שהקובץ יורד.\n\nלאחר שביצעת את הפעולות שלהלן, באפשרותך <strong>[$2 להיכנס לאתר הוויקי שלך]</strong>.",
+ "config-install-done-path": "<strong>מזל טוב!</strong>\nהתקנת את תוכנת מדיה־ויקי.\n\nתוכנת ההתקנה יצרה את הקובץ <code>LocalSettings.php</code>.\nהוא מכיל את כל ההגדרות שלך.\n\nיש להוריד אותו ולהכניס אותו לתיקייה <code>$4</code>. ההורדה אמורה להתחיל באופן אוטומטי.\n\nאם ההורדה לא התחילה, או אם ביטלת אותה, אפשר להתחיל אותה מחדש באמצעות לחיצה על הקישור הבא:\n\n$3\n\n<strong>לתשומת לבך:</strong> אם ההורדה לא תבוצע כעת, קובץ ההגדרות <strong>לא</strong> יהיה זמין מאוחר יותר אם תוכנת ההתקנה תיסגר לפני שהקובץ יורד.\n\nלאחר שביצעת את הפעולות שלהלן, באפשרותך <strong>[$2 להיכנס לאתר הוויקי שלך]</strong>.",
+ "config-download-localsettings": "הורדת <code>LocalSettings.php</code>",
+ "config-help": "עזרה",
+ "config-help-tooltip": "להרחיב",
+ "config-nofile": "הקובץ \"$1\" לא נמצא. האם הוא נמחק?",
+ "config-extension-link": "הידעת שמדיה־ויקי תומכת ב־[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions הרחבות]?\n\nבאפשרותך לעיין ב־[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category הרחבות לפי קטגוריה].",
+ "config-skins-screenshots": "$1 (צילומי מסך: $2)",
+ "config-screenshot": "צילום מסך",
+ "mainpagetext": "<strong>תוכנת מדיה־ויקי הותקנה בהצלחה.</strong>",
+ "mainpagedocfooter": "היעזרו ב[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents מדריך למשתמש] למידע על שימוש בתוכנת הוויקי.\n\n== קישורים שימושיים ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings רשימת ההגדרות]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ שאלות ותשובות על מדיה־ויקי]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce רשימת התפוצה על השקת גרסאות]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources תרגום מדיה־ויקי לשפה שלך]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam איך להיאבק נגד ספאם באתר הוויקי שלך]"
+}
diff --git a/www/wiki/includes/installer/i18n/hi.json b/www/wiki/includes/installer/i18n/hi.json
new file mode 100644
index 00000000..9863beb1
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hi.json
@@ -0,0 +1,113 @@
+{
+ "@metadata": {
+ "authors": [
+ "Smtchahal",
+ "Vivek Rai",
+ "Phoenix303",
+ "संजीव कुमार",
+ "Sahilrathod",
+ "Shyamal",
+ "Sfic",
+ "Sachinkatiyar"
+ ]
+ },
+ "config-desc": "साँचा लिए इंस्टॉलर",
+ "config-title": "मीडियाविकी $1 इंस्टॉलेशन",
+ "config-information": "जानकारी",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code> फ़ाइल पाया गया है ।\nइस स्थापना को अपग्रेड करने के लिए, नीचे दिए गए बॉक्स में <code>$wgUpgradeKey</code> का मान दर्ज करें।\nआपको <code>LocalSettings.php</code> में मिल जाएगा।",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code> फ़ाइल पाया गया है ।\nइस स्थापना को अपग्रेड करने के लिए, बदले में कृपया करके ये चलाए <code>update.php</code>",
+ "config-localsettings-key": "नवीनीकरण कुंजी",
+ "config-localsettings-badkey": "आपकी दी गई कुंजी ग़लत है।",
+ "config-session-error": "सत्र शुरू करने में त्रुटि: $1",
+ "config-session-expired": "लग रहा है कि आपका सत्र डाटा समाप्त हो चुका है।\n$1 हेतु आप इस डाटा को हमेशा के लिए भी कर सकते हैं।\nइसे बढ़ाने हेतु आपको php.ini में <code>session.gc_maxlifetime</code> को बढ़ाना होगा।\nउसके बाद आपको स्थापित करने का कार्य फिर से करना होगा।",
+ "config-no-session": "आपका सत्र डाटा गुम हो गया।\nअपने php.ini को देखें कि <code>session.save_path</code> ठीक तरीके से सही पते पर तो है ना?",
+ "config-your-language": "आपकी भाषा:",
+ "config-your-language-help": "स्थापन के लिए भाषा चुनें",
+ "config-wiki-language": "विकि भाषा:",
+ "config-wiki-language-help": "भाषा चुनें जिस में अधिकतर लेख लिखा जाएगा",
+ "config-back": "← वापस",
+ "config-continue": "आगे बढ़ें →",
+ "config-page-language": "भाषा",
+ "config-page-welcome": "मीडियाविकि में आपका स्वागत है!",
+ "config-page-dbconnect": "डेटाबेस से जोड़ें",
+ "config-page-upgrade": "मौजूदा स्थापना का नवीनीकरण",
+ "config-page-dbsettings": "डेटाबेस सेटिंग (पसंद)",
+ "config-page-name": "नाम",
+ "config-page-options": "विकल्प",
+ "config-page-install": "स्थापित करें",
+ "config-page-complete": "पूर्ण!",
+ "config-page-restart": "स्थापना को पुनरारंभ करें",
+ "config-page-readme": "मुझे पढ़ें",
+ "config-page-releasenotes": "रिलीज नोट्स",
+ "config-page-copying": "अनुकरण",
+ "config-page-upgradedoc": "उन्नयन",
+ "config-page-existingwiki": "मौजूदा विकि",
+ "config-help-restart": "क्या आप अपने द्वारा दिये सभी डाटा को मिटाना चाहते हैं और फिर से स्थापित करना चाहते हैं?",
+ "config-restart": "हाँ, इसे पुनः आरंभ करें",
+ "config-env-php": "PHP $1 स्थापित किया गया है।",
+ "config-env-hhvm": "एचएचवीएम $1 स्थापित किया गया है।",
+ "config-memory-raised": "पीएचपी की <code>memory_limit</code> सीमा $1 है, जो $2 तक बढ़ गई है।",
+ "config-xcache": "[http://xcache.lighttpd.net/ एक्सकैश] स्थापित है।",
+ "config-apc": "[http://www.php.net/apc एपीसी] स्थापित है।",
+ "config-apcu": "[http://www.php.net/apcu एपीसीयू] स्थापित है।",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp विनकैश] स्थापित है।",
+ "config-using-32bit": "<विशेष>चेतावनी:</विशेष> आपका सिस्टम 32-बिट पूर्णांक के साथ चल रहा है यह [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit विवेचित नहीं है]।",
+ "config-db-type": "डेटाबेस प्रकार:",
+ "config-db-host": "डेटाबेस होस्ट:",
+ "config-db-host-oracle": "डेटाबेस टीएनएस:",
+ "config-db-wiki-settings": "इस विकि को पहचानें",
+ "config-db-name": "डेटाबेस का नाम:",
+ "config-db-install-account": "इसे स्थापित करने हेतु सदस्य खाता",
+ "config-db-username": "डेटाबेस सदस्यनाम:",
+ "config-db-password": "डेटाबेस पासवर्ड:",
+ "config-db-port": "डेटाबेस पोर्ट:",
+ "config-type-mssql": "माइक्रोसॉफ़्ट एसक्यूएल सर्वर",
+ "config-invalid-db-type": "अमान्य डेटाबेस प्रकार",
+ "config-regenerate": "LocalSettings.php फिर से निर्मित करें →",
+ "config-db-web-account": "वेब पहुँच हेतु डेटाबेस खाता",
+ "config-mysql-innodb": "इनोडीबी",
+ "config-mysql-binary": "बाइनरी",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "प्रमाणन प्रकार:",
+ "config-mssql-sqlauth": "SQL सर्वर प्रमाणन",
+ "config-site-name": "विकि का नाम:",
+ "config-site-name-blank": "एक साइट का नाम लिखें",
+ "config-project-namespace": "प्रकल्प नामस्थान:",
+ "config-ns-generic": "प्रकल्प",
+ "config-ns-other": "अन्य (निर्दिष्ट करें)",
+ "config-ns-other-default": "मेरा विकि",
+ "config-admin-box": "व्यवस्थापक खाता",
+ "config-admin-name": "आपका सदस्य नाम:",
+ "config-admin-password": "कूटशब्द:",
+ "config-admin-password-confirm": "फिर से कूटशब्द:",
+ "config-admin-name-blank": "प्रबन्धक का सदस्य नाम लिखें।",
+ "config-admin-email": "ईमेल पता:",
+ "config-optional-continue": "मुझसे और सवाल पूछें।",
+ "config-optional-skip": "मैं पहले से ही ऊब चुका हूँ, बस विकि स्थापित करें।",
+ "config-profile-wiki": "खुला विकि",
+ "config-profile-no-anon": "खाता बनाने की आवश्यकता",
+ "config-profile-fishbowl": "केवल प्रमाषित संपादक ही",
+ "config-profile-private": "निजी विकि",
+ "config-license-cc-by": "क्रिएटिव कॉमन्स ऍट्रीब्यूशन",
+ "config-license-pd": "सार्वजनिक डोमैन",
+ "config-email-watchlist": "ध्यानसूची अधिसूचना को सक्षम करें",
+ "config-upload-enable": "फ़ाइल अपलोड सक्रिय करें",
+ "config-upload-help": "यदि आप अपने सर्वर में फ़ाइल अपलोड की सेवा दे रहे हैं तो आपको सुरक्षा से समझौता करना पड़ सकता है।\n\nअधिक जानकारी के लिए मार्गदर्शक में [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security सुरक्षा अनुभाग] देखें।\n\nयदि आप फ़ाइल अपलोड को सक्रिय करना चाहते हैं तो आपको मीडियाविकि के फोंल्डर के <code>images</code> फोंल्डर में जाने के बाद उसे सर्वर द्वारा लिखने लायक बनाना होगा।\nउसके बाद ही आप इस विकल्प को सक्रिय कर सकते हैं।",
+ "config-logo": "''लोगो'' का पता:",
+ "config-instantcommons": "''कॉमन्स'' सक्रिय करें",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons कॉमन्स] एक प्रकार की विशेषता प्रदान करता है, जिससे आप विकि में [https://commons.wikimedia.org/ विकिमीडिया कॉमन्स] साइट के किसी भी तस्वीर, आवाज या अन्य फ़ाइल का उपयोग अपने मीडियाविकि में कर सकते हैं। इसके लिए मीडियाविकि को इंटरनेट से जुड़ा होना चाहिए।\n\nइस विशेषता की अधिक जानकारी के लिए और इसे किस प्रकार आप अपने विकि में सक्रिय कर सकते हैं आदि जानने के लिए [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos मार्गदर्शक] देखें।",
+ "config-extensions": "एक्सटेंशन",
+ "config-skins": "त्वचा",
+ "config-install-step-done": "पूरा हुआ",
+ "config-install-step-failed": "विफल हुआ",
+ "config-install-user-alreadyexists": "सदस्य \"$1\" पहले से उपस्थित है।",
+ "config-install-user-create-failed": "सदस्य \"$1\" का निर्माण विफल हुआ: $2",
+ "config-install-keys": "गुप्त कुंजी बना रहा",
+ "config-install-sysop": "प्रबन्धक सदस्य खाता बना रहा",
+ "config-download-localsettings": "<code>LocalSettings.php</code> को डाउनलोड करें।",
+ "config-help": "सहायता",
+ "config-help-tooltip": "विस्तार हेतु क्लिक करें",
+ "config-nofile": "फ़ाइल \"$1\" नहीं पाई जा सकी। क्या इसे हटा दिया गया है?",
+ "mainpagetext": "<strong>मीडियाविकि का अब स्थापित हो चुका है।</strong>",
+ "mainpagedocfooter": "इस विकि सॉफ्टवेयर का किस प्रकार आप इस्तेमाल कर सकते हैं, इसकी जानकारी के लिए [https://meta.wikimedia.org/wiki/Help:Contents उपयोग मार्गदर्शक] देखें।\n== शुरुआत करें ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings विकि में बदलाव की सूची]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ मीडियाविकि के बारे में प्राय: पूछे जाने वाले सवाल]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce मीडियाविकि की मेल सूची]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources मीडियाविकि का आपके भाषा में अनुवाद]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam अपने विकि को किस प्रकार से विज्ञापन डालने वाले और बर्बरता करने वालों से बचा सकते हैं]"
+}
diff --git a/www/wiki/includes/installer/i18n/hif-latn.json b/www/wiki/includes/installer/i18n/hif-latn.json
new file mode 100644
index 00000000..3f17c120
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hif-latn.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Thakurji"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki ke safalta se install kar dewa gais hai.'''",
+ "mainpagedocfooter": "Wiki software ke use kare ke aur jaankari ke khatir [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] ke dekho.\n\n== Getting started ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/hil.json b/www/wiki/includes/installer/i18n/hil.json
new file mode 100644
index 00000000..99abc4f2
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hil.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Anjoeli9806"
+ ]
+ },
+ "mainpagetext": "'''Ang MediaWiki madinalag-on nga na-instala.'''",
+ "mainpagedocfooter": " Magkonsulta sa [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] para sa mga impormasyon sa paggamit sang wiki nga software.\n\n== Pag-umpisa ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista sang mga konpigorasyon sang pagkay-o]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Mga Masami Pamangkoton sa MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista sang mga ginapadal-an sang sulat kon may paguha-on nga MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/hr.json b/www/wiki/includes/installer/i18n/hr.json
new file mode 100644
index 00000000..45a637d4
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hr.json
@@ -0,0 +1,26 @@
+{
+ "@metadata": {
+ "authors": [
+ "Vrhnje"
+ ]
+ },
+ "config-information": "Informacije",
+ "config-your-language": "Vaš jezik:",
+ "config-your-language-help": "Odaberite jezik na kojem će se pojaviti proces instalacije.",
+ "config-wiki-language": "Wiki jezik:",
+ "config-back": "← Povratak",
+ "config-continue": "Dalje →",
+ "config-page-language": "Jezik",
+ "config-page-welcome": "Dobrodošli na MediaWiki!",
+ "config-page-dbconnect": "Spajanje na bazu podataka",
+ "config-page-upgrade": "Ažuriranje postojeće instalacije",
+ "config-page-restart": "Ponovno pokreni instalaciju",
+ "config-page-readme": "Pročitajte više",
+ "config-page-releasenotes": "Informacije o verziji",
+ "config-page-copying": "Kopiranje",
+ "config-page-upgradedoc": "Ažuriranje",
+ "config-page-existingwiki": "Postojeći wiki",
+ "config-restart": "Da, počni iznova",
+ "mainpagetext": "'''Softver MediaWiki je uspješno instaliran.'''",
+ "mainpagedocfooter": "Pogledajte [https://meta.wikimedia.org/wiki/MediaWiki_localisation dokumentaciju o prilagodbi sučelja]\ni [https://meta.wikimedia.org/wiki/MediaWiki_User%27s_Guide Vodič za suradnike] za pomoć pri uporabi i podešavanju."
+}
diff --git a/www/wiki/includes/installer/i18n/hrx.json b/www/wiki/includes/installer/i18n/hrx.json
new file mode 100644
index 00000000..6bee175d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hrx.json
@@ -0,0 +1,297 @@
+{
+ "@metadata": {
+ "authors": [
+ "Paul Beppler",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Das MediaWiki-Installationsprogramm",
+ "config-title": "Installation von MediaWiki $1",
+ "config-information": "Informatione",
+ "config-localsettings-upgrade": "En Datei <code>LocalSettings.php</code> woard gefund.\nUm die vorhandne Installation aktualisiere zu könne, muss der Parameter sein Weart <code>$wgUpgradeKey</code> im follichende Ingäbfeld oongeb sin.\nDer Parameterweart befindt sich in der Datei <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "En Datei <code><code>LocalSettings.php</code></code> woard gefund.\nUm die vorhandne Installation se aktualisiere, muss die Datei <code>update.php</code> ausgefüahrt sin.",
+ "config-localsettings-key": "Aktualisierungsschlüssel:",
+ "config-localsettings-badkey": "Der angebne Aktualisierungsschlüssel ist falsch.",
+ "config-upgrade-key-missing": "En MediaWiki-Installation woard gefund.\nUm die vorhandne Installation aktualisiere zu könne, muss die unne oongebne Codezeil in die Datei <code>LocalSettings.php</code> an sein End ingefücht sin:\n\n$1",
+ "config-localsettings-incomplete": "Die vorhandne Datei <code>LocalSettings.php</code> scheint unvollständig zu sin.\nDie Variable <code>$1</code> woard net definiert.\nDie Datei <code>LocalSettings.php</code> muss entsprechend geännert sin, so dass sie definiert ist. Klick donoh uff „{{int:Config-continue}}“.",
+ "config-localsettings-connection-error": "Beim Verbinnungsversuch zur Datenbank ist, unner Verwennung von der in der Datei <code>LocalSettings.php</code> hinnerlehte Instellunge, en Fehler uffgetret. Die Instellunge müsse korrischiert sin. Donoh kann en erneiter Versuch unternomm sin. \n\n$1",
+ "config-session-error": "Fehler bei dem Setzung Oonfänge: $1",
+ "config-session-expired": "Die Setzungsdate scheine abgeloof se sin.\nSetzunge sind für en Zeitraum von $1 konfiguriert.\nDer kann doorrich der Parameter sein Oonhebung <code>session.gc_maxlifetime</code> in der Datei <code>php.ini</code> erhöcht sin.\nDen Installationsvoargang erneit oonfänge.",
+ "config-no-session": "Die Setzungsdate sind verloar gang!\nDie Datei <code>php.ini</code> muss geprüft und es muss dabei sichergestellt sin, dass der Parameter <code>session.save_path</code> uff das richtiche Verzeichnis verweist.",
+ "config-your-language": "Installions Sproch:",
+ "config-your-language-help": "Bittschön die Sproch auswähle, wo verwennet sin soll währed der Installationsvoargang.",
+ "config-wiki-language": "Die Wiki sei Sproch:",
+ "config-wiki-language-help": "Bittschön die Sproch auswähle, wo üwerwiechend bei der Inhalte ehre Erstelle verwennet sin soll.",
+ "config-back": "← Zurück",
+ "config-continue": "Weiter →",
+ "config-page-language": "Sproch",
+ "config-page-welcome": "Willkomme bei MediaWiki!",
+ "config-page-dbconnect": "Mit der Datenbank verbinne",
+ "config-page-upgrade": "En voarhandne Installation aktualisiere",
+ "config-page-dbsettings": "Instellunge zur Datenbank",
+ "config-page-name": "Noome",
+ "config-page-options": "Optione",
+ "config-page-install": "Installiere",
+ "config-page-complete": "Fertich!",
+ "config-page-restart": "Installationsvoargang erneit oonfänge",
+ "config-page-readme": "Les mich",
+ "config-page-releasenotes": "Versionsinfos (en)",
+ "config-page-copying": "Kopie von die Lizenz",
+ "config-page-upgradedoc": "Aktualisiere",
+ "config-page-existingwiki": "Voarhandnes Wiki",
+ "config-help-restart": "Solle all bereits ingebne Daten gelöscht und der Installationsvoargang erneit oogefäng sin?",
+ "config-restart": "Jo, erneit oonfänge",
+ "config-welcome": "=== Prüfung von die Installationsumgebung ===\nDie Basisprüfunge were jetzt doorrichgefüahrt, um festzustelle, ob die Installationsumgebung für MediaWiki geeichnet ist.\nNotier die Informatione und geb se an, sofern du Hellf beim Installiere benötichst.",
+ "config-copyright": "=== Lizenz und Nutzungsbedingunge ===\n\n$1\n\nDas Programm ist freie Software, d. h. es kann, gemäss den Bedingunge der von der Free Software Foundation veröffentlichte ''GNU General Public License'', weiterverteilt und/oder modifiziert sin. Dabei kann die Version 2, orrer noh eichnem Ermess, jede neuire Version von der Lizenz verwennet sin.\n\nDas Programm weard in der Hoffnung verteilt, dass das nützlich sein weard, dennoch '''ohne jechliche Garantie''' und sogoor ohne die implizierte Garantie von ener '''Marrektgängigkeit''' orrer '''Eichnung für en bestimmte Zweck'''. Hierzu sind weitre Hinweise in der ''GNU General Public License'' enthalt.\n\nEn <doclink href=Copying>Kopie von der GNU General Public License</doclink> sollt zusammer mit dem Programm verteilt woard sin. Sofern das net der Fall woar, kann en Kopie bei der Free Software Foundation Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, schriftlich verlangt sin orrer uff ehre Website [http://www.gnu.org/copyleft/gpl.html online gelesen] sin.",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/de Website von MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/de Benutzeroonleitung]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/de Administratorenoonleitung]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/de Häifig gestellte Frache]\n----\n* <doclink href=Readme>Lies mich</doclink>\n* <doclink href=ReleaseNotes>Versionsinformatione</doclink>\n* <doclink href=Copying>Lizenzbestimmunge</doclink>\n* <doclink href=UpgradeDoc>Aktualisierung</doclink>",
+ "config-env-good": "Die Installationsumgebung woard geprüft.\nMediaWiki kann installiert sin.",
+ "config-env-bad": "Die Installationsumgebung woard geprüft.\nMediaWiki kann net installiert sin.",
+ "config-env-php": "Die Skriptsproch „PHP“ ($1) ist installiert.",
+ "config-unicode-using-intl": "Zur Unicode-Normalisierung weard die [http://pecl.php.net/intl PECL-Erweiterung intl] ingesetzt.",
+ "config-unicode-pure-php-warning": "'''Warnung:''' Die [http://pecl.php.net/intl PECL-Erweiterung intl] ist für die Unicode-Normalisierung net verfüchbar, so dass stattdessen die langsame pure-PHP-Implementierung genutzt weard.\nSofern en Webseit mit grosser Benutzeranzoohl betrieb weard, sollte weitre Informatione uff der Webseite [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-Normalisierung (en)] geles sin.",
+ "config-unicode-update-warning": "'''Warnung:''' Die installierte Version von der Unicode-Normalisierungswrappers nutzt en ältre Version von der [http://site.icu-project.org/ ICU-Projekts] sein Bibliothek.\nDie sollte [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations aktualisiert] sin, sofern uff die Verwennung von Unicode Wert geleht weard.",
+ "config-no-db": "Es konnt ken adäquater Datenbanktreiwer gefund sin. Es muss doher en Datenbanktreiwer für PHP installiert sin.\nDie folchende Datebanksysteme werre unnerstützt: $1\n\nWenn du PHP sellebst kompiliert host, konfigurier es erneit mit en aktiviert Datebankclient, zum Beispiel dorrich Verwennung von <code>./configure --with-mysqli</code>.\nWenn du PHP von en Debian- orrer Ubuntu-Paket installiert host, dann musst du ooch beispielsweis das <code>php5-mysql</code>-Paket installiere.",
+ "config-outdated-sqlite": "'''Warnung:''' SQLite $1 ist installiert. Allerdings benöticht MediaWiki SQLite $2 orrer höcher. SQLite weard doher net verfüchbar sin.",
+ "config-no-fts3": "'''Warnung:''' SQLite woard ohne das [//sqlite.org/fts3.html FTS3-Modul] kompiliert, so dass ken Suchfunktione für das Datenbanksystem zur Verfüchung stehn werre.",
+ "config-pcre-old": "<strong>Fataler Fehler:</strong> PCRE $1 orrer neier ist notwendich!\nDie vorhandne PHP-Binärdatei ist mit PCRE $2 verknüpft.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Weitre Informatione].",
+ "config-pcre-no-utf8": "'''Fataler Fehler:''' Das PHP-Modul PCRE scheint ohne PCRE_UTF8-Unterstützung kompiliert worre sin.\nMediaWiki benöticht die UTF-8-Unnerstützung, um fehlerfrei looffähich zu sin.",
+ "config-memory-raised": "Der PHP-Parameter <code>memory_limit</code> betruch $1 und woard uff $2 erhöcht.",
+ "config-memory-bad": "'''Warnung:''' Der PHP-Parameter <code>memory_limit</code> beträcht $1.\nDer Weart ist wahrscheinlich zu niedrich.\nDer Installationsvoargang könnt doher scheitre!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] ist installiert",
+ "config-apc": "[http://www.php.net/apc APC] ist installiert",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] ist installiert",
+ "config-mod-security": "'''Warnung:''' Uff dem Webserver woard [http://modsecurity.org/ ModSecurity] aktiviert. Sofern falsch konfiguriert, kann das zu Probleme mit MediaWiki sowie annrer Software uff dem Server führe und es Benutzer ermöchliche beliebiche Inhalte im Wiki Renzustelle.\nFür weitre Informatione empfehle mir die [http://modsecurity.org/documentation/ Dokumentation zu ModSecurity] orrer den Kontakt zum Hoster, sofern Fehler ufftrete.",
+ "config-diff3-bad": "GNU diff3 woard net gefund.",
+ "config-git": "Die Versionsverwaltungssoftware „Git“ woard gefund: <code>$1</code>.",
+ "config-git-bad": "Die Versionsverwaltungssoftware „Git“ woard net gefund.",
+ "config-imagemagick": "Die Bildverooweitungssoftware „ImageMagick“ woard gefund: <code>$1</code>.\nMiniaturoonsichte von Bilder werre möchlich sin, sobald das Hochloode von Dateie aktiviert woard.",
+ "config-gd": "Die im System integrierte GD-Grafikbibliothek woard gefund.\nMiniaturoonsichte von Bilder werre möchlich sin, sobald das Hochloode von Dateie aktiviert woard.",
+ "config-no-scaling": "Weder die GD-Grafikbibliothek noch ImageMagick wore gefund.\nMiniaturoonsichte von Bilder sind dohear net möchlich.",
+ "config-no-uri": "'''Fehler:''' Die aktuelle URL konnte net identifiziert sin.\nDer Installationsvoargang woard doher abgebroch.",
+ "config-no-cli-uri": "'''Warnung''': Es woard ken Pad zum Skipt (<code>--scriptpath</code>) oongeb. Doher weard der Standardpad benutzt: <code>$1</code>.",
+ "config-using-server": "Der Servernoome „<nowiki>$1</nowiki>“ weard verwennet.",
+ "config-using-uri": "Der Server URL \"<nowiki>$1$2</nowiki>\" weard benutz.",
+ "config-uploads-not-safe": "'''Warnung:''' Das Standardverzeichnis für hochgeloodne Dateie <code>$1</code> ist für die willkürliche/arbiträer Ausführung von Skripte oonfällich.\nObwohl MediaWiki die hochgeloodne Dateie uff Sicherheitsrisike üwerprüft, weard dennoch dringend empfohl die [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security Sicherheitslücke] zu schliesse, bevor das Hochloode von Dateie aktiviert weard.",
+ "config-no-cli-uploads-check": "'''Warnung''': Das Standardverzeichnis für hochgeloodene Dateie (<code>$1</code>) weard, während der Installation üwer die Kommandozeile, net auf Sicherheitsoonfälligkeite hinsichtlich willkürlicher/arbiträr Skriptausführunge geprüft.",
+ "config-brokenlibxml": "Das System nutzt en Kombination aus PHP- und libxml2-Versione, die fehleroonfällich ist und versteckte Datefehler bei MediaWiki und annere Weboonwennunge verursache kann.\nAktualisier uff libxml2 2.7.3 orrer später, um das Problem zu löse. Installationsabbruch ([https://bugs.php.net/bug.php?id=45996 sieh hierzu die Fehlermeldung bei PHP]).",
+ "config-suhosin-max-value-length": "Suhosin ist installiert und beschränkt die Läng von der GET-Parameters auf $1 Bytes.\nDer ResouceLoader von MediaWiki weard zwoor unner den Bedingunge funktioniere, allerdings nuer mit verminnerter Leistungsfähigkeit.\nSoweit möchlich sollt der Parameter <code>suhosin.get.max_value_length</code> in der Datei <code>php.ini</code> uff 1024 oreer höcher festgeleht werre.\nGleichzeitich muss der Parameter <code>$wgResourceLoaderMaxQueryLength</code> in der Datei <code>LocalSettings.php</code> uff den selwer Weart rengestellt sin.",
+ "config-db-type": "Datebanksystem:",
+ "config-db-host": "Datebankserver:",
+ "config-db-host-help": "Soweit sich die Datebank uff en annre Server befindt, ist hier der Servernoome orrer die entsprechende IP-Adresse oonzugewe.\n\nSoweit en gemeinschaftlich genutzter Server verwendt weard, sollt der Hoster den zutreffend Servernoomen in seiner Dokumentation oongeb hoon.\n\nSoweit uff enem Windows-Server installiert und MySQL genutzt weard, funktioniert der Servername „localhost“ voaraussichtlich net. Wenn net, sollte „127.0.0.1“ orrer die lokale IP-Adress oongeb sin.\n\nSoweit PostgresQL benutzt weard, muss das Feld/Campo leer geloss sin, um üwer en Unix-Socket zu verbinne.",
+ "config-db-host-oracle": "Datebank-TNS:",
+ "config-db-host-oracle-help": "En gültiche [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm „Local Connect“-Namen] angeben. Die „tnsnames.ora“-Datei muss von die Installation erkannt werre könne.<br />Sofern die Client-Bibliotheke für Version 10g orrer neier verwennet weare, kann ooch [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm „Easy Connect“] zu der Noomensgebung benutzt sin.",
+ "config-db-wiki-settings": "Bitte Date zu der indeitiche Identifikatio von das Wiki oongewe",
+ "config-db-name": "Datebanksystem:",
+ "config-db-name-help": "Bitte en Noome oongewe, mit dem das Wiki identifiziert werre kann.\nDobei sollt ken Leerzeiche verwennet sin.\n\nSoweit en gemeinschaftlich genutzter Server verwennet weard, sollt der Hoster den Datebanknoome oongegewe orrer awer die Erstellung von en Datebank üwer en entsprechendes Interface gestattet hoon.",
+ "config-db-name-oracle": "Datebankschema:",
+ "config-db-account-oracle-warn": "Es gebt drei von MediaWiki unnerstützte Möchlichkeite Oracle als Datebank inzurichte:\n\nSoweit das Datebankbenutzerkonto im Moment (während des) von dem Installationsvoargang erstellt werre soll, muss en Datebankbenutzerkonto mit der SYSDBA-Berechtichung zusammer mit den entsprechende Onnmeldeinformatione oongeb sin, mit dem dann üwer das Web uff die Datebank zugegriff sin kann. Alternativ kann man ooch ledichlich en enzelnes manuell oongelechtes Datebankbenutzerkonto oongewe, mit dem üwer das Web uff die Datebank zugegriff werre kann, soweit das üwer die Berechtichung zur Erstellung von Datebankscheme verfücht. Zudem ist es möchlich zwooi Datebankbenutzerkonte oonzugew von dene enes die Berechtichung zu der Erstellung von Datebankscheme hot und das annere, um mit ihm üwer das Web uff die Datebank zuzugreife.\n\nEn Skript zu dem Oonlehn von en Datebankbenutzerkonto mit den notwendiche Berechtichunge findt man unner dem Pad „…/maintenance/oracle/“ von der MediaWiki-Installation. Das ist dobei zu bedenke, dass die Verwennung von en Datebankbenutzerkonto mit beschränkte Berechtichunge die Nutzung von der Wartungsfunktione für das Standarddatebankbenutzerkonto deaktiviert.",
+ "config-db-install-account": "Benutzerkonto für die Installation",
+ "config-db-username": "Der Datebankbenutzer sein Noome:",
+ "config-db-password": "Der Datebankbenutzer sei Passwort:",
+ "config-db-install-username": "Den Benutzernoome oongewe, der für die Verbinnung mit der Datebank (während des) wo im Moment von dem Installationsvoargang benutzt sin soll. Das handelt sich dobei net um den Benutzernoome für das MediaWiki-Konto, awer um den Benutzernoomen von der voargesiehne Datebank.",
+ "config-db-install-password": "Das Passwort oongewe, das für die Verbinnung mit der Datebank (während des) im Momento von der Installationsvoargang benutzt sin soll. Das handelt sich dobei net um das Passwort für das MediaWiki-Konto, awer um das Passwort von der voargesiehne Datebank.",
+ "config-db-install-help": "Benutzernoome und Passwort, die (während des) im Moment von der Installationsvoargang, für die Verbinnung mit der Datebank, benutzt werre solle, sind jetzt oonzugewe.",
+ "config-db-account-lock": "Derselwe Benutzernoome und das Passwort müsse (während des) im Moment von der Wiki sein Normalbetrieb verwennt sin.",
+ "config-db-wiki-account": "Benutzerkonto für den normalen Betrieb",
+ "config-db-wiki-help": "Bitte Benutzernoome und Passwort oongewe, wo der Webserver (während des) im Moment von dem Normalbetriebe dozu verwenne soll, en Verbinnung zu dem Datebankserver hearzustelle.\nSoweit en entsprechendes Benutzerkonto net voarhand ist und das Benutzerkonto für den Installationsvoargang üwer ausreichende Berechtichunge verfücht, weard das Benutzerkonto automatisch mit den Mindestberechtichunge zu der Wiki sein Normalbetrieb oongeleht.",
+ "config-db-prefix": "Datebanktabellepräfix:",
+ "config-db-prefix-help": "Soweit en Datebank für mehrer Wikiinstallatione orrer en Wikiinstallatio und en annre Programminstallation benutzt werre soll, muss en Datebanktabellenpräfix oogeb sin, um Datebankprobleme zu vermeide.\nDas könne ken Leerzeiche verwennt sin.\n\nGewöhnlich bleibt das Datefeld (Datecampo) lear.",
+ "config-mysql-old": "MySQL $1 orrer höcher weard benöticht. MySQL $2 ist momentan voarhand.",
+ "config-db-port": "Datebankserver:",
+ "config-db-schema": "Dateschema für MediaWiki",
+ "config-db-schema-help": "Das Dateschema ist in der Rechel allgemein verwendbar orrer zu der Verwennung geeichnet.\nNuar Ännrunge dron voarnehme, soweit do gute Gründe dofür gebt.",
+ "config-pg-test-error": "Do kann ken Verbinnung zur Datebank '''$1''' heargestellt sin: $2",
+ "config-sqlite-dir": "SQLite-Dateverzeichnis:",
+ "config-sqlite-dir-help": "SQLite speichert alle Date in en enziche Datei.\n\nDas für sie voargesiehn Verzeichnis muss (während des) im Momento von dem Installationsvoargang beschreibbar orrer beschrib fähich sin.\n\nDas sollt '''net''' üwer das Web zugänglich sin, was der Grund ist, warum die Datei net dort abgeleht weard, wo sich die PHP-Dateie befinne.\n\nDas Installationsprogramm weard mit der Datei zusammer en zusätzliche <code>.htaccess</code>-Datei erstelle. Soweit das scheitert, könne Dritte uff die Datedatei zugreife.\nDas umfasst die Nutzerdate (E-Mail-Adresse, Passwörter, und so weiter) wie ooch gelöschte Seiteversione und annere vertrauliche Date, die im Wiki gespeichert sind.\n\nTue konsideriere, erwäch die Datedatei an en gänz anner Platz abzulehn, zum beispiel im Verzeichnis <code>./var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Standardtabelleraum:",
+ "config-oracle-temp-ts": "Temporärer Tabelleraum:",
+ "config-type-mysql": "MySQL (orrer kompatible Datebanksysteme)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki unnerstützt die follichenne Datebanksysteme:\n\n$1\n\nSoweit net das Datebanksystem oongezeicht weard, das verwennt werre soll, gebt das uwe en Link zu der Oonleitung mit Informatione, wie das aktiviert sin kann.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] ist das von MediaWiki primär unterstützte Datebanksystem. MediaWiki funktioniert ooch mit [{{int:version-db-mariadb-url}} MariaDB] und [{{int:version-db-percona-url}} Percona Server], die MySQL-kompatibel sind. ([http://www.php.net/manual/en/mysqli.installation.php Oonleitung zur Kompilierung von PHP mit MySQL-Unnerstützung] [englisch Sproch])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] ist en beliebtes Open-Source-Datebanksystem und ein Alternativ zu MySQL. Es gibt awer enche klenre Implementierungsfehler, so dass von der Nutzung in ener Produktivumgebung abgerat weard. ([http://www.php.net/manual/de/pgsql.installation.php Oonnleitung zur Kompilierung von PHP mit PostgreSQL-Unterstützung])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] ist en verschlanktes Datebanksystem, das ooch gut unnerstützt weard ([http://www.php.net/manual/de/pdo.installation.php Oonleitung zur Kompilierung von PHP mit SQLite-Unterstützung], verwennt PHP Data Objects (PDO))",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] ist en kommerzielle Unnernehmensdatebank ([http://www.php.net/manual/en/oci8.installation.php Oonleitung zur Kompilierung von PHP mit OCI8-Unnerstützung (en)])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] ist en gewerbliche Unnernehmensdatebank für Windows. ([http://www.php.net/manual/de/sqlsrv.installation.php Oonleitung zur Kompilierung von PHP mithilfe SQLSRV-Unnerstützung])",
+ "config-header-mysql": "MySQL-Instellunge",
+ "config-header-postgres": "PostgreSQL-Instellunge",
+ "config-header-sqlite": "SQLite-Instellunge",
+ "config-header-oracle": "Oracle-Instellunge",
+ "config-header-mssql": "Instellunge von Microsoft SQL Server",
+ "config-invalid-db-type": "Unzulässiges Datebanksystem",
+ "config-missing-db-name": "Bei \"{{int:config-db-name}}\" muss en Weart oongeb sin.",
+ "config-missing-db-host": "Bei \"{{int:config-db-host}}\" muss en Weart oongeb sin.",
+ "config-missing-db-server-oracle": "Für das \"{{int:config-db-host-oracle}}\" muss en Weart ingeb sin.",
+ "config-invalid-db-server-oracle": "Ungültiches Datebank-TNS „$1“.\nEntweder „TNS Noome“ orrer ene „Easy Connect“-Zeichefolliche verwenne ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle-Benennungsmethode])",
+ "config-invalid-db-name": "Ungülticher Datebankname „$1“.\nDo deerfe nuar ASCII-codierte Buchstoobe (a-z, A-Z), Zooahle (0-9), Unner- (_) sowie Binnestriche (-) verwennt sin.",
+ "config-invalid-db-prefix": "Ungülticher Datebanktabellepräfix „$1“.\nEs dürfe nuar ASCII-codierte Buchstoobe (a-z, A-Z), Zoohle (0-9), Unner- (_) sowie Binnestriche (-) verwennt sin.",
+ "config-connection-error": "$1.\n\nBitte unne oongeb Servernoome, Benutzernoome sowie das Passwort üwerprüfe und es dann erneit versuche.",
+ "config-invalid-schema": "Ungültiches Dateschema für MediaWiki „$1“.\nEs dürfe nuar ASCII-codierte Buchstoobe (a-z, A-Z), Zoohle (0-9) und Unnerstriche (_) verwennt sin.",
+ "config-db-sys-create-oracle": "Das Installationsprogramm unnerstützt nuar die Verwennung von en Datebankbenutzerkonto mit SYSDBA-Berechtichung zum oonlehn von en neie Datebankbenutzerkonto.",
+ "config-db-sys-user-exists-oracle": "Das Datebankbenutzerkonto „$1“ ist schoon voarhand. En Datebankbenutzerkontos mit SYSDBA-Berechtichung kann nuar zum oonlehn von en neie Datebankbenutzerkonto benutzt sin.",
+ "config-postgres-old": "MySQL $1 orrer höcher weard benöticht. MySQL $2 ist momentan voarhand.",
+ "config-mssql-old": "Es weard Microsoft SQL Server $1 orrer später benöticht. Dein Version ist $2.",
+ "config-sqlite-name-help": "Bitte en Noome oongewe, mit dem das Wiki identifiziert werre kann.\nDobei bitte ken Leerzeiche orrer Binnestriche verwenne.\nDer Noome weard für die SQLite-Datedateinoome benutzt.",
+ "config-sqlite-parent-unwritable-group": "Das Dateverzeichnis <code><nowiki>$1</nowiki></code> kann net erzeicht werre, weil das üwergeoordnete Verzeichnis <code><nowiki>$2</nowiki></code> net für den Webserver beschreibbar ist.\n\nDas Installationsprogramm konnt den Benutzer bestimme, mit dem Webserver ausgeführt weard.\nSchreibzugriff uff das <code><nowiki>$3</nowiki></code>-Verzeichnis muss für den ermöglicht werre, so das den Installationsvoargang fortgesetz sin kann.\n\nUff enem Unix- orrer Linux-System:\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Das Dateverzeichnis <code><nowiki>$1</nowiki></code> kann net erzeicht sin, weil das üwergeordnete Verzeichnis <code><nowiki>$2</nowiki></code> net für den Webserver beschreibbar ist.\n\nDas Installationsprogramm konnt den Benutzer bestimmen, mit dem Webserver ausgeführt weard.\nSchreibzugriff uff das <code><nowiki>$3</nowiki></code>-Verzeichnis muss global für den und annre Benutzer ermöglicht sin, so das den Installationsvoargang fortgesetzt sin kann.\n\nUff enem Unix- orrer Linux-System:\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Fehler beim Erstelle von dem Dateverzeichnisses „$1“.\n\nBitte den Speicherplatz üwerprüfe und es donoh erneit versuche.",
+ "config-sqlite-dir-unwritable": "Das Verzeichnis „$1“ ist net beschreibbar.\nBitte die Zugriffsberechtigunge so ännre, dass das Verzeichnis für den Webserver beschreibbar ist und es donoh erneit versuche.",
+ "config-sqlite-connection-error": "$1.\n\nBitte unne oongeb Dateverzeichnis sowie den Datebanknoome üwerprüfe und es donoh erneit versuche.",
+ "config-sqlite-readonly": "Die Datei <code>$1</code> ist net beschreibbar.",
+ "config-sqlite-cant-create-db": "Die Datebankdatei <code>$1</code> konnte net erzeicht sin.",
+ "config-sqlite-fts3-downgrade": "PHP verfücht net üwer FTS3-Unnerstützung. Die Tabelle woare zurückgestuft.",
+ "config-can-upgrade": "Es woare MediaWiki-Tabelle in der Datebank gefund.\nUm sie uff MediaWiki $1 zu aktualisiere, bitte uff '''Weiter''' klicke.",
+ "config-upgrade-done": "Die Aktualisierung ist jetzt abgeschloss.\n\nDas Wiki kann jetzt [$1 benutzt sin].\n\nSoweit die Datei <code>LocalSettings.php</code> nei erzeicht werre soll, bitte uff die Schaltfläche unne klicke.\nDas weard '''net rekomendiert''', es sei denn, es trete Probleme mit dem Wiki uff.",
+ "config-upgrade-done-no-regenerate": "Die Aktualisierung ist abgeschloss.\n\nDas Wiki kann jetzt [$1 benutzt sin].",
+ "config-regenerate": "LocalSettings.php nei erstelle →",
+ "config-show-table-status": "Die Abfroch <code>SHOW TABLE STATUS</code> ist gescheitert!",
+ "config-unknown-collation": "'''Warnung:''' Die Datebank nutzt en unbekannte Kollation.",
+ "config-db-web-account": "Datebankkonto für den Webzugriff",
+ "config-db-web-help": "Bitte Benutzernoome und Passwort auswähle, die der Webserver im Verloof von der Normalbetriebe dozu verwenn soll, en Verbinnung zum Datebankserver herzustelle.",
+ "config-db-web-account-same": "Dasselbe Datebankkonto wie im Verloof von der Installationsvoargang verwenne",
+ "config-db-web-create": "Soweit net bereits voarhand, muss jetzt das Konto erstellt sin",
+ "config-db-web-no-create-privs": "Das oongebne und für den Installationsvoargang voargesiehne Datebankkonto verfücht nwt üwer ausreichend Berechtichunge, um en weitres Datebankkonto zu erstelle.\nDas hier oongebne Datebankkonto muss dohear bereits voarhand sin.",
+ "config-mysql-engine": "Speicher-Engine:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Warnung:''' Es woard MyISAM als Speicher-Engine für MySQL ausgewählt, die aus follichend Gründe net für den Insatz mit MediaWiki rekommendiert ist:\n* Sie unnerstützt uffgrund von Tabellesperrunge koom die neweloofiche Ausführung von Aktione.\n* Sie ist oonfällicher für Dateprobleme.\n* Sie weard von MediaWiki net immer adäquat unnerstützt.\n\nSoweit die voarhandne MySQL-Installation die Speicher-Engine InnoDB unnerstützt, weard sei Verwennung eindringlich rekommendiert.\nSoweit sie sie net unnerstützt, sollt en entsprechend Aktualisierung nunmeahr Erwächung gezoh sin.",
+ "config-mysql-only-myisam-dep": "'''Warnung:''' MyISAM ist die einziche verfüchbare Speicher-Engine für MySQL uff dem Rechner, und das weard net für die Verwennung mit MediaWiki rekommendiert, weil sie\n* uffgrund von Tabellesperrunge koom die neweloofiche Ausführung von Aktione unnerstützt,\n* oonfällicher für Dateprobleme ist und\n* von MediaWiki net immer adäquat unnerstützt weard.\n\nDein MySQL-Installation unnerstützt net InnoDB. Eventuell muss en Aktualisierung dorrichgeführt werre.",
+ "config-mysql-engine-help": "'''InnoDB''' ist nächst immer die bessre Wähl, weil es gleichzeitiche Zugriffe gut unnerstützt.\n\n'''MyISAM''' ist in Enzelnutzerumgebunge sowie bei schreibgeschützte Wikis schneller.\nBei MyISAM-Datebanke treten tendenziell häuficher Fehler uff als bei InnoDB-Datebanke.",
+ "config-mysql-charset": "Datebankzeichesatz",
+ "config-mysql-binary": "binär",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Im '''binäre Modus''' speichert MediaWiki UTF-8 Texte in der Datebank in binär kodierte Datefelder.\nDas ist effizienter als der UTF-8-Modus von MySQL und ermöglicht so die Verwennung jeder Unicode-Zeiche.\n\nIm '''UTF-8-Modus''' weard MySQL den Zeichesatz der Date erkenne und sie richtich oonzeiche und konvertiere,\nawer könne ken Zeiche ausserhalb von der [https://de.wikipedia.org/wiki/Basic_Multilingual_Plane#Gliederung_in_Ebenen_und_Bl.C3.B6cke ''Basic Multilingual Plane'' (BMP)] gespeichert sin.",
+ "config-mssql-auth": "Authentifikationstyp:",
+ "config-mssql-install-auth": "Wähl den Authentifikationstyp aus, der zur Verbinnung mit der Datebank während von der Installationsprozesses verwennt weard.\nFalls du „{{int:config-mssql-windowsauth}}“ auswählst, werre die Oonmeldeinformatione von en beliebiche Benutzer verwennt, wo den Webserver ausführt.",
+ "config-mssql-web-auth": "Wähl den Authentifikationstyp aus, der vom Webserver zur Verbinnung mit dem Datebankserver während / im Verloof von der gewöhnliche Betrieb von der Wiki verwennt weard.\nFalls du „{{int:config-mssql-windowsauth}}“ auswählst, werre die Oonmeldeinformatione von en beliebiche Benutzer verwennt, wo den Webserver ausführt.",
+ "config-mssql-sqlauth": "SQL-Server-Authentifikation",
+ "config-mssql-windowsauth": "Windows-Authentifikation",
+ "config-site-name": "Der Wiki sein Noome:",
+ "config-site-name-help": "Er weard in der Titelleiste von der Browser, wie ooch verschiedne annre Stelle, benutzt.",
+ "config-site-name-blank": "Der Wiki sein Noome oongewe.",
+ "config-project-namespace": "Der Projekt sein noomeraum:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Entsprecht der Wiki sein Noome: $1",
+ "config-ns-other": "Annrer Noome (bitte oongewe)",
+ "config-ns-other-default": "MeinWiki",
+ "config-project-namespace-help": "Dem Beispiel von Wikipedia follichend, unnerscheide viele Wikis zwischich den Seite für Inhalte und dene für Richtlinie. Letztre werre im „'''Projekt sein Noomeraum'''“ hinnerleht.\nAll Seite von dem Noomeraume verfüche üwer en Seitepräfix, wo jetzt an der Stell oongeb sinn kann.\nTraditionell steht dieser Seitepräfix mit dem Noome von der Wiki in enem enge Zusammerhang. Dobei könne bestimmte Sonnerzeiche wie „#“ orrer „:“ net verwennt sin.",
+ "config-ns-invalid": "Der oongebne Noomensraum „<nowiki>$1</nowiki>“ ist ungültich.\nBitte en abweichende Projektnoomeraum oongewe.",
+ "config-ns-conflict": "Der oongebne Noomenraum „<nowiki>$1</nowiki>“ verursacht Problem mit dem Standardnppmeraum von MediaWiki.\nBitte en abweichende Projektnoomeraum oongewe.",
+ "config-admin-box": "Administratorkonto",
+ "config-admin-name": "Dein Benutzernoome:",
+ "config-admin-password": "Passwort:",
+ "config-admin-password-confirm": "Passwort repetiere:",
+ "config-admin-help": "Bitte den bevoarzugten Benutzernoome oongewe, beispielsweise \"Friedrich Beppler\".\nDas ist der Noome, wo benöticht weard, um sich im Wiki oonzumelde.",
+ "config-admin-name-blank": "Bitte den Benutzernoome für den Administratore oongewe.",
+ "config-admin-name-invalid": "Der oongebne Noomensraum „<nowiki>$1</nowiki>“ ist ungültich.\nBitte en abweichende Projektnoomeraum oongewe.",
+ "config-admin-password-blank": "Bitte das Passwort für das Administratorkonto oongewe.",
+ "config-admin-password-mismatch": "Die beide Passwörter stimme net doorrichaus und komplet.",
+ "config-admin-email": "E-Mail-Adress:",
+ "config-admin-email-help": "Bitte hier ein E-Mail-Adress oongewe, wo den E-Mail-Emfang von annre Benutzer von der Wiki, das Zurücksetze von das Passworte sowie Benachrichtichunge zu Ändrunge an beobachtete Seite ermöchlicht. Das Feld kann leer geloss sin.",
+ "config-admin-error-user": "Es ist beim Erstelle von der Administrator mit dem Noome „<nowiki>$1</nowiki>“ en interner Fehler uffgetret.",
+ "config-admin-error-password": "Es ist beim Setze von das Passwort für den Administrator „<nowiki>$1</nowiki>“ en interner Fehler uffgetret: <pre>$2</pre>",
+ "config-admin-error-bademail": "Es woard en ungültiche E-Mail-Adress oongeb",
+ "config-subscribe": "Bitte die Mailinglist [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mitteilunge zu Versionsveröffentlichunge] abonniere.",
+ "config-subscribe-help": "Es handelt sich hierbei um en Mailinglist mit weniche Aussendunge, die für Mitteilunge zu Versionsveröffentlichunge, inschliesslich wichticher Sicherheitsveröffentlichungen, benutzt weard.\nDie Mailingliste sollte abonniert werre. Zudem sollte die MediaWiki-Installation stets aktualisiert werre, sobald en neie Programmversion veröffentlicht woard.",
+ "config-subscribe-noemail": "Beim Abonniere (fazer assinatura) von der Mailinglist mit Mitteilunge zu Versionsveröffentlichunge woard ken E-Mail-Adress oongeb.\nBitte en E-Mail-Adress oongewe, soweit die Mailinglist abonniert werre soll.",
+ "config-almost-done": "Der Vorgang ist nächst abgeschloss!\nDie verbleibende Konfigurationsinstellunge könne üwersprung und das Wiki umgehend installiert sin.",
+ "config-optional-continue": "Jo, es solle weitre Konfigurationinstellunge voargenomm sin.",
+ "config-optional-skip": "Nee, das Wiki soll jetzt installiert werre.",
+ "config-profile": "Profil von der Benutzerberechtichunge:",
+ "config-profile-wiki": "Uffnes Wiki",
+ "config-profile-no-anon": "Erstellung von en Benutzerkonto erforderlich",
+ "config-profile-fishbowl": "ausschliesslich berechtichte Beoorbeiter",
+ "config-profile-private": "geschlossnes Wiki",
+ "config-profile-help": "Wikis sind am nützlichste, wenn so viele Mensche wie möchlich droon Bearbeitunge voarnehme könne.\nMit MediaWiki ist das enfach die letzte Ännrunge nohzuvollziehe und unbrauchbare Bearbeitunge, beispielsweise von unbedärfte orrer böswilliche Benutzer, rückgängich zu mache.\n\nAwer finne etliche Mensche Wikis ooch mit annere Beoorbeitungskonzepte sinnvoll. Manchmol ist das zudem net enfach alle Beteilichte von den Voarteile des „Wiki-Prinzips” zu üwerzeiche. Dodrum ist die Auswoohl möchlich.\n\nDas Modell „'''{{int:config-profile-wiki}}'''“ ermöchlicht es jederene, sogoor ohne üwer en Benutzerkonto zu verfüche, Bearbeitunge voarzunehme.\nEn Wiki bei dem die '''{{int:config-profile-no-anon}}''' ist, verlang von den Benutzer en höchre Verantwortung für ehre Beoorbeitunge en, könnt awer Persone abschrecke, die nuar gelechentlich Beoorbeitunge voarnehme wolle. En Wiki für '''{{int:config-profile-fishbowl}}''' gestattet (permitiert) es nuar bestimmte Benutzer, Beoorbeitunge voarzunehme. Awer kann dobei die Allgemeinheit die Seite immer noch betrachte und Ändrunge nohvollziehe. En '''{{int:config-profile-private}}''' gestattet es nur ausgewählte Benutzer, Seite zu betrachte sowie zu beoorbeite.\n\nKomplexre Konzepte zur Zugriffssteierung könne earst noh abgeschlossne Installationsvoargang ingerichtet sin. Hierzu gebts weitre Informatione uff der Website mit der [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights entsprechende Oonleitung].",
+ "config-license": "Lizenz:",
+ "config-license-none": "Ken Lizenzoongäb in der Fusszeile",
+ "config-license-cc-by-sa": "Creative Commons \"Noomenennung, Weitergäb unner gleiche Bedingunge“",
+ "config-license-cc-by": "Creative Commons „Noomenennung“",
+ "config-license-cc-by-nc-sa": "Creative Commons \"Noomenennung, net kommerziell, Weitergäb unner gleiche Bedingunge\"",
+ "config-license-cc-0": "Creative Commons \"Zero\" (Gemeinfreiheit)",
+ "config-license-gfdl": "GNU-Lizenz für freie Dokumentation 1.3 orrer höcher",
+ "config-license-pd": "Gemeinfreiheit",
+ "config-license-cc-choose": "En benutzerdefiniert Creative-Commons-Lizenz auswähle",
+ "config-license-help": "Viele öffentliche Wikis publiziere all Beiträche unner en [http://freedomdefined.org/Definition/De freie Lizenz].\nDas träht dozu bei en Gefühl von Gemeinschaft zu schaffe und ermuticht zu längerfristicher Mitoorweit.\nDahinchege ist im Allgemeinen en freie Lizenz uff geschlossne Wikis net notwennich.\n\nSoweit man Texte aus der Wikipedia verwenne möcht und umgekehrt, sollt die Creative Commons-Lizenz \"Noomenennung, Weitergäb unner gleiche Bedingunge\" gewählt sin.\n\nDie Wikipedia nutzte voarmols die GNU-Lizenz für freie Dokumentation (GFDL).\nDie GFDL ist en gültiche Lizenz, wo awer schwear zu verstehn ist.\nEs ist zudem schwierich gemäss die Lizenz lizenziert Inhalte wiederzuverwenne.",
+ "config-email-settings": "E-Mail-Instellunge",
+ "config-enable-email": "Ausgehende E-Mails ermöchliche",
+ "config-enable-email-help": "Soweit die E-Mail-Funktione benutzt sin solle, müsse die entsprechende [http://www.php.net/manual/en/mail.configuration.php PHP-E-Mail-Einstellungen] richtich konfiguriert sin.\nFür den Fall, dass die E-Mail-Funktione net benöticht sin, könne die dohier deaktiviert sin.",
+ "config-email-user": "E-Mail-Versand von Benutzer zu Benutzer aktiviere",
+ "config-email-user-help": "Alle Benutzer ermöchliche, sich gecheseitich E-Mails zu schicke, soweit die das in ehre Instellunge aktiviert hoon.",
+ "config-email-usertalk": "Benachrichtigunge zu Ändrunge an Benutzerdiskussionsseite ermöchliche",
+ "config-email-usertalk-help": "Ermöglicht es Benutzer, Benachrichtichunge zu Ännrunge an ehre Benutzerdiskussionsseite zu erhalte, soweit sie das in ehre Instellunge aktiviert hoon.",
+ "config-email-watchlist": "Benachrichtichunge zu Ändrunge an Seite uff der Beobachtungslist ermöchliche",
+ "config-email-watchlist-help": "Ermöglicht es Benutzer, Benachrichtichunge zu Ännrunge an ehre Benutzerdiskussionsseite zu erhalte, soweit sie das in ehre Instellunge aktiviert hoon.",
+ "config-email-auth": "E-Mail-Authentifizierung ermöchliche",
+ "config-email-auth-help": "Soweit die Funktion aktiviert ist, müsse Benutzer ehre E-Mail-Adress bestätiche, indem sie den Bestätichungslink nutze, der ehne immer dann zugesandt weard, wenn se ehre E-Mail-Adress oongeb orrer ändern.\nNuar bestätichte E-Mail-Adresse könne Nachrichte von annren Benutzer orrer Benachrichtichungsmitteilunge erhalten.\nDie Aktivierung con der Funktion weard bei offne Wikis, mit Hinblick uff möchliche Missbrauch von der E-Mail-Funktione, (rekommendiert) '''emfohl.'''",
+ "config-email-sender": "E-Mail-Adress für Antworte:",
+ "config-email-sender-help": "Bitte hier die E-Mail-Adress oongewe, die als Absenderadress bei ausgehende E-Mails ingesetzt werre soll.\nRückloofende E-Mails werre an die E-Mail-Adress gesandt.\nBei viele E-Mail-Server muss der Tel der E-Mail-Adress mit der Domainoongäb korrekt sin.",
+ "config-upload-settings": "Hochloode von Bilder und Dateie",
+ "config-upload-enable": "Das Hochloode von Dateie ermöchliche",
+ "config-upload-help": "Das Hochloode von Dateie macht den Server für potentielle Sicherheitsprobleme oonfällich.\nWeitre Informatione hierzu könne im [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security Abschnitt Sicherheit] von der Anleitung nohgeles sin.\n\nUm das Hochloode von Dateie zu ermöglichen, muss der Zugriff uff das Unnerverzeichnis <code>./images</code> so geännert sin, das das für den Webserver beschreibbar ist.\nHernoh kann die Option aktiviert sin.",
+ "config-upload-deleted": "Verzeichnis für gelöschte Dateie:",
+ "config-upload-deleted-help": "Bitte en Verzeichnis auswähle, in dem gelöschte Dateie archiviert werre solle.\nIdealerweise sollt es net üwer das Internet zugänglich sin.",
+ "config-logo": "Das Logo sein URL:",
+ "config-logo-help": "Die Standardoberfläche von MediaWiki verfücht links owerhalleb von der Seiteleiste üwer Platz für en Logo mit den Moaa 135x160 Pixel.\nBitte en Logo in entsprechender Gröss hochloode und die zugehöriche URL an der Stell oongewe.\n\nDu kannst <code>$wgStylePath</code> orrer <code>$wgScriptPath</code> verwenne, falls dein Logo relativ zu den Pade ist.\n\nSofern ken Logo benöticht weard, kann das Datefeld leer bleiwe.",
+ "config-instantcommons": "\"InstantCommons\" aktiviere",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons InstantCommons] ist en Funktion, wos Wikis ermöchlicht, Bild-, Klang- und annre Mediendateie zu nutze, wo uff der Website [https://commons.wikimedia.org/ Wikimedia Commons] verfüchbar sind.\nUm die Funktion nutze zu könne, muss das Wiki üwer en Verbinnung zum Internet verfüche.\n\nWeitre Informatione zu der Funktion, inschliesslich von der Oonleitung, wie hierfür annre Wikis als Wikimedia Commons ingerichtet werre könne, gebts im [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos Handbuch].",
+ "config-cc-error": "Der Creativ-Commons-Lizenzassistent konnt ken Lizenz ermittle.\nDie Lizenz ist doher jetzt manuell inzugewe.",
+ "config-cc-again": "Erneit auswähle …",
+ "config-cc-not-chosen": "Die gewünschte Creative-Commons-Lizenz auswähle und dann uff \"proceed\" klicke.",
+ "config-advanced-settings": "Erweiterte Konfiguratio",
+ "config-cache-options": "Instellunge für die Zwischichspeichrung von Objekte:",
+ "config-cache-help": "Das Objektcaching weard dozu benutzt die Geschwindigkeit von MediaWiki zu verbessre, indem häifich genutzte Date zwischichgespeichert werre.\nEs weard seahr emfohl (rekommendiert) das für mittelgrosse bis grosse Wikis zu nutze, awer ooch für klene Wikis ergewe sich erkennbare Geschwindichkeitsverbessrunge.",
+ "config-cache-none": "Ken Objektcaching (es weard ken Funktion entfernt, trotzdem kann das die Geschwindigkeit grössrer Wikis negativ beeinflusse)",
+ "config-cache-accel": "Objektcaching (Objektfeststelle, Objektufffänge) von PHP (APC, XCache orrer WinCache)",
+ "config-cache-memcached": "Memcached Cacheserver nutze (erfordert en zusätzliche Installationsvoargang mitsamt Konfiguration)",
+ "config-memcached-servers": "Memcached Cacheserver",
+ "config-memcached-help": "List der für Memcached nutzboore IP-Adresse.\nEs sollt en je Zeil mitsamt das voargesiehne Ports oongeb sin. Beispiele:\n127.0.0.1:11211 orrer\n192.168.1.25:1234 usw.",
+ "config-memcache-needservers": "Memcached woard als Cacheserver ausgewählt. Dobei woard trotzdem ken Server oongeb.",
+ "config-memcache-badip": "Es woard für Memcached en ungültiche IP-Adress oongeb: $1",
+ "config-memcache-noport": "Es woard ken Port zur Nutzung dorrich den Memcached Cacheserver oongeb: $1\nSoweit der Port unbekannt ist, ist 11211 die Standardoongäb.",
+ "config-memcache-badport": "Der Ports für den Memcached Cacheserver sollte zwischich $1 und $2 leihe",
+ "config-extensions": "Erweitrunge",
+ "config-extensions-help": "Die obich oongebne Erweitrungen woore im Verzeichnis <code>./extensions</code> gefund.\n\nEs könnte zusätzliche Konfigurierunge zu enzelne Erweitrunge erforderlich sin, trotzdem könne sie awer bereits jetzt aktiviert sin.",
+ "config-install-alreadydone": "'''Warnung:''' Es woard en vorhandne MediaWiki-Installation gefund.\nEs muss doher mit den nächste Seit weitergemacht sin.",
+ "config-install-begin": "Doorrich das Drücke von \"{{int:config-continue}}\" weard die Installation von MediaWiki gestartet (oongefang).\nSoweit Ännrunge voargenomm werre solle, kann man uff \"{{int:config-back}}\" klicken.",
+ "config-install-step-done": "erledichht",
+ "config-install-step-failed": "gescheitert",
+ "config-install-extensions": "Programmerweitrunge",
+ "config-install-database": "Datebank weard ingerichtet",
+ "config-install-schema": "Dateschema weard erstellt",
+ "config-install-pg-schema-not-exist": "Das PostgesSQL-Datenschema ist net voarhande",
+ "config-install-pg-schema-failed": "Das Erstelle von der Datetabelle ist gescheitert.\nDas muss sichergestellt sin, dass der Benutzer „$1“ Schreibzugriff uff das Dateschema „$2“ hot.",
+ "config-install-pg-commit": "Ändrunge oonwenne",
+ "config-install-pg-plpgsql": "Such noh der Datebanksprache PL/pgSQL",
+ "config-pg-no-plpgsql": "Für Datebank $1 muss die Datebanksprache PL/pgSQL installiert sin",
+ "config-pg-no-create-privs": "Das für die Installation oongeb Konto verfücht net üwer ausreichende Berechtichunge, um en Datebanknutzerkonto zu erstelle.",
+ "config-pg-not-in-role": "Das für den Webbenutzer oongebne Benutzerkonto ist bereits voarhand.\nDas für den Installationsvoargang oongebne Benutzerkonto ist ken Superbenutzer und net Mitglied von der Webbenutzer sein Benutzergrupp, so dass ken dem Webbenutzer zugeoordnete Dateobjekte erstellt sin könne.\n\nFür MediaWiki ist es momentan erforderlich (rekommendiert), dass die Tabelle dem Webbenutzer rechtemässich zugeoordnet sind. Bitte en annre Noome für den Wikibenutzer oongewe orrer „← Zurück“ oonklicke, um en ausreichend berechtichte Benutzer für den Installationsvoargang oonzugewe.",
+ "config-install-user": "Datebankbenutzer weard erstellt",
+ "config-install-user-alreadyexists": "Datebankbenutzer „$1“ ist bereits voarhand",
+ "config-install-user-create-failed": "Das Oonlehn von der Datebankbenutzers „$1“ ist gescheitert: $2",
+ "config-install-user-grant-failed": "Die Gewährung von der Berechtigung für Datebankbenutzer „$1“ ist gescheitert: $2",
+ "config-install-user-missing": "Der oongebne Benutzer „$1“ ist net voarhand.",
+ "config-install-user-missing-create": "Der oongebne Benutzer „$1“ ist net voarhanden.\nBitte das Auswählkästche „Benutzerkonto erstelle“ oonklicke, soweit der erstellt werre soll.",
+ "config-install-tables": "Datetabellen werre erstellt",
+ "config-install-tables-exist": "'''Warnung:''' Es woare MediaWiki-Datetabelle gefund.\nDie Erstellung woor üwersprung.",
+ "config-install-tables-failed": "'''Fehler:''' Die Erstellung von der Datetabelle ist (weche) uffgrund von der follichende Fehler gescheitert: $1",
+ "config-install-interwiki": "Interwikitabelle werre ingerichtet",
+ "config-install-interwiki-list": "Die Datei <code>interwiki.list</code> konnt net geles sin.",
+ "config-install-interwiki-exists": "'''Warnung:''' Es woare Interwikitabelle mit Date gefund.\nDie Standardliste weard üwersprung.",
+ "config-install-stats": "Statistike werre initialisiert",
+ "config-install-keys": "Geheimschlüssel werre erstellt",
+ "config-insecure-keys": "'''Warnung:''' {{PLURAL:$2|Der Geheimschlüssel|Die Geheimschlüssel}} $1, {{PLURAL:$2|der|die}} (während) im Verloof von der Installationsvoargang generiert {{PLURAL:$2|woard, ist|woare, sind}} net seahr sicher. {{PLURAL:$2|Er sollt|Sie sollte}} manuell geännert sin.",
+ "config-install-sysop": "Administratorkonto weard erstellt",
+ "config-install-subscribe-fail": "Abonniere von „mediawiki-announce“ ist gescheitert: $1",
+ "config-install-subscribe-notpossible": "cURL ist net installiert und <code>allow_url_fopen</code> ist niet verfüchbar.",
+ "config-install-mainpage": "Erstellung von der Hauptseit mit Standardinhalte (padronisierte Inhalte)",
+ "config-install-extension-tables": "Erstellung von der Tabelle für die aktivierte Erweitrunge",
+ "config-install-mainpage-failed": "Die Hauptseite konnt net erstellt sin: $1",
+ "config-install-done": "'''Herzliche Glückwunsch!'''\nMediaWiki woard erfollichreich installiert.\n\nDas Installationsprogramm hot die Datei <code>LocalSettings.php</code> erzeicht.\nSie enthält all voargenommne Konfigurationsinstellunge.\n\nDie Datei muss jetzt herunnergelood und oonschliessend in das Stammverzeichnis von der MediaWiki-Installation hochgelood sin. Das ist dasselwe Verzeichnis, in dem sich ooch die Datei <code>index.php</code> befinnt. Das Herunnerloode sollt inzwische automatisch oongefang sin.\n\nSoweit das net der Fall woar, orrer das Herunnerloode unnerbroch (interrompiert) woard, kann der Voargang doorrich en Klick uff den follichend Link erneit oongefang sinn:\n\n$3\n\n'''Hinweis:''' Die Konfigurationsdatei sollt jetzt unbedingt herunnergelood sin. Sie weard noh Beende von der Installationsprogramms, nemehr zur Verfüchung stehn (net mehr disponivel bleiwe).\n\nSobald alles erledicht woard, kann uff das '''[$2 Wiki zugegriffe werre]'''. Mir wünsche viel Spass und Erfollich mit dem Wiki.",
+ "config-download-localsettings": "<code>LocalSettings.php</code> herunnterlade",
+ "config-help": "Hellef",
+ "config-nofile": "Die Datei „$1“ konnt net gefund sin. Woor sie gelöscht?",
+ "config-extension-link": "Wusst du, dass dein Wiki die Nutzung von [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions Erweiterungen] unnerstützt?\n\nDu kannst [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category Erweitrunge noh Kategorie] doorrichsuche orrer die [https://www.mediawiki.org/wiki/Extension_Matrix Matrix der Erweiterungen] oonsiehn, um en Üwersicht zu verfüchbare Erweitrunge zu erhalten.",
+ "mainpagetext": "'''MediaWiki woor erfollichreich installiert.'''",
+ "mainpagedocfooter": "Hellef zur Benutzung und Konfiguration von der Wiki-Software finnst du im [https://meta.wikimedia.org/wiki/Help:Contents Benutzerhandbuch].\n\n== Oonfänghellef ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings List von der Konfigurationsvariable]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailinglist neier MediaWiki-Versione]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokalisier MediaWiki für dein Sproch]"
+}
diff --git a/www/wiki/includes/installer/i18n/hsb.json b/www/wiki/includes/installer/i18n/hsb.json
new file mode 100644
index 00000000..e875961a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hsb.json
@@ -0,0 +1,246 @@
+{
+ "@metadata": {
+ "authors": [
+ "Michawiki",
+ "아라",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Instalaciski program za MediaWiki",
+ "config-title": "Instalacija MediaWiki $1",
+ "config-information": "Informacije",
+ "config-localsettings-upgrade": "Dataja <code>LocalSettings.php</code> je so wotkryła.\nZo by tutu instalaciju aktualizował, zapodaj prošu hódnotu za parameter <code>$wgUpgradeKey</code> do slědowaceho pola.\nNamakaš tón parameter w dataji <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Dataja <code>LocalSettings.php</code> bu wotkryta.\nZo by tutu instalaciju aktualizował, wuwjedźće <code>update.php</code>",
+ "config-localsettings-key": "Aktualizaciski kluč:",
+ "config-localsettings-badkey": "Kluč, kotryž sy podał, je wopak",
+ "config-upgrade-key-missing": "Eksistowaca instalacija MediaWiki je so wotkryła.\nZo by tutu instalaciju aktualizował, staj prošu slědowacu linku deleka w dataji <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Zda so, zo eksistwoaca dataja <code>LocalSettings.php</code> je njedospołna.\nWariabla $1 njeje nastajena.\nProšu změń dataju <code>LocalSettings.php</code>, zo by so tuta wariabla nastajiła a klikń na \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Při zwjazowanju z datowej banku z pomocu nastajenjow podatych w <code>LocalSettings.php</code> je zmylk wustupił. Prošu skoriguj tute nastajenja a spytaj hišće raz.\n\n$1",
+ "config-session-error": "Zmylk při startowanju posedźenja: $1",
+ "config-session-expired": "Zda so, zo twoje posedźenske daty su spadnjene.\nPosedźenja su za čas žiwjenja $1 skonfigurowane.\nMóžeš jón přez nastajenje <code>session.gc_maxlifetime</code> w php.ini powyšić.\nStartuj instalaciski proces znowa.",
+ "config-no-session": "Twoje posedźenske daty su so zhubili!\nSkontroluj swój php.ini a zawěsć, zo <code>session.save_path</code> je na prawy zapis nastajeny.",
+ "config-your-language": "Twoja rěč:",
+ "config-your-language-help": "Wubjer rěč, kotraž ma so za instalaciski proces wužiwać.",
+ "config-wiki-language": "Wikirěč:",
+ "config-wiki-language-help": "Wubjer rěč, w kotrejž wiki ma so zwjetša pisać.",
+ "config-back": "← Wróćo",
+ "config-continue": "Dale →",
+ "config-page-language": "Rěč",
+ "config-page-welcome": "Witaj do MediaWiki!",
+ "config-page-dbconnect": "Z datowej banku zwjazać",
+ "config-page-upgrade": "Eksistowacu instalaciju aktualizować",
+ "config-page-dbsettings": "Nastajenja datoweje banki",
+ "config-page-name": "Mjeno",
+ "config-page-options": "Opcije",
+ "config-page-install": "Instalować",
+ "config-page-complete": "Dokónčeny!",
+ "config-page-restart": "Instalaciju znowa startować",
+ "config-page-readme": "Čitaj mje",
+ "config-page-releasenotes": "Wersijowe informacije",
+ "config-page-copying": "Kopěrowanje",
+ "config-page-upgradedoc": "Aktualizowanje",
+ "config-page-existingwiki": "Eksistowacy wiki",
+ "config-help-restart": "Chceš wšě składowane daty hašeć, kotrež sy zapodał a instalaciski proces znowa startować?",
+ "config-restart": "Haj, znowa startować",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki Startowa strona MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Nawod za wužiwarjow]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Nawod za administratorow]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Huste prašenja]\n----\n* <doclink href=Readme>Čitaj mje</doclink>\n* <doclink href=ReleaseNotes>Wersijowe informacije</doclink>\n* <doclink href=Copying>Licencne postajenja</doclink>\n* <doclink href=UpgradeDoc>Aktualizacija</doclink>",
+ "config-env-good": "Wokolina je so skontrolowała.\nMóžeš MediaWiki instalować.",
+ "config-env-bad": "Wokolina je so skontrolowała.\nNjemóžeš MediaWiki instalować.",
+ "config-env-php": "PHP $1 je instalowany.",
+ "config-unicode-using-intl": "Za normalizaciju Unicode so [http://pecl.php.net/intl PECL-rozšěrjenje intl] wužiwa.",
+ "config-no-db": "Njeda so přihódny ćěrjak datoweje banki namakać! Dyrbiš ćěrjak datoweje banki za PHP instalować.\nSlědowace typy datoweje banki so podpěruja: $1.\n\nJeli sy PHP sam kompilował, konfiguruj jón znowa z aktiwizowanym programom datoweje banki, na přikład z pomocu <code>./configure --with-mysqli</code>.\nJeli sy PHP z Debianoweho abo Ubuntuoweho paketa instalował, dyrbiš tež paket <code>php5-mysql</code> instalować.",
+ "config-outdated-sqlite": "'''Warnowanje''': maš SQLite $1, kotryž je starši hač minimalna trěbna wersija $2. SQLite njebudźe k dispoziciji stać.",
+ "config-no-fts3": "'''Warnowanje''': SQLite je so bjez [//sqlite.org/fts3.html FTS3-modula] kompilował, pytanske funkcije njebudu k dispoziciji stać.",
+ "config-pcre-no-utf8": "'''Ćežki zmylk''': Zda so, zo PCRE-modul za PHP ma so bjez PCRE_UTF8-podpěry kompilować.\nMediaWiki trjeba UTF-8-podpěru, zo by korektnje fungował.",
+ "config-memory-raised": "PHP-parameter <code>memory_limit</code> je $1, je so na hódnotu $2 zwyšił.",
+ "config-memory-bad": "'''Warnowanje:''' PHP-parameter <code>memory_limit</code> ma hódnotu $1,\nTo je najskerje přeniske.\nInstalacija móhła so njeporadźić!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] je instalowany",
+ "config-apc": "[http://www.php.net/apc APC] je instalowany",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] je instalowany",
+ "config-diff3-bad": "GNU diff3 njenamakany.",
+ "config-no-uri": "'''Zmylk:''' Aktualny URI njeda so postajić.\nInstalacija bu přetorhnjena.",
+ "config-no-cli-uri": "'''Warnowanje''': Žana skriptowa šćežka (<code>--scriptpath</code>) podata, standard so wužiwa: <code>$1</code>.",
+ "config-using-server": "Serwerowe mjeno \"<nowiki>$1</nowiki>\" so wužiwa.",
+ "config-using-uri": "Serwerowy URL \"<nowiki>$1$2</nowiki>\" so wužiwa.",
+ "config-db-type": "Typ datoweje banki:",
+ "config-db-host": "Serwer datoweje banki:",
+ "config-db-host-oracle": "Datowa banka TNS:",
+ "config-db-wiki-settings": "Tutón wiki identifikować",
+ "config-db-name": "Mjeno datoweje banki:",
+ "config-db-name-oracle": "Šema datoweje banki:",
+ "config-db-install-account": "Wužiwarske konto za instalaciju",
+ "config-db-username": "Wužiwarske mjeno datoweje banki:",
+ "config-db-password": "Hesło datoweje banki:",
+ "config-db-install-username": "Zapodaj wužiwarske mjeno, kotrež budźe so za zwisk z datowej banku za instalaciski proces wužiwać.\nTo njeje wužiwarske mjeno konta MediaWiki; to je wužiwarske mjeno za twoju datowu banku.",
+ "config-db-install-password": "Zapodaj hesło, kotrež budźe so za zwisk z datowej banku za instalaciski proces wužiwać.\nTo njeje hesło konta MediaWiki; to je hesło za twoju datowu banku.",
+ "config-db-install-help": "Zapodaj wužiwarske mjeno a hesło, kotrejž měłoj so za zwisk z datowej banku za instalaciski proces wužiwać.",
+ "config-db-account-lock": "Samsne wužiwarske mjeno a hesło za normalnu operaciju wužiwać",
+ "config-db-wiki-account": "Wužiwarske konto za normalnu operaciju",
+ "config-db-prefix": "Tabelowy prefiks datoweje banki:",
+ "config-mysql-old": "MySQL $1 abo nowši trěbny, maš $2.",
+ "config-db-port": "Port datoweje banki:",
+ "config-db-schema": "Šema za MediaWiki",
+ "config-db-schema-help": "Tuta šema da so zwjetša derje wužiwać.\nZměń ju jenož, jeli su přeswědčiwe přičiny za to.",
+ "config-pg-test-error": "Zwisk z datowej banku '''$1''' móžno njeje: $2",
+ "config-sqlite-dir": "Zapis SQLite-datow:",
+ "config-oracle-def-ts": "Standardny tabelowy rum:",
+ "config-oracle-temp-ts": "Nachwilny tabelowy rum:",
+ "config-type-mysql": "MySQL (abo kompatibelny)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] je primarny cil za MediaWiki a podpěruje so najlěpje. MediaWiki funguje tež z [{{int:version-db-mariadb-url}} MariaDB] a [{{int:version-db-percona-url}} Percona Server], kotrejž stej kompatibelnej z MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Nawod ke kompilowanju PHP z MySQL-podpěru])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] je popularny system datoweje banki zjawneho žórła jako alternatiwa k MySQL. Móhło hišće někotre zmylki eksistować, a njeporuča so jón w produktiwnej wokolinje wužiwać. ([http://www.php.net/manual/en/pgsql.installation.php Nawod za kompilowanje PHP z podpěru PostgreSQL])",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] je komercielna předewzaćelska datowa banka. ([http://www.php.net/manual/en/oci8.installation.php Nawod za kompilowanje PHP z OCI8-podpěru])",
+ "config-header-mysql": "Nastajenja MySQL",
+ "config-header-postgres": "Nastajenja PostgreSQL",
+ "config-header-sqlite": "Nastajenja SQLite",
+ "config-header-oracle": "Nastajenja Oracle",
+ "config-invalid-db-type": "Njepłaćiwy typ datoweje banki",
+ "config-missing-db-name": "Dyrbiš hódnotu za \"Mjeno datoweje banki\" zapodać",
+ "config-missing-db-host": "Dyrbiš hódnotu za \"Database host\" zapodać",
+ "config-missing-db-server-oracle": "Dyrbiš hódnotu za \"Database TNS\" zapodać",
+ "config-invalid-db-server-oracle": "Njepłaćiwa datowa banka TNS \"$1\".\nWužij pak \"TNS Name\" pak znamješkowy rjećazk \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle - pomjenowanske metody])",
+ "config-invalid-db-name": "Njepłaćiwe mjeno \"$1\" datoweje banki.\nWužij jenož pismiki ASCII (a-z, A-Z), ličby (0-9),a podsmužki (_) a wjazawki (-).",
+ "config-invalid-db-prefix": "Njepłaćiwy prefiks \"$1\" datoweje banki.\nWužij jenož pismiki ASCII (a-z, A-Z), ličby (0-9), podsmužki (_) a wjazawki (-).",
+ "config-connection-error": "$1.\n\nSkontroluj serwer, wužiwarske a hesło a spytaj hišće raz.",
+ "config-invalid-schema": "Njepłaćiwe šema za MediaWiki \"$1\".\nWužij jenož pismiki ASCII (a-z, A-Z), ličby (0-9) a podsmužki (_).",
+ "config-db-sys-create-oracle": "Instalaciski program podpěruje jenož wužiwanje SYSDBA-konta za zakoženje noweho konta.",
+ "config-db-sys-user-exists-oracle": "Wužiwarske konto \"$1\" hižo eksistuje. SYSDBA hodźi so jenož za załoženje noweho konta wužiwać!",
+ "config-postgres-old": "PostgreSQL $1 abo nowši trěbny, maš $2.",
+ "config-sqlite-name-help": "Wubjer mjeno, kotrež twój wiki identifikuje.\nNjewužij mjezery abo wjazawki.\nTo budźe so za mjeno dataje SQLite-datow wužiwać.",
+ "config-sqlite-mkdir-error": "Zmylk při wutworjenju datoweho zapisa \"$1\".\nSkontroluj městno a spytaj hišće raz.",
+ "config-sqlite-dir-unwritable": "Njeje móžno do zapisa \"$1\" pisać.\nZměń jeho prawa, tak zo webserwer móže do njeho pisać a spytaj hišće raz.",
+ "config-sqlite-connection-error": "$1.\n\nSkontroluj datowy zapis a mjeno datoweje banki kaj spytaj hišće raz.",
+ "config-sqlite-readonly": "Do dataje <code>$1</code> njeda so pisać.",
+ "config-sqlite-cant-create-db": "Dataja <code>$1</code> datoweje banki njeda so wutworić.",
+ "config-sqlite-fts3-downgrade": "PHP wo podpěrje FTS3 k dispoziciji njesteji, table so znižuja",
+ "config-can-upgrade": "Su tabele MediaWiki w tutej datowej bance.\nZo by je na MediaWiki $1 aktualizował, klikń na '''Dale'''.",
+ "config-upgrade-done-no-regenerate": "Aktualizacija dokónčena.\n\nMóžeš nětko [$1 swój wiki wužiwać].",
+ "config-regenerate": "LocalSettings.php znowa wutworić →",
+ "config-show-table-status": "Naprašowanje <code>SHOW TABLE STATUS</code> je so njeporadźiło!",
+ "config-unknown-collation": "'''Warnowanje:''' Datowa banka njeznatu kolaciju wužiwa.",
+ "config-db-web-account": "Konto datoweje banki za webpřistup",
+ "config-db-web-help": "wubjer wužiwarske mjeno a hesło, kotrejž webserwer budźe wužiwać, zo by z serwerom datoweje banki za wšědnu operaciju zwjazać",
+ "config-db-web-account-same": "Samsne konto kaž za instalaciju wužiwać",
+ "config-db-web-create": "Załož konto, jeli hišće njeeksistuje.",
+ "config-db-web-no-create-privs": "Konto, kotrež sy za instalaciju podał, nima dosć woprawnjenjow, zo by konto wutworiło.\nKonto, kotrež tu podawaće, dyrbi hižo eksistować.",
+ "config-mysql-engine": "Składowanska mašina:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Znamješkowa sadźba datoweje banki:",
+ "config-mysql-binary": "Binarny",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Mjeno wikija:",
+ "config-site-name-help": "To zjewi so w titulowej lejstwje wobhladaka kaž tež na wšelakich druhich městnach.",
+ "config-site-name-blank": "Zapodaj sydłowe mjeno.",
+ "config-project-namespace": "Mjenowy rum projekta:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Samsne kaž wikimjeno: $1",
+ "config-ns-other": "Druhe (podać)",
+ "config-ns-other-default": "MyWiki",
+ "config-ns-invalid": "Podaty mjenowy rum \"<nowiki>$1</nowiki>\" je njepłaćiwy.\nPodaj druhi projektowy mjenowy rum.",
+ "config-ns-conflict": "Podaty mjenowy rum \"<nowiki>$1</nowiki>\" je w konflikće ze standardnym mjenjowym rumom MediaWiki.\nPodaj druhi projektowy mjenowy rum.",
+ "config-admin-box": "Administratorowe konto",
+ "config-admin-name": "Twoje wužiwarske mjeno:",
+ "config-admin-password": "Hesło:",
+ "config-admin-password-confirm": "Hesło wospjetować:",
+ "config-admin-help": "Zapodaj swoje preferowane wužiwarske mjeno, na přikład \"Jurij Serb\".\nTo je mjeno, kotrež budźeš wužiwać, zo by so do wikija přizjewił.",
+ "config-admin-name-blank": "Zapodaj administratorowe wužiwarske mjeno.",
+ "config-admin-name-invalid": "Podate wužiwarske mjeno \"<nowiki>$1</nowiki>\" je njepłaćiwe.\nPodaj druhe wužiwarske mjeno.",
+ "config-admin-password-blank": "Zapodaj hesło za administratorowe konto.",
+ "config-admin-password-mismatch": "Wobě hesle, kotrejž sy zapodał, njejstej jenakej.",
+ "config-admin-email": "E-mejlowa adresa:",
+ "config-admin-email-help": "Zapodaj tu e-mejlowu adresu, zo by přijimanje e-mejlow wot druhich wužiwarjow w tutym wikiju zmóžnił, swoje hesło wróćo stajił a zdźělenki wo změnach na swojich wobkedźbowanych stronach dostał. Móžeš polo prózdne wostajić.",
+ "config-admin-error-user": "Interny zmylk při wutworjenju administratora z mjenom \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Interny zmylk při nastajenju hesła za administratora \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Sy njepłaćiwu e-mejlowu adresu zapodał.",
+ "config-subscribe": "[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Rozesyłansku lisćinu wo připowědźenjach nowych wersijow ].abonować",
+ "config-almost-done": "Sy skoro hotowy!\nMóžeš nětko zbytnu konfiguraciju přeskočić a wiki hnydom instalować.",
+ "config-optional-continue": "Dalše prašenja?",
+ "config-optional-skip": "Instaluj nětko wiki.",
+ "config-profile": "Profil wužiwarskich prawow:",
+ "config-profile-wiki": "Zjawny wiki",
+ "config-profile-no-anon": "Załoženje konto je trěbne",
+ "config-profile-fishbowl": "Jenož awtorizowani wobdźěłarjo",
+ "config-profile-private": "Priwatny wiki",
+ "config-license": "Awtorske prawo a licenca:",
+ "config-license-none": "Žane licencne podaća w nohowej lince",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (zjawnosći přistupny)",
+ "config-license-gfdl": "GNU-licenca za swobodnu dokumentaciju 1.3 abo nowša",
+ "config-license-pd": "Powšitkownosći přistupny",
+ "config-license-cc-choose": "Swójsku licencu Creative Commons wubrać",
+ "config-email-settings": "E-mejlowe nastajenja",
+ "config-enable-email": "Wuchadźace e-mejlki zmóžnić",
+ "config-enable-email-help": "Jeli chceš e-mejl wužiwać, dyrbja so [http://www.php.net/manual/en/mail.configuration.php e-mejlowe nastajenja PHP] prawje konfigurować.\nJeli nochceš e-mejlowe funkcije wužiwać, móžeš je tu znjemóžnić.",
+ "config-email-user": "E-mejl mjez wužiwarjemi zmóžnić",
+ "config-email-user-help": "Wšěm wužiwarjam dowolić, jednomu druhemu e-mejlki pósłać, jeli su tutu funkciju w swojich nastajenjach zmóžnili.",
+ "config-email-usertalk": "Zdźělenja za wužiwarske diskusijne strony zmóžnić",
+ "config-email-usertalk-help": "Wužiwarjam dowolić zdźělenki wo změnach na wužiwarskich diskusijnych stronach dóstać, jeli woni su to w swojich nastajenjach zmóžnili.",
+ "config-email-watchlist": "Zdźělenja za wobkedźbowanki zmóžnić",
+ "config-email-watchlist-help": "Wužiwarjam dowolić zdźělenki wo jich wobked´bowanych stronach dóstać, jeli woni su to w swojich nastajenjach zmóžnili.",
+ "config-email-auth": "E-mejlowu awtentifikaciju zmóžnić",
+ "config-email-sender": "E-mejlowa adresa za wotmołwy:",
+ "config-upload-settings": "Wobrazy a nahraća datajow",
+ "config-upload-enable": "Nahraće datajow zmóžnić",
+ "config-upload-deleted": "Zapis za zhašane dataje:",
+ "config-upload-deleted-help": "Wubjer zapis, w kotrymž zhašene dataje maja so archiwować.\nIdealnje tón njeměł z weba přistupny być.",
+ "config-logo": "URL loga:",
+ "config-instantcommons": "Instant commons zmóžnić",
+ "config-cc-error": "Pytanje za licencu Creative Commons njeje žadyn wuslědk přinjesło.\nZapodaj licencne mjeno manuelnje.",
+ "config-cc-again": "Zaso wubrać...",
+ "config-cc-not-chosen": "Wubjer licencu Creative Commons a klikń na \"proceed\".",
+ "config-advanced-settings": "Rozšěrjena konfiguraćija",
+ "config-cache-options": "Nastajenja za objektowe pufrowanje:",
+ "config-cache-none": "Žane pufrowanje (žana funkcionalnosć so njewotstronja, ale spěšnosć móže so na wjetšich wikijowych sydłach wobwliwować)",
+ "config-cache-accel": "Objektowe pufrowanje PHP (APC, XCache abo WinCache)",
+ "config-cache-memcached": "Memcached wužiwać (wužaduje sej přidatnu instalaciju a konfiguraciju)",
+ "config-memcached-servers": "Serwery memcached:",
+ "config-memcached-help": "Lisćina IP-adresow, kotrež maja so za Memcached wužiwać.\nKóžda linka měła jenož jednu IP-adresu a port, kotryž ma so wužiwać, wobsahować. Na přikład:\n127.0.0.1:11211\n192.168.1.25:1234",
+ "config-memcache-needservers": "Sy Memcached jako swój pufrowakowy typ wubrał, ale njejsy žane serwery podał",
+ "config-memcache-badip": "Sy njepłaćiwu IP-adresu za Memcached zapodał: $1",
+ "config-memcache-noport": "Njejsy žadyn port za wužiwanje serwera Memcached podał: $1.\nJeli port njewěš, standard je 11211.",
+ "config-memcache-badport": "Portowe čisła za Memcached měli mjez $1 a $2 być",
+ "config-extensions": "Rozšěrjenja",
+ "config-extensions-help": "Rozšěrjenja podate horjeka buchu w twojim zapisu <code>./extensions</code> namakane.\n\nTo móže sej přidatnu konfiguraciju wužadać, ale móžeš je nětko zmóžnić.",
+ "config-install-alreadydone": "'''Warnowanje:''' Zda so, zo sy hižo MediaWiki instalował a pospytuješ jón znowa instalować.\nProšu pokročuj z přichodnej stronu.",
+ "config-install-begin": "Přez kliknjenje na \"{{int:config-continue}}\" budźe so instalacija MediaWiki startować.\nJeli hišće chceš něšto změnić, klikń na \"{{int:config-back}}\".",
+ "config-install-step-done": "dokónčene",
+ "config-install-step-failed": "njeporadźiło",
+ "config-install-extensions": "Inkluziwnje rozšěrjenja",
+ "config-install-database": "Datowa banka so připrawja",
+ "config-install-schema": "Datowa šema so twori",
+ "config-install-pg-schema-not-exist": "Šema PostgreSQL njeeksistuje",
+ "config-install-pg-schema-failed": "Wutworjenje tabelow je so njeporadźiło.\nZawěsć, zo wužiwar \"$1\" móže do šemy \"$2\" pisać.",
+ "config-install-pg-commit": "Změny so wotesyłaja",
+ "config-install-pg-plpgsql": "Pruwowanje za rěču PL/pgSQL",
+ "config-pg-no-plpgsql": "Dyrbiš rěč PL/pgSQL w datowej bance $1 instalować",
+ "config-pg-no-create-privs": "Konto, kotrež sy za instalaciju podał, nima dosahace prawa za wutworjenje konta.",
+ "config-install-user": "Tworjenje wužiwarja datoweje banki",
+ "config-install-user-alreadyexists": "Wužiwar \"$1\" hižo eksistuje",
+ "config-install-user-create-failed": "Wutworjenje wužiwarja \"$1\" je so njeporadźiło: $2",
+ "config-install-user-grant-failed": "Prawo njeda so wužiwarjej \"$1\" dać: $2",
+ "config-install-user-missing": "Podaty wužiwar \"$1\" njeeksistuje.",
+ "config-install-user-missing-create": "Podaty wužiwar \"$1\" njeeksistuje.\nProšu klikń na slědowacy kontrolny kašćik \"konto załožić\", jeli chceš jo wutworić.",
+ "config-install-tables": "Tworjenje tabelow",
+ "config-install-tables-exist": "'''Warnowanje''': Zda so, zo tabele MediaWiki hižo eksistuja.\nWutworjenje so přeskakuje.",
+ "config-install-tables-failed": "'''Zmylk''': Wutworjenje tabele je so slědowaceho zmylka dla njeporadźiło: $1",
+ "config-install-interwiki": "Standardna tabela interwikijow so pjelni",
+ "config-install-interwiki-list": "<code>interwiki.list</code> njeda so namakać.",
+ "config-install-interwiki-exists": "'''Warnowanje''': Zda so, zo tabela interwikjow hižo zapiski wobsahuje.\nStandardna lisćina sp přeskakuje.",
+ "config-install-stats": "Statistika so inicializuje",
+ "config-install-keys": "Tajne kluče so tworja",
+ "config-install-sysop": "Tworjenje administratoroweho wužiwarskeho konta",
+ "config-install-subscribe-fail": "Abonowanje \"mediawiki-announce\" njemóžno: $1",
+ "config-install-subscribe-notpossible": "cURL njeje instalowany a <code>allow_url_fopen</code> k dispoziciji njesteji.",
+ "config-install-mainpage": "Hłowna strona so ze standardnym wobsahom wutworja",
+ "config-install-extension-tables": "Tabele za zmóžnjene rozšěrjenja so tworja",
+ "config-install-mainpage-failed": "Powěsć njeda so zasunyć: $1",
+ "config-download-localsettings": "<code>LocalSettings.php</code> sćahnyć",
+ "config-help": "pomoc",
+ "config-nofile": "Dataja \"$1\" njeje so namakała. Je so zhašała?",
+ "mainpagetext": "'''MediaWiki bu wuspěšnje instalowany.'''",
+ "mainpagedocfooter": "Prošu hlej [https://meta.wikimedia.org/wiki/Help:Contents dokumentaciju] za informacije wo wužiwanju softwary.\n\n== Za nowačkow ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Wo nastajenjach]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources MediaWiki za twoju rěč lokalizować]"
+}
diff --git a/www/wiki/includes/installer/i18n/hsn.json b/www/wiki/includes/installer/i18n/hsn.json
new file mode 100644
index 00000000..cebc9798
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hsn.json
@@ -0,0 +1,35 @@
+{
+ "@metadata": {
+ "authors": [
+ "SolidBlock"
+ ]
+ },
+ "config-desc": "MediaWiki安装程序",
+ "config-title": "MediaWiki $1配置",
+ "config-information": "信息",
+ "config-localsettings-upgrade": "发现哒<code>LocalSettings.php</code>文件。要提升啯杂配置,就于啯杂框框中输入<code>$wgUpgradeKey</code>的值。您可以在<code>LocalSettings.php</code>中寻它。",
+ "config-localsettings-cli-upgrade": "查噶<code>LocalSettings.php</code>文件。要升级啯杂配置,请直接走<code>update.php</code>。",
+ "config-localsettings-key": "升级密钥:",
+ "config-localsettings-badkey": "啯杂密钥是错的。",
+ "config-session-error": "搞会话碰哒鬼:$1",
+ "config-your-language": "您使用的语言:",
+ "config-wiki-language": "Wiki语言:",
+ "config-back": "← 后退",
+ "config-continue": "行克 →",
+ "config-page-language": "语言",
+ "config-page-welcome": "欢迎使用MediaWiki!",
+ "config-page-name": "名字",
+ "config-page-options": "选项",
+ "config-page-install": "装下克",
+ "config-page-complete": "搞好哒!",
+ "config-page-readme": "自述",
+ "config-page-copying": "复制",
+ "config-page-upgradedoc": "升级",
+ "config-page-existingwiki": "现成的wiki",
+ "config-help-restart": "要不要哈消嘎输入且存好的东西,并且重新开始装?",
+ "config-restart": "嗯,重搞",
+ "config-env-good": "环境检查哈好哒。你可以安装MediaWiki哒。",
+ "config-env-bad": "环境检查好哒,可惜你搞不了MediaWiki。",
+ "config-env-php": "PHP $1装哒。",
+ "config-env-hhvm": "HHVM $1装哒。"
+}
diff --git a/www/wiki/includes/installer/i18n/ht.json b/www/wiki/includes/installer/i18n/ht.json
new file mode 100644
index 00000000..d261a4b6
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ht.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Boukman",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''MedyaWiki byen enstale l.'''",
+ "mainpagedocfooter": "Konsilte [https://meta.wikimedia.org/wiki/Help:Contents Gid Itilizatè] pou enfòmasyon sou kijan pou w itilize logisyèl wiki a.\n\n== Kijan pou kòmanse ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lis paramèt yo pou konfigirasyon]\n* [https://www.mediawiki.org/wiki/Manyèl:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lis diskisyon ki parèt sou MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/hu-formal.json b/www/wiki/includes/installer/i18n/hu-formal.json
new file mode 100644
index 00000000..882a084d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hu-formal.json
@@ -0,0 +1,31 @@
+{
+ "@metadata": {
+ "authors": [
+ "Dani",
+ "Glanthor Reviol",
+ "Tacsipacsi"
+ ]
+ },
+ "config-localsettings-upgrade": "Már létezik a <code>LocalSettings.php</code> fájl.\nA telepített szoftver frissítéséhez írja be az alábbi mezőbe a <code>$wgUpgradeKey</code> beállítás értékét, melyet a <code>LocalSettings.php</code> nevű fájlban találhat meg.",
+ "config-session-expired": "Úgy tűnik, hogy a munkamenetadatok lejártak.\nA munkamenetek élettartama a következőre van beállítva: $1.\nAz érték növelhető a php.ini <code>session.gc_maxlifetime</code> beállításának módosításával.\nIndítsa újra a telepítési folyamatot.",
+ "config-no-session": "Elvesztek a munkamenetadatok!\nEllenőrizze, hogy a php.ini-ben a <code>session.save_path</code> beállítás a megfelelő könyvtárra mutat-e.",
+ "config-your-language-help": "Válassza ki a telepítési folyamat során használandó nyelvet.",
+ "config-wiki-language-help": "Az a nyelv, amin a wiki tartalmának legnagyobb része íródik.",
+ "config-page-welcome": "Üdvözli a MediaWiki!",
+ "config-help-restart": "Szeretné törölni az eddig megadott összes adatot és újraindítani a telepítési folyamatot?",
+ "config-welcome": "=== Környezet ellenőrzése ===\nAlapvető ellenőrzés, ami megmondja, hogy a környezet alkalmas-e a MediaWiki számára.\nHa probléma merülne fel a telepítés során, meg kell adnia mások számára az alább megjelenő információkat.",
+ "config-unicode-pure-php-warning": "<strong>Figyelmeztetés:</strong> Az [http://pecl.php.net/intl intl PECL kiterjesztés] nem érhető el Unicode normalizáláshoz, helyette a lassú, PHP alapú implementáció lesz használva.\nHa nagy látogatottságú oldalt üzemeltet, itt találhat információkat [http://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations a témáról].",
+ "config-imagemagick": "Az ImageMagick megtalálható a rendszeren: <code>$1</code>.\nA bélyegképek készítése engedélyezve lesz, ha engedélyezi a feltöltéseket.",
+ "config-db-name-help": "Válassza ki a wikije azonosítására használt nevet.\nNem tartalmazhat szóközt.\n\nHa megosztott webtárhelyet használ, a szolgáltatója vagy egy konkrét adatbázisnevet ad önnek használatra, vagy létrehozhat egyet a vezérlőpulton keresztül.",
+ "config-db-install-help": "Adja meg a felhasználónevet és jelszót, amivel a telepítő csatlakozhat az adatbázishoz.",
+ "config-db-wiki-help": "Adja meg azt a felhasználónevet és jelszót, amivel a wiki fog csatlakozni az adatbázishoz működés közben.\nHa a fiók nem létezik és a telepítést végző fiók rendelkezik megfelelő jogosultsággal, egy új fiók készül a megadott a névvel, azon minimális jogosultságkörrel, ami a wiki működéséhez szükséges.",
+ "config-db-schema-help": "A fenti sémák általában megfelelőek.\nCsak akkor módosítson rajta, ha szükség van rá.",
+ "config-sqlite-parent-unwritable-nogroup": "Nem lehet létrehozni az adatok tárolásához szükséges <code><nowiki>$1</nowiki></code> könyvtárat, mert a webszerver nem írhat a szülőkönyvtárba (<code><nowiki>$2</nowiki></code>).\n\nA telepítő nem tudta megállapíteni, hogy melyik felhasználói fiókon fut a webszerver.\nA folytatáshoz tegye írhatóvá ezen fiók (és más fiókok!) számára a következő könyvtárat: <code><nowiki>$3</nowiki></code>.\nUnix/Linux rendszereken tedd a következőt:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-ns-other": "Más (adja meg)",
+ "config-admin-name-blank": "Adja meg az adminisztrátor felhasználónevét!",
+ "config-admin-name-invalid": "A megadott felhasználónév (<nowiki>$1</nowiki>) érvénytelen.\nAdjon meg egy másik felhasználónevet.",
+ "config-admin-password-blank": "Adja meg az adminisztrátori fiók jelszavát!",
+ "config-instantcommons-help": "Az [https://www.mediawiki.org/wiki/InstantCommons Instant Commons] lehetővé teszi, hogy a wikin használhassák a [https://commons.wikimedia.org/ Wikimedia Commons] oldalon található képeket, hangokat és más médiafájlokat.\nA használatához a MediaWikinek internethozzáférésre van szüksége.\n\nA funkcióról és hogy hogyan állítható be más wikik esetén [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos a kézikönyvben] találhat további információkat.",
+ "config-install-done": "'''Gratulálunk!'''\nSikeresen telepítette a MediaWikit.\n\nA telepítő készített egy <code>LocalSettings.php</code> fájlt.\nEz tartalmazza az összes beállítást.\n\n[$1 Le kell töltenie], és el kell helyeznie a MediaWiki telepítési könyvtárába (az a könyvtár, ahol az index.php van).\n'''Megjegyzés''': Ha ezt most nem teszi meg, és kilép, a generált fájl nem lesz elérhető a későbbiekben.\n\nHa ezzel készen van, '''[$2 beléphet a wikibe]'''.",
+ "mainpagedocfooter": "Ha segítségre van szüksége a wikiszoftver használatához, akkor keresse fel a [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] oldalt.\n\n== Alapok (angol nyelven) ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Beállítások listája]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki GyIK]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki-kiadások levelezőlistája]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources A MediaWiki fordítása a saját nyelvére]"
+}
diff --git a/www/wiki/includes/installer/i18n/hu.json b/www/wiki/includes/installer/i18n/hu.json
new file mode 100644
index 00000000..759b82db
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hu.json
@@ -0,0 +1,309 @@
+{
+ "@metadata": {
+ "authors": [
+ "Dani",
+ "Glanthor Reviol",
+ "아라",
+ "Dj",
+ "Misibacsi",
+ "Tacsipacsi",
+ "Dorgan",
+ "Macofe",
+ "Máté",
+ "Seb35",
+ "Urbalazs"
+ ]
+ },
+ "config-desc": "A MediaWiki telepítője",
+ "config-title": "A MediaWiki $1 telepítése",
+ "config-information": "Információ",
+ "config-localsettings-upgrade": "Már létezik a <code>LocalSettings.php</code> fájl.\nA telepített szoftver frissítéséhez írd be az alábbi mezőbe a <code>$wgUpgradeKey</code> beállítás értékét, melyet a <code>LocalSettings.php</code> nevű fájlban találhatsz meg.",
+ "config-localsettings-cli-upgrade": "A <code>LocalSettings.php</code> fájl megtalálható.\nA telepített rendszer frissítéséhez futtasd az <code>update.php</code>-t.",
+ "config-localsettings-key": "Frissítési kulcs:",
+ "config-localsettings-badkey": "A megadott frissítési kulcs érvénytelen.",
+ "config-upgrade-key-missing": "A telepítő a MediaWiki meglévő példányát észlelte.\nA telepített rendszer frissítéséhez helyezd el az alábbi sort a <code>LocalSettings.php</code> végére:\n\n$1",
+ "config-localsettings-incomplete": "A meglévő <code>LocalSettings.php</code> hiányosnak tűnik.\nA(z) $1 változó értéke nincs beállítva.\nMódosítsd a <code>LocalSettings.php</code> fájlt úgy, hogy ez a változó be legyen állítva, majd kattints a „{{int:Config-continue}}” gombra.",
+ "config-localsettings-connection-error": "Nem sikerült csatlakozni az adatbázishoz a <code>LocalSettings.php</code>-ben megadott adatokkal. Ellenőrizd a beállításokat, majd próbáld újra.\n\n$1",
+ "config-session-error": "Nem sikerült elindítani a munkamenetet: $1",
+ "config-session-expired": "Úgy tűnik, hogy a munkamenetadatok lejártak.\nA munkamenetek élettartama a következőre van beállítva: $1.\nAz érték növelhető a php.ini <code>session.gc_maxlifetime</code> beállításának módosításával.\nIndítsd újra a telepítési folyamatot.",
+ "config-no-session": "Elvesztek a munkamenetadatok!\nEllenőrizd, hogy a php.ini-ben a <code>session.save_path</code> a megfelelő könyvtárra mutat-e.",
+ "config-your-language": "Nyelv:",
+ "config-your-language-help": "A telepítési folyamat során használandó nyelv.",
+ "config-wiki-language": "A wiki nyelve:",
+ "config-wiki-language-help": "Az a nyelv, amin a wiki tartalmának legnagyobb része íródik.",
+ "config-back": "← Vissza",
+ "config-continue": "Folytatás →",
+ "config-page-language": "Nyelv",
+ "config-page-welcome": "Üdvözöl a MediaWiki!",
+ "config-page-dbconnect": "Kapcsolódás az adatbázishoz",
+ "config-page-upgrade": "Telepített változat frissítése",
+ "config-page-dbsettings": "Adatbázis-beállítások",
+ "config-page-name": "Név",
+ "config-page-options": "Beállítások",
+ "config-page-install": "Telepítés",
+ "config-page-complete": "Kész!",
+ "config-page-restart": "Telepítés újraindítása",
+ "config-page-readme": "Tudnivalók",
+ "config-page-releasenotes": "Kiadási megjegyzések",
+ "config-page-copying": "Másolás",
+ "config-page-upgradedoc": "Frissítés",
+ "config-page-existingwiki": "Létező wiki",
+ "config-help-restart": "Szeretnéd törölni az eddig megadott összes adatot és újraindítani a telepítési folyamatot?",
+ "config-restart": "Igen, újraindítás",
+ "config-welcome": "=== A környezet ellenőrzése ===\nNéhány alapvető ellenőrzés kerül végrehajtásra, hogy kiderüljön ,hogy ez a környezet alkalmas-e a MediaWiki telepítésére.\nHa telepítéssel kapcsolatos segítségre van szükséged, add meg ezen ellenőrzések eredményét.",
+ "config-copyright": "=== Licenc és feltételek ===\n\n$1\n\nEz a program szabad szoftver; terjeszthető illetve módosítható a Free Software Foundation által kiadott GNU General Public License dokumentumában leírtak; akár a licenc 2-es, akár (tetszőleges) későbbi változata szerint.\n\nEz a program abban a reményben kerül közreadásra, hogy hasznos lesz, de minden egyéb '''garancia nélkül''', az '''eladhatóságra''' vagy '''valamely célra való alkalmazhatóságra''' való származtatott garanciát is beleértve. További részleteket a GNU General Public License tartalmaz.\n\nA felhasználónak a programmal együtt meg kell kapnia a <doclink href=Copying>GNU General Public License egy példányát</doclink>; ha mégsem kapta meg, akkor írjon a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. címre, vagy [http://www.gnu.org/copyleft/gpl.html tekintse meg online].",
+ "config-sidebar": "* [https://www.mediawiki.org A MediaWiki honlapja]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Felhasználói kézikönyv]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Útmutató adminisztrátoroknak]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ GyIK]\n----\n* <doclink href=Readme>Ismertető</doclink>\n* <doclink href=ReleaseNotes>Kiadási megjegyzések</doclink>\n* <doclink href=Copying>Másolás</doclink>\n* <doclink href=UpgradeDoc>Frissítés</doclink>",
+ "config-env-good": "A környezet ellenőrzése befejeződött.\nA MediaWiki telepíthető.",
+ "config-env-bad": "A környezet ellenőrzése befejeződött.\nA MediaWiki nem telepíthető.",
+ "config-env-php": "A PHP verziója: $1",
+ "config-env-hhvm": "HHVM verziója: $1",
+ "config-unicode-using-intl": "A rendszer Unicode normalizálására az [http://pecl.php.net/intl intl PECL kiterjesztést] használja.",
+ "config-unicode-pure-php-warning": "<strong>Figyelmeztetés:</strong> A Unicode-normalizáláshoz szükséges [http://pecl.php.net/intl intl PECL kiterjesztés] nem érhető el, helyette a lassú, PHP-alapú implementáció lesz használatban.\nHa nagy látogatottságú oldalt üzemeltetsz, [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations itt] találhatsz további információkat a témáról.",
+ "config-unicode-update-warning": "<strong>Figyelmeztetés:</strong> A Unicode normalizáláshoz szükséges burkolókönyvtár [http://site.icu-project.org/ az ICU projekt] függvénykönyvtárának régebbi változatát használja.\nHa ügyelni kívánsz a Unicode használatára, fontold meg a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations frissítését].",
+ "config-no-db": "Nem sikerült egyetlen használható adatbázis-illesztőprogramot sem találni. Telepítened kell egyet a PHP-hez.\nA következő {{PLURAL:$2|adatbázistípus támogatott|adatbázistípusok támogatottak}}: $1.\n\nHa a PHP-t magad fordítottad, konfiguráld újra úgy, hogy engedélyezve legyen egy adatbáziskliens, pl. a <code>./configure --with-mysqli</code> parancs használatával.\nHa a PHP-t Debian vagy Ubuntu csomaggal telepítetted, akkor szükséged lesz például a <code>php5-mysql</code> csomagra is.",
+ "config-outdated-sqlite": "<strong>Figyelmeztetés:</strong> SQLite $1 verziód van, ami alacsonyabb a legalább szükséges $2 verziónál. Az SQLite nem lesz elérhető.",
+ "config-no-fts3": "<strong>Figyelmeztetés:</strong> Az SQLite [//sqlite.org/fts3.html FTS3 modul] nélkül lett fordítva, a keresési funkciók nem fognak működni ezen a rendszeren.",
+ "config-pcre-old": "<strong>Kritikus hiba:</strong> PCRE $1 vagy későbbi szükséges.\nA Te PHP binárisod PCRE $2-vel lett linkelve.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE További információ].",
+ "config-pcre-no-utf8": "<strong>Kritikus hiba:</strong> Úgy tűnik, hogy a PHP PRCE modulja PRCE_UTF8 támogatás nélkül lett fordítva.\nA MediaWikinek UTF-8-támogatásra van szüksége a helyes működéshez.",
+ "config-memory-raised": "A PHP <code>memory_limit</code> beállításának értéke: $1. Meg lett növelve a következő értékre: $2.",
+ "config-memory-bad": "<strong>Figyelmeztetés:</strong> A PHP <code>memory_limit</code> beállításának értéke $1.\nEz az érték valószínűleg túl kevés, a telepítés sikertelen lehet.",
+ "config-xcache": "Az [http://xcache.lighttpd.net/ XCache] telepítve van",
+ "config-apc": "Az [http://www.php.net/apc APC] telepítve van",
+ "config-apcu": "Az [http://www.php.net/apcu APCu] telepítve van",
+ "config-wincache": "A [http://www.iis.net/download/WinCacheForPhp WinCache] telepítve van",
+ "config-no-cache-apcu": "<strong>Figyelmeztetés:</strong> nem találhatók a következők: [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] vagy [http://www.iis.net/download/WinCacheForPhp WinCache].\nAz objektum gyorsítótárazása nincs engedélyezve.",
+ "config-diff3-bad": "GNU diff3 nem található.",
+ "config-git": "Megtaláltam a Git verziókezelő szoftvert: <code>$1</code>.",
+ "config-git-bad": "A Git verziókezelő rendszer nem található.",
+ "config-imagemagick": "Az ImageMagick megtalálható a rendszeren: <code>$1</code>.\nA bélyegképek készítése engedélyezve lesz a feltöltések engedélyezése esetén.",
+ "config-gd": "A GD grafikai könyvtár elérhető.\nBélyegképek készítése működni fog, miután engedélyezted a fájlfeltöltést.",
+ "config-no-scaling": "Nem található a GD könyvtár és az ImageMagick.\nA bélyegképek készítése le lesz tiltva.",
+ "config-no-uri": "<strong>Hiba:</strong> Nem sikerült megállapítani a jelenlegi URI-t.\nA telepítés megszakítva.",
+ "config-no-cli-uri": "<strong>Figyelmeztetés:</strong> Nincs <code>--scriptpath</code> megadva, használom az alapértelmezettet: <code>$1</code>.",
+ "config-using-server": "A következő szervernév használata: „<nowiki>$1</nowiki>”.",
+ "config-using-uri": "A következő szerver-URL használata: „<nowiki>$1$2</nowiki>”.",
+ "config-uploads-not-safe": "<strong>Figyelmeztetés:</strong> a feltöltésekhez használt alapértelmezett könyvtárban (<code>$1</code>) tetszőleges külső szkript futtatható.\nHabár a MediaWiki ellenőrzi a feltöltött fájlokat az efféle biztonsági veszélyek megtalálása érdekében, a feltöltés engedélyezése előtt erősen ajánlott [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security a sérülékenység megszüntetése].",
+ "config-brokenlibxml": "A rendszereden a PHP és libxml2 verziók olyan kombinációja található meg, ami hibásan működik, és észrevehetetlen adatkárosodást okoz a MediaWikiben és más webalkalmazásokban.\nFrissíts a libxml2 2.7.3 vgy újabb verziójára ([https://bugs.php.net/bug.php?id=45996 A hiba bejelentése a PHP-nél]).\nTelepítés megszakítva.",
+ "config-suhosin-max-value-length": "A Suhosin telepítve van, és a GET <code>length</code> paraméterét $1 bájtra korlátozza.\nA MediaWiki ResourceLoader (erőforrásbetöltő) összetevője megkerüli a problémát, de így csökkenni fog a teljesítmény.\nHa lehetséges, állítsd be a <code>suhosin.get.max_value_length</code> értékét legalább 1024-re a <code>php.ini</code>-ben, és állítsd be a <code>$wgResourceLoaderMaxQueryLength</code> változót ugyanerre az értékre a <code>LocalSettings.php</code>-ban.",
+ "config-db-type": "Adatbázis típusa:",
+ "config-db-host": "Adatbázis hosztneve:",
+ "config-db-host-help": "Ha az adatbázisszerver másik szerveren található, add meg a hosztnevét vagy az IP-címét.\n\nHa megosztott webtárhelyet használsz, a szolgáltató dokumentációjában megtalálható a helyes hosztnév.\n\nHa Windows-alapú szerverre telepítesz, és MySQL-t használsz, a „localhost” nem biztos, hogy működni fog. Ha így van, próbáld meg a „127.0.0.1” helyi IP-cím használatát.\n\nHa PostgreSQL-t használsz, hagyd ezt a mezőt üresen a Unix-socketon keresztül történő csatlakozáshoz.",
+ "config-db-host-oracle": "Adatbázis TNS:",
+ "config-db-wiki-settings": "A wiki azonosítása",
+ "config-db-name": "Adatbázisnév:",
+ "config-db-name-help": "Válassz egy nevet a wiki azonosítására.\nNe tartalmazzon szóközt.\n\nHa megosztott webtárhelyet használsz, a szolgáltatód vagy egy konkrét adatbázisnevet ad neked használatra, vagy te magad hozhatsz létre adatbázisokat a vezérlőpulton keresztül.",
+ "config-db-name-oracle": "Adatbázisséma:",
+ "config-db-account-oracle-warn": "Oracle adatbázisba való telepítésnek három támogatott módja van:\n\nHa a telepítési folyamat során adatbázisfiókot szeretnél létrehozni, akkor egy olyan fiókot kell használnod, mely rendelkezik SYSDBA jogosultsággal, majd meg kell adnod a létrehozandó, webes hozzáféréshez használt fiók adatait. Emellett a fiók kézzel is létrehozható, ekkor ennek az adatait kell megadni (a fióknak rendelkeznie kell megfelelő jogosul adatbázis-objektumok létrehozásához), vagy megadhatsz két fiókot: egyet a létrehozáshoz szükséges jogosultságokkal, és egy korlátozottat a webes hozzáféréshez.\n\nA megfelelő jogosultságokkal rendelkező fiók létrehozásához használható szkript a szoftver „maintenance/oracle/” könyvtárában található. Ne feledd, hogy korlátozott fiók használatakor az alapértelmezett fiókkal nem végezhetőek el a karbantartási műveletek.",
+ "config-db-install-account": "A telepítéshez használt felhasználói fiók adatai",
+ "config-db-username": "Felhasználónév:",
+ "config-db-password": "Jelszó:",
+ "config-db-install-username": "Írd be az adatbázisrendszerhez való csatlakozáshoz használt felhasználónevet.\nEz nem a MediaWiki fiók felhasználóneve; ez az adatbázisrendszeren használt felhasználóneved.",
+ "config-db-install-password": "Írd be az adatbázisrendszerhez való csatlakozáshoz használt jelszót.\nEz nem a MediaWiki-fiók jelszava; ez az adatbázisrendszeren használt jelszavad.",
+ "config-db-install-help": "Add meg a felhasználónevet és jelszót, amivel a telepítő csatlakozhat az adatbázishoz.",
+ "config-db-account-lock": "Általános működés során is ezen információk használata",
+ "config-db-wiki-account": "Általános működéshez használt felhasználói adatok",
+ "config-db-wiki-help": "Add meg azt a felhasználónevet és jelszót, amivel a wiki fog csatlakozni az adatbázishoz működés közben.\nHa a fiók nem létezik és a telepítést végző fiók rendelkezik megfelelő jogosultsággal, egy új fiók készül a megadott a névvel, azon minimális jogosultságkörrel, ami a wiki működéséhez szükséges.",
+ "config-db-prefix": "Adatbázistáblák nevének előtagja:",
+ "config-db-prefix-help": "Ha egyetlen adatbázison osztozik több wiki, vagy a MediaWiki és más webalkalmazás, választhatsz egy előtagot a táblaneveknek, hogy megelőzd a konfliktusokat.\nNe használj szóközöket.\n\nA mezőt általában üresen kell hagyni.",
+ "config-mysql-old": "A MySQL $1 vagy újabb verziója szükséges, a rendszeren $2 van.",
+ "config-db-port": "Adatbázisport:",
+ "config-db-schema": "MediaWiki-séma",
+ "config-db-schema-help": "A fenti sémák általában megfelelőek.\nCsak akkor módosíts rajtuk, ha tudod, hogy szükséges.",
+ "config-pg-test-error": "Nem sikerült csatlakozni a(z) '''$1''' adatbázishoz: $2",
+ "config-sqlite-dir": "SQLite-adatkönyvtár:",
+ "config-sqlite-dir-help": "Az SQLite minden adatot egyetlen fájlban tárol.\n\nA megadott könyvtárban írási jogosultsággal kell rendelkeznie a webszervernek.\n\n'''Nem''' szabad elérhetőnek lennie weben keresztül, ezért nem rakjuk oda, ahol a PHP-fájljaid vannak.\n\nA telepítő készít egy <code>.htaccess</code> fájlt az adatbázis mellé, azonban ha valamilyen okból nem sikerül, akkor akárki hozzáférhet a teljes adatbázisodhoz. Ez a felhasználók adatai (e-mail címek, jelszók hashei) mellett a törölt változatokat és más, korlátozott hozzáférésű információkat is tartalmaz.\n\nFontold meg az adatbázis más helyre történő elhelyezését, például a <code>/var/lib/mediawiki/tewikid</code> könyvtárba.",
+ "config-oracle-def-ts": "Alapértelmezett táblatér:",
+ "config-oracle-temp-ts": "Ideiglenes táblatér:",
+ "config-type-mysql": "MySQL (vagy kompatibilis)",
+ "config-type-mssql": "Microsoft SQL Szerver",
+ "config-support-info": "A MediaWiki a következő adatbázisrendszereket támogatja:\n\n$1\n\nHa az alábbi listán nem találod azt a rendszert, melyet használni szeretnél, a fenti linken található instrukciókat követve engedélyezheted a támogatását.",
+ "config-dbsupport-mysql": "* A [{{int:version-db-mysql-url}} MySQL] a MediaWiki elsődleges célpontja, így a legjobban támogatott. A MediaWiki elfut [{{int:version-db-mariadb-url}} MariaDB-n] és [{{int:version-db-percona-url}} Percona Serveren] is, mivel ezek MySQL-kompatibilisek. ([http://www.php.net/manual/en/mysql.installation.php Hogyan fordítható a PHP MySQL-támogatással])",
+ "config-dbsupport-postgres": "* A [{{int:version-db-postgres-url}} PostgreSQL] népszerű, nyílt forráskódú adatbázisrendszer, a MySQL alternatívája. ([http://www.php.net/manual/en/pgsql.installation.php Hogyan fordítható a PHP PostgreSQL-támogatással])",
+ "config-dbsupport-sqlite": "* Az [{{int:version-db-sqlite-url}} SQLite] egy könnyű, nagyon jól támogatott adatbázisrendszer. ([http://www.php.net/manual/en/pdo.installation.php Hogyan fordítható a PHP SQLite-támogatással], PDO-t használ)",
+ "config-dbsupport-oracle": "* Az [{{int:version-db-oracle-url}} Oracle] kereskedelmi, vállalati adatbázisrendszer. ([http://www.php.net/manual/en/oci8.installation.php Hogyan fordítható a PHP OCI8-támogatással])",
+ "config-dbsupport-mssql": "* A [{{int:version-db-mssql-url}} Microsoft SQL Server] kereskedelmi, vállalati adatbázisrendszer. ([http://www.php.net/manual/en/sqlsrv.installation.php Hogyan fordítható a PHP SQLSRV-támogatással])",
+ "config-header-mysql": "MySQL-beállítások",
+ "config-header-postgres": "PostgreSQL-beállítások",
+ "config-header-sqlite": "SQLite-beállítások",
+ "config-header-oracle": "Oracle-beállítások",
+ "config-header-mssql": "Microsoft SQL Server beállítások",
+ "config-invalid-db-type": "Érvénytelen adatbázistípus",
+ "config-missing-db-name": "Meg kell adnod a(z) „{{int:config-db-name}}” értékét.",
+ "config-missing-db-host": "Meg kell adnod az „{{int:config-db-host}}” értékét.",
+ "config-missing-db-server-oracle": "Meg kell adnod az „{{int:config-db-host-oracle}}” értékét.",
+ "config-invalid-db-server-oracle": "Érvénytelen adatbázis TNS: „$1”\nHasználd a „TNS Name” vagy az Easy Connect” sztringet!\n([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).",
+ "config-invalid-db-name": "Érvénytelen adatbázisnév: „$1”.\nCsak ASCII-karakterek (a-z, A-Z), számok (0-9), alulvonás (_) és kötőjel (-) használható.",
+ "config-invalid-db-prefix": "Érvénytelen adatbázisnév-előtag: „$1”.\nCsak ASCII-karakterek (a-z, A-Z), számok (0-9), alulvonás (_) és kötőjel (-) használható.",
+ "config-connection-error": "$1.\n\nEllenőrizd a hosztot, felhasználónevet és jelszót, majd próbáld újra.",
+ "config-invalid-schema": "Érvénytelen MediaWiki-séma: „$1”.\nCsak ASCII-karakterek (a-z, A-Z), számok (0-9) és alulvonás (_) használható.",
+ "config-db-sys-create-oracle": "A telepítő csak a SYSDBA fiókkal tud új felhasználói fiókot létrehozni.",
+ "config-db-sys-user-exists-oracle": "Már létezik „$1” nevű felhasználói fiók. A SYSDBA csak új fiók létrehozására használható!",
+ "config-postgres-old": "A PostgreSQL $1 vagy újabb verziója szükséges, a rendszeren $2 van.",
+ "config-mssql-old": "Microsoft SQL Server $1 vagy későbbi szükséges. Te verziód: $2.",
+ "config-sqlite-name-help": "Válassz egy nevet a wiki azonosítására.\nNe tartalmazzon szóközt vagy kötőjelet.\nEz lesz az SQLite-adatfájl neve.",
+ "config-sqlite-parent-unwritable-group": "Nem hozható létre a(z) <code><nowiki>$1</nowiki></code> adatkönyvtár, mert a szülőkönyvtárba (<code><nowiki>$2</nowiki></code>) nem írhat a webszerver.\n\nA telepítő megállapította, hogy mely felhasználó futtatja a webszervert.\nA folytatáshoz tedd írhatóvá a(z) <code><nowiki>$3</nowiki></code> könyvtárat.\nUnix/Linux rendszeren tedd a következőt:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Nem lehet létrehozni az adatok tárolásához szükséges <code><nowiki>$1</nowiki></code> könyvtárat, mert a webszerver nem írhat a szülőkönyvtárba (<code><nowiki>$2</nowiki></code>).\n\nA telepítő nem tudta megállapíteni, hogy melyik felhasználói fiókon fut a webszerver.\nA folytatáshoz tedd írhatóvá ezen fiók (és más fiókok!) számára a következő könyvtárat: <code><nowiki>$3</nowiki></code>.\nUnix/Linux rendszereken tedd a következőt:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Nem sikerült létrehozni a következő adatkönyvtárat: „$1”.\nEllenőrizd a helyet, majd próbáld újra.",
+ "config-sqlite-dir-unwritable": "Nem sikerült írni a következő könyvtárba: „$1”.\nMódosítsd a jogosultságokat úgy, hogy a webszerver tudjon oda írni, majd próbáld újra.",
+ "config-sqlite-connection-error": "$1.\n\nEllenőrizd az adatkönyvtárat és az adatbázisnevet, majd próbáld újra.",
+ "config-sqlite-readonly": "A következő fájl nem írható: <code>$1</code>.",
+ "config-sqlite-cant-create-db": "Nem sikerült létrehozni a következő adatbázisfájlt: <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "A PHP nem rendelkezik FTS3-támogatással, táblák visszaminősítése",
+ "config-can-upgrade": "Ebben az adatábizban MediaWiki-táblák találhatóak.\nA MediaWiki $1 verzióra történő frissítéséhez kattints a '''Folytatás''' gombra.",
+ "config-upgrade-done": "A frissítés befejeződött.\n\nMost már '''[$1 beléphetsz a wikibe]'''.\n\nHa újra szeretnéd generálni a <code>LocalSettings.php</code> fájlt, kattints az alábbi gombra.\nEz '''nem ajánlott''', csak akkor, ha problémák vannak a wikivel.",
+ "config-upgrade-done-no-regenerate": "A frissítés befejeződött.\n\nMost már '''[$1 beléphetsz a wikibe]'''.",
+ "config-regenerate": "LocalSettings.php elkészítése újra →",
+ "config-show-table-status": "A <code>SHOW TABLE STATUS</code> lekérdezés nem sikerült!",
+ "config-unknown-collation": "'''Figyelmeztetés:''' az adatbázis ismeretlen egybevetést használ.",
+ "config-db-web-account": "A webes hozzáférésnél használt adatbázisfiók",
+ "config-db-web-help": "Add meg azt a felhasználónevet és jelszót, amit a webszerver a wiki általános működése során használ a csatlakozáshoz.",
+ "config-db-web-account-same": "A telepítéshez használt fiók használata",
+ "config-db-web-create": "Fiók létrehozása, ha még nem létezik.",
+ "config-db-web-no-create-privs": "A telepítéshez megadott fiók nem rendelkezik megfelelő jogosultságokkal új felhasználó létrehozásához.\nAz itt megadott fióknak léteznie kell.",
+ "config-mysql-engine": "Tárolómotor:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Figyelmeztetés''': A MyISAM tárolómotort választottad, ami nem ajánlott a MediaWiki használatánál, mert:\n* nagyon rosszul kezeli a párhuzamos lekéréseket a táblák zárolása miatt\n* sokkal nagyobb az esélye az adatkorrupció kialakulásának\n* a MediaWiki kódbázisa nem mindig úgy kezeli a MyISAM-ot, ahogyan kellene\n\nHa a feltelepített MySQL támogatja az InnoDB-t, erősen ajánlott, hogy inkább azt válaszd.\nHa nem, akkor lehet, hogy itt az ideje a frissítésnek.",
+ "config-mysql-engine-help": "A legtöbb esetben az '''InnoDB''' a legjobb választás, mivel megfelelően támogatja a párhuzamosságot.\n\nA '''MyISAM''' gyorsabb megoldás lehet egyfelhasználós vagy csak olvasható környezetekben, azonban a MyISAM-adatbázisok sokkal gyakrabban sérülnek meg, mint az InnoDB-adatbázisok.",
+ "config-mysql-charset": "Adatbázis karakterkészlete:",
+ "config-mysql-binary": "Bináris",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "'''Bináris módban''' a MediaWiki az UTF-8-as szövegeket bináris mezőkben tárolja az adatbázisban.\nEz sokkal hatékonyabb a MySQL UTF-8-as módjánál, és lehetővé teszi a teljes Unicode-karakterkészlet használatát.\n\n'''UTF-8-as módban''' a MySQL tudni fogja,hogy az adatok milyen karakterkészlettel rendelkeznek, és megfelelően átalakítja őket, azonban nem tárolhatóak olyan karakterek, melyek a [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane] felett vannak.",
+ "config-mssql-auth": "Hitelesítés típusa:",
+ "config-mssql-sqlauth": "SQL Server hitelesítés",
+ "config-mssql-windowsauth": "Windows hitelesítés",
+ "config-site-name": "A wiki neve:",
+ "config-site-name-help": "A böngésző címsorában és még számos más helyen jelenik meg.",
+ "config-site-name-blank": "Add meg az oldal nevét.",
+ "config-project-namespace": "Projektnévtér:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Ugyanaz, mint a wiki neve: $1",
+ "config-ns-other": "Más (meg kell adni)",
+ "config-ns-other-default": "SajátWiki",
+ "config-project-namespace-help": "A Wikipédia példáját követve számos wiki elkülöníti az irányelveit a tartalmi lapoktól egy '''projektnévtérbe'''.\nAz ebben a névtérben található lapok nevei egy előtaggal kezdődnek, amit itt adhatsz meg.\nÁltalában az előtag a wiki nevéből származik, de nem tartalmazhat írásjeleket, például „#”-t vagy „:”-t.",
+ "config-ns-invalid": "A megadott névtér („<nowiki>$1</nowiki>”) érvénytelen.\nVálassz másik projektnévteret!",
+ "config-ns-conflict": "A megadott névtér („<nowiki>$1</nowiki>”) ütközik az egyik alapértelmezett MediaWiki-névtérrel.\nVálassz másik projektnévteret!",
+ "config-admin-box": "Adminisztrátori fiók",
+ "config-admin-name": "Név:",
+ "config-admin-password": "Jelszó:",
+ "config-admin-password-confirm": "Jelszó újra:",
+ "config-admin-help": "Írd be a kívánt felhasználónevet, például „Kovács János”.\nEzzel a névvel fogsz majd bejelentkezni a wikibe.",
+ "config-admin-name-blank": "Add meg az adminisztrátor felhasználónevét!",
+ "config-admin-name-invalid": "A megadott felhasználónév (<nowiki>$1</nowiki>) érvénytelen.\nAdj meg egy másik felhasználónevet.",
+ "config-admin-password-blank": "Add meg az adminisztrátori fiók jelszavát!",
+ "config-admin-password-mismatch": "A megadott jelszavak nem egyeznek.",
+ "config-admin-email": "E-mail cím:",
+ "config-admin-email-help": "Add meg az e-mail címedet, hogy más felhasználók küldhessenek e-maileket a wikin keresztül, új jelszót tudj kérni, és értesülhess a figyelőlistádon lévő lapokon történt változásokról. Üresen is hagyhatod ezt a mezőt.",
+ "config-admin-error-user": "Belső hiba történt a(z) „<nowiki>$1</nowiki>” nevű adminisztrátor létrehozásakor.",
+ "config-admin-error-password": "Belső hiba történt a(z) „<nowiki>$1</nowiki>” nevű adminisztrátor jelszavának beállításakor: <pre>$2</pre>",
+ "config-admin-error-bademail": "Érvénytelen e-mail címet adtál meg.",
+ "config-subscribe": "Feliratkozás a [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce kiadási bejelentések levelezőlistájára].",
+ "config-subscribe-help": "Ez egy alacsony forgalmú levelezőlista, ahol a kiadásokkal kapcsolatos bejelentések jelennek meg, a fontos biztonsági javításokkal együtt.\nAjánlott feliratkozni rá, és frissíteni a MediaWikit, ha új verzió jön ki.",
+ "config-subscribe-noemail": "Anélkül próbáltál feliratkozni a kiadási bejelentések levelezőlistájára, hogy megadtál volna egy e-mail címet.\nAdj meg egyet, ha fel szeretnél iratkozni a levelezőlistára.",
+ "config-almost-done": "Már majdnem kész!\nA további konfigurációt kihagyhatod, és most azonnal elindíthatod a wiki telepítését.",
+ "config-optional-continue": "További információk megadása.",
+ "config-optional-skip": "Épp elég volt, települjön a wiki!",
+ "config-profile": "Felhasználói jogosultságok profilja:",
+ "config-profile-wiki": "Nyílt wiki",
+ "config-profile-no-anon": "Felhasználói fiók létrehozása szükséges",
+ "config-profile-fishbowl": "Csak engedélyezett szerkesztők",
+ "config-profile-private": "Privát wiki",
+ "config-profile-help": "A wikik akkor működnek a legjobban, ha minél több felhasználó számára engedélyezett a szerkesztés.\nA MediaWikiben könnyű ellenőrizni a legutóbbi változtatásokat,és visszaállítani a naiv vagy káros felhasználók által okozott károkat.\n\nA MediaWiki azonban számos helyzetben hasznos lehet, és néha nem könnyű mindenkit meggyőzni a wiki előnyeiről.\nVálaszthatsz!\n\n<strong>{{int:config-profile-wiki}}kben</strong> bárki szerkeszthet, akár bejelentkezés nélkül is. A <strong>{{int:config-profile-no-anon}}</strong> beállítás további biztonságot nyújt, azonban elijesztheti az alkalmi szerkesztőket.\n\nLehetőség van arra is, hogy <strong>{{lc:{{int:config-profile-fishbowl}}}}</strong> módosíthassák a lapokat, de a nyilvánosság ekkor megtekintheti a lapokat és azok laptörténetét is. <strong>{{int:config-profile-private}}</strong> esetén csak az engedélyezett szerkesztők tekinthetik meg a lapokat, és ugyanez a csoport szerkeszthet.\n\nTelepítés után jóval összetettebb jogosultságrendszer állítható össze, további információ a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights kézikönyv kapcsolódó bejegyzésében].",
+ "config-license": "Szerzői jog és licenc:",
+ "config-license-none": "Nincs licencjelzés",
+ "config-license-cc-by-sa": "Creative Commons Nevezd meg! - Így add tovább!",
+ "config-license-cc-by": "Creative Commons Nevezd meg!",
+ "config-license-cc-by-nc-sa": "Creative Commons Nevezd meg! - Ne add el! - Így add tovább!",
+ "config-license-cc-0": "Creative Commons Zero (közkincs)",
+ "config-license-gfdl": "GNU Szabad Dokumentációs Licenc 1.3 vagy újabb",
+ "config-license-pd": "Közkincs",
+ "config-license-cc-choose": "Creative Commons-licenc választása",
+ "config-license-help": "A legtöbb wiki valamilyen [http://freedomdefined.org/Definition szabad licenc] alatt teszi közzé a szerkesztéseit.\nEz erősíti a közösségi tulajdon érzését, és elősegíti a hosszú távú közreműködők megjelenését.\nÁltalában nem szükséges magán- vagy vállalati wiki esetén.\n\nHa a Wikipédiáról szeretnél szövegeket másolni, és azt szeretnéd, hogy a Wikipédián felhasználhassák a wikidben található szöveget, akkor a <strong>{{int:config-license-cc-by-sa}}</strong> lehetőséget válaszd.\n\nA Wikipédia korábban a GNU Szabad Dokumentációs Licencet használta.\nEz a licenc még ma is használható, azonban nem könnyű megérteni,\ntovábbá a GFDL alatt közzétett tartalom újrafelhasználása nehézkes.",
+ "config-email-settings": "E-mail beállítások",
+ "config-enable-email": "Kimenő e-mailek engedélyezése",
+ "config-enable-email-help": "E-mailek küldéséhez [http://www.php.net/manual/en/mail.configuration.php a PHP mail beállításait] megfelelően meg kell adni.\nHa nem akarsz semmilyen e-mailes funkciót használni, itt tilthatod le őket.",
+ "config-email-user": "A felhasználók küldhetnek egymásnak e-maileket",
+ "config-email-user-help": "Bármelyik felhasználó küldhet másiknak e-mail üzenetet, amennyiben engedélyezték a lehetőséget a beállításaiknál.",
+ "config-email-usertalk": "Vitalapi értesítések engedélyezése",
+ "config-email-usertalk-help": "A felhasználók értesítéseket kapnak a vitalapjuk változásairól, amennyiben engedélyezték ezt a lehetőséget a beállításaiknál.",
+ "config-email-watchlist": "Figyelőlistai értesítések engedélyezése",
+ "config-email-watchlist-help": "A felhasználók értesítéseket kapnak a figyelt lapjaik változásairól, amennyiben engedélyezték ezt a lehetőséget a beállításaiknál.",
+ "config-email-auth": "E-mailes hitelesítés engedélyezése",
+ "config-email-auth-help": "Ha a beállítás engedélyezve van, a felhasználóknak meg kell erősíteniük az e-mail címüket egy kiküldött link segítségével, amikor megadják vagy módosítják azt.\nCsak a megerősített e-mail címmel rendelkezők kaphatnak e-maileket más felhasználóktól vagy értesítéseket.\nA beállítás engedélyezése '''ajánlott''' publikus wikiknél, mivel így megakadályozható az e-mailes funkciókkal való visszaélés.",
+ "config-email-sender": "Válaszcím:",
+ "config-email-sender-help": "Add meg a kimenő e-mail-üzenetek válaszcímét.\nIde lesznek küldve a visszapattant üzenetek is.\nSzámos levelezőszerver számára a cím domainrészének érvényesnek kell lennie.",
+ "config-upload-settings": "Képek és fájlok feltöltése",
+ "config-upload-enable": "Fájlfeltöltés engedélyezése",
+ "config-upload-help": "A fájlfeltöltés lehetséges biztonsági kockázatoknak teszi ki a szerveredet.\nTovábbi információért olvasd el a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security biztonságról szóló szakaszt] a kézikönyvben.\n\nA fájlfeltöltés engedélyezéséhez változtasd meg a MediaWiki gyökérkönyvtárában található <code>images</code> alkönyvtár jogosultságát úgy, hogy a szerver írhasson oda, majd engedélyezd itt a beállítást.",
+ "config-upload-deleted": "Törölt fájlok könyvtára:",
+ "config-upload-deleted-help": "Válaszd ki azt a könyvtárat, ahol a törölt fájlok lesznek archiválva.\nNormális esetben ennek nem szabad elérhetőnek lennie az internetről.",
+ "config-logo": "A logó URL-címe:",
+ "config-logo-help": "A MediaWiki alapértelmezett felülete helyet ad egy 135×160 pixeles logónak a bal felső sarokban.\nTölts fel egy megfelelő méretű képet, majd írd be ide az URL-címét!\n\nHasználhatsz <code>$wgStylePath</code>-t vagy <code>$wgScriptPath</code>-t, ha más méretű a logód.\n\nHa nem szeretnél logót használni, egyszerűen hagyd üresen a mezőt.",
+ "config-instantcommons": "Instant Commons engedélyezése",
+ "config-instantcommons-help": "Az [https://www.mediawiki.org/wiki/InstantCommons Instant Commons] lehetővé teszi, hogy a wikin használhassák a [https://commons.wikimedia.org/ Wikimédia Commons] oldalon található képeket, hangokat és más médiafájlokat.\nA használatához a MediaWikinek internet-hozzáférésre van szüksége.\n\nA funkcióról és hogy hogyan állítható be más wikik esetén [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos a kézikönyvben] találhatsz további információkat.",
+ "config-cc-error": "A Creative Commons-licencválasztó nem tért vissza eredménnyel.\nAdd meg kézzel a licencet.",
+ "config-cc-again": "Válassz újra…",
+ "config-cc-not-chosen": "Válaszd ki a kívánt Creative Commons licencet, majd kattints a „proceed”!",
+ "config-advanced-settings": "Haladó beállítások",
+ "config-cache-options": "Objektum-gyorsítótárazás beállításai:",
+ "config-cache-help": "Az objektumgyorsítótárazás célja, hogy felgyorsítsa a MediaWiki működését a gyakran használt adatok gyorsítótárazásával.\nKözepes vagy nagyobb oldalak esetén erősen ajánlott a használata, de kisebb oldalak esetén is hasznos lehet.",
+ "config-cache-none": "Nincs gyorsítótárazás (minden funkció működik, de nagyobb wiki esetében lassabb működést eredményezhet)",
+ "config-cache-accel": "PHP-objektumok gyorsítótárazása (APC, APCu, XCache vagy WinCache)",
+ "config-cache-memcached": "Memcached használata (további telepítés és konfigurálás szükséges)",
+ "config-memcached-servers": "Memcached-szerverek:",
+ "config-memcached-help": "Azon IP-címek listája, melyeket a Memcached használhat.\nVesszővel kell elválasztani őket, és meg kell adni a portot is. Például:\n 127.0.0.1:11211\n 192.168.1.25:11211",
+ "config-memcache-needservers": "Memcachedet választottad gyorsítótárnak, de nem adtál meg egyetlen szervert sem.",
+ "config-memcache-badip": "Érvénytelen IP-címet adtál meg a Memcachednek: $1.",
+ "config-memcache-noport": "Nem adtál meg portot a Memcached-szervernek: $1.\nHa nem ismered a portszámot, használd az alapértelmezettet: 11211.",
+ "config-memcache-badport": "A Memcached a(z) $1 és $2 közötti portokat szokta használni.",
+ "config-extensions": "Kiterjesztések",
+ "config-extensions-help": "A fent felsorolt kiterjesztések találhatóak meg az <code>./extensions</code> könyvtárban.\n\nLehetséges, hogy további beállításra lesz szükség hozzájuk, de már most engedélyezheted őket.",
+ "config-skins": "Felületek",
+ "config-skins-help": "A fent felsorolt felületek a <code>./skins</code> könyvtáradban találhatóak. Legalább egyet engedélyezned kell, és ki kell választanod az alapértelmezettet.",
+ "config-skins-use-as-default": "Felület használata alapértelmezettként",
+ "config-skins-must-enable-some": "Legalább egy felületet engedélyezned kell.",
+ "config-skins-must-enable-default": "Az alapértelmezett felületnek engedélyezettnek kell lennie.",
+ "config-install-alreadydone": "'''Figyelmeztetés:''' Úgy tűnik, hogy a MediaWiki telepítve van, és te ismét megpróbálod telepíteni.\nFolytasd a következő oldalon.",
+ "config-install-begin": "A „{{int:config-continue}}” gomb megnyomása elindítja a MediaWiki telepítését.\nHa szeretnél módosítani a beállításokon, kattints a \"{{int:config-back}}\" gombra.",
+ "config-install-step-done": "kész",
+ "config-install-step-failed": "sikertelen",
+ "config-install-extensions": "Kiterjesztések beillesztése",
+ "config-install-database": "Adatbázis felállítása",
+ "config-install-schema": "Adatbázis-szerkezet létrehozása",
+ "config-install-pg-schema-not-exist": "A PostgreSQL-adatbázis nem létezik.",
+ "config-install-pg-schema-failed": "A táblák létrehozása nem sikerült.\nEllenőrizd, hogy „$1” felhasználó írhat-e a következő adatbázisba: „$2”.",
+ "config-install-pg-commit": "Változtatások közzététele",
+ "config-install-pg-plpgsql": "PL/pgSQL nyelv meglétének ellenőrzése",
+ "config-pg-no-plpgsql": "Telepítened kell a PL/pgSQL nyelvet a következő adatbázishoz: $1",
+ "config-pg-no-create-privs": "A telepítéshez megadott felhasználói fiók nem rendelkezik új fiók létrehozásához szükséges jogosultságokkal.",
+ "config-install-user": "Adatbázis-felhasználó létrehozása",
+ "config-install-user-alreadyexists": "Már létezik „$1” nevű felhasználó",
+ "config-install-user-create-failed": "Nem sikerült a(z) „$1” nevű felhasználó létrehozása: $2",
+ "config-install-user-grant-failed": "Nem sikerült jogosultságokkal felruházni a(z) „$1” nevű felhasználót: $2",
+ "config-install-user-missing": "A megadott felhasználó („$1”) nem létezik.",
+ "config-install-user-missing-create": "A megadott felhasználó („$1”) nem létezik.\nPipáld ki a „Fiók létrehozása” dobozt, ha létre szeretnéd hozni.",
+ "config-install-tables": "Táblák létrehozása",
+ "config-install-tables-exist": "'''Figyelmeztetés''': úgy tűnik, hogy a MediaWiki táblái már léteznek.\nLétrehozás kihagyása.",
+ "config-install-tables-failed": "'''Hiba''': a tábla létrehozása nem sikerült a következő miatt: $1",
+ "config-install-interwiki": "Alapértelmezett nyelvközihivatkozás-tábla feltöltése",
+ "config-install-interwiki-list": "Az <code>interwiki.list</code> fájl nem található.",
+ "config-install-interwiki-exists": "'''Figyelmeztetés''': Úgy tűnik, hogy az interwiki táblában már vannak bejegyzések.\nAlapértelmezett lista kihagyása.",
+ "config-install-stats": "Statisztika inicializálása",
+ "config-install-keys": "Titkos kulcsok generálása",
+ "config-insecure-keys": "'''Figyelmeztetés:''' A telepítés során generált $1 {{PLURAL:$2|biztonsági kulcs|biztonsági kulcsok}} nem teljesen $1 {{PLURAL:$2|biztonságos|biztonságosak}}. Érdemes {{PLURAL:$2||őket}} manuálisan megváltoztatni.",
+ "config-install-updates": "Nem szükséges frissítések futtatásának megakadályozása",
+ "config-install-sysop": "Az adminisztrátor felhasználói fiókjának létrehozása",
+ "config-install-subscribe-fail": "Nem sikerült feliratkozni a mediawiki-announce levelezőlistára: $1",
+ "config-install-subscribe-notpossible": "A cURL nincs telepítve és az <code>allow_url_fopen</code> nem érhető el.",
+ "config-install-mainpage": "Kezdőlap létrehozása az alapértelmezett tartalommal",
+ "config-install-extension-tables": "Táblák létrehozása az engedélyezett kiterjesztésekhez",
+ "config-install-mainpage-failed": "Nemsikerült létrehozni a kezdőlapot: $1",
+ "config-install-done": "<strong>Gratulálunk!</strong>\nA MediaWiki telepítése sikeresen befejeződött.\n\nA telepítő elkészítette a <code>LocalSettings.php</code> fájlt, amely tartalmazza az összes beállítást.\n\nEzt le kell tölteni, majd elhelyezni a wiki telepítési könyvtárába (az a könyvtár, ahol az index.php is található).\n\nA letöltés automatikusan elindul. Ha mégsem indulna el, vagy megszakítottad, az alábbi linkre kattintva újra letöltheted:\n\n$3\n\n<strong>Megjegyzés:</strong> Ha ezt most nem teszed meg, és kilépsz a telepítésből, az elkészített konfigurációs fájlt nem tudod elérni a későbbiekben.\n\nHa végeztél a fájl elhelyezésével, <strong>[$2 beléphetsz a wikibe]</strong>.",
+ "config-download-localsettings": "<code>LocalSettings.php</code> letöltése",
+ "config-help": "segítség",
+ "config-help-tooltip": "kattints a kibontáshoz",
+ "config-nofile": "\"$1\" fájl nem található. Törölve lett?",
+ "config-extension-link": "Tudtad, hogy a wikid támogat [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions kiterjesztéseket]?\n\nBöngészhetsz [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category kiterjesztéseket kategóriánként] vagy válogathatsz a [https://www.mediawiki.org/wiki/Extension_Matrix kiterjesztésmátrixból] az összes kiterjesztés áttekintéséhez.",
+ "mainpagetext": "<strong>A MediaWiki telepítése sikeresen befejeződött.</strong>",
+ "mainpagedocfooter": "Ha segítségre van szükséged a wikiszoftver használatához, akkor keresd fel a [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] oldalt.\n\n== Alapok (angol nyelven) ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Beállítások listája]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki GyIK]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki-kiadások levelezőlistája]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources A MediaWiki fordítása a saját nyelvedre]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Tudd meg többet, hogyan küzdhetsz a kéretlen levelek ellen a wikiden]"
+}
diff --git a/www/wiki/includes/installer/i18n/hy.json b/www/wiki/includes/installer/i18n/hy.json
new file mode 100644
index 00000000..0f5f6841
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/hy.json
@@ -0,0 +1,43 @@
+{
+ "@metadata": {
+ "authors": [
+ "Vahe Gharakhanyan",
+ "Kareyac"
+ ]
+ },
+ "config-title": "ՄեդիաՎիքի $1-ի տեղադրում",
+ "config-information": "Տեղեկատվություն",
+ "config-localsettings-key": "Թարմացման բանալի`",
+ "config-your-language": "Ձեր լեզուն`",
+ "config-wiki-language": "Վիքի լեզու`",
+ "config-back": "← Վերադառնալ",
+ "config-continue": "Շարունակել →",
+ "config-page-language": "Լեզու",
+ "config-page-welcome": "Բարի գալուստ ՄեդիաՎիքի:",
+ "config-page-dbconnect": "Միացում տվյալների բազային",
+ "config-page-name": "Անվանում",
+ "config-page-options": "Ընտրանքներ",
+ "config-page-install": "Տեղադրում",
+ "config-page-complete": "Պատրաստ է:",
+ "config-page-readme": "Կարդա ինձ",
+ "config-page-releasenotes": "Տեղեկություն տարբերակի մասին",
+ "config-page-existingwiki": "Գոյություն ունեցող վիքի",
+ "config-restart": "Այո, նորից սկսել",
+ "config-ns-generic": "Նախագիծ",
+ "config-admin-password": "Գաղտնաբառ՝",
+ "config-admin-password-confirm": "Գաղտնաբառը կրկին`",
+ "config-admin-email": "Էլ-փոստի հասցեն՝",
+ "config-profile-wiki": "Բաց վիքի",
+ "config-profile-private": "Մասնավոր վիքի",
+ "config-license": "Հեղինակային իրավունք և արտոնագիր`",
+ "config-license-cc-by-sa": "Creative Commons Attribution-ShareAlike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution-NonCommercial-ShareAlike",
+ "config-license-cc-0": "Creative Commons Zero (հանրային սեփականություն)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 կամ ավելի ուշ",
+ "config-license-pd": "Հանրային սեփականություն",
+ "config-cc-again": "Ընտրեեք նորից...",
+ "config-help": "օգնություն",
+ "mainpagetext": "'''«MediaWiki» ծրագիրը հաջողությամբ տեղադրվեց։'''",
+ "mainpagedocfooter": "Այցելեք [https://meta.wikimedia.org/wiki/Help:Contents User's Guide]՝ վիքի ծրագրային ապահովման օգտագործման մասին տեղեկությունների համար։\n\n== Որոշ օգտակար ռեսուրսներ ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/ia.json b/www/wiki/includes/installer/i18n/ia.json
new file mode 100644
index 00000000..13e7a6b6
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ia.json
@@ -0,0 +1,317 @@
+{
+ "@metadata": {
+ "authors": [
+ "McDutchie",
+ "아라",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Le installator de MediaWiki",
+ "config-title": "Installation de MediaWiki $1",
+ "config-information": "Information",
+ "config-localsettings-upgrade": "Un file <code>LocalSettings.php</code> ha essite detegite.\nPro actualisar iste installation, per favor entra le valor de <code>$wgUpgradeKey</code> in le quadro hic infra.\nIste se trova in <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Un file <code>LocalSettings.php</code> file ha essite detegite.\nPro actualisar iste installation, per favor executa <code>update.php</code>.",
+ "config-localsettings-key": "Clave de actualisation:",
+ "config-localsettings-badkey": "Le clave de actualisation fornite es incorrecte.",
+ "config-upgrade-key-missing": "Un installation existente de MediaWiki ha essite detegite.\nPro actualisar iste installation, es necessari adjunger le sequente linea al fin del file <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Le file <code>LocalSettings.php</code> existente pare esser incomplete.\nLe variabile $1 non es definite.\nPer favor cambia <code>LocalSettings.php</code> de sorta que iste variabile es definite, e clicca \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Un error ha essite incontrate durante le connexion al base de datos usante le configuration specificate in <code>LocalSettings.php</code>. Per favor corrige iste configuration e reproba.\n\n$1",
+ "config-session-error": "Error al comenciamento del session: $1",
+ "config-session-expired": "Le datos de tu session pare haber expirate.\nLe sessiones es configurate pro un duration de $1.\nTu pote augmentar isto per definir <code>session.gc_maxlifetime</code> in php.ini.\nReinitia le processo de installation.",
+ "config-no-session": "Le datos de tu session es perdite!\nVerifica tu php.ini e assecura te que un directorio appropriate es definite in <code>session.save_path</code>.",
+ "config-your-language": "Tu lingua:",
+ "config-your-language-help": "Selige un lingua a usar durante le processo de installation.",
+ "config-wiki-language": "Lingua del wiki:",
+ "config-wiki-language-help": "Selige le lingua in que le wiki essera predominantemente scribite.",
+ "config-back": "← Retro",
+ "config-continue": "Continuar →",
+ "config-page-language": "Lingua",
+ "config-page-welcome": "Benvenite a MediaWiki!",
+ "config-page-dbconnect": "Connecter al base de datos",
+ "config-page-upgrade": "Actualisar le installation existente",
+ "config-page-dbsettings": "Configuration del base de datos",
+ "config-page-name": "Nomine",
+ "config-page-options": "Optiones",
+ "config-page-install": "Installar",
+ "config-page-complete": "Complete!",
+ "config-page-restart": "Reinitiar installation",
+ "config-page-readme": "Lege me",
+ "config-page-releasenotes": "Notas del version",
+ "config-page-copying": "Copiar",
+ "config-page-upgradedoc": "Actualisar",
+ "config-page-existingwiki": "Wiki existente",
+ "config-help-restart": "Vole tu rader tote le datos salveguardate que tu ha entrate e reinitiar le processo de installation?",
+ "config-restart": "Si, reinitia lo",
+ "config-welcome": "=== Verificationes del ambiente ===\nVerificationes de base essera ora exequite pro determinar si iste ambiente es apte pro le installation de MediaWiki.\nNon oblida de includer iste information si tu cerca adjuta pro completar le installation.",
+ "config-copyright": "=== Copyright and Terms ===\n\n$1\n\nIste programma es software libere; vos pote redistribuer lo e/o modificar lo sub le conditiones del Licentia Public General de GNU publicate per le Free Software Foundation; version 2 del Licentia, o (a vostre option) qualcunque version posterior.\n\nIste programma es distribuite in le sperantia que illo sia utile, ma '''sin garantia''', sin mesmo le implicite garantia de '''commercialisation''' o '''aptitude pro un proposito particular'''.\nVide le Licentia Public General de GNU pro plus detalios.\n\nVos deberea haber recipite <doclink href=Copying>un exemplar del Licentia Public General de GNU</doclink> con iste programma; si non, scribe al Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [http://www.gnu.org/copyleft/gpl.html lege lo in linea].",
+ "config-sidebar": "* [https://www.mediawiki.org Pagina principal de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guida pro usatores]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guida pro administratores]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Lege me</doclink>\n* <doclink href=ReleaseNotes>Notas de iste version</doclink>\n* <doclink href=Copying>Conditiones de copia</doclink>\n* <doclink href=UpgradeDoc>Actualisation</doclink>",
+ "config-env-good": "Le ambiente ha essite verificate.\nTu pote installar MediaWiki.",
+ "config-env-bad": "Le ambiente ha essite verificate.\nTu non pote installar MediaWiki.",
+ "config-env-php": "PHP $1 es installate.",
+ "config-env-hhvm": "HHVM $1 es installate.",
+ "config-unicode-using-intl": "Le [http://pecl.php.net/intl extension PECL intl] es usate pro le normalisation Unicode.",
+ "config-unicode-pure-php-warning": "'''Aviso''': Le [http://pecl.php.net/intl extension PECL intl] non es disponibile pro exequer le normalisation Unicode; le systema recurre al implementation lente in PHP pur.\nSi tu sito ha un alte volumine de traffico, tu deberea informar te un poco super le [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalisation Unicode].",
+ "config-unicode-update-warning": "'''Aviso''': Le version installate del bibliotheca inveloppante pro normalisation Unicode usa un version ancian del bibliotheca del [http://site.icu-project.org/ projecto ICU].\nTu deberea [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations actualisar lo] si le uso de Unicode importa a te.",
+ "config-no-db": "Non poteva trovar un driver appropriate pro le base de datos! Es necessari installar un driver de base de datos pro PHP.\nLe sequente {{PLURAL:$2|typo|typos}} de base de datos es supportate: $1.\n\nSi tu compilava PHP tu mesme, reconfigura lo con un cliente de base de datos activate, per exemplo, usante <code>./configure --with-mysqli</code>.\nSi tu installava PHP ex un pacchetto Debian o Ubuntu, tu debe etiam installar, per exemplo, le modulo <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Attention''': tu ha SQLite $1, que es inferior al version minimal requirite, $2. SQLite essera indisponibile.",
+ "config-no-fts3": "'''Attention''': SQLite es compilate sin [//sqlite.org/fts3.html modulo FTS3]; functionalitate de recerca non essera disponibile in iste back-end.",
+ "config-pcre-old": "<strong>Fatal:</strong> PCRE $1 o plus tarde es necessari.\nTu binario de PHP binary es ligate con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Plus information].",
+ "config-pcre-no-utf8": "'''Fatal''': Le modulo PCRE de PHP pare haber essite compilate sin supporto de PCRE_UTF8.\nMediaWiki require supporto de UTF-8 pro functionar correctemente.",
+ "config-memory-raised": "Le <code>memory_limit</code> de PHP es $1, elevate a $2.",
+ "config-memory-bad": "'''Aviso:''' Le <code>memory_limit</code> de PHP es $1.\nIsto es probabilemente troppo basse.\nLe installation pote faller!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] es installate",
+ "config-apc": "[http://www.php.net/apc APC] es installate",
+ "config-apcu": "[http://www.php.net/apcu APCu] es installate",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] es installate",
+ "config-no-cache-apcu": "<strong>Attention:</strong> Impossibile trovar [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] o [http://www.iis.net/download/WinCacheForPhp WinCache].\nLe cache de objectos non es activate.",
+ "config-mod-security": "'''Attention''': [http://modsecurity.org/ mod_security] es active in tu servitor web. Si mal configurate, isto pote causar problemas pro MediaWiki o altere software que permitte al usatores de publicar contento arbitrari.\nConsulta le [http://modsecurity.org/documentation/ documentation de mod_security] o contacta le servicio de adjuta de tu host si tu incontra estranie errores.",
+ "config-diff3-bad": "GNU diff3 non trovate.",
+ "config-git": "Systema de controlo de version Git trovate: <code>$1</code>",
+ "config-git-bad": "Systema de controlo de version Git non trovate.",
+ "config-imagemagick": "ImageMagick trovate: <code>$1</code>.\nLe miniaturas de imagines essera activate si tu activa le incargamento de files.",
+ "config-gd": "Le bibliotheca graphic GD se trova integrate in le systema.\nLe miniaturas de imagines essera activate si tu activa le incargamento de files.",
+ "config-no-scaling": "Non poteva trovar le bibliotheca GD ni ImageMagick.\nLe miniaturas de imagines essera disactivate.",
+ "config-no-uri": "'''Error:''' Non poteva determinar le URI actual.\nInstallation abortate.",
+ "config-no-cli-uri": "'''Attention''': Cammino al script (<code>--scriptpath</code>) non specificate. Le predefinition es usate: <code>$1</code>.",
+ "config-using-server": "Es usate le nomine de servitor \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Le URL de servitor \"<nowiki>$1$2</nowiki>\" es usate.",
+ "config-uploads-not-safe": "'''Aviso:''' Le directorio predefinite pro files incargate <code>$1</code> es vulnerabile al execution arbitrari de scripts.\nBen que MediaWiki verifica tote le files incargate contra le menacias de securitate, il es altemente recommendate [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security remediar iste vulnerabilitate de securitate] ante de activar le incargamento de files.",
+ "config-no-cli-uploads-check": "'''Attention:''' Le directorio predefinite pro files incargate (<code>$1</code>) non es verificate contra le vulnerabilitate\nal execution arbitrari de scripts durante le installation de CLI.",
+ "config-brokenlibxml": "Vostre systema ha un combination de versiones de PHP e libxml2 que es defectuose e pote causar corruption celate de datos in MediaWiki e altere applicationes web.\nActualisa a libxml2 2.7.3 o plus recente ([https://bugs.php.net/bug.php?id=45996 problema reportate presso PHP]).\nInstallation abortate.",
+ "config-suhosin-max-value-length": "Suhosin es installate e limita parametro <code>length</code> de GET a $1 bytes.\nLe componente ResourceLoader de MediaWiki va contornar iste limite, ma isto prejudicara le rendimento.\nSi possibile, tu deberea mitter <code>suhosin.get.max_value_length</code> a 1024 o superior in <code>php.ini</code>, e mitter <code>$wgResourceLoaderMaxQueryLength</code> al mesme valor in <code>LocalSettings.php</code>.",
+ "config-db-type": "Typo de base de datos:",
+ "config-db-host": "Servitor de base de datos:",
+ "config-db-host-help": "Si tu servitor de base de datos es in un altere servitor, entra hic le nomine o adresse IP del servitor.\n\nSi tu usa un servitor web usate in commun, tu providitor deberea dar te le correcte nomine de servitor in su documentation.\n\nSi tu face le installation in un servitor Windows e usa MySQL, le nomine \"localhost\" possibilemente non functiona como nomine de servitor. In tal caso, essaya \"127.0.0.1\", i.e. le adresse IP local.\n\nSi tu usa PostgreSQL, lassa iste campo vacue pro connecter via un \"socket\" de Unix.",
+ "config-db-host-oracle": "TNS del base de datos:",
+ "config-db-host-oracle-help": "Entra un [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm nomine Local Connect] valide; un file tnsnames.ora debe esser visibile a iste installation.<br />Si tu usa bibliothecas de cliente 10g o plus recente, tu pote anque usar le methodo de nomination [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identificar iste wiki",
+ "config-db-name": "Nomine del base de datos:",
+ "config-db-name-help": "Selige un nomine que identifica tu wiki.\nIllo non pote continer spatios.\n\nSi tu usa un servitor web usate in commun, tu providitor te fornira le nomine specific de un base de datos a usar, o te permitte crear un base de datos via un pannello de controlo.",
+ "config-db-name-oracle": "Schema del base de datos:",
+ "config-db-account-oracle-warn": "Il ha tres scenarios supportate pro le installation de Oracle como le base de datos de iste systema:\n\nSi tu vole crear un conto del base de datos como parte del processo de installation, per favor specifica un conto con le rolo SYSDBA como le conto del base de datos pro installation, e specifica le nomine e contrasigno desirate pro le conto de accesso per web. Alteremente tu pote crear le conto de accesso per web manualmente e specificar solmente iste conto (si illo ha le permissiones requisite pro crear le objectos de schema) o specifica duo contos differente, un con privilegios de creation e un conto restringite pro accesso per web.\n\nUn script pro crear un conto con le privilegios requisite se trova in le directorio \"maintenance/oracle/\" de iste installation. Non oblida que le uso de un conto restringite disactiva tote le capacitates de mantenentia in le conto predefinite.",
+ "config-db-install-account": "Conto de usator pro installation",
+ "config-db-username": "Nomine de usator del base de datos:",
+ "config-db-password": "Contrasigno del base de datos:",
+ "config-db-install-username": "Entra le nomine de usator que essera usate pro connecter al base de datos durante le processo de installation. Isto non es le nomine de usator del conto MediaWiki; isto es le nomine de usator pro tu base de datos.",
+ "config-db-install-password": "Entra le contrasigno que essera usate pro connecter al base de datos durante le processo de installation. Isto non es le contrasigno del conto MediaWiki; isto es le contrasigno pro tu base de datos.",
+ "config-db-install-help": "Entra le nomine de usator e contrasigno que essera usate pro connecter al base de datos durante le processo de installation.",
+ "config-db-account-lock": "Usar le mesme nomine de usator e contrasigno durante le operation normal",
+ "config-db-wiki-account": "Conto de usator pro operation normal",
+ "config-db-wiki-help": "Entra le nomine de usator e contrasigno que essera usate pro connecter al base de datos durante le operation normal del wiki.\nSi le conto non existe, e si le conto de installation possede sufficiente privilegios, iste conto de usator essera create con le minime privilegios necessari pro operar le wiki.",
+ "config-db-prefix": "Prefixo de tabella del base de datos:",
+ "config-db-prefix-help": "Si il es necessari usar un base de datos in commun inter multiple wikis, o inter MediaWiki e un altere application web, tu pote optar pro adder un prefixo a tote le nomines de tabella pro evitar conflictos.\nNon usa spatios.\n\nIste campo usualmente resta vacue.",
+ "config-mysql-old": "MySQL $1 o plus recente es requirite, tu ha $2.",
+ "config-db-port": "Porto de base de datos:",
+ "config-db-schema": "Schema pro MediaWiki",
+ "config-db-schema-help": "Iste schema es generalmente correcte.\nSolmente cambia lo si tu es secur que es necessari.",
+ "config-pg-test-error": "Impossibile connecter al base de datos '''$1''': $2",
+ "config-sqlite-dir": "Directorio pro le datos de SQLite:",
+ "config-sqlite-dir-help": "SQLite immagazina tote le datos in un sol file.\n\nLe directorio que tu forni debe permitter le accesso de scriptura al servitor web durante le installation.\n\nIllo '''non''' debe esser accessibile via web. Pro isto, nos non lo pone ubi tu files PHP es.\n\nLe installator scribera un file <code>.htaccess</code> insimul a illo, ma si isto falli, alcuno pote ganiar accesso directe a tu base de datos.\nIsto include le crude datos de usator (adresses de e-mail, contrasignos codificate) assi como versiones delite e altere datos restringite super le wiki.\n\nConsidera poner le base de datos in un loco completemente differente, per exemplo in <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Spatio de tabellas predefinite:",
+ "config-oracle-temp-ts": "Spatio de tabellas temporari:",
+ "config-type-mysql": "MySQL (o compatibile)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki supporta le sequente systemas de base de datos:\n\n$1\n\nSi tu non vide hic infra le systema de base de datos que tu tenta usar, alora seque le instructiones ligate hic supra pro activar le supporto.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] es le systema primari pro MediaWiki e le melio supportate. MediaWiki functiona anque con [{{int:version-db-mariadb-url}} MariaDB] e con [{{int:version-db-percona-url}} Percona Server], le quales es compatibile con MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Como compilar PHP con supporto de MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] es un systema de base de datos popular e open source, alternativa a MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Como compilar PHP con supporto de PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] es un systema de base de datos legier que es multo ben supportate. ([http://www.php.net/manual/en/pdo.installation.php Como compilar PHP con supporto de SQLite], usa PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] es un banca de datos commercial pro interprisas. ([http://www.php.net/manual/en/oci8.installation.php Como compilar PHP con supporto de OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] es un base de datos de interprisa commercial pro Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Como compilar PHP con supporto de SQLSRV])",
+ "config-header-mysql": "Configuration de MySQL",
+ "config-header-postgres": "Configuration de PostgreSQL",
+ "config-header-sqlite": "Configuration de SQLite",
+ "config-header-oracle": "Configuration de Oracle",
+ "config-header-mssql": "Configuration de Microsoft SQL Server",
+ "config-invalid-db-type": "Typo de base de datos invalide",
+ "config-missing-db-name": "Es necessari entrar un valor pro \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Es necessari entrar un valor pro \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Es necessari entrar un valor pro \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "TNS de base de datos \"$1\" invalide.\nUsa o \"TNS Name\" o un catena \"Easy Connect\". ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Methodos de nomenclatura de Oracle])",
+ "config-invalid-db-name": "Nomine de base de datos \"$1\" invalide.\nUsa solmente litteras ASCII (a-z, A-Z), numeros (0-9), characteres de sublineamento (_) e tractos de union (-).",
+ "config-invalid-db-prefix": "Prefixo de base de datos \"$1\" invalide.\nUsa solmente litteras ASCII (a-z, A-Z), numeros (0-9), characteres de sublineamento (_) e tractos de union (-).",
+ "config-connection-error": "$1.\n\nVerifica le servitor, nomine de usator e contrasigno hic infra e reproba.",
+ "config-invalid-schema": "Schema invalide pro MediaWiki \"$1\".\nUsa solmente litteras ASCII (a-z, A-Z), numeros (0-9) e characteres de sublineamento (_).",
+ "config-db-sys-create-oracle": "Le installator supporta solmente le uso de un conto SYSDBA pro le creation de un nove conto.",
+ "config-db-sys-user-exists-oracle": "Le conto de usator \"$1\" ja existe. SYSDBA pote solmente esser usate pro le creation de un nove conto!",
+ "config-postgres-old": "PostgreSQL $1 o plus recente es requirite, tu ha $2.",
+ "config-mssql-old": "Microsoft SQL Server $1 o plus recente es necessari. Tu ha $2.",
+ "config-sqlite-name-help": "Selige un nomine que identifica tu wiki.\nNon usar spatios o tractos de union.\nIsto essera usate pro le nomine del file de datos de SQLite.",
+ "config-sqlite-parent-unwritable-group": "Impossibile crear le directorio de datos <code><nowiki>$1</nowiki></code>, proque le directorio superjacente <code><nowiki>$2</nowiki></code> non concede le accesso de scriptura al servitor web.\n\nLe installator ha determinate le usator sub que le servitor web es executate.\nConcede le accesso de scriptura in le directorio <code><nowiki>$3</nowiki></code> a iste usator pro continuar.\nIn un systema Unix/Linux:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Impossibile crear le directorio de datos <code><nowiki>$1</nowiki></code>, proque le directorio superjacente <code><nowiki>$2</nowiki></code> non concede le accesso de scriptura al servitor web.\n\nLe installator non poteva determinar le usator sub que le servitor web es executate.\nConcede le accesso de scriptura in le directorio <code><nowiki>$3</nowiki></code> a iste usator (e alteres!) pro continuar.\nIn un systema Unix/Linux:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Error al creation del directorio de datos \"$1\".\nVerifica le loco e reproba.",
+ "config-sqlite-dir-unwritable": "Impossibile scriber in le directorio \"$1\".\nCambia su permissiones de sorta que le servitor web pote scriber in illo, e reproba.",
+ "config-sqlite-connection-error": "$1.\n\nVerifica le directorio de datos e le nomine de base de datos hic infra e reproba.",
+ "config-sqlite-readonly": "Le file <code>$1</code> non es accessibile pro scriptura.",
+ "config-sqlite-cant-create-db": "Non poteva crear le file de base de datos <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP non ha supporto pro FTS3. Le tabellas es retrogradate.",
+ "config-can-upgrade": "Il ha tabellas MediaWiki in iste base de datos.\nPro actualisar los a MediaWiki $1, clicca super '''Continuar'''.",
+ "config-upgrade-done": "Actualisation complete.\n\nTu pote ora [$1 comenciar a usar tu wiki].\n\nSi tu vole regenerar tu file <code>LocalSettings.php</code>, clicca super le button hic infra.\nIsto '''non es recommendate''' si tu non ha problemas con tu wiki.",
+ "config-upgrade-done-no-regenerate": "Actualisation complete.\n\nTu pote ora [$1 comenciar a usar tu wiki].",
+ "config-regenerate": "Regenerar LocalSettings.php →",
+ "config-show-table-status": "Le consulta <code>SHOW TABLE STATUS</code> falleva!",
+ "config-unknown-collation": "'''Aviso:''' Le base de datos usa un collation non recognoscite.",
+ "config-db-web-account": "Conto de base de datos pro accesso via web",
+ "config-db-web-help": "Selige le nomine de usator e contrasigno que le servitor web usara pro connecter al servitor de base de datos, durante le operation ordinari del wiki.",
+ "config-db-web-account-same": "Usar le mesme conto que pro le installation",
+ "config-db-web-create": "Crear le conto si illo non jam existe",
+ "config-db-web-no-create-privs": "Le conto que tu specificava pro installation non ha sufficiente privilegios pro crear un conto.\nLe conto que tu specifica hic debe jam exister.",
+ "config-mysql-engine": "Motor de immagazinage:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "* '''Attention:''' Tu ha seligite MyISAM como motor de immagazinage pro MySQL, lo que non es recommendate pro uso con MediaWiki, perque:\n* illo a pena supporta le processamento simultanee a causa del blocada le tabulas\n* illo es plus susceptibile al corruption que altere motores\n* le base de codice de MediaWiki non sempre manea MyISAM como illo deberea\n\nSi tu installation de MySQL supporta InnoDB, es multo recommendate que tu selige iste in su loco.\nSi tu installation de MySQL non supporta InnoDB, forsan isto es un bon occasion pro actualisar lo.",
+ "config-mysql-only-myisam-dep": "'''Attention:''' MyISAM es le unic motor de immagazinage disponibile pro MySQL in iste machina, ma isto non es recommendate pro le uso con MediaWiki, perque:\n* a pena supporto le accesso simultanee a causa del blocage de tabellas\n* es plus propense a corrumper se que altere motores\n* le codice base de MediaWiki non sempre gere MyISAM como deberea\n\nTu installation de MySQL non supporta InnoDB; forsan il es tempore de actualisar lo.",
+ "config-mysql-engine-help": "'''InnoDB''' es quasi sempre le melior option, post que illo ha bon supporto pro simultaneitate.\n\n'''MyISAM''' pote esser plus rapide in installationes a usator singule o a lectura solmente.\nLe bases de datos MyISAM tende a esser corrumpite plus frequentemente que le base de datos InnoDB.",
+ "config-mysql-charset": "Codification de characteres in le base de datos:",
+ "config-mysql-binary": "Binari",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "In '''modo binari''', MediaWiki immagazina le texto UTF-8 in le base de datos in campos binari.\nIsto es plus efficiente que le modo UTF-8 de MySQL, e permitte usar le rango complete de characteres Unicode.\n\nIn '''modo UTF-8''', MySQL cognoscera le codification de characteres usate pro tu dats, e pote presentar e converter lo appropriatemente, ma illo non permittera immagazinar characteres supra le [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Plano Multilingue Basic].",
+ "config-mssql-auth": "Typo de authentication:",
+ "config-mssql-install-auth": "Selige le typo de authentication a usar pro connecter al base de datos durante le processo de installation.\nSi tu selige \"{{int:config-mssql-windowsauth}}\", le credentiales del usator que executa le servitor web essera usate.",
+ "config-mssql-web-auth": "Selige le typo de authentication que le servitor web usara pro connecter al base de datos durante le operation ordinari del wiki.\nSi tu selige \"{{int:config-mssql-windowsauth}}\", le credentiales del usator que executa le servitor web essera usate.",
+ "config-mssql-sqlauth": "Authentication per SQL Server",
+ "config-mssql-windowsauth": "Authentication per Windows",
+ "config-site-name": "Nomine del wiki:",
+ "config-site-name-help": "Isto apparera in le barra de titulo del navigator e in varie altere locos.",
+ "config-site-name-blank": "Entra un nomine de sito.",
+ "config-project-namespace": "Spatio de nomines del projecto:",
+ "config-ns-generic": "Projecto",
+ "config-ns-site-name": "Mesme nomine que le wiki: $1",
+ "config-ns-other": "Altere (specifica)",
+ "config-ns-other-default": "MiWiki",
+ "config-project-namespace-help": "Sequente le exemplo de Wikipedia, multe wikis tene lor paginas de politica separate de lor paginas de contento, in un '''spatio de nomines de projecto'''.\nTote le titulos de pagina in iste spatio de nomines comencia con un certe prefixo, le qual tu pote specificar hic.\nNormalmente, iste prefixo deriva del nomine del wiki, ma illo non pote continer characteres de punctuation como \"#\" o \":\".",
+ "config-ns-invalid": "Le spatio de nomines specificate \"<nowiki>$1</nowiki>\" es invalide.\nSpecifica un altere spatio de nomines de projecto.",
+ "config-ns-conflict": "Le spatio de nomines specificate \"<nowiki>$1</nowiki>\" conflige con un spatio de nomines predefinite de MediaWiki.\nSpecifica un altere spatio de nomines pro le projecto.",
+ "config-admin-box": "Conto de administrator",
+ "config-admin-name": "Tu nomine de usator:",
+ "config-admin-password": "Contrasigno:",
+ "config-admin-password-confirm": "Repete contrasigno:",
+ "config-admin-help": "Entra hic tu nomine de usator preferite, per exemplo \"Julio Cesare\".\nIsto es le nomine que tu usara pro aperir session in le wiki.",
+ "config-admin-name-blank": "Entra un nomine de usator pro administrator.",
+ "config-admin-name-invalid": "Le nomine de usator specificate \"<nowiki>$1</nowiki>\" es invalide.\nSpecifica un altere nomine de usator.",
+ "config-admin-password-blank": "Entra un contrasigno pro le conto de administrator.",
+ "config-admin-password-mismatch": "Le duo contrasignos que tu scribeva non es identic.",
+ "config-admin-email": "Adresse de e-mail:",
+ "config-admin-email-help": "Entra un adresse de e-mail hic pro permitter le reception de e-mail ab altere usatores del wiki, pro poter reinitialisar tu contrasigno, e pro reciper notification de cambios a paginas in tu observatorio. Iste campo pote esser lassate vacue.",
+ "config-admin-error-user": "Error interne durante le creation de un administrator con le nomine \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Error interne durante le definition de un contrasigno pro le administrator \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Tu ha entrate un adresse de e-mail invalide",
+ "config-subscribe": "Subscribe al [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce lista de diffusion pro annuncios de nove versiones].",
+ "config-subscribe-help": "Isto es un lista de e-mail a basse volumine pro annuncios de nove versiones, includente importante annuncios de securitate.\nTu deberea subscriber a illo e actualisar tu installation de MediaWiki quando nove versiones es editate.",
+ "config-subscribe-noemail": "Tu tentava abonar te al lista de diffusion pro annunciamento de nove versiones sin fornir un adresse de e-mail.\nPer favor specifica un adresse de e-mail si tu vole abonar te al lista de diffusion.",
+ "config-pingback": "Divider datos sur iste installation con le disveloppatores de MediaWiki.",
+ "config-pingback-help": "Si tu selige option, MediaWiki inviara periodicamente a https://www.mediawiki.org certe datos basic sur iste installation de MediaWiki. Iste datos include, per exemplo, le typo de systema, le version de PHP, e le programma de base de datos seligite. Le Fundation Wikimedia divide iste datos con le disveloppatores de MediaWiki pro adjutar a guidar le effortios de disveloppamento futur. Le sequente datos concernente iste systema essera inviate:\n<pre>$1</pre>",
+ "config-almost-done": "Tu ha quasi finite!\nTu pote ora saltar le configuration remanente e installar le wiki immediatemente.",
+ "config-optional-continue": "Pone me plus questiones.",
+ "config-optional-skip": "Isto me es jam tediose. Simplemente installa le wiki.",
+ "config-profile": "Profilo de derectos de usator:",
+ "config-profile-wiki": "Wiki aperte",
+ "config-profile-no-anon": "Creation de conto obligatori",
+ "config-profile-fishbowl": "Modificatores autorisate solmente",
+ "config-profile-private": "Wiki private",
+ "config-profile-help": "Le wikis functiona melio si tu permitte a tante personas como possibile de modificar los.\nIn MediaWiki, il es facile revider le modificationes recente, e reverter omne damno facite per usatores naive o malitiose.\n\nNonobstante, multes ha trovate MediaWiki utile in un grande varietate de rolos, e alcun vices il non es facile convincer omnes del beneficios del principio wiki.\nDunque, a te le option.\n\nLe modello '''{{int:config-profile-wiki}}''' permitte a omnes de modificar, sin mesmo aperir un session.\nUn wiki con '''{{int:config-profile-no-anon}}''' attribue additional responsabilitate, ma pote dissuader contributores occasional.\n\nLe scenario '''{{int:config-profile-fishbowl}}''' permitte al usatores approbate de modificar, ma le publico pote vider le paginas, includente lor historia.\nUn '''{{int:config-profile-private}}''' permitte solmente al usatores approbate de vider le paginas e de modificar los.\n\nConfigurationes de derectos de usator plus complexe es disponibile post installation, vide le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights pertinente section del manual].",
+ "config-license": "Copyright e licentia:",
+ "config-license-none": "Nulle licentia in pede de paginas",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (dominio public)",
+ "config-license-gfdl": "Licentia GNU pro Documentation Libere 1.3 o plus recente",
+ "config-license-pd": "Dominio public",
+ "config-license-cc-choose": "Seliger un licentia Creative Commons personalisate",
+ "config-license-help": "Multe wikis public pone tote le contributiones sub un [http://freedomdefined.org/Definition/Ia?uselang=ia licentia libere].\nIsto adjuta a crear un senso de proprietate communitari e incoragia le contribution in longe termino.\nIsto non es generalmente necessari pro un wiki private o de interprisa.\n\nSi tu vole poter usar texto de Wikipedia, e si tu vole que Wikipedia pote acceptar texto copiate de tu wiki, tu debe seliger <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia usava anteriormente le Licentia GNU pro Documentation Libere (GFDL).\nIste es un licentia valide, ma es difficile a comprender.\nIl es anque difficile reusar le contento licentiate sub GFDL.",
+ "config-email-settings": "Configuration de e-mail",
+ "config-enable-email": "Activar le e-mail sortiente",
+ "config-enable-email-help": "Si tu vole que e-mail functiona, [http://www.php.net/manual/en/mail.configuration.php le optiones de e-mail de PHP] debe esser configurate correctemente.\nSi tu non vole functiones de e-mail, tu pote disactivar los hic.",
+ "config-email-user": "Activar le e-mail de usator a usator",
+ "config-email-user-help": "Permitter a tote le usatores de inviar e-mail inter se, si illes lo ha activate in lor preferentias.",
+ "config-email-usertalk": "Activar notification de cambios in paginas de discussion de usatores",
+ "config-email-usertalk-help": "Permitter al usatores de reciper notification de modificationes in lor paginas de discussion personal, si illes lo ha activate in lor preferentias.",
+ "config-email-watchlist": "Activar notification de observatorio",
+ "config-email-watchlist-help": "Permitter al usatores de reciper notification super lor paginas sub observation, si illes lo ha activate in lor preferentias.",
+ "config-email-auth": "Activar authentication de e-mail",
+ "config-email-auth-help": "Si iste option es activate, le usatores debe confirmar lor adresse de e-mail usante un ligamine inviate a illes, quandocunque illes lo defini o cambia.\nSolmente le adresses de e-mail authenticate pote reciper e-mail de altere usatores o alterar le e-mails de notification.\nEs '''recommendate''' activar iste option pro wikis public a causa de abuso potential del functionalitate de e-mail.",
+ "config-email-sender": "Adresse de e-mail de retorno:",
+ "config-email-sender-help": "Entra le adresse de e-mail a usar como adresse de retorno in e-mail sortiente.\nHic es recipite le notificationes de non-livration.\nMulte servitores de e-mail require que al minus le parte de nomine de dominio sia valide.",
+ "config-upload-settings": "Incargamento de imagines e files",
+ "config-upload-enable": "Activar le incargamento de files",
+ "config-upload-help": "Le incargamento de files potentialmente expone tu servitor a riscos de securitate.\nPro plus information, lege le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security section de securitate] in le manual.\n\nPro activar le incargamento de files, cambia le modo in le subdirectorio <code>images</code> sub le directorio-radice de MediaWiki de sorta que le servitor web pote scriber in illo.\nPostea activa iste option.",
+ "config-upload-deleted": "Directorio pro files delite:",
+ "config-upload-deleted-help": "Selige un directorio in le qual archivar le files delite.\nIdealmente, isto non debe esser accessibile ab le web.",
+ "config-logo": "URL del logotypo:",
+ "config-logo-help": "Le apparentia predefinite de MediaWiki include spatio pro un logotypo de 135×160 pixels supra le menu del barra lateral.\nIncarga un imagine con le dimensiones appropriate, e entra le URL hic.\n\nTu pote usar <code>$wgStylePath</code> o <code>$wgScriptPath</code> si le loco de tu logotypo es relative a iste camminos.\n\nSi tu non vole un logotypo, lassa iste quadro vacue.",
+ "config-instantcommons": "Activar \"Instant Commons\"",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] es un function que permitte a wikis de usar imagines, sonos e altere multimedia trovate in le sito [https://commons.wikimedia.org/ Wikimedia Commons].\nPro poter facer isto, MediaWiki require accesso a Internet.\n\nPro saper plus, p.ex. como configurar lo pro wikis altere que Wikimedia Commons, consulta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos le manual].",
+ "config-cc-error": "Le selector de licentia Creative Commons non dava un resultato.\nEntra le nomine del licentia manualmente.",
+ "config-cc-again": "Selige de novo…",
+ "config-cc-not-chosen": "Selige le licentia Creative Commons que tu prefere e clicca \"proceed\".",
+ "config-advanced-settings": "Configuration avantiate",
+ "config-cache-options": "Configuration del cache de objectos:",
+ "config-cache-help": "Le cache de objectos es usate pro meliorar le rapiditate de MediaWiki per immagazinar le datos frequentemente usate.\nLe sitos medie o grande es multo incoragiate de activar isto, ma anque le sitos parve percipera le beneficios.",
+ "config-cache-none": "Nulle cache (nulle functionalitate es removite, ma le rapiditate pote diminuer in grande sitos wiki)",
+ "config-cache-accel": "Cache de objectos PHP (APC, APCu, XCache o WinCache)",
+ "config-cache-memcached": "Usar Memcached (require additional installation e configuration)",
+ "config-memcached-servers": "Servitores Memcached:",
+ "config-memcached-help": "Lista de adresses IP a usar pro Memcached.\nDebe specificar un per linea e specificar le porto a usar. Per exemplo:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Tu seligeva Memcached como typo de cache ma non specificava alcun servitores",
+ "config-memcache-badip": "Tu ha entrate un adresse IP invalide pro Memcached: $1",
+ "config-memcache-noport": "Tu non specificava un porto a usar pro le servitor Memcached: $1.\nSi tu non cognosce le porto, le standard es 11211",
+ "config-memcache-badport": "Le numeros de porto de Memcached debe esser inter $1 e $2",
+ "config-extensions": "Extensiones",
+ "config-extensions-help": "Le extensiones listate hic supra esseva detegite in tu directorio <code>./extensions</code>.\n\nIstes pote requirer additional configuration, ma tu pote activar los ora.",
+ "config-skins": "Apparentias",
+ "config-skins-help": "Hic supra es le lista de apparentias detegite in tu directorio <code>./skins</code>. Tu debe activar al minus un de illos e seliger le predefinite.",
+ "config-skins-use-as-default": "Usar iste apparentia como predefinite",
+ "config-skins-missing": "Nulle apparentia ha essite trovate; MediaWiki usara un apparentia de reserva usque tu installa alcun apparentia complete.",
+ "config-skins-must-enable-some": "Tu debe seliger al minus un apparentia a activar.",
+ "config-skins-must-enable-default": "Le apparentia seligite como predefinite debe esser activate.",
+ "config-install-alreadydone": "'''Aviso:''' Il pare que tu ha jam installate MediaWiki e tenta installar lo de novo.\nPer favor continua al proxime pagina.",
+ "config-install-begin": "Un clic sur \"{{int:config-continue}}\" comencia le installation de MediaWiki.\nPro facer alterationes, clicca sur \"{{int:config-back}}\".",
+ "config-install-step-done": "finite",
+ "config-install-step-failed": "fallite",
+ "config-install-extensions": "Include le extensiones",
+ "config-install-database": "Configura le base de datos",
+ "config-install-schema": "Creation de schema",
+ "config-install-pg-schema-not-exist": "Iste schema de PostgreSQL non existe",
+ "config-install-pg-schema-failed": "Le creation del tabellas falleva.\nAssecura te que le usator \"$1\" pote scriber in le schema \"$2\".",
+ "config-install-pg-commit": "Committer cambiamentos",
+ "config-install-pg-plpgsql": "Verifica le presentia del linguage PL/pgSQL",
+ "config-pg-no-plpgsql": "Es necessari installar le linguage PL/pgSQL in le base de datos $1",
+ "config-pg-no-create-privs": "Le conto que tu specificava pro installation non ha sufficiente privilegios pro crear un conto.",
+ "config-pg-not-in-role": "Le conto que tu specificava pro le usator web ja existe.\nLe conto que tu specificava pro installation non es superusator e non es membro del rolo de usator web, dunque es incapace de crear objectos possedite per le usator web.\n\nMediaWiki require actualmente que le tabellas sia possedite per le usator web. Per favor specifica un altere nomine de conto web, o clicca super \"retornar\" e specifica un usator de installation con sufficiente privilegios.",
+ "config-install-user": "Crea usator pro base de datos",
+ "config-install-user-alreadyexists": "Le usator \"$1\" ja existe",
+ "config-install-user-create-failed": "Le creation del usator \"$1\" ha fallite: $2",
+ "config-install-user-grant-failed": "Le concession de permission al usator \"$1\" falleva: $2",
+ "config-install-user-missing": "Le usator specificate, \"$1\", non existe.",
+ "config-install-user-missing-create": "Le usator specificate, \"$1\", non existe.\nPer favor marca le quadrato \"crear conto\" hic infra si tu vole crear lo.",
+ "config-install-tables": "Crea tabellas",
+ "config-install-tables-exist": "'''Aviso''': Il pare que le tabellas de MediaWiki jam existe.\nLe creation es saltate.",
+ "config-install-tables-failed": "'''Error''': Le creation del tabellas falleva con le sequente error: $1",
+ "config-install-interwiki": "Plena le tabella interwiki predefinite",
+ "config-install-interwiki-list": "Non poteva trovar le file <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Aviso''': Le tabella interwiki pare jam haber entratas.\nLe lista predefinite es saltate.",
+ "config-install-stats": "Initialisation del statisticas",
+ "config-install-keys": "Generation de claves secrete",
+ "config-insecure-keys": "'''Attention:''' {{PLURAL:$2|Un clave|Alcun claves}} secur ($1) generate durante le installation non es completemente secur. Considera cambiar {{PLURAL:$2|lo|los}} manualmente.",
+ "config-install-updates": "Impedir le execution de actualisationes innecessari",
+ "config-install-updates-failed": "<strong>Error:</strong> Le insertion de claves de actualisation in le tabellas ha fallite con le error sequente: $1",
+ "config-install-sysop": "Crea conto de usator pro administrator",
+ "config-install-subscribe-fail": "Impossibile subscriber a mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL non es installate e <code>allow_url_fopen</code> non es disponibile.",
+ "config-install-mainpage": "Crea pagina principal con contento predefinite",
+ "config-install-mainpage-exists": "Le pagina principal jam existe, es omittite",
+ "config-install-extension-tables": "Creation de tabellas pro le extensiones activate",
+ "config-install-mainpage-failed": "Non poteva inserer le pagina principal: $1",
+ "config-install-done": "<strong>Felicitationes!</strong>\nTu ha installate MediaWiki.\n\nLe installator ha generate un file <code>LocalSettings.php</code>.\nIste contine tote le configuration.\n\nEs necessari discargar lo e poner lo in le base del installation wiki (le mesme directorio que index.php).\nLe discargamento debe haber comenciate automaticamente.\n\nSi le discargamento non ha comenciate, o si illo esseva cancellate, recomencia le discargamento con un clic sur le ligamine sequente:\n\n$3\n\n<strong>Nota:</strong> Si tu non discarga iste file de configuration ora, illo non essera disponibile plus tarde.\n\nPost facer isto, tu pote <strong>[$2 entrar in tu wiki]</strong>.",
+ "config-install-done-path": "<strong>Felicitationes!</strong>\nTu ha installate MediaWiki.\n\nLe installator ha generate un file <code>LocalSettings.php</code>.\nIste contine tote le configuration.\n\nEs necessari discargar lo e poner lo in <code>$4</code>.\nLe discargamento debe haber comenciate automaticamente.\n\nSi le discargamento non ha comenciate, o si illo esseva cancellate, recomencia le discargamento con un clic sur le ligamine sequente:\n\n$3\n\n<strong>Nota:</strong> Si tu non discarga iste file de configuration ora, illo non essera disponibile plus tarde.\n\nPost facer isto, tu pote <strong>[$2 entrar in tu wiki]</strong>.",
+ "config-download-localsettings": "Discargar <code>LocalSettings.php</code>",
+ "config-help": "adjuta",
+ "config-help-tooltip": "clicca pro displicar",
+ "config-nofile": "Le file \"$1\" non poteva esser trovate. Ha illo essite delite?",
+ "config-extension-link": "Sapeva tu que tu wiki supporta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensiones]?\n\nTu pote explorar le [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensiones per category] o le [https://www.mediawiki.org/wiki/Extension_Matrix matrice de extensiones] pro vider le lista complete de extensiones.",
+ "mainpagetext": "<strong>MediaWiki ha essite installate.</strong>",
+ "mainpagedocfooter": "Consulta le [https://meta.wikimedia.org/wiki/Help:Contents Guida del usator] pro information sur le uso del software wiki.\n\n== Pro initiar ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de configurationes]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ a proposito de MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de diffusion pro annuncios de nove versiones de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Traducer MediaWiki in tu lingua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Como combatter le spam in tu wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/id.json b/www/wiki/includes/installer/i18n/id.json
new file mode 100644
index 00000000..37172585
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/id.json
@@ -0,0 +1,324 @@
+{
+ "@metadata": {
+ "authors": [
+ "Farras",
+ "IvanLanin",
+ "Kenrick95",
+ "Reedy",
+ "아라",
+ "C5st4wr6ch",
+ "Seb35",
+ "Arifin.wijaya",
+ "Ilham151096",
+ "Bennylin",
+ "WongKentir",
+ "Macofe",
+ "Rachmat.Wahidi",
+ "Gombang"
+ ]
+ },
+ "config-desc": "Penginstal untuk MediaWiki",
+ "config-title": "Instalasi MediaWiki $1",
+ "config-information": "Informasi",
+ "config-localsettings-upgrade": "Berkas <code>LocalSettings.php</code> sudah ada.\nUntuk memutakhirkan instalasi ini, masukkan nilai <code>$wgUpgradeKey</code> dalam kotak yang tersedia di bawah ini.\nAnda dapat menemukan nilai tersebut dalam <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Berkas <code>LocalSettings.php</code> terdeteksi.\nUntuk meningkatkan versi, harap jalankan <code>update.php</code>.",
+ "config-localsettings-key": "Kunci pemutakhiran:",
+ "config-localsettings-badkey": "Kunci pembaruan yang Anda berikan tidak benar.",
+ "config-upgrade-key-missing": "Suatu instalasi MediaWiki telah terdeteksi.\nUntuk memutakhirkan instalasi ini, silakan masukkan baris berikut di bagian bawah <code>LocalSettings.php</code> Anda:\n\n$1",
+ "config-localsettings-incomplete": "<code>LocalSettings.php</code> yang ada tampaknya tidak lengkap.\nVariabel $1 tidak diatur.\nSilakan ubah <code>LocalSettings.php</code> untuk mengatur variabel ini dan klik \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Ditemukan galat saat menghubungkan ke basis data dengan menggunakan setelan yang ditentukan di <code>LocalSettings.php</code>. Harap perbaiki setelan ini dan coba lagi.\n\n$1",
+ "config-session-error": "Kesalahan sesi mulai: $1",
+ "config-session-expired": "Data sesi tampaknya telah kedaluwarsa.\nSesi dikonfigurasi untuk berlaku selama $1.\nAnda dapat menaikkannya dengan menetapkan <code>session.gc_maxlifetime</code> dalam php.ini.\nUlangi proses instalasi.",
+ "config-no-session": "Data sesi Anda hilang!\nCek php.ini Anda dan pastikan bahwa <code>session.save_path</code> diatur ke direktori yang sesuai.",
+ "config-your-language": "Bahasa Anda:",
+ "config-your-language-help": "Pilih bahasa yang akan digunakan selama proses instalasi.",
+ "config-wiki-language": "Bahasa wiki:",
+ "config-wiki-language-help": "Pilih bahasa yang akan digunakan tulisan-tulisan wiki.",
+ "config-back": "← Kembali",
+ "config-continue": "Lanjut →",
+ "config-page-language": "Bahasa",
+ "config-page-welcome": "Selamat datang di MediaWiki",
+ "config-page-dbconnect": "Hubungkan ke basis data",
+ "config-page-upgrade": "Perbarui instalasi yang ada",
+ "config-page-dbsettings": "Pengaturan basis data",
+ "config-page-name": "Nama",
+ "config-page-options": "Pilihan",
+ "config-page-install": "Instal",
+ "config-page-complete": "Selesai!",
+ "config-page-restart": "Ulangi instalasi",
+ "config-page-readme": "Baca saya",
+ "config-page-releasenotes": "Catatan pelepasan",
+ "config-page-copying": "Menyalin",
+ "config-page-upgradedoc": "Memerbarui",
+ "config-page-existingwiki": "Wiki yang ada",
+ "config-help-restart": "Apakah Anda ingin menghapus semua data tersimpan yang telah Anda masukkan dan mengulang proses instalasi?",
+ "config-restart": "Ya, nyalakan ulang",
+ "config-welcome": "=== Pengecekan lingkungan ===\nPengecekan dasar kini akan dilakukan untuk melihat apakah lingkungan ini memadai untuk instalasi MediaWiki.\nIngatlah untuk menyertakan informasi ini jika Anda mencari bantuan tentang cara menyelesaikan instalasi.",
+ "config-copyright": "=== Hak cipta dan persyaratan ===\n\n$1\n\nProgram ini adalah perangkat lunak bebas; Anda dapat mendistribusikan dan/atau memodifikasi di bawah persyaratan GNU General Public License seperti yang diterbitkan oleh Free Software Foundation; baik versi 2 lisensi, atau (sesuai pilihan Anda) versi yang lebih baru.\n\nProgram ini didistribusikan dengan harapan bahwa itu akan berguna, tetapi <strong>tanpa jaminan apa pun</strong>; bahkan tanpa jaminan tersirat untuk <strong>dapat diperjualbelikan</strong> atau <strong>sesuai untuk tujuan tertentu</strong>.\nLihat GNU General Public License untuk lebih jelasnya.\n\nAnda seharusnya telah menerima <doclink href=\"Copying\">salinan dari GNU General Public License</doclink> bersama dengan program ini; jika tidak, kirimkan surat untuk Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, atau [http://www.gnu.org/copyleft/gpl.html baca versi daring].",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/id Situs MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/id Pedoman Pengguna]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/id Pedoman Administrator]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/id FAQ]\n----\n* <doclink href=Readme>Read me</doclink>\n* <doclink href=ReleaseNotes>Release notes</doclink>\n* <doclink href=Copying>Copying</doclink>\n* <doclink href=UpgradeDoc>Upgrading</doclink>",
+ "config-env-good": "Kondisi telah diperiksa.\nAnda dapat menginstal MediaWiki.",
+ "config-env-bad": "Kondisi telah diperiksa.\nAnda tidak dapat menginstal MediaWiki.",
+ "config-env-php": "PHP $1 diinstal.",
+ "config-env-hhvm": "HHVM $1 telah dipasang.",
+ "config-unicode-using-intl": "Menggunakan [http://pecl.php.net/intl ekstensi PECL intl] untuk normalisasi Unicode.",
+ "config-unicode-pure-php-warning": "'''Peringatan''': [http://pecl.php.net/intl Ekstensi intl PECL] untuk menangani normalisasi Unicode tidak tersedia, kembali menggunakan implementasi murni PHP yang lambat.\nJika Anda menjalankan situs berlalu lintas tinggi, Anda harus sedikit membaca [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalisasi Unicode].",
+ "config-unicode-update-warning": "'''Peringatan''': Versi terinstal dari pembungkus normalisasi Unicode menggunakan versi lama pustaka [http://site.icu-project.org/ proyek ICU].\nAnda harus [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations memutakhirkannya] jika Anda ingin menggunakan Unicode.",
+ "config-no-db": "Pengandar basis data yang sesuai tidak ditemukan! Anda perlu menginstal pengandar basis data untuk PHP.\n{{PLURAL:$2|Jenis|Jenis}} basis data yang didukung: $1.\n\nJika Anda mengompilasi PHP sendiri, ubahlah konfigurasinya dengan mengaktifkan klien basis data, misalnya menggunakan <code>./configure --with-mysqli</code>.\nJika Anda menginstal PHP dari paket Debian atau Ubuntu, maka Anda juga perlu menginstal seperti paket <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Peringatan:</strong> Anda menggunakan SQLite $1, yang lebih rendah dari versi minimum yang diperlukan $2. SQLite akan tidak tersedia.",
+ "config-no-fts3": "'''Peringatan''': SQLite dikompilasi tanpa [//sqlite.org/fts3.html modul FTS3], fitur pencarian tidak akan tersedia pada konfigurasi ini.",
+ "config-pcre-old": "<strong>Fatal:</strong> PCRE $1 atau kemudian diperlukan.\nBiner PHP Anda dihubungkan dengan PCRE $2. [https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Selengkapnya].",
+ "config-pcre-no-utf8": "'''Fatal''': Modul PCRE PHP tampaknya dikompilasi tanpa dukungan PCRE_UTF8.\nMediaWiki memerlukan dukungan UTF-8 untuk berfungsi dengan benar.",
+ "config-memory-raised": "<code>memory_limit</code> PHP adalah $1, dinaikkan ke $2.",
+ "config-memory-bad": "'''Peringatan:''' <code>memory_limit</code> PHP adalah $1.\nIni terlalu rendah.\nInstalasi terancam gagal!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] telah diinstal",
+ "config-apc": "[http://www.php.net/apc APC] telah diinstal",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] telah diinstal",
+ "config-no-cache-apcu": "<strong>Peringatan:</strong> Tidak dapat menemukan [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] atau [http://www.iis.net/download/WinCacheForPhp WinCache]. Singgahan obyek tidak diaktifkan.",
+ "config-mod-security": "<strong>Peringatan:</strong> Server web Anda memiliki [http://modsecurity.org/ mod_security] yang diaktifkan. Jika salah dalam mengkonfigurasi, ini dapat menyebabkan masalah untuk MediaWiki atau perangkat lunak lain yang memungkinkan pengguna untuk mengirim sembarang konten.\nLihat [http://modsecurity.org/documentation/ dokumentasi mod_security] atau hubungi layanan host Anda jika Anda mengalami kesalahan acak.",
+ "config-diff3-bad": "GNU diff3 tidak ditemukan.",
+ "config-git": "Menemukan perangkat lunak kontrol versi Git: <code>$1</code>.",
+ "config-git-bad": "Perangkat lunak kontrol versi Git tidak ditemukan.",
+ "config-imagemagick": "ImageMagick ditemukan: <code>$1</code> .\nPembuatan gambar mini akan diaktifkan jika Anda mengaktifkan pengunggahan.",
+ "config-gd": "Pustaka grafis GD terpasang ditemukan.\nPembuatan gambar mini akan diaktifkan jika Anda mengaktifkan pengunggahan.",
+ "config-no-scaling": "Pustaka GD atau ImageMagick tidak ditemukan.\nPembuatan gambar mini dinonaktifkan.",
+ "config-no-uri": "'''Kesalahan:''' URI saat ini tidak dapat ditentukan.\nInstalasi dibatalkan.",
+ "config-no-cli-uri": "<strong>Peringatan:</strong> Tidak ada <code>--scriptpath</code> yang ditentukan, dengan menggunakan standar: <code>$1</code>.",
+ "config-using-server": "Menggunakan nama server \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Menggunakan URL server \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "'''Peringatan:''' Direktori bawaan pengunggahan <code>$1</code> Anda rentan terhadap eksekusi skrip yang sewenang-wenang.\nMeskipun MediaWiki memeriksa semua berkas unggahan untuk ancaman keamanan, sangat dianjurkan untuk [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security menutup kerentanan keamanan ini] sebelum mengaktifkan pengunggahan.",
+ "config-no-cli-uploads-check": "<strong>Peringatan:</strong> Direktori default Anda untuk unggahan (<code>$1</code>) tidak diperiksa untuk kerentanan terhadap\neksekusi script sewenang-wenang selama instalasi CLI.",
+ "config-brokenlibxml": "Sistem Anda memiliki kombinasi versi PHP dan libxml2 yang memiliki bug dan dapat menyebabkan kerusakan data tersembunyi pada MediaWiki dan aplikasi web lain.\nMutakhirkan ke PHP 5.2.9 atau yang lebih baru dan libxml2 2.7.3 atau yang lebih baru ([https://bugs.php.net/bug.php?id=45996 arsip bug di PHP]).\nInstalasi dibatalkan.",
+ "config-suhosin-max-value-length": "Suhosin terpasang dan membatasi parameter GET <code>length</code> sebesar $1 bita. Komponen ResourceLoader MediaWiki akan berjalan dalam batasan ini, tetapi penanganannya akan menurunkan kinerja. Jika memungkinkan, Anda sebaiknya menetapkan nilai <code>suhosin.get.max_value_length</code> menjadi 1024 atau lebih tinggi dalam <code>php.ini</code> dan menyetel <code>$wgResourceLoaderMaxQueryLength</code> dengan nilai yang sama dalam <code>LocalSettings.php</code>.",
+ "config-db-type": "Jenis basis data:",
+ "config-db-host": "Inang basis data:",
+ "config-db-host-help": "Jika server basis data Anda berada di server yang berbeda, masukkan nama inang atau alamat IP di sini.\n\nJika Anda menggunakan inang web bersama, penyedia inang Anda harus memberikan nama inang yang benar di dokumentasi mereka.\n\nJika Anda menginstal pada server Windows dan menggunakan MySQL, \"localhost\" mungkin tidak dapat digunakan sebagai nama server. Jika demikian, coba \"127.0.0.1\" untuk alamat IP lokal.\n\nJika Anda menggunakan PostgreSQL, biarkan field ini kosong untuk menghubungkan lewat soket Unix.",
+ "config-db-host-oracle": "TNS basis data:",
+ "config-db-host-oracle-help": "Masukkan [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name] yang sah; berkas tnsnames.ora harus dapat diakses oleh instalasi ini.<br />Jika Anda menggunakan pustaka klien 10g atau lebih baru, Anda juga dapat menggunakan metode penamaan [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifikasi wiki ini",
+ "config-db-name": "Nama basis data:",
+ "config-db-name-help": "Pilih nama yang mengidentifikasikan wiki Anda.\nNama tersebut tidak boleh mengandung spasi.\n\nJika Anda menggunakan inang web bersama, penyedia inang Anda dapat memberikan Anda nama basis data khusus untuk digunakan atau mengizinkan Anda membuat basis data melalui panel kontrol.",
+ "config-db-name-oracle": "Skema basis data:",
+ "config-db-account-oracle-warn": "Ada tiga skenario yang didukung untuk instalasi Oracle sebagai basis data pendukung:\n\nJika Anda ingin membuat akun basis data sebagai bagian dari proses instalasi, silakan masukkan akun dengan peran SYSDBA sebagai akun basis data untuk instalasi dan tentukan kredensial yang diinginkan untuk akun akses web. Jika tidak, Anda dapat membuat akun akses web secara manual dan hanya memberikan akun tersebut (jika memiliki izin yang diperlukan untuk membuat objek skema) atau memasukkan dua akun yang berbeda, satu dengan hak membuat objek dan satu dibatasi untuk akses web.\n\nSkrip untuk membuat akun dengan privilese yang diperlukan dapat ditemukan pada direktori \"maintenance/oracle/\" instalasi ini. Harap diingat bahwa penggunaan akun terbatas akan menonaktifkan semua kemampuan pemeliharaan dengan akun bawaan.",
+ "config-db-install-account": "Akun pengguna untuk instalasi",
+ "config-db-username": "Nama pengguna basis data:",
+ "config-db-password": "Kata sandi basis data:",
+ "config-db-install-username": "Masukkan nama pengguna yang akan digunakan untuk terhubung ke basis data selama proses instalasi.\nIni bukan nama pengguna akun MediaWiki, melainkan nama pengguna untuk basis data Anda.",
+ "config-db-install-password": "Masukkan sandi yang akan digunakan untuk terhubung ke basis data selama proses instalasi.\nIni bukan sandi untuk akun MediaWiki, melainkan sandi untuk basis data Anda.",
+ "config-db-install-help": "Masukkan nama pengguna dan sandi yang akan digunakan untuk terhubung ke basis data pada saat proses instalasi.",
+ "config-db-account-lock": "Gunakan nama pengguna dan kata sandi yang sama selama operasi normal",
+ "config-db-wiki-account": "Akun pengguna untuk operasi normal",
+ "config-db-wiki-help": "Masukkan nama pengguna dan sandi yang akan digunakan untuk terhubung ke basis data wiki selama operasi normal.\nJika akun tidak ada, akun instalasi memiliki hak yang memadai, akun pengguna ini akan dibuat dengan hak akses minimum yang diperlukan untuk mengoperasikan wiki.",
+ "config-db-prefix": "Prefiks tabel basis data:",
+ "config-db-prefix-help": "Jika Anda perlu berbagi satu basis data di antara beberapa wiki, atau antara MediaWiki dan aplikasi web lain, Anda dapat memilih untuk menambahkan prefiks terhadap semua nama tabel demi menghindari konflik.\nJangan gunakan spasi.\n\nPrefiks ini biasanya dibiarkan kosong.",
+ "config-mysql-old": "MySQL $1 atau versi terbaru diperlukan, Anda menggunakan $2.",
+ "config-db-port": "Porta basis data:",
+ "config-db-schema": "Skema untuk MediaWiki",
+ "config-db-schema-help": "Skema ini biasanya berjalan baik.\nUbah hanya jika Anda tahu Anda perlu mengubahnya.",
+ "config-pg-test-error": "Tidak dapat terhubung ke basis data <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Direktori data SQLite:",
+ "config-sqlite-dir-help": "SQLite menyimpan semua data dalam satu berkas.\n\nDirektori yang Anda berikan harus dapat ditulisi oleh server web selama instalasi.\n\nDirektori itu '''tidak''' boleh dapat diakses melalui web, inilah sebabnya kami tidak menempatkannya bersama dengan berkas PHP lain.\n\nPenginstal akan membuat berkas <code>.htaccess</code> bersamaan dengan itu, tetapi jika gagal, orang dapat memperoleh akses ke basis data mentah Anda.\nItu termasuk data mentah pengguna (alamat surel, hash sandi) serta revisi yang dihapus dan data lainnya yang dibatasi pada wiki.\n\nPertimbangkan untuk menempatkan basis data di tempat lain, misalnya di <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Tablespace bawaan:",
+ "config-oracle-temp-ts": "Tablespace sementara:",
+ "config-type-mysql": "MySQL (atau yang kompatibel)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki mendukung sistem basis data berikut:\n\n$1\n\nJika Anda tidak melihat sistem basis data yang Anda gunakan tercantum di bawah ini, ikuti petunjuk terkait di atas untuk mengaktifkan dukungan.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] adalah target utama MediaWiki dan memiliki dukungan terbaik. MediaWiki juga berjalan dengan [{{int:version-db-mariadb-url}} MariaDB] dan [{{int:version-db-percona-url}} Server Percona], yang kompatibel dengan MySQL. ([http://www.php.net/manual/en/mysql.installation.php Cara mengompilasi PHP dengan dukungan MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] adalah sistem basis data sumber terbuka populer sebagai alternatif untuk MySQL. Mungkin ada beberapa bug terbuka dan alternatif ini tidak direkomendasikan untuk dipakai dalam lingkungan produksi. ([http://www.php.net/manual/en/pgsql.installation.php cara mengompilasi PHP dengan dukungan PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] adalah sistem basis data yang ringan yang sangat baik dukungannya. ([http://www.php.net/manual/en/pdo.installation.php cara mengompilasi PHP dengan dukungan SQLite], menggunakan PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] adalah basis data komersial untuk perusahaan. ([http://www.php.net/manual/en/oci8.installation.php cara mengompilasi PHP dengan dukungan OCI8])",
+ "config-dbsupport-mssql": "[{{int:version-db-mssql-url}} Microsoft SQL Server] adalah database perusahaan komersial untuk Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Bagaimana cara mengkompilasi PHP dengan dukungan SQLSRV])",
+ "config-header-mysql": "Pengaturan MySQL",
+ "config-header-postgres": "Pengaturan PostgreSQL",
+ "config-header-sqlite": "Pengaturan SQLite",
+ "config-header-oracle": "Pengaturan Oracle",
+ "config-header-mssql": "Setelan Microsoft SQL Server",
+ "config-invalid-db-type": "Jenis basis data tidak sah",
+ "config-missing-db-name": "Anda harus memasukkan nilai untuk \"{{int:config-db-name}}\"",
+ "config-missing-db-host": "Anda harus memasukkan nilai untuk \"{{int:config-db-host}}\"",
+ "config-missing-db-server-oracle": "Anda harus memasukkan nilai untuk \"{{int:config-db-host-oracle}}\"",
+ "config-invalid-db-server-oracle": "TNS basis data \"$1\" tidak sah.\nGunakan baik \"Nama TNS\" atau string \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Metode Penamaan Oracle]).",
+ "config-invalid-db-name": "Nama basis data \"$1\" tidak sah.\nGunakan hanya huruf ASCII (a-z, A-Z), angka (0-9), garis bawah (_), dan tanda hubung (-).",
+ "config-invalid-db-prefix": "Prefiks basis data \"$1\" tidak sah.\nGunakan hanya huruf ASCII (a-z, A-Z), angka (0-9), garis bawah (_), dan tanda hubung (-).",
+ "config-connection-error": "$1.\n\nPeriksa nama inang, pengguna, dan sandi di bawah ini dan coba lagi.",
+ "config-invalid-schema": "Skema MediaWiki \"$1\" tidak sah.\nGunakan hanya huruf ASCII (a-z, A-Z), angka (0-9), dan garis bawah (_).",
+ "config-db-sys-create-oracle": "Penginstal hanya mendukung penggunaan akun SYSDBA untuk membuat akun baru.",
+ "config-db-sys-user-exists-oracle": "Akun pengguna \"$1\"sudah ada. SYSDBA hanya dapat digunakan untuk membuat akun baru!",
+ "config-postgres-old": "PostgreSQL $1 atau versi terbaru diperlukan, Anda menggunakan $2.",
+ "config-mssql-old": "Microsoft SQL Server $1 atau yang lebih baru dibutuhkan. Anda memiliki versi $2.",
+ "config-sqlite-name-help": "Pilih nama yang mengidentifikasi wiki Anda.\nJangan gunakan spasi atau tanda hubung.\nNama ini akan digunakan untuk nama berkas data SQLite.",
+ "config-sqlite-parent-unwritable-group": "Tidak dapat membuat direktori data <code><nowiki>$1</nowiki></code>, karena direktori induk <code><nowiki>$2</nowiki></code> tidak bisa ditulisi oleh server web.\n\nPenginstal telah menentukan pengguna yang menjalankan server web Anda.\nBuat direktori <code><nowiki>$3</nowiki></code> menjadi dapat ditulisi olehnya.\nPada sistem Unix/Linux lakukan hal berikut:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Tidak dapat membuat direktori data <code><nowiki>$1</nowiki></code>, karena direktori induk <code><nowiki>$2</nowiki></code> tidak bisa ditulisi oleh server web.\n\nPenginstal tidak dapat menentukan pengguna yang menjalankan server web Anda.\nBuat direktori <code><nowiki>$3</nowiki></code> menjadi dapat ditulisi oleh semua orang.\nPada sistem Unix/Linux lakukan hal berikut:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Kesalahan saat membuat direktori data \"$1\".\nPeriksa lokasi dan coba lagi.",
+ "config-sqlite-dir-unwritable": "Tidak dapat menulisi direktori \"$1\".\nUbah hak akses direktori sehingga server web dapat menulis ke sana, dan coba lagi.",
+ "config-sqlite-connection-error": "$1.\n\nPeriksa direktori data dan nama basis data di bawah dan coba lagi.",
+ "config-sqlite-readonly": "Berkas <code>$1</code> tidak dapat ditulisi.",
+ "config-sqlite-cant-create-db": "Tidak dapat membuat berkas basis data <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP tidak memiliki dukungan FTS3, tabel dituruntarafkan.",
+ "config-can-upgrade": "Ada tabel MediaWiki di basis dataini.\nUntuk memperbaruinya ke MediaWiki $1, klik '''Lanjut'''.",
+ "config-upgrade-done": "Pemutakhiran selesai.\n\nAnda sekarang dapat [$1 mulai menggunakan wiki Anda].\n\nJika Anda ingin membuat ulang berkas <code>LocalSettings.php</code>, klik tombol di bawah ini.\nTindakan ini '''tidak dianjurkan''' kecuali jika Anda mengalami masalah dengan wiki Anda.",
+ "config-upgrade-done-no-regenerate": "Pemutakhiran selesai.\n\nAnda sekarang dapat [$1 mulai menggunakan wiki Anda].",
+ "config-regenerate": "Regenerasi LocalSettings.php →",
+ "config-show-table-status": "Kueri <code>SHOW TABLE STATUS</code> gagal!",
+ "config-unknown-collation": "'''Peringatan:''' basis data menggunakan kolasi yang tidak dikenal.",
+ "config-db-web-account": "Akun basis data untuk akses web",
+ "config-db-web-help": "Masukkan nama pengguna dan sandi yang akan digunakan server web untuk terhubung ke server basis data saat operasi normal wiki.",
+ "config-db-web-account-same": "Gunakan akun yang sama seperti untuk instalasi",
+ "config-db-web-create": "Buat akun jika belum ada",
+ "config-db-web-no-create-privs": "Akun Anda berikan untuk instalasi tidak memiliki hak yang cukup untuk membuat akun.\nAkun yang Anda berikan harus sudah ada.",
+ "config-mysql-engine": "Mesin penyimpanan:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Peringatan:</strong> Anda telah memilih MyISAM sebagai mesin penyimpanan MySQL, yang tidak dianjurkan untuk digunakan dengan MediaWiki, karena:\n * nyaris tidak mendukung operasi bersamaan karena penguncian tabel\n * lebih rentan terhadap korupsi daripada mesin lain\n * basis kode MediaWiki tidak selalu menangani MyISAM sebagaimana mestinya\n\nJika instalasi MySQL Anda mendukung InnoDB, sangat disarankan bagi Anda memilih itu.\nJika instalasi MySQL tidak mendukung InnoDB, mungkin sudah waktunya untuk pemutakhiran.",
+ "config-mysql-only-myisam-dep": "<strong>Peringatan:</strong> MyISAM adalah satu-satunya mesin penyimpanan yang tersedia untuk MySQL pada mesin ini, dan hal ini tidak dianjurkan untuk digunakan dengan MediaWiki, karena:\n* hampir tidak mendukung konkurensi karena penguncian tabel\n* basis kode MediaWiki tidak selalu menangani MyISAM sebagaimana mestinya\n\nInstalasi MySQL Anda tidak mendukung InnoDB, mungkin sudah waktunya untuk peningkatan.",
+ "config-mysql-engine-help": "'''InnoDB''' hampir selalu merupakan pilihan terbaik karena memiliki dukungan konkurensi yang baik.\n\n'''MyISAM''' mungkin lebih cepat dalam instalasi pengguna-tunggal atau hanya-baca.\nBasis data MyISAM cenderung lebih sering rusak daripada basis data InnoDB.",
+ "config-mysql-charset": "Set karakter basis data:",
+ "config-mysql-binary": "Biner",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Dalam '''modus biner''', MediaWiki menyimpan teks UTF-8 untuk basis data dalam bidang biner.\nIni lebih efisien daripada modus UTF-8 MySQL dan memungkinkan Anda untuk menggunakan ragam penuh karakter Unicode.\n\nDalam '''modus UTF-8''', MySQL akan tahu apa set karakter data dan dapat menampilkan dan mengubahnya sesuai keperluan, tetapi tidak akan mengizinkan Anda menyimpan karakter di atas [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane].",
+ "config-mssql-auth": "Jenis otentikasi:",
+ "config-mssql-install-auth": "Pilih jenis otentikasi yang akan digunakan untuk menyambung ke database selama proses instalasi.\nJika Anda memilih \"{{int:config-mssql-windowsauth}}\", kredensial dari pengguna apapun pada server web yang berjalan akan digunakan.",
+ "config-mssql-web-auth": "Pilih jenis otentikasi yang akan digunakan oleh server web untuk menyambung ke server basis data, selama operasi biasa dari wiki.\nJika Anda memilih \"{{int:config-mssql-windowsauth}}\", kredensial dari pengguna apapun pada server web yang berjalan akan digunakan.",
+ "config-mssql-sqlauth": "Otentikasi Server SQL",
+ "config-mssql-windowsauth": "Otentikasi Windows",
+ "config-site-name": "Nama wiki:",
+ "config-site-name-help": "Ini akan muncul di bilah judul peramban dan di berbagai tempat lainnya.",
+ "config-site-name-blank": "Masukkan nama situs.",
+ "config-project-namespace": "Ruang nama proyek:",
+ "config-ns-generic": "Proyek",
+ "config-ns-site-name": "Sama seperti nama wiki: $1",
+ "config-ns-other": "Lainnya (sebutkan)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Dengan mengikuti contoh Wikipedia, banyak wiki menyimpan halaman kebijakan mereka terpisah dari halaman konten mereka, dalam '''ruang nama proyek'''.\nSemua judul halaman dalam ruang nama ini diawali dengan awalan tertentu yang dapat Anda tetapkan di sini.\nBiasanya, awalan ini berasal dari nama wiki, tetapi tidak dapat berisi karakter tanda baca seperti \"#\" atau \":\".",
+ "config-ns-invalid": "Ruang nama \"<nowiki>$1</nowiki>\" yang ditentukan tidak sah.\nBerikan ruang nama proyek lain.",
+ "config-ns-conflict": "Ruang nama \"<nowiki>$1</nowiki>\" yang diberikan berkonflik dengan ruang nama bawaan MediaWiki.\nTentukan ruang nama proyek yang berbeda.",
+ "config-admin-box": "Akun pengurus",
+ "config-admin-name": "Nama pengguna:",
+ "config-admin-password": "Kata sandi:",
+ "config-admin-password-confirm": "Kata sandi lagi:",
+ "config-admin-help": "Masukkan nama pengguna pilihan Anda di sini, misalnya \"Udin Wiki\".\nIni adalah nama yang akan Anda gunakan untuk masuk ke wiki.",
+ "config-admin-name-blank": "Masukkan nama pengguna pengurus.",
+ "config-admin-name-invalid": "Nama pengguna \"<nowiki>$1</nowiki>\" yang diberikan tidak sah.\nBerikan nama pengguna lain.",
+ "config-admin-password-blank": "Masukkan kata sandi untuk akun pengurus.",
+ "config-admin-password-mismatch": "Dua kata sandi yang Anda masukkan tidak cocok.",
+ "config-admin-email": "Alamat surel:",
+ "config-admin-email-help": "Masukkan alamat surel untuk memungkinkan Anda menerima surel dari pengguna lain, menyetel ulang sandi, dan mendapat pemberitahuan tentang perubahan atas daftar pantauan Anda. Anda dapat mengosongkan bidang ini.",
+ "config-admin-error-user": "Kesalahan internal saat membuat admin dengan nama \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Kesalahan internal saat membuat sandi untuk admin \"<nowiki>$1</nowiki>\":<pre>$2</pre>",
+ "config-admin-error-bademail": "Anda memasukkan alamat surel yang tidak sah",
+ "config-subscribe": "Berlangganan ke [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce milis pengumuman rilis].",
+ "config-subscribe-help": "Ini adalah milis bervolume rendah yang digunakan untuk pengumuman rilis, termasuk pengumuman keamanan penting.\nAnda sebaiknya berlangganan dan memperbarui instalasi MediaWiki saat versi baru keluar.",
+ "config-subscribe-noemail": "Anda mencoba untuk berlangganan milis pengumuman rilis tanpa menyediakan alamat email.\nHarap berikan alamat surel jika Anda ingin berlangganan ke milis.",
+ "config-pingback": "Bagikan data tentang instalasi ini dengan pengembang MediaWiki",
+ "config-almost-done": "Anda hampir selesai!\nAnda sekarang dapat melewati sisa konfigurasi dan menginstal wiki sekarang.",
+ "config-optional-continue": "Berikan saya pertanyaan lagi.",
+ "config-optional-skip": "Saya sudah bosan, instal saja wikinya.",
+ "config-profile": "Profil hak pengguna:",
+ "config-profile-wiki": "Wiki terbuka",
+ "config-profile-no-anon": "Pembuatan akun diperlukan",
+ "config-profile-fishbowl": "Khusus penyunting terdaftar",
+ "config-profile-private": "Wiki pribadi",
+ "config-profile-help": "Wiki paling baik bekerja jika Anda membiarkan sebanyak mungkin orang untuk menyunting. Dengan MediaWiki, sangat mudah meninjau perubahan terbaru dan mengembalikan kerusakan yang dilakukan oleh pengguna naif atau berbahaya.\n\nNamun, berbagai kegunaan lain dari MediaWiki telah ditemukan, dan kadang tidak mudah untuk meyakinkan semua orang manfaat dari cara wiki. Jadi, Anda yang menentukan.\n\n'''{{int:config-profile-wiki}}''' memungkinkan setiap orang untuk menyunting, bahkan tanpa masuk log.\n'''{{int:config-profile-no-anon}}''' menyediakan akuntabilitas tambahan, tetapi dapat mengurangi semangat kontributor sambil lalu.\n\n'''{{int:config-profile-fishbowl}}''' memungkinkan pengguna yang disetujui untuk menyunting, tetapi publik dapat melihat halaman, termasuk riwayatnya.\n'''{{int:config-profile-private}}''' hanya memungkinkan pengguna yang disetujui untuk melihat dan menyunting halaman.\n\nKonfigurasi hak pengguna yang lebih kompleks tersedia setelah instalasi. Lihat [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights/id entri manual terkait].",
+ "config-license": "Hak cipta dan lisensi:",
+ "config-license-none": "Tidak ada lisensi",
+ "config-license-cc-by-sa": "Creative Commons Atribusi Berbagi Serupa",
+ "config-license-cc-by": "Creative Commons Atribusi",
+ "config-license-cc-by-nc-sa": "Creative Commons Atribusi Nonkomersial Berbagi Serupa",
+ "config-license-cc-0": "Creative Commons Zero (Domain Publik)",
+ "config-license-gfdl": "Lisensi Dokumentasi Bebas GNU 1.3 atau versi terbaru",
+ "config-license-pd": "Domain Umum",
+ "config-license-cc-choose": "Pilih lisensi Creative Commons kustom",
+ "config-license-help": "Banyak wiki publik melisensikan semua kontribusi di bawah [http://freedomdefined.org/Definition lisensi bebas].\nHal ini membantu menciptakan rasa kepemilikan komunitas dan mendorong kontribusi jangka panjang.\nHal ini umumnya tidak diperlukan untuk wiki pribadi atau perusahaan.\n\nJika Anda ingin dapat menggunakan teks dari Wikipedia dan Anda ingin agar Wikipedia dapat menerima teks yang disalin dari wiki Anda, Anda harus memilih <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia sebelumnya menggunakan GNU Free Documentation License.\nLisensi ini masih sah, namun sulit dipahami.\nSelain itu, sulit untuk menggunakan ulang konten yang dilisensikan di bawah GFDL.",
+ "config-email-settings": "Pengaturan surel",
+ "config-enable-email": "Aktifkan surel keluar",
+ "config-enable-email-help": "Jika Anda ingin mengaktifkan surel, [http://www.php.net/manual/en/mail.configuration.php setelah surel PHP] perlu dikonfigurasi dengan benar.\nJika Anda tidak perlu fitur surel, Anda dapat menonaktifkannya di sini.",
+ "config-email-user": "Aktifkan surel antarpengguna",
+ "config-email-user-help": "Memungkinkan semua pengguna untuk saling berkirim surel jika mereka mengaktifkan pilihan tersebut dalam preferensi mereka.",
+ "config-email-usertalk": "Aktifkan pemberitahuan perubahan halaman pembicaraan pengguna",
+ "config-email-usertalk-help": "Memungkinkan pengguna untuk menerima pemberitahuan tentang perubahan halaman pembicaraan pengguna, jika pilihan tersebut telah diaktifkan dalam preferensi mereka.",
+ "config-email-watchlist": "Aktifkan pemberitahuan daftar pantau",
+ "config-email-watchlist-help": "Memungkinkan pengguna untuk menerima pemberitahuan tentang perubahan halaman yang ada dalam daftar pantauan mereka, jika pilihan tersebut telah diaktifkan dalam preferensi mereka.",
+ "config-email-auth": "Aktifkan otentikasi surel",
+ "config-email-auth-help": "Jika opsi ini diaktifkan, pengguna harus mengonfirmasi alamat surel dengan menggunakan pranala yang dikirim kepadanya setiap kali mereka mengatur atau mengubahnya.\nHanya alamat surel yang dikonfirmasi yang dapat menerima surel dari pengguna lain atau surel pemberitahuan perubahan.\nPenetapan opsi ini '''direkomendasikan''' untuk wiki publik karena adanya potensi penyalahgunaan fitur surel.",
+ "config-email-sender": "Alamat surel balasan:",
+ "config-email-sender-help": "Masukkan alamat surel untuk digunakan sebagai alamat pengirim pada surel keluar.\nAlamat ini akan menerima pentalan.\nBanyak server surel mensyaratkan paling tidak bagian nama domain yang sah.",
+ "config-upload-settings": "Pengunggahan gambar dan berkas",
+ "config-upload-enable": "Aktifkan pengunggahan berkas",
+ "config-upload-help": "Pengunggahan berkas berpotensi memaparkan server Anda dengan risiko keamanan.\nUntuk informasi lebih lanjut, baca [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security/id manual keamanan].\n\nUntuk mengaktifkan pengunggahan berkas, ubah modus subdirektori <code>images</code> di bawah direktori akar MediaWiki agar server web dapat menulis ke sana.\nKemudian aktifkan opsi ini.",
+ "config-upload-deleted": "Direktori untuk berkas terhapus:",
+ "config-upload-deleted-help": "Pilih direktori tempat mengarsipkan berkas yang dihapus.\nIdealnya, direktori ini tidak boleh dapat diakses dari web.",
+ "config-logo": "URL logo:",
+ "config-logo-help": "Kulit bawaan MediaWiki memberikan ruang untuk logo berukuran 135x160 piksel di atas menu bilah samping.\nUnggah gambar dengan ukuran yang sesuai, lalu masukkan URL di sini.\n\nAnda dapat menggunakan <code>$wgStylePath</code> atau <code>$wgScriptPath</code> jika logo Anda relatif terhadap jalur (path) ini.\n\nJika Anda tidak ingin menyertakan logo, biarkan kotak ini kosong.",
+ "config-instantcommons": "Aktifkan Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] adalah fitur yang memungkinkan wiki untuk menggunakan gambar, suara, dan media lain dari [https://commons.wikimedia.org/ Wikimedia Commons].\nUntuk melakukannya, MediaWiki memerlukan akses ke Internet.\n\nUntuk informasi lebih lanjut tentang fitur ini, termasuk petunjuk tentang cara untuk mengatur untuk wiki selain Wikimedia Commons, baca [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos manual].",
+ "config-cc-error": "Pemilih lisensi Creative Commons tidak memberikan hasil.\nMasukkan nama lisensi secara manual.",
+ "config-cc-again": "Pilih lagi...",
+ "config-cc-not-chosen": "Pilih lisensi Creative Commons yang Anda inginkan dan klik \"proceed\".",
+ "config-advanced-settings": "Konfigurasi lebih lanjut",
+ "config-cache-options": "Pengaturan untuk penyinggahan objek:",
+ "config-cache-help": "Penyinggahan objek digunakan untuk meningkatkan kecepatan MediaWiki dengan menyinggahkan data yang sering digunakan.\nSitus berukuran sedang hingga besar sangat dianjurkan untuk mengaktifkan fitur ini, dan situs kecil juga akan merasakan manfaatnya.",
+ "config-cache-none": "Tidak ada penyinggahan (tidak ada fungsi yang dibuang, tetapi kecepatan dapat terpengaruh pada situs wiki yang besar)",
+ "config-cache-accel": "Penyinggahan objek PHP (APC, XCache atau WinCache)",
+ "config-cache-memcached": "Gunakan Memcached (memerlukan setup dan konfigurasi tambahan)",
+ "config-memcached-servers": "Server Memcached:",
+ "config-memcached-help": "Daftar alamat IP yang digunakan untuk Memcached.\nHarus dispesifikasikan per baris berikut porta yang akan digunakan. Contoh:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Anda memilih Memcached sebagai jenis singgahan, tetapi tidak menentukan server apa pun.",
+ "config-memcache-badip": "Anda memasukkan alamat IP yang tidak sah untuk Memcached: $1 .",
+ "config-memcache-noport": "Anda tidak menentukan suatu porta untuk digunakan oleh server Memcached: $1.\nJika Anda tidak tahu porta tersebut, porta bawaan adalah 11211.",
+ "config-memcache-badport": "Nomor porta Memcached harus antara $1 dan $2.",
+ "config-extensions": "Ekstensi",
+ "config-extensions-help": "Ekstensi yang tercantum di atas terdeteksi di direktori <code>./extensions</code>.\n\nEkstensi tersebut mungkin memerlukan konfigurasi tambahan, tetapi Anda dapat mengaktifkannya sekarang.",
+ "config-skins": "Tampilan",
+ "config-skins-help": "Kulit yang tersedia di atas terdeteksi dalam direktori <code>./skins</code> Anda. Anda harus mengaktifkan sedikitnya satu kulit, dan pilih kulit baku.",
+ "config-skins-use-as-default": "Gunakan tampilan ini secara baku",
+ "config-skins-missing": "Tidak ada kulit ditemukan; MediaWiki akan menggunakan skin cadangan hingga Anda memasang kulit yang cocok.",
+ "config-skins-must-enable-some": "Anda wajib memiliki sedikitnya satu kulit untuk diaktifkan.",
+ "config-skins-must-enable-default": "Kulit yang dipilih sebagai kulit baku harus diaktifkan.",
+ "config-install-alreadydone": "'''Peringatan:''' Anda tampaknya telah menginstal MediaWiki dan mencoba untuk menginstalnya lagi.\nLanjutkan ke halaman berikutnya.",
+ "config-install-begin": "Dengan menekan \"{{int:config-continue}}\", Anda akan memulai instalasi MediaWiki.\nJika Anda masih ingin membuat perubahan, tekan \"{{int:config-back}}\".",
+ "config-install-step-done": "selesai",
+ "config-install-step-failed": "gagal",
+ "config-install-extensions": "Termasuk ekstensi",
+ "config-install-database": "Menyiapkan basis data",
+ "config-install-schema": "Membuat skema",
+ "config-install-pg-schema-not-exist": "Skema PostgreSQL tidak tersedia.",
+ "config-install-pg-schema-failed": "Pembuatan tabel gagal.\nPastikan bahwa pengguna \"$1\" dapat menulis ke skema \"$2\".",
+ "config-install-pg-commit": "Melakukan perubahan",
+ "config-install-pg-plpgsql": "Memeriksa bahasa PL / pgSQL",
+ "config-pg-no-plpgsql": "Anda perlu menginstal bahasa PL/pgSQL pada basis data $1",
+ "config-pg-no-create-privs": "Akun yang Anda tetapkan untuk instalasi tidak memiliki hak yang cukup untuk membuat akun.",
+ "config-pg-not-in-role": "Akun yang ditentukan untuk pengguna web sudah ada.\nAkun yang ditentukan untuk instalasi tidak superuser dan bukan anggota dari peran pengguna Web, sehingga tidak dapat membuat objek yang dimiliki oleh pengguna web.\n\nMediaWiki saat ini membutuhkan bahwa tabel dimiliki oleh pengguna web. Silakan tentukan nama account web lain, atau klik \"back\" dan tentukan pengguna yang terinstal sesuai istimewa.",
+ "config-install-user": "Membuat pengguna basis data",
+ "config-install-user-alreadyexists": "Pengguna \"$1\" sudah ada",
+ "config-install-user-create-failed": "Pembuatan pengguna \"$1\" gagal: $2",
+ "config-install-user-grant-failed": "Memberikan izin untuk pengguna \"$1\" gagal: $2",
+ "config-install-user-missing": "Pengguna \"$1\" yang dimaksud tidak ditemukan.",
+ "config-install-user-missing-create": "Akun yang ditentukan \"$1\" tidak ada.\nSilahkan klik kotak centang \"Buat akun\" di bawah ini jika Anda ingin membuatnya.",
+ "config-install-tables": "Membuat tabel",
+ "config-install-tables-exist": "'''Peringatan''': Tabel MediaWiki sepertinya sudah ada.\nMelompati pembuatan.",
+ "config-install-tables-failed": "'''Kesalahan''': Pembuatan tabel gagal dengan kesalahan berikut: $1",
+ "config-install-interwiki": "Mengisi tabel bawaan antarwiki",
+ "config-install-interwiki-list": "Tidak dapat menemukan berkas <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Peringatan''': Tabel antarwiki tampaknya sudah memiliki entri.\nMengabaikan daftar bawaan.",
+ "config-install-stats": "Inisialisasi statistik",
+ "config-install-keys": "Membuat kunci rahasia",
+ "config-insecure-keys": "'''Peringatan:''' {{PLURAL:$2|Suatu|Beberapa}} kunci aman ($1) yang dibuat selama instalasi {{PLURAL:$2|tidak|tidak}} benar-benar aman. Pertimbangkan untuk mengubah {{PLURAL:$2|kunci|kunci-kunci}} tersebut secara manual.",
+ "config-install-updates": "Cegah jalannya pembaruan yang tidak dibutuhkan",
+ "config-install-updates-failed": "<strong>Kesalahan:</strong> Memasukkan kunci pembaruan ke dalam tabel gagal dengan kode kesalahan: $1",
+ "config-install-sysop": "Membuat akun pengguna pengurus",
+ "config-install-subscribe-fail": "Tidak dapat berlangganan mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL tidak diinstal dan <code>allow_url_fopen</code> tidak tersedia.",
+ "config-install-mainpage": "Membuat halaman utama dengan konten bawaan",
+ "config-install-extension-tables": "Pembuatan tabel untuk ekstensi yang diaktifkan",
+ "config-install-mainpage-failed": "Tidak dapat membuat halaman utama: $1",
+ "config-install-done": "<strong>Selamat!</strong>\nAnda telah berhasil menginstal MediaWiki.\n\nPemasang telah membuat sebuah berkas <code>LocalSettings.php</code>.\nBerkas itu berisi semua setelan Anda.\n\nAnda perlu mengunduh berkas itu dan meletakkannya di direktori instalasi wiki (direktori yang sama dengan index.php). Pengunduhan akan dimulai secara otomatis.\n\nJika pengunduhan tidak terjadi, atau jika Anda membatalkannya, Anda dapat mengulangi pengunduhan dengan mengeklik tautan berikut:\n\n$3\n\n<strong>Catatan</strong>: Jika Anda tidak melakukannya sekarang, berkas konfigurasi yang dihasilkan ini tidak akan tersedia lagi setelah Anda keluar dari proses instalasi tanpa mengunduhnya.\n\nSetelah melakukannya, Anda dapat <strong>[$2 memasuki wiki Anda]</strong>.",
+ "config-download-localsettings": "Unduh <code>LocalSettings.php</code>",
+ "config-help": "bantuan",
+ "config-help-tooltip": "klik untuk memperluas",
+ "config-nofile": "Berkas \"$1\" tidak dapat ditemukan. Mungkin sudah dihapus?",
+ "config-extension-link": "Tahukah Anda bahwa wiki Anda mendukung [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions ekstensi]?\n\nAnda dapat menjelajahi [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category ekstensi menurut kategori] atau [https://www.mediawiki.org/wiki/Extension_Matrix Ekstensi Matriks] untuk melihat daftar lengkap ekstensi.",
+ "mainpagetext": "<strong>MediaWiki telah terpasang dengan sukses.</strong>",
+ "mainpagedocfooter": "Silakan baca [https://www.mediawiki.org/wiki/Help:Contents Panduan Pengguna] untuk cara penggunaan perangkat lunak wiki ini.\n\n== Memulai penggunaan ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings/id Daftar pengaturan konfigurasi]\n* [https://www.mediawiki.org/wiki/Manual:FAQ/id Daftar pertanyaan yang sering diajukan mengenai MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Milis rilis MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Terjemahkan MediaWiki ke bahasa Anda]"
+}
diff --git a/www/wiki/includes/installer/i18n/ie.json b/www/wiki/includes/installer/i18n/ie.json
new file mode 100644
index 00000000..32bcfd8a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ie.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''Software del wiki installat con successe.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/ig.json b/www/wiki/includes/installer/i18n/ig.json
new file mode 100644
index 00000000..f8269da0
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ig.json
@@ -0,0 +1,17 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ukabia"
+ ]
+ },
+ "config-back": "← Laàzú",
+ "config-continue": "Gawazie →",
+ "config-page-language": "Ásụ̀sụ̀",
+ "config-page-name": "Áhà",
+ "config-page-install": "Sụ̀ímé",
+ "config-restart": "Eeh, bìdówárí ya.",
+ "config-admin-password": "Okwúngáfè:",
+ "config-admin-password-confirm": "Okwúngáfè mgbe ozor:",
+ "mainpagetext": "'''MediaWiki a banyélé nke oma.'''",
+ "mainpagedocfooter": "Gbàkpó [https://meta.wikimedia.org/wiki/Help:Contents Ǹdù Ọ'bànifé] màkà ụmá màkà Í jí ngwa nsónùsòrò bu wiki.\n\n== I bídó ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Ndétu ndósé ihe]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce wéfù ndétu nke ozi MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/ilo.json b/www/wiki/includes/installer/i18n/ilo.json
new file mode 100644
index 00000000..edb44f16
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ilo.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''Sibaballigi a nainstolar ti MediaWiki.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/inh.json b/www/wiki/includes/installer/i18n/inh.json
new file mode 100644
index 00000000..8ad43474
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/inh.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "Умар",
+ "ElizaMag",
+ "Adam-Yourist"
+ ]
+ },
+ "config-localsettings-key": "Кердадаккхара дIоагIа:",
+ "config-localsettings-badkey": "Iа белгалдаьккхад харцахь дола кердадаккхара дIоагIа.",
+ "config-help": "новкъoстал"
+}
diff --git a/www/wiki/includes/installer/i18n/io.json b/www/wiki/includes/installer/i18n/io.json
new file mode 100644
index 00000000..aa6395e1
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/io.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Wyvernoid"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki instalesis sucese.'''",
+ "mainpagedocfooter": "Videz la [https://meta.wikimedia.org/wiki/Help:Contents Guidilo por Uzanti] por informo pri uzar la wiki programo.\n\n== Komencar ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Listo di ''Configuration setting'']\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki OQQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki nova versioni posto-listo]"
+}
diff --git a/www/wiki/includes/installer/i18n/is.json b/www/wiki/includes/installer/i18n/is.json
new file mode 100644
index 00000000..37f243da
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/is.json
@@ -0,0 +1,103 @@
+{
+ "@metadata": {
+ "authors": [
+ "Snævar",
+ "Sveinn í Felli"
+ ]
+ },
+ "config-desc": "Uppsetningarforrit fyrir MediaWiki",
+ "config-title": "MediaWiki $1 uppsetning",
+ "config-information": "Upplýsingar",
+ "config-localsettings-key": "Uppfærslulykill:",
+ "config-session-error": "Villa við að ræsa setu: $1",
+ "config-your-language": "Tungumálið þitt:",
+ "config-your-language-help": "Veldu tungumál að nota við uppsetninguna.",
+ "config-wiki-language": "Tungumál á wiki:",
+ "config-back": "← Til baka",
+ "config-continue": "Halda áfram →",
+ "config-page-language": "Tungumál",
+ "config-page-welcome": "Velkomin í MediaWiki!",
+ "config-page-dbconnect": "Tengjast gagnagrunni",
+ "config-page-upgrade": "Uppfæra núverandi kerfi",
+ "config-page-dbsettings": "Gagnagrunnsstillingar",
+ "config-page-name": "Heiti",
+ "config-page-options": "Valkostir",
+ "config-page-install": "Setja upp",
+ "config-page-complete": "Lokið!",
+ "config-page-restart": "Byrja uppsetningu aftur",
+ "config-page-readme": "Lesa meira",
+ "config-page-releasenotes": "Athugasemdir með útgáfu",
+ "config-page-copying": "Afritun",
+ "config-page-upgradedoc": "Uppfærsla",
+ "config-page-existingwiki": "Fyrirliggjandi wiki",
+ "config-restart": "Já, endurræsa",
+ "config-copyright": "=== Höfundarréttur og skilmálar ===\n\n$1\n\nÞetta er frjáls hugbúnaður; þú mátt dreifa honum og/eða breyta samkvæmt skilmálum í almenna GNU GPL notkunarleyfinu eins og það er gefið út af Frjálsu hugbúnaðarstofnuninni; annaðhvort útgáfu 2 af GPL-leyfinu, eða (ef þér sýnist svo) einhverri nýrri útgáfu leyfisins.\n\nHugbúnaði þessum er dreift í þeirri von að hann geti verið gagnlegur, en <strong>ÁN ALLRAR ÁBYRGÐAR</strong>; einnig án þeirrar ábyrgðar sem gefin er í skyn með <strong>SELJANLEIKA</strong> eða <strong>EIGINLEIKUM TIL TILTEKINNA NOTA</strong>. Sjá almenna GNU GPL notkunarleyfið fyrir nánari upplýsingar.\n\nÞað ætti að hafa fylgt afrit af almenna <doclink href=Copying>GNU GPL notkunarleyfinu</doclink> með forritinu; ef ekki skrifið þá Fjálsu hugbúnarstofnuninni: Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, eða [http://www.gnu.org/copyleft/gpl.html lestu það á netinu].",
+ "config-env-php": "PHP $1 er uppsett.",
+ "config-env-hhvm": "HHVM $1 er uppsett.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] er uppsett",
+ "config-apc": "[http://www.php.net/apc APC] er uppsett",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] er uppsett",
+ "config-diff3-bad": "GNU diff3 fannst ekki.",
+ "config-using-server": "Nota \"<nowiki>$1</nowiki>\" sem heiti á þjóni.",
+ "config-using-uri": "Nota \"<nowiki>$1$2</nowiki>\" sem slóð á þjón.",
+ "config-db-type": "Tegund gagnagrunns:",
+ "config-db-host": "Netþjónn gagnagrunns:",
+ "config-db-name": "Heiti gagnagrunns:",
+ "config-db-name-oracle": "Gagnagrunnsskema:",
+ "config-db-username": "Notandanafn á gagnagrunni:",
+ "config-db-password": "Lykilorð gagnagrunns:",
+ "config-db-port": "Gátt gagnagrunns:",
+ "config-sqlite-dir": "Gagnamappa SQLite:",
+ "config-type-mysql": "MySQL (eða samhæft)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "Stillingar MySQL",
+ "config-header-postgres": "Stillingar PostgreSQL",
+ "config-header-sqlite": "Stillingar SQLite",
+ "config-header-oracle": "Stillingar Oracle",
+ "config-header-mssql": "Stillingar Microsoft SQL Server",
+ "config-regenerate": "Endurgera LocalSettings.php →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code> beiðni mistókst!",
+ "config-db-web-account": "Gagnagrunnsreikningur fyrir vefaðgang",
+ "config-mysql-engine": "Gagnagrunnshýsing:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Stafatafla gagnagrunns:",
+ "config-mysql-binary": "Tvíundakerfis",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Tegund auðkenningar:",
+ "config-mssql-sqlauth": "SQL Server auðkenning",
+ "config-mssql-windowsauth": "Windows auðkenning",
+ "config-ns-generic": "Verkefni",
+ "config-admin-name": "Notandanafnið þitt:",
+ "config-admin-password": "Lykilorð:",
+ "config-admin-password-confirm": "Lykilorðið aftur:",
+ "config-admin-email": "Tölvupóstfang:",
+ "config-profile": "Snið notandaréttinda:",
+ "config-profile-wiki": "Opið wiki",
+ "config-profile-no-anon": "Stofnun aðgangs krafist",
+ "config-profile-fishbowl": "Aðeins auðkenndir ritstjórar",
+ "config-profile-private": "Einkawiki",
+ "config-license": "Höfundaréttur og notkunarleyfi:",
+ "config-license-none": "Ekki síðufótur með notkunarleyfi",
+ "config-license-cc-by-sa": "Creative Commons: Höfundar getið - Deilist áfram",
+ "config-license-cc-by": "Creative Commons: Höfundar getið",
+ "config-license-cc-by-nc-sa": "Creative Commons: Höfundar getið - Ekki í ágóðaskyni - Deilist áfram",
+ "config-license-cc-0": "Creative Commons Zero leyfi (almenningseign)",
+ "config-license-gfdl": "Frjálsa GNU-handbókarleyfið, útgáfa 1.3 eða nýrri",
+ "config-license-pd": "Almenningseign (Public Domain)",
+ "config-license-cc-choose": "Veldu sérsniðið Creative Commons notkunarleyfi",
+ "config-extensions": "Viðbætur",
+ "config-skins": "Skinn",
+ "config-install-step-done": "lokið",
+ "config-install-step-failed": "mistókst",
+ "config-install-pg-commit": "Virkja breytingar",
+ "config-install-tables": "Töflur búnar til",
+ "config-download-localsettings": "Ná í <code>LocalSettings.php</code>",
+ "config-help": "hjálp",
+ "config-help-tooltip": "Smella til að þenja út",
+ "mainpagetext": "'''Uppsetning á MediaWiki heppnaðist.'''",
+ "mainpagedocfooter": "Ráðfærðu þig við [https://meta.wikimedia.org/wiki/Help:Contents Notandahandbókina] fyrir frekari upplýsingar um notkun wiki-hugbúnaðarins.\n\n== Fyrir byrjendur ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Listi yfir uppsetningarstillingar]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki Algengar spurningar MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Póstlisti MediaWiki-útgáfa]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Læra hvernig á að berjast við amapóst á þínum wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/it.json b/www/wiki/includes/installer/i18n/it.json
new file mode 100644
index 00000000..5453e453
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/it.json
@@ -0,0 +1,331 @@
+{
+ "@metadata": {
+ "authors": [
+ "Beta16",
+ "Darth Kule",
+ "F. Cosoleto",
+ "Gianfranco",
+ "Karika",
+ "아라",
+ "Lucas2",
+ "Ontsed",
+ "Seb35",
+ "Nemo bis",
+ "Ricordisamoa",
+ "Fpugliajno",
+ "The Polish",
+ "Sannita",
+ "C.R.",
+ "Macofe",
+ "Matteocng",
+ "Einreiher",
+ "Tosky",
+ "Selven"
+ ]
+ },
+ "config-desc": "Programma di installazione per MediaWiki",
+ "config-title": "Installazione di MediaWiki $1",
+ "config-information": "Informazioni",
+ "config-localsettings-upgrade": "È stato rilevato un file <code>LocalSettings.php</code>.\nPer aggiornare questa installazione, si prega di inserire il valore di <code>$wgUpgradeKey</code> nella casella qui sotto.\nLo potete trovare in <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "È stato rilevato un file <code>LocalSettings.php</code>.\nPer aggiornare questa installazione, eseguire <code>update.php</code>",
+ "config-localsettings-key": "Chiave di aggiornamento:",
+ "config-localsettings-badkey": "La chiave di aggiornamento che hai fornito non è corretta.",
+ "config-upgrade-key-missing": "È stata rilevata un'installazione esistente di MediaWiki.\nPer aggiornare questa installazione, si prega di inserire la seguente riga nella parte inferiore del tuo <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Il file <code>LocalSettings.php</code> esistente sembra essere incompleto.\nLa variabile $1 non è impostata.\nCambia <code>LocalSettings.php</code> in modo che questa variabile sia impostata e fai clic su \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Si è verificato un errore durante la connessione al database utilizzando le impostazioni specificate in <code>LocalSettings.php</code>. Si prega di correggere queste impostazioni e riprovare.\n\n$1",
+ "config-session-error": "Errore nell'avvio della sessione: $1",
+ "config-session-expired": "I dati della sessione sembrano essere scaduti.\nLe sessioni sono configurate per una durata di $1.\nPuoi aumentarla impostando <code>session.gc_maxlifetime</code> nel file php.ini.\nRiavvia il processo di installazione.",
+ "config-no-session": "I dati della sessione sono andati persi!\nControlla il tuo file php.ini ed assicurati che <code>session.save_path</code> è impostato su una directory appropriata.",
+ "config-your-language": "La tua lingua:",
+ "config-your-language-help": "Seleziona una lingua da utilizzare durante il processo di installazione.",
+ "config-wiki-language": "La lingua del wiki:",
+ "config-wiki-language-help": "Seleziona la lingua che verrà prevalentemente usata nel wiki.",
+ "config-back": "← Indietro",
+ "config-continue": "Continua →",
+ "config-page-language": "Lingua",
+ "config-page-welcome": "Benvenuti in MediaWiki!",
+ "config-page-dbconnect": "Connessione al database",
+ "config-page-upgrade": "Aggiornamento dell'installazione esistente",
+ "config-page-dbsettings": "Impostazioni del database",
+ "config-page-name": "Nome",
+ "config-page-options": "Opzioni",
+ "config-page-install": "Installa",
+ "config-page-complete": "Completa!",
+ "config-page-restart": "Riavvio installazione",
+ "config-page-readme": "Leggimi",
+ "config-page-releasenotes": "Note di versione",
+ "config-page-copying": "Copia",
+ "config-page-upgradedoc": "Aggiornamento",
+ "config-page-existingwiki": "Wiki esistenti",
+ "config-help-restart": "Vuoi cancellare tutti i dati salvati che hai inserito e riavviare il processo di installazione?",
+ "config-restart": "Sì, riavvia",
+ "config-welcome": "=== Controllo dell'ambiente ===\nSaranno eseguiti controlli di base per vedere se questo ambiente è adatto per l'installazione di MediaWiki.\nRicordati di includere queste informazioni se chiedi assistenza su come completare l'installazione.",
+ "config-copyright": "=== Copyright e termini ===\n\n$1\n\nQuesto programma è un software libero; puoi redistribuirlo e/o modificarlo secondo i termini della GNU General Public License, come pubblicata dalla Free Software Foundation; o la versione 2 della Licenza o (a propria scelta) qualunque versione successiva.\n\nQuesto programma è distribuito nella speranza che sia utile, ma SENZA ALCUNA GARANZIA; senza neppure la garanzia implicita di NEGOZIABILITÀ o di APPLICABILITÀ PER UN PARTICOLARE SCOPO.\nSi veda la GNU General Public License per maggiori dettagli.\n\nQuesto programma deve essere distribuito assieme ad <doclink href=Copying>una copia della GNU General Public License</doclink>; in caso contrario, se ne può ottenere una scrivendo alla Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA oppure [http://www.gnu.org/copyleft/gpl.html leggerla in rete].",
+ "config-sidebar": "* [https://www.mediawiki.org Pagina principale MediaWiki]\n* [https://www.mediawiki.org/wiki/Aiuto:Guida ai contenuti per utenti]\n* [https://www.mediawiki.org/wiki/Manuale:Guida ai contenuti per admin]\n* [https://www.mediawiki.org/wiki/Manuale:FAQ FAQ]\n----\n* <doclink href=Readme>Leggimi</doclink>\n* <doclink href=ReleaseNotes>Note di versione</doclink>\n* <doclink href=Copying>Copie</doclink>\n* <doclink href=UpgradeDoc>Aggiornamenti</doclink>",
+ "config-env-good": "L'ambiente è stato controllato.\nÈ possibile installare MediaWiki.",
+ "config-env-bad": "L'ambiente è stato controllato.\nNon è possibile installare MediaWiki.",
+ "config-env-php": "PHP $1 è installato.",
+ "config-env-hhvm": "HHVM $1 è installato.",
+ "config-unicode-using-intl": "Usa [http://pecl.php.net/intl l'estensione PECL intl] per la normalizzazione Unicode.",
+ "config-unicode-pure-php-warning": "'''Attenzione:''' [http://pecl.php.net/intl l'estensione PECL intl] non è disponibile per gestire la normalizzazione Unicode, quindi si torna alla lenta implementazione in PHP puro.\nSe esegui un sito ad alto traffico, dovresti leggere alcune considerazioni sulla [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzazione Unicode].",
+ "config-unicode-update-warning": "'''Attenzione:''' la versione installata del gestore per la normalizzazione Unicode usa una vecchia versione della libreria [http://site.icu-project.org/ del progetto ICU].\nDovresti [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations aggiornare] se vuoi usare l'Unicode.",
+ "config-no-db": "Impossibile trovare un driver adatto per il database! È necessario installare un driver per PHP.\n{{PLURAL:$2|Il seguente formato di database è supportato|I seguenti formati di database sono supportati}}: $1.\n\nSe compili PHP autonomamente, riconfiguralo attivando un client database, per esempio utilizzando <code>./configure --with-mysqli</code>.\nQualora avessi installato PHP per mezzo di un pacchetto Debian o Ubuntu, allora devi installare anche il pacchetto <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Attenzione''': è presente SQLite $1 mentre è richiesta la versione $2, SQLite non sarà disponibile.",
+ "config-no-fts3": "'''Attenzione''': SQLite è compilato senza il [//sqlite.org/fts3.html modulo FTS3], le funzionalità di ricerca non saranno disponibili su questo backend.",
+ "config-pcre-old": "<strong>Errore fatale:</strong> si richiede PCRE $1 o successivo.\nIl tuo file binario PHP è collegato con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/Maggiori informazioni su PCRE].",
+ "config-pcre-no-utf8": "'''Errore''': Il modulo PCRE di PHP sembra essere stato compilato senza il supporto PCRE_UTF8, ma MediaWiki lo richiede per funzionare correttamente.",
+ "config-memory-raised": "Il valore <code>memory_limit</code> di PHP è $1, aumentato a $2.",
+ "config-memory-bad": "''Attenzione:''' Il valore di <code>memory_limit</code> di PHP è $1.\nProbabilmente è troppo basso.\nL'installazione potrebbe non riuscire!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] è installato",
+ "config-apc": "[http://www.php.net/apc APC] è installato",
+ "config-apcu": "[http://www.php.net/apc APC] è installato",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] è installato",
+ "config-no-cache-apcu": "'''Attenzione:''' [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] o [http://www.iis.net/download/WinCacheForPhp WinCache] non sono stati trovati.\nLa caching degli oggetti non è attivata.",
+ "config-mod-security": "<strong>Attenzione:</strong> Il tuo server web ha il [http://modsecurity.org/ mod_security] abilitato. Se non correttamente configurato, può creare problemi a MediaWiki o ad altro software che permette agli utenti di pubblicare contenuto.\nFai riferimento alla [http://modsecurity.org/documentation/ documentazione sul mod_security] o contatta il supporto tecnico del tuo provider di hosting se si verificano errori.",
+ "config-diff3-bad": "GNU diff3 non trovato.",
+ "config-git": "Trovato software di controllo della versione Git: <code>$1</code>.",
+ "config-git-bad": "Software di controllo della versione Git non trovato.",
+ "config-imagemagick": "Trovato ImageMagick: <code>$1</code>.\nLe miniature delle immagini saranno presenti se gli upload vengono abilitati.",
+ "config-gd": "Trovata la GD Graphics Library built-in.\nLe miniature delle immagini saranno presenti se gli upload vengono abilitati.",
+ "config-no-scaling": "Impossibile trovare GD library o ImageMagick.\nLe miniature delle immagini saranno disabilitate.",
+ "config-no-uri": "'''Errore:''' Impossibile determinare l'URI attuale.\nInstallazione interrotta.",
+ "config-no-cli-uri": "'''Attenzione''': <code>--scriptpath</code> non specificato, si utilizza il valore predefinito: <code>$1</code>.",
+ "config-using-server": "Nome server in uso \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "URL del server in uso \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Attenzione:</strong> la directory predefinita per i caricamenti <code>$1</code> è vulnerabile all'esecuzione arbitraria di script.\nAnche se, a difesa della sicurezza, MediaWiki controlla tutti i file caricati, è fortemente raccomandato di [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security chiudere questa minaccia] prima di abilitare i caricamenti.",
+ "config-no-cli-uploads-check": "<strong>Attenzione:</strong> la directory predefinita per i caricamenti (<code>$1</code>) non è stata verificata per la vulnerabilità sull'esecuzione arbitraria di script durante l'installazione da linea di comando.",
+ "config-brokenlibxml": "Il tuo sistema ha una combinazione di versioni di PHP e libxml2 che è difettosa e che può provocare un danneggiamento non visibile di dati in MediaWiki ed in altre applicazioni per il web.\nAggiorna a libxml2 2.7.3 o successivo ([https://bugs.php.net/bug.php?id=45996 il bug è studiato dal lato PHP]).\nInstallazione interrotta.",
+ "config-suhosin-max-value-length": "Suhosin è installato e limita il parametro GET <code>length</code> a $1 byte.\nIl componente MediaWiki ResourceLoader funzionerà aggirando questo limite, ma riducendo le prestazioni.\nSe possibile, dovresti impostare <code>suhosin.get.max_value_length</code> a 1024 o superiore in <code>php.ini</code>, ed impostare <code>$wgResourceLoaderMaxQueryLength</code> allo stesso valore in <code>LocalSettings.php</code>.",
+ "config-db-type": "Tipo di database:",
+ "config-db-host": "Host del database:",
+ "config-db-host-help": "Se il server del tuo database è su un server diverso, immetti qui il nome dell'host o il suo indirizzo IP.\n\nSe stai utilizzando un web hosting condiviso, il tuo hosting provider dovrebbe fornirti il nome host corretto nella sua documentazione.\n\nSe stai installando su un server Windows con uso di MySQL, l'uso di \"localhost\" potrebbe non funzionare correttamente come nome del server. In caso di problemi, prova a impostare \"127.0.0.1\" come indirizzo IP locale.\n\nSe usi PostgreSQL, lascia questo campo vuoto per consentire di connettersi tramite un socket Unix.",
+ "config-db-host-oracle": "TNS del database:",
+ "config-db-host-oracle-help": "Inserisci un valido [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; un file tnsnames.ora deve essere visibile a questa installazione.<br />Se stai usando la libreria cliente 10g o più recente puoi anche usare il metodo di denominazione [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifica questo wiki",
+ "config-db-name": "Nome del database:",
+ "config-db-name-help": "Scegli un nome che identifica il tuo wiki.\nNon deve contenere spazi.\n\nSe utilizzi un web hosting condiviso, il tuo hosting provider o ti fornisce uno specifico nome di database da utilizzare, oppure ti consentirà di creare il database tramite un pannello di controllo.",
+ "config-db-name-oracle": "Schema del database:",
+ "config-db-account-oracle-warn": "Ci sono tre scenari supportati per l'installazione di Oracle come database di backend:\n\nSe vuoi creare un'utenza di database come parte del processo di installazione, fornisci un account con ruolo SYSDBA come utenza di database per l'installazione e specifica le credenziali volute per l'utenza di accesso web, altrimenti è possibile creare manualmente l'utenza di accesso web e fornire solo quell'account (se dispone delle autorizzazioni necessario per creare gli oggetti dello schema) o fornire due diverse utenze, una con i permessi di creazione e una per l'accesso web.\n\nScript per la creazione di un'utenza con le autorizzazioni necessarie può essere trovato nella directory \"maintenance/oracle/\" di questa installazione. Tieni presente che l'uso di un'utenza con restrizioni disabiliterà tutte le funzionalità di manutenzione con l'account predefinito.",
+ "config-db-install-account": "Account utente per l'installazione",
+ "config-db-username": "Nome utente del database:",
+ "config-db-password": "Password del database:",
+ "config-db-install-username": "Inserisci il nome utente che verrà utilizzato per connettersi al database durante il processo di installazione.\nQuesto non è il nome utente dell'account MediaWiki; ma quello per il tuo database.",
+ "config-db-install-password": "Inserisci la password che verrà utilizzato per connettersi al database durante il processo di installazione.\nQuesta non è la password dell'account MediaWiki; ma quella per il tuo database.",
+ "config-db-install-help": "Inserire il nome utente e la password che verranno usate per la connessione al database durante il processo d'installazione.",
+ "config-db-account-lock": "Utilizza lo stesso nome utente e password durante il normale funzionamento",
+ "config-db-wiki-account": "Account utente per il normale funzionamento",
+ "config-db-wiki-help": "Inserisci il nome utente e la password che verrà utilizzato per connettersi al database durante il normale funzionamento del wiki.\nSe l'account non esiste, e l'account di installazione dispone di privilegi sufficienti, verrà creato con privilegi minimi necessari per operare sul wiki.",
+ "config-db-prefix": "Prefisso tabella del database:",
+ "config-db-prefix-help": "Se hai bisogno di condividere un database tra più wiki, o tra MediaWiki e un'altra applicazione web, puoi scegliere di aggiungere un prefisso a tutti i nomi di tabella, per evitare conflitti.\nNon utilizzare spazi.\n\nSolitamente, questo campo viene lasciato vuoto.",
+ "config-mysql-old": "MySQL $1 o una versione successiva è necessaria, rilevata la $2.",
+ "config-db-port": "Porta del database:",
+ "config-db-schema": "Schema per MediaWiki:",
+ "config-db-schema-help": "Questo schema in genere andrà bene.\nDa cambiare solamente se si è sicuri di averne bisogno.",
+ "config-pg-test-error": "Impossibile connettersi al database '''$1''': $2",
+ "config-sqlite-dir": "Directory data di SQLite:",
+ "config-sqlite-dir-help": "SQLite memorizza tutti i dati in un unico file.\n\nLa directory che indicherai deve essere scrivibile dal server web durante l'installazione.\n\nDovrebbe essere <strong>non accessibile via web</strong>, è per questo che non la stiamo mettendo dove ci sono i file PHP.\n\nL'installatore scriverà insieme ad essa un file <code>.htaccess</code>, ma se il tentativo fallisse qualcuno potrebbe avere accesso al database grezzo.\nQuesto include dati utente grezzi (indirizzi, password cifrate) così come versioni eliminate e altri dati ad accesso limitato del wiki.\n\nConsidera l'opportunità di sistemare allo stesso tempo il database da qualche altra parte, per esempio in <code>/var/lib/mediawiki/tuowiki</code>.",
+ "config-oracle-def-ts": "Tablespace di default:",
+ "config-oracle-temp-ts": "Tablespace temporaneo:",
+ "config-type-mysql": "MySQL (o compatibile)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki supporta i seguenti sistemi di database:\n\n$1\n\nSe fra quelli elencati qui sotto non vedi il sistema di database che vorresti utilizzare, seguire le istruzioni linkate sopra per abilitare il supporto.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] è la configurazione preferibile per MediaWiki ed è quella meglio supportata. MediaWiki funziona anche con [{{int:version-db-mariadb-url}} MariaDB] e [{{int:version-db-percona-url}} Percona Server], che sono compatibili con MySQL.([http://www.php.net/manual/en/mysqli.installation.php Come compilare PHP con supporto MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] è un popolare sistema di database open source come alternativa a MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Come compilare PHP con supporto PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] è un sistema di database leggero, che è supportato molto bene. ([http://www.php.net/manual/en/pdo.installation.php Come compilare PHP con supporto SQLite], utilizza PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] è un database di un'impresa commerciale. ([http://www.php.net/manual/en/oci8.installation.php Come compilare PHP con supporto OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] è un database di un'impresa commerciale per Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Come compilare PHP con supporto SQLSRV])",
+ "config-header-mysql": "Impostazioni MySQL",
+ "config-header-postgres": "Impostazioni PostgreSQL",
+ "config-header-sqlite": "Impostazioni SQLite",
+ "config-header-oracle": "Impostazioni Oracle",
+ "config-header-mssql": "Impostazioni di Microsoft SQL Server",
+ "config-invalid-db-type": "Tipo di database non valido",
+ "config-missing-db-name": "È necessario immettere un valore per \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "È necessario immettere un valore per \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "È necessario immettere un valore per \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "TNS database \"$1\" non valido.\nUsa \"TNS Name\" o una stringa \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).",
+ "config-invalid-db-name": "Nome di database \"$1\" non valido.\nUtilizza soltanto caratteri ASCII come lettere (a-z, A-Z), numeri (0-9), sottolineatura (_) e trattini (-).",
+ "config-invalid-db-prefix": "Prefisso database \"$1\" non valido.\nUtilizza soltanto caratteri ASCII come lettere (a-z, A-Z), numeri (0-9), sottolineatura (_) e trattini (-).",
+ "config-connection-error": "$1.\n\nControlla host, nome utente e password e prova ancora.",
+ "config-invalid-schema": "Schema MediaWiki \"$1\" non valido.\nUsa solo lettere ASCII (a-z, A-Z), numeri (0-9) e caratteri di sottolineatura (_).",
+ "config-db-sys-create-oracle": "Il programma di installazione supporta solo l'utilizzo di un account SYSDBA per la creazione di un nuovo account.",
+ "config-db-sys-user-exists-oracle": "L'account utente \"$1\" esiste già. SYSDBA può essere usato solo per la creazione di un nuovo account!",
+ "config-postgres-old": "PostgreSQL $1 o una versione successiva è necessaria, rilevata la $2.",
+ "config-mssql-old": "Si richiede Microsoft SQL Server $1 o successivo. Tu hai la versione $2.",
+ "config-sqlite-name-help": "Scegli un nome che identifichi il tuo wiki.\nNon utilizzare spazi o trattini.\nQuesto servirà per il nome del file di dati SQLite.",
+ "config-sqlite-parent-unwritable-group": "Non è possibile creare la directory dati <code><nowiki>$1</nowiki></code>, perché la directory superiore <code><nowiki>$2</nowiki></code> non è scrivibile dal webserver.\n\nIl programma di installazione ha determinato l'utente con cui il server web è in esecuzione.\nForniscigli la possibilità di scrivere nella directory <code><nowiki>$3</nowiki></code> per continuare.\nSu un sistema Unix/Linux:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Non è possibile creare la directory dati <code><nowiki>$1</nowiki></code>, perché la directory superiore <code><nowiki>$2</nowiki></code> non è scrivibile dal webserver.\n\nIl programma di installazione non ha potuto determinare l'utente con cui il server web è in esecuzione.\nFornisci ad esso (ed altri!) la possibilità di scrivere globalmente nella directory <code><nowiki>$3</nowiki></code> per continuare.\nSu un sistema Unix/Linux:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Errore durante la creazione della directory dati \"$1\".\nControlla la posizione e riprova.",
+ "config-sqlite-dir-unwritable": "Impossibile scrivere nella directory \"$1\".\nModifica le autorizzazioni in modo che il webserver possa scrivere in essa e riprova.",
+ "config-sqlite-connection-error": "$1.\n\nControlla la directory dati e il nome del database qui sotto, poi riprova.",
+ "config-sqlite-readonly": "Il file <code>$1</code> non è scrivibile.",
+ "config-sqlite-cant-create-db": "Impossibile creare il file di database <code>$1</code> .",
+ "config-sqlite-fts3-downgrade": "Il PHP è mancante del supporto FTS3, declassamento tabelle in corso",
+ "config-can-upgrade": "Ci sono tabelle di MediaWiki in questo database.\nPer aggiornarle a MediaWiki $1, fai clic su '''continua'''.",
+ "config-upgrade-done": "Aggiornamento completo.\n\nPuoi [$1 iniziare ad usare il tuo wiki].\n\nSe vuoi rigenerare il tuo file <code>LocalSettings.php</code>, clicca sul pulsante sotto. Questa operazione '''non è raccomandata''', a meno che non hai problemi con il tuo wiki.",
+ "config-upgrade-done-no-regenerate": "Aggiornamento completo.\n\nPuoi [$1 iniziare ad usare il tuo wiki].",
+ "config-regenerate": "Rigenera LocalSettings.php →",
+ "config-show-table-status": "La query <code>SHOW TABLE STATUS</code> è fallita!",
+ "config-unknown-collation": "'''Attenzione:''' il database utilizza regole di confronto non riconosciute.",
+ "config-db-web-account": "Account del database per l'accesso web",
+ "config-db-web-help": "Seleziona il nome utente e la password che il server web utilizzerà per connettersi al server di database, durante il normale funzionamento del wiki.",
+ "config-db-web-account-same": "Utilizza lo stesso account dell'installazione",
+ "config-db-web-create": "Crea l'account se non esiste già",
+ "config-db-web-no-create-privs": "L'account usato per l'installazione non dispone dei privilegi necessari per creare un altro account.\nL'account indicato qui deve già esistere.",
+ "config-mysql-engine": "Storage engine:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Attenzione:</strong> hai selezionato MyISAM come motore di archiviazione per MySQL, che non è raccomandato per l'uso con MediaWiki, perché:\n* supporta debolmente la concorrenza per il blocco della tabella\n* è più incline alla corruzione di altri motori\n* il codice di base MediaWiki non gestisce sempre MyISAM come dovrebbe\n\nSe la tua installazione MySQL supporta InnoDB, è altamente raccomandato che lo si scelga al suo posto.\nSe la tua installazione MySQL non supporta InnoDB, forse è il momento per un aggiornamento.",
+ "config-mysql-only-myisam-dep": "<strong>Attenzione:</strong> MyISAM è l'unico motore di archiviazione disponibile per MySQL su questa macchina, e questo non è consigliato per l'uso con MediaWiki, perché:\n* supporta debolmente la concorrenza per il blocco della tabella\n* è più incline alla corruzione di altri motori\n* il codice di base MediaWiki non gestisce sempre MyISAM come dovrebbe\n\nSe la tua installazione MySQL non supporta InnoDB, forse è il momento per un aggiornamento.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> è quasi sempre l'opzione migliore, in quanto ha un buon supporto della concorrenza.\n\n<strong>MyISAM</strong> potrebbe essere più veloce nelle installazioni monoutente o in sola lettura.\nI database MyISAM tendono a danneggiarsi più spesso dei database InnoDB.",
+ "config-mysql-charset": "Set di caratteri del database:",
+ "config-mysql-binary": "Binario",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "In <strong>modalità binaria</strong>, MediaWiki archivia il testo UTF-8 nel database in campi binari.\nCiò è più efficiente rispetto alla modalità UTF-8 di MySQL, e consente di utilizzare la gamma completa di caratteri Unicode.\n\nIn <strong>modalità UTF-8</strong>, MySQL saprà in quale set di caratteri sono i tuoi dati, e potrà presentarli e convertirli in modo appropriato, ma non ti permetterà di memorizzare i caratteri al di sopra del [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Piano di base multilinguistico].",
+ "config-mssql-auth": "Tipo di autenticazione:",
+ "config-mssql-install-auth": "Seleziona il tipo di autenticazione che verrà utilizzato per connettersi al database durante il processo di installazione.\nSe si seleziona \"{{int:config-mssql-windowsauth}}\", saranno utilizzate le credenziali dell'utente con cui viene eseguito il server web, qualunque esso sia.",
+ "config-mssql-web-auth": "Seleziona il tipo di autenticazione che il server web utilizzerà per connettersi al database, durante il normale funzionamento del wiki.\nSe si seleziona \"{{int:config-mssql-windowsauth}}\", saranno utilizzate le credenziali dell'utente con cui viene eseguito il server web, qualunque esso sia.",
+ "config-mssql-sqlauth": "Autenticazione di SQL Server",
+ "config-mssql-windowsauth": "Autenticazione di Windows",
+ "config-site-name": "Nome del wiki:",
+ "config-site-name-help": "Questo verrà visualizzato nella barra del titolo del browser e in vari altri posti.",
+ "config-site-name-blank": "Inserisci il nome del sito.",
+ "config-project-namespace": "Namespace del progetto:",
+ "config-ns-generic": "Progetto",
+ "config-ns-site-name": "Stesso nome del wiki: $1",
+ "config-ns-other": "Altro (specificare)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Seguendo l'esempio di Wikipedia, molti wiki tengono le loro pagine con le regole separate dalle pagine di contenuto, in un '''namespace di progetto'''.\nTutti i titoli delle pagine in questo namespace iniziano con un certo prefisso, che puoi indicare qui.\nSolitamente, questo prefisso deriva dal nome del wiki, ma non può contenere caratteri di punteggiatura come \"#\" o \":\".",
+ "config-ns-invalid": "Il namespace indicato \"<nowiki>$1</nowiki>\" non è valido.\nSpecificare un diverso namespace di progetto.",
+ "config-ns-conflict": "Il namespace indicato \"<nowiki>$1</nowiki>\" è in conflitto con un namespace predefinito MediaWiki.\nSpecificare un diverso namespace di progetto.",
+ "config-admin-box": "Account amministratore",
+ "config-admin-name": "Il tuo nome utente:",
+ "config-admin-password": "Password:",
+ "config-admin-password-confirm": "Ripeti la password:",
+ "config-admin-help": "Inserisci il tuo nome utente scelto qui, ad esempio \"Mario Rossi\".\nQuesto è il nome che userai per accedere al wiki.",
+ "config-admin-name-blank": "Inserisci un nome utente per l'amministratore.",
+ "config-admin-name-invalid": "Il nome utente specificato \"<nowiki>$1</nowiki>\" non è valido.\nSpecificare un nome utente diverso.",
+ "config-admin-password-blank": "Inserisci una password per l'account di amministratore.",
+ "config-admin-password-mismatch": "Le password inserite non coincidono tra loro.",
+ "config-admin-email": "Indirizzo email:",
+ "config-admin-email-help": "Inserisci qui un indirizzo email per poter ricevere email dagli altri utenti del wiki, reimpostare la tua password, ed essere informato delle modifiche apportate alle pagine tuoi osservati speciali. Se non ti interessa, puoi lasciare vuoto questo campo.",
+ "config-admin-error-user": "Errore interno durante la creazione di un amministratore con il nome \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Errore interno durante l'impostazione di una password per amministratore \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "È stato inserito un indirizzo email non valido.",
+ "config-subscribe": "Sottoscrivi la [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce mailing list degli annunci di rilasci].",
+ "config-subscribe-help": "Si tratta di una mailing list a basso traffico dedicata agli annunci di nuove versioni, compresi importanti segnalazioni riguardanti la sicurezza.\nÈ consigliato iscriversi e aggiornare la propria installazione di MediaWiki quando una nuova versione viene resa pubblica.",
+ "config-subscribe-noemail": "Hai provato ad iscriverti alla mailing list dedicata agli annunci delle nuove versioni senza fornire un indirizzo email.\nInserire un indirizzo email se si desidera effettuare l'iscrizione alla mailing list.",
+ "config-pingback": "Condividi i dati su questa installazione con gli sviluppatori di MediaWiki.",
+ "config-pingback-help": "Se si seleziona questa opzione, MediaWiki contatterà periodicamente https://www.mediawiki.org con i dati base su questa istanza MediaWiki. In questa categoria di dati rientrano, per esempio, il tipo di sistema, la versione di PHP e database di backend scelto. La Wikimedia Foundation condivide questi dati con gli sviluppatori Mediawiki per aiutarla a guidare i futuri sforzi di sviluppo. Per il tuo sistema saranno inviati i seguenti dati:\n<pre>$1</pre>",
+ "config-almost-done": "Hai quasi finito!\nAdesso puoi saltare la rimanente parte della configurazione e semplicemente installare il wiki.",
+ "config-optional-continue": "Fammi altre domande.",
+ "config-optional-skip": "Sono già stanco, installa solo il wiki.",
+ "config-profile": "Profilo dei diritti utente:",
+ "config-profile-wiki": "Wiki aperto",
+ "config-profile-no-anon": "Creazione utenza obbligatoria",
+ "config-profile-fishbowl": "Solo utenti autorizzati",
+ "config-profile-private": "Wiki privato",
+ "config-profile-help": "I wiki funzionano meglio se si permette a molte persone di poterli modificare.\nIn MediaWiki, è semplice controllare le ultime modifiche, e ripristinare i danni causati da utenti inesperti o malintenzionati.\n\nTuttavia, molti hanno trovato MediaWiki essere utile in un'ampia varietà di ruoli, e a volte non è facile convincere tutti dei vantaggi della modalità wiki.\nPerciò, fa' la tua scelta.\n\nIl modello <strong>{{int:config-profile-wiki}}</strong> consente a chiunque di modificare, anche senza effettuare l'accesso.\nUn wiki con <strong>{{int:config-profile-no-anon}}</strong> offre una maggiore responsabilità, ma potrebbe scoraggiare i contributori occasionali.\n\nLo scenario <strong>{{int:config-profile-fishbowl}}</strong> consente agli utenti autorizzati di modificare, ma il pubblico può visualizzare le pagine, compresa la cronologia.\nUn <strong>{{int:config-profile-private}}</strong> consente solo agli utenti autorizzati di visualizzare le pagine, lo stesso gruppo può modificarle.\n\nConfigurazioni di diritti utente più complesse sono disponibili dopo l'installazione, vedi la [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights parte relativa del manuale].",
+ "config-license": "Copyright e licenza:",
+ "config-license-none": "Nessun piè di pagina per la licenza",
+ "config-license-cc-by-sa": "Creative Commons Attribuzione-Condividi allo stesso modo",
+ "config-license-cc-by": "Creative Commons Attribuzione",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribuzione-Non commerciale-Condividi allo stesso modo",
+ "config-license-cc-0": "Creative Commons Zero (pubblico dominio)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 o versioni successive",
+ "config-license-pd": "Pubblico dominio",
+ "config-license-cc-choose": "Seleziona una delle licenze Creative Commons",
+ "config-license-help": "Molti wiki pubblici rilasciano i loro contributi con una [http://freedomdefined.org/Definition licenza libera]. Questo aiuta a creare un senso di proprietà condivisa nella comunità e incoraggia a contribuire a lungo termine. Non è generalmente necessario per un wiki privato o aziendale.\n\nSe vuoi usare testi da Wikipedia, o desideri che Wikipedia possa essere in grado di accettare testi copiati dal tuo wiki, dovresti scegliere <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nIn precedenza Wikipedia ha utilizzato la GNU Free Documentation License. La GFDL è una licenza valida, ma è di difficile comprensione e complica il riutilizzo dei contenuti.",
+ "config-email-settings": "Impostazioni email",
+ "config-enable-email": "Abilita la posta elettronica in uscita",
+ "config-enable-email-help": "Se vuoi che funzionino le email, le [http://www.php.net/manual/en/mail.configuration.php PHP's impostazioni della posta] devono essere configurate correttamente.\nSe non si desidera alcuna funzionalità di posta elettronica, puoi disabilitarla qui.",
+ "config-email-user": "Abilita invio email fra utenti",
+ "config-email-user-help": "Consente a tutti gli utenti di inviarsi a vicenda email, se lo hanno abilitato nelle loro preferenze.",
+ "config-email-usertalk": "Abilita le notifiche per le pagine di discussione utente",
+ "config-email-usertalk-help": "Consente agli utenti di ricevere notifiche per le modifiche delle loro pagine di discussione, se lo hanno abilitato nelle loro preferenze.",
+ "config-email-watchlist": "Abilita le notifiche per gli osservati speciali",
+ "config-email-watchlist-help": "Consente agli utenti di ricevere notifiche per pagine tra gli osservati speciali, se lo hanno abilitato nelle loro preferenze.",
+ "config-email-auth": "Abilita autenticazione via email",
+ "config-email-auth-help": "Se questa opzione è attivata, gli utenti dovranno confermare il loro indirizzo email utilizzando un collegamento che viene inviato ogni volta che lo impostano o lo modificano.\nSolo gli indirizzi di posta elettronica autenticati possono ricevere email da altri utenti o modificare le email di notifica.\nImpostare questa opzione è <strong>raccomandato</strong> per wiki pubblici a causa del potenziale abuso delle funzioni di posta elettronica.",
+ "config-email-sender": "Indirizzo email di ritorno:",
+ "config-email-sender-help": "Inserisci l'indirizzo email da utilizzare come indirizzo di ritorno per la posta in uscita.\nQuesto è dove verranno inviati gli eventuali errori.\nMolti server di posta richiedono che almeno la parte del nome di dominio sia valido.",
+ "config-upload-settings": "Caricamenti di immagini e file",
+ "config-upload-enable": "Consentire il caricamento di file",
+ "config-upload-help": "Il caricamento di file potrebbe esporre il tuo server a rischi di sicurezza.\nPer ulteriori informazioni, leggi la [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security sezione sulla sicurezza] nel manuale.\n\nPer consentire il caricamento di file, modifica la modalità nella sottodirectory <code>images</code> della directory principale di MediaWiki affinché il server web possa scrivere lì.\nPoi attiva questa opzione.",
+ "config-upload-deleted": "Directory per i file cancellati:",
+ "config-upload-deleted-help": "Scegli una directory in cui archiviare i file cancellati.\nIdealmente, questa non dovrebbe essere accessibile dal web.",
+ "config-logo": "URL del logo:",
+ "config-logo-help": "Il tema predefinito di MediaWiki include lo spazio per un logo di 135 x 160 pixel sopra il menu laterale.\nCarica un'immagine di dimensioni appropriate e inserisci l'URL qui.\n\nÈ possibile utilizzare <code>$wgStylePath</code> o <code>$wgScriptPath</code> se il logo è relativo a tali percorsi.\n\nSe non si desidera un logo, lascia vuota questa casella.",
+ "config-instantcommons": "Abilita Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] è una funzionalità che consente ai wiki di usare immagini, suoni e altri file multimediali che trovate sul sito [https://commons.wikimedia.org/ Wikimedia Commons].\nPer fare questo, MediaWiki richiede l'accesso a Internet.\n\nPer ulteriori informazioni su questa funzionalità, incluse le istruzioni su come configurarlo per wiki diversi da Wikimedia Commons, consultare [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos il manuale].",
+ "config-cc-error": "Il selettore di licenze Creative Commons non ha dato alcun risultato.\nInserisci manualmente il nome della licenza.",
+ "config-cc-again": "Seleziona di nuovo...",
+ "config-cc-not-chosen": "Scegliere quale licenza Creative Commons si desidera e cliccare su \"proceed\".",
+ "config-advanced-settings": "Configurazione avanzata",
+ "config-cache-options": "Impostazioni per la cache di oggetti:",
+ "config-cache-help": "La memorizzazione di oggetti nella cache è utilizzata per migliorare la velocità di MediaWiki attraverso l'allocazione nella cache dei dati utilizzati di frequente.\nPer siti di dimensioni medie e grandi, è caldamente consigliato attivare la cache, ma anche per piccoli siti se ne vedranno i benefici.",
+ "config-cache-none": "Nessuna memorizzazione in cache (nessuna funzionalità viene impedita, ma sui siti wiki più grandi la velocità potrebbe risentirne)",
+ "config-cache-accel": "Mettere in cache oggetti PHP (APC, APCu, XCache o WinCache)",
+ "config-cache-memcached": "Usa Memcached (richiede ulteriori attività di installazione e configurazione)",
+ "config-memcached-servers": "Server di memcached:",
+ "config-memcached-help": "Elenco di indirizzi IP da utilizzare per Memcached.\nDovresti specificarne uno per riga e indicare la porta da utilizzare. Per esempio:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "È stato selezionato il tipo di caching Memcached, ma non è stato impostato alcun server.",
+ "config-memcache-badip": "È stato inserito un indirizzo IP non valido per Memcached: $1.",
+ "config-memcache-noport": "Non è stata specificata una porta da utilizzare per il server Memcached: $1.\nSe non sai qual'è la porta, il valore di default è 11211.",
+ "config-memcache-badport": "I numeri di porta per memcached dovrebbero essere tra $1 e $2.",
+ "config-extensions": "Estensioni",
+ "config-extensions-help": "Le estensioni elencate sopra sono state rilevate nella tua directory <code>./extensions</code>.\n\nQueste potrebbero richiedere ulteriore configurazione, ma è possibile attivarle ora",
+ "config-skins": "Temi",
+ "config-skins-help": "I temi elencati sopra sono stati rilevati nella tua directory <code>./skins</code>. Devi attivarne almeno uno e scegliere quello predefinito.",
+ "config-skins-use-as-default": "Usa questo tema come predefinito",
+ "config-skins-missing": "Non è stato trovato alcun tema, MediaWiki userà una soluzione di ripiego finché non ne installerai uno appropriato.",
+ "config-skins-must-enable-some": "Devi scegliere almeno un tema da attivare.",
+ "config-skins-must-enable-default": "Il tema scelto come predefinito deve essere attivato.",
+ "config-install-alreadydone": "'''Attenzione:''' sembra che hai già installato MediaWiki e stai tentando di installarlo nuovamente.\nProcedi alla pagina successiva.",
+ "config-install-begin": "Premendo \"{{int:config-continue}}\", si avvierà l'installazione di MediaWiki.\nSe prima desideri apportare altre modifiche, premi \"{{int:config-back}}\".",
+ "config-install-step-done": "fatto",
+ "config-install-step-failed": "non riuscito",
+ "config-install-extensions": "Comprese le estensioni",
+ "config-install-database": "Configurazione database",
+ "config-install-schema": "Creazione dello schema",
+ "config-install-pg-schema-not-exist": "Lo schema PostgreSQL non esiste.",
+ "config-install-pg-schema-failed": "Creazione tabelle non riuscita.\nAssicurati che l'utente \"$1\" può scrivere nello schema \"$2\".",
+ "config-install-pg-commit": "Applica le modifiche",
+ "config-install-pg-plpgsql": "Controllo il linguaggio PL/pgSQL",
+ "config-pg-no-plpgsql": "È necessario installare il linguaggio PL/pgSQL nel database $1",
+ "config-pg-no-create-privs": "L'account indicato per l'installazione non dispone dei permessi necessari per creare un'utenza.",
+ "config-pg-not-in-role": "L'account indicato per l'utente web esiste già.\nL'account indicato per l'installazione non è un utente avanzato e non è un membro del ruolo degli utente web, quindi non è in grado di creare oggetti di proprietà dell'utente web.\n\nMediaWiki attualmente richiede che le tabelle siano di proprietà dell'utente web. Indica un altro account web, o fai click su \"indietro\" e specifica un utente per l'installazione opportunamente privilegiato.",
+ "config-install-user": "Creazione di utente del database",
+ "config-install-user-alreadyexists": "L'utente \"$1\" è già presente",
+ "config-install-user-create-failed": "Creazione dell'utente \"$1\" non riuscita: $2",
+ "config-install-user-grant-failed": "Errore durante la concessione delle autorizzazione all'utente \"$1\": $2",
+ "config-install-user-missing": "L'utente indicato \"$1\" non esiste.",
+ "config-install-user-missing-create": "L'utente indicato \"$1\" non esiste.\nSeleziona la casella \"crea utenza\" qui sotto se vuoi crearla.",
+ "config-install-tables": "Creazione tabelle",
+ "config-install-tables-exist": "'''Attenzione:''' sembra che le tabelle di MediaWiki esistono già.\nSalto la creazione.",
+ "config-install-tables-failed": "'''Errore''': La creazione della tabella non è riuscita: $1",
+ "config-install-interwiki": "Riempimento della tabella interwiki predefinita",
+ "config-install-interwiki-list": "Impossibile leggere il file <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Attenzione:''' la tabella interwiki sembra che contiene già elementi.\nSalto l'elenco predefinito.",
+ "config-install-stats": "Inizializzazione delle statistiche",
+ "config-install-keys": "Generazione delle chiavi segrete",
+ "config-insecure-keys": "'''Attenzione:''' {{PLURAL:$2|Una chiave sicura|Delle chiavi sicure}} ($1) {{PLURAL:$2|generata|generate}} durante l'installazione non {{PLURAL:$2|è|sono}} completamente {{PLURAL:$2|sicura|sicure}}. Considera di {{PLURAL:$2|cambiarla|cambiarle}} manualmente.",
+ "config-install-updates": "Impedire l'esecuzione di aggiornamenti non necessari",
+ "config-install-updates-failed": "<strong>Errore:</strong> l'inserimento delle chiavi di aggiornamento nelle tabelle non è riuscito con il seguente errore: $1",
+ "config-install-sysop": "Creazione dell'account utente per l'amministratore",
+ "config-install-subscribe-fail": "Impossibile sottoscrivere mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL non è installato e <code>allow_url_fopen</code> non è disponibile.",
+ "config-install-mainpage": "Creazione della pagina principale con contenuto predefinito",
+ "config-install-mainpage-exists": "La pagina principale già esiste, saltata",
+ "config-install-extension-tables": "Creazione delle tabelle per le estensioni attivate",
+ "config-install-mainpage-failed": "Impossibile inserire la pagina principale: $1",
+ "config-install-done": "<strong>Complimenti!</strong>\nHai installato MediaWiki.\n\nIl programma di installazione ha generato un file <code>LocalSettings.php</code> che contiene tutte le impostazioni.\n\nDevi scaricarlo ed inserirlo nella directory base del tuo wiki (la stessa dove è presente index.php). Il download dovrebbe partire automaticamente.\n\nSe il download non si avvia, o se è stato annullato, puoi riavviarlo cliccando sul collegamento di seguito:\n\n$3\n\n<strong>Nota:</strong> se esci ora dall'installazione senza scaricare il file di configurazione che è stato generato, questo poi non sarà più disponibile in seguito.\n\nQuando hai fatto, puoi <strong>[$2 entrare nel tuo wiki]</strong>.",
+ "config-install-done-path": "<strong>Complimenti!</strong>\nHai installato MediaWiki.\n\nIl programma di installazione ha generato un file <code>LocalSettings.php</code> che contiene tutte le impostazioni.\n\nDevi scaricarlo ed inserirlo in <code>$4</code>. Il download dovrebbe partire automaticamente.\n\nSe il download non si avvia, o se è stato annullato, puoi riavviarlo cliccando sul collegamento seguente:\n\n$3\n\n<strong>Nota:</strong> se esci ora dall'installazione senza scaricare il file di configurazione che è stato generato, questo poi non sarà più disponibile in seguito.\n\nQuando hai fatto, puoi <strong>[$2 entrare nel tuo wiki]</strong>.",
+ "config-download-localsettings": "Scarica <code>LocalSettings.php</code>",
+ "config-help": "aiuto",
+ "config-help-tooltip": "fai clic per espandere",
+ "config-nofile": "Il file \"$1\" non può essere trovato. È stato eliminato?",
+ "config-extension-link": "Sapevi che il tuo wiki supporta le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions estensioni]?\n\nPuoi navigare tra le [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category estensioni per categoria].",
+ "mainpagetext": "<strong>MediaWiki è stato installato.</strong>",
+ "mainpagedocfooter": "Consulta la [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents guida utente] per maggiori informazioni sull'uso di questo software wiki.\n\n== Per iniziare ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Impostazioni di configurazione]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Domande frequenti su MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list annunci MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Trova MediaWiki nella tua lingua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Imparare a combattere lo spam sul tuo wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/ja.json b/www/wiki/includes/installer/i18n/ja.json
new file mode 100644
index 00000000..ef286e36
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ja.json
@@ -0,0 +1,335 @@
+{
+ "@metadata": {
+ "authors": [
+ "Aphaia",
+ "Fryed-peach",
+ "Iwai.masaharu",
+ "Mizusumashi",
+ "Ninomy",
+ "Ohgi",
+ "Shirayuki",
+ "Whym",
+ "Yanajin66",
+ "青子守歌",
+ "아라",
+ "Shield-9",
+ "Takot",
+ "Sujiniku",
+ "Macofe",
+ "2nd-player",
+ "Otokoume",
+ "Rxy",
+ "Foresttttttt",
+ "ネイ",
+ "Suchichi02",
+ "Omotecho"
+ ]
+ },
+ "config-desc": "MediaWiki のインストーラー",
+ "config-title": "MediaWiki $1 のインストール",
+ "config-information": "情報",
+ "config-localsettings-upgrade": "ファイル <code>LocalSettings.php</code> を検出しました。\nインストールされているものをアップグレードするには、<code>$wgUpgradeKey</code> の値を以下の欄に入力してください。\nこの値は <code>LocalSettings.php</code> 内にあります。",
+ "config-localsettings-cli-upgrade": "ファイル <code>LocalSettings.php</code> を検出しました。\nインストールされているものをアップグレードするには、<code>update.php</code> を実行してください",
+ "config-localsettings-key": "アップグレード キー:",
+ "config-localsettings-badkey": "与えられたアップグレード・キーが間違っています",
+ "config-upgrade-key-missing": "MediaWiki が既にインストールされていることを検出しました。\nインストールされているものをアップグレードするために、以下の行を <code>LocalSettings.php</code> の末尾に挿入してください:\n\n$1",
+ "config-localsettings-incomplete": "既存の <code>LocalSettings.php</code> の内容は不完全のようです。\n変数 $1 が設定されていません。\n<code>LocalSettings.php</code> 内でこの変数を設定して、「{{int:Config-continue}}」をクリックしてください。",
+ "config-localsettings-connection-error": "<code>LocalSettings.php</code> で指定した設定を使用してデータベースに接続する際にエラーが発生しました。\n設定を修正してから再度試してください。\n\n$1",
+ "config-session-error": "セッションの開始エラー: $1",
+ "config-session-expired": "セッションの有効期限が切れたようです。\nセッションの有効期間は$1に設定されています。\nphp.iniの<code>session.gc_maxlifetime</code>を設定することで、この問題を改善できます。\nインストール作業を再起動させてください。",
+ "config-no-session": "セッションのデータが消失しました!\nphp.ini 内で <code>session.save_path</code> が適切なディレクトリに設定されていることを確認してください。",
+ "config-your-language": "あなたの言語:",
+ "config-your-language-help": "インストール作業に使用する言語を選択してください。",
+ "config-wiki-language": "ウィキの言語:",
+ "config-wiki-language-help": "ウィキで主に書き込まれる言語を選択してください。",
+ "config-back": "← 戻る",
+ "config-continue": "続行 →",
+ "config-page-language": "言語",
+ "config-page-welcome": "MediaWiki へようこそ!",
+ "config-page-dbconnect": "データベースに接続",
+ "config-page-upgrade": "既存のインストールを更新",
+ "config-page-dbsettings": "データベースの設定",
+ "config-page-name": "名前",
+ "config-page-options": "オプション",
+ "config-page-install": "インストール",
+ "config-page-complete": "完了!",
+ "config-page-restart": "インストールを再起動",
+ "config-page-readme": "お読みください",
+ "config-page-releasenotes": "リリースノート",
+ "config-page-copying": "複製",
+ "config-page-upgradedoc": "アップグレード",
+ "config-page-existingwiki": "既存のウィキ",
+ "config-help-restart": "入力した保存データをすべて消去して、インストール作業を再起動しますか?",
+ "config-restart": "はい、再起動します",
+ "config-welcome": "=== 環境の確認 ===\n基本的な確認では、現在の環境が MediaWiki のインストールに適しているかを確認します。\nインストール方法について助けが必要になった場合は、必ずこの確認結果を添えてください。",
+ "config-copyright": "=== 著作権および規約 ===\n$1\n\nこの作品はフリーソフトウェアです。あなたは、フリーソフトウェア財団の発行する GNU 一般公衆利用許諾書 (GNU General Public License) (バージョン 2、またはそれ以降のライセンス) の規約に基づき、このライブラリを再配布および改変できます。\n\nこの作品は、有用であることを期待して配布されていますが、<strong>商用または特定の目的に適するかどうか</strong>も含めて、暗黙的にも、<strong>一切保証されません</strong>。\n詳しくは、 GNU 一般公衆利用許諾書をご覧ください。\n\nあなたはこのプログラムと共に、<doclink href=Copying>GNU 一般公衆利用許諾契約書の複製</doclink>を受け取ったはずです。受け取っていない場合は、フリーソフトウェア財団 (宛先は the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA) まで請求するか、または[http://www.gnu.org/copyleft/gpl.html オンラインでお読みください]。",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWikiのホーム]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 利用者向け案内]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents 管理者向け案内]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>お読みください</doclink>\n* <doclink href=ReleaseNotes>リリースノート</doclink>\n* <doclink href=Copying>コピー</doclink>\n* <doclink href=UpgradeDoc>アップグレード</doclink>",
+ "config-env-good": "環境を確認しました。\nMediaWiki をインストールできます。",
+ "config-env-bad": "環境を確認しました。\nMediaWiki のインストールはできません。",
+ "config-env-php": "PHP $1がインストールされています。",
+ "config-env-hhvm": "HHVM $1 がインストールされています。",
+ "config-unicode-using-intl": "Unicode正規化に[http://pecl.php.net/intl intl PECL 拡張機能]を使用。",
+ "config-unicode-pure-php-warning": "<strong>警告:</strong> Unicode 正規化の処理に [http://pecl.php.net/intl intl PECL 拡張機能]を利用できないため、処理が遅いピュア PHP の実装を代わりに使用しています。\n高トラフィックのサイトを運営する場合は、[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode 正規化]をお読みください。",
+ "config-unicode-update-warning": "<strong>警告:</strong> インストールされているバージョンの Unicode 正規化ラッパーは、[http://site.icu-project.org/ ICU プロジェクト]のライブラリの古いバージョンを使用しています。\nUnicode を少しでも利用する可能性がある場合は、[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations アップグレード]してください。",
+ "config-no-db": "適切なデータベース ドライバーが見つかりませんでした! PHP にデータベース ドライバーをインストールする必要があります。\n以下の種類のデータベース{{PLURAL:$2|のタイプ}}に対応しています: $1\n\nPHP を自分でコンパイルした場合は、例えば <code>./configure --with-mysqli</code> を実行して、データベース クライアントを使用できるように再設定してください。\nDebian または Ubuntu のパッケージから PHP をインストールした場合は、モジュール (例: <code>php5-mysql</code>) もインストールする必要があります。",
+ "config-outdated-sqlite": "<strong>警告:</strong> あなたは SQLite $1 を使用していますが、最低限必要なバージョン $2 より古いバージョンです。SQLite は利用できません。",
+ "config-no-fts3": "<strong>警告:</strong> SQLite は [//sqlite.org/fts3.html FTS3] モジュールなしでコンパイルされており、このバックエンドでは検索機能は利用できなくなります。",
+ "config-pcre-old": "<strong>致命的エラー:</strong> PCRE $1 以降が必要です。\nご使用中の PHP のバイナリは PCRE $2 とリンクされています。\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE 詳細情報]",
+ "config-pcre-no-utf8": "<strong>致命的エラー:</strong> PHP の PCRE が PCRE_UTF8 対応なしでコンパイルされているようです。\nMediaWiki を正しく動作させるには、UTF-8 対応が必要です。",
+ "config-memory-raised": "PHPの<code>memory_limit</code>は$1で、$2に引き上げられました。",
+ "config-memory-bad": "<strong>警告:</strong> PHPの<code>memory_limit</code>に$1に設定されています。\nこの値はおそらく小さすぎます。\nインストールが失敗するおそれがあります!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] がインストール済み",
+ "config-apc": "[http://www.php.net/apc APC] がインストール済み",
+ "config-apcu": "[http://www.php.net/apc APC] がインストール済みです。",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] がインストール済み",
+ "config-no-cache-apcu": "<strong>警告:</strong> [http://www.php.net/apcu APCu]、 [http://xcache.lighttpd.net/ XCache]、 [http://www.iis.net/download/WinCacheForPhp WinCache] のいずれも見つかりませんでした。\nオブジェクトのキャッシュは有効化されません。",
+ "config-mod-security": "<strong>警告:</strong> あなたのウェブサーバーでは [http://modsecurity.org/ mod_security] が有効になっています。正しく構成されていない場合は、MediaWiki や利用者にコンテンツの投稿を許可するその他のソフトウェアに問題が発生する場合があります。\n[http://modsecurity.org/documentation/ mod_security の説明文書]を確認するか、ランダムなエラーが発生した場合はあなたのホストのサポートにお問い合わせください。",
+ "config-diff3-bad": "GNU diff3 が見つかりません。",
+ "config-git": "バージョン管理ソフトウェア Git が見つかりました: <code>$1</code>",
+ "config-git-bad": "バージョン管理ソフトウェア Git が見つかりません。",
+ "config-imagemagick": "ImageMagickが見つかりました: <code>$1</code>。\nアップロードが有効であれば、画像のサムネイルを利用できます。",
+ "config-gd": "GD画像ライブラリが内蔵されていることが確認されました。\nアップロードが有効なら、画像のサムネイルが利用できます。",
+ "config-no-scaling": "GDライブラリもImageMagickも見つかりませんでした。\n画像のサムネイル生成は無効になります。",
+ "config-no-uri": "<strong>エラー:</strong> 現在のURIを決定できませんでした。\nインストールは中止されました。",
+ "config-no-cli-uri": "<strong>警告:</strong> <code>--scriptpath</code> が指定されていないため、既定値 <code>$1</code> を使用します。",
+ "config-using-server": "サーバー名「<nowiki>$1</nowiki>」を使用しています。",
+ "config-using-uri": "サーバー URL「<nowiki>$1$2</nowiki>」を使用しています。",
+ "config-uploads-not-safe": "<strong>警告:</strong> アップロードの既定ディレクトリ <code>$1</code> に、任意のスクリプト実行に関する脆弱性があります。\nMediaWiki はアップロードされたファイルのセキュリティ上の脅威を確認しますが、アップロードを有効化する前に、[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security このセキュリティ上の脆弱性を解決する]ことを強く推奨します。",
+ "config-no-cli-uploads-check": "<strong>警告:</strong> アップロード用のデフォルトディレクトリ (<code>$1</code>) が、CLIでのインストール中に任意のスクリプト実行の脆弱性チェックを受けていません。",
+ "config-brokenlibxml": "このシステムで使われているPHPとlibxml2のバージョンのこの組み合わせにはバグがあります。具体的には、MediaWikiやその他のウェブアプリケーションでhiddenデータが破損する可能性があります。\nlibxml2を2.7.3以降のバージョンにアップグレードしてください([https://bugs.php.net/bug.php?id=45996 PHPでのバグ情報])。\nインストールを終了します。",
+ "config-suhosin-max-value-length": "Suhosin がインストールされており、GET パラメーターの <code>length</code> を $1 バイトに制限しています。\nMediaWiki の ResourceLoader コンポーネントはこの制限を回避しますが、パフォーマンスは低下します。\n可能な限り、<code>php.ini</code> で <code>suhosin.get.max_value_length</code> を 1024 以上に設定し、同じ値を <code>LocalSettings.php</code> 内で <code>$wgResourceLoaderMaxQueryLength</code> に設定してください。",
+ "config-db-type": "データベースの種類:",
+ "config-db-host": "データベースのホスト:",
+ "config-db-host-help": "異なるサーバー上にデータベースサーバーがある場合、ホスト名またはIPアドレスをここに入力してください。\n\nもし、共有されたウェブホスティングを使用している場合、ホスティングプロバイダーは正確なホスト名を解説しているはずです。\n\nWindowsでMySQLを使用している場合に、「localhost」は、サーバー名としてはうまく働かないでしょう。もしそのような場合は、ローカルIPアドレスとして「127.0.0.1」を試してみてください。\n\nPostgreSQLを使用している場合、UNIXソケットで接続するにはこの欄を空欄のままにしてください。",
+ "config-db-host-oracle": "データベース TNS:",
+ "config-db-host-oracle-help": "有効な[http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm ローカル接続名]を入力してください。tnsnames.ora ファイルは、このインストール先から参照できる場所に置いてください。<br />ご使用中のクライアント ライブラリが 10g 以降の場合、[http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect] ネーミング メソッドを使用できます。",
+ "config-db-wiki-settings": "このウィキの識別情報",
+ "config-db-name": "データベース名:",
+ "config-db-name-help": "このウィキを識別する名前を入力してください。\n空白を含めることはできません。\n\n共有ウェブホストを利用している場合、ホスティングプロバイダーが特定の使用可能なデータベース名を提供するか、あるいは管理パネルからデータベースを作成できるようにしているでしょう。",
+ "config-db-name-oracle": "データベースのスキーマ:",
+ "config-db-account-oracle-warn": "バックエンドのデータベースとして Oracle をインストールする場合、3つのシナリオが考えられます。\n\nデータベース用のアカウントをインストールのプロセス途中で作成したい場合、インストールに使うデータベース用のアカウントしては SYSDBAロール付きのアカウントを指定し、ウェブアクセス用アカウントには必要なログイン情報を指定してください。あるいは、ウェブアクセス用のアカウントを手動で作成して、そのアカウント(スキーマオブジェクトの作成のパーミッションを要求する場合)だけを使うか、二つの異なるアカウントを用意して一つは特権を付与できるもの、もう一つをウェブアクセス用の制限アカウントとしてください。\n\n要求された特権でアカウントを作成するスクリプトは、このインストール環境では、\"maintenance/oracle/\" にあります。制限アカウントを使用することは、デフォルトアカウントでのすべてのメンテナンス特権を無効にすることにご注意ください。",
+ "config-db-install-account": "インストールで使用する利用者アカウント",
+ "config-db-username": "データベースのユーザー名:",
+ "config-db-password": "データベースのパスワード:",
+ "config-db-install-username": "インストール中にデータベースへの接続で使用するユーザー名を入力してください。\nこれは MediaWiki アカウントの利用者名のことではありません。あなたのデータベースでのユーザー名です。",
+ "config-db-install-password": "インストール中にデータベースへの接続で使用するパスワードを入力してください。\nこれは MediaWiki アカウントのパスワードのことではありません。あなたのデータベースでのパスワードです。",
+ "config-db-install-help": "インストール作業中にデータベースに接続するための利用者名とパスワードを入力してください。",
+ "config-db-account-lock": "インストール作業終了後も同じ利用者名とパスワードを使用する",
+ "config-db-wiki-account": "インストール作業終了後の利用者アカウント",
+ "config-db-wiki-help": "通常のウィキ操作中にデータベースへの接続する時に利用する利用者名とパスワードを入力してください。\nアカウントが存在せず、インストールのアカウントに十分な権限がある場合は、この利用者アカウントは、ウィキを操作する上で最小限の権限を持った状態で作成されます。",
+ "config-db-prefix": "データベース テーブルの接頭辞:",
+ "config-db-prefix-help": "データベースを複数のウィキ間、あるいはMediaWikiと他のウェブアプリケーションで共有する必要がある場合、衝突を避けるために、すべてのテーブル名に接頭辞を付ける必要があります。\n空白は使用できません。\n\nこのフィールドは、通常は空のままです。",
+ "config-mysql-old": "MySQL $1 以降が必要です。ご使用中の MySQL は $2 です。",
+ "config-db-port": "データベースのポート:",
+ "config-db-schema": "MediaWiki のスキーマ:",
+ "config-db-schema-help": "通常はこのスキーマで問題ありません。\n必要な場合のみ変更してください。",
+ "config-pg-test-error": "データベース <strong>$1</strong> に接続できません: $2",
+ "config-sqlite-dir": "SQLite データ ディレクトリ:",
+ "config-sqlite-dir-help": "SQLite は単一のファイル内にすべてのデータを格納しています。\n\n指定したディレクトリは、インストール時にウェブ サーバーが書き込めるようにしておく必要があります。\n\nこのディレクトリはウェブからアクセス<strong>不可能</strong>である必要があります。PHP ファイルがある場所には配置できないのはこのためです。\n\nインストーラーは <code>.htaccess</code> ファイルにも書き込みます。しかし、これが失敗した場合は、誰かが生のデータベースにアクセスできてしまいます。\nデータベースは、生のデータ (メールアドレス、パスワードのハッシュ値) の他、削除された版、その他ウィキ上の制限されているデータを含んでいます。\n\n例えば <code>/var/lib/mediawiki/yourwiki</code> のように、別の場所にデータベースを配置することを検討してください。",
+ "config-oracle-def-ts": "既定のテーブル領域:",
+ "config-oracle-temp-ts": "一時的なテーブル領域:",
+ "config-type-mysql": "MySQL(または互換製品)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "マイクロソフト SQL Server",
+ "config-support-info": "MediaWiki は以下のデータベース システムに対応しています:\n\n$1\n\n使用しようとしているデータベース システムが下記の一覧にない場合は、上記リンク先の手順に従ってインストールしてください。",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL]はMediaWikiの主要な対象であり、最もよくサポートされています。MediaWikiはMySQLと互換性のある[{{int:version-db-mariadb-url}} MariaDB]、[{{int:version-db-percona-url}} Percona Server]でも動きます。 ([http://www.php.net/manual/ja/mysqli.installation.php PHPをMySQLサポート付きでコンパイルする方法])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] は、MySQLの代替として人気がある公開のデータベースシステムです。([http://www.php.net/manual/en/pgsql.installation.php PHPをPostgreSQLサポート付きでコンパイルする方法])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite]は、良くサポートされている、軽量データベースシステムです。([http://www.php.net/manual/ja/pdo.installation.php SQLiteに対応したPHPをコンパイルする方法]、PDOを使用)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle]は商業企業のデータベースです。([http://www.php.net/manual/en/oci8.installation.php OCI8サポートなPHPをコンパイルする方法])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server]は商業企業のWindows用データベースです。([http://www.php.net/manual/en/sqlsrv.installation.php SQLSRVサポートなPHPをコンパイルする方法])",
+ "config-header-mysql": "MySQL の設定",
+ "config-header-postgres": "PostgreSQL の設定",
+ "config-header-sqlite": "SQLite の設定",
+ "config-header-oracle": "Oracle の設定",
+ "config-header-mssql": "Microsoft SQL Server の設定",
+ "config-invalid-db-type": "データベースの種類が無効です。",
+ "config-missing-db-name": "「{{int:config-db-name}}」を入力してください",
+ "config-missing-db-host": "「{{int:config-db-host}}」を入力してください。",
+ "config-missing-db-server-oracle": "「{{int:config-db-host-oracle}}」の値を入力してください",
+ "config-invalid-db-server-oracle": "「$1」は無効なデータベース TNS です。\n「TNS 名」「Easy Connect」文字列のいずれかを使用してください ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle ネーミング メソッド])。",
+ "config-invalid-db-name": "「$1」は無効なデータベース名です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_)、ハイフン (-) のみを使用してください。",
+ "config-invalid-db-prefix": "「$1」は無効なデータベース接頭辞です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_)、ハイフン (-) のみを使用してください。",
+ "config-connection-error": "$1。\n\n以下のホスト名、ユーザー名、パスワードを確認してから再度試してください。",
+ "config-invalid-schema": "「$1」は MediaWiki のスキーマとして無効です。\n半角の英数字 (a-z、A-Z、0-9)、アンダースコア (_) のみを使用してください。",
+ "config-db-sys-create-oracle": "インストーラーは、新規アカウント作成にはSYSDBAアカウントの利用のみをサポートしています。",
+ "config-db-sys-user-exists-oracle": "利用者アカウント「$1」は既に存在します。SYSDBA は新しいアカウントの作成のみに使用できます!",
+ "config-postgres-old": "PostgreSQL $1 以降が必要です。ご使用中の PostgreSQL は $2 です。",
+ "config-mssql-old": "Microsoft SQL Server $1 以降が必要です。ご使用中の Microsoft SQL Server は $2 です。",
+ "config-sqlite-name-help": "あなたのウェキと同一性のある名前を選んでください。\n空白およびハイフンは使用しないでください。\nSQLiteのデータファイル名として使用されます。",
+ "config-sqlite-parent-unwritable-group": "データ ディレクトリ <code><nowiki>$1</nowiki></code> を作成できません。ウェブ サーバーは親ディレクトリ <code><nowiki>$2</nowiki></code> に書き込めませんでした。\n\nインストーラーは、ウェブ サーバーの実行ユーザーを特定しました。\n続行するには、ディレクトリ <code><nowiki>$3</nowiki></code> に書き込めるようにしてください。\nUnix または Linux であれば、以下を実行してください:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "データ ディレクトリ <code><nowiki>$1</nowiki></code> を作成できません。ウェブ サーバーは、親ディレクトリ <code><nowiki>$2</nowiki></code> に書き込めませんでした。\n\nインストーラーは、ウェブ サーバーの実行ユーザーを特定できませんでした。\n続行するには、ディレクトリ <code><nowiki>$3</nowiki></code> に、ウェブ サーバー (と、あらゆる人々!) がグローバルに書き込めるようにしてください。\nUnix または Linux では、以下を実行してください:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "データ ディレクトリ「$1」を作成する際にエラーが発生しました。\n場所を確認してから、再度試してください。",
+ "config-sqlite-dir-unwritable": "ディレクトリ「$1」に書き込めません。\nウェブ サーバーが書き込めるようにパーミッションを変更してから、再度試してください。",
+ "config-sqlite-connection-error": "$1。\n\nデータ ディレクトリおよびデータベース名を確認してから、再度試してください。",
+ "config-sqlite-readonly": "ファイル <code>$1</code> に書き込めません。",
+ "config-sqlite-cant-create-db": "データベース ファイル <code>$1</code> を作成できませんでした。",
+ "config-sqlite-fts3-downgrade": "PHP が FTS3 に対応していないため、テーブルをダウングレードしています。",
+ "config-can-upgrade": "このデータベースには MediaWiki テーブルがあります。\nこれらのテーブルを MediaWiki $1 にアップグレードするには、<strong>続行</strong>をクリックしてください。",
+ "config-upgrade-done": "更新は完了しました。\n\n[$1 ウィキを使い始める]ことができます。\n\n<code>LocalSettings.php</code> ファイルを再生成したい場合は、下のボタンを押してください。\nウィキに問題がある場合を除き、再生成は<strong>推奨されません</strong>。",
+ "config-upgrade-done-no-regenerate": "アップグレードが完了しました。\n\n[$1 ウィキの使用を開始]することができます。",
+ "config-regenerate": "LocalSettings.php を再生成→",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code> クエリが失敗しました!",
+ "config-unknown-collation": "<strong>警告:</strong> データベースは認識されない照合を使用しています。",
+ "config-db-web-account": "ウェブアクセスのためのデータベースアカウント",
+ "config-db-web-help": "ウィキの通常の操作の際に、ウェブ サーバーがデータベース サーバーに接続できるように、ユーザー名とパスワードを指定してください。",
+ "config-db-web-account-same": "インストール作業と同じアカウントを使用する",
+ "config-db-web-create": "アカウントが存在しない場合は作成する",
+ "config-db-web-no-create-privs": "あなたがインストールのために定義したアカウントは、アカウント作成のための特権としては不充分です。\nあなたがここで指定したアカウントは既に存在している必要があります。",
+ "config-mysql-engine": "ストレージ エンジン:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>警告:</strong> MySQLのストレージエンジンとして MyISAM を選択していますが、これをMediaWikiで利用するのは推奨されていません。その理由は: \n* テーブルロックによる並列性をほとんどサポートしていない\n* 他のエンジンに比べて壊れやすい\n* MediaWiki のコードベースは必ずしも MyISAM を本来あるべきほどには扱っていない\n\nあなたがインストールした MySQL が InnoDB をサポートしている場合、代わりにそちらをお使いになることを強くお勧めします。\nあなたがインストールした MySQL が InnoDB をサポートしていない場合、アップグレードした方がいいでしょう。",
+ "config-mysql-only-myisam-dep": "<strong>警告:</strong> MyISAM がこのマシンの MySQL の唯一のストレージエンジンですが、これをMediaWikiで利用するのは推奨されていません。その理由は: \n* テーブルロックによる並列性をほとんどサポートしていない\n* 他のエンジンに比べて壊れやすい\n* MediaWiki のコードベースは必ずしも MyISAM を本来あるべきほどには扱っていない\n\nあなたがインストールした MySQL が InnoDB をサポートしていない場合、アップグレードした方がいいでしょう。",
+ "config-mysql-engine-help": "<strong>InnoDB</strong>は、並行処理のサポートに優れているので、ほとんどの場合において最良の選択肢です。\n\n<strong>MyISAM</strong>は、利用者が1人の場合、あるいは読み込み専用でインストールする場合に、より処理が早くなるでしょう。\nただし、MyISAMのデータベースは、InnoDBより高頻度で破損する傾向があります。",
+ "config-mysql-charset": "データベースの文字セット:",
+ "config-mysql-binary": "バイナリ",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "<strong>バイナリ モード</strong>では、MediaWiki は、UTF-8 テキストをデータベースのバイナリ フィールドに格納します。\nこれは、MySQL の UTF-8 モードより効率的で、Unicode 文字の全範囲を利用できるようになります。\n\n<strong>UTF-8 モード</strong>では、MySQL は、データ内で使用している文字集合を知っているため、適切に表現や変換ができますが、\n[https://ja.wikipedia.org/wiki/%E5%9F%BA%E6%9C%AC%E5%A4%9A%E8%A8%80%E8%AA%9E%E9%9D%A2 基本多言語面]の外にある文字を格納できません。",
+ "config-mssql-auth": "認証の種類:",
+ "config-mssql-install-auth": "インストール過程でデータベースに接続するために使用する認証の種類を選択してください。\n「{{int:config-mssql-windowsauth}}」を選択した場合、ウェブサーバーを実行しているユーザーの認証情報が使用されます。",
+ "config-mssql-web-auth": "ウィキの通常の操作の際にウェブサーバーがデータベースサーバーに接続するために使用する認証の種類を選択してください。\n「{{int:config-mssql-windowsauth}}」を選択した場合、ウェブサーバーを実行しているユーザーの認証情報が使用されます。",
+ "config-mssql-sqlauth": "SQL Server 認証",
+ "config-mssql-windowsauth": "Windows 認証",
+ "config-site-name": "ウィキ名:",
+ "config-site-name-help": "この事象はブラウザーのタイトルバーと他のさまざまな場所に現れる。",
+ "config-site-name-blank": "サイト名を入力してください。",
+ "config-project-namespace": "プロジェクト名前空間:",
+ "config-ns-generic": "プロジェクト",
+ "config-ns-site-name": "ウィキ名と同じ: $1",
+ "config-ns-other": "その他 (指定してください)",
+ "config-ns-other-default": "マイウィキ",
+ "config-project-namespace-help": "ウィキペディアの例に従い、多くのウィキは、コンテンツのページとは分離したポリシーページを'''プロジェクトの名前空間'''に持っています。\nこの名前空間内のページのページ名はすべて特定の接頭辞で始まります。それをここで指定できます。\n通常、この接頭辞はウィキ名に基づきますが、「#」や「:」のような区切り文字を含めることはできません。",
+ "config-ns-invalid": "指定した名前空間「<nowiki>$1</nowiki>」は無効です。\n別のプロジェクト名前空間を指定してください。",
+ "config-ns-conflict": "指定された名前空間「\"<nowiki>$1</nowiki>\" 」は、MediaWikiのデフォルト名前空間と衝突しています。\n他のプロジェクト名前空間を指定してください。",
+ "config-admin-box": "管理アカウント",
+ "config-admin-name": "利用者名:",
+ "config-admin-password": "パスワード:",
+ "config-admin-password-confirm": "パスワードの再入力:",
+ "config-admin-help": "希望するユーザー名をここに入力してください (例:「Joe Bloggs」)。\nこの名前でこのウィキにログインすることになります。",
+ "config-admin-name-blank": "管理者のユーザー名を入力してください。",
+ "config-admin-name-invalid": "指定したユーザー名「<nowiki>$1</nowiki>」は無効です。\n別のユーザー名を指定してください。",
+ "config-admin-password-blank": "管理者アカウントのパスワードを入力してください。",
+ "config-admin-password-mismatch": "入力された2つのパスワードが一致しません。",
+ "config-admin-email": "メールアドレス:",
+ "config-admin-email-help": "メールアドレスを入力してください。他の利用者からのメールの受け取り、パスワードのリセット、ウォッチリストに登録したページの更新通知に使用します。空欄のままにすることもできます。",
+ "config-admin-error-user": "「<nowiki>$1</nowiki>」という名前の管理者を作成する際に内部エラーが発生しました。",
+ "config-admin-error-password": "管理者「<nowiki>$1</nowiki>」のパスワードを設定する際に内部エラーが発生しました: <pre>$2</pre>",
+ "config-admin-error-bademail": "無効なメールアドレスを入力しました。",
+ "config-subscribe": "[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce リリース告知のメーリングリスト]を購読する。",
+ "config-subscribe-help": "これは、リリースの告知 (重要なセキュリティに関する案内を含む) に使用される、流量が少ないメーリングリストです。\nこのメーリングリストを購読して、新しいバージョンが出た場合にMediaWikiを更新してください。",
+ "config-subscribe-noemail": "メールアドレスなしでリリースアナウンスのメーリングリストを購読しようとしています。\nメーリングリストを購読する場合にはメールアドレスを入力してください。",
+ "config-pingback": "このインストールに関するデータをMediaWikiの開発者と共有する。",
+ "config-pingback-help": "もしこのオプションを選択すると、メディアウィキは定期的にhttps://www.mediawiki.orgとメディアウィキのインスタンスに関する基本データを呼び出します。このデータは例えばシステムのタイプ、PHPのバージョンと選択したデータベースのバックエンドなどを含んでいます。メディアウィキ財団はメディアウィキ開発者とこの情報を共有し、将来の開発の方向付けに役立たせます。ご使用のシステムに送るデータは次のとおりです。\n<pre>$1</pre>",
+ "config-almost-done": "これでほぼ終わりました!\n残りの設定を飛ばして、ウィキを今すぐインストールできます。",
+ "config-optional-continue": "私にもっと質問してください。",
+ "config-optional-skip": "もう飽きてしまったので、とにかくウィキをインストールしてください。",
+ "config-profile": "利用者権限のプロファイル:",
+ "config-profile-wiki": "公開ウィキ",
+ "config-profile-no-anon": "アカウントの作成が必要",
+ "config-profile-fishbowl": "承認された編集者のみ",
+ "config-profile-private": "非公開ウィキ",
+ "config-profile-help": "ウィキは、できるだけ多くの人が編集できるようにすると最も優れた働きをします。\nMediaWikiでは、最近の更新を確認しやすく、神経質な、または悪意を持った利用者からの損害を簡単に差し戻せます。\n\nしかし一方で、MediaWikiは、さらにさまざまな形態での利用も優れていると言われています。また、時には、すべての人にウィキ手法の利点を説得させるのは容易ではないかもしれません。\nそこで、選択肢があります。\n\n「<strong>{{int:config-profile-wiki}}</strong>」モデルでは、ログインしなくても、誰でも編集できます。\n「<strong>{{int:config-profile-no-anon}}</strong>」なウィキでは、各編集に対してより強い説明責任を付与しますが、気軽な投稿を阻害するかもしれません。\n\n「<strong>{{int:config-profile-fishbowl}}</strong>」シナリオでは、承認された利用者のみが編集でき、一般の人はページ (とその履歴) を閲覧できます。\n「<strong>{{int:config-profile-private}}</strong>」では、承認された利用者のみがページを閲覧でき、そのグループが編集できます。\n\nより複雑な利用者権限の設定は、インストール後に設定できます。詳細は[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights 関連するマニュアル]をご覧ください。",
+ "config-license": "著作権とライセンス:",
+ "config-license-none": "ライセンスのフッターを付けない",
+ "config-license-cc-by-sa": "クリエイティブ・コモンズ 表示-継承",
+ "config-license-cc-by": "クリエイティブ・コモンズ 表示",
+ "config-license-cc-by-nc-sa": "クリエイティブ・コモンズ 表示-非営利-継承",
+ "config-license-cc-0": "クリエイティブ・コモンズ・ゼロ(パブリックドメイン)",
+ "config-license-gfdl": "GNU フリー文書利用許諾契約書 1.3 以降",
+ "config-license-pd": "パブリック・ドメイン",
+ "config-license-cc-choose": "その他のクリエイティブ・コモンズ・ライセンスを選択する",
+ "config-license-help": "多くの公開ウィキでは、すべての寄稿物が[http://freedomdefined.org/Definition フリーライセンス]のもとに置かれています。\nこうすることにより、コミュニティによる共有の感覚が生まれ、長期的な寄稿が促されます。\n私的ウィキや企業のウィキでは、通常、フリーライセンスにする必要はありません。\n\nウィキペディアにあるテキストをあなたのウィキで利用し、逆にあなたのウィキにあるテキストをウィキペディアに複製することを許可したい場合には、<strong>{{int:config-license-cc-by-sa}}</strong>を選択するべきです。\n\nウィキペディアは以前、GNUフリー文書利用許諾契約書(GFDL)を使用していました。\nGFDLは有効なライセンスですが、内容を理解するのは困難です。\nまた、GFDLのもとに置かれているコンテンツの再利用も困難です。",
+ "config-email-settings": "メールの設定",
+ "config-enable-email": "メール送信を有効にする",
+ "config-enable-email-help": "メールを使用したい場合は、[http://www.php.net/manual/en/mail.configuration.php PHP のメール設定]が正しく設定されている必要があります。\nメールの機能を使用しない場合は、ここで無効にすることができます。",
+ "config-email-user": "利用者間のメールを有効にする",
+ "config-email-user-help": "設定で有効になっている場合、すべてのユーザーがお互いにメールのやりとりを行うことを許可する。",
+ "config-email-usertalk": "ユーザーのトークページでの通知を有効にする",
+ "config-email-usertalk-help": "設定で有効にしている場合は、ユーザーのトークページの変更の通知を受けることをユーザーに許可する。",
+ "config-email-watchlist": "ウォッチリストの通知を有効にする",
+ "config-email-watchlist-help": "利用者が設定で有効にしている場合、閲覧されたページに関する通知を受け取ることを許可する。",
+ "config-email-auth": "メールの認証を有効にする",
+ "config-email-auth-help": "この選択肢を有効にすると、利用者がメールアドレスを設定あるいは変更したときに送信されるリンクにより、そのアドレスを確認しなければならなくなります。\n認証済みのアドレスだけが、他の利用者からのメールや、変更通知のメールを受け取ることができます。\n公開ウィキでは、メール機能による潜在的な不正利用の防止のため、この選択肢を設定することが<strong>推奨</strong>されます。",
+ "config-email-sender": "返信先メールアドレス:",
+ "config-email-sender-help": "送信メールで返信先として使用するメールアドレスを入力してください。\nこのアドレスは、宛先不明の場合の通知の宛先になります。\n多くのメールサーバーでは、少なくともドメイン名部分は有効である必要があります。",
+ "config-upload-settings": "画像およびファイルのアップロード",
+ "config-upload-enable": "ファイルのアップロードを有効にする",
+ "config-upload-help": "ファイルのアップロードは、あなたのサーバーをセキュリティ上の潜在的な危険に晒します。\nこの詳細は、マニュアルの [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security security section] をお読みください。\n\nファイルのアップロードを有効にするには、MediaWiki のルート ディレクトリ内の <code>images</code> サブ ディレクトリのモードを変更します。これにより、ウェブ サーバーがそこに書き込めるようになります。\nそして、このオプションを有効にしてください。",
+ "config-upload-deleted": "削除されたファイルのためのディレクトリ:",
+ "config-upload-deleted-help": "削除されるファイルを保存するためのディレクトリを選択してください。\nこれがウェブからアクセスできないことが理想です。",
+ "config-logo": "ロゴ のURL:",
+ "config-logo-help": "MediaWiki の既定の外装では、サイドバー上部に135x160ピクセルのロゴ用の余白があります。\n適切なサイズの画像をアップロードして、その URL をここに入力してください。\n\nロゴが相対パスの場合は、<code>$wgStylePath</code> や <code>$wgScriptPath</code> を使用できます。\n\nロゴが不要の場合は、この欄を空白のままにしてください。",
+ "config-instantcommons": "Instant Commons 機能を有効にする",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] は、[https://commons.wikimedia.org/ ウィキメディア・コモンズ]のサイトにある画像、音声、その他のメディアをウィキ上で利用できるようにする機能です。\nこれを使用するには、MediaWiki がインターネットに接続できる必要があります。\n\nウィキメディア・コモンズ以外のウィキを同様に設定する手順など、この機能に関する詳細な情報は、[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos マニュアル]をご覧ください。",
+ "config-cc-error": "クリエイティブ・コモンズ・ライセンスの選択器から結果が得られませんでした。\nライセンスの名前を手動で入力してください。",
+ "config-cc-again": "もう一度選択してください...",
+ "config-cc-not-chosen": "希望するクリエイティブ・コモンズのライセンスを選択して、「proceed」をクリックしてください。",
+ "config-advanced-settings": "高度な設定",
+ "config-cache-options": "オブジェクトのキャッシュの設定:",
+ "config-cache-help": "オブジェクトのキャッシュを使用すると、頻繁に使用するデータをキャッシュするため MediaWiki の動作速度を改善できます。\n中〜大規模サイトではこれを有効にすることを強くお勧めします。小規模サイトでも同様に効果があります。",
+ "config-cache-none": "キャッシングしない(機能は取り払われます、しかもより大きなウィキサイト上でスピードの問題が発生します)",
+ "config-cache-accel": "PHPオブジェクトキャッシュ (APC、APCu、XCache、WinCache のいずれか)",
+ "config-cache-memcached": "memcached を使用 (追加の設定が必要)",
+ "config-memcached-servers": "memcached サーバー:",
+ "config-memcached-help": "Memcachedを使用するIPアドレスの一覧。\nカンマ区切りで、利用する特定のポートの指定が必要です。例:\n127.0.0.1:11211\n192.168.1.25:1234",
+ "config-memcache-needservers": "キャッシュタイプに Memcached を選択しましたが、サーバーを指定していません。",
+ "config-memcache-badip": "Memcached用に無効な IPアドレス ($1) を入力しています。",
+ "config-memcache-noport": "Memcached サーバー $1 で使用するポート番号を指定していません。\nポート番号が分からない場合、既定値は 11211 です。",
+ "config-memcache-badport": "Memcached のポート番号は $1 から $2 の範囲にしてください。",
+ "config-extensions": "拡張機能",
+ "config-extensions-help": "<code>./extensions</code> ディレクトリ内で、上に列挙した拡張機能を検出しました。\n\nこれらの拡張機能には追加の設定が必要な場合がありますが、今すぐ有効化できます。",
+ "config-skins": "外装",
+ "config-skins-help": "以上に挙げたスキンは<code>./skins</code>で検出されたものです。\n少なくともひとつを有効にして、デフォルトを選択してください。",
+ "config-skins-use-as-default": "この外装をデフォルトとして使う",
+ "config-skins-missing": "外装が見つかりませんでした。適切なものをいくつかインストールするまで、MediaWikiはフォールバック外装を使用します。",
+ "config-skins-must-enable-some": "少なくとも1つの有効化する外装を選択する必要があります。",
+ "config-skins-must-enable-default": "デフォルトとして選択された外装は有効である必要があります。",
+ "config-install-alreadydone": "<strong>警告:</strong> 既にMediaWikiがインストール済みで、再びインストールし直そうとしています。\n次のページへ進んでください。",
+ "config-install-begin": "「{{int:config-continue}}」を押すと、MediaWiki のインストールを開始できます。\n変更したい設定がある場合は、「{{int:config-back}}」を押してください。",
+ "config-install-step-done": "実行",
+ "config-install-step-failed": "失敗した",
+ "config-install-extensions": "拡張機能を含む",
+ "config-install-database": "データベースの構築",
+ "config-install-schema": "スキーマの作成",
+ "config-install-pg-schema-not-exist": "PostgreSQL スキーマがありません。",
+ "config-install-pg-schema-failed": "テーブルの作成に失敗しました。\n利用者「$1」がスキーマ「$2」に書き込めるようにしてください。",
+ "config-install-pg-commit": "変更を送信",
+ "config-install-pg-plpgsql": "PL/pgSQLの言語をチェックしています",
+ "config-pg-no-plpgsql": "データベース $1 内に PL/pgSQL 言語をインストールする必要があります。",
+ "config-pg-no-create-privs": "インストール用に指定したアカウントには、アカウントを作成するのに十分な特権がありません。",
+ "config-pg-not-in-role": "ウェブユーザー用に指定したアカウントはすでに存在しています。\nインストール用に指定したアカウントはスーパーユーザーでなく、またウェブユーザーのロールを持ったものでもありません。そのためウェブユーザーが所有するオブジェクトを作成することができません。\n\nMediaWikiは現状では、ウェブユーザーが所有するテーブルを要求します。別のウェブアカウント名を指定するか、「戻る」をクリックして適切な権限を持つインストール用ユーザーを指定してください。",
+ "config-install-user": "データベースユーザーの作成",
+ "config-install-user-alreadyexists": "ユーザー「$1」は既に存在します",
+ "config-install-user-create-failed": "ユーザー「$1」の作成に失敗しました: $2",
+ "config-install-user-grant-failed": "ユーザー「$1」に許可を与えることに失敗しました: $2",
+ "config-install-user-missing": "指定したユーザー「$1」は存在しません。",
+ "config-install-user-missing-create": "指定したユーザー「$1」は存在しません。\nアカウントを作成する場合は、下の「アカウント作成」をクリックしてください。",
+ "config-install-tables": "テーブルの作成",
+ "config-install-tables-exist": "<strong>警告:</strong> MediaWiki テーブルは既に存在するようです。\n作成を省略します。",
+ "config-install-tables-failed": "<strong>エラー:</strong> テーブルの作成が、以下のエラーにより失敗しました: $1",
+ "config-install-interwiki": "既定のウィキ間テーブルの導入",
+ "config-install-interwiki-list": "ファイル <code>interwiki.list</code> から読み取れませんでした。",
+ "config-install-interwiki-exists": "<strong>警告:</strong> ウィキ間テーブルは既に登録されているようです。\n既定のテーブルを無視します。",
+ "config-install-stats": "統計情報の初期化",
+ "config-install-keys": "秘密鍵の生成",
+ "config-insecure-keys": "<strong>警告:</strong> インストール中に生成されたセキュアキー ($1) は完璧に安全ではありません。手動で変更することを検討してください。",
+ "config-install-updates": "不要な更新を実行するのを防ぐ",
+ "config-install-updates-failed": "<strong>エラー:</strong> 更新キーをテーブルに挿入する際に失敗しました。以下のエラーが起こっています: $1",
+ "config-install-sysop": "管理者のアカウントの作成",
+ "config-install-subscribe-fail": "mediawiki-announce を購読できませんでした: $1",
+ "config-install-subscribe-notpossible": "cURL がインストールされていないため、<code>allow_url_fopen</code> を利用できません。",
+ "config-install-mainpage": "メインページを既定の内容で作成",
+ "config-install-extension-tables": "有効にした拡張機能のためのテーブルを作成しています",
+ "config-install-mainpage-failed": "メインページを挿入できませんでした: $1",
+ "config-install-done": "<strong>おめでとうございます!</strong>\nMediaWikiのインストールに成功しました。\n\n<code>LocalSettings.php</code>ファイルが生成されました。\nこのファイルはすべての設定を含んでいます。\n\nこれをダウンロードして、ウィキをインストールした基準ディレクトリ (index.phpと同じディレクトリ) に設置する必要があります。ダウンロードは自動的に開始されるはずです。\n\nダウンロードが開始されていない場合、またはダウンロードをキャンセルした場合は、下記のリンクをクリックしてダウンロードを再開できます:\n\n$3\n\n<strong>注意:</strong> この生成された設定ファイルをダウンロードせずにインストールを終了すると、このファイルは利用できなくなります。\n\n上記の作業が完了すると、<strong>[$2 ウィキに入る]</strong>ことができます。",
+ "config-install-done-path": "<strong>おめでとうございます!</strong>\nMediaWikiのインストールに成功しました。\n\n<code>LocalSettings.php</code>ファイルが生成されました。\nこのファイルはすべての設定を含んでいます。\n\nこれをダウンロードして、<code>$4</code> に設置する必要があります。ダウンロードは自動的に開始されるはずです。\n\nダウンロードが開始されていない場合、またはダウンロードをキャンセルした場合は、下記のリンクをクリックしてダウンロードを再開できます:\n\n$3\n\n<strong>注意:</strong> この生成された設定ファイルをダウンロードせずにインストールを終了すると、このファイルは利用できなくなります。\n\n上記の作業が完了すると、<strong>[$2 ウィキに入る]</strong>ことができます。",
+ "config-download-localsettings": "<code>LocalSettings.php</code> をダウンロード",
+ "config-help": "ヘルプ",
+ "config-help-tooltip": "クリックで展開",
+ "config-nofile": "ファイル「$1」が見つかりませんでした。削除された可能性があります。",
+ "config-extension-link": "あなたのウィキは[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions 拡張機能]をサポートしていることをご存知ですか?\n\n[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category カテゴリ別で拡張機能を見る]か[https://www.mediawiki.org/wiki/Extension_Matrix 拡張機能のマトリックス]で拡張機能すべてのリストをご覧になれます。",
+ "mainpagetext": "<strong>MediaWiki はインストール済みです。</strong>",
+ "mainpagedocfooter": "ウィキソフトウェアの使い方に関する情報は[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 利用者案内]を参照してください。\n\n== はじめましょう ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings/ja 設定の一覧]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/ja MediaWiki よくある質問と回答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki リリース情報メーリングリスト]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation/ja MediaWiki のあなたの言語へのローカライズ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam あなたのウィキでスパムと戦う方法を学ぶ]"
+}
diff --git a/www/wiki/includes/installer/i18n/jam.json b/www/wiki/includes/installer/i18n/jam.json
new file mode 100644
index 00000000..e6cccdbd
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/jam.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Yocahuna"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki don instaal soksesful.'''",
+ "mainpagedocfooter": "Kansolt di [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] fi infamieshan ou fi yuuz di wiki saafwier.\n\n== Taatop ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/jbo.json b/www/wiki/includes/installer/i18n/jbo.json
new file mode 100644
index 00000000..e52919fb
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/jbo.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "Xbony2"
+ ]
+ },
+ "config-information": "lo datni",
+ "config-page-name": "lo cmene",
+ "config-page-options": "lo cuxna",
+ "config-page-install": "lo instale",
+ "config-page-copying": "nu lo fukpi"
+}
diff --git a/www/wiki/includes/installer/i18n/jut.json b/www/wiki/includes/installer/i18n/jut.json
new file mode 100644
index 00000000..4dc6d111
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/jut.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Huslåke",
+ "Jyllanj"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki ä nu installiirtj.'''",
+ "mainpagedocfooter": "Sie [https://meta.wikimedia.org/wiki/Help:Contents brugewejliednengen] for oplysnenge om brugi å wikiprogramme.\n\n== Å kom i gång ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Listen öwe opsättnengsmulihede]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki oft stellen spöyrgsmol]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Postlist ångoenje utdjielse å MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Öwesätt MediaWiki te det spraw]"
+}
diff --git a/www/wiki/includes/installer/i18n/jv.json b/www/wiki/includes/installer/i18n/jv.json
new file mode 100644
index 00000000..2d93ba5e
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/jv.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Anggoro",
+ "NoiX180"
+ ]
+ },
+ "config-install-mainpage-failed": "Ora bisa nglebokaké tepas: $1",
+ "mainpagetext": "'''Prangkat empuk wiki wis suksès dipasang.'''",
+ "mainpagedocfooter": "Mangga maca [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] kanggo katrangan luwih langkung prakara panggunan prangkat empuk wiki\n== Miwiti panggunan ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Daftar pangaturan préférènsi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Milis rilis MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Learn how to combat spam on your wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/ka.json b/www/wiki/includes/installer/i18n/ka.json
new file mode 100644
index 00000000..f5771aa3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ka.json
@@ -0,0 +1,102 @@
+{
+ "@metadata": {
+ "authors": [
+ "David1010",
+ "BRUTE"
+ ]
+ },
+ "config-information": "ინფორმაცია",
+ "config-your-language": "თქვენი ენა:",
+ "config-wiki-language": "ვიკის ენა:",
+ "config-back": "← უკან",
+ "config-continue": "გაგრძელება →",
+ "config-page-language": "ენა",
+ "config-page-welcome": "კეთილი იყოს თქვენი მობრძანება მედიავიკიში!",
+ "config-page-dbconnect": "მონაცემთა ბაზასთან დაკავშირება",
+ "config-page-dbsettings": "მონაცემთა ბაზის კონფიგურაცია",
+ "config-page-name": "სახელი",
+ "config-page-options": "პარამეტრები",
+ "config-page-install": "ინსტალაცია",
+ "config-page-complete": "დასრულებულია!",
+ "config-page-restart": "ინსტალაციის თავიდან დაწყება",
+ "config-page-readme": "წამიკითხე",
+ "config-page-copying": "ლიცენზია",
+ "config-page-upgradedoc": "განახლება",
+ "config-page-existingwiki": "არსებული ვიკი",
+ "config-restart": "დიახ, თავიდან დაიწყეთ",
+ "config-sidebar": "* [https://www.mediawiki.org მედიავიკის ვებ-გვერდი]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/ka მომხმარებლების დახმარება]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/ka ადმინისტრატორების დახმარება]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/ka FAQ]\n----\n* <doclink href=Readme>წამიკითხე</doclink>\n* <doclink href=ReleaseNotes>ინფორმაცია გამოშვებაზე</doclink>\n* <doclink href=Copying>ლიცენზია</doclink>\n* <doclink href=UpgradeDoc>განახლება</doclink>",
+ "config-env-php": "PHP $1 დაინსტალირებულია",
+ "config-env-hhvm": "HHVM $1 დაინსტალირებულია.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] დაყენდა",
+ "config-apc": "[http://www.php.net/apc APC] დაყენდა",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] დაყენდა",
+ "config-diff3-bad": "GNU diff3 ვერ მოიძებნა.",
+ "config-db-type": "მონაცემთა ბაზის ტიპი:",
+ "config-db-host-oracle": "მონაცემთა ბაზის TNS:",
+ "config-db-name": "მონაცემთა ბაზის სახელი:",
+ "config-db-name-oracle": "მონაცემთა ბაზის სქემა:",
+ "config-db-username": "მონაცემთა ბაზის მომხმარებლის სახელი:",
+ "config-db-password": "მონაცემთა ბაზის პაროლი:",
+ "config-db-port": "მონაცემთა ბაზის პორტი:",
+ "config-db-schema": "მედიავიკის სქემა:",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "MySQL-ის პარამეტრები",
+ "config-header-postgres": "PostgreSQL-ის პარამეტრები",
+ "config-header-sqlite": "SQLite-ის პარამეტრები",
+ "config-header-oracle": "Oracle-ის პარამეტრები",
+ "config-invalid-db-type": "არასწორი მონაცემთა ბაზის ტიპი",
+ "config-sqlite-readonly": "ფაილი <code>$1</code> ჩასაწერად მიუწვდომელია.",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "ორობითი",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "ვიკის სახელი:",
+ "config-site-name-blank": "შეიყვანეთ ვებ-გვერდის სახელი.",
+ "config-project-namespace": "პროექტის სახელთა სივრცე:",
+ "config-ns-generic": "პროექტი",
+ "config-ns-other": "სხვა (მიუთითეთ)",
+ "config-ns-other-default": "ჩემი ვიკი",
+ "config-admin-box": "ადმინისტრატორის ანგარიში",
+ "config-admin-name": "თქვენი მომხმარებლის სახელი:",
+ "config-admin-password": "პაროლი:",
+ "config-admin-password-confirm": "პაროლი ხელმეორედ:",
+ "config-admin-name-blank": "შეიყვანეთ ადმინისტრატორის მომხმარებლის სახელი.",
+ "config-admin-password-blank": "შეიყვანეთ ადმინისტრატორის ანგარიშის პაროლი.",
+ "config-admin-password-mismatch": "თქვენ მიერ შეყვანილი პაროლები ერთმანეთს არ ემთხვევა.",
+ "config-admin-email": "ელ. ფოსტის მისამართი:",
+ "config-admin-error-bademail": "თქვენ მიერ შეყვანილი ელ.ფოსტა არასწორია.",
+ "config-subscribe": "გამოიწერეთ [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce მედიავიკის ახალი ვერსიის გამოსვლის სიახლეები].",
+ "config-optional-continue": "მკითხე მეტი.",
+ "config-profile": "მომხმარებელთა უფლებების პროფილი:",
+ "config-profile-wiki": "ღია ვიკი",
+ "config-profile-no-anon": "საჭიროა ანგარიშის შექმნა",
+ "config-profile-fishbowl": "მხოლოდ ავტორიზებული რედაქტორებისათვის",
+ "config-profile-private": "დახურული ვიკი",
+ "config-license": "საავტორო უფლები და ლიცენზია:",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (საზოგადოებრივი საკუთრება)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 ან უფრო გვიანი",
+ "config-license-pd": "საზოგადოებრივი საკუთრება",
+ "config-license-cc-choose": "აირჩიეთ Creative Commons-ის ლიცენზიიდან ერთ-ერთი",
+ "config-email-settings": "ელ. ფოსტის პარამეტრები",
+ "config-upload-settings": "სურათებისა და ფაილების ატვირთვა",
+ "config-upload-enable": "ფაილების ატვირთვის ჩართვა",
+ "config-upload-deleted": "წაშლილი ფაილების დირექტორია:",
+ "config-logo": "ლოგოს URL:",
+ "config-cc-again": "აირჩიეთ კიდევ ერთხელ...",
+ "config-advanced-settings": "დამატებითი კონფიგურაცია",
+ "config-extensions": "გაფართოებები",
+ "config-install-step-done": "შესრულდა",
+ "config-install-step-failed": "ვერ მოხერხდა",
+ "config-install-schema": "სქემის შექმნა",
+ "config-install-user-alreadyexists": "მომხმარებელი \"$1\" უკვე არსებობს",
+ "config-install-tables": "ცხრილების შექმნა",
+ "config-install-interwiki-list": "ვერ მოიძებნა ფაილი <code>interwiki.list</code>.",
+ "config-download-localsettings": "<code>LocalSettings.php</code>-ის გადმოწერა",
+ "config-help": "დახმარება",
+ "config-help-tooltip": "გასაშლელად დააწკაპუნეთ",
+ "mainpagetext": "'''მედიავიკი წარმატებით ჩაიტვირთა.'''",
+ "mainpagedocfooter": "ვიკი პროგრამის გამოყენების ინფორმაციისთვის იხილეთ [https://meta.wikimedia.org/wiki/Help:Contents მომხმარებლის მეგზური].\n\n== დაწყება ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings კონფიგურაციის მაჩვენებლების სია]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce მედიავიკის გამოცემის დაგზავნის სია]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources მედიავიკის ლოკალიზება თქვენ ენაზე]"
+}
diff --git a/www/wiki/includes/installer/i18n/kaa.json b/www/wiki/includes/installer/i18n/kaa.json
new file mode 100644
index 00000000..42d9fbbc
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/kaa.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki tabıslı ornatıldı.'''",
+ "mainpagedocfooter": "Wiki bag'darlamasın qollanıw haqqındag'i mag'lıwmat usın [https://meta.wikimedia.org/wiki/Help:Contents Paydalanıwshılar qollanbasınan] ken'es alın'.\n\n== Baslaw ushın ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Konfiguratsiya sazlaw dizimi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWikidin' Ko'p Soralatug'ın Sorawları]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki haqqında xat tarqatıw dizimi]"
+}
diff --git a/www/wiki/includes/installer/i18n/kbd-cyrl.json b/www/wiki/includes/installer/i18n/kbd-cyrl.json
new file mode 100644
index 00000000..3d9b1a57
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/kbd-cyrl.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bogups",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''«MediaWiki» узыншу хэгъува.'''",
+ "mainpagedocfooter": "Мы виким и лэжьыгъэ хъыбархэр здэбгъуэтыфынур [https://meta.wikimedia.org/wiki/Help:Contents/ru дэӀэпыкъуэгъу тхылъым].\n\n== Къыщхьэпэгъуэ хъуфынухэр ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Зэгъэзэхуэгъуэ гуэрэхэм я тхылъ];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-м упщӀэ нахъыбу ятхэмрэ я жэуапхэмрэ];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki-м и версиэ щӀэуэ къэжахэм я къэӀохугъуэ].\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/khw.json b/www/wiki/includes/installer/i18n/khw.json
new file mode 100644
index 00000000..a2e90b6e
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/khw.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Rachitrali"
+ ]
+ },
+ "mainpagetext": "'''میڈیاوکیو کامیابیو سورا چالو کورونو بیتی شیر۔.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/kiu.json b/www/wiki/includes/installer/i18n/kiu.json
new file mode 100644
index 00000000..dd8091e7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/kiu.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mirzali"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki fist ra ser, vıraziya.'''",
+ "mainpagedocfooter": "Serba melumatê gurenaena ''wiki software''i [https://meta.wikimedia.org/wiki/Help:Contents İdarê karberi] de mıracaet ke.\n\n== Gamê verêni ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista ayarunê vırastene]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki de ÇZP]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki ra lista serbest-daena postey]"
+}
diff --git a/www/wiki/includes/installer/i18n/kk-arab.json b/www/wiki/includes/installer/i18n/kk-arab.json
new file mode 100644
index 00000000..6f077369
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/kk-arab.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''مەدىياۋىيكىي بۋماسى ٴساتتى ورناتىلدى.'''",
+ "mainpagedocfooter": "ۋىيكىي باعدارلامالىق جاساقتاماسىن قالاي قولداناتىن اقپاراتى ٴۇشىن [https://meta.wikimedia.org/wiki/Help:Contents پايدالانۋشىلىق نۇسقاۋلارىنان] كەڭەس الىڭىز.\n\n== باستاۋ ٴۇشىن ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings باپتالىم قالاۋلارىنىڭ ٴتىزىمى]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ مەدىياۋىيكىيدىڭ جىيى قويىلعان ساۋالدارى]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce مەدىياۋىيكىي شىعۋ تۋرالى حات تاراتۋ ٴتىزىمى]"
+}
diff --git a/www/wiki/includes/installer/i18n/kk-cyrl.json b/www/wiki/includes/installer/i18n/kk-cyrl.json
new file mode 100644
index 00000000..97abae80
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/kk-cyrl.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''МедиаУики бумасы сәтті орнатылды.'''",
+ "mainpagedocfooter": "Уики бағдарламалық жасақтамасын қалай қолданатын ақпараты үшін [https://meta.wikimedia.org/wiki/Help:Contents Пайдаланушылық нұсқауларынан] кеңес алыңыз.\n\n== Бастау үшін ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Бапталым қалауларының тізімі]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ МедиаУикидің Жиы Қойылған Сауалдары]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce МедиаУики шығу туралы хат тарату тізімі]"
+}
diff --git a/www/wiki/includes/installer/i18n/kk-latn.json b/www/wiki/includes/installer/i18n/kk-latn.json
new file mode 100644
index 00000000..95e95803
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/kk-latn.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MedïaWïkï bwması sätti ornatıldı.'''",
+ "mainpagedocfooter": "Wïkï bağdarlamalıq jasaqtamasın qalaý qoldanatın aqparatı üşin [https://meta.wikimedia.org/wiki/Help:Contents Paýdalanwşılıq nusqawlarınan] keñes alıñız.\n\n== Bastaw üşin ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Baptalım qalawlarınıñ tizimi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MedïaWïkïdiñ Jïı Qoýılğan Sawaldarı]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MedïaWïkï şığw twralı xat taratw tizimi]"
+}
diff --git a/www/wiki/includes/installer/i18n/km.json b/www/wiki/includes/installer/i18n/km.json
new file mode 100644
index 00000000..d2a85875
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/km.json
@@ -0,0 +1,35 @@
+{
+ "@metadata": {
+ "authors": [
+ "Thearith",
+ "គីមស៊្រុន",
+ "Sovichet",
+ "Seb35"
+ ]
+ },
+ "config-desc": "កម្មវិធី​ដំឡើង​សម្រាប់ MediaWiki",
+ "config-title": "ការ​ដំឡើង MediaWiki $1",
+ "config-information": "ព័ត៌មាន",
+ "config-localsettings-badkey": "អ្នក​បាន​ផ្ដល់​សោ​មិន​ត្រឹម​ត្រូវ។",
+ "config-your-language": "ភាសារបស់អ្នក៖",
+ "config-your-language-help": "ជ្រើសយកភាសាដើម្បីប្រើក្នុងពេលតំលើង។",
+ "config-wiki-language": "ភាសាវិគី៖",
+ "config-wiki-language-help": "ជ្រើសរើសភាសាដែលវិគីនេះប្រើជាចំបង។",
+ "config-back": "← ត្រលប់ក្រោយ",
+ "config-continue": "បន្ត →",
+ "config-page-language": "ភាសា",
+ "config-page-welcome": "មេឌាវិគីសូមស្វាគមន៍!",
+ "config-page-dbconnect": "ភ្ជាប់ទៅមូលដ្ឋានទិន្នន័យ",
+ "config-page-dbsettings": "ការកំណត់​មូលដ្ឋាន​ទិន្នន័យ",
+ "config-page-name": "ឈ្មោះ",
+ "config-page-options": "ជំរើស",
+ "config-page-install": "តំលើង",
+ "config-page-complete": "បញ្ចប់!",
+ "config-page-restart": "តំលើងឡើងវិញ",
+ "config-install-sysop": "កំពុងបង្កើតគណនីអភិបាល",
+ "config-help": "ជំនួយ",
+ "config-help-tooltip": "ចុចដើម្បីពន្លាត",
+ "config-nofile": "រកមិនឃើញឯកសារ \"$1\" ទេ។ វាប្រហែលជាត្រូវបានលុបចោលហើយ។",
+ "mainpagetext": "'''មេឌាវិគីត្រូវបានដំឡើងសំរេចហើយ​។'''",
+ "mainpagedocfooter": "សូមពិនិត្យមើល [https://meta.wikimedia.org/wiki/Help:Contents ខ្លឹមសារ​ណែនាំ​ប្រើប្រាស់]សម្រាប់​ព័ត៌មាន​​បន្ថែមអំពី​ការប្រើប្រាស់សូហ្វវែរវិគី​។\n\n== ការចាប់ផ្ដើម ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings បញ្ជីការកំណត់នានា]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/km សំណួរញឹកញាប់​ក្នុងមេឌាវិគី]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce បញ្ជី​អ៊ីមែលផ្សព្វផ្សាយ​របស់​មេឌាវិគី]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources ការប្រែសម្រួលមេឌាវិគីសម្រាប់ភាសារបស់អ្នក]"
+}
diff --git a/www/wiki/includes/installer/i18n/kn.json b/www/wiki/includes/installer/i18n/kn.json
new file mode 100644
index 00000000..aebaea73
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/kn.json
@@ -0,0 +1,48 @@
+{
+ "@metadata": {
+ "authors": [
+ "VASANTH S.N.",
+ "Pavanaja"
+ ]
+ },
+ "config-title": "ಮೀಡಿಯಾವಿಕಿ ಆವೃತ್ತಿ $1 ರ ಅನುಸ್ಥಾಪನೆ",
+ "config-information": "ಮಾಹಿತಿ",
+ "config-localsettings-key": "ಉನ್ನತೀಕರಣ ಕೀಲಿ",
+ "config-localsettings-badkey": "ನೀವು ನೀಡಿದ ಕೀಲಿ ಸರಿಯಾಗಿಲ್ಲ",
+ "config-session-error": "ಅವಧಿ ಪ್ರಾರಂಭ ದೋಷ: $1",
+ "config-your-language": "ನಿಮ್ಮ ಭಾಷೆ:",
+ "config-wiki-language": "ವಿಕಿ ಭಾಷೆ:",
+ "config-back": "← ವಾಪಸು",
+ "config-continue": "ಮುಂದುವರೆಸಿ →",
+ "config-page-language": "ಭಾಷೆ",
+ "config-page-welcome": "ಮಾಧ್ಯಮವಿಕಿಗೆ ಸ್ವಾಗತ",
+ "config-page-dbconnect": "ದತ್ತಾಂಶಸಂಚಯಕ್ಕೆ ಸಂಪರ್ಕನೀಡಿ",
+ "config-page-name": "ಹೆಸರು",
+ "config-page-options": "ಆಯ್ಕೆಗಳು",
+ "config-page-install": "ಸ್ಥಾಪಿಸು",
+ "config-page-complete": "ಪೂರ್ಣ!",
+ "config-page-readme": "ನನ್ನನ್ನು ಓದಿ",
+ "config-page-copying": "ನಕಲಿಸುತ್ತಿದೆ..",
+ "config-page-upgradedoc": "ಪರಿಷ್ಕರಿಸಲ್ಪಡುತ್ತಿದೆ",
+ "config-page-existingwiki": "ಪ್ರಸ್ತುತ ವಿಕಿ",
+ "config-restart": "ಸರಿ,ಪುನಃ ಪ್ರಾರಂಭಿಸಿ",
+ "config-db-type": "ದತ್ತಾಂಶಸಂಚಯ ಮಾದರಿ:",
+ "config-db-host-oracle": "ದತ್ತಾಂಶಸಂಚಯ TNS:",
+ "config-db-wiki-settings": "ಈ ವಿಕಿಯನ್ನು ಗುರುತಿಸಿ",
+ "config-db-name": "ದತ್ತಾಂಶಸಂಚಯ ಹೆಸರು:",
+ "config-db-username": "ದತ್ತಾಂಶಸಂಚಯ ಬಳಕೆದಾರಹೆಸರು:",
+ "config-db-password": "ದತ್ತಾಂಶಸಂಚಯ ಪ್ರವೇಶಪದ:",
+ "config-ns-generic": "ಯೋಜನೆ",
+ "config-admin-name": "ನಿಮ್ಮ ಬಳಕೆದಾರಹೆಸರು:",
+ "config-admin-password": "ಪ್ರವೇಶಪದ:",
+ "config-admin-password-confirm": "ಪುನಃ ಪ್ರವೇಶಪದ:",
+ "config-admin-password-mismatch": "ನೀವು ಕೊಟ್ಟ ಪ್ರವೇಶಪದಗಳು ಬೇರೆಬೇರೆಯಾಗಿವೆ.",
+ "config-admin-email": "ಮಿಂಚಂಚೆ ವಿಳಾಸ:",
+ "config-optional-continue": "ನನ್ನಲ್ಲಿ ಹೆಚ್ಚಿನ ಪ್ರಶ್ನೆಗಳನ್ನು ಕೇಳಿ.",
+ "config-license": "ಕೃತಿಸ್ವಾಮ್ಯ ಮತ್ತು ಪರವಾನಗಿ:",
+ "config-extensions": "ವಿಸ್ತರಣೆಗಳು",
+ "config-install-step-failed": "ವಿಫಲವಾಗಿದೆ",
+ "config-help": "ಸಹಾಯ",
+ "mainpagetext": "'''ವಿಕಿ ತಂತ್ರಾಂಶವನ್ನು ಯಶಸ್ವಿಯಾಗಿ ಅನುಸ್ಥಾಪಿಸಲಾಯಿತು.'''",
+ "mainpagedocfooter": "ವಿಕಿ ತಂತ್ರಾಂಶವನ್ನು ಬಳಸುವ ಬಗ್ಗೆ ಮಾಹಿತಿಗೆ [https://meta.wikimedia.org/wiki/Help:Contents ಬಳಕೆದಾರರಿಗೆ ನಿರ್ದೇಶನ ಪುಟ] ನೋಡಿ.\n\n== ಪ್ರಾರಂಭಿಸುವುದು ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ಮೀಡಿಯವಿಕಿ FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/ko.json b/www/wiki/includes/installer/i18n/ko.json
new file mode 100644
index 00000000..f268215f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ko.json
@@ -0,0 +1,327 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kwj2772",
+ "아라",
+ "Hym411",
+ "Priviet",
+ "Namoroka",
+ "Revi",
+ "Alex00728",
+ "Hwangjy9",
+ "Macofe",
+ "Mooozi",
+ "Ykhwong",
+ "Jerrykim306"
+ ]
+ },
+ "config-desc": "미디어위키를 위한 설치 관리자",
+ "config-title": "미디어위키 $1 설치",
+ "config-information": "정보",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code> 파일을 감지했습니다.\n이 설치를 업그레이드하려면, 아래 상자에 <code>$wgUpgradeKey</code>의 값을 입력하세요.\n<code>LocalSettings.php</code>에서 찾을 수 있습니다.",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code> 파일을 감지했습니다.\n이 설치를 업그레이드하려면 <code>update.php</code>를 대신 실행하세요",
+ "config-localsettings-key": "업그레이드 키:",
+ "config-localsettings-badkey": "업그레이드 키가 잘못되었습니다.",
+ "config-upgrade-key-missing": "미디어위키의 기존 설치를 감지했습니다.\n이 설치를 업그레이드하려면, <code>LocalSettings.php</code>의 아래에 다음 줄을 넣으세요:\n\n$1",
+ "config-localsettings-incomplete": "기존 <code>LocalSettings.php</code>가 완전하지 않은 것 같습니다.\n$1 변수가 설정되어 있지 않습니다.\n이 변수가 설정되도록 <code>LocalSettings.php</code>를 바꾸고 \"{{int:Config-continue}}\"을 클릭하세요.",
+ "config-localsettings-connection-error": "<code>LocalSettings.php</code>에 지정한 설정을 사용하여 데이터베이스에 연결할 때 오류가 발생했습니다. 이러한 설정을 고치고 다시 시도하세요.\n\n$1",
+ "config-session-error": "세션 시작 오류: $1",
+ "config-session-expired": "세션 데이터가 만료된 것 같습니다.\n세션은 $1의 작동 시간 동안 구성됩니다.\nphp.ini에 있는 <code>session.gc_maxlifetime</code>에서 설정해 이를 증가시킬 수 있습니다.\n설치 과정을 다시 시작하세요.",
+ "config-no-session": "세션 데이터가 없어졌습니다!\nphp.ini를 확인하고 <code>session.save_path</code>가 적절한 디렉토리로 설정되어 있는지 확인하세요.",
+ "config-your-language": "설치 언어:",
+ "config-your-language-help": "설치 과정에서 사용할 언어를 선택하세요.",
+ "config-wiki-language": "위키 언어:",
+ "config-wiki-language-help": "위키에 주로 작성될 언어를 선택하세요.",
+ "config-back": "← 뒤로",
+ "config-continue": "계속 →",
+ "config-page-language": "언어",
+ "config-page-welcome": "미디어위키에 오신 것을 환영합니다!",
+ "config-page-dbconnect": "데이터베이스에 연결",
+ "config-page-upgrade": "기존 설치 업그레이드",
+ "config-page-dbsettings": "데이터베이스 설정",
+ "config-page-name": "이름",
+ "config-page-options": "설정",
+ "config-page-install": "설치",
+ "config-page-complete": "완료!",
+ "config-page-restart": "설치 다시 시작",
+ "config-page-readme": "읽어보기",
+ "config-page-releasenotes": "릴리스 노트",
+ "config-page-copying": "전문",
+ "config-page-upgradedoc": "업그레이드하기",
+ "config-page-existingwiki": "기존 위키",
+ "config-help-restart": "입력한 모든 저장된 데이터를 지우고 설치 과정을 다시 시작하겠습니까?",
+ "config-restart": "예, 다시 시작합니다",
+ "config-welcome": "=== 사용 환경 검사 ===\n기본 검사는 지금 이 환경이 미디어위키 설치에 적합한지 수행합니다.\n설치를 완료하는 방법에 대한 지원을 찾는다면 이 정보를 포함해야 하는 것을 기억하세요.",
+ "config-copyright": "=== 저작권 및 약관 ===\n\n$1\n\n이 프로그램은 자유 소프트웨어입니다. 당신은 자유 소프트웨어 재단이 발표한 GNU 일반 공중 사용 허가서 버전 2나 그 이후 버전에 따라 이 프로그램을 재배포하거나 수정할 수 있습니다.\n\n이 프로그램이 유용하게 사용될 수 있기를 바라지만 <strong>상용으로 사용</strong>되거나 <strong>특정 목적에 맞을 것</strong>이라는 것을 <strong>보증하지 않습니다</strong>.\n자세한 내용은 GNU 일반 공중 사용 허가서를 참조하십시오.\n\n당신은 이 프로그램을 통해 <doclink href=Copying>GNU 일반 공중 사용 허가서 전문</doclink>을 받았습니다. 그렇지 않다면, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA로 편지를 보내주시거나 [http://www.gnu.org/copyleft/gpl.html 온라인으로 읽어보시기] 바랍니다.",
+ "config-sidebar": "* [https://www.mediawiki.org 미디어위키 홈]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 사용자 가이드]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents 관리자 가이드]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>읽어보기</doclink>\n* <doclink href=ReleaseNotes>릴리스 노트</doclink>\n* <doclink href=Copying>전문</doclink>\n* <doclink href=UpgradeDoc>업그레이드하기</doclink>",
+ "config-env-good": "환경이 확인되었습니다.\n미디어위키를 설치할 수 있습니다.",
+ "config-env-bad": "환경이 확인되었습니다.\n미디어위키를 설치할 수 없습니다.",
+ "config-env-php": "PHP $1이(가) 설치되어 있습니다.",
+ "config-env-hhvm": "HHVM $1이(가) 설치되어 있습니다.",
+ "config-unicode-using-intl": "유니코드 정규화에 [http://pecl.php.net/intl intl PECL 확장 기능]을 사용합니다.",
+ "config-unicode-pure-php-warning": "<strong>경고:</strong> 유니코드 정규화를 처리할 [http://pecl.php.net/intl intl PECL 확장 기능]을 사용할 수 없기 때문에 느린 pure-PHP 구현을 대신 사용합니다.\n트래픽이 높은 사이트에서 실행하시려면 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations 유니코드 정규화]를 읽어보셔야 합니다.",
+ "config-unicode-update-warning": "<strong>경고:</strong> 유니코드 정규화 래퍼의 설치된 버전은 [http://site.icu-project.org/ ICU 프로젝트]의 라이브러리의 이전 버전을 사용합니다.\n만약 유니코드를 사용하는 것에 대해 우려가 된다면 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations 업그레이드]해야합니다.",
+ "config-no-db": "적절한 데이터베이스 드라이버를 찾을 수 없습니다! PHP용 데이터베이스 드라이버를 설치해야 합니다.\n다음 데이터베이스 {{PLURAL:$2|유형을}} 지원합니다: $1.\n\nPHP를 직접 컴파일했다면, 예를 들어 <code>./configure --with-mysqli</code>을 사용하여, 데이터베이스 클라이언트를 활성화하도록 다시 설정하세요.\n데비안이나 우분투 패키지에서 PHP를 설치했다면 <code>php5-mysql</code> 패키지도 설치해야 합니다.",
+ "config-outdated-sqlite": "<strong>경고:</strong> 최소 요구 버전 $2 보다 낮은 SQLite $1이(가) 있습니다. SQLite를 사용할 수 없습니다.",
+ "config-no-fts3": "<strong>경고:</strong> SQLite를 [//sqlite.org/fts3.html FTS3 모듈] 없이 컴파일하며, 검색 기능은 백엔드에 사용할 수 없습니다.",
+ "config-pcre-old": "<strong>치명:</strong> PCRE $1 또는 그 이상이 필요합니다.\nPHP 바이너리는 PCRE $2에 연결되어 있습니다. [https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE 자세한 정보].",
+ "config-pcre-no-utf8": "<strong>치명:</strong> PHP의 PCRE 모듈은 RCRE_UTF8 지원 없이 컴파일된 것 같습니다.\n미디어위키가 올바르게 작동하려면 UTF-8을 지원해야 합니다.",
+ "config-memory-raised": "PHP의 <code>memory_limit</code>는 $1이며 $2(으)로 늘렸습니다.",
+ "config-memory-bad": "<strong>경고:</strong> PHP의 <code>memory_limit</code>는 $1입니다.\n아마도 너무 낮은 것 같습니다.\n설치가 실패할 수 있습니다!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache]가 설치되었습니다",
+ "config-apc": "[http://www.php.net/apc APC]가 설치되었습니다",
+ "config-apcu": "[http://www.php.net/apcu APCu]가 설치되었습니다",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache]가 설치되었습니다",
+ "config-no-cache-apcu": "<strong>경고:</strong> [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] 또는 [http://www.iis.net/download/WinCacheForPhp WinCache]를 찾을 수 없습니다.\n개체 캐싱을 활성화할 수 없습니다.",
+ "config-mod-security": "<strong>경고:</strong> 웹 서버에 [http://modsecurity.org/ mod_security]가 허용되었습니다. 잘못 설정된 경우 미디어위키나 사용자가 임의의 내용을 게시할 수 있는 다른 소프트웨어에 대한 문제를 일으킬 수 있습니다.\n[http://modsecurity.org/documentation/ mod_security] 문서를 참고하거나 임의의 오류가 발생할 경우 호스트의 지원 요청에 문의하십시오.",
+ "config-diff3-bad": "GNU diff3를 찾을 수 없습니다.",
+ "config-git": "Git 버전 관리 소프트웨어를 찾았습니다: <code>$1</code>.",
+ "config-git-bad": "Git 버전 관리 소프트웨어를 찾을 수 없습니다.",
+ "config-imagemagick": "ImageMagick를 찾았습니다: <code>$1</code>.\n올리기를 활성화할 경우 그림 섬네일이 활성화됩니다.",
+ "config-gd": "내장된 GD 그래픽 라이브러리를 찾았습니다.\n올리기를 활성화할 경우 그림 섬네일이 활성화됩니다.",
+ "config-no-scaling": "GD 라이브러리나 ImageMagick를 찾을 수 없습니다.\n그림 섬네일이 비활성화됩니다.",
+ "config-no-uri": "<strong>오류:</strong> 현재 URI를 확인할 수 없습니다.\n설치가 중단되었습니다.",
+ "config-no-cli-uri": "<strong>경고:</strong> 기본값을 사용하여 <code>--scriptpath</code>를 지정하지 않았습니다: <code>$1</code>.",
+ "config-using-server": "\"<nowiki>$1</nowiki>\" 서버 이름을 사용 중입니다.",
+ "config-using-uri": "\"<nowiki>$1$2</nowiki>\" 서버 URL을 사용 중입니다.",
+ "config-uploads-not-safe": "<strong>경고:</strong> 올리기에 대한 기본 디렉터리(<code>$1</code>)는 임의의 스크립트 실행에 취약합니다.\n미디어위키는 보안 위협 때문에 모든 올려진 파일을 검사하지만, 올리기를 활성화하기 전에 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security 이 보안 취약점을 해결할 것]을 매우 권장합니다.",
+ "config-no-cli-uploads-check": "<strong>경고:</strong> 올리기를 위한 기본 디렉터리(<code>$1</code>)는 CLI를 설치하는 동안 임의의 스크립트 실행에 대한 취약점에 대해 검사되지 않습니다.",
+ "config-brokenlibxml": "시스템에 버그가 있는 PHP와 libxml2의 조합이 있으며 미디어위키나 다른 웹 애플리케이션에 숨겨진 데이터 손상을 일으킬 수 있습니다.\nlibxml2 2.7.3 이후 버전으로 업그레이드하세요. ([https://bugs.php.net/bug.php?id=45996 PHP에 제기한 버그])\n설치가 중단되었습니다.",
+ "config-suhosin-max-value-length": "수호신(Suhosin)이 설치되고 $1 바이트로 GET 매개 변수 <code>length</code>를 제한하고 있습니다.\n미디어위키의 ResourceLoader 구성 요소는 이 제한을 회피하지만 성능이 저하됩니다.\n가능하면 <code>php.ini</code>의 <code>suhosin.get.max_value_length</code>를 1024 이상으로 설정하고 <code>LocalSettings.php</code>의 <code>$wgResourceLoaderMaxQueryLength</code>를 같은 값으로 설정해야 합니다.",
+ "config-using-32bit": "<strong>경고:</strong> 시스템이 32비트 정수와 함께 실행 중인 것으로 보입니다. 이것은 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit 권장되지 않습니다].",
+ "config-db-type": "데이터베이스 종류:",
+ "config-db-host": "데이터베이스 호스트:",
+ "config-db-host-help": "데이터베이스 서버가 다른 서버에 있으면 여기에 호스트 이름이나 IP 주소를 입력하세요.\n\n공유하는 웹 호스팅을 사용하고 있으면 호스팅 제공 업체는 올바른 호스트 이름을 설명하고 있을 것입니다.\n\nWindows 서버에 설치하고 MySQL을 사용하면 \"localhost\"가 서버 이름으로는 작동하지 않을 수 있습니다. 그렇게 된다면 로컬 IP 주소로 \"127.0.0.1\"을 시도하세요.\n\nPostgreSQL을 사용하면 유닉스 소켓을 통해 연결되도록 입력란을 비워두세요.",
+ "config-db-host-oracle": "데이터베이스 TNS:",
+ "config-db-host-oracle-help": "올바른 [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm 로컬 연결 이름]을 입력하세요. tnsnames.ora 파일이 이 설치 위치에서 참조할 수 있는 곳에 있어야 합니다.<br />10g 이후의 클라이언트 라이브러리를 사용하는 경우 [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm 쉬운 연결] 네이밍 메서드도 사용할 수 있습니다.",
+ "config-db-wiki-settings": "이 위키 식별",
+ "config-db-name": "데이터베이스 이름:",
+ "config-db-name-help": "위키를 식별하기 위한 이름을 선택하세요.\n공백이 없어야 합니다.\n\n공유하는 웹 호스팅 사용하면 호스팅 제공 업체가 특정 데이터베이스 이름을 제공하거나 제어판에서 데이터베이스를 만들 수 있습니다.",
+ "config-db-name-oracle": "데이터베이스 스키마:",
+ "config-db-account-oracle-warn": "데이터베이스 백엔드로 Oracle을 설치하기 위해 지원하는 세 가지 시나리오가 있습니다:\n\n설치 과정의 일부로 데이터베이스 계정을 만들려면 설치를 위해 데이터베이스 계정으로 SYSDBA 역할을 가진 계정을 제공하고 웹 접근 계정에 대해 원하는 자격 증명을 지정하세요, 그렇지 않으면 수동으로 웹 접근 계정을 만들 수 있으며 (스키마 개체를 만들 권한이 필요한 경우) 또는 생성 권한을 가진 계정과 웹 접근이 제한된 계정의 두 가지 다른 계정을 제공할 수도 있습니다\n\n필요한 권한을 가진 계정을 만드는 스크립트는 이 설치 위치의 \"maintenance/oracle/\" 디렉터리에서 찾을 수 있습니다. 제한된 계정을 사용하면 기본 계정의 모든 유지 관리 기능이 비활성화된다는 점에 유의하십시오.",
+ "config-db-install-account": "설치를 위한 사용자 계정",
+ "config-db-username": "데이터베이스 사용자 이름:",
+ "config-db-password": "데이터베이스 비밀번호:",
+ "config-db-install-username": "설치 과정 도중 데이터베이스에 연결할 때 사용할 사용자 이름을 입력하세요.\n미디어위키 계정의 사용자 이름이 아닌 데이터베이스의 사용자 이름입니다.",
+ "config-db-install-password": "설치 과정 도중 데이터베이스에 연결할 때 사용할 비밀번호을 입력하세요.\n미디어위키 계정의 비밀번호가 아닌 데이터베이스의 비밀번호입니다.",
+ "config-db-install-help": "설치 과정 중에 데이터베이스에 연결할 때 사용할 사용자 이름과 비밀번호를 입력하세요.",
+ "config-db-account-lock": "정상적으로 작동하는 동안 같은 사용자 이름과 비밀번호를 사용함",
+ "config-db-wiki-account": "정상적인 작동을 위한 사용자 계정",
+ "config-db-wiki-help": "정상적인 위키 작업 동안 데이터베이스에 연결하는 데 사용할 사용자 이름과 비밀번호를 입력하세요.\n계정이 존재하지 않고 설치 계정에 충분한 권한이 있는 경우 이 사용자 계정은 위키를 작동하는 데 필요한 최소 권한으로 만들어집니다.",
+ "config-db-prefix": "데이터베이스 테이블 접두어:",
+ "config-db-prefix-help": "여러 위키 사이 또는 미디어위키와 다른 웹 애플리케이션 사이에 하나의 데이터베이스를 공유해야 하는 경우, 충돌을 피하기 위해 모든 테이블 이름에 접두어를 추가하도록 선택할 수 있습니다.\n공백을 사용하지 마세요.\n\n이 필드는 일반적으로 비어 있습니다.",
+ "config-mysql-old": "MySQL $1 이상이 필요합니다. $2이(가) 있습니다.",
+ "config-db-port": "데이터베이스 포트:",
+ "config-db-schema": "미디어위키에 대한 스키마:",
+ "config-db-schema-help": "보통 이 스키마는 문제가 없습니다.\n필요한 경우에만 바꾸세요.",
+ "config-pg-test-error": "<strong>$1</strong> 데이터베이스에 연결할 수 없습니다: $2",
+ "config-sqlite-dir": "SQLite 데이터 디렉터리:",
+ "config-sqlite-dir-help": "SQLite는 하나의 파일에 모든 데이터를 저장합니다.\n\n입력한 디렉토리는 설치하는 동안 웹 서버가 쓸 수 있어야 합니다.\n\n이 디렉토리는 웹을 통해 접근할 수 <strong>없어야</strong> 합니다. PHP 파일이 있는 곳에 넣을 수 없는 것은 이 때문입니다.\n\n설치 관리자는 <code>.htaccess</code> 파일을 작성하지만, 이것이 실패하면 누군가가 원본 데이터베이스에 접근할 수 있습니다.\n데이터베이스는 원본 사용자 데이터(이메일 주소, 해시한 비밀번호)뿐만 아니라 삭제된 판과 위키의 다른 제한된 데이터를 포함합니다.\n\n예를 들어 <code>/var/lib/mediawiki/yourwiki</code>와 같이 다른 곳에 데이터베이스를 넣는 것이 좋습니다.",
+ "config-oracle-def-ts": "기본 테이블공간:",
+ "config-oracle-temp-ts": "임시 테이블공간:",
+ "config-type-mysql": "MySQL (또는 호환되는 데이터베이스 시스템)",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL 서버",
+ "config-support-info": "미디어위키는 다음의 데이터베이스 시스템을 지원합니다:\n\n$1\n\n데이터베이스 시스템이 표시되지 않을 때 아래에 나열된 다음 지원을 활성화하려면 위의 링크된 지시에 따라 설치해볼 수 있습니다.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL]은 미디어위키의 기본 대상이며 가장 잘 지원됩니다. 미디어위키는 또한 MySQL와 호환되는 [{{int:version-db-mariadb-url}} MariaDB]와 [{{int:version-db-percona-url}} Percona 서버]에서도 작동합니다. ([http://www.php.net/manual/en/mysql.installation.php MySQL 지원으로 PHP를 컴파일하는 방법])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL]은 MySQL의 대안으로서 인기 있는 오픈 소스 데이터베이스 시스템입니다. ([http://www.php.net/manual/en/pgsql.installation.php PostgreSQL 지원으로 PHP를 컴파일하는 방법])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite]는 매우 잘 지원되고 가벼운 데이터베이스 시스템입니다. ([http://www.php.net/manual/en/pdo.installation.php SQLite 지원으로 PHP를 컴파일하는 방법], PDO 사용)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle]은 상용 기업 데이터베이스입니다. ([http://www.php.net/manual/en/oci8.installation.php OCI8 지원으로 PHP를 컴파일하는 방법])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL 서버]는 Windows용 상용 기업 데이터베이스입니다. ([http://www.php.net/manual/en/sqlsrv.installation.php SQLSRV 지원으로 PHP를 컴파일하는 방법])",
+ "config-header-mysql": "MySQL 설정",
+ "config-header-postgres": "PostgreSQL 설정",
+ "config-header-sqlite": "SQLite 설정",
+ "config-header-oracle": "Oracle 설정",
+ "config-header-mssql": "Microsoft SQL 서버 설정",
+ "config-invalid-db-type": "잘못된 데이터베이스 종류",
+ "config-missing-db-name": "\"{{int:config-db-name}}\"에 대한 값을 입력해야 합니다.",
+ "config-missing-db-host": "\"{{int:config-db-host}}\"에 대한 값을 입력해야 합니다.",
+ "config-missing-db-server-oracle": "\"{{int:config-db-host-oracle}}\"에 대한 값을 입력해야 합니다.",
+ "config-invalid-db-server-oracle": "\"$1\" 데이터베이스 TNS가 잘못됐습니다.\n\"TNS Name\"이나 \"Easy Connect\" 문자열 중 하나를 사용하세요 ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle 네이밍 메서드]).",
+ "config-invalid-db-name": "\"$1\" 데이터베이스 이름이 잘못되었습니다.\nASCII 글자 (a-z, A-Z), 숫자 (0-9), 밑줄 (_)과 하이픈 (-)만 사용하세요.",
+ "config-invalid-db-prefix": "\"$1\" 데이터베이스 접두어가 잘못됐습니다.\nASCII 글자 (a-z, A-Z), 숫자 (0-9), 밑줄 (_)과 하이픈 (-)만 사용하세요.",
+ "config-connection-error": "$1.\n\n호스트, 계정 이름과 비밀번호를 확인하고 다시 시도하세요.",
+ "config-invalid-schema": "미디어위키 \"$1\"에 대한 스키마가 잘못됐습니다.\nASCII 글자 (a-z, A-Z), 숫자 (0-9), 밑줄 (_)과 하이픈 (-)만 사용하세요.",
+ "config-db-sys-create-oracle": "설치 관리자는 새 계정을 만들기 위한 SYSDBA 계정만을 지원합니다.",
+ "config-db-sys-user-exists-oracle": "\"$1\" 사용자 계정이 이미 존재합니다. SYSDBA는 새 계정을 만드는 데에만 사용할 수 있습니다!",
+ "config-postgres-old": "PostgreSQL $1 이상이 필요합니다. $2이(가) 있습니다.",
+ "config-mssql-old": "Microsoft SQL 서버 $1 이상의 버전이 필요합니다. 현재 버전은 $2입니다.",
+ "config-sqlite-name-help": "위키를 식별하기 위한 이름을 선택하세요.\n공백이나 하이픈을 사용하지 마십시오.\nSQLite 데이터 파일 이름에 사용됩니다.",
+ "config-sqlite-parent-unwritable-group": "<code><nowiki>$1</nowiki></code> 데이터 디렉토리를 만들 수 없으며, 이는 웹 서버는 상위 디렉토리인 <code><nowiki>$2</nowiki></code>에 쓸 수 없기 때문입니다.\n\n설치 관리자는 웹 서버로 실행 중인 사용자를 지정할 수 없습니다.\n계속하려면 웹 서버가 쓸 수 있는 <code><nowiki>$3</nowiki></code> 디렉토리를 만드세요.\n유닉스/리눅스 시스템에서의 수행:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "<code><nowiki>$1</nowiki></code> 데이터 디렉토리를 만들 수 없으며, 이는 웹 서버가 상위 디렉토리인 <code><nowiki>$2</nowiki></code>에 쓸 수 없기 때문입니다.\n\n설치 관리자는 웹 서버로 실행 중인 사용자를 지정할 수 없습니다.\n계속하려면 웹 서버(와 그 외 서버!)가 전역으로 쓸 수 있는 <code><nowiki>$3</nowiki></code> 디렉토리를 만드세요.\n유닉스/리눅스 시스템에서의 수행:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "\"$1\" 데이터 디렉터리를 만드는 도중 오류가 발생했습니다.\n경로를 확인하고 다시 시도하세요.",
+ "config-sqlite-dir-unwritable": "\"$1\" 디렉토리에 쓸 수 없습니다.\n웹 서버를 쓸 수 있도록 권한을 바꾸고 다시 시도하세요.",
+ "config-sqlite-connection-error": "$1.\n\n호스트, 계정 이름과 비밀번호를 확인하고 다시 시도하세요.",
+ "config-sqlite-readonly": "<code>$1</code> 파일은 쓸 수 없습니다.",
+ "config-sqlite-cant-create-db": "<code>$1</code> 데이터베이스 파일을 만들 수 없습니다.",
+ "config-sqlite-fts3-downgrade": "PHP가 FTS3 지원이 없어졌습니다. 테이블을 다운그레이드합니다",
+ "config-can-upgrade": "이 데이터베이스에 미디어위키 테이블이 있습니다.\n미디어위키 $1(으)로 업그레이드하려면 <strong>계속</strong>을 클릭하세요.",
+ "config-upgrade-done": "업그레이드가 완료되었습니다.\n\n이제 [$1 위키를 시작]할 수 있습니다.\n\n만약 <code>LocalSettings.php</code> 파일을 다시 만들고 싶다면 아래의 버튼을 클릭하세요.\n위키에 문제가 있지 않는 한 <strong>권장하지 않습니다</strong>.",
+ "config-upgrade-done-no-regenerate": "업그레이드가 완료되었습니다.\n\n이제 [$1 위키를 시작]할 수 있습니다.",
+ "config-regenerate": "LocalSettings.php 다시 생성 →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code> 쿼리를 실패했습니다!",
+ "config-unknown-collation": "<strong>경고:</strong> 데이터베이스가 인식하지 않는 집합을 사용하고 있습니다.",
+ "config-db-web-account": "웹 접근을 위한 데이터베이스 계정",
+ "config-db-web-help": "위키의 일반적인 작업을 수행하는 동안 데이터베이스 서버에 연결하는 데 사용할 웹 서버의 계정 이름과 비밀번호를 선택하세요.",
+ "config-db-web-account-same": "설치를 위해 같은 계정 사용",
+ "config-db-web-create": "이 계정이 아직 존재하지 않을 경우 계정 만들기",
+ "config-db-web-no-create-privs": "설치를 위해 지정한 계정이 계정을 만들 수 있는 충분한 권한이 없습니다.\n여기서 지정한 계정은 이미 존재해야 합니다.",
+ "config-mysql-engine": "저장소 엔진:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>경고:</strong> MySQL을 위한 저장소 엔진으로 MyISAM을 선택하였습니다. MyISAM을 미디어위키에 사용하는 것은 좋지 않습니다. 이유는:\n* 테이블 잠금 때문에 동시 실행을 지원하지 않습니다\n* 다른 엔진보다 더 손상되는 경향이 있습니다\n* 미디어위키 코드베이스가 항상 정상적으로 MyISAM을 처리하지 않습니다\n\nMySQL이 InnoDB를 지원한다면, InnoDB를 선택할 것을 매우 권장합니다.\nMySQL이 InnoDB를 지원하지 않는다면, 업그레이드를 하시는 편이 좋습니다.",
+ "config-mysql-only-myisam-dep": "<strong>경고:</strong> MyISAM은 이 기계에 유일하게 사용할 수 있는 MySQL용 저장소 엔진이며, 미디어위키에 사용하는 것은 좋지 않습니다. 이유는:\n* 테이블 잠금 때문에 동시 실행을 지원하지 않습니다\n* 다른 엔진보다 더 손상시키는 경향이 있습니다\n* 미디어위키 코드베이스가 항상 정상적으로 MyISAM을 처리하지 않습니다\n\n당신의 MySQL은 InnoDB를 지원하지 않으며, 업그레이드를 하는 것이 좋습니다.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong>는 동시 실행 지원이 우수하기 때문에 대부분의 경우 최고의 옵션입니다.\n\n<strong>MyISAM</strong>은 단일 사용자나 읽기 전용 설치에서 더 빠를 수 있습니다.\nMyISAM 데이터베이스는 InnoDB 데이터베이스보다 더 자주 손실될 수 있습니다.",
+ "config-mysql-charset": "데이터베이스 문자 집합:",
+ "config-mysql-binary": "바이너리",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "<strong>바이너리 모드</strong>에서 미디어위키는 바이너리 필드의 데이터베이스에 UTF-8 텍스트를 저장합니다.\nMySQL의 UTF-8 모드보다 더 효율적이고, 유니코드 문자의 전체 범위를 사용할 수 있습니다.\n<strong>UTF-8 모드</strong>에서는 MySQL이 데이터를 사용하는 문자 집합을 알고 있기 때문에 적절하게 표현하고 변환할 수 있지만\n[https://ko.wikipedia.org/wiki/%EC%9C%A0%EB%8B%88%EC%BD%94%EB%93%9C_%ED%8F%89%EB%A9%B4#.EA.B8.B0.EB.B3.B8_.EB.8B.A4.EA.B5.AD.EC.96.B4_.ED.8F.89.EB.A9.B4 기본 다국어 평면] 밖에 있는 문자를 저장할 수 없습니다.",
+ "config-mssql-auth": "인증 형식:",
+ "config-mssql-install-auth": "설치 과정 중 데이터베이스에 연결하는 데 사용할 인증 형식을 선택하세요.\n\"{{int:config-mssql-windowsauth}}\"을 선택하시면 웹서버를 실행 중인 아무 사용자의 자격 증명이 사용됩니다.",
+ "config-mssql-web-auth": "위키가 일반적인 작업을 수행하는 동안 데이터베이스 서버에 연결하는 데 사용할 인증 형식을 선택하세요.\n\n\"{{int:config-mssql-windowsauth}}\"을 선택하시면 웹서버를 실행 중인 아무 사용자의 자격 증명이 사용됩니다.",
+ "config-mssql-sqlauth": "SQL 서버 인증",
+ "config-mssql-windowsauth": "Windows 인증",
+ "config-site-name": "위키 이름:",
+ "config-site-name-help": "브라우저 제목 표시줄과 다른 여러 곳에 나타납니다.",
+ "config-site-name-blank": "사이트 이름을 입력하세요.",
+ "config-project-namespace": "프로젝트 이름공간:",
+ "config-ns-generic": "프로젝트",
+ "config-ns-site-name": "위키 이름과 같은 이름: $1",
+ "config-ns-other": "기타 (지정)",
+ "config-ns-other-default": "내위키",
+ "config-project-namespace-help": "위키백과의 예에 따르면, 많은 위키는 정책 문서를 일반 문서와는 별도로 '''프로젝트 이름공간'''에 보관합니다.\n이 이름공간에 있는 모든 문서의 제목은 여기서 지정할 수 있는 특정 접두어로 시작합니다.\n보통 이 접두어는 위키의 이름에서 파생되지만, \"#\" 또는 \":\"와 같은 특수 문자를 포함할 수 없습니다.",
+ "config-ns-invalid": "특정 \"<nowiki>$1</nowiki>\" 이름공간이 잘못되었습니다.\n다른 프로젝트 이름공간을 지정하세요.",
+ "config-ns-conflict": "특정 \"<nowiki>$1</nowiki>\" 이름공간이 기본 미디어위키 이름공간과 충돌합니다.\n다른 프로젝트 이름공간을 지정하세요.",
+ "config-admin-box": "관리자 계정",
+ "config-admin-name": "내 사용자 이름:",
+ "config-admin-password": "비밀번호:",
+ "config-admin-password-confirm": "비밀번호 확인:",
+ "config-admin-help": "\"홍길동\"과 같이 여기에 원하는 사용자 이름을 입력하세요.\n위키에 로그인하는 데 사용되는 이름입니다.",
+ "config-admin-name-blank": "관리자의 사용자 이름을 입력하세요.",
+ "config-admin-name-invalid": "특정 \"<nowiki>$1</nowiki>\" 사용자 이름이 잘못되었습니다.\n다른 사용자 이름을 지정하세요.",
+ "config-admin-password-blank": "관리자 계정의 비밀번호를 입력하세요.",
+ "config-admin-password-mismatch": "입력한 비밀번호 두 개가 일치하지 않습니다.",
+ "config-admin-email": "이메일 주소:",
+ "config-admin-email-help": "여기에 이메일 주소를 입력하여 위키의 다른 사용자로부터 이메일을 전달받거나 비밀번호를 재설정하고 주시문서 목록에 대한 바뀜 알림을 받으세요. 이 필드를 비워 둘 수 있습니다.",
+ "config-admin-error-user": "\"<nowiki>$1</nowiki>\" 이름의 관리자를 만드는 중 내부 오류가 발생했습니다.",
+ "config-admin-error-password": "\"<nowiki>$1</nowiki>\" 관리자의 비밀번호를 설정하는 중 내부 오류가 발생했습니다: <pre>$2</pre>",
+ "config-admin-error-bademail": "이메일 주소를 잘못 입력하였습니다.",
+ "config-subscribe": "[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce 릴리스 발표 메일링 리스트]를 구독합니다.",
+ "config-subscribe-help": "중요한 보안 발표를 포함한 배포판 발표에 사용되는 저용량 메일링 리스트입니다.\n이 리스트를 구독하고 새 버전이 나올 때 미디어위키 설치를 업데이트해야 합니다.",
+ "config-subscribe-noemail": "이메일 주소를 입력하지 않고 릴리스 발표 메일링 리스트에 가입하려 합니다.\n메일링 리스트에 가입하고자 할 경우 이메일 주소를 입력하세요.",
+ "config-pingback": "본 설치에 관한 데이터를 미디어위키 개발자와 공유합니다.",
+ "config-pingback-help": "이 옵션을 선택하면 미디어위키는 주기적으로 이 미디어위키 인스턴스에 대한 기본 데이터를 가지고 https://www.mediawiki.org에 핑을 합니다. 이 데이터에는 이를테면 시스템의 종류, PHP 버전, 선택한 데이터베이스 백엔드를 포함합니다. 위키미디어 재단은 이 데이터를 미디어위키 개발자들과 공유하여 향후 개발 활동의 길잡이에 도움을 줍니다. 시스템에 대해 다음의 데이터가 전송될 것입니다:\n<pre>$1</pre>",
+ "config-almost-done": "거의 다 완료했습니다!\n이제 남은 설정을 생략하고 지금 바로 위키를 설치할 수 있습니다.",
+ "config-optional-continue": "더 많은 질문을 물어보세요.",
+ "config-optional-skip": "지겨워요, 그냥 위키를 설치할래요.",
+ "config-profile": "사용자 권한 프로필:",
+ "config-profile-wiki": "열린 위키",
+ "config-profile-no-anon": "계정 만들기 필요",
+ "config-profile-fishbowl": "승인된 편집자만",
+ "config-profile-private": "비공개 위키",
+ "config-profile-help": "위키는 가능한 많은 사람들이 편집할 수 있도록 할 때 가장 뛰어난 역할을 합니다.\n미디어위키에서는 최근 바뀜을 검토하기 쉽고, 미숙하거나 악의적인 사용자에 의한 손실을 되돌리는 것이 쉽습니다.\n\n그러나 많은 사람이 미디어위키가 다양한 역할을 수행하는 데 유용하다는 것을 알고 있지만, 때로는 모든 사람에게 위키 방식의 장점을 설득하기 쉽지 않을 지도 모릅니다.\n그래서 선택할 수 있습니다.\n\n<strong>{{int:config-profile-wiki}}/<strong> 모델은 로그인하지 않고도 누구나 편집할 수 있습니다.\n<strong>{{int:config-profile-no-anon}}</strong>인 위키에서는 편집자에게 추가적인 책임을 부여하지만, 부담 없는 기여를 저해할 수도 있습니다.\n\n<strong>{{int:config-profile-fishbowl}}</strong> 시나리오에서는 승인된 사용자만 편집할 수 있지만, 일반 사용자도 문서(문서 역사 포함)는 볼 수 있습니다.\n<strong>{{int:config-profile-private}}</strong>는 승인된 사용자만 문서를 볼 수 있으며, 승인된 사용자 그룹이 편집할 수 있습니다.\n\n더 복잡한 사용자 권한 설정은 설치한 후 사용할 수 있으며 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights 관련 설명서 항목]을 참조하세요.",
+ "config-license": "저작권 및 라이선스:",
+ "config-license-none": "라이선스 바닥글 없음",
+ "config-license-cc-by-sa": "크리에이티브 커먼즈 저작자표시-동일조건변경허락",
+ "config-license-cc-by": "크리에이티브 커먼즈 저작자표시",
+ "config-license-cc-by-nc-sa": "크리에이티브 커먼즈 저작자표시-비영리-동일조건변경허락",
+ "config-license-cc-0": "크리에이티브 커먼즈 제로 (퍼블릭 도메인)",
+ "config-license-gfdl": "GNU 자유 문서 사용 허가서 1.3 이상",
+ "config-license-pd": "퍼블릭 도메인",
+ "config-license-cc-choose": "다른 크리에이티브 커먼즈 라이선스 선택",
+ "config-license-help": "많은 공개 위키는 모든 기여를 [http://freedomdefined.org/Definition 자유 라이선스]에 따르도록 합니다.\n이렇게 하면 커뮤니티에 대한 소유권을 이해할 수 있도록 하고 장기적인 기여를 장려합니다.\n일반적으로 개인 또는 회사 위키에게는 필요하지 않습니다.\n\n위키백과의 텍스트를 사용할 수 있도록 하고 위키백과가 위키에서 복사한 텍스트를 사용할 수 있도록 원한다면 <strong>{{int:config-license-cc-by-sa}}</strong>으로 선택해야 합니다.\n\n위키백과는 이전에 GNU 자유 문서 사용 허가서(GFDL)를 사용했습니다.\nGFDL은 유효한 라이선스이지만 내용을 이해하기 어렵습니다.\nGFDL에 따라 사용이 허가된 내용을 재사용하는 것도 어렵습니다.",
+ "config-email-settings": "이메일 설정",
+ "config-enable-email": "발신 이메일 활성화",
+ "config-enable-email-help": "이메일을 작동하려면 [http://www.php.net/manual/en/mail.configuration.php PHP의 메일 설정]을 올바르게 설정해야 합니다.\n이메일 기능을 사용하지 않으려면 이를 비활성화할 수 있습니다.",
+ "config-email-user": "사용자와 사용자 간 이메일 활성화",
+ "config-email-user-help": "환경 설정에서 활성화한 경우 모든 사용자가 이메일을 서로 보내도록 활성화합니다.",
+ "config-email-usertalk": "사용자 토론 문서 알림 활성화",
+ "config-email-usertalk-help": "환경 설정에서 활성화한 경우 사용자는 사용자 토론 문서의 바뀜 알림을 받도록 활성화합니다.",
+ "config-email-watchlist": "주시문서 목록 알림 활성화",
+ "config-email-watchlist-help": "환경 설정에서 활성화한 경우 사용자가 주시한 문서에 대한 알림을 받도록 활성화합니다.",
+ "config-email-auth": "이메일 인증 활성화",
+ "config-email-auth-help": "이 설정이 활성화되어 있으면 사용자는 이메일 주소를 설정하거나 바꿀 때마다 링크를 사용하여 이메일 주소를 확인해야 합니다.\n인증된 이메일 주소만 다른 사용자로부터의 이메일이나 바뀜 알림 이메일을 받을 수 있습니다.\n이메일 기능의 남용 가능성이 있기 때문에 공개 위키에서는 이 옵션을 설정할 것을 <strong>권장</strong>합니다.",
+ "config-email-sender": "반송 이메일 주소",
+ "config-email-sender-help": "발신한 이메일에 대한 반송 주소로 사용할 이메일 주소를 입력하세요.\n반송할 때 보내는 주소입니다.\n대부분의 메일 서버는 적어도 도메인 이름 부분은 유효합니다.",
+ "config-upload-settings": "그림과 파일 올리기",
+ "config-upload-enable": "파일 올리기 활성화",
+ "config-upload-help": "파일 올리기는 서버에 잠재적인 보안 위험에 쉽게 노출될 수 있습니다.\n자세한 내용은 매뉴얼의 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security 보안 문단]을 참조하세요.\n\n파일 올리기를 활성화하려면 미디어위키의 루트 디렉토리에 있는 <code>images</code> 하위 디렉토리에서 웹 서버가 기록할 수 있도록 모드를 바꿉니다.\n그 다음 이 옵션을 활성화합니다.",
+ "config-upload-deleted": "삭제된 파일에 대한 디렉터리:",
+ "config-upload-deleted-help": "삭제된 파일을 보관할 디렉토리를 선택하세요.\n이상적으로 웹에서 접근할 수 없게 해야 합니다.",
+ "config-logo": "로고 URL:",
+ "config-logo-help": "미디어위키의 기본 스킨은 사이드바 메뉴 위에 135×160 픽셀의 로고의 공간을 포함하고 있습니다.\n적당한 크기로 그림을 올리고 여기에 URL을 입력하세요.\n\n로고가 상대적인 경로에 있으면 <code>$wgStylePath</code>나 <code>$wgScriptPath</code>를 사용할 수 있습니다.\n\n로고 사용을 원하지 않으면 이 상자를 비우세요.",
+ "config-instantcommons": "인스턴트 공용 기능 활성화",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons 인스턴트 공용]은 [https://commons.wikimedia.org/ 위키미디어 공용] 사이트에서 찾을 수 있는 그림, 소리 및 다른 미디어를 위키에서 사용할 수 있도록 하는 기능입니다.\n이렇게 하려면 미디어위키가 인터넷 연결을 필요로 합니다.\n\n위키미디어 공용 이외에 다른 위키에서 이를 설정하는 방법에 대한 지침을 포함한, 기능에 대한 자세한 내용은 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos 매뉴얼]을 참조하세요.",
+ "config-cc-error": "크리에이티브 커먼즈 라이선스 선택기에 결과가 없습니다.\n수동으로 라이선스 이름을 입력하세요.",
+ "config-cc-again": "다시 선택...",
+ "config-cc-not-chosen": "원하는 크리에이티브 커먼즈 라이선스를 선택하고 \"proceed\"을 클릭하세요.",
+ "config-advanced-settings": "고급 설정",
+ "config-cache-options": "개체 캐싱을 위한 설정:",
+ "config-cache-help": "개체 캐싱은 자주 사용하는 데이터를 캐싱하여 미디어위키의 속도를 개선하는 데 사용합니다.\n큰 규모의 사이트는 이를 많이 사용하도록 권장하고 있으며, 소규모 사이트들도 물론 혜택을 볼 수 있습니다.",
+ "config-cache-none": "캐시하지 않음 (기능이 삭제되지는 않지만 큰 위키 사이트에 속도가 영향을 받을 수 있습니다)",
+ "config-cache-accel": "PHP 개체 캐싱 (APC, APCu, XCache 또는 WinCache)",
+ "config-cache-memcached": "Memcached 사용 (추가적인 설치와 설정이 필요합니다)",
+ "config-memcached-servers": "Memcached 서버:",
+ "config-memcached-help": "Memcached의 사용하기 위한 IP 주소 목록입니다.\n한 줄에 하나씩 사용할 포트를 지정해야 합니다. 예를 들어:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "캐시 종류로 Memcached를 선택했지만 어떠한 서버도 지정하지 않았습니다.",
+ "config-memcache-badip": "Memcached에 대해 잘못된 IP 주소를 입력했습니다: $1.",
+ "config-memcache-noport": "Memcached 서버에 사용할 포트를 지정하지 않았습니다: $1.\n포트를 모를 경우 기본 값은 11211입니다.",
+ "config-memcache-badport": "Memcached 포트 번호는 $1와(과) $2 사이여야 합니다.",
+ "config-extensions": "확장 기능",
+ "config-extensions-help": "위에 나열된 확장 기능이 <code>./extensions</code>에서 발견되었습니다.\n\n추가적인 설정이 필요할 수 있습니다만 지금 활성화시킬 수 있습니다.",
+ "config-skins": "스킨",
+ "config-skins-help": "위에 나열된 스킨이 <code>./skins</code> 디렉터리에서 감지되었습니다. 적어도 하나를 활성화하고 나서, 기본값을 선택해야 합니다.",
+ "config-skins-use-as-default": "이 스킨을 기본값으로 사용",
+ "config-skins-missing": "스킨을 찾을 수 없습니다; 미디어위키는 당신이 적절한 스킨을 설치할 때까지 대체 스킨을 사용합니다.",
+ "config-skins-must-enable-some": "적어도 활성화활 스킨 하나를 선택해야 합니다.",
+ "config-skins-must-enable-default": "기본값으로 설정한 스킨은 반드시 활성화해야 합니다.",
+ "config-install-alreadydone": "<strong>경고:</strong> 이미 미디어위키를 설치했고 다시 설치하려고 합니다.\n다음 페이지로 진행하세요.",
+ "config-install-begin": "\"{{int:config-continue}}\"을 누르면 미디어위키의 설치를 시작합니다.\n그래도 바꾸는 것을 원한다면 \"{{int:config-back}}\"를 누르세요.",
+ "config-install-step-done": "완료",
+ "config-install-step-failed": "실패",
+ "config-install-extensions": "확장 기능을 포함하는 중",
+ "config-install-database": "데이터베이스를 설정하는 중",
+ "config-install-schema": "스키마를 만드는 중",
+ "config-install-pg-schema-not-exist": "PostgreSQL 스키마가 존재하지 않습니다.",
+ "config-install-pg-schema-failed": "테이블을 만드는 데 실패했습니다.\n\"$2\" 스키마에 쓸 수 있는 \"$1\" 사용자가 있는지 확인하세요.",
+ "config-install-pg-commit": "변경사항을 적용하는 중",
+ "config-install-pg-plpgsql": "PL/pgSQL 언어에 대해 확인하는 중",
+ "config-pg-no-plpgsql": "$1 데이터베이스에 PL/pgSQL 언어를 설치해야 합니다",
+ "config-pg-no-create-privs": "설치를 위한 지정한 계정에 계정을 만드는 데 충분한 권한이 없습니다,",
+ "config-pg-not-in-role": "웹 사용자로 지정한 계정이 이미 존재합니다.\n설치를 위해 지정한 사용자는 슈퍼 사용자와 웹 사용자의 역할 회원이 아니므로 웹 사용자가 소유한 개체를 만들 수 없습니다.\n\n현재 미디어위키는 테이블을 웹 사용자가 소유해야 합니다. 다른 웹 계정 이름을 지정하거나 \"뒤로\"를 클릭하고 적절한 권한의 설치할 사용자를 지정하세요.",
+ "config-install-user": "데이터베이스 사용자를 만드는 중",
+ "config-install-user-alreadyexists": "\"$1\" 사용자가 이미 존재합니다",
+ "config-install-user-create-failed": "\"$1\" 사용자 만드는 데 실패: $2",
+ "config-install-user-grant-failed": "\"$1\" 사용자에 대한 권한 부여 실패: $2",
+ "config-install-user-missing": "지정한 \"$1\" 사용자가 존재하지 않습니다.",
+ "config-install-user-missing-create": "지정된 \"$1\" 사용자가 존재하지 않습니다.\n사용자를 만드려면 아래의 \"계정 만들기\" 확인 상자를 클릭하세요.",
+ "config-install-tables": "테이블을 만드는 중",
+ "config-install-tables-exist": "<strong>경고:</strong> 미디어위키 테이블이 이미 있는 것 같습니다.\n테이블 만들기를 생략합니다.",
+ "config-install-tables-failed": "<strong>오류</strong>: 다음 오류로 인해 테이블 만들기에 실패했습니다: $1",
+ "config-install-interwiki": "기본 인터위키 테이블을 채우는 중",
+ "config-install-interwiki-list": "<code>interwiki.list</code> 파일을 불러올 수 없습니다.",
+ "config-install-interwiki-exists": "<strong>경고:</strong> 인터위키 테이블이 이미 항목을 갖고 있는 것 같습니다.\n기본 목록을 건너뜁니다.",
+ "config-install-stats": "통계를 초기화하는 중",
+ "config-install-keys": "보안 키를 만드는 중",
+ "config-insecure-keys": "<strong>경고:</strong> 설치 중에 생성한 {{PLURAL:$2|보안 키}} ($1)는 완전히 안전하지 {{PLURAL:$2|않습니다}}. 직접 바꾸는 것을 고려하세요.",
+ "config-install-updates": "불필요한 업데이트 실행 방지",
+ "config-install-updates-failed": "<strong>오류:</strong> 다음 오류로 테이블 안에 업데이트 키를 넣기에 실패했습니다: $1",
+ "config-install-sysop": "관리자 사용자 계정을 만드는 중",
+ "config-install-subscribe-fail": "미디어위키 알림을 구독할 수 없습니다: $1",
+ "config-install-subscribe-notpossible": "cURL이 설치되어 있지 않고 <code>allow_url_fopen</code>을 사용할 수 없습니다.",
+ "config-install-mainpage": "기본 내용으로 대문을 만드는 중",
+ "config-install-mainpage-exists": "대문은 이미 존재함, 건너뜀",
+ "config-install-extension-tables": "활성화된 확장 기능을 위한 테이블을 만드는 중",
+ "config-install-mainpage-failed": "대문을 삽입할 수 없습니다: $1",
+ "config-install-done": "<strong>축하합니다!</strong>\n미디어위키를 설치했습니다.\n\n설치 관리자가 <code>LocalSettings.php</code> 파일을 만들었습니다.\n여기에 모든 설정이 포함되어 있습니다.\n\n파일을 다운로드하여 위키 설치의 거점에 넣어야 합니다. (index.php와 같은 디렉터리) 다운로드가 자동으로 시작됩니다.\n\n다운로드가 제공되지 않을 경우나 그것을 취소한 경우에는 아래의 링크를 클릭하여 다운로드를 다시 시작할 수 있습니다:\n\n$3\n\n<strong>참고:</strong> 이 생성한 설정 파일을 다운로드하지 않고 설치를 끝내면 이 파일은 나중에 사용할 수 없습니다.\n\n완료되었으면 <strong>[$2 위키에 들어갈 수 있습니다]</strong>.",
+ "config-install-done-path": "<strong>축하합니다!</strong>\n미디어위키가 설치되었습니다.\n\n설치 관리자가 <code>LocalSettings.php</code> 파일을 생성했습니다.\n이 파일에 모든 설정이 포함되어 있습니다.\n\n이 파일을 다운로드하여 <code>$4</code> 위치에 넣으세요. 다운로드가 자동으로 시작되었을 것입니다.\n\n다운로드가 시작되지 않았거나 취소했다면, 아래 링크를 클릭하여 다운로드를 재시작할 수 있습니다.\n\n$3\n\n<strong>알림:</strong> 지금 다운로드하지 않는다면, 이후에는 이 설정 파일을 다운로드할 수 없습니다.\n\n모든 작업이 완료되었다면, <strong>[$2 위키에 들어갈 수 있습니다]</strong>.",
+ "config-download-localsettings": "<code>LocalSettings.php</code> 다운로드",
+ "config-help": "도움말",
+ "config-help-tooltip": "확장하려면 클릭",
+ "config-nofile": "\"$1\" 파일을 찾을 수 없습니다. 이미 삭제되었나요?",
+ "config-extension-link": "당신의 위키가 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions 확장 기능]을 지원한다는 것을 알고 계십니까?\n\n[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category 분류별 확장 기능]을 찾아보실 수 있습니다.",
+ "config-skins-screenshots": "$1 (스크린샷: $2)",
+ "config-screenshot": "스크린샷",
+ "mainpagetext": "<strong>미디어위키가 설치되었습니다.</strong>",
+ "mainpagedocfooter": "[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 이곳]에서 위키 소프트웨어에 대한 정보를 얻을 수 있습니다.\n\n== 시작하기 ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 설정하기 목록]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ 미디어위키 FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce 미디어위키 릴리스 메일링 리스트]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources 내 언어로 미디어위키 지역화]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 당신의 위키에서 스팸에 대처하는 법을 배우세요]"
+}
diff --git a/www/wiki/includes/installer/i18n/krc.json b/www/wiki/includes/installer/i18n/krc.json
new file mode 100644
index 00000000..3a6e9023
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/krc.json
@@ -0,0 +1,30 @@
+{
+ "@metadata": {
+ "authors": [
+ "Iltever",
+ "Ernác"
+ ]
+ },
+ "config-desc": "MediaWiki инсталлятор",
+ "config-title": "MediaWiki $1 инсталляциясы",
+ "config-information": "Информация",
+ "config-localsettings-key": "Джангыртыу ачхыч:",
+ "config-session-error": "Сессияны башланыу халат: $1",
+ "config-your-language": "Тилигиз:",
+ "config-wiki-language": "Викини тили:",
+ "config-back": "← Артха",
+ "config-continue": "Баргъаны →",
+ "config-page-language": "Тил",
+ "config-db-host-oracle": "Билгиле базаны TNS'и:",
+ "config-db-wiki-settings": "Бу Викини идентификациясы",
+ "config-db-name": "Билгиле базаны аты:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Экили",
+ "config-mysql-utf8": "UTF-8",
+ "config-ns-generic": "Проект",
+ "config-ns-other-default": "MyWiki",
+ "config-profile-private": "Джабыкъ вики",
+ "mainpagetext": "'''«MediaWiki» тыйыншлы салынды.'''",
+ "mainpagedocfooter": "Бу вики бла къалай ишлерге ангылатхан информацияны [https://meta.wikimedia.org/wiki/Help:Contents_User's_Guide къошулуучугъа юретиуде] табаргъа боллукъду.\n\n== Файдалы ресурсла ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings тюрлендириулени списогу (ингил.)];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-ни юсюнден кёб берилген соруула];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki-ни джангы версиясыны чыкъгъанын билдириу письмола]."
+}
diff --git a/www/wiki/includes/installer/i18n/ksh.json b/www/wiki/includes/installer/i18n/ksh.json
new file mode 100644
index 00000000..9a33d5b8
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ksh.json
@@ -0,0 +1,315 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mormegil",
+ "Purodha",
+ "Reedy",
+ "Seb35"
+ ]
+ },
+ "config-desc": "Et Projramm för Mediwiki opzesäze.",
+ "config-title": "MehdijaWikki $1 opsäze",
+ "config-information": "Enfomazjuhn",
+ "config-localsettings-upgrade": "De Dattei <code lang=\"en\"><code>LocalSettings.php</code></code> es ald doh.\nDe Projramme vum Wiki künne op der neußte Shtand jebraat wääde:\nDonn doför dä Wäät vum <code lang=\"en\">$wgUpgradeKey</code> en dat heh Feld enjävve.\nDo fenggs_et en dä Dattei <code lang=\"en\"><code>LocalSettings.php</code></code> om ẞööver.",
+ "config-localsettings-cli-upgrade": "En Dattei <code lang=\"en\"><code>LocalSettings.php</code></code> es jefonge woode.\nÖm et Wiki_Projramm op ene neue Shtand ze bränge, donn <code lang=\"en\">update.php</code> oproofe.",
+ "config-localsettings-key": "Der Schlößel för et Projramm op ene neue Schtand ze bränge:",
+ "config-localsettings-badkey": "Dä Schlößel zom aktoallesehre paß nit.",
+ "config-upgrade-key-missing": "Mer han jefonge, dat MediaWiki ald enschtalleed es.\nÜm de Projramme un Daate o der neue Schtand bränge ze künne, dunn aan et Engk vun dä Dattei <code lang=\"en\"><code>LocalSettings.php</code></code> op dämm ẞööver:\n\n$1\n\naanhange.",
+ "config-localsettings-incomplete": "Mer han en Dattei <code lang=\"en\"><code>LocalSettings.php</code>:</code> jefonge, ävver di schingk nit kumplätt ze sin.\nDe Varijable <code lang=\"en\">$1</code> es nit jesatz.\nBes esu joot, un donn di Dattei esu aanpaße, dat se jesaz ea, un dann donn op „{{int:config-continue}}“ klecke.",
+ "config-localsettings-connection-error": "Ene Fähler es opjetrodde wi mer en Verbendong noh de Datebangk opmaache wullte met dä Enschtällonge uß dä Dattei <code lang=\"en\">LocalSettings</code> un et hät nit jeflupp. Bes esu joot un don dat repareere un versöhg et dann norr_ens.\n\n$1\n\n$1",
+ "config-session-error": "Ene Fähler es opjetrodde beim Aanmelde för en Sezung: $1",
+ "config-session-expired": "De Daate för Ding Setzung sinn wall övverholld of afjeloufe.\nDe Setzungunge sin esu enjeshtallt, nit mih wi $1 ze doore.\nDat kanns De verlängere, endämm dat De de <code lang=\"en\">session.gc_maxlifetime</code> en dä Dattei <code>php.ini</code> jrüüßer määß.\nDon dat Projramm för et Opsäze norr_ens aanschmiiße.",
+ "config-no-session": "De Daate för Ding Setzung sinn verschött jejange.\nDonn en dä Dattei <code>php.ini</code> nohloore, ov dä <code lang=\"en\">session.save_path</code> op e zopaß Verzeijschneß zeisch.",
+ "config-your-language": "De Schprohch beim Enreeschte:",
+ "config-your-language-help": "Donn heh di Schprohch ußsöhke, di dat Enschtallzjuhnsprojramm kalle sull.",
+ "config-wiki-language": "Dem Wiki sing Schprohch:",
+ "config-wiki-language-help": "Donn heh di Schprohch ußsöhke, di et Wiki schtandattmääßesch kalle sull.",
+ "config-back": "← Retuhr",
+ "config-continue": "Wigger →",
+ "config-page-language": "Schprohch",
+ "config-page-welcome": "Wellkumme beim MehdijaWikki!",
+ "config-page-dbconnect": "Met dä Daatebangk Verbenge",
+ "config-page-upgrade": "En Inshtallzjuhn op der neuste Shtand bränge",
+ "config-page-dbsettings": "Parrameeter för de Daatebangk",
+ "config-page-name": "Nahme",
+ "config-page-options": "Ennställunge",
+ "config-page-install": "Opsäzze",
+ "config-page-complete": "Fähdesch!",
+ "config-page-restart": "Et Opsäze norr_ens neu aanfange",
+ "config-page-readme": "Donn mesch lässe! (<i lang=\"en\">read me</i>)",
+ "config-page-releasenotes": "Henwies för heh di Version vum Projramm (<i lang=\"en\">Release notes</i>)",
+ "config-page-copying": "Ben aam Kopeere",
+ "config-page-upgradedoc": "Ben op der neuste Stand aam bränge",
+ "config-page-existingwiki": "Mer han ald e Wiki!",
+ "config-help-restart": "Wells De all Ding enjejovve Saache fottjeschmeße han, un dä janze Vörjang vun fürre aan neu aanfange?",
+ "config-restart": "Joh, neu aanfange!",
+ "config-welcome": "=== Ömjevong Pröhfe ===\nMer maache en Aanzahl jrundlääje Pröhvunge, öm erus ze fenge, ov di Ömjävvong heh paß för Mediawiki opzesäze.\nWann de Hölp bem Opsäze hölls, saach wigger, wat heh erus kohm, alsu wat heh schteiht.",
+ "config-copyright": "=== Urhävverrääsch un Lizänzbedengunge ===\n\n$1\n\nDat Projramm heh es frei, mer kann et wiggerjävve un verdeijle un och verändere onger dä Bedengunge vun de GNU <i lang=\"en\">General Public License</i> (Alljemeine öffentlesche Lizänz) wi se vun de <i lang=\"en\">Free Software Foundation</i> (de Schteftung för frei Projramme) veröffentlesch woode es. Dobei kanns De Der de Version 2 vun dä Lizanz ußsöhke, udder jeede Version donoh, wi et Der jefällt.\n\nDat Projramm weed wigger jejovve met dä Hoffnung, dat et jät nöz, ävver <strong>der ohne Jarrantie</strong>, sujaa der ohne de onußjeshproche Jarantie, <strong>verkoufbaa</strong> ze sin, udder <strong>för öhnds_ene beshtemmpte Zweck ze bruche</strong> ze sin.\nLiß de GNU <i lang=\"en\">General Public License</i> sellver, öm mieh ze erfahre.\n\nDo sullts en <doclink href=Copying>Kopie vun dä alljemene öffentlesche Lizänz vun dä GNU</doclink> (<i lang=\"en\">GNU General Public License</i>) zosamme met heh däm Projramm krääje han. Wann dat nit esu es, schrief aan de <i lang=\"en\">Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA</i>, udder [http://www.gnu.org/copyleft/gpl.html liß se online övver et Internet].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki sing Hompäjdsch]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Handbohch för Aanwänder]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Handbohch för Administratohre un Wiki_Köbesse]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Öff jeschtallte Frohre met Antwoote]\n----\n* <doclink href=Readme>Liß Mesch! (<i lang=\"en\">Read me</i>)</doclink>\n* <doclink href=ReleaseNotes><i lang=\"en\">Release notes</i> Övver heh di Projrammversion</doclink>\n* <doclink href=Copying><i lang=\"en\">Copying</i> — Lizänzbeshtemmunge</doclink>\n* <doclink href=UpgradeDoc><i lang=\"en\">Upgrading</i> — Ob en neu Projrammversion jonn</doclink>",
+ "config-env-good": "De Ömjävvöng es jepröhf.\nDo kanns MehdijaWikki opsäze.",
+ "config-env-bad": "De Ömjävong es jeprööf.\nDo kanns MehdijaWikki nit opsäze.",
+ "config-env-php": "PHP $1 es doh.",
+ "config-env-hhvm": "HHVM $1 es enschtalleerd.",
+ "config-unicode-using-intl": "För et <i lang=\"en\">Unicode</i>-Nommaliseere dom_mer dä [http://pecl.php.net/intl Zohsaz <code lang=\"en\">intl</code> uss em <code lang=\"en\">PECL</code>] nämme.",
+ "config-unicode-pure-php-warning": "'''Opjepaß:''' Mer kunnte dä [http://pecl.php.net/intl Zohsaz <code lang=\"en\">intl</code> uss em <code lang=\"en\">PECL</code>] för et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"a standard for the consistent encoding, representation, and handling of text expressed in most of the world's writing systems\">UNICODE</i>-Nommalisehre nit fenge. Dröm nämme mer dat eijfache, ävver ärsch lahme, <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"PHP Hypertext Preprocessor\">PHP</i>-Projrammschtök doför.\nFör jruuße Wikis met vill Metmaachere doht Üsch di Sigg övver et [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"a standard for the consistent encoding, representation, and handling of text expressed in most of the world's writing systems\">UNICODE</i>-Nommaliseere] (es op Änglesch) aanloore.",
+ "config-unicode-update-warning": "'''Opjepaß:''' Dat Projramm för der <i lang=\"en\">Unicode</i> zo normaliseere boud em Momang op en ählter Version vun dä Bibliothek vum [http://site.icu-project.org/ ICU-Projäk] op.\nDoht di [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations op der neuste Shtand bränge], wann auf dat Wiki em Äänz <i lang=\"en\">Unicode</i> bruche sull.",
+ "config-no-db": "Mer kunnte kei zopaß Daatebangk-Driiverprojamm fenge.\nMer bruche e Daatebangk-Driiverprojamm för PHP. Dat moß enjeresht wääde.\nMer künne met heh dä {{PLURAL:$2|Daatebangk|Daatebangke|Daatebangk}} ömjonn: $1.\n\nWann De nit om eijene Rääshner bes, moß De Dinge <i lang=\"en\">provider</i> bedde, dat hä Der ene zopaß Driiver enresht.\nWann de PHP sellver övversaz häs, donn e Zohjangsprojramm för en Daatebangk enbenge, för e Beishpell met: <code lang=\"en\">./configure --with-mysqli</code>.\nWann De PHP uss enem <i lang=\"en\">Debian</i> udder <i lang=\"en\">Ubuntu</i> Pakätt enjeresht häs, moß De dann och noch et <code lang=\"en\">php5-mysql</code> op Dinge Räschner bränge.",
+ "config-outdated-sqlite": "'''Opjepaß:''' <i lang=\"en\">SQLite</i> $1 es enschtaleert. Avver MediaWiki bruch <i lang=\"en\">SQLite</i> $2 udder hühter. <i lang=\"en\">SQLite</i> kann dröm nit enjesaz wääde.",
+ "config-no-fts3": "'''Opjepaß:''' De Projramme vum <i lang=\"en\">SQLite</i> sin der ohne et [//sqlite.org/fts3.html FTS3-Modul] övversaz, dröm wääde de Funxjohne för et Söhke fähle.",
+ "config-pcre-old": "<strong>Fähler:</strong> PCRE $1 udder neuer es nüüdesch.\nPHP es jäz ävver met PCRE $2 zesamme jebonge.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Mieh dohzoh].",
+ "config-pcre-no-utf8": "'''Dä:''' Et PHP-Modul <i lang=\"en\">PCRE</i> schingk ohne de <i lang=\"en\">PCRE_UTF8</i>-Aandeile övversaz ze sin.\nMediaWiki bruch dä UTF-8-Krohm ävver, öm ohne Fähler loufe ze künne.",
+ "config-memory-raised": "Der jrühzte zohjelasse Shpeisherbedarf vum PHP, et <code lang=\"en\">memory_limit</code>, shtund op $1 un es op $2 erop jesaz woode.",
+ "config-memory-bad": "'''Opjepaß:''' Dem PHP singe Parameeter <code lang=\"en\">memory_limit</code> es $1.\nDat es wall ze winnisch.\nEt Enreeschte kunnt doh draan kappott jon!",
+ "config-xcache": "Dä <code lang=\"en\">[http://xcache.lighttpd.net/ XCache]</code> es ennjeresht.",
+ "config-apc": "Dä <code lang=\"en\">[http://www.php.net/apc APC]</code> es ennjeresht.",
+ "config-wincache": "Dä <code lang=\"en\">[http://www.iis.net/download/WinCacheForPhp WinCache]</code> es ennjeresht.",
+ "config-no-cache-apcu": "'''Opjepaß:''' Mer kunnte dä <code lang=\"en\" xml:lang=\"en\" dir=\"rtl\">[http://www.php.net/apcu APCu]</code>, dä <code lang=\"en\" xml:lang=\"en\" dir=\"rtl\">[http://xcache.lighttpd.net/ XCache]</code> udder dä <code lang=\"en\" xml:lang=\"en\" dir=\"rtl\">[http://www.iis.net/download/WinCacheForPhp WinCache]</code> nit fenge.\nEt <i lang=\"en\" xml:lang=\"en\" dir=\"rtl\">object caching</i> es nit müjjelesch un es ußjeschalldt.",
+ "config-mod-security": "<strong>Opjepaß</strong>: Dinge Wäbßööver hät <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[http://modsecurity.org/ mod_security]</code> enjeschalldt. Jenohch schtandattmähßejje Enschtällonge heh em Wikki künne Problehme met MehdijaWikki un och met ander Projramme aanschtivvelle, di zohlohße, dat vun ußerhallef öhndsene Krohm op dä Webßööver jebraat wähde künnt.\nWann müjjelesch sullt mer dat affschallde. Söns beloor Der di Sigg <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[http://modsecurity.org/documentation/ mod_security documentation]</code> udder donn met dä Fachlück för Dinge Webßööver kalle, wann zohfälleje un koomijje Fähler bemärke deihß.",
+ "config-diff3-bad": "Mer han <i lang=\"en\">GNU</i> <code lang=\"en\">diff3</code> nit jefonge.",
+ "config-git": "Mer han de Väsjohn <code>$1</code> vun däm Väsjohnsverwalldongsprojamm <i lang=\"en\">Git</i> jefonge.",
+ "config-git-bad": "Dat Väsjohnsverwalldongsprojamm <i lang=\"en\">Git</i> ham_mer nit jefonge.",
+ "config-imagemagick": "Mer han <i lang=\"en\">ImageMagick</i> jefonge: <code>$1</code>.\nEt Ömrääschne en Minni-Beldsche weed müjjelesch sin, wann De et Belder Huhlaade zohlöhß.",
+ "config-gd": "Mer han de ennjeboute GD-Jrafik-Projramm-Biblijotheek jefonge.\nEt Ömrääschne en Minni-Beldsche weed müjjelesch sin, wann De et Belder Huhlaade zohlöhß.",
+ "config-no-scaling": "Mer han weeder de GD-Jrafik-Projramm-Biblijotheek, noch <i lang=\"en\">ImageMagick</i> jefonge.\nEt Ömrääschne en Minni-Beldsche weed ußjeschalldt.",
+ "config-no-uri": "'''Fähler:''' Mer kunnte der aktoälle <i lang=\"en\">URI</i> nit erusfenge.\nEt Enreeschte es domet heh aam Engk.",
+ "config-no-cli-uri": "'''Opjepaß''': <code lang=\"en\"><code>--scriptpath</code></code> es nit aanjejovve, mer nämme der Schtandatt: <code>$1</code>.",
+ "config-using-server": "Mer nämmen dem ẞööver singe Name: „<nowiki>$1</nowiki>“.",
+ "config-using-uri": "Mer nämmen dem ẞööver singe <i lang=\"en\">URL</i>: „<nowiki>$1$2</nowiki>“.",
+ "config-uploads-not-safe": "'''Opjepaß:''' Uß däm jewöhnlijje Verzeichnes för de huhjelaade Datteie, dat es <code>$1</code>, künnte öhnzwällsche Skrepte un Projramme ußjeföhrt wääde. Och wann MediaWiki de huhjelaade Datteie prööf, dat kein bekannte Risike dren sin, sullt mer doch dat [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security Sesherheitsloch] zoh maache, ih dat mer et Dattei Huhlaade zohlöht.",
+ "config-no-cli-uploads-check": "'''Opjepaß''': <code>$1</code> es dat Schtandatt-Verzeijschneß för et Datteije-Huhlaade. Beim Opsäze met <abbr lang=\"en\" title=\"Call Level Interface\">CLI</abbr> donn mer ävver nit övverpröhve, dat dat jeschöz es dojääje, dat Skrepte vun doh loufe künne, di mer nit loufe han well.",
+ "config-brokenlibxml": "Op Dingem Rääschner loufe Versione vun PHP un <code lang=\"en\">libxml2</code> zosamme, di ävver nit zosamme paßße, un onbimärk de Daate em MediaWiki un ander Web_Aanwändunge [https://bugs.php.net/bug.php?id=45996 bug kapott maache].\nJangk op <code lang=\"en\">libxml2</code> 2.7.3 udder dohnoh.\nHeh jeihd et nit wigger.",
+ "config-suhosin-max-value-length": "<i lang=\"en\">Suhosin</i> es enschtalleet. Dröm kann ene <code lang=\"en\">GET</code>-Parrameeter nit övver {{PLURAL:$1|ei Byte|$1 Bytes|noll Byte}} lang wääde. Dem MediaWiki singe <i lang=\"en\">ResourceLoader</i> kütt doh zwa drömeröm, ävver dat bräms. Wann müjelesch, doht <code lang=\"en\">suhosin.get.max_value_length</code> en dä Dattei <code lang=\"en\">php.ini</code> op 1024 Bytes udder drövver enschtälle, un dann moß <code lang=\"en\">$wgResourceLoaderMaxQueryLength</code> en dä Dattei <code lang=\"en\">LocalSettings.php</code> op däsälve Wäät jesaz wääde.",
+ "config-db-type": "De Zoot Daatebangk:",
+ "config-db-host": "Dä Name vun däm Rääschner met dä Daatebangk:",
+ "config-db-host-help": "Wann Dinge ẞööver för de Daatebangk ob enem andere Rääschner es, donn heh dämm singe Name udder dämm sing <i lang=\"en\">IP</i>-Addräß enjävve.\n\nWann De ob enem Meetẞööver beß, weet Der Dinge Provaider odder däm sing Dokemäntazjuhn saare, wat De endraare moß.\n\nWann De ob enem ẞööver onger <i lang=\"en\">Windows</i> am enshtalleere bes un en <i lang=\"en\">MySQL</i>-Daatebangk häs, künnd_et sin, dat „<code lang=\"en\">localhost</code>“ nit douch för der Name vum ẞööver. Wann dad-esu es, versöhg et ens met „<code lang=\"en\">127.0.0.1</code>“ als <i lang=\"en\">IP</i>-Addräß vum eije Rääschner.\n\nWann De ene <i lang=\"en\">PostgreSQL</i>-ẞööver häs, donn dat Fäld läddesch lohße, öm en Verbendung övver e <i lang=\"en\">Unix socket</i> opzemaache.",
+ "config-db-host-oracle": "Dä Daatebangk ier <i lang=\"en\" title=\"Transparent Network Substrate\">TNS</i>:",
+ "config-db-host-oracle-help": "Donn ene jöltije [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm „<i lang=\"en\">Local Connect</i>“-Name] aanjävve. De Dattei „<code lang=\"en\">tnsnames.ora</code>“ moß för heh dat Projamm seschbaa un ze Lässe sin.<br />Wann heh de Projamm_Biblijoteeke für de Aanwänderprojramme för de Version 10g udder neuer enjesaz wääde, kam_mer och et [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm „<i lang=\"en\">Easy Connect</i>“] jenumme wääde för der Name ze verjävve.",
+ "config-db-wiki-settings": "De Daate vum Wiki",
+ "config-db-name": "Dä Nahme vun dä Daatebangk:",
+ "config-db-name-help": "Jiff ene Name aan, dä för Ding Wiki passe deiht.\nDoh sullte kei Zweschrereum un kein Stresche dren sin.\n\nWann De nit op Dingem eije Rääschner bes, künnt et sin, dat Dinge Provaider Der extra ene beshtemmpte Name för de Daatebangk jejovve hät, uffr dat de dä drom froore moß udder dat De de Daatebangke övver e Fommulaa selver enreeschte moß.",
+ "config-db-name-oracle": "Schema för de Daatebangk:",
+ "config-db-account-oracle-warn": "Mer han drei Aate, wi mer <i lang=\"en\">Oracle</i> als Dahtebangk aanbenge künne.\n\nWann De ene neue Zohjang op de Dahtenbangk met Nahme un Paßwoot mem Projramm för et Opsäze aanlääje wells, dann jif ene Zohjang met däm Rääsch „<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"SYS - Database Administrator Authentication\">SYSDBA</i>“ aan, dä et alld jitt, un jif däm di Daate aan för dä neue Zohjang aanzelääje.\nDo kanns och dä neue Zohjang vun Hand aanlääje un heh beim Opsäze nur dää aanjävve — wann dä dat Rääsch hät, en de Daatebangk Schema_Objäkte aanzelääje.\nUdder De jiß zwei ongerscheidlijje Zohjäng op de Daatenbangk aan, woh eine vun dat Rääsch zom Aanlääje hät un dä andere moß dat nit un es för der nomaale Bedrief zohshtändesch.\n\nEn Skrep, wat ene Zohjang op de Dahtenbangk aanlääsch met all dä nüüdejje Rääschde, fengks De em Verzeishneß <code lang=\"en\">maintenance/oracle/</code> vun Dingem MediaWiki. Donn draan dengke, dat ene Zohjang met beschrängkte Rääschde all di Müjjeleschkeite för et Waade un Repareere nit hät, di de jewöhnlejje Zoot Zohjang met sesh brängk.",
+ "config-db-install-account": "Der Zohjang för en Enreeschte",
+ "config-db-username": "Dä Name vun däm Aanwender för dä Zohjref op de Daatebangk:",
+ "config-db-password": "Et Paßwoot vun däm Aanwender för dä Zohjref op de Daatebangk:",
+ "config-db-install-username": "Jiv ene Nahme aan för dä Aanwender för dä Zohjref op de Datebangk beim Enshtallehre.\nDat es keine Metmaacher_Nahme em Wikki — heh dä Nahme es alleins en der Dahtebangk bikannt.",
+ "config-db-install-password": "Jiv e Paßwoot aan för dä Aanwender för dä Zohjref op de Daatebangk beim Enshtalleere.\nDat es kei Paßwoot för ene Metmaacher em Wiki — et es alleins en der Daatebangk bikannt.",
+ "config-db-install-help": "Donn dä Name un et Paßwoot vun däm Aanwänder för der Zohjreff op de Daatebangk jäz för et Enreeshte aanjävve.",
+ "config-db-account-lock": "Donn dersälve Name un et sälve Paßwoot för der nomaale Bedrief vum Wiki bruche",
+ "config-db-wiki-account": "Dä Name vun däm Aanwender för dä Zohjref op de Daatebangk em nomaale Bedrief:",
+ "config-db-wiki-help": "Jiv ene Nahme un e Paßwoot aan, för dä Aanwänder för dä Zohjref op de Dahtebangk, wann et Wikki nommahl aam Loufe es.\nWann et dä Nahme en der Dahtebangk noch nit jit, un dä Aanwender för dä Zohjrevv op de Dahtebangk beim Enschtallehre jenohch Berääschtejonge hät, läht dä heh dä Aanwänder en der Dahtebangk aan un jidd_em di Rääschde, di dä nühdesch hät, ävver nit mih.",
+ "config-db-prefix": "Vörsaz för de Name vun de Tabälle en de Daatebangk:",
+ "config-db-prefix-help": "Wann ein Daatebangk för mih wi ein Wiki udder e Wiki uns söns jät zosamme jebruch weed, dann kam_mer noch jet vör de Tabälle ier Name säze. Esu ene Vörsaz sull dubblte Tabällename vermeide hälfe.\nDonn kein Zwescheräum enjävve!\n\nJewöhnlesch bliev dat Feld heh ävver läddesch.",
+ "config-mysql-old": "Mer bruche <i lang=\"en\">MySQL</i> $1 udder neuer. Em Momang es <i lang=\"en\">MySQL</i> $2 aam Loufe.",
+ "config-db-port": "De Pooz-Nommer (<i lang=\"en\">port</i>) för de Daatebangk:",
+ "config-db-schema": "Et Schehma en de Datebangk för MehdijaWikki:",
+ "config-db-schema-help": "För jewöhnlesch es dat Schema en Odenong.\nDonn bloß jät draan ändere, wann De sescher weiß, dat dat nüüdesch es.",
+ "config-pg-test-error": "Mer krijje kein Verbendung zor Daatebank '''$1''': $2",
+ "config-sqlite-dir": "Dem <i lang=\"en\">SQLite</i> sing Daateverzeishnes:",
+ "config-sqlite-dir-help": "<i lang=\"en\">SQLite</i> hät all sing Daate zosamme en en einzel Dattei.\n\nEn dat Verzeishneß, wat De aanjiß, moß dat Web_ẞööver_Projramm beim Opsäze eren schriive dörrve.\n\nDat Verzeishneß sullt '''nit''' övver et Web zohjänglesch sin, dröm dom_mer et nit dohen, woh de <i lang=\"en\">PHP</i>-Datteije sin.\n\nMer donn beim Opsäze zwa uß Vöörssh en <code lang=\"en\">.htaccess</code> Dattei dobei, ävver wann di nit werrek, künnte Lück vun ußerhallef aan Ding Daatebangk_Dattei eraan kumme.\nDoh shtonn Saache dren, wi de Addräße för de Metmaacher ier <i lang=\"en\">e-mail</i> un de verschlößelte Paßwööter un de vershtoche un de fottjeschmeße Sigge un ander Saache ussem Wiki, di mer nit öffentlesch maache darref.\n\nDonn Ding Daatebangk et beß janz woh anders hen, noh <code lang=\"en\">/var/lib/mediawiki/''wikiname''</code> för e Beishpell.",
+ "config-oracle-def-ts": "Tabälleroum för der Shtandattjebruch:",
+ "config-oracle-temp-ts": "Tabälleroum för der Jebruch zweschedorsh:",
+ "config-type-mysql": "<i lang=\"en\">MySQL</i> (udder en jlischwääteje)",
+ "config-type-postgres": "<i lang=\"en\">PostgreSQL</i>",
+ "config-type-sqlite": "<i lang=\"en\">SQLite</i>",
+ "config-type-oracle": "<i lang=\"en\">Oracle</i>",
+ "config-type-mssql": "Dä <i lang=\"en\" xml:lang=\"en\">SQL</i>-ẞööver vun <i lang=\"en\" xml:lang=\"en\">Microsoft</i>",
+ "config-support-info": "MediaWiki kann met heh dä Daatebangk_Süßteeme zosamme jonn:\n\n$1\n\nWann dat Daatebangk_Süßteem, wat De nämme wells, onge nit dobei es, dann donn desch aan di Aanleidonge hallde, di bovve verlengk sen, öm et op Dingem ẞööver singem Süßteem müjjelesh ze maache, se aan et Loufe ze krijje.",
+ "config-dbsupport-mysql": "* <i lang=\"en\" xml:lang=\"en\">[{{int:version-db-mysql-url}} MySQL]</i> es dat vum MediaWiki et eets un et bäß ongerschtöz Daatebangksüßtehm. Et leuf ävver och met <i lang=\"en\" xml:lang=\"en\">[{{int:version-db-mariadb-url}} MariaDB]</i> un <i lang=\"en\" xml:lang=\"en\">[{{int:version-db-percona-url}} Percona Server]</i>. Di sin kumpatihbel mem <i lang=\"en\" xml:lang=\"en\">MySQL</i>. ([http://www.php.net/manual/de/mysql.installation.php Aanleidung för et Övversäze un Enreeschte von <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"PHP Hypertext Preprocessor\">PHP</i> met <i lang=\"en\">MySQL</i> dobei, op Deutsch])",
+ "config-dbsupport-postgres": "* <i lang=\"en\">[{{int:version-db-postgres-url}} PostgreSQL]</i> es e bikannt Daatebangksüßtehm met offe Quälltäxde, un ed es och en Wahl nävve <i lang=\"en\">MySQL</i>. Et sinn_er ävver paa klein Fählersche bekannt, um mer künne et em Momang för et reschtijje Werke nit ämfähle. ([http://www.php.net/manual/de/pgsql.installation.php Aanleidung för et Övversäze un Enreeschte von PHP met <i lang=\"en\">PostgreSQL</i> dobei, op Deutsch])",
+ "config-dbsupport-sqlite": "* <i lang=\"en\">[{{int:version-db-sqlite-url}} SQLite]</i> es e eijfach Daatebangksüßtehm, wat joot en Schoß jehallde weed. ([http://www.php.net/manual/de/pdo.installation.php Aanleidong för et Övversäze un Enreeschte von PHP met <i lang=\"en\">SQLite</i> dobei, op Deutsch])",
+ "config-dbsupport-oracle": "* <i lang=\"en\">[{{int:version-db-oracle-url}} Oracle]</i> es e jeschäfflesch Daatebangksüßtehm för Ferme. ([http://www.php.net/manual/de/oci8.installation.php Aanleidong för et Övversäze un Enreeschte von PHP met <i lang=\"en\" xml:lang=\"en\">OCI8</i> dobei, op Deutsch])",
+ "config-dbsupport-mssql": "* Dä <i lang=\"en\" xml:lang=\"en\">[{{int:version-db-mssql-url}} Microsoft SQL Server]</i> es e jeschäfflesch Dahtebangksüßtehm för Rääschner met <i lang=\"en\" xml:lang=\"en\">Windows</i>. ([http://www.php.net/manual/de/sqlsrv.installation.php Aanleidong för et Övversäze un Enreeschte von <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"PHP Hypertext Preprocessor\">PHP</i> met <i lang=\"en\" xml:lang=\"en\">SQLSRV </i> dobei, op Deutsch])",
+ "config-header-mysql": "De Enshtällunge för de <i lang=\"en\">MySQL</i> Daatebangk",
+ "config-header-postgres": "De Enshtällunge för de <i lang=\"en\">PostgreSQL</i> Daatebangk",
+ "config-header-sqlite": "De Enshtällunge för de <i lang=\"en\">SQLite</i> Daatebangk",
+ "config-header-oracle": "De Enshtällunge för de <i lang=\"en\">Oracle</i> Daatebangk",
+ "config-header-mssql": "Enschtällonge för der <i lang=\"en\" xml:lang=\"en\">SQL</i>-ẞööver vun <i lang=\"en\" xml:lang=\"en\">Microsoft</i>",
+ "config-invalid-db-type": "Dat es en onjöltijje Zoot Daatebangk.",
+ "config-missing-db-name": "Do moß jäd enjävve för \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Do moß jät enjävve för \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Do moß jät enjävve för \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "Dä Daatebangk ier <i lang=\"en\" title=\"Transparent Network Substrate\">TNS</i> kann nit „$1“ sin, dat es esu nit jöltesch.\nNemm en „TNS-Nahme“ udder ene „<i lang=\"en\" xml:lang=\"en\">Easy-Connect</i>“-<i lang=\"en\" xml:lang=\"en\">Easy-Connect</i>String</i>(Lor noh dädohwähje noh de <i lang=\"en\" xml:lang=\"en\">[http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]</i>)",
+ "config-invalid-db-name": "Dä Daatebangk iere Name kann nit „$1“ sin, dä es esu nit jöltesch.\nDöh dörve bloß <i lang=\"en\" title=\"American Standard Code for Information Interchange\">ASCII</i> Boochshtaabe (a-z, A-Z), Zahle (0-9), Ongerstresh (_), un Bendeshtresh (-) dren vörkumme.",
+ "config-invalid-db-prefix": "Dä Vörsaz för de Name vun de Tabälle en de Daatebangk kann nit „$1“ sin, dä es esu nit jöltesch.\nDöh dörve bloß <i lang=\"en\" title=\"American Standard Code for Information Interchange\">ASCII</i> Boochshtaabe (a-z, A-Z), Zahle (0-9), Ongerstreshe (_), un Bendeshtreshe (-) dren vörkumme.",
+ "config-connection-error": "$1.\n\nDonn de Name för dä Rääschner, vun däm Aanwender för dä Zohjref op de Daatebangk, un et Paßwoot prööfe, repareere, un dann versöhg et norr_ens.",
+ "config-invalid-schema": "Dat Schema för MediaWiki kann nit „$1“ sin, dä Name wöhr esu nit jöltesch.\nDöh dörve bloß <i lang=\"en\" title=\"American Standard Code for Information Interchange\">ASCII</i> Boochshtaabe (a-z, A-Z), Zahle (0-9), un Ongerstreshe (_) dren vörkumme.",
+ "config-db-sys-create-oracle": "Dat Projramm för MehdijaWikki opzesäze kann blohß ene <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"SYS - Database Administrator Authentication\">SYSDBA</i>-Zohjang bruche för ene neuje Zohjang zor Dahtebangk ennzereeschte.",
+ "config-db-sys-user-exists-oracle": "Dä Aanwender „$1“ för dä Zohjref op de Daatebangk jidd_et ald. <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"SYS - Database Administrator Authentication\">SYSDBA</i> kam_mer bloß bruche, för ene neue Zohjang enzereeschte!",
+ "config-postgres-old": "Mer bruche <i lang=\"en\">PostgreSQL</i> $1 udder neuer. Em Momang es <i lang=\"en\">PostgreSQL</i> $2 aam Loufe.",
+ "config-mssql-old": "Dä <i lang=\"en\" xml:lang=\"en\">SQL</i>-ẞööver vun <i lang=\"en\" xml:lang=\"en\">Microsoft</i> aff de Väsjohn $1 es nüüdesch. Heh es bloß d Väsjohn $2 ze fenge.",
+ "config-sqlite-name-help": "Söhk ene Nahme uß, dä Ding Wikki beschrief.\nDonn kein Bendeschresch un Zweschräum en däm Name bruche.\nDä Name weed för der Datteinahme för de <i lang=\"en\">SQLite</i> Dahtebangk jenumme.",
+ "config-sqlite-parent-unwritable-group": "Mer kunnte dat Verzeischneß för de Daate, <code lang=\"en\"><nowiki>$1</nowiki></code>, nit enreeschte, weil dat Projramm fö dä Web_ẞööver en dat Verzeischneß doh drövver, <code><nowiki>$2</nowiki></code>, nix erin donn darref.\n\nMer han dä Name vun däm Zohjang op et Süßteem eruß jefonge, onger dämm dat Web_ẞööver_Projramm läuf. Jez moß De bloß doför sorrje, dat dä en dat Verzeischneß <code><nowiki>$3</nowiki></code> schrieve kann, öm heh wigger maache ze künne.\nOb enem Süßteem met <i lang=\"en\">Unix</i>- oder <i lang=\"en\">Linux</i> jeiht dat esu:\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Mer kunnte dat Verzeischneß för de Daate, <code lang=\"en\"><nowiki>$1</nowiki></code>, nit enreeschte, weil dat Projramm fö dä Web_ẞööver en dat Verzeischneß doh drövver, <code><nowiki>$2</nowiki></code>, nix erin donn darref.\n\nMer han dä Name vun däm Zohjang op et Süßteem nit eruß fenge künne, onger dämm dat Web_ẞööver_Projramm läuf. Jez moß De bloß doför sorrje, dat dä en dat Verzeischneß <code><nowiki>$3</nowiki></code> schrieve kann, öm heh wigger maache ze künne. Wann De dä Name och nit weiß, maach, dat jeeder_ein doh schrieve kann.\nOb enem Süßteem met <i lang=\"en\">Unix</i>- oder <i lang=\"en\">Linux</i> jeiht dat esu:\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Ene Fähler es opjetrodde beim Aanlääje vum Daate_Verzeishneß „$1“.\nDon dä Plaz för et Shpeishere prööfe un Repareere, dann versöhg et norr_ens.",
+ "config-sqlite-dir-unwritable": "Mer künne nit en dat Verzeishneß „$1“ schrieeve\nDonn dohvun de Zohjreffs_Rääschde esu verändere, dat der Webßööver doh dren schrieeve kann, un dann versöhg et norr_ens.",
+ "config-sqlite-connection-error": "$1.\n\nDonn onge dat Verzeishnes un der Name vun der Daatebangk prööfe un repareere, un dann versöhg_et norr-ens.",
+ "config-sqlite-readonly": "En di Dattei <code lang=\"en\">$1</code> künne mer nit schrieve.",
+ "config-sqlite-cant-create-db": "Mer kunnte di Dattei <code lang=\"en\">$1</code> för de Daatebangk nit aanlääje.",
+ "config-sqlite-fts3-downgrade": "Dat PHP heh hät kein Ongershtözong för FTS3, dröm donn mer de Daatebangktabälle eronger shtoofe.",
+ "config-can-upgrade": "Et sinn-er ald Dahtebangktabälle vum MehdijaWikki en dä Dahtebangk.\nÖm di op der Schtand vum MehdijaWikki $1 ze bränge, donn jäz op „{{int:config-continue}}“ klecke.",
+ "config-upgrade-done": "Alles es jäz om neue Schtand.\n\nMer kann dat Wiki jäz [$1 bruche].\n\nWann De Ding Dattei <code lang=\"en\">LocalSettings.php</code> neu schrieve wells, donn onge op dä Knopp kleke.\nDat dom_mer ävver '''nit vörschlonn''' — em Jääjedeil — ußer, wann et Problehme mem Wiki jitt.",
+ "config-upgrade-done-no-regenerate": "Alles es jäz om neue Shtand.\n\nMer kann dat Wiki jäz [$1 bruche].",
+ "config-regenerate": "Donn de Dattei <code lang=\"en\">LocalSettings.php</code> neu opsäze →",
+ "config-show-table-status": "Et Kommando <code lang=\"en\"><code>SHOW TABLE STATUS</code></code> aan de Daatebangk es donävve jejange!",
+ "config-unknown-collation": "'''Opjepaß:''' De Daatabangk deiht en onbikannte Reijefollsch bruche, för Booshtaabe un Zeishe ze verjliishe un ze zotteere.",
+ "config-db-web-account": "Dä Zohjang zor Daatebangk för et Wiki",
+ "config-db-web-help": "Donn ene Name un e Paßwoot för der Zohjang zor Daatebangk för et Wiki em nomaale Bedrief aanjävve.",
+ "config-db-web-account-same": "Donn dersällve Zohjang nämme, wi heh beim Opsäze.",
+ "config-db-web-create": "Donn dä Zohjang aanlähje, wann dä noch nit doh es.",
+ "config-db-web-no-create-privs": "Dä Zohjang för et Opsäze es nit berääschtesch, ene ander Zohjan enzereeschte.\nDä aanjejovve Zohjang för der Nomaalbedrief moß dröm schunn enjersht sen!",
+ "config-mysql-engine": "De Zoot udder et Fommaat vun de Tabälle:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Opjepaß:''' <i lang=\"en\">MyISAM</i> es als Speicher för <i lang=\"en\">MySQL</i> nit besönders joot för et Zosammeschpell met MediaWiki zo bruche:\n* Dorj_et kumplätte Sperre vun Tabälle, künne koum ens Saache parrallel en dä Daatebangk jedonn wääde.\n* Dat Fomaat es anfällesch för Probleme met de Daate.\n* Et weed vun MediaWiki nit ėmmer zopaß ongerschtöz.\n\nWann Ding <i lang=\"en\">MySQL</i> et Schpeischere en <i lang=\"en\">InnoDB</i>-Datteije ongerschtöze deiht, dom_mer dat nohdröcklesch ämfähle.\nKann dä ẞööver dat nit, künnd et joode jelääjeheit sin, dä ens op der neuste Schtand ze bränge.",
+ "config-mysql-only-myisam-dep": "'''Opjepaß:''' <i lang=\"en\" xml:lang=\"en\">MyISAM</i> es de einzeje Zoot Schpeischerprojramm för <i lang=\"en\" xml:lang=\"en\">MySQL</i> op dä Maschiin. Di es nit för MediaWiki ze ämfähle es, weil:\n* wääje dem Schpärre vun jannze Tabälle sin koum paralleele Axjuhne en dä Daatebangk möjjelesch,\n* ed es aanfällesch för Probleeme met de Daate es, un\n* et weed vun MediaWiki nit emmer jood ongerschtöz.\n\nDing Enschtallazjuhn vum <i lang=\"en\" xml:lang=\"en\">MySQL</i> kann nit met <i lang=\"en\" xml:lang=\"en\">InnoDB</i> ömjonn.\nWi wöhr et med ene neuere Väsjohn vum <i lang=\"en\" xml:lang=\"en\">MySQL</i>?",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> es fö jewöhnlesch et beß, weil vill Zohjreffe op eijmohl joot ongershtöz wääde.\n\n<strong>MyISAM</strong> es flöcker op Rääschnere met bloß einem Minsch draan, un bei Wikis, di mer bloß lässe un nit schrieeve kann.\nMyISAM-Daatebangke han em Schnett mih Fähler un jon flöcker kappott, wi InnoDB-Daatebangke.",
+ "config-mysql-charset": "Dä Daatebangk iere Zeischesaz:",
+ "config-mysql-binary": "binär",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Beim Schpeischere em <strong>binähre Fomaht</strong> deiht MehdijaWikki Täx, dä em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Däm Unicode singe Universal Coded Character Set + Transformation Format—8-Bit\">UTF-8</i>-Fommaht kütt, en singer Dahtebangk en binähr kodehrte Dahtefälder faßhallde.\nDad_es flöcker un spahsamer wi et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Däm Unicode singe Universal Coded Character Set + Transformation Format—8-Bit\">UTF-8</i>-Fommaht vum <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">MySQL</i> un määd_et müjjelesch, jehdes <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"a standard for the consistent encoding, representation, and handling of text expressed in most of the world's writing systems\">UNICODE</i>-Zeische met faßzehallde.\n\nBeim Schpeischere em <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Däm Unicode singe Universal Coded Character Set + Transformation Format—8-Bit\">UTF-8</i> deihd_et <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">MySQL</i> der Zeijschesaz un de Kodehrung vun dä Dahte känne, un kann se akeraht aanzeije un ömwandelle,\nallerdengs künne kein Zeische ußerhalv vum [https://de.wikipedia.org/wiki/Basic_Multilingual_Plane#Gliederung_in_Ebenen_und_Bl.C3.B6cke jrundlähje Knubbel för vill Schprohche (<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\">Basic Multilingual Plane — BMP</i>)] afjeschpeischert wähde.",
+ "config-mssql-auth": "De Zoot Aanmäldong:",
+ "config-mssql-install-auth": "Söhk us, wi dat Aanmälde aan dä Daatebangk vor sesch jonn sull för de Enschtallazjuhn.\nWann De <em>{{int:Config-mssql-windowsauth}}</em> nemms, weed jenumme, met wat emmer dä Wäbßööver aam loufe es.",
+ "config-mssql-web-auth": "Söhk us, wi dat Aanmälde aan dä Daatebangk vör sesch jonn sull för de nommaale Ärbeid vum Wiki.\nWann De <em>{{int:Config-mssql-windowsauth}}</em> nemms, weed dat jenumme, wohmet dä Wäbßööver aam loufe es.",
+ "config-mssql-sqlauth": "De Aanmäldong bemm <i lang=\"en\" xml:lang=\"en\">SQL</i>-ẞööver vun <i lang=\"en\" xml:lang=\"en\">Microsoft</i>",
+ "config-mssql-windowsauth": "De Annmäldong bemm <i lang=\"en\" xml:lang=\"en\">Windows</i>",
+ "config-site-name": "Däm Wikki singe Nahme:",
+ "config-site-name-help": "Dä douch en dä Övverschreff vun de Brauserfinstere un aan ätlije andere Schtälle op.",
+ "config-site-name-blank": "Donn ene Name för di Sait aanjävve.",
+ "config-project-namespace": "Dä Name för et Appachtemang övver et Projäk:",
+ "config-ns-generic": "Projäk",
+ "config-ns-site-name": "Et sällve wi däm Wiki singe Name: $1",
+ "config-ns-other": "Andere (jiff aan wälshe)",
+ "config-ns-other-default": "MingWiki",
+ "config-project-namespace-help": "Noh dämm Vörbeld vun de Wikipehdija, donn vill Wikkis dänne ier Sigge övver et Wikki un sing Rääjelle vun dä Sigge mem Enhald vum Wikki tränne, un en enem extra Appachtemang för et „'''Projäk'''“ afflääje.\nSigge en däm Appachtemang fange all med enem beschtemmpte Vörsaz aan, däm Name vum Appachtemang, un dä moß De heh faßlähje.\nDä Nahme darref beschtemmpte Zeiche nit enthallde, wi „#“ un „:“ un et es övv esu, dat hä vum Nahme vum Wikki her kütt.",
+ "config-ns-invalid": "Dat aanjejovve Appachtemang „<nowiki>$1</nowiki>“ es nit jöltesch.\nNemm ene andere Name för däm Wiki sing eije Appachtemang.",
+ "config-ns-conflict": "Dat aanjejovve Appachtemang „<nowiki>$1</nowiki>“ kütt ald als Standatt-Appachtemang em MediaWiki vör.\nNemm ene andere Name för däm Wiki sing eije Appachtemang.",
+ "config-admin-box": "Der Zohjang för der eezte Wiki_Köbes",
+ "config-admin-name": "Dinge Metmaacher_Nahme:",
+ "config-admin-password": "Et Paßwoot:",
+ "config-admin-password-confirm": "Norrens dat Paßwoot:",
+ "config-admin-help": "Jif Dinge leevste Name als Metmaacher för Desch aan, för e Beishpell „Schmitzens Pitter“\n— Dat weed dä Name wääde, met dämm De Desch enlogge deihs.",
+ "config-admin-name-blank": "Jiv ene Metmaacher_Nahme en för dä Wikki-Köhbes.",
+ "config-admin-name-invalid": "„<nowiki>$1</nowiki>“ es keine jöltijje Metmaacher_Nahme.\nJiv ene johde Nahme en!",
+ "config-admin-password-blank": "Do mos_e Paßwoot för dä Wiki_Köbes aanjävve!",
+ "config-admin-password-mismatch": "Di Paßwööter sin ongerscheidlesh!",
+ "config-admin-email": "Addräß för de <i lang=\"en\">e-mail</i>:",
+ "config-admin-email-help": "Jiv heh di Adräß för de <i lang=\"en\">e-mail</i> aan, woh De <i lang=\"en\">e-mail</i> vun ander Metmaacher uss_em Wiki hen krijje wells, di et Der müjjelesh määt, Ding Paßwoot automatetsch truusche ze lohße, un woh Nohreeshte övver veränderte Sigge op Dinge Oppaßleß hen jescheck wääde sulle.\nDe kanns dat Fäld ävver och läddesch lohße.",
+ "config-admin-error-user": "Beim Enreeshte vum Zohjang för dä Wiki_Köbes „<nowiki>$1</nowiki>“ es ene Fähler em Wiki opjetrodde.",
+ "config-admin-error-password": "Beim Paßwoot-Säze för dä Wiki_Köbes „<nowiki>$1</nowiki>“ es ene Fähler em Wiki opjetrodde.: <pre>$2</pre>",
+ "config-admin-error-bademail": "Do häs_en onjöltijje Addräß för de <i lang=\"en\">e-mail</i> aanjejovve.",
+ "config-subscribe": "Donn de [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce \n<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mail</i>-Leß met de Aanköndijonge vum MehdijaWikki] abonnehre.",
+ "config-subscribe-help": "Do kumme bloß winnish Meddeilunge un di jonn övver neu Versiohne vom MediaWiki un weeshtejje Saache vun däm sing Sesherheit.\nDo sullts se abbonneere, un Ding MediWiki_Projramme op der neue Shtand bränge, wann neu Version eruß kumme.",
+ "config-subscribe-noemail": "Do has versöhk, der ohne en Addräß för Ding <i lang=\"en\">e-mail<i> aanzejävve, de Aanköndijonge för Aanköndijunge för neue Versione ze abboneere. Jivv en Addräß aan, wann De di Aanköndijonge hann wells.",
+ "config-pingback": "Jivv Dahte övver heh di Enschtallazjuhn vum Mehdijawikki aan de Äntwerkere.",
+ "config-almost-done": "Do bes beinah dorsch!\nDo künnts jez der Räß vun de einzel Enschtällonge övverjonn, un et Wiki tiräktemang fähdesch opsäze.",
+ "config-optional-continue": "De wells noch mih Frohre jeschtallt krijje un noch mih Enschtällonge maache?",
+ "config-optional-skip": "Nä, lohß dä Ömshtand, donn eifarr_et Wiki opsäze.",
+ "config-profile": "Enshtällunge för de Metmaacher ier Rääschte:",
+ "config-profile-wiki": "En offe Wiki",
+ "config-profile-no-anon": "Schriever möße enlogge",
+ "config-profile-fishbowl": "Bloß ußdröcklesch zohjelohße Schriever",
+ "config-profile-private": "E jeschloße Privat_Wiki",
+ "config-profile-help": "Wikkis loufe et bäß, wam_mer esu vill Lück wi möjjelesch draan metmaache un schrieve löht.\nMet MehdijaWikki es et ejfach, de neuste Änderonge ze beloore un wat ahnungslose udder fiese Lück kapott jemaat han wider retuur ze maache.\n\nBloß, mänsch eine häd_eruß jefonge, dat mer MediaWiki jood en en jruuße Zahl ongerscheidlijje Rolle bruche kann, un nit emmer es et leisch, ene vum onverfälschte Wiki_Wääsch ze övverzeuje.\nEsu häß De de Wahl:\n\n'''{{int:config-profile-wiki}}''' löht jeder_ein metschrihve, och ohne sesch enzelogge.\n\n'''{{int:config-profile-no-anon}}''', dat sorsch för mieh seeschbaa Verantwootlischkeite, künnt ävver zohfällije Methellefer verschrecke.\n\n'''{{int:config-profile-fishbowl}}''' löht nor de ußjesöhk Metmaacher schrieve, ävver de janze Öffentleshkeit kann et lässe un süht och de ällder Versione, un wat wää wann draan jedonn hät.\n\n'''{{int:config-profile-private}}''' kann nur lässe, wäh en et Wikki zohjelohße es, un desellve Jropp kann och schrieve.\n\nNoch ander un un opwändijere Enschtällonge för de Rääschte sin müjjelesch, wann et Wikki ens aam Loufe es. Loor Der doför de [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights zopaß Hölp em Handbooch] aan.",
+ "config-license": "Urhävverrääsch un Lizänz:",
+ "config-license-none": "Kein Fooßreih övver de Lizänz",
+ "config-license-cc-by-sa": "<i lang=\"en\">Creative Commons</i> Der Name moß jenannt sin, et Wiggerjävve es zohjelohße onger dersellve Bedengunge",
+ "config-license-cc-by": "De <i lang=\"en\">Creative Commons</i> ier Lizänz met Namensnännong",
+ "config-license-cc-by-nc-sa": "<i lang=\"en\">Creative Commons</i> Nit för e Jeschäff ze maache, et Wiggerjävve es zohjelohße unger dersellve Bedengunge",
+ "config-license-cc-0": "<i lang=\"en\">Creative Commons</i> „Noll“ (jemeinfrei udder Pablic Domain)",
+ "config-license-gfdl": "De <i lang=\"en\">GNU</i>-Lizänz för frei Dokemäntazjuhne, Version 1.3 udder en späädere",
+ "config-license-pd": "Allmende (jemeinfrei, <i lang=\"en\">public domain</i>)",
+ "config-license-cc-choose": "En <i lang=\"en\">Creative Commons</i> Lizänz, sellver ußjesöhk:",
+ "config-license-help": "Ättlijje öffentleje Wikis donn iehr Beidrääsch onger en [http://freedomdefined.org/Definition freije Lizänz] schtelle.\nDat hellef, e Jeföhl vun Jemeinsamkeid opzeboue, un op lange Seesch emmer wider Beidrääsch ze krijje.\nDat es nit onbedengk nüüdesh för e Jeschäffs- udder Privaat_Wiki.\n\nWä Stöcke uß de Wikipedia bruche well, un dröm han well, dat mer för Wikipedia uss_em eije Wiki jät övvernämme kann, sullt „'''<i lang=\"en\">Creative Commons</i>, dem Schriever singe Name moß jenannt wääde, un Wiggerjävve zoh dersellve Bedengunge es zohjelohße'''“ ußwähle.\n\nDe su jenannte '''<i lang=\"en\">GNU Free Documentation License</i>''' (de freije Lizänz för Dokemäntazjuhne vun dä GNU) sen de ahle Lizänzbedenonge vun de Wikipedia. Se es emmer noch in Odenong un jöltesch, ävver se es schwer ze verschtonn un et Wiggerjävve un widder Bruche es ens schwieerejer domet.",
+ "config-email-settings": "Enschtellunge för de <i lang=\"en\">e-mail</i>",
+ "config-enable-email": "De <i lang=\"en\">e-mail</i> noh druße zohlohße",
+ "config-enable-email-help": "Sulle \n<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mails</i> zohjelohße sin, moß mer, domet et noher flupp, dä Datteij <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">[http://www.php.net/manual/en/mail.configuration.php</code> Enschtällonge em PHP för de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mail</i>] zopaß jemaat han.\nWann kein <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mails</i> nüüdesch sin, kam_mer se heh afschallde.",
+ "config-email-user": "<i lang=\"en\">e-mails</i> zwesche de Metmaacher zohlohße",
+ "config-email-user-help": "Määt et müjjelesch, dat sesch de Metmaacher jääjesiggesch \n<i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mails</i> scheke künne, wann se dat en iehre eije Enschtällonge och enjeschalldt han.",
+ "config-email-usertalk": "<i lang=\"en\">e-mails</i> mem Bescheid zohlohße, dat einem sing Klaafsigg verändert woodt",
+ "config-email-usertalk-help": "Maach et müjjelesch, dat Metmaaacher en iere Enschtällonge <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„de eläktrohnesche Poß“\">e-mails</i>mem Bescheid zohlohße, dat einem sing Klaafsigg veränndert woodt.",
+ "config-email-watchlist": "Nohreeschte övver Änderonge aan Sigg op de Opaßleßte zohlohße",
+ "config-email-watchlist-help": "Lohß Metmaacher Nohreeshte övver de Sigge op dänne iehr Oppaßleß krijje, wann se et en iehre Enschtellonge ußjewählt han.",
+ "config-email-auth": "Donn de Övverprööfung för Zohjangsberääschtejunge övver de <i lang=\"en\">e-mail</i> zohlohße",
+ "config-email-auth-help": "Wann dat aanjeschald es, möße Metmaacher, di iehr Adräß för de <i lang=\"en\">e-mail</i> neu aanjävve udder ändere, di Addräß övver ene Lengk beschtäätejje, dä se met de <i lang=\"en\">e-mail</i> jescheck krijje.\nBloß aan esu beschtääteschte Adräße deiht et Wiki <i lang=\"en\">e-mails</i> schecke, Di künne vun annder Metmaachere kumme, udder vum Wiki sellver, wann en Sigg en däm Metmaacher singe Oppaßleß verändert woode es.\nMer '''schlonn vör, dat aanzeschallde''' för öffentlesch Wikis, weil sönß zoh leisch Driß mem Wiki singe <i lang=\"en\">e-mail</i> jemaat wääde künnt.",
+ "config-email-sender": "De Adräß för de Antwoote op <i lang=\"en\">e-mails</i>:",
+ "config-email-sender-help": "Jiff de Adräß för de <i lang=\"en\">e-mail</i> en, woh Antwoote ob em Wiki singe <i lang=\"en\">e-mails</i> hen jonn sulle.\nDat es och de Adräß, woh de <i lang=\"en\">e-mails</i> met Fählermäldonge hen jon.\nVill ẞöövere för de <i lang=\"en\">e-mail</i> welle winnischßdens ene jöltijje Domain en dä Adräß han.",
+ "config-upload-settings": "Belder un Datteie huh laade",
+ "config-upload-enable": "Et Belder un Datteie Huhlahde zohlohße",
+ "config-upload-help": "Datteije huh ze lahde künnd e Rissiko för dem ẞööver sing Sescherheit sin.\nMih doh drövver kam_mer em [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security Kapitel övver de Sescherheit] em Handbohch lässe.\n\nÖm et Huhlahde zohzelohße donn de Rääschde för der Zohjreff op dat Ongerverzeischneß <code lang=\"en\" xml:lang=\"en\" title=\"„Bellder“\">images</code> em MehdijaWikki singem Houpverzeischneß esu enschtälle, dat et Webßööverprojramm doh Datteije un Verzeijschneße eren schrihve kann.\nDonoh donn heh di Enschtällong dann zohlohße.",
+ "config-upload-deleted": "Dat Verzeishneß för fottjeschmeße Datteije:",
+ "config-upload-deleted-help": "Söhk e Verzeijschneß uß för de fottjeschmeße Datteije vum Wiki dren afzelääje.\nEt bäß es, wam_mer vum <i lang=\"en\">world wide web</i> doh nit drahn kumme kann.",
+ "config-logo": "Dem Wiki singem Logo sing <i lang=\"en\">URL</i>:",
+ "config-logo-help": "De Schtandart_Bedeen_Bovverfläsch vum MediaWiki hät e Logo bovve en der Eck met 135x160 Pixele.\nDonn e zopaß Logo huh laade, un donn däm sing URL heh endraare.\n\nDo kanns <code lang=\"en\">$wgStylePath</code> udder <code lang=\"en\">$wgScriptPath</code> nämme, wann Ding Logo en einem vun dänne Pahde litt.\n\nWells De kei Logo han, draach heh nix en.",
+ "config-instantcommons": "Donn <i lang=\"en\">InstantCommons</i> zohlohße.",
+ "config-instantcommons-help": "<i lang=\"en\">[https://www.mediawiki.org/wiki/InstantCommons InstantCommons]</i> es en Eijeschaff, di et för Wikis müjjelesch määt, Belder, Tondatteie un ander Mehdijedatteie enzebenge, di op dä Webßait vun de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"A non-profit organization devoted to expanding the range of creative works available for others to build upon legally and to share\">[https://commons.wikimedia.org/ Wikimedia Commons]</i> ongerjebraat sin. Öm dat noze ze künne, moß dä ẞööver vum MediaWiki en Verbendung nohm Internet opnämme künne.\n\nMih Aanjahbe doh drövver un en Aanleidong, wi mer och ander Wikis ußer de <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"A non-profit organization devoted to expanding the range of creative works available for others to build upon legally and to share\">Wikimedia Commons</i> doför enreeschte kann, fengk mer em [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos Hanndbohch].",
+ "config-cc-error": "Et Ußsöhke övver de <i lang=\"en\">Creative Commons</i> iehr Projramm zum Lizänzbeshtemme hät nix jebraat.\nDonn de Lizänz sellver beshtemme.",
+ "config-cc-again": "Noch ens neu ußsöhke&nbsp;…",
+ "config-cc-not-chosen": "Söhk uß, wat för en Lizänz vun de <i lang=\"en\">Creative Commons</i> De han wells, un donn dann op „<i lang=\"en\">proceed</i>“ klecke.",
+ "config-advanced-settings": "Fottjeschredde Enshtellunge",
+ "config-cache-options": "Enshtällunge för et Faßhallde vun Objäkte em Zweschsheisher:",
+ "config-cache-help": "Objäkte em Zwescheshpeisher faßhallde, dat heiß öff jebruchte Daate en der <i lang=\"en\">cache</i> donn, bruche mer, öm MediaWiki flöcker ze maache,\nMeddlere un jruuße Wiki-ẞaits sullte dat onbedengk ußnoze, un och bei klein Wikis weed mer et jood merke.",
+ "config-cache-none": "Keine Zweschshpeijsher (Et jeid_em Wiki nix verloore, ußer velleish Schnälleshkeid wann vill loss es)",
+ "config-cache-accel": "Ene Objäk<i lang=\"en\">cache</i> vum PHP (<i lang=\"en\">APC</i>, <i lang=\"en\">XCache</i>, udder <i lang=\"en\">WinCache</i>)",
+ "config-cache-memcached": "Donn der <code lang=\"en\">memcached</code> ẞööver nämme (Määt extra Enshtellunge un Opsäze nüüdesch)",
+ "config-memcached-servers": "De <code lang=\"en\">memcached</code> ßöövere:",
+ "config-memcached-help": "Donn de Leß aanhjävve, met de <i lang=\"en\">IP</i>-Addräße för der <code lang=\"en\">memcached</code> ẞööver ze bruche.\nSe sullte ein pro Reih opjeschrevve sin, un en Pooz (<i lang=\"en\">port</i>) ier Nommer han, För e Beishpell, esu:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Do häss der <code lang=\"en\">memcached</code> als Dinge Zoot vun Zwescheshpeijscher aanjejovve, ävver nit eine ẞööver doför.",
+ "config-memcache-badip": "Do häss en onjöltijje <i lang=\"en\">IP</i>-Addräß för der <code lang=\"en\">memcached</code> ẞööver aanjejovve: $1.",
+ "config-memcache-noport": "Do has kein Pooz (<code lang=\"en\">port</code>) Nommer aanjejovve för mem <code lang=\"en\">memcached</code> ẞööver ze bruche: $1.\nWann De di Nommer nit weiß, der Shtandatt es 11211.",
+ "config-memcache-badport": "Dem <code lang=\"en\">memcached</code> ẞööver singe Pooz (<code lang=\"en\">port</code>) Nommere sullte zwesche $1 un $2 sin.",
+ "config-extensions": "Projramm-Zohsäz (<i lang=\"en\">Extensions</i>)",
+ "config-extensions-help": "Di bovve opjeleß Zohsazprojramme för et MediaWiki sin em Verzeischneß <code lang=\"en\">./extensions</code> ald ze fenge.\n\nDo kann se heh un jez aanschallde, ävver se künnte noch zohsäzlesch Enshtellunge bruche.",
+ "config-skins": "Bedeenbovverfläsche",
+ "config-skins-help": "De opjeleß Beddenbovverfläsche sin en Dingem Verzeischnesß <code>./skins</code> dre. Do moß winneschßdens eine enschallde, un eine för der Schtandatt ußsöhke.",
+ "config-skins-use-as-default": "Donn heh di Bovverfläsch als der Schtandatt nämme.",
+ "config-skins-missing": "Mer han kein bedeebovverfläsche jevonge un nämme dröm der Schtandatt, bes De wälsche enjeresch häß.",
+ "config-skins-must-enable-some": "Do moß winneschßtens ein Beddenbovverfläsch ußsöhke zum aanschallde.",
+ "config-skins-must-enable-default": "De Schtadatt-Beddenbovverfläsch moß och enjeschalldt sin.",
+ "config-install-alreadydone": "'''Opjepaß:'''\nEt sühd esu uß, wi wann De MediaWiki ald enshtalleet hätß, un wöhrs aam Versöhke, dat norr_ens ze donn.\nJang wigger op de näähßte Sigg.",
+ "config-install-begin": "Wann De op „{{int:config-continue}}“ klecks, jeiht de Enshtallazjuhn vum MediaWiki loßß.\nWann De noch Änderonge maache wells, dann kleck op „{{int:config-back}}“.",
+ "config-install-step-done": "jedonn",
+ "config-install-step-failed": "donävve jejange",
+ "config-install-extensions": "Zohsazprojramme enjeschloße",
+ "config-install-database": "Ben de Daatebangk aam ennreeschte.",
+ "config-install-schema": "Dat Schema en dä Daatebank weed aanjelaat.",
+ "config-install-pg-schema-not-exist": "Dat Scheema för <i lang=\"en\">PostgreSQL</i> es nit doh.",
+ "config-install-pg-schema-failed": "Et Tabälle-Opsäze es donävve jejange.\nDonn doför sorrje, dat dä Daatebangk-Aanwänder „$1“ en dämm Daatebangkscheema „$2“ schrieve kann.",
+ "config-install-pg-commit": "Ben de Änderonge aam ennbränge.",
+ "config-install-pg-plpgsql": "Ben noh dä Daatebangkshprooch <code lang=\"en\">PL/pgSQL</code> aam söhke.",
+ "config-pg-no-plpgsql": "Do moß de Daatebangkshprooch <code lang=\"en\">PL/pgSQL</code> en dä Daatebangk $1 enreeschte.",
+ "config-pg-no-create-privs": "Dä Daatebangk-Aanwänder för et Enreeschte hät nit jenooch Rääschde, öm ene andere Daatebangk-Aanwänder en dä Daatebangk aanzelääje.",
+ "config-pg-not-in-role": "Dä aanjejovve Zohjang för et Web jiddet ald.\nDä aanjejovve Zohjang för et Enschtalleere es keine <i lang=\"en\">superuser<i> un es nit en de Web-Jropp, dröm kam_mer domet kein Dateije aanlääje, di däm Zohjang för et Web jehüüre.\n\nFör MeedijaWiki mößße dämm ävver em Momang di Tabälle jehüüre.\nDröm donn ene andere Name för dä Zohjang zom Wäb nämme, udder donn „retuur“ klicke, un jivv ene Zohjang för et Enschtalleere aan, dä jenooch Rääschte hät.",
+ "config-install-user": "Ben unse Daatebangk-Aanwänder en de Daatebangk am aanlääje.",
+ "config-install-user-alreadyexists": "Dä Aanwender „$1“ för dä Zohjref op de Daatebangk kann nit aanjelaat wääde, et jidd_en alld.",
+ "config-install-user-create-failed": "Dä Aanwender „$1“ för dä Zohjref op de Daatebangk kunnt nit aanjelaat wääde, wäje: <code lang=\"en\">$2</code>",
+ "config-install-user-grant-failed": "Däm Daatebangk-Aanwänder sing Beräschtijunge ze säze däät nit fluppe wääje: $2",
+ "config-install-user-missing": "Dä aanjejovve Metmaacher „$1“ jidd_et nit.",
+ "config-install-user-missing-create": "{{int:Config-install-user-missing}}<!-- $1 -->\nDonn e Höhksche en et Käßje „{{int:Createaccount}}“ onge, wann De dä aanlääje wells.",
+ "config-install-tables": "Ben de Daatebangk-Tabälle aam aanlääje.",
+ "config-install-tables-exist": "'''Opjepaß''': Et schingk, dem MehdijaWikki sing Tabälle sin alt doh.\nDoh dom_mer nix aanlääje.",
+ "config-install-tables-failed": "'''Fähler''': De Tabälle kunnte nit aanjelaat wääde, wääje: $1",
+ "config-install-interwiki": "Ben de Engerwiki-Tabäll met de shtandattmääßejje Daate aam fölle.",
+ "config-install-interwiki-list": "Mer kunnte de Dattei <code lang=\"en\">interwiki.list</code> nit fenge.",
+ "config-install-interwiki-exists": "'''Opjepaß''': En der Engewiki-Tabäll schingk alt jät dren ze shtonn.\nDoh dom_mer nix dobei.",
+ "config-install-stats": "De Shtatestek-Zahle wääde op Aanfang jeshtallt.",
+ "config-install-keys": "Jeheime Schlößel wääde opjebout.",
+ "config-insecure-keys": "'''Opjepaß:''' {{PLURAL:$2|Ene jeheime Schlößel|Jeheim Schlößele|Keine jeheime Schlößel}} ($1) {{PLURAL:$2|es|sin|es}} automattesch aanjelaat woode. {{PLURAL:$2|Dä es|Di sin|Hä es}} ävver nit onbedengk janz sescher. Övverlääsch Der, {{PLURAL:$2|dä|di|en}} norr_ens vun Hand ze ändere.",
+ "config-install-updates": "Donn kein onnühdeje Änderonge maache.",
+ "config-install-updates-failed": "<strong>Dä:</strong> Schlößßelle för et Ändere en Tabälle bränge es donävve jajange. Jemäldt wood: $1",
+ "config-install-sysop": "Dä Zohjang för der Wiki-Köbes weed aanjelaat.",
+ "config-install-subscribe-fail": "Mer künne de <i lang=\"en\">e-mail</i>-Leß <code lang=\"en\">mediawiki-announce</code> nit abonneere: $1",
+ "config-install-subscribe-notpossible": "<code lang=\"en\">cURL</code> es nit enstalleed un <code lang=\"en\">allow_url_fopen</code>es nit doh.",
+ "config-install-mainpage": "Ben de Houpsigg med enem shtandatmääßeje Enhald aam aanlääje",
+ "config-install-extension-tables": "Ben Datebangk-Tabälle för de Zohsazprojramme aam ennreschte",
+ "config-install-mainpage-failed": "Kunnt de Houpsigg nit afshpeishere: $1",
+ "config-install-done": "<strong>Jlöckwonsch!</strong>\nMediaWiki es jetz enstalleet.\n\nEt Projramm zom Enreeschte hät en Dattei <code lang=\"en\">LocalSettings.php</code> aanjelaat.\nDoh sin de Enstellunge vum Wiki dren.\n\nDo weeß se eronge laade möße un dann en dem Wiki sing Aanfangsverzeishnes donn möße, et sellve Verzeisneß, woh di Dattei <code lang=\"en\">index.php</code> dren litt. Dat Erongerlaade sullt automattesch aanjefange han.\n\nWann domet jet nit jeflupp hät, udder De di Dattei norr_ens han wells, donn op dä Lengk heh dronger klecke:\n\n$3\n\n<strong>Opjepaß:</strong> Wann De dat jez nit deihß, es alles verschött, wat De bes jöz enjejovve häs, weil di Dattei fott es en däm Momang, woh heh dat Projamm aam Engk es.\n\nWann De mem Ronger- un widder Huhlaade fäädesh bes, kanns De <strong>[$2 en Ding Wiki jonn]</strong>.",
+ "config-install-done-path": "<strong>Jlöckwonsch!</strong>\nEt MehdijaWiki es jäz enschtallehrt.\n\nDat Projramm zom Enreeschte hädd en Dattei <code lang=\"en\" xml:lang=\"en\" dir=\"rtl\">LocalSettings.php</code> aanjelaat.\nDoh sin alle Enschtällonge vum Wikki dren.\n\nDo weeß se eronge lahde möße, un dann en dem Wikki sing Aanfangsverzeischneß <code>$4</code> donn möße. Dat Erongerlahde sullt automattesch aanjefange han.\n\nWann domet jät nit jeflupp hät, udder De di Dattei norr_ens han wells, donn op dä Lengk heh dronger klecke:\n\n$3\n\n<strong>Opjepaß:</strong> Wann De dat jäz nit deihß, es alles verschött, wat De bes jäz enjejovve häs, weil di Dattei fott es en däm Momang, woh heh dat Projamm aam Engk es.\n\nWann De mem Ronger- un widder Huhlaade fähdesch bes, kanns De <strong>[$2 en Ding Wikki jonn]</strong>.",
+ "config-download-localsettings": "Donn di Dattei <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">LocalSettings.php</code> eronger lahde",
+ "config-help": "Hölp",
+ "config-help-tooltip": "Donn Hölp heh aan däm Plaaz enblände.",
+ "config-nofile": "De Dattei „$1“ ham_mer nit jefonge. Es di fottjeschmeße?",
+ "config-extension-link": "Häs De jewoß, dat et Wiki [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions Zohsazprojramme] hann kann?\n\nDo kanns [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category Zohsazprojramme noh Saachjroppe] söhke udder en de [https://www.mediawiki.org/wiki/Extension_Matrix Tabäll met de Zohsazprojramme] kike, öm de kumplätte Leß met de Zohsazprojramme ze krijje.",
+ "mainpagetext": "<strong>MehdijaWikki es jäz enschtalleht.</strong>",
+ "mainpagedocfooter": "Luuer en et (änglesche) [https://meta.wikimedia.org/wiki/Help:Contents Handbohch] wann De weße wells, wi de Wikki-ẞoffwähr jebruch un bedehnt wähde moß.\n\n== För der Aanfang ==\nDat es och all op Änglesch:\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings En Leß met müjjelesche Enschtällonge för et MehdijaWikki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Öff jefrooch övver et Mehdijawikki&nbsp;&hellip;]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce De Meilengleß met Annköndejonge övver neuje Ußjahbe vum MehdijaWikki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Donn Mehdijawikki op Ding Schprohch aanpaße]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Loor, wi der der <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"„för jewöhnlesch angmaß övverdrahre Nohreeschte udder Meddeijlonge, di wä se kritt jaa nit han well,“\">SPAM</i> em Wikki klein hälls]\n\n=== Jrammatek ===\nJeh nohdämm, ovv_et „di {{SITENAME}}“, „dä {{SITENAME}}“ udder „dat {{SITENAME}}“ heiß, moß mer velleijsch en Datteij änndere. Wann „{{SITENAME}}“ med „wikki“ ov „wiki“ ophürt, moß mer nix donn. Bei „dä {{SITENAME}}“ och nit. Söns kütt en di Datteij <code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">languages/classes/LanguageKsh.php</code> vör udder henger dä Reihj met „<code lang=\"en\" xml:lang=\"en\" dir=\"ltr\">No need add neuter wikis having names ending in -wiki.</code>“ en neuje Reihj eren:\n* för „di {{SITENAME}}“ heijß di:\n*: <code>'{{SITENAME}}' => 'f',</code>\n* för „dat {{SITENAME}}“ heijs et:\n*: <code>'{{SITENAME}}' => 'n',</code>\n\n== Un dann ==\nDonn heh di Sigg ömbenänne un/udder jähje en ääschte Aanfangssigg för heh dat Wikki ußtuusche!\n\nAlles Johde!"
+}
diff --git a/www/wiki/includes/installer/i18n/ku-latn.json b/www/wiki/includes/installer/i18n/ku-latn.json
new file mode 100644
index 00000000..b68b9d8f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ku-latn.json
@@ -0,0 +1,69 @@
+{
+ "@metadata": {
+ "authors": [
+ "George Animal",
+ "Ghybu",
+ "Bikarhêner"
+ ]
+ },
+ "config-desc": "Barkera MediaWikiyê",
+ "config-title": "Barkirina MediaWiki $1",
+ "config-information": "Agahî",
+ "config-your-language": "Zimanê te:",
+ "config-wiki-language": "Zimanê wîkiyê:",
+ "config-back": "← Paş",
+ "config-continue": "Bidomîne →",
+ "config-page-language": "Ziman",
+ "config-page-welcome": "Bi xêr hatî MediaWikiyê!",
+ "config-page-dbsettings": "Eyarên danegehê",
+ "config-page-name": "Nav",
+ "config-page-options": "Vebijêrk",
+ "config-page-install": "Ava bike",
+ "config-page-complete": "Qedîya!",
+ "config-page-restart": "Barkirinê jinûve dest pê bide kirin",
+ "config-page-readme": "Min bixwîne",
+ "config-page-copying": "Kopîkirin",
+ "config-page-upgradedoc": "Bilindkirin",
+ "config-page-existingwiki": "Wîkiya heye",
+ "config-restart": "Erê, jinûve bide destpêkirin",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] hate avakirin",
+ "config-apc": "[http://www.php.net/apc APC] hate avakirin",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] hate avakirin",
+ "config-diff3-bad": "GNU diff3 nehate dîtin.",
+ "config-db-type": "Cureya danegehê:",
+ "config-db-wiki-settings": "Vî wîkîyê bide danasîn",
+ "config-db-name": "Navê danagehê:",
+ "config-db-install-account": "Hesabê bikarhêner bo avakirinê",
+ "config-db-username": "Navê bikarhêner bo danagehê:",
+ "config-db-password": "Şîfreya danegehê:",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-invalid-db-type": "Cureya danegehê ya nederbasdar",
+ "config-sqlite-readonly": "Dosyeya <code>$1</code> ne nivîsbar e.",
+ "config-db-web-account": "Hesabê danegehê bô têgihiştina tora înternetê",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Navê wîkiyê:",
+ "config-site-name-blank": "Navê malperek têkeve.",
+ "config-ns-generic": "Proje",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "Hesabê rêveberiyê",
+ "config-admin-name": "Navê bikarhêner:",
+ "config-admin-password": "Şîfre:",
+ "config-admin-password-confirm": "Şîfreyê dîsa binivîse:",
+ "config-admin-email": "Navnîşana e-nameyê:",
+ "config-optional-continue": "Bêhtir pirsan ji min bike.",
+ "config-profile": "Profîla mafên bikarhêner:",
+ "config-profile-wiki": "Wîkiya vekirî",
+ "config-email-settings": "Eyarên e-nameyê",
+ "config-email-usertalk": "Agahdariyên rûpela gotûbêjê ya bikarhêner gengaz bike",
+ "config-email-sender": "Vegere navnîşana e-nameyêː",
+ "config-upload-settings": "Barkirina wêne û dosyeyan",
+ "config-upload-enable": "Barkirina dosyeyan gengaz bike",
+ "config-logo": "URL'ya logoyêː",
+ "config-cc-again": "Dîsa hilbijêre...",
+ "config-install-step-done": "çêbû",
+ "config-help": "alîkarî",
+ "mainpagetext": "'''MediaWiki serketî hate çêkirin.'''",
+ "mainpagedocfooter": "Alîkarî ji bo bikaranîn û guherandin yê datayê Wîkî tu di bin [https://meta.wikimedia.org/wiki/Help:Contents pirtûka alîkarîyê ji bikarhêneran] da dikarê bibînê.\n\n== Alîkarî ji bo destpêkê ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lîsteya varîyablên konfîgûrasîyonê]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lîsteya e-nameyên versyonên nuh yê MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/lad.json b/www/wiki/includes/installer/i18n/lad.json
new file mode 100644
index 00000000..c22e0dfc
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lad.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Universal Life",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''MedyaViki ya se kureó con reuxitá.'''",
+ "mainpagedocfooter": "Konsulta la [https://meta.wikimedia.org/wiki/Help:Contents/es Guía de usador] para tomar enformasyones encima de como usar el lojikal viki.\n\n== En Empeçando ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings La lista de los arreglamientos de la konfiggurasyón]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/lad DDS de MedyaViki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce La lista de las letrales (e-mail) de MedyaViki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/lb.json b/www/wiki/includes/installer/i18n/lb.json
new file mode 100644
index 00000000..e7952114
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lb.json
@@ -0,0 +1,212 @@
+{
+ "@metadata": {
+ "authors": [
+ "Robby",
+ "Soued031",
+ "아라",
+ "Seb35"
+ ]
+ },
+ "config-desc": "Den Installatiounsprogramm vu MediaWiki",
+ "config-title": "MediaWiki $1 Installatioun",
+ "config-information": "Informatioun",
+ "config-localsettings-upgrade": "'''Opgepasst''': E Fichier <code>LocalSettings.php</code> gouf fonnt.\nÄr Software kann aktualiséiert ginn, setzt w.e.g. de Wäert vum <code>$wgUpgradeKey</code> an d'Këscht.\nDir fannt en am <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "E Fichier <code>LocalSettings.php</code> gouf fonnt.\nFir dës Installatioun z'aktuaéliséieren start w.e.g. <code>update.php</code>",
+ "config-localsettings-key": "Aktualisatiounsschlëssel:",
+ "config-localsettings-badkey": "Den Aktualisatiouns-Schlëssel deen Dir aginn hutt ass net korrekt",
+ "config-localsettings-incomplete": "De Fichier <code>LocalSettings.php</code> schéngt net komplett ze sinn.\nD'Variabel $1 ass net definéiert.\nÄnnert w.e.g. de Fichier <code>LocalSettings.php</code> sou datt déi Variabel definéiert ass a klickt op \"{{int:Config-continue}}\".",
+ "config-session-error": "Feeler beim Starte vun der Sessioun: $1",
+ "config-no-session": "D'Donnéeë vun ärer Sessioun si verluergaangen!\nKuckt Är php.ini no a vergewëssert Iech datt <code>session.save_path</code> op adequate REpertoire agestallt ass.",
+ "config-your-language": "Är Sprooch",
+ "config-your-language-help": "Sicht déi Sprooch eraus déi Dir während der Installatioun benotze wëllt",
+ "config-wiki-language": "Sprooch vun der Wiki:",
+ "config-wiki-language-help": "Sicht d'Sprooch eraus an där d'Wiki haaptsächlech geschriwwe gëtt.",
+ "config-back": "← Zréck",
+ "config-continue": "Weider →",
+ "config-page-language": "Sprooch",
+ "config-page-welcome": "Wëllkomm bei MediaWiki!",
+ "config-page-dbconnect": "Mat der Datebank verbannen",
+ "config-page-upgrade": "Eng Installatioun déi besteet aktualiséieren",
+ "config-page-dbsettings": "Astellunge vun der Datebank",
+ "config-page-name": "Numm",
+ "config-page-options": "Optiounen",
+ "config-page-install": "Installéieren",
+ "config-page-complete": "Fäerdeg!",
+ "config-page-restart": "Installatioun neistarten",
+ "config-page-readme": "Liest dëst",
+ "config-page-releasenotes": "Informatiounen zur Versioun",
+ "config-page-copying": "Kopéieren",
+ "config-page-upgradedoc": "Aktualiséieren",
+ "config-page-existingwiki": "Wiki déi et gëtt",
+ "config-help-restart": "Wëllt Dir all gespäichert Donnéeë läschen déi Dir bis elo aginn hutt an den Installatiounsprozess nei starten?",
+ "config-restart": "Jo, neistarten",
+ "config-welcome": "=== Iwwerpréifung vum Installatiounsenvironnement ===\nEt gi grondsätzlech Iwwerpréifunge gemaach fir ze kucken ob den Environnment gëeegent ass fir MediaWiki z'installéieren.\nDir sollt d'Resultater vun dëser Iwwerpréifung ugi wann Dir während der Installatioun Hëllef frot wéi Dir D'Installatioun ofschléisse kënnt.",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki Haaptsäit]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Benotzerguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guide fir Administrateuren]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Liest dëst</doclink>\n* <doclink href=ReleaseNotes>Informatioune vun der aktueller Versioun</doclink>\n* <doclink href=Copying>Lizenzbedingungen</doclink>\n* <doclink href=UpgradeDoc>Aktualisatioun</doclink>",
+ "config-env-good": "Den Environement gouf nogekuckt.\nDir kënnt MediaWiki installéieren.",
+ "config-env-bad": "Den Environnement gouf iwwerpréift.\nDir kënnt MediWiki net installéieren.",
+ "config-env-php": "PHP $1 ass installéiert.",
+ "config-env-hhvm": "HHVM $1 ass installéiert.",
+ "config-no-db": "Et konnt kee passenden Datebank-Driver fonnt ginn! Dir musst een Datebank-Driver fir PHP installéieren.\n{{PLURAL:$2|Dësn Datebank-Typ gëtt|Dës Datebank-Type ginn}} ënnerstëtzt: $1.\n\nWann Dir PHP selwer compiléiert hutt, da rekonfiguréiert en mat dem ageschalten Datebank-Client, zum Beispill an deem Dir <code>./configure --with-mysqli</code> benotzt.\nWann Dir PHP vun engem Debian oder Ubuntu Package aus installéiert hutt, da musst Dir och den php5-mysql Modul installéieren.",
+ "config-outdated-sqlite": "'''Warnung:''' SQLite $1 ass installéiert. Allerdengs brauch MediaWiki SQLite $2 oder méi nei. SQLite ass dofir net disponibel.",
+ "config-memory-bad": "'''Opgepasst:''' De Parameter <code>memory_limit</code> vu PHP ass $1.\nDat ass wahrscheinlech ze niddreg.\nD'Installatioun kéint net funktionéieren.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] ass installéiert",
+ "config-apc": "[http://www.php.net/apc APC] ass installéiert",
+ "config-apcu": "[http://www.php.net/apcu APCu] ass installéiert.",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] ass installéiert",
+ "config-diff3-bad": "GNU diff3 gouf net fonnt.",
+ "config-git": "D'Software Git fir d'Kontroll vu Versioune gouf fonnt: <code>$1</code>.",
+ "config-git-bad": "D'Software fir d'Kontroll vun de Versiounen 'Git' gouf net fonnt.",
+ "config-no-uri": "'''Feeler:''' Déi aktuell URI konnt net festgestallt ginn.\nInstallatioun ofgebrach.",
+ "config-using-server": "De Servernumm \"<nowiki>$1</nowiki>\" gëtt benotzt.",
+ "config-using-uri": "D'Server URL \"<nowiki>$1$2</nowiki>\" gëtt benotzt.",
+ "config-db-type": "Datebanktyp:",
+ "config-db-host": "Host vun der Datebank:",
+ "config-db-host-oracle": "Datebank-TNS:",
+ "config-db-wiki-settings": "Dës Wiki identifizéieren",
+ "config-db-name": "Numm vun der Datebank:",
+ "config-db-name-oracle": "Datebankschema:",
+ "config-db-install-account": "Benotzerkont fir d'Installatioun",
+ "config-db-username": "Datebank-Benotzernumm:",
+ "config-db-password": "Passwuert vun der Datebank:",
+ "config-db-install-help": "Gitt de Benotzernumm an Passwuert an dat wàhrend der Installatioun benotzt gëtt fir sech mat der Datebank ze verbannen.",
+ "config-db-account-lock": "De selwechte Benotzernumm a Passwuert fir déi normal Operatioune benotzen",
+ "config-db-wiki-account": "Benotzerkont fir normal Operatiounen",
+ "config-db-wiki-help": "Gitt de Benotzernumm an d'Passwuert an dat benotzt wäert gi fir sech bei den normale Wiki-Operatiounen mat der Datebank ze connectéieren.\nWann et de Kont net gëtt, a wann den Installatiouns-Kont genuch Rechter huet, gëtt dëse Benotzerkont opgemaach mat dem Minimum vu Rechter déi gebraucht gi fir dës Wiki bedreiwen ze kënnen.",
+ "config-mysql-old": "MySQL $1 oder eng méi nei Versioun gëtt gebraucht, Dir hutt $2.",
+ "config-db-port": "Port vun der Datebank:",
+ "config-db-schema": "Schema fir MediaWiki",
+ "config-db-schema-help": "D'Schemaen hei driwwer si gewéinlech korrekt.\nÄnnert se nëmme wann Dir wësst datt et néideg ass.",
+ "config-pg-test-error": "Et ass net méiglech d'Datebank '''$1''' ze kontaktéieren: $2",
+ "config-sqlite-dir": "Repertoire vun den SQLite-Donnéeën",
+ "config-oracle-def-ts": "Standard 'tablespace':",
+ "config-oracle-temp-ts": "Temporären 'tablespace':",
+ "config-type-mysql": "MySQL (oder kompatibel)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] ass e beléiften Open-Source-Datebanksystem an eng Alternativ zu MySQL. ([http://www.php.net/manual/de/pgsql.installation.php Uleedung fir d'Kompilatoun vu PHP mat PostgreSQL-Ënnerstëtzung])",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] ass eng kommerziell Datebank-Software. ([http://www.php.net/manual/en/oci8.installation.php How to compile PHP mat OCI8 Ënnerstëtzung])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] ass eng kommerziell Datebank-Software fir Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Wéi PHP mat SQLSRV Ënnerstëtzung kompiléieren])",
+ "config-header-mysql": "MySQL-Astellungen",
+ "config-header-postgres": "PostgreSQL-Astellungen",
+ "config-header-sqlite": "SQLite-Astellungen",
+ "config-header-oracle": "Oracle-Astellungen",
+ "config-header-mssql": "Microsoft SQL Server Astellungen",
+ "config-invalid-db-type": "Net valabelen Datebank-Typ",
+ "config-missing-db-name": "Dir musst e Wäert fir \"{{int:config-db-name}}\" aginn",
+ "config-missing-db-host": "Dir musst e Wäert fir \"{{int:config-db-host}}\" aginn.",
+ "config-missing-db-server-oracle": "Dir musst e Wäert fir \"{{int:config-db-host-oracle}}\" aginn",
+ "config-connection-error": "$1.\n\nKuckt den Numm vum Server, de Benotzernumm an d'Passwuert no a probéiert et nach eng Kéier.",
+ "config-db-sys-user-exists-oracle": "De Benotzerkont \"$1\" gëtt et schonn. SYSDBA kann nëmme benotzt gi fir en neie Benotzerkont opzemaachen.",
+ "config-postgres-old": "PostgreSQL $1 oder eng méi nei Versioun gëtt gebraucht, Dir hutt $2.",
+ "config-mssql-old": "Microsoft SQL Server $1 oder eng méi rezent Versioun gëtt gebraucht. Dir hutt d'Versioun $2.",
+ "config-sqlite-name-help": "Sicht en Numm deen Är wiki identifizéiert.\nBenotzt keng Espacen a Bindestrécher.\nE gëtt fir den Numm vum SQLite Date-Fichier benotzt.",
+ "config-sqlite-readonly": "An de Fichier <code>$1</code> Kann net geschriwwe ginn.",
+ "config-sqlite-cant-create-db": "Den Datebank-Fichier <code>$1</code> konnt net ugeluecht ginn.",
+ "config-can-upgrade": "Et si MediaWiki Tabellen an dëser Datebank.\nFir se op MediaWiki $1 z'aktualiséiere klickt op <strong>Virufueren</strong>.",
+ "config-upgrade-done-no-regenerate": "D'Aktualisatioun ass ofgeschloss.\n\nDir kënnt elo [$1 ufänken Är Wiki ze benotzen]",
+ "config-regenerate": "LocalSettings.php regeneréieren →",
+ "config-db-web-account": "Datebankkont fir den Accès iwwer de Web",
+ "config-db-web-account-same": "Dee selwechte Kont wéi bei der Installatioun benotzen",
+ "config-db-web-create": "De Kont uleeë wann et e net scho gëtt",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "binär",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Typ vun der Authentifikatioun:",
+ "config-mssql-sqlauth": "SOL-Server-Authentifikatioun",
+ "config-mssql-windowsauth": "Windows-Authentifikatioun",
+ "config-site-name": "Numm vun der Wiki:",
+ "config-site-name-help": "Dësen daucht an der Titelleescht vum Browser an op verschiddenen anere Plazen op.",
+ "config-site-name-blank": "Gitt den Numm vum Site un.",
+ "config-project-namespace": "Projet Nummraum:",
+ "config-ns-generic": "Projet",
+ "config-ns-site-name": "Deeselwechte wéi den Numm vun der Wiki: $1",
+ "config-ns-other": "Anerer (spezifizéieren)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "Administrateurs-Kont",
+ "config-admin-name": "Äre Benotzernumm:",
+ "config-admin-password": "Passwuert:",
+ "config-admin-password-confirm": "Passwuert confirméieren:",
+ "config-admin-help": "Gitt w.e.g. Äre gewënschte Benotzernumm hei an, zum Beispill \"Jang Muller\".\nDësen Numm gëtt da gebraucht fir sech an d'Wiki anzeloggen.",
+ "config-admin-name-blank": "Gitt e Benotzernumm fir den Administrateur an.",
+ "config-admin-name-invalid": "De spezifizéierte Benotzernumm \"<nowiki>$1</nowiki>\" ass net valabel.\nSpezifizéiert en anere Benotzernumm.",
+ "config-admin-password-blank": "Gitt e Passwuert fir den Adminstateur-Kont an.",
+ "config-admin-password-mismatch": "Déi zwee Passwierder Déi Dir aginn hutt stëmmen net iwwereneen.",
+ "config-admin-email": "E-Mail-Adress:",
+ "config-admin-error-user": "Interne Feeler beim uleeë vun engem Administrateur mam Numm \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Interne Feeler beim Setze vum Passwuert fir den Admin \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Dir hutt eng E-Mail-Adress aginn déi net valabel ass",
+ "config-subscribe": "Sech op d'[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Ukënnegunge vun neie Versiounen] abonnéieren.",
+ "config-pingback-help": "Wann Dir dës Optioun auswielt schéckt MediaWiki regelméisseg https://www.mediawiki.org Basisdaten iwwer dës MediaWiki-Instanz. An dësen Daten sinn zum Beispill de Systemtyp, d'PHP-Versioun an déi erausgesicht Datebank-Backend. D'Wikimedia Foundation gëtt dës Daten un d'MediaWiki-Entwéckler, fir ze hëllefen d'Entwécklung an der Zukunft efficace z'organiséieren. Dës Date gi fir Äre System geschéckt:\n<pre>$1</pre>",
+ "config-almost-done": "Dir sidd bal fäerdeg!\nDir kënnt elo déi Astellungen déi nach iwwreg sinn iwwersprangen an d'Wiki elo direkt installéieren.",
+ "config-optional-continue": "Stellt mir méi Froen.",
+ "config-optional-skip": "Ech hunn es genuch, installéier just d'Wiki.",
+ "config-profile": "Profil vun de Benotzerrechter:",
+ "config-profile-wiki": "Oppe Wiki",
+ "config-profile-no-anon": "Uleeë vun engem Benotzerkont verlaangt",
+ "config-profile-fishbowl": "Nëmmen autoriséiert Editeuren",
+ "config-profile-private": "Privat Wiki",
+ "config-license": "Copyright a Lizenz:",
+ "config-license-none": "Keng Lizenz ënnen op der Säit",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-gfdl": "GNU-Lizenz fir Fräi Dokumentatioun 1.3 oder méi rezent",
+ "config-license-pd": "Ëffentlechen Domaine",
+ "config-license-cc-choose": "Eng personaliséiert Creative Common Lizenz eraussichen",
+ "config-email-settings": "E-Mail-Astellungen",
+ "config-enable-email": "E-Mailen déi no bausse ginn aschalten",
+ "config-email-user": "Benotzer-op-Benotzer E-Mail aschalten",
+ "config-email-user-help": "All Benotzer erlaben sech géigesäiteg E-Mailen ze schécken, wa si dat an hiren Astellungen aktivéiert hunn.",
+ "config-email-usertalk": "Benoriichtege bei Ännerung vun der Benotzerdiskussiounssäit aschalten",
+ "config-email-watchlist": "Benoriichtigung vun der Iwwerwaachungslëscht aschalten",
+ "config-email-watchlist-help": "Erlaabt et de Benotzer fir Notifikatioune vun hiren iwwerwaachte Säiten ze kréie wa si dat an hiren Astellungen aktivéiert hunn.",
+ "config-email-auth": "E-Mail-Authentifizéierung aschalten",
+ "config-email-sender": "E-Mailadress fir Äntwerten:",
+ "config-upload-settings": "Eropgeluede Biller a Fichieren",
+ "config-upload-enable": "Eropluede vu Fichieren aschalten",
+ "config-upload-deleted": "Repertoire fir geläscht Fichieren:",
+ "config-logo": "URL vum Logo:",
+ "config-instantcommons": "\"Instant Commons\" aktivéieren",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] ass eng Funktioun déi et Wikien erlaabt fir Biller, Téin an aner Medien vu [https://commons.wikimedia.org/ Wikimedia Commons] ze benotzen.\nFir datt dat funktionéiert brauch MediaWiki Zougang zum Internet.\n\nFir méi Informatiounen iwwer dës Funktioun, inklusiv Instruktioune wéi Dir se fir aner Wikie wéi Wikimedia Commons astelle musst, kuckt [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos d'Handbuch].",
+ "config-cc-again": "Nach eng kéier eraussichen...",
+ "config-advanced-settings": "Erweidert Astellungen",
+ "config-extensions": "Erweiderungen",
+ "config-skins": "Ausgesinn",
+ "config-skins-help": "D'Ausgesinn déi hei driwwer stinn goufen am Repertoire <code>./skins</code> fonnt. Dir musst mindestens eent aktivéieren an de Standard eraussichen.",
+ "config-skins-use-as-default": "Dëst Ausgesinn als Standard benotzen",
+ "config-skins-missing": "Et goufe keen Ausgesinn (Skin) fonnt; MediaWiki benotzt e Fallback-Ausgesinnbis Dir anerer installéiert.",
+ "config-skins-must-enable-some": "Dir musst mindestens een Ausgesinn fir z'aktivéieren eraussichen.",
+ "config-skins-must-enable-default": "Dat als Standard erausgesichten Ausgesinn muss aktivéiert sinn.",
+ "config-install-step-done": "fäerdeg",
+ "config-install-step-failed": "huet net funktionéiert",
+ "config-install-extensions": "Mat den Ereiderungen",
+ "config-install-database": "Datebank gëtt installéiert",
+ "config-install-pg-commit": "Ännerungen applizéieren",
+ "config-install-pg-plpgsql": "No der Sprooch PL/pgSQL sichen",
+ "config-pg-no-plpgsql": "Fir d'Datebank $1 muss d'Datebanksprooch PL/pgSQL installéiert ginn",
+ "config-install-user": "Datebank Benotzer uleeën",
+ "config-install-user-alreadyexists": "De Benotzer \"$1\" gëtt et schonn!",
+ "config-install-user-create-failed": "D'Opmaache vum Benotzer \"$1\" huet net funktionéiert: $2",
+ "config-install-user-grant-failed": "D'Bäisetze vu Rechter fir de Benotzer \"$1\" huet net funktionéiert: $2",
+ "config-install-user-missing": "De Benotzer \"$1\" deen ugi gouf gëtt et net.",
+ "config-install-user-missing-create": "De spezifizéierte Benotzer \"$1\" gëtt et net.\nKlickt d'Checkbox \"Benotzerkont uleeën\" wann Dir dee Benotzer uleeë wëllt.",
+ "config-install-tables": "Tabelle ginn ugeluecht",
+ "config-install-interwiki": "Standard Interwiki-Tabell gëtt ausgefëllt",
+ "config-install-interwiki-list": "De Fichier <code>interwiki.list</code> gouf net fonnt.",
+ "config-install-stats": "Initialisatioun vun de Statistiken",
+ "config-install-keys": "Generéiere vum Geheimschlëssel",
+ "config-install-updates": "Net néideg Aktualiséierungen net maachen",
+ "config-install-sysop": "Administrateur Benotzerkont gëtt ugeluecht",
+ "config-install-mainpage": "Haaptsäit mat Standard-Inhalt gëtt ugeluecht",
+ "config-install-mainpage-exists": "Haaptsäit gëtt et schonn, iwwersprangen",
+ "config-install-extension-tables": "D'Tabelle fir déi aktivéiert Erweiderunge ginn ugeluecht",
+ "config-install-mainpage-failed": "D'Haaptsäit konnt net dragesat ginn: $1",
+ "config-download-localsettings": "<code>LocalSettings.php</code> eroflueden",
+ "config-help": "Hëllef",
+ "config-help-tooltip": "klickt fir opzeklappen",
+ "config-nofile": "De Fichier \"$1\" gouf net fonnt. Gouf e geläscht?",
+ "config-screenshot": "Screenshot",
+ "mainpagetext": "<strong>MediaWiki gouf installéiert.</strong>",
+ "mainpagedocfooter": "Kuckt w.e.g. [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents d'Benotzerhandbuch] fir Informatiounen iwwer de Gebruach vun der Wiki Software.\n\n== Fir unzefänken ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Hëllef bei der Konfiguratioun]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailinglëscht vun neie MediaWiki-Versiounen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokaliséiert MediaWiki fir Är Sprooch]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Léiert wéi Spam op Ärer Wiki reduzéiert gi kann]"
+}
diff --git a/www/wiki/includes/installer/i18n/lez.json b/www/wiki/includes/installer/i18n/lez.json
new file mode 100644
index 00000000..953a7df0
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lez.json
@@ -0,0 +1,27 @@
+{
+ "@metadata": {
+ "authors": [
+ "Lezgia"
+ ]
+ },
+ "config-desc": "Инстайлер MediaWiki-диз",
+ "config-title": "Эцигун $1 MediaWiki",
+ "config-information": "Хабар",
+ "config-localsettings-key": "Цийиладин кюлег",
+ "config-localsettings-badkey": "Кюне тъунвай кюлег чlурудия",
+ "config-session-error": "Гъалатl ахъагъдала сессиа",
+ "config-your-language": "Кю чlал",
+ "config-your-language-help": "Хкягъа чlaл, эцигунин юзун тхудай",
+ "config-wiki-language": "Wiki-дин чlaл",
+ "config-wiki-language-help": "Хкягъа чlал викидин акунар къалурдай",
+ "config-back": "Кьулухъ",
+ "config-continue": "Яргъахъ",
+ "config-page-language": "Чlал",
+ "config-page-welcome": "Хийирдиз ша MediaWiki-диз!",
+ "config-page-name": "Тlор",
+ "config-page-options": "Тькlюрнар",
+ "config-page-install": "Эцигун",
+ "config-page-complete": "Гьазур я",
+ "config-page-restart": "Гатlунин эцигун сифтедла",
+ "config-page-readme": "Кlела зу"
+}
diff --git a/www/wiki/includes/installer/i18n/lfn.json b/www/wiki/includes/installer/i18n/lfn.json
new file mode 100644
index 00000000..e0d3d9af
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lfn.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki es aora instalada.'''",
+ "mainpagedocfooter": "Atenda la [https://meta.wikimedia.org/wiki/Help:Contents Gida per Usores] per informa supra la usa de la programa de vici.\n\n== Comensa ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de ajustas de la desinia]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Demandas comun de MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista per receta anunsias de novas supra MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/lg.json b/www/wiki/includes/installer/i18n/lg.json
new file mode 100644
index 00000000..8b6f052a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lg.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kizito"
+ ]
+ },
+ "mainpagetext": "MediaWiki kati ewangidwa ku sisitemu yo",
+ "mainpagedocfooter": "Okuyiga ku nkozesa ya sofutiweya owa wiki, kebera [https://meta.wikimedia.org/wiki/Help:Contents Okulagirira Abakozesa].\n\n== Amagezi agakuyamba okutandika ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lukalala lw'eby'enteekateeka yo]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Ebiter'okubuuzibwa ku MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Wewandise ofunenga amawulire aga email ag'ebifa ku MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/li.json b/www/wiki/includes/installer/i18n/li.json
new file mode 100644
index 00000000..afd7569b
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/li.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki software succesvol geïnsjtalleerd.'''",
+ "mainpagedocfooter": "Raodpleeg de [https://meta.wikimedia.org/wiki/Help:Contents Inhoudsopgave handjleiding] veur informatie euver 't gebroek van de wikisoftware.\n\n== Mieë hölp ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lies mit instellinge]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki VGV (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki mailinglies veur nuuj versies]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/lij.json b/www/wiki/includes/installer/i18n/lij.json
new file mode 100644
index 00000000..0ecc0978
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lij.json
@@ -0,0 +1,312 @@
+{
+ "@metadata": {
+ "authors": [
+ "Giromin Cangiaxo"
+ ]
+ },
+ "config-desc": "Programma de installaçion pe MediaWiki",
+ "config-title": "Installaçion de MediaWiki $1",
+ "config-information": "Informaçioin",
+ "config-localsettings-upgrade": "L'è stæto rilevou un file <code>LocalSettings.php</code>.\nPe aggiornâ questa installaçion, se prega de insei o valô de <code>$wgUpgradeKey</code> inta casella chì de sotta.\nO poei atrovâ in <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "L 'è stæto rilevou un file <code>LocalSettings.php</code>.\nPe aggiornâ questa installaçion, eseguî <code>update.php</code>",
+ "config-localsettings-key": "Ciave de aggiornamento:",
+ "config-localsettings-badkey": "A ciave d'agiornamento che t'hæ fornio a no l'è corretta.",
+ "config-upgrade-key-missing": "L'è stæto rilevou un'installaçion existente de MediaWiki.\nPe aggiornâ quest'installaçion, se prega de insei a seguente riga inta parte infeiô do to <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "O file <code>LocalSettings.php</code> existente pâ ese incompleto.\nA variabile $1 a no l'è impostâ.\nCangia <code>LocalSettings.php</code> de moddo che questa variabile a segge impostâ e fanni clic insce \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "S'è veificou un errô durante a connescion a-o database doeuviando e impostaçioin specificæ in <code>LocalSettings.php</code>. Se prega de coreze queste impostaçioin e riprovâ.\n\n$1",
+ "config-session-error": "Errô inte l'avvio da sescion: $1",
+ "config-session-expired": "I dæti da sescion pan ese descheiti.\nE sescioin son configuæ pe 'na duata de $1.\nTi poeu aomentâla impostando <code>session.gc_maxlifetime</code> into file php.ini.\nRiavvia o processo d'installaçion.",
+ "config-no-session": "I dæti da sescion son andæti persci!\nControlla o to file php.ini e aseguite che <code>session.save_path</code> o l'è impostou insce 'na directory apropiâ.",
+ "config-your-language": "A to lengua:",
+ "config-your-language-help": "Seleçion-a una lengua da doeuviâ durante o processo d'installaçion.",
+ "config-wiki-language": "A lengua do wiki:",
+ "config-wiki-language-help": "Seleçion-a a lengua ch'a saiâ prevalentemente doeuviâ into wiki.",
+ "config-back": "inderê",
+ "config-continue": "Continnoa →",
+ "config-page-language": "Lengua",
+ "config-page-welcome": "Benvegnui a MediaWiki!",
+ "config-page-dbconnect": "Connescion a-o database",
+ "config-page-upgrade": "Agiornamento de l'installaçion existente",
+ "config-page-dbsettings": "Impostaçioin do database",
+ "config-page-name": "Nomme",
+ "config-page-options": "Opçioin",
+ "config-page-install": "Installa",
+ "config-page-complete": "Completa!",
+ "config-page-restart": "Riavvio installaçion",
+ "config-page-readme": "Lezime",
+ "config-page-releasenotes": "Notte de verscion",
+ "config-page-copying": "Copia",
+ "config-page-upgradedoc": "Aggiornamento",
+ "config-page-existingwiki": "Wiki existenti",
+ "config-help-restart": "Ti voeu scassâ tutti i dæti sarvæ che ti t'hæ inseio e riavviâ o processo de installaçion?",
+ "config-restart": "Scì, riavvia",
+ "config-welcome": "=== Controllo de l'ambiente ===\nSaiâ eseguio di controlli de base pe vedde se questo ambiente o l'è adatto pe l'installaçion de MediaWiki.\nRegordite de includde queste informaçioin se ti domandi ascistença insce comme completâ l'installaçion.",
+ "config-copyright": "=== Copyright e termini ===\n\n$1\n\nQuesto programma o l'è un software libero; ti poeu redistriboîlo e/ò modificâlo segondo i termi da GNU General Public License, comme pubbricâ da-a Free Software Foundation; ò a verscion 2 da Liçença ò (a proppia scelta) qualunque verscion succesciva.\n\nQuesto programma o l'è distribuio inta sperança ch'o segge utile, ma SENSA ARCUNA GARANTIA; sença manco a garantia impliçita de NEGOSSIABILITÆ o de APPRICABILITÆ PE UN PARTICOLÂ SCOPO.\nS'amie a GNU General Public License pe maggioî dettaggi.\n\nQuesto programma o dev'ese distribuio insemme a <doclink href=Copying>una copia da GNU General Public License</doclink>; in caxo contraio, se ne poeu otegnî un-a scrivendo a-a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA oppù [http://www.gnu.org/copyleft/gpl.html lezila inta ræ'].",
+ "config-sidebar": "* [https://www.mediawiki.org Paggina prinçipâ MediaWiki]\n* [https://www.mediawiki.org/wiki/Agiutto:Guidda a-i contegnui pe utenti]\n* [https://www.mediawiki.org/wiki/Manoâ:Guidda ai contegnui per admin]\n* [https://www.mediawiki.org/wiki/Manoâ:FAQ FAQ]\n----\n* <doclink href=Readme>Lezime</doclink>\n* <doclink href=ReleaseNotes>Notte de verscion</doclink>\n* <doclink href=Copying>Copie</doclink>\n* <doclink href=UpgradeDoc>Aggiornamenti</doclink>",
+ "config-env-good": "L'ambiente o l'è stæto controllou.\nL'è poscibile installâ MediaWiki.",
+ "config-env-bad": "L'ambiente o l'è stæto controllou.\nNon l'è poscibbile installâ MediaWiki.",
+ "config-env-php": "PHP $1 o l'è installou.",
+ "config-env-hhvm": "HHVM $1 o l'è installou.",
+ "config-unicode-using-intl": "Adoeuvia [http://pecl.php.net/intl l'estenscion PECL intl] pe-a normalizzaçion Unicode.",
+ "config-unicode-pure-php-warning": "'''Attençion:''' [http://pecl.php.net/intl l'estenscion PECL intl] a no l'è disponibile pe gestî a normalizzaçion Unicode, quindi se torna a-a lenta implementaçion in PHP puo.\nSe ti esegui un scito a ato traffego, ti doviesci leze arcun-e conscidiaçioin in sciâ [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzaçion Unicode].",
+ "config-unicode-update-warning": "'''Attençion:''' a verscion installaa do gestô pe-a normalizzaçion Unicode a l'adoeuvia una vegia verscion da libraia [http://site.icu-project.org/ do progetto ICU].\nTi doviesci [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations aggiornâ] se ti voeu doeuviâ l'Unicode.",
+ "config-no-db": "Imposcibile trovâ un driver adatto pe-o database! L'è necessaio installâ un driver pe PHP.\n{{PLURAL:$2|O seguente formato de database o l'è supportou|I seguenti formati de database son supportæ}}: $1.\n\nSe ti compilli PHP aotonomamente, riconfiguilo attivando un client database, presempio utilizzando <code>./configure --with-mysqli</code>.\nQualoa t'avesci installou PHP pe mezo de 'n pacchetto Debian ò Ubuntu, alloa ti devi installâ o pacchetto <code>php5-mysql</code> ascì.",
+ "config-outdated-sqlite": "'''Atençion''': ti g'hæ SQLite $1, ma te ghe voeu comme minnimo a verscion $2. SQLite o no saiâ disponibile.",
+ "config-no-fts3": "'''Atençion''': SQLite o l'è compilou sença o [//sqlite.org/fts3.html modulo FTS3], e fonçioin de çerchia no saian disponibile insce sto motô.",
+ "config-pcre-old": "<strong>Fatale:</strong> se richiede PCRE $1 o succescivo.\nO to PHP binaio o l'è conligou con PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/Ciu informaçioin insce PCRE].",
+ "config-pcre-no-utf8": "'''Fatale''': o modulo PCRE de PHP pâ ch'o segge stæto compilou sença o supporto PCRE_UTF8. A MediaWiki a-o richiede pe fonçionâ corettamente.",
+ "config-memory-raised": "O valô <code>memory_limit</code> de PHP o l'è $1, aomentou a $2.",
+ "config-memory-bad": "''Atençion:''' O valô de <code>memory_limit</code> do PHP o l'è $1.\nFoscia o l'è troppo basso.\nL'installaçion a porriæ fallî!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] o l'è installou",
+ "config-apc": "[http://www.php.net/apc APC] o l'è installou",
+ "config-apcu": "[http://www.php.net/apc APC] o l'è installou",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] o l'è installou",
+ "config-no-cache-apcu": "'''Atençion:''' [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] ò [http://www.iis.net/download/WinCacheForPhp WinCache] no son stæti trovæ.\nA caching di ogetti a no l'è attivâ.",
+ "config-mod-security": "<strong>Atençion:</strong> O to serviou web o g'ha o [http://modsecurity.org/ mod_security] abilitou. Gh'è tante configuaçioin che crean di problemi a-a MediaWiki ò a atro software ch'o permette a-i utenti de pubbricâ quâ-se-segge contegnuo. Se poscibbile o doviæ ese disabilitou.\nFanni rifeimento a-a [http://modsecurity.org/documentation/ documentaçion insce-o mod_security] ò contatta o supporto tecnico do to provider de hosting se se veifica di erroî.",
+ "config-diff3-bad": "GNU diff3 non trovou.",
+ "config-git": "Trovou software de controllo da verscion Git: <code>$1</code>.",
+ "config-git-bad": "Software de controllo da verscion Git non trovou.",
+ "config-imagemagick": "Trovou ImageMagick: <code>$1</code>.\nE miniatue de inmaggine saian presente se i upload vegnan abilitæ.",
+ "config-gd": "Trovou a libraja graffica GD integrâ.\nE miniatue de inmaggine saian presente se i upload vegnan abilitæ.",
+ "config-no-scaling": "Imposcibbile trovâ a libraja GD ò ImageMagick.\nE miniatue de inmaggine saian disabilitæ.",
+ "config-no-uri": "'''Errô:''' Imposcibbile determinâ l'URI attoale.\nInstallaçion interotta.",
+ "config-no-cli-uri": "'''Atençion''': <code>--scriptpath</code> non specificou, s'adoeuvia o valô predefinio: <code>$1</code>.",
+ "config-using-server": "Nomme do serviou in uso \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "URL do serviou in uso \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Atençion:</strong> a directory predefinia pe-i caregamenti <code>$1</code> a l'è vulnerabile a l'execuçion arbitraia de script.\nSciben che, a difeisa da segueçça, a MediaWiki a controlla tutti i file caregæ, l'è fortemente racomandou de [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security serâ questa menaçça] primma d'abilitâ i caregamenti.",
+ "config-no-cli-uploads-check": "<strong>Atençion:</strong> a directory predefinia pe-i caregamenti (<code>$1</code>) a no l'è stæta veificâ pe-a vulnerabilitæ insce l'esecuçion arbitraia de script durante l'installaçion da linnia de comando.",
+ "config-brokenlibxml": "O to scistema o g'ha 'na combinaçion de verscioin de PHP e libxml2 ch'a l'è difettosa e ch'a poeu provocâ un danno ascozo a-i dæti da MediaWiki e d'atre apricaçioin web.\nAgiorna a libxml2 2.7.3 ò succescivo ([https://bugs.php.net/bug.php?id=45996 o babollo o l'è studiou da-o lao PHP]).\nInstallaçion interotta.",
+ "config-suhosin-max-value-length": "Suhosin o l'è installou e o limmita o parammetro GET <code>length</code> a $1 byte.\nO componente MediaWiki ResourceLoader o fonçioniâ inte sto limmite, ma questo o reduiâ e prestaçioin.\nSe poscibbile, ti doviesci impostâ <code>suhosin.get.max_value_length</code> a 1024 ò ciu in <code>php.ini</code>, e impostâ <code>$wgResourceLoaderMaxQueryLength</code> a-o mæximo valô in <code>LocalSettings.php</code>.",
+ "config-db-type": "Tipo de database:",
+ "config-db-host": "Host do database:",
+ "config-db-host-help": "Se o serviou do to database o l'è insce 'n serviou despægio, inmetti chì o nomme de l'host ò o so adresso IP.\n\nSe ti doeuvi un web hosting condiviso, o to hosting provider o doviæ fornite o nomme host corretto inta so documentaçion.\n\nSe t'ê aproeuvo a instalâ insce 'n serviou Windows con uso de MySQL, l'uso de \"localhost\" o porriæ no fonçionâ correttamente comme nomme do serviou. In caxo de problemi, proeuva a impostâ \"127.0.0.1\" comme adresso IP locale.\n\nSe ti t'adoeuvi PostgreSQL, lascia questo campo voeuo pe consentî de connettise trammite un socket Unix.",
+ "config-db-host-oracle": "TNS do database:",
+ "config-db-host-oracle-help": "Inseisci un vallido [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; un file tnsnames.ora o dev'ese vixibbile a questa installaçion.<br />Se ti t'adoeuvi a libraia cliente 10g o ciù reçente ti poeu ascì doeuviâ o mettodo de denominaçion [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identiffica questo wiki",
+ "config-db-name": "Nomme do database:",
+ "config-db-name-help": "Çerni un nomme ch'o l'identiffiche o to wiki.\nO no deve contegnî de spaççi.\n\nSe ti doeuvi un web hosting condiviso, o to hosting provider o te fornisce un speciffico nomme de database da doeuviâ, opû o ti consentiâ de creâ o database trammite un panello de controllo.",
+ "config-db-name-oracle": "Schema do database:",
+ "config-db-account-oracle-warn": "Gh'è trei scenarri supportæ pe instalâ l'Oracle comme database de backend:\n\nSe t'oeu creâ 'n'utença de database comme parte do processo d'instalaçion, fornisci un account con rollo SYSDBA comme utença de database pe l'instalaçion e speciffica e credençiæ vosciue pe l'utença d'accesso web, sedonque l'è poscibbile creâ manoalmente l'utença d'accesso web e fornî solo quell'account (s'o g'ha e aotorizaçioin necessaie pe creâ i ogetti do schema) ò fornî doe utençe despæge, un-a co-i permissi de creaçion e un-a pe l'accesso web.\n\nO Script pe creâ un'utença co-e aotorizaçioin necessaie o se troeuva inta directory \"maintenance/oracle/\" de questa instalaçion. Tegnit'amente che l'uzo de 'n'utença con restriçioin o dizabilitiâ tutte e fonçionalitæ de manutençion con l'account predefinio.",
+ "config-db-install-account": "Account utente pe l'instalaçion",
+ "config-db-username": "Nomme utente do database:",
+ "config-db-password": "Password do database:",
+ "config-db-install-username": "Inseisci o nomme utente ch'o saiâ doeuviou pe conettise a-o database durante o processo d'installaçion.\nQuesto o no l'è o nomme utente de l'account MediaWiki; ma quello pe-o to database.",
+ "config-db-install-password": "Inseisci a password ch'a saiâ doeuviâ pe connettise a-o database into processo d'installaçion.\nQuesta a no l'è a password de l'account MediaWiki; ma quella pe-o to database.",
+ "config-db-install-help": "Insei o nomme utente e a password che saian doeuviæ pe-a conescion a-o database durante o processo d'instalaçion.",
+ "config-db-account-lock": "Doeuvia o mæximo nomme utente e password durante o normale fonçionamento",
+ "config-db-wiki-account": "Account utente pe-o normale fonçionamento",
+ "config-db-wiki-help": "Insei o nomme utente e a password che saiâ doeuviou pe connettise a-o database durante o normale fonçionamento da wiki.\nSe l'account o no l'existe, e l'account d'instalaçion o g'ha assæ privileggi, st'account utente o saiâ creou con privileggi minnimi necessai pe operâ in sciâ wiki.",
+ "config-db-prefix": "Prefisso da tabella do database:",
+ "config-db-prefix-help": "Se ti g'hæ de bezoeugno de condividde un database tra ciu wiki, ò tra MediaWiki e un'atra apricaçion web, ti poeu çerne d'azonze un prefisso a tutti i nommi de tabella, pe evitâ confliti.\nNo doeuviâ spaççi.\n\nA l'uzo sto campo o l'è lasciou voeuo.",
+ "config-mysql-old": "L'è necessaio MySQL $1 ò 'na verscion succesciva. Ti ti g'hæ a $2.",
+ "config-db-port": "Porta do database:",
+ "config-db-schema": "Schema pe MediaWiki:",
+ "config-db-schema-help": "Questo schema in genere o l'aniâ ben.\nCangilo solo se t'ê seguo de doveilo fâ.",
+ "config-pg-test-error": "Imposcibbile conettise a-o database '''$1''': $2",
+ "config-sqlite-dir": "Cartella dæti de SQLite:",
+ "config-sqlite-dir-help": "SQLite o memorizza tutti i dæti inte 'n unnico file.\n\nA directory che t'indichiæ a dev'ese scrivibile da-o serviou web durante l'instalaçion.\n\nA dev'ese <strong>non acescibbile via web</strong>, l'è pe questo che no a mettemmo donde gh'è i file PHP.\n\nL'instalou o ghe scriviâ insemme un file <code>.htaccess</code>, ma se o tentativo o falisse quarcun poriæ avei accesso a-o database sgroeuzzo.\nQuesto o l'includde di dæti utente sgroeuzzi (adressi, password çiffræ) coscì comme de vercsioin eliminæ e atri dæti a accesso limitou da wiki.\n\nConsciddera a-a dreitua l'oportunitæ d'alugâ o database da quarch'atra parte, prezempio in <code>/var/lib/mediawiki/tuowiki</code>.",
+ "config-oracle-def-ts": "Tablespace pe difetto:",
+ "config-oracle-temp-ts": "Tablespace tempoannio:",
+ "config-type-mysql": "MySQL (ò compatibbile)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki o supporta i seguenti scistemi de database:\n\n$1\n\nSe fra quelli elencæ chì de sotta no ti veddi o scistema de database che ti voriesci doeuviâ, segui e instruçioin inganciæ de d'ato pe abilitâ o supporto.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] a l'è a primma scelta pe MediaWiki e a l'è quella megio suportâ. MediaWiki a fonçion-a ascì con [{{int:version-db-mariadb-url}} MariaDB] e [{{int:version-db-percona-url}} Percona Server], che son compatibbili con MySQL.([http://www.php.net/manual/en/mysqli.installation.php Comme compilâ PHP con suporto MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] o l'è un popolare scistema de database open source comme alternativa a MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Comme compilâ PHP con suporto PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] o l'è un scistema de database leggio, ch'o l'è suportou molto ben. ([http://www.php.net/manual/en/pdo.installation.php Comme compilâ PHP con suporto SQLite], o l'utilizza PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] o l'è un database de un'impreiza comerciâ. ([http://www.php.net/manual/en/oci8.installation.php Comme compilâ PHP con suporto OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] o l'è un database de un'impreiza commerciâ per Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Comme compilâ PHP con supporto SQLSRV])",
+ "config-header-mysql": "Impostaçioin MySQL",
+ "config-header-postgres": "Impostaçioin PostgreSQL",
+ "config-header-sqlite": "Impostaçioin SQLite",
+ "config-header-oracle": "Impostaçioin Oracle",
+ "config-header-mssql": "Impostaçioin do Microsoft SQL Server",
+ "config-invalid-db-type": "Tipo de database non vallido",
+ "config-missing-db-name": "Ti g'hæ da mettighe un valô pe \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Ti g'hæ da mettighe un valô pe \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "L'è necessaio inmettere un valô pe \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "TNS database \"$1\" non vallido.\nAdoeuvia \"TNS Name\" ò 'na stringa \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods]).",
+ "config-invalid-db-name": "Nomme de database \"$1\" non vallido.\nAdoeuvia solo che di caratteri ASCII comme lettie (a-z, A-Z), nummeri (0-9), sottoliniatua (_) e trattin (-).",
+ "config-invalid-db-prefix": "Prefisso database \"$1\" non vallido.\nChe ti doeuvi solo di caratteri ASCII comme lettie (a-z, A-Z), nummeri (0-9), sottoliniatua (_) e trattin (-).",
+ "config-connection-error": "$1.\n\nControlla l'host, o nomme utente e a password, e proeuva torna.",
+ "config-invalid-schema": "Schema MediaWiki \"$1\" non vallido.\nChe ti doeuvi solo lettie ASCII (a-z, A-Z), nummeri (0-9) ò caratteri de sottoliniatua (_).",
+ "config-db-sys-create-oracle": "O programma d'instalaçion o suporta solo l'utilizzo de 'n account SYSDBA pe-a creaçion de 'n noeuvo account.",
+ "config-db-sys-user-exists-oracle": "L'utença \"$1\" a l'existe za. SYSDBA o poeu vese doeuviou solo che pe-a creaçion de 'na noeuva utença!",
+ "config-postgres-old": "Ghe voeu MySQL $1 ò 'na verscion succesciva. Ti ti g'hæ a $2.",
+ "config-mssql-old": "Ghe voeu Microsoft SQL Server $1 ò succescivo. Ti ti g'hæ a verscion $2.",
+ "config-sqlite-name-help": "Çerni un nomme ch'o l'identiffiche a to wiki.\nNo doeuviâ spaÇçi ò trattin.\nQuesto o serviâ pe-o nomme do file di dæti SQLite.",
+ "config-sqlite-parent-unwritable-group": "No se poeu creâ a directory dæti <code><nowiki>$1</nowiki></code>, percose a directory supeiô <code><nowiki>$2</nowiki></code> a no l'è scrivibbile da-o webserver.\n\nO programma d'instalaÇion o l'ha determinou l'utente con chi o serviou web o l'è in esecuçion.\nDagghe a poscibilitæ de scrive inta directory <code><nowiki>$3</nowiki></code> pe continoâ.\nInsce un scistema Unix/Linux fanni:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "No se poeu creâ a directory dæti <code><nowiki>$1</nowiki></code>, percose a directory supeiô <code><nowiki>$2</nowiki></code> a no l'è scrivibbile da-o webserver.\n\nO programma d'instalaçion o no l'ha posciuo determinâ l'utente con chi o serviou web o l'è in esecuçion.\nRendi a directory <code><nowiki>$3</nowiki></code> scrivibbile globalmente, da esso (e da atri) pe continoâ.\nInsce un scistema Unix/Linux fanni:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Errô durante a creaçion da directory dæti \"$1\".\nControlla a poxiçion e proeuva torna.",
+ "config-sqlite-dir-unwritable": "Imposcibile scrive inta directory \"$1\".\nCangia i aotoizaçioin de mainea che o webserver o ghe posse scrive e proeuva torna.",
+ "config-sqlite-connection-error": "$1.\n\nControlla a directory dæti e o nomme do database chì de sotta, e proeuva torna.",
+ "config-sqlite-readonly": "O file <code>$1</code> o no l'è scrivibbile.",
+ "config-sqlite-cant-create-db": "Imposcibile creâ o file do database <code>$1</code> .",
+ "config-sqlite-fts3-downgrade": "A-o PHP gh'amanca o suporto FTS3, declassamento tabelle in corso",
+ "config-can-upgrade": "Gh'è de tabelle da MediaWiki in questo database.\nPe agiornâle a MediaWiki $1, clicca insce '''continnoa'''.",
+ "config-upgrade-done": "Agiornamento completo.\n\nAoa ti poeu [$1 començâ a doeuviâ a to wiki].\n\nSe t'oeu rigenerâ o to file <code>LocalSettings.php</code>, clicca in sciô pomello de sotta. Questa opiaçion '''a no l'è racomandâ''', a meno che no ti gh'aggi di problemi co-a to wiki.",
+ "config-upgrade-done-no-regenerate": "Agiornamento completo.\n\nAoa ti poeu [$1 començâ a doeuviâ a to wiki].",
+ "config-regenerate": "Rigennera LocalSettings.php →",
+ "config-show-table-status": "A query <code>SHOW TABLE STATUS</code> a l'è fallia!",
+ "config-unknown-collation": "'''Atençion:''' o database o doeuvia de reggole de confronto non riconosciue.",
+ "config-db-web-account": "Account do database pe l'acesso web",
+ "config-db-web-help": "Seleçion-a o nomme utente e a password che o serviou web o l'adoeuviâ pe conettise a-o serviou de database, durante o normale fonçionamento da wiki.",
+ "config-db-web-account-same": "Doeuvia o mæximo account de l'instalaçion",
+ "config-db-web-create": "Crea l'account s'o no l'existe ancon",
+ "config-db-web-no-create-privs": "L'account doeuviou pe l'installaçion o no dispon-e di privileggi necessai pe creâ un atro account.\nL'account indicou chì o deve za existe.",
+ "config-mysql-engine": "Motô d'archiviaçion:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Atençion:</strong> t'hæ seleçionou MyISAM comme motô d'archiviaçion pe MySQL, ch'o no l'è racomandou pe l'uso con MediaWiki, percose:\n* o supporta debolmente a concorença pe-o blocco da tabella\n* o l'è ciu inclinou a-a corruçion di atri motoî\n* o codiçe de base MediaWiki o no gestisce sempre MyISAM comm'o doviæ\n\nSe a to instalaçion MySQL a supporta InnoDB, l'è atamente racomandou che ti o çerni a-o so posto.\nSe a to installaçion MySQL a no supporta InnoDB, foscia l'è o momento pe 'n agiornamento.",
+ "config-mysql-only-myisam-dep": "<strong>Atençion:</strong> MyISAM o l'è l'unnico motô d'archiviaçion disponibbile pe MySQL insce sta macchina, e questo no l'è consegiou pe doeuviâlo con MediaWiki, percose:\n* o supporta debolmente a concorenza pe-o blocco da tabella\n* o l''è ciu inclinou a-a corruçion di atri motoî\n* o coddiçe de base MediaWiki MyISAM o no-o gestisce sempre comm'o doviæ\n\nS'a to installaçion MySQL a no supporta InnoDB, foscia l'è o momento pe 'n agiornamento.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> o l'è quæxi sempre a megio opçion, in quante o g'ha 'n bon supporto da concorença.\n\n<strong>MyISAM</strong> o poriæ vese ciu veloçe inte installaçioin mono-utente ò in sola-lettua.\nI database MyISAM tendan a dannezâse ciu soventi di database InnoDB.",
+ "config-mysql-charset": "Set di caratteri do database:",
+ "config-mysql-binary": "Binaio",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "In <strong>modalitæ binaia</strong>, MediaWiki a l'archivvia o testo UTF-8 into database in campi binai.\nQuest'o l'è ciu efficaçe che a modalitæ UTF-8 do MySQL, e o consente de doeuviâ a gamma completa de caratteri Unicode.\n\nIn <strong>modalitæ UTF-8</strong>, MySQL o saviâ inte quæ set de caratteri l'è che son i to dæti, e o poriâ presentâli e convertîli in moddo apropiou, ma o no te permetiâ de memorizâ i caratteri de d'ato a-o [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Cian de base murtilenguistego].",
+ "config-mssql-auth": "Tipo d'aotenticaçion:",
+ "config-mssql-install-auth": "Seleçion-a o tipo d'aotenticaçion ch'o saiâ doeuviou pe conettise a-o database durante o processo de instalaçion.\nSe ti seleçion-i \"{{int:config-mssql-windowsauth}}\", saiâ doeuviou e credençiæ de quæ se segge utente segge aproeuv'a fâ giâ o serviou web.",
+ "config-mssql-web-auth": "Seleçion-a o tipo d'aotenticaçion che o serviou web o doeuviâ pe conettise a-o database. \nSe ti seleçion-i \"{{int:config-mssql-windowsauth}}\", saiâ doeuviou e credençiæ de quæ se segge utente segge aproeuv'a fâ giâ o serviou web.",
+ "config-mssql-sqlauth": "Aotenticaçion de SQL Server",
+ "config-mssql-windowsauth": "Aotenticaçion de Windows",
+ "config-site-name": "Nomme da wiki:",
+ "config-site-name-help": "Questo saiâ vixualizou inta bara do tittolo do navegatô e in atri varri recanti.",
+ "config-site-name-blank": "Inseisci o nomme de 'n scito.",
+ "config-project-namespace": "Namespace do progetto:",
+ "config-ns-generic": "Progetto",
+ "config-ns-site-name": "Pægio che o nomme do wiki: $1",
+ "config-ns-other": "Atro (specificâ)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Aproeuvo a l'exempio da Wikipedia, molte wiki tegnan e so paggine co-e reggole separæ da-e paggine de contegnuo, inte 'n '''namespace de progetto'''.\nTutti i tittoli de paggine inte sto namespace començan co-in çerto prefisso, che ti poeu indicâ chie.\nA l'uzo, sto prefisso o deriva da-o nomme da wiki, ma o no poeu contegnî di caratteri de pontezatua comme \"#\" ò \":\".",
+ "config-ns-invalid": "O namespace indicou \"<nowiki>$1</nowiki>\" o no l'è vallido.\nSpecificâ un despægio namespace de progetto.",
+ "config-ns-conflict": "O namespace indicou \"<nowiki>$1</nowiki>\" o l'è in conflito co-in namespace predefinio MediaWiki.\nSpecificâ un despægio namespace de progetto.",
+ "config-admin-box": "Account amministratô",
+ "config-admin-name": "O to nomme utente:",
+ "config-admin-password": "Poula segretta:",
+ "config-admin-password-confirm": "Ripeti a poula segretta:",
+ "config-admin-help": "Inseisci chì o to nomme utente prefeio, presempio \"Giobatta Parodi\".\nSto chì o l'è o nomme che ti doeuviæ pe acede a-a wiki.",
+ "config-admin-name-blank": "Inseisci un nomme pe l'aministratô.",
+ "config-admin-name-invalid": "O nomme utente indicou \"<nowiki>$1</nowiki>\" o no l'è vallido.\nSpecificâ un nomme utente despægio.",
+ "config-admin-password-blank": "Inseisci 'na password pe l'account d'aministratô.",
+ "config-admin-password-mismatch": "E doe password inseie no corispondan.",
+ "config-admin-email": "Adresso e-mail:",
+ "config-admin-email-help": "Inseisci chì 'n adresso e-mail pe poei riçeive di e-mail da-i atri utenti da wiki, rimpostâ a to password, e vese informou de modiffiche aportæ a-e to paggine sotta oservaçion. Se no te interessa, ti poeu lasciâ voeuo questo campo.",
+ "config-admin-error-user": "Erô interno durante a creaçion de 'n aministratô co-o nomme \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Erô interno durante l'impostaçion de 'na password pe aministratô \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "T'hæ inseio un adresso e-mail non vallido.",
+ "config-subscribe": "Sottoscrivi a [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce mailing list di anonçi de release].",
+ "config-subscribe-help": "Se tratta de 'na mailing list a basso traffego dedicâ a-i anonçi de sciortie de noeuve verscioin, compreize de importante segnalaçioin pe-a segueçça.\nSe conseggia de inscrivise e agiornâ a proppia instalaçion de MediaWiki quande sciorte 'na noeuva verscion.",
+ "config-subscribe-noemail": "T'hæ provou a inscrivite a-a mailing list dedicâ a-i anonçi de noeuve verscioin sença fornî un adresso e-mail.\nInseisci un adresso e-mail se ti dexidei efetoâ l'inscriçion a-a mailing list.",
+ "config-pingback": "Condividdi i dæti insce questa installaçion co-i svilupatoî da MediaWiki.",
+ "config-pingback-help": "Se ti seleçion-i questa opçion, MediaWiki a contattiâ periodicamente https://www.mediawiki.org co-i dæti base insce questa instançia MediaWiki. Queta categoria de dæti a l'includde, prexempio, o tipo de scistema, a verscion de PHP e o database de backend çernuo. A Wikimedia Foundation a condividde questi dæti co-i sviluppatoî Mediawiki pe agiutâla a guidâ i futuri sforsci de sviluppo. Pe-o to scistema saiâ inviou i seguenti dæti:\n<pre>$1</pre>",
+ "config-almost-done": "T'hæ quæxi a tio!\nAoa ti poeu sâtâ a restante parte da configuaçion e instalâ a wiki subbito.",
+ "config-optional-continue": "Famme di atre domande.",
+ "config-optional-skip": "Son za stuffo, installa a wiki e basta.",
+ "config-profile": "Profî di driti utente:",
+ "config-profile-wiki": "Wiki averta",
+ "config-profile-no-anon": "Creaçion utença obrigatoia",
+ "config-profile-fishbowl": "Solo utenti aotorizæ",
+ "config-profile-private": "Wiki privâ",
+ "config-profile-help": "E wiki fonçion-an megio se ti permetti a tante person-e de poeili modificâ.\nIn MediaWiki, l'è sempliçe controlâ i urtime modiffiche, e ripristinâ i danni caosæ da di utenti inesperti ò malintençionæ.\n\nTuttavia, tanti han trovou a MediaWiki uttile inte 'n'ampia varietæ de rolli, e de volte no l'è faççile convinçe tutti di vantaggi da modalitæ wiki.\nPerciò, fanni a to scelta.\n\nO modello <strong>{{int:config-profile-wiki}}</strong> o consente a chi se segge de modificâ, anche sença efetoâ l'acesso.\nUna wiki con <strong>{{int:config-profile-no-anon}}</strong> a l'ofre 'na magiô responsabilitæ, ma a poriæ scoragî i contributoî ocaxonæ.\n\nO scenario <strong>{{int:config-profile-fishbowl}}</strong> o consente a-i utenti aotorizæ de modificâ, ma o pubbrico o poeu vixualizâ e paggine, compreiso a cronologia.\nUn <strong>{{int:config-profile-private}}</strong> o consente solo ch'a-i utenti aotorizæ de vixualizâ e paggine, o mæximo groppo o poeu modificâle.\n\nDe configuaçioin di driti utente ciu complesse son disponibbile doppo l'instalaçion, amia a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights parte relativa do manoâ].",
+ "config-license": "Driti d'aotô e liçençia:",
+ "config-license-none": "Nisciun pê de paggina pe-a liçençia",
+ "config-license-cc-by-sa": "Creative Commons Attribuçion-Condividdi pægio",
+ "config-license-cc-by": "Creative Commons Attribuçion",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribuçion-Non comerciale-Condividdi Pægio",
+ "config-license-cc-0": "Creative Commons Zero (pubbrico dominnio)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 o verscioin sucescive",
+ "config-license-pd": "Pubbrico dominnio",
+ "config-license-cc-choose": "Seleçion-a un-a de liçençie Creative Commons",
+ "config-license-help": "Tante wiki pubbriche rilascian i so contributi co-ina [http://freedomdefined.org/Definition liçençia libbera]. Sto fæto o l'agiutta a creâ un senso de propietæ condivisa inta comunitæ e o l'incoragisce a contriboî a longo termine. O no l'è generalmente necessaio pe 'na wiki privâ ò aziendale.\n\nSe ti voeu doeuviâ di scriti da Wikipedia, ò ti dexiddei che a Wikipedia a posse vese in graddo de acetâ di scriti copiæ da-a to wiki, ti doviesci scellie <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nIn precedença a Wikipedia a l'ha doeuviou a GNU Free Documentation License. A GFDL a l'è 'na liçençia vallida, ma a l'è difiççile da capî e a complica o riutilizzo di contegnui.",
+ "config-email-settings": "Impostaçioin e-mail",
+ "config-enable-email": "Abillita a sciortia da posta elettronica",
+ "config-enable-email-help": "Se ti voeu che fonçion-e l'e-mail, e [http://www.php.net/manual/en/mail.configuration.php PHP's impostaçioin della posta] dev'esan configuæ corettamente.\nSe non ti dexiddei arcun-a fonçionalitæ de posta eletronnica, ti a poeu disabilitâ chie.",
+ "config-email-user": "Abillita e-mail fra utenti",
+ "config-email-user-help": "Consente a tutti i utenti de inviâse l'un l'atro l'e-mail, se l'han abilitou inte so preferençe.",
+ "config-email-usertalk": "Abillita e notiffiche pe-e paggine de discuscion utente",
+ "config-email-usertalk-help": "Consente a-i utenti de riçeive de notiffiche pe-e modiffiche de so paggine de discuscion, se l'han abilitou inte so preferençe.",
+ "config-email-watchlist": "Abillita e notiffiche pe-a lista sott'oservaçion",
+ "config-email-watchlist-help": "Consente a-i utenti de riçeive de notiffiche pe-e pagine da lista sott'oservaçion, se l'han abilitou inte so preferençe.",
+ "config-email-auth": "Abillita aotenticaçion via e-mail",
+ "config-email-auth-help": "Se questa opçion a l'è attivâ, i utenti dovian confermâ o so adresso e-mail doeouviando un ingancio ch'o ven inviou ogni votta che l'impostan ò o cangian.\nSolo i adressi de posta elettronica aotenticæ poeuan riçeive de e-mail da di atri utenti ò cangiâ e e-mail de notiffica.\nImpostâ st'opçion l'è <strong>raccomandou</strong> pe-e wiki pubbriche pe via do potençiale abuso dee fonçioin de posta elettronica.",
+ "config-email-sender": "Adresso e-mail de ritorno:",
+ "config-email-sender-help": "Inseisci l'adresso e-mail da doeuviâ comme adresso de ritorno pe-a posta ch'a sciorte.\nChì l'è donde ghe saiâ inviou i eventoali eroî.\nMolti server de posta richiedan che armeno a parte do nomme de dominnio a segge vallida.",
+ "config-upload-settings": "Caregamenti de inmaggine e file",
+ "config-upload-enable": "Consentî o caregamento di file",
+ "config-upload-help": "O caregamento di file o poriæ espon-e o to serviou a di reizeghi de segueçça.\nPe magioî informaçioin, lezi a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security seçion in sciâ segueçça] into manoâ.\n\nPe consentî o caregamento di file, modiffica a modalitæ inta sottodirectory <code>images</code> da directory prinçipâ da MediaWiki coscì che o serviou web o posse scrive lì.\nPoi attiva questa opçion.",
+ "config-upload-deleted": "Directory pe-i file scassæ:",
+ "config-upload-deleted-help": "Çerni 'na directory onde archiviâ i file scassæ.\nIdealmente, questa a no doviæ ese accescibbile da-o web.",
+ "config-logo": "URL do logo:",
+ "config-logo-help": "O tema predefinio da MediaWiki o l'includde o spaççio pe 'n logo de 135 x 160 pixel sorve o menù laterâ.\nCarrega 'n'inmaggine de dimenscioin apropiæ e inseisci l'URL chie.\n\nL'è poscibbile doeuviâ <code>$wgStylePath</code> o <code>$wgScriptPath</code> se o logo o l'è relativo a sti percorsci.\n\nSe un logo no ti o voeu, lascia sta casella voeua.",
+ "config-instantcommons": "Abillita Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] a l'è 'na fonçionalitæ ch'a consente a-i wiki de doeuviâ inmaggine, soin e atri file murtimediæ ch'atroæ 'n sciô scito [https://commons.wikimedia.org/ Wikimedia Commons].\nPe fâ questo, a MediaWiki a richiede l'accesso a l'Internet.\n\nPe ciu informaçioin insce sta fonçionalitæ, con tanto de instruçioin sciu comme configuâlo pe de wiki despæge da-a Wikimedia Commons, consurtæ [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos o manoâ].",
+ "config-cc-error": "O selettô de liçençie Creative Commons o no l'ha reizo arcun resultato.\nInseisci manoalmente o nomme da liçençia.",
+ "config-cc-again": "Çerni torna...",
+ "config-cc-not-chosen": "Çerni quæ liçençia Creative Commons ti voeu e sciacca \"proceed\".",
+ "config-advanced-settings": "Configuaçion avançâ",
+ "config-cache-options": "Impostaçioin pe-a cache di ogetti:",
+ "config-cache-help": "A memorizaçion di ogetti inta cache a serve pe fâ anâ ciu fito a MediaWiki sarvando inta cache i dæti che ti doeuvi soventi.\nPe di sciti de dimenscioin mezan-e e grende l'è cadamente consegiou attivâ a cache, ma pe-i piccin ascì se ne vediâ i benefiççi.",
+ "config-cache-none": "Nisciun-a memorizaçion inta cache (nisciun-a fonçionalitæ a l'è impedia, ma in scî sciti wiki ciu grendi a veloçitæ a poriæ risentîne)",
+ "config-cache-accel": "Mette in cache ogetti PHP (APC, APCu, XCache ò WinCache)",
+ "config-cache-memcached": "Doeuvia Memcached (a richiede urteioî attivitæ de instalaçion e configuaçion)",
+ "config-memcached-servers": "Serviou de Memcached:",
+ "config-memcached-help": "Lista de adressi IP da doeuviâ pe Memcached.\nTi doviesci specificâne un pe riga e indicâ a porta da doeuviâ. Presempio:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "L'è stæto seleçionou o tipo de caching Memcached, ma no l'è stæto impostou arcun serviou.",
+ "config-memcache-badip": "L'è stæto inseio un adresso IP non vallido pe Memcached: $1.",
+ "config-memcache-noport": "Non l'è stæto specificou 'na porta da doeuviâ pe-o serviou Memcached: $1.\nSe no ti sæ quæ a l'è a porta, o valô pe difetto o l'è 11211.",
+ "config-memcache-badport": "I nummeri de porta pe Memcached dovieivan ese tra $1 e $2.",
+ "config-extensions": "Estenscioin",
+ "config-extensions-help": "I estenscioin elencæ de d'ato son stæte rilevæ inta to directory <code>./extensions</code>.\n\nQueste poririvan richiede 'n urteiô configuaçion, ma l'è poscibbile attivâle aoa",
+ "config-skins": "Pelle",
+ "config-skins-help": "E pelle elencæ de d'ato son stæte rilevæ inta to directory <code>./skins</code>. Ti devi attivâne aomanco un-a e scellie quella predefinia.",
+ "config-skins-use-as-default": "Doeuvia sta pelle comme predefinia",
+ "config-skins-missing": "No l'è stæto trovou arcun-a pelle; a MediaWiki a l'adoeuviâ 'na soluçion de ripiego pe scin che no ti ne instaliæ un-a apropiâ.",
+ "config-skins-must-enable-some": "Ti devi çerne armeno 'na pelle da attivâ.",
+ "config-skins-must-enable-default": "A pelle çernua comme predefinia a dev'ese attivâ.",
+ "config-install-alreadydone": "'''Attençion:''' pâ che t'aggi za instalou a MediaWiki e ti çerchi de instalâla torna.\nProcedi a-a paggina succesciva.",
+ "config-install-begin": "Sciacando \"{{int:config-continue}}\", t'inçiæ l'instalaçion da MediaWiki.\nSe primma ti voesci fâ di atri cangiamenti, premmi \"{{int:config-back}}\".",
+ "config-install-step-done": "fæto",
+ "config-install-step-failed": "no ariescio",
+ "config-install-extensions": "Estenscioin compreize",
+ "config-install-database": "Configuaçion do database",
+ "config-install-schema": "Creaçion do schema",
+ "config-install-pg-schema-not-exist": "O schema PostgreSQL o no l'existe.",
+ "config-install-pg-schema-failed": "Creaçion tabelle non riuscia.\nAsseguite che l'utente \"$1\" o posse scrive into schema \"$2\".",
+ "config-install-pg-commit": "Commetti i cangiamenti",
+ "config-install-pg-plpgsql": "Controllo do lenguaggio PL/pgSQL",
+ "config-pg-no-plpgsql": "Bezoeugna che t'installi o lenguaggio PL/pgSQL into database $1",
+ "config-pg-no-create-privs": "L'utença indicâ pe l'instalaçion a no dispon-e di privileggi necessai pe creâ 'n'utença.",
+ "config-pg-not-in-role": "L'utença indicâ pe l'utente web a l'existe za.\nL'utença indicâ pe l'instalaçion a no l'è un super-utente e a no l'è un membro do rollo di utenti web, quindi a no l'è in graddo de creâ di ogetti de propietæ de l'utente web.\n\nMediaWiki atoalmente a richiede che e tabelle seggian de propietæ de l'utente web. Indica 'n atro account web, ò clicca \"inderê\" e speciffica un utente pe l'instalaçion oportunamente privilegiou.",
+ "config-install-user": "Creaçion de utente do database",
+ "config-install-user-alreadyexists": "L'utente $1 o l'existe za.",
+ "config-install-user-create-failed": "Creaçion de l'utente \"$1\" no ariescia: $2",
+ "config-install-user-grant-failed": "Erô durante a concescion de l'aotorizaçion a l'utente \"$1\": $2",
+ "config-install-user-missing": "L'utente indicou \"$1\" o no l'existe.",
+ "config-install-user-missing-create": "L'utente indicou \"$1\" o no l'existe.\nSeleçion-a a casella \"crea utença\" chì de sotta, se ti a voeu creâ.",
+ "config-install-tables": "Creaçion tabelle",
+ "config-install-tables-exist": "'''Atençion:''' pâ che e tabelle da MediaWiki ghe seggian za.\nSato a creaçion.",
+ "config-install-tables-failed": "'''Erô''': a creaçion da tabella a no l'è ariescia: $1",
+ "config-install-interwiki": "Impimento da tabella interwiki predefinia",
+ "config-install-interwiki-list": "Imposcibbile leze o file <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Atençion:''' pâ che inta tabella interwiki ghe segge za di elementi.\nA lista predefinia a se sata.",
+ "config-install-stats": "Iniçializaçion de statisteghe",
+ "config-install-keys": "Generaçion de ciave segrette",
+ "config-insecure-keys": "'''Atençion:''' {{PLURAL:$2|Una ciave segûa|De ciave segûe}} ($1) {{PLURAL:$2|generâ|generæ}} durante l'instalaçion {{PLURAL:$2|a|}} no {{PLURAL:$2|l'è|son}} completamente {{PLURAL:$2|segûa|segûe}}. Consciddera de cangiâ{{PLURAL:$2|la|le}} manoalmente.",
+ "config-install-updates": "Impedî l'esecuçion di agiornamenti non necessai",
+ "config-install-updates-failed": "<strong>Erô:</strong> l'inseimento de ciave de agiornamento inte tabelle o no l'è ariescio pe-o seguente erô: $1",
+ "config-install-sysop": "Creaçion de l'utença pe l'aministratô",
+ "config-install-subscribe-fail": "Imposcibbile sottoscrive mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL o no l'è instalou e <code>allow_url_fopen</code> o no l'è disponibbile.",
+ "config-install-mainpage": "Creaçion da paggina prinçipâ con contegnuo predefinio",
+ "config-install-mainpage-exists": "A paggina prinçipâ a l'existe za, ignorou",
+ "config-install-extension-tables": "Creaçion de tabelle pe i estenscioin attivæ",
+ "config-install-mainpage-failed": "Imposcibbile insei a paggina prinçipâ: $1",
+ "config-install-done": "<strong>Complimenti!</strong>\nT'hæ instalou MediaWiki.\n\nO programma d'instalaçion o l'ha generou un file <code>LocalSettings.php</code> ch'o conten tutte e impostaçioin.\n\nTi devi scaregâlo e inseilo inta directory base da to wiki (a mæxima dovve gh'è index.php). O download o doviæ partî aotomaticamente.\n\nSe o download o no s'inandia, ò s'o l'è stæto annulou, ti poeu ricomençâ clicando in sce l'ingancio chì aproeuvo:\n\n$3\n\n<strong>Notta:</strong> se ti sciorti aoa da l'installaçion sença scaregâ o file de configuaçion ch'o l'è stæto generou, questo doppo o no saiâ ciu disponibbile.\n\nQuande t'hæ fæto, ti poeu <strong>[$2 intrâ inta to wiki]</strong>.",
+ "config-install-done-path": "<strong>Complimenti!</strong>\nT'hæ instalou MediaWiki.\n\nO programma d'instalaçion o l'ha generou un file <code>LocalSettings.php</code> ch'o conten tutte e impostaçioin.\n\nTi devi scaregâlo e inseilo in <code>$4</code>. O download o doviæ partî aotomaticamente.\n\nSe o download o no s'inandia, ò s'o l'è stæto annulou, ti poeu inandiâlo torna clicando in sce l'ingancio chì de sotta:\n\n$3\n\n<strong>Notta:</strong> se ti sciorti aoa da l'installaçion sença scaregâ o file de configuaçion ch'o l'è stæto generou, questo doppo o no saiâ ciu disponibbile.\n\nQuande t'hæ fæto, ti poeu <strong>[$2 intrâ inta to wiki]</strong>.",
+ "config-download-localsettings": "Scarega <code>LocalSettings.php</code>",
+ "config-help": "agiutto",
+ "config-help-tooltip": "clicca pe espande",
+ "config-nofile": "O file \"$1\" o no poeu vese atrovou. O l'è stæto eliminou?",
+ "config-extension-link": "Ti o saveivi che o to wiki o suporta i [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions estenscioin]?\n\nTi poeu navegâ tra i [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category estenscioin pe categoria].",
+ "mainpagetext": "<strong>MediaWiki o l'è stæto instalou.</strong>",
+ "mainpagedocfooter": "Consurta a [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents guidda utente] pe ciu informaçioin in sce l'uso de questo software wiki.\n\n== Pe començâ ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Impostaçioin de configuaçion]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Domande frequente sciu MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list anonçi MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Troeuva MediaWiki inta to lengoa]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Imprendi a combatte o spam in sciâ to wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/lki.json b/www/wiki/includes/installer/i18n/lki.json
new file mode 100644
index 00000000..45f13314
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lki.json
@@ -0,0 +1,81 @@
+{
+ "@metadata": {
+ "authors": [
+ "Hosseinblue",
+ "Arash71",
+ "Lakzon",
+ "Seb35"
+ ]
+ },
+ "config-desc": "نۀصب کۀر ویکی‌مدیا",
+ "config-title": "نۀصب ویکی‌مدیا $1",
+ "config-information": "اطلاعات",
+ "config-localsettings-upgrade": "یک پرونده <code>LocalSettings.php</code> شناسایی شده‌است.\nبرای ارتقاء این نصب لطفاً مقدار <code>$wgUpgradeKey</code> در جعبه زیر وارد کنید.\nشما می‌توانید آن را در <code>LocalSettings.php</code> پیدا کنید.",
+ "config-localsettings-cli-upgrade": "یک پرونده <code>LocalSettings.php</code> شناسایی شده است.\nبرای ارتقاء این نصب، لطفاً <code>update.php</code> را اجرا کنید.",
+ "config-localsettings-key": ":کلید ارتقا",
+ "config-localsettings-badkey": "کلیدی که شما ارائه کردید نادرست است.",
+ "config-upgrade-key-missing": "نصب موجود مدیاویکی شناسایی شده‌است.\nبرای بروزرسانی این نصب، لطفاً خط زیر را در آخر کد \nقرار دادن به نصب ارتقاء داده شده، به خط زیر لطفاً در پایین خود را <code>LocalSettings.php</code> قرار دهید:\n\n$1",
+ "config-localsettings-incomplete": "وجود <code>LocalSettings.php</code> به نظر ناقص می‌رسد.\nمتغیر $1 تنظیم نشده‌است.\nبرای اینکه این متغیر تنظیم شود لطفاً <code>LocalSettings.php</code> را تغییر دهید، و \"{{int:Config-continue}}\" را کلیک کنید.",
+ "config-localsettings-connection-error": "هنگام اتصال به پایگاه اطلاعاتی که ازتنظیمات مشخص شده در<code>LocalSettings.php</code> استفاده می‌کند، خطایی رخ داد. لطفاً این تنظیمات را نصب کنید و دوباره تلاش کنید.\n$1",
+ "config-session-error": "خطا در شروع جلسه: $1",
+ "config-session-expired": "به نظر می‌رسد اطلاعات جلسهٔ شما منقضی شده‌است.\nجلسات برای مادام العمر $1 پیکربندی شده‌اند.\nشما می‌توانید این پیکربندی را با تنظیم <code>session.gc_maxlifetime</code> در php.ini افزایش دهید.\nروند نصب را دوباره شروع کنید.",
+ "config-no-session": "اطلاعات دورهٔ شما از دست رفته‌ است!\nphp.ini خود را بررسی کنید و مطمئن شوید <code>session.save_path</code> برای یک فهرست مناسب تنظیم شده‌است.",
+ "config-your-language": "زوون هوومۀ",
+ "config-your-language-help": "زوونئ ئۀرا استفاده در طی کار نۀب انتخب کۀن.",
+ "config-wiki-language": "زوون ویکی:",
+ "config-wiki-language-help": "زوونئ انتخاب کۀن گِ ویکی ویشتر وۀ ئۀؤۀ مۀنیوسِرئ.",
+ "config-back": "→ ئاهۀتن-بازگشت",
+ "config-continue": "ادامه-دؤم گرتن ←",
+ "config-page-language": "زوون",
+ "config-page-welcome": "خؤةش هةتینِ مدیا ویکی!",
+ "config-page-dbconnect": "اتصال به پایگاه داده",
+ "config-page-upgrade": "ارتقای نصب موجود",
+ "config-page-dbsettings": "تنظیمات پایگاه داده",
+ "config-page-name": "نۆم",
+ "config-page-options": "گزینۀل",
+ "config-page-install": "نۀصب",
+ "config-page-complete": "انجؤم دریا-انجؤم هنگت",
+ "config-page-restart": "راه‌اندازی دوواره نصب",
+ "config-page-readme": "أڕانم بخووەن",
+ "config-page-releasenotes": "یادداشت‌های انتشار",
+ "config-page-copying": "کپی",
+ "config-page-upgradedoc": "ارتقاء",
+ "config-page-existingwiki": "ویکی موجود",
+ "config-help-restart": "آیا می‌خواهید همهٔ اطلاعات ذخیره شده‌ای که وارد کرده‌اید را پاک کنید و دوباره روند نصب را شروع کنید؟",
+ "config-restart": "أرێ، دوواره راه‌اندازی کة",
+ "config-welcome": "===بررسی‌های محیطی===\nبرای فهمیدن اینکه این محیط برای نصب مدیاویکی مناسب است، اکنون بررسی‌های اساسی انجام خواهد‌شد.\nاگر به دنبال پشتیبانی در چگونگی تکمیل نصب هستید،به یاد داشته باشید این اطلاعات را بگنجانید.",
+ "config-copyright": "===حق چاپ و شرایط===\n$1\nاین برنامه، یک نرم‌افزاری آزاد است. شما می‌توانید آن را بازتوزیع کرده و/یا با شرایط نگارش ۲ یا (با نظر خودتان) هر نگارش جدیدتری از پروانه جامع همگانی گنو که توسط بنیاد نرم‌افزار آزاد منتشر شده، تغییر دهید.\n\nاین برنامه با امید این که مفید واقع‌ شود توزیع شده‌است،اما '''بدون هیچ ضمانتی'''; حتی بدون اشارهٔ ضمانتی از '''قابلیت عرضه''' یا ''' صلاحیت برای یک هدف خاص'''.\nبرای جزئیات بیش‌تر پروانه جامع همگانی گنو را مشاهده کنید.\n\nشما باید <doclink href=Copying> یک نگارش ازمجوز عمومی کلی </doclink> همراه این برنامه دریافت کرده باشید. در غیر این صورت با بنیاد نرم‌افزار آزاد، ایالات متحده امریکا، بوستون، خیابان فرانکلین، پلاک ۵۱، طبقه پنجم، صندوق پستی MA۰۲۱۱۰-۱۳۰ مکاتبه کنید، یا [http://www.gnu.org/copyleft/gpl.html در این‌جا به صورت برخط بخوانید].",
+ "config-sidebar": "* [https://www.mediawiki.org وةڵگة اصلی مدیاویکی]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents راهنمای کاربر]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents راهنمای مدیر]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ پرسش‌های رایج]\n----\n* <doclink href=Readme>مرا بخوان</doclink>\n* <doclink href=ReleaseNotes>یادداشت‌های انتشار</doclink>\n* <doclink href=Copying>نسخه برداری</doclink>\n* <doclink href=UpgradeDoc>ارتقا</doclink>",
+ "config-env-good": "محیط بررسی شده‌است.\nشما می‌توانید مدیاویکی را نصب کنید.",
+ "config-env-bad": "محیط بررسی شده‌است.\nشما نمی‌توانید مدیاویکی را نصب کنید.",
+ "config-env-php": "پی‌اچ‌پی $1 نصب شده‌است.",
+ "config-env-hhvm": "HHVM $1 نصب شده‌است.",
+ "config-unicode-using-intl": "برای یونیکد عادی از [http://pecl.php.net/intl intl PECL extension] استفاده کنید.",
+ "config-unicode-pure-php-warning": "'''هشدار:''' [http://pecl.php.net/intl intl PECL extension] برای کنترل یونیکد عادی در دسترس نیست،اجرای کاملاً آهسته به تعویق می‌افتد.\nاگر شما یک سایت پر‌ ترافیک را اجرا می‌کنید، باید کمی [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization] را بخوانید.",
+ "config-unicode-update-warning": "'''هشدار:''' نسخهٔ نصب شدهٔ پوشهٔ یونیکد عادی از ورژن قدیمی‌تر کتابخانه [http://site.icu-project.org/ the ICU project's] استفاده می‌کند.\nاگر کلاً علاقه‌مند به استفاده از یونیکد هستید باید [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations upgrade].",
+ "config-no-db": "درایور پایگاه اطلاعاتی مناسب پیدا نشد! شما لازم دارید یک درایور پایگاه اطلاعاتی برای پی‌اچ‌پی نصب کنید.انواع پایگاه اطلاعاتی زیر پشتیبانی شده‌اند:$1.\nاگر شما در گروه اشتراک‌گذاری هستید، از تهیه کنندهٔ گروه خود برای نصب یک درایور پایگاه اطلاعاتی مناسب {{PLURAL:$2|سوأل کنید.|پرسش کنید.}}\nاگر خود، پی‌اچ‌پی را تهیه کرده‌اید، با یک پردازشگر فعال دوباره پیکربندی کنید، برای مثال از <code>./configure --with-mysqli</code> استفاده کنید.\nاگر پی‌اچ‌پی را از یک بستهٔ دبیان یا آبونتو نصب کرده‌اید، بنابراین لازم دارید بخش php5-mysql را نصب کنید.",
+ "config-outdated-sqlite": "''' هشدار:''' شما اس‌کیولایت $1 دارید، که پایین‌تر از حداقل نسخهٔ $2 مورد نیاز است.اس‌کیولایت در دسترس نخواهد بود.",
+ "config-no-fts3": "'''هشدار:''' اس‌کیولایت بدون [//sqlite.org/fts3.html FTS3 module] تهیه شده‌است ، جستجوی ویژگی‌ها در این بخش پیشین در دسترس نخواهد‌بود.",
+ "config-pcre-old": "''' خطای اساسی:'' ' PCRE $1 یا بعدا مورد نیاز است.\nکد باینری پی‌اچ‌پی‌تان با PCRE $2 پیوند دارد.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE اطلاعات بیشتر].",
+ "config-pcre-no-utf8": "'''مخرب:''' به‌ نظر می‌رسد ماژول پی‌سی‌آرایی پی‌اچ‌پی بدون پشتیبانی پی‌سی‌آرایی_یو‌تی‌اف۸ تهیه شده‌است.\nمدیاویکی برای درست عمل کردن نیازمند پشتیبانی یوتی‌اف-۸ است.",
+ "config-memory-raised": "PHP's <code>memory_limit</code>, نسخهٔ $1 است، به نسخهٔ $2 ارتقاء داده شده‌است.",
+ "config-memory-bad": "'''هشدار:''' PHP's <code>memory_limit</code> نسخهٔ $1 است.\nاین ممکن است خیلی پایین باشد.\nممکن است نصب با مشکل رو‌به‌رو شود.",
+ "config-db-type": "نوع پایگاه اطلاعات:",
+ "config-db-host": "میزبان پایگاه اطلاعات:",
+ "config-db-host-oracle": "ای ویکیۀ شناسایی کۀ.",
+ "config-db-name": "نام پایگاه اطلاعات:",
+ "config-upgrade-done": "تکمیل ارتقاء.\nاکنون شما می‌توانید [$1 start using your wiki].\nاگر می‌خواهید پوشهٔ <code>LocalSettings.php</code> را احیا کنید،دکمهٔ زیر را کلیک کنید.\nاین ''' توصیه نمی‌شود ''' مگر اینکه با ویکی خود مشکل داشته باشید.",
+ "config-site-name-blank": "نام سایتئ وارد کۀن.",
+ "config-ns-generic": "پروژۀ",
+ "config-admin-box": "حساوو مدیر سامانه",
+ "config-admin-name": "نام کاربری هؤمۀ:",
+ "config-admin-password": ":رمز",
+ "config-admin-password-confirm": "رمز دووآرۀ:",
+ "config-admin-email": ":نیشانی رایانامۀ",
+ "config-optional-continue": "پرسشۀلی تریژ بپر إژ مِ",
+ "config-profile-wiki": "واز کردن ویکی",
+ "config-email-user-help": "به همهٔ کاربرانی که ارسال ایمیل را در ترجیحات خود فعال کرده‌اند، اجازه داده خواهد شد که به یکدیگر ایمیل ارسال کنند.",
+ "config-email-usertalk-help": "به همهٔ کاربرانی که دریافت اطلاعیه را در اولویت‌های خود فعال کرده‌اند،اجازه خواهد داده‌شد که اطلاعیه‌ها را در صفحهٔ تغییر گفت‌وگوی کاربر دریافت کنند.",
+ "config-email-watchlist-help": "به همهٔ کاربرانی که مشاهدهٔ صفحه را در اولویت‌های خود فعال کرده‌اند،اجازه خواهد داده‌شد که اطلاعیه‌های در رابطه با صفحات مشاهده شده را دریافت کنند.",
+ "config-install-step-done": "انجؤم بی"
+}
diff --git a/www/wiki/includes/installer/i18n/lo.json b/www/wiki/includes/installer/i18n/lo.json
new file mode 100644
index 00000000..9d964449
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lo.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''ຕິດຕັ້ງມີເດຍວິກິນີ້ສຳເລັດແລ້ວ.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/lrc.json b/www/wiki/includes/installer/i18n/lrc.json
new file mode 100644
index 00000000..6d93a5c5
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lrc.json
@@ -0,0 +1,28 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bonevarluri",
+ "Mogoeilor"
+ ]
+ },
+ "config-information": "دونسمنيا",
+ "config-your-language": "زون شما:",
+ "config-wiki-language": "زون ویکی:",
+ "config-back": "← ڤادئما",
+ "config-continue": "نئها گئرئتئن →",
+ "config-page-language": "زون",
+ "config-page-welcome": "د ویکی رسانه خوش اومایت!",
+ "config-page-dbconnect": "ڤأصل بییئن د رئسینە جا",
+ "config-page-dbsettings": "میزوٙنکاری رئسینە جا",
+ "config-page-name": "نوم",
+ "config-page-options": "گزينه يا هنی:",
+ "config-page-install": "پورنیئن",
+ "config-page-complete": "تموم بيه!",
+ "config-page-readme": "منه بحون",
+ "config-page-copying": "د حال ورداشتن",
+ "config-page-upgradedoc": "د حالت نو کردن",
+ "config-page-existingwiki": "ویکی یایی که هئن",
+ "config-restart": "هری، دواره رئش بون",
+ "config-env-php": "پی اچ پی $1 پورسته.",
+ "config-install-pg-plpgsql": "وارسی سی زون پی ال/پی جی اس کیو ال"
+}
diff --git a/www/wiki/includes/installer/i18n/lt.json b/www/wiki/includes/installer/i18n/lt.json
new file mode 100644
index 00000000..07e4c907
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lt.json
@@ -0,0 +1,176 @@
+{
+ "@metadata": {
+ "authors": [
+ "Eitvys200",
+ "Mantak111",
+ "Zygimantus",
+ "Hugo.arg",
+ "Homo"
+ ]
+ },
+ "config-desc": "MediaWiki diegimas",
+ "config-title": "MediaWiki $1 diegimas",
+ "config-information": "Informacija",
+ "config-localsettings-upgrade": "Aptiktas failas <code>LocalSettings.php</code>. Norėdami patobulinti šią instaliaciją, prašome įvesti reikšmę <code>$wgUpgradeKey</code> į dėžutę žemiau. Jūs rasite ją <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Aptiktas failas <code>LocalSettings.php</code>. Tam kad patobulinti šią instaliaciją, prašome paleisti <code>update.php</code>.",
+ "config-localsettings-key": "Naujinimo raktas:",
+ "config-localsettings-badkey": "Atnaujinimo raktas, kurį pateikėte, yra neteisingas.",
+ "config-upgrade-key-missing": "Aptikta esama MediaWiki instaliacija. Tam kad atnaujinti šią instaliaciją, prašome įrašyti šią eilutę <code>LocalSettings.php</code> failo pabaigoje:\n\n$1",
+ "config-localsettings-incomplete": "Esamas failas <code>LocalSettings.php</code> yra nepilnas. Nenustatytas kintamasis $1.\nPrašome pakeisti failą <code>LocalSettings.php</code> tai, kad kintamasis būtų nustatytas ir spauskite „{{int:Config-continue}}“.",
+ "config-localsettings-connection-error": "Buvo susidurta su klaida, kai jungtasi prie duomenų bazės naudojantis nustatymais iš <code>LocalSettings.php</code>. Prašome pataisyti šiuos nustatymus ir bandyti dar kartą.\n\n$1",
+ "config-session-error": "Klaida pradedant seansą: $1",
+ "config-session-expired": "Jūsų sesija pasibaigė.\nSesijos yra nustatytos trukmei - $1.\nGalite ją padidinti nustatydami <code>session.gc_maxlifetime</code> php.ini faile.\nPaleiskite iš naujo instaliaciją.",
+ "config-no-session": "Jūsų sesijos duomenys prarasti!\nPatikrinkite savo php.ini failą ir įsitikinkite, kad <code>session.save_path</code> yra nustatytas tinkamai direktorijai.",
+ "config-your-language": "Jūsų kalba:",
+ "config-your-language-help": "Pasirinkite kalbą, kurią naudosite instaliuodami.",
+ "config-wiki-language": "Viki kalba:",
+ "config-wiki-language-help": "Pasirinkite kalbą, kuria viki bus parašyta.",
+ "config-back": "← Atgal",
+ "config-continue": "Toliau →",
+ "config-page-language": "Kalba",
+ "config-page-welcome": "Sveiki atvykę į MediaWiki!",
+ "config-page-dbconnect": "Prisijungti prie duomenų bazės",
+ "config-page-upgrade": "Atnaujinti esamą instaliaciją",
+ "config-page-dbsettings": "Duomenų bazės nustatymai",
+ "config-page-name": "Vardas",
+ "config-page-options": "Parinktys",
+ "config-page-install": "Įdiegti",
+ "config-page-complete": "Baigta!",
+ "config-page-restart": "Iš naujo paleiskite diegimą",
+ "config-page-readme": "Skaityti daugiau",
+ "config-page-releasenotes": "Leidimo pastabos",
+ "config-page-copying": "Kopijuojama",
+ "config-page-upgradedoc": "Atnaujinama",
+ "config-page-existingwiki": "Esamas viki",
+ "config-help-restart": "Ar norite ištrinti visus išsaugotus duomenis, kuriuos jūs įvedėte ir iš naujo paleisti diegimo procesą?",
+ "config-restart": "Taip, paleiskite jį iš naujo",
+ "config-welcome": "== Aplinkos patikrinimas ==\nDabar bus atlikti pagrindiniai patikrinimai, po kurių paaiškės, ar ši aplinka yra tinkama MediaWiki įrangai.\nNepamirškite įtraukti šią informaciją, jeigu norite gauti pagalbos, kaip užbaigti įdiegimą.",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki namų tinklalapis]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Vartotojo gidas]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administratoriaus gidas]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ DUK]\n----\n* <doclink href=Readme>Skaityk mane</doclink>\n* <doclink href=ReleaseNotes>Leidimo pastabos</doclink>\n* <doclink href=Copying>Kopijavimas</doclink>\n* <doclink href=UpgradeDoc>Atnaujinimas</doclink>",
+ "config-env-good": "Aplinka buvo patikrinta.\nJūs galite įdiegti MediaWiki.",
+ "config-env-bad": "Aplinka buvo patikrinta.\nJūs negalite įdiegti MediaWiki.",
+ "config-env-php": "PHP $1 yra įdiegtas.",
+ "config-env-hhvm": "HHVM $1 yra įdiegtas.",
+ "config-outdated-sqlite": "<strong>Įspėjimas:</strong> jūs turite SQLite $1, kuri yra mažesnė nei minimali reikalinga versija $2. SQLite nebus prieinama.",
+ "config-memory-raised": "PHP <code>memory_limit</code> yra $1, padidintas iki $2.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] yra įdiegtas",
+ "config-apc": "[http://www.php.net/apc APC] yra įdiegtas",
+ "config-apcu": "[http://www.php.net/apcu APCu] yra įdiegtas",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] yra įdiegtas",
+ "config-no-cache-apcu": "<strong>Įspėjimas:</strong> Nepavyko rasti [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] or [http://www.iis.net/download/WinCacheForPhp WinCache].\nObjekto spartinimas neįjungtas.",
+ "config-diff3-bad": "GNU diff3 nerastas.",
+ "config-git": "Rasta Git versijų kontrolės sistema: <code>$1</code>.",
+ "config-imagemagick": "Rastas „ImageMagick“: <code>$1</code>.\nPaveikslėlių miniatiūrizavimas bus įjungtas, jeigu įgalinsite vaizdų įkėlimą.",
+ "config-using-server": "Naudojamas serverio pavadinimas „<nowiki>$1</nowiki>“.",
+ "config-using-uri": "Naudojamas serverio URL „<nowiki>$1$2</nowiki>“.",
+ "config-db-type": "Duomenų bazės tipas:",
+ "config-db-host": "Duomenų bazės serveris:",
+ "config-db-host-oracle": "Duomenų bazės TNS:",
+ "config-db-wiki-settings": "Identifikuoti šią viki",
+ "config-db-name": "Duomenų bazės pavadinimas:",
+ "config-db-name-oracle": "Duomenų bazės schema:",
+ "config-db-install-account": "Vartotojo paskyra diegimui",
+ "config-db-username": "Duomenų bazės vartotojo vardas:",
+ "config-db-password": "Duomenų bazės slaptažodis:",
+ "config-db-wiki-account": "Naudotojo paskyra įprastai operacijai",
+ "config-db-prefix": "Duomenų bazės lentelės priešdėlis:",
+ "config-mysql-old": "MySQL $1 ar vėlesnė yra reikalinga. Jūs turite $2.",
+ "config-db-port": "Duomenų bazės prievadas:",
+ "config-db-schema": "MediaWiki schema:",
+ "config-pg-test-error": "Negalima prisijungti prie duomenų bazės <strong>$1</strong>: $2",
+ "config-sqlite-dir": "SQLite duomenų katalogas:",
+ "config-oracle-def-ts": "Numatytoji lentelių sritis:",
+ "config-oracle-temp-ts": "Laikina lentelių sritis:",
+ "config-type-mysql": "MySQL (arba suderinama)",
+ "config-type-mssql": "Microsoft SQL serveris",
+ "config-header-mysql": "MySQL nustatymai",
+ "config-header-postgres": "PostgreSQL nustatymai",
+ "config-header-sqlite": "SQLite nustatymai",
+ "config-header-oracle": "Oracle nustatymai",
+ "config-header-mssql": "„Microsoft“ SQL serverio nustatymai",
+ "config-invalid-db-type": "Neteisingas duomenų bazės tipas",
+ "config-missing-db-name": "Privalote įvesti „{{int:config-db-name}}“ reikšmę.",
+ "config-missing-db-host": "Privalote įvesti „{{int:config-db-host}}“ reikšmę.",
+ "config-missing-db-server-oracle": "Privalote įvesti „{{int:config-db-host-oracle}}“ reikšmę.",
+ "config-postgres-old": "PostgreSQL $1 ar vėlesnė yra reikalinga. Jūs turite $2.",
+ "config-sqlite-cant-create-db": "Nepavyko sukurti duomenų bazės failo <code>$1</code>.",
+ "config-regenerate": "Pergeneruoti LocalSettings.php →",
+ "config-db-web-account": "Duomenų bazės paskyra dėl internetinės prieigos",
+ "config-db-web-account-same": "Naudoti tą pačią paskyrą kaip ir įdiegimui",
+ "config-db-web-create": "Sukurti paskyrą, jeigu jos nėra",
+ "config-mysql-engine": "Saugojimo variklis:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Duomenų bazės simbolių rinkinys:",
+ "config-mysql-binary": "Dvejetainis",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Autentifikavimo tipas:",
+ "config-mssql-sqlauth": "SQL Serverio autentifikavimas",
+ "config-mssql-windowsauth": "Windows autentifikavimas",
+ "config-site-name": "Viki pavadinimas:",
+ "config-site-name-blank": "Įveskite svetainės pavadinimą.",
+ "config-project-namespace": "Projekto vardų sritis:",
+ "config-ns-generic": "Projektas",
+ "config-ns-site-name": "Toks pat kaip viki pavadinimas: $1",
+ "config-ns-other": "Kita (nurodyti)",
+ "config-ns-other-default": "ManoViki",
+ "config-admin-box": "Administratoriaus paskyra",
+ "config-admin-name": "Jūsų naudotojo vardas:",
+ "config-admin-password": "Slaptažodis:",
+ "config-admin-password-confirm": "Slaptažodis dar kartą:",
+ "config-admin-name-blank": "Įveskite administratoriaus vartotojo vardą.",
+ "config-admin-password-blank": "Įvesti administratoriaus paskyros slaptažodį.",
+ "config-admin-password-mismatch": "Įvesti slaptažodžiai nesutampa.",
+ "config-admin-email": "El. pašto adresas:",
+ "config-admin-error-bademail": "Įvedėte neteisingą el. pašto adresą.",
+ "config-optional-continue": "Paklausti daugiau klausimų.",
+ "config-optional-skip": "Man jau nuobodu, tiesiog įdiekite viki.",
+ "config-profile": "Vartotojo teisių paskyra:",
+ "config-profile-wiki": "Atidaryti viki",
+ "config-profile-no-anon": "Reikalingas paskyros sukūrimas",
+ "config-profile-private": "Privati viki",
+ "config-license": "Autorinės teisės ir licencija:",
+ "config-license-pd": "Viešas domenas",
+ "config-email-settings": "El. pašto nustatymai",
+ "config-email-watchlist": "Įjungti stebimųjų pranešimą",
+ "config-email-auth": "Įjungti el. pašto autentifikavimą",
+ "config-upload-settings": "Vaizdų ir failų įkėlimai",
+ "config-upload-enable": "Įgalinti failų įkėlimus",
+ "config-upload-deleted": "Katalogas ištrintiems failams:",
+ "config-logo": "Logotipo URL:",
+ "config-cc-again": "Pasirinkti dar kartą...",
+ "config-advanced-settings": "Išplėstinė konfigūracija",
+ "config-cache-options": "Nustatymai objektų podėliavimui:",
+ "config-memcached-servers": "„Memcached“ serveriai:",
+ "config-extensions": "Plėtiniai",
+ "config-skins": "Išvaizda",
+ "config-skins-use-as-default": "Naudoti šią išvaizdą pagal nutylėjimą",
+ "config-install-step-done": "atlikta",
+ "config-install-step-failed": "nepavyko",
+ "config-install-extensions": "Įskaitant plėtinius",
+ "config-install-database": "Tvarkoma duomenų bazė",
+ "config-install-schema": "Kuriama schema",
+ "config-install-pg-schema-not-exist": "PostgreSQL schemos nėra.",
+ "config-install-user": "Kuriamas duomenų bazės naudotojas",
+ "config-install-user-alreadyexists": "Naudotojas „$1“ jau yra",
+ "config-install-user-create-failed": "Nepavyko sukurti naudotojo „$1“: $2",
+ "config-install-user-missing": "Nurodytas vartotojas „$1“ neegzistuoja.",
+ "config-install-user-missing-create": "Nurodytas vartotojas „$1“ neegzistuoja.\nPrašome pažymėti „sukurti paskyrą“ laukelį žemiau jei norite jį sukurti.",
+ "config-install-tables": "Kuriamos lentelės",
+ "config-install-tables-exist": "<strong>Įspėjimas:</strong> MediaWiki lentelės, atrodo, jau egzistuoja.\nKūrimas praleidžiamas.",
+ "config-install-tables-failed": "<strong>Klaida:</strong> Lentelės sukūrimas nepavyko dėl šios klaidos: $1",
+ "config-install-interwiki-list": "Nepavyko perskaityti failo <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Įspėjimas:</strong> Interwiki lentelė, atrodo, jau turi įrašų.\nNumatytasis sąrašas praleidžiamas.",
+ "config-install-stats": "Inicijuojamos statistikos",
+ "config-install-keys": "Generuojami slapti raktai",
+ "config-install-sysop": "Administratoriaus vartotojo paskyra kuriama",
+ "config-install-mainpage": "Kuriamas pagrindinis puslapis su numatytu turiniu",
+ "config-install-extension-tables": "Kuriamos lentelės įgalintiems plėtiniams",
+ "config-install-mainpage-failed": "Nepavyko įterpti pagrindinio puslapio: $1",
+ "config-install-done": "'''Sveikiname!'''\nJūs sėkmingai įdiegėte MediaWiki.\n\nĮdiegimo programa sukūrė <code>LocalSettings.php</code> failą.\nJame yra visos jūsų konfigūracijos.\n\nJums reikės atsisiųsti ir įdėti jį į savo wiki įdiegimo bazę (pačiame kataloge, kaip index.php). Atsisiuntimas turėtų prasidėti automatiškai.\n\nJei atsisiuntimas nebuvo pasiūlytas, arba jį atšaukėte, galite iš naujo atsisiųsti paspaudę žemiau esančią nuorodą:\n\n$3\n\n'''Pastaba:''' Jei jūs to nepadarysite dabar, tada šis sukurtas konfigūracijos failas nebus galimas vėliau, jei išeisite iš įdiegimo be atsisiuntimo.\n\nKai baigsite, jūs galėsite '''[$2 įeiti į savo viki]'''.",
+ "config-download-localsettings": "Atsisiųsti <code>LocalSettings.php</code>",
+ "config-help": "pagalba",
+ "config-help-tooltip": "spustelėkite išplėtimui",
+ "config-nofile": "Failas \"$1\" nerastas. Ar jis buvo ištrintas?",
+ "mainpagetext": "<strong>MediaWiki sėkmingai įdiegta.</strong>",
+ "mainpagedocfooter": "Informacijos apie viki programinės įrangos naudojimą, ieškokite [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents].\n\n== Pradžia ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Konfigūracijos nustatymų sąrašas]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MedijaViki DUK]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MedijaViki pranešimai el. paštu apie naujas versijas]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokalizuoti MedijaViki savo kalbai]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Sužinokite kaip kovoti su šlamštu savo viki]"
+}
diff --git a/www/wiki/includes/installer/i18n/lv.json b/www/wiki/includes/installer/i18n/lv.json
new file mode 100644
index 00000000..671d0735
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lv.json
@@ -0,0 +1,70 @@
+{
+ "@metadata": {
+ "authors": [
+ "GreenZeb",
+ "Papuass",
+ "Silraks"
+ ]
+ },
+ "config-information": "Informācija",
+ "config-your-language": "Jūsu valoda:",
+ "config-wiki-language": "Wiki valoda:",
+ "config-back": "← Atpakaļ",
+ "config-continue": "Turpināt →",
+ "config-page-language": "Valoda",
+ "config-page-welcome": "Laipni lūdzam MediaWiki!",
+ "config-page-dbconnect": "Savienoties ar datubāzi",
+ "config-page-upgrade": "Atjaunināt pašreizējo instalāciju",
+ "config-page-dbsettings": "Datubāzes iestatījumi",
+ "config-page-name": "Vārds",
+ "config-page-options": "Iespējas",
+ "config-page-install": "Instalēt",
+ "config-page-complete": "Pabeigts!",
+ "config-page-restart": "Pārstartēt instalāciju",
+ "config-page-readme": "Lasīt mani",
+ "config-page-releasenotes": "Informācija par laidienu",
+ "config-page-copying": "Kopē",
+ "config-restart": "Jā, restartēt",
+ "config-env-php": "PHP $1 ir uzstādīts.",
+ "config-env-hhvm": "HHVM $1 ir uzstādīts.",
+ "config-apcu": "[http://www.php.net/apcu APCu] ir uzstādīts",
+ "config-diff3-bad": "GNU diff3 nav atrasts.",
+ "config-db-host-oracle": "Datubāzes TNS:",
+ "config-db-name": "Datubāzes nosaukums:",
+ "config-db-username": "Datubāzes lietotājvārds:",
+ "config-db-password": "Datubāzes parole:",
+ "config-db-port": "Datubāzes ports:",
+ "config-db-schema": "MediaWiki shēma:",
+ "config-db-schema-help": "Šī shēma derēs vairumā gadījumu.\nMainiet to tikai, ja zināt, ka tas nepieciešams.",
+ "config-type-mysql": "MySQL (vai saderīga)",
+ "config-header-mysql": "MySQL iestatījumi",
+ "config-header-postgres": "PostgreSQL iestatījumi",
+ "config-header-sqlite": "SQLite iestatījumi",
+ "config-header-oracle": "Oracle iestatījumi",
+ "config-header-mssql": "Microsoft SQL servera iestatījumi",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-windowsauth": "Windows Autentifikācija",
+ "config-ns-generic": "Projekts",
+ "config-ns-site-name": "Tāds pats kā viki nosaukums: $1",
+ "config-ns-other": "Cits (jānorāda)",
+ "config-ns-other-default": "MansViki",
+ "config-admin-box": "Administratora konts",
+ "config-admin-name": "Tavs lietotājvārds:",
+ "config-admin-password": "Parole:",
+ "config-admin-password-confirm": "Parole vēlreiz:",
+ "config-admin-name-blank": "Ievadiet administratora lietotājvārdu.",
+ "config-admin-email": "E-pasta adrese:",
+ "config-email-settings": "E-pasta iestatījumi",
+ "config-logo": "Logo URL:",
+ "config-cc-again": "Izvēlies vēlreiz...",
+ "config-memcached-servers": "Memcached serveri:",
+ "config-extensions": "Paplašinājumi",
+ "config-install-step-done": "Gatavs",
+ "config-install-user": "Veido datu bāzes lietotāju",
+ "config-help": "palīdzība",
+ "config-help-tooltip": "uzspiediet, lai izvērstu",
+ "mainpagetext": "<strong>MediaWiki veiksmīgi instalēts.</strong>",
+ "mainpagedocfooter": "Izlasi [https://meta.wikimedia.org/wiki/Help:Contents Lietotāja pamācību], lai iegūtu vairāk informācijas par Wiki programmatūras lietošanu.\n\n== Pirmie soļi ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Konfigurācijas iespēju saraksts]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki J&A]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Parakstīties uz paziņojumiem par jaunām MediaWiki versijām]"
+}
diff --git a/www/wiki/includes/installer/i18n/lzh.json b/www/wiki/includes/installer/i18n/lzh.json
new file mode 100644
index 00000000..8574d593
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lzh.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jason924tw"
+ ]
+ },
+ "config-information": "文訊",
+ "config-page-language": "語",
+ "mainpagetext": "'''共筆臺已立'''",
+ "mainpagedocfooter": "欲識維基,見[https://meta.wikimedia.org/wiki/Help:Contents User's Guide]\n\n== 始 ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/lzz.json b/www/wiki/includes/installer/i18n/lzz.json
new file mode 100644
index 00000000..cdc70786
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/lzz.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bombola"
+ ]
+ },
+ "mainpagetext": "'''Mediawiki dido k'ai ik'idu.'''",
+ "mainpagedocfooter": "Vik'i şeni muç'o ixmarinen ya mutxanepe oguru şeni [https://meta.wikimedia.org/wiki/Help:Contents oxmaruşi rexberis] o3'k'edit.\n\n== Ağani na gyoç’k’u maxmarepe ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Ok'iduşi ayarepeşi liste]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki P'anda Na-k'itxu K'itxalape]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki e-mailepeşiş liste]"
+}
diff --git a/www/wiki/includes/installer/i18n/mai.json b/www/wiki/includes/installer/i18n/mai.json
new file mode 100644
index 00000000..5e3fc0d3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mai.json
@@ -0,0 +1,39 @@
+{
+ "@metadata": {
+ "authors": [
+ "Umeshberma",
+ "बिप्लब आनन्द",
+ "Bijay chaurasia",
+ "Tulsi Bhagat"
+ ]
+ },
+ "config-desc": "मेडिया-विकी के लेल इंस्टॉलर",
+ "config-information": "जानकारी",
+ "config-your-language": "अहाँक भाषा:",
+ "config-your-language-help": "प्रतिस्थापन होएतकाल भाषाके चयन करू",
+ "config-wiki-language": "विकि भाषा:",
+ "config-back": "← पाछा",
+ "config-continue": "आगु चलु →",
+ "config-page-language": "भाषा",
+ "config-page-welcome": "मेडियाविकिमे अहाँक स्वागत अछि!",
+ "config-page-dbconnect": "डेटाबेसस जुडु",
+ "config-page-upgrade": "भेल प्रतिस्थापन के नविनीकरण करु",
+ "config-page-dbsettings": "डाटाबेस कुंजी",
+ "config-page-name": "नाम",
+ "config-page-options": "विकल्प",
+ "config-page-install": "स्थापित करी",
+ "config-page-complete": "पूर्ण!",
+ "config-page-restart": "स्थापनाक पुनारम्भ करी",
+ "config-page-readme": "पढू",
+ "config-page-releasenotes": "रिलीज नोट्स",
+ "config-page-copying": "अनुकरण",
+ "config-page-upgradedoc": "उपारोपण भऽ रहल अछि।",
+ "config-page-existingwiki": "रहल विकी",
+ "config-restart": "हँ, एकरा पुन: सुरु कएल जाए",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki home]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Read me</doclink>\n* <doclink href=ReleaseNotes>Release notes</doclink>\n* <doclink href=Copying>Copying</doclink>\n* <doclink href=UpgradeDoc>Upgrading</doclink>",
+ "config-env-good": "पर्यावरण क जाँच कएल गेल अछि।\nआहाँ मीडियाविकि स्थापित कर सकै चिए।",
+ "config-env-bad": "पर्यावरण क जाँच कएल गेल अछि।\nआहाँ मीडियाविकि स्थापित नै कर सकै चिए।",
+ "config-env-php": "PHP $1 स्थापित कएल ग्याल अछि।",
+ "mainpagetext": "<strong>मेडियाविकी नीक जकाँ प्रस्थापित भेल।</strong>",
+ "mainpagedocfooter": "सम्पर्क करी [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] विकि सफ्टवेयर प्रयोगक जानकारी लेल।\n\n==प्रारम्भ कोना करी==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/mdf.json b/www/wiki/includes/installer/i18n/mdf.json
new file mode 100644
index 00000000..725f497b
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mdf.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''МедиаВикить арафтозь лац.'''",
+ "mainpagedocfooter": "Ванк [https://meta.wikimedia.org/wiki/Help:Contents Ветямовал Тиинди] тяса ули кода содамс Вики програпнень эрявикснень колга.\n\n== Эрявикс сюлмафксне ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Васьфневи арафнематнень кярькссь]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ МедиаВикить Сидеста Кеподеви Кизефксне]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce МедиаВикить од верзиятнень колга кулянь пачфтема]"
+}
diff --git a/www/wiki/includes/installer/i18n/mfe.json b/www/wiki/includes/installer/i18n/mfe.json
new file mode 100644
index 00000000..0cd9b6e3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mfe.json
@@ -0,0 +1,45 @@
+{
+ "@metadata": {
+ "authors": [
+ "Moris231"
+ ]
+ },
+ "config-desc": "Programme installasion pu MediaWiki",
+ "config-title": "Installasion MediaWiki $1",
+ "config-information": "Informasion",
+ "config-localsettings-key": "Mis a zour lakle:",
+ "config-localsettings-badkey": "Lakle ki ou inn fourni inkorrekt.",
+ "config-your-language": "Ou langaz:",
+ "config-your-language-help": "Seleksionn enn langaz ki pu servi pendan prosesis installasion.",
+ "config-wiki-language": "Langaz Wiki:",
+ "config-wiki-language-help": "Seleksionn langaz dan ki Wiki pu prinsipalman ekrir.",
+ "config-back": "← Retourne",
+ "config-continue": "Kontinye →",
+ "config-page-language": "Langaz",
+ "config-page-welcome": "Bienvini lor MediaWiki!",
+ "config-page-dbconnect": "Konekte base donnee",
+ "config-page-dbsettings": "Paramets database",
+ "config-page-name": "Nom",
+ "config-page-options": "Opsion",
+ "config-page-install": "Installe",
+ "config-page-complete": "Termine!",
+ "config-page-restart": "Rekoumans installasion",
+ "config-page-readme": "Lir-mwa",
+ "config-page-releasenotes": "Notes verzion",
+ "config-page-copying": "Kopi",
+ "config-page-upgradedoc": "Mis a zour",
+ "config-page-existingwiki": "Wiki existan",
+ "config-restart": "Oui, rekoumans li",
+ "config-env-php": "PHP $1 inn finn installe.",
+ "config-env-hhvm": "HHVM $1 inn finn installe.",
+ "config-diff3-bad": "GNU diff3 introuvab.",
+ "config-db-type": "Type database:",
+ "config-db-host": "Hote database:",
+ "config-db-host-oracle": "Nom TNS database:",
+ "config-db-wiki-settings": "Idantifie sa wiki-la",
+ "config-db-name": "Nom base donnee:",
+ "config-db-name-oracle": "Schema base donnee:",
+ "config-db-install-account": "Kontt litilizater pu sa installasion",
+ "config-db-username": "Itilizater database:",
+ "config-db-password": "Password database:"
+}
diff --git a/www/wiki/includes/installer/i18n/mg.json b/www/wiki/includes/installer/i18n/mg.json
new file mode 100644
index 00000000..2a270fc3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mg.json
@@ -0,0 +1,88 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jagwar",
+ "Seb35"
+ ]
+ },
+ "config-desc": "Fandaharana mametraka an'i MediaWiki",
+ "config-title": "Fametrahana an'i MediaWiki $1",
+ "config-information": "Fampahalalana",
+ "config-localsettings-upgrade": "Hita ny <code>LocalSettings.php</code>.\nMba hanavao ity fametrahana ity, atsofohy ny sandan'i <code>$wgUpgradeKey</code> amin'ny saha eo ambany.\nHo hitanao eo amin'i <code>LocalSettings.php</code> ilay izy.",
+ "config-localsettings-key": "Lakile fanavaozana:",
+ "config-localsettings-badkey": "Diso ilay lakile fanavaozana natsofokao.",
+ "config-localsettings-connection-error": "Nisy hadisoana nitranga teo am-pametrahana ny fifandraisana amin'ny banky angona miaraka amin'ny parametatra voalaza ao amin'i <code>LocalSettings.php</code>. Vahao ny olan'ireo parametatra ireo dia avereno fanindroany.\n\n$1",
+ "config-session-error": "Hadisoana teo am-panombohana ny fidirana : $1",
+ "config-your-language": "Ny fiteninao :",
+ "config-your-language-help": "Hifidy ny teny ilaina amin'ny piraosesy fametrahana.",
+ "config-wiki-language": "Fiteny ho ampiasain'ny wiki :",
+ "config-wiki-language-help": "Hifidy ny teny hanoratana ny votoatin'ny wiki.",
+ "config-back": "← Miverina",
+ "config-continue": "Manohy →",
+ "config-page-language": "Fiteny",
+ "config-page-welcome": "Tonga soa eto amin'i MediaWiki !",
+ "config-page-dbconnect": "Hiditra eo amin'i banky angona",
+ "config-page-upgrade": "Hanavao ny fametrahana efa misy",
+ "config-page-dbsettings": "Parametatry ny banky angona",
+ "config-page-name": "Anarana",
+ "config-page-options": "Safidy",
+ "config-page-install": "Apetraka",
+ "config-page-complete": "Tapitra!",
+ "config-page-restart": "Hamerina ny fametrahana",
+ "config-page-readme": "Vakio aho",
+ "config-page-releasenotes": "Resaka mikasika ilay versiona",
+ "config-page-copying": "Hala-tahaka",
+ "config-page-upgradedoc": "Fanavaozina",
+ "config-page-existingwiki": "Wiki efa misy",
+ "config-help-restart": "Tianao hofafana avokoa ve ny data voaangona natsofokao ary hamerina ny fizotran'ny fametrahana ?",
+ "config-restart": "Eny, avereno atao",
+ "config-welcome": "=== Fanamarinana mikasika ny tontolo ===\nNy fanamarihana tsotsotra dia atao hijerena raha mety ho ana rindrankajy Mediawiki ny tontolo.\nTadidio ny mametraka ireto torohay ireo raha mitady fanohanana mikasika ny fomba famaranana ny fametrahana ianao.",
+ "config-copyright": "== Zom-pamorona ary fepetra ==\n\n$1\n\n\nIo fandaharana dia rindrambaiko maimaim-poana; dia afaka zarazarain ary ovaina araka ny fepetra ao amin'ny GNU General Public License navoakan'ny Free Software Foundation; na versiona 2 ao amin'ny lisansa, na (araka ny safidinao) versiona tatỳ aoriana.\n\nIo fandaharaa io dia zaraina amin'ny fanantenana fa ho ilaina, anefa kosa dia <strong>tsy misy fiantohana</strong>; tsy misy fiantohana mikasika ny <strong>fivarotana azy</strong> na <strong>famendrehana ho azo ampiasaina amin'ny tranga iray manokana</strong>.\nJereo ny GNU General Public License hahazoana zavatra amin'ny antsipiriany.\n\nIanao dia tokony nandray <doclink href=Copying> kôpian'nyGNU General Public License </doclink> miaraka amin'ny fandaharana ity; raha tsy izany, manorata any amin'ny Free Software Foundation, Inc., 51 Franklin Street, Fahadimy Floor, Boston, MA 02110-1301, USA, na [http://www.gnu.org/copyleft/gpl.html vakio ao amin'ny Internet izany].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki fandraisana]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Torolalan'ny mampiasa]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Torolalan'ny mpandrindra]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Fanontaniana mipetraka matetika]\n----\n* <doclink href=Readme>Vakio aho</doclink>\n* <doclink href=ReleaseNotes>Naoty famoahana</doclink>\n* <doclink href=Copying>Fandikàna</doclink>\n* <doclink href=UpgradeDoc>Fampihatsaràna</doclink>",
+ "config-env-good": "Voamarina ny tontolo.\nAfaka apetrakao i MediaWiki.",
+ "config-env-bad": "Voamarina ny tontolo.\nTsy afaka mametraka an'i MediaWiki ianao.",
+ "config-env-php": "Misy ato PHP $1.",
+ "config-env-hhvm": "Misy ato HHVM $1.",
+ "config-unicode-using-intl": "Mampiasa ny [http://pecl.php.net/intl itatra PECL intl] ho an'ny fampifenerana Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Fampitandremana: </strong> Ny [http://pecl.php.net/intl itatra PECL intl] dia tsy misy mba hahazakana ny fampifenerana Unicode, ka mitontona amin'ny implementasiona PHP ranoray noho ny tsifisiany.\nRaha hametraka tranonkala be mpamangy ianao dia tokony mamaky ny [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ''Unicode normalization''] (amin'ny teny anglisy)",
+ "config-db-type": "Karazana banky angona:",
+ "config-db-host": "Anaran'ny lohamilin'ny banky angona:",
+ "config-db-host-help": "Raha lohamila hafa ny lohamilin'ny banky angona, dia atsofohy eto ny anaran'ilay lohamilina na ny adiresy IP-ny.\n\nRaha mampiasa fampiantranoana iombonana ianao dia tokony hanome anao ny anaran-dohamilina izy ao amin'ny toromariny.\n\nRaha mametraka amin'ny lohamilina Windows ianao sady mampiasa MySQL, dia mety tsy mandeha ny anaran-dohamilina \"localhost\". Raha tsy mandeha ilay izy dia \"127.0.0.1\" no atao adiresy IP an-toerana.\n\nRaha mampiasa PostgreSQL ianao, dia avelaho ho fotsy ity saha ity ahafahana mifandray amin'ny alalan'ny socket Unix.",
+ "config-db-host-oracle": "TNS an'ny banky angona:",
+ "config-db-username": "Anaram-pikamban'ny banky angona :",
+ "config-db-password": "Tenimiafin'ny banky angona :",
+ "config-db-prefix": "Tovom-banky angona:",
+ "config-db-port": "Seranam-banky angona:",
+ "config-header-mysql": "Parametatr'i MySQL",
+ "config-header-postgres": "Parametatra PostgreSQL",
+ "config-header-sqlite": "Parametatr'i SQLite",
+ "config-header-oracle": "Parametatr'i Oracle",
+ "config-header-mssql": "Parametatry ny lohamilina Microsoft SQL Server",
+ "config-invalid-db-type": "Karazana banky angona tsy ekena.",
+ "config-mysql-innodb": "innoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-ns-generic": "Tetikasa",
+ "config-ns-other": "Hafa (lazao)",
+ "config-admin-name": "Ny anaranao :",
+ "config-admin-password": "Tenimiafina :",
+ "config-admin-email": "Adiresy imailaka :",
+ "config-profile-wiki": "Wiki tsotra",
+ "config-profile-no-anon": "Mila mamorona kaonty",
+ "config-profile-fishbowl": "Mpanova mahazo alalana ihany",
+ "config-profile-private": "Wiki tsy sarababem-bahoaka",
+ "config-license": "Zom-pamorona ary lisansa :",
+ "config-license-none": "Tsy misy lisansa any an-tongom-pejy",
+ "config-email-user": "Avela mifandefa imailaka ny mpikambana",
+ "config-email-user-help": "Hahafahan'ny mpikambana mifandefa imailaka raha omen'ny mpikambana alalana ao amin'ny safidiny.",
+ "config-upload-deleted": "Petra-drakitra ho an'ny rakitra voafafa :",
+ "config-extensions": "Fanitarana",
+ "config-install-step-done": "vita",
+ "config-install-step-failed": "hadisoana",
+ "config-install-user": "Famoronana mpapiasan'ny banky angona",
+ "config-install-tables": "Famoronana tabilao",
+ "config-install-stats": "Fanombohana ny statistika",
+ "config-install-keys": "Fanamboarana lakile miafina",
+ "config-help": "fanoroana",
+ "mainpagetext": "'''Tafajoro soa aman-tsara ny rindrankajy Wiki.'''",
+ "mainpagedocfooter": "Vangio ny [https://meta.wikimedia.org/wiki/Help:Contents/fr Fanoroana ho an'ny mpampiasa] ra te hitady fanoroana momba ny fampiasan'ity rindrankajy ity.\n\n== Hanomboka amin'ny MediaWiki ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lisitra ny paramètre de configuration]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/fr FAQ momba ny MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Resaka momba ny fizaràn'ny MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/mhr.json b/www/wiki/includes/installer/i18n/mhr.json
new file mode 100644
index 00000000..269f0aab
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mhr.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki сай шындыме.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/min.json b/www/wiki/includes/installer/i18n/min.json
new file mode 100644
index 00000000..f4249602
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/min.json
@@ -0,0 +1,11 @@
+{
+ "@metadata": {
+ "authors": [
+ "Iwan Novirion",
+ "Luthfi94",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki alah tapasang jo sukses'''.",
+ "mainpagedocfooter": "Konsultasian [https://meta.wikimedia.org/wiki/Help:Contents Panduan Panggunoan] untuak informasi caro panggunoan parangkaik lunak wiki.\n\n== Mamulai panggunoan ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings/id Daftar pangaturan konfigurasi]\n* [https://www.mediawiki.org/wiki/Manual:FAQ/id Daftar patanyoan nan acok diajukan manganai MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Milis rilis MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Palokalan MediaWiki untuak bahaso Sanak]"
+}
diff --git a/www/wiki/includes/installer/i18n/mk.json b/www/wiki/includes/installer/i18n/mk.json
new file mode 100644
index 00000000..d028a8c4
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mk.json
@@ -0,0 +1,321 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bjankuloski06",
+ "아라",
+ "Macofe",
+ "Srdjan m"
+ ]
+ },
+ "config-desc": "Воспоставувачот на МедијаВики",
+ "config-title": "Воспоставка на МедијаВики $1",
+ "config-information": "Информации",
+ "config-localsettings-upgrade": "Востановена е податотека <code>LocalSettings.php</code>.\nЗа да ја надградите инсталцијава, внесете ја вредноста на <code>$wgUpgradeKey</code> во полето подолу.\nТоа е го најдете во <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Утврдено е присуството на податотеката „<code>LocalSettings.php</code>“.\nЗа да ја надградите воспоставката, пуштете ја „<code>update.php</code>“ наместо горенаведената.",
+ "config-localsettings-key": "Надградбен клуч:",
+ "config-localsettings-badkey": "Надградбениот клуч што го наведовте е погрешен.",
+ "config-upgrade-key-missing": "Востановена е постоечка воспоставка на МедијаВики.\nЗа да ја надградите, вметнете го следниов ред на дното од вашата страница <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Постоечката страница <code>LocalSettings.php</code> е нецелосна.\nНе е поставена променливата $1.\nИзменете ја страницата <code>LocalSettings.php</code> така што ќе ѝ зададете вредност на променливата, па стиснете на „{{int:Config-continue}}“.",
+ "config-localsettings-connection-error": "Се појави грешка при поврзувањето со базата користејќи ги поставките назначени во <code>LocalSettings.php</code>. Исправете ги овие поставки и обидете се повторно.\n\n$1",
+ "config-session-error": "Грешка при започнување на седницата: $1",
+ "config-session-expired": "Вашите податоци од седницата истекоа.\nПоставките на седниците траат $1.\nНивниот рок можете да го зголемите со задавање на <code>session.gc_maxlifetime</code> во php.ini.\nПочнете ја воспоставката одново.",
+ "config-no-session": "Податоците од седницата се изгубени!\nПогледајте во php.ini дали <code>session.save_path</code> е поставен во правилна папка.",
+ "config-your-language": "Вашиот јазик:",
+ "config-your-language-help": "Одберете на кој јазик да се одвива воспоставката.",
+ "config-wiki-language": "Јазик на викито:",
+ "config-wiki-language-help": "Одберете на кој јазик ќе бидат содржините на викито.",
+ "config-back": "← Назад",
+ "config-continue": "Продолжи →",
+ "config-page-language": "Јазик",
+ "config-page-welcome": "Добре дојдовте на МедијаВики!",
+ "config-page-dbconnect": "Поврзување со базата",
+ "config-page-upgrade": "Надградба на постоечката воспоставка",
+ "config-page-dbsettings": "Нагодувања на базата",
+ "config-page-name": "Назив",
+ "config-page-options": "Поставки",
+ "config-page-install": "Воспостави",
+ "config-page-complete": "Готово!",
+ "config-page-restart": "Пушти ја воспоставката одново",
+ "config-page-readme": "Прочитај ме",
+ "config-page-releasenotes": "Белешки за изданието",
+ "config-page-copying": "Копирање",
+ "config-page-upgradedoc": "Надградба",
+ "config-page-existingwiki": "Постоечко вики",
+ "config-help-restart": "Дали сакате да ги исчистите сите зачувани податоци што ги внесовте и да ја започнете воспоставката одново?",
+ "config-restart": "Да, почни одново",
+ "config-welcome": "=== Проверки на околината ===\nСега ќе се извршиме основни проверки за да се востанови дали околината е погодна за воспоставкa на МедијаВики. Не заборавајте да ги приложите овие информации ако барате помош со довршување на воспоставката.",
+ "config-copyright": "=== Авторски права и услови ===\n\n$1\n\nОва е слободна програмска опрема (free software); можете да го редистрибуирате и/или менувате согласно условите на ГНУ-овата општа јавна лиценца (GNU General Public License) на Фондацијата за слободна програмска опрема (Free Software Foundation); верзија 2 или било која понова верзија на лиценцата (по ваш избор).\n\nОвој програм се нуди со надеж дека ќе биде корисен, но '''без никаква гаранција'''; дури ни подразбраната гаранција за '''продажна способност''' или '''погодност за определена цел'''.\nПовеќе информации ќе најдете во текстот на ГНУ-овата општа јавна лиценца.\n\nБи требало да имате добиено <doclink href=Copying>примерок од ГНУ-овата општа јавна лиценца</doclink> заедно со програмов; ако немате добиено, тогаш пишете ни на Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. или [http://www.gnu.org/copyleft/gpl.html прочитајте ја тука].",
+ "config-sidebar": "* [https://www.mediawiki.org Домашна страница на МедијаВики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Водич за корисници]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Водич за администратори]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ЧПП]\n----\n* <doclink href=Readme>Прочитај ме</doclink>\n* <doclink href=ReleaseNotes>Белешки за изданието</doclink>\n* <doclink href=Copying>Копирање</doclink>\n* <doclink href=UpgradeDoc>Надградување</doclink>",
+ "config-env-good": "Околината е проверена.\nМожете да го воспоставите МедијаВики.",
+ "config-env-bad": "Околината е проверена.\nНе можете да го воспоставите МедијаВики.",
+ "config-env-php": "PHP $1 е воспоставен.",
+ "config-env-hhvm": "HHVM $1 е воспоставен.",
+ "config-unicode-using-intl": "Со додатокот [http://pecl.php.net/intl intl PECL] за уникодна нормализација.",
+ "config-unicode-pure-php-warning": "'''Предупредување''': Додатокот [http://pecl.php.net/intl intl PECL] не е достапен за врши уникодна нормализација, враќајќи се на бавна примена на чист PHP.\n\nАко имате високопрометно мрежно место, тогаш ќе треба да прочитате повеќе за [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations уникодната нормализација].",
+ "config-unicode-update-warning": "'''Предупредување:''' Воспоставената верзија на обвивката за уникодна нормализација користи постара верзија на библиотеката на [http://site.icu-project.org/ проектот ICU].\nЗа да користите Уникод, ќе треба да направите [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations надградба].",
+ "config-no-db": "Не можев да најдам соодветен двигател за базата на податоци! Ќе треба да воспоставите двигател за PHP-база.\n{{PLURAL:$2|Поддржан се следниов вид|Поддржани се следниве видови}} бази: $1.\n\nДоколку самите го срочивте овој PHP, овозможете го базниот клиент во поставките — на пр. со <code>./configure --with-mysqli</code>.\nАко овој PHP го воспоставите од пакет на Debian или Ubuntu, тогаш ќе треба исто така да го воспоставите, на пр., пакетот <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Предупредување''': имате SQLite $1. Најстарата допуштена верзија е $2. Затоа, SQLite ќе биде недостапен.",
+ "config-no-fts3": "'''Предупредување''': SQLite iе составен без модулот [//sqlite.org/fts3.html FTS3] - за оваа база нема да има можност за пребарување.",
+ "config-pcre-old": "'''Кобно:''' Се бара PCRE $1 или понова верзија.\nВашиот PHP-бинарен е сврзан со PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Повеќе информации].",
+ "config-pcre-no-utf8": "<strong>Кобно</strong>: PCRE-модулот на PHP е срочен без поддршка за PCRE_UTF8.\nМедијаВики бара поддршка за UTF-8 за да може да работи правилно.",
+ "config-memory-raised": "<code>memory_limit</code> за PHP изнесува $1, зголемен на $2.",
+ "config-memory-bad": "<strong>Предупредување:</strong> <code>memory_limit</code> за PHP изнесува $1.\nОва е веројатно премалку.\nВоспоставката може да не успее!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] е воспоставен",
+ "config-apc": "[http://www.php.net/apc APC] е воспоставен",
+ "config-apcu": "[http://www.php.net/apcu APCu] е воспоставен",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] е воспоставен",
+ "config-no-cache-apcu": "<strong>Предупредување:</strong> Не можев да го најдам [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] или [http://www.iis.net/download/WinCacheForPhp WinCache].\nМеѓускладирањето на објекти не е овозможено",
+ "config-mod-security": "'''Предупредување''': на вашиот опслужувач има овозможено [http://modsecurity.org/ mod_security]. Ако не е поставено како што треба, ова може да предизвика проблеми кај МедијаВики и други програми што им овозможуваат на корисниците да објавуваат произволни содржини.\nПогледнете ја [http://modsecurity.org/documentation/ mod_security документацијата] или обратете се кај домаќинот ако наидете на случајни грешки.",
+ "config-diff3-bad": "GNU diff3 не е пронајден.",
+ "config-git": "Го пронајдов Git програмот за контрола на верзии: <code>$1</code>.",
+ "config-git-bad": "Не го пронајдов Git-програмот за контрола на верзии.",
+ "config-imagemagick": "Пронајден е ImageMagick: <code>$1</code>.\nАко овозможите подигање, тогаш ќе биде овозможена минијатуризација на сликите.",
+ "config-gd": "Утврдив дека има вградена GD графичка библиотека.\nАко овозможите подигање, тогаш ќе биде овозможена минијатураизација на сликите.",
+ "config-no-scaling": "Не можев да пронајдам GD-библиотека или ImageMagick.\nМинијатуризацијата на сликите ќе биде оневозможена.",
+ "config-no-uri": "<strong>Грешка:</strong> Не можев да го утврдам тековниот URI.\nВоспоставката е откажана.",
+ "config-no-cli-uri": "'''Предупредување''': Нема наведено <code>--scriptpath</code>. Ќе се користи основниот: <code>$1</code>.",
+ "config-using-server": "Користите опслужувач под името „<nowiki>$1</nowiki>“.",
+ "config-using-uri": "Користите опслужувач со URL-адреса „<nowiki>$1$2</nowiki>“.",
+ "config-uploads-not-safe": "'''Предупредување:''' Вашата матична папка за подигање <code>$1</code> е подложна на извршување (пуштање) на произволни скрипти.\nИако МедијаВики врши безбедносни проверки на сите подигнати податотеки, ве советуваме [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security да ја затворите оваа безбедносна дупка] пред да овозможите подигање.",
+ "config-no-cli-uploads-check": "'''Предупредување:''' Вашата основна папка за подигања (<code>$1</code>) не е проверена дали е подложна\nпроизволно извршување на скрипти во текот на воспоставката на посредникот на повикувачко ниво (CLI).",
+ "config-brokenlibxml": "Вашиот систем има комбинација од PHP и libxml2 верзии и затоа има грешки и може да предизвика скриено расипување на податоците кај МедијаВики и други мрежни програми.\nНадградете го на libxml2 2.7.3 или нивни понови верзии! ([https://bugs.php.net/bug.php?id=45996 грешката е заведена во PHP]). Воспоставката е откажана.",
+ "config-suhosin-max-value-length": "Suhosin е воспоставен и ја ограничува должината на параметарот GET на $1 бајти. Делот ResourceLoader на МедијаВики ќе ја заобиколува ова граница, но со тоа ќе се влоши делотворноста. Ако е воопшто можно, на <code>suhosin.get.max_value_length</code> треба да го наместите на 1024 или повеќе во <code>php.ini</code>, и да му ја зададете истата вредност на <code>$wgResourceLoaderMaxQueryLength</code> во <code>LocalSettings.php</code>.",
+ "config-using-32bit": "<strong>Предупредување:</strong> вашиот систем работи на 32-битни цели броеви. Ова [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-не се препорачува].",
+ "config-db-type": "Тип на база:",
+ "config-db-host": "Домаќин на базата:",
+ "config-db-host-help": "Ако вашата база е на друг опслужувач, тогаш тука внесете го името на домаќинот или IP-адресата.\n\nАко користите заедничко (споделено) вдомување, тогаш вашиот вдомител треба да го наведе точното име на домаќинот во неговата документација.\n\nАко воспоставувате на опслужувач на Windows и користите MySQL, можноста „localhost“ може да не функционира за опслужувачкото име. Во тој случај, обидете се со внесување на „127.0.0.1“ како месна IP-адреса.\n\nАко користите PostgreSQL, оставете го полево празно за да се поврзете преку Unix-приклучок.",
+ "config-db-host-oracle": "TNS на базата:",
+ "config-db-host-oracle-help": "Внесете важечко [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm месно име за поврзување]. На оваа воспоставка мора да ѝ биде видлива податотеката tnsnames.ora.<br />Ако користите клиентски библиотеки 10g или понови, тогаш можете да го користите и методот на иметнување на [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Идентификувај го викиво",
+ "config-db-name": "Име на базата:",
+ "config-db-name-help": "Одберете име што ќе го претставува вашето вики.\nИмето не смее да содржи празни места.\n\nАко користите заедничко (споделено) вдомување, тогаш вашиот вдомител ќе ви даде конкретно име на база за користење, или пак ќе ви даде да создавате бази преку управувачницата.",
+ "config-db-name-oracle": "Шема на базата:",
+ "config-db-account-oracle-warn": "Постојат три поддржани сценарија за воспоставка на Oracle како базен услужник:\n\nАко сакате да создадете сметка на базата како дел од постапката за воспоставка, наведете сметка со SYSDBA-улога како сметка за базата што ќе се воспостави и наведете ги саканите податоци за сметката за мрежен пристап. Во друг случај, можете да создадете сметка за мрежен пристап рачно и да ја наведете само таа сметка (ако има дозволи за создавање на шематски објекти) или пак да наведете две различни сметки, една со привилегии за создавање, а друга (ограничена) за мрежен пристап.\n\nСкриптата за создавање сметка со задолжителни привилегии ќе ја најдете во папката „maintenance/oracle/“ од оваа воспоставка. Имајте на ум дека ако користите ограничена сметка ќе ги оневозможите сите функции за одржување со основната сметка.",
+ "config-db-install-account": "Корисничка смета за воспоставка",
+ "config-db-username": "Корисничко име за базата:",
+ "config-db-password": "Лозинка за базата:",
+ "config-db-install-username": "Внесете корисничко име што ќе се користи за поврзување со базата во текот на воспоставката. Ова не е корисничкото име од сметката на МедијаВики, туку посебно корисничко име за вашата база на податоци.",
+ "config-db-install-password": "Внесете клозинка што ќе се користи за поврзување со базата во текот на воспоставката. Ова не е лозинката од сметката на МедијаВики, туку посебна лозинка за вашата база на податоци.",
+ "config-db-install-help": "Внесете го корисничкото име и лозинката што ќе се користи за поврзување со базата на податоци во текот на воспоставката.",
+ "config-db-account-lock": "Користи го истото корисничко име и лозинка за редовна работа",
+ "config-db-wiki-account": "Корисничко име за редовна работа",
+ "config-db-wiki-help": "Внесете корисничко име и лозинка што ќе се користат за поврзување со базата на податоци во текот на редовната работа со викито.\nАко сметката не постои, а инсталационата сметка има доволно привилегии, тогаш оваа корисничка сметка ќе биде создадена со најмалите привилегии потребни за работа со викито.",
+ "config-db-prefix": "Претставка на табелата на базата:",
+ "config-db-prefix-help": "Ако треба да делите една база на податоци со повеќе викија, или со МедијаВики и друг мрежен програм, тогаш можете да додадете претставка на сите називи на табелите за да спречите проблематични ситуации.\nНе користете празни простори.\n\nОва поле обично се остава празно.",
+ "config-mysql-old": "Се бара MySQL $1 или поново, а вие имате $2.",
+ "config-db-port": "Порта на базата:",
+ "config-db-schema": "Шема за МедијаВики",
+ "config-db-schema-help": "Оваа шема обично по правило ќе работи нормално.\nСменете ја само ако знаете дека треба да се смени.",
+ "config-pg-test-error": "Не можам да се поврзам со базата <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Папка на SQLite-податоци:",
+ "config-sqlite-dir-help": "SQLite ги складира сите податоци во една податотека.\n\nПапката што ќе ја наведете мора да е запислива од мрежниот опслужувач во текот на воспоставката.\n\nТаа '''не''' смее да биде достапна преку семрежјето, и затоа не ја ставаме кајшто ви се наоѓаат PHP-податотеките.\n\nВоспоставувачот воедно ќе создаде податотека <code>.htaccess</code>, но ако таа не функционира како што треба, тогаш некој ќе може да ви влезе во вашата необработена (сирова) база на податоци.\nТука спаѓаат необработени кориснички податоци (е-поштенски адреси, хеширани лозинки) како и избришани преработки и други податоци за викито до кои се има ограничен пристап.\n\nСе препорачува целата база да ја сместите некаде, како на пр. <code>/var/lib/mediawiki/вашетовики</code>.",
+ "config-oracle-def-ts": "Стандарден таблеарен простор:",
+ "config-oracle-temp-ts": "Привремен табеларен простор:",
+ "config-type-mysql": "MySQL (или складно)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "МедијаВики ги поддржува следниве системи на бази на податоци:\n\n$1\n\nАко системот што сакате да го користите не е наведен подолу, тогаш проследете ја горенаведената врска со инструкции за да овозможите поддршка за тој систем.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] е главната цел на МедијаВики и најдобро е поддржан. МедијаВики работи и со [{{int:version-db-mariadb-url}} MariaDB] и [{{int:version-db-percona-url}} Percona], кои се складни со MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Како да срочите PHP со поддршка за MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] е популарен систем на бази на податоци со отворен код кој претставува алтернатива на MySQL ([http://www.php.net/manual/en/pgsql.installation.php како да составите PHP со поддршка за PostgreSQL]). ([http://www.php.net/manual/en/pgsql.installation.php Како да срочите PHP со поддршка за PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] е лесен систем за бази на податоци кој е многу добро поддржан. ([http://www.php.net/manual/en/pdo.installation.php Како да составите PHP со поддршка за SQLite], користи PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] е база на податоци на комерцијално претпријатие. ([http://www.php.net/manual/en/oci8.installation.php Како да составите PHP со поддршка за OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] е база на податоци на комерцијално претпријатиe за Windows ([http://www.php.net/manual/en/sqlsrv.installation.php How to compile PHP with SQLSRV поддршка])",
+ "config-header-mysql": "Нагодувања на MySQL",
+ "config-header-postgres": "Нагодувања на PostgreSQL",
+ "config-header-sqlite": "Нагодувања на SQLite",
+ "config-header-oracle": "Нагодувања на Oracle",
+ "config-header-mssql": "Нагодувања за Microsoft SQL Server",
+ "config-invalid-db-type": "Неважечки тип на база",
+ "config-missing-db-name": "Мора да внесете значење за параметарот „{{int:config-db-name}}“.",
+ "config-missing-db-host": "Мора да внесете вредност за „{{int:config-db-host}}“.",
+ "config-missing-db-server-oracle": "Мора да внесете вредност за „{{int:config-db-host-oracle}}“.",
+ "config-invalid-db-server-oracle": "Неважечки TNS „$1“.\nКористете или „TNS Name“ или низата „Easy Connect“ ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Методи на именување за Oracle])",
+ "config-invalid-db-name": "Неважечко име на базата „$1“.\nКористете само ASCII-букви (a-z, A-Z), бројки (0-9), долни црти (_) и цртички (-).",
+ "config-invalid-db-prefix": "Неважечка претставка за базата „$1“.\nКористете само ASCII-букви (a-z, A-Z), бројки (0-9), долни црти (_) и цртички (-).",
+ "config-connection-error": "$1.\n\nПроверете го долунаведениот домаќин, корисничко име и лозинка и обидете се повторно.",
+ "config-invalid-schema": "Неважечка шема за МедијаВики „$1“.\nКористете само букви, бројки и долни црти.",
+ "config-db-sys-create-oracle": "Воспоставувачот поддржува само употреба на SYSDBA-сметка за создавање на нова сметка.",
+ "config-db-sys-user-exists-oracle": "Корисничката сметка „$1“ веќе постои. SYSDBA служи само за создавање на нова сметка!",
+ "config-postgres-old": "Се бара PostgreSQL $1 или поново, а вие имате $2.",
+ "config-mssql-old": "Се бара Microsoft SQL Server $1 или понова верзија. Вие имате $2.",
+ "config-sqlite-name-help": "Одберете име кое ќе го претставува вашето вики.\nНе користете празни простори и црти.\nОва ќе се користи за податотечното име на SQLite-податоците.",
+ "config-sqlite-parent-unwritable-group": "Не можам да ја создадам папката <code><nowiki>$1</nowiki></code> бидејќи мрежниот опслужувач не може да запише во матичната папка <code><nowiki>$2</nowiki></code>.\n\nВоспоставувачот го утврди корисникот под кој работи вашиот мрежен опслужувач.\nЗа да продолжите, наместете да може да запишува во папката <code><nowiki>$3</nowiki></code>.\nНа Unix/Linux систем направете го следново:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Не можам да ја создадам папката <code><nowiki>$1</nowiki></code> бидејќи мрежниот опслужувач не може да запише во матичната папка <code><nowiki>$2</nowiki></code>.\n\nВоспоставувачот не можеше го утврди корисникот под кој работи вашиот мрежен опслужувач.\nЗа да продолжите, наместете тој (и други!) да може глобално да запишува во папката <code><nowiki>$3</nowiki></code>\nНа Unix/Linux систем направете го следново:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Грешка при создавањето на податочната папка „$1“.\nПроверете каде се наоѓа и обидете се повторно.",
+ "config-sqlite-dir-unwritable": "Не можам да запишам во папката „$1“.\nВо дозволите за неа, овозможете му на мрежниот опслужувач да запишува во неа и обидете се повторно.",
+ "config-sqlite-connection-error": "$1.\n\nПроверете ја податочната папка и името на базата, и обидете се повторно.",
+ "config-sqlite-readonly": "Податотеката <code>$1</code> е незапислива.",
+ "config-sqlite-cant-create-db": "Не можев да ја создадам податотеката <code>$1</code> за базата.",
+ "config-sqlite-fts3-downgrade": "PHP нема поддршка за FTS3 — ја поништувам надградбата за табелите",
+ "config-can-upgrade": "Во оваа база има табели на МедијаВики.\nЗа да ги надградите на МедијаВики $1, стиснете на '''Продолжи'''.",
+ "config-upgrade-done": "Надградбата заврши.\n\nСега можете да [$1 почнете да го користите вашето вики].\n\nАко сакате да ја пресоздадете вашата податотека <code>LocalSettings.php</code>, тогаш стиснете на копчето подолу.\nОва '''не се препорачува''' освен во случај на проблеми со викито.",
+ "config-upgrade-done-no-regenerate": "Надградбата заврши.\n\nСега можете да [$1 почнете да го користите викито].",
+ "config-regenerate": "Пресоздај LocalSettings.php →",
+ "config-show-table-status": "Барањето <code>SHOW TABLE STATUS</code> не успеа!",
+ "config-unknown-collation": "'''Предупредување:''' Базата корисни непрепознаена упатна споредба.",
+ "config-db-web-account": "Сметка на базата за мрежен пристап",
+ "config-db-web-help": "Одберете корисничко име и лозинка што ќе ги користи мрежниот опслужувач за поврзување со опслужувачот на базта на податоци во текот на редовната работа со викито.",
+ "config-db-web-account-same": "Користи ја истата сметка од воспоставката",
+ "config-db-web-create": "Создај ја сметката ако веќе не постои",
+ "config-db-web-no-create-privs": "Сметката што ја назначивте за воспоставка нема доволно привилегии за да може да создаде сметка.\nТука мора да назначите постоечка сметка.",
+ "config-mysql-engine": "Складишен погон:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Предупредување:''' Го одбравте MyISAM како складишен погон за MySQL. Но тој не се препорачува за МедијаВики бидејќи:\n* одвај поддржува едновременост поради заклучување на табелите\n* поподложен на расипување од другите погони\n* кодната база на МедијаВики не секогаш може да работи со MyISAM како што треба\n\nАко вашата воспоставка на MySQL поддржува InnoDB, тогаш сериозно препорачуваме да го користите него наместо MyISAM.\nАко вашата воспоставка на MySQL не поддржува InnoDB, веројатно дошло време за надградба.",
+ "config-mysql-only-myisam-dep": "'''Предупредување:''' MyISAM е единствениот достапен складишен погон за MySQL на оваа машина, а ова не се препорачува за употреба со МедијаВики, бидејќи:\n* речиси не поддржува истовремено извршување на задачите поради заклучувањето на табелите\n* поподложен е на расипувања од другите погони\n* кодната база на МедијаВИки не секогаш работи исправно со MyISAM\nВашата воспоставка на MySQL не поддржува InnoDB. Можеби е време да ја надградите.",
+ "config-mysql-engine-help": "'''InnoDB''' речиси секогаш е најдобар избор, бидејќи има добра поддршка за едновременост.\n\n'''MyISAM''' може да е побрз кај воспоставките наменети за само еден корисник или незаписни воспоставки (само читање).\nБазите на податоци од MyISAM почесто се расипуваат од базите на InnoDB.",
+ "config-mysql-charset": "Збир знаци за базата:",
+ "config-mysql-binary": "Бинарен",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Во '''бинарен режим''', во базата на податоци МедијаВики складира UTF-8 текст во бинарни полиња.\nОва е поефикасно отколку TF-8 режимот на MySQL, и ви овозможува да ја користите целата палета на уникодни знаци.\n\nВо '''UTF-8 режим''', MySQL ќе знае на кој збир знаци припаѓаат вашите податоци, и може соодветно да ги претстави и претвори, но нема да ви дозволи да складиратезнаци над [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Основната повеќејазична рамнина].",
+ "config-mssql-auth": "Тип на заверка:",
+ "config-mssql-install-auth": "Изберете го типот на заверка што ќе се користи за поврзување со базата на податоци во текот на воспоставката.\nАко изберете „{{int:config-mssql-windowsauth}}“, ќе се користат најавните податоци или корисникот како кој работи мрежниот опслужувач.",
+ "config-mssql-web-auth": "Изберете го типот на заверка што мрежниот послужувач ќе го користи за поврзување со опслужувачот на базата во текот на редовната работа на викито.\nАко изберете „{{int:config-mssql-windowsauth}}“, ќе се користат најавните податоци или корисникот како кој работи мрежниот опслужувач.",
+ "config-mssql-sqlauth": "Заверка за SQL Server",
+ "config-mssql-windowsauth": "Заверка за Windows",
+ "config-site-name": "Име на викито:",
+ "config-site-name-help": "Ова ќе се појавува во заглавната лента на прелистувачот и на разни други места.",
+ "config-site-name-blank": "Внесете име на мрежното место.",
+ "config-project-namespace": "Проектен именски простор:",
+ "config-ns-generic": "Проект",
+ "config-ns-site-name": "Исто име како викито: $1",
+ "config-ns-other": "Друго (наведете)",
+ "config-ns-other-default": "МоеВики",
+ "config-project-namespace-help": "По примерот на Википедија, многу викија ги чуваат страниците со правила на посебно место од самите содржини, т.е. во „'''проектен именски простор'''“.\nСите наслови на страниците во овој именски простор почнуваат со извесна претставка, којшто можете да го укажете тука.\nПо традиција претставката произлегува од името на викито, но не смее да содржи интерпункциски знаци како „#“ или „:“.",
+ "config-ns-invalid": "Назначениот именски простор „<nowiki>$1</nowiki>“ е неважечки.\nНазначете друг проектен именски простор.",
+ "config-ns-conflict": "Наведениот именски простор „<nowiki>$1</nowiki>“ се коси со основниот именски простор на МедијаВики.\nНаведете друг именски простор за проектот.",
+ "config-admin-box": "Администратоска сметка",
+ "config-admin-name": "Вашето корисничко име:",
+ "config-admin-password": "Лозинка:",
+ "config-admin-password-confirm": "Пак лозинката:",
+ "config-admin-help": "Тука внесете го вашето корисничко име, на пр. „Петар Петровски“.\nОва име ќесе користи за најава во викито.",
+ "config-admin-name-blank": "Внесете администраторско корисничко име.",
+ "config-admin-name-invalid": "Назначенотго корисничко име „<nowiki>$1</nowiki>“ е неважечко.\nНазначете друго.",
+ "config-admin-password-blank": "Внесете лозинка за администраторската сметка",
+ "config-admin-password-mismatch": "Лозинките што ги внесовте не се совпаѓаат.",
+ "config-admin-email": "Е-поштенска адреса:",
+ "config-admin-email-help": "Тука внесете е-поштенска адреса за да можете да добивате е-пошта од други корисници на викито, да ја менувате лозинката, и да бидете известувани за промени во страниците на вашиот список на набљудувања. Можете и да го оставите празно.",
+ "config-admin-error-user": "Се појави внатрешна грешка при создавањето на администраторот со име „<nowiki>$1</nowiki>“.",
+ "config-admin-error-password": "Се појави внатрешна грешка при задавање на лозинката за администраторот „<nowiki>$1</nowiki>“: <pre>$2</pre>",
+ "config-admin-error-bademail": "Внесовте неважечка е-поштенска адреса",
+ "config-subscribe": "Претплатете се на [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce release поштенскиот список за известувања].",
+ "config-subscribe-help": "Ова е нископрометен поштенски список кој се користи за соопштувања во врска со изданија, вклучувајќи важни безбедносни соопштенија.\nТреба да се претплатите и да ја надградувате вашата воспоставка на МедијаВики кога излегуваат нови верзии.",
+ "config-subscribe-noemail": "Се обидовте да се претплатите на поштенскиот список со известувања за нови изданија без да наведете е-пошта.\nНаведете е-поштенска адреса ако сакате да се претплатите на списокот.",
+ "config-pingback": "Споделувај податоци за воспоставката со разработувачите на МедијаВики.",
+ "config-pingback-help": "Ако ја изберете оваа можност, МедијаВики повремено ќе му испраќа на https://www.mediawiki.org основни податоци за овој примерок на МедијаВики. Тука спаѓаат видот на системот, PHP-верзијата и избраната базна заднина. Фондацијата Викимедија ги споделува овие податоци со разработувачите на МедијаВики со цел да им даде насоки за разработка во идните верзии. За вашиот систем ќе се испратат следниве податоци:\n<pre>$1</pre>",
+ "config-almost-done": "Уште малку сте готови!\nСега можете да ги прескокнете преостанатите поставувања и веднаш да го воспоставите викито.",
+ "config-optional-continue": "Постави ми повеќе прашања.",
+ "config-optional-skip": "Веќе ми здосади, дај само воспостави го викито.",
+ "config-profile": "Профил на кориснички права:",
+ "config-profile-wiki": "Отворено вики",
+ "config-profile-no-anon": "Задолжително отворање сметка",
+ "config-profile-fishbowl": "Само овластени уредници",
+ "config-profile-private": "Лично вики",
+ "config-profile-help": "Викијата функционираат најдобро кога имаат што повеќе уредници.\nВо МедијаВики лесно се проверуваат скорешните промени, и лесно се исправа (технички: „враќа“) штетата направена од неупатени или злонамерни корисници.\n\nМногумина имаат најдено најразлични полезни примени за МедијаВики, но понекогаш не е лесно да убедите некого во предностите на вики-концептот.\nЗначи имате избор.\n\n'''{{int:config-profile-wiki}}''' — модел според кој секој може да уредува, дури и без најавување.\nАко имате вики со '''задолжително отворање на сметка''', тогаш добивате повеќе контрола, но ова може даги одврати спонтаните учесници.\n\n'''{{int:config-profile-fishbowl}}''' — може да уредуваат само уредници што имаат добиено дозвола за тоа, но јавноста може да ги гледа страниците, вклучувајќи ја нивната историја.\n'''{{int:config-profile-private}}''' — страниците се видливи и уредливи само за овластени корисници.\n\nПо воспоставката имате на избор и посложени кориснички права и поставки. Погледајте во [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights прирачникот].",
+ "config-license": "Авторски права и лиценца:",
+ "config-license-none": "Без подножје за лиценца",
+ "config-license-cc-by-sa": "Криејтив комонс Наведи извор-Сподели под исти услови",
+ "config-license-cc-by": "Криејтив комонс Наведи извор",
+ "config-license-cc-by-nc-sa": "Криејтив комонс Наведи извор-Сподели под исти услови",
+ "config-license-cc-0": "Криејтив комонс Нула (јавна сопственост)",
+ "config-license-gfdl": "ГНУ-ова лиценца за слободна документација 1.3 или понова",
+ "config-license-pd": "Јавна сопственост",
+ "config-license-cc-choose": "Одберете друга лиценца на Криејтив комонс по ваш избор",
+ "config-license-help": "Многу јавни викија ги ставаат сите придонеси под [http://freedomdefined.org/Definition слободна лиценца].\nСо ова се создава атмосфера на општа сопственост и поттикнува долгорочно учество.\nОва не е неопходно за викија на поединечни физички или правни лица.\n\nАко сакате да користите текст од Википедија, и сакате Википедија да прифаќа текст прекопиран од вашето вики, тогаш треба да ја одберете лиценцата <strong>{{int:config-license-cc-by-sa}}</strong>..\n\nГНУ-овата лиценца за слободна документација (ГЛСД) е старата лиценца на Википедија.\nОваа лиценца сè уште важи, но е тешка за разбирање.\nИсто така треба да се има на ум дека пренамената на содржините под ГЛСД не е лесна.",
+ "config-email-settings": "Нагодувања за е-пошта",
+ "config-enable-email": "Овозможи излезна е-пошта",
+ "config-enable-email-help": "Ако сакате да работи е-поштата, [http://www.php.net/manual/en/mail.configuration.php поштенските нагодувања на PHP] треба да се правилно наместени.\nАко воопшто не сакате никакви функции за е-пошта, тогаш можете да ги оневозможите тука.",
+ "config-email-user": "Овозможи е-пошта од корисник до корисник",
+ "config-email-user-help": "Дозволи сите корисници да можат да си праќаат е-пошта ако ја имаат овозможено во нагодувањата.",
+ "config-email-usertalk": "Овозможи известувања за промени во кориснички страници за разговор",
+ "config-email-usertalk-help": "Овозможи корисниците да добиваат известувања за промени во нивните кориснички страници за разговор ако ги имаат овозможено во нагодувањата.",
+ "config-email-watchlist": "Овозможи известувања за список на набљудувања",
+ "config-email-watchlist-help": "Овозможи корисниците да добиваат известувања за нивните набљудувани страници ако ги имаат овозможено во нагодувањата.",
+ "config-email-auth": "Овозможи потврдување на е-пошта",
+ "config-email-auth-help": "Ако оваа можност е вклучена, тогаш корисниците ќе мора да ја потврдат нивната е-поштенска адреса преку врска испратена до нив кога ја укажуваат или менуваат е-поштенската адреса.\nСамо корисници со потврдена е-пошта можат да добиваат е-пошта од други корисници или да ги менуваат писмата за известување.\nОваа можност е '''препорачана''' за јавни викија поради можни злоупотреби на е-поштенската функција.",
+ "config-email-sender": "Повратна е-поштенска адреса:",
+ "config-email-sender-help": "Внесете ја е-поштенската адреса што ќе се користи како повратна адреса за излезна е-пошта.\nТаму ќе се испраќаат вратените (непримени) писма.\nМногу поштенски опслужувачи бараат барем делот за доменско име да биде важечки.",
+ "config-upload-settings": "Подигање на слики и податотеки",
+ "config-upload-enable": "Овозможи подигање на податотеки",
+ "config-upload-help": "Подигањето на податотеки потенцијално го изложуваат вашиот опслужувач на безбедносни ризици.\nЗа повеќе информации, прочитајте го [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security поглавието за безбедност] во прирачникот.\n\nЗа да овозможите подигање на податотеки, сменете го режимот на потпапката <code>images</code> во основната папка на МедијаВики, за да му овозможите на мрежниот опслужувач да запишува во неа.\nПотоа овозможете ја оваа функција.",
+ "config-upload-deleted": "Папка за избришаните податотеки:",
+ "config-upload-deleted-help": "Одберете во која папка да се архивираат избришаните податотеки.\nНајдобро би било ако таа не е достапна преку семрежјето.",
+ "config-logo": "URL за логото:",
+ "config-logo-help": "Матичното руво на МедијаВики има простор за лого од 135x160 пиксели над страничникот.\n\nМожете да употребите <code>$wgStylePath</code> или <code>$wgScriptPath</code> ако вашето лого е релативно на тие патеки.\n\nАко не сакате да имате лого, тогаш оставете го ова поле празно.",
+ "config-instantcommons": "Овозможи Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] е функција која им овозможува на викијата да користат слики, звучни записи и други мултимедијални содржини од [https://commons.wikimedia.org/ Ризницата].\nЗа да може ова да работи, МедијаВики бара пристап до семрежјето.\n\nЗа повеќе информации за оваа функција и напатствија за нејзино поставување на вики (сите други освен Ризницата), коносултирајте го [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos прирачникот].",
+ "config-cc-error": "Изборникот на лиценци од Криејтив комонс не даде лиценца.\nВнесете го името на лиценцата рачно.",
+ "config-cc-again": "Одберете повторно...",
+ "config-cc-not-chosen": "Одберете ја саканата лиценца од Криејтив комонс и стиснете на „proceed“.",
+ "config-advanced-settings": "Напредни нагодувања",
+ "config-cache-options": "Нагодувања за меѓускладирање на објекти:",
+ "config-cache-help": "Меѓускладирањето на објекти се користи за зголемување на брзината на МедијаВики со меѓускладирање на често употребуваните податоци.\nОва многу се препорачува на средни до големи викија, но од тоа ќе имаат полза и малите викија.",
+ "config-cache-none": "Без меѓускладирање (не се остранува ниедна функција, но може да влијае на брзината кај поголеми викија)",
+ "config-cache-accel": "Меѓускладирање на PHP-објекти (APC, APCu, XCache или WinCache)",
+ "config-cache-memcached": "Користи Memcached (бара дополнително поставување и нагодување)",
+ "config-memcached-servers": "Memcached-опслужувачи:",
+ "config-memcached-help": "Список на IP-адреси за употреба во Memcached.\nТреба да се наведе по една во секој ред, како и портата што ќе се користи. На пример:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Го одбравте Memcached како ваш тип на меѓусклад (кеш), но не наведовте ниеден опслужувач.",
+ "config-memcache-badip": "Внесовте неважечка IP-адреса за Memcached: $1",
+ "config-memcache-noport": "Не ја наведовте портата за опслужувачот на Memcached: $1.\nАко не знаете која порта треба да се користи, основната е 11211",
+ "config-memcache-badport": "Бројките за портата на Memcached треба да бидат помеѓу $1 и $2",
+ "config-extensions": "Додатоци",
+ "config-extensions-help": "Во вашата папка <code>./extensions</code> беа востановени горенаведените додатоци.\n\nЗа ова може да треба дополнително нагодување, но можете да ги овозможите сега",
+ "config-skins": "Рува",
+ "config-skins-help": "Во вашата папка <code>./skins</code> се утврдени горенаведените рува. Ќе мора да овозможите барем едно и да го изберете основното.",
+ "config-skins-use-as-default": "Користи го како основно",
+ "config-skins-missing": "Не пронајдов ниедно руво. МедијаВики ќе користи резервно руво сè додека не воспоставите други.",
+ "config-skins-must-enable-some": "Ќе треба да изберете барем едно руво.",
+ "config-skins-must-enable-default": "Рувото што го избравте како основно мора да се овозможи.",
+ "config-install-alreadydone": "'''Предупредување:''' Изгледа дека веќе го имате воспоставено МедијаВики и сега сакате да го воспоставите повторно.\nПродолжете на следната страница.",
+ "config-install-begin": "Стискајќи на „{{int:config-continue}}“ ќе ја започнете воспоставката на МедијаВики.\nАко сакате да направите измени во досегашното, стиснете на „{{int:config-back}}“.",
+ "config-install-step-done": "готово",
+ "config-install-step-failed": "не успеа",
+ "config-install-extensions": "Вклучувам додатоци",
+ "config-install-database": "Ја поставувам базата на податоци",
+ "config-install-schema": "Создавам шема",
+ "config-install-pg-schema-not-exist": "PostgreSQL-шемата не постои",
+ "config-install-pg-schema-failed": "Создавањето натабелите не успеа.\nПроверете дали корисникот „$1“ може да запишува во шемата „$2“.",
+ "config-install-pg-commit": "Спроведување на промени",
+ "config-install-pg-plpgsql": "Проверувам јазик PL/pgSQL",
+ "config-pg-no-plpgsql": "Ќе треба да го воспоставите јазикот PL/pgSQL во базата $1",
+ "config-pg-no-create-privs": "Сметката што ја наведовте за воспоставката нема доволно привилегии за да создаде друга сметка.",
+ "config-pg-not-in-role": "Сметката што ја наведовте за мрежниот корисник веќе постои.\nСметката што ја наведовте за воспоставка не е суперкорисник и не ѝ припаѓа на улогата на мрежниот корисник, па затоа не може да создава објекти во негова сопственост.\n\nМедијаВики налага дека табелите мора да се во сопственост на мрежниот корисник. Наведете друга мрежна сметка, или стиснете на „назад“ и наведете соодветно привилегиран корисник за инталацијата.",
+ "config-install-user": "Создавам корисник за базата",
+ "config-install-user-alreadyexists": "Корисникот „$1“ веќе постои",
+ "config-install-user-create-failed": "Создавањето на корисникот „$1“ не успеа: $2",
+ "config-install-user-grant-failed": "Доделувањето на дозвола на корисникот „$1“ не успеа: $2",
+ "config-install-user-missing": "Наведениот корисник „$1“ не постои.",
+ "config-install-user-missing-create": "Наведениот корисник „$1“ не постои.\nАко сакате да го создадете, штиклирајте ја можноста „создај сметка“.",
+ "config-install-tables": "Создавам табели",
+ "config-install-tables-exist": "'''Предупредување''': Изгледа дека табелите за МедијаВики веќе постојат.\nГо прескокнувам создавањето.",
+ "config-install-tables-failed": "'''Грешка''': Создавањето на табелата не успеа поради следнава грешка: $1",
+ "config-install-interwiki": "Ги пополнувам основно зададените меѓупроектни табели",
+ "config-install-interwiki-list": "Не можев да ја пронајдам податотеката <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Предупредување''': Табелата со интервикија веќе содржи ставки.\nГо прескокнувам основно-зададениот список.",
+ "config-install-stats": "Ги подготвувам статистиките",
+ "config-install-keys": "Создавање на тајни клучеви",
+ "config-insecure-keys": "'''Предупредување:''' {{PLURAL:$2|Безбедносниот клуч $1 создаден во текот на воспоставката не е сосем безбеден|Безбедносните клучеви $1 создадени во текот на воспоставката не се сосем безбедни}}. Ви препорачуваме да {{PLURAL:$2|го|ги}} смените рачно.",
+ "config-install-updates": "Спречи вршење на непотребни поднови",
+ "config-install-updates-failed": "<strong>Грешка:</strong> Вметнувањето на подновни клучеви во табелите не успеа, со следнава грешка: $1",
+ "config-install-sysop": "Создавање на администраторска корисничка сметка",
+ "config-install-subscribe-fail": "Не можам да ве претплатам на известувањето mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL не е воспоставен, а <code>allow_url_fopen</code> не е достапно.",
+ "config-install-mainpage": "Создавам главна страница со стандардна содржина",
+ "config-install-mainpage-exists": "Главната страница веќе постои. Преоѓам на следно.",
+ "config-install-extension-tables": "Изработка на табели за овозможени додатоци",
+ "config-install-mainpage-failed": "Не можев да вметнам главна страница: $1",
+ "config-install-done": "<strong>Честитаме!</strong>\nУспешно го воспоставивте МедијаВики.\n\nВоспоставувачот создаде податотека <code>LocalSettings.php</code>.\nТаму се содржат сите ваши нагодувања.\n\nЌе треба да ја преземете и да ја ставите во основата на воспоставката (истата папка во која се наоѓа index.php). Преземањето треба да е започнато автоматски.\n\nАко не ви е понудено преземање, или пак ако сте го откажале, можете да го почнете одново стискајќи на следнава врска:\n\n$3\n\n<strong>Напомена</strong>: Ако ова не го направите сега, податотеката со поставки повеќе нема да биде на достапна.\n\nОткога ќе завршите со тоа, можете да <strong>[$2 влезете на вашето вики]</strong>.",
+ "config-install-done-path": "<strong>Честитаме!</strong>\nГо воспоставивте МедијаВики.\n\nВоспоставувачот создаде податотека <code>LocalSettings.php</code>.\nТаму се содржат сите ваши нагодувања.\n\nЌе треба да ја преземете и да ја ставите во <code>$4</code>. Преземањето треба да е започнато автоматски.\n\nАко не ви е понудено преземање, или пак ако сте го откажале, можете да го почнете одново стискајќи на следнава врска:\n\n$3\n\n<strong>Напомена</strong>: Ако ова не го направите сега, создадената податотека со поставки повеќе нема да биде на достапна, освен ако не ја преземете пред да излезете.\n\nОткога ќе завршите со тоа, можете да <strong>[$2 влезете на вашето вики]</strong>.",
+ "config-download-localsettings": "Преземи го <code>LocalSettings.php</code>",
+ "config-help": "помош",
+ "config-help-tooltip": "стиснете да расклопите",
+ "config-nofile": "Податотеката „$1“ не е пронајдена. Да не е избришана?",
+ "config-extension-link": "Дали сте знаеле дека вашето вики поддржува [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions додатоци]?\n\nМожете да ги прелистате [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category по категории]",
+ "config-skins-screenshots": "$1 (екр. снимки: $2)",
+ "config-screenshot": "екранска снимка",
+ "mainpagetext": "<strong>МедијаВики е успешно воспоставен.</strong>",
+ "mainpagedocfooter": "Погледнете го [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Упатството за корисници] за подетални информации како се користи вики-програмот.\n\n==Од каде да почнете==\n* [https://meta.wikimedia.org/wiki/Manual:Configuration_settings Список на нагодувања]\n* [https://meta.wikimedia.org/wiki/Manual:FAQ ЧПП (често поставувани прашања) за МедијаВики].\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Поштенски список на МедијаВики за нови верзии]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Локализирајте го МедијаВики на вашиот јазик]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Дознајте како да се борите против спам на вашето вики]"
+}
diff --git a/www/wiki/includes/installer/i18n/ml.json b/www/wiki/includes/installer/i18n/ml.json
new file mode 100644
index 00000000..55ce4406
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ml.json
@@ -0,0 +1,120 @@
+{
+ "@metadata": {
+ "authors": [
+ "Praveenp",
+ "Sadik Khalid"
+ ]
+ },
+ "config-desc": "മീഡിയവിക്കി ഇൻസ്റ്റോളർ",
+ "config-title": "മീഡിയവിക്കി $1 ഇൻസ്റ്റലേഷൻ",
+ "config-information": "വിവരങ്ങൾ",
+ "config-localsettings-upgrade": "'''അറിയിപ്പ്''': ഒരു <code>LocalSettings.php</code> ഫയൽ കാണുന്നു.\nസോഫ്റ്റ്‌വേർ അപ്‌ഗ്രേഡ് ചെയ്യുക സാദ്ധ്യമാണ്.\nദയവായി പെട്ടിയിൽ <code>$wgUpgradeKey</code> എന്നതിന്റെ വില നൽകുക.",
+ "config-localsettings-key": "അപ്‌ഗ്രേഡ് ചാവി:",
+ "config-localsettings-badkey": "താങ്കൾ നൽകിയ ചാവി തെറ്റാണ്",
+ "config-session-error": "സെഷൻ തുടങ്ങുന്നതിൽ പിഴവ്: $1",
+ "config-your-language": "താങ്കളുടെ ഭാഷ:",
+ "config-your-language-help": "ഇൻസ്റ്റലേഷൻ പ്രക്രിയയിൽ ഉപയോഗിക്കേണ്ട ഭാഷ തിരഞ്ഞെടുക്കുക.",
+ "config-wiki-language": "വിക്കി ഭാഷ:",
+ "config-wiki-language-help": "വിക്കിയിൽ പ്രധാനമായി ഉപയോഗിക്കേണ്ട ഭാഷ തിരഞ്ഞെടുക്കുക.",
+ "config-back": "← പിന്നിലേയ്ക്ക്",
+ "config-continue": "തുടരുക →",
+ "config-page-language": "ഭാഷ",
+ "config-page-welcome": "മീഡിയവിക്കിയിലേയ്ക്ക് സ്വാഗതം!",
+ "config-page-dbconnect": "ഡേറ്റാബേസുമായി ബന്ധപ്പെടുക",
+ "config-page-upgrade": "നിലവിലുള്ള ഇൻസ്റ്റലേഷൻ അപ്‌ഗ്രേഡ് ചെയ്യുക",
+ "config-page-dbsettings": "ഡേറ്റാബേസ് സജ്ജീകരണങ്ങൾ",
+ "config-page-name": "പേര്",
+ "config-page-options": "ഐച്ഛികങ്ങൾ",
+ "config-page-install": "ഇൻസ്റ്റോൾ",
+ "config-page-complete": "സമ്പൂർണ്ണം!",
+ "config-page-restart": "ഇൻസ്റ്റലേഷൻ അടച്ച ശേഷം പുനർപ്രവർത്തിപ്പിക്കുക",
+ "config-page-readme": "ഇത് വായിക്കൂ",
+ "config-page-releasenotes": "പ്രകാശന കുറിപ്പുകൾ",
+ "config-page-copying": "പകർത്തൽ",
+ "config-page-upgradedoc": "അപ്‌ഗ്രേഡിങ്",
+ "config-help-restart": "ഇതുവരെ ഉൾപ്പെടുത്തിയ എല്ലാവിവരങ്ങളും ഒഴിവാക്കാനും ഇൻസ്റ്റലേഷൻ പ്രക്രിയ നിർത്തി-വീണ്ടുമാരംഭിക്കാനും താങ്കളാഗ്രഹിക്കുന്നുണ്ടോ?",
+ "config-restart": "അതെ, പുനർപ്രവർത്തിപ്പിക്കുക",
+ "config-sidebar": "* [https://www.mediawiki.org മീഡിയവിക്കി പ്രധാനതാൾ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents ഉപയോക്തൃസഹായി]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents കാര്യനിർവഹണസഹായി]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ പതിവുചോദ്യങ്ങൾ]",
+ "config-env-php": "പി.എച്ച്.പി. $1 ഇൻസ്റ്റോൾ ചെയ്തിട്ടുണ്ട്.",
+ "config-no-db": "അനുയോജ്യമായ ഡേറ്റാബേസ് ഡ്രൈവർ കണ്ടെത്താനായില്ല!",
+ "config-memory-raised": "പി.എച്ച്.പി.യുടെ <code>memory_limit</code> $1 ആണ്, $2 ആയി ഉയർത്തിയിരിക്കുന്നു.",
+ "config-memory-bad": "'''മുന്നറിയിപ്പ്:''' പി.എച്ച്.പി.യുടെ <code>memory_limit</code> $1 ആണ്.\nഇത് മിക്കവാറും വളരെ കുറവാണ്.\nഇൻസ്റ്റലേഷൻ പരാജയപ്പെട്ടേക്കാം!",
+ "config-db-type": "ഡേറ്റാബേസ് തരം:",
+ "config-db-host": "ഡേറ്റാബേസ് ഹോസ്റ്റ്:",
+ "config-db-name": "ഡേറ്റാബേസിന്റെ പേര്:",
+ "config-db-name-oracle": "ഡേറ്റാബേസ് സ്കീമ:",
+ "config-db-install-account": "ഇൻസ്റ്റലേഷനുള്ള ഉപയോക്തൃ അംഗത്വം",
+ "config-db-username": "ഡേറ്റാബേസ് ഉപയോക്തൃനാമം:",
+ "config-db-password": "ഡേറ്റാബേസ് രഹസ്യവാക്ക്:",
+ "config-mysql-old": "മൈഎസ്‌ക്യൂഎൽ $1 അഥവാ അതിലും പുതിയത് ആവശ്യമാണ്, താങ്കളുടെ പക്കൽ ഉള്ളത് $2 ആണ്.",
+ "config-db-port": "ഡേറ്റാബേസ് പോർട്ട്:",
+ "config-db-schema": "മീഡിയവിക്കിയ്ക്കായുള്ള സ്കീമ",
+ "config-support-info": "മീഡിയവിക്കി താഴെ പറയുന്ന ഡേറ്റാബേസ് സിസ്റ്റംസ് പിന്തുണയ്ക്കുന്നു:\n\n$1\n\nതാങ്കൾ ഉപയോഗിക്കാനാഗ്രഹിക്കുന്ന ഡേറ്റാബേസ് സിസ്റ്റം പട്ടികയിലില്ലെങ്കിൽ, ദയവായി പിന്തുണ സജ്ജമാക്കാനായി മുകളിൽ നൽകിയിട്ടുള്ള ലിങ്കിലെ നിർദ്ദേശങ്ങൾ ചെയ്യുക.",
+ "config-header-mysql": "മൈഎസ്‌ക്യൂഎൽ സജ്ജീകരണങ്ങൾ",
+ "config-invalid-db-type": "അസാധുവായ ഡേറ്റാബേസ് തരം",
+ "config-missing-db-name": "\"ഡേറ്റാബേസിന്റെ പേരി\"ന് ഒരു വില നിർബന്ധമായും നൽകിയിരിക്കണം",
+ "config-connection-error": "$1.\n\nതാഴെ നൽകിയിരിക്കുന്ന ഹോസ്റ്റ്, ഉപയോക്തൃനാമം, രഹസ്യവാക്ക് എന്നിവ പരിശോധിച്ച് വീണ്ടും ശ്രമിക്കുക.",
+ "config-regenerate": "LocalSettings.php പുനഃസൃഷ്ടിക്കുക →",
+ "config-mysql-engine": "സ്റ്റോറേജ് എൻജിൻ:",
+ "config-site-name": "വിക്കിയുടെ പേര്:",
+ "config-site-name-help": "ഇത് ബ്രൗസറിന്റെ ടൈറ്റിൽ ബാറിലും മറ്റനേകം ഇടങ്ങളിലും പ്രദർശിപ്പിക്കപ്പെടും.",
+ "config-site-name-blank": "സൈറ്റിന്റെ പേര് നൽകുക.",
+ "config-project-namespace": "പദ്ധതി നാമമേഖല:",
+ "config-ns-generic": "പദ്ധതി",
+ "config-ns-site-name": "വിക്കിയുടെ പേര് തന്നെ: $1",
+ "config-ns-other": "ഇതരം (വ്യക്തമാക്കുക)",
+ "config-ns-other-default": "എന്റെ‌വിക്കി",
+ "config-admin-box": "കാര്യനിർവാഹക അംഗത്വം",
+ "config-admin-name": "താങ്കളുടെ പേര്:",
+ "config-admin-password": "രഹസ്യവാക്ക്:",
+ "config-admin-password-confirm": "രഹസ്യവാക്ക് ഒരിക്കൽക്കൂടി:",
+ "config-admin-help": "ഇവിടെ താങ്കളുടെ ഇച്ഛാനുസരണമുള്ള ഉപയോക്തൃനാമം നൽകുക, ഉദാഹരണം \"ശശി കൊട്ടാരത്തിൽ\".\nഈ പേരായിരിക്കണം വിക്കിയിൽ പ്രവേശിക്കാൻ താങ്കൾ ഉപയോഗിക്കേണ്ടത്.",
+ "config-admin-name-blank": "ഒരു കാര്യനിർവാഹക ഉപയോക്തൃനാമം നൽകുക.",
+ "config-admin-name-invalid": "നൽകിയിട്ടുള്ള ഉപയോക്തൃനാമം \"<nowiki>$1</nowiki>\" അസാധുവാണ്.\nമറ്റൊരു ഉപയോക്തൃനാമം നൽകുക.",
+ "config-admin-password-blank": "കാര്യനിർവാഹക അംഗത്വത്തിനുള്ള രഹസ്യവാക്ക് നൽകുക.",
+ "config-admin-password-mismatch": "താങ്കൾ നൽകിയ രഹസ്യവാക്കുകൾ രണ്ടും തമ്മിൽ യോജിക്കുന്നില്ല.",
+ "config-admin-email": "ഇമെയിൽ വിലാസം:",
+ "config-admin-error-user": "\"<nowiki>$1</nowiki>\" എന്ന പേരിലുള്ള കാര്യനിർവഹണ അംഗത്വ നിർമ്മിതിയ്ക്കിടെ ആന്തരികമായ പിഴവുണ്ടായി.",
+ "config-admin-error-password": "\"<nowiki>$1</nowiki>\" എന്ന പേരിലുള്ള കാര്യനിർവാഹക അംഗത്വത്തിനു രഹസ്യവാക്ക് സജ്ജീകരിച്ചപ്പോൾ ആന്തരികമായ പിഴവുണ്ടായി: <pre>$2</pre>",
+ "config-subscribe": "[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce പ്രകാശന അറിയിപ്പ് മെയിലിങ് ലിസ്റ്റിൽ] വരിക്കാരാകുക.",
+ "config-subscribe-help": "പുറത്തിറക്കൽ അറിയിപ്പുകളും, പ്രധാന സുരക്ഷാ അറിയിപ്പുകളും പ്രസിദ്ധീകരിക്കുന്ന വളരെ എഴുത്തുകളൊന്നും ഉണ്ടാകാറില്ലാത്ത മെയിലിങ് ലിസ്റ്റ് ആണിത്.\nപുതിയ പതിപ്പുകൾ പുറത്ത് വരുന്നതനുസരിച്ച് അവയെക്കുറിച്ചറിയാനും മീഡിയവിക്കി ഇൻസ്റ്റലേഷൻ പുതുക്കാനും ഇതിന്റെ വരിക്കാരൻ/വരിക്കാരി ആവുക.",
+ "config-almost-done": "മിക്കവാറും പൂർത്തിയായിരിക്കുന്നു!\nബാക്കിയുള്ളവ അവഗണിച്ച് വിക്കി ഇൻസ്റ്റോൾ ചെയ്യാവുന്നതാണ്.",
+ "config-optional-continue": "കൂടുതൽ ചോദ്യങ്ങൾ ചോദിക്കൂ.",
+ "config-optional-skip": "എനിക്ക് മടുത്തു, ഒന്ന് ഇൻസ്റ്റോൾ ചെയ്ത് തീർക്ക്.",
+ "config-profile-wiki": "പരമ്പരാഗത വിക്കി",
+ "config-profile-no-anon": "അംഗത്വ സൃഷ്ടി ചെയ്യേണ്ടതുണ്ട്",
+ "config-profile-fishbowl": "അനുവാദമുള്ളവർ മാത്രം തിരുത്തുക",
+ "config-profile-private": "സ്വകാര്യ വിക്കി",
+ "config-license": "പകർപ്പവകാശവും അനുമതിയും:",
+ "config-license-cc-by-sa": "ക്രിയേറ്റീവ് കോമൺസ് ആട്രിബ്യൂഷൻ ഷെയർ എലൈക്",
+ "config-license-cc-by-nc-sa": "ക്രിയേറ്റീവ് കോമൺസ് ആട്രിബ്യൂഷൻ നോൺ-കൊമേഴ്സ്യൽ ഷെയർ എലൈക്",
+ "config-license-pd": "പൊതുസഞ്ചയം",
+ "config-email-settings": "ഇമെയിൽ സജ്ജീകരണങ്ങൾ",
+ "config-enable-email-help": "ഇമെയിൽ പ്രവർത്തിക്കണമെങ്കിൽ, [http://www.php.net/manual/en/mail.configuration.php PHP's മെയിൽ സജ്ജീകരണങ്ങൾ] ശരിയായി ക്രമീകരിക്കേണ്ടതുണ്ട്.\nഇമെയിൽ സൗകര്യം ആവശ്യമില്ലെങ്കിൽ, ഇവിടെത്തന്നെ അത് നിർജ്ജീവമാക്കാം.",
+ "config-email-user": "ഉപയോക്താക്കൾ തമ്മിലുള്ള ഇമെയിൽ പ്രവർത്തനസജ്ജമാക്കുക",
+ "config-email-user-help": "സ്വന്തം ക്രമീകരണങ്ങളിൽ ഇമെയിൽ സജ്ജമാക്കിയിട്ടുണ്ടെങ്കിൽ ഉപയോക്താക്കളെ മറ്റുള്ളവർക്ക് ഇമെയിൽ അയയ്ക്കാൻ അനുവദിക്കുക.",
+ "config-email-usertalk": "ഉപയോക്തൃസംവാദം താളിൽ മാറ്റങ്ങളുണ്ടായാൽ അറിയിക്കുക",
+ "config-email-watchlist": "ശ്രദ്ധിക്കുന്നവയിൽ മാറ്റം വന്നാൽ അറിയിക്കുക",
+ "config-email-auth": "ഇമെയിലിന്റെ സാധുതാപരിശോധന സജ്ജമാക്കുക",
+ "config-email-sender": "മറുപടിയ്ക്കുള്ള ഇമെയിൽ വിലാസം:",
+ "config-upload-settings": "ചിത്രങ്ങളും പ്രമാണങ്ങളും അപ്‌ലോഡ് ചെയ്യൽ",
+ "config-upload-enable": "പ്രമാണ അപ്‌ലോഡുകൾ സജ്ജമാക്കുക",
+ "config-upload-deleted": "മായ്ക്കപ്പെട്ട ഫയലുകൾക്കുള്ള ഡയറക്റ്ററി:",
+ "config-logo": "ലോഗോയുടെ യൂ.ആർ.എൽ.:",
+ "config-logo-help": "മീഡിയവിക്കിയിൽ സ്വതേയുള്ള ദൃശ്യരൂപത്തിൽ 135x160 പിക്സലുള്ള ലോഗോ മുകളിൽ ഇടത് മൂലയിൽ കാണാം.\nഅനുയോജ്യമായ വലിപ്പമുള്ള ഒരു ചിത്രം അപ്‌ലോഡ് ചെയ്തിട്ട്, അതിന്റെ യൂ.ആർ.എൽ. ഇവിടെ നൽകുക.\n\nതാങ്കൾക്ക് ലോഗോ ആവശ്യമില്ലെങ്കിൽ, ഈ പെട്ടി ശൂന്യമായിടുക.",
+ "config-cc-again": "ഒന്നുകൂടി എടുക്കൂ...",
+ "config-advanced-settings": "വിപുലീകൃത ക്രമീകരണങ്ങൾ",
+ "config-extensions": "അനുബന്ധങ്ങൾ",
+ "config-install-step-done": "ചെയ്തു കഴിഞ്ഞു",
+ "config-install-step-failed": "പരാജയപ്പെട്ടു",
+ "config-install-extensions": "അനുബന്ധങ്ങൾ ഉൾപ്പെടുത്തുന്നു",
+ "config-install-database": "ഡേറ്റാബേസ് സജ്ജമാക്കുന്നു",
+ "config-install-pg-commit": "മാറ്റങ്ങൾ സ്വീകരിക്കുന്നു",
+ "config-install-user": "ഡേറ്റാബേസ് ഉപയോക്താവിനെ സൃഷ്ടിക്കുന്നു",
+ "config-install-sysop": "കാര്യനിർവാഹക അംഗത്വം സൃഷ്ടിക്കുന്നു",
+ "config-install-mainpage": "സ്വാഭാവിക ഉള്ളടക്കത്തോടുകൂടി പ്രധാനതാൾ സൃഷ്ടിക്കുന്നു",
+ "config-install-mainpage-failed": "പ്രധാന താൾ ഉൾപ്പെടുത്താൻ കഴിഞ്ഞില്ല: $1",
+ "config-install-done": "'''അഭിനന്ദനങ്ങൾ!'''\nതാങ്കൾ വിജയകരമായി മീഡിയവിക്കി സജ്ജീകരിച്ചിരിക്കുന്നു.\n\nഇൻസ്റ്റോളർ താങ്കളുടെ എല്ലാ ക്രമീകരണങ്ങളുമടങ്ങുന്ന <code>LocalSettings.php</code> ഫയൽ സൃഷ്ടിച്ചിട്ടുണ്ട്.\n\nപ്രസ്തുത പ്രമാണം ഡൗൺലോഡ് ചെയ്ത് താങ്കളുടെ വിക്കി സജ്ജീകരണത്തിന്റെ അടിസ്ഥാന ഡയറക്റ്ററിയിൽ ഇടേണ്ടതാണ് (index.php കിടക്കുന്ന അതേ ഡയറക്റ്ററിയിൽ). ഡൗൺലോഡിങ്ങ് സ്വയം ആരംഭിക്കുന്നതാണ്. ഡൗൺലോഡിങ്ങ് സ്വയം തുടങ്ങാതിരിക്കുകയോ, താങ്കൾ റദ്ദാക്കുകയോ ചെയ്ത പക്ഷം താഴെ കാണുന്ന കണ്ണിയിൽ ഞെക്കുക:\n$3\n\n'''ശ്രദ്ധിക്കുക''': താങ്കൾ ഇപ്പോൾ ചെയ്തില്ലെങ്കിൽ, ഫയൽ എടുക്കാതെ ഇൻസ്റ്റലേഷൻ പ്രക്രിയയിൽ നിന്ന് പുറത്തിറങ്ങിയാൽ, സൃഷ്ടിക്കപ്പെട്ട ക്രമീകരണങ്ങളടങ്ങുന്ന പ്രമാണം പിന്നീട് ലഭ്യമായിരിക്കില്ല.\n\nമുകളിൽ പറഞ്ഞ പ്രകാരം ചെയ്തു കഴിഞ്ഞാൽ, താങ്കൾക്ക് '''[$2 വിക്കിയിൽ പ്രവേശിക്കാവുന്നതാണ്]'''.",
+ "mainpagetext": "'''മീഡിയവിക്കി വിജയകരമായി സജ്ജീകരിച്ചിരിക്കുന്നു.'''",
+ "mainpagedocfooter": "വിക്കി സോഫ്റ്റ്‌വെയർ ഉപയോഗിക്കുന്നതിനെ കുറിച്ചുള്ള വിശദാംശങ്ങൾക്ക് [https://meta.wikimedia.org/wiki/Help:Contents സോഫ്റ്റ്‌വെയർ സഹായി] കാണുക.\n\n== പ്രാരംഭസഹായികൾ ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings ക്രമീകരണങ്ങളുടെ പട്ടിക]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ മീഡിയവിക്കി പതിവുചോദ്യങ്ങൾ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce മീഡിയവിക്കി പ്രകാശന മെയിലിങ് ലിസ്റ്റ്]"
+}
diff --git a/www/wiki/includes/installer/i18n/mn.json b/www/wiki/includes/installer/i18n/mn.json
new file mode 100644
index 00000000..88a5f75c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mn.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chinneeb"
+ ]
+ },
+ "config-page-language": "Хэл",
+ "mainpagetext": "'''МедиаВики амжилттай суулаа.'''",
+ "mainpagedocfooter": "Вики программыг хэрэглэх талаар заавар авахын тулд [https://meta.wikimedia.org/wiki/Help:Contents хэрэглэгчийн гарын авлага]-г үзнэ үү.\n\n== Эхлэх ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Тохиргоо]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ МедиаВикигийн тогтмол тавигддаг асуултууд]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce МедиаВикигийн мэдээний мэйл явуулах жагсаалт]"
+}
diff --git a/www/wiki/includes/installer/i18n/mr.json b/www/wiki/includes/installer/i18n/mr.json
new file mode 100644
index 00000000..d3e0e173
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mr.json
@@ -0,0 +1,110 @@
+{
+ "@metadata": {
+ "authors": [
+ "V.narsikar",
+ "Suyog"
+ ]
+ },
+ "config-desc": "मिडियाविकि करीता उभारक(ईन्स्टॉलर)",
+ "config-title": "मिडियाविकि $1 उभारणी",
+ "config-information": "माहिती",
+ "config-localsettings-upgrade": "<code>LocalSettings.php</code>ही संचिका संसुचित झाली.या उभारणीची दर्जोन्नती करण्यास,खालील पेटीत कृपया <code>$wgUpgradeKey</code> ची किंमत टाका. ती आपणास<code>LocalSettings.php</code>मध्ये सापडेल.",
+ "config-localsettings-cli-upgrade": "<code>LocalSettings.php</code>ही संचिका संसुचित झाली.या उभारणीची दर्जोन्नती करण्यास त्याऐवजी,कृपया<code>update.php</code>चालवा",
+ "config-localsettings-key": "दर्जोन्नती कळ:",
+ "config-localsettings-badkey": "आपण दिलेली कळ चुकीची आहे.",
+ "config-upgrade-key-missing": "मिडियाविकिचे अस्तित्वात असलेली उभारणी संसुचित झाली आहे.या उभारणीची दर्जोन्नती करण्यास,आपल्या <code>LocalSettings.php</code>च्या खाली असलेल्या ओळीत खालील टाका:\n\n$1",
+ "config-localsettings-incomplete": "अस्तित्वात असलेला <code>LocalSettings.php</code>अपूर्ण दिसत आहे.\n$1 हे चल स्थापिलेले नाही.<code>LocalSettings.php</code> कृपया बदला ज्याने,हे चल स्थापिले जाईल व त्यानंतर \"{{int:Config-continue}}\" टिचका.",
+ "config-localsettings-connection-error": "<code>LocalSettings.php</code> मधील नमूद मांडण्या वापरुन विदागाराशी अनुबंध करतांना एक त्रुटी उद्भवली.\nकृपया या मांडण्या सुधरवुन पुन्हा प्रयत्न करा.\n\n$1",
+ "config-session-error": "सत्र सुरू करण्यात त्रुटी:$1",
+ "config-session-expired": "आपला सत्र डाटा कालबाह्य झाला आहे असे दिसते.\n$1 या हयातवेळेशी सत्र रचित असते.\nphp.ini मधिल <code>session.gc_maxlifetime</code> द्वारे आपण त्या वेळेस वाढवु शकता.\nउभारणीची प्रक्रिया पुन्हा सुरू करा.",
+ "config-no-session": "आपला सत्र डाटा हरविला आहे!\nआपली php.ini तपासा व याची खात्री करा कि <code>session.save_path</code> हा योग्य डिरेक्टरीत स्थापिला आहे.",
+ "config-your-language": "आपली भाषा:",
+ "config-your-language-help": "उभारणी प्रक्रियेत वापरावयाची भाषा निवडा.",
+ "config-wiki-language": "विकी भाषा:",
+ "config-wiki-language-help": "ज्यात विकि प्रबळरित्या लिहीला जाईल अशी तुमची लेखन भाषा निवडा.",
+ "config-back": "← परत",
+ "config-continue": "चालू ठेवा →",
+ "config-page-language": "भाषा",
+ "config-page-welcome": "मिडियाविकीवर स्वागत आहे!",
+ "config-page-dbconnect": "डाटाबेसशी अनुबंध करा",
+ "config-page-upgrade": "सध्याच्या उभारणीची(इन्स्टॉलेशन) दर्जोन्नती करा",
+ "config-page-dbsettings": "डाटाबेसच्या मांडण्या",
+ "config-page-name": "नाव",
+ "config-page-options": "पर्याय",
+ "config-page-install": "स्थापित करा(इन्स्टॉल)",
+ "config-page-complete": "पूर्ण!",
+ "config-page-restart": "उभारणीस पुन्हा सुरू करा",
+ "config-page-readme": "हे वाचा",
+ "config-page-releasenotes": "विमोचन टिप्पण्या",
+ "config-page-copying": "नकलवित आहे",
+ "config-page-upgradedoc": "दर्जोन्नती करीत आहे",
+ "config-page-existingwiki": "सध्याचा विकि",
+ "config-help-restart": "आपण टाकून जतन केलेला सर्व डाटा आपणास साफ करावयाचा व उभारणीची प्रक्रिया पुन्हा सुरू करावयाची आहे काय?",
+ "config-restart": "होय, परत चालू करा",
+ "config-welcome": "=== पारिसरीक तपासण्या ===\nमिडियाविकिच्या उभारणीस हा परिसर योग्य आहे काय याच्या मूळ तपासण्या आता केल्या जातील.\nजर आपणास पुढे याची उभारणी करण्याबद्दल साहाय्य लागल्यास, याचा अंतर्भाव करणे लक्षात ठेवा.",
+ "config-copyright": "=== प्रताधिकार व अटी ===\n\n$1\nहा कार्यसंच,हे एक मुक्त संचेतन आहे;आपण त्यास पुनर्वितरीत व/किंवा त्यास फ्री सॉफ्टवेअर फाऊंडेशन द्वारे प्रकाशित, GNU जनरल पब्लिक लायसन्स अंतर्गत बदलु शकता;या परवान्याची आवृत्ती २ किंवा (आपल्या इच्छेनुसार)त्यानंतरची आवृत्ती.\n\nहा कार्यसंचाचे वितरण,पण, <strong>कोणत्याही हमीशिवाय</strong>; याशिवाय <strong>व्यापारीकरणाच्या</strong> कोणत्याही अभिप्रेत आश्वासनाशिवाय किंवा <strong>एखाद्या विशिष्ट कार्यासाठीच्या अर्हतेशिवाय</strong>ही आशा ठेऊन केले आहे कि, तो उपयोगी असेल.\nअधिक माहितीसाठी GNU जनरल पब्लिक लायसन्स बघा.\nआपणास या कार्यसंचासमवेत <doclink href=Copying>GNU जनरल पब्लिक लायसन्सची प्रत मिळाली असेल,</doclink>नसल्यास,फ्री सॉफ्टवेअर फाऊंडेशनला या पत्त्यावर लिहा.Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. किंवा त्यास [http://www.gnu.org/copyleft/gpl.html ऑनलाईन वाचा].",
+ "config-sidebar": "* [https://www.mediawiki.org मिडियाविकि गृह]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents सदस्य मार्गदर्शिका]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents प्रशासकाची मार्गदर्शिका]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ एफएक्यू]\n----\n* <doclink href=Readme>रीड मी</doclink>\n* <doclink href=ReleaseNotes>विमोचन टिप्पण्या</doclink>\n* <doclink href=Copying>नकलविणे</doclink>\n* <doclink href=UpgradeDoc>दर्जोन्नती करणे</doclink>",
+ "config-env-good": "पारिसरीक तपासणी झाली आहे.\nआपण मिडियाविकि उभारू शकता.",
+ "config-env-bad": "पारिसरीक तपासणी झाली आहे.\nआपण मिडियाविकि उभारू शकत नाही.",
+ "config-env-php": "PHP $1 उभारल्या गेली.",
+ "config-env-hhvm": "HHVM $1 उभारल्या गेली.",
+ "config-unicode-using-intl": "यूनिकोड सामान्यिकरणासाठी [http://pecl.php.net/intl intl PECL विस्तारक] वापरत आहे.",
+ "config-outdated-sqlite": "<strong>इशारा:</strong> आपणापाशी SQLite $1 आहे, जी किमान आवश्यक आवृत्ती $2 पेक्षा, निम्न आहे. SQLite अनुपलब्ध राहील.",
+ "config-memory-raised": "पीएचपीची <code>memory_limit</code> ही $1 आहे, त्यास $2 ला वाढविली.",
+ "config-memory-bad": "पीएचपीची <code>memory_limit</code> ही $1 आहे.\nही बरीच खालच्या स्तरावरची आहे.\nउभारणी अयशस्वी होऊ शकते!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] उभारली",
+ "config-apc": "[http://www.php.net/apc APC] उभारली आहे",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] उभारली आहे",
+ "config-diff3-bad": "GNU diff3 सापडली नाही.",
+ "config-git-bad": "गीट आवृत्ती नियमन संचेतन सापडली नाही.",
+ "config-db-type": "डाटाबेसचा प्रकार:",
+ "config-db-host": "डाटाबेसचा यजमान:",
+ "config-db-name": "डाटाबेसचे नाव:",
+ "config-db-name-oracle": "डाटाबेस आकृतीबंध:",
+ "config-db-install-account": "उभारणीसाठी सदस्य खाते",
+ "config-db-username": "डाटाबेसवरील सदस्यनाव:",
+ "config-db-password": "डाटाबेसवरील परवलीचा शब्द:",
+ "config-db-prefix": "डाटाबेस सारणी उपसर्ग:",
+ "config-db-port": "डाटाबेस द्वार:",
+ "config-pg-test-error": "विदागाराशी अनुबंधन करता येत नाही <strong>$1</strong>: $2",
+ "config-type-mssql": "मायक्रोसॉफ्ट एसक्युएल सर्व्हर",
+ "config-header-mssql": "मायक्रोसॉफ्ट एसक्युएल सर्व्हर मांडणावळ",
+ "config-invalid-db-type": "डाटाबेसचा अवैध प्रकार.",
+ "config-connection-error": "$1.\n\nयजमान,सदस्यनाव व परवलीचा शब्द तपासा व पुन्हा प्रयत्न करा.",
+ "config-mssql-old": "मायक्रोसॉफ्ट एसक्युएल सर्व्हर $1 किंवा त्यानंतरची आवृत्ती हवी. आपणापाशी $2 आहे.",
+ "config-upgrade-done-no-regenerate": "दर्जोन्नती पूर्ण.\n\nआपण आता [$1 आपला विकिचा वापर करु शकता].",
+ "config-mssql-auth": "अधिप्रमाणन प्रकार:",
+ "config-mssql-install-auth": "उभारणीच्या(इन्स्टॉलेशन) प्रक्रियेदरम्यान,'अधिप्रमाणन प्रकार'( ऑथेंटीकेशन टाईप) निवडा, ज्याचा वापर डाटाबेसशी अनुबंधनात करण्यात येईल.जर आपण \"{{int:config-mssql-windowsauth}} निवडले तर,ज्याकोणत्याही सदस्याची अधिकारपत्रे(क्रेडेंटियल्स) वेबसर्व्हरवर सुरू असतील,तशीच वापरल्या जातील.",
+ "config-mssql-web-auth": "'अधिप्रमाणन प्रकार'( ऑथेंटीकेशन टाईप) निवडा, ज्यास,या विकिचे सामान्य चालनादरम्यान, वेब सर्व्हर हा डाटाबेसशी अनुबंधन करण्यास वापरेल.जर आपण\"{{int:config-mssql-windowsauth}}\" निवडले तर,ज्याकोणत्याही सदस्याची अधिकारपत्रे(क्रेडेंटियल्स) वेबसर्व्हरवर सुरू असतील,तशीच वापरल्या जातील.",
+ "config-mssql-sqlauth": "एसक्युएल सर्व्हर अधिप्रमाणन",
+ "config-mssql-windowsauth": "विंडोजचे अधिप्रमाणन",
+ "config-site-name": "विकिचे नाव:",
+ "config-site-name-help": "हे न्याहाळकाच्या शीर्षक पट्टीत व इतर ठिकाणीही दिसेल .",
+ "config-site-name-blank": "संकेतस्थळाचे नाव टाका.",
+ "config-project-namespace": "प्रकल्प नामविश्व:",
+ "config-ns-generic": "प्रकल्प",
+ "config-ns-site-name": "विकि नावाप्रमाणेच: $1",
+ "config-admin-name": "आपले सदस्यनाव:",
+ "config-admin-password": "परवलीचा शब्द:",
+ "config-admin-password-confirm": "परवलीचा शब्द पुन्हा टाका:",
+ "config-admin-email": "विपत्र पत्ता:",
+ "config-admin-error-bademail": "आपण अवैध विपत्रपत्ता टाकला आहे.",
+ "config-profile-no-anon": "खाते तयार करणे आवश्यक",
+ "config-profile-fishbowl": "फक्त प्रमाणित संपादक",
+ "config-profile-private": "खाजगी विकि",
+ "config-license": "प्रताधिकार व परवाना",
+ "config-email-user": "सदस्य ते सदस्य विपत्र पाठविणे सक्षम करा",
+ "config-email-usertalk": "सदस्य चर्चा पान अधिसूचना सक्षम करा",
+ "config-email-watchlist": "निरीक्षणसूची अधिसूचना सक्षम करा",
+ "config-email-sender": "प्रत्युत्तराचा विपत्रपत्ता:",
+ "config-upload-settings": "संचिका अपभारणे",
+ "config-upload-enable": "संचिका अपभारणे सक्षम करा",
+ "config-extensions": "विस्तारके",
+ "config-install-step-done": "झाले",
+ "config-install-extensions": "विस्तारके अंतर्भूत करून",
+ "config-install-tables": "सारण्या बनवित आहे",
+ "config-install-tables-failed": "<strong>त्रुटी:</strong>खालील त्रुटीमुळे सारणी बनविणे अयशस्वी:$1",
+ "config-help": "साहाय्य",
+ "mainpagetext": "'''मीडियाविकीचे इन्स्टॉलेशन पूर्ण.'''",
+ "mainpagedocfooter": "विकी संचेतन वापरण्याकरिता [https://meta.wikimedia.org/wiki/Help:Contents वापरकर्ता मार्गदर्शिका] पहा.\n\n== सुरुवात करा ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings रचित मांडण्यांची यादी]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ मिडियाविकि एफएक्यू]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-मिडियाविकिच्या मेलिंग यादीच्या विमोचनाची उद्घोषणा]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources आपल्या भाषेसाठी मिडियाविकिचे स्थानिकिकरण करा]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam आपल्या विकिवर स्पॅमशी दोन हात कसे करावे ते शिका]"
+}
diff --git a/www/wiki/includes/installer/i18n/ms.json b/www/wiki/includes/installer/i18n/ms.json
new file mode 100644
index 00000000..61acd3ec
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ms.json
@@ -0,0 +1,153 @@
+{
+ "@metadata": {
+ "authors": [
+ "Anakmalaysia",
+ "Pizza1016",
+ "SNN95",
+ "MaxSem",
+ "Aviator",
+ "Macofe",
+ "Jeluang Terluang"
+ ]
+ },
+ "config-desc": "Pemasang MediaWiki",
+ "config-title": "Pemasangan MediaWiki $1",
+ "config-information": "Maklumat",
+ "config-localsettings-upgrade": "Fail <code>LocalSettings.php</code> telah dikesan.\nUntuk menaik taraf pemasangan, sila masukkan nilai <code>$wgUpgradeKey</code> dalam kotak di bawah.\nAnda akan menjumpainya di <code>LocalSettings.php</code> .",
+ "config-localsettings-cli-upgrade": "Fail <code>LocalSettings.php</code> telah dikesan.\nUntuk menaik taraf pemasangan, sila jalankan <code>update.php</code> sebaliknya",
+ "config-localsettings-key": "Kunci naik taraf:",
+ "config-localsettings-badkey": "Kunci yang anda berikan tidak betul.",
+ "config-upgrade-key-missing": "Pemasangan yang sedia ada MediaWiki telah dikesan.\nUntuk menaik taraf pemasangan, Sila letakkan baris berikut di bahagian bawah <code>LocalSettings.php</code> anda:\n\n$1",
+ "config-localsettings-incomplete": "<code>LocalSettings.php</code> sedia ada nampaknya tidak lengkap.\nPemboleh ubah $1 tidak disetkan.\nSila tukar <code>LocalSettings.php</code> supaya pemboleh ubah ini disetkan, dan klik \"{{int:Config-terus}}\".",
+ "config-localsettings-connection-error": "Ralat berlaku semasa menyambung ke pangkalan data dengan menggunakan tetapan yang dinyatakan dalam <code>LocalSettings.php</code>. Sila betulkan tetapan tersebut dan cuba lagi.\n\n$1",
+ "config-session-error": "Ralat ketika memulakan sesi: $1",
+ "config-session-expired": "Data sesi anda seolah-olah telah tamat tempoh.\nSesi dikonfigurasi untuk seumur hidup sebanyak $1.\nAnda boleh menambah ini dengan menetapkan <code>session.gc_maxlifetime</code> di php.ini.\nMemulakan semula proses pemasangan.",
+ "config-no-session": "Data sesi anda telah hilang!\nSemak php.ini anda dan pastikan <code>session.save_path</code> disetkan kepada satu direktori yang sesuai.",
+ "config-your-language": "Bahasa anda:",
+ "config-your-language-help": "Pilihkan bahasa untuk digunakan dalam proses pemasangan ini.",
+ "config-wiki-language": "Bahasa wiki:",
+ "config-wiki-language-help": "Pilih bahasa utama wiki yang bakal dicipta ini.",
+ "config-back": "← Undur",
+ "config-continue": "Teruskan →",
+ "config-page-language": "Bahasa",
+ "config-page-welcome": "Selamat datang ke MediaWiki!",
+ "config-page-dbconnect": "Bersambung dengan pangkalan data",
+ "config-page-upgrade": "Naik taraf pemasangan sedia ada",
+ "config-page-dbsettings": "Tetapan pangkalan data",
+ "config-page-name": "Nama",
+ "config-page-options": "Pilihan",
+ "config-page-install": "Pasang",
+ "config-page-complete": "Selesai!",
+ "config-page-restart": "Mulakan semula pemasangan",
+ "config-page-readme": "Baca saya",
+ "config-page-releasenotes": "Catatan keluaran",
+ "config-page-copying": "Sedang menyalin",
+ "config-page-upgradedoc": "Sedang menaik taraf",
+ "config-page-existingwiki": "Wiki sedia ada",
+ "config-help-restart": "Adakah anda ingin untuk membersihkan semua data yang disimpan yang anda telah masukkan dan memulakan semula proses pemasangan?",
+ "config-restart": "Ya, mula semula",
+ "config-welcome": "=== Pemeriksaan persekitaran ===\nPemeriksaan asas kini boleh dilakukan untuk melihat jika persekitaran ini adalah sesuai untuk pemasangan MediaWiki.\nIngat untuk memasukkan maklumat ini jika anda mahukan sokongan tentang bagaimana untuk menyelesaikan pemasangan.",
+ "config-copyright": "=== Hakcipta dan Syarat-Syarat ===\n\n$1\n\nProgram ini merupakan perisian bebas; anda boleh mengedarkannya semula dan/atau mengubahsuainya di bawah syarat-syarat Lesen Awam GNU seperti yang diterbitkan oleh Yayasan Perisian Bebas; sama ada versi 2 Lesen ini atau (mengikut pilihan anda) mana-mana versi selepas ini.\n\nProgram ini diedarkan dengan harapan bahawa ia akan menjadi berguna, tetapi '''tanpa sebarang waranti'''; tanpa jaminan yang tersirat '''kebolehdagangan''' atau '''kesesuaian untuk tujuan tertentu'''.\nLihat Lesen Awam GNU untuk maklumat lanjut.\n\nAnda sepatutnya telah menerima <doclink href=Copying> satu salinan Lesen Awam GNU </doclink> bersama-sama dengan program ini, jika tidak, menulis surat kepada Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, atau [http://www.gnu.org/copyleft/gpl.html membacanya dalam talian].",
+ "config-sidebar": "* [https://www.mediawiki.org Laman utama MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Panduan Pengguna]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Panduan Penyelia]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Soalan lazim]\n----\n* <doclink href=Readme>Baca saya</doclink>\n* <doclink href=ReleaseNotes>Nota keluaran</doclink>\n* <doclink href=Copying>Menyalin</doclink>\n* <doclink href=UpgradeDoc>Menaik taraf</doclink>",
+ "config-env-good": "Persekitaran telah diperiksa.\nAnda boleh memasang MediaWiki.",
+ "config-env-bad": "Persekitaran telah diperiksa. \nAnda tidak boleh memasang MediaWiki.",
+ "config-env-php": "PHP $1 dipasang.",
+ "config-unicode-using-intl": "[http://pecl.php.net/intl Sambungan intl PECL] digunakan untuk penormalan Unicode.",
+ "config-unicode-update-warning": "<strong>Amaran:</strong> Versi pembalut penormalan Unicode yang terpasang menggunakan perpustakaan [http://site.icu-project.org/ projek ICU] dalam versi yang lampau.\nAnda harus [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations menaik taraf] jika Unicode penting bagi anda.",
+ "config-outdated-sqlite": "<strong>Amaran:</strong> anda mempunyai SQLite $1 yang lebih rendah daripada versi keperluan minimum $1. SQLite tidak akan disediakan.",
+ "config-no-fts3": "<strong>Amaran:</strong> SQLite disusun tanpa [//sqlite.org/fts3.html modil FTS3], maka ciri-ciri pencarian tidak akan disediakan pada backend ini.",
+ "config-pcre-old": "<strong>Amaran keras:</strong> PCRE $1 ke atas diperlukan.\nBinari PHP anda berpaut dengan PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Keterangan lanjut].",
+ "config-memory-bad": "<strong>Amaran:</strong> <code>memory_limit</code> (Had memori) PHP ialah $1.\nIni mungkin terlalu rendah.\nPemasangan mungkin akan gagal!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] dipasang",
+ "config-apc": "[http://www.php.net/apc APC] dipasang",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] dipasang",
+ "config-mod-security": "<strong>Amaran:</strong> Pelayan web anda dihidupkan [http://modsecurity.org/ mod_security]/mod_security2. Kebanyakan konfigurasinya yang umum boleh menimbulkan kesulitan untuk MediaWiki dan perisian-perisian lain yang membolehkan pengguna untuk mengeposkan kandungan yang sewenang-wenang.\nJika boleh, ciri-ciri ini harus dimatikan. Jika tidak, rujuki [http://modsecurity.org/documentation/ dokumentasi mod_security] atau hubungi bantuan hos anda jika anda menghadapi ralat sembarangan.",
+ "config-diff3-bad": "GNU diff3 tidak dijumpai.",
+ "config-git": "Perisian kawalan versi Git dijumpai: <code>$1</code>.",
+ "config-git-bad": "Perisian kawalan versi Git tidak dijumpai.",
+ "config-no-cli-uri": "<strong>Amaran:</strong> Tiada <code>--scriptpath</code> dinyatakan, maka digunakannya yang asali: <code>$1</code>.",
+ "config-using-server": "Sedang menggunakan nama pelayan \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Sedang menggunakan URL pelayan \"<nowiki>$1$2</nowiki>\".",
+ "config-no-cli-uploads-check": "<strong>Amaran:</strong> Direktori asali anda untuk muat naikan (<code>$1</code>) belum diperiksa untuk kerentanan\nkepada pelaksanaan skrip yang menyeleweng sewaktu pemasangan CLI.",
+ "config-db-type": "Jenis pangkalan data:",
+ "config-db-host": "Hos pangkalan data:",
+ "config-db-host-oracle": "TNS pangkalan data:",
+ "config-db-name": "Nama pangkalan data:",
+ "config-db-name-oracle": "Skema pangkalan data:",
+ "config-db-username": "Nama pengguna pangkalan data:",
+ "config-db-password": "Kata laluan pangkalan data:",
+ "config-db-prefix": "Awalan jadual pangkalan data:",
+ "config-db-port": "Port pangkalan data:",
+ "config-db-schema": "Skema untuk MediaWiki:",
+ "config-pg-test-error": "Tidak boleh bersambung dengan pangkalan data <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Direktori data SQLite:",
+ "config-oracle-def-ts": "Ruang jadual lalai:",
+ "config-oracle-temp-ts": "Ruang jadual sementara:",
+ "config-type-mysql": "MySQL (atau yang serasi)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "Keutamaan MySQL",
+ "config-header-postgres": "Keutamaan PostgreSQL",
+ "config-header-sqlite": "Keutamaan SQLite",
+ "config-header-oracle": "Keutamaan Oracle",
+ "config-header-mssql": "Tetapan Microsoft SQL Server",
+ "config-invalid-db-type": "Jenis pangkalan data tidak sah",
+ "config-can-upgrade": "Terdapat jadual MediaWiki dalam pangkalan data ini. Untuk menaik tarafnya kepada MediaWiki $1, klik <strong>Teruskan</strong>.",
+ "config-unknown-collation": "<strong>Amaran:</strong> Pangkalan data sedang menggunakan kolasi yang tidak dikenali.",
+ "config-db-web-account-same": "Gunakan akaun yang sama seperti dalam pemasangan",
+ "config-db-web-create": "Ciptakan akaun jika belum wujud",
+ "config-mysql-engine": "Enjin storan:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-only-myisam-dep": "<strong>Amaran:</strong> MyISAM ialah satu-satunya enjin storan yang terdapat untuk MySQL di mesin ini, dan penggunaannya dengan MediaWiki tidak digalakkan kerana:\n* ia tidak menyokong keserempakan (''concurrency'') disebabkan penguncian jadual\n* ia lebih terdedah kepada korupsi daripada enjin-enjin lain\n* pangkalan kod MediaWiki tidak sentiasa mengendalikan MyISAM seperti yang diharapkan\n\nPemasangan MySQL anda tidak menyokong InnoDB. Mungkin tiba masanya untuk naik taraf.",
+ "config-mysql-charset": "Peranggu aksara pangkalan data:",
+ "config-mysql-binary": "Perduaan",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Jenis pengesahan:",
+ "config-site-name": "Nama wiki:",
+ "config-site-name-help": "Ini akan dipaparkan pada bar tajuk perisian pelayar dan tempat-tempat lain yang berkenaan.",
+ "config-site-name-blank": "Isikan nama tapak.",
+ "config-project-namespace": "Ruang nama projek:",
+ "config-ns-generic": "Projek",
+ "config-ns-site-name": "Sama dengan nama wiki: $1",
+ "config-ns-other": "Lain-lain (nyatakan)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "Akaun penyelia",
+ "config-admin-name": "Nama pengguna anda:",
+ "config-admin-password": "Kata laluan:",
+ "config-admin-password-confirm": "Kata laluan lagi:",
+ "config-admin-name-blank": "Masukkan nama pengguna pentadbir.",
+ "config-admin-password-blank": "Berikan kata laluan untuk akaun pentadbir.",
+ "config-admin-password-mismatch": "Kata-kata laluan yang kamu berikan tidak sepadan.",
+ "config-admin-email": "Alamat e-mel:",
+ "config-admin-error-bademail": "Kamu telah memberikan alamat e-mel yang tidak betul.",
+ "config-optional-skip": "Saya sudah bosan, pasangkanlah wiki sahaja.",
+ "config-profile-wiki": "Wiki terbuka",
+ "config-profile-no-anon": "Pembukaan akaun diwajibkan",
+ "config-profile-private": "Wiki tertutup",
+ "config-license": "Hak cipta dan lesen:",
+ "config-license-none": "Tiada pengaki lesen",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (Domain Awam)",
+ "config-license-gfdl": "Lesen Pendokumenan Bebas GNU 1.3 atau ke atas",
+ "config-license-pd": "Domain Awam",
+ "config-email-settings": "Tetapan e-mel",
+ "config-skins": "Rupa",
+ "config-skins-use-as-default": "Gunakan rupa ini sebagai asal",
+ "config-install-step-done": "siap",
+ "config-install-step-failed": "gagal",
+ "config-install-user-alreadyexists": "Pengguna \"$1\" sudah wujud",
+ "config-install-tables": "Mencipta jadual",
+ "config-install-tables-exist": "<strong>Amaran:</strong> Nampaknya sudah terdapat jadual MediaWiki. Penciptaan dilangkau.",
+ "config-install-interwiki": "Mengisi jadual antara wiki lalai",
+ "config-install-interwiki-list": "Fail <code>interwiki.list</code> tidak dapat dibaca.",
+ "config-install-interwiki-exists": "<strong>Amaran:</strong> Jadual antara wiki nampaknya sudah ada entri. Senarai asali dilangkau.",
+ "config-install-keys": "Menjana kunci-kunci rahsia",
+ "config-insecure-keys": "<strong>Amaran:</strong> {{PLURAL:$2|Kunci keselamatan|Kunci-kunci keselamatan}} ($1) yang dihasilkan sewaktu pemasangan itu {{PLURAL:$2|adalah}} tidak selamat sepenuhnya. Oleh itu, {{PLURAL:$2|ia}} wajar ditukar secara manual.",
+ "config-install-sysop": "Membuka akaun pengguna pentadbir",
+ "config-install-mainpage": "Mewujudkan laman utama dengan kandungan lalai",
+ "config-help": "bantuan",
+ "mainpagetext": "'''MediaWiki telah berjaya dipasang.'''",
+ "mainpagedocfooter": "Sila rujuk [https://meta.wikimedia.org/wiki/Help:Contents Panduan Penggunaan] untuk maklumat mengenai penggunaan perisian wiki ini.\n\n== Permulaan ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Senarai tetapan konfigurasi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Soalan Lazim MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Senarai surat keluaran MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Terjemahkan MediaWiki ke dalam bahasa anda]"
+}
diff --git a/www/wiki/includes/installer/i18n/mt.json b/www/wiki/includes/installer/i18n/mt.json
new file mode 100644
index 00000000..ad1da47c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mt.json
@@ -0,0 +1,90 @@
+{
+ "@metadata": {
+ "authors": [
+ "Chrisportelli",
+ "Leli Forte"
+ ]
+ },
+ "config-title": "Installazzjoni ta' MediaWiki $1",
+ "config-information": "Informazzjoni",
+ "config-localsettings-key": "Ċavetta tal-aġġornament:",
+ "config-localsettings-badkey": "Iċ-ċavetta li tajt hija ħażina.",
+ "config-your-language": "Il-lingwa tiegħek:",
+ "config-your-language-help": "Agħżel lingwa li tixtieq tuża' matul il-proċess ta' installazzjoni.",
+ "config-wiki-language": "Lingwi tal-wiki:",
+ "config-wiki-language-help": "Agħżel il-lingwa li l-wiki se tkun l-aktar użata fil-wiki.",
+ "config-back": "← Lura",
+ "config-continue": "Kompli →",
+ "config-page-language": "Lingwa",
+ "config-page-welcome": "Merħba fuq MediaWiki!",
+ "config-page-dbconnect": "Aqbad mad-databażi",
+ "config-page-upgrade": "Aġġorna l-installazzjoni eżistenti",
+ "config-page-dbsettings": "Impostazzjonijiet tad-databażi",
+ "config-page-name": "Isem",
+ "config-page-options": "Għażliet",
+ "config-page-install": "Installa",
+ "config-page-complete": "Lesta!",
+ "config-page-restart": "Erġa' ibda l-installazzjoni",
+ "config-page-readme": "Aqrani",
+ "config-page-releasenotes": "Noti tal-verżjoni",
+ "config-page-upgradedoc": "Aġġornament",
+ "config-page-existingwiki": "Wiki eżistenti",
+ "config-restart": "Iva, erġa' ibda",
+ "config-env-php": "PHP $1 huwa installat.",
+ "config-env-hhvm": "HHVM $1 hu installat.",
+ "config-db-wiki-settings": "Identifika din il-wiki",
+ "config-db-name": "Isem tad-databażi:",
+ "config-db-install-account": "Kont tal-utent għall-installazzjoni",
+ "config-db-username": "Isem tal-utent tad-databażi:",
+ "config-db-password": "Password tad-databażi:",
+ "config-db-port": "Port tad-databażi:",
+ "config-db-schema": "Skema għal MediaWiki:",
+ "config-db-web-create": "Oħloq il-kont jekk għadu ma jeżistix",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Sett ta' karattri tad-databażi:",
+ "config-mysql-binary": "Binarju",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Isem tal-wiki:",
+ "config-site-name-help": "Dan se jidher fil-barra tat-titlu tal-browżer u f'diversi postijiet oħra.",
+ "config-site-name-blank": "Daħħal isem tas-sit.",
+ "config-project-namespace": "Spazju tal-ismijiet tal-proġett:",
+ "config-ns-generic": "Proġett",
+ "config-ns-site-name": "L-istess bħall-isem tal-wiki: $1",
+ "config-ns-other": "Oħrajn (speċifika)",
+ "config-ns-other-default": "MyWiki",
+ "config-ns-invalid": "L-ispazju speċifikat \"<nowiki>$1</nowiki>\" huwa ħażin.\nSpeċifika spazju tal-ismijiet ta' proġett differenti.",
+ "config-ns-conflict": "L-ispazju speċifikat \"<nowiki>$1</nowiki>\" joħloq kunflitt ma' spazju tal-ismijiet tal-MediaWiki predeterminat.\nSpeċifika spazju tal-ismijiet ta' proġett differenti.",
+ "config-admin-box": "Kont tal-amministratur",
+ "config-admin-name": "Ismek:",
+ "config-admin-password": "Password:",
+ "config-admin-password-confirm": "Erġa' daħħal il-password:",
+ "config-admin-help": "Daħħal l-isem tal-utent preferit hawnhekk, per eżempju \"Joe Borg\".\nDan huwa l-isem li se tuża' kull darba li tidħol fil-wiki.",
+ "config-admin-name-blank": "Daħħal isem tal-utent għall-amministratur.",
+ "config-admin-name-invalid": "L-isem tal-utent speċifikat \"<nowiki>$1</nowiki>\" huwa ħażin.\nSpeċifika isem tal-utent differenti.",
+ "config-admin-password-blank": "Daħħal password għall-kont tal-amministratur.",
+ "config-admin-password-mismatch": "Il-passwords li daħħalt ma jaqblux.",
+ "config-admin-email": "Indirizz elettroniku:",
+ "config-admin-error-bademail": "Daħħalt indirizz elettroniku ħażin.",
+ "config-almost-done": "Kważi lest!\nJekk trid tista' taqbeż il-parti li jmiss tal-konfigurazzjoni u sempliċiment tinstalla l-wiki.",
+ "config-optional-continue": "Staqsini aktar mistoqsijiet.",
+ "config-optional-skip": "Xbajt diġà, installa l-wiki.",
+ "config-profile-wiki": "Wiki tradizzjonali",
+ "config-profile-no-anon": "Huwa obbligatorju l-ħolqien tal-kont",
+ "config-profile-fishbowl": "Edituri awtorizzati biss",
+ "config-profile-private": "Wiki privata",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (dominju pubbliku)",
+ "config-license-pd": "Dominju pubbliku",
+ "config-license-cc-choose": "Agħżel waħda mil-liċenzji tal-Creative Commons",
+ "config-upload-deleted": "Direttorju għall-fajls imħassra:",
+ "config-upload-deleted-help": "Agħżel direttorju fejn iżżomm fajls imħassra.\nIdealment, dan m'għandux ikun aċċessibbli mill-web.",
+ "config-logo": "URL tal-logo:",
+ "config-download-localsettings": "Niżżel <code>LocalSettings.php</code>",
+ "config-help": "għajnuna",
+ "config-nofile": "Il-fajl \"$1\" ma setax jinstab. Dan ġie mħassar?",
+ "mainpagetext": "'''MediaWiki ġie installat b'suċċess.'''",
+ "mainpagedocfooter": "Ikkonsulta l-[https://meta.wikimedia.org/wiki/Help:Contents Gwida għall-utenti] sabiex tikseb iktar informazzjoni dwar kif tuża' s-softwer tal-wiki.\n\n== Biex tibda ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista ta' preferenzi għall-konfigurazzjoni]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Mistoqsijiet rikorrenti fuq il-MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Il-lista tal-posta tħabbar 'l MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/my.json b/www/wiki/includes/installer/i18n/my.json
new file mode 100644
index 00000000..a37336ee
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/my.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Lionslayer",
+ "Ninjastrikers"
+ ]
+ },
+ "config-help": "အကူအညီ",
+ "mainpagetext": "<strong>မီဒီယာဝီကီကို အောင်မြင်စွာ သွင်းပြီးပါပြီ။</strong>"
+}
diff --git a/www/wiki/includes/installer/i18n/myv.json b/www/wiki/includes/installer/i18n/myv.json
new file mode 100644
index 00000000..02e8b1cc
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/myv.json
@@ -0,0 +1,16 @@
+{
+ "@metadata": {
+ "authors": [
+ "Botuzhaleny-sodamo"
+ ]
+ },
+ "config-page-language": "Кель",
+ "config-page-name": "Лемезэ",
+ "config-page-readme": "Ловномак",
+ "config-admin-name": "Совамовалот:",
+ "config-admin-password": "Совамо валот:",
+ "config-admin-password-confirm": "Совамо валот одов:",
+ "config-admin-email": "Е-сёрма паргот:",
+ "config-install-step-done": "теезь",
+ "mainpagetext": "'''МедияВикинь тевс аравтомазо парсте лиссь.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/mzn.json b/www/wiki/includes/installer/i18n/mzn.json
new file mode 100644
index 00000000..b8218463
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/mzn.json
@@ -0,0 +1,59 @@
+{
+ "@metadata": {
+ "authors": [
+ "محک"
+ ]
+ },
+ "config-information": "اطلاعات",
+ "config-localsettings-key": "آپگریت کلید:",
+ "config-your-language": "شمه زوون:",
+ "config-wiki-language": "ویکی زوون:",
+ "config-back": "→ دِگِرِستِن",
+ "config-continue": "دمباله ←",
+ "config-page-language": "زوون",
+ "config-page-welcome": "مدیاویکی ره خِش بمونی!",
+ "config-page-dbconnect": "اتصال به دیتابیس",
+ "config-page-name": "نوم",
+ "config-page-options": "تنظیمات",
+ "config-page-install": "نصب",
+ "config-page-complete": "کامل!",
+ "config-page-restart": "دِباره نصب هاکردن",
+ "config-page-readme": "مه ره ونگ هاکن",
+ "config-page-releasenotes": "انتشار یادداشتون",
+ "config-page-copying": "کپی هاکردن",
+ "config-page-upgradedoc": "آپگریت هاکردن",
+ "config-page-existingwiki": "دیی ویکی",
+ "config-help-restart": "خانی تموم اطلاعاتی که ذخیره بینه ره حذف هاکنین و اَی این صوه سَری دِباره نصب هاکردن ره شروع هاکنین؟",
+ "config-restart": "اره. دِباره",
+ "config-env-good": "محیط بررسی بیّه.\nشما توندی مدیاویکی ره نصب هاکنی.",
+ "config-env-bad": "محیط بررسی بیه.\nشما نتوندی مدیاویکی ره نصب هاکنی.",
+ "config-env-php": "پی‌اچ‌پی $1 نصب بیه.",
+ "config-env-hhvm": "اچ‌اچ‌وی‌ام $1 نصب بیه.",
+ "config-unicode-using-intl": "عادی یونیکد وسه [http://pecl.php.net/intl افزونهٔ intl برای PECL] جه استفاده هاکن.",
+ "config-memory-raised": "PHP's <code>memory_limit</code>, نسخهٔ $1 هسته، ونه نسخهٔ $2 ره بَیری آپگریت هاکنی.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] نصب بیه.",
+ "config-apc": "[http://www.php.net/apc APC] نصب بیه.",
+ "config-apcu": "[http://www.php.net/apcu APCu] نصب بیه.",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] نصب بیه.",
+ "config-diff3-bad": "GNU diff3 پیدا نیه.",
+ "config-mysql-binary": "باینری",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "نوع تأیید:",
+ "config-ns-generic": "پروژه",
+ "config-ns-other": "دیگه ( تعیین هاکنین)",
+ "config-ns-other-default": "مه‌ویکی",
+ "config-admin-box": "مدیر ِکاروری حیساب",
+ "config-admin-name": "شمه کاروری نوم:",
+ "config-admin-password": "پسوُرد:",
+ "config-admin-password-confirm": "دِباره پسورد:",
+ "config-admin-password-mismatch": "دِتا پسوردی که بنویشتی اتجور نینه",
+ "config-admin-email": "ایمیل آدرس:",
+ "config-admin-error-bademail": "شمه ایمیل آدرس مشکل دارنه.",
+ "config-optional-continue": "مه جه ویشته سوال هاپرس.",
+ "config-optional-skip": "اسا خستومه، زودته ویکی ره نصب هاکن.",
+ "config-profile-wiki": "ویکی ره دیار هاکن",
+ "config-profile-private": "خصوصی ویکی",
+ "config-license-pd": "عمومی دامنه",
+ "config-email-settings": "ایمیل تنظیمات",
+ "config-help": "راهنما"
+}
diff --git a/www/wiki/includes/installer/i18n/nah.json b/www/wiki/includes/installer/i18n/nah.json
new file mode 100644
index 00000000..235ff29d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nah.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Taresi"
+ ]
+ },
+ "config-project-namespace": "Tlatequipanōlli ītōcātlacāuh:",
+ "mainpagetext": "'''MediaHuiqui cualli ōmotlahtlāli.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/nan.json b/www/wiki/includes/installer/i18n/nan.json
new file mode 100644
index 00000000..697b9fb0
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nan.json
@@ -0,0 +1,58 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ianbu",
+ "唐吉訶德的侍從"
+ ]
+ },
+ "config-desc": "MediaWiki的安裝程式",
+ "config-title": "MediaWiki $1的安裝",
+ "config-information": "資訊",
+ "config-localsettings-upgrade": "有一个<code>LocalSettings.php</code>檔案佇咧。若欲升級,請佇下面的框內底拍<code>$wgUpgradeKey</code>的內容。你會使佇<code>LocalSettings.php</code>內底揣著彼項。",
+ "config-localsettings-cli-upgrade": "有一个<code>LocalSettings.php</code>檔案。若欲升級,請直接執行<code>update.php</code>。",
+ "config-localsettings-key": "Seng-kip--ê bi̍t-bé:",
+ "config-localsettings-badkey": "Lí phah--ê bi̍t-bé bô chèng-khak.",
+ "config-upgrade-key-missing": "已經有一个MediaWiki矣。若要升級,請共下面這逝加去<code>LocalSettings.php</code>的下跤:\n\n$1",
+ "config-localsettings-incomplete": "這馬的<code>LocalSettings.php</code>可能無齊全,因為無設變量$1。請佇<code>LocalSettings.php</code>設彼个變量,並且揤「{{int:Config-continue}}」。",
+ "config-localsettings-connection-error": "An error was encountered when connecting to the database 用<code>LocalSettings.php</code>的設定去連接資料庫的時陣有一个錯誤發生,請改遮的設定了,才閣試。\n\n$1",
+ "config-session-error": "連線開始了的錯誤:$1",
+ "config-session-expired": "你連線資料已經過時矣,連線的使用期限是設做$1。你會使改共php.ini的<code>session.gc_maxlifetime</code>改較長,並且重新安裝動作。",
+ "config-no-session": "你連線的資料已經無去矣,看你的php.ini,並且確定<code>session.save_path</code>是正確的目錄。",
+ "config-your-language": "你的話語:",
+ "config-your-language-help": "選一个安裝過程時欲用的話語",
+ "config-wiki-language": "Wiki話語",
+ "config-wiki-language-help": "選一个Wiki大部份用的話",
+ "config-back": "← 倒退",
+ "config-continue": "繼續 →",
+ "config-page-language": "話語",
+ "config-page-welcome": "歡迎來MediaWiki!",
+ "config-page-dbconnect": "連接去資料庫",
+ "config-page-upgrade": "共這馬的安裝升級",
+ "config-page-dbsettings": "資料庫的設定",
+ "config-page-name": "Miâ",
+ "config-page-options": "選項",
+ "config-page-install": "安裝",
+ "config-page-complete": "完成",
+ "config-page-restart": "重裝",
+ "config-page-readme": "讀我",
+ "config-page-releasenotes": "發布的說明",
+ "config-page-copying": "複製",
+ "config-page-upgradedoc": "升級",
+ "config-page-existingwiki": "已經裝的Wiki",
+ "config-help-restart": "你敢欲共你拍的佮保存的資料攏清掉,並且重開始安裝的動作?",
+ "config-restart": "是,重來",
+ "config-welcome": "=== 環境檢測 ===\n這馬欲做基本的檢測,看環境是毋是適合裝 MediaWiki。\n若你愛有支援,才裝會起來,請共遮的資訊記起來。",
+ "config-copyright": "=== 版權聲明佮授權條款 ===\n\n$1\n\n本程式是自由軟體;你會當照自由軟體基金會所發表的 GNU 通用公共授權條款規定,共本程式重新發佈抑是修改;無論你是照本授權條款的第二版抑第二版以後的任何版本(你會當家己選) 。\n\n本程式發佈的目的是希望會當提供幫助,但是 <strong>無負任何擔保責任</strong>;抑無表示講對 <strong>販賣性</strong> 抑 <strong>特定用途的適用性</strong> 的情形擔保。詳情請參照 GNU 通用公共授權。\n\n你應該已隨本程式收著 <doclink href=\"Copying\">GNU 通用公共授權條款的副本</doclink>;若無,請寫批通知自由軟體基金會,51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA,或 [http://www.gnu.org/copyleft/gpl.html 線頂看]。",
+ "config-sidebar": "* [www.mediawiki.org/wiki/MediaWiki/zh-hant MediaWiki 頭頁]\n* [www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/zh 使用者指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/zh 管理者指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh-hant 四常問題集]\n----\n* <doclink href=Readme>讀我說明</doclink>\n* <doclink href=ReleaseNotes>發行說明</doclink>\n* <doclink href=Copying>版權聲明</doclink>\n* <doclink href=UpgradeDoc>升級</doclink>",
+ "config-env-good": "環境檢查已完成。\n你會當安裝 MediaWiki。",
+ "config-env-bad": "環境檢查已完成。\n你無法度安裝 MediaWiki。",
+ "config-env-php": "PHP $1 已經安裝。",
+ "config-unicode-using-intl": "用 [http://pecl.php.net/intl intl PECL 擴充套件] 做 Unicode 正規化。",
+ "config-unicode-pure-php-warning": "<strong>警告:</strong> 無法度用 [http://pecl.php.net/intl intl PECL 擴充套件] 處理 Unicode 正規化,所以退回用純 PHP 實作的正規化程式,這種方式處理速度較慢。\n\n若你的網站瀏覽人數誠濟,你應該先看 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations/zh Unicode 正規化]。",
+ "config-unicode-update-warning": "<strong>警告</strong>:這馬安裝的 Unicode 正規化包裝程式用舊版 [http://site.icu-project.org/ ICU 計劃] 的程式庫。\n若你需要用 Unicode,你應該先進行 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations 升級]。",
+ "config-no-db": "揣無適合的資料庫驅動程式!你需要安裝 PHP 資料庫驅動程式。\n這馬支援下跤類型的資料庫: $1 。\n\n若你是家己編譯 PHP,你需要重新設定並且開資料庫客戶端,譬如:用 <code>./configure --with-mysqli</code> 指令參數。\n如你是用 Debian 或 Ubuntu 的套件安裝,你著需要閣另外安裝,例:<code>php5-mysql</code> 套件。",
+ "config-outdated-sqlite": "<strong>警告:</strong>你已經安裝 SQLite $1,毋閣伊的版本比會當裝的版本 $2閣較舊。所以你無法度用 SQLite。",
+ "config-no-fts3": "<strong>警告:</strong> SQLite 佇編譯的時陣無包括 [//sqlite.org/fts3.html FTS3 模組],後台搜揣功能就會無法度用。",
+ "mainpagetext": "'''MediaWiki已經裝好矣。'''",
+ "mainpagedocfooter": "請查看[https://meta.wikimedia.org/wiki/Help:Contents 用者說明書]的資料通使用wiki 軟體\n\n== 入門 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 配置的設定]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki時常問答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki的公布列單]"
+}
diff --git a/www/wiki/includes/installer/i18n/nap.json b/www/wiki/includes/installer/i18n/nap.json
new file mode 100644
index 00000000..84fb9a3c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nap.json
@@ -0,0 +1,309 @@
+{
+ "@metadata": {
+ "authors": [
+ "C.R.",
+ "Chelin",
+ "Macofe"
+ ]
+ },
+ "config-desc": "'O prugramma d'istallazione 'e MediaWiki",
+ "config-title": "Installazione 'e MediaWiki $1",
+ "config-information": "Nfurmaziune",
+ "config-localsettings-upgrade": "È stato rilevato nu file <code>LocalSettings.php</code>.\nP'agghiurnà sta installazione, pe' piacere nzertàte 'o valore 'e <code>$wgUpgradeKey</code> dint' 'a cascia ccà abbascio.\n'O putite truvà dint'a <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "È stato scummigliato nu file <code>LocalSettings.php</code>.\nPe l'agghiurnà sta installazione, secutate <code>update.php</code>",
+ "config-localsettings-key": "Chiave d'agghiurnamiento:",
+ "config-localsettings-badkey": "'A chiave 'agghiurnamento c'avite dato nun è curretta.",
+ "config-upgrade-key-missing": "S'è scummigliata n'installazione 'e MediaWiki ch'esisteva già.\nPe' ll'agghiurnà, nzertate pe' piacere sta riga ccà abbascio dint' 'a parta vascia d' 'o <code>LocalSettings.php</code> vuosto:\n\n$1",
+ "config-localsettings-incomplete": "'O file <code>LocalSettings.php</code> esistente pare ca fosse cumpleto a metà.\n'A variabbele $1 nun è mpustata.\nCagnate <code>LocalSettings.php</code> in modo ca sta variabbele fosse mpustata e facite clic ncopp'a \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "S'è truvato n'errore pe' tramente ca se faceva 'a connessione a 'o database ausanno 'e mpustaziune specificate dint'a <code>LocalSettings.php</code>. Pe' piacere curriggite sti mpustaziuni e provate n'ata vota.\n\n$1",
+ "config-session-error": "Errore facenno accumincià 'a sessione: $1",
+ "config-session-expired": "'E date d' 'a sessione pareno ammaturate.\n'E sessiune so' configurate pe na durata 'e $1.\n'A putite aumentà pe' bbìa 'e na mpustazione <code>session.gc_maxlifetime</code> dint' 'o file php.ini.\nRiabbìa 'o prucesso 'e installazione.",
+ "config-no-session": "'E date d' 'a sessione so' state perdute!\nCuntrullate 'o file php.ini vuosto e assicurateve ca 'a <code>session.save_path</code> è stata mpustata ncopp'a na cartella appropriata.",
+ "config-your-language": "'A lengua vosta:",
+ "config-your-language-help": "Scigliete na lengua pe' l'ausà pe' tramente ca se fa 'o prucesso 'installazione.",
+ "config-wiki-language": "Lengua d' 'o wiki:",
+ "config-wiki-language-help": "Scigliete 'a lengua ca sarrà ausàta prevalentemente ncopp' 'a wiki.",
+ "config-back": "← Arreto",
+ "config-continue": "Annanze →",
+ "config-page-language": "Lengua",
+ "config-page-welcome": "Bemmenute a MediaWiki!",
+ "config-page-dbconnect": "Connessione a 'o database",
+ "config-page-upgrade": "Agghiuorna l'istallazione esistente",
+ "config-page-dbsettings": "Mpustaziune d' 'o database",
+ "config-page-name": "Nomme",
+ "config-page-options": "Opziune",
+ "config-page-install": "Installa",
+ "config-page-complete": "Cumpreta!",
+ "config-page-restart": "Riabbìa l'installazione",
+ "config-page-readme": "Lieggeme",
+ "config-page-releasenotes": "Note 'e verziona",
+ "config-page-copying": "Copia",
+ "config-page-upgradedoc": "Agghiurnanno",
+ "config-page-existingwiki": "Wiki esistente",
+ "config-help-restart": "Vulite scancellà tutt' 'e date astipate c'avite nzertato e riabbià 'o prucesso d'installazione?",
+ "config-restart": "Sì, riabbìa",
+ "config-welcome": "=== Cuntrollo 'e ll'ambiente ===\nSarranno eseguite 'e cuntrolle bbase pe' putè vedè si st'ambiente è adatto pe' ne ffà l'installazione 'e MediaWiki.\nArricurdateve d'includere sti nfurmaziune si spiate assistenza ncopp' 'a maniera 'e cumpletà l'installazione.",
+ "config-copyright": "=== Copyright e termine ===\n\n$1\n\nChistu programma è nu software libbero; vuje 'o putite redestribbuì e/o cagnà sott' 'e termine d' 'a licienza GNU GPL ('a Licienza Pubbreca Generale) comme pubbrecata d' 'a Free Software Foundation; o pure 'a verziona 2 d' 'a Licienza, o pure (comme vulite vuje) 'a n'ata verziona cchiù nnova.\n\nChistu programma è destribbuito c' 'a speranza d'essere utile, ma SENZA NISCIUNA GARANZIA; senza manco 'a garanzia p' 'a CUMMERCIABBELETÀ O IDONIETÀ PE' NU SCOPO PARTICULARE.\nIate a vedé 'a GNU GPL pe' n'avé cchiù nfurmaziune.\n\nCu stu programma avísseve 'a ricevere <doclink href=Copying>na copia d' 'a Licienza GNU GPL</doclink> cu stu prugramma; si nò, scrivete â Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA o [http://www.gnu.org/copyleft/gpl.html liggite sta paggena ncopp' 'a l'Internet].",
+ "config-sidebar": "* [https://www.mediawiki.org Paggina prencepale MediaWiki]\n* [https://www.mediawiki.org/wiki/Aiuto:Guida a 'e cuntenute pe' l'utente]\n* [https://www.mediawiki.org/wiki/Manuale:Guida a 'e cuntenute pe l'ammenistrature]\n* [https://www.mediawiki.org/wiki/Manuale:FAQ FAQ]\n----\n* <doclink href=Readme>Lieggeme</doclink>\n* <doclink href=ReleaseNotes>Note 'e verziona</doclink>\n* <doclink href=Copying>Copie</doclink>\n* <doclink href=UpgradeDoc>Agghiurnamento</doclink>",
+ "config-env-good": "L'ambiente è stato cuntrullato.\nÈ pussibbele installare MediaWiki.",
+ "config-env-bad": "L'ambiente è stato cuntrullato.\nNun se può installà MediaWiki.",
+ "config-env-php": "PHP $1 è installato.",
+ "config-env-hhvm": "HHVM $1 è installato.",
+ "config-unicode-using-intl": "Aúsa [http://pecl.php.net/intl l'estensione PECL intl] pe' ne fà 'a normalizzazione Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Attenziò:</strong> L' [http://pecl.php.net/intl estensione intl PECL] nun è a disposizione pe' gestire 'a normalizzazione Unicode, accussì se ausasse n'imprementazziona llenta 'n puro PHP.\nSi state a gestire nu pizzo ad alto traffico, avisseve a lieggere cocche considerazione ncopp' 'a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzaziona Unicode].",
+ "config-unicode-update-warning": "<strong>Attenziò:</strong> 'A verziona installata 'e normalizzazione Unicode aùsa 'a verziona viecchia d' 'o [http://site.icu-project.org/ pruggetto ICU].\nV'avite 'a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations agghiurnà] si state a penzà ncopp' 'o fatto d'ausà Unicode.",
+ "config-no-db": "Nun se può truvà nu driver adatto p' 'o database! È necessario installare nu driver p' 'o PHP.\n{{PLURAL:$2|'O furmato suppurtato|'E furmate suppurtate}} 'e database ccà annanze: $1.\n\nSi cumpilate PHP autonomamente, riaccunciatevello attivando nu client database, p'esempio ausannoo <code>./configure --with-mysqli</code>.\nQuanno fosse installato PHP pe' bbìa 'e nu pacchetto Debian o Ubuntu, allora avite 'a installà pure 'o pacchetto <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Attenziò''': tenite 'o SQLite $1 pe' tramente ca ce vulesse 'a verziona $2, SQLite nun sarrà a disposizione.",
+ "config-no-fts3": "'''Attenziò''': SQLite è cumpilato senza 'o [//sqlite.org/fts3.html modulo FTS3], 'e funziune 'e p'ascià dinto nun sarranno a disposizione ncopp'a stu backend.",
+ "config-pcre-old": "<strong>Errore fatale:</strong> s'addimanna PCRE $1 o succiessivo.\n'O file vuosto binario PHP è acucchiato c' 'o PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Cchiù nfurmaziune].",
+ "config-pcre-no-utf8": "<strong>Fatale:</strong> 'E module PCRE d' 'o PHP pare ca se so' compilate senza PCRE_UTF8 supporto.\nA MediaWiki serve nu supporto UTF-8 pe' putè funziunà apposto.",
+ "config-memory-raised": "'O valore 'e PHP <code>memory_limit</code> è $1, aumentato a $2.",
+ "config-memory-bad": "<strong>Attenziò:</strong> 'o valore 'e PHP <code>memory_limit</code> è $1.\nProbabbilmente troppo basso.\n'A installazione se putesse scassà!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] è installato",
+ "config-apc": "[http://www.php.net/apc APC] è installato",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] è installato",
+ "config-no-cache-apcu": "<strong>Attenziò:</strong> [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] o [http://www.iis.net/download/WinCacheForPhp WinCache] nun so' state truvate.\n'A funziona caching 'e ll'oggette non è apicciata.",
+ "config-mod-security": "<strong>Attenziò:</strong> 'O servitore web vuosto téne [http://modsecurity.org/ mod_security]/mod_security2 appicciato. Ce stanno tante mpustaziune commune ca 'o facessero causà prubbleme a MediaWiki e ll'ati software ca permettessero ll'utente 'e pubbrecà cuntenute.\nSi putite, stutate sta funziona. Sinò, riferite 'a [http://modsecurity.org/documentation/ documentaziona ncopp' 'o mod_security] o cuntattate 'o host vuosto pe' ve dà supporto quanno se scummogliasse cocch'errore.",
+ "config-diff3-bad": "GNU diff3 nun truvato.",
+ "config-git": "Truvato software 'e cuntrollo d' 'a verziona Git: <code>$1</code>.",
+ "config-git-bad": "Software 'e cuntrollo d' 'a verziona Git nun truvato.",
+ "config-imagemagick": "Truvato ImageMagick: <code>$1</code>.\n'E miniature d' 'e fiùre sarranno prisente si l'upload song'abbiàte.",
+ "config-gd": "Truvata 'a bibblioteca ntegrata GD Graphics.\n'E miniature 'e ll'immaggene sarranno prisente si l'upload se song'abbiàte.",
+ "config-no-scaling": "Nun se può truvà 'a bibblioteca GD o ImageMagick.\n'E miniature 'e l'immaggene sarranno stutate.",
+ "config-no-uri": "<strong>Errore:</strong> Nun se può determina l'URI 'e mmò.\nInstallazione spezzata.",
+ "config-no-cli-uri": "'''Attenziò''': <code>--scriptpath</code> nun specificato, s'aùsa 'o valore predefinito: <code>$1</code>.",
+ "config-using-server": "Nomme d' 'o server ca se stà ausanno \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "URL d' 'o server ca se stà ausanno \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Attenziò:</strong> 'a cartella predefinita p' 'e carreche <code>$1</code> è vulnerabbele all'esecuzione arbitraria 'e script.\nPure si MediaWiki cuntrolla tutt' 'e file carrecate pe' rischio 'a sicurezza se raccumanna 'e [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security nchiure sta vulnerabbilità 'e sicurezza] apprimm'appiccià 'a funcione 'e carreche.",
+ "config-no-cli-uploads-check": "<strong>Attenziò:</strong> 'a cartella predefinita p' 'e carreche (<code>$1</code>) nun è stata cuntrullata p' 'a vulnerabbelità ncopp'a l'esecuzione arbitraria 'e script pe' tramente ca se fà l'installazione 'a linea 'e commando.",
+ "config-brokenlibxml": "'O sistema vuosto ave na combinazione 'e verziune 'e PHP e libxml2 nguacchiata ca putesse scassà 'e date 'e MediaWiki 'n manera annascunnusa e pure l'ati apprecaziune p' 'o web.\nAgghiurnate a libxml2 2.7.3 o cchiù muderno ([https://bugs.php.net/bug.php?id=45996 'o bug studiato d' 'o lato PHP]).\nInstallaziona spezzata.",
+ "config-suhosin-max-value-length": "Suhosin è installato e miette lemmeto 'o parametro GET <code>length</code> a $1 byte.\n'O componente MediaWiki ResourceLoader funzionarrà aggirann'a stu lemmeto, ma luvanno prestaziune.\nSi pussibile, avit'a mpustà <code>suhosin.get.max_value_length</code> a 1024 o cchiù auto 'n <code>php.ini</code>, e mpustà <code>$wgResourceLoaderMaxQueryLength</code> a 'o stesso valore 'n <code>LocalSettings.php</code>.",
+ "config-db-type": "Tipo 'e database:",
+ "config-db-host": "Host d' 'o database:",
+ "config-db-host-help": "Si 'o server database vuosto stà mpizzato dint' 'a nu server differente, miette 'o nomme d' 'o host o l'indirizzo IP sujo.\n\nSi state ausanno nu servizio spartuto web hosting, 'aggenzia 'e hosting v'avess'a dà 'o nomme buono 'e nomme host dint' 'a documentaziona suoja.\n\nSi state installanno chisto dint'a nu server Windows cu MySQL, ausanno \"localhost\" può darse ca nun funziona p' 'o nomme server. Si chisto nun funziona, mettite \"127.0.0.1\" p' 'o ndirizzo locale vuosto.\n\nSi state ausanno PostgreSQL, lassate abbacante stu campo pe' v'accucchià cu nu socket Unix.",
+ "config-db-host-oracle": "TNS d' 'o database:",
+ "config-db-host-oracle-help": "Mettite nu valido [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Nomme 'e conessione lucale]; nu file \"tnsnames.ora\" adda essere vesibbele dint'a sta installazione.<br />Si state ausanno 'a libbreria cliente 10g o cchiù ricente putite pure ausà 'o metodo 'e denominazione [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifica stu wiki",
+ "config-db-name": "Nomme d' 'o database:",
+ "config-db-name-help": "Sciglite nu nomme ca identificasse 'a wiki vosta.\nNun avess'a cuntenè spazie.\n\nSi ausate nu web hosting spartuto, 'o furnitore d' 'o hosting v'avesse 'a specificà nu nomme 'e database specifico pe' ve permettere 'e crià database pe' bbìa 'e nu pannello 'e cuntrollo.",
+ "config-db-name-oracle": "Schema d' 'o database:",
+ "config-db-account-oracle-warn": "Ce stanno tre scenarie suppurtate p' 'a installazione d'Oracle comme database 'e backend:\n\nSi vulite crià n'utenza 'e database comme parte d' 'o prucesso 'e installazione, dàte nu cunto c' 'o ruolo SYSDBA comme utenza d' 'o database pe ne fà installazione e specificate 'e credenziale vulute pe' ne fà l'utenza d'acciesso web, sinò è possibbele crià manualmente l'utenza d'accesso web e dà surtanto chillu cunto (si tenite autorizzaziune neccessarie pe' crià oggette 'e stu schema) po dà dduje utenze divierze, una ch' 'e permesse 'e criazione e n'ata pe ne putè trasì ô web.\n\n'O script p' 'a criazione 'e n'utenza cu tutte st'autorizzaziune neccessarie 'o putite truvà dint' 'a cartella \"maintenance/oracle\" 'e sta installazione. Tenite a mmente che l'uso 'e n'utenza cu sti restriziune stutarrà tutt' 'e funziune 'e manutenzione c' 'o cunto predefinito.",
+ "config-db-install-account": "Cunto utente pe' l'installazione",
+ "config-db-username": "Nomme utente p' 'o database:",
+ "config-db-password": "Password d' 'o database:",
+ "config-db-install-username": "Nzertate 'o nomme utente ca s'aussarrà pe' ve cullegà ô database pe' tramente ca se fà l'installazione. Chistu nun è 'o nomme utente d' 'o cunto MediaWiki; ma chillo p' 'o database vuosto.",
+ "config-db-install-password": "Nzertate 'a password che s'ausarrà pe' ve putè cullegà ô database pe' tramente ca se fa l'installazione.\nChista nun è 'a password d' 'o cunto 'e MediaWiki; ma chilla p' 'o database vuosto.",
+ "config-db-install-help": "Miette 'o nomme utente e 'a password ca sarrà usata quanno ve cullegate ô database pe' tramente ca facite 'a installazione.",
+ "config-db-account-lock": "Aúsa 'o stisso nomme utente e password pe' l'operazione normale.",
+ "config-db-wiki-account": "Cunto utente p' 'o funzionamento nurmale",
+ "config-db-wiki-help": "Miette 'o nomme utente e 'a password ca sarrà ausata pe' se cullegà ô database pe' l'operazione normale d' 'o wiki. Si 'o cunto nun esiste, e 'o cunto e installazione téne diritte sufficiente, sarrà criato ch' 'e diritte minime necessarie pe' putè faticà ncopp' 'o wiki.",
+ "config-db-prefix": "Prefisso d' 'a tavolozza d' 'o database:",
+ "config-db-prefix-help": "Si tenite abbesuogno 'e spartì nu database nfra cchiù wiki, o nfra MediaWiki e n'at'apprecazione web, putite scegliere d'azzeccà nu prefisso a tutte 'e nomme 'e tabbella, pe putè evità cunflitte.\nNun ausate abbacante.\n\n'O solito, stu campo se lassasse abbacante.",
+ "config-mysql-old": "MySQL $1 o cchiù muderno è necessario. Vuje avite $2.",
+ "config-db-port": "Porta d' 'o database:",
+ "config-db-schema": "Schema pe' MediaWiki:",
+ "config-db-schema-help": "Stu schema 'n genere sarrà buono.\nSi 'o vulite cagnà facite sulamente si ne tenite abbesuogno.",
+ "config-pg-test-error": "Nun se può connettà a 'o database <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Cartella 'e data 'e SQLite:",
+ "config-sqlite-dir-help": "SQLite astipa tutte 'e date dint'a n'uneco file.\n\n'A cartella ca starraje a innecà adda essere scrivibbele d' 'o server webe pe' tramente ca sta l'istallazione.\n\nAvess'a essere <strong>nun trasibbele via web</strong>, è pecchesto ca nun se sta mettenno addò stanno 'e file PHP.\n\nL'installatore scriverrà nzieme a chesta nu file <code>.htaccess</code>, ma si 'o tentativo scassasse, coccheruno putesse tenè acciesso dint' 'o database ncruro.\nChesto pure cunzidera 'e date ncruro 'e ll'utente (indirizze, password cifrate) accussì comme 'e verziune luvate e ati dati d'accesso limmetato dint' 'o wiki.\n\nCunzidera ll'opportunità 'e sistimà ô tiempo 'o database 'a n'ata parte, p'esempio int'a <code>/var/lib/mediawiki/tuowiki</code>.",
+ "config-oracle-def-ts": "Tablespace 'e default:",
+ "config-oracle-temp-ts": "Tablespace temporaneo:",
+ "config-type-mysql": "MySQL (o compatibbele)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki supporta 'e sisteme 'e database ccà abbascio:\n\n$1\n\nSi nfra chiste ccà nun vedite 'o sistema 'e database ca vulite ausà, allora avite liegge 'e instruziune ccà ncoppa pe' ne dà supporto.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] è 'a configurazione cchiù mmeglio p' 'o MediaWiki e è chilla meglio suppurtata. MediaWiki può faticà pure cu' [{{int:version-db-mariadb-url}} MariaDB] e [{{int:version-db-percona-url}} Percona Server], ca fossero MySQL cumpatibbele. ([http://www.php.net/manual/en/mysqli.installation.php Comme s'adda fà pe' cumpilà PHP cu suppuorto MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] è nu sistema canusciuto 'e database open source ca fosse n'alternativa a MySQL. Putess'avé cocch'errore p'arricettà, e nun è cunzigliato 'e ll'ausà dint'a n'ambiente 'e produziona. ([http://www.php.net/manual/en/pgsql.installation.php Comme s'avess'a cumpilà PHP cu suppuorto PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] è nu sistema 'e database leggero, ca fosse assaje buono suppurtato. ([http://www.php.net/manual/en/pdo.installation.php Comme cumpilà PHP cu suppuorto SQLite], aùsa PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] è nu database 'e na fraveca commerciale. ([http://www.php.net/manual/en/oci8.installation.php Comme cumpilà PHP cu suppuorto OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] è nu database 'e na fraveca commerciale p' 'o Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Comme cumpilà PHP cu suppuorto SQLSRV])",
+ "config-header-mysql": "Mpustaziune MySQL",
+ "config-header-postgres": "Mpustaziune PostgreSQL",
+ "config-header-sqlite": "Mpustaziune SQLite",
+ "config-header-oracle": "Mpustaziune Oracle",
+ "config-header-mssql": "Mpustaziune 'e Microsoft SQL Server",
+ "config-invalid-db-type": "'O tipo 'e database nun è buono.",
+ "config-missing-db-name": "Avita miette nu valore p' 'o \"{{int:config-db-name}}\"",
+ "config-missing-db-host": "Avita miette nu valore p' 'o \"{{int:config-db-host}}\"",
+ "config-missing-db-server-oracle": "Avita miette nu valore p' 'o \"{{int:config-db-host-oracle}}\"",
+ "config-invalid-db-server-oracle": "'O database 'e TNS \"$1\" nun è buono.\nAusate 'o \"TNS Name\" o na catena d' \"Easy Connect\"([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Metude 'e Nommena Oracle]).",
+ "config-invalid-db-name": "Nomme 'e database \"$1\" nun valido.\nAúsa surtanto carattere ASCII comme lettere (a-z, A-Z), nummere (0-9), sottolineatura (_) e trattine (-).",
+ "config-invalid-db-prefix": "Prefisso database \"$1\" nun valido.\nAúsa surtanto carattere ASCII comme lettere (a-z, A-Z), nummere (0-9), sottolineatura (_) e trattine (-).",
+ "config-connection-error": "$1.\n\nCuntrullate 'o host, nomme utente e password e tentate n'ata vota.",
+ "config-invalid-schema": "Schema MediaWiki \"$1\" nun è buono.\nAusate surtanto 'e lettere ASCII (a-z, A-Z), nummere (0-9) e carattere 'e sottolineatura (_).",
+ "config-db-sys-create-oracle": "'O prugramma 'e installazione supporta surtanto l'uso 'e nu cunto SYSDBA pe' putè crià nu cunto nuovo.",
+ "config-db-sys-user-exists-oracle": "'O cunto utente \"$1\" esiste già. SYSDBA se pò ausà surtanto pe' crià cunte nuove!",
+ "config-postgres-old": "PostgreSQL $1 o cchiù muderno è necessario. Vuje tenite $2.",
+ "config-mssql-old": "Microsoft SQL Server $1 o cchiù muderno è necessario. Vuje tenite $2.",
+ "config-sqlite-name-help": "Sciglite nu nomme ca identificasse 'o wiki vuosto.\nNun ausà spazie o trattine.\nChesto serverrà pe' putè miettere 'o nomme ro file 'e date SQLite.",
+ "config-sqlite-parent-unwritable-group": "Nun se pò crià 'a cartella 'e date <code><nowiki>$1</nowiki></code>, pecché 'a cartella supiriore <code><nowiki>$2</nowiki></code> nun se pò scrivere 'a 'o webserver.\n\n'O prugramma d'installazione ha determinato l'utente c' 'o quale 'o server web se stà a esecutà.\nDàte 'a pussibbelità 'e scrivere dint' 'a cartella <code><nowiki>$3</nowiki></code> pe' cuntinuà\nNcopp'a nu sistema Unix/Linux:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Nun se può crià na cartella 'e date <code><nowiki>$1</nowiki></code>, pecché 'a cartella patre <code><nowiki>$2</nowiki></code> nun è scrivibbele p' 'o server web.\n\n'O prugramma 'e installazione nun ave pututo determinà l'utente c' 'o quale se stà ausanno 'o server web.\nFacite 'a cartella <code><nowiki>$3</nowiki></code> screvibbele globbalmente pe chisto (e ll'ati!) pe' putè cuntinuà:\nDint'a nu sistema Unix/Linux facite:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Errore pe' tramente ca se faceva 'a criazione d' 'o directory date \"$1\".\nCuntrullate 'a posizione e pruvate n'ata vota.",
+ "config-sqlite-dir-unwritable": "Nun se pò scrivere dint' 'a directory \"$1\".\nCagnate ll'autorizzaziune 'n modo ca 'o webserver pozza scrivere ncoppa e pruvate n'ata vota.",
+ "config-sqlite-connection-error": "$1.\n\nCuntrullate 'a cartella 'e date e 'o nomme d' 'o database ccà abbascio e pruvate n'ata vota.",
+ "config-sqlite-readonly": "'O file <code>$1</code> nun è scrivibbele.",
+ "config-sqlite-cant-create-db": "Nun se può crià 'o file database <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "'O PHP è mancante d' 'o suppuorto FTS3, declassamento tabbelle 'n curzo",
+ "config-can-upgrade": "Nce stanno tabbelle 'e MediaWiki int'a stu database.\nPe ll'agghiurnà a MediaWiki $1, facite click ncopp' 'a '''continua'''.",
+ "config-upgrade-done": "Agghiurnamiento cumpreto.\n\nMo' putite [$1 accummincià 'ausà 'o wiki vuosto].\n\nSi vulite ringignà 'o file vuosto 'e <code>LocalSettings.php</code>, cliccate ncopp' 'o buttone ccà abbascio. St'operazione '''nun è cunzigliata''', si nun è pecché forse tenite cocche prubblema c' 'o wiki vuosto.",
+ "config-upgrade-done-no-regenerate": "Agghiurnamiento cumpreto.\n\nPutite [$1 accummincià 'ausà 'o wiki vuosto].",
+ "config-regenerate": "Rigennera LocalSettings.php →",
+ "config-show-table-status": "'A query <code>SHOW TABLE STATUS</code> è fallita!",
+ "config-unknown-collation": "<strong>Attenziò:</strong> 'O database sta ausanno reule 'e cunfronto nun ricanusciute.",
+ "config-db-web-account": "Cunto d' 'o database pe' ne fà acciesso web",
+ "config-db-web-help": "Scigliete 'o nomme utente e passwrod ca 'o web server ausarrà pe' se cullegà 'o server database, pe' tramente ca se fa' operazione normale d' 'o wiki.",
+ "config-db-web-account-same": "Aúsa 'o stisso cunto comme quanno s'è fatta 'a installazione",
+ "config-db-web-create": "Crìa 'o cunto si nun esiste ancora",
+ "config-db-web-no-create-privs": "'O cunto ausato pe' ne fà l'installazione nun tene diritte necessarie pe' ne putè crià n'atu cunto.\n'O cunto zegnàto ccà adda esistere già.",
+ "config-mysql-engine": "Mutore d'astipo:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Attenziò:</strong> avite scigliuto MyISAM comm' 'o mutore 'archiviaziona MySQL, ca nun è raccummannato pe' l'ausà cu MediaWiki, pecché:\n* supporta debolmente 'a concorrenza p' 'o blocco d' 'a tabbella\n* è cchiù inchine 'a corruzione 'e l'ati mutore\n* 'o codece 'e base 'e MediaWiki nun gestisce sempe MyISAM comme l'avess'a gistiunà\n\nSi ll'installazione vosta MySQL suppuorta InnoDB, è autamente raccummandato ca si scigliesse a 'o posto suo.\nSi 'a installazione MySQL nun suppurtasse InnoDB, forse è 'o mumento 'e ll'agghiurnà.",
+ "config-mysql-only-myisam-dep": "<strong>Attenziò:</strong> MyISAM è l'uneco mutore p'astipà date ca se trova mo' a disposizione p' 'o MySQL dint'a sta macchina, e nun fosse raccumandato 'e s'ausà cu MediaWiki, pecché:\n* suppurtasse minimamente concorrenza pe' bbìa 'e bluccà tabbelle\n* è cchiù facile ca jesse a se scassà cchiù 'e l'ati mutore\n* 'o codece MediaWiki nun maniasse sempe MyISAM comme l'avesse 'e manià\n\nL'installazione MySQL nun suppurtasse InnoDB, può darse ca chist'è 'o mumento pe' ve ll'agghiurnà.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> è quase sempe 'a meglia opzione, pecché ave nu buono suppuorto concorrente.\n\n<strong>MyISAM</strong> putesse ghì cchiù ampressa int'a na installazione mono-utente e liegge-surtanto.\n'E database MyISAM se scassano cchiù spisso d' 'e database InnoDB.",
+ "config-mysql-charset": "Nzieme 'e carattere d' 'o database:",
+ "config-mysql-binary": "Binario",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Int'a <strong>modalità binaria</strong>, MediaWiki archiviasse 'o testo UTF-8 dint' 'o database a campe binarie.\nChest'è cchiù efficiente rispetto 'a modalità UTF-8 'e MySQL, e ve cunzente d'ausà 'a gamma cumpreta 'e carattere Unicode.\n\nInt'a <strong>modalità UTF-8</strong>, MySQL canoscesse dint'a quale set 'e carattere ce stanno 'e date vuoste, e putesse presentà e scagnà sti date int'a nu modo appropriato, ma nun ve premmettesse 'e dà memoria a 'e carattere ncopp' 'o [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Piano base Multilengua].",
+ "config-mssql-auth": "Tipo d'autenticazione:",
+ "config-mssql-install-auth": "Sceglie 'o tipo d'autenticazziona ca s'ausarrà pe cunnettà â database, durante ll'operazziona d'istallazziona. Si piglie \"{{int:config-mssql-windowsauth}}\", 'e credenziale 'e qualunque fosse ll'utenza ca 'o webserver sta pruciessanno sarranno ausate.",
+ "config-mssql-web-auth": "Sceglie 'o tipo d'autenticazziona ca 'o web server pigliarrà pe se cunnettà a 'o server 'e bbase 'e dati, durante ll'operazziona nurmale d' 'a wiki.\nSi piglie \"{{int:config-mssql-windowsauth}}\", 'e credenziale 'e qualunque fosse ll'utenza ca 'o webserver sta pruciessanno sarranno ausate.",
+ "config-mssql-sqlauth": "Autenticazione 'e SQL Server",
+ "config-mssql-windowsauth": "Autenticazione 'e Windows",
+ "config-site-name": "Nomme d' 'o wiki:",
+ "config-site-name-help": "Chisto cumparerrà dint' 'a barra d' 'o titolo d' 'o navigatore e pure dint'a n'ati pizze.",
+ "config-site-name-blank": "Scrive 'o nomme d' 'o sito.",
+ "config-project-namespace": "Namespace d' 'o pruggetto:",
+ "config-ns-generic": "Pruggetto",
+ "config-ns-site-name": "'O stesso ch' 'o nomme d' 'o wiki: $1",
+ "config-ns-other": "Ati (specificà)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Secutanno l'esempio 'e Wikipedia, tante wiki teneno 'e paggene lloro ch' 'e regole spartute d' 'e paggene 'e cuntenute, dint'a nu '''namespace 'e pruggetto'''. Tuttuquante 'e titule d' 'e paggene dint'a stu namespace accummenciano cu nu certo prefisso ca se putesse nzegnà ccà.\n'O solito, stu prefisso vene d' 'o nomme d' 'o wiki, ma nun adda cuntenè carattere 'e punteggiatura comme fossero \"#\" o \":\".",
+ "config-ns-invalid": "'O namespace specificato \"<nowiki>$1</nowiki>\" nun è buono.\nSpecificate nu namespace 'e pruggetto differente.",
+ "config-ns-conflict": "'O namespace innecato \"<nowiki>$1</nowiki>\" tràse ncunflitto cu nu namespace predefinito 'e MediaWiki.\nSpecifiate n'atu nomme divierzo 'e namespace 'e pruggetto.",
+ "config-admin-box": "Cunto ammenistratore",
+ "config-admin-name": "'O nomme utente vuosto:",
+ "config-admin-password": "'A password vuosta:",
+ "config-admin-password-confirm": "Password n'ata vota:",
+ "config-admin-help": "Mettite 'o nomme utente ca vuje vulite ccà, p'esempio \"Gennaro Esposito\".\nChest'è 'o nomme ca vuje auserrate pe' trasì 'o wiki.",
+ "config-admin-name-blank": "Mettite nu nomme utente p' 'ammenistratore.",
+ "config-admin-name-invalid": "'O namespace specificato \"<nowiki>$1</nowiki>\" nun è buono.\nSpecificate nu namespace differente.",
+ "config-admin-password-blank": "Miette na password p' 'o cunto d'ammenistratore.",
+ "config-admin-password-mismatch": "'E dduje password c'avite miso nun songhe eguale.",
+ "config-admin-email": "Indirizzo e-mail:",
+ "config-admin-email-help": "Azzecate ccà nu nderizzo e-mail pe' pute ricevere 'e mmasciate mail 'a ll'at'utente d' 'o wiki, mpustà n'ata vota 'a password vuosta, e ve nfurmà d' 'e cagnamiente fatte a 'e paggene dint'a ll'elenco 'e paggene cuntrullate. Putite lassà stu campo abbacante.",
+ "config-admin-error-user": "Errore interno quanno se steva a crià n'ammenistratore c' 'o nomme \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Errore interno quanno se steva a mpustà na password pe ll'ammenistratore \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Avite miso n'indirizzo e-mail invalido.",
+ "config-subscribe": "Mettiteve dint' 'a [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce mailing list 'e ll'annunciazione 'e verziune d' 'o software rilassate].",
+ "config-subscribe-help": "Chest'è na mailing list a basso traffeco ca se dedicasse a se ffà annunzie 'e verziune nove, piglianno pure mpurtante nutarelle ca riguardassero 'a sicurezza.\nFosse cunzigliato 'e se nzegnà e s'agghiurnà l'installazione 'e MediaWiki quanno na verziona nova fosse pubbreca.",
+ "config-subscribe-noemail": "Vuje avite tentato 'e ve trasì dint' 'a mailing list addedecata a se fà annunzie ncopp' 'e verziune nove senza ve dà n'indirizzo email.\nNzertanno n'indirizzo email se vulite affettuà l'iscrizione dint'a mailing list.",
+ "config-almost-done": "Avite quase fernuto!\nMo' putite zumpà 'a parta r' 'a configurazione e sempricemente installà 'a wiki.",
+ "config-optional-continue": "Spiate cchiù dimanne.",
+ "config-optional-skip": "Me so' scucciato già, installa surtanto 'o wiki.",
+ "config-profile": "Profilo 'e deritte utente:",
+ "config-profile-wiki": "Wiki araputo",
+ "config-profile-no-anon": "Cunto utente obbligatorio",
+ "config-profile-fishbowl": "Surtanto ll'editure premmesse",
+ "config-profile-private": "Wiki privato",
+ "config-profile-help": "'E wiki funzionano meglio si se lassa ca tante perzone 'e putessero cagnà.\nInt'a MediaWiki, è semprice cuntrullà sti cagnamiente cchiù ricente, e arrepiglià 'e danne causate d' 'e prove o male ntenziune.\n\nAncora, nu cuofeno 'utente hanno truvato ca MediaWiki fosse utile int'a tante ruole, e cocche vota nun è facile 'e cunvencere tuttuquante ncopp' 'e vantagge d' 'a modalità wiki. Picciò facite 'a scelta vosta. Sciglite vuje.\n\n'O mudello <strong>{{int:config-profile-wiki}}</strong> cunzente a chiunque 'e cagnà, pure senza trasì.\nNu wiki cu <strong>{{int:config-profile-no-anon}}</strong> uffrisse na responsabilità maggiore, ma putesse scuraggià cocche cuntribbutore occasionale.\n\n'O scenario <strong>{{int:config-profile-fishbowl}}</strong> cunzente l'utente autorizzate 'e cagnà, ma 'o pubbreco putesse vedé 'e paggene vedenno pure tutta quanta 'a cronologgia.\nNu <strong>{{int:config-profile-private}}</strong> cunzente ll'utente autorizzate surtanto 'e se vedé 'e paggene, 'o stesso gruppo 'e putesse cagnà.\n\nMpustaziune 'e deritte utente cchiù cumplesse songo a disposizione aropp'a l'installazione, vedite 'a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights parte relativa d' 'o manuale].",
+ "config-license": "Copyright e licienza:",
+ "config-license-none": "Nisciuno piede 'e paggena p' 'a licienza",
+ "config-license-cc-by-sa": "Creative Commons Attribuziona-SparteEguale",
+ "config-license-cc-by": "Creative Commons Attribuziona",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribuziona-NunCommerciale-SparteEguale",
+ "config-license-cc-0": "Creative Commons Zero (Pubbreco dumminio)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 o verziune aroppo",
+ "config-license-pd": "Pubbreco duminio",
+ "config-license-cc-choose": "Sciglite na licienza Creative Commons ca vulite",
+ "config-license-help": "Nu cuofeno 'e wiki pubbrece lassano 'e cuntribbute lloro cu na [http://freedomdefined.org/Definition licienza libbera]. Chesto aiutasse a crià nu senso 'e pruprietà spartuta dint'a communità e ncuraggiasse a cuntribbuiì a nu tèrmene luongo. Nun è generalmente necessario pe' nu wiki privato o aziendale.\n\nSi vulite ausà testi 'a Wikipedia, o vulite ca Wikipedia se pozza miette 'n grado d'accettà teste cupiate d' 'o wiki vuosto, avissev'a scegiere <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nApprimma Wikipedia aveva ausato 'a GNU Free Documentation License. 'A GFDL è una licienza valida, ma è di difficile comprensiona e complica 'o riutilizzo 'e cuntenute.",
+ "config-email-settings": "Mpustaziune email",
+ "config-enable-email": "Premmette mmasciate elettroniche r'asciuta",
+ "config-enable-email-help": "Si vulite ca 'o sistema 'e mmasciate mail funziunasse, [http://www.php.net/manual/en/mail.configuration.php 'e mpustaziune PHP] s'avesser'a ffà bbuone.\nSi nun vulite 'a funziona mmasciata e-mail, allora stutate chiste llàn.",
+ "config-email-user": "Premmette email utente-utente",
+ "config-email-user-help": "Cunzente a ll'utente 'e mannà uno a ll'ato le mail si 'e teneno appicciate dint' 'e preferenze lloro.",
+ "config-email-usertalk": "Premmette notifiche p' 'e paggene 'e chiacchiera utente",
+ "config-email-usertalk-help": "Premmette ll'utente 'e ricevere notifiche p' 'e cagnamiente r' 'e paggene 'e chiacchiera lloro, si l'avessero appicciato dint' 'e preferenze lloro.",
+ "config-email-watchlist": "Appiccia notifica 'osservati speciale",
+ "config-email-watchlist-help": "Premmettesse ll'utente 'e se piglià notifiche ncopp' 'e paggene cuntrullate lloro si tenessero appicciate chisto in'a le mpustaziune.",
+ "config-email-auth": "Appiccia autenticaziona via email",
+ "config-email-auth-help": "Si st'opzione è appicciata, ll'utente avesser'a cunfermà 'o cunto e-mail ausanno nu cullegamento ca se mannasse a chiste quanno facessero 'a mpustaziona o pure quanno facessero 'o cagnamiento.\nSurtanto 'e mail autenticate putesser piglià mail 'a ll'at'utente o cagnà email 'e notifica.\nMpustà st'opzione è <strong>raccumannato</strong p' 'e wiki pubbreche pecché se putesse ausà o se fà abbuso d' 'e funziune mail.",
+ "config-email-sender": "Innerizo email e ritorno:",
+ "config-email-sender-help": "Nzèrta l'indirizzo email ca s'avess'ausà comme indirizzo 'e turnata p' 'a posta asciuta.\nChest'è l'indirizzo addò veneno l'errure.\nNu cuofeno 'e server posta vulessero minimo na parte d' 'o nomme dumminio valido.",
+ "config-upload-settings": "Immaggene e upload",
+ "config-upload-enable": "Premmette 'a carreca 'e file",
+ "config-upload-help": "'E careche 'e file putessero esporre 'e server vuoste a rischie 'e sicurezza. Pe n'avé cchiù nfurmaziune, liggite [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security 'a seziona 'e sicurezza] int' 'o manuale.\n\nPe' puté appiccià 'a funziona carreca, cagnate 'o modo dint' 'a cartella 'e ll'<code>immaggene</code> sott' ' a cartella rareca MediaWiki accussì facenno ca 'o web server putesse scrivere a into.\nAllora appicciate st'opzione.",
+ "config-upload-deleted": "Cartella p' 'e file scancellate:",
+ "config-upload-deleted-help": "Sciglite na cartella addò s'astipassero 'e file scancellate.\nIdealmente a sta cartella nun s'avess'a trasì r' 'a web",
+ "config-logo": "URL d\"o logo:",
+ "config-logo-help": "'A skin predefinita 'e MediaWiki tene spazion p' 'o logo 'e 135 x 160 pixel ncopp' 'o menu laterale.\nCarreca n'immaggene 'e diminziuna apprupriata e azzecca l'URL ccà.\n\nFosse pussibbele ausà <code>$wgStylePath</code> o <code>$wgScriptPath</code> si 'o logo è relativo a sti percurze. Si nun vulite nu logo, lassate abbacante sta casciulella.",
+ "config-instantcommons": "Appiccia Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] è na funziunalita ca premmettesse 'e wiki 'ausà immaggene, suone e ati file multimediale ca ve truvate ncopp' 'o sito 'e [https://commons.wikimedia.org/ Wikimedia Commons].\nSi chesto vulite fà, MediaWiki vulesse accieso a Internet.\n\nPe n'avé cchiù nfurmaziune ncopp'a sta funziunalità, ncludenno 'e struziune ncopp' 'a configuraziona pe' wiki divierze 'e Wikimedia Commons, fermateve nu poco a stureà [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos 'o manuale].",
+ "config-cc-error": "'O selettore 'e licienze Creative Commons nun mmustaje nisciuno risultato.\nNzertate manualmente 'o nomme d' 'a licienza.",
+ "config-cc-again": "Selezziona n'ata vota...",
+ "config-cc-not-chosen": "Sciglite quale licienza Creative Commons desiderate e cliccate ncopp' 'a \"proceed\".",
+ "config-advanced-settings": "Configurazione avanzata",
+ "config-cache-options": "Mpustaziune p' 'a cache d'oggette:",
+ "config-cache-help": "'O caching 'uggette s'ausa pe' puté migliurà 'a velocità 'e MediaWiki a fforza 'e ffà caching d' 'e date cchiù spisso ausàte.\nE Mezze a gruosse site se songo ncuraggiate a ll'appiccià chiste, e site piccerilli vedarranno migliuramente pure.",
+ "config-cache-none": "Nisciuna memorizzazione n cache (nisciuna funziunalità è luvata, ma 'a velocità se putesse ffà a meno dint' 'e wiki cchiù gruosse)",
+ "config-cache-accel": "Mettere 'n cache oggette PHP (APC, XCache o WinCache)",
+ "config-cache-memcached": "Aúsa 'o Memcached (richiede cchiù mpustaziune 'installazione e configuraziona)",
+ "config-memcached-servers": "Server memcached:",
+ "config-memcached-help": "Elenco 'e ll'indirizzi IP p' 'e putè ausà p' 'o Memcached.\nS'avess'a specificà uno pe' riga e scrivere 'a porta 'e trasuta. P'esempio:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "È stato scigliuto 'o tipo 'e caching Memcached, ma nun è stato mpustato 'a nisciunu server.",
+ "config-memcache-badip": "È stat'azzeccato nu indirizzo IP nun valido p' 'o Memcached: $1.",
+ "config-memcache-noport": "Nun avite specificato na porta p'ausà 'o server Memcached: $1. Si nun sapite 'a porta, chill' 'e default è 11211.",
+ "config-memcache-badport": "'E nummere 'e porta 'e Memcached avesser'a stà nfra $1 e $2.",
+ "config-extensions": "Estenziune",
+ "config-extensions-help": "L'estensiune elencate ncoppa so' state rilevate dint' 'a cartella vosta <code>./extensions</code>.\n\nChiste putessero richiedere nu poco 'e cchiù 'e mpustaziona, ma se putessero appiccià mo'",
+ "config-skins": "Skin",
+ "config-skins-help": "'E skin elencate ncoppa so' state rilevate dint' 'a cartella vosta <code>./skins</code>. Avit'appiccià minimo una e ne scegliere chilla predefinita.",
+ "config-skins-use-as-default": "Aùsa sta skin comme predefinita",
+ "config-skins-missing": "Nisciuna skin s'è truvata, MediaWiki ausasse na soluzione 'e ripiego nfin'a quanno nun sarrà installata una buona.",
+ "config-skins-must-enable-some": "Avit'a scegliere minimo na skin p' 'a puté appiccià.",
+ "config-skins-must-enable-default": "'A skin scigliuta comme predefinita s'avess'appiccià.",
+ "config-install-alreadydone": "'''Attenziò:''' pare c'avite già installato MediaWiki e state tentanno 'e installà chesto n'ata vota.\nPrucedete 'a paggena aroppa.",
+ "config-install-begin": "Spremmènno \"{{int:config-continue}}\", s'abbiàsse l'installaziona 'e MediaWiki.\nSi primma vulite dà ati cagnamiente, spremmìte \"{{int:config-back}}\".",
+ "config-install-step-done": "fatto",
+ "config-install-step-failed": "fallito",
+ "config-install-extensions": "Ncludenno 'estenziune",
+ "config-install-database": "Configurazione database",
+ "config-install-schema": "Crianno schema",
+ "config-install-pg-schema-not-exist": "'O schema PostgreSQL nun esiste.",
+ "config-install-pg-schema-failed": "Criazione 'e tabbelle scassata.\nCuntrullate si l'utente \"$1\" può scrivere dint' 'o schema \"$2\".",
+ "config-install-pg-commit": "Mannann' 'e cagnamiente",
+ "config-install-pg-plpgsql": "Cuntrollo p' 'o lenguaggio PL/pgSQL",
+ "config-pg-no-plpgsql": "Ce vulesse 'a installazione d' 'o linguagio PL/pgSQL dint' 'o database $1",
+ "config-pg-no-create-privs": "'O cunto innecato p' 'a installazione nun tene premmesse abbastanza pe' puté crià n'utenza.",
+ "config-pg-not-in-role": "'O cunto c'avite nzegnato p' 'o utente web esiste già.\n'O cunto dato pe' ce fà 'installazione nun è n'utente avanzato e nun è nu membro d' 'o ruolo 'utente web, picciò nun è in grado 'e se crià oggette 'e pruprietà 'utente web.\n\nMediaWiki mo' addimanna ch' 'e tabbelle fossero pruprietà 'e ll'utente web. Putite nzegnà n'atu cunto web, o facite pure \"arreto\" e specificate n'utente pe' ce ffà 'installazione opportunamente privileggiato.",
+ "config-install-user": "Crianno utente 'e database",
+ "config-install-user-alreadyexists": "L'utente \"$1\" esiste già",
+ "config-install-user-create-failed": "Crianno utente \"$1\" guastaje: $2",
+ "config-install-user-grant-failed": "A ce dà 'e premmesse a ll'utente \"$1\" scassaje: $2",
+ "config-install-user-missing": "L'utente indicato \"$1\" nun esiste.",
+ "config-install-user-missing-create": "L'utente indicato \"$1\" nun esiste.\nSciglite 'a casella \"crìa utenza\" ccà abbascio si 'a vulite crià.",
+ "config-install-tables": "Crianno tabbelle",
+ "config-install-tables-exist": "'''Attenziò:''' pare ch' 'e tabbelle 'e MediaWiki esisteno già.\nZumpo 'a creazione.",
+ "config-install-tables-failed": "<strong>Error:</strong>: 'A criazione d' 'a tabbella nun ngarraje, scassaje accussì: $1",
+ "config-install-interwiki": "Ghienchere 'a tabbella predefinita interwiki",
+ "config-install-interwiki-list": "Nun se pò lieggere 'o file <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Attenziò:''' 'a tabbella interwiki pare ca cuntenesse già elemente.\nZumpann' 'a lista predefinita.",
+ "config-install-stats": "Inizializzaziona d' 'e statistiche",
+ "config-install-keys": "Generaziona d' 'e chiave segrete",
+ "config-insecure-keys": "'''Attenziò:''' {{PLURAL:$2|Na chiave sicura|'E chiave sicure}} ($1) {{PLURAL:$2|generata|generate}} pe' tramente ca se fà l'installazione nun {{PLURAL:$2|è|songo}} completamente {{PLURAL:$2|sicura|sicure}}. Cunziderate d' {{PLURAL:$2|'a|'e}} cagnà manualmente.",
+ "config-install-updates": "Mpiccià ll'agghiurnamiente ca nun fossero necessarie",
+ "config-install-updates-failed": "<strong>Errore:</strong> l'inserimento d' 'e chiave 'agghiurnamiento dint' 'e tabbelle nun è asciuto pecché se cunfermaje l'errore ccà annanze: $1",
+ "config-install-sysop": "Crianno nu cunto utente ammenistratore",
+ "config-install-subscribe-fail": "Nun se pò sottoscrivere mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL nun è installato e <code>allow_url_fopen</code> nun è disponibbele.",
+ "config-install-mainpage": "Crianno 'a paggena prencepale ch' 'e cuntenute predefinite",
+ "config-install-extension-tables": "Crianno tabelle pe' estenziune appicciate",
+ "config-install-mainpage-failed": "Nun se pò nzertà 'a paggena prencepale: $1",
+ "config-install-done": "<strong>Cumplimente!</strong>\nAvite installato MediaWiki apposto.\n\n'O prugramma 'installazione ha generato nu file <code>LocalSettings.php</code> ca cuntene tuttuquante 'e mpustaziune.\n\nAvit'a scarrecà chisto e 'o nzertà dint' 'a cartella bbase d' 'o wiki vuosto ('a stessa addò fosse prisente l' index.php). 'A scarreca avess'a partì automaticamente.\n\nSi nu download nun s'avviasse, o si è stato annullato, putite riavvià cliccanno ncopp' 'o cullegamento 'e seguito:\n\n$3\n\n<strong>Nota:</strong> si ascite mò 'a ll'installazione senza manco scarrecà 'o file 'e configurazione che s'è criato, po chesto nun sarrà cchiù dispunibbele.\n\nQuanno fosse tutto fernuto allora <strong>[$2 trasite dint' 'o wiki vuosto]</strong>.",
+ "config-download-localsettings": "Scarreca <code>LocalSettings.php</code>",
+ "config-help": "ajùto",
+ "config-help-tooltip": "cliccà pe' 'o spannere",
+ "config-nofile": "'O file \"$1\" nun se trova. Forse è stato scancellato?",
+ "config-extension-link": "'O sapevate ch' 'o wiki vuosto suppurtasse 'e [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions estensiune]?\n\nPutite navigà nfra chiste [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category estensiune pe' categurìa].",
+ "mainpagetext": "<strong>MediaWiki è stato nstallato.</strong>",
+ "mainpagedocfooter": "Iate a cunzultà [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] pe' n'avé nfurmaziune ncopp' 'o modo aùso d' 'o software wiki.\n\n== P'accummincià ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Elenco 'e mpustaziune pe' sta configuraziona]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ 'e Mediawiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Elenco 'e nutizie 'e Mediawiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localizzazzione 'e MediaWiki p' 'a lengua vuosta]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Mparate a cumbattere 'o spammo dint' 'a wiki d' 'a vosta]"
+}
diff --git a/www/wiki/includes/installer/i18n/nb.json b/www/wiki/includes/installer/i18n/nb.json
new file mode 100644
index 00000000..1eb418ed
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nb.json
@@ -0,0 +1,326 @@
+{
+ "@metadata": {
+ "authors": [
+ "Event",
+ "Nghtwlkr",
+ "아라",
+ "Danmichaelo",
+ "Jeblad",
+ "Macofe",
+ "SuperPotato",
+ "Jon Harald Søby",
+ "Seb35"
+ ]
+ },
+ "config-desc": "Installasjonsprogrammet for MediaWiki",
+ "config-title": "Installasjon av MediaWiki $1",
+ "config-information": "Informasjon",
+ "config-localsettings-upgrade": "En <code>LocalSettings.php</code>-fil har blitt oppdaget.\nFor å oppgradere denne installasjonen, skriv inn verdien av <code>$wgUpgradeKey</code> i boksen nedenfor.\nDu finner denne i <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Filen ''<code>LocalSettings.php</code>'' er funnet.\nFor å oppgradere denne installasjonen, vennligst kjør ''update.php'' i stedet",
+ "config-localsettings-key": "Oppgraderingsnøkkel:",
+ "config-localsettings-badkey": "Oppgraderingsnøkkelen du oppga er feil.",
+ "config-upgrade-key-missing": "En eksisterende installasjon av MediaWiki er funnet.\nFor å oppgradere denne installasjonen, vær vennlig å legge til følgende linje helt til slutt i din ''<code>LocalSettings.php</code>''-fil:\n\n$1",
+ "config-localsettings-incomplete": "Den eksisterende ''<code>LocalSettings.php</code>'' ser ut til å være ufullstendig.\nVariabelen $1 har ingen verdi.\nVær vennlig å endre ''<code>LocalSettings.php</code>'' slik at variabelen får en verdi, og klikk ''{{int:Config-continue}}''.",
+ "config-localsettings-connection-error": "Det ble funnet en feil ved tilknytning av databasen med innstillingene i ''<code>LocalSettings.php</code>'' eller ''<code>LocalSettings.php</code>''. Vær vennlig å rette opp disse innstillingene og prøv igjen.\n\n$1",
+ "config-session-error": "Feil under oppstart av økt: $1",
+ "config-session-expired": "Dine øktdata ser ut til å ha utløpt.\nØkter er konfigurert for en levetid på $1.\nDu kan øke dette ved å sette <code>session.gc_maxlifetime</code> i php.ini.\nStart installasjonsprosessen på nytt.",
+ "config-no-session": "Dine øktdata ble tapt!\nSjekk din php.ini og sørg for at <code>session.save_path</code> er satt til en passende mappe.",
+ "config-your-language": "Ditt språk:",
+ "config-your-language-help": "Velg et språk å bruke under installasjonsprosessen.",
+ "config-wiki-language": "Wikispråk:",
+ "config-wiki-language-help": "Velg språket som wikien hovedsakelig vil bli skrevet i.",
+ "config-back": "← Tilbake",
+ "config-continue": "Fortsett →",
+ "config-page-language": "Språk",
+ "config-page-welcome": "Velkommen til MediaWiki!",
+ "config-page-dbconnect": "Koble til database",
+ "config-page-upgrade": "Oppgrader eksisterende innstallasjon",
+ "config-page-dbsettings": "Databaseinnstillinger",
+ "config-page-name": "Navn",
+ "config-page-options": "Valg",
+ "config-page-install": "Installer",
+ "config-page-complete": "Ferdig!",
+ "config-page-restart": "Start installasjonen på nytt",
+ "config-page-readme": "Les meg",
+ "config-page-releasenotes": "Utgivelsesnotat",
+ "config-page-copying": "Kopiering",
+ "config-page-upgradedoc": "Oppgradering",
+ "config-page-existingwiki": "Eksisterende wiki",
+ "config-help-restart": "Ønsker du å fjerne alle lagrede data som du har skrevet inn og starte installasjonsprosessen på nytt?",
+ "config-restart": "Ja, start på nytt",
+ "config-welcome": "=== Miljøsjekker ===\nGrunnleggende sjekker utføres for å se om dette miljøet er egnet for en MediaWiki-installasjon.\nDu bør oppgi resultatene fra disse sjekkene om du trenger hjelp under installasjonen.",
+ "config-copyright": "=== Opphavsrett og vilkår ===\n\n$1\n\nMediaWiki er fri programvare; du kan redistribuere det og/eller modifisere det under betingelsene i GNU General Public License som publisert av Free Software Foundation; enten versjon 2 av lisensen, eller (etter eget valg) enhver senere versjon.\n\nDette programmet er distribuert i håp om at det vil være nyttig, men '''uten noen garanti'''; ikke engang implisitt garanti av '''salgbarhet''' eller '''egnethet for et bestemt formål'''.\nSe GNU General Public License for flere detaljer.\n\nDu skal ha mottatt <doclink href=Copying>en kopi av GNU General Public License</doclink> sammen med dette programmet; hvis ikke, skriv til Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA eller [http://www.gnu.org/copyleft/gpl.html les det på nettet].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki hjem]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Brukerguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administratorguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ OSS]\n----\n* <doclink href=Readme>Les meg</doclink>\n* <doclink href=ReleaseNotes>Utgivelsesnotater</doclink>\n* <doclink href=Copying>Kopiering</doclink>\n* <doclink href=UpgradeDoc>Oppgradering</doclink>",
+ "config-env-good": "Miljøet har blitt sjekket.\nDu kan installere MediaWiki.",
+ "config-env-bad": "Miljøet har blitt sjekket.\nDu kan installere MediaWiki.",
+ "config-env-php": "PHP $1 er installert.",
+ "config-env-hhvm": "HHVM $1 er installert.",
+ "config-unicode-using-intl": "Bruker [http://pecl.php.net/intl intl PECL-utvidelsen] for Unicode-normalisering.",
+ "config-unicode-pure-php-warning": "'''Advarsel''': [http://pecl.php.net/intl intl PECL-utvidelsen] er ikke tilgjengelig for å håndtere Unicode-normaliseringen, faller tilbake til en langsommere ren-PHP-implementasjon.\nOm du kjører et nettsted med høy trafikk bør du lese litt om [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-normalisering].",
+ "config-unicode-update-warning": "<strong>Advarsel:</strong> Den installerte versjonen av Unicode-normalisereren bruker en eldre versjon av [http://site.icu-project.org/ ICU-prosjektets] bibliotek.\nDu bør [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations oppgradere] om du er bekymret for å bruke Unicode.",
+ "config-no-db": "Fant ingen passende databasedriver! Du må installere en databasedriver for PHP.\nFølgende {{PLURAL:$2|databasetype|databasetyper}} støttes: $1\n\nOm du kompilerte PHP selv, rekonfigurer den med en aktivert databaseklient, for eksempel ved å bruke <code>./configure --with-mysqli</code>.\nOm du installerte PHP fra en Debian- eller Ubuntu-pakke, må du også installere for eksempel <code>php5-mysql</code>-pakken.",
+ "config-outdated-sqlite": "'''Advarsel''': Du har SQLite $1, som er en eldre versjon enn minimumskravet SQLite $2. SQLite vil ikke være tilgjengelig.",
+ "config-no-fts3": "'''Advarsel''': SQLite er kompilert uten [//sqlite.org/fts3.html FTS3-modulen], søkefunksjoner vil ikke være tilgjengelig på dette bakstykket.",
+ "config-pcre-old": "'''Alvorlig:''' PCRE $1 eller senere kreves.\nDin PHP-kode er lenket med PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Nærmere informasjon].",
+ "config-pcre-no-utf8": "'''Fatal''': PHPs PCRE modul ser ut til å være kompilert uten PCRE_UTF8-støtte.\nMediaWiki krever UTF-8-støtte for å fungere riktig.",
+ "config-memory-raised": "PHPs <code>memory_limit</code> er $1, økt til $2.",
+ "config-memory-bad": "'''Advarsel:''' PHPs <code>memory_limit</code> er $1.\nDette er sannsynligvis for lavt.\nInstallasjonen kan mislykkes!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] er installert",
+ "config-apc": "[http://www.php.net/apc APC] er installert",
+ "config-apcu": "[http://www.php.net/apcu APCu] er installert",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] er installert",
+ "config-no-cache-apcu": "<strong>Advarsel:</strong> Kunne ikke finne [http://www.php.net/apc APC], [http://xcache.lighttpd.net/ XCache] eller [http://www.iis.net/download/WinCacheForPhp WinCache].\nObjekthurtiglagring er ikke aktivert.",
+ "config-mod-security": "'''Advarsel''': Din web-tjener har [http://modsecurity.org/ mod_security] påslått. Hvis denne er feilinnstilt, kan det gi problemer for MediaWiki eller annen programvare som tillater brukere å poste vilkårlig innhold.\nSjekk [http://modsecurity.org/documentation/ mod_security-dokumentasjonen] eller ta kontakt med din nettleverandør hvis du opplever tilfeldige feil.",
+ "config-diff3-bad": "GNU diff3 ikke funnet.",
+ "config-git": "Har funnet Git version control software: <code>$1</code>.",
+ "config-git-bad": "Git version control software ble ikke funnet.",
+ "config-imagemagick": "Fant ImageMagick: <code>$1</code>.\nBildeminiatyrisering vil aktiveres om du aktiverer opplastinger.",
+ "config-gd": "Fant innebygd GD-grafikkbibliotek.\nBildeminiatyrisering vil aktiveres om du aktiverer opplastinger.",
+ "config-no-scaling": "Kunne ikke finne GD-bibliotek eller ImageMagick.\nBildeminiatyrisering vil være deaktivert.",
+ "config-no-uri": "'''Feil:''' Kunne ikke bestemme gjeldende URI.\nInstallasjon avbrutt.",
+ "config-no-cli-uri": "'''Advarsel''': Ingen <code>--scriptpath</code> er angitt; bruker standard: <code>$1</code>.",
+ "config-using-server": "Bruker servernavnet \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Bruker server-URL \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "'''Advarsel:''' Din standardmappe for opplastinger <code>$1</code> er sårbar for kjøring av vilkårlige skript.\nSelv om MediaWiki sjekker alle opplastede filer for sikkerhetstrusler er det sterkt anbefalt å [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security lukke denne sikkerhetssårbarheten] før du aktiverer opplastinger.",
+ "config-no-cli-uploads-check": "'''Advarsel:''' Din standard-katalog for opplastinger (<code>$1</code>) er ikke kontrollert for sårbarhet overfor vilkårlig skript-kjøring under CLI-installasjonen.",
+ "config-brokenlibxml": "Ditt system bruker en kombinasjon av PHP- og libxml2-versjoner som har feil og kan forårsake skjult dataødeleggelse i MediaWiki og andre web-applikasjoner.\nOppgrader til libxml2 2.7.3 eller nyere ([https://bugs.php.net/bug.php?id=45996 Feil-liste for PHP]).\nInstalleringen ble abortert.",
+ "config-suhosin-max-value-length": "Suhosin er installert og begrenser GET-parameterlengder til $1 bytes. MediaWiki sin ResourceLoader-komponent klarer å komme rundt denne begrensningen, men med redusert ytelse. Om mulig bør du sette <code>suhosin.get.max_value_length</code> til minst 1024 i <code>php.ini</code>, og sette <code>$wgResourceLoaderMaxQueryLength</code> til samme verdi i <code>LocalSettings.php</code>.",
+ "config-using-32bit": "<strong>Adversel:</strong> Systemet ditt ser ut til å være 32-bit-basert, mens dette er [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit not advised].",
+ "config-db-type": "Databasetype:",
+ "config-db-host": "Databasevert:",
+ "config-db-host-help": "Hvis databasen kjører på en annen tjenermaskin, skriv inn vertsnavnet eller IP-adressen her.\n\nHvis du bruker et webhotell, vil du kunne be om aktuelt vertsnavn fra din leverandør.\n\nHvis du installerer på en Windowstjener og bruker MySQL, kan det hende at «localhost» ikke brukes som tjenernavn. Hvis så er tilfelle, prøv «127.0.0.1» som lokal IP-adresse.\n\nHvis du bruker PostgreSQL, la dette feltet være blankt slik at koplingen gjøres via en \"Unix socket\".",
+ "config-db-host-oracle": "Database TNS:",
+ "config-db-host-oracle-help": "Skriv inn et gyldig [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; en tnsnames.ora-fil må være synlig for installasjonsprosessen.<br />Hvis du bruker klientbibliotek 10g eller nyere kan du også bruke navngivingsmetoden [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifiser denne wikien",
+ "config-db-name": "Databasenavn:",
+ "config-db-name-help": "Velg et navn som identifiserer wikien din.\nDet bør ikke inneholde mellomrom.\n\nHvis du bruker en delt nettvert vil verten din enten gi deg et spesifikt databasenavn å bruke, eller la deg opprette databaser via kontrollpanelet.",
+ "config-db-name-oracle": "Databaseskjema:",
+ "config-db-account-oracle-warn": "Det finnes tre mulig fremgangsmåter for å installere Oracle som database:\n\nHvis du ønsker å opprette en databasekonto som del av installasjonsprosessen, oppgi da en konto med SYSDBA-rolle som databasekonto for installasjonen og angi påkrevd autentiseringsinformasjon for web-aksesskontoen. Ellers kan du enten opprette web-aksesskontoen manuelt eller kun oppgi den kontoen (hvis den har påkrevede tillatelser for å opprette skjemeobjektene) , alternativt oppgi to ulike kontoer, en med opprettelsesprivilegier (create) og en begrenset konto for web-aksess.\n\nSkript for å opprette en konto med påkrevde privilegier finnes i \"maintenance/oracle/\"-folderen av denne installasjonen. Husk at det å bruke en begrenset konto vil blokkere all vedlikeholdsfunksjonalitet med standard konto.",
+ "config-db-install-account": "Brukerkonto for installasjon",
+ "config-db-username": "Databasebrukernavn:",
+ "config-db-password": "Databasepassord:",
+ "config-db-install-username": "Skriv inn brukernavnet som vil bli brukt til å koble til databasen under installasjonsprosessen.\nDette er ikke brukernavnet på MediaWiki-kontoen; dette er brukernavnet for databasen din.",
+ "config-db-install-password": "Skriv inn passordet som vil bli brukt til å koble til databasen under installasjonsprosessen.\nDette er ikke passordet på MediaWiki-kontoen; dette er passordet for databasen din.",
+ "config-db-install-help": "Skriv inn brukernavnet og passordet som vil bli brukt for å koble til databasen under installasjonsprosessen.",
+ "config-db-account-lock": "Bruk det samme brukernavnet og passordet under normal drift",
+ "config-db-wiki-account": "Brukerkonto for normal drift",
+ "config-db-wiki-help": "Skriv inn brukernavnet og passordet som vil bli brukt til å koble til databasen under normal wikidrift.\nHvis kontoen ikke finnes, og installasjonskontoen har tilstrekkelige privilegier, vil denne brukerkontoen bli opprettet med et minimum av privilegier, tilstrekkelig for å operere wikien.",
+ "config-db-prefix": "Databasetabellprefiks:",
+ "config-db-prefix-help": "Hvis du trenger å dele en database mellom flere wikier, eller mellom MediaWiki og andre nettapplikasjoner, kan du velge å legge til et prefiks til alle tabellnavnene for å unngå konflikter.\nIkke bruk mellomrom.\n\nDette feltet er vanligvis tomt.",
+ "config-mysql-old": "MySQL $1 eller senere kreves, du har $2.",
+ "config-db-port": "Databaseport:",
+ "config-db-schema": "Skjema for MediaWiki",
+ "config-db-schema-help": "Dette skjemaet er som regel riktig.\nBare endre det hvis du vet at du trenger det.",
+ "config-pg-test-error": "Får ikke kontakt med database '''$1''': $2",
+ "config-sqlite-dir": "SQLite datamappe:",
+ "config-sqlite-dir-help": "SQLite lagrer alle data i en enkelt fil.\n\nMappen du oppgir må være skrivbar for nettjeneren under installasjonen.\n\nDen bør '''ikke''' være tilgjengelig fra nettet, dette er grunnen til at vi ikke legger det der PHP-filene dine er.\n\nInstallasjonsprogrammet vil skrive en <code>.htaccess</code>-fil sammen med det, men om det mislykkes kan noen få tilgang til din råe database. Dette inkluderer rå brukerdata (e-postadresser, hashede passord) samt slettede revisjoner og andre begrensede data på wikien.\n\nVurder å plassere databasen et helt annet sted, for eksempel i <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Standard tabellrom:",
+ "config-oracle-temp-ts": "Midlertidig tabellrom:",
+ "config-type-mysql": "MySQL (eller kompatibelt)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQLServer",
+ "config-support-info": "MediaWiki støtter følgende databasesystem:\n\n$1\n\nHvis du ikke ser databasesystemet du prøver å bruke i listen nedenfor, følg instruksjonene det er lenket til over for å aktivere støtte.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] er den prefererte databasen for MediaWiki og er derfor best støttet. MediaWiki fungerer også med [{{int:version-db-mariadb-url}} MariaDB] og [{{int:version-db-percona-url}} Percona Server], som begge er MySQL-kompatible. ([http://www.php.net/manual/en/mysqli.installation.php Hvordan kompilere med PHP med MySQL-støtte])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] er et populært åpen kildekode-databasesystem og et alternativ til MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Hvordan kompilere PHP med PostgreSQL-støtte])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] er et lettvekts-databasesystem som har veldig god støtte. ([http://www.php.net/manual/en/pdo.installation.php Hvordan kompilere PHP med SQLite-støtte], bruker PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] er en kommersiell database for bedrifter. ([http://www.php.net/manual/en/oci8.installation.php Hvordan kompilere PHP med OCI8-støtte])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQLServer] er en kommersiell enterprise-database for Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Hvordan kompilere PHP med SQLSRV-støtte])",
+ "config-header-mysql": "MySQL-innstillinger",
+ "config-header-postgres": "PostgreSQL-innstillinger",
+ "config-header-sqlite": "SQLite-innstillinger",
+ "config-header-oracle": "Oracle-innstillinger",
+ "config-header-mssql": "Microsoft SQLServer-innstillinger",
+ "config-invalid-db-type": "Ugyldig databasetype",
+ "config-missing-db-name": "Du må skrive inn en verdi for «{{int:config-db-name}}»",
+ "config-missing-db-host": "Du må skrive inn en verdi for «{{int:config-db-host}}»",
+ "config-missing-db-server-oracle": "Du må skrive inn en verdi for «{{int:config-db-host-oracle}}»",
+ "config-invalid-db-server-oracle": "Ugyldig database-TNS «$1».\nBruk enten \"TNS Name\" eller en \"Easy Connect\"-streng ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods])",
+ "config-invalid-db-name": "Ugyldig databasenavn «$1».\nBruk bare ASCII-bokstaver (a-z, A-Z), tall (0-9), undestreker (_) og bindestreker (-).",
+ "config-invalid-db-prefix": "Ugyldig databaseprefiks «$1».\nBruk bare ASCII-bokstaver (a-z, A-Z), tall (0-9), undestreker (_) og bindestreker (-).",
+ "config-connection-error": "$1.\n\nSjekk verten, brukernavnet og passordet nedenfor og prøv igjen.",
+ "config-invalid-schema": "Ugyldig skjema for MediaWiki «$1».\nBruk bare ASCII-bokstaver (a-z, A-Z), tall (0-9) og undestreker (_).",
+ "config-db-sys-create-oracle": "Installasjonsprogrammet støtter kun bruk av en SYSDBA-konto for opprettelse av en ny konto.",
+ "config-db-sys-user-exists-oracle": "Brukerkontoen «$1» finnes allerede. SYSDBA kan kun brukes for oppretting av nye kontoer!",
+ "config-postgres-old": "PostgreSQL $1 eller senere kreves, du har $2.",
+ "config-mssql-old": "Microsoft SQLServer $1 eller senere kreves. Du har $2.",
+ "config-sqlite-name-help": "Velg et navn som identifiserer wikien din.\nIkke bruk mellomrom eller bindestreker.\nDette vil bli brukt til SQLite-datafilnavnet.",
+ "config-sqlite-parent-unwritable-group": "Kan ikke opprette datamappen <code><nowiki>$1</nowiki></code> fordi foreldremappen <code><nowiki>$2</nowiki></code> ikke er skrivbar for nettjeneren.\n\nInstallasjonsprogrammet har bestemt brukeren nettjeneren din kjører som.\nGjør <code><nowiki>$3</nowiki></code>-mappen skrivbar for denne for å fortsette.\nPå et Unix/Linux-system, gjør:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Kan ikke opprette datamappen <code><nowiki>$1</nowiki></code> fordi foreldremappen <code><nowiki>$2</nowiki></code> ikke er skrivbar for nettjeneren.\n\nInstallasjonsprogrammet kunne ikke bestemme brukeren nettjeneren din kjører som.\nGjør <code><nowiki>$3</nowiki></code>-mappen globalt skrivbar for denne (og andre!) for å fortsette.\nPå et Unix/Linux-system, gjør:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Feil under oppretting av datamappen «$1».\nSjekk plasseringen og prøv igjen.",
+ "config-sqlite-dir-unwritable": "Kan ikke skrive til mappen «$1».\nEndre dens tilganger slik at nettjeneren kan skrive til den og prøv igjen.",
+ "config-sqlite-connection-error": "$1.\n\nSjekk datamappen og databasenavnet nedenfor og prøv igjen.",
+ "config-sqlite-readonly": "Filen <code>$1</code> er ikke skrivbar.",
+ "config-sqlite-cant-create-db": "Kunne ikke opprette databasefilen <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP mangler FTS3-støtte, nedgraderer tabeller",
+ "config-can-upgrade": "Det er MediaWiki-tabeller i denne databasen.\nFor å oppgradere dem til MediaWiki $1, klikk '''Fortsett'''.",
+ "config-upgrade-done": "Oppgradering fullført.\n\nDu kan nå [$1 begynne å bruke wikien din].\n\nHvis du ønsker å regenerere <code>LocalSettings.php</code>-filen din, klikk på knappen nedenfor.\nDette er '''ikke anbefalt''' med mindre du har problemer med wikien din.",
+ "config-upgrade-done-no-regenerate": "Oppgradering fullført.\n\nDu kan nå [$1 begynne å bruke wikien din].",
+ "config-regenerate": "Regenerer LocalSettings.php →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code> etterspørselen mislyktes!",
+ "config-unknown-collation": "'''Advarsel:''' Databasen bruker en ukjent sortering.",
+ "config-db-web-account": "Databasekonto for nettilgang",
+ "config-db-web-help": "Velg brukernavnet og passordet som nettjeneren skal bruke for å koble til databasetjeneren under ordinær drift av wikien.",
+ "config-db-web-account-same": "Bruk samme konto som for installasjonen",
+ "config-db-web-create": "Opprett kontoen om den ikke finnes allerede",
+ "config-db-web-no-create-privs": "Kontoen du oppga for installasjonen har ikke nok privilegier til å opprette en konto.\nKontoen du oppgir her må finnes allerede.",
+ "config-mysql-engine": "Lagringsmotor:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Advarsel:''' Du har valgt MyISAM som lagringsmotor for MySQL, noe som ikke er anbefalt for bruk med MediaWiki, fordi:\n* den knapt støtter samtidighet pga. tabell-låsing\n* den har større tilbøyelighet for å bli korrupt enn andre motorer\n* MediaWiki-koden håndterer ikke alltid MyISAM som den burde\n\nHvis din MySQL-installasjon støtter InnoDB, er det sterkt å anbefale at du i stedet velger den.\nHvis din MySQL-installasjon ikke støtter InnoDB, kan det være på tide med en oppgradering.",
+ "config-mysql-only-myisam-dep": "'''Advarsel:''' MyISAM er den eneste tilgjengelig lagringsmotoren for MySQL på denne maskinen, og det er ikke anbefalt brukt for MediaWiki, fordi:\n* den knapt støtter samtidighet pga. tabell-låsing\n* den har større tilbøyelighet for å bli korrupt enn andre motorer\n* MediaWiki-koden håndterer ikke alltid MyISAM som den burde\n\nHvis din MySQL-installasjon ikke støtter InnoDB, kan det være på tide med en oppgradering.",
+ "config-mysql-engine-help": "'''InnoDB''' er nesten alltid det beste alternativet siden den har god støtte for samtidighet («concurrency»).\n\n'''MyISAM''' kan være raskere i enbruker- eller les-bare-installasjoner.\nMyISAM-databaser har en tendens til å bli ødelagt oftere enn InnoDB-databaser.",
+ "config-mysql-charset": "Databasetegnsett:",
+ "config-mysql-binary": "Binær",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "I '''binary mode''' lagrer MediaWiki UTF-8 tekst til databasen i binærfelt.\nDette er mer effektivt enn MySQLs UTF-8 modus og tillater deg å bruke hele spekteret av Unicode-tegn.\n\nI '''UTF-8 mode''' vil MySQL vite hvilket tegnsett dataene dine er i og kan presentere og konvertere det på en riktig måte,\nmen det vil ikke la deg lagre tegn over «[https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes the Basic Multilingual Plane]».",
+ "config-mssql-auth": "Autentiseringstype:",
+ "config-mssql-install-auth": "Valg autentiseringstypen som skal brukes for å koble til databasen under installeringsprosessen. Hvis du velger «{{int:config-mssql-windowsauth}}», vil påloggingsinformasjonen for brukeren som kjører webtjeneren blir brukt.",
+ "config-mssql-web-auth": "Velg autentiseringstype som webtjeneren vil bruke for å koble til databasetjeneren under normal kjøring av wikien.\nHvis du velger «{{int:config-mssql-windowsauth}}», vil påloggingsinformasjonen til brukeren som kjører webtjeneren blir brukt.",
+ "config-mssql-sqlauth": "SQLServer-autentisering",
+ "config-mssql-windowsauth": "Windows-autentisering",
+ "config-site-name": "Navn på wiki:",
+ "config-site-name-help": "Dette vil vises i tittellinjen i nettleseren og diverse andre steder.",
+ "config-site-name-blank": "Skriv inn et nettstedsnavn.",
+ "config-project-namespace": "Prosjektnavnerom:",
+ "config-ns-generic": "Prosjekt",
+ "config-ns-site-name": "Samme som wikinavnet: $1",
+ "config-ns-other": "Annet (spesifiser)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Etter Wikipedias eksempel holder mange wikier deres sider med retningslinjer atskilt fra sine innholdssider, i et «'''prosjektnavnerom'''».\nAlle sidetitler i dette navnerommet starter med et gitt prefiks som du kan angi her.\nVanligvis er dette prefikset avledet fra navnet på wikien, men det kan ikke innholde punkttegn som «#» eller «:».",
+ "config-ns-invalid": "Det angitte navnerommet «<nowiki>$1</nowiki>» er ugyldig.\nAngi et annet prosjektnavnerom.",
+ "config-ns-conflict": "Det angitte navnerommet «<nowiki>$1</nowiki>» er i konflikt med et standard MediaWiki-navnerom.\nAngi et annet prosjekt-navnerom.",
+ "config-admin-box": "Administratorkonto",
+ "config-admin-name": "Ditt navn:",
+ "config-admin-password": "Passord:",
+ "config-admin-password-confirm": "Passord igjen:",
+ "config-admin-help": "Skriv inn ditt ønskede brukernavn her, for eksempel «Joe Bloggs».\nDette er navnet du vil bruke for å logge inn på denne wikien.",
+ "config-admin-name-blank": "Skriv inn et administratorbrukernavn.",
+ "config-admin-name-invalid": "Det angitte brukernavnet «<nowiki>$1</nowiki>» er ugyldig.\nAngi et annet brukernavn.",
+ "config-admin-password-blank": "Skriv inn et passord for administratorkontoen.",
+ "config-admin-password-mismatch": "De to passordene du skrev inn samsvarte ikke.",
+ "config-admin-email": "E-postadresse:",
+ "config-admin-email-help": "Skriv inn en e-postadresse her for at du skal kunne motta e-post fra andre brukere på wikien, tilbakestille passordet ditt, og bli varslet om endringer på sider på overvåkningslisten din. Du kan la dette feltet stå tomt.",
+ "config-admin-error-user": "Intern feil ved opprettelse av en admin med navnet «<nowiki>$1</nowiki>».",
+ "config-admin-error-password": "Intern feil ved opprettelse av passord for admin «<nowiki>$1</nowiki>»: <pre>$2</pre>",
+ "config-admin-error-bademail": "Du har skrevet inn en ugyldig e-postadresse.",
+ "config-subscribe": "Abonner på [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce e-postlisten for utgivelsesannonseringer].",
+ "config-subscribe-help": "Dette er en lav-volums e-postliste brukt til utgivelsesannonseringer, herunder viktige sikkerhetsannonseringer.\nDu bør abonnere på den og oppdatere MediaWikiinstallasjonen din når nye versjoner kommer ut.",
+ "config-subscribe-noemail": "Du prøvde å abonnere på epost-meldinger om nye versjoner uten å oppgi en epost-adresse. Vær vennlig å oppgi en epost-adresse om du ønsker dette abonnementet.",
+ "config-pingback": "Del data om denne installasjonen med MediaWiki-utviklerne.",
+ "config-pingback-help": "Om du velger denne vil MediaWiki periodisk pinge https://www.mediawiki.org med grunnleggende data om denne MediaWiki-instansen. Disse dataene inkluderer for eksempel systemtypen, PHP-versjonen og hvilket databasebakstykke som er valgt. Wikimedia Foundation deler disse dataene med MediaWiki-utviklerne for å bestemme framtidige utviklingstiltak. Følgende data vil bli sendt for ditt system:\n<pre>$1</pre>",
+ "config-almost-done": "Du er nesten ferdig!\nDu kan velge å hoppe over de siste konfigurasjonstrinnene og installere wikien med en gang.",
+ "config-optional-continue": "Still meg flere spørsmål.",
+ "config-optional-skip": "Jeg er lei, bare installer wikien.",
+ "config-profile": "Brukerrettighetsprofil:",
+ "config-profile-wiki": "Åpen wiki",
+ "config-profile-no-anon": "Kontoopprettelse påkrevd",
+ "config-profile-fishbowl": "Kun autoriserte bidragsytere",
+ "config-profile-private": "Privat wiki",
+ "config-profile-help": "Wikier fungerer best om du lar så mange mennesker som mulig redigere den.\nI MediaWiki er det enkelt å se på de siste endringene og tilbakestille eventuell skade som er gjort av naive eller ondsinnede brukere.\n\nImidlertid har mange funnet at MediaWiki er nyttig for mange formål, og av og til er det ikke lett å overbevise alle om fordelene med wiki-funksjonaliteten.\nSå du har valget.\n\nEn '''{{int:config-profile-wiki}}''' tillater enhver å redigere, selv uten å logge inn.\nEn wiki med '''{{int:config-profile-no-anon}}''' tilbyr ekstra ansvarlighet, men kan avskrekke tilfeldige bidragsytere.\n\n'''{{int:config-profile-fishbowl}}'''-scenariet tillater godkjente brukere å redigere, mens publikum kan se sider, og også historikken.\nEn '''{{int:config-profile-private}}''' tillater kun godkjente brukere å se sider, der den samme gruppen også får lov til å redigere dem.\n\nMer komplekse konfigurasjoner av brukerrettigheter er tilgjengelige etter installasjonen, se [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights relevant avsnitt i brukerbeskrivelsen].",
+ "config-license": "Opphavsrett og lisens:",
+ "config-license-none": "Ingen lisensbunntekst",
+ "config-license-cc-by-sa": "Creative Commons Navngivelse-DelPåSammeVilkår",
+ "config-license-cc-by": "Creative Commons Navngivelse",
+ "config-license-cc-by-nc-sa": "Creative Commons Navngivelse-IkkeKommersiell-DelPåSammeVilkår",
+ "config-license-cc-0": "Creative Commons Zero (Fristatus-erklæring)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 eller senere",
+ "config-license-pd": "Offentlig eiendom",
+ "config-license-cc-choose": "Velg en egendefinert Creative Commons-lisens",
+ "config-license-help": "Mange åpne wikier legger alle bidrag under en [http://freedomdefined.org/Definition gratislisens].\nDette gir en følelse av felleseie og stimulerer til langvarige bidrag.\nDette er normalt unødvendig for en privat eller virksomhetsbegrenset wiki.\n\nHvis du ønsker å kunne bruke tekst fra Wikipedia, og at Wikipedia skal kunne ta i mot tekst kopiert fra din wiki, bør du velge <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia brukte tidligere GNU Free Documentation License.\nGFDL er en grei lisens, med vanskelig å forstå.\nDet er også vanskelig å gjenbruke innhold lisensiert under GFDL.",
+ "config-email-settings": "E-postinnstillinger",
+ "config-enable-email": "Aktiver utgående e-post",
+ "config-enable-email-help": "Hvis du vil at e-post skal virke må [http://www.php.net/manual/en/mail.configuration.php PHPs e-postinnstillinger] bli konfigurert riktig.\nHvis du ikke ønsker noen e-postfunksjoner kan du deaktivere dem her.",
+ "config-email-user": "Aktiver e-post mellom brukere",
+ "config-email-user-help": "Tillat alle brukere å sende hverandre e-post hvis de har aktivert det i deres innstillinger.",
+ "config-email-usertalk": "Aktiver brukerdiskusjonssidevarsler",
+ "config-email-usertalk-help": "Tillat brukere å motta varsler ved endringer på deres brukerdiskusjonsside hvis de har aktivert dette i deres innstillinger.",
+ "config-email-watchlist": "Aktiver overvåkningslistevarsler",
+ "config-email-watchlist-help": "Tillat brukere å motta varsler ved endringer på deres overvåkede sider hvis de har aktivert dette i deres innstillinger.",
+ "config-email-auth": "Aktiver e-postautentisering",
+ "config-email-auth-help": "Om dette alternativet er aktivert må brukere bekrefte sin e-postadresse ved å bruke en lenke som blir sendt til dem når de setter eller endrer adressen sin.\nKun autentiserte e-postadresser kan motta e-post fra andre brukere eller endringsvarsel.\nÅ sette dette valget er '''anbefalt''' for offentlige wikier på grunn av potensiell misbruk av e-postfunksjonene.",
+ "config-email-sender": "Svar-e-postadresse:",
+ "config-email-sender-help": "Skriv inn e-postadressen som skal brukes som svar-adresse ved utgående e-post.\nDet er hit returmeldinger vil bli sendt.\nMange e-posttjenere krever at minst domenenavnet må være gyldig.",
+ "config-upload-settings": "Bilde- og filopplastinger",
+ "config-upload-enable": "Aktiver filopplastinger",
+ "config-upload-help": "Filopplastinger kan potensielt utsette tjeneren din for sikkerhetsrisikoer.\nFor mer informasjon, les [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security sikkerhetsseksjonen] i manualen.\n\nFor å aktivere filopplastinger, endre modusen i <code>images</code>-undermappen i MediaWikis rotmappe slik at nettjeneren kan skrive til den.\nAktiver så dette alternativet.",
+ "config-upload-deleted": "Mappe for slettede filer:",
+ "config-upload-deleted-help": "Velg en mappe for å arkivere slettede filer.\nIdeelt burde ikke denne være tilgjengelig for nettet.",
+ "config-logo": "Logo-URL:",
+ "config-logo-help": "MediaWikis standarddrakt har satt av plass til en 135x160 pikslers logo i øvre venstre hjørne av sidepanelet.\nLast opp et bilde i passende størrelse og skriv inn nettadressen her.\n\nDu kan bruke code>$wgStylePath</code> eller <code>$wgScriptPath</code> hvis logoen er relativ til disse stiene.\n\nHvis du ikke ønsker noen logo, la boksen være tom.",
+ "config-instantcommons": "Aktiver Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] er en funksjon som gjør det mulig for wikier å bruke bilder, lyder og andre media funnet på nettstedet [https://commons.wikimedia.org/ Wikimedia Commons].\nFor å gjøre dette krever MediaWiki tilgang til internett.\n\nFor mer informasjon om denne funksjonen, inklusive instruksjoner om hvordan man setter opp dette for andre wikier enn Wikimedia Commons, konsulter [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos manualen].",
+ "config-cc-error": "Lisensvelgeren for Creative Commons ga ikke noe resultat.\nLegg inn lisensnavnet manuelt.",
+ "config-cc-again": "Velg igjen...",
+ "config-cc-not-chosen": "Velg hvilken Creative Commons-lisens du ønsker og klikk «proceed».",
+ "config-advanced-settings": "Avansert konfigurasjon",
+ "config-cache-options": "Innstillinger for objekt-mellomlagring:",
+ "config-cache-help": "Objekt-mellomlagring brukes for å forbedre hastigheten for MediaWiki. Ofte forekommende data lagres for gjenbruk.\nMiddels til store nettsteder bør absolutt aktivisere mellomlagring, med også små nettsteder kan ha nytte av dette.",
+ "config-cache-none": "Ingen mellomlagring (ingen funksjonalitet mistes, men hastigheten kan bli dårlig for store wikier-nettsteder)",
+ "config-cache-accel": "Mellomlagring av PHP-objekter (APC, APCu, XCache eller WinCache)",
+ "config-cache-memcached": "Bruk Memcached (krever tilleggsoppsett og -konfigurering)",
+ "config-memcached-servers": "Memcached-servere:",
+ "config-memcached-help": "Liste av IP-adresser for bruk fra Memcached.\nDet bør angis en per linje sammen med porten som brukes. For eksempel:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Du valgte Memcached som din mellomlagringstype, men anga ingen servere.",
+ "config-memcache-badip": "Du har lagt inn en ugyldig IP-adresse for Memcached: $1.",
+ "config-memcache-noport": "Du spesifiserte ingen port til å bruke for Memcached-server: $1.\nHvis du ikke kjenner porten, så er stardard 11211.",
+ "config-memcache-badport": "Memcached-port-numrene må være mellom $1 og $2.",
+ "config-extensions": "Utvidelser",
+ "config-extensions-help": "Utvidelsene listet over ble oppdaget i din <code>./extensions</code>-folder.\n\nDisse kan trenge ekstra konfigurering, men du kan aktivisere dem nå.",
+ "config-skins": "Drakt",
+ "config-skins-help": "Draktene som listes opp nedenfor ble funnet i <code>./skins</code>-mappen din. Du må slå på minst én, og velge en standarddrakt.",
+ "config-skins-use-as-default": "Bruk denne drakta som standard",
+ "config-skins-missing": "Ingen drakter ble funnet; MediaWiki vil bruke en reservedrakt til du har installert noen ordentlige drakter.",
+ "config-skins-must-enable-some": "Du må velge minst én drakt å slå på.",
+ "config-skins-must-enable-default": "Standarddrakten må være slått på.",
+ "config-install-alreadydone": "'''Advarsel:''' Det ser ut til at allerede har installert MediaWiki og prøver å installere denne må nytt.\nVær vennlig å fortsette til neste side.",
+ "config-install-begin": "Ved å trykke \"{{int:config-continue}}\", starter du installeringen av MediaWiki.\nHvis du først ønsker å endre på noe, trykk\"{{int:config-back}}\".",
+ "config-install-step-done": "ferdig",
+ "config-install-step-failed": "mislyktes",
+ "config-install-extensions": "Inkludert utvidelser",
+ "config-install-database": "Setter opp database",
+ "config-install-schema": "Opprette \"schema\"",
+ "config-install-pg-schema-not-exist": "PostgreSQL \"schema\" finnes ikke.",
+ "config-install-pg-schema-failed": "Opprettelse av tabell var mislykket.\nPass på at bruker \"$1\" kan skrive til schema \"$2\".",
+ "config-install-pg-commit": "Beslutte endringer",
+ "config-install-pg-plpgsql": "Sjekk om språket er PL/pgSQL",
+ "config-pg-no-plpgsql": "Du må installere språket PL/pgSQL i database $1",
+ "config-pg-no-create-privs": "Brukerkontoen du anga for installering har ikke nok privilegier for å opprette annen brukerkonto.",
+ "config-pg-not-in-role": "Brukerkontoen du anga for web-brukeren finnes allerede.\nBrukerkontoen du anga for installering er ikke superbruker og er ikke medlem av webbrukerens rolle, så den kan ikke opprette objekter eid av webbrukeren.\n\nMediaWiki krever nå at tabellen eies av webbrukeren. Vær vennlig å angi en annen webbrukerkonto, eller klikk \"tilbake\" og angi en installeringsbruker med nødvendige privilegier.",
+ "config-install-user": "Oppretter databasebruker",
+ "config-install-user-alreadyexists": "Brukeren «$1» finnes allerede",
+ "config-install-user-create-failed": "Opprettelse av brukeren «$1» mislyktes: $2",
+ "config-install-user-grant-failed": "Å gi tillatelse til brukeren «$1» mislyktes: $2",
+ "config-install-user-missing": "Den angitte brukeren \"$1\" finnes ikke.",
+ "config-install-user-missing-create": "Den angitte brukeren \"$1\" finnes ikke.\nVær vennlig å klikke i avkrysningsboksen \"opprett konto\" under hvis du ønsker å opprette denne.",
+ "config-install-tables": "Oppretter tabeller",
+ "config-install-tables-exist": "'''Advarsel:''' MediaWiki-tabellen ser ut til å finnes allerede.\nHopper derfor over opprettelsen.",
+ "config-install-tables-failed": "<strong>Feil:</strong> Opprettelsen av tabellen feilet med følgende melding: $1",
+ "config-install-interwiki": "Populerer standard interwiki-tabell",
+ "config-install-interwiki-list": "Det var ikke mulig å lese filen <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Advarsel:</strong> Interwiki-tabellen ser allerede ut til å ha innhold.\nLegger derfor ikke inn standardlisten.",
+ "config-install-stats": "Initialiserer statisikk",
+ "config-install-keys": "Genererer hemmelige nøkler",
+ "config-insecure-keys": "<strong>Advarsel:</strong> {{PLURAL:$2|En sikker nøkkel|Sikre nøkler}} ($1) generert under installeringen er ikke helt {{PLURAL:$2|trygg|trygge}}. Vurder å endre {{PLURAL:$2|den|dem}} manuelt.",
+ "config-install-updates": "Forhindre unødvendige oppdateringer",
+ "config-install-updates-failed": "<strong>Feil:</strong> Innsetting av oppdateringsnøkler i tabellene mislyktes med følgende feilmelding: $1",
+ "config-install-sysop": "Oppretter brukerkonto for administrator",
+ "config-install-subscribe-fail": "Ikke mulig å abonnere på mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL er ikke installert og <code>allow_url_fopen</code> er ikke tilgjengelig.",
+ "config-install-mainpage": "Oppretter hovedside med standard innhold",
+ "config-install-mainpage-exists": "Hovedsiden eksisterer allerede, hopper over",
+ "config-install-extension-tables": "Oppretter tabeller for aktiviserte utvidelser",
+ "config-install-mainpage-failed": "Kunne ikke sette inn hovedside: $1",
+ "config-install-done": "<strong>Gratulrerer!</strong>\nDu har lykkes i å installere MediaWiki.\n\nInstallasjonsprogrammet har generert en <code>LocalSettings.php</code>-fil.\nDen inneholder alle dine konfigureringer.\n\nDu må laste den ned og legge den på hovedfolderen for din wiki-installasjon (der index.php ligger). Nedlastingen skulle ha startet automatisk.\n\nHvis ingen nedlasting ble tilbudt, eller du avbrøt den, kan du få den i gang ved å klikke på lenken under:\n\n$3\n\n<strong>OBS:</strong> Hvis du ikke gjør dette nå, vil den genererte konfigurasjonsfilen ikke være tilgjengelig for deg senere.\n\nNår dette er gjort, kan du <strong>[$2 gå inn i wikien]</strong>.",
+ "config-install-done-path": "<strong>Gratulerer!</strong>\nDu har installert MediaWiki.\n\nInstallereren har generert en <code>LocalSettings.php</code>-fil.\nDen inneholder all konfigurasjonen for wikien.\n\nDu må laste den ned og legge den i <code>$4</code>. Nedlastingen skal ha startet automatisk.\n\nOm nedlastingen ikke ble startet, eller om du avbrøt den, kan du starte på nytt ved å klikke lenken nedenfor:\n\n$3\n\n<strong>Merk:</strong> Om du ikke gjør dette nå vil den genererte konfigurasjonen ikke være tilgjengelig senere.\n\nNår dette er gjort kan du <strong>[$2 gå til wikien din]</strong>.",
+ "config-download-localsettings": "Last ned <code>LocalSettings.php</code>",
+ "config-help": "hjelp",
+ "config-help-tooltip": "klikk for å utvide",
+ "config-nofile": "Filen \"$1\" ble ikke funnet. Kan den være blitt slettet?",
+ "config-extension-link": "Visste du at wikien din kan brukes sammen med en mengde [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions utvidelser]?\n\nDu kan sjekke gjennom [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category utvidelser per kategori] eller [https://www.mediawiki.org/wiki/Extension_Matrix utvidelsesmatrisen] for å se den komplette listen av utvidelser.",
+ "config-skins-screenshots": "$1 (skjermbilder: $2)",
+ "config-screenshot": "skjermbilde",
+ "mainpagetext": "<strong>MediaWiki har blitt installert.</strong>",
+ "mainpagedocfooter": "Sjekk [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents brukerveiledningen] for å få informasjon om hvordan du bruker wiki-programvaren.\n\n==Hvordan komme igang==\n*[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Innstillingsliste]\n*[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Ofte stilte spørsmål om MediaWiki]\n*[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki e-postliste]\n*[https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Tilpass MediaWiki for ditt språk]\n*[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Lær deg å beskytte deg mot spam på wikien din]"
+}
diff --git a/www/wiki/includes/installer/i18n/nds-nl.json b/www/wiki/includes/installer/i18n/nds-nl.json
new file mode 100644
index 00000000..bc3b9538
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nds-nl.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Servien"
+ ]
+ },
+ "config-help": "hulpe",
+ "mainpagetext": "'''’t Installeren van de MediaWiki programmatuur is succesvol.'''",
+ "mainpagedocfooter": "Bekiek de [https://meta.wikimedia.org/wiki/Help:Contents haandleiding] veur informasie over t gebruuk van de wikiprogrammatuur.\n\n== Meer hulpe ==\n* [https://www.mediawiki.org/wiki/Help:Configuration_settings Lieste mit instellingen]\n* [https://www.mediawiki.org/wiki/Help:FAQ MediaWiki-vragen die vake esteld wörden]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki-postlieste veur nieje versies]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Maak MediaWiki beschikbaor in joew taal]"
+}
diff --git a/www/wiki/includes/installer/i18n/nds.json b/www/wiki/includes/installer/i18n/nds.json
new file mode 100644
index 00000000..01ed68a3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nds.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "Joachim Mos"
+ ]
+ },
+ "config-page-name": "Naam",
+ "config-ns-generic": "Projekt",
+ "config-admin-name": "Dien Naam:",
+ "config-admin-password": "Passwoord:",
+ "config-help": "Hülp",
+ "mainpagetext": "'''De MediaWiki-Software is mit Spood installeert worrn.'''",
+ "mainpagedocfooter": "Kiek de [https://meta.wikimedia.org/wiki/MediaWiki_localisation Dokumentatschoon för dat Anpassen vun de Brukerböversiet]\nun dat [https://meta.wikimedia.org/wiki/MediaWiki_User%27s_Guide Brukerhandbook] för Hülp to de Bruuk un Konfiguratschoon."
+}
diff --git a/www/wiki/includes/installer/i18n/ne.json b/www/wiki/includes/installer/i18n/ne.json
new file mode 100644
index 00000000..3ee33c11
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ne.json
@@ -0,0 +1,86 @@
+{
+ "@metadata": {
+ "authors": [
+ "Bhawani Gautam",
+ "RajeshPandey",
+ "सरोज कुमार ढकाल",
+ "Ganesh Paudel",
+ "बिप्लब आनन्द",
+ "Nirjal stha",
+ "राम प्रसाद जोशी"
+ ]
+ },
+ "config-desc": "मेडियाविकिको लागि स्थापक",
+ "config-title": "मेडिया विकि $1 स्थापना",
+ "config-information": "जानकारी",
+ "config-localsettings-badkey": "तपाईंले दिएको कुंजी मिलेन ।",
+ "config-your-language": "तपाईंको भाषा:",
+ "config-your-language-help": "इन्स्टल गर्दा उपयोग गर्ने भाषा छान्नुहोस् ।",
+ "config-wiki-language": "विकि भाषाहरू",
+ "config-back": "← पछाडी",
+ "config-continue": "जारी राख्ने →",
+ "config-page-language": "भाषा",
+ "config-page-welcome": "मिडीयाविकिमा तपाईंलाई स्वागत छ!",
+ "config-page-dbconnect": "डेटाबेससँग सम्बन्ध बनाउने",
+ "config-page-dbsettings": "डेटावेस सेटिङ",
+ "config-page-name": "नाम",
+ "config-page-options": "विकल्पहरू",
+ "config-page-install": "स्थापना गर्ने",
+ "config-page-complete": "पूरा भयो !",
+ "config-page-restart": "स्थापना फेरि सुरु गर्ने",
+ "config-page-readme": "पढ्नुहोस्",
+ "config-page-releasenotes": "प्रकाशन टिप्पणी",
+ "config-page-copying": "कपि हुदै",
+ "config-page-upgradedoc": "अद्यावधिक गरिदै",
+ "config-page-existingwiki": "विकि बन्द हुदै",
+ "config-restart": "हुन्छ, पुनः सुचारू गर्ने",
+ "config-env-php": "PHP $1 स्थापना गरिएको छ ।",
+ "config-env-hhvm": "HHVM $1 स्थापना गरिएको छ ।",
+ "config-db-type": "डाटाबेस प्रकारः",
+ "config-db-host": "डेटाबेस होस्ट:",
+ "config-db-host-oracle": "डेटाबेस TNS:",
+ "config-db-name": "डाटाबेस नामः",
+ "config-db-name-oracle": "डेटाबेस स्केमा:",
+ "config-db-username": "डाटाबेस प्रयोगकर्ता नामः",
+ "config-db-password": "डाटाबेस पासबर्डः",
+ "config-db-port": "डेटाबेस पोर्ट:",
+ "config-header-mysql": "MySQL सेटिङ",
+ "config-header-postgres": "PostgreSQL सेटिङहरू",
+ "config-header-sqlite": "SQLite सेटिङ्हरू",
+ "config-header-oracle": "ओरेकल सेटिङहरू",
+ "config-mysql-binary": "बाइनरी",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "विकीको नाम:",
+ "config-site-name-blank": "साइटको नाम लेख्नुहोस।",
+ "config-project-namespace": "आयोजना नेमस्पेस:",
+ "config-ns-generic": "परियोजना",
+ "config-ns-other": "अन्य(खुलाउनुहोस)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "प्रवन्धक खाता",
+ "config-admin-name": "तपाईंको प्रयोगकर्ता नाम:",
+ "config-admin-password": "पासवर्ड:",
+ "config-admin-email": "इमेल ठेगाना:",
+ "config-optional-continue": "मलाई थप प्रश्नहरू सोध्नुहोस् ।",
+ "config-profile": "प्रयोगकर्ता अधिकार प्रोफाइल:",
+ "config-profile-wiki": "खुल्ला विकि",
+ "config-profile-no-anon": "खाता बनाउन नै पर्ने",
+ "config-profile-fishbowl": "अधिकार प्राप्त प्रयोगकर्ताहरू मात्र",
+ "config-profile-private": "निजी विकि",
+ "config-license": "प्रतिलिपी अधिकार र इजाजतपत्र:",
+ "config-license-none": "इजाजतपत्र फूटर नभएको",
+ "config-license-cc-by-sa": "क्रियटिभ कमन्स एट्रिव्युसन- सेयर अलाइक",
+ "config-license-cc-by": "क्रियटिभ कमन्स एट्रिव्युसन",
+ "config-email-settings": "इमेल सेटिंग",
+ "config-extensions": "एक्सटेन्सनहरू",
+ "config-skins": "स्किनहरू",
+ "config-install-step-done": "सम्पन्न",
+ "config-install-step-failed": "असफल",
+ "config-install-tables": "टेबल बनाउदै",
+ "config-install-stats": "तथ्यांक सुचारू हुदै",
+ "config-install-keys": "गोप्य चाबी उत्पन्न गर्दै",
+ "config-install-sysop": "प्रबन्धकको प्रयोगकर्ता खाता बनाउदै",
+ "config-help": "सहायता",
+ "config-help-tooltip": "विस्तार गर्न क्लीक गर्नुहोस्",
+ "mainpagetext": "'''मीडिया सफलतापूर्वक कम्प्यूटरमा स्थापित भयो ।'''",
+ "mainpagedocfooter": " विकी अनुप्रयोग कसरी प्रयोग गर्ने भन्ने जानकारीको लागि [https://meta.wikimedia.org/wiki/Help:Contents प्रयोगकर्ता सहायता] हेर्नुहोस्\n\n विकी अनुप्रयोग कसरी प्रयोग गर्ने भन्ने जानकारीको लागि [https://meta.wikimedia.org/wiki/Help:Contents प्रयोगकर्ता सहायता] हेर्नुहोस्\n\n== सुरू गर्नको लागि ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings विन्यास सेटिङ्ग सूची]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ मेडियाविकि सामान्य प्रश्नका उत्तरहरू]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce मेडियाविकि सूचना मेलिङ्ग सूची]"
+}
diff --git a/www/wiki/includes/installer/i18n/nl-informal.json b/www/wiki/includes/installer/i18n/nl-informal.json
new file mode 100644
index 00000000..299b3b14
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nl-informal.json
@@ -0,0 +1,74 @@
+{
+ "@metadata": {
+ "authors": [
+ "Siebrand",
+ "Seb35",
+ "Macofe"
+ ]
+ },
+ "config-localsettings-badkey": "De sleutel die je hebt opgegeven is onjuist",
+ "config-upgrade-key-missing": "Er is een bestaande installatie van MediaWiki aangetroffen.\nPlaats de volgende regel onderaan je <code>LocalSettings.php</code> om deze installatie bij te werken:\n\n$1",
+ "config-session-expired": "Je sessiegegevens zijn verlopen.\nSessies zijn ingesteld om een levensduur van $1 te hebben.\nJe kunt deze wijzigen via de instelling <code>session.gc_maxlifetime</code> in php.ini.\nBegin het installatieproces opnieuw.",
+ "config-no-session": "Je sessiegegevens zijn verloren gegaan.\nControleer je php.ini en zorg dat er een juiste map is ingesteld voor <code>session.save_path</code>.",
+ "config-your-language": "Jouw taal:",
+ "config-help-restart": "Wil je alle opgeslagen gegevens die je hebt ingevoerd wissen en het installatieproces opnieuw starten?",
+ "config-welcome": "=== Controle omgeving ===\nEr worden een aantal basiscontroles uitgevoerd met als doel vast te stellen of deze omgeving geschikt is voor een installatie van MediaWiki.\nAls je hulp nodig hebt bij de installatie, lever deze gegevens dan ook aan.",
+ "config-copyright": "=== Auteursrechten en voorwaarden ===\n\n$1\n\nDit programma is vrije software. Je mag het verder verspreiden en/of aanpassen in overeenstemming met de voorwaarden van de GNU General Public License zoals uitgegeven door de Free Software Foundation; ofwel versie 2 van de Licentie of - naar eigen keuze - enige latere versie.\n\nDit programma wordt verspreid in de hoop dat het nuttig is, maar '''zonder enige garantie''', zelfs zonder de impliciete garantie van '''verkoopbaarheid''' of '''geschiktheid voor een bepaald doel'''.\nZie de GNU General Public License voor meer informatie.\n\nSamen met dit programma hoor je een <doclink href=Copying>exemplaar van de GNU General Public License</doclink> ontvangen te hebben; zo niet, schrijf dan aan de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, Verenigde Staten. Of [http://www.gnu.org/copyleft/gpl.html lees de licentie online].",
+ "config-env-good": "De omgeving is gecontroleerd.\nJe kunt MediaWiki installeren.",
+ "config-env-bad": "De omgeving is gecontroleerd.\nJe kunt MediaWiki niet installeren.",
+ "config-unicode-pure-php-warning": "'''Waarschuwing''': de [http://pecl.php.net/intl PECL-extensie intl] is niet beschikbaar om de Unicodenormalisatie af te handelen en daarom wordt de langzame PHP-implementatie gebruikt.\nAls je MediaWiki voor een website met veel verkeer installeert, lees je dan in over [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicodenormalisatie].",
+ "config-unicode-update-warning": "'''Waarschuwing''': de geïnstalleerde versie van de Unicodenormalisatiewrapper maakt gebruik van een oudere versie van [http://site.icu-project.org/ de bibliotheek van het ICU-project].\nJe moet [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations bijwerken] als Unicode voor jou van belang is.",
+ "config-no-db": "Het was niet mogelijk een geschikte databasedriver te vinden voor PHP.\nJe moet een databasedriver installeren voor PHP.\nDe volgende databases worden ondersteund: $1.\n\nAls je op een gedeelde omgeving zit, vraag dan aan je hostingprovider een geschikte databasedriver te installeren.\nAls je PHP zelf hebt gecompileerd, wijzig dan je instellingen zodat een databasedriver wordt geactiveerd, bijvoorbeeld via <code>./configure --with-mysql</code>.\nAls je PHP hebt geïnstalleerd via een Debian- of Ubuntu-package, installeer dan ook de module php5-mysql.",
+ "config-outdated-sqlite": "''' Waarschuwing:''' je gebruikt SQLite $1. SQLite is niet beschikbaar omdat de minimaal vereiste versie $2 is.",
+ "config-mod-security": "'''Waarschuwing:''' je webserver heeft de module [http://modsecurity.org/ mod_security] ingeschakeld. Als deze onjuist is ingesteld, kan dit problemen geven in combinatie met MediaWiki of andere software die gebruikers in staat stelt willekeurige inhoud te posten.\nLees de [http://modsecurity.org/documentation/ documentatie over mod_security] of neem contact op met de helpdesk van je provider als je tegen problemen aanloopt.",
+ "config-imagemagick": "ImageMagick aangetroffen: <code>$1</code>.\nHet aanmaken van miniaturen van afbeeldingen wordt ingeschakeld als je uploaden inschakelt.",
+ "config-gd": "Ingebouwde GD grafische bibliotheek aangetroffen.\nHet aanmaken van miniaturen van afbeeldingen wordt ingeschakeld als je uploaden inschakelt.",
+ "config-uploads-not-safe": "'''Waarschuwing:''' je uploadmap <code>$1</code> kan gebruikt worden voor het arbitrair uitvoeren van scripts.\nHoewel MediaWiki alle toegevoegde bestanden controleert op bedreigingen, is het zeer aan te bevelen het [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security beveiligingslek te verhelpen] alvorens uploads in te schakelen.",
+ "config-no-cli-uploads-check": "''Waarschuwing:'' je standaardmap voor uploads (<code>$1</code>) wordt niet gecontroleerd op kwetsbaarheden voor het uitvoeren van willekeurige scripts gedurende de CLI-installatie.",
+ "config-brokenlibxml": "Je systeem heeft een combinatie van PHP- en libxml2-versies geïnstalleerd die is foutgevoelig is en kan leiden tot onzichtbare beschadiging van gegevens in MediaWiki en andere webapplicaties.\nUpgrade naar PHP 5.2.9 of hoger en libxml2 2.7.3 of hoger([https://bugs.php.net/bug.php?id=45996 bij PHP gerapporteerde fout]).\nDe installatie wordt afgebroken.",
+ "config-db-host-help": "Als je databaseserver een andere server is, voer dan de hostnaam of het IP-adres hier in.\n\nAls je gebruik maakt van gedeelde webhosting, hoort je provider je de juiste hostnaam te hebben verstrekt.\n\nAls je MediaWiki op een Windowsserver installeert en MySQL gebruikt, dan werkt \"localhost\" mogelijk niet als servernaam.\nAls het inderdaad niet werkt, probeer dan \"127.0.0.1\" te gebruiken als lokaal IP-adres.\n\nAls je PostgreSQL gebruikt, laat dit veld dan leeg om via een Unix-socket te verbinden.",
+ "config-db-host-oracle-help": "Voer een geldige [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name] in; een tnsnames.ora-bestand moet zichtbaar zijn voor deze installatie.<br />Als je gebruik maakt van clientlibraries 10g of een latere versie, kan je ook gebruik maken van de naamgevingsmethode [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-name-help": "Kies een naam die je wiki identificeert.\nEr mogen geen spaties gebruikt worden.\nAls je gebruik maakt van gedeelde webhosting, dan hoort je provider ofwel jou een te gebruiken databasenaam gegeven te hebben, of je aangegeven te hebben hoe je databases kunt aanmaken.",
+ "config-db-account-oracle-warn": "Er zijn drie ondersteunde scenario's voor het installeren van Oracle als databasebackend:\n\nAls je een databasegebruiker wilt aanmaken als onderdeel van het installatieproces, geef dan de gegevens op van een databasegebruiker in met de rol SYSDBA voor de installatie en voer de gewenste aanmeldgegevens in voor de gebruiker met webtoegang. Je kunt ook de gebruiker met webtoegang handmatig aanmaken en alleen van die gebruiker de aanmeldgegevens opgeven als deze de vereiste rechten heeft om schemaobjecten aan te maken. Als laatste is het mogelijk om aanmeldgegevens van twee verschillende gebruikers op te geven; een met de rechten om schemaobjecten aan te maken, en een met alleen webtoegang.\n\nEen script voor het aanmaken van een gebruiker met de vereiste rechten is te vinden in de map \"maintenance/oracle/\" van deze installatie. Onthoud dat het gebruiken van een gebruiker met beperkte rechten alle mogelijkheden om beheerscripts uit te voeren met de standaard gebruiker onmogelijk maakt.",
+ "config-db-prefix-help": "Als je een database moet gebruiken voor meerdere wiki's, of voor MediaWiki en een andere toepassing, dan kan je ervoor kiezen om een voorvoegsel toe te voegen aan de tabelnamen om conflicten te voorkomen.\nGebruik geen spaties.\n\nDit veld wordt meestal leeg gelaten.",
+ "config-mysql-old": "Je moet MySQL $1 of later gebruiken.\nJij gebruikt $2.",
+ "config-db-schema-help": "Dit schema klopt meestal.\nWijzig het alleen als je weet dat dit nodig is.",
+ "config-sqlite-dir-help": "SQLite slaat alle gegevens op in een enkel bestand.\n\nDe map die je opgeeft moet beschrijfbaar zijn voor de webserver tijdens de installatie.\n\nDeze mag '''niet toegankelijk''' zijn via het web en het bestand mag dus niet tussen de PHP-bestanden staan.\n\nHet installatieprogramma schrijft het bestand <code>.htaccess</code> weg met het databasebestand, maar als dat niet werkt kan iemand zich toegang tot het ruwe databasebestand verschaffen.\nOok de gebruikersgegevens (e-mailadressen, wachtwoordhashes) en verwijderde versies en overige gegevens met beperkte toegang via MediaWiki zijn dan onbeschermd.\n\nOverweeg om de database op een totaal andere plaats neer te zetten, bijvoorbeeld in <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-support-info": "MediaWiki ondersteunt de volgende databasesystemen:\n\n$1\n\nAls je het databasesysteem dat je wilt gebruiken niet in de lijst terugvindt, volg dan de handleiding waarnaar hierboven wordt verwezen om ondersteuning toe te voegen.",
+ "config-missing-db-name": "Je moet een waarde opgeven voor \"Databasenaam\"",
+ "config-missing-db-host": "Je moet een waarde invoeren voor \"Databaseserver\"",
+ "config-missing-db-server-oracle": "Je moet een waarde opgeven voor \"Database-TNS\"",
+ "config-postgres-old": "PostgreSQL $1 of hoger is vereist.\nJij gebruikt $2.",
+ "config-sqlite-name-help": "Kies een naam die je wiki identificeert.\nGebruik geen spaties of koppeltekens.\nDeze naam wordt gebruikt voor het gegevensbestand van SQLite.",
+ "config-upgrade-done": "Het bijwerken is afgerond.\n\nJe kunt [$1 je wiki nu gebruiken].\n\nAls je je <code>LocalSettings.php</code> opnieuw wilt aanmaken, klik dan op de knop hieronder.\nDit is '''niet aan te raden''' tenzij je problemen hebt met je wiki.",
+ "config-upgrade-done-no-regenerate": "Het bijwerken is afgerond.\n\nJe kunt nu [$1 je wiki gebruiken].",
+ "config-db-web-no-create-privs": "De gebruiker die je hebt opgegeven voor de installatie heeft niet voldoende rechten om een gebruiker aan te maken.\nDe gebruiker die je hier opgeeft moet al bestaan.",
+ "config-mysql-myisam-dep": "'''Waarschuwing''': je hebt MyISAM geselecteerd als opslagengine voor MySQL. Dit is niet aan te raden voor MediaWiki omdat:\n* het nauwelijks ondersteuning biedt voor gebruik door meerdere gebruikers tegelijkertijd door het locken van tabellen;\n* het meer vatbaar is voor corruptie dan andere engines;\n* de code van MediaWiki niet alstijd omgaat met MyISAM zoals dat zou moeten.\n\nAls je installatie van MySQL InnoDB ondersteunt, gebruik dat dan vooral.\nAls je installatie van MySQL geen ondersteuning heeft voor InnoDB, denk dan na over upgraden.",
+ "config-mysql-charset-help": "In '''binaire modus''' slaat MediaWiki tekst in UTF-8 op in binaire databasevelden.\nDit is efficiënter dan de UTF-8-modus van MySQL en stelt je in staat de volledige reeks Unicodetekens te gebruiken.\n\nIn '''UTF-8-modus''' kent MySQL de tekenset van je gegevens en kan de databaseserver ze juist weergeven en converteren.\nHet is dat niet mogelijk tekens op te slaan die de \"[https://nl.wikipedia.org/wiki/Lijst_van_Unicode-subbereiken#Basic_Multilingual_Plane Basic Multilingual Plane]\" te boven gaan.",
+ "config-project-namespace-help": "In het kielzog van Wikipedia beheren veel wiki's hun beleidspagina's apart van hun inhoudelijke pagina's in een \"'''projectnaamruimte'''\".\nAlle paginanamen in deze naamruimte beginnen met een bepaald voorvoegsel dat je hier kunt opgeven.\nDit voorvoegsel wordt meestal afgeleid van de naam van de wiki, maar het kan geen bijzondere tekens bevatten als \"#\" of \":\".",
+ "config-admin-name": "Je naam:",
+ "config-admin-password-mismatch": "De twee door jou ingevoerde wachtwoorden komen niet overeen.",
+ "config-admin-email-help": "Voer hier een e-mailadres in om e-mail te kunnen ontvangen van andere gebruikers op de wiki, je wachtwoord opnieuw in te kunnen stellen en op de hoogte te worden gehouden van wijzigingen van pagina's op uw volglijst. Je kunt het veld leeg laten.",
+ "config-admin-error-bademail": "Je hebt een ongeldig e-mailadres opgegeven",
+ "config-subscribe-help": "Dit is een mailinglijst met een laag volume voor aankondigingen van nieuwe versies, inclusief belangrijke aankondigingen met betrekking tot beveiliging.\nAbonneer jezelf erop en werk je MediaWiki-installatie bij als er nieuwe versies uitkomen.",
+ "config-subscribe-noemail": "Je hebt geprobeerd je te abonneren op de mailinglijst voor release-aankondigingen zonder een e-mailadres op te geven.\nGeef een e-mailadres op als je je wilt abonneren op de mailinglijst.",
+ "config-almost-done": "Je bent bijna klaar!\nAls je wilt kan je de overige instellingen overslaan en de wiki nu installeren.",
+ "config-profile-help": "Wiki's werken het beste als ze door zoveel mogelijk gebruikers worden bewerkt.\nIn MediaWiki is het eenvoudig om de recente wijzigingen te controleren en eventuele foutieve of kwaadwillende bewerkingen terug te draaien.\n\nDaarnaast vinden velen MediaWiki goed inzetbaar in vele andere rollen, en soms is het niet handig om helemaal \"op de wikimanier\" te werken.\nDaarom biedt dit installatieprogramma je de volgende keuzes voor de basisinstelling van gebruikersvrijheden:\n\nEen '''{{int:config-profile-wiki}}''' staat iedereen toe te bewerken, zonder zelfs aan te melden.\nEen wiki met '''{{int:config-profile-no-anon}}\" biedt extra verantwoordelijkheid, maar kan afschrikken toevallige gebruikers afschrikken.\n\nHet scenario '''{{int:config-profile-fishbowl}}''' laat gebruikers waarvoor dat is ingesteld bewerkt, maar andere gebruikers kunnen alleen pagina's bekijken, inclusief de bewerkingsgeschiedenis.\nIn een '''{{int:config-profile-private}}''' kunnen alleen goedgekeurde gebruikers pagina's bekijken en bewerken.\n\nMeer complexe instellingen voor gebruikersrechten zijn te maken na de installatie; hierover is meer te lezen in de [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights handleiding].",
+ "config-license-help": "In veel openbare wiki's zijn alle bijdragen beschikbaar onder een [http://freedomdefined.org/Definition vrije licentie].\nDit helpt bij het creëren van een gevoel van gemeenschappelijk eigendom en stimuleert bijdragen op lange termijn.\nDit is over het algemeen niet nodig is voor een particuliere of zakelijke wiki.\n\nAls je teksten uit Wikipedia wilt kunnen gebruiken en je wilt het mogelijk maken teksten uit je wiki naar Wikipedia te kopiëren, kies dan de licentie '''Creative Commons Naamsvermelding-Gelijk delen'''.\n\nDe GNU Free Documentation License is de oude licentie voor inhoud uit Wikipedia.\nDit is nog steeds een geldige licentie, maar deze licentie is lastig te begrijpen.\nHet is ook lastig inhoud te hergebruiken onder de GFDL.",
+ "config-enable-email-help": "Als je wilt dat e-mailen mogelijk is, dan moeten de [http://www.php.net/manual/en/mail.configuration.php e-mailinstellingen van PHP] correct zijn.\nAls je niet wilt dat e-mailen mogelijk is, dan kan je de instellingen hier uitschakelen.",
+ "config-upload-help": "Het toestaan van het uploaden van bestanden stelt je server mogelijk bloot aan beveiligingsrisico's.\nEr is meer [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security informatie over beveiliging] beschikbaar in de handleiding.\n\nOm het bestandsuploads mogelijk te maken kan je de rechten op de submap <code>images</code> onder de hoofdmap van MediaWiki aanpassen, zodat de webserver erin kan schrijven.\nDaarmee wordt deze functie ingeschakeld.",
+ "config-logo-help": "Het standaarduiterlijk van MediaWiki bevat ruimte voor een logo van 135x160 pixels boven het menu.\nUpload een afbeelding met de juiste afmetingen en voer de URL hier in.\n\nAls je geen logo wilt gebruiken, kan je dit veld leeg laten.",
+ "config-cc-not-chosen": "Kies de Creative Commonslicentie die je wilt gebruiken en klik op \"proceed\".",
+ "config-memcache-needservers": "Je hebt Memcached geselecteerd als je cache, maar je hebt geen servers opgegeven.",
+ "config-memcache-badip": "Je hebt een ongeldig IP-adres ingevoerd voor Memcached: $1.",
+ "config-memcache-noport": "Je hebt geen poort opgegeven voor de Memcachedserver: $1.\nDe standaardpoort is 11211.",
+ "config-extensions-help": "De bovenstaande uitbreidingen zijn aangetroffen in de map <code>./extensions</code>.\n\nMogelijk moet je aanvullende instellingen maken, maar je kunt deze uitbreidingen nu inschakelen.",
+ "config-install-alreadydone": "'''Waarschuwing:''' het lijkt alsof je MediaWiki al hebt geïnstalleerd en probeert het programma opnieuw te installeren.\nGa door naar de volgende pagina.",
+ "config-install-begin": "Als je nu op \"{{int:config-continue}}\" klikt, begint de installatie van MediaWiki.\nAls je nog wijzigingen wilt maken, klik dan op \"Terug\".",
+ "config-pg-no-plpgsql": "Je moet de taal PL/pgSQL installeren in de database $1",
+ "config-pg-no-create-privs": "De gebruiker die je hebt opgegeven door de installatie heeft niet voldoende rechten om een gebruiker aan te maken.",
+ "config-pg-not-in-role": "De gebruiker die je hebt opgegeven voor de webgebruiker bestaat al.\nDe gebruiker die je hebt opgegeven voor installatie is geen superuser en geen lid van de rol van de webgebruiker, en kan het dus geen objecten aanmaken die van de webgebruiker zijn.\n\nMediaWiki vereist momenteel dat de tabellen van de webgebruiker zijn. Geef een andere webgebruikersnaam op, of klik op \"terug\" en geef een gebruiker op die voldoende installatierechten heeft.",
+ "config-install-user-missing-create": "De opgegeven gebruiker \"$1\" bestaat niet.\nKlik op \"registreren\" onderaan als je de gebruiker wilt aanmaken.",
+ "config-install-done": "'''Gefeliciteerd!'''\nJe hebt MediaWiki met geïnstalleerd.\n\nHet installatieprogramma heeft het bestand <code>LocalSettings.php</code> aangemaakt.\nDit bevat al je instellingen.\n\nJe moet het bestand downloaden en in de hoofdmap van uw wikiinstallatie plaatsten; in dezelfde map als index.php.\nDe download moet je automatisch zijn aangeboden.\n\nAls de download niet is aangeboden of als je de download hebt geannuleerd, dan kan je de download opnieuw starten door op de onderstaande koppeling te klikken:\n\n$3\n\n'''Let op''': als je dit niet nu doet, dan het is bestand als u later de installatieprocedure afsluit zonder het bestand te downloaden niet meer beschikbaar.\n\nNa het plaatsen van het bestand met instellingen kan je '''[$2 je wiki betreden]'''.",
+ "mainpagedocfooter": "Raadpleeg de [https://meta.wikimedia.org/wiki/Help:Contents Inhoudsopgave handleiding] voor informatie over het gebruik van de wikisoftware.\n\n== Meer hulp over MediaWiki ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lijst met instellingen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Veelgestelde vragen (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailinglijst voor aankondigingen van nieuwe versies]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Maak MediaWiki beschikbaar in jouw taal]"
+}
diff --git a/www/wiki/includes/installer/i18n/nl.json b/www/wiki/includes/installer/i18n/nl.json
new file mode 100644
index 00000000..6185ef4c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nl.json
@@ -0,0 +1,338 @@
+{
+ "@metadata": {
+ "authors": [
+ "Catrope",
+ "McDutchie",
+ "Purodha",
+ "SPQRobin",
+ "Siebrand",
+ "Tjcool007",
+ "아라",
+ "Arent",
+ "JurgenNL",
+ "Southparkfan",
+ "Seb35",
+ "Mar(c)",
+ "Sjoerddebruin",
+ "Esketti",
+ "JaapDeKleine",
+ "Macofe",
+ "Hex",
+ "Mainframe98",
+ "Rcdeboer",
+ "Festina90",
+ "MarcoSwart"
+ ]
+ },
+ "config-desc": "Het installatieprogramma voor MediaWiki",
+ "config-title": "Installatie van MediaWiki $1",
+ "config-information": "Gegevens",
+ "config-localsettings-upgrade": "Er is een bestaand instellingenbestand <code>LocalSettings.php</code> gevonden.\nVoer de waarde van <code>$wgUpgradeKey</code> in in onderstaande invoerveld om deze installatie bij te werken.\nDe instelling is terug te vinden in <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Het bestand <code>LocalSettings.php</code> is al aanwezig.\nVoer <code>update.php</code> uit om deze installatie bij te werken.",
+ "config-localsettings-key": "Upgradesleutel:",
+ "config-localsettings-badkey": "De upgradesleutel die u hebt opgegeven is onjuist.",
+ "config-upgrade-key-missing": "Er is een bestaande installatie van MediaWiki aangetroffen.\nPlaats de volgende regel onderaan uw <code>LocalSettings.php</code> om deze installatie bij te werken:\n\n$1",
+ "config-localsettings-incomplete": "De bestaande inhoud van <code>LocalSettings.php</code> lijkt incompleet.\nDe variabele $1 is niet ingesteld.\nWijzig <code>LocalSettings.php</code> zodat deze variabele is ingesteld en klik op \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Er is een fout opgetreden tijdens het verbinden met de database met de instellingen uit <code>LocalSettings.php</code>. Los het probleem met de instellingen op en probeer het daarna opnieuw.\n\n$1",
+ "config-session-error": "Fout bij het starten van sessie: $1",
+ "config-session-expired": "Uw sessiegegevens zijn verlopen.\nSessies zijn ingesteld om een levensduur van $1 te hebben.\nU kunt deze wijzigen via de instelling <code>session.gc_maxlifetime</code> in php.ini.\nBegin het installatieproces opnieuw.",
+ "config-no-session": "Uw sessiegegevens zijn verloren gegaan.\nControleer uw php.ini en zorg dat er een juiste map is ingesteld voor <code>session.save_path</code>.",
+ "config-your-language": "Uw taal:",
+ "config-your-language-help": "Selecteer een taal om tijdens het installatieproces te gebruiken.",
+ "config-wiki-language": "Wikitaal:",
+ "config-wiki-language-help": "Selecteer de taal waar de wiki voornamelijk in wordt geschreven.",
+ "config-back": "← Terug",
+ "config-continue": "Doorgaan →",
+ "config-page-language": "Taal",
+ "config-page-welcome": "Welkom bij MediaWiki!",
+ "config-page-dbconnect": "Verbinding maken met database",
+ "config-page-upgrade": "Bestaande installatie bijwerken",
+ "config-page-dbsettings": "Databaseinstellingen",
+ "config-page-name": "Naam",
+ "config-page-options": "Opties",
+ "config-page-install": "Installeren",
+ "config-page-complete": "Voltooid!",
+ "config-page-restart": "Installatie herstarten",
+ "config-page-readme": "Lees mij",
+ "config-page-releasenotes": "Release notes",
+ "config-page-copying": "Kopiëren",
+ "config-page-upgradedoc": "Bijwerken",
+ "config-page-existingwiki": "Bestaande wiki",
+ "config-help-restart": "Wilt u alle opgeslagen gegevens die u hebt ingevoerd wissen en het installatieproces opnieuw starten?",
+ "config-restart": "Ja, opnieuw starten",
+ "config-welcome": "=== Controle omgeving ===\nEr worden een aantal basiscontroles uitgevoerd met als doel vast te stellen of deze omgeving geschikt is voor een installatie van MediaWiki.\nLever deze gegevens aan als u ondersteuning vraagt bij de installatie.",
+ "config-copyright": "=== Auteursrechten en voorwaarden ===\n\n$1\n\nDit programma is vrije software. U mag het verder verspreiden en/of aanpassen in overeenstemming met de voorwaarden van de GNU General Public License zoals uitgegeven door de Free Software Foundation; ofwel versie 2 van de Licentie of - naar uw keuze - enige latere versie.\n\nDit programma wordt verspreid in de hoop dat het nuttig is, maar '''zonder enige garantie''', zelfs zonder de impliciete garantie van '''verkoopbaarheid''' of '''geschiktheid voor een bepaald doel'''.\nZie de GNU General Public License voor meer informatie.\n\nSamen met dit programma hoort u een <doclink href=Copying>exemplaar van de GNU General Public License</doclink> ontvangen te hebben; zo niet, schrijf dan aan de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, Verenigde Staten. Of [http://www.gnu.org/copyleft/gpl.html lees de licentie online].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki-thuispagina]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Gebruikershandleiding] (Engelstalig)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Beheerdershandleiding] (Engelstalig)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Veelgestelde vragen] (Engelstalig)\n----\n* <doclink href=Readme>Leesmij</doclink> (Engelstalig)\n* <doclink href=ReleaseNotes>Release notes</doclink> (Engelstalig)\n* <doclink href=Copying>Kopiëren</doclink> (Engelstalig)\n* <doclink href=UpgradeDoc>Versie bijwerken</doclink> (Engelstalig)",
+ "config-env-good": "De omgeving is gecontroleerd.\nU kunt MediaWiki installeren.",
+ "config-env-bad": "De omgeving is gecontroleerd.\nU kunt MediaWiki niet installeren.",
+ "config-env-php": "PHP $1 is geïnstalleerd.",
+ "config-env-hhvm": "HHVM $1 is geïnstalleerd.",
+ "config-unicode-using-intl": "Voor Unicode-normalisatie wordt de [http://pecl.php.net/intl PECL-extensie intl] gebruikt.",
+ "config-unicode-pure-php-warning": "<strong>Waarschuwing:</strong> de [http://pecl.php.net/intl PECL-extensie intl] is niet beschikbaar om de Unicodenormalisatie af te handelen en daarom wordt de langzamere PHP-implementatie gebruikt.\nAls u MediaWiki voor een website met veel verkeer installeert, lees u dan in over [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicodenormalisatie].",
+ "config-unicode-update-warning": "<strong>Waarschuwing:</strong> de geïnstalleerde versie van de Unicodenormalisatiewrapper maakt gebruik van een oudere versie van [http://site.icu-project.org/ de bibliotheek van het ICU-project].\nU moet [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations bijwerken] als Unicode voor u van belang is.",
+ "config-no-db": "Het was niet mogelijk een geschikte databasedriver te vinden voor PHP! U moet een databasedriver installeren voor PHP.\n{{PLURAL:$2|Het volgende databasetype wordt|De volgende databasetypes worden}} ondersteund: $1.\n\nAls u PHP zelf hebt gecompileerd, wijzig dan uw instellingen zodat een databasedriver wordt geactiveerd, bijvoorbeeld via <code>./configure --with-mysqli</code>.\nAls u PHP hebt geïnstalleerd via een Debian- of Ubuntu-package, installeer dan ook bijvoorbeeld de module <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "''' Waarschuwing:''' u gebruikt SQLite $1. SQLite is niet beschikbaar omdat de minimaal vereiste versie $2 is.",
+ "config-no-fts3": "<strong>Waarschuwing:</strong> SQLite is gecompileerd zonder de module [//sqlite.org/fts3.html FTS3]; zoekfuncties zijn niet beschikbaar.",
+ "config-pcre-old": "'''Onherstelbare fout:''' PCRE $1 of een latere versie is vereist.\nUw uitvoerbare versie van PHP is gekoppeld met PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Meer informatie].",
+ "config-pcre-no-utf8": "<strong>Onherstelbare fout:</strong> de module PRCE van PHP lijkt te zijn gecompileerd zonder ondersteuning voor PCRE_UTF8.\nMediaWiki heeft ondersteuning voor UTF-8 nodig om correct te kunnen werken.",
+ "config-memory-raised": "PHP's <code>memory_limit</code> is $1 en is verhoogd tot $2.",
+ "config-memory-bad": "'''Waarschuwing:''' PHP's <code>memory_limit</code> is $1.\nDit is waarschijnlijk te laag.\nDe installatie kan mislukken!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] is op dit moment geïnstalleerd",
+ "config-apc": "[http://www.php.net/apc APC] is op dit moment geïnstalleerd",
+ "config-apcu": "[http://www.php.net/apcu APCu] is geïnstalleerd",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] is op dit moment geïnstalleerd",
+ "config-no-cache-apcu": "<strong>Waarschuwing:</strong> [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] of [http://www.iis.net/download/WinCacheForPhp WinCache] is niet aangetroffen.\nHet cachen van objecten is niet ingeschakeld.",
+ "config-mod-security": "<strong>Waarschuwing:</strong> Uw webserver heeft de module [http://modsecurity.org/ mod_security]/mod_security2 ingeschakeld. Veel standaard instellingen hiervan zorgen voor problemen in combinatie met MediaWiki en andere software die gebruikers in staat stelt willekeurige inhoud te posten.\nIndien mogelijk, zou deze moeten worden uitgeschakeld. Lees anders de [http://modsecurity.org/documentation/ documentatie over mod_security] of neem contact op met de helpdesk van uw provider als u tegen problemen aanloopt.",
+ "config-diff3-bad": "GNU diff3 niet aangetroffen.",
+ "config-git": "Versiecontrolesoftware git is aangetroffen: <code>$1</code>",
+ "config-git-bad": "Geen git versiecontrolesoftware aangetroffen.",
+ "config-imagemagick": "ImageMagick aangetroffen: <code>$1</code>.\nHet aanmaken van miniaturen van afbeeldingen wordt ingeschakeld als u uploaden inschakelt.",
+ "config-gd": "Ingebouwde GD grafische bibliotheek aangetroffen.\nHet aanmaken van miniaturen van afbeeldingen wordt ingeschakeld als u uploaden inschakelt.",
+ "config-no-scaling": "Noch de GD-bibliotheek noch ImageMagick zijn aangetroffen.\nHet maken van miniaturen van afbeeldingen wordt uitgeschakeld.",
+ "config-no-uri": "'''Fout:''' de huidige URI kon niet vastgesteld worden.\nDe installatie is afgebroken.",
+ "config-no-cli-uri": "<strong>Waarschuwing:</strong> de parameter <code>--scriptpath</code> is niet opgegeven. De standaardwaarde wordt gebruikt: <code>$1</code>.",
+ "config-using-server": "De servernaam \"<nowiki>$1</nowiki>\" wordt gebruikt.",
+ "config-using-uri": "De server-URL \"<nowiki>$1$2</nowiki>\" wordt gebruikt.",
+ "config-uploads-not-safe": "<strong>Waarschuwing:</strong> uw uploadmap <code>$1</code> kan gebruikt worden voor het arbitrair uitvoeren van scripts.\nHoewel MediaWiki alle toegevoegde bestanden controleert op bedreigingen, is het zeer aan te bevelen het [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security beveiligingslek te verhelpen] alvorens uploads in te schakelen.",
+ "config-no-cli-uploads-check": "''Waarschuwing:'' uw standaardmap voor uploads (<code>$1</code>) wordt niet gecontroleerd op kwetsbaarheden voor het uitvoeren van willekeurige scripts gedurende de CLI-installatie.",
+ "config-brokenlibxml": "Uw systeem heeft een combinatie van PHP- en libxml2-versies geïnstalleerd die is foutgevoelig is en kan leiden tot onzichtbare beschadiging van gegevens in MediaWiki en andere webapplicaties.\nUpgrade naar libxml2 2.7.3 of hoger([https://bugs.php.net/bug.php?id=45996 bij PHP gerapporteerde fout]).\nDe installatie wordt afgebroken.",
+ "config-suhosin-max-value-length": "Suhosin is geïnstalleerd en beperkt de GET-parameter <code>length</code> tot $1 bytes.\nDe ResourceLoader van MediaWiki omzeilt deze beperking, maar dat is slecht voor de prestaties.\nAls het mogelijk is, moet u de waarde \"<code>suhosin.get.max_value_length</code>\" in <code>php.ini</code> instellen op 1024 of hoger en <code>$wgResourceLoaderMaxQueryLength</code> in LocalSettings.php op dezelfde waarde instellen.",
+ "config-using-32bit": "<strong>Pas op:</strong> uw systeem lijkt met 32-bit integers te werken. Dit is [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit anders dan aangeraden].",
+ "config-db-type": "Databasetype:",
+ "config-db-host": "Databasehost:",
+ "config-db-host-help": "Als uw databaseserver een andere server is, voer dan de hostnaam of het IP-adres hier in.\n\nAls u gebruik maakt van gedeelde webhosting, hoort uw provider u de juiste hostnaam te hebben verstrekt.\n\nAls u MediaWiki op een Windowsserver installeert en MySQL gebruikt, dan werkt \"localhost\" mogelijk niet als servernaam.\nAls het inderdaad niet werkt, probeer dan \"127.0.0.1\" te gebruiken als lokaal IP-adres.\n\nAls u PostgreSQL gebruikt, laat dit veld dan leeg om via een Unix-socket te verbinden.",
+ "config-db-host-oracle": "Database-TNS:",
+ "config-db-host-oracle-help": "Voer een geldige [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name] in; een tnsnames.ora-bestand moet zichtbaar zijn voor deze installatie.<br />Als u gebruik maakt van clientlibraries 10g of een latere versie, kunt u ook gebruik maken van de naamgevingsmethode [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identificeer deze wiki",
+ "config-db-name": "Databasenaam:",
+ "config-db-name-help": "Kies een naam die uw wiki identificeert.\nEr mogen geen spaties gebruikt worden.\nAls u gebruik maakt van gedeelde webhosting, dan hoort uw provider ofwel u een te gebruiken databasenaam gegeven te hebben, of u aangegeven te hebben hoe u databases kunt aanmaken.",
+ "config-db-name-oracle": "Databaseschema:",
+ "config-db-account-oracle-warn": "Er zijn drie ondersteunde scenario's voor het installeren van Oracle als databasebackend:\n\nAls u een databasegebruiker wilt aanmaken als onderdeel van het installatieproces, geef dan de gegevens op van een databasegebruiker in met de rol SYSDBA voor de installatie en voer de gewenste aanmeldgegevens in voor de gebruiker met webtoegang. U kunt ook de gebruiker met webtoegang handmatig aanmaken en alleen van die gebruiker de aanmeldgegevens opgeven als deze de vereiste rechten heeft om schemaobjecten aan te maken. Als laatste is het mogelijk om aanmeldgegevens van twee verschillende gebruikers op te geven; een met de rechten om schemaobjecten aan te maken, en een met alleen webtoegang.\n\nEen script voor het aanmaken van een gebruiker met de vereiste rechten is te vinden in de map \"maintenance/oracle/\" van deze installatie. Onthoud dat het gebruiken van een gebruiker met beperkte rechten alle mogelijkheden om beheerscripts uit te voeren met de standaard gebruiker onmogelijk maakt.",
+ "config-db-install-account": "Gebruiker voor installatie",
+ "config-db-username": "Gebruikersnaam voor database:",
+ "config-db-password": "Wachtwoord voor database:",
+ "config-db-install-username": "Voer de gebruikersnaam in die gebruikt moet worden om te verbinden met de database tijdens het installatieproces. Dit is niet de gebruikersnaam van de MediaWikigebruiker. Dit is de gebruikersnaam voor de database.",
+ "config-db-install-password": "Voer het wachtwoord in dat gebruikt moet worden om te verbinden met de database tijdens het installatieproces. Dit is niet het wachtwoord van de MediaWikigebruiker. Dit is het wachtwoord voor de database.",
+ "config-db-install-help": "Voer de gebruikersnaam en het wachtwoord in die worden gebruikt voor de databaseverbinding tijdens het installatieproces.",
+ "config-db-account-lock": "Dezelfde gebruiker en wachwoord gebruiken na de installatie",
+ "config-db-wiki-account": "Gebruikersaccount voor na de installatie",
+ "config-db-wiki-help": "Voer de gebruikersnaam en het wachtwoord in die gebruikt worden om verbinding te maken met de database na de installatie.\nAls de gebruiker niet bestaat en de gebruiker die tijdens de installatie gebruikt wordt voldoende rechten heeft, wordt deze gebruiker aangemaakt met de minimaal benodigde rechten voor het laten werken van de wiki.",
+ "config-db-prefix": "Databasetabelvoorvoegsel:",
+ "config-db-prefix-help": "Als u een database moet gebruiken voor meerdere wiki's, of voor MediaWiki en een andere toepassing, dan kunt u ervoor kiezen om een voorvoegsel toe te voegen aan de tabelnamen om conflicten te voorkomen.\nGebruik geen spaties.\n\nDit veld wordt meestal leeg gelaten.",
+ "config-mysql-old": "U moet MySQL $1 of later gebruiken.\nU gebruikt $2.",
+ "config-db-port": "Databasepoort:",
+ "config-db-schema": "Schema voor MediaWiki",
+ "config-db-schema-help": "Dit schema klopt meestal.\nWijzig het alleen als u weet dat dit nodig is.",
+ "config-pg-test-error": "Kan geen verbinding maken met database '''$1''': $2",
+ "config-sqlite-dir": "Gegevensmap voor SQLite:",
+ "config-sqlite-dir-help": "SQLite slaat alle gegevens op in een enkel bestand.\n\nDe map die u opgeeft moet beschrijfbaar zijn voor de webserver tijdens de installatie.\n\nDeze mag '''niet toegankelijk''' zijn via het web en het bestand mag dus niet tussen de PHP-bestanden staan.\n\nHet installatieprogramma schrijft het bestand <code>.htaccess</code> weg met het databasebestand, maar als dat niet werkt kan iemand zich toegang tot het ruwe databasebestand verschaffen.\nOok de gebruikersgegevens (e-mailadressen, wachtwoordhashes) en verwijderde versies en overige gegevens met beperkte toegang via MediaWiki zijn dan onbeschermd.\n\nOverweeg om de database op een totaal andere plaats neer te zetten, bijvoorbeeld in <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Standaard tablespace:",
+ "config-oracle-temp-ts": "Tijdelijke tablespace:",
+ "config-type-mysql": "MySQL (of compatibel)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki ondersteunt de volgende databasesystemen:\n\n$1\n\nAls u het databasesysteem dat u wilt gebruiken niet in de lijst terugvindt, volg dan de handleiding waarnaar hierboven wordt verwezen om ondersteuning toe te voegen.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] is de primaire database voor MediaWiki en wordt het best ondersteund. MediaWiki werkt ook met [{{int:version-db-mariadb-url}} MariaDB] en [{{int:version-db-percona-url}} Percona Server], die MySQL compatibel zijn ([http://www.php.net/manual/en/mysqli.installation.php hoe PHP te compileren met MySQL-ondersteuning]).",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] is een populair open source databasesysteem als alternatief voor MySQL.([http://www.php.net/manual/en/pgsql.installation.php Hoe u PHP kunt compileren met ondersteuning voor PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] is een zeer goed ondersteund lichtgewicht databasesysteem ([http://www.php.net/manual/en/pdo.installation.php hoe PHP gecompileerd moet zijn met ondersteuning voor SQLite]; gebruikt PDO).",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] is een commerciële database voor grote bedrijven ([http://www.php.net/manual/en/oci8.installation.php PHP compileren met ondersteuning voor OCI8]).",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] is een commerciële enterprisedatabase voor Windows ([http://www.php.net/manual/en/sqlsrv.installation.php PHP compileren met ondersteuning voor SQLSRV]).",
+ "config-header-mysql": "MySQL-instellingen",
+ "config-header-postgres": "PostgreSQL-instellingen",
+ "config-header-sqlite": "SQLite-instellingen",
+ "config-header-oracle": "Oracle-instellingen",
+ "config-header-mssql": "Instellingen voor Microsoft SQL Server",
+ "config-invalid-db-type": "Ongeldig databasetype.",
+ "config-missing-db-name": "U moet een waarde opgeven voor \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "U moet een waarde invoeren voor \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "U moet een waarde opgeven voor \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "Ongeldige database-TNS \"$1\".\nGebruik \"TNS Names\" of een \"Easy Connect\" tekst ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle naamgevingsmethoden])",
+ "config-invalid-db-name": "Ongeldige databasenaam \"$1\".\nGebruik alleen letters (a-z, A-Z), cijfers (0-9) en liggende streepjes (_) en streepjes (-).",
+ "config-invalid-db-prefix": "Ongeldig databasevoorvoegsel \"$1\".\nGebruik alleen letters (a-z, A-Z), cijfers (0-9) en liggende streepjes (_) en streepjes (-).",
+ "config-connection-error": "$1.\n\nControleer de host, gebruikersnaam en wachtwoord en probeer het opnieuw.",
+ "config-invalid-schema": "Ongeldig schema voor MediaWiki \"$1\".\nGebruik alleen letters (a-z, A-Z), cijfers (0-9) en liggende streepjes (_).",
+ "config-db-sys-create-oracle": "Het installatieprogramma biedt alleen de mogelijkheid een nieuwe gebruiker aan te maken met de SYSDBA-gebruiker.",
+ "config-db-sys-user-exists-oracle": "De gebruiker \"$1\" bestaat al. SYSDBA kan alleen gebruikt worden voor het aanmaken van een nieuwe gebruiker!",
+ "config-postgres-old": "PostgreSQL $1 of hoger is vereist.\nU gebruikt $2.",
+ "config-mssql-old": "Microsoft SQL Server $1 of hoger is vereist. U hebt $2.",
+ "config-sqlite-name-help": "Kies een naam die uw wiki identificeert.\nGebruik geen spaties of koppeltekens.\nDeze naam wordt gebruikt voor het gegevensbestand van SQLite.",
+ "config-sqlite-parent-unwritable-group": "Het was niet mogelijk de gegevensmap <code><nowiki>$1</nowiki></code> te maken omdat in de bovenliggende map <code><nowiki>$2</nowiki></code> niet geschreven mag worden door de webserver.\n\nHet installatieprogramma heeft vast kunnen stellen onder welke gebruiker de webserver draait.\nMaak de map <code><nowiki>$3</nowiki></code> beschrijfbaar om door te kunnen gaan.\nVoer op een Linux-systeem de volgende opdrachten uit:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Het was niet mogelijk de gegevensmap <code><nowiki>$1</nowiki></code> te maken omdat in de bovenliggende map <code><nowiki>$2</nowiki></code> niet geschreven mag worden door de webserver.\n\nHet installatieprogramma heeft niet vast kunnen stellen onder welke gebruiker de webserver draait.\nMaak de map <code><nowiki>$3</nowiki></code> beschrijfbaar voor de webserver (en anderen!) om door te kunnen gaan.\nVoer op een Linux-systeem de volgende opdrachten uit:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Er is een fout opgetreden tijdens het aanmaken van de gegevensmap \"$1\".\nControleer de locatie en probeer het opnieuw.",
+ "config-sqlite-dir-unwritable": "Het was niet mogelijk in de map \"$1\" te schrijven.\nWijzig de rechten zodat de webserver erin kan schrijven en probeer het opnieuw.",
+ "config-sqlite-connection-error": "$1.\n\nControleer de map voor gegevens en de databasenaam hieronder en probeer het opnieuw.",
+ "config-sqlite-readonly": "Er kan niet naar bestand <code>$1</code> worden geschreven.",
+ "config-sqlite-cant-create-db": "Het was niet mogelijk het databasebestand <code>$1</code> aan te maken.",
+ "config-sqlite-fts3-downgrade": "PHP heeft geen ondersteuning voor FTS3.\nDe tabellen worden gedowngrade.",
+ "config-can-upgrade": "Er staan al tabellen voor MediaWiki in deze database.\nKlik op '''Doorgaan''' om ze bij te werken naar MediaWiki $1.",
+ "config-upgrade-done": "Het bijwerken is afgerond.\n\nUw kunt [$1 uw wiki nu gebruiken].\n\nAls u uw <code>LocalSettings.php</code> opnieuw wilt aanmaken, klik dan op de knop hieronder.\nDit is '''niet aan te raden''' tenzij u problemen hebt met uw wiki.",
+ "config-upgrade-done-no-regenerate": "Het bijwerken is afgerond.\n\nU kunt nu [$1 uw wiki gebruiken].",
+ "config-regenerate": "LocalSettings.php opnieuw aanmaken →",
+ "config-show-table-status": "Het uitvoeren van <code>SHOW TABLE STATUS</code> is mislukt!",
+ "config-unknown-collation": "'''Waarschuwing:''' de database gebruikt een collatie die niet wordt herkend.",
+ "config-db-web-account": "Databaseaccount voor webtoegang",
+ "config-db-web-help": "Selecteer de gebruikersnaam en het wachtwoord die de webserver gebruikt om verbinding te maken met de databaseserver na de installatie.",
+ "config-db-web-account-same": "Hetzelfde account gebruiken als voor de installatie",
+ "config-db-web-create": "Maak de gebruiker aan als deze nog niet bestaat",
+ "config-db-web-no-create-privs": "De gebruiker die u hebt opgegeven voor de installatie heeft niet voldoende rechten om een gebruiker aan te maken.\nDe gebruiker die u hier opgeeft moet al bestaan.",
+ "config-mysql-engine": "Opslagmethode:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Waarschuwing''': u hebt MyISAM geselecteerd als opslagengine voor MySQL. Dit is niet aan te raden voor MediaWiki omdat:\n* het nauwelijks ondersteuning biedt voor gebruik door meerdere gebruikers tegelijkertijd door het locken van tabellen;\n* het meer vatbaar is voor corruptie dan andere engines;\n* de code van MediaWiki niet alstijd omgaat met MyISAM zoals dat zou moeten.\n\nAls uw installatie van MySQL InnoDB ondersteunt, gebruik dat dan vooral.\nAls uw installatie van MySQL geen ondersteuning heeft voor InnoDB, denk dan na over upgraden.",
+ "config-mysql-only-myisam-dep": "'''Waarschuwing:''' MyISAM is enige beschikbare opslagmethode voor MySQL in deze omgeving, en deze wordt niet aangeraden voor gebruik met MediaWiki, omdat:\n* er nauwelijks ondersteuning is voor meerdere gelijktijdige transacties omdat tabellen op slot gezet worden;\n* tabellen makkelijker stuk kunnen gaan;\n* de code van MediaWiki niet altijd op de juiste wijze omgaat met MyISAM.\n\nUw installatie van MySQL heeft geen ondersteuning voor InnoDB. We raden u aan om een meer recente versie te gebruiken.",
+ "config-mysql-engine-help": "'''InnoDB''' is vrijwel altijd de beste instelling, omdat deze goed omgaat met meerdere verzoeken tegelijkertijd.\n\n'''MyISAM''' is bij een zeer beperkt aantal gebruikers mogelijk sneller, of als de wiki alleen-lezen is.\nMyISAM-databases raken vaker beschadigd dan InnoDB-databases.",
+ "config-mysql-charset": "Tekenset voor de database:",
+ "config-mysql-binary": "Binair",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "In <strong>binaire modus</strong> slaat MediaWiki tekst in UTF-8 op in binaire databasevelden.\nDit is efficiënter dan de UTF-8-modus van MySQL en stelt u in staat de volledige reeks Unicodetekens te gebruiken.\n\nIn <strong>UTF-8-modus</strong> kent MySQL de tekenset van uw gegevens en kan de databaseserver ze juist weergeven en converteren.\nHet is dan niet mogelijk tekens op te slaan die de \"[https://nl.wikipedia.org/wiki/Lijst_van_Unicode-subbereiken#Basic_Multilingual_Plane Basic Multilingual Plane]\" te boven gaan.",
+ "config-mssql-auth": "Authenticatietype:",
+ "config-mssql-install-auth": "Selecteer de authenticatiemethode die wordt gebruikt om met de database te verbinden tijdens het installatieproces.\nAls u \"{{int:config-mssql-windowsauth}}\" selecteert, dan worden de aanmeldgegevens van de gebruiker waaronder de webserver draait voor authenticatie gebruikt.",
+ "config-mssql-web-auth": "Selecteer de authenticatiemethode die de webserver gebruikt om met de database te verbinden tijdens het installatieproces.\nAls u \"{{int:config-mssql-windowsauth}}\" selecteert, dan worden de aanmeldgegevens van de gebruiker waaronder de webserver draait voor authenticatie gebruikt.",
+ "config-mssql-sqlauth": "SQL Server Authenticatie",
+ "config-mssql-windowsauth": "Windowsauthenticatie",
+ "config-site-name": "Naam van de wiki:",
+ "config-site-name-help": "Deze naam verschijnt in de titelbalk van browsers en op andere plaatsen.",
+ "config-site-name-blank": "Geef een naam op voor de site.",
+ "config-project-namespace": "Projectnaamruimte:",
+ "config-ns-generic": "Project",
+ "config-ns-site-name": "Zelfde als de wiki: $1",
+ "config-ns-other": "Andere (geef aan welke)",
+ "config-ns-other-default": "MijnWiki",
+ "config-project-namespace-help": "In het kielzog van Wikipedia beheren veel wiki's hun beleidspagina's apart van hun inhoudelijke pagina's in een '''projectnaamruimte'''.\nAlle paginanamen in deze naamruimte beginnen met een bepaald voorvoegsel dat u hier kunt opgeven.\nDit voorvoegsel wordt meestal afgeleid van de naam van de wiki, maar het kan geen bijzondere tekens bevatten als \"#\" of \":\".",
+ "config-ns-invalid": "De opgegeven naamruimte \"<nowiki>$1</nowiki>\" is ongeldig.\nGeef een andere naamruimte op.",
+ "config-ns-conflict": "De opgegeven naamruimte \"<nowiki>$1</nowiki>\" conflicteert met een standaard naamruimte in MediaWiki.\nGeef een andere naam op voor de projectnaamruimte.",
+ "config-admin-box": "Beheerdersgebruiker",
+ "config-admin-name": "Uw gebruikersnaam:",
+ "config-admin-password": "Wachtwoord:",
+ "config-admin-password-confirm": "Wachtwoord opnieuw:",
+ "config-admin-help": "Voer de gebruikersnaam hier in, bijvoorbeeld \"Jan Jansen\".\nDit is de naam die wordt gebruikt om aan de melden bij de wiki.",
+ "config-admin-name-blank": "Geef een gebruikersnaam op voor de beheerder.",
+ "config-admin-name-invalid": "De opgegeven gebruikersnaam \"<nowiki>$1</nowiki>\" is ongeldig.\nKies een andere gebruikersnaam.",
+ "config-admin-password-blank": "Voer een wachtwoord voor de beheerder in.",
+ "config-admin-password-mismatch": "De twee door u ingevoerde wachtwoorden komen niet overeen.",
+ "config-admin-email": "E-mailadres:",
+ "config-admin-email-help": "Voer hier een e-mailadres in om e-mail te kunnen ontvangen van andere gebruikers op de wiki, uw wachtwoord opnieuw in te kunnen stellen en op de hoogte te worden gehouden van wijzigingen van pagina's op uw volglijst. U kunt het veld leeg laten.",
+ "config-admin-error-user": "Interne fout bij het aanmaken van een beheerder met de naam \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Interne fout bij het instellen van een wachtwoord voor de beheerder \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "U hebt een ongeldig e-mailadres opgegeven.",
+ "config-subscribe": "Abonneren op de [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce mailinglijst releaseaankondigen].",
+ "config-subscribe-help": "Dit is een mailinglijst met een laag volume voor aankondigingen van nieuwe versies, inclusief belangrijke aankondigingen met betrekking tot beveiliging.\nAbonneer uzelf erop en werk uw MediaWiki-installatie bij als er nieuwe versies uitkomen.",
+ "config-subscribe-noemail": "U hebt geprobeerd zich te abonneren op de mailinglijst voor release-aankondigingen zonder een e-mailadres op te geven.\nGeef een e-mailadres op als u zich wilt abonneren op de mailinglijst.",
+ "config-pingback": "Gegevens over deze installatie delen met MediaWiki-ontwikkelaars.",
+ "config-pingback-help": "Wanneer deze optie is geselecteerd, zal MediaWiki periodiek https://www.mediawiki.org pingen met informatie over deze MediaWiki instantie. Deze gegevens bevatten bijvoorbeeld het type systeem, de PHP versie, en de gekozen database. De WikiMedia Foundation deelt deze informatie met MediaWiki ontwikkelaars om toekomstige ontwikkeling te ondersteunen. De volgende gegevens zullen worden verstuurd voor uw systeem: \n<pre>$1</pre>",
+ "config-almost-done": "U bent bijna klaar!\nAls u wilt kunt u de overige instellingen overslaan en de wiki nu installeren.",
+ "config-optional-continue": "Stel me meer vragen.",
+ "config-optional-skip": "Laat maar zitten, installeer gewoon de wiki.",
+ "config-profile": "Gebruikersrechtenprofiel:",
+ "config-profile-wiki": "Open wiki",
+ "config-profile-no-anon": "Account aanmaken verplicht",
+ "config-profile-fishbowl": "Alleen voor geautoriseerde bewerkers",
+ "config-profile-private": "Privéwiki",
+ "config-profile-help": "Wiki's werken het beste als ze door zoveel mogelijk gebruikers worden bewerkt.\nIn MediaWiki is het eenvoudig om de recente wijzigingen te controleren en eventuele foutieve of kwaadwillende bewerkingen terug te draaien.\n\nDaarnaast vinden velen MediaWiki goed inzetbaar in vele andere rollen, en soms is het niet handig om helemaal \"op de wikimanier\" te werken.\nDaarom biedt dit installatieprogramma u de volgende keuzes voor de basisinstelling van gebruikersvrijheden:\n\nHet profiel '''{{int:config-profile-wiki}}''' staat iedereen toe te bewerken, zonder zelfs aan te melden.\nEen wiki met '''{{int:config-profile-no-anon}}''' biedt extra verantwoordelijkheid, maar kan toevallige gebruikers afschrikken.\n\nHet scenario '''{{int:config-profile-fishbowl}}''' laat gebruikers waarvoor dat is ingesteld bewerken, maar andere gebruikers kunnen alleen pagina's bekijken, inclusief de bewerkingsgeschiedenis.\nIn een '''{{int:config-profile-private}}''' kunnen alleen goedgekeurde gebruikers pagina's bekijken en bewerken.\n\nMeer complexe instellingen voor gebruikersrechten zijn te maken na de installatie; hierover is meer te lezen in de [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights handleiding].",
+ "config-license": "Auteursrechten en licentie:",
+ "config-license-none": "Geen licentie in de voettekst",
+ "config-license-cc-by-sa": "Creative Commons Naamsvermelding-Gelijk delen",
+ "config-license-cc-by": "Creative Commons Naamsvermelding",
+ "config-license-cc-by-nc-sa": "Creative Commons Naamsvermelding-Niet Commercieel-Gelijk delen",
+ "config-license-cc-0": "Creative Commons Zero (Publiek domein)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 of hoger",
+ "config-license-pd": "Publiek domein",
+ "config-license-cc-choose": "Een Creative Commons-licentie selecteren",
+ "config-license-help": "In veel openbare wiki's zijn alle bijdragen beschikbaar onder een [http://freedomdefined.org/Definition vrije licentie].\nDit helpt bij het creëren van een gevoel van gemeenschappelijk eigendom en stimuleert bijdragen op lange termijn.\nDit is over het algemeen niet nodig is voor een particuliere of zakelijke wiki.\n\nAls u teksten uit Wikipedia wilt kunnen gebruiken en u wilt het mogelijk maken teksten uit uw wiki naar Wikipedia te kopiëren, kies dan de licentie <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nDe GNU Free Documentation License is de oude licentie voor inhoud uit Wikipedia.\nDit is nog steeds een geldige licentie, maar deze licentie is lastig te begrijpen.\nHet is ook lastig inhoud te hergebruiken onder de GFDL.",
+ "config-email-settings": "E-mailinstellingen",
+ "config-enable-email": "Uitgaande e-mail inschakelen",
+ "config-enable-email-help": "Als u wilt dat e-mailen mogelijk is, dan moeten de [http://www.php.net/manual/en/mail.configuration.php e-mailinstellingen van PHP] correct zijn.\nAls u niet wilt dat e-mailen mogelijk is, dan kunt u de instellingen hier uitschakelen.",
+ "config-email-user": "E-mail tussen gebruikers inschakelen",
+ "config-email-user-help": "Gebruikers toestaan e-mail aan elkaar te verzenden als dit in de voorkeuren is ingesteld.",
+ "config-email-usertalk": "Gebruikersoverlegmeldingen inschakelen",
+ "config-email-usertalk-help": "Gebruikers toestaan meldingen te ontvangen bij wijzigingen op de eigen overlegpagina, als dit in de voorkeuren is ingesteld.",
+ "config-email-watchlist": "Volglijstmeldingen inschakelen",
+ "config-email-watchlist-help": "Gebruikers toestaan meldingen te ontvangen bij wijzigingen van pagina's op hun volglijst, als dit in de voorkeuren is ingesteld.",
+ "config-email-auth": "E-mailbevestiging inschakelen",
+ "config-email-auth-help": "Als deze instelling actief is, moeten gebruikers hun e-mailadres bevestigen via een verwijziging die per e-mail wordt toegezonden.\nAlleen bevestigde e-mailadressen kunnen e-mail ontvangen van andere gebruikers of wijzigingsnotificaties ontvangen.\nHet inschakelen van deze instelling wordt <strong>aangeraden</strong> voor openbare wiki's vanwege de mogelijkheden voor misbruik van e-mailmogelijkheden.",
+ "config-email-sender": "E-mailadres voor antwoorden:",
+ "config-email-sender-help": "Voer het e-mailadres in dat u wilt gebruiken als antwoordadres voor uitgaande e-mail.\nAls een e-mail niet bezorgd kan worden, wordt dat op dit e-mailadres gemeld.\nVeel mailservers vereisen dat ten minste het domein bestaat.",
+ "config-upload-settings": "Afbeeldingen en bestanden uploaden",
+ "config-upload-enable": "Uploaden van bestanden inschakelen",
+ "config-upload-help": "Het toestaan van het uploaden van bestanden stelt uw server mogelijk bloot aan beveiligingsrisico's.\nEr is meer [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security informatie over beveiliging] beschikbaar in de handleiding.\n\nOm het bestandsuploads mogelijk te maken kunt u de rechten op de submap <code>images</code> onder de hoofdmap van MediaWiki aanpassen, zodat de webserver erin kan schrijven.\nDaarmee wordt deze functie ingeschakeld.",
+ "config-upload-deleted": "Map voor verwijderde bestanden:",
+ "config-upload-deleted-help": "Kies een map waarin verwijderde bestanden gearchiveerd kunnen worden.\nIdealiter is deze map niet via het web te benaderen.",
+ "config-logo": "URL voor logo:",
+ "config-logo-help": "Het standaarduiterlijk van MediaWiki bevat ruimte voor een logo van 135x160 pixels boven het menu.\nUpload een afbeelding met de juiste afmetingen en voer de URL hier in.\n\nU kunt <code>$wgStylePath</code> of <code>$wgScriptPath</code> gebruiken als uw logo relatief is aan een van deze paden.\n\nAls u geen logo wilt gebruiken, kunt u dit veld leeg laten.",
+ "config-instantcommons": "Instant Commons inschakelen",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] is functie die het mogelijk maakt om afbeeldingen, geluidsbestanden en andere mediabestanden te gebruiken van de website [https://commons.wikimedia.org/ Wikimedia Commons].\nHiervoor heeft MediaWiki toegang nodig tot internet.\n\nMeer informatie over deze functie en hoe deze in te stellen voor andere wiki's dan Wikimedia Commons is te vinden in de [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos handleiding].",
+ "config-cc-error": "De licentiekiezer van Creative Commons heeft geen resultaat opgeleverd.\nVoer de licentie handmatig in.",
+ "config-cc-again": "Opnieuw kiezen...",
+ "config-cc-not-chosen": "Kies de Creative Commonslicentie die u wilt gebruiken en klik op \"proceed\".",
+ "config-advanced-settings": "Gevorderde instellingen",
+ "config-cache-options": "Instellingen voor het cachen van objecten:",
+ "config-cache-help": "Het cachen van objecten wordt gebruikt om de snelheid van MediaWiki te verbeteren door vaak gebruikte gegevens te bewaren.\nMiddelgrote tot grote websites wordt geadviseerd dit in te schakelen en ook kleine sites merken de voordelen.",
+ "config-cache-none": "Niets cachen.\nEr gaat geen functionaliteit verloren, maar dit kan invloed hebben op de prestaties.",
+ "config-cache-accel": "Cachen van objecten via PHP (APC, APCu, XCache of WinCache)",
+ "config-cache-memcached": "Memcached gebruiken (dit vereist aanvullende instellingen)",
+ "config-memcached-servers": "Memcachedservers:",
+ "config-memcached-help": "Lijst met IP-adressen te gebruiken voor Memcached.\nEén IP-adres per regel met een poortnummer.\nBijvoorbeeld:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "U hebt Memcached geselecteerd als uw cache, maar u hebt geen servers opgegeven.",
+ "config-memcache-badip": "U hebt een ongeldig IP-adres ingevoerd voor Memcached: $1.",
+ "config-memcache-noport": "U hebt geen poort opgegeven voor de Memcachedserver: $1.\nDe standaardpoort is 11211.",
+ "config-memcache-badport": "Poortnummers voor Memcached moeten tussen $1 en $2 liggen.",
+ "config-extensions": "Uitbreidingen",
+ "config-extensions-help": "De bovenstaande uitbreidingen zijn aangetroffen in de map <code>./extensions</code>.\n\nMogelijk moet u aanvullende instellingen maken, maar u kunt deze uitbreidingen nu inschakelen.",
+ "config-skins": "Vormgevingen",
+ "config-skins-help": "De hierboven weergegeven vormgevingen zijn aangetroffen in de map <code>./skins</code>. U moet ten minste één vormgeving inschakelen en de standaard vormgeving selecteren.",
+ "config-skins-use-as-default": "Als standaard vormgeving instellen",
+ "config-skins-missing": "Er zijn geen vormgevingen aangetroffen. MediaWiki gebruikt een basisvormgeving totdat u een vormgeving installeert.",
+ "config-skins-must-enable-some": "U moet minstens één vormgeving kiezen om in te schakelen.",
+ "config-skins-must-enable-default": "De vormgeving gekozen als standaard moet ingeschakeld zijn.",
+ "config-install-alreadydone": "'''Waarschuwing:''' het lijkt alsof u MediaWiki al hebt geïnstalleerd en probeert het programma opnieuw te installeren.\nGa door naar de volgende pagina.",
+ "config-install-begin": "Als u nu op \"{{int:config-continue}}\" klikt, begint de installatie van MediaWiki.\nAls u nog wijzigingen wilt maken, klik dan op \"{{int:config-back}}\".",
+ "config-install-step-done": "afgerond",
+ "config-install-step-failed": "mislukt",
+ "config-install-extensions": "Inclusief uitbreidingen",
+ "config-install-database": "Database inrichten",
+ "config-install-schema": "Het schema wordt aangemaakt",
+ "config-install-pg-schema-not-exist": "Het schema voor PostgreSQL bestaat niet",
+ "config-install-pg-schema-failed": "Het aanmaken van de tabellen is mislukt.\nZorg dat de gebruiker \"$1\" in het schema \"$2\" mag schrijven.",
+ "config-install-pg-commit": "Wijzigingen worden doorgevoerd",
+ "config-install-pg-plpgsql": "Controle op de taal PL/pgSQL",
+ "config-pg-no-plpgsql": "U moet de taal PL/pgSQL installeren in de database $1",
+ "config-pg-no-create-privs": "De gebruiker die u hebt opgegeven door de installatie heeft niet voldoende rechten om een gebruiker aan te maken.",
+ "config-pg-not-in-role": "De gebruiker die u hebt opgegeven voor de webgebruiker bestaat al.\nDe gebruiker die u hebt opgegeven voor installatie is geen superuser en geen lid van de rol van de webgebruiker, en kan het dus geen objecten aanmaken die van de webgebruiker zijn.\n\nMediaWiki vereist momenteel dat de tabellen van de webgebruiker zijn. Geef een andere webgebruikersnaam op, of klik op \"terug\" en geef een gebruiker op die voldoende installatierechten heeft.",
+ "config-install-user": "Databasegebruiker aan het aanmaken",
+ "config-install-user-alreadyexists": "Gebruiker \"$1\" bestaat al",
+ "config-install-user-create-failed": "Het aanmaken van de gebruiker \"$1\" is mislukt: $2",
+ "config-install-user-grant-failed": "Het geven van rechten aan gebruiker \"$1\" is mislukt: $2",
+ "config-install-user-missing": "De opgegeven gebruiker \"$1\" bestaat niet.",
+ "config-install-user-missing-create": "De opgegeven gebruiker \"$1\" bestaat niet.\nKlik op \"registreren\" onderaan als u de gebruiker wilt aanmaken.",
+ "config-install-tables": "Tabellen aanmaken",
+ "config-install-tables-exist": "'''Waarschuwing''': de MediaWikitabellen lijken al te bestaan.\nHet aanmaken wordt overgeslagen.",
+ "config-install-tables-failed": "'''Fout''': het aanmaken van een tabel is mislukt met de volgende foutmelding: $1",
+ "config-install-interwiki": "Bezig met het vullen van de interwikitabel",
+ "config-install-interwiki-list": "Het bestand <code>interwiki.list</code> is niet aangetroffen",
+ "config-install-interwiki-exists": "'''Waarschuwing''': de interwikitabel heeft al inhoud.\nDe standaardlijst wordt overgeslagen.",
+ "config-install-stats": "Statistieken initialiseren",
+ "config-install-keys": "Bezig met aanmaken van geheime sleutels",
+ "config-insecure-keys": "'''Waarschuwing:''' De {{PLURAL:$2|sleutel die is aangemaakt|sleutels die zijn aangemaakt}} ($1) tijdens de installatie {{PLURAL:$2|is|zijn}} niet volledig veilig. Overweeg deze handmatig te wijzigen.",
+ "config-install-updates": "Voorkomen dat updates onnodig worden uitgevoerd",
+ "config-install-updates-failed": "<strong>Fout:</strong> het toevoegen van updatesleutels aan tabellen is mislukt met de volgende fout: $1",
+ "config-install-sysop": "Account voor beheerder aanmaken",
+ "config-install-subscribe-fail": "Het is niet mogelijk te abonneren op mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL is niet geïnstalleerd en <code>allow_url_fopen</code> is niet beschikbaar.",
+ "config-install-mainpage": "Hoofdpagina aanmaken met standaard inhoud",
+ "config-install-mainpage-exists": "Hoofdpagina bestaat al, overslaan",
+ "config-install-extension-tables": "Tabellen voor ingeschakelde uitbreidingen worden aangemaakt",
+ "config-install-mainpage-failed": "Het was niet mogelijk de hoofdpagina in te voegen: $1",
+ "config-install-done": "<strong>Gefeliciteerd!</strong>\nU hebt MediaWiki geïnstalleerd.\n\nHet installatieprogramma heeft het bestand <code>LocalSettings.php</code> aangemaakt.\nDit bevat al uw instellingen.\n\nU moet het bestand downloaden en in de hoofdmap van uw wiki-installatie plaatsen, in dezelfde map als index.php.\nDe download zou automatisch moeten zijn gestart.\n\nAls de download niet is gestart of als u de download hebt geannuleerd, dan kunt u de download opnieuw starten door op de onderstaande koppeling te klikken:\n\n$3\n\n<strong>Let op:</strong> als u dit niet nu doet, dan is het bestand als u later de installatieprocedure afsluit zonder het bestand te downloaden niet meer beschikbaar.\n\nNa het plaatsen van het bestand met instellingen kunt u <strong>[$2 uw wiki gebruiken]</strong>.",
+ "config-install-done-path": "<strong>Gefeliciteerd!</strong>\nU hebt MediaWiki geïnstalleerd.\n\nHet installatieprogramma heeft het bestand <code>LocalSettings.php</code> aangemaakt.\nDit bevat al uw instellingen.\n\nU moet het bestand downloaden en in <code>$4</code> plaatsen. De download zou automatisch moeten zijn gestart.\n\nAls de download niet is gestart of als u de download hebt geannuleerd, dan kunt u de download opnieuw starten door op de onderstaande koppeling te klikken:\n\n$3\n\n<strong>Let op:</strong> Als u dit niet nu doet, dan is het bestand als u later de installatieprocedure afsluit zonder het bestand te downloaden niet meer beschikbaar.\n\nNa het plaatsen van het bestand met instellingen kunt u <strong>[$2 uw wiki gebruiken]</strong>.",
+ "config-download-localsettings": "<code>LocalSettings.php</code> downloaden",
+ "config-help": "hulp",
+ "config-help-tooltip": "klik om uit te vouwen",
+ "config-nofile": "Het bestand \"$1\" is niet gevonden. Is het verwijderd?",
+ "config-extension-link": "Weet u dat u [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions uitbreidingen] kunt gebruiken voor uw wiki?\n\nU kunt [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category uitbreidingen op categorie] bekijken of ga naar de [https://www.mediawiki.org/wiki/Extension_Matrix uitbreidingenmatrix] om de volledige lijst met uitbreidingen te bekijken.",
+ "config-skins-screenshots": "$1 (schermafbeeldingen: $2)",
+ "config-screenshot": "schermafbeelding",
+ "mainpagetext": "<strong>De installatie van MediaWiki is geslaagd.</strong>",
+ "mainpagedocfooter": "Raadpleeg de [https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Contents handleiding] voor informatie over het gebruik van de wikisoftware.\n\n== Meer hulp over MediaWiki ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lijst met instellingen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Veelgestelde vragen (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailinglijst voor aankondigingen van nieuwe versies]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Maak MediaWiki beschikbaar in uw taal]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Leer hoe u spam kunt voorkomen op uw wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/nn.json b/www/wiki/includes/installer/i18n/nn.json
new file mode 100644
index 00000000..6cd4d38a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/nn.json
@@ -0,0 +1,40 @@
+{
+ "@metadata": {
+ "authors": [
+ "Harald Khan",
+ "Nghtwlkr",
+ "Njardarlogar",
+ "Jon Harald Søby"
+ ]
+ },
+ "config-your-language": "Språket ditt:",
+ "config-wiki-language": "Wikispråk:",
+ "config-back": "← Attende",
+ "config-continue": "Hald fram →",
+ "config-page-language": "Språk",
+ "config-memory-raised": "PHPs <code>memory_limit</code> er $1, auka til $2.",
+ "config-memory-bad": "'''Advarsel:''' PHPs <code>memory_limit</code> er $1.\nDette er sannsynlegvis for lågt.\nInstallasjonen kan mislukkast!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] er installert",
+ "config-apc": "[http://www.php.net/apc APC] er installert",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] er installert",
+ "config-db-name": "Databasenamn:",
+ "config-db-username": "Databasebrukarnamn:",
+ "config-db-password": "Databasepassord:",
+ "config-mysql-old": "MySQL $1 eller seinare krevst, du har $2.",
+ "config-db-port": "Databaseport:",
+ "config-db-schema": "Skjema for MediaWiki",
+ "config-header-mysql": "MySQL-innstillingar",
+ "config-header-postgres": "PostgreSQL-innstillingar",
+ "config-header-sqlite": "SQLite-innstillingar",
+ "config-header-oracle": "Oracle-innstillingar",
+ "config-invalid-db-type": "Ugyldig databasetype",
+ "config-invalid-db-name": "Ugyldig databasenamn «$1».\nBerre bruk ASCII-bokstavar (a-z, A-Z), tal (0-9) og undestrekar (_).",
+ "config-invalid-db-prefix": "Ugyldig databaseprefiks «$1».\nBerre bruk ASCII-bokstavar (a-z, A-Z), tal (0-9) og undestrekar (_).",
+ "config-invalid-schema": "Ugyldig skjema for MediaWiki «$1».\nBerre bruk ASCII-bokstavar (a-z, A-Z), tal (0-9) og undestrekar (_).",
+ "config-postgres-old": "PostgreSQL $1 eller seinare krevst, du har $2.",
+ "config-email-settings": "E-postinnstillingar",
+ "config-logo": "Logo-URL:",
+ "config-help": "hjelp",
+ "mainpagetext": "'''MediaWiki er no installert.'''",
+ "mainpagedocfooter": "Sjå [https://meta.wikimedia.org/wiki/Help:Contents brukarmanualen] for informasjon om bruk og oppsettshjelp for wikiprogramvara.\n\n==Kome i gang==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste over oppsettsinnstillingar]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Spørsmål og svar om MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce E-postliste med informasjon om nye MediaWiki-versjonar]"
+}
diff --git a/www/wiki/includes/installer/i18n/oc.json b/www/wiki/includes/installer/i18n/oc.json
new file mode 100644
index 00000000..3573c3ef
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/oc.json
@@ -0,0 +1,181 @@
+{
+ "@metadata": {
+ "authors": [
+ "Cedric31",
+ "Jfblanc",
+ "Seb35",
+ "Nicolas Eynaud"
+ ]
+ },
+ "config-desc": "Lo programa d’installacion de MediaWiki",
+ "config-title": "Installacion de MediaWiki $1",
+ "config-information": "Informacions",
+ "config-localsettings-key": "Clau de mesa a jorn :",
+ "config-localsettings-badkey": "La clau de mesa a jorn qu'avètz provesida es incorrècta",
+ "config-session-error": "Error al moment de l'aviada de la session : $1",
+ "config-your-language": "Vòstra lenga :",
+ "config-your-language-help": "Seleccionatz la lenga d'utilizar pendent lo processus d'installacion.",
+ "config-wiki-language": "Lenga del wiki :",
+ "config-wiki-language-help": "Seleccionar la lenga dins la quala lo wiki serà principalament escrit.",
+ "config-back": "← Retorn",
+ "config-continue": "Contunhar →",
+ "config-page-language": "Lenga",
+ "config-page-welcome": "Benvenguda sus MediaWiki !",
+ "config-page-dbconnect": "Se connectar a la basa de donadas",
+ "config-page-upgrade": "Metre a jorn l’installacion existenta",
+ "config-page-dbsettings": "Paramètres de la basa de donadas",
+ "config-page-name": "Nom",
+ "config-page-options": "Opcions",
+ "config-page-install": "Installar",
+ "config-page-complete": "Acabat !",
+ "config-page-restart": "Reaviar l’installacion",
+ "config-page-readme": "Legissètz-me",
+ "config-page-releasenotes": "Nòtas de version",
+ "config-page-copying": "Còpia",
+ "config-page-upgradedoc": "Mesa a jorn",
+ "config-page-existingwiki": "Wiki existent",
+ "config-restart": "Òc, lo reaviar",
+ "config-env-good": "L’environament es estat verificat.\nPodètz installar MediaWiki.",
+ "config-env-bad": "L’environament es estat verificat.\nPodètz pas installar MediaWiki.",
+ "config-env-php": "PHP $1 es installat.",
+ "config-env-hhvm": "HHVM $1 es installat.",
+ "config-unicode-using-intl": "Utilizacion de [http://pecl.php.net/intl l'extension PECL intl] per la normalizacion Unicode.",
+ "config-memory-raised": "Lo paramètre <code>memory_limit</code> de PHP èra a $1, portat a $2.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] es installat",
+ "config-apc": "[http://www.php.net/apc APC] es installat",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] es installat",
+ "config-diff3-bad": "GNU diff3 pas trobat.",
+ "config-git": "Logicial de contraròtle de version Git trobat : <code>$1</code>.",
+ "config-git-bad": "Logicial de contraròtle de version Git pas trobat.",
+ "config-imagemagick": "ImageMagick trobat : <code>$1</code>.\nLa miniaturizacion d'imatges serà activada se activatz lo telecargament de fichièrs.",
+ "config-gd": "La bibliotèca grafica GD integrada es estada trobada.\nLa miniaturizacion d'imatges serà activada se activatz lo telecargament de fichièrs.",
+ "config-using-server": "Utilizacion del nom de servidor \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Utilizacion de l'URL de servidor \"<nowiki>$1$2</nowiki>\".",
+ "config-db-type": "Tipe de basa de donadas :",
+ "config-db-host": "Nom d’òste de la basa de donadas :",
+ "config-db-host-oracle": "Nom TNS de la basa de donadas :",
+ "config-db-wiki-settings": "Identificar aqueste wiki",
+ "config-db-name": "Nom de la basa de donadas :",
+ "config-db-name-oracle": "Esquèma de basa de donadas :",
+ "config-db-install-account": "Compte d'utilizaire per l'installacion",
+ "config-db-username": "Nom d'utilizaire de la basa de donadas :",
+ "config-db-password": "Senhal de la basa de donadas :",
+ "config-db-account-lock": "Utilizar lo meteis nom d'utilizaire e lo meteis senhal pendent lo foncionament abitual",
+ "config-db-wiki-account": "Compte d'utilizaire pel foncionament abitual",
+ "config-db-prefix": "Prefix de las taulas de la basa de donadas :",
+ "config-mysql-old": "MySQL $1 o version ulteriora es requesit, avètz $2.",
+ "config-db-port": "Pòrt de la basa de donadas :",
+ "config-db-schema": "Esquèma per MediaWiki",
+ "config-pg-test-error": "Impossible de se connectar a la basa de donadas '''$1''' : $2",
+ "config-sqlite-dir": "Dorsièr de las donadas SQLite :",
+ "config-oracle-def-ts": "Espaci d'emmagazinatge (''tablespace'') per defaut :",
+ "config-oracle-temp-ts": "Espaci d'emmagazinatge (''tablespace'') temporari :",
+ "config-type-mysql": "MySQL (o compatible)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "Paramètres de MySQL",
+ "config-header-postgres": "Paramètres de PostgreSQL",
+ "config-header-sqlite": "Paramètres de SQLite",
+ "config-header-oracle": "Paramètres d’Oracle",
+ "config-header-mssql": "Paramètres de Microsoft SQL Server",
+ "config-invalid-db-type": "Tipe de basa de donadas invalid",
+ "config-missing-db-name": "Vos cal entrar una valor per « {{int:config-db-name}} ».",
+ "config-missing-db-host": "Vos cal entrar una valor per « {{int:config-db-host}} ».",
+ "config-missing-db-server-oracle": "Vos cal entrar una valor per « {{int:config-db-oracle}} ».",
+ "config-postgres-old": "PostgreSQL $1 o version ulteriora es requesit, avètz $2.",
+ "config-sqlite-readonly": "Lo fichièr <code>$1</code> es pas accessible en escritura.",
+ "config-sqlite-cant-create-db": "Impossible de crear lo fichièr de basa de donadas <code>$1</code>.",
+ "config-regenerate": "Regenerar LocalSettings.php →",
+ "config-show-table-status": "Fracàs de la requèsta <code>SHOW TABLE STATUS</code> !",
+ "config-db-web-account": "Compte de la basa de donadas per l'accès Web",
+ "config-db-web-account-same": "Utilizatz lo meteis compte que per l'installacion",
+ "config-db-web-create": "Creatz lo compte se existís pas ja",
+ "config-mysql-engine": "Motor d'emmagazinatge :",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Jòc de caractèrs de la basa de donadas :",
+ "config-mysql-binary": "Binari",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Tipe d’autentificacion :",
+ "config-mssql-sqlauth": "Autentificacion de SQL Server",
+ "config-mssql-windowsauth": "Autentificacion Windows",
+ "config-site-name": "Nom del wiki :",
+ "config-site-name-blank": "Entratz un nom de site.",
+ "config-project-namespace": "Espaci de noms del projècte :",
+ "config-ns-generic": "Projècte",
+ "config-ns-site-name": "Meteis nom que lo wiki : $1",
+ "config-ns-other": "Autre (precisar)",
+ "config-ns-other-default": "MonWiki",
+ "config-admin-box": "Compte d'administrator",
+ "config-admin-name": "Vòstre nom d'utilizaire :",
+ "config-admin-password": "Senhal :",
+ "config-admin-password-confirm": "Picatz lo senhal tornarmai :",
+ "config-admin-name-blank": "Entratz un nom d'administrator.",
+ "config-admin-password-blank": "Entratz un senhal pel compte d'administrator.",
+ "config-admin-password-mismatch": "Los dos senhals qu'avètz picats correspondon pas.",
+ "config-admin-email": "Adreça de corrièr electronic :",
+ "config-admin-error-user": "Error intèrna al moment de la creacion d'un administrator amb lo nom « <nowiki>$1</nowiki> ».",
+ "config-admin-error-bademail": "Avètz entrat una adreça de corrièr electronic invalida",
+ "config-optional-continue": "Me pausar mai de questions.",
+ "config-optional-skip": "N'ai pro, installar simplament lo wiki.",
+ "config-profile": "Perfil dels dreits d’utilizaires :",
+ "config-profile-wiki": "Wiki dobèrt",
+ "config-profile-no-anon": "Creacion de compte requesida",
+ "config-profile-fishbowl": "Editors autorizats solament",
+ "config-profile-private": "Wiki privat",
+ "config-license": "Dreits d'autor e licéncia :",
+ "config-license-none": "Pas cap de licéncia en bas de pagina",
+ "config-license-cc-by-sa": "Creative Commons atribucion partiment a l'identic",
+ "config-license-cc-by": "Creative Commons Atribucion",
+ "config-license-cc-by-nc-sa": "Creative Commons paternitat – non comercial – partiment a l’identic",
+ "config-license-cc-0": "Creative Commons Zero (domeni public)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 o ulteriora",
+ "config-license-pd": "Domeni public",
+ "config-license-cc-choose": "Seleccionar una licéncia Creative Commons personalizada",
+ "config-email-settings": "Paramètres de corrièr electronic",
+ "config-enable-email": "Activar los corrièls sortents",
+ "config-email-user": "Activar los corrièrs electronics d'utilizaire a utilizaire",
+ "config-email-usertalk": "Activar la notificacion de las paginas de discussion dels utilizaires",
+ "config-email-watchlist": "Activar la notificacion de la lista de seguiment",
+ "config-email-auth": "Activar l'autentificacion per corrièr electronic",
+ "config-email-sender": "Entrar una adreça electronica de retorn :",
+ "config-upload-settings": "Telecargament dels imatges e dels fichièrs",
+ "config-upload-enable": "Activar lo telecargament dels fichièrs",
+ "config-upload-deleted": "Repertòri pels fichièrs suprimits :",
+ "config-logo": "URL del lògo :",
+ "config-instantcommons": "Activar ''InstantCommons''",
+ "config-cc-again": "Causissètz tornarmai...",
+ "config-advanced-settings": "Configuracion avançada",
+ "config-cache-options": "Paramètres per la mesa en cache dels objèctes :",
+ "config-memcached-servers": "servidors per Memcached :",
+ "config-extensions": "Extensions",
+ "config-skins": "Abilhatges",
+ "config-skins-use-as-default": "Utilizar aqueste abilhatge per defaut",
+ "config-skins-must-enable-some": "Vos cal seleccionar almens un tèma per activar.",
+ "config-install-step-done": "fait",
+ "config-install-step-failed": "fracàs",
+ "config-install-extensions": "Inclusion de las extensions",
+ "config-install-database": "Creacion de la basa de donadas",
+ "config-install-schema": "Creacion d'esquèma",
+ "config-install-pg-schema-not-exist": "L'esquèma PostgreSQL existís pas",
+ "config-install-pg-commit": "Validacion de las modificacions",
+ "config-install-pg-plpgsql": "Verificacion del lengatge PL/pgSQL",
+ "config-install-user": "Creacion d'un utilizaire de la basa de donadas",
+ "config-install-user-alreadyexists": "L'utilizaire « $1 » existís ja.",
+ "config-install-user-create-failed": "Fracàs al moment de la creacion de l'utilizaire « $1 » : $2",
+ "config-install-user-grant-failed": "Fracàs al moment de l'apondon de permissions a l'utilizaire « $1 » : $2",
+ "config-install-user-missing": "L'utilizaire «$1» existís pas.",
+ "config-install-tables": "Creacion de las taulas",
+ "config-install-interwiki-list": "Impossible de legir lo fichier <code>interwiki.list</code>.",
+ "config-install-stats": "Inicializacion de las estatisticas",
+ "config-install-keys": "Generacion de la clau secreta",
+ "config-install-updates": "Empachar l’execucion de las mesas a jorn inutilas",
+ "config-install-sysop": "Creacion del compte administrator",
+ "config-install-mainpage": "Creacion de la pagina principala amb un contengut per defaut",
+ "config-install-extension-tables": "Creacion de taulas per las extensions activadas",
+ "config-install-mainpage-failed": "Impossible d’inserir la pagina principala : $1",
+ "config-download-localsettings": "Telecargar <code>LocalSettings.php</code>",
+ "config-help": "ajuda",
+ "config-help-tooltip": "clicar per agrandir",
+ "mainpagetext": "<strong>MediaWiki es estat installat amb succès.<strong>",
+ "mainpagedocfooter": "Consultatz lo [https://meta.wikimedia.org/wiki/Help:Contents/fr Guida de l'utilizaire] per mai d'entresenhas sus l'utilizacion d'aqueste logicial de wiki.\n\n== Per començar ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista dels paramètres de configuracion]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/oc FAQ MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de discussions de las distribucions de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Adaptatz MediaWiki dins vòstra lenga]"
+}
diff --git a/www/wiki/includes/installer/i18n/olo.json b/www/wiki/includes/installer/i18n/olo.json
new file mode 100644
index 00000000..68945341
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/olo.json
@@ -0,0 +1,60 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mashoi7",
+ "Ilja.mos"
+ ]
+ },
+ "config-your-language": "Sinun kieli:",
+ "config-wiki-language": "Wikin kieli:",
+ "config-back": "← Järilleh",
+ "config-continue": "Jatka →",
+ "config-page-language": "Kieli",
+ "config-page-welcome": "Tule terveh MediaWikih!",
+ "config-page-dbconnect": "Yhtistä tiedokandah",
+ "config-page-dbsettings": "Tiedokanduazetukset",
+ "config-page-name": "Nimi",
+ "config-page-options": "Azetukset",
+ "config-page-install": "Azenda",
+ "config-page-complete": "Valmis!",
+ "config-page-readme": "Luve minut",
+ "config-page-copying": "Kopiruijah",
+ "config-page-upgradedoc": "Päivitetäh",
+ "config-page-existingwiki": "Olemasolii wiki",
+ "config-restart": "Muga, käynnistä uvvelleh",
+ "config-env-php": "PHP $1 on azendettu.",
+ "config-env-hhvm": "HHVM $1 on azendettu.",
+ "config-db-name": "Tiedokannan nimi:",
+ "config-db-username": "Tiedokannan käyttäinimi:",
+ "config-db-password": "Tiedokannan salasana:",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "MySQL-azetukset",
+ "config-header-postgres": "PostgreSQL-azetukset",
+ "config-header-sqlite": "SQLite-azetukset",
+ "config-header-oracle": "Oracle-azetukset",
+ "config-header-mssql": "Microsoft SQL Server azetukset",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Wikin nimi:",
+ "config-site-name-blank": "Kirjuta sivun nimi.",
+ "config-project-namespace": "Projektan nimitila:",
+ "config-ns-generic": "Projektu",
+ "config-ns-other-default": "MinunWiki",
+ "config-admin-name": "Sinun käyttäitunnus:",
+ "config-admin-password": "Salasana:",
+ "config-admin-password-confirm": "Salasana myös:",
+ "config-admin-name-blank": "Kirjuta administruattoran käyttäinimi.",
+ "config-admin-password-blank": "Kirjuta administruattorutilin salasana.",
+ "config-admin-password-mismatch": "Sinun kirjutetut kaksi salasanua ei oldu yhtenjyttymät.",
+ "config-admin-email": "Sähköpoštuadressu:",
+ "config-optional-continue": "Kyzy minuspäi ližiä kyzymyksii.",
+ "config-optional-skip": "Olen jo terstavunnuh, vaiku azenda wiki.",
+ "config-email-settings": "Sähköpoštuazetukset",
+ "config-logo": "Logon URL:",
+ "config-skins": "Sivun ulgonävöt",
+ "config-install-step-done": "ruattu",
+ "config-install-user-alreadyexists": "Käyttäi \"$1\" on jo olemas",
+ "config-help": "abu",
+ "config-nofile": "Failua \"$1\" ei löydynyh. Ongo se otettu iäre?"
+}
diff --git a/www/wiki/includes/installer/i18n/or.json b/www/wiki/includes/installer/i18n/or.json
new file mode 100644
index 00000000..fecf4749
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/or.json
@@ -0,0 +1,48 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jnanaranjan Sahu",
+ "Psubhashish"
+ ]
+ },
+ "config-information": "ସୂଚନା",
+ "config-session-error": "ଅଧିବେଶନ ଆରମ୍ଭରେ ଅସୁବିଧା: $1",
+ "config-your-language": "ଆପଣଙ୍କ ଭାଷା:",
+ "config-your-language-help": "ଇନଷ୍ଟଲ କରିବା ବେଳେ ବ୍ୟବହାର ପାଇଁ ଏକ ଭାଷା ବାଛନ୍ତୁ ।",
+ "config-wiki-language": "ଉଇକି ଭାଷା:",
+ "config-back": "← ପଛକୁ",
+ "config-continue": "ଚାଲୁରଖିବେ →",
+ "config-page-language": "ଭାଷା",
+ "config-page-welcome": "ମେଡିଆଉଇକିକୁ ଆପଣଙ୍କୁ ସ୍ଵାଗତ",
+ "config-page-dbconnect": "ଡାଟାବେସ ସହ ଯୋଡ଼ନ୍ତୁ",
+ "config-page-upgrade": "ଏବେର ଇନଷ୍ଟଲେସନଟିକୁ ଅପଗ୍ରେଡ଼ କରନ୍ତୁ",
+ "config-page-dbsettings": "ଡାଟାବେସ ସଂରଚନା",
+ "config-page-name": "ନାମ",
+ "config-page-options": "ପସନ୍ଦସମୂହ",
+ "config-page-install": "ଇନଷ୍ଟଲ",
+ "config-page-complete": "ଶେଷ ହେଲା!",
+ "config-page-restart": "ଇନଷ୍ଟଲେସନ ପୁନଃଆରମ୍ଭ କରନ୍ତୁ",
+ "config-page-readme": "ପଢ଼ନ୍ତୁ",
+ "config-page-releasenotes": "ପ୍ରକାଶନ ସୂଚନା",
+ "config-page-copying": "ନକଲ କରୁଛି",
+ "config-page-upgradedoc": "ଅପଗ୍ରେଡ଼ କରୁଛି",
+ "config-page-existingwiki": "ଏବେକାର ଉଇକି",
+ "config-restart": "ହଁ, ଏହାକୁ ପୁନରାରମ୍ଭ କରନ୍ତୁ",
+ "config-env-php": "PHP $1 ଇନଷ୍ଟଲ ହେଲା ।",
+ "config-db-type": "ଡାଟାବେସ ପ୍ରକାର:",
+ "config-db-host": "ଡାଟାବେସ ହୋଷ୍ଟ:",
+ "config-db-name": "ଡାଟାବେସ ନାମ:",
+ "config-site-name": "ଉଇକିର ନାମ:",
+ "config-site-name-blank": "ସାଇଟ ନାମ ଦିଅନ୍ତୁ ।",
+ "config-project-namespace": "ପ୍ରକଳ୍ପ ନେମସ୍ପେସ:",
+ "config-ns-generic": "ପ୍ରକଳ୍ପ",
+ "config-ns-other": "ଅନ୍ୟ (ଦର୍ଶାଇବେ)",
+ "config-ns-other-default": "ମୋଉଇକି",
+ "config-admin-box": "ପରିଚାଳକ ଆକାଉଣ୍ଟ",
+ "config-admin-name": "ଆପଣଙ୍କର ବ୍ୟବହାରକାରୀ ନାମ:",
+ "config-admin-password": "ପାସୱାର୍ଡ",
+ "config-admin-password-confirm": "ଆଉଥରେ ପାସୱାର୍ଡ",
+ "config-license-cc-by-sa": "କ୍ରିଏଟିଭ କମନ୍ସ ଆଟ୍ରିବ୍ୟୁସନ-ସେଆର ଏଲାଇକ",
+ "config-license-cc-by-nc-sa": "କ୍ରିଏଟିଭ କମନ୍ସ ଆଟ୍ରିବ୍ୟୁସନ-ନନକମର୍ସିଆଲ ସେଆର ଏଲାଇକ",
+ "config-install-step-done": "ହୋଇଗଲା"
+}
diff --git a/www/wiki/includes/installer/i18n/os.json b/www/wiki/includes/installer/i18n/os.json
new file mode 100644
index 00000000..0df9aa1d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/os.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Amikeco"
+ ]
+ },
+ "config-page-language": "Æвзаг",
+ "mainpagetext": "'''Вики-скрипт «MediaWiki» æнтыстджынæй æвæрд æрцыд.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/pa.json b/www/wiki/includes/installer/i18n/pa.json
new file mode 100644
index 00000000..7f76504e
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pa.json
@@ -0,0 +1,40 @@
+{
+ "@metadata": {
+ "authors": [
+ "Aalam",
+ "Babanwalia",
+ "ਪ੍ਰਚਾਰਕ"
+ ]
+ },
+ "config-desc": "ਮੀਡੀਅਾਵਿਕੀ ਲੲੀ ਸਥਾਪਿਤਕਰਤਾ",
+ "config-information": "ਜਾਣਕਾਰੀ",
+ "config-localsettings-badkey": "ਤੁਹਾਡੇ ਦੁਅਾਰਾ ਦਿਤੀ ਗੲੀ ਚਾਬੀ ਗਲਤ ਹੈ",
+ "config-your-language": "ਤੁਹਾਡੀ ਭਾਸ਼ਾ:",
+ "config-your-language-help": "ਜੜਾਈ ਦੀ ਕਾਰਵਾਈ ਵੇਲੇ ਵਰਤਣ ਵਾਸਤੇ ਕੋਈ ਭਾਸ਼ਾ ਚੁਣੋ।",
+ "config-wiki-language": "ਵਿਕੀ ਦੀ ਭਾਸ਼ਾ:",
+ "config-wiki-language-help": "ਉਹ ਭਾਸ਼ਾ ਚੁਣੋ ਜਿਸ ਵਿੱਚ ਵਿਕੀ ਮੁੱਖ ਤੌਰ 'ਤੇ ਲਿਖਿਆ ਜਾਵੇਗਾ।",
+ "config-back": "← ਪਿੱਛੇ",
+ "config-continue": "ਜਾਰੀ ਰੱਖੋ →",
+ "config-page-language": "ਭਾਸ਼ਾ",
+ "config-page-welcome": "ਮੀਡੀਆਵਿਕੀ 'ਤੇ ਜੀ ਆਇਆਂ ਨੂੰ!",
+ "config-page-dbconnect": "ਤੱਥ-ਅਧਾਰ ਨਾਲ਼ ਜੁੜੋ",
+ "config-page-upgrade": "ਮੌਜੂਦਾ ਜੜਾਈ ਦਾ ਮਿਆਰ ਚੁੱਕੋ",
+ "config-page-dbsettings": "ਤੱਥ-ਅਧਾਰ ਦੀਆਂ ਸੈਟਿੰਗਾਂ",
+ "config-page-name": "ਨਾਂ",
+ "config-page-options": "ਚੋਣਾਂ",
+ "config-page-install": "ਜੜੋ",
+ "config-page-complete": "ਮੁਕੰਮਲ!",
+ "config-page-restart": "ਜੜਾਈ ਮੁੜ-ਸ਼ੁਰੂ ਕਰੋ",
+ "config-page-readme": "ਮੈਨੂੰ ਪੜ੍ਹੋ",
+ "config-page-copying": "ਨਕਲ",
+ "config-page-upgradedoc": "ਮਿਆਰ-ਉਚਾਈ",
+ "config-page-existingwiki": "ਮੌਜੂਦਾ ਵਿਕੀ",
+ "config-restart": "ਹਾਂਜੀ, ਮੁੜ ਸ਼ੁਰੂ ਕਰੋ",
+ "config-env-good": "ਵਾਤਾਵਰਨ ਪਰਖਿਅਾ ਗਿਅਾ ਹੈ।\nਤੁਸੀਂ ਮੀਡੀਆਵਿਕੀ ਸਥਾਪਿਤ ਕਰ ਸਕਦੇ ਹੋ",
+ "config-env-bad": "ਵਾਤਾਵਰਨ ਪਰਖਿਅਾ ਗਿਅਾ ਹੈ।\nਤੁਸੀਂ ਮੀਡੀਆਵਿਕੀ ਸਥਾਪਿਤ ਨਹੀਂ ਕਰ ਸਕਦੇ ਹੋ",
+ "config-env-php": "PHP $1 ਜੜਿਆ ਗਿਆ।",
+ "config-env-hhvm": "HHVM $1 ਜੜਿਆ ਗਿਆ।",
+ "config-db-wiki-settings": "ਇਸ ਵਿਕੀ ਦੀ ਪਛਾਣ ਕਰਾਉ",
+ "config-db-name": "ਤੱਥ-ਅਧਾਰ ਦਾ ਨਾਂ:",
+ "mainpagetext": "'''ਮੀਡਿਆਵਿਕਿ ਠੀਕ ਤਰ੍ਹਾਂ ਇੰਸਟਾਲ ਹੋ ਗਿਆ ਹੈ।'''"
+}
diff --git a/www/wiki/includes/installer/i18n/pam.json b/www/wiki/includes/installer/i18n/pam.json
new file mode 100644
index 00000000..f95624ac
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pam.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''Melaus ing pamipalyari ning MediaWiki.'''",
+ "mainpagedocfooter": "Basan me ing [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] para king impormasiun keng pamangamit ning wiki software.\n\n== Pamagumpisa ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/pcd.json b/www/wiki/includes/installer/i18n/pcd.json
new file mode 100644
index 00000000..84f9d3c3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pcd.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki o té instalé aveuc victoère.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/pdc.json b/www/wiki/includes/installer/i18n/pdc.json
new file mode 100644
index 00000000..f35a76ff
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pdc.json
@@ -0,0 +1,13 @@
+{
+ "@metadata": {
+ "authors": [
+ "Xqt"
+ ]
+ },
+ "config-continue": "Weider →",
+ "config-page-language": "Schprooch",
+ "config-admin-password": "Paesswatt:",
+ "config-install-step-done": "geduh",
+ "config-help": "Hilf",
+ "mainpagedocfooter": "Hilf fer's Yuuse unn Konfiguriere vun de Wiki-Software kansch finne im [https://meta.wikimedia.org/wiki/Help:Contents Handbuch fer Yuuser].\n\n== Hilf zum Schtaerte ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lischt vun Gnepp zum Konfiguriere]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Eposchde-Lischt fer neie MediaWiki-Versione]"
+}
diff --git a/www/wiki/includes/installer/i18n/pl.json b/www/wiki/includes/installer/i18n/pl.json
new file mode 100644
index 00000000..d712afe1
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pl.json
@@ -0,0 +1,333 @@
+{
+ "@metadata": {
+ "authors": [
+ "Beau",
+ "BeginaFelicysym",
+ "Chrumps",
+ "Holek",
+ "Matma Rex",
+ "Michał Roszka",
+ "Saper",
+ "Sp5uhe",
+ "Woytecr",
+ "아라",
+ "Amire80",
+ "Jacenty359",
+ "Pan Cube",
+ "WTM",
+ "Alan ffm",
+ "Matik7",
+ "Pio387",
+ "Darellur",
+ "The Polish",
+ "Macofe",
+ "Sethakill",
+ "Peter Bowman"
+ ]
+ },
+ "config-desc": "Instalator MediaWiki",
+ "config-title": "Instalacja MediaWiki $1",
+ "config-information": "Informacja",
+ "config-localsettings-upgrade": "Plik <code>LocalSettings.php</code> istnieje.\nAby oprogramowanie zostało zaktualizowane musisz wstawić wartość <code>$wgUpgradeKey</code> w poniższe pole.\nOdnajdziesz ją w <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Wykryto obecność pliku <code>LocalSettings.php</code>.\nAktualizację należy wykonać poprzez uruchomienie <code>update.php</code>",
+ "config-localsettings-key": "Klucz aktualizacji:",
+ "config-localsettings-badkey": "Podany klucz aktualizacji jest nieprawidłowy.",
+ "config-upgrade-key-missing": "Wykryto zainstalowane wcześniej MediaWiki.\nJeśli chcesz je zaktualizować, dodaj na koniec pliku <code>LocalSettings.php</code> poniższą linię tekstu:\n\n$1",
+ "config-localsettings-incomplete": "Istniejący plik <code>LocalSettings.php</code> wygląda na niekompletny.\nBrak wartości zmiennej $1.\nZmień plik <code>LocalSettings.php</code>, tak by zawierał deklarację wartości tej zmiennej, a następnie kliknij „{{int:Config-continue}}”.",
+ "config-localsettings-connection-error": "Wystąpił błąd podczas łączenia z bazą danych używając ustawień podanych w <code>LocalSettings.php</code>.\nNapraw te ustawienia i spróbuj ponownie.\n\n$1",
+ "config-session-error": "Błąd uruchomienia sesji – $1",
+ "config-session-expired": "Wygląda na to, że Twoja sesja wygasła.\nCzas życia sesji został skonfigurowany na $1.\nMożesz go wydłużyć zmieniając <code>session.gc_maxlifetime</code> w pliku php.ini.\nUruchom ponownie proces instalacji.",
+ "config-no-session": "Dane sesji zostały utracone.\nSprawdź plik php.ini i upewnij się, że <code>session.save_path</code> wskazuje na odpowiedni katalog.",
+ "config-your-language": "Twój język:",
+ "config-your-language-help": "Wybierz język używany podczas procesu instalacji.",
+ "config-wiki-language": "Język wiki:",
+ "config-wiki-language-help": "Wybierz język, w którym będzie tworzona większość treści wiki.",
+ "config-back": "← Wstecz",
+ "config-continue": "Dalej →",
+ "config-page-language": "Język",
+ "config-page-welcome": "Witamy w MediaWiki!",
+ "config-page-dbconnect": "Połączenie z bazą danych",
+ "config-page-upgrade": "Uaktualnienie istniejącej instalacji",
+ "config-page-dbsettings": "Ustawienia bazy danych",
+ "config-page-name": "Nazwa",
+ "config-page-options": "Opcje",
+ "config-page-install": "Instaluj",
+ "config-page-complete": "Zakończono!",
+ "config-page-restart": "Rozpoczęcie instalacji od nowa",
+ "config-page-readme": "Podstawowe informacje",
+ "config-page-releasenotes": "Informacje o wersji",
+ "config-page-copying": "Kopiowanie",
+ "config-page-upgradedoc": "Uaktualnienie",
+ "config-page-existingwiki": "Istniejąca wiki",
+ "config-help-restart": "Czy chcesz usunąć wszystkie zapisane dane i uruchomić ponownie proces instalacji?",
+ "config-restart": "Tak, zacznij od nowa",
+ "config-welcome": "=== Sprawdzenie środowiska instalacji ===\nTeraz zostaną wykonane podstawowe testy sprawdzające czy to środowisko jest odpowiednie dla instalacji MediaWiki.\nJeśli potrzebujesz pomocy podczas instalacji, załącz wyniki tych testów.",
+ "config-copyright": "=== Prawa autorskie i warunki użytkowania ===\n\n$1\n\nTo oprogramowanie jest wolne; możesz je rozprowadzać dalej i modyfikować zgodnie z warunkami licencji GNU General Public License opublikowanej przez Free Software Foundation w wersji 2 tej licencji lub (według Twojego wyboru) którejś z późniejszych jej wersji.\n\nNiniejsze oprogramowanie jest rozpowszechniane w nadziei, że będzie użyteczne, ale '''bez żadnej gwarancji'''; nawet bez domniemanej gwarancji '''handlowej''' lub '''przydatności do określonego celu'''.\nZobacz treść licencji GNU General Public License, aby uzyskać więcej szczegółów.\n\nRazem z oprogramowaniem powinieneś otrzymać <doclink href=Copying>kopię licencji GNU General Public License</doclink>. Jeśli jej nie otrzymałeś, napisz do Free Software Foundation, Inc, 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. lub [http://www.gnu.org/copyleft/gpl.html przeczytaj ją online].",
+ "config-sidebar": "* [https://www.mediawiki.org Strona domowa MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Podręcznik użytkownika]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Podręcznik administratora]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Odpowiedzi na często zadawane pytania]\n----\n* <doclink href=Readme>Przeczytaj to</doclink>\n* <doclink href=ReleaseNotes>Informacje o tej wersji</doclink>\n* <doclink href=Copying>Kopiowanie</doclink>\n* <doclink href=UpgradeDoc>Aktualizacja</doclink>",
+ "config-env-good": "Środowisko oprogramowania zostało sprawdzone.\nMożesz teraz zainstalować MediaWiki.",
+ "config-env-bad": "Środowisko oprogramowania zostało sprawdzone.\nNie możesz zainstalować MediaWiki.",
+ "config-env-php": "Zainstalowane jest PHP w wersji $1.",
+ "config-env-hhvm": "Zainstalowany jest HHVM $1.",
+ "config-unicode-using-intl": "Korzystanie z [http://pecl.php.net/intl rozszerzenia intl PECL] do normalizacji Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Uwaga:<strong> [http://pecl.php.net/intl Rozszerzenie intl PECL] do obsługi normalizacji Unicode nie jest dostępne. Użyta zostanie mało wydajna zwykła implementacja w PHP.\nJeśli prowadzisz stronę o dużym natężeniu ruchu, powinieneś zapoznać się z informacjami o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizacji Unicode].",
+ "config-unicode-update-warning": "<strong>Uwaga:</strong> zainstalowana wersja normalizacji Unicode korzysta z nieaktualnej biblioteki [http://site.icu-project.org/ projektu ICU].\nPowinieneś [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations wykonać aktualizację], jeśli chcesz korzystać w pełni z Unicode.",
+ "config-no-db": "Nie można odnaleźć właściwego sterownika bazy danych! Musisz zainstalować sterownik bazy danych dla PHP.\nMożna użyć {{PLURAL:$2|następującego typu bazy|następujących typów baz}} danych: $1.\n\nJeśli skompilowałeś PHP samodzielnie, skonfiguruj go ponownie z włączonym klientem bazy danych, na przykład za pomocą polecenia <code>./configure --with-mysqli</code>.\nJeśli zainstalowałeś PHP jako pakiet Debiana lub Ubuntu, musisz również zainstalować np. moduł <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Ostrzeżenie''': masz SQLite $1, która jest niższa od minimalnej wymaganej wersji $2 . SQLite będzie niedostępne.",
+ "config-no-fts3": "'''Uwaga''' – SQLite został skompilowany bez [//sqlite.org/fts3.html modułu FTS3] – funkcje wyszukiwania nie będą dostępne.",
+ "config-pcre-old": "<strong>Błąd krytyczny:</strong> Wymagany jest PCRE w wersji $1 lub nowszej.\nTwój plik wykonywalny PHP jest powiązany z wersją PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Więcej informacji].",
+ "config-pcre-no-utf8": "'''Błąd krytyczny''' – wydaje się, że moduł PCRE w PHP został skompilowany bez wsparcia dla UTF‐8.\nMediaWiki wymaga wsparcia dla UTF‐8 do prawidłowego działania.",
+ "config-memory-raised": "PHP <code>memory_limit</code> było ustawione na $1, zostanie zwiększone do $2.",
+ "config-memory-bad": "'''Uwaga:''' PHP <code>memory_limit</code> jest ustawione na $1.\nTo jest prawdopodobnie zbyt mało.\nInstalacja może się nie udać!",
+ "config-xcache": "[Http://trac.lighttpd.net/xcache/ XCache] jest zainstalowany",
+ "config-apc": "[Http://www.php.net/apc APC] jest zainstalowany",
+ "config-apcu": "[http://www.php.net/apcu APCu] jest zainstalowany",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] jest zainstalowany",
+ "config-no-cache-apcu": "<strong>Ostrzeżenie:</strong> Nie można znaleźć [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] lub [http://www.iis.net/download/WinCacheForPhp WinCache].\nPamięć podręczna obiektów nie zostanie włączona.",
+ "config-mod-security": "''' Ostrzeżenie ''': Serwer sieci web ma włączone [http://modsecurity.org/ mod_security]. Jeśli jest niepoprawnie skonfigurowane, może być przyczyną problemów MediaWiki lub innego oprogramowania, które pozwala użytkownikom na wysyłanie dowolnej zawartości.\nSprawdź w [http://modsecurity.org/documentation/ dokumentacji mod_security] lub skontaktuj się z obsługa hosta, jeśli wystąpią losowe błędy.",
+ "config-diff3-bad": "Nie znaleziono GNU diff3.",
+ "config-git": "Znaleziono oprogramowanie kontroli wersji Git: <code>$1</code>.",
+ "config-git-bad": "Oprogramowanie systemu kontroli wersji Git nie zostało znalezione.",
+ "config-imagemagick": "Mamy zainstalowany ImageMagick <code>$1</code>, dzięki czemu będzie można pomniejszać załadowane grafiki.",
+ "config-gd": "Mamy wbudowaną bibliotekę graficzną GD, dzięki czemu będzie można pomniejszać załadowane grafiki.",
+ "config-no-scaling": "Nie odnaleziono biblioteki GD lub ImageMagick. Możliwość zmniejszania załadowywanych grafik zostanie wyłączona.",
+ "config-no-uri": "'''Błąd:''' Nie można określić aktualnego URI.\nInstalacja została przerwana.",
+ "config-no-cli-uri": "<strong>Ostrzeżenie:</strong> Nie wskazano <code>--scriptpath</code>, użycie wartości domyślnej: <code>$1</code>.",
+ "config-using-server": "„<nowiki>$1</nowiki>” jest adresem serwera, na którym instalowana jest wiki.",
+ "config-using-uri": "Wiki będzie zainstalowana pod adresem \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Uwaga</strong> – domyślny katalog, do którego zapisywane są przesyłane pliki <code>$1</code>, jest podatny na wykonanie dowolnego skryptu.\nChociaż MediaWiki sprawdza wszystkie przesłane pliki pod kątem bezpieczeństwa, zaleca się jednak, aby [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security zamknąć tę lukę w zabezpieczeniach] przed włączeniem przesyłania plików.",
+ "config-no-cli-uploads-check": "'''Ostrzeżenie:''' Katalog domyślny przesyłanych plików ( <code>$1</code> ) nie jest sprawdzona względem luki\n wykonania dowolnego skryptu podczas instalacji CLI w zabezpieczeniach.",
+ "config-brokenlibxml": "Twój system jest kombinacją wersji PHP i libxml2, która zawiera błędy mogące powodować ukryte uszkodzenia danych w MediaWiki i innych aplikacjach sieci web.\nWykonaj aktualizację libxml2 do wersji 2.7.3 lub późniejszej ([https://bugs.php.net/bug.php?id=45996 bug filed with PHP]).\nInstalacja została przerwana.",
+ "config-suhosin-max-value-length": "Jest zainstalowany Suhosin i ogranicza długość parametru GET <code>length</code> do $1 bajtów. Komponent ResourceLoader w MediaWiki wykona obejście tego ograniczenia, ale kosztem wydajności.\nJeśli to możliwe, należy ustawić <code>suhosin.get.max_value_length</code> na 1024 lub więcej w <code>php.ini</code> oraz ustawić <code>$wgResourceLoaderMaxQueryLength</code> w <code>LocalSettings.php</code> na tę samą wartość.",
+ "config-db-type": "Typ bazy danych:",
+ "config-db-host": "Adres serwera bazy danych:",
+ "config-db-host-help": "Jeśli serwer bazy danych jest na innej maszynie, wprowadź jej nazwę domenową lub adres IP.\n\nJeśli korzystasz ze współdzielonego hostingu, operator serwera powinien podać Ci prawidłową nazwę serwera w swojej dokumentacji.\n\nJeśli instalujesz oprogramowanie na serwerze Windows i korzystasz z MySQL, użycie „localhost” może nie zadziałać jako nazwa hosta. Jeśli wystąpi ten problem, użyj „127.0.0.1” jako lokalnego adresu IP.\n\nJeżeli korzystasz z PostgreSQL, pozostaw to pole puste, aby połączyć się poprzez gniazdo Unixa.",
+ "config-db-host-oracle": "Nazwa instancji bazy danych (TNS):",
+ "config-db-host-oracle-help": "Wprowadź prawidłową [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm nazwę połączenia lokalnego]. Plik „tnsnames.ora” musi być widoczny dla instalatora.<br />Jeśli używasz biblioteki klienckiej 10g lub nowszej możesz również skorzystać z metody nazw [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm łatwego łączenia].",
+ "config-db-wiki-settings": "Zidentyfikuj tę wiki",
+ "config-db-name": "Nazwa bazy danych:",
+ "config-db-name-help": "Wybierz nazwę, która zidentyfikuje Twoją wiki.\nNie może ona zawierać spacji.\n\nJeśli korzystasz ze współdzielonego hostingu, dostawca usługi hostingowej może wymagać użycia konkretnej nazwy bazy danych lub pozwalać na tworzenie baz danych za pośrednictwem panelu użytkownika.",
+ "config-db-name-oracle": "Nazwa schematu bazy danych:",
+ "config-db-account-oracle-warn": "Bazę danych Oracle można przygotować do pracy z MediaWiki na trzy sposoby:\n\nMożesz utworzyć konto użytkownika bazy danych podczas instalacji MediaWiki. Wówczas należy podać nazwę i hasło użytkownika z rolą SYSDBA w celu użycia go przez instalator do utworzenia nowe konta użytkownika, z którego korzystać będzie MediaWiki.\n\nMożesz również skorzystać z konta użytkownika bazy danych utworzonego bezpośrednio w Oracle i wówczas wystarczy podać tylko nazwę i hasło tego użytkownika. Konto z rolą SYSDBA nie będzie potrzebne, jednak konto użytkownika powinno mieć uprawnienia do utworzenia obiektów w schemacie bazy danych. Możesz też podać dwa konta - konto dla instalatora, z pomocą którego zostaną obiekty w schemacie bazy danych i drugie konto, z którego będzie MediaWiki korzystać będzie do pracy.\n\nW podkatalogu \"maintenance/oracle\" znajduje się skrypt do tworzenia konta użytkownika. Korzystanie z konta użytkownika z ograniczonymi uprawnieniami spowoduje wyłączenie funkcji związanych z aktualizacją oprogramowania MediaWiki.",
+ "config-db-install-account": "Konto użytkownika dla instalatora",
+ "config-db-username": "Nazwa użytkownika bazy danych:",
+ "config-db-password": "Hasło bazy danych:",
+ "config-db-install-username": "Wprowadź nazwę użytkownika, który będzie używany do łączenia się z bazą danych podczas procesu instalacji.\nNie jest to nazwa konta MediaWiki, a użytkownika bazy danych.",
+ "config-db-install-password": "Wprowadź hasło, które będzie wykorzystywane do łączenia się z bazą danych w procesie instalacji.\nTo nie jest hasło konta MediaWiki, lecz hasło do bazy danych.",
+ "config-db-install-help": "Podaj nazwę użytkownika i jego hasło, które zostaną użyte do połączenia z bazą danych w czasie procesu instalacji.",
+ "config-db-account-lock": "Użyj tej samej nazwy użytkownika i hasła w czasie normalnej pracy.",
+ "config-db-wiki-account": "Konto użytkownika do normalnej pracy",
+ "config-db-wiki-help": "Wprowadź nazwę użytkownika i hasło, które będą używane do połączenia z bazą danych podczas normalnej pracy wiki.\nJeśli konto nie istnieje, a konto instalacji ma wystarczające uprawnienia, to zostanie utworzone konto użytkownika z minimalnymi uprawnieniami wymaganymi do działania wiki.",
+ "config-db-prefix": "Przedrostek tabel bazy danych:",
+ "config-db-prefix-help": "Jeśli zachodzi potrzeba współdzielenia jednej bazy danych między wieloma wiki, lub między MediaWiki i inną aplikacją sieciową, można dodać przedrostek do wszystkich nazw tabel w celu uniknięcia konfliktów.\nNie należy używać spacji.\n\nTo pole zwykle pozostawiane jest puste.",
+ "config-mysql-old": "Wymagany jest MySQL $1 lub nowszy; korzystasz z $2.",
+ "config-db-port": "Port bazy danych:",
+ "config-db-schema": "Nazwa schematu bazy danych, z którego ma korzystać MediaWiki:",
+ "config-db-schema-help": "Zaproponowana nazwa schematu jest odpowiednia dla większości sytuacji i przeważnie nie trzeba jej zmieniać.",
+ "config-pg-test-error": "Nie można połączyć się z bazą danych''' $1 ''': $2",
+ "config-sqlite-dir": "Katalog danych SQLite:",
+ "config-sqlite-dir-help": "SQLite przechowuje wszystkie dane w pojedynczym pliku.\n\nWskazany katalog musi być dostępny do zapisu przez webserver podczas instalacji.\n\nPowinien '''nie''' być dostępny za z sieci web, dlatego nie umieszczamy ich tam, gdzie znajdują się pliki PHP.\n\nInstalator zapisze plik <code>.htaccess</code> obokniego, ale jeśli to zawiedzie, ktoś może uzyskać dostęp do nieprzetworzonej bazy danych.\nZawiera ona nieopracowane dane użytkownika (adresy e-mail, zahaszowane hasła) jak również usunięte wersje oraz inne dane o ograniczonym dostępie na wiki.\n\nWarto rozważyć umieszczenie w bazie danych zupełnie gdzie indziej, na przykład w <code>/var/lib/mediawiki/yourwiki</code> .",
+ "config-oracle-def-ts": "Domyślna przestrzeń tabel:",
+ "config-oracle-temp-ts": "Przestrzeń tabel tymczasowych:",
+ "config-type-mysql": "MySQL (lub kompatybilna)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki może współpracować z następującymi systemami baz danych:\n\n$1\n\nPoniżej wyświetlone są systemy baz danych gotowe do użycia. Jeżeli poniżej brakuje bazy danych, z której chcesz skorzystać, oznacza to, że brakuje odpowiedniego oprogramowania lub zostało ono niepoprawnie skonfigurowane. Powyżej znajdziesz odnośniki do dokumentacji, która pomoże w konfiguracji odpowiednich komponentów.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] jest bazą danych, na której rozwijane jest oprogramowanie MediaWiki. MediaWiki działa również z [{{int:version-db-mariadb-url}} MariaDB] i [{{int:version-db-percona-url}} Percona Server], które są zgodne z MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Zobacz, jak skompilować PHP ze wsparciem dla MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] jest popularnym systemem baz danych, często stosowanym zamiast MySQL. ([http://www.php.net/manual/pl/pgsql.installation.php Zobacz, jak skompilować PHP z obsługą PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] jest niewielkim systemem bazy danych, z którym MediaWiki bardzo dobrze współpracuje. ([http://www.php.net/manual/en/pdo.installation.php Zobacz, jak skompilować PHP ze wsparciem dla SQLite], korzystając z PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] jest komercyjną profesjonalną bazą danych. ([http://www.php.net/manual/en/oci8.installation.php Jak skompilować PHP ze wsparciem dla OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] jest komercyjną profesjonalną bazą danych. ([http://www.php.net/manual/pl/sqlsrv.installation.php Jak skompilować PHP ze wsparciem dla SQLSRV])",
+ "config-header-mysql": "Ustawienia MySQL",
+ "config-header-postgres": "Ustawienia PostgreSQL",
+ "config-header-sqlite": "Ustawienia SQLite",
+ "config-header-oracle": "Ustawienia Oracle",
+ "config-header-mssql": "Ustawienia Microsoft SQL Server",
+ "config-invalid-db-type": "Nieprawidłowy typ bazy danych",
+ "config-missing-db-name": "Należy wpisać wartość w polu „{{int:config-db-name}}”.",
+ "config-missing-db-host": "Należy wpisać wartość w polu „{{int:config-db-host}}”.",
+ "config-missing-db-server-oracle": "Należy wpisać wartość w polu „{{int:config-db-host-oracle}}”.",
+ "config-invalid-db-server-oracle": "Nieprawidłowa nazwa instancji bazy danych (TNS) „$1”.\nUżyj \"TNS Name\" lub \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods])",
+ "config-invalid-db-name": "Nieprawidłowa nazwa bazy danych „$1”.\nUżywaj wyłącznie liter ASCII (a-z, A-Z), cyfr (0-9), podkreślenia (_) lub znaku odejmowania (-).",
+ "config-invalid-db-prefix": "Nieprawidłowy prefiks bazy danych „$1”.\nUżywaj wyłącznie liter ASCII (a-z, A-Z), cyfr (0-9), podkreślenia (_) lub znaku odejmowania (-).",
+ "config-connection-error": "$1.\n\nSprawdź adres serwera, nazwę użytkownika i hasło, a następnie spróbuj ponownie.",
+ "config-invalid-schema": "Nieprawidłowa nazwa schematu dla MediaWiki „$1”.\nNazwa może zawierać wyłącznie liter ASCII (a-z, A-Z), cyfr (0-9) i podkreślenia (_).",
+ "config-db-sys-create-oracle": "Instalator może wykorzystać wyłącznie konto SYSDBA do tworzenia nowych kont użytkowników.",
+ "config-db-sys-user-exists-oracle": "Konto użytkownika „$1” już istnieje. SYSDBA można użyć tylko do utworzenia nowego konta!",
+ "config-postgres-old": "Korzystasz z wersji $2 oprogramowania PostgreSQL, a potrzebna jest wersja co najmniej $1.",
+ "config-mssql-old": "Wymagany jest Microsoft SQL Server w wersji $1 lub nowszej. Masz zainstalowaną wersję $2.",
+ "config-sqlite-name-help": "Wybierz nazwę, która będzie identyfikować Twoją wiki.\nNie wolno używać spacji ani myślników.\nZostanie ona użyta jako nazwa pliku danych SQLite.",
+ "config-sqlite-parent-unwritable-group": "Nie można utworzyć katalogu danych <code><nowiki>$1</nowiki></code> , ponieważ katalog nadrzędny <code><nowiki>$2</nowiki></code> nie jest dostępny do zapisu przez webserwer.\n\nInstalator nie może określić, jako kttóry użytkownik działa webserwer.\nZezwól by katalog <code><nowiki>$3</nowiki></code> był dostępny do zapisu przez niego, aby przejść dalej.\nW systemie Unix/Linux wykonaj:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Nie można utworzyć katalogu danych <code><nowiki>$1</nowiki></code> , ponieważ katalog nadrzędny <code><nowiki>$2</nowiki></code> nie jest dostępny do zapisu przez webserwer.\n\nInstalator nie może określić, jako kttóry użytkownik działa webserwer.\nZezwól by katalog <code><nowiki>$3</nowiki></code> był globalnie modyfikowalny przez niego (i innych!) aby przejść dalej.\nW systemie Unix/Linux wykonaj:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Błąd podczas tworzenia katalogu dla danych „$1”.\nSprawdź lokalizację i spróbuj ponownie.",
+ "config-sqlite-dir-unwritable": "Nie można zapisać do katalogu „$1”.\nZmień uprawnienia dostępu do katalogu tak, aby serwer WWW mógł pisać do niego, a następnie spróbuj ponownie.",
+ "config-sqlite-connection-error": "$1.\n\nSprawdź katalog danych oraz nazwę bazy danych, a następnie spróbuj ponownie.",
+ "config-sqlite-readonly": "Plik <code>$1</code> nie jest zapisywalny.",
+ "config-sqlite-cant-create-db": "Nie można utworzyć pliku bazy danych <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "Brak wsparcia FTS3 dla PHP. Tabele zostały cofnięte",
+ "config-can-upgrade": "W bazie danych są już tabele MediaWiki.\nAby uaktualnić je do MediaWiki $1, kliknij <strong>Dalej</strong>.",
+ "config-upgrade-done": "Uaktualnienie kompletne.\n\nMożna teraz [$1 rozpocząć korzystanie z wiki].\n\nJeśli chcesz ponownie wygenerować plik <code>LocalSettings.php</code>, kliknij przycisk poniżej.\nJest to <strong>niezalecane</strong>, chyba że występują problemy z twoją wiki.",
+ "config-upgrade-done-no-regenerate": "Aktualizacja zakończona.\n\nMożesz teraz [$1 zacząć korzystać ze swojej wiki].",
+ "config-regenerate": "Ponowne generowanie LocalSettings.php →",
+ "config-show-table-status": "Zapytanie „<code>SHOW TABLE STATUS</code>” nie powiodło się!",
+ "config-unknown-collation": "'''Uwaga''' – bazy danych używa nierozpoznanej metody porównywania.",
+ "config-db-web-account": "Konto bazy danych dla dostępu przez WWW",
+ "config-db-web-help": "Wybierz nazwę użytkownika i hasło, z których korzystać będzie serwer WWW do łączenia się z serwerem baz danych, podczas zwykłej pracy z wiki.",
+ "config-db-web-account-same": "Użyj tego samego konta, co dla instalacji",
+ "config-db-web-create": "Utwórz konto, jeśli jeszcze nie istnieje",
+ "config-db-web-no-create-privs": "Konto podane do wykonania instalacji nie ma wystarczających uprawnień, aby utworzyć nowe konto.\nKonto, które wskazałeś tutaj musi już istnieć.",
+ "config-mysql-engine": "Silnik przechowywania",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Ostrzeżenie''': wybrano MyISIAM jako silnik składowania danych MySQL, co nie jest zalecane do użytku w MediaWiki, ponieważ:\n * ledwo obsługuje współbieżnośći ze względu na blokowanie tabel\n * jest bardziej podatna na uszkodzenie niż inne silniki\n * kod źródłowy MediaWiki nie zawsze obsługuje MyISAM tak, jak powinien\n\nJeśli instalacja MySQL obsługuje InnoDB, jest wysoce zalecane, by to je wybrać.\nJeśli instalacja MySQL nie obsługuje InnoDB, być może nadszedł czas na jej uaktualnienie.",
+ "config-mysql-only-myisam-dep": "'''Ostrzeżenie:''' MyISAM jest jedynym dostępnym na tym komputerze mechanizmem składowania dla MySQL, który jednak nie jest zalecany do używania z MediaWiki, ponieważ:\n* słabo obsługuje współbieżność z powodu blokowania tabel\n* jest bardziej skłonny do uszkodzeń niż inne silniki\n* kod MediaWiki nie zawsze traktuje MyISAM jak powinien\n\nTwoja instalacja MySQL nie obsługuje InnoDB, być może jest to czas na aktualizację.",
+ "config-mysql-engine-help": "'''InnoDB''' jest prawie zawsze najlepszą opcją, ponieważ posiada dobrą obsługę współbieżności.\n\n'''MyISAM''' może być szybsze w instalacjach pojedynczego użytkownika lub tylko do odczytu.\nBazy danych MyISAM mają tendencję do ulegania uszkodzeniom częściej niż bazy InnoDB.",
+ "config-mysql-charset": "Zestaw znaków bazy danych:",
+ "config-mysql-binary": "binarny",
+ "config-mysql-utf8": "UTF‐8",
+ "config-mysql-charset-help": "W <strong>trybie binarnym</strong>, MediaWiki zapisuje tekst UTF-8 do bazy danych w polach binarnych.\nJest on bardziej wydajny niż tryb UTF-8 w MySQL i pozwala na używanie znaków z pełnego zakresu Unicode.\n\nW <strong>trybie UTF-8</strong> MySQL będzie znać zestaw znaków w jakim zakodowano dane, możne też je wyświetlić i odpowiednio przekonwertować, ale nie pozwoli Ci przechowywać znaków spoza [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes podstawowej płaszczyzny wielojęzyczności].",
+ "config-mssql-auth": "Typ uwierzytelniania:",
+ "config-mssql-install-auth": "Wybierz typ uwierzytelniania, który będzie używany do łączenia się z bazą danych w trakcie procesu instalacji.\nJeśli wybierzesz „{{int:config-mssql-windowsauth}}”, będą wykorzystywane dane konta użytkownika, pod którym działa serwer www.",
+ "config-mssql-web-auth": "Wybierz typ uwierzytelniania, który będzie używany przez serwer www do łączenia się z bazą danych podczas normalnego funkcjonowania wiki.\nJeśli wybierzesz „{{int:config-mssql-windowsauth}}”, użyte zostaną dane konta użytkownika, pod którym działa serwer www.",
+ "config-mssql-sqlauth": "Uwierzytelnianie serwera SQL",
+ "config-mssql-windowsauth": "Autoryzacja Windows",
+ "config-site-name": "Nazwa wiki:",
+ "config-site-name-help": "Ten napis pojawi się w pasku tytułowym przeglądarki oraz w różnych innych miejscach.",
+ "config-site-name-blank": "Wprowadź nazwę witryny.",
+ "config-project-namespace": "Przestrzeń nazw projektu:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Taka sama jak nazwa wiki: $1",
+ "config-ns-other": "Inna (należy określić)",
+ "config-ns-other-default": "MojaWiki",
+ "config-project-namespace-help": "Według przykładu Wikipedii wiele wiki przechowuje swoje strony zasad oddzielnie od stron z zawartością, w '''przestrzeni nazw projektu'''.\nWszystkie tytuły stron w tej przestrzeni nazw zaczynają się od pewnego przedrostka, który można tutaj określić.\nZazwyczaj ten przedrostek wywodzi się od nazwy wiki, ale nie może zawierać pewnych znaków przestankowych takich jak \"#\" lub \":\".",
+ "config-ns-invalid": "Podana przestrzeń nazw „<nowiki>$1</nowiki>” jest nieprawidłowa.\nPodaj inną przestrzeń nazw projektu.",
+ "config-ns-conflict": "Określona przestrzeń nazw \"<nowiki>$1</nowiki>\" powoduje konflikt z domyślną przestrzenią nazw MediaWiki.\nWskaż inną przestrzeń nazw projektu.",
+ "config-admin-box": "Konto administratora",
+ "config-admin-name": "Twoja nazwa użytkownika:",
+ "config-admin-password": "Hasło:",
+ "config-admin-password-confirm": "Hasło powtórnie:",
+ "config-admin-help": "Wprowadź preferowaną nazwę użytkownika, na przykład „Jan Kowalski”.\nTej nazwy będziesz używać do logowania się do wiki.",
+ "config-admin-name-blank": "Wpisz nazwę użytkownika, który będzie administratorem.",
+ "config-admin-name-invalid": "Podana nazwa użytkownika „<nowiki>$1</nowiki>” jest nieprawidłowa.\nPodaj inną nazwę.",
+ "config-admin-password-blank": "Wprowadź hasło dla konta administratora.",
+ "config-admin-password-mismatch": "Wprowadzone dwa hasła różnią się między sobą.",
+ "config-admin-email": "Adres e‐mail:",
+ "config-admin-email-help": "Wpisz adres e‐mail, aby mieć możliwość odbierania e‐maili od innych użytkowników wiki, zresetowania hasła oraz otrzymywania powiadomień o zmianach na stronach z listy obserwowanych. Możesz pozostawić to pole niewypełnione.",
+ "config-admin-error-user": "Błąd wewnętrzny podczas tworzenia konta administratora o nazwie „<nowiki>$1</nowiki>”.",
+ "config-admin-error-password": "Wewnętrzny błąd podczas ustawiania hasła dla administratora „<nowiki>$1</nowiki>”: <pre>$2</pre>",
+ "config-admin-error-bademail": "Wpisałeś nieprawidłowy adres e‐mail.",
+ "config-subscribe": "Zapisz się na [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce listę pocztową z ogłoszeniami o nowych wersjach].",
+ "config-subscribe-help": "Jest to lista o małej liczbie wiadomości, wykorzystywana do przesyłania informacji o udostępnieniu nowej wersji oraz istotnych sprawach dotyczących bezpieczeństwa.\nPowinieneś zapisać się na tę listę i aktualizować zainstalowane oprogramowanie MediaWiki gdy pojawia się nowa wersja.",
+ "config-subscribe-noemail": "Próbowano subskrybować listę mailingową ogłoszeń wersji bez podania adresu e-mail.\nProszę podać adres e-mail, jeśli chcesz subskrybować listę wysyłkową.",
+ "config-pingback": "Udostępnij dane o instalacji twórcom MediaWiki.",
+ "config-pingback-help": "Jeżeli wybierzesz tę opcję, MediaWiki będzie okresowo wysyłać na https://www.mediawiki.org podstawowe dane na temat tej instancji MediaWiki. Te dane zawierają np. typ systemu, wersję PHP i wybrany silnik bazy danych. Fundacja Wikimedia dzieli się tymi danymi z twórcami MediaWiki, aby pomóc w podejmowaniu dalszych wysiłków co do rozwoju. Poniższe dane o Twoim systemie zostaną wysłane:\n<pre>$1</pre>",
+ "config-almost-done": "To już prawie koniec!\nMożesz pominąć pozostałe czynności konfiguracyjne i zainstalować wiki.",
+ "config-optional-continue": "Zadaj mi więcej pytań.",
+ "config-optional-skip": "Jestem już znudzony, po prostu zainstaluj wiki.",
+ "config-profile": "Profil uprawnień użytkowników",
+ "config-profile-wiki": "Otwarte wiki",
+ "config-profile-no-anon": "Wymagane utworzenie konta",
+ "config-profile-fishbowl": "Wyłącznie zatwierdzeni edytorzy",
+ "config-profile-private": "Prywatna wiki",
+ "config-profile-help": "Strony typu wiki działają najlepiej, gdy umożliwisz ich edytowanie jak największej liczbie osób.\nW MediaWiki można łatwo sprawdzić ostatnie zmiany i wycofać szkody, spowodowane przez naiwnych lub złośliwych użytkowników.\n\nPomimo, że wielu uznało MediaWiki jako przydatne do wielu zadań, nie jest łatwo przekonać wszystkich do korzyści ze sposobu działania wiki. Masz więc wybór.\n\nUstawienie <strong>{{int:config-profile-wiki}}</strong> pozwala każdemu na edycję, nawet bez logowania się.\nWiki z <strong>{{int:config-profile-no-anon}}</strong> dostarcza szersze możliwości związane z podziałem kont użytkowników, ale może zniechęcić okazjonalnych redaktorów.\n\nScenariusz <strong>{{int:config-profile-fishbowl}}</strong> umożliwia zatwierdzonym użytkownikom edycję, ale wyświetlanie stron jest powszechnie dostępne, włącznie z historią.\nUstawienie <strong>{{int:config-profile-private}}</strong> pozwala na wyświetlanie stron tylko zatwierdzonym użytkownikom, ta sama grupa może je edytować.\n\nBardziej złożone konfiguracje uprawnień użytkowników są dostępne po zakończeniu instalacji, zobacz [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights odpowiednią część podręcznika].",
+ "config-license": "Prawa autorskie i licencja:",
+ "config-license-none": "Brak stopki z licencją",
+ "config-license-cc-by-sa": "Creative Commons – za uznaniem autora, na tych samych zasadach",
+ "config-license-cc-by": "Creative Commons – za podaniem autora",
+ "config-license-cc-by-nc-sa": "Creative Commons – za uznaniem autora, bez użycia komercyjnego, na tych samych zasadach",
+ "config-license-cc-0": "Creative Commons Zero (domena publiczna)",
+ "config-license-gfdl": "GNU licencja wolnej dokumentacji 1.3 lub nowsza",
+ "config-license-pd": "Domena publiczna",
+ "config-license-cc-choose": "Wybierz własną licencję Creative Commons",
+ "config-license-help": "Wiele publicznych wiki umieszcza wszystkie dopisane treści na [http://freedomdefined.org/Definition wolnej licencji].\nPomaga to tworzyć poczucie wspólnoty i zachęca do długoterminowego wkładu.\nNie jest to zazwyczaj konieczne w prywatnych lub firmowych wiki.\n\nJeśli chcesz móc użyć tekstu z Wikipedii i chcesz Wikipedia mogła zaakceptować tekst skopiowany z twojej wiki, należy wybrać <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia używała poprzednio GNU Free Documentation License.\nGFDL jest poprawną licencję, ale trudno ją zrozumieć.\nTrudno także ponowne użyć zawartości na licencji GFDL.",
+ "config-email-settings": "Ustawienia e-maili",
+ "config-enable-email": "Włącz wychodzące wiadomości e–mail",
+ "config-enable-email-help": "Jeśli chcesz, aby działał e-mail, [http://www.php.net/manual/en/mail.configuration.php Ustawienia poczty PHP] muszą być poprawnie wprowadzone.\nJeśli nie chcesz jakichś funkcji poczty e-mail, można je wyłączyć tutaj.",
+ "config-email-user": "Włącz możliwość przesyłania e‐maili pomiędzy użytkownikami",
+ "config-email-user-help": "Zezwalaj użytkownikom na wysyłanie wzajemnie e‐maili, jeśli będą mieć włączoną tę funkcję w swoich preferencjach.",
+ "config-email-usertalk": "Włącz powiadamianie o zmianach na stronie dyskusji użytkownika",
+ "config-email-usertalk-help": "Pozwól użytkownikom otrzymywać powiadomienia o zmianach na stronie dyskusji użytkownika, jeśli będą mieć włączoną tę funkcję w swoich preferencjach.",
+ "config-email-watchlist": "Włącz powiadomienie o zmianach stron obserwowanych",
+ "config-email-watchlist-help": "Pozwól użytkownikom otrzymywać powiadomienia o zmianach na stronach obserwowanych, jeśli będą mieć włączoną tę funkcję w swoich preferencjach.",
+ "config-email-auth": "Włącz uwierzytelnianie e‐mailem",
+ "config-email-auth-help": "Jeśli ta opcja jest włączona, użytkownicy będą musieli potwierdzić swoje adresy e-mail przy użyciu wysłanego do nich łącza, gdy będą je ustawiać lub zmieniać.\nTylko uwierzytelnione adresy e-mail mogą otrzymywać wiadomości od innych użytkowników lub mailowe powiadomienia o zmianach.\nUstawienie tej opcji jest'''zalecane''' na publicznych wiki ze względu na potencjalne nadużycia funkcji poczty e-mail.",
+ "config-email-sender": "Zwrotny adres e‐mail",
+ "config-email-sender-help": "Wprowadź adres e-mail używany jako adres zwrotny wiadomości wychodzących.\nTo tam będą wysyłane szturchnięcia.\nWiele serwerów poczty wymaga, by co najmniej część nazwy domeny była prawidłowa.",
+ "config-upload-settings": "Przesyłanie obrazków i plików",
+ "config-upload-enable": "Włącz przesyłanie plików na serwer",
+ "config-upload-help": "Przesyłanie plików potencjalnie wystawia serwer na zagrożenia.\nWięcej informacji na ten temat można znaleźć w [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security sekcji zabezpieczeń] podręcznika.\n\nAby włączyć przesyłanie plików, zmień właściwości podkatalogu <code>images</code> katalogu głównego MediaWiki tak, aby serwer sieci web mógł zapisywać do niego.\nNastępnie włącz tę opcję.",
+ "config-upload-deleted": "Katalog dla usuniętych plików",
+ "config-upload-deleted-help": "Wybierz katalog, w którym będzie archiwum usuniętych plików.\nNajlepiej, aby nie był on dostępny z internetu.",
+ "config-logo": "Adres URL logo:",
+ "config-logo-help": "Domyślny motyw MediaWiki zawiera miejsce na logo wielkości 135 x 160 pikseli powyżej menu na pasku bocznym.\nPrześlij obrazek o odpowiednim rozmiarze, a następnie wpisz jego URL tutaj.\n\nMożesz użyć <code>$wgStylePath</code> lub <code>$wgScriptPath</code> jeżeli twoje logo jest relatywne do tych ścieżek.\n\nJeśli nie chcesz logo, pozostaw to pole puste.",
+ "config-instantcommons": "Włącz Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] jest funkcją, która pozwala wiki używać obrazów, dźwięków i innych mediów znalezionych na witrynie [https://commons.wikimedia.org/ Wikimedia Commons].\nAby to zrobić, MediaWiki wymaga dostępu do Internetu.\n\nAby uzyskać więcej informacji na temat tej funkcji, w tym instrukcje dotyczące sposobu ustawiania go na wiki innych niż Wikimedia Commons, sprawdź w [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos podręczniku].",
+ "config-cc-error": "Wybieranie licencji Creative Commons nie dało wyniku.\nWpisz nazwę licencji ręcznie.",
+ "config-cc-again": "Wybierz jeszcze raz...",
+ "config-cc-not-chosen": "Wybierz, którą chcesz licencję Creative Commons i kliknij „proceed”.",
+ "config-advanced-settings": "Konfiguracja zaawansowana",
+ "config-cache-options": "Ustawienia buforowania obiektów:",
+ "config-cache-help": "Buforowanie obiektów jest używane do przyspieszenia MediaWiki przez trzymanie w pamięci podręcznej często używanych danych.\nŚrednie oraz duże witryny są wysoce zachęcane by je włączyć, ale małe witryny również dostrzegą korzyści.",
+ "config-cache-none": "Brak buforowania (wszystkie funkcje będą działać, ale mogą wystąpić kłopoty z wydajnością na dużych witrynach wiki)",
+ "config-cache-accel": "Buforowania obiektów PHP (APC, APCu, XCache lub WinCache)",
+ "config-cache-memcached": "Użyj Memcached (wymaga dodatkowej instalacji i konfiguracji)",
+ "config-memcached-servers": "Serwery Memcached:",
+ "config-memcached-help": "Lista adresów IP do wykorzystania przez Memcached.\nAdresy powinny być umieszczane po jednym w linii i określać również wykorzystywany port. Na przykład:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Został wybrany Memcached jako typ pamięci podręcznej, ale nie określono żadnych serwerów.",
+ "config-memcache-badip": "Wprowadzono nieprawidłowy adres IP dla Memcached: $1.",
+ "config-memcache-noport": "Nie określono portu dla serwera Memcached: $1.\nJeśli nie znasz numeru portu, wartością domyślną jest 11211.",
+ "config-memcache-badport": "Numery portu Memcached powinny zawierać się pomiędzy $1 i $2.",
+ "config-extensions": "Rozszerzenia",
+ "config-extensions-help": "Rozszerzenia wyżej wymienione zostały wykryte w katalogu <code>./extensions</code>.\n\nMogą one wymagać dodatkowych czynności konfiguracyjnych, ale można je teraz włączyć",
+ "config-skins": "Skórki",
+ "config-skins-help": "Powyższe skórki zostały wykryte w twoim katalogi <code>./skins</code>. Należy włączyć co najmniej jedną i wybrać domyślną.",
+ "config-skins-use-as-default": "Użyj tej skórki jako domyślnej",
+ "config-skins-missing": "Nie znaleziono skórki; MediaWiki będzie używać rezerwowej skórki do czasu zainstalowania odpowiednich.",
+ "config-skins-must-enable-some": "Musisz wybrać co najmniej jedną skórkę, aby ją włączyć.",
+ "config-skins-must-enable-default": "Skórka wybrana jako domyślna musi być włączona.",
+ "config-install-alreadydone": "'''Uwaga''' – wydaje się, że MediaWiki jest już zainstalowane, a obecnie próbujesz zainstalować je ponownie.\nPrzejdź do następnej strony.",
+ "config-install-begin": "Po naciśnięciu \"{{int:config-continue}}\", rozpocznie się instalacja MediaWiki.\nJeśli nadal chcesz dokonać zmian, naciśnij \"{{int:config-back}}\".",
+ "config-install-step-done": "gotowe",
+ "config-install-step-failed": "nieudane",
+ "config-install-extensions": "Dołączanie rozszerzeń",
+ "config-install-database": "Konfigurowanie bazy danych",
+ "config-install-schema": "Tworzenie schematu",
+ "config-install-pg-schema-not-exist": "Schemat PostgreSQL nie istnieje.",
+ "config-install-pg-schema-failed": "Utworzenie tabel nie powiodło się.\nUpewnij się, że użytkownik „$1” może zapisywać do schematu „$2”.",
+ "config-install-pg-commit": "Zatwierdzanie zmian",
+ "config-install-pg-plpgsql": "Sprawdzanie języka PL/pgSQL",
+ "config-pg-no-plpgsql": "Musisz zainstalować język PL/pgSQL w bazie danych $1",
+ "config-pg-no-create-privs": "Konto, które zostało określone dla instalacji nie ma wystarczających uprawnień, aby utworzyć konto.",
+ "config-pg-not-in-role": "Konto określone dla użytkownika sieci już istnieje.\nKonto określone dla instalacji nie ma uprawnień administratora, ani nie jest przynależy do roli użytkownika sieci web, więc nie można utworzyć obiektów stanowiących własność użytkownika sieci.\n\nMediaWiki obecnie wymaga, aby tabele były własnością konta zwykłego użytkownika. Podaj inną nazwę konta użytkownika albo kliknij przycisk „Wstecz” i podaj nazwę konta użytkownika instalatora, które posiada odpowiednie uprawnienia.",
+ "config-install-user": "Tworzenie użytkownika bazy danych",
+ "config-install-user-alreadyexists": "Konto użytkownika „$1” już istnieje",
+ "config-install-user-create-failed": "Tworzenie użytkownika \"$1\" nie powiodło się: $2",
+ "config-install-user-grant-failed": "Przyznanie uprawnień użytkownikowi „$1” nie powiodło się – $2",
+ "config-install-user-missing": "Nie istnieje konto użytkownika „$1”.",
+ "config-install-user-missing-create": "Określony użytkownik \"$1\" nie istnieje.\nKliknij poniższe pole wyboru „utwórz konto\" jeśli chcesz go utworzyć.",
+ "config-install-tables": "Tworzenie tabel",
+ "config-install-tables-exist": "'''Uwaga''' – wygląda na to, że tabele MediaWiki już istnieją.\nPomijam tworzenie tabel.",
+ "config-install-tables-failed": "'''Błąd''' – tworzenie tabeli nie powiodło się z powodu błędu – $1",
+ "config-install-interwiki": "Wypełnianie tabeli domyślnymi interwiki",
+ "config-install-interwiki-list": "Nie można odnaleźć pliku <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Uwaga''' – wygląda na to, że tabela interwiki ma już jakieś wpisy.\nTworzenie domyślnej listy pominięto.",
+ "config-install-stats": "Inicjowanie statystyki",
+ "config-install-keys": "Generowanie tajnych kluczy",
+ "config-insecure-keys": "'''Ostrzeżenie:''' {{PLURAL:$2|Klucz bezpieczeństwa|Klucze bezpieczeństwa|Klucze bezpieczeństwa}} ($1) utworzone podczas instalacji {{PLURAL:$2|utworzony podczas instalacji nie jest|utworzone podczas instalacji nie są|utworzone podczas instalacji nie są}} w pełni bezpieczne. Być może warto wygenerować {{PLURAL:$2|własny klucz|własne klucze|własne klucze}}.",
+ "config-install-updates": "Zapobieganie uruchamianiu niepotrzebnych aktualizacji",
+ "config-install-updates-failed": "<strong>Błąd:</strong> Wstawianie kluczy aktualizacji d0 tabeli nie powiodło się z powodu następującego błędu: $1",
+ "config-install-sysop": "Tworzenie konta administratora",
+ "config-install-subscribe-fail": "Nie można zapisać na listę „mediawiki-announce” – $1",
+ "config-install-subscribe-notpossible": "cURL nie jest zainstalowany, więc <code>allow_url_fopen</code> nie jest dostępne.",
+ "config-install-mainpage": "Tworzenie strony głównej z domyślną zawartością",
+ "config-install-mainpage-exists": "Strona główna już istnieje, pomijanie",
+ "config-install-extension-tables": "Tworzenie tabel dla aktywnych rozszerzeń",
+ "config-install-mainpage-failed": "Nie udało się wstawić strony głównej: $1",
+ "config-install-done": "<strong>'''Gratulacje!</strong>\nUdało Ci się zainstalować MediaWiki.\n\nInstalator wygenerował plik konfiguracyjny <code>LocalSettings.php</code>.\n\nMusisz go pobrać i umieścić w katalogu głównym Twojej instalacji wiki (tym samym katalogu co index.php). Pobieranie powinno zacząć się automatycznie.\n\nJeżeli pobieranie nie zostało zaproponowane lub jeśli użytkownik je anulował, można ponownie uruchomić pobranie klikając poniższe łącze:\n\n$3\n\n<strong>Uwaga</strong>: Jeśli nie zrobisz tego teraz, wygenerowany plik konfiguracyjny nie będzie już dostępny po zakończeniu instalacji.\n\nPo załadowaniu pliku konfiguracyjnego możesz <strong>[$2 wejść na wiki]</strong>.",
+ "config-install-done-path": "<strong>Gratulacje!</strong>\nZainstalowałeś właśnie MediaWiki.\n\nInstalator wygenerował plik <code>LocalSettings.php</code>.\nZawiera całą Twoją konfigurację.\n\nMusisz go pobrać i umieścić w <code>$4</code>. Pobieranie powinno rozpocząć się automatycznie.\n\nJeżeli nie pojawiła się informacja o pobieraniu lub jeżeli ja anulowałeś, kliknij poniższy link:\n\n$3\n\n<strong>Uwaga:</strong> Jeżeli nie zrobisz tego teraz, wygenerowany plik konfiguracyjny nie będzie potem dostępny, jeżeli wyjdziesz z instalacji bez jego pobrania.\n\nGdy to będzie zrobione, możesz <strong>[$2 wejść na swoją wiki]</strong>.",
+ "config-download-localsettings": "Pobierz <code>LocalSettings.php</code>",
+ "config-help": "pomoc",
+ "config-help-tooltip": "kliknij, aby rozwinąć",
+ "config-nofile": "Nie udało się odnaleźć pliku \"$1\". Czy nie został usunięty?",
+ "config-extension-link": "Czy wiesz, że twoja wiki obsługuje [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions rozszerzenia]?\n\nMożesz przejrzeć [https://www.mediawiki.org/wiki/Category:Extensions_by_category rozszerzenia według kategorii] lub [https://www.mediawiki.org/wiki/Extension_Matrix Extension Matrix], aby zobaczyć pełną listę rozszerzeń.",
+ "mainpagetext": "<strong>Instalacja MediaWiki powiodła się.</strong>",
+ "mainpagedocfooter": "Zapoznaj się z [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Podręcznikiem użytkownika] zawierającym informacje o tym jak korzystać z oprogramowania wiki.\n\n== Na początek ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista ustawień konfiguracyjnych]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Komunikaty o nowych wersjach MediaWiki (lista dyskusyjna)]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Przetłumacz MediaWiki na swój język]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Dowiedz się, jak walczyć ze spamem na swojej wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/pms.json b/www/wiki/includes/installer/i18n/pms.json
new file mode 100644
index 00000000..e97db426
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pms.json
@@ -0,0 +1,288 @@
+{
+ "@metadata": {
+ "authors": [
+ "Borichèt",
+ "Dragonòt",
+ "Krinkle",
+ "아라",
+ "Amire80",
+ "Macofe",
+ "Seb35"
+ ]
+ },
+ "config-desc": "L'instalador për mediaWiki",
+ "config-title": "Anstalassion ëd MediaWiki $1",
+ "config-information": "Anformassion",
+ "config-localsettings-upgrade": "A l'é stàit trovà n'archivi <code>LocalSettings.php</code>.\nPër agiorné cost'anstalassion, ch'a anserissa ël valor ëd <code>$wgUpgradeKey</code> ant la casela sì-sota.\nA la trovrà an LocalSetting.php.",
+ "config-localsettings-cli-upgrade": "N'archivi <code>LocalSettings.php</code> a l'é stàit trovà.\nPër agiorné sta instalassion, për piasì fà anvece giré <code>update.php</code>",
+ "config-localsettings-key": "Ciav d'agiornament:",
+ "config-localsettings-badkey": "La ciav ch'it l'has dàit a l'é pa giusta.",
+ "config-upgrade-key-missing": "A l'é stàita trovà n'istalassion esistenta ëd MediaWiki.\nPër agiorné soa istalassion, për piasì ch'a buta la linia sì-sota al fond ëd sò <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "L'esistent <code>LocalSettings.php</code> a smija esse ancomplet.\nLa variàbil $1 a l'é nen ampostà.\nPër piasì, ch'a modìfica <code>LocalSettings.php</code> ëd fasson che costa variàbil a sia ampostà, e ch'a sgnaca «{{int:Config-continue}}».",
+ "config-localsettings-connection-error": "A l'é ancapitaje n'eror an colegand-se a la base ëd dàit an dovrand j'ampostassion specificà an <code>LocalSettings.php</code>. Për piasì, ch'a coregia cost'ampostassion e ch'a preuva torna.\n\n$1",
+ "config-session-error": "Eror an fasend parte la session: $1",
+ "config-session-expired": "Ij sò dat ëd session a smijo scadù.\nLe session a son configurà për na durà ëd $1.\nA peul aumenté sòn an ampostand <code>session.gc_maxlifetime</code> an php.ini.\nCh'a anandia torna ël process d'instalassion.",
+ "config-no-session": "Ij sò dat ëd session a son përdù!\nCh'a contròla sò php.ini e ch'as sigura che <code>session.save_path</code> a sia ampostà ant ël dossié giust.",
+ "config-your-language": "Toa lenga:",
+ "config-your-language-help": "Selessioné na lenga da dovré durant ël process d'instalassion.",
+ "config-wiki-language": "Lenga dla Wiki:",
+ "config-wiki-language-help": "Selession-a la lenga dont la wiki a sarà prevalentement scrivùa.",
+ "config-back": "← André",
+ "config-continue": "Continua →",
+ "config-page-language": "Lenga",
+ "config-page-welcome": "Bin ëvnù a MediaWiki!",
+ "config-page-dbconnect": "Coleghesse a la base ëd dàit",
+ "config-page-upgrade": "Agiorné l'instalassion esistenta",
+ "config-page-dbsettings": "Ampostassion dla base ëd dàit",
+ "config-page-name": "Nòm",
+ "config-page-options": "Opsion",
+ "config-page-install": "Instala",
+ "config-page-complete": "Completa!",
+ "config-page-restart": "Fé torna parte l'instalassion",
+ "config-page-readme": "Lesme",
+ "config-page-releasenotes": "Nòte ëd publicassion",
+ "config-page-copying": "Copié",
+ "config-page-upgradedoc": "Agiorné",
+ "config-page-existingwiki": "Wiki esistenta",
+ "config-help-restart": "Veul-lo scancelé tùit ij dat salvà ch'a l'ha anserì e anandié torna ël process d'instalassion?",
+ "config-restart": "É!, felo torna parte",
+ "config-welcome": "=== Contròj d'ambient ===\nDle verìfiche ëd base a saran adess fàite për vëdde se st'ambient a va bin për l'instalassion ëd MediaWiki.\nCh'as visa d'anserì coste anformassion s'a sërca d'agiut su com completé l'instalassion.",
+ "config-copyright": "=== Drit d'Autor e Condission ===\n\n$1\n\nCost-sì a l'é un programa lìber e a gràtis: a peul ridistribuilo e/o modifichelo sota le condission dla licensa pùblica general GNU com publicà da la Free Software Foundation; la version 2 dla Licensa, o (a toa sèrnìa) qualsëssìa version pi recenta.\n\nCost programa a l'é distribuì ant la speransa ch'a sia ùtil, ma '''sensa gnun-e garansìe'''; sensa gnanca la garansia implìssita ëd '''comersiabilità''' o '''d'esse adat a un but particolar'''.\n\nA dovrìa avèj arseivù <doclink href=Copying>na còpia ëd la licensa pùblica general GNU</doclink> ansema a sto programa; dësnò, ch'a scriva a la Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA opura [http://www.gnu.org/copyleft/gpl.html ch'a la lesa an linia].",
+ "config-sidebar": "* [https://www.mediawiki.org Intrada MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guida dl'Utent]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Guida dl'Aministrator]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Soens an ciamo]\n----\n* <doclink href=Readme>Ch'am lesa</doclink>\n* <doclink href=ReleaseNotes>Nòte ëd publicassion</doclink>\n* <doclink href=Copying>Còpia</doclink>\n* <doclink href=UpgradeDoc>Agiornament</doclink>",
+ "config-env-good": "L'ambient a l'é stàit controlà.\nIt peule instalé MediaWiki.",
+ "config-env-bad": "L'ambient a l'é stàit controlà.\nIt peule pa instalé MediaWiki.",
+ "config-env-php": "PHP $1 a l'é instalà.",
+ "config-env-hhvm": "HHVM $1 a l'é instalà.",
+ "config-unicode-using-intl": "As deuvra l'[http://pecl.php.net/intl estension intl PECL] për la normalisassion Unicode.",
+ "config-unicode-pure-php-warning": "'''Avis:''' L'[http://pecl.php.net/intl estension intl PECL] a l'é pa disponìbil për gestì la normalisassion Unicode, da già che l'implementassion an PHP pur a faliss për lentëssa.\nS'a gestiss un sit a àut tràfich, a dovrìa lese cheicòs an sla [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalisassion Unicode].",
+ "config-unicode-update-warning": "'''Avis:''' La version instalà dlë spassiador ëd normalisassion Unicode a deuvra na version veja ëd la librarìa dël [http://site.icu-project.org/ proget ICU].\nA dovrìa fé n'[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations agiornament] s'a l'é anteressà a dovré Unicode.",
+ "config-no-db": "Impossìbil trové un pilòta ëd base ëd dàit bon! A dev instalé un pilòta ëd base ëd dàit për PHP.\n{{PLURAL:$2|La sòrt ëd base ëd dàit mantnùa a l'é costa|Le sòrt ëd base ëd dàit mantùe a son coste}} sì-dapress: $1.\n\nS'a l'é compilasse PHP chiel-midem, ch'a lo configura torna con un client ëd base ëd dàit abilità, për esempi an dovrand <code>./configure --with-mysqli</code>.\nS'a l'ha instalà PHP dai pachèt Debian o Ubuntu, antlora a dev ëdcò anstalé, për esempi, ël mòdul <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Avis''': chiel a l'ha SQLite $1, che a l'é pi vej che la version mìnima dont a-i é damanca $2. SQLite a sarà pa disponìbil.",
+ "config-no-fts3": "'''Avis''': SQLite a l'é compilà sensa ël mòdul [//sqlite.org/fts3.html FTS3], le funsion d'arserca a saran pa disponìbij su cost motor.",
+ "config-pcre-no-utf8": "'''Fatal''': ël mòdul PCRE ëd PHP a smija esse compilà sensa l'apògg PCRE_UTF8.\nMediaWiki a ciama l'apògg d'UTF8 për marcé për da bin.",
+ "config-memory-raised": "<code>memory_limit</code> ëd PHP a l'é $1, aussà a $2.",
+ "config-memory-bad": "'''Avis:''' <code>memory_limit</code> ëd PHP a l'é $1.\nSossì a l'é probabilment tròp bass.\nL'instalassion a peul falì!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] a l'é instalà",
+ "config-apc": "[http://www.php.net/apc APC] a l'é instalà",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] a l'é instalà",
+ "config-mod-security": "'''Avis''': Sò servent për l'aragnà a l'ha [http://modsecurity.org/ mod_security] abilità. Se mal configurà, a peul causé dij problema për MediaWiki o d'àutri programa ch'a përmëtto a j'utent dë spedì un contnù qualsëssìa.\nCh'a fasa arferiment a la [http://modsecurity.org/documentation/ mod_security documentassion] o ch'a contata l'echip ëd sò servissi s'a-j rivo dj'eror casuaj.",
+ "config-diff3-bad": "GNU diff3 pa trovà.",
+ "config-imagemagick": "Trovà ImageMagick: <code>$1</code>.\nLa miniaturisassion ëd figure a sarà abilità s'it abìlite le carie.",
+ "config-gd": "Trovà la librarìa gràfica antëgrà GD.\nLa miniaturisassion ëd figure a sarà abilità s'a abìlita ij cariament.",
+ "config-no-scaling": "As treuva pa la librarìa GD o ImageMagick.\nLa miniaturisassion ëd figure a sarà disabilità.",
+ "config-no-uri": "'''Eror:''' As peul pa determiné l'URI corenta.\nInstalassion abortìa.",
+ "config-no-cli-uri": "'''Avis''': pa gnun <code>--scriptpath</code> specificà, a sarà dovrà ël predefinì: <code>$1</code>.",
+ "config-using-server": "Utilisassion dël nòm ëd servent \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Utilisassion ëd l'anliura ëd servent «<nowiki>$1$2</nowiki>».",
+ "config-uploads-not-safe": "'''Avis:''' Sò dossié stàndard për carié <code>$1</code> a l'é vulneràbil a l'esecussion ëd qualsëssìa senari.\nBele che MediaWiki a contròla j'aspet ëd sicurëssa ëd tùit j'archivi carià, a l'é motobin arcomandà ëd [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security saré ës përtus ëd sicurëssa] prima d'abilité ij cariament.",
+ "config-no-cli-uploads-check": "'''Avis:''' Toa cartela predefinìa për j-amportassion (<code>$1</code>) a l'é nen controlà a propòsit ëd la vulnerabilità\nd'esecussion ëd senari arbitrari durant l'istalassion CLI.",
+ "config-brokenlibxml": "Sò sistema a l'ha na combinassion ëd version PHP e libxml2 che a l'ha dij bigat e a peul provoché la corussion ëd dat ëstërmà an MediaWiki e d'àutre aplicassion për l'aragnà.\nCh'a agiorna a PHP 5.2.9 o pi neuv e libxml2 2.7.3 o pi neuv ([https://bugs.php.net/bug.php?id=45996 bigat archivià con PHP]).\nAnstalassion abortìa.",
+ "config-suhosin-max-value-length": "Suhosin a l'é instalà e a lìmita la longheur dël paràmetr GET a $1 byte. Ël component ResourceLoader ëd MediaWiki a travajerà an rispetand ës lìmit, ma sòn a degraderà le prestassion. Se possìbil, a dovrìa amposté suhosin.get.max_value_lenght a 1024 o pi àut an <code>php.ini</code>, e amposté <code>$wgResourceLoaderMaxQueryLength</code> al midem valor an LocalSettings.php .",
+ "config-db-type": "Sòrt ëd base ëd dàit:",
+ "config-db-host": "Ospitant ëd la base ëd dàit:",
+ "config-db-host-help": "Se sò servent ëd base ëd dàit a l'é su un servent diferent, ch'a anserissa ambelessì ël nòm dl'ospitant o l'adrëssa IP.\n\nS'a deuvra n'ospitalità partagià, sò fornidor d'ospitalità a dovrìa deje ël nòm dl'ospitant giust ant soa documentassion.\n\nSe a anstala su un servent Windows e a deuvra MySQL, dovré «localhost» a podrìa funsioné nen com nòm dël servent. S'a marcia nen, ch'a preuva «127.0.0.1» com adrëssa IP local.\n\nS'a deuvra PostgresSQL, ch'a lassa sto camp bianch për coleghesse a travers un socket UNIX.",
+ "config-db-host-oracle": "TNS dla base ëd dàit:",
+ "config-db-host-oracle-help": "Anserì un [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm nòm ëd conession local] bon; n'archivi tnsnames.ora a dev esse visìbil da costa anstalassion..<br />S'a deuvra le librarìe cliente 10g o pi neuve a peul ëdcò dovré ël métod ëd nominassion [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identìfica sta wiki",
+ "config-db-name": "Nòm dla base ëd dàit:",
+ "config-db-name-help": "Ch'a serna un nòm ch'a identìfica soa wiki.\nA dovrìa conten-e gnun ëspassi.\n\nS'a deuvra n'ospitalità partagià, sò fornidor ëd l'ospitalità a-j darà un nòm ëd base ëd dàit specìfich da dovré o a lassrà ch'a lo crea via un panel ëd contròl.",
+ "config-db-name-oracle": "Schema dla base ëd dàit:",
+ "config-db-account-oracle-warn": "A-i é tre possibilità mantnùe për istalé Oracle tanme terminal ëd base ëd dàit:\n\nS'a veul creé un cont ëd base ëd dàit com part dël process d'istalassion, për piasì ch'a fornissa un cont con ël ròl SYSDBA com cont ëd base ëd dàit për l'istalassion e ch'a specìfica le credensiaj vorsùe për ël cont d'acess an sl'aragnà, dësnò a peul ëdcò creé ël cont d'acess an sl'aragnà manualment e mach fornì col cont (se a l'ha ij përmess necessari për creé j'oget dë schema) o fornì doi cont diferent, un con ij privilegi ëd creé e un limità për l'acess an sla Ragnà.\n\nIj senari për creé un cont con ij privilegi necessari a peul esse trovà ant la cartela «manutension/oracol/» ëd costa istalassion. Ch'a ten-a da ment che dovrand un cont limità a disabiliterà tute le funsion ëd manutension con ël cont predefinì.",
+ "config-db-install-account": "Cont d'utent për l'instalassion.",
+ "config-db-username": "Nòm d'utent dla base ëd dàit:",
+ "config-db-password": "Ciav dla base ëd dàit:",
+ "config-db-install-username": "Ch'a nserissa lë stranòm che a sarà dovrà për coleghesse a la base ëd dàit durant ël process d'istalassion.\nCost-sì a l'é nen lë stranòm dël cont MediaWiki; a l'é lë stranòm për soa base ëd dàit.",
+ "config-db-install-password": "Ch'a anserissa la ciav che a sarà dovrà për coleghesse a la base ëd dàit durant ël process d'istalassion.\nCosta-sì a l'é nen la ciav dël cont MediaWiki; a l'é la ciav për soa base ëd dàit.",
+ "config-db-install-help": "Ch'a anserissa lë stranòm d'utent e la ciav che a saran dovrà për coleghesse a la base ëd dàit durant ël process d'instalassion.",
+ "config-db-account-lock": "Dovré ij midem stranòm d'utent e ciav durant j'operassion normaj",
+ "config-db-wiki-account": "Cont d'utent për j'operassion normaj",
+ "config-db-wiki-help": "Ch'a anseriss lë stranòm d'utent e la ciav che a saran dovrà për coleghesse a la base ëd dàit durant j'operassion normaj dla wiki.\nS'ël cont a esist pa, e ël cont d'instalassion a l'ha ij privilegi ch'a-i van, sto cont utent a sarà creà con ij privilegi mìnin për fé marcé la wiki.",
+ "config-db-prefix": "Prefiss dle tàule dla base ëd dàit:",
+ "config-db-prefix-help": "S'a l'ha dabzògn ëd partagé na base ëd dàit an tra vàire wiki, o tra MediaWiki e n'àutra aplicassion dl'aragnà, a peul serne ëd gionté un prefiss a tùit ij nòm ëd le tàule për evité ëd conflit.\nCh'a deuvra pa dë spassi.\n\nCost camp a l'é lassà normalment veuid.",
+ "config-mysql-old": "A-i é da manca ëd MySQL $1 o pi recent, chiel a l'ha $2.",
+ "config-db-port": "Porta dla base ëd dàit:",
+ "config-db-schema": "Schema për MediaWiki",
+ "config-db-schema-help": "Lë schema sì-sota a l'é ëd sòlit giust.\nCh'a lo cangia mach s'a sa ch'a n'ha da manca.",
+ "config-pg-test-error": "Impossìbil coleghesse a la base ëd dàit '''$1'''; $2",
+ "config-sqlite-dir": "Dossié dij dat SQLite:",
+ "config-sqlite-dir-help": "SQLite a memorisa tùit ij dat ant n'archivi ùnich.\n\nËl dossié che chiel a forniss a dev esse scrivìbil dal servent durant l'instalassion.\n\nA dovrìa '''pa''' esse acessìbil da l'aragnà, sossì a l'é për sòn ch'i l'oma pa butalo andova a-i son ij sò file PHP.\n\nL'instalador a scriverà n'archivi <code>.htaccess</code> ansema con chiel, ma se lòn a faliss quaidun a peul intré an soa base ëd dàit originaria.\nLòn a comprend ij dat brut ëd l'utent (adrëssa ëd pòsta eletrònica, ciav tërbola) e ëdcò le revision scancelà e d'àutri dat segret ëd la wiki.\n\nCh'a consìdera ëd buté la base ëd dàit tuta antrega da n'àutra part, për esempi an <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Spassi dla tàula dë stàndard:",
+ "config-oracle-temp-ts": "Spassi dla tàula temporani:",
+ "config-support-info": "MediaWiki a manten ij sistema ëd base ëd dàit sì-dapress:\n\n$1\n\nS'a vëd pa listà sì-sota ël sistema ëd base ëd dàit ch'a preuva a dovré, antlora va andaré a j'istrussion dl'anliura sì-dzora për abilité ël manteniment.",
+ "config-dbsupport-mysql": "* $1 e l'é l'obietiv primari për MediaWiki e a l'é mej mantnù ([http://www.php.net/manual/en/mysql.installation.php com compilé PHP con ël manteniment MySQL])",
+ "config-dbsupport-postgres": "* $1 e l'é un sistema ëd base ëd dàit popolar a sorgiss duverta com alternativa a MySQL ([http://www.php.net/manual/en/pgsql.installation.php com compilé PHP con ël manteniment ëd PostgreSQL]). A peulo ess-ie chèich cit bigat, e a l'é nen arcomandà ëd dovrelo an n'ambient ëd produssion.",
+ "config-dbsupport-sqlite": "* $1 e l'é un sistema ëd base ëd dàit leger che a l'é motobin bin mantnù ([http://www.php.net/manual/en/pdo.installation.php com compilé PHP con ël manteniment ëd SQLite], a deuvra PDO)",
+ "config-dbsupport-oracle": "* $1 a l'é na base ëd dàit comersial për j'amprèise. ([http://www.php.net/manual/en/oci8.installation.php Com compilé PHP con ël manteniment OCI8])",
+ "config-header-mysql": "Ampostassion MySQL",
+ "config-header-postgres": "Ampostassion PostgreSQL",
+ "config-header-sqlite": "Ampostassion SQLite",
+ "config-header-oracle": "Ampostassion Oracle",
+ "config-invalid-db-type": "Sòrt ëd ëd base ëd dàit pa bon-a",
+ "config-missing-db-name": "A dev buteje un valor për \"Nòm ëd la base ëd dàit\"",
+ "config-missing-db-host": "A dev buteje un valor për \"l'òspit ëd la base ëd dàit\"",
+ "config-missing-db-server-oracle": "A dev buteje un valor për \"TNS ëd la base ëd dat\"",
+ "config-invalid-db-server-oracle": "TNS ëd la base ëd dat pa bon \"$1\".\nDovré mach dle litre ASCII (a-z, A-Z), nùmer (0-9), sotlignadure (_) e pontin (.).",
+ "config-invalid-db-name": "Nòm ëd la base ëd dàit pa bon \"$1\".\nDovré mach litre ASCII (a-z, A-Z), nùmer (0-9), sotlignadure (_) e tratin (-).",
+ "config-invalid-db-prefix": "Prefiss dla base ëd dàit pa bon \"$1\".\nDovré mach litre ASCII (a-z, A-Z), nùmer (0-9), sotlignadure (_) e tratin (-).",
+ "config-connection-error": "$1.\n\nControla l'ospitant, lë stranòm d'utent e la ciav sì-sota e prové torna.",
+ "config-invalid-schema": "Schema pa bon për MediaWiki \"$1\".\nDovré mach litre ASCII (a-z, A-Z), nùmer (0-9) e sotlignadure (_).",
+ "config-db-sys-create-oracle": "L'istalador a arconòss mach ij cont SYSDBA durant la creassion d'un cont neuv.",
+ "config-db-sys-user-exists-oracle": "Ël cont utent \"$1\" a esist già. SYSDBA a peul mach esse dovrà për creé un cont neuv!",
+ "config-postgres-old": "A-i é da manca ëd PostgreSQL $1 o pi recent, chiel a l'ha $2.",
+ "config-sqlite-name-help": "Serne un nòm ch'a identìfica soa wiki.\nDovré nì dë spassi nì ëd tratin.\nSòn a sarà dovrà për ël nòm ëd l'archivi ëd dat SQLite.",
+ "config-sqlite-parent-unwritable-group": "As peul pa creesse ël dossié ëd dat <code><nowiki>$1</nowiki></code>, përchè ël dossié a mont <code><nowiki>$2</nowiki></code> a l'é pa scrivìbil dal servent.\n\nL'instalador a l'ha determinà sota che utent a gira sò servent.\nFé an manera che ël dossié <code><nowiki>$3</nowiki></code> a sia scrivìbil da chiel për continué.\nSu un sistema Unix/Linux buté:\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "As peul pa creesse ël dossié ëd dat <code><nowiki>$1</nowiki></code>, përchè ël dossié a mont <code><nowiki>$2</nowiki></code> a l'é pa scrivìbil dal servent.\n\nL'instalador a peul pa determiné l'utent sota ël qual a gira sò servent.\nFé an manera che ël dossié <code><nowiki>$3</nowiki></code> a sia scrivìbil globalment da chiel (e da d'àutri) për continué.\nSu un sistema Unix/Linux buté:\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Eror an creand ël dossié ëd dat \"$1\".\nCh'a contròla la locassion e ch'a preuva torna.",
+ "config-sqlite-dir-unwritable": "As peul pa scrivse an sël dossié \"$1\".\nModifiché ij sò përmess an manera che ël servent a peula scrivje ansima, e prové torna.",
+ "config-sqlite-connection-error": "$1.\n\nControlé ël dossié ëd dat e ël nòm ëd la base ëd dàit ambelessì-sota e prové torna.",
+ "config-sqlite-readonly": "L'archivi <code>$1</code> a l'é nen scrivìbil.",
+ "config-sqlite-cant-create-db": "As peul pa cresse l'archivi ëd base ëd dàit <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP a l'ha pa ël supòrt ëd FTS3, le tàule a son degradà",
+ "config-can-upgrade": "A-i é dle tàule MediaWiki an costa base ëd dàit.\nPër agiorneje a MediaWiki $1, ch'a sgnaca su '''Continué'''.",
+ "config-upgrade-done": "Agiornament completà.\n\nAdess a peule [$1 ancaminé a dovré soa wiki].\n\nS'a veul generé torna sò archivi <code>LocalSettings.php</code>, ch'a sgnaca ël boton sì-sota.\nSòn a l'è '''pa arcomandà''' gavà ch'a rancontra dij problema con soa wiki.",
+ "config-upgrade-done-no-regenerate": "Agiornament complet.\n\nIt peule adess [$1 ancaminé a dovré toa wiki].",
+ "config-regenerate": "Generé torna LocalSettings.php →",
+ "config-show-table-status": "Arcesta <code>SHOW TABLE STATUS</code> falìa!",
+ "config-unknown-collation": "'''Avis:''' La base ëd dàit a deuvra na classificassion pa arconossùa.",
+ "config-db-web-account": "Cont dla base ëd dàit për l'acess a l'aragnà",
+ "config-db-web-help": "Ch'a selession-a lë stranòm d'utent e la ciav che ël servent ëd l'aragnà a dovrërà për coleghesse al servent dle base ëd dàit, durant j'operassion ordinarie dla wiki.",
+ "config-db-web-account-same": "Ch'a deuvra ël midem cont com për l'istalassion",
+ "config-db-web-create": "Crea ël cont se a esist pa anco'",
+ "config-db-web-no-create-privs": "Ël cont ch'a l'ha specificà për l'instalassion a l'ha pa basta 'd privilegi për creé un cont.\nËl cont ch'a spessìfica ambelessì a dev già esiste.",
+ "config-mysql-engine": "Motor ëd memorisassion:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Avis''': A l'ha selessionà MyISAM com motor ëd memorisassion për MySQL, che a l'é pa arcomandà da dovré con MediaWiki, përchè:\n* a sopòrta a pen-a la contemporanità për via ëd saradure ëd tàula\n* a l'é pi soget a la corussion che j'àutri motor\n* ël còdes bas ëd MediaWiki pa sempe a gestiss MyISAM com a dovrìa\n\nSe soa istalassion MySQL a manten InnoDB, a l'é fortement arcomandà ch'a serna pitòst col-lì.\nSe soa istalassion MySQL a manten nen InnoDB, a peul esse ch'a sia ël moment ëd n'agiornament.",
+ "config-mysql-engine-help": "'''InnoDB''' a l'é scasi sempe la mej opsion, da già ch'a l'ha un bon manteniment dla concorensa.\n\n'''MyISAM''' a peul esse pi lest an instalassion për n'utent sol o mach an letura.\nLa base ëd dàit MyISAM a tira a corompse pi 'd soens che la base ëd dàit InnoDB.",
+ "config-mysql-charset": "Ansem ëd caràter dla base ëd dàit:",
+ "config-mysql-binary": "Binari",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "An '''manera binaria''', MediaWiki a memorisa ël test UTF-8 ant la base ëd dàit an camp binari.\nSòn a l'é pi eficient che la manera UTF-8 ëd MySQL, e a-j përmët ëd dovré l'ansema antregh ëd caràter Unicode.\n\nAn '''manera UTF-8''', MySQL a conossrà an che ansem ëd caràter a son ij sò dat, e a peul presenteje e convertije apropriatament, ma a-j lassa pa memorisé ij caràter ëdzora al [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes pian multilenghìstich ëd base].",
+ "config-site-name": "Nòm ëd la wiki:",
+ "config-site-name-help": "Sòn a comparirà ant la bara dël tìtol dël navigador e an vàire d'àutri pòst.",
+ "config-site-name-blank": "Ch'a buta un nòm ëd sit.",
+ "config-project-namespace": "Spassi nominal dël proget:",
+ "config-ns-generic": "Proget",
+ "config-ns-site-name": "Midem com ël nom dla wiki: $1",
+ "config-ns-other": "Àutr (specìfica)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Andasend daré a l'esempi ëd Wikipedia, vàire wiki a manten-o soe pàgine ëd regolament separà da soe pàgine ëd contnù, ant në \"'''spassi nominal ëd proget'''\".\nTùit ij tìtoj ëd pàgina ant cost ëspassi nominal a parto con un sert prefiss, che a peul specifiché ambelessì.\nTradissionalment, sto prefiss a l'é derivà dal nòm ëd la wiki, ma a peul pa conten-e caràter ëd pontegiatura coma \"#\" o \":\".",
+ "config-ns-invalid": "Lë spassi nominal specificà \"<nowiki>$1</nowiki>\" a l'é pa bon.\nSpecìfica në spassi nominal ëd proget diferent.",
+ "config-ns-conflict": "Lë spassi nominal specificà \"<nowiki>$1</nowiki>\" a và contra në spassi nominal predefinì ëd MediaWiki.\nSpecìfica në spassi nominal ëd proget diferent.",
+ "config-admin-box": "Cont ëd l'Aministrator",
+ "config-admin-name": "Tò nòm:",
+ "config-admin-password": "Ciav:",
+ "config-admin-password-confirm": "Buté torna la ciav:",
+ "config-admin-help": "Ch'a butà ambelessì tò stranòm d'utent preferì, për esempi \"Gioann Scriv\".\nCost-sì a l'é lë stranòm ch'a dovrërà për intré ant la wiki.",
+ "config-admin-name-blank": "Ch'a anserissa në stranòm d'aministrator.",
+ "config-admin-name-invalid": "Ël nòm utent specificà \"<nowiki>$1</nowiki>\" a l'é pa bon.\nSpecìfica un nòm utent diferent.",
+ "config-admin-password-blank": "Ch'a anserissa na ciav për ël cont d'aministrator.",
+ "config-admin-password-mismatch": "Le doe ciav che a l'ha scrivù a son diferente antra 'd lor.",
+ "config-admin-email": "Adrëssa ëd pòsta eletrònica:",
+ "config-admin-email-help": "Ch'a anserissa ambelessì n'adrëssa ëd pòsta eletrònica për përmëtt-je d'arsèive ëd mëssagi da d'àutri utent an sla wiki, riamposté soa ciav, e esse anformà dle modìfiche a le pàgine ch'a ten sot-euj. A peule lassé ës camp veuid.",
+ "config-admin-error-user": "Eror antern an creand n'aministrator con lë stranòm \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Eror antern an ampostand na ciav për l'admin \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "A l'ha butà n'adrëssa ëd pòsta eletrònica pa bon-a.",
+ "config-subscribe": "Ch'a sot-scriva la [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce lista ëd discussion ëd j'anonsi ëd publicassion].",
+ "config-subscribe-help": "Costa a l'é na lista ëd discussion a bass tràfich dovrà për j'anonsi ëd publicassion, comprèis d'amportant anonsi ëd sicurëssa.\nA dovrìa sot-ëscrivla e agiorné soa instalassion mediaWiki quand che ëd version neuve a rivo.",
+ "config-subscribe-noemail": "A l'ha provà a abonesse a la lista ëd difusion dij comunicà sensa dé n'adrëssa ëd pòsta eletrònica.\nPër piasì, ch'a fornissa n'adrëssa ëd pòsta eletrònica s'a veul abonesse a la lista ëd pòsta.",
+ "config-almost-done": "A l'ha bele che fàit!\nA peul adess sauté la configurassion rimanenta e instalé dlongh la wiki.",
+ "config-optional-continue": "Ciameme d'àutre chestion.",
+ "config-optional-skip": "I son già anojà, instala mach la wiki.",
+ "config-profile": "Profil dij drit d'utent:",
+ "config-profile-wiki": "Wiki duverta",
+ "config-profile-no-anon": "A venta creé un cont",
+ "config-profile-fishbowl": "Mach editor autorisà",
+ "config-profile-private": "Wiki privà",
+ "config-profile-help": "Le wiki a marcio mej quand ch'a lassa che pì përsone possìbij a-j modìfico.\nAn MediaWiki, a l'é bel fé revisioné j'ùltime modìfiche, e buté andré qualsëssìa dann che a sia fàit da dj'utent noviss o malissios.\n\nAn tùit ij cas, an tanti a l'han trovà che MediaWiki a sia ùtil ant na gran varietà ëd manere, e dle vire a l'é pa bel fé convince cheidun dij vantagi dla wiki.\nParèj a l'ha doe possibilità.\n\nËl model '''{{int:config-profile-wiki}}''' a përmët a chicassìa ëd modifiché, bele sensa intré ant ël sistema.\nNa wiki con '''{{int:config-profile-no-anon}}''' a dà pì 'd contròl, ma a peul slontané dij contributor ocasionaj.\n\nËl senari '''{{int:config-profile-fishbowl}}''' a përmët a j'utent aprovà ëd modifiché, ma ël pùblich a peul vëdde le pàgine, comprèisa la stòria.\nNa '''{{int:config-profile-private}}''' a përmët mach a j'utent aprovà ëd vëdde le pàgine, con la midema partìa ch'a peul modifiché.\n\nConfigurassion ëd drit d'utent pi complicà a son disponìbij apress l'instalassion, vëdde la [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights pàgina a pòsta dël manual].",
+ "config-license": "Drit d'autor e licensa",
+ "config-license-none": "Gnun-a licensa an nòta an bass",
+ "config-license-cc-by-sa": "Creative Commons atribussion an part uguaj",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons atribussion nen comersial an part uguaj",
+ "config-license-cc-0": "Creative Commons Zero (domini pùblich)",
+ "config-license-gfdl": "Licensa GNU Free Documentation 1.3 o pi neuva",
+ "config-license-pd": "Domini Pùblich",
+ "config-license-cc-choose": "Selessioné na licensa Creative Commons përsonalisà",
+ "config-license-help": "Vàire wiki pùbliche a buto tute le contribussion sota na [http://freedomdefined.org/Definition licensa lìbera]. Sòn a giuta a creé un sens d'apartenensa a la comunità e a ancoragia ëd contribussion ëd longa durà.\nA l'é generalment nen necessari për na wiki privà o d'asienda.\n\nS'a veul podèj dovré dij test da Wikipedia, e a veul che Wikipedia a aceta dij test copià da soa wiki, a dovrìa serne '''Creative Commons Attribution Share Alike'''.\n\nWikipedia prima a dovrava la GNU Free Documentation License.\nLa GDFL a l'é anco' na licensa bon-a, ma a l'é malfé da capila.\nA l'é ëdcò mal fé riutilisé dël contnù licensià sota la GDFL.",
+ "config-email-settings": "Ampostassion ëd pòsta eletrònica",
+ "config-enable-email": "Abilité ij mëssagi ëd pòsta eletrònica an surtìa",
+ "config-enable-email-help": "S'a veul che la pòsta eletrònica a marcia, j'[http://www.php.net/manual/en/mail.configuration.php ampostassion ëd pòsta eletrònica PHP] a devo esse configurà për da bin.\nS'a veul pa 'd funsion ëd pòsta eletrònica, a dev disabiliteje ambelessì.",
+ "config-email-user": "Abilité ij mëssagi ëd pòsta eletrònica da utent a utent",
+ "config-email-user-help": "A përmët a tùit j'utent ëd mandesse ëd mëssagi ëd pòsta eletrònica se lor a l'han abilità sòn an soe preferense.",
+ "config-email-usertalk": "Abilité notìfica dle pàgine ëd discussion dj'utent",
+ "config-email-usertalk-help": "A përmët a j'utent d'arsèive na notìfica dle modìfiche dle pàgine ëd discussion d'utent, s'a l'han abilitalo ant soe preferense.",
+ "config-email-watchlist": "Abilité la notìfica ëd lòn ch'as ten sot euj",
+ "config-email-watchlist-help": "A përmët a j'utent d'arsèive dle notificassion a propòsit dle pàgine ch'a ten-o sot euj s'a l'han abilitalo ant soe preferense.",
+ "config-email-auth": "Abilité l'autenticassion për pòsta eletrònica",
+ "config-email-auth-help": "Se st'opsion a l'é abilità, j'utent a devo confirmé soe adrësse ëd pòsta eletrònica an dovrand un colegament mandà a lor quand ch'a l'han ampostala o cambiala.\nMach j'adrësse ëd pòsta eletrònica autenticà a peulo arsèive ëd mëssagi da j'àutri utent o cangé adrëssa ëd notìfica.\nAmposté st'opsion a l'é '''arcomandà''' për le wiki pùbliche a càusa ëd possìbij abus ëd le funsion ëd pòsta eletrònica.",
+ "config-email-sender": "Adrëssa ëd pòsta eletrònica ëd ritorn:",
+ "config-email-sender-help": "Ch'a anserissa l'adrëssa ëd pòsta eletrònica da dovré com adrëssa d'artorn dij mëssagi an surtìa.\nAmbelessì a l'é andova j'arspòste a saran mandà.\nMotobin ëd servent ëd pòsta a ciamo che almanch la part dël nòm ëd domini a sia bon-a.",
+ "config-upload-settings": "Cariament ëd figure e archivi",
+ "config-upload-enable": "Abilité ël cariament d'archivi",
+ "config-upload-help": "Carié d'archivi potensialment a espon sò servent a d'arzigh ëd sicurëssa.\nPer pi d'anformassion, ch'a lesa la [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security session ëd sicurëssa] d'ës manual.\n\nPër abilité ël cariament d'archivi, ch'a modìfica la manera dël sot-dossié dle <code>figure</code> sota al dossié rèis ëd MediaWiki an manera che ël servent dl'aragnà a peussa scrivlo.\nPeui ch'a abìlita costa opsion.",
+ "config-upload-deleted": "Dossié për j'archivi scancelà:",
+ "config-upload-deleted-help": "ch'a serna un dossié andova goerné j'archivi scancelà.\nIdealment, sòn a dovrìa pa esse acessìbil an sl'aragnà.",
+ "config-logo": "Anliura dla marca:",
+ "config-logo-help": "La pel dë stàndard ëd MediaWiki a comprend lë spassi për na marca ëd 135x160 pontin dzora la lista dla bara lateral.\nCh'a dëscaria na figura ëd la dimension aproprià, e ch'a anserissa l'anliura ambelessì.\n\nS'a veul gnun-e marche, ch'a lassa ës camp bianch.",
+ "config-instantcommons": "Abìlita Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] a l'é na funsion ch'a përmët a le wiki ëd dovré dle figure, dij son e d'àutri mojen trovà an sël sit [https://commons.wikimedia.org/ Wikimedia Commons].\nPër dovré sossì, MediaWiki a l'ha da manca dl'acess a la ragnà.\n\nPër pi d'anformassion su sta funsion, comprèise j'istrussion ëd com ampostela për wiki diferente da Wikimedia Commons, ch'a consulta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos ël manual].",
+ "config-cc-error": "La selession ëd la licensa Creative Commons a l'ha dàit gnun arzultà.\nCh'a anserissa ël nòm dla licensa a man.",
+ "config-cc-again": "Torna cheuje...",
+ "config-cc-not-chosen": "Sern che licensa Creative Commons it veule e sgnaca \"proceed\".",
+ "config-advanced-settings": "Configurassion avansà",
+ "config-cache-options": "Ampostassion për la memorisassion local d'oget:",
+ "config-cache-help": "La memorisassion loca d'oget a l'é dovrà për amelioré l'andi ëd MediaWiki an butant an local dij dat dovrà 'd soens.\nIj sit da mesan a gròss a son motobin ancoragià a abilité sòn, e ij sit cit a l'avran ëdcò dij benefissi.",
+ "config-cache-none": "Gnun-a memorisassion local (gnun-a funsionalità gavà, ma l'andi a peul esse anfluensà an sij sit ëd wiki gròsse)",
+ "config-cache-accel": "Memorisassion local d'oget PHP (APC, XCache o WinCache)",
+ "config-cache-memcached": "Dovré Memcached (a ciama n'ampostassion e na configurassion adissionaj)",
+ "config-memcached-servers": "Servent Memcached:",
+ "config-memcached-help": "Lista d'adrësse IP da dovré për Memcached.\nA dovrìa specifichene un-a për linia e specifiché la pòrta da dovré. Për esempi:\n127.0.0.1:11211\n192.168.1.25:11211",
+ "config-memcache-needservers": "A l'ha selessionà Memcached com soa sòrt ëd memorisassion local ma a l'ha specificà gnun servent.",
+ "config-memcache-badip": "It l'ha anserì n'adrëssa IP pa bon-a për Memcached: $1.",
+ "config-memcache-noport": "A l'ha pa specificà na pòrta da dovré për ël servent Memcached: $1.\nS'a conòsse nen la pòrta, cola predefinìa a l'é 11211.",
+ "config-memcache-badport": "Ij nùmer ëd pòrta ëd Memcached a dovrìo esse tra $1 e $2.",
+ "config-extensions": "Estension",
+ "config-extensions-help": "J'estension listà dì-sota a son ëstàite trovà ant sò dossié <code>./extensions</code>.\n\nA peulo avèj da manca ëd configurassion adissionaj, ma a peul abiliteje adess",
+ "config-install-alreadydone": "'''Avis''' A smija ch'a l'abie già instalà MediaWiki e ch'a preuva a instalelo torna.\nPër piasì, ch'a vada a la pàgina ch'a-i ven.",
+ "config-install-begin": "An sgnacand su «{{int:config-continue}}», a anandiërà l'istalassion ëd MediaWiki.\nS'a veul anco' fé dle modìfiche, ch'a sgnaca su «{{int:config-back}}».",
+ "config-install-step-done": "fàit",
+ "config-install-step-failed": "falì",
+ "config-install-extensions": "Comprende j'estension",
+ "config-install-database": "Creassion ëd la base ëd dàit",
+ "config-install-schema": "Creassion dë schema",
+ "config-install-pg-schema-not-exist": "Lë schema postgreSQL a esist pa.",
+ "config-install-pg-schema-failed": "Creassion dle tàule falìa.\nSigurte che l'utent \"$1\" a peussa scrive lë schema \"$2\".",
+ "config-install-pg-commit": "Salvé ij cambi.",
+ "config-install-pg-plpgsql": "Contròl dël langagi PL/pgSQL",
+ "config-pg-no-plpgsql": "A dev istalé ël langage PL/pgSQL ant la base ëd dàit $1",
+ "config-pg-no-create-privs": "Ël cont ch'a l'ha specificà për l'istalassion a l'ha pa basta 'd privilegi për creé un cont.",
+ "config-pg-not-in-role": "Ël cont ch'a l'ha specificà për l'utent ëd la ragnà a esist già.\nËl cont ch'a l'has specificà për l'istalassion a l'é pa un superutent e a l'é pa un mémber dla partìa dj'utent dla Ragnà, parèj a peul pa creé dj'oget ch'a apartenent a l'utent dla Ragnà.\n\nMediaWiki al moment a ciama che le tàule a sia possedùe da n'utent dla Ragnà. Për piasì, ch'a specìfica n'àutr nòm ëd cont dla Ragnà, o ch'a sgnaca ansima a \"andré\" e ch'a specìfica n'utent ch'a l'ha ij privilegi ch'a basto për l'anstalassion.",
+ "config-install-user": "Creassion ëd n'utent ëd la base ëd dàit",
+ "config-install-user-alreadyexists": "L'utent \"$1\" a esist già",
+ "config-install-user-create-failed": "Faliment ant la creassion ëd l'utent «$1»: $2",
+ "config-install-user-grant-failed": "Falì a dé ij përmess a l'utent \"$1\": $2",
+ "config-install-user-missing": "L'utent specificà \"$1\" a esist pa.",
+ "config-install-user-missing-create": "L'utent specificà «$1» a esist pa.\nPër piasì, ch'a selession-a la casela «cont da creé» sì-sota s'a veul creelo.",
+ "config-install-tables": "Creassion dle tàule",
+ "config-install-tables-exist": "'''Avis''': A smija che le tàule ëd mediaWiki a esisto già.\nSauté la creassion.",
+ "config-install-tables-failed": "'''Eror''': Creassion ëd le tàule falìa con l'eror sì-dapress: $1",
+ "config-install-interwiki": "Ampiniment dë stàndard ëd le tàule dj'anliure interwiki",
+ "config-install-interwiki-list": "As peul pa trovesse l'archivi <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Avis''': La tàula interwiki a smija ch'a l'abia già dj'element.\nPër stàndard, la lista a sarà sautà.",
+ "config-install-stats": "Inissialisassion dle statìstiche",
+ "config-install-keys": "Generassion ëd le ciav segrete",
+ "config-insecure-keys": "'''Avis:''' {{PLURAL:$2|Na ciav sigura|Dle ciav sigure}} ($1) generà durant l'istalassion {{PLURAL:$2|a l'é|a son}} pa completament sigure. Ch'a consìdera ëd modifiche{{PLURAL:$2|la|je}} manualment.",
+ "config-install-sysop": "Creassion dël cont ëd l'utent aministrator",
+ "config-install-subscribe-fail": "As peul pa sot-scrivse mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL a l'é pa istalà e <code>allow_url_fopen</code> a l'é pa disponìbil.",
+ "config-install-mainpage": "Creassion ëd la pàgina prinsipal con un contnù predefinì",
+ "config-install-extension-tables": "Creassion ëd tàule për j'estension abilità",
+ "config-install-mainpage-failed": "As peul pa inserisse la pàgina prinsipal: $1",
+ "config-install-done": "'''Congratulassion!'''\nA l'ha instalà për da bin mediaWiki.\n\nL'instalador a l'ha generà n'archivi <code>LocalSettings.php</code>.\nA conten tuta soa configurassion.\n\nA dovrà dëscarielo e butelo ant la bas ëd l'instalassion ëd soa wiki (ël midem dossié d'index.php). La dëscaria a dovrìa esse ancaminà automaticament.\n\nSe la dëscaria a l'é pa disponìbil, o s'a l'ha scancelala, a peul torna ancaminé la dëscaria an sgnacand an sla liura sì-sota:\n\n$3\n\n'''Nòta''': S'a lo fa nen adess, cost archivi ëd configurassion generà a sarà pa disponìbil për chiel pi tard s'a chita l'instalassion sensa dëscarielo.\n\nQuand che a l'é stàit fàit, a peul '''[$2 intré an soa wiki]'''.",
+ "config-download-localsettings": "Dëscarié <code>LocalSettings.php</code>",
+ "config-help": "agiut",
+ "config-nofile": "L'archivi «$1» as treuva nen. A l'é stàit ëscancelà?",
+ "mainpagetext": "'''MediaWiki a l'é staita anstalà a la përfession.'''",
+ "mainpagedocfooter": "Che a varda la [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] për avèj dj'anformassion ant sël coma dovré ël programa dla wiki.\n\n== Për anandiesse a travajé ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista dij paràmeter ëd configurassion]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Chestion frequente su MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista ëd discussion an sla distribussion ëd MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Ch'a localisa MediaWiki për toa lenga]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Amprende coma combate contra la rumenta su soa wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/pnt.json b/www/wiki/includes/installer/i18n/pnt.json
new file mode 100644
index 00000000..4c40116d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pnt.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sinopeus"
+ ]
+ },
+ "mainpagetext": "'''To λογισμικόν MediaWiki εθέκεν.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/prg.json b/www/wiki/includes/installer/i18n/prg.json
new file mode 100644
index 00000000..a283fcec
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/prg.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Nertiks"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki's instalaciōni izpalla.'''",
+ "mainpagedocfooter": "Wīdais [https://meta.wikimedia.org/wiki/Help:Contents przewodnik użytkownika] kāi gaūlai infōrmaciōnei ezze wiki prōgramijas tērpausnan.\n\n== En pagaūseņu ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/ps.json b/www/wiki/includes/installer/i18n/ps.json
new file mode 100644
index 00000000..5e8d7dec
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ps.json
@@ -0,0 +1,93 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ahmed-Najib-Biabani-Ibrahimkhel"
+ ]
+ },
+ "config-desc": "د مېډياويکي نصبونکی",
+ "config-title": "مېډياويکي $1 نصبېدنه",
+ "config-information": "مالومات",
+ "config-localsettings-key": "کونجۍ نومهالول:",
+ "config-localsettings-badkey": "کومه اوسمهاله شوې کونجۍ مو چې ورکړې، ناسمه ده.",
+ "config-your-language": "ستاسې ژبه:",
+ "config-wiki-language": "د ويکي ژبه:",
+ "config-back": "← پر شا تلل",
+ "config-continue": "پر مخ تلل →",
+ "config-page-language": "ژبه",
+ "config-page-welcome": "مېډياويکي ته ښه راغلاست!",
+ "config-page-dbconnect": "توکبنسټ سره اړيکه نيول",
+ "config-page-dbsettings": "د توکبنسټ امستنې",
+ "config-page-name": "نوم",
+ "config-page-options": "خوښنې",
+ "config-page-install": "لگول",
+ "config-page-complete": "بشپړ!",
+ "config-page-restart": "نصبېدنه بياپيلول",
+ "config-page-readme": "ما ولوله",
+ "config-page-releasenotes": "خپاره شوي يادښتونه",
+ "config-page-copying": "لمېسنه",
+ "config-page-upgradedoc": "نومهالېدنه",
+ "config-page-existingwiki": "شته ويکي",
+ "config-restart": "هو، سر له نوي يې پيل کړه",
+ "config-env-php": "د $1 PHP نصب شو.",
+ "config-env-hhvm": "HHVM $1 نصب شو.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] نصب شو",
+ "config-apc": "[http://www.php.net/apc APC] نصب شو",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] نصب شو",
+ "config-diff3-bad": "جي ان يو ډيف3 و نه موندل شو.",
+ "config-using-server": "د پالنگر نوم \"<nowiki>$1</nowiki>\" کارېږي.",
+ "config-using-uri": "د پالنگر URL \"<nowiki>$1$2</nowiki>\" کارېږي.",
+ "config-db-type": "د توکبنسټ ډول:",
+ "config-db-host": "د توکبنسټ کوربه:",
+ "config-db-host-oracle": "د توکبنسټ TNS:",
+ "config-db-wiki-settings": "دا ويکي پېژندل",
+ "config-db-name": "د توکبنسټ نوم:",
+ "config-db-name-oracle": "د اومتوکبنسټ طرحه:",
+ "config-db-username": "د توکبنسټ کارن-نوم:",
+ "config-db-password": "د توکبنسټ پټنوم:",
+ "config-db-port": "د توکبنسټ ور:",
+ "config-db-schema": "د مېډياويکي طرحه:",
+ "config-type-mssql": "مايکروسافټ SQL پالنگر",
+ "config-header-mysql": "د MySQL امستنې",
+ "config-header-postgres": "د PostgreSQL امستنې",
+ "config-header-sqlite": "د SQLite امستنې",
+ "config-header-oracle": "د اورېکل امستنې",
+ "config-header-mssql": "د مايکروسافټ SQL پالنگر امستنې",
+ "config-sqlite-readonly": "د <code>$1</code> دوتنه د ليکلو وړ نه ده.",
+ "config-sqlite-cant-create-db": "د توکبنسټ دوتنه <code>$1</code> جوړه نه شوه.",
+ "config-mysql-binary": "دوه ايز",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "د ويکي نوم:",
+ "config-site-name-blank": "د وېبځي نوم وليکۍ.",
+ "config-project-namespace": "د پروژې نوم-تشيال:",
+ "config-ns-generic": "پروژه",
+ "config-ns-site-name": "ويکي نوم ته ورته: $1",
+ "config-ns-other": "بل (ځانگړی کړئ)",
+ "config-ns-other-default": "زما ويکي",
+ "config-admin-box": "د پازوال گڼون",
+ "config-admin-name": "ستاسې کارن نوم:",
+ "config-admin-password": "پټنوم:",
+ "config-admin-password-confirm": "پټنوم يو ځل بيا:",
+ "config-admin-email": "برېښليک پته:",
+ "config-optional-continue": "نورې پوښتنې راڅخه وپوښتئ.",
+ "config-profile": "د کارن رښتو پېژنليک:",
+ "config-profile-wiki": "پرانيستې ويکي",
+ "config-profile-private": "شخصي ويکي",
+ "config-license": "منښتليک او د خپرولو رښته:",
+ "config-license-none": "بې پايڅوړه منښتليک",
+ "config-license-pd": "ټولگړی شپول",
+ "config-email-settings": "د برېښليک امستنې",
+ "config-email-user": "کارن تر کارن برېښليک چارنول",
+ "config-upload-enable": "دوتنې پورته کېدنې چارنول",
+ "config-logo": "د نښې يو آر ال:",
+ "config-extensions": "شاتاړي",
+ "config-skins": "پوښۍ",
+ "config-skins-use-as-default": "همدا پوښۍ په تلواليزه توگه کارول",
+ "config-install-step-done": "ترسره شو",
+ "config-install-step-failed": "نابريال شو",
+ "config-install-user-alreadyexists": "د \"$1\" کارن له پخوا څخه شته",
+ "config-install-tables": "لښتيالونه جوړول",
+ "config-download-localsettings": "ښکته کول <code>LocalSettings.php</code>",
+ "config-help": "لارښود",
+ "mainpagetext": "<strong>MediaWiki نصب شو.</strong>",
+ "mainpagedocfooter": "د ويکي ساوترې د کارولو د مالوماتو په اړه [https://meta.wikimedia.org/wiki/Help:Contents د کارن لارښود] سره سلا وکړۍ.\n\n== پيلول ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings د امستنو د سازونې لړليک]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ د ميډياويکي ډېرځليزې پوښتنې]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce د مېډياويکي د برېښليکونو لړليک]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources خپلې ژبې لپاره MediaWiki ځايتابول]"
+}
diff --git a/www/wiki/includes/installer/i18n/pt-br.json b/www/wiki/includes/installer/i18n/pt-br.json
new file mode 100644
index 00000000..7a490513
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pt-br.json
@@ -0,0 +1,339 @@
+{
+ "@metadata": {
+ "authors": [
+ "Cainamarques",
+ "Giro720",
+ "Gustavo",
+ "Luckas",
+ "Marcionunes",
+ "555",
+ "Amgauna",
+ "Anaclaudiaml",
+ "Cybermandrake",
+ "Fabsouza1",
+ "Rodrigo codignoli",
+ "Tuliouel",
+ "Marcos dias de oliveira",
+ "Fasouzafreitas",
+ "TheEduGobi",
+ "Dianakc",
+ "Walesson",
+ "Almondega",
+ "Luk3",
+ "Eduardo Addad de Oliveira",
+ "Warley Felipe C.",
+ "Felipe L. Ewald"
+ ]
+ },
+ "config-desc": "O instalador do MediaWiki",
+ "config-title": "Instalação do MediaWiki $1",
+ "config-information": "Informações",
+ "config-localsettings-upgrade": "Foi detectada a existência do arquivo <code>LocalSettings.php</code>.\nPara atualizar esta instalação, insira na caixa abaixo o valor de <code>$wgUpgradeKey</code>.\nEssa informação pode ser encontrada no arquivo <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Foi detectada a existência do arquivo <code>LocalSettings.php</code>.\nPara atualizar esta instalação, por favor, executando o arquivo <code>update.php</code> em vez",
+ "config-localsettings-key": "Chave de atualização:",
+ "config-localsettings-badkey": "A chave de atualização que você forneceu está incorreta.",
+ "config-upgrade-key-missing": "Foi detectada uma instalação existente do MediaWiki.\nPara atualizar esta instalação, insira a seguinte linha ao final do seu <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "O arquivo <code>LocalSettings.php</code> parece incompleto.\nA variável $1 não está definida.\nPor favor, altere seu <code>LocalSettings.php</code> e defina esta variável e clique em \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Ocorreu um erro ao conectar ao banco de dados através das configurações presentes no arquivo <code>LocalSettings.php</code>. Por favor, corrija essas configurações e tente novamente.\n\n$1",
+ "config-session-error": "Erro ao iniciar a sessão: $1",
+ "config-session-expired": "Os dados da sua sessão parecem ter expirado.\nAs sessões estão configuradas para uma duração de $1.\nVocê pode aumentar esta duração configurando <code>session.gc_maxlifetime</code> no php.ini.\nReinicie o processo de instalação.",
+ "config-no-session": "Os dados da sua sessão foram perdidos!\nVerifique o seu php.ini e certifique-se de que <code>session.save_path</code> está definido com um diretório apropriado.",
+ "config-your-language": "Seu idioma:",
+ "config-your-language-help": "Selecione o idioma que será usado durante o processo de instalação.",
+ "config-wiki-language": "Idioma da wiki:",
+ "config-wiki-language-help": "Selecione o idioma no qual a wiki será predominantemente escrita.",
+ "config-back": "← Voltar",
+ "config-continue": "Continuar →",
+ "config-page-language": "Idioma",
+ "config-page-welcome": "Bem-vindo(a) ao MediaWiki!",
+ "config-page-dbconnect": "Conectar ao banco de dados",
+ "config-page-upgrade": "Atualizar a instalação existente",
+ "config-page-dbsettings": "Configurações do banco de dados",
+ "config-page-name": "Nome",
+ "config-page-options": "Opções",
+ "config-page-install": "Instalar",
+ "config-page-complete": "Concluído!",
+ "config-page-restart": "Reiniciar a instalação",
+ "config-page-readme": "Leia-me",
+ "config-page-releasenotes": "Notas de lançamento",
+ "config-page-copying": "Copiando",
+ "config-page-upgradedoc": "Atualizando",
+ "config-page-existingwiki": "Wiki existente",
+ "config-help-restart": "Deseja limpar todos os dados salvos que você introduziu e reiniciar o processo de instalação?",
+ "config-restart": "Sim, reiniciar",
+ "config-welcome": "=== Verificações de ambiente ===\nSerão realizadas verificações básicas para determinar se este ambiente é apropriado para a instalação do MediaWiki.\nLembre-se de incluir estas informações se for procurar por suporte para como concluir a instalação.",
+ "config-copyright": "=== Direitos autorais e Termos de uso ===\n\n$1\n\nEste programa é software livre; você pode redistribuí-lo e/ou modificá-lo nos termos da licença GNU General Public License tal como publicada pela Free Software Foundation; tanto a versão 2 da Licença, como (por opção sua) qualquer versão posterior.\n\nEste programa é distribuído na esperança de que seja útil, mas <strong>sem qualquer garantia</strong>; inclusive, sem a garantia implícita da <strong>possibilidade de ser comercializado</strong> ou de <strong>adequação para qualquer finalidade específica</strong>.\nConsulte a licença GNU General Public License para mais detalhes.\n\nEm conjunto com este programa você deve ter recebido <doclink href=Copying>uma cópia da licença GNU General Public License</doclink>; se não a recebeu, peça-a por escrito para Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA ou [http://www.gnu.org/copyleft/gpl.html leia-a na internet].",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki Página principal do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Manual do usuário]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Manual do administrador]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Leia-me</doclink>\n* <doclink href=ReleaseNotes>Notas de lançamento</doclink>\n* <doclink href=Copying>Licença</doclink>\n* <doclink href=UpgradeDoc>Atualizando</doclink>",
+ "config-env-good": "O ambiente foi verificado.\nVocê pode instalar o MediaWiki.",
+ "config-env-bad": "O ambiente foi verificado.\nVocê não pode instalar o MediaWiki.",
+ "config-env-php": "O PHP $1 está instalado.",
+ "config-env-hhvm": "O HHVM $1 está instalado.",
+ "config-unicode-using-intl": "Usando a [http://pecl.php.net/intl extensão intl PECL] para a normalização Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Aviso</strong>: A [http://pecl.php.net/intl extensão intl PECL] não está disponível para efetuar a normalização Unicode, abortando e passando para a lenta implementação de PHP puro.\nSe o seu site tem um alto volume de tráfego, informe-se sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalização Unicode].",
+ "config-unicode-update-warning": "<strong>Aviso:</strong> A versão instalada do wrapper de normalização Unicode usa uma versão mais antiga da biblioteca do [http://www.site.icu-project.org/projeto ICU].\nVocê deve [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations atualizar] se você tem quaisquer preocupações com o uso do Unicode.",
+ "config-no-db": "Não foi possível encontrar um driver apropriado para a banco de dados! Você precisa instalar um driver de banco de dados para PHP. {{PLURAL:$2|É aceito o seguinte tipo|São aceitos os seguintes tipos}} de banco de dados: $1.\n\nSe você compilou o PHP, reconfigure-o com um cliente de banco de dados ativado, por exemplo, usando <code>./configure --with-mysqli</code>.\nSe instalou o PHP a partir de um pacote Debian ou Ubuntu, então também precisa instalar, por exemplo, o pacote <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Aviso:</strong> você tem o SQLite versão $1, que é menor do que a versão mínima necessária $2. O SQLite não estará disponível.",
+ "config-no-fts3": "<strong>Aviso</strong> O SQLite foi compilado sem o [//sqlite.org/fts3.html módulo FTS3], as funcionalidades de pesquisa não estarão disponíveis nesta instalação.",
+ "config-pcre-old": "<strong>Erro fatal:</strong> É necessário o PCRE $1 ou versão posterior.\nO binário do seu PHP foi vinculado com o PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Mais informações].",
+ "config-pcre-no-utf8": "<strong>Erro fatal:</strong> O módulo PCRE do PHP parece ser compilado sem suporte a PCRE_UTF8.\nO MediaWiki requer suporte a UTF-8 para funcionar corretamente.",
+ "config-memory-raised": "A configuração <code>memory_limit</code> do PHP era $1; foi aumentada para $2.",
+ "config-memory-bad": "<strong>Aviso:</strong> A configuração <code>memory_limit</code> do PHP é $1.\nIsso provavelmente é muito baixo.\nA instalação pode falhar!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] está instalado",
+ "config-apc": "[http://www.php.net/apc APC] está instalado",
+ "config-apcu": "[http://www.php.net/apcu APCu] está instalado",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] está instalado",
+ "config-no-cache-apcu": "<strong>Aviso:</strong> Não se pode encontrar [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] ou [http://www.iis.net/download/WinCacheForPhp WinCache].\nO caching de objetos não foi ativado.",
+ "config-mod-security": "<strong>Aviso:</strong> Seu servidor web tem [http://modsecurity.org/ mod_security2] habilitado. Muitas configurações comuns de módulo podem causar problemas para o MediaWiki ou outro software que permite aos usuários postar conteúdo arbitrário.\nSe possível, ele dever ser desativad. Consulte a [http://modsecurity.org/documentation/ documentação do mod_security] ou entre em contato com o suporte do seu host se você encontrar erros aleatórios.",
+ "config-diff3-bad": "O GNU diff3 não foi encontrado.",
+ "config-git": "Foi encontrado o software de controle de versão Git: <code>$1</code>.",
+ "config-git-bad": "O software de controle de versão Git não foi encontrado.",
+ "config-imagemagick": "Encontrado ImageMagick: <code>$1</code>.\nRedimensionamento de imagem será ativado se você permitir uploads.",
+ "config-gd": "Encontrada biblioteca gráfica GD embutida\nO redimensionamento de imagens será habilitado se você permitir uploads.",
+ "config-no-scaling": "Não foi possível encontrar biblioteca GD ou ImageMagick.\nO redimensionamento de imagens será desabilitado.",
+ "config-no-uri": "<strong>Erro:</strong> Não foi possível determinar o URI atual.\nA instalação foi abortada.",
+ "config-no-cli-uri": "<strong>Aviso:</strong> Nenhum <code>--scriptpath</code> foi especificado, usando o padrão: <code>$1</code>.",
+ "config-using-server": "Utilizando o nome do servidor \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Usando URL do servidor \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Aviso:</strong> O seu diretório atual de envios <code>$1</code> está vulnerável a execuções de script arbitrárias.\nEmbora o MediaWiki verifique todos os arquivos enviados por ameaças de segurança, é altamente recomendável [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security evitar essa vulnerabilidade de segurança] antes de permitir envios.",
+ "config-no-cli-uploads-check": "<strong>Atenção:</strong> O seu diretório padrão para envios (<code>$1</code>) não está marcado para vulnerabilidade\npara execução de script arbitrário durante a instalação do CLI.",
+ "config-brokenlibxml": "O sistema tem uma combinação de PHP e libxml2 que é conflitante e pode causar corrupção de dados ocultos no MediaWiki e outros aplicativos da web.\nAtualize para o libxml2 2.7.3 ou mais recente ([https://bugs.php.net/bug.php?id=45996 bugs com o PHP]).\nInstalação abortada.",
+ "config-suhosin-max-value-length": "O Suhosin está instalado e limita o parâmetro GET <code>length</code> para $1 bytes. O componente ResourceLoader trabalhará em torno deste limite, mas degradará a performance.\nSe possível, defina <code>suhosin.get.max_value_length</code> em <code>php.ini</code> para 1024 ou mais e defina <code>$wgResourceLoaderMaxQueryLength</code> em <code>LocalSettings.php</code> para o mesmo valor.",
+ "config-using-32bit": "<strong>Aviso:</strong> o seu sistema parece estar sendo executado com inteiros de 32 bits. Isto [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit não é recomendado].",
+ "config-db-type": "Tipo do banco de dados:",
+ "config-db-host": "Servidor do banco de dados:",
+ "config-db-host-help": "Se a banco de dados do seu servidor está em um servidor diferente, digite o nome do host ou o endereço IP aqui.\n\nSe você está utilizando uma hospedagem web compartilhada, o seu provedor de hospedagem deverá fornecer o nome do host correto na sua documentação.\n\nSe você está instalando em um servidor Windows e usando o MySQL, usar \"localhost\" pode não funcionar para o nome de servidor. Se não funcionar, tente \"127.0.0.1\" para o endereço de IP local.\n\nSe você está usando PostgreSQl, deixe este campo em branco para se conectar através de um socket Unix.",
+ "config-db-host-oracle": "TNS do banco de dados:",
+ "config-db-host-oracle-help": "Digite um [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Nome de Conexão local] válido; o arquivo tnsnames.ora precisa estar visível para esta instalação.<br />Se você estiver usando bibliotecas cliente 10g ou mais recente, você também pode usar o método [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Identifique esta wiki",
+ "config-db-name": "Nome do banco de dados:",
+ "config-db-name-help": "Escolha um nome que identifique a sua wiki.\nEle não deve conter espaços.\n\nSe você está utilizando uma hospedagem web compartilhada, o provedor de hospedagem lhe dará um nome especifico de banco de dados para usar ou o deixará criar a partir do painel de controle.",
+ "config-db-name-oracle": "Esquema do banco de dados:",
+ "config-db-account-oracle-warn": "Há três cenários suportados para instalar o Oracle como backend do banco de dados:\n\nSe você deseja criar a conta do banco de dados como parte do processo de instalação, forneça uma conta com função SYSDBA como conta do banco de dados para instalação e especifique as credenciais desejadas para a conta de acesso pela web, caso contrário, você poderá criar a conta de acesso via web manualmente e fornecer apenas aquela conta (se tiver permissões necessárias para criar os objetos schema) ou fornecer duas contas diferentes, uma com privilégios de criação e uma restrita para acesso à web.\n\nO script para criar uma conta com os privilégios necessários pode ser encontrado no diretório \"maintenance/oracle/\" desta instalação. Lembre-se de que usar uma conta restrita desativará todos os recursos de manutenção com a conta padrão.",
+ "config-db-install-account": "Conta de usuário para instalação",
+ "config-db-username": "Nome de usuário do banco de dados:",
+ "config-db-password": "Senha do banco de dados:",
+ "config-db-install-username": "Digite o nome de usuário que será utilizado para conectar com o banco de dados durante o processo de instalação.\nEste não é a conta de usuário do MediaWiki; este é o nome de usuário para sua banco de dados.",
+ "config-db-install-password": "Digite a senha que será utilizada para conectar com o banco de dados durante o processo de instalação.\nEsta não é a senha de usuário da conta do MediaWiki; esta será a senha para seu banco de dados.",
+ "config-db-install-help": "Digite o nome de usuário e a senha que serão utilizados para conectar com o banco de dados durante o processo de instalação.",
+ "config-db-account-lock": "Use o mesmo nome de usuário e senha durante a operação normal",
+ "config-db-wiki-account": "Conta de usuário para operação normal",
+ "config-db-wiki-help": "Digite o nome de usuário e senha que será usada para se conectar ao banco de dados durante a operação normal wiki.\nSe a conta não existir e a conta de instalação tiver privilégios suficientes, a conta do usuário será criada com os privilégios mínimos necessários para o funcionamento da wiki.",
+ "config-db-prefix": "Prefixo da tabela do banco de dados:",
+ "config-db-prefix-help": "Se você precisar compartilhar a banco de dados entre várias wikis, ou entre o MediaWiki e uma outra aplicação web, você pode adicionar um prefixo ao nome de todas as tabelas para evitar conflitos.\nNão utilize espaços.\n\nEste campo é habitualmente deixado em branco.",
+ "config-mysql-old": "MySQL $1 ou posterior é necessário. Você tem $2.",
+ "config-db-port": "Porta do banco de dados:",
+ "config-db-schema": "Esquema para MediaWiki:",
+ "config-db-schema-help": "Este esquema geralmente estará correto.\nSó o modifique se você tiver certeza que precisa.",
+ "config-pg-test-error": "Não foi possível se conectar com o banco de dados <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Diretório de dados do SQLite:",
+ "config-sqlite-dir-help": "O SQLite armazena todos os dados em um único arquivo.\n\nO diretório que você fornecer deve permitir a sua escrita pelo servidor web durante a instalação.\n\nO diretório <strong>não</strong> deve ser acessível pela web, por isso não estamos colocando onde estão os seus arquivos PHP.\n\nO instalador escreverá um arquivo <code>.htaccess</code>, mas se isso falhar alguém poderá ganhar acesso a toda sua base de dados.\nIsso inclui dados brutos dos usuários (endereços de e-mail, senhas criptografadas) assim como todas revisões deletadas e outros dados restritos na wiki.\n\nConsidere colocar a banco de dados em algum outro lugar, por exemplo <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Espaço de tabela padrão:",
+ "config-oracle-temp-ts": "Tablespace temporário:",
+ "config-type-mysql": "MySQL (ou compatível)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "O MediaWiki suporta os sistemas de banco de dados a seguir:\n\n$1\n\nSe você não vê o sistema de banco de dados que você está tentando usar listados abaixo, siga as instruções relacionadas acima, para ativar o suporte.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] é o principal alvo para o MediaWiki e é melhor suportado. O MediaWiki também funciona com [{{int:version-db-mariadb-url}} MariaDB] e [{{int:version-db-percona-url}} Percona Server], que são compatíveis com MySQL. ([Http://www.php.net/manual/en/mysqli.installation.php Como compilar PHP com suporte a MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] é um popular sistema de banco de dados de código aberto como uma alternativa para o MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Como compilar o PHP com suporte PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] é um sistema de banco de dados leve que é muito bem suportado. ([http://www.php.net/manual/en/pdo.installation.php como compilar o PHP com suporte a SQLite], usa DOP)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] é um banco de dados comercial de empresas. ([http://www.php.net/manual/en/oci8.installation.php Como compilar o PHP com suporte OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] é uma banco de dados comercial do Windows para empresas. ([http://www.php.net/manual/en/sqlsrv.installation.php Como compilar o PHP com suporte SQLSRV])",
+ "config-header-mysql": "Configurações MySQL",
+ "config-header-postgres": "Configurações PostgreSQL",
+ "config-header-sqlite": "Configurações SQLite",
+ "config-header-oracle": "Configurações Oracle",
+ "config-header-mssql": "Configurações Microsoft SQL Server",
+ "config-invalid-db-type": "Tipo do banco de dados inválido.",
+ "config-missing-db-name": "Você deve inserir um valor para \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Você deve inserir um valor para \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Você deve inserir um valor para \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "Banco de dados TNS inválido \"$1\".\nUse \"TNS Name\" ou \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Métodos de nomeação da Oracle]).",
+ "config-invalid-db-name": "O nome do banco de dados é inválido \"$1\".\nUse apenas letras ASCII (a-z, A-Z), números (0-9), underscores (_) e hifens (-).",
+ "config-invalid-db-prefix": "O prefixo do banco de dados é inválido \"$1\".\nUse apenas letras ASCII (a-z, A-Z), números (0-9), underscores (_) e hifens (-).",
+ "config-connection-error": "$1\n\nVerifique o servidor, nome de usuário e senha e tente novamente.",
+ "config-invalid-schema": "Schema inválido para o MediaWiki \"$1\".\nUse apenas letras ASCII (a-z, A-Z), números (0-9) e underscores (_).",
+ "config-db-sys-create-oracle": "O instalador só permite criar uma conta nova usando uma conta SYSDBA.",
+ "config-db-sys-user-exists-oracle": "A conta de usuário \"$1\" já existe. SYSDBA somente pode ser utilizado na criação de uma nova conta!",
+ "config-postgres-old": "PostgreSQL $1 ou posterior é necessário. Você tem $2.",
+ "config-mssql-old": "Microsoft SQL Server $1 ou posterior é necessário. Você tem $2.",
+ "config-sqlite-name-help": "Escolha um nome que identifique a sua wiki.\nNão utilize espaços ou hifens.\nIsto será utilizado como nome do arquivo de dados do SQLite.",
+ "config-sqlite-parent-unwritable-group": "Não é possível criar o diretório de dados <code><nowiki>$1</nowiki></code>, porque o diretório pai <code><nowiki>$2</nowiki></code> não pode ser gravado pelo servidor web.\n\nO instalador conseguiu determinar o usuário em que seu servidor web está sendo executado.\nDe permissão de gravação global ao diretório <code><nowiki>$3</nowiki></ code> para o instalador para continuar.\nEm um sistema Unix/Linux faça:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Não é possível criar o diretório de dados <code><nowiki>$1</nowiki></code>, porque o diretório pai <code><nowiki>$2</nowiki></code> não pode ser gravado pelo servidor web.\n\nO instalador não conseguiu determinar o usuário em que seu servidor web está sendo executado.\nDe permissão de gravação global ao diretório <code><nowiki>$3</nowiki></ code> para o instalador (e outros!) para continuar.\nEm um sistema Unix/Linux faça:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Ocorreu um erro na criação do diretório de dados \"$1\".\nVerifique a localização e tente de novo.",
+ "config-sqlite-dir-unwritable": "Não foi possível escrever no diretório \"$1\".\nModifique as permissões de modo que o servidor web possa escrever no diretório e tente novamente.",
+ "config-sqlite-connection-error": "$1\n\nVerifique o diretório de dados e nome do banco de dados abaixo e tente novamente.",
+ "config-sqlite-readonly": "Não é possível escrever no arquivo <code>$1</code>.",
+ "config-sqlite-cant-create-db": "Não foi possível criar o arquivo do banco de dados <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "O PHP não tem suporte FTS3; revertendo tabelas.",
+ "config-can-upgrade": "Este banco de dados contém tabelas do MediaWiki.\nPara atualizá-las para o MediaWiki $1, clique em <strong>Continuar</strong>.",
+ "config-upgrade-done": "Atualização completa.\n\nAgora você pode [$1 começar a usar sua wiki].\n\nSe você quiser regenerar seu arquivo <code>LocalSettings.php</code>, clique no botão abaixo.\n<strong>Não recomendado </strong> a menos que você esteja tendo problemas com sua wiki.",
+ "config-upgrade-done-no-regenerate": "Atualização completa.\n\nAgora pode [$1 começar a usar a sua wiki].",
+ "config-regenerate": "Regenerar arquivo LocalSettings.php →",
+ "config-show-table-status": "Consulta <code>SHOW TABLE STATUS</code> falhou!",
+ "config-unknown-collation": "<strong>Aviso:</strong> O banco de dados está usando agrupamento não reconhecido.",
+ "config-db-web-account": "Conta do banco de dados para acesso web",
+ "config-db-web-help": "Escolha um nome de usuário e uma senha que o servidor web irá utilizar para se conectar ao servidor do banco de dados durante o funcionamento normal da wiki.",
+ "config-db-web-account-same": "Use a mesma conta usada na instalação",
+ "config-db-web-create": "Crie a conta se esta ainda não existir",
+ "config-db-web-no-create-privs": "A conta que você especificou para a instalação não possui privilégios suficientes para criar uma conta.\nA conta que for especificada aqui já deve existir.",
+ "config-mysql-engine": "Mecanismo de armazenamento:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Aviso:</strong> Você selecionou MyISAM como mecanismo de armazenamento para o MySQL, o que não é recomendado para uso com o MediaWiki, porque:\n* dificilmente suporta concorrência devido ao bloqueio da tabela\n* é mais propenso à corrupção do que outros motores\n*a base de código MediaWiki nem sempre lida com o MyISAM como deveria\n\nSe sua instalação MySQL suportar o InnoDB, é altamente recomendável que você escolha ele.\nSe sua instalação MySQL não suportar o InnoDB, talvez seja hora de uma atualização.",
+ "config-mysql-only-myisam-dep": "<strong>Aviso:</strong> O MyISAM é o único mecanismo de armazenamento disponível para o MySQL nesta máquina e isso não é recomendado para uso com o MediaWiki, porque:\n* dificilmente suporta concorrência devido ao bloqueio da tabela\n* é mais propenso à corrupção do que outros motores\n*a base de código MediaWiki nem sempre lida com o MyISAM como deveria\n\nA sua instalação no MySQL não suporta InnoDB, talvez seja hora de uma atualização.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> é quase sempre a melhor opção, uma vez que possui um bom suporte de concorrência.\n\n<strong>MyISAM</strong> pode ser mais rápido em instalações de usuário único ou somente leitura.\\O banco de dados MyISAM tendem a ficar corrompidos mais frequentemente do que os bancos de dados InnoDB.",
+ "config-mysql-charset": "Conjunto de caracteres do banco de dados:",
+ "config-mysql-binary": "Binário",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Em <strong>modo binário</strong>, o MediaWiki armazena texto UTF-8 no banco de dados em campos binários.\nEsto é mais eficiente que o modo UTF-8 do MySQL e permite que você use a gama completa de caracteres Unicode.\n\nNo <strong>modo UTF-8</strong>, o MySQL saberá o caracterer em que seus dados estão inseridos e pode apresentá-lo e convertê-lo adequadamente, mas não permitirá que você armazene caracteres acima do [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Multilingue Básico].",
+ "config-mssql-auth": "Tipo de autenticação:",
+ "config-mssql-install-auth": "Selecione o tipo de autenticação que será usado para se conectar ao banco de dados durante o processo de instalação.\nSe você selecionar \"{{int:config-mssql-windowsauth}}\", as credenciais de qualquer usuário que o servidor web esteja executando serão usadas.",
+ "config-mssql-web-auth": "Selecione o tipo de autenticação que o servidor web usará para se conectar ao servidor do banco de dados, durante a operação normal da wiki.\nSe você selecionar \"{{int:config-mssql-windowsauth}}\", as credenciais de qualquer usuário no qual o servidor web está rodando serão usadas.",
+ "config-mssql-sqlauth": "Autenticação do SQL Server",
+ "config-mssql-windowsauth": "Autenticação do Windows",
+ "config-site-name": "Nome da wiki:",
+ "config-site-name-help": "Isto aparecerá na barra de títulos do navegador e em vários outros lugares.",
+ "config-site-name-blank": "Digite o nome do site.",
+ "config-project-namespace": "Domínio do projeto:",
+ "config-ns-generic": "Projeto",
+ "config-ns-site-name": "O mesmo que o nome da wiki: $1",
+ "config-ns-other": "Outro (especifique)",
+ "config-ns-other-default": "MinhaWiki",
+ "config-project-namespace-help": "Seguindo o exemplo da Wikipédia, muitas wikis mantêm suas páginas de políticas separadas de suas páginas de conteúdo, em um ''espaço espaço nominal de projeto''.\nTodos os títulos de páginas neste espaço espaço nominal começam com um determinado prefixo, que você pode especificar aqui.\nUsualmente, este prefixo é derivado do nome da wiki, mas não pode conter caracteres de pontuação como \"#\" ou \":\".",
+ "config-ns-invalid": "O domínio especificado \"<nowiki>$1</nowiki>\" é inválido.\nEspecifique um domínio do projeto diferente.",
+ "config-ns-conflict": "O domínio especificado \"<nowiki>$1</nowiki>\" conflita com um domínio padrão do MediaWiki.\nEspecifique um domínio do projeto diferente.",
+ "config-admin-box": "Conta de administrador",
+ "config-admin-name": "Seu nome de usuário:",
+ "config-admin-password": "Senha:",
+ "config-admin-password-confirm": "Repita a senha:",
+ "config-admin-help": "Digite o seu nome de usuário preferido aqui, por exemplo \"José Silveira\". Este será o nome que você usará para entrar na wiki.",
+ "config-admin-name-blank": "Digite um nome de usuário administrador.",
+ "config-admin-name-invalid": "O nome de usuário especificado \"<nowiki>$1</nowiki>\" é inválido.\nEspecifique um nome de usuário diferente.",
+ "config-admin-password-blank": "Digite uma senha para a conta de administrador.",
+ "config-admin-password-mismatch": "As duas senhas que você digitou não são iguais.",
+ "config-admin-email": "Endereço de e-mail:",
+ "config-admin-email-help": "Digite aqui um endereço de e-mail para permitir que você receba e-mail de outros usuários na wiki, refaça a sua senha, e seja notificado das mudanças das páginas que você vigia. Você pode deixar esse campo vazio.",
+ "config-admin-error-user": "Erro interno ao criar um administrador com o nome \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Erro interno ao configurar uma senha para o administrador \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Você digitou um endereço de e-mail inválido.",
+ "config-subscribe": "Inscrever-se na [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce lista de anúncios de laçamentos].",
+ "config-subscribe-help": "Esta é uma lista de discussão de baixo volume usada para anúncios de lançamento, incluindo importantes anúncios de segurança.\nVocê deve se inscrever e atualizar sua instalação do MediaWiki quando novas versões forem lançadas.",
+ "config-subscribe-noemail": "Você tentou se inscrever na lista de discussão de anúncios de lançamento sem fornecer um endereço de e-mail.\n\nPor favor, forneça um endereço de e-mail se desejar se inscrever na lista de discussão.",
+ "config-pingback": "Compartilhe dados sobre esta instalação com desenvolvedores do MediaWiki.",
+ "config-pingback-help": "Se você selecionar esta opção, o MediaWiki periodicamente enviará para https://www.mediawiki.org dados básicos sobre esta instância do MediaWiki. Esses dados incluem, por exemplo, o tipo de sistema, a versão PHP e o backend do banco de dados escolhido. A Fundação Wikimedia compartilha esses dados com os desenvolvedores do MediaWiki para ajudar a orientar os esforços de desenvolvimento futuros. Os seguintes dados serão enviados para o seu sistema:\n<pre>$1</pre>",
+ "config-almost-done": "Você está quase terminando!\nVocê agora pode pular as configurações restantes e instalar a wiki agora mesmo.",
+ "config-optional-continue": "Faça-me mais perguntas.",
+ "config-optional-skip": "Já estou aborrecido, apenas instale a wiki.",
+ "config-profile": "Perfil de permissões do usuário:",
+ "config-profile-wiki": "Wiki aberta",
+ "config-profile-no-anon": "Criação de conta exigida",
+ "config-profile-fishbowl": "Somente editores autorizados",
+ "config-profile-private": "Wiki privada",
+ "config-profile-help": "As Wikis funcionam melhor quando você deixa que muitas pessoas as editem o quanto for possível.\nNo MediaWiki é fácil revisar as mudanças recentes e reverter qualquer dano que seja feito por usuários ingênuos ou mal-intencionados.\n\nNo entanto, muitos encontraram no MediaWiki em uma grande variedade de funções e às vezes não é fácil convencer todos dos benefícios do modo wiki.\nEntão você tem a escolha.\n\nO modelo <strong>{{int:config-profile-wiki}}</strong> permite que qualquer pessoa edite, sem sequer fazer login.\nUma wiki com <strong>{{int:config-profile-no-anon}}</strong> fornece uma responsabilidade adicional, mas pode impedir contribuintes ocasionais.\n\nO cenário <strong>{{int:config-profile-fishbowl}}</strong> permite que usuários aprovados possam editar, mas o público pode visualizar as páginas, incluindo o histórico.\nUma <strong>{{int:config-profile-private}}</strong> só permite que usuários aprovados vejam páginas, com o mesmo grupo permitido a editar.\n\nMais configurações de direitos de usuário complexas estão disponíveis após a instalação, veja as[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights entradas relevantes no manual].",
+ "config-license": "Direitos autorais e licenças:",
+ "config-license-none": "Sem rodapé com a licença",
+ "config-license-cc-by-sa": "Creative Commons - Atribuição - Compartilhamento pela mesma Licença",
+ "config-license-cc-by": "Atribuição Creative Commons",
+ "config-license-cc-by-nc-sa": "Creative Commons - Atribuição – Uso Não Comercial – Compartilhamento pela mesma Licença",
+ "config-license-cc-0": "Creative Commons Zero (Domínio público)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 ou posterior",
+ "config-license-pd": "Domínio público",
+ "config-license-cc-choose": "Selecionar uma licença personalizada da organização Creative Commons",
+ "config-license-help": "Muitas wikis públicas colocam todas as contribuições sob uma [http://freedomdefined.org/Definition licença livre].\nIsso ajuda a criar um senso de propriedade da comunidade e incentiva a contribuição de longo prazo.\nGeralmente não é necessário para uma empresa privada ou wiki corporativa.\nSe você quiser poder usar o texto da Wikipédia e quiser que a Wikipédia possa aceitar o texto copiado da sua wiki, você deve escolher <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nA Wikipédia usou anteriormente a Licença de Documentação Livre GNU.\n A GFDL é uma licença válida, mas é difícil de entender.\nTambém é difícil reutilizar conteúdo licenciado sob o GFDL.",
+ "config-email-settings": "Configurações de e-mail",
+ "config-enable-email": "Ativar envio de e-mail",
+ "config-enable-email-help": "Se você quer que o e-mail funcione, estas [http://www.php.net/manual/en/mail.configuration.php configurações de e-mail PHP] precisam ser configuradas corretamente.\nSe você não quiser usar nenhuma das funcionalidades, você pode desabilitá-las aqui.",
+ "config-email-user": "Ativar e-mails entre usuários",
+ "config-email-user-help": "Permitir que todos os usuários enviem e-mail entre si se eles tiverem habilitado este recurso em suas preferências.",
+ "config-email-usertalk": "Ativar notificação de alterações em páginas de discussão de usuário",
+ "config-email-usertalk-help": "Permitir que os usuários recebam notificações quando suas páginas de discussão forem modificadas se eles tiverem habilitado as notificações em suas preferências.",
+ "config-email-watchlist": "Ativar notificação de alterações em páginas vigiadas",
+ "config-email-watchlist-help": "Permitir que os usuários recebam notificações sobre suas páginas vigiadas se eles tiverem habilitado as notificações em suas preferências.",
+ "config-email-auth": "Ativar autenticação de e-mail",
+ "config-email-auth-help": "Se esta opção estiver habilitada, os usuários devem confirmar seu endereço de e-mail usando um link enviado para eles sempre que eles o definirem ou mudá-lo.\nApenas endereços de e-mail autenticados podem receber e-mails de outros usuários ou alterar e-mails de notificação. \nConfigurar esta opção é <strong>recomendado</ strong> para wikis públicas devido ao potencial abuso dos recursos de e-mail.",
+ "config-email-sender": "Endereço de e-mail para resposta:",
+ "config-email-sender-help": "Digite o endereço de e-mail para usar como o endereço de retorno para os e-mails enviados.\nIsto é onde os saltos serão enviados.\nMuitos servidores de correio exigem que pelo menos o nome de domínio seja válida.",
+ "config-upload-settings": "Carregamento de imagens e arquivos",
+ "config-upload-enable": "Permitir o carregamento de arquivos",
+ "config-upload-help": "Os carregamentos de arquivos potencialmente expõem seu servidor a riscos de segurança.\nPara mais informações, leia a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security seção de segurança] no manual.\nPara ativar o envio de arquivos, mude o modo no subdiretório <code>images</code> no diretório raiz do MediaWiki para que o servidor web possa escrever nele.\nEntão, habilite esta opção.",
+ "config-upload-deleted": "Diretório para arquivos excluídos:",
+ "config-upload-deleted-help": "Escolha um diretório no qual serão armazenados os arquivos excluídos. \nIdealmente, este não deveria ser acessível pela web.",
+ "config-logo": "URL do logotipo:",
+ "config-logo-help": "O tema padrão do MediaWiki inclui espaço para um logotipo de 135x160 acima do menu lateral. Carregue uma imagem do tamanho apropriado e insira o URL aqui.\n\nVocê pode utilizar <code>$wgStylePath</code> ou <code>$wgScriptPath</code> se seu logotipo esta relacionado a estes caminhos.\n\nSe não pretende usar um logótipo, deixe esta caixa em branco.",
+ "config-instantcommons": "Ativar o Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] é um recurso que permite que as wikis usem imagens, sons e outras mídias encontradas no site [https://commons.wikimedia.org/ Wikimedia Commons].\nPara fazer isso, o MediaWiki requer acesso à Internet.\n\nPara obter mais informações sobre esse recurso, incluindo instruções sobre como configurá-lo para wikis diferentes da Wikimedia Commons, consulte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos o manual].",
+ "config-cc-error": "O escolhedor de licenças Creative Commons não retornou nenhum resultado.\nDigite o nome da licença manualmente.",
+ "config-cc-again": "Escolha novamente...",
+ "config-cc-not-chosen": "Escolha qual licença Creative Commons deseja e clique em \"continuar\".",
+ "config-advanced-settings": "Configuração avançada",
+ "config-cache-options": "Configuração da cache de objetos:",
+ "config-cache-help": "O cache de objetos é usado para melhorar o desempenho do MediaWiki, armazenando dados usados com frequência.\nSites de tamanho médio ou grande são altamente encorajados a ativar esta funcionalidade e os sites pequenos também terão alguns benefícios em fazê-lo.",
+ "config-cache-none": "Sem cache (nenhuma funcionalidade é removida, mas a velocidade pode ser afetada em wikis maiores)",
+ "config-cache-accel": "Cache de objetos PHP (APC, APCu, XCache ou WinCache)",
+ "config-cache-memcached": "Usar Memcached (requer instalação e configurações adicionais)",
+ "config-memcached-servers": "Servidores Memcached:",
+ "config-memcached-help": "Lista de endereços IP a serem usados para Memcached.\nDeve especificar um por linha e especificar a porta a ser utilizada. Por exemplo:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Você selecionou Memcached como seu tipo de cache, mas não especificou nenhum servidor.",
+ "config-memcache-badip": "Introduziu um endereço IP inválido para Memcached: $1.",
+ "config-memcache-noport": "Você não especificou uma porta para usar no servidor Memcached: $1.\nSe você não souber a porta, o padrão é 11211.",
+ "config-memcache-badport": "Os números de porta Memcached devem estar entre $1 e $2.",
+ "config-extensions": "Extensões",
+ "config-extensions-help": "As extensões listadas acima foram detectadas em seu diretório <code>./extensions</code>.\n\nElas podem exigir configuração adicional, mas você pode habilitá-las agora.",
+ "config-skins": "Temas",
+ "config-skins-help": "As skins mencionadas acima foram detectadas no seu diretório <code>./skins</code>. Você deve habilitar pelo menos uma e escolher uma como padrão.",
+ "config-skins-use-as-default": "Utilize esta skin como padrão",
+ "config-skins-missing": "Não foram encontradas skins; MediaWiki usará uma skin de fallback até que você instale algumas adequadas.",
+ "config-skins-must-enable-some": "Você deve escolher pelo menos uma skin para habilitar.",
+ "config-skins-must-enable-default": "A skin escolhida como padrão deve ser ativada.",
+ "config-install-alreadydone": "<strong>Aviso:</strong> Parece que já instalou o MediaWiki e está tentando instalá-lo novamente.\nPor favor, vá para a próxima página.",
+ "config-install-begin": "Ao pressionar \"{{int:config-continue}}\", você iniciará a instalação do MediaWiki.\nSe ainda quiser fazer alterações, pressione \"{{int:config-back}}\".",
+ "config-install-step-done": "feito",
+ "config-install-step-failed": "falhou",
+ "config-install-extensions": "Incluindo extensões",
+ "config-install-database": "Criando banco de dados",
+ "config-install-schema": "Criando esquema",
+ "config-install-pg-schema-not-exist": "O esquema ''(schema)'' PostgreSQL não existe.",
+ "config-install-pg-schema-failed": "A criação de tabelas falhou.\nCertifique-se de que o usuário \"$1\" possa escrever no esquema \"$2\".",
+ "config-install-pg-commit": "Enviando alterações",
+ "config-install-pg-plpgsql": "Verificando por linguagem PL/pgSQL",
+ "config-pg-no-plpgsql": "Você precisa instalar a linguagem PL/pgSQL no banco de dados $1",
+ "config-pg-no-create-privs": "A conta que você especificou para a instalação não tem privilégios suficientes para criar uma conta.",
+ "config-pg-not-in-role": "A conta que você especificou para o usuário da web já existe.\nA conta que você especificou para instalação não é um super usuário e não é um membro da função do usuário da web, portanto, não é possível criar objetos pertencentes ao usuário da web.\n\nO MediaWiki atualmente exige que as tabelas sejam de propriedade do usuário da web. Por favor, especifique outro nome da conta da Web ou clique em \"voltar\" e especifique um usuário de instalação com o privilégio adequado.",
+ "config-install-user": "Criando usuário do banco de dados",
+ "config-install-user-alreadyexists": "O usuário \"$1\" já existe",
+ "config-install-user-create-failed": "Criação usuário \"$1\" falhou: $2",
+ "config-install-user-grant-failed": "A concessão de permissão para o usuário \"$1\" falhou: $2",
+ "config-install-user-missing": "O usuário especificado, \"$1\", não existe.",
+ "config-install-user-missing-create": "O usuário especificado \"$1\" não existe.\nPor favor, clique na opção de \"criar conta\" abaixo se você deseja criá-lo.",
+ "config-install-tables": "Criando tabelas",
+ "config-install-tables-exist": "<strong>Aviso:</strong> As tabelas do MediaWiki parecem já existir.\nA criação das tabelas será pulada.",
+ "config-install-tables-failed": "<strong>Error:</strong> A criação das tabelas falhou com o seguinte erro: $1",
+ "config-install-interwiki": "Preenchendo a tabela padrão de interwiki",
+ "config-install-interwiki-list": "Não foi possível ler o arquivo <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Aviso:</strong> A tabela de interwiki parece já ter entradas.\\NPulando lista padrão.",
+ "config-install-stats": "Inicializando estatísticas",
+ "config-install-keys": "Gerando senhas secretas",
+ "config-insecure-keys": "<strong>Aviso:</strong> {{PLURAL:$2|Uma chave segura gerada|Algumas chaves seguras geradas}} ($1) durante a instalação {{PLURAL:$2|não é completamente segura|não são completamente seguras}}. Considere mudar {{PLURAL:$2|ela|elas}} manualmente.",
+ "config-install-updates": "Impedir a execução de atualizações desnecessárias",
+ "config-install-updates-failed": "<strong>Error:</strong> A inserção de chaves de atualização em tabelas falhou com o seguinte erro: $1",
+ "config-install-sysop": "Criando conta de usuário administrador",
+ "config-install-subscribe-fail": "Não foi possível subscrever ao mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL não está instalada e <code>allow_url_fopen</code> não está disponível.",
+ "config-install-mainpage": "Criando página principal com o conteúdo padrão",
+ "config-install-mainpage-exists": "A página principal já existe, pulando",
+ "config-install-extension-tables": "Criando tabelas para extensões habilitadas",
+ "config-install-mainpage-failed": "Não foi possível inserir a página principal: $1",
+ "config-install-done": "<strong>Parabéns!</strong>\nVocê instalou o MediaWiki.\n\nO instalador gerou um arquivo <code>LocalSettings.php</code>.\nEste arquivo contém todas as suas configurações.\n\nVocê precisa fazer o download desse arquivo e colocá-lo na raiz da sua instalação (o mesmo diretório onde está o arquivo index.php). O download deve iniciar automaticamente.\n\nSe o download não foi iniciado ou se ele foi cancelado, você pode recomeçá-lo clicando no link abaixo:\n\n$3\n\n<strong>Nota:</strong> Se você não fizer isso agora, o arquivo de configuração que foi gerado não estará mais disponível se você sair da instalação sem fazer o download.\n\nQuando isso tiver sido feito, você pode <strong>[$2 entrar na sua wiki]</strong>.",
+ "config-install-done-path": "<strong>Parabéns!</strong>\nVocê instalou o MediaWiki.\n\nO instalador gerou um arquivo <code>LocalSettings.php</code>.\nEste arquivo contém todas as suas configurações.\n\nVocê precisa fazer o download desse arquivo e colocá-lo em <code>$4</code>. O download deve iniciar automaticamente.\n\nSe o download não foi iniciado ou se ele foi cancelado, você pode recomeçá-lo clicando no link abaixo:\n\n$3\n\n<strong>Nota:</strong> Se você não fizer isso agora, o arquivo de configuração que foi gerado não estará mais disponível se você sair da instalação sem fazer o download.\n\nQuando isso tiver sido feito, você pode <strong>[$2 entrar na sua wiki]</strong>.",
+ "config-download-localsettings": "Baixar <code>LocalSettings.php</code>",
+ "config-help": "ajuda",
+ "config-help-tooltip": "clique para expandir",
+ "config-nofile": "O arquivo \"$1\" não foi encontrado. Ele foi apagado?",
+ "config-extension-link": "Você sabia que sua wiki suporta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensões]?\n\nVocê pode explorar as [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensões por categoria] ou visitar a [https://www.mediawiki.org/wiki/Extension_Matrix Matriz de Extensões] para ver a lista completa.",
+ "config-skins-screenshots": "$1 (screenshots: $2)",
+ "config-screenshot": "screenshot",
+ "mainpagetext": "<strong>O MediaWiki foi instalado.</strong>",
+ "mainpagedocfooter": "Consulte o [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Manual de Usuário] para informações de como usar o software wiki.\n\n== Começando ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de opções de configuração]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ do MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de discussão com avisos de novas versões do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Traduza o MediaWiki para seu idioma]"
+}
diff --git a/www/wiki/includes/installer/i18n/pt.json b/www/wiki/includes/installer/i18n/pt.json
new file mode 100644
index 00000000..a6ebd92a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/pt.json
@@ -0,0 +1,335 @@
+{
+ "@metadata": {
+ "authors": [
+ "Crazymadlover",
+ "Hamilton Abreu",
+ "Luckas",
+ "Mormegil",
+ "Platonides",
+ "SandroHc",
+ "Waldir",
+ "아라",
+ "555",
+ "Fúlvio",
+ "Giro720",
+ "Imperadeiro98",
+ "Cainamarques",
+ "Vitorvicentevalente",
+ "Macofe",
+ "Diniscoelho",
+ "Ruila",
+ "Seb35"
+ ]
+ },
+ "config-desc": "O instalador do MediaWiki",
+ "config-title": "Instalação do MediaWiki $1",
+ "config-information": "Informação",
+ "config-localsettings-upgrade": "Foi detetado um ficheiro <code>LocalSettings.php</code>.\nPara atualizar esta instalação, por favor introduza o valor de <code>$wgUpgradeKey</code> na caixa abaixo.\nEncontra este valor em <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Foi detetado um ficheiro <code>LocalSettings.php</code>.\nPara atualizar esta instalação, execute o <code>update.php</code>, por favor",
+ "config-localsettings-key": "Chave de atualização:",
+ "config-localsettings-badkey": "A chave de atualização que forneceu está incorreta.",
+ "config-upgrade-key-missing": "Foi detetada uma instalação existente do MediaWiki.\nPara atualizar esta instalação, por favor coloque a seguinte linha no final do seu <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "O ficheiro <code>LocalSettings.php</code> existente parece estar incompleto.\nA variável $1 não está definida.\nPor favor, defina esta variável no <code>LocalSettings.php</code> e clique \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Ocorreu um erro ao ligar à base de dados usando as configurações especificadas no <code>LocalSettings.php</code>. Por favor corrija essas configurações e tente novamente.\n\n$1",
+ "config-session-error": "Erro ao iniciar a sessão: $1",
+ "config-session-expired": "Os seus dados de sessão parecem ter expirado.\nAs sessões estão configuradas para uma duração de $1.\nPode aumentar esta duração configurando <code>session.gc_maxlifetime</code> no php.ini.\nReinicie o processo de instalação.",
+ "config-no-session": "Os seus dados de sessão foram perdidos!\nVerifique o seu php.ini e certifique-se de que em <code>session.save_path</code> está definido um diretório apropriado.",
+ "config-your-language": "A sua língua:",
+ "config-your-language-help": "Selecione o idioma que será usado durante o processo de instalação.",
+ "config-wiki-language": "Língua da wiki:",
+ "config-wiki-language-help": "Selecione a língua que será predominante na wiki.",
+ "config-back": "← Voltar",
+ "config-continue": "Continuar →",
+ "config-page-language": "Língua",
+ "config-page-welcome": "Bem-vindo(a) ao MediaWiki!",
+ "config-page-dbconnect": "Ligar à base de dados",
+ "config-page-upgrade": "Atualizar a instalação existente",
+ "config-page-dbsettings": "Configurações da base de dados",
+ "config-page-name": "Nome",
+ "config-page-options": "Opções",
+ "config-page-install": "Instalar",
+ "config-page-complete": "Terminado!",
+ "config-page-restart": "Reiniciar a instalação",
+ "config-page-readme": "Leia-me",
+ "config-page-releasenotes": "Notas de lançamento",
+ "config-page-copying": "A copiar",
+ "config-page-upgradedoc": "A atualizar",
+ "config-page-existingwiki": "Wiki existente",
+ "config-help-restart": "Deseja limpar todos os dados gravados que introduziu e reiniciar o processo de instalação?",
+ "config-restart": "Sim, reiniciar",
+ "config-welcome": "=== Verificações do ambiente ===\nSerão agora realizadas verificações básicas para determinar se este ambiente é apropriado para instalação do MediaWiki.\nLembre-se de fornecer esta informação se necessitar de pedir ajuda para concluir a instalação.",
+ "config-copyright": "=== Direitos de autor e Condições de uso ===\n\n$1\n\nEste programa é software livre; pode redistribuí-lo e/ou modificá-lo nos termos da licença GNU General Public License, tal como publicada pela Free Software Foundation; tanto a versão 2 da Licença, como (por opção sua) qualquer versão posterior.\n\nEste programa é distribuído na esperança de que seja útil, mas '''sem qualquer garantia'''; inclusive, sem a garantia implícita da '''possibilidade de ser comercializado''' ou de '''adequação para qualquer finalidade específica'''.\nConsulte a licença GNU General Public License para mais detalhes.\n\nEm conjunto com este programa deve ter recebido <doclink href=Copying>uma cópia da licença GNU General Public License</doclink>; se não a recebeu, peça-a por escrito a Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA ou [http://www.gnu.org/copyleft/gpl.html leia-a na internet].",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/pt Página principal do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/pt Ajuda]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/pt Manual técnico]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Leia-me</doclink>\n* <doclink href=ReleaseNotes>Notas de lançamento</doclink>\n* <doclink href=Copying>Cópia</doclink>\n* <doclink href=UpgradeDoc>Atualização</doclink>",
+ "config-env-good": "O ambiente foi verificado.\nPode instalar o MediaWiki.",
+ "config-env-bad": "O ambiente foi verificado.\nNão pode instalar o MediaWiki.",
+ "config-env-php": "O PHP $1 está instalado.",
+ "config-env-hhvm": "HHVM $1 está instalado.",
+ "config-unicode-using-intl": "A usar a [http://pecl.php.net/intl extensão intl PECL] para a normalização Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Aviso:</strong> A [http://pecl.php.net/intl extensão intl PECL] não está disponível para efetuar a normalização Unicode. Irá recorrer-se à implementação em PHP puro, que é mais lenta.\nSe o seu site tem alto volume de tráfego, devia informar-se um pouco sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations/pt normalização Unicode].",
+ "config-unicode-update-warning": "<strong>Aviso:</strong> A versão instalada do wrapper de normalização Unicode usa uma versão mais antiga da biblioteca do [http://site.icu-project.org/ projeto ICU].\nDevia [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations atualizá-la] se tem quaisquer preocupações sobre o uso do Unicode.",
+ "config-no-db": "Não foi possível encontrar um controlador apropriado da base de dados! Precisa de instalar um controlador da base de dados para o PHP. {{PLURAL:$2|É aceite o seguinte tipo|São aceites os seguintes tipos}} de base de dados: $1.\n\nSe fez a compilação do PHP, reconfigure-o com um cliente de base de dados ativado; por exemplo, usando <code>./configure --with-mysqli</code>.\nSe instalou o PHP a partir de um pacote Debian ou Ubuntu, então precisa de instalar também, por exemplo, o pacote <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Aviso:</strong> Tem a versão $1 do SQLite, que é anterior à versão mínima necessária, a $2. O SQLite não estará disponível.",
+ "config-no-fts3": "<strong>Aviso:</strong> O SQLite foi compilado sem o módulo [//sqlite.org/fts3.html FTS3]; as funcionalidades de pesquisa não estarão disponíveis nesta instalação.",
+ "config-pcre-old": "<strong>Erro fatal:</strong> É necessário o PCRE $1 ou versão posterior.\nO <i>link</i> do seu binário PHP foi feito com o PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Mais informações].",
+ "config-pcre-no-utf8": "'''Erro fatal''': O módulo PCRE do PHP parece ter sido compilado sem suporte PCRE_UTF8.\nO MediaWiki necessita do suporte UTF-8 para funcionar corretamente.",
+ "config-memory-raised": "A configuração <code>memory_limit</code> do PHP era $1; foi aumentada para $2.",
+ "config-memory-bad": "<strong>Aviso:</strong> A configuração <code>memory_limit</code> do PHP é $1.\nIsto é provavelmente demasiado baixo.\nA instalação poderá falhar!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] instalada",
+ "config-apc": "[http://www.php.net/apc APC] instalada",
+ "config-apcu": "[http://www.php.net/apcu APCu] instalado",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] instalada",
+ "config-no-cache-apcu": "<strong>Aviso:</strong> Não foram encontrados o [http://www.php.net/apcu APCu], o [http://xcache.lighttpd.net/ XCache] ou o [http://www.iis.net/download/WinCacheForPhp WinCache].\nA cache de objetos não está ativa.",
+ "config-mod-security": "<strong>Aviso:</strong> O seu servidor de Internet tem o [http://modsecurity.org/ mod_security]/mod_security2 ativado. Muitas das suas configurações normais podem causar problemas ao MediaWiki e a outros programas, permitindo que os utilizadores publiquem conteúdos arbitrários.\nSe possível, isto deve ser desativado. Se não, consulte a [http://modsecurity.org/documentation/ mod_security documentação] ou peça apoio ao fornecedor do alojamento do seu servidor se encontrar erros aleatórios.",
+ "config-diff3-bad": "O GNU diff3 não foi encontrado.",
+ "config-git": "Foi encontrado o software de controlo de versões Git: <code>$1</code>.",
+ "config-git-bad": "Não foi encontrado o software de controlo de versões Git.",
+ "config-imagemagick": "Foi encontrado o ImageMagick: <code>$1</code>.\nSe possibilitar uploads, a miniaturização de imagens será ativada.",
+ "config-gd": "Foi encontrada a biblioteca gráfica GD.\nSe possibilitar uploads, a miniaturização de imagens será ativada.",
+ "config-no-scaling": "Não foi encontrada a biblioteca gráfica GD nem o ImageMagick.\nA miniaturização de imagens será desativada.",
+ "config-no-uri": "<strong>Erro:</strong> Não foi possível determinar o URI atual.\nA instalação foi abortada.",
+ "config-no-cli-uri": "<strong>Aviso:</strong> Não foi especificado um <code>--scriptpath</code>; por omissão, será usado: <code>$1</code>.",
+ "config-using-server": "Será usado o nome do servidor \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Será usado o URL do servidor \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Aviso:</strong> O diretório por omissão para carregamentos, <code>$1</code>, está vulnerável à execução arbitrária de listas de comandos (scripts).\nEmbora o MediaWiki verifique a existência de ameaças de segurança em todos os ficheiros enviados, é altamente recomendado que [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security vede esta vulnerabilidade de segurança] antes de possibilitar uploads.",
+ "config-no-cli-uploads-check": "<strong>Aviso:</strong> O diretório por omissão para carregamentos, <code>$1</code>, não é verificado para determinar se é vulnerável à execução de listas arbitrárias de comandos durante a instalação por CLI (\"Command-line Interface\").",
+ "config-brokenlibxml": "O seu sistema tem uma combinação de versões do PHP e do libxml2 conhecida por ser problemática, podendo causar corrupção de dados no MediaWiki e noutras aplicações da Internet.\nAtualize para a versão 2.7.3 ou posterior do libxml2 ([https://bugs.php.net/bug.php?id=45996 incidência reportada no PHP]).\nInstalação cancelada.",
+ "config-suhosin-max-value-length": "O Suhosin está instalado e limita o parâmetro GET <code>length</code> a $1 bytes.\nO componente ResourceLoader do MediaWiki consegue exceder este limite, mas prejudicando o desempenho.\nSe lhe for possível, deve atribuir ao parâmetro <code>suhosin.get.max_value_length</code> o valor 1024 ou maior no ficheiro <code>php.ini</code>, e definir o mesmo valor para <code>$wgResourceLoaderMaxQueryLength</code> no ficheiro LocalSettings.php.",
+ "config-using-32bit": "<strong>Aviso:</strong> o seu sistema parece estar a funcionar com inteiros de 32 bits. Isto [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit não é recomendado].",
+ "config-db-type": "Tipo da base de dados:",
+ "config-db-host": "Servidor da base de dados:",
+ "config-db-host-help": "Se a base de dados estiver num servidor separado, introduza aqui o nome ou o endereço IP desse servidor.\n\nSe estiver a usar um servidor partilhado, o fornecedor do alojamento deve fornecer o nome do servidor na documentação.\n\nSe está a fazer a instalação num servidor Windows com MySQL, usar como nome do servidor \"localhost\" poderá não funcionar. Se não funcionar, tente usar \"127.0.0.1\" como endereço IP local.\n\nSe estiver a usar PostgreSQL, deixe este campo em branco para fazer a ligação através de um socket Unix.",
+ "config-db-host-oracle": "TNS (Transparent Network Substrate) da base de dados:",
+ "config-db-host-oracle-help": "Introduza um [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Nome Local de Ligação] válido; tem de estar visível para esta instalação um ficheiro tnsnames.ora.<br />Se está a usar bibliotecas cliente versão 10g ou posterior, também pode usar o método [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Ligação Fácil] de atribuição do nome.",
+ "config-db-wiki-settings": "Identifique esta wiki",
+ "config-db-name": "Nome da base de dados:",
+ "config-db-name-help": "Escolha um nome para identificar a sua wiki.\nO nome não deve conter espaços.\n\nSe estiver a usar um servidor partilhado, o fornecedor do alojamento deve poder fornecer-lhe o nome de uma base de dados que possa usar, ou permite-lhe criar bases de dados através de um painel de controlo.",
+ "config-db-name-oracle": "Esquema ''(schema)'' da base de dados:",
+ "config-db-account-oracle-warn": "Há três cenários suportados na instalação do servidor de base de dados Oracle:\n\nSe pretende criar a conta de acesso pela internet na base de dados durante o processo de instalação, forneça como conta para a instalação uma conta com o papel de SYSDBA na base de dados e especifique as credenciais desejadas para a conta de acesso pela internet. Se não pretende criar a conta de acesso pela internet durante a instalação, pode criá-la manualmente e fornecer só essa conta para a instalação (se ela tiver as permissões necessárias para criar os objetos do esquema ''(schema)''). A terceira alternativa é fornecer duas contas diferentes; uma com privilégios de criação e outra com privilégios limitados para o acesso pela internet.\n\nExiste um script para criação de uma conta com os privilégios necessários no diretório \"maintenance/oracle/\" desta instalação. Mantenha em mente que usar uma conta com privilégios limitados impossibilita todas as operações de manutenção com a conta padrão.",
+ "config-db-install-account": "Conta do utilizador para a instalação",
+ "config-db-username": "Nome do utilizador da base de dados:",
+ "config-db-password": "Palavra-passe do utilizador da base de dados:",
+ "config-db-install-username": "Introduza o nome de utilizador que será usado para aceder à base de dados durante o processo de instalação. Este utilizador não é o do MediaWiki; é o utilizador da base de dados.",
+ "config-db-install-password": "Introduza a palavra-passe do utilizador que será usado para aceder à base de dados durante o processo de instalação.\nEsta palavra-passe não é a do utilizador do MediaWiki; é a palavra-passe do utilizador da base de dados.",
+ "config-db-install-help": "Introduza o nome de utilizador e a palavra-passe que serão usados para aceder à base de dados durante o processo de instalação.",
+ "config-db-account-lock": "Usar o mesmo nome de utilizador e palavra-passe durante a operação normal",
+ "config-db-wiki-account": "Conta de utilizador para a operação normal",
+ "config-db-wiki-help": "Introduza o nome de utilizador e a palavra-passe que serão usados para aceder à base de dados durante a operação normal da wiki.\nSe o utilizador não existir na base de dados, mas a conta de instalação tiver privilégios suficientes, o utilizador que introduzir será criado na base de dados com os privilégios mínimos necessários para a operação normal da wiki.",
+ "config-db-prefix": "Prefixo para as tabelas da base de dados:",
+ "config-db-prefix-help": "Se necessitar de partilhar uma só base de dados entre várias wikis, ou entre o MediaWiki e outra aplicação, pode escolher adicionar um prefixo ao nome de todas as tabelas desta instalação, para evitar conflitos.\nNão use espaços.\n\nNormalmente, este campo deve ficar vazio.",
+ "config-mysql-old": "É necessário o MySQL $1 ou posterior; tem a versão $2.",
+ "config-db-port": "Porta da base de dados:",
+ "config-db-schema": "Esquema ''(schema)'' do MediaWiki",
+ "config-db-schema-help": "Normalmente, este esquema estará correto.\nAltere-o só se souber que precisa de o fazer.",
+ "config-pg-test-error": "Não foi possível criar uma ligação à base de dados <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Diretório de dados do SQLite:",
+ "config-sqlite-dir-help": "O SQLite armazena todos os dados num único ficheiro.\n\nDurante a instalação, o servidor de Internet precisa de ter permissão de escrita no diretório que especificar.\n\nEste diretório <strong>não</strong> deve poder ser acedido diretamente da Internet, por isso está a ser colocado onde estão os seus ficheiros PHP.\n\nJuntamente com o diretório, o instalador irá criar um ficheiro <code>.htaccess</code>, mas se esta operação falhar é possível que alguém venha a ter acesso direto à base de dados.\nIsto inclui acesso aos dados dos utilizadores (endereços de correio eletrónico, palavras-passe encriptadas), às revisões eliminadas e a outros dados de acesso restrito na wiki.\n\nConsidere colocar a base de dados num local completamente diferente, como, por exemplo, em <code>/var/lib/mediawiki/asuawiki</code>.",
+ "config-oracle-def-ts": "Tablespace padrão:",
+ "config-oracle-temp-ts": "Tablespace temporário:",
+ "config-type-mysql": "MySQL (ou compatível)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "O MediaWiki suporta as seguintes plataformas de base de dados:\n\n$1\n\nSe a plataforma que pretende usar não está listada abaixo, siga as instruções nos links acima para ativar o suporte.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] é a plataforma primária do MediaWiki e é a melhor suportada. O MediaWiki também trabalha com [{{int:version-db-mariadb-url}} MariaDB] e [{{int:version-db-percona-url}} Percona Server], que são compatíveis com MySQL. ([http://www.php.net/manual/en/mysql.installation.php Como compilar PHP com suporte a MySQL])",
+ "config-dbsupport-postgres": "* O [{{int:version-db-postgres-url}} PostgreSQL] é uma plataforma popular de base de dados de código aberto, alternativa ao MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Como compilar o PHP com suporte PostgreSQL])",
+ "config-dbsupport-sqlite": "* O [{{int:version-db-sqlite-url}} SQLite] é uma plataforma de base de dados ligeira muito bem suportada. ([http://www.php.net/manual/en/pdo.installation.php Como compilar o PHP com suporte SQLite], usa PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] é uma base de dados comercial para empresas. ([http://www.php.net/manual/en/oci8.installation.php Como compilar o PHP com suporte OCI8])",
+ "config-dbsupport-mssql": "* O [{{int:version-db-mssql-url}} Microsoft SQL Server] é uma base de dados comercial do Windows para empresas. ([http://www.php.net/manual/en/sqlsrv.installation.php Como compilar o PHP com suporte SQLSRV])",
+ "config-header-mysql": "Definições MySQL",
+ "config-header-postgres": "Definições PostgreSQL",
+ "config-header-sqlite": "Definições SQLite",
+ "config-header-oracle": "Definições Oracle",
+ "config-header-mssql": "Configurações do Microsoft SQL Server",
+ "config-invalid-db-type": "O tipo de base de dados é inválido",
+ "config-missing-db-name": "Tem de introduzir um valor para \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Tem de introduzir um valor para \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Tem de introduzir um valor para \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "O TNS da base de dados, \"$1\", é inválido.\nUse \"TNS Name\" ou o método \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Métodos de Configuração da Conectividade em Oracle])",
+ "config-invalid-db-name": "O nome da base de dados, \"$1\", é inválido.\nUse só letras (a-z, A-Z), algarismos (0-9), sublinhados (_) e hífens (-) dos caracteres ASCII.",
+ "config-invalid-db-prefix": "O prefixo da base de dados, \"$1\", é inválido.\nUse só letras (a-z, A-Z), algarismos (0-9), sublinhados (_) e hífens (-) dos caracteres ASCII.",
+ "config-connection-error": "$1.\n\nVerifique o servidor, o nome do utilizador e a palavra-passe e tente novamente.",
+ "config-invalid-schema": "O esquema ''(schema)'' do MediaWiki, \"$1\", é inválido.\nUse só letras (a-z, A-Z), algarismos (0-9) e sublinhados (_) dos caracteres ASCII.",
+ "config-db-sys-create-oracle": "O instalador só permite criar uma conta nova usando uma conta SYSDBA.",
+ "config-db-sys-user-exists-oracle": "A conta \"$1\" já existe. A conta SYSDBA só pode criar uma conta nova!",
+ "config-postgres-old": "É necessário o PostgreSQL $1 ou posterior; tem a versão $2.",
+ "config-mssql-old": "É necessário o Microsoft SQL Server $1 ou posterior. Tem a versão $2.",
+ "config-sqlite-name-help": "Escolha o nome que identificará a sua wiki.\nNão use espaços ou hífens.\nEste nome será usado como nome do ficheiro de dados do SQLite.",
+ "config-sqlite-parent-unwritable-group": "Não é possível criar o diretório de dados <code><nowiki>$1</nowiki></code>, porque o servidor de internet não tem permissão de escrita no diretório que o contém <code><nowiki>$2</nowiki></code>.\n\nO instalador determinou em que nome de utilizador o seu servidor de internet está a correr.\nPara continuar, configure o diretório <code><nowiki>$3</nowiki></code> para poder ser escrito por este utilizador.\nPara fazê-lo em sistemas Unix ou Linux, use:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Não é possível criar o diretório de dados <code><nowiki>$1</nowiki></code>, porque o servidor de internet não tem permissão de escrita no diretório que o contém <code><nowiki>$2</nowiki></code>.\n\nNão foi possível determinar em que nome de utilizador o seu servidor de internet está a correr.\nPara continuar, configure o diretório <code><nowiki>$3</nowiki></code> para que este possa ser globalmente escrito por esse utilizador (e por outros!).\nPara fazê-lo em sistemas Unix ou Linux, use:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Ocorreu um erro ao criar o diretório de dados \"$1\".\nVerifique a localização e tente novamente.",
+ "config-sqlite-dir-unwritable": "Não foi possível escrever no diretório \"$1\".\nAltere as permissões para que ele possa ser escrito pelo servidor de internet e tente novamente.",
+ "config-sqlite-connection-error": "$1.\n\nVerifique o diretório de dados e o nome da base de dados abaixo e tente novamente.",
+ "config-sqlite-readonly": "Não é possível escrever no ficheiro <code>$1</code>.",
+ "config-sqlite-cant-create-db": "Não foi possível criar o ficheiro da base de dados <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "O PHP não tem suporte FTS3; a reverter o esquema das tabelas para o anterior",
+ "config-can-upgrade": "Esta base de dados contém tabelas do MediaWiki.\nPara atualizá-las para o MediaWiki $1, clique '''Continuar'''.",
+ "config-upgrade-done": "Atualização terminada.\n\nAgora pode [$1 começar a usar a sua wiki].\n\nSe quiser regenerar o seu ficheiro <code>LocalSettings.php</code>, clique o botão abaixo.\nEsta operação '''não é recomendada''' a menos que esteja a ter problemas com a sua wiki.",
+ "config-upgrade-done-no-regenerate": "Atualização terminada.\n\nAgora pode [$1 começar a usar a sua wiki].",
+ "config-regenerate": "Regenerar o LocalSettings.php →",
+ "config-show-table-status": "A consulta <code>SHOW TABLE STATUS</code> falhou!",
+ "config-unknown-collation": "'''Aviso:''' A base de dados está a utilizar uma colação ''(collation)'' desconhecida.",
+ "config-db-web-account": "Conta na base de dados para acesso pela internet",
+ "config-db-web-help": "Selecione o nome de utilizador e a palavra-passe que o servidor de Internet irá utilizar para aceder ao servidor da base de dados, durante a operação normal da wiki.",
+ "config-db-web-account-same": "Usar a mesma conta usada na instalação",
+ "config-db-web-create": "Criar a conta se ainda não existir",
+ "config-db-web-no-create-privs": "A conta que especificou para a instalação não tem privilégios suficientes para criar uma conta.\nA conta que especificar aqui já tem de existir.",
+ "config-mysql-engine": "Motor de armazenamento:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Aviso:</strong> Selecionou o MyISAM para motor de armazenamento do MySQL, uma combinação desaconselhada para usar com o MediaWiki porque:\n* praticamente não permite acessos simultâneos, porque bloqueia tabelas\n* o MyISAM é mais suscetível a perdas da integridade dos dados do que outros motores\n* o código do MediaWiki não trabalha devidamente com o MyISAM\n\nSe a sua instalação do MySQL suporta InnoDB, é altamente recomendado que o escolha em vez do MyISAM.\nSe não suporta o InnoDB, talvez seja uma boa altura para atualizá-la para a versão mais recente.",
+ "config-mysql-only-myisam-dep": "<strong>Aviso:</strong> O único motor de armazenamento para MySQL nesta máquina é o MyISAM e o seu uso com o MediaWiki não é recomendado porque:\n* praticamente não suporta acessos simultâneos, porque bloqueia tabelas\n* o MyISAM é mais suscetível a perdas da integridade dos dados do que outros motores\n* o código do MediaWiki não trabalha devidamente com o MyISAM\n\nA sua instalação MySQL não suporta InnoDB, talvez seja uma boa altura para atualizá-la para a versão mais recente.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> é quase sempre a melhor opção, porque suporta bem acessos simultâneos <i>(concurrency)</i>.\n\n<strong>MyISAM</strong> pode ser mais rápido no modo de utilizador único ou em instalações somente para leitura.\nAs bases de dados MyISAM tendem a perder integridade de dados com mais frequência do que as bases de dados InnoDB.",
+ "config-mysql-charset": "Conjunto de caracteres da base de dados:",
+ "config-mysql-binary": "Binário",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "No modo '''binary''' (\"binário\"), o MediaWiki armazena o texto UTF-8 na base de dados em campos binários.\nIsto é mais eficiente do que o modo UTF-8 do MySQL e permite que sejam usados todos os caracteres Unicode.\n\nNo modo '''UTF-8''', o MySQL saberá em que conjunto de caracteres os seus dados estão e pode apresentá-los e convertê-los da forma mais adequada,\nmas não lhe permitirá armazenar caracteres acima do [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Plano Multilingue Básico].",
+ "config-mssql-auth": "Tipo de autenticação:",
+ "config-mssql-install-auth": "Selecione o tipo de autenticação a usar para ligar à base de dados durante o processo de instalação.\nSe selecionar \"{{int:config-mssql-windowsauth}}\", serão usadas as credenciais do utilizador com que o servidor de Internet está a ser executado.",
+ "config-mssql-web-auth": "Selecione o tipo de autenticação que o servidor de Internet irá usar para se ligar ao servidor da base de dados durante a operação normal da wiki.\nSe selecionar \"{{int:config-mssql-windowsauth}}\", serão usadas as credenciais do utilizador com que o servidor de Internet está a ser executado.",
+ "config-mssql-sqlauth": "Autenticação do SQL Server",
+ "config-mssql-windowsauth": "Autenticação do Windows",
+ "config-site-name": "Nome da wiki:",
+ "config-site-name-help": "Este nome aparecerá no título da janela do seu navegador e em vários outros sítios.",
+ "config-site-name-blank": "Introduza o nome do site.",
+ "config-project-namespace": "Espaço nominal do projeto:",
+ "config-ns-generic": "Projeto",
+ "config-ns-site-name": "O mesmo que o nome da wiki: $1",
+ "config-ns-other": "Outro (especifique)",
+ "config-ns-other-default": "AMinhaWiki",
+ "config-project-namespace-help": "Seguindo o exemplo da Wikipédia, muitas wikis mantêm as páginas das suas normas e políticas, separadas das páginas de conteúdo, num '''domínio do projeto'''.\nTodos os nomes das páginas neste domínio começam com um determinado prefixo, que pode especificar aqui.\nTradicionalmente, este prefixo deriva do nome da wiki, mas não pode conter caracteres de pontuação, como \"#\" ou \":\".",
+ "config-ns-invalid": "O espaço nominal especificado \"<nowiki>$1</nowiki>\" é inválido.\nIntroduza um espaço nominal de projeto diferente.",
+ "config-ns-conflict": "O espaço nominal que especificou, \"<nowiki>$1</nowiki>\", cria um conflito com um dos espaços nominais padrão do MediaWiki.\nEspecifique um espaço nominal do projeto diferente.",
+ "config-admin-box": "Conta de administrador",
+ "config-admin-name": "Seu nome de utilizador:",
+ "config-admin-password": "Palavra-passe:",
+ "config-admin-password-confirm": "Repita a palavra-passe:",
+ "config-admin-help": "Introduza aqui o seu nome de utilizador preferido, por exemplo, \"João Beltrão\".\nEste é o nome que irá utilizar para entrar na wiki.",
+ "config-admin-name-blank": "Introduza um nome de utilizador para administrador.",
+ "config-admin-name-invalid": "O nome de utilizador especificado \"<nowiki>$1</nowiki>\" é inválido.\nIntroduza um nome de utilizador diferente.",
+ "config-admin-password-blank": "Introduza uma palavra-passe para a conta de administrador.",
+ "config-admin-password-mismatch": "As duas palavras-passe que introduziu não coincidem.",
+ "config-admin-email": "Correio eletrónico:",
+ "config-admin-email-help": "Introduza aqui um correio eletrónico que lhe permita receber mensagens de outros utilizadores da wiki, reiniciar a sua palavra-passe e receber notificações de alterações às suas páginas vigiadas. Pode deixar o campo vazio.",
+ "config-admin-error-user": "Ocorreu um erro interno ao criar um administrador com o nome \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Ocorreu um erro interno ao definir uma palavra-passe para o administrador \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Introduziu um correio eletrónico inválido",
+ "config-subscribe": "Subscrever a [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce lista de divulgação de anúncios de lançamento].",
+ "config-subscribe-help": "Esta é uma lista de divulgação de baixo volume para anúncios de lançamento de versões novas, incluindo anúncios de segurança importantes.\nDeve subscrevê-la e atualizar a sua instalação MediaWiki quando são lançadas versões novas.",
+ "config-subscribe-noemail": "Tentou subscrever a lista de divulgação dos anúncios de novas versões, sem fornecer um endereço de correio eletrónico.\nPara subscrever esta lista de divulgação tem de fornecer um endereço de correio eletrónico.",
+ "config-pingback": "Partilhar dados sobre esta instalação com os programadores do MediaWiki.",
+ "config-pingback-help": "Se selecionar esta opção, o MediaWiki fará periodicamente um <i>ping</i> a https://www.mediawiki.org com dados básicos acerca desta instância do MediaWiki. Estes dados incluem, por exemplo, o tipo de sistema, a versão do PHP e a base de dados que escolheu. A Wikimedia Foundation partilha estes dados com os programadores do MediaWiki, para ajudar a guiar o esforço de desenvolvimento futuro. Para o seu sistema, serão enviados os seguintes dados:\n<pre>$1</pre>",
+ "config-almost-done": "Está quase a terminar!\nAgora pode ignorar as restantes configurações e instalar já a wiki.",
+ "config-optional-continue": "Faz-me mais perguntas.",
+ "config-optional-skip": "Já estou aborrecido, instala lá a wiki.",
+ "config-profile": "Perfil de permissões:",
+ "config-profile-wiki": "Wiki aberta",
+ "config-profile-no-anon": "Criação de conta exigida",
+ "config-profile-fishbowl": "Somente utilizadores autorizados",
+ "config-profile-private": "Wiki privada",
+ "config-profile-help": "As wikis funcionam melhor quando se deixa tantas pessoas editá-las quanto possível.\nNo MediaWiki, é fácil rever as alterações recentes e reverter quaisquer estragos causados por utilizadores novatos ou maliciosos.\n\nNo entanto, muitas pessoas consideram o MediaWiki útil de variadas formas e nem sempre é fácil convencer todas as pessoas dos benefícios desta filosofia wiki.\nPor isso pode optar.\n\nUma '''{{int:config-profile-wiki}}''' permite que todos a editem, sem sequer necessitar de autenticação.\nUma wiki com '''{{int:config-profile-no-anon}}''' atribui mais responsabilidade, mas pode afastar os colaboradores ocasionais.\n\nUm cenário '''{{int:config-profile-fishbowl}}''' permite que os utilizadores aprovados editem, mas que o público visione as páginas, incluindo o historial das mesmas.\nUma '''{{int:config-profile-private}}''' só permite que os utilizadores aprovados visionem as páginas e as editem.\n\nApós a instalação, estarão disponíveis mais configurações de privilégios. Consulte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights a entrada relevante no Manual].",
+ "config-license": "Direitos de autor e licença:",
+ "config-license-none": "Sem rodapé com a licença",
+ "config-license-cc-by-sa": "Creative Commons - Atribuição - Partilha nos Mesmos Termos",
+ "config-license-cc-by": "Creative Commons - Atribuição",
+ "config-license-cc-by-nc-sa": "Creative Commons - Atribuição - Uso Não Comercial - Partilha nos Mesmos Termos",
+ "config-license-cc-0": "Creative Commons Zero (Domínio Público)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 ou posterior",
+ "config-license-pd": "Domínio Público",
+ "config-license-cc-choose": "Selecionar uma licença personalizada Creative Commons",
+ "config-license-help": "Muitas wikis de acesso público licenciam todas as colaborações com uma [http://freedomdefined.org/Definition licença livre].\nIsto ajuda a criar um sentido de propriedade da comunidade e encoraja as colaborações a longo prazo.\nTal não é geralmente necessário nas wikis privadas ou corporativas.\n\nSe pretende que seja possível usar textos da Wikipédia na sua wiki e que seja possível a Wikipédia aceitar textos copiados da sua wiki, deve escolher a licença <strong>{{int:config-license-cc-by-sa}}</strong>..\n\nA licença anterior da Wikipédia era a licença GNU Free Documentation License.\nA GFDL é uma licença válida, mas de difícil compreensão.\nTambém é difícil reutilizar conteúdos licenciados com a GFDL.",
+ "config-email-settings": "Definições do correio eletrónico",
+ "config-enable-email": "Ativar mensagens eletrónicas de saída",
+ "config-enable-email-help": "Se quer que o correio eletrónico funcione, as [http://www.php.net/manual/en/mail.configuration.php definições de correio eletrónico do PHP] têm de estar configuradas corretamente.\nSe não pretende viabilizar qualquer funcionalidade de correio eletrónico, pode desativá-lo aqui.",
+ "config-email-user": "Ativar mensagens eletrónicas entre utilizadores",
+ "config-email-user-help": "Permitir que todos os utilizadores troquem entre si mensagens de correio eletrónico, se tiverem ativado esta funcionalidade nas suas preferências.",
+ "config-email-usertalk": "Ativar notificações de alterações à página de discussão dos utilizadores",
+ "config-email-usertalk-help": "Permitir que os utilizadores recebam notificações de alterações à sua página de discussão, se tiverem ativado esta funcionalidade nas suas preferências.",
+ "config-email-watchlist": "Ativar notificação de alterações às páginas vigiadas",
+ "config-email-watchlist-help": "Permitir que os utilizadores recebam notificações de alterações às suas páginas vigiadas, se tiverem ativado esta funcionalidade nas suas preferências.",
+ "config-email-auth": "Ativar autenticação do correio eletrónico",
+ "config-email-auth-help": "Se esta opção for ativada, os utilizadores têm de confirmar o seu endereço de correio eletrónico usando um link que lhes é enviado sempre que o definirem ou alterarem.\nSó os endereços de correio eletrónico autenticados podem receber mensagens eletrónicas dos outros utilizadores ou alterar as mensagens de notificação.\nÉ '''recomendado''' que esta opção seja ativada nas wikis de acesso público para impedir o uso abusivo das funcionalidades de correio eletrónico.",
+ "config-email-sender": "Endereço de correio eletrónico de retorno:",
+ "config-email-sender-help": "Introduza o endereço de correio eletrónico que será usado como endereço de retorno nas mensagens eletrónicas de saída.\nÉ para este endereço que serão enviadas as mensagens que não podem ser entregues.\nMuitos servidores de correio eletrónico exigem que pelo menos a parte do nome do domínio seja válida. \\",
+ "config-upload-settings": "Carregamento de imagens e ficheiros",
+ "config-upload-enable": "Possibilitar o carregamento de ficheiros",
+ "config-upload-help": "O carregamento de ficheiros expõe o seu servidor a riscos de segurança.\nPara mais informações, leia a [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security secção sobre segurança] do Manual Técnico.\n\nPara permitir o carregamento de ficheiros, altere as permissões do subdiretório <code>images</code> no diretório de raiz do MediaWiki para que o servidor de Internet possa escrever nele.\nDepois ative esta opção.",
+ "config-upload-deleted": "Diretório para os ficheiros apagados:",
+ "config-upload-deleted-help": "Escolha um diretório onde serão arquivados os ficheiros apagados.\nO ideal é que este diretório não possa ser diretamente acedido a partir da internet.",
+ "config-logo": "URL do logótipo:",
+ "config-logo-help": "O tema padrão do MediaWiki inclui espaço para um logótipo de 135x160 píxeis acima do menu da barra lateral.\nColoque na wiki uma imagem com estas dimensões e introduza aqui o URL dessa imagem.\n\nSe não pretende usar um logótipo, deixe este campo em branco.",
+ "config-instantcommons": "Ativar Instant Commons",
+ "config-instantcommons-help": "O [https://www.mediawiki.org/wiki/InstantCommons Instant Commons] é uma funcionalidade que permite que as wikis usem imagens, áudio e outros ficheiros multimédia disponíveis no site [https://commons.wikimedia.org/ Wikimedia Commons].\nPara poder usá-los, o MediaWiki necessita de acesso à Internet.\n\nPara mais informações sobre esta funcionalidade, incluindo instruções sobre como configurá-la para usar outras wikis em vez da Wikimedia Commons, consulte o [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos Manual Técnico].",
+ "config-cc-error": "O auxiliar de escolha de licenças da Creative Commons não produziu resultados.\nIntroduza o nome da licença manualmente.",
+ "config-cc-again": "Escolha outra vez...",
+ "config-cc-not-chosen": "Escolha a licença da Creative Commons que pretende e clique \"proceed\".",
+ "config-advanced-settings": "Configuração avançada",
+ "config-cache-options": "Configuração da cache de objetos:",
+ "config-cache-help": "A cache de objetos é usada para melhorar o desempenho do MediaWiki. Armazena dados usados com frequência.\nSites de tamanho médio ou grande são altamente encorajados a ativar esta funcionalidade e os sites pequenos também terão alguns benefícios em fazê-lo.",
+ "config-cache-none": "Sem cache (não é removida nenhuma funcionalidade, mas a velocidade de operação pode ser afectada nas wikis grandes)",
+ "config-cache-accel": "Cache de objetos do PHP (APC, APCu, XCache ou WinCache)",
+ "config-cache-memcached": "Usar Memcached (requer instalação e configurações adicionais)",
+ "config-memcached-servers": "Servidores Memcached:",
+ "config-memcached-help": "Lista de endereços IP que serão usados para o Memcached.\nDeve-se colocar um por linha e indicar a porta a utilizar. Por exemplo:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Selecionou o Memcached como tipo de chache, mas não especificou nenhum servidor.",
+ "config-memcache-badip": "Introduziu um endereço IP inválido para o Memcached: $1.",
+ "config-memcache-noport": "Não especificou a porta a usar para o servidor Memcached: $1.\nSe não sabe qual é a porta, a predefinida é a 11211.",
+ "config-memcache-badport": "Os números das portas do Memcached devem estar entre $1 e $2.",
+ "config-extensions": "Extensões",
+ "config-extensions-help": "Foi detetada a existência das extensões listadas acima, no seu diretório <code>./extensions</code>.\n\nEstas talvez necessitem de configurações adicionais, mas pode ativá-las agora.",
+ "config-skins": "Temas",
+ "config-skins-help": "Os temas listados abaixo foram detetados no seu diretório <code>./skins</code>. Deverá ativar pelo menos um e escolher qual o escolhido por padrão.",
+ "config-skins-use-as-default": "Usar este tema como padrão",
+ "config-skins-missing": "Não foi encontrado nenhum tema; o MediaWiki usará um tema de recurso até instalar temas adequados.",
+ "config-skins-must-enable-some": "Deve escolher pelo menos um tema para ativar.",
+ "config-skins-must-enable-default": "O tema escolhido como padrão deve ser ativado.",
+ "config-install-alreadydone": "<strong>Aviso:</strong> Parece que já instalou o MediaWiki e está a tentar instalá-lo novamente.\nPasse para a próxima página, por favor.",
+ "config-install-begin": "Ao clicar \"{{int:config-continue}}\", vai iniciar a instalação do MediaWiki.\nSe quiser fazer mais alterações, clique \"{{int:config-back}}\".",
+ "config-install-step-done": "terminado",
+ "config-install-step-failed": "falhou",
+ "config-install-extensions": "A incluir as extensões",
+ "config-install-database": "A preparar a base de dados",
+ "config-install-schema": "A criar o esquema (''schema'') da base de dados",
+ "config-install-pg-schema-not-exist": "O esquema ''(schema)'' PostgreSQL não existe",
+ "config-install-pg-schema-failed": "A criação das tabelas falhou.\nCertifique-se de que o utilizador \"$1\" pode escrever no esquema ''(schema)'' \"$2\".",
+ "config-install-pg-commit": "A gravar as alterações",
+ "config-install-pg-plpgsql": "A verificar a presença da linguagem PL/pgSQL",
+ "config-pg-no-plpgsql": "É preciso instalar a linguagem PL/pgSQL na base de dados $1",
+ "config-pg-no-create-privs": "A conta que especificou para a instalação não tem privilégios suficientes para criar uma conta.",
+ "config-pg-not-in-role": "A conta que especificou para o utilizador da internet já existe.\nA conta que especificou para a instalação não é a de um super-utilizador e não pertence ao grupo de utilizadores de acesso pela internet, por isso não pode criar objetos que pertencem ao utilizador da internet.\n\nO MediaWiki necessita que as tabelas pertençam ao utilizador da internet. Especifique outra conta de internet, ou clique \"voltar\" e especifique um utilizador com os privilégios necessários para a instalação.",
+ "config-install-user": "A criar o utilizador da base de dados",
+ "config-install-user-alreadyexists": "O utilizador \"$1\" já existe",
+ "config-install-user-create-failed": "A criação do utilizador \"$1\" falhou: $2",
+ "config-install-user-grant-failed": "A atribuição das permissões ao utilizador \"$1\" falhou: $2",
+ "config-install-user-missing": "O utilizador especificado, \"$1\", não existe.",
+ "config-install-user-missing-create": "O utilizador especificado, \"$1\", não existe.\nMarque a caixa de seleção \"criar conta\" abaixo se pretende criá-la, por favor.",
+ "config-install-tables": "A criar as tabelas",
+ "config-install-tables-exist": "<strong>Aviso:</strong> As tabelas do MediaWiki parecem já existir.\nA criação das tabelas será saltada.",
+ "config-install-tables-failed": "<strong>Erro:</strong> A criação das tabelas falhou com o seguinte erro: $1",
+ "config-install-interwiki": "A preencher a tabela padrão de links interwikis",
+ "config-install-interwiki-list": "Não foi possível ler o ficheiro <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Aviso:</strong> A tabela de interwikis parece já conter entradas.\nO preenchimento padrão desta tabela será saltado.",
+ "config-install-stats": "A inicializar as estatísticas",
+ "config-install-keys": "A gerar as chaves secretas",
+ "config-insecure-keys": "<strong>Aviso:</strong> {{PLURAL:$2|Uma chave segura|Chaves seguras}} ($1) {{PLURAL:$2|gerada durante a instalação não é completamente segura|geradas durante a instalação não são completamente seguras}}. Considere a possibilidade de {{PLURAL:$2|alterá-la|alterá-las}} manualmente.",
+ "config-install-updates": "Evitar executar atualizações desnecessárias",
+ "config-install-updates-failed": "<strong>Erro:</strong> A inserção de chaves de atualização nas tabelas falhou com o seguinte erro: $1",
+ "config-install-sysop": "A criar a conta de administrador",
+ "config-install-subscribe-fail": "Não foi possível subscrever a lista mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL não está instalado e <code>allow_url_fopen</code> não está disponível.",
+ "config-install-mainpage": "A criar a página principal com o conteúdo padrão",
+ "config-install-mainpage-exists": "A página principal já existe; a saltar este passo",
+ "config-install-extension-tables": "A criar as tabelas das extensões ativadas",
+ "config-install-mainpage-failed": "Não foi possível inserir a página principal: $1",
+ "config-install-done": "<strong>Parabéns!</strong>\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro <code>LocalSettings.php</code>.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório de raiz da sua instalação (o mesmo diretório onde está o ficheiro index.php). Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando na ligação abaixo:\n\n$3\n\n<strong>Nota</strong>: Se não o descarregar agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode <strong>[$2 entrar na wiki]</strong>.",
+ "config-install-done-path": "<strong>Parabéns!</strong>\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro <code>LocalSettings.php</code>.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório <code>$4</code>. Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando na ligação abaixo:\n\n$3\n\n<strong>Nota</strong>: Se não fizer o descarregamento agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode <strong>[$2 entrar na wiki]</strong>.",
+ "config-download-localsettings": "Descarregar <code>LocalSettings.php</code>",
+ "config-help": "ajuda",
+ "config-help-tooltip": "clique para expandir",
+ "config-nofile": "Não foi possível encontrar o ficheiro \"$1\". Terá sido apagado?",
+ "config-extension-link": "Sabia que a sua wiki suporta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensões]?\n\nPode consultar as [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensões por categoria] ou a [https://www.mediawiki.org/wiki/Extension_Matrix Matriz de Extensões] para ver a lista completa de extensões.",
+ "config-skins-screenshots": "$1 (capturas de ecrã: $2)",
+ "config-screenshot": "captura de ecrã",
+ "mainpagetext": "<strong>O MediaWiki foi instalado.</strong>",
+ "mainpagedocfooter": "Consulte a [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Ajuda do MediaWiki] para informações sobre o uso do software wiki.\n\n== Onde começar ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de opções de configuração]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Perguntas e respostas frequentes sobre o MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Subscreva a lista de divulgação de novas versões do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Regionalize o MediaWiki para a sua língua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Aprenda a combater <i>spam</i> na sua wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/qqq.json b/www/wiki/includes/installer/i18n/qqq.json
new file mode 100644
index 00000000..a5c67903
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/qqq.json
@@ -0,0 +1,337 @@
+{
+ "@metadata": {
+ "authors": [
+ "Amire80",
+ "Dani",
+ "EugeneZelenko",
+ "Kghbln",
+ "McDutchie",
+ "Mormegil",
+ "Nemo bis",
+ "Nike",
+ "Platonides",
+ "Purodha",
+ "Raymond",
+ "SPQRobin",
+ "Shirayuki",
+ "Siebrand",
+ "Umherirrender",
+ "Waldir",
+ "Jdforrester",
+ "Liuxinyu970226",
+ "Metalhead64"
+ ]
+ },
+ "config-desc": "Short description of the installer.",
+ "config-title": "Parameters:\n* $1 is the version of MediaWiki that is being installed.",
+ "config-information": "{{Identical|Information}}",
+ "config-localsettings-upgrade": "{{doc-important|Do not translate <code>LocalSettings.php</code> and <code>$wgUpgradeKey</code>.}}",
+ "config-localsettings-cli-upgrade": "{{doc-important|Do not translate the <code>LocalSettings.php</code> and the <code>update.php</code> parts.}}",
+ "config-localsettings-key": "Label for the upgrade key that confirms a user upgrading through the web UI has access to LocalSettings.php. Details at https://www.mediawiki.org/wiki/Manual:Upgrading#Web_browser.",
+ "config-localsettings-badkey": "Error message when an incorrect upgrade key has been provided while trying to upgrade.",
+ "config-upgrade-key-missing": "Used in info box. Parameters:\n* $1 - the upgrade key, enclosed in <code><nowiki><pre></nowikI></code> tag.",
+ "config-localsettings-incomplete": "{{doc-important|Do not translate <code>LocalSettings.php</code> and <code><nowiki>{{int:Config-continue}}</nowiki><code>.}}\nParameters:\n* $1 - name of variable (any one of required variables or installer-specific global variables)",
+ "config-localsettings-connection-error": "{{doc-important|Do not translate <code>LocalSettings.php</code>.}}\nUsed as error message. Parameters:\n* $1 - (probably empty string)",
+ "config-session-error": "Parameters:\n* $1 is the error that was encountered with the session.",
+ "config-session-expired": "Parameters:\n* $1 is the configured session lifetime.",
+ "config-no-session": "{{doc-important|Do not translate <code>php.ini</code> and <code>session.save_path</code>.}}\nUsed as error message.",
+ "config-your-language": "Label in MediaWiki installer followed by a drop down where a user can select the language to use for the installer.",
+ "config-your-language-help": "Prompt for which language messages should be displayed in during the installation.",
+ "config-wiki-language": "Prompt for the language the wiki should be set to.",
+ "config-wiki-language-help": "Prompt for the language the wiki's content should be set to.",
+ "config-back": "{{Identical|Back}}",
+ "config-continue": "{{Identical|Continue}}",
+ "config-page-language": "{{Identical|Language}}",
+ "config-page-welcome": "Page header in MediaWiki installer.",
+ "config-page-dbconnect": "Page header in MediaWiki installer.",
+ "config-page-upgrade": "Page header in MediaWiki installer.",
+ "config-page-dbsettings": "Page header in MediaWiki installer.",
+ "config-page-name": "{{Identical|Name}}",
+ "config-page-options": "{{Identical|Options}}",
+ "config-page-install": "{{Identical|Install}}",
+ "config-page-complete": "{{Identical|Complete}}",
+ "config-page-restart": "Page header in MediaWiki installer.",
+ "config-page-readme": "Page header in MediaWiki installer.",
+ "config-page-releasenotes": "{{Identical|Release notes}}",
+ "config-page-copying": "This is a link to the full GPL text",
+ "config-page-upgradedoc": "Page header in MediaWiki installer.",
+ "config-page-existingwiki": "Page header in MediaWiki installer.",
+ "config-help-restart": "Message in warning box in MediaWiki installer.",
+ "config-restart": "Button text to confirm the installation procedure has to be restarted.",
+ "config-welcome": "Notice that the installer is about to check as to whether MediaWiki can be installed.",
+ "config-copyright": "This message follows {{msg-mw|config-env-good}}.\n\nParameters:\n* $1 - copyright and author list",
+ "config-sidebar": "Maximum width for words is 24 characters. Only visible part of the translation counts to this limit.",
+ "config-env-good": "See also:\n* {{msg-mw|Config-env-bad}}",
+ "config-env-bad": "See also:\n* {{msg-mw|Config-env-good}}",
+ "config-env-php": "Parameters:\n* $1 - the version of PHP that has been installed\nSee also:\n* {{msg-mw|config-env-php-toolow}}",
+ "config-env-hhvm": "Parameters:\n* $1 - the version of HHVM that has been installed",
+ "config-unicode-using-intl": "Status message in the MediaWiki installer environment checks.",
+ "config-unicode-pure-php-warning": "PECL is the name of a group producing standard pieces of software for PHP, and intl is the name of their library handling some aspects of internationalization.",
+ "config-unicode-update-warning": "ICU is a body producing standard software tools for support of Unicode and other internationalization aspects. This message warns the system administrator installing MediaWiki that the server's software is not up-to-date and MediaWiki will have problems handling some characters.",
+ "config-no-db": "{{doc-important|Do not translate \"<code>./configure --with-mysqli</code>\" and \"<code>php5-mysql</code>\".}}\nParameters:\n* $1 is comma separated list of database types supported by MediaWiki.\n* $2 is the count of items in $1 - for use in plural.",
+ "config-outdated-sqlite": "Used as warning. Parameters:\n* $1 - the version of SQLite that has been installed\n* $2 - minimum version",
+ "config-no-fts3": "A \"[[:wikipedia:Front and back ends|backend]]\" is a system or component that ordinary users don't interact with directly and don't need to know about, and that is responsible for a distinct task or service - for example, a storage back-end is a generic system for storing data which other applications can use. Possible alternatives for back-end are \"system\" or \"service\", or (depending on context and language) even leave it untranslated.",
+ "config-pcre-old": "Parameters:\n* $1 - minimum PCRE version number\n* $2 - the installed version of [[wikipedia:PCRE|PCRE]]\n{{Related|Config-fatal}}",
+ "config-pcre-no-utf8": "PCRE is a name of a programmers' library for supporting regular expressions. It can probably be translated without change.\n{{Related|Config-fatal}}",
+ "config-memory-raised": "Parameters:\n* $1 is the configured <code>memory_limit</code>.\n* $2 is the value to which <code>memory_limit</code> was raised.",
+ "config-memory-bad": "Parameters:\n* $1 is the configured <code>memory_limit</code>.",
+ "config-xcache": "Message indicates if this program is available",
+ "config-apc": "Message indicates if this program is available",
+ "config-apcu": "Message indicates if this program is available",
+ "config-wincache": "Message indicates if this program is available",
+ "config-no-cache-apcu": "Status message in the MediaWiki installer environment checks.",
+ "config-mod-security": "Status message in the MediaWiki installer environment checks.",
+ "config-diff3-bad": "Status message in the MediaWiki installer environment checks.",
+ "config-git": "Message if Git version control software is available.\nParameter:\n* $1 is the <code>Git</code> executable file name.",
+ "config-git-bad": "Message if Git version control software is not found.",
+ "config-imagemagick": "$1 is ImageMagick's <code>convert</code> executable file name.\n\nAdd dir=\"ltr\" to the <nowiki><code></nowiki> for right-to-left languages.",
+ "config-gd": "Status message in the MediaWiki installer environment checks.",
+ "config-no-scaling": "Status message in the MediaWiki installer environment checks.",
+ "config-no-uri": "Status message in the MediaWiki installer environment checks.",
+ "config-no-cli-uri": "Parameters:\n* $1 is the default value for scriptpath.\n\nDo not translate <nowiki><code>--scriptpath</code></nowiki>",
+ "config-using-server": "Used as a part of environment check result. Parameters:\n* $1 - default server name",
+ "config-using-uri": "Used as a part of environment check result. Parameters:\n* $1 - server name\n* $2 - script path",
+ "config-uploads-not-safe": "Used as a part of environment check result. Parameters:\n* $1 - name of directory for images: <code>$IP/images/</code>",
+ "config-no-cli-uploads-check": "CLI = [[w:Command-line interface|command-line interface]] (i.e. the installer runs as a command-line script, not using HTML interface via an internet browser)",
+ "config-brokenlibxml": "Status message in the MediaWiki installer environment checks.",
+ "config-suhosin-max-value-length": "{{doc-important|Do not translate \"length\", \"suhosin.get.max_value_length\", \"php.ini\", \"$wgResourceLoaderMaxQueryLength\" and \"LocalSettings.php\".}}\nMessage shown when PHP parameter <code>suhosin.get.max_value_length</code> is between 0 and 1023 (that max value is hard set in MediaWiki software).",
+ "config-using-32bit": "Warning message shown when installing on a 32-bit system.",
+ "config-db-type": "Field label in the MediaWiki installer followed by possible database types.",
+ "config-db-host": "Used as label.\n\nAlso used in {{msg-mw|Config-missing-db-host}}.",
+ "config-db-host-help": "{{doc-singularthey}}",
+ "config-db-host-oracle": "TNS = [[w:Transparent Network Substrate]].\n\nUsed as label.\n\nAlso used in {{msg-mw|Config-missing-db-server-oracle}}.",
+ "config-db-host-oracle-help": "See also:\n* {{msg-mw|Config-invalid-db-server-oracle}}",
+ "config-db-wiki-settings": "This is more acurate: \"Enter identifying or distinguishing data for this wiki\" since a MySQL database can host tables of several wikis.",
+ "config-db-name": "Used as label.\n\nAlso used in {{msg-mw|Config-missing-db-name}}.\n{{Identical|Database name}}",
+ "config-db-name-help": "Help box text in the MediaWiki installer.",
+ "config-db-name-oracle": "Field label in the MediaWiki installer where an Oracle database schema can be specified.",
+ "config-db-account-oracle-warn": "A \"[[:wikipedia:Front and back ends|backend]]\" is a system or component that ordinary users don't interact with directly and don't need to know about, and that is responsible for a distinct task or service - for example, a storage back-end is a generic system for storing data which other applications can use. Possible alternatives for back-end are \"system\" or \"service\", or (depending on context and language) even leave it untranslated.",
+ "config-db-install-account": "Legend in the MediaWiki installer for the section where database username and password have to be provided.",
+ "config-db-username": "Used as label.",
+ "config-db-password": "Field label in the MediaWiki installer where database password has to be provided.",
+ "config-db-install-username": "Help box text in the MediaWiki installer clarifying the requirement for database username.",
+ "config-db-install-password": "Help box text in the MediaWiki installer clarifying the requirement for database password.",
+ "config-db-install-help": "Help text in MediaWiki installer.",
+ "config-db-account-lock": "It might be easier to translate ''normal operation'' as \"also after the installation process\"",
+ "config-db-wiki-account": "Fieldset label for database user information.",
+ "config-db-wiki-help": "Help text for database user information.",
+ "config-db-prefix": "Field label for database prefix (a piece of text that all tables for a MediaWiki instance are prefixed with).",
+ "config-db-prefix-help": "Help text for database prefix form field.",
+ "config-mysql-old": "Used as error message. Parameters:\n* $1 - minimum version\n* $2 - the version of MySQL that has been installed\n{{Related|Config-old}}",
+ "config-db-port": "Field label in MediaWiki installer for database port.",
+ "config-db-schema": "Field label in MediaWiki installer for database schema.",
+ "config-db-schema-help": "Help text in MediaWiki installer for database schema.",
+ "config-pg-test-error": "Parameters:\n* $1 - database name\n* $2 - error message",
+ "config-sqlite-dir": "Field label for a folder location.",
+ "config-sqlite-dir-help": "{{doc-important|Do not translate <code>.htaccess</code> and <code>/var/lib/mediawiki/yourwiki</code>.}}\nUsed in help box.",
+ "config-oracle-def-ts": "Field label for an Oracle default tablespace.",
+ "config-oracle-temp-ts": "Field label for an Oracle temporary tablespace.",
+ "config-type-mysql": "\"Or compatible\" refers to several database systems that are compatible with MySQL, as explained in {{msg-mw|config-dbsupport-mysql}}, and thus also work with this choice of database type.",
+ "config-type-postgres": "{{optional}}",
+ "config-type-sqlite": "{{optional}}",
+ "config-type-oracle": "{{optional}}",
+ "config-type-mssql": "{{optional}}",
+ "config-support-info": "Parameters:\n* $1 - a list of DBMSs that MediaWiki supports, composed with config-dbsupport-* messages.\nSee also:\n* {{msg-mw|Config-dbsupport-mysql}}\n* {{msg-mw|Config-dbsupport-postgres}}\n* {{msg-mw|Config-dbsupport-oracle}}\n* {{msg-mw|Config-dbsupport-sqlite}}\n* {{msg-mw|Config-dbsupport-mssql}}",
+ "config-dbsupport-mysql": "Used in:\n* {{msg-mw|config-support-info}}\n{{Related|Config-dbsupport}}",
+ "config-dbsupport-postgres": "Used in:\n* {{msg-mw|config-support-info}}\n{{Related|Config-dbsupport}}",
+ "config-dbsupport-sqlite": "Used in:\n* {{msg-mw|config-support-info}}\n{{Related|Config-dbsupport}}",
+ "config-dbsupport-oracle": "Used in:\n* {{msg-mw|Config-support-info}}.\n{{Related|Config-dbsupport}}",
+ "config-dbsupport-mssql": "Used in:\n* {{msg-mw|Config-support-info}}\n{{Related|Config-dbsupport}}",
+ "config-header-mysql": "Header for MySQL database settings in the MediaWiki installer.",
+ "config-header-postgres": "Header for PostgreSQL database settings in the MediaWiki installer.",
+ "config-header-sqlite": "Header for SQLite database settings in the MediaWiki installer.",
+ "config-header-oracle": "Header for Oracle database settings in the MediaWiki installer.",
+ "config-header-mssql": "Used as a section heading on the installer form, inside of a fieldset",
+ "config-invalid-db-type": "Error message in MediaWiki installer when an invalid database type has been provided.",
+ "config-missing-db-name": "Refers to {{msg-mw|Config-db-name}}.\n{{Related|Config-missing}}",
+ "config-missing-db-host": "Refers to {{msg-mw|Config-db-host}}.\n{{Related|Config-missing}}",
+ "config-missing-db-server-oracle": "Refers to {{msg-mw|Config-db-host-oracle}}.\n{{Related|Config-missing}}",
+ "config-invalid-db-server-oracle": "Used as error message. Parameters:\n* $1 - database server name\nSee also:\n* {{msg-mw|Config-db-host-oracle-help}}",
+ "config-invalid-db-name": "Used as error message. Parameters:\n* $1 - database name\nSee also:\n* {{msg-mw|Config-invalid-db-prefix}}",
+ "config-invalid-db-prefix": "Used as error message. Parameters:\n* $1 - database prefix\nSee also:\n* {{msg-mw|Config-invalid-db-name}}",
+ "config-connection-error": "$1 is the external error from the database, such as \"DB connection error: Access denied for user 'dba'@'localhost' (using password: YES) (localhost).\"\n\nIf you're translating this message to a right-to-left language, consider writing <nowiki><div dir=\"ltr\">$1.</div></nowiki>. (When the bidi features for HTML5 will be implemented in the browsers, it will probably be a good idea to write it as <nowiki><div dir=\"auto\">$1.</div></nowiki>.)",
+ "config-invalid-schema": "*$1 - schema name",
+ "config-db-sys-create-oracle": "Error message in the MediaWiki installer when Oracle is used as database and an incorrect user account type has been provided.",
+ "config-db-sys-user-exists-oracle": "Used as error message. Parameters:\n* $1 - database username",
+ "config-postgres-old": "Used as error message. Used as warning. Parameters:\n* $1 - minimum version\n* $2 - the version of PostgreSQL that has been installed\n{{Related|Config-old}}",
+ "config-mssql-old": "Used as an error message. Parameters:\n* $1 - minimum version\n* $2 - the version of Microsoft SQL Server that has been installed\n{{Related|Config-old}}",
+ "config-sqlite-name-help": "Help text for the form field for the SQLite data file name.",
+ "config-sqlite-parent-unwritable-group": "Used as SQLite error message. Parameters:\n* $1 - data directory\n* $2 - \"dirname\" part of $1\n* $3 - \"basename\" part of $1\n* $4 - web server's primary group name\nSee also:\n* {{msg-mw|Config-sqlite-parent-unwritable-nogroup}}",
+ "config-sqlite-parent-unwritable-nogroup": "Used as SQLite error message. Parameters:\n* $1 - data directory\n* $2 - \"dirname\" part of $1\n* $3 - \"basename\" part of $1\nSee also:\n* {{msg-mw|Config-sqlite-parent-unwritable-group}}",
+ "config-sqlite-mkdir-error": "Used as SQLite error message. Parameters:\n* $1 - data directory name",
+ "config-sqlite-dir-unwritable": "webserver refers to a software like Apache or Lighttpd.",
+ "config-sqlite-connection-error": "Used as SQLite error message. Parameters:\n* $1 - error message which SQLite server returned",
+ "config-sqlite-readonly": "Used as SQLite error message. Parameters:\n* $1 - filename",
+ "config-sqlite-cant-create-db": "Used as SQLite error message. Parameters:\n* $1 - filename",
+ "config-sqlite-fts3-downgrade": "Status message in the MediaWiki installer when SQLite is used without the FTS3 module. The FTS3 feature allows users to create special tables with a built-in full-text index.",
+ "config-can-upgrade": "Parameters:\n* $1 - Version or Revision indicator.",
+ "config-upgrade-done": "Used as success message. Parameters:\n* $1 - full URL of index.php\nSee also:\n* {{msg-mw|config-upgrade-done-no-regenerate}}",
+ "config-upgrade-done-no-regenerate": "Used as success message. Parameters:\n* $1 - full URL of index.php\nSee also:\n* {{msg-mw|config-upgrade-done}}",
+ "config-regenerate": "This message appears in a button after LocalSettings.php is generated and downloaded at the end of the MediaWiki installation process.",
+ "config-show-table-status": "{{doc-important|\"<code>SHOW TABLE STATUS</code>\" is a MySQL command. Do not translate this.}}",
+ "config-unknown-collation": "Warning messages in the MediaWiki installer for the database type MySQL when an unrecognised collation is used.",
+ "config-db-web-account": "Fieldset legend in MediaWiki installer",
+ "config-db-web-help": "Help text in MediaWiki installer.",
+ "config-db-web-account-same": "checkbox label",
+ "config-db-web-create": "checkbox label",
+ "config-db-web-no-create-privs": "Error message in the MediaWiki installer.",
+ "config-mysql-engine": "Field label for MySQL storage engine in the MediaWiki installer.",
+ "config-mysql-innodb": "Option for the MySQL storage engine in the MediaWiki installer.",
+ "config-mysql-myisam": "Option for the MySQL storage engine in the MediaWiki installer.",
+ "config-mysql-myisam-dep": "Warning message in the MediaWiki installer when MyISAM is chosen as MySQL storage engine.",
+ "config-mysql-only-myisam-dep": "Used as warning message when mysql does not support the minimum suggested feature set.",
+ "config-mysql-engine-help": "Help text in MediaWiki installer with advice for picking a MySQL storage engine.",
+ "config-mysql-charset": "Field label for the MySQL character set in the MediaWiki installer.",
+ "config-mysql-binary": "{{Identical|Binary}}",
+ "config-mysql-utf8": "Option for the MySQL character set in the MediaWiki installer.",
+ "config-mysql-charset-help": "Help text for the MySQL character set setting in the MediaWiki installer.",
+ "config-mssql-auth": "Radio button group label.\n\nFollowed by the following radio button labels:\n* {{msg-mw|Config-mssql-sqlauth}}\n* {{msg-mw|Config-mssql-windowsauth}}",
+ "config-mssql-install-auth": "Used as the help text for the \"Authentication type\" radio button when typing in database settings for installation.\n\nRefers to {{msg-mw|Config-mssql-windowsauth}}.\n\nSee also:\n* {{msg-mw|Config-mssql-web-auth}}",
+ "config-mssql-web-auth": "Used as the help text for the \"Authentication type\" radio button when typing in database settings for normal wiki usage.\n\nRefers to {{msg-mw|Config-mssql-windowsauth}}.\n\nSee also:\n* {{msg-mw|Config-mssql-install-auth}}",
+ "config-mssql-sqlauth": "Radio button.\n\n\"SQL Server\" refers to \"Microsoft SQL Server\".\n\nSee also:\n* {{msg-mw|Config-mssql-windowsauth}}",
+ "config-mssql-windowsauth": "Radio button. The official term is \"Integrated Windows Authentication\" but Microsoft itself uses \"Windows Authentication\" elsewhere in Microsoft SQL Server as a synonym.\n\nAlso used in:\n* {{msg-mw|Config-mssql-install-auth}}\n* {{msg-mw|Config-mssql-web-auth}}\n\nSee also:\n* {{msg-mw|Config-mssql-sqlauth}}",
+ "config-site-name": "Field label for the form field where a wiki name has to be entered.",
+ "config-site-name-help": "Help text for the form field where a wiki name has to be entered.",
+ "config-site-name-blank": "Error text in the MediaWiki installer when the site name is left empty.",
+ "config-project-namespace": "Field label for the form field where the name of the MediaWiki project namespace has to be entered.",
+ "config-ns-generic": "Used as label for \"namespace type\" radio button.\n\nSee also:\n* {{msg-mw|Config-ns-site-name}}\n* {{msg-mw|Config-ns-other}}\n{{Identical|Project}}",
+ "config-ns-site-name": "Used as label for \"namespace type\" radio button. Parameters:\n* $1 - wiki name\nSee also:\n* {{msg-mw|Config-ns-generic}}\n* {{msg-mw|Config-ns-other}}",
+ "config-ns-other": "Used as label for \"namespace type\" radio button.\n\nThis message is followed by the input box which enables to '''specify''' a namespace name.\n\nSee also:\n* {{msg-mw|Config-ns-site-name}}\n* {{msg-mw|Config-ns-generic}}",
+ "config-ns-other-default": "Default value for the option of a different project namespace name in the MediaWiki installer.",
+ "config-project-namespace-help": "Help text for the MediaWiki project namespace setting.",
+ "config-ns-invalid": "Used as error message. Parameters:\n* $1 - namespace name\nSee also:\n* {{msg-mw|Config-ns-conflict}}",
+ "config-ns-conflict": "Used as error message. Parameters:\n* $1 - namespace name\nSee also:\n* {{msg-mw|Config-ns-invalid}}",
+ "config-admin-box": "Fieldset label for settings for the MediaWiki administrator account that is created by the MediaWiki installer.",
+ "config-admin-name": "{{Identical|Your username}}",
+ "config-admin-password": "{{Identical|Password}}",
+ "config-admin-password-confirm": "{{Identical|Password again}}",
+ "config-admin-help": "Help text for the MediaWiki admin user creation form fields.",
+ "config-admin-name-blank": "Error message when no administrator username was provided.",
+ "config-admin-name-invalid": "Used as error message. Parameters:\n* $1 - username of administrator",
+ "config-admin-password-blank": "Error message when no administrator password was provided.",
+ "config-admin-password-mismatch": "Error message when no two equal administrator passwords were provided.",
+ "config-admin-email": "{{Identical|E-mail address}}",
+ "config-admin-email-help": "Help text for an administrator email address in the MediaWiki installer.",
+ "config-admin-error-user": "Used as error message. Parameters:\n* $1 - username of administrator\nSee also:\n* {{msg-mw|Config-admin-error-password}}",
+ "config-admin-error-password": "Used as error message. Parameters:\n* $1 - username of administrator\n* $2 - error message\nSee also:\n* {{msg-mw|Config-admin-error-user}}",
+ "config-admin-error-bademail": "Error text in the MediaWiki installer when an entered email address does not validate.",
+ "config-subscribe": "Used as label for the installer checkbox",
+ "config-subscribe-help": "\"Low-volume\" in this context means that there will be few e-mails to that mailing list per time period.",
+ "config-subscribe-noemail": "Error text in MediaWiki installer.",
+ "config-pingback": "Option in the MediaWiki installer to submit data about this installation to MediaWiki.org.",
+ "config-pingback-help": "Explains what data will be shared if the user chooses to submit data to MediaWiki.org. $1 is the JSON data that will be sent",
+ "config-almost-done": "Status message in the MediaWiki installer.",
+ "config-optional-continue": "Option in the MediaWiki installer to make a more fine-tuned installation.",
+ "config-optional-skip": "Option in the MediaWiki installer to start executing the actual installation and stop asking questions.",
+ "config-profile": "Field label for the radio button list to pick a standard user rights profile.",
+ "config-profile-wiki": "Option for the radio button list to pick a standard user rights profile.",
+ "config-profile-no-anon": "Option for the radio button list to pick a standard user rights profile.",
+ "config-profile-fishbowl": "Option for the radio button list to pick a standard user rights profile.",
+ "config-profile-private": "Option for the radio button list to pick a standard user rights profile.",
+ "config-profile-help": "Messages referenced:\n* {{msg-mw|config-profile-wiki}}\n* {{msg-mw|config-profile-no-anon}}\n* {{msg-mw|config-profile-fishbowl}}\n* {{msg-mw|config-profile-private}}",
+ "config-license": "Setting for the wiki content license in the MediaWiki installer.",
+ "config-license-none": "Option for the wiki content license in the MediaWiki installer.",
+ "config-license-cc-by-sa": "Option for the wiki content license in the MediaWiki installer.",
+ "config-license-cc-by": "Option for the wiki content license in the MediaWiki installer.",
+ "config-license-cc-by-nc-sa": "Option for the wiki content license in the MediaWiki installer.",
+ "config-license-cc-0": "Option for the wiki content license in the MediaWiki installer.",
+ "config-license-gfdl": "Option for the wiki content license in the MediaWiki installer.",
+ "config-license-pd": "{{Identical|Public domain}}",
+ "config-license-cc-choose": "Option for the wiki content license in the MediaWiki installer.",
+ "config-license-help": "Help text in MediaWiki installer for license selection.\n\nRefers to {{msg-mw|Config-license-cc-by-sa}}.",
+ "config-email-settings": "{{Identical|E-mail setting}}",
+ "config-enable-email": "Checkbox label in the MediaWiki installer to allow the wiki to send email to its users.",
+ "config-enable-email-help": "Help text in the MediaWiki installer to allow the wiki to send email to its users.",
+ "config-email-user": "{{Identical|Enable user-to-user e-mail}}",
+ "config-email-user-help": "Label for user-to-user e-mailing option.",
+ "config-email-usertalk": "Label for user e-mailing notification option.",
+ "config-email-usertalk-help": "Description for user e-mailing notification option.",
+ "config-email-watchlist": "Label for user watchlist notification option.",
+ "config-email-watchlist-help": "Description for user watchlist notification option.",
+ "config-email-auth": "Label for user e-mail authentication requirement.",
+ "config-email-auth-help": "Description for user e-mail authentication requirement.",
+ "config-email-sender": "Prompt for the e-mail address from which the wiki's e-mails will be sent.",
+ "config-email-sender-help": "Explanation for the e-mail address from which the wiki's e-mails will be sent.",
+ "config-upload-settings": "Label for the file and image upload settings section.",
+ "config-upload-enable": "Label for the option to enable the file and image upload system.",
+ "config-upload-help": "The word \"mode\" here refers to the access rights given to various user groups when attempting to create and store files and/or subdiretories in the said directory on the server. It also refers to the <code>mode</code> command used to maipulate said right mask under Unix, Linux, and similar operating systems. A less operating-system-centric translation is fine.",
+ "config-upload-deleted": "Prompt for the server directory into which deleted files should be moved.",
+ "config-upload-deleted-help": "Explanation for {{msg|config-upload-deleted}}.",
+ "config-logo": "Prompt for a link to the logo to use for the wiki.",
+ "config-logo-help": "Help string shown to the user explaining the requirements for the wiki's logo.",
+ "config-instantcommons": "Used as label for the checkbox.\n\nThe help message for this checkbox is:\n* {{msg-mw|Config-instantcommons-help}}",
+ "config-instantcommons-help": "Used as help message for the checkbox which is labeled {{msg-mw|config-instantcommons}}.",
+ "config-cc-error": "Prompt to manually enter a license when the tool fails to match.",
+ "config-cc-again": "Prompt to re-try picking a Creative Commons license.",
+ "config-cc-not-chosen": "{{doc-important|Do not translate the \"<code>proceed</code>\" part.}}\nThis message refers to a block of HTML being embedded into the installer page. It comes from the Creative Commons Web site. The block is in the English language. It is a scripted license chooser. When an individual license has been selected, it asks you to click \"proceed\" so as to return to the MediaWiki installer page.",
+ "config-advanced-settings": "Label for the advanced configuration settings page.",
+ "config-cache-options": "Prompt for the object caching options.",
+ "config-cache-help": "Explanation for what object caching is, next to {{msg|config-cache-options}}.",
+ "config-cache-none": "Label for the object caching disabled option.",
+ "config-cache-accel": "Label for the object caching via PHP option.",
+ "config-cache-memcached": "{{doc-important|Do not translate \"memcached\".}}\nLabel for the object caching via memcached option.",
+ "config-memcached-servers": "{{doc-important|Do not translate \"memcached\".}}\n{{Identical|Memcached server}}",
+ "config-memcached-help": "Prompt for the object caching via Memcached option for the user to define server(s) to be used.",
+ "config-memcache-needservers": "Error message for the object caching via Memcached option when the user has failed to define servers at the above prompt.\n{{doc-important|Do not translate \"memcached\".}}",
+ "config-memcache-badip": "Used as error message. Parameters:\n* $1 - IP address for Memcached\nSee also:\n* {{msg-mw|Config-memcache-noport}}\n* {{msg-mw|Config-memcache-badport}}",
+ "config-memcache-noport": "Used as error message. Parameters:\n* $1 - Memcached server name\nSee also:\n* {{msg-mw|Config-memcache-badip}}\n* {{msg-mw|Config-memcache-badport}}\n{{doc-important|Do not translate \"memcached\".}}",
+ "config-memcache-badport": "Used as error message. Parameters:\n* $1 - 1 (hard-coded)\n* $2 - 65535 (hard-coded)\nSee also:\n* {{msg-mw|Config-memcache-badip}}\n* {{msg-mw|Config-memcache-noport}}\n{{doc-important|Do not translate \"memcached\".}}",
+ "config-extensions": "{{Identical|Extension}}",
+ "config-extensions-help": "{{doc-important|Do not translate <code>./extensions</code>.}}\nUsed in help box.",
+ "config-skins": "{{Identical|Skin}}",
+ "config-skins-help": "{{doc-important|Do not translate <code>./skins</code>.}}\nUsed in help box.",
+ "config-skins-use-as-default": "Label shown next to skin names.",
+ "config-skins-missing": "Warning message shown when there are no skins to install.",
+ "config-skins-must-enable-some": "Error message shown when the user does silly things.",
+ "config-skins-must-enable-default": "Error message shown when the user does silly things.",
+ "config-install-alreadydone": "Error message shown to users visiting the installer when the wiki appears to already be set up.",
+ "config-install-begin": "Prompt at the end of the initial configuration options screen before the wiki software is installed.",
+ "config-install-step-done": "{{Identical|Done}}",
+ "config-install-step-failed": "{{Identical|Failed}}",
+ "config-install-extensions": "Notice shown to the user during the install about progress when extensions are being installed.",
+ "config-install-database": "Message indicates the database is being set up\n\nSee also:\n*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-updates}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-schema": "*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-pg-schema-not-exist": "Error message shown to users picking PostgreSQL.",
+ "config-install-pg-schema-failed": "Parameters:\n* $1 = database user name (usernames in the database are unrelated to wiki user names)\n* $2 =",
+ "config-install-pg-commit": "Notice shown to the user during the install about progress with PostgreSQL.",
+ "config-install-pg-plpgsql": "Notice shown to users using PL/pgSQL installation.",
+ "config-pg-no-plpgsql": "Used as error message. Parameters:\n* $1 - database name",
+ "config-pg-no-create-privs": "Error shown to users using PL/pgSQL installation when the system account lacks the ability to install.",
+ "config-pg-not-in-role": "Error shown to users using PL/pgSQL installation when the system account lacks the ability to install.",
+ "config-install-user": "Message indicates that the user is being created\n\nSee also:\n*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-user-alreadyexists": "Used as warning. Parameters:\n* $1 - database username",
+ "config-install-user-create-failed": "Used as MySQL warning and as PostgreSQL error. Parameters:\n* $1 - database username\n* $2 - detailed warning/error message",
+ "config-install-user-grant-failed": "Parameters:\n* $1 is the database username for which granting rights failed\n* $2 is the error message",
+ "config-install-user-missing": "Used as PostgreSQL error message. Parameters:\n* $1 - database username\nSee also:\n* {{msg-mw|Config-install-user-missing-create}}",
+ "config-install-user-missing-create": "Used as PostgreSQL error message. Parameters:\n* $1 - database username\nSee also:\n* {{msg-mw|Config-install-user-missing}}",
+ "config-install-tables": "Message indicates that the tables are being created\n\nSee also:\n*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-updates}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-tables-exist": "Error notice during the installation saying that the database already seems set up for MediaWiki, so it's continuing without taking that step.",
+ "config-install-tables-failed": "Used as PostgreSQL error message. Parameters:\n* $1 - detailed error message",
+ "config-install-interwiki": "Message indicates that the interwikitables are being populated\n\nSee also:\n*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-interwiki-list": "{{doc-important|Do not translate <code>interwiki.list</code>.}}\nUsed as error message.",
+ "config-install-interwiki-exists": "Error notice during the installation saying that one of the database tables is already set up, so it's continuing without taking that step.",
+ "config-install-stats": "*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-keys": "*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-insecure-keys": "Parameters:\n* $1 - A list of names of the secret keys that were generated.\n* $2 - the number of items in the list $1, to be used with PLURAL.",
+ "config-install-updates": "Message indicating that the updatelog table is filled with keys of updates that won't be run when running database updates.\n\nSee also:\n*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-updates}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-updates-failed": "Used as error message. Parameters:\n* $1 - detailed error message",
+ "config-install-sysop": "Message indicates that the administrator user account is being created\n\nSee also:\n*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-subscribe-fail": "{{doc-important|\"[[m:mail:mediawiki-announce|mediawiki-announce]]\" is the name of a mailing list and should not be translated.}}\nA message displayed if the MediaWiki installer encounters an error making a request to lists.wikimedia.org which hosts the mailing list.\n* $1 - the HTTP error encountered, reproduced as is (English string)",
+ "config-install-subscribe-notpossible": "Error shown when automatically subscribing to the MediaWiki announcements mailing list fails.",
+ "config-install-mainpage": "*{{msg-mw|Config-install-database}}\n*{{msg-mw|Config-install-tables}}\n*{{msg-mw|Config-install-schema}}\n*{{msg-mw|Config-install-user}}\n*{{msg-mw|Config-install-interwiki}}\n*{{msg-mw|Config-install-stats}}\n*{{msg-mw|Config-install-keys}}\n*{{msg-mw|Config-install-sysop}}\n*{{msg-mw|Config-install-mainpage}}",
+ "config-install-mainpage-exists": "Warning shown when installer attempts to create main page but it already exists.",
+ "config-install-extension-tables": "Notice shown to the user during the install about progress.",
+ "config-install-mainpage-failed": "Used as error message. Parameters:\n* $1 - detailed error message",
+ "config-install-done": "Parameters:\n* $1 is the URL to LocalSettings download\n* $2 is a link to the wiki.\n* $3 is a download link with attached download icon. The config-download-localsettings message will be used as the link text.",
+ "config-install-done-path": "Parameters:\n* $1 is the URL to LocalSettings download\n* $2 is a link to the wiki.\n* $3 is a download link with attached download icon. The config-download-localsettings message will be used as the link text.\n* $4 is the filesystem location of where the LocalSettings.php file should be saved to.",
+ "config-download-localsettings": "The link text used in the download link in config-install-done.",
+ "config-help": "This is used in help boxes.\n{{Identical|Help}}",
+ "config-help-tooltip": "Tooltip for the 'help' links ({{msg-mw|config-help}}), to make it clear they'll expand in place rather than open a new page",
+ "config-nofile": "Used as failure message. Parameters:\n* $1 - filename",
+ "config-extension-link": "Shown on last page of installation to inform about possible extensions.\n{{Identical|Did you know}}",
+ "config-skins-screenshots": "Radio button text, $1 is the skin name, and $2 is a list of links to screenshots of that skin",
+ "config-skins-screenshot": "Radio button text, $1 is the skin name, and $2 is a link to a screenshot of that skin, where the link text is {{msg-mw|config-screenshot}}.",
+ "config-screenshot": "Link text for the link in {{msg-mw|config-skins-screenshot}}\n{{Identical|Screenshot}}",
+ "mainpagetext": "Along with {{msg-mw|mainpagedocfooter}}, the text you will see on the Main Page when your wiki is installed.",
+ "mainpagedocfooter": "Along with {{msg-mw|mainpagetext}}, the text you will see on the Main Page when your wiki is installed.\nThis might be a good place to put information about <nowiki>{{GRAMMAR:}}</nowiki>. See [[{{NAMESPACE}}:{{BASEPAGENAME}}/fi]] for an example. For languages having grammatical distinctions and not having an appropriate <nowiki>{{GRAMMAR:}}</nowiki> software available, a suggestion to check and possibly amend the messages having <nowiki>{{SITENAME}}</nowiki> may be valuable. See [[{{NAMESPACE}}:{{BASEPAGENAME}}/ksh]] for an example."
+}
diff --git a/www/wiki/includes/installer/i18n/qu.json b/www/wiki/includes/installer/i18n/qu.json
new file mode 100644
index 00000000..9f8e0a38
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/qu.json
@@ -0,0 +1,20 @@
+{
+ "@metadata": {
+ "authors": [
+ "AlimanRuna"
+ ]
+ },
+ "config-desc": "MediaWiki tiyachiq",
+ "config-title": "MediaWiki $1 tiyachiy",
+ "config-information": "Willay",
+ "config-your-language": "Rimayniyki:",
+ "config-wiki-language": "Wiki rimay:",
+ "config-back": "← Ñawpaqman",
+ "config-extensions": "Mast'ariykuna",
+ "config-install-step-done": "rurasqañam",
+ "config-install-step-failed": "manam aypasqachu",
+ "config-help": "yanapay",
+ "config-nofile": "\"$1\" sutiyuq willañiqiqa manam tarisqachu. Qullusqachu?",
+ "mainpagetext": "'''MediaWiki nisqa llamp'u kaqqa aypaylla takyachisqañam.'''",
+ "mainpagedocfooter": "Wiki llamp'u kaqmanta willasunaykipaqqa [https://meta.wikimedia.org/wiki/Help:Contents Ruraqpaq yanapana] ''(User's Guide)'' sutiyuq p'anqata qhaway.\n\n== Qallarichkaspa ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Kunphigurasyun churanamanta sutisuyu]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki nisqamanta pasaq tapuykuna]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki kachaykuy e-chaski sutisuyu]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources MediaWiki nisqata qampa rimaykiman t'ikray]"
+}
diff --git a/www/wiki/includes/installer/i18n/rgn.json b/www/wiki/includes/installer/i18n/rgn.json
new file mode 100644
index 00000000..0783bde6
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/rgn.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''L'instalaziòn d'MediaWiki l'è andêda ben'''"
+}
diff --git a/www/wiki/includes/installer/i18n/rm.json b/www/wiki/includes/installer/i18n/rm.json
new file mode 100644
index 00000000..61345ba5
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/rm.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gion-andri"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki è vegnì installà cun success.'''",
+ "mainpagedocfooter": "Consultai il [https://meta.wikimedia.org/wiki/Help:Contents manual per utilisaders] per infurmaziuns davart l'utilisaziun da questa software da wiki.\n\n== Cumenzar ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Glista da las opziuns per la configuraziun]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Glista da mail da MediaWiki cun annunzias da novas versiuns]"
+}
diff --git a/www/wiki/includes/installer/i18n/ro.json b/www/wiki/includes/installer/i18n/ro.json
new file mode 100644
index 00000000..14c60fe5
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ro.json
@@ -0,0 +1,161 @@
+{
+ "@metadata": {
+ "authors": [
+ "Firilacroco",
+ "Minisarm",
+ "Stelistcristi",
+ "XXN",
+ "Tuxilina",
+ "Strainu"
+ ]
+ },
+ "config-desc": "Programul de instalare pentru MediaWiki",
+ "config-title": "Instalarea MediaWiki $1",
+ "config-information": "Informații",
+ "config-localsettings-key": "Cheie de actualizare:",
+ "config-localsettings-badkey": "Cheia furnizată este incorectă.",
+ "config-upgrade-key-missing": "S-a detectat o instalare existentă de MediaWiki.\nPentru a efectua un upgrade în cazul acestei instalări, vă rugăm să introduceți următorul rând în partea de jos a fișierului <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Fișierul <code>LocalSettings.php</code> deja existent pare a fi incomplet.\nVariabila $1 nu este definită.\nModificați fișierul <code>LocalSettings.php</code> astfel încât această variabilă să fie definită, după care apăsați pe „{{int:Config-continue}}”.",
+ "config-localsettings-connection-error": "A apărut o eroare în timpul conectării la baza de date utilizând setările specificate în <code>LocalSettings.php</code>. Vă rugăm să ajustați aceste setări și încercați din nou.\n\n$1",
+ "config-session-error": "Eroare la pornirea sesiunii: $1",
+ "config-session-expired": "Este posibil ca datele sesiunii dumnevoastră să fi expirat.\nSesiunile sunt configurate pentru o durată de viață de $1.\nO puteți mări configurând parametrul <code>session.gc_maxlifetime</code> din fișierul php.ini.\nReporniți procesul de instalare.",
+ "config-no-session": "Datele sesiunii dumneavoastră s-au pierdut!\nVerificați-vă fișierul php.ini și asigurați-vă că <code>session.save_path</code> conține calea către un director corespunzător.",
+ "config-your-language": "Limba ta:",
+ "config-your-language-help": "Alege o limbă pentru a o utiliza în timpul procesului de instalare.",
+ "config-wiki-language": "Limbă wiki:",
+ "config-wiki-language-help": "Alege limba în care wiki-ul va fi scris predominant.",
+ "config-back": "← Înapoi",
+ "config-continue": "Continuă →",
+ "config-page-language": "Limbă",
+ "config-page-welcome": "Bun venit la MediaWiki!",
+ "config-page-dbconnect": "Conectează la baza de date",
+ "config-page-upgrade": "Extinde instalarea existentă",
+ "config-page-dbsettings": "Setări ale bazei de date",
+ "config-page-name": "Nume",
+ "config-page-options": "Opţiuni",
+ "config-page-install": "Instalare",
+ "config-page-complete": "Finalizat!",
+ "config-page-restart": "Reporneşte instalarea",
+ "config-page-readme": "Citeşte-mă",
+ "config-page-releasenotes": "Note de lansare",
+ "config-page-copying": "Copiere",
+ "config-page-upgradedoc": "Actualizare",
+ "config-page-existingwiki": "Wiki existent",
+ "config-help-restart": "Doriți să ștergeți toate datele salvate introduse și să reporniți procesul de instalare?",
+ "config-restart": "Da, repornește.",
+ "config-env-good": "Verificarea mediului a fost efectuată cu succes.\nPuteți instala MediaWiki.",
+ "config-env-bad": "Verificarea mediului a fost efectuată.\nNu puteți instala MediaWiki.",
+ "config-env-php": "PHP $1 este instalat.",
+ "config-env-hhvm": "HHVM $1 este instalat.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] este instalat",
+ "config-apc": "[http://www.php.net/apc APC] este instalat",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] este instalat",
+ "config-diff3-bad": "GNU diff3 nu a fost găsit.",
+ "config-no-uri": "<strong>Eroare:</strong> Nu pot determina URI-ul curent.\nInstalare întreruptă.",
+ "config-db-type": "Tipul bazei de date:",
+ "config-db-host": "Gazdă bază de date:",
+ "config-db-host-oracle": "Baza de date TNS:",
+ "config-db-wiki-settings": "Identificați acest wiki",
+ "config-db-name": "Numele bazei de date:",
+ "config-db-name-oracle": "Schema bazei de date:",
+ "config-db-install-account": "Contul de utilizator pentru instalare",
+ "config-db-username": "Nume de utilizator pentru baza de date:",
+ "config-db-password": "Parola bazei de date:",
+ "config-db-install-username": "Introduceți numele de utilizator care va fi utilizat pentru conexiunea la baza de date în timpul procesului de instalare.\nNu este numele de utilizator al contului MediaWiki; este numele de utilizator al bazei dumneavoastră de date.",
+ "config-db-install-password": "Introduceți parola care va fi utilizată pentru conexiunea la baza de date în timpul procesului de instalare.\nNu este parola contului MediaWiki; este parola bazei dumneavoastră de date.",
+ "config-db-install-help": "Introduceți numele de utilizator și parola care vor fi utilizate pentru conexiunea la baza de date în timpul procesului de instalare.",
+ "config-db-account-lock": "Folosește același nume de utilizator și parolă în timpul funcționării normale",
+ "config-db-wiki-account": "Contul de utilizator pentru funcționarea normală",
+ "config-db-prefix": "Prefixul tabelelor din baza de date:",
+ "config-db-port": "Portul bazei de date:",
+ "config-db-schema": "Schema pentru MediaWiki:",
+ "config-sqlite-dir": "Director de date SQLite:",
+ "config-oracle-def-ts": "Spațiu de stocare („tablespace”) implicit:",
+ "config-oracle-temp-ts": "Spațiu de stocare („tablespace”) temporar:",
+ "config-type-mysql": "MySQL (sau compatibil)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "Setările MySQL",
+ "config-header-postgres": "Setări PostgreSQL",
+ "config-header-sqlite": "Setări SQLite",
+ "config-header-oracle": "Setări Oracle",
+ "config-header-mssql": "Setări Microsoft SQL Server",
+ "config-invalid-db-type": "Tip de bază de date incorect",
+ "config-missing-db-name": "Trebuie să introduceți o valoare pentru „{{int:config-db-name}}”.",
+ "config-missing-db-host": "Trebuie să introduceți o valoare pentru „{{int:config-db-host}}”.",
+ "config-missing-db-server-oracle": "Trebuie să introduceți o valoare pentru „{{int:config-db-host-oracle}}”.",
+ "config-connection-error": "$1.\n\nVerificați gazda, numele de utilizator și parola și reîncercați.",
+ "config-upgrade-done-no-regenerate": "Actualizare completă.\n\nAcum puteți [$1 începe să vă folosiți wikiul].",
+ "config-regenerate": "Regenerare LocalSettings.php →",
+ "config-unknown-collation": "AVERTISMENT: Baza de date folosește o colaționare nerecunoscută.",
+ "config-db-web-account": "Contul bazei de date pentru accesul web.",
+ "config-db-web-create": "Creați contul dacă nu există deja",
+ "config-mysql-engine": "Motor de stocare:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Setul de caractere al bazei de date:",
+ "config-mysql-binary": "Binar",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Tip de autentificare:",
+ "config-site-name": "Numele wikiului:",
+ "config-site-name-blank": "Introduceți un nume pentru sit.",
+ "config-project-namespace": "Spațiul de nume al proiectului:",
+ "config-ns-generic": "Proiect",
+ "config-ns-site-name": "Același nume ca al wikiului: $1",
+ "config-ns-other": "Altul (specificați)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-box": "Cont de administrator",
+ "config-admin-name": "Numele dumneavoastră de utilizator:",
+ "config-admin-password": "Parolă:",
+ "config-admin-password-confirm": "Parola, din nou:",
+ "config-admin-password-blank": "Introduceți o parolă pentru contul de administrator.",
+ "config-admin-password-mismatch": "Cele două parole introduse nu corespund.",
+ "config-admin-email": "Adresa de e-mail:",
+ "config-admin-error-bademail": "Ați introdus o adresă de e-mail incorectă.",
+ "config-almost-done": "Sunteți aproape gata!\nPuteți sări peste configurarea rămasă și să instalați wikiul chiar acum.",
+ "config-optional-continue": "Adresează-mi mai multe întrebări.",
+ "config-optional-skip": "Sunt deja plictisit, doar instalează wikiul.",
+ "config-profile": "Profilul drepturilor de utilizator:",
+ "config-profile-wiki": "Wiki deschis",
+ "config-profile-no-anon": "Crearea de cont este necesară",
+ "config-profile-fishbowl": "Doar editorii autorizați",
+ "config-profile-private": "Wiki privat",
+ "config-license": "Drepturi de autor și licență:",
+ "config-license-none": "Fără licență în subsolul paginii",
+ "config-license-cc-by-sa": "Creative Commons Atribuire și distribuire în condiții identice",
+ "config-license-cc-by": "Creative Commons Atribuire",
+ "config-license-cc-by-nc-sa": "Creative Commons Atribuire, necomercial și distribuire în condiții identice",
+ "config-license-cc-0": "Creative Commons Zero (domeniu public)",
+ "config-license-gfdl": "Licența GNU pentru Documentare Liberă 1.3 sau ulterioară",
+ "config-license-pd": "Domeniu public",
+ "config-license-cc-choose": "Alegeți o licență Creative Commons personalizată",
+ "config-email-settings": "Setări pentru e-mail",
+ "config-email-usertalk": "Activați notificările pentru pagina de discuții a utilizatorului",
+ "config-upload-settings": "Încărcare de imagini și fișiere",
+ "config-upload-deleted": "Director pentru fișierele șterse:",
+ "config-logo": "Adresa URL a siglei:",
+ "config-cc-again": "Alegeți din nou...",
+ "config-advanced-settings": "Configurare avansată",
+ "config-cache-options": "Parametrii pentru stocarea temporară a obiectelor:",
+ "config-extensions": "Extensii",
+ "config-install-step-done": "realizat",
+ "config-install-step-failed": "eșuat",
+ "config-install-extensions": "Se includ extensiile",
+ "config-install-database": "Se creează baza de date",
+ "config-install-schema": "Se creează schema",
+ "config-install-pg-schema-not-exist": "Schema PostgreSQL nu există.",
+ "config-install-pg-commit": "Se validează modificările",
+ "config-install-user": "Se creează utilizatorul pentru baza de date",
+ "config-install-user-alreadyexists": "Utilizatorul „$1” există deja",
+ "config-install-user-create-failed": "Crearea utilizatorului „$1” a eșuat: $2",
+ "config-install-user-missing": "Utilizatorul „$1” specificat nu există.",
+ "config-install-tables": "Se creează tabelele",
+ "config-install-stats": "Se inițializează statisticile",
+ "config-install-keys": "Se generează cheile secrete",
+ "config-install-sysop": "Se creează contul de administrator",
+ "config-install-mainpage-failed": "Nu s-a putut insera pagina principală: $1",
+ "config-download-localsettings": "Descarcă <code>LocalSettings.php</code>",
+ "config-help": "ajutor",
+ "config-help-tooltip": "clic pentru a extinde",
+ "mainpagetext": "'''Programul Wiki a fost instalat cu succes.'''",
+ "mainpagedocfooter": "Consultați [https://meta.wikimedia.org/wiki/Help:Contents Ghidul utilizatorului (en)] pentru informații despre utilizarea software-ului wiki.\n\n== Primii pași ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista parametrilor configurabili (en)]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Întrebări frecvente despre MediaWiki (en)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de discuții a MediaWiki (en)]"
+}
diff --git a/www/wiki/includes/installer/i18n/roa-tara.json b/www/wiki/includes/installer/i18n/roa-tara.json
new file mode 100644
index 00000000..11c13d7f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/roa-tara.json
@@ -0,0 +1,70 @@
+{
+ "@metadata": {
+ "authors": [
+ "Joetaras"
+ ]
+ },
+ "config-desc": "'U 'nstallatore de MediaUicchi",
+ "config-title": "Installazzione de MediaUicchi $1",
+ "config-information": "'Mbormaziune",
+ "config-localsettings-key": "Chiave de aggiornamende:",
+ "config-localsettings-badkey": "'A chiave de aggiornamende ca è date non g'è corrette.",
+ "config-session-error": "Errore facenne accumenzà 'a sessione: $1",
+ "config-your-language": "'A lènga toje:",
+ "config-your-language-help": "Scacchie 'na lènghe da ausà duranne 'u processe de installazzione:",
+ "config-wiki-language": "Lènga de Uicchi:",
+ "config-back": "← Rrète",
+ "config-continue": "Condinue →",
+ "config-page-language": "Lènghe",
+ "config-page-welcome": "Bovègne jndr'à MediaUicchi!",
+ "config-page-dbconnect": "Collegate a 'u database",
+ "config-page-upgrade": "Aggiorne l'installazzione esistende",
+ "config-page-dbsettings": "'Mbostaziune d'u database",
+ "config-page-name": "Nome",
+ "config-page-options": "Opziune",
+ "config-page-install": "Installe",
+ "config-page-complete": "Combletate!",
+ "config-page-restart": "Riavvie l'installazzione",
+ "config-page-readme": "Liggeme",
+ "config-page-releasenotes": "Note de rilasce",
+ "config-page-copying": "Stoche a copie",
+ "config-page-upgradedoc": "Aggiornamende",
+ "config-page-existingwiki": "Uicchi esistende",
+ "config-restart": "Sìne, falle repartì",
+ "config-env-php": "PHP $1 ha state installate.",
+ "config-env-hhvm": "HHVM $1 ha state installate.",
+ "config-db-type": "Tipe de database:",
+ "config-db-host-oracle": "Database TNS:",
+ "config-db-name-oracle": "Scheme d'u database:",
+ "config-db-username": "Nome utende d'u database:",
+ "config-db-password": "Password d'u database:",
+ "config-db-port": "Porte d'u database:",
+ "config-db-schema": "Scheme pe MediaUicchi:",
+ "config-type-mysql": "MySQL (o combatibbile)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "'Mbostaziune de MySQL",
+ "config-header-postgres": "'Mbostaziune de PostgreSQL",
+ "config-header-sqlite": "'Mbostaziune de SQLite",
+ "config-header-oracle": "'Mbostaziune de Oracle",
+ "config-header-mssql": "'Mbostaziune de Microsoft SQL Server",
+ "config-invalid-db-type": "Tipe de database invalide.",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Binarie",
+ "config-mysql-utf8": "UTF-8",
+ "config-ns-generic": "Proggette",
+ "config-admin-email": "Indirizze e-mail:",
+ "config-install-step-done": "fatte",
+ "config-install-step-failed": "fallite",
+ "config-install-extensions": "'Ngludenne le estenziune",
+ "config-install-database": "Stoche a 'mboste l'archivije",
+ "config-install-schema": "Stoche a creje 'u scheme",
+ "config-install-pg-schema-not-exist": "'U scheme PostgreSQL non g'esiste.",
+ "config-help": "ajute",
+ "config-help-tooltip": "cazze pe spannere",
+ "mainpagetext": "<strong>MediaUicchi ha state 'nstallate.</strong>",
+ "mainpagedocfooter": "Vè 'ndruche [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide] pe l'mbormaziune sus a cumme s'ause 'u softuer uicchi.\n\n== Pe accumenzà ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Elenghe de le 'mbostaziune pa configurazione]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ de MediaUicchi]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Elenghe d'a poste de MediaUicchi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localizzazzione de MediaUicchi pa lènga toje]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 'Mbare accume combattere condre a 'u rummate sus 'a uicchi toje]"
+}
diff --git a/www/wiki/includes/installer/i18n/ru.json b/www/wiki/includes/installer/i18n/ru.json
new file mode 100644
index 00000000..90456055
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ru.json
@@ -0,0 +1,341 @@
+{
+ "@metadata": {
+ "authors": [
+ "Adata80",
+ "DCamer",
+ "Eleferen",
+ "Express2000",
+ "KPu3uC B Poccuu",
+ "Kaganer",
+ "Krinkle",
+ "Lockal",
+ "MaxSem",
+ "Okras",
+ "Yuriy Apostol",
+ "Александр Сигачёв",
+ "Сrower",
+ "아라",
+ "Meshkov.a",
+ "Eroha",
+ "Seb35",
+ "Striking Blue",
+ "Ильнар",
+ "Macofe",
+ "StasR",
+ "Irus",
+ "Mailman",
+ "Facenapalm"
+ ]
+ },
+ "config-desc": "Инсталлятор MediaWiki",
+ "config-title": "Установка MediaWiki $1",
+ "config-information": "Информация",
+ "config-localsettings-upgrade": "Обнаружен файл <code>LocalSettings.php</code>.\nДля обновления этой установки, пожалуйста, введите значение <code>$wgUpgradeKey</code>.\nЕго можно найти в файле <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Обнаружен файл <code>LocalSettings.php</code>.\nДля обновления этой установки, пожалуйста, запустите <code>update.php</code>",
+ "config-localsettings-key": "Ключ обновления:",
+ "config-localsettings-badkey": "Вы указали неверный ключ обновления.",
+ "config-upgrade-key-missing": "Обнаружена существующая установленная копия MediaWiki.\nЧтобы обновить обнаруженную установку, пожалуйста, добавьте следующую строку в конец вашего файла <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Похоже, что существующий файл <code>LocalSettings.php</code> неполон.\nНе установлена переменная $1.\nПожалуйста, измените <code>LocalSettings.php</code> так, чтобы значение этой переменной было задано, затем нажмите «{{int:Config-continue}}».",
+ "config-localsettings-connection-error": "Произошла ошибка при подключении к базе данных с использованием настроек, указанных в <code>LocalSettings.php</code>. Пожалуйста, исправьте эти настройки и повторите попытку.\n\n$1",
+ "config-session-error": "Ошибка при запуске сессии: $1",
+ "config-session-expired": "Ваша сессия истекла.\nСессии настроены на длительность $1.\nВы её можете увеличить, изменив <code>session.gc_maxlifetime</code> в php.ini.\nПерезапустите процесс установки.",
+ "config-no-session": "Данные сессии потеряны!\nПроверьте ваш php.ini и убедитесь, что <code>session.save_path</code> установлен в соответствующий каталог.",
+ "config-your-language": "Ваш язык:",
+ "config-your-language-help": "Выберите язык, на котором будет происходить процесс установки.",
+ "config-wiki-language": "Язык, который будет использовать вики:",
+ "config-wiki-language-help": "Выберите язык, на котором будут отображаться вики.",
+ "config-back": "← Назад",
+ "config-continue": "Далее →",
+ "config-page-language": "Язык",
+ "config-page-welcome": "Добро пожаловать в MediaWiki!",
+ "config-page-dbconnect": "Подключение к базе данных",
+ "config-page-upgrade": "Обновление существующей установки",
+ "config-page-dbsettings": "Настройки базы данных",
+ "config-page-name": "Название",
+ "config-page-options": "Настройки",
+ "config-page-install": "Установка",
+ "config-page-complete": "Готово!",
+ "config-page-restart": "Начать установку заново",
+ "config-page-readme": "Прочти меня",
+ "config-page-releasenotes": "Информация о версии",
+ "config-page-copying": "Лицензия",
+ "config-page-upgradedoc": "Обновление",
+ "config-page-existingwiki": "Существующая вики",
+ "config-help-restart": "Вы хотите удалить все сохранённые данные, которые вы ввели, и запустить процесс установки заново?",
+ "config-restart": "Да, начать заново",
+ "config-welcome": "=== Проверка окружения ===\nБудут проведены базовые проверки с целью определить, подходит ли данная система для установки MediaWiki.\nНе забудьте включить эту информацию, если вам потребуется помощь для завершения установки.",
+ "config-copyright": "=== Авторские права и условия ===\n\n$1\n\nMediaWiki — свободное программное обеспечение, которое вы можете распространять и/или изменять в соответствии с условиями лицензии GNU General Public License, опубликованной фондом свободного программного обеспечения; второй версии, либо любой более поздней версии.\n\nMediaWiki распространяется в надежде, что она будет полезной, но <strong>без каких-либо гарантий</strong>, даже без подразумеваемых гарантий <strong>коммерческой ценности</strong> или <strong>пригодности для определённой цели</strong>. См. лицензию GNU General Public License для более подробной информации.\n\nВы должны были получить <doclink href=Copying>копию GNU General Public License</doclink> вместе с этой программой, если нет, то напишите Free Software Foundation, Inc., по адресу: 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA или [http://www.gnu.org/copyleft/gpl.html прочтите её онлайн].",
+ "config-sidebar": "* [https://www.mediawiki.org Сайт MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/ru Справка для пользователей]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/ru Справка для администраторов]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/ru FAQ]\n----\n* <doclink href=Readme>Readme-файл</doclink>\n* <doclink href=ReleaseNotes>Информация о выпуске</doclink>\n* <doclink href=Copying>Лицензия</doclink>\n* <doclink href=UpgradeDoc>Обновление</doclink>",
+ "config-env-good": "Проверка внешней среды была успешно проведена.\nВы можете установить MediaWiki.",
+ "config-env-bad": "Была проведена проверка внешней среды.\nВы не можете установить MediaWiki.",
+ "config-env-php": "Установленная версия PHP: $1.",
+ "config-env-hhvm": "HHVM $1 установлена.",
+ "config-unicode-using-intl": "Будет использовано [http://pecl.php.net/intl расширение «intl» для PECL] для нормализации Юникода.",
+ "config-unicode-pure-php-warning": "'''Внимание!''': [http://pecl.php.net/intl расширение intl из PECL] недоступно для нормализации Юникода, будет использоваться медленная реализация на чистом PHP.\nЕсли ваш сайт работает под высокой нагрузкой, вам следует больше узнать о [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормализации Юникода].",
+ "config-unicode-update-warning": "'''Предупреждение''': установленная версия обёртки нормализации Юникода использует старую версию библиотеки [http://site.icu-project.org/ проекта ICU].\nВы должны [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations обновить версию], если хотите полноценно использовать Юникод.",
+ "config-no-db": "Не удалось найти подходящие драйвера баз данных! Вам необходимо установить драйвера базы данных для PHP.\n{{PLURAL:$2|Поддерживается следующий тип|Поддерживаются следующие типы}} баз данных: $1.\n\nЕсли вы скомпилировали PHP сами, перенастройте его с включением клиента баз данных, например, с помощью <code>./configure --with-mysqli</code>.\nЕсли вы установили PHP из пакетов Debian или Ubuntu, то вам также необходимо установить, например, пакет <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Предупреждение''': у Вас установлен SQLite $1, версия которого ниже требуемой $2 . SQLite будет недоступен.",
+ "config-no-fts3": "'''Внимание''': SQLite собран без модуля [//sqlite.org/fts3.html FTS3] — поиск не будет работать для этой базы данных.",
+ "config-pcre-old": "'''Фатальная ошибка:''' требуется PCRE версии $1 или более поздняя.\nВаш исполняемый файл PHP связан с PCRE версии $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Подробнее].",
+ "config-pcre-no-utf8": "'''Фатальная ошибка'''. Модуль PCRE для PHP, похоже, собран без поддержки PCRE_UTF8.\nMediaWiki требует поддержки UTF-8 для корректной работы.",
+ "config-memory-raised": "Ограничение на доступную PHP память (<code>memory_limit</code>) поднято с $1 до $2.",
+ "config-memory-bad": "'''Внимание:''' размер PHP <code>memory_limit</code> составляет $1.\nВероятно, этого слишком мало.\nУстановка может потерпеть неудачу!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] установлен",
+ "config-apc": "[http://www.php.net/apc APC] установлен",
+ "config-apcu": "[http://www.php.net/apcu APCu] установлен",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] установлен",
+ "config-no-cache-apcu": "'''Внимание:''' Не найдены [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] или [http://www.iis.net/download/WinCacheForPhp WinCache].\nКэширование объектов будет отключено.",
+ "config-mod-security": "<strong>Внимание</strong>: На вашем веб-сервере включен [http://modsecurity.org/ mod_security]/mod_security2. Многие его стандартные настройки могут вызывать проблемы для MediaWiki или другого ПО, позволяющего пользователям отправлять на сервер произвольный контент.\nОбратитесь к [http://modsecurity.org/documentation/ документации mod_security] или в службу поддержки вашего хостинг-провайдера, если вы сталкиваетесь со случайными ошибками.",
+ "config-diff3-bad": "GNU diff3 не найден.",
+ "config-git": "Найдена система контроля версий Git: <code>$1</code>.",
+ "config-git-bad": "Программное обеспечение по управлению версиями Git не найдено.",
+ "config-imagemagick": "Обнаружен ImageMagick: <code>$1</code>.\nВозможно отображение миниатюр изображений, если вы разрешите закачки файлов.",
+ "config-gd": "Найдена встроенная графическая библиотека GD.\nВозможность использования миниатюр изображений будет включена, если вы включите их загрузку.",
+ "config-no-scaling": "Не удалось найти встроенную библиотеку GD или ImageMagick.\nВозможность использования миниатюр изображений будет отключена.",
+ "config-no-uri": "'''Ошибка:''' Не могу определить текущий URI.\nУстановка прервана.",
+ "config-no-cli-uri": "'''Предупреждение''': нет задан параметр <code>--scriptpath</code>, используется по умолчанию: <code>$1</code> .",
+ "config-using-server": "Используется имя сервера «<nowiki>$1</nowiki>».",
+ "config-using-uri": "Используется имя сервера \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "'''Внимание:''' директория, используемая по умолчанию для загрузок (<code>$1</code>) уязвима к выполнению произвольных скриптов.\nХотя MediaWiki проверяет все загружаемые файлы на наличие угроз, настоятельно рекомендуется [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security закрыть данную уязвимость] перед включением загрузки файлов.",
+ "config-no-cli-uploads-check": "'''Предупреждение:''' каталог для загрузки по умолчанию ( <code>$1</code> ) не проверялся на уязвимости\n на выполнение произвольного сценария во время установки CLI.",
+ "config-brokenlibxml": "В вашей системе имеется сочетание версий PHP и libxml2, которое может привести к скрытым повреждениям данных в MediaWiki и других веб-приложениях.\nОбновите libxml2 до версии 2.7.3 или старше ([https://bugs.php.net/bug.php?id=45996 сведения об ошибке]).\nУстановка прервана.",
+ "config-suhosin-max-value-length": "Suhosin установлен и ограничивает параметр GET <code>length</code> до $1 {{PLURAL:$1|байт|байта|байт}}. Компонент MediaWiki ResourceLoader будет обходить это ограничение, но это снизит производительность. Если это возможно, следует установить <code>suhosin.get.max_value_length</code> в значение 1024 или выше в <code>php.ini</code>, а также установить для <code>$wgResourceLoaderMaxQueryLength</code> такое же значение в LocalSettings.php.",
+ "config-using-32bit": "<strong>Внимание:</strong> похоже, ваша система работает с 32-битными целыми числами. Это [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit не рекомендуется].",
+ "config-db-type": "Тип базы данных:",
+ "config-db-host": "Хост базы данных:",
+ "config-db-host-help": "Если сервер базы данных находится на другом сервере, введите здесь его имя хоста или IP-адрес.\n\nЕсли вы используете виртуальный хостинг, ваш провайдер должен указать правильное имя хоста в своей документации.\n\nЕсли вы устанавливаете систему на сервере под Windows и используете MySQL, имя сервера «localhost» может не работать. В этом случае попробуйте указать 127.0.0.1 локальный IP-адрес.\n\nЕсли вы используете PostgreSQL, оставьте это поле пустым для подключения через сокет Unix.",
+ "config-db-host-oracle": "TNS базы данных:",
+ "config-db-host-oracle-help": "Введите действительный [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; файл tnsnames.ora должен быть видимым для этой инсталляции. <br />При использовании клиентских библиотек версии 10g и старше также возможно использовать метод именования [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Идентификация этой вики",
+ "config-db-name": "Имя базы данных:",
+ "config-db-name-help": "Выберите название-идентификатор для вашей вики.\nОно не должно содержать пробелов.\n\nЕсли вы используете виртуальный хостинг, провайдер или выдаст вам конкретное имя базы данных, или позволит создавать базы данных с помощью панели управления.",
+ "config-db-name-oracle": "Схема базы данных:",
+ "config-db-account-oracle-warn": "Поддерживаются три сценария установки Oracle в качестве базы данных:\n\nЕсли вы хотите создать учётную запись базы данных в процессе установки, пожалуйста, укажите учётную запись роли SYSDBA для установки и укажите желаемые полномочия учётной записи с веб-доступом. вы также можете учётную запись с веб-доступом вручную и указать только её (если у неё есть необходимые разрешения на создание объектов схемы) или указать две учётные записи, одну с правами создания объектов, а другую с ограничениями для веб-доступа.\n\nСценарий для создания учётной записи с необходимыми привилегиями можно найти в папке «maintenance/oracle/» этой программы установки. Имейте в виду, что использование ограниченной учётной записи приведёт к отключению всех возможностей обслуживания с учётной записи по умолчанию.",
+ "config-db-install-account": "Учётная запись для установки",
+ "config-db-username": "Имя пользователя базы данных:",
+ "config-db-password": "Пароль базы данных:",
+ "config-db-install-username": "Введите имя пользователя, которое будет использоваться для подключения к базе данных в процессе установки.\nЭто не имя пользователя MediaWiki, это имя пользователя для базы данных.",
+ "config-db-install-password": "Введите пароль, который будет использоваться для подключения к базе данных в процессе установки.\nЭто не пароль пользователя MediaWiki, это пароль для базы данных.",
+ "config-db-install-help": "Введите имя пользователя и пароль, которые будут использоваться для подключения к базе данных во время процесса установки.",
+ "config-db-account-lock": "Использовать то же имя пользователя и пароль для обычной работы",
+ "config-db-wiki-account": "Учётная запись для обычной работы",
+ "config-db-wiki-help": "Введите имя пользователя и пароль, которые будут использоваться для подключения к базе данных во время обычной работы вики.\nЕсли такой учётной записи не существует, а установочная учётная запись имеет достаточно привилегий, то обычная учётная запись будет создана с минимально необходимыми для работы вики привилегиями.",
+ "config-db-prefix": "Префикс таблиц базы данных:",
+ "config-db-prefix-help": "Если вам нужно делить одну базу данных между несколькими вики, или между MediaWiki и другими веб-приложениями, вы можете добавить префикс для всех имён таблиц.\nНе используйте пробелы.\n\nЭто поле обычно остаётся пустым.",
+ "config-mysql-old": "Необходим MySQL $1 или более поздняя версия. У вас установлен MySQL $2.",
+ "config-db-port": "Порт базы данных:",
+ "config-db-schema": "Схема для MediaWiki",
+ "config-db-schema-help": "Эта схема обычно работает хорошо.\nИзменяйте её только если знаете, что Вам это нужно.",
+ "config-pg-test-error": "Не удаётся подключиться к базе данных <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Директория данных SQLite:",
+ "config-sqlite-dir-help": "SQLite хранит все данные в одном файле.\n\nДиректория, указываемая вами, должна быть доступна для записи веб-сервером во время установки.\n\nОна '''не должна''' быть доступна через Интернет, поэтому не должна совпадать с той, где хранятся PHP файлы.\n\nУстановщик запишет в эту директорию файл <code>.htaccess</code>, но если это не сработает, кто-нибудь может получить доступ ко всей базе данных.\nВ этой базе находится в том числе и информация о пользователях (адреса электронной почты, хэши паролей), а также удалённые страницы и другие секретные данные о вики.\n\nПо возможности, расположите базу данных где-нибудь в стороне, например, в <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Пространство таблиц по умолчанию:",
+ "config-oracle-temp-ts": "Временное пространство таблиц:",
+ "config-type-mysql": "MySQL (или совместимая)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki поддерживает следующие СУБД:\n\n$1\n\nЕсли вы не видите своей системы хранения данных в этом списке, следуйте инструкциям, на которые есть ссылка выше, чтобы получить поддержку.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] — основная база данных для MediaWiki, которая поддерживается лучше всего. MediaWiki также работает с [{{int:version-db-mariadb-url}} MariaDB] и [{{int:version-db-percona-url}} Percona Server], которые являются MySQL-совместимым. ([http://www.php.net/manual/ru/mysql.installation.php инструкция, как собрать PHP с поддержкой MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] — популярная открытая СУБД, альтернатива MySQL. ([http://www.php.net/manual/ru/pgsql.installation.php инструкция, как собрать PHP с поддержкой PostgreSQL]).",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] — это легковесная система баз данных, имеющая очень хорошую поддержку. ([http://www.php.net/manual/ru/pdo.installation.php инструкция, как собрать PHP с поддержкой SQLite], работающей посредством PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] — это коммерческая база данных масштаба предприятия. ([http://www.php.net/manual/ru/oci8.installation.php Как собрать PHP с поддержкой OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] — это коммерческое база данных база данных для Windows масштаба предприятия. ([http://www.php.net/manual/ru/sqlsrv.installation.php Как собрать PHP с поддержкой SQLSRV])",
+ "config-header-mysql": "Настройки MySQL",
+ "config-header-postgres": "Настройки PostgreSQL",
+ "config-header-sqlite": "Настройки SQLite",
+ "config-header-oracle": "Настройки Oracle",
+ "config-header-mssql": "Параметры Microsoft SQL Server",
+ "config-invalid-db-type": "Неверный тип базы данных",
+ "config-missing-db-name": "Вы должны ввести значение «{{int:config-db-name}}».",
+ "config-missing-db-host": "Необходимо ввести значение параметра «{{int:config-db-host}}».",
+ "config-missing-db-server-oracle": "Вы должны заполнить поле «{{int:config-db-host-oracle}}»",
+ "config-invalid-db-server-oracle": "Неверное TNS базы данных «$1».\nИспользуйте либо «TNS Name», либо строку «Easy Connect» ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Методы наименования Oracle])",
+ "config-invalid-db-name": "Неверное имя базы данных «$1».\nИспользуйте только ASCII-символы (a-z, A-Z), цифры (0-9), знак подчёркивания (_) и дефис(-).",
+ "config-invalid-db-prefix": "Неверный префикс базы данных «$1».\nИспользуйте только буквы ASCII (a-z, A-Z), цифры (0-9), знак подчёркивания (_) и дефис (-).",
+ "config-connection-error": "$1.\n\nПроверьте хост, имя пользователя и пароль и попробуйте ещё раз.",
+ "config-invalid-schema": "Неправильная схема для MediaWiki «$1».\nИспользуйте только ASCII символы (a-z, A-Z), цифры(0-9) и знаки подчёркивания(_).",
+ "config-db-sys-create-oracle": "Программа установки поддерживает только использование SYSDBA для создания новой учётной записи.",
+ "config-db-sys-user-exists-oracle": "Учётная запись «$1». SYSDBA может использоваться только для создания новой учётной записи!",
+ "config-postgres-old": "Необходим PostgreSQL $1 или более поздняя версия. У вас установлен PostgreSQL $2.",
+ "config-mssql-old": "Требуется Microsoft SQL Server версии $1 или более поздней. У вас установлена версия $2.",
+ "config-sqlite-name-help": "Выберите имя-идентификатор для вашей вики.\nНе используйте дефисы и пробелы.\nЭта строка будет использоваться в имени файла SQLite.",
+ "config-sqlite-parent-unwritable-group": "Не удалось создать директорию данных <nowiki><code>$1</code></nowiki>, так как у веб-сервера нет прав записи в родительскую директорию <nowiki><code>$2</code></nowiki>.\n\nУстановщик определил пользователя, под которым работает веб-сервер.\nСделайте директорию <nowiki><code>$3</code></nowiki> доступной для записи и продолжите.\nВ Unix/Linux системе выполните:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Не удалось создать директорию для данных <code><nowiki>$1</nowiki></code>, так как у веб-сервера нет прав на запись в родительскую директорию <code><nowiki>$2</nowiki></code>.\n\nПрограмма установки не смогла определить пользователя, под которым работает веб-сервер.\nДля продолжения сделайте каталог <code><nowiki>$3</nowiki></code> глобально доступным для записи серверу (и другим).\nВ Unix/Linux сделайте:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Ошибка при создании директории для данных «$1».\nПроверьте расположение и повторите попытку.",
+ "config-sqlite-dir-unwritable": "Невозможно произвести запись в каталог «$1».\nИзмените настройки доступа так, чтобы веб-сервер мог записывать в этот каталог, и попробуйте ещё раз.",
+ "config-sqlite-connection-error": "$1.\n\nПроверьте название базы данных и директорию с данными и попробуйте ещё раз.",
+ "config-sqlite-readonly": "Файл <code>$1</code> недоступен для записи.",
+ "config-sqlite-cant-create-db": "Не удаётся создать файл базы данных <code>$1</code> .",
+ "config-sqlite-fts3-downgrade": "У PHP отсутствует поддержка FTS3 — сбрасываем таблицы",
+ "config-can-upgrade": "В базе данных найдены таблицы MediaWiki.\nЧтобы обновить их до MediaWiki $1, нажмите на кнопку '''«Продолжить»'''.",
+ "config-upgrade-done": "Обновление завершено.\n\nТеперь вы можете [$1 начать использовать вики].\n\nЕсли вы хотите повторно создать файл <code>LocalSettings.php</code>, нажмите на кнопку ниже.\nЭто действие '''не рекомендуется''', если у вас не возникло проблем при установке.",
+ "config-upgrade-done-no-regenerate": "Обновление завершено.\n\nТеперь вы можете [$1 начать работу с вики].",
+ "config-regenerate": "Создать LocalSettings.php заново →",
+ "config-show-table-status": "Запрос «<code>SHOW TABLE STATUS</code>» не выполнен!",
+ "config-unknown-collation": "'''Внимание:''' База данных использует нераспознанные правила сортировки.",
+ "config-db-web-account": "Учётная запись для доступа к базе данных из веб-сервера",
+ "config-db-web-help": "Выберите имя пользователя и пароль, которые веб-сервер будет использовать для подключения к серверу базы данных при обычной работе вики.",
+ "config-db-web-account-same": "Использовать ту же учётную запись, что и для установки",
+ "config-db-web-create": "Создать учётную запись, если она ещё не существует",
+ "config-db-web-no-create-privs": "Учётная запись, указанная вами для установки, не обладает достаточными правами для создания учётной записи.\nУказанная здесь учётная запись уже должна существовать.",
+ "config-mysql-engine": "Движок базы данных:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "''' Внимание.''' Вы выбрали механизм MyISAM для хранения данных MySQL. Он не рекомендуется к использованию по следующим причинам:\n* он слабо поддерживает параллелизм из-за табличных блокировок;\n* более склонен к потере данных, по сравнению с другими механизмами;\n* код MediaWiki не всегда учитывает особенности MyISAM должным образом.\n\nЕсли ваша MySQL поддерживает InnoDB, настоятельно рекомендуется выбрать этот механизм.\nЕсли ваша MySQL не поддерживает InnoDB, возможно, настало время обновиться.",
+ "config-mysql-only-myisam-dep": "<strong>Предупреждение:</strong> MyISAM — единственная доступная система хранения данных для MySQL на этом компьютере, и она не рекомендуется для использования совместно с MediaWiki, потому что:\n* слабо поддерживает параллелизм из-за блокировки таблиц\n* больше других систем подвержена повреждению\n* кодовая база MediaWiki не всегда обрабатывает MyISAM так, как следует\n\nВаша MySQL не поддерживает InnoDB, так что, возможно, настало время для обновления.",
+ "config-mysql-engine-help": "'''InnoDB''' почти всегда предпочтительнее, так как он лучше справляется с параллельным доступом.\n\n'''MyISAM''' может оказаться быстрее для вики с одним пользователем или с минимальным количеством поступающих правок, однако базы данных на нём портятся чаще, чем на InnoDB.",
+ "config-mysql-charset": "Кодировка базы данных:",
+ "config-mysql-binary": "Двоичный",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "В '''двоичном режиме''' MediaWiki хранит UTF-8 текст в бинарных полях базы данных.\nЭто более эффективно, чем ''UTF-8 режим'' MySQL, и позволяет использовать полный набор символов Unicode.\n\nВ '''режиме UTF-8''' MySQL будет знать в какой кодировке находятся Ваши данные и может отображать и преобразовывать их соответствующим образом, но это не позволит вам хранить символы выше [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Базовой Многоязыковой Плоскости].",
+ "config-mssql-auth": "Тип аутентификации:",
+ "config-mssql-install-auth": "Выберите тип проверки подлинности, который будет использоваться для подключения к базе данных во время процесса установки.\nЕсли вы выберите «{{int:config-mssql-windowsauth}}», будут использоваться учётные данные пользователя, под которым работает веб-сервер.",
+ "config-mssql-web-auth": "Выберите тип проверки подлинности, который веб-сервер будет использовать для подключения к серверу базы данных во время обычного функционирования вики.\nЕсли вы выберите «{{int:config-mssql-windowsauth}}», будут использоваться учётные данные пользователя, под которым работает веб-сервер.",
+ "config-mssql-sqlauth": "Проверка подлинности SQL Server",
+ "config-mssql-windowsauth": "Проверка подлинности Windows",
+ "config-site-name": "Название вики:",
+ "config-site-name-help": "Название будет отображаться в заголовке окна браузера и в некоторых других местах вики.",
+ "config-site-name-blank": "Введите название сайта.",
+ "config-project-namespace": "Пространство имён проекта:",
+ "config-ns-generic": "Проект",
+ "config-ns-site-name": "То же, что имя вики: $1",
+ "config-ns-other": "Другое (укажите)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "Следуя примеру Википедии, многие вики хранят свои страницы правил отдельно от страниц основного содержания, в так называемом '''«пространстве имён проекта»'''.\nВсе названия страниц в этом пространстве имён начинается с определённого префикса, который вы можете задать здесь.\nОбычно, этот префикс происходит от имени вики, но он не может содержать знаки препинания, символы «#» или «:».",
+ "config-ns-invalid": "Указанное пространство имён <nowiki>$1</nowiki> недопустимо.\nУкажите другое пространство имён проекта.",
+ "config-ns-conflict": "Указанное пространство имён «<nowiki>$1</nowiki>» конфликтует со стандартным пространством имён MediaWiki.\nУкажите другое пространство имён проекта.",
+ "config-admin-box": "Учётная запись администратора",
+ "config-admin-name": "Ваше имя участника:",
+ "config-admin-password": "Пароль:",
+ "config-admin-password-confirm": "Пароль ещё раз:",
+ "config-admin-help": "Введите ваше имя пользователя здесь, например, «Иван Иванов».\nЭто имя будет использоваться для входа в вики.",
+ "config-admin-name-blank": "Введите имя пользователя администратора.",
+ "config-admin-name-invalid": "Указанное имя пользователя «<nowiki>$1</nowiki>» недопустимо.\nУкажите другое имя пользователя.",
+ "config-admin-password-blank": "Введите пароль для учётной записи администратора.",
+ "config-admin-password-mismatch": "Введённые вами пароли не совпадают.",
+ "config-admin-email": "Адрес электронной почты:",
+ "config-admin-email-help": "Введите адрес электронной почты, чтобы получать сообщения от других пользователей вики, иметь возможность восстановить пароль, а также получать уведомления об изменениях страниц из списка наблюдения. Вы можете оставить это поле пустым.",
+ "config-admin-error-user": "Внутренняя ошибка при создании учётной записи администратора с именем «<nowiki>$1</nowiki>».",
+ "config-admin-error-password": "Внутренняя ошибка при установке пароля для учётной записи администратора «<nowiki>$1</nowiki>»: <pre>$2</pre>",
+ "config-admin-error-bademail": "Вы ввели неправильный адрес электронной почты",
+ "config-subscribe": "Подписаться на [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce рассылку новостей о появлении новых версий MediaWiki].",
+ "config-subscribe-help": "Это список рассылки с малым числом сообщений, используется для анонса новых выпусков и сообщений о проблемах с безопасностью.\nВам следует подписаться на него и обновлять движок MediaWiki, по мере выхода новых версий.",
+ "config-subscribe-noemail": "Вы попытались подписаться на список рассылки уведомлений о новых выпусках без указания адреса электронной почты.\nУкажите адрес электронной почты, если вы хотите подписаться на список рассылки.",
+ "config-pingback": "Поделиться сведениями об этой установке с разработчикам MediaWiki.",
+ "config-pingback-help": "Если вы выберите этот вариант, MediaWiki будет периодически отправлять на https://www.mediawiki.org основные сведения об этом экземпляре MediaWiki. К этим данным относятся, в частности, тип операционной системы, версия PHP и выбранная СУБД. Фонда Викимедиа делится этими данными с разработчиками MediaWiki, чтобы помочь им в проведении будущих разработок. Следующие данные будут отправлены для вашей системы:\n<pre>$1</pre>",
+ "config-almost-done": "Вы почти у цели!\nОстальные настройки можно пропустить и приступить к установке вики.",
+ "config-optional-continue": "Произвести тонкую настройку",
+ "config-optional-skip": "Хватит, установить вики",
+ "config-profile": "Профиль прав прользователей:",
+ "config-profile-wiki": "Открытая вики",
+ "config-profile-no-anon": "Требуется создание учётной записи",
+ "config-profile-fishbowl": "Только для авторизованных редакторов",
+ "config-profile-private": "Закрытая вики",
+ "config-profile-help": "Вики-технология лучше всего работает, когда вы позволяете редактировать сайт максимально широкому кругу лиц.\nВ MediaWiki легко просмотреть последних изменений и, при необходимости, откатить любой ущерб сделанный злоумышленниками или наивными пользователями.\n\nОднако, движок MediaWiki можно использовать и иными способами, и не далеко не всех удаётся убедить в преимуществах открытой вики-работы.\nТак что в вас есть выбор.\n\nМодель '''«{{int:config-profile-wiki}}»''' позволяет всем править страницы даже не регистрируясь на сайте. Конфигурация '''{{int:config-profile-no-anon}}''' обеспечивает дополнительный учёт, но может отсечь случайных участников.\n\nСценарий '''«{{int:config-profile-fishbowl}}»''' разрешает редактирование только определённым участникам, но общедоступным остаётся просмотр страниц, в том числе просмотр истории изменения. В режиме '''«{{int:config-profile-private}}»''' просмотр страниц разрешён только определённым пользователям, какая-то их часть может иметь также права на редактирование.\n\nБолее сложные схемы разграничения прав можно настроить после установки, см. [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights соответствующее руководство].",
+ "config-license": "Авторские права и лицензии:",
+ "config-license-none": "Не указывать лицензию в колонтитуле внизу страницы",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (общественное достояние)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 или более поздняя",
+ "config-license-pd": "Общественное достояние",
+ "config-license-cc-choose": "Выберите одну из лицензий Creative Commons",
+ "config-license-help": "Многие общедоступные вики разрешают использовать свои материалы на условиях [http://freedomdefined.org/Definition/Ru свободных лицензий].\nЭто помогает созданию чувства общности, стимулирует долгосрочное участие.\nНо в этом нет необходимости для частных или корпоративных вики.\n\nЕсли вы хотите использовать тексты из Википедии или хотите, что в Википедию можно было копировать тексты из вашей вики, вам следует выбрать <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nВикипедия ранее использовала лицензию GNU Free Documentation License.\nGFDL может быть использована, но она сложна для понимания и осложняет повторное использование материалов.",
+ "config-email-settings": "Настройки электронной почты",
+ "config-enable-email": "Включить исходящие e-mail",
+ "config-enable-email-help": "Если вы хотите, чтобы электронная почта работала, необходимо выполнить [http://www.php.net/manual/ru/mail.configuration.php соответствующие настройки PHP].\nЕсли вы не хотите использовать возможности электронной почты в вики, вы можете её отключить.",
+ "config-email-user": "Включить электронную почту от участника к участнику",
+ "config-email-user-help": "Разрешить всем пользователям отправлять друг другу электронные письма, если выставлена соответствующая настройка в профиле.",
+ "config-email-usertalk": "Включить уведомления пользователей о сообщениях на их странице обсуждения",
+ "config-email-usertalk-help": "Разрешить пользователям получать уведомления об изменениях своих страниц обсуждения, если они разрешат это в своих настройках.",
+ "config-email-watchlist": "Включить уведомление на электронную почту об изменении списка наблюдения",
+ "config-email-watchlist-help": "Разрешить пользователям получать уведомления об отслеживаемых ими страницах, если они разрешили это в своих настройках.",
+ "config-email-auth": "Включить аутентификацию через электронную почту",
+ "config-email-auth-help": "Если эта опция включена, пользователи должны подтвердить свой адрес электронной почты перейдя по ссылке, которая отправляется на e-mail. Подтверждение требуется каждый раз при смене электронного ящика в настройках пользователя.\nТолько прошедшие проверку подлинности адреса электронной почты, могут получать электронные письма от других пользователей или изменять уведомления, отправляемые по электронной почте.\nВключение этой опции '''рекомендуется''' для открытых вики в целях пресечения потенциальных злоупотреблений возможностями электронной почты.",
+ "config-email-sender": "Обратный адрес электронной почты:",
+ "config-email-sender-help": "Введите адрес электронной почты для использования в качестве обратного адреса исходящей электронной почты.\nНа него будут отправляться отказы.\nМногие почтовые серверы требуют, чтобы по крайней мере доменное имя в нём было правильным.",
+ "config-upload-settings": "Загрузка изображений и файлов",
+ "config-upload-enable": "Разрешить загрузку файлов",
+ "config-upload-help": "Разрешение загрузки файлов, потенциально, может привести к угрозе безопасности сервера.\nДля получения дополнительной информации, прочтите в руководстве [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security раздел, посвящённый безопасности].\n\nЧтобы разрешить загрузку файлов, необходимо изменить права на каталог <code>images</code>, в корневой директории MediaWiki так, чтобы веб-сервер мог записывать в него файлы.\nЗатем включите эту опцию.",
+ "config-upload-deleted": "Директория для удалённых файлов:",
+ "config-upload-deleted-help": "Выберите каталог, в котором будут храниться архивы удалённых файлов.\nВ идеальном случае, в этот каталог не должно быть доступа из сети Интернет.",
+ "config-logo": "URL логотипа:",
+ "config-logo-help": "Стандартная тема оформления MediaWiki содержит над боковой панелью пространство для логотипа размером 135x160 пикселей.\nЗагрузите изображение соответствующего размера, и введите его URL здесь.\n\nВы можете использовать <code>$wgStylePath</code> или <code>$wgScriptPath</code>, если ваш логотип находится относительно к этим путям.\n\nЕсли вам не нужен логотип, оставьте это поле пустым.",
+ "config-instantcommons": "Включить Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] — это функция, позволяющая использовать изображения, звуки и другие медиафайлы с Викисклада ([https://commons.wikimedia.org/ Wikimedia Commons]).\nДля работы этой функции MediaWiki необходим доступ к Интернету.\n\nДополнительную информацию об Instant Commons, в том числе указания о том, как её настроить для других вики, отличных от Викисклада, можно найти в [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos руководстве].",
+ "config-cc-error": "Механизм выбора лицензии Creative Commons не вернул результата.\nВведите название лицензии вручную.",
+ "config-cc-again": "Выберите ещё раз…",
+ "config-cc-not-chosen": "Выберите, какую лицензию Creative Commons Вы хотите использовать, и нажмите кнопку \"proceed\".",
+ "config-advanced-settings": "Дополнительные настройки",
+ "config-cache-options": "Параметры кэширования объектов:",
+ "config-cache-help": "Кэширование объектов используется для повышения скорости MediaWiki путем кэширования часто используемых данных.\nДля средних и больших сайтов кеширование настоятельно рекомендуется включать, а для небольших сайтов кеширование может показать преимущество.",
+ "config-cache-none": "Без кэширования (никакой функционал не теряется, но крупные вики-сайты могут работать медленнее)",
+ "config-cache-accel": "Кэширование PHP-объектов (APC, APCu, XCache или WinCache)",
+ "config-cache-memcached": "Использовать Memcached (требует дополнительной настройки)",
+ "config-memcached-servers": "Сервера Memcached:",
+ "config-memcached-help": "Список IP-адресов, используемых Memcached.\nПеречислите по одному адресу на строку с указанием портов. Например:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Вы выбрали тип кэширования Memcached, но не задали адреса серверов.",
+ "config-memcache-badip": "Вы ввели неверный IP-адрес для Memcached: $1.",
+ "config-memcache-noport": "Не указан порт для сервера Memcached: $1.\nЕсли вы не знаете порт, по умолчанию используется 11211.",
+ "config-memcache-badport": "Номера портов Memcached должны лежать в пределах от $1 до $2.",
+ "config-extensions": "Расширения",
+ "config-extensions-help": "Расширения MediaWiki, перечисленные выше, были найдены в каталоге <code>./extensions</code>.\n\nОни могут потребовать дополнительные настройки, но их можно включить прямо сейчас",
+ "config-skins": "Темы оформления",
+ "config-skins-help": "Перечисленные выше темы оформления были обнаружены в вашем каталоге <code>./skins</code>. Вам необходимо включить по крайней мере один из них и выбрать тот, что будет по умолчанию.",
+ "config-skins-use-as-default": "Использовать по умолчанию эту тему оформления",
+ "config-skins-missing": "Темы оформления не найдены. MediaWiki будет использовать резервную тему до тех пор, пока вы не установите что-нибудь подходящее.",
+ "config-skins-must-enable-some": "Вы должны оставить включённой как минимум одну тему оформления.",
+ "config-skins-must-enable-default": "Тема оформления, выбранная по умолчанию, должна быть включена.",
+ "config-install-alreadydone": "'''Предупреждение:''' Вы, кажется, уже устанавливали MediaWiki и пытаетесь произвести повторную установку.\nПожалуйста, перейдите на следующую страницу.",
+ "config-install-begin": "Нажав «{{int:config-continue}}», вы начнёте установку MediaWiki.\nЕсли вы хотите внести изменения, нажмите «{{int:config-back}}».",
+ "config-install-step-done": "выполнено",
+ "config-install-step-failed": "не удалось",
+ "config-install-extensions": "В том числе расширения",
+ "config-install-database": "Настройка базы данных",
+ "config-install-schema": "Создание схемы",
+ "config-install-pg-schema-not-exist": "Схемы PostgreSQL не существует",
+ "config-install-pg-schema-failed": "Не удалось создать таблицы.\nУбедитесь в том, что пользователь «$1» может писать в схему «$2».",
+ "config-install-pg-commit": "Внесение изменений",
+ "config-install-pg-plpgsql": "Проверка языка PL/pgSQL",
+ "config-pg-no-plpgsql": "Вам необходимо установить поддержку языка PL/pgSQL для базы данных $1",
+ "config-pg-no-create-privs": "Учётная запись, указанная для установки, не обладает достаточными привилегиями для создания учётной записи.",
+ "config-pg-not-in-role": "Указанная учётная запись веб-пользователя уже существует.\nВыбранная вами для установки учётная запись не является записью суперпользователя и не относится к роли веб-пользователя; поэтому не получается создать объекты, принадлежащие веб-пользователю.\n\nMediaWiki в настоящее время требует, чтобы владельцем таблиц был веб-пользователь. Пожалуйста, укажите другое имя учётной записи для веб-пользователя, или нажмите кнопку «назад» и укажите пользователя с достаточными для установки правами.",
+ "config-install-user": "Создание базы данных пользователей",
+ "config-install-user-alreadyexists": "Участник «$1» уже существует",
+ "config-install-user-create-failed": "Не получилось создать участника «$1»: $2",
+ "config-install-user-grant-failed": "Ошибка предоставления прав пользователю «$1»: $2",
+ "config-install-user-missing": "Указанного пользователя «$1» не существует.",
+ "config-install-user-missing-create": "Указанного пользователя «$1» не существует.\nПожалуйста поставьте ниже отметку «Создать учётную запись», если вы хотите создать его.",
+ "config-install-tables": "Создание таблиц",
+ "config-install-tables-exist": "'''Предупреждение''': таблицы MediaWiki, возможно, уже существуют.\nПропуск повторного создания.",
+ "config-install-tables-failed": "'''Ошибка''': Таблица не может быть создана из-за ошибки: $1",
+ "config-install-interwiki": "Заполнение таблицы интервики значениями по умолчанию",
+ "config-install-interwiki-list": "Не удалось найти файл <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Предупреждение''': в интервики-таблице, кажется, уже есть записи.\nСоздание стандартного списка пропущено.",
+ "config-install-stats": "Статистика инициализации",
+ "config-install-keys": "Создание секретных ключей",
+ "config-insecure-keys": "'''Предупреждение.''' {{PLURAL:$2|1=Ключ безопасности $1, созданный во время установки, недостаточно надёжен|Ключи безопасности $1, созданные во время установки, недостаточно надёжны}}. Рассмотрите возможность {{PLURAL:$2|1=его|их}} изменения вручную.",
+ "config-install-updates": "Предотвращение запуска ненужных обновлений",
+ "config-install-updates-failed": "<strong>Ошибка:</strong> Вставка ключей обновления в таблицы завершилась со следующей ошибкой: $1",
+ "config-install-sysop": "Создание учётной записи администратора",
+ "config-install-subscribe-fail": "Не удаётся подписаться на mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL не установлен и не доступна опция <code>allow_url_fopen</code>.",
+ "config-install-mainpage": "Создание главной страницы с содержимым по умолчанию",
+ "config-install-mainpage-exists": "Главная страница уже существует, пропускаем",
+ "config-install-extension-tables": "Создание таблиц для включённых расширений",
+ "config-install-mainpage-failed": "Не удаётся вставить главную страницу: $1",
+ "config-install-done": "<strong>Поздравляем!</strong>\nВы установили MediaWiki.\n\nВо время установки был создан файл <code>LocalSettings.php</code>.\nОн содержит все ваши настройки.\n\nВам необходимо скачать его и положить в корневую директорию вашей вики (ту же директорию, где находится файл index.php). Его загрузка должна начаться автоматически.\n\nЕсли автоматическая загрузка не началась или вы её отменили, вы можете скачать по ссылке ниже:\n\n$3\n\n<strong>Примечание</strong>: Если вы не сделаете этого сейчас, то сгенерированный файл конфигурации не будет доступен вам в дальнейшем, если вы выйдете из установки, не скачивая его.\n\nПо окончании действий, описанных выше, вы сможете <strong>[$2 войти в вашу вики]</strong>.",
+ "config-install-done-path": "<strong>Поздравляем!</strong>\nВы установили MediaWiki.\n\nВо время установки был создан файл <code>LocalSettings.php</code>.\nОн содержит все ваши настройки.\n\nВам необходимо скачать его и положить в <code>$4</code>. Его загрузка должна начаться автоматически.\n\nЕсли автоматическая загрузка не началась или вы её отменили, вы можете скачать по ссылке ниже:\n\n$3\n\n<strong>Примечание</strong>: Если вы не сделаете этого сейчас, то сгенерированный файл конфигурации не будет доступен вам в дальнейшем, если вы выйдете из установки, не скачивая его.\n\nПо окончании действий, описанных выше, вы сможете <strong>[$2 войти в вашу вики]</strong>.",
+ "config-download-localsettings": "Загрузить <code>LocalSettings.php</code>",
+ "config-help": "справка",
+ "config-help-tooltip": "нажмите, чтобы развернуть",
+ "config-nofile": "Файл \"$1\" не удается найти. Он был удален?",
+ "config-extension-link": "Знаете ли вы, что ваш вики-проект поддерживает [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions расширения]?\n\nВы можете просмотреть [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category расширения по категориям] или [https://www.mediawiki.org/wiki/Extension_Matrix матрицу расширений], чтобы увидеть их полный список.",
+ "config-skins-screenshots": "$1 (скриншоты: $2)",
+ "config-screenshot": "скриншот",
+ "mainpagetext": "<strong>MediaWiki успешно установлена.</strong>",
+ "mainpagedocfooter": "Информацию по работе с этой вики можно найти в [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents справочном руководстве].\n\n== Некоторые полезные ресурсы ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Список возможных настроек];\n* [https://www.mediawiki.org/wiki/Manual:FAQ/ru Часто задаваемые вопросы и ответы по MediaWiki];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Рассылка уведомлений о выходе новых версий MediaWiki].\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Перевод MediaWiki на свой язык]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Узнайте, как бороться со спамом в вашей вики]"
+}
diff --git a/www/wiki/includes/installer/i18n/rue.json b/www/wiki/includes/installer/i18n/rue.json
new file mode 100644
index 00000000..c7a4f728
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/rue.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Gazeb"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki была успішно наіншталована.'''",
+ "mainpagedocfooter": "[https://meta.wikimedia.org/wiki/Help:Contents Мануял хоснователя] Вам порадить, як хосновати MediaWiki.\n\n== Про початок ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Наставлїня конфіґурації]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Часты вопросы о MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Розосыланя повідомлїнь про новы верзії MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/sa.json b/www/wiki/includes/installer/i18n/sa.json
new file mode 100644
index 00000000..71764c9b
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sa.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Hemant wikikosh1"
+ ]
+ },
+ "mainpagetext": "मीडियाविकि तु सफलतया अन्तःस्थापितमस्ति"
+}
diff --git a/www/wiki/includes/installer/i18n/sah.json b/www/wiki/includes/installer/i18n/sah.json
new file mode 100644
index 00000000..99610e39
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sah.json
@@ -0,0 +1,39 @@
+{
+ "@metadata": {
+ "authors": [
+ "HalanTul",
+ "Мария Олесова"
+ ]
+ },
+ "config-desc": "MediaWiki инсталлятора",
+ "config-title": "MediaWiki $1 туруоруу",
+ "config-information": "Бу туһунан",
+ "config-localsettings-key": "Саҥатытыы күлүүһэ:",
+ "config-localsettings-badkey": "Саҥатытыыга сыыһа күлүүһү ыйдыҥ.",
+ "config-your-language-help": "Туруоруу кэмигэр туттуллар тылы тал.",
+ "config-wiki-language": "Биики туттуохтаах тыла:",
+ "config-back": "← Төттөрү",
+ "config-continue": "Салгыы →",
+ "config-page-language": "Тыла",
+ "config-page-dbconnect": "Билии олоҕор холбонуу",
+ "config-page-upgrade": "Баар туруорууну саҥатытыы",
+ "config-page-dbsettings": "Билии олоҕун бэлэмнээһин",
+ "config-page-name": "Аат",
+ "config-page-options": "Туруоруулар",
+ "config-page-install": "Туруоруу",
+ "config-page-complete": "Бэлэм!",
+ "config-page-restart": "Туруорууну саҥаттан саҕалыырга",
+ "config-page-upgradedoc": "Саҥардыы",
+ "config-page-existingwiki": "Баар биики",
+ "config-help-restart": "Харайыллыбыт көрдөрүүлэри сотуоххун уонна туруоруу кэмин саҥаттан ыытыаххын баҕараҕын дуо?",
+ "config-restart": "Сөп, саҥаттан саҕалыырга",
+ "config-env-good": "Тас кэккэ бэрэбиэркэтэ ситиһиилээхтик ыытылынна. \nMediaWiki туруоруоххун сөп.",
+ "config-db-type": "Билии олоҕун көрүҥэ:",
+ "config-db-wiki-settings": "Бу биики тэҥнэбилэ",
+ "config-db-name": "Билии олоҕун аата:",
+ "config-db-name-oracle": "Билии олоҕун исхиэмэтэ:",
+ "config-db-install-account": "Туруорууга анаммыт бэлиэ-аат",
+ "config-db-username": "Билии олоҕун туһанааччы аата:",
+ "mainpagetext": "'''«MediaWiki» сөпкө туруорулунна.'''",
+ "mainpagedocfooter": "Биики программатын туһунан [https://meta.wikimedia.org/wiki/Help:Contents справочникка] көрүөххүн сөп.\n\n== Саҕаланыыта ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Конфигурация уларытыытын параметрдара]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki релизтарын почтовай испииһэгэ]"
+}
diff --git a/www/wiki/includes/installer/i18n/sc.json b/www/wiki/includes/installer/i18n/sc.json
new file mode 100644
index 00000000..100f3165
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sc.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "Andria",
+ "L2212",
+ "Uharteko",
+ "Taxandru"
+ ]
+ },
+ "config-page-language": "Limba",
+ "config-page-name": "Nùmene",
+ "config-page-options": "Preferèntzias",
+ "mainpagetext": "'''MediaWiki est stadu installadu in modu currèggidu.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/scn.json b/www/wiki/includes/installer/i18n/scn.json
new file mode 100644
index 00000000..b9c049a0
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/scn.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''Nstallazzioni di MediaWiki cumplitata currettamenti.'''",
+ "mainpagedocfooter": "Pi favuri taliari [https://meta.wikimedia.org/wiki/Help:Contents Guida utenti] pi aiutu supra l'usu e la cunfigurazzioni di stu software wiki.\n\n== P'accuminzari ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Alencu di mpustazzioni di cunfigurazzioni]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list dî rilassi di MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/sco.json b/www/wiki/includes/installer/i18n/sco.json
new file mode 100644
index 00000000..c79c22d3
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sco.json
@@ -0,0 +1,311 @@
+{
+ "@metadata": {
+ "authors": [
+ "AmaryllisGardener",
+ "John Reid",
+ "Seb35",
+ "Macofe",
+ "Aursani"
+ ]
+ },
+ "config-desc": "The installer fer MediaWiki",
+ "config-title": "MediaWiki $1 installation.",
+ "config-information": "Information",
+ "config-localsettings-upgrade": "Ae <code>LocalSettings.php</code> file haes been detectit.\nTae upgrade this installation, please enter the vailyie o <code>$wgUpgradeKey</code> in the kist ablo.\nYe'll fynd it in <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Ae <code>LocalSettings.php</code> file haes been detectit.\nTae upgrade this installation, please rin <code>update.php</code> insteid",
+ "config-localsettings-key": "The Upgrade key:",
+ "config-localsettings-badkey": "The upgrade key ye providit is incorrect.",
+ "config-upgrade-key-missing": "Aen exeestin installation o MediaWiki haes been detectit.\nTae upgrade this installation, please pit the follaein line at the bottom o yer <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "The exeestin <code>LocalSettings.php</code> appears tae be oncompleate.\nThe $1 variable isna set.\nPlease chynge <code>LocalSettings.php</code> sae that this variable is set, n clap \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Ae mistak wis encountered whan connectin til the database uisin the settins specified in <code>LocalSettings.php</code>. Please fix thir settins n try again.\n\n$1",
+ "config-session-error": "mistak in stertin session: $1",
+ "config-session-expired": "Yer session data seems tae'v expired.\nSessions ar configured fer ae lifetime o $1.\nYe can increase this bi settin <code>session.gc_maxlifetime</code> in php.ini.\nRestart the installation process.",
+ "config-no-session": "Yer session data wis tint!\nCheck yer php.ini an mak sair <code>session.save_path</code> is set til aen appropriate directerie.",
+ "config-your-language": "Yer leid:",
+ "config-your-language-help": "Select ae leid tae uise durin the installâtion process.",
+ "config-wiki-language": "Wiki leid:",
+ "config-wiki-language-help": "Select the leid that the wiki will predominantly be wrutten in.",
+ "config-back": "← Laist",
+ "config-continue": "Contînue →",
+ "config-page-language": "Leid",
+ "config-page-welcome": "Weelcome til MediaWiki!",
+ "config-page-dbconnect": "Connect til database",
+ "config-page-upgrade": "Upgrade exeestin installâtion",
+ "config-page-dbsettings": "Database settins",
+ "config-page-name": "Name,",
+ "config-page-options": "Opties",
+ "config-page-install": "Install,",
+ "config-page-complete": "Compleate!",
+ "config-page-restart": "Restart installâtion",
+ "config-page-readme": "Read me,",
+ "config-page-releasenotes": "Release nôtes",
+ "config-page-copying": "Copiein",
+ "config-page-upgradedoc": "Upgradin",
+ "config-page-existingwiki": "Exeestin wiki",
+ "config-help-restart": "Div ye wish tae clear aw hained data that ye'v entered n restairt the instawlation process?",
+ "config-restart": "Ai, restart it",
+ "config-welcome": "=== Environmêntal checks ===\nBasic checks will nou be performed tae see gif this environment is suitable fer MediaWiki installâtion.\nMynd tae inclæde this information gif ye seek heelp oan hou tae complete the installâtion.",
+ "config-copyright": "=== Copiericht n Terms ===\n\n$1\n\nThis program is free saffware; ye can redistreebute it n/or modifie it unner the terms o the GNU General Public License aes published bi the Free Software Foundation; either version 2 o the License, or (yer optie) onie later version.\n\nThis program is distributed in the hope that it will be uiseful, but <strong>wioot onie warrantie</strong>; wioot even the implied warrantie o <strong>merchantabeelity</strong> or <strong>fitness fer ae parteecular purpose</strong>.\nSee the GNU General Public License fer mair details.\n\nYe shid hae receeved <doclink href=Copying> ae copie o the GNU General Publeec License</doclink> alang wi this program; gif naw, write til the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, or [http://www.gnu.org/copyleft/gpl.html read it online].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki home]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administrator's Guide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Read me</doclink>\n* <doclink href=ReleaseNotes>Release notes</doclink>\n* <doclink href=Copying>Copiein</doclink>\n* <doclink href=UpgradeDoc>Upgradin</doclink>",
+ "config-env-good": "The environment haes been checked.\nYe can install MediaWiki.",
+ "config-env-bad": "The environment haes been checked.\nYe canna install MediaWiki.",
+ "config-env-php": "PHP $1 is instâlled.",
+ "config-env-hhvm": "HHVM $1 is instawed.",
+ "config-unicode-using-intl": "Uising the [http://pecl.php.net/intl intl PECL extension] fer Unicode normalization.",
+ "config-unicode-pure-php-warning": "<strong>Warnishment:</strong> The [http://pecl.php.net/intl intl PECL extension] is no available tae haunle Unicode normalisation, fawin back tae slaw pure-PHP implementation.\nGif ye rin ae hei-traffic steid, ye shid read ae wee bit oan [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].",
+ "config-unicode-update-warning": "<strong>Wairnin:</strong> The installed version o the Unicode normalisation wrapper uises an aulder version o [http://site.icu-project.org/ the ICU project's] library.\nYe shoud [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations upgrade] if ye are at aw concerned aboot uisin Unicode.",
+ "config-no-db": "Could nae find a suitable database driver! Ye need tae install a database driver for PHP.\nThe follaein database {{PLURAL:$2|type is|types are}} supportit: $1.\n\nIf you compiled PHP yersel, reconfigur it wi a database client enabled, for example, uisin <code>./configure --with-mysqli</code>.\nIf ye installed PHP frae a Debian or Ubuntu package, then ye an aa need tae install, for example, the <code>php5-mysql</code> package.",
+ "config-outdated-sqlite": "<strong>Warnishment:</strong> ye have SQLite $1, this is lower than minimum required version $2. SQLite will be onavailable.",
+ "config-no-fts3": "<strong>Warnishment:</strong> SQLite is compiled wioot the [//sqlite.org/fts3.html FTS3 module], rake features will be onavailable oan this backend.",
+ "config-pcre-old": "<strong>Fatal:</strong> PCRE $1 or later is required.\nYer PHP binary is link't wi PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Mair informâtion].",
+ "config-pcre-no-utf8": "<strong>Fatal:</strong> PHP's PCRE module seems tae be compiled wioot PCRE_UTF8 support.\nMediaWiki requires UTF-8 support tae function correctly.",
+ "config-memory-raised": "PHP's <code>memerie_limit</code> is $1, raised til $2.",
+ "config-memory-bad": "<strong>Warnishment:</strong> PHP's <code>memerie_limit</code> is $1.\nThis is proably ower low.\nThe installation micht fail!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] is installed.",
+ "config-apc": "[http://www.php.net/apc APC] is installed.",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] is instawed.",
+ "config-no-cache-apcu": "<strong>Wairnin:</strong> Could nae find [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] or [http://www.iis.net/download/WinCacheForPhp WinCache].\nObject cachin isna enabled.",
+ "config-mod-security": "<strong>Warnishment:</strong> Yer wab server haes [http://modsecurity.org/ mod_security] enabled. Gif misconfeegured, it can cause problems fer MediaWiki or ither saffware that allous uisers tae post arbitrie content.\nRefer til [http://modsecurity.org/documentation/ mod_security documentation] or contact yer host's support gif ye encounter random mistaks.",
+ "config-diff3-bad": "GNU diff3 naw foond.",
+ "config-git": "Foond the Git version control saffware: <code>$1</code>.",
+ "config-git-bad": "Git version control saffware no foond.",
+ "config-imagemagick": "Foond ImageMagick: <code>$1</code>.\nEemage thummnailin will be enabled gif ye enable uplaids.",
+ "config-gd": "Foond GD graphics librie biggit-in.\nEemage thummnailin will be enabled gif ye enable uplaids.",
+ "config-no-scaling": "Coudna fynd GD librie or ImageMagick.\nEemage thummnailin will be disabled.",
+ "config-no-uri": "<strong>Mistak:</strong> Coudna determine the current URI.\nInstallâtion aborted.",
+ "config-no-cli-uri": "<strong>Warnishment:</strong> Naw <code>--scriptpath</code> speceefied, uisin defaut: <code>$1</code>.",
+ "config-using-server": "Uisin server name \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Uisin server URL \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Warnishment:</strong> Yer defaut directerie fer uplaids <code>$1</code> is vulnerable til arbitrie scripts execution.\nAathough MediaWiki checks aw uplaided files fer securitie threats, it is heily recommended tae [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security close this security vulnerabeelitie] afore enablin uplaids.",
+ "config-no-cli-uploads-check": "<strong>Warnishment:</strong> Yer defaut directerie fer uplaids (<code>$1</code>) isna checkit fer vulnerabeelitie\ntae arbitrie script execution durin the CLI install.",
+ "config-brokenlibxml": "Yer system haes ae combinâtion o PHP n libxml2 versions that's buggie n can cause skauk't data rottin in MediaWiki n ither wab applicâtions.\nUpgrade til libxml2 2.7.3 or later ([https://bugs.php.net/bug.php?id=45996 bug filed with PHP]).\nInstallâtion aborted.",
+ "config-suhosin-max-value-length": "Suhosin is installed n limits the GET parameter <code>length</code> til $1 bytes.\nMediaWiki's ResoorceLaider component will wark aroonn this limit, but that will lawer performance.\nGif at aw possible, ye shid set <code>suhosin.get.max_value_length</code> til 1024 or heier in <code>php.ini</code>, n set <code>$wgResourceLoaderMaxQueryLength</code> til the same value in <code>LocalSettings.php</code>.",
+ "config-db-type": "Dâtabase type:",
+ "config-db-host": "Dâtabase host:",
+ "config-db-host-help": "Gif yer database server is oan ae different server, enter the host name or IP address here.\n\nGif ye'r uisin shaired wab hostin, yer hostin provider shid gie ye the richt host name in their documentation.\n\nGif ye'r installin oan ae Windows server n uisin MySQL, uisin \"localhost\" michtna wark fer the server name. Gif it disna, try \"127.0.0.1\" fer the local IP address.\n\nGif ye'r uisin PostgreSQL, lea this field blank tae connect bi wa o ae Unix socket.",
+ "config-db-host-oracle": "Dâtabase TNS:",
+ "config-db-host-oracle-help": "Enter ae valid [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; ae tnsnames.ora file maun be veesible til this instawation. <br />Gif ye'r uisin client libries 10g or newer ye can uise forby the [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect] namin methyd.",
+ "config-db-wiki-settings": "Identifie this wiki",
+ "config-db-name": "Dâtabase name:",
+ "config-db-name-help": "Chuise ae name that identifies yer wiki.\nIt shidna contain spaces.\n\nGif ye'r uisin shaired wab hoastin, yer hoastin provider will either gie ye ae speceefic database name tae uise or let ye mak databases bi waa o ae control panel.",
+ "config-db-name-oracle": "Dâtabase schema:",
+ "config-db-account-oracle-warn": "Thaur's three supportit scenaríos fer instawin Oracle aes ae database backend:\n\nGif ye wish tae cræft ae database accoont aes pairt o the instawation process, please supplie aen accoont wi SYSDBA role aes database accoont fer instawation n speceefie the desired creedentials fer the wab-access accoont, itherwise ye can eether cræft the wab-access accoont manuallie n supplie yinlie that accoont (gif it haes the needit permeessions tae cræft the schema objects) or supplie twa differant accoonts, yin wi cræft preevileges n ae restreectit yin fer wab access.\n\nScreept fer cræftin aen accoont wi the needit preevileges can be foond in the \"maintenance/oracle/\" directerie o this instawation. Keep in mynd that uisin ae restreectit accoont will disable aw maintenance capabileeties wi the defaut accoont.",
+ "config-db-install-account": "Uiser accoont fer installâtion",
+ "config-db-username": "Database uisername:",
+ "config-db-password": "Database passwaird:",
+ "config-db-install-username": "Enter the uisername that will be uised tae connect til the database durin the installâtion process.\nThis isna the uisername o the MediaWiki accont; this is the uisername fr yer database.",
+ "config-db-install-password": "Enter the passwaird that will be uised tae connect til the database durin the installâtion process.\nThis isna the passwaird fer the MediaWiki accoont; this is the passwaird fer yer database.",
+ "config-db-install-help": "Enter the uisername an passwaird that will be uised tae connect til the database durin the installâtion process.",
+ "config-db-account-lock": "Uise the same uisername an passwaird durin normal operation",
+ "config-db-wiki-account": "Uiser accoont fer normal operâtion",
+ "config-db-wiki-help": "Enter the uisername n passwaird that will be uised tae connect til the database durin normal wiki operâtion.\nGif the accoont disna exeest, n the instawlation accoont haes suffeecient preevileges, this uiser accoont will be cræftit wi the least preevileges needed tae operate the wiki.",
+ "config-db-prefix": "Database buird prefix:",
+ "config-db-prefix-help": "Gif ye need tae shair yin database atween multiple wikis, or atween MediaWiki n anither wab appleecation, ye can chuise tae eik ae prefix til aw the buird names tae avoid confleects.\nDinna uise spaces.\n\nThis field is uisuallie left tuim.",
+ "config-mysql-old": "MaSQL $1 or later is required. Ye hae $2.",
+ "config-db-port": "Dâtabase port:",
+ "config-db-schema": "Schema fer MediaWiki:",
+ "config-db-schema-help": "This schema will uisually be fine.\nyinly chynge it gif ye ken ye need tae.",
+ "config-pg-test-error": "Canna connect til database <strong>$1</strong>: $2",
+ "config-sqlite-dir": "SQLite data directerie:",
+ "config-sqlite-dir-help": "SQLite stores aw data in ae single file.\n\nThe directerie ye provide maun be writable bi the wabserver durin instawation.\n\nIt shid <strong>no</strong> be accessible bi waa o the wab, this is why we'r no puttin it whaur yer PHP files ar.\n\nThe instawer will write ae <code>.htaccess</code> file alang wi it, but gif that fails somebodie can gain access til yer raw database.\nThat incluides raw uiser data (wab-mail addresses, hashed passwairds) aes weel aes delytit reveesions n ither restreected data oan the wiki.\n\nConsider puttin the database some ither place awthegether, fer example in <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Defaut buirdspace:",
+ "config-oracle-temp-ts": "Temperie buirdspace:",
+ "config-type-mysql": "MaSQL (or compâtible)",
+ "config-type-mssql": "Micræsaff SQL Server",
+ "config-support-info": "MediaWiki supports the follaein database systems:\n\n$1\n\nGif ye dinna see the database system ye'r tryin tae uise listed ablow, than follae the instructions linked abuin tae enable support.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] is the primarie tairget fer MediaWiki n is best supported. MediaWiki warks forby wi [{{int:version-db-mariadb-url}} MariaDB] n [{{int:version-db-percona-url}} Percona Server], thir ar MySQL compatible. ([http://www.php.net/manual/en/mysqli.installation.php Hou tae compile PHP wi MySQL support])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] is ae popular apen soorce database system aes aen alternative til MySQL. Thaur micht be some wee bugs still hingin roond, n it's na recommendit fer uiss in ae production environment. ([http://www.php.net/manual/en/pgsql.installation.php Hou tae compile PHP wi PostgreSQL support])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] is ae lichtweicht database system that is ver weel supportit. ([http://www.php.net/manual/en/pdo.installation.php Hou tae compile PHP wi SQLite support], uises PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] is ae commercial enterprise database. ([http://www.php.net/manual/en/oci8.installation.php Hou tae compile PHP wi OCI8 support])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] is ae commercial enterprise database fer Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Hou tae compile PHP wi SQLSRV support])",
+ "config-header-mysql": "MaSQL settins",
+ "config-header-postgres": "PostgreSQL settins",
+ "config-header-sqlite": "SQLite settins",
+ "config-header-oracle": "Oracle settins",
+ "config-header-mssql": "Microsoft SQL Server settings",
+ "config-invalid-db-type": "Onvalid database type",
+ "config-missing-db-name": "Ye maun enter ae value fer \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Ye maun enter ae value fer \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Ye maun enter ae value fer \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "Onvalid database TNS \"$1\".\nUise either \"TNS Name\" or aen \"Easy Connect\" string ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods])",
+ "config-invalid-db-name": "Onvalid database name \"$1\".\nUise yinly ASCII letters (a-z, A-Z), nummers (0-9), unnerscores (_) an hyphens (-).",
+ "config-invalid-db-prefix": "Onvalid database prefix \"$1\".\nUise yinly ASCII letters (a-z, A-Z), nummers (0-9), unnerscores (_) an hyphens (-).",
+ "config-connection-error": "$1.\n\nCheck the host, uisername n passwaird n gie it anither shot.",
+ "config-invalid-schema": "Onvalid schema fer MediaWiki \"$1\".\nUise yinly ASCII letters (a-z, A-Z), nummers (0-9) an unnerscores (_).",
+ "config-db-sys-create-oracle": "Installer yinly supports usin ae SYSDBA accoont fer makin ae new accoont.",
+ "config-db-sys-user-exists-oracle": "Uiser accoont \"$1\" awreadie exeests. SYSDBA can yinly be uised fer the makin o ae new accoont!",
+ "config-postgres-old": "PostgreSQL $1 or later is required. Ye hae $2.",
+ "config-mssql-old": "Microsoft SQL Server $1 or newer is needed. Ye hae $2.",
+ "config-sqlite-name-help": "Chuise ae name that identifies yer wiki.\nDinna uise spaces or hyphens.\nThis will be uised fer the SQLite data file name.",
+ "config-sqlite-parent-unwritable-group": "Canna mak the data directerie <code><nowiki>$1</nowiki></code>, cause the parent directerie <code><nowiki>$2</nowiki></code> isna writable bi the wabserver.\n\nThe installer haes determined the uiser yer wabserver is runnin aes.\nMak the <code><nowiki>$3</nowiki></code> directerie writable bi it tae continue.\nOan ae Unix/Linux system dae:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Canna cræft the data directerie <code><nowiki>$1</nowiki></code>, cause the pairent directerie <code><nowiki>$2</nowiki></code> isna writable bi the wabserver.\n\nThe instawer coudna determine the uiser yer wabserver is rinnin aes.\nMak the <code><nowiki>$3</nowiki></code> directerie globallie writable bi it (n ithers!) tae continue.\nOan ae Unix/Linux system dae:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Mistak in cræftin the data directerie \"$1\".\nCheck the location n try again.",
+ "config-sqlite-dir-unwritable": "Onable tae write in the directerie \"$1\".\nChynge its permeessions sae that the wabserver can write in it, n gie it anither gae.",
+ "config-sqlite-connection-error": "$1.\n\nCheck the data directerie n database name ablo n try again.",
+ "config-sqlite-readonly": "The file <code>$1</code> isna writeable.",
+ "config-sqlite-cant-create-db": "Coudna make database file <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP is missing FTS3 support, doongradin buirds",
+ "config-can-upgrade": "Thaur's MediaWiki buirds in this database.\nTae upgrade thaim til MediaWiki $1, clap <strong>Continue</strong>.",
+ "config-upgrade-done": "Upgrade compleate.\n\nYe can nou [$1 stert uising yer wiki].\n\nGif ye wish tae regenerate yer <code>LocalSettings.php</code> file, clap the button ablow.\nThis <strong> isna recommended</strong> onless ye'r haein problems wi yer wiki.",
+ "config-upgrade-done-no-regenerate": "Upgrade compleate.\n\nYe can nou [$1 stert uising yer wiki].",
+ "config-regenerate": "Regênerate LocalSettings.php →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code> speirin failed!",
+ "config-unknown-collation": "<strong>Warnishment:</strong> Database is uisin onrecognized collation.",
+ "config-db-web-account": "Database accoont fer wab access",
+ "config-db-web-help": "Select the uisername n passwaird that the wab server will uise tae connect til the database server, durin ordinair operation o the wiki.",
+ "config-db-web-account-same": "Uise the same accoont aes fer installation",
+ "config-db-web-create": "Cræft the accoont gif it disna awreadie exeest",
+ "config-db-web-no-create-privs": "The accoont that ye speceefied fer instawation disna hae enooch preevileges tae cræft aen accoont.\nThe accoont that ye speceefie here maun awreadie exeest.",
+ "config-mysql-engine": "Storage engine:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Warnishment:</strong> Ye'v selected MyISAM aes storage engine fer MySQL, this isna recommended fer uiss wi MediaWiki, cause:\n* it barelie supports concurrencie cause o buird lockin\n* it's mair prone til rot than ither engines\n* the MediaWiki codebase disna aye haunnle MyISAM aes it shid\n\nGif yer MySQL installâtion supports InnoDB, it is heilie recommended that ye chuise that instead.\nGif yer MySQL installâtion disna support InnoDB, than perhaps it's time fer aen upgrade.",
+ "config-mysql-only-myisam-dep": "<strong>Warnishment:</strong> MyISAM is the yinly available storage engine fer MySQL oan this machine, n this isna recommended fer uiss wi MediaWiki, cause:\n* it barelie supports concurrencie cause o buird lockin\n* it is mair prone til rot than ither engines\n* the MediaWiki codebase disna aye haunnle MyISAM aes it shid\n\nYer MySQL installâtion dina support InnoDB, perhaps it's time fer aen upgrade.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> is awmaist aye the best optie, aes it haes guid concurrencie support.\n\n<strong>MyISAM</strong> micht be faster in single-uiser or read-yinly installâtions.\nMyISAM databases tend tae rot mair aften than InnoDB databases.",
+ "config-mysql-charset": "Database chairacter set:",
+ "config-mysql-binary": "Binarie",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "In <strong>binarie mode</strong>, MediaWiki stores UTF-8 tex til the database in binarie fields.\nThis is mair effeecient than MySQL's UTF-8 mode, n permits ye tae uise the ful range o Unicode chairacters.\n\nIn <strong>UTF-8 mode</strong>, MySQL will ken whit chairacter set yer data is in, n can present n convert it appropreeatelie, but it'll naw lat ye store chairacters abuin the [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane].",
+ "config-mssql-auth": "Authentication type:",
+ "config-mssql-install-auth": "Select the authentication type that's tae be uised tae connect wi the database durin the installation process.\nGif ye select \"{{int:config-mssql-windowsauth}}\", the credeentials o whitever uiser the wabserver is rinnin aes will be uised.",
+ "config-mssql-web-auth": "Select the authentication type that the wab server will uise tae connect wi the database server, durin ordinair operation o the wiki.\nGif ye select \"{{int:config-mssql-winowsauth}}\", the credeentials o whitever uiser the wabserver is rinnin aes will be uised.",
+ "config-mssql-sqlauth": "SQL Server Authentication",
+ "config-mssql-windowsauth": "Windows Authentication",
+ "config-site-name": "Name o wiki:",
+ "config-site-name-help": "This will kyth in the title baur o the brouser n in varioos ither places.",
+ "config-site-name-blank": "Enter ae site name.",
+ "config-project-namespace": "Waurk namespace:",
+ "config-ns-generic": "Waurk",
+ "config-ns-site-name": "Same aes the wiki name: $1",
+ "config-ns-other": "Ither (speceefie)",
+ "config-ns-other-default": "MaWiki",
+ "config-project-namespace-help": "Follaein Wikipaedia's example, mony wikis keep thair policy pages separate frae thair content pages, in a '''project namespace'''.\nAw page teetles in this namespace stairt wi a certain prefix, which ye can specify here.\nUisually, this prefix is derived frae the name o the wiki, but it canna conteen punctuation characters such as \"#\" or \":\".",
+ "config-ns-invalid": "The speceefied namespace \"<nowiki>$1</nowiki>\" is onvalid.\nSpeceefie ae different project namespace.",
+ "config-ns-conflict": "The speceefied namespace \"<nowiki>$1</nowiki>\" conflicts wi ae defaut MediaWiki namespace.\nSpeceefie ae different project namespace.",
+ "config-admin-box": "Admeenistrater accoont",
+ "config-admin-name": "Yer uisername:",
+ "config-admin-password": "Passwaird:",
+ "config-admin-password-confirm": "Passwaird again:",
+ "config-admin-help": "Enter yer preferred uisername here, fer example \"John Smith\".\nThis is the name ye'll uise tae log in til the wiki.",
+ "config-admin-name-blank": "Enter aen admeenistrater uisername.",
+ "config-admin-name-invalid": "The speceefied uisername \"<nowiki>$1</nowiki>\" is onvalid.\nSpeceefie ae different uisername.",
+ "config-admin-password-blank": "Enter ae passwaird fer the admeenistrater accoont.",
+ "config-admin-password-mismatch": "The twa passwairds ye entered dinna match.",
+ "config-admin-email": "Wab-mail address:",
+ "config-admin-email-help": "Enter ae wab-mail address here tae permit ye tae receive wab-mail fae ither uisers oan the wiki, reset yer passwaird, n be telt o chynges til pages oan yer watchleet. Ye can lea this field tuim.",
+ "config-admin-error-user": "Internal mistak whan makin aen admeen wi the name \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Internal mistak whan settin ae passwaird fer the admeen \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Ye'v entered aen onvalid wab-mail address.",
+ "config-subscribe": "Subscribe til the [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce release annooncements mailin leet].",
+ "config-subscribe-help": "This is ae low-volume mailin leet uised fer release annooncements, inclæding important securitie annooncements.\nYe shid subscribe til it an update yer MediaWiki installâtion whan new versions come oot.",
+ "config-subscribe-noemail": "Ye tried tae subscribe til the release annooncements mailin let wioot giein ae wab-mail address.\nPlease gei ae wab-mail address gif ye wish tae subscribe til the mailin leet.",
+ "config-almost-done": "Ye'r awmaist dun!\nYe can nou skip the remainin confeegurâtion n install the wiki stricht awa.",
+ "config-optional-continue": "Speir me mair speirins.",
+ "config-optional-skip": "Ah'm bored awreadie, jyst install the wiki.",
+ "config-profile": "Uiser richts profile:",
+ "config-profile-wiki": "Apen wiki",
+ "config-profile-no-anon": "Please mak aen accoont",
+ "config-profile-fishbowl": "Permited eiditors yinly",
+ "config-profile-private": "Private wiki",
+ "config-profile-help": "Wikis wark best whan ye lat aes monie fawk eedit thaim aes possible.\nIn MediaWiki, it's easie tae luik ower the recent chynges, n tae revert onie damage that's dun bi naeeve or maleecioos uisers.\n\nHouever, monie hae foond MediaWiki tae be uissful in ae wide varietie o roles, n sometimes it's na easie tae conveence awbodie o the beneefits o the wiki wa.\nSae ye hae the choice.\n\nThe <strong>{{int:config-profile-wiki}}</strong> model allous oniebdie tae eedit, wioot even loggin in.\nAe wiki wi <strong>{{int:config-profile-no-anon}}</strong> provides eextra accoontabeelitie, but micht deter casual contreebuters.\n\nThe <strong>{{int:config-profile-fishbowl}}</strong> scenario allous appruived uisers tae eedit, but the publeec can see the pages, incluidin histerie.\nA <strong>{{int:config-profile-private}}</strong> yinlie permits appruived uisers tae see pages, wi the same groop permited tae eedit.\n\nMair complex uiser richts confeegurations ar available efter instawation, see the [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights relevant manual entrie].",
+ "config-license": "Copiericht n license:",
+ "config-license-none": "Nae license fiter",
+ "config-license-cc-by-sa": "Creative Commyns Attribution Share Alike",
+ "config-license-cc-by": "Creative Commyns Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commyns Attribution No-Commercial Shair Alike",
+ "config-license-cc-0": "Creative Commyns Zero (Public Domain)",
+ "config-license-gfdl": "GNU Free Documentâtion License 1.3 or later",
+ "config-license-pd": "Public Domain",
+ "config-license-cc-choose": "Select ae custym Creative Commyns license",
+ "config-license-help": "Monie publeec wikis pit aw contreebutions unner ae [http://freedomdefined.org/Defineetion free license].\nThis heelps tae creaut ae sense o communitie ainership n encoorages lang-term contreebution.\nIt's naw generallie necessair fer ae preevate or corporate wiki.\n\nGif ye wish tae be able tae uise tex fae Wikipædia, n ye want Wikipædia tae be able tae accept tex copied fae yer wiki, than ye shid chuise <strong>Creative Commons Attribution Shair Alike</strong>.\n\nWikipædia preeveeooslie uised the GNU Free Documentation License.\nThe GFDL is ae valid license, but it's difficult tae unnerstaunn.\nMairower, it's difficult tae reuise content licensed unner the GFDL.",
+ "config-email-settings": "Wab-mail settins",
+ "config-enable-email": "Enable ootboond wab-mail",
+ "config-enable-email-help": "Gif ye want wab-mail tae wark, [http://www.php.net/manual/en/mail.configuration.php PHP's mail settins] need tae be confeegured jyst richt.\nGif ye dinna want oni wab-mail features, ye can disable theim here.",
+ "config-email-user": "Enable uiser-til-uiser wab-mail",
+ "config-email-user-help": "Permit aw uisers tae send each ither wab-mail gif they'v enabled it in their preferences.",
+ "config-email-usertalk": "Enable uiser tauk page notifeecâtion",
+ "config-email-usertalk-help": "Permit uisers tae receive notifeecâtions oan uiser tauk page chynges, gif they'v enabled it in their preferences.",
+ "config-email-watchlist": "Enable watchleet notifeecâtion",
+ "config-email-watchlist-help": "Permit uisers tae receive notifeecâtions aneat their watched pages gif they'v enabled it in their preferences.",
+ "config-email-auth": "Enable wab-mail authenticâtion",
+ "config-email-auth-help": "Gif this optie is enabled, uisers hae tae confirm their wab-mail address uising ae link sent til theim whanivir they set or chynge it.\nYinly authenticated wab-mail addresses can receive emails fae ither uisers or chynge notifeecâtion wab-mails.\nSettin this optiei is <strong>recommended</strong> fer public wikis cause o potential abuise o the wab-mail features.",
+ "config-email-sender": "Return wab-mail address:",
+ "config-email-sender-help": "Enter the wab-mail address tae uise aes the return address oan ootboond wab-mail.\nThis is whaur boonces will be sent.\nMonie mail servers need at least the domain name pairt tae be valid.",
+ "config-upload-settings": "Eemages n file uplaids",
+ "config-upload-enable": "Enable file uplaids",
+ "config-upload-help": "File uplaids potentiallie expose yer server til securitie risks.\nFer mair information, read the [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security securitie section] in the manual.\n\nTae enable file uplaids, chynge the mode oan the <code>eemages</code> subdirecterie unner MediaWiki's ruit directerie sae that the wab server can write til it.\nThan enable this optie.",
+ "config-upload-deleted": "Directerie fer delytit files:",
+ "config-upload-deleted-help": "Chuise ae directerie tae archive delytit files in.\nIdeally, this shidna be accessible fae the wab.",
+ "config-logo": "Logo URL:",
+ "config-logo-help": "MediaWiki's defaut skin inclædes space fer ae 135x160 pixel logo abuin the sidebaur menu.\nUplaid aen eemage o the appropriate size, n enter the URL here.\n\nYe can uise <code>$wgStylePath</code> or <code>$wgScriptPath</code> gif yer logo is relative til thae paths.\n\nGif ye dinna want ae logo, lea this kist blank.",
+ "config-instantcommons": "Enable Instant Commyns",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] is a featur that allaes wikis tae uise images, soonds an ither media foond on the [https://commons.wikimedia.org/ Wikimedia Commons] steid.\nIn order tae dae this, MediaWiki requires access tae the Internet.\n\nFor mair information on this featur, includin instructions on hou tae set it up for wikis ither nir the Wikimedia Commons, consult [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos the manual].",
+ "config-cc-error": "The Creative Commyns license chuiser gae naw result.\nEnter the license name manually.",
+ "config-cc-again": "Pick again...",
+ "config-cc-not-chosen": "Chuise whit Creative Commyns license ye want an clap oan \"proceed\".",
+ "config-advanced-settings": "Advanced confeegurâtion",
+ "config-cache-options": "Settins fer object cachin:",
+ "config-cache-help": "Object cachin is uised tae impruiv the speed o MediaWiki bi cachin frequentlie uised data.\nMedium til muckle sites ar heilie encooraged tae enable this, n wee sites will see benefits ava.",
+ "config-cache-none": "Naw caching (nae functionâlitie is remuived, but speed mmicht be impacted oan muckler wiki sites)",
+ "config-cache-accel": "PHP object cachin (APC, XCache or WinCache)",
+ "config-cache-memcached": "Uise Memcached (needs addeetional setup n confeegurâtion)",
+ "config-memcached-servers": "Memcached servers:",
+ "config-memcached-help": "Leet o IP addresses tae uise fer Memcached.\nShid speceefie yin per line n speceefie the port tae be uised. Fer example:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Ye selected Memcached aes yer cache type but dinna speceefie oni servers.",
+ "config-memcache-badip": "Ye'v entered aen onvalid IP address fer Memcached: $1.",
+ "config-memcache-noport": "Ye didna speceefie ae port tae uise fer Memcached server: $1.\nGif ye dinna knaw the port, the defauut is 11211.",
+ "config-memcache-badport": "Memcached port nummers shid be atween $1 n $2.",
+ "config-extensions": "Extensions",
+ "config-extensions-help": "The extensions leetit abuin were detected in yer <code>./extensions</code> directerie.\n\nThey micht need addeetional confeeguration, but ye can enable thaim nou.",
+ "config-skins": "Skins",
+ "config-skins-help": "The skins leetit abuin were detectit in yer <code>./skins</code> directerie. Ye maun enable at least yin, n chuise the defaut.",
+ "config-skins-use-as-default": "Uise this skin aes the defaut",
+ "config-skins-missing": "Nae skins were foond; MediaWiki will uise ae fawback skin ontil ye instaw some proper skins.",
+ "config-skins-must-enable-some": "Ye need tae chuisse at least yin skin tae enable.",
+ "config-skins-must-enable-default": "The skin chosen aes the defaut maun be enablit.",
+ "config-install-alreadydone": "<strong>Warnishment:</strong> Ye seem tae'v awreadie instawed MediaWiki n ar tryin tae instaw it again.\nPlease proceed til the nex page.",
+ "config-install-begin": "Bi pressin \"{{int:config-continue}}\", ye will begin the installation o MediaWiki.\nGif ye still wish tae mak chynges, press \"{{int:config-back}}\".",
+ "config-install-step-done": "dun",
+ "config-install-step-failed": "failed",
+ "config-install-extensions": "Inclædin extensions",
+ "config-install-database": "Settin up database",
+ "config-install-schema": "Makin schema",
+ "config-install-pg-schema-not-exist": "PostgreSQL schema disna exeest.",
+ "config-install-pg-schema-failed": "Buirds makin failed.\nMak sair that the uiser \"$1\" can write til the schema \"$2\".",
+ "config-install-pg-commit": "Committin chynges",
+ "config-install-pg-plpgsql": "Checkin fer lied PL/pgSQL",
+ "config-pg-no-plpgsql": "Ye need tae install the leid PL/pgSQL in the database $1",
+ "config-pg-no-create-privs": "The accoont ye speceefied fer installâtion disna hae enough preevileges tae mak aen accoont.",
+ "config-pg-not-in-role": "The accoont that ye speceefied fer the wab uiser awreadie exists.\nThe accoont that ye specefied fer installâtion isna ae suiperuiser an isna a memmer o the wab uiser's role, sae it is onable tae mak objects ained bi the wab uiser.\n\nMediaWiki currentlie requires that the buirds be ained bi the wab uiser. Please specefie anither wab accoont name, or clap \"back\" an speceefie ae suitablie preevileged install uiser.",
+ "config-install-user": "Makin database uiser",
+ "config-install-user-alreadyexists": "Uiser \"$1\" awreadie exists",
+ "config-install-user-create-failed": "Makin uiser \"$1\" failed: $2",
+ "config-install-user-grant-failed": "Grantin permission til uiser \"$1\" failed: $2",
+ "config-install-user-missing": "The speceefied uiser \"$1\" disna exeest.",
+ "config-install-user-missing-create": "The speceefied uiser \"$1\" disna exeest.\nPlease clap the \"cræft accoont\" checkkist ablo gif ye wish tae cræft it.",
+ "config-install-tables": "Makin buirds",
+ "config-install-tables-exist": "<strong>Warnishment:</strong> MediaWiki buirds awreadie seem tae exeest.\nSkippin the cræftin.",
+ "config-install-tables-failed": "<strong>Mistak:</strong> Buird cræftin failed wi the follaein mistak: $1",
+ "config-install-interwiki": "Populatin defaut interwiki buird",
+ "config-install-interwiki-list": "Coudna read file <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Warnishment:</strong> The interwiki buird awreadie seems tae hae entries.\nSkippin defaut let.",
+ "config-install-stats": "Ineetializin stateestics",
+ "config-install-keys": "Generatin hidlins keys",
+ "config-insecure-keys": "<strong>Warnishment:</strong> {{PLURAL:$2|Ae secure key|Secure keys}} ($1) generated durin instawation {{PLURAL:$2|is|ar}} naw compleatelie safe. Consider chyngin {{PLURAL:$2|it|theim}} manuallie.",
+ "config-install-updates": "Hinder the runnin o onneedit updates.",
+ "config-install-updates-failed": "<strong>Mistak:</strong> Insertin update keys intae the buirds failed wi the folleain mistak: $1",
+ "config-install-sysop": "Makin admeenistrâter uiser accoont",
+ "config-install-subscribe-fail": "Onable tae subscribe til mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL isna instawed n <code>allow_url_fopen</code> is na available.",
+ "config-install-mainpage": "Cræftin main page wi defaut content",
+ "config-install-extension-tables": "Makin buirds fer enabled extensions",
+ "config-install-mainpage-failed": "Coudna insert main page: $1",
+ "config-install-done": "<strong>Congratulations!</strong>\nYe hae installed MediaWiki.\n\nThe installer has generatit a <code>LocalSettings.php</code> file.\nIt conteens aw yer configuration.\n\nYe will need tae dounlaid it an put it in the base o yer wiki installation (the same directory as index.php). The dounlaid should hae stairtit automatically.\n\nIf the dounlaid wisna offered, or if ye cancelled it, ye can restairt the dounlaid bi clickin the airtin ablo:\n\n$3\n\n<strong>Note:</strong> If ye dinna dae this nou, this generatit configuration file will nae be available tae ye later if ye exit the installation wioot dounlaidin it.\n\nWhen that haes been duin, ye can <strong>[$2 enter yer wiki]</strong>.",
+ "config-download-localsettings": "Dounlaid <code>LocalSettings.php</code>",
+ "config-help": "heelp",
+ "config-help-tooltip": "clap tae mak muckler",
+ "config-nofile": "File \"$1\" coudna be foond. Haes it been delytit?",
+ "config-extension-link": "Did ye ken that yer wiki supports [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensions]?\n\nYe can brouse [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensions bi categorie] or the [https://www.mediawiki.org/wiki/Extension_Matrix Extension Matrix] tae see the full leet o extensions.",
+ "mainpagetext": "<strong>MediaWiki haes been installed.</strong>",
+ "mainpagedocfooter": "Consult the [https://meta.wikimedia.org/wiki/Help:Contents/sco Uiser's Guide] fer information oan uisin the wiki saffware.\n\n== Gettin stairtit ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Confeeguration settins leet]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailin leet]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki fer yer leid]"
+}
diff --git a/www/wiki/includes/installer/i18n/sd.json b/www/wiki/includes/installer/i18n/sd.json
new file mode 100644
index 00000000..6dab0881
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sd.json
@@ -0,0 +1,38 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sindhu",
+ "Aursani",
+ "Mehtab ahmed"
+ ]
+ },
+ "config-desc": "ميڊياوڪيءَ لاءِ تنصيبڪار",
+ "config-title": "ميڊياوڪي $1 تنصيب",
+ "config-information": "معلومات",
+ "config-localsettings-key": "ڪنجي اپگريڊ ڪريو:",
+ "config-localsettings-badkey": "توهان جي سنواري ڏنل ڪنجي غيردرست آهي.",
+ "config-session-error": "سيشن ھلائڻ ۾ خرابي:$1",
+ "config-your-language": "توهان جي ٻولي:",
+ "config-your-language-help": "تنصيب جي عمل دوران استعمال ڪرڻ لاءِ ڪا ٻولي چونڊيو.",
+ "config-wiki-language": "وڪِي ٻولي:",
+ "config-back": "پوئتي ←",
+ "config-continue": "اڳتي →",
+ "config-page-language": "ٻولي",
+ "config-page-welcome": "ذريعات‌وڪي تي ڀلي ڪري آيا!",
+ "config-page-dbconnect": "اعدادخاني سان جُڙو",
+ "config-page-upgrade": "هاڻوڪي تنصيبڪاريءَ کي سڌاريو",
+ "config-page-dbsettings": "اعدادخاني جون ترتيبون",
+ "config-page-name": "نالو",
+ "config-page-options": "آپشنس",
+ "config-page-install": "تنصيبيو",
+ "config-page-complete": "پُورو!",
+ "config-page-restart": "تنصيبڪاري وري کان شروع ڪريو",
+ "config-page-readme": "مون کي پڙهو",
+ "config-page-copying": "پرت ڪندي",
+ "config-page-upgradedoc": "اپگريڊڪندي",
+ "config-page-existingwiki": "موجوده وڪِي",
+ "config-help-restart": "ڇا توھان سڄي سانڍيل ڊيٽا ختم ڪرڻ چاھيو ٿا جيڪا توھان داخل ڪئي آھي ۽ تنصيبڪاريءَ جي عمل کي ٻيھر شروع ڪرڻ چاھيو ٿا؟",
+ "config-restart": "ها، وري کان شروع ڪريو",
+ "config-env-php": "PHP $1 تنصيب ٿي چڪو",
+ "config-env-hhvm": "HHVM $1 تنصيب ٿي چڪو."
+}
diff --git a/www/wiki/includes/installer/i18n/sdc.json b/www/wiki/includes/installer/i18n/sdc.json
new file mode 100644
index 00000000..74cbeb23
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sdc.json
@@ -0,0 +1,19 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jun Misugi",
+ "Seb35"
+ ]
+ },
+ "config-title": "Isthallazioni di MediaWiki $1",
+ "config-information": "Infuimmazioni",
+ "config-localsettings-key": "Ciabi di attuarizazioni",
+ "config-your-language": "Linga tòia",
+ "config-wiki-language": "Linga di la Vichi",
+ "config-back": "← Indareddu",
+ "config-continue": "Continuà →",
+ "config-page-language": "Linga",
+ "config-page-welcome": "Binvinuddi in MediaWiki!",
+ "mainpagetext": "'''Isthallazioni di MediaWiki accabadda currentementi.'''",
+ "mainpagedocfooter": "Cunsultha la [https://meta.wikimedia.org/wiki/Help:Contents Ghia utenti] pa maggiori infuimmazioni i l'usu di chisthu software wiki.\n\n== Pa ischuminzà ==\nLi sighenti cullegamenti so in linga ingrese:\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Impusthazioni di cunfigurazioni]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Prigonti friquenti i MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list annùnzii MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/sei.json b/www/wiki/includes/installer/i18n/sei.json
new file mode 100644
index 00000000..19c8ef32
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sei.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki coccebj installöx successua zo mii.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/sh.json b/www/wiki/includes/installer/i18n/sh.json
new file mode 100644
index 00000000..f6850a41
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sh.json
@@ -0,0 +1,12 @@
+{
+ "@metadata": {
+ "authors": [
+ "OC Ripper",
+ "Seb35",
+ "Conquistador"
+ ]
+ },
+ "config-admin-password": "Lozinka:",
+ "mainpagetext": "<strong>MediaWiki je uspješno instaliran.</strong>",
+ "mainpagedocfooter": "Za informacije o korištenju wiki softvera konzultirajte [https://meta.wikimedia.org/wiki/Help:Contents Vodič za korisnike].\n\n== Uvod u rad ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista konfiguracije postavki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista primatelja izdanja MediaWikija]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokalizirajte MediaWiki za svoj jezik]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Saznajte kako se boriti protiv spama na svojem wikiju]"
+}
diff --git a/www/wiki/includes/installer/i18n/shi.json b/www/wiki/includes/installer/i18n/shi.json
new file mode 100644
index 00000000..60ef3829
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/shi.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Dalinanir",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki tǧizn (tsrbk) bla tamukrist.'''",
+ "mainpagedocfooter": "Ẓr taǧttnn [https://meta.wikimedia.org/wiki/Help:Contents/fr Guide de l’utilisateur] bac ad tawit inɣmisn yaḍn f manik sa tswwurt asɣẓan ad.\n\n== Izwir d MediaWiki ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Umuɣ n iɣwwarn n usgadda ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/fr Isqqsitn f MidyWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Umuɣ n imsgdaln f imbḍitn n MidyaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/si.json b/www/wiki/includes/installer/i18n/si.json
new file mode 100644
index 00000000..410f480c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/si.json
@@ -0,0 +1,140 @@
+{
+ "@metadata": {
+ "authors": [
+ "Singhalawap",
+ "පසිඳු කාවින්ද",
+ "Sahan.ssw",
+ "Thirsty"
+ ]
+ },
+ "config-desc": "මාධ්‍යවිකි සඳහා වූ ස්ථාපකය",
+ "config-title": "මාධ්‍යවිකි $1 ස්ථාපනය",
+ "config-information": "තොරතුරු",
+ "config-localsettings-key": "උසස්කිරීම් යතුර:",
+ "config-localsettings-badkey": "ඔබ ඉදිරිපත් කෙරූ යතුර වැරදිය.",
+ "config-session-error": "සැසිය ඇරඹීමේ දෝෂය: $1",
+ "config-your-language": "ඔබේ භාෂාව:",
+ "config-wiki-language": "විකි භාෂාව:",
+ "config-back": "← ආපසු",
+ "config-continue": "ඉදිරියට →",
+ "config-page-language": "භාෂාව",
+ "config-page-welcome": "මාධ්‍යවිකි වෙත පිළිගනිමු!",
+ "config-page-dbconnect": "දත්ත සංචිතයට සම්බන්ධ කරන්න",
+ "config-page-upgrade": "පවත්නා ස්ථාපනය උසස් කරන්න",
+ "config-page-dbsettings": "දත්ත සංචිත සැකසුම්",
+ "config-page-name": "නම",
+ "config-page-options": "විකල්ප",
+ "config-page-install": "ස්ථාපනය",
+ "config-page-complete": "සම්පූර්ණයි!",
+ "config-page-restart": "ස්ථාපනය යළි අරඹන්න",
+ "config-page-readme": "මාව කියවන්න",
+ "config-page-releasenotes": "නිකුතු සටහන්",
+ "config-page-copying": "පිටපත් කරමින්",
+ "config-page-upgradedoc": "උසස් කරමින්",
+ "config-page-existingwiki": "පවත්නා විකිය",
+ "config-env-php": "PHP $1 ස්ථාපිතයි.",
+ "config-db-type": "දත්ත සංචිත වර්ගය:",
+ "config-db-host": "දත්ත සංචිත ධාරක:",
+ "config-db-wiki-settings": "මෙම විකිය හඳුනා ගන්න",
+ "config-db-name": "දත්ත සංචිතයේ නම:",
+ "config-db-name-oracle": "දත්ත සංචිත සංක්ෂිප්ත නිරූපණය:",
+ "config-db-install-account": "ස්ථාපනය සඳහා පරිශීලක ගිණුම",
+ "config-db-username": "දත්ත සංචිතයේ පරිශීලක නාමය:",
+ "config-db-password": "දත්ත සංචිතයේ මුරපදය:",
+ "config-db-wiki-account": "සාමාන්‍ය ක්‍රියාකාරිත්වය සඳහා පරිශීලක ගිණුම",
+ "config-db-prefix": "දත්ත සංචිත වගු උපසර්ගය:",
+ "config-db-port": "දත්ත සංචිතයේ කවුළුව:",
+ "config-db-schema": "මාධ්‍යවිකි සඳහා සංක්ෂිප්ත නිරූපණය:",
+ "config-pg-test-error": "'''$1''' දත්ත සංචිතය වෙත සම්බන්ධ විය නොහැක: $2",
+ "config-sqlite-dir": "SQLite දත්ත නාමවලිය:",
+ "config-oracle-def-ts": "සාමාන්‍ය වගු අවකාශය:",
+ "config-oracle-temp-ts": "තාවකාලික වගු අවකාශය:",
+ "config-header-mysql": "MySQL සැකසුම්",
+ "config-header-postgres": "PostgreSQL සැකසුම්",
+ "config-header-sqlite": "SQLite සැකසුම්",
+ "config-header-oracle": "ඔරකල් සැකසුම්",
+ "config-invalid-db-type": "වලංගු නොවන දත්ත සංචිත වර්ගය",
+ "config-missing-db-name": "\"දත්ත සංචිත නාමය\" සඳහා ඔබ විසින් අගයක් දිය යුතු වේ",
+ "config-missing-db-host": "\"දත්ත සංචිත ධාරකය\" සඳහා ඔබ විසින් අගයක් දිය යුතු වේ",
+ "config-missing-db-server-oracle": "\"දත්ත සංචිත TNS\" සඳහා ඔබ විසින් අගයක් දිය යුතු වේ",
+ "config-sqlite-name-help": "ඔබගේ විකිය හදුන්වාදෙන නමක් තෝරාගන්න. \nහිස්තැන් හෝ විරාම ලක්ෂණ ‍නොයොදන්න.\nමෙය SQLite දත්ත ගොනුනාමය සදහා යොදා ගනු ඇත.",
+ "config-regenerate": "නැවත ජනිත කරන්න LocalSettings.php →",
+ "config-db-web-account": "ජාල ප්‍රවේශනය සඳහා දත්ත සංචිත ගිණුම",
+ "config-mysql-engine": "ආචයන එන්ජිම:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "ද්විමය",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-windowsauth": "windows සහතික කිරීම.",
+ "config-site-name": "විකියෙහි නම:",
+ "config-site-name-blank": "අඩවි නාමයක් යොදන්න.",
+ "config-project-namespace": "ව්‍යාපෘතියේ නාමඅවකාශය:",
+ "config-ns-generic": "ව්‍යාපෘතිය",
+ "config-ns-site-name": "විකියෙහි නම ලෙසම: $1",
+ "config-ns-other": "වෙනත් (විශේෂණය කරන්න)",
+ "config-ns-other-default": "මගේවිකිය",
+ "config-admin-box": "පරිපාලක ගිණුම",
+ "config-admin-name": "ඔබේ පරිශීලක නාමය:",
+ "config-admin-password": "මුරපදය:",
+ "config-admin-password-confirm": "මුරපදය නැවතත්:",
+ "config-admin-name-blank": "පරිපාලක පරිශීලක නාමය යොදන්න.",
+ "config-admin-password-blank": "පරිපාලක ගිණුම සඳහා මුරපදය යොදන්න.",
+ "config-admin-password-mismatch": "ඔබ ඇතුළු කල මුරපද දෙක නොගැලපේ.",
+ "config-admin-email": "විද්‍යුත්-තැපැල් ලිපිනය:",
+ "config-admin-error-bademail": "ඔබ විසින් වලංගු නොවන විද්‍යුත්-ලිපිනයක් යොදා ඇත.",
+ "config-optional-continue": "මගෙන් තව ප්‍රශ්ණ අහන්න.",
+ "config-optional-skip": "මම දැනටමත් කම්මැලි වී ඇත, විකිය ස්ථාපනය කරන්න.",
+ "config-profile": "පරිශීලක හිමිකම් පැතිකඩ:",
+ "config-profile-wiki": "සාම්ප්‍රදායික විකිය",
+ "config-profile-no-anon": "ගිණුම් තැනීම අවශ්‍යයි",
+ "config-profile-fishbowl": "අවසරලත් සංස්කාරකවරුන් පමණි",
+ "config-profile-private": "පුද්ගලික විකිය",
+ "config-license": "කතුහිමිකම සහ බලපත්‍රය:",
+ "config-license-none": "බලපත්‍ර පාද තලයක් නොමැත",
+ "config-license-cc-by-sa": "නිර්මාණාත්මක පොදුජන ආරෝපණය හුවමාරුවට සමානව",
+ "config-license-cc-by": "නිර්මාණාත්මක පොදුජන ආරෝපණය",
+ "config-license-cc-by-nc-sa": "නිර්මාණාත්මක පොදුජන ආරෝපණය වාණිජ්‍ය-නොවන හුවමාරුවට සමානව",
+ "config-license-pd": "පොදු වසම",
+ "config-email-settings": "විද්‍යුත්-තැපැල් සැකසුම්",
+ "config-enable-email": "පිටතට යොමු වූ විද්‍යුත්-තැපෑල සක්‍රිය කරන්න",
+ "config-email-user": "පරිශීලක-වෙත-පරිශීලක විද්‍යුත්-තැපෑල සක්‍රිය කරන්න",
+ "config-email-usertalk": "පරිශීලක කතාබහ පිටු නිවේදනය සක්‍රිය කරන්න",
+ "config-email-watchlist": "මුරලැයිස්තු නිවේදනය සක්‍රිය කරන්න",
+ "config-email-auth": "විද්‍යුත්-තැපැල් සහතික කිරීම සක්‍රිය කරන්න",
+ "config-email-sender": "ප්‍රත්‍යාගමන විද්‍යුත්-තැපැල් ලිපිනය:",
+ "config-upload-settings": "පින්තූර සහ ගොනු උඩුගත කිරීම්",
+ "config-upload-enable": "ගොනු උඩුගත කිරීම් සක්‍රිය කරන්න",
+ "config-upload-deleted": "මැකූ ගොනු සඳහා නාමාවලිය:",
+ "config-logo": "ලාංඡනයේ URL:",
+ "config-instantcommons": "ක්ෂණික කොමන්ස් සක්‍රිය කරන්න",
+ "config-cc-again": "නැවත ඇහිඳගන්න...",
+ "config-advanced-settings": "උසස් වින්‍යාසගතකෙරුම",
+ "config-cache-options": "වස්තු කෑෂය සඳහා සැකසුම්:",
+ "config-memcached-servers": "මතකකෑෂිත සර්වරයන්:",
+ "config-extensions": "විස්තීර්ණ",
+ "config-install-step-done": "සිදුකලා",
+ "config-install-step-failed": "අසාර්ථකයි",
+ "config-install-extensions": "විස්තීර්ණ අඩංගු කරමින්",
+ "config-install-database": "දත්ත සංචිතය සකසමින්",
+ "config-install-schema": "සංක්ෂිප්ත නිරූපණය තනමින්",
+ "config-install-pg-schema-not-exist": "PostgreSQL සංක්ෂිප්ත නිරූපණය නොපවතියි.",
+ "config-install-pg-commit": "වෙනස්කම් ප්‍රයාපනය කරමින්",
+ "config-install-pg-plpgsql": "PL/pgSQL භාෂාව සඳහා පරික්ෂා කරමින්",
+ "config-install-user": "දත්ත සංචිත පරිශීලක තනමින්",
+ "config-install-user-alreadyexists": "\"$1\" පරිශීලක දැනටමත් පවතී",
+ "config-install-user-create-failed": "\"$1\" පරිශීලක තැනීම අසාර්ථකයි: $2",
+ "config-install-user-missing": "විශේෂණය කෙරූ \"$1\" පරිශීලකයා නොපවතියි.",
+ "config-install-tables": "වගු තනමින්",
+ "config-install-interwiki": "සාමාන්‍ය අන්තර්විකි වගුව ගහනය කරමින්",
+ "config-install-interwiki-list": "<code>interwiki.list</code> ගොනුව කියවිය නොහැක.",
+ "config-install-stats": "සංඛ්‍යානය අරඹමින්",
+ "config-install-keys": "රහස් යතුරු ජනිත කරමින්",
+ "config-install-sysop": "පරිපාලක පරිශීලක ගිණුම තනමින්",
+ "config-install-mainpage": "සාමාන්‍ය අන්තර්ගතය සමඟින් ප්‍රධාන පිටුව තනමින්",
+ "config-install-mainpage-failed": "ප්‍රධාන පිටුව ඇතුල් කල නොහැක: $1",
+ "config-download-localsettings": "<code>LocalSettings.php</code> බාගන්න",
+ "config-help": "උදව්",
+ "config-nofile": "\"$1\" ගොනුව සොයාගත නොහැක. එක මැකිලා ගියාවත්ද?",
+ "mainpagetext": "'''මීඩියාවිකි සාර්ථක ලෙස ස්ථාපනය කරන ලදි.'''",
+ "mainpagedocfooter": "විකි මෘදුකාංග භාවිතා කිරීම පිළිබඳ තොරතුරු සඳහා [https://meta.wikimedia.org/wiki/Help:Contents පරිශීලකයන් සඳහා නියමුව] හදාරන්න.\n\n== ඇරඹුම ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings වින්‍යාස සැකසුම්]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ මීඩියාවිකි නිති-විමසන-පැන]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce මීඩියාවිකි නිකුතුව තැපැල් ලැයිස්තුව]"
+}
diff --git a/www/wiki/includes/installer/i18n/sk.json b/www/wiki/includes/installer/i18n/sk.json
new file mode 100644
index 00000000..1dcd2c1e
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sk.json
@@ -0,0 +1,84 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kusavica",
+ "KuboF",
+ "Sudo77(new)",
+ "Hromoslav"
+ ]
+ },
+ "config-desc": "Inštalátor pre MediaWiki",
+ "config-title": "Inštalácia MediaWiki $1",
+ "config-information": "Informácie",
+ "config-localsettings-key": "Aktualizačný kľúč:",
+ "config-localsettings-badkey": "Zadaný aktualizačný kľúč je nesprávny.",
+ "config-your-language": "Váš jazyk:",
+ "config-your-language-help": "Vyberte jazyk, ktorý chcete použiť počas inštalácie.",
+ "config-wiki-language": "Wiki jazyk:",
+ "config-wiki-language-help": "Vyberte jazyk, v ktorom bude wiki napísaná.",
+ "config-back": "← Späť",
+ "config-continue": "Pokračovať →",
+ "config-page-language": "Jazyk",
+ "config-page-welcome": "Vitajte na MediaWiki!",
+ "config-page-dbconnect": "Pripojiť sa k databáze",
+ "config-page-upgrade": "Aktualizovať existujúcu inštaláciu",
+ "config-page-dbsettings": "Nastavenie databázy",
+ "config-page-name": "Názov",
+ "config-page-options": "Možnosti",
+ "config-page-install": "Inštalovať",
+ "config-page-complete": "Dokončené",
+ "config-page-restart": "Reštartovať inštaláciu",
+ "config-page-readme": "Čítaj ma",
+ "config-page-releasenotes": "Poznámky k vydaniu",
+ "config-page-copying": "Licencia",
+ "config-page-upgradedoc": "Aktualizácia",
+ "config-page-existingwiki": "Existujúca wiki",
+ "config-help-restart": "Chcete vymazať všetky uložené dáta, ktoré ste zadali a reštartovať proces inštalácie?",
+ "config-restart": "Áno, reštartovať",
+ "config-env-good": "Prostredie bolo skontrolované.\nMôžete nainštalovať MediaWiki.",
+ "config-env-bad": "Prostredie bolo skontrolované.\nNemôžete nainštalovať MediaWiki.",
+ "config-env-php": "PHP $1 je nainštalované.",
+ "config-env-hhvm": "HHVM $1 je nainštalované.",
+ "config-db-type": "Typ databázy:",
+ "config-db-host": "Databázový server:",
+ "config-db-host-oracle": "Databázové TNS:",
+ "config-db-wiki-settings": "Identifikácia tejto wiki",
+ "config-db-name": "Názov databázy:",
+ "config-db-name-oracle": "Databázová schéma:",
+ "config-db-install-account": "Používateľský účet pre inštaláciu",
+ "config-db-username": "Databázové používateľské meno:",
+ "config-db-password": "Databázové heslo:",
+ "config-missing-db-name": "Musíte zadať hodnotu pre \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Musíte zadať hodnotu pre \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Musíte zadať hodnotu pre \"{{int:config-db-host-oracle}}\".",
+ "config-site-name": "Názov wiki:",
+ "config-site-name-blank": "Zadajte názov stránky.",
+ "config-ns-generic": "Projekt",
+ "config-admin-box": "Účet správcu",
+ "config-admin-name": "Vaše používateľské meno:",
+ "config-admin-password": "Heslo:",
+ "config-admin-password-confirm": "Zopakujte heslo:",
+ "config-admin-name-blank": "Zadajte používateľské meno správcu.",
+ "config-admin-name-invalid": "Zadané používateľské meno \"<nowiki>$1</nowiki>\" je neplatné. \nZadajte iné meno.",
+ "config-admin-password-blank": "Zadajte heslo ku správcovskému účtu.",
+ "config-admin-password-mismatch": "Zadané heslá sa nezhodujú.",
+ "config-admin-email": "Emailová adresa:",
+ "config-admin-error-bademail": "Zadali ste neplatnú emailovú adresu.",
+ "config-optional-continue": "Opýtaj sa ma ďalšie otázky.",
+ "config-optional-skip": "Už ma to nudí, proste nainštaluj wiki.",
+ "config-profile-wiki": "Otvorená wiki",
+ "config-profile-private": "Súkromná wiki",
+ "config-license-pd": "Voľné dielo",
+ "config-email-settings": "Nastavenia e-mailu",
+ "config-install-step-done": "hotovo",
+ "config-install-step-failed": "zlyhalo",
+ "config-install-extensions": "Inštalujú sa rozšírenia",
+ "config-install-user-alreadyexists": "Používateľ \"$1\" už existuje",
+ "config-install-tables-failed": "<strong>Chyba:</strong> Vytvorenie tabuľky zlyhalo s nasledujúcou chybou: $1",
+ "config-download-localsettings": "Stiahnuť <code>LocalSettings.php</code>",
+ "config-help": "nápoveda",
+ "config-nofile": "Súbor \"$1\" sa nenašiel. Bol zmazaný?",
+ "config-extension-link": "Vedeli ste, že vaša wiki podporuje [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions rozšírenia]?\nMôžete hľadať rozšírenia [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category podľa kategórie] alebo si pozrite [https://www.mediawiki.org/wiki/Extension_Matrix Extension Matrix] - kompletný zoznam rozšírení.",
+ "mainpagetext": "'''Softvér MediaWiki bol úspešne nainštalovaný.'''",
+ "mainpagedocfooter": "Informácie ako používať wiki softvér nájdete v [https://meta.wikimedia.org/wiki/Help:Contents Používateľskej príručke].\n\n== Začíname ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Zoznam konfiguračných nastavení]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Časté otázky o MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce E-poštová konferencia oznámení o MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Preklad MediaWiki do vášho jazyka]"
+}
diff --git a/www/wiki/includes/installer/i18n/sl.json b/www/wiki/includes/installer/i18n/sl.json
new file mode 100644
index 00000000..86d88a3d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sl.json
@@ -0,0 +1,183 @@
+{
+ "@metadata": {
+ "authors": [
+ "Dbc334",
+ "Eleassar",
+ "Yerpo"
+ ]
+ },
+ "config-desc": "Namestitveni program za MediaWiki",
+ "config-title": "Namestitev MediaWiki $1",
+ "config-information": "Informacije",
+ "config-localsettings-upgrade": "Zaznana je bila datoteka <code>LocalSettings.php</code>.\nZa nadgradnjo te inštalacije prosim vnesite vrednost <code>$wgUpgradeKey</code> v polje za vnos spodaj.\nNašli jo boste v <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Zaznana je bila datoteka <code>LocalSettings.php</code>.\nZa nadgradnjo te namestitve zaženite <code>update.php</code>",
+ "config-localsettings-key": "Nadgraditveni ključ:",
+ "config-localsettings-badkey": "Navedeni ključ za nadgradnjo je napačen.",
+ "config-upgrade-key-missing": "Zaznana je bila obstoječa namestitev MediaWiki.\nZa nadgradnjo te namestitve vstavite naslednjo vrstico na dno vaše <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Kaže, da je obstoječa datoteka <code>LocalSettings.php</code> nepopolna. Vrednost $1 ni nastavljena. Prosimo, nastavite to vrednost v <code>LocalSettings.php</code> in kliknite \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Prišlo je do napake pri povezovanju s podatkovno zbirko z nastavitvami, določenimi v <code>LocalSettings.php</code>. Prosimo popravite te nastavitve in poskusite znova.\n\n$1",
+ "config-session-error": "Napaka pri začenjanju seje: $1",
+ "config-session-expired": "Kot kaže, so vaši podatki seje potekli.\nSeje so konfigurirane za dobo $1.\nTo lahko povečate tako, da nastavite <code>session.gc_maxlifetime</code> v php.ini.\nPonovno zaženite postopek namestitve.",
+ "config-no-session": "Vaši podatki seje so bili izgubljeni!\nPreverite vaš php.ini in se prepričajte, da je <code>session.save_path</code> nastavljena na ustrezno mapo.",
+ "config-your-language": "Vaš jezik:",
+ "config-your-language-help": "Izberite jezik, ki bo uporabljen med postopkom namestitve.",
+ "config-wiki-language": "Jezik wikija:",
+ "config-wiki-language-help": "Izberite jezik, v katerem bo wiki večinoma pisan.",
+ "config-back": "← Nazaj",
+ "config-continue": "Nadaljuj →",
+ "config-page-language": "Jezik",
+ "config-page-welcome": "Dobrodošli na MediaWiki!",
+ "config-page-dbconnect": "Vzpostavi povezavo z zbirko podatkov",
+ "config-page-upgrade": "Nadgradi obstoječo namestitev",
+ "config-page-dbsettings": "Nastavitve zbirke podatkov",
+ "config-page-name": "Ime",
+ "config-page-options": "Možnosti",
+ "config-page-install": "Namesti",
+ "config-page-complete": "Končano!",
+ "config-page-restart": "Ponovno zaženi namestitev",
+ "config-page-readme": "Beri me",
+ "config-page-releasenotes": "Opombe ob izidu",
+ "config-page-copying": "Kopiranje",
+ "config-page-upgradedoc": "Nadgrajevanje",
+ "config-page-existingwiki": "Obstoječ wiki",
+ "config-help-restart": "Želite počistiti vse shranjene podatke, ki ste jih vnesti, in ponovno začeti s postopkom namestitve?",
+ "config-restart": "Da, ponovno zaženi",
+ "config-welcome": "=== Pregledi okolja ===\nIzvedli bomo osnovne preglede, da vidimo, če je okolje primerno za namestitev MediaWiki.\nPosredujte rezultate teh pregledov, če med namestitvijo potrebujete pomoč.",
+ "config-sidebar": "* [https://www.mediawiki.org Domača stran MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Vodnik za uporabnike]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Vodnik za administratorje]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Pogosto zastavljena vprašanja]\n----\n* <doclink href=Readme>Beri me</doclink>\n* <doclink href=ReleaseNotes>Opombe ob izidu</doclink>\n* <doclink href=Copying>Kopiranje</doclink>\n* <doclink href=UpgradeDoc>Nadgrajevanje</doclink>",
+ "config-env-good": "Okolje je pregledano.\nLahko namestite MediaWiki.",
+ "config-env-bad": "Okolje je pregledano.\nNe morete namestiti MediaWiki.",
+ "config-env-php": "Nameščen je PHP $1.",
+ "config-env-hhvm": "HHVM $1 je nameščen.",
+ "config-unicode-using-intl": "Uporaba [http://pecl.php.net/intl razširitve PECL intl] za normalizacijo unikoda.",
+ "config-memory-raised": "PHP-jev <code>memory_limit</code> je $1, dvignjen na $2.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] je nameščen",
+ "config-apc": "[http://www.php.net/apc APC] je nameščen",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] je nameščen",
+ "config-diff3-bad": "GNU diff3 ni bilo mogoče najti.",
+ "config-using-server": "Uporabljam ime strežnika \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Uporabljam URL strežnika \"<nowiki>$1$2</nowiki>\".",
+ "config-db-type": "Vrsta zbirke podatkov:",
+ "config-db-host": "Gostitelj zbirke podatkov:",
+ "config-db-host-oracle": "TNS zbirke podatkov:",
+ "config-db-wiki-settings": "Prepoznaj ta wiki:",
+ "config-db-name": "Ime zbirke podatkov:",
+ "config-db-name-oracle": "Shema zbirke podatkov:",
+ "config-db-install-account": "Uporabniški račun za namestitev",
+ "config-db-username": "Uporabniško ime zbirke podatkov:",
+ "config-db-password": "Geslo zbirke podatkov:",
+ "config-db-install-username": "Vnesite uporabniško ime za povezavo s podatkovno zbirko med postopkom nameščanja.\nTo ni uporabniško ime računa MediaWiki, pač pa uporabniško ime za vašo podatkovno zbirko.",
+ "config-db-install-password": "Vnesite geslo za povezavo s podatkovno zbirko med postopkom nameščanja.\nTo ni geslo računa MediaWiki, pač pa geslo za vašo podatkovno zbirko.",
+ "config-db-install-help": "Vnesite uporabniško ime in geslo za povezavo s podatkovno zbirko med postopkom nameščanja.",
+ "config-db-account-lock": "Uporabite isto uporabniško ime in geslo tudi po namestitvi.",
+ "config-db-prefix": "Predpona tabel zbirke podatkov:",
+ "config-mysql-old": "Potreben je MySQL $1 ali novejši; vi imate $2.",
+ "config-db-port": "Vrata zbirke podatkov:",
+ "config-db-schema": "Shema MediaWiki",
+ "config-db-schema-help": "Ta shema je po navadi v redu.\nSpremenite jo samo, če veste, da jo morate.",
+ "config-pg-test-error": "Ne morem se povezati z zbirko podatkov <strong>$1</strong>: $2",
+ "config-sqlite-dir": "Mapa podatkov SQLite:",
+ "config-type-mysql": "MySQL (ali združljiv)",
+ "config-support-info": "MediaWiki podpira naslednje sisteme zbirk podatkov:\n\n$1\n\nČe zgoraj ne vidite navedenega sistema zbirk podatkov, ki ga poskušate uporabiti, sledite navodilom na spodnji povezavi, da omogočite podporo.",
+ "config-header-mysql": "Nastavitve MySQL",
+ "config-header-postgres": "Nastavitve PostgreSQL",
+ "config-header-sqlite": "Nastavitve SQLite",
+ "config-header-oracle": "Nastavitve Oracle",
+ "config-header-mssql": "nastavitve Microsoft SQL Server",
+ "config-invalid-db-type": "Neveljavna vrsta zbirke podatkov",
+ "config-missing-db-name": "Vnesti morate vrednost za »{{int:config-db-name}}«",
+ "config-missing-db-host": "Vnesti morate vrednost za »{{int:config-db-host}}«.",
+ "config-missing-db-server-oracle": "Vnesti morate vrednost za »{{int:config-db-host-oracle}}«.",
+ "config-invalid-db-server-oracle": "Neveljaven TNS zbirke podatkov »$1«.\nUporabite ali \"ime TNS\" ali niz \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Načini poimenovanja Oracle])",
+ "config-invalid-db-name": "Neveljavno ime zbirke podatkov »$1«.\nUporabljajte samo črke ASCII (a-z, A-Z), številke (0-9), podčrtaje (_) in vezaje (-).",
+ "config-invalid-db-prefix": "Neveljavna predpona zbirke podatkov »$1«.\nUporabljajte samo črke ASCII (a-z, A-Z), številke (0-9), podčrtaje (_) in vezaje (-).",
+ "config-connection-error": "$1.\n\nPreverite gostitelja, uporabniško ime in geslo spodaj ter poskusite znova.",
+ "config-postgres-old": "Potreben je PostgreSQL $1 ali novejši; vi imate $2.",
+ "config-sqlite-connection-error": "$1.\n\nPreverite mapo podatkov in ime zbirke podatkov spodaj ter poskusite znova.",
+ "config-sqlite-readonly": "Datoteka <code>$1</code> ni zapisljiva.",
+ "config-sqlite-cant-create-db": "Ne morem ustvariti datoteke zbirke podatkov <code>$1</code>.",
+ "config-upgrade-done-no-regenerate": "Nadgradnja je končana.\n\nSedaj lahko [$1 začnete uporabljati vaš wiki].",
+ "config-regenerate": "Ponovno ustvari LocalSettings.php →",
+ "config-show-table-status": "Poizvedba <code>SHOW TABLE STATUS</code> ni uspela!",
+ "config-unknown-collation": "'''Opozorilo:''' Zbirke podatkov uporablja neprepoznano razvrščanje znakov.",
+ "config-db-web-account": "Račun zbirke podatkov za spletni dostop",
+ "config-db-web-account-same": "Uporabi enak račun kot za namestitev",
+ "config-db-web-create": "Ustvari račun, če že ne obstaja",
+ "config-mysql-engine": "Pogon skladiščenja:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Nabor znakov zbirke podatkov:",
+ "config-mysql-binary": "Dvojiško",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Tip avtentikacije:",
+ "config-site-name": "Ime wikija:",
+ "config-site-name-help": "To bo prikazano v naslovni vrstici brskalnika in na drugih različnih mestih.",
+ "config-site-name-blank": "Vnesite ime strani.",
+ "config-project-namespace": "Imenski prostor projekta:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Enako kot ime wikija: $1",
+ "config-ns-other": "Drugo (navedite)",
+ "config-ns-other-default": "MojWiki",
+ "config-ns-invalid": "Naveden imenski prostor »<nowiki>$1</nowiki>« ni veljaven.\nDoločite drug imenski prostor projekta.",
+ "config-ns-conflict": "Naveden imenski prostor »<nowiki>$1</nowiki>« je v sporu s privzetim imenskim prostorom MediaWiki.\nDoločite drug imenski prostor projekta.",
+ "config-admin-box": "Administratorski račun",
+ "config-admin-name": "Vaše uporabniško ime:",
+ "config-admin-password": "Geslo:",
+ "config-admin-password-confirm": "Geslo, ponovno:",
+ "config-admin-help": "Tukaj vnesite želeno uporabniško ime, na primer »Janez Blog«.\nTo je ime, ki ga boste uporabljali za prijavo v wiki.",
+ "config-admin-name-blank": "Vnesite uporabniško ime administratorja.",
+ "config-admin-name-invalid": "Navedeno uporabniško ime »<nowiki>$1</nowiki>« ni veljavno.\nDoločite drugo uporabniško ime.",
+ "config-admin-password-blank": "Vnesite geslo za administratorski račun.",
+ "config-admin-password-mismatch": "Vneseni gesli se ne ujemata.",
+ "config-admin-email": "E-poštni naslov:",
+ "config-admin-error-user": "Med ustvarjanjem administratorja »<nowiki>$1</nowiki>« je prišlo do notranje napake.",
+ "config-admin-error-password": "Med nastavljanjem gesla za administratorja »<nowiki>$1</nowiki>« je prišlo do notranje napake: <pre>$2</pre>",
+ "config-admin-error-bademail": "Vnesli ste neveljaven e-poštni naslov.",
+ "config-subscribe": "Naročite se na [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce poštni seznam obvestil o izdajah].",
+ "config-almost-done": "Skoraj ste že končali!\nPreostalo konfiguriranje lahko zdaj preskočite in wiki takoj namestite.",
+ "config-optional-continue": "Zastavi mi več vprašanj.",
+ "config-optional-skip": "Se že dolgočasim; samo namesti wiki.",
+ "config-profile": "Profil uporabniških pravic:",
+ "config-profile-wiki": "Odprti wiki",
+ "config-profile-no-anon": "Zahtevano je ustvarjanje računa",
+ "config-profile-fishbowl": "Samo pooblaščeni urejevalci",
+ "config-profile-private": "Zasebni wiki",
+ "config-profile-help": "Wikiji delujejo najbolje, kadar jih lahko ureja največje možno število ljudi.\nPregled nad zadnjimi spremembami in razveljavljanje škode, ki jo povzročijo neuki ali zlonamerni uporabniki, je v MediaWiki preprosto.\n\nVendar pa je MediaWiki uporaben v celi vrsti različnih vlog, pri čemer včasih ni lahko prepričati vseh o prednostih wiki načina. Zato imate izbiro.\n\nModel <strong>{{int:config-profile-wiki}}</strong> dovoljuje urejanje vsem, tudi brez prijavljanja.\nWiki, nastavljen na <strong>{{int:config-profile-no-anon}}</strong> nudi dodatno sledljivost, vendar lahko odvrne priložnostne urejevalce.\n\nScenarij <strong>{{int:config-profile-fishbowl}}</strong> dovoljuje urejanje odobrenim uporabnikom, pri čemer sta vsebina in zgodovina strani javni.\nV načinu <strong>{{int:config-profile-private}}</strong> lahko urejajo in pregledujejo strani le odobreni uporabniki.\n\nPodrobnejše konfiguriranje uporabniških pravic je možno po namestitvi, glejte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights vnos v uporabniškem priročniku].",
+ "config-license": "Avtorske pravice in licenca:",
+ "config-license-none": "Brez noge dovoljenja",
+ "config-license-cc-by-sa": "Creative Commons Priznanje avtorstva-Deljenje pod enakimi pogoji",
+ "config-license-cc-by": "Creative Commons Priznanje avtorstva",
+ "config-license-cc-by-nc-sa": "Creative Commons Priznanje avtorstva-Nekomercialno-Deljenje pod enakimi pogoji",
+ "config-license-cc-0": "Creative Commons Zero (javna last)",
+ "config-license-gfdl": "Dovoljenje GNU za rabo proste dokumentacije 1.3 ali kasnejše",
+ "config-license-pd": "Javna last",
+ "config-license-cc-choose": "Izberite dovoljenje Creative Commons po meri",
+ "config-email-settings": "Nastavitve e-pošte",
+ "config-enable-email": "Omogoči odhodno e-pošto",
+ "config-email-user": "Omogoči e-pošto med uporabniki",
+ "config-email-auth": "Omogoči overitev preko e-pošte",
+ "config-email-sender": "E-poštni naslov za vrnjeno pošto:",
+ "config-upload-settings": "Nalaganje slike in datotek",
+ "config-upload-enable": "Omogoči nalaganje datotek",
+ "config-upload-deleted": "Mapa za izbrisane datoteke:",
+ "config-upload-deleted-help": "Izberite mapo za arhiviranje izbrisanih datotek.\nNajbolje je, da mapa ni dostopna preko spleta.",
+ "config-logo": "URL logotipa:",
+ "config-cc-error": "Izbirnik dovoljenja Creative Commons ni vrnil nobenih rezultatov.\nVnesite ime dovoljenja ročno.",
+ "config-cc-again": "Izberi ponovno ...",
+ "config-cc-not-chosen": "Izberite licenco Creative Commons, ki jo želite uporabiti, in kliknite »proceed«.",
+ "config-advanced-settings": "Napredna konfiguracija",
+ "config-cache-accel": "Predpomnjenje predmetov PHP (APC, APCu, XCache ali WinCache)",
+ "config-cache-memcached": "Uporabi Memcached (zahteva dodatno namestitev in konfiguracijo)",
+ "config-memcached-servers": "Strežniki Memcached:",
+ "config-memcache-badip": "Vnesli ste neveljaven IP-naslov za Memcached: $1",
+ "config-extensions": "Razširitve",
+ "config-install-step-done": "končano",
+ "config-install-step-failed": "spodletelo",
+ "config-install-database": "Vzpostavljanje zbirke podatkov",
+ "config-install-pg-schema-not-exist": "Shema PostgreSQL ne obstaja.",
+ "config-install-user-alreadyexists": "Uporabnik »$1« že obstaja",
+ "config-install-tables": "Ustvarjanje tabel",
+ "config-download-localsettings": "Prenesi <code>LocalSettings.php</code>",
+ "config-help": "pomoč",
+ "mainpagetext": "<strong>Programje MediaWiki je bilo nameščeno.</strong>",
+ "mainpagedocfooter": "Oglejte si [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Uporabniški priročnik] za informacije o uporabi programja wiki.\n\n== Kako začeti ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Seznam konfiguracijskih nastavitev]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Poogsto zastavljena vprašanja MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Poštni seznam izdaj MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Prevedite MediaWiki v svoj jezik]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Izvedite, kako se boriti proti smetju na svojem wikiju]"
+}
diff --git a/www/wiki/includes/installer/i18n/sli.json b/www/wiki/includes/installer/i18n/sli.json
new file mode 100644
index 00000000..ddf0e7a7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sli.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Äberlausitzer"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki wourde erfolgreich installiert.'''",
+ "mainpagedocfooter": "Hilfe zur Benutzung und Konfiguration der Wiki-Software fendest du eim [https://meta.wikimedia.org/wiki/Help:Contents Benutzerhandbichl].\n\n== Stoarthilfa ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste der Konfigurationsvariablen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailingliste neuer MediaWiki-Versionen]"
+}
diff --git a/www/wiki/includes/installer/i18n/so.json b/www/wiki/includes/installer/i18n/so.json
new file mode 100644
index 00000000..aebc6974
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/so.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Maax"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki Si fiican oo kuugu install gareeyay.'''",
+ "mainpagedocfooter": "Meeshaan ka akhriso sidii aad u isticmaali leheed brogramka wiki [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] .\n== Bilaaw ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/sq.json b/www/wiki/includes/installer/i18n/sq.json
new file mode 100644
index 00000000..27636e44
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sq.json
@@ -0,0 +1,45 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ammartivari",
+ "Kosovastar"
+ ]
+ },
+ "config-desc": "Instaluesi për MediaWiki",
+ "config-title": "Instalimi MediaWiki $1",
+ "config-information": "Informacion",
+ "config-your-language": "Gjuha e juaj:",
+ "config-your-language-help": "Zgjidhni një gjuhë për ta përdorur gjatë procesit të instalimit.",
+ "config-wiki-language": "Gjuha e wikit:",
+ "config-wiki-language-help": "Zgjidhni gjuhën e cila do të mbizotërojë në shkrimin e wiki-t.",
+ "config-back": "← Prapa",
+ "config-continue": "Para →",
+ "config-page-language": "Gjuha",
+ "config-page-welcome": "Mirësevini në MediaWiki!",
+ "config-page-dbconnect": "Lidhu me bazën e të dhënave",
+ "config-page-dbsettings": "Parametrat e bazës së të dhënave",
+ "config-page-name": "Emri",
+ "config-page-options": "Opcionet",
+ "config-page-install": "Instalo",
+ "config-page-complete": "Përfundoi!",
+ "config-page-restart": "Rinisni instalimin",
+ "config-page-copying": "Duke kopjuar",
+ "config-restart": "Po, rinisni",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] u instalua",
+ "config-apc": "[http://www.php.net/apc APC] u instalua",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] u instalua",
+ "config-diff3-bad": "GNU diff3 nuk u gjet.",
+ "config-db-wiki-settings": "Identifikoni këtë wiki",
+ "config-db-name": "Emri i bazës së të dhënave:",
+ "config-admin-box": "Llogari administruesi",
+ "config-admin-name-blank": "Shkruani nofkën e një administruesi.",
+ "config-admin-password-blank": "Shkruani një fjalëkalim për llogarinë e administruesit.",
+ "config-admin-email": "Adresa e emailit:",
+ "config-license-pd": "Domeni publik",
+ "config-logo": "URL e logos:",
+ "config-install-tables": "Duke krijuar tabela",
+ "config-install-stats": "Nisja e statistikave",
+ "config-help": "ndihmë",
+ "mainpagetext": "'''MediaWiki software u instalua me sukses.'''",
+ "mainpagedocfooter": "Për më shumë informata rreth përdorimit të softwerit wiki , ju lutem shikoni [https://meta.wikimedia.org/wiki/Help:Contents dokumentacionin përkatës].\n\n== Sa për fillim==\n* [https://www.mediawiki.org/wiki/Help:Configuration_settings Parazgjedhjet e MediaWiki-t]\n* [https://www.mediawiki.org/wiki/Help:FAQ Pyetjet e shpeshta rreth MediaWiki-t]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Njoftime rreth MediaWiki-t]"
+}
diff --git a/www/wiki/includes/installer/i18n/sr-ec.json b/www/wiki/includes/installer/i18n/sr-ec.json
new file mode 100644
index 00000000..02f63359
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sr-ec.json
@@ -0,0 +1,91 @@
+{
+ "@metadata": {
+ "authors": [
+ "Rancher",
+ "Михајло Анђелковић",
+ "Milicevic01",
+ "Aktron",
+ "Сербијана",
+ "Zoranzoki21"
+ ]
+ },
+ "config-desc": "Инсталација за Медијавики",
+ "config-title": "Инсталација Медијавикија $1",
+ "config-information": "Информације",
+ "config-session-error": "Грешка при започињању сесије: $1",
+ "config-session-expired": "Ваши подаци о сесији су истекли.\nСесије су подешене да трају $1.\nЊихов рок можете повећати постављањем <code>session.gc_maxlifetime</code> у php.ini.\nПоново покрените инсталацију.",
+ "config-no-session": "Ваши подаци о сесији су изгубљени!\nПроверите Ваш php.ini и обезбедите да је <code>session.save_path</code> постављен на одговарајући директоријум.",
+ "config-your-language": "Ваш језик:",
+ "config-your-language-help": "Изаберите језик који желите да користите током инсталације.",
+ "config-wiki-language": "Језик викија:",
+ "config-wiki-language-help": "Изаберите језик на ком ће бити садржај викија.",
+ "config-back": "← Назад",
+ "config-continue": "Настави →",
+ "config-page-language": "Језик",
+ "config-page-welcome": "Добро дошли на МедијаВики!",
+ "config-page-dbconnect": "Повезивање са базом података",
+ "config-page-upgrade": "Надоградња постојеће инсталације",
+ "config-page-dbsettings": "Подешавања базе података",
+ "config-page-name": "Назив",
+ "config-page-options": "Поставке",
+ "config-page-install": "Инсталирај",
+ "config-page-complete": "Завршено!",
+ "config-page-restart": "Поновно покретање инсталације",
+ "config-page-readme": "Прочитај ме",
+ "config-page-releasenotes": "Белешке издања",
+ "config-page-copying": "Умножавање",
+ "config-page-upgradedoc": "Надоградња",
+ "config-page-existingwiki": "Постојећи вики",
+ "config-help-restart": "Желите ли да обришете све сачуване податке које сте унели и поново покренете инсталацију?",
+ "config-restart": "Да, покрени поново",
+ "config-env-php": "PHP $1 је инсталиран.",
+ "config-env-hhvm": "HHVM $1 је инсталиран.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] је инсталиран",
+ "config-apc": "[http://www.php.net/apc APC] је инсталиран",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] је инсталиран",
+ "config-db-type": "Тип базе података:",
+ "config-db-host": "Хост базе података",
+ "config-db-name": "Назив базе података:",
+ "config-db-password": "Лозинка за базу података:",
+ "config-type-mysql": "MySQL (или компактибилан)",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "MySQL подешавања",
+ "config-header-mssql": "Подешавања Microsoft SQL Server-а",
+ "config-invalid-db-type": "Неважећи тип базе података.",
+ "config-mssql-old": "Потребан је Microsoft SQL Server $1 или новији. Ви имате $2.",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Тип провере идентитета:",
+ "config-mssql-sqlauth": "Провера идентитета за SQL Server",
+ "config-mssql-windowsauth": "Провера идентитета Windows-а",
+ "config-site-name": "Име викија:",
+ "config-admin-name": "Корисничко име:",
+ "config-admin-password": "Лозинка:",
+ "config-admin-email": "Имејл адреса:",
+ "config-optional-skip": "Досадно ми је, хајде да инсталирамо вики.",
+ "config-profile-no-anon": "Неопходно је отворити налог",
+ "config-profile-fishbowl": "Само овлашћени корисници",
+ "config-profile-private": "Приватна вики",
+ "config-license": "Ауторска права и лиценца:",
+ "config-license-none": "Без заглавља са лиценцом",
+ "config-license-cc-by-sa": "Creative Commons Ауторство-Делити под истим условима (CC BY-SA)",
+ "config-license-cc-by": "Creative Commons Ауторство (CC BY)",
+ "config-license-cc-by-nc-sa": "Creative Commons Ауторство-Некомерцијално-Делити под истим условима (CC BY-NC-SA)",
+ "config-license-cc-0": "Creative Commons Zero (јавно власништво)",
+ "config-license-gfdl": "ГНУ-ова лиценца за слободну документацију верзија 1.3 или новија верзија",
+ "config-license-pd": "Јавно власништво",
+ "config-email-settings": "Подешавања имејла",
+ "config-cc-not-chosen": "Одаберите која Кријејтив комонс лиценца вам одговара и потврдите.",
+ "config-skins": "Теме",
+ "config-install-step-done": "готово",
+ "config-install-step-failed": "није успело",
+ "config-install-mainpage-exists": "Главна страна већ постоји, прескакање",
+ "config-help": "помоћ",
+ "config-help-tooltip": "кликните да проширите",
+ "mainpagetext": "<strong>Медијавики је успешно инсталиран.</strong>",
+ "mainpagedocfooter": "Погледајте [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents кориснички водич] за коришћење програма.\n\n== Увод ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Помоћ у вези са подешавањима]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Често постављена питања]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Дописна листа о издањима Медијавикија]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Научите како да се борете против спама на Вашој вики]"
+}
diff --git a/www/wiki/includes/installer/i18n/sr-el.json b/www/wiki/includes/installer/i18n/sr-el.json
new file mode 100644
index 00000000..e0c5408c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sr-el.json
@@ -0,0 +1,44 @@
+{
+ "@metadata": {
+ "authors": [
+ "Milicevic01",
+ "Сербијана"
+ ]
+ },
+ "config-session-error": "Greška pri započinjanju sesije: $1",
+ "config-session-expired": "Vaši podaci o sesiji su istekli.\nSesije su podešene da traju $1.\nNjihov rok možete povećati postavljanjem <code>session.gc_maxlifetime</code> u php.ini.\nPonovo pokrenite instalaciju.",
+ "config-no-session": "Vaši podaci o sesiji su izgubljeni!\nProverite Vaš php.ini i obezbedite da je <code>session.save_path</code> postavljen na odgovarajući direktorijum.",
+ "config-your-language": "Vaš jezik:",
+ "config-your-language-help": "Izaberite jezik koji želite da koristite tokom instalacije.",
+ "config-wiki-language": "Jezik vikija:",
+ "config-wiki-language-help": "Izaberite jezik na kom će biti sadržaj vikija.",
+ "config-back": "← Nazad",
+ "config-continue": "Nastavi →",
+ "config-page-language": "Jezik",
+ "config-page-welcome": "Dobro došli na MedijaViki!",
+ "config-page-dbconnect": "Povezivanje sa bazom podataka",
+ "config-page-upgrade": "Nadogradnja postojeće instalacije",
+ "config-page-dbsettings": "Podešavanja baze podataka",
+ "config-page-name": "Naziv",
+ "config-page-options": "Postavke",
+ "config-page-install": "Instaliraj",
+ "config-page-complete": "Završeno!",
+ "config-page-restart": "Ponovno pokretanje instalacije",
+ "config-page-copying": "Umnožavanje",
+ "config-page-upgradedoc": "Nadogradnja",
+ "config-page-existingwiki": "Postojeći viki",
+ "config-help-restart": "Želite li da obrišete sve sačuvane podatke koje ste uneli i ponovo pokrenete instalaciju?",
+ "config-restart": "Da, pokreni ponovo",
+ "config-type-mysql": "MySQL",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-header-mssql": "Podešavanja Microsoft SQL Server-a",
+ "config-site-name": "Ime vikija:",
+ "config-admin-email": "Imejl adresa:",
+ "config-license-cc-0": "Creative Commons Zero (javno vlasništvo)",
+ "config-email-settings": "Podešavanja imejla",
+ "config-skins": "Teme",
+ "mainpagetext": "<strong>Medijaviki je uspešno instaliran.</strong>",
+ "mainpagedocfooter": "Molimo vidite [https://meta.wikimedia.org/wiki/Help:Contents korisnički vodič] za informacije o upotrebi viki softvera.\n\n== Za početak ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Pomoć u vezi sa podešavanjima]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Najčešće postavljena pitanja]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mejling lista o izdanjima MedijaVikija]"
+}
diff --git a/www/wiki/includes/installer/i18n/srn.json b/www/wiki/includes/installer/i18n/srn.json
new file mode 100644
index 00000000..643a9251
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/srn.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki seti kon bun.'''",
+ "mainpagedocfooter": "Luku na ini a [https://meta.wikimedia.org/wiki/Help:Contents yepibuku] fu si fa fu kebrouki a wikisoftware.\n\n== Moro yepi ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Den seti]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Sani di ben aksi furu (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Boskopu grupu gi nyun meki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/ss.json b/www/wiki/includes/installer/i18n/ss.json
new file mode 100644
index 00000000..b1d87cd7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ss.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''i-MediaWiki seyifakeke ngalokuphelele.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/stq.json b/www/wiki/includes/installer/i18n/stq.json
new file mode 100644
index 00000000..d75b9ea6
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/stq.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Maartenvdbent"
+ ]
+ },
+ "mainpagetext": "'''Ju MediaWiki Software wuude mäd Ärfoulch installierd.'''",
+ "mainpagedocfooter": "Sjuch ju [https://meta.wikimedia.org/wiki/MediaWiki_localization Dokumentation tou de Anpaasenge fon dän Benutseruurfläche] un dät [https://meta.wikimedia.org/wiki/Help:Contents Benutserhondbouk] foar Hälpe tou ju Benutsenge un Konfiguration."
+}
diff --git a/www/wiki/includes/installer/i18n/su.json b/www/wiki/includes/installer/i18n/su.json
new file mode 100644
index 00000000..46b920cf
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/su.json
@@ -0,0 +1,16 @@
+{
+ "@metadata": {
+ "authors": [
+ "Kandar"
+ ]
+ },
+ "config-desc": "Panginstal MediaWiki",
+ "config-title": "Instalasi MediaWiki $1",
+ "config-information": "Émbaran",
+ "config-localsettings-upgrade": "Hiji berkas <code>LocalSettings.php</code> kapanggih.\nPikeun apgréd ngamutahirkeun ieu instalasi, mangga asupkeun sandi <code>$wgUpgradeKey</code> kana kotak di handap.\nAnjeun bisa manggihan sandina di <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Hiji berkas <code>LocalSettings.php</code> geus kabaca.\nPikeun apgréd ieu instalasi, mangga jalankeun <code>update.php</code>",
+ "config-localsettings-key": "Sandi apgréd:",
+ "config-localsettings-badkey": "Sandi anu diasupkeun salah.",
+ "mainpagetext": "<strong>MediaWiki geus réngsé diinstal.</strong>",
+ "mainpagedocfooter": "Mangga tingal ''[https://meta.wikimedia.org/wiki/MediaWiki_localisation documentation on customizing the interface]'' jeung [https://meta.wikimedia.org/wiki/MediaWiki_User%27s_Guide Tungtunan Pamaké] pikeun pitulung maké jeung konfigurasi."
+}
diff --git a/www/wiki/includes/installer/i18n/sv.json b/www/wiki/includes/installer/i18n/sv.json
new file mode 100644
index 00000000..a338387a
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sv.json
@@ -0,0 +1,323 @@
+{
+ "@metadata": {
+ "authors": [
+ "Jopparn",
+ "Skalman",
+ "WikiPhoenix",
+ "Josve05a",
+ "Lokal Profil",
+ "Tobulos1",
+ "Rotsee",
+ "Boom",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Installationsprogrammet för MediaWiki",
+ "config-title": "Installation av MediaWiki $1",
+ "config-information": "Information",
+ "config-localsettings-upgrade": "A <code>LocalSettings.php</code>-fil har upptäckts.\nFör att uppgradera den här installationen, vänligen ange värdet för <code>$wgUpgradeKey</code> i rutan nedan.\nDu hittar den i <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "En <code>LocalSettings.php</code>-fil har upptäckts.\nFör att uppgradera denna installation, kör <code>update.php</code> istället",
+ "config-localsettings-key": "Uppgraderingsnyckel:",
+ "config-localsettings-badkey": "Uppgraderingsnyckeln du angav är inkorrekt.",
+ "config-upgrade-key-missing": "En existerande installation av MediaWiki har upptäckts.\nFör att uppgradera installationen, lägg till följande rad i slutet av din <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Den befintliga <code>LocalSettings.php</code> verkar vara ofullständig.\nVariabeln $1 är inte inställd.\nÄndra <code>LocalSettings.php</code> så att denna variabel är inställd och klicka på \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Ett fel uppstod vid anslutning till databasen med inställningarna angivna i <code>LocalSettings.php</code>. Vänligen åtgärda dessa inställningar och försök igen.\n\n$1",
+ "config-session-error": "Fel vid uppstart av session: $1",
+ "config-session-expired": "Dina sessionsdata verkar har gått ut.\nSessioner är konfigurerade för en livstid på $1.\nDu kan öka denna genom att ange <code>session.gc_maxlifetime</code> i php.ini.\nStarta om installationen.",
+ "config-no-session": "Din sessionsdata förlorades!\nKolla din php.ini och se till att <code>session.save_path</code> är inställd på en lämplig katalog.",
+ "config-your-language": "Ditt språk:",
+ "config-your-language-help": "Välj ett språk att använda under installationen.",
+ "config-wiki-language": "Wikispråk:",
+ "config-wiki-language-help": "Välj det språk som wikin främst kommer att skrivas i.",
+ "config-back": "← Tillbaka",
+ "config-continue": "Fortsätt →",
+ "config-page-language": "Språk",
+ "config-page-welcome": "Välkommen till MediaWiki!",
+ "config-page-dbconnect": "Anslut till databas",
+ "config-page-upgrade": "Uppgradera existerande installation",
+ "config-page-dbsettings": "Databasinställningar",
+ "config-page-name": "Namn",
+ "config-page-options": "Alternativ",
+ "config-page-install": "Installera",
+ "config-page-complete": "Slutfört!",
+ "config-page-restart": "Starta om installationen",
+ "config-page-readme": "Läs mig",
+ "config-page-releasenotes": "Utgivningsanteckningar",
+ "config-page-copying": "Kopiering",
+ "config-page-upgradedoc": "Uppgradering",
+ "config-page-existingwiki": "Befintlig wiki",
+ "config-help-restart": "Vill du rensa all sparad data som du har angivit och starta om installationen?",
+ "config-restart": "Ja, starta om",
+ "config-welcome": "=== Miljökontroller ===\nGrundläggande kontroller kommer nu att utföras för att se om denna miljö är lämplig för installation av MediaWiki.\nKom ihåg att ta med denna information om du söker stöd för hur du skall slutföra installationen.",
+ "config-copyright": "=== Upphovsrätt och Villkor ===\n\n$1\n\nDetta program är fri programvara; du kan vidaredistribuera den och/eller modifiera det enligt villkoren i GNU General Public License som publicerats av Free Software Foundation; antingen genom version 2 av licensen, eller (på ditt initiativ) någon senare version.\n\nDetta program är distribuerat i hopp om att det kommer att vara användbart, men '''utan någon garanti'''; utan att ens ha en underförstådd garanti om '''säljbarhet''' eller '''lämplighet för ett särskilt ändamål'''.\nSe GNU General Public License för mer detaljer.\n\nDu bör ha fått <doclink href=Copying>en kopia av GNU General Public License</doclink> tillsammans med detta program; om inte, skriv till Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, eller [http://www.gnu.org/copyleft/gpl.html läs den online].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWikis webbplats]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Användarguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Administratörguide]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Frågor och svar]\n----\n* <doclink href=Readme>Läs mig</doclink>\n* <doclink href=ReleaseNotes>Utgivningsanteckningar</doclink>\n* <doclink href=Copying>Kopiering</doclink>\n* <doclink href=UpgradeDoc>Uppgradering</doclink>",
+ "config-env-good": "Miljön har kontrollerats.\nDu kan installera MediaWiki.",
+ "config-env-bad": "Miljön har kontrollerats.\nDu kan inte installera MediaWiki.",
+ "config-env-php": "PHP $1 är installerat.",
+ "config-env-hhvm": "HHVM $1 är installerat.",
+ "config-unicode-using-intl": "Använder [http://pecl.php.net/intl intl PECL-tillägget] för Unicode-normalisering.",
+ "config-unicode-pure-php-warning": "'''Varning:''' [http://pecl.php.net/intl intl PECL-tillägget] är inte tillgängligt för att hantera Unicode-normalisering, faller tillbaka till en långsamt implementering i ren PHP.\nOm du driver en högtrafikerad webbplats bör du läsa lite om [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-normalisering].",
+ "config-unicode-update-warning": "<strong>Varning:</strong> Den installerade versionen av Unicode-normaliserings \"wrappern\" använder en äldre version av [http://site.icu-project.org/ ICU projektets] bibliotek.\nDu bör [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations uppgradera] om är intresserad av att använda Unicode.",
+ "config-no-db": "Kunde inte hitta en lämplig databasdrivrutin! Du måste installera en databasdrivrutin för PHP.\nFöljande databas{{PLURAL:$2|typ |typer}} stöds: $1.\n\nI du själv kompilerat din PHP, konfigurera den med en databasklient aktiverad genom att t.ex. använda <code>./configure --with-mysqli</code>.\nOm du installerade PHP från ett Debian- eller Ubuntupaket måste du även installera, t.ex. <code>php5-mysql</code>-paketet.",
+ "config-outdated-sqlite": "'''Varning:''' du har SQLite $1, vilket är lägre än minimikravet version $2. SQLite kommer inte att vara tillgänglig.",
+ "config-no-fts3": "'''Varning:''' SQLite kompileras utan [//sqlite.org/fts3.html FTS3-modulen], sökfunktioner kommer att vara otillgängliga på denna backend.",
+ "config-pcre-old": "'''Kritiskt:''' PCRE $1 eller senare krävs.\nDin PHP-binär är länkad till PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Mer information].",
+ "config-pcre-no-utf8": "'''Kritiskt:''' PHP:s PCRE-modul verkar vara kompilerat utan PCRE_UTF8-stöd.\nMediaWiki kräver stöd för UTF-8 för att fungera korrekt.",
+ "config-memory-raised": "PHPs <code>memory_limit</code> är $1, ökad till $2.",
+ "config-memory-bad": "''' Varning:''' PHP:s <code>memory_limit</code> är $1.\nDetta är förmodligen för lågt.\nInstallationen kan misslyckas!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] är installerat",
+ "config-apc": "[http://www.php.net/apc APC] är installerat",
+ "config-apcu": "[http://www.php.net/apcu APCu] är installerat",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] är installerat",
+ "config-no-cache-apcu": "'''Varning:''' Kunde inte hitta [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] eller [http://www.iis.net/download/WinCacheForPhp WinCache].\nCachelagring av objekt är inte aktiverat.",
+ "config-mod-security": "'''Varning:''' Din webbserver har [http://modsecurity.org/ mod_security] aktiverat. Om felaktigt konfigurerat kan den skapa problem för MediaWiki eller annan programvara som tillåter användaren att posta godtyckligt innehåll.\nTitta på [http://modsecurity.org/documentation/ mod_security-dokumentationen] eller kontakta din värd om du påträffar slumpmässiga fel.",
+ "config-diff3-bad": "GNU diff3 hittades inte.",
+ "config-git": "Hittade Git-mjukvara för versionskontroll: <code>$1</code>.",
+ "config-git-bad": "Git-mjukvara för versionskontroll hittades inte.",
+ "config-imagemagick": "Hittade ImageMagick: <code>$1</code>.\nMiniatyrvisning av bilder kommer att aktiveras om du aktiverar uppladdningar.",
+ "config-gd": "Hittade ett integrerat GD-grafikbibliotek.\nMiniatyrvisning av bilder kommer att aktiveras om du aktiverar uppladdningar.",
+ "config-no-scaling": "Kunde inte hitta GD-biblioteket eller ImageMagick.\nMiniatyrvisning av bilder kommer att inaktiveras.Miniatyrvisning av bilder",
+ "config-no-uri": "'''Fel:''' Kunde inte fastställa det nuvarande URI:et.\nInstallationen avbröts.",
+ "config-no-cli-uri": "'''Varning:''' Ingen <code>--scriptpath</code> är angiven, använder standarden: <code>$1</code> .",
+ "config-using-server": "Använder servernamn \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Använder server-URL \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "<strong>Varning:</strong> Din standardkatalog för uppladdningar <code>$1</code> är sårbar för körning av godtyckliga skript.\nÄven om MediaWiki kontrollerar alla uppladdade filer för säkerhetshot är det ändå starkt rekommenderat att [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security stänga detta säkerhetshål] innan du aktiverar uppladdningar.",
+ "config-no-cli-uploads-check": "'''Varning:''' Din standardkatalog för uppladdningar (<code>$1</code>) har inte kontrollerats för sårbarhet från körning av godtyckliga skript under CLI-installationen.",
+ "config-brokenlibxml": "Ditt system har en kombination av PHP och libxml2 som är buggigt och kan orsaka datakorruption i MediaWiki och andra webbprogram.\nUppgradera till libxml2 2.7.3 eller senare ([https//bugs.php.net/bug.php?id=45996 buggfil med PHP]).\nInstallationen avbröts.",
+ "config-suhosin-max-value-length": "Suhosin är installerat och begränsar GET-parametern <code>length</code> till $1 bytes.\nMediaWikis ResourceLoader-komponent kommer att arbeta runt denna begränsning, men det kommer att försämra prestandan.\nOm möjligt bör du sätta <code>suhosin.get.max_value_length</code> till 1024 eller högre i <code>php.ini</code>, och sätta <code>$wgResourceLoaderMaxQueryLength</code> till samma värde som i <code>LocalSettings.php</code>.",
+ "config-using-32bit": "<strong>Varning:</strong> ditt system verkar vara en 32-bitarsversion. Detta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit rekommenderas inte].",
+ "config-db-type": "Databastyp:",
+ "config-db-host": "Databasvärd:",
+ "config-db-host-help": "Om din databasserver är på en annan server, ange då värdnamnet eller IP-adressen här.\n\nOm du använder ett delat webbhotell, bör din leverantör ge dig rätt värdnamn i deras dokumentation.\n\nOm du installerar på en Windowsserver och använder MySQL, kanske \"localhost\" inte fungerar för servernamnet. Om det inte gör det försök med \"127.0.0.1\" som den lokala IP-adressen.\n\nOm du använder PostgreSQL, lämna detta fält blankt för att ansluta via en Unix-socket.",
+ "config-db-host-oracle": "Databas TNS:",
+ "config-db-host-oracle-help": "Ange ett giltigt [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; en tnsnames.ora-fil måste vara synlig för denna installation.<br />Om du använder klientbibliotek 10g eller nyare kan du också använda [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect] namngivningsmetoden.",
+ "config-db-wiki-settings": "Identifiera denna wiki",
+ "config-db-name": "Databasnamn:",
+ "config-db-name-help": "Välj ett namn som identifierar din wiki.\nDet bör inte innehålla mellanslag.\n\nOm du använder ett delat webbhotell kan de antingen ge dig ett särskilt databasnamn att använda eller så kan de låta dig skapa en databas via kontrollpanelen.",
+ "config-db-name-oracle": "Databasschema:",
+ "config-db-account-oracle-warn": "Det finns tre stödda scenarier för installationen av Oracle som en backend-databas:\n\nOm du vill skapa ett databaskonto som en del av installationen, ange ett konto med SYSDBA-roll som databaskonto under installationen och ange de önskade autentiseringsuppgifterna för kontot med webb-åtkomst, annars kan du antingen skapa ett konto med webb-åtkomst manuellt och ange enbart detta konto (om den har behörighet att skapa schema-objekt) eller ange två olika konton, en med create-behörighet och en begränsad för webb-åtkomst.\n\nSkript för att skapa ett konto med de korrekta behörigheterna kan hittas i \"maintenance/oracle/\"-katalogen för denna installation. Tänk på att användningen av ett begränsat konto inaktiverar all underhållsmöjlighet med standardkontot.",
+ "config-db-install-account": "Användarkonto för installation",
+ "config-db-username": "Databas-användarnamn:",
+ "config-db-password": "Databas-lösenord:",
+ "config-db-install-username": "Ange det användarnamn som ska används för att ansluta till databasen under installationsprocessen.\nDetta är inte användarnamnet för ditt MediaWiki-konto; detta är användarnamnet för din databas.",
+ "config-db-install-password": "Ange det lösenord som ska användas för att ansluta till databasen under installationsprocessen.\nDetta är inte lösenordet för ditt MediaWiki-konto; detta är lösenordet för din databas.",
+ "config-db-install-help": "Ange användarnamnet och lösenordet som kommer att användas för att ansluta till databasen under installationsprocessen.",
+ "config-db-account-lock": "Använda samma användarnamn och lösenord under normal drift",
+ "config-db-wiki-account": "Användarkonto för normal drift",
+ "config-db-wiki-help": "Ange det användarnamn och lösenorde som skall användas för att ansluta till databasen under normal wiki-drift. Om kontot inte existerar, och om installationskontot har tillräcklig behörighet, kommer detta användarkontot att skapas med de minimiprivilegier som krävs för att driva wikin.",
+ "config-db-prefix": "Prefix för tabellerna i databasen:",
+ "config-db-prefix-help": "Om du behöver dela en databas mellan flera olika wikis, eller mellan MediaWiki och en annan webbapplikation, kan du välja att lägga till ett prefix till alla tabellnamn för att undvika konflikter.\nAnvänd inte mellanslag.\n\nDet här fältet lämnas vanligtvis tomt.",
+ "config-mysql-old": "MySQL $1 eller senare krävs. Du har $2.",
+ "config-db-port": "Databasport:",
+ "config-db-schema": "Schema för MediaWiki",
+ "config-db-schema-help": "Det här schemat blir oftast bra.\nÄndra det endast om du vet att du behöver.",
+ "config-pg-test-error": "Kan inte ansluta till databas '''$1''': $2",
+ "config-sqlite-dir": "SQLite data-katalog:",
+ "config-sqlite-dir-help": "SQLite lagrar all data i en enda fil.\n\nDen katalog du anger måste vara skrivbar av webbservern under installationen.\n\nDet bör <strong>inte</strong> vara tillgänglig via webben; Det är därför vi inte lägger den där dina PHP-filer är.\n\nInstallationsprogrammet kommer att skriva en <code>.htaccess</code>-fil tillsammans med den, men om det misslyckas kan någon få tillgång till den råa databasen.\nVlken innehåller rå användardata (e-postadresser, hashade lösenord) samt borttagna revideringar och annan begränsad data på wiki.\n\nÖverväga att lägga databasen någon helt annanstans, till exempel i <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Standardtabellutrymme (tablespace):",
+ "config-oracle-temp-ts": "Tillfälligt tabellutrymme (tablespace):",
+ "config-type-mysql": "MySQL (eller kompatibelt)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki stöder följande databassystem:\n\n$1\n\nOm du inte ser det databassystem som du försöker använda nedanstående, följ då instruktionerna länkade ovan för aktivera stöd för det.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] är det primära målet för MediaWiki och det stöds bäst. MediaWiki fungerar även med [{{int:version-db-mariadb-url}} MariaDB] och [{{int:version-db-percona-url}} Percona Server], som är kompatibla med MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Hur man kompilerar PHP med stöd för MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] är ett populärt databassystem med öppen källkod som ett alternativ till MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Hur man kompilerar PHP med PostgreSQL stöd])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] är en lättviktsdatabassystem med väldigt bra stöd. ([http://www.php.net/manual/en/pdo.installation.php Hur man kompilerar PHP med SQLite stöd], använder PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] är en kommersiellt databas för företag. ([http://www.php.net/manual/en/oci8.installation.php Hur man kompilerar PHP med OCI8 stöd])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] är en kommersiellt databas för företag för Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Hur man kompilerar PHP med SQLSRV stöd])",
+ "config-header-mysql": "MySQL-inställningar",
+ "config-header-postgres": "PostgreSQL-inställningar",
+ "config-header-sqlite": "SQLite-inställningar",
+ "config-header-oracle": "Oracle-inställningar",
+ "config-header-mssql": "Inställningar för Microsoft SQL Server",
+ "config-invalid-db-type": "Ogiltig databastyp",
+ "config-missing-db-name": "Du måste ange ett värde för \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "Du måste ange ett värde för \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "Du måste ange ett värde för \"{{int:config-db-host-oracle}}\".",
+ "config-invalid-db-server-oracle": "Ogiltig databas-TNS \"$1\".\nAnvända antingen \"TNS Name\" eller en \"Easy Connect\"-sträng ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracles namngivningsmetoder]).",
+ "config-invalid-db-name": "\"$1\" är ett ogiltigt databasnamn.\nAnvänd bara ASCII-bokstäver (a-z, A-Z), siffror (0-9), understreck (_) och bindestreck (-).",
+ "config-invalid-db-prefix": "\"$1\" är ett ogiltigt databasprefix.\nAnvänd bara ASCII-bokstäver (a-z, A-Z), siffror (0-9), understreck (_) och bindestreck (-).",
+ "config-connection-error": "$1.\n\nKontrollera värd, användarnamn och lösenord och försök igen.",
+ "config-invalid-schema": "\"$1\" är ett ogiltigt schema för MediaWiki.\nAnvänd bara ASCII-bokstäver (a-z, A-Z), siffror (0-9), understreck (_) och bindestreck (-).",
+ "config-db-sys-create-oracle": "Installationsprogrammet stöder endast användningen av ett SYSDBA-konto för att skapa ett nytt konto.",
+ "config-db-sys-user-exists-oracle": "Användarkontot \"$1\" finns redan. SYSDBA kan endast användas för att skapa ett nytt konto!",
+ "config-postgres-old": "PostgreSQL $1 eller senare krävs, du har $2.",
+ "config-mssql-old": "Microsoft SQL-server $1 eller senare krävs. Du har $2.",
+ "config-sqlite-name-help": "Välja ett namn som identifierar din wiki.\nAnvänd inte mellanslag eller bindestreck.\nDetta kommer att användas för SQLite-data filnamnet.",
+ "config-sqlite-parent-unwritable-group": "Kan inte skapa datakatalogen <code><nowiki>$1</nowiki></code>, då den överordnade katalogen <code><nowiki>$2</nowiki></code> inte är skrivbar för webbservern.\n\nInstallationen har avgjort vilken användare din webbserver körs som.\nGör <code><nowiki>$3</nowiki></code>-katalogen skrivbar för den för att fortsätta.\nPå ett Unix/Linux system gör:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Kan inte skapa datakatalogen <code><nowiki>$1</nowiki></code>, då den överordnade katalogen <code><nowiki>$2</nowiki></code> inte är skrivbar för webbservern.\n\nInstallationen kunde inte avgöra vilken användare din webbserver körs som.\nGör <code><nowiki>$3</nowiki></code>-katalogen skrivbar för den (och andra!) för att fortsätta.\nPå ett Unix/Linux system gör:\n\n<pre>cd $2\nmkdir $3\nchmod g+w $3</pre>",
+ "config-sqlite-mkdir-error": "Fel uppstod när datakatalogen \"$1\" skulle skapas.\nKontrollera platsen och försök igen.",
+ "config-sqlite-dir-unwritable": "Kunde inte skriva till katalogen \"$1\".\nÄndra dess behörighet så att webbservern kan skriva till den och försök igen.",
+ "config-sqlite-connection-error": "$1.\n\nKontrollera datakatalogen och databasnamnet nedan och försök igen.",
+ "config-sqlite-readonly": "Filen <code>$1</code> är inte skrivbar.",
+ "config-sqlite-cant-create-db": "Kunde inte skapa databasfilen <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP saknar stöd för FTS3, nedgraderar tabeller",
+ "config-can-upgrade": "Det finns MediaWiki-tabeller i den här databasen.\nFör att uppgradera dem till MediaWiki $1, klicka på '''Fortsätt'''.",
+ "config-upgrade-done": "Uppgraderingen slutfördes.\n\nDu kan nu [$1 börja använda din wiki].\n\nOm du vill förnya din <code>LocalSettings.php</code>-fil, klicka på knappen nedan.\nDetta '''rekommenderas inte''' om du har problem med din wiki.",
+ "config-upgrade-done-no-regenerate": "Uppgraderingen slutfördes.\n\nDu kan nu [$1 börja använda din wiki].",
+ "config-regenerate": "Återskapa LocalSettings.php →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code>-förfrågan misslyckades!",
+ "config-unknown-collation": "'''Varning:''' Databasen använder en okänd sortering.",
+ "config-db-web-account": "Databaskonto för webbaccess",
+ "config-db-web-help": "Välj det användarnamn och lösenord som webbservern använder för att ansluta till databasservern, under ordinarie drift av wikin.",
+ "config-db-web-account-same": "Använd samma konto som för installation",
+ "config-db-web-create": "Skapa kontot om det inte redan finns",
+ "config-db-web-no-create-privs": "Det konto som du har angett för installation har inte tillräcklig behörighet för att skapa ett konto.\nDet konto du anger här måste redan finnas.",
+ "config-mysql-engine": "Lagringsmotor:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Varning:''' Du har valt MyISAM som lagringsmotor för MySQL, vilket inte rekommenderas för användning med MediaWiki eftersom:\n* den knappt stöder samtidigt exekvering på grund av låsning av tabeller\n* den är mer benägen att korrumpera data än andra motorer\n* MediaWiki-kodbasen hanterar inte alltid MyISAM som den ska\n\nOm din MySQL-installation stöder InnoDB, är det starkt rekommenderat att du väljer det istället.\nOm din MySQL-installation inte stöder InnoDB, kanske det är dags för en uppgradering.",
+ "config-mysql-only-myisam-dep": "'''Varning:''' MyISAM är den enda tillgängliga lagringsmotorn för MySQL på denna maskin, och den är inte rekommenderad att använda med MediaWiki eftersom:\n* den knappt stöder samtidigt exekvering på grund av låsning av tabeller\n* den är mer benägen att korrumpera data än andra motorer\n* MediaWiki-kodbasen hanterar inte alltid MyISAM som den ska\n\nDin MySQL-installation stöder inte InnoDB, det kanske är dags för en uppgradering.",
+ "config-mysql-engine-help": "'''InnoDB''' är nästan alltid det bästa valet eftersom den har ett bra system för samtidiga arbeten.\n\n'''MyISAM''' kan vara snabbare i enanvändarläge eller skrivskyddade installationer.\nMyISAM-databaser tenderar att bli korrupta oftare än InnoDB-databaser.",
+ "config-mysql-charset": "Databasteckensuppsättning:",
+ "config-mysql-binary": "Binär",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "I '''binärt läge''' lagrar MediaWiki UTF-8 text till databasen i binära fält.\nDetta är mer effektivt än MySQLs UTF-8-läge, och den tillåter dig att använda den fulla uppsättningen av Unicode-tecken.\n\nI '''UTF-8-läge''' vet MySQL vilket teckenuppsättning din data är i och kan presentera och konvertera den på ett lämpligt sätt, men den tillåter dig inte att lagra tecken över [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane].",
+ "config-mssql-auth": "Autentiseringstyp:",
+ "config-mssql-install-auth": "Välj autentiseringstypen som kommer att användas för att ansluta till databasen under installationsprocessen.\nOm du väljer \"{{int:config-mssql-windowsauth}}\", kommer autentiseringsuppgifterna för den användare webbservern körs som att användas.",
+ "config-mssql-web-auth": "Välj autentiseringstypen som kommer att användas för att ansluta till databasen under ordinarie drift av wikin.\nOm du väljer \"{{int:config-mssql-windowsauth}}\", kommer autentiseringsuppgifterna för den användare webbservern körs som att användas.",
+ "config-mssql-sqlauth": "SQL Server-autentisering",
+ "config-mssql-windowsauth": "Windows-autentisering",
+ "config-site-name": "Namnet på wikin:",
+ "config-site-name-help": "Detta visas i titelfältet i webbläsaren och på flera andra platser.",
+ "config-site-name-blank": "Ange ett webbplatsnamn.",
+ "config-project-namespace": "Projektnamnrymd:",
+ "config-ns-generic": "Projekt",
+ "config-ns-site-name": "Samma som wikinamnet: $1",
+ "config-ns-other": "Annan (specificera)",
+ "config-ns-other-default": "MinWiki",
+ "config-project-namespace-help": "Per Wikipedias exempel håller många wikis sina policy-sidor separata från innehållssidorna i en '''projektnamnrymd'''.\nAlla sidtitlar i denna namnrymd startar med ett visst prefix vilket du specificerar här.\nVanligtvis kan detta namn härledas från namnet på wikin, men den får inte innehålla interpunktionstecken som exempelvis \"#\" eller \":\".",
+ "config-ns-invalid": "Den angivna namnrymden \"<nowiki>$1</nowiki>\" är ogiltig.\nAnge en annan namnrymd för projektet.",
+ "config-ns-conflict": "Den angivna namnrymden \"<nowiki>$1</nowiki>\" står i konflikt med en standardnamnrymd för MediaWiki.\nAnge en annan namnrymd för projektet.",
+ "config-admin-box": "Administratörskonto",
+ "config-admin-name": "Ditt användarnamn:",
+ "config-admin-password": "Lösenord:",
+ "config-admin-password-confirm": "Lösenord igen:",
+ "config-admin-help": "Ange ditt önskade användarnamn här, t.ex. \"Sven Svensson\".\nDetta är namnet du kommer att använda för att logga in på wikin.",
+ "config-admin-name-blank": "Ange ett användarnamn för administratörskontot.",
+ "config-admin-name-invalid": "Det angivna användarnamnet \"<nowiki>$1</nowiki>\" är ogiltigt.\nAnge ett annat användarnamn.",
+ "config-admin-password-blank": "Ange ett lösenord för administratörskontot.",
+ "config-admin-password-mismatch": "De två lösenord du angav överensstämmer inte med varandra.",
+ "config-admin-email": "E-postadress:",
+ "config-admin-email-help": "Ange en e-postadress här så att du kan ta emot e-post från andra användare på wikin, för att återställa ditt lösenord och för att informeras om ändringar på sidor du bevakar. Du kan lämna fältet tomt.",
+ "config-admin-error-user": "Internt fel när du skapar en administratör med namnet \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Internt fel lösenordet för administratören \"<nowiki>$1</nowiki>\" ställdes in: <pre>$2</pre>",
+ "config-admin-error-bademail": "Du har angivit en ogiltig e-postadress.",
+ "config-subscribe": "Prenumerera på [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce e-postlistan för kungörelser av nya versioner].",
+ "config-subscribe-help": "Detta är en e-postlista med låg volym vilken används för meddelanden om nya versionssläpp, inklusive viktiga säkerhetsmeddelanden.\nDu bör prenumerera på den och uppdatera din MediaWiki-installation när nya versioner kommer ut.",
+ "config-subscribe-noemail": "Du försökte att prenumerera på e-postlistan för versionssläppsmeddelanden utan att tillhandahålla en e-postadress.\nAnge en e-postadress om du vill prenumerera på e-postlistan.",
+ "config-pingback": "Dela data om denna installation med MediaWikis utvecklare.",
+ "config-pingback-help": "Om du väljer det här alternativet kommer MediaWiki periodvis pinga https://www.mediawiki.org med grundläggande data om den här MediaWiki-instansen. Denna data innehåller exempelvis typen av system, PHP-version och valde databas. Wikimedia Foundation delar denna data med MediaWiki-utvecklare för att hjälpa till att guida framtida utvecklingsarbete. Följande data kommer att skickas till ditt system: <pre>$1</pre>",
+ "config-almost-done": "Du är nästan färdig!\nDu kan nu hoppa över återstående konfigurationer och installera wikin direkt.",
+ "config-optional-continue": "Ställ fler frågor till mig.",
+ "config-optional-skip": "Jag är redan uttråkad, bara installera wiki.",
+ "config-profile": "Profil för användarrättigheter:",
+ "config-profile-wiki": "Öppen wiki",
+ "config-profile-no-anon": "Kontoskapande krävs",
+ "config-profile-fishbowl": "Endast auktoriserade redigerare",
+ "config-profile-private": "Privat wiki",
+ "config-profile-help": "Wikier fungerar bäst när du låter som många människor som möjligt redigera dem.\nI MediaWiki är det lätt att granska de senaste ändringarna och återställa alla skador som utförs av naiva eller illvilliga användare.\n\nMen många har funnit MediaWiki användbart i en mängd olika roller, och ibland är det inte lätt att övertyga alla om fördelarna med wiki-sättet.\nSå valet är ditt.\n\nModellen <strong>{{int:config-profil-wiki}}</strong> tillåter vem som helst att redigera, utan att ens behöva logga in.\nEn wiki med <strong>{{int:config-profil-ingen-anon}}</strong> ger extra ansvarskänsla, men kan avskräcka tillfälliga bidragsgivare.\n\nScenariot <strong>{{int:config-profil-fishbowl}}</strong> tillåter godkända användare att redigera, men allmänheten kan se sidorna, inklusive historik.\nA <strong>{{int:config-profil-privat}}</strong> tillåter endast godkända användare att se sidor, samma grupp får även redigera.\n\nMer komplexa användarrättighetskonfigurationer finns tillgängliga efter installationen, se [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights avsnittet i manualen].",
+ "config-license": "Upphovsrätt och licens:",
+ "config-license-none": "Ingen licenssidfot",
+ "config-license-cc-by-sa": "Creative Commons Erkännande-DelaLika",
+ "config-license-cc-by": "Creative Commons Erkännande",
+ "config-license-cc-by-nc-sa": "Creative Commons Erkännande-IckeKommersiell-DelaLika",
+ "config-license-cc-0": "Creative Commons Zero (Public Domain)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 eller senare",
+ "config-license-pd": "Public Domain",
+ "config-license-cc-choose": "Välj en anpassad Creative Commons-licens",
+ "config-license-help": "Många publika wikis släpper alla bidrag under en [http://freedomdefined.org/Definition fri licens].\nDetta bidrar till en känsla av gemensamt ägandeskap och uppmuntrar till långsiktiga bidrag.\nDet är i allmänhet inte nödvändigt för en privat eller företagswiki.\n\nOm du vill kunna använda text från Wikipedia, och du vill att Wikipedia ska kunna acceptera text kopierad ifrån din wiki bör du välja <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia använde tidigare GNU Free Documentation License.\nGFDL är en giltig licens, men svår att förstå.\nDet är även svårt att återanvända innehåll som licensierats under GFDL.",
+ "config-email-settings": "E-postinställningar",
+ "config-enable-email": "Aktivera utgående e-post",
+ "config-enable-email-help": "Om du vill att e-post ska fungera behöver,[http://www.php.net/manual/en/mail.configuration.php PHPs e-postinställningar] vara konfigurerad på rätt sätt.\nOm du inte vill ha några e-postfunktioner, kan du inaktivera dem här.",
+ "config-email-user": "Aktivera e-post mellan användare",
+ "config-email-user-help": "Tillåta alla användare att skicka e-post till varandra om de har aktiverat det i sina inställningar.",
+ "config-email-usertalk": "Aktivera meddelanden för användardiskussionssidor",
+ "config-email-usertalk-help": "Tillåt användare att få meddelanden när användardiskussionssidor ändras, om de har aktiverat detta i sina inställningar.",
+ "config-email-watchlist": "Aktivera meddelanden för bevakningslistan",
+ "config-email-watchlist-help": "Tillåt användare att få meddelanden när deras bevakade sidor ändras, om de har aktiverat detta i sina inställningar.",
+ "config-email-auth": "Aktivera autentisering via e-post",
+ "config-email-auth-help": "Om detta alternativ är aktiverat, måste användare bekräfta sin e-postadress via en länk som skickas till dem när de ställer in eller ändra den.\nEndast autentiserade e-postadresser kan ta emot e-post från andra användare eller ändra aviserings-e-post.\nDet här alternativet är <strong>rekommenderat</strong> för offentliga wikis på grund av potentiellt missbruk av e-postfunktionerna.",
+ "config-email-sender": "Returadress för e-post:",
+ "config-email-sender-help": "Ange den e-postadressen som ska användas som returadress på utgående e-post.\nDetta är dit studsar skickas.\nMånga mailservrar kräver att minst domännamndelen är giltigt.",
+ "config-upload-settings": "Bild- och filuppladdningar",
+ "config-upload-enable": "Aktivera filuppladdningar",
+ "config-upload-help": "Filuppladdning utsätter potentiellt din server för säkerhetsrisker.\nFör mer information, Läs [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security säkerhetsavsnittet] i manualen.\n\nFör att aktivera filuppladdning, ändra läget för <code>images</code>-underkatalogen under rotkatalogen för MediaWiki så att webbservern kan skriva till den.\nAktivera sedan detta alternativ.",
+ "config-upload-deleted": "Katalog för raderade filer:",
+ "config-upload-deleted-help": "Välja en katalog i vilken raderade filer arkiveras.\nHelst bör denna inte vara tillgängliga från webben.",
+ "config-logo": "Logotyp-URL:",
+ "config-logo-help": "MediaWikis standardutseende innehåller ett mellanrum för en 135x160 bildpunkter stor logotyp ovanför sidofältsmenyn.\nLadda upp en bild med lämplig storlek och ange webbadressen här.\n\nDu kan använda <code>$wgStylePath</code> eller <code>$wgScriptPath</code> om din logotyp är relativ till dessa sökvägar.\n\nOm du inte vill ha en logotyp kan du lämna detta fält tomt.",
+ "config-instantcommons": "Aktivera Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] är en funktion som gör det möjligt för wikis att använda bilder, ljud och andra media som finns på [https://commons.wikimedia.org/ Wikimedia Commons]-webbplatsen.\nFör att göra detta, kräver MediaWiki tillgång till Internet.\n\nFör mer information om denna funktion, inklusive instruktioner om hur man ställer in den för andra wikis än Wikimedia Commons, se [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos manualen].",
+ "config-cc-error": "Creative Commons-licens-väljaren gav inget resultat.\nAnge licensnamnet manuellt.",
+ "config-cc-again": "Välj igen...",
+ "config-cc-not-chosen": "Välj vilken Creative Commons-licens du vill ha och klicka på \"proceed\".",
+ "config-advanced-settings": "Avancerad konfiguration",
+ "config-cache-options": "Inställningar för cachelagring av objekt:",
+ "config-cache-help": "Cachelagring av objekt används för att förbättra hastigheten på MediaWiki genom att cachelagra data som används ofta.\nMedelstora till stora webbplatser är starkt uppmuntrade att aktivera detta, och små webbplatser kommer även att se fördelar.",
+ "config-cache-none": "Ingen cachelagring (ingen funktionalitet tas bort, men hastighet kan påverkas på större wiki-webbplatser)",
+ "config-cache-accel": "Cachelagring av PHP-objekt (APC, APCu, XCache eller WinCache)",
+ "config-cache-memcached": "Använda Memcached (kräver ytterligare inställningar och konfiguration)",
+ "config-memcached-servers": "Memcached-servrar:",
+ "config-memcached-help": "Lista över IP-adresser som ska användas för Memcached.\nBör ange en per rad och specificera den port som ska användas. Till exempel:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Du valde Memcached som din cachelagringstyp men angav inte några servrar.",
+ "config-memcache-badip": "Du har angett en ogiltig IP-adress för Memcached: $1.",
+ "config-memcache-noport": "Du angav inte en port som ska användas för Memcached-server: $1.\nOm du inte vet porten, är standard 11211.",
+ "config-memcache-badport": "Memcached-portnummer bör vara mellan $1 och $2.",
+ "config-extensions": "Tillägg",
+ "config-extensions-help": "Tilläggen ovan upptäcktes i din <code>./extensions</code>-katalog.\n\nDe kan kräva ytterligare konfiguration, men du kan aktivera dem nu.",
+ "config-skins": "Utseenden",
+ "config-skins-help": "Utseenden som listas upp ovan identifierades i din filkatalog <code>./skins</code>. Du måste aktivera minst en och välja ett som standard.",
+ "config-skins-use-as-default": "Använd detta utseende som standard",
+ "config-skins-missing": "Ingen utseenden hittades; MediaWiki använder ett reservutseende tills du installerar några.",
+ "config-skins-must-enable-some": "Du måste välja minst ett utseende att aktivera.",
+ "config-skins-must-enable-default": "Utseendet som valdes som standard måste aktiveras.",
+ "config-install-alreadydone": "''' Varning:''' Du verkar redan ha installerat MediaWiki och försöker installera det igen.\nVänligen fortsätt till nästa sida.",
+ "config-install-begin": "Genom att trycka på \"{{int:config-continue}}\", påbörjar du installationen av MediaWiki.\nOm du fortfarande vill göra ändringar tryck på \"{{int:config-back}}\".",
+ "config-install-step-done": "klar",
+ "config-install-step-failed": "misslyckades",
+ "config-install-extensions": "Inklusive tillägg",
+ "config-install-database": "Konfigurerar databas",
+ "config-install-schema": "Skapar schema",
+ "config-install-pg-schema-not-exist": "PostgreSQL-schemat finns inte.",
+ "config-install-pg-schema-failed": "Det gick inte att skapa tabeller.\nSe till att användaren \"$1\" kan skriva till schemat \"$2\".",
+ "config-install-pg-commit": "Begår ändringar",
+ "config-install-pg-plpgsql": "Kontroll för språket PL/pgSQL",
+ "config-pg-no-plpgsql": "Du måste installera språket PL/pgSQL i databasen $1",
+ "config-pg-no-create-privs": "Det konto som du har angett för installationen har inte tillräcklig behörighet för att skapa ett konto.",
+ "config-pg-not-in-role": "Det konto du angav för webbanvändaren finns redan.\nKontot du angav för installationen är inte en superanvändare (superuser) och är inte en medlem av webbanvändarens roll, därför kan den inte skapa objekt som ägs av webbanvändaren.\n\nMediaWiki kräver för närvarande att tabellerna ägs av webbanvändaren. Vänligen ange ett annat webbkontonamn, eller klicka \"tillbaka\" och ange en installationsanvändare med passande behörigheter.",
+ "config-install-user": "Skapar databasanvändare",
+ "config-install-user-alreadyexists": "Användaren \"$1\" finns redan",
+ "config-install-user-create-failed": "Misslyckades att skapa användare \"$1\": $2",
+ "config-install-user-grant-failed": "Beviljandet av behörighet till användaren \"$1\" misslyckades: $2",
+ "config-install-user-missing": "Den angivna användaren \"$1\" existerar inte.",
+ "config-install-user-missing-create": "Den angivna användaren \"$1\" existerar inte.\nVänligen klicka på kryssrutan \"skapa konto\" nedan om du vill skapa den.",
+ "config-install-tables": "Skapar tabeller",
+ "config-install-tables-exist": "'''Varning:''' MediaWiki-tabeller verkar redan finnas.\nHoppar över skapandet.",
+ "config-install-tables-failed": "'''Fel:''' Skapandet av tabell misslyckades med följande fel: $1",
+ "config-install-interwiki": "Lägger till standardtabell för interwiki",
+ "config-install-interwiki-list": "Kunde inte läsa filen <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "<strong>Varning:</strong> Interwiki-tabellen verkar redan innehålla poster.\nHoppar över standardlistan.",
+ "config-install-stats": "Initierar statistik",
+ "config-install-keys": "Genererar hemliga nycklar",
+ "config-insecure-keys": "'''Varning:''' {{PLURAL:$2|En säkerhetsnyckel|Säkerhetsnycklar}} ($1) som generades under installationen är inte helt {{PLURAL:$2|säker|säkra}} . Överväg att ändra {{PLURAL:$2|den|dem}} manuellt.",
+ "config-install-updates": "Förhindra att onödiga uppdateringar körs",
+ "config-install-updates-failed": "<strong>Fel:</strong> Infogning av uppdateringsnycklar i tabeller misslyckades med följande fel:$1",
+ "config-install-sysop": "Skapar administratörskonto",
+ "config-install-subscribe-fail": "Det gick inte att prenumerera på mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL är inte installerad och <code>allow_url_fopen</code> är inte tillgänglig.",
+ "config-install-mainpage": "Skapa huvudsida med standardinnehåll",
+ "config-install-mainpage-exists": "Huvudsidan finns redan, hoppar över",
+ "config-install-extension-tables": "Skapar tabeller för aktiverade tillägg",
+ "config-install-mainpage-failed": "Kunde inte infoga huvudsidan: $1",
+ "config-install-done": "<strong>Grattis!</strong>\nDu har installerat MediaWiki.\n\nInstallationsprogrammet har genererat filen <code>LocalSettings.php</code>.\nDet innehåller alla dina konfigurationer.\n\nDu kommer att behöva ladda ner den och placera den i roten för din wiki-installation (samma katalog som index.php). Nedladdningen borde ha startats automatiskt.\n\nOm ingen nedladdning erbjöds, eller om du har avbrutit det kan du starta om nedladdningen genom att klicka på länken nedan:\n\n$3\n\n<strong>OBS</strong>: Om du inte gör detta nu, kommer denna genererade konfigurationsfil inte vara tillgänglig för dig senare om du avslutar installationen utan att ladda ned den.\n\nNär det är klart, kan du <strong>[$2 gå in på din wiki]</strong>",
+ "config-install-done-path": "<strong>Grattis!</strong>\nDu har installerat MediaWiki.\n\nInstallationsprogrammet har genererat filen <code>LocalSettings.php</code>.\nDet innehåller alla dina konfigurationer.\n\nDu kommer att behöva ladda ner den och placera den i <code>$4</code>. Nedladdningen borde ha startats automatiskt.\n\nOm ingen nedladdning erbjöds, eller om du har avbrutit det kan du starta om nedladdningen genom att klicka på länken nedan:\n\n$3\n\n<strong>OBS</strong>: Om du inte gör detta nu, kommer denna genererade konfigurationsfil inte vara tillgänglig för dig senare om du avslutar installationen utan att ladda ned den.\n\nNär det är klart, kan du <strong>[$2 gå in på din wiki]</strong>",
+ "config-download-localsettings": "Ladda ner <code>LocalSettings.php</code>",
+ "config-help": "hjälp",
+ "config-help-tooltip": "klicka för att expandera",
+ "config-nofile": "Filen \"$1\" kunde inte hittas. Har den raderats?",
+ "config-extension-link": "Visste du att din wiki stödjer [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions tillägg]?\n\nDu kan bläddra [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category tillägg efter kategori].",
+ "config-skins-screenshots": "$1 (skärmbilder: $2)",
+ "config-screenshot": "skärmbild",
+ "mainpagetext": "<strong>MediaWiki har installerats utan problem.</strong>",
+ "mainpagedocfooter": "Information om hur wiki-programvaran används finns i [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents användarguiden].\n\n== Att komma igång ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista över konfigurationsinställningar]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce E-postlista för nya versioner av MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokalisera MediaWiki för ditt språk]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Läs om hur du bekämpar spam på din wiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/sw.json b/www/wiki/includes/installer/i18n/sw.json
new file mode 100644
index 00000000..b39a1edb
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/sw.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Lloffiwr"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki imefanikiwa kuingizwa.'''",
+ "mainpagedocfooter": "Shauriana na [https://meta.wikimedia.org/wiki/Help:Contents Mwongozo wa Mtumiaji] kwa habari juu ya utumiaji wa bidhaa pepe ya wiki.\n\n== Msaada wa kianzio ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Orodha ya mipangilio ya msingi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ ya MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Orodha ya utoaji wa habari za MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/szl.json b/www/wiki/includes/installer/i18n/szl.json
new file mode 100644
index 00000000..2efcf0ab
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/szl.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Djpalar"
+ ]
+ },
+ "mainpagetext": "'''Sztalowańy MediaWiki śe udoło.'''",
+ "mainpagedocfooter": "Uobezdrzij [https://meta.wikimedia.org/wiki/Help:Contents przewodńik sprowjacza], kaj sům informacyje uo dźołańu uoprogramowańo MediaWiki.\n\n== Na sztart ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista sztalowań konfiguracyje]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Komuńikaty uo nowych wersyjach MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/ta.json b/www/wiki/includes/installer/i18n/ta.json
new file mode 100644
index 00000000..a531f4fc
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ta.json
@@ -0,0 +1,89 @@
+{
+ "@metadata": {
+ "authors": [
+ "Karthi.dr",
+ "TRYPPN",
+ "மதனாஹரன்",
+ "Jayarathina"
+ ]
+ },
+ "config-title": "மீடியாவிக்கி $1 நிறுவுதல்",
+ "config-information": "தகவல்",
+ "config-localsettings-key": "தரமுயர்த்தல் குறியீடு:",
+ "config-localsettings-badkey": "நீங்கள் தந்த குறியீடு தவறானது.",
+ "config-your-language": "தங்களது மொழி:",
+ "config-your-language-help": "நிறுவல் செயன்முறையின்போது பயன்படுத்துவதற்கு ஒரு மொழியைத் தெரிவு செய்யவும்.",
+ "config-wiki-language": "விக்கி மொழி:",
+ "config-back": "← முந்தைய",
+ "config-continue": "தொடரவும் →",
+ "config-page-language": "மொழி",
+ "config-page-welcome": "மீடியாவிக்கிக்கு வருக !",
+ "config-page-dbconnect": "தரவுத் தளத்துடன் தொடர்பு கொள்ளவும்",
+ "config-page-dbsettings": "தரவுத் தள அமைப்புகள்",
+ "config-page-name": "பெயர்",
+ "config-page-options": "விருப்பத்தேர்வுகள்",
+ "config-page-install": "நிறுவு",
+ "config-page-complete": "நிறைவு!",
+ "config-page-restart": "நிறுவலை மீண்டும் தொடங்கவும்",
+ "config-page-readme": "இதைப் படி",
+ "config-page-releasenotes": "வெளியீட்டு குறிப்புகள்",
+ "config-page-copying": "நகலெடுக்கப்படுகிறது",
+ "config-page-upgradedoc": "தரமுயர்த்தப்படுகிறது",
+ "config-page-existingwiki": "இருக்கின்ற விக்கி",
+ "config-restart": "ஆம், மறுமுறை துவங்கு",
+ "config-sidebar": "* [https://www.mediawiki.org மீடியாவிக்கி முகப்பு]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents பயனரின் கையேடு]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents மேலாளரின் கையேடு]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ அகேகே]\n----\n* <doclink href=Readme>என்னை வாசிக்கவும்</doclink>\n* <doclink href=ReleaseNotes>வெளியீட்டுக் குறிப்புகள்</doclink>\n* <doclink href=Copying>படியெடுத்தல்</doclink>\n* <doclink href=UpgradeDoc>நிகழ்நிலைப்படுத்தல்</doclink>",
+ "config-db-type": "தரவுத்தள வகை:",
+ "config-db-wiki-settings": "இந்த விக்கியைக் கண்டுபிடி",
+ "config-db-name": "தரவுதளப் பெயர்:",
+ "config-db-install-account": "நிறுவலுக்கான பயனர் கணக்கு",
+ "config-db-username": "தரவுத்தள பயனர்பெயர்:",
+ "config-db-password": "தரவுத்தள கடவுச்சொல்:",
+ "config-db-prefix": "தரவுத் தள வரிசைப் பட்டியல் முன்னொட்டு:",
+ "config-invalid-db-type": "செல்லாத தரவுத்தள வகை",
+ "config-upgrade-done-no-regenerate": "தரமுயர்த்தல் முழுமையடைந்தது.\nநீங்கள் தற்போது [$1 உங்கள் விக்கியைப் பயன்படுத்தத் துவங்கலாம்].",
+ "config-db-web-account": "வலை அணுகலுக்கான தரவுத் தளக் கணக்கு",
+ "config-mysql-engine": "சேமிப்பு இயந்திரம்:",
+ "config-mysql-charset": "தரவுத் தள வரியுருத் தொகுதி:",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "விக்கியின் பெயர்:",
+ "config-site-name-blank": "ஒரு தளத்தின் பெயரை உள்ளிடுக.",
+ "config-ns-generic": "திட்டம்",
+ "config-ns-other": "ஏனையவை (குறிப்பிடவும்)",
+ "config-admin-box": "நிருவாகி கணக்கு",
+ "config-admin-name": "உங்களின் பயனர் பெயர்:",
+ "config-admin-password": "கடவுச்சொல்:",
+ "config-admin-password-confirm": "கடவுச்சொல் மறுமுறையும்:",
+ "config-admin-name-blank": "நிருவாக அணுக்கம் உள்ள பயனர் பெயரை இடுக.",
+ "config-admin-password-blank": "நிருவாகி கணக்குக்கு கடவுச்சொல் ஒன்றை உள்ளிடவும்.",
+ "config-admin-password-mismatch": "நீங்கள் பதிந்த கடவுச்சொற்கள் ஒன்றுக்கொன்று பொருந்தவில்லை.",
+ "config-admin-email": "மின்னஞ்சல் முகவரி:",
+ "config-admin-error-bademail": "நீங்கள் செல்லாத ஒரு மின்னஞ்சல் முகவரியைத் தந்துள்ளீர்கள்.",
+ "config-optional-continue": "என்னை இன்னும் அதிகமாக வினவு.",
+ "config-optional-skip": "நான் ஏற்கனவே சோர்வடைந்துள்ளேன், விக்கியை மட்டும் உருவாக்கு.",
+ "config-profile": "பயனர் உரிமைகள் சுயவிவரம்:",
+ "config-profile-wiki": "பாரம்பரிய விக்கி",
+ "config-profile-no-anon": "கணக்கு உருவாக்குதல் அவசியம்",
+ "config-profile-private": "தனியார் விக்கி",
+ "config-license": "பதிப்புரிமை மற்றும் உரிமம்:",
+ "config-license-pd": "பொதுக்களம்",
+ "config-email-settings": "மின்னஞ்சல் அமைப்புகள்",
+ "config-email-user": "பயனர்-பயனர் மின்னஞ்சலைச் செயற்படுத்தவும்",
+ "config-email-usertalk": "பயனர் பேச்சுப் பக்க அறிவிப்பைச் செயற்படுத்தவும்",
+ "config-email-watchlist": "கவனிப்புப் பட்டியல் அறிவிப்பைச் செயற்படுத்தவும்",
+ "config-upload-settings": "படிமம் மற்றும் கோப்பு பதிவேற்றங்கள்",
+ "config-upload-enable": "கோப்புப் பதிவேற்றங்களைச் செயற்படுத்தவும்",
+ "config-upload-deleted": "அழித்த கோப்புகளுக்கான அடைவு:",
+ "config-logo": "அடையாளச் சின்ன உரலி:",
+ "config-extensions": "நீட்சிகள்",
+ "config-install-step-done": "முடிந்தது",
+ "config-install-step-failed": "தோல்வியுற்றது",
+ "config-install-user": "தரவுத் தளப் பயனரை உருவாக்குகிறது",
+ "config-install-user-alreadyexists": "பயனர் \"$1\" ஏற்கனவே உள்ளது",
+ "config-install-tables": "வரிசைப் பட்டியல்களை உருவாக்குகிறது",
+ "config-install-mainpage": "இயல்புநிலை உள்ளடக்கத்துடன் முதற்பக்கத்தை உருவாக்குகிறது",
+ "config-install-extension-tables": "செயற்படுத்தப்பட்ட நீட்சிகளுக்கு வரிசைப் பட்டியல்களை உருவாக்குகிறது",
+ "config-download-localsettings": "<code>LocalSettings.php</code>ஐத் தரவிறக்கவும்",
+ "config-help": "உதவி",
+ "mainpagetext": "'''விக்கி மென்பொருள் வெற்றிகரமாக உள்ளிடப்பட்டது.'''",
+ "mainpagedocfooter": "விக்கி மென்பொருளைப் பயன்படுத்துவது தொடர்பாக [https://meta.wikimedia.org/wiki/Help:Contents பயனர் வழிகாட்டியைப்] பார்க்க.\n\n== தொடக்கப்படிகள் ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings அமைப்புக்களை மாற்றம் செய்தல்]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ மிடியாவிக்கி பொதுவான கேள்விகள்]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce மீடியாவிக்கி வெளியீடு மின்னஞ்சல் பட்டியல்]"
+}
diff --git a/www/wiki/includes/installer/i18n/tcy.json b/www/wiki/includes/installer/i18n/tcy.json
new file mode 100644
index 00000000..6ef4dd3f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tcy.json
@@ -0,0 +1,51 @@
+{
+ "@metadata": {
+ "authors": [
+ "VASANTH S.N.",
+ "Vishwanatha Badikana"
+ ]
+ },
+ "config-desc": "ಮೀಡಿಯ ವಿಕಿದ ಪ್ರತಿಸ್ಟಾಪನೆ",
+ "config-title": "ಮೀಡಿಯಾವಿಕಿ ಆವೃತ್ತಿ $1 ರ ಪ್ರತಿಸ್ಟಾಪನೆ",
+ "config-information": "ಮಾಹಿತಿ",
+ "config-localsettings-upgrade": "ಒಂಜಿ<code>LocalSettings.php</code>ದ ಕಡತೊ ಪತ್ತೆ ಆತ್ಂಡ್. ಈ ಪ್ರತಿಸ್ಟಾಪನೆನ್ ಪೊಸತ್ ಮಲ್ಪೆರೆ, ದಯದೀಡ್ದ್ ತಿರ್ತ್‌ದ ಪಟ್ಟಿಗೆದ <code>$wgUpgradeKey</code>ದ ಬಿಲೆನ್ ನಮೂದಿಸಲೆ.\nಈರ್ <code>LocalSettings.php</code>ನ್ ನಾಡೊಲಿ",
+ "config-localsettings-cli-upgrade": "ಒಂಜಿ<code>LocalSettings.php</code>ದ ಕಡತೊ ಪತ್ತೆ ಆತ್ಂಡ್. ಈ ಪ್ರತಿಸ್ಟಾಪನೆನ್ ಪೊಸತ್ ಮಲ್ಪೆರೆ, ದಯದೀಡ್ದ್ ತಿರ್ತ್‌ದ ಪಟ್ಟಿಗೆದ <code>$wgUpgradeKey</code>ದ ಬಿಲೆನ್ ನಮೂದಿಸಲೆ.\nದಯಮಲ್ತ್ <code>LocalSettings.php</code>ಗ್ ಬದಲಾದ್ ಬಲಿಪಾಲೆ",
+ "config-localsettings-key": "ಏಲಿಗೆದ ಕೀ",
+ "config-session-error": "ಸಬೆ ಸುರುಮಲ್ಪುನ ದೋಸೊ: $1",
+ "config-your-language": "ಇರೆನಾ ಬಾಸೆ",
+ "config-wiki-language": "ವಿಕಿ ಬಾಸೆ:",
+ "config-back": "← ಪಿರ",
+ "config-continue": "ಮುಂದುವರೆಸಾಲೆ →",
+ "config-page-language": "ಬಾಸೆ",
+ "config-page-welcome": "ಮಾಧ್ಯಮವಿಕಿಗ್ ಸ್ವಾಗತ",
+ "config-page-dbconnect": "ದತ್ತಾಂಸೊ ಸಂಚಯೊಗ್ ಕೊಲಿಕೆಕೊರ್ಲೆ",
+ "config-page-name": "ಪುದರ್",
+ "config-page-options": "ಆಯ್ಕೆಲು",
+ "config-page-install": "ಸ್ಥಾಪಿಸಾಲೆ",
+ "config-page-complete": "ಮುಗಿಂಡ್!",
+ "config-page-readme": "ಎನನ್ ಓದುಲೆ",
+ "config-page-releasenotes": "ಬುಡುಗಡೆದ ಟಿಪ್ಪಣಿಲು",
+ "config-page-copying": "ನಕಲ್ ಮಲ್ತೊಂದುಂಡು",
+ "config-page-upgradedoc": "ಪರಿಷ್ಕರಣೆ ಆವೊಂದುಂಡು",
+ "config-page-existingwiki": "ಇತ್ತೆದ ವಿಕಿ",
+ "config-restart": "ಸರಿ,ಕುಡ ಸುರು ಮಲ್ಪುಲೆ",
+ "config-db-type": "ದತ್ತಾಂಶಸಂಚಯ ಮಾದರಿ:",
+ "config-db-host-oracle": "ದತ್ತಾಂಶಸಂಚಯ TNS:",
+ "config-db-wiki-settings": "ಈ ವಿಕಿಯನ್ನು ಗುರುತಿಸಾಲೆ",
+ "config-db-name": "ದತ್ತಾಂಶಸಂಚಯ ಪುದರ್:",
+ "config-db-username": "ದತ್ತಾಂಶಸಂಚಯ ಪುದರ್:",
+ "config-db-password": "ದತ್ತಾಂಶಸಂಚಯ ಪ್ರವೇಶಪದ:",
+ "config-ns-generic": "ಯೋಜನೆ",
+ "config-admin-password": "ಪ್ರವೇಶ ಪದೊ",
+ "config-admin-password-confirm": "ಪುನಃ ಪ್ರವೇಶ ಪದೊ:",
+ "config-admin-password-mismatch": "ಈರ್ ಕೊರ್ನ ಪ್ರವೇಶ ಪದೆ ಬೇತೆ ಬೇತೆ ಅತ್ಂಡ್",
+ "config-admin-email": "ಇ-ಅಂಚೆ ವಿಳಾಸೊ",
+ "config-license": "ಕೃತಿಸ್ವಾಮ್ಯ ಬೊಕ್ಕ ಪರವಾನಗಿ:",
+ "config-license-pd": "ಸಾರ್ವಜನಿಕೊ ಕ್ಷೇತ್ರೊ",
+ "config-extensions": "ವಿಸ್ತರಣೆಲು",
+ "config-install-step-failed": "ವಿಫಲವಾತ್‍ಂಡ್",
+ "config-install-extensions": "ವಿಸ್ತರಣೆಲೆನ್ ಸೇರಾದ್",
+ "config-help": "ಸಹಾಯೊ",
+ "mainpagetext": "'''ಮೀಡಿಯವಿಕಿ ಯಶಸ್ವಿಯಾದ್ ಸ್ಥಾಪನೆ ಆಂಡ್.'''",
+ "mainpagedocfooter": "ವಿಕಿ ತಂತ್ರಾಂಶನ್ ಉಪಗೋಗ ಮನ್ಪುನ ಬಗ್ಗೆ ಮಾಹಿತಿಗ್ [https://meta.wikimedia.org/wiki/Help:Contents ಸದಸ್ಯೆರ್ನ ನಿರ್ದೇಶನ ಪುಟ] ತೂಲೆ.\n\n== ಎಂಚ ಶುರು ಮಲ್ಪುನಿ ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ ಮೀಡಿಯವಿಕಿ FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/te.json b/www/wiki/includes/installer/i18n/te.json
new file mode 100644
index 00000000..1978af0d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/te.json
@@ -0,0 +1,234 @@
+{
+ "@metadata": {
+ "authors": [
+ "Veeven",
+ "Chaduvari",
+ "Ravichandra",
+ "Macofe"
+ ]
+ },
+ "config-desc": "మీడియావికీ కొరకై స్థాపకి",
+ "config-title": "మీడియావికీ $1స్థాపన",
+ "config-information": "సమాచారం",
+ "config-localsettings-upgrade": "ఓ <code>LocalSettings.php</code> ఫైలు కనబడింది.\nఈ స్థాపనను ఉన్నతీకరించడానికి, కింద ఇచ్చిన పెట్టెలో <code>$wgUpgradeKey</code> యొక్క విలువను ఇవ్వండి.\nఅది <code>LocalSettings.php</code> లో ఉంటుంది.",
+ "config-localsettings-cli-upgrade": "ఓ <code>LocalSettings.php</code> ఫైలు కనబడింది.\nఈ స్థాపనను ఉన్నతీకరించడానికి, దాని బదులు <code>update.php</code> ను రన్ చెయ్యండి.",
+ "config-localsettings-key": "ఉన్నతీకరణ కీ:",
+ "config-localsettings-badkey": "మీరిచ్చిన కీ తప్పు.",
+ "config-upgrade-key-missing": "MediaWiki యొక్క ఒక స్థాపన కనబడింది.\nదాన్ని ఉన్నతీకరించడానికి, కింది లైనును <code>LocalSettings.php</code> లో అట్టడుగున ఉంచండి:\n\n$1",
+ "config-localsettings-incomplete": "ఇప్పటి <code>LocalSettings.php</code> అసంపూర్తిగా ఉన్నట్లుగా కనబడుతోంది.\n$1 చరరాశిని సెట్ చెయ్యలేదు.\nఈ చరరాశిని సెట్ చేస్తూ <code>LocalSettings.php</code> ను మార్చి, \"{{int:Config-continue}}\" ను నొక్కండి.",
+ "config-localsettings-connection-error": "<code>LocalSettings.php</code> లో ఇచ్చిన సెట్టింగులను వాడుతూ డేటాబేసుకు కనెక్టు కాబోతే, లోపం తలెత్తింది. ఈ సెట్టింగులను సరిచేసి మళ్ళీ ప్రయత్నించండి.\n\n$1",
+ "config-session-error": "సెషన్ను ప్రారంభించబోతే లోపం జరిగింది: $1",
+ "config-session-expired": "మీ సెషన్ డేటాకు కాలదోషం పట్టినట్లుంది.\nసెషన్ల జీవితకాలం $1 ఉండేలా అమర్చబడ్డాయి.\nphp.ini లో <code>session.gc_maxlifetime</code> ను మార్చి దీన్ని పెంచవచ్చు.\nస్థాపన పనిని తిరిగి మొదలుపెట్టండి.",
+ "config-no-session": "మీ సెషను డేటా పోయింది!\nphp.ini లో <code>session.save_path</code> సరైన డైరెక్టరీకి సెట్ చేసి ఉందో లేదో చూడండి.",
+ "config-your-language": "మీ భాష:",
+ "config-your-language-help": "స్థాపన పనిలో వాడేందుకు ఓ భాషను ఎంచుకోండి.",
+ "config-wiki-language": "వికీ భాష:",
+ "config-wiki-language-help": "వికీని ప్రధానంగా ఏ భాషలో రాయాలో ఎంచుకోండి.",
+ "config-back": "← వెనక్కి",
+ "config-continue": "కొనసాగించు →",
+ "config-page-language": "భాష",
+ "config-page-welcome": "మీడియావికీకి స్వాగతం!",
+ "config-page-dbconnect": "డేటాబేసుకు కనెక్టవు",
+ "config-page-upgrade": "ప్రస్తుత స్థాపనను ఉన్నతీకరించు",
+ "config-page-dbsettings": "డాటాబేసు అమరికలు",
+ "config-page-name": "పేరు",
+ "config-page-options": "ఎంపికలు",
+ "config-page-install": "స్థాపించు",
+ "config-page-complete": "పూర్తయ్యింది!",
+ "config-page-restart": "స్థాపనను తిరిగి ప్రారంభించు",
+ "config-page-readme": "నన్ను చదవండి",
+ "config-page-releasenotes": "విడుదల విశేషాలు",
+ "config-page-copying": "కాపీ చేస్తున్నాం",
+ "config-page-upgradedoc": "ఉన్నతీకరిస్తున్నాం",
+ "config-page-existingwiki": "ప్రస్తుత వికీ",
+ "config-help-restart": "మీరు భద్రపరిచిన డేటా మొత్తాన్ని తీసివేసి స్థాపనను తిరిగి ప్రారంభించాలా?",
+ "config-restart": "ఔను, తిరిగి ప్రారంభించు",
+ "config-welcome": "=== పర్యావరణ పరీక్షలు ===\nఈ పర్యావరణం MediaWiki స్థాపనకు అనుకూలంగా ఉందో లేదో చూసే ప్రాథమిక పరీక్షలు ఇపుడు చేస్తాం.\nస్థాపనను ఎలా పూర్తి చెయ్యాలనే విషయమై మీకు సహాయం అడిగేటపుడు, ఈ సమాచారాన్ని ఇవ్వాలని గుర్తుంచుకోండి.",
+ "config-copyright": "=== కాపీహక్కు, నిబంధనలు===\n\n$1\n\nఇది ఉచిత సాఫ్ట్‌వేరు; ఫ్రీ సాఫ్ట్‌వేర్ ఫౌండేషన్ వారు ప్రచురించిన GNU జనరల్ పబ్లిక్ లైసెన్సును (2వ లేదా తరువాతి వర్షన్) అనుసరించి దీన్ని పంపిణీ చెయ్యవచ్చు లేదా మార్చుకోనూవచ్చు.\n\nదీని వలన ఉపయోగం ఉంటుందనే నమ్మకంతో ప్రచురింపబడింది. కానీ <strong>ఎటువంటి వారంటీ లేదు</strong>; <strong> వర్తకం చేయదగ్గ </strong> లేదా <strong> ఒక అవసరానికి సరిపడే సామర్థ్యం</strong> ఉన్నదనే అంతరార్థ వారంటీ కూడా లేదు.\nమరిన్ని వివరాలకు GNU జనరల్ పబ్లిక్ లైసెన్స్ చూడండి.\n\nమీరు ఈ ప్రోగ్రాముతో పాటు <doclink href=Copying> GNU జనరల్ పబ్లిక్ లైసెన్స్ ప్రతిని </doclink> అందుకుని ఉండాలి; లేకపోతే, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA కు జాబు రాయండి లేదా [http://www.gnu.org/copyleft/gpl.html ఆన్‌లైన్‌లో చదివండి].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki మొదటిపేజీ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents వాడుకరుల మార్గదర్శి]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents అధికారుల మార్గదర్శి]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>చదవాల్సినవి</doclink>\n* <doclink href=ReleaseNotes>విడుదల గమనికలు</doclink>\n* <doclink href=Copying>కాపీ చెయ్యడం</doclink>\n* <doclink href=UpgradeDoc>ఉన్నతీకరించడం</doclink>",
+ "config-env-good": "పర్యావరణాన్ని పరీక్షించాం.\nఇక మీరు MediaWiki ని స్థాపించుకోవచ్చు.",
+ "config-env-bad": "పర్యావరణాన్ని పరీక్షించాం.\nమీరు MediaWiki ని స్థాపించలేరు.",
+ "config-env-php": "PHP $1 స్థాపించబడింది.",
+ "config-unicode-using-intl": "యూనికోడు నార్మలైజేషన్ కోసం [http://pecl.php.net/intl intl PECL పొడిగింత] ను వాడుతున్నాం.",
+ "config-outdated-sqlite": "<strong>హెచ్చరిక:</strong> మీ వద్ద SQLite $1 ఉంది. అదికావలసిన వెర్షను $2 కంటే దిగువది. SQLite అందుబాటులో ఉండదు.",
+ "config-memory-raised": "PHP యొక్క <code>memory_limit</code> $1, దాన్ని $2 కి పెంచాం.",
+ "config-memory-bad": "<strong>హెచ్చరిక:</strong> PHP యొక్క <code>memory_limit</code> $1.\nబహుశా ఇది మరీ తక్కువ.\nస్థాపన విఫలం కావచ్చు!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] స్థాపించబడింది",
+ "config-apc": "[http://www.php.net/apc APC] స్థాపించబడింది",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] స్థాపించబడింది",
+ "config-diff3-bad": "GNU diff3 కనబడలేదు.",
+ "config-no-uri": "<strong>లోపం:</strong> ప్రస్తుత URI ఏమిటో నిర్ధారించలేకపోయాం.\nస్థాపన ఆగిపోయింది.",
+ "config-using-server": "సర్వరు పేరు \"<nowiki>$1</nowiki>\" ను వాడుతున్నాం.",
+ "config-using-uri": "సర్వరు URL \"<nowiki>$1$2</nowiki>\" ను వాడుతున్నాం.",
+ "config-db-type": "డాటాబేసు రకం:",
+ "config-db-host": "డేటాబేసు హోస్టు:",
+ "config-db-host-help": "మీ డేటాబేసు సర్వరు వేరే సర్వరులో ఉంటే, దాని హోస్ట్ పేరు, ఐపీ చిరునామా ఇక్కడ ఇవ్వండి.\n\nమీరు షేర్‍డ్ వెబ్ హోస్టింగును వాడుతూంటే, మీ హోస్టింగు సేవను అందించేవారు తమ డాక్యుమెంటేషనులో సరైన హోస్ట్ పేరును ఇచ్చి ఉండాలి.\n\nమీరు విండోస్ సర్వరులో స్థాపిస్తూ, MySQL వాడుతూ ఉంటే, సర్వరు పేరుగా \"localhost\" పనిచెయ్యకపోవచ్చు. అపుడు, స్థానిక ఐపీ చిరునామాగా \"127.0.0.1\" వాడండి.\n\nమీరు PostgreSQL వాడుతూ ఉంటే, Unix సాకెట్ ద్వారా కనెక్టయేందుకు ఈ ఫీల్డును ఖాళీగా వదిలెయ్యండి.",
+ "config-db-host-oracle": "డేటాబేసు TNS:",
+ "config-db-wiki-settings": "ఈ వికీ గుర్తింపును ఇవ్వండి",
+ "config-db-name": "డాటాబేసు పేరు:",
+ "config-db-name-help": "మీ వికీని సూచించే విధంగా ఓ పేరును ఎంచుకోండి.\nదానిలో స్పేసులు ఉండరాదు.\n\nమీరు షేర్‍డ్ వెబ్ హోస్టింగును వాడుతూంటే, మీకు హోస్టింగు సేవనందించేవారు మీకు ఓ డేటాబేసు పేరును గాని, లేదా కంట్రోలు ప్యానెలు ద్వారా ఓ డేటాబేసును సృష్టించుకునే వీలునుగానీ ఇస్తారు.",
+ "config-db-name-oracle": "డేటాబేసు స్కీమా:",
+ "config-db-install-account": "స్థాపనకి వాడుకరి ఖాతా",
+ "config-db-username": "డేటాబేసు వాడుకరిపేరు:",
+ "config-db-password": "డేటాబేసు సంకేతపదం:",
+ "config-db-install-username": "స్థాపన దశలో డేటాబేసుకు కనెక్టయ్యేందుకు వాడే వాడుకరిపేరును ఇవ్వండి.\nఇది MediaWiki ఖాతా యొక్క వాడుకరిపేరు కాదు; మీ డేటాబేసు కోసం వాడుకరిపేరు.",
+ "config-db-install-password": "స్థాపన దశలో డేటాబేసుకు కనెక్టయ్యేందుకు వాడే సంకేతపదాన్ని ఇవ్వండి.\nఇది MediaWiki ఖాతా యొక్క సంకేతపదం కాదు; మీ డేటాబేసు కోసం సంకేతపదం.",
+ "config-db-install-help": "స్థాపన దశలో డేటాబేసుకు కనెక్టయ్యేందుకు వాడే వాడుకరిపేరు, సంకేతపదం ఇవ్వండి.",
+ "config-db-account-lock": "అదే వాడుకరిపేరును, సంకేతపదాన్ని మామూలు వాడుకలో కూడా వాడు",
+ "config-db-wiki-account": "మామూలు వాడుక కోసం వాడుకరి ఖాతా",
+ "config-db-wiki-help": "మామూలుగా వికీ పనిచేసేటపుడు వాడే డేటాబేసును కనెక్టయేందుకు వాడే వాడుకరిపేరును, సంకేతపదాన్నీ ఎంచుకోండి.\nఒకవేళ ఈ ఖాతా ఉనికిలో లేకపోతే, స్థాపన కోసం వాడుతున్న ఖాతాకు తగు విధమైన అనుమతులు ఉన్న పక్షంలో, ఈ ఖాతాను సృష్టిస్తాం. ఈ కొత్త ఖాతాకు వికీని నడిపేందుకు అవసరమైన అనుమతులను మాత్రం ఇస్తాం.",
+ "config-db-prefix": "డేటాబేసు టెబులు ఆదిపదం:",
+ "config-db-prefix-help": "ఒకటి కంటే ఎక్కువ వికీలను గానీ, లేదా మీడియావికీ తో పాటు మరో వెబ్ అప్లికేషన్ను గానీ ఒకే డేటాబేసు నుండి వాడదలిస్తే, టేబులు పేర్లకు ముందు ఓ ఆదిపదాన్ని (ప్రిఫిక్స్) ను ఎంచుకోండి. ఈ విధంగా పేర్ల ఘర్షణను నివారింవచ్చు. \nస్పేసులను వాడకండి.\n\nఈ ఫీల్డును సాధారణంగా ఖాళీగా ఉంచేస్తారు.",
+ "config-mysql-old": "MySQL $1 గానీ ఆ తరువాతిది గానీ కావాలి. మీకు $2 ఉంది.",
+ "config-db-port": "డేటాబేసు పోర్టు:",
+ "config-db-schema": "MediaWiki కొరకు స్కీమా:",
+ "config-db-schema-help": "మామూలుగా ఈ స్కీమా సరిపోతుంది.\nఅవసరమని మీకు తెలిస్తేనే మార్చండి.",
+ "config-pg-test-error": "డేటాబేసు <strong>$1</strong> కి కనెక్టు కాలేకపోయాం: $2",
+ "config-sqlite-dir": "SQLite డేటా డైరెక్టరీ:",
+ "config-oracle-def-ts": "డిఫాల్టు టేబుల్‍స్పేసు:",
+ "config-oracle-temp-ts": "తాత్కాలిక టేబుల్‍స్పేసు:",
+ "config-type-mysql": "MySQL (లేదా సరిపోయేది)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki కింది డేటాబేసు వ్యవస్థలకు అనుకూలిస్తుంది:\n\n$1\n\nమీరు వాడదలచిన డేటాబేసు వ్యవస్ కింది జాబితాలో లేకపోతే, పైన లింకు ద్వారా ఇచ్చిన సూచనలను పాటించి, అనుకూలతలను సాధించండి.",
+ "config-dbsupport-postgres": "* MySQL కు ప్రత్యామ్నాయంగా [{{int:version-db-postgres-url}} PostgreSQL] ప్రజామోదం పొందిన ఓపెన్‍సోర్సు డేటాబేసు వ్యవస్థ. దానిలో చిన్న చితకా లోపాలుండే అవకాశం ఉంది. అందుచేత దాన్ని ఉత్పాదక రంగంలో వాడవచ్చని చెప్పలేం. ([http://www.php.net/manual/en/pgsql.installation.php How to compile PHP with PostgreSQL support])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] ఓ తేలికైన డేటాబేసు వ్యవస్థ. దానికి చక్కటి అనుకూలతలున్నాయి. ([http://www.php.net/manual/en/pdo.installation.php How to compile PHP with SQLite support], uses PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] ఒక వాణిజ్యపరంగా సంస్థాగతంగా వాడదగ్గ డేటాబేసు. ([http://www.php.net/manual/en/oci8.installation.php How to compile PHP with OCI8 support])",
+ "config-header-mysql": "MySQL అమరికలు",
+ "config-header-postgres": "PostgreSQL అమరికలు",
+ "config-header-sqlite": "SQLite అమరికలు",
+ "config-header-oracle": "Oracle అమరికలు",
+ "config-header-mssql": "Microsoft SQL Server అమరికలు",
+ "config-invalid-db-type": "తప్పుడు డాటాబేసు రకం",
+ "config-missing-db-name": "\"{{int:config-db-name}}\" ను తప్పకుండా ఇవ్వాలి",
+ "config-missing-db-host": "\"{{int:config-db-host}}\" ను తప్పకుండా ఇవ్వాలి",
+ "config-missing-db-server-oracle": "\"{{int:config-db-host-oracle}}\" ను తప్పకుండా ఇవ్వాలి",
+ "config-invalid-db-name": "డేటాబేసు పేరు సరైనది కాదు \"$1\".\nASCII అక్షరాలు (a-z, A-Z), అంకెలు (0-9), క్రీగీత (_) and హైఫన్ (-) లను మాత్రమే వాడాలి.",
+ "config-invalid-db-prefix": "డేటాబేసు ఆదిపదం (ప్రిఫిక్స్) సరైనది కాదు \"$1\".\nASCII అక్షరాలు (a-z, A-Z), అంకెలు (0-9), క్రీగీత (_) and హైఫన్ (-) లను మాత్రమే వాడాలి.",
+ "config-connection-error": "$1.\n\nక్రింది హోస్టు, వాడుకరిపేరు మరియు సంకేతపదాలను ఒకసారి సరిచూసుకుని అప్పుడు ప్రయత్నించండి.",
+ "config-invalid-schema": "\"$1\" MediaWiki కోసం చెల్లని స్కీమా.\nASCII అక్షరాలు (a-z, A-Z), అంకెలు (0-9) క్రీగీత (_) లను మాత్రమే వాడాలి.",
+ "config-db-sys-user-exists-oracle": "వాడుకరి ఖాతా \"$1\" ఈసరికే ఉంది. కొత్త ఖాతాను సృష్టించేందుకు SYSDBA ను మాత్రమే వాడాలి!",
+ "config-postgres-old": "PostgreSQL $1 గానీ ఆ తరువాతిది గానీ అవసరం. మీకు $2 ఉంది.",
+ "config-mssql-old": "మైక్రోసాఫ్ట్ SQL సర్వర్ $1 లేదీ దాని తరువాతి వర్షన్ ఉండాలి. మీ దగ్గర $2 ఉంది.",
+ "config-sqlite-name-help": "మీ వికీని గుర్తించే పేరు ఒకదాన్ని ఎంచుకోండి.\nస్పేసులు గానీ, హైఫన్‍లు గానీ వాడకండి.\nదాన్ని SQLite డేటాఫైలు పేరు కోసంవాడతాం.",
+ "config-sqlite-mkdir-error": "డేటా డైరెక్టరీని సృష్టించడంలో లోపం \"$1\".\nస్థానాన్ని సరిచూసి మళ్ళీ ప్రయత్నించండి.",
+ "config-sqlite-connection-error": "$1.\n\nకింద ఉన్న డేటా డైరెక్టరీ, డేటాబేసు పేరును సరిచూసి మళ్ళీ ప్రయత్నించండి.",
+ "config-sqlite-readonly": "ఫైలు <code>$1</code> లో రాసే అనుమతి లేదు.",
+ "config-sqlite-cant-create-db": "డేటాబేసు ఫైలు <code>$1</code> సృష్టించలేకపోయాం.",
+ "config-upgrade-done-no-regenerate": ".ఉన్నతీకరణ పూర్తయింది.\n\nఇక మీరు [$1 మీ వికీలో పని మొదలుపెట్టవచ్చు].",
+ "config-regenerate": "LocalSettings.php ని తిరిగి సృజించు →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code> క్వెరీ విఫలమైంది!",
+ "config-unknown-collation": "<strong>హెచ్చరిక:</strong> డేటాబేసు గుర్తింపులేని కొల్లేషన్ వాడుతున్నది.",
+ "config-db-web-account": "వెబ్ అందుబాటు కోసం డేటాబేసు ఖాతా",
+ "config-db-web-help": "మామూలుగా వికీని నడిపేటపుడు, వెబ్ సర్వరు డేటాబేసును కనెక్టయేందుకు వాడే వాడుకరిపేరు, సంకేతపదాలను ఎంచుకోండి.",
+ "config-db-web-account-same": "స్థాపనకు వాడిన ఖాతానే వాడు",
+ "config-db-web-create": "ఖాతా ఉనికిలో లేకపోతే, సృష్టించు",
+ "config-db-web-no-create-privs": "స్థాపన కోసం మీరిచ్చిన ఖాతాకు ఓ కొత్త ఖాతాను సృష్టించే అనుమతులు లేవు.\nఇక్కడ మీరిచ్చే ఖాతా తప్పనిసరిగా ఈసరికే ఉనికిలో ఉండాలి.",
+ "config-mysql-engine": "స్టోరేజీ ఇంజను:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "డేటాబేసు కారెక్టరు సెట్:",
+ "config-mysql-binary": "బైనరీ",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "ఆథెంటికేషన్ రకం:",
+ "config-mssql-sqlauth": "SQL Server ఆథెంటికేషన్",
+ "config-mssql-windowsauth": "విండోస్ ఆథెంటికేషన్",
+ "config-site-name": "వికీ యొక్క పేరు:",
+ "config-site-name-help": "ఇది బ్రౌజరు టిటిలుబారు లోను, అనేక ఇతర చోట్లా కనిపిస్తుంది.",
+ "config-site-name-blank": "ఓ సైటు పేరును ఇవ్వండి.",
+ "config-project-namespace": "ప్రాజెక్టు పేరుబరి:",
+ "config-ns-generic": "ప్రాజెక్టు",
+ "config-ns-site-name": "వికీ పేరే: $1",
+ "config-ns-other": "ఇతర (ఇవ్వండి)",
+ "config-ns-other-default": "నావికీ",
+ "config-ns-invalid": "ఇచ్చిన పేరుబరి \"<nowiki>$1</nowiki>\" చెల్లనిది.\nవేరే ప్రాజెక్టు పేరుబరిని ఇవ్వండి.",
+ "config-ns-conflict": "ఇచ్చిన పేరుబరి \"<nowiki>$1</nowiki>\" డిఫాల్టు MediaWiki పేరుబరి ఒకదానితో ఘర్షిస్తోంది.\nవేరే ప్రాజెక్టు పేరుబరిని ఇవ్వండి.",
+ "config-admin-box": "నిర్వాహకుని ఖాతా",
+ "config-admin-name": "మీ వాడుకరి పేరు:",
+ "config-admin-password": "సంకేతపదం:",
+ "config-admin-password-confirm": "సంకేతపదం మళ్ళీ:",
+ "config-admin-name-blank": "ఓ నిర్వాహక వాడుకరిపేరును ఇవ్వండి",
+ "config-admin-name-invalid": "ఇచ్చిన వాడుకరిపేరు \"<nowiki>$1</nowiki>\" చెల్లనిది.\nవేరే వాడుకరిపేరును ఇవ్వండి.",
+ "config-admin-password-blank": "నిర్వాహక ఖాతాకు సంకేతపదం ఇవ్వండి.",
+ "config-admin-password-mismatch": "మీరిచ్చిన రెండు సంకేతపదాలు సరిపోలడం లేదు.",
+ "config-admin-email": "ఈ-మెయిలు చిరునామా:",
+ "config-admin-error-user": "\"<nowiki>$1</nowiki>\" పేరుతో నిర్వాహకుణ్ణి సృష్టించబోతే అంతర్గత లోపం దొర్లింది.",
+ "config-admin-error-password": "నిర్వాహకుడు \"<nowiki>$1</nowiki>\" కు సంకేతపదాన్ని ఇవ్వబోతే అంతర్గత లోపం దొర్లింది: <pre>$2</pre>",
+ "config-admin-error-bademail": "మీరు చెల్లని ఈమెయిలు చిరునామా ఇచ్చారు.",
+ "config-subscribe": "[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce విడుదల ప్రకటనల మెయిలింగు జాబితా] కు చందాదారులు కండి.",
+ "config-almost-done": "దాదాపు పూర్తైనట్లే!\nమిగతా కాన్ఫిగరేషన్ను దాటేసి, ఇప్పుడే వికీని స్థాపించుకోవచ్చు.",
+ "config-optional-continue": "నన్ను మరిన్ని ప్రశ్నలు అడుగు.",
+ "config-optional-skip": "నాకు బోరు కొట్టేసింది, ఇక వికీని స్థాపించేయ్.",
+ "config-profile": "వాడుకరి హక్కుల ప్రవర:",
+ "config-profile-wiki": "వికీని తెరువు",
+ "config-profile-no-anon": "ఖాతా సృష్టింపు తప్పనిసరి",
+ "config-profile-fishbowl": "అధీకృత వాడుకరులు మాత్రమే",
+ "config-profile-private": "అంతరంగిక వికీ",
+ "config-license": "కాపీహక్కులు మరియు లైసెన్సు:",
+ "config-license-none": "లైసెన్సు పాదపీఠిక వద్దు",
+ "config-license-pd": "సార్వజనీనం",
+ "config-email-settings": "ఈ-మెయిల్ అమరికలు",
+ "config-enable-email": "ఈమెయిళ్ళు పంపడాన్ని చేతనం చెయ్యి",
+ "config-email-user": "వాడుకరి-నుండి-వాడుకరికి ఈమెయిళ్ళని చేతనం చెయ్యి",
+ "config-email-user-help": "వాడుకరులంతా తమ తమ అభిరుచుల్లో సెట్ చేసుకుని ఉంటే, ప్రతి ఒక్కరూ ప్రతీ ఒక్కరికీ ఈమెయిళ్ళు పంపించుకునే వీలును కల్పించు.",
+ "config-email-usertalk": "వాడుకరి చర్చా పేజీ వార్తాహరిని చేతనం చెయ్యి",
+ "config-email-usertalk-help": "వాడుకరులు వారి ప్రాధాన్యతలలో చేతనం చేస్తే వారి వీక్షించే పేజీలు గురించి నోటిఫికేషన్లు అందుకోవడానికి అనుమతించు.",
+ "config-email-watchlist": "వీక్షణజాబితా నోటిఫికేషన్లను చేతనం చేయి",
+ "config-email-watchlist-help": "వాడుకరులు వారి ప్రాధాన్యతలలో చేతనం చేస్తే వారి వీక్షించే పేజీలు గురించి నోటిఫికేషన్లు అందుకోవడానికి అనుమతించు.",
+ "config-email-auth": "ఈమెయిల్ ప్రమాణీకరణను చేతనం చేయండి",
+ "config-email-auth-help": "దీన్ని ఎంచుకుంటే, వాడుకరులు ఈమెయిలు కొత్తగా ఇచ్చేటపుడు లేదా మార్చేటపుడు తమకు వచ్చిన లింకు నొక్కి తమ చిరునామాను నిర్ధారించాలి.\nనిర్ధారించిన ఈమెయిలు చిరునామాలు మాత్రమే ఇతర వాడుకరుల నుంచి, మార్పు నోటిఫికేషన్లు అందుకునే వీలుంది.\nబహిరంగ వికీలలో దీన్ని ఎంచుకోవడం <strong>ఉత్తమమైన</strong> పద్ధతి. ఎందుకంటే మీ ఈమెయిలును ఎవరూ దుర్వినియోగం చేయలేరు.",
+ "config-email-sender": "తిరుగు టపా చిరునామా:",
+ "config-upload-settings": "బొమ్మలు, ఫైళ్ళ ఎక్కింపులు",
+ "config-upload-enable": "ఫైళ్ళ ఎక్కింపును చేతనం చెయ్యి",
+ "config-upload-deleted": "తొలగించిన దస్త్రాల కొరకు సంచయం:",
+ "config-upload-deleted-help": "తొలగించిన ఫైళ్ళను ఏ డైరెక్టరీలో అటకెక్కించాలో ఎంచుకోండి.\nఇది వెబ్‍లో అందుబాటులో లేకుండా ఉంటే మంచిది.,",
+ "config-logo": "లోగో URL:",
+ "config-instantcommons": "తక్షణ కామన్స్ ను చేతనం చెయ్యి",
+ "config-cc-again": "మళ్ళీ ఎంచుకోండి...",
+ "config-cc-not-chosen": "ఏ Creative Commons లైసెన్సు కావాలో ఎంచుకుని \"proceed\" ను నొక్కండి.",
+ "config-advanced-settings": "ఉన్నత స్వరూపణం",
+ "config-cache-options": "ఆబ్జక్ట్ క్యాషింగ్ అమరికలు:",
+ "config-cache-help": "ఆబ్జక్ట్ క్యాషింగ్ అనేది తరచు వాడే డేటాను సిద్ధంగా ఉంచడం ద్వారా మీడియావికీ పనితీరును మెరుగుపరచడానికి ఉద్దేశించినది.\nమధ్యతరగతి నుంచి పెద్ద సైట్లలో దీనిని చేతనం చేయడాన్ని ప్రోత్సహిస్తున్నాం. అలాగే చిన్న సైట్లు కూడా దీన్నుంచి ప్రయోజనం పొందగలవు.",
+ "config-memcached-help": "Memcached కోసం వాడాల్సిన ఐపీ చిరునామాలు.\nవరస కొకటి రాయాలి. పోర్టును కూడా సూచించాలి. ఉదాహరణకు:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-badip": "Memcached కోసం ఇచ్చిన ఐపీ చిరునామా చెల్లనిది: $1.",
+ "config-memcache-noport": "Memcached సర్వరు కోసం వాడేందుకు పోర్టును ఇవ్వలేదు: $1.\nమీకు పోర్టు తెలీనట్లైతే, డిఫాల్టు పోర్టు: 11211.",
+ "config-memcache-badport": "Memcached పోర్టు సఖ్యలు $1, $2 ల మధ్య ఉండాలి.",
+ "config-extensions": "పొడిగింతలు",
+ "config-extensions-help": "పైన చూపిన పొడిగింతలు మీ <code>./extensions</code> డైరెక్టరీలో ఉన్నాయి.\n\nవాటికి అదనంగా కాన్ఫిగరేషన్ అవసరం కావచ్చు. అయితే మీరు వాటిని చేతనం చెయ్యవచ్చు.",
+ "config-skins": "అలంకారాలు",
+ "config-install-alreadydone": "<strong>హెచ్చరిక:</strong> మీరు ఈసరికే MediaWiki ని స్థాపించినట్లుగా అనిపిస్తోంది. మళ్ళీ స్థాపించే ప్రయత్నం చేస్తున్నట్లున్నారు.\nతరువాత పేజీకి వెళ్ళండి.",
+ "config-install-begin": "\"{{int:config-continue}}\" నొక్కి, MediaWiki స్థాపనను మొదలుపెట్టవచ్చు.\nఇంకా మార్పులు చెయ్యదలిస్తే, \"{{int:config-back}}\" నొక్కండి.",
+ "config-install-step-done": "పూర్తయింది",
+ "config-install-step-failed": "విఫలమైంది",
+ "config-install-extensions": "పొడిగింతలను చేరుస్తున్నాం",
+ "config-install-database": "డేటాబేసును స్థాపిస్తున్నాం",
+ "config-install-schema": "స్కీమాను సృష్టిస్తున్నాం",
+ "config-install-pg-schema-not-exist": "PostgreSQL స్కీమా లేదు.",
+ "config-install-pg-schema-failed": "టేబుళ్ళ సృష్టి విఫలమైంది.\nవాడుకరి \"$1\" కు స్కీమా \"$2\" లో రాసే అనుమతి ఉన్నదని నిర్ధారించుకోండి.",
+ "config-install-pg-commit": "మార్పులను నిర్ధారిస్తున్నాం",
+ "config-install-pg-plpgsql": "PL/pgSQL భాష కోసం పరీక్షిస్తున్నాం",
+ "config-pg-no-plpgsql": "డేటాబేసు $1 లో PL/pgSQL భాషను స్థాపించాల్సిన అవసరం ఉంది.",
+ "config-pg-no-create-privs": "స్థాపన కోసం మీరిచ్చిన ఖాతాకు, ఓ ఖాతా సృష్టించేందుకు అవసరమైన హక్కులు లేవు.",
+ "config-install-user": "డేటాబేసు వాడుకరిని సృష్టిస్తున్నాం",
+ "config-install-user-alreadyexists": "వాడుకరి \"$1\" ఈసరికే ఉన్నారు",
+ "config-install-user-create-failed": "వాడుకరి \"$1\" సృష్టించడం విఫలమైంది: $2",
+ "config-install-user-grant-failed": "వాడుకరి \"$1\" కి అనుమతి ప్రసాదించడం విఫలమైంది: $2",
+ "config-install-user-missing": "సూచించిన వాడుకరి \"$1\" ఉనికిలో లేరు.",
+ "config-install-user-missing-create": "మీరిచ్చిన వాడుకరి \"$1\" ఉనికిలో లేదు.\nదాన్ని సృష్టించదలిస్తే, కింద ఉన్న \"ఖాతాను సృష్టించు\" చెక్‍బాక్సును నొక్కండి.",
+ "config-install-tables": "టేబుళ్ళను సృష్టిస్తున్నాం",
+ "config-install-tables-exist": "<strong>హెచ్చరిక:</strong> MediaWiki టేబుళ్ళు ఈసరికే ఉన్నట్లుగా ఉన్నాయి.\nసృష్టించడాన్ని తప్పిస్తున్నాం.",
+ "config-install-tables-failed": "<strong>లోపం:</strong> టేబుల్ సృష్టి ఈ లోపంతో విఫలమైంది: $1",
+ "config-install-interwiki": "డిఫాల్టు అంతరవికీ టేబులులో డేటాను పెడుతున్నాం",
+ "config-install-interwiki-list": "<code>interwiki.list</code> ఫైలును చదవలేకపోయాం.",
+ "config-install-interwiki-exists": "<strong>హెచ్చరిక:</strong> అంతర్వికీ టేబుల్ లో ఈసరికే ఎంట్రీలున్నట్లుగా ఉన్నాయి.\nడిఫాల్టు జాబితాను దాటేస్తున్నాం.",
+ "config-install-stats": "గణాంకాలను తొలికరిస్తున్నాం (ఇనిషియలైజింగ్)",
+ "config-install-keys": "రహస్య కీలను సృష్టిస్తున్నాం",
+ "config-install-sysop": "అధికారి ఖాతా సృష్టిస్తున్నాము",
+ "config-install-mainpage": "డిఫాల్టు కంటెంటుతో మొదటిపేజీని సృష్టిస్తున్నాం",
+ "config-install-extension-tables": "చేతనం చేసిన పొడిగింతల కోసం టేబుళ్ళను సృష్టిస్తున్నాం",
+ "config-install-mainpage-failed": "మొదటిపేజీని చొప్పించలేకపోయాం: $1",
+ "config-download-localsettings": "<code>LocalSettings.php</code> దించు",
+ "config-help": "సహాయం",
+ "config-nofile": "\"$1\" ఫైలు దొరకలేదు. దాన్ని గానీ తొలగించారా?",
+ "mainpagetext": "'''మీడియా వికీని విజయవంతంగా ప్రతిష్టించాం.'''",
+ "mainpagedocfooter": "వికీ సాఫ్టువేరును వాడటనికి కావలిసిన సమాచారం కోసం [https://meta.wikimedia.org/wiki/Help:Contents వాడుకరుల గైడు]ను సందర్శించండి.\n\n== మొదలు పెట్టండి ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings మీడియావికీ పనితీరు, అమరిక మార్చుకునేందుకు వీలుకల్పించే చిహ్నాల జాబితా]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ మీడియావికీపై తరుచుగా అడిగే ప్రశ్నలు]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce మీడియావికీ సాఫ్టువేరు కొత్త వెర్షను విడుదలల గురించి తెలిపే మెయిలింగు లిస్టు]"
+}
diff --git a/www/wiki/includes/installer/i18n/tet.json b/www/wiki/includes/installer/i18n/tet.json
new file mode 100644
index 00000000..1a5ea26f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tet.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "MF-Warburg"
+ ]
+ },
+ "config-page-language": "Lian",
+ "config-page-name": "Naran"
+}
diff --git a/www/wiki/includes/installer/i18n/tg-cyrl.json b/www/wiki/includes/installer/i18n/tg-cyrl.json
new file mode 100644
index 00000000..6dfcbb79
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tg-cyrl.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Ibrahim"
+ ]
+ },
+ "mainpagetext": "'''Нармафзори МедиаВики бо муваффақият насб шуд.'''",
+ "mainpagedocfooter": "Барои иттилоот дар бораи истифода нармафзори вики аз //meta.wikimedia.org/wiki/Help:Contents User's Guide] истифода баред.\n\n== Оғоз ба кор ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Феҳристи танзимоти пайгирбандӣ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Пурсишҳои МедиаВики]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Феҳристи роҳнамои МедиаВики]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Маҳалликунонии МедиаВики ба забони шумо]"
+}
diff --git a/www/wiki/includes/installer/i18n/tg-latn.json b/www/wiki/includes/installer/i18n/tg-latn.json
new file mode 100644
index 00000000..6c5390c7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tg-latn.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Liangent"
+ ]
+ },
+ "mainpagetext": "'''Narmafzori MediaViki bo muvaffaqijat nasb şud.'''",
+ "mainpagedocfooter": "Az [https://meta.wikimedia.org/wiki/Help:Contents Rohnamoi Korbaron] baroi istifodai narmafzori viki kūmak bigired.\n\n== Oƣoz ba kor ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Fehristi tanzimoti pajgirbandī]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Pursişhoi MediaViki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Fehristi rojnomahoi nusxahoi MediaViki]"
+}
diff --git a/www/wiki/includes/installer/i18n/th.json b/www/wiki/includes/installer/i18n/th.json
new file mode 100644
index 00000000..b60f2a6b
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/th.json
@@ -0,0 +1,240 @@
+{
+ "@metadata": {
+ "authors": [
+ "Korrawit",
+ "Horus",
+ "Octahedron80",
+ "Aefgh39622",
+ "Ans"
+ ]
+ },
+ "config-desc": "ตัวติดตั้ง MediaWiki",
+ "config-title": "การติดตั้ง MediaWiki $1",
+ "config-information": "ข้อมูล",
+ "config-localsettings-upgrade": "ตรวจพบไฟล์ <code>LocalSettings.php</code>\nเมื่อต้องการอัปเกรดการติดตั้งนี้ โปรดป้อนค่าของ <code>$wgUpgradeKey</code> ในกล่องด้านล่าง\nคุณสามารถพบค่านี้ได้ใน <code>LocalSettings.php</code>",
+ "config-localsettings-cli-upgrade": "ตรวจพบไฟล์ <code>LocalSettings.php</code>\nเมื่อต้องการอัปเกรดการติดตั้งนี้ โปรดเรียกใช้ <code>update.php</code> แทน",
+ "config-localsettings-key": "คีย์อัปเกรด:",
+ "config-localsettings-badkey": "คีย์อัปเกรดที่คุณระบุไม่ถูกต้อง",
+ "config-upgrade-key-missing": "ตรวจพบการติดตั้ง MediaWiki ที่มีอยู่แล้ว\nเมื่อต้องการอัปเกรดการติดตั้งนี้ โปรดใส่บรรทัดต่อไปนี้ที่ท้ายไฟล์\n<code>LocalSettings.php</code> ของคุณ:\n\n$1",
+ "config-localsettings-incomplete": "ไฟล์ <code>LocalSettings.php</code> ที่มีอยู่ดูเหมือนว่าไม่สมบูรณ์\nไม่ได้ตั้งค่าตัวแปร $1\nโปรดเปลี่ยนแปลง <code>LocalSettings.php</code> เพื่อตั้งค่าตัวแปรนี้ และคลิก \"{{int:Config-continue}}\"",
+ "config-localsettings-connection-error": "เกิดข้อผิดพลาดขึ้นเมื่อเชื่อมต่อฐานข้อมูลโดยใช้การตั้งค่าที่ระบุใน <code>LocalSettings.php</code>\nโปรดแก้ไขการตั้งค่าเหล่านี้แล้วลองใหม่อีกครั้ง\n\n$1",
+ "config-session-error": "ข้อผิดพลาดการเริ่มต้นช่วงเวลาสื่อสาร: $1",
+ "config-session-expired": "ช่วงเวลาสื่อสารของคุณดูเหมือนว่าหมดอายุแล้ว\nช่วงเวลาสื่อสารถูกตั้งไว้ให้มีช่วงอายุเป็น $1\nคุณสามารถแก้ไขปัญหานี้ได้โดยตั้งค่า <code>session.gc_maxlifetime</code> ใน php.ini\nให้เริ่มกระบวนการติดตั้งใหม่อีกครั้ง",
+ "config-no-session": "ข้อมูลช่วงเวลาสื่อสารของคุณสูญหาย!\nให้ตรวจสอบ php.ini ของคุณและแน่ใจว่า <code>session.save_path</code> ถูกตั้งค่าไปยังไดเรกทอรีที่เหมาะสม",
+ "config-your-language": "ภาษาของคุณ:",
+ "config-your-language-help": "โปรดเลือกภาษาที่จะใช้ระหว่างกระบวนการติดตั้ง",
+ "config-wiki-language": "ภาษาของวิกิ:",
+ "config-wiki-language-help": "โปรดเลือกภาษาที่จะใช้เขียนเป็นหลักในวิกิ",
+ "config-back": "← ย้อนกลับ",
+ "config-continue": "ดำเนินการต่อ →",
+ "config-page-language": "ภาษา",
+ "config-page-welcome": "ยินดีต้อนรับสู่ MediaWiki!",
+ "config-page-dbconnect": "เชื่อมต่อไปยังฐานข้อมูล",
+ "config-page-upgrade": "อัปเกรดการติดตั้งที่มีอยู่",
+ "config-page-dbsettings": "การตั้งค่าฐานข้อมูล",
+ "config-page-name": "ชื่อ",
+ "config-page-options": "ตัวเลือก",
+ "config-page-install": "ติดตั้ง",
+ "config-page-complete": "เสร็จสมบูรณ์!",
+ "config-page-restart": "เริ่มการติดตั้งใหม่อีกครั้ง",
+ "config-page-readme": "อ่านเอกสารกำกับ",
+ "config-page-releasenotes": "บันทึกการเผยแพร่",
+ "config-page-copying": "การคัดลอก",
+ "config-page-upgradedoc": "การอัปเกรด",
+ "config-page-existingwiki": "วิกิที่มีอยู่",
+ "config-help-restart": "คุณต้องการล้างข้อมูลทั้งหมดที่คุณกรอกและเริ่มกระบวนการติดตั้งใหม่อีกครั้งหรือไม่?",
+ "config-restart": "ใช่ เริ่มใหม่อีกครั้ง",
+ "config-welcome": "=== การตรวจสอบสภาพแวดล้อม ===\nการตรวจสอบเบื้องต้นจะกระทำขึ้น เพื่อยืนยันว่าสภาพแวดล้อมปัจจุบันเหมาะสมสำหรับการติดตั้ง MediaWiki หรือไม่\nโปรดจำไว้ว่าให้รวบรวมผลลัพธ์การตรวจสอบนี้ ถ้าคุณต้องการแสวงหาการสนับสนุนเพื่อที่จะติดตั้งให้สมบูรณ์",
+ "config-copyright": "=== ลิขสิทธิ์และเงื่อนไข ===\n\n$1\n\nโปรแกรมนี้เป็นซอฟต์แวร์เสรี คุณสามารถนำโปรแกรมนี้มาเผยแพร่ซ้ำและ/หรือดัดแปลงได้ภายใต้เงื่อนไขของสัญญาอนุญาตสาธารณะทั่วไปของ GNU (GNU General Public License) ซึ่งเผยแพร่โดย Free Software Foundation (สัญญาอนุญาตรุ่น 2 ขึ้นไป)\n\nโปรแกรมนี้ถูกเผยแพร่โดยหวังว่าจะเป็นประโยชน์แก่ผู้ใช้ แต่<strong>จะไม่มีการรับประกันใด ๆ</strong> แม้แต่การรับประกันเกี่ยวกับ<strong>การนำไปใช้ในการซื้อขาย</strong> หรือ<strong>ความเหมาะสมสำหรับวัตถุประสงค์เฉพาะ</strong>\nสำหรับรายละเอียดเพิ่มเติม โปรดดูที่สัญญาอนุญาตสาธารณะทั่วไปของ GNU\n\nคุณควรได้รับ<doclink href=Copying>สำเนาของสัญญาอนุญาตสาธารณะทั่วไปของ GNU</doclink> มาพร้อมกับโปรแกรมนี้ ถ้าไม่ได้รับ ให้ขอได้ที่ Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, \nหรือ[http://www.gnu.org/copyleft/gpl.html อ่านออนไลน์ที่นี่]",
+ "config-sidebar": "* [https://www.mediawiki.org โฮมเพจของ MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents แนวปฏิบัติของผู้ใช้]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents แนวปฏิบัติของผู้ดูแลระบบ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ คำถามที่ถามบ่อย]\n----\n* <doclink href=Readme>อ่านเอกสารกำกับ</doclink>\n* <doclink href=ReleaseNotes>บันทึกการเผยแพร่</doclink>\n* <doclink href=Copying>การคัดลอก</doclink>\n* <doclink href=UpgradeDoc>การอัปเกรด</doclink>",
+ "config-env-good": "ตรวจสอบสภาพแวดล้อมแล้ว\nคุณสามารถติดตั้ง MediaWiki",
+ "config-env-bad": "ตรวจสอบสภาพแวดล้อมแล้ว\nคุณไม่สามารถติดตั้ง MediaWiki",
+ "config-env-php": "มี PHP $1 ติดตั้งอยู่",
+ "config-env-hhvm": "มี HHVM $1 ติดตั้งอยู่",
+ "config-unicode-using-intl": "ใช้[http://pecl.php.net/intl ส่วนขยาย intl PECL] สำหรับการปรับ Unicode เข้าสู่รูปปกติ (Unicode normalization)",
+ "config-unicode-pure-php-warning": "<strong>คำเตือน:</strong> [http://pecl.php.net/intl intl ส่วนขยาย PECL] ไม่พร้อมใช้งานสำหรับการจัดมาตรฐาน Unicode กำลังกลับไปใช้ PHP ที่แท้จริงแบบช้า\nถ้าคุณเปิดดำเนินการไซต์ที่มีปริมาณการใช้งานสูง คุณควรอ่านดูเกี่ยวกับ[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations การจัดมาตรฐาน Unicode] สักเล็กน้อย",
+ "config-no-db": "ไม่พบไดรเวอร์ฐานข้อมูลที่เหมาะสม! คุณจำเป็นต้องติดตั้งไดรเวอร์ฐานข้อมูลสำหรับ PHP\nชนิดฐานข้อมูลต่อไปนี้ได้รับการสนับสนุน: $1\n\nถ้าคุณคอมไพล์ PHP ด้วยตนเอง ให้กำหนดค่าใหม่อีกครั้งโดยเปิดใช้งานไคลเอนต์ฐานข้อมูล ตัวอย่างเช่น ใช้ <code>./configure --with-mysqli</code>\nถ้าคุณติดตั้ง PHP จากแพกเกจ Debian หรือ Ubuntu คุณก็จำเป็นต้องติดตั้งแพกเกจต่อไปนี้ ตัวอย่างเช่น แพกเกจ <code>php5-mysql</code>",
+ "config-outdated-sqlite": "<strong>คำเตือน:</strong> คุณมี SQLite $1 ซึ่งต่ำกว่ารุ่นขั้นต่ำที่ต้องการ $2 ดังนั้น SQLite จะไม่พร้อมให้ใช้งาน",
+ "config-no-fts3": "<strong>คำเตือน:</strong> SQLite ถูกคอมไพล์โดยไม่มี[//sqlite.org/fts3.html โมดูล FTS3] คุณลักษณะการค้นหาจะไม่พร้อมใช้งานบนแบ็กเอนด์นี้",
+ "config-pcre-old": "<strong>ข้อผิดพลาดร้ายแรง:</strong> ต้องใช้ PCRE $1 หรือสูงกว่า\nไบนารี PHP ของคุณถูกเชื่อมโยงกับ PCRE $2\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE ข้อมูลเพิ่มเติม]",
+ "config-pcre-no-utf8": "<strong>ข้อผิดพลาดร้ายแรง:</strong> โมดูล PCRE ของ PHP ดูเหมือนจะถูกคอมไพล์โดยไม่มีการรองรับ PCRE_UTF8\nMediaWiki ต้องการการรองรับ UTF-8 เพื่อให้ทำงานได้อย่างถูกต้อง",
+ "config-memory-raised": "<code>memory_limit</code> ของ PHP คือ $1 ได้เพิ่มเป็น $2",
+ "config-memory-bad": "<strong>คำเตือน:</strong> <code>memory_limit</code> ของ PHP คือ $1.\nเป็นไปได้ว่ามันอาจต่ำเกินไป\nการติดตั้งอาจล้มเหลวได้!",
+ "config-xcache": "มี [http://xcache.lighttpd.net/ XCache] ติดตั้งอยู่",
+ "config-apc": "มี [http://www.php.net/apc APC] ติดตั้งอยู่",
+ "config-apcu": "มี [http://www.php.net/apcu APCu] ติดตั้งอยู่",
+ "config-wincache": "มี [http://www.iis.net/download/WinCacheForPhp WinCache] ติดตั้งอยู่",
+ "config-no-cache-apcu": "<strong>คำเตือน:</strong> ไม่พบ [http://www.php.net/apcu APCu] [http://xcache.lighttpd.net/ XCache] หรือ [http://www.iis.net/download/WinCacheForPhp WinCache]\nการแคชวัตถุไม่ได้ถูกเปิดใช้งาน",
+ "config-mod-security": "<strong>คำเตือน:</strong> เว็บเซิร์ฟเวอร์ของคุณมี [http://modsecurity.org/ mod_security]/mod_security2 เปิดใช้งานอยู่ การตั้งค่าทั่วไปหลายอย่างของสิ่งนี้จะก่อให้เกิดปัญหาสำหรับ MediaWiki และซอฟต์แวร์อื่นที่อนุญาตให้ผู้ใช้สามารถโพสต์เนื้อหาได้ตามที่ผู้ใช้\nหากเป็นไปได้ ควรปิดใช้งานคุณลักษณะนี้ หรือมิฉะนั้นก็ อ้างไปยัง[http://modsecurity.org/documentation/ เอกสารกำกับการใช้งาน mod_security] หรือติดต่อการสนับสนุนจากโฮสต์ของคุณ ถ้าคุณพบข้อผิดพลาดโดยสุ่ม",
+ "config-diff3-bad": "ไม่พบ GNU diff3",
+ "config-git": "พบซอฟต์แวร์ควบคุมรุ่น Git: <code>$1</code>",
+ "config-git-bad": "ไม่พบซอฟต์แวร์ควบคุมรุ่น Git",
+ "config-imagemagick": "พบ ImageMagick: <code>$1</code>\nการย่อรูปภาพจะถูกเปิดใช้งาน ถ้าคุณเปิดใช้งานการอัปโหลด",
+ "config-gd": "พบไลบรารีกราฟิก GD ในตัว\nการย่อรูปภาพจะถูกเปิดใช้งาน ถ้าคุณเปิดใช้งานการอัปโหลด",
+ "config-no-scaling": "ไม่พบไลบรารี GD หรือ ImageMagick\nการย่อรูปภาพจะถูกปิดใช้งาน",
+ "config-no-uri": "<strong>ข้อผิดพลาด:</strong> ไม่สามารถทำการตรวจสอบ URI ปัจจุบันได้\nการติดตั้งถูกยกเลิกแล้ว",
+ "config-no-cli-uri": "<strong>คำเตือน:</strong> ไม่ได้ระบุ <code>--scriptpath</code> กำลังใช้ค่าเริ่มต้น: <code>$1</code>",
+ "config-using-server": "ใช้ชื่อเซิร์ฟเวอร์ \"<nowiki>$1</nowiki>\"",
+ "config-using-uri": "ใช้ยูอาร์แอลของเซิร์ฟเวอร์ \"<nowiki>$1$2</nowiki>\"",
+ "config-uploads-not-safe": "<strong>คำเตือน:</strong> ไดเรกทอรีเริ่มต้นของคุณสำหรับการอัปโหลด <code>$1</code> มีช่องโหว่ที่มีต่อการดำเนินการสคริปต์ด้วยตัวเอง\nถึงแม้ว่า MediaWiki จะมีการตรวจสอบช่องโหว่ด้านความปลอดภัยในไฟล์ที่อัปโหลดทั้งหมด แต่ขอแนะนำอย่างยิ่งว่าให้[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security ปิดช่องโหว่ด้านความปลอดภัยนี้]ก่อนเปิดใช้งานการอัปโหลด",
+ "config-no-cli-uploads-check": "<strong>คำเตือน:</strong> ไดเรกทอรีสำหรับอัปโหลดเริ่มต้นของคุณ (<code>$1</code>) ยังไม่ได้ถูกตรวจสอบช่องโหว่ด้านความปลอดภัย\nที่มีต่อการดำเนินการสคริปต์เองระหว่างการติดตั้ง CLI",
+ "config-brokenlibxml": "การใช้รุ่น PHP และ libxml2 ร่วมกันในระบบของคุณเป็นคู่รุ่นที่มีบักมากและอาจทำให้เกิดการเสียหายของข้อมูลแอบแฝงอยู่ใน MediaWiki และเว็บแอปพลิเคชั่นอื่นๆ ได้\nอัปเกรดเป็น libxml2 2.7.3 หรือสูงกว่า ([https://bugs.php.net/bug.php?id=45996 บักที่รายงานด้วย PHP])\nการติดตั้งถูกยกเลิกแล้ว",
+ "config-suhosin-max-value-length": "Suhosin ถูกติดตั้งแล้วและจำกัด<code>ความยาว</code>พารามิเตอร์ GET เป็น $1 ไบต์\nองค์ประกอบ ResourceLoader ของ MediaWiki จะยังคงทำงานภายใต้การจำกัดนี้ แต่อาจลดระดับประสิทธิภาพลงได้\nถ้าเป็นไปได้ คุณควรตั้ง <code>suhosin.get.max_value_length</code> เป็น 1024 หรือสูงกว่าใน <code>php.ini</code> และตั้งค่า <code>$wgResourceLoaderMaxQueryLength</code> ให้เป็นค่าเดียวกับใน <code>LocalSettings.php</code>",
+ "config-db-type": "ชนิดฐานข้อมูล:",
+ "config-db-host": "โฮสต์ฐานข้อมูล:",
+ "config-db-host-help": "ถ้าเซิร์ฟเวอร์ฐานข้อมูลของคุณอยู่บนเซิร์ฟเวอร์อื่น ให้ป้อนชื่อโฮสต์หรือที่อยู่ IP ที่นี่\n\nถ้าคุณกำลังใช้งานโฮสต์เว็บที่ใช้ร่วมกัน ผู้ให้บริการโฮสต์ควรให้ชื่อโฮสต์ที่ถูกต้องแก่คุณในเอกสารคู่มือ\n\nถ้าคุณกำลังติดตั้งบนเซิร์ฟเวอร์ Windows และกำลังใช้ MySQL การใช้ \"localhost\" อาจไม่สามารถใช้ได้สำหรับชื่อเซิร์ฟเวอร์ ถ้าไม่สามารถใช้ได้ ให้ลองใช้ \"127.0.0.1\" สำหรับที่อยู่ IP เฉพาะที่",
+ "config-db-host-oracle": "TNS ฐานข้อมูล:",
+ "config-db-host-oracle-help": "ป้อน [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name] ที่ถูกต้อง; ไฟล์ tnsnames.ora ต้องสามารถมองเห็นได้โดยการติดตัั้งนี้<br />ถ้าคุณกำลังใช้ไลบรารีไคลเอนต์ 10g หรือใหม่กว่า คุณก็สามารถใช้วิธีการตั้งชื่อแบบ [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect] ได้เช่นกัน",
+ "config-db-wiki-settings": "ระบุวิกินี้",
+ "config-db-name": "ชื่อฐานข้อมูล:",
+ "config-db-name-help": "เลือกชื่อที่ระบุวิกิของคุณ\nชื่อไม่ควรมีช่องว่าง\n\nถ้าคุณกำลังใช้โฮสต์เว็บที่ใช้ร่วมกัน ผู้ให้บริการโฮสต์ของคุณจะระบุชื่อฐานข้อมูลให้คุณ หรือให้คุณสร้างฐานข้อมูลโดยใช้แผงควบคุม",
+ "config-db-name-oracle": "แบบแผนฐานข้อมูล:",
+ "config-db-account-oracle-warn": "มีสถานการณ์สมมติสามสถานการณ์ที่สนับสนุนสำหรับการติดตั้ง Oracle เป็นแบ็กเอนด์ฐานข้อมูล:\n\nถ้าคุณต้องการสร้างบัญชีฐานข้อมูลเป็นส่วนหนึ่งของกระบวนการติดตั้ง โปรดจัดหาบัญชีที่มีบทบาท SYSDBA เป็นบัญชีฐานข้อมูลสำหรับการติดตั้งและระบุข้อมูลประจำตัวที่ต้องการสำหรับบัญชีการเข้าถึงเว็บ หรือคุณสามารถสร้างบัญชีการเข้าถึงเว็บด้วยตนเองและจัดหาเฉพาะบัญชีนั้น (ถ้ามีสิทธิ์ที่ต้องการในการสร้างวัตถุแบบแผน) หรือจัดหาบัญชีสองบัญชี โดยบัญชีหนึ่งใช้สร้างสิทธิ์ และบัญชีที่จำกัดอีกบัญชีหนึ่งสำหรับการเข้าถึงเว็บ\n\nสคริปต์ที่ใช้สำหรับการสร้างบัญชีพร้อมสิทธิ์ที่ต้องการสามารถพบได้ในไดเรกทอรี \"maintenance/oracle/\" ของการติดตั้งนี้\nอย่าลืมว่าการใช้บัญชีที่จำกัดจะเป็นการปิดใช้งานความสามารถในการบำรุงรักษาทั้งหมดด้วยบัญชีเริ่มต้น",
+ "config-db-install-account": "บัญชีผู้ใช้สำหรับการติดตั้ง",
+ "config-db-username": "ชื่อผู้ใช้ฐานข้อมูล:",
+ "config-db-password": "รหัสผ่านฐานข้อมูล:",
+ "config-db-install-username": "ป้อนชื่อผู้ใช้ที่จะใช้เชื่อมต่อไปยังฐานข้อมูลในระหว่างกระบวนการติดตั้ง\nชื่อผู้ใช้นี้ไม่ใช่ชื่อผู้ใช้สำหรับบัญชี MediaWiki แต่เป็นชื่อผู้ใช้สำหรับฐานข้อมูลของคุณ",
+ "config-db-install-password": "ป้อนรหัสผ่านที่จะใช้เชื่อมต่อไปยังฐานข้อมูลในระหว่างกระบวนการติดตั้ง\nรหัสผ่านนี้ไม่ใช่รหัสผ่านสำหรับบัญชี MediaWiki แต่เป็นรหัสผ่านสำหรับฐานข้อมูลของคุณ",
+ "config-db-install-help": "ป้อนชื่อผู้ใช้และรหัสผ่านที่จะใช้เชื่อมต่อไปยังฐานข้อมูลในระหว่างกระบวนการติดตั้ง",
+ "config-db-account-lock": "ใช้ชื่อผู้ใช้และรหัสผ่านเดียวกันในระหว่างการใช้งานปกติ",
+ "config-db-wiki-account": "บัญชีผู้ใช้สำหรับการใช้งานปกติ",
+ "config-db-wiki-help": "ป้อนชื่อผู้ใช้และรหัสผ่านที่จะใช้เชื่อมต่อไปยังฐานข้อมูลในระหว่างการใช้งานวิกิตามปกติ\nถ้าไม่มีบัญชีอยู่ และบัญชีการติดตั้งมีสิทธิ์เพียงพอ บัญชีผู้ใช้นี้จะถูกสร้างพร้อมสิทธิ์ขั้นต่ำที่จำเป็นต้องใช้ดำเนินการกับวิกิ",
+ "config-db-prefix": "คำนำหน้าตารางฐานข้อมูล:",
+ "config-db-prefix-help": "ถ้าคุณต้องการใช้ฐานข้อมูลเดียวร่วมกันระหว่างหลายวิกิ หรือระหว่าง MediaWiki กับเว็บแอปพลิเคชันอื่นๆ คุณอาจต้องเลือกเพิ่มคำนำหน้าให้กับชื่อตารางทั้งหมดเพื่อป้องกันความขัดแย้ง\nอย่าใช้ช่องว่าง\n\nโดยปกติ เขตข้อมูลนี้มักจะถูกปล่อยให้ว่างเปล่า",
+ "config-mysql-old": "จำเป็นต้องใช้ MySQL $1 หรือสูงกว่า คุณมี $2",
+ "config-db-port": "พอร์ตฐานข้อมูล:",
+ "config-db-schema": "แบบแผนสำหรับ MediaWiki:",
+ "config-db-schema-help": "โดยปกติ แบบแผนนี้จะไม่มีปัญหาใดๆ อยู่แล้ว\nเปลี่ยนเฉพาะก็ต่อเมื่อคุณรู้ว่าคุณจำเป็นต้องดำเนินการนี้",
+ "config-pg-test-error": "ไม่สามารถเชื่อมต่อไปยังฐานข้อมูล <strong>$1</strong>: $2",
+ "config-sqlite-dir": "ไดเรกทอรีข้อมูล SQLite:",
+ "config-sqlite-dir-help": "SQLite จัดเก็บข้อมูลทั้งหมดในไฟล์เดียว\n\nไดเรกทอรีที่คุณระบุจะต้องสามารถเขียนได้โดยเว็บเซิร์ฟเวอร์ระหว่างการติดตั้ง\n\nไดเรกทอรีดังกล่าว<strong>ไม่</strong>ควรสามารถเข้าถึงได้ผ่านเว็บ นี่คือเหตุผลที่เราไม่นำไฟล์ข้อมูลดังกล่าวไปไว้ในตำแหน่งที่มีไฟล์ PHP ของคุณอยู่\n\nโปรแกรมติดตั้งจะเขียนไฟล์ <code>.htaccess</code> ไปพร้อมกับไฟล์ข้อมูลดังกล่าว แต่ถ้าเกิดความล้มเหลว ทุกคนจะสามารถเข้าถึงฐานข้อมูลดิบของคุณได้\nซึ่งรวมถึงข้อมูลผู้ใช้ดิบ (ที่อยู่อีเมล ข้อมูลแฮช) รวมถึงรุ่นปรับปรุงที่ถูกลบไปแล้ว และข้อมูลที่ถูกจำกัดอื่นๆ บนวิกิ\n\nให้พิจารณานำฐานข้อมูลไปไว้ในตำแหน่งอื่น ตัวอย่างเช่น ใน <code>/var/lib/mediawiki/yourwiki</code>",
+ "config-oracle-def-ts": "พื้นที่ตารางเริ่มต้น:",
+ "config-oracle-temp-ts": "พื้นที่ตารางชั่วคราว:",
+ "config-type-mysql": "MySQL (หรือที่เข้ากันได้)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki สนับสนุนระบบฐานข้อมูลต่อไปนี้:\n\n$1\n\nถ้าคุณไม่พบระบบฐานข้อมูลที่คุณกำลังพยายามใช้ในรายการด้านล่างนี้ ให้ทำตามคำแนะนำที่เชื่อมโยงด้านบนเพื่อเปิดใช้งานการสนับสนุน",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] คือเป้าหมายหลักสำหรับ MediaWiki และได้รับการสนับสนุนดีที่สุด MediaWiki ยังคงสามารถใช้ได้ร่วมกับ [{{int:version-db-mariadb-url}} MariaDB] และ [{{int:version-db-percona-url}} Percona Server] ซึ่งเข้ากันได้กับ MySQL ([http://www.php.net/manual/en/mysqli.installation.php วิธีการคอมไพล์ PHP ด้วยการสนับสนุน MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] คือระบบฐานข้อมูลแบบโอเพนซอร์สที่ได้รับความนิยมสูงที่สามารถใช้แทน MySQL ได้ ([http://www.php.net/manual/en/pgsql.installation.php วิธีการคอมไพล์ PHP ด้วยการสนับสนุน PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] คือระบบฐานข้อมูลขนาดเล็กที่ได้รับการสนับสนุนดีมาก ([http://www.php.net/manual/en/pdo.installation.php วิธีการคอมไพล์ PHP ด้วยการสนับสนุน SQLite], ใช้ PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] คือฐานข้อมูลสำหรับองค์กรพาณิชย์ ([http://www.php.net/manual/en/oci8.installation.php วิธีการคอมไพล์ PHP ด้วยการสนับสนุน OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] คือฐานข้อมูลสำหรับองค์กรพาณิชย์สำหรับ Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php วิธีการคอมไพล์ PHP ด้วยการสนับสนุน SQLSRV])",
+ "config-header-mysql": "การตั้งค่า MySQL",
+ "config-header-postgres": "การตั้งค่า PostgreSQL",
+ "config-header-sqlite": "การตั้งค่า SQLite",
+ "config-header-oracle": "การตั้งค่า Oracle",
+ "config-header-mssql": "การตั้งค่า Microsoft SQL Server",
+ "config-invalid-db-type": "ชนิดฐานข้อมูลไม่ถูกต้อง",
+ "config-missing-db-name": "คุณต้องป้อนค่าสำหรับ \"{{int:config-db-name}}\"",
+ "config-missing-db-host": "คุณต้องป้อนค่าสำหรับ \"{{int:config-db-host}}\"",
+ "config-missing-db-server-oracle": "คุณต้องป้อนค่าสำหรับ \"{{int:config-db-host-oracle}}\"",
+ "config-invalid-db-server-oracle": "TNS ฐานข้อมูล \"$1\" ไม่ถูกต้อง\nให้ใช้สตริง \"ชื่อ TNS\" หรือ \"Easy Connect\"\n ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm วิธีการตั้งชื่อของ Oracle])",
+ "config-invalid-db-name": "ชื่อฐานข้อมูล \"$1\" ไม่ถูกต้อง\nให้ใช้เฉพาะอักษร ASCII (a-z, A-Z) ตัวเลข (0-9) ขีดล่าง (_) และยัติภังค์ (-)",
+ "config-invalid-db-prefix": "คำนำหน้าฐานข้อมูล \"$1\" ไม่ถูกต้อง\nให้ใช้เฉพาะอักษร ASCII (a-z, A-Z) ตัวเลข (0-9) ขีดล่าง (_) และยัติภังค์ (-)",
+ "config-connection-error": "$1\n\nตรวจสอบโฮสต์ ชื่อผู้ใช้และรหัสผ่าน และลองอีกครั้ง",
+ "config-invalid-schema": "แบบแผนสำหรับ MediaWiki \"$1\" ไม่ถูกต้อง\nให้ใช้เฉพาะอักษร ASCII (a-z, A-Z) ตัวเลข (0-9) และขีดล่าง (_)",
+ "config-db-sys-create-oracle": "โปรแกรมติดตั้งสนับสนุนเฉพาะการใช้บัญชี SYSDBA สำหรับการสร้างบัญชีใหม่เท่านั้น",
+ "config-db-sys-user-exists-oracle": "มีบัญชีผู้ใช้ \"$1\" อยู่แล้ว คุณสามารถใช้เฉพาะ SYSDBA สำหรับการสร้างบัญชีใหม่ได้เท่านั้น!",
+ "config-postgres-old": "จำเป็นต้องใช้ PostgreSQL $1 หรือสูงกว่า คุณมี $2",
+ "config-mssql-old": "จำเป็นต้องใช้ Microsoft SQL Server $1 หรือสูงกว่า คุณมี $2.",
+ "config-sqlite-name-help": "เลือกชื่อที่จะระบุวิกิของคุณ\nอย่าใช้ช่องว่างหรือยัติภังค์\nชื่อนี้จะถูกใช้สำหรับชื่อไฟล์ข้อมูล SQLite",
+ "config-sqlite-parent-unwritable-group": "ไม่สามารถสร้างไดเรกทอรีข้อมูล <code><nowiki>$1</nowiki></code> ได้ เนื่องจากไดเรกทอรีหลัก <code><nowiki>$2</nowiki></code> ไม่สามารถเขียนได้โดยเว็บเซิร์ฟเวอร์\n\nโปรแกรมติดตั้งได้ทำการตรวจสอบแล้วว่าเว็บเซิร์ฟเวอร์ของคุณกำลังทำงานในฐานะผู้ใช้ใด\nทำให้ไดเรกทอรี <code><nowiki>$3</nowiki></code> สามารถเขียนโดยผู้ใช้ดังกล่าวได้เพื่อดำเนินการต่อ\nถ้าคุณใช้ระบบ Unix/Linux ให้่ทำเช่นนี้:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "ไม่สามารถสร้างไดเรกทอรีข้อมูล <code><nowiki>$1</nowiki></code> ได้ เนื่องจากไดเรกทอรีหลัก <code><nowiki>$2</nowiki></code> ไม่สามารถเขียนได้โดยเว็บเซิร์ฟเวอร์\n\nโปรแกรมติดตั้งไม่สามารถทำการตรวจสอบได้ว่าเว็บเซิร์ฟเวอร์ของคุณกำลังทำงานในฐานะผู้ใช้ใด\nทำให้ไดเรกทอรี <code><nowiki>$3</nowiki></code> สามารถเขียนโดยส่วนกลาง (ุผู้ใช้ดังกล่าว รวมถึงคนอื่นๆ ด้วย!) ได้เพื่อดำเนินการต่อ\nถ้าคุณใช้ระบบ Unix/Linux ให้่ทำเช่นนี้:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "ไม่สามารถสร้างไดเรกทอรีข้อมูล \"$1\" ได้\nให้ตรวจสอบตำแหน่งที่ตั้ง และลองอีกครั้ง",
+ "config-sqlite-dir-unwritable": "ไม่สามารถเขียนข้อมูลลงในไดเรกทอรี \"$1\" ได้\nให้เปลี่ยนสิทธิ์ เพื่อให้เว็บเซิร์ฟเวอร์สามารถเขียนข้อมูลลงในไดเรกทอรีดังกล่าวได้ และลองอีกครั้ง",
+ "config-sqlite-connection-error": "$1\n\nตรวจสอบไดเรกทอรีข้อมูลและชื่อฐานข้อมูลด้านล่าง และลองอีกครั้ง",
+ "config-sqlite-readonly": "ไม่สามารถเขียนไฟล์ <code>$1</code> ได้",
+ "config-sqlite-cant-create-db": "ไม่สามารถสร้างไฟล์ฐานข้อมูล <code>$1</code> ได้",
+ "config-sqlite-fts3-downgrade": "PHP กำลังขาดการสนับสนุน FTS3 กำลังดาวน์เกรดตารางข้อมูล",
+ "config-can-upgrade": "มีตารางข้อมูล MediaWiki tables ในฐานข้อมูลนี้\nเมื่อต้องการอัปเกรดตารางข้อมูลเหล่านั้นไปเป็น MediaWiki $1 ให้คลิก <strong>ดำเนินการต่อ</strong>",
+ "config-upgrade-done": "การอัปเกรดเสร็จสมบูรณ์\n\nคุณสามารถ[$1 เริ่มใช้วิกิของคุณ]ได้ในขณะนี้\n\nถ้าคุณต้องการสร้างไฟล์ <code>LocalSettings.php</code> ของคุณใหม่ ให้คลิกปุ่มด้านล่างนี้\n<strong>ไม่แนะนำ</strong>ให้ดำเนินการนี้นอกจากว่าคุณกำลังมีปัญหากับวิกิของคุณ",
+ "config-upgrade-done-no-regenerate": "การอัปเกรดเสร็จสมบูรณ์\n\nคุณสามารถ[$1 เริ่มใช้วิกิของคุณ]ได้ในขณะนี้",
+ "config-regenerate": "สร้าง LocalSettings.php ใหม่ →",
+ "config-show-table-status": "คิวรี <code>SHOW TABLE STATUS</code> ล้มเหลว!",
+ "config-unknown-collation": "<strong>คำเตือน:</strong> ฐานข้อมูลกำลังใช้การจัดเรียงที่ไม่รู้จัก",
+ "config-db-web-account": "บัญชีฐานข้อมูลสำหรับการเข้าถึงเว็บ",
+ "config-db-web-help": "เลือกชื่อผู้ใช้และรหัสผ่านที่เว็บเซิร์ฟเวอร์จะใช้เชื่อมต่อไปยังเซิร์ฟเวอร์ฐานข้อมูล ในระหว่างการใช้งานวิกิตามปกติ",
+ "config-db-web-account-same": "ใช้บัญชีเดียวกับที่ใช้ในการติดตั้ง",
+ "config-db-web-create": "สร้างบัญชี ถ้าบัญชีดังกล่าวไม่มีอยู่",
+ "config-db-web-no-create-privs": "บัญชีที่คุณระบุไว้สำหรับการติดตั้งมีสิทธิ์ไม่เพียงพอที่จะสร้างบัญชี\nบัญชีที่คุณระบุไว้ที่นี่จะต้องมีอยู่แล้ว",
+ "config-mysql-engine": "กลไกที่จัดเก็บข้อมูล:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>คำเตือน:</strong> คุณได้เลือก MyISAM เป็นกลไกที่จัดเก็บข้อมูลสำหรับ MySQL ซึ่่งไม่แนะนำให้ใช้กับ MediaWiki เนื่องจาก:\n* ไม่ค่อยสนับสนุนกระบวนการทำงานพร้อมกันเนื่องจากการล็อกตารางข้อมูล\n* มีแนวโน้มที่จะเสียหายมากกว่ากลไกอื่น\n* Codebase ของ MediaWiki ไม่สามารถจัดการ MyISAM ได้ดีเท่าที่ควร\n\nถ้าการติดตั้ง MySQL ของคุณสนับสนุน InnoDB แนะนำอย่างยิ่งว่าให้คุณเลือก InnoDB แทน\nถ้าการติดตั้ง MySQL ของคุณไม่สนับสนุน InnoDB อาจถึงเวลาที่คุณต้องอัปเกรดแล้ว",
+ "config-mysql-only-myisam-dep": "<strong>คำเตือน:</strong> กลไกที่จัดเก็บข้อมูลสำหรับ MySQL ที่พร้อมใช้งานบนเครื่องนี้มีเพียง MyISAM ซึ่่งไม่แนะนำให้ใช้กับ MediaWiki เนื่องจาก:\n* ไม่ค่อยสนับสนุนกระบวนการทำงานพร้อมกันเนื่องจากการล็อกตารางข้อมูล\n* มีแนวโน้มที่จะเสียหายมากกว่ากลไกอื่น\n* Codebase ของ MediaWiki ไม่สามารถจัดการ MyISAM ได้ดีเท่าที่ควร\n\nการติดตั้ง MySQL ของคุณไม่สนับสนุน InnoDB อาจถึงเวลาที่คุณต้องอัปเกรดแล้ว",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> เป็นตัวเลือกที่เกือบดีที่สุดเสมอ เนื่องจากมีการสนับสนุนกระบวนการทำงานพร้อมกัน\n\n<strong>MyISAM</strong> อาจทำงานได้เร็วกว่าในการติดตั้งแบบผู้ใช้คนเดียวหรือแบบอ่านอย่างเดียว\nฐานข้อมูล MyISAM มักจะได้รับความเสียหายบ่อยมากกว่าฐานข้อมูล InnoDB",
+ "config-mysql-charset": "ชุดอักขระของฐานข้อมูล:",
+ "config-mysql-binary": "ไบนารี",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "ใน<strong>โหมดไบนารี</strong> MediaWiki จะจัดเก็บข้อความ UTF-8 ไว้ในฐานข้อมูลในเขตข้อมูลไบนารี\nการใช้โหมดไบนารีจะมีประสิทธิภาพมากกว่าการใช้โหมด UTF-8 ของ MySQL และจะอนุญาตให้คุณสามารถใช้อักขระที่มีใน Unicode ได้หมดทุกช่วง\n\nใน<strong>โหมด UTF-8</strong> MySQL จะทราบว่าข้อมูลของคุณอยู่ในชุดอักขระได้ และจะสามารถเสนอและแปลงข้อมูลดังกล่าวได้อย่างเหมาะสม แต่จะไม่อนุญาตให้คุณจัดเก็บข้อมูลที่มีอักขระนอกเหนือจากในช่วง[https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes เพลนหลายภาษาพื้นฐาน]",
+ "config-mssql-auth": "ชนิดการยืนยันตัวตน:",
+ "config-mssql-install-auth": "เลือกชนิดการยืนยันตัวตนที่จะใช้เชื่อมต่อไปยังฐานข้อมูลในระหว่างกระบวนการติดตั้ง\nถ้าคุณเลือก \"{{int:config-mssql-windowsauth}}\" ข้อมูลประจำตัวที่ระบุว่าเว็บเซิร์ฟเวอร์กำลังทำงานในฐานะผู้ใช้ใดจะถูกใช้",
+ "config-mssql-web-auth": "เลือกชนิดการยืนยันตัวตนที่จะใช้เชื่อมต่อไปยังฐานข้อมูลในระหว่างการใช้งานวิกิตามปกติ\nถ้าคุณเลือก \"{{int:config-mssql-windowsauth}}\" ข้อมูลประจำตัวที่ระบุว่าเว็บเซิร์ฟเวอร์กำลังทำงานในฐานะผู้ใช้ใดจะถูกใช้",
+ "config-mssql-sqlauth": "การยืนยันตัวตนโดย SQL Server",
+ "config-mssql-windowsauth": "การยืนยันตัวตนโดย Windows",
+ "config-site-name": "ชื่อของวิกิ:",
+ "config-site-name-help": "ชื่อนี้จะปรากฏในแถบชื่อเรื่องของเบราว์เซอร์และในที่อื่นๆ อีกหลายแห่ง",
+ "config-site-name-blank": "ป้อนชื่อไซต์",
+ "config-project-namespace": "เนมสเปซโครงการ:",
+ "config-ns-generic": "โครงการ",
+ "config-ns-site-name": "เหมือนกับชื่อวิกิ: $1",
+ "config-ns-other": "อื่นๆ (ระบุ)",
+ "config-ns-other-default": "วิกิของฉัน",
+ "config-project-namespace-help": "ตามตัวอย่างในวิกิพีเดีย วิกิหลายแห่งจะแยกหน้านโยบายออกจากหน้าเนื้อหาต่างๆ ใน '''เนมสเปซโครงการ'''\nชื่อเรื่องหน้าทั้งหมดในเนมสเปซนี้จะขึ้นต้นด้วยคำนำหน้าบางคำ ซึ่งคุณสามารถระบุได้ที่นี่\nโดยปกติ คำนำหน้านี้จะถูกสืบทอดมาจากชื่อของวิกิ แต่ไม่สามารถมีอักขระเครื่องหมายวรรคตอนได้ เช่น \"#\" หรือ \":\"",
+ "config-ns-invalid": "เนมสเปซ \"<nowiki>$1</nowiki>\" ที่ระบุไม่ถูกต้อง\nระบุเนมสเปซโครงการอื่น",
+ "config-ns-conflict": "เนมสเปซ \"<nowiki>$1</nowiki>\" ที่ระบุขัดแย้งกับเนมสเปซเริ่มต้นของ MediaWiki\nระบุเนมสเปซโครงการอื่น",
+ "config-admin-box": "บัญชีผู้ดูแล",
+ "config-admin-name": "ชื่อผู้ใช้ของคุณ:",
+ "config-admin-password": "รหัสผ่าน:",
+ "config-admin-password-confirm": "รหัสผ่านอีกครั้ง:",
+ "config-admin-help": "ป้อนชื่อผู้ใช้ที่ต้องการของคุณที่นี่ ตัวอย่างเช่น \"Joe Bloggs\"\nชื่อนี้จะเป็นชื่อที่คุณจะใช้สำหรับเข้าสู่ระบบวิกิ",
+ "config-admin-name-blank": "ป้อนชื่อผู้ใช้ของผู้ดูแล",
+ "config-admin-name-invalid": "ชื่อผู้ใช้ \"<nowiki>$1</nowiki>\" ที่ระบุไม่ถูกต้อง\nระบุชื่อผู้ใช้อื่น",
+ "config-admin-password-blank": "ป้อนรหัสผ่านสำหรับบัญชีผู้ดูแล",
+ "config-admin-password-mismatch": "รหัสผ่านสองรหัสที่คุณป้อนไม่ตรงกัน",
+ "config-admin-email": "ที่อยู่อีเมล:",
+ "config-admin-email-help": "ป้อนที่อยู่อีเมลที่นี่เพื่อให้คุณสามารถรับอีเมลจากผู้ใช้อื่นๆ บนวิกิ ตั้งค่ารหัสผ่านใหม่ และรับการแจ้งเตือนเกี่ยวกับการเปลี่ยนแปลงในหน้าที่อยู่บนรายการเฝ้าดูของคุณ คุณสามารถปล่อยเขตข้อมูลนี้ให้ว่างไว้ได้",
+ "config-admin-error-user": "เกิดข้อผิดพลาดภายในขณะสร้างผู้ดูแลด้วยชื่อ \"<nowiki>$1</nowiki>\"",
+ "config-admin-error-password": "เกิดข้อผิดพลาดภายในขณะตั้งค่ารหัสผ่านสำหรับผู้ดูแล \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "คุณป้อนที่อยู่อีเมลที่ไม่ถูกต้อง",
+ "config-subscribe": "สมัครรับข้อมูลกับ[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce รายชื่อผู้รับจดหมายเกี่ยวกับการประกาศการออกรุ่น]",
+ "config-subscribe-help": "รายชื่อนี้เป็นรายชื่อผู้รับจดหมายที่มีปริมาณต่ำสำหรับแจ้งข่าวเกี่ยวกับการประกาศการออกรุ่น รวมถึงการประกาศความปลอดภัยที่สำคัญ\nคุณควรสมัครรับข้อมูล และทำการอัปเดตการติดตั้ง MediaWiki ของคุณเมื่อมีรุ่นใหม่ออกมา",
+ "config-subscribe-noemail": "คุณได้พยายามสมัครรับข้อมูลกับรายชื่อผู้รับจดหมายการประกาศการออกรุ่นโดยไม่ได้ระบุที่อยู่อีเมล\nโปรดระบุที่อยู่อีเมล ถ้าคุณต้องการสมัครรับข้อมูลกับรายชื่อผู้รับจดหมาย",
+ "config-pingback": "แบ่งปันข้อมูลเกี่ยวกับการติดตั้งนี้ให้กับผู้พัฒนา MediaWiki",
+ "config-almost-done": "คุณใกล้จะเสร็จสมบูรณ์แล้ว!\nคุณสามารถข้ามการกำหนดค่าที่เหลืออยู่และติดตั้งวิกิได้ในขณะนี้",
+ "config-optional-continue": "ถามคำถามฉันอีก",
+ "config-optional-skip": "ฉันเบื่อแล้ว ติดตั้งวิกิให้ฉันเถอะ",
+ "config-profile": "โปรไฟล์สิทธิ์ผู้ใช้:",
+ "config-profile-wiki": "วิกิเปิด",
+ "config-profile-no-anon": "จำเป็นต้องสร้างบัญชี",
+ "config-profile-fishbowl": "เฉพาะผู้แก้ไขที่ได้รับอนุญาตเท่านั้น",
+ "config-profile-private": "วิกิส่วนตัว",
+ "config-profile-help": "วิกิต่างๆ จะใช้งานได้ดีที่สุดถ้าคุณเปิดให้หลายๆ คนร่วมแก้ไขวิกิของคุณได้มากเท่าที่จะได้\nใน MediaWiki มันง่ายที่จะตรวจทานการแก้ไขล่าสุด และแปลงกลับความเสียหายใดๆ ที่ถูกกระทำโดยผู้ใช้ที่ไม่มีมารยาทหรือที่เป็นอันตราย\n\nอย่างไรก็ตาม หลายคนได้พบว่า MediaWiki to be useful มีประโยชน์ในหลากหลายบทบาท และในบางครั้งมันไม่ง่ายที่จะทำให้ทุกๆ คนเชื่อว่า MediaWiki นั้นมีประโยชน์ในทางวิกิ\nดังนั้น คุณมีตัวเลือก\n\nแบบจำลอง <strong>{{int:config-profile-wiki}}</strong> อนุญาตให้ทุกๆ คนร่วมแก้ไขได้ โดยไม่จำเป็นต้องเข้าสู่ระบบ\nวิกิที่มี <strong>{{int:config-profile-no-anon}}</strong> จะจัดเตรียมการดำเนินงานพิเศษ แต่อาจห้ามไม่ให้ผู้คนเข้ามามีส่วนร่วม\n\nสถานการณ์จำลอง <strong>{{int:config-profile-fishbowl}}</strong> อนุญาตให้ผู้แก้ไขที่ได้รับการอนุมัติสามารถทำการแก้ไขได้ แต่ทุกคนสามารถมองเห็นหน้า รวมถึงประวัติได้\n<strong>{{int:config-profile-private}}</strong> อนุญาตให้เฉพาะผู้แก้ไขที่ได้รับการอนุมัติดูหน้า และแก้ไขได้\n\nการกำหนดค่าสิทธิ์ผู้ใช้ที่ซับซ้อนขึ้นจะพร้อมใช้งานหลังจากการติดตั้ง ดูที่ [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights รายการคู่มือที่เกี่ยวข้อง]",
+ "config-license": "ลิขสิทธิ์และสัญญาอนุญาต:",
+ "config-license-none": "ไม่มีส่วนท้ายของใบอนุญาต",
+ "config-license-cc-by-sa": "Creative Commons Attribution-ShareAlike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution-NonCommercial-ShareAlike",
+ "config-license-cc-0": "Creative Commons Zero (สาธารณสมบัติ)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 หรือสูงกว่า",
+ "config-license-pd": "สาธารณสมบัติ",
+ "config-license-cc-choose": "เลือกสัญญาอนุญาต Creative Commons เอง",
+ "config-extensions": "ส่วนขยาย",
+ "config-install-step-done": "เสร็จสิ้น",
+ "config-install-step-failed": "ล้มเหลว",
+ "config-install-user": "สร้างผู้ใช้ของฐานข้อมูล",
+ "config-install-user-alreadyexists": "ผู้ใช้ \"$1\" มีอยู่แล้ว",
+ "config-install-user-create-failed": "การสร้างผู้ใช้ \"$1\" ล้มเหลว: $2",
+ "config-install-user-grant-failed": "การกำหนดสิทธิผู้ใช้ \"$1\" ล้มเหลว: $2",
+ "config-install-user-missing": "ผู้ใช้ \"$1\" ที่ระบุไม่มีอยู่",
+ "config-install-user-missing-create": "ผู้ใช้ \"$1\" ที่ระบุไม่มีอยู่\nกรุณาคลิกกล่อง \"สร้างบัญชี\" ด้านล่างถ้าคุณต้องการสร้างขึ้น",
+ "config-install-tables": "สร้างตาราง",
+ "config-install-tables-exist": "<strong>คำเตือน:</strong> ตารางมีเดียวิกิดูเหมือนว่ามีอยู่แล้ว\nข้ามการสร้างไป",
+ "config-install-tables-failed": "<strong>ความผิดพลาด:</strong> การสร้างตารางล้มเหลวด้วยความผิดพลาดต่อไปนี้: $1",
+ "config-install-interwiki-list": "ไม่สามารถอ่านไฟล์ <code>interwiki.list</code>",
+ "config-install-sysop": "สร้างบัญชีผู้ใช้ที่เป็นผู้ดูแลระบบ",
+ "config-download-localsettings": "ดาวน์โหลด <code>LocalSettings.php</code>",
+ "config-help-tooltip": "คลิกเพื่อขยาย",
+ "config-nofile": "ไม่พบไฟล์ \"$1\" มันอาจถูกลบไปแล้วหรือไม่?",
+ "mainpagetext": "<strong>ติดตั้งมีเดียวิกิสำเร็จ</strong>",
+ "mainpagedocfooter": "ศึกษา[https://meta.wikimedia.org/wiki/Help:Contents คู่มือการใช้งาน] สำหรับเริ่มต้นใช้งานซอฟต์แวร์วิกิ\n\n== เริ่มต้น ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings รายการการปรับแต่งระบบ] (ภาษาอังกฤษ)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ คำถามที่ถามบ่อยในมีเดียวิกิ] (ภาษาอังกฤษ)\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce เมลลิงลิสต์ของมีเดียวิกิ]"
+}
diff --git a/www/wiki/includes/installer/i18n/tk.json b/www/wiki/includes/installer/i18n/tk.json
new file mode 100644
index 00000000..ba00162b
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tk.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Hanberke"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki şowlulyk bilen guruldy.'''",
+ "mainpagedocfooter": "Wiki programmasynyň ulanylyşy hakynda maglumat almak üçin [https://meta.wikimedia.org/wiki/Help:Contents ulanyjy gollanmasyna] serediň.\n\n== Öwrenjeler ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Konfigurasiýa sazlamalary]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki SSS]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki e-poçta sanawy]"
+}
diff --git a/www/wiki/includes/installer/i18n/tl.json b/www/wiki/includes/installer/i18n/tl.json
new file mode 100644
index 00000000..e078752f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tl.json
@@ -0,0 +1,292 @@
+{
+ "@metadata": {
+ "authors": [
+ "AnakngAraw",
+ "Sky Harbor",
+ "아라",
+ "Amire80",
+ "Jojit fb",
+ "Macofe",
+ "Emem.calist"
+ ]
+ },
+ "config-desc": "Ang tagapagluklok para sa MediaWiki",
+ "config-title": "Instalasyong $1 ng MediaWiki",
+ "config-information": "Kabatiran",
+ "config-localsettings-upgrade": "Napansin ang isang talaksang <code>LocalSettings.php</code>.\nUpang maitaas ang uri ng pagluluklok na ito, paki ipasok ang halaga ng <code>$wgUpgradeKey</code> sa loob ng kahong nasa ibaba.\nMatatagpuan mo ito sa loob ng <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Napansin ang isang talaksan ng <code>LocalSettings.php</code>.\nUpang isapanahon ang pagtatalagang ito, mangyaring patakbuhin sa halip ang <code>update.php</code>",
+ "config-localsettings-key": "Susi ng pagsasapanahon:",
+ "config-localsettings-badkey": "Hindi tama ang susing ibinigay mo.",
+ "config-upgrade-key-missing": "Napansin ang isang umiiral na pagtatalaga ng MediaWiki.\nUpang isapanahon ang katalagahang ito, mangyaring ilagay ang sumusunod na guhit sa ilalim ng iyong <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Lumilitaw na hindi pa buo ang umiiral na <code>LocalSettings.php</code>.\nAng pabagu-bagong $1 ay hindi nakatakda.\nMangyaring baguhin ang <code>LocalSettings.php</code> upang ang maitakda ang pagpapabagu-bagong ito, at pindutin ang \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Isang kamalian ang nakatagpo noong kumakabit sa kalipunan ng dato na ginagamit ang tinukoy na mga katakdaan sa loob ng <code>LocalSettings.php</code> o\n<code>LocalSettings.php</code>. Paki kumpunihin ang mga katakdaang ito at subukang muli.\n\n$1",
+ "config-session-error": "Kamalian sa pagsisimula ng sesyon: $1",
+ "config-session-expired": "Tila nagwakas na ang inilaan sa iyong panahon ng dato.\nAng inilaang mga panahon ay iniayos para sa isang panahon ng buhay na $1.\nMapapataas mo ito sa pamamagitan ng pagtatakda ng <code>session.gc_maxlifetime</code> sa loob ng php.ini.\nMuling simulan ang proseso ng pagluluklok.",
+ "config-no-session": "Nawala ang iyong datos ng sesyon!\nSuriin ang iyong php.ini at tiyakin na ang <code>session.save_path</code> ay nakatakda sa angkop na direktoryo.",
+ "config-your-language": "Ang wika mo:",
+ "config-your-language-help": "Pumili ng isang wikang gagamitin habang isinasagawa ang pagtatalaga.",
+ "config-wiki-language": "Wika ng Wiki:",
+ "config-wiki-language-help": "Piliin ang wika kung saan mangingibabaw na isusulat ang wiki.",
+ "config-back": "← Bumalik",
+ "config-continue": "Magpatuloy →",
+ "config-page-language": "Wika",
+ "config-page-welcome": "Maligayang pagdating sa MediaWiki!",
+ "config-page-dbconnect": "Umugnay sa kalipunan ng datos",
+ "config-page-upgrade": "Itaas ng uri ang umiiral na pagkakatalaga",
+ "config-page-dbsettings": "Mga katakdaan ng kalipunan ng datos",
+ "config-page-name": "Pangalan",
+ "config-page-options": "Mga mapipili",
+ "config-page-install": "Italaga",
+ "config-page-complete": "Buo na!",
+ "config-page-restart": "Simulan muli ang pag-iinstala",
+ "config-page-readme": "Basahin ako",
+ "config-page-releasenotes": "Pakawalan ang mga tala",
+ "config-page-copying": "Kinokopya",
+ "config-page-upgradedoc": "Itinataas ang uri",
+ "config-page-existingwiki": "Umiiral na wiki",
+ "config-help-restart": "Nais mo bang hawiin ang lahat ng nasagip na datong ipinasok mo at muling simulan ang proseso ng pagluluklok?",
+ "config-restart": "Oo, muling simulan ito",
+ "config-welcome": "=== Pagsusuring pangkapaligiran ===\nIsinasagawa ang payak na mga pagsusuri upang makita kung ang kapaligirang ito ay angkop para sa pagluluklok ng MediaWiki.\nDapat mong ibigay ang mga kinalabasan ng mga pagsusuring ito kung kailangan mo ng tulong habang nagluluklok.",
+ "config-copyright": "=== Karapatang-ari at Tadhana ===\n\n$1\n\nAng programang ito ay malayang software; maaari mo itong ipamahagi at/o baguhin sa ilalim ng mga tadhana ng Pangkalahatang Pampublikong Lisensiyang GNU ayon sa pagkakalathala ng Free Software Foundation; na maaaring bersyong 2 ng Lisensiya, o (kung nais mo) anumang susunod na bersyon.\n\nIpinamamahagi ang programang ito na umaasang magiging gamitin, subaliut '''walang anumang katiyakan'''; na walang pahiwatig ng '''pagiging mabenta''' o '''kaangkupan para sa isang tiyak na layunin'''.\nTingnan ang Pangkalahatang Pampublikong Lisensiyang GNU para sa mas maraming detalye.\n\nDapat nakatanggap ka ng <doclink href=Copying>isang sipi ng Pangkalahatang Pampublikong Lisensiyang GNU</doclink> kasama ng programang ito; kung hindi, sumulat sa Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, o [http://www.gnu.org/licenses//gpl.html basahin ito sa Internet].",
+ "config-sidebar": "* [https://www.mediawiki.org Tahanan ng MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Gabay ng Tagagamit]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Gabay ng Tagapangasiwa]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Mga Malimit Itanong]\n----\n* <doclink href=Readme>Basahin ako</doclink>\n* <doclink href=ReleaseNotes>Mga tala ng paglalabas</doclink>\n* <doclink href=Copying>Pagkopya</doclink>\n* <doclink href=UpgradeDoc>Pagsasapanahon</doclink>",
+ "config-env-good": "Nasuri na ang kapaligiran.\nMailuluklok mo ang MediaWiki.",
+ "config-env-bad": "Nasuri na ang kapaligiran.\nHindi mo mailuklok ang MediaWiki.",
+ "config-env-php": "Naitalaga ang PHP na $1.",
+ "config-unicode-using-intl": "Ginagamit ang [http://pecl.php.net/intl intl dugtong na PECL] para sa pagsasanormal ng Unikodigo.",
+ "config-unicode-pure-php-warning": "'''Babala''': Ang [http://pecl.php.net/intl dugtong ng internasyunal na PECL] ay hindi makukuha upang makapanghawak ng pagpapanormal ng Unikodigo, na babagsak na pabalik sa mabagal na pagsasakatuparan ng dalisay na PHP.\nKapag nagpapatakbo ka ng isang pook na mataas ang trapiko, dapat kang bumasa ng kaunti hinggil sa [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations pagpapanormal ng Unikodigo].",
+ "config-unicode-update-warning": "'''Babala''': Ang nakaluklok na bersiyon ng pambalot ng pagpapanormal ng Unikodigo ay gumagamit ng isang mas matandang bersiyon ng aklatan ng [http://site.icu-project.org/ proyekto ng ICU].\nDapat kang [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations magtaas ng uri] kung may pag-aalala ka hinggil sa paggamit ng Unikodigo.",
+ "config-no-db": "Hindi matagpuan ang isang angkop na tagapagmaneho ng kalipunan ng datos! Kailangan mong magluklok ng isang tagapagmaneho ng kalipunan ng dato para sa PHP.\nTinatangkilik ang sumusunod na mga uri ng kalipunan ng dato: $1.\n\nKung ikaw ay nasa isang pinagsasaluhang pagpapasinaya, hilingin sa iyong tagapagbigay ng pagpapasinaya na iluklok ang isang angkop na tagapagmaneho ng kalipunan ng dato.\nKung ikaw mismo ang nangalap ng PHP, muling isaayos ito na pinagagana ang isang kliyente ng kalipunan ng dato, halimbawa na ang paggamit ng <code>./configure --with-mysql</code>.\nKung iniluklok mo ang PHP mula sa isang pakete ng Debian o Ubuntu, kung gayon kailangan mo ring magluklok ng modyul na php5-mysql.",
+ "config-outdated-sqlite": "'''Babala''': mayroong kang $1 ng SQLite, na mas mababa kaysa sa pinaka mababang kailangang bersiyon na $2. Magiging hindi makukuha ang SQLite.",
+ "config-no-fts3": "'''Warning''': Ang SQLite ay hindi itinala at tinipon na wala ang [//sqlite.org/fts3.html modulong FTS3], ang mga tampok na panghanap ay magiging hindi makukuha sa ibabaw ng panlikod na dulong ito.",
+ "config-pcre-no-utf8": "'''Malubha''': Tila tinipon ang modyul na PCRE ng PHP na wala ang suporta ng PCRE_UTF8.\nNangangailangan ang MediaWiki ng suporta ng UTF-8 upang maging tama ang pag-andar.",
+ "config-memory-raised": "Ang <code>hangganan_ng_alaala</code> ng PHP ay $1, itinaas sa $2.",
+ "config-memory-bad": "'''Babala:''' Ang <code>hangganan_ng_alaala</code> ng PHP ay $1.\nIto ay maaaring napakababa.\nMaaaring mabigo ang pagluluklok!",
+ "config-xcache": "Ininstala na ang [http://xcache.lighttpd.net/ XCache]",
+ "config-apc": "Ininstala na ang [http://www.php.net/apc APC]",
+ "config-wincache": "Ininstala na ang [http://www.iis.net/download/WinCacheForPhp WinCache]",
+ "config-mod-security": "'''Babala''': Ang tagapaghain mo ng sangkasaputan ay pinagana na mayroong [http://modsecurity.org/ mod_security]. Kung mali ang kaayusan, makapagdurulot ito ng mga suliranin para sa MediaWiki o ibang mga sopwer na nagpapahintulot sa mga tagagamit na magpaskil ng hindi makatwirang nilalaman.\nSumangguni sa [http://modsecurity.org/documentation/ mod_security kasulatan] o makipag-ugnayan sa suporta ng iyong tagapagpasinaya kapag nakatagpo ng alin mang mga kamalian.",
+ "config-diff3-bad": "Hindi natagpuan ang GNU diff3.",
+ "config-imagemagick": "Natagpuan ang ImageMagick: <code>$1</code>.\nPapaganahin ang pagkakagyat ng larawan kapag pinagana mo ang mga pagkakargang paitaas.",
+ "config-gd": "Natagpuan ang pinasadyang nakapaloob na grapiks ng GD.\nPapaganahin ang pagkakagyat ng larawan kapag pinagana mo ang mga pagkakargang paitaas.",
+ "config-no-scaling": "Hindi matagpuan ang aklatang GD o ImageMagick.\nHindi papaganahin ang pagkakagyat ng larawan.",
+ "config-no-uri": "'''Kamalian:''' Hindi matukoy ang kasalukuyang URI.\nPinigilan ang pag-iinstala.",
+ "config-no-cli-uri": "'''Babala''': Walang tinukoy na --landas ng panitik, ginagamit ang likas na katakdaan: <code>$1</code>.",
+ "config-using-server": "Ginagamit ang pangalan ng tagapaghain na \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Ginagamit ang URL ng tagapaghain na \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "'''Babala:''' Ang iyong likas na nakatakdang direktoryo para sa paitaas na mga pagkakarga na <code>$1</code> ay may kahinaan laban sa pagsasagawa ng mga panitiki na hindi makatwiran. Bagaman sinisiyasat ng MediaWiki ang lahat ng paitaas na naikargang mga talaksan para sa mga panganib na pangkatiwasayan, mataas na iminumungkahi na [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security isara ang kahinaang ito na pangkatiwasayan] bago paganahin ang paitaas na mga pagkakarga.",
+ "config-no-cli-uploads-check": "'''Babala:''' Ang iyong likas na nakatakdang direktoryo para sa paitaas na mga pagkakarga (<code>$1</code>) ay hindi nasuri para sa kahinaan laban sa pagsasagawa ng panitik na hindi makatwiran habang iniluluklok ang Ugnayang Mukha ng Guhit ng Kaataasan o Command-Line Interface (CLI).",
+ "config-brokenlibxml": "Ang sistema mo ay mayroong isang pagsasama ng mga bersiyon ng PHP at libxml2 na maaaring masurot at maaaring makapagsanhi ng pagkasira ng datong nakakubli sa loob ng MediaWiki at iba pang mga aplikasyon ng sangkasaputan.\nMagtaas ng uri upang maging PHP 5.2.9 o mas lalong huli at libxml2 2.7.3 o mas lalong huli ([https://bugs.php.net/bug.php?id=45996 isinalansan ang surot o ''bug'' na mayroong PHP]). Binigo ang pagluluklok.",
+ "config-suhosin-max-value-length": "Nakaluklok ang Suhosin at hinahanggahan ang haba ng parametro ng GET sa $1 mga byte. Ang sangkap na ResourceLoader ng MediaWiki ay gagana sa paligid ng hangganang ito, subalit pasasamain nito ang pagganap. Kung talagang maaari, dapat mong itakda ang <code>suhosin.get.max_value_length</code> upang maging 1024 o mas mataas sa loob ng <code>php.ini</code>, at itakda ang <code>$wgResourceLoaderMaxQueryLength</code> sa katulad na halaga sa loob ng LocalSettings.php.",
+ "config-db-type": "Uri ng kalipunan ng datos:",
+ "config-db-host": "Tagapagpasinaya ng kalipunan ng datos:",
+ "config-db-host-help": "Kung ang iyong tagapaghain ng kalipunan ng dato ay nasa ibabaw ng isang ibang tagapaghain, ipasok ang pangalan ng tagapagpasinaya o tirahan ng IP dito.\n\nKung gumagamit ka ng pinagsasaluhang pagpapasinaya ng sangkasaputan, dapat ibigay sa iyo ng iyong tagapagbigay ng pagpapasinaya ang tamang pangalan ng tagapagpasinaya sa loob ng kanilang kasulatan.\n\nKapag nagluluklok ka sa ibabaw ng isang tagapaghain ng Windows at gumagamit ng MySQL, maaaring hindi gumana ang paggamit ng \"localhost\" para sa pangalan ng tagapaghain. Kung hindi, subukan ang \"127.0.0.1\" para sa katutubong tirahan ng IP.\n\nKapag gumagamit ka ng PostgreSQL, iwanang walang laman ang hanay na ito upang kumabit sa pamamagitan ng bokilya ng Unix.",
+ "config-db-host-oracle": "TNS ng kalipunan ng dato:",
+ "config-db-host-oracle-help": "Magpasok ng isang katanggap-tanggap na [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Katutubong Pangalan ng Pagkabit]; dapat na nakikita ang isang talaksan ng tnsnames.ora sa pagluluklok na ito.<br />Kung gumagamit ka ng mga aklatan ng kliyente na 10g o mas bago, maaari mo ring gamitin ang pamamaraan ng pagpapangalan ng [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Maginhawang Pagkabit].",
+ "config-db-wiki-settings": "Kilalanin ang wiking ito",
+ "config-db-name": "Pangalan ng kalipunan ng dato:",
+ "config-db-name-help": "Pumili ng isang pangalan na pangkilala sa wiki mo.\nHindi ito dapat maglaman ng mga patlang.\n\nKung gumagamit ka ng pinagsasaluhang pagpapasinaya ng sangkasaputan, ang iyong tagapagbigay ng pagpapasinaya ay maaaring bigyan ka ng isang tiyak na pangalan ng kalipunan ng datong gagamitin o papayagan kang lumikha ng mga kalipunan ng dato sa pamamagitan ng isang entrepanyong pantaban.",
+ "config-db-name-oracle": "Balangkas ng kalipunan ng dato:",
+ "config-db-account-oracle-warn": "Mayroong tatlong suportadong senaryo para sa pag-install ng Oracle bilang database backend:\n\nKung nais mong lumikha ng account ng database bilang bahagi ng proseso ng pag-install, paki magbigay ng isang account na mayroong gampanin ng SYSDBA bilang account ng database para sa pag-install at tukuyin ang ninanais na mga kredensiyal para sa account ng web-access, o di kaya ay maaaring gawing manu-mano ang paglikha ng account ng web access at ibigay lamang ang account na iyan (kung mayroong ito ng kinakailangang mga pahintulot upang malikha ang mga schema object) o magbigay ng dalawang magkaibang mga account, isang mayroong pribilehiyo ng paglikha at isang may pagbabawal para sa web access.\n\nAng script sa paglikha ng isang account na mayroon ng kinakailangang mga pribilehiyo ay matatagpuan sa loob ng directory na \"maintenance/oracle/\" ng pag-install na ito. Pakatandaan na ang paggamit ng isang account na may pagbabawal ay hindi magpapagana sa lahat ng mga kakayahang pampananatili kasama ang nakatakdang account.",
+ "config-db-install-account": "Account ng tagagamit para sa pagluluklok",
+ "config-db-username": "Pangalang pangtagagamit ng kalipunan ng dato:",
+ "config-db-password": "Password sa kalipunan ng dato:",
+ "config-db-install-username": "Ipasok ang pangalan ng tagagamit na gagamitin upang kumabit sa database habang isinasagawa ang pag-install.\nHindi ito ang pangalan ng tagagamit ng account ng MediaWiki; ito ang pangalan ng tagagamit para sa iyong database.",
+ "config-db-install-password": "Ipasok ang password na gagamitin upang maka-connect sa database habang isinasagawa ang pag-install.\nHindi ito ang password para sa account ng MediaWiki; ito ang password para sa iyong database.",
+ "config-db-install-help": "Ipasok ang pangalan ng tagagamit at password na gagamitin upang umugnay sa databasehabang isinasagawa ang pag-install.",
+ "config-db-account-lock": "Gamitin ang kaparehong pangalan at password habang nasa normal na operasyon",
+ "config-db-wiki-account": "Account ng tagagamit para sa pangkaraniwang pagpapaandar",
+ "config-db-wiki-help": "Ipasok ang pangalan ng tagagamit at password na gagamitin upang kumabit sa database habang nasa karaniwang pagtakbo ng wiki.\nKung hindi umiiral ang account, at ang pag-install ng account ay mayroong sapat na mga pribilehiyo, ang account na ito ng tagagamit ay lilikhain na mayroong pinaka mababang mga pribilehiyo na kailangan upang mapatakbo ang wiki.",
+ "config-db-prefix": "Unlapi ng talahanayan ng kalipunan ng dato:",
+ "config-db-prefix-help": "Kung kailangan mong ibahagi ang isang kalipunan ng dato sa pagitan ng maramihang mga wiki, o sa pagitan ng MediaWiki at ibang aplikasyon ng kasaputan, maaaring piliin mo na magdagdag ng isang unlapi sa lahat ng mga pangalan ng talahanayan upang maiwasan ang mga salungatan.\nHuwag gumamit ng mga patlang.\n\nAng hanay na ito ay karaniwang iniiwanang walang laman.",
+ "config-mysql-old": "Hindi kailangan ang MySQL na $1 o mas bago, mayroon kang $2.",
+ "config-db-port": "Daungan ng kalipunan ng dato:",
+ "config-db-schema": "Panukala para sa MediaWiki",
+ "config-db-schema-help": "Ang nasa itaas na panukala ay pangkaraniwang magiging maayos.\nBaguhin lamang ito kung alam mong kinakailangan.",
+ "config-pg-test-error": "Hindi makakabit sa kalipunan ng dato na '''$1''': $2",
+ "config-sqlite-dir": "Direktoryo ng dato ng SQLite:",
+ "config-sqlite-dir-help": "Iniimbak ng SQLite ang lahat ng dato sa loob ng isang nag-iisang file.\n\nAng ibibigay mong directory ay dapat na maging masusulatan ng tagapaghain ng kasaputan habang nag-i-install.\n\n'''Hindi''' ito dapat na mapuntahan sa pamamagitan ng web server, ito ang dahilan kung bakit hindi namin ito inilalagay sa kung nasaan ang iyong mga file ng PHP.\n\nAng installer ay magsusulat ng isang file na <code>.htaccess</code> na kasama ito, subalit kapag nabigo iyon mayroong isang tao na maaaring makakuha ng pagka nakakapunta sa iyong hilaw na database.\nKasama riyan ang hilaw na dato ng tagagamit (mga email address, pinaghalong mga password) pati na ang nabura nang mga pagbabago at iba pang may pagbabawal na dato ng wiki.\n\nIsaalang-alang ang paglalagay na magkakasama ang database sa ibang lugar, halimbawa na ang sa loob ng <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Likas na nakatakdang puwang ng talahanayan:",
+ "config-oracle-temp-ts": "Pansamantalang puwang ng talahanayan:",
+ "config-type-mysql": "MySQL",
+ "config-type-postgres": "PostgreSQL",
+ "config-type-sqlite": "SQLite",
+ "config-type-oracle": "Oracle",
+ "config-support-info": "Sinusuportahan ng MediaWiki ang sumusunod na mga sistema ng kalipunan ng dato:\n\n$1\n\nKung hindi mo makita ang sistema ng kalipunan ng dato na sinusubukan mong gamitin na nakatala sa ibaba, kung gayon ay sundi ang mga tagubilin na nakakawing sa itaas upang mapagana ang suporta,",
+ "config-dbsupport-mysql": "* Ang $1 ay ang pangunahing puntirya para sa MediaWiki at ang pinaka sinusuportahan ([http://www.php.net/manual/en/mysql.installation.php paano magtipon ng PHP na mayroong suporta ng MySQL])",
+ "config-dbsupport-postgres": "* Ang $1 ay isang bantog na sistema ng kalipunan ng dato na bukas ang pinagmulan na panghalili sa MySQL ([http://www.php.net/manual/en/pgsql.installation.php paano magtipon ng PHP na mayroong suporta ng PostgreSQL]). Maaaring mayroong ilang hindi pangunahing mga surot na natitira pa, at hindi iminumungkahi para gamitin sa loob ng isang kapaligiran ng produksiyon.",
+ "config-dbsupport-sqlite": "Ang $1 ay isang magaan ang timbang na sistema ng kalipunan ng dato na sinusuportahan nang napaka mainam. ([http://www.php.net/manual/en/pdo.installation.php Paano magtipon ng PHP na mayroong suporta ng SQLite], gumagamit ng PDO)",
+ "config-dbsupport-oracle": "* Ang $1 ay isang kalipunan ng dato ng kasigasigang pangkalakal. ([http://www.php.net/manual/en/oci8.installation.php Paano magtipunan ng PHP na mayroong suporta ng OCI8])",
+ "config-header-mysql": "Mga katakdaan ng MySQL",
+ "config-header-postgres": "Mga katakdaan ng PostgreSQL",
+ "config-header-sqlite": "Mga katakdaan ng SQLite",
+ "config-header-oracle": "Mga katakdaan ng Oracle",
+ "config-invalid-db-type": "Hindi tanggap na uri ng kalipunan ng dato",
+ "config-missing-db-name": "Dapat kang magpasok ng isang halaga para sa \"Pangalan ng kalipunan ng dato\"",
+ "config-missing-db-host": "Dapat kang magpasok ng isang halaga para sa \"Tagapagpasinaya ng kalipunan ng dato\"",
+ "config-missing-db-server-oracle": "Dapat kang magpasok ng isang halaga para sa \"TNS ng kalipunan ng dato\"",
+ "config-invalid-db-server-oracle": "Hindi katanggap-tanggap na pangalan ng TNSng kalipunan ng dato na \"$1\".\nGumamit lamang ng mga titik ng ASCII (a-z, A-Z), mga bilang (0-9), mga salungguhit (_) at mga tuldok (.).",
+ "config-invalid-db-name": "Hindi tanggap na pangalan ng kalipunan ng dato na \"$1\".\nGumamit lamang ng mga titik ng ASCII (a-z, A-Z), mga bilang (0-9), mga salungguhit (_) at mga gitling (-).",
+ "config-invalid-db-prefix": "Hindi tanggap na unlapi ng kalipunan ng dato na \"$1\".\nGamitin lamang ang mga titik na ASCII (a-z, A-Z), mga bilang (0-9), mga salungguhit (_) at mga gitling (-).",
+ "config-connection-error": "$1.\n\nSuriin ang host, pangalan at password na nasa ibaba at subukan ulit.",
+ "config-invalid-schema": "Hindi katanggap-tanggap na panukala para sa \"$1\" ng MediaWiki.\nGumamit lamang ng mga titik ng ASCII (a-z, A-Z), mga bilang (0-9), at mga salungguhit (_).",
+ "config-db-sys-create-oracle": "Ang installer ay sumusuporta lamang sa paggamit ng isang account ng SYSDBA para sa paglikha ng isang bagong account.",
+ "config-db-sys-user-exists-oracle": "Umiiral na ang account ng tagagamit na \"$1\". Magagamit lamang ang SYSDBA para sa paglikha ng isang bagong account!",
+ "config-postgres-old": "Kailangan ang PostgreSQL $1 o mas bago, mayroon kang $2.",
+ "config-sqlite-name-help": "Pumili ng isang pangalan na pangkilala na wiki mo.\nHuwag gumamit ng mga puwang o mga gitling.\nGagamitin ito para sa pangalan ng talaksan ng dato ng SQLite.",
+ "config-sqlite-parent-unwritable-group": "Hindi malikha ang direktoryo ng dato na <code><nowiki>$1</nowiki></code>, sapagkat ang magulang na direktoryong <code><nowiki>$2</nowiki></code> ay hindi masulatan ng tagapaghain ng kasaputan.\n\nNapag-alaman ng tagapagluklok kung sinong tagagamit ang kinatatakbuhan ng iyong tagapaghain ng kasaputan.\nGawing nasusulatan nito ang <code><nowiki>$3</nowiki></code> ng direktoryo upang makapagpatuloy.\nIto ang gawin sa ibabaw ng isang sistema ng Unix/Linux:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Hindi malikha ang direktoryo ng dato na <code><nowiki>$1</nowiki></code>, sapagkat ang magulang na direktoryong <code><nowiki>$2</nowiki></code> ay hindi masulatan ng tagapaghain ng kasaputan.\n\nHindi malaman ng tagapagluklok kung sinong tagagamit ang kinatatakbuhan ng iyong tagapaghain ng kasaputan.\nGawing nasusulatan nito (at ng mga iba pa) ang <code><nowiki>$3</nowiki></code> ng direktoryo upang makapagpatuloy.\nIto ang gawin sa ibabaw ng isang sistema ng Unix/Linux:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Kamalian sa paglikha ng direktoryo ng datong \"$1\".\nSuriin ang kinalalagyan at subukang muli.",
+ "config-sqlite-dir-unwritable": "Hindi nagawang magsulat sa direktoryong \"$1\".\nBaguhin ang mga kapahintulutan nito upang makapagsulat dito ang tagapaghain ng sapot, at subukang muli.",
+ "config-sqlite-connection-error": "$1.\n\nSurrin ang direktoryo ng dato at pangalan ng kalipunan ng datong nasa ibaba at subukan uli.",
+ "config-sqlite-readonly": "Ang talaksang <code>$1</code> ay hindi maisusulat.",
+ "config-sqlite-cant-create-db": "Hindi malikha ang talaksang <code>$1</code> ng kalipunan ng dato.",
+ "config-sqlite-fts3-downgrade": "Nawawala ang suportang FTS3 ng PHP, ibinababa ang uri ng mga talahanayan",
+ "config-can-upgrade": "Mayroong mga talahanayan ng MediaWiki sa loob ng kalipunan ng datong ito.\nUpang maitaas ang uri ng mga ito upang maging MediaWiki na $1, pindutin ang '''Magpatuloy'''.",
+ "config-upgrade-done": "Buo na ang pagtataas ng uri.\n\nMaaari mo na ngayong [$1 gamitin ang iyong wiki].\n\nKung nais mong muling likhain ang iyong talaksang <code>LocalSettings.php</code>, lagitikin ang pindutang nasa ibaba.\n'''Hindi minumungkahi''' ito maliban na lamang kung nagkakaroon ka ng mga suliranin sa piling ng wiki mo.",
+ "config-upgrade-done-no-regenerate": "Buo na ang pagsasapanahon.\n\nMaaari ka na ngayong [$1 magsimula sa paggamit ng wiki mo].",
+ "config-regenerate": "Muling likhain ang LocalSettings.php →",
+ "config-show-table-status": "Nabigo ang pagtatanong na IPAKITA ANG KALAGAYAN NG TALAHANAYAN!",
+ "config-unknown-collation": "'''Babala:''' Ang kalipunan ng dato ay gumagagamit ng hindi nakikilalang pag-iipon.",
+ "config-db-web-account": "Account ng kalipunan ng dato para sa pagpunta sa web",
+ "config-db-web-help": "Piliin ang pangalan ng tagagamit at password na gagamitin ng tagapaghain ng web upang umugnay sa tagapaghain ng database, habang nasa pangkaraniwang pagtakbo ng wiki.",
+ "config-db-web-account-same": "Gamitin ang gayun din account katulad ng sa pag-install",
+ "config-db-web-create": "Likhain ang account kung hindi pa ito umiiral",
+ "config-db-web-no-create-privs": "Ang tinukoy mong account na iluluklok ay walang sapat na mga pribilehiyo upang makalikha ng isang account.\nAng account na tutukuyin mo rito ay umiiral na dapat.",
+ "config-mysql-engine": "Makinang imbakan:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Babala''': Pinili mo ang MyISAM bilang makinang imbakan para sa MySQL, na hindi iminumungkahi para gamitin sa MediaWiki, sapagkat:\n* bahagya lamang itong sumusuporta ng pagkakasundu-sundo dahil sa pagkakandado ng talahanayan\n* mas malaki ang pagkakataon na kapitan ng sira kaysa sa ibang mga makina\n* ang himpilang kodigo ng MediaWiki ay hindi palaging humahawak ng MyISAM ayon sa nararapat\n\nKung ang iyong nakaluklok na MySQL ay sumusuporta ng InnoDB, higit na iminumungkahi na piliin mo iyon sa halip.\nKung ang iyong nakaluklok na MySQL ay hindi sumusuporta ng InnoDB, marahil ay panahon na para sa isang pagtataas ng uri.",
+ "config-mysql-engine-help": "Ang '''InnoDB''' ay ang halos palaging pinaka mainam na mapipili, dahil mayroon itong mabuting suporta ng pagkakasundu-sundo.\n\nMaaaring mas mabilis ang '''MyISAM''' sa mga pagluluklok na pang-isahang tagagamit o mababasa lamang.\nMay gawi ang mga kalipunan ng dato ng MyISAM na masira nang mas madalas kaysa sa mga kalipunan ng dato ng InnoDB.",
+ "config-mysql-charset": "Pangkat ng panitik ng kalipunan ng dato:",
+ "config-mysql-binary": "Binaryo",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Sa '''gawi na binaryo''', iniimbak ng MediaWiki ang tekstong UTF-8 sa kalipunan ng dato sa loob ng mga hanay na binaryo.\nMas kapaki-pakinabang ito kaysa sa gawi na UTF-8 ng MySQL, at nagpapahintulot sa iyo upang magamit ang buong kasaklawan ng mga panitik ng Unikodigo.\n\nSa ''gawi na UTF-8''', malalaman ng MySQL kung sa anong pangkat ng panitik nakapaloob ang iyong dato, at angkop na makakapagharap at makapapagpalit nito, subalit hindi ka nito papayagan na mag-imbak ng mga panitik na nasa itaas ng [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane] o Saligang Tapyas na Pangmaramihang Wika.",
+ "config-site-name": "Pangalan ng wiki:",
+ "config-site-name-help": "Lilitaw ito sa bareta ng pamagat ng pantingin-tingin at sa samu't saring ibang mga lugar.",
+ "config-site-name-blank": "Magpasok ng isang pangalan ng sityo.",
+ "config-project-namespace": "Puwang na pampangalan ng proyekto:",
+ "config-ns-generic": "Proyekto",
+ "config-ns-site-name": "Katulad ng sa pangalan ng wiki: $1",
+ "config-ns-other": "Iba pa (tukuyin)",
+ "config-ns-other-default": "Wiki Ko",
+ "config-project-namespace-help": "Bilang pagsunod sa halimbawa ng Wikipedia, maraming mga wiki ang nagpapanatili ng kanilang mga pahina ng patakaran na nakahiwalay magmula sa kanilang mga pahina ng nilalaman, na nasa loob ng isang \"'''puwang na pampangalan ng proyekto'''\".\nAng lahat ng mga pamagat ng pahina na nasa loob ng puwang ng pangalang ito ay nagsisimula na mayroong isang partikular na unlapi, na maaari mong tukuyin dito.\nSa nakaugalian, ang unlaping ito ay hinango mula sa pangalan ng wiki, subalit hindi ito maaaring maglaman ng mga panitik ng palabantasan na katulad ng \"#\" o \":\".",
+ "config-ns-invalid": "Ang tinukoy na puwang ng pangalan na \"<nowiki>$1</nowiki>\" ay hindi katanggap-tanggap.\nTumukoy ng isang ibang puwang ng pangalan ng proyekto.",
+ "config-ns-conflict": "Ang tinukoy na puwang ng pangalan na \"<nowiki>$1</nowiki>\" ay sumasalungat sa isang likas na nakatakdang puwang ng pangalan ng MediaWiki.\nTumukoy ng isang ibang puwang ng pangalan ng proyekto.",
+ "config-admin-box": "Account ng tagapangasiwa",
+ "config-admin-name": "Pangalan mo:",
+ "config-admin-password": "Password:",
+ "config-admin-password-confirm": "Password uli:",
+ "config-admin-help": "Ipasok dito ang mas ninanais mong pangalan ng tagagamit, bilang halimbawa na ang \"Joe Bloggs\".\nIto ang pangalang gagamitin mo upang lumagdang papasok sa wiki.",
+ "config-admin-name-blank": "Magpasok ng isang pangalan ng tagagamit na tagapangasiwa.",
+ "config-admin-name-invalid": "Ang tinukoy na pangalan ng tagagamit na \"<nowiki>$1</nowiki>\" ay hindi tanggap.\nTumukoy ng ibang pangalan ng tagagamit.",
+ "config-admin-password-blank": "Magpasok ng isang password para sa account ng tagapangasiwa.",
+ "config-admin-password-mismatch": "Hindi magkatugma ang ipinasok mong dalawang mga password.",
+ "config-admin-email": "Tirahan ng e-liham:",
+ "config-admin-email-help": "Magpasok dito ng isang email address upang mapahintulutan kang makatanggap ng email mula sa iba pang mga tagagamit ng wiki, itakdang muli ang password mo, at mabatid ang mga pagbabago sa mga pahinang nasa ibabaw ng iyong tala ng mga binabantayan. Maiiwanan mo na walang laman ang field na ito.",
+ "config-admin-error-user": "Panloob na kamalian kapag nililikha ang isang tagapangasiwa na may pangalang \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Panloob na kamalian kapag nagtatakda ng isang password na para sa tagapangasiwang \"<nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Nagpasok ka ng isang hindi katanggap-tanggap na tirahan ng e-liham.",
+ "config-subscribe": "Tumanggap mula sa [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce talaan ng mga pinadadalhan ng mga nilalabas na mga pabatid].",
+ "config-subscribe-help": "Isang itong tala ng pagliliham na mababa ang dami na ginagamit para sa pagpapakawala ng mga pahayag, kabilang na ang mahahalagang mga pahayag na pangkatiwasayan. Dapat kang magpasipi nito at isapanahon ang iyong nakaluklok na MediaWiki kapag lumalabas ang bagong mga bersiyon.",
+ "config-subscribe-noemail": "Sinubukan mong magpasipi sa tala ng nililihaman ng pagpapakawala ng mga pahayag na hindi nagbibigay ng isang tirahan ng -eliham. Paki magbigay ng isang tirahan ng e-liham kung nais mong magpasipi sa listahan ng pagliliham.",
+ "config-almost-done": "Halos tapos ka na!\nMaaari mo ngayong laktawan ang natitira pang pag-aayos at iluklok na ang wiki ngayon.",
+ "config-optional-continue": "Magtanong sa akin ng marami pang mga tanong.",
+ "config-optional-skip": "Naiinip na ako, basta iluklok na lang ang wiki.",
+ "config-profile": "Balangkas ng mga karapatan ng tagagamit:",
+ "config-profile-wiki": "Tradisyonal na wiki",
+ "config-profile-no-anon": "Kailangan ang paglikha ng account",
+ "config-profile-fishbowl": "Pinahintulutang mga patnugot lamang",
+ "config-profile-private": "Pribadong wiki",
+ "config-profile-help": "Pinaka mahusay ang pagtakbo ng mga Wiki kapag pinapahintulutan mo ang pinaka maraming mga tao na makapamatnugot ng mga ito hanggang sa maaari.\nSa loob ng MediaWiki, maginhawang masusuring muli ang kamakailang mga pagbabago, at mapanauli sa dati ang anumang nasira na nagawa ng isang walang muwang o may masamang hangarin na mga tagagamit.\n\nSubalit, marami ang nakatagpo na nagagamit ang MediaWiki sa loob ng malawak na sari-saring mga gampanin, at kung minsan ay hindi madaling makumbinsi ang lahat ng mga tao hinggil sa kapakinabangan ng kaparaanan ng wiki.\nKung kaya't nasa iyo ang pagpili.\n\nAng isang '''{{int:config-profile-wiki}}''' ay nagpapahintulot sa sinuman upang makapagbago, na hindi kailangan ang paglagdang papasok.\nAng isang wiki na mayroong '''{{int:config-profile-no-anon}}''' ay nagbibigay ng karagdagang pananagutan, subalit maaaring pumigil sa nagkataon lamang na mga tagapag-ambag.\n\nAng tagpo na '''{{int:config-profile-fishbowl}}''' ay nagpapahintulot lamang sa pinayagang mga tagagamit na makatingin ng mga pahina, na kapiling ang pangkat na pinayagang makapamatnugot.\nAng isang '''{{int:config-profile-private}}''' ay nagpapahintulot lamang sa pinayagang mga tagagamit na makatingin ng mga pahina, na kapiling ang pangkat na pinayagang makapamatnugot.\n\nAng mas masasalimuot na mga kaayusan ng mga karapatan ng tagagamit ay makukuha pagkaraan ng pagluluklok, tingnan ang [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights may kaugnayang kinamay na lahok].",
+ "config-license": "Karapatang-ari at lisensiya:",
+ "config-license-none": "Walang talababa ng lisensiya",
+ "config-license-cc-by-sa": "Malikhaing Pangkaraniwang Pagtukoy Pamamahaging Magkatulad",
+ "config-license-cc-by": "Atribusyon ng Creative Commons",
+ "config-license-cc-by-nc-sa": "Malikhaing Pangkaraniwang Pagtukoy Hindi-Pangkalakal Pamamahaging Magkatulad",
+ "config-license-cc-0": "Sero na Creative Commons (Nasasakop ng Madla)",
+ "config-license-gfdl": "Lisensiyang 1.3 ng Malayang Dokumentasyon ng GNU o mas lalong huli",
+ "config-license-pd": "Nasasakupan ng Madla",
+ "config-license-cc-choose": "Pumili ng isang pasadyang Lisensiya ng Malikhaing mga Pangkaraniwan",
+ "config-license-help": "Maraming mga pangmadlang wiki ang naglalagay ng lahat ng mga ambag sa ilalim ng [http://freedomdefined.org/Definition lisensiyang malaya].\nNakakatulong ito sa paglikha ng isang diwa ng pagmamay-ari ng pamayanan at nakapanghihikayat ng ambag na pangmahabang panahon.\nSa pangkalahatan, hindi kailangan ang isang wiking pribado o pangsamahan.\n\nKung nais mong magamit ang teksto magmula sa Wikipedia, at nais mong makatanggap ang Wikipedia ng tekstong kinopya magmula sa wiki mo, dapat mong piliin ang '''Creative Commons Attribution Share Alike''' (Pagbanggit na Pinagsasaluhang Magkatulad ng Malikhaing Pangkaraniwan).\n\nDating ginamit ng Wikipedia ang Lisensiya ng Kasulatang Malaya ng GNU (GNU Free Documentation License o GFDL).\nIsang katanggap-tanggap na lisensiya ang GFDL, subalit mahirap itong maunawaan.\nMahirap din ang paggamit na muli ng nilalaman na nasa ilalim ng GFDL.",
+ "config-email-settings": "Mga katakdaan ng e-liham",
+ "config-enable-email": "Paganahin ang palabas na e-liham",
+ "config-enable-email-help": "Kung nais mong gumana ang e-liham, ang mga katakdaan ng liham ng [http://www.php.net/manual/en/mail.configuration.php PHP] ay kailangang maging wasto ang pagkakaayos.\nKung ayaw mo nang anumang mga katampukan ng e-liham, maaari mong huwag paganahin ang mga ito rito.",
+ "config-email-user": "Paganahin ang tagagamit-sa-tagagamit na e-liham",
+ "config-email-user-help": "Payagan ang lahat ng mga tagagamit na magpadala ng e-liham sa bawat isa kapag pinagana nila ito sa kanilang mga nais.",
+ "config-email-usertalk": "Paganahin ang pabatid na pampahina ng usapan ng tagagamit",
+ "config-email-usertalk-help": "Payagan ang mga tagagamit na tumanggap ng mga pabatid sa mga pagbabago ng pahina ng usapan ng tagagamit, kapag pinagana nila ito sa kanilang mga nais.",
+ "config-email-watchlist": "Paganahin ang pabatid ng talaan ng bantayan",
+ "config-email-watchlist-help": "Payagan ang mga tagagamit na tumanggap ng mga pabatid tungkol sa kanilang binabantayang mga pahina kapag pinagana nila ito sa kanilang mga nais.",
+ "config-email-auth": "Paganahin ang pagpapatunay ng e-liham",
+ "config-email-auth-help": "Kapag pinagagana ang mapipiling ito, dapat tiyakin ng mga tagagamit ang kanilang tirahan ng e-liham na ginagamit ang isang kawing na ipinadala sa kanila tuwing itinatakda o binabago nila ito.\nTanging napatunayang mga tirahan ng e-liham lamang ang makakatanggap ng mga e-liham magmula sa ibang mga tagagamit o makakapagbago ng mga e-liham ng pagpapabatid.\n'''Iminumungkahi''' ang mapipiling katakdaan na ito para sa mga wiking pangmadla dahil sa maaaring mangyaring pagmamalabis ng mga katampukan ng e-liham.",
+ "config-email-sender": "Pabalik na tirahan ng e-liham:",
+ "config-email-sender-help": "Ipasok ang tirahan ng e-liham na gagamitin bilang tirahang pagsasaulian ng e-liham na papalabas.\nDito ang kung saan ipapadala ang mga pagtalbog.\nMaraming mga tagapaghain ng liham ang nangangailangan ng kahit na bahagi lamang ng pangalan ng nasasakupan upang maging katanggap-tanggap.",
+ "config-upload-settings": "Mga pagkakarga ng mga larawan at talaksan",
+ "config-upload-enable": "Paganahin ang pagkakarga ng talaksan",
+ "config-upload-help": "Ang paitaas na mga pagkakarga ng mga talaksan ay maaaring makapaglantad ng iyong tagapaghain sa mga panganib na pangkatiwasayan.\nPara sa mas marami pang kabatiran, basahin ang [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security seksiyon ng katiwasayan] sa loob ng gabay.\n\nUpang mapagana ang paitaas na mga pagkakarga ng talaksan, baguhin ang gawi roon sa subdirektoryo ng <code>mga imahe</code> sa ilalim ng ugat na direktoryo ng MediaWiki upang ang tagapaghain ng kasaputan ay makapagsulat dito.\nPagkaraan ay paganahin ang pipiliing ito.",
+ "config-upload-deleted": "Direktoryo para sa binurang mga talaksan:",
+ "config-upload-deleted-help": "Pumili ng isang direktoryong pagsusupnayan ng naburang mga talaksan.\nIdeyal na dapat itong hindi mapupuntahan mula sa web.",
+ "config-logo": "URL ng logo:",
+ "config-logo-help": "Ang likas na nakatakdang pabalat ng MediaWiki ay nagsasama ng puwang para sa isang logong 135x160 ang piksel na nasa itaas ng menu ng panggilid na bareta.\nMagkargang papaitaas ng isang imahe na mayroong naaangkop na sukat, at ipasok dito ang URL.\n\nKung ayaw mo ng isang logo, iwanang walang laman ang kahong ito.",
+ "config-instantcommons": "Paganahin ang Mga Pangkaraniwang Biglaan",
+ "config-instantcommons-help": "Ang [https://www.mediawiki.org/wiki/InstantCommons Instant Commons] ay isang tampok na nagpapahintulot sa mga wiki upang gumamit ng mga imahe, mga tunog at iba pang mga midyang matatagpuan sa pook ng [https://commons.wikimedia.org/ Wikimedia Commons].\nUpang magawa ito, nangangailangan ang MediaWiki ng pagka nakakapunta sa Internet.\n\nPara sa mas marami pang kabatiran hinggil sa tampok na ito, kabilang na ang mga tagubilin sa kung paano ito itakda para sa mga wiki na bukod pa kaysa sa Wikimedia Commons, sumangguni sa [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos gabay].",
+ "config-cc-error": "Hindi nagbigay ng resulta ang pampili ng lisensiya ng Malikhaing Pangkaraniwan.\nIpasok na kinakamay ang pangalan ng lisensiya.",
+ "config-cc-again": "Pumili uli...",
+ "config-cc-not-chosen": "Piliin kung anong lisensiya ng Malikhaing mga Pangkaraniwan ang nais mo at pindutin ang \"proceed\".",
+ "config-advanced-settings": "Mas masulong na pagkakaayos",
+ "config-cache-options": "Mga katakdaan para sa pagtatago ng bagay:",
+ "config-cache-help": "Ang pagtatago ng bagay ay ginagamit upang mapainam ang tulin ng MediaWiki sa pamamagitan ng pagtatago ng madalas gamiting dato.\nAng mga pook na bahagya hanggang malalaki ang sukat ay labis na hinihikayat na paganahin ito, at ang mga pook na maliliit ay makakakita rin ng mga kapakinabangan.",
+ "config-cache-none": "Walang pagtatago (tinanggal ang katungkulan, subalit maaaring maapektuhan ang tulin sa mas malalaking mga pook ng wiki)",
+ "config-cache-accel": "Pagtatago ng bagay ng PHP (APC, XCache o WinCache)",
+ "config-cache-memcached": "Gamitin ang Pagtatago sa Alaala (Memcached) (nangangailangan ng karagdagang kaayusan ng pagkakahanda at pagsasaayos)",
+ "config-memcached-servers": "Mga tagapaghaing itinago sa alaala:",
+ "config-memcached-help": "Listahan ng mga tirahan ng IP na gagamitin para sa Memcached o Itinagong Alaala.\nDapat na tukuyin na isa sa bawat guhit at tukuyin ang daungang gagamitin. Bilang halimbawa:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Pinili mo ang Memcached bilang uri mo ng taguan ngunit hindi tumukoy ng anumang mga tagapaghain.",
+ "config-memcache-badip": "Nagpasok ka ng isang hindi tanggap na tirahan ng IP para sa Memcached: $1.",
+ "config-memcache-noport": "Hindi ka tumukoy ng isang daungan na gagamitin para sa tagapaghain ng Memcached: $1.\nKung hindi mo alam ang daungan, ang likas na nakatakda ay 11211.",
+ "config-memcache-badport": "Ang bilang ng daungan ng Memcached ay dapat na nasa pagitan ng $1 at $2.",
+ "config-extensions": "Mga dugtong",
+ "config-extensions-help": "Ang mga dugtong na nakalista sa ibabaw ay napansin sa loob ng iyong direktoryo ng <code>./extensions</code>.\n\nMaaaring mangailangan ang mga ito ng karagdagang kaayusan, subalit mapapagana mo ngayon ang mga ito",
+ "config-install-alreadydone": "'''Babala:''' Tila nailuklok mo na ang MediaWiki at tinatangka mong iluklok ito ulit.\nPaki magpatuloy sa susunod na pahina.",
+ "config-install-begin": "Sa pamamagitan ng pagpindot sa \"{{int:config-continue}}\", sisimulan mo ang pagluluklok ng MediaWiki.\nKung nais mo paring gumawa ng mga pagbabago, paki pindutin ang bumalik.",
+ "config-install-step-done": "nagawa na",
+ "config-install-step-failed": "nabigo",
+ "config-install-extensions": "Isinasama ang mga karugtong",
+ "config-install-database": "Inihahanda ang kalipunan ng dato",
+ "config-install-schema": "Nililikha ang panukala",
+ "config-install-pg-schema-not-exist": "Hindi umiiral ang panukala ng PostgreSQL.",
+ "config-install-pg-schema-failed": "Nabigo ang paglikha ng mga talahanayan.\nTiyakin na ang tagagamit na \"$1\" ay maaaring makasulat sa balangkas na \"$2\".",
+ "config-install-pg-commit": "Isinasagawa ang mga pagbabago",
+ "config-install-pg-plpgsql": "Sumusuri ng wikang PL/pgSQL",
+ "config-pg-no-plpgsql": "Kailangan mong magtalaga ng wikang PL/pgSQL sa loob ng kalipunan ng datong $1",
+ "config-pg-no-create-privs": "Ang tinukoy mong accountpara sa pagtatalaga ay walang sapat na mga pribilehiyo upang makalikha ng isang account.",
+ "config-pg-not-in-role": "Umiiral na ang account na tinukoy mo para sa tagagamit ng web.\nAng tinukoy mong account para sa pag-install ay hindi isang tagagamit na super at hindi isang kasapi sa gampanin ng tagagamit ng web, kung kaya't hindi nito nagawang makalikha ng mga bagay na pag-aari ng tagagamit ng web.\n\nSa kasalukuyan, nangangailangan ang MediaWiki na ang mga table ay maging pag-aari ng tagagamit ng web. Pakitukoy ng isa pang pangalan ng account na web, o pindutin ang \"bumalik\" at tumukoy ng isang tagagamit na may kaangkupang pribilehiyo ng pag-install.",
+ "config-install-user": "Nililikha ang tagagamit ng kalipunan ng dato",
+ "config-install-user-alreadyexists": "Umiiral na ang tagagamit na \"$1\"",
+ "config-install-user-create-failed": "Nabigo ang paglikha ng tagagamit na \"$1\": $2",
+ "config-install-user-grant-failed": "Nabigo ang pagbibigay ng pahintulot sa tagagamit na \"$1\": $2",
+ "config-install-user-missing": "Hindi umiiral ang tinukoy na tagagamit na si \"$1\".",
+ "config-install-user-missing-create": "Hindi umiiral ang tinukoy na tagagamit na si \"$1\".\nPaki-klik ang nasa ibabang kahong natsetsekan na \"likhain ang account\" kung nais mong likhain ito.",
+ "config-install-tables": "Nililikha ang mga talahanayan",
+ "config-install-tables-exist": "'''Babala''': Tila umiiral na ang mga talahanayan ng MediaWiki.\nNilalaktawan ang paglikha.",
+ "config-install-tables-failed": "'''Kamalian''': Nabigo ang paglikha ng talahanayan na may sumusunod na kamalian: $1",
+ "config-install-interwiki": "Nilalagyan ng laman ang likas na nakatakdang talahanayan ng interwiki",
+ "config-install-interwiki-list": "Hindi matagpuan ang talaksang <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Babala''': Tila may mga laman na ang talahanayan ng interwiki.\nNilalaktawan ang likas na nakatakdang talaan.",
+ "config-install-stats": "Sinisimulan ang estadistika",
+ "config-install-keys": "Ginagawa ang lihim na mga susi",
+ "config-insecure-keys": "'''Babala:''' Nalikha ang {{PLURAL:$2|A secure key|ligtas na mga susi}} ($1) habang ang pagluluklok {{PLURAL:$2|ay|ay}} hindi pa lubos na ligtas. Isaalang-alang ang kinakamay na pagbago {{PLURAL:$2|nito|ng mga ito}}.",
+ "config-install-sysop": "Nililikha ang account ng tagagamit na tagapangasiwa",
+ "config-install-subscribe-fail": "Hindi nagawang magpasipi mula sa mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "Hindi nakalagak ang cURL at hindi makukuha ang <code>allow_url_fopen</code>",
+ "config-install-mainpage": "Nililikha ang pangunahing pahina na may likas na nakatakdang nilalaman",
+ "config-install-mainpage-exists": "Ang pangunahing pahina ay nakasaad na, ipagpatuloy ang paglalathala",
+ "config-install-extension-tables": "Nililikha ang mga talahanayan para sa pinagaganang mga dugtong",
+ "config-install-mainpage-failed": "Hindi maisingit ang pangunahing pahina: $1",
+ "config-install-done": "'''Maligayang bati!'''\nMatagumpay mong nailuklok ang MediaWiki.\n\nAng tagapagluklok ay nakagawa ng isang talaksan ng <code>LocalSettings.php</code>.\nNaglalaman ito ng lahat ng iyong mga pagsasaayos.\n\nKailangan mo itong ikargang paibaba at ilagay ito sa lipon ng iyong pagluluklok ng wiki (katulad ng direktoryo ng index.php). Ang pagkakargang paibaba ay dapat na kusang magsimula.\n\nKung ang pagkakargang paibaba ay hindi inialok, o kung hindi mo ito itinuloy, maaari mong muling simulan ang pagkakargang paibaba sa pamamagitan ng pagpindot sa kawing na nasa ibaba:\n\n$3\n\n'''Paunawa''': Kapag hindi mo ito ginawa ngayon, ang nagawang talaksang ito ng pagkakaayos ay hindi mo na makukuha mamaya kapag lumabas ka mula sa pagluluklok na hindi ikinakarga itong paibaba.\n\nKapag nagawa na iyan, maaari ka nang '''[$2 pumasok sa wiki mo]'''.",
+ "config-download-localsettings": "Ikargang paibaba ang <code>LocalSettings.php</code>",
+ "config-help": "saklolo",
+ "config-nofile": "Hindi matagpuan ang talaksang \"$1\". Binura na ba ito?",
+ "mainpagetext": "'''Matagumpay na ininstala ang MediaWiki.'''",
+ "mainpagedocfooter": "Silipin ang [https://meta.wikimedia.org/wiki/Help:Contents Patnubay sa Tagagamit] (''\"User's Guide\"'') para sa kaalaman sa paggamit ng wiking ''software''.\n\n== Pagsisimula ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Tala ng mga nakatakdang kumpigurasyon]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Mga malimit itanong sa MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Tala ng mga pinadadalhan ng liham ng MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/tly.json b/www/wiki/includes/installer/i18n/tly.json
new file mode 100644
index 00000000..b0d03f51
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tly.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Erdemaslancan"
+ ]
+ },
+ "config-page-options": "Кукон"
+}
diff --git a/www/wiki/includes/installer/i18n/tokipona.json b/www/wiki/includes/installer/i18n/tokipona.json
new file mode 100644
index 00000000..348380f7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tokipona.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Robin0van0der0vliet"
+ ]
+ },
+ "config-page-language": "toki"
+}
diff --git a/www/wiki/includes/installer/i18n/tr.json b/www/wiki/includes/installer/i18n/tr.json
new file mode 100644
index 00000000..c3a75398
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tr.json
@@ -0,0 +1,242 @@
+{
+ "@metadata": {
+ "authors": [
+ "Cagrix",
+ "Joseph",
+ "Rhinestorm",
+ "SiLveRLeaD",
+ "Trncmvsr",
+ "Sayginer",
+ "Trockya",
+ "Aşilleus",
+ "Nighteagle2000",
+ "Sadrettin",
+ "Captantrips",
+ "Stultiwikia",
+ "Meelo",
+ "HakanIST",
+ "McAang",
+ "Elftrkn",
+ "Vito Genovese"
+ ]
+ },
+ "config-desc": "MediaWiki yükleyicisi",
+ "config-title": "MediaWiki $1 yüklemesi",
+ "config-information": "Bilgi",
+ "config-localsettings-upgrade": "Bir <code>LocalSettings.php</code> dosyası algılandı.\nBu kurulumu güncelleştirmek için, lütfen <code>$wgUpgradeKey</code> değerini aşağıdaki kutuya girin.\nBunu <code>LocalSettings.php</code> dosyasında bulabilirsiniz.",
+ "config-localsettings-cli-upgrade": "Bir <code>LocalSettings.php</code> dosyası algılandı.\nBu kurulumu güncelleştirmek için, lütfen <code>update.php</code> dosyasını çalıştırın.",
+ "config-localsettings-key": "Yükseltme anahtarı:",
+ "config-localsettings-badkey": "Girdiğiniz güncelleme anahtarı doğru değildir.",
+ "config-upgrade-key-missing": "Mevcut bir MediaWiki kurulumu algılandı.\nBu kurulumu güncelleştirmek için, lütfen aşağıdaki satırı <code>LocalSettings.php</code> dosyanızın en altına koyun:\n\n$1",
+ "config-localsettings-incomplete": "Mevcut <code>LocalSettings.php</code> eksik gibi görünüyor.\n $1 değişkeni ayarlanmamış.\nLütfen <code>LocalSettings.php</code> dosyasını değiştirin bu değişkenleri kuracak, ve tıklayın \"{{int:Config-cuntinue}}\".",
+ "config-localsettings-connection-error": "<code>LocalSettings.php</code> içinde belirtilen ayarları kullanarak veritabanına bağlanırken bir hatayla karşılaşıldı. Lütfen bu ayarları düzeltin ve yeniden deneyin.\n\n$1",
+ "config-session-error": "Oturum başlatılırken hata: $1",
+ "config-session-expired": "Oturum bilgilerinizin süresi bitmiş.\nOturumların süresi $1 kadardır.\nBu süreyi php.ini' deki <code>session.gc_maxlifetime</code> ayarla arttırabilirsiniz.\nKurulum işlemini yeniden başlatın.",
+ "config-no-session": "Oturum bilgileriniz silinmiş.\nphp.ini dosyanızı kontrol edin ve <code>session.save_path</code> ayarının uygun bir klasöre yönlendiğinden emin olun.",
+ "config-your-language": "Diliniz:",
+ "config-your-language-help": "Yükleme sürecinde kullanılacak bir dil seçin.",
+ "config-wiki-language": "Viki dili:",
+ "config-wiki-language-help": "Vikinin ağırlıklı olarak yazılacağı dili seçin.",
+ "config-back": "← Geri",
+ "config-continue": "Devam →",
+ "config-page-language": "Dil",
+ "config-page-welcome": "MediaWiki'ye hoş geldiniz!",
+ "config-page-dbconnect": "Veritabanına bağlan",
+ "config-page-upgrade": "Varolan yüklemeyi yükselt",
+ "config-page-dbsettings": "Veritabanı ayarları",
+ "config-page-name": "İsim",
+ "config-page-options": "Seçenekler",
+ "config-page-install": "Yükle",
+ "config-page-complete": "Tamamlandı!",
+ "config-page-restart": "Yüklemeyi yeniden başlat",
+ "config-page-readme": "Beni oku",
+ "config-page-releasenotes": "Sürüm notları",
+ "config-page-copying": "Kopyalama",
+ "config-page-upgradedoc": "Yükseltme",
+ "config-page-existingwiki": "Mevcut viki",
+ "config-help-restart": "Girişini yaptığınız tüm kayıtlı verileri silerek, yükleme işlemini yeniden başlatmak ister misiniz?",
+ "config-restart": "Evet, yeniden başlat",
+ "config-welcome": "===Ortam Kontrolleri===\nOrtamın Mediawiki kurulumuna uygun olup olmadığını anlamak için basit kontroller yapılacak.\nKurulumu nasıl tamamlayacağınız konusunda destek isterken bu bilgileri eklemeyi unutmayın.",
+ "config-copyright": "=== Telif Hakları ve Koşulları ===\n\n$1\n\nBu program ücretsiz bir yazılımdır; yeniden dağıtabilir veya Özgür Yazılım Kuruluşu tarafından yayınlanan (GNU) Genel Kamu Lisansı koşulları altında değiştirebilirsiniz; isterseniz ikinci lisans sürümünü veya (sizin seçeneğiniz) herhangi bir sonraki lisans sürümünü kullanabilirsiniz.\n\nBu program, faydalı olacağı umuduyla dağıtılmaktadır, ancak ''' herhangi bir garantisi yoktur '''; ''' uygunluk ''' veya ''' belirli bir amaca uygunluk ''' gibi dolaylı garantileri bile yoktur.\nDaha fazla ayrıntı için (GNU) Genel Kamu Lisansına bakınız.\n\nBu program ile birlikte <doclink href=\"Copying\">bir (GNU) Genel Kamu Lisansının bir kopyasını </doclink> almış olmanız gerekir; bu program (GNU) Genel Kamu Lisansı ile dağıtılmadıysa, Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, ABD adresine yazın veya [http://www.gnu.org/copyleft/gpl.html online olarak okuyun].",
+ "config-sidebar": "* [https://www.mediawiki.org MediaWiki anasayfa]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Kullanıcı Kılavuzu]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Hizmetli Rehberi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ SSS]\n----\n* <doclink href=Readme>Beni oku</doclink>\n* <doclink href=ReleaseNotes>Sürüm notları</doclink>\n* <doclink href=Copying>Kopyalama</doclink>\n* <doclink href=UpgradeDoc>Yükseltme</doclink>",
+ "config-env-good": "Ortam kontrol edildi.\nMediaWiki'yi kurabilirsiniz.",
+ "config-env-bad": "Ortam kontrol edildi.\nMediaWiki'yi kuramazsınız.",
+ "config-env-php": "PHP $1 kurulu.",
+ "config-env-hhvm": "HHVM $1 kuruldu",
+ "config-unicode-using-intl": "Unikod normalleştirmesi için [http://pecl.php.net/intl intl PECL uzantısı] kullanılıyor.",
+ "config-unicode-pure-php-warning": "<strong>Uyarı:</strong> [http://pecl.php.net/intl intl PECL uzantısı] Unicode normalizasyonunu kaldırabilecek şekilde müsait değil; bu yüzden sayfa saf PHP uygulamasına dönüyor. Yüksek trafik alan bir sayfa çalıştırıyorsanız, [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalizasyonu] ile ilgili biraz bilgi almalısınız.",
+ "config-outdated-sqlite": "<strong>Uyarı:</strong> Elinizde SQLite $1 var. Gerekli minimum sürüm: $2. SQLite kullanılamayacaktır.",
+ "config-no-fts3": "<strong>Uyarı:</strong> SQLite [//sqlite.org/fts3.html FTS3 modülü] olmadan derlendi, bu arkayüzde arama özellikleri kullanılamayacaktır.",
+ "config-pcre-old": "<strong>Ağır hata:</strong> PCRE $1 veya daha üst versiyon gerekli.\nSizin PHP kurulumunuz PCRE $2 ile bağlı.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Daha fazla bilgi].",
+ "config-memory-raised": "PHP'nin <code>memory_limit</code> (hafıza sınırı) değeri $1, $2'ye yükseltildi.",
+ "config-memory-bad": "<strong>Uyarı:</strong> PHP'nin <code>memory_limit</code> (hafıza sınırı) değeri $1.\nBu büyük ihtimalle çok düşük.\nKurulum başarısız olabilir!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] kurulu",
+ "config-apc": "[http://www.php.net/apc APC] kurulu",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] kurulu",
+ "config-mod-security": "'''Uyarı:''' Web sunucunuz [http://modsecurity.org/ mod_security] etkin. Eğer yanlış yapılandırılmış ise, bu MediaWiki ve kullanıcılara isteğe bağlı içerik göndermesine izin veren diğer yazılımlar için sorun oluşturabilir.\nRastgele hatalar alırsanız [http://modsecurity.org/documentation/ mod_security belgelemesine] bakın ya da sunucunuzun desteğine başvurun.",
+ "config-diff3-bad": "GNU diff3 bulunamadı.",
+ "config-git": "Sürüm kontrol yazılımı Git bulundu: <code>$1</code>.",
+ "config-git-bad": "Sürüm kontrol yazılımı Git bulunamadı.",
+ "config-imagemagick": "ImageMagick bulundu: <code>$1</code>.\nEğer yüklemeleri etkinleştirirseniz, küçük resimler etkinleştirilecektir.",
+ "config-gd": "Kurulu GD grafik kütüphanesi bulundu.\nDosya yüklemeyi açarsanız miniboy resim görüntüleme açılacaktır.",
+ "config-no-scaling": "GD kütüphanesi veya ImageMagick bulunamadı.\nMiniboy resim görüntüleme devre dışı kalacak.",
+ "config-no-uri": "<strong>Hata:</strong> Mevcut URI tespit edilemedi.\nKurulum iptal edildi.",
+ "config-no-cli-uri": "<strong>Uyarı:</strong> Herhangi bir <code>--scriptpath</code> belirlenmemiş, varsayılan kullanılıyor: <code>$1</code>.",
+ "config-using-server": "Sunucu adı olarak \"<nowiki>$1</nowiki>\" kullanılıyor.",
+ "config-using-uri": "Sunucu URLsi olarak \"<nowiki>$1$2</nowiki>\" kullanılıyor.",
+ "config-db-type": "Veritabanı tipi:",
+ "config-db-host": "Veritabanı sunucusu:",
+ "config-db-host-help": "Veritabanı sunucunuz farklı bir sunucu üzerinde ise, ana bilgisayar adını veya IP adresini buraya girin.\n\nPaylaşılan ağ barındırma hizmeti kullanıyorsanız, barındırma sağlayıcınız size doğru bir ana bilgisayar adını kendi belgelerinde vermiştir.\n\nEğer MySQL kullanan bir Windows sunucusuna yükleme yapıyorsanız, sunucu adı olarak \"localhost\" kullanırsanız çalışmayabilir. Çalışmazsa, yerel IP adresi için \"127.0.0.1\" deneyin.\n\nPostgreSQL kullanıyorsanız, bu alanı bir Unix soketi ile bağlanmak için boş bırakın.",
+ "config-db-host-oracle": "Veritabanı TNS:",
+ "config-db-wiki-settings": "Bu wikiyi tanımla",
+ "config-db-name": "Veritabanı adı:",
+ "config-db-name-help": "Vikinizi tanımlayan bir isim seçin.\nBoşluk karakteri içermemelidir.\n\nPaylaşılan bir web hosting servisi kullanıyorsanız, tedarikçiniz size ya kullanmanız için bir veritabanı ismi verecek ya da bir kontrol paneli vasıtasıyla sizin oluşturmanıza izin verecektir.",
+ "config-db-name-oracle": "Veritabanı şeması:",
+ "config-db-install-account": "Yükleme için kullanıcı hesabı",
+ "config-db-username": "Veritabanı kullanıcı adı:",
+ "config-db-password": "Veritabanı parolası:",
+ "config-db-install-username": "Yükleme sırasında veritabanına bağlanmak için kullanılan kullanıcı adını girin.\nBu MediaWiki hesabının kullanıcı adı değildir; Bu veritabanın kullanıcı adıdır.",
+ "config-db-install-password": "Kurulum işlemi boyunca veritabanına bağlanmak için kullanılacak şifreyi girin.\nBu şifre MediaWiki hesap şifresi değil, veritabanınızın şifresidir.",
+ "config-db-install-help": "Kurulum işlemi boyunca veritabanına bağlanmak için kullanıcı adı ve şifre giriniz.",
+ "config-db-account-lock": "Normal çalışma sırasında aynı kullanıcı adı ve şifreyi kullanınız.",
+ "config-db-wiki-account": "Kullanıcı hesabı için normal işlem",
+ "config-db-prefix": "Veritabanı Tablo öneki:",
+ "config-mysql-old": "MySQL $1 veya daha yenisi gerekir. Sende bulunan $2 .",
+ "config-db-port": "Veritabanı bağlantı noktası:",
+ "config-db-schema": "MediaWiki için şema:",
+ "config-db-schema-help": "Bu şema yeterli olacaktır.\nEğer gerçekten ihtiyaç duyarsanız değiştirin.",
+ "config-pg-test-error": "Veritabanıyla bağlantı kurulamıyor ''' $1 ''':$2",
+ "config-sqlite-dir": "SQLite veri dizini",
+ "config-oracle-def-ts": "Varsayılan tablo alanı:",
+ "config-oracle-temp-ts": "Geçici tablo alanı:",
+ "config-type-mysql": "MySQL (veya uyumlu)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-header-mysql": "MySQL ayarları",
+ "config-header-postgres": "PostgreSQL ayarları",
+ "config-header-sqlite": "SQLite ayarları",
+ "config-header-oracle": "Oracle ayarları",
+ "config-header-mssql": "Microsoft SQL Server ayarları",
+ "config-invalid-db-type": "Geçersiz veritabanı türü",
+ "config-missing-db-name": "\"Veritabanı adı\" için bir değer girmelisiniz",
+ "config-missing-db-host": "\"{{int:config-db-host}}\" için bir değer girmelisiniz.",
+ "config-missing-db-server-oracle": "\"{{int:config-db-host-oracle}}\" için bir değer girmelisiniz",
+ "config-invalid-db-name": "Geçersiz veritabanı adı \" $1 \".\nSadece ASCII harf (a-z, A-Z), rakamların (0-9), alt çizgi (_) ve tire (-) kullanın.",
+ "config-connection-error": "$1.\n\nSunucuyu kontrol edin, kullanıcı adı ve parolayı denetleyin ve yeniden deneyin.",
+ "config-invalid-schema": "Geçersiz şema MediaWiki için \" $1 \".\nYalnızca ASCII harf (a-z, A-Z), rakamların (0-9) ve alt çizgi (_) kullanın.",
+ "config-db-sys-create-oracle": "Kurulum yeni hesap oluştururken sadece SYSDBA hesabı kullanımını destekliyor.",
+ "config-db-sys-user-exists-oracle": "Kullanıcı hesabı \" $1 \" zaten var. SYSDBA sadece yeni bir hesap oluşturmak için kullanılabilir.",
+ "config-postgres-old": "PostgreSQL $1 veya daha yenisi gerekir. Sende $2 sürümü var.",
+ "config-mssql-old": "Microsoft SQL Server $1 veya daha yükseği gerekli. Sizdeki sürüm: $2.",
+ "config-sqlite-name-help": "Wiki'nizi tanımlayan bir ad seçin.\nBoşluk ya da tire kullanmayın.\nBu isim SQLite veri dosyası için kullanılacaktır.",
+ "config-sqlite-mkdir-error": "Veri dizini oluşturulurken bir hata oluştu \" $1 \".\nKonumu denetleyin ve yeniden deneyin.",
+ "config-sqlite-dir-unwritable": "Bu dizine yazılamadı: \"$1\"\nİzinleri değiştirerek tekrar deneyiniz.",
+ "config-sqlite-connection-error": "$1.\n\nVeri dizini ve veritabanı adını denetleyin ve yeniden deneyin.",
+ "config-sqlite-readonly": "Dosya <code>$1</code> yazılabilir değil.",
+ "config-sqlite-cant-create-db": "Veritabanı dosyası oluşturamadı <code>$1</code> .",
+ "config-sqlite-fts3-downgrade": "PHP, FTS3 desteğinden yoksun, tabloların sürümü düşürülüyor.",
+ "config-upgrade-done-no-regenerate": "Güncelleme tamam.\n\nVikinizi kullanmaya [$1 başlayabilrsiniz].",
+ "config-regenerate": "LocalSettings.php yi yeniden oluştur →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code>sorgu başarısız!",
+ "config-unknown-collation": "<strong>Uyarı:</strong> Veritabanı tanınmayan bir harmanlama kullanıyor.",
+ "config-db-web-account": "Ağ erişimi için veritabanı hesabı",
+ "config-db-web-help": "Ağ sunucusunun olağan wiki işlemleri için veritabanına bağlanırken kullanacağı kullanıcı adı ve parolayı seçin.",
+ "config-db-web-account-same": "Yükleme için aynı hesabı kullan",
+ "config-db-web-create": "Eğer oluşturulmuş hesap yoksa yeni hesap oluştur",
+ "config-db-web-no-create-privs": "Kurulum için belirlediğiniz hesap, hesap yaratımı için gerekli izinlere sahip değil.\nBurada belirttiğiniz hesap halihazırda var olmalı.",
+ "config-mysql-engine": "Depolama motoru:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-charset": "Veritabanı karakter seti",
+ "config-mysql-binary": "İkili",
+ "config-mysql-utf8": "UTF-8",
+ "config-mssql-auth": "Kimlik doğrulama türü:",
+ "config-mssql-install-auth": "Kurulum işlemi sırasında veritabanına bağlanmak için kullanılacak doğrulama türünü seçin.\n\"{{int:config-mssql-windowsauth}}\"'ı seçerseniz,ağ sunucusu olarak çalışan kullanıcının kimlik bilgileri kullanılacaktır.",
+ "config-mssql-sqlauth": "SQL Server kimlik doğrulaması",
+ "config-mssql-windowsauth": "Windows Kimlik Doğrulama",
+ "config-site-name": "Wiki adı:",
+ "config-site-name-help": "Bu tarayıcının başlık çubuğunda ve diğer yerlerde görünecek.",
+ "config-site-name-blank": "Bir site adı girin.",
+ "config-project-namespace": "Proje isim alanı:",
+ "config-ns-generic": "Proje",
+ "config-ns-site-name": "Aynı wiki adı:$1",
+ "config-ns-other": "Diğer (belirtin)",
+ "config-ns-other-default": "MyWiki",
+ "config-ns-invalid": "Belirtilen ad \"<nowiki> $1 </nowiki>\" geçersiz.\nFarklı proje isim alanı belirtin.",
+ "config-ns-conflict": "Belirtilen ad \"<nowiki> $1 </nowiki>\" varsayılan MediaWiki ad alanı ile çakışıyor.\nFarklı proje isim alanı belirtin.",
+ "config-admin-box": "Yönetici hesabı",
+ "config-admin-name": "Kullanıcı adınız:",
+ "config-admin-password": "Parola:",
+ "config-admin-password-confirm": "Yeniden parola:",
+ "config-admin-help": "Buraya tercih ettiğiniz kullanıcı adını girin; örneğin \"Joe Bloggs\". Bu vikide oturum açmak için kullanacağınız addır.",
+ "config-admin-name-blank": "Bir yönetici kullanıcı adını giriniz.",
+ "config-admin-name-invalid": "Belirtilen ad \"<nowiki> $1 </nowiki>\" geçersiz.\nFarklı bir kullanıcı adı belirtin.",
+ "config-admin-password-blank": "Yönetici hesabı için bir parola girin.",
+ "config-admin-password-mismatch": "Girdiğiniz iki parola eşleşmiyor.",
+ "config-admin-email": "E-posta adresi:",
+ "config-admin-email-help": "Wiki'de diğer kullanıcılardan e-posta almak, parolanızı sıfırlamak ve sizin izlediğiniz sayfalarda yapılan değişikliklerin bildirilmesini sağlamak için e-posta adresinizi girin. Bu alanı boş bırakabilirsiniz.",
+ "config-admin-error-user": "Bir yönetici adı ile oluşturma sırasında iç hata \"<nowiki> $1 </nowiki>\".",
+ "config-admin-error-bademail": "Geçersiz e-posta adresi girdiniz.",
+ "config-subscribe": "[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Sürüm duyuruları e-posta listesi]ne abone olun.",
+ "config-subscribe-noemail": "Sürüm duyuruları e-posta listesine herhangi bir eposta adresi belirtmeden abone olmaya çalıştınız.\nLütfen abone olmak istiyorsanız bir posta adresi belirtiniz.",
+ "config-almost-done": "Neredeyse bitti\nŞimdi kalan yapılandırmaları atlayın ve wikiyi şimdi yükleyin.",
+ "config-optional-continue": "Bana daha fazla soru sor.",
+ "config-optional-skip": "Şimdiden sıkıldım, sadece wikiyi yükle.",
+ "config-profile": "Kullanıcı hakları profili:",
+ "config-profile-wiki": "Açık wiki",
+ "config-profile-no-anon": "Hesap oluşturmak gerekli",
+ "config-profile-fishbowl": "Yalnızca yetkili editörler",
+ "config-profile-private": "Özel wiki",
+ "config-profile-help": "Vikiler, mümkün olan en fazla kişiye değişiklik imkânı verdiğinizde, en iyi şekilde çalışır.\nMediaWiki'de son değişiklikleri incelemek ve tecrübesiz veya kötü niyetli kullanıcıların verdiği zararları geri almak kolaydır.\n\nAncak birçok kişi MediaWiki'yi farklı şekillerde kullanışlı bulmaktadır ve bazen herkesi viki yolunun faydalarına ikna etmek zordur.\nYani seçim sizin.\n\n<strong>{{int:config-profile-wiki}}</strong> modeli, giriş yapmamış olsa bile herkese değişiklik izni verir.\n\n<strong>{{int:config-profile-no-anon}}</strong> kullanan bir viki ise daha izlenebilirdir ancak sıradan, basit, gündelik katkı yapan kullanıcıları caydırabilir.\n\n<strong>{{int:config-profile-fishbowl}}</strong> onaylanmış kullanıcıların değişikliklerine izin verir ama herkes sayfaları ve sayfa geçmişlerini görebilir.\n\n<strong>{{int:config-profile-private}}</strong> sadece onaylanmış kullanıcıları değişiklik yapma ve sayfaları görme imkânı tanır.\n\nDaha karmaşık kullanıcı hakkı ayarları, yüklemeden sonra görülebilir; [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights ilgili kılavuza] bakınız.",
+ "config-license": "Telif Hakkı ve Lisans",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 veya üstü",
+ "config-license-pd": "Kamu Malı",
+ "config-license-cc-choose": "Özel bir Creative Commons lisansı seçin",
+ "config-email-settings": "E-posta ayarları",
+ "config-enable-email": "Giden e-posta etkinleştirme",
+ "config-email-user": "Kullanıcıdan kullanıcıya e-posta gönderimini etkinleştir",
+ "config-email-user-help": "Eğer tercihlerinde etkinleştirmişlerse, kullanıcıların birbirlerine e-posta göndermesine izin ver.",
+ "config-email-usertalk": "Kullanıcı mesaj sayfası bildirimlerini etkinleştir",
+ "config-email-watchlist": "Watchlist bildirimini etkinleştirmek",
+ "config-email-auth": "E-posta kimlik doğrulamasını etkinleştir",
+ "config-email-sender": "E-posta adresini ayarlayın",
+ "config-upload-settings": "Resim ve dosya yükleme",
+ "config-upload-enable": "Dosya yüklemeyi etkinleştirin",
+ "config-upload-deleted": "Silinen dosyalar için dizin:",
+ "config-logo": "Logo URL'si:",
+ "config-cc-again": "Tekrar al...",
+ "config-cc-not-chosen": "Hangi Creative Commons lisansı istiyorum ve tıklayın \"proceed\" ı seçin.",
+ "config-advanced-settings": "Gelişmiş yapılandırma",
+ "config-memcached-servers": "Memcached sunucuları:",
+ "config-extensions": "Uzantılar",
+ "config-skins": "Görünümler",
+ "config-install-step-done": "Yapıldı",
+ "config-install-step-failed": "Başarısız",
+ "config-install-database": "Veritabanı ayarlama",
+ "config-install-schema": "Şema oluştur",
+ "config-install-pg-schema-not-exist": "PostgreSQL şema yok.",
+ "config-install-pg-commit": "Değişiklikleri yapılıyor",
+ "config-pg-no-create-privs": "Kurulum için belirttiğiniz hesap yeni bir hesap oluşturmak için gereken izinlere sahip değil.",
+ "config-install-user": "Veritabanı kullanıcısı oluşturma",
+ "config-install-user-alreadyexists": "Kullanıcı \" $1 \" zaten var",
+ "config-install-user-create-failed": "Kullanıcı oluşturma \" $1 \" başarısız oldu:$2",
+ "config-install-user-missing": "Belirtilen kullanıcı \" $1 \" adlı biri yok.",
+ "config-install-user-missing-create": "Belirtilen kullanıcı \" $1 \" yok.\nOluşturmak istiyorsanız, lütfen aşağıdaki \"hesap oluştur\" onay kutusunu tıklatın.",
+ "config-install-tables": "Tabloları oluşturma",
+ "config-install-tables-exist": "''' Uyarı:'' ' MediaWiki tabloları zaten var gibi görünüyor.\nOluşturma atlanıyor.",
+ "config-install-tables-failed": "''' Hata:'' ' tablo oluşturma aşağıdaki hata ile başarısız oldu:$1",
+ "config-install-interwiki-list": "Dosya okunamadı <code>interwiki.list</code> .",
+ "config-install-interwiki-exists": "''' Uyarı:'' ' interwiki Tablo girdileri zaten görünüyor.\nVarsayılan liste atlanıyor.",
+ "config-install-stats": "İstatistik başlatılıyor",
+ "config-install-keys": "Gizli anahtar oluşturma",
+ "config-install-subscribe-notpossible": "cURL yüklü değil ve <code>allow_url_fopen</code> kullanılamaz.",
+ "config-install-mainpage": "Varsayılan içerik ile anasayfa oluşturma",
+ "config-install-extension-tables": "Uzantılar için etkinleştirilmiş tablolar oluşturma",
+ "config-install-mainpage-failed": "Anasayfa eklenemedi: $1",
+ "config-download-localsettings": "İndir <code>LocalSettings.php</code>",
+ "config-help": "Yardım",
+ "config-help-tooltip": "genişletmek için tıklayın",
+ "config-nofile": "\"$1\" dosyası bulunamadı. Silindi mi?",
+ "config-extension-link": "Vikinizin [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions eklentileri] desteklediğini biliyor musunuz?\n\n[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category Eklentileri kategorilerine göre] inceleyebilir ya da tüm eklentilerin listesini görmek için [https://www.mediawiki.org/wiki/Extension_Matrix Eklenti Matrisine] bakabilirsiniz.",
+ "mainpagetext": "'''MediaWiki başarı ile kuruldu.'''",
+ "mainpagedocfooter": "Viki yazılımının kullanımı hakkında bilgi almak için [https://meta.wikimedia.org/wiki/Help:Contents kullanıcı rehberine] bakınız.\n\n== Yeni Başlayanlar ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Yapılandırma ayarlarının listesi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki SSS]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki e-posta listesi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Kendi diliniz için MediaWiki yerelleştirmesi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Kendi vikinizde spam ile nasıl savaşılacağını öğrennin]"
+}
diff --git a/www/wiki/includes/installer/i18n/tt-cyrl.json b/www/wiki/includes/installer/i18n/tt-cyrl.json
new file mode 100644
index 00000000..2cc0d1ac
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tt-cyrl.json
@@ -0,0 +1,72 @@
+{
+ "@metadata": {
+ "authors": [
+ "KhayR",
+ "Seb35",
+ "Ильнар"
+ ]
+ },
+ "config-desc": "MediaWiki йөкләүче",
+ "config-title": "MediaWiki $1 куелышы",
+ "config-information": "Мәгълүмат",
+ "config-localsettings-key": "Яңарту ачкычы:",
+ "config-your-language": "Телегез:",
+ "config-wiki-language": "Вики теле:",
+ "config-back": "← Артка",
+ "config-continue": "Киләсе →",
+ "config-page-language": "Тел",
+ "config-page-welcome": "MediaWiki проектына рәхим итегез!",
+ "config-page-name": "Исем",
+ "config-page-options": "Көйләнмәләр",
+ "config-page-install": "Урнаштыру",
+ "config-page-complete": "Тәмам!",
+ "config-page-restart": "Урнаштыруны яңадан башлау",
+ "config-page-readme": "Укып чык",
+ "config-page-releasenotes": "Юрама турында мәгълүмат",
+ "config-page-copying": "Лицензия",
+ "config-page-upgradedoc": "Яңарту",
+ "config-page-existingwiki": "Хәзерге вики",
+ "config-restart": "Әйе, яңадан башларга",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] куелды",
+ "config-apc": "[http://www.php.net/apc APC] куелды",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] куелды",
+ "config-diff3-bad": "GNU diff3 табылмады.",
+ "config-git": "Git юрамалар идарә итү системасы табылды: <code>$1</code>.",
+ "config-db-type": "Мәгълүмат базасы төре:",
+ "config-db-host": "Мәгълүмат базасы хосты:",
+ "config-db-host-oracle": "TNS мәгълүмат базасы:",
+ "config-db-name-oracle": "Мәгълүмат базасы төзелеше:",
+ "config-db-username": "Мәгълүмат базасын кулланучы исеме:",
+ "config-db-password": "Мәгълүмат базасының серсүзе:",
+ "config-db-port": "Мәгълүмат базасы порты:",
+ "config-db-schema": "MediaWiki өчен төзелеш:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Икеле",
+ "config-mysql-utf8": "UTF-8",
+ "config-ns-generic": "Проект",
+ "config-ns-other": "Башка (күрсәтегез)",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-password": "Серсүз:",
+ "config-admin-password-confirm": "Серсүзне кабатлагыз:",
+ "config-admin-email": "Электрон почта адресы:",
+ "config-profile-wiki": "Ачык вики",
+ "config-profile-private": "Ябык вики",
+ "config-license": "Автор хокуклары һәм лицензияләр:",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (җәмгыять мирасы)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 яки яңарагы",
+ "config-license-pd": "Җәмгыять мирасы",
+ "config-logo": "Логотип URL:",
+ "config-cc-again": "Кабат сайлагыз...",
+ "config-advanced-settings": "Өстәмә көйләнмәләр",
+ "config-extensions": "Киңәйтүләр",
+ "config-skins": "Бизәлеш",
+ "config-install-step-done": "әзер",
+ "config-install-step-failed": "булмады",
+ "config-help": "ярдәм",
+ "mainpagetext": "<strong>«MediaWiki» куелды.</strong>",
+ "mainpagedocfooter": "Бу вики турында мәгълүматны [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/ru биредә] табып була.\n\n== Кайбер файдалы ресурслар ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Көйләнмәләр исемлеге (рус.)];\n* [https://www.mediawiki.org/wiki/Manual:FAQ/ru MediaWiki турында еш бирелгән сораулар һәм җаваплар (рус.)];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki сәхифәсенең яңа юрамалары турында хәбәрләр яздырып алу].\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources MediaWiki сәхифәсен туган телегезгә тәрҗемә итү]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Үзегезнең викида ничек спам белән көрәшү турында мәгълүмат]"
+}
diff --git a/www/wiki/includes/installer/i18n/tt-latn.json b/www/wiki/includes/installer/i18n/tt-latn.json
new file mode 100644
index 00000000..5680a1eb
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tt-latn.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Don Alessandro",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "«MediaWiki» uñışlı quyıldı.",
+ "mainpagedocfooter": "Bu wiki turında mäğlümatnı [https://meta.wikimedia.org/wiki/Help:Contents biredä] tabıp bula.\n\n== Qayber faydalı resurslar ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Köylänmälär isemlege (ing.)];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki turında yış birelgän sorawlar häm cawaplar (ing.)];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki'nıñ yaña versiäläre turında xäbärlär yazdırıp alu];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]."
+}
diff --git a/www/wiki/includes/installer/i18n/tyv.json b/www/wiki/includes/installer/i18n/tyv.json
new file mode 100644
index 00000000..1652bcf7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/tyv.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Agilight"
+ ]
+ },
+ "config-page-welcome": "MediaWiki-же кирип моорлаңар!"
+}
diff --git a/www/wiki/includes/installer/i18n/udm.json b/www/wiki/includes/installer/i18n/udm.json
new file mode 100644
index 00000000..5500097f
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/udm.json
@@ -0,0 +1,23 @@
+{
+ "@metadata": {
+ "authors": [
+ "Andrewboltachev",
+ "AlnashPiyash2",
+ "Zpizza"
+ ]
+ },
+ "config-title": "MediaWiki $1 пуктон",
+ "config-information": "Информация",
+ "config-your-language": "Тӥляд кылды:",
+ "config-back": "← Берлань",
+ "config-continue": "Азьлань →",
+ "config-page-language": "Кыл",
+ "config-page-name": "Ним",
+ "config-page-options": "Настройкаос",
+ "config-page-complete": "Быдэстэмын!",
+ "config-page-readme": "Лыдӟы монэ",
+ "config-page-copying": "Лицензия",
+ "config-page-upgradedoc": "Выльдытон",
+ "config-diff3-bad": "GNU diff3 шедьтэмын ӧвӧл.",
+ "mainpagetext": "<strong>MediaWiki движок азинлыко пуктэмын.</strong>"
+}
diff --git a/www/wiki/includes/installer/i18n/ug-arab.json b/www/wiki/includes/installer/i18n/ug-arab.json
new file mode 100644
index 00000000..7c187f74
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ug-arab.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sahran"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki مۇۋەپپەقىيەتلىك قاچىلاندى.'''",
+ "mainpagedocfooter": "[https://meta.wikimedia.org/wiki/Help:Contents ئىشلەتكۈچى قوللانمىسى] نى زىيارەت قىلىپ wiki يۇمشاق دېتالىنى ئىشلىتىش ئۇچۇرىغا ئېرىشىڭ.\n\n== دەسلەپكى ساۋات ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings سەپلىمە تەڭشەك تىزىملىكى]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki كۆپ ئۇچرايدىغان مەسىلىلەرگە جاۋاب]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki تارقاتقان ئېلخەت تىزىملىكى]"
+}
diff --git a/www/wiki/includes/installer/i18n/uk.json b/www/wiki/includes/installer/i18n/uk.json
new file mode 100644
index 00000000..54339c3b
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/uk.json
@@ -0,0 +1,326 @@
+{
+ "@metadata": {
+ "authors": [
+ "AS",
+ "Ahonc",
+ "Alex Khimich",
+ "Andriykopanytsia",
+ "Base",
+ "Diemon.ukr",
+ "Ата",
+ "Тест",
+ "아라",
+ "Amire80",
+ "Piramidion",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Інсталятор MediaWiki",
+ "config-title": "Встановлення MediaWiki $1",
+ "config-information": "Інформація",
+ "config-localsettings-upgrade": "'''Увага''': було виявлено файл <code>LocalSettings.php</code>.\nВаше програмне забезпечення може бути оновлено.\nБудь-ласка, перемістіть файл <code>LocalSettings.php</code> в іншу безпечну директорію, а потім знову запустіть програму установки.",
+ "config-localsettings-cli-upgrade": "Виявлено файл <code>LocalSettings.php</code>.\nЩоб оновити наявну установку, запустіть <code>update.php</code>",
+ "config-localsettings-key": "Ключ оновлення:",
+ "config-localsettings-badkey": "Ви вказали неправильний ключ оновлення.",
+ "config-upgrade-key-missing": "Виявлено наявну установку MediaWiki.\nДля оновлення цієї установки, будь ласка, вставте такий рядок в кінець вашого <code>LocalSettings.php</code>:\n$1",
+ "config-localsettings-incomplete": "Існуючий файл <code>LocalSettings.php</code> виявився неповним.\nНе вказано змінну $1.\nБудь ласка, змініть <code>LocalSettings.php</code> так, щоб цю змінну було задано, і натисніть \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "Сталася помилка при підключення до бази даних з допомогою налаштувань на сторінці <code>LocalSettings.php</code>. Будь ласка, виправте ці налаштування і спробуйте знову.\n\n$1",
+ "config-session-error": "Помилка початку сесії: $1",
+ "config-session-expired": "Час Вашої сесії минув.\nЗадана тривалість сесії — $1.\nВи можете збільшити її, змінивши <code>session.gc_maxlifetime</code> у php.ini.\nПерезапустіть процес встановлення.",
+ "config-no-session": "Дані сесії було втрачено!\nПеревірте Ваш php.ini і переконайтесь, що <code>session.save_path</code> встановлено у відповідну папку.",
+ "config-your-language": "Ваша мова:",
+ "config-your-language-help": "Оберіть мову для використання в процесі установки.",
+ "config-wiki-language": "Мова для вікі:",
+ "config-wiki-language-help": "Виберіть мову, якою буде відображатися вікі.",
+ "config-back": "← Назад",
+ "config-continue": "Далі →",
+ "config-page-language": "Мова",
+ "config-page-welcome": "Ласкаво просимо на MediaWiki!",
+ "config-page-dbconnect": "Підключення до бази даних",
+ "config-page-upgrade": "Оновлення існуючої установки",
+ "config-page-dbsettings": "Налаштування бази даних",
+ "config-page-name": "Назва",
+ "config-page-options": "Параметри",
+ "config-page-install": "Установка",
+ "config-page-complete": "Готово!",
+ "config-page-restart": "Перезапустити установку",
+ "config-page-readme": "Прочитай мене",
+ "config-page-releasenotes": "Інформація про версію",
+ "config-page-copying": "Копіювання",
+ "config-page-upgradedoc": "Оновлення",
+ "config-page-existingwiki": "Існуюча вікі",
+ "config-help-restart": "Ви бажаєте видалити всі введені та збережені вами дані і запустити процес установки спочатку?",
+ "config-restart": "Так, перезапустити установку",
+ "config-welcome": "=== Перевірка оточення ===\nБудуть проведені базові перевірки, щоб виявити, чи можлива установка MediaWiki у даній системі.\nНе забудьте включити цю інформацію, якщо ви звернетеся по підтримку, як завершити установку.",
+ "config-copyright": "=== Авторське право і умови ===\n\n$1\n\nЦя програма є вільним програмним забезпеченням; Ви можете розповсюджувати та/або змінювати її під ліцензією GNU General Public License, опублікованою Фондом вільного програмного забезпечення; версією 2 цієї ліцензії або будь-якою пізнішою на Ваш вибір.\n\nЦя програма поширюється з надією на те, що вона буде корисною, однак '''без жодних гарантій'''; навіть без неявної гарантії '''комерційної цінності''' або '''придатності для певних цілей'''.\nДив. GNU General Public License для детальної інформації.\n\nВи повинні були отримати <doclink href=Copying>копію GNU General Public License</doclink> разом із цією програмою; якщо ж ні, зверніться до Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. або [http://www.gnu.org/copyleft/gpl.html ознайомтесь з нею онлайн].",
+ "config-sidebar": "* [https://www.mediawiki.org Сайт MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Посібник користувача]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Посібник адміністратора]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ]\n----\n* <doclink href=Readme>Read me</doclink>\n* <doclink href=ReleaseNotes>Інформація про випуск</doclink>\n* <doclink href=Copying>Ліцензія</doclink>\n* <doclink href=UpgradeDoc>Оновлення</doclink>",
+ "config-env-good": "Перевірку середовища успішно завершено.\nВи можете встановити MediaWiki.",
+ "config-env-bad": "Було проведено перевірку середовища. Ви не можете встановити MediaWiki.",
+ "config-env-php": "Встановлено версію PHP: $1.",
+ "config-env-hhvm": "HHVM $1 встановлено.",
+ "config-unicode-using-intl": "Використовувати [http://pecl.php.net/intl міжнародне розширення PECL] для нормалізації Юнікоду.",
+ "config-unicode-pure-php-warning": "'''Увага''': [http://pecl.php.net/intl міжнародне розширення PECL] не може провести нормалізацію Юнікоду.\nЯкщо ваш сайт має високий трафік, вам варто почитати про [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations нормалізацію Юнікоду].",
+ "config-unicode-update-warning": "'''Увага''': Встановлена версія обгортки нормалізації Юнікоду використовує стару версію бібліотеки [http://site.icu-project.org/ проекту ICU].\nВи маєте [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations оновити версію], якщо плануєте повноцінно використовувати Юнікод.",
+ "config-no-db": "Не вдалося знайти потрібний драйвер бази даних! Вам необхідно встановити драйвер бази даних для PHP. Підтримуються {{PLURAL:$2|такий тип|такі типи}} баз даних: $1.\n\nЯкщо ви скомпілювали PHP самостійно, переналаштуйте його з увімкненим клієнтом бази даних, наприклад за допомогою <code>./configure --with-mysqli</code>.\n\nЯкщо установлено PHP з пакетів Debian або Ubuntu, тоді ви також повинні встановити, наприклад, пакунок <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "'''Увага''': у Вас встановлена версія SQLite $1, а це нижче, ніж мінімально необхідна версія $2. SQLite буде недоступним.",
+ "config-no-fts3": "'''Увага''': SQLite зібраний без [//sqlite.org/fts3.html модуля FTS3], функції пошуку не будуть працювати у цій системі.",
+ "config-pcre-old": "'''Фатальна помилка:''' потрібно PCRE версії $1 або пізнішої.\nВаш виконуваний файл PHP пов'язаний з PCRE версії $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Подробиці].",
+ "config-pcre-no-utf8": "'''Помилка''': PCRE-модуть PHP, вочевидь, було зібрано без підтримки PCRE_UTF8.\nMediaWiki вимагає підтримку UTF-8 для коректної роботи.",
+ "config-memory-raised": "Обмеження пам'яті PHP (<code>memory_limit</code>) $1, піднято до $2.",
+ "config-memory-bad": "'''Увага:''' Розмір пам'яті PHP (<code>memory_limit</code>) становить $1.\nІмовірно, це замало.\nВстановлення може не вдатись!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] встановлено",
+ "config-apc": "[http://www.php.net/apc APC] встановлено",
+ "config-apcu": "[http://www.php.net/apcu APCu] встановлено",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] встановлено",
+ "config-no-cache-apcu": "<strong>Увага:</strong> Не вдалося знайти [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] чи [http://www.iis.net/download/WinCacheForPhp WinCache].\nКешування об'єктів не ввімкнено.",
+ "config-mod-security": "'''Увага''': на Вашому веб-сервері увімкнено [http://modsecurity.org/ mod_security]. У разі неправильних налаштувать, він може викликати проблеми MediaWiki або іншого ПЗ, яке дозволяє користувачам надсилати довільний вміст.\nЗверніться до [http://modsecurity.org/documentation/ документації mod_security] або підтримки Вашого хостера, якщо під час роботи виникають незрозумілі помилки.",
+ "config-diff3-bad": "GNU diff3 не знайдено.",
+ "config-git": "Знайшов програму управління версіями Git: <code>$1</code>.",
+ "config-git-bad": "Програму управління версіями Git не знайдено.",
+ "config-imagemagick": "Виявлено ImageMagick: <code>$1</code>.\nБуде ввімкнуто відображення мініатюр, якщо ви дозволите завантаження файлів.",
+ "config-gd": "Виявлено вбудовано графічну бібліотеку GD.\nБуде ввімкнуто відображення мініатюр, якщо ви дозволите завантаження файлів.",
+ "config-no-scaling": "Не вдалося виявити бібліотеку GD чи ImageMagick.\nВідображення мініатюр буде вимкнено.",
+ "config-no-uri": "'''Помилка:''' Не вдалося визначити поточний URI.\nВстановлення перервано.",
+ "config-no-cli-uri": "'''Увага''': Не задано параметр <code>--scriptpath</code>, використовується за замовчуванням: <code>$1</code>.",
+ "config-using-server": "Використовується ім'я сервера \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "Використовується URL сервера \"<nowiki>$1$2</nowiki>\".",
+ "config-uploads-not-safe": "'''Увага:''' Ваша типова папка для завантажень <code>$1</code> вразлива до виконання довільних скриптів.\nХоча MediaWiki перевіряє усі завантажені файли на наявність загроз, наполегливо рекомендується [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security закрити дану вразливість] перед тим, як дозволяти завантаження файлів.",
+ "config-no-cli-uploads-check": "'''Увага:''' Ваша типова папка для завантажень (<code>$1</code>) не перевірялась на вразливість до виконання довільних скриптів під час встановлення CLI.",
+ "config-brokenlibxml": "У Вашій системі невдале поєднання версій PHP і libxml2, яке може спричинити пошкодження прихованих даних у MediaWiki та інших веб-застосунках.\nОновіть libxml2 до версії 2.7.3 або пізнішої ([https://bugs.php.net/bug.php?id=45996 відомості про помилку]).\nВстановлення перервано.",
+ "config-suhosin-max-value-length": "Suhosin встановлено і обмежує параметра GET <code>length</code> до $1 байта. Компонент MediaWiki ResourceLoader буде обходити це обмеження, однак це зменшить продуктивність. Якщо це можливо, Вам варто встановити значення <code>suhosin.get.max_value_length</code> як 1024 і більше у <code>php.ini</code> і встановити таке ж значення <code>$wgResourceLoaderMaxQueryLength</code> у LocalSettings.php .",
+ "config-using-32bit": "<strong>Попередження:</strong> схоже, що Ваша система працює з 32-бітними цілими числами. Таке [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit не рекомендується].",
+ "config-db-type": "Тип бази даних:",
+ "config-db-host": "Хост бази даних:",
+ "config-db-host-help": "Якщо сервер бази даних знаходиться на іншому сервері, введіть тут ім'я хосту і IP адресу.\n\nЯкщо Ви використовуєте віртуальний хостинг, Ваш хостинг-провайдер має надати Вам правильне ім'я хосту у його документації.\n\nЯкщо у Вас сервер із Windows Ви використовуєте MySQL, параметр \"localhost\" може не працювати для імені сервера. Якщо не працює, використайте \"127.0.0.1\" як локальну IP-адресу.\n\nЯкщо Ви використовуєте PostgreSQL, залиште це поле пустим, щоб під'єднатись через сокет Unix.",
+ "config-db-host-oracle": "TNS бази даних:",
+ "config-db-host-oracle-help": "Введіть допустиме [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; файл tnsnames.ora має бути видимим для цієї інсталяції. <br />Якщо Ви використовуєте бібліотеки 10g чи новіші, можна також використовувати метод іменування [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Ідентифікувати цю вікі",
+ "config-db-name": "Назва бази даних:",
+ "config-db-name-help": "Виберіть назву, що ідентифікує Вашу вікі.\nВона не повинна містити пробілів.\n\nЯкщо Ви використовуєте віртуальний хостинг, Ваш хостинг-провайдер або надасть Вам конкретну назву бази даних, або дозволить створювати бази даних з допомогою панелі управління.",
+ "config-db-name-oracle": "Схема бази даних:",
+ "config-db-account-oracle-warn": "Є три підтримувані сценарії установки Oracle:\n\nЯкщо Ви хочете створити обліковий запис бази даних у процесі встановлення, будь ласка, вкажіть обліковий запис ролі SYSDBA для установки і бажані повноваження для облікового запису з веб-доступом. В протилежному випадку Ви можете або створити обліковий запис з веб-доступом вручну і вказати тільки цей обліковий запис (якщо він має необхідні дозволи на створення об'єктів-схем), або вказати два різні облікові записи, з яких в одного будуть права на створення, а в другого, обмеженого — права веб-доступу.\n\nСкрипт для створення облікового запису з необхідними повноваженнями можна знайти у папці \"maintenance/oracle/\" цієї інсталяції. Майте на увазі, що використання обмеженого облікового запису вимкне можливість використання технічного обслуговування з облікового запису за замовчуванням.",
+ "config-db-install-account": "Обліковий запис користувача для встановлення",
+ "config-db-username": "Ім'я користувача бази даних:",
+ "config-db-password": "Пароль бази даних:",
+ "config-db-install-username": "Введіть ім'я користувача, яке буде використано для підключення до бази даних під час процесу встановлення.\nЦе не ім'я користувача облікового запису MediaWiki; це ім'я користувача для Вашої бази даних.",
+ "config-db-install-password": "Введіть пароль, який буде використано для підключення до бази даних під час процесу встановлення.\nЦе не пароль облікового запису MediaWiki; це пароль для Вашої бази даних.",
+ "config-db-install-help": "Введіть ім'я користувача і пароль, які буде використано для підключення до бази даних у процесі встановлення.",
+ "config-db-account-lock": "Використовувати ті ж ім'я користувача і пароль і для звичайної роботи",
+ "config-db-wiki-account": "Обліковий запис користувача для звичайної роботи",
+ "config-db-wiki-help": "Введіть ім'я користувача і пароль, які будуть використовуватись для з'єднання з базою даних під час звичайної роботи.\nЯкщо обліковий запис не існує, а в облікового запису інсталяції є достатні повноваження, цей обліковий запис користувача буде створено з мінімальними правами, що необхідні для роботи з вікі.",
+ "config-db-prefix": "Префікс таблиць бази даних:",
+ "config-db-prefix-help": "Якщо треба ділити одну базу даних між декількома вікі або між MediaWiki та іншим веб-застосунком, Ви можете додати префікс до усіх назв таблиць для уникнення конфліктів.\nНе використовуйте пробіли.\n\nЦе поле зазвичай залишають пустим.",
+ "config-mysql-old": "Необхідна MySQL $1 або пізніша, а у Вас $2.",
+ "config-db-port": "Порт бази даних:",
+ "config-db-schema": "Схема для MediaWiki",
+ "config-db-schema-help": "Ця схема зазвичай працює добре.\nЗмінюйте її тільки якщо знаєте, що Вам це потрібно.",
+ "config-pg-test-error": "Не вдається підключитися до бази даних '''$1''': $2",
+ "config-sqlite-dir": "Папка даних SQLite:",
+ "config-sqlite-dir-help": "SQLite зберігає усі дані в єдиному файлі.\n\nПапка, яку Ви вказуєте, має бути доступна серверу для запису під час встановлення.\n\nВона '''не''' повинна бути доступна через інтернет, тому ми і не поміщуємо її туди, де Ваші файли PHP.\n\nІнсталятор пропише у неї файл <code>.htaccess</code>, але якщо це не спрацює, хтось може отримати доступ до Вашої вихідної бази даних, яка містить вихідні дані користувача (адреси електронної пошти, хеші паролів), а також видалені версії та інші обмежені дані на вікі.\n\nЗа можливості розташуйте базу даних десь окремо, наприклад в <code>/var/lib/mediawiki/yourwiki</code>.",
+ "config-oracle-def-ts": "Простір таблиць за замовчуванням:",
+ "config-oracle-temp-ts": "Тимчасовий простір таблиць:",
+ "config-type-mysql": "MySQL (або сумісний)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki підтримує такі системи баз даних:\n\n$1\n\nЯкщо Ви не бачите серед перерахованих систему баз даних, яку використовуєте, виконайте вказівки, вказані вище, щоб увімкнути підтримку.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] є основною для MediaWiki і найкраще підтримується. MediaWiki також працює із [{{int:version-db-mariadb-url}} MariaDB] та [{{int:version-db-percona-url}} Percona Server], які сумісні з MySQL. ([http://www.php.net/manual/en/mysqli.installation.php як зібрати PHP з допомогою MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] — популярна відкрита СУБД, альтернатива MySQL. ([http://www.php.net/manual/en/pgsql.installation.php як зібрати PHP з допомогою PostgreSQL]).",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] — легка система баз даних, яка дуже добре підтримується. ([http://www.php.net/manual/en/pdo.installation.php Як зібрати PHP з допомогою SQLite], що використовує PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] — комерційна база даних масштабу підприємства. ([http://www.php.net/manual/en/oci8.installation.php Як зібрати PHP з підтримкою OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] — це комерційна база даних для Windows масштабу підприємства. ([http://www.php.net/manual/ru/sqlsrv.installation.php Як зібрати PHP з підтримкою SQLSRV])",
+ "config-header-mysql": "Налаштування MySQL",
+ "config-header-postgres": "Налаштування PostgreSQL",
+ "config-header-sqlite": "Налаштування SQLite",
+ "config-header-oracle": "Налаштування Oracle",
+ "config-header-mssql": "Параметри Microsoft SQL Server",
+ "config-invalid-db-type": "Невірний тип бази даних",
+ "config-missing-db-name": "Ви повинні ввести значення параметра «{{int:config-db-name}}».",
+ "config-missing-db-host": "Ви повинні ввести значення параметра «{{int:config-db-host}}».",
+ "config-missing-db-server-oracle": "Ви повинні ввести значення параметра «{{int:config-db-host-oracle}}».",
+ "config-invalid-db-server-oracle": "Неприпустиме TNS бази даних \"$1\".\nВикористовуйте \"TNS Name\" або рядок \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Методи найменування Oracle])",
+ "config-invalid-db-name": "Неприпустима назва бази даних \"$1\".\nВикористовуйте тільки ASCII букви (a-z, A-Z), цифри (0-9), знаки підкреслення (_) і дефіси (-).",
+ "config-invalid-db-prefix": "Неприпустимий префікс бази даних \"$1\".\nВикористовуйте тільки ASCII букви (a-z, A-Z), цифри (0-9), знаки підкреслення (_) і дефіси (-).",
+ "config-connection-error": "$1.\n\nПеревірте хост, ім'я користувача та пароль і спробуйте ще раз.",
+ "config-invalid-schema": "Неприпустима схема для MediaWiki \"$1\".\nВикористовуйте тільки ASCII букви (a-z, A-Z), цифри (0-9) і знаки підкреслення(_).",
+ "config-db-sys-create-oracle": "Інсталятор підтримує лише використання облікового запису SYSDBA для створення нового облікового запису.",
+ "config-db-sys-user-exists-oracle": "Обліковий запис користувача \"$1\" уже існує. SYSDBA використовується лише для створення новий облікових записів!",
+ "config-postgres-old": "Необхідна PostgreSQL $1 або пізніша, а у Вас $2.",
+ "config-mssql-old": "Вимагається Microsoft SQL Server версії $1 або більш пізнішої. У вас установлена версія $2.",
+ "config-sqlite-name-help": "Виберіть назву, що ідентифікує Вашу вікі.\nНе використовуйте пробіли і дефіси.\nЦе буде використовуватись у назві файлу даних SQLite.",
+ "config-sqlite-parent-unwritable-group": "Не можна створити папку даних <code><nowiki>$1</nowiki></code>, оскільки батьківська папка <code><nowiki>$2</nowiki></code> не доступна веб-серверу для запису.\n\nІнсталятор виявив, під яким користувачем працює Ваш сервер.\nЗробіть папку <code><nowiki>$3</nowiki></code> доступною для запису, щоб продовжити.\nВ ОС Unix/Linux виконайте:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Не можна створити папку даних <code><nowiki>$1</nowiki></code>, оскільки батьківська папка <code><nowiki>$2</nowiki></code> не доступна веб-серверу для запису.\n\nІнсталятор не зміг виявити, під яким користувачем працює Ваш сервер.\nЗробіть папку <code><nowiki>$3</nowiki></code> доступною для запису серверу (і всім!) глобально, щоб продовжити.\nВ ОС Unix/Linux виконайте:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Помилка при створенні папки даних \"$1\".\nПеревірте розташування і спробуйте знову.",
+ "config-sqlite-dir-unwritable": "Не можливо записати до папки \"$1\".\nЗмініть налаштування доступу так, щоб веб-сервер міг писати до неї, і спробуйте ще раз.",
+ "config-sqlite-connection-error": "$1.\n\nПеревірте папку даних і назву бази даних нижче та спробуйте знову.",
+ "config-sqlite-readonly": "Файл <code>$1</code> недоступний для запису.",
+ "config-sqlite-cant-create-db": "Не вдалося створити файл бази даних <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "У PHP немає підтримки FTS3, скидаю таблиці",
+ "config-can-upgrade": "У цій базі даних є таблиці MediaWiki.\nЩоб оновити їх до MediaWiki $1, натисніть '''Продовжити'''.",
+ "config-upgrade-done": "Оновлення завершено.\n\nВи можете зараз [$1 починати використовувати свою вікі].\n\nЯкщо Ви хочете повторно згенерувати файл <code>LocalSettings.php</code>, натисніть на кнопку нижче.\nЦе '''не рекомендується''', якщо тільки у Вас не виникли проблеми з Вашою вікі.",
+ "config-upgrade-done-no-regenerate": "Оновлення завершено.\n\nВи можете зараз [$1 починати використовувати свою вікі].",
+ "config-regenerate": "Повторно згенерувати LocalSettings.php →",
+ "config-show-table-status": "Запит <code>SHOW TABLE STATUS</code> не виконано!",
+ "config-unknown-collation": "'''Увага:''' База даних використовує нерозпізнане сортування.",
+ "config-db-web-account": "Обліковий запис бази даних для інтернет-доступу",
+ "config-db-web-help": "Оберіть ім'я користувача і пароль, які веб-сервер буде використовувати для з'єднання із сервером бази даних під час звичайної роботи вікі.",
+ "config-db-web-account-same": "Використати той же обліковий запис для встановлення",
+ "config-db-web-create": "Створити обліковий запис, якщо його ще не існує",
+ "config-db-web-no-create-privs": "Обліковий запис, вказаний Вами для встановлення, не має достатніх повноважень для створення облікового запису.\nОбліковий запис, який Ви вказуєте тут, уже повинен існувати.",
+ "config-mysql-engine": "Двигун бази даних:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "'''Увага''': Ви обрали MyISAM для зберігання даних MySQL, що не рекомендовано для роботи з MediaWiki, оскільки:\n* він слабко підтримує паралелізм через блокування таблиць\n* він більш схильний до ушкоджень, ніж інші двигуни\n* база коду MediaWiki не завжди працює з MyISAM так, як мала б.\n\nЯкщо Ваша інсталяція MySQL підтримує InnoDB, дуже рекомендується вибрати цей двигун.\nЯкщо Ваша інсталяція MySQL не підтримує InnoDB, можливо настав час її оновити.",
+ "config-mysql-only-myisam-dep": "\"'Зауваження:\"' MyISAM є єдиним механізмом для зберігання MySQL на цій машині, який не рекомендується для використання з MediaWiki, оскільки:\n* слабо підтримує паралелізм через блокування таблиць\n* більш схильний до пошкоджень, ніж інші двигуни\n* код MediaWiki не завжди розглядає MyISAM, як повинен\n\nТвоє встановлення MySQL не підтримує InnoDB, можливо, потрібно оновити.",
+ "config-mysql-engine-help": "'''InnoDB''' є завжди кращим вибором, оскільки краще підтримує паралельний доступ.\n\n'''MyISAM''' може бути швидшим для одного користувача або в інсталяціях read-only.\nБази даних MyISAM схильні псуватись частіше, ніж бази InnoDB.",
+ "config-mysql-charset": "Кодування бази даних:",
+ "config-mysql-binary": "Двійкове",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "У '''бінарному режимі''' MediaWiki зберігає текст UTF-8 у базі даних з бінарними полями.\nЦе більш ефективно, ніж UTF-8 режим MySQL, і дозволяє використовувати увесь набір символів Юнікоду.\n\nУ '''режимі UTF-8''' MySQL буде знати, якого символу стосуються Ваші дані, і могтиме відображати та конвертувати їх належним чином, але не дозволятиме зберігати символи, що виходять за межі [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane].",
+ "config-mssql-auth": "Тип автентифікації:",
+ "config-mssql-install-auth": "Виберіть тип перевірки автентичності, який буде використовуватися для підключення до бази даних під час процесу установки. \nЯкщо ви оберете \"{{int:config-mssql-windowsauth}}\", будуть використовуватися облікові дані користувача, під яким працює веб-сервер.",
+ "config-mssql-web-auth": "Виберіть тип перевірки автентичності, який веб-сервер буде використовувати для підключення до сервера бази даних під час звичайного функціонування вікі. \nЯкщо ви оберете \"{{int:config-mssql-windowsauth}}\", будуть використовуватися облікові дані користувача, під яким працює веб-сервер.",
+ "config-mssql-sqlauth": "Автентифікація сервера SQL",
+ "config-mssql-windowsauth": "Перевірка Достовірності Windows",
+ "config-site-name": "Назва вікі:",
+ "config-site-name-help": "Це буде відображатись у заголовку вікна браузера та у деяких інших місцях.",
+ "config-site-name-blank": "Введіть назву сайту.",
+ "config-project-namespace": "Простір назв проекту:",
+ "config-ns-generic": "Проект",
+ "config-ns-site-name": "Те ж саме, що й назва вікі: $1",
+ "config-ns-other": "Інше (вкажіть)",
+ "config-ns-other-default": "MyWiki",
+ "config-project-namespace-help": "За прикладом Вікіпедії, чимало вікі тримають свої сторінки правил окремо від сторінок основного вмісту, у '''просторі назв проекту'''.\nУсі назви сторінок у цьому просторі назв починаються з певного префікса, який Ви можете вказати тут.\nЗазвичай цей префікс виводиться з назви вікі, але не може містити знаки пунктуації, як-то «#» чи «:».",
+ "config-ns-invalid": "Вказаний простір назв «<nowiki>$1</nowiki>» не припустимий.\nВкажіть інший простір назв проекту.",
+ "config-ns-conflict": "Вказаний простір назв «<nowiki>$1</nowiki>» конфліктує зі стандартним простором назв MediaWiki.\nВкажіть інший простір назв проекту.",
+ "config-admin-box": "Обліковий запис адміністратора",
+ "config-admin-name": "Ваше ім'я користувача:",
+ "config-admin-password": "Пароль:",
+ "config-admin-password-confirm": "Пароль ще раз:",
+ "config-admin-help": "Введіть бажане ім'я користувача тут, наприклад \"Павло НЛО\".\nЦе ім'я ви будете використовувати про вході у вікі.",
+ "config-admin-name-blank": "Введіть ім'я користувача адміністратора.",
+ "config-admin-name-invalid": "Вказане ім'я користувача «<nowiki>$1</nowiki>» не припустиме.\nВкажіть інше ім'я користувача.",
+ "config-admin-password-blank": "Введіть пароль до облікового запису адміністратора.",
+ "config-admin-password-mismatch": "Два введені вами паролі не збігаються.",
+ "config-admin-email": "Адреса електронної пошти:",
+ "config-admin-email-help": "Введіть адресу електронної пошти, щоб мати змогу отримувати електронну пошту від інших користувачів у вікі, відновити пароль і отримувати повідомлення про зміни, внесені до сторінок у Вашому списку спостереження. Ви можете залишити це поле пустим.",
+ "config-admin-error-user": "Внутрішня помилка під час створення адміністратора з ім'ям \"<nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Внутрішня помилка під час встановлення пароля для адміністратора \"<nowiki>$1</nowiki>\":<pre>$2</pre>",
+ "config-admin-error-bademail": "Ви ввели недопустиму адресу електронної пошти.",
+ "config-subscribe": "Підписатися на [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce розсилку анонсів нових версій MediaWiki].",
+ "config-subscribe-help": "Це список розсилки з малим обсягом повідомлень, що використовується для анонсування релізів, а також важливих повідомлень про безпеку.\nВам варто підписати і оновлювати інсталяцію MediaWiki, коли з'являтимуться нові версії.",
+ "config-subscribe-noemail": "Ви намагались підписатись на розсилку анонсів релізів, не вказавши адреси електронної пошти.\nБудь ласка, вкажіть адресу електронної пошти, якщо хочете підписатись на розсилку.",
+ "config-pingback": "Поділитися даними про цю інсталяцію з розробниками MediaWiki.",
+ "config-pingback-help": "Якщо Ви обираєте цю опцію, MediaWiki періодично пінгуватиме https://www.mediawiki.org базовими даними про цю інсталяцію MediaWiki. Дані включають, наприклад, тип системи, версію PHP, обраний бекенд бази даних. Фонд Вікімедіа ділиться цими даними з розробниками MediaWiki, щоб допомогти спрямувати подальні розробки. Від Вашої системи надсилатимуться такі дані:\n<pre>$1</pre>",
+ "config-almost-done": "Майже готово!\nВи можете зараз пропустити налаштування, що залишилось, і встановити вікі прямо зараз.",
+ "config-optional-continue": "Запитуйте ще.",
+ "config-optional-skip": "Це вже втомлює, просто встановити вікі.",
+ "config-profile": "Профіль прав користувача:",
+ "config-profile-wiki": "Відкрита вікі",
+ "config-profile-no-anon": "Необхідно створити обліковий запис",
+ "config-profile-fishbowl": "Тільки для авторизованих редакторів",
+ "config-profile-private": "Приватна вікі",
+ "config-profile-help": "Вікі краще працюють, коли Ви дозволяєте їх редагувати якомога ширшому колу людей.\nУ MediaWiki легко переглядати останні зміни і відкочувати будь-яку шкоду, спричинену недосвідченими або зловмисними користувачами.\n\nОдначе, MediaWiki може бути корисна по-різному, й інколи важко переконати у вигідності відкритої вікі-роботи.\nТож у Вас є вибір.\n\nМодель '''{{int:config-profile-wiki}}''' дозволяє редагувати будь-кому, навіть без входження в систему.\nВікі з вимогою \"'''{{int:config-profile-no-anon}}'''\" дає певний облік, але може відвернути випадкових дописувачів.\nСпосіб \"'''{{int:config-profile-fishbowl}}'''\" дозволяє редагувати підтвердженим користувачам, а переглядати сторінки і історію можуть усі.\n'''{{int:config-profile-private}}''' дозволяє переглядати сторінки і редагувати лише підтвердженим користувачам.\n\nДетальніші конфігурації прав користувачів доступні після встановлення, див. [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights відповідний розділ посібника].",
+ "config-license": "Авторські права і ліцензія:",
+ "config-license-none": "Без ліцензії у нижньому колонтитулі",
+ "config-license-cc-by-sa": "Creative Commons Attribution Share Alike",
+ "config-license-cc-by": "Creative Commons Attribution",
+ "config-license-cc-by-nc-sa": "Creative Commons Attribution Non-Commercial Share Alike",
+ "config-license-cc-0": "Creative Commons Zero (Суспільне надбання)",
+ "config-license-gfdl": "GNU Free Documentation License 1.3 або пізніша",
+ "config-license-pd": "Суспільне надбання (Public Domain)",
+ "config-license-cc-choose": "Виберіть одну з ліцензій Creative Commons",
+ "config-license-help": "Чимало загальнодоступних вікі публікують увесь свій вміст під [http://freedomdefined.org/Definition вільною ліцензією]. Це розвиває відчуття спільної власності і заохочує довготривалу участь. У загальному випадку для приватної чи корпоративної вікі у цьому немає необхідності.\n\nЯкщо Ви хочете мати змогу використовувати текст з Вікіпедії і дати Вікіпедії змогу використовувати текст, скопійований з Вашої вікі, вам необхідно обрати <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nРаніше Вікіпедія використовувала GNU Free Documentation License.\nGFDL — допустима ліцензія, але у ній важко розібратися, а контент під GFDL важко використовувати повторно.",
+ "config-email-settings": "Налаштування електронної пошти",
+ "config-enable-email": "Увімкнути вихідну електронну пошту",
+ "config-enable-email-help": "Якщо Ви хочете, що електронна пошта працювала, необхідно виставити коректні [http://www.php.net/manual/en/mail.configuration.php налаштування пошти у PHP].\nЯкщо Вам не потрібні жодні можливості електронної пошти у вікі, можете тут їх відключити.",
+ "config-email-user": "Увімкнути електронну пошту користувач-користувачеві",
+ "config-email-user-help": "Дозволити усім користувачам надсилати один одному електронну пошту, якщо вони увімкнули цю можливість у своїх налаштуваннях.",
+ "config-email-usertalk": "Увімкнути сповіщення про повідомлення на сторінці обговорення користувача",
+ "config-email-usertalk-help": "Дозволити користувачам отримувати сповіщення про зміни на своїй сторінці обговорення, якщо вони увімкнули цю можливість у своїх налаштуваннях.",
+ "config-email-watchlist": "Увімкнути сповіщення про зміни у списку спостереження",
+ "config-email-watchlist-help": "Дозволити користувачам отримувати сповіщення про сторінки з їхнього списку спостереження, якщо вони увімкнули цю можливість у своїх налаштуваннях.",
+ "config-email-auth": "Увімкнути автентифікацію через електронну пошту",
+ "config-email-auth-help": "Якщо ця опція увімкнена, користувачам треба підтвердити свою адресу електронної пошти з допомогою надісланого їм посилання, коли вони встановлюють чи змінюють її.\nТільки автентифіковані адреси електронної пошти отримують листи від інших користувачів або змінювати поштові сповіщення.\nУвімкнення цієї опції '''рекомендується''' загальнодоступним вікі через можливі зловживання функціями електронної пошти.",
+ "config-email-sender": "Зворотна адреса електронної пошти:",
+ "config-email-sender-help": "Введіть адресу електронної пошти, що буде використовуватись як зворотна адреса для вихідної пошти.\nНа неї будуть надсилатись відмови.\nЧимало поштових серверів вимагають, щоб принаймні доменне ім'я було допустимим.",
+ "config-upload-settings": "Завантаження зображень і файлів",
+ "config-upload-enable": "Дозволити завантаження файлів",
+ "config-upload-help": "Завантаження файлів підставляє Ваш сервер під потенційні загрози.\nДетальнішу інформацію можна почитати у посібнику, [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security розділ про безпеку].\n\nЩоб дозволити завантаження файлів, змініть режим підпапки <code>images</code> у кореневій папці MediaWiki так, щоб сервер міг у неї записувати.\nПотім увімкніть цю опцію.",
+ "config-upload-deleted": "Каталог для вилучених файлів:",
+ "config-upload-deleted-help": "Оберіть папку для архівації видалених файлів.\nВ ідеалі, вона не має бути доступною через інтернет.",
+ "config-logo": "URL логотипу:",
+ "config-logo-help": "Стандартна схема оформлення MediaWiki містить вільне для логотипу місце над бічною панеллю розміром 135x160 пікселів.\n\nЗавантажте зображення відповідного розміру і введіть тут його URL.\n\nВи можете використати <code>$wgStylePath</code> або <code>$wgScriptPath</code>, якщо ваш логотип пов'язаний з цими шляхами.\n\nЯкщо Вам не потрібен логотип, залиште це поле пустим.",
+ "config-instantcommons": "Увімкнути Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] — це функція, що дозволяє вікі використовувати зображення, звуки та інші медіа, розміщені у [https://commons.wikimedia.org/ Вікісховищі].\nДля цього MediaWiki необхідний доступ до інтернету.\n\nДодаткову інформацію стосовно цієї функції, включаючи інструкції, як її увімкнути у вікі, відмінних від Вікісховища, дивіться у [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos посібнику].",
+ "config-cc-error": "Механізм вибору ліцензії Creative Commons не дав результатів.\nВведіть назву ліцензії вручну.",
+ "config-cc-again": "Виберіть знову ...",
+ "config-cc-not-chosen": "Оберіть, яку ліцензію Creative Commons Ви хочете використовувати, і натисніть \"proceed\".",
+ "config-advanced-settings": "Розширені налаштування",
+ "config-cache-options": "Налаштування кешування об'єктів:",
+ "config-cache-help": "Кешування об'єктів використовується для покращення швидкодії MediaWiki методом кешування часто використовуваних даних.\nЗаохочується увімкнення цієї можливості для середніх і великих сайтів, малі сайти також можуть відчути її перевагу.",
+ "config-cache-none": "Без кешування (жодні функції не втрачаються, але впливає на швидкодію великих вікі-сайтів)",
+ "config-cache-accel": "PHP кешування об'єктів (APC, APCu, XCache чи WinCache)",
+ "config-cache-memcached": "Використовувати Memcached (вимагає додаткової установки і налаштування)",
+ "config-memcached-servers": "Сервери Memcached:",
+ "config-memcached-help": "Список IP-адрес, що викоритовує Memcached.\nВкажіть по одному в рядку, разом з портами. Наприклад:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Ви обрали тип кешування Memcached, але не вказали ніяких серверів.",
+ "config-memcache-badip": "Ви ввели недопустиму IP-адресу для Memcached: $1.",
+ "config-memcache-noport": "Ви не вказали порт для сервера Memcached: $1.\nЯкщо Ви його не знаєте, за замовчуванням використовується 11211.",
+ "config-memcache-badport": "Номери портів Memcached повинні лежати в межах від $1 до $2.",
+ "config-extensions": "Розширення",
+ "config-extensions-help": "Розширення, перераховані вище, були знайдені у папці <code>./extensions</code>.\n\nВони можуть потребувати додаткових налаштувань, але Ви можете увімкнути їх зараз.",
+ "config-skins": "Оформлення",
+ "config-skins-help": "Перераховані вище теми оформлення було знайдено у Вашій папці <code>./skins</code>. Ви маєте увімкнути хоча б одну, і обрати тему за замовчуванням.",
+ "config-skins-use-as-default": "Використовувати цю тему за замовчуванням",
+ "config-skins-missing": "Не було знайдено жодних тем; MediaWiki буде використовувати резервну тему, поки Ви не встановите власні.",
+ "config-skins-must-enable-some": "Потрібно вибрати принаймні одну тему, щоб увімкнути.",
+ "config-skins-must-enable-default": "Тема, обрана за замовчуванням, повинна бути увімкнена.",
+ "config-install-alreadydone": "'''Увага:''' Здається, Ви вже встановлювали MediaWiki і зараз намагаєтесь встановити її знову.\nБудь ласка, перейдіть на наступну сторінку.",
+ "config-install-begin": "Натискаючи \"{{int:config-continue}}\", Ви розпочинаєте встановлення MediaWiki.\nЯкщо Ви все ще хочете внести зміни, натисніть \"{{int:config-back}}\".",
+ "config-install-step-done": "виконано",
+ "config-install-step-failed": "не вдалося",
+ "config-install-extensions": "У тому числі розширення",
+ "config-install-database": "Налаштування бази даних",
+ "config-install-schema": "Створення схеми",
+ "config-install-pg-schema-not-exist": "Схеми PostgreSQL не існує.",
+ "config-install-pg-schema-failed": "Не вдалось створити таблиці.\nПереконайтесь, що користувач \"$1\" може писати до схеми \"$2\".",
+ "config-install-pg-commit": "Внесення змін",
+ "config-install-pg-plpgsql": "Перевірка мови PL/pgSQL",
+ "config-pg-no-plpgsql": "Вам необхідно встановити мову PL/pgSQL у базі даних $1",
+ "config-pg-no-create-privs": "Обліковий запис, вказаний для встановлення, має недостатньо прав для створення облікового запису.",
+ "config-pg-not-in-role": "Обліковий запис, який Ви вказали для веб-користувача, уже існує.\nОбліковий запис, який Ви вказали для встановлення не є суперюзером і не відноситься до ролі веб-користувача, тому неможливо створити об'єкти, що належать веб-користувачеві.\n\nУ даний час MediaWiki вимагає, щоб усі таблиці належали веб-користувачу. Будь ласка, вкажіть інше ім'я облікового запису або натисніть \"Назад\" та вкажіть користувача з достатніми правами.",
+ "config-install-user": "Створення користувача бази даних",
+ "config-install-user-alreadyexists": "Користувач \"$1\" уже існує",
+ "config-install-user-create-failed": "Не вдалося створити користувача \"$1\": $2",
+ "config-install-user-grant-failed": "Не вдалося надати права користувачеві \"$1\": $2",
+ "config-install-user-missing": "Зазначеного користувача \"$1\" не існує.",
+ "config-install-user-missing-create": "Зазначеного користувача \"$1\" не існує.\nБудь ласка, поставте галочку \"Створити обліковий запис\", якщо хочете його створити.",
+ "config-install-tables": "Створення таблиць",
+ "config-install-tables-exist": "'''Увага''': Таблиці MediaWiki уже, здається, існують.\nПропуск створення.",
+ "config-install-tables-failed": "'''Помилка''': Не вдалося створити таблицю внаслідок такої помилки: $1",
+ "config-install-interwiki": "Заповнення таблиці інтервікі значеннями за замовчуванням",
+ "config-install-interwiki-list": "Не вдалося знайти файл <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Увага''': Таблиця інтервікі уже, здається, має записи.\nСтворення стандартного списку пропускається.",
+ "config-install-stats": "Ініціалізація статистики",
+ "config-install-keys": "Генерація секретних ключів",
+ "config-insecure-keys": "'''Увага:''' {{PLURAL:$2|1=Секретний ключ|Секретні ключі}} ($1), {{PLURAL:$2|1=згенерований в процесі встановлення, недостатньо надійний|згенеровані в процесі встановлення, недостатньо надійні}}. Розгляньте можливість {{PLURAL:$2|1=його|їх}} заміни вручну.",
+ "config-install-updates": "Запобігти запуску непотрібних оновлень",
+ "config-install-updates-failed": "<strong>Помилка:</strong> Вставка оновленних ключів в таблиці не вдалося через таку помилку:$1",
+ "config-install-sysop": "Створення облікового запису адміністратора",
+ "config-install-subscribe-fail": "Не можливо підписатись на mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL не встановлено і опція <code>allow_url_fopen</code> не доступна.",
+ "config-install-mainpage": "Створення головної сторінки із вмістом за замовчуванням",
+ "config-install-mainpage-exists": "Головна сторінка вже існує, пропускаємо",
+ "config-install-extension-tables": "Створення таблиць для увімкнених розширень",
+ "config-install-mainpage-failed": "Не вдається вставити головну сторінку: $1",
+ "config-install-done": "<strong>Вітаємо!</strong>\nВи успішно встановили MediaWiki.\n\nІнсталятор згенерував файл <code>LocalSettings.php</code>, який містить усі Ваші налаштування.\n\nВам необхідно завантажити його і помістити у кореневу папку Вашої вікі (туди ж, де index.php). Завантаження мало початись автоматично.\n\nЯкщо завантаження не почалось або Ви його скасували, можете заново його почати, натиснувши на посилання внизу:\n\n$3\n\n<strong>Примітка</strong>: Якщо Ви не зробите цього зараз, цей файл не буде доступним пізніше, коли Ви вийдете з встановлення, не скачавши його.\n\nПісля виконання дій, описаних вище, Ви зможете <strong>[$2 увійти у свою вікі]</strong>.",
+ "config-install-done-path": "<strong>Вітаємо!</strong>\nВи встановили Медіавікі.\n\nІнсталятор створив файл <code>LocalSettings.php</code>.\nУ ньому містяться всі Ваші налаштування.\n\nВам потрібно завантажити його й помістити в <code>$4</code>. Завантаження повинно було автоматично розпочатись.\n\nЯкщо завантаження не було запропоновано, або Ви його скасували, Ви можете перезапустити завантаження натиснувши на посилання нижче:\n\n$3\n\n<strong>Зверніть увагу:</strong> Якщо Ви не зробите це зараз, цей згенерований файл налаштувань не буде доступним для Вас пізніше якщо Ви вийдете зі встановлення не завантаживши його.\n\nКоли це було зроблено Ви можете <strong>[$2 зайти до своєї вікі]</strong>.",
+ "config-download-localsettings": "Завантажити <code>LocalSettings.php</code>",
+ "config-help": "допомога",
+ "config-help-tooltip": "натисніть, щоб розгорнути",
+ "config-nofile": "Файл \"$1\" не знайдено. Його видалено?",
+ "config-extension-link": "Чи знаєте ви, що ваше вікі підтримує [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions розширення]?\n\nВи можете переглядати [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category розширення по категорії] або в [https://www.mediawiki.org/wiki/Extension_Matrix матрицю розширень] щоб побачити повний список розширень.",
+ "config-skins-screenshots": "$1 (скріншоти: $2)",
+ "config-screenshot": "скріншот",
+ "mainpagetext": "<strong>Програмне забезпечення «MediaWiki» встановлено.</strong>",
+ "mainpagedocfooter": "Інформацію про роботу з цією вікі можна знайти на сторінці [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Довідка:Вміст].\n\n== Деякі корисні ресурси ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Список налаштувань];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Часті питання з приводу MediaWiki];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Розсилка повідомлень про появу нових версій MediaWiki];\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Локалізувати MediaWiki своєю мовою]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Дізнатися, як боротися зі спамом у своїй вікі]"
+}
diff --git a/www/wiki/includes/installer/i18n/ur.json b/www/wiki/includes/installer/i18n/ur.json
new file mode 100644
index 00000000..98e8825c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/ur.json
@@ -0,0 +1,34 @@
+{
+ "@metadata": {
+ "authors": [
+ "Noor2020",
+ "පසිඳු කාවින්ද"
+ ]
+ },
+ "config-information": "معلومات",
+ "config-git": "Git ورژن کنٹرول مصنع لطیف ملا: <code>$1</code> ۔",
+ "config-git-bad": "GIT ورژن کنٹرول مصنع لطیف نہيں ملا ۔",
+ "config-mysql-only-myisam-dep": "' ' تنبیہ: ' '[[MyISAM|مائ اسام]] واحد دستیاب 'ذخیرہ جاتی انجن' ہے جو مائی ایس کیو ایل کے لیے ہے ، جو کہ ناموزوں ہے میڈیا وکی کے لیے ،کیوں کہ :\n* یہ ہموار قطاروں کی سہولت بمشکل فراہم کرتا ہے\n* یہ دوسرے انجنوں کے مقابلے زیادہ بگڑ جاتا ہے\n* میڈیا وکی کوڈ بیس ہمیشہ سنبھال نہيں پاتا مائی اسام کو ۔\n\nآپ کا مائی ایس کیو ایل کا نصب ہمیشہ اننو ڈی بی کی سہولت نہيں دے سکتا ، ہو سکتا ہے یہ مزید ترقیاتی کام چاہے",
+ "config-profile-fishbowl": "صرف مجاز ایڈیٹرز",
+ "config-license-pd": "پبلک ڈومین",
+ "config-email-settings": "ای میل کی ترتیبات",
+ "config-email-user-help": "تمام صارفین ای میل بھیجنے کیلئے ایک دوسرے اگر وہ یہ ان کی ترجیحات میں فعال ہے کی اجازت دیتے ہیں.",
+ "config-email-usertalk": "صارف بات صفحہ کی اطلاع فعال",
+ "config-email-usertalk-help": "اگر وہ یہ ان کی ترجیحات میں فعال ہے صارف بات صفحہ تبدیلی پر اطلاعات حاصل کرنے کے لئے صارفین کی اجازت دیں.",
+ "config-email-watchlist": "دیکھنی والی فہرست کی اطلاع فعال",
+ "config-email-auth": "فعال ای میل کی تصدیق",
+ "config-email-sender": "ای میل ایڈریس پر واپس:",
+ "config-upload-deleted": "ڈائرکٹری خارج کردہ فائلوں کے لیے:",
+ "config-advanced-settings": "اعلی درجے کی ترتیب",
+ "config-cache-options": "اعتراض کیش کے لئے ترتیب دینا:",
+ "config-extensions": "ملانے",
+ "config-install-step-done": "کیا کیا",
+ "config-install-step-failed": "میں ناکام رہے",
+ "config-install-extensions": "سمیت ملانے",
+ "config-install-database": "ڈیٹا بیس کی ترتیب",
+ "config-install-pg-commit": "تبدیلیوں کے ارتکاب",
+ "config-install-keys": "خفیہ چابیاں پیدا",
+ "config-install-sysop": "منتظم کے صارف کے اکاؤنٹ کی تشکیل",
+ "config-install-mainpage": "پہلے سے طے شدہ مواد کے ساتھ سب سے کامیاب کی تشکیل",
+ "mainpagetext": "'''میڈیاوکی کو کامیابی سے چالو کردیا گیا ہے۔.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/uz.json b/www/wiki/includes/installer/i18n/uz.json
new file mode 100644
index 00000000..c51656a8
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/uz.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Sociologist"
+ ]
+ },
+ "config-admin-password-blank": "Administrator hisob yozuvi uchun maxfiy soʻz kiriting.",
+ "mainpagetext": "'''MediaWiki muvaffaqiyatli o'rnatildi.'''",
+ "mainpagedocfooter": "Wiki dasturini ishlatish haqida ma'lumot olish uchun [https://meta.wikimedia.org/wiki/Help:Contents Foydalanuvchi qo'llanmasi] sahifasiga murojaat qiling.\n\n== Dastlabki qadamlar ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Moslamalar ro'yxati]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki haqida ko'p so'raladigan savollar]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki yangi versiyasi chiqqanda xabar berish ro'yxati]"
+}
diff --git a/www/wiki/includes/installer/i18n/vec.json b/www/wiki/includes/installer/i18n/vec.json
new file mode 100644
index 00000000..3f496d88
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/vec.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Vajotwo",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''Instałasion de MediaWiki conpletà coretamente.'''",
+ "mainpagedocfooter": "Varda ła [https://meta.wikimedia.org/wiki/Help:Contents Guida utente] par majori informasion so l'uso de sto software wiki.\n\n== Par scumisiar ==\n\nI seguenti cołegamenti i xé en łengua inglese:\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Inpostasion de configurasion]\n* [https://www.mediawiki.org/wiki/Manual:FAQ/it Domande frequenti so MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list anunsi MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/vep.json b/www/wiki/includes/installer/i18n/vep.json
new file mode 100644
index 00000000..586c6472
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/vep.json
@@ -0,0 +1,10 @@
+{
+ "@metadata": {
+ "authors": [
+ "Игорь Бродский",
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''MediaWiki-likutim om seižutadud jügedusita.'''",
+ "mainpagedocfooter": "Kc. [https://meta.wikimedia.org/wiki/Help:Contents Kävutajan kirj], miše sada informacijad wikin kävutamižes.\n\n== Erased tarbhaižed resursad ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Järgendusiden nimikirjutez]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce počtnimikirjutez]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/vi.json b/www/wiki/includes/installer/i18n/vi.json
new file mode 100644
index 00000000..1c441cd7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/vi.json
@@ -0,0 +1,320 @@
+{
+ "@metadata": {
+ "authors": [
+ "පසිඳු කාවින්ද",
+ "Minh Nguyen",
+ "Withoutaname",
+ "Dinhxuanduyet",
+ "Nguyên Lê",
+ "Macofe"
+ ]
+ },
+ "config-desc": "Trình cài đặt MediaWiki",
+ "config-title": "Cài đặt MediaWiki $1",
+ "config-information": "Thông tin",
+ "config-localsettings-upgrade": "Một tập tin <code>LocalSettings.php</code> đã được phát hiện.\nĐể nâng cấp bản cài đặt này, xin nhập giá trị của <code>$wgUpgradeKey</code> trong hộp thoại bên dưới đây.\nBạn sẽ tìm thấy nó trong <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Một tập tin <code>LocalSettings.php</code> đã được phát hiện.\nĐể nâng cấp bản cài đặt này, hãy chạy <code>update.php</code> thay thế.",
+ "config-localsettings-key": "Chìa khóa nâng cấp:",
+ "config-localsettings-badkey": "Bạn đã cung cấp một chìa khóa nâng cấp sai.",
+ "config-upgrade-key-missing": "Một bản cài đặt MediaWiki sẵn đã được phát hiện.\nĐể nâng cấp bản cài đặt này, hãy thêm dòng sau vào cuối <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "Tập tin <code>LocalSettings.php</code> đã tồn tại hình như không hoàn chỉnh.\nBiến $1 chưa được đặt.\nXin hãy thay đổi <code>LocalSettings.php</code> để đặt biến này, rồi bấm “{{int:Config-continue}}”.",
+ "config-localsettings-connection-error": "Đã xuất hiện lỗi khi kết nối với cơ sở dữ liệu dùng cấu hình trong <code>LocalSettings.php</code>. Xin hãy sửa lại cấu hình và thử lại.\n\n$1",
+ "config-session-error": "Lỗi khi bắt đầu phiên làm việc: $1",
+ "config-session-expired": "Dữ liệu phiên làm việc của bạn dường như đã hết hạn. Các phiên làm việc được cấu hình để kéo dài $1. Để tăng thời gian này, đặt <code>session.gc_maxlifetime</code> trong php.ini, rồi khởi động lại quá trình cài đặt.",
+ "config-no-session": "Đã mất dữ liệu phiên làm việc của bạn! Kiểm tra tập tin php.ini và đảm bảo rằng <code>session.save_path</code> đã được đặt thành một thư mục thích hợp.",
+ "config-your-language": "Ngôn ngữ của bạn:",
+ "config-your-language-help": "Chọn một ngôn ngữ để sử dụng trong quá trình cài đặt.",
+ "config-wiki-language": "Ngôn ngữ của wiki:",
+ "config-wiki-language-help": "Chọn ngôn ngữ chủ yếu của nội dung trong wiki này.",
+ "config-back": "← Lùi",
+ "config-continue": "Tiếp →",
+ "config-page-language": "Ngôn ngữ",
+ "config-page-welcome": "Chào mừng đến với MediaWiki!",
+ "config-page-dbconnect": "Kết nối với cơ sở dữ liệu",
+ "config-page-upgrade": "Nâng cấp một bản cài đặt có sẵn",
+ "config-page-dbsettings": "Thiết lập cơ sở dữ liệu",
+ "config-page-name": "Tên",
+ "config-page-options": "Tùy chọn",
+ "config-page-install": "Cài đặt",
+ "config-page-complete": "Xong rồi!",
+ "config-page-restart": "Bắt đầu cài đặt lại",
+ "config-page-readme": "Đọc trước",
+ "config-page-releasenotes": "Thông báo phát hành",
+ "config-page-copying": "Sao chép",
+ "config-page-upgradedoc": "Nâng cấp",
+ "config-page-existingwiki": "Wiki đã tồn tại",
+ "config-help-restart": "Bạn có muốn xóa tất cả dữ liệu được lưu mà bạn vừa nhập và khởi động lại quá trình cài đặt?",
+ "config-restart": "Có, khởi động lại nó",
+ "config-welcome": "=== Kiểm tra môi trường ===\nBây giờ sẽ kiểm tra sơ qua môi trường này có phù hợp cho việc cài đặt MediaWiki.\nHãy nhớ bao gồm thông tin này khi nào xin hỗ trợ hoàn thành việc cài đặt.",
+ "config-copyright": "=== Bản quyền và Điều khoản ===\n\n$1\n\nChương trình này là phần mềm tự do; bạn có thể phân phối lại và/hoặc chỉnh sửa nó dưới điều khoản của Giấy phép Công cộng GNU (GNU General Public License) do Quỹ Phần mềm Tự do (Free Software Foundation) xuất bản; hoặc phiên bản 2 của giấy phép đó, hoặc (tùy theo ý bạn) bất kỳ phiên bản nào sau này.\n\nChương trình này được phân phối với hy vọng rằng nó sẽ hữu ích, nhưng <strong>không có bất kỳ bảo hành nào</strong>; không có thậm chí bảo hành bao hàm về <strong>khả năng thương mại</strong> hoặc <strong>sự thích hợp với một mục đích cụ thể</strong>.\nXem Giấy phép Công cộng GNU để biết thêm chi tiết.\n\nBạn phải nhận <doclink href=Copying>một bản sao của Giấy phép Công cộng GNU</doclink> đi kèm chương trình này; nếu không, hãy viết thư cho Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, Hoa Kỳ, hoặc [http://www.gnu.org/copyleft/gpl.html đọc nó trên mạng].",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/Special:MyLanguage/MediaWiki Trang chủ MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Hướng dẫn sử dụng]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Hướng dẫn quản lý]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Câu thường hỏi]\n----\n* <doclink href=Readme>Cần đọc trước</doclink>\n* <doclink href=ReleaseNotes>Ghi chú phát hành</doclink>\n* <doclink href=Copying>Sao chép</doclink>\n* <doclink href=UpgradeDoc>Nâng cấp</doclink>",
+ "config-env-good": "Đã kiểm tra môi trường.\nBạn có thể cài đặt MediaWiki.",
+ "config-env-bad": "Đã kiểm tra môi trường.\nBạn không thể cài đặt MediaWiki.",
+ "config-env-php": "PHP $1 đã được cài đặt.",
+ "config-env-hhvm": "HHVM $1 được cài đặt.",
+ "config-unicode-using-intl": "Sẽ sử dụng [http://pecl.php.net/intl phần mở rộng PECL intl] để chuẩn hóa Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Cảnh báo:</strong> [http://pecl.php.net/intl intl PECL extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn phải để ý qua một chút trên [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].",
+ "config-unicode-update-warning": "<strong>Cảnh báo:</strong> Phiên bản cài đặt của gói Unicode chuẩn hóa sử dụng một phiên bản cũ của thư viện [http://site.icu-project.org/ the ICU project].\nBạn phải [https://www.mediawiki.org/wiki/Special:MyLanguage/nâng cấp Unicode_normalization_considerations] nếu bạn quan tâm đến việc sử dụng Unicode.",
+ "config-no-db": "Không tìm thấy một trình điều khiển cơ sở dữ liệu phù hợp! Bạn cần phải cài một trình điều khiển cơ sở dữ liệu cho PHP.\n{{PLURAL:$2|Loại|Các loại}} cơ sở dữ liệu sau đây được hỗ trợ: $1.\n\nNếu bạn đã biên dịch PHP lấy, cấu hình lại nó mà kích hoạt một trình khách cơ sở dữ liệu, ví dụ bằng lệnh <code>./configure --with-mysqli</code>.\nNếu bạn đã cài PHP từ một gói Debian hoặc Ubuntu, thì bạn cũng cần phải cài ví dụ gói <code>php5-mysql</code>.",
+ "config-outdated-sqlite": "<strong>Chú ý:</strong> Bạn có SQLite $1, phiên bản này thấp hơn phiên bản yêu câu tối thiểu $2. SQLite sẽ không có tác dụng.",
+ "config-no-fts3": "<strong>Chú ý:</strong> SQLite được biên dịch mà không có [//sqlite.org/fts3.html mô đun FTS3], nên các chức năng tìm kiếm sẽ bị vô hiệu trên hệ thống phía sau này.",
+ "config-pcre-old": "<strong>Lỗi chí tử:</strong> PCRE $1 trở lên được yêu cầu phải có.\nBản nhị phân PHP của bạn dang được liên kết với PCRE $2.\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE Thông tin bổ sung].",
+ "config-pcre-no-utf8": "<strong>Lỗi chí tử:</strong> Mô đun PCRE của PHP dường như được biên dịch mà không có hỗ trợ PCRE_UTF8.\nMediaWiki yêu cầu phải có hỗ trợ UTF-8 để hoạt động chính xác.",
+ "config-memory-raised": "<code>memory_limit</code> của PHP là $1, tăng lên $2.",
+ "config-memory-bad": "<strong>Cảnh báo:</strong> <code>memory_limit</code> của PHP là $1.\nGiá trị này có lẽ quá thấp.\nCài đặt có thể bị thất bại!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] đã được cài đặt",
+ "config-apc": "[http://www.php.net/apc APC] đã được cài đặt",
+ "config-apcu": "[http://www.php.net/apcu APCu] đã được cài đặt",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] đã được cài đặt",
+ "config-no-cache-apcu": "<strong>Cảnh báo:</strong> Không tìm thấy [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache], hoặc [http://www.iis.net/download/WinCacheForPhp WinCache].\nVùng nhớ đệm đối tượng không được kích hoạt.",
+ "config-mod-security": "<strong>Cảnh báo:</strong> [http://modsecurity.org/ mod_security]/mod_security2 đã được kích hoạt trên máy chủ Web của bạn. Nhiều cấu hình phổ biến của phần mềm này sẽ gây vấn đề cho MediaWiki và những phần mềm khác cho phép người dùng đăng các nội dung tùy tiện.\nNếu có thể, bạn nên vô hiệu nó. Còn không, tra cứu [http://modsecurity.org/documentation/ tài liệu mod_security] hoặc liên hệ với nhà cung cấp hỗ trợ cho máy chủ nếu bạn gặp những lỗi ngẫu nhiên nào đó.",
+ "config-diff3-bad": "Không tìm thấy GNU diff3.",
+ "config-git": "Đã tìm thấy phần mềm điều khiển phiên bản Git: <code>$1</code>.",
+ "config-git-bad": "Không tìm thấy phần mềm điều khiển phiên bản Git.",
+ "config-imagemagick": "Đã tìm thấy ImageMagick: <code>$1</code>.\nChức năng thu nhỏ hình ảnh sẽ được kích hoạt nếu bạn cho phép tải lên.",
+ "config-gd": "Đã tìm thấy thư viện đồ họa GD đi kèm.\nChức năng thu nhỏ hình ảnh sẽ được kích hoạt nếu bạn cho phép tải lên.",
+ "config-no-scaling": "Không thể tìm thấy thư viện GD hoặc ImageMagic. Chức năng thu nhỏ hình ảnh sẽ bị vô hiệu.",
+ "config-no-uri": "<strong>Lỗi:</strong> Không thể xác định URI hiện tại. Cài đặt bị thất bại.",
+ "config-no-cli-uri": "<strong>Cảnh báo:</strong> Không có <code>--scriptpath</code> nào được xác định, nên sử dụng mặc định: <code>$1</code>.",
+ "config-using-server": "Sẽ sử dụng tên máy chủ “<nowiki>$1</nowiki>”.",
+ "config-using-uri": "Sẽ sử dụng URL máy chủ “<nowiki>$1$2</nowiki>”.",
+ "config-uploads-not-safe": "<strong>Cảnh báo:</strong> Thư mục tải lên mặc định của bạn, <code>$1</code>, đang có lỗ hỏng bảo mật, dễ bị khai thác bởi các đoạn mã thực thi xấu.\nMặc dù MediaWiki kiểm tra tất cả các tập tin tải lên để tránh nguy cơ bảo mật, chúng tôi đặc biệt khuyến cáo [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security đóng lỗ hỏng bảo mật này lại] trước khi kích hoạt tính năng tải lên.",
+ "config-no-cli-uploads-check": "<strong>Cảnh báo:</strong> Thư mục tải lên mặc định của bạn (<code>$1</code>) không được kiểm tra lỗ hỏng bảo mật dễ bị khai thác bởi các đoạn mã thực thi xấu trong quá trình cài đặt giao diện dòng lệnh.",
+ "config-brokenlibxml": "Hệ thống của bạn có kết hợp các phiên bản lỗi lầm của PHP và libxml2, điều này có thể gây ra tổn thương không nhìn thấy được đối với dữ liệu trong MediaWiki và các ứng dụng Web khác.\nHãy nâng cấp lên phiên bản libxml2 2.7.3 trở lên ([https://bugs.php.net/bug.php?id=45996 lỗi nộp PHP]).\nCài đặt bị hủy bỏ.",
+ "config-suhosin-max-value-length": "Suhosin được cài đặt và hạn chế tham số GET <code>length</code> (độ dài) không thể vượt quá $1 byte.\nThành phần ResourceLoader của MediaWiki sẽ khắc phục giới hạn này, nhưng điều này sẽ làm giảm hiệu suất.\nNếu có thể, bạn nên tăng <code>suhosin.get.max_value_length</code> lên 1024 trở lên trong <code>php.ini</code>, và đặt <code>$wgResourceLoaderMaxQueryLength</code> cùng giá trị trong <code>LocalSettings.php</code>.",
+ "config-using-32bit": "<strong>Cảnh báo:</strong> Máy của bạn hình như sử dụng các số nguyên 32 bit. Chế độ này [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit không được khuyên khích].",
+ "config-db-type": "Kiểu cơ sở dữ liệu:",
+ "config-db-host": "Máy chủ của cơ sở dữ liệu:",
+ "config-db-host-help": "Nếu máy chủ cơ sở dữ liệu của bạn nằm trên máy chủ khác, hãy điền tên hoặc địa chỉ IP của máy chủ vào đây.\n\nNếu bạn đang dùng Web hosting chia sẻ, tài liệu của nhà cung cấp hosting của bạn sẽ có tên chính xác của máy chủ.\n\nNếu bạn đang cài đặt trên một máy chủ Windows và sử dụng MySQL, việc dùng “localhost” có thể không hợp với tên máy chủ. Nếu bị như vậy, hãy thử “127.0.0.1” tức địa chỉ IP địa phương.\n\nNếu bạn đang dùng PostgreSQL, hãy để trống mục này để kết nối với một ổ cắm Unix.",
+ "config-db-host-oracle": "TNS cơ sở dữ liệu:",
+ "config-db-host-oracle-help": "Nhập một [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Tên Kết nối Địa phương] hợp lệ; một tập tin tnsnames.ora phải được hiển thị đối với cài đặt này.<br />Nếu bạn đang sử dụng các thư viện trình khách 10g trở lên, bạn cũng có thể sử dụng phương pháp đặt tên [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
+ "config-db-wiki-settings": "Dữ liệu để nhận ra wiki này",
+ "config-db-name": "Tên cơ sở dữ liệu:",
+ "config-db-name-help": "Chọn một tên để chỉ thị wiki của bạn.\nKhông nên đưa dấu cách vào tên này.\n\nNếu bạn đang sử dụng Web hosting chia sẻ, nhà cung cấp hosting của bạn hoặc là sẽ cung cấp cho bạn một tên cơ sở dữ liệu cụ thể để sử dụng hoặc là sẽ cho phép bạn tạo ra các cơ sở dữ liệu thông qua một bảng điều khiển.",
+ "config-db-name-oracle": "Giản đồ cơ sở dữ liệu:",
+ "config-db-account-oracle-warn": "Có ba trường hợp được hỗ trợ để cài đặt Oracle làm cơ sở dữ liệu phía sau:\n\nNếu bạn muốn tạo tài khoản cơ sở dữ liệu trong quá trình cài đặt, xin vui lòng cung cấp một tài khoản với vai trò SYSDBA là tài khoản cơ sở dữ liệu để cài đặt và xác định định danh mong muốn cho tài khoản truy cập Web, nếu không bạn có thể tạo tài khoản truy cập Web thủ công và chỉ cung cấp tài khoản đó (nếu nó có các quyền yêu cầu để tạo ra các đối tượng giản đồ) hoặc cung cấp hai tài khoản riêng, một có quyền tạo ra và một bị hạn chế có quyền truy cập Web.\n\nMột kịch bản để tạo một tài khoản với quyền yêu cầu có sẵn trong thư mục cài đặt “maintenance/oracle/”. Hãy nhớ rằng việc sử dụng một tài khoản bị hạn chế sẽ vô hiệu hóa tất cả các khả năng bảo trì với tài khoản mặc định.",
+ "config-db-install-account": "Tài khoản người dùng để cài đặt",
+ "config-db-username": "Tên người dùng cơ sở dữ liệu:",
+ "config-db-password": "Mật khẩu cơ sở dữ liệu:",
+ "config-db-install-username": "Nhập tên người dùng để kết nối với cơ sở dữ liệu trong quá trình cài đặt.\nĐây không phải là tên người dùng của tài khoản MediaWiki; đây là tên người dùng cho cơ sở dữ liệu của bạn.",
+ "config-db-install-password": "Nhập mật khẩu để kết nối với cơ sở dữ liệu trong quá trình cài đặt.\nĐây không phải là mật khẩu của tài khoản MediaWiki; đây là mật khẩu cho cơ sở dữ liệu của bạn.",
+ "config-db-install-help": "Nhập tên người dùng và mật khẩu sẽ được sử dụng để kết nối với cơ sở dữ liệu trong quá trình cài đặt.",
+ "config-db-account-lock": "Sử dụng cùng tên người dùng và mật khẩu trong quá trình hoạt động bình thường",
+ "config-db-wiki-account": "Tài khoản người dùng để hoạt động bình thường",
+ "config-db-wiki-help": "Nhập tên người dùng và mật khẩu sẽ được sử dụng để kết nối với cơ sở dữ liệu trong quá trình hoạt động bình thường của wiki.\nNếu tài khoản không tồn tại và tài khoản cài đặt có đủ quyền hạn, tài khoản người dùng này sẽ được tạo ra với những đặc quyền tối thiểu cần thiết để vận hành wiki.",
+ "config-db-prefix": "Tiền tố bảng cơ sở dữ liệu:",
+ "config-db-prefix-help": "Nếu bạn cần phải chia sẻ một cơ sở dữ liệu chung với nhiều wiki, hay giữa MediaWiki và một ứng dụng Web, bạn có thể quyết định thêm một tiền tố cho tất cả các tên bảng để tránh xung đột.\nKhông sử dụng dấu cách.\n\nTrường này thường được bỏ trống.",
+ "config-mysql-old": "Cần MySQL $1 trở lên; bạn có $2.",
+ "config-db-port": "Cổng cơ sở dữ liệu:",
+ "config-db-schema": "Giản đồ cho MediaWiki:",
+ "config-db-schema-help": "Giản đồ này thường làm việc tốt.\nChỉ thay đổi nó nếu bạn biết cần phải làm như vậy.",
+ "config-pg-test-error": "Không thể kết nối với cơ sở dữ liệu '''$1''': $2",
+ "config-sqlite-dir": "Thư mục dữ liệu SQLite:",
+ "config-sqlite-dir-help": "SQLite lưu tất cả các dữ liệu trong một tập tin duy nhất.\n\nThư mục mà bạn cung cấp phải cho phép máy chủ Web ghi vào khi cài đặt.\n\n<strong>Không</strong> nên làm cho nó truy cập được qua Web; đây là lý do chúng tôi không đặt nó vào cùng thư mục với các tập tin PHP của bạn.\n\nTrình cài đặt sẽ ghi một tập tin <code>.htaccess</code> đi kèm, nhưng nếu thất bại người nào đó có thể truy cập vào cơ sở dữ liệu thô của bạn.\nĐiều đó bao gồm dữ liệu người dùng thô (địa chỉ thư điện tử, mật khẩu được băm) cũng như các phiên bản bị xóa và dữ liệu bị hạn chế khác trên wiki.\n\nXem xét đặt cơ sở dữ liệu tại nơi nào khác hẳn, ví dụ trong <code>/var/lib/mediawiki/wiki_cua_ban</code>.",
+ "config-oracle-def-ts": "Không gian bảng mặc định:",
+ "config-oracle-temp-ts": "Không gian bảng tạm:",
+ "config-type-mysql": "MySQL (hoặc tương hợp)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki hỗ trợ các hệ thống cơ sở dữ liệu sau đây:\n\n$1\n\nNếu bạn không thấy hệ thống cơ sở dữ liệu mà bạn đang muốn sử dụng được liệt kê dưới đây, thì hãy theo chỉ dẫn được liên kết ở trên để kích hoạt tính năng hỗ trợ.",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] là mục tiêu chính cho MediaWiki và được hỗ trợ tốt nhất. MediaWiki cũng làm việc với [{{int:version-db-mariadb-url}} MariaDB] và [{{int:version-db-percona-url}} Percona Server], là những cơ sở dữ liệu tương thích với MySQL. ([http://www.php.net/manual/en/mysqli.installation.php Làm thế nào để biên dịch PHP với sự hỗ trợ của MySQL])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] là một hệ thống cơ sở dữ liệu mã nguồn mở phổ biến như là một thay thế cho MySQL. ([http://www.php.net/manual/en/pgsql.installation.php Làm thế nào để biên dịch PHP với sự hỗ trợ của PostgreSQL])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] là một hệ thống cơ sở dữ liệu dung lượng nhẹ được hỗ trợ rất tốt. ([http://www.php.net/manual/en/pdo.installation.php Làm thế nào để biên dịch PHP với sự hỗ trợ của SQLite], sử dụng PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] là một cơ sở dữ liệu doanh nghiệp thương mại. ([http://www.php.net/manual/en/oci8.installation.php Làm thế nào để biên dịch PHP với sự hỗ trợ của OCI8])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] là một cơ sở dữ liệu doanh nghiệp thương mại cho Windows. ([http://www.php.net/manual/en/sqlsrv.installation.php Làm thế nào để biên dịch PHP với sự hỗ trợ của SQLSRV])",
+ "config-header-mysql": "Thiết lập MySQL",
+ "config-header-postgres": "Thiết lập PostgreSQL",
+ "config-header-sqlite": "Thiết lập SQLite",
+ "config-header-oracle": "Thiết lập Oracle",
+ "config-header-mssql": "Thiết lập Microsoft SQL Server",
+ "config-invalid-db-type": "Loại cơ sở dữ liệu không hợp lệ",
+ "config-missing-db-name": "Bạn phải nhập một giá trị cho “{{int:config-db-name}}”",
+ "config-missing-db-host": "Bạn phải nhập một giá trị cho “{{int:config-db-host}}”",
+ "config-missing-db-server-oracle": "Bạn phải nhập một giá trị cho “{{int:config-db-host-oracle}}”",
+ "config-invalid-db-server-oracle": "Cơ sở dữ liệu TNS không hợp lệ “$1”.\nHoặc sử dụng “TNS Name” hoặc một chuỗi “Easy Connect” ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Phương pháp đặt tên Oracle]).",
+ "config-invalid-db-name": "Tên cơ sở dữ liệu không hợp lệ “$1”.\nChỉ sử dụng các chữ cái ASCII (a–z, A–Z), số (0–9), dấu gạch dưới (_) và dấu gạch ngang (-).",
+ "config-invalid-db-prefix": "Tiền tố cơ sở dữ liệu không hợp lệ “$1”.\nChỉ sử dụng các chữ cái ASCII (a–z, A–Z), số (0–9), dấu gạch dưới (_) và dấu gạch ngang (-).",
+ "config-connection-error": "$1.\n\nKiểm tra máy chủ, tên người dùng, và mật khẩu và thử lại lần nữa.",
+ "config-invalid-schema": "Giản đồ “$1” không hợp lệ cho MediaWiki.\nHãy chỉ sử dụng các chữ cái ASCII (a–z, A–Z), chữ số (0–9), và dấu gạch dưới (_).",
+ "config-db-sys-create-oracle": "Trình cài đặt chỉ hỗ trợ sử dụng một tài khoản SYSDBA để tạo một tài khoản mới.",
+ "config-db-sys-user-exists-oracle": "Tài khoản người dùng “$1” đã tồn tại. SYSDBA chỉ có thể được sử dụng để tạo một tài khoản mới!",
+ "config-postgres-old": "Cần PostgreSQL $1 trở lên; bạn có $2.",
+ "config-mssql-old": "Cần Microsoft SQL Server $1 trở lên. Bạn có $2.",
+ "config-sqlite-name-help": "Chọn một tên để chỉ thị wiki của bạn.\nKhông sử dụng các dấu cách ( ) hoặc dấu gạch nối (-).\nTên này sẽ được sử dụng cho tên tập tin dữ liệu SQLite.",
+ "config-sqlite-parent-unwritable-group": "Không thể tạo ra thư mục dữ liệu <code><nowiki>$1</nowiki></code>, bởi vì thư mục cha <code><nowiki>$2</nowiki></code> không cho phép máy chủ Web ghi vào.\n\nTrình cài đặt đã xác định người dùng mà máy chủ Web của bạn đang chạy.\n\nHãy thiết lập để thư mục <code><nowiki>$3</nowiki></code> có thể ghi được bởi nó để tiếp tục.\nTrong một hệ thống Unix/Linux làm theo như sau:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "Không thể tạo ra thư mục dữ liệu <code><nowiki>$1</nowiki></code>, bởi vì thư mục cha <code><nowiki>$2</nowiki></code> không cho phép máy chủ Web ghi vào.\n\nTrình cài đặt không thể xác định người sử dụng mà máy chủ web của bạn đang chạy.\nThiết lập thư mục <code><nowiki>$3</nowiki></code> có thể ghi toàn cục bởi nó (và những người khác!) để tiếp tục.\nTrong một hệ thống Unix/Linux hãy đánh các dòng lệnh sau:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "Lỗi tạo thư mục dữ liệu “$1”.\nKiểm tra vị trí lưu và thử lại.",
+ "config-sqlite-dir-unwritable": "Không thể ghi vào thư mục “$1”.\nThay đổi quyền hạn của nó để máy chủ Web có thể ghi vào, và thử lại.",
+ "config-sqlite-connection-error": "$1.\n\nKiểm tra thư mục dữ liệu và tên cơ sở dữ liệu dưới đây và thử lại.",
+ "config-sqlite-readonly": "Không thể ghi vào tập tin <code>$1</code>.",
+ "config-sqlite-cant-create-db": "Không thể tạo ra tập tin cơ sở dữ liệu <code>$1</code>.",
+ "config-sqlite-fts3-downgrade": "PHP thiếu sự hỗ trợ cho FTS3; đang giáng cấp các bảng",
+ "config-can-upgrade": "Cơ sở dữ liệu này có bảng MediaWiki.\nĐể nâng cấp các bảng đến MediaWiki $1, bấm <strong>Tiếp tục</strong>.",
+ "config-upgrade-done": "Nâng cấp đã hoàn thành.\n\nBạn có thể [$1 bắt đầu sử dụng wiki của bạn] ngay bây giờ.\n\nNếu bạn muốn tạo lại tập tin <code>LocalSettings.php</code> của bạn, bấm nút bên dưới.\nĐiều này <strong>không được khuyến khích</strong>, trừ khi bạn đang gặp vấn đề với wiki của bạn.",
+ "config-upgrade-done-no-regenerate": "Nâng cấp đã hoàn thành.\n\nBạn có thể [$1 bắt đầu sử dụng wiki của bạn] ngay bây giờ.",
+ "config-regenerate": "Tạo lại LocalSettings.php →",
+ "config-show-table-status": "Truy vấn <code>SHOW TABLE STATUS</code> bị thất bại!",
+ "config-unknown-collation": "<strong>Cảnh báo:</strong> Database đang sử dụng đối chiếu không được thừa nhận.",
+ "config-db-web-account": "Tài khoản cơ sở dữ liệu để truy cập Web",
+ "config-db-web-help": "Chọn tên người dùng và mật khẩu mà máy chủ Web sẽ sử dụng để kết nối đến máy chủ cơ sở dữ liệu trong quá trình hoạt động bình thường của wiki.",
+ "config-db-web-account-same": "Sử dụng lại tài khoản cài đặt",
+ "config-db-web-create": "Mở tài khoản nếu chưa tồn tại",
+ "config-db-web-no-create-privs": "Tài khoản mà bạn xác định để cài đặt không có đủ quyền để tạo một tài khoản. Tài khoản mà bạn chỉ ra ở đây phải thực sự tồn tại trước đó.",
+ "config-mysql-engine": "Máy lưu trữ:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>Cảnh báo:</strong> Bạn đã chọn MyISAM làm động cơ lưu trữ cho MySQL, điều này không được khuyến khích sử dụng với MediaWiki, bởi vì:\n* Nó ít hỗ trợ đồng thời do việc khóa bảng\n* Nó dễ bị lỗi hơn so với các động cơ khác\n* Kho mã nguồn của MediaWiki không phải khi nào cũng xử lý MyISAM như mong muốn\n\nNếu cài đặt MySQL của bạn hỗ trợ InnoDB, đặc biệt khuyến cáo bạn nên chọn để thay thế.\nNếu cài đặt MySQL của bạn không hỗ trợ InnoDB, có lẽ đã đến lúc để nâng cấp.",
+ "config-mysql-only-myisam-dep": "<strong>Cảnh báo:</strong> MyISAM chỉ là công cụ lưu trữ có sẵn cho MySQL trên máy tính này, và điều này không được khuyến khích sử dụng với MediaWiki, bởi vì:\n* Nó ít hỗ trợ đồng thời do việc khóa khóa\n* Nó là dễ bị hư hỏng hơn các engine khác\n* Codebase MediaWiki không phải khi nào cũng xử lý MyISAM như mong muốn\n\nCài đặt MySQL của bạn không hỗ trợ InnoDB, có lẽ đã đến lúc để nâng cấp.",
+ "config-mysql-engine-help": "<strong>InnoDB</strong> hầu như luôn là tùy chọn tốt nhất, vì nó có hỗ trợ đồng thời rất tốt.\n\n<strong>MyISAM</strong> có thể nhanh hơn trong chế độ một người dùng hoặc các cài đặt chỉ-đọc (read-only).\nCơ sở dữ liệu MyISAM có xu hướng thường xuyên bị hỏng hóc hơn so với cơ sở dữ liệu InnoDB.",
+ "config-mysql-charset": "Bảng mã cơ sở dữ liệu:",
+ "config-mysql-binary": "Nhị phân",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "Trong <strong>chế độ nhị phân</strong>, MediaWiki lưu văn bản UTF-8 vào cơ sở dữ liệu trong các trường nhị phân.\nĐiều này hiệu quả hơn so với chế độ UTF-8 của MySQL, và cho phép bạn sử dụng đầy đủ các ký tự Unicode.\n\nTrong <strong>chế độ UTF-8 </strong>, MySQL sẽ biết những ký tự nào thiết lập dữ liệu của bạn, và có thể trình bày và chuyển đổi nó một cách thích hợp, nhưng nó sẽ không cho phép bạn lưu trữ các ký tự nằm trên [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes Basic Multilingual Plane].",
+ "config-mssql-auth": "Kiểu xác thực:",
+ "config-mssql-install-auth": "Chọn loại xác thực sẽ được sử dụng để kết nối với cơ sở dữ liệu trong quá trình cài đặt.\nNếu bạn chọn “{{int:config-mssql-windowsauth}}”, thông tin của bất cứ người sử dụng nào mà máy chủ web đang chạy sẽ được sử dụng.",
+ "config-mssql-web-auth": "Chọn kiểu xác thực mà máy chủ web sẽ sử dụng để kết nối đến máy chủ cơ sở dữ liệu, trong quá trình hoạt động bình thường của wiki.\nNếu bạn chọn “{{int:config-mssql-windowsauth}}”, thông tin của bất cứ người sử dụng nào mà máy chủ web đang hoạt động sẽ được sử dụng.",
+ "config-mssql-sqlauth": "Xác thực SQL Server",
+ "config-mssql-windowsauth": "Xác thực Windows",
+ "config-site-name": "Tên wiki:",
+ "config-site-name-help": "Điều này sẽ xuất hiện trên thanh tiêu đề của trình duyệt và ở những nơi khác.",
+ "config-site-name-blank": "Nhập tên của trang Web.",
+ "config-project-namespace": "Không gian tên dự án:",
+ "config-ns-generic": "Dự án",
+ "config-ns-site-name": "Cùng với tên wiki: $1",
+ "config-ns-other": "Khác (định rõ)",
+ "config-ns-other-default": "WikiTôi",
+ "config-project-namespace-help": "Nhiều wiki bắt chước Wikipedia bằng cách tách các trang quy định ra khỏi các trang nội dung trong một '''không gian tên dự án'''.\nTất cả các tên trang trong không gian tên này bắt đầu với một tiền tố cụ thể, bạn có thể xác định ở đây.\nThông thường, tiền tố này bắt nguồn từ tên của wiki, nhưng nó không thể chứa các ký tự đặc biệt như “#” hoặc “:”.",
+ "config-ns-invalid": "Không gian tên cụ thể \"<nowiki>$1</nowiki>\" không hợp lệ.\nXác định một không gian tên dự án khác.",
+ "config-ns-conflict": "Không gian tên cụ thể \"<nowiki>$1</nowiki>\" xung đột với một không gian tên MediaWiki mặc định.\nXác định một không gian tên dự án khác.",
+ "config-admin-box": "Tài khoản bảo quản viên",
+ "config-admin-name": "Tên người dùng của bạn:",
+ "config-admin-password": "Mật khẩu:",
+ "config-admin-password-confirm": "Nhập lại mật khẩu:",
+ "config-admin-help": "Nhập tên người dùng ưa thích ở đây, ví dụ như \" Joe Bloggs\" .\nĐây là tên mà bạn sẽ sử dụng để đăng nhập vào wiki.",
+ "config-admin-name-blank": "Nhập tên người dùng của bảo quản viên.",
+ "config-admin-name-invalid": "Tên người dùng cụ thể \"<nowiki>$1</nowiki>\" không hợp lệ.\nChỉ định một tên người dùng khác.",
+ "config-admin-password-blank": "Nhập mật khẩu của tài khoản bảo quản viên.",
+ "config-admin-password-mismatch": "Bạn đã nhập hai mật khẩu không khớp với nhau.",
+ "config-admin-email": "Địa chỉ thư điện tử:",
+ "config-admin-email-help": "Nhập một địa chỉ thư điện tử vào đây để cho phép bạn nhận được thư điện tử từ những người dùng khác trên wiki, đặt lại mật khẩu của bạn, và được thông báo về những thay đổi trong các trang nằm trong danh sách theo dõi của bạn. Bạn có thể để trống trường này.",
+ "config-admin-error-user": "Lỗi nội bộ khi tạo một admin với tên <nowiki>$1</nowiki>\".",
+ "config-admin-error-password": "Lỗi nội bộ khi thiết lập một mật khẩu cho admin \" <nowiki>$1</nowiki>\": <pre>$2</pre>",
+ "config-admin-error-bademail": "Bạn đã nhập một địa chỉ thư điện tử không hợp lệ.",
+ "config-subscribe": "Theo dõi [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce danh sách thư thông báo phát hành].",
+ "config-subscribe-help": "thông báo an ninh.\nBạn nên đồng ý với nó và cập nhật bản cài đặt MediaWiki của bạn khi phiên bản mới xuất hiện.",
+ "config-subscribe-noemail": "Bạn đã cố gắng để đăng ký vào danh sách thư thông báo phát hành mà không cung cấp một địa chỉ thư điện tử nào cả.\nVui lòng cung cấp một địa chỉ thư điện tử nếu bạn muốn đăng ký vào danh sách thư.",
+ "config-pingback": "Chia sẻ dữ liệu về bản cài đặt này với nhóm phát triển MediaWiki.",
+ "config-pingback-help": "Nếu bạn kích hoạt chức năng này, MediaWiki sẽ thỉnh thoảng gửi cho https://www.mediawiki.org/ dữ liệu cơ bản về bản cài đặt MediaWiki này. Dữ liệu này có chẳng hạn loại máy, phiên bản PHP, và phía sau cơ sở dữ liệu đã chọn. Quỹ Wikimedia chia sẻ dữ liệu này với các nhà phát triển MediaWiki để giúp họ trong việc phát triển phần mềm vào tương lai. Dữ liệu sau sẽ được gửi từ hệ thống này:\n<pre>$1</pre>",
+ "config-almost-done": "Bạn gần như đã hoàn tất!\nBây giờ bạn có thể bỏ qua cấu hình còn lại và cài đặt wiki ngay bây giờ.",
+ "config-optional-continue": "Hỏi tôi về thêm chi tiết.",
+ "config-optional-skip": "Chán quá, cài đặt wiki rỗi.",
+ "config-profile": "Hồ sơ quyền người dùng:",
+ "config-profile-wiki": "Mở wiki",
+ "config-profile-no-anon": "Bắt buộc mở tài khoản",
+ "config-profile-fishbowl": "Chỉ những người dùng được phép",
+ "config-profile-private": "Wiki riêng tư",
+ "config-profile-help": "Wiki hoạt động tốt nhất khi có càng nhiều người sửa đổi nó nhất có thể.\nTrong MediaWiki, có thể rất dễ dàng xem lại các thay đổi gần đây và lùi lại bất kỳ thiệt hại nào được thực hiện bởi người dùng vô tình hoặc người dùng có dụng ý xấu.\n\nTuy nhiên, nhiều người thấy là MediaWiki rất hữu ích trong chừng mực nào đó, và đôi khi thật không phải dễ dàng để thuyết phục mọi người về những lợi ích theo cách thức mà wiki mang lại.\nVì vậy, bạn có sự lựa chọn của riêng bạn.\n\nMô hình <strong>{{int:config-profile-wiki}}</strong> cho phép bất cứ ai tham gia sửa đổi, thậm chí không cần đăng nhập.\nMột wiki với <strong>{{int:config-profile-no-anon}}</strong> cung cấp thêm trách nhiệm, nhưng có thể ngăn chặn những người đóng góp thông thường.\n\nTùy chọn <strong>{{int:config-profile-fishbowl}}</strong> cho phép người dùng được duyệt sửa đổi, nhưng công chúng có thể xem các trang, bao gồm cả lịch sử.\nMột <strong>{{int:config-profile-private}}</strong> chỉ cho phép được duyệt xem các trang, với cùng nhóm được phép sửa đổi.\n\nNhiều cấu hình quyền sử dụng phức tạp có sẵn sau khi cài đặt, xin xem [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights mục liên quan trong sách hướng dẫn].",
+ "config-license": "Bản quyền và giấy phép:",
+ "config-license-none": "Không hiển thị giấy phép ở chân trang",
+ "config-license-cc-by-sa": "Creative Commons Ghi công–Chia sẻ tương tự",
+ "config-license-cc-by": "Creative Commons Ghi công",
+ "config-license-cc-by-nc-sa": "Creative Commons Ghi công–Phi thương mại–Chia sẻ tương tự",
+ "config-license-cc-0": "Creative Commons CC0 (phạm vi công cộng)",
+ "config-license-gfdl": "Giấy pháp Tài liệu Tự do GNU 1.3 trở lên",
+ "config-license-pd": "Phạm vi công cộng",
+ "config-license-cc-choose": "Chọn một giấy phép Creative Commons tùy biến",
+ "config-license-help": "Nhiều wiki công khai phát hành tất cả các đóng góp theo một [http://freedomdefined.org/Definition/Vi?uselang=vi giấy phép tự do].\nĐiều này giúp tạo nên thái độ cộng đồng sở hữu và ủng hộ sự đóng góp lâu dài.\nNói chung, một wiki riêng tư hoặc của công ty không nhất thiết phải sử dụng một giấy phép tự do.\n\nNếu bạn muốn được phép sử dụng văn bản từ Wikipedia và muốn Wikipedia nhận được những văn bản được sao chép từ wiki của bạn, bạn nên chọn <strong>{{int:config-license-cc-by-sa}}</strong>.\n\nWikipedia từng sử dụng Giấy phép Tài liệu Tự do GNU.\nGFDL là một giấy phép hợp lệ nhưng khó hiểu trên thực tế.\nNội dung được phát hành theo GFDL cũng khó tái sử dụng.",
+ "config-email-settings": "Thiết lập thư điện tử",
+ "config-enable-email": "Cho phép gửi thư điện tử đi",
+ "config-enable-email-help": "Nếu bạn muốn khả năng gửi thư điện tử, [http://www.php.net/manual/en/mail.configuration.php thiết lập mail của PHP] cần phải được cấu hình đúng.\nNếu bạn không muốn sử dụng bất kỳ tính năng thư điện tử nào, bạn có thể vô hiệu chúng ở đây.",
+ "config-email-user": "Cho phép người dùng gửi thư điện tử cho người dùng khác",
+ "config-email-user-help": "Cho phép tất cả người dùng gửi thư điện tử cho nhau, nếu họ đã kích hoạt nó trong cài đặt tùy chọn của họ.",
+ "config-email-usertalk": "Gửi thư thông báo về tin nhắn mới",
+ "config-email-usertalk-help": "Cho phép người dùng nhận được thông báo về các thay đổi trong trang thảo luận người dùng, nếu họ đã kích hoạt nó trong cài đặt tùy chọn của họ.",
+ "config-email-watchlist": "Gửi thư thông báo về bài theo dõi",
+ "config-email-watchlist-help": "Cho phép người dùng nhận được thông báo về các trang theo dõi của họ nếu họ đã kích hoạt nó trong ưu tiên của họ.",
+ "config-email-auth": "Xác minh qua thư điện tử",
+ "config-email-auth-help": "Nếu tùy chọn này được kích hoạt, người dùng phải xác nhận địa chỉ thư điện tử của họ bằng cách sử dụng một liên kết được gửi tới cho họ bất cứ khi nào họ thiết lập hoặc thay đổi nó.\nChỉ có địa chỉ thư điện tử được xác thực mới có thể nhận thư điện tử từ những người dùng khác hoặc thay đổi địa chỉ thông báo.\nThiết lập tùy chọn này <strong>khuyến cáo sử dụng</strong> cho các wiki công cộng do khả năng các tính năng gửi thư điện tử dễ bị lạm dụng để gây hại.",
+ "config-email-sender": "Địa chỉ thư điện tử trả lại:",
+ "config-email-sender-help": "Nhập địa chỉ thư điện tử để làm địa chỉ trở về trong thư gửi đi.\nĐây là nơi mà thư từ chối sẽ được gửi đi.\nNhiều máy chủ thư điện tử yêu cầu phải có ít nhất là phần tên miền để đảm bảo tính hợp lệ.",
+ "config-upload-settings": "Hình ảnh và tập tin tải lên",
+ "config-upload-enable": "Cho phép tải lên tập tin",
+ "config-upload-help": "Tập tin tải lên có khả năng làm lộ các nguy cơ bảo mật của máy chủ của bạn.\nĐể biết thêm thông tin, xin đọc [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security phần bảo mật] trong tài liệu hướng dẫn.\n\nĐể kích hoạt tính năng tải tập tin lên, thay đổi chế độ trên thư mục con <code>hình ảnh</code> trong thư mục gốc (root) của MediaWiki để máy chủ web có thể lưu dữ liệu vào đó.\nSau đó kích hoạt tùy chọn này.",
+ "config-upload-deleted": "Thư mục chứa các tập tin đã xóa:",
+ "config-upload-deleted-help": "Chọn một thư mục trong đó lưu trữ các tập tin đã bị xóa.\nLý tưởng nhất, thư mục này không nên được truy cập từ trang web.",
+ "config-logo": "URL biểu trưng:",
+ "config-logo-help": "Giao diện mặc định của MediaWiki bao gồm không gian cho một logo 135x160 điểm ảnh trên menu sidebar (thanh bên).\nTải lên một hình ảnh kích thước thích hợp, và nhập URL hình ảnh đó vào đây.\n\nBạn có thể sử dụng <code>$wgStylePath</code> hoặc <code>$wgScriptPath</code> nếu logo của bạn liên quan đường những đường dẫn ở đây.\n\nNếu bạn không muốn có một logo, hãy bỏ trống ô này.",
+ "config-instantcommons": "Kích hoạt Instant Commons",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons Instant Commons] là một tính năng cho phép wiki sử dụng hình ảnh, âm thanh và tập tin đa phương tiện khác được tìm thấy trong trang web [https://commons.wikimedia.org/ Wikimedia Commons].\nĐể làm được điều này, MediaWiki yêu cầu phải truy cập vào Internet.\n\nĐể biết thêm thông tin về tính năng này, trong đó có hướng dẫn về cách thiết lập cho các wiki khác với Wikimedia Commons, tham khảo thêm tại [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos tài liệu hướng dẫn].",
+ "config-cc-error": "Người chọn giấy phép Creative Commons đã không đưa ra kết quả nào.\nNhập tên giấy phép bằng tay.",
+ "config-cc-again": "Chọn một lần nữa…",
+ "config-cc-not-chosen": "Chọn một giấy phép Creative Commons và bấm “proceed”.",
+ "config-advanced-settings": "Thiết lập nâng cao",
+ "config-cache-options": "Thiết lập bộ nhớ đệm đối tượng:",
+ "config-cache-help": "Lưu vào bộ nhớ đệm đối tượng được sử dụng để cải thiện tốc độ của MediaWiki bằng cách lưu vào bộ nhớ đệm những dữ liệu thường xuyên sử dụng.\nCác trang web từ trung bình cho đến các trang web lớn rất được khuyến khích kích hoạt tính năng này, và các trang web nhỏ cũng sẽ nhìn thấy lợi ích tương tự.",
+ "config-cache-none": "Không lưu vào bộ nhớ đệm (không có chức năng nhiệm vụ sẽ được loại bỏ, nhưng tốc độ có thể bị ảnh hưởng trên các trang web wiki lớn hơn)",
+ "config-cache-accel": "Bộ nhớ đệm đối tượng PHP (APC, APCu, XCache, hoặc WinCache)",
+ "config-cache-memcached": "Sử dụng Memcached (cần thiết lập và cấu hình thêm)",
+ "config-memcached-servers": "Máy chủ Memcached:",
+ "config-memcached-help": "Danh sách các địa chỉ IP để sử dụng cho Memcached .\nNên xác định trên một dòng và chỉ định các cổng được sử dụng. Ví dụ:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "Bạn đã chọn Memcached là loại bộ nhớ đệm nhưng không định rõ máy chủ nào.",
+ "config-memcache-badip": "Bạn đã nhập một địa chỉ IP không hợp lệ cho Memcached: $1.",
+ "config-memcache-noport": "Bạn không thể chỉ định một cổng để sử dụng cho máy chủ Memcached:$1.\nNếu bạn không biết cổng nào, mặc định là 11211.",
+ "config-memcache-badport": "Số cổng Memcached phải từ $1 đến $2.",
+ "config-extensions": "Phần mở rộng",
+ "config-extensions-help": "Mở rộng được liệt kê ở trên đã được phát hiện trong thư mục <code>./extensions</code> của bạn.\n\nChúng có thể yêu cầu thêm cấu hình, nhưng bạn có thể kích hoạt chúng ngay bây giờ.",
+ "config-skins": "Giao diện",
+ "config-skins-help": "Các giao diện được liệt kê ở trên đã được phát hiện trong thư mục <code>./skins</code> của bạn. Bạn phải kích hoạt ít nhất một giao diện, và chọn nó làm mặc định.",
+ "config-skins-use-as-default": "Dùng giao diện này làm mặc định",
+ "config-skins-missing": "Không giao diện nào được tìm thấy; MediaWiki sẽ sử dụng một giao diện dự phòng cho đến khi bạn cài đặt giao diện thích hợp.",
+ "config-skins-must-enable-some": "Phải chọn ít nhất một giao diện để kích hoạt.",
+ "config-skins-must-enable-default": "Giao diện được chọn làm mặc định phải được kích hoạt.",
+ "config-install-alreadydone": "<strong>Cảnh báo:</strong> Bạn dường như đã cài đặt MediaWiki và đang cố gắng để cài đặt nó lại một lần nữa.\nXin hãy chuyển sang trang tiếp theo.",
+ "config-install-begin": "Bằng cách nhấn “{{int:config-continue}}”, bạn sẽ bắt đầu cài đặt MediaWiki của mình.\nNếu bạn vẫn muốn thay đổi, nhấn “{{int:config-back}}”.",
+ "config-install-step-done": "hoàn tất",
+ "config-install-step-failed": "thất bại",
+ "config-install-extensions": "Đang bao gồm phần mở rộng",
+ "config-install-database": "Đang thiết lập cơ sở dữ liệu",
+ "config-install-schema": "Đang tạo giản đồ",
+ "config-install-pg-schema-not-exist": "Lược đồ PostgreSQL không tồn tại.",
+ "config-install-pg-schema-failed": "Thất bại khi tạo các bảng.\nHãy chắc chắn rằng người dùng “$1” có thể ghi vào giản đồ “$2”.",
+ "config-install-pg-commit": "Đang gửi các thay đổi",
+ "config-install-pg-plpgsql": "Tìm ngôn ngữ PL/pgSQL",
+ "config-pg-no-plpgsql": "Bạn cần phải cài đặt ngôn ngữ PL/pgSQL vào cơ sở dữ liệu $1",
+ "config-pg-no-create-privs": "Tài khoản bạn xác định để cài đặt không đủ quyền hạn để tạo một tài khoản.",
+ "config-pg-not-in-role": "Tài khoản bạn xác định cho người dùng web đã tồn tại.\nTài khoản bạn xác định cho việc cài đặt không phải là một superuser(người dùng cao cấp) và không phải là một thành viên của vai trò người sử dụng web, vì vậy nó không thể tạo ra các đối tượng thuộc sở hữu của người sử dụng web.\n\nMediaWiki hiện nay yêu cầu rằng các bảng được sở hữu bởi người sử dụng web. Hãy xác định một tên tài khoản web, hoặc click \"back\" (quay trở về) và chỉ định một người dùng cài đặt có đặc quyền thích hợp.",
+ "config-install-user": "Đang tạo người dùng trên cơ sở dữ liệu",
+ "config-install-user-alreadyexists": "Người dùng “$1” đã tồn tại",
+ "config-install-user-create-failed": "Thất bại khi tạo người dùng “$1”: $2",
+ "config-install-user-grant-failed": "Thất bại khi cấp quyền cho người dùng “$1”: $2",
+ "config-install-user-missing": "Người dùng chỉ định “$1” không tồn tại.",
+ "config-install-user-missing-create": "Người dùng chỉ định “$1” không tồn tại.\nKiểm hộp “mở tài khoản” ở dưới để tạo ra nó.",
+ "config-install-tables": "Đang tạo các bảng",
+ "config-install-tables-exist": "'''Cảnh báo:''' Các bảng MediaWiki hình như đã tồn tại.\nĐã bỏ qua việc tạo ra các bảng.",
+ "config-install-tables-failed": "'''Lỗi:''' Thất bại khi tạo các bảng với lỗi sau: $1",
+ "config-install-interwiki": "Đang xây dựng bảng liên wiki mặc định",
+ "config-install-interwiki-list": "Không thể đọc tập tin <code>interwiki.list</code>.",
+ "config-install-interwiki-exists": "'''Cảnh báo:''' Hình như đã có mục trong bảng liên wiki.\nĐã bỏ qua danh sách mặc định.",
+ "config-install-stats": "Đang khởi tạo các thống kê",
+ "config-install-keys": "Tạo ra các chìa khóa bí mật",
+ "config-insecure-keys": "<strong>Cảnh báo:</strong> {{PLURAL:$2|Một khóa an toàn|Khóa an toàn}} ($1) được tạo ra trong quá trình cài đặt {{PLURAL:$2}}không phải an toàn hẳn. Hãy cân nhắc việc thay đổi {{PLURAL:$2|nó|chúng}} thủ công.",
+ "config-install-updates": "Tránh các cập nhật không cần thiết",
+ "config-install-updates-failed": "<strong>Lỗi:</strong> Chèn phím cập nhật vào các bảng không thành công với các lỗi sau:1$",
+ "config-install-sysop": "Đang mở tài khoản người dùng bảo quản viên",
+ "config-install-subscribe-fail": "Không thể theo dõi mediawiki-announce: $1",
+ "config-install-subscribe-notpossible": "cURL không được cài đặt và <code>allow_url_fopen</code> không có sẵn.",
+ "config-install-mainpage": "Đang tạo trang đầu với nội dung mặc định",
+ "config-install-mainpage-exists": "Bỏ qua trang chính đã tồn tại",
+ "config-install-extension-tables": "Đang tạo bảng cho các phần mở rộng được kích hoạt",
+ "config-install-mainpage-failed": "Không thể chèn trang đầu: $1",
+ "config-install-done": "<strong>Xin chúc mừng!</strong>\nBạn đã cài đặt MediaWiki.\n\nBộ cài đặt đã tạo ra một tập tin <code>LocalSettings.php</code>.\nTập tin này chứa tất cả các cấu hình của bạn.\n\nBạn sẽ cần phải tải nó về và đặt nó trong thư mục cài đặt wiki của bạn (cùng thư mục với index.php). Việc tải về có lẽ sẽ được khởi động tự động.\n\nNếu bản tải về không được cung cấp, hoặc nếu bạn hủy bỏ nó, bạn có thể khởi động lại tải về bằng cách nhấn vào liên kết dưới đây:\n\n$3\n\n<strong>Lưu ý:</strong> Nếu bạn không làm điều này ngay bây giờ, điều này sẽ tạo ra tập tin cấu hình sẽ không có giá trị cho bạn sau này nếu bạn thoát khỏi trình cài đặt mà không tải nó về.\n\nKhi đã việc tải về đã hoàn thành, bạn có thể <strong>[$2 truy cập trang wiki của bạn]</strong>.",
+ "config-install-done-path": "<strong>Xin chúc mừng!</strong>\nBạn đã cài đặt MediaWiki.\n\nBộ cài đặt đã tạo ra một tập tin <code>LocalSettings.php</code>.\nTập tin này chứa tất cả các cấu hình của bạn.\n\nBạn sẽ cần phải tải nó về và đặt nó tại <code>$4</code>. Việc tải về có lẽ sẽ được khởi động tự động.\n\nNếu bản tải về không được cung cấp, hoặc nếu bạn hủy bỏ nó, bạn có thể khởi động lại tải về bằng cách nhấn vào liên kết dưới đây:\n\n$3\n\n<strong>Lưu ý:</strong> Nếu bạn không làm điều này ngay bây giờ, điều này sẽ tạo ra tập tin cấu hình sẽ không có giá trị cho bạn sau này nếu bạn thoát khỏi trình cài đặt mà không tải nó về.\n\nKhi đã việc tải về đã hoàn thành, bạn có thể <strong>[$2 truy cập trang wiki của bạn]</strong>.",
+ "config-download-localsettings": "Tải về <code>LocalSettings.php</code>",
+ "config-help": "Trợ giúp",
+ "config-help-tooltip": "nhấn chuột để mở rộng",
+ "config-nofile": "Không tìm thấy tập tin “$1”. Nó có phải bị xóa không?",
+ "config-extension-link": "Bạn có biết rằng wiki của bạn có hỗ trợ [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions mở rộng]?\n\nBạn có thể truy cập [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category phần mở rộng theo thể loại] hoặc [https://www.mediawiki.org/wiki/Extension_Matrix Ma trận Mở rộng] để xem danh sách đầy đủ các phần mở rộng.",
+ "config-skins-screenshots": "$1 (ảnh chụp màn hình: $2)",
+ "config-screenshot": "ảnh chụp màn hình",
+ "mainpagetext": "'''MediaWiki đã được cài đặt.'''",
+ "mainpagedocfooter": "Xin đọc [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Hướng dẫn sử dụng] để biết thêm thông tin về cách sử dụng phần mềm wiki.\n\n== Để bắt đầu ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Danh sách các thiết lập cấu hình]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Các câu hỏi thường gặp MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Danh sách gửi thư về việc phát hành MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Tìm hiểu cách chống spam tại wiki của bạn]"
+}
diff --git a/www/wiki/includes/installer/i18n/vo.json b/www/wiki/includes/installer/i18n/vo.json
new file mode 100644
index 00000000..2c385124
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/vo.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''El MediaWiki pestiton benosekiko.'''",
+ "mainpagedocfooter": "Konsultolös [https://meta.wikimedia.org/wiki/Help:Contents Gebanageidian] ad tuvön nünis dö geb programema vükik.\n\n== Nüdugot ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Parametalised]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki: SSP]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Potalised tefü fomams nulik ela MediaWiki]"
+}
diff --git a/www/wiki/includes/installer/i18n/vro.json b/www/wiki/includes/installer/i18n/vro.json
new file mode 100644
index 00000000..4ee179de
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/vro.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki tarkvara paika säet.'''",
+ "mainpagedocfooter": "Vikitarkvara pruukmisõ kotsilõ loeq mano:\n* [https://meta.wikimedia.org/wiki/MediaWiki_User%27s_Guide MediaWiki pruukmisoppus (inglüse keelen)].\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Säädmiisi oppus (inglüse keelen)]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki kõgõ küsütümbäq küsümiseq (inglüse keelen)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce E-postilist, minka andas teedäq MediaWiki vahtsist kujõst]."
+}
diff --git a/www/wiki/includes/installer/i18n/wa.json b/www/wiki/includes/installer/i18n/wa.json
new file mode 100644
index 00000000..a201132b
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/wa.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Srtxg"
+ ]
+ },
+ "mainpagetext": "'''Li programe MediaWiki a stî astalé a l' idêye.'''"
+}
diff --git a/www/wiki/includes/installer/i18n/war.json b/www/wiki/includes/installer/i18n/war.json
new file mode 100644
index 00000000..ef7694a7
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/war.json
@@ -0,0 +1,114 @@
+{
+ "@metadata": {
+ "authors": [
+ "Harvzsf",
+ "JinJian"
+ ]
+ },
+ "config-desc": "An pan-installar han MediaWiki",
+ "config-title": "MediaWiki $1 nga pag-installar",
+ "config-information": "Impormasyon",
+ "config-localsettings-upgrade": "Mayda <code>LocalSettings.php</code> nga paypay nga nabilngan. Basi ma-upgrade ini nga pag-installar, alayon pagbutáng han value han <code>$wgUpgradeKey</code> ha kahon ha ubós. Mabibilngan mo ini ha <code>LocalSettings.php</code>.",
+ "config-localsettings-cli-upgrade": "Mayda <code>LocalSettings.php</code> nga paypay nga nabilngan. Basi ma-upgrade ini nga pag-installar, alayon pagpadalagan lugod han <code>update.php</code>",
+ "config-localsettings-key": "Upgrade nga yabi:",
+ "config-localsettings-badkey": "Sayop an upgrade nga yabi nga imo ginhátag",
+ "config-upgrade-key-missing": "Mayda daan na ng gin-installar nga MediaWiki nga nabilngan.\nBasi ma-upgrade ini nga pag-instalar, alayon pagbutang han nahasunod nga linya ha ubós han imo <code>LocalSettings.php</code>:\n\n$1",
+ "config-localsettings-incomplete": "An yana nga <code>LocalSettings.php</code> in baga diri kompleto.\nAn $1 variable in diri naka-set.\nAlayon igsaliwan an <code>LocalSettings.php</code> para ini nga variable in mai-set, ngan pidlita an \"{{int:Config-continue}}\".",
+ "config-localsettings-connection-error": "May-ada pagsayop an nahitabo han pagpapakabit ngada ha database nga gingagamitan hin mga kamumutangan nga dapat unta ginpapatuman han <code>LocalSettings.php</code>. Alayon ayda ini nga mga kamumutangan ngan utrohon nala.\n\n$1",
+ "config-session-error": "Pakyas an pagtikang han session: $1",
+ "config-session-expired": "An imo sesyon nga data baga na hin naglahós na hin panahón\nIt mga sesyon gin-configure hin pagkaiha nga $1\nPuyde mo ini paiha-on ha pagset hit <code>session.gc_maxlifetime</code> ha php.ini.\nIgtikang hin utro an pag-instalar nga proseso.",
+ "config-no-session": "¡Nawarâ an imo sesyon nga data!\nKitaa an imo php.ini ngan siguroa nga an <code>session.save_path</code> ginkadâ hin naangay nga direktory.",
+ "config-your-language": "Imo pinulongán",
+ "config-your-language-help": "Pili-a in yinaknan nga gagamiton dida han proseso han pag-instalar.",
+ "config-wiki-language": "Pinulongán han wiki",
+ "config-wiki-language-help": "Pilía an pinulongán nga kauróg igsúsurat hit wiki",
+ "config-back": "Bálik",
+ "config-continue": "Padayon",
+ "config-page-language": "Pinulongán",
+ "config-page-welcome": "Maupay nga pag-abot ha MediaWiki!",
+ "config-page-dbconnect": "Igsumpay ha database",
+ "config-page-upgrade": "Ig-upgrade it aada nga na-instalar",
+ "config-page-dbsettings": "Mga setting ha database",
+ "config-page-name": "Ngaran",
+ "config-page-options": "Mga pagpipilian",
+ "config-page-install": "Ig-instalar",
+ "config-page-complete": "Nakompleto!",
+ "config-page-restart": "Igbalik hin utro in pag-instalar",
+ "config-page-readme": "Basaha ako",
+ "config-page-releasenotes": "Mga nota han ginpagawás",
+ "config-page-copying": "Nagkokopya",
+ "config-page-upgradedoc": "Pag-upgrade",
+ "config-page-existingwiki": "Aada nga wiki",
+ "config-help-restart": "¿Karúyag mo ba ighawan an tanan nga gin-save nga data nga imo gin-enter ngan igbalik hin utro an proseso hin pag-instalar?",
+ "config-restart": "Oo, utroha patikanga",
+ "config-welcome": "=== Mga pagpanginano panlibong ===\nMagkakamay-ada yano nga panginano para masabtan kun ini nga libong in naaangay para hiton pagtataod hiton MediaWiki. Hinumdomi iton paglakip hinin nga impormasyon kun karuyag mo mangaro hin suporta kun paunan-on humanon an pagtataod.",
+ "config-env-php": "Gin-install an PHP $1.",
+ "config-env-hhvm": "Gin-install an HHVM $1.",
+ "config-unicode-using-intl": "Gamita an [http://pecl.php.net/intl intl PECL extension] para han normalisasyon han Unicode.",
+ "config-unicode-pure-php-warning": "<strong>Pahimatngon:</strong> An [http://pecl.php.net/intl intl PECL extension] in waray akos kumapot hin Unicode normalization, tungod hini mabalik ha mahinay nga puro-PHP nga implementasyon.\nKun nagpapadalagan ka hin high-traffic site, alayon pagbasa hin guti han [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].",
+ "config-no-db": "Diri nakakabiling hin naaangay nga database driver! Kinahanglan mo magtaod hin uska database driver para han PHP. An masunod nga mga klase hin database in ginsusuporatahan: $1.\n\nKun ikaw mismo an nag-compile han PHP, kinahanglan ma-reconfigure iton nga para maapandar an database client, pananglitan, han paggamit han <code>./configure --with-mysqli</code>.\nKun gintaod mo an PHP tikang ha uska Debian o Ubuntu nga pakete, kinahanglan nimo magtaod liwat, pananglitan, hiton an <code>php5-mysql</code> nga pakete.",
+ "config-pcre-old": "<strong>Nangangarat-an:</strong> Nagkikinahanglan hin PCRE $1 o mas urhi pa.\nAn imo PHP nga binaryo in nakasumpay hin PCRE $2. [https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE More information].",
+ "config-db-name": "Ngaran han database:",
+ "config-db-name-help": "Pagpili hin ngaran nga natudlok ha imo wiki.\nDapat waray ini mga espasyo.\n\nKun ikaw in nagamit hin shared web hosting, an imo hosting provider in mahatag diri ngani an specific database name para paggamit, matugot ha imo paghimo hin mga database pinaagi han control panel.",
+ "config-db-name-oracle": "Schema han database:",
+ "config-db-username": "Agnay-gumaramit para ha database:",
+ "config-db-password": "Password para ha database:",
+ "config-db-port": "Database port:",
+ "config-type-mysql": "MySQL (o compatible)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-sqlite-readonly": "An file nga <code>$1</code> in diri writeable.",
+ "config-sqlite-cant-create-db": "Diri nakakahimo hin database file nga <code>$1</code>.",
+ "config-db-web-account": "Database account para han web access",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-binary": "Binary",
+ "config-mysql-utf8": "UTF-8",
+ "config-site-name": "Ngaran han wiki:",
+ "config-ns-generic": "Proyekto",
+ "config-ns-site-name": "Kapareho han wiki nga ngaran: $1",
+ "config-ns-other-default": "MyWiki",
+ "config-admin-name": "Imo ngaran-gumaramit:",
+ "config-admin-password": "Tigaman panulod:",
+ "config-admin-password-confirm": "Tigaman panulod utro:",
+ "config-admin-name-blank": "Pagbutang hin ngaran-gumaramit hit magdudumara.",
+ "config-admin-password-blank": "Pagbutang hin tigaman-panulod para hit akawnt han magdudumara.",
+ "config-admin-password-mismatch": "An duha nga mga tigaman-panulod nga imo ginbutang in diri magkaparehas.",
+ "config-admin-email": "Address hit Email:",
+ "config-admin-error-bademail": "Nagbutang ka hin diri puydi nga address hit email.",
+ "config-almost-done": "Harani ka na mahuman!\nPuydi nim ilaktaw an nasasalin nga configuration ngan ig-install an wiki yana dayon.",
+ "config-optional-continue": "Pakyana pa hin durudamo nga mga pakiana.",
+ "config-profile": "Profile han mga katungod han gumaramit:",
+ "config-profile-wiki": "Open wiki",
+ "config-profile-no-anon": "Kinahanglan an paghimo hin akawnt",
+ "config-profile-fishbowl": "Otorisado nga mga editor la",
+ "config-profile-private": "Pribado nga wiki",
+ "config-license-pd": "Dominyo Publiko",
+ "config-enable-email-help": "Kun naruruyag ka nga gumana an email, an [http://www.php.net/manual/en/mail.configuration.php PHP's mail settings] in kinahanglan nga mai-configure hin asya.\nKun diri ka naruruyag hin bisan ano nga mga email feature, puydi nim igparong dinhi.",
+ "config-email-user": "Igpaandar an gumaramit-ha-gumaramit nga email",
+ "config-email-user-help": "Igtugot an ngatanan nga mga gumaramit nga magpadangat hin email ha tagsa-tagsa kun ira ginpaandar ini ha ira karuyagon.",
+ "config-email-usertalk": "Igpaandar an pagpasabot ha pakli han hiruhimangraw han gumaramit",
+ "config-cc-again": "Pilii utro...",
+ "config-extensions": "Mga panugtong",
+ "config-install-step-done": "human na",
+ "config-install-step-failed": "pakyas",
+ "config-install-extensions": "Lakip an mga panugtong",
+ "config-install-schema": "Naghihimo hin iskima",
+ "config-install-pg-schema-not-exist": "Waray natatad-an nga iskima PostgreSQL.",
+ "config-install-pg-commit": "Nagsasaad hin pagbabag-o",
+ "config-install-user": "Naghihimo hin gumaramit hit database",
+ "config-install-user-alreadyexists": "May-ada na gumaramit nga \"$1\"",
+ "config-install-user-create-failed": "Pakyas an paghimo hin gumaramit nga \"$1\": $2",
+ "config-install-user-grant-failed": "Pakyas an paghatag hin pagtugot han gumaramit \"$1\": $2",
+ "config-install-tables": "Naghihimo hin mga table",
+ "config-install-tables-failed": "<strong>Sayop:</strong> An paghimo hin table in pakyas tungod han masunod nga pagsayop: $1",
+ "config-install-interwiki-list": "Diri nakakabasa han paypay <code>interwiki.list</code>.",
+ "config-install-sysop": "Naghihimo hin akawnt han gumaramit han magdudurama",
+ "config-install-extension-tables": "Naghihimo hin mga table han pinaandar nga mga panugtong",
+ "config-install-mainpage-failed": "Diri nakakasuksok hin panguna nga pakli: $1",
+ "config-download-localsettings": "Ikarga-paubos an <code>LocalSettings.php</code>",
+ "config-help": "buligi",
+ "config-help-tooltip": "igpidlit para dumako",
+ "config-nofile": "An paypay nga \"$1\" in diri nabibilngan. Ginpara na ini?",
+ "mainpagetext": "'''Malinamposon an pag-instalar han MediaWiki.'''",
+ "mainpagedocfooter": "Kitaa an [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] para hin impormasyon ha paggamit han wiki nga softweyr.\n\n== Ha pagtikang==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/wo.json b/www/wiki/includes/installer/i18n/wo.json
new file mode 100644
index 00000000..f53ba9f6
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/wo.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''Campug MediaWiki gi sotti na . '''",
+ "mainpagedocfooter": "Saytul [https://meta.wikimedia.org/wiki/Help:Contents Gindikaayu jëfandikukat bi] ngir yeneeni xibaar ci jëfandiku gu tëriin gi.\n\n== Tambali ak MediaWiki ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Limu jumtukaayi kocc-koccal gi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Limu waxtaan ci liy-génn ci MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/wuu.json b/www/wiki/includes/installer/i18n/wuu.json
new file mode 100644
index 00000000..6b5bfe00
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/wuu.json
@@ -0,0 +1,14 @@
+{
+ "@metadata": {
+ "authors": [
+ "Wu-chinese.com",
+ "Poiuyt",
+ "飞舞回堂前"
+ ]
+ },
+ "config-information": "信息",
+ "config-back": "← 转去",
+ "config-page-language": "闲话",
+ "mainpagetext": "<strong>MediaWiki安装好哉。</strong>",
+ "mainpagedocfooter": "请访问[https://meta.wikimedia.org/wiki/Help:Contents 用户手册]以获得使用此维基软件个信息!\n\n== 入门 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings MediaWiki 配置设置列表]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki 常见问题解答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki 发布邮件列表]"
+}
diff --git a/www/wiki/includes/installer/i18n/xal.json b/www/wiki/includes/installer/i18n/xal.json
new file mode 100644
index 00000000..fc7917be
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/xal.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Huuchin"
+ ]
+ },
+ "mainpagetext": "Йовудта Mediawiki гүүлһүдә тәвллһн.'''",
+ "mainpagedocfooter": "Тер бики закллһна теткүл ю кеһәд олзлх туск [https://meta.wikimedia.org/wiki/Help:Contents көтлвр] дастн.\n\n== Туста заавр ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Көгүдә бүрткл]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki туск ЮмБи]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki шинҗллһнә бүрткл]"
+}
diff --git a/www/wiki/includes/installer/i18n/xmf.json b/www/wiki/includes/installer/i18n/xmf.json
new file mode 100644
index 00000000..dea81d9c
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/xmf.json
@@ -0,0 +1,33 @@
+{
+ "@metadata": {
+ "authors": [
+ "Silovan"
+ ]
+ },
+ "config-desc": "MediaWiki-შ ინსტალატორი",
+ "config-title": "MediaWiki $1 ინსტალაცია",
+ "config-information": "ინფორმაცია",
+ "config-localsettings-key": "გოახალაფაშ კილა:",
+ "config-localsettings-badkey": "კილა, ნამუთ თქვა წჷმარინეთინ ცაგანა რე.",
+ "config-session-error": "ჩილათაშ დოჭყაფაშ სესია: $1",
+ "config-your-language": "თქვან ნინა:",
+ "config-your-language-help": "გეგშაგორით ნინა, ნამუსჷთ ინსტალაციაშ პროცესის გიმირინუანთინ.",
+ "config-wiki-language": "ვიკიშ ნინა:",
+ "config-back": "← უკახალე",
+ "config-continue": "უკული →",
+ "config-page-language": "ნინა",
+ "config-page-welcome": "ბედინერ ორდას თქვანი მოზოჯუა მედიავიკიშა!",
+ "config-page-dbconnect": "მუნაჩემეფიშ ბაზაწჷმა მერსხუალა",
+ "config-page-dbsettings": "მუნაჩემეფიშ ბაზაშ კონფიგურაცია",
+ "config-page-name": "ჯოხო",
+ "config-page-options": "პარამეტრეფი",
+ "config-page-install": "ინსტალაცია",
+ "config-page-complete": "თებული რე!",
+ "config-page-restart": "ინსტალაციაშ დუდშე დოჭყაფა",
+ "config-page-readme": "წემკითხი",
+ "config-page-releasenotes": "გიშაშქუმალაშ მეღანკუეფი",
+ "config-page-upgradedoc": "გოახალაფა",
+ "config-help-restart": "გოკონანო თქვან მიშნაჸონეფი არძო ჩუალირ მუნაჩემიშ ლასუა დო ინსტალაციაშ დუდშე დოჭყაფა?",
+ "config-restart": "ქო, დუდშე დიჭყით",
+ "config-env-php": "PHP $1 დოინსტალირაფირი რე."
+}
diff --git a/www/wiki/includes/installer/i18n/yi.json b/www/wiki/includes/installer/i18n/yi.json
new file mode 100644
index 00000000..e89113cb
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/yi.json
@@ -0,0 +1,75 @@
+{
+ "@metadata": {
+ "authors": [
+ "פוילישער",
+ "පසිඳු කාවින්ද",
+ "Har-wradim"
+ ]
+ },
+ "config-desc": "דער אינסטאלירער פאר מעדיעוויקי",
+ "config-title": "מעדיעוויקי $1 אינסטאלירונג",
+ "config-information": "אינפֿארמאציע",
+ "config-localsettings-key": "אקטואליזירונג־שליסל:",
+ "config-localsettings-badkey": "דעם ראנג־העכערונג שליסל וואס איר האט אײַנגעגעבן איז פאלש.",
+ "config-session-error": "פֿעלער ביים אָנהייבן סעסיע: $1",
+ "config-your-language": "אײַער שפראך:",
+ "config-your-language-help": "קלויבט א שפראך צו ניצן ביים אינסטאלירונג פראצעס.",
+ "config-wiki-language": "ווקי שפראך:",
+ "config-wiki-language-help": "קלויבט אויס די שפראך מיט וואס בעיקר מען וועט שרײַבן די וויקי.",
+ "config-back": "→ צוריק",
+ "config-continue": "פֿארזעצן ←",
+ "config-page-language": "שפראַך",
+ "config-page-welcome": "ברוכים הבאים צו מעדיעוויקי!",
+ "config-page-dbconnect": "פארבינדן צו דאטנבאזע",
+ "config-page-upgrade": "ראנג־העכערונג פון פארהאנע אינסטאלאציע",
+ "config-page-dbsettings": "דאטנבאזע איינשטעלונגען",
+ "config-page-name": "נאָמען",
+ "config-page-options": "ברירות",
+ "config-page-install": "אינסטאלירן",
+ "config-page-complete": "פארטיק!",
+ "config-page-restart": "ווידער־אנהייבן אינסטאלאציע",
+ "config-page-readme": "לייענט מיך",
+ "config-page-releasenotes": "ווערסיע־הערות",
+ "config-page-copying": "קאפיע",
+ "config-page-upgradedoc": "ראנג־העכערן",
+ "config-page-existingwiki": "עקזיסטירנדע וויקי",
+ "config-help-restart": "צי ווילט איר אפראמען די גארע געשפייכלערטע דאטן וואס איר האט אײַנגעגעבן און ווידער אנהייבן דעם אינסטאלאציע־פראצעס?",
+ "config-restart": "יא, ווידעראמאל אנהייבן",
+ "config-env-good": "מ'האט קאנטראלירט די סביבה.\nאיר קענט אינסטאלירן מעדיעוויקי.",
+ "config-env-bad": "מ'האט קאנטראלירט די סביבה.\nאיר קענט נישט אינסטאלירן מעדיעוויקי.",
+ "config-env-php": "PHP $1 איז אינצטאלירט.",
+ "config-env-hhvm": "HHVM $1 איז אינסטאלירט.",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] איז אינסטאלירט",
+ "config-apc": "[http://www.php.net/apc APC] איז אינסטאלירט",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] איז אינסטאלירט",
+ "config-diff3-bad": "GNU diff3 נישט געטראפן.",
+ "config-using-server": "באניצן סארווער־נאמען \"<nowiki>$1</nowiki>\".",
+ "config-using-uri": "באניצן סארווער־אדרעס \"<nowiki>$1$2</nowiki>\".",
+ "config-db-type": "דאטנבאזע טיפ:",
+ "config-db-host": "דאטנבאזע־סארווער:",
+ "config-db-host-oracle": "דאטנבאזע־TNS:",
+ "config-db-wiki-settings": "אידענטיפיצירן די דאזיקע וויקי",
+ "config-db-name": "דאטנבאזע נאָמען:",
+ "config-db-name-oracle": "דאטנבאזע סכעמע:",
+ "config-db-install-account": "באניצער־קאנטע פאר אינסטאלאציע",
+ "config-db-username": "דאטנבאזע באניצער־נאָמען:",
+ "config-db-password": "דאטנבאזע־פאסווארט:",
+ "config-invalid-db-type": "אומגילטיגער דאטנבאזע־טיפ",
+ "config-missing-db-name": "איר דארפט איינגעבן א ווערט פאר \"{{int:config-db-name}}\".",
+ "config-missing-db-host": "איר דארפט איינגעבן א ווערט פאר \"{{int:config-db-host}}\".",
+ "config-missing-db-server-oracle": "איר דארפט איינגעבן א ווערט פאר \"{{int:config-db-host-oracle}}\".",
+ "config-project-namespace": "פראיעקט נאָמענטייל:",
+ "config-ns-generic": "פראיעקט",
+ "config-admin-name": "אײַער באַניצער־נאָמען:",
+ "config-admin-password": "פאַסווארט:",
+ "config-admin-password-mismatch": "די צוויי פאסוועטרט איר האט איינגעגעבן שטימען נישט.",
+ "config-admin-email": "בליצפּאָסט אַדרעס:",
+ "config-install-tables": "שאפן טאבעלעס",
+ "config-install-tables-exist": "'''ווארענונג''': זעט אויס אז די מעדיעוויקי טאבעלעס עקזיסטירן שוין.\nאיבערהיפן שאפֿן.",
+ "config-install-mainpage-failed": "מ'האט נישט געקענט אריינגעבן הויפטבלאט: $1",
+ "config-download-localsettings": "אראפלאדן <code>LocalSettings.php</code>",
+ "config-help": "הילף",
+ "config-nofile": "מ'האט נישט געקענט טרעפן די טעקע \"$1\". צי האט מען זי אויסגעמעקט?",
+ "mainpagetext": "<strong> מעדיעוויקי אינסטאלירט.</strong>",
+ "mainpagedocfooter": "גיט זיך אן עצה מיט [https://meta.wikimedia.org/wiki/Help:Contents באניצער'ס וועגווײַזער] פֿאר אינפֿארמאציע וויאזוי זיך באנוצן מיט וויקי ווייכוואַרג.\n\n== נוצליכע וועבלינקען פֿאַר אנהייבערס ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings רשימה פון קאנפֿיגוראציעס]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ אפֿט געפֿרעגטע שאלות]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce מעדיעוויקי באפֿרײַאונג פאסטליסטע]* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources איבערזעצן מעדיעוויקי אין אײַער שפראך]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam לערנט וויאזוי צו באקעמפן בפעם אויף אייער וויקי]"
+}
diff --git a/www/wiki/includes/installer/i18n/yo.json b/www/wiki/includes/installer/i18n/yo.json
new file mode 100644
index 00000000..aee9b848
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/yo.json
@@ -0,0 +1,19 @@
+{
+ "@metadata": {
+ "authors": [
+ "Demmy"
+ ]
+ },
+ "config-your-language": "Èdè yín:",
+ "config-wiki-language": "Èdè wiki:",
+ "config-back": "← Ẹ̀yìn",
+ "config-continue": "Ìtẹ̀síwájú →",
+ "config-page-language": "Èdè",
+ "config-page-welcome": "Ẹ kú àbò sí MediaWiki!",
+ "config-page-dbconnect": "Ìjápọ̀ mọ́ ibùdó dátà",
+ "config-page-name": "Orúkọ",
+ "config-db-type": "Irú ibùdó dátà:",
+ "config-db-host": "Agbàlejò ibùdó dátà:",
+ "mainpagetext": "'''MediaWiki ti jẹ́ gbígbékọ́sínú láyọrísírere.'''",
+ "mainpagedocfooter": "Ẹ ṣàbẹ̀wò sí [https://meta.wikimedia.org/wiki/Help:Contents User's Guide] fún ìfitólétí nípa líló atòlànà wíkì.\n\n== Láti bẹ̀rẹ̀ ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]"
+}
diff --git a/www/wiki/includes/installer/i18n/yue.json b/www/wiki/includes/installer/i18n/yue.json
new file mode 100644
index 00000000..44bcb0e1
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/yue.json
@@ -0,0 +1,5 @@
+{
+ "@metadata": [],
+ "mainpagetext": "'''MediaWiki已經裝好。'''",
+ "mainpagedocfooter": "參閱[https://meta.wikimedia.org/wiki/Help:Contents 用戶指引](英),裏面有資料講點用wiki軟件。\n\n==開始使用==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 配置設定清單](英)\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki 常見問題](英)\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki 發佈郵件名單](英)"
+}
diff --git a/www/wiki/includes/installer/i18n/zea.json b/www/wiki/includes/installer/i18n/zea.json
new file mode 100644
index 00000000..df13fdfb
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/zea.json
@@ -0,0 +1,9 @@
+{
+ "@metadata": {
+ "authors": [
+ "Seb35"
+ ]
+ },
+ "mainpagetext": "'''De installaotie van MediaWiki is geslaegd.'''",
+ "mainpagedocfooter": "Raedpleeg de [https://meta.wikimedia.org/wiki/Help:Contents Inhoudsopgaeve andleidieng] voe informatie over 't gebruuk van de wikisoftware.\n\n== Meer ulpe over MediaWiki ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lieste mie instelliengen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Veehestelde vraehen (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailienglieste voe ankondigiengen van nieuwe versies]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
+}
diff --git a/www/wiki/includes/installer/i18n/zh-hans.json b/www/wiki/includes/installer/i18n/zh-hans.json
new file mode 100644
index 00000000..fab5eef9
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/zh-hans.json
@@ -0,0 +1,336 @@
+{
+ "@metadata": {
+ "authors": [
+ "Anthony Fok",
+ "Cwek",
+ "Hydra",
+ "Hzy980512",
+ "Liangent",
+ "Makecat",
+ "PhiLiP",
+ "Xiaomingyan",
+ "Yfdyh000",
+ "乌拉跨氪",
+ "阿pp",
+ "아라",
+ "Byfserag",
+ "Hudafu",
+ "Liuxinyu970226",
+ "Qiyue2001",
+ "Kuailong",
+ "Zjzengdongyang",
+ "Mywood",
+ "Impersonator 1",
+ "Fengchao",
+ "Duolaimi"
+ ]
+ },
+ "config-desc": "MediaWiki安装程序",
+ "config-title": "MediaWiki $1配置",
+ "config-information": "信息",
+ "config-localsettings-upgrade": "已检测到<code>LocalSettings.php</code>文件。要升级该配置,请在下面的框中输入<code>$wgUpgradeKey</code>的值。您可以在<code>LocalSettings.php</code>中找到它。",
+ "config-localsettings-cli-upgrade": "已检测到<code>LocalSettings.php</code>文件。要升级该配置,请直接运行<code>update.php</code>。",
+ "config-localsettings-key": "升级密钥:",
+ "config-localsettings-badkey": "您提供的升级密钥不正确。",
+ "config-upgrade-key-missing": "检测到MediaWiki的配置已经存在。若要升级该配置,请将下面一行文本添加到<code>LocalSettings.php</code>的底部:\n\n$1",
+ "config-localsettings-incomplete": "当前的<code>LocalSettings.php</code>可能并不完整,因为变量$1没有设置。请在<code>LocalSettings.php</code>设置该变量,并单击“{{int:Config-continue}}”。",
+ "config-localsettings-connection-error": "在使用<code>LocalSettings.php</code>中指定的设置连接数据库时发生错误。请修复相应设置并重试。\n\n$1",
+ "config-session-error": "启动会话出错:$1",
+ "config-session-expired": "您的会话数据可能已经过期,当前会话的使用期限被设定为$1。您可以在php.ini中设置<code>session.gc_maxlifetime</code>来延长此期限,并重新启动本配置程序。",
+ "config-no-session": "您的会话数据丢失了!请检查php.ini并确保<code>session.save_path</code>被设置为适当的目录。",
+ "config-your-language": "您使用的语言:",
+ "config-your-language-help": "选择在安装过程中使用的语言。",
+ "config-wiki-language": "Wiki使用的语言:",
+ "config-wiki-language-help": "选择将要安装的wiki在多数情况下使用的语言。",
+ "config-back": "← 后退",
+ "config-continue": "继续 →",
+ "config-page-language": "语言",
+ "config-page-welcome": "欢迎使用MediaWiki!",
+ "config-page-dbconnect": "连接到数据库",
+ "config-page-upgrade": "升级当前配置",
+ "config-page-dbsettings": "数据库设置",
+ "config-page-name": "名称",
+ "config-page-options": "选项",
+ "config-page-install": "安装",
+ "config-page-complete": "完成!",
+ "config-page-restart": "重新开始安装",
+ "config-page-readme": "自述",
+ "config-page-releasenotes": "发布说明",
+ "config-page-copying": "复制",
+ "config-page-upgradedoc": "升级",
+ "config-page-existingwiki": "现有wiki",
+ "config-help-restart": "是否要清除所有已输入且保存的数据,并重新启动安装过程吗?",
+ "config-restart": "是的,重启吧",
+ "config-welcome": "=== 环境检查 ===\n将简单检查当前环境是否适合安装MediaWiki。如果您要寻求安装过程的支持,请记得附上此信息。",
+ "config-copyright": "=== 版权和条款 ===\n\n$1\n\n本程序为自由软件;您可依据自由软件基金会所发表的GNU通用公共授权条款规定,就本程序再为发布与/或修改;无论您依据的是本授权的第二版或(您自行选择的)任一日后发行的版本。\n\n本程序是基于使用目的而加以发布,然而'''不负任何担保责任''';亦无对'''适售性'''或'''特定目的适用性'''所为的默示性担保。详情请参照GNU通用公共授权。\n\n您应已收到附随于本程序的<doclink href=\"Copying\">GNU通用公共授权的副本</doclink>;如果没有,请写信至自由软件基金会:59 Temple Place - Suite 330, Boston, Ma 02111-1307, USA,或[http://www.gnu.org/copyleft/gpl.html 在线阅读]。",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/zh-hans MediaWiki首页]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/zh-hans 用户指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents 管理员指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh-hans 常见问题解答]\n----\n* <doclink href=Readme>自述文件</doclink>\n* <doclink href=ReleaseNotes>发行说明</doclink>\n* <doclink href=Copying>协议副本</doclink>\n* <doclink href=UpgradeDoc>升级</doclink>",
+ "config-env-good": "环境检查已经完成。您可以安装MediaWiki。",
+ "config-env-bad": "环境检查已经完成。您不能安装MediaWiki。",
+ "config-env-php": "PHP $1已安装。",
+ "config-env-hhvm": "HHVM $1已安装。",
+ "config-unicode-using-intl": "使用[http://pecl.php.net/intl intl PECL扩展程序]标准化Unicode。",
+ "config-unicode-pure-php-warning": "<strong>警告:</strong>因为尚未安装 [http://pecl.php.net/intl intl PECL 扩展]以处理 Unicode 正常化,故只能退而采用运行较慢的纯 PHP 实现的方法。如果您运行着一个高流量的网站,请参阅 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode标准化]一文。",
+ "config-unicode-update-warning": "<strong>警告:</strong>Unicode正常化封装器的已安装版本使用了旧版本的[http://site.icu-project.org/ ICU项目]库。如果您需要使用Unicode,请将其[https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations 升级]。",
+ "config-no-db": "无法找到合适的数据库驱动!您需要为PHP安装数据库驱动。目前支持以下数据库{{PLURAL:$2|类型}}:$1。\n\n如果您自己编译了PHP,请通过启用数据库客户端重新配置它,例如使用 <code>./configure --with-mysqli</code>。如果您从 Debian 或 Ubuntu 安装包安装了PHP,那么您也需要安装,例如 <code>php5-mysql</code> 安装包。",
+ "config-outdated-sqlite": "<strong>警告:</strong>您已安装SQLite $1,但是它的版本低于最低要求版本$2。因此您无法选择SQLite。",
+ "config-no-fts3": "<strong>警告:</strong>已编译的SQLite不包含[//sqlite.org/fts3.html FTS3模块],后台搜索功能将不可用。",
+ "config-pcre-old": "<strong>致命错误:</strong>需要PCRE $1 或更高版本。\n您的 PHP 二进制文件与 PCRE $2 链接。\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE 详细信息]。",
+ "config-pcre-no-utf8": "<strong>致命错误:</strong>PHP的PCRE模块在编译时可能没有包含PCRE_UTF8支持。\nMediaWiki需要UTF-8支持才能正常工作。",
+ "config-memory-raised": "PHP的内存使用上限<code>memory_limit</code>为$1,自动提升到$2。",
+ "config-memory-bad": "<strong>警告:</strong>PHP的内存使用上限<code>memory_limit</code>为$1。\n该设定可能过低,并导致安装失败!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache]已安装",
+ "config-apc": "[http://www.php.net/apc APC]已安装",
+ "config-apcu": "已安装[http://www.php.net/apcu APCu]",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache]已安装",
+ "config-no-cache-apcu": "<strong>警告:</strong>找不到[http://www.php.net/apcu APCu]、[http://xcache.lighttpd.net/ XCache]或[http://www.iis.net/download/WinCacheForPhp WinCache]。\n对象缓存未启用。",
+ "config-mod-security": "<strong>警告:</strong>您的web服务器已启用[http://modsecurity.org/ mod_security]/mod_security2。它的很多常见配置可能导致MediaWiki及其他软件允许用户发布任意内容的问题。如果可能,这应当被禁用。否则,当您遭遇随机错误时,请参考[http://modsecurity.org/documentation/ mod_security 文档]或联络您的主机支持。",
+ "config-diff3-bad": "找不到GNU diff3。",
+ "config-git": "发现Git版本控制软件:<code>$1</code>",
+ "config-git-bad": "Git版本控制软件未找到。",
+ "config-imagemagick": "已找到ImageMagick:<code>$1</code>。如果你启用了上传功能,缩略图功能也将被启用。",
+ "config-gd": "已找到内建的GD图形库。如果你启用了上传功能,缩略图功能也将被启用。",
+ "config-no-scaling": "找不到GD库或ImageMagick。缩略图功能将不可用。",
+ "config-no-uri": "<strong>错误:</strong>无法确定当前的URI。\n安装已中断。",
+ "config-no-cli-uri": "<strong>警告:</strong>未指定<code>--scriptpath</code>参数,使用默认值:<code>$1</code>。",
+ "config-using-server": "使用服务器名“<nowiki>$1</nowiki>”。",
+ "config-using-uri": "使用服务器URL“<nowiki>$1$2</nowiki>”。",
+ "config-uploads-not-safe": "<strong>警告:</strong>您的默认上传目录<code>$1</code>存在允许执行任意脚本的漏洞。尽管MediaWiki会对所有已上传的文件进行安全检查,但我们仍然强烈建议您在启用上传功能前[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security 关闭该安全漏洞]。",
+ "config-no-cli-uploads-check": "<strong>警告:</strong>在CLI安装过程中,没有对您的默认上传目录(<code>$1</code>)进行执行任意脚本的漏洞检查。",
+ "config-brokenlibxml": "您的系统安装的PHP和libxml2版本组合存在故障,并可能在MediaWiki和其他web应用程序中造成隐藏的数据损坏。请将libxml2升级到2.7.3或以上([https://bugs.php.net/bug.php?id=45996 PHP的故障报告])。安装已中断。",
+ "config-suhosin-max-value-length": "Suhosin已经安装并将GET请求的参数长度限制在$1字节。MediaWiki的ResourceLoader部件可以在此限制下正常工作,但其性能会被降低。如果可能,请在<code>php.ini</code>中将<code>suhosin.get.max_value_length</code>设为1024或更高值,并在LocalSettings.php中将<code>$wgResourceLoaderMaxQueryLength</code>设为同一值。",
+ "config-using-32bit": "<strong>警告:</strong>您的系统似乎是32位系统。我们[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit 不建议您]使用32位系统。",
+ "config-db-type": "数据库类型:",
+ "config-db-host": "数据库主机:",
+ "config-db-host-help": "如果您的数据库在别的服务器上,请在这里输入它的域名或IP地址。\n\n如果您在使用共享网站套餐,您的网站商应该已在他们的控制面板中给您数据库信息了。\n\n如果您在Windows中安装并且使用MySQL,“localhost”可能无效。如果确实无效,请输入“127.0.0.1”作为IP地址。\n\n如果您在使用PostgreSQL,并且要用Unix socket来连接,请留空。",
+ "config-db-host-oracle": "数据库透明网络底层(TNS):",
+ "config-db-host-oracle-help": "请输入合法的[http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm 本地连接名],并确保tnsnames.ora文件对本安装程序可见。<br />如果您使用的客户端库为10g或更新的版本,您还可以使用[http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm 简单连接名方法](easy connect naming method)。",
+ "config-db-wiki-settings": "标识本wiki",
+ "config-db-name": "数据库名称:",
+ "config-db-name-help": "请输入一个可以标识您的wiki的名称。请勿使用空格。\n\n如果您正在使用共享web主机,您的主机提供商或会给您指定一个数据库名称,或会让您通过控制面板创建数据库。",
+ "config-db-name-oracle": "数据库模式:",
+ "config-db-account-oracle-warn": "现有三种已支持方案可以将Oracle设置为后端数据库:\n\n如果您希望在安装过程中创建数据库帐户,请为安装程序提供具有SYSDBA角色的数据库帐户,并为web访问帐户指定所需身份证明;否则您可以手动创建web访问的账户并仅须提供该帐户(确保帐户已有创建方案对象(schema object)的所需权限);或提供两个不同的帐户,其一具有创建权限,另一则被限制为web访问。\n\n具有所需权限账户的创建脚本存放于本程序的“maintenance/oracle/”目录下。请注意,使用受限制的帐户将禁用默认帐户的所有维护性功能。",
+ "config-db-install-account": "用于安装的用户帐号",
+ "config-db-username": "数据库用户名:",
+ "config-db-password": "数据库密码:",
+ "config-db-install-username": "请输入在安装过程中用于连接数据库的用户名。请勿输入MediaWiki帐号的用户名,请输入您数据库的用户名。",
+ "config-db-install-password": "请输入在安装过程中用于连接数据库的密码。请勿输入MediaWiki帐号的密码,请输入您数据库的密码。",
+ "config-db-install-help": "请输入在安装过程中用于连接数据库的用户名和密码。",
+ "config-db-account-lock": "在普通操作中使用相同的用户名和密码",
+ "config-db-wiki-account": "用于普通操作的用户帐号",
+ "config-db-wiki-help": "输入在普通的wiki操作中(安装完成后)将用于连接数据库的用户名和密码。如果该帐号并不存在,而安装帐号具有足够的权限,该用户帐号会被自动创建,并被赋予足以运行此wiki的最低权限。",
+ "config-db-prefix": "数据库表前缀:",
+ "config-db-prefix-help": "如果您需要在多个wiki之间(或在MediaWiki与其他web应用程序之间)共享一个数据库,您可以通过添加前缀的方式来避免出现表名称的冲突。请勿使用空格。\n\n此字段通常可留空。",
+ "config-mysql-old": "需要MySQL $1或更新的版本,您的版本为$2。",
+ "config-db-port": "数据库端口:",
+ "config-db-schema": "MediaWiki的数据库模式",
+ "config-db-schema-help": "此数据库模式通常是正确的,请在有明确需求时才改动之。",
+ "config-pg-test-error": "无法连接到数据库<strong>$1</strong>:$2",
+ "config-sqlite-dir": "SQLite数据目录:",
+ "config-sqlite-dir-help": "SQLite会将所有的数据存储于单一文件中。\n\n您所提供的目录必须在安装过程中对网页服务器可写。\n\n该目录<strong>不应</strong>允许通过web访问,因此我们不会将数据文件和PHP文件放在一起。\n\n安装程序在创建数据文件时,亦会在相同目录下创建<code>.htaccess</code>以控制权限。假若此等控制失效,则可能会将您的数据文件暴露于公共空间,让他人可以获取用户数据(电子邮件地址、杂凑后的密码)、被删除的版本以及其他在wiki上被限制访问的数据。\n\n请考虑将数据库统一放置在某处,如<code>/var/lib/mediawiki/yourwiki</code>下。",
+ "config-oracle-def-ts": "默认表空间:",
+ "config-oracle-temp-ts": "临时表空间:",
+ "config-type-mysql": "MySQL(或兼容程序)",
+ "config-type-mssql": "微软SQL服务器",
+ "config-support-info": "MediaWiki支持以下数据库系统:\n\n$1\n\n如果您在下面列出的数据库系统中没有找到您希望使用的系统,请根据上方链向的指引启用支持。",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL]是MediaWiki的首选数据库,对它的支持最为完备。MediaWiki也可以在[{{int:version-db-mariadb-url}} MariaDB]和[{{int:version-db-percona-url}} Percona Server]下工作,它们与MySQL兼容。([http://www.php.net/manual/en/mysql.installation.php 如何将对MySQL的支持编译进PHP中])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL]是一种流行的开源数据库系统,可作为MySQL的替代([http://www.php.net/manual/en/pgsql.installation.php 如何将对PostgreSQL的支持编译进PHP中])",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite]是一种轻量级的数据库系统,能被良好地支持。([http://www.php.net/manual/en/pdo.installation.php 如何将对SQLite的支持编译进PHP中],须使用PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle]是一种商用企业级的数据库。([http://www.php.net/manual/en/oci8.installation.php 如何将对OCI8的支持编译进PHP中])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server]是一个适用于Windows的商业性企业数据库。([http://www.php.net/manual/en/sqlsrv.installation.php 如何编译带有SQLSRV支持的PHP])",
+ "config-header-mysql": "MySQL设置",
+ "config-header-postgres": "PostgreSQL设置",
+ "config-header-sqlite": "SQLite设置",
+ "config-header-oracle": "Oracle设置",
+ "config-header-mssql": "Microsoft SQL Server设置",
+ "config-invalid-db-type": "无效的数据库类型",
+ "config-missing-db-name": "您必须为“{{int:config-db-name}}”输入一个值。",
+ "config-missing-db-host": "您必须为“{{int:config-db-host}}”输入一个值。",
+ "config-missing-db-server-oracle": "您必须为“{{int:config-db-host-oracle}}”输入一个值。",
+ "config-invalid-db-server-oracle": "无效的数据库TNS“$1”。请使用“TNS 名称”或者一个“轻松连接”字符串([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle 命名方法])",
+ "config-invalid-db-name": "无效的数据库名称“$1”。请只使用ASCII字母(a-z、A-Z)、数字(0-9)、下划线(_)和连字号(-)。",
+ "config-invalid-db-prefix": "无效的数据库前缀“$1”。请只使用ASCII字母(a-z、A-Z)、数字(0-9)、下划线(_)和连字号(-)。",
+ "config-connection-error": "$1。\n\n请检查下列的主机、用户名和密码设置后重试。",
+ "config-invalid-schema": "无效的MediaWiki数据库模式“$1”。请只使用ASCII字母(a-z、A-Z)、数字(0-9)和下划线(_)。",
+ "config-db-sys-create-oracle": "安装程序仅支持使用SYSDBA帐户创建新帐户。",
+ "config-db-sys-user-exists-oracle": "用户帐户“$1”已经存在。SYSDBA仅可用于创建新帐户!",
+ "config-postgres-old": "需要PostgreSQL $1或更新的版本,您的版本为$2。",
+ "config-mssql-old": "需要 Microsoft SQL Server $1 或者更高版本。您的版本是 $2。",
+ "config-sqlite-name-help": "请为您的wiki指定一个用于标识的名称。请勿使用空格或连字号,该名称将被用作SQLite的数据文件名。",
+ "config-sqlite-parent-unwritable-group": "由于父目录<code><nowiki>$2</nowiki></code>对网页服务器不可写,无法创建数据目录<code><nowiki>$1</nowiki></code>。\n\n安装程序已确定您网页服务器所使用的用户。请将<code><nowiki>$3</nowiki></code>目录设为对该用户可写以继续安装过程。在Unix/Linux系统中,您可以逐行输入下列命令:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "由于父目录<code><nowiki>$2</nowiki></code>对网页服务器不可写,无法创建数据目录<code><nowiki>$1</nowiki></code>。\n\n安装程序无法确定您网页服务器所使用的用户。请将<code><nowiki>$3</nowiki></code>目录设为全局可写(对所有用户)以继续安装过程。在Unix/Linux系统中,您可以逐行输入下列命令:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "创建数据目录“$1”时发生错误。请检查路径后重试。",
+ "config-sqlite-dir-unwritable": "无法写入目录“$1”。请修改该目录的权限,使其对网页服务器可写后重试。",
+ "config-sqlite-connection-error": "$1。\n\n请检查下列的数据目录和数据库名称后重试。",
+ "config-sqlite-readonly": "文件<code>$1</code>不可写。",
+ "config-sqlite-cant-create-db": "无法创建数据文件<code>$1</code>。",
+ "config-sqlite-fts3-downgrade": "PHP缺少FTS3支持,正在降级数据表",
+ "config-can-upgrade": "在数据库中发现了MediaWiki的数据表。要将它们升级至MediaWiki $1,请点击<strong>继续</strong>。",
+ "config-upgrade-done": "升级完成。\n\n现在您可以[$1 开始使用您的wiki]了。\n\n如果您需要重新生成<code>LocalSettings.php</code>文件,请点击下面的按钮。除非您的wiki出现了问题,我们<strong>不推荐</strong>您执行此操作。",
+ "config-upgrade-done-no-regenerate": "升级完成。\n\n现在您可以[$1 开始使用您的wiki]了。",
+ "config-regenerate": "重新生成LocalSettings.php →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code>语句执行失败!",
+ "config-unknown-collation": "<strong>警告:</strong>数据库使用了无法识别的整理。",
+ "config-db-web-account": "供网页访问使用的数据库帐号",
+ "config-db-web-help": "请指定在wiki执行普通操作时,网页服务器用于连接数据库服务器的用户名和密码。",
+ "config-db-web-account-same": "使用和安装程序相同的帐号",
+ "config-db-web-create": "如果帐号不存在,则自动创建",
+ "config-db-web-no-create-privs": "您指定给安装程序的帐号缺少创建帐号的权限,因此您指定的帐号必须已经存在。",
+ "config-mysql-engine": "存储引擎:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>警告:</strong>您选择了MyISAM作为MySQL的存储引擎,MediaWiki并不推荐您这么做,因为:\n* 它仅能通过表锁定来勉强支持并发\n* 与其他引擎相比,它更容易被损坏\n* MediaWiki代码库并不总会去处理MyISAM\n\n如果您的MySQL程序支持InnoDB,我们高度推荐您使用该引擎替代MyISAM。\n如果您的MySQL程序不支持InnoDB,请考虑升级。",
+ "config-mysql-only-myisam-dep": "<strong>警告:</strong>MyISAM是MySQL在此机器上唯一可用的存储引擎,但它不适合用于MediaWiki,因为:\n*因为表级锁定,它几乎不支持并发。\n*它相比其他引擎更容易损坏。\n*MediaWiki代码不能总是按照预期操作MyISAM。\n\n你的MySQL不支持InnoDB,是时候升级了。",
+ "config-mysql-engine-help": "<strong>InnoDB</strong>通常是最佳选项,因为它对并发操作有着良好的支持。\n\n<strong>MyISAM</strong>在单用户或只读环境下可能会有更快的性能表现。但MyISAM数据库出错的概率一般要大于InnoDB数据库。",
+ "config-mysql-charset": "数据库字符集:",
+ "config-mysql-binary": "二进制",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "在<strong>二进制模式</strong>下,MediaWiki会将UTF-8编码的文本存于数据库的二进制字段中。相对于MySQL的UTF-8模式,这种方法效率更高,并允许您使用全范围的Unicode字符。\n\n在<strong>UTF-8模式</strong>下,MySQL将知道您数据使用的字符集,并能适当地提供和转换内容。但这样做您将无法在数据库中存储[https://zh.wikipedia.org/wiki/基本多文种平面 基本多文种平面]以外的字符。",
+ "config-mssql-auth": "身份验证类型:",
+ "config-mssql-install-auth": "选择安装过程中链接数据库时将采用的身份验证方式。如果您选择“{{int:config-mssql-windowsauth}}”,将使用运行服务器的用户的身份凭据。",
+ "config-mssql-web-auth": "选择Web服务器在通常wiki操作期间用来连接数据库服务器的身份验证方式。如果您选择“{{int:config-mssql-windowsauth}}”,将使用运行Web服务器的用户的凭据。",
+ "config-mssql-sqlauth": "SQL Server 身份验证",
+ "config-mssql-windowsauth": "Windows 身份验证",
+ "config-site-name": "wiki的名称:",
+ "config-site-name-help": "填入的内容会出现在浏览器的标题栏以及其他多处位置中。",
+ "config-site-name-blank": "输入网站的名称。",
+ "config-project-namespace": "项目名字空间:",
+ "config-ns-generic": "项目",
+ "config-ns-site-name": "与wiki名称相同:$1",
+ "config-ns-other": "其他(自定义)",
+ "config-ns-other-default": "我的Wiki",
+ "config-project-namespace-help": "依循维基百科形成的惯例,许多wiki将他们的方针页面存放在与内容页面不同的'''项目名字空间'''中。所有位于该名字空间下的页面标题都会被冠以固定的前缀,您可以在此处指定这一前缀。通常,这一前缀应与wiki的命名保持一致,但请勿在其中使用标点符号,如“#”或“:”。",
+ "config-ns-invalid": "指定的名字空间“<nowiki>$1</nowiki>”无效,请为项目名字空间指定其他名称。",
+ "config-ns-conflict": "指定的名字空间“<nowiki>$1</nowiki>”与默认的MediaWiki名字空间冲突。请指定一个不同的项目名字空间。",
+ "config-admin-box": "管理员帐号",
+ "config-admin-name": "您的用户名:",
+ "config-admin-password": "密码:",
+ "config-admin-password-confirm": "确认密码:",
+ "config-admin-help": "在此输入您想使用的用户名,例如“乔帮主”。您将使用该名称登录本wiki。",
+ "config-admin-name-blank": "输入管理员的用户名。",
+ "config-admin-name-invalid": "指定的用户名“<nowiki>$1</nowiki>”无效,请指定其他用户名。",
+ "config-admin-password-blank": "输入管理员帐号的密码。",
+ "config-admin-password-mismatch": "两次输入的密码并不相同。",
+ "config-admin-email": "电子邮件地址:",
+ "config-admin-email-help": "在这里输入电子邮件地址可以允许您收到来自本wiki其他用户的电子邮件,重置您的密码和收到您的监视列表中的页面的更改通知。您可以将该字段留空。",
+ "config-admin-error-user": "在创建用户名为“<nowiki>$1</nowiki>”的管理员帐号时发生内部错误。",
+ "config-admin-error-password": "在为管理员“<nowiki>$1</nowiki>”设置密码时发生内部错误:<pre>$2</pre>",
+ "config-admin-error-bademail": "您输入了无效的电子邮件地址。",
+ "config-subscribe": "订阅[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce 发行公告邮件列表]。",
+ "config-subscribe-help": "此低流量的邮件列表仅用于发行公告,其中包括重要安全公告。请订阅该列表以便在新的版本推出时升级您的MediaWiki。",
+ "config-subscribe-noemail": "您选择了订阅发行公告邮件列表,但没有提供电子邮件地址。请提供一个电子邮件地址以订阅邮件列表。",
+ "config-pingback": "与MediaWiki开发人员分享有关此安装程序的数据。",
+ "config-pingback-help": "如果您选择此选项,MediaWiki将定期与https://www.mediawiki.org通信,传输与此MediaWiki实例相关的基础数据。此数据包括例如系统类型、PHP版本和选择的数据库后端。维基媒体基金会与MediaWiki开发人员分享此数据,以帮助引导将来的开发计划。以下数据将为您的系统发送:\n<pre>$1</pre>",
+ "config-almost-done": "您几乎已经完成了!现在您可以跳过剩下的配置流程并立即安装wiki。",
+ "config-optional-continue": "多问我一些问题吧。",
+ "config-optional-skip": "我已经不耐烦了,赶紧安装我的wiki。",
+ "config-profile": "用户权限配置:",
+ "config-profile-wiki": "开放wiki",
+ "config-profile-no-anon": "需要注册帐号",
+ "config-profile-fishbowl": "编辑受限",
+ "config-profile-private": "非公开wiki",
+ "config-profile-help": "如果您允许尽量多的人编写wiki,网站上的内容会更加丰富。在MediaWiki中,您可以轻松地审查最近更改,并轻易回退掉新手或破坏者造成的损害。\n\n然而,许多人觉得让MediaWiki存在多种角色将更加好用;同时,要说服所有人都愿以wiki的方式作贡献并非一件易事。因此,您可以有以下选择:\n\n<strong>{{int:config-profile-wiki}}</strong>模式允许包括未登录用户在内的所有人编辑。<strong>{{int:config-profile-no-anon}}</strong>的wiki需要额外的注册流程,这有可能会阻碍随意贡献者。\n\n<strong>{{int:config-profile-fishbowl}}</strong>方案只允许获批准的用户编辑,但对公众开放页面浏览(包括历史记录)。<strong>{{int:config-profile-private}}</strong>则只允许获批准的用户浏览、编辑页面。\n\n安装完成后,您还可以对用户权限进行更多、更复杂的配置,参见[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights 相关的使用手册]。",
+ "config-license": "版权和许可证:",
+ "config-license-none": "页脚无许可证",
+ "config-license-cc-by-sa": "知识共享署名-相同方式共享",
+ "config-license-cc-by": "知识共享署名",
+ "config-license-cc-by-nc-sa": "知识共享署名-非商业性使用-相同方式共享",
+ "config-license-cc-0": "知识共享Zero(公有领域)",
+ "config-license-gfdl": "GNU自由文档许可证1.3或更高版本",
+ "config-license-pd": "公有领域",
+ "config-license-cc-choose": "选择自定义的知识共享许可证",
+ "config-license-help": "许多公共wiki将所有用户贡献置于[http://freedomdefined.org/Definition 自由许可证]之下。这有助于构建社区的主人翁意识,并鼓励长期贡献。对于非公共wiki或公司wiki,这并非必要条件。\n\n如果您希望使用来自维基百科的内容,并希望维基百科能接受复制自您的wiki的内容,您应当选择<strong>{{int:config-license-cc-by-sa}}</strong>。\n\nGNU自由文档许可证是维基百科曾经使用过的许可证,并迄今有效。然而,该许可证难以理解,并会增加重用内容的难度。",
+ "config-email-settings": "电子邮件设置",
+ "config-enable-email": "启用出站电子邮件",
+ "config-enable-email-help": "如果您希望使用电子邮件功能,请正确配置[http://www.php.net/manual/en/mail.configuration.php PHP的邮件设定]。如果您不需要任何电子邮件功能,请在此处禁用它。",
+ "config-email-user": "启用用户到用户的电子邮件",
+ "config-email-user-help": "允许所有用户互发邮件,假若他们启用了该功能。",
+ "config-email-usertalk": "启用用户讨论页面通知",
+ "config-email-usertalk-help": "允许用户收到用户讨论页被修改的通知,假若他们启用了该功能。",
+ "config-email-watchlist": "启用监视列表通知",
+ "config-email-watchlist-help": "允许用户收到与其监视列表有关的通知,假若他们启用了该功能。",
+ "config-email-auth": "启用电子邮件身份验证",
+ "config-email-auth-help": "如果启用此选项,在用户设置或修改电子邮件地址时,就会收到一封邮件,内含确认电子地址的链接。只有经过身份验证的电子邮件地址,才能收到来自其他用户的电子邮件,或任何修改通知的邮件。<strong>建议</strong>公开wiki启用本选项,以防对电子邮件功能的滥用。",
+ "config-email-sender": "回复电子邮件地址:",
+ "config-email-sender-help": "输入要用来发送出站电子邮件的地址,该地址将会收到被拒收的邮件。许多邮件服务器要求域名部分必须有效。",
+ "config-upload-settings": "图像和文件上传",
+ "config-upload-enable": "启用文件上传",
+ "config-upload-help": "文件上传可能会将您的服务器暴露在安全风险下。有关更多的信息,请参阅手册的[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security 安全部分]。\n\n要启用文件上传,请先将MediaWiki根目录下的<code>images</code>子目录更改为对web服务器可写,然后再启用此选项。",
+ "config-upload-deleted": "已删除文件的目录:",
+ "config-upload-deleted-help": "指定用于存放被删除文件的目录。理想情况下,该目录不应能通过web访问。",
+ "config-logo": "标志URL:",
+ "config-logo-help": "在MediaWiki的默认外观中,左侧栏菜单之上有一块135x160像素的标志区。请上传一幅相应大小的图像,并在此输入URL。\n\n您可以用<code>$wgStylePath</code>或<code>$wgScriptPath</code>来表示相对于这些位置的路径。\n\n如果您不希望使用标志,请将本处留空。",
+ "config-instantcommons": "启用即时共享资源",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons 即时共享资源]可以让wiki使用来自[https://commons.wikimedia.org/ 维基共享资源]网站的图像、音频和其他媒体文件。要启用该功能,MediaWiki必须能够访问互联网。\n\n有关此功能的详细信息,包括如何将其他wiki网站设为具有类似共享功能的方法,请参考[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos 手册]。",
+ "config-cc-error": "知识共享许可证挑选器无法找到结果,请手动输入许可证的名称。",
+ "config-cc-again": "重新挑选……",
+ "config-cc-not-chosen": "选择您想要的知识共享许可协议并单击“proceed”。",
+ "config-advanced-settings": "高级设置",
+ "config-cache-options": "对象缓存设置:",
+ "config-cache-help": "对象缓存可通过缓存频繁使用的数据来提高MediaWiki的速度。高度推荐中到大型的网站启用该功能,小型网站亦能从其中受益。",
+ "config-cache-none": "无缓存(不影响功能,但对较大型的wiki网站会有速度影响)",
+ "config-cache-accel": "PHP对象缓存(APC、APCu、XCache或WinCache)",
+ "config-cache-memcached": "使用Memcached(需要另外安装并配置)",
+ "config-memcached-servers": "Memcached服务器:",
+ "config-memcached-help": "用于Memcached的IP地址列表。请保持每行一条,并指定要使用的端口。例如:\n127.0.0.1:11211\n192.168.1.25:1234",
+ "config-memcache-needservers": "您选择了Memcached作为您的缓存,但并未指定任何服务器。",
+ "config-memcache-badip": "您为Memcached输入了无效的IP地址:$1。",
+ "config-memcache-noport": "您没有指定Memcached服务器的端口:$1。如果您不清楚端口是多少,默认值为11211。",
+ "config-memcache-badport": "Memcached的端口号应该在$1到$2之间。",
+ "config-extensions": "扩展程序",
+ "config-extensions-help": "已在您的<code>./extensions</code>目录中发现下列扩展。\n\n您可能要对它们进行额外的配置,但您现在可以启用它们。",
+ "config-skins": "皮肤",
+ "config-skins-help": "在您的<code>./skins</code>目录中检测到上面列出的皮肤。您必须选择至少一个,并选择一个默认值。",
+ "config-skins-use-as-default": "使用此皮肤作为默认皮肤",
+ "config-skins-missing": "没有找到皮肤;MediaWiki将使用备选皮肤直到您自行安装一个后。",
+ "config-skins-must-enable-some": "您必须选择至少一个皮肤以起用。",
+ "config-skins-must-enable-default": "默认选择的皮肤必须启用。",
+ "config-install-alreadydone": "<strong>警告:</strong>您似乎已经安装了MediaWiki,并试图重新安装它。请前往下一个页面。",
+ "config-install-begin": "点击“{{int:config-continue}}”后,您将开始安装MediaWiki。如果您还想对配置作一些修改,请点击“{{int:config-back}}”。",
+ "config-install-step-done": "完成",
+ "config-install-step-failed": "失败",
+ "config-install-extensions": "正在包含扩展程序",
+ "config-install-database": "正在配置数据库",
+ "config-install-schema": "创建架构",
+ "config-install-pg-schema-not-exist": "PostgreSQL 架构不存在。",
+ "config-install-pg-schema-failed": "创建数据表失败。请确保用户“$1”拥有写入模式“$2”的权限。",
+ "config-install-pg-commit": "正在提交更改",
+ "config-install-pg-plpgsql": "正在检查PL/pgSQL语言",
+ "config-pg-no-plpgsql": "您需要为数据库$1安装PL/pgSQL语言",
+ "config-pg-no-create-privs": "为安装程序指定的帐号缺少创建帐号的权限。",
+ "config-pg-not-in-role": "您指定为web用户的帐户已经存在。\n您给本程序指定的帐户不是超级用户,也不是web用户角色的成员,所以它不能创建web用户所拥有的对象。\n\nMediaWiki当前需要使用由web用户所有的表。请指定另一个web帐户名称,或点击“后退”并指定具有适当权限的安装用户。",
+ "config-install-user": "正在创建数据库用户",
+ "config-install-user-alreadyexists": "用户“$1”已存在",
+ "config-install-user-create-failed": "创建用户“$1”失败:$2",
+ "config-install-user-grant-failed": "授予用户“$1”权限失败:$2",
+ "config-install-user-missing": "指定的用户“$1”不存在。",
+ "config-install-user-missing-create": "指定的用户“$1”不存在。如果您想要创建一名,请点选“创建帐户”下面的复选框。",
+ "config-install-tables": "正在创建数据表",
+ "config-install-tables-exist": "<strong>警告:</strong>MediaWiki的数据表似乎已经存在,跳过创建。",
+ "config-install-tables-failed": "<strong>错误:</strong>创建数据表出错,下为错误信息:$1",
+ "config-install-interwiki": "正在填充默认的跨wiki数据表",
+ "config-install-interwiki-list": "无法读取文件<code>interwiki.list</code>。",
+ "config-install-interwiki-exists": "<strong>警告:</strong>跨wiki数据表似乎已有内容,跳过默认列表。",
+ "config-install-stats": "初始化统计",
+ "config-install-keys": "生成密钥中",
+ "config-insecure-keys": "<strong>警告:</strong>在安装过程中生成的{{PLURAL:$2|安全密钥}}($1){{PLURAL:$2|并}}不一定安全。请考虑手动更改{{PLURAL:$2|它|它们}}。",
+ "config-install-updates": "防止运行不需要的更新",
+ "config-install-updates-failed": "<strong>错误:</strong>表格中插入更新关键字失败并出现如下错误:$1",
+ "config-install-sysop": "正在创建管理员用户帐号",
+ "config-install-subscribe-fail": "无法订阅mediawiki-announce:$1",
+ "config-install-subscribe-notpossible": "没有安装cURL,<code>allow_url_fopen</code>也不可用。",
+ "config-install-mainpage": "正在创建显示默认内容的首页",
+ "config-install-mainpage-exists": "首页已存在,正在跳过",
+ "config-install-extension-tables": "正在创建已启用扩展程序表",
+ "config-install-mainpage-failed": "无法插入首页:$1",
+ "config-install-done": "<strong>恭喜!</strong>\n您已经安装了MediaWiki。\n\n安装程序已经生成了<code>LocalSettings.php</code>文件,其中包含了您所有的配置。\n\n您需要下载该文件,并将其放在您wiki的根目录(index.php的同级目录)中。稍后下载将自动开始。\n\n如果浏览器没有提示您下载,或者您取消了下载,您可以点击下面的链接重新开始下载:\n\n$3\n\n<strong>注意:</strong>如果您现在不完成本步骤,而是没有下载便退出了安装过程,此后您将无法获得自动生成的配置文件。\n\n当本步骤完成后,您可以<strong>[$2 进入您的wiki]</strong>。",
+ "config-install-done-path": "<strong>祝贺!</strong>您已经安装了MediaWiki。\n\n安装程序已经生成了<code>LocalSettings.php</code>文件。它包含您所有的配置。\n\n您需要下载该文件,并将其放在<code>$4</code>。下载应已自动开始。\n\n如果没有提供下载,或者您取消了下载,您可以点击下面的链接重新开始下载:\n\n$3\n\n<strong>注意:</strong>如果您现在不完成本步骤,而是没有下载便退出了安装过程,此后您将无法获得自动生成的配置文件。\n\n当本步骤完成后,您可以<strong>[$2 进入您的wiki]</strong>。",
+ "config-download-localsettings": "下载<code>LocalSettings.php</code>",
+ "config-help": "帮助",
+ "config-help-tooltip": "单击展开",
+ "config-nofile": "找不到文件“$1”。它是否已被删除?",
+ "config-extension-link": "您是否知道您的wiki支持[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions 扩展]?\n\n您可以浏览[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category 扩展分类]或[https://www.mediawiki.org/wiki/Extension_Matrix 扩展矩阵]以查看完整的扩展列表。",
+ "config-skins-screenshots": "$1(截图:$2)",
+ "config-screenshot": "截图",
+ "mainpagetext": "<strong>已安装MediaWiki。</strong>",
+ "mainpagedocfooter": "请查阅[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 用户指导]以获取使用本wiki软件的信息!\n\n== 入门 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings MediaWiki配置设置列表]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh-hans MediaWiki常见问题]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki发布邮件列表]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources 本地化MediaWiki到您的语言]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 了解如何在您的wiki上打击破坏]"
+}
diff --git a/www/wiki/includes/installer/i18n/zh-hant.json b/www/wiki/includes/installer/i18n/zh-hant.json
new file mode 100644
index 00000000..ab365ec2
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/zh-hant.json
@@ -0,0 +1,330 @@
+{
+ "@metadata": {
+ "authors": [
+ "Anthony Fok",
+ "Hzy980512",
+ "Justincheng12345",
+ "Liangent",
+ "Mark85296341",
+ "Simon Shek",
+ "아라",
+ "Liuxinyu970226",
+ "Xiaomingyan",
+ "Cwlin0416",
+ "S8321414",
+ "LNDDYL",
+ "NigelSoft",
+ "Macofe",
+ "Reke",
+ "Suchichi02",
+ "Winstonyin",
+ "Wehwei",
+ "Wwycheuk"
+ ]
+ },
+ "config-desc": "MediaWiki 安裝程式",
+ "config-title": "MediaWiki $1 安裝",
+ "config-information": "資訊",
+ "config-localsettings-upgrade": "已偵測到 <code>LocalSettings.php</code> 檔案。\n要升級目前安裝的版本,請在下方輸入框中輸入 <code>$wgUpgradeKey</code> 的值。\n您可以從 <code>LocalSettings.php</code> 檔案中找到。",
+ "config-localsettings-cli-upgrade": "已偵測到 <code>LocalSettings.php</code> 檔案。\n要升級目前安裝的版本,請執行 <code>update.php</code>。",
+ "config-localsettings-key": "升級金鑰:",
+ "config-localsettings-badkey": "你提供的升級金鑰不正確。",
+ "config-upgrade-key-missing": "已偵測到先前安裝的 MediaWiki。\n要升級目前安裝的版本,請將下列文字附加到 <code>LocalSettings.php</code> 的檔案最下方:\n\n$1",
+ "config-localsettings-incomplete": "目前的 <code>LocalSettings.php</code> 檔案不完整。\n未設定參數 $1。\n請將此參數設定至 <code>LocalSettings.php</code> 中,並點選 \"{{int:Config-continue}}\"。",
+ "config-localsettings-connection-error": "使用 <code>LocalSettings.php</code> 中所指定的資料庫設定連線發生錯誤。 請修復相關設定並再試一次。\n\n$1",
+ "config-session-error": "開始連線階段錯誤:$1",
+ "config-session-expired": "您的連線階段已過期。\n目前設定的工作階段期限為 $1。\n您可以在 php.ini 設定檔中設定 <code>session.gc_maxlifetime</code> 的參數來延長此期限。\n重新開始安裝程序。",
+ "config-no-session": "您的連線階段資料遺失!\n請檢查 php.ini 設定檔並確認 <code>session.save_path</code> 所設定的目錄是否合適。",
+ "config-your-language": "您的語言:",
+ "config-your-language-help": "請選擇接下來安裝程序中要使用的語言。",
+ "config-wiki-language": "Wiki 語言:",
+ "config-wiki-language-help": "選擇將要安裝的 Wiki 多數情況主要使用的語言。",
+ "config-back": "← 返回",
+ "config-continue": "繼續 →",
+ "config-page-language": "語言",
+ "config-page-welcome": "歡迎使用 MediaWiki!",
+ "config-page-dbconnect": "連線到資料庫",
+ "config-page-upgrade": "升級目前安裝的版本",
+ "config-page-dbsettings": "資料庫設定",
+ "config-page-name": "名稱",
+ "config-page-options": "選項",
+ "config-page-install": "安裝",
+ "config-page-complete": "完成!",
+ "config-page-restart": "重新安裝",
+ "config-page-readme": "讀我說明",
+ "config-page-releasenotes": "發佈說明",
+ "config-page-copying": "複製",
+ "config-page-upgradedoc": "升級",
+ "config-page-existingwiki": "現有 Wiki",
+ "config-help-restart": "是否要清除所有已輸入且儲存的資料,並重新開始安裝程序嗎?",
+ "config-restart": "是的,重新開始",
+ "config-welcome": "=== 環境檢查 ===\n現在會做基本的檢查,檢查環境是否符合 MediaWiki 安裝所需。\n若您要尋求如何完成安裝的協助,請記得提供以下訊息。",
+ "config-copyright": "=== 版權聲明與授權條款 ===\n\n$1\n\n本程式為自由軟體;您可依據自由軟體基金會所發表的 GNU 通用公共授權條款規定,將本程式重新發佈與/或修改;無論您依據的是本授權條款的第二版或 (您可自行選擇) 之後的任何版本。\n\n本程式發佈的目的是希望可以提供幫助,但 <strong>不負任何擔保責任</strong>;亦無隱含對 <strong>適售性</strong> 或 <strong>特定用途的適用性</strong> 的情形擔保。詳情請參照 GNU 通用公共授權。\n\n您應已隨本程式收到 <doclink href=\"Copying\">GNU 通用公共授權條款的副本</doclink>;如果沒有,請信件通知自由軟體基金會,51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA,或 [http://www.gnu.org/copyleft/gpl.html 線上閱讀]。",
+ "config-sidebar": "* [https://www.mediawiki.org/wiki/MediaWiki/zh-hant MediaWiki 首頁]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents/zh 使用者指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents/zh 管理員指南]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh 常見問題集]\n----\n* <doclink href=Readme>讀我說明</doclink>\n* <doclink href=ReleaseNotes>發行說明</doclink>\n* <doclink href=Copying>版權聲明</doclink>\n* <doclink href=UpgradeDoc>升級</doclink>",
+ "config-env-good": "環境檢查已完成。\n您可以安裝 MediaWiki。",
+ "config-env-bad": "環境檢查已完成。\n您無法安裝 MediaWiki。",
+ "config-env-php": "PHP $1 已安裝。",
+ "config-env-hhvm": "HHVM $1 已安裝。",
+ "config-unicode-using-intl": "使用 [http://pecl.php.net/intl intl PECL 擴充套件] 做 Unicode 正規化。",
+ "config-unicode-pure-php-warning": "<strong>警告:</strong> 無法使用 [http://pecl.php.net/intl intl PECL 擴充套件] 處理 Unicode 正規化,故回退使用純 PHP 實作的正規化程式,此方式處理速度較緩慢。\n\n如果您的網站瀏覽人次很高,您應先閱讀 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations/zh Unicode 正規化]。",
+ "config-unicode-update-warning": "<strong>警告</strong>:目前安裝的 Unicode 正規化包裝程式使用了舊版 [http://site.icu-project.org/ ICU 計劃] 的程式庫。\n若您需要使用 Unicode,您應先進行 [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations 升級]。",
+ "config-no-db": "找不到合適的資料庫驅動程式!您需要安裝 PHP 資料庫驅動程式。\n目前支援以下{{PLURAL:$2|類型|類型}}的資料庫: $1 。\n\n如果您是自行編譯 PHP,您必須重新設定並開啟資料庫客戶端,例:使用 <code>./configure --with-mysqli</code> 指令參數。\n如果您是使用 Debian 或 Ubuntu 的套件安裝 PHP ,您則需要額外安裝,例:<code>php5-mysql</code> 套件。",
+ "config-outdated-sqlite": "<strong>警告:</strong>您已安裝 SQLite $1,但是它的版本低於最低需求版本 $2。 因此您無法使用 SQLite。",
+ "config-no-fts3": "<strong>警告:</strong> SQLite 編譯時未包含 [//sqlite.org/fts3.html FTS3 模組],後台搜尋功能將無法使用。",
+ "config-pcre-old": "<strong>嚴重:</strong> 需要使用 PCRE $1 或更新的版本。\n您的 PHP 執行檔使用的是 PCRE $2。\n[https://www.mediawiki.org/wiki/Manual:Errors_and_symptoms/PCRE 詳細資訊]。",
+ "config-pcre-no-utf8": "<strong>嚴重:</strong> PHP 的 PCRE 模組在編譯時未包含 PCRE_UTF8 支援。\nMediaWiki 需要支援 UTF-8 才可正常運作。",
+ "config-memory-raised": "PHP 的記憶體使用上限 <code>memory_limit</code> 目前為 $1,自動提高到 $2。",
+ "config-memory-bad": "<strong>警告:</strong>PHP 的記憶體使用上限 <code>memory_limit</code> 為 $1。\n該設定值可能過低。\n這可能導致後續的安裝失敗!",
+ "config-xcache": "[http://xcache.lighttpd.net/ XCache] 已安裝",
+ "config-apc": "[http://www.php.net/apc APC] 已安裝",
+ "config-apcu": "已安裝[http://www.php.net/apcu APCu]",
+ "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] 已安裝",
+ "config-no-cache-apcu": "<strong>警告:</strong>找不到[http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache]或[http://www.iis.net/download/WinCacheForPhp WinCache]。未開啟物件緩存。",
+ "config-mod-security": "<strong>警告:</strong>您的網頁伺服器已開啟 [http://modsecurity.org/ mod_security] 模組,如果設定不恰當會導致使用者可在 MediaWiki 或其他應用程式發佈任意的內容。\n若您遇到任何問題,請參考 [http://modsecurity.org/documentation/ mod_security 文件] 或聯繫您的伺服器技術支援人員。",
+ "config-diff3-bad": "找不到 GNU diff3。",
+ "config-git": "找到 Git 版本控制軟體:<code>$1</code>。",
+ "config-git-bad": "查無 Git 版本控制軟體。",
+ "config-imagemagick": "找到 ImageMagick:<code>$1</code>。\n若您開啟了檔案上傳功能,將可啟用縮圖功能。",
+ "config-gd": "找到內建 GD 圖形程式庫。\n若您開啟了檔案上傳功能,將可啟用縮圖功能。",
+ "config-no-scaling": "找不到 GD 程式庫或 ImageMagick。\n無法使用縮圖功能。",
+ "config-no-uri": "<strong>錯誤:</strong>無法辨識目前的 URI 位置。\n安裝已中止。",
+ "config-no-cli-uri": "<strong>警告:</strong>:未指定 <code>--scriptpath</code> 指令參數,使用預設值:<code>$1</code>。",
+ "config-using-server": "使用伺服器名稱 \"<nowiki>$1</nowiki>\"。",
+ "config-using-uri": "使用伺服器 URL \"<nowiki>$1$2</nowiki>\"。",
+ "config-uploads-not-safe": "<strong>警告:</strong>您預設的上傳目錄 <code>$1</code> 有可被任意執行 Script 的漏洞。\n雖然 MediaWiki 會對所有上傳的檔案進行安全檢查,但我們仍強烈建議您在開啟上傳功能前了解如何 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security 關閉此安全漏洞]。",
+ "config-no-cli-uploads-check": "<strong>警告:</strong>透過指令介面安不會檢查您預設的上傳目錄 (<code>$1</code>) 是否有可任意執行 Script 的安全性漏洞。",
+ "config-brokenlibxml": "您的系統使用了可能造成 MediaWiki 或其他網頁應用程式資料損毀問題的 PHP 與 limbxml2 版本。\n請升級 libxml2 2.7.3 或更新的版本 ([https://bugs.php.net/bug.php?id=45996 PHP 問題報告])。\n安裝已中止。",
+ "config-suhosin-max-value-length": "Suhosin 已安裝並且限制 GET 參數的長度 <code>length</code> 為 $1 位元組。\nMediaWiki 的 ResourceLoader 元件可以在此限制下正常運作,但仍會降低執行的效能。\n如果可能的情況下,您應該設定 <code>php.ini</code> 設定檔中的項目 <code>suhosin.get.max_value_length</code> 為 1024 或者更高的數值,並且將\n<code>LocalSettings.php</code> 中的設定項目 <code>$wgResourceLoaderMaxQueryLength</code> 設為相同的數值。",
+ "config-db-type": "資料庫類型:",
+ "config-db-host": "資料庫主機:",
+ "config-db-host-help": "如果您的資料庫安裝在其他伺服器上,請在此輸入該主機的名稱或 IP 位址。\n\n如果您使用共用的網頁主機,您的主機提供商應會在說明文件上告訴您正確的主機名稱。\n\n如果您安裝在 Windows 伺服器並且使用 MySQL,伺服器名稱可能無法使用使用 \"localhost\"。若確實無法使用,請改嘗試使用本機的 IP 位址 \"127.0.0.1\"。\n\n如果您使用 PostgreSQL,將此欄位空白以使用 Unix socket 來連線。",
+ "config-db-host-oracle": "資料庫的 TNS:",
+ "config-db-host-oracle-help": "請輸入有效的 [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm 本地連線名稱],並確認安裝程式可以讀取 tnsnames.ora 檔案。<br />如果您使用的客戶端程式庫為 10g 或者更新的版本,您也可使用 [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm 簡易連線] 的命名方法進行連線。",
+ "config-db-wiki-settings": "此 Wiki 的 ID",
+ "config-db-name": "資料庫名稱:",
+ "config-db-name-help": "請輸入一個可以辨識您的 Wiki 的名稱,\n請勿包含空格。\n\n如果您使用的是共用的網頁主機,您的主機提供商會給您一個指定的資料庫名稱,或者讓您透過管理介面建立資料庫。",
+ "config-db-name-oracle": "資料庫 Schema:",
+ "config-db-account-oracle-warn": "目前有三種支援 Oracle 做為後端資料庫的方案:\n\n如果您希望在安裝的過程中自動建立新的資料庫,請提供具有 SYSDBA 權限的帳號並且提供未來要給網頁存取使用的資料庫帳號及密碼。或者您可以手動建立給網頁存取使用的資料庫帳號 (請確保該帳號有建立 Schema Object 的權限),再不然您可以提供兩組不同的帳號,一組用來建立權限,而另一組用來做為網頁存取使用。\n\n本次安裝建立的帳號以及權限所需要的 Script,可以在 \"maintenance/oracle/\" 中找到。\n請注意,若您使用有限制的帳號將會預設關閉所有維護性功能。",
+ "config-db-install-account": "安裝程式使用的使用者帳號",
+ "config-db-username": "資料庫使用者名稱:",
+ "config-db-password": "資料庫密碼:",
+ "config-db-install-username": "請輸入在安裝過程中用來連線資料庫的使用者名稱。\n請注意,這不是 MediaWiki 帳號的使用者名稱,這是您資料庫的使用者名稱。",
+ "config-db-install-password": "請輸入在安裝過程中用來連線資料庫的密碼。\n請注意,這不是 MediaWiki 帳號的密碼,這是您資料庫的密碼。",
+ "config-db-install-help": "請輸入在安裝過程中用來連線資料庫的使用者名稱及密碼。",
+ "config-db-account-lock": "在一般操作時使用同樣的使用者名稱及密碼。",
+ "config-db-wiki-account": "用於一般操作的使用者帳號",
+ "config-db-wiki-help": "請輸入一般操作用來連線資料庫的使用者名稱及密碼。\n若您安裝使用的資料庫帳號有足夠的權限,您可以輸入新的帳號,系統會自動幫您以最低權限建立一組專門做為 Wiki 一般操作的帳號。",
+ "config-db-prefix": "資料庫資料表名稱的字首:",
+ "config-db-prefix-help": "如果您需要讓多個 Wiki 共用同一個資料庫,或者與其他網頁應用程式共用一個資料庫,您也許會需要在所有資料表的名稱前面加上字首,可以避免資料表名稱的衝突。\n請勿使用空格。\n\n此欄位可不填。",
+ "config-mysql-old": "需要使用 MySQL $1 或更新的版本,您的版本為 $2。",
+ "config-db-port": "資料庫埠號:",
+ "config-db-schema": "MediaWiki 的 Schema:",
+ "config-db-schema-help": "資料庫 Schema 通常不需更動。\n只在有特殊需求時才需修改。",
+ "config-pg-test-error": "無法連線到資料庫 <strong>$1</strong>:$2",
+ "config-sqlite-dir": "SQLite 的資料目錄:",
+ "config-sqlite-dir-help": "SQLite 會將所有的資料存儲於單一檔案中。\n\n您所提供的目錄在安裝過程中必須開啟給網頁伺服器的寫入權限。\n\n該目錄 <strong>不應</strong> 可以被透過網頁所開啟,這也是為什麼我們不將資料與 PHP 檔案放在一起。\n\n安裝程式在建立資料庫檔案時,會同時在目錄下建立 <code>.htaccess</code> 以控制網頁伺服器權限。若此設定失效,則會導致任何人可以直接存取您的原始資料檔案,而資料庫的內容包含原始的使用者資料 (電子郵件地址、加密後的密碼)、刪除後的修訂及其他在 Wiki 上被限制存取的資料。\n\n請考慮將資料庫統一放置在某處,如 <code>/var/lib/mediawiki/yourwiki</code> 底下。",
+ "config-oracle-def-ts": "預設資料表空間:",
+ "config-oracle-temp-ts": "臨時資料表空間:",
+ "config-type-mysql": "MySQL (或與其相容的程式)",
+ "config-type-mssql": "Microsoft SQL Server",
+ "config-support-info": "MediaWiki 支援以下資料庫系統:\n\n$1\n\n如果您下方沒有看到您要使用的資料庫系統,請根據上方連結指示開啟資料庫的支援。",
+ "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] 是 MediaWiki 主要支援的資料庫系統。MediaWiki 也同時可運作與於 [{{int:version-db-mariadb-url}} MariaDB] 和[{{int:version-db-percona-url}} Percona 伺服器],上述這些與 MySQL 相容的資料庫系統。([http://www.php.net/manual/en/mysqli.installation.php 如何編譯支援 MySQL 的 PHP])",
+ "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL]是一套受歡迎的開源資料庫系統,可用來替代 MySQL。([http://www.php.net/manual/en/pgsql.installation.php 如何編譯支援PostgreSQL的PHP])。",
+ "config-dbsupport-sqlite": "* [{{int:version-db-sqlite-url}} SQLite] 是一套輕量級的資料庫系統,MediaWiki 可在此資料庫系統上良好的運作。([http://www.php.net/manual/en/pdo.installation.php 如何編譯支援 SQLite 的 PHP],須透過 PDO)",
+ "config-dbsupport-oracle": "* [{{int:version-db-oracle-url}} Oracle] 是一套商用企業級的資料庫。([http://www.php.net/manual/en/oci8.installation.php 如何編譯支援 OCI8 的 PHP])",
+ "config-dbsupport-mssql": "* [{{int:version-db-mssql-url}} Microsoft SQL Server] 是一套 Windows 專用的商用企業級的資料庫。 ([http://www.php.net/manual/en/sqlsrv.installation.php 如何編譯支援 SQLSRV 的 PHP])",
+ "config-header-mysql": "MySQL 設定",
+ "config-header-postgres": "PostgreSQL 設定",
+ "config-header-sqlite": "SQLite 設定",
+ "config-header-oracle": "Oracle 設定",
+ "config-header-mssql": "Microsoft SQL Server 設定",
+ "config-invalid-db-type": "無效的資料庫類型。",
+ "config-missing-db-name": "您必須輸入 \"{{int:config-db-name}}\" 欄位的內容。",
+ "config-missing-db-host": "您必須輸入 \"{{int:config-db-host}}\" 欄位的內容。",
+ "config-missing-db-server-oracle": "您必須輸入 \"{{int:config-db-host-oracle}}\" 欄位的內容。",
+ "config-invalid-db-server-oracle": "無效的資料庫 TNS \"$1\"。\n請使用符合 \"TNS 名稱\" 或 \"簡易連線\" 規則的字串([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle命名規則])",
+ "config-invalid-db-name": "無效的資料庫名稱 \"$1\"。\n僅允許使用 ASCII 字母(a-z、A-Z)、數字(0-9)、底線(_)與連字號(-)。",
+ "config-invalid-db-prefix": "無效的資料庫字首 \"$1\"。\n僅允許使用 ASCII 字母(a-z、A-Z)、數字(0-9)、底線(_)與連字號(-)。",
+ "config-connection-error": "$1。\n\n請檢查主機、使用者名稱和密碼設定,然後重試。",
+ "config-invalid-schema": "無效的資料庫 Schema \"$1\"。\n僅允許使用 ASCII 字母(a-z、A-Z)、數字(0-9)、底線(_)與連字號(-)。",
+ "config-db-sys-create-oracle": "安裝程式只支援使用 SYSDBA 帳號建立新帳號。",
+ "config-db-sys-user-exists-oracle": "使用者帳號 \"$1\" 已存在。 SYSDBA 只可用來建立新的帳號!",
+ "config-postgres-old": "需要使用 PostgreSQL $1 或更新的版本,您的版本為 $2。",
+ "config-mssql-old": "需要使用 Microsoft SQL Server $1 或更新的版本,您的版本為 $2。",
+ "config-sqlite-name-help": "請為您的 Wiki 設定一個用來辨識的名稱。\n請勿使用空格或連字號,\n該名稱會被用來做為 SQLite 資料檔的名稱。",
+ "config-sqlite-parent-unwritable-group": "無法建立資料目錄 <nowiki>$1</nowiki></code>,因網頁伺服器對該目錄所在的上層目錄 <code><nowiki>$2</nowiki></code> 沒有寫入權限。\n\n安裝程序所使用的身份依據您用來執行網頁伺服器的身份而定,\n請開啟網頁伺服器對 <code><nowiki>$3</nowiki></code> 的寫入權以繼續安裝,\n在 Unix/Linux 系統可以執行以下指令:\n\n<pre>cd $2\nmkdir $3\nchgrp $4 $3\nchmod g+w $3</pre>",
+ "config-sqlite-parent-unwritable-nogroup": "無法建立資料目錄 <nowiki>$1</nowiki></code>,因網頁伺服器對該目錄所在的上層目錄 <code><nowiki>$2</nowiki></code> 沒有寫入權限。\n\n安裝程序所使用的身份依據您用來執行網頁伺服器的身份而定,\n請開啟全部人對 <code><nowiki>$3</nowiki></code> 的寫入權以繼續安裝,\n在 Unix/Linux 系統可以執行以下指令:\n\n<pre>cd $2\nmkdir $3\nchmod a+w $3</pre>",
+ "config-sqlite-mkdir-error": "建立資料目錄 \"$1\" 時發生錯誤。\n請檢查路徑後再試一次。",
+ "config-sqlite-dir-unwritable": "無法寫入目錄 \"$1\"。\n請修改該目錄的權限,請開啟網頁伺服器的寫入權限後,再試一次。",
+ "config-sqlite-connection-error": "$1。\n\n請檢查下方資料目錄與資料庫名稱,再試一次。",
+ "config-sqlite-readonly": "檔案 <code>$1</code> 無寫入權限。",
+ "config-sqlite-cant-create-db": "無法建立資料庫檔案 <code>$1</code>。",
+ "config-sqlite-fts3-downgrade": "PHP 不支援 FTS3,正在降級資料表。",
+ "config-can-upgrade": "在資料庫中找到 MediaWiki 的資料表。\n要升級至 MediaWiki $1,請點選 <strong>繼續</strong>。",
+ "config-upgrade-done": "升級完成。\n\n現在您可以 [$1 開始使用您的 Wiki] 了。\n\n如果您需要重新產生 <code>LocalSettings.php</code> 檔案,請點選下方按鈕。\n除非您的 Wiki 出現了問題,否則我們 <strong>不建議</strong> 您執行此操作。",
+ "config-upgrade-done-no-regenerate": "升級完成。\n\n現在您可以 [$1 開始使用您的 Wiki] 了。",
+ "config-regenerate": "重新產生 LocalSettings.php →",
+ "config-show-table-status": "<code>SHOW TABLE STATUS</code> 查詢失敗!",
+ "config-unknown-collation": "<strong>警告:</strong>資料庫使用了無法辨識的字元與排序規則。",
+ "config-db-web-account": "供網頁存取使用的資料庫帳號",
+ "config-db-web-help": "請設定網頁伺服器在一般操作時連線到資料庫使用的使用者名稱及密碼。",
+ "config-db-web-account-same": "使用與安裝程序相同的帳號",
+ "config-db-web-create": "如果帳號不存在則建立新帳號",
+ "config-db-web-no-create-privs": "您指定給安裝程序使用的帳號沒有足夠的權限建立新帳號。\n在此處必須指定已經存在的帳號。",
+ "config-mysql-engine": "儲存引擎:",
+ "config-mysql-innodb": "InnoDB",
+ "config-mysql-myisam": "MyISAM",
+ "config-mysql-myisam-dep": "<strong>警告:</strong>您選擇用來做為 MySQL 的儲存引撆 MyISAM 並不建議使用在 MediaWiki,主要原因為:\n* MyISAM 使用的資料表鎖定較無法承受多人同時連線\n* 比起其他儲存引擎相,它較容易損壞\n* MediaWiki 程式碼並沒有針對 MyISAM 做特別的處理\n\n若您安裝的 MySQL 支援 InnoDB,我們強烈建議您改用 InnoDB。\n若您安裝的 MySQL 不支援 InnoDB,則應考慮升級 MySQL。",
+ "config-mysql-only-myisam-dep": "<strong>警告:</strong>您的伺服器上的 MySQL 唯一可用的儲存引擎是 MyISAM,但並不建議使用,主要原因為:\n* MyISAM 使用的資料表鎖定較無法承受多人同時連線\n* 比起其他儲存引擎相,它較容易損壞\n* MediaWiki 程式碼並沒有針對 MyISAM 做特別的處理\n\n若您安裝的 MySQL 不支援 InnoDB,則應考慮升級 MySQL。",
+ "config-mysql-engine-help": "由於對同時連線有較好的處理能力,<strong>InnoDB</strong> 通常是最佳的選項。\n\n<strong>MyISAM</strong> 只在單人使用或者唯讀作業的情況之下才可能有較快的處理能力。\n相較於 InnoDB,MyISAM 也較容易出現資料損毀的情況。",
+ "config-mysql-charset": "資料庫字元集:",
+ "config-mysql-binary": "二進制",
+ "config-mysql-utf8": "UTF-8",
+ "config-mysql-charset-help": "在 <strong>二進制模式</strong> 下,MediaWiki 將 UTF-8 的文字儲存在二進位型態的欄位。\n這個模式比 MySQL 的 UTF-8 模式還要更有效,並且可以讓您使用完整的 Unicode 字元集。\n\n在 <strong>UTF-8 模式</strong> 下,MySQL 可以知道您的資料使用何種編碼儲存,您可以正常的取得與轉換內容,但此個模式只支援到 Unicode 中的 [https://en.wikipedia.org/wiki/Mapping_of_Unicode_character_planes 基本多文種平面] 字元。",
+ "config-mssql-auth": "身份驗證類型:",
+ "config-mssql-install-auth": "請選擇安裝程序中要用來連線資料庫使用的身份驗證類型。\n若您選擇 \"{{int:config-mssql-windowsauth}}\",不論網頁伺服器是使用何種身份執行都會使用這組驗證資料。",
+ "config-mssql-web-auth": "請選擇一般操作中要用來連線資料庫使用的身份驗證類型。\n若您選擇 \"{{int:config-mssql-windowsauth}}\",不論網頁伺服器是使用何種身份執行都會使用這組驗證資料。",
+ "config-mssql-sqlauth": "SQL Server 身份驗證",
+ "config-mssql-windowsauth": "Windows 身份驗證",
+ "config-site-name": "Wiki 的名稱:",
+ "config-site-name-help": "您所填入的內容會出現在瀏覽器的標題列以及各種其他地方。",
+ "config-site-name-blank": "請輸入網站名稱。",
+ "config-project-namespace": "專案命名空間:",
+ "config-ns-generic": "專案",
+ "config-ns-site-name": "同 Wiki 名稱:$1",
+ "config-ns-other": "其他 (請註明)",
+ "config-ns-other-default": "我的 wiki",
+ "config-project-namespace-help": "許多 Wiki 以維基百科 (Wikipedia) 做為範例將政策頁面從內容頁面抽離,放置在 \"'''專案命名空間'''\" 中。\n所有在此命名空間裡的頁面都會有特定的字首,您可以在此處設定。\n通常這些字首是由該 Wiki 的名稱所衍伸出來,但無法使用標點符號,如 \"#\" 或 \":\"。",
+ "config-ns-invalid": "您指定的命名空間 \"<nowiki>$1</nowiki>\" 無效,\n請指定另一個專案命名空間。",
+ "config-ns-conflict": "您指定的命名空間 \"<nowiki>$1</nowiki>\" 與 MediaWiki 預設的命名空間衝突。\n請指定另一個專案命名空間。",
+ "config-admin-box": "管理員帳號",
+ "config-admin-name": "您的使用者名稱:",
+ "config-admin-password": "密碼:",
+ "config-admin-password-confirm": "再次輸入密碼:",
+ "config-admin-help": "在此輸入您想使用的使用者名稱,例如 \"Joe Bloggs\"。\n此名稱將用來登入 Wiki。",
+ "config-admin-name-blank": "輸入管理員的使用者名稱。",
+ "config-admin-name-invalid": "指定的使用者名稱 \"<nowiki>$1</nowiki>\" 無效,請改用其他使用者名稱。",
+ "config-admin-password-blank": "輸入管理員帳號密碼。",
+ "config-admin-password-mismatch": "兩次輸入的密碼並不相同。",
+ "config-admin-email": "電子郵件地址:",
+ "config-admin-email-help": "在此輸入的電子郵件信箱可用來接收 Wiki 上其他使用者所傳送的訊息、重設您的密碼與通知監視清單中頁面更動。您可將此欄位留空。",
+ "config-admin-error-user": "建立管理員帳號 \"<nowiki>$1</nowiki>\" 時發生內部錯誤。",
+ "config-admin-error-password": "設定管理員 \"<nowiki>$1</nowiki>\" 的密碼時發生內部錯誤:<pre>$2</pre>",
+ "config-admin-error-bademail": "您輸入了不正確的電子郵件地址。",
+ "config-subscribe": "訂閱 [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce 發佈公告郵寄清單]。",
+ "config-subscribe-help": "這是一個用於發佈公告的低郵件量郵寄清單,內容包括重要的安全公告。\n您應該訂閱它並在 MediaWiki 發佈新版的時候更新系統。",
+ "config-subscribe-noemail": "您正嘗試不填寫電子郵件地址訂閱發佈公告郵寄清單。 \n請如果您希望訂閱郵寄清單,請提供一個有效的電子郵件地址。",
+ "config-pingback": "與MediaWiki開發人員分享此安裝過程的數據。",
+ "config-pingback-help": "如果您選擇此項設定,MediaWiki將會定期把有關本MediaWiki實例的基本數據傳送給https://www.mediawiki.org。數據包括系統類型、PHP版本、所選的資料庫後端等等。維基媒體基金會會向MediaWiki的開發人員分享這組數據,以幫助將來的開發計劃。將會傳送以下有關您系統的數據:\n<pre>$1</pre>",
+ "config-almost-done": "您快要完成了!\n您現在可以跳過其餘的設定項目並且立即安裝 Wiki。",
+ "config-optional-continue": "多問我一些問題吧。",
+ "config-optional-skip": "我已經不耐煩了,請趕緊安裝 Wiki。",
+ "config-profile": "使用者權限基本資料:",
+ "config-profile-wiki": "開放式 Wiki",
+ "config-profile-no-anon": "需要註冊帳號",
+ "config-profile-fishbowl": "僅授權的編輯者",
+ "config-profile-private": "私人 wiki",
+ "config-profile-help": "Wiki 最佳的運作方式是盡可能讓大家都可以編輯文件。\n在 MediaWiki,可以很輕易的審查最近做的所有變更動作,並且可以還原由新手或惡意使用者造成的損害。\n\n不論如何,很多人發現 MediaWiki 可以廣泛的運用在各種地方,但並不是很容易可以說服每個人都遵守對 Wiki 有益的方式。\n所以您必須做出以下選擇。\n\n使用 <strong>{{int:config-profile-wiki}}</strong> 模式,允許所有人編輯文章,包含未匿名使用者。\n使用 <strong>{{int:config-profile-no-anon}}</strong> 模式,允許所有人編輯文章,不包含未登入的使用者。此模式較能管理所有使用者的言論,但會扼殺臨時使用者的貢獻機會。\n\n使用 <strong>{{int:config-profile-fishbowl}}</strong> 模式,僅經核准的使用者可以編輯,所有人可以檢視頁面,包含修訂的記錄。\n使用 <strong>{{int:config-profile-private}}</strong> 模式,僅經核准的使用者可以編輯、檢視頁面。\n\n有關更多複雜的使用者權限設定可在安裝程序結束後設定,請參考 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights 相關文件說明]。",
+ "config-license": "版權聲明與授權條款:",
+ "config-license-none": "無授權條款頁腳",
+ "config-license-cc-by-sa": "創用 CC 姓名標示-相同方式分享",
+ "config-license-cc-by": "創用 CC 姓名標示",
+ "config-license-cc-by-nc-sa": "創作共用 Attribution-NonCommercial-ShareAlike",
+ "config-license-cc-0": "創作共用 Zero (公有領域)",
+ "config-license-gfdl": "GNU 自由文件授權條款 1.3 或更高版本",
+ "config-license-pd": "公有領域",
+ "config-license-cc-choose": "請選擇一個自訂的創作共用授權條款",
+ "config-license-help": "許多開放式 Wiki 會以 [http://freedomdefined.org/Definition 自由授權條款] 的方式釋放出編者的所有貢獻,這有助於構建社群的所有權,並且能鼓勵長期貢獻。對於封閉式的 Wiki 或公司 Wiki 則是非必要的。\n\n如果您希望使用來自維基百科(Wikipedia)的內容,並希望維基百科能接受您的 Wiki 內容,請應選擇 <strong>{{int:config-license-cc-by-sa}}</strong> 授權條款。\n\n維基百科(Wikipedia)先前是使用 GNU 自由文件授權條款,\n但該授權條款的內容較難理解,因此較難再利用在該條款底下的內容。",
+ "config-email-settings": "E-mail 設定",
+ "config-enable-email": "開啟外寄電子郵件",
+ "config-enable-email-help": "如果您要使用電子郵件功能,請正確設定 [http://www.php.net/manual/en/mail.configuration.php PHP 的郵件設定]。\n如果您不需要使用電子郵件功能,請在此處關閉。",
+ "config-email-user": "開啟使用者對使用者間的電子郵件互通",
+ "config-email-user-help": "若使用者在個人偏好設定開啟了此功能,則可允許使用者間相互傳送郵件。",
+ "config-email-usertalk": "開啟使用者討論頁面通知",
+ "config-email-usertalk-help": "若使用者在個人偏好開啟了此功能,則可收到使用者討論頁面被修改的通知。",
+ "config-email-watchlist": "開啟監視清單通知",
+ "config-email-watchlist-help": "若使用者在個人偏好開啟了此功能,允許使用者收到與其監視清單有關的通知。",
+ "config-email-auth": "開啟電子郵件身份認證",
+ "config-email-auth-help": "若開啟此選項,使用者不論設定或者更改電子郵件地址,都必須透過收信的方式確認沒有問題。\n只有驗證過的電子郵件地址可以收到來自其他使用者或修改通知的信件。\n公開的 Wiki 會 <strong>建議</strong> 設定此選項,以防使用者濫用電子郵件功能。",
+ "config-email-sender": "電子郵件回覆地址:",
+ "config-email-sender-help": "請輸入要用來做為外寄郵件的電子郵件回覆地址。\n該郵件地址會收到被拒收的信件。\n許多郵件伺服器會要求使用有效的網域名稱。",
+ "config-upload-settings": "圖片和檔案上傳",
+ "config-upload-enable": "開啟檔案上傳",
+ "config-upload-help": "檔案上傳功能會讓您的伺服器暴露在潛藏的安全性風險之下。\n要取得更多相關的資訊,請參考 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security 安全性章節]。\n\n要開啟檔案上傳需要將 MediaWiki 根目錄底下的 <code>images</code> 目錄開啟網頁伺服器的寫入權,\n然後再啟動選項。",
+ "config-upload-deleted": "已刪除檔案的目錄:",
+ "config-upload-deleted-help": "請選擇用來存放已刪除檔案的目錄。\n理想情況下,此目錄不可被網頁直接存取。",
+ "config-logo": "Logo 網址:",
+ "config-logo-help": "在 MediaWiki 的預設介面,側欄選單上方有一塊 135x160 像素用來放置標誌的區域。\n請上傳合適大小的圖片並在此輸入 URL 網址。\n\n您可以透過 <code>$wgStylePath</code> 或者 <code>$wgScriptPath</code> 來表示您的圖片與這些路徑的相對位置。\n\n如果您不想使用標誌,可略過此欄位。",
+ "config-instantcommons": "開啟即時共享資源",
+ "config-instantcommons-help": "[https://www.mediawiki.org/wiki/InstantCommons 即時共享資源] 是允許 Wiki 使用來自 [https://commons.wikimedia.org/ Wikimedia Commons] 網站上的圖片、聲音以及其他媒體的一項功能。\n若要開啟此功能,您的 MediaWiki 必須能夠連線網際網路。\n更多有關此功能的訊息,包含如何存除了 Wikimedia Commons 之外其他網站的說明,請參考 \n[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos 操作手冊]。",
+ "config-cc-error": "查無該創作共用授權條款,\n請手動輸入您的授權條款名稱。",
+ "config-cc-again": "請重新選取...",
+ "config-cc-not-chosen": "請選擇您要使用的創用CC授權條款,然後點選 \"proceed\"。",
+ "config-advanced-settings": "進階設定",
+ "config-cache-options": "物件快取設定:",
+ "config-cache-help": "物件快取是用來增進 MediaWiki 速度的一項功能,透過快取經常使用的資料。\n中型到大型的網站我們會建議開啟這個選項,對小型的網站也有一定程度的效果。",
+ "config-cache-none": "不快取 (不會影響功能,但在大型 Wiki 網站可能會有處理速度的問題)",
+ "config-cache-accel": "使用 PHP 物件快取 (APC、APCu、XCache 或 WinCache)",
+ "config-cache-memcached": "使用 Memcached (需要額外安裝與設定)",
+ "config-memcached-servers": "Memcached 伺服器:",
+ "config-memcached-help": "請列出 Memcached 伺服器的 IP 位址。\n每一行只指定一個位置並且要註明使用的埠號,例如:\n 127.0.0.1:11211\n 192.168.1.25:1234",
+ "config-memcache-needservers": "您的快取類型選擇使用 Memcached,但並未設定任何的伺服器。",
+ "config-memcache-badip": "您輸入了一筆無效的 Memcached IP 位址:$1。",
+ "config-memcache-noport": "您沒有輸入 Memcached 伺服器的埠號:$1。\n如果您不曉得埠號為多少,預設為 11211。",
+ "config-memcache-badport": "Memcached 埠號應介於 $1 到 $2 之間。",
+ "config-extensions": "擴充套件",
+ "config-extensions-help": "已在您的 <code>./extensions</code> 目錄中發現下列擴充套件。\n\n這些擴充套件可能需要做額外的設定,但您可以現在先開啟功能。",
+ "config-skins": "外觀",
+ "config-skins-help": "系統偵測到您於 <code>./skins</code> 資料夾中含有外觀如上清單。 您必須開啟其中一項並設為預設值。",
+ "config-skins-use-as-default": "使用此外觀作為預設",
+ "config-skins-missing": "沒有發現任何外觀;MediaWiki 在您安裝一些恰當的外觀前將會使用備用外觀。",
+ "config-skins-must-enable-some": "您至少須選擇一個外觀啟用。",
+ "config-skins-must-enable-default": "必須啟用己選擇為預設值的外觀。",
+ "config-install-alreadydone": "<strong>警告:</strong>您已經安裝 MediaWiki,並且試圖重新安裝。\n請點繼續前往下一個頁面。",
+ "config-install-begin": "請點選 \"{{int:config-continue}}\" 開始安裝 MediaWiki。\n若您還想要修改設定,請點選 \"{{int:config-back}}\"。",
+ "config-install-step-done": "完成",
+ "config-install-step-failed": "失敗",
+ "config-install-extensions": "正在開啟擴充套件",
+ "config-install-database": "正在設定資料庫",
+ "config-install-schema": "建立 Schema",
+ "config-install-pg-schema-not-exist": "PostgreSQL Schema 不存在",
+ "config-install-pg-schema-failed": "資料表建立失敗。\n請確認使用者 \"$1\" 可以寫入 Schema \"$2\"。",
+ "config-install-pg-commit": "認可變更中",
+ "config-install-pg-plpgsql": "正在檢查 PL/pgSQL 語言",
+ "config-pg-no-plpgsql": "您需要安裝 PL/pgSQL 到資料庫 $1",
+ "config-pg-no-create-privs": "您所指定用來給安裝程序使用的帳號沒有足夠的權限可以建立新帳號。",
+ "config-pg-not-in-role": "您指定用來給網頁存取的帳號已存在。\n您指定用來給安裝程序使用的的帳號既不是管理者,也不是給網頁存取使用者,因此無法使用網頁存取使用者的權限建立物件。\n\nMediaWiki 目前需要使用由網頁使用者所建立的資料表。請指定另一個網頁使用者的帳號名稱,或點選 \"返回\" 指定具有適當權限使用者給安裝程序使用。",
+ "config-install-user": "正在建立資料庫使用者",
+ "config-install-user-alreadyexists": "使用者 \"$1\" 已存在",
+ "config-install-user-create-failed": "建立使用者 \"$1\" 失敗:$2",
+ "config-install-user-grant-failed": "授序權限給使用者 \"$1\" 失敗:$2",
+ "config-install-user-missing": "指定的使用者 \"$1\" 不存在。",
+ "config-install-user-missing-create": "指定的使用者 \"$1\" 不存在。\n若您想建立帳號,請勾選下方 \"建立帳號\" 核選方塊。",
+ "config-install-tables": "正在建立資料表",
+ "config-install-tables-exist": "<strong>警告:</strong> MediaWiki 資料表已存在,略過建立資料表。",
+ "config-install-tables-failed": "<strong>錯誤:</strong>建立資料表失敗,以下為錯誤訊息:$1",
+ "config-install-interwiki": "正在匯入預設的 interwiki 資料表",
+ "config-install-interwiki-list": "查無檔案 <code>interwiki.list</code>。",
+ "config-install-interwiki-exists": "<strong>警告:</strong> interwiki 資料表內已有資料,略過建立預設資料。",
+ "config-install-stats": "初始化統計資訊",
+ "config-install-keys": "產生秘密金鑰中",
+ "config-insecure-keys": "<strong>警告:</strong>在安裝過程中所產生的 $2 組安全金鑰($1)並不完全安全。請考慮手動更改。",
+ "config-install-updates": "略過執行不需要的更新",
+ "config-install-updates-failed": "<strong>錯誤:</strong> 插入更新鍵值至資料表失敗,並出現以下錯誤:$1",
+ "config-install-sysop": "正在建立管理員使用者帳號",
+ "config-install-subscribe-fail": "無法訂閱 mediawiki-announce:$1",
+ "config-install-subscribe-notpossible": "未安裝 cURL,因此無法使用 <code>allow_url_fopen</code> 設定項目。",
+ "config-install-mainpage": "正在使用預設的內容建立首頁",
+ "config-install-mainpage-exists": "首頁已存在,略過中",
+ "config-install-extension-tables": "正在建立已啟動的擴充套件的資料表",
+ "config-install-mainpage-failed": "無法插入首頁: $1",
+ "config-install-done": "<strong>恭喜!</strong>\n您已經成功安裝MediaWiki。\n\n安裝程式已自動產生<code>LocalSettings.php</code>檔案,\n該檔案中包含了您所有的設定項目。\n\n您需要下載該檔案,並將其放置在您的Wiki的根目錄(index.php所在的目錄)中,下載應已自動開始。\n\n若瀏覽器沒有提示您下載,或者您取消了下載,您可以點選下方連結重新下載:\n\n$3\n\n<strong>注意:</strong>如果您現在不下載此檔案,稍後結束安裝程式之後將無法再下載設定檔。\n\n當您完成本步驟後,您可以<strong>[$2 進入您的Wiki]</strong>。",
+ "config-install-done-path": "<strong>恭喜!</strong>\n您已經成功安裝MediaWiki。\n\n安裝程式已自動產生<code>LocalSettings.php</code>檔案,\n該檔案中包含了您所有的設定項目。\n\n您需要下載該檔案,並將其放置在<code>$4</code>中,下載應已自動開始。\n\n若瀏覽器沒有提示您下載,或者您取消了下載,您可以點選下方連結重新下載:\n\n$3\n\n<strong>注意:</strong>如果您現在不下載此檔案,稍後結束安裝程式之後將無法再下載設定檔。\n\n當您完成本步驟後,您可以<strong>[$2 進入您的Wiki]</strong>。",
+ "config-download-localsettings": "下載 <code>LocalSettings.php</code>",
+ "config-help": "說明",
+ "config-help-tooltip": "點選以展開",
+ "config-nofile": "查無檔案 \"$1\",是否已被刪除?",
+ "config-extension-link": "您是否了解您的 Wiki 支援 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions 擴充套件]?\n\n\n您可以瀏覽 [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category 擴充套件分類] 或 [https://www.mediawiki.org/wiki/Extension_Matrix 擴充套件資料表] 以取得相關的資訊。",
+ "mainpagetext": "<strong>已安裝 MediaWiki。</strong>",
+ "mainpagedocfooter": "有關使用wiki的訊息,請參閱[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 使用者指南]。\n\n== 新手入門 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 系統設定]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki常見問題]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki郵寄清單]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources 將MediaWiki翻譯至您的語言]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 了解如何在您的wiki上防禦破壞]"
+}
diff --git a/www/wiki/includes/installer/i18n/zh-hk.json b/www/wiki/includes/installer/i18n/zh-hk.json
new file mode 100644
index 00000000..dfffd68d
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/zh-hk.json
@@ -0,0 +1,8 @@
+{
+ "@metadata": {
+ "authors": [
+ "Mark85296341"
+ ]
+ },
+ "mainpagedocfooter": "請參閱[https://meta.wikimedia.org/wiki/Help:Contents 用戶手冊]以獲得使用此 wiki 軟件的訊息!\n\n== 入門 ==\n* [https://www.mediawiki.org/wiki/Manual:Configuration_settings MediaWiki 配置設定清單]\n* [https://www.mediawiki.org/wiki/Manual:FAQ MediaWiki 常見問題解答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki 發佈郵件清單]"
+}
diff --git a/www/wiki/includes/installer/i18n/zh-tw.json b/www/wiki/includes/installer/i18n/zh-tw.json
new file mode 100644
index 00000000..51a1f622
--- /dev/null
+++ b/www/wiki/includes/installer/i18n/zh-tw.json
@@ -0,0 +1,4 @@
+{
+ "@metadata": [],
+ "mainpagedocfooter": "請參閱 [https://meta.wikimedia.org/wiki/Help:Contents 使用者手冊] 以獲得使用此 wiki 軟體的訊息!\n\n== 入門 ==\n\n* [https://www.mediawiki.org/wiki/Manual:Configuration_settings MediaWiki 配置設定清單]\n* [https://www.mediawiki.org/wiki/Manual:FAQ MediaWiki 常見問題解答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki 發佈郵件清單]"
+}
diff --git a/www/wiki/includes/interwiki/ClassicInterwikiLookup.php b/www/wiki/includes/interwiki/ClassicInterwikiLookup.php
new file mode 100644
index 00000000..d9c04240
--- /dev/null
+++ b/www/wiki/includes/interwiki/ClassicInterwikiLookup.php
@@ -0,0 +1,451 @@
+<?php
+namespace MediaWiki\Interwiki;
+
+/**
+ * InterwikiLookup implementing the "classic" interwiki storage (hardcoded up to MW 1.26).
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use \Cdb\Exception as CdbException;
+use \Cdb\Reader as CdbReader;
+use Wikimedia\Rdbms\Database;
+use Hooks;
+use Interwiki;
+use Language;
+use MapCacheLRU;
+use WANObjectCache;
+
+/**
+ * InterwikiLookup implementing the "classic" interwiki storage (hardcoded up to MW 1.26).
+ *
+ * This implements two levels of caching (in-process array and a WANObjectCache)
+ * and tree storage backends (SQL, CDB, and plain PHP arrays).
+ *
+ * All information is loaded on creation when called by $this->fetch( $prefix ).
+ * All work is done on replica DB, because this should *never* change (except during
+ * schema updates etc, which aren't wiki-related)
+ *
+ * @since 1.28
+ */
+class ClassicInterwikiLookup implements InterwikiLookup {
+
+ /**
+ * @var MapCacheLRU
+ */
+ private $localCache;
+
+ /**
+ * @var Language
+ */
+ private $contentLanguage;
+
+ /**
+ * @var WANObjectCache
+ */
+ private $objectCache;
+
+ /**
+ * @var int
+ */
+ private $objectCacheExpiry;
+
+ /**
+ * @var bool|array|string
+ */
+ private $cdbData;
+
+ /**
+ * @var int
+ */
+ private $interwikiScopes;
+
+ /**
+ * @var string
+ */
+ private $fallbackSite;
+
+ /**
+ * @var CdbReader|null
+ */
+ private $cdbReader = null;
+
+ /**
+ * @var string|null
+ */
+ private $thisSite = null;
+
+ /**
+ * @param Language $contentLanguage Language object used to convert prefixes to lower case
+ * @param WANObjectCache $objectCache Cache for interwiki info retrieved from the database
+ * @param int $objectCacheExpiry Expiry time for $objectCache, in seconds
+ * @param bool|array|string $cdbData The path of a CDB file, or
+ * an array resembling the contents of a CDB file,
+ * or false to use the database.
+ * @param int $interwikiScopes Specify number of domains to check for messages:
+ * - 1: Just local wiki level
+ * - 2: wiki and global levels
+ * - 3: site level as well as wiki and global levels
+ * @param string $fallbackSite The code to assume for the local site,
+ */
+ function __construct(
+ Language $contentLanguage,
+ WANObjectCache $objectCache,
+ $objectCacheExpiry,
+ $cdbData,
+ $interwikiScopes,
+ $fallbackSite
+ ) {
+ $this->localCache = new MapCacheLRU( 100 );
+
+ $this->contentLanguage = $contentLanguage;
+ $this->objectCache = $objectCache;
+ $this->objectCacheExpiry = $objectCacheExpiry;
+ $this->cdbData = $cdbData;
+ $this->interwikiScopes = $interwikiScopes;
+ $this->fallbackSite = $fallbackSite;
+ }
+
+ /**
+ * Check whether an interwiki prefix exists
+ *
+ * @param string $prefix Interwiki prefix to use
+ * @return bool Whether it exists
+ */
+ public function isValidInterwiki( $prefix ) {
+ $result = $this->fetch( $prefix );
+
+ return (bool)$result;
+ }
+
+ /**
+ * Fetch an Interwiki object
+ *
+ * @param string $prefix Interwiki prefix to use
+ * @return Interwiki|null|bool
+ */
+ public function fetch( $prefix ) {
+ if ( $prefix == '' ) {
+ return null;
+ }
+
+ $prefix = $this->contentLanguage->lc( $prefix );
+ if ( $this->localCache->has( $prefix ) ) {
+ return $this->localCache->get( $prefix );
+ }
+
+ if ( $this->cdbData ) {
+ $iw = $this->getInterwikiCached( $prefix );
+ } else {
+ $iw = $this->load( $prefix );
+ if ( !$iw ) {
+ $iw = false;
+ }
+ }
+ $this->localCache->set( $prefix, $iw );
+
+ return $iw;
+ }
+
+ /**
+ * Resets locally cached Interwiki objects. This is intended for use during testing only.
+ * This does not invalidate entries in the persistent cache, as invalidateCache() does.
+ * @since 1.27
+ */
+ public function resetLocalCache() {
+ $this->localCache->clear();
+ }
+
+ /**
+ * Purge the in-process and object cache for an interwiki prefix
+ * @param string $prefix
+ */
+ public function invalidateCache( $prefix ) {
+ $this->localCache->clear( $prefix );
+
+ $key = $this->objectCache->makeKey( 'interwiki', $prefix );
+ $this->objectCache->delete( $key );
+ }
+
+ /**
+ * Fetch interwiki prefix data from local cache in constant database.
+ *
+ * @note More logic is explained in DefaultSettings.
+ *
+ * @param string $prefix Interwiki prefix
+ * @return Interwiki|false
+ */
+ private function getInterwikiCached( $prefix ) {
+ $value = $this->getInterwikiCacheEntry( $prefix );
+
+ if ( $value ) {
+ // Split values
+ list( $local, $url ) = explode( ' ', $value, 2 );
+ return new Interwiki( $prefix, $url, '', '', (int)$local );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get entry from interwiki cache
+ *
+ * @note More logic is explained in DefaultSettings.
+ *
+ * @param string $prefix Database key
+ * @return bool|string The interwiki entry or false if not found
+ */
+ private function getInterwikiCacheEntry( $prefix ) {
+ wfDebug( __METHOD__ . "( $prefix )\n" );
+ $value = false;
+ try {
+ // Resolve site name
+ if ( $this->interwikiScopes >= 3 && !$this->thisSite ) {
+ $this->thisSite = $this->getCacheValue( '__sites:' . wfWikiID() );
+ if ( $this->thisSite == '' ) {
+ $this->thisSite = $this->fallbackSite;
+ }
+ }
+
+ $value = $this->getCacheValue( wfWikiID() . ':' . $prefix );
+ // Site level
+ if ( $value == '' && $this->interwikiScopes >= 3 ) {
+ $value = $this->getCacheValue( "_{$this->thisSite}:{$prefix}" );
+ }
+ // Global Level
+ if ( $value == '' && $this->interwikiScopes >= 2 ) {
+ $value = $this->getCacheValue( "__global:{$prefix}" );
+ }
+ if ( $value == 'undef' ) {
+ $value = '';
+ }
+ } catch ( CdbException $e ) {
+ wfDebug( __METHOD__ . ": CdbException caught, error message was "
+ . $e->getMessage() );
+ }
+
+ return $value;
+ }
+
+ private function getCacheValue( $key ) {
+ if ( $this->cdbReader === null ) {
+ if ( is_string( $this->cdbData ) ) {
+ $this->cdbReader = \Cdb\Reader::open( $this->cdbData );
+ } elseif ( is_array( $this->cdbData ) ) {
+ $this->cdbReader = new \Cdb\Reader\Hash( $this->cdbData );
+ } else {
+ $this->cdbReader = false;
+ }
+ }
+
+ if ( $this->cdbReader ) {
+ return $this->cdbReader->get( $key );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Load the interwiki, trying first memcached then the DB
+ *
+ * @param string $prefix The interwiki prefix
+ * @return Interwiki|bool Interwiki if $prefix is valid, otherwise false
+ */
+ private function load( $prefix ) {
+ $iwData = [];
+ if ( !Hooks::run( 'InterwikiLoadPrefix', [ $prefix, &$iwData ] ) ) {
+ return $this->loadFromArray( $iwData );
+ }
+
+ if ( is_array( $iwData ) ) {
+ $iw = $this->loadFromArray( $iwData );
+ if ( $iw ) {
+ return $iw; // handled by hook
+ }
+ }
+
+ $iwData = $this->objectCache->getWithSetCallback(
+ $this->objectCache->makeKey( 'interwiki', $prefix ),
+ $this->objectCacheExpiry,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $prefix ) {
+ $dbr = wfGetDB( DB_REPLICA ); // TODO: inject LoadBalancer
+
+ $setOpts += Database::getCacheSetOptions( $dbr );
+
+ $row = $dbr->selectRow(
+ 'interwiki',
+ self::selectFields(),
+ [ 'iw_prefix' => $prefix ],
+ __METHOD__
+ );
+
+ return $row ? (array)$row : '!NONEXISTENT';
+ }
+ );
+
+ if ( is_array( $iwData ) ) {
+ return $this->loadFromArray( $iwData ) ?: false;
+ }
+
+ return false;
+ }
+
+ /**
+ * Fill in member variables from an array (e.g. memcached result, Database::fetchRow, etc)
+ *
+ * @param array $mc Associative array: row from the interwiki table
+ * @return Interwiki|bool Interwiki object or false if $mc['iw_url'] is not set
+ */
+ private function loadFromArray( $mc ) {
+ if ( isset( $mc['iw_url'] ) ) {
+ $url = $mc['iw_url'];
+ $local = isset( $mc['iw_local'] ) ? $mc['iw_local'] : 0;
+ $trans = isset( $mc['iw_trans'] ) ? $mc['iw_trans'] : 0;
+ $api = isset( $mc['iw_api'] ) ? $mc['iw_api'] : '';
+ $wikiId = isset( $mc['iw_wikiid'] ) ? $mc['iw_wikiid'] : '';
+
+ return new Interwiki( null, $url, $api, $wikiId, $local, $trans );
+ }
+
+ return false;
+ }
+
+ /**
+ * Fetch all interwiki prefixes from interwiki cache
+ *
+ * @param null|string $local If not null, limits output to local/non-local interwikis
+ * @return array List of prefixes, where each row is an associative array
+ */
+ private function getAllPrefixesCached( $local ) {
+ wfDebug( __METHOD__ . "()\n" );
+ $data = [];
+ try {
+ /* Resolve site name */
+ if ( $this->interwikiScopes >= 3 && !$this->thisSite ) {
+ $site = $this->getCacheValue( '__sites:' . wfWikiID() );
+
+ if ( $site == '' ) {
+ $this->thisSite = $this->fallbackSite;
+ } else {
+ $this->thisSite = $site;
+ }
+ }
+
+ // List of interwiki sources
+ $sources = [];
+ // Global Level
+ if ( $this->interwikiScopes >= 2 ) {
+ $sources[] = '__global';
+ }
+ // Site level
+ if ( $this->interwikiScopes >= 3 ) {
+ $sources[] = '_' . $this->thisSite;
+ }
+ $sources[] = wfWikiID();
+
+ foreach ( $sources as $source ) {
+ $list = $this->getCacheValue( '__list:' . $source );
+ foreach ( explode( ' ', $list ) as $iw_prefix ) {
+ $row = $this->getCacheValue( "{$source}:{$iw_prefix}" );
+ if ( !$row ) {
+ continue;
+ }
+
+ list( $iw_local, $iw_url ) = explode( ' ', $row );
+
+ if ( $local !== null && $local != $iw_local ) {
+ continue;
+ }
+
+ $data[$iw_prefix] = [
+ 'iw_prefix' => $iw_prefix,
+ 'iw_url' => $iw_url,
+ 'iw_local' => $iw_local,
+ ];
+ }
+ }
+ } catch ( CdbException $e ) {
+ wfDebug( __METHOD__ . ": CdbException caught, error message was "
+ . $e->getMessage() );
+ }
+
+ return array_values( $data );
+ }
+
+ /**
+ * Fetch all interwiki prefixes from DB
+ *
+ * @param string|null $local If not null, limits output to local/non-local interwikis
+ * @return array[] Interwiki rows
+ */
+ private function getAllPrefixesDB( $local ) {
+ $db = wfGetDB( DB_REPLICA ); // TODO: inject DB LoadBalancer
+
+ $where = [];
+
+ if ( $local !== null ) {
+ if ( $local == 1 ) {
+ $where['iw_local'] = 1;
+ } elseif ( $local == 0 ) {
+ $where['iw_local'] = 0;
+ }
+ }
+
+ $res = $db->select( 'interwiki',
+ self::selectFields(),
+ $where, __METHOD__, [ 'ORDER BY' => 'iw_prefix' ]
+ );
+
+ $retval = [];
+ foreach ( $res as $row ) {
+ $retval[] = (array)$row;
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Returns all interwiki prefixes
+ *
+ * @param string|null $local If set, limits output to local/non-local interwikis
+ * @return array[] Interwiki rows, where each row is an associative array
+ */
+ public function getAllPrefixes( $local = null ) {
+ if ( $this->cdbData ) {
+ return $this->getAllPrefixesCached( $local );
+ }
+
+ return $this->getAllPrefixesDB( $local );
+ }
+
+ /**
+ * Return the list of interwiki fields that should be selected to create
+ * a new Interwiki object.
+ * @return string[]
+ */
+ private static function selectFields() {
+ return [
+ 'iw_prefix',
+ 'iw_url',
+ 'iw_api',
+ 'iw_wikiid',
+ 'iw_local',
+ 'iw_trans'
+ ];
+ }
+
+}
diff --git a/www/wiki/includes/interwiki/Interwiki.php b/www/wiki/includes/interwiki/Interwiki.php
new file mode 100644
index 00000000..21568205
--- /dev/null
+++ b/www/wiki/includes/interwiki/Interwiki.php
@@ -0,0 +1,185 @@
+<?php
+/**
+ * Interwiki table entry.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Value object for representing interwiki records.
+ */
+class Interwiki {
+
+ /** @var string The interwiki prefix, (e.g. "Meatball", or the language prefix "de") */
+ protected $mPrefix;
+
+ /** @var string The URL of the wiki, with "$1" as a placeholder for an article name. */
+ protected $mURL;
+
+ /** @var string The URL of the file api.php */
+ protected $mAPI;
+
+ /** @var string The name of the database (for a connection to be established
+ * with wfGetLB( 'wikiid' ))
+ */
+ protected $mWikiID;
+
+ /** @var bool Whether the wiki is in this project */
+ protected $mLocal;
+
+ /** @var bool Whether interwiki transclusions are allowed */
+ protected $mTrans;
+
+ public function __construct( $prefix = null, $url = '', $api = '', $wikiId = '', $local = 0,
+ $trans = 0
+ ) {
+ $this->mPrefix = $prefix;
+ $this->mURL = $url;
+ $this->mAPI = $api;
+ $this->mWikiID = $wikiId;
+ $this->mLocal = (bool)$local;
+ $this->mTrans = (bool)$trans;
+ }
+
+ /**
+ * Check whether an interwiki prefix exists
+ *
+ * @deprecated since 1.28, use InterwikiLookup instead
+ *
+ * @param string $prefix Interwiki prefix to use
+ * @return bool Whether it exists
+ */
+ public static function isValidInterwiki( $prefix ) {
+ return MediaWikiServices::getInstance()->getInterwikiLookup()->isValidInterwiki( $prefix );
+ }
+
+ /**
+ * Fetch an Interwiki object
+ *
+ * @deprecated since 1.28, use InterwikiLookup instead
+ *
+ * @param string $prefix Interwiki prefix to use
+ * @return Interwiki|null|bool
+ */
+ public static function fetch( $prefix ) {
+ return MediaWikiServices::getInstance()->getInterwikiLookup()->fetch( $prefix );
+ }
+
+ /**
+ * Purge the cache (local and persistent) for an interwiki prefix.
+ *
+ * @param string $prefix
+ * @since 1.26
+ */
+ public static function invalidateCache( $prefix ) {
+ MediaWikiServices::getInstance()->getInterwikiLookup()->invalidateCache( $prefix );
+ }
+
+ /**
+ * Returns all interwiki prefix definitions.
+ *
+ * @deprecated since 1.28, unused. Use InterwikiLookup instead.
+ *
+ * @param string|null $local If set, limits output to local/non-local interwikis
+ * @return array[] List of interwiki rows
+ * @since 1.19
+ */
+ public static function getAllPrefixes( $local = null ) {
+ return MediaWikiServices::getInstance()->getInterwikiLookup()->getAllPrefixes( $local );
+ }
+
+ /**
+ * Get the URL for a particular title (or with $1 if no title given)
+ *
+ * @param string $title What text to put for the article name
+ * @return string The URL
+ * @note Prior to 1.19 The getURL with an argument was broken.
+ * If you if you use this arg in an extension that supports MW earlier
+ * than 1.19 please wfUrlencode and substitute $1 on your own.
+ */
+ public function getURL( $title = null ) {
+ $url = $this->mURL;
+ if ( $title !== null ) {
+ $url = str_replace( "$1", wfUrlencode( $title ), $url );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get the API URL for this wiki
+ *
+ * @return string The URL
+ */
+ public function getAPI() {
+ return $this->mAPI;
+ }
+
+ /**
+ * Get the DB name for this wiki
+ *
+ * @return string The DB name
+ */
+ public function getWikiID() {
+ return $this->mWikiID;
+ }
+
+ /**
+ * Is this a local link from a sister project, or is
+ * it something outside, like Google
+ *
+ * @return bool
+ */
+ public function isLocal() {
+ return $this->mLocal;
+ }
+
+ /**
+ * Can pages from this wiki be transcluded?
+ * Still requires $wgEnableScaryTransclusion
+ *
+ * @return bool
+ */
+ public function isTranscludable() {
+ return $this->mTrans;
+ }
+
+ /**
+ * Get the name for the interwiki site
+ *
+ * @return string
+ */
+ public function getName() {
+ $msg = wfMessage( 'interwiki-name-' . $this->mPrefix )->inContentLanguage();
+
+ return !$msg->exists() ? '' : $msg->text();
+ }
+
+ /**
+ * Get a description for this interwiki
+ *
+ * @return string
+ */
+ public function getDescription() {
+ $msg = wfMessage( 'interwiki-desc-' . $this->mPrefix )->inContentLanguage();
+
+ return !$msg->exists() ? '' : $msg->text();
+ }
+
+}
diff --git a/www/wiki/includes/interwiki/InterwikiLookup.php b/www/wiki/includes/interwiki/InterwikiLookup.php
new file mode 100644
index 00000000..697e39d5
--- /dev/null
+++ b/www/wiki/includes/interwiki/InterwikiLookup.php
@@ -0,0 +1,74 @@
+<?php
+namespace MediaWiki\Interwiki;
+
+/**
+ * Service interface for looking up Interwiki records.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Interwiki;
+
+/**
+ * Service interface for looking up Interwiki records.
+ *
+ * @since 1.28
+ */
+interface InterwikiLookup {
+
+ /**
+ * Check whether an interwiki prefix exists
+ *
+ * @param string $prefix Interwiki prefix to use
+ * @return bool Whether it exists
+ */
+ public function isValidInterwiki( $prefix );
+
+ /**
+ * Fetch an Interwiki object
+ *
+ * @param string $prefix Interwiki prefix to use
+ * @return Interwiki|null|bool
+ */
+ public function fetch( $prefix );
+
+ /**
+ * Returns information about all interwiki prefixes, in the form of rows
+ * of the interwiki table. Each row may have the following keys:
+ *
+ * - iw_prefix: the prefix. Always present.
+ * - iw_url: the URL to use for linking, with $1 as a placeholder for the target page.
+ * Always present.
+ * - iw_api: the URL of the API. Optional.
+ * - iw_wikiid: the wiki ID (usually the database name for local wikis). Optional.
+ * - iw_local: whether the wiki is local, and the "magic redirect" mechanism should apply.
+ * Defaults to false.
+ * - iw_trans: whether "scary transclusion" is allowed for this site.
+ * Defaults to false.
+ *
+ * @param string|null $local If set, limits output to local/non-local interwikis
+ * @return array[] interwiki rows.
+ */
+ public function getAllPrefixes( $local = null );
+
+ /**
+ * Purge the in-process and persistent object cache for an interwiki prefix
+ * @param string $prefix
+ */
+ public function invalidateCache( $prefix );
+
+}
diff --git a/www/wiki/includes/interwiki/InterwikiLookupAdapter.php b/www/wiki/includes/interwiki/InterwikiLookupAdapter.php
new file mode 100644
index 00000000..076c37fe
--- /dev/null
+++ b/www/wiki/includes/interwiki/InterwikiLookupAdapter.php
@@ -0,0 +1,178 @@
+<?php
+namespace MediaWiki\Interwiki;
+
+/**
+ * InterwikiLookupAdapter on top of SiteLookup
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.29
+ * @ingroup InterwikiLookup
+ *
+ * @license GNU GPL v2+
+ */
+
+use Interwiki;
+use Site;
+use SiteLookup;
+use MediaWikiSite;
+
+class InterwikiLookupAdapter implements InterwikiLookup {
+
+ /**
+ * @var SiteLookup
+ */
+ private $siteLookup;
+
+ /**
+ * @var Interwiki[]|null associative array mapping interwiki prefixes to Interwiki objects
+ */
+ private $interwikiMap;
+
+ function __construct(
+ SiteLookup $siteLookup,
+ array $interwikiMap = null
+ ) {
+ $this->siteLookup = $siteLookup;
+ $this->interwikiMap = $interwikiMap;
+ }
+
+ /**
+ * See InterwikiLookup::isValidInterwiki
+ * It loads the whole interwiki map.
+ *
+ * @param string $prefix Interwiki prefix to use
+ * @return bool Whether it exists
+ */
+ public function isValidInterwiki( $prefix ) {
+ return array_key_exists( $prefix, $this->getInterwikiMap() );
+ }
+
+ /**
+ * See InterwikiLookup::fetch
+ * It loads the whole interwiki map.
+ *
+ * @param string $prefix Interwiki prefix to use
+ * @return Interwiki|null|bool
+ */
+ public function fetch( $prefix ) {
+ if ( $prefix == '' ) {
+ return null;
+ }
+
+ if ( !$this->isValidInterwiki( $prefix ) ) {
+ return false;
+ }
+
+ return $this->interwikiMap[$prefix];
+ }
+
+ /**
+ * See InterwikiLookup::getAllPrefixes
+ *
+ * @param string|null $local If set, limits output to local/non-local interwikis
+ * @return array[] interwiki rows
+ */
+ public function getAllPrefixes( $local = null ) {
+ $res = [];
+ foreach ( $this->getInterwikiMap() as $interwikiId => $interwiki ) {
+ if ( $local === null || $interwiki->isLocal() === $local ) {
+ $res[] = [
+ 'iw_prefix' => $interwikiId,
+ 'iw_url' => $interwiki->getURL(),
+ 'iw_api' => $interwiki->getAPI(),
+ 'iw_wikiid' => $interwiki->getWikiID(),
+ 'iw_local' => $interwiki->isLocal(),
+ 'iw_trans' => $interwiki->isTranscludable(),
+ ];
+ }
+ }
+ return $res;
+ }
+
+ /**
+ * See InterwikiLookup::invalidateCache
+ *
+ * @param string $prefix
+ */
+ public function invalidateCache( $prefix ) {
+ if ( !isset( $this->interwikiMap[$prefix] ) ) {
+ return;
+ }
+ $globalId = $this->interwikiMap[$prefix]->getWikiID();
+ unset( $this->interwikiMap[$prefix] );
+
+ // Reload the interwiki
+ $site = $this->siteLookup->getSites()->getSite( $globalId );
+ $interwikis = $this->getSiteInterwikis( $site );
+ $this->interwikiMap = array_merge( $this->interwikiMap, [ $interwikis[$prefix] ] );
+ }
+
+ /**
+ * Load interwiki map to use as cache
+ */
+ private function loadInterwikiMap() {
+ $interwikiMap = [];
+ $siteList = $this->siteLookup->getSites();
+ foreach ( $siteList as $site ) {
+ $interwikis = $this->getSiteInterwikis( $site );
+ $interwikiMap = array_merge( $interwikiMap, $interwikis );
+ }
+ $this->interwikiMap = $interwikiMap;
+ }
+
+ /**
+ * Get interwikiMap attribute, load if needed.
+ *
+ * @return Interwiki[]
+ */
+ private function getInterwikiMap() {
+ if ( $this->interwikiMap === null ) {
+ $this->loadInterwikiMap();
+ }
+ return $this->interwikiMap;
+ }
+
+ /**
+ * Load interwikis for the given site
+ *
+ * @param Site $site
+ * @return Interwiki[]
+ */
+ private function getSiteInterwikis( Site $site ) {
+ $interwikis = [];
+ foreach ( $site->getInterwikiIds() as $interwiki ) {
+ $url = $site->getPageUrl();
+ if ( $site instanceof MediaWikiSite ) {
+ $path = $site->getFileUrl( 'api.php' );
+ } else {
+ $path = '';
+ }
+ $local = $site->getSource() === 'local';
+ // TODO: How to adapt trans?
+ $interwikis[$interwiki] = new Interwiki(
+ $interwiki,
+ $url,
+ $path,
+ $site->getGlobalId(),
+ $local
+ );
+ }
+ return $interwikis;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/Job.php b/www/wiki/includes/jobqueue/Job.php
new file mode 100644
index 00000000..703e4856
--- /dev/null
+++ b/www/wiki/includes/jobqueue/Job.php
@@ -0,0 +1,410 @@
+<?php
+/**
+ * Job queue task base code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @defgroup JobQueue JobQueue
+ */
+
+/**
+ * Class to both describe a background job and handle jobs.
+ * The queue aspects of this class are now deprecated.
+ * Using the class to push jobs onto queues is deprecated (use JobSpecification).
+ *
+ * @ingroup JobQueue
+ */
+abstract class Job implements IJobSpecification {
+ /** @var string */
+ public $command;
+
+ /** @var array Array of job parameters */
+ public $params;
+
+ /** @var array Additional queue metadata */
+ public $metadata = [];
+
+ /** @var Title */
+ protected $title;
+
+ /** @var bool Expensive jobs may set this to true */
+ protected $removeDuplicates;
+
+ /** @var string Text for error that occurred last */
+ protected $error;
+
+ /** @var callable[] */
+ protected $teardownCallbacks = [];
+
+ /**
+ * Run the job
+ * @return bool Success
+ */
+ abstract public function run();
+
+ /**
+ * Create the appropriate object to handle a specific job
+ *
+ * @param string $command Job command
+ * @param Title $title Associated title
+ * @param array $params Job parameters
+ * @throws MWException
+ * @return Job
+ */
+ public static function factory( $command, Title $title, $params = [] ) {
+ global $wgJobClasses;
+
+ if ( isset( $wgJobClasses[$command] ) ) {
+ $handler = $wgJobClasses[$command];
+
+ if ( is_callable( $handler ) ) {
+ $job = call_user_func( $handler, $title, $params );
+ } elseif ( class_exists( $handler ) ) {
+ $job = new $handler( $title, $params );
+ } else {
+ $job = null;
+ }
+
+ if ( $job instanceof Job ) {
+ $job->command = $command;
+ return $job;
+ } else {
+ throw new InvalidArgumentException( "Cannot instantiate job '$command': bad spec!" );
+ }
+ }
+
+ throw new InvalidArgumentException( "Invalid job command '{$command}'" );
+ }
+
+ /**
+ * @param string $command
+ * @param Title $title
+ * @param array|bool $params Can not be === true
+ */
+ public function __construct( $command, $title, $params = false ) {
+ $this->command = $command;
+ $this->title = $title;
+ $this->params = is_array( $params ) ? $params : []; // sanity
+
+ // expensive jobs may set this to true
+ $this->removeDuplicates = false;
+
+ if ( !isset( $this->params['requestId'] ) ) {
+ $this->params['requestId'] = WebRequest::getRequestId();
+ }
+ }
+
+ /**
+ * Batch-insert a group of jobs into the queue.
+ * This will be wrapped in a transaction with a forced commit.
+ *
+ * This may add duplicate at insert time, but they will be
+ * removed later on, when the first one is popped.
+ *
+ * @param Job[] $jobs Array of Job objects
+ * @return bool
+ * @deprecated since 1.21
+ */
+ public static function batchInsert( $jobs ) {
+ wfDeprecated( __METHOD__, '1.21' );
+ JobQueueGroup::singleton()->push( $jobs );
+ return true;
+ }
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return $this->command;
+ }
+
+ /**
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * @return array
+ */
+ public function getParams() {
+ return $this->params;
+ }
+
+ /**
+ * @return int|null UNIX timestamp to delay running this job until, otherwise null
+ * @since 1.22
+ */
+ public function getReleaseTimestamp() {
+ return isset( $this->params['jobReleaseTimestamp'] )
+ ? wfTimestampOrNull( TS_UNIX, $this->params['jobReleaseTimestamp'] )
+ : null;
+ }
+
+ /**
+ * @return int|null UNIX timestamp of when the job was queued, or null
+ * @since 1.26
+ */
+ public function getQueuedTimestamp() {
+ return isset( $this->metadata['timestamp'] )
+ ? wfTimestampOrNull( TS_UNIX, $this->metadata['timestamp'] )
+ : null;
+ }
+
+ /**
+ * @return string|null Id of the request that created this job. Follows
+ * jobs recursively, allowing to track the id of the request that started a
+ * job when jobs insert jobs which insert other jobs.
+ * @since 1.27
+ */
+ public function getRequestId() {
+ return isset( $this->params['requestId'] )
+ ? $this->params['requestId']
+ : null;
+ }
+
+ /**
+ * @return int|null UNIX timestamp of when the job was runnable, or null
+ * @since 1.26
+ */
+ public function getReadyTimestamp() {
+ return $this->getReleaseTimestamp() ?: $this->getQueuedTimestamp();
+ }
+
+ /**
+ * Whether the queue should reject insertion of this job if a duplicate exists
+ *
+ * This can be used to avoid duplicated effort or combined with delayed jobs to
+ * coalesce updates into larger batches. Claimed jobs are never treated as
+ * duplicates of new jobs, and some queues may allow a few duplicates due to
+ * network partitions and fail-over. Thus, additional locking is needed to
+ * enforce mutual exclusion if this is really needed.
+ *
+ * @return bool
+ */
+ public function ignoreDuplicates() {
+ return $this->removeDuplicates;
+ }
+
+ /**
+ * @return bool Whether this job can be retried on failure by job runners
+ * @since 1.21
+ */
+ public function allowRetries() {
+ return true;
+ }
+
+ /**
+ * @return int Number of actually "work items" handled in this job
+ * @see $wgJobBackoffThrottling
+ * @since 1.23
+ */
+ public function workItemCount() {
+ return 1;
+ }
+
+ /**
+ * Subclasses may need to override this to make duplication detection work.
+ * The resulting map conveys everything that makes the job unique. This is
+ * only checked if ignoreDuplicates() returns true, meaning that duplicate
+ * jobs are supposed to be ignored.
+ *
+ * @return array Map of key/values
+ * @since 1.21
+ */
+ public function getDeduplicationInfo() {
+ $info = [
+ 'type' => $this->getType(),
+ 'namespace' => $this->getTitle()->getNamespace(),
+ 'title' => $this->getTitle()->getDBkey(),
+ 'params' => $this->getParams()
+ ];
+ if ( is_array( $info['params'] ) ) {
+ // Identical jobs with different "root" jobs should count as duplicates
+ unset( $info['params']['rootJobSignature'] );
+ unset( $info['params']['rootJobTimestamp'] );
+ // Likewise for jobs with different delay times
+ unset( $info['params']['jobReleaseTimestamp'] );
+ // Identical jobs from different requests should count as duplicates
+ unset( $info['params']['requestId'] );
+ // Queues pack and hash this array, so normalize the order
+ ksort( $info['params'] );
+ }
+
+ return $info;
+ }
+
+ /**
+ * Get "root job" parameters for a task
+ *
+ * This is used to no-op redundant jobs, including child jobs of jobs,
+ * as long as the children inherit the root job parameters. When a job
+ * with root job parameters and "rootJobIsSelf" set is pushed, the
+ * deduplicateRootJob() method is automatically called on it. If the
+ * root job is only virtual and not actually pushed (e.g. the sub-jobs
+ * are inserted directly), then call deduplicateRootJob() directly.
+ *
+ * @see JobQueue::deduplicateRootJob()
+ *
+ * @param string $key A key that identifies the task
+ * @return array Map of:
+ * - rootJobIsSelf : true
+ * - rootJobSignature : hash (e.g. SHA1) that identifies the task
+ * - rootJobTimestamp : TS_MW timestamp of this instance of the task
+ * @since 1.21
+ */
+ public static function newRootJobParams( $key ) {
+ return [
+ 'rootJobIsSelf' => true,
+ 'rootJobSignature' => sha1( $key ),
+ 'rootJobTimestamp' => wfTimestampNow()
+ ];
+ }
+
+ /**
+ * @see JobQueue::deduplicateRootJob()
+ * @return array
+ * @since 1.21
+ */
+ public function getRootJobParams() {
+ return [
+ 'rootJobSignature' => isset( $this->params['rootJobSignature'] )
+ ? $this->params['rootJobSignature']
+ : null,
+ 'rootJobTimestamp' => isset( $this->params['rootJobTimestamp'] )
+ ? $this->params['rootJobTimestamp']
+ : null
+ ];
+ }
+
+ /**
+ * @see JobQueue::deduplicateRootJob()
+ * @return bool
+ * @since 1.22
+ */
+ public function hasRootJobParams() {
+ return isset( $this->params['rootJobSignature'] )
+ && isset( $this->params['rootJobTimestamp'] );
+ }
+
+ /**
+ * @see JobQueue::deduplicateRootJob()
+ * @return bool Whether this is job is a root job
+ */
+ public function isRootJob() {
+ return $this->hasRootJobParams() && !empty( $this->params['rootJobIsSelf'] );
+ }
+
+ /**
+ * @param callable $callback A function with one parameter, the success status, which will be
+ * false if the job failed or it succeeded but the DB changes could not be committed or
+ * any deferred updates threw an exception. (This parameter was added in 1.28.)
+ * @since 1.27
+ */
+ protected function addTeardownCallback( $callback ) {
+ $this->teardownCallbacks[] = $callback;
+ }
+
+ /**
+ * Do any final cleanup after run(), deferred updates, and all DB commits happen
+ * @param bool $status Whether the job, its deferred updates, and DB commit all succeeded
+ * @since 1.27
+ */
+ public function teardown( $status ) {
+ foreach ( $this->teardownCallbacks as $callback ) {
+ call_user_func( $callback, $status );
+ }
+ }
+
+ /**
+ * Insert a single job into the queue.
+ * @return bool True on success
+ * @deprecated since 1.21
+ */
+ public function insert() {
+ JobQueueGroup::singleton()->push( $this );
+ return true;
+ }
+
+ /**
+ * @return string
+ */
+ public function toString() {
+ $paramString = '';
+ if ( $this->params ) {
+ foreach ( $this->params as $key => $value ) {
+ if ( $paramString != '' ) {
+ $paramString .= ' ';
+ }
+ if ( is_array( $value ) ) {
+ $filteredValue = [];
+ foreach ( $value as $k => $v ) {
+ $json = FormatJson::encode( $v );
+ if ( $json === false || mb_strlen( $json ) > 512 ) {
+ $filteredValue[$k] = gettype( $v ) . '(...)';
+ } else {
+ $filteredValue[$k] = $v;
+ }
+ }
+ if ( count( $filteredValue ) <= 10 ) {
+ $value = FormatJson::encode( $filteredValue );
+ } else {
+ $value = "array(" . count( $value ) . ")";
+ }
+ } elseif ( is_object( $value ) && !method_exists( $value, '__toString' ) ) {
+ $value = "object(" . get_class( $value ) . ")";
+ }
+
+ $flatValue = (string)$value;
+ if ( mb_strlen( $value ) > 1024 ) {
+ $flatValue = "string(" . mb_strlen( $value ) . ")";
+ }
+
+ $paramString .= "$key={$flatValue}";
+ }
+ }
+
+ $metaString = '';
+ foreach ( $this->metadata as $key => $value ) {
+ if ( is_scalar( $value ) && mb_strlen( $value ) < 1024 ) {
+ $metaString .= ( $metaString ? ",$key=$value" : "$key=$value" );
+ }
+ }
+
+ $s = $this->command;
+ if ( is_object( $this->title ) ) {
+ $s .= " {$this->title->getPrefixedDBkey()}";
+ }
+ if ( $paramString != '' ) {
+ $s .= " $paramString";
+ }
+ if ( $metaString != '' ) {
+ $s .= " ($metaString)";
+ }
+
+ return $s;
+ }
+
+ protected function setLastError( $error ) {
+ $this->error = $error;
+ }
+
+ public function getLastError() {
+ return $this->error;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/JobQueue.php b/www/wiki/includes/jobqueue/JobQueue.php
new file mode 100644
index 00000000..1f4f179a
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobQueue.php
@@ -0,0 +1,731 @@
+<?php
+/**
+ * Job queue base code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @defgroup JobQueue JobQueue
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Class to handle enqueueing and running of background jobs
+ *
+ * @ingroup JobQueue
+ * @since 1.21
+ */
+abstract class JobQueue {
+ /** @var string Wiki ID */
+ protected $wiki;
+ /** @var string Job type */
+ protected $type;
+ /** @var string Job priority for pop() */
+ protected $order;
+ /** @var int Time to live in seconds */
+ protected $claimTTL;
+ /** @var int Maximum number of times to try a job */
+ protected $maxTries;
+ /** @var string|bool Read only rationale (or false if r/w) */
+ protected $readOnlyReason;
+
+ /** @var BagOStuff */
+ protected $dupCache;
+ /** @var JobQueueAggregator */
+ protected $aggr;
+
+ const QOS_ATOMIC = 1; // integer; "all-or-nothing" job insertions
+
+ const ROOTJOB_TTL = 2419200; // integer; seconds to remember root jobs (28 days)
+
+ /**
+ * @param array $params
+ * @throws MWException
+ */
+ protected function __construct( array $params ) {
+ $this->wiki = $params['wiki'];
+ $this->type = $params['type'];
+ $this->claimTTL = isset( $params['claimTTL'] ) ? $params['claimTTL'] : 0;
+ $this->maxTries = isset( $params['maxTries'] ) ? $params['maxTries'] : 3;
+ if ( isset( $params['order'] ) && $params['order'] !== 'any' ) {
+ $this->order = $params['order'];
+ } else {
+ $this->order = $this->optimalOrder();
+ }
+ if ( !in_array( $this->order, $this->supportedOrders() ) ) {
+ throw new MWException( __CLASS__ . " does not support '{$this->order}' order." );
+ }
+ $this->dupCache = wfGetCache( CACHE_ANYTHING );
+ $this->aggr = isset( $params['aggregator'] )
+ ? $params['aggregator']
+ : new JobQueueAggregatorNull( [] );
+ $this->readOnlyReason = isset( $params['readOnlyReason'] )
+ ? $params['readOnlyReason']
+ : false;
+ }
+
+ /**
+ * Get a job queue object of the specified type.
+ * $params includes:
+ * - class : What job class to use (determines job type)
+ * - wiki : wiki ID of the wiki the jobs are for (defaults to current wiki)
+ * - type : The name of the job types this queue handles
+ * - order : Order that pop() selects jobs, one of "fifo", "timestamp" or "random".
+ * If "fifo" is used, the queue will effectively be FIFO. Note that job
+ * completion will not appear to be exactly FIFO if there are multiple
+ * job runners since jobs can take different times to finish once popped.
+ * If "timestamp" is used, the queue will at least be loosely ordered
+ * by timestamp, allowing for some jobs to be popped off out of order.
+ * If "random" is used, pop() will pick jobs in random order.
+ * Note that it may only be weakly random (e.g. a lottery of the oldest X).
+ * If "any" is choosen, the queue will use whatever order is the fastest.
+ * This might be useful for improving concurrency for job acquisition.
+ * - claimTTL : If supported, the queue will recycle jobs that have been popped
+ * but not acknowledged as completed after this many seconds. Recycling
+ * of jobs simply means re-inserting them into the queue. Jobs can be
+ * attempted up to three times before being discarded.
+ * - readOnlyReason : Set this to a string to make the queue read-only.
+ *
+ * Queue classes should throw an exception if they do not support the options given.
+ *
+ * @param array $params
+ * @return JobQueue
+ * @throws MWException
+ */
+ final public static function factory( array $params ) {
+ $class = $params['class'];
+ if ( !class_exists( $class ) ) {
+ throw new MWException( "Invalid job queue class '$class'." );
+ }
+ $obj = new $class( $params );
+ if ( !( $obj instanceof self ) ) {
+ throw new MWException( "Class '$class' is not a " . __CLASS__ . " class." );
+ }
+
+ return $obj;
+ }
+
+ /**
+ * @return string Wiki ID
+ */
+ final public function getWiki() {
+ return $this->wiki;
+ }
+
+ /**
+ * @return string Job type that this queue handles
+ */
+ final public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * @return string One of (random, timestamp, fifo, undefined)
+ */
+ final public function getOrder() {
+ return $this->order;
+ }
+
+ /**
+ * Get the allowed queue orders for configuration validation
+ *
+ * @return array Subset of (random, timestamp, fifo, undefined)
+ */
+ abstract protected function supportedOrders();
+
+ /**
+ * Get the default queue order to use if configuration does not specify one
+ *
+ * @return string One of (random, timestamp, fifo, undefined)
+ */
+ abstract protected function optimalOrder();
+
+ /**
+ * Find out if delayed jobs are supported for configuration validation
+ *
+ * @return bool Whether delayed jobs are supported
+ */
+ protected function supportsDelayedJobs() {
+ return false; // not implemented
+ }
+
+ /**
+ * @return bool Whether delayed jobs are enabled
+ * @since 1.22
+ */
+ final public function delayedJobsEnabled() {
+ return $this->supportsDelayedJobs();
+ }
+
+ /**
+ * @return string|bool Read-only rational or false if r/w
+ * @since 1.27
+ */
+ public function getReadOnlyReason() {
+ return $this->readOnlyReason;
+ }
+
+ /**
+ * Quickly check if the queue has no available (unacquired, non-delayed) jobs.
+ * Queue classes should use caching if they are any slower without memcached.
+ *
+ * If caching is used, this might return false when there are actually no jobs.
+ * If pop() is called and returns false then it should correct the cache. Also,
+ * calling flushCaches() first prevents this. However, this affect is typically
+ * not distinguishable from the race condition between isEmpty() and pop().
+ *
+ * @return bool
+ * @throws JobQueueError
+ */
+ final public function isEmpty() {
+ $res = $this->doIsEmpty();
+
+ return $res;
+ }
+
+ /**
+ * @see JobQueue::isEmpty()
+ * @return bool
+ */
+ abstract protected function doIsEmpty();
+
+ /**
+ * Get the number of available (unacquired, non-delayed) jobs in the queue.
+ * Queue classes should use caching if they are any slower without memcached.
+ *
+ * If caching is used, this number might be out of date for a minute.
+ *
+ * @return int
+ * @throws JobQueueError
+ */
+ final public function getSize() {
+ $res = $this->doGetSize();
+
+ return $res;
+ }
+
+ /**
+ * @see JobQueue::getSize()
+ * @return int
+ */
+ abstract protected function doGetSize();
+
+ /**
+ * Get the number of acquired jobs (these are temporarily out of the queue).
+ * Queue classes should use caching if they are any slower without memcached.
+ *
+ * If caching is used, this number might be out of date for a minute.
+ *
+ * @return int
+ * @throws JobQueueError
+ */
+ final public function getAcquiredCount() {
+ $res = $this->doGetAcquiredCount();
+
+ return $res;
+ }
+
+ /**
+ * @see JobQueue::getAcquiredCount()
+ * @return int
+ */
+ abstract protected function doGetAcquiredCount();
+
+ /**
+ * Get the number of delayed jobs (these are temporarily out of the queue).
+ * Queue classes should use caching if they are any slower without memcached.
+ *
+ * If caching is used, this number might be out of date for a minute.
+ *
+ * @return int
+ * @throws JobQueueError
+ * @since 1.22
+ */
+ final public function getDelayedCount() {
+ $res = $this->doGetDelayedCount();
+
+ return $res;
+ }
+
+ /**
+ * @see JobQueue::getDelayedCount()
+ * @return int
+ */
+ protected function doGetDelayedCount() {
+ return 0; // not implemented
+ }
+
+ /**
+ * Get the number of acquired jobs that can no longer be attempted.
+ * Queue classes should use caching if they are any slower without memcached.
+ *
+ * If caching is used, this number might be out of date for a minute.
+ *
+ * @return int
+ * @throws JobQueueError
+ */
+ final public function getAbandonedCount() {
+ $res = $this->doGetAbandonedCount();
+
+ return $res;
+ }
+
+ /**
+ * @see JobQueue::getAbandonedCount()
+ * @return int
+ */
+ protected function doGetAbandonedCount() {
+ return 0; // not implemented
+ }
+
+ /**
+ * Push one or more jobs into the queue.
+ * This does not require $wgJobClasses to be set for the given job type.
+ * Outside callers should use JobQueueGroup::push() instead of this function.
+ *
+ * @param IJobSpecification|IJobSpecification[] $jobs
+ * @param int $flags Bitfield (supports JobQueue::QOS_ATOMIC)
+ * @return void
+ * @throws JobQueueError
+ */
+ final public function push( $jobs, $flags = 0 ) {
+ $jobs = is_array( $jobs ) ? $jobs : [ $jobs ];
+ $this->batchPush( $jobs, $flags );
+ }
+
+ /**
+ * Push a batch of jobs into the queue.
+ * This does not require $wgJobClasses to be set for the given job type.
+ * Outside callers should use JobQueueGroup::push() instead of this function.
+ *
+ * @param IJobSpecification[] $jobs
+ * @param int $flags Bitfield (supports JobQueue::QOS_ATOMIC)
+ * @return void
+ * @throws MWException
+ */
+ final public function batchPush( array $jobs, $flags = 0 ) {
+ $this->assertNotReadOnly();
+
+ if ( !count( $jobs ) ) {
+ return; // nothing to do
+ }
+
+ foreach ( $jobs as $job ) {
+ if ( $job->getType() !== $this->type ) {
+ throw new MWException(
+ "Got '{$job->getType()}' job; expected a '{$this->type}' job." );
+ } elseif ( $job->getReleaseTimestamp() && !$this->supportsDelayedJobs() ) {
+ throw new MWException(
+ "Got delayed '{$job->getType()}' job; delays are not supported." );
+ }
+ }
+
+ $this->doBatchPush( $jobs, $flags );
+ $this->aggr->notifyQueueNonEmpty( $this->wiki, $this->type );
+
+ foreach ( $jobs as $job ) {
+ if ( $job->isRootJob() ) {
+ $this->deduplicateRootJob( $job );
+ }
+ }
+ }
+
+ /**
+ * @see JobQueue::batchPush()
+ * @param IJobSpecification[] $jobs
+ * @param int $flags
+ */
+ abstract protected function doBatchPush( array $jobs, $flags );
+
+ /**
+ * Pop a job off of the queue.
+ * This requires $wgJobClasses to be set for the given job type.
+ * Outside callers should use JobQueueGroup::pop() instead of this function.
+ *
+ * @throws MWException
+ * @return Job|bool Returns false if there are no jobs
+ */
+ final public function pop() {
+ global $wgJobClasses;
+
+ $this->assertNotReadOnly();
+ if ( $this->wiki !== wfWikiID() ) {
+ throw new MWException( "Cannot pop '{$this->type}' job off foreign wiki queue." );
+ } elseif ( !isset( $wgJobClasses[$this->type] ) ) {
+ // Do not pop jobs if there is no class for the queue type
+ throw new MWException( "Unrecognized job type '{$this->type}'." );
+ }
+
+ $job = $this->doPop();
+
+ if ( !$job ) {
+ $this->aggr->notifyQueueEmpty( $this->wiki, $this->type );
+ }
+
+ // Flag this job as an old duplicate based on its "root" job...
+ try {
+ if ( $job && $this->isRootJobOldDuplicate( $job ) ) {
+ self::incrStats( 'dupe_pops', $this->type );
+ $job = DuplicateJob::newFromJob( $job ); // convert to a no-op
+ }
+ } catch ( Exception $e ) {
+ // don't lose jobs over this
+ }
+
+ return $job;
+ }
+
+ /**
+ * @see JobQueue::pop()
+ * @return Job|bool
+ */
+ abstract protected function doPop();
+
+ /**
+ * Acknowledge that a job was completed.
+ *
+ * This does nothing for certain queue classes or if "claimTTL" is not set.
+ * Outside callers should use JobQueueGroup::ack() instead of this function.
+ *
+ * @param Job $job
+ * @return void
+ * @throws MWException
+ */
+ final public function ack( Job $job ) {
+ $this->assertNotReadOnly();
+ if ( $job->getType() !== $this->type ) {
+ throw new MWException( "Got '{$job->getType()}' job; expected '{$this->type}'." );
+ }
+
+ $this->doAck( $job );
+ }
+
+ /**
+ * @see JobQueue::ack()
+ * @param Job $job
+ */
+ abstract protected function doAck( Job $job );
+
+ /**
+ * Register the "root job" of a given job into the queue for de-duplication.
+ * This should only be called right *after* all the new jobs have been inserted.
+ * This is used to turn older, duplicate, job entries into no-ops. The root job
+ * information will remain in the registry until it simply falls out of cache.
+ *
+ * This requires that $job has two special fields in the "params" array:
+ * - rootJobSignature : hash (e.g. SHA1) that identifies the task
+ * - rootJobTimestamp : TS_MW timestamp of this instance of the task
+ *
+ * A "root job" is a conceptual job that consist of potentially many smaller jobs
+ * that are actually inserted into the queue. For example, "refreshLinks" jobs are
+ * spawned when a template is edited. One can think of the task as "update links
+ * of pages that use template X" and an instance of that task as a "root job".
+ * However, what actually goes into the queue are range and leaf job subtypes.
+ * Since these jobs include things like page ID ranges and DB master positions,
+ * and can morph into smaller jobs recursively, simple duplicate detection
+ * for individual jobs being identical (like that of job_sha1) is not useful.
+ *
+ * In the case of "refreshLinks", if these jobs are still in the queue when the template
+ * is edited again, we want all of these old refreshLinks jobs for that template to become
+ * no-ops. This can greatly reduce server load, since refreshLinks jobs involves parsing.
+ * Essentially, the new batch of jobs belong to a new "root job" and the older ones to a
+ * previous "root job" for the same task of "update links of pages that use template X".
+ *
+ * This does nothing for certain queue classes.
+ *
+ * @param IJobSpecification $job
+ * @throws MWException
+ * @return bool
+ */
+ final public function deduplicateRootJob( IJobSpecification $job ) {
+ $this->assertNotReadOnly();
+ if ( $job->getType() !== $this->type ) {
+ throw new MWException( "Got '{$job->getType()}' job; expected '{$this->type}'." );
+ }
+
+ return $this->doDeduplicateRootJob( $job );
+ }
+
+ /**
+ * @see JobQueue::deduplicateRootJob()
+ * @param IJobSpecification $job
+ * @throws MWException
+ * @return bool
+ */
+ protected function doDeduplicateRootJob( IJobSpecification $job ) {
+ if ( !$job->hasRootJobParams() ) {
+ throw new MWException( "Cannot register root job; missing parameters." );
+ }
+ $params = $job->getRootJobParams();
+
+ $key = $this->getRootJobCacheKey( $params['rootJobSignature'] );
+ // Callers should call batchInsert() and then this function so that if the insert
+ // fails, the de-duplication registration will be aborted. Since the insert is
+ // deferred till "transaction idle", do the same here, so that the ordering is
+ // maintained. Having only the de-duplication registration succeed would cause
+ // jobs to become no-ops without any actual jobs that made them redundant.
+ $timestamp = $this->dupCache->get( $key ); // current last timestamp of this job
+ if ( $timestamp && $timestamp >= $params['rootJobTimestamp'] ) {
+ return true; // a newer version of this root job was enqueued
+ }
+
+ // Update the timestamp of the last root job started at the location...
+ return $this->dupCache->set( $key, $params['rootJobTimestamp'], JobQueueDB::ROOTJOB_TTL );
+ }
+
+ /**
+ * Check if the "root" job of a given job has been superseded by a newer one
+ *
+ * @param Job $job
+ * @throws MWException
+ * @return bool
+ */
+ final protected function isRootJobOldDuplicate( Job $job ) {
+ if ( $job->getType() !== $this->type ) {
+ throw new MWException( "Got '{$job->getType()}' job; expected '{$this->type}'." );
+ }
+ $isDuplicate = $this->doIsRootJobOldDuplicate( $job );
+
+ return $isDuplicate;
+ }
+
+ /**
+ * @see JobQueue::isRootJobOldDuplicate()
+ * @param Job $job
+ * @return bool
+ */
+ protected function doIsRootJobOldDuplicate( Job $job ) {
+ if ( !$job->hasRootJobParams() ) {
+ return false; // job has no de-deplication info
+ }
+ $params = $job->getRootJobParams();
+
+ $key = $this->getRootJobCacheKey( $params['rootJobSignature'] );
+ // Get the last time this root job was enqueued
+ $timestamp = $this->dupCache->get( $key );
+
+ // Check if a new root job was started at the location after this one's...
+ return ( $timestamp && $timestamp > $params['rootJobTimestamp'] );
+ }
+
+ /**
+ * @param string $signature Hash identifier of the root job
+ * @return string
+ */
+ protected function getRootJobCacheKey( $signature ) {
+ list( $db, $prefix ) = wfSplitWikiID( $this->wiki );
+
+ return wfForeignMemcKey( $db, $prefix, 'jobqueue', $this->type, 'rootjob', $signature );
+ }
+
+ /**
+ * Deleted all unclaimed and delayed jobs from the queue
+ *
+ * @throws JobQueueError
+ * @since 1.22
+ * @return void
+ */
+ final public function delete() {
+ $this->assertNotReadOnly();
+
+ $this->doDelete();
+ }
+
+ /**
+ * @see JobQueue::delete()
+ * @throws MWException
+ */
+ protected function doDelete() {
+ throw new MWException( "This method is not implemented." );
+ }
+
+ /**
+ * Wait for any replica DBs or backup servers to catch up.
+ *
+ * This does nothing for certain queue classes.
+ *
+ * @return void
+ * @throws JobQueueError
+ */
+ final public function waitForBackups() {
+ $this->doWaitForBackups();
+ }
+
+ /**
+ * @see JobQueue::waitForBackups()
+ * @return void
+ */
+ protected function doWaitForBackups() {
+ }
+
+ /**
+ * Clear any process and persistent caches
+ *
+ * @return void
+ */
+ final public function flushCaches() {
+ $this->doFlushCaches();
+ }
+
+ /**
+ * @see JobQueue::flushCaches()
+ * @return void
+ */
+ protected function doFlushCaches() {
+ }
+
+ /**
+ * Get an iterator to traverse over all available jobs in this queue.
+ * This does not include jobs that are currently acquired or delayed.
+ * Note: results may be stale if the queue is concurrently modified.
+ *
+ * @return Iterator
+ * @throws JobQueueError
+ */
+ abstract public function getAllQueuedJobs();
+
+ /**
+ * Get an iterator to traverse over all delayed jobs in this queue.
+ * Note: results may be stale if the queue is concurrently modified.
+ *
+ * @return Iterator
+ * @throws JobQueueError
+ * @since 1.22
+ */
+ public function getAllDelayedJobs() {
+ return new ArrayIterator( [] ); // not implemented
+ }
+
+ /**
+ * Get an iterator to traverse over all claimed jobs in this queue
+ *
+ * Callers should be quick to iterator over it or few results
+ * will be returned due to jobs being acknowledged and deleted
+ *
+ * @return Iterator
+ * @throws JobQueueError
+ * @since 1.26
+ */
+ public function getAllAcquiredJobs() {
+ return new ArrayIterator( [] ); // not implemented
+ }
+
+ /**
+ * Get an iterator to traverse over all abandoned jobs in this queue
+ *
+ * @return Iterator
+ * @throws JobQueueError
+ * @since 1.25
+ */
+ public function getAllAbandonedJobs() {
+ return new ArrayIterator( [] ); // not implemented
+ }
+
+ /**
+ * Do not use this function outside of JobQueue/JobQueueGroup
+ *
+ * @return string
+ * @since 1.22
+ */
+ public function getCoalesceLocationInternal() {
+ return null;
+ }
+
+ /**
+ * Check whether each of the given queues are empty.
+ * This is used for batching checks for queues stored at the same place.
+ *
+ * @param array $types List of queues types
+ * @return array|null (list of non-empty queue types) or null if unsupported
+ * @throws MWException
+ * @since 1.22
+ */
+ final public function getSiblingQueuesWithJobs( array $types ) {
+ return $this->doGetSiblingQueuesWithJobs( $types );
+ }
+
+ /**
+ * @see JobQueue::getSiblingQueuesWithJobs()
+ * @param array $types List of queues types
+ * @return array|null (list of queue types) or null if unsupported
+ */
+ protected function doGetSiblingQueuesWithJobs( array $types ) {
+ return null; // not supported
+ }
+
+ /**
+ * Check the size of each of the given queues.
+ * For queues not served by the same store as this one, 0 is returned.
+ * This is used for batching checks for queues stored at the same place.
+ *
+ * @param array $types List of queues types
+ * @return array|null (job type => whether queue is empty) or null if unsupported
+ * @throws MWException
+ * @since 1.22
+ */
+ final public function getSiblingQueueSizes( array $types ) {
+ return $this->doGetSiblingQueueSizes( $types );
+ }
+
+ /**
+ * @see JobQueue::getSiblingQueuesSize()
+ * @param array $types List of queues types
+ * @return array|null (list of queue types) or null if unsupported
+ */
+ protected function doGetSiblingQueueSizes( array $types ) {
+ return null; // not supported
+ }
+
+ /**
+ * @throws JobQueueReadOnlyError
+ */
+ protected function assertNotReadOnly() {
+ if ( $this->readOnlyReason !== false ) {
+ throw new JobQueueReadOnlyError( "Job queue is read-only: {$this->readOnlyReason}" );
+ }
+ }
+
+ /**
+ * Call wfIncrStats() for the queue overall and for the queue type
+ *
+ * @param string $key Event type
+ * @param string $type Job type
+ * @param int $delta
+ * @since 1.22
+ */
+ public static function incrStats( $key, $type, $delta = 1 ) {
+ static $stats;
+ if ( !$stats ) {
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ }
+ $stats->updateCount( "jobqueue.{$key}.all", $delta );
+ $stats->updateCount( "jobqueue.{$key}.{$type}", $delta );
+ }
+}
+
+/**
+ * @ingroup JobQueue
+ * @since 1.22
+ */
+class JobQueueError extends MWException {
+}
+
+class JobQueueConnectionError extends JobQueueError {
+}
+
+class JobQueueReadOnlyError extends JobQueueError {
+
+}
diff --git a/www/wiki/includes/jobqueue/JobQueueDB.php b/www/wiki/includes/jobqueue/JobQueueDB.php
new file mode 100644
index 00000000..b68fdaef
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobQueueDB.php
@@ -0,0 +1,851 @@
+<?php
+/**
+ * Database-backed job queue code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBConnRef;
+use Wikimedia\Rdbms\DBConnectionError;
+use Wikimedia\Rdbms\DBError;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Class to handle job queues stored in the DB
+ *
+ * @ingroup JobQueue
+ * @since 1.21
+ */
+class JobQueueDB extends JobQueue {
+ const CACHE_TTL_SHORT = 30; // integer; seconds to cache info without re-validating
+ const MAX_AGE_PRUNE = 604800; // integer; seconds a job can live once claimed
+ const MAX_JOB_RANDOM = 2147483647; // integer; 2^31 - 1, used for job_random
+ const MAX_OFFSET = 255; // integer; maximum number of rows to skip
+
+ /** @var WANObjectCache */
+ protected $cache;
+
+ /** @var bool|string Name of an external DB cluster. False if not set */
+ protected $cluster = false;
+
+ /**
+ * Additional parameters include:
+ * - cluster : The name of an external cluster registered via LBFactory.
+ * If not specified, the primary DB cluster for the wiki will be used.
+ * This can be overridden with a custom cluster so that DB handles will
+ * be retrieved via LBFactory::getExternalLB() and getConnection().
+ * @param array $params
+ */
+ protected function __construct( array $params ) {
+ parent::__construct( $params );
+
+ $this->cluster = isset( $params['cluster'] ) ? $params['cluster'] : false;
+ $this->cache = ObjectCache::getMainWANInstance();
+ }
+
+ protected function supportedOrders() {
+ return [ 'random', 'timestamp', 'fifo' ];
+ }
+
+ protected function optimalOrder() {
+ return 'random';
+ }
+
+ /**
+ * @see JobQueue::doIsEmpty()
+ * @return bool
+ */
+ protected function doIsEmpty() {
+ $dbr = $this->getReplicaDB();
+ try {
+ $found = $dbr->selectField( // unclaimed job
+ 'job', '1', [ 'job_cmd' => $this->type, 'job_token' => '' ], __METHOD__
+ );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+
+ return !$found;
+ }
+
+ /**
+ * @see JobQueue::doGetSize()
+ * @return int
+ */
+ protected function doGetSize() {
+ $key = $this->getCacheKey( 'size' );
+
+ $size = $this->cache->get( $key );
+ if ( is_int( $size ) ) {
+ return $size;
+ }
+
+ try {
+ $dbr = $this->getReplicaDB();
+ $size = (int)$dbr->selectField( 'job', 'COUNT(*)',
+ [ 'job_cmd' => $this->type, 'job_token' => '' ],
+ __METHOD__
+ );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+ $this->cache->set( $key, $size, self::CACHE_TTL_SHORT );
+
+ return $size;
+ }
+
+ /**
+ * @see JobQueue::doGetAcquiredCount()
+ * @return int
+ */
+ protected function doGetAcquiredCount() {
+ if ( $this->claimTTL <= 0 ) {
+ return 0; // no acknowledgements
+ }
+
+ $key = $this->getCacheKey( 'acquiredcount' );
+
+ $count = $this->cache->get( $key );
+ if ( is_int( $count ) ) {
+ return $count;
+ }
+
+ $dbr = $this->getReplicaDB();
+ try {
+ $count = (int)$dbr->selectField( 'job', 'COUNT(*)',
+ [ 'job_cmd' => $this->type, "job_token != {$dbr->addQuotes( '' )}" ],
+ __METHOD__
+ );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+ $this->cache->set( $key, $count, self::CACHE_TTL_SHORT );
+
+ return $count;
+ }
+
+ /**
+ * @see JobQueue::doGetAbandonedCount()
+ * @return int
+ * @throws MWException
+ */
+ protected function doGetAbandonedCount() {
+ if ( $this->claimTTL <= 0 ) {
+ return 0; // no acknowledgements
+ }
+
+ $key = $this->getCacheKey( 'abandonedcount' );
+
+ $count = $this->cache->get( $key );
+ if ( is_int( $count ) ) {
+ return $count;
+ }
+
+ $dbr = $this->getReplicaDB();
+ try {
+ $count = (int)$dbr->selectField( 'job', 'COUNT(*)',
+ [
+ 'job_cmd' => $this->type,
+ "job_token != {$dbr->addQuotes( '' )}",
+ "job_attempts >= " . $dbr->addQuotes( $this->maxTries )
+ ],
+ __METHOD__
+ );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+
+ $this->cache->set( $key, $count, self::CACHE_TTL_SHORT );
+
+ return $count;
+ }
+
+ /**
+ * @see JobQueue::doBatchPush()
+ * @param IJobSpecification[] $jobs
+ * @param int $flags
+ * @throws DBError|Exception
+ * @return void
+ */
+ protected function doBatchPush( array $jobs, $flags ) {
+ $dbw = $this->getMasterDB();
+ // In general, there will be two cases here:
+ // a) sqlite; DB connection is probably a regular round-aware handle.
+ // If the connection is busy with a transaction, then defer the job writes
+ // until right before the main round commit step. Any errors that bubble
+ // up will rollback the main commit round.
+ // b) mysql/postgres; DB connection is generally a separate CONN_TRX_AUTO handle.
+ // No transaction is active nor will be started by writes, so enqueue the jobs
+ // now so that any errors will show up immediately as the interface expects. Any
+ // errors that bubble up will rollback the main commit round.
+ $fname = __METHOD__;
+ $dbw->onTransactionPreCommitOrIdle(
+ function () use ( $dbw, $jobs, $flags, $fname ) {
+ $this->doBatchPushInternal( $dbw, $jobs, $flags, $fname );
+ },
+ $fname
+ );
+ }
+
+ /**
+ * This function should *not* be called outside of JobQueueDB
+ *
+ * @param IDatabase $dbw
+ * @param IJobSpecification[] $jobs
+ * @param int $flags
+ * @param string $method
+ * @throws DBError
+ * @return void
+ */
+ public function doBatchPushInternal( IDatabase $dbw, array $jobs, $flags, $method ) {
+ if ( !count( $jobs ) ) {
+ return;
+ }
+
+ $rowSet = []; // (sha1 => job) map for jobs that are de-duplicated
+ $rowList = []; // list of jobs for jobs that are not de-duplicated
+ foreach ( $jobs as $job ) {
+ $row = $this->insertFields( $job );
+ if ( $job->ignoreDuplicates() ) {
+ $rowSet[$row['job_sha1']] = $row;
+ } else {
+ $rowList[] = $row;
+ }
+ }
+
+ if ( $flags & self::QOS_ATOMIC ) {
+ $dbw->startAtomic( $method ); // wrap all the job additions in one transaction
+ }
+ try {
+ // Strip out any duplicate jobs that are already in the queue...
+ if ( count( $rowSet ) ) {
+ $res = $dbw->select( 'job', 'job_sha1',
+ [
+ // No job_type condition since it's part of the job_sha1 hash
+ 'job_sha1' => array_keys( $rowSet ),
+ 'job_token' => '' // unclaimed
+ ],
+ $method
+ );
+ foreach ( $res as $row ) {
+ wfDebug( "Job with hash '{$row->job_sha1}' is a duplicate.\n" );
+ unset( $rowSet[$row->job_sha1] ); // already enqueued
+ }
+ }
+ // Build the full list of job rows to insert
+ $rows = array_merge( $rowList, array_values( $rowSet ) );
+ // Insert the job rows in chunks to avoid replica DB lag...
+ foreach ( array_chunk( $rows, 50 ) as $rowBatch ) {
+ $dbw->insert( 'job', $rowBatch, $method );
+ }
+ JobQueue::incrStats( 'inserts', $this->type, count( $rows ) );
+ JobQueue::incrStats( 'dupe_inserts', $this->type,
+ count( $rowSet ) + count( $rowList ) - count( $rows )
+ );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+ if ( $flags & self::QOS_ATOMIC ) {
+ $dbw->endAtomic( $method );
+ }
+
+ return;
+ }
+
+ /**
+ * @see JobQueue::doPop()
+ * @return Job|bool
+ */
+ protected function doPop() {
+ $dbw = $this->getMasterDB();
+ try {
+ $autoTrx = $dbw->getFlag( DBO_TRX ); // get current setting
+ $dbw->clearFlag( DBO_TRX ); // make each query its own transaction
+ $scopedReset = new ScopedCallback( function () use ( $dbw, $autoTrx ) {
+ $dbw->setFlag( $autoTrx ? DBO_TRX : 0 ); // restore old setting
+ } );
+
+ $uuid = wfRandomString( 32 ); // pop attempt
+ $job = false; // job popped off
+ do { // retry when our row is invalid or deleted as a duplicate
+ // Try to reserve a row in the DB...
+ if ( in_array( $this->order, [ 'fifo', 'timestamp' ] ) ) {
+ $row = $this->claimOldest( $uuid );
+ } else { // random first
+ $rand = mt_rand( 0, self::MAX_JOB_RANDOM ); // encourage concurrent UPDATEs
+ $gte = (bool)mt_rand( 0, 1 ); // find rows with rand before/after $rand
+ $row = $this->claimRandom( $uuid, $rand, $gte );
+ }
+ // Check if we found a row to reserve...
+ if ( !$row ) {
+ break; // nothing to do
+ }
+ JobQueue::incrStats( 'pops', $this->type );
+ // Get the job object from the row...
+ $title = Title::makeTitle( $row->job_namespace, $row->job_title );
+ $job = Job::factory( $row->job_cmd, $title,
+ self::extractBlob( $row->job_params ), $row->job_id );
+ $job->metadata['id'] = $row->job_id;
+ $job->metadata['timestamp'] = $row->job_timestamp;
+ break; // done
+ } while ( true );
+
+ if ( !$job || mt_rand( 0, 9 ) == 0 ) {
+ // Handled jobs that need to be recycled/deleted;
+ // any recycled jobs will be picked up next attempt
+ $this->recycleAndDeleteStaleJobs();
+ }
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+
+ return $job;
+ }
+
+ /**
+ * Reserve a row with a single UPDATE without holding row locks over RTTs...
+ *
+ * @param string $uuid 32 char hex string
+ * @param int $rand Random unsigned integer (31 bits)
+ * @param bool $gte Search for job_random >= $random (otherwise job_random <= $random)
+ * @return stdClass|bool Row|false
+ */
+ protected function claimRandom( $uuid, $rand, $gte ) {
+ $dbw = $this->getMasterDB();
+ // Check cache to see if the queue has <= OFFSET items
+ $tinyQueue = $this->cache->get( $this->getCacheKey( 'small' ) );
+
+ $row = false; // the row acquired
+ $invertedDirection = false; // whether one job_random direction was already scanned
+ // This uses a replication safe method for acquiring jobs. One could use UPDATE+LIMIT
+ // instead, but that either uses ORDER BY (in which case it deadlocks in MySQL) or is
+ // not replication safe. Due to https://bugs.mysql.com/bug.php?id=6980, subqueries cannot
+ // be used here with MySQL.
+ do {
+ if ( $tinyQueue ) { // queue has <= MAX_OFFSET rows
+ // For small queues, using OFFSET will overshoot and return no rows more often.
+ // Instead, this uses job_random to pick a row (possibly checking both directions).
+ $ineq = $gte ? '>=' : '<=';
+ $dir = $gte ? 'ASC' : 'DESC';
+ $row = $dbw->selectRow( 'job', self::selectFields(), // find a random job
+ [
+ 'job_cmd' => $this->type,
+ 'job_token' => '', // unclaimed
+ "job_random {$ineq} {$dbw->addQuotes( $rand )}" ],
+ __METHOD__,
+ [ 'ORDER BY' => "job_random {$dir}" ]
+ );
+ if ( !$row && !$invertedDirection ) {
+ $gte = !$gte;
+ $invertedDirection = true;
+ continue; // try the other direction
+ }
+ } else { // table *may* have >= MAX_OFFSET rows
+ // T44614: "ORDER BY job_random" with a job_random inequality causes high CPU
+ // in MySQL if there are many rows for some reason. This uses a small OFFSET
+ // instead of job_random for reducing excess claim retries.
+ $row = $dbw->selectRow( 'job', self::selectFields(), // find a random job
+ [
+ 'job_cmd' => $this->type,
+ 'job_token' => '', // unclaimed
+ ],
+ __METHOD__,
+ [ 'OFFSET' => mt_rand( 0, self::MAX_OFFSET ) ]
+ );
+ if ( !$row ) {
+ $tinyQueue = true; // we know the queue must have <= MAX_OFFSET rows
+ $this->cache->set( $this->getCacheKey( 'small' ), 1, 30 );
+ continue; // use job_random
+ }
+ }
+
+ if ( $row ) { // claim the job
+ $dbw->update( 'job', // update by PK
+ [
+ 'job_token' => $uuid,
+ 'job_token_timestamp' => $dbw->timestamp(),
+ 'job_attempts = job_attempts+1' ],
+ [ 'job_cmd' => $this->type, 'job_id' => $row->job_id, 'job_token' => '' ],
+ __METHOD__
+ );
+ // This might get raced out by another runner when claiming the previously
+ // selected row. The use of job_random should minimize this problem, however.
+ if ( !$dbw->affectedRows() ) {
+ $row = false; // raced out
+ }
+ } else {
+ break; // nothing to do
+ }
+ } while ( !$row );
+
+ return $row;
+ }
+
+ /**
+ * Reserve a row with a single UPDATE without holding row locks over RTTs...
+ *
+ * @param string $uuid 32 char hex string
+ * @return stdClass|bool Row|false
+ */
+ protected function claimOldest( $uuid ) {
+ $dbw = $this->getMasterDB();
+
+ $row = false; // the row acquired
+ do {
+ if ( $dbw->getType() === 'mysql' ) {
+ // Per https://bugs.mysql.com/bug.php?id=6980, we can't use subqueries on the
+ // same table being changed in an UPDATE query in MySQL (gives Error: 1093).
+ // Oracle and Postgre have no such limitation. However, MySQL offers an
+ // alternative here by supporting ORDER BY + LIMIT for UPDATE queries.
+ $dbw->query( "UPDATE {$dbw->tableName( 'job' )} " .
+ "SET " .
+ "job_token = {$dbw->addQuotes( $uuid ) }, " .
+ "job_token_timestamp = {$dbw->addQuotes( $dbw->timestamp() )}, " .
+ "job_attempts = job_attempts+1 " .
+ "WHERE ( " .
+ "job_cmd = {$dbw->addQuotes( $this->type )} " .
+ "AND job_token = {$dbw->addQuotes( '' )} " .
+ ") ORDER BY job_id ASC LIMIT 1",
+ __METHOD__
+ );
+ } else {
+ // Use a subquery to find the job, within an UPDATE to claim it.
+ // This uses as much of the DB wrapper functions as possible.
+ $dbw->update( 'job',
+ [
+ 'job_token' => $uuid,
+ 'job_token_timestamp' => $dbw->timestamp(),
+ 'job_attempts = job_attempts+1' ],
+ [ 'job_id = (' .
+ $dbw->selectSQLText( 'job', 'job_id',
+ [ 'job_cmd' => $this->type, 'job_token' => '' ],
+ __METHOD__,
+ [ 'ORDER BY' => 'job_id ASC', 'LIMIT' => 1 ] ) .
+ ')'
+ ],
+ __METHOD__
+ );
+ }
+ // Fetch any row that we just reserved...
+ if ( $dbw->affectedRows() ) {
+ $row = $dbw->selectRow( 'job', self::selectFields(),
+ [ 'job_cmd' => $this->type, 'job_token' => $uuid ], __METHOD__
+ );
+ if ( !$row ) { // raced out by duplicate job removal
+ wfDebug( "Row deleted as duplicate by another process.\n" );
+ }
+ } else {
+ break; // nothing to do
+ }
+ } while ( !$row );
+
+ return $row;
+ }
+
+ /**
+ * @see JobQueue::doAck()
+ * @param Job $job
+ * @throws MWException
+ */
+ protected function doAck( Job $job ) {
+ if ( !isset( $job->metadata['id'] ) ) {
+ throw new MWException( "Job of type '{$job->getType()}' has no ID." );
+ }
+
+ $dbw = $this->getMasterDB();
+ try {
+ $autoTrx = $dbw->getFlag( DBO_TRX ); // get current setting
+ $dbw->clearFlag( DBO_TRX ); // make each query its own transaction
+ $scopedReset = new ScopedCallback( function () use ( $dbw, $autoTrx ) {
+ $dbw->setFlag( $autoTrx ? DBO_TRX : 0 ); // restore old setting
+ } );
+
+ // Delete a row with a single DELETE without holding row locks over RTTs...
+ $dbw->delete( 'job',
+ [ 'job_cmd' => $this->type, 'job_id' => $job->metadata['id'] ], __METHOD__ );
+
+ JobQueue::incrStats( 'acks', $this->type );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+ }
+
+ /**
+ * @see JobQueue::doDeduplicateRootJob()
+ * @param IJobSpecification $job
+ * @throws MWException
+ * @return bool
+ */
+ protected function doDeduplicateRootJob( IJobSpecification $job ) {
+ $params = $job->getParams();
+ if ( !isset( $params['rootJobSignature'] ) ) {
+ throw new MWException( "Cannot register root job; missing 'rootJobSignature'." );
+ } elseif ( !isset( $params['rootJobTimestamp'] ) ) {
+ throw new MWException( "Cannot register root job; missing 'rootJobTimestamp'." );
+ }
+ $key = $this->getRootJobCacheKey( $params['rootJobSignature'] );
+ // Callers should call batchInsert() and then this function so that if the insert
+ // fails, the de-duplication registration will be aborted. Since the insert is
+ // deferred till "transaction idle", do the same here, so that the ordering is
+ // maintained. Having only the de-duplication registration succeed would cause
+ // jobs to become no-ops without any actual jobs that made them redundant.
+ $dbw = $this->getMasterDB();
+ $cache = $this->dupCache;
+ $dbw->onTransactionIdle(
+ function () use ( $cache, $params, $key, $dbw ) {
+ $timestamp = $cache->get( $key ); // current last timestamp of this job
+ if ( $timestamp && $timestamp >= $params['rootJobTimestamp'] ) {
+ return true; // a newer version of this root job was enqueued
+ }
+
+ // Update the timestamp of the last root job started at the location...
+ return $cache->set( $key, $params['rootJobTimestamp'], JobQueueDB::ROOTJOB_TTL );
+ },
+ __METHOD__
+ );
+
+ return true;
+ }
+
+ /**
+ * @see JobQueue::doDelete()
+ * @return bool
+ */
+ protected function doDelete() {
+ $dbw = $this->getMasterDB();
+ try {
+ $dbw->delete( 'job', [ 'job_cmd' => $this->type ] );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+
+ return true;
+ }
+
+ /**
+ * @see JobQueue::doWaitForBackups()
+ * @return void
+ */
+ protected function doWaitForBackups() {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->waitForReplication( [ 'wiki' => $this->wiki, 'cluster' => $this->cluster ] );
+ }
+
+ /**
+ * @return void
+ */
+ protected function doFlushCaches() {
+ foreach ( [ 'size', 'acquiredcount' ] as $type ) {
+ $this->cache->delete( $this->getCacheKey( $type ) );
+ }
+ }
+
+ /**
+ * @see JobQueue::getAllQueuedJobs()
+ * @return Iterator
+ */
+ public function getAllQueuedJobs() {
+ return $this->getJobIterator( [ 'job_cmd' => $this->getType(), 'job_token' => '' ] );
+ }
+
+ /**
+ * @see JobQueue::getAllAcquiredJobs()
+ * @return Iterator
+ */
+ public function getAllAcquiredJobs() {
+ return $this->getJobIterator( [ 'job_cmd' => $this->getType(), "job_token > ''" ] );
+ }
+
+ /**
+ * @param array $conds Query conditions
+ * @return Iterator
+ */
+ protected function getJobIterator( array $conds ) {
+ $dbr = $this->getReplicaDB();
+ try {
+ return new MappedIterator(
+ $dbr->select( 'job', self::selectFields(), $conds ),
+ function ( $row ) {
+ $job = Job::factory(
+ $row->job_cmd,
+ Title::makeTitle( $row->job_namespace, $row->job_title ),
+ strlen( $row->job_params ) ? unserialize( $row->job_params ) : []
+ );
+ $job->metadata['id'] = $row->job_id;
+ $job->metadata['timestamp'] = $row->job_timestamp;
+
+ return $job;
+ }
+ );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+ }
+
+ public function getCoalesceLocationInternal() {
+ return $this->cluster
+ ? "DBCluster:{$this->cluster}:{$this->wiki}"
+ : "LBFactory:{$this->wiki}";
+ }
+
+ protected function doGetSiblingQueuesWithJobs( array $types ) {
+ $dbr = $this->getReplicaDB();
+ // @note: this does not check whether the jobs are claimed or not.
+ // This is useful so JobQueueGroup::pop() also sees queues that only
+ // have stale jobs. This lets recycleAndDeleteStaleJobs() re-enqueue
+ // failed jobs so that they can be popped again for that edge case.
+ $res = $dbr->select( 'job', 'DISTINCT job_cmd',
+ [ 'job_cmd' => $types ], __METHOD__ );
+
+ $types = [];
+ foreach ( $res as $row ) {
+ $types[] = $row->job_cmd;
+ }
+
+ return $types;
+ }
+
+ protected function doGetSiblingQueueSizes( array $types ) {
+ $dbr = $this->getReplicaDB();
+ $res = $dbr->select( 'job', [ 'job_cmd', 'COUNT(*) AS count' ],
+ [ 'job_cmd' => $types ], __METHOD__, [ 'GROUP BY' => 'job_cmd' ] );
+
+ $sizes = [];
+ foreach ( $res as $row ) {
+ $sizes[$row->job_cmd] = (int)$row->count;
+ }
+
+ return $sizes;
+ }
+
+ /**
+ * Recycle or destroy any jobs that have been claimed for too long
+ *
+ * @return int Number of jobs recycled/deleted
+ */
+ public function recycleAndDeleteStaleJobs() {
+ $now = time();
+ $count = 0; // affected rows
+ $dbw = $this->getMasterDB();
+
+ try {
+ if ( !$dbw->lock( "jobqueue-recycle-{$this->type}", __METHOD__, 1 ) ) {
+ return $count; // already in progress
+ }
+
+ // Remove claims on jobs acquired for too long if enabled...
+ if ( $this->claimTTL > 0 ) {
+ $claimCutoff = $dbw->timestamp( $now - $this->claimTTL );
+ // Get the IDs of jobs that have be claimed but not finished after too long.
+ // These jobs can be recycled into the queue by expiring the claim. Selecting
+ // the IDs first means that the UPDATE can be done by primary key (less deadlocks).
+ $res = $dbw->select( 'job', 'job_id',
+ [
+ 'job_cmd' => $this->type,
+ "job_token != {$dbw->addQuotes( '' )}", // was acquired
+ "job_token_timestamp < {$dbw->addQuotes( $claimCutoff )}", // stale
+ "job_attempts < {$dbw->addQuotes( $this->maxTries )}" ], // retries left
+ __METHOD__
+ );
+ $ids = array_map(
+ function ( $o ) {
+ return $o->job_id;
+ }, iterator_to_array( $res )
+ );
+ if ( count( $ids ) ) {
+ // Reset job_token for these jobs so that other runners will pick them up.
+ // Set the timestamp to the current time, as it is useful to now that the job
+ // was already tried before (the timestamp becomes the "released" time).
+ $dbw->update( 'job',
+ [
+ 'job_token' => '',
+ 'job_token_timestamp' => $dbw->timestamp( $now ) ], // time of release
+ [
+ 'job_id' => $ids ],
+ __METHOD__
+ );
+ $affected = $dbw->affectedRows();
+ $count += $affected;
+ JobQueue::incrStats( 'recycles', $this->type, $affected );
+ $this->aggr->notifyQueueNonEmpty( $this->wiki, $this->type );
+ }
+ }
+
+ // Just destroy any stale jobs...
+ $pruneCutoff = $dbw->timestamp( $now - self::MAX_AGE_PRUNE );
+ $conds = [
+ 'job_cmd' => $this->type,
+ "job_token != {$dbw->addQuotes( '' )}", // was acquired
+ "job_token_timestamp < {$dbw->addQuotes( $pruneCutoff )}" // stale
+ ];
+ if ( $this->claimTTL > 0 ) { // only prune jobs attempted too many times...
+ $conds[] = "job_attempts >= {$dbw->addQuotes( $this->maxTries )}";
+ }
+ // Get the IDs of jobs that are considered stale and should be removed. Selecting
+ // the IDs first means that the UPDATE can be done by primary key (less deadlocks).
+ $res = $dbw->select( 'job', 'job_id', $conds, __METHOD__ );
+ $ids = array_map(
+ function ( $o ) {
+ return $o->job_id;
+ }, iterator_to_array( $res )
+ );
+ if ( count( $ids ) ) {
+ $dbw->delete( 'job', [ 'job_id' => $ids ], __METHOD__ );
+ $affected = $dbw->affectedRows();
+ $count += $affected;
+ JobQueue::incrStats( 'abandons', $this->type, $affected );
+ }
+
+ $dbw->unlock( "jobqueue-recycle-{$this->type}", __METHOD__ );
+ } catch ( DBError $e ) {
+ $this->throwDBException( $e );
+ }
+
+ return $count;
+ }
+
+ /**
+ * @param IJobSpecification $job
+ * @return array
+ */
+ protected function insertFields( IJobSpecification $job ) {
+ $dbw = $this->getMasterDB();
+
+ return [
+ // Fields that describe the nature of the job
+ 'job_cmd' => $job->getType(),
+ 'job_namespace' => $job->getTitle()->getNamespace(),
+ 'job_title' => $job->getTitle()->getDBkey(),
+ 'job_params' => self::makeBlob( $job->getParams() ),
+ // Additional job metadata
+ 'job_timestamp' => $dbw->timestamp(),
+ 'job_sha1' => Wikimedia\base_convert(
+ sha1( serialize( $job->getDeduplicationInfo() ) ),
+ 16, 36, 31
+ ),
+ 'job_random' => mt_rand( 0, self::MAX_JOB_RANDOM )
+ ];
+ }
+
+ /**
+ * @throws JobQueueConnectionError
+ * @return DBConnRef
+ */
+ protected function getReplicaDB() {
+ try {
+ return $this->getDB( DB_REPLICA );
+ } catch ( DBConnectionError $e ) {
+ throw new JobQueueConnectionError( "DBConnectionError:" . $e->getMessage() );
+ }
+ }
+
+ /**
+ * @throws JobQueueConnectionError
+ * @return DBConnRef
+ */
+ protected function getMasterDB() {
+ try {
+ return $this->getDB( DB_MASTER );
+ } catch ( DBConnectionError $e ) {
+ throw new JobQueueConnectionError( "DBConnectionError:" . $e->getMessage() );
+ }
+ }
+
+ /**
+ * @param int $index (DB_REPLICA/DB_MASTER)
+ * @return DBConnRef
+ */
+ protected function getDB( $index ) {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lb = ( $this->cluster !== false )
+ ? $lbFactory->getExternalLB( $this->cluster )
+ : $lbFactory->getMainLB( $this->wiki );
+
+ return ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' )
+ // Keep a separate connection to avoid contention and deadlocks;
+ // However, SQLite has the opposite behavior due to DB-level locking.
+ ? $lb->getConnectionRef( $index, [], $this->wiki, $lb::CONN_TRX_AUTO )
+ // Jobs insertion will be defered until the PRESEND stage to reduce contention.
+ : $lb->getConnectionRef( $index, [], $this->wiki );
+ }
+
+ /**
+ * @param string $property
+ * @return string
+ */
+ private function getCacheKey( $property ) {
+ list( $db, $prefix ) = wfSplitWikiID( $this->wiki );
+ $cluster = is_string( $this->cluster ) ? $this->cluster : 'main';
+
+ return wfForeignMemcKey( $db, $prefix, 'jobqueue', $cluster, $this->type, $property );
+ }
+
+ /**
+ * @param array|bool $params
+ * @return string
+ */
+ protected static function makeBlob( $params ) {
+ if ( $params !== false ) {
+ return serialize( $params );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * @param string $blob
+ * @return bool|mixed
+ */
+ protected static function extractBlob( $blob ) {
+ if ( (string)$blob !== '' ) {
+ return unserialize( $blob );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param DBError $e
+ * @throws JobQueueError
+ */
+ protected function throwDBException( DBError $e ) {
+ throw new JobQueueError( get_class( $e ) . ": " . $e->getMessage() );
+ }
+
+ /**
+ * Return the list of job fields that should be selected.
+ * @since 1.23
+ * @return array
+ */
+ public static function selectFields() {
+ return [
+ 'job_id',
+ 'job_cmd',
+ 'job_namespace',
+ 'job_title',
+ 'job_timestamp',
+ 'job_params',
+ 'job_random',
+ 'job_attempts',
+ 'job_token',
+ 'job_token_timestamp',
+ 'job_sha1',
+ ];
+ }
+}
diff --git a/www/wiki/includes/jobqueue/JobQueueFederated.php b/www/wiki/includes/jobqueue/JobQueueFederated.php
new file mode 100644
index 00000000..e7433111
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobQueueFederated.php
@@ -0,0 +1,497 @@
+<?php
+/**
+ * Job queue code for federated queues.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to handle enqueueing and running of background jobs for federated queues
+ *
+ * This class allows for queues to be partitioned into smaller queues.
+ * A partition is defined by the configuration for a JobQueue instance.
+ * For example, one can set $wgJobTypeConf['refreshLinks'] to point to a
+ * JobQueueFederated instance, which itself would consist of three JobQueueRedis
+ * instances, each using their own redis server. This would allow for the jobs
+ * to be split (evenly or based on weights) across multiple servers if a single
+ * server becomes impractical or expensive. Different JobQueue classes can be mixed.
+ *
+ * The basic queue configuration (e.g. "order", "claimTTL") of a federated queue
+ * is inherited by the partition queues. Additional configuration defines what
+ * section each wiki is in, what partition queues each section uses (and their weight),
+ * and the JobQueue configuration for each partition. Some sections might only need a
+ * single queue partition, like the sections for groups of small wikis.
+ *
+ * If used for performance, then $wgMainCacheType should be set to memcached/redis.
+ * Note that "fifo" cannot be used for the ordering, since the data is distributed.
+ * One can still use "timestamp" instead, as in "roughly timestamp ordered". Also,
+ * queue classes used by this should ignore down servers (with TTL) to avoid slowness.
+ *
+ * @ingroup JobQueue
+ * @since 1.22
+ */
+class JobQueueFederated extends JobQueue {
+ /** @var HashRing */
+ protected $partitionRing;
+ /** @var JobQueue[] (partition name => JobQueue) reverse sorted by weight */
+ protected $partitionQueues = [];
+
+ /** @var int Maximum number of partitions to try */
+ protected $maxPartitionsTry;
+
+ /**
+ * @param array $params Possible keys:
+ * - sectionsByWiki : A map of wiki IDs to section names.
+ * Wikis will default to using the section "default".
+ * - partitionsBySection : Map of section names to maps of (partition name => weight).
+ * A section called 'default' must be defined if not all wikis
+ * have explicitly defined sections.
+ * - configByPartition : Map of queue partition names to configuration arrays.
+ * These configuration arrays are passed to JobQueue::factory().
+ * The options set here are overridden by those passed to this
+ * the federated queue itself (e.g. 'order' and 'claimTTL').
+ * - maxPartitionsTry : Maximum number of times to attempt job insertion using
+ * different partition queues. This improves availability
+ * during failure, at the cost of added latency and somewhat
+ * less reliable job de-duplication mechanisms.
+ * @throws MWException
+ */
+ protected function __construct( array $params ) {
+ parent::__construct( $params );
+ $section = isset( $params['sectionsByWiki'][$this->wiki] )
+ ? $params['sectionsByWiki'][$this->wiki]
+ : 'default';
+ if ( !isset( $params['partitionsBySection'][$section] ) ) {
+ throw new MWException( "No configuration for section '$section'." );
+ }
+ $this->maxPartitionsTry = isset( $params['maxPartitionsTry'] )
+ ? $params['maxPartitionsTry']
+ : 2;
+ // Get the full partition map
+ $partitionMap = $params['partitionsBySection'][$section];
+ arsort( $partitionMap, SORT_NUMERIC );
+ // Get the config to pass to merge into each partition queue config
+ $baseConfig = $params;
+ foreach ( [ 'class', 'sectionsByWiki', 'maxPartitionsTry',
+ 'partitionsBySection', 'configByPartition', ] as $o
+ ) {
+ unset( $baseConfig[$o] ); // partition queue doesn't care about this
+ }
+ // The class handles all aggregator calls already
+ unset( $baseConfig['aggregator'] );
+ // Get the partition queue objects
+ foreach ( $partitionMap as $partition => $w ) {
+ if ( !isset( $params['configByPartition'][$partition] ) ) {
+ throw new MWException( "No configuration for partition '$partition'." );
+ }
+ $this->partitionQueues[$partition] = JobQueue::factory(
+ $baseConfig + $params['configByPartition'][$partition] );
+ }
+ // Ring of all partitions
+ $this->partitionRing = new HashRing( $partitionMap );
+ }
+
+ protected function supportedOrders() {
+ // No FIFO due to partitioning, though "rough timestamp order" is supported
+ return [ 'undefined', 'random', 'timestamp' ];
+ }
+
+ protected function optimalOrder() {
+ return 'undefined'; // defer to the partitions
+ }
+
+ protected function supportsDelayedJobs() {
+ foreach ( $this->partitionQueues as $queue ) {
+ if ( !$queue->supportsDelayedJobs() ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected function doIsEmpty() {
+ $empty = true;
+ $failed = 0;
+ foreach ( $this->partitionQueues as $queue ) {
+ try {
+ $empty = $empty && $queue->doIsEmpty();
+ } catch ( JobQueueError $e ) {
+ ++$failed;
+ $this->logException( $e );
+ }
+ }
+ $this->throwErrorIfAllPartitionsDown( $failed );
+
+ return $empty;
+ }
+
+ protected function doGetSize() {
+ return $this->getCrossPartitionSum( 'size', 'doGetSize' );
+ }
+
+ protected function doGetAcquiredCount() {
+ return $this->getCrossPartitionSum( 'acquiredcount', 'doGetAcquiredCount' );
+ }
+
+ protected function doGetDelayedCount() {
+ return $this->getCrossPartitionSum( 'delayedcount', 'doGetDelayedCount' );
+ }
+
+ protected function doGetAbandonedCount() {
+ return $this->getCrossPartitionSum( 'abandonedcount', 'doGetAbandonedCount' );
+ }
+
+ /**
+ * @param string $type
+ * @param string $method
+ * @return int
+ */
+ protected function getCrossPartitionSum( $type, $method ) {
+ $count = 0;
+ $failed = 0;
+ foreach ( $this->partitionQueues as $queue ) {
+ try {
+ $count += $queue->$method();
+ } catch ( JobQueueError $e ) {
+ ++$failed;
+ $this->logException( $e );
+ }
+ }
+ $this->throwErrorIfAllPartitionsDown( $failed );
+
+ return $count;
+ }
+
+ protected function doBatchPush( array $jobs, $flags ) {
+ // Local ring variable that may be changed to point to a new ring on failure
+ $partitionRing = $this->partitionRing;
+ // Try to insert the jobs and update $partitionsTry on any failures.
+ // Retry to insert any remaning jobs again, ignoring the bad partitions.
+ $jobsLeft = $jobs;
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $i = $this->maxPartitionsTry; $i > 0 && count( $jobsLeft ); --$i ) {
+ // @codingStandardsIgnoreEnd
+ try {
+ $partitionRing->getLiveRing();
+ } catch ( UnexpectedValueException $e ) {
+ break; // all servers down; nothing to insert to
+ }
+ $jobsLeft = $this->tryJobInsertions( $jobsLeft, $partitionRing, $flags );
+ }
+ if ( count( $jobsLeft ) ) {
+ throw new JobQueueError(
+ "Could not insert job(s), {$this->maxPartitionsTry} partitions tried." );
+ }
+ }
+
+ /**
+ * @param array $jobs
+ * @param HashRing &$partitionRing
+ * @param int $flags
+ * @throws JobQueueError
+ * @return array List of Job object that could not be inserted
+ */
+ protected function tryJobInsertions( array $jobs, HashRing &$partitionRing, $flags ) {
+ $jobsLeft = [];
+
+ // Because jobs are spread across partitions, per-job de-duplication needs
+ // to use a consistent hash to avoid allowing duplicate jobs per partition.
+ // When inserting a batch of de-duplicated jobs, QOS_ATOMIC is disregarded.
+ $uJobsByPartition = []; // (partition name => job list)
+ /** @var Job $job */
+ foreach ( $jobs as $key => $job ) {
+ if ( $job->ignoreDuplicates() ) {
+ $sha1 = sha1( serialize( $job->getDeduplicationInfo() ) );
+ $uJobsByPartition[$partitionRing->getLiveLocation( $sha1 )][] = $job;
+ unset( $jobs[$key] );
+ }
+ }
+ // Get the batches of jobs that are not de-duplicated
+ if ( $flags & self::QOS_ATOMIC ) {
+ $nuJobBatches = [ $jobs ]; // all or nothing
+ } else {
+ // Split the jobs into batches and spread them out over servers if there
+ // are many jobs. This helps keep the partitions even. Otherwise, send all
+ // the jobs to a single partition queue to avoids the extra connections.
+ $nuJobBatches = array_chunk( $jobs, 300 );
+ }
+
+ // Insert the de-duplicated jobs into the queues...
+ foreach ( $uJobsByPartition as $partition => $jobBatch ) {
+ /** @var JobQueue $queue */
+ $queue = $this->partitionQueues[$partition];
+ try {
+ $ok = true;
+ $queue->doBatchPush( $jobBatch, $flags | self::QOS_ATOMIC );
+ } catch ( JobQueueError $e ) {
+ $ok = false;
+ $this->logException( $e );
+ }
+ if ( !$ok ) {
+ if ( !$partitionRing->ejectFromLiveRing( $partition, 5 ) ) { // blacklist
+ throw new JobQueueError( "Could not insert job(s), no partitions available." );
+ }
+ $jobsLeft = array_merge( $jobsLeft, $jobBatch ); // not inserted
+ }
+ }
+
+ // Insert the jobs that are not de-duplicated into the queues...
+ foreach ( $nuJobBatches as $jobBatch ) {
+ $partition = ArrayUtils::pickRandom( $partitionRing->getLiveLocationWeights() );
+ $queue = $this->partitionQueues[$partition];
+ try {
+ $ok = true;
+ $queue->doBatchPush( $jobBatch, $flags | self::QOS_ATOMIC );
+ } catch ( JobQueueError $e ) {
+ $ok = false;
+ $this->logException( $e );
+ }
+ if ( !$ok ) {
+ if ( !$partitionRing->ejectFromLiveRing( $partition, 5 ) ) { // blacklist
+ throw new JobQueueError( "Could not insert job(s), no partitions available." );
+ }
+ $jobsLeft = array_merge( $jobsLeft, $jobBatch ); // not inserted
+ }
+ }
+
+ return $jobsLeft;
+ }
+
+ protected function doPop() {
+ $partitionsTry = $this->partitionRing->getLiveLocationWeights(); // (partition => weight)
+
+ $failed = 0;
+ while ( count( $partitionsTry ) ) {
+ $partition = ArrayUtils::pickRandom( $partitionsTry );
+ if ( $partition === false ) {
+ break; // all partitions at 0 weight
+ }
+
+ /** @var JobQueue $queue */
+ $queue = $this->partitionQueues[$partition];
+ try {
+ $job = $queue->pop();
+ } catch ( JobQueueError $e ) {
+ ++$failed;
+ $this->logException( $e );
+ $job = false;
+ }
+ if ( $job ) {
+ $job->metadata['QueuePartition'] = $partition;
+
+ return $job;
+ } else {
+ unset( $partitionsTry[$partition] ); // blacklist partition
+ }
+ }
+ $this->throwErrorIfAllPartitionsDown( $failed );
+
+ return false;
+ }
+
+ protected function doAck( Job $job ) {
+ if ( !isset( $job->metadata['QueuePartition'] ) ) {
+ throw new MWException( "The given job has no defined partition name." );
+ }
+
+ $this->partitionQueues[$job->metadata['QueuePartition']]->ack( $job );
+ }
+
+ protected function doIsRootJobOldDuplicate( Job $job ) {
+ $signature = $job->getRootJobParams()['rootJobSignature'];
+ $partition = $this->partitionRing->getLiveLocation( $signature );
+ try {
+ return $this->partitionQueues[$partition]->doIsRootJobOldDuplicate( $job );
+ } catch ( JobQueueError $e ) {
+ if ( $this->partitionRing->ejectFromLiveRing( $partition, 5 ) ) {
+ $partition = $this->partitionRing->getLiveLocation( $signature );
+ return $this->partitionQueues[$partition]->doIsRootJobOldDuplicate( $job );
+ }
+ }
+
+ return false;
+ }
+
+ protected function doDeduplicateRootJob( IJobSpecification $job ) {
+ $signature = $job->getRootJobParams()['rootJobSignature'];
+ $partition = $this->partitionRing->getLiveLocation( $signature );
+ try {
+ return $this->partitionQueues[$partition]->doDeduplicateRootJob( $job );
+ } catch ( JobQueueError $e ) {
+ if ( $this->partitionRing->ejectFromLiveRing( $partition, 5 ) ) {
+ $partition = $this->partitionRing->getLiveLocation( $signature );
+ return $this->partitionQueues[$partition]->doDeduplicateRootJob( $job );
+ }
+ }
+
+ return false;
+ }
+
+ protected function doDelete() {
+ $failed = 0;
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ try {
+ $queue->doDelete();
+ } catch ( JobQueueError $e ) {
+ ++$failed;
+ $this->logException( $e );
+ }
+ }
+ $this->throwErrorIfAllPartitionsDown( $failed );
+ return true;
+ }
+
+ protected function doWaitForBackups() {
+ $failed = 0;
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ try {
+ $queue->waitForBackups();
+ } catch ( JobQueueError $e ) {
+ ++$failed;
+ $this->logException( $e );
+ }
+ }
+ $this->throwErrorIfAllPartitionsDown( $failed );
+ }
+
+ protected function doFlushCaches() {
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ $queue->doFlushCaches();
+ }
+ }
+
+ public function getAllQueuedJobs() {
+ $iterator = new AppendIterator();
+
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ $iterator->append( $queue->getAllQueuedJobs() );
+ }
+
+ return $iterator;
+ }
+
+ public function getAllDelayedJobs() {
+ $iterator = new AppendIterator();
+
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ $iterator->append( $queue->getAllDelayedJobs() );
+ }
+
+ return $iterator;
+ }
+
+ public function getAllAcquiredJobs() {
+ $iterator = new AppendIterator();
+
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ $iterator->append( $queue->getAllAcquiredJobs() );
+ }
+
+ return $iterator;
+ }
+
+ public function getAllAbandonedJobs() {
+ $iterator = new AppendIterator();
+
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ $iterator->append( $queue->getAllAbandonedJobs() );
+ }
+
+ return $iterator;
+ }
+
+ public function getCoalesceLocationInternal() {
+ return "JobQueueFederated:wiki:{$this->wiki}" .
+ sha1( serialize( array_keys( $this->partitionQueues ) ) );
+ }
+
+ protected function doGetSiblingQueuesWithJobs( array $types ) {
+ $result = [];
+
+ $failed = 0;
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ try {
+ $nonEmpty = $queue->doGetSiblingQueuesWithJobs( $types );
+ if ( is_array( $nonEmpty ) ) {
+ $result = array_unique( array_merge( $result, $nonEmpty ) );
+ } else {
+ return null; // not supported on all partitions; bail
+ }
+ if ( count( $result ) == count( $types ) ) {
+ break; // short-circuit
+ }
+ } catch ( JobQueueError $e ) {
+ ++$failed;
+ $this->logException( $e );
+ }
+ }
+ $this->throwErrorIfAllPartitionsDown( $failed );
+
+ return array_values( $result );
+ }
+
+ protected function doGetSiblingQueueSizes( array $types ) {
+ $result = [];
+ $failed = 0;
+ /** @var JobQueue $queue */
+ foreach ( $this->partitionQueues as $queue ) {
+ try {
+ $sizes = $queue->doGetSiblingQueueSizes( $types );
+ if ( is_array( $sizes ) ) {
+ foreach ( $sizes as $type => $size ) {
+ $result[$type] = isset( $result[$type] ) ? $result[$type] + $size : $size;
+ }
+ } else {
+ return null; // not supported on all partitions; bail
+ }
+ } catch ( JobQueueError $e ) {
+ ++$failed;
+ $this->logException( $e );
+ }
+ }
+ $this->throwErrorIfAllPartitionsDown( $failed );
+
+ return $result;
+ }
+
+ protected function logException( Exception $e ) {
+ wfDebugLog( 'JobQueueFederated', $e->getMessage() . "\n" . $e->getTraceAsString() );
+ }
+
+ /**
+ * Throw an error if no partitions available
+ *
+ * @param int $down The number of up partitions down
+ * @return void
+ * @throws JobQueueError
+ */
+ protected function throwErrorIfAllPartitionsDown( $down ) {
+ if ( $down >= count( $this->partitionQueues ) ) {
+ throw new JobQueueError( 'No queue partitions available.' );
+ }
+ }
+}
diff --git a/www/wiki/includes/jobqueue/JobQueueGroup.php b/www/wiki/includes/jobqueue/JobQueueGroup.php
new file mode 100644
index 00000000..addc7fc2
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobQueueGroup.php
@@ -0,0 +1,475 @@
+<?php
+/**
+ * Job queue base code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to handle enqueueing of background jobs
+ *
+ * @ingroup JobQueue
+ * @since 1.21
+ */
+class JobQueueGroup {
+ /** @var JobQueueGroup[] */
+ protected static $instances = [];
+
+ /** @var ProcessCacheLRU */
+ protected $cache;
+
+ /** @var string Wiki ID */
+ protected $wiki;
+ /** @var string|bool Read only rationale (or false if r/w) */
+ protected $readOnlyReason;
+ /** @var bool Whether the wiki is not recognized in configuration */
+ protected $invalidWiki = false;
+
+ /** @var array Map of (bucket => (queue => JobQueue, types => list of types) */
+ protected $coalescedQueues;
+
+ /** @var Job[] */
+ protected $bufferedJobs = [];
+
+ const TYPE_DEFAULT = 1; // integer; jobs popped by default
+ const TYPE_ANY = 2; // integer; any job
+
+ const USE_CACHE = 1; // integer; use process or persistent cache
+
+ const PROC_CACHE_TTL = 15; // integer; seconds
+
+ const CACHE_VERSION = 1; // integer; cache version
+
+ /**
+ * @param string $wiki Wiki ID
+ * @param string|bool $readOnlyReason Read-only reason or false
+ */
+ protected function __construct( $wiki, $readOnlyReason ) {
+ $this->wiki = $wiki;
+ $this->readOnlyReason = $readOnlyReason;
+ $this->cache = new ProcessCacheLRU( 10 );
+ }
+
+ /**
+ * @param bool|string $wiki Wiki ID
+ * @return JobQueueGroup
+ */
+ public static function singleton( $wiki = false ) {
+ global $wgLocalDatabases;
+
+ $wiki = ( $wiki === false ) ? wfWikiID() : $wiki;
+
+ if ( !isset( self::$instances[$wiki] ) ) {
+ self::$instances[$wiki] = new self( $wiki, wfConfiguredReadOnlyReason() );
+ // Make sure jobs are not getting pushed to bogus wikis. This can confuse
+ // the job runner system into spawning endless RPC requests that fail (T171371).
+ if ( $wiki !== wfWikiID() && !in_array( $wiki, $wgLocalDatabases ) ) {
+ self::$instances[$wiki]->invalidWiki = true;
+ }
+ }
+
+ return self::$instances[$wiki];
+ }
+
+ /**
+ * Destroy the singleton instances
+ *
+ * @return void
+ */
+ public static function destroySingletons() {
+ self::$instances = [];
+ }
+
+ /**
+ * Get the job queue object for a given queue type
+ *
+ * @param string $type
+ * @return JobQueue
+ */
+ public function get( $type ) {
+ global $wgJobTypeConf;
+
+ $conf = [ 'wiki' => $this->wiki, 'type' => $type ];
+ if ( isset( $wgJobTypeConf[$type] ) ) {
+ $conf = $conf + $wgJobTypeConf[$type];
+ } else {
+ $conf = $conf + $wgJobTypeConf['default'];
+ }
+ $conf['aggregator'] = JobQueueAggregator::singleton();
+ if ( $this->readOnlyReason !== false ) {
+ $conf['readOnlyReason'] = $this->readOnlyReason;
+ }
+
+ return JobQueue::factory( $conf );
+ }
+
+ /**
+ * Insert jobs into the respective queues of which they belong
+ *
+ * This inserts the jobs into the queue specified by $wgJobTypeConf
+ * and updates the aggregate job queue information cache as needed.
+ *
+ * @param IJobSpecification|IJobSpecification[] $jobs A single Job or a list of Jobs
+ * @throws InvalidArgumentException
+ * @return void
+ */
+ public function push( $jobs ) {
+ global $wgJobTypesExcludedFromDefaultQueue;
+
+ if ( $this->invalidWiki ) {
+ // Do not enqueue job that cannot be run (T171371)
+ $e = new LogicException( "Domain '{$this->wiki}' is not recognized." );
+ MWExceptionHandler::logException( $e );
+ return;
+ }
+
+ $jobs = is_array( $jobs ) ? $jobs : [ $jobs ];
+ if ( !count( $jobs ) ) {
+ return;
+ }
+
+ $this->assertValidJobs( $jobs );
+
+ $jobsByType = []; // (job type => list of jobs)
+ foreach ( $jobs as $job ) {
+ $jobsByType[$job->getType()][] = $job;
+ }
+
+ foreach ( $jobsByType as $type => $jobs ) {
+ $this->get( $type )->push( $jobs );
+ }
+
+ if ( $this->cache->has( 'queues-ready', 'list' ) ) {
+ $list = $this->cache->get( 'queues-ready', 'list' );
+ if ( count( array_diff( array_keys( $jobsByType ), $list ) ) ) {
+ $this->cache->clear( 'queues-ready' );
+ }
+ }
+
+ $cache = ObjectCache::getLocalClusterInstance();
+ $cache->set(
+ $cache->makeGlobalKey( 'jobqueue', $this->wiki, 'hasjobs', self::TYPE_ANY ),
+ 'true',
+ 15
+ );
+ if ( array_diff( array_keys( $jobsByType ), $wgJobTypesExcludedFromDefaultQueue ) ) {
+ $cache->set(
+ $cache->makeGlobalKey( 'jobqueue', $this->wiki, 'hasjobs', self::TYPE_DEFAULT ),
+ 'true',
+ 15
+ );
+ }
+ }
+
+ /**
+ * Buffer jobs for insertion via push() or call it now if in CLI mode
+ *
+ * Note that pushLazyJobs() is registered as a deferred update just before
+ * DeferredUpdates::doUpdates() in MediaWiki and JobRunner classes in order
+ * to be executed as the very last deferred update (T100085, T154425).
+ *
+ * @param IJobSpecification|IJobSpecification[] $jobs A single Job or a list of Jobs
+ * @return void
+ * @since 1.26
+ */
+ public function lazyPush( $jobs ) {
+ if ( $this->invalidWiki ) {
+ // Do not enqueue job that cannot be run (T171371)
+ throw new LogicException( "Domain '{$this->wiki}' is not recognized." );
+ }
+
+ if ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ) {
+ $this->push( $jobs );
+ return;
+ }
+
+ $jobs = is_array( $jobs ) ? $jobs : [ $jobs ];
+
+ // Throw errors now instead of on push(), when other jobs may be buffered
+ $this->assertValidJobs( $jobs );
+
+ $this->bufferedJobs = array_merge( $this->bufferedJobs, $jobs );
+ }
+
+ /**
+ * Push all jobs buffered via lazyPush() into their respective queues
+ *
+ * @return void
+ * @since 1.26
+ */
+ public static function pushLazyJobs() {
+ foreach ( self::$instances as $group ) {
+ try {
+ $group->push( $group->bufferedJobs );
+ $group->bufferedJobs = [];
+ } catch ( Exception $e ) {
+ // Get in as many jobs as possible and let other post-send updates happen
+ MWExceptionHandler::logException( $e );
+ }
+ }
+ }
+
+ /**
+ * Pop a job off one of the job queues
+ *
+ * This pops a job off a queue as specified by $wgJobTypeConf and
+ * updates the aggregate job queue information cache as needed.
+ *
+ * @param int|string $qtype JobQueueGroup::TYPE_* constant or job type string
+ * @param int $flags Bitfield of JobQueueGroup::USE_* constants
+ * @param array $blacklist List of job types to ignore
+ * @return Job|bool Returns false on failure
+ */
+ public function pop( $qtype = self::TYPE_DEFAULT, $flags = 0, array $blacklist = [] ) {
+ $job = false;
+
+ if ( is_string( $qtype ) ) { // specific job type
+ if ( !in_array( $qtype, $blacklist ) ) {
+ $job = $this->get( $qtype )->pop();
+ }
+ } else { // any job in the "default" jobs types
+ if ( $flags & self::USE_CACHE ) {
+ if ( !$this->cache->has( 'queues-ready', 'list', self::PROC_CACHE_TTL ) ) {
+ $this->cache->set( 'queues-ready', 'list', $this->getQueuesWithJobs() );
+ }
+ $types = $this->cache->get( 'queues-ready', 'list' );
+ } else {
+ $types = $this->getQueuesWithJobs();
+ }
+
+ if ( $qtype == self::TYPE_DEFAULT ) {
+ $types = array_intersect( $types, $this->getDefaultQueueTypes() );
+ }
+
+ $types = array_diff( $types, $blacklist ); // avoid selected types
+ shuffle( $types ); // avoid starvation
+
+ foreach ( $types as $type ) { // for each queue...
+ $job = $this->get( $type )->pop();
+ if ( $job ) { // found
+ break;
+ } else { // not found
+ $this->cache->clear( 'queues-ready' );
+ }
+ }
+ }
+
+ return $job;
+ }
+
+ /**
+ * Acknowledge that a job was completed
+ *
+ * @param Job $job
+ * @return void
+ */
+ public function ack( Job $job ) {
+ $this->get( $job->getType() )->ack( $job );
+ }
+
+ /**
+ * Register the "root job" of a given job into the queue for de-duplication.
+ * This should only be called right *after* all the new jobs have been inserted.
+ *
+ * @param Job $job
+ * @return bool
+ */
+ public function deduplicateRootJob( Job $job ) {
+ return $this->get( $job->getType() )->deduplicateRootJob( $job );
+ }
+
+ /**
+ * Wait for any replica DBs or backup queue servers to catch up.
+ *
+ * This does nothing for certain queue classes.
+ *
+ * @return void
+ */
+ public function waitForBackups() {
+ global $wgJobTypeConf;
+
+ // Try to avoid doing this more than once per queue storage medium
+ foreach ( $wgJobTypeConf as $type => $conf ) {
+ $this->get( $type )->waitForBackups();
+ }
+ }
+
+ /**
+ * Get the list of queue types
+ *
+ * @return array List of strings
+ */
+ public function getQueueTypes() {
+ return array_keys( $this->getCachedConfigVar( 'wgJobClasses' ) );
+ }
+
+ /**
+ * Get the list of default queue types
+ *
+ * @return array List of strings
+ */
+ public function getDefaultQueueTypes() {
+ global $wgJobTypesExcludedFromDefaultQueue;
+
+ return array_diff( $this->getQueueTypes(), $wgJobTypesExcludedFromDefaultQueue );
+ }
+
+ /**
+ * Check if there are any queues with jobs (this is cached)
+ *
+ * @param int $type JobQueueGroup::TYPE_* constant
+ * @return bool
+ * @since 1.23
+ */
+ public function queuesHaveJobs( $type = self::TYPE_ANY ) {
+ $cache = ObjectCache::getLocalClusterInstance();
+ $key = $cache->makeGlobalKey( 'jobqueue', $this->wiki, 'hasjobs', $type );
+
+ $value = $cache->get( $key );
+ if ( $value === false ) {
+ $queues = $this->getQueuesWithJobs();
+ if ( $type == self::TYPE_DEFAULT ) {
+ $queues = array_intersect( $queues, $this->getDefaultQueueTypes() );
+ }
+ $value = count( $queues ) ? 'true' : 'false';
+ $cache->add( $key, $value, 15 );
+ }
+
+ return ( $value === 'true' );
+ }
+
+ /**
+ * Get the list of job types that have non-empty queues
+ *
+ * @return array List of job types that have non-empty queues
+ */
+ public function getQueuesWithJobs() {
+ $types = [];
+ foreach ( $this->getCoalescedQueues() as $info ) {
+ $nonEmpty = $info['queue']->getSiblingQueuesWithJobs( $this->getQueueTypes() );
+ if ( is_array( $nonEmpty ) ) { // batching features supported
+ $types = array_merge( $types, $nonEmpty );
+ } else { // we have to go through the queues in the bucket one-by-one
+ foreach ( $info['types'] as $type ) {
+ if ( !$this->get( $type )->isEmpty() ) {
+ $types[] = $type;
+ }
+ }
+ }
+ }
+
+ return $types;
+ }
+
+ /**
+ * Get the size of the queus for a list of job types
+ *
+ * @return array Map of (job type => size)
+ */
+ public function getQueueSizes() {
+ $sizeMap = [];
+ foreach ( $this->getCoalescedQueues() as $info ) {
+ $sizes = $info['queue']->getSiblingQueueSizes( $this->getQueueTypes() );
+ if ( is_array( $sizes ) ) { // batching features supported
+ $sizeMap = $sizeMap + $sizes;
+ } else { // we have to go through the queues in the bucket one-by-one
+ foreach ( $info['types'] as $type ) {
+ $sizeMap[$type] = $this->get( $type )->getSize();
+ }
+ }
+ }
+
+ return $sizeMap;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getCoalescedQueues() {
+ global $wgJobTypeConf;
+
+ if ( $this->coalescedQueues === null ) {
+ $this->coalescedQueues = [];
+ foreach ( $wgJobTypeConf as $type => $conf ) {
+ $queue = JobQueue::factory(
+ [ 'wiki' => $this->wiki, 'type' => 'null' ] + $conf );
+ $loc = $queue->getCoalesceLocationInternal();
+ if ( !isset( $this->coalescedQueues[$loc] ) ) {
+ $this->coalescedQueues[$loc]['queue'] = $queue;
+ $this->coalescedQueues[$loc]['types'] = [];
+ }
+ if ( $type === 'default' ) {
+ $this->coalescedQueues[$loc]['types'] = array_merge(
+ $this->coalescedQueues[$loc]['types'],
+ array_diff( $this->getQueueTypes(), array_keys( $wgJobTypeConf ) )
+ );
+ } else {
+ $this->coalescedQueues[$loc]['types'][] = $type;
+ }
+ }
+ }
+
+ return $this->coalescedQueues;
+ }
+
+ /**
+ * @param string $name
+ * @return mixed
+ */
+ private function getCachedConfigVar( $name ) {
+ // @TODO: cleanup this whole method with a proper config system
+ if ( $this->wiki === wfWikiID() ) {
+ return $GLOBALS[$name]; // common case
+ } else {
+ $wiki = $this->wiki;
+ $cache = ObjectCache::getMainWANInstance();
+ $value = $cache->getWithSetCallback(
+ $cache->makeGlobalKey( 'jobqueue', 'configvalue', $wiki, $name ),
+ $cache::TTL_DAY + mt_rand( 0, $cache::TTL_DAY ),
+ function () use ( $wiki, $name ) {
+ global $wgConf;
+
+ return [ 'v' => $wgConf->getConfig( $wiki, $name ) ];
+ },
+ [ 'pcTTL' => WANObjectCache::TTL_PROC_LONG ]
+ );
+
+ return $value['v'];
+ }
+ }
+
+ /**
+ * @param array $jobs
+ * @throws InvalidArgumentException
+ */
+ private function assertValidJobs( array $jobs ) {
+ foreach ( $jobs as $job ) { // sanity checks
+ if ( !( $job instanceof IJobSpecification ) ) {
+ throw new InvalidArgumentException( "Expected IJobSpecification objects" );
+ }
+ }
+ }
+
+ function __destruct() {
+ $n = count( $this->bufferedJobs );
+ if ( $n > 0 ) {
+ $type = implode( ', ', array_unique( array_map( 'get_class', $this->bufferedJobs ) ) );
+ trigger_error( __METHOD__ . ": $n buffered job(s) of type(s) $type never inserted." );
+ }
+ }
+}
diff --git a/www/wiki/includes/jobqueue/JobQueueMemory.php b/www/wiki/includes/jobqueue/JobQueueMemory.php
new file mode 100644
index 00000000..f9e2c3dc
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobQueueMemory.php
@@ -0,0 +1,230 @@
+<?php
+/**
+ * PHP memory-backed job queue code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to handle job queues stored in PHP memory for testing
+ *
+ * JobQueueGroup does not remember every queue instance, so statically track it here
+ *
+ * @ingroup JobQueue
+ * @since 1.27
+ */
+class JobQueueMemory extends JobQueue {
+ /** @var array[] */
+ protected static $data = [];
+
+ /**
+ * @see JobQueue::doBatchPush
+ *
+ * @param IJobSpecification[] $jobs
+ * @param int $flags
+ */
+ protected function doBatchPush( array $jobs, $flags ) {
+ $unclaimed =& $this->getQueueData( 'unclaimed', [] );
+
+ foreach ( $jobs as $job ) {
+ if ( $job->ignoreDuplicates() ) {
+ $sha1 = Wikimedia\base_convert(
+ sha1( serialize( $job->getDeduplicationInfo() ) ),
+ 16, 36, 31
+ );
+ if ( !isset( $unclaimed[$sha1] ) ) {
+ $unclaimed[$sha1] = $job;
+ }
+ } else {
+ $unclaimed[] = $job;
+ }
+ }
+ }
+
+ /**
+ * @see JobQueue::supportedOrders
+ *
+ * @return string[]
+ */
+ protected function supportedOrders() {
+ return [ 'random', 'timestamp', 'fifo' ];
+ }
+
+ /**
+ * @see JobQueue::optimalOrder
+ *
+ * @return string
+ */
+ protected function optimalOrder() {
+ return 'fifo';
+ }
+
+ /**
+ * @see JobQueue::doIsEmpty
+ *
+ * @return bool
+ */
+ protected function doIsEmpty() {
+ return ( $this->doGetSize() == 0 );
+ }
+
+ /**
+ * @see JobQueue::doGetSize
+ *
+ * @return int
+ */
+ protected function doGetSize() {
+ $unclaimed = $this->getQueueData( 'unclaimed' );
+
+ return $unclaimed ? count( $unclaimed ) : 0;
+ }
+
+ /**
+ * @see JobQueue::doGetAcquiredCount
+ *
+ * @return int
+ */
+ protected function doGetAcquiredCount() {
+ $claimed = $this->getQueueData( 'claimed' );
+
+ return $claimed ? count( $claimed ) : 0;
+ }
+
+ /**
+ * @see JobQueue::doPop
+ *
+ * @return Job|bool
+ */
+ protected function doPop() {
+ if ( $this->doGetSize() == 0 ) {
+ return false;
+ }
+
+ $unclaimed =& $this->getQueueData( 'unclaimed' );
+ $claimed =& $this->getQueueData( 'claimed', [] );
+
+ if ( $this->order === 'random' ) {
+ $key = array_rand( $unclaimed );
+ } else {
+ reset( $unclaimed );
+ $key = key( $unclaimed );
+ }
+
+ $spec = $unclaimed[$key];
+ unset( $unclaimed[$key] );
+ $claimed[] = $spec;
+
+ $job = $this->jobFromSpecInternal( $spec );
+
+ end( $claimed );
+ $job->metadata['claimId'] = key( $claimed );
+
+ return $job;
+ }
+
+ /**
+ * @see JobQueue::doAck
+ *
+ * @param Job $job
+ */
+ protected function doAck( Job $job ) {
+ if ( $this->getAcquiredCount() == 0 ) {
+ return;
+ }
+
+ $claimed =& $this->getQueueData( 'claimed' );
+ unset( $claimed[$job->metadata['claimId']] );
+ }
+
+ /**
+ * @see JobQueue::doDelete
+ */
+ protected function doDelete() {
+ if ( isset( self::$data[$this->type][$this->wiki] ) ) {
+ unset( self::$data[$this->type][$this->wiki] );
+ if ( !self::$data[$this->type] ) {
+ unset( self::$data[$this->type] );
+ }
+ }
+ }
+
+ /**
+ * @see JobQueue::getAllQueuedJobs
+ *
+ * @return Iterator of Job objects.
+ */
+ public function getAllQueuedJobs() {
+ $unclaimed = $this->getQueueData( 'unclaimed' );
+ if ( !$unclaimed ) {
+ return new ArrayIterator( [] );
+ }
+
+ return new MappedIterator(
+ $unclaimed,
+ function ( $value ) {
+ return $this->jobFromSpecInternal( $value );
+ }
+ );
+ }
+
+ /**
+ * @see JobQueue::getAllAcquiredJobs
+ *
+ * @return Iterator of Job objects.
+ */
+ public function getAllAcquiredJobs() {
+ $claimed = $this->getQueueData( 'claimed' );
+ if ( !$claimed ) {
+ return new ArrayIterator( [] );
+ }
+
+ return new MappedIterator(
+ $claimed,
+ function ( $value ) {
+ return $this->jobFromSpecInternal( $value );
+ }
+ );
+ }
+
+ /**
+ * @param IJobSpecification $spec
+ *
+ * @return Job
+ */
+ public function jobFromSpecInternal( IJobSpecification $spec ) {
+ return Job::factory( $spec->getType(), $spec->getTitle(), $spec->getParams() );
+ }
+
+ /**
+ * @param string $field
+ * @param mixed $init
+ *
+ * @return mixed
+ */
+ private function &getQueueData( $field, $init = null ) {
+ if ( !isset( self::$data[$this->type][$this->wiki][$field] ) ) {
+ if ( $init !== null ) {
+ self::$data[$this->type][$this->wiki][$field] = $init;
+ } else {
+ return $init;
+ }
+ }
+
+ return self::$data[$this->type][$this->wiki][$field];
+ }
+}
diff --git a/www/wiki/includes/jobqueue/JobQueueRedis.php b/www/wiki/includes/jobqueue/JobQueueRedis.php
new file mode 100644
index 00000000..7dad014e
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobQueueRedis.php
@@ -0,0 +1,820 @@
+<?php
+/**
+ * Redis-backed job queue code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class to handle job queues stored in Redis
+ *
+ * This is a faster and less resource-intensive job queue than JobQueueDB.
+ * All data for a queue using this class is placed into one redis server.
+ * The mediawiki/services/jobrunner background service must be set up and running.
+ *
+ * There are eight main redis keys (per queue) used to track jobs:
+ * - l-unclaimed : A list of job IDs used for ready unclaimed jobs
+ * - z-claimed : A sorted set of (job ID, UNIX timestamp as score) used for job retries
+ * - z-abandoned : A sorted set of (job ID, UNIX timestamp as score) used for broken jobs
+ * - z-delayed : A sorted set of (job ID, UNIX timestamp as score) used for delayed jobs
+ * - h-idBySha1 : A hash of (SHA1 => job ID) for unclaimed jobs used for de-duplication
+ * - h-sha1ById : A hash of (job ID => SHA1) for unclaimed jobs used for de-duplication
+ * - h-attempts : A hash of (job ID => attempt count) used for job claiming/retries
+ * - h-data : A hash of (job ID => serialized blobs) for job storage
+ * A job ID can be in only one of z-delayed, l-unclaimed, z-claimed, and z-abandoned.
+ * If an ID appears in any of those lists, it should have a h-data entry for its ID.
+ * If a job has a SHA1 de-duplication value and its ID is in l-unclaimed or z-delayed, then
+ * there should be no other such jobs with that SHA1. Every h-idBySha1 entry has an h-sha1ById
+ * entry and every h-sha1ById must refer to an ID that is l-unclaimed. If a job has its
+ * ID in z-claimed or z-abandoned, then it must also have an h-attempts entry for its ID.
+ *
+ * The following keys are used to track queue states:
+ * - s-queuesWithJobs : A set of all queues with non-abandoned jobs
+ *
+ * The background service takes care of undelaying, recycling, and pruning jobs as well as
+ * removing s-queuesWithJobs entries as queues empty.
+ *
+ * Additionally, "rootjob:* keys track "root jobs" used for additional de-duplication.
+ * Aside from root job keys, all keys have no expiry, and are only removed when jobs are run.
+ * All the keys are prefixed with the relevant wiki ID information.
+ *
+ * This class requires Redis 2.6 as it makes use Lua scripts for fast atomic operations.
+ * Additionally, it should be noted that redis has different persistence modes, such
+ * as rdb snapshots, journaling, and no persistence. Appropriate configuration should be
+ * made on the servers based on what queues are using it and what tolerance they have.
+ *
+ * @ingroup JobQueue
+ * @ingroup Redis
+ * @since 1.22
+ */
+class JobQueueRedis extends JobQueue {
+ /** @var RedisConnectionPool */
+ protected $redisPool;
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var string Server address */
+ protected $server;
+ /** @var string Compression method to use */
+ protected $compression;
+
+ const MAX_PUSH_SIZE = 25; // avoid tying up the server
+
+ /**
+ * @param array $params Possible keys:
+ * - redisConfig : An array of parameters to RedisConnectionPool::__construct().
+ * Note that the serializer option is ignored as "none" is always used.
+ * - redisServer : A hostname/port combination or the absolute path of a UNIX socket.
+ * If a hostname is specified but no port, the standard port number
+ * 6379 will be used. Required.
+ * - compression : The type of compression to use; one of (none,gzip).
+ * - daemonized : Set to true if the redisJobRunnerService runs in the background.
+ * This will disable job recycling/undelaying from the MediaWiki side
+ * to avoid redundance and out-of-sync configuration.
+ * @throws InvalidArgumentException
+ */
+ public function __construct( array $params ) {
+ parent::__construct( $params );
+ $params['redisConfig']['serializer'] = 'none'; // make it easy to use Lua
+ $this->server = $params['redisServer'];
+ $this->compression = isset( $params['compression'] ) ? $params['compression'] : 'none';
+ $this->redisPool = RedisConnectionPool::singleton( $params['redisConfig'] );
+ if ( empty( $params['daemonized'] ) ) {
+ throw new InvalidArgumentException(
+ "Non-daemonized mode is no longer supported. Please install the " .
+ "mediawiki/services/jobrunner service and update \$wgJobTypeConf as needed." );
+ }
+ $this->logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'redis' );
+ }
+
+ protected function supportedOrders() {
+ return [ 'timestamp', 'fifo' ];
+ }
+
+ protected function optimalOrder() {
+ return 'fifo';
+ }
+
+ protected function supportsDelayedJobs() {
+ return true;
+ }
+
+ /**
+ * @see JobQueue::doIsEmpty()
+ * @return bool
+ * @throws JobQueueError
+ */
+ protected function doIsEmpty() {
+ return $this->doGetSize() == 0;
+ }
+
+ /**
+ * @see JobQueue::doGetSize()
+ * @return int
+ * @throws JobQueueError
+ */
+ protected function doGetSize() {
+ $conn = $this->getConnection();
+ try {
+ return $conn->lSize( $this->getQueueKey( 'l-unclaimed' ) );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+ }
+
+ /**
+ * @see JobQueue::doGetAcquiredCount()
+ * @return int
+ * @throws JobQueueError
+ */
+ protected function doGetAcquiredCount() {
+ $conn = $this->getConnection();
+ try {
+ $conn->multi( Redis::PIPELINE );
+ $conn->zSize( $this->getQueueKey( 'z-claimed' ) );
+ $conn->zSize( $this->getQueueKey( 'z-abandoned' ) );
+
+ return array_sum( $conn->exec() );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+ }
+
+ /**
+ * @see JobQueue::doGetDelayedCount()
+ * @return int
+ * @throws JobQueueError
+ */
+ protected function doGetDelayedCount() {
+ $conn = $this->getConnection();
+ try {
+ return $conn->zSize( $this->getQueueKey( 'z-delayed' ) );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+ }
+
+ /**
+ * @see JobQueue::doGetAbandonedCount()
+ * @return int
+ * @throws JobQueueError
+ */
+ protected function doGetAbandonedCount() {
+ $conn = $this->getConnection();
+ try {
+ return $conn->zSize( $this->getQueueKey( 'z-abandoned' ) );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+ }
+
+ /**
+ * @see JobQueue::doBatchPush()
+ * @param IJobSpecification[] $jobs
+ * @param int $flags
+ * @return void
+ * @throws JobQueueError
+ */
+ protected function doBatchPush( array $jobs, $flags ) {
+ // Convert the jobs into field maps (de-duplicated against each other)
+ $items = []; // (job ID => job fields map)
+ foreach ( $jobs as $job ) {
+ $item = $this->getNewJobFields( $job );
+ if ( strlen( $item['sha1'] ) ) { // hash identifier => de-duplicate
+ $items[$item['sha1']] = $item;
+ } else {
+ $items[$item['uuid']] = $item;
+ }
+ }
+
+ if ( !count( $items ) ) {
+ return; // nothing to do
+ }
+
+ $conn = $this->getConnection();
+ try {
+ // Actually push the non-duplicate jobs into the queue...
+ if ( $flags & self::QOS_ATOMIC ) {
+ $batches = [ $items ]; // all or nothing
+ } else {
+ $batches = array_chunk( $items, self::MAX_PUSH_SIZE );
+ }
+ $failed = 0;
+ $pushed = 0;
+ foreach ( $batches as $itemBatch ) {
+ $added = $this->pushBlobs( $conn, $itemBatch );
+ if ( is_int( $added ) ) {
+ $pushed += $added;
+ } else {
+ $failed += count( $itemBatch );
+ }
+ }
+ JobQueue::incrStats( 'inserts', $this->type, count( $items ) );
+ JobQueue::incrStats( 'inserts_actual', $this->type, $pushed );
+ JobQueue::incrStats( 'dupe_inserts', $this->type,
+ count( $items ) - $failed - $pushed );
+ if ( $failed > 0 ) {
+ $err = "Could not insert {$failed} {$this->type} job(s).";
+ wfDebugLog( 'JobQueueRedis', $err );
+ throw new RedisException( $err );
+ }
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+ }
+
+ /**
+ * @param RedisConnRef $conn
+ * @param array $items List of results from JobQueueRedis::getNewJobFields()
+ * @return int Number of jobs inserted (duplicates are ignored)
+ * @throws RedisException
+ */
+ protected function pushBlobs( RedisConnRef $conn, array $items ) {
+ $args = [ $this->encodeQueueName() ];
+ // Next args come in 4s ([id, sha1, rtime, blob [, id, sha1, rtime, blob ... ] ] )
+ foreach ( $items as $item ) {
+ $args[] = (string)$item['uuid'];
+ $args[] = (string)$item['sha1'];
+ $args[] = (string)$item['rtimestamp'];
+ $args[] = (string)$this->serialize( $item );
+ }
+ static $script =
+ /** @lang Lua */
+<<<LUA
+ local kUnclaimed, kSha1ById, kIdBySha1, kDelayed, kData, kQwJobs = unpack(KEYS)
+ -- First argument is the queue ID
+ local queueId = ARGV[1]
+ -- Next arguments all come in 4s (one per job)
+ local variadicArgCount = #ARGV - 1
+ if variadicArgCount % 4 ~= 0 then
+ return redis.error_reply('Unmatched arguments')
+ end
+ -- Insert each job into this queue as needed
+ local pushed = 0
+ for i = 2,#ARGV,4 do
+ local id,sha1,rtimestamp,blob = ARGV[i],ARGV[i+1],ARGV[i+2],ARGV[i+3]
+ if sha1 == '' or redis.call('hExists',kIdBySha1,sha1) == 0 then
+ if 1*rtimestamp > 0 then
+ -- Insert into delayed queue (release time as score)
+ redis.call('zAdd',kDelayed,rtimestamp,id)
+ else
+ -- Insert into unclaimed queue
+ redis.call('lPush',kUnclaimed,id)
+ end
+ if sha1 ~= '' then
+ redis.call('hSet',kSha1ById,id,sha1)
+ redis.call('hSet',kIdBySha1,sha1,id)
+ end
+ redis.call('hSet',kData,id,blob)
+ pushed = pushed + 1
+ end
+ end
+ -- Mark this queue as having jobs
+ redis.call('sAdd',kQwJobs,queueId)
+ return pushed
+LUA;
+ return $conn->luaEval( $script,
+ array_merge(
+ [
+ $this->getQueueKey( 'l-unclaimed' ), # KEYS[1]
+ $this->getQueueKey( 'h-sha1ById' ), # KEYS[2]
+ $this->getQueueKey( 'h-idBySha1' ), # KEYS[3]
+ $this->getQueueKey( 'z-delayed' ), # KEYS[4]
+ $this->getQueueKey( 'h-data' ), # KEYS[5]
+ $this->getGlobalKey( 's-queuesWithJobs' ), # KEYS[6]
+ ],
+ $args
+ ),
+ 6 # number of first argument(s) that are keys
+ );
+ }
+
+ /**
+ * @see JobQueue::doPop()
+ * @return Job|bool
+ * @throws JobQueueError
+ */
+ protected function doPop() {
+ $job = false;
+
+ $conn = $this->getConnection();
+ try {
+ do {
+ $blob = $this->popAndAcquireBlob( $conn );
+ if ( !is_string( $blob ) ) {
+ break; // no jobs; nothing to do
+ }
+
+ JobQueue::incrStats( 'pops', $this->type );
+ $item = $this->unserialize( $blob );
+ if ( $item === false ) {
+ wfDebugLog( 'JobQueueRedis', "Could not unserialize {$this->type} job." );
+ continue;
+ }
+
+ // If $item is invalid, the runner loop recyling will cleanup as needed
+ $job = $this->getJobFromFields( $item ); // may be false
+ } while ( !$job ); // job may be false if invalid
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+
+ return $job;
+ }
+
+ /**
+ * @param RedisConnRef $conn
+ * @return array Serialized string or false
+ * @throws RedisException
+ */
+ protected function popAndAcquireBlob( RedisConnRef $conn ) {
+ static $script =
+ /** @lang Lua */
+<<<LUA
+ local kUnclaimed, kSha1ById, kIdBySha1, kClaimed, kAttempts, kData = unpack(KEYS)
+ local rTime = unpack(ARGV)
+ -- Pop an item off the queue
+ local id = redis.call('rPop',kUnclaimed)
+ if not id then
+ return false
+ end
+ -- Allow new duplicates of this job
+ local sha1 = redis.call('hGet',kSha1ById,id)
+ if sha1 then redis.call('hDel',kIdBySha1,sha1) end
+ redis.call('hDel',kSha1ById,id)
+ -- Mark the jobs as claimed and return it
+ redis.call('zAdd',kClaimed,rTime,id)
+ redis.call('hIncrBy',kAttempts,id,1)
+ return redis.call('hGet',kData,id)
+LUA;
+ return $conn->luaEval( $script,
+ [
+ $this->getQueueKey( 'l-unclaimed' ), # KEYS[1]
+ $this->getQueueKey( 'h-sha1ById' ), # KEYS[2]
+ $this->getQueueKey( 'h-idBySha1' ), # KEYS[3]
+ $this->getQueueKey( 'z-claimed' ), # KEYS[4]
+ $this->getQueueKey( 'h-attempts' ), # KEYS[5]
+ $this->getQueueKey( 'h-data' ), # KEYS[6]
+ time(), # ARGV[1] (injected to be replication-safe)
+ ],
+ 6 # number of first argument(s) that are keys
+ );
+ }
+
+ /**
+ * @see JobQueue::doAck()
+ * @param Job $job
+ * @return Job|bool
+ * @throws UnexpectedValueException
+ * @throws JobQueueError
+ */
+ protected function doAck( Job $job ) {
+ if ( !isset( $job->metadata['uuid'] ) ) {
+ throw new UnexpectedValueException( "Job of type '{$job->getType()}' has no UUID." );
+ }
+
+ $uuid = $job->metadata['uuid'];
+ $conn = $this->getConnection();
+ try {
+ static $script =
+ /** @lang Lua */
+<<<LUA
+ local kClaimed, kAttempts, kData = unpack(KEYS)
+ local id = unpack(ARGV)
+ -- Unmark the job as claimed
+ local removed = redis.call('zRem',kClaimed,id)
+ -- Check if the job was recycled
+ if removed == 0 then
+ return 0
+ end
+ -- Delete the retry data
+ redis.call('hDel',kAttempts,id)
+ -- Delete the job data itself
+ return redis.call('hDel',kData,id)
+LUA;
+ $res = $conn->luaEval( $script,
+ [
+ $this->getQueueKey( 'z-claimed' ), # KEYS[1]
+ $this->getQueueKey( 'h-attempts' ), # KEYS[2]
+ $this->getQueueKey( 'h-data' ), # KEYS[3]
+ $uuid # ARGV[1]
+ ],
+ 3 # number of first argument(s) that are keys
+ );
+
+ if ( !$res ) {
+ wfDebugLog( 'JobQueueRedis', "Could not acknowledge {$this->type} job $uuid." );
+
+ return false;
+ }
+
+ JobQueue::incrStats( 'acks', $this->type );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+
+ return true;
+ }
+
+ /**
+ * @see JobQueue::doDeduplicateRootJob()
+ * @param IJobSpecification $job
+ * @return bool
+ * @throws JobQueueError
+ * @throws LogicException
+ */
+ protected function doDeduplicateRootJob( IJobSpecification $job ) {
+ if ( !$job->hasRootJobParams() ) {
+ throw new LogicException( "Cannot register root job; missing parameters." );
+ }
+ $params = $job->getRootJobParams();
+
+ $key = $this->getRootJobCacheKey( $params['rootJobSignature'] );
+
+ $conn = $this->getConnection();
+ try {
+ $timestamp = $conn->get( $key ); // current last timestamp of this job
+ if ( $timestamp && $timestamp >= $params['rootJobTimestamp'] ) {
+ return true; // a newer version of this root job was enqueued
+ }
+
+ // Update the timestamp of the last root job started at the location...
+ return $conn->set( $key, $params['rootJobTimestamp'], self::ROOTJOB_TTL ); // 2 weeks
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+ }
+
+ /**
+ * @see JobQueue::doIsRootJobOldDuplicate()
+ * @param Job $job
+ * @return bool
+ * @throws JobQueueError
+ */
+ protected function doIsRootJobOldDuplicate( Job $job ) {
+ if ( !$job->hasRootJobParams() ) {
+ return false; // job has no de-deplication info
+ }
+ $params = $job->getRootJobParams();
+
+ $conn = $this->getConnection();
+ try {
+ // Get the last time this root job was enqueued
+ $timestamp = $conn->get( $this->getRootJobCacheKey( $params['rootJobSignature'] ) );
+ } catch ( RedisException $e ) {
+ $timestamp = false;
+ $this->throwRedisException( $conn, $e );
+ }
+
+ // Check if a new root job was started at the location after this one's...
+ return ( $timestamp && $timestamp > $params['rootJobTimestamp'] );
+ }
+
+ /**
+ * @see JobQueue::doDelete()
+ * @return bool
+ * @throws JobQueueError
+ */
+ protected function doDelete() {
+ static $props = [ 'l-unclaimed', 'z-claimed', 'z-abandoned',
+ 'z-delayed', 'h-idBySha1', 'h-sha1ById', 'h-attempts', 'h-data' ];
+
+ $conn = $this->getConnection();
+ try {
+ $keys = [];
+ foreach ( $props as $prop ) {
+ $keys[] = $this->getQueueKey( $prop );
+ }
+
+ $ok = ( $conn->delete( $keys ) !== false );
+ $conn->sRem( $this->getGlobalKey( 's-queuesWithJobs' ), $this->encodeQueueName() );
+
+ return $ok;
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+ }
+
+ /**
+ * @see JobQueue::getAllQueuedJobs()
+ * @return Iterator
+ * @throws JobQueueError
+ */
+ public function getAllQueuedJobs() {
+ $conn = $this->getConnection();
+ try {
+ $uids = $conn->lRange( $this->getQueueKey( 'l-unclaimed' ), 0, -1 );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+
+ return $this->getJobIterator( $conn, $uids );
+ }
+
+ /**
+ * @see JobQueue::getAllDelayedJobs()
+ * @return Iterator
+ * @throws JobQueueError
+ */
+ public function getAllDelayedJobs() {
+ $conn = $this->getConnection();
+ try {
+ $uids = $conn->zRange( $this->getQueueKey( 'z-delayed' ), 0, -1 );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+
+ return $this->getJobIterator( $conn, $uids );
+ }
+
+ /**
+ * @see JobQueue::getAllAcquiredJobs()
+ * @return Iterator
+ * @throws JobQueueError
+ */
+ public function getAllAcquiredJobs() {
+ $conn = $this->getConnection();
+ try {
+ $uids = $conn->zRange( $this->getQueueKey( 'z-claimed' ), 0, -1 );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+
+ return $this->getJobIterator( $conn, $uids );
+ }
+
+ /**
+ * @see JobQueue::getAllAbandonedJobs()
+ * @return Iterator
+ * @throws JobQueueError
+ */
+ public function getAllAbandonedJobs() {
+ $conn = $this->getConnection();
+ try {
+ $uids = $conn->zRange( $this->getQueueKey( 'z-abandoned' ), 0, -1 );
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+
+ return $this->getJobIterator( $conn, $uids );
+ }
+
+ /**
+ * @param RedisConnRef $conn
+ * @param array $uids List of job UUIDs
+ * @return MappedIterator
+ */
+ protected function getJobIterator( RedisConnRef $conn, array $uids ) {
+ return new MappedIterator(
+ $uids,
+ function ( $uid ) use ( $conn ) {
+ return $this->getJobFromUidInternal( $uid, $conn );
+ },
+ [ 'accept' => function ( $job ) {
+ return is_object( $job );
+ } ]
+ );
+ }
+
+ public function getCoalesceLocationInternal() {
+ return "RedisServer:" . $this->server;
+ }
+
+ protected function doGetSiblingQueuesWithJobs( array $types ) {
+ return array_keys( array_filter( $this->doGetSiblingQueueSizes( $types ) ) );
+ }
+
+ protected function doGetSiblingQueueSizes( array $types ) {
+ $sizes = []; // (type => size)
+ $types = array_values( $types ); // reindex
+ $conn = $this->getConnection();
+ try {
+ $conn->multi( Redis::PIPELINE );
+ foreach ( $types as $type ) {
+ $conn->lSize( $this->getQueueKey( 'l-unclaimed', $type ) );
+ }
+ $res = $conn->exec();
+ if ( is_array( $res ) ) {
+ foreach ( $res as $i => $size ) {
+ $sizes[$types[$i]] = $size;
+ }
+ }
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+
+ return $sizes;
+ }
+
+ /**
+ * This function should not be called outside JobQueueRedis
+ *
+ * @param string $uid
+ * @param RedisConnRef $conn
+ * @return Job|bool Returns false if the job does not exist
+ * @throws JobQueueError
+ * @throws UnexpectedValueException
+ */
+ public function getJobFromUidInternal( $uid, RedisConnRef $conn ) {
+ try {
+ $data = $conn->hGet( $this->getQueueKey( 'h-data' ), $uid );
+ if ( $data === false ) {
+ return false; // not found
+ }
+ $item = $this->unserialize( $data );
+ if ( !is_array( $item ) ) { // this shouldn't happen
+ throw new UnexpectedValueException( "Could not find job with ID '$uid'." );
+ }
+ $title = Title::makeTitle( $item['namespace'], $item['title'] );
+ $job = Job::factory( $item['type'], $title, $item['params'] );
+ $job->metadata['uuid'] = $item['uuid'];
+ $job->metadata['timestamp'] = $item['timestamp'];
+ // Add in attempt count for debugging at showJobs.php
+ $job->metadata['attempts'] = $conn->hGet( $this->getQueueKey( 'h-attempts' ), $uid );
+
+ return $job;
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+ }
+
+ /**
+ * @return array List of (wiki,type) tuples for queues with non-abandoned jobs
+ * @throws JobQueueConnectionError
+ * @throws JobQueueError
+ */
+ public function getServerQueuesWithJobs() {
+ $queues = [];
+
+ $conn = $this->getConnection();
+ try {
+ $set = $conn->sMembers( $this->getGlobalKey( 's-queuesWithJobs' ) );
+ foreach ( $set as $queue ) {
+ $queues[] = $this->decodeQueueName( $queue );
+ }
+ } catch ( RedisException $e ) {
+ $this->throwRedisException( $conn, $e );
+ }
+
+ return $queues;
+ }
+
+ /**
+ * @param IJobSpecification $job
+ * @return array
+ */
+ protected function getNewJobFields( IJobSpecification $job ) {
+ return [
+ // Fields that describe the nature of the job
+ 'type' => $job->getType(),
+ 'namespace' => $job->getTitle()->getNamespace(),
+ 'title' => $job->getTitle()->getDBkey(),
+ 'params' => $job->getParams(),
+ // Some jobs cannot run until a "release timestamp"
+ 'rtimestamp' => $job->getReleaseTimestamp() ?: 0,
+ // Additional job metadata
+ 'uuid' => UIDGenerator::newRawUUIDv4( UIDGenerator::QUICK_RAND ),
+ 'sha1' => $job->ignoreDuplicates()
+ ? Wikimedia\base_convert( sha1( serialize( $job->getDeduplicationInfo() ) ), 16, 36, 31 )
+ : '',
+ 'timestamp' => time() // UNIX timestamp
+ ];
+ }
+
+ /**
+ * @param array $fields
+ * @return Job|bool
+ */
+ protected function getJobFromFields( array $fields ) {
+ $title = Title::makeTitle( $fields['namespace'], $fields['title'] );
+ $job = Job::factory( $fields['type'], $title, $fields['params'] );
+ $job->metadata['uuid'] = $fields['uuid'];
+ $job->metadata['timestamp'] = $fields['timestamp'];
+
+ return $job;
+ }
+
+ /**
+ * @param array $fields
+ * @return string Serialized and possibly compressed version of $fields
+ */
+ protected function serialize( array $fields ) {
+ $blob = serialize( $fields );
+ if ( $this->compression === 'gzip'
+ && strlen( $blob ) >= 1024
+ && function_exists( 'gzdeflate' )
+ ) {
+ $object = (object)[ 'blob' => gzdeflate( $blob ), 'enc' => 'gzip' ];
+ $blobz = serialize( $object );
+
+ return ( strlen( $blobz ) < strlen( $blob ) ) ? $blobz : $blob;
+ } else {
+ return $blob;
+ }
+ }
+
+ /**
+ * @param string $blob
+ * @return array|bool Unserialized version of $blob or false
+ */
+ protected function unserialize( $blob ) {
+ $fields = unserialize( $blob );
+ if ( is_object( $fields ) ) {
+ if ( $fields->enc === 'gzip' && function_exists( 'gzinflate' ) ) {
+ $fields = unserialize( gzinflate( $fields->blob ) );
+ } else {
+ $fields = false;
+ }
+ }
+
+ return is_array( $fields ) ? $fields : false;
+ }
+
+ /**
+ * Get a connection to the server that handles all sub-queues for this queue
+ *
+ * @return RedisConnRef
+ * @throws JobQueueConnectionError
+ */
+ protected function getConnection() {
+ $conn = $this->redisPool->getConnection( $this->server, $this->logger );
+ if ( !$conn ) {
+ throw new JobQueueConnectionError(
+ "Unable to connect to redis server {$this->server}." );
+ }
+
+ return $conn;
+ }
+
+ /**
+ * @param RedisConnRef $conn
+ * @param RedisException $e
+ * @throws JobQueueError
+ */
+ protected function throwRedisException( RedisConnRef $conn, $e ) {
+ $this->redisPool->handleError( $conn, $e );
+ throw new JobQueueError( "Redis server error: {$e->getMessage()}\n" );
+ }
+
+ /**
+ * @return string JSON
+ */
+ private function encodeQueueName() {
+ return json_encode( [ $this->type, $this->wiki ] );
+ }
+
+ /**
+ * @param string $name JSON
+ * @return array (type, wiki)
+ */
+ private function decodeQueueName( $name ) {
+ return json_decode( $name );
+ }
+
+ /**
+ * @param string $name
+ * @return string
+ */
+ private function getGlobalKey( $name ) {
+ $parts = [ 'global', 'jobqueue', $name ];
+ foreach ( $parts as $part ) {
+ if ( !preg_match( '/[a-zA-Z0-9_-]+/', $part ) ) {
+ throw new InvalidArgumentException( "Key part characters are out of range." );
+ }
+ }
+
+ return implode( ':', $parts );
+ }
+
+ /**
+ * @param string $prop
+ * @param string|null $type Override this for sibling queues
+ * @return string
+ */
+ private function getQueueKey( $prop, $type = null ) {
+ $type = is_string( $type ) ? $type : $this->type;
+ list( $db, $prefix ) = wfSplitWikiID( $this->wiki );
+ $keyspace = $prefix ? "$db-$prefix" : $db;
+
+ $parts = [ $keyspace, 'jobqueue', $type, $prop ];
+
+ // Parts are typically ASCII, but encode for sanity to escape ":"
+ return implode( ':', array_map( 'rawurlencode', $parts ) );
+ }
+}
diff --git a/www/wiki/includes/jobqueue/JobQueueSecondTestQueue.php b/www/wiki/includes/jobqueue/JobQueueSecondTestQueue.php
new file mode 100644
index 00000000..4e3409af
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobQueueSecondTestQueue.php
@@ -0,0 +1,282 @@
+<?php
+
+/**
+ * A wrapper for the JobQueue that delegates all the method calls to a single,
+ * main queue, and also pushes all the jobs to a second job queue that's being
+ * debugged.
+ *
+ * This class was temporary added to test transitioning to the JobQueueEventBus
+ * and will removed after the transition is completed. This code is only needed
+ * while we are testing the new infrastructure to be able to compare the results
+ * between the queue implementations and make sure that they process the same jobs,
+ * deduplicate correctly, compare the delays, backlogs and make sure no jobs are lost.
+ * When the new infrastructure is well tested this will not be needed any more.
+ *
+ * @deprecated since 1.30
+ * @since 1.30
+ */
+class JobQueueSecondTestQueue extends JobQueue {
+
+ /**
+ * @var JobQueue
+ */
+ private $mainQueue;
+
+ /**
+ * @var JobQueue
+ */
+ private $debugQueue;
+
+ protected function __construct( array $params ) {
+ if ( !isset( $params['mainqueue'] ) ) {
+ throw new MWException( "mainqueue parameter must be provided to the debug queue" );
+ }
+
+ if ( !isset( $params['debugqueue'] ) ) {
+ throw new MWException( "debugqueue parameter must be provided to the debug queue" );
+ }
+
+ $conf = [ 'wiki' => $params['wiki'], 'type' => $params['type'] ];
+ $this->mainQueue = JobQueue::factory( $params['mainqueue'] + $conf );
+ $this->debugQueue = JobQueue::factory( $params['debugqueue'] + $conf );
+
+ // We need to construct parent after creating the main and debug queue
+ // because super constructor calls some methods we delegate to the main queue.
+ parent::__construct( $params );
+ }
+
+ /**
+ * Get the allowed queue orders for configuration validation
+ *
+ * @return array Subset of (random, timestamp, fifo, undefined)
+ */
+ protected function supportedOrders() {
+ return $this->mainQueue->supportedOrders();
+ }
+
+ /**
+ * Get the default queue order to use if configuration does not specify one
+ *
+ * @return string One of (random, timestamp, fifo, undefined)
+ */
+ protected function optimalOrder() {
+ return $this->mainQueue->optimalOrder();
+ }
+
+ /**
+ * Find out if delayed jobs are supported for configuration validation
+ *
+ * @return bool Whether delayed jobs are supported
+ */
+ protected function supportsDelayedJobs() {
+ return $this->mainQueue->supportsDelayedJobs();
+ }
+
+ /**
+ * @see JobQueue::isEmpty()
+ * @return bool
+ */
+ protected function doIsEmpty() {
+ return $this->mainQueue->doIsEmpty();
+ }
+
+ /**
+ * @see JobQueue::getSize()
+ * @return int
+ */
+ protected function doGetSize() {
+ return $this->mainQueue->doGetSize();
+ }
+
+ /**
+ * @see JobQueue::getAcquiredCount()
+ * @return int
+ */
+ protected function doGetAcquiredCount() {
+ return $this->mainQueue->doGetAcquiredCount();
+ }
+
+ /**
+ * @see JobQueue::getDelayedCount()
+ * @return int
+ */
+ protected function doGetDelayedCount() {
+ return $this->mainQueue->doGetDelayedCount();
+ }
+
+ /**
+ * @see JobQueue::getAbandonedCount()
+ * @return int
+ */
+ protected function doGetAbandonedCount() {
+ return $this->mainQueue->doGetAbandonedCount();
+ }
+
+ /**
+ * @see JobQueue::batchPush()
+ * @param IJobSpecification[] $jobs
+ * @param int $flags
+ */
+ protected function doBatchPush( array $jobs, $flags ) {
+ $this->mainQueue->doBatchPush( $jobs, $flags );
+
+ try {
+ $this->debugQueue->doBatchPush( $jobs, $flags );
+ } catch ( Exception $exception ) {
+ MWExceptionHandler::logException( $exception );
+ }
+ }
+
+ /**
+ * @see JobQueue::pop()
+ * @return Job|bool
+ */
+ protected function doPop() {
+ return $this->mainQueue->doPop();
+ }
+
+ /**
+ * @see JobQueue::ack()
+ * @param Job $job
+ * @return Job|bool
+ */
+ protected function doAck( Job $job ) {
+ return $this->mainQueue->doAck( $job );
+ }
+
+ /**
+ * @see JobQueue::deduplicateRootJob()
+ * @param IJobSpecification $job
+ * @throws MWException
+ * @return bool
+ */
+ protected function doDeduplicateRootJob( IJobSpecification $job ) {
+ return $this->mainQueue->doDeduplicateRootJob( $job );
+ }
+
+ /**
+ * @see JobQueue::isRootJobOldDuplicate()
+ * @param Job $job
+ * @return bool
+ */
+ protected function doIsRootJobOldDuplicate( Job $job ) {
+ return $this->mainQueue->doIsRootJobOldDuplicate( $job );
+ }
+
+ /**
+ * @param string $signature Hash identifier of the root job
+ * @return string
+ */
+ protected function getRootJobCacheKey( $signature ) {
+ return $this->mainQueue->getRootJobCacheKey( $signature );
+ }
+
+ /**
+ * @see JobQueue::delete()
+ * @return bool
+ * @throws MWException
+ */
+ protected function doDelete() {
+ return $this->mainQueue->doDelete();
+ }
+
+ /**
+ * @see JobQueue::waitForBackups()
+ * @return void
+ */
+ protected function doWaitForBackups() {
+ $this->mainQueue->doWaitForBackups();
+ }
+
+ /**
+ * @see JobQueue::flushCaches()
+ * @return void
+ */
+ protected function doFlushCaches() {
+ $this->mainQueue->doFlushCaches();
+ }
+
+ /**
+ * Get an iterator to traverse over all available jobs in this queue.
+ * This does not include jobs that are currently acquired or delayed.
+ * Note: results may be stale if the queue is concurrently modified.
+ *
+ * @return Iterator
+ * @throws JobQueueError
+ */
+ public function getAllQueuedJobs() {
+ return $this->mainQueue->getAllQueuedJobs();
+ }
+
+ /**
+ * Get an iterator to traverse over all delayed jobs in this queue.
+ * Note: results may be stale if the queue is concurrently modified.
+ *
+ * @return Iterator
+ * @throws JobQueueError
+ * @since 1.22
+ */
+ public function getAllDelayedJobs() {
+ return $this->mainQueue->getAllDelayedJobs();
+ }
+
+ /**
+ * Get an iterator to traverse over all claimed jobs in this queue
+ *
+ * Callers should be quick to iterator over it or few results
+ * will be returned due to jobs being acknowledged and deleted
+ *
+ * @return Iterator
+ * @throws JobQueueError
+ * @since 1.26
+ */
+ public function getAllAcquiredJobs() {
+ return $this->mainQueue->getAllAcquiredJobs();
+ }
+
+ /**
+ * Get an iterator to traverse over all abandoned jobs in this queue
+ *
+ * @return Iterator
+ * @throws JobQueueError
+ * @since 1.25
+ */
+ public function getAllAbandonedJobs() {
+ return $this->mainQueue->getAllAbandonedJobs();
+ }
+
+ /**
+ * Do not use this function outside of JobQueue/JobQueueGroup
+ *
+ * @return string
+ * @since 1.22
+ */
+ public function getCoalesceLocationInternal() {
+ return $this->mainQueue->getCoalesceLocationInternal();
+ }
+
+ /**
+ * @see JobQueue::getSiblingQueuesWithJobs()
+ * @param array $types List of queues types
+ * @return array|null (list of queue types) or null if unsupported
+ */
+ protected function doGetSiblingQueuesWithJobs( array $types ) {
+ return $this->mainQueue->doGetSiblingQueuesWithJobs( $types );
+ }
+
+ /**
+ * @see JobQueue::getSiblingQueuesSize()
+ * @param array $types List of queues types
+ * @return array|null (list of queue types) or null if unsupported
+ */
+ protected function doGetSiblingQueueSizes( array $types ) {
+ return $this->mainQueue->doGetSiblingQueueSizes( $types );
+ }
+
+ /**
+ * @throws JobQueueReadOnlyError
+ */
+ protected function assertNotReadOnly() {
+ $this->mainQueue->assertNotReadOnly();
+ }
+}
diff --git a/www/wiki/includes/jobqueue/JobRunner.php b/www/wiki/includes/jobqueue/JobRunner.php
new file mode 100644
index 00000000..db881d5e
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobRunner.php
@@ -0,0 +1,606 @@
+<?php
+/**
+ * Job queue runner utility methods
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Logger\LoggerFactory;
+use Liuggio\StatsdClient\Factory\StatsdDataFactory;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Rdbms\LBFactory;
+use Wikimedia\Rdbms\DBError;
+use Wikimedia\Rdbms\DBReplicationWaitError;
+
+/**
+ * Job queue runner utility methods
+ *
+ * @ingroup JobQueue
+ * @since 1.24
+ */
+class JobRunner implements LoggerAwareInterface {
+ /** @var Config */
+ protected $config;
+ /** @var callable|null Debug output handler */
+ protected $debug;
+
+ /**
+ * @var LoggerInterface $logger
+ */
+ protected $logger;
+
+ const MAX_ALLOWED_LAG = 3; // abort if more than this much DB lag is present
+ const LAG_CHECK_PERIOD = 1.0; // check replica DB lag this many seconds
+ const ERROR_BACKOFF_TTL = 1; // seconds to back off a queue due to errors
+ const READONLY_BACKOFF_TTL = 30; // seconds to back off a queue due to read-only errors
+
+ /**
+ * @param callable $debug Optional debug output handler
+ */
+ public function setDebugHandler( $debug ) {
+ $this->debug = $debug;
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ * @return void
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ */
+ public function __construct( LoggerInterface $logger = null ) {
+ if ( $logger === null ) {
+ $logger = LoggerFactory::getInstance( 'runJobs' );
+ }
+ $this->setLogger( $logger );
+ $this->config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+
+ /**
+ * Run jobs of the specified number/type for the specified time
+ *
+ * The response map has a 'job' field that lists status of each job, including:
+ * - type : the job type
+ * - status : ok/failed
+ * - error : any error message string
+ * - time : the job run time in ms
+ * The response map also has:
+ * - backoffs : the (job type => seconds) map of backoff times
+ * - elapsed : the total time spent running tasks in ms
+ * - reached : the reason the script finished, one of (none-ready, job-limit, time-limit,
+ * memory-limit)
+ *
+ * This method outputs status information only if a debug handler was set.
+ * Any exceptions are caught and logged, but are not reported as output.
+ *
+ * @param array $options Map of parameters:
+ * - type : the job type (or false for the default types)
+ * - maxJobs : maximum number of jobs to run
+ * - maxTime : maximum time in seconds before stopping
+ * - throttle : whether to respect job backoff configuration
+ * @return array Summary response that can easily be JSON serialized
+ */
+ public function run( array $options ) {
+ $jobClasses = $this->config->get( 'JobClasses' );
+ $profilerLimits = $this->config->get( 'TrxProfilerLimits' );
+
+ $response = [ 'jobs' => [], 'reached' => 'none-ready' ];
+
+ $type = isset( $options['type'] ) ? $options['type'] : false;
+ $maxJobs = isset( $options['maxJobs'] ) ? $options['maxJobs'] : false;
+ $maxTime = isset( $options['maxTime'] ) ? $options['maxTime'] : false;
+ $noThrottle = isset( $options['throttle'] ) && !$options['throttle'];
+
+ // Bail if job type is invalid
+ if ( $type !== false && !isset( $jobClasses[$type] ) ) {
+ $response['reached'] = 'none-possible';
+ return $response;
+ }
+ // Bail out if DB is in read-only mode
+ if ( wfReadOnly() ) {
+ $response['reached'] = 'read-only';
+ return $response;
+ }
+
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ // Bail out if there is too much DB lag.
+ // This check should not block as we want to try other wiki queues.
+ list( , $maxLag ) = $lbFactory->getMainLB( wfWikiID() )->getMaxLag();
+ if ( $maxLag >= self::MAX_ALLOWED_LAG ) {
+ $response['reached'] = 'replica-lag-limit';
+ return $response;
+ }
+
+ // Flush any pending DB writes for sanity
+ $lbFactory->commitAll( __METHOD__ );
+
+ // Catch huge single updates that lead to replica DB lag
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+ $trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) );
+ $trxProfiler->setExpectations( $profilerLimits['JobRunner'], __METHOD__ );
+
+ // Some jobs types should not run until a certain timestamp
+ $backoffs = []; // map of (type => UNIX expiry)
+ $backoffDeltas = []; // map of (type => seconds)
+ $wait = 'wait'; // block to read backoffs the first time
+
+ $group = JobQueueGroup::singleton();
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $jobsPopped = 0;
+ $timeMsTotal = 0;
+ $startTime = microtime( true ); // time since jobs started running
+ $lastCheckTime = 1; // timestamp of last replica DB check
+ do {
+ // Sync the persistent backoffs with concurrent runners
+ $backoffs = $this->syncBackoffDeltas( $backoffs, $backoffDeltas, $wait );
+ $blacklist = $noThrottle ? [] : array_keys( $backoffs );
+ $wait = 'nowait'; // less important now
+
+ if ( $type === false ) {
+ $job = $group->pop(
+ JobQueueGroup::TYPE_DEFAULT,
+ JobQueueGroup::USE_CACHE,
+ $blacklist
+ );
+ } elseif ( in_array( $type, $blacklist ) ) {
+ $job = false; // requested queue in backoff state
+ } else {
+ $job = $group->pop( $type ); // job from a single queue
+ }
+ $lbFactory->commitMasterChanges( __METHOD__ ); // flush any JobQueueDB writes
+
+ if ( $job ) { // found a job
+ ++$jobsPopped;
+ $popTime = time();
+ $jType = $job->getType();
+
+ WebRequest::overrideRequestId( $job->getRequestId() );
+
+ // Back off of certain jobs for a while (for throttling and for errors)
+ $ttw = $this->getBackoffTimeToWait( $job );
+ if ( $ttw > 0 ) {
+ // Always add the delta for other runners in case the time running the
+ // job negated the backoff for each individually but not collectively.
+ $backoffDeltas[$jType] = isset( $backoffDeltas[$jType] )
+ ? $backoffDeltas[$jType] + $ttw
+ : $ttw;
+ $backoffs = $this->syncBackoffDeltas( $backoffs, $backoffDeltas, $wait );
+ }
+
+ $info = $this->executeJob( $job, $lbFactory, $stats, $popTime );
+ if ( $info['status'] !== false || !$job->allowRetries() ) {
+ $group->ack( $job ); // succeeded or job cannot be retried
+ $lbFactory->commitMasterChanges( __METHOD__ ); // flush any JobQueueDB writes
+ }
+
+ // Back off of certain jobs for a while (for throttling and for errors)
+ if ( $info['status'] === false && mt_rand( 0, 49 ) == 0 ) {
+ $ttw = max( $ttw, $this->getErrorBackoffTTL( $info['error'] ) );
+ $backoffDeltas[$jType] = isset( $backoffDeltas[$jType] )
+ ? $backoffDeltas[$jType] + $ttw
+ : $ttw;
+ }
+
+ $response['jobs'][] = [
+ 'type' => $jType,
+ 'status' => ( $info['status'] === false ) ? 'failed' : 'ok',
+ 'error' => $info['error'],
+ 'time' => $info['timeMs']
+ ];
+ $timeMsTotal += $info['timeMs'];
+
+ // Break out if we hit the job count or wall time limits...
+ if ( $maxJobs && $jobsPopped >= $maxJobs ) {
+ $response['reached'] = 'job-limit';
+ break;
+ } elseif ( $maxTime && ( microtime( true ) - $startTime ) > $maxTime ) {
+ $response['reached'] = 'time-limit';
+ break;
+ }
+
+ // Don't let any of the main DB replica DBs get backed up.
+ // This only waits for so long before exiting and letting
+ // other wikis in the farm (on different masters) get a chance.
+ $timePassed = microtime( true ) - $lastCheckTime;
+ if ( $timePassed >= self::LAG_CHECK_PERIOD || $timePassed < 0 ) {
+ try {
+ $lbFactory->waitForReplication( [
+ 'ifWritesSince' => $lastCheckTime,
+ 'timeout' => self::MAX_ALLOWED_LAG
+ ] );
+ } catch ( DBReplicationWaitError $e ) {
+ $response['reached'] = 'replica-lag-limit';
+ break;
+ }
+ $lastCheckTime = microtime( true );
+ }
+ // Don't let any queue replica DBs/backups fall behind
+ if ( $jobsPopped > 0 && ( $jobsPopped % 100 ) == 0 ) {
+ $group->waitForBackups();
+ }
+
+ // Bail if near-OOM instead of in a job
+ if ( !$this->checkMemoryOK() ) {
+ $response['reached'] = 'memory-limit';
+ break;
+ }
+ }
+ } while ( $job ); // stop when there are no jobs
+
+ // Sync the persistent backoffs for the next runJobs.php pass
+ if ( $backoffDeltas ) {
+ $this->syncBackoffDeltas( $backoffs, $backoffDeltas, 'wait' );
+ }
+
+ $response['backoffs'] = $backoffs;
+ $response['elapsed'] = $timeMsTotal;
+
+ return $response;
+ }
+
+ /**
+ * @param string $error
+ * @return int TTL in seconds
+ */
+ private function getErrorBackoffTTL( $error ) {
+ return strpos( $error, 'DBReadOnlyError' ) !== false
+ ? self::READONLY_BACKOFF_TTL
+ : self::ERROR_BACKOFF_TTL;
+ }
+
+ /**
+ * @param Job $job
+ * @param LBFactory $lbFactory
+ * @param StatsdDataFactory $stats
+ * @param float $popTime
+ * @return array Map of status/error/timeMs
+ */
+ private function executeJob( Job $job, LBFactory $lbFactory, $stats, $popTime ) {
+ $jType = $job->getType();
+ $msg = $job->toString() . " STARTING";
+ $this->logger->debug( $msg, [
+ 'job_type' => $job->getType(),
+ ] );
+ $this->debugCallback( $msg );
+
+ // Run the job...
+ $rssStart = $this->getMaxRssKb();
+ $jobStartTime = microtime( true );
+ try {
+ $fnameTrxOwner = get_class( $job ) . '::run'; // give run() outer scope
+ $lbFactory->beginMasterChanges( $fnameTrxOwner );
+ $status = $job->run();
+ $error = $job->getLastError();
+ $this->commitMasterChanges( $lbFactory, $job, $fnameTrxOwner );
+ // Important: this must be the last deferred update added (T100085, T154425)
+ DeferredUpdates::addCallableUpdate( [ JobQueueGroup::class, 'pushLazyJobs' ] );
+ // Run any deferred update tasks; doUpdates() manages transactions itself
+ DeferredUpdates::doUpdates();
+ } catch ( Exception $e ) {
+ MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+ $status = false;
+ $error = get_class( $e ) . ': ' . $e->getMessage();
+ }
+ // Always attempt to call teardown() even if Job throws exception.
+ try {
+ $job->teardown( $status );
+ } catch ( Exception $e ) {
+ MWExceptionHandler::logException( $e );
+ }
+
+ // Commit all outstanding connections that are in a transaction
+ // to get a fresh repeatable read snapshot on every connection.
+ // Note that jobs are still responsible for handling replica DB lag.
+ $lbFactory->flushReplicaSnapshots( __METHOD__ );
+ // Clear out title cache data from prior snapshots
+ MediaWikiServices::getInstance()->getLinkCache()->clear();
+ $timeMs = intval( ( microtime( true ) - $jobStartTime ) * 1000 );
+ $rssEnd = $this->getMaxRssKb();
+
+ // Record how long jobs wait before getting popped
+ $readyTs = $job->getReadyTimestamp();
+ if ( $readyTs ) {
+ $pickupDelay = max( 0, $popTime - $readyTs );
+ $stats->timing( 'jobqueue.pickup_delay.all', 1000 * $pickupDelay );
+ $stats->timing( "jobqueue.pickup_delay.$jType", 1000 * $pickupDelay );
+ }
+ // Record root job age for jobs being run
+ $rootTimestamp = $job->getRootJobParams()['rootJobTimestamp'];
+ if ( $rootTimestamp ) {
+ $age = max( 0, $popTime - wfTimestamp( TS_UNIX, $rootTimestamp ) );
+ $stats->timing( "jobqueue.pickup_root_age.$jType", 1000 * $age );
+ }
+ // Track the execution time for jobs
+ $stats->timing( "jobqueue.run.$jType", $timeMs );
+ // Track RSS increases for jobs (in case of memory leaks)
+ if ( $rssStart && $rssEnd ) {
+ $stats->updateCount( "jobqueue.rss_delta.$jType", $rssEnd - $rssStart );
+ }
+
+ if ( $status === false ) {
+ $msg = $job->toString() . " t={job_duration} error={job_error}";
+ $this->logger->error( $msg, [
+ 'job_type' => $job->getType(),
+ 'job_duration' => $timeMs,
+ 'job_error' => $error,
+ ] );
+
+ $msg = $job->toString() . " t=$timeMs error={$error}";
+ $this->debugCallback( $msg );
+ } else {
+ $msg = $job->toString() . " t={job_duration} good";
+ $this->logger->info( $msg, [
+ 'job_type' => $job->getType(),
+ 'job_duration' => $timeMs,
+ ] );
+
+ $msg = $job->toString() . " t=$timeMs good";
+ $this->debugCallback( $msg );
+ }
+
+ return [ 'status' => $status, 'error' => $error, 'timeMs' => $timeMs ];
+ }
+
+ /**
+ * @return int|null Max memory RSS in kilobytes
+ */
+ private function getMaxRssKb() {
+ $info = wfGetRusage() ?: [];
+ // see https://linux.die.net/man/2/getrusage
+ return isset( $info['ru_maxrss'] ) ? (int)$info['ru_maxrss'] : null;
+ }
+
+ /**
+ * @param Job $job
+ * @return int Seconds for this runner to avoid doing more jobs of this type
+ * @see $wgJobBackoffThrottling
+ */
+ private function getBackoffTimeToWait( Job $job ) {
+ $throttling = $this->config->get( 'JobBackoffThrottling' );
+
+ if ( !isset( $throttling[$job->getType()] ) || $job instanceof DuplicateJob ) {
+ return 0; // not throttled
+ }
+
+ $itemsPerSecond = $throttling[$job->getType()];
+ if ( $itemsPerSecond <= 0 ) {
+ return 0; // not throttled
+ }
+
+ $seconds = 0;
+ if ( $job->workItemCount() > 0 ) {
+ $exactSeconds = $job->workItemCount() / $itemsPerSecond;
+ // use randomized rounding
+ $seconds = floor( $exactSeconds );
+ $remainder = $exactSeconds - $seconds;
+ $seconds += ( mt_rand() / mt_getrandmax() < $remainder ) ? 1 : 0;
+ }
+
+ return (int)$seconds;
+ }
+
+ /**
+ * Get the previous backoff expiries from persistent storage
+ * On I/O or lock acquisition failure this returns the original $backoffs.
+ *
+ * @param array $backoffs Map of (job type => UNIX timestamp)
+ * @param string $mode Lock wait mode - "wait" or "nowait"
+ * @return array Map of (job type => backoff expiry timestamp)
+ */
+ private function loadBackoffs( array $backoffs, $mode = 'wait' ) {
+ $file = wfTempDir() . '/mw-runJobs-backoffs.json';
+ if ( is_file( $file ) ) {
+ $noblock = ( $mode === 'nowait' ) ? LOCK_NB : 0;
+ $handle = fopen( $file, 'rb' );
+ if ( !flock( $handle, LOCK_SH | $noblock ) ) {
+ fclose( $handle );
+ return $backoffs; // don't wait on lock
+ }
+ $content = stream_get_contents( $handle );
+ flock( $handle, LOCK_UN );
+ fclose( $handle );
+ $ctime = microtime( true );
+ $cBackoffs = json_decode( $content, true ) ?: [];
+ foreach ( $cBackoffs as $type => $timestamp ) {
+ if ( $timestamp < $ctime ) {
+ unset( $cBackoffs[$type] );
+ }
+ }
+ } else {
+ $cBackoffs = [];
+ }
+
+ return $cBackoffs;
+ }
+
+ /**
+ * Merge the current backoff expiries from persistent storage
+ *
+ * The $deltas map is set to an empty array on success.
+ * On I/O or lock acquisition failure this returns the original $backoffs.
+ *
+ * @param array $backoffs Map of (job type => UNIX timestamp)
+ * @param array $deltas Map of (job type => seconds)
+ * @param string $mode Lock wait mode - "wait" or "nowait"
+ * @return array The new backoffs account for $backoffs and the latest file data
+ */
+ private function syncBackoffDeltas( array $backoffs, array &$deltas, $mode = 'wait' ) {
+ if ( !$deltas ) {
+ return $this->loadBackoffs( $backoffs, $mode );
+ }
+
+ $noblock = ( $mode === 'nowait' ) ? LOCK_NB : 0;
+ $file = wfTempDir() . '/mw-runJobs-backoffs.json';
+ $handle = fopen( $file, 'wb+' );
+ if ( !flock( $handle, LOCK_EX | $noblock ) ) {
+ fclose( $handle );
+ return $backoffs; // don't wait on lock
+ }
+ $ctime = microtime( true );
+ $content = stream_get_contents( $handle );
+ $cBackoffs = json_decode( $content, true ) ?: [];
+ foreach ( $deltas as $type => $seconds ) {
+ $cBackoffs[$type] = isset( $cBackoffs[$type] ) && $cBackoffs[$type] >= $ctime
+ ? $cBackoffs[$type] + $seconds
+ : $ctime + $seconds;
+ }
+ foreach ( $cBackoffs as $type => $timestamp ) {
+ if ( $timestamp < $ctime ) {
+ unset( $cBackoffs[$type] );
+ }
+ }
+ ftruncate( $handle, 0 );
+ fwrite( $handle, json_encode( $cBackoffs ) );
+ flock( $handle, LOCK_UN );
+ fclose( $handle );
+
+ $deltas = [];
+
+ return $cBackoffs;
+ }
+
+ /**
+ * Make sure that this script is not too close to the memory usage limit.
+ * It is better to die in between jobs than OOM right in the middle of one.
+ * @return bool
+ */
+ private function checkMemoryOK() {
+ static $maxBytes = null;
+ if ( $maxBytes === null ) {
+ $m = [];
+ if ( preg_match( '!^(\d+)(k|m|g|)$!i', ini_get( 'memory_limit' ), $m ) ) {
+ list( , $num, $unit ) = $m;
+ $conv = [ 'g' => 1073741824, 'm' => 1048576, 'k' => 1024, '' => 1 ];
+ $maxBytes = $num * $conv[strtolower( $unit )];
+ } else {
+ $maxBytes = 0;
+ }
+ }
+ $usedBytes = memory_get_usage();
+ if ( $maxBytes && $usedBytes >= 0.95 * $maxBytes ) {
+ $msg = "Detected excessive memory usage ({used_bytes}/{max_bytes}).";
+ $this->logger->error( $msg, [
+ 'used_bytes' => $usedBytes,
+ 'max_bytes' => $maxBytes,
+ ] );
+
+ $msg = "Detected excessive memory usage ($usedBytes/$maxBytes).";
+ $this->debugCallback( $msg );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Log the job message
+ * @param string $msg The message to log
+ */
+ private function debugCallback( $msg ) {
+ if ( $this->debug ) {
+ call_user_func_array( $this->debug, [ wfTimestamp( TS_DB ) . " $msg\n" ] );
+ }
+ }
+
+ /**
+ * Issue a commit on all masters who are currently in a transaction and have
+ * made changes to the database. It also supports sometimes waiting for the
+ * local wiki's replica DBs to catch up. See the documentation for
+ * $wgJobSerialCommitThreshold for more.
+ *
+ * @param LBFactory $lbFactory
+ * @param Job $job
+ * @param string $fnameTrxOwner
+ * @throws DBError
+ */
+ private function commitMasterChanges( LBFactory $lbFactory, Job $job, $fnameTrxOwner ) {
+ $syncThreshold = $this->config->get( 'JobSerialCommitThreshold' );
+
+ $time = false;
+ $lb = $lbFactory->getMainLB( wfWikiID() );
+ if ( $syncThreshold !== false && $lb->getServerCount() > 1 ) {
+ // Generally, there is one master connection to the local DB
+ $dbwSerial = $lb->getAnyOpenConnection( $lb->getWriterIndex() );
+ // We need natively blocking fast locks
+ if ( $dbwSerial && $dbwSerial->namedLocksEnqueue() ) {
+ $time = $dbwSerial->pendingWriteQueryDuration( $dbwSerial::ESTIMATE_DB_APPLY );
+ if ( $time < $syncThreshold ) {
+ $dbwSerial = false;
+ }
+ } else {
+ $dbwSerial = false;
+ }
+ } else {
+ // There are no replica DBs or writes are all to foreign DB (we don't handle that)
+ $dbwSerial = false;
+ }
+
+ if ( !$dbwSerial ) {
+ $lbFactory->commitMasterChanges(
+ $fnameTrxOwner,
+ // Abort if any transaction was too big
+ [ 'maxWriteDuration' => $this->config->get( 'MaxJobDBWriteDuration' ) ]
+ );
+
+ return;
+ }
+
+ $ms = intval( 1000 * $time );
+
+ $msg = $job->toString() . " COMMIT ENQUEUED [{job_commit_write_ms}ms of writes]";
+ $this->logger->info( $msg, [
+ 'job_type' => $job->getType(),
+ 'job_commit_write_ms' => $ms,
+ ] );
+
+ $msg = $job->toString() . " COMMIT ENQUEUED [{$ms}ms of writes]";
+ $this->debugCallback( $msg );
+
+ // Wait for an exclusive lock to commit
+ if ( !$dbwSerial->lock( 'jobrunner-serial-commit', __METHOD__, 30 ) ) {
+ // This will trigger a rollback in the main loop
+ throw new DBError( $dbwSerial, "Timed out waiting on commit queue." );
+ }
+ $unlocker = new ScopedCallback( function () use ( $dbwSerial ) {
+ $dbwSerial->unlock( 'jobrunner-serial-commit', __METHOD__ );
+ } );
+
+ // Wait for the replica DBs to catch up
+ $pos = $lb->getMasterPos();
+ if ( $pos ) {
+ $lb->waitForAll( $pos );
+ }
+
+ // Actually commit the DB master changes
+ $lbFactory->commitMasterChanges(
+ $fnameTrxOwner,
+ // Abort if any transaction was too big
+ [ 'maxWriteDuration' => $this->config->get( 'MaxJobDBWriteDuration' ) ]
+ );
+ ScopedCallback::consume( $unlocker );
+ }
+}
diff --git a/www/wiki/includes/jobqueue/JobSpecification.php b/www/wiki/includes/jobqueue/JobSpecification.php
new file mode 100644
index 00000000..d8447951
--- /dev/null
+++ b/www/wiki/includes/jobqueue/JobSpecification.php
@@ -0,0 +1,234 @@
+<?php
+/**
+ * Job queue task description base code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Job queue task description interface
+ *
+ * @ingroup JobQueue
+ * @since 1.23
+ */
+interface IJobSpecification {
+ /**
+ * @return string Job type
+ */
+ public function getType();
+
+ /**
+ * @return array
+ */
+ public function getParams();
+
+ /**
+ * @return int|null UNIX timestamp to delay running this job until, otherwise null
+ */
+ public function getReleaseTimestamp();
+
+ /**
+ * @return bool Whether only one of each identical set of jobs should be run
+ */
+ public function ignoreDuplicates();
+
+ /**
+ * Subclasses may need to override this to make duplication detection work.
+ * The resulting map conveys everything that makes the job unique. This is
+ * only checked if ignoreDuplicates() returns true, meaning that duplicate
+ * jobs are supposed to be ignored.
+ *
+ * @return array Map of key/values
+ */
+ public function getDeduplicationInfo();
+
+ /**
+ * @see JobQueue::deduplicateRootJob()
+ * @return array
+ * @since 1.26
+ */
+ public function getRootJobParams();
+
+ /**
+ * @see JobQueue::deduplicateRootJob()
+ * @return bool
+ * @since 1.22
+ */
+ public function hasRootJobParams();
+
+ /**
+ * @see JobQueue::deduplicateRootJob()
+ * @return bool Whether this is job is a root job
+ */
+ public function isRootJob();
+
+ /**
+ * @return Title Descriptive title (this can simply be informative)
+ */
+ public function getTitle();
+}
+
+/**
+ * Job queue task description base code
+ *
+ * Example usage:
+ * @code
+ * $job = new JobSpecification(
+ * 'null',
+ * array( 'lives' => 1, 'usleep' => 100, 'pi' => 3.141569 ),
+ * array( 'removeDuplicates' => 1 ),
+ * Title::makeTitle( NS_SPECIAL, 'nullity' )
+ * );
+ * JobQueueGroup::singleton()->push( $job )
+ * @endcode
+ *
+ * @ingroup JobQueue
+ * @since 1.23
+ */
+class JobSpecification implements IJobSpecification {
+ /** @var string */
+ protected $type;
+
+ /** @var array Array of job parameters or false if none */
+ protected $params;
+
+ /** @var Title */
+ protected $title;
+
+ /** @var array */
+ protected $opts;
+
+ /**
+ * @param string $type
+ * @param array $params Map of key/values
+ * @param array $opts Map of key/values; includes 'removeDuplicates'
+ * @param Title $title Optional descriptive title
+ */
+ public function __construct(
+ $type, array $params, array $opts = [], Title $title = null
+ ) {
+ $this->validateParams( $params );
+ $this->validateParams( $opts );
+
+ $this->type = $type;
+ $this->params = $params;
+ $this->title = $title ?: Title::makeTitle( NS_SPECIAL, 'Badtitle/' . static::class );
+ $this->opts = $opts;
+ }
+
+ /**
+ * @param array $params
+ */
+ protected function validateParams( array $params ) {
+ foreach ( $params as $p => $v ) {
+ if ( is_array( $v ) ) {
+ $this->validateParams( $v );
+ } elseif ( !is_scalar( $v ) && $v !== null ) {
+ throw new UnexpectedValueException( "Job parameter $p is not JSON serializable." );
+ }
+ }
+ }
+
+ public function getType() {
+ return $this->type;
+ }
+
+ public function getTitle() {
+ return $this->title;
+ }
+
+ public function getParams() {
+ return $this->params;
+ }
+
+ public function getReleaseTimestamp() {
+ return isset( $this->params['jobReleaseTimestamp'] )
+ ? wfTimestampOrNull( TS_UNIX, $this->params['jobReleaseTimestamp'] )
+ : null;
+ }
+
+ public function ignoreDuplicates() {
+ return !empty( $this->opts['removeDuplicates'] );
+ }
+
+ public function getDeduplicationInfo() {
+ $info = [
+ 'type' => $this->getType(),
+ 'namespace' => $this->getTitle()->getNamespace(),
+ 'title' => $this->getTitle()->getDBkey(),
+ 'params' => $this->getParams()
+ ];
+ if ( is_array( $info['params'] ) ) {
+ // Identical jobs with different "root" jobs should count as duplicates
+ unset( $info['params']['rootJobSignature'] );
+ unset( $info['params']['rootJobTimestamp'] );
+ // Likewise for jobs with different delay times
+ unset( $info['params']['jobReleaseTimestamp'] );
+ }
+
+ return $info;
+ }
+
+ public function getRootJobParams() {
+ return [
+ 'rootJobSignature' => isset( $this->params['rootJobSignature'] )
+ ? $this->params['rootJobSignature']
+ : null,
+ 'rootJobTimestamp' => isset( $this->params['rootJobTimestamp'] )
+ ? $this->params['rootJobTimestamp']
+ : null
+ ];
+ }
+
+ public function hasRootJobParams() {
+ return isset( $this->params['rootJobSignature'] )
+ && isset( $this->params['rootJobTimestamp'] );
+ }
+
+ public function isRootJob() {
+ return $this->hasRootJobParams() && !empty( $this->params['rootJobIsSelf'] );
+ }
+
+ /**
+ * @return array Field/value map that can immediately be serialized
+ * @since 1.25
+ */
+ public function toSerializableArray() {
+ return [
+ 'type' => $this->type,
+ 'params' => $this->params,
+ 'opts' => $this->opts,
+ 'title' => [
+ 'ns' => $this->title->getNamespace(),
+ 'key' => $this->title->getDBkey()
+ ]
+ ];
+ }
+
+ /**
+ * @param array $map Field/value map
+ * @return JobSpecification
+ * @since 1.25
+ */
+ public static function newFromArray( array $map ) {
+ $title = Title::makeTitle( $map['title']['ns'], $map['title']['key'] );
+
+ return new self( $map['type'], $map['params'], $map['opts'], $title );
+ }
+}
diff --git a/www/wiki/includes/jobqueue/README b/www/wiki/includes/jobqueue/README
new file mode 100644
index 00000000..2073b0f8
--- /dev/null
+++ b/www/wiki/includes/jobqueue/README
@@ -0,0 +1,80 @@
+/*!
+\ingroup JobQueue
+\page jobqueue_design Job queue design
+
+Notes on the Job queuing system architecture.
+
+\section intro Introduction
+
+The data model consist of the following main components:
+* The Job object represents a particular deferred task that happens in the
+ background. All jobs subclass the Job object and put the main logic in the
+ function called run().
+* The JobQueue object represents a particular queue of jobs of a certain type.
+ For example there may be a queue for email jobs and a queue for CDN purge
+ jobs.
+
+\section jobqueue Job queues
+
+Each job type has its own queue and is associated to a storage medium. One
+queue might save its jobs in redis while another one uses would use a database.
+
+Storage medium are defined in a queue class. Before using it, you must
+define in $wgJobTypeConf a mapping of the job type to a queue class.
+
+The factory class JobQueueGroup provides helper functions:
+- getting the queue for a given job
+- route new job insertions to the proper queue
+
+The following queue classes are available:
+* JobQueueDB (stores jobs in the `job` table in a database)
+* JobQueueRedis (stores jobs in a redis server)
+
+All queue classes support some basic operations (though some may be no-ops):
+* enqueueing a batch of jobs
+* dequeueing a single job
+* acknowledging a job is completed
+* checking if the queue is empty
+
+Some queue classes (like JobQueueDB) may dequeue jobs in random order while other
+queues might dequeue jobs in exact FIFO order. Callers should thus not assume jobs
+are executed in FIFO order.
+
+Also note that not all queue classes will have the same reliability guarantees.
+In-memory queues may lose data when restarted depending on snapshot and journal
+settings (including journal fsync() frequency). Some queue types may totally remove
+jobs when dequeued while leaving the ack() function as a no-op; if a job is
+dequeued by a job runner, which crashes before completion, the job will be
+lost. Some jobs, like purging CDN caches after a template change, may not
+require durable queues, whereas other jobs might be more important.
+
+\section aggregator Job queue aggregator
+
+The aggregators are used by nextJobDB.php, which is a script that will return a
+random ready queue (on any wiki in the farm) that can be used with runJobs.php.
+This can be used in conjunction with any scripts that handle wiki farm job queues.
+Note that $wgLocalDatabases defines what wikis are in the wiki farm.
+
+Since each job type has its own queue, and wiki-farms may have many wikis,
+there might be a large number of queues to keep track of. To avoid wasting
+large amounts of time polling empty queues, aggregators exists to keep track
+of which queues are ready.
+
+The following queue aggregator classes are available:
+* JobQueueAggregatorRedis (uses a redis server to track ready queues)
+
+Some aggregators cache data for a few minutes while others may be always up to date.
+This can be an important factor for jobs that need a low pickup time (or latency).
+
+\section jobs Jobs
+
+Callers should also try to make jobs maintain correctness when executed twice.
+This is useful for queues that actually implement ack(), since they may recycle
+dequeued but un-acknowledged jobs back into the queue to be attempted again. If
+a runner dequeues a job, runs it, but then crashes before calling ack(), the
+job may be returned to the queue and run a second time. Jobs like cache purging can
+happen several times without any correctness problems. However, a pathological case
+would be if a bug causes the problem to systematically keep repeating. For example,
+a job may always throw a DB error at the end of run(). This problem is trickier to
+solve and more obnoxious for things like email jobs, for example. For such jobs,
+it might be useful to use a queue that does not retry jobs.
diff --git a/www/wiki/includes/jobqueue/aggregator/JobQueueAggregator.php b/www/wiki/includes/jobqueue/aggregator/JobQueueAggregator.php
new file mode 100644
index 00000000..f26beee4
--- /dev/null
+++ b/www/wiki/includes/jobqueue/aggregator/JobQueueAggregator.php
@@ -0,0 +1,177 @@
+<?php
+/**
+ * Job queue aggregator code.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to handle tracking information about all queues
+ *
+ * @ingroup JobQueue
+ * @since 1.21
+ */
+abstract class JobQueueAggregator {
+ /** @var JobQueueAggregator */
+ protected static $instance = null;
+
+ /**
+ * @param array $params
+ */
+ public function __construct( array $params ) {
+ }
+
+ /**
+ * @throws MWException
+ * @return JobQueueAggregator
+ */
+ final public static function singleton() {
+ global $wgJobQueueAggregator;
+
+ if ( !isset( self::$instance ) ) {
+ $class = $wgJobQueueAggregator['class'];
+ $obj = new $class( $wgJobQueueAggregator );
+ if ( !( $obj instanceof JobQueueAggregator ) ) {
+ throw new MWException( "Class '$class' is not a JobQueueAggregator class." );
+ }
+ self::$instance = $obj;
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Destroy the singleton instance
+ *
+ * @return void
+ */
+ final public static function destroySingleton() {
+ self::$instance = null;
+ }
+
+ /**
+ * Mark a queue as being empty
+ *
+ * @param string $wiki
+ * @param string $type
+ * @return bool Success
+ */
+ final public function notifyQueueEmpty( $wiki, $type ) {
+ $ok = $this->doNotifyQueueEmpty( $wiki, $type );
+
+ return $ok;
+ }
+
+ /**
+ * @see JobQueueAggregator::notifyQueueEmpty()
+ * @param string $wiki
+ * @param string $type
+ * @return bool
+ */
+ abstract protected function doNotifyQueueEmpty( $wiki, $type );
+
+ /**
+ * Mark a queue as being non-empty
+ *
+ * @param string $wiki
+ * @param string $type
+ * @return bool Success
+ */
+ final public function notifyQueueNonEmpty( $wiki, $type ) {
+ $ok = $this->doNotifyQueueNonEmpty( $wiki, $type );
+
+ return $ok;
+ }
+
+ /**
+ * @see JobQueueAggregator::notifyQueueNonEmpty()
+ * @param string $wiki
+ * @param string $type
+ * @return bool
+ */
+ abstract protected function doNotifyQueueNonEmpty( $wiki, $type );
+
+ /**
+ * Get the list of all of the queues with jobs
+ *
+ * @return array (job type => (list of wiki IDs))
+ */
+ final public function getAllReadyWikiQueues() {
+ $res = $this->doGetAllReadyWikiQueues();
+
+ return $res;
+ }
+
+ /**
+ * @see JobQueueAggregator::getAllReadyWikiQueues()
+ */
+ abstract protected function doGetAllReadyWikiQueues();
+
+ /**
+ * Purge all of the aggregator information
+ *
+ * @return bool Success
+ */
+ final public function purge() {
+ $res = $this->doPurge();
+
+ return $res;
+ }
+
+ /**
+ * @see JobQueueAggregator::purge()
+ */
+ abstract protected function doPurge();
+
+ /**
+ * Get all databases that have a pending job.
+ * This poll all the queues and is this expensive.
+ *
+ * @return array (job type => (list of wiki IDs))
+ */
+ protected function findPendingWikiQueues() {
+ global $wgLocalDatabases;
+
+ $pendingDBs = []; // (job type => (db list))
+ foreach ( $wgLocalDatabases as $db ) {
+ foreach ( JobQueueGroup::singleton( $db )->getQueuesWithJobs() as $type ) {
+ $pendingDBs[$type][] = $db;
+ }
+ }
+
+ return $pendingDBs;
+ }
+}
+
+class JobQueueAggregatorNull extends JobQueueAggregator {
+ protected function doNotifyQueueEmpty( $wiki, $type ) {
+ return true;
+ }
+
+ protected function doNotifyQueueNonEmpty( $wiki, $type ) {
+ return true;
+ }
+
+ protected function doGetAllReadyWikiQueues() {
+ return [];
+ }
+
+ protected function doPurge() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/aggregator/JobQueueAggregatorRedis.php b/www/wiki/includes/jobqueue/aggregator/JobQueueAggregatorRedis.php
new file mode 100644
index 00000000..db07086f
--- /dev/null
+++ b/www/wiki/includes/jobqueue/aggregator/JobQueueAggregatorRedis.php
@@ -0,0 +1,135 @@
+<?php
+/**
+ * Job queue aggregator code that uses PhpRedis.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class to handle tracking information about all queues using PhpRedis
+ *
+ * The mediawiki/services/jobrunner background service must be set up and running.
+ *
+ * @ingroup JobQueue
+ * @ingroup Redis
+ * @since 1.21
+ */
+class JobQueueAggregatorRedis extends JobQueueAggregator {
+ /** @var RedisConnectionPool */
+ protected $redisPool;
+ /** @var LoggerInterface */
+ protected $logger;
+ /** @var array List of Redis server addresses */
+ protected $servers;
+
+ /**
+ * @param array $params Possible keys:
+ * - redisConfig : An array of parameters to RedisConnectionPool::__construct().
+ * - redisServers : Array of server entries, the first being the primary and the
+ * others being fallback servers. Each entry is either a hostname/port
+ * combination or the absolute path of a UNIX socket.
+ * If a hostname is specified but no port, the standard port number
+ * 6379 will be used. Required.
+ */
+ public function __construct( array $params ) {
+ parent::__construct( $params );
+ $this->servers = isset( $params['redisServers'] )
+ ? $params['redisServers']
+ : [ $params['redisServer'] ]; // b/c
+ $params['redisConfig']['serializer'] = 'none';
+ $this->redisPool = RedisConnectionPool::singleton( $params['redisConfig'] );
+ $this->logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'redis' );
+ }
+
+ protected function doNotifyQueueEmpty( $wiki, $type ) {
+ return true; // managed by the service
+ }
+
+ protected function doNotifyQueueNonEmpty( $wiki, $type ) {
+ return true; // managed by the service
+ }
+
+ protected function doGetAllReadyWikiQueues() {
+ $conn = $this->getConnection();
+ if ( !$conn ) {
+ return [];
+ }
+ try {
+ $map = $conn->hGetAll( $this->getReadyQueueKey() );
+
+ if ( is_array( $map ) && isset( $map['_epoch'] ) ) {
+ unset( $map['_epoch'] ); // ignore
+ $pendingDBs = []; // (type => list of wikis)
+ foreach ( $map as $key => $time ) {
+ list( $type, $wiki ) = $this->decodeQueueName( $key );
+ $pendingDBs[$type][] = $wiki;
+ }
+ } else {
+ throw new UnexpectedValueException(
+ "No queue listing found; make sure redisJobChronService is running."
+ );
+ }
+
+ return $pendingDBs;
+ } catch ( RedisException $e ) {
+ $this->redisPool->handleError( $conn, $e );
+
+ return [];
+ }
+ }
+
+ protected function doPurge() {
+ return true; // fully and only refreshed by the service
+ }
+
+ /**
+ * Get a connection to the server that handles all sub-queues for this queue
+ *
+ * @return RedisConnRef|bool Returns false on failure
+ * @throws MWException
+ */
+ protected function getConnection() {
+ $conn = false;
+ foreach ( $this->servers as $server ) {
+ $conn = $this->redisPool->getConnection( $server, $this->logger );
+ if ( $conn ) {
+ break;
+ }
+ }
+
+ return $conn;
+ }
+
+ /**
+ * @return string
+ */
+ private function getReadyQueueKey() {
+ return "jobqueue:aggregator:h-ready-queues:v2"; // global
+ }
+
+ /**
+ * @param string $name
+ * @return string[]
+ */
+ private function decodeQueueName( $name ) {
+ list( $type, $wiki ) = explode( '/', $name, 2 );
+
+ return [ rawurldecode( $type ), rawurldecode( $wiki ) ];
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/ActivityUpdateJob.php b/www/wiki/includes/jobqueue/jobs/ActivityUpdateJob.php
new file mode 100644
index 00000000..da4ec233
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/ActivityUpdateJob.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Job for updating user activity like "last viewed" timestamps
+ *
+ * @ingroup JobQueue
+ * @since 1.26
+ */
+class ActivityUpdateJob extends Job {
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'activityUpdateJob', $title, $params );
+
+ if ( !isset( $params['type'] ) ) {
+ throw new InvalidArgumentException( "Missing 'type' parameter." );
+ }
+
+ $this->removeDuplicates = true;
+ }
+
+ public function run() {
+ if ( $this->params['type'] === 'updateWatchlistNotification' ) {
+ $this->updateWatchlistNotification();
+ } else {
+ throw new InvalidArgumentException(
+ "Invalid 'type' parameter '{$this->params['type']}'." );
+ }
+
+ return true;
+ }
+
+ protected function updateWatchlistNotification() {
+ $casTimestamp = ( $this->params['notifTime'] !== null )
+ ? $this->params['notifTime']
+ : $this->params['curTime'];
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update( 'watchlist',
+ [
+ 'wl_notificationtimestamp' => $dbw->timestampOrNull( $this->params['notifTime'] )
+ ],
+ [
+ 'wl_user' => $this->params['userid'],
+ 'wl_namespace' => $this->title->getNamespace(),
+ 'wl_title' => $this->title->getDBkey(),
+ // Add a "check and set" style comparison to handle conflicts.
+ // The inequality always avoids updates when the current value
+ // is already NULL per ANSI SQL. This is desired since NULL means
+ // that the user is "caught up" on edits already. When the field
+ // is non-NULL, make sure not to set it back in time or set it to
+ // NULL when newer revisions were in fact added to the page.
+ 'wl_notificationtimestamp < ' . $dbw->addQuotes( $dbw->timestamp( $casTimestamp ) )
+ ],
+ __METHOD__
+ );
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/AssembleUploadChunksJob.php b/www/wiki/includes/jobqueue/jobs/AssembleUploadChunksJob.php
new file mode 100644
index 00000000..e2914be5
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/AssembleUploadChunksJob.php
@@ -0,0 +1,138 @@
+<?php
+/**
+ * Assemble the segments of a chunked upload.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Upload
+ */
+use Wikimedia\ScopedCallback;
+
+/**
+ * Assemble the segments of a chunked upload.
+ *
+ * @ingroup Upload
+ */
+class AssembleUploadChunksJob extends Job {
+ public function __construct( Title $title, array $params ) {
+ parent::__construct( 'AssembleUploadChunks', $title, $params );
+ $this->removeDuplicates = true;
+ }
+
+ public function run() {
+ $scope = RequestContext::importScopedSession( $this->params['session'] );
+ $this->addTeardownCallback( function () use ( &$scope ) {
+ ScopedCallback::consume( $scope ); // T126450
+ } );
+
+ $context = RequestContext::getMain();
+ $user = $context->getUser();
+ try {
+ if ( !$user->isLoggedIn() ) {
+ $this->setLastError( "Could not load the author user from session." );
+
+ return false;
+ }
+
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [ 'result' => 'Poll', 'stage' => 'assembling', 'status' => Status::newGood() ]
+ );
+
+ $upload = new UploadFromChunks( $user );
+ $upload->continueChunks(
+ $this->params['filename'],
+ $this->params['filekey'],
+ new WebRequestUpload( $context->getRequest(), 'null' )
+ );
+
+ // Combine all of the chunks into a local file and upload that to a new stash file
+ $status = $upload->concatenateChunks();
+ if ( !$status->isGood() ) {
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
+ );
+ $this->setLastError( $status->getWikiText( false, false, 'en' ) );
+
+ return false;
+ }
+
+ // We can only get warnings like 'duplicate' after concatenating the chunks
+ $status = Status::newGood();
+ $status->value = [ 'warnings' => $upload->checkWarnings() ];
+
+ // We have a new filekey for the fully concatenated file
+ $newFileKey = $upload->getStashFile()->getFileKey();
+
+ // Remove the old stash file row and first chunk file
+ $upload->stash->removeFileNoAuth( $this->params['filekey'] );
+
+ // Build the image info array while we have the local reference handy
+ $apiMain = new ApiMain(); // dummy object (XXX)
+ $imageInfo = $upload->getImageInfo( $apiMain->getResult() );
+
+ // Cleanup any temporary local file
+ $upload->cleanupTempFile();
+
+ // Cache the info so the user doesn't have to wait forever to get the final info
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [
+ 'result' => 'Success',
+ 'stage' => 'assembling',
+ 'filekey' => $newFileKey,
+ 'imageinfo' => $imageInfo,
+ 'status' => $status
+ ]
+ );
+ } catch ( Exception $e ) {
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [
+ 'result' => 'Failure',
+ 'stage' => 'assembling',
+ 'status' => Status::newFatal( 'api-error-stashfailed' )
+ ]
+ );
+ $this->setLastError( get_class( $e ) . ": " . $e->getMessage() );
+ // To be extra robust.
+ MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getDeduplicationInfo() {
+ $info = parent::getDeduplicationInfo();
+ if ( is_array( $info['params'] ) ) {
+ $info['params'] = [ 'filekey' => $info['params']['filekey'] ];
+ }
+
+ return $info;
+ }
+
+ public function allowRetries() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/CategoryMembershipChangeJob.php b/www/wiki/includes/jobqueue/jobs/CategoryMembershipChangeJob.php
new file mode 100644
index 00000000..3907fc65
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/CategoryMembershipChangeJob.php
@@ -0,0 +1,243 @@
+<?php
+/**
+ * Updater for link tracking tables after a page edit.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\LBFactory;
+
+/**
+ * Job to add recent change entries mentioning category membership changes
+ *
+ * Parameters include:
+ * - pageId : page ID
+ * - revTimestamp : timestamp of the triggering revision
+ *
+ * Category changes will be mentioned for revisions at/after the timestamp for this page
+ *
+ * @since 1.27
+ */
+class CategoryMembershipChangeJob extends Job {
+ /** @var int|null */
+ private $ticket;
+
+ const ENQUEUE_FUDGE_SEC = 60;
+
+ public function __construct( Title $title, array $params ) {
+ parent::__construct( 'categoryMembershipChange', $title, $params );
+ // Only need one job per page. Note that ENQUEUE_FUDGE_SEC handles races where an
+ // older revision job gets inserted while the newer revision job is de-duplicated.
+ $this->removeDuplicates = true;
+ }
+
+ public function run() {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lb = $lbFactory->getMainLB();
+ $dbw = $lb->getConnection( DB_MASTER );
+
+ $this->ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+
+ $page = WikiPage::newFromID( $this->params['pageId'], WikiPage::READ_LATEST );
+ if ( !$page ) {
+ $this->setLastError( "Could not find page #{$this->params['pageId']}" );
+ return false; // deleted?
+ }
+
+ // Use a named lock so that jobs for this page see each others' changes
+ $lockKey = "CategoryMembershipUpdates:{$page->getId()}";
+ $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 3 );
+ if ( !$scopedLock ) {
+ $this->setLastError( "Could not acquire lock '$lockKey'" );
+ return false;
+ }
+
+ $dbr = $lb->getConnection( DB_REPLICA, [ 'recentchanges' ] );
+ // Wait till the replica DB is caught up so that jobs for this page see each others' changes
+ if ( !$lb->safeWaitForMasterPos( $dbr ) ) {
+ $this->setLastError( "Timed out while waiting for replica DB to catch up" );
+ return false;
+ }
+ // Clear any stale REPEATABLE-READ snapshot
+ $dbr->flushSnapshot( __METHOD__ );
+
+ $cutoffUnix = wfTimestamp( TS_UNIX, $this->params['revTimestamp'] );
+ // Using ENQUEUE_FUDGE_SEC handles jobs inserted out of revision order due to the delay
+ // between COMMIT and actual enqueueing of the CategoryMembershipChangeJob job.
+ $cutoffUnix -= self::ENQUEUE_FUDGE_SEC;
+
+ // Get the newest revision that has a SRC_CATEGORIZE row...
+ $row = $dbr->selectRow(
+ [ 'revision', 'recentchanges' ],
+ [ 'rev_timestamp', 'rev_id' ],
+ [
+ 'rev_page' => $page->getId(),
+ 'rev_timestamp >= ' . $dbr->addQuotes( $dbr->timestamp( $cutoffUnix ) )
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_timestamp DESC, rev_id DESC' ],
+ [
+ 'recentchanges' => [
+ 'INNER JOIN',
+ [
+ 'rc_this_oldid = rev_id',
+ 'rc_source' => RecentChange::SRC_CATEGORIZE,
+ // Allow rc_cur_id or rc_timestamp index usage
+ 'rc_cur_id = rev_page',
+ 'rc_timestamp >= rev_timestamp'
+ ]
+ ]
+ ]
+ );
+ // Only consider revisions newer than any such revision
+ if ( $row ) {
+ $cutoffUnix = wfTimestamp( TS_UNIX, $row->rev_timestamp );
+ $lastRevId = (int)$row->rev_id;
+ } else {
+ $lastRevId = 0;
+ }
+
+ // Find revisions to this page made around and after this revision which lack category
+ // notifications in recent changes. This lets jobs pick up were the last one left off.
+ $encCutoff = $dbr->addQuotes( $dbr->timestamp( $cutoffUnix ) );
+ $res = $dbr->select(
+ 'revision',
+ Revision::selectFields(),
+ [
+ 'rev_page' => $page->getId(),
+ "rev_timestamp > $encCutoff" .
+ " OR (rev_timestamp = $encCutoff AND rev_id > $lastRevId)"
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_timestamp ASC, rev_id ASC' ]
+ );
+
+ // Apply all category updates in revision timestamp order
+ foreach ( $res as $row ) {
+ $this->notifyUpdatesForRevision( $lbFactory, $page, Revision::newFromRow( $row ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * @param LBFactory $lbFactory
+ * @param WikiPage $page
+ * @param Revision $newRev
+ * @throws MWException
+ */
+ protected function notifyUpdatesForRevision(
+ LBFactory $lbFactory, WikiPage $page, Revision $newRev
+ ) {
+ $config = RequestContext::getMain()->getConfig();
+ $title = $page->getTitle();
+
+ // Get the new revision
+ if ( !$newRev->getContent() ) {
+ return; // deleted?
+ }
+
+ // Get the prior revision (the same for null edits)
+ if ( $newRev->getParentId() ) {
+ $oldRev = Revision::newFromId( $newRev->getParentId(), Revision::READ_LATEST );
+ if ( !$oldRev->getContent() ) {
+ return; // deleted?
+ }
+ } else {
+ $oldRev = null;
+ }
+
+ // Parse the new revision and get the categories
+ $categoryChanges = $this->getExplicitCategoriesChanges( $title, $newRev, $oldRev );
+ list( $categoryInserts, $categoryDeletes ) = $categoryChanges;
+ if ( !$categoryInserts && !$categoryDeletes ) {
+ return; // nothing to do
+ }
+
+ $catMembChange = new CategoryMembershipChange( $title, $newRev );
+ $catMembChange->checkTemplateLinks();
+
+ $batchSize = $config->get( 'UpdateRowsPerQuery' );
+ $insertCount = 0;
+
+ foreach ( $categoryInserts as $categoryName ) {
+ $categoryTitle = Title::makeTitle( NS_CATEGORY, $categoryName );
+ $catMembChange->triggerCategoryAddedNotification( $categoryTitle );
+ if ( $insertCount++ && ( $insertCount % $batchSize ) == 0 ) {
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $this->ticket );
+ }
+ }
+
+ foreach ( $categoryDeletes as $categoryName ) {
+ $categoryTitle = Title::makeTitle( NS_CATEGORY, $categoryName );
+ $catMembChange->triggerCategoryRemovedNotification( $categoryTitle );
+ if ( $insertCount++ && ( $insertCount++ % $batchSize ) == 0 ) {
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $this->ticket );
+ }
+ }
+ }
+
+ private function getExplicitCategoriesChanges(
+ Title $title, Revision $newRev, Revision $oldRev = null
+ ) {
+ // Inject the same timestamp for both revision parses to avoid seeing category changes
+ // due to time-based parser functions. Inject the same page title for the parses too.
+ // Note that REPEATABLE-READ makes template/file pages appear unchanged between parses.
+ $parseTimestamp = $newRev->getTimestamp();
+ // Parse the old rev and get the categories. Do not use link tables as that
+ // assumes these updates are perfectly FIFO and that link tables are always
+ // up to date, neither of which are true.
+ $oldCategories = $oldRev
+ ? $this->getCategoriesAtRev( $title, $oldRev, $parseTimestamp )
+ : [];
+ // Parse the new revision and get the categories
+ $newCategories = $this->getCategoriesAtRev( $title, $newRev, $parseTimestamp );
+
+ $categoryInserts = array_values( array_diff( $newCategories, $oldCategories ) );
+ $categoryDeletes = array_values( array_diff( $oldCategories, $newCategories ) );
+
+ return [ $categoryInserts, $categoryDeletes ];
+ }
+
+ /**
+ * @param Title $title
+ * @param Revision $rev
+ * @param string $parseTimestamp TS_MW
+ *
+ * @return string[] category names
+ */
+ private function getCategoriesAtRev( Title $title, Revision $rev, $parseTimestamp ) {
+ $content = $rev->getContent();
+ $options = $content->getContentHandler()->makeParserOptions( 'canonical' );
+ $options->setTimestamp( $parseTimestamp );
+ // This could possibly use the parser cache if it checked the revision ID,
+ // but that's more complicated than it's worth.
+ $output = $content->getParserOutput( $title, $rev->getId(), $options );
+
+ // array keys will cast numeric category names to ints
+ // so we need to cast them back to strings to avoid breaking things!
+ return array_map( 'strval', array_keys( $output->getCategories() ) );
+ }
+
+ public function getDeduplicationInfo() {
+ $info = parent::getDeduplicationInfo();
+ unset( $info['params']['revTimestamp'] ); // first job wins
+
+ return $info;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/CdnPurgeJob.php b/www/wiki/includes/jobqueue/jobs/CdnPurgeJob.php
new file mode 100644
index 00000000..356eebab
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/CdnPurgeJob.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Job to purge a set of URLs from CDN
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Job to purge a set of URLs from CDN
+ *
+ * @ingroup JobQueue
+ * @since 1.27
+ */
+class CdnPurgeJob extends Job {
+ /**
+ * @param Title $title
+ * @param array $params Job parameters (urls)
+ */
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'cdnPurge', $title, $params );
+ $this->removeDuplicates = false; // delay semantics are critical
+ }
+
+ public function run() {
+ // Use purge() directly to avoid infinite recursion
+ CdnCacheUpdate::purge( $this->params['urls'] );
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/DeleteLinksJob.php b/www/wiki/includes/jobqueue/jobs/DeleteLinksJob.php
new file mode 100644
index 00000000..5c0f89f7
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/DeleteLinksJob.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Job to update link tables for pages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+use \MediaWiki\MediaWikiServices;
+
+/**
+ * Job to prune link tables for pages that were deleted
+ *
+ * Only DataUpdate classes should construct these jobs
+ *
+ * @ingroup JobQueue
+ * @since 1.27
+ */
+class DeleteLinksJob extends Job {
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'deleteLinks', $title, $params );
+ $this->removeDuplicates = true;
+ }
+
+ function run() {
+ if ( is_null( $this->title ) ) {
+ $this->setLastError( "deleteLinks: Invalid title" );
+ return false;
+ }
+
+ $pageId = $this->params['pageId'];
+
+ // Serialize links updates by page ID so they see each others' changes
+ $scopedLock = LinksUpdate::acquirePageLock( wfGetDB( DB_MASTER ), $pageId, 'job' );
+
+ if ( WikiPage::newFromID( $pageId, WikiPage::READ_LATEST ) ) {
+ // The page was restored somehow or something went wrong
+ $this->setLastError( "deleteLinks: Page #$pageId exists" );
+ return false;
+ }
+
+ $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $timestamp = isset( $this->params['timestamp'] ) ? $this->params['timestamp'] : null;
+ $page = WikiPage::factory( $this->title ); // title when deleted
+
+ $update = new LinksDeletionUpdate( $page, $pageId, $timestamp );
+ $update->setTransactionTicket( $factory->getEmptyTransactionTicket( __METHOD__ ) );
+ $update->doUpdate();
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/DoubleRedirectJob.php b/www/wiki/includes/jobqueue/jobs/DoubleRedirectJob.php
new file mode 100644
index 00000000..74c446fc
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/DoubleRedirectJob.php
@@ -0,0 +1,252 @@
+<?php
+/**
+ * Job to fix double redirects after moving a page.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Job to fix double redirects after moving a page
+ *
+ * @ingroup JobQueue
+ */
+class DoubleRedirectJob extends Job {
+ /** @var string Reason for the change, 'maintenance' or 'move'. Suffix fo
+ * message key 'double-redirect-fixed-'.
+ */
+ private $reason;
+
+ /** @var Title The title which has changed, redirects pointing to this
+ * title are fixed
+ */
+ private $redirTitle;
+
+ /** @var User */
+ private static $user;
+
+ /**
+ * @param Title $title
+ * @param array $params
+ */
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'fixDoubleRedirect', $title, $params );
+ $this->reason = $params['reason'];
+ $this->redirTitle = Title::newFromText( $params['redirTitle'] );
+ }
+
+ /**
+ * Insert jobs into the job queue to fix redirects to the given title
+ * @param string $reason The reason for the fix, see message
+ * "double-redirect-fixed-<reason>"
+ * @param Title $redirTitle The title which has changed, redirects
+ * pointing to this title are fixed
+ * @param bool $destTitle Not used
+ */
+ public static function fixRedirects( $reason, $redirTitle, $destTitle = false ) {
+ # Need to use the master to get the redirect table updated in the same transaction
+ $dbw = wfGetDB( DB_MASTER );
+ $res = $dbw->select(
+ [ 'redirect', 'page' ],
+ [ 'page_namespace', 'page_title' ],
+ [
+ 'page_id = rd_from',
+ 'rd_namespace' => $redirTitle->getNamespace(),
+ 'rd_title' => $redirTitle->getDBkey()
+ ], __METHOD__ );
+ if ( !$res->numRows() ) {
+ return;
+ }
+ $jobs = [];
+ foreach ( $res as $row ) {
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ if ( !$title ) {
+ continue;
+ }
+
+ $jobs[] = new self( $title, [
+ 'reason' => $reason,
+ 'redirTitle' => $redirTitle->getPrefixedDBkey() ] );
+ # Avoid excessive memory usage
+ if ( count( $jobs ) > 10000 ) {
+ JobQueueGroup::singleton()->push( $jobs );
+ $jobs = [];
+ }
+ }
+ JobQueueGroup::singleton()->push( $jobs );
+ }
+
+ /**
+ * @return bool
+ */
+ function run() {
+ if ( !$this->redirTitle ) {
+ $this->setLastError( 'Invalid title' );
+
+ return false;
+ }
+
+ $targetRev = Revision::newFromTitle( $this->title, false, Revision::READ_LATEST );
+ if ( !$targetRev ) {
+ wfDebug( __METHOD__ . ": target redirect already deleted, ignoring\n" );
+
+ return true;
+ }
+ $content = $targetRev->getContent();
+ $currentDest = $content ? $content->getRedirectTarget() : null;
+ if ( !$currentDest || !$currentDest->equals( $this->redirTitle ) ) {
+ wfDebug( __METHOD__ . ": Redirect has changed since the job was queued\n" );
+
+ return true;
+ }
+
+ // Check for a suppression tag (used e.g. in periodically archived discussions)
+ $mw = MagicWord::get( 'staticredirect' );
+ if ( $content->matchMagicWord( $mw ) ) {
+ wfDebug( __METHOD__ . ": skipping: suppressed with __STATICREDIRECT__\n" );
+
+ return true;
+ }
+
+ // Find the current final destination
+ $newTitle = self::getFinalDestination( $this->redirTitle );
+ if ( !$newTitle ) {
+ wfDebug( __METHOD__ .
+ ": skipping: single redirect, circular redirect or invalid redirect destination\n" );
+
+ return true;
+ }
+ if ( $newTitle->equals( $this->redirTitle ) ) {
+ // The redirect is already right, no need to change it
+ // This can happen if the page was moved back (say after vandalism)
+ wfDebug( __METHOD__ . " : skipping, already good\n" );
+ }
+
+ // Preserve fragment (T16904)
+ $newTitle = Title::makeTitle( $newTitle->getNamespace(), $newTitle->getDBkey(),
+ $currentDest->getFragment(), $newTitle->getInterwiki() );
+
+ // Fix the text
+ $newContent = $content->updateRedirect( $newTitle );
+
+ if ( $newContent->equals( $content ) ) {
+ $this->setLastError( 'Content unchanged???' );
+
+ return false;
+ }
+
+ $user = $this->getUser();
+ if ( !$user ) {
+ $this->setLastError( 'Invalid user' );
+
+ return false;
+ }
+
+ // Save it
+ global $wgUser;
+ $oldUser = $wgUser;
+ $wgUser = $user;
+ $article = WikiPage::factory( $this->title );
+
+ // Messages: double-redirect-fixed-move, double-redirect-fixed-maintenance
+ $reason = wfMessage( 'double-redirect-fixed-' . $this->reason,
+ $this->redirTitle->getPrefixedText(), $newTitle->getPrefixedText()
+ )->inContentLanguage()->text();
+ $flags = EDIT_UPDATE | EDIT_SUPPRESS_RC | EDIT_INTERNAL;
+ $article->doEditContent( $newContent, $reason, $flags, false, $user );
+ $wgUser = $oldUser;
+
+ return true;
+ }
+
+ /**
+ * Get the final destination of a redirect
+ *
+ * @param Title $title
+ *
+ * @return Title|bool The final Title after following all redirects, or false if
+ * the page is not a redirect or the redirect loops.
+ */
+ public static function getFinalDestination( $title ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ // Circular redirect check
+ $seenTitles = [];
+ $dest = false;
+
+ while ( true ) {
+ $titleText = $title->getPrefixedDBkey();
+ if ( isset( $seenTitles[$titleText] ) ) {
+ wfDebug( __METHOD__, "Circular redirect detected, aborting\n" );
+
+ return false;
+ }
+ $seenTitles[$titleText] = true;
+
+ if ( $title->isExternal() ) {
+ // If the target is interwiki, we have to break early (T42352).
+ // Otherwise it will look up a row in the local page table
+ // with the namespace/page of the interwiki target which can cause
+ // unexpected results (e.g. X -> foo:Bar -> Bar -> .. )
+ break;
+ }
+
+ $row = $dbw->selectRow(
+ [ 'redirect', 'page' ],
+ [ 'rd_namespace', 'rd_title', 'rd_interwiki' ],
+ [
+ 'rd_from=page_id',
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ], __METHOD__ );
+ if ( !$row ) {
+ # No redirect from here, chain terminates
+ break;
+ } else {
+ $dest = $title = Title::makeTitle(
+ $row->rd_namespace,
+ $row->rd_title,
+ '',
+ $row->rd_interwiki
+ );
+ }
+ }
+
+ return $dest;
+ }
+
+ /**
+ * Get a user object for doing edits, from a request-lifetime cache
+ * False will be returned if the user name specified in the
+ * 'double-redirect-fixer' message is invalid.
+ *
+ * @return User|bool
+ */
+ function getUser() {
+ if ( !self::$user ) {
+ $username = wfMessage( 'double-redirect-fixer' )->inContentLanguage()->text();
+ self::$user = User::newFromName( $username );
+ # User::newFromName() can return false on a badly configured wiki.
+ if ( self::$user && !self::$user->isLoggedIn() ) {
+ self::$user->addToDatabase();
+ }
+ }
+
+ return self::$user;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/DuplicateJob.php b/www/wiki/includes/jobqueue/jobs/DuplicateJob.php
new file mode 100644
index 00000000..c005a29a
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/DuplicateJob.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * No-op job that does nothing.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * No-op job that does nothing. Used to represent duplicates.
+ *
+ * @ingroup JobQueue
+ */
+final class DuplicateJob extends Job {
+ /**
+ * Callers should use DuplicateJob::newFromJob() instead
+ *
+ * @param Title $title
+ * @param array $params Job parameters
+ */
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'duplicate', $title, $params );
+ }
+
+ /**
+ * Get a duplicate no-op version of a job
+ *
+ * @param Job $job
+ * @return Job
+ */
+ public static function newFromJob( Job $job ) {
+ $djob = new self( $job->getTitle(), $job->getParams() );
+ $djob->command = $job->getType();
+ $djob->params = is_array( $djob->params ) ? $djob->params : [];
+ $djob->params = [ 'isDuplicate' => true ] + $djob->params;
+ $djob->metadata = $job->metadata;
+
+ return $djob;
+ }
+
+ public function run() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/EmaillingJob.php b/www/wiki/includes/jobqueue/jobs/EmaillingJob.php
new file mode 100644
index 00000000..960e8828
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/EmaillingJob.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Old job for notification emails.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Old job used for sending single notification emails;
+ * kept for backwards-compatibility
+ *
+ * @ingroup JobQueue
+ */
+class EmaillingJob extends Job {
+ function __construct( Title $title = null, array $params ) {
+ parent::__construct( 'sendMail', Title::newMainPage(), $params );
+ }
+
+ function run() {
+ $status = UserMailer::send(
+ $this->params['to'],
+ $this->params['from'],
+ $this->params['subj'],
+ $this->params['body'],
+ [ 'replyTo' => $this->params['replyto'] ]
+ );
+
+ return $status->isOK();
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/EnotifNotifyJob.php b/www/wiki/includes/jobqueue/jobs/EnotifNotifyJob.php
new file mode 100644
index 00000000..9a5c3c72
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/EnotifNotifyJob.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Job for notification emails.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Job for email notification mails
+ *
+ * @ingroup JobQueue
+ */
+class EnotifNotifyJob extends Job {
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'enotifNotify', $title, $params );
+ }
+
+ function run() {
+ $enotif = new EmailNotification();
+ // Get the user from ID (rename safe). Anons are 0, so defer to name.
+ if ( isset( $this->params['editorID'] ) && $this->params['editorID'] ) {
+ $editor = User::newFromId( $this->params['editorID'] );
+ // B/C, only the name might be given.
+ } else {
+ # @todo FIXME: newFromName could return false on a badly configured wiki.
+ $editor = User::newFromName( $this->params['editor'], false );
+ }
+ $enotif->actuallyNotifyOnPageChange(
+ $editor,
+ $this->title,
+ $this->params['timestamp'],
+ $this->params['summary'],
+ $this->params['minorEdit'],
+ $this->params['oldid'],
+ $this->params['watchers'],
+ $this->params['pageStatus']
+ );
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/EnqueueJob.php b/www/wiki/includes/jobqueue/jobs/EnqueueJob.php
new file mode 100644
index 00000000..5ffb01b4
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/EnqueueJob.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * Router job that takes jobs and enqueues them.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Router job that takes jobs and enqueues them to their proper queues
+ *
+ * This can be used for several things:
+ * - a) Making multi-job enqueues more robust by atomically enqueueing
+ * a single job that pushes the actual jobs (with retry logic)
+ * - b) Masking the latency of pushing jobs to different queues/wikis
+ * - c) Low-latency enqueues to push jobs from warm to hot datacenters
+ *
+ * @ingroup JobQueue
+ * @since 1.25
+ */
+final class EnqueueJob extends Job {
+ /**
+ * Callers should use the factory methods instead
+ *
+ * @param Title $title
+ * @param array $params Job parameters
+ */
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'enqueue', $title, $params );
+ }
+
+ /**
+ * @param JobSpecification|JobSpecification[] $jobs
+ * @return EnqueueJob
+ */
+ public static function newFromLocalJobs( $jobs ) {
+ $jobs = is_array( $jobs ) ? $jobs : [ $jobs ];
+
+ return self::newFromJobsByWiki( [ wfWikiID() => $jobs ] );
+ }
+
+ /**
+ * @param array $jobsByWiki Map of (wiki => JobSpecification list)
+ * @return EnqueueJob
+ */
+ public static function newFromJobsByWiki( array $jobsByWiki ) {
+ $deduplicate = true;
+
+ $jobMapsByWiki = [];
+ foreach ( $jobsByWiki as $wiki => $jobs ) {
+ $jobMapsByWiki[$wiki] = [];
+ foreach ( $jobs as $job ) {
+ if ( $job instanceof JobSpecification ) {
+ $jobMapsByWiki[$wiki][] = $job->toSerializableArray();
+ } else {
+ throw new InvalidArgumentException( "Jobs must be of type JobSpecification." );
+ }
+ $deduplicate = $deduplicate && $job->ignoreDuplicates();
+ }
+ }
+
+ $eJob = new self(
+ Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __CLASS__ ),
+ [ 'jobsByWiki' => $jobMapsByWiki ]
+ );
+ // If *all* jobs to be pushed are to be de-duplicated (a common case), then
+ // de-duplicate this whole job itself to avoid build up in high traffic cases
+ $eJob->removeDuplicates = $deduplicate;
+
+ return $eJob;
+ }
+
+ public function run() {
+ foreach ( $this->params['jobsByWiki'] as $wiki => $jobMaps ) {
+ $jobSpecs = [];
+ foreach ( $jobMaps as $jobMap ) {
+ $jobSpecs[] = JobSpecification::newFromArray( $jobMap );
+ }
+ JobQueueGroup::singleton( $wiki )->push( $jobSpecs );
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/HTMLCacheUpdateJob.php b/www/wiki/includes/jobqueue/jobs/HTMLCacheUpdateJob.php
new file mode 100644
index 00000000..0aa33cac
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/HTMLCacheUpdateJob.php
@@ -0,0 +1,178 @@
+<?php
+/**
+ * HTML cache invalidation of all pages linking to a given title.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ * @ingroup Cache
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Job to purge the cache for all pages that link to or use another page or file
+ *
+ * This job comes in a few variants:
+ * - a) Recursive jobs to purge caches for backlink pages for a given title.
+ * These jobs have (recursive:true,table:<table>) set.
+ * - b) Jobs to purge caches for a set of titles (the job title is ignored).
+ * These jobs have (pages:(<page ID>:(<namespace>,<title>),...) set.
+ *
+ * @ingroup JobQueue
+ */
+class HTMLCacheUpdateJob extends Job {
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'htmlCacheUpdate', $title, $params );
+ // Avoid the overhead of de-duplication when it would be pointless.
+ // Note that these jobs always set page_touched to the current time,
+ // so letting the older existing job "win" is still correct.
+ $this->removeDuplicates = (
+ // Ranges rarely will line up
+ !isset( $params['range'] ) &&
+ // Multiple pages per job make matches unlikely
+ !( isset( $params['pages'] ) && count( $params['pages'] ) != 1 )
+ );
+ }
+
+ /**
+ * @param Title $title Title to purge backlink pages from
+ * @param string $table Backlink table name
+ * @return HTMLCacheUpdateJob
+ */
+ public static function newForBacklinks( Title $title, $table ) {
+ return new self(
+ $title,
+ [
+ 'table' => $table,
+ 'recursive' => true
+ ] + Job::newRootJobParams( // "overall" refresh links job info
+ "htmlCacheUpdate:{$table}:{$title->getPrefixedText()}"
+ )
+ );
+ }
+
+ function run() {
+ global $wgUpdateRowsPerJob, $wgUpdateRowsPerQuery;
+
+ if ( isset( $this->params['table'] ) && !isset( $this->params['pages'] ) ) {
+ $this->params['recursive'] = true; // b/c; base job
+ }
+
+ // Job to purge all (or a range of) backlink pages for a page
+ if ( !empty( $this->params['recursive'] ) ) {
+ // Convert this into no more than $wgUpdateRowsPerJob HTMLCacheUpdateJob per-title
+ // jobs and possibly a recursive HTMLCacheUpdateJob job for the rest of the backlinks
+ $jobs = BacklinkJobUtils::partitionBacklinkJob(
+ $this,
+ $wgUpdateRowsPerJob,
+ $wgUpdateRowsPerQuery, // jobs-per-title
+ // Carry over information for de-duplication
+ [ 'params' => $this->getRootJobParams() ]
+ );
+ JobQueueGroup::singleton()->push( $jobs );
+ // Job to purge pages for a set of titles
+ } elseif ( isset( $this->params['pages'] ) ) {
+ $this->invalidateTitles( $this->params['pages'] );
+ // Job to update a single title
+ } else {
+ $t = $this->title;
+ $this->invalidateTitles( [
+ $t->getArticleID() => [ $t->getNamespace(), $t->getDBkey() ]
+ ] );
+ }
+
+ return true;
+ }
+
+ /**
+ * @param array $pages Map of (page ID => (namespace, DB key)) entries
+ */
+ protected function invalidateTitles( array $pages ) {
+ global $wgUpdateRowsPerQuery, $wgUseFileCache;
+
+ // Get all page IDs in this query into an array
+ $pageIds = array_keys( $pages );
+ if ( !$pageIds ) {
+ return;
+ }
+
+ // Bump page_touched to the current timestamp. This used to use the root job timestamp
+ // (e.g. template/file edit time), which was a bit more efficient when template edits are
+ // rare and don't effect the same pages much. However, this way allows for better
+ // de-duplication, which is much more useful for wikis with high edit rates. Note that
+ // RefreshLinksJob, which is enqueued alongside HTMLCacheUpdateJob, saves the parser output
+ // since it has to parse anyway. We assume that vast majority of the cache jobs finish
+ // before the link jobs, so using the current timestamp instead of the root timestamp is
+ // not expected to invalidate these cache entries too often.
+ $touchTimestamp = wfTimestampNow();
+ // If page_touched is higher than this, then something else already bumped it after enqueue
+ $condTimestamp = isset( $this->params['rootJobTimestamp'] )
+ ? $this->params['rootJobTimestamp']
+ : $touchTimestamp;
+
+ $dbw = wfGetDB( DB_MASTER );
+ $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
+ // Update page_touched (skipping pages already touched since the root job).
+ // Check $wgUpdateRowsPerQuery for sanity; batch jobs are sized by that already.
+ foreach ( array_chunk( $pageIds, $wgUpdateRowsPerQuery ) as $batch ) {
+ $factory->commitAndWaitForReplication( __METHOD__, $ticket );
+
+ $dbw->update( 'page',
+ [ 'page_touched' => $dbw->timestamp( $touchTimestamp ) ],
+ [ 'page_id' => $batch,
+ // don't invalidated pages that were already invalidated
+ "page_touched < " . $dbw->addQuotes( $dbw->timestamp( $condTimestamp ) )
+ ],
+ __METHOD__
+ );
+ }
+ // Get the list of affected pages (races only mean something else did the purge)
+ $titleArray = TitleArray::newFromResult( $dbw->select(
+ 'page',
+ [ 'page_namespace', 'page_title' ],
+ [ 'page_id' => $pageIds, 'page_touched' => $dbw->timestamp( $touchTimestamp ) ],
+ __METHOD__
+ ) );
+
+ // Update CDN; call purge() directly so as to not bother with secondary purges
+ $urls = [];
+ foreach ( $titleArray as $title ) {
+ /** @var Title $title */
+ $urls = array_merge( $urls, $title->getCdnUrls() );
+ }
+ CdnCacheUpdate::purge( $urls );
+
+ // Update file cache
+ if ( $wgUseFileCache ) {
+ foreach ( $titleArray as $title ) {
+ HTMLFileCache::clearFileCache( $title );
+ }
+ }
+ }
+
+ public function workItemCount() {
+ if ( !empty( $this->params['recursive'] ) ) {
+ return 0; // nothing actually purged
+ } elseif ( isset( $this->params['pages'] ) ) {
+ return count( $this->params['pages'] );
+ }
+
+ return 1; // one title
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/NullJob.php b/www/wiki/includes/jobqueue/jobs/NullJob.php
new file mode 100644
index 00000000..80826fe1
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/NullJob.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Degenerate job that does nothing.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Degenerate job that does nothing, but can optionally replace itself
+ * in the queue and/or sleep for a brief time period. These can be used
+ * to represent "no-op" jobs or test lock contention and performance.
+ *
+ * @par Example:
+ * Inserting a null job in the configured job queue:
+ * @code
+ * $ php maintenance/eval.php
+ * > $queue = JobQueueGroup::singleton();
+ * > $job = new NullJob( Title::newMainPage(), [ 'lives' => 10 ] );
+ * > $queue->push( $job );
+ * @endcode
+ * You can then confirm the job has been enqueued by using the showJobs.php
+ * maintenance utility:
+ * @code
+ * $ php maintenance/showJobs.php --group
+ * null: 1 queue; 0 claimed (0 active, 0 abandoned)
+ * $
+ * @endcode
+ *
+ * @ingroup JobQueue
+ */
+class NullJob extends Job {
+ /**
+ * @param Title $title
+ * @param array $params Job parameters (lives, usleep)
+ */
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'null', $title, $params );
+ if ( !isset( $this->params['lives'] ) ) {
+ $this->params['lives'] = 1;
+ }
+ if ( !isset( $this->params['usleep'] ) ) {
+ $this->params['usleep'] = 0;
+ }
+ $this->removeDuplicates = !empty( $this->params['removeDuplicates'] );
+ }
+
+ public function run() {
+ if ( $this->params['usleep'] > 0 ) {
+ usleep( $this->params['usleep'] );
+ }
+ if ( $this->params['lives'] > 1 ) {
+ $params = $this->params;
+ $params['lives']--;
+ $job = new self( $this->title, $params );
+ JobQueueGroup::singleton()->push( $job );
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/PublishStashedFileJob.php b/www/wiki/includes/jobqueue/jobs/PublishStashedFileJob.php
new file mode 100644
index 00000000..e89812be
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/PublishStashedFileJob.php
@@ -0,0 +1,152 @@
+<?php
+/**
+ * Upload a file from the upload stash into the local file repo.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Upload
+ * @ingroup JobQueue
+ */
+use Wikimedia\ScopedCallback;
+
+/**
+ * Upload a file from the upload stash into the local file repo.
+ *
+ * @ingroup Upload
+ * @ingroup JobQueue
+ */
+class PublishStashedFileJob extends Job {
+ public function __construct( Title $title, array $params ) {
+ parent::__construct( 'PublishStashedFile', $title, $params );
+ $this->removeDuplicates = true;
+ }
+
+ public function run() {
+ $scope = RequestContext::importScopedSession( $this->params['session'] );
+ $this->addTeardownCallback( function () use ( &$scope ) {
+ ScopedCallback::consume( $scope ); // T126450
+ } );
+
+ $context = RequestContext::getMain();
+ $user = $context->getUser();
+ try {
+ if ( !$user->isLoggedIn() ) {
+ $this->setLastError( "Could not load the author user from session." );
+
+ return false;
+ }
+
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [ 'result' => 'Poll', 'stage' => 'publish', 'status' => Status::newGood() ]
+ );
+
+ $upload = new UploadFromStash( $user );
+ // @todo initialize() causes a GET, ideally we could frontload the antivirus
+ // checks and anything else to the stash stage (which includes concatenation and
+ // the local file is thus already there). That way, instead of GET+PUT, there could
+ // just be a COPY operation from the stash to the public zone.
+ $upload->initialize( $this->params['filekey'], $this->params['filename'] );
+
+ // Check if the local file checks out (this is generally a no-op)
+ $verification = $upload->verifyUpload();
+ if ( $verification['status'] !== UploadBase::OK ) {
+ $status = Status::newFatal( 'verification-error' );
+ $status->value = [ 'verification' => $verification ];
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [ 'result' => 'Failure', 'stage' => 'publish', 'status' => $status ]
+ );
+ $this->setLastError( "Could not verify upload." );
+
+ return false;
+ }
+
+ // Upload the stashed file to a permanent location
+ $status = $upload->performUpload(
+ $this->params['comment'],
+ $this->params['text'],
+ $this->params['watch'],
+ $user,
+ isset( $this->params['tags'] ) ? $this->params['tags'] : []
+ );
+ if ( !$status->isGood() ) {
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [ 'result' => 'Failure', 'stage' => 'publish', 'status' => $status ]
+ );
+ $this->setLastError( $status->getWikiText( false, false, 'en' ) );
+
+ return false;
+ }
+
+ // Build the image info array while we have the local reference handy
+ $apiMain = new ApiMain(); // dummy object (XXX)
+ $imageInfo = $upload->getImageInfo( $apiMain->getResult() );
+
+ // Cleanup any temporary local file
+ $upload->cleanupTempFile();
+
+ // Cache the info so the user doesn't have to wait forever to get the final info
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [
+ 'result' => 'Success',
+ 'stage' => 'publish',
+ 'filename' => $upload->getLocalFile()->getName(),
+ 'imageinfo' => $imageInfo,
+ 'status' => Status::newGood()
+ ]
+ );
+ } catch ( Exception $e ) {
+ UploadBase::setSessionStatus(
+ $user,
+ $this->params['filekey'],
+ [
+ 'result' => 'Failure',
+ 'stage' => 'publish',
+ 'status' => Status::newFatal( 'api-error-publishfailed' )
+ ]
+ );
+ $this->setLastError( get_class( $e ) . ": " . $e->getMessage() );
+ // To prevent potential database referential integrity issues.
+ // See T34551.
+ MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getDeduplicationInfo() {
+ $info = parent::getDeduplicationInfo();
+ if ( is_array( $info['params'] ) ) {
+ $info['params'] = [ 'filekey' => $info['params']['filekey'] ];
+ }
+
+ return $info;
+ }
+
+ public function allowRetries() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/RecentChangesUpdateJob.php b/www/wiki/includes/jobqueue/jobs/RecentChangesUpdateJob.php
new file mode 100644
index 00000000..6f349d44
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/RecentChangesUpdateJob.php
@@ -0,0 +1,244 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\DBReplicationWaitError;
+
+/**
+ * Job for pruning recent changes
+ *
+ * @ingroup JobQueue
+ * @since 1.25
+ */
+class RecentChangesUpdateJob extends Job {
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'recentChangesUpdate', $title, $params );
+
+ if ( !isset( $params['type'] ) ) {
+ throw new Exception( "Missing 'type' parameter." );
+ }
+
+ $this->removeDuplicates = true;
+ }
+
+ /**
+ * @return RecentChangesUpdateJob
+ */
+ final public static function newPurgeJob() {
+ return new self(
+ SpecialPage::getTitleFor( 'Recentchanges' ), [ 'type' => 'purge' ]
+ );
+ }
+
+ /**
+ * @return RecentChangesUpdateJob
+ * @since 1.26
+ */
+ final public static function newCacheUpdateJob() {
+ return new self(
+ SpecialPage::getTitleFor( 'Recentchanges' ), [ 'type' => 'cacheUpdate' ]
+ );
+ }
+
+ public function run() {
+ if ( $this->params['type'] === 'purge' ) {
+ $this->purgeExpiredRows();
+ } elseif ( $this->params['type'] === 'cacheUpdate' ) {
+ $this->updateActiveUsers();
+ } else {
+ throw new InvalidArgumentException(
+ "Invalid 'type' parameter '{$this->params['type']}'." );
+ }
+
+ return true;
+ }
+
+ protected function purgeExpiredRows() {
+ global $wgRCMaxAge, $wgUpdateRowsPerQuery;
+
+ $lockKey = wfWikiID() . ':recentchanges-prune';
+
+ $dbw = wfGetDB( DB_MASTER );
+ if ( !$dbw->lockIsFree( $lockKey, __METHOD__ )
+ || !$dbw->lock( $lockKey, __METHOD__, 1 )
+ ) {
+ return; // already in progress
+ }
+
+ $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
+ $cutoff = $dbw->timestamp( time() - $wgRCMaxAge );
+ do {
+ $rcIds = [];
+ $rows = [];
+ $res = $dbw->select( 'recentchanges',
+ RecentChange::selectFields(),
+ [ 'rc_timestamp < ' . $dbw->addQuotes( $cutoff ) ],
+ __METHOD__,
+ [ 'LIMIT' => $wgUpdateRowsPerQuery ]
+ );
+ foreach ( $res as $row ) {
+ $rcIds[] = $row->rc_id;
+ $rows[] = $row;
+ }
+ if ( $rcIds ) {
+ $dbw->delete( 'recentchanges', [ 'rc_id' => $rcIds ], __METHOD__ );
+ Hooks::run( 'RecentChangesPurgeRows', [ $rows ] );
+ // There might be more, so try waiting for replica DBs
+ try {
+ $factory->commitAndWaitForReplication(
+ __METHOD__, $ticket, [ 'timeout' => 3 ]
+ );
+ } catch ( DBReplicationWaitError $e ) {
+ // Another job will continue anyway
+ break;
+ }
+ }
+ } while ( $rcIds );
+
+ $dbw->unlock( $lockKey, __METHOD__ );
+ }
+
+ protected function updateActiveUsers() {
+ global $wgActiveUserDays;
+
+ // Users that made edits at least this many days ago are "active"
+ $days = $wgActiveUserDays;
+ // Pull in the full window of active users in this update
+ $window = $wgActiveUserDays * 86400;
+
+ $dbw = wfGetDB( DB_MASTER );
+ // JobRunner uses DBO_TRX, but doesn't call begin/commit itself;
+ // onTransactionIdle() will run immediately since there is no trx.
+ $dbw->onTransactionIdle(
+ function () use ( $dbw, $days, $window ) {
+ $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
+ // Avoid disconnect/ping() cycle that makes locks fall off
+ $dbw->setSessionOptions( [ 'connTimeout' => 900 ] );
+
+ $lockKey = wfWikiID() . '-activeusers';
+ if ( !$dbw->lockIsFree( $lockKey, __METHOD__ ) || !$dbw->lock( $lockKey, __METHOD__, 1 ) ) {
+ // Exclusive update (avoids duplicate entries)… it's usually fine to just drop out here,
+ // if the Job is already running.
+ return;
+ }
+
+ $nowUnix = time();
+ // Get the last-updated timestamp for the cache
+ $cTime = $dbw->selectField( 'querycache_info',
+ 'qci_timestamp',
+ [ 'qci_type' => 'activeusers' ]
+ );
+ $cTimeUnix = $cTime ? wfTimestamp( TS_UNIX, $cTime ) : 1;
+
+ // Pick the date range to fetch from. This is normally from the last
+ // update to till the present time, but has a limited window for sanity.
+ // If the window is limited, multiple runs are need to fully populate it.
+ $sTimestamp = max( $cTimeUnix, $nowUnix - $days * 86400 );
+ $eTimestamp = min( $sTimestamp + $window, $nowUnix );
+
+ // Get all the users active since the last update
+ $res = $dbw->select(
+ [ 'recentchanges' ],
+ [ 'rc_user_text', 'lastedittime' => 'MAX(rc_timestamp)' ],
+ [
+ 'rc_user > 0', // actual accounts
+ 'rc_type != ' . $dbw->addQuotes( RC_EXTERNAL ), // no wikidata
+ 'rc_log_type IS NULL OR rc_log_type != ' . $dbw->addQuotes( 'newusers' ),
+ 'rc_timestamp >= ' . $dbw->addQuotes( $dbw->timestamp( $sTimestamp ) ),
+ 'rc_timestamp <= ' . $dbw->addQuotes( $dbw->timestamp( $eTimestamp ) )
+ ],
+ __METHOD__,
+ [
+ 'GROUP BY' => [ 'rc_user_text' ],
+ 'ORDER BY' => 'NULL' // avoid filesort
+ ]
+ );
+ $names = [];
+ foreach ( $res as $row ) {
+ $names[$row->rc_user_text] = $row->lastedittime;
+ }
+
+ // Find which of the recently active users are already accounted for
+ if ( count( $names ) ) {
+ $res = $dbw->select( 'querycachetwo',
+ [ 'user_name' => 'qcc_title' ],
+ [
+ 'qcc_type' => 'activeusers',
+ 'qcc_namespace' => NS_USER,
+ 'qcc_title' => array_keys( $names ),
+ 'qcc_value >= ' . $dbw->addQuotes( $nowUnix - $days * 86400 ), // TS_UNIX
+ ],
+ __METHOD__
+ );
+ // Note: In order for this to be actually consistent, we would need
+ // to update these rows with the new lastedittime.
+ foreach ( $res as $row ) {
+ unset( $names[$row->user_name] );
+ }
+ }
+
+ // Insert the users that need to be added to the list
+ if ( count( $names ) ) {
+ $newRows = [];
+ foreach ( $names as $name => $lastEditTime ) {
+ $newRows[] = [
+ 'qcc_type' => 'activeusers',
+ 'qcc_namespace' => NS_USER,
+ 'qcc_title' => $name,
+ 'qcc_value' => wfTimestamp( TS_UNIX, $lastEditTime ),
+ 'qcc_namespacetwo' => 0, // unused
+ 'qcc_titletwo' => '' // unused
+ ];
+ }
+ foreach ( array_chunk( $newRows, 500 ) as $rowBatch ) {
+ $dbw->insert( 'querycachetwo', $rowBatch, __METHOD__ );
+ $factory->commitAndWaitForReplication( __METHOD__, $ticket );
+ }
+ }
+
+ // If a transaction was already started, it might have an old
+ // snapshot, so kludge the timestamp range back as needed.
+ $asOfTimestamp = min( $eTimestamp, (int)$dbw->trxTimestamp() );
+
+ // Touch the data freshness timestamp
+ $dbw->replace( 'querycache_info',
+ [ 'qci_type' ],
+ [ 'qci_type' => 'activeusers',
+ 'qci_timestamp' => $dbw->timestamp( $asOfTimestamp ) ], // not always $now
+ __METHOD__
+ );
+
+ $dbw->unlock( $lockKey, __METHOD__ );
+
+ // Rotate out users that have not edited in too long (according to old data set)
+ $dbw->delete( 'querycachetwo',
+ [
+ 'qcc_type' => 'activeusers',
+ 'qcc_value < ' . $dbw->addQuotes( $nowUnix - $days * 86400 ) // TS_UNIX
+ ],
+ __METHOD__
+ );
+ },
+ __METHOD__
+ );
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/RefreshLinksJob.php b/www/wiki/includes/jobqueue/jobs/RefreshLinksJob.php
new file mode 100644
index 00000000..424fcecb
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/RefreshLinksJob.php
@@ -0,0 +1,312 @@
+<?php
+/**
+ * Job to update link tables for pages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\DBReplicationWaitError;
+
+/**
+ * Job to update link tables for pages
+ *
+ * This job comes in a few variants:
+ * - a) Recursive jobs to update links for backlink pages for a given title.
+ * These jobs have (recursive:true,table:<table>) set.
+ * - b) Jobs to update links for a set of pages (the job title is ignored).
+ * These jobs have (pages:(<page ID>:(<namespace>,<title>),...) set.
+ * - c) Jobs to update links for a single page (the job title)
+ * These jobs need no extra fields set.
+ *
+ * @ingroup JobQueue
+ */
+class RefreshLinksJob extends Job {
+ /** @var float Cache parser output when it takes this long to render */
+ const PARSE_THRESHOLD_SEC = 1.0;
+ /** @var int Lag safety margin when comparing root job times to last-refresh times */
+ const CLOCK_FUDGE = 10;
+ /** @var int How many seconds to wait for replica DBs to catch up */
+ const LAG_WAIT_TIMEOUT = 15;
+
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'refreshLinks', $title, $params );
+ // Avoid the overhead of de-duplication when it would be pointless
+ $this->removeDuplicates = (
+ // Ranges rarely will line up
+ !isset( $params['range'] ) &&
+ // Multiple pages per job make matches unlikely
+ !( isset( $params['pages'] ) && count( $params['pages'] ) != 1 )
+ );
+ }
+
+ /**
+ * @param Title $title
+ * @param array $params
+ * @return RefreshLinksJob
+ */
+ public static function newPrioritized( Title $title, array $params ) {
+ $job = new self( $title, $params );
+ $job->command = 'refreshLinksPrioritized';
+
+ return $job;
+ }
+
+ /**
+ * @param Title $title
+ * @param array $params
+ * @return RefreshLinksJob
+ */
+ public static function newDynamic( Title $title, array $params ) {
+ $job = new self( $title, $params );
+ $job->command = 'refreshLinksDynamic';
+
+ return $job;
+ }
+
+ function run() {
+ global $wgUpdateRowsPerJob;
+
+ // Job to update all (or a range of) backlink pages for a page
+ if ( !empty( $this->params['recursive'] ) ) {
+ // When the base job branches, wait for the replica DBs to catch up to the master.
+ // From then on, we know that any template changes at the time the base job was
+ // enqueued will be reflected in backlink page parses when the leaf jobs run.
+ if ( !isset( $this->params['range'] ) ) {
+ try {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->waitForReplication( [
+ 'wiki' => wfWikiID(),
+ 'timeout' => self::LAG_WAIT_TIMEOUT
+ ] );
+ } catch ( DBReplicationWaitError $e ) { // only try so hard
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $stats->increment( 'refreshlinks.lag_wait_failed' );
+ }
+ }
+ // Carry over information for de-duplication
+ $extraParams = $this->getRootJobParams();
+ $extraParams['triggeredRecursive'] = true;
+ // Convert this into no more than $wgUpdateRowsPerJob RefreshLinks per-title
+ // jobs and possibly a recursive RefreshLinks job for the rest of the backlinks
+ $jobs = BacklinkJobUtils::partitionBacklinkJob(
+ $this,
+ $wgUpdateRowsPerJob,
+ 1, // job-per-title
+ [ 'params' => $extraParams ]
+ );
+ JobQueueGroup::singleton()->push( $jobs );
+ // Job to update link tables for a set of titles
+ } elseif ( isset( $this->params['pages'] ) ) {
+ foreach ( $this->params['pages'] as $nsAndKey ) {
+ list( $ns, $dbKey ) = $nsAndKey;
+ $this->runForTitle( Title::makeTitleSafe( $ns, $dbKey ) );
+ }
+ // Job to update link tables for a given title
+ } else {
+ $this->runForTitle( $this->title );
+ }
+
+ return true;
+ }
+
+ /**
+ * @param Title $title
+ * @return bool
+ */
+ protected function runForTitle( Title $title ) {
+ $services = MediaWikiServices::getInstance();
+ $stats = $services->getStatsdDataFactory();
+ $lbFactory = $services->getDBLoadBalancerFactory();
+ $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+
+ $page = WikiPage::factory( $title );
+ $page->loadPageData( WikiPage::READ_LATEST );
+
+ // Serialize links updates by page ID so they see each others' changes
+ $dbw = $lbFactory->getMainLB()->getConnection( DB_MASTER );
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scopedLock = LinksUpdate::acquirePageLock( $dbw, $page->getId(), 'job' );
+ // Get the latest ID *after* acquirePageLock() flushed the transaction.
+ // This is used to detect edits/moves after loadPageData() but before the scope lock.
+ // The works around the chicken/egg problem of determining the scope lock key.
+ $latest = $title->getLatestRevID( Title::GAID_FOR_UPDATE );
+
+ if ( !empty( $this->params['triggeringRevisionId'] ) ) {
+ // Fetch the specified revision; lockAndGetLatest() below detects if the page
+ // was edited since and aborts in order to avoid corrupting the link tables
+ $revision = Revision::newFromId(
+ $this->params['triggeringRevisionId'],
+ Revision::READ_LATEST
+ );
+ } else {
+ // Fetch current revision; READ_LATEST reduces lockAndGetLatest() check failures
+ $revision = Revision::newFromTitle( $title, false, Revision::READ_LATEST );
+ }
+
+ if ( !$revision ) {
+ $stats->increment( 'refreshlinks.rev_not_found' );
+ $this->setLastError( "Revision not found for {$title->getPrefixedDBkey()}" );
+ return false; // just deleted?
+ } elseif ( $revision->getId() != $latest || $revision->getPage() !== $page->getId() ) {
+ // Do not clobber over newer updates with older ones. If all jobs where FIFO and
+ // serialized, it would be OK to update links based on older revisions since it
+ // would eventually get to the latest. Since that is not the case (by design),
+ // only update the link tables to a state matching the current revision's output.
+ $stats->increment( 'refreshlinks.rev_not_current' );
+ $this->setLastError( "Revision {$revision->getId()} is not current" );
+ return false;
+ }
+
+ $content = $revision->getContent( Revision::RAW );
+ if ( !$content ) {
+ // If there is no content, pretend the content is empty
+ $content = $revision->getContentHandler()->makeEmptyContent();
+ }
+
+ $parserOutput = false;
+ $parserOptions = $page->makeParserOptions( 'canonical' );
+ // If page_touched changed after this root job, then it is likely that
+ // any views of the pages already resulted in re-parses which are now in
+ // cache. The cache can be reused to avoid expensive parsing in some cases.
+ if ( isset( $this->params['rootJobTimestamp'] ) ) {
+ $opportunistic = !empty( $this->params['isOpportunistic'] );
+
+ $skewedTimestamp = $this->params['rootJobTimestamp'];
+ if ( $opportunistic ) {
+ // Neither clock skew nor DB snapshot/replica DB lag matter much for such
+ // updates; focus on reusing the (often recently updated) cache
+ } else {
+ // For transclusion updates, the template changes must be reflected
+ $skewedTimestamp = wfTimestamp( TS_MW,
+ wfTimestamp( TS_UNIX, $skewedTimestamp ) + self::CLOCK_FUDGE
+ );
+ }
+
+ if ( $page->getLinksTimestamp() > $skewedTimestamp ) {
+ // Something already updated the backlinks since this job was made
+ $stats->increment( 'refreshlinks.update_skipped' );
+ return true;
+ }
+
+ if ( $page->getTouched() >= $this->params['rootJobTimestamp'] || $opportunistic ) {
+ // Cache is suspected to be up-to-date. As long as the cache rev ID matches
+ // and it reflects the job's triggering change, then it is usable.
+ $parserOutput = $services->getParserCache()->getDirty( $page, $parserOptions );
+ if ( !$parserOutput
+ || $parserOutput->getCacheRevisionId() != $revision->getId()
+ || $parserOutput->getCacheTime() < $skewedTimestamp
+ ) {
+ $parserOutput = false; // too stale
+ }
+ }
+ }
+
+ // Fetch the current revision and parse it if necessary...
+ if ( $parserOutput ) {
+ $stats->increment( 'refreshlinks.parser_cached' );
+ } else {
+ $start = microtime( true );
+ // Revision ID must be passed to the parser output to get revision variables correct
+ $parserOutput = $content->getParserOutput(
+ $title, $revision->getId(), $parserOptions, false );
+ $elapsed = microtime( true ) - $start;
+ // If it took a long time to render, then save this back to the cache to avoid
+ // wasted CPU by other apaches or job runners. We don't want to always save to
+ // cache as this can cause high cache I/O and LRU churn when a template changes.
+ if ( $elapsed >= self::PARSE_THRESHOLD_SEC
+ && $page->shouldCheckParserCache( $parserOptions, $revision->getId() )
+ && $parserOutput->isCacheable()
+ ) {
+ $ctime = wfTimestamp( TS_MW, (int)$start ); // cache time
+ $services->getParserCache()->save(
+ $parserOutput, $page, $parserOptions, $ctime, $revision->getId()
+ );
+ }
+ $stats->increment( 'refreshlinks.parser_uncached' );
+ }
+
+ $updates = $content->getSecondaryDataUpdates(
+ $title,
+ null,
+ !empty( $this->params['useRecursiveLinksUpdate'] ),
+ $parserOutput
+ );
+
+ // For legacy hook handlers doing updates via LinksUpdateConstructed, make sure
+ // any pending writes they made get flushed before the doUpdate() calls below.
+ // This avoids snapshot-clearing errors in LinksUpdate::acquirePageLock().
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
+
+ foreach ( $updates as $update ) {
+ // FIXME: This code probably shouldn't be here?
+ // Needed by things like Echo notifications which need
+ // to know which user caused the links update
+ if ( $update instanceof LinksUpdate ) {
+ $update->setRevision( $revision );
+ if ( !empty( $this->params['triggeringUser'] ) ) {
+ $userInfo = $this->params['triggeringUser'];
+ if ( $userInfo['userId'] ) {
+ $user = User::newFromId( $userInfo['userId'] );
+ } else {
+ // Anonymous, use the username
+ $user = User::newFromName( $userInfo['userName'], false );
+ }
+ $update->setTriggeringUser( $user );
+ }
+ }
+ }
+
+ foreach ( $updates as $update ) {
+ $update->setTransactionTicket( $ticket );
+ $update->doUpdate();
+ }
+
+ InfoAction::invalidateCache( $title );
+
+ // Commit any writes here in case this method is called in a loop.
+ // In that case, the scoped lock will fail to be acquired.
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
+
+ return true;
+ }
+
+ public function getDeduplicationInfo() {
+ $info = parent::getDeduplicationInfo();
+ if ( is_array( $info['params'] ) ) {
+ // For per-pages jobs, the job title is that of the template that changed
+ // (or similar), so remove that since it ruins duplicate detection
+ if ( isset( $info['pages'] ) ) {
+ unset( $info['namespace'] );
+ unset( $info['title'] );
+ }
+ }
+
+ return $info;
+ }
+
+ public function workItemCount() {
+ if ( !empty( $this->params['recursive'] ) ) {
+ return 0; // nothing actually refreshed
+ } elseif ( isset( $this->params['pages'] ) ) {
+ return count( $this->params['pages'] );
+ }
+
+ return 1; // one title
+ }
+}
diff --git a/www/wiki/includes/jobqueue/jobs/ThumbnailRenderJob.php b/www/wiki/includes/jobqueue/jobs/ThumbnailRenderJob.php
new file mode 100644
index 00000000..cf3155d7
--- /dev/null
+++ b/www/wiki/includes/jobqueue/jobs/ThumbnailRenderJob.php
@@ -0,0 +1,111 @@
+<?php
+/**
+ * Job for asynchronous rendering of thumbnails.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Job for asynchronous rendering of thumbnails.
+ *
+ * @ingroup JobQueue
+ */
+class ThumbnailRenderJob extends Job {
+ public function __construct( Title $title, array $params ) {
+ parent::__construct( 'ThumbnailRender', $title, $params );
+ }
+
+ public function run() {
+ global $wgUploadThumbnailRenderMethod;
+
+ $transformParams = $this->params['transformParams'];
+
+ $file = wfLocalFile( $this->title );
+ $file->load( File::READ_LATEST );
+
+ if ( $file && $file->exists() ) {
+ if ( $wgUploadThumbnailRenderMethod === 'jobqueue' ) {
+ $thumb = $file->transform( $transformParams, File::RENDER_NOW );
+
+ if ( $thumb && !$thumb->isError() ) {
+ return true;
+ } else {
+ $this->setLastError( __METHOD__ . ': thumbnail couln\'t be generated' );
+ return false;
+ }
+ } elseif ( $wgUploadThumbnailRenderMethod === 'http' ) {
+ $thumbUrl = '';
+ $status = $this->hitThumbUrl( $file, $transformParams, $thumbUrl );
+
+ wfDebug( __METHOD__ . ": received status {$status}\n" );
+
+ // 400 happens when requesting a size greater or equal than the original
+ if ( $status === 200 || $status === 301 || $status === 302 || $status === 400 ) {
+ return true;
+ } elseif ( $status ) {
+ $this->setLastError( __METHOD__ . ': incorrect HTTP status ' .
+ $status . ' when hitting ' . $thumbUrl );
+ return false;
+ } else {
+ $this->setLastError( __METHOD__ . ': HTTP request failure' );
+ return false;
+ }
+ } else {
+ $this->setLastError( __METHOD__ . ': unknown thumbnail render method ' .
+ $wgUploadThumbnailRenderMethod );
+ return false;
+ }
+ } else {
+ $this->setLastError( __METHOD__ . ': file doesn\'t exist' );
+ return false;
+ }
+ }
+
+ protected function hitThumbUrl( LocalFile $file, $transformParams, &$thumbUrl ) {
+ global $wgUploadThumbnailRenderHttpCustomHost, $wgUploadThumbnailRenderHttpCustomDomain;
+
+ $thumbName = $file->thumbName( $transformParams );
+ $thumbUrl = $file->getThumbUrl( $thumbName );
+
+ if ( $wgUploadThumbnailRenderHttpCustomDomain ) {
+ $parsedUrl = wfParseUrl( $thumbUrl );
+
+ if ( !$parsedUrl || !isset( $parsedUrl['path'] ) || !strlen( $parsedUrl['path'] ) ) {
+ return false;
+ }
+
+ $thumbUrl = '//' . $wgUploadThumbnailRenderHttpCustomDomain . $parsedUrl['path'];
+ }
+
+ wfDebug( __METHOD__ . ": hitting url {$thumbUrl}\n" );
+
+ $request = MWHttpRequest::factory( $thumbUrl,
+ [ 'method' => 'HEAD', 'followRedirects' => true ],
+ __METHOD__
+ );
+
+ if ( $wgUploadThumbnailRenderHttpCustomHost ) {
+ $request->setHeader( 'Host', $wgUploadThumbnailRenderHttpCustomHost );
+ }
+
+ $status = $request->execute();
+
+ return $request->getStatus();
+ }
+}
diff --git a/www/wiki/includes/jobqueue/utils/BacklinkJobUtils.php b/www/wiki/includes/jobqueue/utils/BacklinkJobUtils.php
new file mode 100644
index 00000000..76f8d6d2
--- /dev/null
+++ b/www/wiki/includes/jobqueue/utils/BacklinkJobUtils.php
@@ -0,0 +1,149 @@
+<?php
+/**
+ * Job to update links for a given title.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+/**
+ * Class with Backlink related Job helper methods
+ *
+ * When an asset changes, a base job can be inserted to update all assets that depend on it.
+ * The base job splits into per-title "leaf" jobs and a "remnant" job to handle the remaining
+ * range of backlinks. This recurs until the remnant job's backlink range is small enough that
+ * only leaf jobs are created from it.
+ *
+ * For example, if templates A and B are edited (at the same time) the queue will have:
+ * (A base, B base)
+ * When these jobs run, the queue will have per-title and remnant partition jobs:
+ * (titleX,titleY,titleZ,...,A remnant,titleM,titleN,titleO,...,B remnant)
+ *
+ * This works best when the queue is FIFO, for several reasons:
+ * - a) Since the remnant jobs are enqueued after the leaf jobs, the slower leaf jobs have to
+ * get popped prior to the fast remnant jobs. This avoids flooding the queue with leaf jobs
+ * for every single backlink of widely used assets (which can be millions).
+ * - b) Other jobs going in the queue still get a chance to run after a widely used asset changes.
+ * This is due to the large remnant job pushing to the end of the queue with each division.
+ *
+ * The size of the queues used in this manner depend on the number of assets changes and the
+ * number of workers. Also, with FIFO-per-partition queues, the queue size can be somewhat larger,
+ * depending on the number of queue partitions.
+ *
+ * @ingroup JobQueue
+ * @since 1.23
+ */
+class BacklinkJobUtils {
+ /**
+ * Break down $job into approximately ($bSize/$cSize) leaf jobs and a single partition
+ * job that covers the remaining backlink range (if needed). Jobs for the first $bSize
+ * titles are collated ($cSize per job) into leaf jobs to do actual work. All the
+ * resulting jobs are of the same class as $job. No partition job is returned if the
+ * range covered by $job was less than $bSize, as the leaf jobs have full coverage.
+ *
+ * The leaf jobs have the 'pages' param set to a (<page ID>:(<namespace>,<DB key>),...)
+ * map so that the run() function knows what pages to act on. The leaf jobs will keep
+ * the same job title as the parent job (e.g. $job).
+ *
+ * The partition jobs have the 'range' parameter set to a map of the format
+ * (start:<integer>, end:<integer>, batchSize:<integer>, subranges:((<start>,<end>),...)),
+ * the 'table' parameter set to that of $job, and the 'recursive' parameter set to true.
+ * This method can be called on the resulting job to repeat the process again.
+ *
+ * The job provided ($job) must have the 'recursive' parameter set to true and the 'table'
+ * parameter must be set to a backlink table. The job title will be used as the title to
+ * find backlinks for. Any 'range' parameter must follow the same format as mentioned above.
+ * This should be managed by recursive calls to this method.
+ *
+ * The first jobs return are always the leaf jobs. This lets the caller use push() to
+ * put them directly into the queue and works well if the queue is FIFO. In such a queue,
+ * the leaf jobs have to get finished first before anything can resolve the next partition
+ * job, which keeps the queue very small.
+ *
+ * $opts includes:
+ * - params : extra job parameters to include in each job
+ *
+ * @param Job $job
+ * @param int $bSize BacklinkCache partition size; usually $wgUpdateRowsPerJob
+ * @param int $cSize Max titles per leaf job; Usually 1 or a modest value
+ * @param array $opts Optional parameter map
+ * @return Job[] List of Job objects
+ */
+ public static function partitionBacklinkJob( Job $job, $bSize, $cSize, $opts = [] ) {
+ $class = get_class( $job );
+ $title = $job->getTitle();
+ $params = $job->getParams();
+
+ if ( isset( $params['pages'] ) || empty( $params['recursive'] ) ) {
+ $ranges = []; // sanity; this is a leaf node
+ $realBSize = 0;
+ wfWarn( __METHOD__ . " called on {$job->getType()} leaf job (explosive recursion)." );
+ } elseif ( isset( $params['range'] ) ) {
+ // This is a range job to trigger the insertion of partitioned/title jobs...
+ $ranges = $params['range']['subranges'];
+ $realBSize = $params['range']['batchSize'];
+ } else {
+ // This is a base job to trigger the insertion of partitioned jobs...
+ $ranges = $title->getBacklinkCache()->partition( $params['table'], $bSize );
+ $realBSize = $bSize;
+ }
+
+ $extraParams = isset( $opts['params'] ) ? $opts['params'] : [];
+
+ $jobs = [];
+ // Combine the first range (of size $bSize) backlinks into leaf jobs
+ if ( isset( $ranges[0] ) ) {
+ list( $start, $end ) = $ranges[0];
+ $iter = $title->getBacklinkCache()->getLinks( $params['table'], $start, $end );
+ $titles = iterator_to_array( $iter );
+ /** @var Title[] $titleBatch */
+ foreach ( array_chunk( $titles, $cSize ) as $titleBatch ) {
+ $pages = [];
+ foreach ( $titleBatch as $tl ) {
+ $pages[$tl->getArticleID()] = [ $tl->getNamespace(), $tl->getDBkey() ];
+ }
+ $jobs[] = new $class(
+ $title, // maintain parent job title
+ [ 'pages' => $pages ] + $extraParams
+ );
+ }
+ }
+ // Take all of the remaining ranges and build a partition job from it
+ if ( isset( $ranges[1] ) ) {
+ $jobs[] = new $class(
+ $title, // maintain parent job title
+ [
+ 'recursive' => true,
+ 'table' => $params['table'],
+ 'range' => [
+ 'start' => $ranges[1][0],
+ 'end' => $ranges[count( $ranges ) - 1][1],
+ 'batchSize' => $realBSize,
+ 'subranges' => array_slice( $ranges, 1 )
+ ],
+ // Track how many times the base job divided for debugging
+ 'division' => isset( $params['division'] )
+ ? ( $params['division'] + 1 )
+ : 1
+ ] + $extraParams
+ );
+ }
+
+ return $jobs;
+ }
+}
diff --git a/www/wiki/includes/jobqueue/utils/PurgeJobUtils.php b/www/wiki/includes/jobqueue/utils/PurgeJobUtils.php
new file mode 100644
index 00000000..ba80c8e4
--- /dev/null
+++ b/www/wiki/includes/jobqueue/utils/PurgeJobUtils.php
@@ -0,0 +1,81 @@
+<?php
+/**
+ * Base code for update jobs that put some secondary data extracted
+ * from article content into the database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\MediaWikiServices;
+
+class PurgeJobUtils {
+ /**
+ * Invalidate the cache of a list of pages from a single namespace.
+ * This is intended for use by subclasses.
+ *
+ * @param IDatabase $dbw
+ * @param int $namespace Namespace number
+ * @param array $dbkeys
+ */
+ public static function invalidatePages( IDatabase $dbw, $namespace, array $dbkeys ) {
+ if ( $dbkeys === [] ) {
+ return;
+ }
+
+ $dbw->onTransactionIdle(
+ function () use ( $dbw, $namespace, $dbkeys ) {
+ $services = MediaWikiServices::getInstance();
+ $lbFactory = $services->getDBLoadBalancerFactory();
+ // Determine which pages need to be updated.
+ // This is necessary to prevent the job queue from smashing the DB with
+ // large numbers of concurrent invalidations of the same page.
+ $now = $dbw->timestamp();
+ $ids = $dbw->selectFieldValues(
+ 'page',
+ 'page_id',
+ [
+ 'page_namespace' => $namespace,
+ 'page_title' => $dbkeys,
+ 'page_touched < ' . $dbw->addQuotes( $now )
+ ],
+ __METHOD__
+ );
+
+ if ( !$ids ) {
+ return;
+ }
+
+ $batchSize = $services->getMainConfig()->get( 'UpdateRowsPerQuery' );
+ $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+ foreach ( array_chunk( $ids, $batchSize ) as $idBatch ) {
+ $dbw->update(
+ 'page',
+ [ 'page_touched' => $now ],
+ [
+ 'page_id' => $idBatch,
+ 'page_touched < ' . $dbw->addQuotes( $now ) // handle races
+ ],
+ __METHOD__
+ );
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
+ }
+ },
+ __METHOD__
+ );
+ }
+}
diff --git a/www/wiki/includes/json/FormatJson.php b/www/wiki/includes/json/FormatJson.php
new file mode 100644
index 00000000..0c77a7bc
--- /dev/null
+++ b/www/wiki/includes/json/FormatJson.php
@@ -0,0 +1,338 @@
+<?php
+/**
+ * Wrapper for json_encode and json_decode.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * JSON formatter wrapper class
+ */
+class FormatJson {
+ /**
+ * Skip escaping most characters above U+007F for readability and compactness.
+ * This encoding option saves 3 to 8 bytes (uncompressed) for each such character;
+ * however, it could break compatibility with systems that incorrectly handle UTF-8.
+ *
+ * @since 1.22
+ */
+ const UTF8_OK = 1;
+
+ /**
+ * Skip escaping the characters '<', '>', and '&', which have special meanings in
+ * HTML and XML.
+ *
+ * @warning Do not use this option for JSON that could end up in inline scripts.
+ * - HTML5, §4.3.1.2 Restrictions for contents of script elements
+ * - XML 1.0 (5th Ed.), §2.4 Character Data and Markup
+ *
+ * @since 1.22
+ */
+ const XMLMETA_OK = 2;
+
+ /**
+ * Skip escaping as many characters as reasonably possible.
+ *
+ * @warning When generating inline script blocks, use FormatJson::UTF8_OK instead.
+ *
+ * @since 1.22
+ */
+ const ALL_OK = 3;
+
+ /**
+ * If set, treat json objects '{...}' as associative arrays. Without this option,
+ * json objects will be converted to stdClass.
+ * The value is set to 1 to be backward compatible with 'true' that was used before.
+ *
+ * @since 1.24
+ */
+ const FORCE_ASSOC = 0x100;
+
+ /**
+ * If set, attempts to fix invalid json.
+ *
+ * @since 1.24
+ */
+ const TRY_FIXING = 0x200;
+
+ /**
+ * If set, strip comments from input before parsing as JSON.
+ *
+ * @since 1.25
+ */
+ const STRIP_COMMENTS = 0x400;
+
+ /**
+ * Regex that matches whitespace inside empty arrays and objects.
+ *
+ * This doesn't affect regular strings inside the JSON because those can't
+ * have a real line break (\n) in them, at this point they are already escaped
+ * as the string "\n" which this doesn't match.
+ *
+ * @private
+ */
+ const WS_CLEANUP_REGEX = '/(?<=[\[{])\n\s*+(?=[\]}])/';
+
+ /**
+ * Characters problematic in JavaScript.
+ *
+ * @note These are listed in ECMA-262 (5.1 Ed.), §7.3 Line Terminators along with U+000A (LF)
+ * and U+000D (CR). However, PHP already escapes LF and CR according to RFC 4627.
+ */
+ private static $badChars = [
+ "\xe2\x80\xa8", // U+2028 LINE SEPARATOR
+ "\xe2\x80\xa9", // U+2029 PARAGRAPH SEPARATOR
+ ];
+
+ /**
+ * Escape sequences for characters listed in FormatJson::$badChars.
+ */
+ private static $badCharsEscaped = [
+ '\u2028', // U+2028 LINE SEPARATOR
+ '\u2029', // U+2029 PARAGRAPH SEPARATOR
+ ];
+
+ /**
+ * Returns the JSON representation of a value.
+ *
+ * @note Empty arrays are encoded as numeric arrays, not as objects, so cast any associative
+ * array that might be empty to an object before encoding it.
+ *
+ * @note In pre-1.22 versions of MediaWiki, using this function for generating inline script
+ * blocks may result in an XSS vulnerability, and quite likely will in XML documents
+ * (cf. FormatJson::XMLMETA_OK). Use Xml::encodeJsVar() instead in such cases.
+ *
+ * @param mixed $value The value to encode. Can be any type except a resource.
+ * @param string|bool $pretty If a string, add non-significant whitespace to improve
+ * readability, using that string for indentation. If true, use the default indent
+ * string (four spaces).
+ * @param int $escaping Bitfield consisting of _OK class constants
+ * @return string|false String if successful; false upon failure
+ */
+ public static function encode( $value, $pretty = false, $escaping = 0 ) {
+ if ( !is_string( $pretty ) ) {
+ $pretty = $pretty ? ' ' : false;
+ }
+
+ static $bug66021;
+ if ( $pretty !== false && $bug66021 === null ) {
+ $bug66021 = json_encode( [], JSON_PRETTY_PRINT ) !== '[]';
+ }
+
+ // PHP escapes '/' to prevent breaking out of inline script blocks using '</script>',
+ // which is hardly useful when '<' and '>' are escaped (and inadequate), and such
+ // escaping negatively impacts the human readability of URLs and similar strings.
+ $options = JSON_UNESCAPED_SLASHES;
+ $options |= $pretty !== false ? JSON_PRETTY_PRINT : 0;
+ $options |= ( $escaping & self::UTF8_OK ) ? JSON_UNESCAPED_UNICODE : 0;
+ $options |= ( $escaping & self::XMLMETA_OK ) ? 0 : ( JSON_HEX_TAG | JSON_HEX_AMP );
+ $json = json_encode( $value, $options );
+ if ( $json === false ) {
+ return false;
+ }
+
+ if ( $pretty !== false ) {
+ // Workaround for <https://bugs.php.net/bug.php?id=66021>
+ if ( $bug66021 ) {
+ $json = preg_replace( self::WS_CLEANUP_REGEX, '', $json );
+ }
+ if ( $pretty !== ' ' ) {
+ // Change the four-space indent to a tab indent
+ $json = str_replace( "\n ", "\n\t", $json );
+ while ( strpos( $json, "\t " ) !== false ) {
+ $json = str_replace( "\t ", "\t\t", $json );
+ }
+
+ if ( $pretty !== "\t" ) {
+ // Change the tab indent to the provided indent
+ $json = str_replace( "\t", $pretty, $json );
+ }
+ }
+ }
+ if ( $escaping & self::UTF8_OK ) {
+ $json = str_replace( self::$badChars, self::$badCharsEscaped, $json );
+ }
+
+ return $json;
+ }
+
+ /**
+ * Decodes a JSON string. It is recommended to use FormatJson::parse(),
+ * which returns more comprehensive result in case of an error, and has
+ * more parsing options.
+ *
+ * @param string $value The JSON string being decoded
+ * @param bool $assoc When true, returned objects will be converted into associative arrays.
+ *
+ * @return mixed The value encoded in JSON in appropriate PHP type.
+ * `null` is returned if $value represented `null`, if $value could not be decoded,
+ * or if the encoded data was deeper than the recursion limit.
+ * Use FormatJson::parse() to distinguish between types of `null` and to get proper error code.
+ */
+ public static function decode( $value, $assoc = false ) {
+ return json_decode( $value, $assoc );
+ }
+
+ /**
+ * Decodes a JSON string.
+ * Unlike FormatJson::decode(), if $value represents null value, it will be
+ * properly decoded as valid.
+ *
+ * @param string $value The JSON string being decoded
+ * @param int $options A bit field that allows FORCE_ASSOC, TRY_FIXING,
+ * STRIP_COMMENTS
+ * @return Status If valid JSON, the value is available in $result->getValue()
+ */
+ public static function parse( $value, $options = 0 ) {
+ if ( $options & self::STRIP_COMMENTS ) {
+ $value = self::stripComments( $value );
+ }
+ $assoc = ( $options & self::FORCE_ASSOC ) !== 0;
+ $result = json_decode( $value, $assoc );
+ $code = json_last_error();
+
+ if ( $code === JSON_ERROR_SYNTAX && ( $options & self::TRY_FIXING ) !== 0 ) {
+ // The most common error is the trailing comma in a list or an object.
+ // We cannot simply replace /,\s*[}\]]/ because it could be inside a string value.
+ // But we could use the fact that JSON does not allow multi-line string values,
+ // And remove trailing commas if they are et the end of a line.
+ // JSON only allows 4 control characters: [ \t\r\n]. So we must not use '\s' for matching.
+ // Regex match ,]<any non-quote chars>\n or ,\n] with optional spaces/tabs.
+ $count = 0;
+ $value =
+ preg_replace( '/,([ \t]*[}\]][^"\r\n]*([\r\n]|$)|[ \t]*[\r\n][ \t\r\n]*[}\]])/', '$1',
+ $value, -1, $count );
+ if ( $count > 0 ) {
+ $result = json_decode( $value, $assoc );
+ if ( JSON_ERROR_NONE === json_last_error() ) {
+ // Report warning
+ $st = Status::newGood( $result );
+ $st->warning( wfMessage( 'json-warn-trailing-comma' )->numParams( $count ) );
+ return $st;
+ }
+ }
+ }
+
+ switch ( $code ) {
+ case JSON_ERROR_NONE:
+ return Status::newGood( $result );
+ default:
+ return Status::newFatal( wfMessage( 'json-error-unknown' )->numParams( $code ) );
+ case JSON_ERROR_DEPTH:
+ $msg = 'json-error-depth';
+ break;
+ case JSON_ERROR_STATE_MISMATCH:
+ $msg = 'json-error-state-mismatch';
+ break;
+ case JSON_ERROR_CTRL_CHAR:
+ $msg = 'json-error-ctrl-char';
+ break;
+ case JSON_ERROR_SYNTAX:
+ $msg = 'json-error-syntax';
+ break;
+ case JSON_ERROR_UTF8:
+ $msg = 'json-error-utf8';
+ break;
+ case JSON_ERROR_RECURSION:
+ $msg = 'json-error-recursion';
+ break;
+ case JSON_ERROR_INF_OR_NAN:
+ $msg = 'json-error-inf-or-nan';
+ break;
+ case JSON_ERROR_UNSUPPORTED_TYPE:
+ $msg = 'json-error-unsupported-type';
+ break;
+ }
+ return Status::newFatal( $msg );
+ }
+
+ /**
+ * Remove multiline and single line comments from an otherwise valid JSON
+ * input string. This can be used as a preprocessor for to allow JSON
+ * formatted configuration files to contain comments.
+ *
+ * @param string $json
+ * @return string JSON with comments removed
+ */
+ public static function stripComments( $json ) {
+ // Ensure we have a string
+ $str = (string)$json;
+ $buffer = '';
+ $maxLen = strlen( $str );
+ $mark = 0;
+
+ $inString = false;
+ $inComment = false;
+ $multiline = false;
+
+ for ( $idx = 0; $idx < $maxLen; $idx++ ) {
+ switch ( $str[$idx] ) {
+ case '"':
+ $lookBehind = ( $idx - 1 >= 0 ) ? $str[$idx - 1] : '';
+ if ( !$inComment && $lookBehind !== '\\' ) {
+ // Either started or ended a string
+ $inString = !$inString;
+ }
+ break;
+
+ case '/':
+ $lookAhead = ( $idx + 1 < $maxLen ) ? $str[$idx + 1] : '';
+ $lookBehind = ( $idx - 1 >= 0 ) ? $str[$idx - 1] : '';
+ if ( $inString ) {
+ continue;
+
+ } elseif ( !$inComment &&
+ ( $lookAhead === '/' || $lookAhead === '*' )
+ ) {
+ // Transition into a comment
+ // Add characters seen to buffer
+ $buffer .= substr( $str, $mark, $idx - $mark );
+ // Consume the look ahead character
+ $idx++;
+ // Track state
+ $inComment = true;
+ $multiline = $lookAhead === '*';
+
+ } elseif ( $multiline && $lookBehind === '*' ) {
+ // Found the end of the current comment
+ $mark = $idx + 1;
+ $inComment = false;
+ $multiline = false;
+ }
+ break;
+
+ case "\n":
+ if ( $inComment && !$multiline ) {
+ // Found the end of the current comment
+ $mark = $idx + 1;
+ $inComment = false;
+ }
+ break;
+ }
+ }
+ if ( $inComment ) {
+ // Comment ends with input
+ // Technically we should check to ensure that we aren't in
+ // a multiline comment that hasn't been properly ended, but this
+ // is a strip filter, not a validating parser.
+ $mark = $maxLen;
+ }
+ // Add final chunk to buffer before returning
+ return $buffer . substr( $str, $mark, $maxLen - $mark );
+ }
+}
diff --git a/www/wiki/includes/libs/APACHE-LICENSE-2.0.txt b/www/wiki/includes/libs/APACHE-LICENSE-2.0.txt
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/www/wiki/includes/libs/APACHE-LICENSE-2.0.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/www/wiki/includes/libs/ArrayUtils.php b/www/wiki/includes/libs/ArrayUtils.php
new file mode 100644
index 00000000..0413ea0d
--- /dev/null
+++ b/www/wiki/includes/libs/ArrayUtils.php
@@ -0,0 +1,187 @@
+<?php
+/**
+ * Methods to play with arrays.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A collection of static methods to play with arrays.
+ *
+ * @since 1.21
+ */
+class ArrayUtils {
+ /**
+ * Sort the given array in a pseudo-random order which depends only on the
+ * given key and each element value in $array. This is typically used for load
+ * balancing between servers each with a local cache.
+ *
+ * Keys are preserved. The input array is modified in place.
+ *
+ * Note: Benchmarking on PHP 5.3 and 5.4 indicates that for small
+ * strings, md5() is only 10% slower than hash('joaat',...) etc.,
+ * since the function call overhead dominates. So there's not much
+ * justification for breaking compatibility with installations
+ * compiled with ./configure --disable-hash.
+ *
+ * @param array &$array Array to sort
+ * @param string $key
+ * @param string $separator A separator used to delimit the array elements and the
+ * key. This can be chosen to provide backwards compatibility with
+ * various consistent hash implementations that existed before this
+ * function was introduced.
+ */
+ public static function consistentHashSort( &$array, $key, $separator = "\000" ) {
+ $hashes = [];
+ foreach ( $array as $elt ) {
+ $hashes[$elt] = md5( $elt . $separator . $key );
+ }
+ uasort( $array, function ( $a, $b ) use ( $hashes ) {
+ return strcmp( $hashes[$a], $hashes[$b] );
+ } );
+ }
+
+ /**
+ * Given an array of non-normalised probabilities, this function will select
+ * an element and return the appropriate key
+ *
+ * @param array $weights
+ * @return bool|int|string
+ */
+ public static function pickRandom( $weights ) {
+ if ( !is_array( $weights ) || count( $weights ) == 0 ) {
+ return false;
+ }
+
+ $sum = array_sum( $weights );
+ if ( $sum == 0 ) {
+ # No loads on any of them
+ # In previous versions, this triggered an unweighted random selection,
+ # but this feature has been removed as of April 2006 to allow for strict
+ # separation of query groups.
+ return false;
+ }
+ $max = mt_getrandmax();
+ $rand = mt_rand( 0, $max ) / $max * $sum;
+
+ $sum = 0;
+ foreach ( $weights as $i => $w ) {
+ $sum += $w;
+ # Do not return keys if they have 0 weight.
+ # Note that the "all 0 weight" case is handed above
+ if ( $w > 0 && $sum >= $rand ) {
+ break;
+ }
+ }
+
+ return $i;
+ }
+
+ /**
+ * Do a binary search, and return the index of the largest item that sorts
+ * less than or equal to the target value.
+ *
+ * @since 1.23
+ *
+ * @param callable $valueCallback A function to call to get the value with
+ * a given array index.
+ * @param int $valueCount The number of items accessible via $valueCallback,
+ * indexed from 0 to $valueCount - 1
+ * @param callable $comparisonCallback A callback to compare two values, returning
+ * -1, 0 or 1 in the style of strcmp().
+ * @param string $target The target value to find.
+ *
+ * @return int|bool The item index of the lower bound, or false if the target value
+ * sorts before all items.
+ */
+ public static function findLowerBound( $valueCallback, $valueCount,
+ $comparisonCallback, $target
+ ) {
+ if ( $valueCount === 0 ) {
+ return false;
+ }
+
+ $min = 0;
+ $max = $valueCount;
+ do {
+ $mid = $min + ( ( $max - $min ) >> 1 );
+ $item = call_user_func( $valueCallback, $mid );
+ $comparison = call_user_func( $comparisonCallback, $target, $item );
+ if ( $comparison > 0 ) {
+ $min = $mid;
+ } elseif ( $comparison == 0 ) {
+ $min = $mid;
+ break;
+ } else {
+ $max = $mid;
+ }
+ } while ( $min < $max - 1 );
+
+ if ( $min == 0 ) {
+ $item = call_user_func( $valueCallback, $min );
+ $comparison = call_user_func( $comparisonCallback, $target, $item );
+ if ( $comparison < 0 ) {
+ // Before the first item
+ return false;
+ }
+ }
+ return $min;
+ }
+
+ /**
+ * Do array_diff_assoc() on multi-dimensional arrays.
+ *
+ * Note: empty arrays are removed.
+ *
+ * @since 1.23
+ *
+ * @param array $array1 The array to compare from
+ * @param array $array2,... More arrays to compare against
+ * @return array An array containing all the values from array1
+ * that are not present in any of the other arrays.
+ */
+ public static function arrayDiffAssocRecursive( $array1 ) {
+ $arrays = func_get_args();
+ array_shift( $arrays );
+ $ret = [];
+
+ foreach ( $array1 as $key => $value ) {
+ if ( is_array( $value ) ) {
+ $args = [ $value ];
+ foreach ( $arrays as $array ) {
+ if ( isset( $array[$key] ) ) {
+ $args[] = $array[$key];
+ }
+ }
+ $valueret = call_user_func_array( __METHOD__, $args );
+ if ( count( $valueret ) ) {
+ $ret[$key] = $valueret;
+ }
+ } else {
+ foreach ( $arrays as $array ) {
+ if ( isset( $array[$key] ) && $array[$key] === $value ) {
+ continue 2;
+ }
+ }
+ $ret[$key] = $value;
+ }
+ }
+
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/libs/CSSMin.php b/www/wiki/includes/libs/CSSMin.php
new file mode 100644
index 00000000..ee88d0d2
--- /dev/null
+++ b/www/wiki/includes/libs/CSSMin.php
@@ -0,0 +1,539 @@
+<?php
+/**
+ * Minification of CSS stylesheets.
+ *
+ * Copyright 2010 Wikimedia Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed
+ * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
+ * OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ *
+ * @file
+ * @version 0.1.1 -- 2010-09-11
+ * @author Trevor Parscal <tparscal@wikimedia.org>
+ * @copyright Copyright 2010 Wikimedia Foundation
+ * @license http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+/**
+ * Transforms CSS data
+ *
+ * This class provides minification, URL remapping, URL extracting, and data-URL embedding.
+ */
+class CSSMin {
+
+ /* Constants */
+
+ /** @var string Strip marker for comments. **/
+ const PLACEHOLDER = "\x7fPLACEHOLDER\x7f";
+
+ /**
+ * Internet Explorer data URI length limit. See encodeImageAsDataURI().
+ */
+ const DATA_URI_SIZE_LIMIT = 32768;
+
+ const EMBED_REGEX = '\/\*\s*\@embed\s*\*\/';
+ const COMMENT_REGEX = '\/\*.*?\*\/';
+
+ /* Protected Static Members */
+
+ /** @var array List of common image files extensions and MIME-types */
+ protected static $mimeTypes = [
+ 'gif' => 'image/gif',
+ 'jpe' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'jpg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'tif' => 'image/tiff',
+ 'tiff' => 'image/tiff',
+ 'xbm' => 'image/x-xbitmap',
+ 'svg' => 'image/svg+xml',
+ ];
+
+ /* Static Methods */
+
+ /**
+ * Get a list of local files referenced in a stylesheet (includes non-existent files).
+ *
+ * @param string $source CSS stylesheet source to process
+ * @param string $path File path where the source was read from
+ * @return array List of local file references
+ */
+ public static function getLocalFileReferences( $source, $path ) {
+ $stripped = preg_replace( '/' . self::COMMENT_REGEX . '/s', '', $source );
+ $path = rtrim( $path, '/' ) . '/';
+ $files = [];
+
+ $rFlags = PREG_OFFSET_CAPTURE | PREG_SET_ORDER;
+ if ( preg_match_all( '/' . self::getUrlRegex() . '/', $stripped, $matches, $rFlags ) ) {
+ foreach ( $matches as $match ) {
+ self::processUrlMatch( $match, $rFlags );
+ $url = $match['file'][0];
+
+ // Skip fully-qualified and protocol-relative URLs and data URIs
+ // Also skips the rare `behavior` property specifying application's default behavior
+ if (
+ substr( $url, 0, 2 ) === '//' ||
+ parse_url( $url, PHP_URL_SCHEME ) ||
+ substr( $url, 0, 9 ) === '#default#'
+ ) {
+ break;
+ }
+
+ $files[] = $path . $url;
+ }
+ }
+ return $files;
+ }
+
+ /**
+ * Encode an image file as a data URI.
+ *
+ * If the image file has a suitable MIME type and size, encode it as a data URI, base64-encoded
+ * for binary files or just percent-encoded otherwise. Return false if the image type is
+ * unfamiliar or file exceeds the size limit.
+ *
+ * @param string $file Image file to encode.
+ * @param string|null $type File's MIME type or null. If null, CSSMin will
+ * try to autodetect the type.
+ * @param bool $ie8Compat By default, a data URI will only be produced if it can be made short
+ * enough to fit in Internet Explorer 8 (and earlier) URI length limit (32,768 bytes). Pass
+ * `false` to remove this limitation.
+ * @return string|bool Image contents encoded as a data URI or false.
+ */
+ public static function encodeImageAsDataURI( $file, $type = null, $ie8Compat = true ) {
+ // Fast-fail for files that definitely exceed the maximum data URI length
+ if ( $ie8Compat && filesize( $file ) >= self::DATA_URI_SIZE_LIMIT ) {
+ return false;
+ }
+
+ if ( $type === null ) {
+ $type = self::getMimeType( $file );
+ }
+ if ( !$type ) {
+ return false;
+ }
+
+ return self::encodeStringAsDataURI( file_get_contents( $file ), $type, $ie8Compat );
+ }
+
+ /**
+ * Encode file contents as a data URI with chosen MIME type.
+ *
+ * The URI will be base64-encoded for binary files or just percent-encoded otherwise.
+ *
+ * @since 1.25
+ *
+ * @param string $contents File contents to encode.
+ * @param string $type File's MIME type.
+ * @param bool $ie8Compat See encodeImageAsDataURI().
+ * @return string|bool Image contents encoded as a data URI or false.
+ */
+ public static function encodeStringAsDataURI( $contents, $type, $ie8Compat = true ) {
+ // Try #1: Non-encoded data URI
+ // The regular expression matches ASCII whitespace and printable characters.
+ if ( preg_match( '/^[\r\n\t\x20-\x7e]+$/', $contents ) ) {
+ // Do not base64-encode non-binary files (sane SVGs).
+ // (This often produces longer URLs, but they compress better, yielding a net smaller size.)
+ $encoded = rawurlencode( $contents );
+ // Unencode some things that don't need to be encoded, to make the encoding smaller
+ $encoded = strtr( $encoded, [
+ '%20' => ' ', // Unencode spaces
+ '%2F' => '/', // Unencode slashes
+ '%3A' => ':', // Unencode colons
+ '%3D' => '=', // Unencode equals signs
+ ] );
+ $uri = 'data:' . $type . ',' . $encoded;
+ if ( !$ie8Compat || strlen( $uri ) < self::DATA_URI_SIZE_LIMIT ) {
+ return $uri;
+ }
+ }
+
+ // Try #2: Encoded data URI
+ $uri = 'data:' . $type . ';base64,' . base64_encode( $contents );
+ if ( !$ie8Compat || strlen( $uri ) < self::DATA_URI_SIZE_LIMIT ) {
+ return $uri;
+ }
+
+ // A data URI couldn't be produced
+ return false;
+ }
+
+ /**
+ * Serialize a string (escape and quote) for use as a CSS string value.
+ * http://www.w3.org/TR/2013/WD-cssom-20131205/#serialize-a-string
+ *
+ * @param string $value
+ * @return string
+ * @throws Exception
+ */
+ public static function serializeStringValue( $value ) {
+ if ( strstr( $value, "\0" ) ) {
+ throw new Exception( "Invalid character in CSS string" );
+ }
+ $value = strtr( $value, [ '\\' => '\\\\', '"' => '\\"' ] );
+ $value = preg_replace_callback( '/[\x01-\x1f\x7f-\x9f]/', function ( $match ) {
+ return '\\' . base_convert( ord( $match[0] ), 10, 16 ) . ' ';
+ }, $value );
+ return '"' . $value . '"';
+ }
+
+ /**
+ * @param string $file
+ * @return bool|string
+ */
+ public static function getMimeType( $file ) {
+ // Infer the MIME-type from the file extension
+ $ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) );
+ if ( isset( self::$mimeTypes[$ext] ) ) {
+ return self::$mimeTypes[$ext];
+ }
+
+ return mime_content_type( realpath( $file ) );
+ }
+
+ /**
+ * Build a CSS 'url()' value for the given URL, quoting parentheses (and other funny characters)
+ * and escaping quotes as necessary.
+ *
+ * See http://www.w3.org/TR/css-syntax-3/#consume-a-url-token
+ *
+ * @param string $url URL to process
+ * @return string 'url()' value, usually just `"url($url)"`, quoted/escaped if necessary
+ */
+ public static function buildUrlValue( $url ) {
+ // The list below has been crafted to match URLs such as:
+ // scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s
+ // data:image/png;base64,R0lGODlh/+==
+ if ( preg_match( '!^[\w\d:@/~.%+;,?&=-]+$!', $url ) ) {
+ return "url($url)";
+ } else {
+ return 'url("' . strtr( $url, [ '\\' => '\\\\', '"' => '\\"' ] ) . '")';
+ }
+ }
+
+ /**
+ * Remaps CSS URL paths and automatically embeds data URIs for CSS rules
+ * or url() values preceded by an / * @embed * / comment.
+ *
+ * @param string $source CSS data to remap
+ * @param string $local File path where the source was read from
+ * @param string $remote URL path to the file
+ * @param bool $embedData If false, never do any data URI embedding,
+ * even if / * @embed * / is found.
+ * @return string Remapped CSS data
+ */
+ public static function remap( $source, $local, $remote, $embedData = true ) {
+ // High-level overview:
+ // * For each CSS rule in $source that includes at least one url() value:
+ // * Check for an @embed comment at the start indicating that all URIs should be embedded
+ // * For each url() value:
+ // * Check for an @embed comment directly preceding the value
+ // * If either @embed comment exists:
+ // * Embedding the URL as data: URI, if it's possible / allowed
+ // * Otherwise remap the URL to work in generated stylesheets
+
+ // Guard against trailing slashes, because "some/remote/../foo.png"
+ // resolves to "some/remote/foo.png" on (some?) clients (T29052).
+ if ( substr( $remote, -1 ) == '/' ) {
+ $remote = substr( $remote, 0, -1 );
+ }
+
+ // Disallow U+007F DELETE, which is illegal anyway, and which
+ // we use for comment placeholders.
+ $source = str_replace( "\x7f", "?", $source );
+
+ // Replace all comments by a placeholder so they will not interfere with the remapping.
+ // Warning: This will also catch on anything looking like the start of a comment between
+ // quotation marks (e.g. "foo /* bar").
+ $comments = [];
+
+ $pattern = '/(?!' . self::EMBED_REGEX . ')(' . self::COMMENT_REGEX . ')/s';
+
+ $source = preg_replace_callback(
+ $pattern,
+ function ( $match ) use ( &$comments ) {
+ $comments[] = $match[ 0 ];
+ return CSSMin::PLACEHOLDER . ( count( $comments ) - 1 ) . 'x';
+ },
+ $source
+ );
+
+ // Note: This will not correctly handle cases where ';', '{' or '}'
+ // appears in the rule itself, e.g. in a quoted string. You are advised
+ // not to use such characters in file names. We also match start/end of
+ // the string to be consistent in edge-cases ('@import url(…)').
+ $pattern = '/(?:^|[;{])\K[^;{}]*' . self::getUrlRegex() . '[^;}]*(?=[;}]|$)/';
+
+ $source = preg_replace_callback(
+ $pattern,
+ function ( $matchOuter ) use ( $local, $remote, $embedData ) {
+ $rule = $matchOuter[0];
+
+ // Check for global @embed comment and remove it. Allow other comments to be present
+ // before @embed (they have been replaced with placeholders at this point).
+ $embedAll = false;
+ $rule = preg_replace(
+ '/^((?:\s+|' .
+ CSSMin::PLACEHOLDER .
+ '(\d+)x)*)' .
+ CSSMin::EMBED_REGEX .
+ '\s*/',
+ '$1',
+ $rule,
+ 1,
+ $embedAll
+ );
+
+ // Build two versions of current rule: with remapped URLs
+ // and with embedded data: URIs (where possible).
+ $pattern = '/(?P<embed>' . CSSMin::EMBED_REGEX . '\s*|)' . self::getUrlRegex() . '/';
+
+ $ruleWithRemapped = preg_replace_callback(
+ $pattern,
+ function ( $match ) use ( $local, $remote ) {
+ self::processUrlMatch( $match );
+
+ $remapped = CSSMin::remapOne( $match['file'], $match['query'], $local, $remote, false );
+ return CSSMin::buildUrlValue( $remapped );
+ },
+ $rule
+ );
+
+ if ( $embedData ) {
+ // Remember the occurring MIME types to avoid fallbacks when embedding some files.
+ $mimeTypes = [];
+
+ $ruleWithEmbedded = preg_replace_callback(
+ $pattern,
+ function ( $match ) use ( $embedAll, $local, $remote, &$mimeTypes ) {
+ self::processUrlMatch( $match );
+
+ $embed = $embedAll || $match['embed'];
+ $embedded = CSSMin::remapOne(
+ $match['file'],
+ $match['query'],
+ $local,
+ $remote,
+ $embed
+ );
+
+ $url = $match['file'] . $match['query'];
+ $file = "{$local}/{$match['file']}";
+ if (
+ !self::isRemoteUrl( $url ) && !self::isLocalUrl( $url )
+ && file_exists( $file )
+ ) {
+ $mimeTypes[ CSSMin::getMimeType( $file ) ] = true;
+ }
+
+ return CSSMin::buildUrlValue( $embedded );
+ },
+ $rule
+ );
+
+ // Are all referenced images SVGs?
+ $needsEmbedFallback = $mimeTypes !== [ 'image/svg+xml' => true ];
+ }
+
+ if ( !$embedData || $ruleWithEmbedded === $ruleWithRemapped ) {
+ // We're not embedding anything, or we tried to but the file is not embeddable
+ return $ruleWithRemapped;
+ } elseif ( $embedData && $needsEmbedFallback ) {
+ // Build 2 CSS properties; one which uses a data URI in place of the @embed comment, and
+ // the other with a remapped and versioned URL with an Internet Explorer 6 and 7 hack
+ // making it ignored in all browsers that support data URIs
+ return "$ruleWithEmbedded;$ruleWithRemapped!ie";
+ } else {
+ // Look ma, no fallbacks! This is for files which IE 6 and 7 don't support anyway: SVG.
+ return $ruleWithEmbedded;
+ }
+ }, $source );
+
+ // Re-insert comments
+ $pattern = '/' . self::PLACEHOLDER . '(\d+)x/';
+ $source = preg_replace_callback( $pattern, function ( $match ) use ( &$comments ) {
+ return $comments[ $match[1] ];
+ }, $source );
+
+ return $source;
+ }
+
+ /**
+ * Is this CSS rule referencing a remote URL?
+ *
+ * @param string $maybeUrl
+ * @return bool
+ */
+ protected static function isRemoteUrl( $maybeUrl ) {
+ if ( substr( $maybeUrl, 0, 2 ) === '//' || parse_url( $maybeUrl, PHP_URL_SCHEME ) ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Is this CSS rule referencing a local URL?
+ *
+ * @param string $maybeUrl
+ * @return bool
+ */
+ protected static function isLocalUrl( $maybeUrl ) {
+ if ( $maybeUrl !== '' && $maybeUrl[0] === '/' && !self::isRemoteUrl( $maybeUrl ) ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * @codeCoverageIgnore
+ */
+ private static function getUrlRegex() {
+ static $urlRegex;
+ if ( $urlRegex === null ) {
+ // Match these three variants separately to avoid broken urls when
+ // e.g. a double quoted url contains a parenthesis, or when a
+ // single quoted url contains a double quote, etc.
+ // Note: PCRE doesn't support multiple capture groups with the same name by default.
+ // - PCRE 6.7 introduced the "J" modifier (PCRE_INFO_JCHANGED for PCRE_DUPNAMES).
+ // https://secure.php.net/manual/en/reference.pcre.pattern.modifiers.php
+ // However this isn't useful since it just ignores all but the first one.
+ // Also, while the modifier was introduced in PCRE 6.7 (PHP 5.2+) it was
+ // not exposed to public preg_* functions until PHP 5.6.0.
+ // - PCRE 8.36 fixed this to work as expected (e.g. merge conceptually to
+ // only return the one matched in the part that actually matched).
+ // However MediaWiki supports 5.5.9, which has PCRE 8.32
+ // Per https://secure.php.net/manual/en/pcre.installation.php:
+ // - PCRE 8.32 (PHP 5.5.0)
+ // - PCRE 8.34 (PHP 5.5.10, PHP 5.6.0)
+ // - PCRE 8.37 (PHP 5.5.26, PHP 5.6.9, PHP 7.0.0)
+ // Workaround by using different groups and merge via processUrlMatch().
+ // - Using string concatenation for class constant or member assignments
+ // is only supported in PHP 5.6. Use a getter method for now.
+ $urlRegex = '(' .
+ // Unquoted url
+ 'url\(\s*(?P<file0>[^\'"][^\?\)]*?)(?P<query0>\?[^\)]*?|)\s*\)' .
+ // Single quoted url
+ '|url\(\s*\'(?P<file1>[^\?\']*?)(?P<query1>\?[^\']*?|)\'\s*\)' .
+ // Double quoted url
+ '|url\(\s*"(?P<file2>[^\?"]*?)(?P<query2>\?[^"]*?|)"\s*\)' .
+ ')';
+ }
+ return $urlRegex;
+ }
+
+ private static function processUrlMatch( array &$match, $flags = 0 ) {
+ if ( $flags & PREG_SET_ORDER ) {
+ // preg_match_all with PREG_SET_ORDER will return each group in each
+ // match array, and if it didn't match, instead of the sub array
+ // being an empty array it is `[ '', -1 ]`...
+ if ( isset( $match['file0'] ) && $match['file0'][1] !== -1 ) {
+ $match['file'] = $match['file0'];
+ $match['query'] = $match['query0'];
+ } elseif ( isset( $match['file1'] ) && $match['file1'][1] !== -1 ) {
+ $match['file'] = $match['file1'];
+ $match['query'] = $match['query1'];
+ } else {
+ $match['file'] = $match['file2'];
+ $match['query'] = $match['query2'];
+ }
+ } else {
+ if ( isset( $match['file0'] ) && $match['file0'] !== '' ) {
+ $match['file'] = $match['file0'];
+ $match['query'] = $match['query0'];
+ } elseif ( isset( $match['file1'] ) && $match['file1'] !== '' ) {
+ $match['file'] = $match['file1'];
+ $match['query'] = $match['query1'];
+ } else {
+ $match['file'] = $match['file2'];
+ $match['query'] = $match['query2'];
+ }
+ }
+ }
+
+ /**
+ * Remap or embed a CSS URL path.
+ *
+ * @param string $file URL to remap/embed
+ * @param string $query
+ * @param string $local File path where the source was read from
+ * @param string $remote URL path to the file
+ * @param bool $embed Whether to do any data URI embedding
+ * @return string Remapped/embedded URL data
+ */
+ public static function remapOne( $file, $query, $local, $remote, $embed ) {
+ // The full URL possibly with query, as passed to the 'url()' value in CSS
+ $url = $file . $query;
+
+ // Expand local URLs with absolute paths like /w/index.php to possibly protocol-relative URL, if
+ // wfExpandUrl() is available. (This will not be the case if we're running outside of MW.)
+ if ( self::isLocalUrl( $url ) && function_exists( 'wfExpandUrl' ) ) {
+ return wfExpandUrl( $url, PROTO_RELATIVE );
+ }
+
+ // Pass thru fully-qualified and protocol-relative URLs and data URIs, as well as local URLs if
+ // we can't expand them.
+ // Also skips the rare `behavior` property specifying application's default behavior
+ if (
+ self::isRemoteUrl( $url ) ||
+ self::isLocalUrl( $url ) ||
+ substr( $url, 0, 9 ) === '#default#'
+ ) {
+ return $url;
+ }
+
+ if ( $local === false ) {
+ // Assume that all paths are relative to $remote, and make them absolute
+ $url = $remote . '/' . $url;
+ } else {
+ // We drop the query part here and instead make the path relative to $remote
+ $url = "{$remote}/{$file}";
+ // Path to the actual file on the filesystem
+ $localFile = "{$local}/{$file}";
+ if ( file_exists( $localFile ) ) {
+ if ( $embed ) {
+ $data = self::encodeImageAsDataURI( $localFile );
+ if ( $data !== false ) {
+ return $data;
+ }
+ }
+ if ( method_exists( 'OutputPage', 'transformFilePath' ) ) {
+ $url = OutputPage::transformFilePath( $remote, $local, $file );
+ } else {
+ // Add version parameter as the first five hex digits
+ // of the MD5 hash of the file's contents.
+ $url .= '?' . substr( md5_file( $localFile ), 0, 5 );
+ }
+ }
+ // If any of these conditions failed (file missing, we don't want to embed it
+ // or it's not embeddable), return the URL (possibly with ?timestamp part)
+ }
+ if ( function_exists( 'wfRemoveDotSegments' ) ) {
+ $url = wfRemoveDotSegments( $url );
+ }
+ return $url;
+ }
+
+ /**
+ * Removes whitespace from CSS data
+ *
+ * @param string $css CSS data to minify
+ * @return string Minified CSS data
+ */
+ public static function minify( $css ) {
+ return trim(
+ str_replace(
+ [ '; ', ': ', ' {', '{ ', ', ', '} ', ';}' ],
+ [ ';', ':', '{', '{', ',', '}', '}' ],
+ preg_replace( [ '/\s+/', '/\/\*.*?\*\//s' ], [ ' ', '' ], $css )
+ )
+ );
+ }
+}
diff --git a/www/wiki/includes/libs/Cookie.php b/www/wiki/includes/libs/Cookie.php
new file mode 100644
index 00000000..a67b919f
--- /dev/null
+++ b/www/wiki/includes/libs/Cookie.php
@@ -0,0 +1,208 @@
+<?php
+/**
+ * Cookie for HTTP requests.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup HTTP
+ */
+
+class Cookie {
+ protected $name;
+ protected $value;
+ protected $expires;
+ protected $path;
+ protected $domain;
+ protected $isSessionKey = true;
+ // TO IMPLEMENT protected $secure
+ // TO IMPLEMENT? protected $maxAge (add onto expires)
+ // TO IMPLEMENT? protected $version
+ // TO IMPLEMENT? protected $comment
+
+ function __construct( $name, $value, $attr ) {
+ $this->name = $name;
+ $this->set( $value, $attr );
+ }
+
+ /**
+ * Sets a cookie. Used before a request to set up any individual
+ * cookies. Used internally after a request to parse the
+ * Set-Cookie headers.
+ *
+ * @param string $value The value of the cookie
+ * @param array $attr Possible key/values:
+ * expires A date string
+ * path The path this cookie is used on
+ * domain Domain this cookie is used on
+ * @throws InvalidArgumentException
+ */
+ public function set( $value, $attr ) {
+ $this->value = $value;
+
+ if ( isset( $attr['expires'] ) ) {
+ $this->isSessionKey = false;
+ $this->expires = strtotime( $attr['expires'] );
+ }
+
+ if ( isset( $attr['path'] ) ) {
+ $this->path = $attr['path'];
+ } else {
+ $this->path = '/';
+ }
+
+ if ( isset( $attr['domain'] ) ) {
+ if ( self::validateCookieDomain( $attr['domain'] ) ) {
+ $this->domain = $attr['domain'];
+ }
+ } else {
+ throw new InvalidArgumentException( '$attr must contain a domain' );
+ }
+ }
+
+ /**
+ * Return the true if the cookie is valid is valid. Otherwise,
+ * false. The uses a method similar to IE cookie security
+ * described here:
+ * http://kuza55.blogspot.com/2008/02/understanding-cookie-security.html
+ * A better method might be to use a blacklist like
+ * http://publicsuffix.org/
+ *
+ * @todo fixme fails to detect 3-letter top-level domains
+ * @todo fixme fails to detect 2-letter top-level domains for single-domain use (probably
+ * not a big problem in practice, but there are test cases)
+ *
+ * @param string $domain The domain to validate
+ * @param string $originDomain (optional) the domain the cookie originates from
+ * @return bool
+ */
+ public static function validateCookieDomain( $domain, $originDomain = null ) {
+ $dc = explode( ".", $domain );
+
+ // Don't allow a trailing dot or addresses without a or just a leading dot
+ if ( substr( $domain, -1 ) == '.' ||
+ count( $dc ) <= 1 ||
+ count( $dc ) == 2 && $dc[0] === ''
+ ) {
+ return false;
+ }
+
+ // Only allow full, valid IP addresses
+ if ( preg_match( '/^[0-9.]+$/', $domain ) ) {
+ if ( count( $dc ) != 4 ) {
+ return false;
+ }
+
+ if ( ip2long( $domain ) === false ) {
+ return false;
+ }
+
+ if ( $originDomain == null || $originDomain == $domain ) {
+ return true;
+ }
+
+ }
+
+ // Don't allow cookies for "co.uk" or "gov.uk", etc, but allow "supermarket.uk"
+ if ( strrpos( $domain, "." ) - strlen( $domain ) == -3 ) {
+ if ( ( count( $dc ) == 2 && strlen( $dc[0] ) <= 2 )
+ || ( count( $dc ) == 3 && strlen( $dc[0] ) == "" && strlen( $dc[1] ) <= 2 ) ) {
+ return false;
+ }
+ if ( ( count( $dc ) == 2 || ( count( $dc ) == 3 && $dc[0] == '' ) )
+ && preg_match( '/(com|net|org|gov|edu)\...$/', $domain ) ) {
+ return false;
+ }
+ }
+
+ if ( $originDomain != null ) {
+ if ( substr( $domain, 0, 1 ) != '.' && $domain != $originDomain ) {
+ return false;
+ }
+
+ if ( substr( $domain, 0, 1 ) == '.'
+ && substr_compare(
+ $originDomain,
+ $domain,
+ -strlen( $domain ),
+ strlen( $domain ),
+ true
+ ) != 0
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Serialize the cookie jar into a format useful for HTTP Request headers.
+ *
+ * @param string $path The path that will be used. Required.
+ * @param string $domain The domain that will be used. Required.
+ * @return string
+ */
+ public function serializeToHttpRequest( $path, $domain ) {
+ $ret = '';
+
+ if ( $this->canServeDomain( $domain )
+ && $this->canServePath( $path )
+ && $this->isUnExpired() ) {
+ $ret = $this->name . '=' . $this->value;
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $domain
+ * @return bool
+ */
+ protected function canServeDomain( $domain ) {
+ if ( $domain == $this->domain
+ || ( strlen( $domain ) > strlen( $this->domain )
+ && substr( $this->domain, 0, 1 ) == '.'
+ && substr_compare(
+ $domain,
+ $this->domain,
+ -strlen( $this->domain ),
+ strlen( $this->domain ),
+ true
+ ) == 0
+ )
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $path
+ * @return bool
+ */
+ protected function canServePath( $path ) {
+ return ( $this->path && substr_compare( $this->path, $path, 0, strlen( $this->path ) ) == 0 );
+ }
+
+ /**
+ * @return bool
+ */
+ protected function isUnExpired() {
+ return $this->isSessionKey || $this->expires > time();
+ }
+}
diff --git a/www/wiki/includes/libs/CookieJar.php b/www/wiki/includes/libs/CookieJar.php
new file mode 100644
index 00000000..8f5700ab
--- /dev/null
+++ b/www/wiki/includes/libs/CookieJar.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup HTTP
+ */
+
+/**
+ * Cookie jar to use with MWHttpRequest. Does not handle cookie unsetting.
+ */
+class CookieJar {
+ /** @var Cookie[] */
+ private $cookie = [];
+
+ /**
+ * Set a cookie in the cookie jar. Make sure only one cookie per-name exists.
+ * @see Cookie::set()
+ * @param string $name
+ * @param string $value
+ * @param array $attr
+ */
+ public function setCookie( $name, $value, $attr ) {
+ /* cookies: case insensitive, so this should work.
+ * We'll still send the cookies back in the same case we got them, though.
+ */
+ $index = strtoupper( $name );
+
+ if ( isset( $this->cookie[$index] ) ) {
+ $this->cookie[$index]->set( $value, $attr );
+ } else {
+ $this->cookie[$index] = new Cookie( $name, $value, $attr );
+ }
+ }
+
+ /**
+ * @see Cookie::serializeToHttpRequest
+ * @param string $path
+ * @param string $domain
+ * @return string
+ */
+ public function serializeToHttpRequest( $path, $domain ) {
+ $cookies = [];
+
+ foreach ( $this->cookie as $c ) {
+ $serialized = $c->serializeToHttpRequest( $path, $domain );
+
+ if ( $serialized ) {
+ $cookies[] = $serialized;
+ }
+ }
+
+ return implode( '; ', $cookies );
+ }
+
+ /**
+ * Parse the content of an Set-Cookie HTTP Response header.
+ *
+ * @param string $cookie
+ * @param string $domain Cookie's domain
+ * @return null
+ */
+ public function parseCookieResponseHeader( $cookie, $domain ) {
+ $len = strlen( 'Set-Cookie:' );
+
+ if ( substr_compare( 'Set-Cookie:', $cookie, 0, $len, true ) === 0 ) {
+ $cookie = substr( $cookie, $len );
+ }
+
+ $bit = array_map( 'trim', explode( ';', $cookie ) );
+
+ if ( count( $bit ) >= 1 ) {
+ list( $name, $value ) = explode( '=', array_shift( $bit ), 2 );
+ $attr = [];
+
+ foreach ( $bit as $piece ) {
+ $parts = explode( '=', $piece );
+ if ( count( $parts ) > 1 ) {
+ $attr[strtolower( $parts[0] )] = $parts[1];
+ } else {
+ $attr[strtolower( $parts[0] )] = true;
+ }
+ }
+
+ if ( !isset( $attr['domain'] ) ) {
+ $attr['domain'] = $domain;
+ } elseif ( !Cookie::validateCookieDomain( $attr['domain'], $domain ) ) {
+ return null;
+ }
+
+ $this->setCookie( $name, $value, $attr );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/CryptHKDF.php b/www/wiki/includes/libs/CryptHKDF.php
new file mode 100644
index 00000000..6b3e4a7a
--- /dev/null
+++ b/www/wiki/includes/libs/CryptHKDF.php
@@ -0,0 +1,282 @@
+<?php
+/**
+ * Extract-and-Expand Key Derivation Function (HKDF). A cryptographicly
+ * secure key expansion function based on RFC 5869.
+ *
+ * This relies on the secrecy of $wgSecretKey (by default), or $wgHKDFSecret.
+ * By default, sha256 is used as the underlying hashing algorithm, but any other
+ * algorithm can be used. Finding the secret key from the output would require
+ * an attacker to discover the input key (the PRK) to the hmac that generated
+ * the output, and discover the particular data, hmac'ed with an evolving key
+ * (salt), to produce the PRK. Even with md5, no publicly known attacks make
+ * this currently feasible.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @author Chris Steipp
+ * @file
+ */
+
+class CryptHKDF {
+
+ /**
+ * @var BagOStuff The persistent cache
+ */
+ protected $cache = null;
+
+ /**
+ * @var string Cache key we'll use for our salt
+ */
+ protected $cacheKey = null;
+
+ /**
+ * @var string The hash algorithm being used
+ */
+ protected $algorithm = null;
+
+ /**
+ * @var string binary string, the salt for the HKDF
+ * @see getSaltUsingCache
+ */
+ protected $salt = '';
+
+ /**
+ * @var string The pseudorandom key
+ */
+ private $prk = '';
+
+ /**
+ * The secret key material. This must be kept secret to preserve
+ * the security properties of this RNG.
+ *
+ * @var string
+ */
+ private $skm;
+
+ /**
+ * @var string The last block (K(i)) of the most recent expanded key
+ */
+ protected $lastK;
+
+ /**
+ * a "context information" string CTXinfo (which may be null)
+ * See http://eprint.iacr.org/2010/264.pdf Section 4.1
+ *
+ * @var array
+ */
+ protected $context = [];
+
+ /**
+ * Round count is computed based on the hash'es output length,
+ * which neither php nor openssl seem to provide easily.
+ *
+ * @var int[]
+ */
+ public static $hashLength = [
+ 'md5' => 16,
+ 'sha1' => 20,
+ 'sha224' => 28,
+ 'sha256' => 32,
+ 'sha384' => 48,
+ 'sha512' => 64,
+ 'ripemd128' => 16,
+ 'ripemd160' => 20,
+ 'ripemd256' => 32,
+ 'ripemd320' => 40,
+ 'whirlpool' => 64,
+ ];
+
+ /**
+ * @var CryptRand
+ */
+ private $cryptRand;
+
+ /**
+ * @param string $secretKeyMaterial
+ * @param string $algorithm Name of hashing algorithm
+ * @param BagOStuff $cache
+ * @param string|array $context Context to mix into HKDF context
+ * @param CryptRand $cryptRand
+ * @throws InvalidArgumentException if secret key material is too short
+ */
+ public function __construct( $secretKeyMaterial, $algorithm, BagOStuff $cache, $context,
+ CryptRand $cryptRand
+ ) {
+ if ( strlen( $secretKeyMaterial ) < 16 ) {
+ throw new InvalidArgumentException( "secret was too short." );
+ }
+ $this->skm = $secretKeyMaterial;
+ $this->algorithm = $algorithm;
+ $this->cache = $cache;
+ $this->context = is_array( $context ) ? $context : [ $context ];
+ $this->cryptRand = $cryptRand;
+
+ // To prevent every call from hitting the same memcache server, pick
+ // from a set of keys to use. mt_rand is only use to pick a random
+ // server, and does not affect the security of the process.
+ $this->cacheKey = $cache->makeKey( 'HKDF', mt_rand( 0, 16 ) );
+ }
+
+ /**
+ * Save the last block generated, so the next user will compute a different PRK
+ * from the same SKM. This should keep things unpredictable even if an attacker
+ * is able to influence CTXinfo.
+ */
+ function __destruct() {
+ if ( $this->lastK ) {
+ $this->cache->set( $this->cacheKey, $this->lastK );
+ }
+ }
+
+ /**
+ * MW specific salt, cached from last run
+ * @return string Binary string
+ */
+ protected function getSaltUsingCache() {
+ if ( $this->salt == '' ) {
+ $lastSalt = $this->cache->get( $this->cacheKey );
+ if ( $lastSalt === false ) {
+ // If we don't have a previous value to use as our salt, we use
+ // 16 bytes from CryptRand, which will use a small amount of
+ // entropy from our pool. Note, "XTR may be deterministic or keyed
+ // via an optional “salt value” (i.e., a non-secret random
+ // value)..." - http://eprint.iacr.org/2010/264.pdf. However, we
+ // use a strongly random value since we can.
+ $lastSalt = $this->cryptRand->generate( 16 );
+ }
+ // Get a binary string that is hashLen long
+ $this->salt = hash( $this->algorithm, $lastSalt, true );
+ }
+ return $this->salt;
+ }
+
+ /**
+ * Produce $bytes of secure random data. As a side-effect,
+ * $this->lastK is set to the last hashLen block of key material.
+ *
+ * @param int $bytes Number of bytes of data
+ * @param string $context Context to mix into CTXinfo
+ * @return string Binary string of length $bytes
+ */
+ public function generate( $bytes, $context = '' ) {
+ if ( $this->prk === '' ) {
+ $salt = $this->getSaltUsingCache();
+ $this->prk = self::HKDFExtract(
+ $this->algorithm,
+ $salt,
+ $this->skm
+ );
+ }
+
+ $CTXinfo = implode( ':', array_merge( $this->context, [ $context ] ) );
+
+ return self::HKDFExpand(
+ $this->algorithm,
+ $this->prk,
+ $CTXinfo,
+ $bytes,
+ $this->lastK
+ );
+ }
+
+ /**
+ * RFC5869 defines HKDF in 2 steps, extraction and expansion.
+ * From http://eprint.iacr.org/2010/264.pdf:
+ *
+ * The scheme HKDF is specifed as:
+ * HKDF(XTS, SKM, CTXinfo, L) = K(1) || K(2) || ... || K(t)
+ * where the values K(i) are defined as follows:
+ * PRK = HMAC(XTS, SKM)
+ * K(1) = HMAC(PRK, CTXinfo || 0);
+ * K(i+1) = HMAC(PRK, K(i) || CTXinfo || i), 1 <= i < t;
+ * where t = [L/k] and the value K(t) is truncated to its first d = L mod k bits;
+ * the counter i is non-wrapping and of a given fixed size, e.g., a single byte.
+ * Note that the length of the HMAC output is the same as its key length and therefore
+ * the scheme is well defined.
+ *
+ * XTS is the "extractor salt"
+ * SKM is the "secret keying material"
+ *
+ * N.B. http://eprint.iacr.org/2010/264.pdf seems to differ from RFC 5869 in that the test
+ * vectors from RFC 5869 only work if K(0) = '' and K(1) = HMAC(PRK, K(0) || CTXinfo || 1)
+ *
+ * @param string $hash The hashing function to use (e.g., sha256)
+ * @param string $ikm The input keying material
+ * @param string $salt The salt to add to the ikm, to get the prk
+ * @param string $info Optional context (change the output without affecting
+ * the randomness properties of the output)
+ * @param int $L Number of bytes to return
+ * @return string Cryptographically secure pseudorandom binary string
+ */
+ public static function HKDF( $hash, $ikm, $salt, $info, $L ) {
+ $prk = self::HKDFExtract( $hash, $salt, $ikm );
+ $okm = self::HKDFExpand( $hash, $prk, $info, $L );
+ return $okm;
+ }
+
+ /**
+ * Extract the PRK, PRK = HMAC(XTS, SKM)
+ * Note that the hmac is keyed with XTS (the salt),
+ * and the SKM (source key material) is the "data".
+ *
+ * @param string $hash The hashing function to use (e.g., sha256)
+ * @param string $salt The salt to add to the ikm, to get the prk
+ * @param string $ikm The input keying material
+ * @return string Binary string (pseudorandm key) used as input to HKDFExpand
+ */
+ private static function HKDFExtract( $hash, $salt, $ikm ) {
+ return hash_hmac( $hash, $ikm, $salt, true );
+ }
+
+ /**
+ * Expand the key with the given context
+ *
+ * @param string $hash Hashing Algorithm
+ * @param string $prk A pseudorandom key of at least HashLen octets
+ * (usually, the output from the extract step)
+ * @param string $info Optional context and application specific information
+ * (can be a zero-length string)
+ * @param int $bytes Length of output keying material in bytes
+ * (<= 255*HashLen)
+ * @param string &$lastK Set by this function to the last block of the expansion.
+ * In MediaWiki, this is used to seed future Extractions.
+ * @return string Cryptographically secure random string $bytes long
+ * @throws InvalidArgumentException
+ */
+ private static function HKDFExpand( $hash, $prk, $info, $bytes, &$lastK = '' ) {
+ $hashLen = self::$hashLength[$hash];
+ $rounds = ceil( $bytes / $hashLen );
+ $output = '';
+
+ if ( $bytes > 255 * $hashLen ) {
+ throw new InvalidArgumentException( 'Too many bytes requested from HDKFExpand' );
+ }
+
+ // K(1) = HMAC(PRK, CTXinfo || 1);
+ // K(i) = HMAC(PRK, K(i-1) || CTXinfo || i); 1 < i <= t;
+ for ( $counter = 1; $counter <= $rounds; ++$counter ) {
+ $lastK = hash_hmac(
+ $hash,
+ $lastK . $info . chr( $counter ),
+ $prk,
+ true
+ );
+ $output .= $lastK;
+ }
+
+ return substr( $output, 0, $bytes );
+ }
+}
diff --git a/www/wiki/includes/libs/CryptRand.php b/www/wiki/includes/libs/CryptRand.php
new file mode 100644
index 00000000..859d58b5
--- /dev/null
+++ b/www/wiki/includes/libs/CryptRand.php
@@ -0,0 +1,404 @@
+<?php
+/**
+ * A cryptographic random generator class used for generating secret keys
+ *
+ * This is based in part on Drupal code as well as what we used in our own code
+ * prior to introduction of this class.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @author Daniel Friesen
+ * @file
+ */
+use Psr\Log\LoggerInterface;
+
+class CryptRand {
+ /**
+ * Minimum number of iterations we want to make in our drift calculations.
+ */
+ const MIN_ITERATIONS = 1000;
+
+ /**
+ * Number of milliseconds we want to spend generating each separate byte
+ * of the final generated bytes.
+ * This is used in combination with the hash length to determine the duration
+ * we should spend doing drift calculations.
+ */
+ const MSEC_PER_BYTE = 0.5;
+
+ /**
+ * A boolean indicating whether the previous random generation was done using
+ * cryptographically strong random number generator or not.
+ */
+ protected $strong = null;
+
+ /**
+ * List of functions to call to generate some random state
+ *
+ * @var callable[]
+ */
+ protected $randomFuncs = [];
+
+ /**
+ * List of files to generate some random state from
+ *
+ * @var string[]
+ */
+ protected $randomFiles = [];
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ public function __construct( array $randomFuncs, array $randomFiles, LoggerInterface $logger ) {
+ $this->randomFuncs = $randomFuncs;
+ $this->randomFiles = $randomFiles;
+ $this->logger = $logger;
+ }
+
+ /**
+ * Initialize an initial random state based off of whatever we can find
+ * @return string
+ */
+ protected function initialRandomState() {
+ // $_SERVER contains a variety of unstable user and system specific information
+ // It'll vary a little with each page, and vary even more with separate users
+ // It'll also vary slightly across different machines
+ $state = serialize( $_SERVER );
+
+ // Try to gather a little entropy from the different php rand sources
+ $state .= rand() . uniqid( mt_rand(), true );
+
+ // Include some information about the filesystem's current state in the random state
+ $files = $this->randomFiles;
+
+ // We know this file is here so grab some info about ourselves
+ $files[] = __FILE__;
+
+ // We must also have a parent folder, and with the usual file structure, a grandparent
+ $files[] = __DIR__;
+ $files[] = dirname( __DIR__ );
+
+ foreach ( $files as $file ) {
+ MediaWiki\suppressWarnings();
+ $stat = stat( $file );
+ MediaWiki\restoreWarnings();
+ if ( $stat ) {
+ // stat() duplicates data into numeric and string keys so kill off all the numeric ones
+ foreach ( $stat as $k => $v ) {
+ if ( is_numeric( $k ) ) {
+ unset( $k );
+ }
+ }
+ // The absolute filename itself will differ from install to install so don't leave it out
+ $path = realpath( $file );
+ if ( $path !== false ) {
+ $state .= $path;
+ } else {
+ $state .= $file;
+ }
+ $state .= implode( '', $stat );
+ } else {
+ // The fact that the file isn't there is worth at least a
+ // minuscule amount of entropy.
+ $state .= '0';
+ }
+ }
+
+ // Try and make this a little more unstable by including the varying process
+ // id of the php process we are running inside of if we are able to access it
+ if ( function_exists( 'getmypid' ) ) {
+ $state .= getmypid();
+ }
+
+ // If available try to increase the instability of the data by throwing in
+ // the precise amount of memory that we happen to be using at the moment.
+ if ( function_exists( 'memory_get_usage' ) ) {
+ $state .= memory_get_usage( true );
+ }
+
+ foreach ( $this->randomFuncs as $randomFunc ) {
+ $state .= call_user_func( $randomFunc );
+ }
+
+ return $state;
+ }
+
+ /**
+ * Randomly hash data while mixing in clock drift data for randomness
+ *
+ * @param string $data The data to randomly hash.
+ * @return string The hashed bytes
+ * @author Tim Starling
+ */
+ protected function driftHash( $data ) {
+ // Minimum number of iterations (to avoid slow operations causing the
+ // loop to gather little entropy)
+ $minIterations = self::MIN_ITERATIONS;
+ // Duration of time to spend doing calculations (in seconds)
+ $duration = ( self::MSEC_PER_BYTE / 1000 ) * MWCryptHash::hashLength();
+ // Create a buffer to use to trigger memory operations
+ $bufLength = 10000000;
+ $buffer = str_repeat( ' ', $bufLength );
+ $bufPos = 0;
+
+ // Iterate for $duration seconds or at least $minIterations number of iterations
+ $iterations = 0;
+ $startTime = microtime( true );
+ $currentTime = $startTime;
+ while ( $iterations < $minIterations || $currentTime - $startTime < $duration ) {
+ // Trigger some memory writing to trigger some bus activity
+ // This may create variance in the time between iterations
+ $bufPos = ( $bufPos + 13 ) % $bufLength;
+ $buffer[$bufPos] = ' ';
+ // Add the drift between this iteration and the last in as entropy
+ $nextTime = microtime( true );
+ $delta = (int)( ( $nextTime - $currentTime ) * 1000000 );
+ $data .= $delta;
+ // Every 100 iterations hash the data and entropy
+ if ( $iterations % 100 === 0 ) {
+ $data = sha1( $data );
+ }
+ $currentTime = $nextTime;
+ $iterations++;
+ }
+ $timeTaken = $currentTime - $startTime;
+ $data = MWCryptHash::hash( $data );
+
+ $this->logger->debug( "Clock drift calculation " .
+ "(time-taken=" . ( $timeTaken * 1000 ) . "ms, " .
+ "iterations=$iterations, " .
+ "time-per-iteration=" . ( $timeTaken / $iterations * 1e6 ) . "us)" );
+
+ return $data;
+ }
+
+ /**
+ * Return a rolling random state initially build using data from unstable sources
+ * @return string A new weak random state
+ */
+ protected function randomState() {
+ static $state = null;
+ if ( is_null( $state ) ) {
+ // Initialize the state with whatever unstable data we can find
+ // It's important that this data is hashed right afterwards to prevent
+ // it from being leaked into the output stream
+ $state = MWCryptHash::hash( $this->initialRandomState() );
+ }
+ // Generate a new random state based on the initial random state or previous
+ // random state by combining it with clock drift
+ $state = $this->driftHash( $state );
+
+ return $state;
+ }
+
+ /**
+ * Return a boolean indicating whether or not the source used for cryptographic
+ * random bytes generation in the previously run generate* call
+ * was cryptographically strong.
+ *
+ * @return bool Returns true if the source was strong, false if not.
+ */
+ public function wasStrong() {
+ if ( is_null( $this->strong ) ) {
+ throw new RuntimeException( __METHOD__ . ' called before generation of random data' );
+ }
+
+ return $this->strong;
+ }
+
+ /**
+ * Generate a run of (ideally) cryptographically random data and return
+ * it in raw binary form.
+ * You can use CryptRand::wasStrong() if you wish to know if the source used
+ * was cryptographically strong.
+ *
+ * @param int $bytes The number of bytes of random data to generate
+ * @param bool $forceStrong Pass true if you want generate to prefer cryptographically
+ * strong sources of entropy even if reading from them may steal
+ * more entropy from the system than optimal.
+ * @return string Raw binary random data
+ */
+ public function generate( $bytes, $forceStrong = false ) {
+ $bytes = floor( $bytes );
+ static $buffer = '';
+ if ( is_null( $this->strong ) ) {
+ // Set strength to false initially until we know what source data is coming from
+ $this->strong = true;
+ }
+
+ if ( strlen( $buffer ) < $bytes ) {
+ // If available make use of PHP 7's random_bytes
+ // On Linux, getrandom syscall will be used if available.
+ // On Windows CryptGenRandom will always be used
+ // On other platforms, /dev/urandom will be used.
+ // Avoids polyfills from before php 7.0
+ // All error situations will throw Exceptions and or Errors
+ if ( PHP_VERSION_ID >= 70000
+ || ( defined( 'HHVM_VERSION_ID' ) && HHVM_VERSION_ID >= 31101 )
+ ) {
+ $rem = $bytes - strlen( $buffer );
+ $buffer .= random_bytes( $rem );
+ }
+ if ( strlen( $buffer ) >= $bytes ) {
+ $this->strong = true;
+ }
+ }
+
+ if ( strlen( $buffer ) < $bytes ) {
+ // If available make use of mcrypt_create_iv URANDOM source to generate randomness
+ // On unix-like systems this reads from /dev/urandom but does it without any buffering
+ // and bypasses openbasedir restrictions, so it's preferable to reading directly
+ // On Windows starting in PHP 5.3.0 Windows' native CryptGenRandom is used to generate
+ // entropy so this is also preferable to just trying to read urandom because it may work
+ // on Windows systems as well.
+ if ( function_exists( 'mcrypt_create_iv' ) ) {
+ $rem = $bytes - strlen( $buffer );
+ $iv = mcrypt_create_iv( $rem, MCRYPT_DEV_URANDOM );
+ if ( $iv === false ) {
+ $this->logger->debug( "mcrypt_create_iv returned false." );
+ } else {
+ $buffer .= $iv;
+ $this->logger->debug( "mcrypt_create_iv generated " . strlen( $iv ) .
+ " bytes of randomness." );
+ }
+ }
+ }
+
+ if ( strlen( $buffer ) < $bytes ) {
+ if ( function_exists( 'openssl_random_pseudo_bytes' ) ) {
+ $rem = $bytes - strlen( $buffer );
+ $openssl_bytes = openssl_random_pseudo_bytes( $rem, $openssl_strong );
+ if ( $openssl_bytes === false ) {
+ $this->logger->debug( "openssl_random_pseudo_bytes returned false." );
+ } else {
+ $buffer .= $openssl_bytes;
+ $this->logger->debug( "openssl_random_pseudo_bytes generated " .
+ strlen( $openssl_bytes ) . " bytes of " .
+ ( $openssl_strong ? "strong" : "weak" ) . " randomness." );
+ }
+ if ( strlen( $buffer ) >= $bytes ) {
+ // openssl tells us if the random source was strong, if some of our data was generated
+ // using it use it's say on whether the randomness is strong
+ $this->strong = !!$openssl_strong;
+ }
+ }
+ }
+
+ // Only read from urandom if we can control the buffer size or were passed forceStrong
+ if ( strlen( $buffer ) < $bytes &&
+ ( function_exists( 'stream_set_read_buffer' ) || $forceStrong )
+ ) {
+ $rem = $bytes - strlen( $buffer );
+ if ( !function_exists( 'stream_set_read_buffer' ) && $forceStrong ) {
+ $this->logger->debug( "Was forced to read from /dev/urandom " .
+ "without control over the buffer size." );
+ }
+ // /dev/urandom is generally considered the best possible commonly
+ // available random source, and is available on most *nix systems.
+ MediaWiki\suppressWarnings();
+ $urandom = fopen( "/dev/urandom", "rb" );
+ MediaWiki\restoreWarnings();
+
+ // Attempt to read all our random data from urandom
+ // php's fread always does buffered reads based on the stream's chunk_size
+ // so in reality it will usually read more than the amount of data we're
+ // asked for and not storing that risks depleting the system's random pool.
+ // If stream_set_read_buffer is available set the chunk_size to the amount
+ // of data we need. Otherwise read 8k, php's default chunk_size.
+ if ( $urandom ) {
+ // php's default chunk_size is 8k
+ $chunk_size = 1024 * 8;
+ if ( function_exists( 'stream_set_read_buffer' ) ) {
+ // If possible set the chunk_size to the amount of data we need
+ stream_set_read_buffer( $urandom, $rem );
+ $chunk_size = $rem;
+ }
+ $random_bytes = fread( $urandom, max( $chunk_size, $rem ) );
+ $buffer .= $random_bytes;
+ fclose( $urandom );
+ $this->logger->debug( "/dev/urandom generated " . strlen( $random_bytes ) .
+ " bytes of randomness." );
+
+ if ( strlen( $buffer ) >= $bytes ) {
+ // urandom is always strong, set to true if all our data was generated using it
+ $this->strong = true;
+ }
+ } else {
+ $this->logger->debug( "/dev/urandom could not be opened." );
+ }
+ }
+
+ // If we cannot use or generate enough data from a secure source
+ // use this loop to generate a good set of pseudo random data.
+ // This works by initializing a random state using a pile of unstable data
+ // and continually shoving it through a hash along with a variable salt.
+ // We hash the random state with more salt to avoid the state from leaking
+ // out and being used to predict the /randomness/ that follows.
+ if ( strlen( $buffer ) < $bytes ) {
+ $this->logger->debug( __METHOD__ .
+ ": Falling back to using a pseudo random state to generate randomness." );
+ }
+ while ( strlen( $buffer ) < $bytes ) {
+ $buffer .= MWCryptHash::hmac( $this->randomState(), strval( mt_rand() ) );
+ // This code is never really cryptographically strong, if we use it
+ // at all, then set strong to false.
+ $this->strong = false;
+ }
+
+ // Once the buffer has been filled up with enough random data to fulfill
+ // the request shift off enough data to handle the request and leave the
+ // unused portion left inside the buffer for the next request for random data
+ $generated = substr( $buffer, 0, $bytes );
+ $buffer = substr( $buffer, $bytes );
+
+ $this->logger->debug( strlen( $buffer ) .
+ " bytes of randomness leftover in the buffer." );
+
+ return $generated;
+ }
+
+ /**
+ * Generate a run of (ideally) cryptographically random data and return
+ * it in hexadecimal string format.
+ * You can use CryptRand::wasStrong() if you wish to know if the source used
+ * was cryptographically strong.
+ *
+ * @param int $chars The number of hex chars of random data to generate
+ * @param bool $forceStrong Pass true if you want generate to prefer cryptographically
+ * strong sources of entropy even if reading from them may steal
+ * more entropy from the system than optimal.
+ * @return string Hexadecimal random data
+ */
+ public function generateHex( $chars, $forceStrong = false ) {
+ // hex strings are 2x the length of raw binary so we divide the length in half
+ // odd numbers will result in a .5 that leads the generate() being 1 character
+ // short, so we use ceil() to ensure that we always have enough bytes
+ $bytes = ceil( $chars / 2 );
+ // Generate the data and then convert it to a hex string
+ $hex = bin2hex( $this->generate( $bytes, $forceStrong ) );
+
+ // A bit of paranoia here, the caller asked for a specific length of string
+ // here, and it's possible (eg when given an odd number) that we may actually
+ // have at least 1 char more than they asked for. Just in case they made this
+ // call intending to insert it into a database that does truncation we don't
+ // want to give them too much and end up with their database and their live
+ // code having two different values because part of what we gave them is truncated
+ // hence, we strip out any run of characters longer than what we were asked for.
+ return substr( $hex, 0, $chars );
+ }
+}
diff --git a/www/wiki/includes/libs/DeferredStringifier.php b/www/wiki/includes/libs/DeferredStringifier.php
new file mode 100644
index 00000000..a6fd11a4
--- /dev/null
+++ b/www/wiki/includes/libs/DeferredStringifier.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Class that defers a slow string generation until the string is actually needed.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @since 1.25
+ */
+class DeferredStringifier {
+ /** @var callable Callback used for result string generation */
+ private $callback;
+
+ /** @var array */
+ private $params;
+
+ /** @var string */
+ private $result;
+
+ /**
+ * @param callable $callback Callback that gets called by __toString
+ * @param mixed $param,... Parameters to the callback
+ */
+ public function __construct( $callback /*...*/ ) {
+ $this->params = func_get_args();
+ array_shift( $this->params );
+ $this->callback = $callback;
+ }
+
+ /**
+ * Get the string generated from the callback
+ *
+ * @return string
+ */
+ public function __toString() {
+ if ( $this->result === null ) {
+ $this->result = call_user_func_array( $this->callback, $this->params );
+ }
+ return $this->result;
+ }
+}
diff --git a/www/wiki/includes/libs/DnsSrvDiscoverer.php b/www/wiki/includes/libs/DnsSrvDiscoverer.php
new file mode 100644
index 00000000..ce8a2044
--- /dev/null
+++ b/www/wiki/includes/libs/DnsSrvDiscoverer.php
@@ -0,0 +1,108 @@
+<?php
+/**
+ * Service discovery using DNS SRV records
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @since 1.29
+ */
+class DnsSrvDiscoverer {
+ /**
+ * @var string
+ */
+ private $domain;
+
+ /**
+ * @param string $domain
+ */
+ public function __construct( $domain ) {
+ $this->domain = $domain;
+ }
+
+ /**
+ * Fetch the servers with a DNS SRV request
+ *
+ * @return array
+ */
+ public function getServers() {
+ $result = [];
+ foreach ( $this->getDnsRecords() as $record ) {
+ $result[] = [
+ 'target' => $record['target'],
+ 'port' => $record['port'],
+ 'pri' => $record['pri'],
+ 'weight' => $record['weight'],
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Pick a server according to the priority fields.
+ * Note that weight is currently ignored.
+ *
+ * @param array $servers from getServers
+ * @return array|bool
+ */
+ public function pickServer( array $servers ) {
+ if ( !$servers ) {
+ return false;
+ }
+
+ $srvsByPrio = [];
+ foreach ( $servers as $server ) {
+ $srvsByPrio[$server['pri']][] = $server;
+ }
+
+ $min = min( array_keys( $srvsByPrio ) );
+ if ( count( $srvsByPrio[$min] ) == 1 ) {
+ return $srvsByPrio[$min][0];
+ } else {
+ // Choose randomly
+ $rand = mt_rand( 0, count( $srvsByPrio[$min] ) - 1 );
+
+ return $srvsByPrio[$min][$rand];
+ }
+ }
+
+ /**
+ * @param array $server
+ * @param array $servers
+ * @return array[]
+ */
+ public function removeServer( $server, array $servers ) {
+ foreach ( $servers as $i => $srv ) {
+ if ( $srv['target'] === $server['target'] && $srv['port'] === $server['port'] ) {
+ unset( $servers[$i] );
+ break;
+ }
+ }
+
+ return array_values( $servers );
+ }
+
+ /**
+ * @return array[]
+ */
+ protected function getDnsRecords() {
+ return dns_get_record( $this->domain, DNS_SRV );
+ }
+}
diff --git a/www/wiki/includes/libs/ExplodeIterator.php b/www/wiki/includes/libs/ExplodeIterator.php
new file mode 100644
index 00000000..d4abdc86
--- /dev/null
+++ b/www/wiki/includes/libs/ExplodeIterator.php
@@ -0,0 +1,116 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * An iterator which works exactly like:
+ *
+ * foreach ( explode( $delim, $s ) as $element ) {
+ * ...
+ * }
+ *
+ * Except it doesn't use 193 byte per element
+ */
+class ExplodeIterator implements Iterator {
+ // The subject string
+ private $subject, $subjectLength;
+
+ // The delimiter
+ private $delim, $delimLength;
+
+ // The position of the start of the line
+ private $curPos;
+
+ // The position after the end of the next delimiter
+ private $endPos;
+
+ /** @var string|false The current token */
+ private $current;
+
+ /**
+ * Construct a DelimIterator
+ * @param string $delim
+ * @param string $subject
+ */
+ public function __construct( $delim, $subject ) {
+ $this->subject = $subject;
+ $this->delim = $delim;
+
+ // Micro-optimisation (theoretical)
+ $this->subjectLength = strlen( $subject );
+ $this->delimLength = strlen( $delim );
+
+ $this->rewind();
+ }
+
+ public function rewind() {
+ $this->curPos = 0;
+ $this->endPos = strpos( $this->subject, $this->delim );
+ $this->refreshCurrent();
+ }
+
+ public function refreshCurrent() {
+ if ( $this->curPos === false ) {
+ $this->current = false;
+ } elseif ( $this->curPos >= $this->subjectLength ) {
+ $this->current = '';
+ } elseif ( $this->endPos === false ) {
+ $this->current = substr( $this->subject, $this->curPos );
+ } else {
+ $this->current = substr( $this->subject, $this->curPos, $this->endPos - $this->curPos );
+ }
+ }
+
+ public function current() {
+ return $this->current;
+ }
+
+ /**
+ * @return int|bool Current position or boolean false if invalid
+ */
+ public function key() {
+ return $this->curPos;
+ }
+
+ /**
+ * @return string
+ */
+ public function next() {
+ if ( $this->endPos === false ) {
+ $this->curPos = false;
+ } else {
+ $this->curPos = $this->endPos + $this->delimLength;
+ if ( $this->curPos >= $this->subjectLength ) {
+ $this->endPos = false;
+ } else {
+ $this->endPos = strpos( $this->subject, $this->delim, $this->curPos );
+ }
+ }
+ $this->refreshCurrent();
+
+ return $this->current;
+ }
+
+ /**
+ * @return bool
+ */
+ public function valid() {
+ return $this->curPos !== false;
+ }
+}
diff --git a/www/wiki/includes/libs/GenericArrayObject.php b/www/wiki/includes/libs/GenericArrayObject.php
new file mode 100644
index 00000000..79d13741
--- /dev/null
+++ b/www/wiki/includes/libs/GenericArrayObject.php
@@ -0,0 +1,239 @@
+<?php
+
+/**
+ * Extends ArrayObject and does two things:
+ *
+ * Allows for deriving classes to easily intercept additions
+ * and deletions for purposes such as additional indexing.
+ *
+ * Enforces the objects to be of a certain type, so this
+ * can be replied upon, much like if this had true support
+ * for generics, which sadly enough is not possible in PHP.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.20
+ *
+ * @file
+ *
+ * @license GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+abstract class GenericArrayObject extends ArrayObject {
+ /**
+ * Returns the name of an interface/class that the element should implement/extend.
+ *
+ * @since 1.20
+ *
+ * @return string
+ */
+ abstract public function getObjectType();
+
+ /**
+ * @see SiteList::getNewOffset()
+ * @since 1.20
+ * @var int
+ */
+ protected $indexOffset = 0;
+
+ /**
+ * Finds a new offset for when appending an element.
+ * The base class does this, so it would be better to integrate,
+ * but there does not appear to be any way to do this...
+ *
+ * @since 1.20
+ *
+ * @return int
+ */
+ protected function getNewOffset() {
+ while ( $this->offsetExists( $this->indexOffset ) ) {
+ $this->indexOffset++;
+ }
+
+ return $this->indexOffset;
+ }
+
+ /**
+ * @see ArrayObject::__construct
+ *
+ * @since 1.20
+ *
+ * @param null|array $input
+ * @param int $flags
+ * @param string $iterator_class
+ */
+ public function __construct( $input = null, $flags = 0, $iterator_class = 'ArrayIterator' ) {
+ parent::__construct( [], $flags, $iterator_class );
+
+ if ( !is_null( $input ) ) {
+ foreach ( $input as $offset => $value ) {
+ $this->offsetSet( $offset, $value );
+ }
+ }
+ }
+
+ /**
+ * @see ArrayObject::append
+ *
+ * @since 1.20
+ *
+ * @param mixed $value
+ */
+ public function append( $value ) {
+ $this->setElement( null, $value );
+ }
+
+ /**
+ * @see ArrayObject::offsetSet()
+ *
+ * @since 1.20
+ *
+ * @param mixed $index
+ * @param mixed $value
+ */
+ public function offsetSet( $index, $value ) {
+ $this->setElement( $index, $value );
+ }
+
+ /**
+ * Returns if the provided value has the same type as the elements
+ * that can be added to this ArrayObject.
+ *
+ * @since 1.20
+ *
+ * @param mixed $value
+ *
+ * @return bool
+ */
+ protected function hasValidType( $value ) {
+ $class = $this->getObjectType();
+ return $value instanceof $class;
+ }
+
+ /**
+ * Method that actually sets the element and holds
+ * all common code needed for set operations, including
+ * type checking and offset resolving.
+ *
+ * If you want to do additional indexing or have code that
+ * otherwise needs to be executed whenever an element is added,
+ * you can overload @see preSetElement.
+ *
+ * @since 1.20
+ *
+ * @param mixed $index
+ * @param mixed $value
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function setElement( $index, $value ) {
+ if ( !$this->hasValidType( $value ) ) {
+ throw new InvalidArgumentException(
+ 'Can only add ' . $this->getObjectType() . ' implementing objects to '
+ . static::class . '.'
+ );
+ }
+
+ if ( is_null( $index ) ) {
+ $index = $this->getNewOffset();
+ }
+
+ if ( $this->preSetElement( $index, $value ) ) {
+ parent::offsetSet( $index, $value );
+ }
+ }
+
+ /**
+ * Gets called before a new element is added to the ArrayObject.
+ *
+ * At this point the index is always set (ie not null) and the
+ * value is always of the type returned by @see getObjectType.
+ *
+ * Should return a boolean. When false is returned the element
+ * does not get added to the ArrayObject.
+ *
+ * @since 1.20
+ *
+ * @param int|string $index
+ * @param mixed $value
+ *
+ * @return bool
+ */
+ protected function preSetElement( $index, $value ) {
+ return true;
+ }
+
+ /**
+ * @see Serializable::serialize
+ *
+ * @since 1.20
+ *
+ * @return string
+ */
+ public function serialize() {
+ return serialize( $this->getSerializationData() );
+ }
+
+ /**
+ * Returns an array holding all the data that should go into serialization calls.
+ * This is intended to allow overloading without having to reimplement the
+ * behavior of this base class.
+ *
+ * @since 1.20
+ *
+ * @return array
+ */
+ protected function getSerializationData() {
+ return [
+ 'data' => $this->getArrayCopy(),
+ 'index' => $this->indexOffset,
+ ];
+ }
+
+ /**
+ * @see Serializable::unserialize
+ *
+ * @since 1.20
+ *
+ * @param string $serialization
+ *
+ * @return array
+ */
+ public function unserialize( $serialization ) {
+ $serializationData = unserialize( $serialization );
+
+ foreach ( $serializationData['data'] as $offset => $value ) {
+ // Just set the element, bypassing checks and offset resolving,
+ // as these elements have already gone through this.
+ parent::offsetSet( $offset, $value );
+ }
+
+ $this->indexOffset = $serializationData['index'];
+
+ return $serializationData;
+ }
+
+ /**
+ * Returns if the ArrayObject has no elements.
+ *
+ * @since 1.20
+ *
+ * @return bool
+ */
+ public function isEmpty() {
+ return $this->count() === 0;
+ }
+}
diff --git a/www/wiki/includes/libs/HashRing.php b/www/wiki/includes/libs/HashRing.php
new file mode 100644
index 00000000..be40965e
--- /dev/null
+++ b/www/wiki/includes/libs/HashRing.php
@@ -0,0 +1,238 @@
+<?php
+/**
+ * Convenience class for weighted consistent hash rings.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Convenience class for weighted consistent hash rings
+ *
+ * @since 1.22
+ */
+class HashRing {
+ /** @var Array (location => weight) */
+ protected $sourceMap = [];
+ /** @var Array (location => (start, end)) */
+ protected $ring = [];
+
+ /** @var HashRing|null */
+ protected $liveRing;
+ /** @var Array (location => UNIX timestamp) */
+ protected $ejectionExpiries = [];
+ /** @var int UNIX timestamp */
+ protected $ejectionNextExpiry = INF;
+
+ const RING_SIZE = 268435456; // 2^28
+
+ /**
+ * @param array $map (location => weight)
+ */
+ public function __construct( array $map ) {
+ $map = array_filter( $map, function ( $w ) {
+ return $w > 0;
+ } );
+ if ( !count( $map ) ) {
+ throw new UnexpectedValueException( "Ring is empty or all weights are zero." );
+ }
+ $this->sourceMap = $map;
+ // Sort the locations based on the hash of their names
+ $hashes = [];
+ foreach ( $map as $location => $weight ) {
+ $hashes[$location] = sha1( $location );
+ }
+ uksort( $map, function ( $a, $b ) use ( $hashes ) {
+ return strcmp( $hashes[$a], $hashes[$b] );
+ } );
+ // Fit the map to weight-proportionate one with a space of size RING_SIZE
+ $sum = array_sum( $map );
+ $standardMap = [];
+ foreach ( $map as $location => $weight ) {
+ $standardMap[$location] = (int)floor( $weight / $sum * self::RING_SIZE );
+ }
+ // Build a ring of RING_SIZE spots, with each location at a spot in location hash order
+ $index = 0;
+ foreach ( $standardMap as $location => $weight ) {
+ // Location covers half-closed interval [$index,$index + $weight)
+ $this->ring[$location] = [ $index, $index + $weight ];
+ $index += $weight;
+ }
+ // Make sure the last location covers what is left
+ end( $this->ring );
+ $this->ring[key( $this->ring )][1] = self::RING_SIZE;
+ }
+
+ /**
+ * Get the location of an item on the ring
+ *
+ * @param string $item
+ * @return string Location
+ */
+ public function getLocation( $item ) {
+ $locations = $this->getLocations( $item, 1 );
+
+ return $locations[0];
+ }
+
+ /**
+ * Get the location of an item on the ring, as well as the next locations
+ *
+ * @param string $item
+ * @param int $limit Maximum number of locations to return
+ * @return array List of locations
+ */
+ public function getLocations( $item, $limit ) {
+ $locations = [];
+ $primaryLocation = null;
+ $spot = hexdec( substr( sha1( $item ), 0, 7 ) ); // first 28 bits
+ foreach ( $this->ring as $location => $range ) {
+ if ( count( $locations ) >= $limit ) {
+ break;
+ }
+ // The $primaryLocation is the location the item spot is in.
+ // After that is reached, keep appending the next locations.
+ if ( ( $range[0] <= $spot && $spot < $range[1] ) || $primaryLocation !== null ) {
+ if ( $primaryLocation === null ) {
+ $primaryLocation = $location;
+ }
+ $locations[] = $location;
+ }
+ }
+ // If more locations are requested, wrap-around and keep adding them
+ reset( $this->ring );
+ while ( count( $locations ) < $limit ) {
+ list( $location, ) = each( $this->ring );
+ if ( $location === $primaryLocation ) {
+ break; // don't go in circles
+ }
+ $locations[] = $location;
+ }
+
+ return $locations;
+ }
+
+ /**
+ * Get the map of locations to weight (ignores 0-weight items)
+ *
+ * @return array
+ */
+ public function getLocationWeights() {
+ return $this->sourceMap;
+ }
+
+ /**
+ * Get a new hash ring with a location removed from the ring
+ *
+ * @param string $location
+ * @return HashRing|bool Returns false if no non-zero weighted spots are left
+ */
+ public function newWithoutLocation( $location ) {
+ $map = $this->sourceMap;
+ unset( $map[$location] );
+
+ return count( $map ) ? new self( $map ) : false;
+ }
+
+ /**
+ * Remove a location from the "live" hash ring
+ *
+ * @param string $location
+ * @param int $ttl Seconds
+ * @return bool Whether some non-ejected locations are left
+ */
+ public function ejectFromLiveRing( $location, $ttl ) {
+ if ( !isset( $this->sourceMap[$location] ) ) {
+ throw new UnexpectedValueException( "No location '$location' in the ring." );
+ }
+ $expiry = time() + $ttl;
+ $this->liveRing = null; // stale
+ $this->ejectionExpiries[$location] = $expiry;
+ $this->ejectionNextExpiry = min( $expiry, $this->ejectionNextExpiry );
+
+ return ( count( $this->ejectionExpiries ) < count( $this->sourceMap ) );
+ }
+
+ /**
+ * Get the "live" hash ring (which does not include ejected locations)
+ *
+ * @return HashRing
+ * @throws UnexpectedValueException
+ */
+ public function getLiveRing() {
+ $now = time();
+ if ( $this->liveRing === null || $this->ejectionNextExpiry <= $now ) {
+ $this->ejectionExpiries = array_filter(
+ $this->ejectionExpiries,
+ function ( $expiry ) use ( $now ) {
+ return ( $expiry > $now );
+ }
+ );
+ if ( count( $this->ejectionExpiries ) ) {
+ $map = array_diff_key( $this->sourceMap, $this->ejectionExpiries );
+ $this->liveRing = count( $map ) ? new self( $map ) : false;
+
+ $this->ejectionNextExpiry = min( $this->ejectionExpiries );
+ } else { // common case; avoid recalculating ring
+ $this->liveRing = clone $this;
+ $this->liveRing->ejectionExpiries = [];
+ $this->liveRing->ejectionNextExpiry = INF;
+ $this->liveRing->liveRing = null;
+
+ $this->ejectionNextExpiry = INF;
+ }
+ }
+ if ( !$this->liveRing ) {
+ throw new UnexpectedValueException( "The live ring is currently empty." );
+ }
+
+ return $this->liveRing;
+ }
+
+ /**
+ * Get the location of an item on the "live" ring
+ *
+ * @param string $item
+ * @return string Location
+ * @throws UnexpectedValueException
+ */
+ public function getLiveLocation( $item ) {
+ return $this->getLiveRing()->getLocation( $item );
+ }
+
+ /**
+ * Get the location of an item on the "live" ring, as well as the next locations
+ *
+ * @param string $item
+ * @param int $limit Maximum number of locations to return
+ * @return array List of locations
+ * @throws UnexpectedValueException
+ */
+ public function getLiveLocations( $item, $limit ) {
+ return $this->getLiveRing()->getLocations( $item, $limit );
+ }
+
+ /**
+ * Get the map of "live" locations to weight (ignores 0-weight items)
+ *
+ * @return array
+ * @throws UnexpectedValueException
+ */
+ public function getLiveLocationWeights() {
+ return $this->getLiveRing()->getLocationWeights();
+ }
+}
diff --git a/www/wiki/includes/libs/HtmlArmor.php b/www/wiki/includes/libs/HtmlArmor.php
new file mode 100644
index 00000000..1c141ab0
--- /dev/null
+++ b/www/wiki/includes/libs/HtmlArmor.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL-2.0+
+ * @author Kunal Mehta <legoktm@member.fsf.org>
+ */
+
+/**
+ * Marks HTML that shouldn't be escaped
+ *
+ * @since 1.28
+ */
+class HtmlArmor {
+
+ /**
+ * @var string|null
+ */
+ private $value;
+
+ /**
+ * @param string|null $value
+ */
+ public function __construct( $value ) {
+ $this->value = $value;
+ }
+
+ /**
+ * Provide a string or HtmlArmor object
+ * and get safe HTML back
+ *
+ * @param string|HtmlArmor $input
+ * @return string|null safe for usage in HTML, or null
+ * if the HtmlArmor instance was wrapping null.
+ */
+ public static function getHtml( $input ) {
+ if ( $input instanceof HtmlArmor ) {
+ return $input->value;
+ } else {
+ return htmlspecialchars( $input, ENT_QUOTES );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/HttpStatus.php b/www/wiki/includes/libs/HttpStatus.php
new file mode 100644
index 00000000..7e652162
--- /dev/null
+++ b/www/wiki/includes/libs/HttpStatus.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * List of HTTP status codes.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @todo document
+ */
+class HttpStatus {
+
+ /**
+ * Get the message associated with an HTTP response status code
+ *
+ * @param int $code Status code
+ * @return string|null Message, or null if $code is not known
+ */
+ public static function getMessage( $code ) {
+ static $statusMessage = [
+ 100 => 'Continue',
+ 101 => 'Switching Protocols',
+ 102 => 'Processing',
+ 200 => 'OK',
+ 201 => 'Created',
+ 202 => 'Accepted',
+ 203 => 'Non-Authoritative Information',
+ 204 => 'No Content',
+ 205 => 'Reset Content',
+ 206 => 'Partial Content',
+ 207 => 'Multi-Status',
+ 300 => 'Multiple Choices',
+ 301 => 'Moved Permanently',
+ 302 => 'Found',
+ 303 => 'See Other',
+ 304 => 'Not Modified',
+ 305 => 'Use Proxy',
+ 307 => 'Temporary Redirect',
+ 400 => 'Bad Request',
+ 401 => 'Unauthorized',
+ 402 => 'Payment Required',
+ 403 => 'Forbidden',
+ 404 => 'Not Found',
+ 405 => 'Method Not Allowed',
+ 406 => 'Not Acceptable',
+ 407 => 'Proxy Authentication Required',
+ 408 => 'Request Timeout',
+ 409 => 'Conflict',
+ 410 => 'Gone',
+ 411 => 'Length Required',
+ 412 => 'Precondition Failed',
+ 413 => 'Request Entity Too Large',
+ 414 => 'Request-URI Too Large',
+ 415 => 'Unsupported Media Type',
+ 416 => 'Request Range Not Satisfiable',
+ 417 => 'Expectation Failed',
+ 422 => 'Unprocessable Entity',
+ 423 => 'Locked',
+ 424 => 'Failed Dependency',
+ 428 => 'Precondition Required',
+ 429 => 'Too Many Requests',
+ 431 => 'Request Header Fields Too Large',
+ 500 => 'Internal Server Error',
+ 501 => 'Not Implemented',
+ 502 => 'Bad Gateway',
+ 503 => 'Service Unavailable',
+ 504 => 'Gateway Timeout',
+ 505 => 'HTTP Version Not Supported',
+ 507 => 'Insufficient Storage',
+ 511 => 'Network Authentication Required',
+ ];
+ return isset( $statusMessage[$code] ) ? $statusMessage[$code] : null;
+ }
+
+ /**
+ * Output an HTTP status code header
+ *
+ * @since 1.26
+ * @param int $code Status code
+ */
+ public static function header( $code ) {
+ static $version = null;
+ $message = self::getMessage( $code );
+ if ( $message === null ) {
+ trigger_error( "Unknown HTTP status code $code", E_USER_WARNING );
+ return;
+ }
+
+ MediaWiki\HeaderCallback::warnIfHeadersSent();
+ if ( $version === null ) {
+ $version = isset( $_SERVER['SERVER_PROTOCOL'] ) &&
+ $_SERVER['SERVER_PROTOCOL'] === 'HTTP/1.0' ?
+ '1.0' :
+ '1.1';
+ }
+
+ header( "HTTP/$version $code $message" );
+ }
+
+}
diff --git a/www/wiki/includes/libs/IEContentAnalyzer.php b/www/wiki/includes/libs/IEContentAnalyzer.php
new file mode 100644
index 00000000..0d1e527b
--- /dev/null
+++ b/www/wiki/includes/libs/IEContentAnalyzer.php
@@ -0,0 +1,851 @@
+<?php
+/**
+ * Simulation of Microsoft Internet Explorer's MIME type detection algorithm.
+ *
+ * @file
+ * @todo Define the exact license of this file.
+ */
+
+/**
+ * This class simulates Microsoft Internet Explorer's terribly broken and
+ * insecure MIME type detection algorithm. It can be used to check web uploads
+ * with an apparently safe type, to see if IE will reinterpret them to produce
+ * something dangerous.
+ *
+ * It is full of bugs and strange design choices should not under any
+ * circumstances be used to determine a MIME type to present to a user or
+ * client. (Apple Safari developers, this means you too.)
+ *
+ * This class is based on a disassembly of IE 5.0, 6.0 and 7.0. Although I have
+ * attempted to ensure that this code works in exactly the same way as Internet
+ * Explorer, it does not share any source code, or creative choices such as
+ * variable names, thus I (Tim Starling) claim copyright on it.
+ *
+ * It may be redistributed without restriction. To aid reuse, this class does
+ * not depend on any MediaWiki module.
+ */
+class IEContentAnalyzer {
+ /**
+ * Relevant data taken from the type table in IE 5
+ */
+ protected $baseTypeTable = [
+ 'ambiguous' /*1*/ => [
+ 'text/plain',
+ 'application/octet-stream',
+ 'application/x-netcdf', // [sic]
+ ],
+ 'text' /*3*/ => [
+ 'text/richtext', 'image/x-bitmap', 'application/postscript', 'application/base64',
+ 'application/macbinhex40', 'application/x-cdf', 'text/scriptlet'
+ ],
+ 'binary' /*4*/ => [
+ 'application/pdf', 'audio/x-aiff', 'audio/basic', 'audio/wav', 'image/gif',
+ 'image/pjpeg', 'image/jpeg', 'image/tiff', 'image/x-png', 'image/png', 'image/bmp',
+ 'image/x-jg', 'image/x-art', 'image/x-emf', 'image/x-wmf', 'video/avi',
+ 'video/x-msvideo', 'video/mpeg', 'application/x-compressed',
+ 'application/x-zip-compressed', 'application/x-gzip-compressed', 'application/java',
+ 'application/x-msdownload'
+ ],
+ 'html' /*5*/ => [ 'text/html' ],
+ ];
+
+ /**
+ * Changes to the type table in later versions of IE
+ */
+ protected $addedTypes = [
+ 'ie07' => [
+ 'text' => [ 'text/xml', 'application/xml' ]
+ ],
+ ];
+
+ /**
+ * An approximation of the "Content Type" values in HKEY_CLASSES_ROOT in a
+ * typical Windows installation.
+ *
+ * Used for extension to MIME type mapping if detection fails.
+ */
+ protected $registry = [
+ '.323' => 'text/h323',
+ '.3g2' => 'video/3gpp2',
+ '.3gp' => 'video/3gpp',
+ '.3gp2' => 'video/3gpp2',
+ '.3gpp' => 'video/3gpp',
+ '.aac' => 'audio/aac',
+ '.ac3' => 'audio/ac3',
+ '.accda' => 'application/msaccess',
+ '.accdb' => 'application/msaccess',
+ '.accdc' => 'application/msaccess',
+ '.accde' => 'application/msaccess',
+ '.accdr' => 'application/msaccess',
+ '.accdt' => 'application/msaccess',
+ '.ade' => 'application/msaccess',
+ '.adp' => 'application/msaccess',
+ '.adts' => 'audio/aac',
+ '.ai' => 'application/postscript',
+ '.aif' => 'audio/aiff',
+ '.aifc' => 'audio/aiff',
+ '.aiff' => 'audio/aiff',
+ '.amc' => 'application/x-mpeg',
+ '.application' => 'application/x-ms-application',
+ '.asf' => 'video/x-ms-asf',
+ '.asx' => 'video/x-ms-asf',
+ '.au' => 'audio/basic',
+ '.avi' => 'video/avi',
+ '.bmp' => 'image/bmp',
+ '.caf' => 'audio/x-caf',
+ '.cat' => 'application/vnd.ms-pki.seccat',
+ '.cbo' => 'application/sha',
+ '.cdda' => 'audio/aiff',
+ '.cer' => 'application/x-x509-ca-cert',
+ '.conf' => 'text/plain',
+ '.crl' => 'application/pkix-crl',
+ '.crt' => 'application/x-x509-ca-cert',
+ '.css' => 'text/css',
+ '.csv' => 'application/vnd.ms-excel',
+ '.der' => 'application/x-x509-ca-cert',
+ '.dib' => 'image/bmp',
+ '.dif' => 'video/x-dv',
+ '.dll' => 'application/x-msdownload',
+ '.doc' => 'application/msword',
+ '.docm' => 'application/vnd.ms-word.document.macroEnabled.12',
+ '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ '.dot' => 'application/msword',
+ '.dotm' => 'application/vnd.ms-word.template.macroEnabled.12',
+ '.dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
+ '.dv' => 'video/x-dv',
+ '.dwfx' => 'model/vnd.dwfx+xps',
+ '.edn' => 'application/vnd.adobe.edn',
+ '.eml' => 'message/rfc822',
+ '.eps' => 'application/postscript',
+ '.etd' => 'application/x-ebx',
+ '.exe' => 'application/x-msdownload',
+ '.fdf' => 'application/vnd.fdf',
+ '.fif' => 'application/fractals',
+ '.gif' => 'image/gif',
+ '.gsm' => 'audio/x-gsm',
+ '.hqx' => 'application/mac-binhex40',
+ '.hta' => 'application/hta',
+ '.htc' => 'text/x-component',
+ '.htm' => 'text/html',
+ '.html' => 'text/html',
+ '.htt' => 'text/webviewhtml',
+ '.hxa' => 'application/xml',
+ '.hxc' => 'application/xml',
+ '.hxd' => 'application/octet-stream',
+ '.hxe' => 'application/xml',
+ '.hxf' => 'application/xml',
+ '.hxh' => 'application/octet-stream',
+ '.hxi' => 'application/octet-stream',
+ '.hxk' => 'application/xml',
+ '.hxq' => 'application/octet-stream',
+ '.hxr' => 'application/octet-stream',
+ '.hxs' => 'application/octet-stream',
+ '.hxt' => 'application/xml',
+ '.hxv' => 'application/xml',
+ '.hxw' => 'application/octet-stream',
+ '.ico' => 'image/x-icon',
+ '.iii' => 'application/x-iphone',
+ '.ins' => 'application/x-internet-signup',
+ '.iqy' => 'text/x-ms-iqy',
+ '.isp' => 'application/x-internet-signup',
+ '.jfif' => 'image/jpeg',
+ '.jnlp' => 'application/x-java-jnlp-file',
+ '.jpe' => 'image/jpeg',
+ '.jpeg' => 'image/jpeg',
+ '.jpg' => 'image/jpeg',
+ '.jtx' => 'application/x-jtx+xps',
+ '.latex' => 'application/x-latex',
+ '.log' => 'text/plain',
+ '.m1v' => 'video/mpeg',
+ '.m2v' => 'video/mpeg',
+ '.m3u' => 'audio/x-mpegurl',
+ '.mac' => 'image/x-macpaint',
+ '.man' => 'application/x-troff-man',
+ '.mda' => 'application/msaccess',
+ '.mdb' => 'application/msaccess',
+ '.mde' => 'application/msaccess',
+ '.mfp' => 'application/x-shockwave-flash',
+ '.mht' => 'message/rfc822',
+ '.mhtml' => 'message/rfc822',
+ '.mid' => 'audio/mid',
+ '.midi' => 'audio/mid',
+ '.mod' => 'video/mpeg',
+ '.mov' => 'video/quicktime',
+ '.mp2' => 'video/mpeg',
+ '.mp2v' => 'video/mpeg',
+ '.mp3' => 'audio/mpeg',
+ '.mp4' => 'video/mp4',
+ '.mpa' => 'video/mpeg',
+ '.mpe' => 'video/mpeg',
+ '.mpeg' => 'video/mpeg',
+ '.mpf' => 'application/vnd.ms-mediapackage',
+ '.mpg' => 'video/mpeg',
+ '.mpv2' => 'video/mpeg',
+ '.mqv' => 'video/quicktime',
+ '.NMW' => 'application/nmwb',
+ '.nws' => 'message/rfc822',
+ '.odc' => 'text/x-ms-odc',
+ '.ols' => 'application/vnd.ms-publisher',
+ '.p10' => 'application/pkcs10',
+ '.p12' => 'application/x-pkcs12',
+ '.p7b' => 'application/x-pkcs7-certificates',
+ '.p7c' => 'application/pkcs7-mime',
+ '.p7m' => 'application/pkcs7-mime',
+ '.p7r' => 'application/x-pkcs7-certreqresp',
+ '.p7s' => 'application/pkcs7-signature',
+ '.pct' => 'image/pict',
+ '.pdf' => 'application/pdf',
+ '.pdx' => 'application/vnd.adobe.pdx',
+ '.pfx' => 'application/x-pkcs12',
+ '.pic' => 'image/pict',
+ '.pict' => 'image/pict',
+ '.pinstall' => 'application/x-picasa-detect',
+ '.pko' => 'application/vnd.ms-pki.pko',
+ '.png' => 'image/png',
+ '.pnt' => 'image/x-macpaint',
+ '.pntg' => 'image/x-macpaint',
+ '.pot' => 'application/vnd.ms-powerpoint',
+ '.potm' => 'application/vnd.ms-powerpoint.template.macroEnabled.12',
+ '.potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template',
+ '.ppa' => 'application/vnd.ms-powerpoint',
+ '.ppam' => 'application/vnd.ms-powerpoint.addin.macroEnabled.12',
+ '.pps' => 'application/vnd.ms-powerpoint',
+ '.ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
+ '.ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
+ '.ppt' => 'application/vnd.ms-powerpoint',
+ '.pptm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
+ '.pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ '.prf' => 'application/pics-rules',
+ '.ps' => 'application/postscript',
+ '.pub' => 'application/vnd.ms-publisher',
+ '.pwz' => 'application/vnd.ms-powerpoint',
+ '.py' => 'text/plain',
+ '.pyw' => 'text/plain',
+ '.qht' => 'text/x-html-insertion',
+ '.qhtm' => 'text/x-html-insertion',
+ '.qt' => 'video/quicktime',
+ '.qti' => 'image/x-quicktime',
+ '.qtif' => 'image/x-quicktime',
+ '.qtl' => 'application/x-quicktimeplayer',
+ '.rat' => 'application/rat-file',
+ '.rmf' => 'application/vnd.adobe.rmf',
+ '.rmi' => 'audio/mid',
+ '.rqy' => 'text/x-ms-rqy',
+ '.rtf' => 'application/msword',
+ '.sct' => 'text/scriptlet',
+ '.sd2' => 'audio/x-sd2',
+ '.sdp' => 'application/sdp',
+ '.shtml' => 'text/html',
+ '.sit' => 'application/x-stuffit',
+ '.sldm' => 'application/vnd.ms-powerpoint.slide.macroEnabled.12',
+ '.sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide',
+ '.slk' => 'application/vnd.ms-excel',
+ '.snd' => 'audio/basic',
+ '.so' => 'application/x-apachemodule',
+ '.sol' => 'text/plain',
+ '.sor' => 'text/plain',
+ '.spc' => 'application/x-pkcs7-certificates',
+ '.spl' => 'application/futuresplash',
+ '.sst' => 'application/vnd.ms-pki.certstore',
+ '.stl' => 'application/vnd.ms-pki.stl',
+ '.swf' => 'application/x-shockwave-flash',
+ '.thmx' => 'application/vnd.ms-officetheme',
+ '.tif' => 'image/tiff',
+ '.tiff' => 'image/tiff',
+ '.txt' => 'text/plain',
+ '.uls' => 'text/iuls',
+ '.vcf' => 'text/x-vcard',
+ '.vdx' => 'application/vnd.ms-visio.viewer',
+ '.vsd' => 'application/vnd.ms-visio.viewer',
+ '.vss' => 'application/vnd.ms-visio.viewer',
+ '.vst' => 'application/vnd.ms-visio.viewer',
+ '.vsx' => 'application/vnd.ms-visio.viewer',
+ '.vtx' => 'application/vnd.ms-visio.viewer',
+ '.wav' => 'audio/wav',
+ '.wax' => 'audio/x-ms-wax',
+ '.wbk' => 'application/msword',
+ '.wdp' => 'image/vnd.ms-photo',
+ '.wiz' => 'application/msword',
+ '.wm' => 'video/x-ms-wm',
+ '.wma' => 'audio/x-ms-wma',
+ '.wmd' => 'application/x-ms-wmd',
+ '.wmv' => 'video/x-ms-wmv',
+ '.wmx' => 'video/x-ms-wmx',
+ '.wmz' => 'application/x-ms-wmz',
+ '.wpl' => 'application/vnd.ms-wpl',
+ '.wsc' => 'text/scriptlet',
+ '.wvx' => 'video/x-ms-wvx',
+ '.xaml' => 'application/xaml+xml',
+ '.xbap' => 'application/x-ms-xbap',
+ '.xdp' => 'application/vnd.adobe.xdp+xml',
+ '.xfdf' => 'application/vnd.adobe.xfdf',
+ '.xht' => 'application/xhtml+xml',
+ '.xhtml' => 'application/xhtml+xml',
+ '.xla' => 'application/vnd.ms-excel',
+ '.xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12',
+ '.xlk' => 'application/vnd.ms-excel',
+ '.xll' => 'application/vnd.ms-excel',
+ '.xlm' => 'application/vnd.ms-excel',
+ '.xls' => 'application/vnd.ms-excel',
+ '.xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
+ '.xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
+ '.xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ '.xlt' => 'application/vnd.ms-excel',
+ '.xltm' => 'application/vnd.ms-excel.template.macroEnabled.12',
+ '.xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
+ '.xlw' => 'application/vnd.ms-excel',
+ '.xml' => 'text/xml',
+ '.xps' => 'application/vnd.ms-xpsdocument',
+ '.xsl' => 'text/xml',
+ ];
+
+ /**
+ * IE versions which have been analysed to bring you this class, and for
+ * which some substantive difference exists. These will appear as keys
+ * in the return value of getRealMimesFromData(). The names are chosen to sort correctly.
+ */
+ protected $versions = [ 'ie05', 'ie06', 'ie07', 'ie07.strict', 'ie07.nohtml' ];
+
+ /**
+ * Type table with versions expanded
+ */
+ protected $typeTable = [];
+
+ /** constructor */
+ function __construct() {
+ // Construct versioned type arrays from the base type array plus additions
+ $types = $this->baseTypeTable;
+ foreach ( $this->versions as $version ) {
+ if ( isset( $this->addedTypes[$version] ) ) {
+ foreach ( $this->addedTypes[$version] as $format => $addedTypes ) {
+ $types[$format] = array_merge( $types[$format], $addedTypes );
+ }
+ }
+ $this->typeTable[$version] = $types;
+ }
+ }
+
+ /**
+ * Get the MIME types from getMimesFromData(), but convert the result from IE's
+ * idiosyncratic private types into something other apps will understand.
+ *
+ * @param string $fileName the file name (unused at present)
+ * @param string $chunk the first 256 bytes of the file
+ * @param string $proposed the MIME type proposed by the server
+ *
+ * @return Array: map of IE version to detected MIME type
+ */
+ public function getRealMimesFromData( $fileName, $chunk, $proposed ) {
+ $types = $this->getMimesFromData( $fileName, $chunk, $proposed );
+ $types = array_map( [ $this, 'translateMimeType' ], $types );
+ return $types;
+ }
+
+ /**
+ * Translate a MIME type from IE's idiosyncratic private types into
+ * more commonly understood type strings
+ * @param $type
+ * @return string
+ */
+ public function translateMimeType( $type ) {
+ static $table = [
+ 'image/pjpeg' => 'image/jpeg',
+ 'image/x-png' => 'image/png',
+ 'image/x-wmf' => 'application/x-msmetafile',
+ 'image/bmp' => 'image/x-bmp',
+ 'application/x-zip-compressed' => 'application/zip',
+ 'application/x-compressed' => 'application/x-compress',
+ 'application/x-gzip-compressed' => 'application/x-gzip',
+ 'audio/mid' => 'audio/midi',
+ ];
+ if ( isset( $table[$type] ) ) {
+ $type = $table[$type];
+ }
+ return $type;
+ }
+
+ /**
+ * Get the untranslated MIME types for all known versions
+ *
+ * @param string $fileName the file name (unused at present)
+ * @param string $chunk the first 256 bytes of the file
+ * @param string $proposed the MIME type proposed by the server
+ *
+ * @return Array: map of IE version to detected MIME type
+ */
+ public function getMimesFromData( $fileName, $chunk, $proposed ) {
+ $types = [];
+ foreach ( $this->versions as $version ) {
+ $types[$version] = $this->getMimeTypeForVersion( $version, $fileName, $chunk, $proposed );
+ }
+ return $types;
+ }
+
+ /**
+ * Get the MIME type for a given named version
+ * @param $version
+ * @param $fileName
+ * @param $chunk
+ * @param $proposed
+ * @return bool|string
+ */
+ protected function getMimeTypeForVersion( $version, $fileName, $chunk, $proposed ) {
+ // Strip text after a semicolon
+ $semiPos = strpos( $proposed, ';' );
+ if ( $semiPos !== false ) {
+ $proposed = substr( $proposed, 0, $semiPos );
+ }
+
+ $proposedFormat = $this->getDataFormat( $version, $proposed );
+ if ( $proposedFormat == 'unknown'
+ && $proposed != 'multipart/mixed'
+ && $proposed != 'multipart/x-mixed-replace' )
+ {
+ return $proposed;
+ }
+ if ( strval( $chunk ) === '' ) {
+ return $proposed;
+ }
+
+ // Truncate chunk at 255 bytes
+ $chunk = substr( $chunk, 0, 255 );
+
+ // IE does the Check*Headers() calls last, and instead does the following image
+ // type checks by directly looking for the magic numbers. What I do here should
+ // have the same effect since the magic number checks are identical in both cases.
+ $result = $this->sampleData( $version, $chunk );
+ $sampleFound = $result['found'];
+ $counters = $result['counters'];
+ $binaryType = $this->checkBinaryHeaders( $version, $chunk );
+ $textType = $this->checkTextHeaders( $version, $chunk );
+
+ if ( $proposed == 'text/html' && isset( $sampleFound['html'] ) ) {
+ return 'text/html';
+ }
+ if ( $proposed == 'image/gif' && $binaryType == 'image/gif' ) {
+ return 'image/gif';
+ }
+ if ( ( $proposed == 'image/pjpeg' || $proposed == 'image/jpeg' )
+ && $binaryType == 'image/pjpeg' )
+ {
+ return $proposed;
+ }
+ // PNG check added in IE 7
+ if ( $version >= 'ie07'
+ && ( $proposed == 'image/x-png' || $proposed == 'image/png' )
+ && $binaryType == 'image/x-png' )
+ {
+ return $proposed;
+ }
+
+ // CDF was removed in IE 7 so it won't be in $sampleFound for later versions
+ if ( isset( $sampleFound['cdf'] ) ) {
+ return 'application/x-cdf';
+ }
+
+ // RSS and Atom were added in IE 7 so they won't be in $sampleFound for
+ // previous versions
+ if ( isset( $sampleFound['rss'] ) ) {
+ return 'application/rss+xml';
+ }
+ if ( isset( $sampleFound['rdf-tag'] )
+ && isset( $sampleFound['rdf-url'] )
+ && isset( $sampleFound['rdf-purl'] ) )
+ {
+ return 'application/rss+xml';
+ }
+ if ( isset( $sampleFound['atom'] ) ) {
+ return 'application/atom+xml';
+ }
+
+ if ( isset( $sampleFound['xml'] ) ) {
+ // TODO: I'm not sure under what circumstances this flag is enabled
+ if ( strpos( $version, 'strict' ) !== false ) {
+ if ( $proposed == 'text/html' || $proposed == 'text/xml' ) {
+ return 'text/xml';
+ }
+ } else {
+ return 'text/xml';
+ }
+ }
+ if ( isset( $sampleFound['html'] ) ) {
+ // TODO: I'm not sure under what circumstances this flag is enabled
+ if ( strpos( $version, 'nohtml' ) !== false ) {
+ if ( $proposed == 'text/plain' ) {
+ return 'text/html';
+ }
+ } else {
+ return 'text/html';
+ }
+ }
+ if ( isset( $sampleFound['xbm'] ) ) {
+ return 'image/x-bitmap';
+ }
+ if ( isset( $sampleFound['binhex'] ) ) {
+ return 'application/macbinhex40';
+ }
+ if ( isset( $sampleFound['scriptlet'] ) ) {
+ if ( strpos( $version, 'strict' ) !== false ) {
+ if ( $proposed == 'text/plain' || $proposed == 'text/scriptlet' ) {
+ return 'text/scriptlet';
+ }
+ } else {
+ return 'text/scriptlet';
+ }
+ }
+
+ // Freaky heuristics to determine if the data is text or binary
+ // The heuristic is of course broken for non-ASCII text
+ if ( $counters['ctrl'] != 0 && ( $counters['ff'] + $counters['low'] )
+ < ( $counters['ctrl'] + $counters['high'] ) * 16 )
+ {
+ $kindOfBinary = true;
+ $type = $binaryType ? $binaryType : $textType;
+ if ( $type === false ) {
+ $type = 'application/octet-stream';
+ }
+ } else {
+ $kindOfBinary = false;
+ $type = $textType ? $textType : $binaryType;
+ if ( $type === false ) {
+ $type = 'text/plain';
+ }
+ }
+
+ // Check if the output format is ambiguous
+ // This generally means that detection failed, real types aren't ambiguous
+ $detectedFormat = $this->getDataFormat( $version, $type );
+ if ( $detectedFormat != 'ambiguous' ) {
+ return $type;
+ }
+
+ if ( $proposedFormat != 'ambiguous' ) {
+ // FormatAgreesWithData()
+ if ( $proposedFormat == 'text' && !$kindOfBinary ) {
+ return $proposed;
+ }
+ if ( $proposedFormat == 'binary' && $kindOfBinary ) {
+ return $proposed;
+ }
+ if ( $proposedFormat == 'html' ) {
+ return $proposed;
+ }
+ }
+
+ // Find a MIME type by searching the registry for the file extension.
+ $dotPos = strrpos( $fileName, '.' );
+ if ( $dotPos === false ) {
+ return $type;
+ }
+ $ext = substr( $fileName, $dotPos );
+ if ( isset( $this->registry[$ext] ) ) {
+ return $this->registry[$ext];
+ }
+
+ // TODO: If the extension has an application registered to it, IE will return
+ // application/octet-stream. We'll skip that, so we could erroneously
+ // return text/plain or application/x-netcdf where application/octet-stream
+ // would be correct.
+
+ return $type;
+ }
+
+ /**
+ * Check for text headers at the start of the chunk
+ * Confirmed same in 5 and 7.
+ * @param $version
+ * @param $chunk
+ * @return bool|string
+ */
+ private function checkTextHeaders( $version, $chunk ) {
+ $chunk2 = substr( $chunk, 0, 2 );
+ $chunk4 = substr( $chunk, 0, 4 );
+ $chunk5 = substr( $chunk, 0, 5 );
+ if ( $chunk4 == '%PDF' ) {
+ return 'application/pdf';
+ }
+ if ( $chunk2 == '%!' ) {
+ return 'application/postscript';
+ }
+ if ( $chunk5 == '{\\rtf' ) {
+ return 'text/richtext';
+ }
+ if ( $chunk5 == 'begin' ) {
+ return 'application/base64';
+ }
+ return false;
+ }
+
+ /**
+ * Check for binary headers at the start of the chunk
+ * Confirmed same in 5 and 7.
+ * @param $version
+ * @param $chunk
+ * @return bool|string
+ */
+ private function checkBinaryHeaders( $version, $chunk ) {
+ $chunk2 = substr( $chunk, 0, 2 );
+ $chunk3 = substr( $chunk, 0, 3 );
+ $chunk4 = substr( $chunk, 0, 4 );
+ $chunk5 = substr( $chunk, 0, 5 );
+ $chunk5uc = strtoupper( $chunk5 );
+ $chunk8 = substr( $chunk, 0, 8 );
+ if ( $chunk5uc == 'GIF87' || $chunk5uc == 'GIF89' ) {
+ return 'image/gif';
+ }
+ if ( $chunk2 == "\xff\xd8" ) {
+ return 'image/pjpeg'; // actually plain JPEG but this is what IE returns
+ }
+
+ if ( $chunk2 == 'BM'
+ && substr( $chunk, 6, 2 ) == "\000\000"
+ && substr( $chunk, 8, 2 ) == "\000\000" )
+ {
+ return 'image/bmp'; // another non-standard MIME
+ }
+ if ( $chunk4 == 'RIFF'
+ && substr( $chunk, 8, 4 ) == 'WAVE' )
+ {
+ return 'audio/wav';
+ }
+ // These were integer literals in IE
+ // Perhaps the author was not sure what the target endianness was
+ if ( $chunk4 == ".sd\000"
+ || $chunk4 == ".snd"
+ || $chunk4 == "\000ds."
+ || $chunk4 == "dns." )
+ {
+ return 'audio/basic';
+ }
+ if ( $chunk3 == "MM\000" ) {
+ return 'image/tiff';
+ }
+ if ( $chunk2 == 'MZ' ) {
+ return 'application/x-msdownload';
+ }
+ if ( $chunk8 == "\x89PNG\x0d\x0a\x1a\x0a" ) {
+ return 'image/x-png'; // [sic]
+ }
+ if ( strlen( $chunk ) >= 5 ) {
+ $byte2 = ord( $chunk[2] );
+ $byte4 = ord( $chunk[4] );
+ if ( $byte2 >= 3 && $byte2 <= 31 && $byte4 == 0 && $chunk2 == 'JG' ) {
+ return 'image/x-jg';
+ }
+ }
+ // More endian confusion?
+ if ( $chunk4 == 'MROF' ) {
+ return 'audio/x-aiff';
+ }
+ $chunk4_8 = substr( $chunk, 8, 4 );
+ if ( $chunk4 == 'FORM' && ( $chunk4_8 == 'AIFF' || $chunk4_8 == 'AIFC' ) ) {
+ return 'audio/x-aiff';
+ }
+ if ( $chunk4 == 'RIFF' && $chunk4_8 == 'AVI ' ) {
+ return 'video/avi';
+ }
+ if ( $chunk4 == "\x00\x00\x01\xb3" || $chunk4 == "\x00\x00\x01\xba" ) {
+ return 'video/mpeg';
+ }
+ if ( $chunk4 == "\001\000\000\000"
+ && substr( $chunk, 40, 4 ) == ' EMF' )
+ {
+ return 'image/x-emf';
+ }
+ if ( $chunk4 == "\xd7\xcd\xc6\x9a" ) {
+ return 'image/x-wmf';
+ }
+ if ( $chunk4 == "\xca\xfe\xba\xbe" ) {
+ return 'application/java';
+ }
+ if ( $chunk2 == 'PK' ) {
+ return 'application/x-zip-compressed';
+ }
+ if ( $chunk2 == "\x1f\x9d" ) {
+ return 'application/x-compressed';
+ }
+ if ( $chunk2 == "\x1f\x8b" ) {
+ return 'application/x-gzip-compressed';
+ }
+ // Skip redundant check for ZIP
+ if ( $chunk5 == "MThd\000" ) {
+ return 'audio/mid';
+ }
+ if ( $chunk4 == '%PDF' ) {
+ return 'application/pdf';
+ }
+ return false;
+ }
+
+ /**
+ * Do heuristic checks on the bulk of the data sample.
+ * Search for HTML tags.
+ * @param $version
+ * @param $chunk
+ * @return array
+ */
+ protected function sampleData( $version, $chunk ) {
+ $found = [];
+ $counters = [
+ 'ctrl' => 0,
+ 'high' => 0,
+ 'low' => 0,
+ 'lf' => 0,
+ 'cr' => 0,
+ 'ff' => 0
+ ];
+ $htmlTags = [
+ 'html',
+ 'head',
+ 'title',
+ 'body',
+ 'script',
+ 'a href',
+ 'pre',
+ 'img',
+ 'plaintext',
+ 'table'
+ ];
+ $rdfUrl = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
+ $rdfPurl = 'http://purl.org/rss/1.0/';
+ $xbmMagic1 = '#define';
+ $xbmMagic2 = '_width';
+ $xbmMagic3 = '_bits';
+ $binhexMagic = 'converted with BinHex';
+ $chunkLength = strlen( $chunk );
+
+ for ( $offset = 0; $offset < $chunkLength; $offset++ ) {
+ $curChar = $chunk[$offset];
+ if ( $curChar == "\x0a" ) {
+ $counters['lf']++;
+ continue;
+ } elseif ( $curChar == "\x0d" ) {
+ $counters['cr']++;
+ continue;
+ } elseif ( $curChar == "\x0c" ) {
+ $counters['ff']++;
+ continue;
+ } elseif ( $curChar == "\t" ) {
+ $counters['low']++;
+ continue;
+ } elseif ( ord( $curChar ) < 32 ) {
+ $counters['ctrl']++;
+ continue;
+ } elseif ( ord( $curChar ) >= 128 ) {
+ $counters['high']++;
+ continue;
+ }
+
+ $counters['low']++;
+ if ( $curChar == '<' ) {
+ // XML
+ $remainder = substr( $chunk, $offset + 1 );
+ if ( !strncasecmp( $remainder, '?XML', 4 ) ) {
+ $nextChar = substr( $chunk, $offset + 5, 1 );
+ if ( $nextChar == ':' || $nextChar == ' ' || $nextChar == "\t" ) {
+ $found['xml'] = true;
+ }
+ }
+ // Scriptlet (JSP)
+ if ( !strncasecmp( $remainder, 'SCRIPTLET', 9 ) ) {
+ $found['scriptlet'] = true;
+ break;
+ }
+ // HTML
+ foreach ( $htmlTags as $tag ) {
+ if ( !strncasecmp( $remainder, $tag, strlen( $tag ) ) ) {
+ $found['html'] = true;
+ }
+ }
+ // Skip broken check for additional tags (HR etc.)
+
+ // CHANNEL replaced by RSS, RDF and FEED in IE 7
+ if ( $version < 'ie07' ) {
+ if ( !strncasecmp( $remainder, 'CHANNEL', 7 ) ) {
+ $found['cdf'] = true;
+ }
+ } else {
+ // RSS
+ if ( !strncasecmp( $remainder, 'RSS', 3 ) ) {
+ $found['rss'] = true;
+ break; // return from SampleData
+ }
+ if ( !strncasecmp( $remainder, 'rdf:RDF', 7 ) ) {
+ $found['rdf-tag'] = true;
+ // no break
+ }
+ if ( !strncasecmp( $remainder, 'FEED', 4 ) ) {
+ $found['atom'] = true;
+ break;
+ }
+ }
+ continue;
+ }
+ // Skip broken check for -->
+
+ // RSS URL checks
+ // For some reason both URLs must appear before it is recognised
+ $remainder = substr( $chunk, $offset );
+ if ( !strncasecmp( $remainder, $rdfUrl, strlen( $rdfUrl ) ) ) {
+ $found['rdf-url'] = true;
+ if ( isset( $found['rdf-tag'] )
+ && isset( $found['rdf-purl'] ) ) // [sic]
+ {
+ break;
+ }
+ continue;
+ }
+
+ if ( !strncasecmp( $remainder, $rdfPurl, strlen( $rdfPurl ) ) ) {
+ if ( isset( $found['rdf-tag'] )
+ && isset( $found['rdf-url'] ) ) // [sic]
+ {
+ break;
+ }
+ continue;
+ }
+
+ // XBM checks
+ if ( !strncasecmp( $remainder, $xbmMagic1, strlen( $xbmMagic1 ) ) ) {
+ $found['xbm1'] = true;
+ continue;
+ }
+ if ( $curChar == '_' ) {
+ if ( isset( $found['xbm2'] ) ) {
+ if ( !strncasecmp( $remainder, $xbmMagic3, strlen( $xbmMagic3 ) ) ) {
+ $found['xbm'] = true;
+ break;
+ }
+ } elseif ( isset( $found['xbm1'] ) ) {
+ if ( !strncasecmp( $remainder, $xbmMagic2, strlen( $xbmMagic2 ) ) ) {
+ $found['xbm2'] = true;
+ }
+ }
+ }
+
+ // BinHex
+ if ( !strncmp( $remainder, $binhexMagic, strlen( $binhexMagic ) ) ) {
+ $found['binhex'] = true;
+ }
+ }
+ return [ 'found' => $found, 'counters' => $counters ];
+ }
+
+ /**
+ * @param $version
+ * @param $type
+ * @return int|string
+ */
+ protected function getDataFormat( $version, $type ) {
+ $types = $this->typeTable[$version];
+ if ( $type == '(null)' || strval( $type ) === '' ) {
+ return 'ambiguous';
+ }
+ foreach ( $types as $format => $list ) {
+ if ( in_array( $type, $list ) ) {
+ return $format;
+ }
+ }
+ return 'unknown';
+ }
+}
diff --git a/www/wiki/includes/libs/IEUrlExtension.php b/www/wiki/includes/libs/IEUrlExtension.php
new file mode 100644
index 00000000..2d1c58b6
--- /dev/null
+++ b/www/wiki/includes/libs/IEUrlExtension.php
@@ -0,0 +1,269 @@
+<?php
+/**
+ * Checks for validity of requested URL's extension.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Internet Explorer derives a cache filename from a URL, and then in certain
+ * circumstances, uses the extension of the resulting file to determine the
+ * content type of the data, ignoring the Content-Type header.
+ *
+ * This can be a problem, especially when non-HTML content is sent by MediaWiki,
+ * and Internet Explorer interprets it as HTML, exposing an XSS vulnerability.
+ *
+ * Usually the script filename (e.g. api.php) is present in the URL, and this
+ * makes Internet Explorer think the extension is a harmless script extension.
+ * But Internet Explorer 6 and earlier allows the script extension to be
+ * obscured by encoding the dot as "%2E".
+ *
+ * This class contains functions which help in detecting and dealing with this
+ * situation.
+ *
+ * Checking the URL for a bad extension is somewhat complicated due to the fact
+ * that CGI doesn't provide a standard method to determine the URL. Instead it
+ * is necessary to pass a subset of $_SERVER variables, which we then attempt
+ * to use to guess parts of the URL.
+ */
+class IEUrlExtension {
+ /**
+ * Check a subset of $_SERVER (or the whole of $_SERVER if you like)
+ * to see if it indicates that the request was sent with a bad file
+ * extension. Returns true if the request should be denied or modified,
+ * false otherwise. The relevant $_SERVER elements are:
+ *
+ * - SERVER_SOFTWARE
+ * - REQUEST_URI
+ * - QUERY_STRING
+ * - PATH_INFO
+ *
+ * If the a variable is unset in $_SERVER, it should be unset in $vars.
+ *
+ * @param array $vars A subset of $_SERVER.
+ * @param array $extWhitelist Extensions which are allowed, assumed harmless.
+ * @return bool
+ */
+ public static function areServerVarsBad( $vars, $extWhitelist = [] ) {
+ // Check QUERY_STRING or REQUEST_URI
+ if ( isset( $vars['SERVER_SOFTWARE'] )
+ && isset( $vars['REQUEST_URI'] )
+ && self::haveUndecodedRequestUri( $vars['SERVER_SOFTWARE'] )
+ ) {
+ $urlPart = $vars['REQUEST_URI'];
+ } elseif ( isset( $vars['QUERY_STRING'] ) ) {
+ $urlPart = $vars['QUERY_STRING'];
+ } else {
+ $urlPart = '';
+ }
+
+ if ( self::isUrlExtensionBad( $urlPart, $extWhitelist ) ) {
+ return true;
+ }
+
+ // Some servers have PATH_INFO but not REQUEST_URI, so we check both
+ // to be on the safe side.
+ if ( isset( $vars['PATH_INFO'] )
+ && self::isUrlExtensionBad( $vars['PATH_INFO'], $extWhitelist )
+ ) {
+ return true;
+ }
+
+ // All checks passed
+ return false;
+ }
+
+ /**
+ * Given a right-hand portion of a URL, determine whether IE would detect
+ * a potentially harmful file extension.
+ *
+ * @param string $urlPart The right-hand portion of a URL
+ * @param array $extWhitelist An array of file extensions which may occur in this
+ * URL, and which should be allowed.
+ * @return bool
+ */
+ public static function isUrlExtensionBad( $urlPart, $extWhitelist = [] ) {
+ if ( strval( $urlPart ) === '' ) {
+ return false;
+ }
+
+ $extension = self::findIE6Extension( $urlPart );
+ if ( strval( $extension ) === '' ) {
+ // No extension or empty extension
+ return false;
+ }
+
+ if ( in_array( $extension, [ 'php', 'php5' ] ) ) {
+ // Script extension, OK
+ return false;
+ }
+ if ( in_array( $extension, $extWhitelist ) ) {
+ // Whitelisted extension
+ return false;
+ }
+
+ if ( !preg_match( '/^[a-zA-Z0-9_-]+$/', $extension ) ) {
+ // Non-alphanumeric extension, unlikely to be registered.
+ // The regex above is known to match all registered file extensions
+ // in a default Windows XP installation. It's important to allow
+ // extensions with ampersands and percent signs, since that reduces
+ // the number of false positives substantially.
+ return false;
+ }
+
+ // Possibly bad extension
+ return true;
+ }
+
+ /**
+ * Returns a variant of $url which will pass isUrlExtensionBad() but has the
+ * same GET parameters, or false if it can't figure one out.
+ * @param string $url
+ * @param array $extWhitelist
+ * @return bool|string
+ */
+ public static function fixUrlForIE6( $url, $extWhitelist = [] ) {
+ $questionPos = strpos( $url, '?' );
+ if ( $questionPos === false ) {
+ $beforeQuery = $url . '?';
+ $query = '';
+ } elseif ( $questionPos === strlen( $url ) - 1 ) {
+ $beforeQuery = $url;
+ $query = '';
+ } else {
+ $beforeQuery = substr( $url, 0, $questionPos + 1 );
+ $query = substr( $url, $questionPos + 1 );
+ }
+
+ // Multiple question marks cause problems. Encode the second and
+ // subsequent question mark.
+ $query = str_replace( '?', '%3E', $query );
+ // Append an invalid path character so that IE6 won't see the end of the
+ // query string as an extension
+ $query .= '&*';
+ // Put the URL back together
+ $url = $beforeQuery . $query;
+ if ( self::isUrlExtensionBad( $url, $extWhitelist ) ) {
+ // Avoid a redirect loop
+ return false;
+ }
+ return $url;
+ }
+
+ /**
+ * Determine what extension IE6 will infer from a certain query string.
+ * If the URL has an extension before the question mark, IE6 will use
+ * that and ignore the query string, but per the comment at
+ * isPathInfoBad() we don't have a reliable way to determine the URL,
+ * so isPathInfoBad() just passes in the query string for $url.
+ * All entry points have safe extensions (php, php5) anyway, so
+ * checking the query string is possibly overly paranoid but never
+ * insecure.
+ *
+ * The criteria for finding an extension are as follows:
+ * - a possible extension is a dot followed by one or more characters not
+ * in <>\"/:|?.#
+ * - if we find a possible extension followed by the end of the string or
+ * a #, that's our extension
+ * - if we find a possible extension followed by a ?, that's our extension
+ * - UNLESS it's exe, dll or cgi, in which case we ignore it and continue
+ * searching for another possible extension
+ * - if we find a possible extension followed by a dot or another illegal
+ * character, we ignore it and continue searching
+ *
+ * @param string $url URL
+ * @return mixed Detected extension (string), or false if none found
+ */
+ public static function findIE6Extension( $url ) {
+ $pos = 0;
+ $hashPos = strpos( $url, '#' );
+ if ( $hashPos !== false ) {
+ $urlLength = $hashPos;
+ } else {
+ $urlLength = strlen( $url );
+ }
+ $remainingLength = $urlLength;
+ while ( $remainingLength > 0 ) {
+ // Skip ahead to the next dot
+ $pos += strcspn( $url, '.', $pos, $remainingLength );
+ if ( $pos >= $urlLength ) {
+ // End of string, we're done
+ return false;
+ }
+
+ // We found a dot. Skip past it
+ $pos++;
+ $remainingLength = $urlLength - $pos;
+
+ // Check for illegal characters in our prospective extension,
+ // or for another dot
+ $nextPos = $pos + strcspn( $url, "<>\\\"/:|?*.", $pos, $remainingLength );
+ if ( $nextPos >= $urlLength ) {
+ // No illegal character or next dot
+ // We have our extension
+ return substr( $url, $pos, $urlLength - $pos );
+ }
+ if ( $url[$nextPos] === '?' ) {
+ // We've found a legal extension followed by a question mark
+ // If the extension is NOT exe, dll or cgi, return it
+ $extension = substr( $url, $pos, $nextPos - $pos );
+ if ( strcasecmp( $extension, 'exe' ) && strcasecmp( $extension, 'dll' ) &&
+ strcasecmp( $extension, 'cgi' )
+ ) {
+ return $extension;
+ }
+ // Else continue looking
+ }
+ // We found an illegal character or another dot
+ // Skip to that character and continue the loop
+ $pos = $nextPos;
+ $remainingLength = $urlLength - $pos;
+ }
+ return false;
+ }
+
+ /**
+ * When passed the value of $_SERVER['SERVER_SOFTWARE'], this function
+ * returns true if that server is known to have a REQUEST_URI variable
+ * with %2E not decoded to ".". On such a server, it is possible to detect
+ * whether the script filename has been obscured.
+ *
+ * The function returns false if the server is not known to have this
+ * behavior. Microsoft IIS in particular is known to decode escaped script
+ * filenames.
+ *
+ * SERVER_SOFTWARE typically contains either a plain string such as "Zeus",
+ * or a specification in the style of a User-Agent header, such as
+ * "Apache/1.3.34 (Unix) mod_ssl/2.8.25 OpenSSL/0.9.8a PHP/4.4.2"
+ *
+ * @param string $serverSoftware
+ * @return bool
+ */
+ public static function haveUndecodedRequestUri( $serverSoftware ) {
+ static $whitelist = [
+ 'Apache',
+ 'Zeus',
+ 'LiteSpeed' ];
+ if ( preg_match( '/^(.*?)($|\/| )/', $serverSoftware, $m ) ) {
+ return in_array( $m[1], $whitelist );
+ } else {
+ return false;
+ }
+ }
+
+}
diff --git a/www/wiki/includes/libs/IP.php b/www/wiki/includes/libs/IP.php
new file mode 100644
index 00000000..1c48f49d
--- /dev/null
+++ b/www/wiki/includes/libs/IP.php
@@ -0,0 +1,756 @@
+<?php
+/**
+ * Functions and constants to play with IP addresses and ranges
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Antoine Musso "<hashar at free dot fr>"
+ */
+
+use IPSet\IPSet;
+
+// Some regex definition to "play" with IP address and IP address ranges
+
+// An IPv4 address is made of 4 bytes from x00 to xFF which is d0 to d255
+define( 'RE_IP_BYTE', '(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])' );
+define( 'RE_IP_ADD', RE_IP_BYTE . '\.' . RE_IP_BYTE . '\.' . RE_IP_BYTE . '\.' . RE_IP_BYTE );
+// An IPv4 range is an IP address and a prefix (d1 to d32)
+define( 'RE_IP_PREFIX', '(3[0-2]|[12]?\d)' );
+define( 'RE_IP_RANGE', RE_IP_ADD . '\/' . RE_IP_PREFIX );
+
+// An IPv6 address is made up of 8 words (each x0000 to xFFFF).
+// However, the "::" abbreviation can be used on consecutive x0000 words.
+define( 'RE_IPV6_WORD', '([0-9A-Fa-f]{1,4})' );
+define( 'RE_IPV6_PREFIX', '(12[0-8]|1[01][0-9]|[1-9]?\d)' );
+define( 'RE_IPV6_ADD',
+ '(?:' . // starts with "::" (including "::")
+ ':(?::|(?::' . RE_IPV6_WORD . '){1,7})' .
+ '|' . // ends with "::" (except "::")
+ RE_IPV6_WORD . '(?::' . RE_IPV6_WORD . '){0,6}::' .
+ '|' . // contains one "::" in the middle (the ^ makes the test fail if none found)
+ RE_IPV6_WORD . '(?::((?(-1)|:))?' . RE_IPV6_WORD . '){1,6}(?(-2)|^)' .
+ '|' . // contains no "::"
+ RE_IPV6_WORD . '(?::' . RE_IPV6_WORD . '){7}' .
+ ')'
+);
+// An IPv6 range is an IP address and a prefix (d1 to d128)
+define( 'RE_IPV6_RANGE', RE_IPV6_ADD . '\/' . RE_IPV6_PREFIX );
+// For IPv6 canonicalization (NOT for strict validation; these are quite lax!)
+define( 'RE_IPV6_GAP', ':(?:0+:)*(?::(?:0+:)*)?' );
+define( 'RE_IPV6_V4_PREFIX', '0*' . RE_IPV6_GAP . '(?:ffff:)?' );
+
+// This might be useful for regexps used elsewhere, matches any IPv4 or IPv6 address or network
+define( 'IP_ADDRESS_STRING',
+ '(?:' .
+ RE_IP_ADD . '(?:\/' . RE_IP_PREFIX . ')?' . // IPv4
+ '|' .
+ RE_IPV6_ADD . '(?:\/' . RE_IPV6_PREFIX . ')?' . // IPv6
+ ')'
+);
+
+/**
+ * A collection of public static functions to play with IP address
+ * and IP ranges.
+ */
+class IP {
+
+ /**
+ * Determine if a string is as valid IP address or network (CIDR prefix).
+ * SIIT IPv4-translated addresses are rejected.
+ * @note canonicalize() tries to convert translated addresses to IPv4.
+ *
+ * @param string $ip Possible IP address
+ * @return bool
+ */
+ public static function isIPAddress( $ip ) {
+ return (bool)preg_match( '/^' . IP_ADDRESS_STRING . '$/', $ip );
+ }
+
+ /**
+ * Given a string, determine if it as valid IP in IPv6 only.
+ * @note Unlike isValid(), this looks for networks too.
+ *
+ * @param string $ip Possible IP address
+ * @return bool
+ */
+ public static function isIPv6( $ip ) {
+ return (bool)preg_match( '/^' . RE_IPV6_ADD . '(?:\/' . RE_IPV6_PREFIX . ')?$/', $ip );
+ }
+
+ /**
+ * Given a string, determine if it as valid IP in IPv4 only.
+ * @note Unlike isValid(), this looks for networks too.
+ *
+ * @param string $ip Possible IP address
+ * @return bool
+ */
+ public static function isIPv4( $ip ) {
+ return (bool)preg_match( '/^' . RE_IP_ADD . '(?:\/' . RE_IP_PREFIX . ')?$/', $ip );
+ }
+
+ /**
+ * Validate an IP address. Ranges are NOT considered valid.
+ * SIIT IPv4-translated addresses are rejected.
+ * @note canonicalize() tries to convert translated addresses to IPv4.
+ *
+ * @param string $ip
+ * @return bool True if it is valid
+ */
+ public static function isValid( $ip ) {
+ return ( preg_match( '/^' . RE_IP_ADD . '$/', $ip )
+ || preg_match( '/^' . RE_IPV6_ADD . '$/', $ip ) );
+ }
+
+ /**
+ * Validate an IP range (valid address with a valid CIDR prefix).
+ * SIIT IPv4-translated addresses are rejected.
+ * @note canonicalize() tries to convert translated addresses to IPv4.
+ *
+ * @deprecated since 1.30. Use the equivalent IP::isValidRange().
+ * @param string $ipRange
+ * @return bool True if it is valid
+ */
+ public static function isValidBlock( $ipRange ) {
+ return self::isValidRange( $ipRange );
+ }
+
+ /**
+ * Validate an IP range (valid address with a valid CIDR prefix).
+ * SIIT IPv4-translated addresses are rejected.
+ * @note canonicalize() tries to convert translated addresses to IPv4.
+ *
+ * @param string $ipRange
+ * @return bool True if it is valid
+ * @since 1.30
+ */
+ public static function isValidRange( $ipRange ) {
+ return ( preg_match( '/^' . RE_IPV6_RANGE . '$/', $ipRange )
+ || preg_match( '/^' . RE_IP_RANGE . '$/', $ipRange ) );
+ }
+
+ /**
+ * Convert an IP into a verbose, uppercase, normalized form.
+ * Both IPv4 and IPv6 addresses are trimmed. Additionally,
+ * IPv6 addresses in octet notation are expanded to 8 words;
+ * IPv4 addresses have leading zeros, in each octet, removed.
+ *
+ * @param string $ip IP address in quad or octet form (CIDR or not).
+ * @return string
+ */
+ public static function sanitizeIP( $ip ) {
+ $ip = trim( $ip );
+ if ( $ip === '' ) {
+ return null;
+ }
+ /* If not an IP, just return trimmed value, since sanitizeIP() is called
+ * in a number of contexts where usernames are supplied as input.
+ */
+ if ( !self::isIPAddress( $ip ) ) {
+ return $ip;
+ }
+ if ( self::isIPv4( $ip ) ) {
+ // Remove leading 0's from octet representation of IPv4 address
+ $ip = preg_replace( '/(?:^|(?<=\.))0+(?=[1-9]|0\.|0$)/', '', $ip );
+ return $ip;
+ }
+ // Remove any whitespaces, convert to upper case
+ $ip = strtoupper( $ip );
+ // Expand zero abbreviations
+ $abbrevPos = strpos( $ip, '::' );
+ if ( $abbrevPos !== false ) {
+ // We know this is valid IPv6. Find the last index of the
+ // address before any CIDR number (e.g. "a:b:c::/24").
+ $CIDRStart = strpos( $ip, "/" );
+ $addressEnd = ( $CIDRStart !== false )
+ ? $CIDRStart - 1
+ : strlen( $ip ) - 1;
+ // If the '::' is at the beginning...
+ if ( $abbrevPos == 0 ) {
+ $repeat = '0:';
+ $extra = ( $ip == '::' ) ? '0' : ''; // for the address '::'
+ $pad = 9; // 7+2 (due to '::')
+ // If the '::' is at the end...
+ } elseif ( $abbrevPos == ( $addressEnd - 1 ) ) {
+ $repeat = ':0';
+ $extra = '';
+ $pad = 9; // 7+2 (due to '::')
+ // If the '::' is in the middle...
+ } else {
+ $repeat = ':0';
+ $extra = ':';
+ $pad = 8; // 6+2 (due to '::')
+ }
+ $ip = str_replace( '::',
+ str_repeat( $repeat, $pad - substr_count( $ip, ':' ) ) . $extra,
+ $ip
+ );
+ }
+ // Remove leading zeros from each bloc as needed
+ $ip = preg_replace( '/(^|:)0+(' . RE_IPV6_WORD . ')/', '$1$2', $ip );
+
+ return $ip;
+ }
+
+ /**
+ * Prettify an IP for display to end users.
+ * This will make it more compact and lower-case.
+ *
+ * @param string $ip
+ * @return string
+ */
+ public static function prettifyIP( $ip ) {
+ $ip = self::sanitizeIP( $ip ); // normalize (removes '::')
+ if ( self::isIPv6( $ip ) ) {
+ // Split IP into an address and a CIDR
+ if ( strpos( $ip, '/' ) !== false ) {
+ list( $ip, $cidr ) = explode( '/', $ip, 2 );
+ } else {
+ list( $ip, $cidr ) = [ $ip, '' ];
+ }
+ // Get the largest slice of words with multiple zeros
+ $offset = 0;
+ $longest = $longestPos = false;
+ while ( preg_match(
+ '!(?:^|:)0(?::0)+(?:$|:)!', $ip, $m, PREG_OFFSET_CAPTURE, $offset
+ ) ) {
+ list( $match, $pos ) = $m[0]; // full match
+ if ( strlen( $match ) > strlen( $longest ) ) {
+ $longest = $match;
+ $longestPos = $pos;
+ }
+ $offset = ( $pos + strlen( $match ) ); // advance
+ }
+ if ( $longest !== false ) {
+ // Replace this portion of the string with the '::' abbreviation
+ $ip = substr_replace( $ip, '::', $longestPos, strlen( $longest ) );
+ }
+ // Add any CIDR back on
+ if ( $cidr !== '' ) {
+ $ip = "{$ip}/{$cidr}";
+ }
+ // Convert to lower case to make it more readable
+ $ip = strtolower( $ip );
+ }
+
+ return $ip;
+ }
+
+ /**
+ * Given a host/port string, like one might find in the host part of a URL
+ * per RFC 2732, split the hostname part and the port part and return an
+ * array with an element for each. If there is no port part, the array will
+ * have false in place of the port. If the string was invalid in some way,
+ * false is returned.
+ *
+ * This was easy with IPv4 and was generally done in an ad-hoc way, but
+ * with IPv6 it's somewhat more complicated due to the need to parse the
+ * square brackets and colons.
+ *
+ * A bare IPv6 address is accepted despite the lack of square brackets.
+ *
+ * @param string $both The string with the host and port
+ * @return array|false Array normally, false on certain failures
+ */
+ public static function splitHostAndPort( $both ) {
+ if ( substr( $both, 0, 1 ) === '[' ) {
+ if ( preg_match( '/^\[(' . RE_IPV6_ADD . ')\](?::(?P<port>\d+))?$/', $both, $m ) ) {
+ if ( isset( $m['port'] ) ) {
+ return [ $m[1], intval( $m['port'] ) ];
+ } else {
+ return [ $m[1], false ];
+ }
+ } else {
+ // Square bracket found but no IPv6
+ return false;
+ }
+ }
+ $numColons = substr_count( $both, ':' );
+ if ( $numColons >= 2 ) {
+ // Is it a bare IPv6 address?
+ if ( preg_match( '/^' . RE_IPV6_ADD . '$/', $both ) ) {
+ return [ $both, false ];
+ } else {
+ // Not valid IPv6, but too many colons for anything else
+ return false;
+ }
+ }
+ if ( $numColons >= 1 ) {
+ // Host:port?
+ $bits = explode( ':', $both );
+ if ( preg_match( '/^\d+/', $bits[1] ) ) {
+ return [ $bits[0], intval( $bits[1] ) ];
+ } else {
+ // Not a valid port
+ return false;
+ }
+ }
+
+ // Plain hostname
+ return [ $both, false ];
+ }
+
+ /**
+ * Given a host name and a port, combine them into host/port string like
+ * you might find in a URL. If the host contains a colon, wrap it in square
+ * brackets like in RFC 2732. If the port matches the default port, omit
+ * the port specification
+ *
+ * @param string $host
+ * @param int $port
+ * @param bool|int $defaultPort
+ * @return string
+ */
+ public static function combineHostAndPort( $host, $port, $defaultPort = false ) {
+ if ( strpos( $host, ':' ) !== false ) {
+ $host = "[$host]";
+ }
+ if ( $defaultPort !== false && $port == $defaultPort ) {
+ return $host;
+ } else {
+ return "$host:$port";
+ }
+ }
+
+ /**
+ * Convert an IPv4 or IPv6 hexadecimal representation back to readable format
+ *
+ * @param string $hex Number, with "v6-" prefix if it is IPv6
+ * @return string Quad-dotted (IPv4) or octet notation (IPv6)
+ */
+ public static function formatHex( $hex ) {
+ if ( substr( $hex, 0, 3 ) == 'v6-' ) { // IPv6
+ return self::hexToOctet( substr( $hex, 3 ) );
+ } else { // IPv4
+ return self::hexToQuad( $hex );
+ }
+ }
+
+ /**
+ * Converts a hexadecimal number to an IPv6 address in octet notation
+ *
+ * @param string $ip_hex Pure hex (no v6- prefix)
+ * @return string (of format a:b:c:d:e:f:g:h)
+ */
+ public static function hexToOctet( $ip_hex ) {
+ // Pad hex to 32 chars (128 bits)
+ $ip_hex = str_pad( strtoupper( $ip_hex ), 32, '0', STR_PAD_LEFT );
+ // Separate into 8 words
+ $ip_oct = substr( $ip_hex, 0, 4 );
+ for ( $n = 1; $n < 8; $n++ ) {
+ $ip_oct .= ':' . substr( $ip_hex, 4 * $n, 4 );
+ }
+ // NO leading zeroes
+ $ip_oct = preg_replace( '/(^|:)0+(' . RE_IPV6_WORD . ')/', '$1$2', $ip_oct );
+
+ return $ip_oct;
+ }
+
+ /**
+ * Converts a hexadecimal number to an IPv4 address in quad-dotted notation
+ *
+ * @param string $ip_hex Pure hex
+ * @return string (of format a.b.c.d)
+ */
+ public static function hexToQuad( $ip_hex ) {
+ // Pad hex to 8 chars (32 bits)
+ $ip_hex = str_pad( strtoupper( $ip_hex ), 8, '0', STR_PAD_LEFT );
+ // Separate into four quads
+ $s = '';
+ for ( $i = 0; $i < 4; $i++ ) {
+ if ( $s !== '' ) {
+ $s .= '.';
+ }
+ $s .= base_convert( substr( $ip_hex, $i * 2, 2 ), 16, 10 );
+ }
+
+ return $s;
+ }
+
+ /**
+ * Determine if an IP address really is an IP address, and if it is public,
+ * i.e. not RFC 1918 or similar
+ *
+ * @param string $ip
+ * @return bool
+ */
+ public static function isPublic( $ip ) {
+ static $privateSet = null;
+ if ( !$privateSet ) {
+ $privateSet = new IPSet( [
+ '10.0.0.0/8', # RFC 1918 (private)
+ '172.16.0.0/12', # RFC 1918 (private)
+ '192.168.0.0/16', # RFC 1918 (private)
+ '0.0.0.0/8', # this network
+ '127.0.0.0/8', # loopback
+ 'fc00::/7', # RFC 4193 (local)
+ '0:0:0:0:0:0:0:1', # loopback
+ '169.254.0.0/16', # link-local
+ 'fe80::/10', # link-local
+ ] );
+ }
+ return !$privateSet->match( $ip );
+ }
+
+ /**
+ * Return a zero-padded upper case hexadecimal representation of an IP address.
+ *
+ * Hexadecimal addresses are used because they can easily be extended to
+ * IPv6 support. To separate the ranges, the return value from this
+ * function for an IPv6 address will be prefixed with "v6-", a non-
+ * hexadecimal string which sorts after the IPv4 addresses.
+ *
+ * @param string $ip Quad dotted/octet IP address.
+ * @return string|bool False on failure
+ */
+ public static function toHex( $ip ) {
+ if ( self::isIPv6( $ip ) ) {
+ $n = 'v6-' . self::IPv6ToRawHex( $ip );
+ } elseif ( self::isIPv4( $ip ) ) {
+ // T62035/T97897: An IP with leading 0's fails in ip2long sometimes (e.g. *.08),
+ // also double/triple 0 needs to be changed to just a single 0 for ip2long.
+ $ip = self::sanitizeIP( $ip );
+ $n = ip2long( $ip );
+ if ( $n < 0 ) {
+ $n += pow( 2, 32 );
+ # On 32-bit platforms (and on Windows), 2^32 does not fit into an int,
+ # so $n becomes a float. We convert it to string instead.
+ if ( is_float( $n ) ) {
+ $n = (string)$n;
+ }
+ }
+ if ( $n !== false ) {
+ # Floating points can handle the conversion; faster than Wikimedia\base_convert()
+ $n = strtoupper( str_pad( base_convert( $n, 10, 16 ), 8, '0', STR_PAD_LEFT ) );
+ }
+ } else {
+ $n = false;
+ }
+
+ return $n;
+ }
+
+ /**
+ * Given an IPv6 address in octet notation, returns a pure hex string.
+ *
+ * @param string $ip Octet ipv6 IP address.
+ * @return string|bool Pure hex (uppercase); false on failure
+ */
+ private static function IPv6ToRawHex( $ip ) {
+ $ip = self::sanitizeIP( $ip );
+ if ( !$ip ) {
+ return false;
+ }
+ $r_ip = '';
+ foreach ( explode( ':', $ip ) as $v ) {
+ $r_ip .= str_pad( $v, 4, 0, STR_PAD_LEFT );
+ }
+
+ return $r_ip;
+ }
+
+ /**
+ * Convert a network specification in CIDR notation
+ * to an integer network and a number of bits
+ *
+ * @param string $range IP with CIDR prefix
+ * @return array(int or string, int)
+ */
+ public static function parseCIDR( $range ) {
+ if ( self::isIPv6( $range ) ) {
+ return self::parseCIDR6( $range );
+ }
+ $parts = explode( '/', $range, 2 );
+ if ( count( $parts ) != 2 ) {
+ return [ false, false ];
+ }
+ list( $network, $bits ) = $parts;
+ $network = ip2long( $network );
+ if ( $network !== false && is_numeric( $bits ) && $bits >= 0 && $bits <= 32 ) {
+ if ( $bits == 0 ) {
+ $network = 0;
+ } else {
+ $network &= ~( ( 1 << ( 32 - $bits ) ) - 1 );
+ }
+ # Convert to unsigned
+ if ( $network < 0 ) {
+ $network += pow( 2, 32 );
+ }
+ } else {
+ $network = false;
+ $bits = false;
+ }
+
+ return [ $network, $bits ];
+ }
+
+ /**
+ * Given a string range in a number of formats,
+ * return the start and end of the range in hexadecimal.
+ *
+ * Formats are:
+ * 1.2.3.4/24 CIDR
+ * 1.2.3.4 - 1.2.3.5 Explicit range
+ * 1.2.3.4 Single IP
+ *
+ * 2001:0db8:85a3::7344/96 CIDR
+ * 2001:0db8:85a3::7344 - 2001:0db8:85a3::7344 Explicit range
+ * 2001:0db8:85a3::7344 Single IP
+ * @param string $range IP range
+ * @return array [ string, string ]
+ */
+ public static function parseRange( $range ) {
+ // CIDR notation
+ if ( strpos( $range, '/' ) !== false ) {
+ if ( self::isIPv6( $range ) ) {
+ return self::parseRange6( $range );
+ }
+ list( $network, $bits ) = self::parseCIDR( $range );
+ if ( $network === false ) {
+ $start = $end = false;
+ } else {
+ $start = sprintf( '%08X', $network );
+ $end = sprintf( '%08X', $network + pow( 2, ( 32 - $bits ) ) - 1 );
+ }
+ // Explicit range
+ } elseif ( strpos( $range, '-' ) !== false ) {
+ list( $start, $end ) = array_map( 'trim', explode( '-', $range, 2 ) );
+ if ( self::isIPv6( $start ) && self::isIPv6( $end ) ) {
+ return self::parseRange6( $range );
+ }
+ if ( self::isIPv4( $start ) && self::isIPv4( $end ) ) {
+ $start = self::toHex( $start );
+ $end = self::toHex( $end );
+ if ( $start > $end ) {
+ $start = $end = false;
+ }
+ } else {
+ $start = $end = false;
+ }
+ } else {
+ # Single IP
+ $start = $end = self::toHex( $range );
+ }
+ if ( $start === false || $end === false ) {
+ return [ false, false ];
+ } else {
+ return [ $start, $end ];
+ }
+ }
+
+ /**
+ * Convert a network specification in IPv6 CIDR notation to an
+ * integer network and a number of bits
+ *
+ * @param string $range
+ *
+ * @return array(string, int)
+ */
+ private static function parseCIDR6( $range ) {
+ # Explode into <expanded IP,range>
+ $parts = explode( '/', self::sanitizeIP( $range ), 2 );
+ if ( count( $parts ) != 2 ) {
+ return [ false, false ];
+ }
+ list( $network, $bits ) = $parts;
+ $network = self::IPv6ToRawHex( $network );
+ if ( $network !== false && is_numeric( $bits ) && $bits >= 0 && $bits <= 128 ) {
+ if ( $bits == 0 ) {
+ $network = "0";
+ } else {
+ # Native 32 bit functions WONT work here!!!
+ # Convert to a padded binary number
+ $network = Wikimedia\base_convert( $network, 16, 2, 128 );
+ # Truncate the last (128-$bits) bits and replace them with zeros
+ $network = str_pad( substr( $network, 0, $bits ), 128, 0, STR_PAD_RIGHT );
+ # Convert back to an integer
+ $network = Wikimedia\base_convert( $network, 2, 10 );
+ }
+ } else {
+ $network = false;
+ $bits = false;
+ }
+
+ return [ $network, (int)$bits ];
+ }
+
+ /**
+ * Given a string range in a number of formats, return the
+ * start and end of the range in hexadecimal. For IPv6.
+ *
+ * Formats are:
+ * 2001:0db8:85a3::7344/96 CIDR
+ * 2001:0db8:85a3::7344 - 2001:0db8:85a3::7344 Explicit range
+ * 2001:0db8:85a3::7344/96 Single IP
+ *
+ * @param string $range
+ *
+ * @return array(string, string)
+ */
+ private static function parseRange6( $range ) {
+ # Expand any IPv6 IP
+ $range = self::sanitizeIP( $range );
+ // CIDR notation...
+ if ( strpos( $range, '/' ) !== false ) {
+ list( $network, $bits ) = self::parseCIDR6( $range );
+ if ( $network === false ) {
+ $start = $end = false;
+ } else {
+ $start = Wikimedia\base_convert( $network, 10, 16, 32, false );
+ # Turn network to binary (again)
+ $end = Wikimedia\base_convert( $network, 10, 2, 128 );
+ # Truncate the last (128-$bits) bits and replace them with ones
+ $end = str_pad( substr( $end, 0, $bits ), 128, 1, STR_PAD_RIGHT );
+ # Convert to hex
+ $end = Wikimedia\base_convert( $end, 2, 16, 32, false );
+ # see toHex() comment
+ $start = "v6-$start";
+ $end = "v6-$end";
+ }
+ // Explicit range notation...
+ } elseif ( strpos( $range, '-' ) !== false ) {
+ list( $start, $end ) = array_map( 'trim', explode( '-', $range, 2 ) );
+ $start = self::toHex( $start );
+ $end = self::toHex( $end );
+ if ( $start > $end ) {
+ $start = $end = false;
+ }
+ } else {
+ # Single IP
+ $start = $end = self::toHex( $range );
+ }
+ if ( $start === false || $end === false ) {
+ return [ false, false ];
+ } else {
+ return [ $start, $end ];
+ }
+ }
+
+ /**
+ * Determine if a given IPv4/IPv6 address is in a given CIDR network
+ *
+ * @param string $addr The address to check against the given range.
+ * @param string $range The range to check the given address against.
+ * @return bool Whether or not the given address is in the given range.
+ *
+ * @note This can return unexpected results for invalid arguments!
+ * Make sure you pass a valid IP address and IP range.
+ */
+ public static function isInRange( $addr, $range ) {
+ $hexIP = self::toHex( $addr );
+ list( $start, $end ) = self::parseRange( $range );
+
+ return ( strcmp( $hexIP, $start ) >= 0 &&
+ strcmp( $hexIP, $end ) <= 0 );
+ }
+
+ /**
+ * Determines if an IP address is a list of CIDR a.b.c.d/n ranges.
+ *
+ * @since 1.25
+ *
+ * @param string $ip the IP to check
+ * @param array $ranges the IP ranges, each element a range
+ *
+ * @return bool true if the specified adress belongs to the specified range; otherwise, false.
+ */
+ public static function isInRanges( $ip, $ranges ) {
+ foreach ( $ranges as $range ) {
+ if ( self::isInRange( $ip, $range ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Convert some unusual representations of IPv4 addresses to their
+ * canonical dotted quad representation.
+ *
+ * This currently only checks a few IPV4-to-IPv6 related cases. More
+ * unusual representations may be added later.
+ *
+ * @param string $addr Something that might be an IP address
+ * @return string|null Valid dotted quad IPv4 address or null
+ */
+ public static function canonicalize( $addr ) {
+ // remove zone info (T37738)
+ $addr = preg_replace( '/\%.*/', '', $addr );
+
+ if ( self::isValid( $addr ) ) {
+ return $addr;
+ }
+ // Turn mapped addresses from ::ce:ffff:1.2.3.4 to 1.2.3.4
+ if ( strpos( $addr, ':' ) !== false && strpos( $addr, '.' ) !== false ) {
+ $addr = substr( $addr, strrpos( $addr, ':' ) + 1 );
+ if ( self::isIPv4( $addr ) ) {
+ return $addr;
+ }
+ }
+ // IPv6 loopback address
+ $m = [];
+ if ( preg_match( '/^0*' . RE_IPV6_GAP . '1$/', $addr, $m ) ) {
+ return '127.0.0.1';
+ }
+ // IPv4-mapped and IPv4-compatible IPv6 addresses
+ if ( preg_match( '/^' . RE_IPV6_V4_PREFIX . '(' . RE_IP_ADD . ')$/i', $addr, $m ) ) {
+ return $m[1];
+ }
+ if ( preg_match( '/^' . RE_IPV6_V4_PREFIX . RE_IPV6_WORD .
+ ':' . RE_IPV6_WORD . '$/i', $addr, $m )
+ ) {
+ return long2ip( ( hexdec( $m[1] ) << 16 ) + hexdec( $m[2] ) );
+ }
+
+ return null; // give up
+ }
+
+ /**
+ * Gets rid of unneeded numbers in quad-dotted/octet IP strings
+ * For example, 127.111.113.151/24 -> 127.111.113.0/24
+ * @param string $range IP address to normalize
+ * @return string
+ */
+ public static function sanitizeRange( $range ) {
+ list( /*...*/, $bits ) = self::parseCIDR( $range );
+ list( $start, /*...*/ ) = self::parseRange( $range );
+ $start = self::formatHex( $start );
+ if ( $bits === false ) {
+ return $start; // wasn't actually a range
+ }
+
+ return "$start/$bits";
+ }
+
+ /**
+ * Returns the subnet of a given IP
+ *
+ * @param string $ip
+ * @return string|false
+ */
+ public static function getSubnet( $ip ) {
+ $matches = [];
+ $subnet = false;
+ if ( self::isIPv6( $ip ) ) {
+ $parts = self::parseRange( "$ip/64" );
+ $subnet = $parts[0];
+ } elseif ( preg_match( '/^(\d+\.\d+\.\d+)\.\d+$/', $ip, $matches ) ) {
+ // IPv4
+ $subnet = $matches[1];
+ }
+ return $subnet;
+ }
+}
diff --git a/www/wiki/includes/libs/JavaScriptMinifier.php b/www/wiki/includes/libs/JavaScriptMinifier.php
new file mode 100644
index 00000000..141a5153
--- /dev/null
+++ b/www/wiki/includes/libs/JavaScriptMinifier.php
@@ -0,0 +1,615 @@
+<?php
+// @codingStandardsIgnoreFile File external to MediaWiki. Ignore coding conventions checks.
+/**
+ * JavaScript Minifier
+ *
+ * @file
+ * @author Paul Copperman <paul.copperman@gmail.com>
+ * @license Choose any of Apache, MIT, GPL, LGPL
+ */
+
+/**
+ * This class is meant to safely minify javascript code, while leaving syntactically correct
+ * programs intact. Other libraries, such as JSMin require a certain coding style to work
+ * correctly. OTOH, libraries like jsminplus, that do parse the code correctly are rather
+ * slow, because they construct a complete parse tree before outputting the code minified.
+ * So this class is meant to allow arbitrary (but syntactically correct) input, while being
+ * fast enough to be used for on-the-fly minifying.
+ */
+class JavaScriptMinifier {
+
+ /* Class constants */
+ /* Parsing states.
+ * The state machine is only necessary to decide whether to parse a slash as division
+ * operator or as regexp literal.
+ * States are named after the next expected item. We only distinguish states when the
+ * distinction is relevant for our purpose.
+ */
+ const STATEMENT = 0;
+ const CONDITION = 1;
+ const PROPERTY_ASSIGNMENT = 2;
+ const EXPRESSION = 3;
+ const EXPRESSION_NO_NL = 4; // only relevant for semicolon insertion
+ const EXPRESSION_OP = 5;
+ const EXPRESSION_FUNC = 6;
+ const EXPRESSION_TERNARY = 7; // used to determine the role of a colon
+ const EXPRESSION_TERNARY_OP = 8;
+ const EXPRESSION_TERNARY_FUNC = 9;
+ const PAREN_EXPRESSION = 10; // expression which is not on the top level
+ const PAREN_EXPRESSION_OP = 11;
+ const PAREN_EXPRESSION_FUNC = 12;
+ const PROPERTY_EXPRESSION = 13; // expression which is within an object literal
+ const PROPERTY_EXPRESSION_OP = 14;
+ const PROPERTY_EXPRESSION_FUNC = 15;
+
+ /* Token types */
+ const TYPE_UN_OP = 1; // unary operators
+ const TYPE_INCR_OP = 2; // ++ and --
+ const TYPE_BIN_OP = 3; // binary operators
+ const TYPE_ADD_OP = 4; // + and - which can be either unary or binary ops
+ const TYPE_HOOK = 5; // ?
+ const TYPE_COLON = 6; // :
+ const TYPE_COMMA = 7; // ,
+ const TYPE_SEMICOLON = 8; // ;
+ const TYPE_BRACE_OPEN = 9; // {
+ const TYPE_BRACE_CLOSE = 10; // }
+ const TYPE_PAREN_OPEN = 11; // ( and [
+ const TYPE_PAREN_CLOSE = 12; // ) and ]
+ const TYPE_RETURN = 13; // keywords: break, continue, return, throw
+ const TYPE_IF = 14; // keywords: catch, for, with, switch, while, if
+ const TYPE_DO = 15; // keywords: case, var, finally, else, do, try
+ const TYPE_FUNC = 16; // keywords: function
+ const TYPE_LITERAL = 17; // all literals, identifiers and unrecognised tokens
+
+ // Sanity limit to avoid excessive memory usage
+ const STACK_LIMIT = 1000;
+
+ /* Static functions */
+
+ /**
+ * Returns minified JavaScript code.
+ *
+ * NOTE: $maxLineLength isn't a strict maximum. Longer lines will be produced when
+ * literals (e.g. quoted strings) longer than $maxLineLength are encountered
+ * or when required to guard against semicolon insertion.
+ *
+ * @param string $s JavaScript code to minify
+ * @param bool $statementsOnOwnLine Whether to put each statement on its own line
+ * @param int $maxLineLength Maximum length of a single line, or -1 for no maximum.
+ * @return String Minified code
+ */
+ public static function minify( $s, $statementsOnOwnLine = false, $maxLineLength = 1000 ) {
+ // First we declare a few tables that contain our parsing rules
+
+ // $opChars : characters, which can be combined without whitespace in between them
+ $opChars = array(
+ '!' => true,
+ '"' => true,
+ '%' => true,
+ '&' => true,
+ "'" => true,
+ '(' => true,
+ ')' => true,
+ '*' => true,
+ '+' => true,
+ ',' => true,
+ '-' => true,
+ '.' => true,
+ '/' => true,
+ ':' => true,
+ ';' => true,
+ '<' => true,
+ '=' => true,
+ '>' => true,
+ '?' => true,
+ '[' => true,
+ ']' => true,
+ '^' => true,
+ '{' => true,
+ '|' => true,
+ '}' => true,
+ '~' => true
+ );
+
+ // $tokenTypes : maps keywords and operators to their corresponding token type
+ $tokenTypes = array(
+ '!' => self::TYPE_UN_OP,
+ '~' => self::TYPE_UN_OP,
+ 'delete' => self::TYPE_UN_OP,
+ 'new' => self::TYPE_UN_OP,
+ 'typeof' => self::TYPE_UN_OP,
+ 'void' => self::TYPE_UN_OP,
+ '++' => self::TYPE_INCR_OP,
+ '--' => self::TYPE_INCR_OP,
+ '!=' => self::TYPE_BIN_OP,
+ '!==' => self::TYPE_BIN_OP,
+ '%' => self::TYPE_BIN_OP,
+ '%=' => self::TYPE_BIN_OP,
+ '&' => self::TYPE_BIN_OP,
+ '&&' => self::TYPE_BIN_OP,
+ '&=' => self::TYPE_BIN_OP,
+ '*' => self::TYPE_BIN_OP,
+ '*=' => self::TYPE_BIN_OP,
+ '+=' => self::TYPE_BIN_OP,
+ '-=' => self::TYPE_BIN_OP,
+ '.' => self::TYPE_BIN_OP,
+ '/' => self::TYPE_BIN_OP,
+ '/=' => self::TYPE_BIN_OP,
+ '<' => self::TYPE_BIN_OP,
+ '<<' => self::TYPE_BIN_OP,
+ '<<=' => self::TYPE_BIN_OP,
+ '<=' => self::TYPE_BIN_OP,
+ '=' => self::TYPE_BIN_OP,
+ '==' => self::TYPE_BIN_OP,
+ '===' => self::TYPE_BIN_OP,
+ '>' => self::TYPE_BIN_OP,
+ '>=' => self::TYPE_BIN_OP,
+ '>>' => self::TYPE_BIN_OP,
+ '>>=' => self::TYPE_BIN_OP,
+ '>>>' => self::TYPE_BIN_OP,
+ '>>>=' => self::TYPE_BIN_OP,
+ '^' => self::TYPE_BIN_OP,
+ '^=' => self::TYPE_BIN_OP,
+ '|' => self::TYPE_BIN_OP,
+ '|=' => self::TYPE_BIN_OP,
+ '||' => self::TYPE_BIN_OP,
+ 'in' => self::TYPE_BIN_OP,
+ 'instanceof' => self::TYPE_BIN_OP,
+ '+' => self::TYPE_ADD_OP,
+ '-' => self::TYPE_ADD_OP,
+ '?' => self::TYPE_HOOK,
+ ':' => self::TYPE_COLON,
+ ',' => self::TYPE_COMMA,
+ ';' => self::TYPE_SEMICOLON,
+ '{' => self::TYPE_BRACE_OPEN,
+ '}' => self::TYPE_BRACE_CLOSE,
+ '(' => self::TYPE_PAREN_OPEN,
+ '[' => self::TYPE_PAREN_OPEN,
+ ')' => self::TYPE_PAREN_CLOSE,
+ ']' => self::TYPE_PAREN_CLOSE,
+ 'break' => self::TYPE_RETURN,
+ 'continue' => self::TYPE_RETURN,
+ 'return' => self::TYPE_RETURN,
+ 'throw' => self::TYPE_RETURN,
+ 'catch' => self::TYPE_IF,
+ 'for' => self::TYPE_IF,
+ 'if' => self::TYPE_IF,
+ 'switch' => self::TYPE_IF,
+ 'while' => self::TYPE_IF,
+ 'with' => self::TYPE_IF,
+ 'case' => self::TYPE_DO,
+ 'do' => self::TYPE_DO,
+ 'else' => self::TYPE_DO,
+ 'finally' => self::TYPE_DO,
+ 'try' => self::TYPE_DO,
+ 'var' => self::TYPE_DO,
+ 'function' => self::TYPE_FUNC
+ );
+
+ // $goto : This is the main table for our state machine. For every state/token pair
+ // the following state is defined. When no rule exists for a given pair,
+ // the state is left unchanged.
+ $goto = array(
+ self::STATEMENT => array(
+ self::TYPE_UN_OP => self::EXPRESSION,
+ self::TYPE_INCR_OP => self::EXPRESSION,
+ self::TYPE_ADD_OP => self::EXPRESSION,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION,
+ self::TYPE_RETURN => self::EXPRESSION_NO_NL,
+ self::TYPE_IF => self::CONDITION,
+ self::TYPE_FUNC => self::CONDITION,
+ self::TYPE_LITERAL => self::EXPRESSION_OP
+ ),
+ self::CONDITION => array(
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION
+ ),
+ self::PROPERTY_ASSIGNMENT => array(
+ self::TYPE_COLON => self::PROPERTY_EXPRESSION,
+ self::TYPE_BRACE_OPEN => self::STATEMENT
+ ),
+ self::EXPRESSION => array(
+ self::TYPE_SEMICOLON => self::STATEMENT,
+ self::TYPE_BRACE_OPEN => self::PROPERTY_ASSIGNMENT,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION,
+ self::TYPE_FUNC => self::EXPRESSION_FUNC,
+ self::TYPE_LITERAL => self::EXPRESSION_OP
+ ),
+ self::EXPRESSION_NO_NL => array(
+ self::TYPE_SEMICOLON => self::STATEMENT,
+ self::TYPE_BRACE_OPEN => self::PROPERTY_ASSIGNMENT,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION,
+ self::TYPE_FUNC => self::EXPRESSION_FUNC,
+ self::TYPE_LITERAL => self::EXPRESSION_OP
+ ),
+ self::EXPRESSION_OP => array(
+ self::TYPE_BIN_OP => self::EXPRESSION,
+ self::TYPE_ADD_OP => self::EXPRESSION,
+ self::TYPE_HOOK => self::EXPRESSION_TERNARY,
+ self::TYPE_COLON => self::STATEMENT,
+ self::TYPE_COMMA => self::EXPRESSION,
+ self::TYPE_SEMICOLON => self::STATEMENT,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION
+ ),
+ self::EXPRESSION_FUNC => array(
+ self::TYPE_BRACE_OPEN => self::STATEMENT
+ ),
+ self::EXPRESSION_TERNARY => array(
+ self::TYPE_BRACE_OPEN => self::PROPERTY_ASSIGNMENT,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION,
+ self::TYPE_FUNC => self::EXPRESSION_TERNARY_FUNC,
+ self::TYPE_LITERAL => self::EXPRESSION_TERNARY_OP
+ ),
+ self::EXPRESSION_TERNARY_OP => array(
+ self::TYPE_BIN_OP => self::EXPRESSION_TERNARY,
+ self::TYPE_ADD_OP => self::EXPRESSION_TERNARY,
+ self::TYPE_HOOK => self::EXPRESSION_TERNARY,
+ self::TYPE_COMMA => self::EXPRESSION_TERNARY,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION
+ ),
+ self::EXPRESSION_TERNARY_FUNC => array(
+ self::TYPE_BRACE_OPEN => self::STATEMENT
+ ),
+ self::PAREN_EXPRESSION => array(
+ self::TYPE_BRACE_OPEN => self::PROPERTY_ASSIGNMENT,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION,
+ self::TYPE_FUNC => self::PAREN_EXPRESSION_FUNC,
+ self::TYPE_LITERAL => self::PAREN_EXPRESSION_OP
+ ),
+ self::PAREN_EXPRESSION_OP => array(
+ self::TYPE_BIN_OP => self::PAREN_EXPRESSION,
+ self::TYPE_ADD_OP => self::PAREN_EXPRESSION,
+ self::TYPE_HOOK => self::PAREN_EXPRESSION,
+ self::TYPE_COLON => self::PAREN_EXPRESSION,
+ self::TYPE_COMMA => self::PAREN_EXPRESSION,
+ self::TYPE_SEMICOLON => self::PAREN_EXPRESSION,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION
+ ),
+ self::PAREN_EXPRESSION_FUNC => array(
+ self::TYPE_BRACE_OPEN => self::STATEMENT
+ ),
+ self::PROPERTY_EXPRESSION => array(
+ self::TYPE_BRACE_OPEN => self::PROPERTY_ASSIGNMENT,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION,
+ self::TYPE_FUNC => self::PROPERTY_EXPRESSION_FUNC,
+ self::TYPE_LITERAL => self::PROPERTY_EXPRESSION_OP
+ ),
+ self::PROPERTY_EXPRESSION_OP => array(
+ self::TYPE_BIN_OP => self::PROPERTY_EXPRESSION,
+ self::TYPE_ADD_OP => self::PROPERTY_EXPRESSION,
+ self::TYPE_HOOK => self::PROPERTY_EXPRESSION,
+ self::TYPE_COMMA => self::PROPERTY_ASSIGNMENT,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION
+ ),
+ self::PROPERTY_EXPRESSION_FUNC => array(
+ self::TYPE_BRACE_OPEN => self::STATEMENT
+ )
+ );
+
+ // $push : This table contains the rules for when to push a state onto the stack.
+ // The pushed state is the state to return to when the corresponding
+ // closing token is found
+ $push = array(
+ self::STATEMENT => array(
+ self::TYPE_BRACE_OPEN => self::STATEMENT,
+ self::TYPE_PAREN_OPEN => self::EXPRESSION_OP
+ ),
+ self::CONDITION => array(
+ self::TYPE_PAREN_OPEN => self::STATEMENT
+ ),
+ self::PROPERTY_ASSIGNMENT => array(
+ self::TYPE_BRACE_OPEN => self::PROPERTY_ASSIGNMENT
+ ),
+ self::EXPRESSION => array(
+ self::TYPE_BRACE_OPEN => self::EXPRESSION_OP,
+ self::TYPE_PAREN_OPEN => self::EXPRESSION_OP
+ ),
+ self::EXPRESSION_NO_NL => array(
+ self::TYPE_BRACE_OPEN => self::EXPRESSION_OP,
+ self::TYPE_PAREN_OPEN => self::EXPRESSION_OP
+ ),
+ self::EXPRESSION_OP => array(
+ self::TYPE_HOOK => self::EXPRESSION,
+ self::TYPE_PAREN_OPEN => self::EXPRESSION_OP
+ ),
+ self::EXPRESSION_FUNC => array(
+ self::TYPE_BRACE_OPEN => self::EXPRESSION_OP
+ ),
+ self::EXPRESSION_TERNARY => array(
+ self::TYPE_BRACE_OPEN => self::EXPRESSION_TERNARY_OP,
+ self::TYPE_PAREN_OPEN => self::EXPRESSION_TERNARY_OP
+ ),
+ self::EXPRESSION_TERNARY_OP => array(
+ self::TYPE_HOOK => self::EXPRESSION_TERNARY,
+ self::TYPE_PAREN_OPEN => self::EXPRESSION_TERNARY_OP
+ ),
+ self::EXPRESSION_TERNARY_FUNC => array(
+ self::TYPE_BRACE_OPEN => self::EXPRESSION_TERNARY_OP
+ ),
+ self::PAREN_EXPRESSION => array(
+ self::TYPE_BRACE_OPEN => self::PAREN_EXPRESSION_OP,
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION_OP
+ ),
+ self::PAREN_EXPRESSION_OP => array(
+ self::TYPE_PAREN_OPEN => self::PAREN_EXPRESSION_OP
+ ),
+ self::PAREN_EXPRESSION_FUNC => array(
+ self::TYPE_BRACE_OPEN => self::PAREN_EXPRESSION_OP
+ ),
+ self::PROPERTY_EXPRESSION => array(
+ self::TYPE_BRACE_OPEN => self::PROPERTY_EXPRESSION_OP,
+ self::TYPE_PAREN_OPEN => self::PROPERTY_EXPRESSION_OP
+ ),
+ self::PROPERTY_EXPRESSION_OP => array(
+ self::TYPE_PAREN_OPEN => self::PROPERTY_EXPRESSION_OP
+ ),
+ self::PROPERTY_EXPRESSION_FUNC => array(
+ self::TYPE_BRACE_OPEN => self::PROPERTY_EXPRESSION_OP
+ )
+ );
+
+ // $pop : Rules for when to pop a state from the stack
+ $pop = array(
+ self::STATEMENT => array( self::TYPE_BRACE_CLOSE => true ),
+ self::PROPERTY_ASSIGNMENT => array( self::TYPE_BRACE_CLOSE => true ),
+ self::EXPRESSION => array( self::TYPE_BRACE_CLOSE => true ),
+ self::EXPRESSION_NO_NL => array( self::TYPE_BRACE_CLOSE => true ),
+ self::EXPRESSION_OP => array( self::TYPE_BRACE_CLOSE => true ),
+ self::EXPRESSION_TERNARY_OP => array( self::TYPE_COLON => true ),
+ self::PAREN_EXPRESSION => array( self::TYPE_PAREN_CLOSE => true ),
+ self::PAREN_EXPRESSION_OP => array( self::TYPE_PAREN_CLOSE => true ),
+ self::PROPERTY_EXPRESSION => array( self::TYPE_BRACE_CLOSE => true ),
+ self::PROPERTY_EXPRESSION_OP => array( self::TYPE_BRACE_CLOSE => true )
+ );
+
+ // $semicolon : Rules for when a semicolon insertion is appropriate
+ $semicolon = array(
+ self::EXPRESSION_NO_NL => array(
+ self::TYPE_UN_OP => true,
+ self::TYPE_INCR_OP => true,
+ self::TYPE_ADD_OP => true,
+ self::TYPE_BRACE_OPEN => true,
+ self::TYPE_PAREN_OPEN => true,
+ self::TYPE_RETURN => true,
+ self::TYPE_IF => true,
+ self::TYPE_DO => true,
+ self::TYPE_FUNC => true,
+ self::TYPE_LITERAL => true
+ ),
+ self::EXPRESSION_OP => array(
+ self::TYPE_UN_OP => true,
+ self::TYPE_INCR_OP => true,
+ self::TYPE_BRACE_OPEN => true,
+ self::TYPE_RETURN => true,
+ self::TYPE_IF => true,
+ self::TYPE_DO => true,
+ self::TYPE_FUNC => true,
+ self::TYPE_LITERAL => true
+ )
+ );
+
+ // Rules for when newlines should be inserted if
+ // $statementsOnOwnLine is enabled.
+ // $newlineBefore is checked before switching state,
+ // $newlineAfter is checked after
+ $newlineBefore = array(
+ self::STATEMENT => array(
+ self::TYPE_BRACE_CLOSE => true,
+ ),
+ );
+ $newlineAfter = array(
+ self::STATEMENT => array(
+ self::TYPE_BRACE_OPEN => true,
+ self::TYPE_PAREN_CLOSE => true,
+ self::TYPE_SEMICOLON => true,
+ ),
+ );
+
+ // $divStates : Contains all states that can be followed by a division operator
+ $divStates = array(
+ self::EXPRESSION_OP => true,
+ self::EXPRESSION_TERNARY_OP => true,
+ self::PAREN_EXPRESSION_OP => true,
+ self::PROPERTY_EXPRESSION_OP => true
+ );
+
+ // Here's where the minifying takes place: Loop through the input, looking for tokens
+ // and output them to $out, taking actions to the above defined rules when appropriate.
+ $out = '';
+ $pos = 0;
+ $length = strlen( $s );
+ $lineLength = 0;
+ $newlineFound = true;
+ $state = self::STATEMENT;
+ $stack = array();
+ $last = ';'; // Pretend that we have seen a semicolon yet
+ while( $pos < $length ) {
+ // First, skip over any whitespace and multiline comments, recording whether we
+ // found any newline character
+ $skip = strspn( $s, " \t\n\r\xb\xc", $pos );
+ if( !$skip ) {
+ $ch = $s[$pos];
+ if( $ch === '/' && substr( $s, $pos, 2 ) === '/*' ) {
+ // Multiline comment. Search for the end token or EOT.
+ $end = strpos( $s, '*/', $pos + 2 );
+ $skip = $end === false ? $length - $pos : $end - $pos + 2;
+ }
+ }
+ if( $skip ) {
+ // The semicolon insertion mechanism needs to know whether there was a newline
+ // between two tokens, so record it now.
+ if( !$newlineFound && strcspn( $s, "\r\n", $pos, $skip ) !== $skip ) {
+ $newlineFound = true;
+ }
+ $pos += $skip;
+ continue;
+ }
+ // Handle C++-style comments and html comments, which are treated as single line
+ // comments by the browser, regardless of whether the end tag is on the same line.
+ // Handle --> the same way, but only if it's at the beginning of the line
+ if( ( $ch === '/' && substr( $s, $pos, 2 ) === '//' )
+ || ( $ch === '<' && substr( $s, $pos, 4 ) === '<!--' )
+ || ( $ch === '-' && $newlineFound && substr( $s, $pos, 3 ) === '-->' )
+ ) {
+ $pos += strcspn( $s, "\r\n", $pos );
+ continue;
+ }
+
+ // Find out which kind of token we're handling. $end will point past the end of it.
+ $end = $pos + 1;
+ // Handle string literals
+ if( $ch === "'" || $ch === '"' ) {
+ // Search to the end of the string literal, skipping over backslash escapes
+ $search = $ch . '\\';
+ do{
+ $end += strcspn( $s, $search, $end ) + 2;
+ } while( $end - 2 < $length && $s[$end - 2] === '\\' );
+ $end--;
+ // We have to distinguish between regexp literals and division operators
+ // A division operator is only possible in certain states
+ } elseif( $ch === '/' && !isset( $divStates[$state] ) ) {
+ // Regexp literal, search to the end, skipping over backslash escapes and
+ // character classes
+ for( ; ; ) {
+ do{
+ $end += strcspn( $s, '/[\\', $end ) + 2;
+ } while( $end - 2 < $length && $s[$end - 2] === '\\' );
+ $end--;
+ if( $end - 1 >= $length || $s[$end - 1] === '/' ) {
+ break;
+ }
+ do{
+ $end += strcspn( $s, ']\\', $end ) + 2;
+ } while( $end - 2 < $length && $s[$end - 2] === '\\' );
+ $end--;
+ };
+ // Search past the regexp modifiers (gi)
+ while( $end < $length && ctype_alpha( $s[$end] ) ) {
+ $end++;
+ }
+ } elseif(
+ $ch === '0'
+ && ($pos + 1 < $length) && ($s[$pos + 1] === 'x' || $s[$pos + 1] === 'X' )
+ ) {
+ // Hex numeric literal
+ $end++; // x or X
+ $len = strspn( $s, '0123456789ABCDEFabcdef', $end );
+ if ( !$len ) {
+ return self::parseError($s, $pos, 'Expected a hexadecimal number but found ' . substr( $s, $pos, 5 ) . '...' );
+ }
+ $end += $len;
+ } elseif(
+ ctype_digit( $ch )
+ || ( $ch === '.' && $pos + 1 < $length && ctype_digit( $s[$pos + 1] ) )
+ ) {
+ $end += strspn( $s, '0123456789', $end );
+ $decimal = strspn( $s, '.', $end );
+ if ($decimal) {
+ if ( $decimal > 2 ) {
+ return self::parseError($s, $end, 'The number has too many decimal points' );
+ }
+ $end += strspn( $s, '0123456789', $end + 1 ) + $decimal;
+ }
+ $exponent = strspn( $s, 'eE', $end );
+ if( $exponent ) {
+ if ( $exponent > 1 ) {
+ return self::parseError($s, $end, 'Number with several E' );
+ }
+ $end++;
+
+ // + sign is optional; - sign is required.
+ $end += strspn( $s, '-+', $end );
+ $len = strspn( $s, '0123456789', $end );
+ if ( !$len ) {
+ return self::parseError($s, $pos, 'No decimal digits after e, how many zeroes should be added?' );
+ }
+ $end += $len;
+ }
+ } elseif( isset( $opChars[$ch] ) ) {
+ // Punctuation character. Search for the longest matching operator.
+ while(
+ $end < $length
+ && isset( $tokenTypes[substr( $s, $pos, $end - $pos + 1 )] )
+ ) {
+ $end++;
+ }
+ } else {
+ // Identifier or reserved word. Search for the end by excluding whitespace and
+ // punctuation.
+ $end += strcspn( $s, " \t\n.;,=<>+-{}()[]?:*/%'\"!&|^~\xb\xc\r", $end );
+ }
+
+ // Now get the token type from our type array
+ $token = substr( $s, $pos, $end - $pos ); // so $end - $pos == strlen( $token )
+ $type = isset( $tokenTypes[$token] ) ? $tokenTypes[$token] : self::TYPE_LITERAL;
+
+ if( $newlineFound && isset( $semicolon[$state][$type] ) ) {
+ // This token triggers the semicolon insertion mechanism of javascript. While we
+ // could add the ; token here ourselves, keeping the newline has a few advantages.
+ $out .= "\n";
+ $state = self::STATEMENT;
+ $lineLength = 0;
+ } elseif( $maxLineLength > 0 && $lineLength + $end - $pos > $maxLineLength &&
+ !isset( $semicolon[$state][$type] ) && $type !== self::TYPE_INCR_OP )
+ {
+ // This line would get too long if we added $token, so add a newline first.
+ // Only do this if it won't trigger semicolon insertion and if it won't
+ // put a postfix increment operator on its own line, which is illegal in js.
+ $out .= "\n";
+ $lineLength = 0;
+ // Check, whether we have to separate the token from the last one with whitespace
+ } elseif( !isset( $opChars[$last] ) && !isset( $opChars[$ch] ) ) {
+ $out .= ' ';
+ $lineLength++;
+ // Don't accidentally create ++, -- or // tokens
+ } elseif( $last === $ch && ( $ch === '+' || $ch === '-' || $ch === '/' ) ) {
+ $out .= ' ';
+ $lineLength++;
+ }
+ if (
+ $type === self::TYPE_LITERAL
+ && ( $token === 'true' || $token === 'false' )
+ && ( $state === self::EXPRESSION || $state === self::PROPERTY_EXPRESSION )
+ && $last !== '.'
+ ) {
+ $token = ( $token === 'true' ) ? '!0' : '!1';
+ }
+
+ $out .= $token;
+ $lineLength += $end - $pos; // += strlen( $token )
+ $last = $s[$end - 1];
+ $pos = $end;
+ $newlineFound = false;
+
+ // Output a newline after the token if required
+ // This is checked before AND after switching state
+ $newlineAdded = false;
+ if ( $statementsOnOwnLine && !$newlineAdded && isset( $newlineBefore[$state][$type] ) ) {
+ $out .= "\n";
+ $lineLength = 0;
+ $newlineAdded = true;
+ }
+
+ // Now that we have output our token, transition into the new state.
+ if( isset( $push[$state][$type] ) && count( $stack ) < self::STACK_LIMIT ) {
+ $stack[] = $push[$state][$type];
+ }
+ if( $stack && isset( $pop[$state][$type] ) ) {
+ $state = array_pop( $stack );
+ } elseif( isset( $goto[$state][$type] ) ) {
+ $state = $goto[$state][$type];
+ }
+
+ // Check for newline insertion again
+ if ( $statementsOnOwnLine && !$newlineAdded && isset( $newlineAfter[$state][$type] ) ) {
+ $out .= "\n";
+ $lineLength = 0;
+ }
+ }
+ return $out;
+ }
+
+ static function parseError($fullJavascript, $position, $errorMsg) {
+ // TODO: Handle the error: trigger_error, throw exception, return false...
+ return false;
+ }
+}
diff --git a/www/wiki/includes/libs/MWCryptHash.php b/www/wiki/includes/libs/MWCryptHash.php
new file mode 100644
index 00000000..f9b71729
--- /dev/null
+++ b/www/wiki/includes/libs/MWCryptHash.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Utility functions for generating hashes
+ *
+ * This is based in part on Drupal code as well as what we used in our own code
+ * prior to introduction of this class, by way of MWCryptRand.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class MWCryptHash {
+ /**
+ * The hash algorithm being used
+ */
+ protected static $algo = null;
+
+ /**
+ * The number of bytes outputted by the hash algorithm
+ */
+ protected static $hashLength = [
+ true => null,
+ false => null,
+ ];
+
+ /**
+ * Decide on the best acceptable hash algorithm we have available for hash()
+ * @return string A hash algorithm
+ */
+ public static function hashAlgo() {
+ if ( !is_null( self::$algo ) ) {
+ return self::$algo;
+ }
+
+ $algos = hash_algos();
+ $preference = [ 'whirlpool', 'sha256', 'sha1', 'md5' ];
+
+ foreach ( $preference as $algorithm ) {
+ if ( in_array( $algorithm, $algos ) ) {
+ self::$algo = $algorithm;
+
+ return self::$algo;
+ }
+ }
+
+ // We only reach here if no acceptable hash is found in the list, this should
+ // be a technical impossibility since most of php's hash list is fixed and
+ // some of the ones we list are available as their own native functions
+ // But since we already require at least 5.2 and hash() was default in
+ // 5.1.2 we don't bother falling back to methods like sha1 and md5.
+ throw new DomainException( "Could not find an acceptable hashing function in hash_algos()" );
+ }
+
+ /**
+ * Return the byte-length output of the hash algorithm we are
+ * using in self::hash and self::hmac.
+ *
+ * @param bool $raw True to return the length for binary data, false to
+ * return for hex-encoded
+ * @return int Number of bytes the hash outputs
+ */
+ public static function hashLength( $raw = true ) {
+ $raw = (bool)$raw;
+ if ( is_null( self::$hashLength[$raw] ) ) {
+ self::$hashLength[$raw] = strlen( self::hash( '', $raw ) );
+ }
+
+ return self::$hashLength[$raw];
+ }
+
+ /**
+ * Generate an acceptably unstable one-way-hash of some text
+ * making use of the best hash algorithm that we have available.
+ *
+ * @param string $data
+ * @param bool $raw True to return binary data, false to return it hex-encoded
+ * @return string A hash of the data
+ */
+ public static function hash( $data, $raw = true ) {
+ return hash( self::hashAlgo(), $data, $raw );
+ }
+
+ /**
+ * Generate an acceptably unstable one-way-hmac of some text
+ * making use of the best hash algorithm that we have available.
+ *
+ * @param string $data
+ * @param string $key
+ * @param bool $raw True to return binary data, false to return it hex-encoded
+ * @return string An hmac hash of the data + key
+ */
+ public static function hmac( $data, $key, $raw = true ) {
+ if ( !is_string( $key ) ) {
+ // a fatal error in HHVM; an exception will at least give us a stack trace
+ throw new InvalidArgumentException( 'Invalid key type: ' . gettype( $key ) );
+ }
+ return hash_hmac( self::hashAlgo(), $data, $key, $raw );
+ }
+
+}
diff --git a/www/wiki/includes/libs/MWMessagePack.php b/www/wiki/includes/libs/MWMessagePack.php
new file mode 100644
index 00000000..a9da3660
--- /dev/null
+++ b/www/wiki/includes/libs/MWMessagePack.php
@@ -0,0 +1,189 @@
+<?php
+/**
+ * MessagePack serializer
+ *
+ * MessagePack is a space-efficient binary data interchange format. This
+ * class provides a pack() method that encodes native PHP values as MessagePack
+ * binary strings. The implementation is derived from msgpack-php.
+ *
+ * Copyright (c) 2013 Ori Livneh <ori@wikimedia.org>
+ * Copyright (c) 2011 OnlineCity <https://github.com/onlinecity/msgpack-php>.
+ *
+ * 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.
+ *
+ * @see <http://msgpack.org/>
+ * @see <http://wiki.msgpack.org/display/MSGPACK/Format+specification>
+ *
+ * @since 1.23
+ * @file
+ */
+class MWMessagePack {
+ /** @var bool|null Whether current system is bigendian. **/
+ public static $bigendian = null;
+
+ /**
+ * Encode a value using MessagePack
+ *
+ * This method supports null, boolean, integer, float, string and array
+ * (both indexed and associative) types. Object serialization is not
+ * supported.
+ *
+ * @param mixed $value
+ * @return string
+ * @throws InvalidArgumentException if $value is an unsupported type or too long a string
+ */
+ public static function pack( $value ) {
+ if ( self::$bigendian === null ) {
+ self::$bigendian = pack( 'S', 1 ) === pack( 'n', 1 );
+ }
+
+ switch ( gettype( $value ) ) {
+ case 'NULL':
+ return "\xC0";
+
+ case 'boolean':
+ return $value ? "\xC3" : "\xC2";
+
+ case 'double':
+ case 'float':
+ return self::$bigendian
+ ? "\xCB" . pack( 'd', $value )
+ : "\xCB" . strrev( pack( 'd', $value ) );
+
+ case 'string':
+ $length = strlen( $value );
+ if ( $length < 32 ) {
+ return pack( 'Ca*', 0xA0 | $length, $value );
+ } elseif ( $length <= 0xFFFF ) {
+ return pack( 'Cna*', 0xDA, $length, $value );
+ } elseif ( $length <= 0xFFFFFFFF ) {
+ return pack( 'CNa*', 0xDB, $length, $value );
+ }
+ throw new InvalidArgumentException( __METHOD__
+ . ": string too long (length: $length; max: 4294967295)" );
+
+ case 'integer':
+ if ( $value >= 0 ) {
+ if ( $value <= 0x7F ) {
+ // positive fixnum
+ return chr( $value );
+ }
+ if ( $value <= 0xFF ) {
+ // uint8
+ return pack( 'CC', 0xCC, $value );
+ }
+ if ( $value <= 0xFFFF ) {
+ // uint16
+ return pack( 'Cn', 0xCD, $value );
+ }
+ if ( $value <= 0xFFFFFFFF ) {
+ // uint32
+ return pack( 'CN', 0xCE, $value );
+ }
+ if ( $value <= 0xFFFFFFFFFFFFFFFF ) {
+ // uint64
+ $hi = ( $value & 0xFFFFFFFF00000000 ) >> 32;
+ $lo = $value & 0xFFFFFFFF;
+ return self::$bigendian
+ ? pack( 'CNN', 0xCF, $lo, $hi )
+ : pack( 'CNN', 0xCF, $hi, $lo );
+ }
+ } else {
+ if ( $value >= -32 ) {
+ // negative fixnum
+ return pack( 'c', $value );
+ }
+ if ( $value >= -0x80 ) {
+ // int8
+ return pack( 'Cc', 0xD0, $value );
+ }
+ if ( $value >= -0x8000 ) {
+ // int16
+ $p = pack( 's', $value );
+ return self::$bigendian
+ ? pack( 'Ca2', 0xD1, $p )
+ : pack( 'Ca2', 0xD1, strrev( $p ) );
+ }
+ if ( $value >= -0x80000000 ) {
+ // int32
+ $p = pack( 'l', $value );
+ return self::$bigendian
+ ? pack( 'Ca4', 0xD2, $p )
+ : pack( 'Ca4', 0xD2, strrev( $p ) );
+ }
+ if ( $value >= -0x8000000000000000 ) {
+ // int64
+ // pack() does not support 64-bit ints either so pack into two 32-bits
+ $p1 = pack( 'l', $value & 0xFFFFFFFF );
+ $p2 = pack( 'l', ( $value >> 32 ) & 0xFFFFFFFF );
+ return self::$bigendian
+ ? pack( 'Ca4a4', 0xD3, $p1, $p2 )
+ : pack( 'Ca4a4', 0xD3, strrev( $p2 ), strrev( $p1 ) );
+ }
+ }
+ throw new InvalidArgumentException( __METHOD__ . ": invalid integer '$value'" );
+
+ case 'array':
+ $buffer = '';
+ $length = count( $value );
+ if ( $length > 0xFFFFFFFF ) {
+ throw new InvalidArgumentException( __METHOD__
+ . ": array too long (length: $length, max: 4294967295)" );
+ }
+
+ $index = 0;
+ foreach ( $value as $k => $v ) {
+ if ( $index !== $k || $index === $length ) {
+ break;
+ } else {
+ $index++;
+ }
+ }
+ $associative = $index !== $length;
+
+ if ( $associative ) {
+ if ( $length < 16 ) {
+ $buffer .= pack( 'C', 0x80 | $length );
+ } elseif ( $length <= 0xFFFF ) {
+ $buffer .= pack( 'Cn', 0xDE, $length );
+ } else {
+ $buffer .= pack( 'CN', 0xDF, $length );
+ }
+ foreach ( $value as $k => $v ) {
+ $buffer .= self::pack( $k );
+ $buffer .= self::pack( $v );
+ }
+ } else {
+ if ( $length < 16 ) {
+ $buffer .= pack( 'C', 0x90 | $length );
+ } elseif ( $length <= 0xFFFF ) {
+ $buffer .= pack( 'Cn', 0xDC, $length );
+ } else {
+ $buffer .= pack( 'CN', 0xDD, $length );
+ }
+ foreach ( $value as $v ) {
+ $buffer .= self::pack( $v );
+ }
+ }
+ return $buffer;
+
+ default:
+ throw new InvalidArgumentException( __METHOD__ . ': unsupported type ' . gettype( $value ) );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/MapCacheLRU.php b/www/wiki/includes/libs/MapCacheLRU.php
new file mode 100644
index 00000000..db6869bd
--- /dev/null
+++ b/www/wiki/includes/libs/MapCacheLRU.php
@@ -0,0 +1,159 @@
+<?php
+/**
+ * Per-process memory cache for storing items.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+use Wikimedia\Assert\Assert;
+
+/**
+ * Handles a simple LRU key/value map with a maximum number of entries
+ *
+ * Use ProcessCacheLRU if hierarchical purging is needed or objects can become stale
+ *
+ * @see ProcessCacheLRU
+ * @ingroup Cache
+ * @since 1.23
+ */
+class MapCacheLRU {
+ /** @var array */
+ protected $cache = []; // (key => value)
+
+ protected $maxCacheKeys; // integer; max entries
+
+ /**
+ * @param int $maxKeys Maximum number of entries allowed (min 1).
+ * @throws Exception When $maxCacheKeys is not an int or not above zero.
+ */
+ public function __construct( $maxKeys ) {
+ Assert::parameterType( 'integer', $maxKeys, '$maxKeys' );
+ Assert::parameter( $maxKeys > 0, '$maxKeys', 'must be above zero' );
+
+ $this->maxCacheKeys = $maxKeys;
+ }
+
+ /**
+ * Set a key/value pair.
+ * This will prune the cache if it gets too large based on LRU.
+ * If the item is already set, it will be pushed to the top of the cache.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return void
+ */
+ public function set( $key, $value ) {
+ if ( $this->has( $key ) ) {
+ $this->ping( $key );
+ } elseif ( count( $this->cache ) >= $this->maxCacheKeys ) {
+ reset( $this->cache );
+ $evictKey = key( $this->cache );
+ unset( $this->cache[$evictKey] );
+ }
+ $this->cache[$key] = $value;
+ }
+
+ /**
+ * Check if a key exists
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function has( $key ) {
+ if ( !is_int( $key ) && !is_string( $key ) ) {
+ throw new MWException( __METHOD__ . ' called with invalid key. Must be string or integer.' );
+ }
+ return array_key_exists( $key, $this->cache );
+ }
+
+ /**
+ * Get the value for a key.
+ * This returns null if the key is not set.
+ * If the item is already set, it will be pushed to the top of the cache.
+ *
+ * @param string $key
+ * @return mixed Returns null if the key was not found
+ */
+ public function get( $key ) {
+ if ( !$this->has( $key ) ) {
+ return null;
+ }
+
+ $this->ping( $key );
+
+ return $this->cache[$key];
+ }
+
+ /**
+ * @return array
+ * @since 1.25
+ */
+ public function getAllKeys() {
+ return array_keys( $this->cache );
+ }
+
+ /**
+ * Get an item with the given key, producing and setting it if not found.
+ *
+ * If the callback returns false, then nothing is stored.
+ *
+ * @since 1.28
+ * @param string $key
+ * @param callable $callback Callback that will produce the value
+ * @return mixed The cached value if found or the result of $callback otherwise
+ */
+ public function getWithSetCallback( $key, callable $callback ) {
+ if ( $this->has( $key ) ) {
+ $value = $this->get( $key );
+ } else {
+ $value = call_user_func( $callback );
+ if ( $value !== false ) {
+ $this->set( $key, $value );
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * Clear one or several cache entries, or all cache entries
+ *
+ * @param string|array $keys
+ * @return void
+ */
+ public function clear( $keys = null ) {
+ if ( $keys === null ) {
+ $this->cache = [];
+ } else {
+ foreach ( (array)$keys as $key ) {
+ unset( $this->cache[$key] );
+ }
+ }
+ }
+
+ /**
+ * Push an entry to the top of the cache
+ *
+ * @param string $key
+ */
+ protected function ping( $key ) {
+ $item = $this->cache[$key];
+ unset( $this->cache[$key] );
+ $this->cache[$key] = $item;
+ }
+}
diff --git a/www/wiki/includes/libs/MappedIterator.php b/www/wiki/includes/libs/MappedIterator.php
new file mode 100644
index 00000000..d60af343
--- /dev/null
+++ b/www/wiki/includes/libs/MappedIterator.php
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Convenience class for generating iterators from iterators.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Convenience class for generating iterators from iterators.
+ *
+ * @since 1.21
+ */
+class MappedIterator extends FilterIterator {
+ /** @var callable */
+ protected $vCallback;
+ /** @var callable */
+ protected $aCallback;
+ /** @var array */
+ protected $cache = [];
+
+ protected $rewound = false; // boolean; whether rewind() has been called
+
+ /**
+ * Build an new iterator from a base iterator by having the former wrap the
+ * later, returning the result of "value" callback for each current() invocation.
+ * The callback takes the result of current() on the base iterator as an argument.
+ * The keys of the base iterator are reused verbatim.
+ *
+ * An "accept" callback can also be provided which will be called for each value in
+ * the base iterator (post-callback) and will return true if that value should be
+ * included in iteration of the MappedIterator (otherwise it will be filtered out).
+ *
+ * @param Iterator|Array $iter
+ * @param callable $vCallback Value transformation callback
+ * @param array $options Options map (includes "accept") (since 1.22)
+ * @throws UnexpectedValueException
+ */
+ public function __construct( $iter, $vCallback, array $options = [] ) {
+ if ( is_array( $iter ) ) {
+ $baseIterator = new ArrayIterator( $iter );
+ } elseif ( $iter instanceof Iterator ) {
+ $baseIterator = $iter;
+ } else {
+ throw new UnexpectedValueException( "Invalid base iterator provided." );
+ }
+ parent::__construct( $baseIterator );
+ $this->vCallback = $vCallback;
+ $this->aCallback = isset( $options['accept'] ) ? $options['accept'] : null;
+ }
+
+ public function next() {
+ $this->cache = [];
+ parent::next();
+ }
+
+ public function rewind() {
+ $this->rewound = true;
+ $this->cache = [];
+ parent::rewind();
+ }
+
+ public function accept() {
+ $value = call_user_func( $this->vCallback, $this->getInnerIterator()->current() );
+ $ok = ( $this->aCallback ) ? call_user_func( $this->aCallback, $value ) : true;
+ if ( $ok ) {
+ $this->cache['current'] = $value;
+ }
+
+ return $ok;
+ }
+
+ public function key() {
+ $this->init();
+
+ return parent::key();
+ }
+
+ public function valid() {
+ $this->init();
+
+ return parent::valid();
+ }
+
+ public function current() {
+ $this->init();
+ if ( parent::valid() ) {
+ return $this->cache['current'];
+ } else {
+ return null; // out of range
+ }
+ }
+
+ /**
+ * Obviate the usual need for rewind() before using a FilterIterator in a manual loop
+ */
+ protected function init() {
+ if ( !$this->rewound ) {
+ $this->rewind();
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/MemoizedCallable.php b/www/wiki/includes/libs/MemoizedCallable.php
new file mode 100644
index 00000000..14462f1d
--- /dev/null
+++ b/www/wiki/includes/libs/MemoizedCallable.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Ori Livneh
+ */
+
+/**
+ * APC-backed and APCu-backed function memoization
+ *
+ * This class provides memoization for pure functions. A function is pure
+ * if its result value depends on nothing other than its input parameters
+ * and if invoking it does not cause any side-effects.
+ *
+ * The first invocation of the memoized callable with a particular set of
+ * arguments will be delegated to the underlying callable. Repeat invocations
+ * with the same input parameters will be served from APC or APCu.
+ *
+ * @par Example:
+ * @code
+ * $memoizedStrrev = new MemoizedCallable( 'range' );
+ * $memoizedStrrev->invoke( 5, 8 ); // result: array( 5, 6, 7, 8 )
+ * $memoizedStrrev->invokeArgs( array( 5, 8 ) ); // same
+ * MemoizedCallable::call( 'range', array( 5, 8 ) ); // same
+ * @endcode
+ *
+ * @since 1.27
+ */
+class MemoizedCallable {
+
+ /** @var callable */
+ private $callable;
+
+ /** @var string Unique name of callable; used for cache keys. */
+ private $callableName;
+
+ /**
+ * @throws InvalidArgumentException if $callable is not a callable.
+ * @param callable $callable Function or method to memoize.
+ * @param int $ttl TTL in seconds. Defaults to 3600 (1hr). Capped at 86400 (24h).
+ */
+ public function __construct( $callable, $ttl = 3600 ) {
+ if ( !is_callable( $callable, false, $this->callableName ) ) {
+ throw new InvalidArgumentException(
+ 'Argument 1 passed to MemoizedCallable::__construct() must ' .
+ 'be an instance of callable; ' . gettype( $callable ) . ' given'
+ );
+ }
+
+ if ( $this->callableName === 'Closure::__invoke' ) {
+ // Differentiate anonymous functions from one another
+ $this->callableName .= uniqid();
+ }
+
+ $this->callable = $callable;
+ $this->ttl = min( max( $ttl, 1 ), 86400 );
+ }
+
+ /**
+ * Fetch the result of a previous invocation from APC or APCu.
+ *
+ * @param string $key
+ * @param bool &$success
+ * @return bool
+ */
+ protected function fetchResult( $key, &$success ) {
+ $success = false;
+ if ( function_exists( 'apc_fetch' ) ) {
+ return apc_fetch( $key, $success );
+ } elseif ( function_exists( 'apcu_fetch' ) ) {
+ return apcu_fetch( $key, $success );
+ }
+ return false;
+ }
+
+ /**
+ * Store the result of an invocation in APC or APCu.
+ *
+ * @param string $key
+ * @param mixed $result
+ */
+ protected function storeResult( $key, $result ) {
+ if ( function_exists( 'apc_store' ) ) {
+ apc_store( $key, $result, $this->ttl );
+ } elseif ( function_exists( 'apcu_store' ) ) {
+ apcu_store( $key, $result, $this->ttl );
+ }
+ }
+
+ /**
+ * Invoke the memoized function or method.
+ *
+ * @throws InvalidArgumentException If parameters list contains non-scalar items.
+ * @param array $args Parameters for memoized function or method.
+ * @return mixed The memoized callable's return value.
+ */
+ public function invokeArgs( array $args = [] ) {
+ foreach ( $args as $arg ) {
+ if ( $arg !== null && !is_scalar( $arg ) ) {
+ throw new InvalidArgumentException(
+ 'MemoizedCallable::invoke() called with non-scalar ' .
+ 'argument'
+ );
+ }
+ }
+
+ $hash = md5( serialize( $args ) );
+ $key = __CLASS__ . ':' . $this->callableName . ':' . $hash;
+ $success = false;
+ $result = $this->fetchResult( $key, $success );
+ if ( !$success ) {
+ $result = call_user_func_array( $this->callable, $args );
+ $this->storeResult( $key, $result );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Invoke the memoized function or method.
+ *
+ * Like MemoizedCallable::invokeArgs(), but variadic.
+ *
+ * @param mixed $params,... Parameters for memoized function or method.
+ * @return mixed The memoized callable's return value.
+ */
+ public function invoke() {
+ return $this->invokeArgs( func_get_args() );
+ }
+
+ /**
+ * Shortcut method for creating a MemoizedCallable and invoking it
+ * with the specified arguments.
+ *
+ * @param callable $callable
+ * @param array $args
+ * @param int $ttl
+ * @return mixed
+ */
+ public static function call( $callable, array $args = [], $ttl = 3600 ) {
+ $instance = new self( $callable, $ttl );
+ return $instance->invokeArgs( $args );
+ }
+}
diff --git a/www/wiki/includes/libs/MessageSpecifier.php b/www/wiki/includes/libs/MessageSpecifier.php
new file mode 100644
index 00000000..b417f299
--- /dev/null
+++ b/www/wiki/includes/libs/MessageSpecifier.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+interface MessageSpecifier {
+ /**
+ * Returns the message key
+ *
+ * If a list of multiple possible keys was supplied to the constructor, this method may
+ * return any of these keys. After the message has been fetched, this method will return
+ * the key that was actually used to fetch the message.
+ *
+ * @return string
+ */
+ public function getKey();
+
+ /**
+ * Returns the message parameters
+ *
+ * @return array
+ */
+ public function getParams();
+}
diff --git a/www/wiki/includes/libs/MultiHttpClient.php b/www/wiki/includes/libs/MultiHttpClient.php
new file mode 100644
index 00000000..16168e6b
--- /dev/null
+++ b/www/wiki/includes/libs/MultiHttpClient.php
@@ -0,0 +1,449 @@
+<?php
+/**
+ * HTTP service client
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * Class to handle concurrent HTTP requests
+ *
+ * HTTP request maps are arrays that use the following format:
+ * - method : GET/HEAD/PUT/POST/DELETE
+ * - url : HTTP/HTTPS URL
+ * - query : <query parameter field/value associative array> (uses RFC 3986)
+ * - headers : <header name/value associative array>
+ * - body : source to get the HTTP request body from;
+ * this can simply be a string (always), a resource for
+ * PUT requests, and a field/value array for POST request;
+ * array bodies are encoded as multipart/form-data and strings
+ * use application/x-www-form-urlencoded (headers sent automatically)
+ * - stream : resource to stream the HTTP response body to
+ * - proxy : HTTP proxy to use
+ * - flags : map of boolean flags which supports:
+ * - relayResponseHeaders : write out header via header()
+ * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'.
+ *
+ * @since 1.23
+ */
+class MultiHttpClient implements LoggerAwareInterface {
+ /** @var resource */
+ protected $multiHandle = null; // curl_multi handle
+ /** @var string|null SSL certificates path */
+ protected $caBundlePath;
+ /** @var int */
+ protected $connTimeout = 10;
+ /** @var int */
+ protected $reqTimeout = 300;
+ /** @var bool */
+ protected $usePipelining = false;
+ /** @var int */
+ protected $maxConnsPerHost = 50;
+ /** @var string|null proxy */
+ protected $proxy;
+ /** @var string */
+ protected $userAgent = 'wikimedia/multi-http-client v1.0';
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /**
+ * @param array $options
+ * - connTimeout : default connection timeout (seconds)
+ * - reqTimeout : default request timeout (seconds)
+ * - proxy : HTTP proxy to use
+ * - usePipelining : whether to use HTTP pipelining if possible (for all hosts)
+ * - maxConnsPerHost : maximum number of concurrent connections (per host)
+ * - userAgent : The User-Agent header value to send
+ * @throws Exception
+ */
+ public function __construct( array $options ) {
+ if ( isset( $options['caBundlePath'] ) ) {
+ $this->caBundlePath = $options['caBundlePath'];
+ if ( !file_exists( $this->caBundlePath ) ) {
+ throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath );
+ }
+ }
+ static $opts = [
+ 'connTimeout', 'reqTimeout', 'usePipelining', 'maxConnsPerHost',
+ 'proxy', 'userAgent', 'logger'
+ ];
+ foreach ( $opts as $key ) {
+ if ( isset( $options[$key] ) ) {
+ $this->$key = $options[$key];
+ }
+ }
+ if ( $this->logger === null ) {
+ $this->logger = new NullLogger;
+ }
+ }
+
+ /**
+ * Execute an HTTP(S) request
+ *
+ * This method returns a response map of:
+ * - code : HTTP response code or 0 if there was a serious cURL error
+ * - reason : HTTP response reason (empty if there was a serious cURL error)
+ * - headers : <header name/value associative array>
+ * - body : HTTP response body or resource (if "stream" was set)
+ * - error : Any cURL error string
+ * The map also stores integer-indexed copies of these values. This lets callers do:
+ * @code
+ * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $http->run( $req );
+ * @endcode
+ * @param array $req HTTP request array
+ * @param array $opts
+ * - connTimeout : connection timeout per request (seconds)
+ * - reqTimeout : post-connection timeout per request (seconds)
+ * @return array Response array for request
+ */
+ public function run( array $req, array $opts = [] ) {
+ return $this->runMulti( [ $req ], $opts )[0]['response'];
+ }
+
+ /**
+ * Execute a set of HTTP(S) requests concurrently
+ *
+ * The maps are returned by this method with the 'response' field set to a map of:
+ * - code : HTTP response code or 0 if there was a serious cURL error
+ * - reason : HTTP response reason (empty if there was a serious cURL error)
+ * - headers : <header name/value associative array>
+ * - body : HTTP response body or resource (if "stream" was set)
+ * - error : Any cURL error string
+ * The map also stores integer-indexed copies of these values. This lets callers do:
+ * @code
+ * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response'];
+ * @endcode
+ * All headers in the 'headers' field are normalized to use lower case names.
+ * This is true for the request headers and the response headers. Integer-indexed
+ * method/URL entries will also be changed to use the corresponding string keys.
+ *
+ * @param array $reqs Map of HTTP request arrays
+ * @param array $opts
+ * - connTimeout : connection timeout per request (seconds)
+ * - reqTimeout : post-connection timeout per request (seconds)
+ * - usePipelining : whether to use HTTP pipelining if possible
+ * - maxConnsPerHost : maximum number of concurrent connections (per host)
+ * @return array $reqs With response array populated for each
+ * @throws Exception
+ */
+ public function runMulti( array $reqs, array $opts = [] ) {
+ $chm = $this->getCurlMulti();
+
+ // Normalize $reqs and add all of the required cURL handles...
+ $handles = [];
+ foreach ( $reqs as $index => &$req ) {
+ $req['response'] = [
+ 'code' => 0,
+ 'reason' => '',
+ 'headers' => [],
+ 'body' => '',
+ 'error' => ''
+ ];
+ if ( isset( $req[0] ) ) {
+ $req['method'] = $req[0]; // short-form
+ unset( $req[0] );
+ }
+ if ( isset( $req[1] ) ) {
+ $req['url'] = $req[1]; // short-form
+ unset( $req[1] );
+ }
+ if ( !isset( $req['method'] ) ) {
+ throw new Exception( "Request has no 'method' field set." );
+ } elseif ( !isset( $req['url'] ) ) {
+ throw new Exception( "Request has no 'url' field set." );
+ }
+ $this->logger->debug( "{$req['method']}: {$req['url']}" );
+ $req['query'] = isset( $req['query'] ) ? $req['query'] : [];
+ $headers = []; // normalized headers
+ if ( isset( $req['headers'] ) ) {
+ foreach ( $req['headers'] as $name => $value ) {
+ $headers[strtolower( $name )] = $value;
+ }
+ }
+ $req['headers'] = $headers;
+ if ( !isset( $req['body'] ) ) {
+ $req['body'] = '';
+ $req['headers']['content-length'] = 0;
+ }
+ $req['flags'] = isset( $req['flags'] ) ? $req['flags'] : [];
+ $handles[$index] = $this->getCurlHandle( $req, $opts );
+ if ( count( $reqs ) > 1 ) {
+ // https://github.com/guzzle/guzzle/issues/349
+ curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true );
+ }
+ }
+ unset( $req ); // don't assign over this by accident
+
+ $indexes = array_keys( $reqs );
+ if ( isset( $opts['usePipelining'] ) ) {
+ curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$opts['usePipelining'] );
+ }
+ if ( isset( $opts['maxConnsPerHost'] ) ) {
+ // Keep these sockets around as they may be needed later in the request
+ curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$opts['maxConnsPerHost'] );
+ }
+
+ // @TODO: use a per-host rolling handle window (e.g. CURLMOPT_MAX_HOST_CONNECTIONS)
+ $batches = array_chunk( $indexes, $this->maxConnsPerHost );
+ $infos = [];
+
+ foreach ( $batches as $batch ) {
+ // Attach all cURL handles for this batch
+ foreach ( $batch as $index ) {
+ curl_multi_add_handle( $chm, $handles[$index] );
+ }
+ // Execute the cURL handles concurrently...
+ $active = null; // handles still being processed
+ do {
+ // Do any available work...
+ do {
+ $mrc = curl_multi_exec( $chm, $active );
+ $info = curl_multi_info_read( $chm );
+ if ( $info !== false ) {
+ $infos[(int)$info['handle']] = $info;
+ }
+ } while ( $mrc == CURLM_CALL_MULTI_PERFORM );
+ // Wait (if possible) for available work...
+ if ( $active > 0 && $mrc == CURLM_OK ) {
+ if ( curl_multi_select( $chm, 10 ) == -1 ) {
+ // PHP bug 63411; https://curl.haxx.se/libcurl/c/curl_multi_fdset.html
+ usleep( 5000 ); // 5ms
+ }
+ }
+ } while ( $active > 0 && $mrc == CURLM_OK );
+ }
+
+ // Remove all of the added cURL handles and check for errors...
+ foreach ( $reqs as $index => &$req ) {
+ $ch = $handles[$index];
+ curl_multi_remove_handle( $chm, $ch );
+
+ if ( isset( $infos[(int)$ch] ) ) {
+ $info = $infos[(int)$ch];
+ $errno = $info['result'];
+ if ( $errno !== 0 ) {
+ $req['response']['error'] = "(curl error: $errno)";
+ if ( function_exists( 'curl_strerror' ) ) {
+ $req['response']['error'] .= " " . curl_strerror( $errno );
+ }
+ $this->logger->warning( "Error fetching URL \"{$req['url']}\": " .
+ $req['response']['error'] );
+ }
+ } else {
+ $req['response']['error'] = "(curl error: no status set)";
+ }
+
+ // For convenience with the list() operator
+ $req['response'][0] = $req['response']['code'];
+ $req['response'][1] = $req['response']['reason'];
+ $req['response'][2] = $req['response']['headers'];
+ $req['response'][3] = $req['response']['body'];
+ $req['response'][4] = $req['response']['error'];
+ curl_close( $ch );
+ // Close any string wrapper file handles
+ if ( isset( $req['_closeHandle'] ) ) {
+ fclose( $req['_closeHandle'] );
+ unset( $req['_closeHandle'] );
+ }
+ }
+ unset( $req ); // don't assign over this by accident
+
+ // Restore the default settings
+ curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$this->usePipelining );
+ curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost );
+
+ return $reqs;
+ }
+
+ /**
+ * @param array &$req HTTP request map
+ * @param array $opts
+ * - connTimeout : default connection timeout
+ * - reqTimeout : default request timeout
+ * @return resource
+ * @throws Exception
+ */
+ protected function getCurlHandle( array &$req, array $opts = [] ) {
+ $ch = curl_init();
+
+ curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT,
+ isset( $opts['connTimeout'] ) ? $opts['connTimeout'] : $this->connTimeout );
+ curl_setopt( $ch, CURLOPT_PROXY, isset( $req['proxy'] ) ? $req['proxy'] : $this->proxy );
+ curl_setopt( $ch, CURLOPT_TIMEOUT,
+ isset( $opts['reqTimeout'] ) ? $opts['reqTimeout'] : $this->reqTimeout );
+ curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 );
+ curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 );
+ curl_setopt( $ch, CURLOPT_HEADER, 0 );
+ if ( !is_null( $this->caBundlePath ) ) {
+ curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
+ curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath );
+ }
+ curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
+
+ $url = $req['url'];
+ $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 );
+ if ( $query != '' ) {
+ $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query";
+ }
+ curl_setopt( $ch, CURLOPT_URL, $url );
+
+ curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] );
+ if ( $req['method'] === 'HEAD' ) {
+ curl_setopt( $ch, CURLOPT_NOBODY, 1 );
+ }
+
+ if ( $req['method'] === 'PUT' ) {
+ curl_setopt( $ch, CURLOPT_PUT, 1 );
+ if ( is_resource( $req['body'] ) ) {
+ curl_setopt( $ch, CURLOPT_INFILE, $req['body'] );
+ if ( isset( $req['headers']['content-length'] ) ) {
+ curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] );
+ } elseif ( isset( $req['headers']['transfer-encoding'] ) &&
+ $req['headers']['transfer-encoding'] === 'chunks'
+ ) {
+ curl_setopt( $ch, CURLOPT_UPLOAD, true );
+ } else {
+ throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." );
+ }
+ } elseif ( $req['body'] !== '' ) {
+ $fp = fopen( "php://temp", "wb+" );
+ fwrite( $fp, $req['body'], strlen( $req['body'] ) );
+ rewind( $fp );
+ curl_setopt( $ch, CURLOPT_INFILE, $fp );
+ curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) );
+ $req['_closeHandle'] = $fp; // remember to close this later
+ } else {
+ curl_setopt( $ch, CURLOPT_INFILESIZE, 0 );
+ }
+ curl_setopt( $ch, CURLOPT_READFUNCTION,
+ function ( $ch, $fd, $length ) {
+ $data = fread( $fd, $length );
+ $len = strlen( $data );
+ return $data;
+ }
+ );
+ } elseif ( $req['method'] === 'POST' ) {
+ curl_setopt( $ch, CURLOPT_POST, 1 );
+ // Don't interpret POST parameters starting with '@' as file uploads, because this
+ // makes it impossible to POST plain values starting with '@' (and causes security
+ // issues potentially exposing the contents of local files).
+ // The PHP manual says this option was introduced in PHP 5.5 defaults to true in PHP 5.6,
+ // but we support lower versions, and the option doesn't exist in HHVM 5.6.99.
+ if ( defined( 'CURLOPT_SAFE_UPLOAD' ) ) {
+ curl_setopt( $ch, CURLOPT_SAFE_UPLOAD, true );
+ } elseif ( is_array( $req['body'] ) ) {
+ // In PHP 5.2 and later, '@' is interpreted as a file upload if POSTFIELDS
+ // is an array, but not if it's a string. So convert $req['body'] to a string
+ // for safety.
+ $req['body'] = http_build_query( $req['body'] );
+ }
+ curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] );
+ } else {
+ if ( is_resource( $req['body'] ) || $req['body'] !== '' ) {
+ throw new Exception( "HTTP body specified for a non PUT/POST request." );
+ }
+ $req['headers']['content-length'] = 0;
+ }
+
+ if ( !isset( $req['headers']['user-agent'] ) ) {
+ $req['headers']['user-agent'] = $this->userAgent;
+ }
+
+ $headers = [];
+ foreach ( $req['headers'] as $name => $value ) {
+ if ( strpos( $name, ': ' ) ) {
+ throw new Exception( "Headers cannot have ':' in the name." );
+ }
+ $headers[] = $name . ': ' . trim( $value );
+ }
+ curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
+
+ curl_setopt( $ch, CURLOPT_HEADERFUNCTION,
+ function ( $ch, $header ) use ( &$req ) {
+ if ( !empty( $req['flags']['relayResponseHeaders'] ) ) {
+ header( $header );
+ }
+ $length = strlen( $header );
+ $matches = [];
+ if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) {
+ $req['response']['code'] = (int)$matches[2];
+ $req['response']['reason'] = trim( $matches[3] );
+ return $length;
+ }
+ if ( strpos( $header, ":" ) === false ) {
+ return $length;
+ }
+ list( $name, $value ) = explode( ":", $header, 2 );
+ $req['response']['headers'][strtolower( $name )] = trim( $value );
+ return $length;
+ }
+ );
+
+ if ( isset( $req['stream'] ) ) {
+ // Don't just use CURLOPT_FILE as that might give:
+ // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE*
+ // The callback here handles both normal files and php://temp handles.
+ curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
+ function ( $ch, $data ) use ( &$req ) {
+ return fwrite( $req['stream'], $data );
+ }
+ );
+ } else {
+ curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
+ function ( $ch, $data ) use ( &$req ) {
+ $req['response']['body'] .= $data;
+ return strlen( $data );
+ }
+ );
+ }
+
+ return $ch;
+ }
+
+ /**
+ * @return resource
+ */
+ protected function getCurlMulti() {
+ if ( !$this->multiHandle ) {
+ $cmh = curl_multi_init();
+ curl_multi_setopt( $cmh, CURLMOPT_PIPELINING, (int)$this->usePipelining );
+ curl_multi_setopt( $cmh, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost );
+ $this->multiHandle = $cmh;
+ }
+ return $this->multiHandle;
+ }
+
+ /**
+ * Register a logger
+ *
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ function __destruct() {
+ if ( $this->multiHandle ) {
+ curl_multi_close( $this->multiHandle );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/ObjectFactory.php b/www/wiki/includes/libs/ObjectFactory.php
new file mode 100644
index 00000000..6c47c3ca
--- /dev/null
+++ b/www/wiki/includes/libs/ObjectFactory.php
@@ -0,0 +1,198 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Construct objects from configuration instructions.
+ *
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ */
+class ObjectFactory {
+
+ /**
+ * Instantiate an object based on a specification array.
+ *
+ * The specification array must contain a 'class' key with string value
+ * that specifies the class name to instantiate or a 'factory' key with
+ * a callable (is_callable() === true). It can optionally contain
+ * an 'args' key that provides arguments to pass to the
+ * constructor/callable.
+ *
+ * Values in the arguments collection which are Closure instances will be
+ * expanded by invoking them with no arguments before passing the
+ * resulting value on to the constructor/callable. This can be used to
+ * pass IDatabase instances or other live objects to the
+ * constructor/callable. This behavior can be suppressed by adding
+ * closure_expansion => false to the specification.
+ *
+ * The specification may also contain a 'calls' key that describes method
+ * calls to make on the newly created object before returning it. This
+ * pattern is often known as "setter injection". The value of this key is
+ * expected to be an associative array with method names as keys and
+ * argument lists as values. The argument list will be expanded (or not)
+ * in the same way as the 'args' key for the main object.
+ *
+ * @param array $spec Object specification
+ * @return object
+ * @throws InvalidArgumentException when object specification does not
+ * contain 'class' or 'factory' keys
+ * @throws ReflectionException when 'args' are supplied and 'class'
+ * constructor is non-public or non-existent
+ */
+ public static function getObjectFromSpec( $spec ) {
+ $args = isset( $spec['args'] ) ? $spec['args'] : [];
+ $expandArgs = !isset( $spec['closure_expansion'] ) ||
+ $spec['closure_expansion'] === true;
+
+ if ( $expandArgs ) {
+ $args = static::expandClosures( $args );
+ }
+
+ if ( isset( $spec['class'] ) ) {
+ $clazz = $spec['class'];
+ if ( !$args ) {
+ $obj = new $clazz();
+ } else {
+ $obj = static::constructClassInstance( $clazz, $args );
+ }
+ } elseif ( isset( $spec['factory'] ) ) {
+ $obj = call_user_func_array( $spec['factory'], $args );
+ } else {
+ throw new InvalidArgumentException(
+ 'Provided specification lacks both factory and class parameters.'
+ );
+ }
+
+ if ( isset( $spec['calls'] ) && is_array( $spec['calls'] ) ) {
+ // Call additional methods on the newly created object
+ foreach ( $spec['calls'] as $method => $margs ) {
+ if ( $expandArgs ) {
+ $margs = static::expandClosures( $margs );
+ }
+ call_user_func_array( [ $obj, $method ], $margs );
+ }
+ }
+
+ return $obj;
+ }
+
+ /**
+ * Iterate a list and call any closures it contains.
+ *
+ * @param array $list List of things
+ * @return array List with any Closures replaced with their output
+ */
+ protected static function expandClosures( $list ) {
+ return array_map( function ( $value ) {
+ if ( is_object( $value ) && $value instanceof Closure ) {
+ // If $value is a Closure, call it.
+ return $value();
+ } else {
+ return $value;
+ }
+ }, $list );
+ }
+
+ /**
+ * Construct an instance of the given class using the given arguments.
+ *
+ * PHP's `call_user_func_array()` doesn't work with object construction so
+ * we have to use other measures. Starting with PHP 5.6.0 we could use the
+ * "splat" operator (`...`) to unpack the array into an argument list.
+ * Sadly there is no way to conditionally include a syntax construct like
+ * a new operator in a way that allows older versions of PHP to still
+ * parse the file. Instead, we will try a loop unrolling technique that
+ * works for 0-10 arguments. If we are passed 11 or more arguments we will
+ * take the performance penalty of using
+ * `ReflectionClass::newInstanceArgs()` to construct the desired object.
+ *
+ * @param string $clazz Class name
+ * @param array $args Constructor arguments
+ * @return mixed Constructed instance
+ */
+ public static function constructClassInstance( $clazz, $args ) {
+ // $args should be a non-associative array; show nice error if that's not the case
+ if ( $args && array_keys( $args ) !== range( 0, count( $args ) - 1 ) ) {
+ throw new InvalidArgumentException( __METHOD__ . ': $args cannot be an associative array' );
+ }
+
+ // TODO: when PHP min version supported is >=5.6.0 replace this
+ // with `return new $clazz( ... $args );`.
+ $obj = null;
+ switch ( count( $args ) ) {
+ case 0:
+ $obj = new $clazz();
+ break;
+ case 1:
+ $obj = new $clazz( $args[0] );
+ break;
+ case 2:
+ $obj = new $clazz( $args[0], $args[1] );
+ break;
+ case 3:
+ $obj = new $clazz( $args[0], $args[1], $args[2] );
+ break;
+ case 4:
+ $obj = new $clazz( $args[0], $args[1], $args[2], $args[3] );
+ break;
+ case 5:
+ $obj = new $clazz(
+ $args[0], $args[1], $args[2], $args[3], $args[4]
+ );
+ break;
+ case 6:
+ $obj = new $clazz(
+ $args[0], $args[1], $args[2], $args[3], $args[4],
+ $args[5]
+ );
+ break;
+ case 7:
+ $obj = new $clazz(
+ $args[0], $args[1], $args[2], $args[3], $args[4],
+ $args[5], $args[6]
+ );
+ break;
+ case 8:
+ $obj = new $clazz(
+ $args[0], $args[1], $args[2], $args[3], $args[4],
+ $args[5], $args[6], $args[7]
+ );
+ break;
+ case 9:
+ $obj = new $clazz(
+ $args[0], $args[1], $args[2], $args[3], $args[4],
+ $args[5], $args[6], $args[7], $args[8]
+ );
+ break;
+ case 10:
+ $obj = new $clazz(
+ $args[0], $args[1], $args[2], $args[3], $args[4],
+ $args[5], $args[6], $args[7], $args[8], $args[9]
+ );
+ break;
+ default:
+ // Fall back to using ReflectionClass and curse the developer
+ // who decided that 11+ args was a reasonable method
+ // signature.
+ $ref = new ReflectionClass( $clazz );
+ $obj = $ref->newInstanceArgs( $args );
+ }
+ return $obj;
+ }
+}
diff --git a/www/wiki/includes/libs/ProcessCacheLRU.php b/www/wiki/includes/libs/ProcessCacheLRU.php
new file mode 100644
index 00000000..03e23edb
--- /dev/null
+++ b/www/wiki/includes/libs/ProcessCacheLRU.php
@@ -0,0 +1,160 @@
+<?php
+/**
+ * Per-process memory cache for storing items.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+use Wikimedia\Assert\Assert;
+
+/**
+ * Handles per process caching of items
+ * @ingroup Cache
+ */
+class ProcessCacheLRU {
+ /** @var Array */
+ protected $cache = []; // (key => prop => value)
+
+ /** @var Array */
+ protected $cacheTimes = []; // (key => prop => UNIX timestamp)
+
+ protected $maxCacheKeys; // integer; max entries
+
+ /**
+ * @param int $maxKeys Maximum number of entries allowed (min 1).
+ * @throws UnexpectedValueException When $maxCacheKeys is not an int or =< 0.
+ */
+ public function __construct( $maxKeys ) {
+ $this->resize( $maxKeys );
+ }
+
+ /**
+ * Set a property field for a cache entry.
+ * This will prune the cache if it gets too large based on LRU.
+ * If the item is already set, it will be pushed to the top of the cache.
+ *
+ * @param string $key
+ * @param string $prop
+ * @param mixed $value
+ * @return void
+ */
+ public function set( $key, $prop, $value ) {
+ if ( isset( $this->cache[$key] ) ) {
+ $this->ping( $key );
+ } elseif ( count( $this->cache ) >= $this->maxCacheKeys ) {
+ reset( $this->cache );
+ $evictKey = key( $this->cache );
+ unset( $this->cache[$evictKey] );
+ unset( $this->cacheTimes[$evictKey] );
+ }
+ $this->cache[$key][$prop] = $value;
+ $this->cacheTimes[$key][$prop] = microtime( true );
+ }
+
+ /**
+ * Check if a property field exists for a cache entry.
+ *
+ * @param string $key
+ * @param string $prop
+ * @param float $maxAge Ignore items older than this many seconds (since 1.21)
+ * @return bool
+ */
+ public function has( $key, $prop, $maxAge = 0.0 ) {
+ if ( isset( $this->cache[$key][$prop] ) ) {
+ return ( $maxAge <= 0 ||
+ ( microtime( true ) - $this->cacheTimes[$key][$prop] ) <= $maxAge
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Get a property field for a cache entry.
+ * This returns null if the property is not set.
+ * If the item is already set, it will be pushed to the top of the cache.
+ *
+ * @param string $key
+ * @param string $prop
+ * @return mixed
+ */
+ public function get( $key, $prop ) {
+ if ( !isset( $this->cache[$key][$prop] ) ) {
+ return null;
+ }
+ $this->ping( $key );
+ return $this->cache[$key][$prop];
+ }
+
+ /**
+ * Clear one or several cache entries, or all cache entries.
+ *
+ * @param string|array $keys
+ * @return void
+ */
+ public function clear( $keys = null ) {
+ if ( $keys === null ) {
+ $this->cache = [];
+ $this->cacheTimes = [];
+ } else {
+ foreach ( (array)$keys as $key ) {
+ unset( $this->cache[$key] );
+ unset( $this->cacheTimes[$key] );
+ }
+ }
+ }
+
+ /**
+ * Resize the maximum number of cache entries, removing older entries as needed
+ *
+ * @param int $maxKeys
+ * @return void
+ * @throws UnexpectedValueException
+ */
+ public function resize( $maxKeys ) {
+ Assert::parameterType( 'integer', $maxKeys, '$maxKeys' );
+ Assert::parameter( $maxKeys > 0, '$maxKeys', 'must be above zero' );
+
+ $this->maxCacheKeys = $maxKeys;
+ while ( count( $this->cache ) > $this->maxCacheKeys ) {
+ reset( $this->cache );
+ $evictKey = key( $this->cache );
+ unset( $this->cache[$evictKey] );
+ unset( $this->cacheTimes[$evictKey] );
+ }
+ }
+
+ /**
+ * Push an entry to the top of the cache
+ *
+ * @param string $key
+ */
+ protected function ping( $key ) {
+ $item = $this->cache[$key];
+ unset( $this->cache[$key] );
+ $this->cache[$key] = $item;
+ }
+
+ /**
+ * Get cache size
+ * @return int
+ */
+ public function getSize() {
+ return $this->maxCacheKeys;
+ }
+}
diff --git a/www/wiki/includes/libs/README b/www/wiki/includes/libs/README
new file mode 100644
index 00000000..85e3db3c
--- /dev/null
+++ b/www/wiki/includes/libs/README
@@ -0,0 +1,4 @@
+The classes in this directory ./includes/libs are considered standalone
+from the remainder of the MediaWiki codebase. They do not call on any other
+portions of MediaWiki code, and can be used in other projects without
+dependency issues.
diff --git a/www/wiki/includes/libs/ReplacementArray.php b/www/wiki/includes/libs/ReplacementArray.php
new file mode 100644
index 00000000..4512a4b1
--- /dev/null
+++ b/www/wiki/includes/libs/ReplacementArray.php
@@ -0,0 +1,104 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Wrapper around strtr() that holds replacements
+ */
+class ReplacementArray {
+ private $data = false;
+
+ /**
+ * Create an object with the specified replacement array
+ * The array should have the same form as the replacement array for strtr()
+ * @param array $data
+ */
+ public function __construct( $data = [] ) {
+ $this->data = $data;
+ }
+
+ /**
+ * @return array
+ */
+ public function __sleep() {
+ return [ 'data' ];
+ }
+
+ /**
+ * Set the whole replacement array at once
+ * @param array $data
+ */
+ public function setArray( $data ) {
+ $this->data = $data;
+ }
+
+ /**
+ * @return array|bool
+ */
+ public function getArray() {
+ return $this->data;
+ }
+
+ /**
+ * Set an element of the replacement array
+ * @param string $from
+ * @param string $to
+ */
+ public function setPair( $from, $to ) {
+ $this->data[$from] = $to;
+ }
+
+ /**
+ * @param array $data
+ */
+ public function mergeArray( $data ) {
+ $this->data = $data + $this->data;
+ }
+
+ /**
+ * @param ReplacementArray $other
+ */
+ public function merge( ReplacementArray $other ) {
+ $this->data = $other->data + $this->data;
+ }
+
+ /**
+ * @param string $from
+ */
+ public function removePair( $from ) {
+ unset( $this->data[$from] );
+ }
+
+ /**
+ * @param array $data
+ */
+ public function removeArray( $data ) {
+ foreach ( $data as $from => $to ) {
+ $this->removePair( $from );
+ }
+ }
+
+ /**
+ * @param string $subject
+ * @return string
+ */
+ public function replace( $subject ) {
+ return strtr( $subject, $this->data );
+ }
+}
diff --git a/www/wiki/includes/libs/ReverseArrayIterator.php b/www/wiki/includes/libs/ReverseArrayIterator.php
new file mode 100644
index 00000000..37b68c3f
--- /dev/null
+++ b/www/wiki/includes/libs/ReverseArrayIterator.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Convenience class for iterating over an array in reverse order.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.27
+ */
+
+/**
+ * Convenience class for iterating over an array in reverse order.
+ *
+ * @since 1.27
+ */
+class ReverseArrayIterator implements Iterator, Countable {
+ /** @var array $array */
+ protected $array;
+
+ /**
+ * Creates an iterator which will visit the keys in $array in
+ * reverse order. If given an object, will visit the properties
+ * of the object in reverse order. (Note that the default order
+ * for PHP arrays and objects is declaration/assignment order.)
+ *
+ * @param array|object $array
+ */
+ public function __construct( $array = [] ) {
+ if ( is_array( $array ) ) {
+ $this->array = $array;
+ } elseif ( is_object( $array ) ) {
+ $this->array = get_object_vars( $array );
+ } else {
+ throw new InvalidArgumentException( __METHOD__ . ' requires an array or object' );
+ }
+
+ $this->rewind();
+ }
+
+ public function current() {
+ return current( $this->array );
+ }
+
+ public function key() {
+ return key( $this->array );
+ }
+
+ public function next() {
+ prev( $this->array );
+ }
+
+ public function rewind() {
+ end( $this->array );
+ }
+
+ public function valid() {
+ return key( $this->array ) !== null;
+ }
+
+ public function count() {
+ return count( $this->array );
+ }
+}
diff --git a/www/wiki/includes/libs/RiffExtractor.php b/www/wiki/includes/libs/RiffExtractor.php
new file mode 100644
index 00000000..304b99b8
--- /dev/null
+++ b/www/wiki/includes/libs/RiffExtractor.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * Extractor for the Resource Interchange File Format
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Bryan Tong Minh
+ * @ingroup Media
+ */
+
+class RiffExtractor {
+ public static function findChunksFromFile( $filename, $maxChunks = -1 ) {
+ $file = fopen( $filename, 'rb' );
+ $info = self::findChunks( $file, $maxChunks );
+ fclose( $file );
+ return $info;
+ }
+
+ public static function findChunks( $file, $maxChunks = -1 ) {
+ $riff = fread( $file, 4 );
+ if ( $riff !== 'RIFF' ) {
+ return false;
+ }
+
+ // Next four bytes are fileSize
+ $fileSize = fread( $file, 4 );
+ if ( !$fileSize || strlen( $fileSize ) != 4 ) {
+ return false;
+ }
+
+ // Next four bytes are the FourCC
+ $fourCC = fread( $file, 4 );
+ if ( !$fourCC || strlen( $fourCC ) != 4 ) {
+ return false;
+ }
+
+ // Create basic info structure
+ $info = [
+ 'fileSize' => self::extractUInt32( $fileSize ),
+ 'fourCC' => $fourCC,
+ 'chunks' => [],
+ ];
+ $numberOfChunks = 0;
+
+ // Find out the chunks
+ while ( !feof( $file ) && !( $numberOfChunks >= $maxChunks && $maxChunks >= 0 ) ) {
+ $chunkStart = ftell( $file );
+
+ $chunkFourCC = fread( $file, 4 );
+ if ( !$chunkFourCC || strlen( $chunkFourCC ) != 4 ) {
+ return $info;
+ }
+
+ $chunkSize = fread( $file, 4 );
+ if ( !$chunkSize || strlen( $chunkSize ) != 4 ) {
+ return $info;
+ }
+ $intChunkSize = self::extractUInt32( $chunkSize );
+
+ // Add chunk info to the info structure
+ $info['chunks'][] = [
+ 'fourCC' => $chunkFourCC,
+ 'start' => $chunkStart,
+ 'size' => $intChunkSize
+ ];
+
+ // Uneven chunks have padding bytes
+ $padding = $intChunkSize % 2;
+ // Seek to the next chunk
+ fseek( $file, $intChunkSize + $padding, SEEK_CUR );
+
+ }
+
+ return $info;
+ }
+
+ /**
+ * Extract a little-endian uint32 from a 4 byte string
+ * @param string $string 4-byte string
+ * @return int
+ */
+ public static function extractUInt32( $string ) {
+ return unpack( 'V', $string )[1];
+ }
+};
diff --git a/www/wiki/includes/libs/SamplingStatsdClient.php b/www/wiki/includes/libs/SamplingStatsdClient.php
new file mode 100644
index 00000000..2e780c97
--- /dev/null
+++ b/www/wiki/includes/libs/SamplingStatsdClient.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Copyright 2015
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Liuggio\StatsdClient\StatsdClient;
+use Liuggio\StatsdClient\Entity\StatsdData;
+use Liuggio\StatsdClient\Entity\StatsdDataInterface;
+
+/**
+ * A statsd client that applies the sampling rate to the data items before sending them.
+ *
+ * @since 1.26
+ */
+class SamplingStatsdClient extends StatsdClient {
+ /**
+ * Sets sampling rate for all items in $data.
+ * The sample rate specified in a StatsdData entity overrides the sample rate specified here.
+ *
+ * {@inheritDoc}
+ */
+ public function appendSampleRate( $data, $sampleRate = 1 ) {
+ if ( $sampleRate < 1 ) {
+ array_walk( $data, function( $item ) use ( $sampleRate ) {
+ /** @var $item StatsdData */
+ if ( $item->getSampleRate() === 1 ) {
+ $item->setSampleRate( $sampleRate );
+ }
+ } );
+ }
+
+ return $data;
+ }
+
+ /*
+ * Send the metrics over UDP
+ * Sample the metrics according to their sample rate and send the remaining ones.
+ *
+ * @param StatsdDataInterface|StatsdDataInterface[] $data message(s) to sent
+ * strings are not allowed here as sampleData requires a StatsdDataInterface
+ * @param int $sampleRate
+ *
+ * @return integer the data sent in bytes
+ */
+ public function send( $data, $sampleRate = 1 ) {
+ if ( !is_array( $data ) ) {
+ $data = [ $data ];
+ }
+ if ( !$data ) {
+ return;
+ }
+ foreach ( $data as $item ) {
+ if ( !( $item instanceof StatsdDataInterface ) ) {
+ throw new InvalidArgumentException(
+ 'SamplingStatsdClient does not accept stringified messages' );
+ }
+ }
+
+ // add sampling
+ if ( $sampleRate < 1 ) {
+ $data = $this->appendSampleRate( $data, $sampleRate );
+ }
+ $data = $this->sampleData( $data );
+
+ $data = array_map( 'strval', $data );
+
+ // reduce number of packets
+ if ( $this->getReducePacket() ) {
+ $data = $this->reduceCount( $data );
+ }
+
+ // failures in any of this should be silently ignored if ..
+ $written = 0;
+ try {
+ $fp = $this->getSender()->open();
+ if ( !$fp ) {
+ return;
+ }
+ foreach ( $data as $message ) {
+ $written += $this->getSender()->write( $fp, $message );
+ }
+ $this->getSender()->close( $fp );
+ } catch ( Exception $e ) {
+ $this->throwException( $e );
+ }
+
+ return $written;
+ }
+
+ /**
+ * Throw away some of the data according to the sample rate.
+ * @param StatsdDataInterface[] $data
+ * @return StatsdDataInterface[]
+ * @throws LogicException
+ */
+ protected function sampleData( $data ) {
+ $newData = [];
+ $mt_rand_max = mt_getrandmax();
+ foreach ( $data as $item ) {
+ $samplingRate = $item->getSampleRate();
+ if ( $samplingRate <= 0.0 || $samplingRate > 1.0 ) {
+ throw new LogicException( 'Sampling rate shall be within ]0, 1]' );
+ }
+ if (
+ $samplingRate === 1 ||
+ ( mt_rand() / $mt_rand_max <= $samplingRate )
+ ) {
+ $newData[] = $item;
+ }
+ }
+ return $newData;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function throwException( Exception $exception ) {
+ if ( !$this->getFailSilently() ) {
+ throw $exception;
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/ScopedCallback.php b/www/wiki/includes/libs/ScopedCallback.php
new file mode 100644
index 00000000..96075aad
--- /dev/null
+++ b/www/wiki/includes/libs/ScopedCallback.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * This file deals with RAII style scoped callbacks.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class for asserting that a callback happens when an dummy object leaves scope
+ *
+ * @since 1.21
+ */
+class ScopedCallback {
+ /** @var callable */
+ protected $callback;
+ /** @var array */
+ protected $params;
+
+ /**
+ * @param callable|null $callback
+ * @param array $params Callback arguments (since 1.25)
+ * @throws Exception
+ */
+ public function __construct( $callback, array $params = [] ) {
+ if ( $callback !== null && !is_callable( $callback ) ) {
+ throw new InvalidArgumentException( "Provided callback is not valid." );
+ }
+ $this->callback = $callback;
+ $this->params = $params;
+ }
+
+ /**
+ * Trigger a scoped callback and destroy it.
+ * This is the same is just setting it to null.
+ *
+ * @param ScopedCallback $sc
+ */
+ public static function consume( ScopedCallback &$sc = null ) {
+ $sc = null;
+ }
+
+ /**
+ * Destroy a scoped callback without triggering it
+ *
+ * @param ScopedCallback $sc
+ */
+ public static function cancel( ScopedCallback &$sc = null ) {
+ if ( $sc ) {
+ $sc->callback = null;
+ }
+ $sc = null;
+ }
+
+ /**
+ * Trigger the callback when this leaves scope
+ */
+ function __destruct() {
+ if ( $this->callback !== null ) {
+ call_user_func_array( $this->callback, $this->params );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/StatusValue.php b/www/wiki/includes/libs/StatusValue.php
new file mode 100644
index 00000000..f9dcc1b5
--- /dev/null
+++ b/www/wiki/includes/libs/StatusValue.php
@@ -0,0 +1,351 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Generic operation result class
+ * Has warning/error list, boolean status and arbitrary value
+ *
+ * "Good" means the operation was completed with no warnings or errors.
+ *
+ * "OK" means the operation was partially or wholly completed.
+ *
+ * An operation which is not OK should have errors so that the user can be
+ * informed as to what went wrong. Calling the fatal() function sets an error
+ * message and simultaneously switches off the OK flag.
+ *
+ * The recommended pattern for Status objects is to return a StatusValue
+ * unconditionally, i.e. both on success and on failure -- so that the
+ * developer of the calling code is reminded that the function can fail, and
+ * so that a lack of error-handling will be explicit.
+ *
+ * The use of Message objects should be avoided when serializability is needed.
+ *
+ * @since 1.25
+ */
+class StatusValue {
+
+ /** @var bool */
+ protected $ok = true;
+
+ /** @var array[] */
+ protected $errors = [];
+
+ /** @var mixed */
+ public $value;
+
+ /** @var bool[] Map of (key => bool) to indicate success of each part of batch operations */
+ public $success = [];
+
+ /** @var int Counter for batch operations */
+ public $successCount = 0;
+
+ /** @var int Counter for batch operations */
+ public $failCount = 0;
+
+ /**
+ * Factory function for fatal errors
+ *
+ * @param string|MessageSpecifier $message Message key or object
+ * @return static
+ */
+ public static function newFatal( $message /*, parameters...*/ ) {
+ $params = func_get_args();
+ $result = new static();
+ call_user_func_array( [ &$result, 'fatal' ], $params );
+ return $result;
+ }
+
+ /**
+ * Factory function for good results
+ *
+ * @param mixed $value
+ * @return static
+ */
+ public static function newGood( $value = null ) {
+ $result = new static();
+ $result->value = $value;
+ return $result;
+ }
+
+ /**
+ * Splits this StatusValue object into two new StatusValue objects, one which contains only
+ * the error messages, and one that contains the warnings, only. The returned array is
+ * defined as:
+ * [
+ * 0 => object(StatusValue) # the StatusValue with error messages, only
+ * 1 => object(StatusValue) # The StatusValue with warning messages, only
+ * ]
+ *
+ * @return StatusValue[]
+ */
+ public function splitByErrorType() {
+ $errorsOnlyStatusValue = clone $this;
+ $warningsOnlyStatusValue = clone $this;
+ $warningsOnlyStatusValue->ok = true;
+
+ $errorsOnlyStatusValue->errors = $warningsOnlyStatusValue->errors = [];
+ foreach ( $this->errors as $item ) {
+ if ( $item['type'] === 'warning' ) {
+ $warningsOnlyStatusValue->errors[] = $item;
+ } else {
+ $errorsOnlyStatusValue->errors[] = $item;
+ }
+ };
+
+ return [ $errorsOnlyStatusValue, $warningsOnlyStatusValue ];
+ }
+
+ /**
+ * Returns whether the operation completed and didn't have any error or
+ * warnings
+ *
+ * @return bool
+ */
+ public function isGood() {
+ return $this->ok && !$this->errors;
+ }
+
+ /**
+ * Returns whether the operation completed
+ *
+ * @return bool
+ */
+ public function isOK() {
+ return $this->ok;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getValue() {
+ return $this->value;
+ }
+
+ /**
+ * Get the list of errors
+ *
+ * Each error is a (message:string or MessageSpecifier,params:array) map
+ *
+ * @return array[]
+ */
+ public function getErrors() {
+ return $this->errors;
+ }
+
+ /**
+ * Change operation status
+ *
+ * @param bool $ok
+ */
+ public function setOK( $ok ) {
+ $this->ok = $ok;
+ }
+
+ /**
+ * Change operation result
+ *
+ * @param bool $ok Whether the operation completed
+ * @param mixed $value
+ */
+ public function setResult( $ok, $value = null ) {
+ $this->ok = (bool)$ok;
+ $this->value = $value;
+ }
+
+ /**
+ * Add a new warning
+ *
+ * @param string|MessageSpecifier $message Message key or object
+ */
+ public function warning( $message /*, parameters... */ ) {
+ $this->errors[] = [
+ 'type' => 'warning',
+ 'message' => $message,
+ 'params' => array_slice( func_get_args(), 1 )
+ ];
+ }
+
+ /**
+ * Add an error, do not set fatal flag
+ * This can be used for non-fatal errors
+ *
+ * @param string|MessageSpecifier $message Message key or object
+ */
+ public function error( $message /*, parameters... */ ) {
+ $this->errors[] = [
+ 'type' => 'error',
+ 'message' => $message,
+ 'params' => array_slice( func_get_args(), 1 )
+ ];
+ }
+
+ /**
+ * Add an error and set OK to false, indicating that the operation
+ * as a whole was fatal
+ *
+ * @param string|MessageSpecifier $message Message key or object
+ */
+ public function fatal( $message /*, parameters... */ ) {
+ $this->errors[] = [
+ 'type' => 'error',
+ 'message' => $message,
+ 'params' => array_slice( func_get_args(), 1 )
+ ];
+ $this->ok = false;
+ }
+
+ /**
+ * Merge another status object into this one
+ *
+ * @param StatusValue $other Other StatusValue object
+ * @param bool $overwriteValue Whether to override the "value" member
+ */
+ public function merge( $other, $overwriteValue = false ) {
+ $this->errors = array_merge( $this->errors, $other->errors );
+ $this->ok = $this->ok && $other->ok;
+ if ( $overwriteValue ) {
+ $this->value = $other->value;
+ }
+ $this->successCount += $other->successCount;
+ $this->failCount += $other->failCount;
+ }
+
+ /**
+ * Returns a list of status messages of the given type
+ *
+ * Each entry is a map of:
+ * - message: string message key or MessageSpecifier
+ * - params: array list of parameters
+ *
+ * @param string $type
+ * @return array[]
+ */
+ public function getErrorsByType( $type ) {
+ $result = [];
+ foreach ( $this->errors as $error ) {
+ if ( $error['type'] === $type ) {
+ $result[] = $error;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns true if the specified message is present as a warning or error
+ *
+ * @param string|MessageSpecifier $message Message key or object to search for
+ *
+ * @return bool
+ */
+ public function hasMessage( $message ) {
+ if ( $message instanceof MessageSpecifier ) {
+ $message = $message->getKey();
+ }
+ foreach ( $this->errors as $error ) {
+ if ( $error['message'] instanceof MessageSpecifier
+ && $error['message']->getKey() === $message
+ ) {
+ return true;
+ } elseif ( $error['message'] === $message ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * If the specified source message exists, replace it with the specified
+ * destination message, but keep the same parameters as in the original error.
+ *
+ * Note, due to the lack of tools for comparing IStatusMessage objects, this
+ * function will not work when using such an object as the search parameter.
+ *
+ * @param MessageSpecifier|string $source Message key or object to search for
+ * @param MessageSpecifier|string $dest Replacement message key or object
+ * @return bool Return true if the replacement was done, false otherwise.
+ */
+ public function replaceMessage( $source, $dest ) {
+ $replaced = false;
+
+ foreach ( $this->errors as $index => $error ) {
+ if ( $error['message'] === $source ) {
+ $this->errors[$index]['message'] = $dest;
+ $replaced = true;
+ }
+ }
+
+ return $replaced;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString() {
+ $status = $this->isOK() ? "OK" : "Error";
+ if ( count( $this->errors ) ) {
+ $errorcount = "collected " . ( count( $this->errors ) ) . " error(s) on the way";
+ } else {
+ $errorcount = "no errors detected";
+ }
+ if ( isset( $this->value ) ) {
+ $valstr = gettype( $this->value ) . " value set";
+ if ( is_object( $this->value ) ) {
+ $valstr .= "\"" . get_class( $this->value ) . "\" instance";
+ }
+ } else {
+ $valstr = "no value set";
+ }
+ $out = sprintf( "<%s, %s, %s>",
+ $status,
+ $errorcount,
+ $valstr
+ );
+ if ( count( $this->errors ) > 0 ) {
+ $hdr = sprintf( "+-%'-4s-+-%'-25s-+-%'-40s-+\n", "", "", "" );
+ $i = 1;
+ $out .= "\n";
+ $out .= $hdr;
+ foreach ( $this->errors as $error ) {
+ if ( $error['message'] instanceof MessageSpecifier ) {
+ $key = $error['message']->getKey();
+ $params = $error['message']->getParams();
+ } elseif ( $error['params'] ) {
+ $key = $error['message'];
+ $params = $error['params'];
+ } else {
+ $key = $error['message'];
+ $params = [];
+ }
+
+ $out .= sprintf( "| %4d | %-25.25s | %-40.40s |\n",
+ $i,
+ $key,
+ implode( " ", $params )
+ );
+ $i += 1;
+ }
+ $out .= $hdr;
+ }
+
+ return $out;
+ }
+}
diff --git a/www/wiki/includes/libs/StringUtils.php b/www/wiki/includes/libs/StringUtils.php
new file mode 100644
index 00000000..9638706d
--- /dev/null
+++ b/www/wiki/includes/libs/StringUtils.php
@@ -0,0 +1,342 @@
+<?php
+/**
+ * Methods to play with strings.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A collection of static methods to play with strings.
+ */
+class StringUtils {
+ /**
+ * Test whether a string is valid UTF-8.
+ *
+ * The function check for invalid byte sequences, overlong encoding but
+ * not for different normalisations.
+ *
+ * @note In MediaWiki 1.21, this function did not provide proper UTF-8 validation.
+ * In particular, the pure PHP code path did not in fact check for overlong forms.
+ * Beware of this when backporting code to that version of MediaWiki.
+ *
+ * @since 1.21
+ * @param string $value String to check
+ * @return bool Whether the given $value is a valid UTF-8 encoded string
+ */
+ static function isUtf8( $value ) {
+ $value = (string)$value;
+
+ // HHVM 3.4 and older come with an outdated version of libmbfl that
+ // incorrectly allows values above U+10FFFF, so we have to check
+ // for them separately. (This issue also exists in PHP 5.3 and
+ // older, which are no longer supported.)
+ static $newPHP;
+ if ( $newPHP === null ) {
+ $newPHP = !mb_check_encoding( "\xf4\x90\x80\x80", 'UTF-8' );
+ }
+
+ return mb_check_encoding( $value, 'UTF-8' ) &&
+ ( $newPHP || preg_match( "/\xf4[\x90-\xbf]|[\xf5-\xff]/S", $value ) === 0 );
+ }
+
+ /**
+ * Explode a string, but ignore any instances of the separator inside
+ * the given start and end delimiters, which may optionally nest.
+ * The delimiters are literal strings, not regular expressions.
+ * @param string $startDelim Start delimiter
+ * @param string $endDelim End delimiter
+ * @param string $separator Separator string for the explode.
+ * @param string $subject Subject string to explode.
+ * @param bool $nested True iff the delimiters are allowed to nest.
+ * @return ArrayIterator
+ */
+ static function delimiterExplode( $startDelim, $endDelim, $separator,
+ $subject, $nested = false ) {
+ $inputPos = 0;
+ $lastPos = 0;
+ $depth = 0;
+ $encStart = preg_quote( $startDelim, '!' );
+ $encEnd = preg_quote( $endDelim, '!' );
+ $encSep = preg_quote( $separator, '!' );
+ $len = strlen( $subject );
+ $m = [];
+ $exploded = [];
+ while (
+ $inputPos < $len &&
+ preg_match(
+ "!$encStart|$encEnd|$encSep!S", $subject, $m,
+ PREG_OFFSET_CAPTURE, $inputPos
+ )
+ ) {
+ $match = $m[0][0];
+ $matchPos = $m[0][1];
+ $inputPos = $matchPos + strlen( $match );
+ if ( $match === $separator ) {
+ if ( $depth === 0 ) {
+ $exploded[] = substr(
+ $subject, $lastPos, $matchPos - $lastPos
+ );
+ $lastPos = $inputPos;
+ }
+ } elseif ( $match === $startDelim ) {
+ if ( $depth === 0 || $nested ) {
+ $depth++;
+ }
+ } else {
+ $depth--;
+ }
+ }
+ $exploded[] = substr( $subject, $lastPos );
+ // This method could be rewritten in the future to avoid creating an
+ // intermediate array, since the return type is just an iterator.
+ return new ArrayIterator( $exploded );
+ }
+
+ /**
+ * Perform an operation equivalent to `preg_replace()`
+ *
+ * Matches this code:
+ *
+ * preg_replace( "!$startDelim(.*?)$endDelim!", $replace, $subject );
+ *
+ * ..except that it's worst-case O(N) instead of O(N^2). Compared to delimiterReplace(), this
+ * implementation is fast but memory-hungry and inflexible. The memory requirements are such
+ * that I don't recommend using it on anything but guaranteed small chunks of text.
+ *
+ * @param string $startDelim
+ * @param string $endDelim
+ * @param string $replace
+ * @param string $subject
+ * @return string
+ */
+ static function hungryDelimiterReplace( $startDelim, $endDelim, $replace, $subject ) {
+ $segments = explode( $startDelim, $subject );
+ $output = array_shift( $segments );
+ foreach ( $segments as $s ) {
+ $endDelimPos = strpos( $s, $endDelim );
+ if ( $endDelimPos === false ) {
+ $output .= $startDelim . $s;
+ } else {
+ $output .= $replace . substr( $s, $endDelimPos + strlen( $endDelim ) );
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Perform an operation equivalent to `preg_replace_callback()`
+ *
+ * Matches this code:
+ *
+ * preg_replace_callback( "!$startDelim(.*)$endDelim!s$flags", $callback, $subject );
+ *
+ * If the start delimiter ends with an initial substring of the end delimiter,
+ * e.g. in the case of C-style comments, the behavior differs from the model
+ * regex. In this implementation, the end must share no characters with the
+ * start, so e.g. `/*\/` is not considered to be both the start and end of a
+ * comment. `/*\/xy/*\/` is considered to be a single comment with contents `/xy/`.
+ *
+ * The implementation of delimiterReplaceCallback() is slower than hungryDelimiterReplace()
+ * but uses far less memory. The delimiters are literal strings, not regular expressions.
+ *
+ * @param string $startDelim Start delimiter
+ * @param string $endDelim End delimiter
+ * @param callable $callback Function to call on each match
+ * @param string $subject
+ * @param string $flags Regular expression flags
+ * @throws InvalidArgumentException
+ * @return string
+ */
+ static function delimiterReplaceCallback( $startDelim, $endDelim, $callback,
+ $subject, $flags = ''
+ ) {
+ $inputPos = 0;
+ $outputPos = 0;
+ $contentPos = 0;
+ $output = '';
+ $foundStart = false;
+ $encStart = preg_quote( $startDelim, '!' );
+ $encEnd = preg_quote( $endDelim, '!' );
+ $strcmp = strpos( $flags, 'i' ) === false ? 'strcmp' : 'strcasecmp';
+ $endLength = strlen( $endDelim );
+ $m = [];
+
+ while ( $inputPos < strlen( $subject ) &&
+ preg_match( "!($encStart)|($encEnd)!S$flags", $subject, $m, PREG_OFFSET_CAPTURE, $inputPos )
+ ) {
+ $tokenOffset = $m[0][1];
+ if ( $m[1][0] != '' ) {
+ if ( $foundStart &&
+ $strcmp( $endDelim, substr( $subject, $tokenOffset, $endLength ) ) == 0
+ ) {
+ # An end match is present at the same location
+ $tokenType = 'end';
+ $tokenLength = $endLength;
+ } else {
+ $tokenType = 'start';
+ $tokenLength = strlen( $m[0][0] );
+ }
+ } elseif ( $m[2][0] != '' ) {
+ $tokenType = 'end';
+ $tokenLength = strlen( $m[0][0] );
+ } else {
+ throw new InvalidArgumentException( 'Invalid delimiter given to ' . __METHOD__ );
+ }
+
+ if ( $tokenType == 'start' ) {
+ # Only move the start position if we haven't already found a start
+ # This means that START START END matches outer pair
+ if ( !$foundStart ) {
+ # Found start
+ $inputPos = $tokenOffset + $tokenLength;
+ # Write out the non-matching section
+ $output .= substr( $subject, $outputPos, $tokenOffset - $outputPos );
+ $outputPos = $tokenOffset;
+ $contentPos = $inputPos;
+ $foundStart = true;
+ } else {
+ # Move the input position past the *first character* of START,
+ # to protect against missing END when it overlaps with START
+ $inputPos = $tokenOffset + 1;
+ }
+ } elseif ( $tokenType == 'end' ) {
+ if ( $foundStart ) {
+ # Found match
+ $output .= call_user_func( $callback, [
+ substr( $subject, $outputPos, $tokenOffset + $tokenLength - $outputPos ),
+ substr( $subject, $contentPos, $tokenOffset - $contentPos )
+ ] );
+ $foundStart = false;
+ } else {
+ # Non-matching end, write it out
+ $output .= substr( $subject, $inputPos, $tokenOffset + $tokenLength - $outputPos );
+ }
+ $inputPos = $outputPos = $tokenOffset + $tokenLength;
+ } else {
+ throw new InvalidArgumentException( 'Invalid delimiter given to ' . __METHOD__ );
+ }
+ }
+ if ( $outputPos < strlen( $subject ) ) {
+ $output .= substr( $subject, $outputPos );
+ }
+
+ return $output;
+ }
+
+ /**
+ * Perform an operation equivalent to `preg_replace()` with flags.
+ *
+ * Matches this code:
+ *
+ * preg_replace( "!$startDelim(.*)$endDelim!$flags", $replace, $subject );
+ *
+ * @param string $startDelim Start delimiter regular expression
+ * @param string $endDelim End delimiter regular expression
+ * @param string $replace Replacement string. May contain $1, which will be
+ * replaced by the text between the delimiters
+ * @param string $subject String to search
+ * @param string $flags Regular expression flags
+ * @return string The string with the matches replaced
+ */
+ static function delimiterReplace( $startDelim, $endDelim, $replace, $subject, $flags = '' ) {
+ $replacer = new RegexlikeReplacer( $replace );
+
+ return self::delimiterReplaceCallback( $startDelim, $endDelim,
+ $replacer->cb(), $subject, $flags );
+ }
+
+ /**
+ * More or less "markup-safe" explode()
+ * Ignores any instances of the separator inside `<...>`
+ * @param string $separator
+ * @param string $text
+ * @return array
+ */
+ static function explodeMarkup( $separator, $text ) {
+ $placeholder = "\x00";
+
+ // Remove placeholder instances
+ $text = str_replace( $placeholder, '', $text );
+
+ // Replace instances of the separator inside HTML-like tags with the placeholder
+ $replacer = new DoubleReplacer( $separator, $placeholder );
+ $cleaned = self::delimiterReplaceCallback( '<', '>', $replacer->cb(), $text );
+
+ // Explode, then put the replaced separators back in
+ $items = explode( $separator, $cleaned );
+ foreach ( $items as $i => $str ) {
+ $items[$i] = str_replace( $placeholder, $separator, $str );
+ }
+
+ return $items;
+ }
+
+ /**
+ * More or less "markup-safe" str_replace()
+ * Ignores any instances of the separator inside `<...>`
+ * @param string $search
+ * @param string $replace
+ * @param string $text
+ * @return string
+ */
+ static function replaceMarkup( $search, $replace, $text ) {
+ $placeholder = "\x00";
+
+ // Remove placeholder instances
+ $text = str_replace( $placeholder, '', $text );
+
+ // Replace instances of the separator inside HTML-like tags with the placeholder
+ $replacer = new DoubleReplacer( $search, $placeholder );
+ $cleaned = self::delimiterReplaceCallback( '<', '>', $replacer->cb(), $text );
+
+ // Explode, then put the replaced separators back in
+ $cleaned = str_replace( $search, $replace, $cleaned );
+ $text = str_replace( $placeholder, $search, $cleaned );
+
+ return $text;
+ }
+
+ /**
+ * Escape a string to make it suitable for inclusion in a preg_replace()
+ * replacement parameter.
+ *
+ * @param string $string
+ * @return string
+ */
+ static function escapeRegexReplacement( $string ) {
+ $string = str_replace( '\\', '\\\\', $string );
+ $string = str_replace( '$', '\\$', $string );
+ return $string;
+ }
+
+ /**
+ * Workalike for explode() with limited memory usage.
+ *
+ * @param string $separator
+ * @param string $subject
+ * @return ArrayIterator|ExplodeIterator
+ */
+ static function explode( $separator, $subject ) {
+ if ( substr_count( $subject, $separator ) > 1000 ) {
+ return new ExplodeIterator( $separator, $subject );
+ } else {
+ return new ArrayIterator( explode( $separator, $subject ) );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/Timing.php b/www/wiki/includes/libs/Timing.php
new file mode 100644
index 00000000..57c253d5
--- /dev/null
+++ b/www/wiki/includes/libs/Timing.php
@@ -0,0 +1,195 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * An interface to help developers measure the performance of their applications.
+ * This interface closely matches the W3C's User Timing specification.
+ * The key differences are:
+ *
+ * - The reference point for all measurements which do not explicitly specify
+ * a start time is $_SERVER['REQUEST_TIME_FLOAT'], not navigationStart.
+ * - Successive calls to mark() and measure() with the same entry name cause
+ * the previous entry to be overwritten. This ensures that there is a 1:1
+ * mapping between names and entries.
+ * - Because there is a 1:1 mapping, instead of getEntriesByName(), we have
+ * getEntryByName().
+ *
+ * The in-line documentation incorporates content from the User Timing Specification
+ * https://www.w3.org/TR/user-timing/
+ * Copyright © 2013 World Wide Web Consortium, (MIT, ERCIM, Keio, Beihang).
+ * https://www.w3.org/Consortium/Legal/2015/doc-license
+ *
+ * @since 1.27
+ */
+class Timing implements LoggerAwareInterface {
+
+ /** @var array[] */
+ private $entries = [];
+
+ /** @var LoggerInterface */
+ protected $logger;
+
+ public function __construct( array $params = [] ) {
+ $this->clearMarks();
+ $this->setLogger( isset( $params['logger'] ) ? $params['logger'] : new NullLogger() );
+ }
+
+ /**
+ * Sets a logger instance on the object.
+ *
+ * @param LoggerInterface $logger
+ * @return null
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Store a timestamp with the associated name (a "mark")
+ *
+ * @param string $markName The name associated with the timestamp.
+ * If there already exists an entry by that name, it is overwritten.
+ * @return array The mark that has been created.
+ */
+ public function mark( $markName ) {
+ $this->entries[$markName] = [
+ 'name' => $markName,
+ 'entryType' => 'mark',
+ 'startTime' => microtime( true ),
+ 'duration' => 0,
+ ];
+ return $this->entries[$markName];
+ }
+
+ /**
+ * @param string $markName The name of the mark that should
+ * be cleared. If not specified, all marks will be cleared.
+ */
+ public function clearMarks( $markName = null ) {
+ if ( $markName !== null ) {
+ unset( $this->entries[$markName] );
+ } else {
+ $this->entries = [
+ 'requestStart' => [
+ 'name' => 'requestStart',
+ 'entryType' => 'mark',
+ 'startTime' => isset( $_SERVER['REQUEST_TIME_FLOAT'] )
+ ? $_SERVER['REQUEST_TIME_FLOAT']
+ : $_SERVER['REQUEST_TIME'],
+ 'duration' => 0,
+ ],
+ ];
+ }
+ }
+
+ /**
+ * This method stores the duration between two marks along with
+ * the associated name (a "measure").
+ *
+ * If neither the startMark nor the endMark argument is specified,
+ * measure() will store the duration from $_SERVER['REQUEST_TIME_FLOAT'] to
+ * the current time.
+ * If the startMark argument is specified, but the endMark argument is not
+ * specified, measure() will store the duration from the most recent
+ * occurrence of the start mark to the current time.
+ * If both the startMark and endMark arguments are specified, measure()
+ * will store the duration from the most recent occurrence of the start
+ * mark to the most recent occurrence of the end mark.
+ *
+ * @param string $measureName
+ * @param string $startMark
+ * @param string $endMark
+ * @return array|bool The measure that has been created, or false if either
+ * the start mark or the end mark do not exist.
+ */
+ public function measure( $measureName, $startMark = 'requestStart', $endMark = null ) {
+ $start = $this->getEntryByName( $startMark );
+ if ( $start === null ) {
+ $this->logger->error( __METHOD__ . ": The mark '$startMark' does not exist" );
+ return false;
+ }
+ $startTime = $start['startTime'];
+
+ if ( $endMark ) {
+ $end = $this->getEntryByName( $endMark );
+ if ( $end === null ) {
+ $this->logger->error( __METHOD__ . ": The mark '$endMark' does not exist" );
+ return false;
+ }
+ $endTime = $end['startTime'];
+ } else {
+ $endTime = microtime( true );
+ }
+
+ $this->entries[$measureName] = [
+ 'name' => $measureName,
+ 'entryType' => 'measure',
+ 'startTime' => $startTime,
+ 'duration' => $endTime - $startTime,
+ ];
+
+ return $this->entries[$measureName];
+ }
+
+ /**
+ * Sort entries in chronological order with respect to startTime.
+ */
+ private function sortEntries() {
+ uasort( $this->entries, function ( $a, $b ) {
+ return 10000 * ( $a['startTime'] - $b['startTime'] );
+ } );
+ }
+
+ /**
+ * @return array[] All entries in chronological order.
+ */
+ public function getEntries() {
+ $this->sortEntries();
+ return $this->entries;
+ }
+
+ /**
+ * @param string $entryType
+ * @return array[] Entries (in chronological order) that have the same value
+ * for the entryType attribute as the $entryType parameter.
+ */
+ public function getEntriesByType( $entryType ) {
+ $this->sortEntries();
+ $entries = [];
+ foreach ( $this->entries as $entry ) {
+ if ( $entry['entryType'] === $entryType ) {
+ $entries[] = $entry;
+ }
+ }
+ return $entries;
+ }
+
+ /**
+ * @param string $name
+ * @return array|null Entry named $name or null if it does not exist.
+ */
+ public function getEntryByName( $name ) {
+ return isset( $this->entries[$name] ) ? $this->entries[$name] : null;
+ }
+}
diff --git a/www/wiki/includes/libs/UDPTransport.php b/www/wiki/includes/libs/UDPTransport.php
new file mode 100644
index 00000000..7fad882a
--- /dev/null
+++ b/www/wiki/includes/libs/UDPTransport.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A generic class to send a message over UDP
+ *
+ * If a message prefix is provided to the constructor or via
+ * UDPTransport::newFromString(), the payload of the UDP datagrams emitted
+ * will be formatted with the prefix and a single space at the start of each
+ * line. This is the payload format expected by the udp2log service.
+ *
+ * @since 1.25
+ */
+class UDPTransport {
+ private $host, $port, $prefix, $domain;
+
+ /**
+ * @param string $host IP address to send to
+ * @param int $port port number
+ * @param int $domain AF_INET or AF_INET6 constant
+ * @param string|bool $prefix Prefix to use, false for no prefix
+ */
+ public function __construct( $host, $port, $domain, $prefix = false ) {
+ $this->host = $host;
+ $this->port = $port;
+ $this->domain = $domain;
+ $this->prefix = $prefix;
+ }
+
+ /**
+ * @param string $info In the format of "udp://host:port/prefix"
+ * @return UDPTransport
+ * @throws InvalidArgumentException
+ */
+ public static function newFromString( $info ) {
+ if ( preg_match( '!^udp:(?://)?\[([0-9a-fA-F:]+)\]:(\d+)(?:/(.*))?$!', $info, $m ) ) {
+ // IPv6 bracketed host
+ $host = $m[1];
+ $port = intval( $m[2] );
+ $prefix = isset( $m[3] ) ? $m[3] : false;
+ $domain = AF_INET6;
+ } elseif ( preg_match( '!^udp:(?://)?([a-zA-Z0-9.-]+):(\d+)(?:/(.*))?$!', $info, $m ) ) {
+ $host = $m[1];
+ if ( !IP::isIPv4( $host ) ) {
+ $host = gethostbyname( $host );
+ }
+ $port = intval( $m[2] );
+ $prefix = isset( $m[3] ) ? $m[3] : false;
+ $domain = AF_INET;
+ } else {
+ throw new InvalidArgumentException( __METHOD__ . ': Invalid UDP specification' );
+ }
+
+ return new self( $host, $port, $domain, $prefix );
+ }
+
+ /**
+ * @param string $text
+ */
+ public function emit( $text ) {
+ // Clean it up for the multiplexer
+ if ( $this->prefix !== false ) {
+ $text = preg_replace( '/^/m', $this->prefix . ' ', $text );
+
+ // Limit to 64KB
+ if ( strlen( $text ) > 65506 ) {
+ $text = substr( $text, 0, 65506 );
+ }
+
+ if ( substr( $text, -1 ) != "\n" ) {
+ $text .= "\n";
+ }
+ } elseif ( strlen( $text ) > 65507 ) {
+ $text = substr( $text, 0, 65507 );
+ }
+
+ $sock = socket_create( $this->domain, SOCK_DGRAM, SOL_UDP );
+ if ( !$sock ) { // @todo should this throw an exception?
+ return;
+ }
+
+ socket_sendto( $sock, $text, strlen( $text ), 0, $this->host, $this->port );
+ socket_close( $sock );
+ }
+}
diff --git a/www/wiki/includes/libs/Xhprof.php b/www/wiki/includes/libs/Xhprof.php
new file mode 100644
index 00000000..e58d98fc
--- /dev/null
+++ b/www/wiki/includes/libs/Xhprof.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Convenience class for working with XHProf
+ * <https://github.com/phacility/xhprof>. XHProf can be installed as a PECL
+ * package for use with PHP5 (Zend PHP) and is built-in to HHVM 3.3.0.
+ *
+ * This also supports using the Tideways profiler
+ * <https://github.com/tideways/php-profiler-extension>, which additionally
+ * has support for PHP7.
+ *
+ * @since 1.28
+ */
+class Xhprof {
+ /**
+ * @var bool $enabled Whether XHProf is currently running.
+ */
+ protected static $enabled;
+
+ /**
+ * Start xhprof profiler
+ * @return bool
+ */
+ public static function isEnabled() {
+ return self::$enabled;
+ }
+
+ /**
+ * Start xhprof profiler
+ * @param int $flags
+ * @param array $options
+ * @throws Exception
+ */
+ public static function enable( $flags = 0, $options = [] ) {
+ if ( self::isEnabled() ) {
+ throw new Exception( 'Profiling is already enabled.' );
+ }
+ self::$enabled = true;
+ if ( function_exists( 'xhprof_enable' ) ) {
+ xhprof_enable( $flags, $options );
+ } elseif ( function_exists( 'tideways_enable' ) ) {
+ tideways_enable( $flags, $options );
+ } else {
+ throw new Exception( "Neither xhprof nor tideways are installed" );
+ }
+ }
+
+ /**
+ * Stop xhprof profiler
+ *
+ * @return array|null xhprof data from the run, or null if xhprof was not running.
+ */
+ public static function disable() {
+ if ( self::isEnabled() ) {
+ self::$enabled = false;
+ if ( function_exists( 'xhprof_disable' ) ) {
+ return xhprof_disable();
+ } else {
+ // tideways
+ return tideways_disable();
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/XhprofData.php b/www/wiki/includes/libs/XhprofData.php
new file mode 100644
index 00000000..0be4ff6a
--- /dev/null
+++ b/www/wiki/includes/libs/XhprofData.php
@@ -0,0 +1,384 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use RunningStat\RunningStat;
+
+/**
+ * Convenience class for working with XHProf profiling data
+ * <https://github.com/phacility/xhprof>. XHProf can be installed as a PECL
+ * package for use with PHP5 (Zend PHP) and is built-in to HHVM 3.3.0.
+ *
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ * @since 1.28
+ */
+class XhprofData {
+
+ /**
+ * @var array $config
+ */
+ protected $config;
+
+ /**
+ * Hierarchical profiling data returned by xhprof.
+ * @var array $hieraData
+ */
+ protected $hieraData;
+
+ /**
+ * Per-function inclusive data.
+ * @var array $inclusive
+ */
+ protected $inclusive;
+
+ /**
+ * Per-function inclusive and exclusive data.
+ * @var array $complete
+ */
+ protected $complete;
+
+ /**
+ * Configuration data can contain:
+ * - include: Array of function names to include in profiling.
+ * - sort: Key to sort per-function reports on.
+ *
+ * @param array $data Xhprof profiling data, as returned by xhprof_disable()
+ * @param array $config
+ */
+ public function __construct( array $data, array $config = [] ) {
+ $this->config = array_merge( [
+ 'include' => null,
+ 'sort' => 'wt',
+ ], $config );
+
+ $this->hieraData = $this->pruneData( $data );
+ }
+
+ /**
+ * Get raw data collected by xhprof.
+ *
+ * Each key in the returned array is an edge label for the call graph in
+ * the form "caller==>callee". There is once special case edge labled
+ * simply "main()" which represents the global scope entry point of the
+ * application.
+ *
+ * XHProf will collect different data depending on the flags that are used:
+ * - ct: Number of matching events seen.
+ * - wt: Inclusive elapsed wall time for this event in microseconds.
+ * - cpu: Inclusive elapsed cpu time for this event in microseconds.
+ * (XHPROF_FLAGS_CPU)
+ * - mu: Delta of memory usage from start to end of callee in bytes.
+ * (XHPROF_FLAGS_MEMORY)
+ * - pmu: Delta of peak memory usage from start to end of callee in
+ * bytes. (XHPROF_FLAGS_MEMORY)
+ * - alloc: Delta of amount memory requested from malloc() by the callee,
+ * in bytes. (XHPROF_FLAGS_MALLOC)
+ * - free: Delta of amount of memory passed to free() by the callee, in
+ * bytes. (XHPROF_FLAGS_MALLOC)
+ *
+ * @return array
+ * @see getInclusiveMetrics()
+ * @see getCompleteMetrics()
+ */
+ public function getRawData() {
+ return $this->hieraData;
+ }
+
+ /**
+ * Convert an xhprof data key into an array of ['parent', 'child']
+ * function names.
+ *
+ * The resulting array is left padded with nulls, so a key
+ * with no parent (eg 'main()') will return [null, 'function'].
+ *
+ * @param string $key
+ * @return array
+ */
+ public static function splitKey( $key ) {
+ return array_pad( explode( '==>', $key, 2 ), -2, null );
+ }
+
+ /**
+ * Remove data for functions that are not included in the 'include'
+ * configuration array.
+ *
+ * @param array $data Raw xhprof data
+ * @return array
+ */
+ protected function pruneData( $data ) {
+ if ( !$this->config['include'] ) {
+ return $data;
+ }
+
+ $want = array_fill_keys( $this->config['include'], true );
+ $want['main()'] = true;
+
+ $keep = [];
+ foreach ( $data as $key => $stats ) {
+ list( $parent, $child ) = self::splitKey( $key );
+ if ( isset( $want[$parent] ) || isset( $want[$child] ) ) {
+ $keep[$key] = $stats;
+ }
+ }
+ return $keep;
+ }
+
+ /**
+ * Get the inclusive metrics for each function call. Inclusive metrics
+ * for given function include the metrics for all functions that were
+ * called from that function during the measurement period.
+ *
+ * See getRawData() for a description of the metric that are returned for
+ * each funcition call. The values for the wt, cpu, mu and pmu metrics are
+ * arrays with these values:
+ * - total: Cumulative value
+ * - min: Minimum value
+ * - mean: Mean (average) value
+ * - max: Maximum value
+ * - variance: Variance (spread) of the values
+ *
+ * @return array
+ * @see getRawData()
+ * @see getCompleteMetrics()
+ */
+ public function getInclusiveMetrics() {
+ if ( $this->inclusive === null ) {
+ $main = $this->hieraData['main()'];
+ $hasCpu = isset( $main['cpu'] );
+ $hasMu = isset( $main['mu'] );
+ $hasAlloc = isset( $main['alloc'] );
+
+ $this->inclusive = [];
+ foreach ( $this->hieraData as $key => $stats ) {
+ list( $parent, $child ) = self::splitKey( $key );
+ if ( !isset( $this->inclusive[$child] ) ) {
+ $this->inclusive[$child] = [
+ 'ct' => 0,
+ 'wt' => new RunningStat(),
+ ];
+ if ( $hasCpu ) {
+ $this->inclusive[$child]['cpu'] = new RunningStat();
+ }
+ if ( $hasMu ) {
+ $this->inclusive[$child]['mu'] = new RunningStat();
+ $this->inclusive[$child]['pmu'] = new RunningStat();
+ }
+ if ( $hasAlloc ) {
+ $this->inclusive[$child]['alloc'] = new RunningStat();
+ $this->inclusive[$child]['free'] = new RunningStat();
+ }
+ }
+
+ $this->inclusive[$child]['ct'] += $stats['ct'];
+ foreach ( $stats as $stat => $value ) {
+ if ( $stat === 'ct' ) {
+ continue;
+ }
+
+ if ( !isset( $this->inclusive[$child][$stat] ) ) {
+ // Ignore unknown stats
+ continue;
+ }
+
+ for ( $i = 0; $i < $stats['ct']; $i++ ) {
+ $this->inclusive[$child][$stat]->addObservation(
+ $value / $stats['ct']
+ );
+ }
+ }
+ }
+
+ // Convert RunningStat instances to static arrays and add
+ // percentage stats.
+ foreach ( $this->inclusive as $func => $stats ) {
+ foreach ( $stats as $name => $value ) {
+ if ( $value instanceof RunningStat ) {
+ $total = $value->m1 * $value->n;
+ $percent = ( isset( $main[$name] ) && $main[$name] )
+ ? 100 * $total / $main[$name]
+ : 0;
+ $this->inclusive[$func][$name] = [
+ 'total' => $total,
+ 'min' => $value->min,
+ 'mean' => $value->m1,
+ 'max' => $value->max,
+ 'variance' => $value->m2,
+ 'percent' => $percent,
+ ];
+ }
+ }
+ }
+
+ uasort( $this->inclusive, self::makeSortFunction(
+ $this->config['sort'], 'total'
+ ) );
+ }
+ return $this->inclusive;
+ }
+
+ /**
+ * Get the inclusive and exclusive metrics for each function call.
+ *
+ * In addition to the normal data contained in the inclusive metrics, the
+ * metrics have an additional 'exclusive' measurement which is the total
+ * minus the totals of all child function calls.
+ *
+ * @return array
+ * @see getRawData()
+ * @see getInclusiveMetrics()
+ */
+ public function getCompleteMetrics() {
+ if ( $this->complete === null ) {
+ // Start with inclusive data
+ $this->complete = $this->getInclusiveMetrics();
+
+ foreach ( $this->complete as $func => $stats ) {
+ foreach ( $stats as $stat => $value ) {
+ if ( $stat === 'ct' ) {
+ continue;
+ }
+ // Initialize exclusive data with inclusive totals
+ $this->complete[$func][$stat]['exclusive'] = $value['total'];
+ }
+ // Add sapce for call tree information to be filled in later
+ $this->complete[$func]['calls'] = [];
+ $this->complete[$func]['subcalls'] = [];
+ }
+
+ foreach ( $this->hieraData as $key => $stats ) {
+ list( $parent, $child ) = self::splitKey( $key );
+ if ( $parent !== null ) {
+ // Track call tree information
+ $this->complete[$child]['calls'][$parent] = $stats;
+ $this->complete[$parent]['subcalls'][$child] = $stats;
+ }
+
+ if ( isset( $this->complete[$parent] ) ) {
+ // Deduct child inclusive data from exclusive data
+ foreach ( $stats as $stat => $value ) {
+ if ( $stat === 'ct' ) {
+ continue;
+ }
+
+ if ( !isset( $this->complete[$parent][$stat] ) ) {
+ // Ignore unknown stats
+ continue;
+ }
+
+ $this->complete[$parent][$stat]['exclusive'] -= $value;
+ }
+ }
+ }
+
+ uasort( $this->complete, self::makeSortFunction(
+ $this->config['sort'], 'exclusive'
+ ) );
+ }
+ return $this->complete;
+ }
+
+ /**
+ * Get a list of all callers of a given function.
+ *
+ * @param string $function Function name
+ * @return array
+ * @see getEdges()
+ */
+ public function getCallers( $function ) {
+ $edges = $this->getCompleteMetrics();
+ if ( isset( $edges[$function]['calls'] ) ) {
+ return array_keys( $edges[$function]['calls'] );
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * Get a list of all callees from a given function.
+ *
+ * @param string $function Function name
+ * @return array
+ * @see getEdges()
+ */
+ public function getCallees( $function ) {
+ $edges = $this->getCompleteMetrics();
+ if ( isset( $edges[$function]['subcalls'] ) ) {
+ return array_keys( $edges[$function]['subcalls'] );
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * Find the critical path for the given metric.
+ *
+ * @param string $metric Metric to find critical path for
+ * @return array
+ */
+ public function getCriticalPath( $metric = 'wt' ) {
+ $func = 'main()';
+ $path = [
+ $func => $this->hieraData[$func],
+ ];
+ while ( $func ) {
+ $callees = $this->getCallees( $func );
+ $maxCallee = null;
+ $maxCall = null;
+ foreach ( $callees as $callee ) {
+ $call = "{$func}==>{$callee}";
+ if ( $maxCall === null ||
+ $this->hieraData[$call][$metric] >
+ $this->hieraData[$maxCall][$metric]
+ ) {
+ $maxCallee = $callee;
+ $maxCall = $call;
+ }
+ }
+ if ( $maxCall !== null ) {
+ $path[$maxCall] = $this->hieraData[$maxCall];
+ }
+ $func = $maxCallee;
+ }
+ return $path;
+ }
+
+ /**
+ * Make a closure to use as a sort function. The resulting function will
+ * sort by descending numeric values (largest value first).
+ *
+ * @param string $key Data key to sort on
+ * @param string $sub Sub key to sort array values on
+ * @return Closure
+ */
+ public static function makeSortFunction( $key, $sub ) {
+ return function ( $a, $b ) use ( $key, $sub ) {
+ if ( isset( $a[$key] ) && isset( $b[$key] ) ) {
+ // Descending sort: larger values will be first in result.
+ // Assumes all values are numeric.
+ // Values for 'main()' will not have sub keys
+ $valA = is_array( $a[$key] ) ? $a[$key][$sub] : $a[$key];
+ $valB = is_array( $b[$key] ) ? $b[$key][$sub] : $b[$key];
+ return $valB - $valA;
+ } else {
+ // Sort datum with the key before those without
+ return isset( $a[$key] ) ? -1 : 1;
+ }
+ };
+ }
+}
diff --git a/www/wiki/includes/libs/XmlTypeCheck.php b/www/wiki/includes/libs/XmlTypeCheck.php
new file mode 100644
index 00000000..7659dfdd
--- /dev/null
+++ b/www/wiki/includes/libs/XmlTypeCheck.php
@@ -0,0 +1,508 @@
+<?php
+/**
+ * XML syntax and type checker.
+ *
+ * Since 1.24.2, it uses XMLReader instead of xml_parse, which gives us
+ * more control over the expansion of XML entities. When passed to the
+ * callback, entities will be fully expanded, but may report the XML is
+ * invalid if expanding the entities are likely to cause a DoS.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class XmlTypeCheck {
+ /**
+ * Will be set to true or false to indicate whether the file is
+ * well-formed XML. Note that this doesn't check schema validity.
+ */
+ public $wellFormed = null;
+
+ /**
+ * Will be set to true if the optional element filter returned
+ * a match at some point.
+ */
+ public $filterMatch = false;
+
+ /**
+ * Will contain the type of filter hit if the optional element filter returned
+ * a match at some point.
+ * @var mixed
+ */
+ public $filterMatchType = false;
+
+ /**
+ * Name of the document's root element, including any namespace
+ * as an expanded URL.
+ */
+ public $rootElement = '';
+
+ /**
+ * A stack of strings containing the data of each xml element as it's processed. Append
+ * data to the top string of the stack, then pop off the string and process it when the
+ * element is closed.
+ */
+ protected $elementData = [];
+
+ /**
+ * A stack of element names and attributes, as we process them.
+ */
+ protected $elementDataContext = [];
+
+ /**
+ * Current depth of the data stack.
+ */
+ protected $stackDepth = 0;
+
+ /**
+ * Additional parsing options
+ */
+ private $parserOptions = [
+ 'processing_instruction_handler' => '',
+ 'external_dtd_handler' => '',
+ 'dtd_handler' => '',
+ 'require_safe_dtd' => true
+ ];
+
+ /**
+ * Allow filtering an XML file.
+ *
+ * Filters should return either true or a string to indicate something
+ * is wrong with the file. $this->filterMatch will store if the
+ * file failed validation (true = failed validation).
+ * $this->filterMatchType will contain the validation error.
+ * $this->wellFormed will contain whether the xml file is well-formed.
+ *
+ * @note If multiple filters are hit, only one of them will have the
+ * result stored in $this->filterMatchType.
+ *
+ * @param string $input a filename or string containing the XML element
+ * @param callable $filterCallback (optional)
+ * Function to call to do additional custom validity checks from the
+ * SAX element handler event. This gives you access to the element
+ * namespace, name, attributes, and text contents.
+ * Filter should return a truthy value describing the error.
+ * @param bool $isFile (optional) indicates if the first parameter is a
+ * filename (default, true) or if it is a string (false)
+ * @param array $options list of additional parsing options:
+ * processing_instruction_handler: Callback for xml_set_processing_instruction_handler
+ * external_dtd_handler: Callback for the url of external dtd subset
+ * dtd_handler: Callback given the full text of the <!DOCTYPE declaration.
+ * require_safe_dtd: Only allow non-recursive entities in internal dtd (default true)
+ */
+ function __construct( $input, $filterCallback = null, $isFile = true, $options = [] ) {
+ $this->filterCallback = $filterCallback;
+ $this->parserOptions = array_merge( $this->parserOptions, $options );
+ $this->validateFromInput( $input, $isFile );
+ }
+
+ /**
+ * Alternative constructor: from filename
+ *
+ * @param string $fname the filename of an XML document
+ * @param callable $filterCallback (optional)
+ * Function to call to do additional custom validity checks from the
+ * SAX element handler event. This gives you access to the element
+ * namespace, name, and attributes, but not to text contents.
+ * Filter should return 'true' to toggle on $this->filterMatch
+ * @return XmlTypeCheck
+ */
+ public static function newFromFilename( $fname, $filterCallback = null ) {
+ return new self( $fname, $filterCallback, true );
+ }
+
+ /**
+ * Alternative constructor: from string
+ *
+ * @param string $string a string containing an XML element
+ * @param callable $filterCallback (optional)
+ * Function to call to do additional custom validity checks from the
+ * SAX element handler event. This gives you access to the element
+ * namespace, name, and attributes, but not to text contents.
+ * Filter should return 'true' to toggle on $this->filterMatch
+ * @return XmlTypeCheck
+ */
+ public static function newFromString( $string, $filterCallback = null ) {
+ return new self( $string, $filterCallback, false );
+ }
+
+ /**
+ * Get the root element. Simple accessor to $rootElement
+ *
+ * @return string
+ */
+ public function getRootElement() {
+ return $this->rootElement;
+ }
+
+ /**
+ * @param string $fname the filename
+ */
+ private function validateFromInput( $xml, $isFile ) {
+ $reader = new XMLReader();
+ if ( $isFile ) {
+ $s = $reader->open( $xml, null, LIBXML_NOERROR | LIBXML_NOWARNING );
+ } else {
+ $s = $reader->XML( $xml, null, LIBXML_NOERROR | LIBXML_NOWARNING );
+ }
+ if ( $s !== true ) {
+ // Couldn't open the XML
+ $this->wellFormed = false;
+ } else {
+ $oldDisable = libxml_disable_entity_loader( true );
+ $reader->setParserProperty( XMLReader::SUBST_ENTITIES, true );
+ try {
+ $this->validate( $reader );
+ } catch ( Exception $e ) {
+ // Calling this malformed, because we didn't parse the whole
+ // thing. Maybe just an external entity refernce.
+ $this->wellFormed = false;
+ $reader->close();
+ libxml_disable_entity_loader( $oldDisable );
+ throw $e;
+ }
+ $reader->close();
+ libxml_disable_entity_loader( $oldDisable );
+ }
+ }
+
+ private function readNext( XMLReader $reader ) {
+ set_error_handler( [ $this, 'XmlErrorHandler' ] );
+ $ret = $reader->read();
+ restore_error_handler();
+ return $ret;
+ }
+
+ public function XmlErrorHandler( $errno, $errstr ) {
+ $this->wellFormed = false;
+ }
+
+ private function validate( $reader ) {
+
+ // First, move through anything that isn't an element, and
+ // handle any processing instructions with the callback
+ do {
+ if ( !$this->readNext( $reader ) ) {
+ // Hit the end of the document before any elements
+ $this->wellFormed = false;
+ return;
+ }
+ if ( $reader->nodeType === XMLReader::PI ) {
+ $this->processingInstructionHandler( $reader->name, $reader->value );
+ }
+ if ( $reader->nodeType === XMLReader::DOC_TYPE ) {
+ $this->DTDHandler( $reader );
+ }
+ } while ( $reader->nodeType != XMLReader::ELEMENT );
+
+ // Process the rest of the document
+ do {
+ switch ( $reader->nodeType ) {
+ case XMLReader::ELEMENT:
+ $name = $this->expandNS(
+ $reader->name,
+ $reader->namespaceURI
+ );
+ if ( $this->rootElement === '' ) {
+ $this->rootElement = $name;
+ }
+ $empty = $reader->isEmptyElement;
+ $attrs = $this->getAttributesArray( $reader );
+ $this->elementOpen( $name, $attrs );
+ if ( $empty ) {
+ $this->elementClose();
+ }
+ break;
+
+ case XMLReader::END_ELEMENT:
+ $this->elementClose();
+ break;
+
+ case XMLReader::WHITESPACE:
+ case XMLReader::SIGNIFICANT_WHITESPACE:
+ case XMLReader::CDATA:
+ case XMLReader::TEXT:
+ $this->elementData( $reader->value );
+ break;
+
+ case XMLReader::ENTITY_REF:
+ // Unexpanded entity (maybe external?),
+ // don't send to the filter (xml_parse didn't)
+ break;
+
+ case XMLReader::COMMENT:
+ // Don't send to the filter (xml_parse didn't)
+ break;
+
+ case XMLReader::PI:
+ // Processing instructions can happen after the header too
+ $this->processingInstructionHandler(
+ $reader->name,
+ $reader->value
+ );
+ break;
+ case XMLReader::DOC_TYPE:
+ // We should never see a doctype after first
+ // element.
+ $this->wellFormed = false;
+ break;
+ default:
+ // One of DOC, ENTITY, END_ENTITY,
+ // NOTATION, or XML_DECLARATION
+ // xml_parse didn't send these to the filter, so we won't.
+ }
+
+ } while ( $this->readNext( $reader ) );
+
+ if ( $this->stackDepth !== 0 ) {
+ $this->wellFormed = false;
+ } elseif ( $this->wellFormed === null ) {
+ $this->wellFormed = true;
+ }
+
+ }
+
+ /**
+ * Get all of the attributes for an XMLReader's current node
+ * @param $r XMLReader
+ * @return array of attributes
+ */
+ private function getAttributesArray( XMLReader $r ) {
+ $attrs = [];
+ while ( $r->moveToNextAttribute() ) {
+ if ( $r->namespaceURI === 'http://www.w3.org/2000/xmlns/' ) {
+ // XMLReader treats xmlns attributes as normal
+ // attributes, while xml_parse doesn't
+ continue;
+ }
+ $name = $this->expandNS( $r->name, $r->namespaceURI );
+ $attrs[$name] = $r->value;
+ }
+ return $attrs;
+ }
+
+ /**
+ * @param $name element or attribute name, maybe with a full or short prefix
+ * @param $namespaceURI the namespaceURI
+ * @return string the name prefixed with namespaceURI
+ */
+ private function expandNS( $name, $namespaceURI ) {
+ if ( $namespaceURI ) {
+ $parts = explode( ':', $name );
+ $localname = array_pop( $parts );
+ return "$namespaceURI:$localname";
+ }
+ return $name;
+ }
+
+ /**
+ * @param $name
+ * @param $attribs
+ */
+ private function elementOpen( $name, $attribs ) {
+ $this->elementDataContext[] = [ $name, $attribs ];
+ $this->elementData[] = '';
+ $this->stackDepth++;
+ }
+
+ /**
+ */
+ private function elementClose() {
+ list( $name, $attribs ) = array_pop( $this->elementDataContext );
+ $data = array_pop( $this->elementData );
+ $this->stackDepth--;
+ $callbackReturn = false;
+
+ if ( is_callable( $this->filterCallback ) ) {
+ $callbackReturn = call_user_func(
+ $this->filterCallback,
+ $name,
+ $attribs,
+ $data
+ );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ }
+ }
+
+ /**
+ * @param $data
+ */
+ private function elementData( $data ) {
+ // Collect any data here, and we'll run the callback in elementClose
+ $this->elementData[ $this->stackDepth - 1 ] .= trim( $data );
+ }
+
+ /**
+ * @param $target
+ * @param $data
+ */
+ private function processingInstructionHandler( $target, $data ) {
+ $callbackReturn = false;
+ if ( $this->parserOptions['processing_instruction_handler'] ) {
+ $callbackReturn = call_user_func(
+ $this->parserOptions['processing_instruction_handler'],
+ $target,
+ $data
+ );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ }
+ }
+ /**
+ * Handle coming across a <!DOCTYPE declaration.
+ *
+ * @param XMLReader $reader Reader currently pointing at DOCTYPE node.
+ */
+ private function DTDHandler( XMLReader $reader ) {
+ $externalCallback = $this->parserOptions['external_dtd_handler'];
+ $generalCallback = $this->parserOptions['dtd_handler'];
+ $checkIfSafe = $this->parserOptions['require_safe_dtd'];
+ if ( !$externalCallback && !$generalCallback && !$checkIfSafe ) {
+ return;
+ }
+ $dtd = $reader->readOuterXML();
+ $callbackReturn = false;
+
+ if ( $generalCallback ) {
+ $callbackReturn = call_user_func( $generalCallback, $dtd );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ $callbackReturn = false;
+ }
+
+ $parsedDTD = $this->parseDTD( $dtd );
+ if ( $externalCallback && isset( $parsedDTD['type'] ) ) {
+ $callbackReturn = call_user_func(
+ $externalCallback,
+ $parsedDTD['type'],
+ isset( $parsedDTD['publicid'] ) ? $parsedDTD['publicid'] : null,
+ isset( $parsedDTD['systemid'] ) ? $parsedDTD['systemid'] : null
+ );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ $callbackReturn = false;
+ }
+
+ if ( $checkIfSafe && isset( $parsedDTD['internal'] ) ) {
+ if ( !$this->checkDTDIsSafe( $parsedDTD['internal'] ) ) {
+ $this->wellFormed = false;
+ }
+ }
+ }
+
+ /**
+ * Check if the internal subset of the DTD is safe.
+ *
+ * We whitelist an extremely restricted subset of DTD features.
+ *
+ * Safe is defined as:
+ * * Only contains entity defintions (e.g. No <!ATLIST )
+ * * Entity definitions are not "system" entities
+ * * Entity definitions are not "parameter" (i.e. %) entities
+ * * Entity definitions do not reference other entites except &amp;
+ * and quotes. Entity aliases (where the entity contains only
+ * another entity are allowed)
+ * * Entity references aren't overly long (>255 bytes).
+ * * <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink">
+ * allowed if matched exactly for compatibility with graphviz
+ * * Comments.
+ *
+ * @param string $internalSubset The internal subset of the DTD
+ * @return bool true if safe.
+ */
+ private function checkDTDIsSafe( $internalSubset ) {
+ $offset = 0;
+ $res = preg_match(
+ '/^(?:\s*<!ENTITY\s+\S+\s+' .
+ '(?:"(?:&[^"%&;]{1,64};|(?:[^"%&]|&amp;|&quot;){0,255})"' .
+ '|\'(?:&[^"%&;]{1,64};|(?:[^\'%&]|&amp;|&apos;){0,255})\')\s*>' .
+ '|\s*<!--(?:[^-]|-[^-])*-->' .
+ '|\s*<!ATTLIST svg xmlns:xlink CDATA #FIXED ' .
+ '"http:\/\/www.w3.org\/1999\/xlink">)*\s*$/',
+ $internalSubset
+ );
+
+ return (bool)$res;
+ }
+
+ /**
+ * Parse DTD into parts.
+ *
+ * If there is an error parsing the dtd, sets wellFormed to false.
+ *
+ * @param $dtd string
+ * @return array Possibly containing keys publicid, systemid, type and internal.
+ */
+ private function parseDTD( $dtd ) {
+ $m = [];
+ $res = preg_match(
+ '/^<!DOCTYPE\s*\S+\s*' .
+ '(?:(?P<typepublic>PUBLIC)\s*' .
+ '(?:"(?P<pubquote>[^"]*)"|\'(?P<pubapos>[^\']*)\')' . // public identifer
+ '\s*"(?P<pubsysquote>[^"]*)"|\'(?P<pubsysapos>[^\']*)\'' . // system identifier
+ '|(?P<typesystem>SYSTEM)\s*' .
+ '(?:"(?P<sysquote>[^"]*)"|\'(?P<sysapos>[^\']*)\')' .
+ ')?\s*' .
+ '(?:\[\s*(?P<internal>.*)\])?\s*>$/s',
+ $dtd,
+ $m
+ );
+ if ( !$res ) {
+ $this->wellFormed = false;
+ return [];
+ }
+ $parsed = [];
+ foreach ( $m as $field => $value ) {
+ if ( $value === '' || is_numeric( $field ) ) {
+ continue;
+ }
+ switch ( $field ) {
+ case 'typepublic':
+ case 'typesystem':
+ $parsed['type'] = $value;
+ break;
+ case 'pubquote':
+ case 'pubapos':
+ $parsed['publicid'] = $value;
+ break;
+ case 'pubsysquote':
+ case 'pubsysapos':
+ case 'sysquote':
+ case 'sysapos':
+ $parsed['systemid'] = $value;
+ break;
+ case 'internal':
+ $parsed['internal'] = $value;
+ break;
+ }
+ }
+ return $parsed;
+ }
+}
diff --git a/www/wiki/includes/libs/composer/ComposerInstalled.php b/www/wiki/includes/libs/composer/ComposerInstalled.php
new file mode 100644
index 00000000..ef2b768d
--- /dev/null
+++ b/www/wiki/includes/libs/composer/ComposerInstalled.php
@@ -0,0 +1,38 @@
+<?php
+
+/**
+ * Reads an installed.json file and provides accessors to get what is
+ * installed
+ *
+ * @since 1.27
+ */
+class ComposerInstalled {
+
+ /**
+ * @param string $location
+ */
+ public function __construct( $location ) {
+ $this->contents = json_decode( file_get_contents( $location ), true );
+ }
+
+ /**
+ * Dependencies currently installed according to installed.json
+ *
+ * @return array
+ */
+ public function getInstalledDependencies() {
+ $deps = [];
+ foreach ( $this->contents as $installed ) {
+ $deps[$installed['name']] = [
+ 'version' => ComposerJson::normalizeVersion( $installed['version'] ),
+ 'type' => $installed['type'],
+ 'licenses' => isset( $installed['license'] ) ? $installed['license'] : [],
+ 'authors' => isset( $installed['authors'] ) ? $installed['authors'] : [],
+ 'description' => isset( $installed['description'] ) ? $installed['description'] : '',
+ ];
+ }
+
+ ksort( $deps );
+ return $deps;
+ }
+}
diff --git a/www/wiki/includes/libs/composer/ComposerJson.php b/www/wiki/includes/libs/composer/ComposerJson.php
new file mode 100644
index 00000000..62231a89
--- /dev/null
+++ b/www/wiki/includes/libs/composer/ComposerJson.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * Reads a composer.json file and provides accessors to get
+ * its hash and the required dependencies
+ *
+ * @since 1.25
+ */
+class ComposerJson {
+
+ /**
+ * @param string $location
+ */
+ public function __construct( $location ) {
+ $this->contents = json_decode( file_get_contents( $location ), true );
+ }
+
+ /**
+ * Dependencies as specified by composer.json
+ *
+ * @return array
+ */
+ public function getRequiredDependencies() {
+ $deps = [];
+ if ( isset( $this->contents['require'] ) ) {
+ foreach ( $this->contents['require'] as $package => $version ) {
+ if ( $package !== "php" && strpos( $package, 'ext-' ) !== 0 ) {
+ $deps[$package] = self::normalizeVersion( $version );
+ }
+ }
+ }
+
+ return $deps;
+ }
+
+ /**
+ * Strip a leading "v" from the version name
+ *
+ * @param string $version
+ * @return string
+ */
+ public static function normalizeVersion( $version ) {
+ if ( strpos( $version, 'v' ) === 0 ) {
+ // Composer auto-strips the "v" in front of the tag name
+ $version = ltrim( $version, 'v' );
+ }
+
+ return $version;
+ }
+
+}
diff --git a/www/wiki/includes/libs/composer/ComposerLock.php b/www/wiki/includes/libs/composer/ComposerLock.php
new file mode 100644
index 00000000..dc8bc035
--- /dev/null
+++ b/www/wiki/includes/libs/composer/ComposerLock.php
@@ -0,0 +1,37 @@
+<?php
+
+/**
+ * Reads a composer.lock file and provides accessors to get
+ * its hash and what is installed
+ *
+ * @since 1.25
+ */
+class ComposerLock {
+
+ /**
+ * @param string $location
+ */
+ public function __construct( $location ) {
+ $this->contents = json_decode( file_get_contents( $location ), true );
+ }
+
+ /**
+ * Dependencies currently installed according to composer.lock
+ *
+ * @return array
+ */
+ public function getInstalledDependencies() {
+ $deps = [];
+ foreach ( $this->contents['packages'] as $installed ) {
+ $deps[$installed['name']] = [
+ 'version' => ComposerJson::normalizeVersion( $installed['version'] ),
+ 'type' => $installed['type'],
+ 'licenses' => isset( $installed['license'] ) ? $installed['license'] : [],
+ 'authors' => isset( $installed['authors'] ) ? $installed['authors'] : [],
+ 'description' => isset( $installed['description'] ) ? $installed['description'] : '',
+ ];
+ }
+
+ return $deps;
+ }
+}
diff --git a/www/wiki/includes/libs/eventrelayer/EventRelayer.php b/www/wiki/includes/libs/eventrelayer/EventRelayer.php
new file mode 100644
index 00000000..0cc9b3d2
--- /dev/null
+++ b/www/wiki/includes/libs/eventrelayer/EventRelayer.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * Base class for reliable event relays
+ */
+abstract class EventRelayer implements LoggerAwareInterface {
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /**
+ * @param array $params
+ */
+ public function __construct( array $params ) {
+ $this->logger = new NullLogger();
+ }
+
+ /**
+ * @param string $channel
+ * @param array $event Event data map
+ * @return bool Success
+ */
+ final public function notify( $channel, $event ) {
+ return $this->doNotify( $channel, [ $event ] );
+ }
+
+ /**
+ * @param string $channel
+ * @param array $events List of event data maps
+ * @return bool Success
+ */
+ final public function notifyMulti( $channel, $events ) {
+ return $this->doNotify( $channel, $events );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param string $channel
+ * @param array $events List of event data maps
+ * @return bool Success
+ */
+ abstract protected function doNotify( $channel, array $events );
+}
diff --git a/www/wiki/includes/libs/eventrelayer/EventRelayerKafka.php b/www/wiki/includes/libs/eventrelayer/EventRelayerKafka.php
new file mode 100644
index 00000000..999eb439
--- /dev/null
+++ b/www/wiki/includes/libs/eventrelayer/EventRelayerKafka.php
@@ -0,0 +1,62 @@
+<?php
+use Kafka\Produce;
+
+/**
+ * Event relayer for Apache Kafka.
+ * Configuring for WANCache:
+ * 'relayerConfig' => [ 'class' => 'EventRelayerKafka', 'KafkaEventHost' => 'localhost:9092' ],
+ */
+class EventRelayerKafka extends EventRelayer {
+ /**
+ * Configuration.
+ *
+ * @var Config
+ */
+ protected $config;
+
+ /**
+ * Kafka producer.
+ *
+ * @var Produce
+ */
+ protected $producer;
+
+ /**
+ * Create Kafka producer.
+ *
+ * @param array $params
+ */
+ public function __construct( array $params ) {
+ parent::__construct( $params );
+
+ $this->config = new HashConfig( $params );
+ if ( !$this->config->has( 'KafkaEventHost' ) ) {
+ throw new InvalidArgumentException( "KafkaEventHost must be configured" );
+ }
+ }
+
+ /**
+ * Get the producer object from kafka-php.
+ * @return Produce
+ */
+ protected function getKafkaProducer() {
+ if ( !$this->producer ) {
+ $this->producer = Produce::getInstance(
+ null, null, $this->config->get( 'KafkaEventHost' ) );
+ }
+ return $this->producer;
+ }
+
+ protected function doNotify( $channel, array $events ) {
+ $jsonEvents = array_map( 'json_encode', $events );
+ try {
+ $producer = $this->getKafkaProducer();
+ $producer->setMessages( $channel, 0, $jsonEvents );
+ $producer->send();
+ } catch ( \Kafka\Exception $e ) {
+ $this->logger->warning( "Sending events failed: $e" );
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/www/wiki/includes/libs/eventrelayer/EventRelayerNull.php b/www/wiki/includes/libs/eventrelayer/EventRelayerNull.php
new file mode 100644
index 00000000..d933dd42
--- /dev/null
+++ b/www/wiki/includes/libs/eventrelayer/EventRelayerNull.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * No-op class for publishing messages into a PubSub system
+ */
+class EventRelayerNull extends EventRelayer {
+ public function doNotify( $channel, array $events ) {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/FSFileBackend.php b/www/wiki/includes/libs/filebackend/FSFileBackend.php
new file mode 100644
index 00000000..30548ef0
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/FSFileBackend.php
@@ -0,0 +1,984 @@
+<?php
+/**
+ * File system based backend.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+
+/**
+ * @brief Class for a file system (FS) based file backend.
+ *
+ * All "containers" each map to a directory under the backend's base directory.
+ * For backwards-compatibility, some container paths can be set to custom paths.
+ * The domain ID will not be used in any custom paths, so this should be avoided.
+ *
+ * Having directories with thousands of files will diminish performance.
+ * Sharding can be accomplished by using FileRepo-style hash paths.
+ *
+ * StatusValue messages should avoid mentioning the internal FS paths.
+ * PHP warnings are assumed to be logged rather than output.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+class FSFileBackend extends FileBackendStore {
+ /** @var string Directory holding the container directories */
+ protected $basePath;
+
+ /** @var array Map of container names to root paths for custom container paths */
+ protected $containerPaths = [];
+
+ /** @var int File permission mode */
+ protected $fileMode;
+ /** @var int File permission mode */
+ protected $dirMode;
+
+ /** @var string Required OS username to own files */
+ protected $fileOwner;
+
+ /** @var bool */
+ protected $isWindows;
+ /** @var string OS username running this script */
+ protected $currentUser;
+
+ /** @var array */
+ protected $hadWarningErrors = [];
+
+ /**
+ * @see FileBackendStore::__construct()
+ * Additional $config params include:
+ * - basePath : File system directory that holds containers.
+ * - containerPaths : Map of container names to custom file system directories.
+ * This should only be used for backwards-compatibility.
+ * - fileMode : Octal UNIX file permissions to use on files stored.
+ * - directoryMode : Octal UNIX file permissions to use on directories created.
+ * @param array $config
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->isWindows = ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' );
+ // Remove any possible trailing slash from directories
+ if ( isset( $config['basePath'] ) ) {
+ $this->basePath = rtrim( $config['basePath'], '/' ); // remove trailing slash
+ } else {
+ $this->basePath = null; // none; containers must have explicit paths
+ }
+
+ if ( isset( $config['containerPaths'] ) ) {
+ $this->containerPaths = (array)$config['containerPaths'];
+ foreach ( $this->containerPaths as &$path ) {
+ $path = rtrim( $path, '/' ); // remove trailing slash
+ }
+ }
+
+ $this->fileMode = isset( $config['fileMode'] ) ? $config['fileMode'] : 0644;
+ $this->dirMode = isset( $config['directoryMode'] ) ? $config['directoryMode'] : 0777;
+ if ( isset( $config['fileOwner'] ) && function_exists( 'posix_getuid' ) ) {
+ $this->fileOwner = $config['fileOwner'];
+ // cache this, assuming it doesn't change
+ $this->currentUser = posix_getpwuid( posix_getuid() )['name'];
+ }
+ }
+
+ public function getFeatures() {
+ return !$this->isWindows ? FileBackend::ATTR_UNICODE_PATHS : 0;
+ }
+
+ protected function resolveContainerPath( $container, $relStoragePath ) {
+ // Check that container has a root directory
+ if ( isset( $this->containerPaths[$container] ) || isset( $this->basePath ) ) {
+ // Check for sane relative paths (assume the base paths are OK)
+ if ( $this->isLegalRelPath( $relStoragePath ) ) {
+ return $relStoragePath;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Sanity check a relative file system path for validity
+ *
+ * @param string $path Normalized relative path
+ * @return bool
+ */
+ protected function isLegalRelPath( $path ) {
+ // Check for file names longer than 255 chars
+ if ( preg_match( '![^/]{256}!', $path ) ) { // ext3/NTFS
+ return false;
+ }
+ if ( $this->isWindows ) { // NTFS
+ return !preg_match( '![:*?"<>|]!', $path );
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Given the short (unresolved) and full (resolved) name of
+ * a container, return the file system path of the container.
+ *
+ * @param string $shortCont
+ * @param string $fullCont
+ * @return string|null
+ */
+ protected function containerFSRoot( $shortCont, $fullCont ) {
+ if ( isset( $this->containerPaths[$shortCont] ) ) {
+ return $this->containerPaths[$shortCont];
+ } elseif ( isset( $this->basePath ) ) {
+ return "{$this->basePath}/{$fullCont}";
+ }
+
+ return null; // no container base path defined
+ }
+
+ /**
+ * Get the absolute file system path for a storage path
+ *
+ * @param string $storagePath Storage path
+ * @return string|null
+ */
+ protected function resolveToFSPath( $storagePath ) {
+ list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath );
+ if ( $relPath === null ) {
+ return null; // invalid
+ }
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $storagePath );
+ $fsPath = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ if ( $relPath != '' ) {
+ $fsPath .= "/{$relPath}";
+ }
+
+ return $fsPath;
+ }
+
+ public function isPathUsableInternal( $storagePath ) {
+ $fsPath = $this->resolveToFSPath( $storagePath );
+ if ( $fsPath === null ) {
+ return false; // invalid
+ }
+ $parentDir = dirname( $fsPath );
+
+ if ( file_exists( $fsPath ) ) {
+ $ok = is_file( $fsPath ) && is_writable( $fsPath );
+ } else {
+ $ok = is_dir( $parentDir ) && is_writable( $parentDir );
+ }
+
+ if ( $this->fileOwner !== null && $this->currentUser !== $this->fileOwner ) {
+ $ok = false;
+ trigger_error( __METHOD__ . ": PHP process owner is not '{$this->fileOwner}'." );
+ }
+
+ return $ok;
+ }
+
+ protected function doCreateInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $dest = $this->resolveToFSPath( $params['dst'] );
+ if ( $dest === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $tempFile = TempFSFile::factory( 'create_', 'tmp', $this->tmpDirectory );
+ if ( !$tempFile ) {
+ $status->fatal( 'backend-fail-create', $params['dst'] );
+
+ return $status;
+ }
+ $this->trapWarnings();
+ $bytes = file_put_contents( $tempFile->getPath(), $params['content'] );
+ $this->untrapWarnings();
+ if ( $bytes === false ) {
+ $status->fatal( 'backend-fail-create', $params['dst'] );
+
+ return $status;
+ }
+ $cmd = implode( ' ', [
+ $this->isWindows ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
+ escapeshellarg( $this->cleanPathSlashes( $tempFile->getPath() ) ),
+ escapeshellarg( $this->cleanPathSlashes( $dest ) )
+ ] );
+ $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-create', $params['dst'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
+ $tempFile->bind( $status->value );
+ } else { // immediate write
+ $this->trapWarnings();
+ $bytes = file_put_contents( $dest, $params['content'] );
+ $this->untrapWarnings();
+ if ( $bytes === false ) {
+ $status->fatal( 'backend-fail-create', $params['dst'] );
+
+ return $status;
+ }
+ $this->chmod( $dest );
+ }
+
+ return $status;
+ }
+
+ protected function doStoreInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $dest = $this->resolveToFSPath( $params['dst'] );
+ if ( $dest === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $cmd = implode( ' ', [
+ $this->isWindows ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
+ escapeshellarg( $this->cleanPathSlashes( $params['src'] ) ),
+ escapeshellarg( $this->cleanPathSlashes( $dest ) )
+ ] );
+ $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
+ } else { // immediate write
+ $this->trapWarnings();
+ $ok = copy( $params['src'], $dest );
+ $this->untrapWarnings();
+ // In some cases (at least over NFS), copy() returns true when it fails
+ if ( !$ok || ( filesize( $params['src'] ) !== filesize( $dest ) ) ) {
+ if ( $ok ) { // PHP bug
+ unlink( $dest ); // remove broken file
+ trigger_error( __METHOD__ . ": copy() failed but returned true." );
+ }
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+ $this->chmod( $dest );
+ }
+
+ return $status;
+ }
+
+ protected function doCopyInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $source = $this->resolveToFSPath( $params['src'] );
+ if ( $source === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $dest = $this->resolveToFSPath( $params['dst'] );
+ if ( $dest === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !is_file( $source ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-copy', $params['src'] );
+ }
+
+ return $status; // do nothing; either OK or bad status
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $cmd = implode( ' ', [
+ $this->isWindows ? 'COPY /B /Y' : 'cp', // (binary, overwrite)
+ escapeshellarg( $this->cleanPathSlashes( $source ) ),
+ escapeshellarg( $this->cleanPathSlashes( $dest ) )
+ ] );
+ $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd, $dest );
+ } else { // immediate write
+ $this->trapWarnings();
+ $ok = ( $source === $dest ) ? true : copy( $source, $dest );
+ $this->untrapWarnings();
+ // In some cases (at least over NFS), copy() returns true when it fails
+ if ( !$ok || ( filesize( $source ) !== filesize( $dest ) ) ) {
+ if ( $ok ) { // PHP bug
+ $this->trapWarnings();
+ unlink( $dest ); // remove broken file
+ $this->untrapWarnings();
+ trigger_error( __METHOD__ . ": copy() failed but returned true." );
+ }
+ $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+ $this->chmod( $dest );
+ }
+
+ return $status;
+ }
+
+ protected function doMoveInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $source = $this->resolveToFSPath( $params['src'] );
+ if ( $source === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $dest = $this->resolveToFSPath( $params['dst'] );
+ if ( $dest === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !is_file( $source ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-move', $params['src'] );
+ }
+
+ return $status; // do nothing; either OK or bad status
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $cmd = implode( ' ', [
+ $this->isWindows ? 'MOVE /Y' : 'mv', // (overwrite)
+ escapeshellarg( $this->cleanPathSlashes( $source ) ),
+ escapeshellarg( $this->cleanPathSlashes( $dest ) )
+ ] );
+ $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
+ } else { // immediate write
+ $this->trapWarnings();
+ $ok = ( $source === $dest ) ? true : rename( $source, $dest );
+ $this->untrapWarnings();
+ clearstatcache(); // file no longer at source
+ if ( !$ok ) {
+ $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+ }
+
+ return $status;
+ }
+
+ protected function doDeleteInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $source = $this->resolveToFSPath( $params['src'] );
+ if ( $source === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ if ( !is_file( $source ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+ }
+
+ return $status; // do nothing; either OK or bad status
+ }
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $cmd = implode( ' ', [
+ $this->isWindows ? 'DEL' : 'unlink',
+ escapeshellarg( $this->cleanPathSlashes( $source ) )
+ ] );
+ $handler = function ( $errors, StatusValue $status, array $params, $cmd ) {
+ if ( $errors !== '' && !( $this->isWindows && $errors[0] === " " ) ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+ trigger_error( "$cmd\n$errors", E_USER_WARNING ); // command output
+ }
+ };
+ $status->value = new FSFileOpHandle( $this, $params, $handler, $cmd );
+ } else { // immediate write
+ $this->trapWarnings();
+ $ok = unlink( $source );
+ $this->untrapWarnings();
+ if ( !$ok ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+
+ return $status;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param string $fullCont
+ * @param string $dirRel
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doPrepareInternal( $fullCont, $dirRel, array $params ) {
+ $status = $this->newStatus();
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ $existed = is_dir( $dir ); // already there?
+ // Create the directory and its parents as needed...
+ $this->trapWarnings();
+ if ( !$existed && !mkdir( $dir, $this->dirMode, true ) && !is_dir( $dir ) ) {
+ $this->logger->error( __METHOD__ . ": cannot create directory $dir" );
+ $status->fatal( 'directorycreateerror', $params['dir'] ); // fails on races
+ } elseif ( !is_writable( $dir ) ) {
+ $this->logger->error( __METHOD__ . ": directory $dir is read-only" );
+ $status->fatal( 'directoryreadonlyerror', $params['dir'] );
+ } elseif ( !is_readable( $dir ) ) {
+ $this->logger->error( __METHOD__ . ": directory $dir is not readable" );
+ $status->fatal( 'directorynotreadableerror', $params['dir'] );
+ }
+ $this->untrapWarnings();
+ // Respect any 'noAccess' or 'noListing' flags...
+ if ( is_dir( $dir ) && !$existed ) {
+ $status->merge( $this->doSecureInternal( $fullCont, $dirRel, $params ) );
+ }
+
+ return $status;
+ }
+
+ protected function doSecureInternal( $fullCont, $dirRel, array $params ) {
+ $status = $this->newStatus();
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ // Seed new directories with a blank index.html, to prevent crawling...
+ if ( !empty( $params['noListing'] ) && !file_exists( "{$dir}/index.html" ) ) {
+ $this->trapWarnings();
+ $bytes = file_put_contents( "{$dir}/index.html", $this->indexHtmlPrivate() );
+ $this->untrapWarnings();
+ if ( $bytes === false ) {
+ $status->fatal( 'backend-fail-create', $params['dir'] . '/index.html' );
+ }
+ }
+ // Add a .htaccess file to the root of the container...
+ if ( !empty( $params['noAccess'] ) && !file_exists( "{$contRoot}/.htaccess" ) ) {
+ $this->trapWarnings();
+ $bytes = file_put_contents( "{$contRoot}/.htaccess", $this->htaccessPrivate() );
+ $this->untrapWarnings();
+ if ( $bytes === false ) {
+ $storeDir = "mwstore://{$this->name}/{$shortCont}";
+ $status->fatal( 'backend-fail-create', "{$storeDir}/.htaccess" );
+ }
+ }
+
+ return $status;
+ }
+
+ protected function doPublishInternal( $fullCont, $dirRel, array $params ) {
+ $status = $this->newStatus();
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ // Unseed new directories with a blank index.html, to allow crawling...
+ if ( !empty( $params['listing'] ) && is_file( "{$dir}/index.html" ) ) {
+ $exists = ( file_get_contents( "{$dir}/index.html" ) === $this->indexHtmlPrivate() );
+ $this->trapWarnings();
+ if ( $exists && !unlink( "{$dir}/index.html" ) ) { // reverse secure()
+ $status->fatal( 'backend-fail-delete', $params['dir'] . '/index.html' );
+ }
+ $this->untrapWarnings();
+ }
+ // Remove the .htaccess file from the root of the container...
+ if ( !empty( $params['access'] ) && is_file( "{$contRoot}/.htaccess" ) ) {
+ $exists = ( file_get_contents( "{$contRoot}/.htaccess" ) === $this->htaccessPrivate() );
+ $this->trapWarnings();
+ if ( $exists && !unlink( "{$contRoot}/.htaccess" ) ) { // reverse secure()
+ $storeDir = "mwstore://{$this->name}/{$shortCont}";
+ $status->fatal( 'backend-fail-delete', "{$storeDir}/.htaccess" );
+ }
+ $this->untrapWarnings();
+ }
+
+ return $status;
+ }
+
+ protected function doCleanInternal( $fullCont, $dirRel, array $params ) {
+ $status = $this->newStatus();
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ $this->trapWarnings();
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir ); // remove directory if empty
+ }
+ $this->untrapWarnings();
+
+ return $status;
+ }
+
+ protected function doGetFileStat( array $params ) {
+ $source = $this->resolveToFSPath( $params['src'] );
+ if ( $source === null ) {
+ return false; // invalid storage path
+ }
+
+ $this->trapWarnings(); // don't trust 'false' if there were errors
+ $stat = is_file( $source ) ? stat( $source ) : false; // regular files only
+ $hadError = $this->untrapWarnings();
+
+ if ( $stat ) {
+ $ct = new ConvertibleTimestamp( $stat['mtime'] );
+
+ return [
+ 'mtime' => $ct->getTimestamp( TS_MW ),
+ 'size' => $stat['size']
+ ];
+ } elseif ( !$hadError ) {
+ return false; // file does not exist
+ } else {
+ return null; // failure
+ }
+ }
+
+ protected function doClearCache( array $paths = null ) {
+ clearstatcache(); // clear the PHP file stat cache
+ }
+
+ protected function doDirectoryExists( $fullCont, $dirRel, array $params ) {
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+
+ $this->trapWarnings(); // don't trust 'false' if there were errors
+ $exists = is_dir( $dir );
+ $hadError = $this->untrapWarnings();
+
+ return $hadError ? null : $exists;
+ }
+
+ /**
+ * @see FileBackendStore::getDirectoryListInternal()
+ * @param string $fullCont
+ * @param string $dirRel
+ * @param array $params
+ * @return array|FSFileBackendDirList|null
+ */
+ public function getDirectoryListInternal( $fullCont, $dirRel, array $params ) {
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ $exists = is_dir( $dir );
+ if ( !$exists ) {
+ $this->logger->warning( __METHOD__ . "() given directory does not exist: '$dir'\n" );
+
+ return []; // nothing under this dir
+ } elseif ( !is_readable( $dir ) ) {
+ $this->logger->warning( __METHOD__ . "() given directory is unreadable: '$dir'\n" );
+
+ return null; // bad permissions?
+ }
+
+ return new FSFileBackendDirList( $dir, $params );
+ }
+
+ /**
+ * @see FileBackendStore::getFileListInternal()
+ * @param string $fullCont
+ * @param string $dirRel
+ * @param array $params
+ * @return array|FSFileBackendFileList|null
+ */
+ public function getFileListInternal( $fullCont, $dirRel, array $params ) {
+ list( , $shortCont, ) = FileBackend::splitStoragePath( $params['dir'] );
+ $contRoot = $this->containerFSRoot( $shortCont, $fullCont ); // must be valid
+ $dir = ( $dirRel != '' ) ? "{$contRoot}/{$dirRel}" : $contRoot;
+ $exists = is_dir( $dir );
+ if ( !$exists ) {
+ $this->logger->warning( __METHOD__ . "() given directory does not exist: '$dir'\n" );
+
+ return []; // nothing under this dir
+ } elseif ( !is_readable( $dir ) ) {
+ $this->logger->warning( __METHOD__ . "() given directory is unreadable: '$dir'\n" );
+
+ return null; // bad permissions?
+ }
+
+ return new FSFileBackendFileList( $dir, $params );
+ }
+
+ protected function doGetLocalReferenceMulti( array $params ) {
+ $fsFiles = []; // (path => FSFile)
+
+ foreach ( $params['srcs'] as $src ) {
+ $source = $this->resolveToFSPath( $src );
+ if ( $source === null || !is_file( $source ) ) {
+ $fsFiles[$src] = null; // invalid path or file does not exist
+ } else {
+ $fsFiles[$src] = new FSFile( $source );
+ }
+ }
+
+ return $fsFiles;
+ }
+
+ protected function doGetLocalCopyMulti( array $params ) {
+ $tmpFiles = []; // (path => TempFSFile)
+
+ foreach ( $params['srcs'] as $src ) {
+ $source = $this->resolveToFSPath( $src );
+ if ( $source === null ) {
+ $tmpFiles[$src] = null; // invalid path
+ } else {
+ // Create a new temporary file with the same extension...
+ $ext = FileBackend::extensionFromPath( $src );
+ $tmpFile = TempFSFile::factory( 'localcopy_', $ext, $this->tmpDirectory );
+ if ( !$tmpFile ) {
+ $tmpFiles[$src] = null;
+ } else {
+ $tmpPath = $tmpFile->getPath();
+ // Copy the source file over the temp file
+ $this->trapWarnings();
+ $ok = copy( $source, $tmpPath );
+ $this->untrapWarnings();
+ if ( !$ok ) {
+ $tmpFiles[$src] = null;
+ } else {
+ $this->chmod( $tmpPath );
+ $tmpFiles[$src] = $tmpFile;
+ }
+ }
+ }
+ }
+
+ return $tmpFiles;
+ }
+
+ protected function directoriesAreVirtual() {
+ return false;
+ }
+
+ /**
+ * @param FSFileOpHandle[] $fileOpHandles
+ *
+ * @return StatusValue[]
+ */
+ protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
+ $statuses = [];
+
+ $pipes = [];
+ foreach ( $fileOpHandles as $index => $fileOpHandle ) {
+ $pipes[$index] = popen( "{$fileOpHandle->cmd} 2>&1", 'r' );
+ }
+
+ $errs = [];
+ foreach ( $pipes as $index => $pipe ) {
+ // Result will be empty on success in *NIX. On Windows,
+ // it may be something like " 1 file(s) [copied|moved].".
+ $errs[$index] = stream_get_contents( $pipe );
+ fclose( $pipe );
+ }
+
+ foreach ( $fileOpHandles as $index => $fileOpHandle ) {
+ $status = $this->newStatus();
+ $function = $fileOpHandle->call;
+ $function( $errs[$index], $status, $fileOpHandle->params, $fileOpHandle->cmd );
+ $statuses[$index] = $status;
+ if ( $status->isOK() && $fileOpHandle->chmodPath ) {
+ $this->chmod( $fileOpHandle->chmodPath );
+ }
+ }
+
+ clearstatcache(); // files changed
+ return $statuses;
+ }
+
+ /**
+ * Chmod a file, suppressing the warnings
+ *
+ * @param string $path Absolute file system path
+ * @return bool Success
+ */
+ protected function chmod( $path ) {
+ $this->trapWarnings();
+ $ok = chmod( $path, $this->fileMode );
+ $this->untrapWarnings();
+
+ return $ok;
+ }
+
+ /**
+ * Return the text of an index.html file to hide directory listings
+ *
+ * @return string
+ */
+ protected function indexHtmlPrivate() {
+ return '';
+ }
+
+ /**
+ * Return the text of a .htaccess file to make a directory private
+ *
+ * @return string
+ */
+ protected function htaccessPrivate() {
+ return "Deny from all\n";
+ }
+
+ /**
+ * Clean up directory separators for the given OS
+ *
+ * @param string $path FS path
+ * @return string
+ */
+ protected function cleanPathSlashes( $path ) {
+ return $this->isWindows ? strtr( $path, '/', '\\' ) : $path;
+ }
+
+ /**
+ * Listen for E_WARNING errors and track whether any happen
+ */
+ protected function trapWarnings() {
+ $this->hadWarningErrors[] = false; // push to stack
+ set_error_handler( [ $this, 'handleWarning' ], E_WARNING );
+ }
+
+ /**
+ * Stop listening for E_WARNING errors and return true if any happened
+ *
+ * @return bool
+ */
+ protected function untrapWarnings() {
+ restore_error_handler(); // restore previous handler
+ return array_pop( $this->hadWarningErrors ); // pop from stack
+ }
+
+ /**
+ * @param int $errno
+ * @param string $errstr
+ * @return bool
+ * @access private
+ */
+ public function handleWarning( $errno, $errstr ) {
+ $this->logger->error( $errstr ); // more detailed error logging
+ $this->hadWarningErrors[count( $this->hadWarningErrors ) - 1] = true;
+
+ return true; // suppress from PHP handler
+ }
+}
+
+/**
+ * @see FileBackendStoreOpHandle
+ */
+class FSFileOpHandle extends FileBackendStoreOpHandle {
+ public $cmd; // string; shell command
+ public $chmodPath; // string; file to chmod
+
+ /**
+ * @param FSFileBackend $backend
+ * @param array $params
+ * @param callable $call
+ * @param string $cmd
+ * @param int|null $chmodPath
+ */
+ public function __construct(
+ FSFileBackend $backend, array $params, $call, $cmd, $chmodPath = null
+ ) {
+ $this->backend = $backend;
+ $this->params = $params;
+ $this->call = $call;
+ $this->cmd = $cmd;
+ $this->chmodPath = $chmodPath;
+ }
+}
+
+/**
+ * Wrapper around RecursiveDirectoryIterator/DirectoryIterator that
+ * catches exception or does any custom behavoir that we may want.
+ * Do not use this class from places outside FSFileBackend.
+ *
+ * @ingroup FileBackend
+ */
+abstract class FSFileBackendList implements Iterator {
+ /** @var Iterator */
+ protected $iter;
+
+ /** @var int */
+ protected $suffixStart;
+
+ /** @var int */
+ protected $pos = 0;
+
+ /** @var array */
+ protected $params = [];
+
+ /**
+ * @param string $dir File system directory
+ * @param array $params
+ */
+ public function __construct( $dir, array $params ) {
+ $path = realpath( $dir ); // normalize
+ if ( $path === false ) {
+ $path = $dir;
+ }
+ $this->suffixStart = strlen( $path ) + 1; // size of "path/to/dir/"
+ $this->params = $params;
+
+ try {
+ $this->iter = $this->initIterator( $path );
+ } catch ( UnexpectedValueException $e ) {
+ $this->iter = null; // bad permissions? deleted?
+ }
+ }
+
+ /**
+ * Return an appropriate iterator object to wrap
+ *
+ * @param string $dir File system directory
+ * @return Iterator
+ */
+ protected function initIterator( $dir ) {
+ if ( !empty( $this->params['topOnly'] ) ) { // non-recursive
+ # Get an iterator that will get direct sub-nodes
+ return new DirectoryIterator( $dir );
+ } else { // recursive
+ # Get an iterator that will return leaf nodes (non-directories)
+ # RecursiveDirectoryIterator extends FilesystemIterator.
+ # FilesystemIterator::SKIP_DOTS default is inconsistent in PHP 5.3.x.
+ $flags = FilesystemIterator::CURRENT_AS_SELF | FilesystemIterator::SKIP_DOTS;
+
+ return new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $dir, $flags ),
+ RecursiveIteratorIterator::CHILD_FIRST // include dirs
+ );
+ }
+ }
+
+ /**
+ * @see Iterator::key()
+ * @return int
+ */
+ public function key() {
+ return $this->pos;
+ }
+
+ /**
+ * @see Iterator::current()
+ * @return string|bool String or false
+ */
+ public function current() {
+ return $this->getRelPath( $this->iter->current()->getPathname() );
+ }
+
+ /**
+ * @see Iterator::next()
+ * @throws FileBackendError
+ */
+ public function next() {
+ try {
+ $this->iter->next();
+ $this->filterViaNext();
+ } catch ( UnexpectedValueException $e ) { // bad permissions? deleted?
+ throw new FileBackendError( "File iterator gave UnexpectedValueException." );
+ }
+ ++$this->pos;
+ }
+
+ /**
+ * @see Iterator::rewind()
+ * @throws FileBackendError
+ */
+ public function rewind() {
+ $this->pos = 0;
+ try {
+ $this->iter->rewind();
+ $this->filterViaNext();
+ } catch ( UnexpectedValueException $e ) { // bad permissions? deleted?
+ throw new FileBackendError( "File iterator gave UnexpectedValueException." );
+ }
+ }
+
+ /**
+ * @see Iterator::valid()
+ * @return bool
+ */
+ public function valid() {
+ return $this->iter && $this->iter->valid();
+ }
+
+ /**
+ * Filter out items by advancing to the next ones
+ */
+ protected function filterViaNext() {
+ }
+
+ /**
+ * Return only the relative path and normalize slashes to FileBackend-style.
+ * Uses the "real path" since the suffix is based upon that.
+ *
+ * @param string $dir
+ * @return string
+ */
+ protected function getRelPath( $dir ) {
+ $path = realpath( $dir );
+ if ( $path === false ) {
+ $path = $dir;
+ }
+
+ return strtr( substr( $path, $this->suffixStart ), '\\', '/' );
+ }
+}
+
+class FSFileBackendDirList extends FSFileBackendList {
+ protected function filterViaNext() {
+ while ( $this->iter->valid() ) {
+ if ( $this->iter->current()->isDot() || !$this->iter->current()->isDir() ) {
+ $this->iter->next(); // skip non-directories and dot files
+ } else {
+ break;
+ }
+ }
+ }
+}
+
+class FSFileBackendFileList extends FSFileBackendList {
+ protected function filterViaNext() {
+ while ( $this->iter->valid() ) {
+ if ( !$this->iter->current()->isFile() ) {
+ $this->iter->next(); // skip non-files and dot files
+ } else {
+ break;
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/FileBackend.php b/www/wiki/includes/libs/filebackend/FileBackend.php
new file mode 100644
index 00000000..51308c13
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/FileBackend.php
@@ -0,0 +1,1638 @@
+<?php
+/**
+ * @defgroup FileBackend File backend
+ *
+ * File backend is used to interact with file storage systems,
+ * such as the local file system, NFS, or cloud storage systems.
+ */
+
+/**
+ * Base class for all file backends.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Wikimedia\ScopedCallback;
+
+/**
+ * @brief Base class for all file backend classes (including multi-write backends).
+ *
+ * This class defines the methods as abstract that subclasses must implement.
+ * Outside callers can assume that all backends will have these functions.
+ *
+ * All "storage paths" are of the format "mwstore://<backend>/<container>/<path>".
+ * The "backend" portion is unique name for the application to refer to a backend, while
+ * the "container" portion is a top-level directory of the backend. The "path" portion
+ * is a relative path that uses UNIX file system (FS) notation, though any particular
+ * backend may not actually be using a local filesystem. Therefore, the relative paths
+ * are only virtual.
+ *
+ * Backend contents are stored under "domain"-specific container names by default.
+ * A domain is simply a logical umbrella for entities, such as those belonging to a certain
+ * application or portion of a website, for example. A domain can be local or global.
+ * Global (qualified) backends are achieved by configuring the "domain ID" to a constant.
+ * Global domains are simpler, but local domains can be used by choosing a domain ID based on
+ * the current context, such as which language of a website is being used.
+ *
+ * For legacy reasons, the FSFileBackend class allows manually setting the paths of
+ * containers to ones that do not respect the "domain ID".
+ *
+ * In key/value (object) stores, containers are the only hierarchy (the rest is emulated).
+ * FS-based backends are somewhat more restrictive due to the existence of real
+ * directory files; a regular file cannot have the same name as a directory. Other
+ * backends with virtual directories may not have this limitation. Callers should
+ * store files in such a way that no files and directories are under the same path.
+ *
+ * In general, this class allows for callers to access storage through the same
+ * interface, without regard to the underlying storage system. However, calling code
+ * must follow certain patterns and be aware of certain things to ensure compatibility:
+ * - a) Always call prepare() on the parent directory before trying to put a file there;
+ * key/value stores only need the container to exist first, but filesystems need
+ * all the parent directories to exist first (prepare() is aware of all this)
+ * - b) Always call clean() on a directory when it might become empty to avoid empty
+ * directory buildup on filesystems; key/value stores never have empty directories,
+ * so doing this helps preserve consistency in both cases
+ * - c) Likewise, do not rely on the existence of empty directories for anything;
+ * calling directoryExists() on a path that prepare() was previously called on
+ * will return false for key/value stores if there are no files under that path
+ * - d) Never alter the resulting FSFile returned from getLocalReference(), as it could
+ * either be a copy of the source file in /tmp or the original source file itself
+ * - e) Use a file layout that results in never attempting to store files over directories
+ * or directories over files; key/value stores allow this but filesystems do not
+ * - f) Use ASCII file names (e.g. base32, IDs, hashes) to avoid Unicode issues in Windows
+ * - g) Do not assume that move operations are atomic (difficult with key/value stores)
+ * - h) Do not assume that file stat or read operations always have immediate consistency;
+ * various methods have a "latest" flag that should always be used if up-to-date
+ * information is required (this trades performance for correctness as needed)
+ * - i) Do not assume that directory listings have immediate consistency
+ *
+ * Methods of subclasses should avoid throwing exceptions at all costs.
+ * As a corollary, external dependencies should be kept to a minimum.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+abstract class FileBackend implements LoggerAwareInterface {
+ /** @var string Unique backend name */
+ protected $name;
+
+ /** @var string Unique domain name */
+ protected $domainId;
+
+ /** @var string Read-only explanation message */
+ protected $readOnly;
+
+ /** @var string When to do operations in parallel */
+ protected $parallelize;
+
+ /** @var int How many operations can be done in parallel */
+ protected $concurrency;
+
+ /** @var string Temporary file directory */
+ protected $tmpDirectory;
+
+ /** @var LockManager */
+ protected $lockManager;
+ /** @var FileJournal */
+ protected $fileJournal;
+ /** @var LoggerInterface */
+ protected $logger;
+ /** @var object|string Class name or object With profileIn/profileOut methods */
+ protected $profiler;
+
+ /** @var callable */
+ protected $obResetFunc;
+ /** @var callable */
+ protected $streamMimeFunc;
+ /** @var callable */
+ protected $statusWrapper;
+
+ /** Bitfield flags for supported features */
+ const ATTR_HEADERS = 1; // files can be tagged with standard HTTP headers
+ const ATTR_METADATA = 2; // files can be stored with metadata key/values
+ const ATTR_UNICODE_PATHS = 4; // files can have Unicode paths (not just ASCII)
+
+ /**
+ * Create a new backend instance from configuration.
+ * This should only be called from within FileBackendGroup.
+ *
+ * @param array $config Parameters include:
+ * - name : The unique name of this backend.
+ * This should consist of alphanumberic, '-', and '_' characters.
+ * This name should not be changed after use (e.g. with journaling).
+ * Note that the name is *not* used in actual container names.
+ * - domainId : Prefix to container names that is unique to this backend.
+ * It should only consist of alphanumberic, '-', and '_' characters.
+ * This ID is what avoids collisions if multiple logical backends
+ * use the same storage system, so this should be set carefully.
+ * - lockManager : LockManager object to use for any file locking.
+ * If not provided, then no file locking will be enforced.
+ * - fileJournal : FileJournal object to use for logging changes to files.
+ * If not provided, then change journaling will be disabled.
+ * - readOnly : Write operations are disallowed if this is a non-empty string.
+ * It should be an explanation for the backend being read-only.
+ * - parallelize : When to do file operations in parallel (when possible).
+ * Allowed values are "implicit", "explicit" and "off".
+ * - concurrency : How many file operations can be done in parallel.
+ * - tmpDirectory : Directory to use for temporary files. If this is not set or null,
+ * then the backend will try to discover a usable temporary directory.
+ * - obResetFunc : alternative callback to clear the output buffer
+ * - streamMimeFunc : alternative method to determine the content type from the path
+ * - logger : Optional PSR logger object.
+ * - profiler : Optional class name or object With profileIn/profileOut methods.
+ * @throws InvalidArgumentException
+ */
+ public function __construct( array $config ) {
+ $this->name = $config['name'];
+ $this->domainId = isset( $config['domainId'] )
+ ? $config['domainId'] // e.g. "my_wiki-en_"
+ : $config['wikiId']; // b/c alias
+ if ( !preg_match( '!^[a-zA-Z0-9-_]{1,255}$!', $this->name ) ) {
+ throw new InvalidArgumentException( "Backend name '{$this->name}' is invalid." );
+ } elseif ( !is_string( $this->domainId ) ) {
+ throw new InvalidArgumentException(
+ "Backend domain ID not provided for '{$this->name}'." );
+ }
+ $this->lockManager = isset( $config['lockManager'] )
+ ? $config['lockManager']
+ : new NullLockManager( [] );
+ $this->fileJournal = isset( $config['fileJournal'] )
+ ? $config['fileJournal']
+ : FileJournal::factory( [ 'class' => 'NullFileJournal' ], $this->name );
+ $this->readOnly = isset( $config['readOnly'] )
+ ? (string)$config['readOnly']
+ : '';
+ $this->parallelize = isset( $config['parallelize'] )
+ ? (string)$config['parallelize']
+ : 'off';
+ $this->concurrency = isset( $config['concurrency'] )
+ ? (int)$config['concurrency']
+ : 50;
+ $this->obResetFunc = isset( $config['obResetFunc'] )
+ ? $config['obResetFunc']
+ : [ $this, 'resetOutputBuffer' ];
+ $this->streamMimeFunc = isset( $config['streamMimeFunc'] )
+ ? $config['streamMimeFunc']
+ : null;
+ $this->statusWrapper = isset( $config['statusWrapper'] ) ? $config['statusWrapper'] : null;
+
+ $this->profiler = isset( $config['profiler'] ) ? $config['profiler'] : null;
+ $this->logger = isset( $config['logger'] ) ? $config['logger'] : new \Psr\Log\NullLogger();
+ $this->statusWrapper = isset( $config['statusWrapper'] ) ? $config['statusWrapper'] : null;
+ $this->tmpDirectory = isset( $config['tmpDirectory'] ) ? $config['tmpDirectory'] : null;
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get the unique backend name.
+ * We may have multiple different backends of the same type.
+ * For example, we can have two Swift backends using different proxies.
+ *
+ * @return string
+ */
+ final public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Get the domain identifier used for this backend (possibly empty).
+ *
+ * @return string
+ * @since 1.28
+ */
+ final public function getDomainId() {
+ return $this->domainId;
+ }
+
+ /**
+ * Alias to getDomainId()
+ * @return string
+ * @since 1.20
+ */
+ final public function getWikiId() {
+ return $this->getDomainId();
+ }
+
+ /**
+ * Check if this backend is read-only
+ *
+ * @return bool
+ */
+ final public function isReadOnly() {
+ return ( $this->readOnly != '' );
+ }
+
+ /**
+ * Get an explanatory message if this backend is read-only
+ *
+ * @return string|bool Returns false if the backend is not read-only
+ */
+ final public function getReadOnlyReason() {
+ return ( $this->readOnly != '' ) ? $this->readOnly : false;
+ }
+
+ /**
+ * Get the a bitfield of extra features supported by the backend medium
+ *
+ * @return int Bitfield of FileBackend::ATTR_* flags
+ * @since 1.23
+ */
+ public function getFeatures() {
+ return self::ATTR_UNICODE_PATHS;
+ }
+
+ /**
+ * Check if the backend medium supports a field of extra features
+ *
+ * @param int $bitfield Bitfield of FileBackend::ATTR_* flags
+ * @return bool
+ * @since 1.23
+ */
+ final public function hasFeatures( $bitfield ) {
+ return ( $this->getFeatures() & $bitfield ) === $bitfield;
+ }
+
+ /**
+ * This is the main entry point into the backend for write operations.
+ * Callers supply an ordered list of operations to perform as a transaction.
+ * Files will be locked, the stat cache cleared, and then the operations attempted.
+ * If any serious errors occur, all attempted operations will be rolled back.
+ *
+ * $ops is an array of arrays. The outer array holds a list of operations.
+ * Each inner array is a set of key value pairs that specify an operation.
+ *
+ * Supported operations and their parameters. The supported actions are:
+ * - create
+ * - store
+ * - copy
+ * - move
+ * - delete
+ * - describe (since 1.21)
+ * - null
+ *
+ * FSFile/TempFSFile object support was added in 1.27.
+ *
+ * a) Create a new file in storage with the contents of a string
+ * @code
+ * [
+ * 'op' => 'create',
+ * 'dst' => <storage path>,
+ * 'content' => <string of new file contents>,
+ * 'overwrite' => <boolean>,
+ * 'overwriteSame' => <boolean>,
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * ]
+ * @endcode
+ *
+ * b) Copy a file system file into storage
+ * @code
+ * [
+ * 'op' => 'store',
+ * 'src' => <file system path, FSFile, or TempFSFile>,
+ * 'dst' => <storage path>,
+ * 'overwrite' => <boolean>,
+ * 'overwriteSame' => <boolean>,
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * ]
+ * @endcode
+ *
+ * c) Copy a file within storage
+ * @code
+ * [
+ * 'op' => 'copy',
+ * 'src' => <storage path>,
+ * 'dst' => <storage path>,
+ * 'overwrite' => <boolean>,
+ * 'overwriteSame' => <boolean>,
+ * 'ignoreMissingSource' => <boolean>, # since 1.21
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * ]
+ * @endcode
+ *
+ * d) Move a file within storage
+ * @code
+ * [
+ * 'op' => 'move',
+ * 'src' => <storage path>,
+ * 'dst' => <storage path>,
+ * 'overwrite' => <boolean>,
+ * 'overwriteSame' => <boolean>,
+ * 'ignoreMissingSource' => <boolean>, # since 1.21
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * ]
+ * @endcode
+ *
+ * e) Delete a file within storage
+ * @code
+ * [
+ * 'op' => 'delete',
+ * 'src' => <storage path>,
+ * 'ignoreMissingSource' => <boolean>
+ * ]
+ * @endcode
+ *
+ * f) Update metadata for a file within storage
+ * @code
+ * [
+ * 'op' => 'describe',
+ * 'src' => <storage path>,
+ * 'headers' => <HTTP header name/value map>
+ * ]
+ * @endcode
+ *
+ * g) Do nothing (no-op)
+ * @code
+ * [
+ * 'op' => 'null',
+ * ]
+ * @endcode
+ *
+ * Boolean flags for operations (operation-specific):
+ * - ignoreMissingSource : The operation will simply succeed and do
+ * nothing if the source file does not exist.
+ * - overwrite : Any destination file will be overwritten.
+ * - overwriteSame : If a file already exists at the destination with the
+ * same contents, then do nothing to the destination file
+ * instead of giving an error. This does not compare headers.
+ * This option is ignored if 'overwrite' is already provided.
+ * - headers : If supplied, the result of merging these headers with any
+ * existing source file headers (replacing conflicting ones)
+ * will be set as the destination file headers. Headers are
+ * deleted if their value is set to the empty string. When a
+ * file has headers they are included in responses to GET and
+ * HEAD requests to the backing store for that file.
+ * Header values should be no larger than 255 bytes, except for
+ * Content-Disposition. The system might ignore or truncate any
+ * headers that are too long to store (exact limits will vary).
+ * Backends that don't support metadata ignore this. (since 1.21)
+ *
+ * $opts is an associative of boolean flags, including:
+ * - force : Operation precondition errors no longer trigger an abort.
+ * Any remaining operations are still attempted. Unexpected
+ * failures may still cause remaining operations to be aborted.
+ * - nonLocking : No locks are acquired for the operations.
+ * This can increase performance for non-critical writes.
+ * This has no effect unless the 'force' flag is set.
+ * - nonJournaled : Don't log this operation batch in the file journal.
+ * This limits the ability of recovery scripts.
+ * - parallelize : Try to do operations in parallel when possible.
+ * - bypassReadOnly : Allow writes in read-only mode. (since 1.20)
+ * - preserveCache : Don't clear the process cache before checking files.
+ * This should only be used if all entries in the process
+ * cache were added after the files were already locked. (since 1.20)
+ *
+ * @remarks Remarks on locking:
+ * File system paths given to operations should refer to files that are
+ * already locked or otherwise safe from modification from other processes.
+ * Normally these files will be new temp files, which should be adequate.
+ *
+ * @par Return value:
+ *
+ * This returns a Status, which contains all warnings and fatals that occurred
+ * during the operation. The 'failCount', 'successCount', and 'success' members
+ * will reflect each operation attempted.
+ *
+ * The StatusValue will be "OK" unless:
+ * - a) unexpected operation errors occurred (network partitions, disk full...)
+ * - b) significant operation errors occurred and 'force' was not set
+ *
+ * @param array $ops List of operations to execute in order
+ * @param array $opts Batch operation options
+ * @return StatusValue
+ */
+ final public function doOperations( array $ops, array $opts = [] ) {
+ if ( empty( $opts['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return $this->newStatus( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ if ( !count( $ops ) ) {
+ return $this->newStatus(); // nothing to do
+ }
+
+ $ops = $this->resolveFSFileObjects( $ops );
+ if ( empty( $opts['force'] ) ) { // sanity
+ unset( $opts['nonLocking'] );
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+
+ return $this->doOperationsInternal( $ops, $opts );
+ }
+
+ /**
+ * @see FileBackend::doOperations()
+ * @param array $ops
+ * @param array $opts
+ */
+ abstract protected function doOperationsInternal( array $ops, array $opts );
+
+ /**
+ * Same as doOperations() except it takes a single operation.
+ * If you are doing a batch of operations that should either
+ * all succeed or all fail, then use that function instead.
+ *
+ * @see FileBackend::doOperations()
+ *
+ * @param array $op Operation
+ * @param array $opts Operation options
+ * @return StatusValue
+ */
+ final public function doOperation( array $op, array $opts = [] ) {
+ return $this->doOperations( [ $op ], $opts );
+ }
+
+ /**
+ * Performs a single create operation.
+ * This sets $params['op'] to 'create' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return StatusValue
+ */
+ final public function create( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'create' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single store operation.
+ * This sets $params['op'] to 'store' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return StatusValue
+ */
+ final public function store( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'store' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single copy operation.
+ * This sets $params['op'] to 'copy' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return StatusValue
+ */
+ final public function copy( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'copy' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single move operation.
+ * This sets $params['op'] to 'move' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return StatusValue
+ */
+ final public function move( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'move' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single delete operation.
+ * This sets $params['op'] to 'delete' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return StatusValue
+ */
+ final public function delete( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'delete' ] + $params, $opts );
+ }
+
+ /**
+ * Performs a single describe operation.
+ * This sets $params['op'] to 'describe' and passes it to doOperation().
+ *
+ * @see FileBackend::doOperation()
+ *
+ * @param array $params Operation parameters
+ * @param array $opts Operation options
+ * @return StatusValue
+ * @since 1.21
+ */
+ final public function describe( array $params, array $opts = [] ) {
+ return $this->doOperation( [ 'op' => 'describe' ] + $params, $opts );
+ }
+
+ /**
+ * Perform a set of independent file operations on some files.
+ *
+ * This does no locking, nor journaling, and possibly no stat calls.
+ * Any destination files that already exist will be overwritten.
+ * This should *only* be used on non-original files, like cache files.
+ *
+ * Supported operations and their parameters:
+ * - create
+ * - store
+ * - copy
+ * - move
+ * - delete
+ * - describe (since 1.21)
+ * - null
+ *
+ * FSFile/TempFSFile object support was added in 1.27.
+ *
+ * a) Create a new file in storage with the contents of a string
+ * @code
+ * [
+ * 'op' => 'create',
+ * 'dst' => <storage path>,
+ * 'content' => <string of new file contents>,
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * ]
+ * @endcode
+ *
+ * b) Copy a file system file into storage
+ * @code
+ * [
+ * 'op' => 'store',
+ * 'src' => <file system path, FSFile, or TempFSFile>,
+ * 'dst' => <storage path>,
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * ]
+ * @endcode
+ *
+ * c) Copy a file within storage
+ * @code
+ * [
+ * 'op' => 'copy',
+ * 'src' => <storage path>,
+ * 'dst' => <storage path>,
+ * 'ignoreMissingSource' => <boolean>, # since 1.21
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * ]
+ * @endcode
+ *
+ * d) Move a file within storage
+ * @code
+ * [
+ * 'op' => 'move',
+ * 'src' => <storage path>,
+ * 'dst' => <storage path>,
+ * 'ignoreMissingSource' => <boolean>, # since 1.21
+ * 'headers' => <HTTP header name/value map> # since 1.21
+ * ]
+ * @endcode
+ *
+ * e) Delete a file within storage
+ * @code
+ * [
+ * 'op' => 'delete',
+ * 'src' => <storage path>,
+ * 'ignoreMissingSource' => <boolean>
+ * ]
+ * @endcode
+ *
+ * f) Update metadata for a file within storage
+ * @code
+ * [
+ * 'op' => 'describe',
+ * 'src' => <storage path>,
+ * 'headers' => <HTTP header name/value map>
+ * ]
+ * @endcode
+ *
+ * g) Do nothing (no-op)
+ * @code
+ * [
+ * 'op' => 'null',
+ * ]
+ * @endcode
+ *
+ * @par Boolean flags for operations (operation-specific):
+ * - ignoreMissingSource : The operation will simply succeed and do
+ * nothing if the source file does not exist.
+ * - headers : If supplied with a header name/value map, the backend will
+ * reply with these headers when GETs/HEADs of the destination
+ * file are made. Header values should be smaller than 256 bytes.
+ * Content-Disposition headers can be longer, though the system
+ * might ignore or truncate ones that are too long to store.
+ * Existing headers will remain, but these will replace any
+ * conflicting previous headers, and headers will be removed
+ * if they are set to an empty string.
+ * Backends that don't support metadata ignore this. (since 1.21)
+ *
+ * $opts is an associative of boolean flags, including:
+ * - bypassReadOnly : Allow writes in read-only mode (since 1.20)
+ *
+ * @par Return value:
+ * This returns a Status, which contains all warnings and fatals that occurred
+ * during the operation. The 'failCount', 'successCount', and 'success' members
+ * will reflect each operation attempted for the given files. The StatusValue will be
+ * considered "OK" as long as no fatal errors occurred.
+ *
+ * @param array $ops Set of operations to execute
+ * @param array $opts Batch operation options
+ * @return StatusValue
+ * @since 1.20
+ */
+ final public function doQuickOperations( array $ops, array $opts = [] ) {
+ if ( empty( $opts['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return $this->newStatus( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ if ( !count( $ops ) ) {
+ return $this->newStatus(); // nothing to do
+ }
+
+ $ops = $this->resolveFSFileObjects( $ops );
+ foreach ( $ops as &$op ) {
+ $op['overwrite'] = true; // avoids RTTs in key/value stores
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+
+ return $this->doQuickOperationsInternal( $ops );
+ }
+
+ /**
+ * @see FileBackend::doQuickOperations()
+ * @param array $ops
+ * @since 1.20
+ */
+ abstract protected function doQuickOperationsInternal( array $ops );
+
+ /**
+ * Same as doQuickOperations() except it takes a single operation.
+ * If you are doing a batch of operations, then use that function instead.
+ *
+ * @see FileBackend::doQuickOperations()
+ *
+ * @param array $op Operation
+ * @return StatusValue
+ * @since 1.20
+ */
+ final public function doQuickOperation( array $op ) {
+ return $this->doQuickOperations( [ $op ] );
+ }
+
+ /**
+ * Performs a single quick create operation.
+ * This sets $params['op'] to 'create' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return StatusValue
+ * @since 1.20
+ */
+ final public function quickCreate( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'create' ] + $params );
+ }
+
+ /**
+ * Performs a single quick store operation.
+ * This sets $params['op'] to 'store' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return StatusValue
+ * @since 1.20
+ */
+ final public function quickStore( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'store' ] + $params );
+ }
+
+ /**
+ * Performs a single quick copy operation.
+ * This sets $params['op'] to 'copy' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return StatusValue
+ * @since 1.20
+ */
+ final public function quickCopy( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'copy' ] + $params );
+ }
+
+ /**
+ * Performs a single quick move operation.
+ * This sets $params['op'] to 'move' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return StatusValue
+ * @since 1.20
+ */
+ final public function quickMove( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'move' ] + $params );
+ }
+
+ /**
+ * Performs a single quick delete operation.
+ * This sets $params['op'] to 'delete' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return StatusValue
+ * @since 1.20
+ */
+ final public function quickDelete( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'delete' ] + $params );
+ }
+
+ /**
+ * Performs a single quick describe operation.
+ * This sets $params['op'] to 'describe' and passes it to doQuickOperation().
+ *
+ * @see FileBackend::doQuickOperation()
+ *
+ * @param array $params Operation parameters
+ * @return StatusValue
+ * @since 1.21
+ */
+ final public function quickDescribe( array $params ) {
+ return $this->doQuickOperation( [ 'op' => 'describe' ] + $params );
+ }
+
+ /**
+ * Concatenate a list of storage files into a single file system file.
+ * The target path should refer to a file that is already locked or
+ * otherwise safe from modification from other processes. Normally,
+ * the file will be a new temp file, which should be adequate.
+ *
+ * @param array $params Operation parameters, include:
+ * - srcs : ordered source storage paths (e.g. chunk1, chunk2, ...)
+ * - dst : file system path to 0-byte temp file
+ * - parallelize : try to do operations in parallel when possible
+ * @return StatusValue
+ */
+ abstract public function concatenate( array $params );
+
+ /**
+ * Prepare a storage directory for usage.
+ * This will create any required containers and parent directories.
+ * Backends using key/value stores only need to create the container.
+ *
+ * The 'noAccess' and 'noListing' parameters works the same as in secure(),
+ * except they are only applied *if* the directory/container had to be created.
+ * These flags should always be set for directories that have private files.
+ * However, setting them is not guaranteed to actually do anything.
+ * Additional server configuration may be needed to achieve the desired effect.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - noAccess : try to deny file access (since 1.20)
+ * - noListing : try to deny file listing (since 1.20)
+ * - bypassReadOnly : allow writes in read-only mode (since 1.20)
+ * @return StatusValue
+ */
+ final public function prepare( array $params ) {
+ if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return $this->newStatus( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+ return $this->doPrepare( $params );
+ }
+
+ /**
+ * @see FileBackend::prepare()
+ * @param array $params
+ */
+ abstract protected function doPrepare( array $params );
+
+ /**
+ * Take measures to block web access to a storage directory and
+ * the container it belongs to. FS backends might add .htaccess
+ * files whereas key/value store backends might revoke container
+ * access to the storage user representing end-users in web requests.
+ *
+ * This is not guaranteed to actually make files or listings publically hidden.
+ * Additional server configuration may be needed to achieve the desired effect.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - noAccess : try to deny file access
+ * - noListing : try to deny file listing
+ * - bypassReadOnly : allow writes in read-only mode (since 1.20)
+ * @return StatusValue
+ */
+ final public function secure( array $params ) {
+ if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return $this->newStatus( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+ return $this->doSecure( $params );
+ }
+
+ /**
+ * @see FileBackend::secure()
+ * @param array $params
+ */
+ abstract protected function doSecure( array $params );
+
+ /**
+ * Remove measures to block web access to a storage directory and
+ * the container it belongs to. FS backends might remove .htaccess
+ * files whereas key/value store backends might grant container
+ * access to the storage user representing end-users in web requests.
+ * This essentially can undo the result of secure() calls.
+ *
+ * This is not guaranteed to actually make files or listings publically viewable.
+ * Additional server configuration may be needed to achieve the desired effect.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - access : try to allow file access
+ * - listing : try to allow file listing
+ * - bypassReadOnly : allow writes in read-only mode (since 1.20)
+ * @return StatusValue
+ * @since 1.20
+ */
+ final public function publish( array $params ) {
+ if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return $this->newStatus( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+ return $this->doPublish( $params );
+ }
+
+ /**
+ * @see FileBackend::publish()
+ * @param array $params
+ */
+ abstract protected function doPublish( array $params );
+
+ /**
+ * Delete a storage directory if it is empty.
+ * Backends using key/value stores may do nothing unless the directory
+ * is that of an empty container, in which case it will be deleted.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - recursive : recursively delete empty subdirectories first (since 1.20)
+ * - bypassReadOnly : allow writes in read-only mode (since 1.20)
+ * @return StatusValue
+ */
+ final public function clean( array $params ) {
+ if ( empty( $params['bypassReadOnly'] ) && $this->isReadOnly() ) {
+ return $this->newStatus( 'backend-fail-readonly', $this->name, $this->readOnly );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForOps(); // try to ignore client aborts
+ return $this->doClean( $params );
+ }
+
+ /**
+ * @see FileBackend::clean()
+ * @param array $params
+ */
+ abstract protected function doClean( array $params );
+
+ /**
+ * Enter file operation scope.
+ * This just makes PHP ignore user aborts/disconnects until the return
+ * value leaves scope. This returns null and does nothing in CLI mode.
+ *
+ * @return ScopedCallback|null
+ */
+ final protected function getScopedPHPBehaviorForOps() {
+ if ( PHP_SAPI != 'cli' ) { // https://bugs.php.net/bug.php?id=47540
+ $old = ignore_user_abort( true ); // avoid half-finished operations
+ return new ScopedCallback( function () use ( $old ) {
+ ignore_user_abort( $old );
+ } );
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if a file exists at a storage path in the backend.
+ * This returns false if only a directory exists at the path.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return bool|null Returns null on failure
+ */
+ abstract public function fileExists( array $params );
+
+ /**
+ * Get the last-modified timestamp of the file at a storage path.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return string|bool TS_MW timestamp or false on failure
+ */
+ abstract public function getFileTimestamp( array $params );
+
+ /**
+ * Get the contents of a file at a storage path in the backend.
+ * This should be avoided for potentially large files.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return string|bool Returns false on failure
+ */
+ final public function getFileContents( array $params ) {
+ $contents = $this->getFileContentsMulti(
+ [ 'srcs' => [ $params['src'] ] ] + $params );
+
+ return $contents[$params['src']];
+ }
+
+ /**
+ * Like getFileContents() except it takes an array of storage paths
+ * and returns a map of storage paths to strings (or null on failure).
+ * The map keys (paths) are in the same order as the provided list of paths.
+ *
+ * @see FileBackend::getFileContents()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * - parallelize : try to do operations in parallel when possible
+ * @return array Map of (path name => string or false on failure)
+ * @since 1.20
+ */
+ abstract public function getFileContentsMulti( array $params );
+
+ /**
+ * Get metadata about a file at a storage path in the backend.
+ * If the file does not exist, then this returns false.
+ * Otherwise, the result is an associative array that includes:
+ * - headers : map of HTTP headers used for GET/HEAD requests (name => value)
+ * - metadata : map of file metadata (name => value)
+ * Metadata keys and headers names will be returned in all lower-case.
+ * Additional values may be included for internal use only.
+ *
+ * Use FileBackend::hasFeatures() to check how well this is supported.
+ *
+ * @param array $params
+ * $params include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return array|bool Returns false on failure
+ * @since 1.23
+ */
+ abstract public function getFileXAttributes( array $params );
+
+ /**
+ * Get the size (bytes) of a file at a storage path in the backend.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return int|bool Returns false on failure
+ */
+ abstract public function getFileSize( array $params );
+
+ /**
+ * Get quick information about a file at a storage path in the backend.
+ * If the file does not exist, then this returns false.
+ * Otherwise, the result is an associative array that includes:
+ * - mtime : the last-modified timestamp (TS_MW)
+ * - size : the file size (bytes)
+ * Additional values may be included for internal use only.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return array|bool|null Returns null on failure
+ */
+ abstract public function getFileStat( array $params );
+
+ /**
+ * Get a SHA-1 hash of the file at a storage path in the backend.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return string|bool Hash string or false on failure
+ */
+ abstract public function getFileSha1Base36( array $params );
+
+ /**
+ * Get the properties of the file at a storage path in the backend.
+ * This gives the result of FSFile::getProps() on a local copy of the file.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return array Returns FSFile::placeholderProps() on failure
+ */
+ abstract public function getFileProps( array $params );
+
+ /**
+ * Stream the file at a storage path in the backend.
+ *
+ * If the file does not exists, an HTTP 404 error will be given.
+ * Appropriate HTTP headers (Status, Content-Type, Content-Length)
+ * will be sent if streaming began, while none will be sent otherwise.
+ * Implementations should flush the output buffer before sending data.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - headers : list of additional HTTP headers to send if the file exists
+ * - options : HTTP request header map with lower case keys (since 1.28). Supports:
+ * range : format is "bytes=(\d*-\d*)"
+ * if-modified-since : format is an HTTP date
+ * - headless : only include the body (and headers from "headers") (since 1.28)
+ * - latest : use the latest available data
+ * - allowOB : preserve any output buffers (since 1.28)
+ * @return StatusValue
+ */
+ abstract public function streamFile( array $params );
+
+ /**
+ * Returns a file system file, identical to the file at a storage path.
+ * The file returned is either:
+ * - a) A local copy of the file at a storage path in the backend.
+ * The temporary copy will have the same extension as the source.
+ * - b) An original of the file at a storage path in the backend.
+ * Temporary files may be purged when the file object falls out of scope.
+ *
+ * Write operations should *never* be done on this file as some backends
+ * may do internal tracking or may be instances of FileBackendMultiWrite.
+ * In that latter case, there are copies of the file that must stay in sync.
+ * Additionally, further calls to this function may return the same file.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return FSFile|null Returns null on failure
+ */
+ final public function getLocalReference( array $params ) {
+ $fsFiles = $this->getLocalReferenceMulti(
+ [ 'srcs' => [ $params['src'] ] ] + $params );
+
+ return $fsFiles[$params['src']];
+ }
+
+ /**
+ * Like getLocalReference() except it takes an array of storage paths
+ * and returns a map of storage paths to FSFile objects (or null on failure).
+ * The map keys (paths) are in the same order as the provided list of paths.
+ *
+ * @see FileBackend::getLocalReference()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * - parallelize : try to do operations in parallel when possible
+ * @return array Map of (path name => FSFile or null on failure)
+ * @since 1.20
+ */
+ abstract public function getLocalReferenceMulti( array $params );
+
+ /**
+ * Get a local copy on disk of the file at a storage path in the backend.
+ * The temporary copy will have the same file extension as the source.
+ * Temporary files may be purged when the file object falls out of scope.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - latest : use the latest available data
+ * @return TempFSFile|null Returns null on failure
+ */
+ final public function getLocalCopy( array $params ) {
+ $tmpFiles = $this->getLocalCopyMulti(
+ [ 'srcs' => [ $params['src'] ] ] + $params );
+
+ return $tmpFiles[$params['src']];
+ }
+
+ /**
+ * Like getLocalCopy() except it takes an array of storage paths and
+ * returns a map of storage paths to TempFSFile objects (or null on failure).
+ * The map keys (paths) are in the same order as the provided list of paths.
+ *
+ * @see FileBackend::getLocalCopy()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * - parallelize : try to do operations in parallel when possible
+ * @return array Map of (path name => TempFSFile or null on failure)
+ * @since 1.20
+ */
+ abstract public function getLocalCopyMulti( array $params );
+
+ /**
+ * Return an HTTP URL to a given file that requires no authentication to use.
+ * The URL may be pre-authenticated (via some token in the URL) and temporary.
+ * This will return null if the backend cannot make an HTTP URL for the file.
+ *
+ * This is useful for key/value stores when using scripts that seek around
+ * large files and those scripts (and the backend) support HTTP Range headers.
+ * Otherwise, one would need to use getLocalReference(), which involves loading
+ * the entire file on to local disk.
+ *
+ * @param array $params Parameters include:
+ * - src : source storage path
+ * - ttl : lifetime (seconds) if pre-authenticated; default is 1 day
+ * @return string|null
+ * @since 1.21
+ */
+ abstract public function getFileHttpUrl( array $params );
+
+ /**
+ * Check if a directory exists at a given storage path.
+ * Backends using key/value stores will check if the path is a
+ * virtual directory, meaning there are files under the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * @return bool|null Returns null on failure
+ * @since 1.20
+ */
+ abstract public function directoryExists( array $params );
+
+ /**
+ * Get an iterator to list *all* directories under a storage directory.
+ * If the directory is of the form "mwstore://backend/container",
+ * then all directories in the container will be listed.
+ * If the directory is of form "mwstore://backend/container/dir",
+ * then all directories directly under that directory will be listed.
+ * Results will be storage directories relative to the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * Failures during iteration can result in FileBackendError exceptions (since 1.22).
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - topOnly : only return direct child dirs of the directory
+ * @return Traversable|array|null Returns null on failure
+ * @since 1.20
+ */
+ abstract public function getDirectoryList( array $params );
+
+ /**
+ * Same as FileBackend::getDirectoryList() except only lists
+ * directories that are immediately under the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * Failures during iteration can result in FileBackendError exceptions (since 1.22).
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * @return Traversable|array|null Returns null on failure
+ * @since 1.20
+ */
+ final public function getTopDirectoryList( array $params ) {
+ return $this->getDirectoryList( [ 'topOnly' => true ] + $params );
+ }
+
+ /**
+ * Get an iterator to list *all* stored files under a storage directory.
+ * If the directory is of the form "mwstore://backend/container",
+ * then all files in the container will be listed.
+ * If the directory is of form "mwstore://backend/container/dir",
+ * then all files under that directory will be listed.
+ * Results will be storage paths relative to the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * Failures during iteration can result in FileBackendError exceptions (since 1.22).
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - topOnly : only return direct child files of the directory (since 1.20)
+ * - adviseStat : set to true if stat requests will be made on the files (since 1.22)
+ * @return Traversable|array|null Returns null on failure
+ */
+ abstract public function getFileList( array $params );
+
+ /**
+ * Same as FileBackend::getFileList() except only lists
+ * files that are immediately under the given directory.
+ *
+ * Storage backends with eventual consistency might return stale data.
+ *
+ * Failures during iteration can result in FileBackendError exceptions (since 1.22).
+ *
+ * @param array $params Parameters include:
+ * - dir : storage directory
+ * - adviseStat : set to true if stat requests will be made on the files (since 1.22)
+ * @return Traversable|array|null Returns null on failure
+ * @since 1.20
+ */
+ final public function getTopFileList( array $params ) {
+ return $this->getFileList( [ 'topOnly' => true ] + $params );
+ }
+
+ /**
+ * Preload persistent file stat cache and property cache into in-process cache.
+ * This should be used when stat calls will be made on a known list of a many files.
+ *
+ * @see FileBackend::getFileStat()
+ *
+ * @param array $paths Storage paths
+ */
+ abstract public function preloadCache( array $paths );
+
+ /**
+ * Invalidate any in-process file stat and property cache.
+ * If $paths is given, then only the cache for those files will be cleared.
+ *
+ * @see FileBackend::getFileStat()
+ *
+ * @param array $paths Storage paths (optional)
+ */
+ abstract public function clearCache( array $paths = null );
+
+ /**
+ * Preload file stat information (concurrently if possible) into in-process cache.
+ *
+ * This should be used when stat calls will be made on a known list of a many files.
+ * This does not make use of the persistent file stat cache.
+ *
+ * @see FileBackend::getFileStat()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * @return bool All requests proceeded without I/O errors (since 1.24)
+ * @since 1.23
+ */
+ abstract public function preloadFileStat( array $params );
+
+ /**
+ * Lock the files at the given storage paths in the backend.
+ * This will either lock all the files or none (on failure).
+ *
+ * Callers should consider using getScopedFileLocks() instead.
+ *
+ * @param array $paths Storage paths
+ * @param int $type LockManager::LOCK_* constant
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.24)
+ * @return StatusValue
+ */
+ final public function lockFiles( array $paths, $type, $timeout = 0 ) {
+ $paths = array_map( 'FileBackend::normalizeStoragePath', $paths );
+
+ return $this->wrapStatus( $this->lockManager->lock( $paths, $type, $timeout ) );
+ }
+
+ /**
+ * Unlock the files at the given storage paths in the backend.
+ *
+ * @param array $paths Storage paths
+ * @param int $type LockManager::LOCK_* constant
+ * @return StatusValue
+ */
+ final public function unlockFiles( array $paths, $type ) {
+ $paths = array_map( 'FileBackend::normalizeStoragePath', $paths );
+
+ return $this->wrapStatus( $this->lockManager->unlock( $paths, $type ) );
+ }
+
+ /**
+ * Lock the files at the given storage paths in the backend.
+ * This will either lock all the files or none (on failure).
+ * On failure, the StatusValue object will be updated with errors.
+ *
+ * Once the return value goes out scope, the locks will be released and
+ * the StatusValue updated. Unlock fatals will not change the StatusValue "OK" value.
+ *
+ * @see ScopedLock::factory()
+ *
+ * @param array $paths List of storage paths or map of lock types to path lists
+ * @param int|string $type LockManager::LOCK_* constant or "mixed"
+ * @param StatusValue $status StatusValue to update on lock/unlock
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.24)
+ * @return ScopedLock|null Returns null on failure
+ */
+ final public function getScopedFileLocks(
+ array $paths, $type, StatusValue $status, $timeout = 0
+ ) {
+ if ( $type === 'mixed' ) {
+ foreach ( $paths as &$typePaths ) {
+ $typePaths = array_map( 'FileBackend::normalizeStoragePath', $typePaths );
+ }
+ } else {
+ $paths = array_map( 'FileBackend::normalizeStoragePath', $paths );
+ }
+
+ return ScopedLock::factory( $this->lockManager, $paths, $type, $status, $timeout );
+ }
+
+ /**
+ * Get an array of scoped locks needed for a batch of file operations.
+ *
+ * Normally, FileBackend::doOperations() handles locking, unless
+ * the 'nonLocking' param is passed in. This function is useful if you
+ * want the files to be locked for a broader scope than just when the
+ * files are changing. For example, if you need to update DB metadata,
+ * you may want to keep the files locked until finished.
+ *
+ * @see FileBackend::doOperations()
+ *
+ * @param array $ops List of file operations to FileBackend::doOperations()
+ * @param StatusValue $status StatusValue to update on lock/unlock
+ * @return ScopedLock|null
+ * @since 1.20
+ */
+ abstract public function getScopedLocksForOps( array $ops, StatusValue $status );
+
+ /**
+ * Get the root storage path of this backend.
+ * All container paths are "subdirectories" of this path.
+ *
+ * @return string Storage path
+ * @since 1.20
+ */
+ final public function getRootStoragePath() {
+ return "mwstore://{$this->name}";
+ }
+
+ /**
+ * Get the storage path for the given container for this backend
+ *
+ * @param string $container Container name
+ * @return string Storage path
+ * @since 1.21
+ */
+ final public function getContainerStoragePath( $container ) {
+ return $this->getRootStoragePath() . "/{$container}";
+ }
+
+ /**
+ * Get the file journal object for this backend
+ *
+ * @return FileJournal
+ */
+ final public function getJournal() {
+ return $this->fileJournal;
+ }
+
+ /**
+ * Convert FSFile 'src' paths to string paths (with an 'srcRef' field set to the FSFile)
+ *
+ * The 'srcRef' field keeps any TempFSFile objects in scope for the backend to have it
+ * around as long it needs (which may vary greatly depending on configuration)
+ *
+ * @param array $ops File operation batch for FileBaclend::doOperations()
+ * @return array File operation batch
+ */
+ protected function resolveFSFileObjects( array $ops ) {
+ foreach ( $ops as &$op ) {
+ $src = isset( $op['src'] ) ? $op['src'] : null;
+ if ( $src instanceof FSFile ) {
+ $op['srcRef'] = $src;
+ $op['src'] = $src->getPath();
+ }
+ }
+ unset( $op );
+
+ return $ops;
+ }
+
+ /**
+ * Check if a given path is a "mwstore://" path.
+ * This does not do any further validation or any existence checks.
+ *
+ * @param string $path
+ * @return bool
+ */
+ final public static function isStoragePath( $path ) {
+ return ( strpos( $path, 'mwstore://' ) === 0 );
+ }
+
+ /**
+ * Split a storage path into a backend name, a container name,
+ * and a relative file path. The relative path may be the empty string.
+ * This does not do any path normalization or traversal checks.
+ *
+ * @param string $storagePath
+ * @return array (backend, container, rel object) or (null, null, null)
+ */
+ final public static function splitStoragePath( $storagePath ) {
+ if ( self::isStoragePath( $storagePath ) ) {
+ // Remove the "mwstore://" prefix and split the path
+ $parts = explode( '/', substr( $storagePath, 10 ), 3 );
+ if ( count( $parts ) >= 2 && $parts[0] != '' && $parts[1] != '' ) {
+ if ( count( $parts ) == 3 ) {
+ return $parts; // e.g. "backend/container/path"
+ } else {
+ return [ $parts[0], $parts[1], '' ]; // e.g. "backend/container"
+ }
+ }
+ }
+
+ return [ null, null, null ];
+ }
+
+ /**
+ * Normalize a storage path by cleaning up directory separators.
+ * Returns null if the path is not of the format of a valid storage path.
+ *
+ * @param string $storagePath
+ * @return string|null
+ */
+ final public static function normalizeStoragePath( $storagePath ) {
+ list( $backend, $container, $relPath ) = self::splitStoragePath( $storagePath );
+ if ( $relPath !== null ) { // must be for this backend
+ $relPath = self::normalizeContainerPath( $relPath );
+ if ( $relPath !== null ) {
+ return ( $relPath != '' )
+ ? "mwstore://{$backend}/{$container}/{$relPath}"
+ : "mwstore://{$backend}/{$container}";
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the parent storage directory of a storage path.
+ * This returns a path like "mwstore://backend/container",
+ * "mwstore://backend/container/...", or null if there is no parent.
+ *
+ * @param string $storagePath
+ * @return string|null
+ */
+ final public static function parentStoragePath( $storagePath ) {
+ $storagePath = dirname( $storagePath );
+ list( , , $rel ) = self::splitStoragePath( $storagePath );
+
+ return ( $rel === null ) ? null : $storagePath;
+ }
+
+ /**
+ * Get the final extension from a storage or FS path
+ *
+ * @param string $path
+ * @param string $case One of (rawcase, uppercase, lowercase) (since 1.24)
+ * @return string
+ */
+ final public static function extensionFromPath( $path, $case = 'lowercase' ) {
+ $i = strrpos( $path, '.' );
+ $ext = $i ? substr( $path, $i + 1 ) : '';
+
+ if ( $case === 'lowercase' ) {
+ $ext = strtolower( $ext );
+ } elseif ( $case === 'uppercase' ) {
+ $ext = strtoupper( $ext );
+ }
+
+ return $ext;
+ }
+
+ /**
+ * Check if a relative path has no directory traversals
+ *
+ * @param string $path
+ * @return bool
+ * @since 1.20
+ */
+ final public static function isPathTraversalFree( $path ) {
+ return ( self::normalizeContainerPath( $path ) !== null );
+ }
+
+ /**
+ * Build a Content-Disposition header value per RFC 6266.
+ *
+ * @param string $type One of (attachment, inline)
+ * @param string $filename Suggested file name (should not contain slashes)
+ * @throws FileBackendError
+ * @return string
+ * @since 1.20
+ */
+ final public static function makeContentDisposition( $type, $filename = '' ) {
+ $parts = [];
+
+ $type = strtolower( $type );
+ if ( !in_array( $type, [ 'inline', 'attachment' ] ) ) {
+ throw new InvalidArgumentException( "Invalid Content-Disposition type '$type'." );
+ }
+ $parts[] = $type;
+
+ if ( strlen( $filename ) ) {
+ $parts[] = "filename*=UTF-8''" . rawurlencode( basename( $filename ) );
+ }
+
+ return implode( ';', $parts );
+ }
+
+ /**
+ * Validate and normalize a relative storage path.
+ * Null is returned if the path involves directory traversal.
+ * Traversal is insecure for FS backends and broken for others.
+ *
+ * This uses the same traversal protection as Title::secureAndSplit().
+ *
+ * @param string $path Storage path relative to a container
+ * @return string|null
+ */
+ final protected static function normalizeContainerPath( $path ) {
+ // Normalize directory separators
+ $path = strtr( $path, '\\', '/' );
+ // Collapse any consecutive directory separators
+ $path = preg_replace( '![/]{2,}!', '/', $path );
+ // Remove any leading directory separator
+ $path = ltrim( $path, '/' );
+ // Use the same traversal protection as Title::secureAndSplit()
+ if ( strpos( $path, '.' ) !== false ) {
+ if (
+ $path === '.' ||
+ $path === '..' ||
+ strpos( $path, './' ) === 0 ||
+ strpos( $path, '../' ) === 0 ||
+ strpos( $path, '/./' ) !== false ||
+ strpos( $path, '/../' ) !== false
+ ) {
+ return null;
+ }
+ }
+
+ return $path;
+ }
+
+ /**
+ * Yields the result of the status wrapper callback on either:
+ * - StatusValue::newGood() if this method is called without parameters
+ * - StatusValue::newFatal() with all parameters to this method if passed in
+ *
+ * @param string $args,...
+ * @return StatusValue
+ */
+ final protected function newStatus() {
+ $args = func_get_args();
+ if ( count( $args ) ) {
+ $sv = call_user_func_array( [ 'StatusValue', 'newFatal' ], $args );
+ } else {
+ $sv = StatusValue::newGood();
+ }
+
+ return $this->wrapStatus( $sv );
+ }
+
+ /**
+ * @param StatusValue $sv
+ * @return StatusValue Modified status or StatusValue subclass
+ */
+ final protected function wrapStatus( StatusValue $sv ) {
+ return $this->statusWrapper ? call_user_func( $this->statusWrapper, $sv ) : $sv;
+ }
+
+ /**
+ * @param string $section
+ * @return ScopedCallback|null
+ */
+ protected function scopedProfileSection( $section ) {
+ if ( $this->profiler ) {
+ call_user_func( [ $this->profiler, 'profileIn' ], $section );
+ return new ScopedCallback( [ $this->profiler, 'profileOut' ], [ $section ] );
+ }
+
+ return null;
+ }
+
+ protected function resetOutputBuffer() {
+ while ( ob_get_status() ) {
+ if ( !ob_end_clean() ) {
+ // Could not remove output buffer handler; abort now
+ // to avoid getting in some kind of infinite loop.
+ break;
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/FileBackendError.php b/www/wiki/includes/libs/filebackend/FileBackendError.php
new file mode 100644
index 00000000..e2335351
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/FileBackendError.php
@@ -0,0 +1,9 @@
+<?php
+/**
+ * File backend exception for checked exceptions (e.g. I/O errors)
+ *
+ * @ingroup FileBackend
+ * @since 1.22
+ */
+class FileBackendError extends Exception {
+}
diff --git a/www/wiki/includes/libs/filebackend/FileBackendMultiWrite.php b/www/wiki/includes/libs/filebackend/FileBackendMultiWrite.php
new file mode 100644
index 00000000..f8ca7e5a
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/FileBackendMultiWrite.php
@@ -0,0 +1,755 @@
+<?php
+/**
+ * Proxy backend that mirrors writes to several internal backends.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * @brief Proxy backend that mirrors writes to several internal backends.
+ *
+ * This class defines a multi-write backend. Multiple backends can be
+ * registered to this proxy backend and it will act as a single backend.
+ * Use this when all access to those backends is through this proxy backend.
+ * At least one of the backends must be declared the "master" backend.
+ *
+ * Only use this class when transitioning from one storage system to another.
+ *
+ * Read operations are only done on the 'master' backend for consistency.
+ * Write operations are performed on all backends, starting with the master.
+ * This makes a best-effort to have transactional semantics, but since requests
+ * may sometimes fail, the use of "autoResync" or background scripts to fix
+ * inconsistencies is important.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+class FileBackendMultiWrite extends FileBackend {
+ /** @var FileBackendStore[] Prioritized list of FileBackendStore objects */
+ protected $backends = [];
+
+ /** @var int Index of master backend */
+ protected $masterIndex = -1;
+ /** @var int Index of read affinity backend */
+ protected $readIndex = -1;
+
+ /** @var int Bitfield */
+ protected $syncChecks = 0;
+ /** @var string|bool */
+ protected $autoResync = false;
+
+ /** @var bool */
+ protected $asyncWrites = false;
+
+ /* Possible internal backend consistency checks */
+ const CHECK_SIZE = 1;
+ const CHECK_TIME = 2;
+ const CHECK_SHA1 = 4;
+
+ /**
+ * Construct a proxy backend that consists of several internal backends.
+ * Locking, journaling, and read-only checks are handled by the proxy backend.
+ *
+ * Additional $config params include:
+ * - backends : Array of backend config and multi-backend settings.
+ * Each value is the config used in the constructor of a
+ * FileBackendStore class, but with these additional settings:
+ * - class : The name of the backend class
+ * - isMultiMaster : This must be set for one backend.
+ * - readAffinity : Use this for reads without 'latest' set.
+ * - syncChecks : Integer bitfield of internal backend sync checks to perform.
+ * Possible bits include the FileBackendMultiWrite::CHECK_* constants.
+ * There are constants for SIZE, TIME, and SHA1.
+ * The checks are done before allowing any file operations.
+ * - autoResync : Automatically resync the clone backends to the master backend
+ * when pre-operation sync checks fail. This should only be used
+ * if the master backend is stable and not missing any files.
+ * Use "conservative" to limit resyncing to copying newer master
+ * backend files over older (or non-existing) clone backend files.
+ * Cases that cannot be handled will result in operation abortion.
+ * - replication : Set to 'async' to defer file operations on the non-master backends.
+ * This will apply such updates post-send for web requests. Note that
+ * any checks from "syncChecks" are still synchronous.
+ *
+ * @param array $config
+ * @throws FileBackendError
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+ $this->syncChecks = isset( $config['syncChecks'] )
+ ? $config['syncChecks']
+ : self::CHECK_SIZE;
+ $this->autoResync = isset( $config['autoResync'] )
+ ? $config['autoResync']
+ : false;
+ $this->asyncWrites = isset( $config['replication'] ) && $config['replication'] === 'async';
+ // Construct backends here rather than via registration
+ // to keep these backends hidden from outside the proxy.
+ $namesUsed = [];
+ foreach ( $config['backends'] as $index => $config ) {
+ $name = $config['name'];
+ if ( isset( $namesUsed[$name] ) ) { // don't break FileOp predicates
+ throw new LogicException( "Two or more backends defined with the name $name." );
+ }
+ $namesUsed[$name] = 1;
+ // Alter certain sub-backend settings for sanity
+ unset( $config['readOnly'] ); // use proxy backend setting
+ unset( $config['fileJournal'] ); // use proxy backend journal
+ unset( $config['lockManager'] ); // lock under proxy backend
+ $config['domainId'] = $this->domainId; // use the proxy backend wiki ID
+ if ( !empty( $config['isMultiMaster'] ) ) {
+ if ( $this->masterIndex >= 0 ) {
+ throw new LogicException( 'More than one master backend defined.' );
+ }
+ $this->masterIndex = $index; // this is the "master"
+ $config['fileJournal'] = $this->fileJournal; // log under proxy backend
+ }
+ if ( !empty( $config['readAffinity'] ) ) {
+ $this->readIndex = $index; // prefer this for reads
+ }
+ // Create sub-backend object
+ if ( !isset( $config['class'] ) ) {
+ throw new InvalidArgumentException( 'No class given for a backend config.' );
+ }
+ $class = $config['class'];
+ $this->backends[$index] = new $class( $config );
+ }
+ if ( $this->masterIndex < 0 ) { // need backends and must have a master
+ throw new LogicException( 'No master backend defined.' );
+ }
+ if ( $this->readIndex < 0 ) {
+ $this->readIndex = $this->masterIndex; // default
+ }
+ }
+
+ final protected function doOperationsInternal( array $ops, array $opts ) {
+ $status = $this->newStatus();
+
+ $mbe = $this->backends[$this->masterIndex]; // convenience
+
+ // Try to lock those files for the scope of this function...
+ $scopeLock = null;
+ if ( empty( $opts['nonLocking'] ) ) {
+ // Try to lock those files for the scope of this function...
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scopeLock = $this->getScopedLocksForOps( $ops, $status );
+ if ( !$status->isOK() ) {
+ return $status; // abort
+ }
+ }
+ // Clear any cache entries (after locks acquired)
+ $this->clearCache();
+ $opts['preserveCache'] = true; // only locked files are cached
+ // Get the list of paths to read/write...
+ $relevantPaths = $this->fileStoragePathsForOps( $ops );
+ // Check if the paths are valid and accessible on all backends...
+ $status->merge( $this->accessibilityCheck( $relevantPaths ) );
+ if ( !$status->isOK() ) {
+ return $status; // abort
+ }
+ // Do a consistency check to see if the backends are consistent...
+ $syncStatus = $this->consistencyCheck( $relevantPaths );
+ if ( !$syncStatus->isOK() ) {
+ wfDebugLog( 'FileOperation', static::class .
+ " failed sync check: " . FormatJson::encode( $relevantPaths ) );
+ // Try to resync the clone backends to the master on the spot...
+ if ( $this->autoResync === false
+ || !$this->resyncFiles( $relevantPaths, $this->autoResync )->isOK()
+ ) {
+ $status->merge( $syncStatus );
+
+ return $status; // abort
+ }
+ }
+ // Actually attempt the operation batch on the master backend...
+ $realOps = $this->substOpBatchPaths( $ops, $mbe );
+ $masterStatus = $mbe->doOperations( $realOps, $opts );
+ $status->merge( $masterStatus );
+ // Propagate the operations to the clone backends if there were no unexpected errors
+ // and if there were either no expected errors or if the 'force' option was used.
+ // However, if nothing succeeded at all, then don't replicate any of the operations.
+ // If $ops only had one operation, this might avoid backend sync inconsistencies.
+ if ( $masterStatus->isOK() && $masterStatus->successCount > 0 ) {
+ foreach ( $this->backends as $index => $backend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // done already
+ }
+
+ $realOps = $this->substOpBatchPaths( $ops, $backend );
+ if ( $this->asyncWrites && !$this->hasVolatileSources( $ops ) ) {
+ // Bind $scopeLock to the callback to preserve locks
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $backend, $realOps, $opts, $scopeLock, $relevantPaths ) {
+ wfDebugLog( 'FileOperationReplication',
+ "'{$backend->getName()}' async replication; paths: " .
+ FormatJson::encode( $relevantPaths ) );
+ $backend->doOperations( $realOps, $opts );
+ }
+ );
+ } else {
+ wfDebugLog( 'FileOperationReplication',
+ "'{$backend->getName()}' sync replication; paths: " .
+ FormatJson::encode( $relevantPaths ) );
+ $status->merge( $backend->doOperations( $realOps, $opts ) );
+ }
+ }
+ }
+ // Make 'success', 'successCount', and 'failCount' fields reflect
+ // the overall operation, rather than all the batches for each backend.
+ // Do this by only using success values from the master backend's batch.
+ $status->success = $masterStatus->success;
+ $status->successCount = $masterStatus->successCount;
+ $status->failCount = $masterStatus->failCount;
+
+ return $status;
+ }
+
+ /**
+ * Check that a set of files are consistent across all internal backends
+ *
+ * @param array $paths List of storage paths
+ * @return StatusValue
+ */
+ public function consistencyCheck( array $paths ) {
+ $status = $this->newStatus();
+ if ( $this->syncChecks == 0 || count( $this->backends ) <= 1 ) {
+ return $status; // skip checks
+ }
+
+ // Preload all of the stat info in as few round trips as possible...
+ foreach ( $this->backends as $backend ) {
+ $realPaths = $this->substPaths( $paths, $backend );
+ $backend->preloadFileStat( [ 'srcs' => $realPaths, 'latest' => true ] );
+ }
+
+ $mBackend = $this->backends[$this->masterIndex];
+ foreach ( $paths as $path ) {
+ $params = [ 'src' => $path, 'latest' => true ];
+ $mParams = $this->substOpPaths( $params, $mBackend );
+ // Stat the file on the 'master' backend
+ $mStat = $mBackend->getFileStat( $mParams );
+ if ( $this->syncChecks & self::CHECK_SHA1 ) {
+ $mSha1 = $mBackend->getFileSha1Base36( $mParams );
+ } else {
+ $mSha1 = false;
+ }
+ // Check if all clone backends agree with the master...
+ foreach ( $this->backends as $index => $cBackend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // master
+ }
+ $cParams = $this->substOpPaths( $params, $cBackend );
+ $cStat = $cBackend->getFileStat( $cParams );
+ if ( $mStat ) { // file is in master
+ if ( !$cStat ) { // file should exist
+ $status->fatal( 'backend-fail-synced', $path );
+ continue;
+ }
+ if ( $this->syncChecks & self::CHECK_SIZE ) {
+ if ( $cStat['size'] != $mStat['size'] ) { // wrong size
+ $status->fatal( 'backend-fail-synced', $path );
+ continue;
+ }
+ }
+ if ( $this->syncChecks & self::CHECK_TIME ) {
+ $mTs = wfTimestamp( TS_UNIX, $mStat['mtime'] );
+ $cTs = wfTimestamp( TS_UNIX, $cStat['mtime'] );
+ if ( abs( $mTs - $cTs ) > 30 ) { // outdated file somewhere
+ $status->fatal( 'backend-fail-synced', $path );
+ continue;
+ }
+ }
+ if ( $this->syncChecks & self::CHECK_SHA1 ) {
+ if ( $cBackend->getFileSha1Base36( $cParams ) !== $mSha1 ) { // wrong SHA1
+ $status->fatal( 'backend-fail-synced', $path );
+ continue;
+ }
+ }
+ } else { // file is not in master
+ if ( $cStat ) { // file should not exist
+ $status->fatal( 'backend-fail-synced', $path );
+ }
+ }
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Check that a set of file paths are usable across all internal backends
+ *
+ * @param array $paths List of storage paths
+ * @return StatusValue
+ */
+ public function accessibilityCheck( array $paths ) {
+ $status = $this->newStatus();
+ if ( count( $this->backends ) <= 1 ) {
+ return $status; // skip checks
+ }
+
+ foreach ( $paths as $path ) {
+ foreach ( $this->backends as $backend ) {
+ $realPath = $this->substPaths( $path, $backend );
+ if ( !$backend->isPathUsableInternal( $realPath ) ) {
+ $status->fatal( 'backend-fail-usable', $path );
+ }
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Check that a set of files are consistent across all internal backends
+ * and re-synchronize those files against the "multi master" if needed.
+ *
+ * @param array $paths List of storage paths
+ * @param string|bool $resyncMode False, True, or "conservative"; see __construct()
+ * @return StatusValue
+ */
+ public function resyncFiles( array $paths, $resyncMode = true ) {
+ $status = $this->newStatus();
+
+ $mBackend = $this->backends[$this->masterIndex];
+ foreach ( $paths as $path ) {
+ $mPath = $this->substPaths( $path, $mBackend );
+ $mSha1 = $mBackend->getFileSha1Base36( [ 'src' => $mPath, 'latest' => true ] );
+ $mStat = $mBackend->getFileStat( [ 'src' => $mPath, 'latest' => true ] );
+ if ( $mStat === null || ( $mSha1 !== false && !$mStat ) ) { // sanity
+ $status->fatal( 'backend-fail-internal', $this->name );
+ wfDebugLog( 'FileOperation', __METHOD__
+ . ': File is not available on the master backend' );
+ continue; // file is not available on the master backend...
+ }
+ // Check of all clone backends agree with the master...
+ foreach ( $this->backends as $index => $cBackend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // master
+ }
+ $cPath = $this->substPaths( $path, $cBackend );
+ $cSha1 = $cBackend->getFileSha1Base36( [ 'src' => $cPath, 'latest' => true ] );
+ $cStat = $cBackend->getFileStat( [ 'src' => $cPath, 'latest' => true ] );
+ if ( $cStat === null || ( $cSha1 !== false && !$cStat ) ) { // sanity
+ $status->fatal( 'backend-fail-internal', $cBackend->getName() );
+ wfDebugLog( 'FileOperation', __METHOD__ .
+ ': File is not available on the clone backend' );
+ continue; // file is not available on the clone backend...
+ }
+ if ( $mSha1 === $cSha1 ) {
+ // already synced; nothing to do
+ } elseif ( $mSha1 !== false ) { // file is in master
+ if ( $resyncMode === 'conservative'
+ && $cStat && $cStat['mtime'] > $mStat['mtime']
+ ) {
+ $status->fatal( 'backend-fail-synced', $path );
+ continue; // don't rollback data
+ }
+ $fsFile = $mBackend->getLocalReference(
+ [ 'src' => $mPath, 'latest' => true ] );
+ $status->merge( $cBackend->quickStore(
+ [ 'src' => $fsFile->getPath(), 'dst' => $cPath ]
+ ) );
+ } elseif ( $mStat === false ) { // file is not in master
+ if ( $resyncMode === 'conservative' ) {
+ $status->fatal( 'backend-fail-synced', $path );
+ continue; // don't delete data
+ }
+ $status->merge( $cBackend->quickDelete( [ 'src' => $cPath ] ) );
+ }
+ }
+ }
+
+ if ( !$status->isOK() ) {
+ wfDebugLog( 'FileOperation', static::class .
+ " failed to resync: " . FormatJson::encode( $paths ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get a list of file storage paths to read or write for a list of operations
+ *
+ * @param array $ops Same format as doOperations()
+ * @return array List of storage paths to files (does not include directories)
+ */
+ protected function fileStoragePathsForOps( array $ops ) {
+ $paths = [];
+ foreach ( $ops as $op ) {
+ if ( isset( $op['src'] ) ) {
+ // For things like copy/move/delete with "ignoreMissingSource" and there
+ // is no source file, nothing should happen and there should be no errors.
+ if ( empty( $op['ignoreMissingSource'] )
+ || $this->fileExists( [ 'src' => $op['src'] ] )
+ ) {
+ $paths[] = $op['src'];
+ }
+ }
+ if ( isset( $op['srcs'] ) ) {
+ $paths = array_merge( $paths, $op['srcs'] );
+ }
+ if ( isset( $op['dst'] ) ) {
+ $paths[] = $op['dst'];
+ }
+ }
+
+ return array_values( array_unique( array_filter( $paths, 'FileBackend::isStoragePath' ) ) );
+ }
+
+ /**
+ * Substitute the backend name in storage path parameters
+ * for a set of operations with that of a given internal backend.
+ *
+ * @param array $ops List of file operation arrays
+ * @param FileBackendStore $backend
+ * @return array
+ */
+ protected function substOpBatchPaths( array $ops, FileBackendStore $backend ) {
+ $newOps = []; // operations
+ foreach ( $ops as $op ) {
+ $newOp = $op; // operation
+ foreach ( [ 'src', 'srcs', 'dst', 'dir' ] as $par ) {
+ if ( isset( $newOp[$par] ) ) { // string or array
+ $newOp[$par] = $this->substPaths( $newOp[$par], $backend );
+ }
+ }
+ $newOps[] = $newOp;
+ }
+
+ return $newOps;
+ }
+
+ /**
+ * Same as substOpBatchPaths() but for a single operation
+ *
+ * @param array $ops File operation array
+ * @param FileBackendStore $backend
+ * @return array
+ */
+ protected function substOpPaths( array $ops, FileBackendStore $backend ) {
+ $newOps = $this->substOpBatchPaths( [ $ops ], $backend );
+
+ return $newOps[0];
+ }
+
+ /**
+ * Substitute the backend of storage paths with an internal backend's name
+ *
+ * @param array|string $paths List of paths or single string path
+ * @param FileBackendStore $backend
+ * @return array|string
+ */
+ protected function substPaths( $paths, FileBackendStore $backend ) {
+ return preg_replace(
+ '!^mwstore://' . preg_quote( $this->name, '!' ) . '/!',
+ StringUtils::escapeRegexReplacement( "mwstore://{$backend->getName()}/" ),
+ $paths // string or array
+ );
+ }
+
+ /**
+ * Substitute the backend of internal storage paths with the proxy backend's name
+ *
+ * @param array|string $paths List of paths or single string path
+ * @return array|string
+ */
+ protected function unsubstPaths( $paths ) {
+ return preg_replace(
+ '!^mwstore://([^/]+)!',
+ StringUtils::escapeRegexReplacement( "mwstore://{$this->name}" ),
+ $paths // string or array
+ );
+ }
+
+ /**
+ * @param array $ops File operations for FileBackend::doOperations()
+ * @return bool Whether there are file path sources with outside lifetime/ownership
+ */
+ protected function hasVolatileSources( array $ops ) {
+ foreach ( $ops as $op ) {
+ if ( $op['op'] === 'store' && !isset( $op['srcRef'] ) ) {
+ return true; // source file might be deleted anytime after do*Operations()
+ }
+ }
+
+ return false;
+ }
+
+ protected function doQuickOperationsInternal( array $ops ) {
+ $status = $this->newStatus();
+ // Do the operations on the master backend; setting StatusValue fields...
+ $realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] );
+ $masterStatus = $this->backends[$this->masterIndex]->doQuickOperations( $realOps );
+ $status->merge( $masterStatus );
+ // Propagate the operations to the clone backends...
+ foreach ( $this->backends as $index => $backend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // done already
+ }
+
+ $realOps = $this->substOpBatchPaths( $ops, $backend );
+ if ( $this->asyncWrites && !$this->hasVolatileSources( $ops ) ) {
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $backend, $realOps ) {
+ $backend->doQuickOperations( $realOps );
+ }
+ );
+ } else {
+ $status->merge( $backend->doQuickOperations( $realOps ) );
+ }
+ }
+ // Make 'success', 'successCount', and 'failCount' fields reflect
+ // the overall operation, rather than all the batches for each backend.
+ // Do this by only using success values from the master backend's batch.
+ $status->success = $masterStatus->success;
+ $status->successCount = $masterStatus->successCount;
+ $status->failCount = $masterStatus->failCount;
+
+ return $status;
+ }
+
+ protected function doPrepare( array $params ) {
+ return $this->doDirectoryOp( 'prepare', $params );
+ }
+
+ protected function doSecure( array $params ) {
+ return $this->doDirectoryOp( 'secure', $params );
+ }
+
+ protected function doPublish( array $params ) {
+ return $this->doDirectoryOp( 'publish', $params );
+ }
+
+ protected function doClean( array $params ) {
+ return $this->doDirectoryOp( 'clean', $params );
+ }
+
+ /**
+ * @param string $method One of (doPrepare,doSecure,doPublish,doClean)
+ * @param array $params Method arguments
+ * @return StatusValue
+ */
+ protected function doDirectoryOp( $method, array $params ) {
+ $status = $this->newStatus();
+
+ $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
+ $masterStatus = $this->backends[$this->masterIndex]->$method( $realParams );
+ $status->merge( $masterStatus );
+
+ foreach ( $this->backends as $index => $backend ) {
+ if ( $index === $this->masterIndex ) {
+ continue; // already done
+ }
+
+ $realParams = $this->substOpPaths( $params, $backend );
+ if ( $this->asyncWrites ) {
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $backend, $method, $realParams ) {
+ $backend->$method( $realParams );
+ }
+ );
+ } else {
+ $status->merge( $backend->$method( $realParams ) );
+ }
+ }
+
+ return $status;
+ }
+
+ public function concatenate( array $params ) {
+ $status = $this->newStatus();
+ // We are writing to an FS file, so we don't need to do this per-backend
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ $status->merge( $this->backends[$index]->concatenate( $realParams ) );
+
+ return $status;
+ }
+
+ public function fileExists( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->fileExists( $realParams );
+ }
+
+ public function getFileTimestamp( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileTimestamp( $realParams );
+ }
+
+ public function getFileSize( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileSize( $realParams );
+ }
+
+ public function getFileStat( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileStat( $realParams );
+ }
+
+ public function getFileXAttributes( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileXAttributes( $realParams );
+ }
+
+ public function getFileContentsMulti( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ $contentsM = $this->backends[$index]->getFileContentsMulti( $realParams );
+
+ $contents = []; // (path => FSFile) mapping using the proxy backend's name
+ foreach ( $contentsM as $path => $data ) {
+ $contents[$this->unsubstPaths( $path )] = $data;
+ }
+
+ return $contents;
+ }
+
+ public function getFileSha1Base36( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileSha1Base36( $realParams );
+ }
+
+ public function getFileProps( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileProps( $realParams );
+ }
+
+ public function streamFile( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->streamFile( $realParams );
+ }
+
+ public function getLocalReferenceMulti( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ $fsFilesM = $this->backends[$index]->getLocalReferenceMulti( $realParams );
+
+ $fsFiles = []; // (path => FSFile) mapping using the proxy backend's name
+ foreach ( $fsFilesM as $path => $fsFile ) {
+ $fsFiles[$this->unsubstPaths( $path )] = $fsFile;
+ }
+
+ return $fsFiles;
+ }
+
+ public function getLocalCopyMulti( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ $tempFilesM = $this->backends[$index]->getLocalCopyMulti( $realParams );
+
+ $tempFiles = []; // (path => TempFSFile) mapping using the proxy backend's name
+ foreach ( $tempFilesM as $path => $tempFile ) {
+ $tempFiles[$this->unsubstPaths( $path )] = $tempFile;
+ }
+
+ return $tempFiles;
+ }
+
+ public function getFileHttpUrl( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->getFileHttpUrl( $realParams );
+ }
+
+ public function directoryExists( array $params ) {
+ $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
+
+ return $this->backends[$this->masterIndex]->directoryExists( $realParams );
+ }
+
+ public function getDirectoryList( array $params ) {
+ $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
+
+ return $this->backends[$this->masterIndex]->getDirectoryList( $realParams );
+ }
+
+ public function getFileList( array $params ) {
+ $realParams = $this->substOpPaths( $params, $this->backends[$this->masterIndex] );
+
+ return $this->backends[$this->masterIndex]->getFileList( $realParams );
+ }
+
+ public function getFeatures() {
+ return $this->backends[$this->masterIndex]->getFeatures();
+ }
+
+ public function clearCache( array $paths = null ) {
+ foreach ( $this->backends as $backend ) {
+ $realPaths = is_array( $paths ) ? $this->substPaths( $paths, $backend ) : null;
+ $backend->clearCache( $realPaths );
+ }
+ }
+
+ public function preloadCache( array $paths ) {
+ $realPaths = $this->substPaths( $paths, $this->backends[$this->readIndex] );
+ $this->backends[$this->readIndex]->preloadCache( $realPaths );
+ }
+
+ public function preloadFileStat( array $params ) {
+ $index = $this->getReadIndexFromParams( $params );
+ $realParams = $this->substOpPaths( $params, $this->backends[$index] );
+
+ return $this->backends[$index]->preloadFileStat( $realParams );
+ }
+
+ public function getScopedLocksForOps( array $ops, StatusValue $status ) {
+ $realOps = $this->substOpBatchPaths( $ops, $this->backends[$this->masterIndex] );
+ $fileOps = $this->backends[$this->masterIndex]->getOperationsInternal( $realOps );
+ // Get the paths to lock from the master backend
+ $paths = $this->backends[$this->masterIndex]->getPathsToLockForOpsInternal( $fileOps );
+ // Get the paths under the proxy backend's name
+ $pbPaths = [
+ LockManager::LOCK_UW => $this->unsubstPaths( $paths[LockManager::LOCK_UW] ),
+ LockManager::LOCK_EX => $this->unsubstPaths( $paths[LockManager::LOCK_EX] )
+ ];
+
+ // Actually acquire the locks
+ return $this->getScopedFileLocks( $pbPaths, 'mixed', $status );
+ }
+
+ /**
+ * @param array $params
+ * @return int The master or read affinity backend index, based on $params['latest']
+ */
+ protected function getReadIndexFromParams( array $params ) {
+ return !empty( $params['latest'] ) ? $this->masterIndex : $this->readIndex;
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/FileBackendStore.php b/www/wiki/includes/libs/filebackend/FileBackendStore.php
new file mode 100644
index 00000000..b8eec3f0
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/FileBackendStore.php
@@ -0,0 +1,1976 @@
+<?php
+/**
+ * Base class for all backends using particular storage medium.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+
+/**
+ * @brief Base class for all backends using particular storage medium.
+ *
+ * This class defines the methods as abstract that subclasses must implement.
+ * Outside callers should *not* use functions with "Internal" in the name.
+ *
+ * The FileBackend operations are implemented using basic functions
+ * such as storeInternal(), copyInternal(), deleteInternal() and the like.
+ * This class is also responsible for path resolution and sanitization.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+abstract class FileBackendStore extends FileBackend {
+ /** @var WANObjectCache */
+ protected $memCache;
+ /** @var BagOStuff */
+ protected $srvCache;
+ /** @var ProcessCacheLRU Map of paths to small (RAM/disk) cache items */
+ protected $cheapCache;
+ /** @var ProcessCacheLRU Map of paths to large (RAM/disk) cache items */
+ protected $expensiveCache;
+
+ /** @var array Map of container names to sharding config */
+ protected $shardViaHashLevels = [];
+
+ /** @var callable Method to get the MIME type of files */
+ protected $mimeCallback;
+
+ protected $maxFileSize = 4294967296; // integer bytes (4GiB)
+
+ const CACHE_TTL = 10; // integer; TTL in seconds for process cache entries
+ const CACHE_CHEAP_SIZE = 500; // integer; max entries in "cheap cache"
+ const CACHE_EXPENSIVE_SIZE = 5; // integer; max entries in "expensive cache"
+
+ /**
+ * @see FileBackend::__construct()
+ * Additional $config params include:
+ * - srvCache : BagOStuff cache to APC/XCache or the like.
+ * - wanCache : WANObjectCache object to use for persistent caching.
+ * - mimeCallback : Callback that takes (storage path, content, file system path) and
+ * returns the MIME type of the file or 'unknown/unknown'. The file
+ * system path parameter should be used if the content one is null.
+ *
+ * @param array $config
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+ $this->mimeCallback = isset( $config['mimeCallback'] )
+ ? $config['mimeCallback']
+ : null;
+ $this->srvCache = new EmptyBagOStuff(); // disabled by default
+ $this->memCache = WANObjectCache::newEmpty(); // disabled by default
+ $this->cheapCache = new ProcessCacheLRU( self::CACHE_CHEAP_SIZE );
+ $this->expensiveCache = new ProcessCacheLRU( self::CACHE_EXPENSIVE_SIZE );
+ }
+
+ /**
+ * Get the maximum allowable file size given backend
+ * medium restrictions and basic performance constraints.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * @return int Bytes
+ */
+ final public function maxFileSizeInternal() {
+ return $this->maxFileSize;
+ }
+
+ /**
+ * Check if a file can be created or changed at a given storage path.
+ * FS backends should check if the parent directory exists, files can be
+ * written under it, and that any file already there is writable.
+ * Backends using key/value stores should check if the container exists.
+ *
+ * @param string $storagePath
+ * @return bool
+ */
+ abstract public function isPathUsableInternal( $storagePath );
+
+ /**
+ * Create a file in the backend with the given contents.
+ * This will overwrite any file that exists at the destination.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - content : the raw file contents
+ * - dst : destination storage path
+ * - headers : HTTP header name/value map
+ * - async : StatusValue will be returned immediately if supported.
+ * If the StatusValue is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ * - dstExists : Whether a file exists at the destination (optimization).
+ * Callers can use "false" if no existing file is being changed.
+ *
+ * @param array $params
+ * @return StatusValue
+ */
+ final public function createInternal( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ if ( strlen( $params['content'] ) > $this->maxFileSizeInternal() ) {
+ $status = $this->newStatus( 'backend-fail-maxsize',
+ $params['dst'], $this->maxFileSizeInternal() );
+ } else {
+ $status = $this->doCreateInternal( $params );
+ $this->clearCache( [ $params['dst'] ] );
+ if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) {
+ $this->deleteFileCache( $params['dst'] ); // persistent cache
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::createInternal()
+ * @param array $params
+ * @return StatusValue
+ */
+ abstract protected function doCreateInternal( array $params );
+
+ /**
+ * Store a file into the backend from a file on disk.
+ * This will overwrite any file that exists at the destination.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source path on disk
+ * - dst : destination storage path
+ * - headers : HTTP header name/value map
+ * - async : StatusValue will be returned immediately if supported.
+ * If the StatusValue is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ * - dstExists : Whether a file exists at the destination (optimization).
+ * Callers can use "false" if no existing file is being changed.
+ *
+ * @param array $params
+ * @return StatusValue
+ */
+ final public function storeInternal( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ if ( filesize( $params['src'] ) > $this->maxFileSizeInternal() ) {
+ $status = $this->newStatus( 'backend-fail-maxsize',
+ $params['dst'], $this->maxFileSizeInternal() );
+ } else {
+ $status = $this->doStoreInternal( $params );
+ $this->clearCache( [ $params['dst'] ] );
+ if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) {
+ $this->deleteFileCache( $params['dst'] ); // persistent cache
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::storeInternal()
+ * @param array $params
+ * @return StatusValue
+ */
+ abstract protected function doStoreInternal( array $params );
+
+ /**
+ * Copy a file from one storage path to another in the backend.
+ * This will overwrite any file that exists at the destination.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source storage path
+ * - dst : destination storage path
+ * - ignoreMissingSource : do nothing if the source file does not exist
+ * - headers : HTTP header name/value map
+ * - async : StatusValue will be returned immediately if supported.
+ * If the StatusValue is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ * - dstExists : Whether a file exists at the destination (optimization).
+ * Callers can use "false" if no existing file is being changed.
+ *
+ * @param array $params
+ * @return StatusValue
+ */
+ final public function copyInternal( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->doCopyInternal( $params );
+ $this->clearCache( [ $params['dst'] ] );
+ if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) {
+ $this->deleteFileCache( $params['dst'] ); // persistent cache
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::copyInternal()
+ * @param array $params
+ * @return StatusValue
+ */
+ abstract protected function doCopyInternal( array $params );
+
+ /**
+ * Delete a file at the storage path.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source storage path
+ * - ignoreMissingSource : do nothing if the source file does not exist
+ * - async : StatusValue will be returned immediately if supported.
+ * If the StatusValue is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ *
+ * @param array $params
+ * @return StatusValue
+ */
+ final public function deleteInternal( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->doDeleteInternal( $params );
+ $this->clearCache( [ $params['src'] ] );
+ $this->deleteFileCache( $params['src'] ); // persistent cache
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::deleteInternal()
+ * @param array $params
+ * @return StatusValue
+ */
+ abstract protected function doDeleteInternal( array $params );
+
+ /**
+ * Move a file from one storage path to another in the backend.
+ * This will overwrite any file that exists at the destination.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source storage path
+ * - dst : destination storage path
+ * - ignoreMissingSource : do nothing if the source file does not exist
+ * - headers : HTTP header name/value map
+ * - async : StatusValue will be returned immediately if supported.
+ * If the StatusValue is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ * - dstExists : Whether a file exists at the destination (optimization).
+ * Callers can use "false" if no existing file is being changed.
+ *
+ * @param array $params
+ * @return StatusValue
+ */
+ final public function moveInternal( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->doMoveInternal( $params );
+ $this->clearCache( [ $params['src'], $params['dst'] ] );
+ $this->deleteFileCache( $params['src'] ); // persistent cache
+ if ( !isset( $params['dstExists'] ) || $params['dstExists'] ) {
+ $this->deleteFileCache( $params['dst'] ); // persistent cache
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::moveInternal()
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doMoveInternal( array $params ) {
+ unset( $params['async'] ); // two steps, won't work here :)
+ $nsrc = FileBackend::normalizeStoragePath( $params['src'] );
+ $ndst = FileBackend::normalizeStoragePath( $params['dst'] );
+ // Copy source to dest
+ $status = $this->copyInternal( $params );
+ if ( $nsrc !== $ndst && $status->isOK() ) {
+ // Delete source (only fails due to races or network problems)
+ $status->merge( $this->deleteInternal( [ 'src' => $params['src'] ] ) );
+ $status->setResult( true, $status->value ); // ignore delete() errors
+ }
+
+ return $status;
+ }
+
+ /**
+ * Alter metadata for a file at the storage path.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * $params include:
+ * - src : source storage path
+ * - headers : HTTP header name/value map
+ * - async : StatusValue will be returned immediately if supported.
+ * If the StatusValue is OK, then its value field will be
+ * set to a FileBackendStoreOpHandle object.
+ *
+ * @param array $params
+ * @return StatusValue
+ */
+ final public function describeInternal( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ if ( count( $params['headers'] ) ) {
+ $status = $this->doDescribeInternal( $params );
+ $this->clearCache( [ $params['src'] ] );
+ $this->deleteFileCache( $params['src'] ); // persistent cache
+ } else {
+ $status = $this->newStatus(); // nothing to do
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::describeInternal()
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doDescribeInternal( array $params ) {
+ return $this->newStatus();
+ }
+
+ /**
+ * No-op file operation that does nothing.
+ * Do not call this function from places outside FileBackend and FileOp.
+ *
+ * @param array $params
+ * @return StatusValue
+ */
+ final public function nullInternal( array $params ) {
+ return $this->newStatus();
+ }
+
+ final public function concatenate( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->newStatus();
+
+ // Try to lock the source files for the scope of this function
+ $scopeLockS = $this->getScopedFileLocks( $params['srcs'], LockManager::LOCK_UW, $status );
+ if ( $status->isOK() ) {
+ // Actually do the file concatenation...
+ $start_time = microtime( true );
+ $status->merge( $this->doConcatenate( $params ) );
+ $sec = microtime( true ) - $start_time;
+ if ( !$status->isOK() ) {
+ $this->logger->error( static::class . "-{$this->name}" .
+ " failed to concatenate " . count( $params['srcs'] ) . " file(s) [$sec sec]" );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::concatenate()
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doConcatenate( array $params ) {
+ $status = $this->newStatus();
+ $tmpPath = $params['dst']; // convenience
+ unset( $params['latest'] ); // sanity
+
+ // Check that the specified temp file is valid...
+ MediaWiki\suppressWarnings();
+ $ok = ( is_file( $tmpPath ) && filesize( $tmpPath ) == 0 );
+ MediaWiki\restoreWarnings();
+ if ( !$ok ) { // not present or not empty
+ $status->fatal( 'backend-fail-opentemp', $tmpPath );
+
+ return $status;
+ }
+
+ // Get local FS versions of the chunks needed for the concatenation...
+ $fsFiles = $this->getLocalReferenceMulti( $params );
+ foreach ( $fsFiles as $path => &$fsFile ) {
+ if ( !$fsFile ) { // chunk failed to download?
+ $fsFile = $this->getLocalReference( [ 'src' => $path ] );
+ if ( !$fsFile ) { // retry failed?
+ $status->fatal( 'backend-fail-read', $path );
+
+ return $status;
+ }
+ }
+ }
+ unset( $fsFile ); // unset reference so we can reuse $fsFile
+
+ // Get a handle for the destination temp file
+ $tmpHandle = fopen( $tmpPath, 'ab' );
+ if ( $tmpHandle === false ) {
+ $status->fatal( 'backend-fail-opentemp', $tmpPath );
+
+ return $status;
+ }
+
+ // Build up the temp file using the source chunks (in order)...
+ foreach ( $fsFiles as $virtualSource => $fsFile ) {
+ // Get a handle to the local FS version
+ $sourceHandle = fopen( $fsFile->getPath(), 'rb' );
+ if ( $sourceHandle === false ) {
+ fclose( $tmpHandle );
+ $status->fatal( 'backend-fail-read', $virtualSource );
+
+ return $status;
+ }
+ // Append chunk to file (pass chunk size to avoid magic quotes)
+ if ( !stream_copy_to_stream( $sourceHandle, $tmpHandle ) ) {
+ fclose( $sourceHandle );
+ fclose( $tmpHandle );
+ $status->fatal( 'backend-fail-writetemp', $tmpPath );
+
+ return $status;
+ }
+ fclose( $sourceHandle );
+ }
+ if ( !fclose( $tmpHandle ) ) {
+ $status->fatal( 'backend-fail-closetemp', $tmpPath );
+
+ return $status;
+ }
+
+ clearstatcache(); // temp file changed
+
+ return $status;
+ }
+
+ final protected function doPrepare( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->newStatus();
+
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
+
+ return $status; // invalid storage path
+ }
+
+ if ( $shard !== null ) { // confined to a single container/shard
+ $status->merge( $this->doPrepareInternal( $fullCont, $dir, $params ) );
+ } else { // directory is on several shards
+ $this->logger->debug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $status->merge( $this->doPrepareInternal( "{$fullCont}{$suffix}", $dir, $params ) );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::doPrepare()
+ * @param string $container
+ * @param string $dir
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doPrepareInternal( $container, $dir, array $params ) {
+ return $this->newStatus();
+ }
+
+ final protected function doSecure( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->newStatus();
+
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
+
+ return $status; // invalid storage path
+ }
+
+ if ( $shard !== null ) { // confined to a single container/shard
+ $status->merge( $this->doSecureInternal( $fullCont, $dir, $params ) );
+ } else { // directory is on several shards
+ $this->logger->debug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $status->merge( $this->doSecureInternal( "{$fullCont}{$suffix}", $dir, $params ) );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::doSecure()
+ * @param string $container
+ * @param string $dir
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doSecureInternal( $container, $dir, array $params ) {
+ return $this->newStatus();
+ }
+
+ final protected function doPublish( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->newStatus();
+
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
+
+ return $status; // invalid storage path
+ }
+
+ if ( $shard !== null ) { // confined to a single container/shard
+ $status->merge( $this->doPublishInternal( $fullCont, $dir, $params ) );
+ } else { // directory is on several shards
+ $this->logger->debug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $status->merge( $this->doPublishInternal( "{$fullCont}{$suffix}", $dir, $params ) );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::doPublish()
+ * @param string $container
+ * @param string $dir
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doPublishInternal( $container, $dir, array $params ) {
+ return $this->newStatus();
+ }
+
+ final protected function doClean( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->newStatus();
+
+ // Recursive: first delete all empty subdirs recursively
+ if ( !empty( $params['recursive'] ) && !$this->directoriesAreVirtual() ) {
+ $subDirsRel = $this->getTopDirectoryList( [ 'dir' => $params['dir'] ] );
+ if ( $subDirsRel !== null ) { // no errors
+ foreach ( $subDirsRel as $subDirRel ) {
+ $subDir = $params['dir'] . "/{$subDirRel}"; // full path
+ $status->merge( $this->doClean( [ 'dir' => $subDir ] + $params ) );
+ }
+ unset( $subDirsRel ); // free directory for rmdir() on Windows (for FS backends)
+ }
+ }
+
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dir'] );
+
+ return $status; // invalid storage path
+ }
+
+ // Attempt to lock this directory...
+ $filesLockEx = [ $params['dir'] ];
+ $scopedLockE = $this->getScopedFileLocks( $filesLockEx, LockManager::LOCK_EX, $status );
+ if ( !$status->isOK() ) {
+ return $status; // abort
+ }
+
+ if ( $shard !== null ) { // confined to a single container/shard
+ $status->merge( $this->doCleanInternal( $fullCont, $dir, $params ) );
+ $this->deleteContainerCache( $fullCont ); // purge cache
+ } else { // directory is on several shards
+ $this->logger->debug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $status->merge( $this->doCleanInternal( "{$fullCont}{$suffix}", $dir, $params ) );
+ $this->deleteContainerCache( "{$fullCont}{$suffix}" ); // purge cache
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::doClean()
+ * @param string $container
+ * @param string $dir
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doCleanInternal( $container, $dir, array $params ) {
+ return $this->newStatus();
+ }
+
+ final public function fileExists( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $stat = $this->getFileStat( $params );
+
+ return ( $stat === null ) ? null : (bool)$stat; // null => failure
+ }
+
+ final public function getFileTimestamp( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $stat = $this->getFileStat( $params );
+
+ return $stat ? $stat['mtime'] : false;
+ }
+
+ final public function getFileSize( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $stat = $this->getFileStat( $params );
+
+ return $stat ? $stat['size'] : false;
+ }
+
+ final public function getFileStat( array $params ) {
+ $path = self::normalizeStoragePath( $params['src'] );
+ if ( $path === null ) {
+ return false; // invalid storage path
+ }
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $latest = !empty( $params['latest'] ); // use latest data?
+ if ( !$latest && !$this->cheapCache->has( $path, 'stat', self::CACHE_TTL ) ) {
+ $this->primeFileCache( [ $path ] ); // check persistent cache
+ }
+ if ( $this->cheapCache->has( $path, 'stat', self::CACHE_TTL ) ) {
+ $stat = $this->cheapCache->get( $path, 'stat' );
+ // If we want the latest data, check that this cached
+ // value was in fact fetched with the latest available data.
+ if ( is_array( $stat ) ) {
+ if ( !$latest || $stat['latest'] ) {
+ return $stat;
+ }
+ } elseif ( in_array( $stat, [ 'NOT_EXIST', 'NOT_EXIST_LATEST' ] ) ) {
+ if ( !$latest || $stat === 'NOT_EXIST_LATEST' ) {
+ return false;
+ }
+ }
+ }
+ $stat = $this->doGetFileStat( $params );
+ if ( is_array( $stat ) ) { // file exists
+ // Strongly consistent backends can automatically set "latest"
+ $stat['latest'] = isset( $stat['latest'] ) ? $stat['latest'] : $latest;
+ $this->cheapCache->set( $path, 'stat', $stat );
+ $this->setFileCache( $path, $stat ); // update persistent cache
+ if ( isset( $stat['sha1'] ) ) { // some backends store SHA-1 as metadata
+ $this->cheapCache->set( $path, 'sha1',
+ [ 'hash' => $stat['sha1'], 'latest' => $latest ] );
+ }
+ if ( isset( $stat['xattr'] ) ) { // some backends store headers/metadata
+ $stat['xattr'] = self::normalizeXAttributes( $stat['xattr'] );
+ $this->cheapCache->set( $path, 'xattr',
+ [ 'map' => $stat['xattr'], 'latest' => $latest ] );
+ }
+ } elseif ( $stat === false ) { // file does not exist
+ $this->cheapCache->set( $path, 'stat', $latest ? 'NOT_EXIST_LATEST' : 'NOT_EXIST' );
+ $this->cheapCache->set( $path, 'xattr', [ 'map' => false, 'latest' => $latest ] );
+ $this->cheapCache->set( $path, 'sha1', [ 'hash' => false, 'latest' => $latest ] );
+ $this->logger->debug( __METHOD__ . ": File $path does not exist.\n" );
+ } else { // an error occurred
+ $this->logger->warning( __METHOD__ . ": Could not stat file $path.\n" );
+ }
+
+ return $stat;
+ }
+
+ /**
+ * @see FileBackendStore::getFileStat()
+ * @param array $params
+ */
+ abstract protected function doGetFileStat( array $params );
+
+ public function getFileContentsMulti( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ $params = $this->setConcurrencyFlags( $params );
+ $contents = $this->doGetFileContentsMulti( $params );
+
+ return $contents;
+ }
+
+ /**
+ * @see FileBackendStore::getFileContentsMulti()
+ * @param array $params
+ * @return array
+ */
+ protected function doGetFileContentsMulti( array $params ) {
+ $contents = [];
+ foreach ( $this->doGetLocalReferenceMulti( $params ) as $path => $fsFile ) {
+ MediaWiki\suppressWarnings();
+ $contents[$path] = $fsFile ? file_get_contents( $fsFile->getPath() ) : false;
+ MediaWiki\restoreWarnings();
+ }
+
+ return $contents;
+ }
+
+ final public function getFileXAttributes( array $params ) {
+ $path = self::normalizeStoragePath( $params['src'] );
+ if ( $path === null ) {
+ return false; // invalid storage path
+ }
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $latest = !empty( $params['latest'] ); // use latest data?
+ if ( $this->cheapCache->has( $path, 'xattr', self::CACHE_TTL ) ) {
+ $stat = $this->cheapCache->get( $path, 'xattr' );
+ // If we want the latest data, check that this cached
+ // value was in fact fetched with the latest available data.
+ if ( !$latest || $stat['latest'] ) {
+ return $stat['map'];
+ }
+ }
+ $fields = $this->doGetFileXAttributes( $params );
+ $fields = is_array( $fields ) ? self::normalizeXAttributes( $fields ) : false;
+ $this->cheapCache->set( $path, 'xattr', [ 'map' => $fields, 'latest' => $latest ] );
+
+ return $fields;
+ }
+
+ /**
+ * @see FileBackendStore::getFileXAttributes()
+ * @param array $params
+ * @return array[][]
+ */
+ protected function doGetFileXAttributes( array $params ) {
+ return [ 'headers' => [], 'metadata' => [] ]; // not supported
+ }
+
+ final public function getFileSha1Base36( array $params ) {
+ $path = self::normalizeStoragePath( $params['src'] );
+ if ( $path === null ) {
+ return false; // invalid storage path
+ }
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $latest = !empty( $params['latest'] ); // use latest data?
+ if ( $this->cheapCache->has( $path, 'sha1', self::CACHE_TTL ) ) {
+ $stat = $this->cheapCache->get( $path, 'sha1' );
+ // If we want the latest data, check that this cached
+ // value was in fact fetched with the latest available data.
+ if ( !$latest || $stat['latest'] ) {
+ return $stat['hash'];
+ }
+ }
+ $hash = $this->doGetFileSha1Base36( $params );
+ $this->cheapCache->set( $path, 'sha1', [ 'hash' => $hash, 'latest' => $latest ] );
+
+ return $hash;
+ }
+
+ /**
+ * @see FileBackendStore::getFileSha1Base36()
+ * @param array $params
+ * @return bool|string
+ */
+ protected function doGetFileSha1Base36( array $params ) {
+ $fsFile = $this->getLocalReference( $params );
+ if ( !$fsFile ) {
+ return false;
+ } else {
+ return $fsFile->getSha1Base36();
+ }
+ }
+
+ final public function getFileProps( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $fsFile = $this->getLocalReference( $params );
+ $props = $fsFile ? $fsFile->getProps() : FSFile::placeholderProps();
+
+ return $props;
+ }
+
+ final public function getLocalReferenceMulti( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ $params = $this->setConcurrencyFlags( $params );
+
+ $fsFiles = []; // (path => FSFile)
+ $latest = !empty( $params['latest'] ); // use latest data?
+ // Reuse any files already in process cache...
+ foreach ( $params['srcs'] as $src ) {
+ $path = self::normalizeStoragePath( $src );
+ if ( $path === null ) {
+ $fsFiles[$src] = null; // invalid storage path
+ } elseif ( $this->expensiveCache->has( $path, 'localRef' ) ) {
+ $val = $this->expensiveCache->get( $path, 'localRef' );
+ // If we want the latest data, check that this cached
+ // value was in fact fetched with the latest available data.
+ if ( !$latest || $val['latest'] ) {
+ $fsFiles[$src] = $val['object'];
+ }
+ }
+ }
+ // Fetch local references of any remaning files...
+ $params['srcs'] = array_diff( $params['srcs'], array_keys( $fsFiles ) );
+ foreach ( $this->doGetLocalReferenceMulti( $params ) as $path => $fsFile ) {
+ $fsFiles[$path] = $fsFile;
+ if ( $fsFile ) { // update the process cache...
+ $this->expensiveCache->set( $path, 'localRef',
+ [ 'object' => $fsFile, 'latest' => $latest ] );
+ }
+ }
+
+ return $fsFiles;
+ }
+
+ /**
+ * @see FileBackendStore::getLocalReferenceMulti()
+ * @param array $params
+ * @return array
+ */
+ protected function doGetLocalReferenceMulti( array $params ) {
+ return $this->doGetLocalCopyMulti( $params );
+ }
+
+ final public function getLocalCopyMulti( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ $params = $this->setConcurrencyFlags( $params );
+ $tmpFiles = $this->doGetLocalCopyMulti( $params );
+
+ return $tmpFiles;
+ }
+
+ /**
+ * @see FileBackendStore::getLocalCopyMulti()
+ * @param array $params
+ * @return array
+ */
+ abstract protected function doGetLocalCopyMulti( array $params );
+
+ /**
+ * @see FileBackend::getFileHttpUrl()
+ * @param array $params
+ * @return string|null
+ */
+ public function getFileHttpUrl( array $params ) {
+ return null; // not supported
+ }
+
+ final public function streamFile( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->newStatus();
+
+ // Always set some fields for subclass convenience
+ $params['options'] = isset( $params['options'] ) ? $params['options'] : [];
+ $params['headers'] = isset( $params['headers'] ) ? $params['headers'] : [];
+
+ // Don't stream it out as text/html if there was a PHP error
+ if ( ( empty( $params['headless'] ) || $params['headers'] ) && headers_sent() ) {
+ print "Headers already sent, terminating.\n";
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+ return $status;
+ }
+
+ $status->merge( $this->doStreamFile( $params ) );
+
+ return $status;
+ }
+
+ /**
+ * @see FileBackendStore::streamFile()
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function doStreamFile( array $params ) {
+ $status = $this->newStatus();
+
+ $flags = 0;
+ $flags |= !empty( $params['headless'] ) ? HTTPFileStreamer::STREAM_HEADLESS : 0;
+ $flags |= !empty( $params['allowOB'] ) ? HTTPFileStreamer::STREAM_ALLOW_OB : 0;
+
+ $fsFile = $this->getLocalReference( $params );
+ if ( $fsFile ) {
+ $streamer = new HTTPFileStreamer(
+ $fsFile->getPath(),
+ [
+ 'obResetFunc' => $this->obResetFunc,
+ 'streamMimeFunc' => $this->streamMimeFunc
+ ]
+ );
+ $res = $streamer->stream( $params['headers'], true, $params['options'], $flags );
+ } else {
+ $res = false;
+ HTTPFileStreamer::send404Message( $params['src'], $flags );
+ }
+
+ if ( !$res ) {
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+ }
+
+ return $status;
+ }
+
+ final public function directoryExists( array $params ) {
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) {
+ return false; // invalid storage path
+ }
+ if ( $shard !== null ) { // confined to a single container/shard
+ return $this->doDirectoryExists( $fullCont, $dir, $params );
+ } else { // directory is on several shards
+ $this->logger->debug( __METHOD__ . ": iterating over all container shards.\n" );
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+ $res = false; // response
+ foreach ( $this->getContainerSuffixes( $shortCont ) as $suffix ) {
+ $exists = $this->doDirectoryExists( "{$fullCont}{$suffix}", $dir, $params );
+ if ( $exists ) {
+ $res = true;
+ break; // found one!
+ } elseif ( $exists === null ) { // error?
+ $res = null; // if we don't find anything, it is indeterminate
+ }
+ }
+
+ return $res;
+ }
+ }
+
+ /**
+ * @see FileBackendStore::directoryExists()
+ *
+ * @param string $container Resolved container name
+ * @param string $dir Resolved path relative to container
+ * @param array $params
+ * @return bool|null
+ */
+ abstract protected function doDirectoryExists( $container, $dir, array $params );
+
+ final public function getDirectoryList( array $params ) {
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) { // invalid storage path
+ return null;
+ }
+ if ( $shard !== null ) {
+ // File listing is confined to a single container/shard
+ return $this->getDirectoryListInternal( $fullCont, $dir, $params );
+ } else {
+ $this->logger->debug( __METHOD__ . ": iterating over all container shards.\n" );
+ // File listing spans multiple containers/shards
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+
+ return new FileBackendStoreShardDirIterator( $this,
+ $fullCont, $dir, $this->getContainerSuffixes( $shortCont ), $params );
+ }
+ }
+
+ /**
+ * Do not call this function from places outside FileBackend
+ *
+ * @see FileBackendStore::getDirectoryList()
+ *
+ * @param string $container Resolved container name
+ * @param string $dir Resolved path relative to container
+ * @param array $params
+ * @return Traversable|array|null Returns null on failure
+ */
+ abstract public function getDirectoryListInternal( $container, $dir, array $params );
+
+ final public function getFileList( array $params ) {
+ list( $fullCont, $dir, $shard ) = $this->resolveStoragePath( $params['dir'] );
+ if ( $dir === null ) { // invalid storage path
+ return null;
+ }
+ if ( $shard !== null ) {
+ // File listing is confined to a single container/shard
+ return $this->getFileListInternal( $fullCont, $dir, $params );
+ } else {
+ $this->logger->debug( __METHOD__ . ": iterating over all container shards.\n" );
+ // File listing spans multiple containers/shards
+ list( , $shortCont, ) = self::splitStoragePath( $params['dir'] );
+
+ return new FileBackendStoreShardFileIterator( $this,
+ $fullCont, $dir, $this->getContainerSuffixes( $shortCont ), $params );
+ }
+ }
+
+ /**
+ * Do not call this function from places outside FileBackend
+ *
+ * @see FileBackendStore::getFileList()
+ *
+ * @param string $container Resolved container name
+ * @param string $dir Resolved path relative to container
+ * @param array $params
+ * @return Traversable|array|null Returns null on failure
+ */
+ abstract public function getFileListInternal( $container, $dir, array $params );
+
+ /**
+ * Return a list of FileOp objects from a list of operations.
+ * Do not call this function from places outside FileBackend.
+ *
+ * The result must have the same number of items as the input.
+ * An exception is thrown if an unsupported operation is requested.
+ *
+ * @param array $ops Same format as doOperations()
+ * @return FileOp[] List of FileOp objects
+ * @throws FileBackendError
+ */
+ final public function getOperationsInternal( array $ops ) {
+ $supportedOps = [
+ 'store' => 'StoreFileOp',
+ 'copy' => 'CopyFileOp',
+ 'move' => 'MoveFileOp',
+ 'delete' => 'DeleteFileOp',
+ 'create' => 'CreateFileOp',
+ 'describe' => 'DescribeFileOp',
+ 'null' => 'NullFileOp'
+ ];
+
+ $performOps = []; // array of FileOp objects
+ // Build up ordered array of FileOps...
+ foreach ( $ops as $operation ) {
+ $opName = $operation['op'];
+ if ( isset( $supportedOps[$opName] ) ) {
+ $class = $supportedOps[$opName];
+ // Get params for this operation
+ $params = $operation;
+ // Append the FileOp class
+ $performOps[] = new $class( $this, $params, $this->logger );
+ } else {
+ throw new FileBackendError( "Operation '$opName' is not supported." );
+ }
+ }
+
+ return $performOps;
+ }
+
+ /**
+ * Get a list of storage paths to lock for a list of operations
+ * Returns an array with LockManager::LOCK_UW (shared locks) and
+ * LockManager::LOCK_EX (exclusive locks) keys, each corresponding
+ * to a list of storage paths to be locked. All returned paths are
+ * normalized.
+ *
+ * @param array $performOps List of FileOp objects
+ * @return array (LockManager::LOCK_UW => path list, LockManager::LOCK_EX => path list)
+ */
+ final public function getPathsToLockForOpsInternal( array $performOps ) {
+ // Build up a list of files to lock...
+ $paths = [ 'sh' => [], 'ex' => [] ];
+ foreach ( $performOps as $fileOp ) {
+ $paths['sh'] = array_merge( $paths['sh'], $fileOp->storagePathsRead() );
+ $paths['ex'] = array_merge( $paths['ex'], $fileOp->storagePathsChanged() );
+ }
+ // Optimization: if doing an EX lock anyway, don't also set an SH one
+ $paths['sh'] = array_diff( $paths['sh'], $paths['ex'] );
+ // Get a shared lock on the parent directory of each path changed
+ $paths['sh'] = array_merge( $paths['sh'], array_map( 'dirname', $paths['ex'] ) );
+
+ return [
+ LockManager::LOCK_UW => $paths['sh'],
+ LockManager::LOCK_EX => $paths['ex']
+ ];
+ }
+
+ public function getScopedLocksForOps( array $ops, StatusValue $status ) {
+ $paths = $this->getPathsToLockForOpsInternal( $this->getOperationsInternal( $ops ) );
+
+ return $this->getScopedFileLocks( $paths, 'mixed', $status );
+ }
+
+ final protected function doOperationsInternal( array $ops, array $opts ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->newStatus();
+
+ // Fix up custom header name/value pairs...
+ $ops = array_map( [ $this, 'sanitizeOpHeaders' ], $ops );
+
+ // Build up a list of FileOps...
+ $performOps = $this->getOperationsInternal( $ops );
+
+ // Acquire any locks as needed...
+ if ( empty( $opts['nonLocking'] ) ) {
+ // Build up a list of files to lock...
+ $paths = $this->getPathsToLockForOpsInternal( $performOps );
+ // Try to lock those files for the scope of this function...
+
+ $scopeLock = $this->getScopedFileLocks( $paths, 'mixed', $status );
+ if ( !$status->isOK() ) {
+ return $status; // abort
+ }
+ }
+
+ // Clear any file cache entries (after locks acquired)
+ if ( empty( $opts['preserveCache'] ) ) {
+ $this->clearCache();
+ }
+
+ // Build the list of paths involved
+ $paths = [];
+ foreach ( $performOps as $performOp ) {
+ $paths = array_merge( $paths, $performOp->storagePathsRead() );
+ $paths = array_merge( $paths, $performOp->storagePathsChanged() );
+ }
+
+ // Enlarge the cache to fit the stat entries of these files
+ $this->cheapCache->resize( max( 2 * count( $paths ), self::CACHE_CHEAP_SIZE ) );
+
+ // Load from the persistent container caches
+ $this->primeContainerCache( $paths );
+ // Get the latest stat info for all the files (having locked them)
+ $ok = $this->preloadFileStat( [ 'srcs' => $paths, 'latest' => true ] );
+
+ if ( $ok ) {
+ // Actually attempt the operation batch...
+ $opts = $this->setConcurrencyFlags( $opts );
+ $subStatus = FileOpBatch::attempt( $performOps, $opts, $this->fileJournal );
+ } else {
+ // If we could not even stat some files, then bail out...
+ $subStatus = $this->newStatus( 'backend-fail-internal', $this->name );
+ foreach ( $ops as $i => $op ) { // mark each op as failed
+ $subStatus->success[$i] = false;
+ ++$subStatus->failCount;
+ }
+ $this->logger->error( static::class . "-{$this->name} " .
+ " stat failure; aborted operations: " . FormatJson::encode( $ops ) );
+ }
+
+ // Merge errors into StatusValue fields
+ $status->merge( $subStatus );
+ $status->success = $subStatus->success; // not done in merge()
+
+ // Shrink the stat cache back to normal size
+ $this->cheapCache->resize( self::CACHE_CHEAP_SIZE );
+
+ return $status;
+ }
+
+ final protected function doQuickOperationsInternal( array $ops ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $status = $this->newStatus();
+
+ // Fix up custom header name/value pairs...
+ $ops = array_map( [ $this, 'sanitizeOpHeaders' ], $ops );
+
+ // Clear any file cache entries
+ $this->clearCache();
+
+ $supportedOps = [ 'create', 'store', 'copy', 'move', 'delete', 'describe', 'null' ];
+ // Parallel ops may be disabled in config due to dependencies (e.g. needing popen())
+ $async = ( $this->parallelize === 'implicit' && count( $ops ) > 1 );
+ $maxConcurrency = $this->concurrency; // throttle
+ /** @var StatusValue[] $statuses */
+ $statuses = []; // array of (index => StatusValue)
+ $fileOpHandles = []; // list of (index => handle) arrays
+ $curFileOpHandles = []; // current handle batch
+ // Perform the sync-only ops and build up op handles for the async ops...
+ foreach ( $ops as $index => $params ) {
+ if ( !in_array( $params['op'], $supportedOps ) ) {
+ throw new FileBackendError( "Operation '{$params['op']}' is not supported." );
+ }
+ $method = $params['op'] . 'Internal'; // e.g. "storeInternal"
+ $subStatus = $this->$method( [ 'async' => $async ] + $params );
+ if ( $subStatus->value instanceof FileBackendStoreOpHandle ) { // async
+ if ( count( $curFileOpHandles ) >= $maxConcurrency ) {
+ $fileOpHandles[] = $curFileOpHandles; // push this batch
+ $curFileOpHandles = [];
+ }
+ $curFileOpHandles[$index] = $subStatus->value; // keep index
+ } else { // error or completed
+ $statuses[$index] = $subStatus; // keep index
+ }
+ }
+ if ( count( $curFileOpHandles ) ) {
+ $fileOpHandles[] = $curFileOpHandles; // last batch
+ }
+ // Do all the async ops that can be done concurrently...
+ foreach ( $fileOpHandles as $fileHandleBatch ) {
+ $statuses = $statuses + $this->executeOpHandlesInternal( $fileHandleBatch );
+ }
+ // Marshall and merge all the responses...
+ foreach ( $statuses as $index => $subStatus ) {
+ $status->merge( $subStatus );
+ if ( $subStatus->isOK() ) {
+ $status->success[$index] = true;
+ ++$status->successCount;
+ } else {
+ $status->success[$index] = false;
+ ++$status->failCount;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Execute a list of FileBackendStoreOpHandle handles in parallel.
+ * The resulting StatusValue object fields will correspond
+ * to the order in which the handles where given.
+ *
+ * @param FileBackendStoreOpHandle[] $fileOpHandles
+ * @return StatusValue[] Map of StatusValue objects
+ * @throws FileBackendError
+ */
+ final public function executeOpHandlesInternal( array $fileOpHandles ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ foreach ( $fileOpHandles as $fileOpHandle ) {
+ if ( !( $fileOpHandle instanceof FileBackendStoreOpHandle ) ) {
+ throw new InvalidArgumentException( "Expected FileBackendStoreOpHandle object." );
+ } elseif ( $fileOpHandle->backend->getName() !== $this->getName() ) {
+ throw new InvalidArgumentException( "Expected handle for this file backend." );
+ }
+ }
+
+ $res = $this->doExecuteOpHandlesInternal( $fileOpHandles );
+ foreach ( $fileOpHandles as $fileOpHandle ) {
+ $fileOpHandle->closeResources();
+ }
+
+ return $res;
+ }
+
+ /**
+ * @see FileBackendStore::executeOpHandlesInternal()
+ *
+ * @param FileBackendStoreOpHandle[] $fileOpHandles
+ *
+ * @throws FileBackendError
+ * @return StatusValue[] List of corresponding StatusValue objects
+ */
+ protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
+ if ( count( $fileOpHandles ) ) {
+ throw new LogicException( "Backend does not support asynchronous operations." );
+ }
+
+ return [];
+ }
+
+ /**
+ * Normalize and filter HTTP headers from a file operation
+ *
+ * This normalizes and strips long HTTP headers from a file operation.
+ * Most headers are just numbers, but some are allowed to be long.
+ * This function is useful for cleaning up headers and avoiding backend
+ * specific errors, especially in the middle of batch file operations.
+ *
+ * @param array $op Same format as doOperation()
+ * @return array
+ */
+ protected function sanitizeOpHeaders( array $op ) {
+ static $longs = [ 'content-disposition' ];
+
+ if ( isset( $op['headers'] ) ) { // op sets HTTP headers
+ $newHeaders = [];
+ foreach ( $op['headers'] as $name => $value ) {
+ $name = strtolower( $name );
+ $maxHVLen = in_array( $name, $longs ) ? INF : 255;
+ if ( strlen( $name ) > 255 || strlen( $value ) > $maxHVLen ) {
+ trigger_error( "Header '$name: $value' is too long." );
+ } else {
+ $newHeaders[$name] = strlen( $value ) ? $value : ''; // null/false => ""
+ }
+ }
+ $op['headers'] = $newHeaders;
+ }
+
+ return $op;
+ }
+
+ final public function preloadCache( array $paths ) {
+ $fullConts = []; // full container names
+ foreach ( $paths as $path ) {
+ list( $fullCont, , ) = $this->resolveStoragePath( $path );
+ $fullConts[] = $fullCont;
+ }
+ // Load from the persistent file and container caches
+ $this->primeContainerCache( $fullConts );
+ $this->primeFileCache( $paths );
+ }
+
+ final public function clearCache( array $paths = null ) {
+ if ( is_array( $paths ) ) {
+ $paths = array_map( 'FileBackend::normalizeStoragePath', $paths );
+ $paths = array_filter( $paths, 'strlen' ); // remove nulls
+ }
+ if ( $paths === null ) {
+ $this->cheapCache->clear();
+ $this->expensiveCache->clear();
+ } else {
+ foreach ( $paths as $path ) {
+ $this->cheapCache->clear( $path );
+ $this->expensiveCache->clear( $path );
+ }
+ }
+ $this->doClearCache( $paths );
+ }
+
+ /**
+ * Clears any additional stat caches for storage paths
+ *
+ * @see FileBackend::clearCache()
+ *
+ * @param array $paths Storage paths (optional)
+ */
+ protected function doClearCache( array $paths = null ) {
+ }
+
+ final public function preloadFileStat( array $params ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $success = true; // no network errors
+
+ $params['concurrency'] = ( $this->parallelize !== 'off' ) ? $this->concurrency : 1;
+ $stats = $this->doGetFileStatMulti( $params );
+ if ( $stats === null ) {
+ return true; // not supported
+ }
+
+ $latest = !empty( $params['latest'] ); // use latest data?
+ foreach ( $stats as $path => $stat ) {
+ $path = FileBackend::normalizeStoragePath( $path );
+ if ( $path === null ) {
+ continue; // this shouldn't happen
+ }
+ if ( is_array( $stat ) ) { // file exists
+ // Strongly consistent backends can automatically set "latest"
+ $stat['latest'] = isset( $stat['latest'] ) ? $stat['latest'] : $latest;
+ $this->cheapCache->set( $path, 'stat', $stat );
+ $this->setFileCache( $path, $stat ); // update persistent cache
+ if ( isset( $stat['sha1'] ) ) { // some backends store SHA-1 as metadata
+ $this->cheapCache->set( $path, 'sha1',
+ [ 'hash' => $stat['sha1'], 'latest' => $latest ] );
+ }
+ if ( isset( $stat['xattr'] ) ) { // some backends store headers/metadata
+ $stat['xattr'] = self::normalizeXAttributes( $stat['xattr'] );
+ $this->cheapCache->set( $path, 'xattr',
+ [ 'map' => $stat['xattr'], 'latest' => $latest ] );
+ }
+ } elseif ( $stat === false ) { // file does not exist
+ $this->cheapCache->set( $path, 'stat',
+ $latest ? 'NOT_EXIST_LATEST' : 'NOT_EXIST' );
+ $this->cheapCache->set( $path, 'xattr',
+ [ 'map' => false, 'latest' => $latest ] );
+ $this->cheapCache->set( $path, 'sha1',
+ [ 'hash' => false, 'latest' => $latest ] );
+ $this->logger->debug( __METHOD__ . ": File $path does not exist.\n" );
+ } else { // an error occurred
+ $success = false;
+ $this->logger->warning( __METHOD__ . ": Could not stat file $path.\n" );
+ }
+ }
+
+ return $success;
+ }
+
+ /**
+ * Get file stat information (concurrently if possible) for several files
+ *
+ * @see FileBackend::getFileStat()
+ *
+ * @param array $params Parameters include:
+ * - srcs : list of source storage paths
+ * - latest : use the latest available data
+ * @return array|null Map of storage paths to array|bool|null (returns null if not supported)
+ * @since 1.23
+ */
+ protected function doGetFileStatMulti( array $params ) {
+ return null; // not supported
+ }
+
+ /**
+ * Is this a key/value store where directories are just virtual?
+ * Virtual directories exists in so much as files exists that are
+ * prefixed with the directory path followed by a forward slash.
+ *
+ * @return bool
+ */
+ abstract protected function directoriesAreVirtual();
+
+ /**
+ * Check if a short container name is valid
+ *
+ * This checks for length and illegal characters.
+ * This may disallow certain characters that can appear
+ * in the prefix used to make the full container name.
+ *
+ * @param string $container
+ * @return bool
+ */
+ final protected static function isValidShortContainerName( $container ) {
+ // Suffixes like '.xxx' (hex shard chars) or '.seg' (file segments)
+ // might be used by subclasses. Reserve the dot character for sanity.
+ // The only way dots end up in containers (e.g. resolveStoragePath)
+ // is due to the wikiId container prefix or the above suffixes.
+ return self::isValidContainerName( $container ) && !preg_match( '/[.]/', $container );
+ }
+
+ /**
+ * Check if a full container name is valid
+ *
+ * This checks for length and illegal characters.
+ * Limiting the characters makes migrations to other stores easier.
+ *
+ * @param string $container
+ * @return bool
+ */
+ final protected static function isValidContainerName( $container ) {
+ // This accounts for NTFS, Swift, and Ceph restrictions
+ // and disallows directory separators or traversal characters.
+ // Note that matching strings URL encode to the same string;
+ // in Swift/Ceph, the length restriction is *after* URL encoding.
+ return (bool)preg_match( '/^[a-z0-9][a-z0-9-_.]{0,199}$/i', $container );
+ }
+
+ /**
+ * Splits a storage path into an internal container name,
+ * an internal relative file name, and a container shard suffix.
+ * Any shard suffix is already appended to the internal container name.
+ * This also checks that the storage path is valid and within this backend.
+ *
+ * If the container is sharded but a suffix could not be determined,
+ * this means that the path can only refer to a directory and can only
+ * be scanned by looking in all the container shards.
+ *
+ * @param string $storagePath
+ * @return array (container, path, container suffix) or (null, null, null) if invalid
+ */
+ final protected function resolveStoragePath( $storagePath ) {
+ list( $backend, $shortCont, $relPath ) = self::splitStoragePath( $storagePath );
+ if ( $backend === $this->name ) { // must be for this backend
+ $relPath = self::normalizeContainerPath( $relPath );
+ if ( $relPath !== null && self::isValidShortContainerName( $shortCont ) ) {
+ // Get shard for the normalized path if this container is sharded
+ $cShard = $this->getContainerShard( $shortCont, $relPath );
+ // Validate and sanitize the relative path (backend-specific)
+ $relPath = $this->resolveContainerPath( $shortCont, $relPath );
+ if ( $relPath !== null ) {
+ // Prepend any wiki ID prefix to the container name
+ $container = $this->fullContainerName( $shortCont );
+ if ( self::isValidContainerName( $container ) ) {
+ // Validate and sanitize the container name (backend-specific)
+ $container = $this->resolveContainerName( "{$container}{$cShard}" );
+ if ( $container !== null ) {
+ return [ $container, $relPath, $cShard ];
+ }
+ }
+ }
+ }
+ }
+
+ return [ null, null, null ];
+ }
+
+ /**
+ * Like resolveStoragePath() except null values are returned if
+ * the container is sharded and the shard could not be determined
+ * or if the path ends with '/'. The latter case is illegal for FS
+ * backends and can confuse listings for object store backends.
+ *
+ * This function is used when resolving paths that must be valid
+ * locations for files. Directory and listing functions should
+ * generally just use resolveStoragePath() instead.
+ *
+ * @see FileBackendStore::resolveStoragePath()
+ *
+ * @param string $storagePath
+ * @return array (container, path) or (null, null) if invalid
+ */
+ final protected function resolveStoragePathReal( $storagePath ) {
+ list( $container, $relPath, $cShard ) = $this->resolveStoragePath( $storagePath );
+ if ( $cShard !== null && substr( $relPath, -1 ) !== '/' ) {
+ return [ $container, $relPath ];
+ }
+
+ return [ null, null ];
+ }
+
+ /**
+ * Get the container name shard suffix for a given path.
+ * Any empty suffix means the container is not sharded.
+ *
+ * @param string $container Container name
+ * @param string $relPath Storage path relative to the container
+ * @return string|null Returns null if shard could not be determined
+ */
+ final protected function getContainerShard( $container, $relPath ) {
+ list( $levels, $base, $repeat ) = $this->getContainerHashLevels( $container );
+ if ( $levels == 1 || $levels == 2 ) {
+ // Hash characters are either base 16 or 36
+ $char = ( $base == 36 ) ? '[0-9a-z]' : '[0-9a-f]';
+ // Get a regex that represents the shard portion of paths.
+ // The concatenation of the captures gives us the shard.
+ if ( $levels === 1 ) { // 16 or 36 shards per container
+ $hashDirRegex = '(' . $char . ')';
+ } else { // 256 or 1296 shards per container
+ if ( $repeat ) { // verbose hash dir format (e.g. "a/ab/abc")
+ $hashDirRegex = $char . '/(' . $char . '{2})';
+ } else { // short hash dir format (e.g. "a/b/c")
+ $hashDirRegex = '(' . $char . ')/(' . $char . ')';
+ }
+ }
+ // Allow certain directories to be above the hash dirs so as
+ // to work with FileRepo (e.g. "archive/a/ab" or "temp/a/ab").
+ // They must be 2+ chars to avoid any hash directory ambiguity.
+ $m = [];
+ if ( preg_match( "!^(?:[^/]{2,}/)*$hashDirRegex(?:/|$)!", $relPath, $m ) ) {
+ return '.' . implode( '', array_slice( $m, 1 ) );
+ }
+
+ return null; // failed to match
+ }
+
+ return ''; // no sharding
+ }
+
+ /**
+ * Check if a storage path maps to a single shard.
+ * Container dirs like "a", where the container shards on "x/xy",
+ * can reside on several shards. Such paths are tricky to handle.
+ *
+ * @param string $storagePath Storage path
+ * @return bool
+ */
+ final public function isSingleShardPathInternal( $storagePath ) {
+ list( , , $shard ) = $this->resolveStoragePath( $storagePath );
+
+ return ( $shard !== null );
+ }
+
+ /**
+ * Get the sharding config for a container.
+ * If greater than 0, then all file storage paths within
+ * the container are required to be hashed accordingly.
+ *
+ * @param string $container
+ * @return array (integer levels, integer base, repeat flag) or (0, 0, false)
+ */
+ final protected function getContainerHashLevels( $container ) {
+ if ( isset( $this->shardViaHashLevels[$container] ) ) {
+ $config = $this->shardViaHashLevels[$container];
+ $hashLevels = (int)$config['levels'];
+ if ( $hashLevels == 1 || $hashLevels == 2 ) {
+ $hashBase = (int)$config['base'];
+ if ( $hashBase == 16 || $hashBase == 36 ) {
+ return [ $hashLevels, $hashBase, $config['repeat'] ];
+ }
+ }
+ }
+
+ return [ 0, 0, false ]; // no sharding
+ }
+
+ /**
+ * Get a list of full container shard suffixes for a container
+ *
+ * @param string $container
+ * @return array
+ */
+ final protected function getContainerSuffixes( $container ) {
+ $shards = [];
+ list( $digits, $base ) = $this->getContainerHashLevels( $container );
+ if ( $digits > 0 ) {
+ $numShards = pow( $base, $digits );
+ for ( $index = 0; $index < $numShards; $index++ ) {
+ $shards[] = '.' . Wikimedia\base_convert( $index, 10, $base, $digits );
+ }
+ }
+
+ return $shards;
+ }
+
+ /**
+ * Get the full container name, including the wiki ID prefix
+ *
+ * @param string $container
+ * @return string
+ */
+ final protected function fullContainerName( $container ) {
+ if ( $this->domainId != '' ) {
+ return "{$this->domainId}-$container";
+ } else {
+ return $container;
+ }
+ }
+
+ /**
+ * Resolve a container name, checking if it's allowed by the backend.
+ * This is intended for internal use, such as encoding illegal chars.
+ * Subclasses can override this to be more restrictive.
+ *
+ * @param string $container
+ * @return string|null
+ */
+ protected function resolveContainerName( $container ) {
+ return $container;
+ }
+
+ /**
+ * Resolve a relative storage path, checking if it's allowed by the backend.
+ * This is intended for internal use, such as encoding illegal chars or perhaps
+ * getting absolute paths (e.g. FS based backends). Note that the relative path
+ * may be the empty string (e.g. the path is simply to the container).
+ *
+ * @param string $container Container name
+ * @param string $relStoragePath Storage path relative to the container
+ * @return string|null Path or null if not valid
+ */
+ protected function resolveContainerPath( $container, $relStoragePath ) {
+ return $relStoragePath;
+ }
+
+ /**
+ * Get the cache key for a container
+ *
+ * @param string $container Resolved container name
+ * @return string
+ */
+ private function containerCacheKey( $container ) {
+ return "filebackend:{$this->name}:{$this->domainId}:container:{$container}";
+ }
+
+ /**
+ * Set the cached info for a container
+ *
+ * @param string $container Resolved container name
+ * @param array $val Information to cache
+ */
+ final protected function setContainerCache( $container, array $val ) {
+ $this->memCache->set( $this->containerCacheKey( $container ), $val, 14 * 86400 );
+ }
+
+ /**
+ * Delete the cached info for a container.
+ * The cache key is salted for a while to prevent race conditions.
+ *
+ * @param string $container Resolved container name
+ */
+ final protected function deleteContainerCache( $container ) {
+ if ( !$this->memCache->delete( $this->containerCacheKey( $container ), 300 ) ) {
+ trigger_error( "Unable to delete stat cache for container $container." );
+ }
+ }
+
+ /**
+ * Do a batch lookup from cache for container stats for all containers
+ * used in a list of container names or storage paths objects.
+ * This loads the persistent cache values into the process cache.
+ *
+ * @param array $items
+ */
+ final protected function primeContainerCache( array $items ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ $paths = []; // list of storage paths
+ $contNames = []; // (cache key => resolved container name)
+ // Get all the paths/containers from the items...
+ foreach ( $items as $item ) {
+ if ( self::isStoragePath( $item ) ) {
+ $paths[] = $item;
+ } elseif ( is_string( $item ) ) { // full container name
+ $contNames[$this->containerCacheKey( $item )] = $item;
+ }
+ }
+ // Get all the corresponding cache keys for paths...
+ foreach ( $paths as $path ) {
+ list( $fullCont, , ) = $this->resolveStoragePath( $path );
+ if ( $fullCont !== null ) { // valid path for this backend
+ $contNames[$this->containerCacheKey( $fullCont )] = $fullCont;
+ }
+ }
+
+ $contInfo = []; // (resolved container name => cache value)
+ // Get all cache entries for these container cache keys...
+ $values = $this->memCache->getMulti( array_keys( $contNames ) );
+ foreach ( $values as $cacheKey => $val ) {
+ $contInfo[$contNames[$cacheKey]] = $val;
+ }
+
+ // Populate the container process cache for the backend...
+ $this->doPrimeContainerCache( array_filter( $contInfo, 'is_array' ) );
+ }
+
+ /**
+ * Fill the backend-specific process cache given an array of
+ * resolved container names and their corresponding cached info.
+ * Only containers that actually exist should appear in the map.
+ *
+ * @param array $containerInfo Map of resolved container names to cached info
+ */
+ protected function doPrimeContainerCache( array $containerInfo ) {
+ }
+
+ /**
+ * Get the cache key for a file path
+ *
+ * @param string $path Normalized storage path
+ * @return string
+ */
+ private function fileCacheKey( $path ) {
+ return "filebackend:{$this->name}:{$this->domainId}:file:" . sha1( $path );
+ }
+
+ /**
+ * Set the cached stat info for a file path.
+ * Negatives (404s) are not cached. By not caching negatives, we can skip cache
+ * salting for the case when a file is created at a path were there was none before.
+ *
+ * @param string $path Storage path
+ * @param array $val Stat information to cache
+ */
+ final protected function setFileCache( $path, array $val ) {
+ $path = FileBackend::normalizeStoragePath( $path );
+ if ( $path === null ) {
+ return; // invalid storage path
+ }
+ $mtime = ConvertibleTimestamp::convert( TS_UNIX, $val['mtime'] );
+ $ttl = $this->memCache->adaptiveTTL( $mtime, 7 * 86400, 300, 0.1 );
+ $key = $this->fileCacheKey( $path );
+ // Set the cache unless it is currently salted.
+ $this->memCache->set( $key, $val, $ttl );
+ }
+
+ /**
+ * Delete the cached stat info for a file path.
+ * The cache key is salted for a while to prevent race conditions.
+ * Since negatives (404s) are not cached, this does not need to be called when
+ * a file is created at a path were there was none before.
+ *
+ * @param string $path Storage path
+ */
+ final protected function deleteFileCache( $path ) {
+ $path = FileBackend::normalizeStoragePath( $path );
+ if ( $path === null ) {
+ return; // invalid storage path
+ }
+ if ( !$this->memCache->delete( $this->fileCacheKey( $path ), 300 ) ) {
+ trigger_error( "Unable to delete stat cache for file $path." );
+ }
+ }
+
+ /**
+ * Do a batch lookup from cache for file stats for all paths
+ * used in a list of storage paths or FileOp objects.
+ * This loads the persistent cache values into the process cache.
+ *
+ * @param array $items List of storage paths
+ */
+ final protected function primeFileCache( array $items ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ $paths = []; // list of storage paths
+ $pathNames = []; // (cache key => storage path)
+ // Get all the paths/containers from the items...
+ foreach ( $items as $item ) {
+ if ( self::isStoragePath( $item ) ) {
+ $paths[] = FileBackend::normalizeStoragePath( $item );
+ }
+ }
+ // Get rid of any paths that failed normalization...
+ $paths = array_filter( $paths, 'strlen' ); // remove nulls
+ // Get all the corresponding cache keys for paths...
+ foreach ( $paths as $path ) {
+ list( , $rel, ) = $this->resolveStoragePath( $path );
+ if ( $rel !== null ) { // valid path for this backend
+ $pathNames[$this->fileCacheKey( $path )] = $path;
+ }
+ }
+ // Get all cache entries for these file cache keys...
+ $values = $this->memCache->getMulti( array_keys( $pathNames ) );
+ foreach ( $values as $cacheKey => $val ) {
+ $path = $pathNames[$cacheKey];
+ if ( is_array( $val ) ) {
+ $val['latest'] = false; // never completely trust cache
+ $this->cheapCache->set( $path, 'stat', $val );
+ if ( isset( $val['sha1'] ) ) { // some backends store SHA-1 as metadata
+ $this->cheapCache->set( $path, 'sha1',
+ [ 'hash' => $val['sha1'], 'latest' => false ] );
+ }
+ if ( isset( $val['xattr'] ) ) { // some backends store headers/metadata
+ $val['xattr'] = self::normalizeXAttributes( $val['xattr'] );
+ $this->cheapCache->set( $path, 'xattr',
+ [ 'map' => $val['xattr'], 'latest' => false ] );
+ }
+ }
+ }
+ }
+
+ /**
+ * Normalize file headers/metadata to the FileBackend::getFileXAttributes() format
+ *
+ * @param array $xattr
+ * @return array
+ * @since 1.22
+ */
+ final protected static function normalizeXAttributes( array $xattr ) {
+ $newXAttr = [ 'headers' => [], 'metadata' => [] ];
+
+ foreach ( $xattr['headers'] as $name => $value ) {
+ $newXAttr['headers'][strtolower( $name )] = $value;
+ }
+
+ foreach ( $xattr['metadata'] as $name => $value ) {
+ $newXAttr['metadata'][strtolower( $name )] = $value;
+ }
+
+ return $newXAttr;
+ }
+
+ /**
+ * Set the 'concurrency' option from a list of operation options
+ *
+ * @param array $opts Map of operation options
+ * @return array
+ */
+ final protected function setConcurrencyFlags( array $opts ) {
+ $opts['concurrency'] = 1; // off
+ if ( $this->parallelize === 'implicit' ) {
+ if ( !isset( $opts['parallelize'] ) || $opts['parallelize'] ) {
+ $opts['concurrency'] = $this->concurrency;
+ }
+ } elseif ( $this->parallelize === 'explicit' ) {
+ if ( !empty( $opts['parallelize'] ) ) {
+ $opts['concurrency'] = $this->concurrency;
+ }
+ }
+
+ return $opts;
+ }
+
+ /**
+ * Get the content type to use in HEAD/GET requests for a file
+ *
+ * @param string $storagePath
+ * @param string|null $content File data
+ * @param string|null $fsPath File system path
+ * @return string MIME type
+ */
+ protected function getContentType( $storagePath, $content, $fsPath ) {
+ if ( $this->mimeCallback ) {
+ return call_user_func_array( $this->mimeCallback, func_get_args() );
+ }
+
+ $mime = ( $fsPath !== null ) ? mime_content_type( $fsPath ) : false;
+ return $mime ?: 'unknown/unknown';
+ }
+}
+
+/**
+ * FileBackendStore helper class for performing asynchronous file operations.
+ *
+ * For example, calling FileBackendStore::createInternal() with the "async"
+ * param flag may result in a StatusValue that contains this object as a value.
+ * This class is largely backend-specific and is mostly just "magic" to be
+ * passed to FileBackendStore::executeOpHandlesInternal().
+ */
+abstract class FileBackendStoreOpHandle {
+ /** @var array */
+ public $params = []; // params to caller functions
+ /** @var FileBackendStore */
+ public $backend;
+ /** @var array */
+ public $resourcesToClose = [];
+
+ public $call; // string; name that identifies the function called
+
+ /**
+ * Close all open file handles
+ */
+ public function closeResources() {
+ array_map( 'fclose', $this->resourcesToClose );
+ }
+}
+
+/**
+ * FileBackendStore helper function to handle listings that span container shards.
+ * Do not use this class from places outside of FileBackendStore.
+ *
+ * @ingroup FileBackend
+ */
+abstract class FileBackendStoreShardListIterator extends FilterIterator {
+ /** @var FileBackendStore */
+ protected $backend;
+
+ /** @var array */
+ protected $params;
+
+ /** @var string Full container name */
+ protected $container;
+
+ /** @var string Resolved relative path */
+ protected $directory;
+
+ /** @var array */
+ protected $multiShardPaths = []; // (rel path => 1)
+
+ /**
+ * @param FileBackendStore $backend
+ * @param string $container Full storage container name
+ * @param string $dir Storage directory relative to container
+ * @param array $suffixes List of container shard suffixes
+ * @param array $params
+ */
+ public function __construct(
+ FileBackendStore $backend, $container, $dir, array $suffixes, array $params
+ ) {
+ $this->backend = $backend;
+ $this->container = $container;
+ $this->directory = $dir;
+ $this->params = $params;
+
+ $iter = new AppendIterator();
+ foreach ( $suffixes as $suffix ) {
+ $iter->append( $this->listFromShard( $this->container . $suffix ) );
+ }
+
+ parent::__construct( $iter );
+ }
+
+ public function accept() {
+ $rel = $this->getInnerIterator()->current(); // path relative to given directory
+ $path = $this->params['dir'] . "/{$rel}"; // full storage path
+ if ( $this->backend->isSingleShardPathInternal( $path ) ) {
+ return true; // path is only on one shard; no issue with duplicates
+ } elseif ( isset( $this->multiShardPaths[$rel] ) ) {
+ // Don't keep listing paths that are on multiple shards
+ return false;
+ } else {
+ $this->multiShardPaths[$rel] = 1;
+
+ return true;
+ }
+ }
+
+ public function rewind() {
+ parent::rewind();
+ $this->multiShardPaths = [];
+ }
+
+ /**
+ * Get the list for a given container shard
+ *
+ * @param string $container Resolved container name
+ * @return Iterator
+ */
+ abstract protected function listFromShard( $container );
+}
+
+/**
+ * Iterator for listing directories
+ */
+class FileBackendStoreShardDirIterator extends FileBackendStoreShardListIterator {
+ protected function listFromShard( $container ) {
+ $list = $this->backend->getDirectoryListInternal(
+ $container, $this->directory, $this->params );
+ if ( $list === null ) {
+ return new ArrayIterator( [] );
+ } else {
+ return is_array( $list ) ? new ArrayIterator( $list ) : $list;
+ }
+ }
+}
+
+/**
+ * Iterator for listing regular files
+ */
+class FileBackendStoreShardFileIterator extends FileBackendStoreShardListIterator {
+ protected function listFromShard( $container ) {
+ $list = $this->backend->getFileListInternal(
+ $container, $this->directory, $this->params );
+ if ( $list === null ) {
+ return new ArrayIterator( [] );
+ } else {
+ return is_array( $list ) ? new ArrayIterator( $list ) : $list;
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/FileOpBatch.php b/www/wiki/includes/libs/filebackend/FileOpBatch.php
new file mode 100644
index 00000000..2324098d
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/FileOpBatch.php
@@ -0,0 +1,204 @@
+<?php
+/**
+ * Helper class for representing batch file operations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Helper class for representing batch file operations.
+ * Do not use this class from places outside FileBackend.
+ *
+ * Methods should avoid throwing exceptions at all costs.
+ *
+ * @ingroup FileBackend
+ * @since 1.20
+ */
+class FileOpBatch {
+ /* Timeout related parameters */
+ const MAX_BATCH_SIZE = 1000; // integer
+
+ /**
+ * Attempt to perform a series of file operations.
+ * Callers are responsible for handling file locking.
+ *
+ * $opts is an array of options, including:
+ * - force : Errors that would normally cause a rollback do not.
+ * The remaining operations are still attempted if any fail.
+ * - nonJournaled : Don't log this operation batch in the file journal.
+ * - concurrency : Try to do this many operations in parallel when possible.
+ *
+ * The resulting StatusValue will be "OK" unless:
+ * - a) unexpected operation errors occurred (network partitions, disk full...)
+ * - b) significant operation errors occurred and 'force' was not set
+ *
+ * @param FileOp[] $performOps List of FileOp operations
+ * @param array $opts Batch operation options
+ * @param FileJournal $journal Journal to log operations to
+ * @return StatusValue
+ */
+ public static function attempt( array $performOps, array $opts, FileJournal $journal ) {
+ $status = StatusValue::newGood();
+
+ $n = count( $performOps );
+ if ( $n > self::MAX_BATCH_SIZE ) {
+ $status->fatal( 'backend-fail-batchsize', $n, self::MAX_BATCH_SIZE );
+
+ return $status;
+ }
+
+ $batchId = $journal->getTimestampedUUID();
+ $ignoreErrors = !empty( $opts['force'] );
+ $journaled = empty( $opts['nonJournaled'] );
+ $maxConcurrency = isset( $opts['concurrency'] ) ? $opts['concurrency'] : 1;
+
+ $entries = []; // file journal entry list
+ $predicates = FileOp::newPredicates(); // account for previous ops in prechecks
+ $curBatch = []; // concurrent FileOp sub-batch accumulation
+ $curBatchDeps = FileOp::newDependencies(); // paths used in FileOp sub-batch
+ $pPerformOps = []; // ordered list of concurrent FileOp sub-batches
+ $lastBackend = null; // last op backend name
+ // Do pre-checks for each operation; abort on failure...
+ foreach ( $performOps as $index => $fileOp ) {
+ $backendName = $fileOp->getBackend()->getName();
+ $fileOp->setBatchId( $batchId ); // transaction ID
+ // Decide if this op can be done concurrently within this sub-batch
+ // or if a new concurrent sub-batch must be started after this one...
+ if ( $fileOp->dependsOn( $curBatchDeps )
+ || count( $curBatch ) >= $maxConcurrency
+ || ( $backendName !== $lastBackend && count( $curBatch ) )
+ ) {
+ $pPerformOps[] = $curBatch; // push this batch
+ $curBatch = []; // start a new sub-batch
+ $curBatchDeps = FileOp::newDependencies();
+ }
+ $lastBackend = $backendName;
+ $curBatch[$index] = $fileOp; // keep index
+ // Update list of affected paths in this batch
+ $curBatchDeps = $fileOp->applyDependencies( $curBatchDeps );
+ // Simulate performing the operation...
+ $oldPredicates = $predicates;
+ $subStatus = $fileOp->precheck( $predicates ); // updates $predicates
+ $status->merge( $subStatus );
+ if ( $subStatus->isOK() ) {
+ if ( $journaled ) { // journal log entries
+ $entries = array_merge( $entries,
+ $fileOp->getJournalEntries( $oldPredicates, $predicates ) );
+ }
+ } else { // operation failed?
+ $status->success[$index] = false;
+ ++$status->failCount;
+ if ( !$ignoreErrors ) {
+ return $status; // abort
+ }
+ }
+ }
+ // Push the last sub-batch
+ if ( count( $curBatch ) ) {
+ $pPerformOps[] = $curBatch;
+ }
+
+ // Log the operations in the file journal...
+ if ( count( $entries ) ) {
+ $subStatus = $journal->logChangeBatch( $entries, $batchId );
+ if ( !$subStatus->isOK() ) {
+ $status->merge( $subStatus );
+
+ return $status; // abort
+ }
+ }
+
+ if ( $ignoreErrors ) { // treat precheck() fatals as mere warnings
+ $status->setResult( true, $status->value );
+ }
+
+ // Attempt each operation (in parallel if allowed and possible)...
+ self::runParallelBatches( $pPerformOps, $status );
+
+ return $status;
+ }
+
+ /**
+ * Attempt a list of file operations sub-batches in series.
+ *
+ * The operations *in* each sub-batch will be done in parallel.
+ * The caller is responsible for making sure the operations
+ * within any given sub-batch do not depend on each other.
+ * This will abort remaining ops on failure.
+ *
+ * @param array $pPerformOps Batches of file ops (batches use original indexes)
+ * @param StatusValue $status
+ */
+ protected static function runParallelBatches( array $pPerformOps, StatusValue $status ) {
+ $aborted = false; // set to true on unexpected errors
+ foreach ( $pPerformOps as $performOpsBatch ) {
+ /** @var FileOp[] $performOpsBatch */
+ if ( $aborted ) { // check batch op abort flag...
+ // We can't continue (even with $ignoreErrors) as $predicates is wrong.
+ // Log the remaining ops as failed for recovery...
+ foreach ( $performOpsBatch as $i => $fileOp ) {
+ $status->success[$i] = false;
+ ++$status->failCount;
+ $performOpsBatch[$i]->logFailure( 'attempt_aborted' );
+ }
+ continue;
+ }
+ /** @var StatusValue[] $statuses */
+ $statuses = [];
+ $opHandles = [];
+ // Get the backend; all sub-batch ops belong to a single backend
+ /** @var FileBackendStore $backend */
+ $backend = reset( $performOpsBatch )->getBackend();
+ // Get the operation handles or actually do it if there is just one.
+ // If attemptAsync() returns a StatusValue, it was either due to an error
+ // or the backend does not support async ops and did it synchronously.
+ foreach ( $performOpsBatch as $i => $fileOp ) {
+ if ( !isset( $status->success[$i] ) ) { // didn't already fail in precheck()
+ // Parallel ops may be disabled in config due to missing dependencies,
+ // (e.g. needing popen()). When they are, $performOpsBatch has size 1.
+ $subStatus = ( count( $performOpsBatch ) > 1 )
+ ? $fileOp->attemptAsync()
+ : $fileOp->attempt();
+ if ( $subStatus->value instanceof FileBackendStoreOpHandle ) {
+ $opHandles[$i] = $subStatus->value; // deferred
+ } else {
+ $statuses[$i] = $subStatus; // done already
+ }
+ }
+ }
+ // Try to do all the operations concurrently...
+ $statuses = $statuses + $backend->executeOpHandlesInternal( $opHandles );
+ // Marshall and merge all the responses (blocking)...
+ foreach ( $performOpsBatch as $i => $fileOp ) {
+ if ( !isset( $status->success[$i] ) ) { // didn't already fail in precheck()
+ $subStatus = $statuses[$i];
+ $status->merge( $subStatus );
+ if ( $subStatus->isOK() ) {
+ $status->success[$i] = true;
+ ++$status->successCount;
+ } else {
+ $status->success[$i] = false;
+ ++$status->failCount;
+ $aborted = true; // set abort flag; we can't continue
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/HTTPFileStreamer.php b/www/wiki/includes/libs/filebackend/HTTPFileStreamer.php
new file mode 100644
index 00000000..9730acb8
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/HTTPFileStreamer.php
@@ -0,0 +1,269 @@
+<?php
+/**
+ * Functions related to the output of file content.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+
+/**
+ * Functions related to the output of file content
+ *
+ * @since 1.28
+ */
+class HTTPFileStreamer {
+ /** @var string */
+ protected $path;
+ /** @var callable */
+ protected $obResetFunc;
+ /** @var callable */
+ protected $streamMimeFunc;
+
+ // Do not send any HTTP headers unless requested by caller (e.g. body only)
+ const STREAM_HEADLESS = 1;
+ // Do not try to tear down any PHP output buffers
+ const STREAM_ALLOW_OB = 2;
+
+ /**
+ * @param string $path Local filesystem path to a file
+ * @param array $params Options map, which includes:
+ * - obResetFunc : alternative callback to clear the output buffer
+ * - streamMimeFunc : alternative method to determine the content type from the path
+ */
+ public function __construct( $path, array $params = [] ) {
+ $this->path = $path;
+ $this->obResetFunc = isset( $params['obResetFunc'] )
+ ? $params['obResetFunc']
+ : [ __CLASS__, 'resetOutputBuffers' ];
+ $this->streamMimeFunc = isset( $params['streamMimeFunc'] )
+ ? $params['streamMimeFunc']
+ : [ __CLASS__, 'contentTypeFromPath' ];
+ }
+
+ /**
+ * Stream a file to the browser, adding all the headings and fun stuff.
+ * Headers sent include: Content-type, Content-Length, Last-Modified,
+ * and Content-Disposition.
+ *
+ * @param array $headers Any additional headers to send if the file exists
+ * @param bool $sendErrors Send error messages if errors occur (like 404)
+ * @param array $optHeaders HTTP request header map (e.g. "range") (use lowercase keys)
+ * @param int $flags Bitfield of STREAM_* constants
+ * @throws MWException
+ * @return bool Success
+ */
+ public function stream(
+ $headers = [], $sendErrors = true, $optHeaders = [], $flags = 0
+ ) {
+ // Don't stream it out as text/html if there was a PHP error
+ if ( ( ( $flags & self::STREAM_HEADLESS ) == 0 || $headers ) && headers_sent() ) {
+ echo "Headers already sent, terminating.\n";
+ return false;
+ }
+
+ $headerFunc = ( $flags & self::STREAM_HEADLESS )
+ ? function ( $header ) {
+ // no-op
+ }
+ : function ( $header ) {
+ is_int( $header ) ? HttpStatus::header( $header ) : header( $header );
+ };
+
+ MediaWiki\suppressWarnings();
+ $info = stat( $this->path );
+ MediaWiki\restoreWarnings();
+
+ if ( !is_array( $info ) ) {
+ if ( $sendErrors ) {
+ self::send404Message( $this->path, $flags );
+ }
+ return false;
+ }
+
+ // Send Last-Modified HTTP header for client-side caching
+ $mtimeCT = new ConvertibleTimestamp( $info['mtime'] );
+ $headerFunc( 'Last-Modified: ' . $mtimeCT->getTimestamp( TS_RFC2822 ) );
+
+ if ( ( $flags & self::STREAM_ALLOW_OB ) == 0 ) {
+ call_user_func( $this->obResetFunc );
+ }
+
+ $type = call_user_func( $this->streamMimeFunc, $this->path );
+ if ( $type && $type != 'unknown/unknown' ) {
+ $headerFunc( "Content-type: $type" );
+ } else {
+ // Send a content type which is not known to Internet Explorer, to
+ // avoid triggering IE's content type detection. Sending a standard
+ // unknown content type here essentially gives IE license to apply
+ // whatever content type it likes.
+ $headerFunc( 'Content-type: application/x-wiki' );
+ }
+
+ // Don't send if client has up to date cache
+ if ( isset( $optHeaders['if-modified-since'] ) ) {
+ $modsince = preg_replace( '/;.*$/', '', $optHeaders['if-modified-since'] );
+ if ( $mtimeCT->getTimestamp( TS_UNIX ) <= strtotime( $modsince ) ) {
+ ini_set( 'zlib.output_compression', 0 );
+ $headerFunc( 304 );
+ return true; // ok
+ }
+ }
+
+ // Send additional headers
+ foreach ( $headers as $header ) {
+ header( $header ); // always use header(); specifically requested
+ }
+
+ if ( isset( $optHeaders['range'] ) ) {
+ $range = self::parseRange( $optHeaders['range'], $info['size'] );
+ if ( is_array( $range ) ) {
+ $headerFunc( 206 );
+ $headerFunc( 'Content-Length: ' . $range[2] );
+ $headerFunc( "Content-Range: bytes {$range[0]}-{$range[1]}/{$info['size']}" );
+ } elseif ( $range === 'invalid' ) {
+ if ( $sendErrors ) {
+ $headerFunc( 416 );
+ $headerFunc( 'Cache-Control: no-cache' );
+ $headerFunc( 'Content-Type: text/html; charset=utf-8' );
+ $headerFunc( 'Content-Range: bytes */' . $info['size'] );
+ }
+ return false;
+ } else { // unsupported Range request (e.g. multiple ranges)
+ $range = null;
+ $headerFunc( 'Content-Length: ' . $info['size'] );
+ }
+ } else {
+ $range = null;
+ $headerFunc( 'Content-Length: ' . $info['size'] );
+ }
+
+ if ( is_array( $range ) ) {
+ $handle = fopen( $this->path, 'rb' );
+ if ( $handle ) {
+ $ok = true;
+ fseek( $handle, $range[0] );
+ $remaining = $range[2];
+ while ( $remaining > 0 && $ok ) {
+ $bytes = min( $remaining, 8 * 1024 );
+ $data = fread( $handle, $bytes );
+ $remaining -= $bytes;
+ $ok = ( $data !== false );
+ print $data;
+ }
+ } else {
+ return false;
+ }
+ } else {
+ return readfile( $this->path ) !== false; // faster
+ }
+
+ return true;
+ }
+
+ /**
+ * Send out a standard 404 message for a file
+ *
+ * @param string $fname Full name and path of the file to stream
+ * @param int $flags Bitfield of STREAM_* constants
+ * @since 1.24
+ */
+ public static function send404Message( $fname, $flags = 0 ) {
+ if ( ( $flags & self::STREAM_HEADLESS ) == 0 ) {
+ HttpStatus::header( 404 );
+ header( 'Cache-Control: no-cache' );
+ header( 'Content-Type: text/html; charset=utf-8' );
+ }
+ $encFile = htmlspecialchars( $fname );
+ $encScript = htmlspecialchars( $_SERVER['SCRIPT_NAME'] );
+ echo "<!DOCTYPE html><html><body>
+ <h1>File not found</h1>
+ <p>Although this PHP script ($encScript) exists, the file requested for output
+ ($encFile) does not.</p>
+ </body></html>
+ ";
+ }
+
+ /**
+ * Convert a Range header value to an absolute (start, end) range tuple
+ *
+ * @param string $range Range header value
+ * @param int $size File size
+ * @return array|string Returns error string on failure (start, end, length)
+ * @since 1.24
+ */
+ public static function parseRange( $range, $size ) {
+ $m = [];
+ if ( preg_match( '#^bytes=(\d*)-(\d*)$#', $range, $m ) ) {
+ list( , $start, $end ) = $m;
+ if ( $start === '' && $end === '' ) {
+ $absRange = [ 0, $size - 1 ];
+ } elseif ( $start === '' ) {
+ $absRange = [ $size - $end, $size - 1 ];
+ } elseif ( $end === '' ) {
+ $absRange = [ $start, $size - 1 ];
+ } else {
+ $absRange = [ $start, $end ];
+ }
+ if ( $absRange[0] >= 0 && $absRange[1] >= $absRange[0] ) {
+ if ( $absRange[0] < $size ) {
+ $absRange[1] = min( $absRange[1], $size - 1 ); // stop at EOF
+ $absRange[2] = $absRange[1] - $absRange[0] + 1;
+ return $absRange;
+ } elseif ( $absRange[0] == 0 && $size == 0 ) {
+ return 'unrecognized'; // the whole file should just be sent
+ }
+ }
+ return 'invalid';
+ }
+ return 'unrecognized';
+ }
+
+ protected static function resetOutputBuffers() {
+ while ( ob_get_status() ) {
+ if ( !ob_end_clean() ) {
+ // Could not remove output buffer handler; abort now
+ // to avoid getting in some kind of infinite loop.
+ break;
+ }
+ }
+ }
+
+ /**
+ * Determine the file type of a file based on the path
+ *
+ * @param string $filename Storage path or file system path
+ * @return null|string
+ */
+ protected static function contentTypeFromPath( $filename ) {
+ $ext = strrchr( $filename, '.' );
+ $ext = $ext === false ? '' : strtolower( substr( $ext, 1 ) );
+
+ switch ( $ext ) {
+ case 'gif':
+ return 'image/gif';
+ case 'png':
+ return 'image/png';
+ case 'jpg':
+ return 'image/jpeg';
+ case 'jpeg':
+ return 'image/jpeg';
+ }
+
+ return 'unknown/unknown';
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/MemoryFileBackend.php b/www/wiki/includes/libs/filebackend/MemoryFileBackend.php
new file mode 100644
index 00000000..0341a2af
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/MemoryFileBackend.php
@@ -0,0 +1,262 @@
+<?php
+/**
+ * Simulation of a backend storage in memory.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Simulation of a backend storage in memory.
+ *
+ * All data in the backend is automatically deleted at the end of PHP execution.
+ * Since the data stored here is volatile, this is only useful for staging or testing.
+ *
+ * @ingroup FileBackend
+ * @since 1.23
+ */
+class MemoryFileBackend extends FileBackendStore {
+ /** @var array Map of (file path => (data,mtime) */
+ protected $files = [];
+
+ public function getFeatures() {
+ return self::ATTR_UNICODE_PATHS;
+ }
+
+ public function isPathUsableInternal( $storagePath ) {
+ return true;
+ }
+
+ protected function doCreateInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $dst = $this->resolveHashKey( $params['dst'] );
+ if ( $dst === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ $this->files[$dst] = [
+ 'data' => $params['content'],
+ 'mtime' => wfTimestamp( TS_MW, time() )
+ ];
+
+ return $status;
+ }
+
+ protected function doStoreInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $dst = $this->resolveHashKey( $params['dst'] );
+ if ( $dst === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ MediaWiki\suppressWarnings();
+ $data = file_get_contents( $params['src'] );
+ MediaWiki\restoreWarnings();
+ if ( $data === false ) { // source doesn't exist?
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+
+ $this->files[$dst] = [
+ 'data' => $data,
+ 'mtime' => wfTimestamp( TS_MW, time() )
+ ];
+
+ return $status;
+ }
+
+ protected function doCopyInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $src = $this->resolveHashKey( $params['src'] );
+ if ( $src === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $dst = $this->resolveHashKey( $params['dst'] );
+ if ( $dst === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ if ( !isset( $this->files[$src] ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
+ }
+
+ return $status;
+ }
+
+ $this->files[$dst] = [
+ 'data' => $this->files[$src]['data'],
+ 'mtime' => wfTimestamp( TS_MW, time() )
+ ];
+
+ return $status;
+ }
+
+ protected function doDeleteInternal( array $params ) {
+ $status = $this->newStatus();
+
+ $src = $this->resolveHashKey( $params['src'] );
+ if ( $src === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ if ( !isset( $this->files[$src] ) ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+ }
+
+ return $status;
+ }
+
+ unset( $this->files[$src] );
+
+ return $status;
+ }
+
+ protected function doGetFileStat( array $params ) {
+ $src = $this->resolveHashKey( $params['src'] );
+ if ( $src === null ) {
+ return null;
+ }
+
+ if ( isset( $this->files[$src] ) ) {
+ return [
+ 'mtime' => $this->files[$src]['mtime'],
+ 'size' => strlen( $this->files[$src]['data'] ),
+ ];
+ }
+
+ return false;
+ }
+
+ protected function doGetLocalCopyMulti( array $params ) {
+ $tmpFiles = []; // (path => TempFSFile)
+ foreach ( $params['srcs'] as $srcPath ) {
+ $src = $this->resolveHashKey( $srcPath );
+ if ( $src === null || !isset( $this->files[$src] ) ) {
+ $fsFile = null;
+ } else {
+ // Create a new temporary file with the same extension...
+ $ext = FileBackend::extensionFromPath( $src );
+ $fsFile = TempFSFile::factory( 'localcopy_', $ext, $this->tmpDirectory );
+ if ( $fsFile ) {
+ $bytes = file_put_contents( $fsFile->getPath(), $this->files[$src]['data'] );
+ if ( $bytes !== strlen( $this->files[$src]['data'] ) ) {
+ $fsFile = null;
+ }
+ }
+ }
+ $tmpFiles[$srcPath] = $fsFile;
+ }
+
+ return $tmpFiles;
+ }
+
+ protected function doDirectoryExists( $container, $dir, array $params ) {
+ $prefix = rtrim( "$container/$dir", '/' ) . '/';
+ foreach ( $this->files as $path => $data ) {
+ if ( strpos( $path, $prefix ) === 0 ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function getDirectoryListInternal( $container, $dir, array $params ) {
+ $dirs = [];
+ $prefix = rtrim( "$container/$dir", '/' ) . '/';
+ $prefixLen = strlen( $prefix );
+ foreach ( $this->files as $path => $data ) {
+ if ( strpos( $path, $prefix ) === 0 ) {
+ $relPath = substr( $path, $prefixLen );
+ if ( $relPath === false ) {
+ continue;
+ } elseif ( strpos( $relPath, '/' ) === false ) {
+ continue; // just a file
+ }
+ $parts = array_slice( explode( '/', $relPath ), 0, -1 ); // last part is file name
+ if ( !empty( $params['topOnly'] ) ) {
+ $dirs[$parts[0]] = 1; // top directory
+ } else {
+ $current = '';
+ foreach ( $parts as $part ) { // all directories
+ $dir = ( $current === '' ) ? $part : "$current/$part";
+ $dirs[$dir] = 1;
+ $current = $dir;
+ }
+ }
+ }
+ }
+
+ return array_keys( $dirs );
+ }
+
+ public function getFileListInternal( $container, $dir, array $params ) {
+ $files = [];
+ $prefix = rtrim( "$container/$dir", '/' ) . '/';
+ $prefixLen = strlen( $prefix );
+ foreach ( $this->files as $path => $data ) {
+ if ( strpos( $path, $prefix ) === 0 ) {
+ $relPath = substr( $path, $prefixLen );
+ if ( $relPath === false ) {
+ continue;
+ } elseif ( !empty( $params['topOnly'] ) && strpos( $relPath, '/' ) !== false ) {
+ continue;
+ }
+ $files[] = $relPath;
+ }
+ }
+
+ return $files;
+ }
+
+ protected function directoriesAreVirtual() {
+ return true;
+ }
+
+ /**
+ * Get the absolute file system path for a storage path
+ *
+ * @param string $storagePath Storage path
+ * @return string|null
+ */
+ protected function resolveHashKey( $storagePath ) {
+ list( $fullCont, $relPath ) = $this->resolveStoragePathReal( $storagePath );
+ if ( $relPath === null ) {
+ return null; // invalid
+ }
+
+ return ( $relPath !== '' ) ? "$fullCont/$relPath" : $fullCont;
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/SwiftFileBackend.php b/www/wiki/includes/libs/filebackend/SwiftFileBackend.php
new file mode 100644
index 00000000..de5a1038
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/SwiftFileBackend.php
@@ -0,0 +1,1938 @@
+<?php
+/**
+ * OpenStack Swift based file backend.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ * @author Russ Nelson
+ */
+
+/**
+ * @brief Class for an OpenStack Swift (or Ceph RGW) based file backend.
+ *
+ * StatusValue messages should avoid mentioning the Swift account name.
+ * Likewise, error suppression should be used to avoid path disclosure.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+class SwiftFileBackend extends FileBackendStore {
+ /** @var MultiHttpClient */
+ protected $http;
+ /** @var int TTL in seconds */
+ protected $authTTL;
+ /** @var string Authentication base URL (without version) */
+ protected $swiftAuthUrl;
+ /** @var string Override of storage base URL */
+ protected $swiftStorageUrl;
+ /** @var string Swift user (account:user) to authenticate as */
+ protected $swiftUser;
+ /** @var string Secret key for user */
+ protected $swiftKey;
+ /** @var string Shared secret value for making temp URLs */
+ protected $swiftTempUrlKey;
+ /** @var string S3 access key (RADOS Gateway) */
+ protected $rgwS3AccessKey;
+ /** @var string S3 authentication key (RADOS Gateway) */
+ protected $rgwS3SecretKey;
+
+ /** @var BagOStuff */
+ protected $srvCache;
+
+ /** @var ProcessCacheLRU Container stat cache */
+ protected $containerStatCache;
+
+ /** @var array */
+ protected $authCreds;
+ /** @var int UNIX timestamp */
+ protected $authSessionTimestamp = 0;
+ /** @var int UNIX timestamp */
+ protected $authErrorTimestamp = null;
+
+ /** @var bool Whether the server is an Ceph RGW */
+ protected $isRGW = false;
+
+ /**
+ * @see FileBackendStore::__construct()
+ * @param array $config Params include:
+ * - swiftAuthUrl : Swift authentication server URL
+ * - swiftUser : Swift user used by MediaWiki (account:username)
+ * - swiftKey : Swift authentication key for the above user
+ * - swiftAuthTTL : Swift authentication TTL (seconds)
+ * - swiftTempUrlKey : Swift "X-Account-Meta-Temp-URL-Key" value on the account.
+ * Do not set this until it has been set in the backend.
+ * - swiftStorageUrl : Swift storage URL (overrides that of the authentication response).
+ * This is useful to set if a TLS proxy is in use.
+ * - shardViaHashLevels : Map of container names to sharding config with:
+ * - base : base of hash characters, 16 or 36
+ * - levels : the number of hash levels (and digits)
+ * - repeat : hash subdirectories are prefixed with all the
+ * parent hash directory names (e.g. "a/ab/abc")
+ * - cacheAuthInfo : Whether to cache authentication tokens in APC, XCache, ect.
+ * If those are not available, then the main cache will be used.
+ * This is probably insecure in shared hosting environments.
+ * - rgwS3AccessKey : Rados Gateway S3 "access key" value on the account.
+ * Do not set this until it has been set in the backend.
+ * This is used for generating expiring pre-authenticated URLs.
+ * Only use this when using rgw and to work around
+ * http://tracker.newdream.net/issues/3454.
+ * - rgwS3SecretKey : Rados Gateway S3 "secret key" value on the account.
+ * Do not set this until it has been set in the backend.
+ * This is used for generating expiring pre-authenticated URLs.
+ * Only use this when using rgw and to work around
+ * http://tracker.newdream.net/issues/3454.
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+ // Required settings
+ $this->swiftAuthUrl = $config['swiftAuthUrl'];
+ $this->swiftUser = $config['swiftUser'];
+ $this->swiftKey = $config['swiftKey'];
+ // Optional settings
+ $this->authTTL = isset( $config['swiftAuthTTL'] )
+ ? $config['swiftAuthTTL']
+ : 15 * 60; // some sane number
+ $this->swiftTempUrlKey = isset( $config['swiftTempUrlKey'] )
+ ? $config['swiftTempUrlKey']
+ : '';
+ $this->swiftStorageUrl = isset( $config['swiftStorageUrl'] )
+ ? $config['swiftStorageUrl']
+ : null;
+ $this->shardViaHashLevels = isset( $config['shardViaHashLevels'] )
+ ? $config['shardViaHashLevels']
+ : '';
+ $this->rgwS3AccessKey = isset( $config['rgwS3AccessKey'] )
+ ? $config['rgwS3AccessKey']
+ : '';
+ $this->rgwS3SecretKey = isset( $config['rgwS3SecretKey'] )
+ ? $config['rgwS3SecretKey']
+ : '';
+ // HTTP helper client
+ $this->http = new MultiHttpClient( [] );
+ // Cache container information to mask latency
+ if ( isset( $config['wanCache'] ) && $config['wanCache'] instanceof WANObjectCache ) {
+ $this->memCache = $config['wanCache'];
+ }
+ // Process cache for container info
+ $this->containerStatCache = new ProcessCacheLRU( 300 );
+ // Cache auth token information to avoid RTTs
+ if ( !empty( $config['cacheAuthInfo'] ) && isset( $config['srvCache'] ) ) {
+ $this->srvCache = $config['srvCache'];
+ } else {
+ $this->srvCache = new EmptyBagOStuff();
+ }
+ }
+
+ public function getFeatures() {
+ return ( FileBackend::ATTR_UNICODE_PATHS |
+ FileBackend::ATTR_HEADERS | FileBackend::ATTR_METADATA );
+ }
+
+ protected function resolveContainerPath( $container, $relStoragePath ) {
+ if ( !mb_check_encoding( $relStoragePath, 'UTF-8' ) ) {
+ return null; // not UTF-8, makes it hard to use CF and the swift HTTP API
+ } elseif ( strlen( urlencode( $relStoragePath ) ) > 1024 ) {
+ return null; // too long for Swift
+ }
+
+ return $relStoragePath;
+ }
+
+ public function isPathUsableInternal( $storagePath ) {
+ list( $container, $rel ) = $this->resolveStoragePathReal( $storagePath );
+ if ( $rel === null ) {
+ return false; // invalid
+ }
+
+ return is_array( $this->getContainerStat( $container ) );
+ }
+
+ /**
+ * Sanitize and filter the custom headers from a $params array.
+ * Only allows certain "standard" Content- and X-Content- headers.
+ *
+ * @param array $params
+ * @return array Sanitized value of 'headers' field in $params
+ */
+ protected function sanitizeHdrs( array $params ) {
+ return isset( $params['headers'] )
+ ? $this->getCustomHeaders( $params['headers'] )
+ : [];
+ }
+
+ /**
+ * @param array $rawHeaders
+ * @return array Custom non-metadata HTTP headers
+ */
+ protected function getCustomHeaders( array $rawHeaders ) {
+ $headers = [];
+
+ // Normalize casing, and strip out illegal headers
+ foreach ( $rawHeaders as $name => $value ) {
+ $name = strtolower( $name );
+ if ( preg_match( '/^content-(type|length)$/', $name ) ) {
+ continue; // blacklisted
+ } elseif ( preg_match( '/^(x-)?content-/', $name ) ) {
+ $headers[$name] = $value; // allowed
+ } elseif ( preg_match( '/^content-(disposition)/', $name ) ) {
+ $headers[$name] = $value; // allowed
+ }
+ }
+ // By default, Swift has annoyingly low maximum header value limits
+ if ( isset( $headers['content-disposition'] ) ) {
+ $disposition = '';
+ // @note: assume FileBackend::makeContentDisposition() already used
+ foreach ( explode( ';', $headers['content-disposition'] ) as $part ) {
+ $part = trim( $part );
+ $new = ( $disposition === '' ) ? $part : "{$disposition};{$part}";
+ if ( strlen( $new ) <= 255 ) {
+ $disposition = $new;
+ } else {
+ break; // too long; sigh
+ }
+ }
+ $headers['content-disposition'] = $disposition;
+ }
+
+ return $headers;
+ }
+
+ /**
+ * @param array $rawHeaders
+ * @return array Custom metadata headers
+ */
+ protected function getMetadataHeaders( array $rawHeaders ) {
+ $headers = [];
+ foreach ( $rawHeaders as $name => $value ) {
+ $name = strtolower( $name );
+ if ( strpos( $name, 'x-object-meta-' ) === 0 ) {
+ $headers[$name] = $value;
+ }
+ }
+
+ return $headers;
+ }
+
+ /**
+ * @param array $rawHeaders
+ * @return array Custom metadata headers with prefix removed
+ */
+ protected function getMetadata( array $rawHeaders ) {
+ $metadata = [];
+ foreach ( $this->getMetadataHeaders( $rawHeaders ) as $name => $value ) {
+ $metadata[substr( $name, strlen( 'x-object-meta-' ) )] = $value;
+ }
+
+ return $metadata;
+ }
+
+ protected function doCreateInternal( array $params ) {
+ $status = $this->newStatus();
+
+ list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] );
+ if ( $dstRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ $sha1Hash = Wikimedia\base_convert( sha1( $params['content'] ), 16, 36, 31 );
+ $contentType = isset( $params['headers']['content-type'] )
+ ? $params['headers']['content-type']
+ : $this->getContentType( $params['dst'], $params['content'], null );
+
+ $reqs = [ [
+ 'method' => 'PUT',
+ 'url' => [ $dstCont, $dstRel ],
+ 'headers' => [
+ 'content-length' => strlen( $params['content'] ),
+ 'etag' => md5( $params['content'] ),
+ 'content-type' => $contentType,
+ 'x-object-meta-sha1base36' => $sha1Hash
+ ] + $this->sanitizeHdrs( $params ),
+ 'body' => $params['content']
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 201 ) {
+ // good
+ } elseif ( $rcode === 412 ) {
+ $status->fatal( 'backend-fail-contenttype', $params['dst'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually write the object in Swift
+ $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doStoreInternal( array $params ) {
+ $status = $this->newStatus();
+
+ list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] );
+ if ( $dstRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ MediaWiki\suppressWarnings();
+ $sha1Hash = sha1_file( $params['src'] );
+ MediaWiki\restoreWarnings();
+ if ( $sha1Hash === false ) { // source doesn't exist?
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+ $sha1Hash = Wikimedia\base_convert( $sha1Hash, 16, 36, 31 );
+ $contentType = isset( $params['headers']['content-type'] )
+ ? $params['headers']['content-type']
+ : $this->getContentType( $params['dst'], null, $params['src'] );
+
+ $handle = fopen( $params['src'], 'rb' );
+ if ( $handle === false ) { // source doesn't exist?
+ $status->fatal( 'backend-fail-store', $params['src'], $params['dst'] );
+
+ return $status;
+ }
+
+ $reqs = [ [
+ 'method' => 'PUT',
+ 'url' => [ $dstCont, $dstRel ],
+ 'headers' => [
+ 'content-length' => filesize( $params['src'] ),
+ 'etag' => md5_file( $params['src'] ),
+ 'content-type' => $contentType,
+ 'x-object-meta-sha1base36' => $sha1Hash
+ ] + $this->sanitizeHdrs( $params ),
+ 'body' => $handle // resource
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 201 ) {
+ // good
+ } elseif ( $rcode === 412 ) {
+ $status->fatal( 'backend-fail-contenttype', $params['dst'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ $opHandle->resourcesToClose[] = $handle;
+
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually write the object in Swift
+ $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doCopyInternal( array $params ) {
+ $status = $this->newStatus();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] );
+ if ( $dstRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ $reqs = [ [
+ 'method' => 'PUT',
+ 'url' => [ $dstCont, $dstRel ],
+ 'headers' => [
+ 'x-copy-from' => '/' . rawurlencode( $srcCont ) .
+ '/' . str_replace( "%2F", "/", rawurlencode( $srcRel ) )
+ ] + $this->sanitizeHdrs( $params ), // extra headers merged into object
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 201 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ $status->fatal( 'backend-fail-copy', $params['src'], $params['dst'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually write the object in Swift
+ $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doMoveInternal( array $params ) {
+ $status = $this->newStatus();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ list( $dstCont, $dstRel ) = $this->resolveStoragePathReal( $params['dst'] );
+ if ( $dstRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['dst'] );
+
+ return $status;
+ }
+
+ $reqs = [
+ [
+ 'method' => 'PUT',
+ 'url' => [ $dstCont, $dstRel ],
+ 'headers' => [
+ 'x-copy-from' => '/' . rawurlencode( $srcCont ) .
+ '/' . str_replace( "%2F", "/", rawurlencode( $srcRel ) )
+ ] + $this->sanitizeHdrs( $params ) // extra headers merged into object
+ ]
+ ];
+ if ( "{$srcCont}/{$srcRel}" !== "{$dstCont}/{$dstRel}" ) {
+ $reqs[] = [
+ 'method' => 'DELETE',
+ 'url' => [ $srcCont, $srcRel ],
+ 'headers' => []
+ ];
+ }
+
+ $method = __METHOD__;
+ $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $request['method'] === 'PUT' && $rcode === 201 ) {
+ // good
+ } elseif ( $request['method'] === 'DELETE' && $rcode === 204 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ $status->fatal( 'backend-fail-move', $params['src'], $params['dst'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually move the object in Swift
+ $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doDeleteInternal( array $params ) {
+ $status = $this->newStatus();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $reqs = [ [
+ 'method' => 'DELETE',
+ 'url' => [ $srcCont, $srcRel ],
+ 'headers' => []
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 204 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ if ( empty( $params['ignoreMissingSource'] ) ) {
+ $status->fatal( 'backend-fail-delete', $params['src'] );
+ }
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually delete the object in Swift
+ $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doDescribeInternal( array $params ) {
+ $status = $this->newStatus();
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ // Fetch the old object headers/metadata...this should be in stat cache by now
+ $stat = $this->getFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
+ if ( $stat && !isset( $stat['xattr'] ) ) { // older cache entry
+ $stat = $this->doGetFileStat( [ 'src' => $params['src'], 'latest' => 1 ] );
+ }
+ if ( !$stat ) {
+ $status->fatal( 'backend-fail-describe', $params['src'] );
+
+ return $status;
+ }
+
+ // POST clears prior headers, so we need to merge the changes in to the old ones
+ $metaHdrs = [];
+ foreach ( $stat['xattr']['metadata'] as $name => $value ) {
+ $metaHdrs["x-object-meta-$name"] = $value;
+ }
+ $customHdrs = $this->sanitizeHdrs( $params ) + $stat['xattr']['headers'];
+
+ $reqs = [ [
+ 'method' => 'POST',
+ 'url' => [ $srcCont, $srcRel ],
+ 'headers' => $metaHdrs + $customHdrs
+ ] ];
+
+ $method = __METHOD__;
+ $handler = function ( array $request, StatusValue $status ) use ( $method, $params ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $request['response'];
+ if ( $rcode === 202 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ $status->fatal( 'backend-fail-describe', $params['src'] );
+ } else {
+ $this->onError( $status, $method, $params, $rerr, $rcode, $rdesc );
+ }
+ };
+
+ $opHandle = new SwiftFileOpHandle( $this, $handler, $reqs );
+ if ( !empty( $params['async'] ) ) { // deferred
+ $status->value = $opHandle;
+ } else { // actually change the object in Swift
+ $status->merge( current( $this->executeOpHandlesInternal( [ $opHandle ] ) ) );
+ }
+
+ return $status;
+ }
+
+ protected function doPrepareInternal( $fullCont, $dir, array $params ) {
+ $status = $this->newStatus();
+
+ // (a) Check if container already exists
+ $stat = $this->getContainerStat( $fullCont );
+ if ( is_array( $stat ) ) {
+ return $status; // already there
+ } elseif ( $stat === null ) {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ $this->logger->error( __METHOD__ . ': cannot get container stat' );
+
+ return $status;
+ }
+
+ // (b) Create container as needed with proper ACLs
+ if ( $stat === false ) {
+ $params['op'] = 'prepare';
+ $status->merge( $this->createContainer( $fullCont, $params ) );
+ }
+
+ return $status;
+ }
+
+ protected function doSecureInternal( $fullCont, $dir, array $params ) {
+ $status = $this->newStatus();
+ if ( empty( $params['noAccess'] ) ) {
+ return $status; // nothing to do
+ }
+
+ $stat = $this->getContainerStat( $fullCont );
+ if ( is_array( $stat ) ) {
+ // Make container private to end-users...
+ $status->merge( $this->setContainerAccess(
+ $fullCont,
+ [ $this->swiftUser ], // read
+ [ $this->swiftUser ] // write
+ ) );
+ } elseif ( $stat === false ) {
+ $status->fatal( 'backend-fail-usable', $params['dir'] );
+ } else {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ $this->logger->error( __METHOD__ . ': cannot get container stat' );
+ }
+
+ return $status;
+ }
+
+ protected function doPublishInternal( $fullCont, $dir, array $params ) {
+ $status = $this->newStatus();
+
+ $stat = $this->getContainerStat( $fullCont );
+ if ( is_array( $stat ) ) {
+ // Make container public to end-users...
+ $status->merge( $this->setContainerAccess(
+ $fullCont,
+ [ $this->swiftUser, '.r:*' ], // read
+ [ $this->swiftUser ] // write
+ ) );
+ } elseif ( $stat === false ) {
+ $status->fatal( 'backend-fail-usable', $params['dir'] );
+ } else {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ $this->logger->error( __METHOD__ . ': cannot get container stat' );
+ }
+
+ return $status;
+ }
+
+ protected function doCleanInternal( $fullCont, $dir, array $params ) {
+ $status = $this->newStatus();
+
+ // Only containers themselves can be removed, all else is virtual
+ if ( $dir != '' ) {
+ return $status; // nothing to do
+ }
+
+ // (a) Check the container
+ $stat = $this->getContainerStat( $fullCont, true );
+ if ( $stat === false ) {
+ return $status; // ok, nothing to do
+ } elseif ( !is_array( $stat ) ) {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ $this->logger->error( __METHOD__ . ': cannot get container stat' );
+
+ return $status;
+ }
+
+ // (b) Delete the container if empty
+ if ( $stat['count'] == 0 ) {
+ $params['op'] = 'clean';
+ $status->merge( $this->deleteContainer( $fullCont, $params ) );
+ }
+
+ return $status;
+ }
+
+ protected function doGetFileStat( array $params ) {
+ $params = [ 'srcs' => [ $params['src'] ], 'concurrency' => 1 ] + $params;
+ unset( $params['src'] );
+ $stats = $this->doGetFileStatMulti( $params );
+
+ return reset( $stats );
+ }
+
+ /**
+ * Convert dates like "Tue, 03 Jan 2012 22:01:04 GMT"/"2013-05-11T07:37:27.678360Z".
+ * Dates might also come in like "2013-05-11T07:37:27.678360" from Swift listings,
+ * missing the timezone suffix (though Ceph RGW does not appear to have this bug).
+ *
+ * @param string $ts
+ * @param int $format Output format (TS_* constant)
+ * @return string
+ * @throws FileBackendError
+ */
+ protected function convertSwiftDate( $ts, $format = TS_MW ) {
+ try {
+ $timestamp = new MWTimestamp( $ts );
+
+ return $timestamp->getTimestamp( $format );
+ } catch ( Exception $e ) {
+ throw new FileBackendError( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Fill in any missing object metadata and save it to Swift
+ *
+ * @param array $objHdrs Object response headers
+ * @param string $path Storage path to object
+ * @return array New headers
+ */
+ protected function addMissingMetadata( array $objHdrs, $path ) {
+ if ( isset( $objHdrs['x-object-meta-sha1base36'] ) ) {
+ return $objHdrs; // nothing to do
+ }
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+ $this->logger->error( __METHOD__ . ": $path was not stored with SHA-1 metadata." );
+
+ $objHdrs['x-object-meta-sha1base36'] = false;
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ return $objHdrs; // failed
+ }
+
+ // Find prior custom HTTP headers
+ $postHeaders = $this->getCustomHeaders( $objHdrs );
+ // Find prior metadata headers
+ $postHeaders += $this->getMetadataHeaders( $objHdrs );
+
+ $status = $this->newStatus();
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scopeLockS = $this->getScopedFileLocks( [ $path ], LockManager::LOCK_UW, $status );
+ if ( $status->isOK() ) {
+ $tmpFile = $this->getLocalCopy( [ 'src' => $path, 'latest' => 1 ] );
+ if ( $tmpFile ) {
+ $hash = $tmpFile->getSha1Base36();
+ if ( $hash !== false ) {
+ $objHdrs['x-object-meta-sha1base36'] = $hash;
+ // Merge new SHA1 header into the old ones
+ $postHeaders['x-object-meta-sha1base36'] = $hash;
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path );
+ list( $rcode ) = $this->http->run( [
+ 'method' => 'POST',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth ) + $postHeaders
+ ] );
+ if ( $rcode >= 200 && $rcode <= 299 ) {
+ $this->deleteFileCache( $path );
+
+ return $objHdrs; // success
+ }
+ }
+ }
+ }
+
+ $this->logger->error( __METHOD__ . ": unable to set SHA-1 metadata for $path" );
+
+ return $objHdrs; // failed
+ }
+
+ protected function doGetFileContentsMulti( array $params ) {
+ $contents = [];
+
+ $auth = $this->getAuthentication();
+
+ $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
+ // Blindly create tmp files and stream to them, catching any exception if the file does
+ // not exist. Doing stats here is useless and will loop infinitely in addMissingMetadata().
+ $reqs = []; // (path => op)
+
+ foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path );
+ if ( $srcRel === null || !$auth ) {
+ $contents[$path] = false;
+ continue;
+ }
+ // Create a new temporary memory file...
+ $handle = fopen( 'php://temp', 'wb' );
+ if ( $handle ) {
+ $reqs[$path] = [
+ 'method' => 'GET',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ + $this->headersFromParams( $params ),
+ 'stream' => $handle,
+ ];
+ }
+ $contents[$path] = false;
+ }
+
+ $opts = [ 'maxConnsPerHost' => $params['concurrency'] ];
+ $reqs = $this->http->runMulti( $reqs, $opts );
+ foreach ( $reqs as $path => $op ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $op['response'];
+ if ( $rcode >= 200 && $rcode <= 299 ) {
+ rewind( $op['stream'] ); // start from the beginning
+ $contents[$path] = stream_get_contents( $op['stream'] );
+ } elseif ( $rcode === 404 ) {
+ $contents[$path] = false;
+ } else {
+ $this->onError( null, __METHOD__,
+ [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
+ }
+ fclose( $op['stream'] ); // close open handle
+ }
+
+ return $contents;
+ }
+
+ protected function doDirectoryExists( $fullCont, $dir, array $params ) {
+ $prefix = ( $dir == '' ) ? null : "{$dir}/";
+ $status = $this->objectListing( $fullCont, 'names', 1, null, $prefix );
+ if ( $status->isOK() ) {
+ return ( count( $status->value ) ) > 0;
+ }
+
+ return null; // error
+ }
+
+ /**
+ * @see FileBackendStore::getDirectoryListInternal()
+ * @param string $fullCont
+ * @param string $dir
+ * @param array $params
+ * @return SwiftFileBackendDirList
+ */
+ public function getDirectoryListInternal( $fullCont, $dir, array $params ) {
+ return new SwiftFileBackendDirList( $this, $fullCont, $dir, $params );
+ }
+
+ /**
+ * @see FileBackendStore::getFileListInternal()
+ * @param string $fullCont
+ * @param string $dir
+ * @param array $params
+ * @return SwiftFileBackendFileList
+ */
+ public function getFileListInternal( $fullCont, $dir, array $params ) {
+ return new SwiftFileBackendFileList( $this, $fullCont, $dir, $params );
+ }
+
+ /**
+ * Do not call this function outside of SwiftFileBackendFileList
+ *
+ * @param string $fullCont Resolved container name
+ * @param string $dir Resolved storage directory with no trailing slash
+ * @param string|null &$after Resolved container relative path to list items after
+ * @param int $limit Max number of items to list
+ * @param array $params Parameters for getDirectoryList()
+ * @return array List of container relative resolved paths of directories directly under $dir
+ * @throws FileBackendError
+ */
+ public function getDirListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
+ $dirs = [];
+ if ( $after === INF ) {
+ return $dirs; // nothing more
+ }
+
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ $prefix = ( $dir == '' ) ? null : "{$dir}/";
+ // Non-recursive: only list dirs right under $dir
+ if ( !empty( $params['topOnly'] ) ) {
+ $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
+ if ( !$status->isOK() ) {
+ throw new FileBackendError( "Iterator page I/O error." );
+ }
+ $objects = $status->value;
+ foreach ( $objects as $object ) { // files and directories
+ if ( substr( $object, -1 ) === '/' ) {
+ $dirs[] = $object; // directories end in '/'
+ }
+ }
+ } else {
+ // Recursive: list all dirs under $dir and its subdirs
+ $getParentDir = function ( $path ) {
+ return ( strpos( $path, '/' ) !== false ) ? dirname( $path ) : false;
+ };
+
+ // Get directory from last item of prior page
+ $lastDir = $getParentDir( $after ); // must be first page
+ $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
+
+ if ( !$status->isOK() ) {
+ throw new FileBackendError( "Iterator page I/O error." );
+ }
+
+ $objects = $status->value;
+
+ foreach ( $objects as $object ) { // files
+ $objectDir = $getParentDir( $object ); // directory of object
+
+ if ( $objectDir !== false && $objectDir !== $dir ) {
+ // Swift stores paths in UTF-8, using binary sorting.
+ // See function "create_container_table" in common/db.py.
+ // If a directory is not "greater" than the last one,
+ // then it was already listed by the calling iterator.
+ if ( strcmp( $objectDir, $lastDir ) > 0 ) {
+ $pDir = $objectDir;
+ do { // add dir and all its parent dirs
+ $dirs[] = "{$pDir}/";
+ $pDir = $getParentDir( $pDir );
+ } while ( $pDir !== false // sanity
+ && strcmp( $pDir, $lastDir ) > 0 // not done already
+ && strlen( $pDir ) > strlen( $dir ) // within $dir
+ );
+ }
+ $lastDir = $objectDir;
+ }
+ }
+ }
+ // Page on the unfiltered directory listing (what is returned may be filtered)
+ if ( count( $objects ) < $limit ) {
+ $after = INF; // avoid a second RTT
+ } else {
+ $after = end( $objects ); // update last item
+ }
+
+ return $dirs;
+ }
+
+ /**
+ * Do not call this function outside of SwiftFileBackendFileList
+ *
+ * @param string $fullCont Resolved container name
+ * @param string $dir Resolved storage directory with no trailing slash
+ * @param string|null &$after Resolved container relative path of file to list items after
+ * @param int $limit Max number of items to list
+ * @param array $params Parameters for getDirectoryList()
+ * @return array List of resolved container relative paths of files under $dir
+ * @throws FileBackendError
+ */
+ public function getFileListPageInternal( $fullCont, $dir, &$after, $limit, array $params ) {
+ $files = []; // list of (path, stat array or null) entries
+ if ( $after === INF ) {
+ return $files; // nothing more
+ }
+
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ $prefix = ( $dir == '' ) ? null : "{$dir}/";
+ // $objects will contain a list of unfiltered names or CF_Object items
+ // Non-recursive: only list files right under $dir
+ if ( !empty( $params['topOnly'] ) ) {
+ if ( !empty( $params['adviseStat'] ) ) {
+ $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix, '/' );
+ } else {
+ $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix, '/' );
+ }
+ } else {
+ // Recursive: list all files under $dir and its subdirs
+ if ( !empty( $params['adviseStat'] ) ) {
+ $status = $this->objectListing( $fullCont, 'info', $limit, $after, $prefix );
+ } else {
+ $status = $this->objectListing( $fullCont, 'names', $limit, $after, $prefix );
+ }
+ }
+
+ // Reformat this list into a list of (name, stat array or null) entries
+ if ( !$status->isOK() ) {
+ throw new FileBackendError( "Iterator page I/O error." );
+ }
+
+ $objects = $status->value;
+ $files = $this->buildFileObjectListing( $params, $dir, $objects );
+
+ // Page on the unfiltered object listing (what is returned may be filtered)
+ if ( count( $objects ) < $limit ) {
+ $after = INF; // avoid a second RTT
+ } else {
+ $after = end( $objects ); // update last item
+ $after = is_object( $after ) ? $after->name : $after;
+ }
+
+ return $files;
+ }
+
+ /**
+ * Build a list of file objects, filtering out any directories
+ * and extracting any stat info if provided in $objects (for CF_Objects)
+ *
+ * @param array $params Parameters for getDirectoryList()
+ * @param string $dir Resolved container directory path
+ * @param array $objects List of CF_Object items or object names
+ * @return array List of (names,stat array or null) entries
+ */
+ private function buildFileObjectListing( array $params, $dir, array $objects ) {
+ $names = [];
+ foreach ( $objects as $object ) {
+ if ( is_object( $object ) ) {
+ if ( isset( $object->subdir ) || !isset( $object->name ) ) {
+ continue; // virtual directory entry; ignore
+ }
+ $stat = [
+ // Convert various random Swift dates to TS_MW
+ 'mtime' => $this->convertSwiftDate( $object->last_modified, TS_MW ),
+ 'size' => (int)$object->bytes,
+ 'sha1' => null,
+ // Note: manifiest ETags are not an MD5 of the file
+ 'md5' => ctype_xdigit( $object->hash ) ? $object->hash : null,
+ 'latest' => false // eventually consistent
+ ];
+ $names[] = [ $object->name, $stat ];
+ } elseif ( substr( $object, -1 ) !== '/' ) {
+ // Omit directories, which end in '/' in listings
+ $names[] = [ $object, null ];
+ }
+ }
+
+ return $names;
+ }
+
+ /**
+ * Do not call this function outside of SwiftFileBackendFileList
+ *
+ * @param string $path Storage path
+ * @param array $val Stat value
+ */
+ public function loadListingStatInternal( $path, array $val ) {
+ $this->cheapCache->set( $path, 'stat', $val );
+ }
+
+ protected function doGetFileXAttributes( array $params ) {
+ $stat = $this->getFileStat( $params );
+ if ( $stat ) {
+ if ( !isset( $stat['xattr'] ) ) {
+ // Stat entries filled by file listings don't include metadata/headers
+ $this->clearCache( [ $params['src'] ] );
+ $stat = $this->getFileStat( $params );
+ }
+
+ return $stat['xattr'];
+ } else {
+ return false;
+ }
+ }
+
+ protected function doGetFileSha1base36( array $params ) {
+ $stat = $this->getFileStat( $params );
+ if ( $stat ) {
+ if ( !isset( $stat['sha1'] ) ) {
+ // Stat entries filled by file listings don't include SHA1
+ $this->clearCache( [ $params['src'] ] );
+ $stat = $this->getFileStat( $params );
+ }
+
+ return $stat['sha1'];
+ } else {
+ return false;
+ }
+ }
+
+ protected function doStreamFile( array $params ) {
+ $status = $this->newStatus();
+
+ $flags = !empty( $params['headless'] ) ? StreamFile::STREAM_HEADLESS : 0;
+
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ StreamFile::send404Message( $params['src'], $flags );
+ $status->fatal( 'backend-fail-invalidpath', $params['src'] );
+
+ return $status;
+ }
+
+ $auth = $this->getAuthentication();
+ if ( !$auth || !is_array( $this->getContainerStat( $srcCont ) ) ) {
+ StreamFile::send404Message( $params['src'], $flags );
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+
+ return $status;
+ }
+
+ // If "headers" is set, we only want to send them if the file is there.
+ // Do not bother checking if the file exists if headers are not set though.
+ if ( $params['headers'] && !$this->fileExists( $params ) ) {
+ StreamFile::send404Message( $params['src'], $flags );
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+
+ return $status;
+ }
+
+ // Send the requested additional headers
+ foreach ( $params['headers'] as $header ) {
+ header( $header ); // aways send
+ }
+
+ if ( empty( $params['allowOB'] ) ) {
+ // Cancel output buffering and gzipping if set
+ call_user_func( $this->obResetFunc );
+ }
+
+ $handle = fopen( 'php://output', 'wb' );
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'GET',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ + $this->headersFromParams( $params ) + $params['options'],
+ 'stream' => $handle,
+ 'flags' => [ 'relayResponseHeaders' => empty( $params['headless'] ) ]
+ ] );
+
+ if ( $rcode >= 200 && $rcode <= 299 ) {
+ // good
+ } elseif ( $rcode === 404 ) {
+ $status->fatal( 'backend-fail-stream', $params['src'] );
+ // Per T43113, nasty things can happen if bad cache entries get
+ // stuck in cache. It's also possible that this error can come up
+ // with simple race conditions. Clear out the stat cache to be safe.
+ $this->clearCache( [ $params['src'] ] );
+ $this->deleteFileCache( $params['src'] );
+ } else {
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+
+ return $status;
+ }
+
+ protected function doGetLocalCopyMulti( array $params ) {
+ /** @var TempFSFile[] $tmpFiles */
+ $tmpFiles = [];
+
+ $auth = $this->getAuthentication();
+
+ $ep = array_diff_key( $params, [ 'srcs' => 1 ] ); // for error logging
+ // Blindly create tmp files and stream to them, catching any exception if the file does
+ // not exist. Doing a stat here is useless causes infinite loops in addMissingMetadata().
+ $reqs = []; // (path => op)
+
+ foreach ( $params['srcs'] as $path ) { // each path in this concurrent batch
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path );
+ if ( $srcRel === null || !$auth ) {
+ $tmpFiles[$path] = null;
+ continue;
+ }
+ // Get source file extension
+ $ext = FileBackend::extensionFromPath( $path );
+ // Create a new temporary file...
+ $tmpFile = TempFSFile::factory( 'localcopy_', $ext, $this->tmpDirectory );
+ if ( $tmpFile ) {
+ $handle = fopen( $tmpFile->getPath(), 'wb' );
+ if ( $handle ) {
+ $reqs[$path] = [
+ 'method' => 'GET',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ + $this->headersFromParams( $params ),
+ 'stream' => $handle,
+ ];
+ } else {
+ $tmpFile = null;
+ }
+ }
+ $tmpFiles[$path] = $tmpFile;
+ }
+
+ $isLatest = ( $this->isRGW || !empty( $params['latest'] ) );
+ $opts = [ 'maxConnsPerHost' => $params['concurrency'] ];
+ $reqs = $this->http->runMulti( $reqs, $opts );
+ foreach ( $reqs as $path => $op ) {
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $op['response'];
+ fclose( $op['stream'] ); // close open handle
+ if ( $rcode >= 200 && $rcode <= 299 ) {
+ $size = $tmpFiles[$path] ? $tmpFiles[$path]->getSize() : 0;
+ // Double check that the disk is not full/broken
+ if ( $size != $rhdrs['content-length'] ) {
+ $tmpFiles[$path] = null;
+ $rerr = "Got {$size}/{$rhdrs['content-length']} bytes";
+ $this->onError( null, __METHOD__,
+ [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
+ }
+ // Set the file stat process cache in passing
+ $stat = $this->getStatFromHeaders( $rhdrs );
+ $stat['latest'] = $isLatest;
+ $this->cheapCache->set( $path, 'stat', $stat );
+ } elseif ( $rcode === 404 ) {
+ $tmpFiles[$path] = false;
+ } else {
+ $tmpFiles[$path] = null;
+ $this->onError( null, __METHOD__,
+ [ 'src' => $path ] + $ep, $rerr, $rcode, $rdesc );
+ }
+ }
+
+ return $tmpFiles;
+ }
+
+ public function getFileHttpUrl( array $params ) {
+ if ( $this->swiftTempUrlKey != '' ||
+ ( $this->rgwS3AccessKey != '' && $this->rgwS3SecretKey != '' )
+ ) {
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] );
+ if ( $srcRel === null ) {
+ return null; // invalid path
+ }
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ return null;
+ }
+
+ $ttl = isset( $params['ttl'] ) ? $params['ttl'] : 86400;
+ $expires = time() + $ttl;
+
+ if ( $this->swiftTempUrlKey != '' ) {
+ $url = $this->storageUrl( $auth, $srcCont, $srcRel );
+ // Swift wants the signature based on the unencoded object name
+ $contPath = parse_url( $this->storageUrl( $auth, $srcCont ), PHP_URL_PATH );
+ $signature = hash_hmac( 'sha1',
+ "GET\n{$expires}\n{$contPath}/{$srcRel}",
+ $this->swiftTempUrlKey
+ );
+
+ return "{$url}?temp_url_sig={$signature}&temp_url_expires={$expires}";
+ } else { // give S3 API URL for rgw
+ // Path for signature starts with the bucket
+ $spath = '/' . rawurlencode( $srcCont ) . '/' .
+ str_replace( '%2F', '/', rawurlencode( $srcRel ) );
+ // Calculate the hash
+ $signature = base64_encode( hash_hmac(
+ 'sha1',
+ "GET\n\n\n{$expires}\n{$spath}",
+ $this->rgwS3SecretKey,
+ true // raw
+ ) );
+ // See https://s3.amazonaws.com/doc/s3-developer-guide/RESTAuthentication.html.
+ // Note: adding a newline for empty CanonicalizedAmzHeaders does not work.
+ // Note: S3 API is the rgw default; remove the /swift/ URL bit.
+ return str_replace( '/swift/v1', '', $this->storageUrl( $auth ) . $spath ) .
+ '?' .
+ http_build_query( [
+ 'Signature' => $signature,
+ 'Expires' => $expires,
+ 'AWSAccessKeyId' => $this->rgwS3AccessKey
+ ] );
+ }
+ }
+
+ return null;
+ }
+
+ protected function directoriesAreVirtual() {
+ return true;
+ }
+
+ /**
+ * Get headers to send to Swift when reading a file based
+ * on a FileBackend params array, e.g. that of getLocalCopy().
+ * $params is currently only checked for a 'latest' flag.
+ *
+ * @param array $params
+ * @return array
+ */
+ protected function headersFromParams( array $params ) {
+ $hdrs = [];
+ if ( !empty( $params['latest'] ) ) {
+ $hdrs['x-newest'] = 'true';
+ }
+
+ return $hdrs;
+ }
+
+ /**
+ * @param FileBackendStoreOpHandle[] $fileOpHandles
+ *
+ * @return StatusValue[]
+ */
+ protected function doExecuteOpHandlesInternal( array $fileOpHandles ) {
+ /** @var StatusValue[] $statuses */
+ $statuses = [];
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ foreach ( $fileOpHandles as $index => $fileOpHandle ) {
+ $statuses[$index] = $this->newStatus( 'backend-fail-connect', $this->name );
+ }
+
+ return $statuses;
+ }
+
+ // Split the HTTP requests into stages that can be done concurrently
+ $httpReqsByStage = []; // map of (stage => index => HTTP request)
+ foreach ( $fileOpHandles as $index => $fileOpHandle ) {
+ /** @var SwiftFileOpHandle $fileOpHandle */
+ $reqs = $fileOpHandle->httpOp;
+ // Convert the 'url' parameter to an actual URL using $auth
+ foreach ( $reqs as $stage => &$req ) {
+ list( $container, $relPath ) = $req['url'];
+ $req['url'] = $this->storageUrl( $auth, $container, $relPath );
+ $req['headers'] = isset( $req['headers'] ) ? $req['headers'] : [];
+ $req['headers'] = $this->authTokenHeaders( $auth ) + $req['headers'];
+ $httpReqsByStage[$stage][$index] = $req;
+ }
+ $statuses[$index] = $this->newStatus();
+ }
+
+ // Run all requests for the first stage, then the next, and so on
+ $reqCount = count( $httpReqsByStage );
+ for ( $stage = 0; $stage < $reqCount; ++$stage ) {
+ $httpReqs = $this->http->runMulti( $httpReqsByStage[$stage] );
+ foreach ( $httpReqs as $index => $httpReq ) {
+ // Run the callback for each request of this operation
+ $callback = $fileOpHandles[$index]->callback;
+ call_user_func_array( $callback, [ $httpReq, $statuses[$index] ] );
+ // On failure, abort all remaining requests for this operation
+ // (e.g. abort the DELETE request if the COPY request fails for a move)
+ if ( !$statuses[$index]->isOK() ) {
+ $stages = count( $fileOpHandles[$index]->httpOp );
+ for ( $s = ( $stage + 1 ); $s < $stages; ++$s ) {
+ unset( $httpReqsByStage[$s][$index] );
+ }
+ }
+ }
+ }
+
+ return $statuses;
+ }
+
+ /**
+ * Set read/write permissions for a Swift container.
+ *
+ * @see http://docs.openstack.org/developer/swift/misc.html#acls
+ *
+ * In general, we don't allow listings to end-users. It's not useful, isn't well-defined
+ * (lists are truncated to 10000 item with no way to page), and is just a performance risk.
+ *
+ * @param string $container Resolved Swift container
+ * @param array $readGrps List of the possible criteria for a request to have
+ * access to read a container. Each item is one of the following formats:
+ * - account:user : Grants access if the request is by the given user
+ * - ".r:<regex>" : Grants access if the request is from a referrer host that
+ * matches the expression and the request is not for a listing.
+ * Setting this to '*' effectively makes a container public.
+ * -".rlistings:<regex>" : Grants access if the request is from a referrer host that
+ * matches the expression and the request is for a listing.
+ * @param array $writeGrps A list of the possible criteria for a request to have
+ * access to write to a container. Each item is of the following format:
+ * - account:user : Grants access if the request is by the given user
+ * @return StatusValue
+ */
+ protected function setContainerAccess( $container, array $readGrps, array $writeGrps ) {
+ $status = $this->newStatus();
+ $auth = $this->getAuthentication();
+
+ if ( !$auth ) {
+ $status->fatal( 'backend-fail-connect', $this->name );
+
+ return $status;
+ }
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'POST',
+ 'url' => $this->storageUrl( $auth, $container ),
+ 'headers' => $this->authTokenHeaders( $auth ) + [
+ 'x-container-read' => implode( ',', $readGrps ),
+ 'x-container-write' => implode( ',', $writeGrps )
+ ]
+ ] );
+
+ if ( $rcode != 204 && $rcode !== 202 ) {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ $this->logger->error( __METHOD__ . ': unexpected rcode value (' . $rcode . ')' );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get a Swift container stat array, possibly from process cache.
+ * Use $reCache if the file count or byte count is needed.
+ *
+ * @param string $container Container name
+ * @param bool $bypassCache Bypass all caches and load from Swift
+ * @return array|bool|null False on 404, null on failure
+ */
+ protected function getContainerStat( $container, $bypassCache = false ) {
+ $ps = $this->scopedProfileSection( __METHOD__ . "-{$this->name}" );
+
+ if ( $bypassCache ) { // purge cache
+ $this->containerStatCache->clear( $container );
+ } elseif ( !$this->containerStatCache->has( $container, 'stat' ) ) {
+ $this->primeContainerCache( [ $container ] ); // check persistent cache
+ }
+ if ( !$this->containerStatCache->has( $container, 'stat' ) ) {
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ return null;
+ }
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'HEAD',
+ 'url' => $this->storageUrl( $auth, $container ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ ] );
+
+ if ( $rcode === 204 ) {
+ $stat = [
+ 'count' => $rhdrs['x-container-object-count'],
+ 'bytes' => $rhdrs['x-container-bytes-used']
+ ];
+ if ( $bypassCache ) {
+ return $stat;
+ } else {
+ $this->containerStatCache->set( $container, 'stat', $stat ); // cache it
+ $this->setContainerCache( $container, $stat ); // update persistent cache
+ }
+ } elseif ( $rcode === 404 ) {
+ return false;
+ } else {
+ $this->onError( null, __METHOD__,
+ [ 'cont' => $container ], $rerr, $rcode, $rdesc );
+
+ return null;
+ }
+ }
+
+ return $this->containerStatCache->get( $container, 'stat' );
+ }
+
+ /**
+ * Create a Swift container
+ *
+ * @param string $container Container name
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function createContainer( $container, array $params ) {
+ $status = $this->newStatus();
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ $status->fatal( 'backend-fail-connect', $this->name );
+
+ return $status;
+ }
+
+ // @see SwiftFileBackend::setContainerAccess()
+ if ( empty( $params['noAccess'] ) ) {
+ $readGrps = [ '.r:*', $this->swiftUser ]; // public
+ } else {
+ $readGrps = [ $this->swiftUser ]; // private
+ }
+ $writeGrps = [ $this->swiftUser ]; // sanity
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'PUT',
+ 'url' => $this->storageUrl( $auth, $container ),
+ 'headers' => $this->authTokenHeaders( $auth ) + [
+ 'x-container-read' => implode( ',', $readGrps ),
+ 'x-container-write' => implode( ',', $writeGrps )
+ ]
+ ] );
+
+ if ( $rcode === 201 ) { // new
+ // good
+ } elseif ( $rcode === 202 ) { // already there
+ // this shouldn't really happen, but is OK
+ } else {
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Delete a Swift container
+ *
+ * @param string $container Container name
+ * @param array $params
+ * @return StatusValue
+ */
+ protected function deleteContainer( $container, array $params ) {
+ $status = $this->newStatus();
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ $status->fatal( 'backend-fail-connect', $this->name );
+
+ return $status;
+ }
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'DELETE',
+ 'url' => $this->storageUrl( $auth, $container ),
+ 'headers' => $this->authTokenHeaders( $auth )
+ ] );
+
+ if ( $rcode >= 200 && $rcode <= 299 ) { // deleted
+ $this->containerStatCache->clear( $container ); // purge
+ } elseif ( $rcode === 404 ) { // not there
+ // this shouldn't really happen, but is OK
+ } elseif ( $rcode === 409 ) { // not empty
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); // race?
+ } else {
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get a list of objects under a container.
+ * Either just the names or a list of stdClass objects with details can be returned.
+ *
+ * @param string $fullCont
+ * @param string $type ('info' for a list of object detail maps, 'names' for names only)
+ * @param int $limit
+ * @param string|null $after
+ * @param string|null $prefix
+ * @param string|null $delim
+ * @return StatusValue With the list as value
+ */
+ private function objectListing(
+ $fullCont, $type, $limit, $after = null, $prefix = null, $delim = null
+ ) {
+ $status = $this->newStatus();
+
+ $auth = $this->getAuthentication();
+ if ( !$auth ) {
+ $status->fatal( 'backend-fail-connect', $this->name );
+
+ return $status;
+ }
+
+ $query = [ 'limit' => $limit ];
+ if ( $type === 'info' ) {
+ $query['format'] = 'json';
+ }
+ if ( $after !== null ) {
+ $query['marker'] = $after;
+ }
+ if ( $prefix !== null ) {
+ $query['prefix'] = $prefix;
+ }
+ if ( $delim !== null ) {
+ $query['delimiter'] = $delim;
+ }
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'GET',
+ 'url' => $this->storageUrl( $auth, $fullCont ),
+ 'query' => $query,
+ 'headers' => $this->authTokenHeaders( $auth )
+ ] );
+
+ $params = [ 'cont' => $fullCont, 'prefix' => $prefix, 'delim' => $delim ];
+ if ( $rcode === 200 ) { // good
+ if ( $type === 'info' ) {
+ $status->value = FormatJson::decode( trim( $rbody ) );
+ } else {
+ $status->value = explode( "\n", trim( $rbody ) );
+ }
+ } elseif ( $rcode === 204 ) {
+ $status->value = []; // empty container
+ } elseif ( $rcode === 404 ) {
+ $status->value = []; // no container
+ } else {
+ $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+
+ return $status;
+ }
+
+ protected function doPrimeContainerCache( array $containerInfo ) {
+ foreach ( $containerInfo as $container => $info ) {
+ $this->containerStatCache->set( $container, 'stat', $info );
+ }
+ }
+
+ protected function doGetFileStatMulti( array $params ) {
+ $stats = [];
+
+ $auth = $this->getAuthentication();
+
+ $reqs = [];
+ foreach ( $params['srcs'] as $path ) {
+ list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $path );
+ if ( $srcRel === null ) {
+ $stats[$path] = false;
+ continue; // invalid storage path
+ } elseif ( !$auth ) {
+ $stats[$path] = null;
+ continue;
+ }
+
+ // (a) Check the container
+ $cstat = $this->getContainerStat( $srcCont );
+ if ( $cstat === false ) {
+ $stats[$path] = false;
+ continue; // ok, nothing to do
+ } elseif ( !is_array( $cstat ) ) {
+ $stats[$path] = null;
+ continue;
+ }
+
+ $reqs[$path] = [
+ 'method' => 'HEAD',
+ 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ),
+ 'headers' => $this->authTokenHeaders( $auth ) + $this->headersFromParams( $params )
+ ];
+ }
+
+ $opts = [ 'maxConnsPerHost' => $params['concurrency'] ];
+ $reqs = $this->http->runMulti( $reqs, $opts );
+
+ foreach ( $params['srcs'] as $path ) {
+ if ( array_key_exists( $path, $stats ) ) {
+ continue; // some sort of failure above
+ }
+ // (b) Check the file
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $reqs[$path]['response'];
+ if ( $rcode === 200 || $rcode === 204 ) {
+ // Update the object if it is missing some headers
+ $rhdrs = $this->addMissingMetadata( $rhdrs, $path );
+ // Load the stat array from the headers
+ $stat = $this->getStatFromHeaders( $rhdrs );
+ if ( $this->isRGW ) {
+ $stat['latest'] = true; // strong consistency
+ }
+ } elseif ( $rcode === 404 ) {
+ $stat = false;
+ } else {
+ $stat = null;
+ $this->onError( null, __METHOD__, $params, $rerr, $rcode, $rdesc );
+ }
+ $stats[$path] = $stat;
+ }
+
+ return $stats;
+ }
+
+ /**
+ * @param array $rhdrs
+ * @return array
+ */
+ protected function getStatFromHeaders( array $rhdrs ) {
+ // Fetch all of the custom metadata headers
+ $metadata = $this->getMetadata( $rhdrs );
+ // Fetch all of the custom raw HTTP headers
+ $headers = $this->sanitizeHdrs( [ 'headers' => $rhdrs ] );
+
+ return [
+ // Convert various random Swift dates to TS_MW
+ 'mtime' => $this->convertSwiftDate( $rhdrs['last-modified'], TS_MW ),
+ // Empty objects actually return no content-length header in Ceph
+ 'size' => isset( $rhdrs['content-length'] ) ? (int)$rhdrs['content-length'] : 0,
+ 'sha1' => isset( $metadata['sha1base36'] ) ? $metadata['sha1base36'] : null,
+ // Note: manifiest ETags are not an MD5 of the file
+ 'md5' => ctype_xdigit( $rhdrs['etag'] ) ? $rhdrs['etag'] : null,
+ 'xattr' => [ 'metadata' => $metadata, 'headers' => $headers ]
+ ];
+ }
+
+ /**
+ * @return array|null Credential map
+ */
+ protected function getAuthentication() {
+ if ( $this->authErrorTimestamp !== null ) {
+ if ( ( time() - $this->authErrorTimestamp ) < 60 ) {
+ return null; // failed last attempt; don't bother
+ } else { // actually retry this time
+ $this->authErrorTimestamp = null;
+ }
+ }
+ // Session keys expire after a while, so we renew them periodically
+ $reAuth = ( ( time() - $this->authSessionTimestamp ) > $this->authTTL );
+ // Authenticate with proxy and get a session key...
+ if ( !$this->authCreds || $reAuth ) {
+ $this->authSessionTimestamp = 0;
+ $cacheKey = $this->getCredsCacheKey( $this->swiftUser );
+ $creds = $this->srvCache->get( $cacheKey ); // credentials
+ // Try to use the credential cache
+ if ( isset( $creds['auth_token'] ) && isset( $creds['storage_url'] ) ) {
+ $this->authCreds = $creds;
+ // Skew the timestamp for worst case to avoid using stale credentials
+ $this->authSessionTimestamp = time() - ceil( $this->authTTL / 2 );
+ } else { // cache miss
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [
+ 'method' => 'GET',
+ 'url' => "{$this->swiftAuthUrl}/v1.0",
+ 'headers' => [
+ 'x-auth-user' => $this->swiftUser,
+ 'x-auth-key' => $this->swiftKey
+ ]
+ ] );
+
+ if ( $rcode >= 200 && $rcode <= 299 ) { // OK
+ $this->authCreds = [
+ 'auth_token' => $rhdrs['x-auth-token'],
+ 'storage_url' => ( $this->swiftStorageUrl !== null )
+ ? $this->swiftStorageUrl
+ : $rhdrs['x-storage-url']
+ ];
+
+ $this->srvCache->set( $cacheKey, $this->authCreds, ceil( $this->authTTL / 2 ) );
+ $this->authSessionTimestamp = time();
+ } elseif ( $rcode === 401 ) {
+ $this->onError( null, __METHOD__, [], "Authentication failed.", $rcode );
+ $this->authErrorTimestamp = time();
+
+ return null;
+ } else {
+ $this->onError( null, __METHOD__, [], "HTTP return code: $rcode", $rcode );
+ $this->authErrorTimestamp = time();
+
+ return null;
+ }
+ }
+ // Ceph RGW does not use <account> in URLs (OpenStack Swift uses "/v1/<account>")
+ if ( substr( $this->authCreds['storage_url'], -3 ) === '/v1' ) {
+ $this->isRGW = true; // take advantage of strong consistency in Ceph
+ }
+ }
+
+ return $this->authCreds;
+ }
+
+ /**
+ * @param array $creds From getAuthentication()
+ * @param string $container
+ * @param string $object
+ * @return string
+ */
+ protected function storageUrl( array $creds, $container = null, $object = null ) {
+ $parts = [ $creds['storage_url'] ];
+ if ( strlen( $container ) ) {
+ $parts[] = rawurlencode( $container );
+ }
+ if ( strlen( $object ) ) {
+ $parts[] = str_replace( "%2F", "/", rawurlencode( $object ) );
+ }
+
+ return implode( '/', $parts );
+ }
+
+ /**
+ * @param array $creds From getAuthentication()
+ * @return array
+ */
+ protected function authTokenHeaders( array $creds ) {
+ return [ 'x-auth-token' => $creds['auth_token'] ];
+ }
+
+ /**
+ * Get the cache key for a container
+ *
+ * @param string $username
+ * @return string
+ */
+ private function getCredsCacheKey( $username ) {
+ return 'swiftcredentials:' . md5( $username . ':' . $this->swiftAuthUrl );
+ }
+
+ /**
+ * Log an unexpected exception for this backend.
+ * This also sets the StatusValue object to have a fatal error.
+ *
+ * @param StatusValue|null $status
+ * @param string $func
+ * @param array $params
+ * @param string $err Error string
+ * @param int $code HTTP status
+ * @param string $desc HTTP StatusValue description
+ */
+ public function onError( $status, $func, array $params, $err = '', $code = 0, $desc = '' ) {
+ if ( $status instanceof StatusValue ) {
+ $status->fatal( 'backend-fail-internal', $this->name );
+ }
+ if ( $code == 401 ) { // possibly a stale token
+ $this->srvCache->delete( $this->getCredsCacheKey( $this->swiftUser ) );
+ }
+ $this->logger->error(
+ "HTTP $code ($desc) in '{$func}' (given '" . FormatJson::encode( $params ) . "')" .
+ ( $err ? ": $err" : "" )
+ );
+ }
+}
+
+/**
+ * @see FileBackendStoreOpHandle
+ */
+class SwiftFileOpHandle extends FileBackendStoreOpHandle {
+ /** @var array List of Requests for MultiHttpClient */
+ public $httpOp;
+ /** @var Closure */
+ public $callback;
+
+ /**
+ * @param SwiftFileBackend $backend
+ * @param Closure $callback Function that takes (HTTP request array, status)
+ * @param array $httpOp MultiHttpClient op
+ */
+ public function __construct( SwiftFileBackend $backend, Closure $callback, array $httpOp ) {
+ $this->backend = $backend;
+ $this->callback = $callback;
+ $this->httpOp = $httpOp;
+ }
+}
+
+/**
+ * SwiftFileBackend helper class to page through listings.
+ * Swift also has a listing limit of 10,000 objects for sanity.
+ * Do not use this class from places outside SwiftFileBackend.
+ *
+ * @ingroup FileBackend
+ */
+abstract class SwiftFileBackendList implements Iterator {
+ /** @var array List of path or (path,stat array) entries */
+ protected $bufferIter = [];
+
+ /** @var string List items *after* this path */
+ protected $bufferAfter = null;
+
+ /** @var int */
+ protected $pos = 0;
+
+ /** @var array */
+ protected $params = [];
+
+ /** @var SwiftFileBackend */
+ protected $backend;
+
+ /** @var string Container name */
+ protected $container;
+
+ /** @var string Storage directory */
+ protected $dir;
+
+ /** @var int */
+ protected $suffixStart;
+
+ const PAGE_SIZE = 9000; // file listing buffer size
+
+ /**
+ * @param SwiftFileBackend $backend
+ * @param string $fullCont Resolved container name
+ * @param string $dir Resolved directory relative to container
+ * @param array $params
+ */
+ public function __construct( SwiftFileBackend $backend, $fullCont, $dir, array $params ) {
+ $this->backend = $backend;
+ $this->container = $fullCont;
+ $this->dir = $dir;
+ if ( substr( $this->dir, -1 ) === '/' ) {
+ $this->dir = substr( $this->dir, 0, -1 ); // remove trailing slash
+ }
+ if ( $this->dir == '' ) { // whole container
+ $this->suffixStart = 0;
+ } else { // dir within container
+ $this->suffixStart = strlen( $this->dir ) + 1; // size of "path/to/dir/"
+ }
+ $this->params = $params;
+ }
+
+ /**
+ * @see Iterator::key()
+ * @return int
+ */
+ public function key() {
+ return $this->pos;
+ }
+
+ /**
+ * @see Iterator::next()
+ */
+ public function next() {
+ // Advance to the next file in the page
+ next( $this->bufferIter );
+ ++$this->pos;
+ // Check if there are no files left in this page and
+ // advance to the next page if this page was not empty.
+ if ( !$this->valid() && count( $this->bufferIter ) ) {
+ $this->bufferIter = $this->pageFromList(
+ $this->container, $this->dir, $this->bufferAfter, self::PAGE_SIZE, $this->params
+ ); // updates $this->bufferAfter
+ }
+ }
+
+ /**
+ * @see Iterator::rewind()
+ */
+ public function rewind() {
+ $this->pos = 0;
+ $this->bufferAfter = null;
+ $this->bufferIter = $this->pageFromList(
+ $this->container, $this->dir, $this->bufferAfter, self::PAGE_SIZE, $this->params
+ ); // updates $this->bufferAfter
+ }
+
+ /**
+ * @see Iterator::valid()
+ * @return bool
+ */
+ public function valid() {
+ if ( $this->bufferIter === null ) {
+ return false; // some failure?
+ } else {
+ return ( current( $this->bufferIter ) !== false ); // no paths can have this value
+ }
+ }
+
+ /**
+ * Get the given list portion (page)
+ *
+ * @param string $container Resolved container name
+ * @param string $dir Resolved path relative to container
+ * @param string &$after
+ * @param int $limit
+ * @param array $params
+ * @return Traversable|array
+ */
+ abstract protected function pageFromList( $container, $dir, &$after, $limit, array $params );
+}
+
+/**
+ * Iterator for listing directories
+ */
+class SwiftFileBackendDirList extends SwiftFileBackendList {
+ /**
+ * @see Iterator::current()
+ * @return string|bool String (relative path) or false
+ */
+ public function current() {
+ return substr( current( $this->bufferIter ), $this->suffixStart, -1 );
+ }
+
+ protected function pageFromList( $container, $dir, &$after, $limit, array $params ) {
+ return $this->backend->getDirListPageInternal( $container, $dir, $after, $limit, $params );
+ }
+}
+
+/**
+ * Iterator for listing regular files
+ */
+class SwiftFileBackendFileList extends SwiftFileBackendList {
+ /**
+ * @see Iterator::current()
+ * @return string|bool String (relative path) or false
+ */
+ public function current() {
+ list( $path, $stat ) = current( $this->bufferIter );
+ $relPath = substr( $path, $this->suffixStart );
+ if ( is_array( $stat ) ) {
+ $storageDir = rtrim( $this->params['dir'], '/' );
+ $this->backend->loadListingStatInternal( "$storageDir/$relPath", $stat );
+ }
+
+ return $relPath;
+ }
+
+ protected function pageFromList( $container, $dir, &$after, $limit, array $params ) {
+ return $this->backend->getFileListPageInternal( $container, $dir, $after, $limit, $params );
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/filejournal/FileJournal.php b/www/wiki/includes/libs/filebackend/filejournal/FileJournal.php
new file mode 100644
index 00000000..5ba59c5c
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/filejournal/FileJournal.php
@@ -0,0 +1,199 @@
+<?php
+/**
+ * @defgroup FileJournal File journal
+ * @ingroup FileBackend
+ */
+
+/**
+ * File operation journaling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileJournal
+ */
+
+/**
+ * @brief Class for handling file operation journaling.
+ *
+ * Subclasses should avoid throwing exceptions at all costs.
+ *
+ * @ingroup FileJournal
+ * @since 1.20
+ */
+abstract class FileJournal {
+ /** @var string */
+ protected $backend;
+
+ /** @var int */
+ protected $ttlDays;
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Includes:
+ * 'ttlDays' : days to keep log entries around (false means "forever")
+ */
+ protected function __construct( array $config ) {
+ $this->ttlDays = isset( $config['ttlDays'] ) ? $config['ttlDays'] : false;
+ }
+
+ /**
+ * Create an appropriate FileJournal object from config
+ *
+ * @param array $config
+ * @param string $backend A registered file backend name
+ * @throws Exception
+ * @return FileJournal
+ */
+ final public static function factory( array $config, $backend ) {
+ $class = $config['class'];
+ $jrn = new $class( $config );
+ if ( !$jrn instanceof self ) {
+ throw new InvalidArgumentException( "Class given is not an instance of FileJournal." );
+ }
+ $jrn->backend = $backend;
+
+ return $jrn;
+ }
+
+ /**
+ * Get a statistically unique ID string
+ *
+ * @return string <9 char TS_MW timestamp in base 36><22 random base 36 chars>
+ */
+ final public function getTimestampedUUID() {
+ $s = '';
+ for ( $i = 0; $i < 5; $i++ ) {
+ $s .= mt_rand( 0, 2147483647 );
+ }
+ $s = Wikimedia\base_convert( sha1( $s ), 16, 36, 31 );
+
+ return substr( Wikimedia\base_convert( wfTimestamp( TS_MW ), 10, 36, 9 ) . $s, 0, 31 );
+ }
+
+ /**
+ * Log changes made by a batch file operation.
+ *
+ * @param array $entries List of file operations (each an array of parameters) which contain:
+ * op : Basic operation name (create, update, delete)
+ * path : The storage path of the file
+ * newSha1 : The final base 36 SHA-1 of the file
+ * Note that 'false' should be used as the SHA-1 for non-existing files.
+ * @param string $batchId UUID string that identifies the operation batch
+ * @return StatusValue
+ */
+ final public function logChangeBatch( array $entries, $batchId ) {
+ if ( !count( $entries ) ) {
+ return StatusValue::newGood();
+ }
+
+ return $this->doLogChangeBatch( $entries, $batchId );
+ }
+
+ /**
+ * @see FileJournal::logChangeBatch()
+ *
+ * @param array $entries List of file operations (each an array of parameters)
+ * @param string $batchId UUID string that identifies the operation batch
+ * @return StatusValue
+ */
+ abstract protected function doLogChangeBatch( array $entries, $batchId );
+
+ /**
+ * Get the position ID of the latest journal entry
+ *
+ * @return int|bool
+ */
+ final public function getCurrentPosition() {
+ return $this->doGetCurrentPosition();
+ }
+
+ /**
+ * @see FileJournal::getCurrentPosition()
+ * @return int|bool
+ */
+ abstract protected function doGetCurrentPosition();
+
+ /**
+ * Get the position ID of the latest journal entry at some point in time
+ *
+ * @param int|string $time Timestamp
+ * @return int|bool
+ */
+ final public function getPositionAtTime( $time ) {
+ return $this->doGetPositionAtTime( $time );
+ }
+
+ /**
+ * @see FileJournal::getPositionAtTime()
+ * @param int|string $time Timestamp
+ * @return int|bool
+ */
+ abstract protected function doGetPositionAtTime( $time );
+
+ /**
+ * Get an array of file change log entries.
+ * A starting change ID and/or limit can be specified.
+ *
+ * @param int $start Starting change ID or null
+ * @param int $limit Maximum number of items to return
+ * @param string &$next Updated to the ID of the next entry.
+ * @return array List of associative arrays, each having:
+ * id : unique, monotonic, ID for this change
+ * batch_uuid : UUID for an operation batch
+ * backend : the backend name
+ * op : primitive operation (create,update,delete,null)
+ * path : affected storage path
+ * new_sha1 : base 36 sha1 of the new file had the operation succeeded
+ * timestamp : TS_MW timestamp of the batch change
+ * Also, $next is updated to the ID of the next entry.
+ */
+ final public function getChangeEntries( $start = null, $limit = 0, &$next = null ) {
+ $entries = $this->doGetChangeEntries( $start, $limit ? $limit + 1 : 0 );
+ if ( $limit && count( $entries ) > $limit ) {
+ $last = array_pop( $entries ); // remove the extra entry
+ $next = $last['id']; // update for next call
+ } else {
+ $next = null; // end of list
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @see FileJournal::getChangeEntries()
+ * @param int $start
+ * @param int $limit
+ * @return array
+ */
+ abstract protected function doGetChangeEntries( $start, $limit );
+
+ /**
+ * Purge any old log entries
+ *
+ * @return StatusValue
+ */
+ final public function purgeOldLogs() {
+ return $this->doPurgeOldLogs();
+ }
+
+ /**
+ * @see FileJournal::purgeOldLogs()
+ * @return StatusValue
+ */
+ abstract protected function doPurgeOldLogs();
+}
diff --git a/www/wiki/includes/libs/filebackend/filejournal/NullFileJournal.php b/www/wiki/includes/libs/filebackend/filejournal/NullFileJournal.php
new file mode 100644
index 00000000..8d472abf
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/filejournal/NullFileJournal.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Simple version of FileJournal that does nothing
+ * @since 1.20
+ */
+class NullFileJournal extends FileJournal {
+ /**
+ * @see FileJournal::doLogChangeBatch()
+ * @param array $entries
+ * @param string $batchId
+ * @return StatusValue
+ */
+ protected function doLogChangeBatch( array $entries, $batchId ) {
+ return StatusValue::newGood();
+ }
+
+ /**
+ * @see FileJournal::doGetCurrentPosition()
+ * @return int|bool
+ */
+ protected function doGetCurrentPosition() {
+ return false;
+ }
+
+ /**
+ * @see FileJournal::doGetPositionAtTime()
+ * @param int|string $time Timestamp
+ * @return int|bool
+ */
+ protected function doGetPositionAtTime( $time ) {
+ return false;
+ }
+
+ /**
+ * @see FileJournal::doGetChangeEntries()
+ * @param int $start
+ * @param int $limit
+ * @return array
+ */
+ protected function doGetChangeEntries( $start, $limit ) {
+ return [];
+ }
+
+ /**
+ * @see FileJournal::doPurgeOldLogs()
+ * @return StatusValue
+ */
+ protected function doPurgeOldLogs() {
+ return StatusValue::newGood();
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fileop/CopyFileOp.php b/www/wiki/includes/libs/filebackend/fileop/CopyFileOp.php
new file mode 100644
index 00000000..527de6a5
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fileop/CopyFileOp.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * Helper class for representing operations with transaction support.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Copy a file from one storage path to another in the backend.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class CopyFileOp extends FileOp {
+ protected function allowedParams() {
+ return [
+ [ 'src', 'dst' ],
+ [ 'overwrite', 'overwriteSame', 'ignoreMissingSource', 'headers' ],
+ [ 'src', 'dst' ]
+ ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = StatusValue::newGood();
+ // Check if the source file exists
+ if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
+ if ( $this->getParam( 'ignoreMissingSource' ) ) {
+ $this->doOperation = false; // no-op
+ // Update file existence predicates (cache 404s)
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+
+ return $status; // nothing to do
+ } else {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ }
+ // Check if a file can be placed/changed at the destination
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['dst'] );
+ $status->fatal( 'backend-fail-copy', $this->params['src'], $this->params['dst'] );
+
+ return $status;
+ }
+ // Check if destination file exists
+ $status->merge( $this->precheckDestExistence( $predicates ) );
+ $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache()
+ if ( $status->isOK() ) {
+ // Update file existence predicates
+ $predicates['exists'][$this->params['dst']] = true;
+ $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
+ }
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ if ( $this->overwriteSameCase ) {
+ $status = StatusValue::newGood(); // nothing to do
+ } elseif ( $this->params['src'] === $this->params['dst'] ) {
+ // Just update the destination file headers
+ $headers = $this->getParam( 'headers' ) ?: [];
+ $status = $this->backend->describeInternal( $this->setFlags( [
+ 'src' => $this->params['dst'], 'headers' => $headers
+ ] ) );
+ } else {
+ // Copy the file to the destination
+ $status = $this->backend->copyInternal( $this->setFlags( $this->params ) );
+ }
+
+ return $status;
+ }
+
+ public function storagePathsRead() {
+ return [ $this->params['src'] ];
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['dst'] ];
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fileop/CreateFileOp.php b/www/wiki/includes/libs/filebackend/fileop/CreateFileOp.php
new file mode 100644
index 00000000..f45b055c
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fileop/CreateFileOp.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Create a file in the backend with the given content.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class CreateFileOp extends FileOp {
+ protected function allowedParams() {
+ return [
+ [ 'content', 'dst' ],
+ [ 'overwrite', 'overwriteSame', 'headers' ],
+ [ 'dst' ]
+ ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = StatusValue::newGood();
+ // Check if the source data is too big
+ if ( strlen( $this->getParam( 'content' ) ) > $this->backend->maxFileSizeInternal() ) {
+ $status->fatal( 'backend-fail-maxsize',
+ $this->params['dst'], $this->backend->maxFileSizeInternal() );
+ $status->fatal( 'backend-fail-create', $this->params['dst'] );
+
+ return $status;
+ // Check if a file can be placed/changed at the destination
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['dst'] );
+ $status->fatal( 'backend-fail-create', $this->params['dst'] );
+
+ return $status;
+ }
+ // Check if destination file exists
+ $status->merge( $this->precheckDestExistence( $predicates ) );
+ $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache()
+ if ( $status->isOK() ) {
+ // Update file existence predicates
+ $predicates['exists'][$this->params['dst']] = true;
+ $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
+ }
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ if ( !$this->overwriteSameCase ) {
+ // Create the file at the destination
+ return $this->backend->createInternal( $this->setFlags( $this->params ) );
+ }
+
+ return StatusValue::newGood();
+ }
+
+ protected function getSourceSha1Base36() {
+ return Wikimedia\base_convert( sha1( $this->params['content'] ), 16, 36, 31 );
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['dst'] ];
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fileop/DeleteFileOp.php b/www/wiki/includes/libs/filebackend/fileop/DeleteFileOp.php
new file mode 100644
index 00000000..01f7df46
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fileop/DeleteFileOp.php
@@ -0,0 +1,71 @@
+<?php
+/**
+* Helper class for representing operations with transaction support.
+*
+* 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.
+* http://www.gnu.org/copyleft/gpl.html
+*
+* @file
+* @ingroup FileBackend
+*/
+
+/**
+ * Delete a file at the given storage path from the backend.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class DeleteFileOp extends FileOp {
+ protected function allowedParams() {
+ return [ [ 'src' ], [ 'ignoreMissingSource' ], [ 'src' ] ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = StatusValue::newGood();
+ // Check if the source file exists
+ if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
+ if ( $this->getParam( 'ignoreMissingSource' ) ) {
+ $this->doOperation = false; // no-op
+ // Update file existence predicates (cache 404s)
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+
+ return $status; // nothing to do
+ } else {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ }
+ // Check if a file can be placed/changed at the source
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['src'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['src'] );
+ $status->fatal( 'backend-fail-delete', $this->params['src'] );
+
+ return $status;
+ }
+ // Update file existence predicates
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ // Delete the source file
+ return $this->backend->deleteInternal( $this->setFlags( $this->params ) );
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['src'] ];
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fileop/DescribeFileOp.php b/www/wiki/includes/libs/filebackend/fileop/DescribeFileOp.php
new file mode 100644
index 00000000..0d1e5532
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fileop/DescribeFileOp.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Helper class for representing operations with transaction support.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Change metadata for a file at the given storage path in the backend.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class DescribeFileOp extends FileOp {
+ protected function allowedParams() {
+ return [ [ 'src' ], [ 'headers' ], [ 'src' ] ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = StatusValue::newGood();
+ // Check if the source file exists
+ if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ // Check if a file can be placed/changed at the source
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['src'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['src'] );
+ $status->fatal( 'backend-fail-describe', $this->params['src'] );
+
+ return $status;
+ }
+ // Update file existence predicates
+ $predicates['exists'][$this->params['src']] =
+ $this->fileExists( $this->params['src'], $predicates );
+ $predicates['sha1'][$this->params['src']] =
+ $this->fileSha1( $this->params['src'], $predicates );
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ // Update the source file's metadata
+ return $this->backend->describeInternal( $this->setFlags( $this->params ) );
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['src'] ];
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fileop/FileOp.php b/www/wiki/includes/libs/filebackend/fileop/FileOp.php
new file mode 100644
index 00000000..40af7aca
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fileop/FileOp.php
@@ -0,0 +1,469 @@
+<?php
+/**
+ * Helper class for representing operations with transaction support.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+use Psr\Log\LoggerInterface;
+
+/**
+ * FileBackend helper class for representing operations.
+ * Do not use this class from places outside FileBackend.
+ *
+ * Methods called from FileOpBatch::attempt() should avoid throwing
+ * exceptions at all costs. FileOp objects should be lightweight in order
+ * to support large arrays in memory and serialization.
+ *
+ * @ingroup FileBackend
+ * @since 1.19
+ */
+abstract class FileOp {
+ /** @var array */
+ protected $params = [];
+
+ /** @var FileBackendStore */
+ protected $backend;
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var int */
+ protected $state = self::STATE_NEW;
+
+ /** @var bool */
+ protected $failed = false;
+
+ /** @var bool */
+ protected $async = false;
+
+ /** @var string */
+ protected $batchId;
+
+ /** @var bool Operation is not a no-op */
+ protected $doOperation = true;
+
+ /** @var string */
+ protected $sourceSha1;
+
+ /** @var bool */
+ protected $overwriteSameCase;
+
+ /** @var bool */
+ protected $destExists;
+
+ /* Object life-cycle */
+ const STATE_NEW = 1;
+ const STATE_CHECKED = 2;
+ const STATE_ATTEMPTED = 3;
+
+ /**
+ * Build a new batch file operation transaction
+ *
+ * @param FileBackendStore $backend
+ * @param array $params
+ * @param LoggerInterface $logger PSR logger instance
+ * @throws FileBackendError
+ */
+ final public function __construct(
+ FileBackendStore $backend, array $params, LoggerInterface $logger
+ ) {
+ $this->backend = $backend;
+ $this->logger = $logger;
+ list( $required, $optional, $paths ) = $this->allowedParams();
+ foreach ( $required as $name ) {
+ if ( isset( $params[$name] ) ) {
+ $this->params[$name] = $params[$name];
+ } else {
+ throw new InvalidArgumentException( "File operation missing parameter '$name'." );
+ }
+ }
+ foreach ( $optional as $name ) {
+ if ( isset( $params[$name] ) ) {
+ $this->params[$name] = $params[$name];
+ }
+ }
+ foreach ( $paths as $name ) {
+ if ( isset( $this->params[$name] ) ) {
+ // Normalize paths so the paths to the same file have the same string
+ $this->params[$name] = self::normalizeIfValidStoragePath( $this->params[$name] );
+ }
+ }
+ }
+
+ /**
+ * Normalize a string if it is a valid storage path
+ *
+ * @param string $path
+ * @return string
+ */
+ protected static function normalizeIfValidStoragePath( $path ) {
+ if ( FileBackend::isStoragePath( $path ) ) {
+ $res = FileBackend::normalizeStoragePath( $path );
+
+ return ( $res !== null ) ? $res : $path;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Set the batch UUID this operation belongs to
+ *
+ * @param string $batchId
+ */
+ final public function setBatchId( $batchId ) {
+ $this->batchId = $batchId;
+ }
+
+ /**
+ * Get the value of the parameter with the given name
+ *
+ * @param string $name
+ * @return mixed Returns null if the parameter is not set
+ */
+ final public function getParam( $name ) {
+ return isset( $this->params[$name] ) ? $this->params[$name] : null;
+ }
+
+ /**
+ * Check if this operation failed precheck() or attempt()
+ *
+ * @return bool
+ */
+ final public function failed() {
+ return $this->failed;
+ }
+
+ /**
+ * Get a new empty predicates array for precheck()
+ *
+ * @return array
+ */
+ final public static function newPredicates() {
+ return [ 'exists' => [], 'sha1' => [] ];
+ }
+
+ /**
+ * Get a new empty dependency tracking array for paths read/written to
+ *
+ * @return array
+ */
+ final public static function newDependencies() {
+ return [ 'read' => [], 'write' => [] ];
+ }
+
+ /**
+ * Update a dependency tracking array to account for this operation
+ *
+ * @param array $deps Prior path reads/writes; format of FileOp::newPredicates()
+ * @return array
+ */
+ final public function applyDependencies( array $deps ) {
+ $deps['read'] += array_fill_keys( $this->storagePathsRead(), 1 );
+ $deps['write'] += array_fill_keys( $this->storagePathsChanged(), 1 );
+
+ return $deps;
+ }
+
+ /**
+ * Check if this operation changes files listed in $paths
+ *
+ * @param array $deps Prior path reads/writes; format of FileOp::newPredicates()
+ * @return bool
+ */
+ final public function dependsOn( array $deps ) {
+ foreach ( $this->storagePathsChanged() as $path ) {
+ if ( isset( $deps['read'][$path] ) || isset( $deps['write'][$path] ) ) {
+ return true; // "output" or "anti" dependency
+ }
+ }
+ foreach ( $this->storagePathsRead() as $path ) {
+ if ( isset( $deps['write'][$path] ) ) {
+ return true; // "flow" dependency
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the file journal entries for this file operation
+ *
+ * @param array $oPredicates Pre-op info about files (format of FileOp::newPredicates)
+ * @param array $nPredicates Post-op info about files (format of FileOp::newPredicates)
+ * @return array
+ */
+ final public function getJournalEntries( array $oPredicates, array $nPredicates ) {
+ if ( !$this->doOperation ) {
+ return []; // this is a no-op
+ }
+ $nullEntries = [];
+ $updateEntries = [];
+ $deleteEntries = [];
+ $pathsUsed = array_merge( $this->storagePathsRead(), $this->storagePathsChanged() );
+ foreach ( array_unique( $pathsUsed ) as $path ) {
+ $nullEntries[] = [ // assertion for recovery
+ 'op' => 'null',
+ 'path' => $path,
+ 'newSha1' => $this->fileSha1( $path, $oPredicates )
+ ];
+ }
+ foreach ( $this->storagePathsChanged() as $path ) {
+ if ( $nPredicates['sha1'][$path] === false ) { // deleted
+ $deleteEntries[] = [
+ 'op' => 'delete',
+ 'path' => $path,
+ 'newSha1' => ''
+ ];
+ } else { // created/updated
+ $updateEntries[] = [
+ 'op' => $this->fileExists( $path, $oPredicates ) ? 'update' : 'create',
+ 'path' => $path,
+ 'newSha1' => $nPredicates['sha1'][$path]
+ ];
+ }
+ }
+
+ return array_merge( $nullEntries, $updateEntries, $deleteEntries );
+ }
+
+ /**
+ * Check preconditions of the operation without writing anything.
+ * This must update $predicates for each path that the op can change
+ * except when a failing StatusValue object is returned.
+ *
+ * @param array &$predicates
+ * @return StatusValue
+ */
+ final public function precheck( array &$predicates ) {
+ if ( $this->state !== self::STATE_NEW ) {
+ return StatusValue::newFatal( 'fileop-fail-state', self::STATE_NEW, $this->state );
+ }
+ $this->state = self::STATE_CHECKED;
+ $status = $this->doPrecheck( $predicates );
+ if ( !$status->isOK() ) {
+ $this->failed = true;
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param array &$predicates
+ * @return StatusValue
+ */
+ protected function doPrecheck( array &$predicates ) {
+ return StatusValue::newGood();
+ }
+
+ /**
+ * Attempt the operation
+ *
+ * @return StatusValue
+ */
+ final public function attempt() {
+ if ( $this->state !== self::STATE_CHECKED ) {
+ return StatusValue::newFatal( 'fileop-fail-state', self::STATE_CHECKED, $this->state );
+ } elseif ( $this->failed ) { // failed precheck
+ return StatusValue::newFatal( 'fileop-fail-attempt-precheck' );
+ }
+ $this->state = self::STATE_ATTEMPTED;
+ if ( $this->doOperation ) {
+ $status = $this->doAttempt();
+ if ( !$status->isOK() ) {
+ $this->failed = true;
+ $this->logFailure( 'attempt' );
+ }
+ } else { // no-op
+ $status = StatusValue::newGood();
+ }
+
+ return $status;
+ }
+
+ /**
+ * @return StatusValue
+ */
+ protected function doAttempt() {
+ return StatusValue::newGood();
+ }
+
+ /**
+ * Attempt the operation in the background
+ *
+ * @return StatusValue
+ */
+ final public function attemptAsync() {
+ $this->async = true;
+ $result = $this->attempt();
+ $this->async = false;
+
+ return $result;
+ }
+
+ /**
+ * Get the file operation parameters
+ *
+ * @return array (required params list, optional params list, list of params that are paths)
+ */
+ protected function allowedParams() {
+ return [ [], [], [] ];
+ }
+
+ /**
+ * Adjust params to FileBackendStore internal file calls
+ *
+ * @param array $params
+ * @return array (required params list, optional params list)
+ */
+ protected function setFlags( array $params ) {
+ return [ 'async' => $this->async ] + $params;
+ }
+
+ /**
+ * Get a list of storage paths read from for this operation
+ *
+ * @return array
+ */
+ public function storagePathsRead() {
+ return [];
+ }
+
+ /**
+ * Get a list of storage paths written to for this operation
+ *
+ * @return array
+ */
+ public function storagePathsChanged() {
+ return [];
+ }
+
+ /**
+ * Check for errors with regards to the destination file already existing.
+ * Also set the destExists, overwriteSameCase and sourceSha1 member variables.
+ * A bad StatusValue will be returned if there is no chance it can be overwritten.
+ *
+ * @param array $predicates
+ * @return StatusValue
+ */
+ protected function precheckDestExistence( array $predicates ) {
+ $status = StatusValue::newGood();
+ // Get hash of source file/string and the destination file
+ $this->sourceSha1 = $this->getSourceSha1Base36(); // FS file or data string
+ if ( $this->sourceSha1 === null ) { // file in storage?
+ $this->sourceSha1 = $this->fileSha1( $this->params['src'], $predicates );
+ }
+ $this->overwriteSameCase = false;
+ $this->destExists = $this->fileExists( $this->params['dst'], $predicates );
+ if ( $this->destExists ) {
+ if ( $this->getParam( 'overwrite' ) ) {
+ return $status; // OK
+ } elseif ( $this->getParam( 'overwriteSame' ) ) {
+ $dhash = $this->fileSha1( $this->params['dst'], $predicates );
+ // Check if hashes are valid and match each other...
+ if ( !strlen( $this->sourceSha1 ) || !strlen( $dhash ) ) {
+ $status->fatal( 'backend-fail-hashes' );
+ } elseif ( $this->sourceSha1 !== $dhash ) {
+ // Give an error if the files are not identical
+ $status->fatal( 'backend-fail-notsame', $this->params['dst'] );
+ } else {
+ $this->overwriteSameCase = true; // OK
+ }
+
+ return $status; // do nothing; either OK or bad status
+ } else {
+ $status->fatal( 'backend-fail-alreadyexists', $this->params['dst'] );
+
+ return $status;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * precheckDestExistence() helper function to get the source file SHA-1.
+ * Subclasses should overwride this if the source is not in storage.
+ *
+ * @return string|bool Returns false on failure
+ */
+ protected function getSourceSha1Base36() {
+ return null; // N/A
+ }
+
+ /**
+ * Check if a file will exist in storage when this operation is attempted
+ *
+ * @param string $source Storage path
+ * @param array $predicates
+ * @return bool
+ */
+ final protected function fileExists( $source, array $predicates ) {
+ if ( isset( $predicates['exists'][$source] ) ) {
+ return $predicates['exists'][$source]; // previous op assures this
+ } else {
+ $params = [ 'src' => $source, 'latest' => true ];
+
+ return $this->backend->fileExists( $params );
+ }
+ }
+
+ /**
+ * Get the SHA-1 of a file in storage when this operation is attempted
+ *
+ * @param string $source Storage path
+ * @param array $predicates
+ * @return string|bool False on failure
+ */
+ final protected function fileSha1( $source, array $predicates ) {
+ if ( isset( $predicates['sha1'][$source] ) ) {
+ return $predicates['sha1'][$source]; // previous op assures this
+ } elseif ( isset( $predicates['exists'][$source] ) && !$predicates['exists'][$source] ) {
+ return false; // previous op assures this
+ } else {
+ $params = [ 'src' => $source, 'latest' => true ];
+
+ return $this->backend->getFileSha1Base36( $params );
+ }
+ }
+
+ /**
+ * Get the backend this operation is for
+ *
+ * @return FileBackendStore
+ */
+ public function getBackend() {
+ return $this->backend;
+ }
+
+ /**
+ * Log a file operation failure and preserve any temp files
+ *
+ * @param string $action
+ */
+ final public function logFailure( $action ) {
+ $params = $this->params;
+ $params['failedAction'] = $action;
+ try {
+ $this->logger->error( static::class .
+ " failed (batch #{$this->batchId}): " . FormatJson::encode( $params ) );
+ } catch ( Exception $e ) {
+ // bad config? debug log error?
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fileop/MoveFileOp.php b/www/wiki/includes/libs/filebackend/fileop/MoveFileOp.php
new file mode 100644
index 00000000..55dca516
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fileop/MoveFileOp.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * Helper class for representing operations with transaction support.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Move a file from one storage path to another in the backend.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class MoveFileOp extends FileOp {
+ protected function allowedParams() {
+ return [
+ [ 'src', 'dst' ],
+ [ 'overwrite', 'overwriteSame', 'ignoreMissingSource', 'headers' ],
+ [ 'src', 'dst' ]
+ ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = StatusValue::newGood();
+ // Check if the source file exists
+ if ( !$this->fileExists( $this->params['src'], $predicates ) ) {
+ if ( $this->getParam( 'ignoreMissingSource' ) ) {
+ $this->doOperation = false; // no-op
+ // Update file existence predicates (cache 404s)
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+
+ return $status; // nothing to do
+ } else {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ }
+ // Check if a file can be placed/changed at the destination
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['dst'] );
+ $status->fatal( 'backend-fail-move', $this->params['src'], $this->params['dst'] );
+
+ return $status;
+ }
+ // Check if destination file exists
+ $status->merge( $this->precheckDestExistence( $predicates ) );
+ $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache()
+ if ( $status->isOK() ) {
+ // Update file existence predicates
+ $predicates['exists'][$this->params['src']] = false;
+ $predicates['sha1'][$this->params['src']] = false;
+ $predicates['exists'][$this->params['dst']] = true;
+ $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
+ }
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ if ( $this->overwriteSameCase ) {
+ if ( $this->params['src'] === $this->params['dst'] ) {
+ // Do nothing to the destination (which is also the source)
+ $status = StatusValue::newGood();
+ } else {
+ // Just delete the source as the destination file needs no changes
+ $status = $this->backend->deleteInternal( $this->setFlags(
+ [ 'src' => $this->params['src'] ]
+ ) );
+ }
+ } elseif ( $this->params['src'] === $this->params['dst'] ) {
+ // Just update the destination file headers
+ $headers = $this->getParam( 'headers' ) ?: [];
+ $status = $this->backend->describeInternal( $this->setFlags(
+ [ 'src' => $this->params['dst'], 'headers' => $headers ]
+ ) );
+ } else {
+ // Move the file to the destination
+ $status = $this->backend->moveInternal( $this->setFlags( $this->params ) );
+ }
+
+ return $status;
+ }
+
+ public function storagePathsRead() {
+ return [ $this->params['src'] ];
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['src'], $this->params['dst'] ];
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fileop/NullFileOp.php b/www/wiki/includes/libs/filebackend/fileop/NullFileOp.php
new file mode 100644
index 00000000..91217596
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fileop/NullFileOp.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * Helper class for representing operations with transaction support.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Placeholder operation that has no params and does nothing
+ */
+class NullFileOp extends FileOp {
+}
diff --git a/www/wiki/includes/libs/filebackend/fileop/StoreFileOp.php b/www/wiki/includes/libs/filebackend/fileop/StoreFileOp.php
new file mode 100644
index 00000000..bba762f0
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fileop/StoreFileOp.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Helper class for representing operations with transaction support.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Store a file into the backend from a file on the file system.
+ * Parameters for this operation are outlined in FileBackend::doOperations().
+ */
+class StoreFileOp extends FileOp {
+ protected function allowedParams() {
+ return [
+ [ 'src', 'dst' ],
+ [ 'overwrite', 'overwriteSame', 'headers' ],
+ [ 'src', 'dst' ]
+ ];
+ }
+
+ protected function doPrecheck( array &$predicates ) {
+ $status = StatusValue::newGood();
+ // Check if the source file exists on the file system
+ if ( !is_file( $this->params['src'] ) ) {
+ $status->fatal( 'backend-fail-notexists', $this->params['src'] );
+
+ return $status;
+ // Check if the source file is too big
+ } elseif ( filesize( $this->params['src'] ) > $this->backend->maxFileSizeInternal() ) {
+ $status->fatal( 'backend-fail-maxsize',
+ $this->params['dst'], $this->backend->maxFileSizeInternal() );
+ $status->fatal( 'backend-fail-store', $this->params['src'], $this->params['dst'] );
+
+ return $status;
+ // Check if a file can be placed/changed at the destination
+ } elseif ( !$this->backend->isPathUsableInternal( $this->params['dst'] ) ) {
+ $status->fatal( 'backend-fail-usable', $this->params['dst'] );
+ $status->fatal( 'backend-fail-store', $this->params['src'], $this->params['dst'] );
+
+ return $status;
+ }
+ // Check if destination file exists
+ $status->merge( $this->precheckDestExistence( $predicates ) );
+ $this->params['dstExists'] = $this->destExists; // see FileBackendStore::setFileCache()
+ if ( $status->isOK() ) {
+ // Update file existence predicates
+ $predicates['exists'][$this->params['dst']] = true;
+ $predicates['sha1'][$this->params['dst']] = $this->sourceSha1;
+ }
+
+ return $status; // safe to call attempt()
+ }
+
+ protected function doAttempt() {
+ if ( !$this->overwriteSameCase ) {
+ // Store the file at the destination
+ return $this->backend->storeInternal( $this->setFlags( $this->params ) );
+ }
+
+ return StatusValue::newGood();
+ }
+
+ protected function getSourceSha1Base36() {
+ MediaWiki\suppressWarnings();
+ $hash = sha1_file( $this->params['src'] );
+ MediaWiki\restoreWarnings();
+ if ( $hash !== false ) {
+ $hash = Wikimedia\base_convert( $hash, 16, 36, 31 );
+ }
+
+ return $hash;
+ }
+
+ public function storagePathsChanged() {
+ return [ $this->params['dst'] ];
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fsfile/FSFile.php b/www/wiki/includes/libs/filebackend/fsfile/FSFile.php
new file mode 100644
index 00000000..dacad1cb
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fsfile/FSFile.php
@@ -0,0 +1,223 @@
+<?php
+/**
+ * Non-directory file on the file system.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * Class representing a non-directory file on the file system
+ *
+ * @ingroup FileBackend
+ */
+class FSFile {
+ /** @var string Path to file */
+ protected $path;
+
+ /** @var string File SHA-1 in base 36 */
+ protected $sha1Base36;
+
+ /**
+ * Sets up the file object
+ *
+ * @param string $path Path to temporary file on local disk
+ */
+ public function __construct( $path ) {
+ $this->path = $path;
+ }
+
+ /**
+ * Returns the file system path
+ *
+ * @return string
+ */
+ public function getPath() {
+ return $this->path;
+ }
+
+ /**
+ * Checks if the file exists
+ *
+ * @return bool
+ */
+ public function exists() {
+ return is_file( $this->path );
+ }
+
+ /**
+ * Get the file size in bytes
+ *
+ * @return int|bool
+ */
+ public function getSize() {
+ return filesize( $this->path );
+ }
+
+ /**
+ * Get the file's last-modified timestamp
+ *
+ * @return string|bool TS_MW timestamp or false on failure
+ */
+ public function getTimestamp() {
+ MediaWiki\suppressWarnings();
+ $timestamp = filemtime( $this->path );
+ MediaWiki\restoreWarnings();
+ if ( $timestamp !== false ) {
+ $timestamp = wfTimestamp( TS_MW, $timestamp );
+ }
+
+ return $timestamp;
+ }
+
+ /**
+ * Get an associative array containing information about
+ * a file with the given storage path.
+ *
+ * Resulting array fields include:
+ * - fileExists
+ * - size (filesize in bytes)
+ * - mime (as major/minor)
+ * - file-mime (as major/minor)
+ * - sha1 (in base 36)
+ * - major_mime
+ * - minor_mime
+ *
+ * @param string|bool $ext The file extension, or true to extract it from the filename.
+ * Set it to false to ignore the extension. Currently unused.
+ * @return array
+ */
+ public function getProps( $ext = true ) {
+ $info = self::placeholderProps();
+ $info['fileExists'] = $this->exists();
+
+ if ( $info['fileExists'] ) {
+ $info['size'] = $this->getSize(); // bytes
+ $info['sha1'] = $this->getSha1Base36();
+
+ $mime = mime_content_type( $this->path );
+ # MIME type according to file contents
+ $info['file-mime'] = ( $mime === false ) ? 'unknown/unknown' : $mime;
+ # logical MIME type
+ $info['mime'] = $mime;
+
+ if ( strpos( $mime, '/' ) !== false ) {
+ list( $info['major_mime'], $info['minor_mime'] ) = explode( '/', $mime, 2 );
+ } else {
+ list( $info['major_mime'], $info['minor_mime'] ) = [ $mime, 'unknown' ];
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * Placeholder file properties to use for files that don't exist
+ *
+ * Resulting array fields include:
+ * - fileExists
+ * - size (filesize in bytes)
+ * - mime (as major/minor)
+ * - file-mime (as major/minor)
+ * - sha1 (in base 36)
+ * - major_mime
+ * - minor_mime
+ *
+ * @return array
+ */
+ public static function placeholderProps() {
+ $info = [];
+ $info['fileExists'] = false;
+ $info['size'] = 0;
+ $info['file-mime'] = null;
+ $info['major_mime'] = null;
+ $info['minor_mime'] = null;
+ $info['mime'] = null;
+ $info['sha1'] = '';
+
+ return $info;
+ }
+
+ /**
+ * Get a SHA-1 hash of a file in the local filesystem, in base-36 lower case
+ * encoding, zero padded to 31 digits.
+ *
+ * 160 log 2 / log 36 = 30.95, so the 160-bit hash fills 31 digits in base 36
+ * fairly neatly.
+ *
+ * @param bool $recache
+ * @return bool|string False on failure
+ */
+ public function getSha1Base36( $recache = false ) {
+ if ( $this->sha1Base36 !== null && !$recache ) {
+ return $this->sha1Base36;
+ }
+
+ MediaWiki\suppressWarnings();
+ $this->sha1Base36 = sha1_file( $this->path );
+ MediaWiki\restoreWarnings();
+
+ if ( $this->sha1Base36 !== false ) {
+ $this->sha1Base36 = Wikimedia\base_convert( $this->sha1Base36, 16, 36, 31 );
+ }
+
+ return $this->sha1Base36;
+ }
+
+ /**
+ * Get the final file extension from a file system path
+ *
+ * @param string $path
+ * @return string
+ */
+ public static function extensionFromPath( $path ) {
+ $i = strrpos( $path, '.' );
+
+ return strtolower( $i ? substr( $path, $i + 1 ) : '' );
+ }
+
+ /**
+ * Get an associative array containing information about a file in the local filesystem.
+ *
+ * @param string $path Absolute local filesystem path
+ * @param string|bool $ext The file extension, or true to extract it from the filename.
+ * Set it to false to ignore the extension.
+ * @return array
+ */
+ public static function getPropsFromPath( $path, $ext = true ) {
+ $fsFile = new self( $path );
+
+ return $fsFile->getProps( $ext );
+ }
+
+ /**
+ * Get a SHA-1 hash of a file in the local filesystem, in base-36 lower case
+ * encoding, zero padded to 31 digits.
+ *
+ * 160 log 2 / log 36 = 30.95, so the 160-bit hash fills 31 digits in base 36
+ * fairly neatly.
+ *
+ * @param string $path
+ * @return bool|string False on failure
+ */
+ public static function getSha1Base36FromPath( $path ) {
+ $fsFile = new self( $path );
+
+ return $fsFile->getSha1Base36();
+ }
+}
diff --git a/www/wiki/includes/libs/filebackend/fsfile/TempFSFile.php b/www/wiki/includes/libs/filebackend/fsfile/TempFSFile.php
new file mode 100644
index 00000000..fed6812f
--- /dev/null
+++ b/www/wiki/includes/libs/filebackend/fsfile/TempFSFile.php
@@ -0,0 +1,196 @@
+<?php
+/**
+ * Location holder of files stored temporarily
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup FileBackend
+ */
+
+/**
+ * This class is used to hold the location and do limited manipulation
+ * of files stored temporarily (this will be whatever wfTempDir() returns)
+ *
+ * @ingroup FileBackend
+ */
+class TempFSFile extends FSFile {
+ /** @var bool Garbage collect the temp file */
+ protected $canDelete = false;
+
+ /** @var array Map of (path => 1) for paths to delete on shutdown */
+ protected static $pathsCollect = null;
+
+ public function __construct( $path ) {
+ parent::__construct( $path );
+
+ if ( self::$pathsCollect === null ) {
+ self::$pathsCollect = [];
+ register_shutdown_function( [ __CLASS__, 'purgeAllOnShutdown' ] );
+ }
+ }
+
+ /**
+ * Make a new temporary file on the file system.
+ * Temporary files may be purged when the file object falls out of scope.
+ *
+ * @param string $prefix
+ * @param string $extension Optional file extension
+ * @param string|null $tmpDirectory Optional parent directory
+ * @return TempFSFile|null
+ */
+ public static function factory( $prefix, $extension = '', $tmpDirectory = null ) {
+ $ext = ( $extension != '' ) ? ".{$extension}" : '';
+
+ $attempts = 5;
+ while ( $attempts-- ) {
+ $hex = sprintf( '%06x%06x', mt_rand( 0, 0xffffff ), mt_rand( 0, 0xffffff ) );
+ if ( !is_string( $tmpDirectory ) ) {
+ $tmpDirectory = self::getUsableTempDirectory();
+ }
+ $path = wfTempDir() . '/' . $prefix . $hex . $ext;
+ MediaWiki\suppressWarnings();
+ $newFileHandle = fopen( $path, 'x' );
+ MediaWiki\restoreWarnings();
+ if ( $newFileHandle ) {
+ fclose( $newFileHandle );
+ $tmpFile = new self( $path );
+ $tmpFile->autocollect();
+ // Safely instantiated, end loop.
+ return $tmpFile;
+ }
+ }
+
+ // Give up
+ return null;
+ }
+
+ /**
+ * @return string Filesystem path to a temporary directory
+ * @throws RuntimeException
+ */
+ public static function getUsableTempDirectory() {
+ $tmpDir = array_map( 'getenv', [ 'TMPDIR', 'TMP', 'TEMP' ] );
+ $tmpDir[] = sys_get_temp_dir();
+ $tmpDir[] = ini_get( 'upload_tmp_dir' );
+ foreach ( $tmpDir as $tmp ) {
+ if ( $tmp != '' && is_dir( $tmp ) && is_writable( $tmp ) ) {
+ return $tmp;
+ }
+ }
+
+ // PHP on Windows will detect C:\Windows\Temp as not writable even though PHP can write to
+ // it so create a directory within that called 'mwtmp' with a suffix of the user running
+ // the current process.
+ // The user is included as if various scripts are run by different users they will likely
+ // not be able to access each others temporary files.
+ if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
+ $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'mwtmp-' . get_current_user();
+ if ( !file_exists( $tmp ) ) {
+ mkdir( $tmp );
+ }
+ if ( is_dir( $tmp ) && is_writable( $tmp ) ) {
+ return $tmp;
+ }
+ }
+
+ throw new RuntimeException(
+ 'No writable temporary directory could be found. ' .
+ 'Please explicitly specify a writable directory in configuration.' );
+ }
+
+ /**
+ * Purge this file off the file system
+ *
+ * @return bool Success
+ */
+ public function purge() {
+ $this->canDelete = false; // done
+ MediaWiki\suppressWarnings();
+ $ok = unlink( $this->path );
+ MediaWiki\restoreWarnings();
+
+ unset( self::$pathsCollect[$this->path] );
+
+ return $ok;
+ }
+
+ /**
+ * Clean up the temporary file only after an object goes out of scope
+ *
+ * @param object $object
+ * @return TempFSFile This object
+ */
+ public function bind( $object ) {
+ if ( is_object( $object ) ) {
+ if ( !isset( $object->tempFSFileReferences ) ) {
+ // Init first since $object might use __get() and return only a copy variable
+ $object->tempFSFileReferences = [];
+ }
+ $object->tempFSFileReferences[] = $this;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Set flag to not clean up after the temporary file
+ *
+ * @return TempFSFile This object
+ */
+ public function preserve() {
+ $this->canDelete = false;
+
+ unset( self::$pathsCollect[$this->path] );
+
+ return $this;
+ }
+
+ /**
+ * Set flag clean up after the temporary file
+ *
+ * @return TempFSFile This object
+ */
+ public function autocollect() {
+ $this->canDelete = true;
+
+ self::$pathsCollect[$this->path] = 1;
+
+ return $this;
+ }
+
+ /**
+ * Try to make sure that all files are purged on error
+ *
+ * This method should only be called internally
+ */
+ public static function purgeAllOnShutdown() {
+ foreach ( self::$pathsCollect as $path ) {
+ MediaWiki\suppressWarnings();
+ unlink( $path );
+ MediaWiki\restoreWarnings();
+ }
+ }
+
+ /**
+ * Cleans up after the temporary file by deleting it
+ */
+ function __destruct() {
+ if ( $this->canDelete ) {
+ $this->purge();
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/http/HttpAcceptNegotiator.php b/www/wiki/includes/libs/http/HttpAcceptNegotiator.php
new file mode 100644
index 00000000..5f8d9a69
--- /dev/null
+++ b/www/wiki/includes/libs/http/HttpAcceptNegotiator.php
@@ -0,0 +1,139 @@
+<?php
+
+/**
+ * Utility for negotiating a value from a set of supported values using a preference list.
+ * This is intended for use with HTTP headers like Accept, Accept-Language, Accept-Encoding, etc.
+ * See RFC 2616 section 14 for details.
+ *
+ * To use this with a request header, first parse the header value into an array of weights
+ * using HttpAcceptParser, then call getBestSupportedKey.
+ *
+ * @license GPL-2.0+
+ * @author Daniel Kinzler
+ * @author Thiemo Mättig
+ */
+
+namespace Wikimedia\Http;
+
+class HttpAcceptNegotiator {
+
+ /**
+ * @var string[]
+ */
+ private $supportedValues;
+
+ /**
+ * @var string
+ */
+ private $defaultValue;
+
+ /**
+ * @param string[] $supported A list of supported values.
+ */
+ public function __construct( array $supported ) {
+ $this->supportedValues = $supported;
+ $this->defaultValue = reset( $supported );
+ }
+
+ /**
+ * Returns the best supported key from the given weight map. Of the keys from the
+ * $weights parameter that are also in the list of supported values supplied to
+ * the constructor, this returns the key that has the highest weight associated
+ * with it. If two keys have the same weight, the more specific key is preferred,
+ * as required by RFC2616 section 14. Keys that map to 0 or false are ignored.
+ * If no matching key is found, $default is returned.
+ *
+ * @param float[] $weights An associative array mapping accepted values to their
+ * respective weights.
+ *
+ * @param null|string $default The value to return if non of the keys in $weights
+ * is supported (null per default).
+ *
+ * @return null|string The best supported key from the $weights parameter.
+ */
+ public function getBestSupportedKey( array $weights, $default = null ) {
+ // Make sure we correctly bias against wildcards and ranges, see RFC2616, section 14.
+ foreach ( $weights as $name => &$weight ) {
+ if ( $name === '*' || $name === '*/*' ) {
+ $weight -= 0.000002;
+ } elseif ( substr( $name, -2 ) === '/*' ) {
+ $weight -= 0.000001;
+ }
+ }
+
+ // Sort $weights by value and...
+ asort( $weights );
+
+ // remove any keys with values equal to 0 or false (HTTP/1.1 section 3.9)
+ $weights = array_filter( $weights );
+
+ // ...use the ordered list of keys
+ $preferences = array_reverse( array_keys( $weights ) );
+
+ $value = $this->getFirstSupportedValue( $preferences, $default );
+ return $value;
+ }
+
+ /**
+ * Returns the first supported value from the given preference list. Of the values from
+ * the $preferences parameter that are also in the list of supported values supplied
+ * to the constructor, this returns the value that has the lowest index in the list.
+ * If no such value is found, $default is returned.
+ *
+ * @param string[] $preferences A list of acceptable values, in order of preference.
+ *
+ * @param null|string $default The value to return if non of the keys in $weights
+ * is supported (null per default).
+ *
+ * @return null|string The best supported key from the $weights parameter.
+ */
+ public function getFirstSupportedValue( array $preferences, $default = null ) {
+ foreach ( $preferences as $value ) {
+ foreach ( $this->supportedValues as $supported ) {
+ if ( $this->valueMatches( $value, $supported ) ) {
+ return $supported;
+ }
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * Returns true if the given acceptable value matches the given supported value,
+ * according to the HTTP specification. The following rules are used:
+ *
+ * - comparison is case-insensitive
+ * - if $accepted and $supported are equal, they match
+ * - if $accepted is `*` or `*` followed by `/*`, it matches any $supported value.
+ * - if both $accepted and $supported contain a `/`, and $accepted ends with `/*`,
+ * they match if the part before the first `/` is equal.
+ *
+ * @param string $accepted An accepted value (may contain wildcards)
+ * @param string $supported A supported value.
+ *
+ * @return bool Whether the given supported value matches the given accepted value.
+ */
+ private function valueMatches( $accepted, $supported ) {
+ // RDF 2045: MIME types are case insensitive.
+ // full match
+ if ( strcasecmp( $accepted, $supported ) === 0 ) {
+ return true;
+ }
+
+ // wildcard match (HTTP/1.1 section 14.1, 14.2, 14.3)
+ if ( $accepted === '*' || $accepted === '*/*' ) {
+ return true;
+ }
+
+ // wildcard match (HTTP/1.1 section 14.1)
+ if ( substr( $accepted, -2 ) === '/*'
+ && strncasecmp( $accepted, $supported, strlen( $accepted ) - 2 ) === 0
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+}
diff --git a/www/wiki/includes/libs/http/HttpAcceptParser.php b/www/wiki/includes/libs/http/HttpAcceptParser.php
new file mode 100644
index 00000000..bce071e7
--- /dev/null
+++ b/www/wiki/includes/libs/http/HttpAcceptParser.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * Utility for parsing a HTTP Accept header value into a weight map. May also be used with
+ * other, similar headers like Accept-Language, Accept-Encoding, etc.
+ *
+ * @license GPL-2.0+
+ * @author Daniel Kinzler
+ */
+
+namespace Wikimedia\Http;
+
+class HttpAcceptParser {
+
+ /**
+ * Parses an HTTP header into a weight map, that is an associative array
+ * mapping values to their respective weights. Any header name preceding
+ * weight spec is ignored for convenience.
+ *
+ * This implementation is partially based on the code at
+ * http://www.thefutureoftheweb.com/blog/use-accept-language-header
+ *
+ * Note that type parameters and accept extension like the "level" parameter
+ * are not supported, weights are derived from "q" values only.
+ *
+ * @todo: If additional type parameters are present, ignore them cleanly.
+ * At present, they often confuse the result.
+ *
+ * See HTTP/1.1 section 14 for details.
+ *
+ * @param string $rawHeader
+ *
+ * @return array
+ */
+ public function parseWeights( $rawHeader ) {
+ //FIXME: The code below was copied and adapted from WebRequest::getAcceptLang.
+ // Move this utility class into core for reuse!
+
+ // first, strip header name
+ $rawHeader = preg_replace( '/^[-\w]+:\s*/', '', $rawHeader );
+
+ // Return values in lower case
+ $rawHeader = strtolower( $rawHeader );
+
+ // Break up string into pieces (values and q factors)
+ $value_parse = null;
+ preg_match_all( '@([a-z\d*]+([-+/.][a-z\d*]+)*)\s*(;\s*q\s*=\s*(1(\.0{0,3})?|0(\.\d{0,3})?)?)?@',
+ $rawHeader, $value_parse );
+
+ if ( !count( $value_parse[1] ) ) {
+ return [];
+ }
+
+ $values = $value_parse[1];
+ $qvalues = $value_parse[4];
+ $indices = range( 0, count( $value_parse[1] ) - 1 );
+
+ // Set default q factor to 1
+ foreach ( $indices as $index ) {
+ if ( $qvalues[$index] === '' ) {
+ $qvalues[$index] = 1;
+ } elseif ( $qvalues[$index] == 0 ) {
+ unset( $values[$index], $qvalues[$index], $indices[$index] );
+ } else {
+ $qvalues[$index] = (float)$qvalues[$index];
+ }
+ }
+
+ // Sort list. First by $qvalues, then by order. Reorder $values the same way
+ array_multisort( $qvalues, SORT_DESC, SORT_NUMERIC, $indices, $values );
+
+ // Create a list like "en" => 0.8
+ $weights = array_combine( $values, $qvalues );
+
+ return $weights;
+ }
+
+}
diff --git a/www/wiki/includes/libs/iterators/IteratorDecorator.php b/www/wiki/includes/libs/iterators/IteratorDecorator.php
new file mode 100644
index 00000000..c1b50207
--- /dev/null
+++ b/www/wiki/includes/libs/iterators/IteratorDecorator.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * Allows extending classes to decorate an Iterator with
+ * reduced boilerplate.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+abstract class IteratorDecorator implements Iterator {
+ protected $iterator;
+
+ public function __construct( Iterator $iterator ) {
+ $this->iterator = $iterator;
+ }
+
+ public function current() {
+ return $this->iterator->current();
+ }
+
+ public function key() {
+ return $this->iterator->key();
+ }
+
+ public function next() {
+ $this->iterator->next();
+ }
+
+ public function rewind() {
+ $this->iterator->rewind();
+ }
+
+ public function valid() {
+ return $this->iterator->valid();
+ }
+}
diff --git a/www/wiki/includes/libs/iterators/NotRecursiveIterator.php b/www/wiki/includes/libs/iterators/NotRecursiveIterator.php
new file mode 100644
index 00000000..52ca61b4
--- /dev/null
+++ b/www/wiki/includes/libs/iterators/NotRecursiveIterator.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Wraps a non-recursive iterator with methods to be recursive
+ * without children.
+ *
+ * Alternatively wraps a recursive iterator to prevent recursing deeper
+ * than the wrapped iterator.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+class NotRecursiveIterator extends IteratorDecorator implements RecursiveIterator {
+ public function hasChildren() {
+ return false;
+ }
+
+ public function getChildren() {
+ return null;
+ }
+}
diff --git a/www/wiki/includes/libs/jsminplus.php b/www/wiki/includes/libs/jsminplus.php
new file mode 100644
index 00000000..7feac7d1
--- /dev/null
+++ b/www/wiki/includes/libs/jsminplus.php
@@ -0,0 +1,2132 @@
+<?php
+// @codingStandardsIgnoreFile File external to MediaWiki. Ignore coding conventions checks.
+/**
+ * JSMinPlus version 1.4
+ *
+ * Minifies a javascript file using a javascript parser
+ *
+ * This implements a PHP port of Brendan Eich's Narcissus open source javascript engine (in javascript)
+ * References: https://en.wikipedia.org/wiki/Narcissus_(JavaScript_engine)
+ * Narcissus sourcecode: https://mxr.mozilla.org/mozilla/source/js/narcissus/
+ * JSMinPlus weblog: https://crisp.tweakblogs.net/blog/cat/716
+ *
+ * Tino Zijdel <crisp@tweakers.net>
+ *
+ * Usage: $minified = JSMinPlus::minify($script [, $filename])
+ *
+ * Versionlog (see also changelog.txt):
+ * 23-07-2011 - remove dynamic creation of OP_* and KEYWORD_* defines and declare them on top
+ * reduce memory footprint by minifying by block-scope
+ * some small byte-saving and performance improvements
+ * 12-05-2009 - fixed hook:colon precedence, fixed empty body in loop and if-constructs
+ * 18-04-2009 - fixed crashbug in PHP 5.2.9 and several other bugfixes
+ * 12-04-2009 - some small bugfixes and performance improvements
+ * 09-04-2009 - initial open sourced version 1.0
+ *
+ * Latest version of this script: http://files.tweakers.net/jsminplus/jsminplus.zip
+ *
+ * @file
+ */
+
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is the Narcissus JavaScript engine.
+ *
+ * The Initial Developer of the Original Code is
+ * Brendan Eich <brendan@mozilla.org>.
+ * Portions created by the Initial Developer are Copyright (C) 2004
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s): Tino Zijdel <crisp@tweakers.net>
+ * PHP port, modifications and minifier routine are (C) 2009-2011
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+define('TOKEN_END', 1);
+define('TOKEN_NUMBER', 2);
+define('TOKEN_IDENTIFIER', 3);
+define('TOKEN_STRING', 4);
+define('TOKEN_REGEXP', 5);
+define('TOKEN_NEWLINE', 6);
+define('TOKEN_CONDCOMMENT_START', 7);
+define('TOKEN_CONDCOMMENT_END', 8);
+
+define('JS_SCRIPT', 100);
+define('JS_BLOCK', 101);
+define('JS_LABEL', 102);
+define('JS_FOR_IN', 103);
+define('JS_CALL', 104);
+define('JS_NEW_WITH_ARGS', 105);
+define('JS_INDEX', 106);
+define('JS_ARRAY_INIT', 107);
+define('JS_OBJECT_INIT', 108);
+define('JS_PROPERTY_INIT', 109);
+define('JS_GETTER', 110);
+define('JS_SETTER', 111);
+define('JS_GROUP', 112);
+define('JS_LIST', 113);
+
+define('JS_MINIFIED', 999);
+
+define('DECLARED_FORM', 0);
+define('EXPRESSED_FORM', 1);
+define('STATEMENT_FORM', 2);
+
+/* Operators */
+define('OP_SEMICOLON', ';');
+define('OP_COMMA', ',');
+define('OP_HOOK', '?');
+define('OP_COLON', ':');
+define('OP_OR', '||');
+define('OP_AND', '&&');
+define('OP_BITWISE_OR', '|');
+define('OP_BITWISE_XOR', '^');
+define('OP_BITWISE_AND', '&');
+define('OP_STRICT_EQ', '===');
+define('OP_EQ', '==');
+define('OP_ASSIGN', '=');
+define('OP_STRICT_NE', '!==');
+define('OP_NE', '!=');
+define('OP_LSH', '<<');
+define('OP_LE', '<=');
+define('OP_LT', '<');
+define('OP_URSH', '>>>');
+define('OP_RSH', '>>');
+define('OP_GE', '>=');
+define('OP_GT', '>');
+define('OP_INCREMENT', '++');
+define('OP_DECREMENT', '--');
+define('OP_PLUS', '+');
+define('OP_MINUS', '-');
+define('OP_MUL', '*');
+define('OP_DIV', '/');
+define('OP_MOD', '%');
+define('OP_NOT', '!');
+define('OP_BITWISE_NOT', '~');
+define('OP_DOT', '.');
+define('OP_LEFT_BRACKET', '[');
+define('OP_RIGHT_BRACKET', ']');
+define('OP_LEFT_CURLY', '{');
+define('OP_RIGHT_CURLY', '}');
+define('OP_LEFT_PAREN', '(');
+define('OP_RIGHT_PAREN', ')');
+define('OP_CONDCOMMENT_END', '@*/');
+
+define('OP_UNARY_PLUS', 'U+');
+define('OP_UNARY_MINUS', 'U-');
+
+/* Keywords */
+define('KEYWORD_BREAK', 'break');
+define('KEYWORD_CASE', 'case');
+define('KEYWORD_CATCH', 'catch');
+define('KEYWORD_CONST', 'const');
+define('KEYWORD_CONTINUE', 'continue');
+define('KEYWORD_DEBUGGER', 'debugger');
+define('KEYWORD_DEFAULT', 'default');
+define('KEYWORD_DELETE', 'delete');
+define('KEYWORD_DO', 'do');
+define('KEYWORD_ELSE', 'else');
+define('KEYWORD_ENUM', 'enum');
+define('KEYWORD_FALSE', 'false');
+define('KEYWORD_FINALLY', 'finally');
+define('KEYWORD_FOR', 'for');
+define('KEYWORD_FUNCTION', 'function');
+define('KEYWORD_IF', 'if');
+define('KEYWORD_IN', 'in');
+define('KEYWORD_INSTANCEOF', 'instanceof');
+define('KEYWORD_NEW', 'new');
+define('KEYWORD_NULL', 'null');
+define('KEYWORD_RETURN', 'return');
+define('KEYWORD_SWITCH', 'switch');
+define('KEYWORD_THIS', 'this');
+define('KEYWORD_THROW', 'throw');
+define('KEYWORD_TRUE', 'true');
+define('KEYWORD_TRY', 'try');
+define('KEYWORD_TYPEOF', 'typeof');
+define('KEYWORD_VAR', 'var');
+define('KEYWORD_VOID', 'void');
+define('KEYWORD_WHILE', 'while');
+define('KEYWORD_WITH', 'with');
+
+
+class JSMinPlus
+{
+ private $parser;
+ private $reserved = array(
+ 'break', 'case', 'catch', 'continue', 'default', 'delete', 'do',
+ 'else', 'finally', 'for', 'function', 'if', 'in', 'instanceof',
+ 'new', 'return', 'switch', 'this', 'throw', 'try', 'typeof', 'var',
+ 'void', 'while', 'with',
+ // Words reserved for future use
+ 'abstract', 'boolean', 'byte', 'char', 'class', 'const', 'debugger',
+ 'double', 'enum', 'export', 'extends', 'final', 'float', 'goto',
+ 'implements', 'import', 'int', 'interface', 'long', 'native',
+ 'package', 'private', 'protected', 'public', 'short', 'static',
+ 'super', 'synchronized', 'throws', 'transient', 'volatile',
+ // These are not reserved, but should be taken into account
+ // in isValidIdentifier (See jslint source code)
+ 'arguments', 'eval', 'true', 'false', 'Infinity', 'NaN', 'null', 'undefined'
+ );
+
+ private function __construct()
+ {
+ $this->parser = new JSParser($this);
+ }
+
+ public static function minify($js, $filename='')
+ {
+ static $instance;
+
+ // this is a singleton
+ if(!$instance)
+ $instance = new JSMinPlus();
+
+ return $instance->min($js, $filename);
+ }
+
+ private function min($js, $filename)
+ {
+ try
+ {
+ $n = $this->parser->parse($js, $filename, 1);
+ return $this->parseTree($n);
+ }
+ catch(Exception $e)
+ {
+ echo $e->getMessage() . "\n";
+ }
+
+ return false;
+ }
+
+ public function parseTree($n, $noBlockGrouping = false)
+ {
+ $s = '';
+
+ switch ($n->type)
+ {
+ case JS_MINIFIED:
+ $s = $n->value;
+ break;
+
+ case JS_SCRIPT:
+ // we do nothing yet with funDecls or varDecls
+ $noBlockGrouping = true;
+ // FALL THROUGH
+
+ case JS_BLOCK:
+ $childs = $n->treeNodes;
+ $lastType = 0;
+ for ($c = 0, $i = 0, $j = count($childs); $i < $j; $i++)
+ {
+ $type = $childs[$i]->type;
+ $t = $this->parseTree($childs[$i]);
+ if (strlen($t))
+ {
+ if ($c)
+ {
+ $s = rtrim($s, ';');
+
+ if ($type == KEYWORD_FUNCTION && $childs[$i]->functionForm == DECLARED_FORM)
+ {
+ // put declared functions on a new line
+ $s .= "\n";
+ }
+ elseif ($type == KEYWORD_VAR && $type == $lastType)
+ {
+ // multiple var-statements can go into one
+ $t = ',' . substr($t, 4);
+ }
+ else
+ {
+ // add terminator
+ $s .= ';';
+ }
+ }
+
+ $s .= $t;
+
+ $c++;
+ $lastType = $type;
+ }
+ }
+
+ if ($c > 1 && !$noBlockGrouping)
+ {
+ $s = '{' . $s . '}';
+ }
+ break;
+
+ case KEYWORD_FUNCTION:
+ $s .= 'function' . ($n->name ? ' ' . $n->name : '') . '(';
+ $params = $n->params;
+ for ($i = 0, $j = count($params); $i < $j; $i++)
+ $s .= ($i ? ',' : '') . $params[$i];
+ $s .= '){' . $this->parseTree($n->body, true) . '}';
+ break;
+
+ case KEYWORD_IF:
+ $s = 'if(' . $this->parseTree($n->condition) . ')';
+ $thenPart = $this->parseTree($n->thenPart);
+ $elsePart = $n->elsePart ? $this->parseTree($n->elsePart) : null;
+
+ // empty if-statement
+ if ($thenPart == '')
+ $thenPart = ';';
+
+ if ($elsePart)
+ {
+ // be careful and always make a block out of the thenPart; could be more optimized but is a lot of trouble
+ if ($thenPart != ';' && $thenPart[0] != '{')
+ $thenPart = '{' . $thenPart . '}';
+
+ $s .= $thenPart . 'else';
+
+ // we could check for more, but that hardly ever applies so go for performance
+ if ($elsePart[0] != '{')
+ $s .= ' ';
+
+ $s .= $elsePart;
+ }
+ else
+ {
+ $s .= $thenPart;
+ }
+ break;
+
+ case KEYWORD_SWITCH:
+ $s = 'switch(' . $this->parseTree($n->discriminant) . '){';
+ $cases = $n->cases;
+ for ($i = 0, $j = count($cases); $i < $j; $i++)
+ {
+ $case = $cases[$i];
+ if ($case->type == KEYWORD_CASE)
+ $s .= 'case' . ($case->caseLabel->type != TOKEN_STRING ? ' ' : '') . $this->parseTree($case->caseLabel) . ':';
+ else
+ $s .= 'default:';
+
+ $statement = $this->parseTree($case->statements, true);
+ if ($statement)
+ {
+ $s .= $statement;
+ // no terminator for last statement
+ if ($i + 1 < $j)
+ $s .= ';';
+ }
+ }
+ $s .= '}';
+ break;
+
+ case KEYWORD_FOR:
+ $s = 'for(' . ($n->setup ? $this->parseTree($n->setup) : '')
+ . ';' . ($n->condition ? $this->parseTree($n->condition) : '')
+ . ';' . ($n->update ? $this->parseTree($n->update) : '') . ')';
+
+ $body = $this->parseTree($n->body);
+ if ($body == '')
+ $body = ';';
+
+ $s .= $body;
+ break;
+
+ case KEYWORD_WHILE:
+ $s = 'while(' . $this->parseTree($n->condition) . ')';
+
+ $body = $this->parseTree($n->body);
+ if ($body == '')
+ $body = ';';
+
+ $s .= $body;
+ break;
+
+ case JS_FOR_IN:
+ $s = 'for(' . ($n->varDecl ? $this->parseTree($n->varDecl) : $this->parseTree($n->iterator)) . ' in ' . $this->parseTree($n->object) . ')';
+
+ $body = $this->parseTree($n->body);
+ if ($body == '')
+ $body = ';';
+
+ $s .= $body;
+ break;
+
+ case KEYWORD_DO:
+ $s = 'do{' . $this->parseTree($n->body, true) . '}while(' . $this->parseTree($n->condition) . ')';
+ break;
+
+ case KEYWORD_BREAK:
+ case KEYWORD_CONTINUE:
+ $s = $n->value . ($n->label ? ' ' . $n->label : '');
+ break;
+
+ case KEYWORD_TRY:
+ $s = 'try{' . $this->parseTree($n->tryBlock, true) . '}';
+ $catchClauses = $n->catchClauses;
+ for ($i = 0, $j = count($catchClauses); $i < $j; $i++)
+ {
+ $t = $catchClauses[$i];
+ $s .= 'catch(' . $t->varName . ($t->guard ? ' if ' . $this->parseTree($t->guard) : '') . '){' . $this->parseTree($t->block, true) . '}';
+ }
+ if ($n->finallyBlock)
+ $s .= 'finally{' . $this->parseTree($n->finallyBlock, true) . '}';
+ break;
+
+ case KEYWORD_THROW:
+ case KEYWORD_RETURN:
+ $s = $n->type;
+ if ($n->value)
+ {
+ $t = $this->parseTree($n->value);
+ if (strlen($t))
+ {
+ if ($this->isWordChar($t[0]) || $t[0] == '\\')
+ $s .= ' ';
+
+ $s .= $t;
+ }
+ }
+ break;
+
+ case KEYWORD_WITH:
+ $s = 'with(' . $this->parseTree($n->object) . ')' . $this->parseTree($n->body);
+ break;
+
+ case KEYWORD_VAR:
+ case KEYWORD_CONST:
+ $s = $n->value . ' ';
+ $childs = $n->treeNodes;
+ for ($i = 0, $j = count($childs); $i < $j; $i++)
+ {
+ $t = $childs[$i];
+ $s .= ($i ? ',' : '') . $t->name;
+ $u = $t->initializer;
+ if ($u)
+ $s .= '=' . $this->parseTree($u);
+ }
+ break;
+
+ case KEYWORD_IN:
+ case KEYWORD_INSTANCEOF:
+ $left = $this->parseTree($n->treeNodes[0]);
+ $right = $this->parseTree($n->treeNodes[1]);
+
+ $s = $left;
+
+ if ($this->isWordChar(substr($left, -1)))
+ $s .= ' ';
+
+ $s .= $n->type;
+
+ if ($this->isWordChar($right[0]) || $right[0] == '\\')
+ $s .= ' ';
+
+ $s .= $right;
+ break;
+
+ case KEYWORD_DELETE:
+ case KEYWORD_TYPEOF:
+ $right = $this->parseTree($n->treeNodes[0]);
+
+ $s = $n->type;
+
+ if ($this->isWordChar($right[0]) || $right[0] == '\\')
+ $s .= ' ';
+
+ $s .= $right;
+ break;
+
+ case KEYWORD_VOID:
+ $s = 'void(' . $this->parseTree($n->treeNodes[0]) . ')';
+ break;
+
+ case KEYWORD_DEBUGGER:
+ throw new Exception('NOT IMPLEMENTED: DEBUGGER');
+ break;
+
+ case TOKEN_CONDCOMMENT_START:
+ case TOKEN_CONDCOMMENT_END:
+ $s = $n->value . ($n->type == TOKEN_CONDCOMMENT_START ? ' ' : '');
+ $childs = $n->treeNodes;
+ for ($i = 0, $j = count($childs); $i < $j; $i++)
+ $s .= $this->parseTree($childs[$i]);
+ break;
+
+ case OP_SEMICOLON:
+ if ($expression = $n->expression)
+ $s = $this->parseTree($expression);
+ break;
+
+ case JS_LABEL:
+ $s = $n->label . ':' . $this->parseTree($n->statement);
+ break;
+
+ case OP_COMMA:
+ $childs = $n->treeNodes;
+ for ($i = 0, $j = count($childs); $i < $j; $i++)
+ $s .= ($i ? ',' : '') . $this->parseTree($childs[$i]);
+ break;
+
+ case OP_ASSIGN:
+ $s = $this->parseTree($n->treeNodes[0]) . $n->value . $this->parseTree($n->treeNodes[1]);
+ break;
+
+ case OP_HOOK:
+ $s = $this->parseTree($n->treeNodes[0]) . '?' . $this->parseTree($n->treeNodes[1]) . ':' . $this->parseTree($n->treeNodes[2]);
+ break;
+
+ case OP_OR: case OP_AND:
+ case OP_BITWISE_OR: case OP_BITWISE_XOR: case OP_BITWISE_AND:
+ case OP_EQ: case OP_NE: case OP_STRICT_EQ: case OP_STRICT_NE:
+ case OP_LT: case OP_LE: case OP_GE: case OP_GT:
+ case OP_LSH: case OP_RSH: case OP_URSH:
+ case OP_MUL: case OP_DIV: case OP_MOD:
+ $s = $this->parseTree($n->treeNodes[0]) . $n->type . $this->parseTree($n->treeNodes[1]);
+ break;
+
+ case OP_PLUS:
+ case OP_MINUS:
+ $left = $this->parseTree($n->treeNodes[0]);
+ $right = $this->parseTree($n->treeNodes[1]);
+
+ switch ($n->treeNodes[1]->type)
+ {
+ case OP_PLUS:
+ case OP_MINUS:
+ case OP_INCREMENT:
+ case OP_DECREMENT:
+ case OP_UNARY_PLUS:
+ case OP_UNARY_MINUS:
+ $s = $left . $n->type . ' ' . $right;
+ break;
+
+ case TOKEN_STRING:
+ //combine concatenated strings with same quote style
+ if ($n->type == OP_PLUS && substr($left, -1) == $right[0])
+ {
+ $s = substr($left, 0, -1) . substr($right, 1);
+ break;
+ }
+ // FALL THROUGH
+
+ default:
+ $s = $left . $n->type . $right;
+ }
+ break;
+
+ case OP_NOT:
+ case OP_BITWISE_NOT:
+ case OP_UNARY_PLUS:
+ case OP_UNARY_MINUS:
+ $s = $n->value . $this->parseTree($n->treeNodes[0]);
+ break;
+
+ case OP_INCREMENT:
+ case OP_DECREMENT:
+ if ($n->postfix)
+ $s = $this->parseTree($n->treeNodes[0]) . $n->value;
+ else
+ $s = $n->value . $this->parseTree($n->treeNodes[0]);
+ break;
+
+ case OP_DOT:
+ $s = $this->parseTree($n->treeNodes[0]) . '.' . $this->parseTree($n->treeNodes[1]);
+ break;
+
+ case JS_INDEX:
+ $s = $this->parseTree($n->treeNodes[0]);
+ // See if we can replace named index with a dot saving 3 bytes
+ if ( $n->treeNodes[0]->type == TOKEN_IDENTIFIER &&
+ $n->treeNodes[1]->type == TOKEN_STRING &&
+ $this->isValidIdentifier(substr($n->treeNodes[1]->value, 1, -1))
+ )
+ $s .= '.' . substr($n->treeNodes[1]->value, 1, -1);
+ else
+ $s .= '[' . $this->parseTree($n->treeNodes[1]) . ']';
+ break;
+
+ case JS_LIST:
+ $childs = $n->treeNodes;
+ for ($i = 0, $j = count($childs); $i < $j; $i++)
+ $s .= ($i ? ',' : '') . $this->parseTree($childs[$i]);
+ break;
+
+ case JS_CALL:
+ $s = $this->parseTree($n->treeNodes[0]) . '(' . $this->parseTree($n->treeNodes[1]) . ')';
+ break;
+
+ case KEYWORD_NEW:
+ case JS_NEW_WITH_ARGS:
+ $s = 'new ' . $this->parseTree($n->treeNodes[0]) . '(' . ($n->type == JS_NEW_WITH_ARGS ? $this->parseTree($n->treeNodes[1]) : '') . ')';
+ break;
+
+ case JS_ARRAY_INIT:
+ $s = '[';
+ $childs = $n->treeNodes;
+ for ($i = 0, $j = count($childs); $i < $j; $i++)
+ {
+ $s .= ($i ? ',' : '') . $this->parseTree($childs[$i]);
+ }
+ $s .= ']';
+ break;
+
+ case JS_OBJECT_INIT:
+ $s = '{';
+ $childs = $n->treeNodes;
+ for ($i = 0, $j = count($childs); $i < $j; $i++)
+ {
+ $t = $childs[$i];
+ if ($i)
+ $s .= ',';
+ if ($t->type == JS_PROPERTY_INIT)
+ {
+ // Ditch the quotes when the index is a valid identifier
+ if ( $t->treeNodes[0]->type == TOKEN_STRING &&
+ $this->isValidIdentifier(substr($t->treeNodes[0]->value, 1, -1))
+ )
+ $s .= substr($t->treeNodes[0]->value, 1, -1);
+ else
+ $s .= $t->treeNodes[0]->value;
+
+ $s .= ':' . $this->parseTree($t->treeNodes[1]);
+ }
+ else
+ {
+ $s .= $t->type == JS_GETTER ? 'get' : 'set';
+ $s .= ' ' . $t->name . '(';
+ $params = $t->params;
+ for ($i = 0, $j = count($params); $i < $j; $i++)
+ $s .= ($i ? ',' : '') . $params[$i];
+ $s .= '){' . $this->parseTree($t->body, true) . '}';
+ }
+ }
+ $s .= '}';
+ break;
+
+ case TOKEN_NUMBER:
+ $s = $n->value;
+ if (preg_match('/^([1-9]+)(0{3,})$/', $s, $m))
+ $s = $m[1] . 'e' . strlen($m[2]);
+ break;
+
+ case KEYWORD_NULL: case KEYWORD_THIS: case KEYWORD_TRUE: case KEYWORD_FALSE:
+ case TOKEN_IDENTIFIER: case TOKEN_STRING: case TOKEN_REGEXP:
+ $s = $n->value;
+ break;
+
+ case JS_GROUP:
+ if (in_array(
+ $n->treeNodes[0]->type,
+ array(
+ JS_ARRAY_INIT, JS_OBJECT_INIT, JS_GROUP,
+ TOKEN_NUMBER, TOKEN_STRING, TOKEN_REGEXP, TOKEN_IDENTIFIER,
+ KEYWORD_NULL, KEYWORD_THIS, KEYWORD_TRUE, KEYWORD_FALSE
+ )
+ ))
+ {
+ $s = $this->parseTree($n->treeNodes[0]);
+ }
+ else
+ {
+ $s = '(' . $this->parseTree($n->treeNodes[0]) . ')';
+ }
+ break;
+
+ default:
+ throw new Exception('UNKNOWN TOKEN TYPE: ' . $n->type);
+ }
+
+ return $s;
+ }
+
+ private function isValidIdentifier($string)
+ {
+ return preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $string) && !in_array($string, $this->reserved);
+ }
+
+ private function isWordChar($char)
+ {
+ return $char == '_' || $char == '$' || ctype_alnum($char);
+ }
+}
+
+class JSParser
+{
+ private $t;
+ private $minifier;
+
+ private $opPrecedence = array(
+ ';' => 0,
+ ',' => 1,
+ '=' => 2, '?' => 2, ':' => 2,
+ // The above all have to have the same precedence, see bug 330975
+ '||' => 4,
+ '&&' => 5,
+ '|' => 6,
+ '^' => 7,
+ '&' => 8,
+ '==' => 9, '!=' => 9, '===' => 9, '!==' => 9,
+ '<' => 10, '<=' => 10, '>=' => 10, '>' => 10, 'in' => 10, 'instanceof' => 10,
+ '<<' => 11, '>>' => 11, '>>>' => 11,
+ '+' => 12, '-' => 12,
+ '*' => 13, '/' => 13, '%' => 13,
+ 'delete' => 14, 'void' => 14, 'typeof' => 14,
+ '!' => 14, '~' => 14, 'U+' => 14, 'U-' => 14,
+ '++' => 15, '--' => 15,
+ 'new' => 16,
+ '.' => 17,
+ JS_NEW_WITH_ARGS => 0, JS_INDEX => 0, JS_CALL => 0,
+ JS_ARRAY_INIT => 0, JS_OBJECT_INIT => 0, JS_GROUP => 0
+ );
+
+ private $opArity = array(
+ ',' => -2,
+ '=' => 2,
+ '?' => 3,
+ '||' => 2,
+ '&&' => 2,
+ '|' => 2,
+ '^' => 2,
+ '&' => 2,
+ '==' => 2, '!=' => 2, '===' => 2, '!==' => 2,
+ '<' => 2, '<=' => 2, '>=' => 2, '>' => 2, 'in' => 2, 'instanceof' => 2,
+ '<<' => 2, '>>' => 2, '>>>' => 2,
+ '+' => 2, '-' => 2,
+ '*' => 2, '/' => 2, '%' => 2,
+ 'delete' => 1, 'void' => 1, 'typeof' => 1,
+ '!' => 1, '~' => 1, 'U+' => 1, 'U-' => 1,
+ '++' => 1, '--' => 1,
+ 'new' => 1,
+ '.' => 2,
+ JS_NEW_WITH_ARGS => 2, JS_INDEX => 2, JS_CALL => 2,
+ JS_ARRAY_INIT => 1, JS_OBJECT_INIT => 1, JS_GROUP => 1,
+ TOKEN_CONDCOMMENT_START => 1, TOKEN_CONDCOMMENT_END => 1
+ );
+
+ public function __construct($minifier=null)
+ {
+ $this->minifier = $minifier;
+ $this->t = new JSTokenizer();
+ }
+
+ public function parse($s, $f, $l)
+ {
+ // initialize tokenizer
+ $this->t->init($s, $f, $l);
+
+ $x = new JSCompilerContext(false);
+ $n = $this->Script($x);
+ if (!$this->t->isDone())
+ throw $this->t->newSyntaxError('Syntax error');
+
+ return $n;
+ }
+
+ private function Script($x)
+ {
+ $n = $this->Statements($x);
+ $n->type = JS_SCRIPT;
+ $n->funDecls = $x->funDecls;
+ $n->varDecls = $x->varDecls;
+
+ // minify by scope
+ if ($this->minifier)
+ {
+ $n->value = $this->minifier->parseTree($n);
+
+ // clear tree from node to save memory
+ $n->treeNodes = null;
+ $n->funDecls = null;
+ $n->varDecls = null;
+
+ $n->type = JS_MINIFIED;
+ }
+
+ return $n;
+ }
+
+ private function Statements($x)
+ {
+ $n = new JSNode($this->t, JS_BLOCK);
+ array_push($x->stmtStack, $n);
+
+ while (!$this->t->isDone() && $this->t->peek() != OP_RIGHT_CURLY)
+ $n->addNode($this->Statement($x));
+
+ array_pop($x->stmtStack);
+
+ return $n;
+ }
+
+ private function Block($x)
+ {
+ $this->t->mustMatch(OP_LEFT_CURLY);
+ $n = $this->Statements($x);
+ $this->t->mustMatch(OP_RIGHT_CURLY);
+
+ return $n;
+ }
+
+ private function Statement($x)
+ {
+ $tt = $this->t->get();
+ $n2 = null;
+
+ // Cases for statements ending in a right curly return early, avoiding the
+ // common semicolon insertion magic after this switch.
+ switch ($tt)
+ {
+ case KEYWORD_FUNCTION:
+ return $this->FunctionDefinition(
+ $x,
+ true,
+ count($x->stmtStack) > 1 ? STATEMENT_FORM : DECLARED_FORM
+ );
+ break;
+
+ case OP_LEFT_CURLY:
+ $n = $this->Statements($x);
+ $this->t->mustMatch(OP_RIGHT_CURLY);
+ return $n;
+
+ case KEYWORD_IF:
+ $n = new JSNode($this->t);
+ $n->condition = $this->ParenExpression($x);
+ array_push($x->stmtStack, $n);
+ $n->thenPart = $this->Statement($x);
+ $n->elsePart = $this->t->match(KEYWORD_ELSE) ? $this->Statement($x) : null;
+ array_pop($x->stmtStack);
+ return $n;
+
+ case KEYWORD_SWITCH:
+ $n = new JSNode($this->t);
+ $this->t->mustMatch(OP_LEFT_PAREN);
+ $n->discriminant = $this->Expression($x);
+ $this->t->mustMatch(OP_RIGHT_PAREN);
+ $n->cases = array();
+ $n->defaultIndex = -1;
+
+ array_push($x->stmtStack, $n);
+
+ $this->t->mustMatch(OP_LEFT_CURLY);
+
+ while (($tt = $this->t->get()) != OP_RIGHT_CURLY)
+ {
+ switch ($tt)
+ {
+ case KEYWORD_DEFAULT:
+ if ($n->defaultIndex >= 0)
+ throw $this->t->newSyntaxError('More than one switch default');
+ // FALL THROUGH
+ case KEYWORD_CASE:
+ $n2 = new JSNode($this->t);
+ if ($tt == KEYWORD_DEFAULT)
+ $n->defaultIndex = count($n->cases);
+ else
+ $n2->caseLabel = $this->Expression($x, OP_COLON);
+ break;
+ default:
+ throw $this->t->newSyntaxError('Invalid switch case');
+ }
+
+ $this->t->mustMatch(OP_COLON);
+ $n2->statements = new JSNode($this->t, JS_BLOCK);
+ while (($tt = $this->t->peek()) != KEYWORD_CASE && $tt != KEYWORD_DEFAULT && $tt != OP_RIGHT_CURLY)
+ $n2->statements->addNode($this->Statement($x));
+
+ array_push($n->cases, $n2);
+ }
+
+ array_pop($x->stmtStack);
+ return $n;
+
+ case KEYWORD_FOR:
+ $n = new JSNode($this->t);
+ $n->isLoop = true;
+ $this->t->mustMatch(OP_LEFT_PAREN);
+
+ if (($tt = $this->t->peek()) != OP_SEMICOLON)
+ {
+ $x->inForLoopInit = true;
+ if ($tt == KEYWORD_VAR || $tt == KEYWORD_CONST)
+ {
+ $this->t->get();
+ $n2 = $this->Variables($x);
+ }
+ else
+ {
+ $n2 = $this->Expression($x);
+ }
+ $x->inForLoopInit = false;
+ }
+
+ if ($n2 && $this->t->match(KEYWORD_IN))
+ {
+ $n->type = JS_FOR_IN;
+ if ($n2->type == KEYWORD_VAR)
+ {
+ if (count($n2->treeNodes) != 1)
+ {
+ throw $this->t->SyntaxError(
+ 'Invalid for..in left-hand side',
+ $this->t->filename,
+ $n2->lineno
+ );
+ }
+
+ // NB: n2[0].type == IDENTIFIER and n2[0].value == n2[0].name.
+ $n->iterator = $n2->treeNodes[0];
+ $n->varDecl = $n2;
+ }
+ else
+ {
+ $n->iterator = $n2;
+ $n->varDecl = null;
+ }
+
+ $n->object = $this->Expression($x);
+ }
+ else
+ {
+ $n->setup = $n2 ? $n2 : null;
+ $this->t->mustMatch(OP_SEMICOLON);
+ $n->condition = $this->t->peek() == OP_SEMICOLON ? null : $this->Expression($x);
+ $this->t->mustMatch(OP_SEMICOLON);
+ $n->update = $this->t->peek() == OP_RIGHT_PAREN ? null : $this->Expression($x);
+ }
+
+ $this->t->mustMatch(OP_RIGHT_PAREN);
+ $n->body = $this->nest($x, $n);
+ return $n;
+
+ case KEYWORD_WHILE:
+ $n = new JSNode($this->t);
+ $n->isLoop = true;
+ $n->condition = $this->ParenExpression($x);
+ $n->body = $this->nest($x, $n);
+ return $n;
+
+ case KEYWORD_DO:
+ $n = new JSNode($this->t);
+ $n->isLoop = true;
+ $n->body = $this->nest($x, $n, KEYWORD_WHILE);
+ $n->condition = $this->ParenExpression($x);
+ if (!$x->ecmaStrictMode)
+ {
+ // <script language="JavaScript"> (without version hints) may need
+ // automatic semicolon insertion without a newline after do-while.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=238945.
+ $this->t->match(OP_SEMICOLON);
+ return $n;
+ }
+ break;
+
+ case KEYWORD_BREAK:
+ case KEYWORD_CONTINUE:
+ $n = new JSNode($this->t);
+
+ if ($this->t->peekOnSameLine() == TOKEN_IDENTIFIER)
+ {
+ $this->t->get();
+ $n->label = $this->t->currentToken()->value;
+ }
+
+ $ss = $x->stmtStack;
+ $i = count($ss);
+ $label = $n->label;
+ if ($label)
+ {
+ do
+ {
+ if (--$i < 0)
+ throw $this->t->newSyntaxError('Label not found');
+ }
+ while ($ss[$i]->label != $label);
+ }
+ else
+ {
+ do
+ {
+ if (--$i < 0)
+ throw $this->t->newSyntaxError('Invalid ' . $tt);
+ }
+ while (!$ss[$i]->isLoop && ($tt != KEYWORD_BREAK || $ss[$i]->type != KEYWORD_SWITCH));
+ }
+ break;
+
+ case KEYWORD_TRY:
+ $n = new JSNode($this->t);
+ $n->tryBlock = $this->Block($x);
+ $n->catchClauses = array();
+
+ while ($this->t->match(KEYWORD_CATCH))
+ {
+ $n2 = new JSNode($this->t);
+ $this->t->mustMatch(OP_LEFT_PAREN);
+ $n2->varName = $this->t->mustMatch(TOKEN_IDENTIFIER)->value;
+
+ if ($this->t->match(KEYWORD_IF))
+ {
+ if ($x->ecmaStrictMode)
+ throw $this->t->newSyntaxError('Illegal catch guard');
+
+ if (count($n->catchClauses) && !end($n->catchClauses)->guard)
+ throw $this->t->newSyntaxError('Guarded catch after unguarded');
+
+ $n2->guard = $this->Expression($x);
+ }
+ else
+ {
+ $n2->guard = null;
+ }
+
+ $this->t->mustMatch(OP_RIGHT_PAREN);
+ $n2->block = $this->Block($x);
+ array_push($n->catchClauses, $n2);
+ }
+
+ if ($this->t->match(KEYWORD_FINALLY))
+ $n->finallyBlock = $this->Block($x);
+
+ if (!count($n->catchClauses) && !$n->finallyBlock)
+ throw $this->t->newSyntaxError('Invalid try statement');
+ return $n;
+
+ case KEYWORD_CATCH:
+ case KEYWORD_FINALLY:
+ throw $this->t->newSyntaxError($tt . ' without preceding try');
+
+ case KEYWORD_THROW:
+ $n = new JSNode($this->t);
+ $n->value = $this->Expression($x);
+ break;
+
+ case KEYWORD_RETURN:
+ if (!$x->inFunction)
+ throw $this->t->newSyntaxError('Invalid return');
+
+ $n = new JSNode($this->t);
+ $tt = $this->t->peekOnSameLine();
+ if ($tt != TOKEN_END && $tt != TOKEN_NEWLINE && $tt != OP_SEMICOLON && $tt != OP_RIGHT_CURLY)
+ $n->value = $this->Expression($x);
+ else
+ $n->value = null;
+ break;
+
+ case KEYWORD_WITH:
+ $n = new JSNode($this->t);
+ $n->object = $this->ParenExpression($x);
+ $n->body = $this->nest($x, $n);
+ return $n;
+
+ case KEYWORD_VAR:
+ case KEYWORD_CONST:
+ $n = $this->Variables($x);
+ break;
+
+ case TOKEN_CONDCOMMENT_START:
+ case TOKEN_CONDCOMMENT_END:
+ $n = new JSNode($this->t);
+ return $n;
+
+ case KEYWORD_DEBUGGER:
+ $n = new JSNode($this->t);
+ break;
+
+ case TOKEN_NEWLINE:
+ case OP_SEMICOLON:
+ $n = new JSNode($this->t, OP_SEMICOLON);
+ $n->expression = null;
+ return $n;
+
+ default:
+ if ($tt == TOKEN_IDENTIFIER)
+ {
+ $this->t->scanOperand = false;
+ $tt = $this->t->peek();
+ $this->t->scanOperand = true;
+ if ($tt == OP_COLON)
+ {
+ $label = $this->t->currentToken()->value;
+ $ss = $x->stmtStack;
+ for ($i = count($ss) - 1; $i >= 0; --$i)
+ {
+ if ($ss[$i]->label == $label)
+ throw $this->t->newSyntaxError('Duplicate label');
+ }
+
+ $this->t->get();
+ $n = new JSNode($this->t, JS_LABEL);
+ $n->label = $label;
+ $n->statement = $this->nest($x, $n);
+
+ return $n;
+ }
+ }
+
+ $n = new JSNode($this->t, OP_SEMICOLON);
+ $this->t->unget();
+ $n->expression = $this->Expression($x);
+ $n->end = $n->expression->end;
+ break;
+ }
+
+ if ($this->t->lineno == $this->t->currentToken()->lineno)
+ {
+ $tt = $this->t->peekOnSameLine();
+ if ($tt != TOKEN_END && $tt != TOKEN_NEWLINE && $tt != OP_SEMICOLON && $tt != OP_RIGHT_CURLY)
+ throw $this->t->newSyntaxError('Missing ; before statement');
+ }
+
+ $this->t->match(OP_SEMICOLON);
+
+ return $n;
+ }
+
+ private function FunctionDefinition($x, $requireName, $functionForm)
+ {
+ $f = new JSNode($this->t);
+
+ if ($f->type != KEYWORD_FUNCTION)
+ $f->type = ($f->value == 'get') ? JS_GETTER : JS_SETTER;
+
+ if ($this->t->match(TOKEN_IDENTIFIER))
+ $f->name = $this->t->currentToken()->value;
+ elseif ($requireName)
+ throw $this->t->newSyntaxError('Missing function identifier');
+
+ $this->t->mustMatch(OP_LEFT_PAREN);
+ $f->params = array();
+
+ while (($tt = $this->t->get()) != OP_RIGHT_PAREN)
+ {
+ if ($tt != TOKEN_IDENTIFIER)
+ throw $this->t->newSyntaxError('Missing formal parameter');
+
+ array_push($f->params, $this->t->currentToken()->value);
+
+ if ($this->t->peek() != OP_RIGHT_PAREN)
+ $this->t->mustMatch(OP_COMMA);
+ }
+
+ $this->t->mustMatch(OP_LEFT_CURLY);
+
+ $x2 = new JSCompilerContext(true);
+ $f->body = $this->Script($x2);
+
+ $this->t->mustMatch(OP_RIGHT_CURLY);
+ $f->end = $this->t->currentToken()->end;
+
+ $f->functionForm = $functionForm;
+ if ($functionForm == DECLARED_FORM)
+ array_push($x->funDecls, $f);
+
+ return $f;
+ }
+
+ private function Variables($x)
+ {
+ $n = new JSNode($this->t);
+
+ do
+ {
+ $this->t->mustMatch(TOKEN_IDENTIFIER);
+
+ $n2 = new JSNode($this->t);
+ $n2->name = $n2->value;
+
+ if ($this->t->match(OP_ASSIGN))
+ {
+ if ($this->t->currentToken()->assignOp)
+ throw $this->t->newSyntaxError('Invalid variable initialization');
+
+ $n2->initializer = $this->Expression($x, OP_COMMA);
+ }
+
+ $n2->readOnly = $n->type == KEYWORD_CONST;
+
+ $n->addNode($n2);
+ array_push($x->varDecls, $n2);
+ }
+ while ($this->t->match(OP_COMMA));
+
+ return $n;
+ }
+
+ private function Expression($x, $stop=false)
+ {
+ $operators = array();
+ $operands = array();
+ $n = false;
+
+ $bl = $x->bracketLevel;
+ $cl = $x->curlyLevel;
+ $pl = $x->parenLevel;
+ $hl = $x->hookLevel;
+
+ while (($tt = $this->t->get()) != TOKEN_END)
+ {
+ if ($tt == $stop &&
+ $x->bracketLevel == $bl &&
+ $x->curlyLevel == $cl &&
+ $x->parenLevel == $pl &&
+ $x->hookLevel == $hl
+ )
+ {
+ // Stop only if tt matches the optional stop parameter, and that
+ // token is not quoted by some kind of bracket.
+ break;
+ }
+
+ switch ($tt)
+ {
+ case OP_SEMICOLON:
+ // NB: cannot be empty, Statement handled that.
+ break 2;
+
+ case OP_HOOK:
+ if ($this->t->scanOperand)
+ break 2;
+
+ while ( !empty($operators) &&
+ $this->opPrecedence[end($operators)->type] > $this->opPrecedence[$tt]
+ )
+ $this->reduce($operators, $operands);
+
+ array_push($operators, new JSNode($this->t));
+
+ ++$x->hookLevel;
+ $this->t->scanOperand = true;
+ $n = $this->Expression($x);
+
+ if (!$this->t->match(OP_COLON))
+ break 2;
+
+ --$x->hookLevel;
+ array_push($operands, $n);
+ break;
+
+ case OP_COLON:
+ if ($x->hookLevel)
+ break 2;
+
+ throw $this->t->newSyntaxError('Invalid label');
+ break;
+
+ case OP_ASSIGN:
+ if ($this->t->scanOperand)
+ break 2;
+
+ // Use >, not >=, for right-associative ASSIGN
+ while ( !empty($operators) &&
+ $this->opPrecedence[end($operators)->type] > $this->opPrecedence[$tt]
+ )
+ $this->reduce($operators, $operands);
+
+ array_push($operators, new JSNode($this->t));
+ end($operands)->assignOp = $this->t->currentToken()->assignOp;
+ $this->t->scanOperand = true;
+ break;
+
+ case KEYWORD_IN:
+ // An in operator should not be parsed if we're parsing the head of
+ // a for (...) loop, unless it is in the then part of a conditional
+ // expression, or parenthesized somehow.
+ if ($x->inForLoopInit && !$x->hookLevel &&
+ !$x->bracketLevel && !$x->curlyLevel &&
+ !$x->parenLevel
+ )
+ break 2;
+ // FALL THROUGH
+ case OP_COMMA:
+ // A comma operator should not be parsed if we're parsing the then part
+ // of a conditional expression unless it's parenthesized somehow.
+ if ($tt == OP_COMMA && $x->hookLevel &&
+ !$x->bracketLevel && !$x->curlyLevel &&
+ !$x->parenLevel
+ )
+ break 2;
+ // Treat comma as left-associative so reduce can fold left-heavy
+ // COMMA trees into a single array.
+ // FALL THROUGH
+ case OP_OR:
+ case OP_AND:
+ case OP_BITWISE_OR:
+ case OP_BITWISE_XOR:
+ case OP_BITWISE_AND:
+ case OP_EQ: case OP_NE: case OP_STRICT_EQ: case OP_STRICT_NE:
+ case OP_LT: case OP_LE: case OP_GE: case OP_GT:
+ case KEYWORD_INSTANCEOF:
+ case OP_LSH: case OP_RSH: case OP_URSH:
+ case OP_PLUS: case OP_MINUS:
+ case OP_MUL: case OP_DIV: case OP_MOD:
+ case OP_DOT:
+ if ($this->t->scanOperand)
+ break 2;
+
+ while ( !empty($operators) &&
+ $this->opPrecedence[end($operators)->type] >= $this->opPrecedence[$tt]
+ )
+ $this->reduce($operators, $operands);
+
+ if ($tt == OP_DOT)
+ {
+ $this->t->mustMatch(TOKEN_IDENTIFIER);
+ array_push($operands, new JSNode($this->t, OP_DOT, array_pop($operands), new JSNode($this->t)));
+ }
+ else
+ {
+ array_push($operators, new JSNode($this->t));
+ $this->t->scanOperand = true;
+ }
+ break;
+
+ case KEYWORD_DELETE: case KEYWORD_VOID: case KEYWORD_TYPEOF:
+ case OP_NOT: case OP_BITWISE_NOT: case OP_UNARY_PLUS: case OP_UNARY_MINUS:
+ case KEYWORD_NEW:
+ if (!$this->t->scanOperand)
+ break 2;
+
+ array_push($operators, new JSNode($this->t));
+ break;
+
+ case OP_INCREMENT: case OP_DECREMENT:
+ if ($this->t->scanOperand)
+ {
+ array_push($operators, new JSNode($this->t)); // prefix increment or decrement
+ }
+ else
+ {
+ // Don't cross a line boundary for postfix {in,de}crement.
+ $t = $this->t->tokens[($this->t->tokenIndex + $this->t->lookahead - 1) & 3];
+ if ($t && $t->lineno != $this->t->lineno)
+ break 2;
+
+ if (!empty($operators))
+ {
+ // Use >, not >=, so postfix has higher precedence than prefix.
+ while ($this->opPrecedence[end($operators)->type] > $this->opPrecedence[$tt])
+ $this->reduce($operators, $operands);
+ }
+
+ $n = new JSNode($this->t, $tt, array_pop($operands));
+ $n->postfix = true;
+ array_push($operands, $n);
+ }
+ break;
+
+ case KEYWORD_FUNCTION:
+ if (!$this->t->scanOperand)
+ break 2;
+
+ array_push($operands, $this->FunctionDefinition($x, false, EXPRESSED_FORM));
+ $this->t->scanOperand = false;
+ break;
+
+ case KEYWORD_NULL: case KEYWORD_THIS: case KEYWORD_TRUE: case KEYWORD_FALSE:
+ case TOKEN_IDENTIFIER: case TOKEN_NUMBER: case TOKEN_STRING: case TOKEN_REGEXP:
+ if (!$this->t->scanOperand)
+ break 2;
+
+ array_push($operands, new JSNode($this->t));
+ $this->t->scanOperand = false;
+ break;
+
+ case TOKEN_CONDCOMMENT_START:
+ case TOKEN_CONDCOMMENT_END:
+ if ($this->t->scanOperand)
+ array_push($operators, new JSNode($this->t));
+ else
+ array_push($operands, new JSNode($this->t));
+ break;
+
+ case OP_LEFT_BRACKET:
+ if ($this->t->scanOperand)
+ {
+ // Array initialiser. Parse using recursive descent, as the
+ // sub-grammar here is not an operator grammar.
+ $n = new JSNode($this->t, JS_ARRAY_INIT);
+ while (($tt = $this->t->peek()) != OP_RIGHT_BRACKET)
+ {
+ if ($tt == OP_COMMA)
+ {
+ $this->t->get();
+ $n->addNode(null);
+ continue;
+ }
+
+ $n->addNode($this->Expression($x, OP_COMMA));
+ if (!$this->t->match(OP_COMMA))
+ break;
+ }
+
+ $this->t->mustMatch(OP_RIGHT_BRACKET);
+ array_push($operands, $n);
+ $this->t->scanOperand = false;
+ }
+ else
+ {
+ // Property indexing operator.
+ array_push($operators, new JSNode($this->t, JS_INDEX));
+ $this->t->scanOperand = true;
+ ++$x->bracketLevel;
+ }
+ break;
+
+ case OP_RIGHT_BRACKET:
+ if ($this->t->scanOperand || $x->bracketLevel == $bl)
+ break 2;
+
+ while ($this->reduce($operators, $operands)->type != JS_INDEX)
+ continue;
+
+ --$x->bracketLevel;
+ break;
+
+ case OP_LEFT_CURLY:
+ if (!$this->t->scanOperand)
+ break 2;
+
+ // Object initialiser. As for array initialisers (see above),
+ // parse using recursive descent.
+ ++$x->curlyLevel;
+ $n = new JSNode($this->t, JS_OBJECT_INIT);
+ while (!$this->t->match(OP_RIGHT_CURLY))
+ {
+ do
+ {
+ $tt = $this->t->get();
+ $tv = $this->t->currentToken()->value;
+ if (($tv == 'get' || $tv == 'set') && $this->t->peek() == TOKEN_IDENTIFIER)
+ {
+ if ($x->ecmaStrictMode)
+ throw $this->t->newSyntaxError('Illegal property accessor');
+
+ $n->addNode($this->FunctionDefinition($x, true, EXPRESSED_FORM));
+ }
+ else
+ {
+ switch ($tt)
+ {
+ case TOKEN_IDENTIFIER:
+ case TOKEN_NUMBER:
+ case TOKEN_STRING:
+ $id = new JSNode($this->t);
+ break;
+
+ case OP_RIGHT_CURLY:
+ if ($x->ecmaStrictMode)
+ throw $this->t->newSyntaxError('Illegal trailing ,');
+ break 3;
+
+ default:
+ throw $this->t->newSyntaxError('Invalid property name');
+ }
+
+ $this->t->mustMatch(OP_COLON);
+ $n->addNode(new JSNode($this->t, JS_PROPERTY_INIT, $id, $this->Expression($x, OP_COMMA)));
+ }
+ }
+ while ($this->t->match(OP_COMMA));
+
+ $this->t->mustMatch(OP_RIGHT_CURLY);
+ break;
+ }
+
+ array_push($operands, $n);
+ $this->t->scanOperand = false;
+ --$x->curlyLevel;
+ break;
+
+ case OP_RIGHT_CURLY:
+ if (!$this->t->scanOperand && $x->curlyLevel != $cl)
+ throw new Exception('PANIC: right curly botch');
+ break 2;
+
+ case OP_LEFT_PAREN:
+ if ($this->t->scanOperand)
+ {
+ array_push($operators, new JSNode($this->t, JS_GROUP));
+ }
+ else
+ {
+ while ( !empty($operators) &&
+ $this->opPrecedence[end($operators)->type] > $this->opPrecedence[KEYWORD_NEW]
+ )
+ $this->reduce($operators, $operands);
+
+ // Handle () now, to regularize the n-ary case for n > 0.
+ // We must set scanOperand in case there are arguments and
+ // the first one is a regexp or unary+/-.
+ $n = end($operators);
+ $this->t->scanOperand = true;
+ if ($this->t->match(OP_RIGHT_PAREN))
+ {
+ if ($n && $n->type == KEYWORD_NEW)
+ {
+ array_pop($operators);
+ $n->addNode(array_pop($operands));
+ }
+ else
+ {
+ $n = new JSNode($this->t, JS_CALL, array_pop($operands), new JSNode($this->t, JS_LIST));
+ }
+
+ array_push($operands, $n);
+ $this->t->scanOperand = false;
+ break;
+ }
+
+ if ($n && $n->type == KEYWORD_NEW)
+ $n->type = JS_NEW_WITH_ARGS;
+ else
+ array_push($operators, new JSNode($this->t, JS_CALL));
+ }
+
+ ++$x->parenLevel;
+ break;
+
+ case OP_RIGHT_PAREN:
+ if ($this->t->scanOperand || $x->parenLevel == $pl)
+ break 2;
+
+ while (($tt = $this->reduce($operators, $operands)->type) != JS_GROUP &&
+ $tt != JS_CALL && $tt != JS_NEW_WITH_ARGS
+ )
+ {
+ continue;
+ }
+
+ if ($tt != JS_GROUP)
+ {
+ $n = end($operands);
+ if ($n->treeNodes[1]->type != OP_COMMA)
+ $n->treeNodes[1] = new JSNode($this->t, JS_LIST, $n->treeNodes[1]);
+ else
+ $n->treeNodes[1]->type = JS_LIST;
+ }
+
+ --$x->parenLevel;
+ break;
+
+ // Automatic semicolon insertion means we may scan across a newline
+ // and into the beginning of another statement. If so, break out of
+ // the while loop and let the t.scanOperand logic handle errors.
+ default:
+ break 2;
+ }
+ }
+
+ if ($x->hookLevel != $hl)
+ throw $this->t->newSyntaxError('Missing : in conditional expression');
+
+ if ($x->parenLevel != $pl)
+ throw $this->t->newSyntaxError('Missing ) in parenthetical');
+
+ if ($x->bracketLevel != $bl)
+ throw $this->t->newSyntaxError('Missing ] in index expression');
+
+ if ($this->t->scanOperand)
+ throw $this->t->newSyntaxError('Missing operand');
+
+ // Resume default mode, scanning for operands, not operators.
+ $this->t->scanOperand = true;
+ $this->t->unget();
+
+ while (count($operators))
+ $this->reduce($operators, $operands);
+
+ return array_pop($operands);
+ }
+
+ private function ParenExpression($x)
+ {
+ $this->t->mustMatch(OP_LEFT_PAREN);
+ $n = $this->Expression($x);
+ $this->t->mustMatch(OP_RIGHT_PAREN);
+
+ return $n;
+ }
+
+ // Statement stack and nested statement handler.
+ private function nest($x, $node, $end = false)
+ {
+ array_push($x->stmtStack, $node);
+ $n = $this->statement($x);
+ array_pop($x->stmtStack);
+
+ if ($end)
+ $this->t->mustMatch($end);
+
+ return $n;
+ }
+
+ private function reduce(&$operators, &$operands)
+ {
+ $n = array_pop($operators);
+ $op = $n->type;
+ $arity = $this->opArity[$op];
+ $c = count($operands);
+ if ($arity == -2)
+ {
+ // Flatten left-associative trees
+ if ($c >= 2)
+ {
+ $left = $operands[$c - 2];
+ if ($left->type == $op)
+ {
+ $right = array_pop($operands);
+ $left->addNode($right);
+ return $left;
+ }
+ }
+ $arity = 2;
+ }
+
+ // Always use push to add operands to n, to update start and end
+ $a = array_splice($operands, $c - $arity);
+ for ($i = 0; $i < $arity; $i++)
+ $n->addNode($a[$i]);
+
+ // Include closing bracket or postfix operator in [start,end]
+ $te = $this->t->currentToken()->end;
+ if ($n->end < $te)
+ $n->end = $te;
+
+ array_push($operands, $n);
+
+ return $n;
+ }
+}
+
+class JSCompilerContext
+{
+ public $inFunction = false;
+ public $inForLoopInit = false;
+ public $ecmaStrictMode = false;
+ public $bracketLevel = 0;
+ public $curlyLevel = 0;
+ public $parenLevel = 0;
+ public $hookLevel = 0;
+
+ public $stmtStack = array();
+ public $funDecls = array();
+ public $varDecls = array();
+
+ public function __construct($inFunction)
+ {
+ $this->inFunction = $inFunction;
+ }
+}
+
+class JSNode
+{
+ private $type;
+ private $value;
+ private $lineno;
+ private $start;
+ private $end;
+
+ public $treeNodes = array();
+ public $funDecls = array();
+ public $varDecls = array();
+
+ public function __construct($t, $type=0)
+ {
+ if ($token = $t->currentToken())
+ {
+ $this->type = $type ? $type : $token->type;
+ $this->value = $token->value;
+ $this->lineno = $token->lineno;
+ $this->start = $token->start;
+ $this->end = $token->end;
+ }
+ else
+ {
+ $this->type = $type;
+ $this->lineno = $t->lineno;
+ }
+
+ if (($numargs = func_num_args()) > 2)
+ {
+ $args = func_get_args();
+ for ($i = 2; $i < $numargs; $i++)
+ $this->addNode($args[$i]);
+ }
+ }
+
+ // we don't want to bloat our object with all kind of specific properties, so we use overloading
+ public function __set($name, $value)
+ {
+ $this->$name = $value;
+ }
+
+ public function __get($name)
+ {
+ if (isset($this->$name))
+ return $this->$name;
+
+ return null;
+ }
+
+ public function addNode($node)
+ {
+ if ($node !== null)
+ {
+ if ($node->start < $this->start)
+ $this->start = $node->start;
+ if ($this->end < $node->end)
+ $this->end = $node->end;
+ }
+
+ $this->treeNodes[] = $node;
+ }
+}
+
+class JSTokenizer
+{
+ private $cursor = 0;
+ private $source;
+
+ public $tokens = array();
+ public $tokenIndex = 0;
+ public $lookahead = 0;
+ public $scanNewlines = false;
+ public $scanOperand = true;
+
+ public $filename;
+ public $lineno;
+
+ private $keywords = array(
+ 'break',
+ 'case', 'catch', 'const', 'continue',
+ 'debugger', 'default', 'delete', 'do',
+ 'else', 'enum',
+ 'false', 'finally', 'for', 'function',
+ 'if', 'in', 'instanceof',
+ 'new', 'null',
+ 'return',
+ 'switch',
+ 'this', 'throw', 'true', 'try', 'typeof',
+ 'var', 'void',
+ 'while', 'with'
+ );
+
+ private $opTypeNames = array(
+ ';', ',', '?', ':', '||', '&&', '|', '^',
+ '&', '===', '==', '=', '!==', '!=', '<<', '<=',
+ '<', '>>>', '>>', '>=', '>', '++', '--', '+',
+ '-', '*', '/', '%', '!', '~', '.', '[',
+ ']', '{', '}', '(', ')', '@*/'
+ );
+
+ private $assignOps = array('|', '^', '&', '<<', '>>', '>>>', '+', '-', '*', '/', '%');
+ private $opRegExp;
+
+ public function __construct()
+ {
+ $this->opRegExp = '#^(' . implode('|', array_map('preg_quote', $this->opTypeNames)) . ')#';
+ }
+
+ public function init($source, $filename = '', $lineno = 1)
+ {
+ $this->source = $source;
+ $this->filename = $filename ? $filename : '[inline]';
+ $this->lineno = $lineno;
+
+ $this->cursor = 0;
+ $this->tokens = array();
+ $this->tokenIndex = 0;
+ $this->lookahead = 0;
+ $this->scanNewlines = false;
+ $this->scanOperand = true;
+ }
+
+ public function getInput($chunksize)
+ {
+ if ($chunksize)
+ return substr($this->source, $this->cursor, $chunksize);
+
+ return substr($this->source, $this->cursor);
+ }
+
+ public function isDone()
+ {
+ return $this->peek() == TOKEN_END;
+ }
+
+ public function match($tt)
+ {
+ return $this->get() == $tt || $this->unget();
+ }
+
+ public function mustMatch($tt)
+ {
+ if (!$this->match($tt))
+ throw $this->newSyntaxError('Unexpected token; token ' . $tt . ' expected');
+
+ return $this->currentToken();
+ }
+
+ public function peek()
+ {
+ if ($this->lookahead)
+ {
+ $next = $this->tokens[($this->tokenIndex + $this->lookahead) & 3];
+ if ($this->scanNewlines && $next->lineno != $this->lineno)
+ $tt = TOKEN_NEWLINE;
+ else
+ $tt = $next->type;
+ }
+ else
+ {
+ $tt = $this->get();
+ $this->unget();
+ }
+
+ return $tt;
+ }
+
+ public function peekOnSameLine()
+ {
+ $this->scanNewlines = true;
+ $tt = $this->peek();
+ $this->scanNewlines = false;
+
+ return $tt;
+ }
+
+ public function currentToken()
+ {
+ if (!empty($this->tokens))
+ return $this->tokens[$this->tokenIndex];
+ }
+
+ public function get($chunksize = 1000)
+ {
+ while($this->lookahead)
+ {
+ $this->lookahead--;
+ $this->tokenIndex = ($this->tokenIndex + 1) & 3;
+ $token = $this->tokens[$this->tokenIndex];
+ if ($token->type != TOKEN_NEWLINE || $this->scanNewlines)
+ return $token->type;
+ }
+
+ $conditional_comment = false;
+
+ // strip whitespace and comments
+ while(true)
+ {
+ $input = $this->getInput($chunksize);
+
+ // whitespace handling; gobble up \r as well (effectively we don't have support for MAC newlines!)
+ $re = $this->scanNewlines ? '/^[ \r\t]+/' : '/^\s+/';
+ if (preg_match($re, $input, $match))
+ {
+ $spaces = $match[0];
+ $spacelen = strlen($spaces);
+ $this->cursor += $spacelen;
+ if (!$this->scanNewlines)
+ $this->lineno += substr_count($spaces, "\n");
+
+ if ($spacelen == $chunksize)
+ continue; // complete chunk contained whitespace
+
+ $input = $this->getInput($chunksize);
+ if ($input == '' || $input[0] != '/')
+ break;
+ }
+
+ // Comments
+ if (!preg_match('/^\/(?:\*(@(?:cc_on|if|elif|else|end))?.*?\*\/|\/[^\n]*)/s', $input, $match))
+ {
+ if (!$chunksize)
+ break;
+
+ // retry with a full chunk fetch; this also prevents breakage of long regular expressions (which will never match a comment)
+ $chunksize = null;
+ continue;
+ }
+
+ // check if this is a conditional (JScript) comment
+ if (!empty($match[1]))
+ {
+ $match[0] = '/*' . $match[1];
+ $conditional_comment = true;
+ break;
+ }
+ else
+ {
+ $this->cursor += strlen($match[0]);
+ $this->lineno += substr_count($match[0], "\n");
+ }
+ }
+
+ if ($input == '')
+ {
+ $tt = TOKEN_END;
+ $match = array('');
+ }
+ elseif ($conditional_comment)
+ {
+ $tt = TOKEN_CONDCOMMENT_START;
+ }
+ else
+ {
+ switch ($input[0])
+ {
+ case '0':
+ // hexadecimal
+ if (($input[1] == 'x' || $input[1] == 'X') && preg_match('/^0x[0-9a-f]+/i', $input, $match))
+ {
+ $tt = TOKEN_NUMBER;
+ break;
+ }
+ // FALL THROUGH
+
+ case '1': case '2': case '3': case '4': case '5':
+ case '6': case '7': case '8': case '9':
+ // should always match
+ preg_match('/^\d+(?:\.\d*)?(?:[eE][-+]?\d+)?/', $input, $match);
+ $tt = TOKEN_NUMBER;
+ break;
+
+ case "'":
+ if (preg_match('/^\'(?:[^\\\\\'\r\n]++|\\\\(?:.|\r?\n))*\'/', $input, $match))
+ {
+ $tt = TOKEN_STRING;
+ }
+ else
+ {
+ if ($chunksize)
+ return $this->get(null); // retry with a full chunk fetch
+
+ throw $this->newSyntaxError('Unterminated string literal');
+ }
+ break;
+
+ case '"':
+ if (preg_match('/^"(?:[^\\\\"\r\n]++|\\\\(?:.|\r?\n))*"/', $input, $match))
+ {
+ $tt = TOKEN_STRING;
+ }
+ else
+ {
+ if ($chunksize)
+ return $this->get(null); // retry with a full chunk fetch
+
+ throw $this->newSyntaxError('Unterminated string literal');
+ }
+ break;
+
+ case '/':
+ if ($this->scanOperand && preg_match('/^\/((?:\\\\.|\[(?:\\\\.|[^\]])*\]|[^\/])+)\/([gimy]*)/', $input, $match))
+ {
+ $tt = TOKEN_REGEXP;
+ break;
+ }
+ // FALL THROUGH
+
+ case '|':
+ case '^':
+ case '&':
+ case '<':
+ case '>':
+ case '+':
+ case '-':
+ case '*':
+ case '%':
+ case '=':
+ case '!':
+ // should always match
+ preg_match($this->opRegExp, $input, $match);
+ $op = $match[0];
+ if (in_array($op, $this->assignOps) && $input[strlen($op)] == '=')
+ {
+ $tt = OP_ASSIGN;
+ $match[0] .= '=';
+ }
+ else
+ {
+ $tt = $op;
+ if ($this->scanOperand)
+ {
+ if ($op == OP_PLUS)
+ $tt = OP_UNARY_PLUS;
+ elseif ($op == OP_MINUS)
+ $tt = OP_UNARY_MINUS;
+ }
+ $op = null;
+ }
+ break;
+
+ case '.':
+ if (preg_match('/^\.\d+(?:[eE][-+]?\d+)?/', $input, $match))
+ {
+ $tt = TOKEN_NUMBER;
+ break;
+ }
+ // FALL THROUGH
+
+ case ';':
+ case ',':
+ case '?':
+ case ':':
+ case '~':
+ case '[':
+ case ']':
+ case '{':
+ case '}':
+ case '(':
+ case ')':
+ // these are all single
+ $match = array($input[0]);
+ $tt = $input[0];
+ break;
+
+ case '@':
+ // check end of conditional comment
+ if (substr($input, 0, 3) == '@*/')
+ {
+ $match = array('@*/');
+ $tt = TOKEN_CONDCOMMENT_END;
+ }
+ else
+ throw $this->newSyntaxError('Illegal token');
+ break;
+
+ case "\n":
+ if ($this->scanNewlines)
+ {
+ $match = array("\n");
+ $tt = TOKEN_NEWLINE;
+ }
+ else
+ throw $this->newSyntaxError('Illegal token');
+ break;
+
+ default:
+ // Fast path for identifiers: word chars followed by whitespace or various other tokens.
+ // Note we don't need to exclude digits in the first char, as they've already been found
+ // above.
+ if (!preg_match('/^[$\w]+(?=[\s\/\|\^\&<>\+\-\*%=!.;,\?:~\[\]\{\}\(\)@])/', $input, $match))
+ {
+ // Character classes per ECMA-262 edition 5.1 section 7.6
+ // Per spec, must accept Unicode 3.0, *may* accept later versions.
+ // We'll take whatever PCRE understands, which should be more recent.
+ $identifierStartChars = "\\p{L}\\p{Nl}" . # UnicodeLetter
+ "\$" .
+ "_";
+ $identifierPartChars = $identifierStartChars .
+ "\\p{Mn}\\p{Mc}" . # UnicodeCombiningMark
+ "\\p{Nd}" . # UnicodeDigit
+ "\\p{Pc}"; # UnicodeConnectorPunctuation
+ $unicodeEscape = "\\\\u[0-9A-F-a-f]{4}";
+ $identifierRegex = "/^" .
+ "(?:[$identifierStartChars]|$unicodeEscape)" .
+ "(?:[$identifierPartChars]|$unicodeEscape)*" .
+ "/uS";
+ if (preg_match($identifierRegex, $input, $match))
+ {
+ if (strpos($match[0], '\\') !== false) {
+ // Per ECMA-262 edition 5.1, section 7.6 escape sequences should behave as if they were
+ // the original chars, but only within the boundaries of the identifier.
+ $decoded = preg_replace_callback('/\\\\u([0-9A-Fa-f]{4})/',
+ array(__CLASS__, 'unicodeEscapeCallback'),
+ $match[0]);
+
+ // Since our original regex didn't de-escape the originals, we need to check for validity again.
+ // No need to worry about token boundaries, as anything outside the identifier is illegal!
+ if (!preg_match("/^[$identifierStartChars][$identifierPartChars]*$/u", $decoded)) {
+ throw $this->newSyntaxError('Illegal token');
+ }
+
+ // Per spec it _ought_ to work to use these escapes for keywords words as well...
+ // but IE rejects them as invalid, while Firefox and Chrome treat them as identifiers
+ // that don't match the keyword.
+ if (in_array($decoded, $this->keywords)) {
+ throw $this->newSyntaxError('Illegal token');
+ }
+
+ // TODO: save the decoded form for output?
+ }
+ }
+ else
+ throw $this->newSyntaxError('Illegal token');
+ }
+ $tt = in_array($match[0], $this->keywords) ? $match[0] : TOKEN_IDENTIFIER;
+ }
+ }
+
+ $this->tokenIndex = ($this->tokenIndex + 1) & 3;
+
+ if (!isset($this->tokens[$this->tokenIndex]))
+ $this->tokens[$this->tokenIndex] = new JSToken();
+
+ $token = $this->tokens[$this->tokenIndex];
+ $token->type = $tt;
+
+ if ($tt == OP_ASSIGN)
+ $token->assignOp = $op;
+
+ $token->start = $this->cursor;
+
+ $token->value = $match[0];
+ $this->cursor += strlen($match[0]);
+
+ $token->end = $this->cursor;
+ $token->lineno = $this->lineno;
+
+ return $tt;
+ }
+
+ public function unget()
+ {
+ if (++$this->lookahead == 4)
+ throw $this->newSyntaxError('PANIC: too much lookahead!');
+
+ $this->tokenIndex = ($this->tokenIndex - 1) & 3;
+ }
+
+ public function newSyntaxError($m)
+ {
+ return new Exception('Parse error: ' . $m . ' in file \'' . $this->filename . '\' on line ' . $this->lineno);
+ }
+
+ public static function unicodeEscapeCallback($m)
+ {
+ return html_entity_decode('&#x' . $m[1]. ';', ENT_QUOTES, 'UTF-8');
+ }
+}
+
+class JSToken
+{
+ public $type;
+ public $value;
+ public $start;
+ public $end;
+ public $lineno;
+ public $assignOp;
+}
diff --git a/www/wiki/includes/libs/lockmanager/DBLockManager.php b/www/wiki/includes/libs/lockmanager/DBLockManager.php
new file mode 100644
index 00000000..26e25f93
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/DBLockManager.php
@@ -0,0 +1,231 @@
+<?php
+/**
+ * Version of LockManager based on using DB table locks.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * Version of LockManager based on using named/row DB locks.
+ *
+ * This is meant for multi-wiki systems that may share files.
+ *
+ * All lock requests for a resource, identified by a hash string, will map to one bucket.
+ * Each bucket maps to one or several peer DBs, each on their own server.
+ * A majority of peer DBs must agree for a lock to be acquired.
+ *
+ * Caching is used to avoid hitting servers that are down.
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+abstract class DBLockManager extends QuorumLockManager {
+ /** @var array[]|IDatabase[] Map of (DB names => server config or IDatabase) */
+ protected $dbServers; // (DB name => server config array)
+ /** @var BagOStuff */
+ protected $statusCache;
+
+ protected $lockExpiry; // integer number of seconds
+ protected $safeDelay; // integer number of seconds
+ /** @var IDatabase[] Map Database connections (DB name => Database) */
+ protected $conns = [];
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Parameters include:
+ * - dbServers : Associative array of DB names to server configuration.
+ * Configuration is an associative array that includes:
+ * - host : DB server name
+ * - dbname : DB name
+ * - type : DB type (mysql,postgres,...)
+ * - user : DB user
+ * - password : DB user password
+ * - tablePrefix : DB table prefix
+ * - flags : DB flags; bitfield of IDatabase::DBO_* constants
+ * - dbsByBucket : Array of 1-16 consecutive integer keys, starting from 0,
+ * each having an odd-numbered list of DB names (peers) as values.
+ * - lockExpiry : Lock timeout (seconds) for dropped connections. [optional]
+ * This tells the DB server how long to wait before assuming
+ * connection failure and releasing all the locks for a session.
+ * - srvCache : A BagOStuff instance using APC or the like.
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->dbServers = $config['dbServers'];
+ // Sanitize srvsByBucket config to prevent PHP errors
+ $this->srvsByBucket = array_filter( $config['dbsByBucket'], 'is_array' );
+ $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive
+
+ if ( isset( $config['lockExpiry'] ) ) {
+ $this->lockExpiry = $config['lockExpiry'];
+ } else {
+ $met = ini_get( 'max_execution_time' );
+ $this->lockExpiry = $met ? $met : 60; // use some sane amount if 0
+ }
+ $this->safeDelay = ( $this->lockExpiry <= 0 )
+ ? 60 // pick a safe-ish number to match DB timeout default
+ : $this->lockExpiry; // cover worst case
+
+ // Tracks peers that couldn't be queried recently to avoid lengthy
+ // connection timeouts. This is useless if each bucket has one peer.
+ $this->statusCache = isset( $config['srvCache'] )
+ ? $config['srvCache']
+ : new HashBagOStuff();
+ }
+
+ /**
+ * @TODO change this code to work in one batch
+ * @param string $lockSrv
+ * @param array $pathsByType
+ * @return StatusValue
+ */
+ protected function getLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = StatusValue::newGood();
+ foreach ( $pathsByType as $type => $paths ) {
+ $status->merge( $this->doGetLocksOnServer( $lockSrv, $paths, $type ) );
+ }
+
+ return $status;
+ }
+
+ abstract protected function doGetLocksOnServer( $lockSrv, array $paths, $type );
+
+ protected function freeLocksOnServer( $lockSrv, array $pathsByType ) {
+ return StatusValue::newGood();
+ }
+
+ /**
+ * @see QuorumLockManager::isServerUp()
+ * @param string $lockSrv
+ * @return bool
+ */
+ protected function isServerUp( $lockSrv ) {
+ if ( !$this->cacheCheckFailures( $lockSrv ) ) {
+ return false; // recent failure to connect
+ }
+ try {
+ $this->getConnection( $lockSrv );
+ } catch ( DBError $e ) {
+ $this->cacheRecordFailure( $lockSrv );
+
+ return false; // failed to connect
+ }
+
+ return true;
+ }
+
+ /**
+ * Get (or reuse) a connection to a lock DB
+ *
+ * @param string $lockDb
+ * @return IDatabase
+ * @throws DBError
+ * @throws UnexpectedValueException
+ */
+ protected function getConnection( $lockDb ) {
+ if ( !isset( $this->conns[$lockDb] ) ) {
+ if ( $this->dbServers[$lockDb] instanceof IDatabase ) {
+ // Direct injected connection hande for $lockDB
+ $db = $this->dbServers[$lockDb];
+ } elseif ( is_array( $this->dbServers[$lockDb] ) ) {
+ // Parameters to construct a new database connection
+ $config = $this->dbServers[$lockDb];
+ $db = Database::factory( $config['type'], $config );
+ } else {
+ throw new UnexpectedValueException( "No server called '$lockDb'." );
+ }
+
+ $db->clearFlag( DBO_TRX );
+ # If the connection drops, try to avoid letting the DB rollback
+ # and release the locks before the file operations are finished.
+ # This won't handle the case of DB server restarts however.
+ $options = [];
+ if ( $this->lockExpiry > 0 ) {
+ $options['connTimeout'] = $this->lockExpiry;
+ }
+ $db->setSessionOptions( $options );
+ $this->initConnection( $lockDb, $db );
+
+ $this->conns[$lockDb] = $db;
+ }
+
+ return $this->conns[$lockDb];
+ }
+
+ /**
+ * Do additional initialization for new lock DB connection
+ *
+ * @param string $lockDb
+ * @param IDatabase $db
+ * @throws DBError
+ */
+ protected function initConnection( $lockDb, IDatabase $db ) {
+ }
+
+ /**
+ * Checks if the DB has not recently had connection/query errors.
+ * This just avoids wasting time on doomed connection attempts.
+ *
+ * @param string $lockDb
+ * @return bool
+ */
+ protected function cacheCheckFailures( $lockDb ) {
+ return ( $this->safeDelay > 0 )
+ ? !$this->statusCache->get( $this->getMissKey( $lockDb ) )
+ : true;
+ }
+
+ /**
+ * Log a lock request failure to the cache
+ *
+ * @param string $lockDb
+ * @return bool Success
+ */
+ protected function cacheRecordFailure( $lockDb ) {
+ return ( $this->safeDelay > 0 )
+ ? $this->statusCache->set( $this->getMissKey( $lockDb ), 1, $this->safeDelay )
+ : true;
+ }
+
+ /**
+ * Get a cache key for recent query misses for a DB
+ *
+ * @param string $lockDb
+ * @return string
+ */
+ protected function getMissKey( $lockDb ) {
+ return 'dblockmanager:downservers:' . str_replace( ' ', '_', $lockDb );
+ }
+
+ /**
+ * Make sure remaining locks get cleared for sanity
+ */
+ function __destruct() {
+ $this->releaseAllLocks();
+ foreach ( $this->conns as $db ) {
+ $db->close();
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/lockmanager/FSLockManager.php b/www/wiki/includes/libs/lockmanager/FSLockManager.php
new file mode 100644
index 00000000..7f33a0ab
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/FSLockManager.php
@@ -0,0 +1,253 @@
+<?php
+/**
+ * Simple version of LockManager based on using FS lock files.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Simple version of LockManager based on using FS lock files.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * This should work fine for small sites running off one server.
+ * Do not use this with 'lockDirectory' set to an NFS mount unless the
+ * NFS client is at least version 2.6.12. Otherwise, the BSD flock()
+ * locks will be ignored; see http://nfs.sourceforge.net/#section_d.
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+class FSLockManager extends LockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ /** @var string Global dir for all servers */
+ protected $lockDir;
+
+ /** @var array Map of (locked key => lock file handle) */
+ protected $handles = [];
+
+ /** @var bool */
+ protected $isWindows;
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Includes:
+ * - lockDirectory : Directory containing the lock files
+ */
+ function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->lockDir = $config['lockDirectory'];
+ $this->isWindows = ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' );
+ }
+
+ /**
+ * @see LockManager::doLock()
+ * @param array $paths
+ * @param int $type
+ * @return StatusValue
+ */
+ protected function doLock( array $paths, $type ) {
+ $status = StatusValue::newGood();
+
+ $lockedPaths = []; // files locked in this attempt
+ foreach ( $paths as $path ) {
+ $status->merge( $this->doSingleLock( $path, $type ) );
+ if ( $status->isOK() ) {
+ $lockedPaths[] = $path;
+ } else {
+ // Abort and unlock everything
+ $status->merge( $this->doUnlock( $lockedPaths, $type ) );
+
+ return $status;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see LockManager::doUnlock()
+ * @param array $paths
+ * @param int $type
+ * @return StatusValue
+ */
+ protected function doUnlock( array $paths, $type ) {
+ $status = StatusValue::newGood();
+
+ foreach ( $paths as $path ) {
+ $status->merge( $this->doSingleUnlock( $path, $type ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Lock a single resource key
+ *
+ * @param string $path
+ * @param int $type
+ * @return StatusValue
+ */
+ protected function doSingleLock( $path, $type ) {
+ $status = StatusValue::newGood();
+
+ if ( isset( $this->locksHeld[$path][$type] ) ) {
+ ++$this->locksHeld[$path][$type];
+ } elseif ( isset( $this->locksHeld[$path][self::LOCK_EX] ) ) {
+ $this->locksHeld[$path][$type] = 1;
+ } else {
+ if ( isset( $this->handles[$path] ) ) {
+ $handle = $this->handles[$path];
+ } else {
+ MediaWiki\suppressWarnings();
+ $handle = fopen( $this->getLockPath( $path ), 'a+' );
+ if ( !$handle ) { // lock dir missing?
+ mkdir( $this->lockDir, 0777, true );
+ $handle = fopen( $this->getLockPath( $path ), 'a+' ); // try again
+ }
+ MediaWiki\restoreWarnings();
+ }
+ if ( $handle ) {
+ // Either a shared or exclusive lock
+ $lock = ( $type == self::LOCK_SH ) ? LOCK_SH : LOCK_EX;
+ if ( flock( $handle, $lock | LOCK_NB ) ) {
+ // Record this lock as active
+ $this->locksHeld[$path][$type] = 1;
+ $this->handles[$path] = $handle;
+ } else {
+ fclose( $handle );
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ } else {
+ $status->fatal( 'lockmanager-fail-openlock', $path );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Unlock a single resource key
+ *
+ * @param string $path
+ * @param int $type
+ * @return StatusValue
+ */
+ protected function doSingleUnlock( $path, $type ) {
+ $status = StatusValue::newGood();
+
+ if ( !isset( $this->locksHeld[$path] ) ) {
+ $status->warning( 'lockmanager-notlocked', $path );
+ } elseif ( !isset( $this->locksHeld[$path][$type] ) ) {
+ $status->warning( 'lockmanager-notlocked', $path );
+ } else {
+ $handlesToClose = [];
+ --$this->locksHeld[$path][$type];
+ if ( $this->locksHeld[$path][$type] <= 0 ) {
+ unset( $this->locksHeld[$path][$type] );
+ }
+ if ( !count( $this->locksHeld[$path] ) ) {
+ unset( $this->locksHeld[$path] ); // no locks on this path
+ if ( isset( $this->handles[$path] ) ) {
+ $handlesToClose[] = $this->handles[$path];
+ unset( $this->handles[$path] );
+ }
+ }
+ // Unlock handles to release locks and delete
+ // any lock files that end up with no locks on them...
+ if ( $this->isWindows ) {
+ // Windows: for any process, including this one,
+ // calling unlink() on a locked file will fail
+ $status->merge( $this->closeLockHandles( $path, $handlesToClose ) );
+ $status->merge( $this->pruneKeyLockFiles( $path ) );
+ } else {
+ // Unix: unlink() can be used on files currently open by this
+ // process and we must do so in order to avoid race conditions
+ $status->merge( $this->pruneKeyLockFiles( $path ) );
+ $status->merge( $this->closeLockHandles( $path, $handlesToClose ) );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param string $path
+ * @param array $handlesToClose
+ * @return StatusValue
+ */
+ private function closeLockHandles( $path, array $handlesToClose ) {
+ $status = StatusValue::newGood();
+ foreach ( $handlesToClose as $handle ) {
+ if ( !flock( $handle, LOCK_UN ) ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+ if ( !fclose( $handle ) ) {
+ $status->warning( 'lockmanager-fail-closelock', $path );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param string $path
+ * @return StatusValue
+ */
+ private function pruneKeyLockFiles( $path ) {
+ $status = StatusValue::newGood();
+ if ( !isset( $this->locksHeld[$path] ) ) {
+ # No locks are held for the lock file anymore
+ if ( !unlink( $this->getLockPath( $path ) ) ) {
+ $status->warning( 'lockmanager-fail-deletelock', $path );
+ }
+ unset( $this->handles[$path] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get the path to the lock file for a key
+ * @param string $path
+ * @return string
+ */
+ protected function getLockPath( $path ) {
+ return "{$this->lockDir}/{$this->sha1Base36Absolute( $path )}.lock";
+ }
+
+ /**
+ * Make sure remaining locks get cleared for sanity
+ */
+ function __destruct() {
+ while ( count( $this->locksHeld ) ) {
+ foreach ( $this->locksHeld as $path => $locks ) {
+ $this->doSingleUnlock( $path, self::LOCK_EX );
+ $this->doSingleUnlock( $path, self::LOCK_SH );
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/lockmanager/LockManager.php b/www/wiki/includes/libs/lockmanager/LockManager.php
new file mode 100644
index 00000000..a6257bfd
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/LockManager.php
@@ -0,0 +1,267 @@
+<?php
+/**
+ * @defgroup LockManager Lock management
+ * @ingroup FileBackend
+ */
+use Psr\Log\LoggerInterface;
+use Wikimedia\WaitConditionLoop;
+
+/**
+ * Resource locking handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * @brief Class for handling resource locking.
+ *
+ * Locks on resource keys can either be shared or exclusive.
+ *
+ * Implementations must keep track of what is locked by this process
+ * in-memory and support nested locking calls (using reference counting).
+ * At least LOCK_UW and LOCK_EX must be implemented. LOCK_SH can be a no-op.
+ * Locks should either be non-blocking or have low wait timeouts.
+ *
+ * Subclasses should avoid throwing exceptions at all costs.
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+abstract class LockManager {
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_EX, // subclasses may use self::LOCK_SH
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ /** @var array Map of (resource path => lock type => count) */
+ protected $locksHeld = [];
+
+ protected $domain; // string; domain (usually wiki ID)
+ protected $lockTTL; // integer; maximum time locks can be held
+
+ /** @var string Random 32-char hex number */
+ protected $session;
+
+ /** Lock types; stronger locks have higher values */
+ const LOCK_SH = 1; // shared lock (for reads)
+ const LOCK_UW = 2; // shared lock (for reads used to write elsewhere)
+ const LOCK_EX = 3; // exclusive lock (for writes)
+
+ /** @var int Max expected lock expiry in any context */
+ const MAX_LOCK_TTL = 7200; // 2 hours
+
+ /**
+ * Construct a new instance from configuration
+ *
+ * @param array $config Parameters include:
+ * - domain : Domain (usually wiki ID) that all resources are relative to [optional]
+ * - lockTTL : Age (in seconds) at which resource locks should expire.
+ * This only applies if locks are not tied to a connection/process.
+ */
+ public function __construct( array $config ) {
+ $this->domain = isset( $config['domain'] ) ? $config['domain'] : 'global';
+ if ( isset( $config['lockTTL'] ) ) {
+ $this->lockTTL = max( 5, $config['lockTTL'] );
+ } elseif ( PHP_SAPI === 'cli' ) {
+ $this->lockTTL = 3600;
+ } else {
+ $met = ini_get( 'max_execution_time' ); // this is 0 in CLI mode
+ $this->lockTTL = max( 5 * 60, 2 * (int)$met );
+ }
+
+ // Upper bound on how long to keep lock structures around. This is useful when setting
+ // TTLs, as the "lockTTL" value may vary based on CLI mode and app server group. This is
+ // a "safe" value that can be used to avoid clobbering other locks that use high TTLs.
+ $this->lockTTL = min( $this->lockTTL, self::MAX_LOCK_TTL );
+
+ $random = [];
+ for ( $i = 1; $i <= 5; ++$i ) {
+ $random[] = mt_rand( 0, 0xFFFFFFF );
+ }
+ $this->session = md5( implode( '-', $random ) );
+
+ $this->logger = isset( $config['logger'] ) ? $config['logger'] : new \Psr\Log\NullLogger();
+ }
+
+ /**
+ * Lock the resources at the given abstract paths
+ *
+ * @param array $paths List of resource names
+ * @param int $type LockManager::LOCK_* constant
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
+ * @return StatusValue
+ */
+ final public function lock( array $paths, $type = self::LOCK_EX, $timeout = 0 ) {
+ return $this->lockByType( [ $type => $paths ], $timeout );
+ }
+
+ /**
+ * Lock the resources at the given abstract paths
+ *
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
+ * @return StatusValue
+ * @since 1.22
+ */
+ final public function lockByType( array $pathsByType, $timeout = 0 ) {
+ $pathsByType = $this->normalizePathsByType( $pathsByType );
+
+ $status = null;
+ $loop = new WaitConditionLoop(
+ function () use ( &$status, $pathsByType ) {
+ $status = $this->doLockByType( $pathsByType );
+
+ return $status->isOK() ?: WaitConditionLoop::CONDITION_CONTINUE;
+ },
+ $timeout
+ );
+ $loop->invoke();
+
+ return $status;
+ }
+
+ /**
+ * Unlock the resources at the given abstract paths
+ *
+ * @param array $paths List of paths
+ * @param int $type LockManager::LOCK_* constant
+ * @return StatusValue
+ */
+ final public function unlock( array $paths, $type = self::LOCK_EX ) {
+ return $this->unlockByType( [ $type => $paths ] );
+ }
+
+ /**
+ * Unlock the resources at the given abstract paths
+ *
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return StatusValue
+ * @since 1.22
+ */
+ final public function unlockByType( array $pathsByType ) {
+ $pathsByType = $this->normalizePathsByType( $pathsByType );
+ $status = $this->doUnlockByType( $pathsByType );
+
+ return $status;
+ }
+
+ /**
+ * Get the base 36 SHA-1 of a string, padded to 31 digits.
+ * Before hashing, the path will be prefixed with the domain ID.
+ * This should be used internally for lock key or file names.
+ *
+ * @param string $path
+ * @return string
+ */
+ final protected function sha1Base36Absolute( $path ) {
+ return Wikimedia\base_convert( sha1( "{$this->domain}:{$path}" ), 16, 36, 31 );
+ }
+
+ /**
+ * Get the base 16 SHA-1 of a string, padded to 31 digits.
+ * Before hashing, the path will be prefixed with the domain ID.
+ * This should be used internally for lock key or file names.
+ *
+ * @param string $path
+ * @return string
+ */
+ final protected function sha1Base16Absolute( $path ) {
+ return sha1( "{$this->domain}:{$path}" );
+ }
+
+ /**
+ * Normalize the $paths array by converting LOCK_UW locks into the
+ * appropriate type and removing any duplicated paths for each lock type.
+ *
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return array
+ * @since 1.22
+ */
+ final protected function normalizePathsByType( array $pathsByType ) {
+ $res = [];
+ foreach ( $pathsByType as $type => $paths ) {
+ $res[$this->lockTypeMap[$type]] = array_unique( $paths );
+ }
+
+ return $res;
+ }
+
+ /**
+ * @see LockManager::lockByType()
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return StatusValue
+ * @since 1.22
+ */
+ protected function doLockByType( array $pathsByType ) {
+ $status = StatusValue::newGood();
+ $lockedByType = []; // map of (type => paths)
+ foreach ( $pathsByType as $type => $paths ) {
+ $status->merge( $this->doLock( $paths, $type ) );
+ if ( $status->isOK() ) {
+ $lockedByType[$type] = $paths;
+ } else {
+ // Release the subset of locks that were acquired
+ foreach ( $lockedByType as $lType => $lPaths ) {
+ $status->merge( $this->doUnlock( $lPaths, $lType ) );
+ }
+ break;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Lock resources with the given keys and lock type
+ *
+ * @param array $paths List of paths
+ * @param int $type LockManager::LOCK_* constant
+ * @return StatusValue
+ */
+ abstract protected function doLock( array $paths, $type );
+
+ /**
+ * @see LockManager::unlockByType()
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return StatusValue
+ * @since 1.22
+ */
+ protected function doUnlockByType( array $pathsByType ) {
+ $status = StatusValue::newGood();
+ foreach ( $pathsByType as $type => $paths ) {
+ $status->merge( $this->doUnlock( $paths, $type ) );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Unlock resources with the given keys and lock type
+ *
+ * @param array $paths List of paths
+ * @param int $type LockManager::LOCK_* constant
+ * @return StatusValue
+ */
+ abstract protected function doUnlock( array $paths, $type );
+}
diff --git a/www/wiki/includes/libs/lockmanager/MemcLockManager.php b/www/wiki/includes/libs/lockmanager/MemcLockManager.php
new file mode 100644
index 00000000..aecdf60c
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/MemcLockManager.php
@@ -0,0 +1,356 @@
+<?php
+/**
+ * Version of LockManager based on using memcached servers.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+use Wikimedia\WaitConditionLoop;
+
+/**
+ * Manage locks using memcached servers.
+ *
+ * Version of LockManager based on using memcached servers.
+ * This is meant for multi-wiki systems that may share files.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * All lock requests for a resource, identified by a hash string, will map to one
+ * bucket. Each bucket maps to one or several peer servers, each running memcached.
+ * A majority of peers must agree for a lock to be acquired.
+ *
+ * @ingroup LockManager
+ * @since 1.20
+ */
+class MemcLockManager extends QuorumLockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ /** @var MemcachedBagOStuff[] Map of (server name => MemcachedBagOStuff) */
+ protected $cacheServers = [];
+ /** @var HashBagOStuff Server status cache */
+ protected $statusCache;
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Parameters include:
+ * - lockServers : Associative array of server names to "<IP>:<port>" strings.
+ * - srvsByBucket : Array of 1-16 consecutive integer keys, starting from 0,
+ * each having an odd-numbered list of server names (peers) as values.
+ * - memcConfig : Configuration array for MemcachedBagOStuff::construct() with an
+ * additional 'class' parameter specifying which MemcachedBagOStuff
+ * subclass to use. The server names will be injected. [optional]
+ * @throws Exception
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ // Sanitize srvsByBucket config to prevent PHP errors
+ $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' );
+ $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive
+
+ $memcConfig = isset( $config['memcConfig'] ) ? $config['memcConfig'] : [];
+ $memcConfig += [ 'class' => 'MemcachedPhpBagOStuff' ]; // default
+
+ $class = $memcConfig['class'];
+ if ( !is_subclass_of( $class, 'MemcachedBagOStuff' ) ) {
+ throw new InvalidArgumentException( "$class is not of type MemcachedBagOStuff." );
+ }
+
+ foreach ( $config['lockServers'] as $name => $address ) {
+ $params = [ 'servers' => [ $address ] ] + $memcConfig;
+ $this->cacheServers[$name] = new $class( $params );
+ }
+
+ $this->statusCache = new HashBagOStuff();
+ }
+
+ protected function getLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = StatusValue::newGood();
+
+ $memc = $this->getCache( $lockSrv );
+ // List of affected paths
+ $paths = call_user_func_array( 'array_merge', array_values( $pathsByType ) );
+ $paths = array_unique( $paths );
+ // List of affected lock record keys
+ $keys = array_map( [ $this, 'recordKeyForPath' ], $paths );
+
+ // Lock all of the active lock record keys...
+ if ( !$this->acquireMutexes( $memc, $keys ) ) {
+ foreach ( $paths as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+
+ return $status;
+ }
+
+ // Fetch all the existing lock records...
+ $lockRecords = $memc->getMulti( $keys );
+
+ $now = time();
+ // Check if the requested locks conflict with existing ones...
+ foreach ( $pathsByType as $type => $paths ) {
+ foreach ( $paths as $path ) {
+ $locksKey = $this->recordKeyForPath( $path );
+ $locksHeld = isset( $lockRecords[$locksKey] )
+ ? self::sanitizeLockArray( $lockRecords[$locksKey] )
+ : self::newLockArray(); // init
+ foreach ( $locksHeld[self::LOCK_EX] as $session => $expiry ) {
+ if ( $expiry < $now ) { // stale?
+ unset( $locksHeld[self::LOCK_EX][$session] );
+ } elseif ( $session !== $this->session ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ }
+ if ( $type === self::LOCK_EX ) {
+ foreach ( $locksHeld[self::LOCK_SH] as $session => $expiry ) {
+ if ( $expiry < $now ) { // stale?
+ unset( $locksHeld[self::LOCK_SH][$session] );
+ } elseif ( $session !== $this->session ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ }
+ }
+ if ( $status->isOK() ) {
+ // Register the session in the lock record array
+ $locksHeld[$type][$this->session] = $now + $this->lockTTL;
+ // We will update this record if none of the other locks conflict
+ $lockRecords[$locksKey] = $locksHeld;
+ }
+ }
+ }
+
+ // If there were no lock conflicts, update all the lock records...
+ if ( $status->isOK() ) {
+ foreach ( $paths as $path ) {
+ $locksKey = $this->recordKeyForPath( $path );
+ $locksHeld = $lockRecords[$locksKey];
+ $ok = $memc->set( $locksKey, $locksHeld, self::MAX_LOCK_TTL );
+ if ( !$ok ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ } else {
+ $this->logger->debug( __METHOD__ . ": acquired lock on key $locksKey.\n" );
+ }
+ }
+ }
+
+ // Unlock all of the active lock record keys...
+ $this->releaseMutexes( $memc, $keys );
+
+ return $status;
+ }
+
+ protected function freeLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = StatusValue::newGood();
+
+ $memc = $this->getCache( $lockSrv );
+ // List of affected paths
+ $paths = call_user_func_array( 'array_merge', array_values( $pathsByType ) );
+ $paths = array_unique( $paths );
+ // List of affected lock record keys
+ $keys = array_map( [ $this, 'recordKeyForPath' ], $paths );
+
+ // Lock all of the active lock record keys...
+ if ( !$this->acquireMutexes( $memc, $keys ) ) {
+ foreach ( $paths as $path ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+
+ return $status;
+ }
+
+ // Fetch all the existing lock records...
+ $lockRecords = $memc->getMulti( $keys );
+
+ // Remove the requested locks from all records...
+ foreach ( $pathsByType as $type => $paths ) {
+ foreach ( $paths as $path ) {
+ $locksKey = $this->recordKeyForPath( $path ); // lock record
+ if ( !isset( $lockRecords[$locksKey] ) ) {
+ $status->warning( 'lockmanager-fail-releaselock', $path );
+ continue; // nothing to do
+ }
+ $locksHeld = $this->sanitizeLockArray( $lockRecords[$locksKey] );
+ if ( isset( $locksHeld[$type][$this->session] ) ) {
+ unset( $locksHeld[$type][$this->session] ); // unregister this session
+ $lockRecords[$locksKey] = $locksHeld;
+ } else {
+ $status->warning( 'lockmanager-fail-releaselock', $path );
+ }
+ }
+ }
+
+ // Persist the new lock record values...
+ foreach ( $paths as $path ) {
+ $locksKey = $this->recordKeyForPath( $path );
+ if ( !isset( $lockRecords[$locksKey] ) ) {
+ continue; // nothing to do
+ }
+ $locksHeld = $lockRecords[$locksKey];
+ if ( $locksHeld === $this->newLockArray() ) {
+ $ok = $memc->delete( $locksKey );
+ } else {
+ $ok = $memc->set( $locksKey, $locksHeld, self::MAX_LOCK_TTL );
+ }
+ if ( $ok ) {
+ $this->logger->debug( __METHOD__ . ": released lock on key $locksKey.\n" );
+ } else {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+ }
+
+ // Unlock all of the active lock record keys...
+ $this->releaseMutexes( $memc, $keys );
+
+ return $status;
+ }
+
+ /**
+ * @see QuorumLockManager::releaseAllLocks()
+ * @return StatusValue
+ */
+ protected function releaseAllLocks() {
+ return StatusValue::newGood(); // not supported
+ }
+
+ /**
+ * @see QuorumLockManager::isServerUp()
+ * @param string $lockSrv
+ * @return bool
+ */
+ protected function isServerUp( $lockSrv ) {
+ return (bool)$this->getCache( $lockSrv );
+ }
+
+ /**
+ * Get the MemcachedBagOStuff object for a $lockSrv
+ *
+ * @param string $lockSrv Server name
+ * @return MemcachedBagOStuff|null
+ */
+ protected function getCache( $lockSrv ) {
+ if ( !isset( $this->cacheServers[$lockSrv] ) ) {
+ throw new InvalidArgumentException( "Invalid cache server '$lockSrv'." );
+ }
+
+ $online = $this->statusCache->get( "online:$lockSrv" );
+ if ( $online === false ) {
+ $online = $this->cacheServers[$lockSrv]->set( __CLASS__ . ':ping', 1, 1 );
+ if ( !$online ) { // server down?
+ $this->logger->warning( __METHOD__ . ": Could not contact $lockSrv." );
+ }
+ $this->statusCache->set( "online:$lockSrv", (int)$online, 30 );
+ }
+
+ return $online ? $this->cacheServers[$lockSrv] : null;
+ }
+
+ /**
+ * @param string $path
+ * @return string
+ */
+ protected function recordKeyForPath( $path ) {
+ return implode( ':', [ __CLASS__, 'locks', $this->sha1Base36Absolute( $path ) ] );
+ }
+
+ /**
+ * @return array An empty lock structure for a key
+ */
+ protected function newLockArray() {
+ return [ self::LOCK_SH => [], self::LOCK_EX => [] ];
+ }
+
+ /**
+ * @param array $a
+ * @return array An empty lock structure for a key
+ */
+ protected function sanitizeLockArray( $a ) {
+ if ( is_array( $a ) && isset( $a[self::LOCK_EX] ) && isset( $a[self::LOCK_SH] ) ) {
+ return $a;
+ }
+
+ $this->logger->error( __METHOD__ . ": reset invalid lock array." );
+
+ return $this->newLockArray();
+ }
+
+ /**
+ * @param MemcachedBagOStuff $memc
+ * @param array $keys List of keys to acquire
+ * @return bool
+ */
+ protected function acquireMutexes( MemcachedBagOStuff $memc, array $keys ) {
+ $lockedKeys = [];
+
+ // Acquire the keys in lexicographical order, to avoid deadlock problems.
+ // If P1 is waiting to acquire a key P2 has, P2 can't also be waiting for a key P1 has.
+ sort( $keys );
+
+ // Try to quickly loop to acquire the keys, but back off after a few rounds.
+ // This reduces memcached spam, especially in the rare case where a server acquires
+ // some lock keys and dies without releasing them. Lock keys expire after a few minutes.
+ $loop = new WaitConditionLoop(
+ function () use ( $memc, $keys, &$lockedKeys ) {
+ foreach ( array_diff( $keys, $lockedKeys ) as $key ) {
+ if ( $memc->add( "$key:mutex", 1, 180 ) ) { // lock record
+ $lockedKeys[] = $key;
+ }
+ }
+
+ return array_diff( $keys, $lockedKeys )
+ ? WaitConditionLoop::CONDITION_CONTINUE
+ : true;
+ },
+ 3.0 // timeout
+ );
+ $loop->invoke();
+
+ if ( count( $lockedKeys ) != count( $keys ) ) {
+ $this->releaseMutexes( $memc, $lockedKeys ); // failed; release what was locked
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param MemcachedBagOStuff $memc
+ * @param array $keys List of acquired keys
+ */
+ protected function releaseMutexes( MemcachedBagOStuff $memc, array $keys ) {
+ foreach ( $keys as $key ) {
+ $memc->delete( "$key:mutex" );
+ }
+ }
+
+ /**
+ * Make sure remaining locks get cleared for sanity
+ */
+ function __destruct() {
+ while ( count( $this->locksHeld ) ) {
+ foreach ( $this->locksHeld as $path => $locks ) {
+ $this->doUnlock( [ $path ], self::LOCK_EX );
+ $this->doUnlock( [ $path ], self::LOCK_SH );
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/lockmanager/NullLockManager.php b/www/wiki/includes/libs/lockmanager/NullLockManager.php
new file mode 100644
index 00000000..b83462c7
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/NullLockManager.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Resource locking handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Simple version of LockManager that does nothing
+ * @since 1.19
+ */
+class NullLockManager extends LockManager {
+ protected function doLock( array $paths, $type ) {
+ return StatusValue::newGood();
+ }
+
+ protected function doUnlock( array $paths, $type ) {
+ return StatusValue::newGood();
+ }
+}
diff --git a/www/wiki/includes/libs/lockmanager/PostgreSqlLockManager.php b/www/wiki/includes/libs/lockmanager/PostgreSqlLockManager.php
new file mode 100644
index 00000000..65c69938
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/PostgreSqlLockManager.php
@@ -0,0 +1,82 @@
+<?php
+
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * PostgreSQL version of DBLockManager that supports shared locks.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * @ingroup LockManager
+ */
+class PostgreSqlLockManager extends DBLockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
+ $status = StatusValue::newGood();
+ if ( !count( $paths ) ) {
+ return $status; // nothing to lock
+ }
+
+ $db = $this->getConnection( $lockSrv ); // checked in isServerUp()
+ $bigints = array_unique( array_map(
+ function ( $key ) {
+ return Wikimedia\base_convert( substr( $key, 0, 15 ), 16, 10 );
+ },
+ array_map( [ $this, 'sha1Base16Absolute' ], $paths )
+ ) );
+
+ // Try to acquire all the locks...
+ $fields = [];
+ foreach ( $bigints as $bigint ) {
+ $fields[] = ( $type == self::LOCK_SH )
+ ? "pg_try_advisory_lock_shared({$db->addQuotes( $bigint )}) AS K$bigint"
+ : "pg_try_advisory_lock({$db->addQuotes( $bigint )}) AS K$bigint";
+ }
+ $res = $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ );
+ $row = $res->fetchRow();
+
+ if ( in_array( 'f', $row ) ) {
+ // Release any acquired locks if some could not be acquired...
+ $fields = [];
+ foreach ( $row as $kbigint => $ok ) {
+ if ( $ok === 't' ) { // locked
+ $bigint = substr( $kbigint, 1 ); // strip off the "K"
+ $fields[] = ( $type == self::LOCK_SH )
+ ? "pg_advisory_unlock_shared({$db->addQuotes( $bigint )})"
+ : "pg_advisory_unlock({$db->addQuotes( $bigint )})";
+ }
+ }
+ if ( count( $fields ) ) {
+ $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ );
+ }
+ foreach ( $paths as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * @see QuorumLockManager::releaseAllLocks()
+ * @return StatusValue
+ */
+ protected function releaseAllLocks() {
+ $status = StatusValue::newGood();
+
+ foreach ( $this->conns as $lockDb => $db ) {
+ try {
+ $db->query( "SELECT pg_advisory_unlock_all()", __METHOD__ );
+ } catch ( DBError $e ) {
+ $status->fatal( 'lockmanager-fail-db-release', $lockDb );
+ }
+ }
+
+ return $status;
+ }
+}
diff --git a/www/wiki/includes/libs/lockmanager/QuorumLockManager.php b/www/wiki/includes/libs/lockmanager/QuorumLockManager.php
new file mode 100644
index 00000000..1d2e21aa
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/QuorumLockManager.php
@@ -0,0 +1,281 @@
+<?php
+/**
+ * Version of LockManager that uses a quorum from peer servers for locks.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Version of LockManager that uses a quorum from peer servers for locks.
+ * The resource space can also be sharded into separate peer groups.
+ *
+ * @ingroup LockManager
+ * @since 1.20
+ */
+abstract class QuorumLockManager extends LockManager {
+ /** @var array Map of bucket indexes to peer server lists */
+ protected $srvsByBucket = []; // (bucket index => (lsrv1, lsrv2, ...))
+
+ /** @var array Map of degraded buckets */
+ protected $degradedBuckets = []; // (bucket index => UNIX timestamp)
+
+ final protected function doLock( array $paths, $type ) {
+ return $this->doLockByType( [ $type => $paths ] );
+ }
+
+ final protected function doUnlock( array $paths, $type ) {
+ return $this->doUnlockByType( [ $type => $paths ] );
+ }
+
+ protected function doLockByType( array $pathsByType ) {
+ $status = StatusValue::newGood();
+
+ $pathsToLock = []; // (bucket => type => paths)
+ // Get locks that need to be acquired (buckets => locks)...
+ foreach ( $pathsByType as $type => $paths ) {
+ foreach ( $paths as $path ) {
+ if ( isset( $this->locksHeld[$path][$type] ) ) {
+ ++$this->locksHeld[$path][$type];
+ } else {
+ $bucket = $this->getBucketFromPath( $path );
+ $pathsToLock[$bucket][$type][] = $path;
+ }
+ }
+ }
+
+ $lockedPaths = []; // files locked in this attempt (type => paths)
+ // Attempt to acquire these locks...
+ foreach ( $pathsToLock as $bucket => $pathsToLockByType ) {
+ // Try to acquire the locks for this bucket
+ $status->merge( $this->doLockingRequestBucket( $bucket, $pathsToLockByType ) );
+ if ( !$status->isOK() ) {
+ $status->merge( $this->doUnlockByType( $lockedPaths ) );
+
+ return $status;
+ }
+ // Record these locks as active
+ foreach ( $pathsToLockByType as $type => $paths ) {
+ foreach ( $paths as $path ) {
+ $this->locksHeld[$path][$type] = 1; // locked
+ // Keep track of what locks were made in this attempt
+ $lockedPaths[$type][] = $path;
+ }
+ }
+ }
+
+ return $status;
+ }
+
+ protected function doUnlockByType( array $pathsByType ) {
+ $status = StatusValue::newGood();
+
+ $pathsToUnlock = []; // (bucket => type => paths)
+ foreach ( $pathsByType as $type => $paths ) {
+ foreach ( $paths as $path ) {
+ if ( !isset( $this->locksHeld[$path][$type] ) ) {
+ $status->warning( 'lockmanager-notlocked', $path );
+ } else {
+ --$this->locksHeld[$path][$type];
+ // Reference count the locks held and release locks when zero
+ if ( $this->locksHeld[$path][$type] <= 0 ) {
+ unset( $this->locksHeld[$path][$type] );
+ $bucket = $this->getBucketFromPath( $path );
+ $pathsToUnlock[$bucket][$type][] = $path;
+ }
+ if ( !count( $this->locksHeld[$path] ) ) {
+ unset( $this->locksHeld[$path] ); // no SH or EX locks left for key
+ }
+ }
+ }
+ }
+
+ // Remove these specific locks if possible, or at least release
+ // all locks once this process is currently not holding any locks.
+ foreach ( $pathsToUnlock as $bucket => $pathsToUnlockByType ) {
+ $status->merge( $this->doUnlockingRequestBucket( $bucket, $pathsToUnlockByType ) );
+ }
+ if ( !count( $this->locksHeld ) ) {
+ $status->merge( $this->releaseAllLocks() );
+ $this->degradedBuckets = []; // safe to retry the normal quorum
+ }
+
+ return $status;
+ }
+
+ /**
+ * Attempt to acquire locks with the peers for a bucket.
+ * This is all or nothing; if any key is locked then this totally fails.
+ *
+ * @param int $bucket
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return StatusValue
+ */
+ final protected function doLockingRequestBucket( $bucket, array $pathsByType ) {
+ return $this->collectPledgeQuorum(
+ $bucket,
+ function ( $lockSrv ) use ( $pathsByType ) {
+ return $this->getLocksOnServer( $lockSrv, $pathsByType );
+ }
+ );
+ }
+
+ /**
+ * Attempt to release locks with the peers for a bucket
+ *
+ * @param int $bucket
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return StatusValue
+ */
+ final protected function doUnlockingRequestBucket( $bucket, array $pathsByType ) {
+ return $this->releasePledges(
+ $bucket,
+ function ( $lockSrv ) use ( $pathsByType ) {
+ return $this->freeLocksOnServer( $lockSrv, $pathsByType );
+ }
+ );
+ }
+
+ /**
+ * Attempt to acquire pledges with the peers for a bucket.
+ * This is all or nothing; if any key is already pledged then this totally fails.
+ *
+ * @param int $bucket
+ * @param callable $callback Pledge method taking a server name and yeilding a StatusValue
+ * @return StatusValue
+ */
+ final protected function collectPledgeQuorum( $bucket, callable $callback ) {
+ $status = StatusValue::newGood();
+
+ $yesVotes = 0; // locks made on trustable servers
+ $votesLeft = count( $this->srvsByBucket[$bucket] ); // remaining peers
+ $quorum = floor( $votesLeft / 2 + 1 ); // simple majority
+ // Get votes for each peer, in order, until we have enough...
+ foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) {
+ if ( !$this->isServerUp( $lockSrv ) ) {
+ --$votesLeft;
+ $status->warning( 'lockmanager-fail-svr-acquire', $lockSrv );
+ $this->degradedBuckets[$bucket] = time();
+ continue; // server down?
+ }
+ // Attempt to acquire the lock on this peer
+ $status->merge( $callback( $lockSrv ) );
+ if ( !$status->isOK() ) {
+ return $status; // vetoed; resource locked
+ }
+ ++$yesVotes; // success for this peer
+ if ( $yesVotes >= $quorum ) {
+ return $status; // lock obtained
+ }
+ --$votesLeft;
+ $votesNeeded = $quorum - $yesVotes;
+ if ( $votesNeeded > $votesLeft ) {
+ break; // short-circuit
+ }
+ }
+ // At this point, we must not have met the quorum
+ $status->setResult( false );
+
+ return $status;
+ }
+
+ /**
+ * Attempt to release pledges with the peers for a bucket
+ *
+ * @param int $bucket
+ * @param callable $callback Pledge method taking a server name and yeilding a StatusValue
+ * @return StatusValue
+ */
+ final protected function releasePledges( $bucket, callable $callback ) {
+ $status = StatusValue::newGood();
+
+ $yesVotes = 0; // locks freed on trustable servers
+ $votesLeft = count( $this->srvsByBucket[$bucket] ); // remaining peers
+ $quorum = floor( $votesLeft / 2 + 1 ); // simple majority
+ $isDegraded = isset( $this->degradedBuckets[$bucket] ); // not the normal quorum?
+ foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) {
+ if ( !$this->isServerUp( $lockSrv ) ) {
+ $status->warning( 'lockmanager-fail-svr-release', $lockSrv );
+ } else {
+ // Attempt to release the lock on this peer
+ $status->merge( $callback( $lockSrv ) );
+ ++$yesVotes; // success for this peer
+ // Normally the first peers form the quorum, and the others are ignored.
+ // Ignore them in this case, but not when an alternative quorum was used.
+ if ( $yesVotes >= $quorum && !$isDegraded ) {
+ break; // lock released
+ }
+ }
+ }
+ // Set a bad StatusValue if the quorum was not met.
+ // Assumes the same "up" servers as during the acquire step.
+ $status->setResult( $yesVotes >= $quorum );
+
+ return $status;
+ }
+
+ /**
+ * Get the bucket for resource path.
+ * This should avoid throwing any exceptions.
+ *
+ * @param string $path
+ * @return int
+ */
+ protected function getBucketFromPath( $path ) {
+ $prefix = substr( sha1( $path ), 0, 2 ); // first 2 hex chars (8 bits)
+ return (int)base_convert( $prefix, 16, 10 ) % count( $this->srvsByBucket );
+ }
+
+ /**
+ * Check if a lock server is up.
+ * This should process cache results to reduce RTT.
+ *
+ * @param string $lockSrv
+ * @return bool
+ */
+ abstract protected function isServerUp( $lockSrv );
+
+ /**
+ * Get a connection to a lock server and acquire locks
+ *
+ * @param string $lockSrv
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return StatusValue
+ */
+ abstract protected function getLocksOnServer( $lockSrv, array $pathsByType );
+
+ /**
+ * Get a connection to a lock server and release locks on $paths.
+ *
+ * Subclasses must effectively implement this or releaseAllLocks().
+ *
+ * @param string $lockSrv
+ * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
+ * @return StatusValue
+ */
+ abstract protected function freeLocksOnServer( $lockSrv, array $pathsByType );
+
+ /**
+ * Release all locks that this session is holding.
+ *
+ * Subclasses must effectively implement this or freeLocksOnServer().
+ *
+ * @return StatusValue
+ */
+ abstract protected function releaseAllLocks();
+}
diff --git a/www/wiki/includes/libs/lockmanager/RedisLockManager.php b/www/wiki/includes/libs/lockmanager/RedisLockManager.php
new file mode 100644
index 00000000..ea9dde7f
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/RedisLockManager.php
@@ -0,0 +1,276 @@
+<?php
+/**
+ * Version of LockManager based on using redis servers.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Manage locks using redis servers.
+ *
+ * Version of LockManager based on using redis servers.
+ * This is meant for multi-wiki systems that may share files.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * All lock requests for a resource, identified by a hash string, will map to one
+ * bucket. Each bucket maps to one or several peer servers, each running redis.
+ * A majority of peers must agree for a lock to be acquired.
+ *
+ * This class requires Redis 2.6 as it makes use Lua scripts for fast atomic operations.
+ *
+ * @ingroup LockManager
+ * @since 1.22
+ */
+class RedisLockManager extends QuorumLockManager {
+ /** @var array Mapping of lock types to the type actually used */
+ protected $lockTypeMap = [
+ self::LOCK_SH => self::LOCK_SH,
+ self::LOCK_UW => self::LOCK_SH,
+ self::LOCK_EX => self::LOCK_EX
+ ];
+
+ /** @var RedisConnectionPool */
+ protected $redisPool;
+
+ /** @var array Map server names to hostname/IP and port numbers */
+ protected $lockServers = [];
+
+ /**
+ * Construct a new instance from configuration.
+ *
+ * @param array $config Parameters include:
+ * - lockServers : Associative array of server names to "<IP>:<port>" strings.
+ * - srvsByBucket : Array of 1-16 consecutive integer keys, starting from 0,
+ * each having an odd-numbered list of server names (peers) as values.
+ * - redisConfig : Configuration for RedisConnectionPool::__construct().
+ * @throws Exception
+ */
+ public function __construct( array $config ) {
+ parent::__construct( $config );
+
+ $this->lockServers = $config['lockServers'];
+ // Sanitize srvsByBucket config to prevent PHP errors
+ $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' );
+ $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive
+
+ $config['redisConfig']['serializer'] = 'none';
+ $this->redisPool = RedisConnectionPool::singleton( $config['redisConfig'] );
+ }
+
+ protected function getLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = StatusValue::newGood();
+
+ $pathList = call_user_func_array( 'array_merge', array_values( $pathsByType ) );
+
+ $server = $this->lockServers[$lockSrv];
+ $conn = $this->redisPool->getConnection( $server, $this->logger );
+ if ( !$conn ) {
+ foreach ( $pathList as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+
+ return $status;
+ }
+
+ $pathsByKey = []; // (type:hash => path) map
+ foreach ( $pathsByType as $type => $paths ) {
+ $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX';
+ foreach ( $paths as $path ) {
+ $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path;
+ }
+ }
+
+ try {
+ static $script =
+ /** @lang Lua */
+<<<LUA
+ local failed = {}
+ -- Load input params (e.g. session, ttl, time of request)
+ local rSession, rTTL, rMaxTTL, rTime = unpack(ARGV)
+ -- Check that all the locks can be acquired
+ for i,requestKey in ipairs(KEYS) do
+ local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
+ local keyIsFree = true
+ local currentLocks = redis.call('hKeys',resourceKey)
+ for i,lockKey in ipairs(currentLocks) do
+ -- Get the type and session of this lock
+ local _, _, type, session = string.find(lockKey,"(%w+):(%w+)")
+ -- Check any locks that are not owned by this session
+ if session ~= rSession then
+ local lockExpiry = redis.call('hGet',resourceKey,lockKey)
+ if 1*lockExpiry < 1*rTime then
+ -- Lock is stale, so just prune it out
+ redis.call('hDel',resourceKey,lockKey)
+ elseif rType == 'EX' or type == 'EX' then
+ keyIsFree = false
+ break
+ end
+ end
+ end
+ if not keyIsFree then
+ failed[#failed+1] = requestKey
+ end
+ end
+ -- If all locks could be acquired, then do so
+ if #failed == 0 then
+ for i,requestKey in ipairs(KEYS) do
+ local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
+ redis.call('hSet',resourceKey,rType .. ':' .. rSession,rTime + rTTL)
+ -- In addition to invalidation logic, be sure to garbage collect
+ redis.call('expire',resourceKey,rMaxTTL)
+ end
+ end
+ return failed
+LUA;
+ $res = $conn->luaEval( $script,
+ array_merge(
+ array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N]
+ [
+ $this->session, // ARGV[1]
+ $this->lockTTL, // ARGV[2]
+ self::MAX_LOCK_TTL, // ARGV[3]
+ time() // ARGV[4]
+ ]
+ ),
+ count( $pathsByKey ) # number of first argument(s) that are keys
+ );
+ } catch ( RedisException $e ) {
+ $res = false;
+ $this->redisPool->handleError( $conn, $e );
+ }
+
+ if ( $res === false ) {
+ foreach ( $pathList as $path ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $path );
+ }
+ } else {
+ foreach ( $res as $key ) {
+ $status->fatal( 'lockmanager-fail-acquirelock', $pathsByKey[$key] );
+ }
+ }
+
+ return $status;
+ }
+
+ protected function freeLocksOnServer( $lockSrv, array $pathsByType ) {
+ $status = StatusValue::newGood();
+
+ $pathList = call_user_func_array( 'array_merge', array_values( $pathsByType ) );
+
+ $server = $this->lockServers[$lockSrv];
+ $conn = $this->redisPool->getConnection( $server, $this->logger );
+ if ( !$conn ) {
+ foreach ( $pathList as $path ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+
+ return $status;
+ }
+
+ $pathsByKey = []; // (type:hash => path) map
+ foreach ( $pathsByType as $type => $paths ) {
+ $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX';
+ foreach ( $paths as $path ) {
+ $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path;
+ }
+ }
+
+ try {
+ static $script =
+ /** @lang Lua */
+<<<LUA
+ local failed = {}
+ -- Load input params (e.g. session)
+ local rSession = unpack(ARGV)
+ for i,requestKey in ipairs(KEYS) do
+ local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
+ local released = redis.call('hDel',resourceKey,rType .. ':' .. rSession)
+ if released > 0 then
+ -- Remove the whole structure if it is now empty
+ if redis.call('hLen',resourceKey) == 0 then
+ redis.call('del',resourceKey)
+ end
+ else
+ failed[#failed+1] = requestKey
+ end
+ end
+ return failed
+LUA;
+ $res = $conn->luaEval( $script,
+ array_merge(
+ array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N]
+ [
+ $this->session, // ARGV[1]
+ ]
+ ),
+ count( $pathsByKey ) # number of first argument(s) that are keys
+ );
+ } catch ( RedisException $e ) {
+ $res = false;
+ $this->redisPool->handleError( $conn, $e );
+ }
+
+ if ( $res === false ) {
+ foreach ( $pathList as $path ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $path );
+ }
+ } else {
+ foreach ( $res as $key ) {
+ $status->fatal( 'lockmanager-fail-releaselock', $pathsByKey[$key] );
+ }
+ }
+
+ return $status;
+ }
+
+ protected function releaseAllLocks() {
+ return StatusValue::newGood(); // not supported
+ }
+
+ protected function isServerUp( $lockSrv ) {
+ $conn = $this->redisPool->getConnection( $this->lockServers[$lockSrv], $this->logger );
+
+ return (bool)$conn;
+ }
+
+ /**
+ * @param string $path
+ * @param string $type One of (EX,SH)
+ * @return string
+ */
+ protected function recordKeyForPath( $path, $type ) {
+ return implode( ':',
+ [ __CLASS__, 'locks', "$type:" . $this->sha1Base36Absolute( $path ) ] );
+ }
+
+ /**
+ * Make sure remaining locks get cleared for sanity
+ */
+ function __destruct() {
+ while ( count( $this->locksHeld ) ) {
+ $pathsByType = [];
+ foreach ( $this->locksHeld as $path => $locks ) {
+ foreach ( $locks as $type => $count ) {
+ $pathsByType[$type][] = $path;
+ }
+ }
+ $this->unlockByType( $pathsByType );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/lockmanager/ScopedLock.php b/www/wiki/includes/libs/lockmanager/ScopedLock.php
new file mode 100644
index 00000000..2ad8ac87
--- /dev/null
+++ b/www/wiki/includes/libs/lockmanager/ScopedLock.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * Resource locking handling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup LockManager
+ */
+
+/**
+ * Self-releasing locks
+ *
+ * LockManager helper class to handle scoped locks, which
+ * release when an object is destroyed or goes out of scope.
+ *
+ * @ingroup LockManager
+ * @since 1.19
+ */
+class ScopedLock {
+ /** @var LockManager */
+ protected $manager;
+
+ /** @var StatusValue */
+ protected $status;
+
+ /** @var array Map of lock types to resource paths */
+ protected $pathsByType;
+
+ /**
+ * @param LockManager $manager
+ * @param array $pathsByType Map of lock types to path lists
+ * @param StatusValue $status
+ */
+ protected function __construct(
+ LockManager $manager, array $pathsByType, StatusValue $status
+ ) {
+ $this->manager = $manager;
+ $this->pathsByType = $pathsByType;
+ $this->status = $status;
+ }
+
+ /**
+ * Get a ScopedLock object representing a lock on resource paths.
+ * Any locks are released once this object goes out of scope.
+ * The StatusValue object is updated with any errors or warnings.
+ *
+ * @param LockManager $manager
+ * @param array $paths List of storage paths or map of lock types to path lists
+ * @param int|string $type LockManager::LOCK_* constant or "mixed" and $paths
+ * can be a map of types to paths (since 1.22). Otherwise $type should be an
+ * integer and $paths should be a list of paths.
+ * @param StatusValue $status
+ * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.22)
+ * @return ScopedLock|null Returns null on failure
+ */
+ public static function factory(
+ LockManager $manager, array $paths, $type, StatusValue $status, $timeout = 0
+ ) {
+ $pathsByType = is_integer( $type ) ? [ $type => $paths ] : $paths;
+ $lockStatus = $manager->lockByType( $pathsByType, $timeout );
+ $status->merge( $lockStatus );
+ if ( $lockStatus->isOK() ) {
+ return new self( $manager, $pathsByType, $status );
+ }
+
+ return null;
+ }
+
+ /**
+ * Release a scoped lock and set any errors in the attatched StatusValue object.
+ * This is useful for early release of locks before function scope is destroyed.
+ * This is the same as setting the lock object to null.
+ *
+ * @param ScopedLock &$lock
+ * @since 1.21
+ */
+ public static function release( ScopedLock &$lock = null ) {
+ $lock = null;
+ }
+
+ /**
+ * Release the locks when this goes out of scope
+ */
+ function __destruct() {
+ $wasOk = $this->status->isOK();
+ $this->status->merge( $this->manager->unlockByType( $this->pathsByType ) );
+ if ( $wasOk ) {
+ // Make sure StatusValue is OK, despite any unlockFiles() fatals
+ $this->status->setResult( true, $this->status->value );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/mime/IEContentAnalyzer.php b/www/wiki/includes/libs/mime/IEContentAnalyzer.php
new file mode 100644
index 00000000..e9fb11f7
--- /dev/null
+++ b/www/wiki/includes/libs/mime/IEContentAnalyzer.php
@@ -0,0 +1,851 @@
+<?php
+/**
+ * Simulation of Microsoft Internet Explorer's MIME type detection algorithm.
+ *
+ * @file
+ * @todo Define the exact license of this file.
+ */
+
+/**
+ * This class simulates Microsoft Internet Explorer's terribly broken and
+ * insecure MIME type detection algorithm. It can be used to check web uploads
+ * with an apparently safe type, to see if IE will reinterpret them to produce
+ * something dangerous.
+ *
+ * It is full of bugs and strange design choices should not under any
+ * circumstances be used to determine a MIME type to present to a user or
+ * client. (Apple Safari developers, this means you too.)
+ *
+ * This class is based on a disassembly of IE 5.0, 6.0 and 7.0. Although I have
+ * attempted to ensure that this code works in exactly the same way as Internet
+ * Explorer, it does not share any source code, or creative choices such as
+ * variable names, thus I (Tim Starling) claim copyright on it.
+ *
+ * It may be redistributed without restriction. To aid reuse, this class does
+ * not depend on any MediaWiki module.
+ */
+class IEContentAnalyzer {
+ /**
+ * Relevant data taken from the type table in IE 5
+ */
+ protected $baseTypeTable = [
+ 'ambiguous' /*1*/ => [
+ 'text/plain',
+ 'application/octet-stream',
+ 'application/x-netcdf', // [sic]
+ ],
+ 'text' /*3*/ => [
+ 'text/richtext', 'image/x-bitmap', 'application/postscript', 'application/base64',
+ 'application/macbinhex40', 'application/x-cdf', 'text/scriptlet'
+ ],
+ 'binary' /*4*/ => [
+ 'application/pdf', 'audio/x-aiff', 'audio/basic', 'audio/wav', 'image/gif',
+ 'image/pjpeg', 'image/jpeg', 'image/tiff', 'image/x-png', 'image/png', 'image/bmp',
+ 'image/x-jg', 'image/x-art', 'image/x-emf', 'image/x-wmf', 'video/avi',
+ 'video/x-msvideo', 'video/mpeg', 'application/x-compressed',
+ 'application/x-zip-compressed', 'application/x-gzip-compressed', 'application/java',
+ 'application/x-msdownload'
+ ],
+ 'html' /*5*/ => [ 'text/html' ],
+ ];
+
+ /**
+ * Changes to the type table in later versions of IE
+ */
+ protected $addedTypes = [
+ 'ie07' => [
+ 'text' => [ 'text/xml', 'application/xml' ]
+ ],
+ ];
+
+ /**
+ * An approximation of the "Content Type" values in HKEY_CLASSES_ROOT in a
+ * typical Windows installation.
+ *
+ * Used for extension to MIME type mapping if detection fails.
+ */
+ protected $registry = [
+ '.323' => 'text/h323',
+ '.3g2' => 'video/3gpp2',
+ '.3gp' => 'video/3gpp',
+ '.3gp2' => 'video/3gpp2',
+ '.3gpp' => 'video/3gpp',
+ '.aac' => 'audio/aac',
+ '.ac3' => 'audio/ac3',
+ '.accda' => 'application/msaccess',
+ '.accdb' => 'application/msaccess',
+ '.accdc' => 'application/msaccess',
+ '.accde' => 'application/msaccess',
+ '.accdr' => 'application/msaccess',
+ '.accdt' => 'application/msaccess',
+ '.ade' => 'application/msaccess',
+ '.adp' => 'application/msaccess',
+ '.adts' => 'audio/aac',
+ '.ai' => 'application/postscript',
+ '.aif' => 'audio/aiff',
+ '.aifc' => 'audio/aiff',
+ '.aiff' => 'audio/aiff',
+ '.amc' => 'application/x-mpeg',
+ '.application' => 'application/x-ms-application',
+ '.asf' => 'video/x-ms-asf',
+ '.asx' => 'video/x-ms-asf',
+ '.au' => 'audio/basic',
+ '.avi' => 'video/avi',
+ '.bmp' => 'image/bmp',
+ '.caf' => 'audio/x-caf',
+ '.cat' => 'application/vnd.ms-pki.seccat',
+ '.cbo' => 'application/sha',
+ '.cdda' => 'audio/aiff',
+ '.cer' => 'application/x-x509-ca-cert',
+ '.conf' => 'text/plain',
+ '.crl' => 'application/pkix-crl',
+ '.crt' => 'application/x-x509-ca-cert',
+ '.css' => 'text/css',
+ '.csv' => 'application/vnd.ms-excel',
+ '.der' => 'application/x-x509-ca-cert',
+ '.dib' => 'image/bmp',
+ '.dif' => 'video/x-dv',
+ '.dll' => 'application/x-msdownload',
+ '.doc' => 'application/msword',
+ '.docm' => 'application/vnd.ms-word.document.macroEnabled.12',
+ '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ '.dot' => 'application/msword',
+ '.dotm' => 'application/vnd.ms-word.template.macroEnabled.12',
+ '.dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
+ '.dv' => 'video/x-dv',
+ '.dwfx' => 'model/vnd.dwfx+xps',
+ '.edn' => 'application/vnd.adobe.edn',
+ '.eml' => 'message/rfc822',
+ '.eps' => 'application/postscript',
+ '.etd' => 'application/x-ebx',
+ '.exe' => 'application/x-msdownload',
+ '.fdf' => 'application/vnd.fdf',
+ '.fif' => 'application/fractals',
+ '.gif' => 'image/gif',
+ '.gsm' => 'audio/x-gsm',
+ '.hqx' => 'application/mac-binhex40',
+ '.hta' => 'application/hta',
+ '.htc' => 'text/x-component',
+ '.htm' => 'text/html',
+ '.html' => 'text/html',
+ '.htt' => 'text/webviewhtml',
+ '.hxa' => 'application/xml',
+ '.hxc' => 'application/xml',
+ '.hxd' => 'application/octet-stream',
+ '.hxe' => 'application/xml',
+ '.hxf' => 'application/xml',
+ '.hxh' => 'application/octet-stream',
+ '.hxi' => 'application/octet-stream',
+ '.hxk' => 'application/xml',
+ '.hxq' => 'application/octet-stream',
+ '.hxr' => 'application/octet-stream',
+ '.hxs' => 'application/octet-stream',
+ '.hxt' => 'application/xml',
+ '.hxv' => 'application/xml',
+ '.hxw' => 'application/octet-stream',
+ '.ico' => 'image/x-icon',
+ '.iii' => 'application/x-iphone',
+ '.ins' => 'application/x-internet-signup',
+ '.iqy' => 'text/x-ms-iqy',
+ '.isp' => 'application/x-internet-signup',
+ '.jfif' => 'image/jpeg',
+ '.jnlp' => 'application/x-java-jnlp-file',
+ '.jpe' => 'image/jpeg',
+ '.jpeg' => 'image/jpeg',
+ '.jpg' => 'image/jpeg',
+ '.jtx' => 'application/x-jtx+xps',
+ '.latex' => 'application/x-latex',
+ '.log' => 'text/plain',
+ '.m1v' => 'video/mpeg',
+ '.m2v' => 'video/mpeg',
+ '.m3u' => 'audio/x-mpegurl',
+ '.mac' => 'image/x-macpaint',
+ '.man' => 'application/x-troff-man',
+ '.mda' => 'application/msaccess',
+ '.mdb' => 'application/msaccess',
+ '.mde' => 'application/msaccess',
+ '.mfp' => 'application/x-shockwave-flash',
+ '.mht' => 'message/rfc822',
+ '.mhtml' => 'message/rfc822',
+ '.mid' => 'audio/mid',
+ '.midi' => 'audio/mid',
+ '.mod' => 'video/mpeg',
+ '.mov' => 'video/quicktime',
+ '.mp2' => 'video/mpeg',
+ '.mp2v' => 'video/mpeg',
+ '.mp3' => 'audio/mpeg',
+ '.mp4' => 'video/mp4',
+ '.mpa' => 'video/mpeg',
+ '.mpe' => 'video/mpeg',
+ '.mpeg' => 'video/mpeg',
+ '.mpf' => 'application/vnd.ms-mediapackage',
+ '.mpg' => 'video/mpeg',
+ '.mpv2' => 'video/mpeg',
+ '.mqv' => 'video/quicktime',
+ '.NMW' => 'application/nmwb',
+ '.nws' => 'message/rfc822',
+ '.odc' => 'text/x-ms-odc',
+ '.ols' => 'application/vnd.ms-publisher',
+ '.p10' => 'application/pkcs10',
+ '.p12' => 'application/x-pkcs12',
+ '.p7b' => 'application/x-pkcs7-certificates',
+ '.p7c' => 'application/pkcs7-mime',
+ '.p7m' => 'application/pkcs7-mime',
+ '.p7r' => 'application/x-pkcs7-certreqresp',
+ '.p7s' => 'application/pkcs7-signature',
+ '.pct' => 'image/pict',
+ '.pdf' => 'application/pdf',
+ '.pdx' => 'application/vnd.adobe.pdx',
+ '.pfx' => 'application/x-pkcs12',
+ '.pic' => 'image/pict',
+ '.pict' => 'image/pict',
+ '.pinstall' => 'application/x-picasa-detect',
+ '.pko' => 'application/vnd.ms-pki.pko',
+ '.png' => 'image/png',
+ '.pnt' => 'image/x-macpaint',
+ '.pntg' => 'image/x-macpaint',
+ '.pot' => 'application/vnd.ms-powerpoint',
+ '.potm' => 'application/vnd.ms-powerpoint.template.macroEnabled.12',
+ '.potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template',
+ '.ppa' => 'application/vnd.ms-powerpoint',
+ '.ppam' => 'application/vnd.ms-powerpoint.addin.macroEnabled.12',
+ '.pps' => 'application/vnd.ms-powerpoint',
+ '.ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
+ '.ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
+ '.ppt' => 'application/vnd.ms-powerpoint',
+ '.pptm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
+ '.pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ '.prf' => 'application/pics-rules',
+ '.ps' => 'application/postscript',
+ '.pub' => 'application/vnd.ms-publisher',
+ '.pwz' => 'application/vnd.ms-powerpoint',
+ '.py' => 'text/plain',
+ '.pyw' => 'text/plain',
+ '.qht' => 'text/x-html-insertion',
+ '.qhtm' => 'text/x-html-insertion',
+ '.qt' => 'video/quicktime',
+ '.qti' => 'image/x-quicktime',
+ '.qtif' => 'image/x-quicktime',
+ '.qtl' => 'application/x-quicktimeplayer',
+ '.rat' => 'application/rat-file',
+ '.rmf' => 'application/vnd.adobe.rmf',
+ '.rmi' => 'audio/mid',
+ '.rqy' => 'text/x-ms-rqy',
+ '.rtf' => 'application/msword',
+ '.sct' => 'text/scriptlet',
+ '.sd2' => 'audio/x-sd2',
+ '.sdp' => 'application/sdp',
+ '.shtml' => 'text/html',
+ '.sit' => 'application/x-stuffit',
+ '.sldm' => 'application/vnd.ms-powerpoint.slide.macroEnabled.12',
+ '.sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide',
+ '.slk' => 'application/vnd.ms-excel',
+ '.snd' => 'audio/basic',
+ '.so' => 'application/x-apachemodule',
+ '.sol' => 'text/plain',
+ '.sor' => 'text/plain',
+ '.spc' => 'application/x-pkcs7-certificates',
+ '.spl' => 'application/futuresplash',
+ '.sst' => 'application/vnd.ms-pki.certstore',
+ '.stl' => 'application/vnd.ms-pki.stl',
+ '.swf' => 'application/x-shockwave-flash',
+ '.thmx' => 'application/vnd.ms-officetheme',
+ '.tif' => 'image/tiff',
+ '.tiff' => 'image/tiff',
+ '.txt' => 'text/plain',
+ '.uls' => 'text/iuls',
+ '.vcf' => 'text/x-vcard',
+ '.vdx' => 'application/vnd.ms-visio.viewer',
+ '.vsd' => 'application/vnd.ms-visio.viewer',
+ '.vss' => 'application/vnd.ms-visio.viewer',
+ '.vst' => 'application/vnd.ms-visio.viewer',
+ '.vsx' => 'application/vnd.ms-visio.viewer',
+ '.vtx' => 'application/vnd.ms-visio.viewer',
+ '.wav' => 'audio/wav',
+ '.wax' => 'audio/x-ms-wax',
+ '.wbk' => 'application/msword',
+ '.wdp' => 'image/vnd.ms-photo',
+ '.wiz' => 'application/msword',
+ '.wm' => 'video/x-ms-wm',
+ '.wma' => 'audio/x-ms-wma',
+ '.wmd' => 'application/x-ms-wmd',
+ '.wmv' => 'video/x-ms-wmv',
+ '.wmx' => 'video/x-ms-wmx',
+ '.wmz' => 'application/x-ms-wmz',
+ '.wpl' => 'application/vnd.ms-wpl',
+ '.wsc' => 'text/scriptlet',
+ '.wvx' => 'video/x-ms-wvx',
+ '.xaml' => 'application/xaml+xml',
+ '.xbap' => 'application/x-ms-xbap',
+ '.xdp' => 'application/vnd.adobe.xdp+xml',
+ '.xfdf' => 'application/vnd.adobe.xfdf',
+ '.xht' => 'application/xhtml+xml',
+ '.xhtml' => 'application/xhtml+xml',
+ '.xla' => 'application/vnd.ms-excel',
+ '.xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12',
+ '.xlk' => 'application/vnd.ms-excel',
+ '.xll' => 'application/vnd.ms-excel',
+ '.xlm' => 'application/vnd.ms-excel',
+ '.xls' => 'application/vnd.ms-excel',
+ '.xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
+ '.xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
+ '.xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ '.xlt' => 'application/vnd.ms-excel',
+ '.xltm' => 'application/vnd.ms-excel.template.macroEnabled.12',
+ '.xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
+ '.xlw' => 'application/vnd.ms-excel',
+ '.xml' => 'text/xml',
+ '.xps' => 'application/vnd.ms-xpsdocument',
+ '.xsl' => 'text/xml',
+ ];
+
+ /**
+ * IE versions which have been analysed to bring you this class, and for
+ * which some substantive difference exists. These will appear as keys
+ * in the return value of getRealMimesFromData(). The names are chosen to sort correctly.
+ */
+ protected $versions = [ 'ie05', 'ie06', 'ie07', 'ie07.strict', 'ie07.nohtml' ];
+
+ /**
+ * Type table with versions expanded
+ */
+ protected $typeTable = [];
+
+ /** constructor */
+ function __construct() {
+ // Construct versioned type arrays from the base type array plus additions
+ $types = $this->baseTypeTable;
+ foreach ( $this->versions as $version ) {
+ if ( isset( $this->addedTypes[$version] ) ) {
+ foreach ( $this->addedTypes[$version] as $format => $addedTypes ) {
+ $types[$format] = array_merge( $types[$format], $addedTypes );
+ }
+ }
+ $this->typeTable[$version] = $types;
+ }
+ }
+
+ /**
+ * Get the MIME types from getMimesFromData(), but convert the result from IE's
+ * idiosyncratic private types into something other apps will understand.
+ *
+ * @param string $fileName the file name (unused at present)
+ * @param string $chunk the first 256 bytes of the file
+ * @param string $proposed the MIME type proposed by the server
+ *
+ * @return array map of IE version to detected MIME type
+ */
+ public function getRealMimesFromData( $fileName, $chunk, $proposed ) {
+ $types = $this->getMimesFromData( $fileName, $chunk, $proposed );
+ $types = array_map( [ $this, 'translateMimeType' ], $types );
+ return $types;
+ }
+
+ /**
+ * Translate a MIME type from IE's idiosyncratic private types into
+ * more commonly understood type strings
+ * @param string $type
+ * @return string
+ */
+ public function translateMimeType( $type ) {
+ static $table = [
+ 'image/pjpeg' => 'image/jpeg',
+ 'image/x-png' => 'image/png',
+ 'image/x-wmf' => 'application/x-msmetafile',
+ 'image/bmp' => 'image/x-bmp',
+ 'application/x-zip-compressed' => 'application/zip',
+ 'application/x-compressed' => 'application/x-compress',
+ 'application/x-gzip-compressed' => 'application/x-gzip',
+ 'audio/mid' => 'audio/midi',
+ ];
+ if ( isset( $table[$type] ) ) {
+ $type = $table[$type];
+ }
+ return $type;
+ }
+
+ /**
+ * Get the untranslated MIME types for all known versions
+ *
+ * @param string $fileName the file name (unused at present)
+ * @param string $chunk the first 256 bytes of the file
+ * @param string $proposed the MIME type proposed by the server
+ *
+ * @return array map of IE version to detected MIME type
+ */
+ public function getMimesFromData( $fileName, $chunk, $proposed ) {
+ $types = [];
+ foreach ( $this->versions as $version ) {
+ $types[$version] = $this->getMimeTypeForVersion( $version, $fileName, $chunk, $proposed );
+ }
+ return $types;
+ }
+
+ /**
+ * Get the MIME type for a given named version
+ * @param string $version
+ * @param string $fileName
+ * @param string $chunk
+ * @param string $proposed
+ * @return bool|string
+ */
+ protected function getMimeTypeForVersion( $version, $fileName, $chunk, $proposed ) {
+ // Strip text after a semicolon
+ $semiPos = strpos( $proposed, ';' );
+ if ( $semiPos !== false ) {
+ $proposed = substr( $proposed, 0, $semiPos );
+ }
+
+ $proposedFormat = $this->getDataFormat( $version, $proposed );
+ if ( $proposedFormat == 'unknown'
+ && $proposed != 'multipart/mixed'
+ && $proposed != 'multipart/x-mixed-replace'
+ ) {
+ return $proposed;
+ }
+ if ( strval( $chunk ) === '' ) {
+ return $proposed;
+ }
+
+ // Truncate chunk at 255 bytes
+ $chunk = substr( $chunk, 0, 255 );
+
+ // IE does the Check*Headers() calls last, and instead does the following image
+ // type checks by directly looking for the magic numbers. What I do here should
+ // have the same effect since the magic number checks are identical in both cases.
+ $result = $this->sampleData( $version, $chunk );
+ $sampleFound = $result['found'];
+ $counters = $result['counters'];
+ $binaryType = $this->checkBinaryHeaders( $version, $chunk );
+ $textType = $this->checkTextHeaders( $version, $chunk );
+
+ if ( $proposed == 'text/html' && isset( $sampleFound['html'] ) ) {
+ return 'text/html';
+ }
+ if ( $proposed == 'image/gif' && $binaryType == 'image/gif' ) {
+ return 'image/gif';
+ }
+ if ( ( $proposed == 'image/pjpeg' || $proposed == 'image/jpeg' )
+ && $binaryType == 'image/pjpeg'
+ ) {
+ return $proposed;
+ }
+ // PNG check added in IE 7
+ if ( $version >= 'ie07'
+ && ( $proposed == 'image/x-png' || $proposed == 'image/png' )
+ && $binaryType == 'image/x-png'
+ ) {
+ return $proposed;
+ }
+
+ // CDF was removed in IE 7 so it won't be in $sampleFound for later versions
+ if ( isset( $sampleFound['cdf'] ) ) {
+ return 'application/x-cdf';
+ }
+
+ // RSS and Atom were added in IE 7 so they won't be in $sampleFound for
+ // previous versions
+ if ( isset( $sampleFound['rss'] ) ) {
+ return 'application/rss+xml';
+ }
+ if ( isset( $sampleFound['rdf-tag'] )
+ && isset( $sampleFound['rdf-url'] )
+ && isset( $sampleFound['rdf-purl'] )
+ ) {
+ return 'application/rss+xml';
+ }
+ if ( isset( $sampleFound['atom'] ) ) {
+ return 'application/atom+xml';
+ }
+
+ if ( isset( $sampleFound['xml'] ) ) {
+ // TODO: I'm not sure under what circumstances this flag is enabled
+ if ( strpos( $version, 'strict' ) !== false ) {
+ if ( $proposed == 'text/html' || $proposed == 'text/xml' ) {
+ return 'text/xml';
+ }
+ } else {
+ return 'text/xml';
+ }
+ }
+ if ( isset( $sampleFound['html'] ) ) {
+ // TODO: I'm not sure under what circumstances this flag is enabled
+ if ( strpos( $version, 'nohtml' ) !== false ) {
+ if ( $proposed == 'text/plain' ) {
+ return 'text/html';
+ }
+ } else {
+ return 'text/html';
+ }
+ }
+ if ( isset( $sampleFound['xbm'] ) ) {
+ return 'image/x-bitmap';
+ }
+ if ( isset( $sampleFound['binhex'] ) ) {
+ return 'application/macbinhex40';
+ }
+ if ( isset( $sampleFound['scriptlet'] ) ) {
+ if ( strpos( $version, 'strict' ) !== false ) {
+ if ( $proposed == 'text/plain' || $proposed == 'text/scriptlet' ) {
+ return 'text/scriptlet';
+ }
+ } else {
+ return 'text/scriptlet';
+ }
+ }
+
+ // Freaky heuristics to determine if the data is text or binary
+ // The heuristic is of course broken for non-ASCII text
+ if ( $counters['ctrl'] != 0 && ( $counters['ff'] + $counters['low'] )
+ < ( $counters['ctrl'] + $counters['high'] ) * 16
+ ) {
+ $kindOfBinary = true;
+ $type = $binaryType ? $binaryType : $textType;
+ if ( $type === false ) {
+ $type = 'application/octet-stream';
+ }
+ } else {
+ $kindOfBinary = false;
+ $type = $textType ? $textType : $binaryType;
+ if ( $type === false ) {
+ $type = 'text/plain';
+ }
+ }
+
+ // Check if the output format is ambiguous
+ // This generally means that detection failed, real types aren't ambiguous
+ $detectedFormat = $this->getDataFormat( $version, $type );
+ if ( $detectedFormat != 'ambiguous' ) {
+ return $type;
+ }
+
+ if ( $proposedFormat != 'ambiguous' ) {
+ // FormatAgreesWithData()
+ if ( $proposedFormat == 'text' && !$kindOfBinary ) {
+ return $proposed;
+ }
+ if ( $proposedFormat == 'binary' && $kindOfBinary ) {
+ return $proposed;
+ }
+ if ( $proposedFormat == 'html' ) {
+ return $proposed;
+ }
+ }
+
+ // Find a MIME type by searching the registry for the file extension.
+ $dotPos = strrpos( $fileName, '.' );
+ if ( $dotPos === false ) {
+ return $type;
+ }
+ $ext = substr( $fileName, $dotPos );
+ if ( isset( $this->registry[$ext] ) ) {
+ return $this->registry[$ext];
+ }
+
+ // TODO: If the extension has an application registered to it, IE will return
+ // application/octet-stream. We'll skip that, so we could erroneously
+ // return text/plain or application/x-netcdf where application/octet-stream
+ // would be correct.
+
+ return $type;
+ }
+
+ /**
+ * Check for text headers at the start of the chunk
+ * Confirmed same in 5 and 7.
+ * @param string $version
+ * @param string $chunk
+ * @return bool|string
+ */
+ private function checkTextHeaders( $version, $chunk ) {
+ $chunk2 = substr( $chunk, 0, 2 );
+ $chunk4 = substr( $chunk, 0, 4 );
+ $chunk5 = substr( $chunk, 0, 5 );
+ if ( $chunk4 == '%PDF' ) {
+ return 'application/pdf';
+ }
+ if ( $chunk2 == '%!' ) {
+ return 'application/postscript';
+ }
+ if ( $chunk5 == '{\\rtf' ) {
+ return 'text/richtext';
+ }
+ if ( $chunk5 == 'begin' ) {
+ return 'application/base64';
+ }
+ return false;
+ }
+
+ /**
+ * Check for binary headers at the start of the chunk
+ * Confirmed same in 5 and 7.
+ * @param string $version
+ * @param string $chunk
+ * @return bool|string
+ */
+ private function checkBinaryHeaders( $version, $chunk ) {
+ $chunk2 = substr( $chunk, 0, 2 );
+ $chunk3 = substr( $chunk, 0, 3 );
+ $chunk4 = substr( $chunk, 0, 4 );
+ $chunk5 = substr( $chunk, 0, 5 );
+ $chunk5uc = strtoupper( $chunk5 );
+ $chunk8 = substr( $chunk, 0, 8 );
+ if ( $chunk5uc == 'GIF87' || $chunk5uc == 'GIF89' ) {
+ return 'image/gif';
+ }
+ if ( $chunk2 == "\xff\xd8" ) {
+ return 'image/pjpeg'; // actually plain JPEG but this is what IE returns
+ }
+
+ if ( $chunk2 == 'BM'
+ && substr( $chunk, 6, 2 ) == "\000\000"
+ && substr( $chunk, 8, 2 ) == "\000\000"
+ ) {
+ return 'image/bmp'; // another non-standard MIME
+ }
+ if ( $chunk4 == 'RIFF'
+ && substr( $chunk, 8, 4 ) == 'WAVE'
+ ) {
+ return 'audio/wav';
+ }
+ // These were integer literals in IE
+ // Perhaps the author was not sure what the target endianness was
+ if ( $chunk4 == ".sd\000"
+ || $chunk4 == ".snd"
+ || $chunk4 == "\000ds."
+ || $chunk4 == "dns."
+ ) {
+ return 'audio/basic';
+ }
+ if ( $chunk3 == "MM\000" ) {
+ return 'image/tiff';
+ }
+ if ( $chunk2 == 'MZ' ) {
+ return 'application/x-msdownload';
+ }
+ if ( $chunk8 == "\x89PNG\x0d\x0a\x1a\x0a" ) {
+ return 'image/x-png'; // [sic]
+ }
+ if ( strlen( $chunk ) >= 5 ) {
+ $byte2 = ord( $chunk[2] );
+ $byte4 = ord( $chunk[4] );
+ if ( $byte2 >= 3 && $byte2 <= 31 && $byte4 == 0 && $chunk2 == 'JG' ) {
+ return 'image/x-jg';
+ }
+ }
+ // More endian confusion?
+ if ( $chunk4 == 'MROF' ) {
+ return 'audio/x-aiff';
+ }
+ $chunk4_8 = substr( $chunk, 8, 4 );
+ if ( $chunk4 == 'FORM' && ( $chunk4_8 == 'AIFF' || $chunk4_8 == 'AIFC' ) ) {
+ return 'audio/x-aiff';
+ }
+ if ( $chunk4 == 'RIFF' && $chunk4_8 == 'AVI ' ) {
+ return 'video/avi';
+ }
+ if ( $chunk4 == "\x00\x00\x01\xb3" || $chunk4 == "\x00\x00\x01\xba" ) {
+ return 'video/mpeg';
+ }
+ if ( $chunk4 == "\001\000\000\000"
+ && substr( $chunk, 40, 4 ) == ' EMF'
+ ) {
+ return 'image/x-emf';
+ }
+ if ( $chunk4 == "\xd7\xcd\xc6\x9a" ) {
+ return 'image/x-wmf';
+ }
+ if ( $chunk4 == "\xca\xfe\xba\xbe" ) {
+ return 'application/java';
+ }
+ if ( $chunk2 == 'PK' ) {
+ return 'application/x-zip-compressed';
+ }
+ if ( $chunk2 == "\x1f\x9d" ) {
+ return 'application/x-compressed';
+ }
+ if ( $chunk2 == "\x1f\x8b" ) {
+ return 'application/x-gzip-compressed';
+ }
+ // Skip redundant check for ZIP
+ if ( $chunk5 == "MThd\000" ) {
+ return 'audio/mid';
+ }
+ if ( $chunk4 == '%PDF' ) {
+ return 'application/pdf';
+ }
+ return false;
+ }
+
+ /**
+ * Do heuristic checks on the bulk of the data sample.
+ * Search for HTML tags.
+ * @param string $version
+ * @param string $chunk
+ * @return array
+ */
+ protected function sampleData( $version, $chunk ) {
+ $found = [];
+ $counters = [
+ 'ctrl' => 0,
+ 'high' => 0,
+ 'low' => 0,
+ 'lf' => 0,
+ 'cr' => 0,
+ 'ff' => 0
+ ];
+ $htmlTags = [
+ 'html',
+ 'head',
+ 'title',
+ 'body',
+ 'script',
+ 'a href',
+ 'pre',
+ 'img',
+ 'plaintext',
+ 'table'
+ ];
+ $rdfUrl = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
+ $rdfPurl = 'http://purl.org/rss/1.0/';
+ $xbmMagic1 = '#define';
+ $xbmMagic2 = '_width';
+ $xbmMagic3 = '_bits';
+ $binhexMagic = 'converted with BinHex';
+ $chunkLength = strlen( $chunk );
+
+ for ( $offset = 0; $offset < $chunkLength; $offset++ ) {
+ $curChar = $chunk[$offset];
+ if ( $curChar == "\x0a" ) {
+ $counters['lf']++;
+ continue;
+ } elseif ( $curChar == "\x0d" ) {
+ $counters['cr']++;
+ continue;
+ } elseif ( $curChar == "\x0c" ) {
+ $counters['ff']++;
+ continue;
+ } elseif ( $curChar == "\t" ) {
+ $counters['low']++;
+ continue;
+ } elseif ( ord( $curChar ) < 32 ) {
+ $counters['ctrl']++;
+ continue;
+ } elseif ( ord( $curChar ) >= 128 ) {
+ $counters['high']++;
+ continue;
+ }
+
+ $counters['low']++;
+ if ( $curChar == '<' ) {
+ // XML
+ $remainder = substr( $chunk, $offset + 1 );
+ if ( !strncasecmp( $remainder, '?XML', 4 ) ) {
+ $nextChar = substr( $chunk, $offset + 5, 1 );
+ if ( $nextChar == ':' || $nextChar == ' ' || $nextChar == "\t" ) {
+ $found['xml'] = true;
+ }
+ }
+ // Scriptlet (JSP)
+ if ( !strncasecmp( $remainder, 'SCRIPTLET', 9 ) ) {
+ $found['scriptlet'] = true;
+ break;
+ }
+ // HTML
+ foreach ( $htmlTags as $tag ) {
+ if ( !strncasecmp( $remainder, $tag, strlen( $tag ) ) ) {
+ $found['html'] = true;
+ }
+ }
+ // Skip broken check for additional tags (HR etc.)
+
+ // CHANNEL replaced by RSS, RDF and FEED in IE 7
+ if ( $version < 'ie07' ) {
+ if ( !strncasecmp( $remainder, 'CHANNEL', 7 ) ) {
+ $found['cdf'] = true;
+ }
+ } else {
+ // RSS
+ if ( !strncasecmp( $remainder, 'RSS', 3 ) ) {
+ $found['rss'] = true;
+ break; // return from SampleData
+ }
+ if ( !strncasecmp( $remainder, 'rdf:RDF', 7 ) ) {
+ $found['rdf-tag'] = true;
+ // no break
+ }
+ if ( !strncasecmp( $remainder, 'FEED', 4 ) ) {
+ $found['atom'] = true;
+ break;
+ }
+ }
+ continue;
+ }
+ // Skip broken check for -->
+
+ // RSS URL checks
+ // For some reason both URLs must appear before it is recognised
+ $remainder = substr( $chunk, $offset );
+ if ( !strncasecmp( $remainder, $rdfUrl, strlen( $rdfUrl ) ) ) {
+ $found['rdf-url'] = true;
+ if ( isset( $found['rdf-tag'] )
+ && isset( $found['rdf-purl'] ) // [sic]
+ ) {
+ break;
+ }
+ continue;
+ }
+
+ if ( !strncasecmp( $remainder, $rdfPurl, strlen( $rdfPurl ) ) ) {
+ if ( isset( $found['rdf-tag'] )
+ && isset( $found['rdf-url'] ) // [sic]
+ ) {
+ break;
+ }
+ continue;
+ }
+
+ // XBM checks
+ if ( !strncasecmp( $remainder, $xbmMagic1, strlen( $xbmMagic1 ) ) ) {
+ $found['xbm1'] = true;
+ continue;
+ }
+ if ( $curChar == '_' ) {
+ if ( isset( $found['xbm2'] ) ) {
+ if ( !strncasecmp( $remainder, $xbmMagic3, strlen( $xbmMagic3 ) ) ) {
+ $found['xbm'] = true;
+ break;
+ }
+ } elseif ( isset( $found['xbm1'] ) ) {
+ if ( !strncasecmp( $remainder, $xbmMagic2, strlen( $xbmMagic2 ) ) ) {
+ $found['xbm2'] = true;
+ }
+ }
+ }
+
+ // BinHex
+ if ( !strncmp( $remainder, $binhexMagic, strlen( $binhexMagic ) ) ) {
+ $found['binhex'] = true;
+ }
+ }
+ return [ 'found' => $found, 'counters' => $counters ];
+ }
+
+ /**
+ * @param string $version
+ * @param string|null $type
+ * @return int|string
+ */
+ protected function getDataFormat( $version, $type ) {
+ $types = $this->typeTable[$version];
+ if ( $type == '(null)' || strval( $type ) === '' ) {
+ return 'ambiguous';
+ }
+ foreach ( $types as $format => $list ) {
+ if ( in_array( $type, $list ) ) {
+ return $format;
+ }
+ }
+ return 'unknown';
+ }
+}
diff --git a/www/wiki/includes/libs/mime/MimeAnalyzer.php b/www/wiki/includes/libs/mime/MimeAnalyzer.php
new file mode 100644
index 00000000..4d860bb5
--- /dev/null
+++ b/www/wiki/includes/libs/mime/MimeAnalyzer.php
@@ -0,0 +1,1200 @@
+<?php
+/**
+ * Module defining helper functions for detecting and dealing with MIME types.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Implements functions related to MIME types such as detection and mapping to file extension
+ *
+ * @since 1.28
+ */
+class MimeAnalyzer implements LoggerAwareInterface {
+ /** @var string */
+ protected $typeFile;
+ /** @var string */
+ protected $infoFile;
+ /** @var string */
+ protected $xmlTypes;
+ /** @var callable */
+ protected $initCallback;
+ /** @var callable */
+ protected $detectCallback;
+ /** @var callable */
+ protected $guessCallback;
+ /** @var callable */
+ protected $extCallback;
+ /** @var array Mapping of media types to arrays of MIME types */
+ protected $mediaTypes = null;
+ /** @var array Map of MIME type aliases */
+ protected $mimeTypeAliases = null;
+ /** @var array Map of MIME types to file extensions (as a space separated list) */
+ protected $mimetoExt = null;
+
+ /** @var array Map of file extensions types to MIME types (as a space separated list) */
+ public $mExtToMime = null; // legacy name; field accessed by hooks
+
+ /** @var IEContentAnalyzer */
+ protected $IEAnalyzer;
+
+ /** @var string Extra MIME types, set for example by media handling extensions */
+ private $extraTypes = '';
+ /** @var string Extra MIME info, set for example by media handling extensions */
+ private $extraInfo = '';
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ /**
+ * Defines a set of well known MIME types
+ * This is used as a fallback to mime.types files.
+ * An extensive list of well known MIME types is provided by
+ * the file mime.types in the includes directory.
+ *
+ * This list concatenated with mime.types is used to create a MIME <-> ext
+ * map. Each line contains a MIME type followed by a space separated list of
+ * extensions. If multiple extensions for a single MIME type exist or if
+ * multiple MIME types exist for a single extension then in most cases
+ * MediaWiki assumes that the first extension following the MIME type is the
+ * canonical extension, and the first time a MIME type appears for a certain
+ * extension is considered the canonical MIME type.
+ *
+ * (Note that appending the type file list to the end of self::$wellKnownTypes
+ * sucks because you can't redefine canonical types. This could be fixed by
+ * appending self::$wellKnownTypes behind type file list, but who knows
+ * what will break? In practice this probably isn't a problem anyway -- Bryan)
+ */
+ protected static $wellKnownTypes = <<<EOT
+application/ogg ogx ogg ogm ogv oga spx opus
+application/pdf pdf
+application/vnd.oasis.opendocument.chart odc
+application/vnd.oasis.opendocument.chart-template otc
+application/vnd.oasis.opendocument.database odb
+application/vnd.oasis.opendocument.formula odf
+application/vnd.oasis.opendocument.formula-template otf
+application/vnd.oasis.opendocument.graphics odg
+application/vnd.oasis.opendocument.graphics-template otg
+application/vnd.oasis.opendocument.image odi
+application/vnd.oasis.opendocument.image-template oti
+application/vnd.oasis.opendocument.presentation odp
+application/vnd.oasis.opendocument.presentation-template otp
+application/vnd.oasis.opendocument.spreadsheet ods
+application/vnd.oasis.opendocument.spreadsheet-template ots
+application/vnd.oasis.opendocument.text odt
+application/vnd.oasis.opendocument.text-master otm
+application/vnd.oasis.opendocument.text-template ott
+application/vnd.oasis.opendocument.text-web oth
+application/javascript js
+application/x-shockwave-flash swf
+audio/midi mid midi kar
+audio/mpeg mpga mpa mp2 mp3
+audio/x-aiff aif aiff aifc
+audio/x-wav wav
+audio/ogg oga spx ogg opus
+audio/opus opus ogg oga ogg spx
+image/x-bmp bmp
+image/gif gif
+image/jpeg jpeg jpg jpe
+image/png png
+image/svg+xml svg
+image/svg svg
+image/tiff tiff tif
+image/vnd.djvu djvu
+image/x.djvu djvu
+image/x-djvu djvu
+image/x-portable-pixmap ppm
+image/x-xcf xcf
+text/plain txt
+text/html html htm
+video/ogg ogv ogm ogg
+video/mpeg mpg mpeg
+EOT;
+
+ /**
+ * Defines a set of well known MIME info entries
+ * This is used as a fallback to mime.info files.
+ * An extensive list of well known MIME types is provided by
+ * the file mime.info in the includes directory.
+ */
+ protected static $wellKnownInfo = <<<EOT
+application/pdf [OFFICE]
+application/vnd.oasis.opendocument.chart [OFFICE]
+application/vnd.oasis.opendocument.chart-template [OFFICE]
+application/vnd.oasis.opendocument.database [OFFICE]
+application/vnd.oasis.opendocument.formula [OFFICE]
+application/vnd.oasis.opendocument.formula-template [OFFICE]
+application/vnd.oasis.opendocument.graphics [OFFICE]
+application/vnd.oasis.opendocument.graphics-template [OFFICE]
+application/vnd.oasis.opendocument.image [OFFICE]
+application/vnd.oasis.opendocument.image-template [OFFICE]
+application/vnd.oasis.opendocument.presentation [OFFICE]
+application/vnd.oasis.opendocument.presentation-template [OFFICE]
+application/vnd.oasis.opendocument.spreadsheet [OFFICE]
+application/vnd.oasis.opendocument.spreadsheet-template [OFFICE]
+application/vnd.oasis.opendocument.text [OFFICE]
+application/vnd.oasis.opendocument.text-template [OFFICE]
+application/vnd.oasis.opendocument.text-master [OFFICE]
+application/vnd.oasis.opendocument.text-web [OFFICE]
+application/javascript text/javascript application/x-javascript [EXECUTABLE]
+application/x-shockwave-flash [MULTIMEDIA]
+audio/midi [AUDIO]
+audio/x-aiff [AUDIO]
+audio/x-wav [AUDIO]
+audio/mp3 audio/mpeg [AUDIO]
+application/ogg audio/ogg video/ogg [MULTIMEDIA]
+image/x-bmp image/x-ms-bmp image/bmp [BITMAP]
+image/gif [BITMAP]
+image/jpeg [BITMAP]
+image/png [BITMAP]
+image/svg+xml [DRAWING]
+image/tiff [BITMAP]
+image/vnd.djvu [BITMAP]
+image/x-xcf [BITMAP]
+image/x-portable-pixmap [BITMAP]
+text/plain [TEXT]
+text/html [TEXT]
+video/ogg [VIDEO]
+video/mpeg [VIDEO]
+unknown/unknown application/octet-stream application/x-empty [UNKNOWN]
+EOT;
+
+ /**
+ * @param array $params Configuration map, includes:
+ * - typeFile: path to file with the list of known MIME types
+ * - infoFile: path to file with the MIME type info
+ * - xmlTypes: map of root element names to XML MIME types
+ * - initCallback: initialization callback that is passed this object [optional]
+ * - detectCallback: alternative to finfo that returns the mime type for a file.
+ * For example, the callback can return the output of "file -bi". [optional]
+ * - guessCallback: callback to improve the guessed MIME type using the file data.
+ * This is intended for fixing mistakes in fileinfo or "detectCallback". [optional]
+ * - extCallback: callback to improve the guessed MIME type using the extension. [optional]
+ * - logger: PSR-3 logger [optional]
+ * @note Constructing these instances is expensive due to file reads.
+ * A service or singleton pattern should be used to avoid creating instances again and again.
+ */
+ public function __construct( array $params ) {
+ $this->typeFile = $params['typeFile'];
+ $this->infoFile = $params['infoFile'];
+ $this->xmlTypes = $params['xmlTypes'];
+ $this->initCallback = isset( $params['initCallback'] )
+ ? $params['initCallback']
+ : null;
+ $this->detectCallback = isset( $params['detectCallback'] )
+ ? $params['detectCallback']
+ : null;
+ $this->guessCallback = isset( $params['guessCallback'] )
+ ? $params['guessCallback']
+ : null;
+ $this->extCallback = isset( $params['extCallback'] )
+ ? $params['extCallback']
+ : null;
+ $this->logger = isset( $params['logger'] )
+ ? $params['logger']
+ : new \Psr\Log\NullLogger();
+
+ $this->loadFiles();
+ }
+
+ protected function loadFiles() {
+ /**
+ * --- load mime.types ---
+ */
+
+ # Allow media handling extensions adding MIME-types and MIME-info
+ if ( $this->initCallback ) {
+ call_user_func( $this->initCallback, $this );
+ }
+
+ $types = self::$wellKnownTypes;
+
+ $mimeTypeFile = $this->typeFile;
+ if ( $mimeTypeFile ) {
+ if ( is_file( $mimeTypeFile ) && is_readable( $mimeTypeFile ) ) {
+ $this->logger->info( __METHOD__ . ": loading mime types from $mimeTypeFile\n" );
+ $types .= "\n";
+ $types .= file_get_contents( $mimeTypeFile );
+ } else {
+ $this->logger->info( __METHOD__ . ": can't load mime types from $mimeTypeFile\n" );
+ }
+ } else {
+ $this->logger->info( __METHOD__ .
+ ": no mime types file defined, using built-ins only.\n" );
+ }
+
+ $types .= "\n" . $this->extraTypes;
+
+ $types = str_replace( [ "\r\n", "\n\r", "\n\n", "\r\r", "\r" ], "\n", $types );
+ $types = str_replace( "\t", " ", $types );
+
+ $this->mimetoExt = [];
+ $this->mExtToMime = [];
+
+ $lines = explode( "\n", $types );
+ foreach ( $lines as $s ) {
+ $s = trim( $s );
+ if ( empty( $s ) ) {
+ continue;
+ }
+ if ( strpos( $s, '#' ) === 0 ) {
+ continue;
+ }
+
+ $s = strtolower( $s );
+ $i = strpos( $s, ' ' );
+
+ if ( $i === false ) {
+ continue;
+ }
+
+ $mime = substr( $s, 0, $i );
+ $ext = trim( substr( $s, $i + 1 ) );
+
+ if ( empty( $ext ) ) {
+ continue;
+ }
+
+ if ( !empty( $this->mimetoExt[$mime] ) ) {
+ $this->mimetoExt[$mime] .= ' ' . $ext;
+ } else {
+ $this->mimetoExt[$mime] = $ext;
+ }
+
+ $extensions = explode( ' ', $ext );
+
+ foreach ( $extensions as $e ) {
+ $e = trim( $e );
+ if ( empty( $e ) ) {
+ continue;
+ }
+
+ if ( !empty( $this->mExtToMime[$e] ) ) {
+ $this->mExtToMime[$e] .= ' ' . $mime;
+ } else {
+ $this->mExtToMime[$e] = $mime;
+ }
+ }
+ }
+
+ /**
+ * --- load mime.info ---
+ */
+
+ $mimeInfoFile = $this->infoFile;
+
+ $info = self::$wellKnownInfo;
+
+ if ( $mimeInfoFile ) {
+ if ( is_file( $mimeInfoFile ) && is_readable( $mimeInfoFile ) ) {
+ $this->logger->info( __METHOD__ . ": loading mime info from $mimeInfoFile\n" );
+ $info .= "\n";
+ $info .= file_get_contents( $mimeInfoFile );
+ } else {
+ $this->logger->info( __METHOD__ . ": can't load mime info from $mimeInfoFile\n" );
+ }
+ } else {
+ $this->logger->info( __METHOD__ .
+ ": no mime info file defined, using built-ins only.\n" );
+ }
+
+ $info .= "\n" . $this->extraInfo;
+
+ $info = str_replace( [ "\r\n", "\n\r", "\n\n", "\r\r", "\r" ], "\n", $info );
+ $info = str_replace( "\t", " ", $info );
+
+ $this->mimeTypeAliases = [];
+ $this->mediaTypes = [];
+
+ $lines = explode( "\n", $info );
+ foreach ( $lines as $s ) {
+ $s = trim( $s );
+ if ( empty( $s ) ) {
+ continue;
+ }
+ if ( strpos( $s, '#' ) === 0 ) {
+ continue;
+ }
+
+ $s = strtolower( $s );
+ $i = strpos( $s, ' ' );
+
+ if ( $i === false ) {
+ continue;
+ }
+
+ # print "processing MIME INFO line $s<br>";
+
+ $match = [];
+ if ( preg_match( '!\[\s*(\w+)\s*\]!', $s, $match ) ) {
+ $s = preg_replace( '!\[\s*(\w+)\s*\]!', '', $s );
+ $mtype = trim( strtoupper( $match[1] ) );
+ } else {
+ $mtype = MEDIATYPE_UNKNOWN;
+ }
+
+ $m = explode( ' ', $s );
+
+ if ( !isset( $this->mediaTypes[$mtype] ) ) {
+ $this->mediaTypes[$mtype] = [];
+ }
+
+ foreach ( $m as $mime ) {
+ $mime = trim( $mime );
+ if ( empty( $mime ) ) {
+ continue;
+ }
+
+ $this->mediaTypes[$mtype][] = $mime;
+ }
+
+ if ( count( $m ) > 1 ) {
+ $main = $m[0];
+ $mCount = count( $m );
+ for ( $i = 1; $i < $mCount; $i += 1 ) {
+ $mime = $m[$i];
+ $this->mimeTypeAliases[$mime] = $main;
+ }
+ }
+ }
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Adds to the list mapping MIME to file extensions.
+ * As an extension author, you are encouraged to submit patches to
+ * MediaWiki's core to add new MIME types to mime.types.
+ * @param string $types
+ */
+ public function addExtraTypes( $types ) {
+ $this->extraTypes .= "\n" . $types;
+ }
+
+ /**
+ * Adds to the list mapping MIME to media type.
+ * As an extension author, you are encouraged to submit patches to
+ * MediaWiki's core to add new MIME info to mime.info.
+ * @param string $info
+ */
+ public function addExtraInfo( $info ) {
+ $this->extraInfo .= "\n" . $info;
+ }
+
+ /**
+ * Returns a list of file extensions for a given MIME type as a space
+ * separated string or null if the MIME type was unrecognized. Resolves
+ * MIME type aliases.
+ *
+ * @param string $mime
+ * @return string|null
+ */
+ public function getExtensionsForType( $mime ) {
+ $mime = strtolower( $mime );
+
+ // Check the mime-to-ext map
+ if ( isset( $this->mimetoExt[$mime] ) ) {
+ return $this->mimetoExt[$mime];
+ }
+
+ // Resolve the MIME type to the canonical type
+ if ( isset( $this->mimeTypeAliases[$mime] ) ) {
+ $mime = $this->mimeTypeAliases[$mime];
+ if ( isset( $this->mimetoExt[$mime] ) ) {
+ return $this->mimetoExt[$mime];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns a list of MIME types for a given file extension as a space
+ * separated string or null if the extension was unrecognized.
+ *
+ * @param string $ext
+ * @return string|null
+ */
+ public function getTypesForExtension( $ext ) {
+ $ext = strtolower( $ext );
+
+ $r = isset( $this->mExtToMime[$ext] ) ? $this->mExtToMime[$ext] : null;
+ return $r;
+ }
+
+ /**
+ * Returns a single MIME type for a given file extension or null if unknown.
+ * This is always the first type from the list returned by getTypesForExtension($ext).
+ *
+ * @param string $ext
+ * @return string|null
+ */
+ public function guessTypesForExtension( $ext ) {
+ $m = $this->getTypesForExtension( $ext );
+ if ( is_null( $m ) ) {
+ return null;
+ }
+
+ // TODO: Check if this is needed; strtok( $m, ' ' ) should be sufficient
+ $m = trim( $m );
+ $m = preg_replace( '/\s.*$/', '', $m );
+
+ return $m;
+ }
+
+ /**
+ * Tests if the extension matches the given MIME type. Returns true if a
+ * match was found, null if the MIME type is unknown, and false if the
+ * MIME type is known but no matches where found.
+ *
+ * @param string $extension
+ * @param string $mime
+ * @return bool|null
+ */
+ public function isMatchingExtension( $extension, $mime ) {
+ $ext = $this->getExtensionsForType( $mime );
+
+ if ( !$ext ) {
+ return null; // Unknown MIME type
+ }
+
+ $ext = explode( ' ', $ext );
+
+ $extension = strtolower( $extension );
+ return in_array( $extension, $ext );
+ }
+
+ /**
+ * Returns true if the MIME type is known to represent an image format
+ * supported by the PHP GD library.
+ *
+ * @param string $mime
+ *
+ * @return bool
+ */
+ public function isPHPImageType( $mime ) {
+ // As defined by imagegetsize and image_type_to_mime
+ static $types = [
+ 'image/gif', 'image/jpeg', 'image/png',
+ 'image/x-bmp', 'image/xbm', 'image/tiff',
+ 'image/jp2', 'image/jpeg2000', 'image/iff',
+ 'image/xbm', 'image/x-xbitmap',
+ 'image/vnd.wap.wbmp', 'image/vnd.xiff',
+ 'image/x-photoshop',
+ 'application/x-shockwave-flash',
+ ];
+
+ return in_array( $mime, $types );
+ }
+
+ /**
+ * Returns true if the extension represents a type which can
+ * be reliably detected from its content. Use this to determine
+ * whether strict content checks should be applied to reject
+ * invalid uploads; if we can't identify the type we won't
+ * be able to say if it's invalid.
+ *
+ * @todo Be more accurate when using fancy MIME detector plugins;
+ * right now this is the bare minimum getimagesize() list.
+ * @param string $extension
+ * @return bool
+ */
+ function isRecognizableExtension( $extension ) {
+ static $types = [
+ // Types recognized by getimagesize()
+ 'gif', 'jpeg', 'jpg', 'png', 'swf', 'psd',
+ 'bmp', 'tiff', 'tif', 'jpc', 'jp2',
+ 'jpx', 'jb2', 'swc', 'iff', 'wbmp',
+ 'xbm',
+
+ // Formats we recognize magic numbers for
+ 'djvu', 'ogx', 'ogg', 'ogv', 'oga', 'spx', 'opus',
+ 'mid', 'pdf', 'wmf', 'xcf', 'webm', 'mkv', 'mka',
+ 'webp', 'mp3',
+
+ // XML formats we sure hope we recognize reliably
+ 'svg',
+
+ // 3D formats
+ 'stl',
+ ];
+ return in_array( strtolower( $extension ), $types );
+ }
+
+ /**
+ * Improves a MIME type using the file extension. Some file formats are very generic,
+ * so their MIME type is not very meaningful. A more useful MIME type can be derived
+ * by looking at the file extension. Typically, this method would be called on the
+ * result of guessMimeType().
+ *
+ * @param string $mime The MIME type, typically guessed from a file's content.
+ * @param string $ext The file extension, as taken from the file name
+ *
+ * @return string The MIME type
+ */
+ public function improveTypeFromExtension( $mime, $ext ) {
+ if ( $mime === 'unknown/unknown' ) {
+ if ( $this->isRecognizableExtension( $ext ) ) {
+ $this->logger->info( __METHOD__ . ': refusing to guess mime type for .' .
+ "$ext file, we should have recognized it\n" );
+ } else {
+ // Not something we can detect, so simply
+ // trust the file extension
+ $mime = $this->guessTypesForExtension( $ext );
+ }
+ } elseif ( $mime === 'application/x-opc+zip' ) {
+ if ( $this->isMatchingExtension( $ext, $mime ) ) {
+ // A known file extension for an OPC file,
+ // find the proper MIME type for that file extension
+ $mime = $this->guessTypesForExtension( $ext );
+ } else {
+ $this->logger->info( __METHOD__ .
+ ": refusing to guess better type for $mime file, " .
+ ".$ext is not a known OPC extension.\n" );
+ $mime = 'application/zip';
+ }
+ } elseif ( $mime === 'text/plain' && $this->findMediaType( ".$ext" ) === MEDIATYPE_TEXT ) {
+ // Textual types are sometimes not recognized properly.
+ // If detected as text/plain, and has an extension which is textual
+ // improve to the extension's type. For example, csv and json are often
+ // misdetected as text/plain.
+ $mime = $this->guessTypesForExtension( $ext );
+ }
+
+ # Media handling extensions can improve the MIME detected
+ $callback = $this->extCallback;
+ if ( $callback ) {
+ $callback( $this, $ext, $mime /* by reference */ );
+ }
+
+ if ( isset( $this->mimeTypeAliases[$mime] ) ) {
+ $mime = $this->mimeTypeAliases[$mime];
+ }
+
+ $this->logger->info( __METHOD__ . ": improved mime type for .$ext: $mime\n" );
+ return $mime;
+ }
+
+ /**
+ * MIME type detection. This uses detectMimeType to detect the MIME type
+ * of the file, but applies additional checks to determine some well known
+ * file formats that may be missed or misinterpreted by the default MIME
+ * detection (namely XML based formats like XHTML or SVG, as well as ZIP
+ * based formats like OPC/ODF files).
+ *
+ * @param string $file The file to check
+ * @param string|bool $ext The file extension, or true (default) to extract
+ * it from the filename. Set it to false to ignore the extension. DEPRECATED!
+ * Set to false, use improveTypeFromExtension($mime, $ext) later to improve MIME type.
+ *
+ * @return string The MIME type of $file
+ */
+ public function guessMimeType( $file, $ext = true ) {
+ if ( $ext ) { // TODO: make $ext default to false. Or better, remove it.
+ $this->logger->info( __METHOD__ .
+ ": WARNING: use of the \$ext parameter is deprecated. " .
+ "Use improveTypeFromExtension(\$mime, \$ext) instead.\n" );
+ }
+
+ $mime = $this->doGuessMimeType( $file, $ext );
+
+ if ( !$mime ) {
+ $this->logger->info( __METHOD__ .
+ ": internal type detection failed for $file (.$ext)...\n" );
+ $mime = $this->detectMimeType( $file, $ext );
+ }
+
+ if ( isset( $this->mimeTypeAliases[$mime] ) ) {
+ $mime = $this->mimeTypeAliases[$mime];
+ }
+
+ $this->logger->info( __METHOD__ . ": guessed mime type of $file: $mime\n" );
+ return $mime;
+ }
+
+ /**
+ * Guess the MIME type from the file contents.
+ *
+ * @todo Remove $ext param
+ *
+ * @param string $file
+ * @param mixed $ext
+ * @return bool|string
+ * @throws UnexpectedValueException
+ */
+ private function doGuessMimeType( $file, $ext ) {
+ // Read a chunk of the file
+ MediaWiki\suppressWarnings();
+ $f = fopen( $file, 'rb' );
+ MediaWiki\restoreWarnings();
+
+ if ( !$f ) {
+ return 'unknown/unknown';
+ }
+
+ $fsize = filesize( $file );
+ if ( $fsize === false ) {
+ return 'unknown/unknown';
+ }
+
+ $head = fread( $f, 1024 );
+ $tailLength = min( 65558, $fsize ); // 65558 = maximum size of a zip EOCDR
+ if ( fseek( $f, -1 * $tailLength, SEEK_END ) === -1 ) {
+ throw new UnexpectedValueException(
+ "Seeking $tailLength bytes from EOF failed in " . __METHOD__ );
+ }
+ $tail = $tailLength ? fread( $f, $tailLength ) : '';
+ fclose( $f );
+
+ $this->logger->info( __METHOD__ .
+ ": analyzing head and tail of $file for magic numbers.\n" );
+
+ // Hardcode a few magic number checks...
+ $headers = [
+ // Multimedia...
+ 'MThd' => 'audio/midi',
+ 'OggS' => 'application/ogg',
+ 'ID3' => 'audio/mpeg',
+ "\xff\xfb" => 'audio/mpeg', // MPEG-1 layer 3
+ "\xff\xf3" => 'audio/mpeg', // MPEG-2 layer 3 (lower sample rates)
+ "\xff\xe3" => 'audio/mpeg', // MPEG-2.5 layer 3 (very low sample rates)
+
+ // Image formats...
+ // Note that WMF may have a bare header, no magic number.
+ "\x01\x00\x09\x00" => 'application/x-msmetafile', // Possibly prone to false positives?
+ "\xd7\xcd\xc6\x9a" => 'application/x-msmetafile',
+ '%PDF' => 'application/pdf',
+ 'gimp xcf' => 'image/x-xcf',
+
+ // Some forbidden fruit...
+ 'MZ' => 'application/octet-stream', // DOS/Windows executable
+ "\xca\xfe\xba\xbe" => 'application/octet-stream', // Mach-O binary
+ "\x7fELF" => 'application/octet-stream', // ELF binary
+ ];
+
+ foreach ( $headers as $magic => $candidate ) {
+ if ( strncmp( $head, $magic, strlen( $magic ) ) == 0 ) {
+ $this->logger->info( __METHOD__ .
+ ": magic header in $file recognized as $candidate\n" );
+ return $candidate;
+ }
+ }
+
+ /* Look for WebM and Matroska files */
+ if ( strncmp( $head, pack( "C4", 0x1a, 0x45, 0xdf, 0xa3 ), 4 ) == 0 ) {
+ $doctype = strpos( $head, "\x42\x82" );
+ if ( $doctype ) {
+ // Next byte is datasize, then data (sizes larger than 1 byte are stupid muxers)
+ $data = substr( $head, $doctype + 3, 8 );
+ if ( strncmp( $data, "matroska", 8 ) == 0 ) {
+ $this->logger->info( __METHOD__ . ": recognized file as video/x-matroska\n" );
+ return "video/x-matroska";
+ } elseif ( strncmp( $data, "webm", 4 ) == 0 ) {
+ // XXX HACK look for a video track, if we don't find it, this is an audio file
+ $videotrack = strpos( $head, "\x86\x85V_VP" );
+
+ if ( $videotrack ) {
+ // There is a video track, so this is a video file.
+ $this->logger->info( __METHOD__ . ": recognized file as video/webm\n" );
+ return "video/webm";
+ }
+
+ $this->logger->info( __METHOD__ . ": recognized file as audio/webm\n" );
+ return "audio/webm";
+ }
+ }
+ $this->logger->info( __METHOD__ . ": unknown EBML file\n" );
+ return "unknown/unknown";
+ }
+
+ /* Look for WebP */
+ if ( strncmp( $head, "RIFF", 4 ) == 0 &&
+ strncmp( substr( $head, 8, 7 ), "WEBPVP8", 7 ) == 0
+ ) {
+ $this->logger->info( __METHOD__ . ": recognized file as image/webp\n" );
+ return "image/webp";
+ }
+
+ /**
+ * Look for PHP. Check for this before HTML/XML... Warning: this is a
+ * heuristic, and won't match a file with a lot of non-PHP before. It
+ * will also match text files which could be PHP. :)
+ *
+ * @todo FIXME: For this reason, the check is probably useless -- an attacker
+ * could almost certainly just pad the file with a lot of nonsense to
+ * circumvent the check in any case where it would be a security
+ * problem. On the other hand, it causes harmful false positives (bug
+ * 16583). The heuristic has been cut down to exclude three-character
+ * strings like "<? ", but should it be axed completely?
+ */
+ if ( ( strpos( $head, '<?php' ) !== false ) ||
+ ( strpos( $head, "<\x00?\x00p\x00h\x00p" ) !== false ) ||
+ ( strpos( $head, "<\x00?\x00 " ) !== false ) ||
+ ( strpos( $head, "<\x00?\x00\n" ) !== false ) ||
+ ( strpos( $head, "<\x00?\x00\t" ) !== false ) ||
+ ( strpos( $head, "<\x00?\x00=" ) !== false )
+ ) {
+ $this->logger->info( __METHOD__ . ": recognized $file as application/x-php\n" );
+ return 'application/x-php';
+ }
+
+ /**
+ * look for XML formats (XHTML and SVG)
+ */
+ $xml = new XmlTypeCheck( $file );
+ if ( $xml->wellFormed ) {
+ $xmlTypes = $this->xmlTypes;
+ if ( isset( $xmlTypes[$xml->getRootElement()] ) ) {
+ return $xmlTypes[$xml->getRootElement()];
+ } else {
+ return 'application/xml';
+ }
+ }
+
+ /**
+ * look for shell scripts
+ */
+ $script_type = null;
+
+ # detect by shebang
+ if ( substr( $head, 0, 2 ) == "#!" ) {
+ $script_type = "ASCII";
+ } elseif ( substr( $head, 0, 5 ) == "\xef\xbb\xbf#!" ) {
+ $script_type = "UTF-8";
+ } elseif ( substr( $head, 0, 7 ) == "\xfe\xff\x00#\x00!" ) {
+ $script_type = "UTF-16BE";
+ } elseif ( substr( $head, 0, 7 ) == "\xff\xfe#\x00!" ) {
+ $script_type = "UTF-16LE";
+ }
+
+ if ( $script_type ) {
+ if ( $script_type !== "UTF-8" && $script_type !== "ASCII" ) {
+ // Quick and dirty fold down to ASCII!
+ $pack = [ 'UTF-16BE' => 'n*', 'UTF-16LE' => 'v*' ];
+ $chars = unpack( $pack[$script_type], substr( $head, 2 ) );
+ $head = '';
+ foreach ( $chars as $codepoint ) {
+ if ( $codepoint < 128 ) {
+ $head .= chr( $codepoint );
+ } else {
+ $head .= '?';
+ }
+ }
+ }
+
+ $match = [];
+
+ if ( preg_match( '%/?([^\s]+/)(\w+)%', $head, $match ) ) {
+ $mime = "application/x-{$match[2]}";
+ $this->logger->info( __METHOD__ . ": shell script recognized as $mime\n" );
+ return $mime;
+ }
+ }
+
+ // Check for ZIP variants (before getimagesize)
+ if ( strpos( $tail, "PK\x05\x06" ) !== false ) {
+ $this->logger->info( __METHOD__ . ": ZIP header present in $file\n" );
+ return $this->detectZipType( $head, $tail, $ext );
+ }
+
+ // Check for STL (3D) files
+ // @see https://en.wikipedia.org/wiki/STL_(file_format)
+ if ( $fsize >= 15 &&
+ stripos( $head, 'SOLID ' ) === 0 &&
+ preg_match( '/\RENDSOLID .*$/i', $tail ) ) {
+ // ASCII STL file
+ return 'application/sla';
+ } elseif ( $fsize > 84 ) {
+ // binary STL file
+ $triangles = substr( $head, 80, 4 );
+ $triangles = unpack( 'V', $triangles );
+ $triangles = reset( $triangles );
+ if ( $triangles !== false && $fsize === 84 + ( $triangles * 50 ) ) {
+ return 'application/sla';
+ }
+ }
+
+ MediaWiki\suppressWarnings();
+ $gis = getimagesize( $file );
+ MediaWiki\restoreWarnings();
+
+ if ( $gis && isset( $gis['mime'] ) ) {
+ $mime = $gis['mime'];
+ $this->logger->info( __METHOD__ . ": getimagesize detected $file as $mime\n" );
+ return $mime;
+ }
+
+ # Media handling extensions can guess the MIME by content
+ # It's intentionally here so that if core is wrong about a type (false positive),
+ # people will hopefully nag and submit patches :)
+ $mime = false;
+ # Some strings by reference for performance - assuming well-behaved hooks
+ $callback = $this->guessCallback;
+ if ( $callback ) {
+ $callback( $this, $head, $tail, $file, $mime /* by reference */ );
+ };
+
+ return $mime;
+ }
+
+ /**
+ * Detect application-specific file type of a given ZIP file from its
+ * header data. Currently works for OpenDocument and OpenXML types...
+ * If can't tell, returns 'application/zip'.
+ *
+ * @param string $header Some reasonably-sized chunk of file header
+ * @param string|null $tail The tail of the file
+ * @param string|bool $ext The file extension, or true to extract it from the filename.
+ * Set it to false (default) to ignore the extension. DEPRECATED! Set to false,
+ * use improveTypeFromExtension($mime, $ext) later to improve MIME type.
+ *
+ * @return string
+ */
+ function detectZipType( $header, $tail = null, $ext = false ) {
+ if ( $ext ) { # TODO: remove $ext param
+ $this->logger->info( __METHOD__ .
+ ": WARNING: use of the \$ext parameter is deprecated. " .
+ "Use improveTypeFromExtension(\$mime, \$ext) instead.\n" );
+ }
+
+ $mime = 'application/zip';
+ $opendocTypes = [
+ 'chart-template',
+ 'chart',
+ 'formula-template',
+ 'formula',
+ 'graphics-template',
+ 'graphics',
+ 'image-template',
+ 'image',
+ 'presentation-template',
+ 'presentation',
+ 'spreadsheet-template',
+ 'spreadsheet',
+ 'text-template',
+ 'text-master',
+ 'text-web',
+ 'text' ];
+
+ // https://lists.oasis-open.org/archives/office/200505/msg00006.html
+ $types = '(?:' . implode( '|', $opendocTypes ) . ')';
+ $opendocRegex = "/^mimetype(application\/vnd\.oasis\.opendocument\.$types)/";
+
+ $openxmlRegex = "/^\[Content_Types\].xml/";
+
+ if ( preg_match( $opendocRegex, substr( $header, 30 ), $matches ) ) {
+ $mime = $matches[1];
+ $this->logger->info( __METHOD__ . ": detected $mime from ZIP archive\n" );
+ } elseif ( preg_match( $openxmlRegex, substr( $header, 30 ) ) ) {
+ $mime = "application/x-opc+zip";
+ # TODO: remove the block below, as soon as improveTypeFromExtension is used everywhere
+ if ( $ext !== true && $ext !== false ) {
+ /** This is the mode used by getPropsFromPath
+ * These MIME's are stored in the database, where we don't really want
+ * x-opc+zip, because we use it only for internal purposes
+ */
+ if ( $this->isMatchingExtension( $ext, $mime ) ) {
+ /* A known file extension for an OPC file,
+ * find the proper mime type for that file extension
+ */
+ $mime = $this->guessTypesForExtension( $ext );
+ } else {
+ $mime = "application/zip";
+ }
+ }
+ $this->logger->info( __METHOD__ .
+ ": detected an Open Packaging Conventions archive: $mime\n" );
+ } elseif ( substr( $header, 0, 8 ) == "\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" &&
+ ( $headerpos = strpos( $tail, "PK\x03\x04" ) ) !== false &&
+ preg_match( $openxmlRegex, substr( $tail, $headerpos + 30 ) ) ) {
+ if ( substr( $header, 512, 4 ) == "\xEC\xA5\xC1\x00" ) {
+ $mime = "application/msword";
+ }
+ switch ( substr( $header, 512, 6 ) ) {
+ case "\xEC\xA5\xC1\x00\x0E\x00":
+ case "\xEC\xA5\xC1\x00\x1C\x00":
+ case "\xEC\xA5\xC1\x00\x43\x00":
+ $mime = "application/vnd.ms-powerpoint";
+ break;
+ case "\xFD\xFF\xFF\xFF\x10\x00":
+ case "\xFD\xFF\xFF\xFF\x1F\x00":
+ case "\xFD\xFF\xFF\xFF\x22\x00":
+ case "\xFD\xFF\xFF\xFF\x23\x00":
+ case "\xFD\xFF\xFF\xFF\x28\x00":
+ case "\xFD\xFF\xFF\xFF\x29\x00":
+ case "\xFD\xFF\xFF\xFF\x10\x02":
+ case "\xFD\xFF\xFF\xFF\x1F\x02":
+ case "\xFD\xFF\xFF\xFF\x22\x02":
+ case "\xFD\xFF\xFF\xFF\x23\x02":
+ case "\xFD\xFF\xFF\xFF\x28\x02":
+ case "\xFD\xFF\xFF\xFF\x29\x02":
+ $mime = "application/vnd.msexcel";
+ break;
+ }
+
+ $this->logger->info( __METHOD__ .
+ ": detected a MS Office document with OPC trailer\n" );
+ } else {
+ $this->logger->info( __METHOD__ . ": unable to identify type of ZIP archive\n" );
+ }
+ return $mime;
+ }
+
+ /**
+ * Internal MIME type detection. Detection is done using the fileinfo
+ * extension if it is available. It can be overriden by callback, which could
+ * use an external program, for example. If detection fails and $ext is not false,
+ * the MIME type is guessed from the file extension, using guessTypesForExtension.
+ *
+ * If the MIME type is still unknown, getimagesize is used to detect the
+ * MIME type if the file is an image. If no MIME type can be determined,
+ * this function returns 'unknown/unknown'.
+ *
+ * @param string $file The file to check
+ * @param string|bool $ext The file extension, or true (default) to extract it from the filename.
+ * Set it to false to ignore the extension. DEPRECATED! Set to false, use
+ * improveTypeFromExtension($mime, $ext) later to improve MIME type.
+ *
+ * @return string The MIME type of $file
+ */
+ private function detectMimeType( $file, $ext = true ) {
+ /** @todo Make $ext default to false. Or better, remove it. */
+ if ( $ext ) {
+ $this->logger->info( __METHOD__ .
+ ": WARNING: use of the \$ext parameter is deprecated. "
+ . "Use improveTypeFromExtension(\$mime, \$ext) instead.\n" );
+ }
+
+ $callback = $this->detectCallback;
+ $m = null;
+ if ( $callback ) {
+ $m = $callback( $file );
+ } else {
+ $m = mime_content_type( $file );
+ }
+
+ if ( $m ) {
+ # normalize
+ $m = preg_replace( '![;, ].*$!', '', $m ); # strip charset, etc
+ $m = trim( $m );
+ $m = strtolower( $m );
+
+ if ( strpos( $m, 'unknown' ) !== false ) {
+ $m = null;
+ } else {
+ $this->logger->info( __METHOD__ . ": magic mime type of $file: $m\n" );
+ return $m;
+ }
+ }
+
+ // If desired, look at extension as a fallback.
+ if ( $ext === true ) {
+ $i = strrpos( $file, '.' );
+ $ext = strtolower( $i ? substr( $file, $i + 1 ) : '' );
+ }
+ if ( $ext ) {
+ if ( $this->isRecognizableExtension( $ext ) ) {
+ $this->logger->info( __METHOD__ . ": refusing to guess mime type for .$ext file, "
+ . "we should have recognized it\n" );
+ } else {
+ $m = $this->guessTypesForExtension( $ext );
+ if ( $m ) {
+ $this->logger->info( __METHOD__ . ": extension mime type of $file: $m\n" );
+ return $m;
+ }
+ }
+ }
+
+ // Unknown type
+ $this->logger->info( __METHOD__ . ": failed to guess mime type for $file!\n" );
+ return 'unknown/unknown';
+ }
+
+ /**
+ * Determine the media type code for a file, using its MIME type, name and
+ * possibly its contents.
+ *
+ * This function relies on the findMediaType(), mapping extensions and MIME
+ * types to media types.
+ *
+ * @todo analyse file if need be
+ * @todo look at multiple extension, separately and together.
+ *
+ * @param string $path Full path to the image file, in case we have to look at the contents
+ * (if null, only the MIME type is used to determine the media type code).
+ * @param string $mime MIME type. If null it will be guessed using guessMimeType.
+ *
+ * @return string A value to be used with the MEDIATYPE_xxx constants.
+ */
+ function getMediaType( $path = null, $mime = null ) {
+ if ( !$mime && !$path ) {
+ return MEDIATYPE_UNKNOWN;
+ }
+
+ // If MIME type is unknown, guess it
+ if ( !$mime ) {
+ $mime = $this->guessMimeType( $path, false );
+ }
+
+ // Special code for ogg - detect if it's video (theora),
+ // else label it as sound.
+ if ( $mime == 'application/ogg' && file_exists( $path ) ) {
+ // Read a chunk of the file
+ $f = fopen( $path, "rt" );
+ if ( !$f ) {
+ return MEDIATYPE_UNKNOWN;
+ }
+ $head = fread( $f, 256 );
+ fclose( $f );
+
+ $head = str_replace( 'ffmpeg2theora', '', strtolower( $head ) );
+
+ // This is an UGLY HACK, file should be parsed correctly
+ if ( strpos( $head, 'theora' ) !== false ) {
+ return MEDIATYPE_VIDEO;
+ } elseif ( strpos( $head, 'vorbis' ) !== false ) {
+ return MEDIATYPE_AUDIO;
+ } elseif ( strpos( $head, 'flac' ) !== false ) {
+ return MEDIATYPE_AUDIO;
+ } elseif ( strpos( $head, 'speex' ) !== false ) {
+ return MEDIATYPE_AUDIO;
+ } elseif ( strpos( $head, 'opus' ) !== false ) {
+ return MEDIATYPE_AUDIO;
+ } else {
+ return MEDIATYPE_MULTIMEDIA;
+ }
+ }
+
+ $type = null;
+ // Check for entry for full MIME type
+ if ( $mime ) {
+ $type = $this->findMediaType( $mime );
+ if ( $type !== MEDIATYPE_UNKNOWN ) {
+ return $type;
+ }
+ }
+
+ // Check for entry for file extension
+ if ( $path ) {
+ $i = strrpos( $path, '.' );
+ $e = strtolower( $i ? substr( $path, $i + 1 ) : '' );
+
+ // TODO: look at multi-extension if this fails, parse from full path
+ $type = $this->findMediaType( '.' . $e );
+ if ( $type !== MEDIATYPE_UNKNOWN ) {
+ return $type;
+ }
+ }
+
+ // Check major MIME type
+ if ( $mime ) {
+ $i = strpos( $mime, '/' );
+ if ( $i !== false ) {
+ $major = substr( $mime, 0, $i );
+ $type = $this->findMediaType( $major );
+ if ( $type !== MEDIATYPE_UNKNOWN ) {
+ return $type;
+ }
+ }
+ }
+
+ if ( !$type ) {
+ $type = MEDIATYPE_UNKNOWN;
+ }
+
+ return $type;
+ }
+
+ /**
+ * Returns a media code matching the given MIME type or file extension.
+ * File extensions are represented by a string starting with a dot (.) to
+ * distinguish them from MIME types.
+ *
+ * This function relies on the mapping defined by $this->mMediaTypes
+ * @access private
+ * @param string $extMime
+ * @return int|string
+ */
+ function findMediaType( $extMime ) {
+ if ( strpos( $extMime, '.' ) === 0 ) {
+ // If it's an extension, look up the MIME types
+ $m = $this->getTypesForExtension( substr( $extMime, 1 ) );
+ if ( !$m ) {
+ return MEDIATYPE_UNKNOWN;
+ }
+
+ $m = explode( ' ', $m );
+ } else {
+ // Normalize MIME type
+ if ( isset( $this->mimeTypeAliases[$extMime] ) ) {
+ $extMime = $this->mimeTypeAliases[$extMime];
+ }
+
+ $m = [ $extMime ];
+ }
+
+ foreach ( $m as $mime ) {
+ foreach ( $this->mediaTypes as $type => $codes ) {
+ if ( in_array( $mime, $codes, true ) ) {
+ return $type;
+ }
+ }
+ }
+
+ return MEDIATYPE_UNKNOWN;
+ }
+
+ /**
+ * Returns an array of media types (MEDIATYPE_xxx constants)
+ *
+ * @return array
+ */
+ public function getMediaTypes() {
+ return array_keys( $this->mediaTypes );
+ }
+
+ /**
+ * Get the MIME types that various versions of Internet Explorer would
+ * detect from a chunk of the content.
+ *
+ * @param string $fileName The file name (unused at present)
+ * @param string $chunk The first 256 bytes of the file
+ * @param string $proposed The MIME type proposed by the server
+ * @return array
+ */
+ public function getIEMimeTypes( $fileName, $chunk, $proposed ) {
+ $ca = $this->getIEContentAnalyzer();
+ return $ca->getRealMimesFromData( $fileName, $chunk, $proposed );
+ }
+
+ /**
+ * Get a cached instance of IEContentAnalyzer
+ *
+ * @return IEContentAnalyzer
+ */
+ protected function getIEContentAnalyzer() {
+ if ( is_null( $this->IEAnalyzer ) ) {
+ $this->IEAnalyzer = new IEContentAnalyzer;
+ }
+ return $this->IEAnalyzer;
+ }
+}
diff --git a/www/wiki/includes/libs/mime/XmlTypeCheck.php b/www/wiki/includes/libs/mime/XmlTypeCheck.php
new file mode 100644
index 00000000..ea7f9a6c
--- /dev/null
+++ b/www/wiki/includes/libs/mime/XmlTypeCheck.php
@@ -0,0 +1,503 @@
+<?php
+/**
+ * XML syntax and type checker.
+ *
+ * Since 1.24.2, it uses XMLReader instead of xml_parse, which gives us
+ * more control over the expansion of XML entities. When passed to the
+ * callback, entities will be fully expanded, but may report the XML is
+ * invalid if expanding the entities are likely to cause a DoS.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class XmlTypeCheck {
+ /**
+ * Will be set to true or false to indicate whether the file is
+ * well-formed XML. Note that this doesn't check schema validity.
+ */
+ public $wellFormed = null;
+
+ /**
+ * Will be set to true if the optional element filter returned
+ * a match at some point.
+ */
+ public $filterMatch = false;
+
+ /**
+ * Will contain the type of filter hit if the optional element filter returned
+ * a match at some point.
+ * @var mixed
+ */
+ public $filterMatchType = false;
+
+ /**
+ * Name of the document's root element, including any namespace
+ * as an expanded URL.
+ */
+ public $rootElement = '';
+
+ /**
+ * A stack of strings containing the data of each xml element as it's processed. Append
+ * data to the top string of the stack, then pop off the string and process it when the
+ * element is closed.
+ */
+ protected $elementData = [];
+
+ /**
+ * A stack of element names and attributes, as we process them.
+ */
+ protected $elementDataContext = [];
+
+ /**
+ * Current depth of the data stack.
+ */
+ protected $stackDepth = 0;
+
+ /**
+ * Additional parsing options
+ */
+ private $parserOptions = [
+ 'processing_instruction_handler' => '',
+ 'external_dtd_handler' => '',
+ 'dtd_handler' => '',
+ 'require_safe_dtd' => true
+ ];
+
+ /**
+ * Allow filtering an XML file.
+ *
+ * Filters should return either true or a string to indicate something
+ * is wrong with the file. $this->filterMatch will store if the
+ * file failed validation (true = failed validation).
+ * $this->filterMatchType will contain the validation error.
+ * $this->wellFormed will contain whether the xml file is well-formed.
+ *
+ * @note If multiple filters are hit, only one of them will have the
+ * result stored in $this->filterMatchType.
+ *
+ * @param string $input a filename or string containing the XML element
+ * @param callable $filterCallback (optional)
+ * Function to call to do additional custom validity checks from the
+ * SAX element handler event. This gives you access to the element
+ * namespace, name, attributes, and text contents.
+ * Filter should return a truthy value describing the error.
+ * @param bool $isFile (optional) indicates if the first parameter is a
+ * filename (default, true) or if it is a string (false)
+ * @param array $options list of additional parsing options:
+ * processing_instruction_handler: Callback for xml_set_processing_instruction_handler
+ * external_dtd_handler: Callback for the url of external dtd subset
+ * dtd_handler: Callback given the full text of the <!DOCTYPE declaration.
+ * require_safe_dtd: Only allow non-recursive entities in internal dtd (default true)
+ */
+ function __construct( $input, $filterCallback = null, $isFile = true, $options = [] ) {
+ $this->filterCallback = $filterCallback;
+ $this->parserOptions = array_merge( $this->parserOptions, $options );
+ $this->validateFromInput( $input, $isFile );
+ }
+
+ /**
+ * Alternative constructor: from filename
+ *
+ * @param string $fname the filename of an XML document
+ * @param callable $filterCallback (optional)
+ * Function to call to do additional custom validity checks from the
+ * SAX element handler event. This gives you access to the element
+ * namespace, name, and attributes, but not to text contents.
+ * Filter should return 'true' to toggle on $this->filterMatch
+ * @return XmlTypeCheck
+ */
+ public static function newFromFilename( $fname, $filterCallback = null ) {
+ return new self( $fname, $filterCallback, true );
+ }
+
+ /**
+ * Alternative constructor: from string
+ *
+ * @param string $string a string containing an XML element
+ * @param callable $filterCallback (optional)
+ * Function to call to do additional custom validity checks from the
+ * SAX element handler event. This gives you access to the element
+ * namespace, name, and attributes, but not to text contents.
+ * Filter should return 'true' to toggle on $this->filterMatch
+ * @return XmlTypeCheck
+ */
+ public static function newFromString( $string, $filterCallback = null ) {
+ return new self( $string, $filterCallback, false );
+ }
+
+ /**
+ * Get the root element. Simple accessor to $rootElement
+ *
+ * @return string
+ */
+ public function getRootElement() {
+ return $this->rootElement;
+ }
+
+ /**
+ * @param string $fname the filename
+ */
+ private function validateFromInput( $xml, $isFile ) {
+ $reader = new XMLReader();
+ if ( $isFile ) {
+ $s = $reader->open( $xml, null, LIBXML_NOERROR | LIBXML_NOWARNING );
+ } else {
+ $s = $reader->XML( $xml, null, LIBXML_NOERROR | LIBXML_NOWARNING );
+ }
+ if ( $s !== true ) {
+ // Couldn't open the XML
+ $this->wellFormed = false;
+ } else {
+ $oldDisable = libxml_disable_entity_loader( true );
+ $reader->setParserProperty( XMLReader::SUBST_ENTITIES, true );
+ try {
+ $this->validate( $reader );
+ } catch ( Exception $e ) {
+ // Calling this malformed, because we didn't parse the whole
+ // thing. Maybe just an external entity refernce.
+ $this->wellFormed = false;
+ $reader->close();
+ libxml_disable_entity_loader( $oldDisable );
+ throw $e;
+ }
+ $reader->close();
+ libxml_disable_entity_loader( $oldDisable );
+ }
+ }
+
+ private function readNext( XMLReader $reader ) {
+ set_error_handler( [ $this, 'XmlErrorHandler' ] );
+ $ret = $reader->read();
+ restore_error_handler();
+ return $ret;
+ }
+
+ public function XmlErrorHandler( $errno, $errstr ) {
+ $this->wellFormed = false;
+ }
+
+ private function validate( $reader ) {
+ // First, move through anything that isn't an element, and
+ // handle any processing instructions with the callback
+ do {
+ if ( !$this->readNext( $reader ) ) {
+ // Hit the end of the document before any elements
+ $this->wellFormed = false;
+ return;
+ }
+ if ( $reader->nodeType === XMLReader::PI ) {
+ $this->processingInstructionHandler( $reader->name, $reader->value );
+ }
+ if ( $reader->nodeType === XMLReader::DOC_TYPE ) {
+ $this->DTDHandler( $reader );
+ }
+ } while ( $reader->nodeType != XMLReader::ELEMENT );
+
+ // Process the rest of the document
+ do {
+ switch ( $reader->nodeType ) {
+ case XMLReader::ELEMENT:
+ $name = $this->expandNS(
+ $reader->name,
+ $reader->namespaceURI
+ );
+ if ( $this->rootElement === '' ) {
+ $this->rootElement = $name;
+ }
+ $empty = $reader->isEmptyElement;
+ $attrs = $this->getAttributesArray( $reader );
+ $this->elementOpen( $name, $attrs );
+ if ( $empty ) {
+ $this->elementClose();
+ }
+ break;
+
+ case XMLReader::END_ELEMENT:
+ $this->elementClose();
+ break;
+
+ case XMLReader::WHITESPACE:
+ case XMLReader::SIGNIFICANT_WHITESPACE:
+ case XMLReader::CDATA:
+ case XMLReader::TEXT:
+ $this->elementData( $reader->value );
+ break;
+
+ case XMLReader::ENTITY_REF:
+ // Unexpanded entity (maybe external?),
+ // don't send to the filter (xml_parse didn't)
+ break;
+
+ case XMLReader::COMMENT:
+ // Don't send to the filter (xml_parse didn't)
+ break;
+
+ case XMLReader::PI:
+ // Processing instructions can happen after the header too
+ $this->processingInstructionHandler(
+ $reader->name,
+ $reader->value
+ );
+ break;
+ case XMLReader::DOC_TYPE:
+ // We should never see a doctype after first
+ // element.
+ $this->wellFormed = false;
+ break;
+ default:
+ // One of DOC, ENTITY, END_ENTITY,
+ // NOTATION, or XML_DECLARATION
+ // xml_parse didn't send these to the filter, so we won't.
+ }
+ } while ( $this->readNext( $reader ) );
+
+ if ( $this->stackDepth !== 0 ) {
+ $this->wellFormed = false;
+ } elseif ( $this->wellFormed === null ) {
+ $this->wellFormed = true;
+ }
+ }
+
+ /**
+ * Get all of the attributes for an XMLReader's current node
+ * @param XMLReader $r
+ * @return array of attributes
+ */
+ private function getAttributesArray( XMLReader $r ) {
+ $attrs = [];
+ while ( $r->moveToNextAttribute() ) {
+ if ( $r->namespaceURI === 'http://www.w3.org/2000/xmlns/' ) {
+ // XMLReader treats xmlns attributes as normal
+ // attributes, while xml_parse doesn't
+ continue;
+ }
+ $name = $this->expandNS( $r->name, $r->namespaceURI );
+ $attrs[$name] = $r->value;
+ }
+ return $attrs;
+ }
+
+ /**
+ * @param string $name element or attribute name, maybe with a full or short prefix
+ * @param string $namespaceURI the namespaceURI
+ * @return string the name prefixed with namespaceURI
+ */
+ private function expandNS( $name, $namespaceURI ) {
+ if ( $namespaceURI ) {
+ $parts = explode( ':', $name );
+ $localname = array_pop( $parts );
+ return "$namespaceURI:$localname";
+ }
+ return $name;
+ }
+
+ /**
+ * @param string $name
+ * @param string $attribs
+ */
+ private function elementOpen( $name, $attribs ) {
+ $this->elementDataContext[] = [ $name, $attribs ];
+ $this->elementData[] = '';
+ $this->stackDepth++;
+ }
+
+ private function elementClose() {
+ list( $name, $attribs ) = array_pop( $this->elementDataContext );
+ $data = array_pop( $this->elementData );
+ $this->stackDepth--;
+ $callbackReturn = false;
+
+ if ( is_callable( $this->filterCallback ) ) {
+ $callbackReturn = call_user_func(
+ $this->filterCallback,
+ $name,
+ $attribs,
+ $data
+ );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ }
+ }
+
+ /**
+ * @param string $data
+ */
+ private function elementData( $data ) {
+ // Collect any data here, and we'll run the callback in elementClose
+ $this->elementData[ $this->stackDepth - 1 ] .= trim( $data );
+ }
+
+ /**
+ * @param string $target
+ * @param string $data
+ */
+ private function processingInstructionHandler( $target, $data ) {
+ $callbackReturn = false;
+ if ( $this->parserOptions['processing_instruction_handler'] ) {
+ $callbackReturn = call_user_func(
+ $this->parserOptions['processing_instruction_handler'],
+ $target,
+ $data
+ );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ }
+ }
+ /**
+ * Handle coming across a <!DOCTYPE declaration.
+ *
+ * @param XMLReader $reader Reader currently pointing at DOCTYPE node.
+ */
+ private function DTDHandler( XMLReader $reader ) {
+ $externalCallback = $this->parserOptions['external_dtd_handler'];
+ $generalCallback = $this->parserOptions['dtd_handler'];
+ $checkIfSafe = $this->parserOptions['require_safe_dtd'];
+ if ( !$externalCallback && !$generalCallback && !$checkIfSafe ) {
+ return;
+ }
+ $dtd = $reader->readOuterXML();
+ $callbackReturn = false;
+
+ if ( $generalCallback ) {
+ $callbackReturn = call_user_func( $generalCallback, $dtd );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ $callbackReturn = false;
+ }
+
+ $parsedDTD = $this->parseDTD( $dtd );
+ if ( $externalCallback && isset( $parsedDTD['type'] ) ) {
+ $callbackReturn = call_user_func(
+ $externalCallback,
+ $parsedDTD['type'],
+ isset( $parsedDTD['publicid'] ) ? $parsedDTD['publicid'] : null,
+ isset( $parsedDTD['systemid'] ) ? $parsedDTD['systemid'] : null
+ );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ $callbackReturn = false;
+ }
+
+ if ( $checkIfSafe && isset( $parsedDTD['internal'] ) ) {
+ if ( !$this->checkDTDIsSafe( $parsedDTD['internal'] ) ) {
+ $this->wellFormed = false;
+ }
+ }
+ }
+
+ /**
+ * Check if the internal subset of the DTD is safe.
+ *
+ * We whitelist an extremely restricted subset of DTD features.
+ *
+ * Safe is defined as:
+ * * Only contains entity defintions (e.g. No <!ATLIST )
+ * * Entity definitions are not "system" entities
+ * * Entity definitions are not "parameter" (i.e. %) entities
+ * * Entity definitions do not reference other entites except &amp;
+ * and quotes. Entity aliases (where the entity contains only
+ * another entity are allowed)
+ * * Entity references aren't overly long (>255 bytes).
+ * * <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink">
+ * allowed if matched exactly for compatibility with graphviz
+ * * Comments.
+ *
+ * @param string $internalSubset The internal subset of the DTD
+ * @return bool true if safe.
+ */
+ private function checkDTDIsSafe( $internalSubset ) {
+ $offset = 0;
+ $res = preg_match(
+ '/^(?:\s*<!ENTITY\s+\S+\s+' .
+ '(?:"(?:&[^"%&;]{1,64};|(?:[^"%&]|&amp;|&quot;){0,255})"' .
+ '|\'(?:&[^"%&;]{1,64};|(?:[^\'%&]|&amp;|&apos;){0,255})\')\s*>' .
+ '|\s*<!--(?:[^-]|-[^-])*-->' .
+ '|\s*<!ATTLIST svg xmlns:xlink CDATA #FIXED ' .
+ '"http:\/\/www.w3.org\/1999\/xlink">)*\s*$/',
+ $internalSubset
+ );
+
+ return (bool)$res;
+ }
+
+ /**
+ * Parse DTD into parts.
+ *
+ * If there is an error parsing the dtd, sets wellFormed to false.
+ *
+ * @param string $dtd
+ * @return array Possibly containing keys publicid, systemid, type and internal.
+ */
+ private function parseDTD( $dtd ) {
+ $m = [];
+ $res = preg_match(
+ '/^<!DOCTYPE\s*\S+\s*' .
+ '(?:(?P<typepublic>PUBLIC)\s*' .
+ '(?:"(?P<pubquote>[^"]*)"|\'(?P<pubapos>[^\']*)\')' . // public identifer
+ '\s*"(?P<pubsysquote>[^"]*)"|\'(?P<pubsysapos>[^\']*)\'' . // system identifier
+ '|(?P<typesystem>SYSTEM)\s*' .
+ '(?:"(?P<sysquote>[^"]*)"|\'(?P<sysapos>[^\']*)\')' .
+ ')?\s*' .
+ '(?:\[\s*(?P<internal>.*)\])?\s*>$/s',
+ $dtd,
+ $m
+ );
+ if ( !$res ) {
+ $this->wellFormed = false;
+ return [];
+ }
+ $parsed = [];
+ foreach ( $m as $field => $value ) {
+ if ( $value === '' || is_numeric( $field ) ) {
+ continue;
+ }
+ switch ( $field ) {
+ case 'typepublic':
+ case 'typesystem':
+ $parsed['type'] = $value;
+ break;
+ case 'pubquote':
+ case 'pubapos':
+ $parsed['publicid'] = $value;
+ break;
+ case 'pubsysquote':
+ case 'pubsysapos':
+ case 'sysquote':
+ case 'sysapos':
+ $parsed['systemid'] = $value;
+ break;
+ case 'internal':
+ $parsed['internal'] = $value;
+ break;
+ }
+ }
+ return $parsed;
+ }
+}
diff --git a/www/wiki/includes/libs/mime/defines.php b/www/wiki/includes/libs/mime/defines.php
new file mode 100644
index 00000000..9f753fee
--- /dev/null
+++ b/www/wiki/includes/libs/mime/defines.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**@{
+ * Media types.
+ * This defines constants for the value returned by File::getMediaType()
+ */
+// unknown format
+define( 'MEDIATYPE_UNKNOWN', 'UNKNOWN' );
+// some bitmap image or image source (like psd, etc). Can't scale up.
+define( 'MEDIATYPE_BITMAP', 'BITMAP' );
+// some vector drawing (SVG, WMF, PS, ...) or image source (oo-draw, etc). Can scale up.
+define( 'MEDIATYPE_DRAWING', 'DRAWING' );
+// simple audio file (ogg, mp3, wav, midi, whatever)
+define( 'MEDIATYPE_AUDIO', 'AUDIO' );
+// simple video file (ogg, mpg, etc;
+// no not include formats here that may contain executable sections or scripts!)
+define( 'MEDIATYPE_VIDEO', 'VIDEO' );
+// Scriptable Multimedia (flash, advanced video container formats, etc)
+define( 'MEDIATYPE_MULTIMEDIA', 'MULTIMEDIA' );
+// Office Documents, Spreadsheets (office formats possibly containing apples, scripts, etc)
+define( 'MEDIATYPE_OFFICE', 'OFFICE' );
+// Plain text (possibly containing program code or scripts)
+define( 'MEDIATYPE_TEXT', 'TEXT' );
+// binary executable
+define( 'MEDIATYPE_EXECUTABLE', 'EXECUTABLE' );
+// archive file (zip, tar, etc)
+define( 'MEDIATYPE_ARCHIVE', 'ARCHIVE' );
+// 3D file types (stl)
+define( 'MEDIATYPE_3D', '3D' );
+/**@}*/
diff --git a/www/wiki/includes/libs/mime/mime.info b/www/wiki/includes/libs/mime/mime.info
new file mode 100644
index 00000000..d8b8be77
--- /dev/null
+++ b/www/wiki/includes/libs/mime/mime.info
@@ -0,0 +1,122 @@
+# MIME type info file.
+# the first MIME type in each line is the "main" MIME type,
+# the others are aliases for this type
+# the media type is given in upper case and square brackets,
+# like [BITMAP], and must indicate a media type as defined by
+# the MEDIATYPE_xxx constants in Defines.php
+
+
+image/gif [BITMAP]
+image/png image/x-png [BITMAP]
+image/ief [BITMAP]
+image/jpeg image/pjpeg [BITMAP]
+image/jp2 [BITMAP]
+image/xbm [BITMAP]
+image/tiff [BITMAP]
+image/x-icon image/x-ico image/vnd.microsoft.icon [BITMAP]
+image/x-rgb [BITMAP]
+image/x-portable-pixmap [BITMAP]
+image/x-portable-graymap image/x-portable-greymap [BITMAP]
+image/x-bmp image/x-ms-bmp image/bmp application/x-bmp application/bmp [BITMAP]
+image/x-photoshop image/psd image/x-psd image/photoshop image/vnd.adobe.photoshop [BITMAP]
+image/vnd.djvu image/x.djvu image/x-djvu [BITMAP]
+image/webp [BITMAP]
+
+image/svg+xml application/svg+xml application/svg image/svg [DRAWING]
+application/postscript [DRAWING]
+application/x-latex [DRAWING]
+application/x-tex [DRAWING]
+application/x-dia-diagram [DRAWING]
+
+
+audio/mpeg audio/mp3 audio/mpeg3 [AUDIO]
+audio/mp4 [AUDIO]
+audio/wav audio/x-wav audio/wave [AUDIO]
+audio/midi audio/mid [AUDIO]
+audio/basic [AUDIO]
+audio/ogg [AUDIO]
+audio/opus [AUDIO]
+audio/x-aiff [AUDIO]
+audio/x-pn-realaudio [AUDIO]
+audio/x-realaudio [AUDIO]
+audio/webm [AUDIO]
+audio/x-matroska [AUDIO]
+audio/x-flac [AUDIO]
+audio/flac [AUDIO]
+
+video/mpeg application/mpeg [VIDEO]
+video/ogg [VIDEO]
+video/x-sgi-video [VIDEO]
+video/x-flv [VIDEO]
+video/webm [VIDEO]
+video/x-matroska [VIDEO]
+video/mp4 [VIDEO]
+
+application/ogg application/x-ogg audio/ogg audio/x-ogg video/ogg video/x-ogg [MULTIMEDIA]
+
+application/x-shockwave-flash [MULTIMEDIA]
+audio/x-pn-realaudio-plugin [MULTIMEDIA]
+model/iges [MULTIMEDIA]
+model/mesh [MULTIMEDIA]
+model/vrml [MULTIMEDIA]
+video/quicktime [MULTIMEDIA]
+video/x-msvideo [MULTIMEDIA]
+
+text/plain [TEXT]
+text/html application/xhtml+xml [TEXT]
+application/xml text/xml [TEXT]
+text [TEXT]
+application/json [TEXT]
+text/csv [TEXT]
+text/tab-separated-values [TEXT]
+
+application/zip application/x-zip [ARCHIVE]
+application/x-gzip [ARCHIVE]
+application/x-bzip [ARCHIVE]
+application/x-bzip2 [ARCHIVE]
+application/x-tar [ARCHIVE]
+application/x-stuffit [ARCHIVE]
+application/x-opc+zip [ARCHIVE]
+application/x-7z-compressed [ARCHIVE]
+
+application/javascript text/javascript application/x-javascript application/x-ecmascript text/ecmascript [EXECUTABLE]
+application/x-bash [EXECUTABLE]
+application/x-sh [EXECUTABLE]
+application/x-csh [EXECUTABLE]
+application/x-tcsh [EXECUTABLE]
+application/x-tcl [EXECUTABLE]
+application/x-perl [EXECUTABLE]
+application/x-python [EXECUTABLE]
+
+application/pdf application/acrobat [OFFICE]
+application/msword [OFFICE]
+application/vnd.ms-excel [OFFICE]
+application/vnd.ms-powerpoint [OFFICE]
+application/x-director [OFFICE]
+text/rtf [OFFICE]
+
+application/vnd.openxmlformats-officedocument.wordprocessingml.document [OFFICE]
+application/vnd.openxmlformats-officedocument.wordprocessingml.template [OFFICE]
+application/vnd.ms-word.document.macroEnabled.12 [OFFICE]
+application/vnd.ms-word.template.macroEnabled.12 [OFFICE]
+application/vnd.openxmlformats-officedocument.presentationml.template [OFFICE]
+application/vnd.openxmlformats-officedocument.presentationml.slideshow [OFFICE]
+application/vnd.openxmlformats-officedocument.presentationml.presentation [OFFICE]
+application/vnd.ms-powerpoint.addin.macroEnabled.12 [OFFICE]
+application/vnd.ms-powerpoint.presentation.macroEnabled.12 [OFFICE]
+application/vnd.ms-powerpoint.presentation.macroEnabled.12 [OFFICE]
+application/vnd.ms-powerpoint.slideshow.macroEnabled.12 [OFFICE]
+application/vnd.openxmlformats-officedocument.spreadsheetml.sheet [OFFICE]
+application/vnd.openxmlformats-officedocument.spreadsheetml.template [OFFICE]
+application/vnd.ms-excel.sheet.macroEnabled.12 [OFFICE]
+application/vnd.ms-excel.template.macroEnabled.12 [OFFICE]
+application/vnd.ms-excel.addin.macroEnabled.12 [OFFICE]
+application/vnd.ms-excel.sheet.binary.macroEnabled.12 [OFFICE]
+application/acad application/x-acad application/autocad_dwg image/x-dwg application/dwg application/x-dwg application/x-autocad image/vnd.dwg drawing/dwg [DRAWING]
+chemical/x-mdl-molfile [DRAWING]
+chemical/x-mdl-sdfile [DRAWING]
+chemical/x-mdl-rxnfile [DRAWING]
+chemical/x-mdl-rdfile [DRAWING]
+chemical/x-mdl-rgfile [DRAWING]
+
+application/sla [3D]
diff --git a/www/wiki/includes/libs/mime/mime.types b/www/wiki/includes/libs/mime/mime.types
new file mode 100644
index 00000000..f1cd59d1
--- /dev/null
+++ b/www/wiki/includes/libs/mime/mime.types
@@ -0,0 +1,189 @@
+application/acad dwg
+application/andrew-inset ez
+application/mac-binhex40 hqx
+application/mac-compactpro cpt
+application/mathml+xml mathml
+application/msword doc dot
+application/octet-stream bin dms lha lzh exe class so dll
+application/oda oda
+application/ogg ogx ogg ogm ogv oga spx opus
+application/pdf pdf
+application/postscript ai eps ps
+application/rdf+xml rdf
+application/smil smi smil
+application/srgs gram
+application/srgs+xml grxml
+application/vnd.mif mif
+application/vnd.ms-excel xls xlt xla
+application/vnd.ms-powerpoint ppt pot pps ppa
+application/vnd.wap.wbxml wbxml
+application/vnd.wap.wmlc wmlc
+application/vnd.wap.wmlscriptc wmlsc
+application/voicexml+xml vxml
+application/x-7z-compressed 7z
+application/x-bcpio bcpio
+application/x-bzip bz
+application/x-bzip2 bz2
+application/x-cdlink vcd
+application/x-chess-pgn pgn
+application/x-cpio cpio
+application/x-csh csh
+application/x-dia-diagram dia
+application/x-director dcr dir dxr
+application/x-dvi dvi
+application/x-futuresplash spl
+application/x-gtar gtar tar
+application/x-gzip gz
+application/x-hdf hdf
+application/x-jar jar
+application/javascript js
+application/json json
+application/x-koan skp skd skt skm
+application/x-latex latex
+application/x-netcdf nc cdf
+application/x-sh sh
+application/x-shar shar
+application/x-shockwave-flash swf
+application/x-stuffit sit
+application/x-sv4cpio sv4cpio
+application/x-sv4crc sv4crc
+application/x-tar tar
+application/x-tcl tcl
+application/x-tex tex
+application/x-texinfo texinfo texi
+application/x-troff t tr roff
+application/x-troff-man man
+application/x-troff-me me
+application/x-troff-ms ms
+application/x-ustar ustar
+application/x-wais-source src
+application/x-xpinstall xpi
+application/xhtml+xml xhtml xht
+application/xslt+xml xslt
+application/xml xml xsl xsd kml
+application/xml-dtd dtd
+application/zip zip jar xpi sxc stc sxd std sxi sti sxm stm sxw stw
+application/x-rar rar
+application/font-woff woff
+application/font-woff2 woff2
+application/vnd.ms-fontobject eot
+application/x-font-ttf ttf
+audio/basic au snd
+audio/midi mid midi kar
+audio/mpeg mpga mp2 mp3
+audio/ogg oga ogg spx opus
+audio/opus opus oga ogg
+video/webm webm
+audio/webm webm
+audio/x-aiff aif aiff aifc
+audio/x-matroska mka mkv
+audio/x-mpegurl m3u
+audio/x-ogg oga ogg spx opus
+audio/x-pn-realaudio ram rm
+audio/x-pn-realaudio-plugin rpm
+audio/x-realaudio ra
+audio/x-wav wav
+audio/wav wav
+audio/x-flac flac
+audio/flac flac
+chemical/x-pdb pdb
+chemical/x-xyz xyz
+image/bmp bmp
+image/cgm cgm
+image/gif gif
+image/ief ief
+image/jp2 j2k jp2 jpg2
+image/jpeg jpeg jpg jpe
+image/png png apng
+image/svg+xml svg
+image/tiff tiff tif
+image/vnd.djvu djvu djv
+image/vnd.microsoft.icon ico
+image/vnd.wap.wbmp wbmp
+image/webp webp
+image/x-cmu-raster ras
+image/x-icon ico
+image/x-ms-bmp bmp
+image/x-portable-anymap pnm
+image/x-portable-bitmap pbm
+image/x-portable-graymap pgm
+image/x-portable-pixmap ppm
+image/x-rgb rgb
+image/x-photoshop psd
+image/x-xbitmap xbm
+image/x-xpixmap xpm
+image/x-xwindowdump xwd
+model/iges igs iges
+model/mesh msh mesh silo
+model/vrml wrl vrml
+text/calendar ics ifb
+text/css css
+text/csv csv
+text/html html htm
+text/plain txt
+text/richtext rtx
+text/rtf rtf
+text/sgml sgml sgm
+text/tab-separated-values tsv
+text/vnd.wap.wml wml
+text/vnd.wap.wmlscript wmls
+text/xml xml xsl xslt rss rdf
+text/x-component htc
+text/x-setext etx
+text/x-sawfish jl
+video/mpeg mpeg mpg mpe
+video/mp4 mp4 m4a m4p m4b m4r m4v
+audio/mp4 m4a
+video/ogg ogv ogm ogg
+video/quicktime qt mov
+video/vnd.mpegurl mxu
+video/x-flv flv
+video/x-matroska mkv mka
+video/x-msvideo avi
+video/x-ogg ogv ogm ogg
+video/x-sgi-movie movie
+x-conference/x-cooltalk ice
+application/vnd.oasis.opendocument.chart odc
+application/vnd.oasis.opendocument.chart-template otc
+application/vnd.oasis.opendocument.database odb
+application/vnd.oasis.opendocument.formula odf
+application/vnd.oasis.opendocument.formula-template otf
+application/vnd.oasis.opendocument.graphics odg
+application/vnd.oasis.opendocument.graphics-template otg
+application/vnd.oasis.opendocument.image odi
+application/vnd.oasis.opendocument.image-template oti
+application/vnd.oasis.opendocument.presentation odp
+application/vnd.oasis.opendocument.presentation-template otp
+application/vnd.oasis.opendocument.spreadsheet ods
+application/vnd.oasis.opendocument.spreadsheet-template ots
+application/vnd.oasis.opendocument.text odt
+application/vnd.oasis.opendocument.text-master odm
+application/vnd.oasis.opendocument.text-template ott
+application/vnd.oasis.opendocument.text-web oth
+application/vnd.openxmlformats-officedocument.wordprocessingml.document docx
+application/vnd.openxmlformats-officedocument.wordprocessingml.template dotx
+application/vnd.ms-word.document.macroEnabled.12 docm
+application/vnd.ms-word.template.macroEnabled.12 dotm
+application/vnd.openxmlformats-officedocument.presentationml.template potx
+application/vnd.openxmlformats-officedocument.presentationml.slideshow ppsx
+application/vnd.openxmlformats-officedocument.presentationml.presentation pptx
+application/vnd.ms-powerpoint.addin.macroEnabled.12 ppam
+application/vnd.ms-powerpoint.presentation.macroEnabled.12 pptm
+application/vnd.ms-powerpoint.presentation.macroEnabled.12 potm
+application/vnd.ms-powerpoint.slideshow.macroEnabled.12 ppsm
+application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx
+application/vnd.openxmlformats-officedocument.spreadsheetml.template xltx
+application/vnd.ms-excel.sheet.macroEnabled.12 xlsm
+application/vnd.ms-excel.template.macroEnabled.12 xltm
+application/vnd.ms-excel.addin.macroEnabled.12 xlam
+application/vnd.ms-excel.sheet.binary.macroEnabled.12 xlsb
+model/vnd.dwfx+xps dwfx
+application/vnd.ms-xpsdocument xps
+application/x-opc+zip docx dotx docm dotm potx ppsx pptx ppam pptm potm ppsm xlsx xltx xlsm xltm xlam xlsb dwfx xps
+chemical/x-mdl-molfile mol
+chemical/x-mdl-sdfile sdf
+chemical/x-mdl-rxnfile rxn
+chemical/x-mdl-rdfile rd
+chemical/x-mdl-rgfile rg
+application/x-amf amf
+application/sla stl
diff --git a/www/wiki/includes/libs/objectcache/APCBagOStuff.php b/www/wiki/includes/libs/objectcache/APCBagOStuff.php
new file mode 100644
index 00000000..e41c3a25
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/APCBagOStuff.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ * Object caching using PHP's APC accelerator.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * This is a wrapper for APC's shared memory functions
+ *
+ * @ingroup Cache
+ */
+class APCBagOStuff extends BagOStuff {
+
+ /**
+ * @var bool If true, trust the APC implementation to serialize and
+ * deserialize objects correctly. If false, (de-)serialize in PHP.
+ */
+ protected $nativeSerialize;
+
+ /**
+ * @var string String to append to each APC key. This may be changed
+ * whenever the handling of values is changed, to prevent existing code
+ * from encountering older values which it cannot handle.
+ */
+ const KEY_SUFFIX = ':2';
+
+ /**
+ * Available parameters are:
+ * - nativeSerialize: If true, pass objects to apc_store(), and trust it
+ * to serialize them correctly. If false, serialize
+ * all values in PHP.
+ *
+ * @param array $params
+ */
+ public function __construct( array $params = [] ) {
+ parent::__construct( $params );
+
+ if ( isset( $params['nativeSerialize'] ) ) {
+ $this->nativeSerialize = $params['nativeSerialize'];
+ } elseif ( extension_loaded( 'apcu' ) && ini_get( 'apc.serializer' ) === 'default' ) {
+ // APCu has a memory corruption bug when the serializer is set to 'default'.
+ // See T120267, and upstream bug reports:
+ // - https://github.com/krakjoe/apcu/issues/38
+ // - https://github.com/krakjoe/apcu/issues/35
+ // - https://github.com/krakjoe/apcu/issues/111
+ $this->logger->warning(
+ 'The APCu extension is loaded and the apc.serializer INI setting ' .
+ 'is set to "default". This can cause memory corruption! ' .
+ 'You should change apc.serializer to "php" instead. ' .
+ 'See <https://github.com/krakjoe/apcu/issues/38>.'
+ );
+ $this->nativeSerialize = false;
+ } else {
+ $this->nativeSerialize = true;
+ }
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ return $this->getUnserialize(
+ apc_fetch( $key . self::KEY_SUFFIX )
+ );
+ }
+
+ protected function getUnserialize( $value ) {
+ if ( is_string( $value ) && !$this->nativeSerialize ) {
+ $value = $this->isInteger( $value )
+ ? intval( $value )
+ : unserialize( $value );
+ }
+ return $value;
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ apc_store(
+ $key . self::KEY_SUFFIX,
+ $this->setSerialize( $value ),
+ $exptime
+ );
+
+ return true;
+ }
+
+ protected function setSerialize( $value ) {
+ if ( !$this->nativeSerialize && !$this->isInteger( $value ) ) {
+ $value = serialize( $value );
+ }
+ return $value;
+ }
+
+ public function delete( $key ) {
+ apc_delete( $key . self::KEY_SUFFIX );
+
+ return true;
+ }
+
+ public function incr( $key, $value = 1 ) {
+ return apc_inc( $key . self::KEY_SUFFIX, $value );
+ }
+
+ public function decr( $key, $value = 1 ) {
+ return apc_dec( $key . self::KEY_SUFFIX, $value );
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/APCUBagOStuff.php b/www/wiki/includes/libs/objectcache/APCUBagOStuff.php
new file mode 100644
index 00000000..a26e5602
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/APCUBagOStuff.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * Object caching using PHP's APCU accelerator.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * This is a wrapper for APCU's shared memory functions
+ *
+ * @ingroup Cache
+ */
+class APCUBagOStuff extends APCBagOStuff {
+ /**
+ * Available parameters are:
+ * - nativeSerialize: If true, pass objects to apcu_store(), and trust it
+ * to serialize them correctly. If false, serialize
+ * all values in PHP.
+ *
+ * @param array $params
+ */
+ public function __construct( array $params = [] ) {
+ parent::__construct( $params );
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ return $this->getUnserialize(
+ apcu_fetch( $key . self::KEY_SUFFIX )
+ );
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ apcu_store(
+ $key . self::KEY_SUFFIX,
+ $this->setSerialize( $value ),
+ $exptime
+ );
+
+ return true;
+ }
+
+ public function delete( $key ) {
+ apcu_delete( $key . self::KEY_SUFFIX );
+
+ return true;
+ }
+
+ public function incr( $key, $value = 1 ) {
+ /**
+ * @todo When we only support php 7 or higher remove this hack
+ *
+ * https://github.com/krakjoe/apcu/issues/166
+ */
+ if ( apcu_exists( $key . self::KEY_SUFFIX ) ) {
+ return apcu_inc( $key . self::KEY_SUFFIX, $value );
+ } else {
+ return false;
+ }
+ }
+
+ public function decr( $key, $value = 1 ) {
+ /**
+ * @todo When we only support php 7 or higher remove this hack
+ *
+ * https://github.com/krakjoe/apcu/issues/166
+ */
+ if ( apcu_exists( $key . self::KEY_SUFFIX ) ) {
+ return apcu_dec( $key . self::KEY_SUFFIX, $value );
+ } else {
+ return false;
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/BagOStuff.php b/www/wiki/includes/libs/objectcache/BagOStuff.php
new file mode 100644
index 00000000..8a23db51
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/BagOStuff.php
@@ -0,0 +1,797 @@
+<?php
+/**
+ * Copyright © 2003-2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * @defgroup Cache Cache
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\ScopedCallback;
+use Wikimedia\WaitConditionLoop;
+
+/**
+ * interface is intended to be more or less compatible with
+ * the PHP memcached client.
+ *
+ * backends for local hash array and SQL table included:
+ * @code
+ * $bag = new HashBagOStuff();
+ * $bag = new SqlBagOStuff(); # connect to db first
+ * @endcode
+ *
+ * @ingroup Cache
+ */
+abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
+ /** @var array[] Lock tracking */
+ protected $locks = [];
+ /** @var int ERR_* class constant */
+ protected $lastError = self::ERR_NONE;
+ /** @var string */
+ protected $keyspace = 'local';
+ /** @var LoggerInterface */
+ protected $logger;
+ /** @var callback|null */
+ protected $asyncHandler;
+ /** @var int Seconds */
+ protected $syncTimeout;
+
+ /** @var bool */
+ private $debugMode = false;
+ /** @var array */
+ private $duplicateKeyLookups = [];
+ /** @var bool */
+ private $reportDupes = false;
+ /** @var bool */
+ private $dupeTrackScheduled = false;
+
+ /** @var callable[] */
+ protected $busyCallbacks = [];
+
+ /** @var int[] Map of (ATTR_* class constant => QOS_* class constant) */
+ protected $attrMap = [];
+
+ /** Possible values for getLastError() */
+ const ERR_NONE = 0; // no error
+ const ERR_NO_RESPONSE = 1; // no response
+ const ERR_UNREACHABLE = 2; // can't connect
+ const ERR_UNEXPECTED = 3; // response gave some error
+
+ /** Bitfield constants for get()/getMulti() */
+ const READ_LATEST = 1; // use latest data for replicated stores
+ const READ_VERIFIED = 2; // promise that caller can tell when keys are stale
+ /** Bitfield constants for set()/merge() */
+ const WRITE_SYNC = 1; // synchronously write to all locations for replicated stores
+ const WRITE_CACHE_ONLY = 2; // Only change state of the in-memory cache
+
+ /**
+ * $params include:
+ * - logger: Psr\Log\LoggerInterface instance
+ * - keyspace: Default keyspace for $this->makeKey()
+ * - asyncHandler: Callable to use for scheduling tasks after the web request ends.
+ * In CLI mode, it should run the task immediately.
+ * - reportDupes: Whether to emit warning log messages for all keys that were
+ * requested more than once (requires an asyncHandler).
+ * - syncTimeout: How long to wait with WRITE_SYNC in seconds.
+ * @param array $params
+ */
+ public function __construct( array $params = [] ) {
+ if ( isset( $params['logger'] ) ) {
+ $this->setLogger( $params['logger'] );
+ } else {
+ $this->setLogger( new NullLogger() );
+ }
+
+ if ( isset( $params['keyspace'] ) ) {
+ $this->keyspace = $params['keyspace'];
+ }
+
+ $this->asyncHandler = isset( $params['asyncHandler'] )
+ ? $params['asyncHandler']
+ : null;
+
+ if ( !empty( $params['reportDupes'] ) && is_callable( $this->asyncHandler ) ) {
+ $this->reportDupes = true;
+ }
+
+ $this->syncTimeout = isset( $params['syncTimeout'] ) ? $params['syncTimeout'] : 3;
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ * @return null
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param bool $bool
+ */
+ public function setDebug( $bool ) {
+ $this->debugMode = $bool;
+ }
+
+ /**
+ * Get an item with the given key, regenerating and setting it if not found
+ *
+ * If the callback returns false, then nothing is stored.
+ *
+ * @param string $key
+ * @param int $ttl Time-to-live (seconds)
+ * @param callable $callback Callback that derives the new value
+ * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
+ * @return mixed The cached value if found or the result of $callback otherwise
+ * @since 1.27
+ */
+ final public function getWithSetCallback( $key, $ttl, $callback, $flags = 0 ) {
+ $value = $this->get( $key, $flags );
+
+ if ( $value === false ) {
+ if ( !is_callable( $callback ) ) {
+ throw new InvalidArgumentException( "Invalid cache miss callback provided." );
+ }
+ $value = call_user_func( $callback );
+ if ( $value !== false ) {
+ $this->set( $key, $value, $ttl );
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * Get an item with the given key
+ *
+ * If the key includes a determistic input hash (e.g. the key can only have
+ * the correct value) or complete staleness checks are handled by the caller
+ * (e.g. nothing relies on the TTL), then the READ_VERIFIED flag should be set.
+ * This lets tiered backends know they can safely upgrade a cached value to
+ * higher tiers using standard TTLs.
+ *
+ * @param string $key
+ * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
+ * @param int $oldFlags [unused]
+ * @return mixed Returns false on failure and if the item does not exist
+ */
+ public function get( $key, $flags = 0, $oldFlags = null ) {
+ // B/C for ( $key, &$casToken = null, $flags = 0 )
+ $flags = is_int( $oldFlags ) ? $oldFlags : $flags;
+
+ $this->trackDuplicateKeys( $key );
+
+ return $this->doGet( $key, $flags );
+ }
+
+ /**
+ * Track the number of times that a given key has been used.
+ * @param string $key
+ */
+ private function trackDuplicateKeys( $key ) {
+ if ( !$this->reportDupes ) {
+ return;
+ }
+
+ if ( !isset( $this->duplicateKeyLookups[$key] ) ) {
+ // Track that we have seen this key. This N-1 counting style allows
+ // easy filtering with array_filter() later.
+ $this->duplicateKeyLookups[$key] = 0;
+ } else {
+ $this->duplicateKeyLookups[$key] += 1;
+
+ if ( $this->dupeTrackScheduled === false ) {
+ $this->dupeTrackScheduled = true;
+ // Schedule a callback that logs keys processed more than once by get().
+ call_user_func( $this->asyncHandler, function () {
+ $dups = array_filter( $this->duplicateKeyLookups );
+ foreach ( $dups as $key => $count ) {
+ $this->logger->warning(
+ 'Duplicate get(): "{key}" fetched {count} times',
+ // Count is N-1 of the actual lookup count
+ [ 'key' => $key, 'count' => $count + 1, ]
+ );
+ }
+ } );
+ }
+ }
+ }
+
+ /**
+ * @param string $key
+ * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
+ * @return mixed Returns false on failure and if the item does not exist
+ */
+ abstract protected function doGet( $key, $flags = 0 );
+
+ /**
+ * @note: This method is only needed if merge() uses mergeViaCas()
+ *
+ * @param string $key
+ * @param mixed &$casToken
+ * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
+ * @return mixed Returns false on failure and if the item does not exist
+ * @throws Exception
+ */
+ protected function getWithToken( $key, &$casToken, $flags = 0 ) {
+ throw new Exception( __METHOD__ . ' not implemented.' );
+ }
+
+ /**
+ * Set an item
+ *
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+ * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+ * @return bool Success
+ */
+ abstract public function set( $key, $value, $exptime = 0, $flags = 0 );
+
+ /**
+ * Delete an item
+ *
+ * @param string $key
+ * @return bool True if the item was deleted or not found, false on failure
+ */
+ abstract public function delete( $key );
+
+ /**
+ * Merge changes into the existing cache value (possibly creating a new one)
+ *
+ * The callback function returns the new value given the current value
+ * (which will be false if not present), and takes the arguments:
+ * (this BagOStuff, cache key, current value, TTL).
+ * The TTL parameter is reference set to $exptime. It can be overriden in the callback.
+ *
+ * @param string $key
+ * @param callable $callback Callback method to be executed
+ * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+ * @param int $attempts The amount of times to attempt a merge in case of failure
+ * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+ * @return bool Success
+ * @throws InvalidArgumentException
+ */
+ public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
+ return $this->mergeViaLock( $key, $callback, $exptime, $attempts, $flags );
+ }
+
+ /**
+ * @see BagOStuff::merge()
+ *
+ * @param string $key
+ * @param callable $callback Callback method to be executed
+ * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+ * @param int $attempts The amount of times to attempt a merge in case of failure
+ * @return bool Success
+ */
+ protected function mergeViaCas( $key, $callback, $exptime = 0, $attempts = 10 ) {
+ do {
+ $this->clearLastError();
+ $reportDupes = $this->reportDupes;
+ $this->reportDupes = false;
+ $casToken = null; // passed by reference
+ $currentValue = $this->getWithToken( $key, $casToken, self::READ_LATEST );
+ $this->reportDupes = $reportDupes;
+
+ if ( $this->getLastError() ) {
+ return false; // don't spam retries (retry only on races)
+ }
+
+ // Derive the new value from the old value
+ $value = call_user_func( $callback, $this, $key, $currentValue, $exptime );
+
+ $this->clearLastError();
+ if ( $value === false ) {
+ $success = true; // do nothing
+ } elseif ( $currentValue === false ) {
+ // Try to create the key, failing if it gets created in the meantime
+ $success = $this->add( $key, $value, $exptime );
+ } else {
+ // Try to update the key, failing if it gets changed in the meantime
+ $success = $this->cas( $casToken, $key, $value, $exptime );
+ }
+ if ( $this->getLastError() ) {
+ return false; // IO error; don't spam retries
+ }
+ } while ( !$success && --$attempts );
+
+ return $success;
+ }
+
+ /**
+ * Check and set an item
+ *
+ * @param mixed $casToken
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+ * @return bool Success
+ * @throws Exception
+ */
+ protected function cas( $casToken, $key, $value, $exptime = 0 ) {
+ throw new Exception( "CAS is not implemented in " . __CLASS__ );
+ }
+
+ /**
+ * @see BagOStuff::merge()
+ *
+ * @param string $key
+ * @param callable $callback Callback method to be executed
+ * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+ * @param int $attempts The amount of times to attempt a merge in case of failure
+ * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+ * @return bool Success
+ */
+ protected function mergeViaLock( $key, $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
+ if ( !$this->lock( $key, 6 ) ) {
+ return false;
+ }
+
+ $this->clearLastError();
+ $reportDupes = $this->reportDupes;
+ $this->reportDupes = false;
+ $currentValue = $this->get( $key, self::READ_LATEST );
+ $this->reportDupes = $reportDupes;
+
+ if ( $this->getLastError() ) {
+ $success = false;
+ } else {
+ // Derive the new value from the old value
+ $value = call_user_func( $callback, $this, $key, $currentValue, $exptime );
+ if ( $value === false ) {
+ $success = true; // do nothing
+ } else {
+ $success = $this->set( $key, $value, $exptime, $flags ); // set the new value
+ }
+ }
+
+ if ( !$this->unlock( $key ) ) {
+ // this should never happen
+ trigger_error( "Could not release lock for key '$key'." );
+ }
+
+ return $success;
+ }
+
+ /**
+ * Reset the TTL on a key if it exists
+ *
+ * @param string $key
+ * @param int $expiry
+ * @return bool Success Returns false if there is no key
+ * @since 1.28
+ */
+ public function changeTTL( $key, $expiry = 0 ) {
+ $value = $this->get( $key );
+
+ return ( $value === false ) ? false : $this->set( $key, $value, $expiry );
+ }
+
+ /**
+ * Acquire an advisory lock on a key string
+ *
+ * Note that if reentry is enabled, duplicate calls ignore $expiry
+ *
+ * @param string $key
+ * @param int $timeout Lock wait timeout; 0 for non-blocking [optional]
+ * @param int $expiry Lock expiry [optional]; 1 day maximum
+ * @param string $rclass Allow reentry if set and the current lock used this value
+ * @return bool Success
+ */
+ public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
+ // Avoid deadlocks and allow lock reentry if specified
+ if ( isset( $this->locks[$key] ) ) {
+ if ( $rclass != '' && $this->locks[$key]['class'] === $rclass ) {
+ ++$this->locks[$key]['depth'];
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ $expiry = min( $expiry ?: INF, self::TTL_DAY );
+ $loop = new WaitConditionLoop(
+ function () use ( $key, $timeout, $expiry ) {
+ $this->clearLastError();
+ if ( $this->add( "{$key}:lock", 1, $expiry ) ) {
+ return true; // locked!
+ } elseif ( $this->getLastError() ) {
+ return WaitConditionLoop::CONDITION_ABORTED; // network partition?
+ }
+
+ return WaitConditionLoop::CONDITION_CONTINUE;
+ },
+ $timeout
+ );
+
+ $locked = ( $loop->invoke() === $loop::CONDITION_REACHED );
+ if ( $locked ) {
+ $this->locks[$key] = [ 'class' => $rclass, 'depth' => 1 ];
+ }
+
+ return $locked;
+ }
+
+ /**
+ * Release an advisory lock on a key string
+ *
+ * @param string $key
+ * @return bool Success
+ */
+ public function unlock( $key ) {
+ if ( isset( $this->locks[$key] ) && --$this->locks[$key]['depth'] <= 0 ) {
+ unset( $this->locks[$key] );
+
+ return $this->delete( "{$key}:lock" );
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a lightweight exclusive self-unlocking lock
+ *
+ * Note that the same lock cannot be acquired twice.
+ *
+ * This is useful for task de-duplication or to avoid obtrusive
+ * (though non-corrupting) DB errors like INSERT key conflicts
+ * or deadlocks when using LOCK IN SHARE MODE.
+ *
+ * @param string $key
+ * @param int $timeout Lock wait timeout; 0 for non-blocking [optional]
+ * @param int $expiry Lock expiry [optional]; 1 day maximum
+ * @param string $rclass Allow reentry if set and the current lock used this value
+ * @return ScopedCallback|null Returns null on failure
+ * @since 1.26
+ */
+ final public function getScopedLock( $key, $timeout = 6, $expiry = 30, $rclass = '' ) {
+ $expiry = min( $expiry ?: INF, self::TTL_DAY );
+
+ if ( !$this->lock( $key, $timeout, $expiry, $rclass ) ) {
+ return null;
+ }
+
+ $lSince = microtime( true ); // lock timestamp
+
+ return new ScopedCallback( function () use ( $key, $lSince, $expiry ) {
+ $latency = 0.050; // latency skew (err towards keeping lock present)
+ $age = ( microtime( true ) - $lSince + $latency );
+ if ( ( $age + $latency ) >= $expiry ) {
+ $this->logger->warning( "Lock for $key held too long ($age sec)." );
+ return; // expired; it's not "safe" to delete the key
+ }
+ $this->unlock( $key );
+ } );
+ }
+
+ /**
+ * Delete all objects expiring before a certain date.
+ * @param string $date The reference date in MW format
+ * @param callable|bool $progressCallback Optional, a function which will be called
+ * regularly during long-running operations with the percentage progress
+ * as the first parameter.
+ *
+ * @return bool Success, false if unimplemented
+ */
+ public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) {
+ // stub
+ return false;
+ }
+
+ /**
+ * Get an associative array containing the item for each of the keys that have items.
+ * @param array $keys List of strings
+ * @param int $flags Bitfield; supports READ_LATEST [optional]
+ * @return array
+ */
+ public function getMulti( array $keys, $flags = 0 ) {
+ $res = [];
+ foreach ( $keys as $key ) {
+ $val = $this->get( $key );
+ if ( $val !== false ) {
+ $res[$key] = $val;
+ }
+ }
+ return $res;
+ }
+
+ /**
+ * Batch insertion
+ * @param array $data $key => $value assoc array
+ * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+ * @return bool Success
+ * @since 1.24
+ */
+ public function setMulti( array $data, $exptime = 0 ) {
+ $res = true;
+ foreach ( $data as $key => $value ) {
+ if ( !$this->set( $key, $value, $exptime ) ) {
+ $res = false;
+ }
+ }
+ return $res;
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime
+ * @return bool Success
+ */
+ public function add( $key, $value, $exptime = 0 ) {
+ if ( $this->get( $key ) === false ) {
+ return $this->set( $key, $value, $exptime );
+ }
+ return false; // key already set
+ }
+
+ /**
+ * Increase stored value of $key by $value while preserving its TTL
+ * @param string $key Key to increase
+ * @param int $value Value to add to $key (Default 1)
+ * @return int|bool New value or false on failure
+ */
+ public function incr( $key, $value = 1 ) {
+ if ( !$this->lock( $key ) ) {
+ return false;
+ }
+ $n = $this->get( $key );
+ if ( $this->isInteger( $n ) ) { // key exists?
+ $n += intval( $value );
+ $this->set( $key, max( 0, $n ) ); // exptime?
+ } else {
+ $n = false;
+ }
+ $this->unlock( $key );
+
+ return $n;
+ }
+
+ /**
+ * Decrease stored value of $key by $value while preserving its TTL
+ * @param string $key
+ * @param int $value
+ * @return int|bool New value or false on failure
+ */
+ public function decr( $key, $value = 1 ) {
+ return $this->incr( $key, - $value );
+ }
+
+ /**
+ * Increase stored value of $key by $value while preserving its TTL
+ *
+ * This will create the key with value $init and TTL $ttl instead if not present
+ *
+ * @param string $key
+ * @param int $ttl
+ * @param int $value
+ * @param int $init
+ * @return int|bool New value or false on failure
+ * @since 1.24
+ */
+ public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) {
+ $newValue = $this->incr( $key, $value );
+ if ( $newValue === false ) {
+ // No key set; initialize
+ $newValue = $this->add( $key, (int)$init, $ttl ) ? $init : false;
+ }
+ if ( $newValue === false ) {
+ // Raced out initializing; increment
+ $newValue = $this->incr( $key, $value );
+ }
+
+ return $newValue;
+ }
+
+ /**
+ * Get the "last error" registered; clearLastError() should be called manually
+ * @return int ERR_* constant for the "last error" registry
+ * @since 1.23
+ */
+ public function getLastError() {
+ return $this->lastError;
+ }
+
+ /**
+ * Clear the "last error" registry
+ * @since 1.23
+ */
+ public function clearLastError() {
+ $this->lastError = self::ERR_NONE;
+ }
+
+ /**
+ * Set the "last error" registry
+ * @param int $err ERR_* constant
+ * @since 1.23
+ */
+ protected function setLastError( $err ) {
+ $this->lastError = $err;
+ }
+
+ /**
+ * Let a callback be run to avoid wasting time on special blocking calls
+ *
+ * The callbacks may or may not be called ever, in any particular order.
+ * They are likely to be invoked when something WRITE_SYNC is used used.
+ * They should follow a caching pattern as shown below, so that any code
+ * using the word will get it's result no matter what happens.
+ * @code
+ * $result = null;
+ * $workCallback = function () use ( &$result ) {
+ * if ( !$result ) {
+ * $result = ....
+ * }
+ * return $result;
+ * }
+ * @endcode
+ *
+ * @param callable $workCallback
+ * @since 1.28
+ */
+ public function addBusyCallback( callable $workCallback ) {
+ $this->busyCallbacks[] = $workCallback;
+ }
+
+ /**
+ * Modify a cache update operation array for EventRelayer::notify()
+ *
+ * This is used for relayed writes, e.g. for broadcasting a change
+ * to multiple data-centers. If the array contains a 'val' field
+ * then the command involves setting a key to that value. Note that
+ * for simplicity, 'val' is always a simple scalar value. This method
+ * is used to possibly serialize the value and add any cache-specific
+ * key/values needed for the relayer daemon (e.g. memcached flags).
+ *
+ * @param array $event
+ * @return array
+ * @since 1.26
+ */
+ public function modifySimpleRelayEvent( array $event ) {
+ return $event;
+ }
+
+ /**
+ * @param string $text
+ */
+ protected function debug( $text ) {
+ if ( $this->debugMode ) {
+ $this->logger->debug( "{class} debug: $text", [
+ 'class' => static::class,
+ ] );
+ }
+ }
+
+ /**
+ * Convert an optionally relative time to an absolute time
+ * @param int $exptime
+ * @return int
+ */
+ protected function convertExpiry( $exptime ) {
+ if ( $exptime != 0 && $exptime < ( 10 * self::TTL_YEAR ) ) {
+ return time() + $exptime;
+ } else {
+ return $exptime;
+ }
+ }
+
+ /**
+ * Convert an optionally absolute expiry time to a relative time. If an
+ * absolute time is specified which is in the past, use a short expiry time.
+ *
+ * @param int $exptime
+ * @return int
+ */
+ protected function convertToRelative( $exptime ) {
+ if ( $exptime >= ( 10 * self::TTL_YEAR ) ) {
+ $exptime -= time();
+ if ( $exptime <= 0 ) {
+ $exptime = 1;
+ }
+ return $exptime;
+ } else {
+ return $exptime;
+ }
+ }
+
+ /**
+ * Check if a value is an integer
+ *
+ * @param mixed $value
+ * @return bool
+ */
+ protected function isInteger( $value ) {
+ return ( is_int( $value ) || ctype_digit( $value ) );
+ }
+
+ /**
+ * Construct a cache key.
+ *
+ * @since 1.27
+ * @param string $keyspace
+ * @param array $args
+ * @return string
+ */
+ public function makeKeyInternal( $keyspace, $args ) {
+ $key = $keyspace;
+ foreach ( $args as $arg ) {
+ $arg = str_replace( ':', '%3A', $arg );
+ $key = $key . ':' . $arg;
+ }
+ return strtr( $key, ' ', '_' );
+ }
+
+ /**
+ * Make a global cache key.
+ *
+ * @since 1.27
+ * @param string $keys,... Key component
+ * @return string
+ */
+ public function makeGlobalKey() {
+ return $this->makeKeyInternal( 'global', func_get_args() );
+ }
+
+ /**
+ * Make a cache key, scoped to this instance's keyspace.
+ *
+ * @since 1.27
+ * @param string $keys,... Key component
+ * @return string
+ */
+ public function makeKey() {
+ return $this->makeKeyInternal( $this->keyspace, func_get_args() );
+ }
+
+ /**
+ * @param int $flag ATTR_* class constant
+ * @return int QOS_* class constant
+ * @since 1.28
+ */
+ public function getQoS( $flag ) {
+ return isset( $this->attrMap[$flag] ) ? $this->attrMap[$flag] : self::QOS_UNKNOWN;
+ }
+
+ /**
+ * Merge the flag maps of one or more BagOStuff objects into a "lowest common denominator" map
+ *
+ * @param BagOStuff[] $bags
+ * @return int[] Resulting flag map (class ATTR_* constant => class QOS_* constant)
+ */
+ protected function mergeFlagMaps( array $bags ) {
+ $map = [];
+ foreach ( $bags as $bag ) {
+ foreach ( $bag->attrMap as $attr => $rank ) {
+ if ( isset( $map[$attr] ) ) {
+ $map[$attr] = min( $map[$attr], $rank );
+ } else {
+ $map[$attr] = $rank;
+ }
+ }
+ }
+
+ return $map;
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/CachedBagOStuff.php b/www/wiki/includes/libs/objectcache/CachedBagOStuff.php
new file mode 100644
index 00000000..c85a82ea
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/CachedBagOStuff.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * Wrapper around a BagOStuff that caches data in memory
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * Wrapper around a BagOStuff that caches data in memory
+ *
+ * The differences between CachedBagOStuff and MultiWriteBagOStuff are:
+ * * CachedBagOStuff supports only one "backend".
+ * * There's a flag for writes to only go to the in-memory cache.
+ * * The in-memory cache is always updated.
+ * * Locks go to the backend cache (with MultiWriteBagOStuff, it would wind
+ * up going to the HashBagOStuff used for the in-memory cache).
+ *
+ * @ingroup Cache
+ */
+class CachedBagOStuff extends HashBagOStuff {
+ /** @var BagOStuff */
+ protected $backend;
+
+ /**
+ * @param BagOStuff $backend Permanent backend to use
+ * @param array $params Parameters for HashBagOStuff
+ */
+ function __construct( BagOStuff $backend, $params = [] ) {
+ unset( $params['reportDupes'] ); // useless here
+
+ parent::__construct( $params );
+
+ $this->backend = $backend;
+ $this->attrMap = $backend->attrMap;
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ $ret = parent::doGet( $key, $flags );
+ if ( $ret === false && !$this->hasKey( $key ) ) {
+ $ret = $this->backend->doGet( $key, $flags );
+ $this->set( $key, $ret, 0, self::WRITE_CACHE_ONLY );
+ }
+ return $ret;
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ parent::set( $key, $value, $exptime, $flags );
+ if ( !( $flags & self::WRITE_CACHE_ONLY ) ) {
+ $this->backend->set( $key, $value, $exptime, $flags & ~self::WRITE_CACHE_ONLY );
+ }
+ return true;
+ }
+
+ public function delete( $key, $flags = 0 ) {
+ unset( $this->bag[$key] );
+ if ( !( $flags & self::WRITE_CACHE_ONLY ) ) {
+ $this->backend->delete( $key );
+ }
+
+ return true;
+ }
+
+ public function setDebug( $bool ) {
+ parent::setDebug( $bool );
+ $this->backend->setDebug( $bool );
+ }
+
+ public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) {
+ parent::deleteObjectsExpiringBefore( $date, $progressCallback );
+ return $this->backend->deleteObjectsExpiringBefore( $date, $progressCallback );
+ }
+
+ public function makeKey() {
+ return call_user_func_array( [ $this->backend, __FUNCTION__ ], func_get_args() );
+ }
+
+ public function makeGlobalKey() {
+ return call_user_func_array( [ $this->backend, __FUNCTION__ ], func_get_args() );
+ }
+
+ // These just call the backend (tested elsewhere)
+ // @codeCoverageIgnoreStart
+
+ public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
+ return $this->backend->lock( $key, $timeout, $expiry, $rclass );
+ }
+
+ public function unlock( $key ) {
+ return $this->backend->unlock( $key );
+ }
+
+ public function getLastError() {
+ return $this->backend->getLastError();
+ }
+
+ public function clearLastError() {
+ return $this->backend->clearLastError();
+ }
+
+ public function modifySimpleRelayEvent( array $event ) {
+ return $this->backend->modifySimpleRelayEvent( $event );
+ }
+
+ // @codeCoverageIgnoreEnd
+}
diff --git a/www/wiki/includes/libs/objectcache/EmptyBagOStuff.php b/www/wiki/includes/libs/objectcache/EmptyBagOStuff.php
new file mode 100644
index 00000000..3f66c06c
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/EmptyBagOStuff.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * Dummy object caching.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * A BagOStuff object with no objects in it. Used to provide a no-op object to calling code.
+ *
+ * @ingroup Cache
+ */
+class EmptyBagOStuff extends BagOStuff {
+ protected function doGet( $key, $flags = 0 ) {
+ return false;
+ }
+
+ public function add( $key, $value, $exp = 0 ) {
+ return true;
+ }
+
+ public function set( $key, $value, $exp = 0, $flags = 0 ) {
+ return true;
+ }
+
+ public function delete( $key ) {
+ return true;
+ }
+
+ public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
+ return true; // faster
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/HashBagOStuff.php b/www/wiki/includes/libs/objectcache/HashBagOStuff.php
new file mode 100644
index 00000000..6d583da0
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/HashBagOStuff.php
@@ -0,0 +1,118 @@
+<?php
+/**
+ * Per-process memory cache for storing items.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * Simple store for keeping values in an associative array for the current process.
+ *
+ * Data will not persist and is not shared with other processes.
+ *
+ * @ingroup Cache
+ */
+class HashBagOStuff extends BagOStuff {
+ /** @var mixed[] */
+ protected $bag = [];
+ /** @var int Max entries allowed */
+ protected $maxCacheKeys;
+
+ const KEY_VAL = 0;
+ const KEY_EXP = 1;
+
+ /**
+ * @param array $params Additional parameters include:
+ * - maxKeys : only allow this many keys (using oldest-first eviction)
+ */
+ function __construct( $params = [] ) {
+ parent::__construct( $params );
+
+ $this->maxCacheKeys = isset( $params['maxKeys'] ) ? $params['maxKeys'] : INF;
+ if ( $this->maxCacheKeys <= 0 ) {
+ throw new InvalidArgumentException( '$maxKeys parameter must be above zero' );
+ }
+ }
+
+ protected function expire( $key ) {
+ $et = $this->bag[$key][self::KEY_EXP];
+ if ( $et == self::TTL_INDEFINITE || $et > time() ) {
+ return false;
+ }
+
+ $this->delete( $key );
+
+ return true;
+ }
+
+ /**
+ * Does this bag have a non-null value for the given key?
+ *
+ * @param string $key
+ * @return bool
+ * @since 1.27
+ */
+ protected function hasKey( $key ) {
+ return isset( $this->bag[$key] );
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ if ( !$this->hasKey( $key ) ) {
+ return false;
+ }
+
+ if ( $this->expire( $key ) ) {
+ return false;
+ }
+
+ // Refresh key position for maxCacheKeys eviction
+ $temp = $this->bag[$key];
+ unset( $this->bag[$key] );
+ $this->bag[$key] = $temp;
+
+ return $this->bag[$key][self::KEY_VAL];
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ // Refresh key position for maxCacheKeys eviction
+ unset( $this->bag[$key] );
+ $this->bag[$key] = [
+ self::KEY_VAL => $value,
+ self::KEY_EXP => $this->convertExpiry( $exptime )
+ ];
+
+ if ( count( $this->bag ) > $this->maxCacheKeys ) {
+ reset( $this->bag );
+ $evictKey = key( $this->bag );
+ unset( $this->bag[$evictKey] );
+ }
+
+ return true;
+ }
+
+ public function delete( $key ) {
+ unset( $this->bag[$key] );
+
+ return true;
+ }
+
+ public function clear() {
+ $this->bag = [];
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/IExpiringStore.php b/www/wiki/includes/libs/objectcache/IExpiringStore.php
new file mode 100644
index 00000000..0e09f16f
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/IExpiringStore.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ * @author 2015 Timo Tijhof
+ */
+
+/**
+ * Generic base class for storage interfaces.
+ *
+ * Provides convenient TTL constants.
+ *
+ * @ingroup Cache
+ * @since 1.27
+ */
+interface IExpiringStore {
+ // Constants for TTL values, in seconds
+ const TTL_MINUTE = 60;
+ const TTL_HOUR = 3600;
+ const TTL_DAY = 86400; // 24 * 3600
+ const TTL_WEEK = 604800; // 7 * 24 * 3600
+ const TTL_MONTH = 2592000; // 30 * 24 * 3600
+ const TTL_YEAR = 31536000; // 365 * 24 * 3600
+
+ // Shorthand process cache TTLs (useful for web requests and CLI mode)
+ const TTL_PROC_SHORT = 3; // reasonably strict cache time that last the life of quick requests
+ const TTL_PROC_LONG = 30; // loose cache time that can survive slow web requests
+
+ const TTL_INDEFINITE = 0;
+
+ // Attribute and QoS constants; higher QOS values with the same prefix rank higher...
+ // Medium attributes constants related to emulation or media type
+ const ATTR_EMULATION = 1;
+ const QOS_EMULATION_SQL = 1;
+ // Medium attributes constants related to replica consistency
+ const ATTR_SYNCWRITES = 2; // SYNC_WRITES flag support
+ const QOS_SYNCWRITES_NONE = 1; // replication only supports eventual consistency or less
+ const QOS_SYNCWRITES_BE = 2; // best effort synchronous with limited retries
+ const QOS_SYNCWRITES_QC = 3; // write quorum applied directly to state machines where R+W > N
+ const QOS_SYNCWRITES_SS = 4; // strict-serializable, nodes refuse reads if possible stale
+ // Generic "unknown" value that is useful for comparisons (e.g. always good enough)
+ const QOS_UNKNOWN = INF;
+}
diff --git a/www/wiki/includes/libs/objectcache/MemcachedBagOStuff.php b/www/wiki/includes/libs/objectcache/MemcachedBagOStuff.php
new file mode 100644
index 00000000..0188991a
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/MemcachedBagOStuff.php
@@ -0,0 +1,192 @@
+<?php
+/**
+ * Base class for memcached clients.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * Base class for memcached clients.
+ *
+ * @ingroup Cache
+ */
+class MemcachedBagOStuff extends BagOStuff {
+ /** @var MemcachedClient|Memcached */
+ protected $client;
+
+ function __construct( array $params ) {
+ parent::__construct( $params );
+
+ $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_BE; // unreliable
+ }
+
+ /**
+ * Fill in some defaults for missing keys in $params.
+ *
+ * @param array $params
+ * @return array
+ */
+ protected function applyDefaultParams( $params ) {
+ return $params + [
+ 'compress_threshold' => 1500,
+ 'connect_timeout' => 0.5,
+ 'debug' => false
+ ];
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ $casToken = null;
+
+ return $this->getWithToken( $key, $casToken, $flags );
+ }
+
+ protected function getWithToken( $key, &$casToken, $flags = 0 ) {
+ return $this->client->get( $this->validateKeyEncoding( $key ), $casToken );
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ return $this->client->set( $this->validateKeyEncoding( $key ), $value,
+ $this->fixExpiry( $exptime ) );
+ }
+
+ protected function cas( $casToken, $key, $value, $exptime = 0 ) {
+ return $this->client->cas( $casToken, $this->validateKeyEncoding( $key ),
+ $value, $this->fixExpiry( $exptime ) );
+ }
+
+ public function delete( $key ) {
+ return $this->client->delete( $this->validateKeyEncoding( $key ) );
+ }
+
+ public function add( $key, $value, $exptime = 0 ) {
+ return $this->client->add( $this->validateKeyEncoding( $key ), $value,
+ $this->fixExpiry( $exptime ) );
+ }
+
+ public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
+ return $this->mergeViaCas( $key, $callback, $exptime, $attempts );
+ }
+
+ public function changeTTL( $key, $exptime = 0 ) {
+ return $this->client->touch( $this->validateKeyEncoding( $key ),
+ $this->fixExpiry( $exptime ) );
+ }
+
+ /**
+ * Get the underlying client object. This is provided for debugging
+ * purposes.
+ * @return MemcachedClient|Memcached
+ */
+ public function getClient() {
+ return $this->client;
+ }
+
+ /**
+ * Construct a cache key.
+ *
+ * @since 1.27
+ * @param string $keyspace
+ * @param array $args
+ * @return string
+ */
+ public function makeKeyInternal( $keyspace, $args ) {
+ // Memcached keys have a maximum length of 255 characters. From that,
+ // subtract the number of characters we need for the keyspace and for
+ // the separator character needed for each argument. To handle some
+ // custom prefixes used by thing like WANObjectCache, limit to 205.
+ $charsLeft = 205 - strlen( $keyspace ) - count( $args );
+
+ $args = array_map(
+ function ( $arg ) use ( &$charsLeft ) {
+ $arg = strtr( $arg, ' ', '_' );
+
+ // Make sure %, #, and non-ASCII chars are escaped
+ $arg = preg_replace_callback(
+ '/[^\x21-\x22\x24\x26-\x39\x3b-\x7e]+/',
+ function ( $m ) {
+ return rawurlencode( $m[0] );
+ },
+ $arg
+ );
+
+ // 33 = 32 characters for the MD5 + 1 for the '#' prefix.
+ if ( $charsLeft > 33 && strlen( $arg ) > $charsLeft ) {
+ $arg = '#' . md5( $arg );
+ }
+
+ $charsLeft -= strlen( $arg );
+ return $arg;
+ },
+ $args
+ );
+
+ if ( $charsLeft < 0 ) {
+ return $keyspace . ':##' . md5( implode( ':', $args ) );
+ }
+
+ return $keyspace . ':' . implode( ':', $args );
+ }
+
+ /**
+ * Ensure that a key is safe to use (contains no control characters and no
+ * characters above the ASCII range.)
+ *
+ * @param string $key
+ * @return string
+ * @throws Exception
+ */
+ public function validateKeyEncoding( $key ) {
+ if ( preg_match( '/[^\x21-\x7e]+/', $key ) ) {
+ throw new Exception( "Key contains invalid characters: $key" );
+ }
+ return $key;
+ }
+
+ /**
+ * TTLs higher than 30 days will be detected as absolute TTLs
+ * (UNIX timestamps), and will result in the cache entry being
+ * discarded immediately because the expiry is in the past.
+ * Clamp expires >30d at 30d, unless they're >=1e9 in which
+ * case they are likely to really be absolute (1e9 = 2011-09-09)
+ * @param int $expiry
+ * @return int
+ */
+ function fixExpiry( $expiry ) {
+ if ( $expiry > 2592000 && $expiry < 1000000000 ) {
+ $expiry = 2592000;
+ }
+ return (int)$expiry;
+ }
+
+ /**
+ * Send a debug message to the log
+ * @param string $text
+ */
+ protected function debugLog( $text ) {
+ $this->logger->debug( $text );
+ }
+
+ public function modifySimpleRelayEvent( array $event ) {
+ if ( array_key_exists( 'val', $event ) ) {
+ $event['flg'] = 0; // data is not serialized nor gzipped (for memcached driver)
+ }
+
+ return $event;
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/MemcachedClient.php b/www/wiki/includes/libs/objectcache/MemcachedClient.php
new file mode 100644
index 00000000..5cb49a99
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/MemcachedClient.php
@@ -0,0 +1,1314 @@
+<?php
+// @codingStandardsIgnoreFile It's an external lib and it isn't. Let's not bother.
+/**
+ * Memcached client for PHP.
+ *
+ * +---------------------------------------------------------------------------+
+ * | memcached client, PHP |
+ * +---------------------------------------------------------------------------+
+ * | Copyright (c) 2003 Ryan T. Dean <rtdean@cytherianage.net> |
+ * | All rights reserved. |
+ * | |
+ * | Redistribution and use in source and binary forms, with or without |
+ * | modification, are permitted provided that the following conditions |
+ * | are met: |
+ * | |
+ * | 1. Redistributions of source code must retain the above copyright |
+ * | notice, this list of conditions and the following disclaimer. |
+ * | 2. Redistributions in binary form must reproduce the above copyright |
+ * | notice, this list of conditions and the following disclaimer in the |
+ * | documentation and/or other materials provided with the distribution. |
+ * | |
+ * | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR |
+ * | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES |
+ * | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. |
+ * | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, |
+ * | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT |
+ * | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
+ * | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
+ * | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
+ * | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF |
+ * | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
+ * +---------------------------------------------------------------------------+
+ * | Author: Ryan T. Dean <rtdean@cytherianage.net> |
+ * | Heavily influenced by the Perl memcached client by Brad Fitzpatrick. |
+ * | Permission granted by Brad Fitzpatrick for relicense of ported Perl |
+ * | client logic under 2-clause BSD license. |
+ * +---------------------------------------------------------------------------+
+ *
+ * @file
+ * $TCAnet$
+ */
+
+/**
+ * This is a PHP client for memcached - a distributed memory cache daemon.
+ *
+ * More information is available at http://www.danga.com/memcached/
+ *
+ * Usage example:
+ *
+ * $mc = new MemcachedClient(array(
+ * 'servers' => array(
+ * '127.0.0.1:10000',
+ * array( '192.0.0.1:10010', 2 ),
+ * '127.0.0.1:10020'
+ * ),
+ * 'debug' => false,
+ * 'compress_threshold' => 10240,
+ * 'persistent' => true
+ * ));
+ *
+ * $mc->add( 'key', array( 'some', 'array' ) );
+ * $mc->replace( 'key', 'some random string' );
+ * $val = $mc->get( 'key' );
+ *
+ * @author Ryan T. Dean <rtdean@cytherianage.net>
+ * @version 0.1.2
+ */
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+// {{{ class MemcachedClient
+/**
+ * memcached client class implemented using (p)fsockopen()
+ *
+ * @author Ryan T. Dean <rtdean@cytherianage.net>
+ * @ingroup Cache
+ */
+class MemcachedClient {
+ // {{{ properties
+ // {{{ public
+
+ // {{{ constants
+ // {{{ flags
+
+ /**
+ * Flag: indicates data is serialized
+ */
+ const SERIALIZED = 1;
+
+ /**
+ * Flag: indicates data is compressed
+ */
+ const COMPRESSED = 2;
+
+ /**
+ * Flag: indicates data is an integer
+ */
+ const INTVAL = 4;
+
+ // }}}
+
+ /**
+ * Minimum savings to store data compressed
+ */
+ const COMPRESSION_SAVINGS = 0.20;
+
+ // }}}
+
+ /**
+ * Command statistics
+ *
+ * @var array
+ * @access public
+ */
+ public $stats;
+
+ // }}}
+ // {{{ private
+
+ /**
+ * Cached Sockets that are connected
+ *
+ * @var array
+ * @access private
+ */
+ public $_cache_sock;
+
+ /**
+ * Current debug status; 0 - none to 9 - profiling
+ *
+ * @var bool
+ * @access private
+ */
+ public $_debug;
+
+ /**
+ * Dead hosts, assoc array, 'host'=>'unixtime when ok to check again'
+ *
+ * @var array
+ * @access private
+ */
+ public $_host_dead;
+
+ /**
+ * Is compression available?
+ *
+ * @var bool
+ * @access private
+ */
+ public $_have_zlib;
+
+ /**
+ * Do we want to use compression?
+ *
+ * @var bool
+ * @access private
+ */
+ public $_compress_enable;
+
+ /**
+ * At how many bytes should we compress?
+ *
+ * @var int
+ * @access private
+ */
+ public $_compress_threshold;
+
+ /**
+ * Are we using persistent links?
+ *
+ * @var bool
+ * @access private
+ */
+ public $_persistent;
+
+ /**
+ * If only using one server; contains ip:port to connect to
+ *
+ * @var string
+ * @access private
+ */
+ public $_single_sock;
+
+ /**
+ * Array containing ip:port or array(ip:port, weight)
+ *
+ * @var array
+ * @access private
+ */
+ public $_servers;
+
+ /**
+ * Our bit buckets
+ *
+ * @var array
+ * @access private
+ */
+ public $_buckets;
+
+ /**
+ * Total # of bit buckets we have
+ *
+ * @var int
+ * @access private
+ */
+ public $_bucketcount;
+
+ /**
+ * # of total servers we have
+ *
+ * @var int
+ * @access private
+ */
+ public $_active;
+
+ /**
+ * Stream timeout in seconds. Applies for example to fread()
+ *
+ * @var int
+ * @access private
+ */
+ public $_timeout_seconds;
+
+ /**
+ * Stream timeout in microseconds
+ *
+ * @var int
+ * @access private
+ */
+ public $_timeout_microseconds;
+
+ /**
+ * Connect timeout in seconds
+ */
+ public $_connect_timeout;
+
+ /**
+ * Number of connection attempts for each server
+ */
+ public $_connect_attempts;
+
+ /**
+ * @var LoggerInterface
+ */
+ private $_logger;
+
+ // }}}
+ // }}}
+ // {{{ methods
+ // {{{ public functions
+ // {{{ memcached()
+
+ /**
+ * Memcache initializer
+ *
+ * @param array $args Associative array of settings
+ *
+ * @return mixed
+ */
+ public function __construct( $args ) {
+ $this->set_servers( isset( $args['servers'] ) ? $args['servers'] : array() );
+ $this->_debug = isset( $args['debug'] ) ? $args['debug'] : false;
+ $this->stats = array();
+ $this->_compress_threshold = isset( $args['compress_threshold'] ) ? $args['compress_threshold'] : 0;
+ $this->_persistent = isset( $args['persistent'] ) ? $args['persistent'] : false;
+ $this->_compress_enable = true;
+ $this->_have_zlib = function_exists( 'gzcompress' );
+
+ $this->_cache_sock = array();
+ $this->_host_dead = array();
+
+ $this->_timeout_seconds = 0;
+ $this->_timeout_microseconds = isset( $args['timeout'] ) ? $args['timeout'] : 500000;
+
+ $this->_connect_timeout = isset( $args['connect_timeout'] ) ? $args['connect_timeout'] : 0.1;
+ $this->_connect_attempts = 2;
+
+ $this->_logger = isset( $args['logger'] ) ? $args['logger'] : new NullLogger();
+ }
+
+ // }}}
+ // {{{ add()
+
+ /**
+ * Adds a key/value to the memcache server if one isn't already set with
+ * that key
+ *
+ * @param string $key Key to set with data
+ * @param mixed $val Value to store
+ * @param int $exp (optional) Expiration time. This can be a number of seconds
+ * to cache for (up to 30 days inclusive). Any timespans of 30 days + 1 second or
+ * longer must be the timestamp of the time at which the mapping should expire. It
+ * is safe to use timestamps in all cases, regardless of expiration
+ * eg: strtotime("+3 hour")
+ *
+ * @return bool
+ */
+ public function add( $key, $val, $exp = 0 ) {
+ return $this->_set( 'add', $key, $val, $exp );
+ }
+
+ // }}}
+ // {{{ decr()
+
+ /**
+ * Decrease a value stored on the memcache server
+ *
+ * @param string $key Key to decrease
+ * @param int $amt (optional) amount to decrease
+ *
+ * @return mixed False on failure, value on success
+ */
+ public function decr( $key, $amt = 1 ) {
+ return $this->_incrdecr( 'decr', $key, $amt );
+ }
+
+ // }}}
+ // {{{ delete()
+
+ /**
+ * Deletes a key from the server, optionally after $time
+ *
+ * @param string $key Key to delete
+ * @param int $time (optional) how long to wait before deleting
+ *
+ * @return bool True on success, false on failure
+ */
+ public function delete( $key, $time = 0 ) {
+ if ( !$this->_active ) {
+ return false;
+ }
+
+ $sock = $this->get_sock( $key );
+ if ( !is_resource( $sock ) ) {
+ return false;
+ }
+
+ $key = is_array( $key ) ? $key[1] : $key;
+
+ if ( isset( $this->stats['delete'] ) ) {
+ $this->stats['delete']++;
+ } else {
+ $this->stats['delete'] = 1;
+ }
+ $cmd = "delete $key $time\r\n";
+ if ( !$this->_fwrite( $sock, $cmd ) ) {
+ return false;
+ }
+ $res = $this->_fgets( $sock );
+
+ if ( $this->_debug ) {
+ $this->_debugprint( sprintf( "MemCache: delete %s (%s)", $key, $res ) );
+ }
+
+ if ( $res == "DELETED" || $res == "NOT_FOUND" ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Changes the TTL on a key from the server to $time
+ *
+ * @param string $key Key
+ * @param int $time TTL in seconds
+ *
+ * @return bool True on success, false on failure
+ */
+ public function touch( $key, $time = 0 ) {
+ if ( !$this->_active ) {
+ return false;
+ }
+
+ $sock = $this->get_sock( $key );
+ if ( !is_resource( $sock ) ) {
+ return false;
+ }
+
+ $key = is_array( $key ) ? $key[1] : $key;
+
+ if ( isset( $this->stats['touch'] ) ) {
+ $this->stats['touch']++;
+ } else {
+ $this->stats['touch'] = 1;
+ }
+ $cmd = "touch $key $time\r\n";
+ if ( !$this->_fwrite( $sock, $cmd ) ) {
+ return false;
+ }
+ $res = $this->_fgets( $sock );
+
+ if ( $this->_debug ) {
+ $this->_debugprint( sprintf( "MemCache: touch %s (%s)", $key, $res ) );
+ }
+
+ if ( $res == "TOUCHED" ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $key
+ * @param int $timeout
+ * @return bool
+ */
+ public function lock( $key, $timeout = 0 ) {
+ /* stub */
+ return true;
+ }
+
+ /**
+ * @param string $key
+ * @return bool
+ */
+ public function unlock( $key ) {
+ /* stub */
+ return true;
+ }
+
+ // }}}
+ // {{{ disconnect_all()
+
+ /**
+ * Disconnects all connected sockets
+ */
+ public function disconnect_all() {
+ foreach ( $this->_cache_sock as $sock ) {
+ fclose( $sock );
+ }
+
+ $this->_cache_sock = array();
+ }
+
+ // }}}
+ // {{{ enable_compress()
+
+ /**
+ * Enable / Disable compression
+ *
+ * @param bool $enable True to enable, false to disable
+ */
+ public function enable_compress( $enable ) {
+ $this->_compress_enable = $enable;
+ }
+
+ // }}}
+ // {{{ forget_dead_hosts()
+
+ /**
+ * Forget about all of the dead hosts
+ */
+ public function forget_dead_hosts() {
+ $this->_host_dead = array();
+ }
+
+ // }}}
+ // {{{ get()
+
+ /**
+ * Retrieves the value associated with the key from the memcache server
+ *
+ * @param array|string $key key to retrieve
+ * @param float $casToken [optional]
+ *
+ * @return mixed
+ */
+ public function get( $key, &$casToken = null ) {
+ if ( $this->_debug ) {
+ $this->_debugprint( "get($key)" );
+ }
+
+ if ( !is_array( $key ) && strval( $key ) === '' ) {
+ $this->_debugprint( "Skipping key which equals to an empty string" );
+ return false;
+ }
+
+ if ( !$this->_active ) {
+ return false;
+ }
+
+ $sock = $this->get_sock( $key );
+
+ if ( !is_resource( $sock ) ) {
+ return false;
+ }
+
+ $key = is_array( $key ) ? $key[1] : $key;
+ if ( isset( $this->stats['get'] ) ) {
+ $this->stats['get']++;
+ } else {
+ $this->stats['get'] = 1;
+ }
+
+ $cmd = "gets $key\r\n";
+ if ( !$this->_fwrite( $sock, $cmd ) ) {
+ return false;
+ }
+
+ $val = array();
+ $this->_load_items( $sock, $val, $casToken );
+
+ if ( $this->_debug ) {
+ foreach ( $val as $k => $v ) {
+ $this->_debugprint( sprintf( "MemCache: sock %s got %s", serialize( $sock ), $k ) );
+ }
+ }
+
+ $value = false;
+ if ( isset( $val[$key] ) ) {
+ $value = $val[$key];
+ }
+ return $value;
+ }
+
+ // }}}
+ // {{{ get_multi()
+
+ /**
+ * Get multiple keys from the server(s)
+ *
+ * @param array $keys Keys to retrieve
+ *
+ * @return array
+ */
+ public function get_multi( $keys ) {
+ if ( !$this->_active ) {
+ return array();
+ }
+
+ if ( isset( $this->stats['get_multi'] ) ) {
+ $this->stats['get_multi']++;
+ } else {
+ $this->stats['get_multi'] = 1;
+ }
+ $sock_keys = array();
+ $socks = array();
+ foreach ( $keys as $key ) {
+ $sock = $this->get_sock( $key );
+ if ( !is_resource( $sock ) ) {
+ continue;
+ }
+ $key = is_array( $key ) ? $key[1] : $key;
+ if ( !isset( $sock_keys[$sock] ) ) {
+ $sock_keys[intval( $sock )] = array();
+ $socks[] = $sock;
+ }
+ $sock_keys[intval( $sock )][] = $key;
+ }
+
+ $gather = array();
+ // Send out the requests
+ foreach ( $socks as $sock ) {
+ $cmd = 'gets';
+ foreach ( $sock_keys[intval( $sock )] as $key ) {
+ $cmd .= ' ' . $key;
+ }
+ $cmd .= "\r\n";
+
+ if ( $this->_fwrite( $sock, $cmd ) ) {
+ $gather[] = $sock;
+ }
+ }
+
+ // Parse responses
+ $val = array();
+ foreach ( $gather as $sock ) {
+ $this->_load_items( $sock, $val, $casToken );
+ }
+
+ if ( $this->_debug ) {
+ foreach ( $val as $k => $v ) {
+ $this->_debugprint( sprintf( "MemCache: got %s", $k ) );
+ }
+ }
+
+ return $val;
+ }
+
+ // }}}
+ // {{{ incr()
+
+ /**
+ * Increments $key (optionally) by $amt
+ *
+ * @param string $key Key to increment
+ * @param int $amt (optional) amount to increment
+ *
+ * @return int|null Null if the key does not exist yet (this does NOT
+ * create new mappings if the key does not exist). If the key does
+ * exist, this returns the new value for that key.
+ */
+ public function incr( $key, $amt = 1 ) {
+ return $this->_incrdecr( 'incr', $key, $amt );
+ }
+
+ // }}}
+ // {{{ replace()
+
+ /**
+ * Overwrites an existing value for key; only works if key is already set
+ *
+ * @param string $key Key to set value as
+ * @param mixed $value Value to store
+ * @param int $exp (optional) Expiration time. This can be a number of seconds
+ * to cache for (up to 30 days inclusive). Any timespans of 30 days + 1 second or
+ * longer must be the timestamp of the time at which the mapping should expire. It
+ * is safe to use timestamps in all cases, regardless of exipration
+ * eg: strtotime("+3 hour")
+ *
+ * @return bool
+ */
+ public function replace( $key, $value, $exp = 0 ) {
+ return $this->_set( 'replace', $key, $value, $exp );
+ }
+
+ // }}}
+ // {{{ run_command()
+
+ /**
+ * Passes through $cmd to the memcache server connected by $sock; returns
+ * output as an array (null array if no output)
+ *
+ * @param Resource $sock Socket to send command on
+ * @param string $cmd Command to run
+ *
+ * @return array Output array
+ */
+ public function run_command( $sock, $cmd ) {
+ if ( !is_resource( $sock ) ) {
+ return array();
+ }
+
+ if ( !$this->_fwrite( $sock, $cmd ) ) {
+ return array();
+ }
+
+ $ret = array();
+ while ( true ) {
+ $res = $this->_fgets( $sock );
+ $ret[] = $res;
+ if ( preg_match( '/^END/', $res ) ) {
+ break;
+ }
+ if ( strlen( $res ) == 0 ) {
+ break;
+ }
+ }
+ return $ret;
+ }
+
+ // }}}
+ // {{{ set()
+
+ /**
+ * Unconditionally sets a key to a given value in the memcache. Returns true
+ * if set successfully.
+ *
+ * @param string $key Key to set value as
+ * @param mixed $value Value to set
+ * @param int $exp (optional) Expiration time. This can be a number of seconds
+ * to cache for (up to 30 days inclusive). Any timespans of 30 days + 1 second or
+ * longer must be the timestamp of the time at which the mapping should expire. It
+ * is safe to use timestamps in all cases, regardless of exipration
+ * eg: strtotime("+3 hour")
+ *
+ * @return bool True on success
+ */
+ public function set( $key, $value, $exp = 0 ) {
+ return $this->_set( 'set', $key, $value, $exp );
+ }
+
+ // }}}
+ // {{{ cas()
+
+ /**
+ * Sets a key to a given value in the memcache if the current value still corresponds
+ * to a known, given value. Returns true if set successfully.
+ *
+ * @param float $casToken Current known value
+ * @param string $key Key to set value as
+ * @param mixed $value Value to set
+ * @param int $exp (optional) Expiration time. This can be a number of seconds
+ * to cache for (up to 30 days inclusive). Any timespans of 30 days + 1 second or
+ * longer must be the timestamp of the time at which the mapping should expire. It
+ * is safe to use timestamps in all cases, regardless of exipration
+ * eg: strtotime("+3 hour")
+ *
+ * @return bool True on success
+ */
+ public function cas( $casToken, $key, $value, $exp = 0 ) {
+ return $this->_set( 'cas', $key, $value, $exp, $casToken );
+ }
+
+ // }}}
+ // {{{ set_compress_threshold()
+
+ /**
+ * Set the compression threshold
+ *
+ * @param int $thresh Threshold to compress if larger than
+ */
+ public function set_compress_threshold( $thresh ) {
+ $this->_compress_threshold = $thresh;
+ }
+
+ // }}}
+ // {{{ set_debug()
+
+ /**
+ * Set the debug flag
+ *
+ * @see __construct()
+ * @param bool $dbg True for debugging, false otherwise
+ */
+ public function set_debug( $dbg ) {
+ $this->_debug = $dbg;
+ }
+
+ // }}}
+ // {{{ set_servers()
+
+ /**
+ * Set the server list to distribute key gets and puts between
+ *
+ * @see __construct()
+ * @param array $list Array of servers to connect to
+ */
+ public function set_servers( $list ) {
+ $this->_servers = $list;
+ $this->_active = count( $list );
+ $this->_buckets = null;
+ $this->_bucketcount = 0;
+
+ $this->_single_sock = null;
+ if ( $this->_active == 1 ) {
+ $this->_single_sock = $this->_servers[0];
+ }
+ }
+
+ /**
+ * Sets the timeout for new connections
+ *
+ * @param int $seconds Number of seconds
+ * @param int $microseconds Number of microseconds
+ */
+ public function set_timeout( $seconds, $microseconds ) {
+ $this->_timeout_seconds = $seconds;
+ $this->_timeout_microseconds = $microseconds;
+ }
+
+ // }}}
+ // }}}
+ // {{{ private methods
+ // {{{ _close_sock()
+
+ /**
+ * Close the specified socket
+ *
+ * @param string $sock Socket to close
+ *
+ * @access private
+ */
+ function _close_sock( $sock ) {
+ $host = array_search( $sock, $this->_cache_sock );
+ fclose( $this->_cache_sock[$host] );
+ unset( $this->_cache_sock[$host] );
+ }
+
+ // }}}
+ // {{{ _connect_sock()
+
+ /**
+ * Connects $sock to $host, timing out after $timeout
+ *
+ * @param int $sock Socket to connect
+ * @param string $host Host:IP to connect to
+ *
+ * @return bool
+ * @access private
+ */
+ function _connect_sock( &$sock, $host ) {
+ list( $ip, $port ) = preg_split( '/:(?=\d)/', $host );
+ $sock = false;
+ $timeout = $this->_connect_timeout;
+ $errno = $errstr = null;
+ for ( $i = 0; !$sock && $i < $this->_connect_attempts; $i++ ) {
+ MediaWiki\suppressWarnings();
+ if ( $this->_persistent == 1 ) {
+ $sock = pfsockopen( $ip, $port, $errno, $errstr, $timeout );
+ } else {
+ $sock = fsockopen( $ip, $port, $errno, $errstr, $timeout );
+ }
+ MediaWiki\restoreWarnings();
+ }
+ if ( !$sock ) {
+ $this->_error_log( "Error connecting to $host: $errstr" );
+ $this->_dead_host( $host );
+ return false;
+ }
+
+ // Initialise timeout
+ stream_set_timeout( $sock, $this->_timeout_seconds, $this->_timeout_microseconds );
+
+ // If the connection was persistent, flush the read buffer in case there
+ // was a previous incomplete request on this connection
+ if ( $this->_persistent ) {
+ $this->_flush_read_buffer( $sock );
+ }
+ return true;
+ }
+
+ // }}}
+ // {{{ _dead_sock()
+
+ /**
+ * Marks a host as dead until 30-40 seconds in the future
+ *
+ * @param string $sock Socket to mark as dead
+ *
+ * @access private
+ */
+ function _dead_sock( $sock ) {
+ $host = array_search( $sock, $this->_cache_sock );
+ $this->_dead_host( $host );
+ }
+
+ /**
+ * @param string $host
+ */
+ function _dead_host( $host ) {
+ $ip = explode( ':', $host )[0];
+ $this->_host_dead[$ip] = time() + 30 + intval( rand( 0, 10 ) );
+ $this->_host_dead[$host] = $this->_host_dead[$ip];
+ unset( $this->_cache_sock[$host] );
+ }
+
+ // }}}
+ // {{{ get_sock()
+
+ /**
+ * get_sock
+ *
+ * @param string $key Key to retrieve value for;
+ *
+ * @return Resource|bool Resource on success, false on failure
+ * @access private
+ */
+ function get_sock( $key ) {
+ if ( !$this->_active ) {
+ return false;
+ }
+
+ if ( $this->_single_sock !== null ) {
+ return $this->sock_to_host( $this->_single_sock );
+ }
+
+ $hv = is_array( $key ) ? intval( $key[0] ) : $this->_hashfunc( $key );
+ if ( $this->_buckets === null ) {
+ $bu = array();
+ foreach ( $this->_servers as $v ) {
+ if ( is_array( $v ) ) {
+ for ( $i = 0; $i < $v[1]; $i++ ) {
+ $bu[] = $v[0];
+ }
+ } else {
+ $bu[] = $v;
+ }
+ }
+ $this->_buckets = $bu;
+ $this->_bucketcount = count( $bu );
+ }
+
+ $realkey = is_array( $key ) ? $key[1] : $key;
+ for ( $tries = 0; $tries < 20; $tries++ ) {
+ $host = $this->_buckets[$hv % $this->_bucketcount];
+ $sock = $this->sock_to_host( $host );
+ if ( is_resource( $sock ) ) {
+ return $sock;
+ }
+ $hv = $this->_hashfunc( $hv . $realkey );
+ }
+
+ return false;
+ }
+
+ // }}}
+ // {{{ _hashfunc()
+
+ /**
+ * Creates a hash integer based on the $key
+ *
+ * @param string $key Key to hash
+ *
+ * @return int Hash value
+ * @access private
+ */
+ function _hashfunc( $key ) {
+ # Hash function must be in [0,0x7ffffff]
+ # We take the first 31 bits of the MD5 hash, which unlike the hash
+ # function used in a previous version of this client, works
+ return hexdec( substr( md5( $key ), 0, 8 ) ) & 0x7fffffff;
+ }
+
+ // }}}
+ // {{{ _incrdecr()
+
+ /**
+ * Perform increment/decriment on $key
+ *
+ * @param string $cmd Command to perform
+ * @param string|array $key Key to perform it on
+ * @param int $amt Amount to adjust
+ *
+ * @return int New value of $key
+ * @access private
+ */
+ function _incrdecr( $cmd, $key, $amt = 1 ) {
+ if ( !$this->_active ) {
+ return null;
+ }
+
+ $sock = $this->get_sock( $key );
+ if ( !is_resource( $sock ) ) {
+ return null;
+ }
+
+ $key = is_array( $key ) ? $key[1] : $key;
+ if ( isset( $this->stats[$cmd] ) ) {
+ $this->stats[$cmd]++;
+ } else {
+ $this->stats[$cmd] = 1;
+ }
+ if ( !$this->_fwrite( $sock, "$cmd $key $amt\r\n" ) ) {
+ return null;
+ }
+
+ $line = $this->_fgets( $sock );
+ $match = array();
+ if ( !preg_match( '/^(\d+)/', $line, $match ) ) {
+ return null;
+ }
+ return $match[1];
+ }
+
+ // }}}
+ // {{{ _load_items()
+
+ /**
+ * Load items into $ret from $sock
+ *
+ * @param Resource $sock Socket to read from
+ * @param array $ret returned values
+ * @param float $casToken [optional]
+ * @return bool True for success, false for failure
+ *
+ * @access private
+ */
+ function _load_items( $sock, &$ret, &$casToken = null ) {
+ $results = array();
+
+ while ( 1 ) {
+ $decl = $this->_fgets( $sock );
+
+ if ( $decl === false ) {
+ /*
+ * If nothing can be read, something is wrong because we know exactly when
+ * to stop reading (right after "END") and we return right after that.
+ */
+ return false;
+ } elseif ( preg_match( '/^VALUE (\S+) (\d+) (\d+) (\d+)$/', $decl, $match ) ) {
+ /*
+ * Read all data returned. This can be either one or multiple values.
+ * Save all that data (in an array) to be processed later: we'll first
+ * want to continue reading until "END" before doing anything else,
+ * to make sure that we don't leave our client in a state where it's
+ * output is not yet fully read.
+ */
+ $results[] = array(
+ $match[1], // rkey
+ $match[2], // flags
+ $match[3], // len
+ $match[4], // casToken
+ $this->_fread( $sock, $match[3] + 2 ), // data
+ );
+ } elseif ( $decl == "END" ) {
+ if ( count( $results ) == 0 ) {
+ return false;
+ }
+
+ /**
+ * All data has been read, time to process the data and build
+ * meaningful return values.
+ */
+ foreach ( $results as $vars ) {
+ list( $rkey, $flags, $len, $casToken, $data ) = $vars;
+
+ if ( $data === false || substr( $data, -2 ) !== "\r\n" ) {
+ $this->_handle_error( $sock,
+ 'line ending missing from data block from $1' );
+ return false;
+ }
+ $data = substr( $data, 0, -2 );
+ $ret[$rkey] = $data;
+
+ if ( $this->_have_zlib && $flags & self::COMPRESSED ) {
+ $ret[$rkey] = gzuncompress( $ret[$rkey] );
+ }
+
+ /*
+ * This unserialize is the exact reason that we only want to
+ * process data after having read until "END" (instead of doing
+ * this right away): "unserialize" can trigger outside code:
+ * in the event that $ret[$rkey] is a serialized object,
+ * unserializing it will trigger __wakeup() if present. If that
+ * function attempted to read from memcached (while we did not
+ * yet read "END"), these 2 calls would collide.
+ */
+ if ( $flags & self::SERIALIZED ) {
+ $ret[$rkey] = unserialize( $ret[$rkey] );
+ } elseif ( $flags & self::INTVAL ) {
+ $ret[$rkey] = intval( $ret[$rkey] );
+ }
+ }
+
+ return true;
+ } else {
+ $this->_handle_error( $sock, 'Error parsing response from $1' );
+ return false;
+ }
+ }
+ }
+
+ // }}}
+ // {{{ _set()
+
+ /**
+ * Performs the requested storage operation to the memcache server
+ *
+ * @param string $cmd Command to perform
+ * @param string $key Key to act on
+ * @param mixed $val What we need to store
+ * @param int $exp (optional) Expiration time. This can be a number of seconds
+ * to cache for (up to 30 days inclusive). Any timespans of 30 days + 1 second or
+ * longer must be the timestamp of the time at which the mapping should expire. It
+ * is safe to use timestamps in all cases, regardless of exipration
+ * eg: strtotime("+3 hour")
+ * @param float $casToken [optional]
+ *
+ * @return bool
+ * @access private
+ */
+ function _set( $cmd, $key, $val, $exp, $casToken = null ) {
+ if ( !$this->_active ) {
+ return false;
+ }
+
+ $sock = $this->get_sock( $key );
+ if ( !is_resource( $sock ) ) {
+ return false;
+ }
+
+ if ( isset( $this->stats[$cmd] ) ) {
+ $this->stats[$cmd]++;
+ } else {
+ $this->stats[$cmd] = 1;
+ }
+
+ $flags = 0;
+
+ if ( is_int( $val ) ) {
+ $flags |= self::INTVAL;
+ } elseif ( !is_scalar( $val ) ) {
+ $val = serialize( $val );
+ $flags |= self::SERIALIZED;
+ if ( $this->_debug ) {
+ $this->_debugprint( sprintf( "client: serializing data as it is not scalar" ) );
+ }
+ }
+
+ $len = strlen( $val );
+
+ if ( $this->_have_zlib && $this->_compress_enable
+ && $this->_compress_threshold && $len >= $this->_compress_threshold
+ ) {
+ $c_val = gzcompress( $val, 9 );
+ $c_len = strlen( $c_val );
+
+ if ( $c_len < $len * ( 1 - self::COMPRESSION_SAVINGS ) ) {
+ if ( $this->_debug ) {
+ $this->_debugprint( sprintf( "client: compressing data; was %d bytes is now %d bytes", $len, $c_len ) );
+ }
+ $val = $c_val;
+ $len = $c_len;
+ $flags |= self::COMPRESSED;
+ }
+ }
+
+ $command = "$cmd $key $flags $exp $len";
+ if ( $casToken ) {
+ $command .= " $casToken";
+ }
+
+ if ( !$this->_fwrite( $sock, "$command\r\n$val\r\n" ) ) {
+ return false;
+ }
+
+ $line = $this->_fgets( $sock );
+
+ if ( $this->_debug ) {
+ $this->_debugprint( sprintf( "%s %s (%s)", $cmd, $key, $line ) );
+ }
+ if ( $line === "STORED" ) {
+ return true;
+ } elseif ( $line === "NOT_STORED" && $cmd === "set" ) {
+ // "Not stored" is always used as the mcrouter response with AllAsyncRoute
+ return true;
+ }
+
+ return false;
+ }
+
+ // }}}
+ // {{{ sock_to_host()
+
+ /**
+ * Returns the socket for the host
+ *
+ * @param string $host Host:IP to get socket for
+ *
+ * @return Resource|bool IO Stream or false
+ * @access private
+ */
+ function sock_to_host( $host ) {
+ if ( isset( $this->_cache_sock[$host] ) ) {
+ return $this->_cache_sock[$host];
+ }
+
+ $sock = null;
+ $now = time();
+ list( $ip, /* $port */) = explode( ':', $host );
+ if ( isset( $this->_host_dead[$host] ) && $this->_host_dead[$host] > $now ||
+ isset( $this->_host_dead[$ip] ) && $this->_host_dead[$ip] > $now
+ ) {
+ return null;
+ }
+
+ if ( !$this->_connect_sock( $sock, $host ) ) {
+ return null;
+ }
+
+ // Do not buffer writes
+ stream_set_write_buffer( $sock, 0 );
+
+ $this->_cache_sock[$host] = $sock;
+
+ return $this->_cache_sock[$host];
+ }
+
+ /**
+ * @param string $text
+ */
+ function _debugprint( $text ) {
+ $this->_logger->debug( $text );
+ }
+
+ /**
+ * @param string $text
+ */
+ function _error_log( $text ) {
+ $this->_logger->error( "Memcached error: $text" );
+ }
+
+ /**
+ * Write to a stream. If there is an error, mark the socket dead.
+ *
+ * @param Resource $sock The socket
+ * @param string $buf The string to write
+ * @return bool True on success, false on failure
+ */
+ function _fwrite( $sock, $buf ) {
+ $bytesWritten = 0;
+ $bufSize = strlen( $buf );
+ while ( $bytesWritten < $bufSize ) {
+ $result = fwrite( $sock, $buf );
+ $data = stream_get_meta_data( $sock );
+ if ( $data['timed_out'] ) {
+ $this->_handle_error( $sock, 'timeout writing to $1' );
+ return false;
+ }
+ // Contrary to the documentation, fwrite() returns zero on error in PHP 5.3.
+ if ( $result === false || $result === 0 ) {
+ $this->_handle_error( $sock, 'error writing to $1' );
+ return false;
+ }
+ $bytesWritten += $result;
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle an I/O error. Mark the socket dead and log an error.
+ *
+ * @param Resource $sock
+ * @param string $msg
+ */
+ function _handle_error( $sock, $msg ) {
+ $peer = stream_socket_get_name( $sock, true /** remote **/ );
+ if ( strval( $peer ) === '' ) {
+ $peer = array_search( $sock, $this->_cache_sock );
+ if ( $peer === false ) {
+ $peer = '[unknown host]';
+ }
+ }
+ $msg = str_replace( '$1', $peer, $msg );
+ $this->_error_log( "$msg" );
+ $this->_dead_sock( $sock );
+ }
+
+ /**
+ * Read the specified number of bytes from a stream. If there is an error,
+ * mark the socket dead.
+ *
+ * @param Resource $sock The socket
+ * @param int $len The number of bytes to read
+ * @return string|bool The string on success, false on failure.
+ */
+ function _fread( $sock, $len ) {
+ $buf = '';
+ while ( $len > 0 ) {
+ $result = fread( $sock, $len );
+ $data = stream_get_meta_data( $sock );
+ if ( $data['timed_out'] ) {
+ $this->_handle_error( $sock, 'timeout reading from $1' );
+ return false;
+ }
+ if ( $result === false ) {
+ $this->_handle_error( $sock, 'error reading buffer from $1' );
+ return false;
+ }
+ if ( $result === '' ) {
+ // This will happen if the remote end of the socket is shut down
+ $this->_handle_error( $sock, 'unexpected end of file reading from $1' );
+ return false;
+ }
+ $len -= strlen( $result );
+ $buf .= $result;
+ }
+ return $buf;
+ }
+
+ /**
+ * Read a line from a stream. If there is an error, mark the socket dead.
+ * The \r\n line ending is stripped from the response.
+ *
+ * @param Resource $sock The socket
+ * @return string|bool The string on success, false on failure
+ */
+ function _fgets( $sock ) {
+ $result = fgets( $sock );
+ // fgets() may return a partial line if there is a select timeout after
+ // a successful recv(), so we have to check for a timeout even if we
+ // got a string response.
+ $data = stream_get_meta_data( $sock );
+ if ( $data['timed_out'] ) {
+ $this->_handle_error( $sock, 'timeout reading line from $1' );
+ return false;
+ }
+ if ( $result === false ) {
+ $this->_handle_error( $sock, 'error reading line from $1' );
+ return false;
+ }
+ if ( substr( $result, -2 ) === "\r\n" ) {
+ $result = substr( $result, 0, -2 );
+ } elseif ( substr( $result, -1 ) === "\n" ) {
+ $result = substr( $result, 0, -1 );
+ } else {
+ $this->_handle_error( $sock, 'line ending missing in response from $1' );
+ return false;
+ }
+ return $result;
+ }
+
+ /**
+ * Flush the read buffer of a stream
+ * @param Resource $f
+ */
+ function _flush_read_buffer( $f ) {
+ if ( !is_resource( $f ) ) {
+ return;
+ }
+ $r = array( $f );
+ $w = null;
+ $e = null;
+ $n = stream_select( $r, $w, $e, 0, 0 );
+ while ( $n == 1 && !feof( $f ) ) {
+ fread( $f, 1024 );
+ $r = array( $f );
+ $w = null;
+ $e = null;
+ $n = stream_select( $r, $w, $e, 0, 0 );
+ }
+ }
+
+ // }}}
+ // }}}
+ // }}}
+}
+
+// }}}
diff --git a/www/wiki/includes/libs/objectcache/MemcachedPeclBagOStuff.php b/www/wiki/includes/libs/objectcache/MemcachedPeclBagOStuff.php
new file mode 100644
index 00000000..fe31c258
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/MemcachedPeclBagOStuff.php
@@ -0,0 +1,270 @@
+<?php
+/**
+ * Object caching using memcached.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * A wrapper class for the PECL memcached client
+ *
+ * @ingroup Cache
+ */
+class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
+
+ /**
+ * Available parameters are:
+ * - servers: The list of IP:port combinations holding the memcached servers.
+ * - persistent: Whether to use a persistent connection
+ * - compress_threshold: The minimum size an object must be before it is compressed
+ * - timeout: The read timeout in microseconds
+ * - connect_timeout: The connect timeout in seconds
+ * - retry_timeout: Time in seconds to wait before retrying a failed connect attempt
+ * - server_failure_limit: Limit for server connect failures before it is removed
+ * - serializer: May be either "php" or "igbinary". Igbinary produces more compact
+ * values, but serialization is much slower unless the php.ini option
+ * igbinary.compact_strings is off.
+ * - use_binary_protocol Whether to enable the binary protocol (default is ASCII) (boolean)
+ * @param array $params
+ * @throws InvalidArgumentException
+ */
+ function __construct( $params ) {
+ parent::__construct( $params );
+ $params = $this->applyDefaultParams( $params );
+
+ if ( $params['persistent'] ) {
+ // The pool ID must be unique to the server/option combination.
+ // The Memcached object is essentially shared for each pool ID.
+ // We can only reuse a pool ID if we keep the config consistent.
+ $this->client = new Memcached( md5( serialize( $params ) ) );
+ if ( count( $this->client->getServerList() ) ) {
+ $this->logger->debug( __METHOD__ . ": persistent Memcached object already loaded." );
+ return; // already initialized; don't add duplicate servers
+ }
+ } else {
+ $this->client = new Memcached;
+ }
+
+ if ( $params['use_binary_protocol'] ) {
+ $this->client->setOption( Memcached::OPT_BINARY_PROTOCOL, true );
+ }
+
+ if ( isset( $params['retry_timeout'] ) ) {
+ $this->client->setOption( Memcached::OPT_RETRY_TIMEOUT, $params['retry_timeout'] );
+ }
+
+ if ( isset( $params['server_failure_limit'] ) ) {
+ $this->client->setOption( Memcached::OPT_SERVER_FAILURE_LIMIT, $params['server_failure_limit'] );
+ }
+
+ // The compression threshold is an undocumented php.ini option for some
+ // reason. There's probably not much harm in setting it globally, for
+ // compatibility with the settings for the PHP client.
+ ini_set( 'memcached.compression_threshold', $params['compress_threshold'] );
+
+ // Set timeouts
+ $this->client->setOption( Memcached::OPT_CONNECT_TIMEOUT, $params['connect_timeout'] * 1000 );
+ $this->client->setOption( Memcached::OPT_SEND_TIMEOUT, $params['timeout'] );
+ $this->client->setOption( Memcached::OPT_RECV_TIMEOUT, $params['timeout'] );
+ $this->client->setOption( Memcached::OPT_POLL_TIMEOUT, $params['timeout'] / 1000 );
+
+ // Set libketama mode since it's recommended by the documentation and
+ // is as good as any. There's no way to configure libmemcached to use
+ // hashes identical to the ones currently in use by the PHP client, and
+ // even implementing one of the libmemcached hashes in pure PHP for
+ // forwards compatibility would require MemcachedClient::get_sock() to be
+ // rewritten.
+ $this->client->setOption( Memcached::OPT_LIBKETAMA_COMPATIBLE, true );
+
+ // Set the serializer
+ switch ( $params['serializer'] ) {
+ case 'php':
+ $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP );
+ break;
+ case 'igbinary':
+ if ( !Memcached::HAVE_IGBINARY ) {
+ throw new InvalidArgumentException(
+ __CLASS__ . ': the igbinary extension is not available ' .
+ 'but igbinary serialization was requested.'
+ );
+ }
+ $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY );
+ break;
+ default:
+ throw new InvalidArgumentException(
+ __CLASS__ . ': invalid value for serializer parameter'
+ );
+ }
+ $servers = [];
+ foreach ( $params['servers'] as $host ) {
+ if ( preg_match( '/^\[(.+)\]:(\d+)$/', $host, $m ) ) {
+ $servers[] = [ $m[1], (int)$m[2] ]; // (ip, port)
+ } elseif ( preg_match( '/^([^:]+):(\d+)$/', $host, $m ) ) {
+ $servers[] = [ $m[1], (int)$m[2] ]; // (ip or path, port)
+ } else {
+ $servers[] = [ $host, false ]; // (ip or path, port)
+ }
+ }
+ $this->client->addServers( $servers );
+ }
+
+ protected function applyDefaultParams( $params ) {
+ $params = parent::applyDefaultParams( $params );
+
+ if ( !isset( $params['use_binary_protocol'] ) ) {
+ $params['use_binary_protocol'] = false;
+ }
+
+ if ( !isset( $params['serializer'] ) ) {
+ $params['serializer'] = 'php';
+ }
+
+ return $params;
+ }
+
+ protected function getWithToken( $key, &$casToken, $flags = 0 ) {
+ $this->debugLog( "get($key)" );
+ if ( defined( Memcached::class . '::GET_EXTENDED' ) ) { // v3.0.0
+ $flags = Memcached::GET_EXTENDED;
+ $res = $this->client->get( $this->validateKeyEncoding( $key ), null, $flags );
+ if ( is_array( $res ) ) {
+ $result = $res['value'];
+ $casToken = $res['cas'];
+ } else {
+ $result = false;
+ $casToken = null;
+ }
+ } else {
+ $result = $this->client->get( $this->validateKeyEncoding( $key ), null, $casToken );
+ }
+ $result = $this->checkResult( $key, $result );
+ return $result;
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ $this->debugLog( "set($key)" );
+ $result = parent::set( $key, $value, $exptime );
+ if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTSTORED ) {
+ // "Not stored" is always used as the mcrouter response with AllAsyncRoute
+ return true;
+ }
+ return $this->checkResult( $key, $result );
+ }
+
+ protected function cas( $casToken, $key, $value, $exptime = 0 ) {
+ $this->debugLog( "cas($key)" );
+ return $this->checkResult( $key, parent::cas( $casToken, $key, $value, $exptime ) );
+ }
+
+ public function delete( $key ) {
+ $this->debugLog( "delete($key)" );
+ $result = parent::delete( $key );
+ if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTFOUND ) {
+ // "Not found" is counted as success in our interface
+ return true;
+ }
+ return $this->checkResult( $key, $result );
+ }
+
+ public function add( $key, $value, $exptime = 0 ) {
+ $this->debugLog( "add($key)" );
+ return $this->checkResult( $key, parent::add( $key, $value, $exptime ) );
+ }
+
+ public function incr( $key, $value = 1 ) {
+ $this->debugLog( "incr($key)" );
+ $result = $this->client->increment( $key, $value );
+ return $this->checkResult( $key, $result );
+ }
+
+ public function decr( $key, $value = 1 ) {
+ $this->debugLog( "decr($key)" );
+ $result = $this->client->decrement( $key, $value );
+ return $this->checkResult( $key, $result );
+ }
+
+ /**
+ * Check the return value from a client method call and take any necessary
+ * action. Returns the value that the wrapper function should return. At
+ * present, the return value is always the same as the return value from
+ * the client, but some day we might find a case where it should be
+ * different.
+ *
+ * @param string $key The key used by the caller, or false if there wasn't one.
+ * @param mixed $result The return value
+ * @return mixed
+ */
+ protected function checkResult( $key, $result ) {
+ if ( $result !== false ) {
+ return $result;
+ }
+ switch ( $this->client->getResultCode() ) {
+ case Memcached::RES_SUCCESS:
+ break;
+ case Memcached::RES_DATA_EXISTS:
+ case Memcached::RES_NOTSTORED:
+ case Memcached::RES_NOTFOUND:
+ $this->debugLog( "result: " . $this->client->getResultMessage() );
+ break;
+ default:
+ $msg = $this->client->getResultMessage();
+ $logCtx = [];
+ if ( $key !== false ) {
+ $server = $this->client->getServerByKey( $key );
+ $logCtx['memcached-server'] = "{$server['host']}:{$server['port']}";
+ $logCtx['memcached-key'] = $key;
+ $msg = "Memcached error for key \"{memcached-key}\" on server \"{memcached-server}\": $msg";
+ } else {
+ $msg = "Memcached error: $msg";
+ }
+ $this->logger->error( $msg, $logCtx );
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
+ }
+ return $result;
+ }
+
+ public function getMulti( array $keys, $flags = 0 ) {
+ $this->debugLog( 'getMulti(' . implode( ', ', $keys ) . ')' );
+ foreach ( $keys as $key ) {
+ $this->validateKeyEncoding( $key );
+ }
+ $result = $this->client->getMulti( $keys ) ?: [];
+ return $this->checkResult( false, $result );
+ }
+
+ /**
+ * @param array $data
+ * @param int $exptime
+ * @return bool
+ */
+ public function setMulti( array $data, $exptime = 0 ) {
+ $this->debugLog( 'setMulti(' . implode( ', ', array_keys( $data ) ) . ')' );
+ foreach ( array_keys( $data ) as $key ) {
+ $this->validateKeyEncoding( $key );
+ }
+ $result = $this->client->setMulti( $data, $this->fixExpiry( $exptime ) );
+ return $this->checkResult( false, $result );
+ }
+
+ public function changeTTL( $key, $expiry = 0 ) {
+ $this->debugLog( "touch($key)" );
+ $result = $this->client->touch( $key, $expiry );
+ return $this->checkResult( $key, $result );
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/MemcachedPhpBagOStuff.php b/www/wiki/includes/libs/objectcache/MemcachedPhpBagOStuff.php
new file mode 100644
index 00000000..971406cf
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/MemcachedPhpBagOStuff.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * Object caching using memcached.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * A wrapper class for the pure-PHP memcached client, exposing a BagOStuff interface.
+ *
+ * @ingroup Cache
+ */
+class MemcachedPhpBagOStuff extends MemcachedBagOStuff {
+ /**
+ * Available parameters are:
+ * - servers: The list of IP:port combinations holding the memcached servers.
+ * - debug: Whether to set the debug flag in the underlying client.
+ * - persistent: Whether to use a persistent connection
+ * - compress_threshold: The minimum size an object must be before it is compressed
+ * - timeout: The read timeout in microseconds
+ * - connect_timeout: The connect timeout in seconds
+ *
+ * @param array $params
+ */
+ function __construct( $params ) {
+ parent::__construct( $params );
+ $params = $this->applyDefaultParams( $params );
+
+ $this->client = new MemcachedClient( $params );
+ $this->client->set_servers( $params['servers'] );
+ $this->client->set_debug( $params['debug'] );
+ }
+
+ public function setDebug( $debug ) {
+ $this->client->set_debug( $debug );
+ }
+
+ public function getMulti( array $keys, $flags = 0 ) {
+ foreach ( $keys as $key ) {
+ $this->validateKeyEncoding( $key );
+ }
+
+ return $this->client->get_multi( $keys );
+ }
+
+ public function incr( $key, $value = 1 ) {
+ $this->validateKeyEncoding( $key );
+
+ $ret = $this->client->incr( $key, $value );
+ return $ret !== null ? $ret : false;
+ }
+
+ public function decr( $key, $value = 1 ) {
+ $this->validateKeyEncoding( $key );
+
+ $ret = $this->client->decr( $key, $value );
+ return $ret !== null ? $ret : false;
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/MultiWriteBagOStuff.php b/www/wiki/includes/libs/objectcache/MultiWriteBagOStuff.php
new file mode 100644
index 00000000..200ab796
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/MultiWriteBagOStuff.php
@@ -0,0 +1,243 @@
+<?php
+/**
+ * Wrapper for object caching in different caches.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * A cache class that replicates all writes to multiple child caches. Reads
+ * are implemented by reading from the caches in the order they are given in
+ * the configuration until a cache gives a positive result.
+ *
+ * @ingroup Cache
+ */
+class MultiWriteBagOStuff extends BagOStuff {
+ /** @var BagOStuff[] */
+ protected $caches;
+ /** @var bool Use async secondary writes */
+ protected $asyncWrites = false;
+
+ /** Idiom for "write to all backends" */
+ const ALL = INF;
+
+ const UPGRADE_TTL = 3600; // TTL when a key is copied to a higher cache tier
+
+ /**
+ * $params include:
+ * - caches: A numbered array of either ObjectFactory::getObjectFromSpec
+ * arrays yeilding BagOStuff objects or direct BagOStuff objects.
+ * If using the former, the 'args' field *must* be set.
+ * The first cache is the primary one, being the first to
+ * be read in the fallback chain. Writes happen to all stores
+ * in the order they are defined. However, lock()/unlock() calls
+ * only use the primary store.
+ * - replication: Either 'sync' or 'async'. This controls whether writes
+ * to secondary stores are deferred when possible. Async writes
+ * require setting 'asyncHandler'. HHVM register_postsend_function() function.
+ * Async writes can increase the chance of some race conditions
+ * or cause keys to expire seconds later than expected. It is
+ * safe to use for modules when cached values: are immutable,
+ * invalidation uses logical TTLs, invalidation uses etag/timestamp
+ * validation against the DB, or merge() is used to handle races.
+ * @param array $params
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( empty( $params['caches'] ) || !is_array( $params['caches'] ) ) {
+ throw new InvalidArgumentException(
+ __METHOD__ . ': "caches" parameter must be an array of caches'
+ );
+ }
+
+ $this->caches = [];
+ foreach ( $params['caches'] as $cacheInfo ) {
+ if ( $cacheInfo instanceof BagOStuff ) {
+ $this->caches[] = $cacheInfo;
+ } else {
+ if ( !isset( $cacheInfo['args'] ) ) {
+ // B/C for when $cacheInfo was for ObjectCache::newFromParams().
+ // Callers intenting this to be for ObjectFactory::getObjectFromSpec
+ // should have set "args" per the docs above. Doings so avoids extra
+ // (likely harmless) params (factory/class/calls) ending up in "args".
+ $cacheInfo['args'] = [ $cacheInfo ];
+ }
+ $this->caches[] = ObjectFactory::getObjectFromSpec( $cacheInfo );
+ }
+ }
+ $this->mergeFlagMaps( $this->caches );
+
+ $this->asyncWrites = (
+ isset( $params['replication'] ) &&
+ $params['replication'] === 'async' &&
+ is_callable( $this->asyncHandler )
+ );
+ }
+
+ public function setDebug( $debug ) {
+ foreach ( $this->caches as $cache ) {
+ $cache->setDebug( $debug );
+ }
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ if ( ( $flags & self::READ_LATEST ) == self::READ_LATEST ) {
+ // If the latest write was a delete(), we do NOT want to fallback
+ // to the other tiers and possibly see the old value. Also, this
+ // is used by mergeViaLock(), which only needs to hit the primary.
+ return $this->caches[0]->get( $key, $flags );
+ }
+
+ $misses = 0; // number backends checked
+ $value = false;
+ foreach ( $this->caches as $cache ) {
+ $value = $cache->get( $key, $flags );
+ if ( $value !== false ) {
+ break;
+ }
+ ++$misses;
+ }
+
+ if ( $value !== false
+ && $misses > 0
+ && ( $flags & self::READ_VERIFIED ) == self::READ_VERIFIED
+ ) {
+ $this->doWrite( $misses, $this->asyncWrites, 'set', $key, $value, self::UPGRADE_TTL );
+ }
+
+ return $value;
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ $asyncWrites = ( ( $flags & self::WRITE_SYNC ) == self::WRITE_SYNC )
+ ? false
+ : $this->asyncWrites;
+
+ return $this->doWrite( self::ALL, $asyncWrites, 'set', $key, $value, $exptime );
+ }
+
+ public function delete( $key ) {
+ return $this->doWrite( self::ALL, $this->asyncWrites, 'delete', $key );
+ }
+
+ public function add( $key, $value, $exptime = 0 ) {
+ return $this->doWrite( self::ALL, $this->asyncWrites, 'add', $key, $value, $exptime );
+ }
+
+ public function incr( $key, $value = 1 ) {
+ return $this->doWrite( self::ALL, $this->asyncWrites, 'incr', $key, $value );
+ }
+
+ public function decr( $key, $value = 1 ) {
+ return $this->doWrite( self::ALL, $this->asyncWrites, 'decr', $key, $value );
+ }
+
+ public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
+ // Only need to lock the first cache; also avoids deadlocks
+ return $this->caches[0]->lock( $key, $timeout, $expiry, $rclass );
+ }
+
+ public function unlock( $key ) {
+ // Only the first cache is locked
+ return $this->caches[0]->unlock( $key );
+ }
+
+ public function getLastError() {
+ return $this->caches[0]->getLastError();
+ }
+
+ public function clearLastError() {
+ $this->caches[0]->clearLastError();
+ }
+
+ /**
+ * Apply a write method to the first $count backing caches
+ *
+ * @param int $count
+ * @param bool $asyncWrites
+ * @param string $method
+ * @param mixed $args,...
+ * @return bool
+ */
+ protected function doWrite( $count, $asyncWrites, $method /*, ... */ ) {
+ $ret = true;
+ $args = array_slice( func_get_args(), 3 );
+
+ if ( $count > 1 && $asyncWrites ) {
+ // Deep-clone $args to prevent misbehavior when something writes an
+ // object to the BagOStuff then modifies it afterwards, e.g. T168040.
+ $args = unserialize( serialize( $args ) );
+ }
+
+ foreach ( $this->caches as $i => $cache ) {
+ if ( $i >= $count ) {
+ break; // ignore the lower tiers
+ }
+
+ if ( $i == 0 || !$asyncWrites ) {
+ // First store or in sync mode: write now and get result
+ if ( !call_user_func_array( [ $cache, $method ], $args ) ) {
+ $ret = false;
+ }
+ } else {
+ // Secondary write in async mode: do not block this HTTP request
+ $logger = $this->logger;
+ call_user_func(
+ $this->asyncHandler,
+ function () use ( $cache, $method, $args, $logger ) {
+ if ( !call_user_func_array( [ $cache, $method ], $args ) ) {
+ $logger->warning( "Async $method op failed" );
+ }
+ }
+ );
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Delete objects expiring before a certain date.
+ *
+ * Succeed if any of the child caches succeed.
+ * @param string $date
+ * @param bool|callable $progressCallback
+ * @return bool
+ */
+ public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) {
+ $ret = false;
+ foreach ( $this->caches as $cache ) {
+ if ( $cache->deleteObjectsExpiringBefore( $date, $progressCallback ) ) {
+ $ret = true;
+ }
+ }
+
+ return $ret;
+ }
+
+ public function makeKey() {
+ return call_user_func_array( [ $this->caches[0], __FUNCTION__ ], func_get_args() );
+ }
+
+ public function makeGlobalKey() {
+ return call_user_func_array( [ $this->caches[0], __FUNCTION__ ], func_get_args() );
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/RESTBagOStuff.php b/www/wiki/includes/libs/objectcache/RESTBagOStuff.php
new file mode 100644
index 00000000..d3aa9f5c
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/RESTBagOStuff.php
@@ -0,0 +1,137 @@
+<?php
+
+/**
+ * Interface to key-value storage behind an HTTP server.
+ *
+ * Uses URL of the form "baseURL/{KEY}" to store, fetch, and delete values.
+ *
+ * E.g., when base URL is `/v1/sessions/`, then the store would do:
+ *
+ * `PUT /v1/sessions/12345758`
+ *
+ * and fetch would do:
+ *
+ * `GET /v1/sessions/12345758`
+ *
+ * delete would do:
+ *
+ * `DELETE /v1/sessions/12345758`
+ *
+ * Configure with:
+ *
+ * @code
+ * $wgObjectCaches['sessions'] = array(
+ * 'class' => 'RESTBagOStuff',
+ * 'url' => 'http://localhost:7231/wikimedia.org/v1/sessions/'
+ * );
+ * @endcode
+ */
+class RESTBagOStuff extends BagOStuff {
+
+ /**
+ * @var MultiHttpClient
+ */
+ private $client;
+
+ /**
+ * REST URL to use for storage.
+ * @var string
+ */
+ private $url;
+
+ public function __construct( $params ) {
+ if ( empty( $params['url'] ) ) {
+ throw new InvalidArgumentException( 'URL parameter is required' );
+ }
+ parent::__construct( $params );
+ if ( empty( $params['client'] ) ) {
+ $this->client = new MultiHttpClient( [] );
+ } else {
+ $this->client = $params['client'];
+ }
+ // Make sure URL ends with /
+ $this->url = rtrim( $params['url'], '/' ) . '/';
+ // Default config, R+W > N; no locks on reads though; writes go straight to state-machine
+ $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_QC;
+ }
+
+ /**
+ * @param string $key
+ * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
+ * @return mixed Returns false on failure and if the item does not exist
+ */
+ protected function doGet( $key, $flags = 0 ) {
+ $req = [
+ 'method' => 'GET',
+ 'url' => $this->url . rawurlencode( $key ),
+ ];
+
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->client->run( $req );
+ if ( $rcode === 200 ) {
+ if ( is_string( $rbody ) ) {
+ return unserialize( $rbody );
+ }
+ return false;
+ }
+ if ( $rcode === 0 || ( $rcode >= 400 && $rcode != 404 ) ) {
+ return $this->handleError( "Failed to fetch $key", $rcode, $rerr );
+ }
+ return false;
+ }
+
+ /**
+ * Handle storage error
+ * @param string $msg Error message
+ * @param int $rcode Error code from client
+ * @param string $rerr Error message from client
+ * @return false
+ */
+ protected function handleError( $msg, $rcode, $rerr ) {
+ $this->logger->error( "$msg : ({code}) {error}", [
+ 'code' => $rcode,
+ 'error' => $rerr
+ ] );
+ $this->setLastError( $rcode === 0 ? self::ERR_UNREACHABLE : self::ERR_UNEXPECTED );
+ return false;
+ }
+
+ /**
+ * Set an item
+ *
+ * @param string $key
+ * @param mixed $value
+ * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+ * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+ * @return bool Success
+ */
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ $req = [
+ 'method' => 'PUT',
+ 'url' => $this->url . rawurlencode( $key ),
+ 'body' => serialize( $value )
+ ];
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->client->run( $req );
+ if ( $rcode === 200 || $rcode === 201 ) {
+ return true;
+ }
+ return $this->handleError( "Failed to store $key", $rcode, $rerr );
+ }
+
+ /**
+ * Delete an item.
+ *
+ * @param string $key
+ * @return bool True if the item was deleted or not found, false on failure
+ */
+ public function delete( $key ) {
+ $req = [
+ 'method' => 'DELETE',
+ 'url' => $this->url . rawurlencode( $key ),
+ ];
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->client->run( $req );
+ if ( $rcode === 200 || $rcode === 204 || $rcode === 205 ) {
+ return true;
+ }
+ return $this->handleError( "Failed to delete $key", $rcode, $rerr );
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/RedisBagOStuff.php b/www/wiki/includes/libs/objectcache/RedisBagOStuff.php
new file mode 100644
index 00000000..583ec377
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/RedisBagOStuff.php
@@ -0,0 +1,433 @@
+<?php
+/**
+ * Object caching using Redis (http://redis.io/).
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Redis-based caching module for redis server >= 2.6.12
+ *
+ * @note: avoid use of Redis::MULTI transactions for twemproxy support
+ */
+class RedisBagOStuff extends BagOStuff {
+ /** @var RedisConnectionPool */
+ protected $redisPool;
+ /** @var array List of server names */
+ protected $servers;
+ /** @var array Map of (tag => server name) */
+ protected $serverTagMap;
+ /** @var bool */
+ protected $automaticFailover;
+
+ /**
+ * Construct a RedisBagOStuff object. Parameters are:
+ *
+ * - servers: An array of server names. A server name may be a hostname,
+ * a hostname/port combination or the absolute path of a UNIX socket.
+ * If a hostname is specified but no port, the standard port number
+ * 6379 will be used. Arrays keys can be used to specify the tag to
+ * hash on in place of the host/port. Required.
+ *
+ * - connectTimeout: The timeout for new connections, in seconds. Optional,
+ * default is 1 second.
+ *
+ * - persistent: Set this to true to allow connections to persist across
+ * multiple web requests. False by default.
+ *
+ * - password: The authentication password, will be sent to Redis in
+ * clear text. Optional, if it is unspecified, no AUTH command will be
+ * sent.
+ *
+ * - automaticFailover: If this is false, then each key will be mapped to
+ * a single server, and if that server is down, any requests for that key
+ * will fail. If this is true, a connection failure will cause the client
+ * to immediately try the next server in the list (as determined by a
+ * consistent hashing algorithm). True by default. This has the
+ * potential to create consistency issues if a server is slow enough to
+ * flap, for example if it is in swap death.
+ * @param array $params
+ */
+ function __construct( $params ) {
+ parent::__construct( $params );
+ $redisConf = [ 'serializer' => 'none' ]; // manage that in this class
+ foreach ( [ 'connectTimeout', 'persistent', 'password' ] as $opt ) {
+ if ( isset( $params[$opt] ) ) {
+ $redisConf[$opt] = $params[$opt];
+ }
+ }
+ $this->redisPool = RedisConnectionPool::singleton( $redisConf );
+
+ $this->servers = $params['servers'];
+ foreach ( $this->servers as $key => $server ) {
+ $this->serverTagMap[is_int( $key ) ? $server : $key] = $server;
+ }
+
+ if ( isset( $params['automaticFailover'] ) ) {
+ $this->automaticFailover = $params['automaticFailover'];
+ } else {
+ $this->automaticFailover = true;
+ }
+
+ $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_NONE;
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ try {
+ $value = $conn->get( $key );
+ $result = $this->unserialize( $value );
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'get', $key, $server, $result );
+ return $result;
+ }
+
+ public function set( $key, $value, $expiry = 0, $flags = 0 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ $expiry = $this->convertToRelative( $expiry );
+ try {
+ if ( $expiry ) {
+ $result = $conn->setex( $key, $expiry, $this->serialize( $value ) );
+ } else {
+ // No expiry, that is very different from zero expiry in Redis
+ $result = $conn->set( $key, $this->serialize( $value ) );
+ }
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'set', $key, $server, $result );
+ return $result;
+ }
+
+ public function delete( $key ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ try {
+ $conn->delete( $key );
+ // Return true even if the key didn't exist
+ $result = true;
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'delete', $key, $server, $result );
+ return $result;
+ }
+
+ public function getMulti( array $keys, $flags = 0 ) {
+ $batches = [];
+ $conns = [];
+ foreach ( $keys as $key ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ continue;
+ }
+ $conns[$server] = $conn;
+ $batches[$server][] = $key;
+ }
+ $result = [];
+ foreach ( $batches as $server => $batchKeys ) {
+ $conn = $conns[$server];
+ try {
+ $conn->multi( Redis::PIPELINE );
+ foreach ( $batchKeys as $key ) {
+ $conn->get( $key );
+ }
+ $batchResult = $conn->exec();
+ if ( $batchResult === false ) {
+ $this->debug( "multi request to $server failed" );
+ continue;
+ }
+ foreach ( $batchResult as $i => $value ) {
+ if ( $value !== false ) {
+ $result[$batchKeys[$i]] = $this->unserialize( $value );
+ }
+ }
+ } catch ( RedisException $e ) {
+ $this->handleException( $conn, $e );
+ }
+ }
+
+ $this->debug( "getMulti for " . count( $keys ) . " keys " .
+ "returned " . count( $result ) . " results" );
+ return $result;
+ }
+
+ /**
+ * @param array $data
+ * @param int $expiry
+ * @return bool
+ */
+ public function setMulti( array $data, $expiry = 0 ) {
+ $batches = [];
+ $conns = [];
+ foreach ( $data as $key => $value ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ continue;
+ }
+ $conns[$server] = $conn;
+ $batches[$server][] = $key;
+ }
+
+ $expiry = $this->convertToRelative( $expiry );
+ $result = true;
+ foreach ( $batches as $server => $batchKeys ) {
+ $conn = $conns[$server];
+ try {
+ $conn->multi( Redis::PIPELINE );
+ foreach ( $batchKeys as $key ) {
+ if ( $expiry ) {
+ $conn->setex( $key, $expiry, $this->serialize( $data[$key] ) );
+ } else {
+ $conn->set( $key, $this->serialize( $data[$key] ) );
+ }
+ }
+ $batchResult = $conn->exec();
+ if ( $batchResult === false ) {
+ $this->debug( "setMulti request to $server failed" );
+ continue;
+ }
+ foreach ( $batchResult as $value ) {
+ if ( $value === false ) {
+ $result = false;
+ }
+ }
+ } catch ( RedisException $e ) {
+ $this->handleException( $server, $conn, $e );
+ $result = false;
+ }
+ }
+
+ return $result;
+ }
+
+ public function add( $key, $value, $expiry = 0 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ $expiry = $this->convertToRelative( $expiry );
+ try {
+ if ( $expiry ) {
+ $result = $conn->set(
+ $key,
+ $this->serialize( $value ),
+ [ 'nx', 'ex' => $expiry ]
+ );
+ } else {
+ $result = $conn->setnx( $key, $this->serialize( $value ) );
+ }
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'add', $key, $server, $result );
+ return $result;
+ }
+
+ /**
+ * Non-atomic implementation of incr().
+ *
+ * Probably all callers actually want incr() to atomically initialise
+ * values to zero if they don't exist, as provided by the Redis INCR
+ * command. But we are constrained by the memcached-like interface to
+ * return null in that case. Once the key exists, further increments are
+ * atomic.
+ * @param string $key Key to increase
+ * @param int $value Value to add to $key (Default 1)
+ * @return int|bool New value or false on failure
+ */
+ public function incr( $key, $value = 1 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ try {
+ if ( !$conn->exists( $key ) ) {
+ return null;
+ }
+ // @FIXME: on races, the key may have a 0 TTL
+ $result = $conn->incrBy( $key, $value );
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'incr', $key, $server, $result );
+ return $result;
+ }
+
+ public function changeTTL( $key, $expiry = 0 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+
+ $expiry = $this->convertToRelative( $expiry );
+ try {
+ $result = $conn->expire( $key, $expiry );
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'expire', $key, $server, $result );
+ return $result;
+ }
+
+ public function modifySimpleRelayEvent( array $event ) {
+ if ( array_key_exists( 'val', $event ) ) {
+ $event['val'] = serialize( $event['val'] ); // this class uses PHP serialization
+ }
+
+ return $event;
+ }
+
+ /**
+ * @param mixed $data
+ * @return string
+ */
+ protected function serialize( $data ) {
+ // Serialize anything but integers so INCR/DECR work
+ // Do not store integer-like strings as integers to avoid type confusion (T62563)
+ return is_int( $data ) ? $data : serialize( $data );
+ }
+
+ /**
+ * @param string $data
+ * @return mixed
+ */
+ protected function unserialize( $data ) {
+ $int = intval( $data );
+ return $data === (string)$int ? $int : unserialize( $data );
+ }
+
+ /**
+ * Get a Redis object with a connection suitable for fetching the specified key
+ * @param string $key
+ * @return array (server, RedisConnRef) or (false, false)
+ */
+ protected function getConnection( $key ) {
+ $candidates = array_keys( $this->serverTagMap );
+
+ if ( count( $this->servers ) > 1 ) {
+ ArrayUtils::consistentHashSort( $candidates, $key, '/' );
+ if ( !$this->automaticFailover ) {
+ $candidates = array_slice( $candidates, 0, 1 );
+ }
+ }
+
+ while ( ( $tag = array_shift( $candidates ) ) !== null ) {
+ $server = $this->serverTagMap[$tag];
+ $conn = $this->redisPool->getConnection( $server, $this->logger );
+ if ( !$conn ) {
+ continue;
+ }
+
+ // If automatic failover is enabled, check that the server's link
+ // to its master (if any) is up -- but only if there are other
+ // viable candidates left to consider. Also, getMasterLinkStatus()
+ // does not work with twemproxy, though $candidates will be empty
+ // by now in such cases.
+ if ( $this->automaticFailover && $candidates ) {
+ try {
+ if ( $this->getMasterLinkStatus( $conn ) === 'down' ) {
+ // If the master cannot be reached, fail-over to the next server.
+ // If masters are in data-center A, and replica DBs in data-center B,
+ // this helps avoid the case were fail-over happens in A but not
+ // to the corresponding server in B (e.g. read/write mismatch).
+ continue;
+ }
+ } catch ( RedisException $e ) {
+ // Server is not accepting commands
+ $this->handleException( $conn, $e );
+ continue;
+ }
+ }
+
+ return [ $server, $conn ];
+ }
+
+ $this->setLastError( BagOStuff::ERR_UNREACHABLE );
+
+ return [ false, false ];
+ }
+
+ /**
+ * Check the master link status of a Redis server that is configured as a replica DB.
+ * @param RedisConnRef $conn
+ * @return string|null Master link status (either 'up' or 'down'), or null
+ * if the server is not a replica DB.
+ */
+ protected function getMasterLinkStatus( RedisConnRef $conn ) {
+ $info = $conn->info();
+ return isset( $info['master_link_status'] )
+ ? $info['master_link_status']
+ : null;
+ }
+
+ /**
+ * Log a fatal error
+ * @param string $msg
+ */
+ protected function logError( $msg ) {
+ $this->logger->error( "Redis error: $msg" );
+ }
+
+ /**
+ * The redis extension throws an exception in response to various read, write
+ * and protocol errors. Sometimes it also closes the connection, sometimes
+ * not. The safest response for us is to explicitly destroy the connection
+ * object and let it be reopened during the next request.
+ * @param RedisConnRef $conn
+ * @param Exception $e
+ */
+ protected function handleException( RedisConnRef $conn, $e ) {
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
+ $this->redisPool->handleError( $conn, $e );
+ }
+
+ /**
+ * Send information about a single request to the debug log
+ * @param string $method
+ * @param string $key
+ * @param string $server
+ * @param bool $result
+ */
+ public function logRequest( $method, $key, $server, $result ) {
+ $this->debug( "$method $key on $server: " .
+ ( $result === false ? "failure" : "success" ) );
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/ReplicatedBagOStuff.php b/www/wiki/includes/libs/objectcache/ReplicatedBagOStuff.php
new file mode 100644
index 00000000..8239491f
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/ReplicatedBagOStuff.php
@@ -0,0 +1,130 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * A cache class that directs writes to one set of servers and reads to
+ * another. This assumes that the servers used for reads are setup to replica DB
+ * those that writes go to. This can easily be used with redis for example.
+ *
+ * In the WAN scenario (e.g. multi-datacenter case), this is useful when
+ * writes are rare or they usually take place in the primary datacenter.
+ *
+ * @ingroup Cache
+ * @since 1.26
+ */
+class ReplicatedBagOStuff extends BagOStuff {
+ /** @var BagOStuff */
+ protected $writeStore;
+ /** @var BagOStuff */
+ protected $readStore;
+
+ /**
+ * Constructor. Parameters are:
+ * - writeFactory : ObjectFactory::getObjectFromSpec array yeilding BagOStuff.
+ * This object will be used for writes (e.g. the master DB).
+ * - readFactory : ObjectFactory::getObjectFromSpec array yeilding BagOStuff.
+ * This object will be used for reads (e.g. a replica DB).
+ *
+ * @param array $params
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ if ( !isset( $params['writeFactory'] ) ) {
+ throw new InvalidArgumentException(
+ __METHOD__ . ': the "writeFactory" parameter is required' );
+ }
+ if ( !isset( $params['readFactory'] ) ) {
+ throw new InvalidArgumentException(
+ __METHOD__ . ': the "readFactory" parameter is required' );
+ }
+
+ $opts = [ 'reportDupes' => false ]; // redundant
+ $this->writeStore = ( $params['writeFactory'] instanceof BagOStuff )
+ ? $params['writeFactory']
+ : ObjectFactory::getObjectFromSpec( $opts + $params['writeFactory'] );
+ $this->readStore = ( $params['readFactory'] instanceof BagOStuff )
+ ? $params['readFactory']
+ : ObjectFactory::getObjectFromSpec( $opts + $params['readFactory'] );
+ $this->attrMap = $this->mergeFlagMaps( [ $this->readStore, $this->writeStore ] );
+ }
+
+ public function setDebug( $debug ) {
+ $this->writeStore->setDebug( $debug );
+ $this->readStore->setDebug( $debug );
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ return ( $flags & self::READ_LATEST )
+ ? $this->writeStore->get( $key, $flags )
+ : $this->readStore->get( $key, $flags );
+ }
+
+ public function getMulti( array $keys, $flags = 0 ) {
+ return ( $flags & self::READ_LATEST )
+ ? $this->writeStore->getMulti( $keys, $flags )
+ : $this->readStore->getMulti( $keys, $flags );
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ return $this->writeStore->set( $key, $value, $exptime, $flags );
+ }
+
+ public function delete( $key ) {
+ return $this->writeStore->delete( $key );
+ }
+
+ public function add( $key, $value, $exptime = 0 ) {
+ return $this->writeStore->add( $key, $value, $exptime );
+ }
+
+ public function incr( $key, $value = 1 ) {
+ return $this->writeStore->incr( $key, $value );
+ }
+
+ public function decr( $key, $value = 1 ) {
+ return $this->writeStore->decr( $key, $value );
+ }
+
+ public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
+ return $this->writeStore->lock( $key, $timeout, $expiry, $rclass );
+ }
+
+ public function unlock( $key ) {
+ return $this->writeStore->unlock( $key );
+ }
+
+ public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
+ return $this->writeStore->merge( $key, $callback, $exptime, $attempts, $flags );
+ }
+
+ public function getLastError() {
+ return ( $this->writeStore->getLastError() != self::ERR_NONE )
+ ? $this->writeStore->getLastError()
+ : $this->readStore->getLastError();
+ }
+
+ public function clearLastError() {
+ $this->writeStore->clearLastError();
+ $this->readStore->clearLastError();
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/WANObjectCache.php b/www/wiki/includes/libs/objectcache/WANObjectCache.php
new file mode 100644
index 00000000..1f757a41
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/WANObjectCache.php
@@ -0,0 +1,1761 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * Multi-datacenter aware caching interface
+ *
+ * All operations go to the local datacenter cache, except for delete(),
+ * touchCheckKey(), and resetCheckKey(), which broadcast to all datacenters.
+ *
+ * This class is intended for caching data from primary stores.
+ * If the get() method does not return a value, then the caller
+ * should query the new value and backfill the cache using set().
+ * The preferred way to do this logic is through getWithSetCallback().
+ * When querying the store on cache miss, the closest DB replica
+ * should be used. Try to avoid heavyweight DB master or quorum reads.
+ * When the source data changes, a purge method should be called.
+ * Since purges are expensive, they should be avoided. One can do so if:
+ * - a) The object cached is immutable; or
+ * - b) Validity is checked against the source after get(); or
+ * - c) Using a modest TTL is reasonably correct and performant
+ *
+ * The simplest purge method is delete().
+ *
+ * There are three supported ways to handle broadcasted operations:
+ * - a) Configure the 'purge' EventRelayer to point to a valid PubSub endpoint
+ * that has subscribed listeners on the cache servers applying the cache updates.
+ * - b) Ignore the 'purge' EventRelayer configuration (default is NullEventRelayer)
+ * and set up mcrouter as the underlying cache backend, using one of the memcached
+ * BagOStuff classes as 'cache'. Use OperationSelectorRoute in the mcrouter settings
+ * to configure 'set' and 'delete' operations to go to all DCs via AllAsyncRoute and
+ * configure other operations to go to the local DC via PoolRoute (for reference,
+ * see https://github.com/facebook/mcrouter/wiki/List-of-Route-Handles).
+ * - c) Ignore the 'purge' EventRelayer configuration (default is NullEventRelayer)
+ * and set up dynomite as cache middleware between the web servers and either
+ * memcached or redis. This will also broadcast all key setting operations, not just purges,
+ * which can be useful for cache warming. Writes are eventually consistent via the
+ * Dynamo replication model (see https://github.com/Netflix/dynomite).
+ *
+ * Broadcasted operations like delete() and touchCheckKey() are done asynchronously
+ * in all datacenters this way, though the local one should likely be near immediate.
+ *
+ * This means that callers in all datacenters may see older values for however many
+ * milliseconds that the purge took to reach that datacenter. As with any cache, this
+ * should not be relied on for cases where reads are used to determine writes to source
+ * (e.g. non-cache) data stores, except when reading immutable data.
+ *
+ * All values are wrapped in metadata arrays. Keys use a "WANCache:" prefix
+ * to avoid collisions with keys that are not wrapped as metadata arrays. The
+ * prefixes are as follows:
+ * - a) "WANCache:v" : used for regular value keys
+ * - b) "WANCache:i" : used for temporarily storing values of tombstoned keys
+ * - c) "WANCache:t" : used for storing timestamp "check" keys
+ * - d) "WANCache:m" : used for temporary mutex keys to avoid cache stampedes
+ *
+ * @ingroup Cache
+ * @since 1.26
+ */
+class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
+ /** @var BagOStuff The local datacenter cache */
+ protected $cache;
+ /** @var HashBagOStuff[] Map of group PHP instance caches */
+ protected $processCaches = [];
+ /** @var string Purge channel name */
+ protected $purgeChannel;
+ /** @var EventRelayer Bus that handles purge broadcasts */
+ protected $purgeRelayer;
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var int ERR_* constant for the "last error" registry */
+ protected $lastRelayError = self::ERR_NONE;
+
+ /** @var int Callback stack depth for getWithSetCallback() */
+ private $callbackDepth = 0;
+ /** @var mixed[] Temporary warm-up cache */
+ private $warmupCache = [];
+ /** @var int Key fetched */
+ private $warmupKeyMisses = 0;
+
+ /** Max time expected to pass between delete() and DB commit finishing */
+ const MAX_COMMIT_DELAY = 3;
+ /** Max replication+snapshot lag before applying TTL_LAGGED or disallowing set() */
+ const MAX_READ_LAG = 7;
+ /** Seconds to tombstone keys on delete() */
+ const HOLDOFF_TTL = 11; // MAX_COMMIT_DELAY + MAX_READ_LAG + 1
+
+ /** Seconds to keep dependency purge keys around */
+ const CHECK_KEY_TTL = self::TTL_YEAR;
+ /** Seconds to keep lock keys around */
+ const LOCK_TTL = 10;
+ /** Default remaining TTL at which to consider pre-emptive regeneration */
+ const LOW_TTL = 30;
+ /** Default time-since-expiry on a miss that makes a key "hot" */
+ const LOCK_TSE = 1;
+
+ /** Never consider performing "popularity" refreshes until a key reaches this age */
+ const AGE_NEW = 60;
+ /** The time length of the "popularity" refresh window for hot keys */
+ const HOT_TTR = 900;
+ /** Hits/second for a refresh to be expected within the "popularity" window */
+ const HIT_RATE_HIGH = 1;
+ /** Seconds to ramp up to the "popularity" refresh chance after a key is no longer new */
+ const RAMPUP_TTL = 30;
+
+ /** Idiom for getWithSetCallback() callbacks to avoid calling set() */
+ const TTL_UNCACHEABLE = -1;
+ /** Idiom for getWithSetCallback() callbacks to 'lockTSE' logic */
+ const TSE_NONE = -1;
+ /** Max TTL to store keys when a data sourced is lagged */
+ const TTL_LAGGED = 30;
+ /** Idiom for delete() for "no hold-off" */
+ const HOLDOFF_NONE = 0;
+ /** Idiom for getWithSetCallback() for "no minimum required as-of timestamp" */
+ const MIN_TIMESTAMP_NONE = 0.0;
+
+ /** Tiny negative float to use when CTL comes up >= 0 due to clock skew */
+ const TINY_NEGATIVE = -0.000001;
+
+ /** Cache format version number */
+ const VERSION = 1;
+
+ const FLD_VERSION = 0; // key to cache version number
+ const FLD_VALUE = 1; // key to the cached value
+ const FLD_TTL = 2; // key to the original TTL
+ const FLD_TIME = 3; // key to the cache time
+ const FLD_FLAGS = 4; // key to the flags bitfield
+ const FLD_HOLDOFF = 5; // key to any hold-off TTL
+
+ /** @var int Treat this value as expired-on-arrival */
+ const FLG_STALE = 1;
+
+ const ERR_NONE = 0; // no error
+ const ERR_NO_RESPONSE = 1; // no response
+ const ERR_UNREACHABLE = 2; // can't connect
+ const ERR_UNEXPECTED = 3; // response gave some error
+ const ERR_RELAY = 4; // relay broadcast failed
+
+ const VALUE_KEY_PREFIX = 'WANCache:v:';
+ const INTERIM_KEY_PREFIX = 'WANCache:i:';
+ const TIME_KEY_PREFIX = 'WANCache:t:';
+ const MUTEX_KEY_PREFIX = 'WANCache:m:';
+
+ const PURGE_VAL_PREFIX = 'PURGED:';
+
+ const VFLD_DATA = 'WOC:d'; // key to the value of versioned data
+ const VFLD_VERSION = 'WOC:v'; // key to the version of the value present
+
+ const PC_PRIMARY = 'primary:1000'; // process cache name and max key count
+
+ const DEFAULT_PURGE_CHANNEL = 'wancache-purge';
+
+ /**
+ * @param array $params
+ * - cache : BagOStuff object for a persistent cache
+ * - channels : Map of (action => channel string). Actions include "purge".
+ * - relayers : Map of (action => EventRelayer object). Actions include "purge".
+ * - logger : LoggerInterface object
+ */
+ public function __construct( array $params ) {
+ $this->cache = $params['cache'];
+ $this->purgeChannel = isset( $params['channels']['purge'] )
+ ? $params['channels']['purge']
+ : self::DEFAULT_PURGE_CHANNEL;
+ $this->purgeRelayer = isset( $params['relayers']['purge'] )
+ ? $params['relayers']['purge']
+ : new EventRelayerNull( [] );
+ $this->setLogger( isset( $params['logger'] ) ? $params['logger'] : new NullLogger() );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get an instance that wraps EmptyBagOStuff
+ *
+ * @return WANObjectCache
+ */
+ public static function newEmpty() {
+ return new self( [
+ 'cache' => new EmptyBagOStuff(),
+ 'pool' => 'empty'
+ ] );
+ }
+
+ /**
+ * Fetch the value of a key from cache
+ *
+ * If supplied, $curTTL is set to the remaining TTL (current time left):
+ * - a) INF; if $key exists, has no TTL, and is not expired by $checkKeys
+ * - b) float (>=0); if $key exists, has a TTL, and is not expired by $checkKeys
+ * - c) float (<0); if $key is tombstoned, stale, or existing but expired by $checkKeys
+ * - d) null; if $key does not exist and is not tombstoned
+ *
+ * If a key is tombstoned, $curTTL will reflect the time since delete().
+ *
+ * The timestamp of $key will be checked against the last-purge timestamp
+ * of each of $checkKeys. Those $checkKeys not in cache will have the last-purge
+ * initialized to the current timestamp. If any of $checkKeys have a timestamp
+ * greater than that of $key, then $curTTL will reflect how long ago $key
+ * became invalid. Callers can use $curTTL to know when the value is stale.
+ * The $checkKeys parameter allow mass invalidations by updating a single key:
+ * - a) Each "check" key represents "last purged" of some source data
+ * - b) Callers pass in relevant "check" keys as $checkKeys in get()
+ * - c) When the source data that "check" keys represent changes,
+ * the touchCheckKey() method is called on them
+ *
+ * Source data entities might exists in a DB that uses snapshot isolation
+ * (e.g. the default REPEATABLE-READ in innoDB). Even for mutable data, that
+ * isolation can largely be maintained by doing the following:
+ * - a) Calling delete() on entity change *and* creation, before DB commit
+ * - b) Keeping transaction duration shorter than delete() hold-off TTL
+ *
+ * However, pre-snapshot values might still be seen if an update was made
+ * in a remote datacenter but the purge from delete() didn't relay yet.
+ *
+ * Consider using getWithSetCallback() instead of get() and set() cycles.
+ * That method has cache slam avoiding features for hot/expensive keys.
+ *
+ * @param string $key Cache key
+ * @param mixed &$curTTL Approximate TTL left on the key if present/tombstoned [returned]
+ * @param array $checkKeys List of "check" keys
+ * @param float &$asOf UNIX timestamp of cached value; null on failure [returned]
+ * @return mixed Value of cache key or false on failure
+ */
+ final public function get( $key, &$curTTL = null, array $checkKeys = [], &$asOf = null ) {
+ $curTTLs = [];
+ $asOfs = [];
+ $values = $this->getMulti( [ $key ], $curTTLs, $checkKeys, $asOfs );
+ $curTTL = isset( $curTTLs[$key] ) ? $curTTLs[$key] : null;
+ $asOf = isset( $asOfs[$key] ) ? $asOfs[$key] : null;
+
+ return isset( $values[$key] ) ? $values[$key] : false;
+ }
+
+ /**
+ * Fetch the value of several keys from cache
+ *
+ * @see WANObjectCache::get()
+ *
+ * @param array $keys List of cache keys
+ * @param array &$curTTLs Map of (key => approximate TTL left) for existing keys [returned]
+ * @param array $checkKeys List of check keys to apply to all $keys. May also apply "check"
+ * keys to specific cache keys only by using cache keys as keys in the $checkKeys array.
+ * @param float[] &$asOfs Map of (key => UNIX timestamp of cached value; null on failure)
+ * @return array Map of (key => value) for keys that exist and are not tombstoned
+ */
+ final public function getMulti(
+ array $keys, &$curTTLs = [], array $checkKeys = [], array &$asOfs = []
+ ) {
+ $result = [];
+ $curTTLs = [];
+ $asOfs = [];
+
+ $vPrefixLen = strlen( self::VALUE_KEY_PREFIX );
+ $valueKeys = self::prefixCacheKeys( $keys, self::VALUE_KEY_PREFIX );
+
+ $checkKeysForAll = [];
+ $checkKeysByKey = [];
+ $checkKeysFlat = [];
+ foreach ( $checkKeys as $i => $checkKeyGroup ) {
+ $prefixed = self::prefixCacheKeys( (array)$checkKeyGroup, self::TIME_KEY_PREFIX );
+ $checkKeysFlat = array_merge( $checkKeysFlat, $prefixed );
+ // Is this check keys for a specific cache key, or for all keys being fetched?
+ if ( is_int( $i ) ) {
+ $checkKeysForAll = array_merge( $checkKeysForAll, $prefixed );
+ } else {
+ $checkKeysByKey[$i] = isset( $checkKeysByKey[$i] )
+ ? array_merge( $checkKeysByKey[$i], $prefixed )
+ : $prefixed;
+ }
+ }
+
+ // Fetch all of the raw values
+ $keysGet = array_merge( $valueKeys, $checkKeysFlat );
+ if ( $this->warmupCache ) {
+ $wrappedValues = array_intersect_key( $this->warmupCache, array_flip( $keysGet ) );
+ $keysGet = array_diff( $keysGet, array_keys( $wrappedValues ) ); // keys left to fetch
+ $this->warmupKeyMisses += count( $keysGet );
+ } else {
+ $wrappedValues = [];
+ }
+ if ( $keysGet ) {
+ $wrappedValues += $this->cache->getMulti( $keysGet );
+ }
+ // Time used to compare/init "check" keys (derived after getMulti() to be pessimistic)
+ $now = microtime( true );
+
+ // Collect timestamps from all "check" keys
+ $purgeValuesForAll = $this->processCheckKeys( $checkKeysForAll, $wrappedValues, $now );
+ $purgeValuesByKey = [];
+ foreach ( $checkKeysByKey as $cacheKey => $checks ) {
+ $purgeValuesByKey[$cacheKey] =
+ $this->processCheckKeys( $checks, $wrappedValues, $now );
+ }
+
+ // Get the main cache value for each key and validate them
+ foreach ( $valueKeys as $vKey ) {
+ if ( !isset( $wrappedValues[$vKey] ) ) {
+ continue; // not found
+ }
+
+ $key = substr( $vKey, $vPrefixLen ); // unprefix
+
+ list( $value, $curTTL ) = $this->unwrap( $wrappedValues[$vKey], $now );
+ if ( $value !== false ) {
+ $result[$key] = $value;
+
+ // Force dependant keys to be invalid for a while after purging
+ // to reduce race conditions involving stale data getting cached
+ $purgeValues = $purgeValuesForAll;
+ if ( isset( $purgeValuesByKey[$key] ) ) {
+ $purgeValues = array_merge( $purgeValues, $purgeValuesByKey[$key] );
+ }
+ foreach ( $purgeValues as $purge ) {
+ $safeTimestamp = $purge[self::FLD_TIME] + $purge[self::FLD_HOLDOFF];
+ if ( $safeTimestamp >= $wrappedValues[$vKey][self::FLD_TIME] ) {
+ // How long ago this value was expired by *this* check key
+ $ago = min( $purge[self::FLD_TIME] - $now, self::TINY_NEGATIVE );
+ // How long ago this value was expired by *any* known check key
+ $curTTL = min( $curTTL, $ago );
+ }
+ }
+ }
+ $curTTLs[$key] = $curTTL;
+ $asOfs[$key] = ( $value !== false ) ? $wrappedValues[$vKey][self::FLD_TIME] : null;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @since 1.27
+ * @param array $timeKeys List of prefixed time check keys
+ * @param array $wrappedValues
+ * @param float $now
+ * @return array List of purge value arrays
+ */
+ private function processCheckKeys( array $timeKeys, array $wrappedValues, $now ) {
+ $purgeValues = [];
+ foreach ( $timeKeys as $timeKey ) {
+ $purge = isset( $wrappedValues[$timeKey] )
+ ? self::parsePurgeValue( $wrappedValues[$timeKey] )
+ : false;
+ if ( $purge === false ) {
+ // Key is not set or invalid; regenerate
+ $newVal = $this->makePurgeValue( $now, self::HOLDOFF_TTL );
+ $this->cache->add( $timeKey, $newVal, self::CHECK_KEY_TTL );
+ $purge = self::parsePurgeValue( $newVal );
+ }
+ $purgeValues[] = $purge;
+ }
+ return $purgeValues;
+ }
+
+ /**
+ * Set the value of a key in cache
+ *
+ * Simply calling this method when source data changes is not valid because
+ * the changes do not replicate to the other WAN sites. In that case, delete()
+ * should be used instead. This method is intended for use on cache misses.
+ *
+ * If the data was read from a snapshot-isolated transactions (e.g. the default
+ * REPEATABLE-READ in innoDB), use 'since' to avoid the following race condition:
+ * - a) T1 starts
+ * - b) T2 updates a row, calls delete(), and commits
+ * - c) The HOLDOFF_TTL passes, expiring the delete() tombstone
+ * - d) T1 reads the row and calls set() due to a cache miss
+ * - e) Stale value is stuck in cache
+ *
+ * Setting 'lag' and 'since' help avoids keys getting stuck in stale states.
+ *
+ * Example usage:
+ * @code
+ * $dbr = wfGetDB( DB_REPLICA );
+ * $setOpts = Database::getCacheSetOptions( $dbr );
+ * // Fetch the row from the DB
+ * $row = $dbr->selectRow( ... );
+ * $key = $cache->makeKey( 'building', $buildingId );
+ * $cache->set( $key, $row, $cache::TTL_DAY, $setOpts );
+ * @endcode
+ *
+ * @param string $key Cache key
+ * @param mixed $value
+ * @param int $ttl Seconds to live. Special values are:
+ * - WANObjectCache::TTL_INDEFINITE: Cache forever
+ * @param array $opts Options map:
+ * - lag : Seconds of replica DB lag. Typically, this is either the replica DB lag
+ * before the data was read or, if applicable, the replica DB lag before
+ * the snapshot-isolated transaction the data was read from started.
+ * Use false to indicate that replication is not running.
+ * Default: 0 seconds
+ * - since : UNIX timestamp of the data in $value. Typically, this is either
+ * the current time the data was read or (if applicable) the time when
+ * the snapshot-isolated transaction the data was read from started.
+ * Default: 0 seconds
+ * - pending : Whether this data is possibly from an uncommitted write transaction.
+ * Generally, other threads should not see values from the future and
+ * they certainly should not see ones that ended up getting rolled back.
+ * Default: false
+ * - lockTSE : if excessive replication/snapshot lag is detected, then store the value
+ * with this TTL and flag it as stale. This is only useful if the reads for
+ * this key use getWithSetCallback() with "lockTSE" set.
+ * Default: WANObjectCache::TSE_NONE
+ * - staleTTL : Seconds to keep the key around if it is stale. The get()/getMulti()
+ * methods return such stale values with a $curTTL of 0, and getWithSetCallback()
+ * will call the regeneration callback in such cases, passing in the old value
+ * and its as-of time to the callback. This is useful if adaptiveTTL() is used
+ * on the old value's as-of time when it is verified as still being correct.
+ * Default: 0.
+ * @note Options added in 1.28: staleTTL
+ * @return bool Success
+ */
+ final public function set( $key, $value, $ttl = 0, array $opts = [] ) {
+ $now = microtime( true );
+ $lockTSE = isset( $opts['lockTSE'] ) ? $opts['lockTSE'] : self::TSE_NONE;
+ $age = isset( $opts['since'] ) ? max( 0, $now - $opts['since'] ) : 0;
+ $lag = isset( $opts['lag'] ) ? $opts['lag'] : 0;
+ $staleTTL = isset( $opts['staleTTL'] ) ? $opts['staleTTL'] : 0;
+
+ // Do not cache potentially uncommitted data as it might get rolled back
+ if ( !empty( $opts['pending'] ) ) {
+ $this->logger->info( "Rejected set() for $key due to pending writes." );
+
+ return true; // no-op the write for being unsafe
+ }
+
+ $wrapExtra = []; // additional wrapped value fields
+ // Check if there's a risk of writing stale data after the purge tombstone expired
+ if ( $lag === false || ( $lag + $age ) > self::MAX_READ_LAG ) {
+ // Case A: read lag with "lockTSE"; save but record value as stale
+ if ( $lockTSE >= 0 ) {
+ $ttl = max( 1, (int)$lockTSE ); // set() expects seconds
+ $wrapExtra[self::FLD_FLAGS] = self::FLG_STALE; // mark as stale
+ // Case B: any long-running transaction; ignore this set()
+ } elseif ( $age > self::MAX_READ_LAG ) {
+ $this->logger->info( "Rejected set() for $key due to snapshot lag." );
+
+ return true; // no-op the write for being unsafe
+ // Case C: high replication lag; lower TTL instead of ignoring all set()s
+ } elseif ( $lag === false || $lag > self::MAX_READ_LAG ) {
+ $ttl = $ttl ? min( $ttl, self::TTL_LAGGED ) : self::TTL_LAGGED;
+ $this->logger->warning( "Lowered set() TTL for $key due to replication lag." );
+ // Case D: medium length request with medium replication lag; ignore this set()
+ } else {
+ $this->logger->info( "Rejected set() for $key due to high read lag." );
+
+ return true; // no-op the write for being unsafe
+ }
+ }
+
+ // Wrap that value with time/TTL/version metadata
+ $wrapped = $this->wrap( $value, $ttl, $now ) + $wrapExtra;
+
+ $func = function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
+ return ( is_string( $cWrapped ) )
+ ? false // key is tombstoned; do nothing
+ : $wrapped;
+ };
+
+ return $this->cache->merge( self::VALUE_KEY_PREFIX . $key, $func, $ttl + $staleTTL, 1 );
+ }
+
+ /**
+ * Purge a key from all datacenters
+ *
+ * This should only be called when the underlying data (being cached)
+ * changes in a significant way. This deletes the key and starts a hold-off
+ * period where the key cannot be written to for a few seconds (HOLDOFF_TTL).
+ * This is done to avoid the following race condition:
+ * - a) Some DB data changes and delete() is called on a corresponding key
+ * - b) A request refills the key with a stale value from a lagged DB
+ * - c) The stale value is stuck there until the key is expired/evicted
+ *
+ * This is implemented by storing a special "tombstone" value at the cache
+ * key that this class recognizes; get() calls will return false for the key
+ * and any set() calls will refuse to replace tombstone values at the key.
+ * For this to always avoid stale value writes, the following must hold:
+ * - a) Replication lag is bounded to being less than HOLDOFF_TTL; or
+ * - b) If lag is higher, the DB will have gone into read-only mode already
+ *
+ * Note that set() can also be lag-aware and lower the TTL if it's high.
+ *
+ * When using potentially long-running ACID transactions, a good pattern is
+ * to use a pre-commit hook to issue the delete. This means that immediately
+ * after commit, callers will see the tombstone in cache upon purge relay.
+ * It also avoids the following race condition:
+ * - a) T1 begins, changes a row, and calls delete()
+ * - b) The HOLDOFF_TTL passes, expiring the delete() tombstone
+ * - c) T2 starts, reads the row and calls set() due to a cache miss
+ * - d) T1 finally commits
+ * - e) Stale value is stuck in cache
+ *
+ * Example usage:
+ * @code
+ * $dbw->startAtomic( __METHOD__ ); // start of request
+ * ... <execute some stuff> ...
+ * // Update the row in the DB
+ * $dbw->update( ... );
+ * $key = $cache->makeKey( 'homes', $homeId );
+ * // Purge the corresponding cache entry just before committing
+ * $dbw->onTransactionPreCommitOrIdle( function() use ( $cache, $key ) {
+ * $cache->delete( $key );
+ * } );
+ * ... <execute some stuff> ...
+ * $dbw->endAtomic( __METHOD__ ); // end of request
+ * @endcode
+ *
+ * The $ttl parameter can be used when purging values that have not actually changed
+ * recently. For example, a cleanup script to purge cache entries does not really need
+ * a hold-off period, so it can use HOLDOFF_NONE. Likewise for user-requested purge.
+ * Note that $ttl limits the effective range of 'lockTSE' for getWithSetCallback().
+ *
+ * If called twice on the same key, then the last hold-off TTL takes precedence. For
+ * idempotence, the $ttl should not vary for different delete() calls on the same key.
+ *
+ * @param string $key Cache key
+ * @param int $ttl Tombstone TTL; Default: WANObjectCache::HOLDOFF_TTL
+ * @return bool True if the item was purged or not found, false on failure
+ */
+ final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
+ $key = self::VALUE_KEY_PREFIX . $key;
+
+ if ( $ttl <= 0 ) {
+ // Publish the purge to all datacenters
+ $ok = $this->relayDelete( $key );
+ } else {
+ // Publish the purge to all datacenters
+ $ok = $this->relayPurge( $key, $ttl, self::HOLDOFF_NONE );
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Fetch the value of a timestamp "check" key
+ *
+ * The key will be *initialized* to the current time if not set,
+ * so only call this method if this behavior is actually desired
+ *
+ * The timestamp can be used to check whether a cached value is valid.
+ * Callers should not assume that this returns the same timestamp in
+ * all datacenters due to relay delays.
+ *
+ * The level of staleness can roughly be estimated from this key, but
+ * if the key was evicted from cache, such calculations may show the
+ * time since expiry as ~0 seconds.
+ *
+ * Note that "check" keys won't collide with other regular keys.
+ *
+ * @param string $key
+ * @return float UNIX timestamp of the check key
+ */
+ final public function getCheckKeyTime( $key ) {
+ $key = self::TIME_KEY_PREFIX . $key;
+
+ $purge = self::parsePurgeValue( $this->cache->get( $key ) );
+ if ( $purge !== false ) {
+ $time = $purge[self::FLD_TIME];
+ } else {
+ // Casting assures identical floats for the next getCheckKeyTime() calls
+ $now = (string)microtime( true );
+ $this->cache->add( $key,
+ $this->makePurgeValue( $now, self::HOLDOFF_TTL ),
+ self::CHECK_KEY_TTL
+ );
+ $time = (float)$now;
+ }
+
+ return $time;
+ }
+
+ /**
+ * Purge a "check" key from all datacenters, invalidating keys that use it
+ *
+ * This should only be called when the underlying data (being cached)
+ * changes in a significant way, and it is impractical to call delete()
+ * on all keys that should be changed. When get() is called on those
+ * keys, the relevant "check" keys must be supplied for this to work.
+ *
+ * The "check" key essentially represents a last-modified field.
+ * When touched, the field will be updated on all cache servers.
+ * Keys using it via get(), getMulti(), or getWithSetCallback() will
+ * be invalidated. It is treated as being HOLDOFF_TTL seconds in the future
+ * by those methods to avoid race conditions where dependent keys get updated
+ * with stale values (e.g. from a DB replica DB).
+ *
+ * This is typically useful for keys with hardcoded names or in some cases
+ * dynamically generated names where a low number of combinations exist.
+ * When a few important keys get a large number of hits, a high cache
+ * time is usually desired as well as "lockTSE" logic. The resetCheckKey()
+ * method is less appropriate in such cases since the "time since expiry"
+ * cannot be inferred, causing any get() after the reset to treat the key
+ * as being "hot", resulting in more stale value usage.
+ *
+ * Note that "check" keys won't collide with other regular keys.
+ *
+ * @see WANObjectCache::get()
+ * @see WANObjectCache::getWithSetCallback()
+ * @see WANObjectCache::resetCheckKey()
+ *
+ * @param string $key Cache key
+ * @param int $holdoff HOLDOFF_TTL or HOLDOFF_NONE constant
+ * @return bool True if the item was purged or not found, false on failure
+ */
+ final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
+ // Publish the purge to all datacenters
+ return $this->relayPurge( self::TIME_KEY_PREFIX . $key, self::CHECK_KEY_TTL, $holdoff );
+ }
+
+ /**
+ * Delete a "check" key from all datacenters, invalidating keys that use it
+ *
+ * This is similar to touchCheckKey() in that keys using it via get(), getMulti(),
+ * or getWithSetCallback() will be invalidated. The differences are:
+ * - a) The "check" key will be deleted from all caches and lazily
+ * re-initialized when accessed (rather than set everywhere)
+ * - b) Thus, dependent keys will be known to be invalid, but not
+ * for how long (they are treated as "just" purged), which
+ * effects any lockTSE logic in getWithSetCallback()
+ * - c) Since "check" keys are initialized only on the server the key hashes
+ * to, any temporary ejection of that server will cause the value to be
+ * seen as purged as a new server will initialize the "check" key.
+ *
+ * The advantage is that this does not place high TTL keys on every cache
+ * server, making it better for code that will cache many different keys
+ * and either does not use lockTSE or uses a low enough TTL anyway.
+ *
+ * This is typically useful for keys with dynamically generated names
+ * where a high number of combinations exist.
+ *
+ * Note that "check" keys won't collide with other regular keys.
+ *
+ * @see WANObjectCache::get()
+ * @see WANObjectCache::getWithSetCallback()
+ * @see WANObjectCache::touchCheckKey()
+ *
+ * @param string $key Cache key
+ * @return bool True if the item was purged or not found, false on failure
+ */
+ final public function resetCheckKey( $key ) {
+ // Publish the purge to all datacenters
+ return $this->relayDelete( self::TIME_KEY_PREFIX . $key );
+ }
+
+ /**
+ * Method to fetch/regenerate cache keys
+ *
+ * On cache miss, the key will be set to the callback result via set()
+ * (unless the callback returns false) and that result will be returned.
+ * The arguments supplied to the callback are:
+ * - $oldValue : current cache value or false if not present
+ * - &$ttl : a reference to the TTL which can be altered
+ * - &$setOpts : a reference to options for set() which can be altered
+ * - $oldAsOf : generation UNIX timestamp of $oldValue or null if not present (since 1.28)
+ *
+ * It is strongly recommended to set the 'lag' and 'since' fields to avoid race conditions
+ * that can cause stale values to get stuck at keys. Usually, callbacks ignore the current
+ * value, but it can be used to maintain "most recent X" values that come from time or
+ * sequence based source data, provided that the "as of" id/time is tracked. Note that
+ * preemptive regeneration and $checkKeys can result in a non-false current value.
+ *
+ * Usage of $checkKeys is similar to get() and getMulti(). However, rather than the caller
+ * having to inspect a "current time left" variable (e.g. $curTTL, $curTTLs), a cache
+ * regeneration will automatically be triggered using the callback.
+ *
+ * The simplest way to avoid stampedes for hot keys is to use
+ * the 'lockTSE' option in $opts. If cache purges are needed, also:
+ * - a) Pass $key into $checkKeys
+ * - b) Use touchCheckKey( $key ) instead of delete( $key )
+ *
+ * Example usage (typical key):
+ * @code
+ * $catInfo = $cache->getWithSetCallback(
+ * // Key to store the cached value under
+ * $cache->makeKey( 'cat-attributes', $catId ),
+ * // Time-to-live (in seconds)
+ * $cache::TTL_MINUTE,
+ * // Function that derives the new key value
+ * function ( $oldValue, &$ttl, array &$setOpts ) {
+ * $dbr = wfGetDB( DB_REPLICA );
+ * // Account for any snapshot/replica DB lag
+ * $setOpts += Database::getCacheSetOptions( $dbr );
+ *
+ * return $dbr->selectRow( ... );
+ * }
+ * );
+ * @endcode
+ *
+ * Example usage (key that is expensive and hot):
+ * @code
+ * $catConfig = $cache->getWithSetCallback(
+ * // Key to store the cached value under
+ * $cache->makeKey( 'site-cat-config' ),
+ * // Time-to-live (in seconds)
+ * $cache::TTL_DAY,
+ * // Function that derives the new key value
+ * function ( $oldValue, &$ttl, array &$setOpts ) {
+ * $dbr = wfGetDB( DB_REPLICA );
+ * // Account for any snapshot/replica DB lag
+ * $setOpts += Database::getCacheSetOptions( $dbr );
+ *
+ * return CatConfig::newFromRow( $dbr->selectRow( ... ) );
+ * },
+ * [
+ * // Calling touchCheckKey() on this key invalidates the cache
+ * 'checkKeys' => [ $cache->makeKey( 'site-cat-config' ) ],
+ * // Try to only let one datacenter thread manage cache updates at a time
+ * 'lockTSE' => 30,
+ * // Avoid querying cache servers multiple times in a web request
+ * 'pcTTL' => $cache::TTL_PROC_LONG
+ * ]
+ * );
+ * @endcode
+ *
+ * Example usage (key with dynamic dependencies):
+ * @code
+ * $catState = $cache->getWithSetCallback(
+ * // Key to store the cached value under
+ * $cache->makeKey( 'cat-state', $cat->getId() ),
+ * // Time-to-live (seconds)
+ * $cache::TTL_HOUR,
+ * // Function that derives the new key value
+ * function ( $oldValue, &$ttl, array &$setOpts ) {
+ * // Determine new value from the DB
+ * $dbr = wfGetDB( DB_REPLICA );
+ * // Account for any snapshot/replica DB lag
+ * $setOpts += Database::getCacheSetOptions( $dbr );
+ *
+ * return CatState::newFromResults( $dbr->select( ... ) );
+ * },
+ * [
+ * // The "check" keys that represent things the value depends on;
+ * // Calling touchCheckKey() on any of them invalidates the cache
+ * 'checkKeys' => [
+ * $cache->makeKey( 'sustenance-bowls', $cat->getRoomId() ),
+ * $cache->makeKey( 'people-present', $cat->getHouseId() ),
+ * $cache->makeKey( 'cat-laws', $cat->getCityId() ),
+ * ]
+ * ]
+ * );
+ * @endcode
+ *
+ * Example usage (hot key holding most recent 100 events):
+ * @code
+ * $lastCatActions = $cache->getWithSetCallback(
+ * // Key to store the cached value under
+ * $cache->makeKey( 'cat-last-actions', 100 ),
+ * // Time-to-live (in seconds)
+ * 10,
+ * // Function that derives the new key value
+ * function ( $oldValue, &$ttl, array &$setOpts ) {
+ * $dbr = wfGetDB( DB_REPLICA );
+ * // Account for any snapshot/replica DB lag
+ * $setOpts += Database::getCacheSetOptions( $dbr );
+ *
+ * // Start off with the last cached list
+ * $list = $oldValue ?: [];
+ * // Fetch the last 100 relevant rows in descending order;
+ * // only fetch rows newer than $list[0] to reduce scanning
+ * $rows = iterator_to_array( $dbr->select( ... ) );
+ * // Merge them and get the new "last 100" rows
+ * return array_slice( array_merge( $new, $list ), 0, 100 );
+ * },
+ * [
+ * // Try to only let one datacenter thread manage cache updates at a time
+ * 'lockTSE' => 30,
+ * // Use a magic value when no cache value is ready rather than stampeding
+ * 'busyValue' => 'computing'
+ * ]
+ * );
+ * @endcode
+ *
+ * @see WANObjectCache::get()
+ * @see WANObjectCache::set()
+ *
+ * @param string $key Cache key
+ * @param int $ttl Seconds to live for key updates. Special values are:
+ * - WANObjectCache::TTL_INDEFINITE: Cache forever
+ * - WANObjectCache::TTL_UNCACHEABLE: Do not cache at all
+ * @param callable $callback Value generation function
+ * @param array $opts Options map:
+ * - checkKeys: List of "check" keys. The key at $key will be seen as invalid when either
+ * touchCheckKey() or resetCheckKey() is called on any of these keys.
+ * Default: [].
+ * - lockTSE: If the key is tombstoned or expired (by checkKeys) less than this many seconds
+ * ago, then try to have a single thread handle cache regeneration at any given time.
+ * Other threads will try to use stale values if possible. If, on miss, the time since
+ * expiration is low, the assumption is that the key is hot and that a stampede is worth
+ * avoiding. Setting this above WANObjectCache::HOLDOFF_TTL makes no difference. The
+ * higher this is set, the higher the worst-case staleness can be.
+ * Use WANObjectCache::TSE_NONE to disable this logic.
+ * Default: WANObjectCache::TSE_NONE.
+ * - busyValue: If no value exists and another thread is currently regenerating it, use this
+ * as a fallback value (or a callback to generate such a value). This assures that cache
+ * stampedes cannot happen if the value falls out of cache. This can be used as insurance
+ * against cache regeneration becoming very slow for some reason (greater than the TTL).
+ * Default: null.
+ * - pcTTL: Process cache the value in this PHP instance for this many seconds. This avoids
+ * network I/O when a key is read several times. This will not cache when the callback
+ * returns false, however. Note that any purges will not be seen while process cached;
+ * since the callback should use replica DBs and they may be lagged or have snapshot
+ * isolation anyway, this should not typically matter.
+ * Default: WANObjectCache::TTL_UNCACHEABLE.
+ * - pcGroup: Process cache group to use instead of the primary one. If set, this must be
+ * of the format ALPHANUMERIC_NAME:MAX_KEY_SIZE, e.g. "mydata:10". Use this for storing
+ * large values, small yet numerous values, or some values with a high cost of eviction.
+ * It is generally preferable to use a class constant when setting this value.
+ * This has no effect unless pcTTL is used.
+ * Default: WANObjectCache::PC_PRIMARY.
+ * - version: Integer version number. This allows for callers to make breaking changes to
+ * how values are stored while maintaining compatability and correct cache purges. New
+ * versions are stored alongside older versions concurrently. Avoid storing class objects
+ * however, as this reduces compatibility (due to serialization).
+ * Default: null.
+ * - minAsOf: Reject values if they were generated before this UNIX timestamp.
+ * This is useful if the source of a key is suspected of having possibly changed
+ * recently, and the caller wants any such changes to be reflected.
+ * Default: WANObjectCache::MIN_TIMESTAMP_NONE.
+ * - hotTTR: Expected time-till-refresh for keys that average ~1 hit/second.
+ * This should be greater than "ageNew". Keys with higher hit rates will regenerate
+ * more often. This is useful when a popular key is changed but the cache purge was
+ * delayed or lost. Seldom used keys are rarely affected by this setting, unless an
+ * extremely low "hotTTR" value is passed in.
+ * Default: WANObjectCache::HOT_TTR.
+ * - lowTTL: Consider pre-emptive updates when the current TTL (seconds) of the key is less
+ * than this. It becomes more likely over time, becoming certain once the key is expired.
+ * Default: WANObjectCache::LOW_TTL.
+ * - ageNew: Consider popularity refreshes only once a key reaches this age in seconds.
+ * Default: WANObjectCache::AGE_NEW.
+ * @return mixed Value found or written to the key
+ * @note Options added in 1.28: version, busyValue, hotTTR, ageNew, pcGroup, minAsOf
+ * @note Callable type hints are not used to avoid class-autoloading
+ */
+ final public function getWithSetCallback( $key, $ttl, $callback, array $opts = [] ) {
+ $pcTTL = isset( $opts['pcTTL'] ) ? $opts['pcTTL'] : self::TTL_UNCACHEABLE;
+
+ // Try the process cache if enabled and the cache callback is not within a cache callback.
+ // Process cache use in nested callbacks is not lag-safe with regard to HOLDOFF_TTL since
+ // the in-memory value is further lagged than the shared one since it uses a blind TTL.
+ if ( $pcTTL >= 0 && $this->callbackDepth == 0 ) {
+ $group = isset( $opts['pcGroup'] ) ? $opts['pcGroup'] : self::PC_PRIMARY;
+ $procCache = $this->getProcessCache( $group );
+ $value = $procCache->get( $key );
+ } else {
+ $procCache = false;
+ $value = false;
+ }
+
+ if ( $value === false ) {
+ // Fetch the value over the network
+ if ( isset( $opts['version'] ) ) {
+ $version = $opts['version'];
+ $asOf = null;
+ $cur = $this->doGetWithSetCallback(
+ $key,
+ $ttl,
+ function ( $oldValue, &$ttl, &$setOpts, $oldAsOf )
+ use ( $callback, $version ) {
+ if ( is_array( $oldValue )
+ && array_key_exists( self::VFLD_DATA, $oldValue )
+ ) {
+ $oldData = $oldValue[self::VFLD_DATA];
+ } else {
+ // VFLD_DATA is not set if an old, unversioned, key is present
+ $oldData = false;
+ }
+
+ return [
+ self::VFLD_DATA => $callback( $oldData, $ttl, $setOpts, $oldAsOf ),
+ self::VFLD_VERSION => $version
+ ];
+ },
+ $opts,
+ $asOf
+ );
+ if ( $cur[self::VFLD_VERSION] === $version ) {
+ // Value created or existed before with version; use it
+ $value = $cur[self::VFLD_DATA];
+ } else {
+ // Value existed before with a different version; use variant key.
+ // Reflect purges to $key by requiring that this key value be newer.
+ $value = $this->doGetWithSetCallback(
+ 'cache-variant:' . md5( $key ) . ":$version",
+ $ttl,
+ $callback,
+ // Regenerate value if not newer than $key
+ [ 'version' => null, 'minAsOf' => $asOf ] + $opts
+ );
+ }
+ } else {
+ $value = $this->doGetWithSetCallback( $key, $ttl, $callback, $opts );
+ }
+
+ // Update the process cache if enabled
+ if ( $procCache && $value !== false ) {
+ $procCache->set( $key, $value, $pcTTL );
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * Do the actual I/O for getWithSetCallback() when needed
+ *
+ * @see WANObjectCache::getWithSetCallback()
+ *
+ * @param string $key
+ * @param int $ttl
+ * @param callback $callback
+ * @param array $opts Options map for getWithSetCallback()
+ * @param float &$asOf Cache generation timestamp of returned value [returned]
+ * @return mixed
+ * @note Callable type hints are not used to avoid class-autoloading
+ */
+ protected function doGetWithSetCallback( $key, $ttl, $callback, array $opts, &$asOf = null ) {
+ $lowTTL = isset( $opts['lowTTL'] ) ? $opts['lowTTL'] : min( self::LOW_TTL, $ttl );
+ $lockTSE = isset( $opts['lockTSE'] ) ? $opts['lockTSE'] : self::TSE_NONE;
+ $checkKeys = isset( $opts['checkKeys'] ) ? $opts['checkKeys'] : [];
+ $busyValue = isset( $opts['busyValue'] ) ? $opts['busyValue'] : null;
+ $popWindow = isset( $opts['hotTTR'] ) ? $opts['hotTTR'] : self::HOT_TTR;
+ $ageNew = isset( $opts['ageNew'] ) ? $opts['ageNew'] : self::AGE_NEW;
+ $minTime = isset( $opts['minAsOf'] ) ? $opts['minAsOf'] : self::MIN_TIMESTAMP_NONE;
+ $versioned = isset( $opts['version'] );
+
+ // Get the current key value
+ $curTTL = null;
+ $cValue = $this->get( $key, $curTTL, $checkKeys, $asOf ); // current value
+ $value = $cValue; // return value
+
+ $preCallbackTime = microtime( true );
+ // Determine if a cached value regeneration is needed or desired
+ if ( $value !== false
+ && $curTTL > 0
+ && $this->isValid( $value, $versioned, $asOf, $minTime )
+ && !$this->worthRefreshExpiring( $curTTL, $lowTTL )
+ && !$this->worthRefreshPopular( $asOf, $ageNew, $popWindow, $preCallbackTime )
+ ) {
+ return $value;
+ }
+
+ // A deleted key with a negative TTL left must be tombstoned
+ $isTombstone = ( $curTTL !== null && $value === false );
+ // Assume a key is hot if requested soon after invalidation
+ $isHot = ( $curTTL !== null && $curTTL <= 0 && abs( $curTTL ) <= $lockTSE );
+ // Use the mutex if there is no value and a busy fallback is given
+ $checkBusy = ( $busyValue !== null && $value === false );
+ // Decide whether a single thread should handle regenerations.
+ // This avoids stampedes when $checkKeys are bumped and when preemptive
+ // renegerations take too long. It also reduces regenerations while $key
+ // is tombstoned. This balances cache freshness with avoiding DB load.
+ $useMutex = ( $isHot || ( $isTombstone && $lockTSE > 0 ) || $checkBusy );
+
+ $lockAcquired = false;
+ if ( $useMutex ) {
+ // Acquire a datacenter-local non-blocking lock
+ if ( $this->cache->add( self::MUTEX_KEY_PREFIX . $key, 1, self::LOCK_TTL ) ) {
+ // Lock acquired; this thread should update the key
+ $lockAcquired = true;
+ } elseif ( $value !== false && $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
+ // If it cannot be acquired; then the stale value can be used
+ return $value;
+ } else {
+ // Use the INTERIM value for tombstoned keys to reduce regeneration load.
+ // For hot keys, either another thread has the lock or the lock failed;
+ // use the INTERIM value from the last thread that regenerated it.
+ $wrapped = $this->cache->get( self::INTERIM_KEY_PREFIX . $key );
+ list( $value ) = $this->unwrap( $wrapped, microtime( true ) );
+ if ( $value !== false && $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
+ $asOf = $wrapped[self::FLD_TIME];
+
+ return $value;
+ }
+ // Use the busy fallback value if nothing else
+ if ( $busyValue !== null ) {
+ return is_callable( $busyValue ) ? $busyValue() : $busyValue;
+ }
+ }
+ }
+
+ if ( !is_callable( $callback ) ) {
+ throw new InvalidArgumentException( "Invalid cache miss callback provided." );
+ }
+
+ // Generate the new value from the callback...
+ $setOpts = [];
+ ++$this->callbackDepth;
+ try {
+ $value = call_user_func_array( $callback, [ $cValue, &$ttl, &$setOpts, $asOf ] );
+ } finally {
+ --$this->callbackDepth;
+ }
+ // When delete() is called, writes are write-holed by the tombstone,
+ // so use a special INTERIM key to pass the new value around threads.
+ if ( ( $isTombstone && $lockTSE > 0 ) && $value !== false && $ttl >= 0 ) {
+ $tempTTL = max( 1, (int)$lockTSE ); // set() expects seconds
+ $newAsOf = microtime( true );
+ $wrapped = $this->wrap( $value, $tempTTL, $newAsOf );
+ // Avoid using set() to avoid pointless mcrouter broadcasting
+ $this->cache->merge(
+ self::INTERIM_KEY_PREFIX . $key,
+ function () use ( $wrapped ) {
+ return $wrapped;
+ },
+ $tempTTL,
+ 1
+ );
+ }
+
+ if ( $value !== false && $ttl >= 0 ) {
+ $setOpts['lockTSE'] = $lockTSE;
+ // Use best known "since" timestamp if not provided
+ $setOpts += [ 'since' => $preCallbackTime ];
+ // Update the cache; this will fail if the key is tombstoned
+ $this->set( $key, $value, $ttl, $setOpts );
+ }
+
+ if ( $lockAcquired ) {
+ // Avoid using delete() to avoid pointless mcrouter broadcasting
+ $this->cache->changeTTL( self::MUTEX_KEY_PREFIX . $key, 1 );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Method to fetch multiple cache keys at once with regeneration
+ *
+ * This works the same as getWithSetCallback() except:
+ * - a) The $keys argument expects the result of WANObjectCache::makeMultiKeys()
+ * - b) The $callback argument expects a callback taking the following arguments:
+ * - $id: ID of an entity to query
+ * - $oldValue : the prior cache value or false if none was present
+ * - &$ttl : a reference to the new value TTL in seconds
+ * - &$setOpts : a reference to options for set() which can be altered
+ * - $oldAsOf : generation UNIX timestamp of $oldValue or null if not present
+ * Aside from the additional $id argument, the other arguments function the same
+ * way they do in getWithSetCallback().
+ * - c) The return value is a map of (cache key => value) in the order of $keyedIds
+ *
+ * @see WANObjectCache::getWithSetCallback()
+ * @see WANObjectCache::getMultiWithUnionSetCallback()
+ *
+ * Example usage:
+ * @code
+ * $rows = $cache->getMultiWithSetCallback(
+ * // Map of cache keys to entity IDs
+ * $cache->makeMultiKeys(
+ * $this->fileVersionIds(),
+ * function ( $id, WANObjectCache $cache ) {
+ * return $cache->makeKey( 'file-version', $id );
+ * }
+ * ),
+ * // Time-to-live (in seconds)
+ * $cache::TTL_DAY,
+ * // Function that derives the new key value
+ * function ( $id, $oldValue, &$ttl, array &$setOpts ) {
+ * $dbr = wfGetDB( DB_REPLICA );
+ * // Account for any snapshot/replica DB lag
+ * $setOpts += Database::getCacheSetOptions( $dbr );
+ *
+ * // Load the row for this file
+ * $row = $dbr->selectRow( 'file', '*', [ 'id' => $id ], __METHOD__ );
+ *
+ * return $row ? (array)$row : false;
+ * },
+ * [
+ * // Process cache for 30 seconds
+ * 'pcTTL' => 30,
+ * // Use a dedicated 500 item cache (initialized on-the-fly)
+ * 'pcGroup' => 'file-versions:500'
+ * ]
+ * );
+ * $files = array_map( [ __CLASS__, 'newFromRow' ], $rows );
+ * @endcode
+ *
+ * @param ArrayIterator $keyedIds Result of WANObjectCache::makeMultiKeys()
+ * @param int $ttl Seconds to live for key updates
+ * @param callable $callback Callback the yields entity regeneration callbacks
+ * @param array $opts Options map
+ * @return array Map of (cache key => value) in the same order as $keyedIds
+ * @since 1.28
+ */
+ final public function getMultiWithSetCallback(
+ ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
+ ) {
+ $valueKeys = array_keys( $keyedIds->getArrayCopy() );
+ $checkKeys = isset( $opts['checkKeys'] ) ? $opts['checkKeys'] : [];
+
+ // Load required keys into process cache in one go
+ $this->warmupCache = $this->getRawKeysForWarmup(
+ $this->getNonProcessCachedKeys( $valueKeys, $opts ),
+ $checkKeys
+ );
+ $this->warmupKeyMisses = 0;
+
+ // Wrap $callback to match the getWithSetCallback() format while passing $id to $callback
+ $id = null; // current entity ID
+ $func = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf ) use ( $callback, &$id ) {
+ return $callback( $id, $oldValue, $ttl, $setOpts, $oldAsOf );
+ };
+
+ $values = [];
+ foreach ( $keyedIds as $key => $id ) { // preserve order
+ $values[$key] = $this->getWithSetCallback( $key, $ttl, $func, $opts );
+ }
+
+ $this->warmupCache = [];
+
+ return $values;
+ }
+
+ /**
+ * Method to fetch/regenerate multiple cache keys at once
+ *
+ * This works the same as getWithSetCallback() except:
+ * - a) The $keys argument expects the result of WANObjectCache::makeMultiKeys()
+ * - b) The $callback argument expects a callback returning a map of (ID => new value)
+ * for all entity IDs in $regenById and it takes the following arguments:
+ * - $ids: a list of entity IDs to regenerate
+ * - &$ttls: a reference to the (entity ID => new TTL) map
+ * - &$setOpts: a reference to options for set() which can be altered
+ * - c) The return value is a map of (cache key => value) in the order of $keyedIds
+ * - d) The "lockTSE" and "busyValue" options are ignored
+ *
+ * @see WANObjectCache::getWithSetCallback()
+ * @see WANObjectCache::getMultiWithSetCallback()
+ *
+ * Example usage:
+ * @code
+ * $rows = $cache->getMultiWithUnionSetCallback(
+ * // Map of cache keys to entity IDs
+ * $cache->makeMultiKeys(
+ * $this->fileVersionIds(),
+ * function ( $id, WANObjectCache $cache ) {
+ * return $cache->makeKey( 'file-version', $id );
+ * }
+ * ),
+ * // Time-to-live (in seconds)
+ * $cache::TTL_DAY,
+ * // Function that derives the new key value
+ * function ( array $ids, array &$ttls, array &$setOpts ) {
+ * $dbr = wfGetDB( DB_REPLICA );
+ * // Account for any snapshot/replica DB lag
+ * $setOpts += Database::getCacheSetOptions( $dbr );
+ *
+ * // Load the rows for these files
+ * $rows = [];
+ * $res = $dbr->select( 'file', '*', [ 'id' => $ids ], __METHOD__ );
+ * foreach ( $res as $row ) {
+ * $rows[$row->id] = $row;
+ * $mtime = wfTimestamp( TS_UNIX, $row->timestamp );
+ * $ttls[$row->id] = $this->adaptiveTTL( $mtime, $ttls[$row->id] );
+ * }
+ *
+ * return $rows;
+ * },
+ * ]
+ * );
+ * $files = array_map( [ __CLASS__, 'newFromRow' ], $rows );
+ * @endcode
+ *
+ * @param ArrayIterator $keyedIds Result of WANObjectCache::makeMultiKeys()
+ * @param int $ttl Seconds to live for key updates
+ * @param callable $callback Callback the yields entity regeneration callbacks
+ * @param array $opts Options map
+ * @return array Map of (cache key => value) in the same order as $keyedIds
+ * @since 1.30
+ */
+ final public function getMultiWithUnionSetCallback(
+ ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
+ ) {
+ $idsByValueKey = $keyedIds->getArrayCopy();
+ $valueKeys = array_keys( $idsByValueKey );
+ $checkKeys = isset( $opts['checkKeys'] ) ? $opts['checkKeys'] : [];
+ unset( $opts['lockTSE'] ); // incompatible
+ unset( $opts['busyValue'] ); // incompatible
+
+ // Load required keys into process cache in one go
+ $keysGet = $this->getNonProcessCachedKeys( $valueKeys, $opts );
+ $this->warmupCache = $this->getRawKeysForWarmup( $keysGet, $checkKeys );
+ $this->warmupKeyMisses = 0;
+
+ // IDs of entities known to be in need of regeneration
+ $idsRegen = [];
+
+ // Find out which keys are missing/deleted/stale
+ $curTTLs = [];
+ $asOfs = [];
+ $curByKey = $this->getMulti( $keysGet, $curTTLs, $checkKeys, $asOfs );
+ foreach ( $keysGet as $key ) {
+ if ( !array_key_exists( $key, $curByKey ) || $curTTLs[$key] < 0 ) {
+ $idsRegen[] = $idsByValueKey[$key];
+ }
+ }
+
+ // Run the callback to populate the regeneration value map for all required IDs
+ $newSetOpts = [];
+ $newTTLsById = array_fill_keys( $idsRegen, $ttl );
+ $newValsById = $idsRegen ? $callback( $idsRegen, $newTTLsById, $newSetOpts ) : [];
+
+ // Wrap $callback to match the getWithSetCallback() format while passing $id to $callback
+ $id = null; // current entity ID
+ $func = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf )
+ use ( $callback, &$id, $newValsById, $newTTLsById, $newSetOpts )
+ {
+ if ( array_key_exists( $id, $newValsById ) ) {
+ // Value was already regerated as expected, so use the value in $newValsById
+ $newValue = $newValsById[$id];
+ $ttl = $newTTLsById[$id];
+ $setOpts = $newSetOpts;
+ } else {
+ // Pre-emptive/popularity refresh and version mismatch cases are not detected
+ // above and thus $newValsById has no entry. Run $callback on this single entity.
+ $ttls = [ $id => $ttl ];
+ $newValue = $callback( [ $id ], $ttls, $setOpts )[$id];
+ $ttl = $ttls[$id];
+ }
+
+ return $newValue;
+ };
+
+ // Run the cache-aside logic using warmupCache instead of persistent cache queries
+ $values = [];
+ foreach ( $idsByValueKey as $key => $id ) { // preserve order
+ $values[$key] = $this->getWithSetCallback( $key, $ttl, $func, $opts );
+ }
+
+ $this->warmupCache = [];
+
+ return $values;
+ }
+
+ /**
+ * Locally set a key to expire soon if it is stale based on $purgeTimestamp
+ *
+ * This sets stale keys' time-to-live at HOLDOFF_TTL seconds, which both avoids
+ * broadcasting in mcrouter setups and also avoids races with new tombstones.
+ *
+ * @param string $key Cache key
+ * @param int $purgeTimestamp UNIX timestamp of purge
+ * @param bool &$isStale Whether the key is stale
+ * @return bool Success
+ * @since 1.28
+ */
+ public function reap( $key, $purgeTimestamp, &$isStale = false ) {
+ $minAsOf = $purgeTimestamp + self::HOLDOFF_TTL;
+ $wrapped = $this->cache->get( self::VALUE_KEY_PREFIX . $key );
+ if ( is_array( $wrapped ) && $wrapped[self::FLD_TIME] < $minAsOf ) {
+ $isStale = true;
+ $this->logger->warning( "Reaping stale value key '$key'." );
+ $ttlReap = self::HOLDOFF_TTL; // avoids races with tombstone creation
+ $ok = $this->cache->changeTTL( self::VALUE_KEY_PREFIX . $key, $ttlReap );
+ if ( !$ok ) {
+ $this->logger->error( "Could not complete reap of key '$key'." );
+ }
+
+ return $ok;
+ }
+
+ $isStale = false;
+
+ return true;
+ }
+
+ /**
+ * Locally set a "check" key to expire soon if it is stale based on $purgeTimestamp
+ *
+ * @param string $key Cache key
+ * @param int $purgeTimestamp UNIX timestamp of purge
+ * @param bool &$isStale Whether the key is stale
+ * @return bool Success
+ * @since 1.28
+ */
+ public function reapCheckKey( $key, $purgeTimestamp, &$isStale = false ) {
+ $purge = $this->parsePurgeValue( $this->cache->get( self::TIME_KEY_PREFIX . $key ) );
+ if ( $purge && $purge[self::FLD_TIME] < $purgeTimestamp ) {
+ $isStale = true;
+ $this->logger->warning( "Reaping stale check key '$key'." );
+ $ok = $this->cache->changeTTL( self::TIME_KEY_PREFIX . $key, 1 );
+ if ( !$ok ) {
+ $this->logger->error( "Could not complete reap of check key '$key'." );
+ }
+
+ return $ok;
+ }
+
+ $isStale = false;
+
+ return false;
+ }
+
+ /**
+ * @see BagOStuff::makeKey()
+ * @param string $keys,... Key component
+ * @return string
+ * @since 1.27
+ */
+ public function makeKey() {
+ return call_user_func_array( [ $this->cache, __FUNCTION__ ], func_get_args() );
+ }
+
+ /**
+ * @see BagOStuff::makeGlobalKey()
+ * @param string $keys,... Key component
+ * @return string
+ * @since 1.27
+ */
+ public function makeGlobalKey() {
+ return call_user_func_array( [ $this->cache, __FUNCTION__ ], func_get_args() );
+ }
+
+ /**
+ * @param array $entities List of entity IDs
+ * @param callable $keyFunc Callback yielding a key from (entity ID, this WANObjectCache)
+ * @return ArrayIterator Iterator yielding (cache key => entity ID) in $entities order
+ * @since 1.28
+ */
+ public function makeMultiKeys( array $entities, callable $keyFunc ) {
+ $map = [];
+ foreach ( $entities as $entity ) {
+ $map[$keyFunc( $entity, $this )] = $entity;
+ }
+
+ return new ArrayIterator( $map );
+ }
+
+ /**
+ * Get the "last error" registered; clearLastError() should be called manually
+ * @return int ERR_* class constant for the "last error" registry
+ */
+ final public function getLastError() {
+ if ( $this->lastRelayError ) {
+ // If the cache and the relayer failed, focus on the latter.
+ // An update not making it to the relayer means it won't show up
+ // in other DCs (nor will consistent re-hashing see up-to-date values).
+ // On the other hand, if just the cache update failed, then it should
+ // eventually be applied by the relayer.
+ return $this->lastRelayError;
+ }
+
+ $code = $this->cache->getLastError();
+ switch ( $code ) {
+ case BagOStuff::ERR_NONE:
+ return self::ERR_NONE;
+ case BagOStuff::ERR_NO_RESPONSE:
+ return self::ERR_NO_RESPONSE;
+ case BagOStuff::ERR_UNREACHABLE:
+ return self::ERR_UNREACHABLE;
+ default:
+ return self::ERR_UNEXPECTED;
+ }
+ }
+
+ /**
+ * Clear the "last error" registry
+ */
+ final public function clearLastError() {
+ $this->cache->clearLastError();
+ $this->lastRelayError = self::ERR_NONE;
+ }
+
+ /**
+ * Clear the in-process caches; useful for testing
+ *
+ * @since 1.27
+ */
+ public function clearProcessCache() {
+ $this->processCaches = [];
+ }
+
+ /**
+ * @param int $flag ATTR_* class constant
+ * @return int QOS_* class constant
+ * @since 1.28
+ */
+ public function getQoS( $flag ) {
+ return $this->cache->getQoS( $flag );
+ }
+
+ /**
+ * Get a TTL that is higher for objects that have not changed recently
+ *
+ * This is useful for keys that get explicit purges and DB or purge relay
+ * lag is a potential concern (especially how it interacts with CDN cache)
+ *
+ * Example usage:
+ * @code
+ * // Last-modified time of page
+ * $mtime = wfTimestamp( TS_UNIX, $page->getTimestamp() );
+ * // Get adjusted TTL. If $mtime is 3600 seconds ago and $minTTL/$factor left at
+ * // defaults, then $ttl is 3600 * .2 = 720. If $minTTL was greater than 720, then
+ * // $ttl would be $minTTL. If $maxTTL was smaller than 720, $ttl would be $maxTTL.
+ * $ttl = $cache->adaptiveTTL( $mtime, $cache::TTL_DAY );
+ * @endcode
+ *
+ * @param int|float $mtime UNIX timestamp
+ * @param int $maxTTL Maximum TTL (seconds)
+ * @param int $minTTL Minimum TTL (seconds); Default: 30
+ * @param float $factor Value in the range (0,1); Default: .2
+ * @return int Adaptive TTL
+ * @since 1.28
+ */
+ public function adaptiveTTL( $mtime, $maxTTL, $minTTL = 30, $factor = 0.2 ) {
+ if ( is_float( $mtime ) || ctype_digit( $mtime ) ) {
+ $mtime = (int)$mtime; // handle fractional seconds and string integers
+ }
+
+ if ( !is_int( $mtime ) || $mtime <= 0 ) {
+ return $minTTL; // no last-modified time provided
+ }
+
+ $age = time() - $mtime;
+
+ return (int)min( $maxTTL, max( $minTTL, $factor * $age ) );
+ }
+
+ /**
+ * @return int Number of warmup key cache misses last round
+ * @since 1.30
+ */
+ public function getWarmupKeyMisses() {
+ return $this->warmupKeyMisses;
+ }
+
+ /**
+ * Do the actual async bus purge of a key
+ *
+ * This must set the key to "PURGED:<UNIX timestamp>:<holdoff>"
+ *
+ * @param string $key Cache key
+ * @param int $ttl How long to keep the tombstone [seconds]
+ * @param int $holdoff HOLDOFF_* constant controlling how long to ignore sets for this key
+ * @return bool Success
+ */
+ protected function relayPurge( $key, $ttl, $holdoff ) {
+ if ( $this->purgeRelayer instanceof EventRelayerNull ) {
+ // This handles the mcrouter and the single-DC case
+ $ok = $this->cache->set( $key,
+ $this->makePurgeValue( microtime( true ), self::HOLDOFF_NONE ),
+ $ttl
+ );
+ } else {
+ $event = $this->cache->modifySimpleRelayEvent( [
+ 'cmd' => 'set',
+ 'key' => $key,
+ 'val' => 'PURGED:$UNIXTIME$:' . (int)$holdoff,
+ 'ttl' => max( $ttl, 1 ),
+ 'sbt' => true, // substitute $UNIXTIME$ with actual microtime
+ ] );
+
+ $ok = $this->purgeRelayer->notify( $this->purgeChannel, $event );
+ if ( !$ok ) {
+ $this->lastRelayError = self::ERR_RELAY;
+ }
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Do the actual async bus delete of a key
+ *
+ * @param string $key Cache key
+ * @return bool Success
+ */
+ protected function relayDelete( $key ) {
+ if ( $this->purgeRelayer instanceof EventRelayerNull ) {
+ // This handles the mcrouter and the single-DC case
+ $ok = $this->cache->delete( $key );
+ } else {
+ $event = $this->cache->modifySimpleRelayEvent( [
+ 'cmd' => 'delete',
+ 'key' => $key,
+ ] );
+
+ $ok = $this->purgeRelayer->notify( $this->purgeChannel, $event );
+ if ( !$ok ) {
+ $this->lastRelayError = self::ERR_RELAY;
+ }
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Check if a key should be regenerated (using random probability)
+ *
+ * This returns false if $curTTL >= $lowTTL. Otherwise, the chance
+ * of returning true increases steadily from 0% to 100% as the $curTTL
+ * moves from $lowTTL to 0 seconds. This handles widely varying
+ * levels of cache access traffic.
+ *
+ * @param float $curTTL Approximate TTL left on the key if present
+ * @param float $lowTTL Consider a refresh when $curTTL is less than this
+ * @return bool
+ */
+ protected function worthRefreshExpiring( $curTTL, $lowTTL ) {
+ if ( $curTTL >= $lowTTL ) {
+ return false;
+ } elseif ( $curTTL <= 0 ) {
+ return true;
+ }
+
+ $chance = ( 1 - $curTTL / $lowTTL );
+
+ return mt_rand( 1, 1e9 ) <= 1e9 * $chance;
+ }
+
+ /**
+ * Check if a key is due for randomized regeneration due to its popularity
+ *
+ * This is used so that popular keys can preemptively refresh themselves for higher
+ * consistency (especially in the case of purge loss/delay). Unpopular keys can remain
+ * in cache with their high nominal TTL. This means popular keys keep good consistency,
+ * whether the data changes frequently or not, and long-tail keys get to stay in cache
+ * and get hits too. Similar to worthRefreshExpiring(), randomization is used.
+ *
+ * @param float $asOf UNIX timestamp of the value
+ * @param int $ageNew Age of key when this might recommend refreshing (seconds)
+ * @param int $timeTillRefresh Age of key when it should be refreshed if popular (seconds)
+ * @param float $now The current UNIX timestamp
+ * @return bool
+ */
+ protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) {
+ $age = $now - $asOf;
+ $timeOld = $age - $ageNew;
+ if ( $timeOld <= 0 ) {
+ return false;
+ }
+
+ // Lifecycle is: new, ramp-up refresh chance, full refresh chance.
+ // Note that the "expected # of refreshes" for the ramp-up time range is half of what it
+ // would be if P(refresh) was at its full value during that time range.
+ $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::RAMPUP_TTL / 2, 1 );
+ // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes)
+ // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1
+ // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec)
+ $chance = 1 / ( self::HIT_RATE_HIGH * $refreshWindowSec );
+
+ // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes
+ $chance *= ( $timeOld <= self::RAMPUP_TTL ) ? $timeOld / self::RAMPUP_TTL : 1;
+
+ return mt_rand( 1, 1e9 ) <= 1e9 * $chance;
+ }
+
+ /**
+ * Check whether $value is appropriately versioned and not older than $minTime (if set)
+ *
+ * @param array $value
+ * @param bool $versioned
+ * @param float $asOf The time $value was generated
+ * @param float $minTime The last time the main value was generated (0.0 if unknown)
+ * @return bool
+ */
+ protected function isValid( $value, $versioned, $asOf, $minTime ) {
+ if ( $versioned && !isset( $value[self::VFLD_VERSION] ) ) {
+ return false;
+ } elseif ( $minTime > 0 && $asOf < $minTime ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Do not use this method outside WANObjectCache
+ *
+ * @param mixed $value
+ * @param int $ttl [0=forever]
+ * @param float $now Unix Current timestamp just before calling set()
+ * @return array
+ */
+ protected function wrap( $value, $ttl, $now ) {
+ return [
+ self::FLD_VERSION => self::VERSION,
+ self::FLD_VALUE => $value,
+ self::FLD_TTL => $ttl,
+ self::FLD_TIME => $now
+ ];
+ }
+
+ /**
+ * Do not use this method outside WANObjectCache
+ *
+ * @param array|string|bool $wrapped
+ * @param float $now Unix Current timestamp (preferrably pre-query)
+ * @return array (mixed; false if absent/tombstoned/invalid, current time left)
+ */
+ protected function unwrap( $wrapped, $now ) {
+ // Check if the value is a tombstone
+ $purge = self::parsePurgeValue( $wrapped );
+ if ( $purge !== false ) {
+ // Purged values should always have a negative current $ttl
+ $curTTL = min( $purge[self::FLD_TIME] - $now, self::TINY_NEGATIVE );
+ return [ false, $curTTL ];
+ }
+
+ if ( !is_array( $wrapped ) // not found
+ || !isset( $wrapped[self::FLD_VERSION] ) // wrong format
+ || $wrapped[self::FLD_VERSION] !== self::VERSION // wrong version
+ ) {
+ return [ false, null ];
+ }
+
+ $flags = isset( $wrapped[self::FLD_FLAGS] ) ? $wrapped[self::FLD_FLAGS] : 0;
+ if ( ( $flags & self::FLG_STALE ) == self::FLG_STALE ) {
+ // Treat as expired, with the cache time as the expiration
+ $age = $now - $wrapped[self::FLD_TIME];
+ $curTTL = min( -$age, self::TINY_NEGATIVE );
+ } elseif ( $wrapped[self::FLD_TTL] > 0 ) {
+ // Get the approximate time left on the key
+ $age = $now - $wrapped[self::FLD_TIME];
+ $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 );
+ } else {
+ // Key had no TTL, so the time left is unbounded
+ $curTTL = INF;
+ }
+
+ return [ $wrapped[self::FLD_VALUE], $curTTL ];
+ }
+
+ /**
+ * @param array $keys
+ * @param string $prefix
+ * @return string[]
+ */
+ protected static function prefixCacheKeys( array $keys, $prefix ) {
+ $res = [];
+ foreach ( $keys as $key ) {
+ $res[] = $prefix . $key;
+ }
+
+ return $res;
+ }
+
+ /**
+ * @param string $value Wrapped value like "PURGED:<timestamp>:<holdoff>"
+ * @return array|bool Array containing a UNIX timestamp (float) and holdoff period (integer),
+ * or false if value isn't a valid purge value
+ */
+ protected static function parsePurgeValue( $value ) {
+ if ( !is_string( $value ) ) {
+ return false;
+ }
+ $segments = explode( ':', $value, 3 );
+ if ( !isset( $segments[0] ) || !isset( $segments[1] )
+ || "{$segments[0]}:" !== self::PURGE_VAL_PREFIX
+ ) {
+ return false;
+ }
+ if ( !isset( $segments[2] ) ) {
+ // Back-compat with old purge values without holdoff
+ $segments[2] = self::HOLDOFF_TTL;
+ }
+ return [
+ self::FLD_TIME => (float)$segments[1],
+ self::FLD_HOLDOFF => (int)$segments[2],
+ ];
+ }
+
+ /**
+ * @param float $timestamp
+ * @param int $holdoff In seconds
+ * @return string Wrapped purge value
+ */
+ protected function makePurgeValue( $timestamp, $holdoff ) {
+ return self::PURGE_VAL_PREFIX . (float)$timestamp . ':' . (int)$holdoff;
+ }
+
+ /**
+ * @param string $group
+ * @return HashBagOStuff
+ */
+ protected function getProcessCache( $group ) {
+ if ( !isset( $this->processCaches[$group] ) ) {
+ list( , $n ) = explode( ':', $group );
+ $this->processCaches[$group] = new HashBagOStuff( [ 'maxKeys' => (int)$n ] );
+ }
+
+ return $this->processCaches[$group];
+ }
+
+ /**
+ * @param array $keys
+ * @param array $opts
+ * @return array List of keys
+ */
+ private function getNonProcessCachedKeys( array $keys, array $opts ) {
+ $keysFound = [];
+ if ( isset( $opts['pcTTL'] ) && $opts['pcTTL'] > 0 && $this->callbackDepth == 0 ) {
+ $pcGroup = isset( $opts['pcGroup'] ) ? $opts['pcGroup'] : self::PC_PRIMARY;
+ $procCache = $this->getProcessCache( $pcGroup );
+ foreach ( $keys as $key ) {
+ if ( $procCache->get( $key ) !== false ) {
+ $keysFound[] = $key;
+ }
+ }
+ }
+
+ return array_diff( $keys, $keysFound );
+ }
+
+ /**
+ * @param array $keys
+ * @param array $checkKeys
+ * @return array Map of (cache key => mixed)
+ */
+ private function getRawKeysForWarmup( array $keys, array $checkKeys ) {
+ if ( !$keys ) {
+ return [];
+ }
+
+ $keysWarmUp = [];
+ // Get all the value keys to fetch...
+ foreach ( $keys as $key ) {
+ $keysWarmUp[] = self::VALUE_KEY_PREFIX . $key;
+ }
+ // Get all the check keys to fetch...
+ foreach ( $checkKeys as $i => $checkKeyOrKeys ) {
+ if ( is_int( $i ) ) {
+ // Single check key that applies to all value keys
+ $keysWarmUp[] = self::TIME_KEY_PREFIX . $checkKeyOrKeys;
+ } else {
+ // List of check keys that apply to value key $i
+ $keysWarmUp = array_merge(
+ $keysWarmUp,
+ self::prefixCacheKeys( $checkKeyOrKeys, self::TIME_KEY_PREFIX )
+ );
+ }
+ }
+
+ $warmupCache = $this->cache->getMulti( $keysWarmUp );
+ $warmupCache += array_fill_keys( $keysWarmUp, false );
+
+ return $warmupCache;
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/WANObjectCacheReaper.php b/www/wiki/includes/libs/objectcache/WANObjectCacheReaper.php
new file mode 100644
index 00000000..14737b14
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/WANObjectCacheReaper.php
@@ -0,0 +1,204 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Class for scanning through chronological, log-structured data or change logs
+ * and locally purging cache keys related to entities that appear in this data.
+ *
+ * This is useful for repairing cache when purges are missed by using a reliable
+ * stream, such as Kafka or a replicated MySQL table. Purge loss between datacenters
+ * is expected to be more common than within them.
+ *
+ * @since 1.28
+ */
+class WANObjectCacheReaper implements LoggerAwareInterface {
+ /** @var WANObjectCache */
+ protected $cache;
+ /** @var BagOStuff */
+ protected $store;
+ /** @var callable */
+ protected $logChunkCallback;
+ /** @var callable */
+ protected $keyListCallback;
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var string */
+ protected $channel;
+ /** @var int */
+ protected $initialStartWindow;
+
+ /**
+ * @param WANObjectCache $cache Cache to reap bad keys from
+ * @param BagOStuff $store Cache to store positions use for locking
+ * @param callable $logCallback Callback taking arguments:
+ * - The starting position as a UNIX timestamp
+ * - The starting unique ID used for breaking timestamp collisions or null
+ * - The ending position as a UNIX timestamp
+ * - The maximum number of results to return
+ * It returns a list of maps of (key: cache key, pos: UNIX timestamp, id: unique ID)
+ * for each key affected, with the corrosponding event timestamp/ID information.
+ * The events should be in ascending order, by (timestamp,id).
+ * @param callable $keyCallback Callback taking arguments:
+ * - The WANObjectCache instance
+ * - An object from the event log
+ * It should return a list of WAN cache keys.
+ * The callback must fully duck-type test the object, since can be any model class.
+ * @param array $params Additional options:
+ * - channel: the name of the update event stream.
+ * Default: WANObjectCache::DEFAULT_PURGE_CHANNEL.
+ * - initialStartWindow: seconds back in time to start if the position is lost.
+ * Default: 1 hour.
+ * - logger: an SPL monolog instance [optional]
+ */
+ public function __construct(
+ WANObjectCache $cache,
+ BagOStuff $store,
+ callable $logCallback,
+ callable $keyCallback,
+ array $params
+ ) {
+ $this->cache = $cache;
+ $this->store = $store;
+
+ $this->logChunkCallback = $logCallback;
+ $this->keyListCallback = $keyCallback;
+ if ( isset( $params['channel'] ) ) {
+ $this->channel = $params['channel'];
+ } else {
+ throw new UnexpectedValueException( "No channel specified." );
+ }
+
+ $this->initialStartWindow = isset( $params['initialStartWindow'] )
+ ? $params['initialStartWindow']
+ : 3600;
+ $this->logger = isset( $params['logger'] )
+ ? $params['logger']
+ : new NullLogger();
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Check and reap stale keys based on a chunk of events
+ *
+ * @param int $n Number of events
+ * @return int Number of keys checked
+ */
+ final public function invoke( $n = 100 ) {
+ $posKey = $this->store->makeGlobalKey( 'WANCache', 'reaper', $this->channel );
+ $scopeLock = $this->store->getScopedLock( "$posKey:busy", 0 );
+ if ( !$scopeLock ) {
+ return 0;
+ }
+
+ $now = time();
+ $status = $this->store->get( $posKey );
+ if ( !$status ) {
+ $status = [ 'pos' => $now - $this->initialStartWindow, 'id' => null ];
+ }
+
+ // Get events for entities who's keys tombstones/hold-off should have expired by now
+ $events = call_user_func_array(
+ $this->logChunkCallback,
+ [ $status['pos'], $status['id'], $now - WANObjectCache::HOLDOFF_TTL - 1, $n ]
+ );
+
+ $event = null;
+ $keyEvents = [];
+ foreach ( $events as $event ) {
+ $keys = call_user_func_array(
+ $this->keyListCallback,
+ [ $this->cache, $event['item'] ]
+ );
+ foreach ( $keys as $key ) {
+ unset( $keyEvents[$key] ); // use only the latest per key
+ $keyEvents[$key] = [
+ 'pos' => $event['pos'],
+ 'id' => $event['id']
+ ];
+ }
+ }
+
+ $purgeCount = 0;
+ $lastOkEvent = null;
+ foreach ( $keyEvents as $key => $keyEvent ) {
+ if ( !$this->cache->reap( $key, $keyEvent['pos'] ) ) {
+ break;
+ }
+ ++$purgeCount;
+ $lastOkEvent = $event;
+ }
+
+ if ( $lastOkEvent ) {
+ $ok = $this->store->merge(
+ $posKey,
+ function ( $bag, $key, $curValue ) use ( $lastOkEvent ) {
+ if ( !$curValue ) {
+ // Use new position
+ } else {
+ $curCoord = [ $curValue['pos'], $curValue['id'] ];
+ $newCoord = [ $lastOkEvent['pos'], $lastOkEvent['id'] ];
+ if ( $newCoord < $curCoord ) {
+ // Keep prior position instead of rolling it back
+ return $curValue;
+ }
+ }
+
+ return [
+ 'pos' => $lastOkEvent['pos'],
+ 'id' => $lastOkEvent['id'],
+ 'ctime' => $curValue ? $curValue['ctime'] : date( 'c' )
+ ];
+ },
+ IExpiringStore::TTL_INDEFINITE
+ );
+
+ $pos = $lastOkEvent['pos'];
+ $id = $lastOkEvent['id'];
+ if ( $ok ) {
+ $this->logger->info( "Updated cache reap position ($pos, $id)." );
+ } else {
+ $this->logger->error( "Could not update cache reap position ($pos, $id)." );
+ }
+ }
+
+ ScopedCallback::consume( $scopeLock );
+
+ return $purgeCount;
+ }
+
+ /**
+ * @return array|bool Returns (pos, id) map or false if not set
+ */
+ public function getState() {
+ $posKey = $this->store->makeGlobalKey( 'WANCache', 'reaper', $this->channel );
+
+ return $this->store->get( $posKey );
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/WinCacheBagOStuff.php b/www/wiki/includes/libs/objectcache/WinCacheBagOStuff.php
new file mode 100644
index 00000000..98f44d11
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/WinCacheBagOStuff.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Object caching using WinCache.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * Wrapper for WinCache object caching functions; identical interface
+ * to the APC wrapper
+ *
+ * @ingroup Cache
+ */
+class WinCacheBagOStuff extends BagOStuff {
+ protected function doGet( $key, $flags = 0 ) {
+ $val = wincache_ucache_get( $key );
+ if ( is_string( $val ) ) {
+ $val = unserialize( $val );
+ }
+
+ return $val;
+ }
+
+ public function set( $key, $value, $expire = 0, $flags = 0 ) {
+ $result = wincache_ucache_set( $key, serialize( $value ), $expire );
+
+ /* wincache_ucache_set returns an empty array on success if $value
+ * was an array, bool otherwise */
+ return ( is_array( $result ) && $result === [] ) || $result;
+ }
+
+ public function delete( $key ) {
+ wincache_ucache_delete( $key );
+
+ return true;
+ }
+
+ public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
+ if ( wincache_lock( $key ) ) { // optimize with FIFO lock
+ $ok = $this->mergeViaLock( $key, $callback, $exptime, $attempts, $flags );
+ wincache_unlock( $key );
+ } else {
+ $ok = false;
+ }
+
+ return $ok;
+ }
+}
diff --git a/www/wiki/includes/libs/objectcache/XCacheBagOStuff.php b/www/wiki/includes/libs/objectcache/XCacheBagOStuff.php
new file mode 100644
index 00000000..47c29064
--- /dev/null
+++ b/www/wiki/includes/libs/objectcache/XCacheBagOStuff.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Object caching using XCache.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * Wrapper for XCache object caching functions; identical interface
+ * to the APC wrapper
+ *
+ * @ingroup Cache
+ */
+class XCacheBagOStuff extends BagOStuff {
+ protected function doGet( $key, $flags = 0 ) {
+ $val = xcache_get( $key );
+
+ if ( is_string( $val ) ) {
+ if ( $this->isInteger( $val ) ) {
+ $val = intval( $val );
+ } else {
+ $val = unserialize( $val );
+ }
+ } elseif ( is_null( $val ) ) {
+ return false;
+ }
+
+ return $val;
+ }
+
+ public function set( $key, $value, $expire = 0, $flags = 0 ) {
+ if ( !$this->isInteger( $value ) ) {
+ $value = serialize( $value );
+ }
+
+ xcache_set( $key, $value, $expire );
+ return true;
+ }
+
+ public function delete( $key ) {
+ xcache_unset( $key );
+ return true;
+ }
+
+ public function incr( $key, $value = 1 ) {
+ return xcache_inc( $key, $value );
+ }
+
+ public function decr( $key, $value = 1 ) {
+ return xcache_dec( $key, $value );
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/ChronologyProtector.php b/www/wiki/includes/libs/rdbms/ChronologyProtector.php
new file mode 100644
index 00000000..8121654e
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/ChronologyProtector.php
@@ -0,0 +1,337 @@
+<?php
+/**
+ * Generator of database load balancing objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\WaitConditionLoop;
+use BagOStuff;
+
+/**
+ * Class for ensuring a consistent ordering of events as seen by the user, despite replication.
+ * Kind of like Hawking's [[Chronology Protection Agency]].
+ */
+class ChronologyProtector implements LoggerAwareInterface {
+ /** @var BagOStuff */
+ protected $store;
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var string Storage key name */
+ protected $key;
+ /** @var string Hash of client parameters */
+ protected $clientId;
+ /** @var float|null Minimum UNIX timestamp of 1+ expected startup positions */
+ protected $waitForPosTime;
+ /** @var int Max seconds to wait on positions to appear */
+ protected $waitForPosTimeout = self::POS_WAIT_TIMEOUT;
+ /** @var bool Whether to no-op all method calls */
+ protected $enabled = true;
+ /** @var bool Whether to check and wait on positions */
+ protected $wait = true;
+
+ /** @var bool Whether the client data was loaded */
+ protected $initialized = false;
+ /** @var DBMasterPos[] Map of (DB master name => position) */
+ protected $startupPositions = [];
+ /** @var DBMasterPos[] Map of (DB master name => position) */
+ protected $shutdownPositions = [];
+ /** @var float[] Map of (DB master name => 1) */
+ protected $shutdownTouchDBs = [];
+
+ /** @var int Seconds to store positions */
+ const POSITION_TTL = 60;
+ /** @var int Max time to wait for positions to appear */
+ const POS_WAIT_TIMEOUT = 5;
+
+ /**
+ * @param BagOStuff $store
+ * @param array $client Map of (ip: <IP>, agent: <user-agent>)
+ * @param float $posTime UNIX timestamp
+ * @since 1.27
+ */
+ public function __construct( BagOStuff $store, array $client, $posTime = null ) {
+ $this->store = $store;
+ $this->clientId = md5( $client['ip'] . "\n" . $client['agent'] );
+ $this->key = $store->makeGlobalKey( __CLASS__, $this->clientId, 'v1' );
+ $this->waitForPosTime = $posTime;
+ $this->logger = new NullLogger();
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param bool $enabled Whether to no-op all method calls
+ * @since 1.27
+ */
+ public function setEnabled( $enabled ) {
+ $this->enabled = $enabled;
+ }
+
+ /**
+ * @param bool $enabled Whether to check and wait on positions
+ * @since 1.27
+ */
+ public function setWaitEnabled( $enabled ) {
+ $this->wait = $enabled;
+ }
+
+ /**
+ * Initialise a ILoadBalancer to give it appropriate chronology protection.
+ *
+ * If the stash has a previous master position recorded, this will try to
+ * make sure that the next query to a replica DB of that master will see changes up
+ * to that position by delaying execution. The delay may timeout and allow stale
+ * data if no non-lagged replica DBs are available.
+ *
+ * @param ILoadBalancer $lb
+ * @return void
+ */
+ public function initLB( ILoadBalancer $lb ) {
+ if ( !$this->enabled || $lb->getServerCount() <= 1 ) {
+ return; // non-replicated setup or disabled
+ }
+
+ $this->initPositions();
+
+ $masterName = $lb->getServerName( $lb->getWriterIndex() );
+ if (
+ isset( $this->startupPositions[$masterName] ) &&
+ $this->startupPositions[$masterName] instanceof DBMasterPos
+ ) {
+ $pos = $this->startupPositions[$masterName];
+ $this->logger->info( __METHOD__ . ": LB for '$masterName' set to pos $pos\n" );
+ $lb->waitFor( $pos );
+ }
+ }
+
+ /**
+ * Notify the ChronologyProtector that the ILoadBalancer is about to shut
+ * down. Saves replication positions.
+ *
+ * @param ILoadBalancer $lb
+ * @return void
+ */
+ public function shutdownLB( ILoadBalancer $lb ) {
+ if ( !$this->enabled ) {
+ return; // not enabled
+ } elseif ( !$lb->hasOrMadeRecentMasterChanges( INF ) ) {
+ // Only save the position if writes have been done on the connection
+ return;
+ }
+
+ $masterName = $lb->getServerName( $lb->getWriterIndex() );
+ if ( $lb->getServerCount() > 1 ) {
+ $pos = $lb->getMasterPos();
+ $this->logger->info( __METHOD__ . ": LB for '$masterName' has pos $pos\n" );
+ $this->shutdownPositions[$masterName] = $pos;
+ } else {
+ $this->logger->info( __METHOD__ . ": DB '$masterName' touched\n" );
+ }
+ $this->shutdownTouchDBs[$masterName] = 1;
+ }
+
+ /**
+ * Notify the ChronologyProtector that the LBFactory is done calling shutdownLB() for now.
+ * May commit chronology data to persistent storage.
+ *
+ * @param callable|null $workCallback Work to do instead of waiting on syncing positions
+ * @param string $mode One of (sync, async); whether to wait on remote datacenters
+ * @return DBMasterPos[] Empty on success; returns the (db name => position) map on failure
+ */
+ public function shutdown( callable $workCallback = null, $mode = 'sync' ) {
+ if ( !$this->enabled ) {
+ return [];
+ }
+
+ $store = $this->store;
+ // Some callers might want to know if a user recently touched a DB.
+ // These writes do not need to block on all datacenters receiving them.
+ foreach ( $this->shutdownTouchDBs as $dbName => $unused ) {
+ $store->set(
+ $this->getTouchedKey( $this->store, $dbName ),
+ microtime( true ),
+ $store::TTL_DAY
+ );
+ }
+
+ if ( !count( $this->shutdownPositions ) ) {
+ return []; // nothing to save
+ }
+
+ $this->logger->info( __METHOD__ . ": saving master pos for " .
+ implode( ', ', array_keys( $this->shutdownPositions ) ) . "\n"
+ );
+
+ // CP-protected writes should overwhemingly go to the master datacenter, so get DC-local
+ // lock to merge the values. Use a DC-local get() and a synchronous all-DC set(). This
+ // makes it possible for the BagOStuff class to write in parallel to all DCs with one RTT.
+ if ( $store->lock( $this->key, 3 ) ) {
+ if ( $workCallback ) {
+ // Let the store run the work before blocking on a replication sync barrier. By the
+ // time it's done with the work, the barrier should be fast if replication caught up.
+ $store->addBusyCallback( $workCallback );
+ }
+ $ok = $store->set(
+ $this->key,
+ self::mergePositions( $store->get( $this->key ), $this->shutdownPositions ),
+ self::POSITION_TTL,
+ ( $mode === 'sync' ) ? $store::WRITE_SYNC : 0
+ );
+ $store->unlock( $this->key );
+ } else {
+ $ok = false;
+ }
+
+ if ( !$ok ) {
+ $bouncedPositions = $this->shutdownPositions;
+ // Raced out too many times or stash is down
+ $this->logger->warning( __METHOD__ . ": failed to save master pos for " .
+ implode( ', ', array_keys( $this->shutdownPositions ) ) . "\n"
+ );
+ } elseif ( $mode === 'sync' &&
+ $store->getQoS( $store::ATTR_SYNCWRITES ) < $store::QOS_SYNCWRITES_BE
+ ) {
+ // Positions may not be in all datacenters, force LBFactory to play it safe
+ $this->logger->info( __METHOD__ . ": store may not support synchronous writes." );
+ $bouncedPositions = $this->shutdownPositions;
+ } else {
+ $bouncedPositions = [];
+ }
+
+ return $bouncedPositions;
+ }
+
+ /**
+ * @param string $dbName DB master name (e.g. "db1052")
+ * @return float|bool UNIX timestamp when client last touched the DB; false if not on record
+ * @since 1.28
+ */
+ public function getTouched( $dbName ) {
+ return $this->store->get( $this->getTouchedKey( $this->store, $dbName ) );
+ }
+
+ /**
+ * @param BagOStuff $store
+ * @param string $dbName
+ * @return string
+ */
+ private function getTouchedKey( BagOStuff $store, $dbName ) {
+ return $store->makeGlobalKey( __CLASS__, 'mtime', $this->clientId, $dbName );
+ }
+
+ /**
+ * Load in previous master positions for the client
+ */
+ protected function initPositions() {
+ if ( $this->initialized ) {
+ return;
+ }
+
+ $this->initialized = true;
+ if ( $this->wait ) {
+ // If there is an expectation to see master positions with a certain min
+ // timestamp, then block until they appear, or until a timeout is reached.
+ if ( $this->waitForPosTime > 0.0 ) {
+ $data = null;
+ $loop = new WaitConditionLoop(
+ function () use ( &$data ) {
+ $data = $this->store->get( $this->key );
+
+ return ( self::minPosTime( $data ) >= $this->waitForPosTime )
+ ? WaitConditionLoop::CONDITION_REACHED
+ : WaitConditionLoop::CONDITION_CONTINUE;
+ },
+ $this->waitForPosTimeout
+ );
+ $result = $loop->invoke();
+ $waitedMs = $loop->getLastWaitTime() * 1e3;
+
+ if ( $result == $loop::CONDITION_REACHED ) {
+ $msg = "expected and found pos time {$this->waitForPosTime} ({$waitedMs}ms)";
+ $this->logger->debug( $msg );
+ } else {
+ $msg = "expected but missed pos time {$this->waitForPosTime} ({$waitedMs}ms)";
+ $this->logger->info( $msg );
+ }
+ } else {
+ $data = $this->store->get( $this->key );
+ }
+
+ $this->startupPositions = $data ? $data['positions'] : [];
+ $this->logger->info( __METHOD__ . ": key is {$this->key} (read)\n" );
+ } else {
+ $this->startupPositions = [];
+ $this->logger->info( __METHOD__ . ": key is {$this->key} (unread)\n" );
+ }
+ }
+
+ /**
+ * @param array|bool $data
+ * @return float|null
+ */
+ private static function minPosTime( $data ) {
+ if ( !isset( $data['positions'] ) ) {
+ return null;
+ }
+
+ $min = null;
+ foreach ( $data['positions'] as $pos ) {
+ if ( $pos instanceof DBMasterPos ) {
+ $min = $min ? min( $pos->asOfTime(), $min ) : $pos->asOfTime();
+ }
+ }
+
+ return $min;
+ }
+
+ /**
+ * @param array|bool $curValue
+ * @param DBMasterPos[] $shutdownPositions
+ * @return array
+ */
+ private static function mergePositions( $curValue, array $shutdownPositions ) {
+ /** @var DBMasterPos[] $curPositions */
+ if ( $curValue === false ) {
+ $curPositions = $shutdownPositions;
+ } else {
+ $curPositions = $curValue['positions'];
+ // Use the newest positions for each DB master
+ foreach ( $shutdownPositions as $db => $pos ) {
+ if (
+ !isset( $curPositions[$db] ) ||
+ !( $curPositions[$db] instanceof DBMasterPos ) ||
+ $pos->asOfTime() > $curPositions[$db]->asOfTime()
+ ) {
+ $curPositions[$db] = $pos;
+ }
+ }
+ }
+
+ return [ 'positions' => $curPositions ];
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/TransactionProfiler.php b/www/wiki/includes/libs/rdbms/TransactionProfiler.php
new file mode 100644
index 00000000..57a12a44
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/TransactionProfiler.php
@@ -0,0 +1,351 @@
+<?php
+/**
+ * Transaction profiling for contention
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\NullLogger;
+use RuntimeException;
+
+/**
+ * Helper class that detects high-contention DB queries via profiling calls
+ *
+ * This class is meant to work with an IDatabase object, which manages queries
+ *
+ * @since 1.24
+ */
+class TransactionProfiler implements LoggerAwareInterface {
+ /** @var float Seconds */
+ protected $dbLockThreshold = 3.0;
+ /** @var float Seconds */
+ protected $eventThreshold = 0.25;
+ /** @var bool */
+ protected $silenced = false;
+
+ /** @var array transaction ID => (write start time, list of DBs involved) */
+ protected $dbTrxHoldingLocks = [];
+ /** @var array transaction ID => list of (query name, start time, end time) */
+ protected $dbTrxMethodTimes = [];
+
+ /** @var array */
+ protected $hits = [
+ 'writes' => 0,
+ 'queries' => 0,
+ 'conns' => 0,
+ 'masterConns' => 0
+ ];
+ /** @var array */
+ protected $expect = [
+ 'writes' => INF,
+ 'queries' => INF,
+ 'conns' => INF,
+ 'masterConns' => INF,
+ 'maxAffected' => INF,
+ 'readQueryTime' => INF,
+ 'writeQueryTime' => INF
+ ];
+ /** @var array */
+ protected $expectBy = [];
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ public function __construct() {
+ $this->setLogger( new NullLogger() );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param bool $value New value
+ * @return bool Old value
+ * @since 1.28
+ */
+ public function setSilenced( $value ) {
+ $old = $this->silenced;
+ $this->silenced = $value;
+
+ return $old;
+ }
+
+ /**
+ * Set performance expectations
+ *
+ * With conflicting expectations, the most narrow ones will be used
+ *
+ * @param string $event (writes,queries,conns,mConns)
+ * @param int $value Maximum count of the event
+ * @param string $fname Caller
+ * @since 1.25
+ */
+ public function setExpectation( $event, $value, $fname ) {
+ $this->expect[$event] = isset( $this->expect[$event] )
+ ? min( $this->expect[$event], $value )
+ : $value;
+ if ( $this->expect[$event] == $value ) {
+ $this->expectBy[$event] = $fname;
+ }
+ }
+
+ /**
+ * Set multiple performance expectations
+ *
+ * With conflicting expectations, the most narrow ones will be used
+ *
+ * @param array $expects Map of (event => limit)
+ * @param string $fname
+ * @since 1.26
+ */
+ public function setExpectations( array $expects, $fname ) {
+ foreach ( $expects as $event => $value ) {
+ $this->setExpectation( $event, $value, $fname );
+ }
+ }
+
+ /**
+ * Reset performance expectations and hit counters
+ *
+ * @since 1.25
+ */
+ public function resetExpectations() {
+ foreach ( $this->hits as &$val ) {
+ $val = 0;
+ }
+ unset( $val );
+ foreach ( $this->expect as &$val ) {
+ $val = INF;
+ }
+ unset( $val );
+ $this->expectBy = [];
+ }
+
+ /**
+ * Mark a DB as having been connected to with a new handle
+ *
+ * Note that there can be multiple connections to a single DB.
+ *
+ * @param string $server DB server
+ * @param string $db DB name
+ * @param bool $isMaster
+ */
+ public function recordConnection( $server, $db, $isMaster ) {
+ // Report when too many connections happen...
+ if ( $this->hits['conns']++ >= $this->expect['conns'] ) {
+ $this->reportExpectationViolated(
+ 'conns', "[connect to $server ($db)]", $this->hits['conns'] );
+ }
+ if ( $isMaster && $this->hits['masterConns']++ >= $this->expect['masterConns'] ) {
+ $this->reportExpectationViolated(
+ 'masterConns', "[connect to $server ($db)]", $this->hits['masterConns'] );
+ }
+ }
+
+ /**
+ * Mark a DB as in a transaction with one or more writes pending
+ *
+ * Note that there can be multiple connections to a single DB.
+ *
+ * @param string $server DB server
+ * @param string $db DB name
+ * @param string $id ID string of transaction
+ */
+ public function transactionWritingIn( $server, $db, $id ) {
+ $name = "{$server} ({$db}) (TRX#$id)";
+ if ( isset( $this->dbTrxHoldingLocks[$name] ) ) {
+ $this->logger->info( "Nested transaction for '$name' - out of sync." );
+ }
+ $this->dbTrxHoldingLocks[$name] = [
+ 'start' => microtime( true ),
+ 'conns' => [], // all connections involved
+ ];
+ $this->dbTrxMethodTimes[$name] = [];
+
+ foreach ( $this->dbTrxHoldingLocks as $name => &$info ) {
+ // Track all DBs in transactions for this transaction
+ $info['conns'][$name] = 1;
+ }
+ }
+
+ /**
+ * Register the name and time of a method for slow DB trx detection
+ *
+ * This assumes that all queries are synchronous (non-overlapping)
+ *
+ * @param string $query Function name or generalized SQL
+ * @param float $sTime Starting UNIX wall time
+ * @param bool $isWrite Whether this is a write query
+ * @param int $n Number of affected rows
+ */
+ public function recordQueryCompletion( $query, $sTime, $isWrite = false, $n = 0 ) {
+ $eTime = microtime( true );
+ $elapsed = ( $eTime - $sTime );
+
+ if ( $isWrite && $n > $this->expect['maxAffected'] ) {
+ $this->logger->info(
+ "Query affected $n row(s):\n" . $query . "\n" .
+ ( new RuntimeException() )->getTraceAsString() );
+ }
+
+ // Report when too many writes/queries happen...
+ if ( $this->hits['queries']++ >= $this->expect['queries'] ) {
+ $this->reportExpectationViolated( 'queries', $query, $this->hits['queries'] );
+ }
+ if ( $isWrite && $this->hits['writes']++ >= $this->expect['writes'] ) {
+ $this->reportExpectationViolated( 'writes', $query, $this->hits['writes'] );
+ }
+ // Report slow queries...
+ if ( !$isWrite && $elapsed > $this->expect['readQueryTime'] ) {
+ $this->reportExpectationViolated( 'readQueryTime', $query, $elapsed );
+ }
+ if ( $isWrite && $elapsed > $this->expect['writeQueryTime'] ) {
+ $this->reportExpectationViolated( 'writeQueryTime', $query, $elapsed );
+ }
+
+ if ( !$this->dbTrxHoldingLocks ) {
+ // Short-circuit
+ return;
+ } elseif ( !$isWrite && $elapsed < $this->eventThreshold ) {
+ // Not an important query nor slow enough
+ return;
+ }
+
+ foreach ( $this->dbTrxHoldingLocks as $name => $info ) {
+ $lastQuery = end( $this->dbTrxMethodTimes[$name] );
+ if ( $lastQuery ) {
+ // Additional query in the trx...
+ $lastEnd = $lastQuery[2];
+ if ( $sTime >= $lastEnd ) { // sanity check
+ if ( ( $sTime - $lastEnd ) > $this->eventThreshold ) {
+ // Add an entry representing the time spent doing non-queries
+ $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $sTime ];
+ }
+ $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
+ }
+ } else {
+ // First query in the trx...
+ if ( $sTime >= $info['start'] ) { // sanity check
+ $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
+ }
+ }
+ }
+ }
+
+ /**
+ * Mark a DB as no longer in a transaction
+ *
+ * This will check if locks are possibly held for longer than
+ * needed and log any affected transactions to a special DB log.
+ * Note that there can be multiple connections to a single DB.
+ *
+ * @param string $server DB server
+ * @param string $db DB name
+ * @param string $id ID string of transaction
+ * @param float $writeTime Time spent in write queries
+ * @param int $affected Number of rows affected by writes
+ */
+ public function transactionWritingOut( $server, $db, $id, $writeTime = 0.0, $affected = 0 ) {
+ $name = "{$server} ({$db}) (TRX#$id)";
+ if ( !isset( $this->dbTrxMethodTimes[$name] ) ) {
+ $this->logger->info( "Detected no transaction for '$name' - out of sync." );
+ return;
+ }
+
+ $slow = false;
+
+ // Warn if too much time was spend writing...
+ if ( $writeTime > $this->expect['writeQueryTime'] ) {
+ $this->reportExpectationViolated(
+ 'writeQueryTime',
+ "[transaction $id writes to {$server} ({$db})]",
+ $writeTime
+ );
+ $slow = true;
+ }
+ // Warn if too many rows were changed...
+ if ( $affected > $this->expect['maxAffected'] ) {
+ $this->reportExpectationViolated(
+ 'maxAffected',
+ "[transaction $id writes to {$server} ({$db})]",
+ $affected
+ );
+ }
+ // Fill in the last non-query period...
+ $lastQuery = end( $this->dbTrxMethodTimes[$name] );
+ if ( $lastQuery ) {
+ $now = microtime( true );
+ $lastEnd = $lastQuery[2];
+ if ( ( $now - $lastEnd ) > $this->eventThreshold ) {
+ $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $now ];
+ }
+ }
+ // Check for any slow queries or non-query periods...
+ foreach ( $this->dbTrxMethodTimes[$name] as $info ) {
+ $elapsed = ( $info[2] - $info[1] );
+ if ( $elapsed >= $this->dbLockThreshold ) {
+ $slow = true;
+ break;
+ }
+ }
+ if ( $slow ) {
+ $trace = '';
+ foreach ( $this->dbTrxMethodTimes[$name] as $i => $info ) {
+ list( $query, $sTime, $end ) = $info;
+ $trace .= sprintf( "%d\t%.6f\t%s\n", $i, ( $end - $sTime ), $query );
+ }
+ $this->logger->info( "Sub-optimal transaction on DB(s) [{dbs}]: \n{trace}", [
+ 'dbs' => implode( ', ', array_keys( $this->dbTrxHoldingLocks[$name]['conns'] ) ),
+ 'trace' => $trace
+ ] );
+ }
+ unset( $this->dbTrxHoldingLocks[$name] );
+ unset( $this->dbTrxMethodTimes[$name] );
+ }
+
+ /**
+ * @param string $expect
+ * @param string $query
+ * @param string|float|int $actual
+ */
+ protected function reportExpectationViolated( $expect, $query, $actual ) {
+ if ( $this->silenced ) {
+ return;
+ }
+
+ $this->logger->info(
+ "Expectation ({measure} <= {max}) by {by} not met (actual: {actual}):\n{query}\n" .
+ ( new RuntimeException() )->getTraceAsString(),
+ [
+ 'measure' => $expect,
+ 'max' => $this->expect[$expect],
+ 'by' => $this->expectBy[$expect],
+ 'actual' => $actual,
+ 'query' => $query
+ ]
+ );
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/connectionmanager/ConnectionManager.php b/www/wiki/includes/libs/rdbms/connectionmanager/ConnectionManager.php
new file mode 100644
index 00000000..212ff315
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/connectionmanager/ConnectionManager.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
+/**
+ * Database connection manager.
+ *
+ * This manages access to master and replica databases.
+ *
+ * @since 1.29
+ *
+ * @license GPL-2.0+
+ * @author Addshore
+ */
+class ConnectionManager {
+
+ /**
+ * @var LoadBalancer
+ */
+ private $loadBalancer;
+
+ /**
+ * The symbolic name of the target database, or false for the local wiki's database.
+ *
+ * @var string|false
+ */
+ private $domain;
+
+ /**
+ * @var string[]
+ */
+ private $groups = [];
+
+ /**
+ * @param LoadBalancer $loadBalancer
+ * @param string|bool $domain Optional logical DB name, defaults to current wiki.
+ * This follows the convention for database names used by $loadBalancer.
+ * @param string[] $groups see LoadBalancer::getConnection
+ *
+ * @throws InvalidArgumentException
+ */
+ public function __construct( LoadBalancer $loadBalancer, $domain = false, array $groups = [] ) {
+ if ( !is_string( $domain ) && $domain !== false ) {
+ throw new InvalidArgumentException( '$dbName must be a string, or false.' );
+ }
+
+ $this->loadBalancer = $loadBalancer;
+ $this->domain = $domain;
+ $this->groups = $groups;
+ }
+
+ /**
+ * @param int $i
+ * @param string[]|null $groups
+ *
+ * @return Database
+ */
+ private function getConnection( $i, array $groups = null ) {
+ $groups = $groups === null ? $this->groups : $groups;
+ return $this->loadBalancer->getConnection( $i, $groups, $this->domain );
+ }
+
+ /**
+ * @param int $i
+ * @param string[]|null $groups
+ *
+ * @return DBConnRef
+ */
+ private function getConnectionRef( $i, array $groups = null ) {
+ $groups = $groups === null ? $this->groups : $groups;
+ return $this->loadBalancer->getConnectionRef( $i, $groups, $this->domain );
+ }
+
+ /**
+ * Returns a connection to the master DB, for updating. The connection should later be released
+ * by calling releaseConnection().
+ *
+ * @since 1.29
+ *
+ * @return Database
+ */
+ public function getWriteConnection() {
+ return $this->getConnection( DB_MASTER );
+ }
+
+ /**
+ * Returns a database connection for reading. The connection should later be released by
+ * calling releaseConnection().
+ *
+ * @since 1.29
+ *
+ * @param string[]|null $groups
+ *
+ * @return Database
+ */
+ public function getReadConnection( array $groups = null ) {
+ $groups = $groups === null ? $this->groups : $groups;
+ return $this->getConnection( DB_REPLICA, $groups );
+ }
+
+ /**
+ * @since 1.29
+ *
+ * @param IDatabase $db
+ */
+ public function releaseConnection( IDatabase $db ) {
+ $this->loadBalancer->reuseConnection( $db );
+ }
+
+ /**
+ * Returns a connection ref to the master DB, for updating.
+ *
+ * @since 1.29
+ *
+ * @return DBConnRef
+ */
+ public function getWriteConnectionRef() {
+ return $this->getConnectionRef( DB_MASTER );
+ }
+
+ /**
+ * Returns a database connection ref for reading.
+ *
+ * @since 1.29
+ *
+ * @param string[]|null $groups
+ *
+ * @return DBConnRef
+ */
+ public function getReadConnectionRef( array $groups = null ) {
+ $groups = $groups === null ? $this->groups : $groups;
+ return $this->getConnectionRef( DB_REPLICA, $groups );
+ }
+
+}
diff --git a/www/wiki/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php b/www/wiki/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php
new file mode 100644
index 00000000..30b1fb4c
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Database connection manager.
+ *
+ * This manages access to master and replica databases. It also manages state that indicates whether
+ * the replica databases are possibly outdated after a write operation, and thus the master database
+ * should be used for subsequent read operations.
+ *
+ * @note: Services that access overlapping sets of database tables, or interact with logically
+ * related sets of data in the database, should share a SessionConsistentConnectionManager.
+ * Services accessing unrelated sets of information may prefer to not share a
+ * SessionConsistentConnectionManager, so they can still perform read operations against replica
+ * databases after a (unrelated, per the assumption) write operation to the master database.
+ * Generally, sharing a SessionConsistentConnectionManager improves consistency (by avoiding race
+ * conditions due to replication lag), but can reduce performance (by directing more read
+ * operations to the master database server).
+ *
+ * @since 1.29
+ *
+ * @license GPL-2.0+
+ * @author Daniel Kinzler
+ * @author Addshore
+ */
+class SessionConsistentConnectionManager extends ConnectionManager {
+
+ /**
+ * @var bool
+ */
+ private $forceWriteConnection = false;
+
+ /**
+ * Forces all future calls to getReadConnection() to return a write connection.
+ * Use this before performing read operations that are critical for a future update.
+ *
+ * @since 1.29
+ */
+ public function prepareForUpdates() {
+ $this->forceWriteConnection = true;
+ }
+
+ /**
+ * @since 1.29
+ *
+ * @param string[]|null $groups
+ *
+ * @return Database
+ */
+ public function getReadConnection( array $groups = null ) {
+ if ( $this->forceWriteConnection ) {
+ return parent::getWriteConnection();
+ }
+
+ return parent::getReadConnection( $groups );
+ }
+
+ /**
+ * @since 1.29
+ *
+ * @return Database
+ */
+ public function getWriteConnection() {
+ $this->prepareForUpdates();
+ return parent::getWriteConnection();
+ }
+
+ /**
+ * @since 1.29
+ *
+ * @param string[]|null $groups
+ *
+ * @return DBConnRef
+ */
+ public function getReadConnectionRef( array $groups = null ) {
+ if ( $this->forceWriteConnection ) {
+ return parent::getWriteConnectionRef();
+ }
+
+ return parent::getReadConnectionRef( $groups );
+ }
+
+ /**
+ * @since 1.29
+ *
+ * @return DBConnRef
+ */
+ public function getWriteConnectionRef() {
+ $this->prepareForUpdates();
+ return parent::getWriteConnectionRef();
+ }
+
+}
diff --git a/www/wiki/includes/libs/rdbms/database/DBConnRef.php b/www/wiki/includes/libs/rdbms/database/DBConnRef.php
new file mode 100644
index 00000000..ef2953ec
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/DBConnRef.php
@@ -0,0 +1,623 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
+/**
+ * Helper class to handle automatically marking connections as reusable (via RAII pattern)
+ * as well handling deferring the actual network connection until the handle is used
+ *
+ * @note: proxy methods are defined explicitly to avoid interface errors
+ * @ingroup Database
+ * @since 1.22
+ */
+class DBConnRef implements IDatabase {
+ /** @var ILoadBalancer */
+ private $lb;
+ /** @var Database|null Live connection handle */
+ private $conn;
+ /** @var array|null N-tuple of (server index, group, DatabaseDomain|string) */
+ private $params;
+
+ const FLD_INDEX = 0;
+ const FLD_GROUP = 1;
+ const FLD_DOMAIN = 2;
+ const FLD_FLAGS = 3;
+
+ /**
+ * @param ILoadBalancer $lb Connection manager for $conn
+ * @param Database|array $conn Database handle or (server index, query groups, domain, flags)
+ */
+ public function __construct( ILoadBalancer $lb, $conn ) {
+ $this->lb = $lb;
+ if ( $conn instanceof Database ) {
+ $this->conn = $conn; // live handle
+ } elseif ( count( $conn ) >= 4 && $conn[self::FLD_DOMAIN] !== false ) {
+ $this->params = $conn;
+ } else {
+ throw new InvalidArgumentException( "Missing lazy connection arguments." );
+ }
+ }
+
+ function __call( $name, array $arguments ) {
+ if ( $this->conn === null ) {
+ list( $db, $groups, $wiki, $flags ) = $this->params;
+ $this->conn = $this->lb->getConnection( $db, $groups, $wiki, $flags );
+ }
+
+ return call_user_func_array( [ $this->conn, $name ], $arguments );
+ }
+
+ public function getServerInfo() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function bufferResults( $buffer = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function trxLevel() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function trxTimestamp() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function explicitTrxActive() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function tablePrefix( $prefix = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function dbSchema( $schema = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getLBInfo( $name = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setLBInfo( $name, $value = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setLazyMasterHandle( IDatabase $conn ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function implicitGroupby() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function implicitOrderby() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lastQuery() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function doneWrites() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lastDoneWrites() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function writesPending() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function writesOrCallbacksPending() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function pendingWriteCallers() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function pendingWriteRowsAffected() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function isOpen() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setFlag( $flag, $remember = self::REMEMBER_NOTHING ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function clearFlag( $flag, $remember = self::REMEMBER_NOTHING ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function restoreFlags( $state = self::RESTORE_PRIOR ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getFlag( $flag ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getProperty( $name ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getDomainID() {
+ if ( $this->conn === null ) {
+ $domain = $this->params[self::FLD_DOMAIN];
+ // Avoid triggering a database connection
+ return $domain instanceof DatabaseDomain ? $domain->getId() : $domain;
+ }
+
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getWikiID() {
+ return $this->getDomainID();
+ }
+
+ public function getType() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function open( $server, $user, $password, $dbName ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fetchObject( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fetchRow( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function numRows( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function numFields( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fieldName( $res, $n ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function insertId() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function dataSeek( $res, $row ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lastErrno() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lastError() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fieldInfo( $table, $field ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function affectedRows() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getSoftwareLink() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getServerVersion() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function close() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function reportConnectionError( $error = 'Unknown error' ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function freeResult( $res ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectField(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectFieldValues(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function select(
+ $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectSQLText(
+ $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectRow(
+ $table, $vars, $conds, $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function estimateRowCount(
+ $table, $vars = '*', $conds = '', $fname = __METHOD__, $options = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectRowCount(
+ $tables, $vars = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fieldExists( $table, $field, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function indexExists( $table, $index, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function tableExists( $table, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function indexUnique( $table, $index ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function update( $table, $values, $conds, $fname = __METHOD__, $options = [] ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function makeList( $a, $mode = self::LIST_COMMA ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function aggregateValue( $valuedata, $valuename = 'value' ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function bitNot( $field ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function bitAnd( $fieldLeft, $fieldRight ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function bitOr( $fieldLeft, $fieldRight ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function buildConcat( $stringList ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function buildStringCast( $field ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function databasesAreIndependent() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function selectDB( $db ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getDBname() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getServer() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function addQuotes( $s ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function buildLike() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function anyChar() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function anyString() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function nextSequenceValue( $seqName ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function upsert(
+ $table, array $rows, array $uniqueIndexes, array $set, $fname = __METHOD__
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function deleteJoin(
+ $delTable, $joinTable, $delVar, $joinVar, $conds, $fname = __METHOD__
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function delete( $table, $conds, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function insertSelect(
+ $destTable, $srcTable, $varMap, $conds,
+ $fname = __METHOD__, $insertOptions = [], $selectOptions = [], $selectJoinConds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function unionSupportsOrderAndLimit() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function unionQueries( $sqls, $all ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function unionConditionPermutations(
+ $table, $vars, array $permute_conds, $extra_conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function conditional( $cond, $trueVal, $falseVal ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function strreplace( $orig, $old, $new ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getServerUptime() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function wasDeadlock() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function wasLockTimeout() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function wasErrorReissuable() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function wasReadOnlyError() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function masterPosWait( DBMasterPos $pos, $timeout ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getReplicaPos() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getMasterPos() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function serverIsReadOnly() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function onTransactionResolution( callable $callback, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setTransactionListener( $name, callable $callback = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function startAtomic( $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function endAtomic( $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function doAtomicSection( $fname, callable $callback ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function begin( $fname = __METHOD__, $mode = IDatabase::TRANSACTION_EXPLICIT ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function commit( $fname = __METHOD__, $flush = '' ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function rollback( $fname = __METHOD__, $flush = '' ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function flushSnapshot( $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function listTables( $prefix = null, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function timestamp( $ts = 0 ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function timestampOrNull( $ts = null ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function ping( &$rtt = null ) {
+ return func_num_args()
+ ? $this->__call( __FUNCTION__, [ &$rtt ] )
+ : $this->__call( __FUNCTION__, [] ); // method cares about null vs missing
+ }
+
+ public function getLag() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getSessionLagStatus() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function maxListLen() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function encodeBlob( $b ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function decodeBlob( $b ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setSessionOptions( array $options ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setSchemaVars( $vars ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lockIsFree( $lockName, $method ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function unlock( $lockName, $method ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getScopedLockAndFlush( $lockKey, $fname, $timeout ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function namedLocksEnqueue() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function getInfinity() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function encodeExpiry( $expiry ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function decodeExpiry( $expiry, $format = TS_MW ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setBigSelects( $value = true ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function isReadOnly() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function setTableAliases( array $aliases ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ /**
+ * Clean up the connection when out of scope
+ */
+ function __destruct() {
+ if ( $this->conn ) {
+ $this->lb->reuseConnection( $this->conn );
+ }
+ }
+}
+
+class_alias( DBConnRef::class, 'DBConnRef' );
diff --git a/www/wiki/includes/libs/rdbms/database/Database.php b/www/wiki/includes/libs/rdbms/database/Database.php
new file mode 100644
index 00000000..c9040928
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/Database.php
@@ -0,0 +1,3696 @@
+<?php
+/**
+ * @defgroup Database Database
+ *
+ * This file deals with database interface functions
+ * and query specifics/optimisations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+use MediaWiki;
+use BagOStuff;
+use HashBagOStuff;
+use InvalidArgumentException;
+use Exception;
+use RuntimeException;
+
+/**
+ * Relational database abstraction object
+ *
+ * @ingroup Database
+ * @since 1.28
+ */
+abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAwareInterface {
+ /** Number of times to re-try an operation in case of deadlock */
+ const DEADLOCK_TRIES = 4;
+ /** Minimum time to wait before retry, in microseconds */
+ const DEADLOCK_DELAY_MIN = 500000;
+ /** Maximum time to wait before retry */
+ const DEADLOCK_DELAY_MAX = 1500000;
+
+ /** How long before it is worth doing a dummy query to test the connection */
+ const PING_TTL = 1.0;
+ const PING_QUERY = 'SELECT 1 AS ping';
+
+ const TINY_WRITE_SEC = 0.010;
+ const SLOW_WRITE_SEC = 0.500;
+ const SMALL_WRITE_ROWS = 100;
+
+ /** @var string SQL query */
+ protected $mLastQuery = '';
+ /** @var float|bool UNIX timestamp of last write query */
+ protected $mLastWriteTime = false;
+ /** @var string|bool */
+ protected $mPHPError = false;
+ /** @var string */
+ protected $mServer;
+ /** @var string */
+ protected $mUser;
+ /** @var string */
+ protected $mPassword;
+ /** @var string */
+ protected $mDBname;
+ /** @var array[] $aliases Map of (table => (dbname, schema, prefix) map) */
+ protected $tableAliases = [];
+ /** @var bool Whether this PHP instance is for a CLI script */
+ protected $cliMode;
+ /** @var string Agent name for query profiling */
+ protected $agent;
+
+ /** @var BagOStuff APC cache */
+ protected $srvCache;
+ /** @var LoggerInterface */
+ protected $connLogger;
+ /** @var LoggerInterface */
+ protected $queryLogger;
+ /** @var callback Error logging callback */
+ protected $errorLogger;
+
+ /** @var resource|null Database connection */
+ protected $mConn = null;
+ /** @var bool */
+ protected $mOpened = false;
+
+ /** @var array[] List of (callable, method name) */
+ protected $mTrxIdleCallbacks = [];
+ /** @var array[] List of (callable, method name) */
+ protected $mTrxPreCommitCallbacks = [];
+ /** @var array[] List of (callable, method name) */
+ protected $mTrxEndCallbacks = [];
+ /** @var callable[] Map of (name => callable) */
+ protected $mTrxRecurringCallbacks = [];
+ /** @var bool Whether to suppress triggering of transaction end callbacks */
+ protected $mTrxEndCallbacksSuppressed = false;
+
+ /** @var string */
+ protected $mTablePrefix = '';
+ /** @var string */
+ protected $mSchema = '';
+ /** @var int */
+ protected $mFlags;
+ /** @var array */
+ protected $mLBInfo = [];
+ /** @var bool|null */
+ protected $mDefaultBigSelects = null;
+ /** @var array|bool */
+ protected $mSchemaVars = false;
+ /** @var array */
+ protected $mSessionVars = [];
+ /** @var array|null */
+ protected $preparedArgs;
+ /** @var string|bool|null Stashed value of html_errors INI setting */
+ protected $htmlErrors;
+ /** @var string */
+ protected $delimiter = ';';
+ /** @var DatabaseDomain */
+ protected $currentDomain;
+
+ /**
+ * Either 1 if a transaction is active or 0 otherwise.
+ * The other Trx fields may not be meaningfull if this is 0.
+ *
+ * @var int
+ */
+ protected $mTrxLevel = 0;
+ /**
+ * Either a short hexidecimal string if a transaction is active or ""
+ *
+ * @var string
+ * @see Database::mTrxLevel
+ */
+ protected $mTrxShortId = '';
+ /**
+ * The UNIX time that the transaction started. Callers can assume that if
+ * snapshot isolation is used, then the data is *at least* up to date to that
+ * point (possibly more up-to-date since the first SELECT defines the snapshot).
+ *
+ * @var float|null
+ * @see Database::mTrxLevel
+ */
+ private $mTrxTimestamp = null;
+ /** @var float Lag estimate at the time of BEGIN */
+ private $mTrxReplicaLag = null;
+ /**
+ * Remembers the function name given for starting the most recent transaction via begin().
+ * Used to provide additional context for error reporting.
+ *
+ * @var string
+ * @see Database::mTrxLevel
+ */
+ private $mTrxFname = null;
+ /**
+ * Record if possible write queries were done in the last transaction started
+ *
+ * @var bool
+ * @see Database::mTrxLevel
+ */
+ private $mTrxDoneWrites = false;
+ /**
+ * Record if the current transaction was started implicitly due to DBO_TRX being set.
+ *
+ * @var bool
+ * @see Database::mTrxLevel
+ */
+ private $mTrxAutomatic = false;
+ /**
+ * Array of levels of atomicity within transactions
+ *
+ * @var array
+ */
+ private $mTrxAtomicLevels = [];
+ /**
+ * Record if the current transaction was started implicitly by Database::startAtomic
+ *
+ * @var bool
+ */
+ private $mTrxAutomaticAtomic = false;
+ /**
+ * Track the write query callers of the current transaction
+ *
+ * @var string[]
+ */
+ private $mTrxWriteCallers = [];
+ /**
+ * @var float Seconds spent in write queries for the current transaction
+ */
+ private $mTrxWriteDuration = 0.0;
+ /**
+ * @var int Number of write queries for the current transaction
+ */
+ private $mTrxWriteQueryCount = 0;
+ /**
+ * @var int Number of rows affected by write queries for the current transaction
+ */
+ private $mTrxWriteAffectedRows = 0;
+ /**
+ * @var float Like mTrxWriteQueryCount but excludes lock-bound, easy to replicate, queries
+ */
+ private $mTrxWriteAdjDuration = 0.0;
+ /**
+ * @var int Number of write queries counted in mTrxWriteAdjDuration
+ */
+ private $mTrxWriteAdjQueryCount = 0;
+ /**
+ * @var float RTT time estimate
+ */
+ private $mRTTEstimate = 0.0;
+
+ /** @var array Map of (name => 1) for locks obtained via lock() */
+ private $mNamedLocksHeld = [];
+ /** @var array Map of (table name => 1) for TEMPORARY tables */
+ protected $mSessionTempTables = [];
+
+ /** @var IDatabase|null Lazy handle to the master DB this server replicates from */
+ private $lazyMasterHandle;
+
+ /** @var float UNIX timestamp */
+ protected $lastPing = 0.0;
+
+ /** @var int[] Prior mFlags values */
+ private $priorFlags = [];
+
+ /** @var object|string Class name or object With profileIn/profileOut methods */
+ protected $profiler;
+ /** @var TransactionProfiler */
+ protected $trxProfiler;
+
+ /**
+ * Constructor and database handle and attempt to connect to the DB server
+ *
+ * IDatabase classes should not be constructed directly in external
+ * code. Database::factory() should be used instead.
+ *
+ * @param array $params Parameters passed from Database::factory()
+ */
+ function __construct( array $params ) {
+ $server = $params['host'];
+ $user = $params['user'];
+ $password = $params['password'];
+ $dbName = $params['dbname'];
+
+ $this->mSchema = $params['schema'];
+ $this->mTablePrefix = $params['tablePrefix'];
+
+ $this->cliMode = $params['cliMode'];
+ // Agent name is added to SQL queries in a comment, so make sure it can't break out
+ $this->agent = str_replace( '/', '-', $params['agent'] );
+
+ $this->mFlags = $params['flags'];
+ if ( $this->mFlags & self::DBO_DEFAULT ) {
+ if ( $this->cliMode ) {
+ $this->mFlags &= ~self::DBO_TRX;
+ } else {
+ $this->mFlags |= self::DBO_TRX;
+ }
+ }
+
+ $this->mSessionVars = $params['variables'];
+
+ $this->srvCache = isset( $params['srvCache'] )
+ ? $params['srvCache']
+ : new HashBagOStuff();
+
+ $this->profiler = $params['profiler'];
+ $this->trxProfiler = $params['trxProfiler'];
+ $this->connLogger = $params['connLogger'];
+ $this->queryLogger = $params['queryLogger'];
+ $this->errorLogger = $params['errorLogger'];
+
+ // Set initial dummy domain until open() sets the final DB/prefix
+ $this->currentDomain = DatabaseDomain::newUnspecified();
+
+ if ( $user ) {
+ $this->open( $server, $user, $password, $dbName );
+ } elseif ( $this->requiresDatabaseUser() ) {
+ throw new InvalidArgumentException( "No database user provided." );
+ }
+
+ // Set the domain object after open() sets the relevant fields
+ if ( $this->mDBname != '' ) {
+ // Domains with server scope but a table prefix are not used by IDatabase classes
+ $this->currentDomain = new DatabaseDomain( $this->mDBname, null, $this->mTablePrefix );
+ }
+ }
+
+ /**
+ * Construct a Database subclass instance given a database type and parameters
+ *
+ * This also connects to the database immediately upon object construction
+ *
+ * @param string $dbType A possible DB type (sqlite, mysql, postgres)
+ * @param array $p Parameter map with keys:
+ * - host : The hostname of the DB server
+ * - user : The name of the database user the client operates under
+ * - password : The password for the database user
+ * - dbname : The name of the database to use where queries do not specify one.
+ * The database must exist or an error might be thrown. Setting this to the empty string
+ * will avoid any such errors and make the handle have no implicit database scope. This is
+ * useful for queries like SHOW STATUS, CREATE DATABASE, or DROP DATABASE. Note that a
+ * "database" in Postgres is rougly equivalent to an entire MySQL server. This the domain
+ * in which user names and such are defined, e.g. users are database-specific in Postgres.
+ * - schema : The database schema to use (if supported). A "schema" in Postgres is roughly
+ * equivalent to a "database" in MySQL. Note that MySQL and SQLite do not use schemas.
+ * - tablePrefix : Optional table prefix that is implicitly added on to all table names
+ * recognized in queries. This can be used in place of schemas for handle site farms.
+ * - flags : Optional bitfield of DBO_* constants that define connection, protocol,
+ * buffering, and transaction behavior. It is STRONGLY adviced to leave the DBO_DEFAULT
+ * flag in place UNLESS this this database simply acts as a key/value store.
+ * - driver: Optional name of a specific DB client driver. For MySQL, there is the old
+ * 'mysql' driver and the newer 'mysqli' driver.
+ * - variables: Optional map of session variables to set after connecting. This can be
+ * used to adjust lock timeouts or encoding modes and the like.
+ * - connLogger: Optional PSR-3 logger interface instance.
+ * - queryLogger: Optional PSR-3 logger interface instance.
+ * - profiler: Optional class name or object with profileIn()/profileOut() methods.
+ * These will be called in query(), using a simplified version of the SQL that also
+ * includes the agent as a SQL comment.
+ * - trxProfiler: Optional TransactionProfiler instance.
+ * - errorLogger: Optional callback that takes an Exception and logs it.
+ * - cliMode: Whether to consider the execution context that of a CLI script.
+ * - agent: Optional name used to identify the end-user in query profiling/logging.
+ * - srvCache: Optional BagOStuff instance to an APC-style cache.
+ * @return Database|null If the database driver or extension cannot be found
+ * @throws InvalidArgumentException If the database driver or extension cannot be found
+ * @since 1.18
+ */
+ final public static function factory( $dbType, $p = [] ) {
+ static $canonicalDBTypes = [
+ 'mysql' => [ 'mysqli', 'mysql' ],
+ 'postgres' => [],
+ 'sqlite' => [],
+ 'oracle' => [],
+ 'mssql' => [],
+ ];
+ static $classAliases = [
+ 'DatabaseMssql' => DatabaseMssql::class,
+ 'DatabaseMysql' => DatabaseMysql::class,
+ 'DatabaseMysqli' => DatabaseMysqli::class,
+ 'DatabaseSqlite' => DatabaseSqlite::class,
+ 'DatabasePostgres' => DatabasePostgres::class
+ ];
+
+ $driver = false;
+ $dbType = strtolower( $dbType );
+ if ( isset( $canonicalDBTypes[$dbType] ) && $canonicalDBTypes[$dbType] ) {
+ $possibleDrivers = $canonicalDBTypes[$dbType];
+ if ( !empty( $p['driver'] ) ) {
+ if ( in_array( $p['driver'], $possibleDrivers ) ) {
+ $driver = $p['driver'];
+ } else {
+ throw new InvalidArgumentException( __METHOD__ .
+ " type '$dbType' does not support driver '{$p['driver']}'" );
+ }
+ } else {
+ foreach ( $possibleDrivers as $posDriver ) {
+ if ( extension_loaded( $posDriver ) ) {
+ $driver = $posDriver;
+ break;
+ }
+ }
+ }
+ } else {
+ $driver = $dbType;
+ }
+
+ if ( $driver === false || $driver === '' ) {
+ throw new InvalidArgumentException( __METHOD__ .
+ " no viable database extension found for type '$dbType'" );
+ }
+
+ $class = 'Database' . ucfirst( $driver );
+ if ( isset( $classAliases[$class] ) ) {
+ $class = $classAliases[$class];
+ }
+
+ if ( class_exists( $class ) && is_subclass_of( $class, IDatabase::class ) ) {
+ // Resolve some defaults for b/c
+ $p['host'] = isset( $p['host'] ) ? $p['host'] : false;
+ $p['user'] = isset( $p['user'] ) ? $p['user'] : false;
+ $p['password'] = isset( $p['password'] ) ? $p['password'] : false;
+ $p['dbname'] = isset( $p['dbname'] ) ? $p['dbname'] : false;
+ $p['flags'] = isset( $p['flags'] ) ? $p['flags'] : 0;
+ $p['variables'] = isset( $p['variables'] ) ? $p['variables'] : [];
+ $p['tablePrefix'] = isset( $p['tablePrefix'] ) ? $p['tablePrefix'] : '';
+ $p['schema'] = isset( $p['schema'] ) ? $p['schema'] : '';
+ $p['cliMode'] = isset( $p['cliMode'] ) ? $p['cliMode'] : ( PHP_SAPI === 'cli' );
+ $p['agent'] = isset( $p['agent'] ) ? $p['agent'] : '';
+ if ( !isset( $p['connLogger'] ) ) {
+ $p['connLogger'] = new \Psr\Log\NullLogger();
+ }
+ if ( !isset( $p['queryLogger'] ) ) {
+ $p['queryLogger'] = new \Psr\Log\NullLogger();
+ }
+ $p['profiler'] = isset( $p['profiler'] ) ? $p['profiler'] : null;
+ if ( !isset( $p['trxProfiler'] ) ) {
+ $p['trxProfiler'] = new TransactionProfiler();
+ }
+ if ( !isset( $p['errorLogger'] ) ) {
+ $p['errorLogger'] = function ( Exception $e ) {
+ trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
+ };
+ }
+
+ $conn = new $class( $p );
+ } else {
+ $conn = null;
+ }
+
+ return $conn;
+ }
+
+ /**
+ * Set the PSR-3 logger interface to use for query logging. (The logger
+ * interfaces for connection logging and error logging can be set with the
+ * constructor.)
+ *
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->queryLogger = $logger;
+ }
+
+ public function getServerInfo() {
+ return $this->getServerVersion();
+ }
+
+ public function bufferResults( $buffer = null ) {
+ $res = !$this->getFlag( self::DBO_NOBUFFER );
+ if ( $buffer !== null ) {
+ $buffer
+ ? $this->clearFlag( self::DBO_NOBUFFER )
+ : $this->setFlag( self::DBO_NOBUFFER );
+ }
+
+ return $res;
+ }
+
+ /**
+ * Turns on (false) or off (true) the automatic generation and sending
+ * of a "we're sorry, but there has been a database error" page on
+ * database errors. Default is on (false). When turned off, the
+ * code should use lastErrno() and lastError() to handle the
+ * situation as appropriate.
+ *
+ * Do not use this function outside of the Database classes.
+ *
+ * @param null|bool $ignoreErrors
+ * @return bool The previous value of the flag.
+ */
+ protected function ignoreErrors( $ignoreErrors = null ) {
+ $res = $this->getFlag( self::DBO_IGNORE );
+ if ( $ignoreErrors !== null ) {
+ $ignoreErrors
+ ? $this->setFlag( self::DBO_IGNORE )
+ : $this->clearFlag( self::DBO_IGNORE );
+ }
+
+ return $res;
+ }
+
+ public function trxLevel() {
+ return $this->mTrxLevel;
+ }
+
+ public function trxTimestamp() {
+ return $this->mTrxLevel ? $this->mTrxTimestamp : null;
+ }
+
+ public function tablePrefix( $prefix = null ) {
+ $old = $this->mTablePrefix;
+ if ( $prefix !== null ) {
+ $this->mTablePrefix = $prefix;
+ $this->currentDomain = ( $this->mDBname != '' )
+ ? new DatabaseDomain( $this->mDBname, null, $this->mTablePrefix )
+ : DatabaseDomain::newUnspecified();
+ }
+
+ return $old;
+ }
+
+ public function dbSchema( $schema = null ) {
+ $old = $this->mSchema;
+ if ( $schema !== null ) {
+ $this->mSchema = $schema;
+ }
+
+ return $old;
+ }
+
+ public function getLBInfo( $name = null ) {
+ if ( is_null( $name ) ) {
+ return $this->mLBInfo;
+ } else {
+ if ( array_key_exists( $name, $this->mLBInfo ) ) {
+ return $this->mLBInfo[$name];
+ } else {
+ return null;
+ }
+ }
+ }
+
+ public function setLBInfo( $name, $value = null ) {
+ if ( is_null( $value ) ) {
+ $this->mLBInfo = $name;
+ } else {
+ $this->mLBInfo[$name] = $value;
+ }
+ }
+
+ public function setLazyMasterHandle( IDatabase $conn ) {
+ $this->lazyMasterHandle = $conn;
+ }
+
+ /**
+ * @return IDatabase|null
+ * @see setLazyMasterHandle()
+ * @since 1.27
+ */
+ protected function getLazyMasterHandle() {
+ return $this->lazyMasterHandle;
+ }
+
+ public function implicitGroupby() {
+ return true;
+ }
+
+ public function implicitOrderby() {
+ return true;
+ }
+
+ public function lastQuery() {
+ return $this->mLastQuery;
+ }
+
+ public function doneWrites() {
+ return (bool)$this->mLastWriteTime;
+ }
+
+ public function lastDoneWrites() {
+ return $this->mLastWriteTime ?: false;
+ }
+
+ public function writesPending() {
+ return $this->mTrxLevel && $this->mTrxDoneWrites;
+ }
+
+ public function writesOrCallbacksPending() {
+ return $this->mTrxLevel && (
+ $this->mTrxDoneWrites || $this->mTrxIdleCallbacks || $this->mTrxPreCommitCallbacks
+ );
+ }
+
+ public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL ) {
+ if ( !$this->mTrxLevel ) {
+ return false;
+ } elseif ( !$this->mTrxDoneWrites ) {
+ return 0.0;
+ }
+
+ switch ( $type ) {
+ case self::ESTIMATE_DB_APPLY:
+ $this->ping( $rtt );
+ $rttAdjTotal = $this->mTrxWriteAdjQueryCount * $rtt;
+ $applyTime = max( $this->mTrxWriteAdjDuration - $rttAdjTotal, 0 );
+ // For omitted queries, make them count as something at least
+ $omitted = $this->mTrxWriteQueryCount - $this->mTrxWriteAdjQueryCount;
+ $applyTime += self::TINY_WRITE_SEC * $omitted;
+
+ return $applyTime;
+ default: // everything
+ return $this->mTrxWriteDuration;
+ }
+ }
+
+ public function pendingWriteCallers() {
+ return $this->mTrxLevel ? $this->mTrxWriteCallers : [];
+ }
+
+ public function pendingWriteRowsAffected() {
+ return $this->mTrxWriteAffectedRows;
+ }
+
+ /**
+ * Get the list of method names that have pending write queries or callbacks
+ * for this transaction
+ *
+ * @return array
+ */
+ protected function pendingWriteAndCallbackCallers() {
+ if ( !$this->mTrxLevel ) {
+ return [];
+ }
+
+ $fnames = $this->mTrxWriteCallers;
+ foreach ( [
+ $this->mTrxIdleCallbacks,
+ $this->mTrxPreCommitCallbacks,
+ $this->mTrxEndCallbacks
+ ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $fnames[] = $callback[1];
+ }
+ }
+
+ return $fnames;
+ }
+
+ public function isOpen() {
+ return $this->mOpened;
+ }
+
+ public function setFlag( $flag, $remember = self::REMEMBER_NOTHING ) {
+ if ( $remember === self::REMEMBER_PRIOR ) {
+ array_push( $this->priorFlags, $this->mFlags );
+ }
+ $this->mFlags |= $flag;
+ }
+
+ public function clearFlag( $flag, $remember = self::REMEMBER_NOTHING ) {
+ if ( $remember === self::REMEMBER_PRIOR ) {
+ array_push( $this->priorFlags, $this->mFlags );
+ }
+ $this->mFlags &= ~$flag;
+ }
+
+ public function restoreFlags( $state = self::RESTORE_PRIOR ) {
+ if ( !$this->priorFlags ) {
+ return;
+ }
+
+ if ( $state === self::RESTORE_INITIAL ) {
+ $this->mFlags = reset( $this->priorFlags );
+ $this->priorFlags = [];
+ } else {
+ $this->mFlags = array_pop( $this->priorFlags );
+ }
+ }
+
+ public function getFlag( $flag ) {
+ return !!( $this->mFlags & $flag );
+ }
+
+ /**
+ * @param string $name Class field name
+ * @return mixed
+ * @deprecated Since 1.28
+ */
+ public function getProperty( $name ) {
+ return $this->$name;
+ }
+
+ public function getDomainID() {
+ return $this->currentDomain->getId();
+ }
+
+ final public function getWikiID() {
+ return $this->getDomainID();
+ }
+
+ /**
+ * Get information about an index into an object
+ * @param string $table Table name
+ * @param string $index Index name
+ * @param string $fname Calling function name
+ * @return mixed Database-specific index description class or false if the index does not exist
+ */
+ abstract function indexInfo( $table, $index, $fname = __METHOD__ );
+
+ /**
+ * Wrapper for addslashes()
+ *
+ * @param string $s String to be slashed.
+ * @return string Slashed string.
+ */
+ abstract function strencode( $s );
+
+ /**
+ * Set a custom error handler for logging errors during database connection
+ */
+ protected function installErrorHandler() {
+ $this->mPHPError = false;
+ $this->htmlErrors = ini_set( 'html_errors', '0' );
+ set_error_handler( [ $this, 'connectionErrorLogger' ] );
+ }
+
+ /**
+ * Restore the previous error handler and return the last PHP error for this DB
+ *
+ * @return bool|string
+ */
+ protected function restoreErrorHandler() {
+ restore_error_handler();
+ if ( $this->htmlErrors !== false ) {
+ ini_set( 'html_errors', $this->htmlErrors );
+ }
+
+ return $this->getLastPHPError();
+ }
+
+ /**
+ * @return string|bool Last PHP error for this DB (typically connection errors)
+ */
+ protected function getLastPHPError() {
+ if ( $this->mPHPError ) {
+ $error = preg_replace( '!\[<a.*</a>\]!', '', $this->mPHPError );
+ $error = preg_replace( '!^.*?:\s?(.*)$!', '$1', $error );
+
+ return $error;
+ }
+
+ return false;
+ }
+
+ /**
+ * Error handler for logging errors during database connection
+ * This method should not be used outside of Database classes
+ *
+ * @param int $errno
+ * @param string $errstr
+ */
+ public function connectionErrorLogger( $errno, $errstr ) {
+ $this->mPHPError = $errstr;
+ }
+
+ /**
+ * Create a log context to pass to PSR-3 logger functions.
+ *
+ * @param array $extras Additional data to add to context
+ * @return array
+ */
+ protected function getLogContext( array $extras = [] ) {
+ return array_merge(
+ [
+ 'db_server' => $this->mServer,
+ 'db_name' => $this->mDBname,
+ 'db_user' => $this->mUser,
+ ],
+ $extras
+ );
+ }
+
+ public function close() {
+ if ( $this->mConn ) {
+ if ( $this->trxLevel() ) {
+ $this->commit( __METHOD__, self::FLUSHING_INTERNAL );
+ }
+
+ $closed = $this->closeConnection();
+ $this->mConn = false;
+ } elseif (
+ $this->mTrxIdleCallbacks ||
+ $this->mTrxPreCommitCallbacks ||
+ $this->mTrxEndCallbacks
+ ) { // sanity
+ throw new RuntimeException( "Transaction callbacks still pending." );
+ } else {
+ $closed = true;
+ }
+ $this->mOpened = false;
+
+ return $closed;
+ }
+
+ /**
+ * Make sure isOpen() returns true as a sanity check
+ *
+ * @throws DBUnexpectedError
+ */
+ protected function assertOpen() {
+ if ( !$this->isOpen() ) {
+ throw new DBUnexpectedError( $this, "DB connection was already closed." );
+ }
+ }
+
+ /**
+ * Closes underlying database connection
+ * @since 1.20
+ * @return bool Whether connection was closed successfully
+ */
+ abstract protected function closeConnection();
+
+ public function reportConnectionError( $error = 'Unknown error' ) {
+ $myError = $this->lastError();
+ if ( $myError ) {
+ $error = $myError;
+ }
+
+ # New method
+ throw new DBConnectionError( $this, $error );
+ }
+
+ /**
+ * The DBMS-dependent part of query()
+ *
+ * @param string $sql SQL query.
+ * @return ResultWrapper|bool Result object to feed to fetchObject,
+ * fetchRow, ...; or false on failure
+ */
+ abstract protected function doQuery( $sql );
+
+ /**
+ * Determine whether a query writes to the DB.
+ * Should return true if unsure.
+ *
+ * @param string $sql
+ * @return bool
+ */
+ protected function isWriteQuery( $sql ) {
+ return !preg_match(
+ '/^(?:SELECT|BEGIN|ROLLBACK|COMMIT|SET|SHOW|EXPLAIN|\(SELECT)\b/i', $sql );
+ }
+
+ /**
+ * @param string $sql
+ * @return string|null
+ */
+ protected function getQueryVerb( $sql ) {
+ return preg_match( '/^\s*([a-z]+)/i', $sql, $m ) ? strtoupper( $m[1] ) : null;
+ }
+
+ /**
+ * Determine whether a SQL statement is sensitive to isolation level.
+ * A SQL statement is considered transactable if its result could vary
+ * depending on the transaction isolation level. Operational commands
+ * such as 'SET' and 'SHOW' are not considered to be transactable.
+ *
+ * @param string $sql
+ * @return bool
+ */
+ protected function isTransactableQuery( $sql ) {
+ return !in_array(
+ $this->getQueryVerb( $sql ),
+ [ 'BEGIN', 'COMMIT', 'ROLLBACK', 'SHOW', 'SET', 'CREATE', 'ALTER' ],
+ true
+ );
+ }
+
+ /**
+ * @param string $sql A SQL query
+ * @return bool Whether $sql is SQL for TEMPORARY table operation
+ */
+ protected function registerTempTableOperation( $sql ) {
+ if ( preg_match(
+ '/^CREATE\s+TEMPORARY\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?/i',
+ $sql,
+ $matches
+ ) ) {
+ $this->mSessionTempTables[$matches[1]] = 1;
+
+ return true;
+ } elseif ( preg_match(
+ '/^DROP\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?/i',
+ $sql,
+ $matches
+ ) ) {
+ $isTemp = isset( $this->mSessionTempTables[$matches[1]] );
+ unset( $this->mSessionTempTables[$matches[1]] );
+
+ return $isTemp;
+ } elseif ( preg_match(
+ '/^TRUNCATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?/i',
+ $sql,
+ $matches
+ ) ) {
+ return isset( $this->mSessionTempTables[$matches[1]] );
+ } elseif ( preg_match(
+ '/^(?:INSERT\s+(?:\w+\s+)?INTO|UPDATE|DELETE\s+FROM)\s+[`"\']?(\w+)[`"\']?/i',
+ $sql,
+ $matches
+ ) ) {
+ return isset( $this->mSessionTempTables[$matches[1]] );
+ }
+
+ return false;
+ }
+
+ public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) {
+ $priorWritesPending = $this->writesOrCallbacksPending();
+ $this->mLastQuery = $sql;
+
+ $isWrite = $this->isWriteQuery( $sql );
+ if ( $isWrite ) {
+ $isNonTempWrite = !$this->registerTempTableOperation( $sql );
+ } else {
+ $isNonTempWrite = false;
+ }
+
+ if ( $isWrite ) {
+ # In theory, non-persistent writes are allowed in read-only mode, but due to things
+ # like https://bugs.mysql.com/bug.php?id=33669 that might not work anyway...
+ $reason = $this->getReadOnlyReason();
+ if ( $reason !== false ) {
+ throw new DBReadOnlyError( $this, "Database is read-only: $reason" );
+ }
+ # Set a flag indicating that writes have been done
+ $this->mLastWriteTime = microtime( true );
+ }
+
+ # Add trace comment to the begin of the sql string, right after the operator.
+ # Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598)
+ $commentedSql = preg_replace( '/\s|$/', " /* $fname {$this->agent} */ ", $sql, 1 );
+
+ # Start implicit transactions that wrap the request if DBO_TRX is enabled
+ if ( !$this->mTrxLevel && $this->getFlag( self::DBO_TRX )
+ && $this->isTransactableQuery( $sql )
+ ) {
+ $this->begin( __METHOD__ . " ($fname)", self::TRANSACTION_INTERNAL );
+ $this->mTrxAutomatic = true;
+ }
+
+ # Keep track of whether the transaction has write queries pending
+ if ( $this->mTrxLevel && !$this->mTrxDoneWrites && $isWrite ) {
+ $this->mTrxDoneWrites = true;
+ $this->trxProfiler->transactionWritingIn(
+ $this->mServer, $this->mDBname, $this->mTrxShortId );
+ }
+
+ if ( $this->getFlag( self::DBO_DEBUG ) ) {
+ $this->queryLogger->debug( "{$this->mDBname} {$commentedSql}" );
+ }
+
+ # Avoid fatals if close() was called
+ $this->assertOpen();
+
+ # Send the query to the server
+ $ret = $this->doProfiledQuery( $sql, $commentedSql, $isNonTempWrite, $fname );
+
+ # Try reconnecting if the connection was lost
+ if ( false === $ret && $this->wasErrorReissuable() ) {
+ $recoverable = $this->canRecoverFromDisconnect( $sql, $priorWritesPending );
+ # Stash the last error values before anything might clear them
+ $lastError = $this->lastError();
+ $lastErrno = $this->lastErrno();
+ # Update state tracking to reflect transaction loss due to disconnection
+ $this->handleSessionLoss();
+ if ( $this->reconnect() ) {
+ $msg = __METHOD__ . ": lost connection to {$this->getServer()}; reconnected";
+ $this->connLogger->warning( $msg );
+ $this->queryLogger->warning(
+ "$msg:\n" . ( new RuntimeException() )->getTraceAsString() );
+
+ if ( !$recoverable ) {
+ # Callers may catch the exception and continue to use the DB
+ $this->reportQueryError( $lastError, $lastErrno, $sql, $fname );
+ } else {
+ # Should be safe to silently retry the query
+ $ret = $this->doProfiledQuery( $sql, $commentedSql, $isNonTempWrite, $fname );
+ }
+ } else {
+ $msg = __METHOD__ . ": lost connection to {$this->getServer()} permanently";
+ $this->connLogger->error( $msg );
+ }
+ }
+
+ if ( false === $ret ) {
+ # Deadlocks cause the entire transaction to abort, not just the statement.
+ # https://dev.mysql.com/doc/refman/5.7/en/innodb-error-handling.html
+ # https://www.postgresql.org/docs/9.1/static/explicit-locking.html
+ if ( $this->wasDeadlock() ) {
+ if ( $this->explicitTrxActive() || $priorWritesPending ) {
+ $tempIgnore = false; // not recoverable
+ }
+ # Update state tracking to reflect transaction loss
+ $this->handleSessionLoss();
+ }
+
+ $this->reportQueryError(
+ $this->lastError(), $this->lastErrno(), $sql, $fname, $tempIgnore );
+ }
+
+ $res = $this->resultObject( $ret );
+
+ return $res;
+ }
+
+ /**
+ * Helper method for query() that handles profiling and logging and sends
+ * the query to doQuery()
+ *
+ * @param string $sql Original SQL query
+ * @param string $commentedSql SQL query with debugging/trace comment
+ * @param bool $isWrite Whether the query is a (non-temporary) write operation
+ * @param string $fname Name of the calling function
+ * @return bool|ResultWrapper True for a successful write query, ResultWrapper
+ * object for a successful read query, or false on failure
+ */
+ private function doProfiledQuery( $sql, $commentedSql, $isWrite, $fname ) {
+ $isMaster = !is_null( $this->getLBInfo( 'master' ) );
+ # generalizeSQL() will probably cut down the query to reasonable
+ # logging size most of the time. The substr is really just a sanity check.
+ if ( $isMaster ) {
+ $queryProf = 'query-m: ' . substr( self::generalizeSQL( $sql ), 0, 255 );
+ } else {
+ $queryProf = 'query: ' . substr( self::generalizeSQL( $sql ), 0, 255 );
+ }
+
+ # Include query transaction state
+ $queryProf .= $this->mTrxShortId ? " [TRX#{$this->mTrxShortId}]" : "";
+
+ $startTime = microtime( true );
+ if ( $this->profiler ) {
+ call_user_func( [ $this->profiler, 'profileIn' ], $queryProf );
+ }
+ $ret = $this->doQuery( $commentedSql );
+ if ( $this->profiler ) {
+ call_user_func( [ $this->profiler, 'profileOut' ], $queryProf );
+ }
+ $queryRuntime = max( microtime( true ) - $startTime, 0.0 );
+
+ unset( $queryProfSection ); // profile out (if set)
+
+ if ( $ret !== false ) {
+ $this->lastPing = $startTime;
+ if ( $isWrite && $this->mTrxLevel ) {
+ $this->updateTrxWriteQueryTime( $sql, $queryRuntime, $this->affectedRows() );
+ $this->mTrxWriteCallers[] = $fname;
+ }
+ }
+
+ if ( $sql === self::PING_QUERY ) {
+ $this->mRTTEstimate = $queryRuntime;
+ }
+
+ $this->trxProfiler->recordQueryCompletion(
+ $queryProf, $startTime, $isWrite, $this->affectedRows()
+ );
+ $this->queryLogger->debug( $sql, [
+ 'method' => $fname,
+ 'master' => $isMaster,
+ 'runtime' => $queryRuntime,
+ ] );
+
+ return $ret;
+ }
+
+ /**
+ * Update the estimated run-time of a query, not counting large row lock times
+ *
+ * LoadBalancer can be set to rollback transactions that will create huge replication
+ * lag. It bases this estimate off of pendingWriteQueryDuration(). Certain simple
+ * queries, like inserting a row can take a long time due to row locking. This method
+ * uses some simple heuristics to discount those cases.
+ *
+ * @param string $sql A SQL write query
+ * @param float $runtime Total runtime, including RTT
+ * @param int $affected Affected row count
+ */
+ private function updateTrxWriteQueryTime( $sql, $runtime, $affected ) {
+ // Whether this is indicative of replica DB runtime (except for RBR or ws_repl)
+ $indicativeOfReplicaRuntime = true;
+ if ( $runtime > self::SLOW_WRITE_SEC ) {
+ $verb = $this->getQueryVerb( $sql );
+ // insert(), upsert(), replace() are fast unless bulky in size or blocked on locks
+ if ( $verb === 'INSERT' ) {
+ $indicativeOfReplicaRuntime = $this->affectedRows() > self::SMALL_WRITE_ROWS;
+ } elseif ( $verb === 'REPLACE' ) {
+ $indicativeOfReplicaRuntime = $this->affectedRows() > self::SMALL_WRITE_ROWS / 2;
+ }
+ }
+
+ $this->mTrxWriteDuration += $runtime;
+ $this->mTrxWriteQueryCount += 1;
+ $this->mTrxWriteAffectedRows += $affected;
+ if ( $indicativeOfReplicaRuntime ) {
+ $this->mTrxWriteAdjDuration += $runtime;
+ $this->mTrxWriteAdjQueryCount += 1;
+ }
+ }
+
+ /**
+ * Determine whether or not it is safe to retry queries after a database
+ * connection is lost
+ *
+ * @param string $sql SQL query
+ * @param bool $priorWritesPending Whether there is a transaction open with
+ * possible write queries or transaction pre-commit/idle callbacks
+ * waiting on it to finish.
+ * @return bool True if it is safe to retry the query, false otherwise
+ */
+ private function canRecoverFromDisconnect( $sql, $priorWritesPending ) {
+ # Transaction dropped; this can mean lost writes, or REPEATABLE-READ snapshots.
+ # Dropped connections also mean that named locks are automatically released.
+ # Only allow error suppression in autocommit mode or when the lost transaction
+ # didn't matter anyway (aside from DBO_TRX snapshot loss).
+ if ( $this->mNamedLocksHeld ) {
+ return false; // possible critical section violation
+ } elseif ( $sql === 'COMMIT' ) {
+ return !$priorWritesPending; // nothing written anyway? (T127428)
+ } elseif ( $sql === 'ROLLBACK' ) {
+ return true; // transaction lost...which is also what was requested :)
+ } elseif ( $this->explicitTrxActive() ) {
+ return false; // don't drop atomocity
+ } elseif ( $priorWritesPending ) {
+ return false; // prior writes lost from implicit transaction
+ }
+
+ return true;
+ }
+
+ /**
+ * Clean things up after transaction loss due to disconnection
+ *
+ * @return null|Exception
+ */
+ private function handleSessionLoss() {
+ $this->mTrxLevel = 0;
+ $this->mTrxIdleCallbacks = []; // T67263
+ $this->mTrxPreCommitCallbacks = []; // T67263
+ $this->mSessionTempTables = [];
+ $this->mNamedLocksHeld = [];
+ try {
+ // Handle callbacks in mTrxEndCallbacks
+ $this->runOnTransactionIdleCallbacks( self::TRIGGER_ROLLBACK );
+ $this->runTransactionListenerCallbacks( self::TRIGGER_ROLLBACK );
+ return null;
+ } catch ( Exception $e ) {
+ // Already logged; move on...
+ return $e;
+ }
+ }
+
+ public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
+ if ( $this->ignoreErrors() || $tempIgnore ) {
+ $this->queryLogger->debug( "SQL ERROR (ignored): $error\n" );
+ } else {
+ $sql1line = mb_substr( str_replace( "\n", "\\n", $sql ), 0, 5 * 1024 );
+ $this->queryLogger->error(
+ "{fname}\t{db_server}\t{errno}\t{error}\t{sql1line}",
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ 'errno' => $errno,
+ 'error' => $error,
+ 'sql1line' => $sql1line,
+ 'fname' => $fname,
+ ] )
+ );
+ $this->queryLogger->debug( "SQL ERROR: " . $error . "\n" );
+ throw new DBQueryError( $this, $error, $errno, $sql, $fname );
+ }
+ }
+
+ public function freeResult( $res ) {
+ }
+
+ public function selectField(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ if ( $var === '*' ) { // sanity
+ throw new DBUnexpectedError( $this, "Cannot use a * field: got '$var'" );
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $options['LIMIT'] = 1;
+
+ $res = $this->select( $table, $var, $cond, $fname, $options, $join_conds );
+ if ( $res === false || !$this->numRows( $res ) ) {
+ return false;
+ }
+
+ $row = $this->fetchRow( $res );
+
+ if ( $row !== false ) {
+ return reset( $row );
+ } else {
+ return false;
+ }
+ }
+
+ public function selectFieldValues(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ if ( $var === '*' ) { // sanity
+ throw new DBUnexpectedError( $this, "Cannot use a * field" );
+ } elseif ( !is_string( $var ) ) { // sanity
+ throw new DBUnexpectedError( $this, "Cannot use an array of fields" );
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $res = $this->select( $table, $var, $cond, $fname, $options, $join_conds );
+ if ( $res === false ) {
+ return false;
+ }
+
+ $values = [];
+ foreach ( $res as $row ) {
+ $values[] = $row->$var;
+ }
+
+ return $values;
+ }
+
+ /**
+ * Returns an optional USE INDEX clause to go after the table, and a
+ * string to go at the end of the query.
+ *
+ * @param array $options Associative array of options to be turned into
+ * an SQL query, valid keys are listed in the function.
+ * @return array
+ * @see Database::select()
+ */
+ protected function makeSelectOptions( $options ) {
+ $preLimitTail = $postLimitTail = '';
+ $startOpts = '';
+
+ $noKeyOptions = [];
+
+ foreach ( $options as $key => $option ) {
+ if ( is_numeric( $key ) ) {
+ $noKeyOptions[$option] = true;
+ }
+ }
+
+ $preLimitTail .= $this->makeGroupByWithHaving( $options );
+
+ $preLimitTail .= $this->makeOrderBy( $options );
+
+ if ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
+ $postLimitTail .= ' FOR UPDATE';
+ }
+
+ if ( isset( $noKeyOptions['LOCK IN SHARE MODE'] ) ) {
+ $postLimitTail .= ' LOCK IN SHARE MODE';
+ }
+
+ if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
+ $startOpts .= 'DISTINCT';
+ }
+
+ # Various MySQL extensions
+ if ( isset( $noKeyOptions['STRAIGHT_JOIN'] ) ) {
+ $startOpts .= ' /*! STRAIGHT_JOIN */';
+ }
+
+ if ( isset( $noKeyOptions['HIGH_PRIORITY'] ) ) {
+ $startOpts .= ' HIGH_PRIORITY';
+ }
+
+ if ( isset( $noKeyOptions['SQL_BIG_RESULT'] ) ) {
+ $startOpts .= ' SQL_BIG_RESULT';
+ }
+
+ if ( isset( $noKeyOptions['SQL_BUFFER_RESULT'] ) ) {
+ $startOpts .= ' SQL_BUFFER_RESULT';
+ }
+
+ if ( isset( $noKeyOptions['SQL_SMALL_RESULT'] ) ) {
+ $startOpts .= ' SQL_SMALL_RESULT';
+ }
+
+ if ( isset( $noKeyOptions['SQL_CALC_FOUND_ROWS'] ) ) {
+ $startOpts .= ' SQL_CALC_FOUND_ROWS';
+ }
+
+ if ( isset( $noKeyOptions['SQL_CACHE'] ) ) {
+ $startOpts .= ' SQL_CACHE';
+ }
+
+ if ( isset( $noKeyOptions['SQL_NO_CACHE'] ) ) {
+ $startOpts .= ' SQL_NO_CACHE';
+ }
+
+ if ( isset( $options['USE INDEX'] ) && is_string( $options['USE INDEX'] ) ) {
+ $useIndex = $this->useIndexClause( $options['USE INDEX'] );
+ } else {
+ $useIndex = '';
+ }
+ if ( isset( $options['IGNORE INDEX'] ) && is_string( $options['IGNORE INDEX'] ) ) {
+ $ignoreIndex = $this->ignoreIndexClause( $options['IGNORE INDEX'] );
+ } else {
+ $ignoreIndex = '';
+ }
+
+ return [ $startOpts, $useIndex, $preLimitTail, $postLimitTail, $ignoreIndex ];
+ }
+
+ /**
+ * Returns an optional GROUP BY with an optional HAVING
+ *
+ * @param array $options Associative array of options
+ * @return string
+ * @see Database::select()
+ * @since 1.21
+ */
+ protected function makeGroupByWithHaving( $options ) {
+ $sql = '';
+ if ( isset( $options['GROUP BY'] ) ) {
+ $gb = is_array( $options['GROUP BY'] )
+ ? implode( ',', $options['GROUP BY'] )
+ : $options['GROUP BY'];
+ $sql .= ' GROUP BY ' . $gb;
+ }
+ if ( isset( $options['HAVING'] ) ) {
+ $having = is_array( $options['HAVING'] )
+ ? $this->makeList( $options['HAVING'], self::LIST_AND )
+ : $options['HAVING'];
+ $sql .= ' HAVING ' . $having;
+ }
+
+ return $sql;
+ }
+
+ /**
+ * Returns an optional ORDER BY
+ *
+ * @param array $options Associative array of options
+ * @return string
+ * @see Database::select()
+ * @since 1.21
+ */
+ protected function makeOrderBy( $options ) {
+ if ( isset( $options['ORDER BY'] ) ) {
+ $ob = is_array( $options['ORDER BY'] )
+ ? implode( ',', $options['ORDER BY'] )
+ : $options['ORDER BY'];
+
+ return ' ORDER BY ' . $ob;
+ }
+
+ return '';
+ }
+
+ public function select( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = [] ) {
+ $sql = $this->selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
+
+ return $this->query( $sql, $fname );
+ }
+
+ public function selectSQLText( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ if ( is_array( $vars ) ) {
+ $vars = implode( ',', $this->fieldNamesWithAlias( $vars ) );
+ }
+
+ $options = (array)$options;
+ $useIndexes = ( isset( $options['USE INDEX'] ) && is_array( $options['USE INDEX'] ) )
+ ? $options['USE INDEX']
+ : [];
+ $ignoreIndexes = (
+ isset( $options['IGNORE INDEX'] ) &&
+ is_array( $options['IGNORE INDEX'] )
+ )
+ ? $options['IGNORE INDEX']
+ : [];
+
+ if ( is_array( $table ) ) {
+ $from = ' FROM ' .
+ $this->tableNamesWithIndexClauseOrJOIN(
+ $table, $useIndexes, $ignoreIndexes, $join_conds );
+ } elseif ( $table != '' ) {
+ if ( $table[0] == ' ' ) {
+ $from = ' FROM ' . $table;
+ } else {
+ $from = ' FROM ' .
+ $this->tableNamesWithIndexClauseOrJOIN(
+ [ $table ], $useIndexes, $ignoreIndexes, [] );
+ }
+ } else {
+ $from = '';
+ }
+
+ list( $startOpts, $useIndex, $preLimitTail, $postLimitTail, $ignoreIndex ) =
+ $this->makeSelectOptions( $options );
+
+ if ( !empty( $conds ) ) {
+ if ( is_array( $conds ) ) {
+ $conds = $this->makeList( $conds, self::LIST_AND );
+ }
+ $sql = "SELECT $startOpts $vars $from $useIndex $ignoreIndex " .
+ "WHERE $conds $preLimitTail";
+ } else {
+ $sql = "SELECT $startOpts $vars $from $useIndex $ignoreIndex $preLimitTail";
+ }
+
+ if ( isset( $options['LIMIT'] ) ) {
+ $sql = $this->limitResult( $sql, $options['LIMIT'],
+ isset( $options['OFFSET'] ) ? $options['OFFSET'] : false );
+ }
+ $sql = "$sql $postLimitTail";
+
+ if ( isset( $options['EXPLAIN'] ) ) {
+ $sql = 'EXPLAIN ' . $sql;
+ }
+
+ return $sql;
+ }
+
+ public function selectRow( $table, $vars, $conds, $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ $options = (array)$options;
+ $options['LIMIT'] = 1;
+ $res = $this->select( $table, $vars, $conds, $fname, $options, $join_conds );
+
+ if ( $res === false ) {
+ return false;
+ }
+
+ if ( !$this->numRows( $res ) ) {
+ return false;
+ }
+
+ $obj = $this->fetchObject( $res );
+
+ return $obj;
+ }
+
+ public function estimateRowCount(
+ $table, $vars = '*', $conds = '', $fname = __METHOD__, $options = []
+ ) {
+ $rows = 0;
+ $res = $this->select( $table, [ 'rowcount' => 'COUNT(*)' ], $conds, $fname, $options );
+
+ if ( $res ) {
+ $row = $this->fetchRow( $res );
+ $rows = ( isset( $row['rowcount'] ) ) ? (int)$row['rowcount'] : 0;
+ }
+
+ return $rows;
+ }
+
+ public function selectRowCount(
+ $tables, $vars = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ $rows = 0;
+ $sql = $this->selectSQLText( $tables, '1', $conds, $fname, $options, $join_conds );
+ // The identifier quotes is primarily for MSSQL.
+ $rowCountCol = $this->addIdentifierQuotes( "rowcount" );
+ $tableName = $this->addIdentifierQuotes( "tmp_count" );
+ $res = $this->query( "SELECT COUNT(*) AS $rowCountCol FROM ($sql) $tableName", $fname );
+
+ if ( $res ) {
+ $row = $this->fetchRow( $res );
+ $rows = ( isset( $row['rowcount'] ) ) ? (int)$row['rowcount'] : 0;
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Removes most variables from an SQL query and replaces them with X or N for numbers.
+ * It's only slightly flawed. Don't use for anything important.
+ *
+ * @param string $sql A SQL Query
+ *
+ * @return string
+ */
+ protected static function generalizeSQL( $sql ) {
+ # This does the same as the regexp below would do, but in such a way
+ # as to avoid crashing php on some large strings.
+ # $sql = preg_replace( "/'([^\\\\']|\\\\.)*'|\"([^\\\\\"]|\\\\.)*\"/", "'X'", $sql );
+
+ $sql = str_replace( "\\\\", '', $sql );
+ $sql = str_replace( "\\'", '', $sql );
+ $sql = str_replace( "\\\"", '', $sql );
+ $sql = preg_replace( "/'.*'/s", "'X'", $sql );
+ $sql = preg_replace( '/".*"/s', "'X'", $sql );
+
+ # All newlines, tabs, etc replaced by single space
+ $sql = preg_replace( '/\s+/', ' ', $sql );
+
+ # All numbers => N,
+ # except the ones surrounded by characters, e.g. l10n
+ $sql = preg_replace( '/-?\d+(,-?\d+)+/s', 'N,...,N', $sql );
+ $sql = preg_replace( '/(?<![a-zA-Z])-?\d+(?![a-zA-Z])/s', 'N', $sql );
+
+ return $sql;
+ }
+
+ public function fieldExists( $table, $field, $fname = __METHOD__ ) {
+ $info = $this->fieldInfo( $table, $field );
+
+ return (bool)$info;
+ }
+
+ public function indexExists( $table, $index, $fname = __METHOD__ ) {
+ if ( !$this->tableExists( $table ) ) {
+ return null;
+ }
+
+ $info = $this->indexInfo( $table, $index, $fname );
+ if ( is_null( $info ) ) {
+ return null;
+ } else {
+ return $info !== false;
+ }
+ }
+
+ public function tableExists( $table, $fname = __METHOD__ ) {
+ $tableRaw = $this->tableName( $table, 'raw' );
+ if ( isset( $this->mSessionTempTables[$tableRaw] ) ) {
+ return true; // already known to exist
+ }
+
+ $table = $this->tableName( $table );
+ $ignoreErrors = true;
+ $res = $this->query( "SELECT 1 FROM $table LIMIT 1", $fname, $ignoreErrors );
+
+ return (bool)$res;
+ }
+
+ public function indexUnique( $table, $index ) {
+ $indexInfo = $this->indexInfo( $table, $index );
+
+ if ( !$indexInfo ) {
+ return null;
+ }
+
+ return !$indexInfo[0]->Non_unique;
+ }
+
+ /**
+ * Helper for Database::insert().
+ *
+ * @param array $options
+ * @return string
+ */
+ protected function makeInsertOptions( $options ) {
+ return implode( ' ', $options );
+ }
+
+ public function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
+ # No rows to insert, easy just return now
+ if ( !count( $a ) ) {
+ return true;
+ }
+
+ $table = $this->tableName( $table );
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $fh = null;
+ if ( isset( $options['fileHandle'] ) ) {
+ $fh = $options['fileHandle'];
+ }
+ $options = $this->makeInsertOptions( $options );
+
+ if ( isset( $a[0] ) && is_array( $a[0] ) ) {
+ $multi = true;
+ $keys = array_keys( $a[0] );
+ } else {
+ $multi = false;
+ $keys = array_keys( $a );
+ }
+
+ $sql = 'INSERT ' . $options .
+ " INTO $table (" . implode( ',', $keys ) . ') VALUES ';
+
+ if ( $multi ) {
+ $first = true;
+ foreach ( $a as $row ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $sql .= ',';
+ }
+ $sql .= '(' . $this->makeList( $row ) . ')';
+ }
+ } else {
+ $sql .= '(' . $this->makeList( $a ) . ')';
+ }
+
+ if ( $fh !== null && false === fwrite( $fh, $sql ) ) {
+ return false;
+ } elseif ( $fh !== null ) {
+ return true;
+ }
+
+ return (bool)$this->query( $sql, $fname );
+ }
+
+ /**
+ * Make UPDATE options array for Database::makeUpdateOptions
+ *
+ * @param array $options
+ * @return array
+ */
+ protected function makeUpdateOptionsArray( $options ) {
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $opts = [];
+
+ if ( in_array( 'IGNORE', $options ) ) {
+ $opts[] = 'IGNORE';
+ }
+
+ return $opts;
+ }
+
+ /**
+ * Make UPDATE options for the Database::update function
+ *
+ * @param array $options The options passed to Database::update
+ * @return string
+ */
+ protected function makeUpdateOptions( $options ) {
+ $opts = $this->makeUpdateOptionsArray( $options );
+
+ return implode( ' ', $opts );
+ }
+
+ public function update( $table, $values, $conds, $fname = __METHOD__, $options = [] ) {
+ $table = $this->tableName( $table );
+ $opts = $this->makeUpdateOptions( $options );
+ $sql = "UPDATE $opts $table SET " . $this->makeList( $values, self::LIST_SET );
+
+ if ( $conds !== [] && $conds !== '*' ) {
+ $sql .= " WHERE " . $this->makeList( $conds, self::LIST_AND );
+ }
+
+ return (bool)$this->query( $sql, $fname );
+ }
+
+ public function makeList( $a, $mode = self::LIST_COMMA ) {
+ if ( !is_array( $a ) ) {
+ throw new DBUnexpectedError( $this, __METHOD__ . ' called with incorrect parameters' );
+ }
+
+ $first = true;
+ $list = '';
+
+ foreach ( $a as $field => $value ) {
+ if ( !$first ) {
+ if ( $mode == self::LIST_AND ) {
+ $list .= ' AND ';
+ } elseif ( $mode == self::LIST_OR ) {
+ $list .= ' OR ';
+ } else {
+ $list .= ',';
+ }
+ } else {
+ $first = false;
+ }
+
+ if ( ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_numeric( $field ) ) {
+ $list .= "($value)";
+ } elseif ( $mode == self::LIST_SET && is_numeric( $field ) ) {
+ $list .= "$value";
+ } elseif (
+ ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_array( $value )
+ ) {
+ // Remove null from array to be handled separately if found
+ $includeNull = false;
+ foreach ( array_keys( $value, null, true ) as $nullKey ) {
+ $includeNull = true;
+ unset( $value[$nullKey] );
+ }
+ if ( count( $value ) == 0 && !$includeNull ) {
+ throw new InvalidArgumentException(
+ __METHOD__ . ": empty input for field $field" );
+ } elseif ( count( $value ) == 0 ) {
+ // only check if $field is null
+ $list .= "$field IS NULL";
+ } else {
+ // IN clause contains at least one valid element
+ if ( $includeNull ) {
+ // Group subconditions to ensure correct precedence
+ $list .= '(';
+ }
+ if ( count( $value ) == 1 ) {
+ // Special-case single values, as IN isn't terribly efficient
+ // Don't necessarily assume the single key is 0; we don't
+ // enforce linear numeric ordering on other arrays here.
+ $value = array_values( $value )[0];
+ $list .= $field . " = " . $this->addQuotes( $value );
+ } else {
+ $list .= $field . " IN (" . $this->makeList( $value ) . ") ";
+ }
+ // if null present in array, append IS NULL
+ if ( $includeNull ) {
+ $list .= " OR $field IS NULL)";
+ }
+ }
+ } elseif ( $value === null ) {
+ if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
+ $list .= "$field IS ";
+ } elseif ( $mode == self::LIST_SET ) {
+ $list .= "$field = ";
+ }
+ $list .= 'NULL';
+ } else {
+ if (
+ $mode == self::LIST_AND || $mode == self::LIST_OR || $mode == self::LIST_SET
+ ) {
+ $list .= "$field = ";
+ }
+ $list .= $mode == self::LIST_NAMES ? $value : $this->addQuotes( $value );
+ }
+ }
+
+ return $list;
+ }
+
+ public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
+ $conds = [];
+
+ foreach ( $data as $base => $sub ) {
+ if ( count( $sub ) ) {
+ $conds[] = $this->makeList(
+ [ $baseKey => $base, $subKey => array_keys( $sub ) ],
+ self::LIST_AND );
+ }
+ }
+
+ if ( $conds ) {
+ return $this->makeList( $conds, self::LIST_OR );
+ } else {
+ // Nothing to search for...
+ return false;
+ }
+ }
+
+ public function aggregateValue( $valuedata, $valuename = 'value' ) {
+ return $valuename;
+ }
+
+ public function bitNot( $field ) {
+ return "(~$field)";
+ }
+
+ public function bitAnd( $fieldLeft, $fieldRight ) {
+ return "($fieldLeft & $fieldRight)";
+ }
+
+ public function bitOr( $fieldLeft, $fieldRight ) {
+ return "($fieldLeft | $fieldRight)";
+ }
+
+ public function buildConcat( $stringList ) {
+ return 'CONCAT(' . implode( ',', $stringList ) . ')';
+ }
+
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ ) {
+ $fld = "GROUP_CONCAT($field SEPARATOR " . $this->addQuotes( $delim ) . ')';
+
+ return '(' . $this->selectSQLText( $table, $fld, $conds, null, [], $join_conds ) . ')';
+ }
+
+ public function buildStringCast( $field ) {
+ return $field;
+ }
+
+ public function databasesAreIndependent() {
+ return false;
+ }
+
+ public function selectDB( $db ) {
+ # Stub. Shouldn't cause serious problems if it's not overridden, but
+ # if your database engine supports a concept similar to MySQL's
+ # databases you may as well.
+ $this->mDBname = $db;
+
+ return true;
+ }
+
+ public function getDBname() {
+ return $this->mDBname;
+ }
+
+ public function getServer() {
+ return $this->mServer;
+ }
+
+ public function tableName( $name, $format = 'quoted' ) {
+ # Skip the entire process when we have a string quoted on both ends.
+ # Note that we check the end so that we will still quote any use of
+ # use of `database`.table. But won't break things if someone wants
+ # to query a database table with a dot in the name.
+ if ( $this->isQuotedIdentifier( $name ) ) {
+ return $name;
+ }
+
+ # Lets test for any bits of text that should never show up in a table
+ # name. Basically anything like JOIN or ON which are actually part of
+ # SQL queries, but may end up inside of the table value to combine
+ # sql. Such as how the API is doing.
+ # Note that we use a whitespace test rather than a \b test to avoid
+ # any remote case where a word like on may be inside of a table name
+ # surrounded by symbols which may be considered word breaks.
+ if ( preg_match( '/(^|\s)(DISTINCT|JOIN|ON|AS)(\s|$)/i', $name ) !== 0 ) {
+ return $name;
+ }
+
+ # Split database and table into proper variables.
+ list( $database, $schema, $prefix, $table ) = $this->qualifiedTableComponents( $name );
+
+ # Quote $table and apply the prefix if not quoted.
+ # $tableName might be empty if this is called from Database::replaceVars()
+ $tableName = "{$prefix}{$table}";
+ if ( $format === 'quoted'
+ && !$this->isQuotedIdentifier( $tableName )
+ && $tableName !== ''
+ ) {
+ $tableName = $this->addIdentifierQuotes( $tableName );
+ }
+
+ # Quote $schema and $database and merge them with the table name if needed
+ $tableName = $this->prependDatabaseOrSchema( $schema, $tableName, $format );
+ $tableName = $this->prependDatabaseOrSchema( $database, $tableName, $format );
+
+ return $tableName;
+ }
+
+ /**
+ * Get the table components needed for a query given the currently selected database
+ *
+ * @param string $name Table name in the form of db.schema.table, db.table, or table
+ * @return array (DB name or "" for default, schema name, table prefix, table name)
+ */
+ protected function qualifiedTableComponents( $name ) {
+ # We reverse the explode so that database.table and table both output the correct table.
+ $dbDetails = explode( '.', $name, 3 );
+ if ( count( $dbDetails ) == 3 ) {
+ list( $database, $schema, $table ) = $dbDetails;
+ # We don't want any prefix added in this case
+ $prefix = '';
+ } elseif ( count( $dbDetails ) == 2 ) {
+ list( $database, $table ) = $dbDetails;
+ # We don't want any prefix added in this case
+ $prefix = '';
+ # In dbs that support it, $database may actually be the schema
+ # but that doesn't affect any of the functionality here
+ $schema = '';
+ } else {
+ list( $table ) = $dbDetails;
+ if ( isset( $this->tableAliases[$table] ) ) {
+ $database = $this->tableAliases[$table]['dbname'];
+ $schema = is_string( $this->tableAliases[$table]['schema'] )
+ ? $this->tableAliases[$table]['schema']
+ : $this->mSchema;
+ $prefix = is_string( $this->tableAliases[$table]['prefix'] )
+ ? $this->tableAliases[$table]['prefix']
+ : $this->mTablePrefix;
+ } else {
+ $database = '';
+ $schema = $this->mSchema; # Default schema
+ $prefix = $this->mTablePrefix; # Default prefix
+ }
+ }
+
+ return [ $database, $schema, $prefix, $table ];
+ }
+
+ /**
+ * @param string|null $namespace Database or schema
+ * @param string $relation Name of table, view, sequence, etc...
+ * @param string $format One of (raw, quoted)
+ * @return string Relation name with quoted and merged $namespace as needed
+ */
+ private function prependDatabaseOrSchema( $namespace, $relation, $format ) {
+ if ( strlen( $namespace ) ) {
+ if ( $format === 'quoted' && !$this->isQuotedIdentifier( $namespace ) ) {
+ $namespace = $this->addIdentifierQuotes( $namespace );
+ }
+ $relation = $namespace . '.' . $relation;
+ }
+
+ return $relation;
+ }
+
+ public function tableNames() {
+ $inArray = func_get_args();
+ $retVal = [];
+
+ foreach ( $inArray as $name ) {
+ $retVal[$name] = $this->tableName( $name );
+ }
+
+ return $retVal;
+ }
+
+ public function tableNamesN() {
+ $inArray = func_get_args();
+ $retVal = [];
+
+ foreach ( $inArray as $name ) {
+ $retVal[] = $this->tableName( $name );
+ }
+
+ return $retVal;
+ }
+
+ /**
+ * Get an aliased table name
+ * e.g. tableName AS newTableName
+ *
+ * @param string $name Table name, see tableName()
+ * @param string|bool $alias Alias (optional)
+ * @return string SQL name for aliased table. Will not alias a table to its own name
+ */
+ protected function tableNameWithAlias( $name, $alias = false ) {
+ if ( !$alias || $alias == $name ) {
+ return $this->tableName( $name );
+ } else {
+ return $this->tableName( $name ) . ' ' . $this->addIdentifierQuotes( $alias );
+ }
+ }
+
+ /**
+ * Gets an array of aliased table names
+ *
+ * @param array $tables [ [alias] => table ]
+ * @return string[] See tableNameWithAlias()
+ */
+ protected function tableNamesWithAlias( $tables ) {
+ $retval = [];
+ foreach ( $tables as $alias => $table ) {
+ if ( is_numeric( $alias ) ) {
+ $alias = $table;
+ }
+ $retval[] = $this->tableNameWithAlias( $table, $alias );
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Get an aliased field name
+ * e.g. fieldName AS newFieldName
+ *
+ * @param string $name Field name
+ * @param string|bool $alias Alias (optional)
+ * @return string SQL name for aliased field. Will not alias a field to its own name
+ */
+ protected function fieldNameWithAlias( $name, $alias = false ) {
+ if ( !$alias || (string)$alias === (string)$name ) {
+ return $name;
+ } else {
+ return $name . ' AS ' . $this->addIdentifierQuotes( $alias ); // PostgreSQL needs AS
+ }
+ }
+
+ /**
+ * Gets an array of aliased field names
+ *
+ * @param array $fields [ [alias] => field ]
+ * @return string[] See fieldNameWithAlias()
+ */
+ protected function fieldNamesWithAlias( $fields ) {
+ $retval = [];
+ foreach ( $fields as $alias => $field ) {
+ if ( is_numeric( $alias ) ) {
+ $alias = $field;
+ }
+ $retval[] = $this->fieldNameWithAlias( $field, $alias );
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Get the aliased table name clause for a FROM clause
+ * which might have a JOIN and/or USE INDEX or IGNORE INDEX clause
+ *
+ * @param array $tables ( [alias] => table )
+ * @param array $use_index Same as for select()
+ * @param array $ignore_index Same as for select()
+ * @param array $join_conds Same as for select()
+ * @return string
+ */
+ protected function tableNamesWithIndexClauseOrJOIN(
+ $tables, $use_index = [], $ignore_index = [], $join_conds = []
+ ) {
+ $ret = [];
+ $retJOIN = [];
+ $use_index = (array)$use_index;
+ $ignore_index = (array)$ignore_index;
+ $join_conds = (array)$join_conds;
+
+ foreach ( $tables as $alias => $table ) {
+ if ( !is_string( $alias ) ) {
+ // No alias? Set it equal to the table name
+ $alias = $table;
+ }
+ // Is there a JOIN clause for this table?
+ if ( isset( $join_conds[$alias] ) ) {
+ list( $joinType, $conds ) = $join_conds[$alias];
+ $tableClause = $joinType;
+ $tableClause .= ' ' . $this->tableNameWithAlias( $table, $alias );
+ if ( isset( $use_index[$alias] ) ) { // has USE INDEX?
+ $use = $this->useIndexClause( implode( ',', (array)$use_index[$alias] ) );
+ if ( $use != '' ) {
+ $tableClause .= ' ' . $use;
+ }
+ }
+ if ( isset( $ignore_index[$alias] ) ) { // has IGNORE INDEX?
+ $ignore = $this->ignoreIndexClause(
+ implode( ',', (array)$ignore_index[$alias] ) );
+ if ( $ignore != '' ) {
+ $tableClause .= ' ' . $ignore;
+ }
+ }
+ $on = $this->makeList( (array)$conds, self::LIST_AND );
+ if ( $on != '' ) {
+ $tableClause .= ' ON (' . $on . ')';
+ }
+
+ $retJOIN[] = $tableClause;
+ } elseif ( isset( $use_index[$alias] ) ) {
+ // Is there an INDEX clause for this table?
+ $tableClause = $this->tableNameWithAlias( $table, $alias );
+ $tableClause .= ' ' . $this->useIndexClause(
+ implode( ',', (array)$use_index[$alias] )
+ );
+
+ $ret[] = $tableClause;
+ } elseif ( isset( $ignore_index[$alias] ) ) {
+ // Is there an INDEX clause for this table?
+ $tableClause = $this->tableNameWithAlias( $table, $alias );
+ $tableClause .= ' ' . $this->ignoreIndexClause(
+ implode( ',', (array)$ignore_index[$alias] )
+ );
+
+ $ret[] = $tableClause;
+ } else {
+ $tableClause = $this->tableNameWithAlias( $table, $alias );
+
+ $ret[] = $tableClause;
+ }
+ }
+
+ // We can't separate explicit JOIN clauses with ',', use ' ' for those
+ $implicitJoins = !empty( $ret ) ? implode( ',', $ret ) : "";
+ $explicitJoins = !empty( $retJOIN ) ? implode( ' ', $retJOIN ) : "";
+
+ // Compile our final table clause
+ return implode( ' ', [ $implicitJoins, $explicitJoins ] );
+ }
+
+ /**
+ * Allows for index remapping in queries where this is not consistent across DBMS
+ *
+ * @param string $index
+ * @return string
+ */
+ protected function indexName( $index ) {
+ return $index;
+ }
+
+ public function addQuotes( $s ) {
+ if ( $s instanceof Blob ) {
+ $s = $s->fetch();
+ }
+ if ( $s === null ) {
+ return 'NULL';
+ } elseif ( is_bool( $s ) ) {
+ return (int)$s;
+ } else {
+ # This will also quote numeric values. This should be harmless,
+ # and protects against weird problems that occur when they really
+ # _are_ strings such as article titles and string->number->string
+ # conversion is not 1:1.
+ return "'" . $this->strencode( $s ) . "'";
+ }
+ }
+
+ /**
+ * Quotes an identifier using `backticks` or "double quotes" depending on the database type.
+ * MySQL uses `backticks` while basically everything else uses double quotes.
+ * Since MySQL is the odd one out here the double quotes are our generic
+ * and we implement backticks in DatabaseMysqlBase.
+ *
+ * @param string $s
+ * @return string
+ */
+ public function addIdentifierQuotes( $s ) {
+ return '"' . str_replace( '"', '""', $s ) . '"';
+ }
+
+ /**
+ * Returns if the given identifier looks quoted or not according to
+ * the database convention for quoting identifiers .
+ *
+ * @note Do not use this to determine if untrusted input is safe.
+ * A malicious user can trick this function.
+ * @param string $name
+ * @return bool
+ */
+ public function isQuotedIdentifier( $name ) {
+ return $name[0] == '"' && substr( $name, -1, 1 ) == '"';
+ }
+
+ /**
+ * @param string $s
+ * @param string $escapeChar
+ * @return string
+ */
+ protected function escapeLikeInternal( $s, $escapeChar = '`' ) {
+ return str_replace( [ $escapeChar, '%', '_' ],
+ [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
+ $s );
+ }
+
+ public function buildLike() {
+ $params = func_get_args();
+
+ if ( count( $params ) > 0 && is_array( $params[0] ) ) {
+ $params = $params[0];
+ }
+
+ $s = '';
+
+ // We use ` instead of \ as the default LIKE escape character, since addQuotes()
+ // may escape backslashes, creating problems of double escaping. The `
+ // character has good cross-DBMS compatibility, avoiding special operators
+ // in MS SQL like ^ and %
+ $escapeChar = '`';
+
+ foreach ( $params as $value ) {
+ if ( $value instanceof LikeMatch ) {
+ $s .= $value->toString();
+ } else {
+ $s .= $this->escapeLikeInternal( $value, $escapeChar );
+ }
+ }
+
+ return ' LIKE ' . $this->addQuotes( $s ) . ' ESCAPE ' . $this->addQuotes( $escapeChar ) . ' ';
+ }
+
+ public function anyChar() {
+ return new LikeMatch( '_' );
+ }
+
+ public function anyString() {
+ return new LikeMatch( '%' );
+ }
+
+ public function nextSequenceValue( $seqName ) {
+ return null;
+ }
+
+ /**
+ * USE INDEX clause. Unlikely to be useful for anything but MySQL. This
+ * is only needed because a) MySQL must be as efficient as possible due to
+ * its use on Wikipedia, and b) MySQL 4.0 is kind of dumb sometimes about
+ * which index to pick. Anyway, other databases might have different
+ * indexes on a given table. So don't bother overriding this unless you're
+ * MySQL.
+ * @param string $index
+ * @return string
+ */
+ public function useIndexClause( $index ) {
+ return '';
+ }
+
+ /**
+ * IGNORE INDEX clause. Unlikely to be useful for anything but MySQL. This
+ * is only needed because a) MySQL must be as efficient as possible due to
+ * its use on Wikipedia, and b) MySQL 4.0 is kind of dumb sometimes about
+ * which index to pick. Anyway, other databases might have different
+ * indexes on a given table. So don't bother overriding this unless you're
+ * MySQL.
+ * @param string $index
+ * @return string
+ */
+ public function ignoreIndexClause( $index ) {
+ return '';
+ }
+
+ public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+ $quotedTable = $this->tableName( $table );
+
+ if ( count( $rows ) == 0 ) {
+ return;
+ }
+
+ # Single row case
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ // @FXIME: this is not atomic, but a trx would break affectedRows()
+ foreach ( $rows as $row ) {
+ # Delete rows which collide
+ if ( $uniqueIndexes ) {
+ $sql = "DELETE FROM $quotedTable WHERE ";
+ $first = true;
+ foreach ( $uniqueIndexes as $index ) {
+ if ( $first ) {
+ $first = false;
+ $sql .= '( ';
+ } else {
+ $sql .= ' ) OR ( ';
+ }
+ if ( is_array( $index ) ) {
+ $first2 = true;
+ foreach ( $index as $col ) {
+ if ( $first2 ) {
+ $first2 = false;
+ } else {
+ $sql .= ' AND ';
+ }
+ $sql .= $col . '=' . $this->addQuotes( $row[$col] );
+ }
+ } else {
+ $sql .= $index . '=' . $this->addQuotes( $row[$index] );
+ }
+ }
+ $sql .= ' )';
+ $this->query( $sql, $fname );
+ }
+
+ # Now insert the row
+ $this->insert( $table, $row, $fname );
+ }
+ }
+
+ /**
+ * REPLACE query wrapper for MySQL and SQLite, which have a native REPLACE
+ * statement.
+ *
+ * @param string $table Table name
+ * @param array|string $rows Row(s) to insert
+ * @param string $fname Caller function name
+ *
+ * @return ResultWrapper
+ */
+ protected function nativeReplace( $table, $rows, $fname ) {
+ $table = $this->tableName( $table );
+
+ # Single row case
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ $sql = "REPLACE INTO $table (" . implode( ',', array_keys( $rows[0] ) ) . ') VALUES ';
+ $first = true;
+
+ foreach ( $rows as $row ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $sql .= ',';
+ }
+
+ $sql .= '(' . $this->makeList( $row ) . ')';
+ }
+
+ return $this->query( $sql, $fname );
+ }
+
+ public function upsert( $table, array $rows, array $uniqueIndexes, array $set,
+ $fname = __METHOD__
+ ) {
+ if ( !count( $rows ) ) {
+ return true; // nothing to do
+ }
+
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ if ( count( $uniqueIndexes ) ) {
+ $clauses = []; // list WHERE clauses that each identify a single row
+ foreach ( $rows as $row ) {
+ foreach ( $uniqueIndexes as $index ) {
+ $index = is_array( $index ) ? $index : [ $index ]; // columns
+ $rowKey = []; // unique key to this row
+ foreach ( $index as $column ) {
+ $rowKey[$column] = $row[$column];
+ }
+ $clauses[] = $this->makeList( $rowKey, self::LIST_AND );
+ }
+ }
+ $where = [ $this->makeList( $clauses, self::LIST_OR ) ];
+ } else {
+ $where = false;
+ }
+
+ $useTrx = !$this->mTrxLevel;
+ if ( $useTrx ) {
+ $this->begin( $fname, self::TRANSACTION_INTERNAL );
+ }
+ try {
+ # Update any existing conflicting row(s)
+ if ( $where !== false ) {
+ $ok = $this->update( $table, $set, $where, $fname );
+ } else {
+ $ok = true;
+ }
+ # Now insert any non-conflicting row(s)
+ $ok = $this->insert( $table, $rows, $fname, [ 'IGNORE' ] ) && $ok;
+ } catch ( Exception $e ) {
+ if ( $useTrx ) {
+ $this->rollback( $fname, self::FLUSHING_INTERNAL );
+ }
+ throw $e;
+ }
+ if ( $useTrx ) {
+ $this->commit( $fname, self::FLUSHING_INTERNAL );
+ }
+
+ return $ok;
+ }
+
+ public function deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds,
+ $fname = __METHOD__
+ ) {
+ if ( !$conds ) {
+ throw new DBUnexpectedError( $this, __METHOD__ . ' called with empty $conds' );
+ }
+
+ $delTable = $this->tableName( $delTable );
+ $joinTable = $this->tableName( $joinTable );
+ $sql = "DELETE FROM $delTable WHERE $delVar IN (SELECT $joinVar FROM $joinTable ";
+ if ( $conds != '*' ) {
+ $sql .= 'WHERE ' . $this->makeList( $conds, self::LIST_AND );
+ }
+ $sql .= ')';
+
+ $this->query( $sql, $fname );
+ }
+
+ public function textFieldSize( $table, $field ) {
+ $table = $this->tableName( $table );
+ $sql = "SHOW COLUMNS FROM $table LIKE \"$field\";";
+ $res = $this->query( $sql, __METHOD__ );
+ $row = $this->fetchObject( $res );
+
+ $m = [];
+
+ if ( preg_match( '/\((.*)\)/', $row->Type, $m ) ) {
+ $size = $m[1];
+ } else {
+ $size = -1;
+ }
+
+ return $size;
+ }
+
+ public function delete( $table, $conds, $fname = __METHOD__ ) {
+ if ( !$conds ) {
+ throw new DBUnexpectedError( $this, __METHOD__ . ' called with no conditions' );
+ }
+
+ $table = $this->tableName( $table );
+ $sql = "DELETE FROM $table";
+
+ if ( $conds != '*' ) {
+ if ( is_array( $conds ) ) {
+ $conds = $this->makeList( $conds, self::LIST_AND );
+ }
+ $sql .= ' WHERE ' . $conds;
+ }
+
+ return $this->query( $sql, $fname );
+ }
+
+ public function insertSelect(
+ $destTable, $srcTable, $varMap, $conds,
+ $fname = __METHOD__, $insertOptions = [], $selectOptions = [], $selectJoinConds = []
+ ) {
+ if ( $this->cliMode ) {
+ // For massive migrations with downtime, we don't want to select everything
+ // into memory and OOM, so do all this native on the server side if possible.
+ return $this->nativeInsertSelect(
+ $destTable,
+ $srcTable,
+ $varMap,
+ $conds,
+ $fname,
+ $insertOptions,
+ $selectOptions,
+ $selectJoinConds
+ );
+ }
+
+ // For web requests, do a locking SELECT and then INSERT. This puts the SELECT burden
+ // on only the master (without needing row-based-replication). It also makes it easy to
+ // know how big the INSERT is going to be.
+ $fields = [];
+ foreach ( $varMap as $dstColumn => $sourceColumnOrSql ) {
+ $fields[] = $this->fieldNameWithAlias( $sourceColumnOrSql, $dstColumn );
+ }
+ $selectOptions[] = 'FOR UPDATE';
+ $res = $this->select(
+ $srcTable, implode( ',', $fields ), $conds, $fname, $selectOptions, $selectJoinConds
+ );
+ if ( !$res ) {
+ return false;
+ }
+
+ $rows = [];
+ foreach ( $res as $row ) {
+ $rows[] = (array)$row;
+ }
+
+ return $this->insert( $destTable, $rows, $fname, $insertOptions );
+ }
+
+ /**
+ * Native server-side implementation of insertSelect() for situations where
+ * we don't want to select everything into memory
+ *
+ * @see IDatabase::insertSelect()
+ * @param string $destTable
+ * @param string|array $srcTable
+ * @param array $varMap
+ * @param array $conds
+ * @param string $fname
+ * @param array $insertOptions
+ * @param array $selectOptions
+ * @param array $selectJoinConds
+ * @return bool
+ */
+ protected function nativeInsertSelect( $destTable, $srcTable, $varMap, $conds,
+ $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = [], $selectJoinConds = []
+ ) {
+ $destTable = $this->tableName( $destTable );
+
+ if ( !is_array( $insertOptions ) ) {
+ $insertOptions = [ $insertOptions ];
+ }
+
+ $insertOptions = $this->makeInsertOptions( $insertOptions );
+
+ $selectSql = $this->selectSQLText(
+ $srcTable,
+ array_values( $varMap ),
+ $conds,
+ $fname,
+ $selectOptions,
+ $selectJoinConds
+ );
+
+ $sql = "INSERT $insertOptions" .
+ " INTO $destTable (" . implode( ',', array_keys( $varMap ) ) . ') ' .
+ $selectSql;
+
+ return $this->query( $sql, $fname );
+ }
+
+ /**
+ * Construct a LIMIT query with optional offset. This is used for query
+ * pages. The SQL should be adjusted so that only the first $limit rows
+ * are returned. If $offset is provided as well, then the first $offset
+ * rows should be discarded, and the next $limit rows should be returned.
+ * If the result of the query is not ordered, then the rows to be returned
+ * are theoretically arbitrary.
+ *
+ * $sql is expected to be a SELECT, if that makes a difference.
+ *
+ * The version provided by default works in MySQL and SQLite. It will very
+ * likely need to be overridden for most other DBMSes.
+ *
+ * @param string $sql SQL query we will append the limit too
+ * @param int $limit The SQL limit
+ * @param int|bool $offset The SQL offset (default false)
+ * @throws DBUnexpectedError
+ * @return string
+ */
+ public function limitResult( $sql, $limit, $offset = false ) {
+ if ( !is_numeric( $limit ) ) {
+ throw new DBUnexpectedError( $this,
+ "Invalid non-numeric limit passed to limitResult()\n" );
+ }
+
+ return "$sql LIMIT "
+ . ( ( is_numeric( $offset ) && $offset != 0 ) ? "{$offset}," : "" )
+ . "{$limit} ";
+ }
+
+ public function unionSupportsOrderAndLimit() {
+ return true; // True for almost every DB supported
+ }
+
+ public function unionQueries( $sqls, $all ) {
+ $glue = $all ? ') UNION ALL (' : ') UNION (';
+
+ return '(' . implode( $glue, $sqls ) . ')';
+ }
+
+ public function unionConditionPermutations(
+ $table, $vars, array $permute_conds, $extra_conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ // First, build the Cartesian product of $permute_conds
+ $conds = [ [] ];
+ foreach ( $permute_conds as $field => $values ) {
+ if ( !$values ) {
+ // Skip empty $values
+ continue;
+ }
+ $values = array_unique( $values ); // For sanity
+ $newConds = [];
+ foreach ( $conds as $cond ) {
+ foreach ( $values as $value ) {
+ $cond[$field] = $value;
+ $newConds[] = $cond; // Arrays are by-value, not by-reference, so this works
+ }
+ }
+ $conds = $newConds;
+ }
+
+ $extra_conds = $extra_conds === '' ? [] : (array)$extra_conds;
+
+ // If there's just one condition and no subordering, hand off to
+ // selectSQLText directly.
+ if ( count( $conds ) === 1 &&
+ ( !isset( $options['INNER ORDER BY'] ) || !$this->unionSupportsOrderAndLimit() )
+ ) {
+ return $this->selectSQLText(
+ $table, $vars, $conds[0] + $extra_conds, $fname, $options, $join_conds
+ );
+ }
+
+ // Otherwise, we need to pull out the order and limit to apply after
+ // the union. Then build the SQL queries for each set of conditions in
+ // $conds. Then union them together (using UNION ALL, because the
+ // product *should* already be distinct).
+ $orderBy = $this->makeOrderBy( $options );
+ $limit = isset( $options['LIMIT'] ) ? $options['LIMIT'] : null;
+ $offset = isset( $options['OFFSET'] ) ? $options['OFFSET'] : false;
+ $all = empty( $options['NOTALL'] ) && !in_array( 'NOTALL', $options );
+ if ( !$this->unionSupportsOrderAndLimit() ) {
+ unset( $options['ORDER BY'], $options['LIMIT'], $options['OFFSET'] );
+ } else {
+ if ( array_key_exists( 'INNER ORDER BY', $options ) ) {
+ $options['ORDER BY'] = $options['INNER ORDER BY'];
+ }
+ if ( $limit !== null && is_numeric( $offset ) && $offset != 0 ) {
+ // We need to increase the limit by the offset rather than
+ // using the offset directly, otherwise it'll skip incorrectly
+ // in the subqueries.
+ $options['LIMIT'] = $limit + $offset;
+ unset( $options['OFFSET'] );
+ }
+ }
+
+ $sqls = [];
+ foreach ( $conds as $cond ) {
+ $sqls[] = $this->selectSQLText(
+ $table, $vars, $cond + $extra_conds, $fname, $options, $join_conds
+ );
+ }
+ $sql = $this->unionQueries( $sqls, $all ) . $orderBy;
+ if ( $limit !== null ) {
+ $sql = $this->limitResult( $sql, $limit, $offset );
+ }
+
+ return $sql;
+ }
+
+ public function conditional( $cond, $trueVal, $falseVal ) {
+ if ( is_array( $cond ) ) {
+ $cond = $this->makeList( $cond, self::LIST_AND );
+ }
+
+ return " (CASE WHEN $cond THEN $trueVal ELSE $falseVal END) ";
+ }
+
+ public function strreplace( $orig, $old, $new ) {
+ return "REPLACE({$orig}, {$old}, {$new})";
+ }
+
+ public function getServerUptime() {
+ return 0;
+ }
+
+ public function wasDeadlock() {
+ return false;
+ }
+
+ public function wasLockTimeout() {
+ return false;
+ }
+
+ public function wasErrorReissuable() {
+ return false;
+ }
+
+ public function wasReadOnlyError() {
+ return false;
+ }
+
+ /**
+ * Do not use this method outside of Database/DBError classes
+ *
+ * @param int|string $errno
+ * @return bool Whether the given query error was a connection drop
+ */
+ public function wasConnectionError( $errno ) {
+ return false;
+ }
+
+ public function deadlockLoop() {
+ $args = func_get_args();
+ $function = array_shift( $args );
+ $tries = self::DEADLOCK_TRIES;
+
+ $this->begin( __METHOD__ );
+
+ $retVal = null;
+ /** @var Exception $e */
+ $e = null;
+ do {
+ try {
+ $retVal = call_user_func_array( $function, $args );
+ break;
+ } catch ( DBQueryError $e ) {
+ if ( $this->wasDeadlock() ) {
+ // Retry after a randomized delay
+ usleep( mt_rand( self::DEADLOCK_DELAY_MIN, self::DEADLOCK_DELAY_MAX ) );
+ } else {
+ // Throw the error back up
+ throw $e;
+ }
+ }
+ } while ( --$tries > 0 );
+
+ if ( $tries <= 0 ) {
+ // Too many deadlocks; give up
+ $this->rollback( __METHOD__ );
+ throw $e;
+ } else {
+ $this->commit( __METHOD__ );
+
+ return $retVal;
+ }
+ }
+
+ public function masterPosWait( DBMasterPos $pos, $timeout ) {
+ # Real waits are implemented in the subclass.
+ return 0;
+ }
+
+ public function getReplicaPos() {
+ # Stub
+ return false;
+ }
+
+ public function getMasterPos() {
+ # Stub
+ return false;
+ }
+
+ public function serverIsReadOnly() {
+ return false;
+ }
+
+ final public function onTransactionResolution( callable $callback, $fname = __METHOD__ ) {
+ if ( !$this->mTrxLevel ) {
+ throw new DBUnexpectedError( $this, "No transaction is active." );
+ }
+ $this->mTrxEndCallbacks[] = [ $callback, $fname ];
+ }
+
+ final public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) {
+ $this->mTrxIdleCallbacks[] = [ $callback, $fname ];
+ if ( !$this->mTrxLevel ) {
+ $this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE );
+ }
+ }
+
+ final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
+ if ( $this->mTrxLevel || $this->getFlag( self::DBO_TRX ) ) {
+ // As long as DBO_TRX is set, writes will accumulate until the load balancer issues
+ // an implicit commit of all peer databases. This is true even if a transaction has
+ // not yet been triggered by writes; make sure $callback runs *after* any such writes.
+ $this->mTrxPreCommitCallbacks[] = [ $callback, $fname ];
+ } else {
+ // No transaction is active nor will start implicitly, so make one for this callback
+ $this->startAtomic( __METHOD__ );
+ try {
+ call_user_func( $callback );
+ $this->endAtomic( __METHOD__ );
+ } catch ( Exception $e ) {
+ $this->rollback( __METHOD__, self::FLUSHING_INTERNAL );
+ throw $e;
+ }
+ }
+ }
+
+ final public function setTransactionListener( $name, callable $callback = null ) {
+ if ( $callback ) {
+ $this->mTrxRecurringCallbacks[$name] = $callback;
+ } else {
+ unset( $this->mTrxRecurringCallbacks[$name] );
+ }
+ }
+
+ /**
+ * Whether to disable running of post-COMMIT/ROLLBACK callbacks
+ *
+ * This method should not be used outside of Database/LoadBalancer
+ *
+ * @param bool $suppress
+ * @since 1.28
+ */
+ final public function setTrxEndCallbackSuppression( $suppress ) {
+ $this->mTrxEndCallbacksSuppressed = $suppress;
+ }
+
+ /**
+ * Actually run and consume any "on transaction idle/resolution" callbacks.
+ *
+ * This method should not be used outside of Database/LoadBalancer
+ *
+ * @param int $trigger IDatabase::TRIGGER_* constant
+ * @since 1.20
+ * @throws Exception
+ */
+ public function runOnTransactionIdleCallbacks( $trigger ) {
+ if ( $this->mTrxEndCallbacksSuppressed ) {
+ return;
+ }
+
+ $autoTrx = $this->getFlag( self::DBO_TRX ); // automatic begin() enabled?
+ /** @var Exception $e */
+ $e = null; // first exception
+ do { // callbacks may add callbacks :)
+ $callbacks = array_merge(
+ $this->mTrxIdleCallbacks,
+ $this->mTrxEndCallbacks // include "transaction resolution" callbacks
+ );
+ $this->mTrxIdleCallbacks = []; // consumed (and recursion guard)
+ $this->mTrxEndCallbacks = []; // consumed (recursion guard)
+ foreach ( $callbacks as $callback ) {
+ try {
+ list( $phpCallback ) = $callback;
+ $this->clearFlag( self::DBO_TRX ); // make each query its own transaction
+ call_user_func_array( $phpCallback, [ $trigger ] );
+ if ( $autoTrx ) {
+ $this->setFlag( self::DBO_TRX ); // restore automatic begin()
+ } else {
+ $this->clearFlag( self::DBO_TRX ); // restore auto-commit
+ }
+ } catch ( Exception $ex ) {
+ call_user_func( $this->errorLogger, $ex );
+ $e = $e ?: $ex;
+ // Some callbacks may use startAtomic/endAtomic, so make sure
+ // their transactions are ended so other callbacks don't fail
+ if ( $this->trxLevel() ) {
+ $this->rollback( __METHOD__, self::FLUSHING_INTERNAL );
+ }
+ }
+ }
+ } while ( count( $this->mTrxIdleCallbacks ) );
+
+ if ( $e instanceof Exception ) {
+ throw $e; // re-throw any first exception
+ }
+ }
+
+ /**
+ * Actually run and consume any "on transaction pre-commit" callbacks.
+ *
+ * This method should not be used outside of Database/LoadBalancer
+ *
+ * @since 1.22
+ * @throws Exception
+ */
+ public function runOnTransactionPreCommitCallbacks() {
+ $e = null; // first exception
+ do { // callbacks may add callbacks :)
+ $callbacks = $this->mTrxPreCommitCallbacks;
+ $this->mTrxPreCommitCallbacks = []; // consumed (and recursion guard)
+ foreach ( $callbacks as $callback ) {
+ try {
+ list( $phpCallback ) = $callback;
+ call_user_func( $phpCallback );
+ } catch ( Exception $ex ) {
+ call_user_func( $this->errorLogger, $ex );
+ $e = $e ?: $ex;
+ }
+ }
+ } while ( count( $this->mTrxPreCommitCallbacks ) );
+
+ if ( $e instanceof Exception ) {
+ throw $e; // re-throw any first exception
+ }
+ }
+
+ /**
+ * Actually run any "transaction listener" callbacks.
+ *
+ * This method should not be used outside of Database/LoadBalancer
+ *
+ * @param int $trigger IDatabase::TRIGGER_* constant
+ * @throws Exception
+ * @since 1.20
+ */
+ public function runTransactionListenerCallbacks( $trigger ) {
+ if ( $this->mTrxEndCallbacksSuppressed ) {
+ return;
+ }
+
+ /** @var Exception $e */
+ $e = null; // first exception
+
+ foreach ( $this->mTrxRecurringCallbacks as $phpCallback ) {
+ try {
+ $phpCallback( $trigger, $this );
+ } catch ( Exception $ex ) {
+ call_user_func( $this->errorLogger, $ex );
+ $e = $e ?: $ex;
+ }
+ }
+
+ if ( $e instanceof Exception ) {
+ throw $e; // re-throw any first exception
+ }
+ }
+
+ final public function startAtomic( $fname = __METHOD__ ) {
+ if ( !$this->mTrxLevel ) {
+ $this->begin( $fname, self::TRANSACTION_INTERNAL );
+ // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result
+ // in all changes being in one transaction to keep requests transactional.
+ if ( !$this->getFlag( self::DBO_TRX ) ) {
+ $this->mTrxAutomaticAtomic = true;
+ }
+ }
+
+ $this->mTrxAtomicLevels[] = $fname;
+ }
+
+ final public function endAtomic( $fname = __METHOD__ ) {
+ if ( !$this->mTrxLevel ) {
+ throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." );
+ }
+ if ( !$this->mTrxAtomicLevels ||
+ array_pop( $this->mTrxAtomicLevels ) !== $fname
+ ) {
+ throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." );
+ }
+
+ if ( !$this->mTrxAtomicLevels && $this->mTrxAutomaticAtomic ) {
+ $this->commit( $fname, self::FLUSHING_INTERNAL );
+ }
+ }
+
+ final public function doAtomicSection( $fname, callable $callback ) {
+ $this->startAtomic( $fname );
+ try {
+ $res = call_user_func_array( $callback, [ $this, $fname ] );
+ } catch ( Exception $e ) {
+ $this->rollback( $fname, self::FLUSHING_INTERNAL );
+ throw $e;
+ }
+ $this->endAtomic( $fname );
+
+ return $res;
+ }
+
+ final public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT ) {
+ // Protect against mismatched atomic section, transaction nesting, and snapshot loss
+ if ( $this->mTrxLevel ) {
+ if ( $this->mTrxAtomicLevels ) {
+ $levels = implode( ', ', $this->mTrxAtomicLevels );
+ $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open.";
+ throw new DBUnexpectedError( $this, $msg );
+ } elseif ( !$this->mTrxAutomatic ) {
+ $msg = "$fname: Explicit transaction already active (from {$this->mTrxFname}).";
+ throw new DBUnexpectedError( $this, $msg );
+ } else {
+ // @TODO: make this an exception at some point
+ $msg = "$fname: Implicit transaction already active (from {$this->mTrxFname}).";
+ $this->queryLogger->error( $msg );
+ return; // join the main transaction set
+ }
+ } elseif ( $this->getFlag( self::DBO_TRX ) && $mode !== self::TRANSACTION_INTERNAL ) {
+ // @TODO: make this an exception at some point
+ $msg = "$fname: Implicit transaction expected (DBO_TRX set).";
+ $this->queryLogger->error( $msg );
+ return; // let any writes be in the main transaction
+ }
+
+ // Avoid fatals if close() was called
+ $this->assertOpen();
+
+ $this->doBegin( $fname );
+ $this->mTrxTimestamp = microtime( true );
+ $this->mTrxFname = $fname;
+ $this->mTrxDoneWrites = false;
+ $this->mTrxAutomaticAtomic = false;
+ $this->mTrxAtomicLevels = [];
+ $this->mTrxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) );
+ $this->mTrxWriteDuration = 0.0;
+ $this->mTrxWriteQueryCount = 0;
+ $this->mTrxWriteAffectedRows = 0;
+ $this->mTrxWriteAdjDuration = 0.0;
+ $this->mTrxWriteAdjQueryCount = 0;
+ $this->mTrxWriteCallers = [];
+ // First SELECT after BEGIN will establish the snapshot in REPEATABLE-READ.
+ // Get an estimate of the replica DB lag before then, treating estimate staleness
+ // as lag itself just to be safe
+ $status = $this->getApproximateLagStatus();
+ $this->mTrxReplicaLag = $status['lag'] + ( microtime( true ) - $status['since'] );
+ // T147697: make explicitTrxActive() return true until begin() finishes. This way, no
+ // caller will think its OK to muck around with the transaction just because startAtomic()
+ // has not yet completed (e.g. setting mTrxAtomicLevels).
+ $this->mTrxAutomatic = ( $mode === self::TRANSACTION_INTERNAL );
+ }
+
+ /**
+ * Issues the BEGIN command to the database server.
+ *
+ * @see Database::begin()
+ * @param string $fname
+ */
+ protected function doBegin( $fname ) {
+ $this->query( 'BEGIN', $fname );
+ $this->mTrxLevel = 1;
+ }
+
+ final public function commit( $fname = __METHOD__, $flush = '' ) {
+ if ( $this->mTrxLevel && $this->mTrxAtomicLevels ) {
+ // There are still atomic sections open. This cannot be ignored
+ $levels = implode( ', ', $this->mTrxAtomicLevels );
+ throw new DBUnexpectedError(
+ $this,
+ "$fname: Got COMMIT while atomic sections $levels are still open."
+ );
+ }
+
+ if ( $flush === self::FLUSHING_INTERNAL || $flush === self::FLUSHING_ALL_PEERS ) {
+ if ( !$this->mTrxLevel ) {
+ return; // nothing to do
+ } elseif ( !$this->mTrxAutomatic ) {
+ throw new DBUnexpectedError(
+ $this,
+ "$fname: Flushing an explicit transaction, getting out of sync."
+ );
+ }
+ } else {
+ if ( !$this->mTrxLevel ) {
+ $this->queryLogger->error(
+ "$fname: No transaction to commit, something got out of sync." );
+ return; // nothing to do
+ } elseif ( $this->mTrxAutomatic ) {
+ // @TODO: make this an exception at some point
+ $msg = "$fname: Explicit commit of implicit transaction.";
+ $this->queryLogger->error( $msg );
+ return; // wait for the main transaction set commit round
+ }
+ }
+
+ // Avoid fatals if close() was called
+ $this->assertOpen();
+
+ $this->runOnTransactionPreCommitCallbacks();
+ $writeTime = $this->pendingWriteQueryDuration( self::ESTIMATE_DB_APPLY );
+ $this->doCommit( $fname );
+ if ( $this->mTrxDoneWrites ) {
+ $this->mLastWriteTime = microtime( true );
+ $this->trxProfiler->transactionWritingOut(
+ $this->mServer,
+ $this->mDBname,
+ $this->mTrxShortId,
+ $writeTime,
+ $this->mTrxWriteAffectedRows
+ );
+ }
+
+ $this->runOnTransactionIdleCallbacks( self::TRIGGER_COMMIT );
+ $this->runTransactionListenerCallbacks( self::TRIGGER_COMMIT );
+ }
+
+ /**
+ * Issues the COMMIT command to the database server.
+ *
+ * @see Database::commit()
+ * @param string $fname
+ */
+ protected function doCommit( $fname ) {
+ if ( $this->mTrxLevel ) {
+ $this->query( 'COMMIT', $fname );
+ $this->mTrxLevel = 0;
+ }
+ }
+
+ final public function rollback( $fname = __METHOD__, $flush = '' ) {
+ if ( $flush === self::FLUSHING_INTERNAL || $flush === self::FLUSHING_ALL_PEERS ) {
+ if ( !$this->mTrxLevel ) {
+ return; // nothing to do
+ }
+ } else {
+ if ( !$this->mTrxLevel ) {
+ $this->queryLogger->error(
+ "$fname: No transaction to rollback, something got out of sync." );
+ return; // nothing to do
+ } elseif ( $this->getFlag( self::DBO_TRX ) ) {
+ throw new DBUnexpectedError(
+ $this,
+ "$fname: Expected mass rollback of all peer databases (DBO_TRX set)."
+ );
+ }
+ }
+
+ // Avoid fatals if close() was called
+ $this->assertOpen();
+
+ $this->doRollback( $fname );
+ $this->mTrxAtomicLevels = [];
+ if ( $this->mTrxDoneWrites ) {
+ $this->trxProfiler->transactionWritingOut(
+ $this->mServer,
+ $this->mDBname,
+ $this->mTrxShortId
+ );
+ }
+
+ $this->mTrxIdleCallbacks = []; // clear
+ $this->mTrxPreCommitCallbacks = []; // clear
+ $this->runOnTransactionIdleCallbacks( self::TRIGGER_ROLLBACK );
+ $this->runTransactionListenerCallbacks( self::TRIGGER_ROLLBACK );
+ }
+
+ /**
+ * Issues the ROLLBACK command to the database server.
+ *
+ * @see Database::rollback()
+ * @param string $fname
+ */
+ protected function doRollback( $fname ) {
+ if ( $this->mTrxLevel ) {
+ # Disconnects cause rollback anyway, so ignore those errors
+ $ignoreErrors = true;
+ $this->query( 'ROLLBACK', $fname, $ignoreErrors );
+ $this->mTrxLevel = 0;
+ }
+ }
+
+ public function flushSnapshot( $fname = __METHOD__ ) {
+ if ( $this->writesOrCallbacksPending() || $this->explicitTrxActive() ) {
+ // This only flushes transactions to clear snapshots, not to write data
+ $fnames = implode( ', ', $this->pendingWriteAndCallbackCallers() );
+ throw new DBUnexpectedError(
+ $this,
+ "$fname: Cannot flush snapshot because writes are pending ($fnames)."
+ );
+ }
+
+ $this->commit( $fname, self::FLUSHING_INTERNAL );
+ }
+
+ public function explicitTrxActive() {
+ return $this->mTrxLevel && ( $this->mTrxAtomicLevels || !$this->mTrxAutomatic );
+ }
+
+ public function duplicateTableStructure(
+ $oldName, $newName, $temporary = false, $fname = __METHOD__
+ ) {
+ throw new RuntimeException( __METHOD__ . ' is not implemented in descendant class' );
+ }
+
+ public function listTables( $prefix = null, $fname = __METHOD__ ) {
+ throw new RuntimeException( __METHOD__ . ' is not implemented in descendant class' );
+ }
+
+ public function listViews( $prefix = null, $fname = __METHOD__ ) {
+ throw new RuntimeException( __METHOD__ . ' is not implemented in descendant class' );
+ }
+
+ public function timestamp( $ts = 0 ) {
+ $t = new ConvertibleTimestamp( $ts );
+ // Let errors bubble up to avoid putting garbage in the DB
+ return $t->getTimestamp( TS_MW );
+ }
+
+ public function timestampOrNull( $ts = null ) {
+ if ( is_null( $ts ) ) {
+ return null;
+ } else {
+ return $this->timestamp( $ts );
+ }
+ }
+
+ /**
+ * Take the result from a query, and wrap it in a ResultWrapper if
+ * necessary. Boolean values are passed through as is, to indicate success
+ * of write queries or failure.
+ *
+ * Once upon a time, Database::query() returned a bare MySQL result
+ * resource, and it was necessary to call this function to convert it to
+ * a wrapper. Nowadays, raw database objects are never exposed to external
+ * callers, so this is unnecessary in external code.
+ *
+ * @param bool|ResultWrapper|resource|object $result
+ * @return bool|ResultWrapper
+ */
+ protected function resultObject( $result ) {
+ if ( !$result ) {
+ return false;
+ } elseif ( $result instanceof ResultWrapper ) {
+ return $result;
+ } elseif ( $result === true ) {
+ // Successful write query
+ return $result;
+ } else {
+ return new ResultWrapper( $this, $result );
+ }
+ }
+
+ public function ping( &$rtt = null ) {
+ // Avoid hitting the server if it was hit recently
+ if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::PING_TTL ) {
+ if ( !func_num_args() || $this->mRTTEstimate > 0 ) {
+ $rtt = $this->mRTTEstimate;
+ return true; // don't care about $rtt
+ }
+ }
+
+ // This will reconnect if possible or return false if not
+ $this->clearFlag( self::DBO_TRX, self::REMEMBER_PRIOR );
+ $ok = ( $this->query( self::PING_QUERY, __METHOD__, true ) !== false );
+ $this->restoreFlags( self::RESTORE_PRIOR );
+
+ if ( $ok ) {
+ $rtt = $this->mRTTEstimate;
+ }
+
+ return $ok;
+ }
+
+ /**
+ * Close existing database connection and open a new connection
+ *
+ * @return bool True if new connection is opened successfully, false if error
+ */
+ protected function reconnect() {
+ $this->closeConnection();
+ $this->mOpened = false;
+ $this->mConn = false;
+ try {
+ $this->open( $this->mServer, $this->mUser, $this->mPassword, $this->mDBname );
+ $this->lastPing = microtime( true );
+ $ok = true;
+ } catch ( DBConnectionError $e ) {
+ $ok = false;
+ }
+
+ return $ok;
+ }
+
+ public function getSessionLagStatus() {
+ return $this->getTransactionLagStatus() ?: $this->getApproximateLagStatus();
+ }
+
+ /**
+ * Get the replica DB lag when the current transaction started
+ *
+ * This is useful when transactions might use snapshot isolation
+ * (e.g. REPEATABLE-READ in innodb), so the "real" lag of that data
+ * is this lag plus transaction duration. If they don't, it is still
+ * safe to be pessimistic. This returns null if there is no transaction.
+ *
+ * @return array|null ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN)
+ * @since 1.27
+ */
+ protected function getTransactionLagStatus() {
+ return $this->mTrxLevel
+ ? [ 'lag' => $this->mTrxReplicaLag, 'since' => $this->trxTimestamp() ]
+ : null;
+ }
+
+ /**
+ * Get a replica DB lag estimate for this server
+ *
+ * @return array ('lag': seconds or false on error, 'since': UNIX timestamp of estimate)
+ * @since 1.27
+ */
+ protected function getApproximateLagStatus() {
+ return [
+ 'lag' => $this->getLBInfo( 'replica' ) ? $this->getLag() : 0,
+ 'since' => microtime( true )
+ ];
+ }
+
+ /**
+ * Merge the result of getSessionLagStatus() for several DBs
+ * using the most pessimistic values to estimate the lag of
+ * any data derived from them in combination
+ *
+ * This is information is useful for caching modules
+ *
+ * @see WANObjectCache::set()
+ * @see WANObjectCache::getWithSetCallback()
+ *
+ * @param IDatabase $db1
+ * @param IDatabase $dbs,...
+ * @return array Map of values:
+ * - lag: highest lag of any of the DBs or false on error (e.g. replication stopped)
+ * - since: oldest UNIX timestamp of any of the DB lag estimates
+ * - pending: whether any of the DBs have uncommitted changes
+ * @since 1.27
+ */
+ public static function getCacheSetOptions( IDatabase $db1 ) {
+ $res = [ 'lag' => 0, 'since' => INF, 'pending' => false ];
+ foreach ( func_get_args() as $db ) {
+ /** @var IDatabase $db */
+ $status = $db->getSessionLagStatus();
+ if ( $status['lag'] === false ) {
+ $res['lag'] = false;
+ } elseif ( $res['lag'] !== false ) {
+ $res['lag'] = max( $res['lag'], $status['lag'] );
+ }
+ $res['since'] = min( $res['since'], $status['since'] );
+ $res['pending'] = $res['pending'] ?: $db->writesPending();
+ }
+
+ return $res;
+ }
+
+ public function getLag() {
+ return 0;
+ }
+
+ public function maxListLen() {
+ return 0;
+ }
+
+ public function encodeBlob( $b ) {
+ return $b;
+ }
+
+ public function decodeBlob( $b ) {
+ if ( $b instanceof Blob ) {
+ $b = $b->fetch();
+ }
+ return $b;
+ }
+
+ public function setSessionOptions( array $options ) {
+ }
+
+ public function sourceFile(
+ $filename,
+ callable $lineCallback = null,
+ callable $resultCallback = null,
+ $fname = false,
+ callable $inputCallback = null
+ ) {
+ MediaWiki\suppressWarnings();
+ $fp = fopen( $filename, 'r' );
+ MediaWiki\restoreWarnings();
+
+ if ( false === $fp ) {
+ throw new RuntimeException( "Could not open \"{$filename}\".\n" );
+ }
+
+ if ( !$fname ) {
+ $fname = __METHOD__ . "( $filename )";
+ }
+
+ try {
+ $error = $this->sourceStream(
+ $fp, $lineCallback, $resultCallback, $fname, $inputCallback );
+ } catch ( Exception $e ) {
+ fclose( $fp );
+ throw $e;
+ }
+
+ fclose( $fp );
+
+ return $error;
+ }
+
+ public function setSchemaVars( $vars ) {
+ $this->mSchemaVars = $vars;
+ }
+
+ public function sourceStream(
+ $fp,
+ callable $lineCallback = null,
+ callable $resultCallback = null,
+ $fname = __METHOD__,
+ callable $inputCallback = null
+ ) {
+ $cmd = '';
+
+ while ( !feof( $fp ) ) {
+ if ( $lineCallback ) {
+ call_user_func( $lineCallback );
+ }
+
+ $line = trim( fgets( $fp ) );
+
+ if ( $line == '' ) {
+ continue;
+ }
+
+ if ( '-' == $line[0] && '-' == $line[1] ) {
+ continue;
+ }
+
+ if ( $cmd != '' ) {
+ $cmd .= ' ';
+ }
+
+ $done = $this->streamStatementEnd( $cmd, $line );
+
+ $cmd .= "$line\n";
+
+ if ( $done || feof( $fp ) ) {
+ $cmd = $this->replaceVars( $cmd );
+
+ if ( !$inputCallback || call_user_func( $inputCallback, $cmd ) ) {
+ $res = $this->query( $cmd, $fname );
+
+ if ( $resultCallback ) {
+ call_user_func( $resultCallback, $res, $this );
+ }
+
+ if ( false === $res ) {
+ $err = $this->lastError();
+
+ return "Query \"{$cmd}\" failed with error code \"$err\".\n";
+ }
+ }
+ $cmd = '';
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Called by sourceStream() to check if we've reached a statement end
+ *
+ * @param string &$sql SQL assembled so far
+ * @param string &$newLine New line about to be added to $sql
+ * @return bool Whether $newLine contains end of the statement
+ */
+ public function streamStatementEnd( &$sql, &$newLine ) {
+ if ( $this->delimiter ) {
+ $prev = $newLine;
+ $newLine = preg_replace(
+ '/' . preg_quote( $this->delimiter, '/' ) . '$/', '', $newLine );
+ if ( $newLine != $prev ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Database independent variable replacement. Replaces a set of variables
+ * in an SQL statement with their contents as given by $this->getSchemaVars().
+ *
+ * Supports '{$var}' `{$var}` and / *$var* / (without the spaces) style variables.
+ *
+ * - '{$var}' should be used for text and is passed through the database's
+ * addQuotes method.
+ * - `{$var}` should be used for identifiers (e.g. table and database names).
+ * It is passed through the database's addIdentifierQuotes method which
+ * can be overridden if the database uses something other than backticks.
+ * - / *_* / or / *$wgDBprefix* / passes the name that follows through the
+ * database's tableName method.
+ * - / *i* / passes the name that follows through the database's indexName method.
+ * - In all other cases, / *$var* / is left unencoded. Except for table options,
+ * its use should be avoided. In 1.24 and older, string encoding was applied.
+ *
+ * @param string $ins SQL statement to replace variables in
+ * @return string The new SQL statement with variables replaced
+ */
+ protected function replaceVars( $ins ) {
+ $vars = $this->getSchemaVars();
+ return preg_replace_callback(
+ '!
+ /\* (\$wgDBprefix|[_i]) \*/ (\w*) | # 1-2. tableName, indexName
+ \'\{\$ (\w+) }\' | # 3. addQuotes
+ `\{\$ (\w+) }` | # 4. addIdentifierQuotes
+ /\*\$ (\w+) \*/ # 5. leave unencoded
+ !x',
+ function ( $m ) use ( $vars ) {
+ // Note: Because of <https://bugs.php.net/bug.php?id=51881>,
+ // check for both nonexistent keys *and* the empty string.
+ if ( isset( $m[1] ) && $m[1] !== '' ) {
+ if ( $m[1] === 'i' ) {
+ return $this->indexName( $m[2] );
+ } else {
+ return $this->tableName( $m[2] );
+ }
+ } elseif ( isset( $m[3] ) && $m[3] !== '' && array_key_exists( $m[3], $vars ) ) {
+ return $this->addQuotes( $vars[$m[3]] );
+ } elseif ( isset( $m[4] ) && $m[4] !== '' && array_key_exists( $m[4], $vars ) ) {
+ return $this->addIdentifierQuotes( $vars[$m[4]] );
+ } elseif ( isset( $m[5] ) && $m[5] !== '' && array_key_exists( $m[5], $vars ) ) {
+ return $vars[$m[5]];
+ } else {
+ return $m[0];
+ }
+ },
+ $ins
+ );
+ }
+
+ /**
+ * Get schema variables. If none have been set via setSchemaVars(), then
+ * use some defaults from the current object.
+ *
+ * @return array
+ */
+ protected function getSchemaVars() {
+ if ( $this->mSchemaVars ) {
+ return $this->mSchemaVars;
+ } else {
+ return $this->getDefaultSchemaVars();
+ }
+ }
+
+ /**
+ * Get schema variables to use if none have been set via setSchemaVars().
+ *
+ * Override this in derived classes to provide variables for tables.sql
+ * and SQL patch files.
+ *
+ * @return array
+ */
+ protected function getDefaultSchemaVars() {
+ return [];
+ }
+
+ public function lockIsFree( $lockName, $method ) {
+ return true;
+ }
+
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ $this->mNamedLocksHeld[$lockName] = 1;
+
+ return true;
+ }
+
+ public function unlock( $lockName, $method ) {
+ unset( $this->mNamedLocksHeld[$lockName] );
+
+ return true;
+ }
+
+ public function getScopedLockAndFlush( $lockKey, $fname, $timeout ) {
+ if ( $this->writesOrCallbacksPending() ) {
+ // This only flushes transactions to clear snapshots, not to write data
+ $fnames = implode( ', ', $this->pendingWriteAndCallbackCallers() );
+ throw new DBUnexpectedError(
+ $this,
+ "$fname: Cannot flush pre-lock snapshot because writes are pending ($fnames)."
+ );
+ }
+
+ if ( !$this->lock( $lockKey, $fname, $timeout ) ) {
+ return null;
+ }
+
+ $unlocker = new ScopedCallback( function () use ( $lockKey, $fname ) {
+ if ( $this->trxLevel() ) {
+ // There is a good chance an exception was thrown, causing any early return
+ // from the caller. Let any error handler get a chance to issue rollback().
+ // If there isn't one, let the error bubble up and trigger server-side rollback.
+ $this->onTransactionResolution(
+ function () use ( $lockKey, $fname ) {
+ $this->unlock( $lockKey, $fname );
+ },
+ $fname
+ );
+ } else {
+ $this->unlock( $lockKey, $fname );
+ }
+ } );
+
+ $this->commit( $fname, self::FLUSHING_INTERNAL );
+
+ return $unlocker;
+ }
+
+ public function namedLocksEnqueue() {
+ return false;
+ }
+
+ public function tableLocksHaveTransactionScope() {
+ return true;
+ }
+
+ final public function lockTables( array $read, array $write, $method ) {
+ if ( $this->writesOrCallbacksPending() ) {
+ throw new DBUnexpectedError( $this, "Transaction writes or callbacks still pending." );
+ }
+
+ if ( $this->tableLocksHaveTransactionScope() ) {
+ $this->startAtomic( $method );
+ }
+
+ return $this->doLockTables( $read, $write, $method );
+ }
+
+ /**
+ * Helper function for lockTables() that handles the actual table locking
+ *
+ * @param array $read Array of tables to lock for read access
+ * @param array $write Array of tables to lock for write access
+ * @param string $method Name of caller
+ * @return true
+ */
+ protected function doLockTables( array $read, array $write, $method ) {
+ return true;
+ }
+
+ final public function unlockTables( $method ) {
+ if ( $this->tableLocksHaveTransactionScope() ) {
+ $this->endAtomic( $method );
+
+ return true; // locks released on COMMIT/ROLLBACK
+ }
+
+ return $this->doUnlockTables( $method );
+ }
+
+ /**
+ * Helper function for unlockTables() that handles the actual table unlocking
+ *
+ * @param string $method Name of caller
+ * @return true
+ */
+ protected function doUnlockTables( $method ) {
+ return true;
+ }
+
+ /**
+ * Delete a table
+ * @param string $tableName
+ * @param string $fName
+ * @return bool|ResultWrapper
+ * @since 1.18
+ */
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ if ( !$this->tableExists( $tableName, $fName ) ) {
+ return false;
+ }
+ $sql = "DROP TABLE " . $this->tableName( $tableName ) . " CASCADE";
+
+ return $this->query( $sql, $fName );
+ }
+
+ public function getInfinity() {
+ return 'infinity';
+ }
+
+ public function encodeExpiry( $expiry ) {
+ return ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() )
+ ? $this->getInfinity()
+ : $this->timestamp( $expiry );
+ }
+
+ public function decodeExpiry( $expiry, $format = TS_MW ) {
+ if ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() ) {
+ return 'infinity';
+ }
+
+ return ConvertibleTimestamp::convert( $format, $expiry );
+ }
+
+ public function setBigSelects( $value = true ) {
+ // no-op
+ }
+
+ public function isReadOnly() {
+ return ( $this->getReadOnlyReason() !== false );
+ }
+
+ /**
+ * @return string|bool Reason this DB is read-only or false if it is not
+ */
+ protected function getReadOnlyReason() {
+ $reason = $this->getLBInfo( 'readOnlyReason' );
+
+ return is_string( $reason ) ? $reason : false;
+ }
+
+ public function setTableAliases( array $aliases ) {
+ $this->tableAliases = $aliases;
+ }
+
+ /**
+ * @return bool Whether a DB user is required to access the DB
+ * @since 1.28
+ */
+ protected function requiresDatabaseUser() {
+ return true;
+ }
+
+ /**
+ * Get the underlying binding handle, mConn
+ *
+ * Makes sure that mConn is set (disconnects and ping() failure can unset it).
+ * This catches broken callers than catch and ignore disconnection exceptions.
+ * Unlike checking isOpen(), this is safe to call inside of open().
+ *
+ * @return resource|object
+ * @throws DBUnexpectedError
+ * @since 1.26
+ */
+ protected function getBindingHandle() {
+ if ( !$this->mConn ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'DB connection was already closed or the connection dropped.'
+ );
+ }
+
+ return $this->mConn;
+ }
+
+ /**
+ * @since 1.19
+ * @return string
+ */
+ public function __toString() {
+ return (string)$this->mConn;
+ }
+
+ /**
+ * Make sure that copies do not share the same client binding handle
+ * @throws DBConnectionError
+ */
+ public function __clone() {
+ $this->connLogger->warning(
+ "Cloning " . static::class . " is not recomended; forking connection:\n" .
+ ( new RuntimeException() )->getTraceAsString()
+ );
+
+ if ( $this->isOpen() ) {
+ // Open a new connection resource without messing with the old one
+ $this->mOpened = false;
+ $this->mConn = false;
+ $this->mTrxEndCallbacks = []; // don't copy
+ $this->handleSessionLoss(); // no trx or locks anymore
+ $this->open( $this->mServer, $this->mUser, $this->mPassword, $this->mDBname );
+ $this->lastPing = microtime( true );
+ }
+ }
+
+ /**
+ * Called by serialize. Throw an exception when DB connection is serialized.
+ * This causes problems on some database engines because the connection is
+ * not restored on unserialize.
+ */
+ public function __sleep() {
+ throw new RuntimeException( 'Database serialization may cause problems, since ' .
+ 'the connection is not restored on wakeup.' );
+ }
+
+ /**
+ * Run a few simple sanity checks and close dangling connections
+ */
+ public function __destruct() {
+ if ( $this->mTrxLevel && $this->mTrxDoneWrites ) {
+ trigger_error( "Uncommitted DB writes (transaction from {$this->mTrxFname})." );
+ }
+
+ $danglingWriters = $this->pendingWriteAndCallbackCallers();
+ if ( $danglingWriters ) {
+ $fnames = implode( ', ', $danglingWriters );
+ trigger_error( "DB transaction writes or callbacks still pending ($fnames)." );
+ }
+
+ if ( $this->mConn ) {
+ // Avoid connection leaks for sanity. Normally, resources close at script completion.
+ // The connection might already be closed in zend/hhvm by now, so suppress warnings.
+ \MediaWiki\suppressWarnings();
+ $this->closeConnection();
+ \MediaWiki\restoreWarnings();
+ $this->mConn = false;
+ $this->mOpened = false;
+ }
+ }
+}
+
+class_alias( Database::class, 'DatabaseBase' ); // b/c for old name
+class_alias( Database::class, 'Database' ); // b/c global alias
diff --git a/www/wiki/includes/libs/rdbms/database/DatabaseDomain.php b/www/wiki/includes/libs/rdbms/database/DatabaseDomain.php
new file mode 100644
index 00000000..ef6600b4
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/DatabaseDomain.php
@@ -0,0 +1,209 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
+/**
+ * Class to handle database/prefix specification for IDatabase domains
+ */
+class DatabaseDomain {
+ /** @var string|null */
+ private $database;
+ /** @var string|null */
+ private $schema;
+ /** @var string */
+ private $prefix;
+
+ /** @var string Cache of convertToString() */
+ private $equivalentString;
+
+ /**
+ * @param string|null $database Database name
+ * @param string|null $schema Schema name
+ * @param string $prefix Table prefix
+ */
+ public function __construct( $database, $schema, $prefix ) {
+ if ( $database !== null && ( !is_string( $database ) || !strlen( $database ) ) ) {
+ throw new InvalidArgumentException( "Database must be null or a non-empty string." );
+ }
+ $this->database = $database;
+ if ( $schema !== null && ( !is_string( $schema ) || !strlen( $schema ) ) ) {
+ throw new InvalidArgumentException( "Schema must be null or a non-empty string." );
+ }
+ $this->schema = $schema;
+ if ( !is_string( $prefix ) ) {
+ throw new InvalidArgumentException( "Prefix must be a string." );
+ }
+ $this->prefix = $prefix;
+ }
+
+ /**
+ * @param DatabaseDomain|string $domain Result of DatabaseDomain::toString()
+ * @return DatabaseDomain
+ */
+ public static function newFromId( $domain ) {
+ if ( $domain instanceof self ) {
+ return $domain;
+ }
+
+ $parts = array_map( [ __CLASS__, 'decode' ], explode( '-', $domain ) );
+
+ $schema = null;
+ $prefix = '';
+
+ if ( count( $parts ) == 1 ) {
+ $database = $parts[0];
+ } elseif ( count( $parts ) == 2 ) {
+ list( $database, $prefix ) = $parts;
+ } elseif ( count( $parts ) == 3 ) {
+ list( $database, $schema, $prefix ) = $parts;
+ } else {
+ throw new InvalidArgumentException( "Domain has too few or too many parts." );
+ }
+
+ if ( $database === '' ) {
+ $database = null;
+ }
+
+ return new self( $database, $schema, $prefix );
+ }
+
+ /**
+ * @return DatabaseDomain
+ */
+ public static function newUnspecified() {
+ return new self( null, null, '' );
+ }
+
+ /**
+ * @param DatabaseDomain|string $other
+ * @return bool
+ */
+ public function equals( $other ) {
+ if ( $other instanceof DatabaseDomain ) {
+ return (
+ $this->database === $other->database &&
+ $this->schema === $other->schema &&
+ $this->prefix === $other->prefix
+ );
+ }
+
+ return ( $this->getId() === $other );
+ }
+
+ /**
+ * @return string|null Database name
+ */
+ public function getDatabase() {
+ return $this->database;
+ }
+
+ /**
+ * @return string|null Database schema
+ */
+ public function getSchema() {
+ return $this->schema;
+ }
+
+ /**
+ * @return string Table prefix
+ */
+ public function getTablePrefix() {
+ return $this->prefix;
+ }
+
+ /**
+ * @return string
+ */
+ public function getId() {
+ if ( $this->equivalentString === null ) {
+ $this->equivalentString = $this->convertToString();
+ }
+
+ return $this->equivalentString;
+ }
+
+ /**
+ * @return string
+ */
+ private function convertToString() {
+ $parts = [ $this->database ];
+ if ( $this->schema !== null ) {
+ $parts[] = $this->schema;
+ }
+ if ( $this->prefix != '' ) {
+ $parts[] = $this->prefix;
+ }
+
+ return implode( '-', array_map( [ __CLASS__, 'encode' ], $parts ) );
+ }
+
+ private static function encode( $decoded ) {
+ $encoded = '';
+
+ $length = strlen( $decoded );
+ for ( $i = 0; $i < $length; ++$i ) {
+ $char = $decoded[$i];
+ if ( $char === '-' ) {
+ $encoded .= '?h';
+ } elseif ( $char === '?' ) {
+ $encoded .= '??';
+ } else {
+ $encoded .= $char;
+ }
+ }
+
+ return $encoded;
+ }
+
+ private static function decode( $encoded ) {
+ $decoded = '';
+
+ $length = strlen( $encoded );
+ for ( $i = 0; $i < $length; ++$i ) {
+ $char = $encoded[$i];
+ if ( $char === '?' ) {
+ $nextChar = isset( $encoded[$i + 1] ) ? $encoded[$i + 1] : null;
+ if ( $nextChar === 'h' ) {
+ $decoded .= '-';
+ ++$i;
+ } elseif ( $nextChar === '?' ) {
+ $decoded .= '?';
+ ++$i;
+ } else {
+ $decoded .= $char;
+ }
+ } else {
+ $decoded .= $char;
+ }
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * @return string
+ */
+ function __toString() {
+ return $this->getId();
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/database/DatabaseMssql.php b/www/wiki/includes/libs/rdbms/database/DatabaseMssql.php
new file mode 100644
index 00000000..8a69eec4
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/DatabaseMssql.php
@@ -0,0 +1,1351 @@
+<?php
+/**
+ * This is the MS SQL Server Native database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ * @author Joel Penner <a-joelpe at microsoft dot com>
+ * @author Chris Pucci <a-cpucci at microsoft dot com>
+ * @author Ryan Biesemeyer <v-ryanbi at microsoft dot com>
+ * @author Ryan Schmidt <skizzerz at gmail dot com>
+ */
+
+namespace Wikimedia\Rdbms;
+
+use MediaWiki;
+use Exception;
+use stdClass;
+
+/**
+ * @ingroup Database
+ */
+class DatabaseMssql extends Database {
+ protected $mPort;
+ protected $mUseWindowsAuth = false;
+
+ protected $mInsertId = null;
+ protected $mLastResult = null;
+ protected $mAffectedRows = null;
+ protected $mSubqueryId = 0;
+ protected $mScrollableCursor = true;
+ protected $mPrepareStatements = true;
+ protected $mBinaryColumnCache = null;
+ protected $mBitColumnCache = null;
+ protected $mIgnoreDupKeyErrors = false;
+ protected $mIgnoreErrors = [];
+
+ public function implicitGroupby() {
+ return false;
+ }
+
+ public function implicitOrderby() {
+ return false;
+ }
+
+ public function unionSupportsOrderAndLimit() {
+ return false;
+ }
+
+ public function __construct( array $params ) {
+ $this->mPort = $params['port'];
+ $this->mUseWindowsAuth = $params['UseWindowsAuth'];
+
+ parent::__construct( $params );
+ }
+
+ /**
+ * Usually aborts on failure
+ * @param string $server
+ * @param string $user
+ * @param string $password
+ * @param string $dbName
+ * @throws DBConnectionError
+ * @return bool|resource|null
+ */
+ public function open( $server, $user, $password, $dbName ) {
+ # Test for driver support, to avoid suppressed fatal error
+ if ( !function_exists( 'sqlsrv_connect' ) ) {
+ throw new DBConnectionError(
+ $this,
+ "Microsoft SQL Server Native (sqlsrv) functions missing.
+ You can download the driver from: http://go.microsoft.com/fwlink/?LinkId=123470\n"
+ );
+ }
+
+ # e.g. the class is being loaded
+ if ( !strlen( $user ) ) {
+ return null;
+ }
+
+ $this->close();
+ $this->mServer = $server;
+ $this->mUser = $user;
+ $this->mPassword = $password;
+ $this->mDBname = $dbName;
+
+ $connectionInfo = [];
+
+ if ( $dbName ) {
+ $connectionInfo['Database'] = $dbName;
+ }
+
+ // Decide which auth scenerio to use
+ // if we are using Windows auth, then don't add credentials to $connectionInfo
+ if ( !$this->mUseWindowsAuth ) {
+ $connectionInfo['UID'] = $user;
+ $connectionInfo['PWD'] = $password;
+ }
+
+ MediaWiki\suppressWarnings();
+ $this->mConn = sqlsrv_connect( $server, $connectionInfo );
+ MediaWiki\restoreWarnings();
+
+ if ( $this->mConn === false ) {
+ throw new DBConnectionError( $this, $this->lastError() );
+ }
+
+ $this->mOpened = true;
+
+ return $this->mConn;
+ }
+
+ /**
+ * Closes a database connection, if it is open
+ * Returns success, true if already closed
+ * @return bool
+ */
+ protected function closeConnection() {
+ return sqlsrv_close( $this->mConn );
+ }
+
+ /**
+ * @param bool|MssqlResultWrapper|resource $result
+ * @return bool|MssqlResultWrapper
+ */
+ protected function resultObject( $result ) {
+ if ( !$result ) {
+ return false;
+ } elseif ( $result instanceof MssqlResultWrapper ) {
+ return $result;
+ } elseif ( $result === true ) {
+ // Successful write query
+ return $result;
+ } else {
+ return new MssqlResultWrapper( $this, $result );
+ }
+ }
+
+ /**
+ * @param string $sql
+ * @return bool|MssqlResultWrapper|resource
+ * @throws DBUnexpectedError
+ */
+ protected function doQuery( $sql ) {
+ // several extensions seem to think that all databases support limits
+ // via LIMIT N after the WHERE clause, but MSSQL uses SELECT TOP N,
+ // so to catch any of those extensions we'll do a quick check for a
+ // LIMIT clause and pass $sql through $this->LimitToTopN() which parses
+ // the LIMIT clause and passes the result to $this->limitResult();
+ if ( preg_match( '/\bLIMIT\s*/i', $sql ) ) {
+ // massage LIMIT -> TopN
+ $sql = $this->LimitToTopN( $sql );
+ }
+
+ // MSSQL doesn't have EXTRACT(epoch FROM XXX)
+ if ( preg_match( '#\bEXTRACT\s*?\(\s*?EPOCH\s+FROM\b#i', $sql, $matches ) ) {
+ // This is same as UNIX_TIMESTAMP, we need to calc # of seconds from 1970
+ $sql = str_replace( $matches[0], "DATEDIFF(s,CONVERT(datetime,'1/1/1970'),", $sql );
+ }
+
+ // perform query
+
+ // SQLSRV_CURSOR_STATIC is slower than SQLSRV_CURSOR_CLIENT_BUFFERED (one of the two is
+ // needed if we want to be able to seek around the result set), however CLIENT_BUFFERED
+ // has a bug in the sqlsrv driver where wchar_t types (such as nvarchar) that are empty
+ // strings make php throw a fatal error "Severe error translating Unicode"
+ if ( $this->mScrollableCursor ) {
+ $scrollArr = [ 'Scrollable' => SQLSRV_CURSOR_STATIC ];
+ } else {
+ $scrollArr = [];
+ }
+
+ if ( $this->mPrepareStatements ) {
+ // we do prepare + execute so we can get its field metadata for later usage if desired
+ $stmt = sqlsrv_prepare( $this->mConn, $sql, [], $scrollArr );
+ $success = sqlsrv_execute( $stmt );
+ } else {
+ $stmt = sqlsrv_query( $this->mConn, $sql, [], $scrollArr );
+ $success = (bool)$stmt;
+ }
+
+ // Make a copy to ensure what we add below does not get reflected in future queries
+ $ignoreErrors = $this->mIgnoreErrors;
+
+ if ( $this->mIgnoreDupKeyErrors ) {
+ // ignore duplicate key errors
+ // this emulates INSERT IGNORE in MySQL
+ $ignoreErrors[] = '2601'; // duplicate key error caused by unique index
+ $ignoreErrors[] = '2627'; // duplicate key error caused by primary key
+ $ignoreErrors[] = '3621'; // generic "the statement has been terminated" error
+ }
+
+ if ( $success === false ) {
+ $errors = sqlsrv_errors();
+ $success = true;
+
+ foreach ( $errors as $err ) {
+ if ( !in_array( $err['code'], $ignoreErrors ) ) {
+ $success = false;
+ break;
+ }
+ }
+
+ if ( $success === false ) {
+ return false;
+ }
+ }
+ // remember number of rows affected
+ $this->mAffectedRows = sqlsrv_rows_affected( $stmt );
+
+ return $stmt;
+ }
+
+ public function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ sqlsrv_free_stmt( $res );
+ }
+
+ /**
+ * @param MssqlResultWrapper $res
+ * @return stdClass
+ */
+ public function fetchObject( $res ) {
+ // $res is expected to be an instance of MssqlResultWrapper here
+ return $res->fetchObject();
+ }
+
+ /**
+ * @param MssqlResultWrapper $res
+ * @return array
+ */
+ public function fetchRow( $res ) {
+ return $res->fetchRow();
+ }
+
+ /**
+ * @param mixed $res
+ * @return int
+ */
+ public function numRows( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ $ret = sqlsrv_num_rows( $res );
+
+ if ( $ret === false ) {
+ // we cannot get an amount of rows from this cursor type
+ // has_rows returns bool true/false if the result has rows
+ $ret = (int)sqlsrv_has_rows( $res );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param mixed $res
+ * @return int
+ */
+ public function numFields( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return sqlsrv_num_fields( $res );
+ }
+
+ /**
+ * @param mixed $res
+ * @param int $n
+ * @return int
+ */
+ public function fieldName( $res, $n ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return sqlsrv_field_metadata( $res )[$n]['Name'];
+ }
+
+ /**
+ * This must be called after nextSequenceVal
+ * @return int|null
+ */
+ public function insertId() {
+ return $this->mInsertId;
+ }
+
+ /**
+ * @param MssqlResultWrapper $res
+ * @param int $row
+ * @return bool
+ */
+ public function dataSeek( $res, $row ) {
+ return $res->seek( $row );
+ }
+
+ /**
+ * @return string
+ */
+ public function lastError() {
+ $strRet = '';
+ $retErrors = sqlsrv_errors( SQLSRV_ERR_ALL );
+ if ( $retErrors != null ) {
+ foreach ( $retErrors as $arrError ) {
+ $strRet .= $this->formatError( $arrError ) . "\n";
+ }
+ } else {
+ $strRet = "No errors found";
+ }
+
+ return $strRet;
+ }
+
+ /**
+ * @param array $err
+ * @return string
+ */
+ private function formatError( $err ) {
+ return '[SQLSTATE ' .
+ $err['SQLSTATE'] . '][Error Code ' . $err['code'] . ']' . $err['message'];
+ }
+
+ /**
+ * @return string|int
+ */
+ public function lastErrno() {
+ $err = sqlsrv_errors( SQLSRV_ERR_ALL );
+ if ( $err !== null && isset( $err[0] ) ) {
+ return $err[0]['code'];
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * @return int
+ */
+ public function affectedRows() {
+ return $this->mAffectedRows;
+ }
+
+ /**
+ * SELECT wrapper
+ *
+ * @param mixed $table Array or string, table name(s) (prefix auto-added)
+ * @param mixed $vars Array or string, field name(s) to be retrieved
+ * @param mixed $conds Array or string, condition(s) for WHERE
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @param array $options Associative array of options (e.g.
+ * [ 'GROUP BY' => 'page_title' ]), see Database::makeSelectOptions
+ * code for list of supported stuff
+ * @param array $join_conds Associative array of table join conditions
+ * (optional) (e.g. [ 'page' => [ 'LEFT JOIN','page_latest=rev_id' ] ]
+ * @return mixed Database result resource (feed to Database::fetchObject
+ * or whatever), or false on failure
+ * @throws DBQueryError
+ * @throws DBUnexpectedError
+ * @throws Exception
+ */
+ public function select( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ $sql = $this->selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
+ if ( isset( $options['EXPLAIN'] ) ) {
+ try {
+ $this->mScrollableCursor = false;
+ $this->mPrepareStatements = false;
+ $this->query( "SET SHOWPLAN_ALL ON" );
+ $ret = $this->query( $sql, $fname );
+ $this->query( "SET SHOWPLAN_ALL OFF" );
+ } catch ( DBQueryError $dqe ) {
+ if ( isset( $options['FOR COUNT'] ) ) {
+ // likely don't have privs for SHOWPLAN, so run a select count instead
+ $this->query( "SET SHOWPLAN_ALL OFF" );
+ unset( $options['EXPLAIN'] );
+ $ret = $this->select(
+ $table,
+ 'COUNT(*) AS EstimateRows',
+ $conds,
+ $fname,
+ $options,
+ $join_conds
+ );
+ } else {
+ // someone actually wanted the query plan instead of an est row count
+ // let them know of the error
+ $this->mScrollableCursor = true;
+ $this->mPrepareStatements = true;
+ throw $dqe;
+ }
+ }
+ $this->mScrollableCursor = true;
+ $this->mPrepareStatements = true;
+ return $ret;
+ }
+ return $this->query( $sql, $fname );
+ }
+
+ /**
+ * SELECT wrapper
+ *
+ * @param mixed $table Array or string, table name(s) (prefix auto-added)
+ * @param mixed $vars Array or string, field name(s) to be retrieved
+ * @param mixed $conds Array or string, condition(s) for WHERE
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @param array $options Associative array of options (e.g. [ 'GROUP BY' => 'page_title' ]),
+ * see Database::makeSelectOptions code for list of supported stuff
+ * @param array $join_conds Associative array of table join conditions (optional)
+ * (e.g. [ 'page' => [ 'LEFT JOIN','page_latest=rev_id' ] ]
+ * @return string The SQL text
+ */
+ public function selectSQLText( $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ ) {
+ if ( isset( $options['EXPLAIN'] ) ) {
+ unset( $options['EXPLAIN'] );
+ }
+
+ $sql = parent::selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
+
+ // try to rewrite aggregations of bit columns (currently MAX and MIN)
+ if ( strpos( $sql, 'MAX(' ) !== false || strpos( $sql, 'MIN(' ) !== false ) {
+ $bitColumns = [];
+ if ( is_array( $table ) ) {
+ foreach ( $table as $t ) {
+ $bitColumns += $this->getBitColumns( $this->tableName( $t ) );
+ }
+ } else {
+ $bitColumns = $this->getBitColumns( $this->tableName( $table ) );
+ }
+
+ foreach ( $bitColumns as $col => $info ) {
+ $replace = [
+ "MAX({$col})" => "MAX(CAST({$col} AS tinyint))",
+ "MIN({$col})" => "MIN(CAST({$col} AS tinyint))",
+ ];
+ $sql = str_replace( array_keys( $replace ), array_values( $replace ), $sql );
+ }
+ }
+
+ return $sql;
+ }
+
+ public function deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds,
+ $fname = __METHOD__
+ ) {
+ $this->mScrollableCursor = false;
+ try {
+ parent::deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds, $fname );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+ }
+
+ public function delete( $table, $conds, $fname = __METHOD__ ) {
+ $this->mScrollableCursor = false;
+ try {
+ parent::delete( $table, $conds, $fname );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+ }
+
+ /**
+ * Estimate rows in dataset
+ * Returns estimated count, based on SHOWPLAN_ALL output
+ * This is not necessarily an accurate estimate, so use sparingly
+ * Returns -1 if count cannot be found
+ * Takes same arguments as Database::select()
+ * @param string $table
+ * @param string $vars
+ * @param string $conds
+ * @param string $fname
+ * @param array $options
+ * @return int
+ */
+ public function estimateRowCount( $table, $vars = '*', $conds = '',
+ $fname = __METHOD__, $options = []
+ ) {
+ // http://msdn2.microsoft.com/en-us/library/aa259203.aspx
+ $options['EXPLAIN'] = true;
+ $options['FOR COUNT'] = true;
+ $res = $this->select( $table, $vars, $conds, $fname, $options );
+
+ $rows = -1;
+ if ( $res ) {
+ $row = $this->fetchRow( $res );
+
+ if ( isset( $row['EstimateRows'] ) ) {
+ $rows = (int)$row['EstimateRows'];
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Returns information about an index
+ * If errors are explicitly ignored, returns NULL on failure
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return array|bool|null
+ */
+ public function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ # This does not return the same info as MYSQL would, but that's OK
+ # because MediaWiki never uses the returned value except to check for
+ # the existence of indexes.
+ $sql = "sp_helpindex '" . $this->tableName( $table ) . "'";
+ $res = $this->query( $sql, $fname );
+
+ if ( !$res ) {
+ return null;
+ }
+
+ $result = [];
+ foreach ( $res as $row ) {
+ if ( $row->index_name == $index ) {
+ $row->Non_unique = !stristr( $row->index_description, "unique" );
+ $cols = explode( ", ", $row->index_keys );
+ foreach ( $cols as $col ) {
+ $row->Column_name = trim( $col );
+ $result[] = clone $row;
+ }
+ } elseif ( $index == 'PRIMARY' && stristr( $row->index_description, 'PRIMARY' ) ) {
+ $row->Non_unique = 0;
+ $cols = explode( ", ", $row->index_keys );
+ foreach ( $cols as $col ) {
+ $row->Column_name = trim( $col );
+ $result[] = clone $row;
+ }
+ }
+ }
+
+ return empty( $result ) ? false : $result;
+ }
+
+ /**
+ * INSERT wrapper, inserts an array into a table
+ *
+ * $arrToInsert may be a single associative array, or an array of these with numeric keys, for
+ * multi-row insert.
+ *
+ * Usually aborts on failure
+ * If errors are explicitly ignored, returns success
+ * @param string $table
+ * @param array $arrToInsert
+ * @param string $fname
+ * @param array $options
+ * @return bool
+ * @throws Exception
+ */
+ public function insert( $table, $arrToInsert, $fname = __METHOD__, $options = [] ) {
+ # No rows to insert, easy just return now
+ if ( !count( $arrToInsert ) ) {
+ return true;
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ $table = $this->tableName( $table );
+
+ if ( !( isset( $arrToInsert[0] ) && is_array( $arrToInsert[0] ) ) ) { // Not multi row
+ $arrToInsert = [ 0 => $arrToInsert ]; // make everything multi row compatible
+ }
+
+ // We know the table we're inserting into, get its identity column
+ $identity = null;
+ // strip matching square brackets and the db/schema from table name
+ $tableRawArr = explode( '.', preg_replace( '#\[([^\]]*)\]#', '$1', $table ) );
+ $tableRaw = array_pop( $tableRawArr );
+ $res = $this->doQuery(
+ "SELECT NAME AS idColumn FROM SYS.IDENTITY_COLUMNS " .
+ "WHERE OBJECT_NAME(OBJECT_ID)='{$tableRaw}'"
+ );
+ if ( $res && sqlsrv_has_rows( $res ) ) {
+ // There is an identity for this table.
+ $identityArr = sqlsrv_fetch_array( $res, SQLSRV_FETCH_ASSOC );
+ $identity = array_pop( $identityArr );
+ }
+ sqlsrv_free_stmt( $res );
+
+ // Determine binary/varbinary fields so we can encode data as a hex string like 0xABCDEF
+ $binaryColumns = $this->getBinaryColumns( $table );
+
+ // INSERT IGNORE is not supported by SQL Server
+ // remove IGNORE from options list and set ignore flag to true
+ if ( in_array( 'IGNORE', $options ) ) {
+ $options = array_diff( $options, [ 'IGNORE' ] );
+ $this->mIgnoreDupKeyErrors = true;
+ }
+
+ $ret = null;
+ foreach ( $arrToInsert as $a ) {
+ // start out with empty identity column, this is so we can return
+ // it as a result of the INSERT logic
+ $sqlPre = '';
+ $sqlPost = '';
+ $identityClause = '';
+
+ // if we have an identity column
+ if ( $identity ) {
+ // iterate through
+ foreach ( $a as $k => $v ) {
+ if ( $k == $identity ) {
+ if ( !is_null( $v ) ) {
+ // there is a value being passed to us,
+ // we need to turn on and off inserted identity
+ $sqlPre = "SET IDENTITY_INSERT $table ON;";
+ $sqlPost = ";SET IDENTITY_INSERT $table OFF;";
+ } else {
+ // we can't insert NULL into an identity column,
+ // so remove the column from the insert.
+ unset( $a[$k] );
+ }
+ }
+ }
+
+ // we want to output an identity column as result
+ $identityClause = "OUTPUT INSERTED.$identity ";
+ }
+
+ $keys = array_keys( $a );
+
+ // Build the actual query
+ $sql = $sqlPre . 'INSERT ' . implode( ' ', $options ) .
+ " INTO $table (" . implode( ',', $keys ) . ") $identityClause VALUES (";
+
+ $first = true;
+ foreach ( $a as $key => $value ) {
+ if ( isset( $binaryColumns[$key] ) ) {
+ $value = new MssqlBlob( $value );
+ }
+ if ( $first ) {
+ $first = false;
+ } else {
+ $sql .= ',';
+ }
+ if ( is_null( $value ) ) {
+ $sql .= 'null';
+ } elseif ( is_array( $value ) || is_object( $value ) ) {
+ if ( is_object( $value ) && $value instanceof Blob ) {
+ $sql .= $this->addQuotes( $value );
+ } else {
+ $sql .= $this->addQuotes( serialize( $value ) );
+ }
+ } else {
+ $sql .= $this->addQuotes( $value );
+ }
+ }
+ $sql .= ')' . $sqlPost;
+
+ // Run the query
+ $this->mScrollableCursor = false;
+ try {
+ $ret = $this->query( $sql );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ $this->mIgnoreDupKeyErrors = false;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+
+ if ( $ret instanceof ResultWrapper && !is_null( $identity ) ) {
+ // Then we want to get the identity column value we were assigned and save it off
+ $row = $ret->fetchObject();
+ if ( is_object( $row ) ) {
+ $this->mInsertId = $row->$identity;
+ // It seems that mAffectedRows is -1 sometimes when OUTPUT INSERTED.identity is
+ // used if we got an identity back, we know for sure a row was affected, so
+ // adjust that here
+ if ( $this->mAffectedRows == -1 ) {
+ $this->mAffectedRows = 1;
+ }
+ }
+ }
+ }
+
+ $this->mIgnoreDupKeyErrors = false;
+
+ return $ret;
+ }
+
+ /**
+ * INSERT SELECT wrapper
+ * $varMap must be an associative array of the form [ 'dest1' => 'source1', ... ]
+ * Source items may be literals rather than field names, but strings should
+ * be quoted with Database::addQuotes().
+ * @param string $destTable
+ * @param array|string $srcTable May be an array of tables.
+ * @param array $varMap
+ * @param array $conds May be "*" to copy the whole table.
+ * @param string $fname
+ * @param array $insertOptions
+ * @param array $selectOptions
+ * @param array $selectJoinConds
+ * @return null|ResultWrapper
+ * @throws Exception
+ */
+ public function nativeInsertSelect( $destTable, $srcTable, $varMap, $conds, $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = [], $selectJoinConds = []
+ ) {
+ $this->mScrollableCursor = false;
+ try {
+ $ret = parent::nativeInsertSelect(
+ $destTable,
+ $srcTable,
+ $varMap,
+ $conds,
+ $fname,
+ $insertOptions,
+ $selectOptions,
+ $selectJoinConds
+ );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+
+ return $ret;
+ }
+
+ /**
+ * UPDATE wrapper. Takes a condition array and a SET array.
+ *
+ * @param string $table Name of the table to UPDATE. This will be passed through
+ * Database::tableName().
+ *
+ * @param array $values An array of values to SET. For each array element,
+ * the key gives the field name, and the value gives the data
+ * to set that field to. The data will be quoted by
+ * Database::addQuotes().
+ *
+ * @param array $conds An array of conditions (WHERE). See
+ * Database::select() for the details of the format of
+ * condition arrays. Use '*' to update all rows.
+ *
+ * @param string $fname The function name of the caller (from __METHOD__),
+ * for logging and profiling.
+ *
+ * @param array $options An array of UPDATE options, can be:
+ * - IGNORE: Ignore unique key conflicts
+ * - LOW_PRIORITY: MySQL-specific, see MySQL manual.
+ * @return bool
+ * @throws DBUnexpectedError
+ * @throws Exception
+ */
+ function update( $table, $values, $conds, $fname = __METHOD__, $options = [] ) {
+ $table = $this->tableName( $table );
+ $binaryColumns = $this->getBinaryColumns( $table );
+
+ $opts = $this->makeUpdateOptions( $options );
+ $sql = "UPDATE $opts $table SET " . $this->makeList( $values, LIST_SET, $binaryColumns );
+
+ if ( $conds !== [] && $conds !== '*' ) {
+ $sql .= " WHERE " . $this->makeList( $conds, LIST_AND, $binaryColumns );
+ }
+
+ $this->mScrollableCursor = false;
+ try {
+ $this->query( $sql );
+ } catch ( Exception $e ) {
+ $this->mScrollableCursor = true;
+ throw $e;
+ }
+ $this->mScrollableCursor = true;
+ return true;
+ }
+
+ /**
+ * Makes an encoded list of strings from an array
+ * @param array $a Containing the data
+ * @param int $mode Constant
+ * - LIST_COMMA: comma separated, no field names
+ * - LIST_AND: ANDed WHERE clause (without the WHERE). See
+ * the documentation for $conds in Database::select().
+ * - LIST_OR: ORed WHERE clause (without the WHERE)
+ * - LIST_SET: comma separated with field names, like a SET clause
+ * - LIST_NAMES: comma separated field names
+ * @param array $binaryColumns Contains a list of column names that are binary types
+ * This is a custom parameter only present for MS SQL.
+ *
+ * @throws DBUnexpectedError
+ * @return string
+ */
+ public function makeList( $a, $mode = LIST_COMMA, $binaryColumns = [] ) {
+ if ( !is_array( $a ) ) {
+ throw new DBUnexpectedError( $this, __METHOD__ . ' called with incorrect parameters' );
+ }
+
+ if ( $mode != LIST_NAMES ) {
+ // In MS SQL, values need to be specially encoded when they are
+ // inserted into binary fields. Perform this necessary encoding
+ // for the specified set of columns.
+ foreach ( array_keys( $a ) as $field ) {
+ if ( !isset( $binaryColumns[$field] ) ) {
+ continue;
+ }
+
+ if ( is_array( $a[$field] ) ) {
+ foreach ( $a[$field] as &$v ) {
+ $v = new MssqlBlob( $v );
+ }
+ unset( $v );
+ } else {
+ $a[$field] = new MssqlBlob( $a[$field] );
+ }
+ }
+ }
+
+ return parent::makeList( $a, $mode );
+ }
+
+ /**
+ * @param string $table
+ * @param string $field
+ * @return int Returns the size of a text field, or -1 for "unlimited"
+ */
+ public function textFieldSize( $table, $field ) {
+ $table = $this->tableName( $table );
+ $sql = "SELECT CHARACTER_MAXIMUM_LENGTH,DATA_TYPE FROM INFORMATION_SCHEMA.Columns
+ WHERE TABLE_NAME = '$table' AND COLUMN_NAME = '$field'";
+ $res = $this->query( $sql );
+ $row = $this->fetchRow( $res );
+ $size = -1;
+ if ( strtolower( $row['DATA_TYPE'] ) != 'text' ) {
+ $size = $row['CHARACTER_MAXIMUM_LENGTH'];
+ }
+
+ return $size;
+ }
+
+ /**
+ * Construct a LIMIT query with optional offset
+ * This is used for query pages
+ *
+ * @param string $sql SQL query we will append the limit too
+ * @param int $limit The SQL limit
+ * @param bool|int $offset The SQL offset (default false)
+ * @return array|string
+ * @throws DBUnexpectedError
+ */
+ public function limitResult( $sql, $limit, $offset = false ) {
+ if ( $offset === false || $offset == 0 ) {
+ if ( strpos( $sql, "SELECT" ) === false ) {
+ return "TOP {$limit} " . $sql;
+ } else {
+ return preg_replace( '/\bSELECT(\s+DISTINCT)?\b/Dsi',
+ 'SELECT$1 TOP ' . $limit, $sql, 1 );
+ }
+ } else {
+ // This one is fun, we need to pull out the select list as well as any ORDER BY clause
+ $select = $orderby = [];
+ $s1 = preg_match( '#SELECT\s+(.+?)\s+FROM#Dis', $sql, $select );
+ $s2 = preg_match( '#(ORDER BY\s+.+?)(\s*FOR XML .*)?$#Dis', $sql, $orderby );
+ $postOrder = '';
+ $first = $offset + 1;
+ $last = $offset + $limit;
+ $sub1 = 'sub_' . $this->mSubqueryId;
+ $sub2 = 'sub_' . ( $this->mSubqueryId + 1 );
+ $this->mSubqueryId += 2;
+ if ( !$s1 ) {
+ // wat
+ throw new DBUnexpectedError( $this, "Attempting to LIMIT a non-SELECT query\n" );
+ }
+ if ( !$s2 ) {
+ // no ORDER BY
+ $overOrder = 'ORDER BY (SELECT 1)';
+ } else {
+ if ( !isset( $orderby[2] ) || !$orderby[2] ) {
+ // don't need to strip it out if we're using a FOR XML clause
+ $sql = str_replace( $orderby[1], '', $sql );
+ }
+ $overOrder = $orderby[1];
+ $postOrder = ' ' . $overOrder;
+ }
+ $sql = "SELECT {$select[1]}
+ FROM (
+ SELECT ROW_NUMBER() OVER({$overOrder}) AS rowNumber, *
+ FROM ({$sql}) {$sub1}
+ ) {$sub2}
+ WHERE rowNumber BETWEEN {$first} AND {$last}{$postOrder}";
+
+ return $sql;
+ }
+ }
+
+ /**
+ * If there is a limit clause, parse it, strip it, and pass the remaining
+ * SQL through limitResult() with the appropriate parameters. Not the
+ * prettiest solution, but better than building a whole new parser. This
+ * exists becase there are still too many extensions that don't use dynamic
+ * sql generation.
+ *
+ * @param string $sql
+ * @return array|mixed|string
+ */
+ public function LimitToTopN( $sql ) {
+ // Matches: LIMIT {[offset,] row_count | row_count OFFSET offset}
+ $pattern = '/\bLIMIT\s+((([0-9]+)\s*,\s*)?([0-9]+)(\s+OFFSET\s+([0-9]+))?)/i';
+ if ( preg_match( $pattern, $sql, $matches ) ) {
+ $row_count = $matches[4];
+ $offset = $matches[3] ?: $matches[6] ?: false;
+
+ // strip the matching LIMIT clause out
+ $sql = str_replace( $matches[0], '', $sql );
+
+ return $this->limitResult( $sql, $row_count, $offset );
+ }
+
+ return $sql;
+ }
+
+ /**
+ * @return string Wikitext of a link to the server software's web site
+ */
+ public function getSoftwareLink() {
+ return "[{{int:version-db-mssql-url}} MS SQL Server]";
+ }
+
+ /**
+ * @return string Version information from the database
+ */
+ public function getServerVersion() {
+ $server_info = sqlsrv_server_info( $this->mConn );
+ $version = 'Error';
+ if ( isset( $server_info['SQLServerVersion'] ) ) {
+ $version = $server_info['SQLServerVersion'];
+ }
+
+ return $version;
+ }
+
+ /**
+ * @param string $table
+ * @param string $fname
+ * @return bool
+ */
+ public function tableExists( $table, $fname = __METHOD__ ) {
+ list( $db, $schema, $table ) = $this->tableName( $table, 'split' );
+
+ if ( $db !== false ) {
+ // remote database
+ $this->queryLogger->error( "Attempting to call tableExists on a remote table" );
+ return false;
+ }
+
+ if ( $schema === false ) {
+ $schema = $this->mSchema;
+ }
+
+ $res = $this->query( "SELECT 1 FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_TYPE = 'BASE TABLE'
+ AND TABLE_SCHEMA = '$schema' AND TABLE_NAME = '$table'" );
+
+ if ( $res->numRows() ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Query whether a given column exists in the mediawiki schema
+ * @param string $table
+ * @param string $field
+ * @param string $fname
+ * @return bool
+ */
+ public function fieldExists( $table, $field, $fname = __METHOD__ ) {
+ list( $db, $schema, $table ) = $this->tableName( $table, 'split' );
+
+ if ( $db !== false ) {
+ // remote database
+ $this->queryLogger->error( "Attempting to call fieldExists on a remote table" );
+ return false;
+ }
+
+ $res = $this->query( "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = '$schema' AND TABLE_NAME = '$table' AND COLUMN_NAME = '$field'" );
+
+ if ( $res->numRows() ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public function fieldInfo( $table, $field ) {
+ list( $db, $schema, $table ) = $this->tableName( $table, 'split' );
+
+ if ( $db !== false ) {
+ // remote database
+ $this->queryLogger->error( "Attempting to call fieldInfo on a remote table" );
+ return false;
+ }
+
+ $res = $this->query( "SELECT * FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = '$schema' AND TABLE_NAME = '$table' AND COLUMN_NAME = '$field'" );
+
+ $meta = $res->fetchRow();
+ if ( $meta ) {
+ return new MssqlField( $meta );
+ }
+
+ return false;
+ }
+
+ /**
+ * Begin a transaction, committing any previously open transaction
+ * @param string $fname
+ */
+ protected function doBegin( $fname = __METHOD__ ) {
+ sqlsrv_begin_transaction( $this->mConn );
+ $this->mTrxLevel = 1;
+ }
+
+ /**
+ * End a transaction
+ * @param string $fname
+ */
+ protected function doCommit( $fname = __METHOD__ ) {
+ sqlsrv_commit( $this->mConn );
+ $this->mTrxLevel = 0;
+ }
+
+ /**
+ * Rollback a transaction.
+ * No-op on non-transactional databases.
+ * @param string $fname
+ */
+ protected function doRollback( $fname = __METHOD__ ) {
+ sqlsrv_rollback( $this->mConn );
+ $this->mTrxLevel = 0;
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ public function strencode( $s ) {
+ // Should not be called by us
+ return str_replace( "'", "''", $s );
+ }
+
+ /**
+ * @param string|int|null|bool|Blob $s
+ * @return string|int
+ */
+ public function addQuotes( $s ) {
+ if ( $s instanceof MssqlBlob ) {
+ return $s->fetch();
+ } elseif ( $s instanceof Blob ) {
+ // this shouldn't really ever be called, but it's here if needed
+ // (and will quite possibly make the SQL error out)
+ $blob = new MssqlBlob( $s->fetch() );
+ return $blob->fetch();
+ } else {
+ if ( is_bool( $s ) ) {
+ $s = $s ? 1 : 0;
+ }
+ return parent::addQuotes( $s );
+ }
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ public function addIdentifierQuotes( $s ) {
+ // http://msdn.microsoft.com/en-us/library/aa223962.aspx
+ return '[' . $s . ']';
+ }
+
+ /**
+ * @param string $name
+ * @return bool
+ */
+ public function isQuotedIdentifier( $name ) {
+ return strlen( $name ) && $name[0] == '[' && substr( $name, -1, 1 ) == ']';
+ }
+
+ /**
+ * MS SQL supports more pattern operators than other databases (ex: [,],^)
+ *
+ * @param string $s
+ * @param string $escapeChar
+ * @return string
+ */
+ protected function escapeLikeInternal( $s, $escapeChar = '`' ) {
+ return str_replace( [ $escapeChar, '%', '_', '[', ']', '^' ],
+ [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_",
+ "{$escapeChar}[", "{$escapeChar}]", "{$escapeChar}^" ],
+ $s );
+ }
+
+ /**
+ * @param string $db
+ * @return bool
+ */
+ public function selectDB( $db ) {
+ try {
+ $this->mDBname = $db;
+ $this->query( "USE $db" );
+ return true;
+ } catch ( Exception $e ) {
+ return false;
+ }
+ }
+
+ /**
+ * @param array $options An associative array of options to be turned into
+ * an SQL query, valid keys are listed in the function.
+ * @return array
+ */
+ public function makeSelectOptions( $options ) {
+ $tailOpts = '';
+ $startOpts = '';
+
+ $noKeyOptions = [];
+ foreach ( $options as $key => $option ) {
+ if ( is_numeric( $key ) ) {
+ $noKeyOptions[$option] = true;
+ }
+ }
+
+ $tailOpts .= $this->makeGroupByWithHaving( $options );
+
+ $tailOpts .= $this->makeOrderBy( $options );
+
+ if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
+ $startOpts .= 'DISTINCT';
+ }
+
+ if ( isset( $noKeyOptions['FOR XML'] ) ) {
+ // used in group concat field emulation
+ $tailOpts .= " FOR XML PATH('')";
+ }
+
+ // we want this to be compatible with the output of parent::makeSelectOptions()
+ return [ $startOpts, '', $tailOpts, '', '' ];
+ }
+
+ public function getType() {
+ return 'mssql';
+ }
+
+ /**
+ * @param array $stringList
+ * @return string
+ */
+ public function buildConcat( $stringList ) {
+ return implode( ' + ', $stringList );
+ }
+
+ /**
+ * Build a GROUP_CONCAT or equivalent statement for a query.
+ * MS SQL doesn't have GROUP_CONCAT so we emulate it with other stuff (and boy is it nasty)
+ *
+ * This is useful for combining a field for several rows into a single string.
+ * NULL values will not appear in the output, duplicated values will appear,
+ * and the resulting delimiter-separated values have no defined sort order.
+ * Code using the results may need to use the PHP unique() or sort() methods.
+ *
+ * @param string $delim Glue to bind the results together
+ * @param string|array $table Table name
+ * @param string $field Field name
+ * @param string|array $conds Conditions
+ * @param string|array $join_conds Join conditions
+ * @return string SQL text
+ * @since 1.23
+ */
+ public function buildGroupConcatField( $delim, $table, $field, $conds = '',
+ $join_conds = []
+ ) {
+ $gcsq = 'gcsq_' . $this->mSubqueryId;
+ $this->mSubqueryId++;
+
+ $delimLen = strlen( $delim );
+ $fld = "{$field} + {$this->addQuotes( $delim )}";
+ $sql = "(SELECT LEFT({$field}, LEN({$field}) - {$delimLen}) FROM ("
+ . $this->selectSQLText( $table, $fld, $conds, null, [ 'FOR XML' ], $join_conds )
+ . ") {$gcsq} ({$field}))";
+
+ return $sql;
+ }
+
+ /**
+ * Returns an associative array for fields that are of type varbinary, binary, or image
+ * $table can be either a raw table name or passed through tableName() first
+ * @param string $table
+ * @return array
+ */
+ private function getBinaryColumns( $table ) {
+ $tableRawArr = explode( '.', preg_replace( '#\[([^\]]*)\]#', '$1', $table ) );
+ $tableRaw = array_pop( $tableRawArr );
+
+ if ( $this->mBinaryColumnCache === null ) {
+ $this->populateColumnCaches();
+ }
+
+ return isset( $this->mBinaryColumnCache[$tableRaw] )
+ ? $this->mBinaryColumnCache[$tableRaw]
+ : [];
+ }
+
+ /**
+ * @param string $table
+ * @return array
+ */
+ private function getBitColumns( $table ) {
+ $tableRawArr = explode( '.', preg_replace( '#\[([^\]]*)\]#', '$1', $table ) );
+ $tableRaw = array_pop( $tableRawArr );
+
+ if ( $this->mBitColumnCache === null ) {
+ $this->populateColumnCaches();
+ }
+
+ return isset( $this->mBitColumnCache[$tableRaw] )
+ ? $this->mBitColumnCache[$tableRaw]
+ : [];
+ }
+
+ private function populateColumnCaches() {
+ $res = $this->select( 'INFORMATION_SCHEMA.COLUMNS', '*',
+ [
+ 'TABLE_CATALOG' => $this->mDBname,
+ 'TABLE_SCHEMA' => $this->mSchema,
+ 'DATA_TYPE' => [ 'varbinary', 'binary', 'image', 'bit' ]
+ ] );
+
+ $this->mBinaryColumnCache = [];
+ $this->mBitColumnCache = [];
+ foreach ( $res as $row ) {
+ if ( $row->DATA_TYPE == 'bit' ) {
+ $this->mBitColumnCache[$row->TABLE_NAME][$row->COLUMN_NAME] = $row;
+ } else {
+ $this->mBinaryColumnCache[$row->TABLE_NAME][$row->COLUMN_NAME] = $row;
+ }
+ }
+ }
+
+ /**
+ * @param string $name
+ * @param string $format
+ * @return string
+ */
+ function tableName( $name, $format = 'quoted' ) {
+ # Replace reserved words with better ones
+ switch ( $name ) {
+ case 'user':
+ return $this->realTableName( 'mwuser', $format );
+ default:
+ return $this->realTableName( $name, $format );
+ }
+ }
+
+ /**
+ * call this instead of tableName() in the updater when renaming tables
+ * @param string $name
+ * @param string $format One of quoted, raw, or split
+ * @return string
+ */
+ function realTableName( $name, $format = 'quoted' ) {
+ $table = parent::tableName( $name, $format );
+ if ( $format == 'split' ) {
+ // Used internally, we want the schema split off from the table name and returned
+ // as a list with 3 elements (database, schema, table)
+ $table = explode( '.', $table );
+ while ( count( $table ) < 3 ) {
+ array_unshift( $table, false );
+ }
+ }
+ return $table;
+ }
+
+ /**
+ * Delete a table
+ * @param string $tableName
+ * @param string $fName
+ * @return bool|ResultWrapper
+ * @since 1.18
+ */
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ if ( !$this->tableExists( $tableName, $fName ) ) {
+ return false;
+ }
+
+ // parent function incorrectly appends CASCADE, which we don't want
+ $sql = "DROP TABLE " . $this->tableName( $tableName );
+
+ return $this->query( $sql, $fName );
+ }
+
+ /**
+ * Called in the installer and updater.
+ * Probably doesn't need to be called anywhere else in the codebase.
+ * @param bool|null $value
+ * @return bool|null
+ */
+ public function prepareStatements( $value = null ) {
+ $old = $this->mPrepareStatements;
+ if ( $value !== null ) {
+ $this->mPrepareStatements = $value;
+ }
+
+ return $old;
+ }
+
+ /**
+ * Called in the installer and updater.
+ * Probably doesn't need to be called anywhere else in the codebase.
+ * @param bool|null $value
+ * @return bool|null
+ */
+ public function scrollableCursor( $value = null ) {
+ $old = $this->mScrollableCursor;
+ if ( $value !== null ) {
+ $this->mScrollableCursor = $value;
+ }
+
+ return $old;
+ }
+}
+
+class_alias( DatabaseMssql::class, 'DatabaseMssql' );
diff --git a/www/wiki/includes/libs/rdbms/database/DatabaseMysql.php b/www/wiki/includes/libs/rdbms/database/DatabaseMysql.php
new file mode 100644
index 00000000..58b09266
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/DatabaseMysql.php
@@ -0,0 +1,210 @@
+<?php
+/**
+ * This is the MySQL database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+/**
+ * Database abstraction object for PHP extension mysql.
+ *
+ * @deprecated 1.30 PHP extension 'mysql' was deprecated in PHP 5.5 and removed in PHP 7.0.
+ * @see PHP extension 'mysqli' and DatabaseMysqli
+ *
+ * @ingroup Database
+ * @see Database
+ */
+class DatabaseMysql extends DatabaseMysqlBase {
+ /**
+ * @param string $sql
+ * @return resource False on error
+ */
+ protected function doQuery( $sql ) {
+ $conn = $this->getBindingHandle();
+
+ if ( $this->bufferResults() ) {
+ $ret = mysql_query( $sql, $conn );
+ } else {
+ $ret = mysql_unbuffered_query( $sql, $conn );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $realServer
+ * @return bool|resource MySQL Database connection or false on failure to connect
+ * @throws DBConnectionError
+ */
+ protected function mysqlConnect( $realServer ) {
+ # Avoid a suppressed fatal error, which is very hard to track down
+ if ( !extension_loaded( 'mysql' ) ) {
+ throw new DBConnectionError(
+ $this,
+ "MySQL functions missing, have you compiled PHP with the --with-mysql option?\n"
+ );
+ }
+
+ $connFlags = 0;
+ if ( $this->mFlags & self::DBO_SSL ) {
+ $connFlags |= MYSQL_CLIENT_SSL;
+ }
+ if ( $this->mFlags & self::DBO_COMPRESS ) {
+ $connFlags |= MYSQL_CLIENT_COMPRESS;
+ }
+
+ if ( ini_get( 'mysql.connect_timeout' ) <= 3 ) {
+ $numAttempts = 2;
+ } else {
+ $numAttempts = 1;
+ }
+
+ $conn = false;
+
+ # The kernel's default SYN retransmission period is far too slow for us,
+ # so we use a short timeout plus a manual retry. Retrying means that a small
+ # but finite rate of SYN packet loss won't cause user-visible errors.
+ for ( $i = 0; $i < $numAttempts && !$conn; $i++ ) {
+ if ( $i > 1 ) {
+ usleep( 1000 );
+ }
+ if ( $this->mFlags & self::DBO_PERSISTENT ) {
+ $conn = mysql_pconnect( $realServer, $this->mUser, $this->mPassword, $connFlags );
+ } else {
+ # Create a new connection...
+ $conn = mysql_connect( $realServer, $this->mUser, $this->mPassword, true, $connFlags );
+ }
+ }
+
+ return $conn;
+ }
+
+ /**
+ * @param string $charset
+ * @return bool
+ */
+ protected function mysqlSetCharset( $charset ) {
+ $conn = $this->getBindingHandle();
+
+ if ( function_exists( 'mysql_set_charset' ) ) {
+ return mysql_set_charset( $charset, $conn );
+ } else {
+ return $this->query( 'SET NAMES ' . $charset, __METHOD__ );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ protected function closeConnection() {
+ $conn = $this->getBindingHandle();
+
+ return mysql_close( $conn );
+ }
+
+ /**
+ * @return int
+ */
+ function insertId() {
+ $conn = $this->getBindingHandle();
+
+ return mysql_insert_id( $conn );
+ }
+
+ /**
+ * @return int
+ */
+ function lastErrno() {
+ if ( $this->mConn ) {
+ return mysql_errno( $this->mConn );
+ } else {
+ return mysql_errno();
+ }
+ }
+
+ /**
+ * @return int
+ */
+ function affectedRows() {
+ $conn = $this->getBindingHandle();
+
+ return mysql_affected_rows( $conn );
+ }
+
+ /**
+ * @param string $db
+ * @return bool
+ */
+ function selectDB( $db ) {
+ $conn = $this->getBindingHandle();
+
+ $this->mDBname = $db;
+
+ return mysql_select_db( $db, $conn );
+ }
+
+ protected function mysqlFreeResult( $res ) {
+ return mysql_free_result( $res );
+ }
+
+ protected function mysqlFetchObject( $res ) {
+ return mysql_fetch_object( $res );
+ }
+
+ protected function mysqlFetchArray( $res ) {
+ return mysql_fetch_array( $res );
+ }
+
+ protected function mysqlNumRows( $res ) {
+ return mysql_num_rows( $res );
+ }
+
+ protected function mysqlNumFields( $res ) {
+ return mysql_num_fields( $res );
+ }
+
+ protected function mysqlFetchField( $res, $n ) {
+ return mysql_fetch_field( $res, $n );
+ }
+
+ protected function mysqlFieldName( $res, $n ) {
+ return mysql_field_name( $res, $n );
+ }
+
+ protected function mysqlFieldType( $res, $n ) {
+ return mysql_field_type( $res, $n );
+ }
+
+ protected function mysqlDataSeek( $res, $row ) {
+ return mysql_data_seek( $res, $row );
+ }
+
+ protected function mysqlError( $conn = null ) {
+ return ( $conn !== null ) ? mysql_error( $conn ) : mysql_error(); // avoid warning
+ }
+
+ protected function mysqlRealEscapeString( $s ) {
+ $conn = $this->getBindingHandle();
+
+ return mysql_real_escape_string( (string)$s, $conn );
+ }
+}
+
+class_alias( DatabaseMysql::class, 'DatabaseMysql' );
diff --git a/www/wiki/includes/libs/rdbms/database/DatabaseMysqlBase.php b/www/wiki/includes/libs/rdbms/database/DatabaseMysqlBase.php
new file mode 100644
index 00000000..3c4cda55
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/DatabaseMysqlBase.php
@@ -0,0 +1,1387 @@
+<?php
+/**
+ * This is the MySQL database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use DateTime;
+use DateTimeZone;
+use MediaWiki;
+use InvalidArgumentException;
+use Exception;
+use stdClass;
+
+/**
+ * Database abstraction object for MySQL.
+ * Defines methods independent on used MySQL extension.
+ *
+ * @ingroup Database
+ * @since 1.22
+ * @see Database
+ */
+abstract class DatabaseMysqlBase extends Database {
+ /** @var MysqlMasterPos */
+ protected $lastKnownReplicaPos;
+ /** @var string Method to detect replica DB lag */
+ protected $lagDetectionMethod;
+ /** @var array Method to detect replica DB lag */
+ protected $lagDetectionOptions = [];
+ /** @var bool bool Whether to use GTID methods */
+ protected $useGTIDs = false;
+ /** @var string|null */
+ protected $sslKeyPath;
+ /** @var string|null */
+ protected $sslCertPath;
+ /** @var string|null */
+ protected $sslCAFile;
+ /** @var string|null */
+ protected $sslCAPath;
+ /** @var string[]|null */
+ protected $sslCiphers;
+ /** @var string sql_mode value to send on connection */
+ protected $sqlMode;
+ /** @var bool Use experimental UTF-8 transmission encoding */
+ protected $utf8Mode;
+
+ /** @var string|null */
+ private $serverVersion = null;
+
+ /**
+ * Additional $params include:
+ * - lagDetectionMethod : set to one of (Seconds_Behind_Master,pt-heartbeat).
+ * pt-heartbeat assumes the table is at heartbeat.heartbeat
+ * and uses UTC timestamps in the heartbeat.ts column.
+ * (https://www.percona.com/doc/percona-toolkit/2.2/pt-heartbeat.html)
+ * - lagDetectionOptions : if using pt-heartbeat, this can be set to an array map to change
+ * the default behavior. Normally, the heartbeat row with the server
+ * ID of this server's master will be used. Set the "conds" field to
+ * override the query conditions, e.g. ['shard' => 's1'].
+ * - useGTIDs : use GTID methods like MASTER_GTID_WAIT() when possible.
+ * - sslKeyPath : path to key file [default: null]
+ * - sslCertPath : path to certificate file [default: null]
+ * - sslCAFile: path to a single certificate authority PEM file [default: null]
+ * - sslCAPath : parth to certificate authority PEM directory [default: null]
+ * - sslCiphers : array list of allowable ciphers [default: null]
+ * @param array $params
+ */
+ function __construct( array $params ) {
+ $this->lagDetectionMethod = isset( $params['lagDetectionMethod'] )
+ ? $params['lagDetectionMethod']
+ : 'Seconds_Behind_Master';
+ $this->lagDetectionOptions = isset( $params['lagDetectionOptions'] )
+ ? $params['lagDetectionOptions']
+ : [];
+ $this->useGTIDs = !empty( $params['useGTIDs' ] );
+ foreach ( [ 'KeyPath', 'CertPath', 'CAFile', 'CAPath', 'Ciphers' ] as $name ) {
+ $var = "ssl{$name}";
+ if ( isset( $params[$var] ) ) {
+ $this->$var = $params[$var];
+ }
+ }
+ $this->sqlMode = isset( $params['sqlMode'] ) ? $params['sqlMode'] : '';
+ $this->utf8Mode = !empty( $params['utf8Mode'] );
+
+ parent::__construct( $params );
+ }
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return 'mysql';
+ }
+
+ /**
+ * @param string $server
+ * @param string $user
+ * @param string $password
+ * @param string $dbName
+ * @throws Exception|DBConnectionError
+ * @return bool
+ */
+ public function open( $server, $user, $password, $dbName ) {
+ # Close/unset connection handle
+ $this->close();
+
+ $this->mServer = $server;
+ $this->mUser = $user;
+ $this->mPassword = $password;
+ $this->mDBname = $dbName;
+
+ $this->installErrorHandler();
+ try {
+ $this->mConn = $this->mysqlConnect( $this->mServer );
+ } catch ( Exception $ex ) {
+ $this->restoreErrorHandler();
+ throw $ex;
+ }
+ $error = $this->restoreErrorHandler();
+
+ # Always log connection errors
+ if ( !$this->mConn ) {
+ if ( !$error ) {
+ $error = $this->lastError();
+ }
+ $this->connLogger->error(
+ "Error connecting to {db_server}: {error}",
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ 'error' => $error,
+ ] )
+ );
+ $this->connLogger->debug( "DB connection error\n" .
+ "Server: $server, User: $user, Password: " .
+ substr( $password, 0, 3 ) . "..., error: " . $error . "\n" );
+
+ $this->reportConnectionError( $error );
+ }
+
+ if ( $dbName != '' ) {
+ MediaWiki\suppressWarnings();
+ $success = $this->selectDB( $dbName );
+ MediaWiki\restoreWarnings();
+ if ( !$success ) {
+ $this->queryLogger->error(
+ "Error selecting database {db_name} on server {db_server}",
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ ] )
+ );
+ $this->queryLogger->debug(
+ "Error selecting database $dbName on server {$this->mServer}" );
+
+ $this->reportConnectionError( "Error selecting database $dbName" );
+ }
+ }
+
+ // Tell the server what we're communicating with
+ if ( !$this->connectInitCharset() ) {
+ $this->reportConnectionError( "Error setting character set" );
+ }
+
+ // Abstract over any insane MySQL defaults
+ $set = [ 'group_concat_max_len = 262144' ];
+ // Set SQL mode, default is turning them all off, can be overridden or skipped with null
+ if ( is_string( $this->sqlMode ) ) {
+ $set[] = 'sql_mode = ' . $this->addQuotes( $this->sqlMode );
+ }
+ // Set any custom settings defined by site config
+ // (e.g. https://dev.mysql.com/doc/refman/4.1/en/innodb-parameters.html)
+ foreach ( $this->mSessionVars as $var => $val ) {
+ // Escape strings but not numbers to avoid MySQL complaining
+ if ( !is_int( $val ) && !is_float( $val ) ) {
+ $val = $this->addQuotes( $val );
+ }
+ $set[] = $this->addIdentifierQuotes( $var ) . ' = ' . $val;
+ }
+
+ if ( $set ) {
+ // Use doQuery() to avoid opening implicit transactions (DBO_TRX)
+ $success = $this->doQuery( 'SET ' . implode( ', ', $set ) );
+ if ( !$success ) {
+ $this->queryLogger->error(
+ 'Error setting MySQL variables on server {db_server} (check $wgSQLMode)',
+ $this->getLogContext( [
+ 'method' => __METHOD__,
+ ] )
+ );
+ $this->reportConnectionError(
+ 'Error setting MySQL variables on server {db_server} (check $wgSQLMode)' );
+ }
+ }
+
+ $this->mOpened = true;
+
+ return true;
+ }
+
+ /**
+ * Set the character set information right after connection
+ * @return bool
+ */
+ protected function connectInitCharset() {
+ if ( $this->utf8Mode ) {
+ // Tell the server we're communicating with it in UTF-8.
+ // This may engage various charset conversions.
+ return $this->mysqlSetCharset( 'utf8' );
+ } else {
+ return $this->mysqlSetCharset( 'binary' );
+ }
+ }
+
+ /**
+ * Open a connection to a MySQL server
+ *
+ * @param string $realServer
+ * @return mixed Raw connection
+ * @throws DBConnectionError
+ */
+ abstract protected function mysqlConnect( $realServer );
+
+ /**
+ * Set the character set of the MySQL link
+ *
+ * @param string $charset
+ * @return bool
+ */
+ abstract protected function mysqlSetCharset( $charset );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @throws DBUnexpectedError
+ */
+ public function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $ok = $this->mysqlFreeResult( $res );
+ MediaWiki\restoreWarnings();
+ if ( !$ok ) {
+ throw new DBUnexpectedError( $this, "Unable to free MySQL result" );
+ }
+ }
+
+ /**
+ * Free result memory
+ *
+ * @param resource $res Raw result
+ * @return bool
+ */
+ abstract protected function mysqlFreeResult( $res );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @return stdClass|bool
+ * @throws DBUnexpectedError
+ */
+ public function fetchObject( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $row = $this->mysqlFetchObject( $res );
+ MediaWiki\restoreWarnings();
+
+ $errno = $this->lastErrno();
+ // Unfortunately, mysql_fetch_object does not reset the last errno.
+ // Only check for CR_SERVER_LOST and CR_UNKNOWN_ERROR, as
+ // these are the only errors mysql_fetch_object can cause.
+ // See https://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
+ if ( $errno == 2000 || $errno == 2013 ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'Error in fetchObject(): ' . htmlspecialchars( $this->lastError() )
+ );
+ }
+
+ return $row;
+ }
+
+ /**
+ * Fetch a result row as an object
+ *
+ * @param resource $res Raw result
+ * @return stdClass
+ */
+ abstract protected function mysqlFetchObject( $res );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @return array|bool
+ * @throws DBUnexpectedError
+ */
+ public function fetchRow( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $row = $this->mysqlFetchArray( $res );
+ MediaWiki\restoreWarnings();
+
+ $errno = $this->lastErrno();
+ // Unfortunately, mysql_fetch_array does not reset the last errno.
+ // Only check for CR_SERVER_LOST and CR_UNKNOWN_ERROR, as
+ // these are the only errors mysql_fetch_array can cause.
+ // See https://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
+ if ( $errno == 2000 || $errno == 2013 ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'Error in fetchRow(): ' . htmlspecialchars( $this->lastError() )
+ );
+ }
+
+ return $row;
+ }
+
+ /**
+ * Fetch a result row as an associative and numeric array
+ *
+ * @param resource $res Raw result
+ * @return array
+ */
+ abstract protected function mysqlFetchArray( $res );
+
+ /**
+ * @throws DBUnexpectedError
+ * @param ResultWrapper|resource $res
+ * @return int
+ */
+ function numRows( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $n = $this->mysqlNumRows( $res );
+ MediaWiki\restoreWarnings();
+
+ // Unfortunately, mysql_num_rows does not reset the last errno.
+ // We are not checking for any errors here, since
+ // these are no errors mysql_num_rows can cause.
+ // See https://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
+ // See https://phabricator.wikimedia.org/T44430
+ return $n;
+ }
+
+ /**
+ * Get number of rows in result
+ *
+ * @param resource $res Raw result
+ * @return int
+ */
+ abstract protected function mysqlNumRows( $res );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @return int
+ */
+ public function numFields( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $this->mysqlNumFields( $res );
+ }
+
+ /**
+ * Get number of fields in result
+ *
+ * @param resource $res Raw result
+ * @return int
+ */
+ abstract protected function mysqlNumFields( $res );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @param int $n
+ * @return string
+ */
+ public function fieldName( $res, $n ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $this->mysqlFieldName( $res, $n );
+ }
+
+ /**
+ * Get the name of the specified field in a result
+ *
+ * @param ResultWrapper|resource $res
+ * @param int $n
+ * @return string
+ */
+ abstract protected function mysqlFieldName( $res, $n );
+
+ /**
+ * mysql_field_type() wrapper
+ * @param ResultWrapper|resource $res
+ * @param int $n
+ * @return string
+ */
+ public function fieldType( $res, $n ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $this->mysqlFieldType( $res, $n );
+ }
+
+ /**
+ * Get the type of the specified field in a result
+ *
+ * @param ResultWrapper|resource $res
+ * @param int $n
+ * @return string
+ */
+ abstract protected function mysqlFieldType( $res, $n );
+
+ /**
+ * @param ResultWrapper|resource $res
+ * @param int $row
+ * @return bool
+ */
+ public function dataSeek( $res, $row ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return $this->mysqlDataSeek( $res, $row );
+ }
+
+ /**
+ * Move internal result pointer
+ *
+ * @param ResultWrapper|resource $res
+ * @param int $row
+ * @return bool
+ */
+ abstract protected function mysqlDataSeek( $res, $row );
+
+ /**
+ * @return string
+ */
+ public function lastError() {
+ if ( $this->mConn ) {
+ # Even if it's non-zero, it can still be invalid
+ MediaWiki\suppressWarnings();
+ $error = $this->mysqlError( $this->mConn );
+ if ( !$error ) {
+ $error = $this->mysqlError();
+ }
+ MediaWiki\restoreWarnings();
+ } else {
+ $error = $this->mysqlError();
+ }
+ if ( $error ) {
+ $error .= ' (' . $this->mServer . ')';
+ }
+
+ return $error;
+ }
+
+ /**
+ * Returns the text of the error message from previous MySQL operation
+ *
+ * @param resource $conn Raw connection
+ * @return string
+ */
+ abstract protected function mysqlError( $conn = null );
+
+ /**
+ * @param string $table
+ * @param array $uniqueIndexes
+ * @param array $rows
+ * @param string $fname
+ * @return ResultWrapper
+ */
+ public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+ return $this->nativeReplace( $table, $rows, $fname );
+ }
+
+ /**
+ * Estimate rows in dataset
+ * Returns estimated count, based on EXPLAIN output
+ * Takes same arguments as Database::select()
+ *
+ * @param string|array $table
+ * @param string|array $vars
+ * @param string|array $conds
+ * @param string $fname
+ * @param string|array $options
+ * @return bool|int
+ */
+ public function estimateRowCount( $table, $vars = '*', $conds = '',
+ $fname = __METHOD__, $options = []
+ ) {
+ $options['EXPLAIN'] = true;
+ $res = $this->select( $table, $vars, $conds, $fname, $options );
+ if ( $res === false ) {
+ return false;
+ }
+ if ( !$this->numRows( $res ) ) {
+ return 0;
+ }
+
+ $rows = 1;
+ foreach ( $res as $plan ) {
+ $rows *= $plan->rows > 0 ? $plan->rows : 1; // avoid resetting to zero
+ }
+
+ return (int)$rows;
+ }
+
+ public function tableExists( $table, $fname = __METHOD__ ) {
+ // Split database and table into proper variables as Database::tableName() returns
+ // shared tables prefixed with their database, which do not work in SHOW TABLES statements
+ list( $database, , $prefix, $table ) = $this->qualifiedTableComponents( $table );
+ $tableName = "{$prefix}{$table}";
+
+ if ( isset( $this->mSessionTempTables[$tableName] ) ) {
+ return true; // already known to exist and won't show in SHOW TABLES anyway
+ }
+
+ // We can't use buildLike() here, because it specifies an escape character
+ // other than the backslash, which is the only one supported by SHOW TABLES
+ $encLike = $this->escapeLikeInternal( $tableName, '\\' );
+
+ // If the database has been specified (such as for shared tables), use "FROM"
+ if ( $database !== '' ) {
+ $encDatabase = $this->addIdentifierQuotes( $database );
+ $query = "SHOW TABLES FROM $encDatabase LIKE '$encLike'";
+ } else {
+ $query = "SHOW TABLES LIKE '$encLike'";
+ }
+
+ return $this->query( $query, $fname )->numRows() > 0;
+ }
+
+ /**
+ * @param string $table
+ * @param string $field
+ * @return bool|MySQLField
+ */
+ public function fieldInfo( $table, $field ) {
+ $table = $this->tableName( $table );
+ $res = $this->query( "SELECT * FROM $table LIMIT 1", __METHOD__, true );
+ if ( !$res ) {
+ return false;
+ }
+ $n = $this->mysqlNumFields( $res->result );
+ for ( $i = 0; $i < $n; $i++ ) {
+ $meta = $this->mysqlFetchField( $res->result, $i );
+ if ( $field == $meta->name ) {
+ return new MySQLField( $meta );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get column information from a result
+ *
+ * @param resource $res Raw result
+ * @param int $n
+ * @return stdClass
+ */
+ abstract protected function mysqlFetchField( $res, $n );
+
+ /**
+ * Get information about an index into an object
+ * Returns false if the index does not exist
+ *
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool|array|null False or null on failure
+ */
+ public function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ # SHOW INDEX works in MySQL 3.23.58, but SHOW INDEXES does not.
+ # SHOW INDEX should work for 3.x and up:
+ # https://dev.mysql.com/doc/mysql/en/SHOW_INDEX.html
+ $table = $this->tableName( $table );
+ $index = $this->indexName( $index );
+
+ $sql = 'SHOW INDEX FROM ' . $table;
+ $res = $this->query( $sql, $fname );
+
+ if ( !$res ) {
+ return null;
+ }
+
+ $result = [];
+
+ foreach ( $res as $row ) {
+ if ( $row->Key_name == $index ) {
+ $result[] = $row;
+ }
+ }
+
+ return empty( $result ) ? false : $result;
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ public function strencode( $s ) {
+ return $this->mysqlRealEscapeString( $s );
+ }
+
+ /**
+ * @param string $s
+ * @return mixed
+ */
+ abstract protected function mysqlRealEscapeString( $s );
+
+ public function addQuotes( $s ) {
+ if ( is_bool( $s ) ) {
+ // Parent would transform to int, which does not play nice with MySQL type juggling.
+ // When searching for an int in a string column, the strings are cast to int, which
+ // means false would match any string not starting with a number.
+ $s = (string)(int)$s;
+ }
+ return parent::addQuotes( $s );
+ }
+
+ /**
+ * MySQL uses `backticks` for identifier quoting instead of the sql standard "double quotes".
+ *
+ * @param string $s
+ * @return string
+ */
+ public function addIdentifierQuotes( $s ) {
+ // Characters in the range \u0001-\uFFFF are valid in a quoted identifier
+ // Remove NUL bytes and escape backticks by doubling
+ return '`' . str_replace( [ "\0", '`' ], [ '', '``' ], $s ) . '`';
+ }
+
+ /**
+ * @param string $name
+ * @return bool
+ */
+ public function isQuotedIdentifier( $name ) {
+ return strlen( $name ) && $name[0] == '`' && substr( $name, -1, 1 ) == '`';
+ }
+
+ public function getLag() {
+ if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
+ return $this->getLagFromPtHeartbeat();
+ } else {
+ return $this->getLagFromSlaveStatus();
+ }
+ }
+
+ /**
+ * @return string
+ */
+ protected function getLagDetectionMethod() {
+ return $this->lagDetectionMethod;
+ }
+
+ /**
+ * @return bool|int
+ */
+ protected function getLagFromSlaveStatus() {
+ $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ );
+ $row = $res ? $res->fetchObject() : false;
+ if ( $row && strval( $row->Seconds_Behind_Master ) !== '' ) {
+ return intval( $row->Seconds_Behind_Master );
+ }
+
+ return false;
+ }
+
+ /**
+ * @return bool|float
+ */
+ protected function getLagFromPtHeartbeat() {
+ $options = $this->lagDetectionOptions;
+
+ if ( isset( $options['conds'] ) ) {
+ // Best method for multi-DC setups: use logical channel names
+ $data = $this->getHeartbeatData( $options['conds'] );
+ } else {
+ // Standard method: use master server ID (works with stock pt-heartbeat)
+ $masterInfo = $this->getMasterServerInfo();
+ if ( !$masterInfo ) {
+ $this->queryLogger->error(
+ "Unable to query master of {db_server} for server ID",
+ $this->getLogContext( [
+ 'method' => __METHOD__
+ ] )
+ );
+
+ return false; // could not get master server ID
+ }
+
+ $conds = [ 'server_id' => intval( $masterInfo['serverId'] ) ];
+ $data = $this->getHeartbeatData( $conds );
+ }
+
+ list( $time, $nowUnix ) = $data;
+ if ( $time !== null ) {
+ // @time is in ISO format like "2015-09-25T16:48:10.000510"
+ $dateTime = new DateTime( $time, new DateTimeZone( 'UTC' ) );
+ $timeUnix = (int)$dateTime->format( 'U' ) + $dateTime->format( 'u' ) / 1e6;
+
+ return max( $nowUnix - $timeUnix, 0.0 );
+ }
+
+ $this->queryLogger->error(
+ "Unable to find pt-heartbeat row for {db_server}",
+ $this->getLogContext( [
+ 'method' => __METHOD__
+ ] )
+ );
+
+ return false;
+ }
+
+ protected function getMasterServerInfo() {
+ $cache = $this->srvCache;
+ $key = $cache->makeGlobalKey(
+ 'mysql',
+ 'master-info',
+ // Using one key for all cluster replica DBs is preferable
+ $this->getLBInfo( 'clusterMasterHost' ) ?: $this->getServer()
+ );
+
+ return $cache->getWithSetCallback(
+ $key,
+ $cache::TTL_INDEFINITE,
+ function () use ( $cache, $key ) {
+ // Get and leave a lock key in place for a short period
+ if ( !$cache->lock( $key, 0, 10 ) ) {
+ return false; // avoid master connection spike slams
+ }
+
+ $conn = $this->getLazyMasterHandle();
+ if ( !$conn ) {
+ return false; // something is misconfigured
+ }
+
+ // Connect to and query the master; catch errors to avoid outages
+ try {
+ $res = $conn->query( 'SELECT @@server_id AS id', __METHOD__ );
+ $row = $res ? $res->fetchObject() : false;
+ $id = $row ? (int)$row->id : 0;
+ } catch ( DBError $e ) {
+ $id = 0;
+ }
+
+ // Cache the ID if it was retrieved
+ return $id ? [ 'serverId' => $id, 'asOf' => time() ] : false;
+ }
+ );
+ }
+
+ /**
+ * @param array $conds WHERE clause conditions to find a row
+ * @return array (heartbeat `ts` column value or null, UNIX timestamp) for the newest beat
+ * @see https://www.percona.com/doc/percona-toolkit/2.1/pt-heartbeat.html
+ */
+ protected function getHeartbeatData( array $conds ) {
+ // Query time and trip time are not counted
+ $nowUnix = microtime( true );
+ // Do not bother starting implicit transactions here
+ $this->clearFlag( self::DBO_TRX, self::REMEMBER_PRIOR );
+ try {
+ $whereSQL = $this->makeList( $conds, self::LIST_AND );
+ // Use ORDER BY for channel based queries since that field might not be UNIQUE.
+ // Note: this would use "TIMESTAMPDIFF(MICROSECOND,ts,UTC_TIMESTAMP(6))" but the
+ // percision field is not supported in MySQL <= 5.5.
+ $res = $this->query(
+ "SELECT ts FROM heartbeat.heartbeat WHERE $whereSQL ORDER BY ts DESC LIMIT 1"
+ );
+ $row = $res ? $res->fetchObject() : false;
+ } finally {
+ $this->restoreFlags();
+ }
+
+ return [ $row ? $row->ts : null, $nowUnix ];
+ }
+
+ protected function getApproximateLagStatus() {
+ if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
+ // Disable caching since this is fast enough and we don't wan't
+ // to be *too* pessimistic by having both the cache TTL and the
+ // pt-heartbeat interval count as lag in getSessionLagStatus()
+ return parent::getApproximateLagStatus();
+ }
+
+ $key = $this->srvCache->makeGlobalKey( 'mysql-lag', $this->getServer() );
+ $approxLag = $this->srvCache->get( $key );
+ if ( !$approxLag ) {
+ $approxLag = parent::getApproximateLagStatus();
+ $this->srvCache->set( $key, $approxLag, 1 );
+ }
+
+ return $approxLag;
+ }
+
+ public function masterPosWait( DBMasterPos $pos, $timeout ) {
+ if ( !( $pos instanceof MySQLMasterPos ) ) {
+ throw new InvalidArgumentException( "Position not an instance of MySQLMasterPos" );
+ }
+
+ if ( $this->getLBInfo( 'is static' ) === true ) {
+ return 0; // this is a copy of a read-only dataset with no master DB
+ } elseif ( $this->lastKnownReplicaPos && $this->lastKnownReplicaPos->hasReached( $pos ) ) {
+ return 0; // already reached this point for sure
+ }
+
+ // Call doQuery() directly, to avoid opening a transaction if DBO_TRX is set
+ if ( $this->useGTIDs && $pos->gtids ) {
+ // Wait on the GTID set (MariaDB only)
+ $gtidArg = $this->addQuotes( implode( ',', $pos->gtids ) );
+ $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
+ } else {
+ // Wait on the binlog coordinates
+ $encFile = $this->addQuotes( $pos->file );
+ $encPos = intval( $pos->pos );
+ $res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" );
+ }
+
+ $row = $res ? $this->fetchRow( $res ) : false;
+ if ( !$row ) {
+ throw new DBExpectedError( $this,
+ "MASTER_POS_WAIT() or MASTER_GTID_WAIT() failed: {$this->lastError()}" );
+ }
+
+ // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
+ $status = ( $row[0] !== null ) ? intval( $row[0] ) : null;
+ if ( $status === null ) {
+ // T126436: jobs programmed to wait on master positions might be referencing binlogs
+ // with an old master hostname. Such calls make MASTER_POS_WAIT() return null. Try
+ // to detect this and treat the replica DB as having reached the position; a proper master
+ // switchover already requires that the new master be caught up before the switch.
+ $replicationPos = $this->getReplicaPos();
+ if ( $replicationPos && !$replicationPos->channelsMatch( $pos ) ) {
+ $this->lastKnownReplicaPos = $replicationPos;
+ $status = 0;
+ }
+ } elseif ( $status >= 0 ) {
+ // Remember that this position was reached to save queries next time
+ $this->lastKnownReplicaPos = $pos;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Get the position of the master from SHOW SLAVE STATUS
+ *
+ * @return MySQLMasterPos|bool
+ */
+ public function getReplicaPos() {
+ $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ );
+ $row = $this->fetchObject( $res );
+
+ if ( $row ) {
+ $pos = isset( $row->Exec_master_log_pos )
+ ? $row->Exec_master_log_pos
+ : $row->Exec_Master_Log_Pos;
+ // Also fetch the last-applied GTID set (MariaDB)
+ if ( $this->useGTIDs ) {
+ $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_slave_pos'", __METHOD__ );
+ $gtidRow = $this->fetchObject( $res );
+ $gtidSet = $gtidRow ? $gtidRow->Value : '';
+ } else {
+ $gtidSet = '';
+ }
+
+ return new MySQLMasterPos( $row->Relay_Master_Log_File, $pos, $gtidSet );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get the position of the master from SHOW MASTER STATUS
+ *
+ * @return MySQLMasterPos|bool
+ */
+ public function getMasterPos() {
+ $res = $this->query( 'SHOW MASTER STATUS', __METHOD__ );
+ $row = $this->fetchObject( $res );
+
+ if ( $row ) {
+ // Also fetch the last-written GTID set (MariaDB)
+ if ( $this->useGTIDs ) {
+ $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_binlog_pos'", __METHOD__ );
+ $gtidRow = $this->fetchObject( $res );
+ $gtidSet = $gtidRow ? $gtidRow->Value : '';
+ } else {
+ $gtidSet = '';
+ }
+
+ return new MySQLMasterPos( $row->File, $row->Position, $gtidSet );
+ } else {
+ return false;
+ }
+ }
+
+ public function serverIsReadOnly() {
+ $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'read_only'", __METHOD__ );
+ $row = $this->fetchObject( $res );
+
+ return $row ? ( strtolower( $row->Value ) === 'on' ) : false;
+ }
+
+ /**
+ * @param string $index
+ * @return string
+ */
+ function useIndexClause( $index ) {
+ return "FORCE INDEX (" . $this->indexName( $index ) . ")";
+ }
+
+ /**
+ * @param string $index
+ * @return string
+ */
+ function ignoreIndexClause( $index ) {
+ return "IGNORE INDEX (" . $this->indexName( $index ) . ")";
+ }
+
+ /**
+ * @return string
+ */
+ function lowPriorityOption() {
+ return 'LOW_PRIORITY';
+ }
+
+ /**
+ * @return string
+ */
+ public function getSoftwareLink() {
+ // MariaDB includes its name in its version string; this is how MariaDB's version of
+ // the mysql command-line client identifies MariaDB servers (see mariadb_connection()
+ // in libmysql/libmysql.c).
+ $version = $this->getServerVersion();
+ if ( strpos( $version, 'MariaDB' ) !== false || strpos( $version, '-maria-' ) !== false ) {
+ return '[{{int:version-db-mariadb-url}} MariaDB]';
+ }
+
+ // Percona Server's version suffix is not very distinctive, and @@version_comment
+ // doesn't give the necessary info for source builds, so assume the server is MySQL.
+ // (Even Percona's version of mysql doesn't try to make the distinction.)
+ return '[{{int:version-db-mysql-url}} MySQL]';
+ }
+
+ /**
+ * @return string
+ */
+ public function getServerVersion() {
+ // Not using mysql_get_server_info() or similar for consistency: in the handshake,
+ // MariaDB 10 adds the prefix "5.5.5-", and only some newer client libraries strip
+ // it off (see RPL_VERSION_HACK in include/mysql_com.h).
+ if ( $this->serverVersion === null ) {
+ $this->serverVersion = $this->selectField( '', 'VERSION()', '', __METHOD__ );
+ }
+ return $this->serverVersion;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function setSessionOptions( array $options ) {
+ if ( isset( $options['connTimeout'] ) ) {
+ $timeout = (int)$options['connTimeout'];
+ $this->query( "SET net_read_timeout=$timeout" );
+ $this->query( "SET net_write_timeout=$timeout" );
+ }
+ }
+
+ /**
+ * @param string &$sql
+ * @param string &$newLine
+ * @return bool
+ */
+ public function streamStatementEnd( &$sql, &$newLine ) {
+ if ( strtoupper( substr( $newLine, 0, 9 ) ) == 'DELIMITER' ) {
+ preg_match( '/^DELIMITER\s+(\S+)/', $newLine, $m );
+ $this->delimiter = $m[1];
+ $newLine = '';
+ }
+
+ return parent::streamStatementEnd( $sql, $newLine );
+ }
+
+ /**
+ * Check to see if a named lock is available. This is non-blocking.
+ *
+ * @param string $lockName Name of lock to poll
+ * @param string $method Name of method calling us
+ * @return bool
+ * @since 1.20
+ */
+ public function lockIsFree( $lockName, $method ) {
+ $encName = $this->addQuotes( $this->makeLockName( $lockName ) );
+ $result = $this->query( "SELECT IS_FREE_LOCK($encName) AS lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ return ( $row->lockstatus == 1 );
+ }
+
+ /**
+ * @param string $lockName
+ * @param string $method
+ * @param int $timeout
+ * @return bool
+ */
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ $encName = $this->addQuotes( $this->makeLockName( $lockName ) );
+ $result = $this->query( "SELECT GET_LOCK($encName, $timeout) AS lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ if ( $row->lockstatus == 1 ) {
+ parent::lock( $lockName, $method, $timeout ); // record
+ return true;
+ }
+
+ $this->queryLogger->warning( __METHOD__ . " failed to acquire lock '$lockName'\n" );
+
+ return false;
+ }
+
+ /**
+ * FROM MYSQL DOCS:
+ * https://dev.mysql.com/doc/refman/5.0/en/miscellaneous-functions.html#function_release-lock
+ * @param string $lockName
+ * @param string $method
+ * @return bool
+ */
+ public function unlock( $lockName, $method ) {
+ $encName = $this->addQuotes( $this->makeLockName( $lockName ) );
+ $result = $this->query( "SELECT RELEASE_LOCK($encName) as lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ if ( $row->lockstatus == 1 ) {
+ parent::unlock( $lockName, $method ); // record
+ return true;
+ }
+
+ $this->queryLogger->warning( __METHOD__ . " failed to release lock '$lockName'\n" );
+
+ return false;
+ }
+
+ private function makeLockName( $lockName ) {
+ // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
+ // Newer version enforce a 64 char length limit.
+ return ( strlen( $lockName ) > 64 ) ? sha1( $lockName ) : $lockName;
+ }
+
+ public function namedLocksEnqueue() {
+ return true;
+ }
+
+ public function tableLocksHaveTransactionScope() {
+ return false; // tied to TCP connection
+ }
+
+ protected function doLockTables( array $read, array $write, $method ) {
+ $items = [];
+ foreach ( $write as $table ) {
+ $items[] = $this->tableName( $table ) . ' WRITE';
+ }
+ foreach ( $read as $table ) {
+ $items[] = $this->tableName( $table ) . ' READ';
+ }
+
+ $sql = "LOCK TABLES " . implode( ',', $items );
+ $this->query( $sql, $method );
+
+ return true;
+ }
+
+ protected function doUnlockTables( $method ) {
+ $this->query( "UNLOCK TABLES", $method );
+
+ return true;
+ }
+
+ /**
+ * @param bool $value
+ */
+ public function setBigSelects( $value = true ) {
+ if ( $value === 'default' ) {
+ if ( $this->mDefaultBigSelects === null ) {
+ # Function hasn't been called before so it must already be set to the default
+ return;
+ } else {
+ $value = $this->mDefaultBigSelects;
+ }
+ } elseif ( $this->mDefaultBigSelects === null ) {
+ $this->mDefaultBigSelects =
+ (bool)$this->selectField( false, '@@sql_big_selects', '', __METHOD__ );
+ }
+ $encValue = $value ? '1' : '0';
+ $this->query( "SET sql_big_selects=$encValue", __METHOD__ );
+ }
+
+ /**
+ * DELETE where the condition is a join. MySql uses multi-table deletes.
+ * @param string $delTable
+ * @param string $joinTable
+ * @param string $delVar
+ * @param string $joinVar
+ * @param array|string $conds
+ * @param bool|string $fname
+ * @throws DBUnexpectedError
+ * @return bool|ResultWrapper
+ */
+ public function deleteJoin(
+ $delTable, $joinTable, $delVar, $joinVar, $conds, $fname = __METHOD__
+ ) {
+ if ( !$conds ) {
+ throw new DBUnexpectedError( $this, __METHOD__ . ' called with empty $conds' );
+ }
+
+ $delTable = $this->tableName( $delTable );
+ $joinTable = $this->tableName( $joinTable );
+ $sql = "DELETE $delTable FROM $delTable, $joinTable WHERE $delVar=$joinVar ";
+
+ if ( $conds != '*' ) {
+ $sql .= ' AND ' . $this->makeList( $conds, self::LIST_AND );
+ }
+
+ return $this->query( $sql, $fname );
+ }
+
+ /**
+ * @param string $table
+ * @param array $rows
+ * @param array $uniqueIndexes
+ * @param array $set
+ * @param string $fname
+ * @return bool
+ */
+ public function upsert( $table, array $rows, array $uniqueIndexes,
+ array $set, $fname = __METHOD__
+ ) {
+ if ( !count( $rows ) ) {
+ return true; // nothing to do
+ }
+
+ if ( !is_array( reset( $rows ) ) ) {
+ $rows = [ $rows ];
+ }
+
+ $table = $this->tableName( $table );
+ $columns = array_keys( $rows[0] );
+
+ $sql = "INSERT INTO $table (" . implode( ',', $columns ) . ') VALUES ';
+ $rowTuples = [];
+ foreach ( $rows as $row ) {
+ $rowTuples[] = '(' . $this->makeList( $row ) . ')';
+ }
+ $sql .= implode( ',', $rowTuples );
+ $sql .= " ON DUPLICATE KEY UPDATE " . $this->makeList( $set, self::LIST_SET );
+
+ return (bool)$this->query( $sql, $fname );
+ }
+
+ /**
+ * Determines how long the server has been up
+ *
+ * @return int
+ */
+ public function getServerUptime() {
+ $vars = $this->getMysqlStatus( 'Uptime' );
+
+ return (int)$vars['Uptime'];
+ }
+
+ /**
+ * Determines if the last failure was due to a deadlock
+ *
+ * @return bool
+ */
+ public function wasDeadlock() {
+ return $this->lastErrno() == 1213;
+ }
+
+ /**
+ * Determines if the last failure was due to a lock timeout
+ *
+ * @return bool
+ */
+ public function wasLockTimeout() {
+ return $this->lastErrno() == 1205;
+ }
+
+ public function wasErrorReissuable() {
+ return $this->lastErrno() == 2013 || $this->lastErrno() == 2006;
+ }
+
+ /**
+ * Determines if the last failure was due to the database being read-only.
+ *
+ * @return bool
+ */
+ public function wasReadOnlyError() {
+ return $this->lastErrno() == 1223 ||
+ ( $this->lastErrno() == 1290 && strpos( $this->lastError(), '--read-only' ) !== false );
+ }
+
+ public function wasConnectionError( $errno ) {
+ return $errno == 2013 || $errno == 2006;
+ }
+
+ /**
+ * @param string $oldName
+ * @param string $newName
+ * @param bool $temporary
+ * @param string $fname
+ * @return bool
+ */
+ public function duplicateTableStructure(
+ $oldName, $newName, $temporary = false, $fname = __METHOD__
+ ) {
+ $tmp = $temporary ? 'TEMPORARY ' : '';
+ $newName = $this->addIdentifierQuotes( $newName );
+ $oldName = $this->addIdentifierQuotes( $oldName );
+ $query = "CREATE $tmp TABLE $newName (LIKE $oldName)";
+
+ return $this->query( $query, $fname );
+ }
+
+ /**
+ * List all tables on the database
+ *
+ * @param string $prefix Only show tables with this prefix, e.g. mw_
+ * @param string $fname Calling function name
+ * @return array
+ */
+ public function listTables( $prefix = null, $fname = __METHOD__ ) {
+ $result = $this->query( "SHOW TABLES", $fname );
+
+ $endArray = [];
+
+ foreach ( $result as $table ) {
+ $vars = get_object_vars( $table );
+ $table = array_pop( $vars );
+
+ if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
+ $endArray[] = $table;
+ }
+ }
+
+ return $endArray;
+ }
+
+ /**
+ * @param string $tableName
+ * @param string $fName
+ * @return bool|ResultWrapper
+ */
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ if ( !$this->tableExists( $tableName, $fName ) ) {
+ return false;
+ }
+
+ return $this->query( "DROP TABLE IF EXISTS " . $this->tableName( $tableName ), $fName );
+ }
+
+ /**
+ * Get status information from SHOW STATUS in an associative array
+ *
+ * @param string $which
+ * @return array
+ */
+ private function getMysqlStatus( $which = "%" ) {
+ $res = $this->query( "SHOW STATUS LIKE '{$which}'" );
+ $status = [];
+
+ foreach ( $res as $row ) {
+ $status[$row->Variable_name] = $row->Value;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Lists VIEWs in the database
+ *
+ * @param string $prefix Only show VIEWs with this prefix, eg.
+ * unit_test_, or $wgDBprefix. Default: null, would return all views.
+ * @param string $fname Name of calling function
+ * @return array
+ * @since 1.22
+ */
+ public function listViews( $prefix = null, $fname = __METHOD__ ) {
+ // The name of the column containing the name of the VIEW
+ $propertyName = 'Tables_in_' . $this->mDBname;
+
+ // Query for the VIEWS
+ $res = $this->query( 'SHOW FULL TABLES WHERE TABLE_TYPE = "VIEW"' );
+ $allViews = [];
+ foreach ( $res as $row ) {
+ array_push( $allViews, $row->$propertyName );
+ }
+
+ if ( is_null( $prefix ) || $prefix === '' ) {
+ return $allViews;
+ }
+
+ $filteredViews = [];
+ foreach ( $allViews as $viewName ) {
+ // Does the name of this VIEW start with the table-prefix?
+ if ( strpos( $viewName, $prefix ) === 0 ) {
+ array_push( $filteredViews, $viewName );
+ }
+ }
+
+ return $filteredViews;
+ }
+
+ /**
+ * Differentiates between a TABLE and a VIEW.
+ *
+ * @param string $name Name of the TABLE/VIEW to test
+ * @param string $prefix
+ * @return bool
+ * @since 1.22
+ */
+ public function isView( $name, $prefix = null ) {
+ return in_array( $name, $this->listViews( $prefix ) );
+ }
+
+ /**
+ * Allows for index remapping in queries where this is not consistent across DBMS
+ *
+ * @param string $index
+ * @return string
+ */
+ protected function indexName( $index ) {
+ /**
+ * When SQLite indexes were introduced in r45764, it was noted that
+ * SQLite requires index names to be unique within the whole database,
+ * not just within a schema. As discussed in CR r45819, to avoid the
+ * need for a schema change on existing installations, the indexes
+ * were implicitly mapped from the new names to the old names.
+ *
+ * This mapping can be removed if DB patches are introduced to alter
+ * the relevant tables in existing installations. Note that because
+ * this index mapping applies to table creation, even new installations
+ * of MySQL have the old names (except for installations created during
+ * a period where this mapping was inappropriately removed, see
+ * T154872).
+ */
+ $renamed = [
+ 'ar_usertext_timestamp' => 'usertext_timestamp',
+ 'un_user_id' => 'user_id',
+ 'un_user_ip' => 'user_ip',
+ ];
+
+ if ( isset( $renamed[$index] ) ) {
+ return $renamed[$index];
+ } else {
+ return $index;
+ }
+ }
+}
+
+class_alias( DatabaseMysqlBase::class, 'DatabaseMysqlBase' );
diff --git a/www/wiki/includes/libs/rdbms/database/DatabaseMysqli.php b/www/wiki/includes/libs/rdbms/database/DatabaseMysqli.php
new file mode 100644
index 00000000..c1a56988
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/DatabaseMysqli.php
@@ -0,0 +1,338 @@
+<?php
+/**
+ * This is the MySQLi database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use mysqli;
+use mysqli_result;
+use IP;
+
+/**
+ * Database abstraction object for PHP extension mysqli.
+ *
+ * @ingroup Database
+ * @since 1.22
+ * @see Database
+ */
+class DatabaseMysqli extends DatabaseMysqlBase {
+ /** @var mysqli $mConn */
+
+ /**
+ * @param string $sql
+ * @return resource
+ */
+ protected function doQuery( $sql ) {
+ $conn = $this->getBindingHandle();
+
+ if ( $this->bufferResults() ) {
+ $ret = $conn->query( $sql );
+ } else {
+ $ret = $conn->query( $sql, MYSQLI_USE_RESULT );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $realServer
+ * @return bool|mysqli
+ * @throws DBConnectionError
+ */
+ protected function mysqlConnect( $realServer ) {
+ # Avoid suppressed fatal error, which is very hard to track down
+ if ( !function_exists( 'mysqli_init' ) ) {
+ throw new DBConnectionError( $this, "MySQLi functions missing,"
+ . " have you compiled PHP with the --with-mysqli option?\n" );
+ }
+
+ // Other than mysql_connect, mysqli_real_connect expects an explicit port
+ // and socket parameters. So we need to parse the port and socket out of
+ // $realServer
+ $port = null;
+ $socket = null;
+ $hostAndPort = IP::splitHostAndPort( $realServer );
+ if ( $hostAndPort ) {
+ $realServer = $hostAndPort[0];
+ if ( $hostAndPort[1] ) {
+ $port = $hostAndPort[1];
+ }
+ } elseif ( substr_count( $realServer, ':' ) == 1 ) {
+ // If we have a colon and something that's not a port number
+ // inside the hostname, assume it's the socket location
+ $hostAndSocket = explode( ':', $realServer );
+ $realServer = $hostAndSocket[0];
+ $socket = $hostAndSocket[1];
+ }
+
+ $mysqli = mysqli_init();
+
+ $connFlags = 0;
+ if ( $this->mFlags & self::DBO_SSL ) {
+ $connFlags |= MYSQLI_CLIENT_SSL;
+ $mysqli->ssl_set(
+ $this->sslKeyPath,
+ $this->sslCertPath,
+ $this->sslCAFile,
+ $this->sslCAPath,
+ $this->sslCiphers
+ );
+ }
+ if ( $this->mFlags & self::DBO_COMPRESS ) {
+ $connFlags |= MYSQLI_CLIENT_COMPRESS;
+ }
+ if ( $this->mFlags & self::DBO_PERSISTENT ) {
+ $realServer = 'p:' . $realServer;
+ }
+
+ if ( $this->utf8Mode ) {
+ // Tell the server we're communicating with it in UTF-8.
+ // This may engage various charset conversions.
+ $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'utf8' );
+ } else {
+ $mysqli->options( MYSQLI_SET_CHARSET_NAME, 'binary' );
+ }
+ $mysqli->options( MYSQLI_OPT_CONNECT_TIMEOUT, 3 );
+
+ if ( $mysqli->real_connect( $realServer, $this->mUser,
+ $this->mPassword, $this->mDBname, $port, $socket, $connFlags )
+ ) {
+ return $mysqli;
+ }
+
+ return false;
+ }
+
+ protected function connectInitCharset() {
+ // already done in mysqlConnect()
+ return true;
+ }
+
+ /**
+ * @param string $charset
+ * @return bool
+ */
+ protected function mysqlSetCharset( $charset ) {
+ $conn = $this->getBindingHandle();
+
+ if ( method_exists( $conn, 'set_charset' ) ) {
+ return $conn->set_charset( $charset );
+ } else {
+ return $this->query( 'SET NAMES ' . $charset, __METHOD__ );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ protected function closeConnection() {
+ $conn = $this->getBindingHandle();
+
+ return $conn->close();
+ }
+
+ /**
+ * @return int
+ */
+ function insertId() {
+ $conn = $this->getBindingHandle();
+
+ return (int)$conn->insert_id;
+ }
+
+ /**
+ * @return int
+ */
+ function lastErrno() {
+ if ( $this->mConn ) {
+ return $this->mConn->errno;
+ } else {
+ return mysqli_connect_errno();
+ }
+ }
+
+ /**
+ * @return int
+ */
+ function affectedRows() {
+ $conn = $this->getBindingHandle();
+
+ return $conn->affected_rows;
+ }
+
+ /**
+ * @param string $db
+ * @return bool
+ */
+ function selectDB( $db ) {
+ $conn = $this->getBindingHandle();
+
+ $this->mDBname = $db;
+
+ return $conn->select_db( $db );
+ }
+
+ /**
+ * @param mysqli_result $res
+ * @return bool
+ */
+ protected function mysqlFreeResult( $res ) {
+ $res->free_result();
+
+ return true;
+ }
+
+ /**
+ * @param mysqli_result $res
+ * @return bool
+ */
+ protected function mysqlFetchObject( $res ) {
+ $object = $res->fetch_object();
+ if ( $object === null ) {
+ return false;
+ }
+
+ return $object;
+ }
+
+ /**
+ * @param mysqli_result $res
+ * @return bool
+ */
+ protected function mysqlFetchArray( $res ) {
+ $array = $res->fetch_array();
+ if ( $array === null ) {
+ return false;
+ }
+
+ return $array;
+ }
+
+ /**
+ * @param mysqli_result $res
+ * @return mixed
+ */
+ protected function mysqlNumRows( $res ) {
+ return $res->num_rows;
+ }
+
+ /**
+ * @param mysqli $res
+ * @return mixed
+ */
+ protected function mysqlNumFields( $res ) {
+ return $res->field_count;
+ }
+
+ /**
+ * @param mysqli $res
+ * @param int $n
+ * @return mixed
+ */
+ protected function mysqlFetchField( $res, $n ) {
+ $field = $res->fetch_field_direct( $n );
+
+ // Add missing properties to result (using flags property)
+ // which will be part of function mysql-fetch-field for backward compatibility
+ $field->not_null = $field->flags & MYSQLI_NOT_NULL_FLAG;
+ $field->primary_key = $field->flags & MYSQLI_PRI_KEY_FLAG;
+ $field->unique_key = $field->flags & MYSQLI_UNIQUE_KEY_FLAG;
+ $field->multiple_key = $field->flags & MYSQLI_MULTIPLE_KEY_FLAG;
+ $field->binary = $field->flags & MYSQLI_BINARY_FLAG;
+ $field->numeric = $field->flags & MYSQLI_NUM_FLAG;
+ $field->blob = $field->flags & MYSQLI_BLOB_FLAG;
+ $field->unsigned = $field->flags & MYSQLI_UNSIGNED_FLAG;
+ $field->zerofill = $field->flags & MYSQLI_ZEROFILL_FLAG;
+
+ return $field;
+ }
+
+ /**
+ * @param mysqli $res
+ * @param int $n
+ * @return mixed
+ */
+ protected function mysqlFieldName( $res, $n ) {
+ $field = $res->fetch_field_direct( $n );
+
+ return $field->name;
+ }
+
+ /**
+ * @param mysqli $res
+ * @param int $n
+ * @return mixed
+ */
+ protected function mysqlFieldType( $res, $n ) {
+ $field = $res->fetch_field_direct( $n );
+
+ return $field->type;
+ }
+
+ /**
+ * @param mysqli_result $res
+ * @param int $row
+ * @return mixed
+ */
+ protected function mysqlDataSeek( $res, $row ) {
+ return $res->data_seek( $row );
+ }
+
+ /**
+ * @param mysqli $conn Optional connection object
+ * @return string
+ */
+ protected function mysqlError( $conn = null ) {
+ if ( $conn === null ) {
+ return mysqli_connect_error();
+ } else {
+ return $conn->error;
+ }
+ }
+
+ /**
+ * Escapes special characters in a string for use in an SQL statement
+ * @param string $s
+ * @return string
+ */
+ protected function mysqlRealEscapeString( $s ) {
+ $conn = $this->getBindingHandle();
+
+ return $conn->real_escape_string( (string)$s );
+ }
+
+ /**
+ * Give an id for the connection
+ *
+ * mysql driver used resource id, but mysqli objects cannot be cast to string.
+ * @return string
+ */
+ public function __toString() {
+ if ( $this->mConn instanceof mysqli ) {
+ return (string)$this->mConn->thread_id;
+ } else {
+ // mConn might be false or something.
+ return (string)$this->mConn;
+ }
+ }
+}
+
+class_alias( DatabaseMysqli::class, 'DatabaseMysqli' );
diff --git a/www/wiki/includes/libs/rdbms/database/DatabasePostgres.php b/www/wiki/includes/libs/rdbms/database/DatabasePostgres.php
new file mode 100644
index 00000000..ac509088
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/DatabasePostgres.php
@@ -0,0 +1,1400 @@
+<?php
+/**
+ * This is the Postgres database abstraction layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+use Wikimedia\WaitConditionLoop;
+use MediaWiki;
+use Exception;
+
+/**
+ * @ingroup Database
+ */
+class DatabasePostgres extends Database {
+ /** @var int|bool */
+ protected $port;
+
+ /** @var resource */
+ protected $mLastResult = null;
+ /** @var int The number of rows affected as an integer */
+ protected $mAffectedRows = null;
+
+ /** @var float|string */
+ private $numericVersion = null;
+ /** @var string Connect string to open a PostgreSQL connection */
+ private $connectString;
+ /** @var string */
+ private $mCoreSchema;
+ /** @var string[] Map of (reserved table name => alternate table name) */
+ private $keywordTableMap = [];
+
+ /**
+ * @see Database::__construct()
+ * @param array $params Additional parameters include:
+ * - keywordTableMap : Map of reserved table names to alternative table names to use
+ */
+ public function __construct( array $params ) {
+ $this->port = isset( $params['port'] ) ? $params['port'] : false;
+ $this->keywordTableMap = isset( $params['keywordTableMap'] )
+ ? $params['keywordTableMap']
+ : [];
+
+ parent::__construct( $params );
+ }
+
+ public function getType() {
+ return 'postgres';
+ }
+
+ public function implicitGroupby() {
+ return false;
+ }
+
+ public function implicitOrderby() {
+ return false;
+ }
+
+ public function hasConstraint( $name ) {
+ $conn = $this->getBindingHandle();
+
+ $sql = "SELECT 1 FROM pg_catalog.pg_constraint c, pg_catalog.pg_namespace n " .
+ "WHERE c.connamespace = n.oid AND conname = '" .
+ pg_escape_string( $conn, $name ) . "' AND n.nspname = '" .
+ pg_escape_string( $conn, $this->getCoreSchema() ) . "'";
+ $res = $this->doQuery( $sql );
+
+ return $this->numRows( $res );
+ }
+
+ public function open( $server, $user, $password, $dbName ) {
+ # Test for Postgres support, to avoid suppressed fatal error
+ if ( !function_exists( 'pg_connect' ) ) {
+ throw new DBConnectionError(
+ $this,
+ "Postgres functions missing, have you compiled PHP with the --with-pgsql\n" .
+ "option? (Note: if you recently installed PHP, you may need to restart your\n" .
+ "webserver and database)\n"
+ );
+ }
+
+ $this->mServer = $server;
+ $this->mUser = $user;
+ $this->mPassword = $password;
+ $this->mDBname = $dbName;
+
+ $connectVars = [
+ // pg_connect() user $user as the default database. Since a database is *required*,
+ // at least pick a "don't care" database that is more likely to exist. This case
+ // arrises when LoadBalancer::getConnection( $i, [], '' ) is used.
+ 'dbname' => strlen( $dbName ) ? $dbName : 'postgres',
+ 'user' => $user,
+ 'password' => $password
+ ];
+ if ( $server != false && $server != '' ) {
+ $connectVars['host'] = $server;
+ }
+ if ( (int)$this->port > 0 ) {
+ $connectVars['port'] = (int)$this->port;
+ }
+ if ( $this->mFlags & self::DBO_SSL ) {
+ $connectVars['sslmode'] = 1;
+ }
+
+ $this->connectString = $this->makeConnectionString( $connectVars );
+ $this->close();
+ $this->installErrorHandler();
+
+ try {
+ // Use new connections to let LoadBalancer/LBFactory handle reuse
+ $this->mConn = pg_connect( $this->connectString, PGSQL_CONNECT_FORCE_NEW );
+ } catch ( Exception $ex ) {
+ $this->restoreErrorHandler();
+ throw $ex;
+ }
+
+ $phpError = $this->restoreErrorHandler();
+
+ if ( !$this->mConn ) {
+ $this->queryLogger->debug(
+ "DB connection error\n" .
+ "Server: $server, Database: $dbName, User: $user, Password: " .
+ substr( $password, 0, 3 ) . "...\n"
+ );
+ $this->queryLogger->debug( $this->lastError() . "\n" );
+ throw new DBConnectionError( $this, str_replace( "\n", ' ', $phpError ) );
+ }
+
+ $this->mOpened = true;
+
+ # If called from the command-line (e.g. importDump), only show errors
+ if ( $this->cliMode ) {
+ $this->doQuery( "SET client_min_messages = 'ERROR'" );
+ }
+
+ $this->query( "SET client_encoding='UTF8'", __METHOD__ );
+ $this->query( "SET datestyle = 'ISO, YMD'", __METHOD__ );
+ $this->query( "SET timezone = 'GMT'", __METHOD__ );
+ $this->query( "SET standard_conforming_strings = on", __METHOD__ );
+ if ( $this->getServerVersion() >= 9.0 ) {
+ $this->query( "SET bytea_output = 'escape'", __METHOD__ ); // PHP bug 53127
+ }
+
+ $this->determineCoreSchema( $this->mSchema );
+ // The schema to be used is now in the search path; no need for explicit qualification
+ $this->mSchema = '';
+
+ return $this->mConn;
+ }
+
+ public function databasesAreIndependent() {
+ return true;
+ }
+
+ /**
+ * Postgres doesn't support selectDB in the same way MySQL does. So if the
+ * DB name doesn't match the open connection, open a new one
+ * @param string $db
+ * @return bool
+ * @throws DBUnexpectedError
+ */
+ public function selectDB( $db ) {
+ if ( $this->mDBname !== $db ) {
+ return (bool)$this->open( $this->mServer, $this->mUser, $this->mPassword, $db );
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * @param string[] $vars
+ * @return string
+ */
+ private function makeConnectionString( $vars ) {
+ $s = '';
+ foreach ( $vars as $name => $value ) {
+ $s .= "$name='" . str_replace( "'", "\\'", $value ) . "' ";
+ }
+
+ return $s;
+ }
+
+ protected function closeConnection() {
+ return $this->mConn ? pg_close( $this->mConn ) : true;
+ }
+
+ public function doQuery( $sql ) {
+ $conn = $this->getBindingHandle();
+
+ $sql = mb_convert_encoding( $sql, 'UTF-8' );
+ // Clear previously left over PQresult
+ while ( $res = pg_get_result( $conn ) ) {
+ pg_free_result( $res );
+ }
+ if ( pg_send_query( $conn, $sql ) === false ) {
+ throw new DBUnexpectedError( $this, "Unable to post new query to PostgreSQL\n" );
+ }
+ $this->mLastResult = pg_get_result( $conn );
+ $this->mAffectedRows = null;
+ if ( pg_result_error( $this->mLastResult ) ) {
+ return false;
+ }
+
+ return $this->mLastResult;
+ }
+
+ protected function dumpError() {
+ $diags = [
+ PGSQL_DIAG_SEVERITY,
+ PGSQL_DIAG_SQLSTATE,
+ PGSQL_DIAG_MESSAGE_PRIMARY,
+ PGSQL_DIAG_MESSAGE_DETAIL,
+ PGSQL_DIAG_MESSAGE_HINT,
+ PGSQL_DIAG_STATEMENT_POSITION,
+ PGSQL_DIAG_INTERNAL_POSITION,
+ PGSQL_DIAG_INTERNAL_QUERY,
+ PGSQL_DIAG_CONTEXT,
+ PGSQL_DIAG_SOURCE_FILE,
+ PGSQL_DIAG_SOURCE_LINE,
+ PGSQL_DIAG_SOURCE_FUNCTION
+ ];
+ foreach ( $diags as $d ) {
+ $this->queryLogger->debug( sprintf( "PgSQL ERROR(%d): %s\n",
+ $d, pg_result_error_field( $this->mLastResult, $d ) ) );
+ }
+ }
+
+ public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
+ if ( $tempIgnore ) {
+ /* Check for constraint violation */
+ if ( $errno === '23505' ) {
+ parent::reportQueryError( $error, $errno, $sql, $fname, $tempIgnore );
+
+ return;
+ }
+ }
+ /* Transaction stays in the ERROR state until rolled back */
+ if ( $this->mTrxLevel ) {
+ // Throw away the transaction state, then raise the error as normal.
+ // Note that if this connection is managed by LBFactory, it's already expected
+ // that the other transactions LBFactory manages will be rolled back.
+ $this->rollback( __METHOD__, self::FLUSHING_INTERNAL );
+ }
+ parent::reportQueryError( $error, $errno, $sql, $fname, false );
+ }
+
+ public function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $ok = pg_free_result( $res );
+ MediaWiki\restoreWarnings();
+ if ( !$ok ) {
+ throw new DBUnexpectedError( $this, "Unable to free Postgres result\n" );
+ }
+ }
+
+ public function fetchObject( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $row = pg_fetch_object( $res );
+ MediaWiki\restoreWarnings();
+ # @todo FIXME: HACK HACK HACK HACK debug
+
+ # @todo hashar: not sure if the following test really trigger if the object
+ # fetching failed.
+ $conn = $this->getBindingHandle();
+ if ( pg_last_error( $conn ) ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'SQL error: ' . htmlspecialchars( pg_last_error( $conn ) )
+ );
+ }
+
+ return $row;
+ }
+
+ public function fetchRow( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $row = pg_fetch_array( $res );
+ MediaWiki\restoreWarnings();
+
+ $conn = $this->getBindingHandle();
+ if ( pg_last_error( $conn ) ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'SQL error: ' . htmlspecialchars( pg_last_error( $conn ) )
+ );
+ }
+
+ return $row;
+ }
+
+ public function numRows( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+ MediaWiki\suppressWarnings();
+ $n = pg_num_rows( $res );
+ MediaWiki\restoreWarnings();
+
+ $conn = $this->getBindingHandle();
+ if ( pg_last_error( $conn ) ) {
+ throw new DBUnexpectedError(
+ $this,
+ 'SQL error: ' . htmlspecialchars( pg_last_error( $conn ) )
+ );
+ }
+
+ return $n;
+ }
+
+ public function numFields( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return pg_num_fields( $res );
+ }
+
+ public function fieldName( $res, $n ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return pg_field_name( $res, $n );
+ }
+
+ public function insertId() {
+ $res = $this->query( "SELECT lastval()" );
+ $row = $this->fetchRow( $res );
+ return is_null( $row[0] ) ? null : (int)$row[0];
+ }
+
+ public function dataSeek( $res, $row ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return pg_result_seek( $res, $row );
+ }
+
+ public function lastError() {
+ if ( $this->mConn ) {
+ if ( $this->mLastResult ) {
+ return pg_result_error( $this->mLastResult );
+ } else {
+ return pg_last_error();
+ }
+ }
+
+ return $this->getLastPHPError() ?: 'No database connection';
+ }
+
+ public function lastErrno() {
+ if ( $this->mLastResult ) {
+ return pg_result_error_field( $this->mLastResult, PGSQL_DIAG_SQLSTATE );
+ } else {
+ return false;
+ }
+ }
+
+ public function affectedRows() {
+ if ( !is_null( $this->mAffectedRows ) ) {
+ // Forced result for simulated queries
+ return $this->mAffectedRows;
+ }
+ if ( empty( $this->mLastResult ) ) {
+ return 0;
+ }
+
+ return pg_affected_rows( $this->mLastResult );
+ }
+
+ /**
+ * Estimate rows in dataset
+ * Returns estimated count, based on EXPLAIN output
+ * This is not necessarily an accurate estimate, so use sparingly
+ * Returns -1 if count cannot be found
+ * Takes same arguments as Database::select()
+ *
+ * @param string $table
+ * @param string $vars
+ * @param string $conds
+ * @param string $fname
+ * @param array $options
+ * @return int
+ */
+ public function estimateRowCount( $table, $vars = '*', $conds = '',
+ $fname = __METHOD__, $options = []
+ ) {
+ $options['EXPLAIN'] = true;
+ $res = $this->select( $table, $vars, $conds, $fname, $options );
+ $rows = -1;
+ if ( $res ) {
+ $row = $this->fetchRow( $res );
+ $count = [];
+ if ( preg_match( '/rows=(\d+)/', $row[0], $count ) ) {
+ $rows = (int)$count[1];
+ }
+ }
+
+ return $rows;
+ }
+
+ public function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ $sql = "SELECT indexname FROM pg_indexes WHERE tablename='$table'";
+ $res = $this->query( $sql, $fname );
+ if ( !$res ) {
+ return null;
+ }
+ foreach ( $res as $row ) {
+ if ( $row->indexname == $this->indexName( $index ) ) {
+ return $row;
+ }
+ }
+
+ return false;
+ }
+
+ public function indexAttributes( $index, $schema = false ) {
+ if ( $schema === false ) {
+ $schema = $this->getCoreSchema();
+ }
+ /*
+ * A subquery would be not needed if we didn't care about the order
+ * of attributes, but we do
+ */
+ $sql = <<<__INDEXATTR__
+
+ SELECT opcname,
+ attname,
+ i.indoption[s.g] as option,
+ pg_am.amname
+ FROM
+ (SELECT generate_series(array_lower(isub.indkey,1), array_upper(isub.indkey,1)) AS g
+ FROM
+ pg_index isub
+ JOIN pg_class cis
+ ON cis.oid=isub.indexrelid
+ JOIN pg_namespace ns
+ ON cis.relnamespace = ns.oid
+ WHERE cis.relname='$index' AND ns.nspname='$schema') AS s,
+ pg_attribute,
+ pg_opclass opcls,
+ pg_am,
+ pg_class ci
+ JOIN pg_index i
+ ON ci.oid=i.indexrelid
+ JOIN pg_class ct
+ ON ct.oid = i.indrelid
+ JOIN pg_namespace n
+ ON ci.relnamespace = n.oid
+ WHERE
+ ci.relname='$index' AND n.nspname='$schema'
+ AND attrelid = ct.oid
+ AND i.indkey[s.g] = attnum
+ AND i.indclass[s.g] = opcls.oid
+ AND pg_am.oid = opcls.opcmethod
+__INDEXATTR__;
+ $res = $this->query( $sql, __METHOD__ );
+ $a = [];
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $a[] = [
+ $row->attname,
+ $row->opcname,
+ $row->amname,
+ $row->option ];
+ }
+ } else {
+ return null;
+ }
+
+ return $a;
+ }
+
+ public function indexUnique( $table, $index, $fname = __METHOD__ ) {
+ $sql = "SELECT indexname FROM pg_indexes WHERE tablename='{$table}'" .
+ " AND indexdef LIKE 'CREATE UNIQUE%(" .
+ $this->strencode( $this->indexName( $index ) ) .
+ ")'";
+ $res = $this->query( $sql, $fname );
+ if ( !$res ) {
+ return null;
+ }
+
+ return $res->numRows() > 0;
+ }
+
+ public function selectSQLText(
+ $table, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+ ) {
+ if ( is_string( $options ) ) {
+ $options = [ $options ];
+ }
+
+ // Change the FOR UPDATE option as necessary based on the join conditions. Then pass
+ // to the parent function to get the actual SQL text.
+ // In Postgres when using FOR UPDATE, only the main table and tables that are inner joined
+ // can be locked. That means tables in an outer join cannot be FOR UPDATE locked. Trying to
+ // do so causes a DB error. This wrapper checks which tables can be locked and adjusts it
+ // accordingly.
+ // MySQL uses "ORDER BY NULL" as an optimization hint, but that is illegal in PostgreSQL.
+ if ( is_array( $options ) ) {
+ $forUpdateKey = array_search( 'FOR UPDATE', $options, true );
+ if ( $forUpdateKey !== false && $join_conds ) {
+ unset( $options[$forUpdateKey] );
+ $options['FOR UPDATE'] = [];
+
+ // All tables not in $join_conds are good
+ foreach ( $table as $alias => $name ) {
+ if ( is_numeric( $alias ) ) {
+ $alias = $name;
+ }
+ if ( !isset( $join_conds[$alias] ) ) {
+ $options['FOR UPDATE'][] = $alias;
+ }
+ }
+
+ foreach ( $join_conds as $table_cond => $join_cond ) {
+ if ( 0 === preg_match( '/^(?:LEFT|RIGHT|FULL)(?: OUTER)? JOIN$/i', $join_cond[0] ) ) {
+ $options['FOR UPDATE'][] = $table_cond;
+ }
+ }
+
+ // Quote alias names so $this->tableName() won't mangle them
+ $options['FOR UPDATE'] = array_map( function ( $name ) use ( $table ) {
+ return isset( $table[$name] ) ? $this->addIdentifierQuotes( $name ) : $name;
+ }, $options['FOR UPDATE'] );
+ }
+
+ if ( isset( $options['ORDER BY'] ) && $options['ORDER BY'] == 'NULL' ) {
+ unset( $options['ORDER BY'] );
+ }
+ }
+
+ return parent::selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds );
+ }
+
+ /**
+ * INSERT wrapper, inserts an array into a table
+ *
+ * $args may be a single associative array, or an array of these with numeric keys,
+ * for multi-row insert (Postgres version 8.2 and above only).
+ *
+ * @param string $table Name of the table to insert to.
+ * @param array $args Items to insert into the table.
+ * @param string $fname Name of the function, for profiling
+ * @param array|string $options String or array. Valid options: IGNORE
+ * @return bool Success of insert operation. IGNORE always returns true.
+ */
+ public function insert( $table, $args, $fname = __METHOD__, $options = [] ) {
+ if ( !count( $args ) ) {
+ return true;
+ }
+
+ $table = $this->tableName( $table );
+ if ( !isset( $this->numericVersion ) ) {
+ $this->getServerVersion();
+ }
+
+ if ( !is_array( $options ) ) {
+ $options = [ $options ];
+ }
+
+ if ( isset( $args[0] ) && is_array( $args[0] ) ) {
+ $multi = true;
+ $keys = array_keys( $args[0] );
+ } else {
+ $multi = false;
+ $keys = array_keys( $args );
+ }
+
+ // If IGNORE is set, we use savepoints to emulate mysql's behavior
+ $savepoint = $olde = null;
+ $numrowsinserted = 0;
+ if ( in_array( 'IGNORE', $options ) ) {
+ $savepoint = new SavepointPostgres( $this, 'mw', $this->queryLogger );
+ $olde = error_reporting( 0 );
+ // For future use, we may want to track the number of actual inserts
+ // Right now, insert (all writes) simply return true/false
+ }
+
+ $sql = "INSERT INTO $table (" . implode( ',', $keys ) . ') VALUES ';
+
+ if ( $multi ) {
+ if ( $this->numericVersion >= 8.2 && !$savepoint ) {
+ $first = true;
+ foreach ( $args as $row ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $sql .= ',';
+ }
+ $sql .= '(' . $this->makeList( $row ) . ')';
+ }
+ $res = (bool)$this->query( $sql, $fname, $savepoint );
+ } else {
+ $res = true;
+ $origsql = $sql;
+ foreach ( $args as $row ) {
+ $tempsql = $origsql;
+ $tempsql .= '(' . $this->makeList( $row ) . ')';
+
+ if ( $savepoint ) {
+ $savepoint->savepoint();
+ }
+
+ $tempres = (bool)$this->query( $tempsql, $fname, $savepoint );
+
+ if ( $savepoint ) {
+ $bar = pg_result_error( $this->mLastResult );
+ if ( $bar != false ) {
+ $savepoint->rollback();
+ } else {
+ $savepoint->release();
+ $numrowsinserted++;
+ }
+ }
+
+ // If any of them fail, we fail overall for this function call
+ // Note that this will be ignored if IGNORE is set
+ if ( !$tempres ) {
+ $res = false;
+ }
+ }
+ }
+ } else {
+ // Not multi, just a lone insert
+ if ( $savepoint ) {
+ $savepoint->savepoint();
+ }
+
+ $sql .= '(' . $this->makeList( $args ) . ')';
+ $res = (bool)$this->query( $sql, $fname, $savepoint );
+ if ( $savepoint ) {
+ $bar = pg_result_error( $this->mLastResult );
+ if ( $bar != false ) {
+ $savepoint->rollback();
+ } else {
+ $savepoint->release();
+ $numrowsinserted++;
+ }
+ }
+ }
+ if ( $savepoint ) {
+ error_reporting( $olde );
+ $savepoint->commit();
+
+ // Set the affected row count for the whole operation
+ $this->mAffectedRows = $numrowsinserted;
+
+ // IGNORE always returns true
+ return true;
+ }
+
+ return $res;
+ }
+
+ /**
+ * INSERT SELECT wrapper
+ * $varMap must be an associative array of the form [ 'dest1' => 'source1', ... ]
+ * Source items may be literals rather then field names, but strings should
+ * be quoted with Database::addQuotes()
+ * $conds may be "*" to copy the whole table
+ * srcTable may be an array of tables.
+ * @todo FIXME: Implement this a little better (seperate select/insert)?
+ *
+ * @param string $destTable
+ * @param array|string $srcTable
+ * @param array $varMap
+ * @param array $conds
+ * @param string $fname
+ * @param array $insertOptions
+ * @param array $selectOptions
+ * @param array $selectJoinConds
+ * @return bool
+ */
+ public function nativeInsertSelect(
+ $destTable, $srcTable, $varMap, $conds, $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = [], $selectJoinConds = []
+ ) {
+ if ( !is_array( $insertOptions ) ) {
+ $insertOptions = [ $insertOptions ];
+ }
+
+ /*
+ * If IGNORE is set, we use savepoints to emulate mysql's behavior
+ * Ignore LOW PRIORITY option, since it is MySQL-specific
+ */
+ $savepoint = $olde = null;
+ $numrowsinserted = 0;
+ if ( in_array( 'IGNORE', $insertOptions ) ) {
+ $savepoint = new SavepointPostgres( $this, 'mw', $this->queryLogger );
+ $olde = error_reporting( 0 );
+ $savepoint->savepoint();
+ }
+
+ $res = parent::nativeInsertSelect( $destTable, $srcTable, $varMap, $conds, $fname,
+ $insertOptions, $selectOptions, $selectJoinConds );
+
+ if ( $savepoint ) {
+ $bar = pg_result_error( $this->mLastResult );
+ if ( $bar != false ) {
+ $savepoint->rollback();
+ } else {
+ $savepoint->release();
+ $numrowsinserted++;
+ }
+ error_reporting( $olde );
+ $savepoint->commit();
+
+ // Set the affected row count for the whole operation
+ $this->mAffectedRows = $numrowsinserted;
+
+ // IGNORE always returns true
+ return true;
+ }
+
+ return $res;
+ }
+
+ public function tableName( $name, $format = 'quoted' ) {
+ // Replace reserved words with better ones
+ $name = $this->remappedTableName( $name );
+
+ return parent::tableName( $name, $format );
+ }
+
+ /**
+ * @param string $name
+ * @return string Value of $name or remapped name if $name is a reserved keyword
+ */
+ public function remappedTableName( $name ) {
+ return isset( $this->keywordTableMap[$name] ) ? $this->keywordTableMap[$name] : $name;
+ }
+
+ /**
+ * @param string $name
+ * @param string $format
+ * @return string Qualified and encoded (if requested) table name
+ */
+ public function realTableName( $name, $format = 'quoted' ) {
+ return parent::tableName( $name, $format );
+ }
+
+ public function nextSequenceValue( $seqName ) {
+ return new NextSequenceValue;
+ }
+
+ /**
+ * Return the current value of a sequence. Assumes it has been nextval'ed in this session.
+ *
+ * @param string $seqName
+ * @return int
+ */
+ public function currentSequenceValue( $seqName ) {
+ $safeseq = str_replace( "'", "''", $seqName );
+ $res = $this->query( "SELECT currval('$safeseq')" );
+ $row = $this->fetchRow( $res );
+ $currval = $row[0];
+
+ return $currval;
+ }
+
+ public function textFieldSize( $table, $field ) {
+ $table = $this->tableName( $table );
+ $sql = "SELECT t.typname as ftype,a.atttypmod as size
+ FROM pg_class c, pg_attribute a, pg_type t
+ WHERE relname='$table' AND a.attrelid=c.oid AND
+ a.atttypid=t.oid and a.attname='$field'";
+ $res = $this->query( $sql );
+ $row = $this->fetchObject( $res );
+ if ( $row->ftype == 'varchar' ) {
+ $size = $row->size - 4;
+ } else {
+ $size = $row->size;
+ }
+
+ return $size;
+ }
+
+ public function limitResult( $sql, $limit, $offset = false ) {
+ return "$sql LIMIT $limit " . ( is_numeric( $offset ) ? " OFFSET {$offset} " : '' );
+ }
+
+ public function wasDeadlock() {
+ return $this->lastErrno() == '40P01';
+ }
+
+ public function duplicateTableStructure(
+ $oldName, $newName, $temporary = false, $fname = __METHOD__
+ ) {
+ $newName = $this->addIdentifierQuotes( $newName );
+ $oldName = $this->addIdentifierQuotes( $oldName );
+
+ return $this->query( 'CREATE ' . ( $temporary ? 'TEMPORARY ' : '' ) . " TABLE $newName " .
+ "(LIKE $oldName INCLUDING DEFAULTS INCLUDING INDEXES)", $fname );
+ }
+
+ public function listTables( $prefix = null, $fname = __METHOD__ ) {
+ $eschema = $this->addQuotes( $this->getCoreSchema() );
+ $result = $this->query(
+ "SELECT tablename FROM pg_tables WHERE schemaname = $eschema", $fname );
+ $endArray = [];
+
+ foreach ( $result as $table ) {
+ $vars = get_object_vars( $table );
+ $table = array_pop( $vars );
+ if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
+ $endArray[] = $table;
+ }
+ }
+
+ return $endArray;
+ }
+
+ public function timestamp( $ts = 0 ) {
+ $ct = new ConvertibleTimestamp( $ts );
+
+ return $ct->getTimestamp( TS_POSTGRES );
+ }
+
+ /**
+ * Posted by cc[plus]php[at]c2se[dot]com on 25-Mar-2009 09:12
+ * to https://secure.php.net/manual/en/ref.pgsql.php
+ *
+ * Parsing a postgres array can be a tricky problem, he's my
+ * take on this, it handles multi-dimensional arrays plus
+ * escaping using a nasty regexp to determine the limits of each
+ * data-item.
+ *
+ * This should really be handled by PHP PostgreSQL module
+ *
+ * @since 1.19
+ * @param string $text Postgreql array returned in a text form like {a,b}
+ * @param string[] $output
+ * @param int|bool $limit
+ * @param int $offset
+ * @return string[]
+ */
+ private function pg_array_parse( $text, &$output, $limit = false, $offset = 1 ) {
+ if ( false === $limit ) {
+ $limit = strlen( $text ) - 1;
+ $output = [];
+ }
+ if ( '{}' == $text ) {
+ return $output;
+ }
+ do {
+ if ( '{' != $text[$offset] ) {
+ preg_match( "/(\\{?\"([^\"\\\\]|\\\\.)*\"|[^,{}]+)+([,}]+)/",
+ $text, $match, 0, $offset );
+ $offset += strlen( $match[0] );
+ $output[] = ( '"' != $match[1][0]
+ ? $match[1]
+ : stripcslashes( substr( $match[1], 1, -1 ) ) );
+ if ( '},' == $match[3] ) {
+ return $output;
+ }
+ } else {
+ $offset = $this->pg_array_parse( $text, $output, $limit, $offset + 1 );
+ }
+ } while ( $limit > $offset );
+
+ return $output;
+ }
+
+ public function aggregateValue( $valuedata, $valuename = 'value' ) {
+ return $valuedata;
+ }
+
+ public function getSoftwareLink() {
+ return '[{{int:version-db-postgres-url}} PostgreSQL]';
+ }
+
+ /**
+ * Return current schema (executes SELECT current_schema())
+ * Needs transaction
+ *
+ * @since 1.19
+ * @return string Default schema for the current session
+ */
+ public function getCurrentSchema() {
+ $res = $this->query( "SELECT current_schema()", __METHOD__ );
+ $row = $this->fetchRow( $res );
+
+ return $row[0];
+ }
+
+ /**
+ * Return list of schemas which are accessible without schema name
+ * This is list does not contain magic keywords like "$user"
+ * Needs transaction
+ *
+ * @see getSearchPath()
+ * @see setSearchPath()
+ * @since 1.19
+ * @return array List of actual schemas for the current sesson
+ */
+ public function getSchemas() {
+ $res = $this->query( "SELECT current_schemas(false)", __METHOD__ );
+ $row = $this->fetchRow( $res );
+ $schemas = [];
+
+ /* PHP pgsql support does not support array type, "{a,b}" string is returned */
+
+ return $this->pg_array_parse( $row[0], $schemas );
+ }
+
+ /**
+ * Return search patch for schemas
+ * This is different from getSchemas() since it contain magic keywords
+ * (like "$user").
+ * Needs transaction
+ *
+ * @since 1.19
+ * @return array How to search for table names schemas for the current user
+ */
+ public function getSearchPath() {
+ $res = $this->query( "SHOW search_path", __METHOD__ );
+ $row = $this->fetchRow( $res );
+
+ /* PostgreSQL returns SHOW values as strings */
+
+ return explode( ",", $row[0] );
+ }
+
+ /**
+ * Update search_path, values should already be sanitized
+ * Values may contain magic keywords like "$user"
+ * @since 1.19
+ *
+ * @param array $search_path List of schemas to be searched by default
+ */
+ private function setSearchPath( $search_path ) {
+ $this->query( "SET search_path = " . implode( ", ", $search_path ) );
+ }
+
+ /**
+ * Determine default schema for the current application
+ * Adjust this session schema search path if desired schema exists
+ * and is not alread there.
+ *
+ * We need to have name of the core schema stored to be able
+ * to query database metadata.
+ *
+ * This will be also called by the installer after the schema is created
+ *
+ * @since 1.19
+ *
+ * @param string $desiredSchema
+ */
+ public function determineCoreSchema( $desiredSchema ) {
+ $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
+ if ( $this->schemaExists( $desiredSchema ) ) {
+ if ( in_array( $desiredSchema, $this->getSchemas() ) ) {
+ $this->mCoreSchema = $desiredSchema;
+ $this->queryLogger->debug(
+ "Schema \"" . $desiredSchema . "\" already in the search path\n" );
+ } else {
+ /**
+ * Prepend our schema (e.g. 'mediawiki') in front
+ * of the search path
+ * Fixes T17816
+ */
+ $search_path = $this->getSearchPath();
+ array_unshift( $search_path,
+ $this->addIdentifierQuotes( $desiredSchema ) );
+ $this->setSearchPath( $search_path );
+ $this->mCoreSchema = $desiredSchema;
+ $this->queryLogger->debug(
+ "Schema \"" . $desiredSchema . "\" added to the search path\n" );
+ }
+ } else {
+ $this->mCoreSchema = $this->getCurrentSchema();
+ $this->queryLogger->debug(
+ "Schema \"" . $desiredSchema . "\" not found, using current \"" .
+ $this->mCoreSchema . "\"\n" );
+ }
+ /* Commit SET otherwise it will be rollbacked on error or IGNORE SELECT */
+ $this->commit( __METHOD__, self::FLUSHING_INTERNAL );
+ }
+
+ /**
+ * Return schema name for core application tables
+ *
+ * @since 1.19
+ * @return string Core schema name
+ */
+ public function getCoreSchema() {
+ return $this->mCoreSchema;
+ }
+
+ public function getServerVersion() {
+ if ( !isset( $this->numericVersion ) ) {
+ $conn = $this->getBindingHandle();
+ $versionInfo = pg_version( $conn );
+ if ( version_compare( $versionInfo['client'], '7.4.0', 'lt' ) ) {
+ // Old client, abort install
+ $this->numericVersion = '7.3 or earlier';
+ } elseif ( isset( $versionInfo['server'] ) ) {
+ // Normal client
+ $this->numericVersion = $versionInfo['server'];
+ } else {
+ // T18937: broken pgsql extension from PHP<5.3
+ $this->numericVersion = pg_parameter_status( $conn, 'server_version' );
+ }
+ }
+
+ return $this->numericVersion;
+ }
+
+ /**
+ * Query whether a given relation exists (in the given schema, or the
+ * default mw one if not given)
+ * @param string $table
+ * @param array|string $types
+ * @param bool|string $schema
+ * @return bool
+ */
+ private function relationExists( $table, $types, $schema = false ) {
+ if ( !is_array( $types ) ) {
+ $types = [ $types ];
+ }
+ if ( $schema === false ) {
+ $schema = $this->getCoreSchema();
+ }
+ $table = $this->realTableName( $table, 'raw' );
+ $etable = $this->addQuotes( $table );
+ $eschema = $this->addQuotes( $schema );
+ $sql = "SELECT 1 FROM pg_catalog.pg_class c, pg_catalog.pg_namespace n "
+ . "WHERE c.relnamespace = n.oid AND c.relname = $etable AND n.nspname = $eschema "
+ . "AND c.relkind IN ('" . implode( "','", $types ) . "')";
+ $res = $this->query( $sql );
+ $count = $res ? $res->numRows() : 0;
+
+ return (bool)$count;
+ }
+
+ /**
+ * For backward compatibility, this function checks both tables and views.
+ * @param string $table
+ * @param string $fname
+ * @param bool|string $schema
+ * @return bool
+ */
+ public function tableExists( $table, $fname = __METHOD__, $schema = false ) {
+ return $this->relationExists( $table, [ 'r', 'v' ], $schema );
+ }
+
+ public function sequenceExists( $sequence, $schema = false ) {
+ return $this->relationExists( $sequence, 'S', $schema );
+ }
+
+ public function triggerExists( $table, $trigger ) {
+ $q = <<<SQL
+ SELECT 1 FROM pg_class, pg_namespace, pg_trigger
+ WHERE relnamespace=pg_namespace.oid AND relkind='r'
+ AND tgrelid=pg_class.oid
+ AND nspname=%s AND relname=%s AND tgname=%s
+SQL;
+ $res = $this->query(
+ sprintf(
+ $q,
+ $this->addQuotes( $this->getCoreSchema() ),
+ $this->addQuotes( $table ),
+ $this->addQuotes( $trigger )
+ )
+ );
+ if ( !$res ) {
+ return null;
+ }
+ $rows = $res->numRows();
+
+ return $rows;
+ }
+
+ public function ruleExists( $table, $rule ) {
+ $exists = $this->selectField( 'pg_rules', 'rulename',
+ [
+ 'rulename' => $rule,
+ 'tablename' => $table,
+ 'schemaname' => $this->getCoreSchema()
+ ]
+ );
+
+ return $exists === $rule;
+ }
+
+ public function constraintExists( $table, $constraint ) {
+ $sql = sprintf( "SELECT 1 FROM information_schema.table_constraints " .
+ "WHERE constraint_schema = %s AND table_name = %s AND constraint_name = %s",
+ $this->addQuotes( $this->getCoreSchema() ),
+ $this->addQuotes( $table ),
+ $this->addQuotes( $constraint )
+ );
+ $res = $this->query( $sql );
+ if ( !$res ) {
+ return null;
+ }
+ $rows = $res->numRows();
+
+ return $rows;
+ }
+
+ /**
+ * Query whether a given schema exists. Returns true if it does, false if it doesn't.
+ * @param string $schema
+ * @return bool
+ */
+ public function schemaExists( $schema ) {
+ if ( !strlen( $schema ) ) {
+ return false; // short-circuit
+ }
+
+ $exists = $this->selectField(
+ '"pg_catalog"."pg_namespace"', 1, [ 'nspname' => $schema ], __METHOD__ );
+
+ return (bool)$exists;
+ }
+
+ /**
+ * Returns true if a given role (i.e. user) exists, false otherwise.
+ * @param string $roleName
+ * @return bool
+ */
+ public function roleExists( $roleName ) {
+ $exists = $this->selectField( '"pg_catalog"."pg_roles"', 1,
+ [ 'rolname' => $roleName ], __METHOD__ );
+
+ return (bool)$exists;
+ }
+
+ /**
+ * @param string $table
+ * @param string $field
+ * @return PostgresField|null
+ */
+ public function fieldInfo( $table, $field ) {
+ return PostgresField::fromText( $this, $table, $field );
+ }
+
+ /**
+ * pg_field_type() wrapper
+ * @param ResultWrapper|resource $res ResultWrapper or PostgreSQL query result resource
+ * @param int $index Field number, starting from 0
+ * @return string
+ */
+ public function fieldType( $res, $index ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res = $res->result;
+ }
+
+ return pg_field_type( $res, $index );
+ }
+
+ public function encodeBlob( $b ) {
+ return new PostgresBlob( pg_escape_bytea( $b ) );
+ }
+
+ public function decodeBlob( $b ) {
+ if ( $b instanceof PostgresBlob ) {
+ $b = $b->fetch();
+ } elseif ( $b instanceof Blob ) {
+ return $b->fetch();
+ }
+
+ return pg_unescape_bytea( $b );
+ }
+
+ public function strencode( $s ) {
+ // Should not be called by us
+ return pg_escape_string( $this->getBindingHandle(), (string)$s );
+ }
+
+ public function addQuotes( $s ) {
+ $conn = $this->getBindingHandle();
+
+ if ( is_null( $s ) ) {
+ return 'NULL';
+ } elseif ( is_bool( $s ) ) {
+ return intval( $s );
+ } elseif ( $s instanceof Blob ) {
+ if ( $s instanceof PostgresBlob ) {
+ $s = $s->fetch();
+ } else {
+ $s = pg_escape_bytea( $conn, $s->fetch() );
+ }
+ return "'$s'";
+ } elseif ( $s instanceof NextSequenceValue ) {
+ return 'DEFAULT';
+ }
+
+ return "'" . pg_escape_string( $conn, (string)$s ) . "'";
+ }
+
+ /**
+ * Postgres specific version of replaceVars.
+ * Calls the parent version in Database.php
+ *
+ * @param string $ins SQL string, read from a stream (usually tables.sql)
+ * @return string SQL string
+ */
+ protected function replaceVars( $ins ) {
+ $ins = parent::replaceVars( $ins );
+
+ if ( $this->numericVersion >= 8.3 ) {
+ // Thanks for not providing backwards-compatibility, 8.3
+ $ins = preg_replace( "/to_tsvector\s*\(\s*'default'\s*,/", 'to_tsvector(', $ins );
+ }
+
+ if ( $this->numericVersion <= 8.1 ) { // Our minimum version
+ $ins = str_replace( 'USING gin', 'USING gist', $ins );
+ }
+
+ return $ins;
+ }
+
+ public function makeSelectOptions( $options ) {
+ $preLimitTail = $postLimitTail = '';
+ $startOpts = $useIndex = $ignoreIndex = '';
+
+ $noKeyOptions = [];
+ foreach ( $options as $key => $option ) {
+ if ( is_numeric( $key ) ) {
+ $noKeyOptions[$option] = true;
+ }
+ }
+
+ $preLimitTail .= $this->makeGroupByWithHaving( $options );
+
+ $preLimitTail .= $this->makeOrderBy( $options );
+
+ if ( isset( $options['FOR UPDATE'] ) ) {
+ $postLimitTail .= ' FOR UPDATE OF ' .
+ implode( ', ', array_map( [ $this, 'tableName' ], $options['FOR UPDATE'] ) );
+ } elseif ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
+ $postLimitTail .= ' FOR UPDATE';
+ }
+
+ if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
+ $startOpts .= 'DISTINCT';
+ }
+
+ return [ $startOpts, $useIndex, $preLimitTail, $postLimitTail, $ignoreIndex ];
+ }
+
+ public function getDBname() {
+ return $this->mDBname;
+ }
+
+ public function getServer() {
+ return $this->mServer;
+ }
+
+ public function buildConcat( $stringList ) {
+ return implode( ' || ', $stringList );
+ }
+
+ public function buildGroupConcatField(
+ $delimiter, $table, $field, $conds = '', $options = [], $join_conds = []
+ ) {
+ $fld = "array_to_string(array_agg($field)," . $this->addQuotes( $delimiter ) . ')';
+
+ return '(' . $this->selectSQLText( $table, $fld, $conds, null, [], $join_conds ) . ')';
+ }
+
+ public function buildStringCast( $field ) {
+ return $field . '::text';
+ }
+
+ public function streamStatementEnd( &$sql, &$newLine ) {
+ # Allow dollar quoting for function declarations
+ if ( substr( $newLine, 0, 4 ) == '$mw$' ) {
+ if ( $this->delimiter ) {
+ $this->delimiter = false;
+ } else {
+ $this->delimiter = ';';
+ }
+ }
+
+ return parent::streamStatementEnd( $sql, $newLine );
+ }
+
+ public function doLockTables( array $read, array $write, $method ) {
+ $tablesWrite = [];
+ foreach ( $write as $table ) {
+ $tablesWrite[] = $this->tableName( $table );
+ }
+ $tablesRead = [];
+ foreach ( $read as $table ) {
+ $tablesRead[] = $this->tableName( $table );
+ }
+
+ // Acquire locks for the duration of the current transaction...
+ if ( $tablesWrite ) {
+ $this->query(
+ 'LOCK TABLE ONLY ' . implode( ',', $tablesWrite ) . ' IN EXCLUSIVE MODE',
+ $method
+ );
+ }
+ if ( $tablesRead ) {
+ $this->query(
+ 'LOCK TABLE ONLY ' . implode( ',', $tablesRead ) . ' IN SHARE MODE',
+ $method
+ );
+ }
+
+ return true;
+ }
+
+ public function lockIsFree( $lockName, $method ) {
+ // http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
+ $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) );
+ $result = $this->query( "SELECT (CASE(pg_try_advisory_lock($key))
+ WHEN 'f' THEN 'f' ELSE pg_advisory_unlock($key) END) AS lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ return ( $row->lockstatus === 't' );
+ }
+
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ // http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
+ $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) );
+ $loop = new WaitConditionLoop(
+ function () use ( $lockName, $key, $timeout, $method ) {
+ $res = $this->query( "SELECT pg_try_advisory_lock($key) AS lockstatus", $method );
+ $row = $this->fetchObject( $res );
+ if ( $row->lockstatus === 't' ) {
+ parent::lock( $lockName, $method, $timeout ); // record
+ return true;
+ }
+
+ return WaitConditionLoop::CONDITION_CONTINUE;
+ },
+ $timeout
+ );
+
+ return ( $loop->invoke() === $loop::CONDITION_REACHED );
+ }
+
+ public function unlock( $lockName, $method ) {
+ // http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
+ $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) );
+ $result = $this->query( "SELECT pg_advisory_unlock($key) as lockstatus", $method );
+ $row = $this->fetchObject( $result );
+
+ if ( $row->lockstatus === 't' ) {
+ parent::unlock( $lockName, $method ); // record
+ return true;
+ }
+
+ $this->queryLogger->debug( __METHOD__ . " failed to release lock\n" );
+
+ return false;
+ }
+
+ public function serverIsReadOnly() {
+ $res = $this->query( "SHOW default_transaction_read_only", __METHOD__ );
+ $row = $this->fetchObject( $res );
+
+ return $row ? ( strtolower( $row->default_transaction_read_only ) === 'on' ) : false;
+ }
+
+ /**
+ * @param string $lockName
+ * @return string Integer
+ */
+ private function bigintFromLockName( $lockName ) {
+ return \Wikimedia\base_convert( substr( sha1( $lockName ), 0, 15 ), 16, 10 );
+ }
+}
+
+class_alias( DatabasePostgres::class, 'DatabasePostgres' );
diff --git a/www/wiki/includes/libs/rdbms/database/DatabaseSqlite.php b/www/wiki/includes/libs/rdbms/database/DatabaseSqlite.php
new file mode 100644
index 00000000..168eef9b
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/DatabaseSqlite.php
@@ -0,0 +1,1044 @@
+<?php
+/**
+ * This is the SQLite database abstraction layer.
+ * See maintenance/sqlite/README for development notes and other specific information
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use PDO;
+use PDOException;
+use LockManager;
+use FSLockManager;
+use InvalidArgumentException;
+use RuntimeException;
+use stdClass;
+
+/**
+ * @ingroup Database
+ */
+class DatabaseSqlite extends Database {
+ /** @var bool Whether full text is enabled */
+ private static $fulltextEnabled = null;
+
+ /** @var string Directory */
+ protected $dbDir;
+ /** @var string File name for SQLite database file */
+ protected $dbPath;
+ /** @var string Transaction mode */
+ protected $trxMode;
+
+ /** @var int The number of rows affected as an integer */
+ protected $mAffectedRows;
+ /** @var resource */
+ protected $mLastResult;
+
+ /** @var PDO */
+ protected $mConn;
+
+ /** @var FSLockManager (hopefully on the same server as the DB) */
+ protected $lockMgr;
+
+ /**
+ * Additional params include:
+ * - dbDirectory : directory containing the DB and the lock file directory
+ * [defaults to $wgSQLiteDataDir]
+ * - dbFilePath : use this to force the path of the DB file
+ * - trxMode : one of (deferred, immediate, exclusive)
+ * @param array $p
+ */
+ function __construct( array $p ) {
+ if ( isset( $p['dbFilePath'] ) ) {
+ parent::__construct( $p );
+ // Standalone .sqlite file mode.
+ // Super doesn't open when $user is false, but we can work with $dbName,
+ // which is derived from the file path in this case.
+ $this->openFile( $p['dbFilePath'] );
+ $lockDomain = md5( $p['dbFilePath'] );
+ } elseif ( !isset( $p['dbDirectory'] ) ) {
+ throw new InvalidArgumentException( "Need 'dbDirectory' or 'dbFilePath' parameter." );
+ } else {
+ $this->dbDir = $p['dbDirectory'];
+ $this->mDBname = $p['dbname'];
+ $lockDomain = $this->mDBname;
+ // Stock wiki mode using standard file names per DB.
+ parent::__construct( $p );
+ // Super doesn't open when $user is false, but we can work with $dbName
+ if ( $p['dbname'] && !$this->isOpen() ) {
+ if ( $this->open( $p['host'], $p['user'], $p['password'], $p['dbname'] ) ) {
+ $done = [];
+ foreach ( $this->tableAliases as $params ) {
+ if ( isset( $done[$params['dbname']] ) ) {
+ continue;
+ }
+ $this->attachDatabase( $params['dbname'] );
+ $done[$params['dbname']] = 1;
+ }
+ }
+ }
+ }
+
+ $this->trxMode = isset( $p['trxMode'] ) ? strtoupper( $p['trxMode'] ) : null;
+ if ( $this->trxMode &&
+ !in_array( $this->trxMode, [ 'DEFERRED', 'IMMEDIATE', 'EXCLUSIVE' ] )
+ ) {
+ $this->trxMode = null;
+ $this->queryLogger->warning( "Invalid SQLite transaction mode provided." );
+ }
+
+ $this->lockMgr = new FSLockManager( [
+ 'domain' => $lockDomain,
+ 'lockDirectory' => "{$this->dbDir}/locks"
+ ] );
+ }
+
+ /**
+ * @param string $filename
+ * @param array $p Options map; supports:
+ * - flags : (same as __construct counterpart)
+ * - trxMode : (same as __construct counterpart)
+ * - dbDirectory : (same as __construct counterpart)
+ * @return DatabaseSqlite
+ * @since 1.25
+ */
+ public static function newStandaloneInstance( $filename, array $p = [] ) {
+ $p['dbFilePath'] = $filename;
+ $p['schema'] = false;
+ $p['tablePrefix'] = '';
+ /** @var DatabaseSqlite $db */
+ $db = Database::factory( 'sqlite', $p );
+
+ return $db;
+ }
+
+ /**
+ * @return string
+ */
+ function getType() {
+ return 'sqlite';
+ }
+
+ /**
+ * @todo Check if it should be true like parent class
+ *
+ * @return bool
+ */
+ function implicitGroupby() {
+ return false;
+ }
+
+ /** Open an SQLite database and return a resource handle to it
+ * NOTE: only $dbName is used, the other parameters are irrelevant for SQLite databases
+ *
+ * @param string $server
+ * @param string $user
+ * @param string $pass
+ * @param string $dbName
+ *
+ * @throws DBConnectionError
+ * @return bool
+ */
+ function open( $server, $user, $pass, $dbName ) {
+ $this->close();
+ $fileName = self::generateFileName( $this->dbDir, $dbName );
+ if ( !is_readable( $fileName ) ) {
+ $this->mConn = false;
+ throw new DBConnectionError( $this, "SQLite database not accessible" );
+ }
+ $this->openFile( $fileName );
+
+ return (bool)$this->mConn;
+ }
+
+ /**
+ * Opens a database file
+ *
+ * @param string $fileName
+ * @throws DBConnectionError
+ * @return PDO|bool SQL connection or false if failed
+ */
+ protected function openFile( $fileName ) {
+ $err = false;
+
+ $this->dbPath = $fileName;
+ try {
+ if ( $this->mFlags & self::DBO_PERSISTENT ) {
+ $this->mConn = new PDO( "sqlite:$fileName", '', '',
+ [ PDO::ATTR_PERSISTENT => true ] );
+ } else {
+ $this->mConn = new PDO( "sqlite:$fileName", '', '' );
+ }
+ } catch ( PDOException $e ) {
+ $err = $e->getMessage();
+ }
+
+ if ( !$this->mConn ) {
+ $this->queryLogger->debug( "DB connection error: $err\n" );
+ throw new DBConnectionError( $this, $err );
+ }
+
+ $this->mOpened = !!$this->mConn;
+ if ( $this->mOpened ) {
+ # Set error codes only, don't raise exceptions
+ $this->mConn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT );
+ # Enforce LIKE to be case sensitive, just like MySQL
+ $this->query( 'PRAGMA case_sensitive_like = 1' );
+
+ return $this->mConn;
+ }
+
+ return false;
+ }
+
+ /**
+ * @return string SQLite DB file path
+ * @since 1.25
+ */
+ public function getDbFilePath() {
+ return $this->dbPath;
+ }
+
+ /**
+ * Does not actually close the connection, just destroys the reference for GC to do its work
+ * @return bool
+ */
+ protected function closeConnection() {
+ $this->mConn = null;
+
+ return true;
+ }
+
+ /**
+ * Generates a database file name. Explicitly public for installer.
+ * @param string $dir Directory where database resides
+ * @param string $dbName Database name
+ * @return string
+ */
+ public static function generateFileName( $dir, $dbName ) {
+ return "$dir/$dbName.sqlite";
+ }
+
+ /**
+ * Check if the searchindext table is FTS enabled.
+ * @return bool False if not enabled.
+ */
+ function checkForEnabledSearch() {
+ if ( self::$fulltextEnabled === null ) {
+ self::$fulltextEnabled = false;
+ $table = $this->tableName( 'searchindex' );
+ $res = $this->query( "SELECT sql FROM sqlite_master WHERE tbl_name = '$table'", __METHOD__ );
+ if ( $res ) {
+ $row = $res->fetchRow();
+ self::$fulltextEnabled = stristr( $row['sql'], 'fts' ) !== false;
+ }
+ }
+
+ return self::$fulltextEnabled;
+ }
+
+ /**
+ * Returns version of currently supported SQLite fulltext search module or false if none present.
+ * @return string
+ */
+ static function getFulltextSearchModule() {
+ static $cachedResult = null;
+ if ( $cachedResult !== null ) {
+ return $cachedResult;
+ }
+ $cachedResult = false;
+ $table = 'dummy_search_test';
+
+ $db = self::newStandaloneInstance( ':memory:' );
+ if ( $db->query( "CREATE VIRTUAL TABLE $table USING FTS3(dummy_field)", __METHOD__, true ) ) {
+ $cachedResult = 'FTS3';
+ }
+ $db->close();
+
+ return $cachedResult;
+ }
+
+ /**
+ * Attaches external database to our connection, see https://sqlite.org/lang_attach.html
+ * for details.
+ *
+ * @param string $name Database name to be used in queries like
+ * SELECT foo FROM dbname.table
+ * @param bool|string $file Database file name. If omitted, will be generated
+ * using $name and configured data directory
+ * @param string $fname Calling function name
+ * @return ResultWrapper
+ */
+ function attachDatabase( $name, $file = false, $fname = __METHOD__ ) {
+ if ( !$file ) {
+ $file = self::generateFileName( $this->dbDir, $name );
+ }
+ $file = $this->addQuotes( $file );
+
+ return $this->query( "ATTACH DATABASE $file AS $name", $fname );
+ }
+
+ function isWriteQuery( $sql ) {
+ return parent::isWriteQuery( $sql ) && !preg_match( '/^(ATTACH|PRAGMA)\b/i', $sql );
+ }
+
+ /**
+ * SQLite doesn't allow buffered results or data seeking etc, so we'll use fetchAll as the result
+ *
+ * @param string $sql
+ * @return bool|ResultWrapper
+ */
+ protected function doQuery( $sql ) {
+ $res = $this->mConn->query( $sql );
+ if ( $res === false ) {
+ return false;
+ }
+
+ $r = $res instanceof ResultWrapper ? $res->result : $res;
+ $this->mAffectedRows = $r->rowCount();
+ $res = new ResultWrapper( $this, $r->fetchAll() );
+
+ return $res;
+ }
+
+ /**
+ * @param ResultWrapper|mixed $res
+ */
+ function freeResult( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $res->result = null;
+ } else {
+ $res = null;
+ }
+ }
+
+ /**
+ * @param ResultWrapper|array $res
+ * @return stdClass|bool
+ */
+ function fetchObject( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $r =& $res->result;
+ } else {
+ $r =& $res;
+ }
+
+ $cur = current( $r );
+ if ( is_array( $cur ) ) {
+ next( $r );
+ $obj = new stdClass;
+ foreach ( $cur as $k => $v ) {
+ if ( !is_numeric( $k ) ) {
+ $obj->$k = $v;
+ }
+ }
+
+ return $obj;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param ResultWrapper|mixed $res
+ * @return array|bool
+ */
+ function fetchRow( $res ) {
+ if ( $res instanceof ResultWrapper ) {
+ $r =& $res->result;
+ } else {
+ $r =& $res;
+ }
+ $cur = current( $r );
+ if ( is_array( $cur ) ) {
+ next( $r );
+
+ return $cur;
+ }
+
+ return false;
+ }
+
+ /**
+ * The PDO::Statement class implements the array interface so count() will work
+ *
+ * @param ResultWrapper|array $res
+ * @return int
+ */
+ function numRows( $res ) {
+ $r = $res instanceof ResultWrapper ? $res->result : $res;
+
+ return count( $r );
+ }
+
+ /**
+ * @param ResultWrapper $res
+ * @return int
+ */
+ function numFields( $res ) {
+ $r = $res instanceof ResultWrapper ? $res->result : $res;
+ if ( is_array( $r ) && count( $r ) > 0 ) {
+ // The size of the result array is twice the number of fields. (Bug: 65578)
+ return count( $r[0] ) / 2;
+ } else {
+ // If the result is empty return 0
+ return 0;
+ }
+ }
+
+ /**
+ * @param ResultWrapper $res
+ * @param int $n
+ * @return bool
+ */
+ function fieldName( $res, $n ) {
+ $r = $res instanceof ResultWrapper ? $res->result : $res;
+ if ( is_array( $r ) ) {
+ $keys = array_keys( $r[0] );
+
+ return $keys[$n];
+ }
+
+ return false;
+ }
+
+ /**
+ * Use MySQL's naming (accounts for prefix etc) but remove surrounding backticks
+ *
+ * @param string $name
+ * @param string $format
+ * @return string
+ */
+ function tableName( $name, $format = 'quoted' ) {
+ // table names starting with sqlite_ are reserved
+ if ( strpos( $name, 'sqlite_' ) === 0 ) {
+ return $name;
+ }
+
+ return str_replace( '"', '', parent::tableName( $name, $format ) );
+ }
+
+ /**
+ * This must be called after nextSequenceVal
+ *
+ * @return int
+ */
+ function insertId() {
+ // PDO::lastInsertId yields a string :(
+ return intval( $this->mConn->lastInsertId() );
+ }
+
+ /**
+ * @param ResultWrapper|array $res
+ * @param int $row
+ */
+ function dataSeek( $res, $row ) {
+ if ( $res instanceof ResultWrapper ) {
+ $r =& $res->result;
+ } else {
+ $r =& $res;
+ }
+ reset( $r );
+ if ( $row > 0 ) {
+ for ( $i = 0; $i < $row; $i++ ) {
+ next( $r );
+ }
+ }
+ }
+
+ /**
+ * @return string
+ */
+ function lastError() {
+ if ( !is_object( $this->mConn ) ) {
+ return "Cannot return last error, no db connection";
+ }
+ $e = $this->mConn->errorInfo();
+
+ return isset( $e[2] ) ? $e[2] : '';
+ }
+
+ /**
+ * @return string
+ */
+ function lastErrno() {
+ if ( !is_object( $this->mConn ) ) {
+ return "Cannot return last error, no db connection";
+ } else {
+ $info = $this->mConn->errorInfo();
+
+ return $info[1];
+ }
+ }
+
+ /**
+ * @return int
+ */
+ function affectedRows() {
+ return $this->mAffectedRows;
+ }
+
+ /**
+ * Returns information about an index
+ * Returns false if the index does not exist
+ * - if errors are explicitly ignored, returns NULL on failure
+ *
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return array|false
+ */
+ function indexInfo( $table, $index, $fname = __METHOD__ ) {
+ $sql = 'PRAGMA index_info(' . $this->addQuotes( $this->indexName( $index ) ) . ')';
+ $res = $this->query( $sql, $fname );
+ if ( !$res || $res->numRows() == 0 ) {
+ return false;
+ }
+ $info = [];
+ foreach ( $res as $row ) {
+ $info[] = $row->name;
+ }
+
+ return $info;
+ }
+
+ /**
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool|null
+ */
+ function indexUnique( $table, $index, $fname = __METHOD__ ) {
+ $row = $this->selectRow( 'sqlite_master', '*',
+ [
+ 'type' => 'index',
+ 'name' => $this->indexName( $index ),
+ ], $fname );
+ if ( !$row || !isset( $row->sql ) ) {
+ return null;
+ }
+
+ // $row->sql will be of the form CREATE [UNIQUE] INDEX ...
+ $indexPos = strpos( $row->sql, 'INDEX' );
+ if ( $indexPos === false ) {
+ return null;
+ }
+ $firstPart = substr( $row->sql, 0, $indexPos );
+ $options = explode( ' ', $firstPart );
+
+ return in_array( 'UNIQUE', $options );
+ }
+
+ /**
+ * Filter the options used in SELECT statements
+ *
+ * @param array $options
+ * @return array
+ */
+ function makeSelectOptions( $options ) {
+ foreach ( $options as $k => $v ) {
+ if ( is_numeric( $k ) && ( $v == 'FOR UPDATE' || $v == 'LOCK IN SHARE MODE' ) ) {
+ $options[$k] = '';
+ }
+ }
+
+ return parent::makeSelectOptions( $options );
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ protected function makeUpdateOptionsArray( $options ) {
+ $options = parent::makeUpdateOptionsArray( $options );
+ $options = self::fixIgnore( $options );
+
+ return $options;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ static function fixIgnore( $options ) {
+ # SQLite uses OR IGNORE not just IGNORE
+ foreach ( $options as $k => $v ) {
+ if ( $v == 'IGNORE' ) {
+ $options[$k] = 'OR IGNORE';
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * @param array $options
+ * @return string
+ */
+ function makeInsertOptions( $options ) {
+ $options = self::fixIgnore( $options );
+
+ return parent::makeInsertOptions( $options );
+ }
+
+ /**
+ * Based on generic method (parent) with some prior SQLite-sepcific adjustments
+ * @param string $table
+ * @param array $a
+ * @param string $fname
+ * @param array $options
+ * @return bool
+ */
+ function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
+ if ( !count( $a ) ) {
+ return true;
+ }
+
+ # SQLite can't handle multi-row inserts, so divide up into multiple single-row inserts
+ if ( isset( $a[0] ) && is_array( $a[0] ) ) {
+ $ret = true;
+ foreach ( $a as $v ) {
+ if ( !parent::insert( $table, $v, "$fname/multi-row", $options ) ) {
+ $ret = false;
+ }
+ }
+ } else {
+ $ret = parent::insert( $table, $a, "$fname/single-row", $options );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $table
+ * @param array $uniqueIndexes Unused
+ * @param string|array $rows
+ * @param string $fname
+ * @return bool|ResultWrapper
+ */
+ function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+ if ( !count( $rows ) ) {
+ return true;
+ }
+
+ # SQLite can't handle multi-row replaces, so divide up into multiple single-row queries
+ if ( isset( $rows[0] ) && is_array( $rows[0] ) ) {
+ $ret = true;
+ foreach ( $rows as $v ) {
+ if ( !$this->nativeReplace( $table, $v, "$fname/multi-row" ) ) {
+ $ret = false;
+ }
+ }
+ } else {
+ $ret = $this->nativeReplace( $table, $rows, "$fname/single-row" );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Returns the size of a text field, or -1 for "unlimited"
+ * In SQLite this is SQLITE_MAX_LENGTH, by default 1GB. No way to query it though.
+ *
+ * @param string $table
+ * @param string $field
+ * @return int
+ */
+ function textFieldSize( $table, $field ) {
+ return -1;
+ }
+
+ /**
+ * @return bool
+ */
+ function unionSupportsOrderAndLimit() {
+ return false;
+ }
+
+ /**
+ * @param string[] $sqls
+ * @param bool $all Whether to "UNION ALL" or not
+ * @return string
+ */
+ function unionQueries( $sqls, $all ) {
+ $glue = $all ? ' UNION ALL ' : ' UNION ';
+
+ return implode( $glue, $sqls );
+ }
+
+ /**
+ * @return bool
+ */
+ function wasDeadlock() {
+ return $this->lastErrno() == 5; // SQLITE_BUSY
+ }
+
+ /**
+ * @return bool
+ */
+ function wasErrorReissuable() {
+ return $this->lastErrno() == 17; // SQLITE_SCHEMA;
+ }
+
+ /**
+ * @return bool
+ */
+ function wasReadOnlyError() {
+ return $this->lastErrno() == 8; // SQLITE_READONLY;
+ }
+
+ /**
+ * @return string Wikitext of a link to the server software's web site
+ */
+ public function getSoftwareLink() {
+ return "[{{int:version-db-sqlite-url}} SQLite]";
+ }
+
+ /**
+ * @return string Version information from the database
+ */
+ function getServerVersion() {
+ $ver = $this->mConn->getAttribute( PDO::ATTR_SERVER_VERSION );
+
+ return $ver;
+ }
+
+ /**
+ * Get information about a given field
+ * Returns false if the field does not exist.
+ *
+ * @param string $table
+ * @param string $field
+ * @return SQLiteField|bool False on failure
+ */
+ function fieldInfo( $table, $field ) {
+ $tableName = $this->tableName( $table );
+ $sql = 'PRAGMA table_info(' . $this->addQuotes( $tableName ) . ')';
+ $res = $this->query( $sql, __METHOD__ );
+ foreach ( $res as $row ) {
+ if ( $row->name == $field ) {
+ return new SQLiteField( $row, $tableName );
+ }
+ }
+
+ return false;
+ }
+
+ protected function doBegin( $fname = '' ) {
+ if ( $this->trxMode ) {
+ $this->query( "BEGIN {$this->trxMode}", $fname );
+ } else {
+ $this->query( 'BEGIN', $fname );
+ }
+ $this->mTrxLevel = 1;
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ function strencode( $s ) {
+ return substr( $this->addQuotes( $s ), 1, -1 );
+ }
+
+ /**
+ * @param string $b
+ * @return Blob
+ */
+ function encodeBlob( $b ) {
+ return new Blob( $b );
+ }
+
+ /**
+ * @param Blob|string $b
+ * @return string
+ */
+ function decodeBlob( $b ) {
+ if ( $b instanceof Blob ) {
+ $b = $b->fetch();
+ }
+
+ return $b;
+ }
+
+ /**
+ * @param string|int|null|bool|Blob $s
+ * @return string|int
+ */
+ function addQuotes( $s ) {
+ if ( $s instanceof Blob ) {
+ return "x'" . bin2hex( $s->fetch() ) . "'";
+ } elseif ( is_bool( $s ) ) {
+ return (int)$s;
+ } elseif ( strpos( (string)$s, "\0" ) !== false ) {
+ // SQLite doesn't support \0 in strings, so use the hex representation as a workaround.
+ // This is a known limitation of SQLite's mprintf function which PDO
+ // should work around, but doesn't. I have reported this to php.net as bug #63419:
+ // https://bugs.php.net/bug.php?id=63419
+ // There was already a similar report for SQLite3::escapeString, bug #62361:
+ // https://bugs.php.net/bug.php?id=62361
+ // There is an additional bug regarding sorting this data after insert
+ // on older versions of sqlite shipped with ubuntu 12.04
+ // https://phabricator.wikimedia.org/T74367
+ $this->queryLogger->debug(
+ __FUNCTION__ .
+ ': Quoting value containing null byte. ' .
+ 'For consistency all binary data should have been ' .
+ 'first processed with self::encodeBlob()'
+ );
+ return "x'" . bin2hex( (string)$s ) . "'";
+ } else {
+ return $this->mConn->quote( (string)$s );
+ }
+ }
+
+ /**
+ * @param string $field Field or column to cast
+ * @return string
+ * @since 1.28
+ */
+ public function buildStringCast( $field ) {
+ return 'CAST ( ' . $field . ' AS TEXT )';
+ }
+
+ /**
+ * No-op version of deadlockLoop
+ *
+ * @return mixed
+ */
+ public function deadlockLoop( /*...*/ ) {
+ $args = func_get_args();
+ $function = array_shift( $args );
+
+ return call_user_func_array( $function, $args );
+ }
+
+ /**
+ * @param string $s
+ * @return string
+ */
+ protected function replaceVars( $s ) {
+ $s = parent::replaceVars( $s );
+ if ( preg_match( '/^\s*(CREATE|ALTER) TABLE/i', $s ) ) {
+ // CREATE TABLE hacks to allow schema file sharing with MySQL
+
+ // binary/varbinary column type -> blob
+ $s = preg_replace( '/\b(var)?binary(\(\d+\))/i', 'BLOB', $s );
+ // no such thing as unsigned
+ $s = preg_replace( '/\b(un)?signed\b/i', '', $s );
+ // INT -> INTEGER
+ $s = preg_replace( '/\b(tiny|small|medium|big|)int(\s*\(\s*\d+\s*\)|\b)/i', 'INTEGER', $s );
+ // floating point types -> REAL
+ $s = preg_replace(
+ '/\b(float|double(\s+precision)?)(\s*\(\s*\d+\s*(,\s*\d+\s*)?\)|\b)/i',
+ 'REAL',
+ $s
+ );
+ // varchar -> TEXT
+ $s = preg_replace( '/\b(var)?char\s*\(.*?\)/i', 'TEXT', $s );
+ // TEXT normalization
+ $s = preg_replace( '/\b(tiny|medium|long)text\b/i', 'TEXT', $s );
+ // BLOB normalization
+ $s = preg_replace( '/\b(tiny|small|medium|long|)blob\b/i', 'BLOB', $s );
+ // BOOL -> INTEGER
+ $s = preg_replace( '/\bbool(ean)?\b/i', 'INTEGER', $s );
+ // DATETIME -> TEXT
+ $s = preg_replace( '/\b(datetime|timestamp)\b/i', 'TEXT', $s );
+ // No ENUM type
+ $s = preg_replace( '/\benum\s*\([^)]*\)/i', 'TEXT', $s );
+ // binary collation type -> nothing
+ $s = preg_replace( '/\bbinary\b/i', '', $s );
+ // auto_increment -> autoincrement
+ $s = preg_replace( '/\bauto_increment\b/i', 'AUTOINCREMENT', $s );
+ // No explicit options
+ $s = preg_replace( '/\)[^);]*(;?)\s*$/', ')\1', $s );
+ // AUTOINCREMENT should immedidately follow PRIMARY KEY
+ $s = preg_replace( '/primary key (.*?) autoincrement/i', 'PRIMARY KEY AUTOINCREMENT $1', $s );
+ } elseif ( preg_match( '/^\s*CREATE (\s*(?:UNIQUE|FULLTEXT)\s+)?INDEX/i', $s ) ) {
+ // No truncated indexes
+ $s = preg_replace( '/\(\d+\)/', '', $s );
+ // No FULLTEXT
+ $s = preg_replace( '/\bfulltext\b/i', '', $s );
+ } elseif ( preg_match( '/^\s*DROP INDEX/i', $s ) ) {
+ // DROP INDEX is database-wide, not table-specific, so no ON <table> clause.
+ $s = preg_replace( '/\sON\s+[^\s]*/i', '', $s );
+ } elseif ( preg_match( '/^\s*INSERT IGNORE\b/i', $s ) ) {
+ // INSERT IGNORE --> INSERT OR IGNORE
+ $s = preg_replace( '/^\s*INSERT IGNORE\b/i', 'INSERT OR IGNORE', $s );
+ }
+
+ return $s;
+ }
+
+ public function lock( $lockName, $method, $timeout = 5 ) {
+ if ( !is_dir( "{$this->dbDir}/locks" ) ) { // create dir as needed
+ if ( !is_writable( $this->dbDir ) || !mkdir( "{$this->dbDir}/locks" ) ) {
+ throw new DBError( $this, "Cannot create directory \"{$this->dbDir}/locks\"." );
+ }
+ }
+
+ return $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout )->isOK();
+ }
+
+ public function unlock( $lockName, $method ) {
+ return $this->lockMgr->unlock( [ $lockName ], LockManager::LOCK_EX )->isOK();
+ }
+
+ /**
+ * Build a concatenation list to feed into a SQL query
+ *
+ * @param string[] $stringList
+ * @return string
+ */
+ function buildConcat( $stringList ) {
+ return '(' . implode( ') || (', $stringList ) . ')';
+ }
+
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ ) {
+ $fld = "group_concat($field," . $this->addQuotes( $delim ) . ')';
+
+ return '(' . $this->selectSQLText( $table, $fld, $conds, null, [], $join_conds ) . ')';
+ }
+
+ /**
+ * @param string $oldName
+ * @param string $newName
+ * @param bool $temporary
+ * @param string $fname
+ * @return bool|ResultWrapper
+ * @throws RuntimeException
+ */
+ function duplicateTableStructure( $oldName, $newName, $temporary = false, $fname = __METHOD__ ) {
+ $res = $this->query( "SELECT sql FROM sqlite_master WHERE tbl_name=" .
+ $this->addQuotes( $oldName ) . " AND type='table'", $fname );
+ $obj = $this->fetchObject( $res );
+ if ( !$obj ) {
+ throw new RuntimeException( "Couldn't retrieve structure for table $oldName" );
+ }
+ $sql = $obj->sql;
+ $sql = preg_replace(
+ '/(?<=\W)"?' . preg_quote( trim( $this->addIdentifierQuotes( $oldName ), '"' ) ) . '"?(?=\W)/',
+ $this->addIdentifierQuotes( $newName ),
+ $sql,
+ 1
+ );
+ if ( $temporary ) {
+ if ( preg_match( '/^\\s*CREATE\\s+VIRTUAL\\s+TABLE\b/i', $sql ) ) {
+ $this->queryLogger->debug(
+ "Table $oldName is virtual, can't create a temporary duplicate.\n" );
+ } else {
+ $sql = str_replace( 'CREATE TABLE', 'CREATE TEMPORARY TABLE', $sql );
+ }
+ }
+
+ $res = $this->query( $sql, $fname );
+
+ // Take over indexes
+ $indexList = $this->query( 'PRAGMA INDEX_LIST(' . $this->addQuotes( $oldName ) . ')' );
+ foreach ( $indexList as $index ) {
+ if ( strpos( $index->name, 'sqlite_autoindex' ) === 0 ) {
+ continue;
+ }
+
+ if ( $index->unique ) {
+ $sql = 'CREATE UNIQUE INDEX';
+ } else {
+ $sql = 'CREATE INDEX';
+ }
+ // Try to come up with a new index name, given indexes have database scope in SQLite
+ $indexName = $newName . '_' . $index->name;
+ $sql .= ' ' . $indexName . ' ON ' . $newName;
+
+ $indexInfo = $this->query( 'PRAGMA INDEX_INFO(' . $this->addQuotes( $index->name ) . ')' );
+ $fields = [];
+ foreach ( $indexInfo as $indexInfoRow ) {
+ $fields[$indexInfoRow->seqno] = $indexInfoRow->name;
+ }
+
+ $sql .= '(' . implode( ',', $fields ) . ')';
+
+ $this->query( $sql );
+ }
+
+ return $res;
+ }
+
+ /**
+ * List all tables on the database
+ *
+ * @param string $prefix Only show tables with this prefix, e.g. mw_
+ * @param string $fname Calling function name
+ *
+ * @return array
+ */
+ function listTables( $prefix = null, $fname = __METHOD__ ) {
+ $result = $this->select(
+ 'sqlite_master',
+ 'name',
+ "type='table'"
+ );
+
+ $endArray = [];
+
+ foreach ( $result as $table ) {
+ $vars = get_object_vars( $table );
+ $table = array_pop( $vars );
+
+ if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
+ if ( strpos( $table, 'sqlite_' ) !== 0 ) {
+ $endArray[] = $table;
+ }
+ }
+ }
+
+ return $endArray;
+ }
+
+ /**
+ * Override due to no CASCADE support
+ *
+ * @param string $tableName
+ * @param string $fName
+ * @return bool|ResultWrapper
+ * @throws DBReadOnlyError
+ */
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ if ( !$this->tableExists( $tableName, $fName ) ) {
+ return false;
+ }
+ $sql = "DROP TABLE " . $this->tableName( $tableName );
+
+ return $this->query( $sql, $fName );
+ }
+
+ protected function requiresDatabaseUser() {
+ return false; // just a file
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString() {
+ return 'SQLite ' . (string)$this->mConn->getAttribute( PDO::ATTR_SERVER_VERSION );
+ }
+}
+
+class_alias( DatabaseSqlite::class, 'DatabaseSqlite' );
diff --git a/www/wiki/includes/libs/rdbms/database/IDatabase.php b/www/wiki/includes/libs/rdbms/database/IDatabase.php
new file mode 100644
index 00000000..5d0e03fc
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/IDatabase.php
@@ -0,0 +1,1868 @@
+<?php
+/**
+ * @defgroup Database Database
+ *
+ * This file deals with database interface functions
+ * and query specifics/optimisations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use Wikimedia\ScopedCallback;
+use Exception;
+use RuntimeException;
+use UnexpectedValueException;
+use stdClass;
+
+/**
+ * Basic database interface for live and lazy-loaded relation database handles
+ *
+ * @note: IDatabase and DBConnRef should be updated to reflect any changes
+ * @ingroup Database
+ */
+interface IDatabase {
+ /** @var int Callback triggered immediately due to no active transaction */
+ const TRIGGER_IDLE = 1;
+ /** @var int Callback triggered by COMMIT */
+ const TRIGGER_COMMIT = 2;
+ /** @var int Callback triggered by ROLLBACK */
+ const TRIGGER_ROLLBACK = 3;
+
+ /** @var string Transaction is requested by regular caller outside of the DB layer */
+ const TRANSACTION_EXPLICIT = '';
+ /** @var string Transaction is requested internally via DBO_TRX/startAtomic() */
+ const TRANSACTION_INTERNAL = 'implicit';
+
+ /** @var string Transaction operation comes from service managing all DBs */
+ const FLUSHING_ALL_PEERS = 'flush';
+ /** @var string Transaction operation comes from the database class internally */
+ const FLUSHING_INTERNAL = 'flush';
+
+ /** @var string Do not remember the prior flags */
+ const REMEMBER_NOTHING = '';
+ /** @var string Remember the prior flags */
+ const REMEMBER_PRIOR = 'remember';
+ /** @var string Restore to the prior flag state */
+ const RESTORE_PRIOR = 'prior';
+ /** @var string Restore to the initial flag state */
+ const RESTORE_INITIAL = 'initial';
+
+ /** @var string Estimate total time (RTT, scanning, waiting on locks, applying) */
+ const ESTIMATE_TOTAL = 'total';
+ /** @var string Estimate time to apply (scanning, applying) */
+ const ESTIMATE_DB_APPLY = 'apply';
+
+ /** @var int Combine list with comma delimeters */
+ const LIST_COMMA = 0;
+ /** @var int Combine list with AND clauses */
+ const LIST_AND = 1;
+ /** @var int Convert map into a SET clause */
+ const LIST_SET = 2;
+ /** @var int Treat as field name and do not apply value escaping */
+ const LIST_NAMES = 3;
+ /** @var int Combine list with OR clauses */
+ const LIST_OR = 4;
+
+ /** @var int Enable debug logging */
+ const DBO_DEBUG = 1;
+ /** @var int Disable query buffering (only one result set can be iterated at a time) */
+ const DBO_NOBUFFER = 2;
+ /** @var int Ignore query errors (internal use only!) */
+ const DBO_IGNORE = 4;
+ /** @var int Autoatically start transaction on first query (work with ILoadBalancer rounds) */
+ const DBO_TRX = 8;
+ /** @var int Use DBO_TRX in non-CLI mode */
+ const DBO_DEFAULT = 16;
+ /** @var int Use DB persistent connections if possible */
+ const DBO_PERSISTENT = 32;
+ /** @var int DBA session mode; mostly for Oracle */
+ const DBO_SYSDBA = 64;
+ /** @var int Schema file mode; mostly for Oracle */
+ const DBO_DDLMODE = 128;
+ /** @var int Enable SSL/TLS in connection protocol */
+ const DBO_SSL = 256;
+ /** @var int Enable compression in connection protocol */
+ const DBO_COMPRESS = 512;
+
+ /**
+ * A string describing the current software version, and possibly
+ * other details in a user-friendly way. Will be listed on Special:Version, etc.
+ * Use getServerVersion() to get machine-friendly information.
+ *
+ * @return string Version information from the database server
+ */
+ public function getServerInfo();
+
+ /**
+ * Turns buffering of SQL result sets on (true) or off (false). Default is "on".
+ *
+ * Unbuffered queries are very troublesome in MySQL:
+ *
+ * - If another query is executed while the first query is being read
+ * out, the first query is killed. This means you can't call normal
+ * Database functions while you are reading an unbuffered query result
+ * from a normal Database connection.
+ *
+ * - Unbuffered queries cause the MySQL server to use large amounts of
+ * memory and to hold broad locks which block other queries.
+ *
+ * If you want to limit client-side memory, it's almost always better to
+ * split up queries into batches using a LIMIT clause than to switch off
+ * buffering.
+ *
+ * @param null|bool $buffer
+ * @return null|bool The previous value of the flag
+ */
+ public function bufferResults( $buffer = null );
+
+ /**
+ * Gets the current transaction level.
+ *
+ * Historically, transactions were allowed to be "nested". This is no
+ * longer supported, so this function really only returns a boolean.
+ *
+ * @return int The previous value
+ */
+ public function trxLevel();
+
+ /**
+ * Get the UNIX timestamp of the time that the transaction was established
+ *
+ * This can be used to reason about the staleness of SELECT data
+ * in REPEATABLE-READ transaction isolation level.
+ *
+ * @return float|null Returns null if there is not active transaction
+ * @since 1.25
+ */
+ public function trxTimestamp();
+
+ /**
+ * @return bool Whether an explicit transaction or atomic sections are still open
+ * @since 1.28
+ */
+ public function explicitTrxActive();
+
+ /**
+ * Get/set the table prefix.
+ * @param string $prefix The table prefix to set, or omitted to leave it unchanged.
+ * @return string The previous table prefix.
+ */
+ public function tablePrefix( $prefix = null );
+
+ /**
+ * Get/set the db schema.
+ * @param string $schema The database schema to set, or omitted to leave it unchanged.
+ * @return string The previous db schema.
+ */
+ public function dbSchema( $schema = null );
+
+ /**
+ * Get properties passed down from the server info array of the load
+ * balancer.
+ *
+ * @param string $name The entry of the info array to get, or null to get the
+ * whole array
+ *
+ * @return array|mixed|null
+ */
+ public function getLBInfo( $name = null );
+
+ /**
+ * Set the LB info array, or a member of it. If called with one parameter,
+ * the LB info array is set to that parameter. If it is called with two
+ * parameters, the member with the given name is set to the given value.
+ *
+ * @param string $name
+ * @param array $value
+ */
+ public function setLBInfo( $name, $value = null );
+
+ /**
+ * Set a lazy-connecting DB handle to the master DB (for replication status purposes)
+ *
+ * @param IDatabase $conn
+ * @since 1.27
+ */
+ public function setLazyMasterHandle( IDatabase $conn );
+
+ /**
+ * Returns true if this database does an implicit sort when doing GROUP BY
+ *
+ * @return bool
+ * @deprecated Since 1.30; only use grouped or aggregated fields in the SELECT
+ */
+ public function implicitGroupby();
+
+ /**
+ * Returns true if this database does an implicit order by when the column has an index
+ * For example: SELECT page_title FROM page LIMIT 1
+ *
+ * @return bool
+ */
+ public function implicitOrderby();
+
+ /**
+ * Return the last query that went through IDatabase::query()
+ * @return string
+ */
+ public function lastQuery();
+
+ /**
+ * Returns true if the connection may have been used for write queries.
+ * Should return true if unsure.
+ *
+ * @return bool
+ */
+ public function doneWrites();
+
+ /**
+ * Returns the last time the connection may have been used for write queries.
+ * Should return a timestamp if unsure.
+ *
+ * @return int|float UNIX timestamp or false
+ * @since 1.24
+ */
+ public function lastDoneWrites();
+
+ /**
+ * @return bool Whether there is a transaction open with possible write queries
+ * @since 1.27
+ */
+ public function writesPending();
+
+ /**
+ * Returns true if there is a transaction open with possible write
+ * queries or transaction pre-commit/idle callbacks waiting on it to finish.
+ * This does *not* count recurring callbacks, e.g. from setTransactionListener().
+ *
+ * @return bool
+ */
+ public function writesOrCallbacksPending();
+
+ /**
+ * Get the time spend running write queries for this transaction
+ *
+ * High times could be due to scanning, updates, locking, and such
+ *
+ * @param string $type IDatabase::ESTIMATE_* constant [default: ESTIMATE_ALL]
+ * @return float|bool Returns false if not transaction is active
+ * @since 1.26
+ */
+ public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL );
+
+ /**
+ * Get the list of method names that did write queries for this transaction
+ *
+ * @return array
+ * @since 1.27
+ */
+ public function pendingWriteCallers();
+
+ /**
+ * Get the number of affected rows from pending write queries
+ *
+ * @return int
+ * @since 1.30
+ */
+ public function pendingWriteRowsAffected();
+
+ /**
+ * Is a connection to the database open?
+ * @return bool
+ */
+ public function isOpen();
+
+ /**
+ * Set a flag for this connection
+ *
+ * @param int $flag DBO_* constants from Defines.php:
+ * - DBO_DEBUG: output some debug info (same as debug())
+ * - DBO_NOBUFFER: don't buffer results (inverse of bufferResults())
+ * - DBO_TRX: automatically start transactions
+ * - DBO_DEFAULT: automatically sets DBO_TRX if not in command line mode
+ * and removes it in command line mode
+ * - DBO_PERSISTENT: use persistant database connection
+ * @param string $remember IDatabase::REMEMBER_* constant [default: REMEMBER_NOTHING]
+ */
+ public function setFlag( $flag, $remember = self::REMEMBER_NOTHING );
+
+ /**
+ * Clear a flag for this connection
+ *
+ * @param int $flag DBO_* constants from Defines.php:
+ * - DBO_DEBUG: output some debug info (same as debug())
+ * - DBO_NOBUFFER: don't buffer results (inverse of bufferResults())
+ * - DBO_TRX: automatically start transactions
+ * - DBO_DEFAULT: automatically sets DBO_TRX if not in command line mode
+ * and removes it in command line mode
+ * - DBO_PERSISTENT: use persistant database connection
+ * @param string $remember IDatabase::REMEMBER_* constant [default: REMEMBER_NOTHING]
+ */
+ public function clearFlag( $flag, $remember = self::REMEMBER_NOTHING );
+
+ /**
+ * Restore the flags to their prior state before the last setFlag/clearFlag call
+ *
+ * @param string $state IDatabase::RESTORE_* constant. [default: RESTORE_PRIOR]
+ * @since 1.28
+ */
+ public function restoreFlags( $state = self::RESTORE_PRIOR );
+
+ /**
+ * Returns a boolean whether the flag $flag is set for this connection
+ *
+ * @param int $flag DBO_* constants from Defines.php:
+ * - DBO_DEBUG: output some debug info (same as debug())
+ * - DBO_NOBUFFER: don't buffer results (inverse of bufferResults())
+ * - DBO_TRX: automatically start transactions
+ * - DBO_PERSISTENT: use persistant database connection
+ * @return bool
+ */
+ public function getFlag( $flag );
+
+ /**
+ * @return string
+ */
+ public function getDomainID();
+
+ /**
+ * Alias for getDomainID()
+ *
+ * @return string
+ * @deprecated 1.30
+ */
+ public function getWikiID();
+
+ /**
+ * Get the type of the DBMS, as it appears in $wgDBtype.
+ *
+ * @return string
+ */
+ public function getType();
+
+ /**
+ * Open a connection to the database. Usually aborts on failure
+ *
+ * @param string $server Database server host
+ * @param string $user Database user name
+ * @param string $password Database user password
+ * @param string $dbName Database name
+ * @return bool
+ * @throws DBConnectionError
+ */
+ public function open( $server, $user, $password, $dbName );
+
+ /**
+ * Fetch the next row from the given result object, in object form.
+ * Fields can be retrieved with $row->fieldname, with fields acting like
+ * member variables.
+ * If no more rows are available, false is returned.
+ *
+ * @param IResultWrapper|stdClass $res Object as returned from IDatabase::query(), etc.
+ * @return stdClass|bool
+ * @throws DBUnexpectedError Thrown if the database returns an error
+ */
+ public function fetchObject( $res );
+
+ /**
+ * Fetch the next row from the given result object, in associative array
+ * form. Fields are retrieved with $row['fieldname'].
+ * If no more rows are available, false is returned.
+ *
+ * @param IResultWrapper $res Result object as returned from IDatabase::query(), etc.
+ * @return array|bool
+ * @throws DBUnexpectedError Thrown if the database returns an error
+ */
+ public function fetchRow( $res );
+
+ /**
+ * Get the number of rows in a result object
+ *
+ * @param mixed $res A SQL result
+ * @return int
+ */
+ public function numRows( $res );
+
+ /**
+ * Get the number of fields in a result object
+ * @see https://secure.php.net/mysql_num_fields
+ *
+ * @param mixed $res A SQL result
+ * @return int
+ */
+ public function numFields( $res );
+
+ /**
+ * Get a field name in a result object
+ * @see https://secure.php.net/mysql_field_name
+ *
+ * @param mixed $res A SQL result
+ * @param int $n
+ * @return string
+ */
+ public function fieldName( $res, $n );
+
+ /**
+ * Get the inserted value of an auto-increment row
+ *
+ * This should only be called after an insert that used an auto-incremented
+ * value. If no such insert was previously done in the current database
+ * session, the return value is undefined.
+ *
+ * @return int
+ */
+ public function insertId();
+
+ /**
+ * Change the position of the cursor in a result object
+ * @see https://secure.php.net/mysql_data_seek
+ *
+ * @param mixed $res A SQL result
+ * @param int $row
+ */
+ public function dataSeek( $res, $row );
+
+ /**
+ * Get the last error number
+ * @see https://secure.php.net/mysql_errno
+ *
+ * @return int
+ */
+ public function lastErrno();
+
+ /**
+ * Get a description of the last error
+ * @see https://secure.php.net/mysql_error
+ *
+ * @return string
+ */
+ public function lastError();
+
+ /**
+ * mysql_fetch_field() wrapper
+ * Returns false if the field doesn't exist
+ *
+ * @param string $table Table name
+ * @param string $field Field name
+ *
+ * @return Field
+ */
+ public function fieldInfo( $table, $field );
+
+ /**
+ * Get the number of rows affected by the last write query
+ * @see https://secure.php.net/mysql_affected_rows
+ *
+ * @return int
+ */
+ public function affectedRows();
+
+ /**
+ * Returns a wikitext link to the DB's website, e.g.,
+ * return "[https://www.mysql.com/ MySQL]";
+ * Should at least contain plain text, if for some reason
+ * your database has no website.
+ *
+ * @return string Wikitext of a link to the server software's web site
+ */
+ public function getSoftwareLink();
+
+ /**
+ * A string describing the current software version, like from
+ * mysql_get_server_info().
+ *
+ * @return string Version information from the database server.
+ */
+ public function getServerVersion();
+
+ /**
+ * Closes a database connection.
+ * if it is open : commits any open transactions
+ *
+ * @throws DBError
+ * @return bool Operation success. true if already closed.
+ */
+ public function close();
+
+ /**
+ * @param string $error Fallback error message, used if none is given by DB
+ * @throws DBConnectionError
+ */
+ public function reportConnectionError( $error = 'Unknown error' );
+
+ /**
+ * Run an SQL query and return the result. Normally throws a DBQueryError
+ * on failure. If errors are ignored, returns false instead.
+ *
+ * In new code, the query wrappers select(), insert(), update(), delete(),
+ * etc. should be used where possible, since they give much better DBMS
+ * independence and automatically quote or validate user input in a variety
+ * of contexts. This function is generally only useful for queries which are
+ * explicitly DBMS-dependent and are unsupported by the query wrappers, such
+ * as CREATE TABLE.
+ *
+ * However, the query wrappers themselves should call this function.
+ *
+ * @param string $sql SQL query
+ * @param string $fname Name of the calling function, for profiling/SHOW PROCESSLIST
+ * comment (you can use __METHOD__ or add some extra info)
+ * @param bool $tempIgnore Whether to avoid throwing an exception on errors...
+ * maybe best to catch the exception instead?
+ * @throws DBError
+ * @return bool|IResultWrapper True for a successful write query, IResultWrapper object
+ * for a successful read query, or false on failure if $tempIgnore set
+ */
+ public function query( $sql, $fname = __METHOD__, $tempIgnore = false );
+
+ /**
+ * Report a query error. Log the error, and if neither the object ignore
+ * flag nor the $tempIgnore flag is set, throw a DBQueryError.
+ *
+ * @param string $error
+ * @param int $errno
+ * @param string $sql
+ * @param string $fname
+ * @param bool $tempIgnore
+ * @throws DBQueryError
+ */
+ public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false );
+
+ /**
+ * Free a result object returned by query() or select(). It's usually not
+ * necessary to call this, just use unset() or let the variable holding
+ * the result object go out of scope.
+ *
+ * @param mixed $res A SQL result
+ */
+ public function freeResult( $res );
+
+ /**
+ * A SELECT wrapper which returns a single field from a single result row.
+ *
+ * Usually throws a DBQueryError on failure. If errors are explicitly
+ * ignored, returns false on failure.
+ *
+ * If no result rows are returned from the query, false is returned.
+ *
+ * @param string|array $table Table name. See IDatabase::select() for details.
+ * @param string $var The field name to select. This must be a valid SQL
+ * fragment: do not use unvalidated user input.
+ * @param string|array $cond The condition array. See IDatabase::select() for details.
+ * @param string $fname The function name of the caller.
+ * @param string|array $options The query options. See IDatabase::select() for details.
+ * @param string|array $join_conds The query join conditions. See IDatabase::select() for details.
+ *
+ * @return bool|mixed The value from the field, or false on failure.
+ */
+ public function selectField(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
+ );
+
+ /**
+ * A SELECT wrapper which returns a list of single field values from result rows.
+ *
+ * Usually throws a DBQueryError on failure. If errors are explicitly
+ * ignored, returns false on failure.
+ *
+ * If no result rows are returned from the query, false is returned.
+ *
+ * @param string|array $table Table name. See IDatabase::select() for details.
+ * @param string $var The field name to select. This must be a valid SQL
+ * fragment: do not use unvalidated user input.
+ * @param string|array $cond The condition array. See IDatabase::select() for details.
+ * @param string $fname The function name of the caller.
+ * @param string|array $options The query options. See IDatabase::select() for details.
+ * @param string|array $join_conds The query join conditions. See IDatabase::select() for details.
+ *
+ * @return bool|array The values from the field, or false on failure
+ * @since 1.25
+ */
+ public function selectFieldValues(
+ $table, $var, $cond = '', $fname = __METHOD__, $options = [], $join_conds = []
+ );
+
+ /**
+ * Execute a SELECT query constructed using the various parameters provided.
+ * See below for full details of the parameters.
+ *
+ * @param string|array $table Table name
+ * @param string|array $vars Field names
+ * @param string|array $conds Conditions
+ * @param string $fname Caller function name
+ * @param array $options Query options
+ * @param array $join_conds Join conditions
+ *
+ *
+ * @param string|array $table
+ *
+ * May be either an array of table names, or a single string holding a table
+ * name. If an array is given, table aliases can be specified, for example:
+ *
+ * [ 'a' => 'user' ]
+ *
+ * This includes the user table in the query, with the alias "a" available
+ * for use in field names (e.g. a.user_name).
+ *
+ * All of the table names given here are automatically run through
+ * Database::tableName(), which causes the table prefix (if any) to be
+ * added, and various other table name mappings to be performed.
+ *
+ * Do not use untrusted user input as a table name. Alias names should
+ * not have characters outside of the Basic multilingual plane.
+ *
+ * @param string|array $vars
+ *
+ * May be either a field name or an array of field names. The field names
+ * can be complete fragments of SQL, for direct inclusion into the SELECT
+ * query. If an array is given, field aliases can be specified, for example:
+ *
+ * [ 'maxrev' => 'MAX(rev_id)' ]
+ *
+ * This includes an expression with the alias "maxrev" in the query.
+ *
+ * If an expression is given, care must be taken to ensure that it is
+ * DBMS-independent.
+ *
+ * Untrusted user input must not be passed to this parameter.
+ *
+ * @param string|array $conds
+ *
+ * May be either a string containing a single condition, or an array of
+ * conditions. If an array is given, the conditions constructed from each
+ * element are combined with AND.
+ *
+ * Array elements may take one of two forms:
+ *
+ * - Elements with a numeric key are interpreted as raw SQL fragments.
+ * - Elements with a string key are interpreted as equality conditions,
+ * where the key is the field name.
+ * - If the value of such an array element is a scalar (such as a
+ * string), it will be treated as data and thus quoted appropriately.
+ * If it is null, an IS NULL clause will be added.
+ * - If the value is an array, an IN (...) clause will be constructed
+ * from its non-null elements, and an IS NULL clause will be added
+ * if null is present, such that the field may match any of the
+ * elements in the array. The non-null elements will be quoted.
+ *
+ * Note that expressions are often DBMS-dependent in their syntax.
+ * DBMS-independent wrappers are provided for constructing several types of
+ * expression commonly used in condition queries. See:
+ * - IDatabase::buildLike()
+ * - IDatabase::conditional()
+ *
+ * Untrusted user input is safe in the values of string keys, however untrusted
+ * input must not be used in the array key names or in the values of numeric keys.
+ * Escaping of untrusted input used in values of numeric keys should be done via
+ * IDatabase::addQuotes()
+ *
+ * @param string|array $options
+ *
+ * Optional: Array of query options. Boolean options are specified by
+ * including them in the array as a string value with a numeric key, for
+ * example:
+ *
+ * [ 'FOR UPDATE' ]
+ *
+ * The supported options are:
+ *
+ * - OFFSET: Skip this many rows at the start of the result set. OFFSET
+ * with LIMIT can theoretically be used for paging through a result set,
+ * but this is discouraged for performance reasons.
+ *
+ * - LIMIT: Integer: return at most this many rows. The rows are sorted
+ * and then the first rows are taken until the limit is reached. LIMIT
+ * is applied to a result set after OFFSET.
+ *
+ * - FOR UPDATE: Boolean: lock the returned rows so that they can't be
+ * changed until the next COMMIT.
+ *
+ * - DISTINCT: Boolean: return only unique result rows.
+ *
+ * - GROUP BY: May be either an SQL fragment string naming a field or
+ * expression to group by, or an array of such SQL fragments.
+ *
+ * - HAVING: May be either an string containing a HAVING clause or an array of
+ * conditions building the HAVING clause. If an array is given, the conditions
+ * constructed from each element are combined with AND.
+ *
+ * - ORDER BY: May be either an SQL fragment giving a field name or
+ * expression to order by, or an array of such SQL fragments.
+ *
+ * - USE INDEX: This may be either a string giving the index name to use
+ * for the query, or an array. If it is an associative array, each key
+ * gives the table name (or alias), each value gives the index name to
+ * use for that table. All strings are SQL fragments and so should be
+ * validated by the caller.
+ *
+ * - EXPLAIN: In MySQL, this causes an EXPLAIN SELECT query to be run,
+ * instead of SELECT.
+ *
+ * And also the following boolean MySQL extensions, see the MySQL manual
+ * for documentation:
+ *
+ * - LOCK IN SHARE MODE
+ * - STRAIGHT_JOIN
+ * - HIGH_PRIORITY
+ * - SQL_BIG_RESULT
+ * - SQL_BUFFER_RESULT
+ * - SQL_SMALL_RESULT
+ * - SQL_CALC_FOUND_ROWS
+ * - SQL_CACHE
+ * - SQL_NO_CACHE
+ *
+ *
+ * @param string|array $join_conds
+ *
+ * Optional associative array of table-specific join conditions. In the
+ * most common case, this is unnecessary, since the join condition can be
+ * in $conds. However, it is useful for doing a LEFT JOIN.
+ *
+ * The key of the array contains the table name or alias. The value is an
+ * array with two elements, numbered 0 and 1. The first gives the type of
+ * join, the second is the same as the $conds parameter. Thus it can be
+ * an SQL fragment, or an array where the string keys are equality and the
+ * numeric keys are SQL fragments all AND'd together. For example:
+ *
+ * [ 'page' => [ 'LEFT JOIN', 'page_latest=rev_id' ] ]
+ *
+ * @return IResultWrapper|bool If the query returned no rows, a IResultWrapper
+ * with no rows in it will be returned. If there was a query error, a
+ * DBQueryError exception will be thrown, except if the "ignore errors"
+ * option was set, in which case false will be returned.
+ */
+ public function select(
+ $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ );
+
+ /**
+ * The equivalent of IDatabase::select() except that the constructed SQL
+ * is returned, instead of being immediately executed. This can be useful for
+ * doing UNION queries, where the SQL text of each query is needed. In general,
+ * however, callers outside of Database classes should just use select().
+ *
+ * @param string|array $table Table name
+ * @param string|array $vars Field names
+ * @param string|array $conds Conditions
+ * @param string $fname Caller function name
+ * @param string|array $options Query options
+ * @param string|array $join_conds Join conditions
+ *
+ * @return string SQL query string.
+ * @see IDatabase::select()
+ */
+ public function selectSQLText(
+ $table, $vars, $conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ );
+
+ /**
+ * Single row SELECT wrapper. Equivalent to IDatabase::select(), except
+ * that a single row object is returned. If the query returns no rows,
+ * false is returned.
+ *
+ * @param string|array $table Table name
+ * @param string|array $vars Field names
+ * @param array $conds Conditions
+ * @param string $fname Caller function name
+ * @param string|array $options Query options
+ * @param array|string $join_conds Join conditions
+ *
+ * @return stdClass|bool
+ */
+ public function selectRow( $table, $vars, $conds, $fname = __METHOD__,
+ $options = [], $join_conds = []
+ );
+
+ /**
+ * Estimate the number of rows in dataset
+ *
+ * MySQL allows you to estimate the number of rows that would be returned
+ * by a SELECT query, using EXPLAIN SELECT. The estimate is provided using
+ * index cardinality statistics, and is notoriously inaccurate, especially
+ * when large numbers of rows have recently been added or deleted.
+ *
+ * For DBMSs that don't support fast result size estimation, this function
+ * will actually perform the SELECT COUNT(*).
+ *
+ * Takes the same arguments as IDatabase::select().
+ *
+ * @param string $table Table name
+ * @param string $vars Unused
+ * @param array|string $conds Filters on the table
+ * @param string $fname Function name for profiling
+ * @param array $options Options for select
+ * @return int Row count
+ */
+ public function estimateRowCount(
+ $table, $vars = '*', $conds = '', $fname = __METHOD__, $options = []
+ );
+
+ /**
+ * Get the number of rows in dataset
+ *
+ * This is useful when trying to do COUNT(*) but with a LIMIT for performance.
+ *
+ * Takes the same arguments as IDatabase::select().
+ *
+ * @since 1.27 Added $join_conds parameter
+ *
+ * @param array|string $tables Table names
+ * @param string $vars Unused
+ * @param array|string $conds Filters on the table
+ * @param string $fname Function name for profiling
+ * @param array $options Options for select
+ * @param array $join_conds Join conditions (since 1.27)
+ * @return int Row count
+ */
+ public function selectRowCount(
+ $tables, $vars = '*', $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
+ );
+
+ /**
+ * Determines whether a field exists in a table
+ *
+ * @param string $table Table name
+ * @param string $field Filed to check on that table
+ * @param string $fname Calling function name (optional)
+ * @return bool Whether $table has filed $field
+ */
+ public function fieldExists( $table, $field, $fname = __METHOD__ );
+
+ /**
+ * Determines whether an index exists
+ * Usually throws a DBQueryError on failure
+ * If errors are explicitly ignored, returns NULL on failure
+ *
+ * @param string $table
+ * @param string $index
+ * @param string $fname
+ * @return bool|null
+ */
+ public function indexExists( $table, $index, $fname = __METHOD__ );
+
+ /**
+ * Query whether a given table exists
+ *
+ * @param string $table
+ * @param string $fname
+ * @return bool
+ */
+ public function tableExists( $table, $fname = __METHOD__ );
+
+ /**
+ * Determines if a given index is unique
+ *
+ * @param string $table
+ * @param string $index
+ *
+ * @return bool
+ */
+ public function indexUnique( $table, $index );
+
+ /**
+ * INSERT wrapper, inserts an array into a table.
+ *
+ * $a may be either:
+ *
+ * - A single associative array. The array keys are the field names, and
+ * the values are the values to insert. The values are treated as data
+ * and will be quoted appropriately. If NULL is inserted, this will be
+ * converted to a database NULL.
+ * - An array with numeric keys, holding a list of associative arrays.
+ * This causes a multi-row INSERT on DBMSs that support it. The keys in
+ * each subarray must be identical to each other, and in the same order.
+ *
+ * Usually throws a DBQueryError on failure. If errors are explicitly ignored,
+ * returns success.
+ *
+ * $options is an array of options, with boolean options encoded as values
+ * with numeric keys, in the same style as $options in
+ * IDatabase::select(). Supported options are:
+ *
+ * - IGNORE: Boolean: if present, duplicate key errors are ignored, and
+ * any rows which cause duplicate key errors are not inserted. It's
+ * possible to determine how many rows were successfully inserted using
+ * IDatabase::affectedRows().
+ *
+ * @param string $table Table name. This will be passed through
+ * Database::tableName().
+ * @param array $a Array of rows to insert
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @param array $options Array of options
+ *
+ * @return bool
+ */
+ public function insert( $table, $a, $fname = __METHOD__, $options = [] );
+
+ /**
+ * UPDATE wrapper. Takes a condition array and a SET array.
+ *
+ * @param string $table Name of the table to UPDATE. This will be passed through
+ * Database::tableName().
+ * @param array $values An array of values to SET. For each array element,
+ * the key gives the field name, and the value gives the data to set
+ * that field to. The data will be quoted by IDatabase::addQuotes().
+ * Values with integer keys form unquoted SET statements, which can be used for
+ * things like "field = field + 1" or similar computed values.
+ * @param array $conds An array of conditions (WHERE). See
+ * IDatabase::select() for the details of the format of condition
+ * arrays. Use '*' to update all rows.
+ * @param string $fname The function name of the caller (from __METHOD__),
+ * for logging and profiling.
+ * @param array $options An array of UPDATE options, can be:
+ * - IGNORE: Ignore unique key conflicts
+ * - LOW_PRIORITY: MySQL-specific, see MySQL manual.
+ * @return bool
+ */
+ public function update( $table, $values, $conds, $fname = __METHOD__, $options = [] );
+
+ /**
+ * Makes an encoded list of strings from an array
+ *
+ * These can be used to make conjunctions or disjunctions on SQL condition strings
+ * derived from an array (see IDatabase::select() $conds documentation).
+ *
+ * Example usage:
+ * @code
+ * $sql = $db->makeList( [
+ * 'rev_user' => $id,
+ * $db->makeList( [ 'rev_minor' => 1, 'rev_len' < 500 ], $db::LIST_OR ] )
+ * ], $db::LIST_AND );
+ * @endcode
+ * This would set $sql to "rev_user = '$id' AND (rev_minor = '1' OR rev_len < '500')"
+ *
+ * @param array $a Containing the data
+ * @param int $mode IDatabase class constant:
+ * - IDatabase::LIST_COMMA: Comma separated, no field names
+ * - IDatabase::LIST_AND: ANDed WHERE clause (without the WHERE).
+ * - IDatabase::LIST_OR: ORed WHERE clause (without the WHERE)
+ * - IDatabase::LIST_SET: Comma separated with field names, like a SET clause
+ * - IDatabase::LIST_NAMES: Comma separated field names
+ * @throws DBError
+ * @return string
+ */
+ public function makeList( $a, $mode = self::LIST_COMMA );
+
+ /**
+ * Build a partial where clause from a 2-d array such as used for LinkBatch.
+ * The keys on each level may be either integers or strings.
+ *
+ * @param array $data Organized as 2-d
+ * [ baseKeyVal => [ subKeyVal => [ignored], ... ], ... ]
+ * @param string $baseKey Field name to match the base-level keys to (eg 'pl_namespace')
+ * @param string $subKey Field name to match the sub-level keys to (eg 'pl_title')
+ * @return string|bool SQL fragment, or false if no items in array
+ */
+ public function makeWhereFrom2d( $data, $baseKey, $subKey );
+
+ /**
+ * Return aggregated value alias
+ *
+ * @param array $valuedata
+ * @param string $valuename
+ *
+ * @return string
+ */
+ public function aggregateValue( $valuedata, $valuename = 'value' );
+
+ /**
+ * @param string $field
+ * @return string
+ */
+ public function bitNot( $field );
+
+ /**
+ * @param string $fieldLeft
+ * @param string $fieldRight
+ * @return string
+ */
+ public function bitAnd( $fieldLeft, $fieldRight );
+
+ /**
+ * @param string $fieldLeft
+ * @param string $fieldRight
+ * @return string
+ */
+ public function bitOr( $fieldLeft, $fieldRight );
+
+ /**
+ * Build a concatenation list to feed into a SQL query
+ * @param array $stringList List of raw SQL expressions; caller is
+ * responsible for any quoting
+ * @return string
+ */
+ public function buildConcat( $stringList );
+
+ /**
+ * Build a GROUP_CONCAT or equivalent statement for a query.
+ *
+ * This is useful for combining a field for several rows into a single string.
+ * NULL values will not appear in the output, duplicated values will appear,
+ * and the resulting delimiter-separated values have no defined sort order.
+ * Code using the results may need to use the PHP unique() or sort() methods.
+ *
+ * @param string $delim Glue to bind the results together
+ * @param string|array $table Table name
+ * @param string $field Field name
+ * @param string|array $conds Conditions
+ * @param string|array $join_conds Join conditions
+ * @return string SQL text
+ * @since 1.23
+ */
+ public function buildGroupConcatField(
+ $delim, $table, $field, $conds = '', $join_conds = []
+ );
+
+ /**
+ * @param string $field Field or column to cast
+ * @return string
+ * @since 1.28
+ */
+ public function buildStringCast( $field );
+
+ /**
+ * Returns true if DBs are assumed to be on potentially different servers
+ *
+ * In systems like mysql/mariadb, different databases can easily be referenced on a single
+ * connection merely by name, even in a single query via JOIN. On the other hand, Postgres
+ * treats databases as fully separate, only allowing mechanisms like postgres_fdw to
+ * effectively "mount" foreign DBs. This is true even among DBs on the same server.
+ *
+ * @return bool
+ * @since 1.29
+ */
+ public function databasesAreIndependent();
+
+ /**
+ * Change the current database
+ *
+ * @param string $db
+ * @return bool Success or failure
+ * @throws DBConnectionError If databasesAreIndependent() is true and an error occurs
+ */
+ public function selectDB( $db );
+
+ /**
+ * Get the current DB name
+ * @return string
+ */
+ public function getDBname();
+
+ /**
+ * Get the server hostname or IP address
+ * @return string
+ */
+ public function getServer();
+
+ /**
+ * Adds quotes and backslashes.
+ *
+ * @param string|int|null|bool|Blob $s
+ * @return string|int
+ */
+ public function addQuotes( $s );
+
+ /**
+ * LIKE statement wrapper, receives a variable-length argument list with
+ * parts of pattern to match containing either string literals that will be
+ * escaped or tokens returned by anyChar() or anyString(). Alternatively,
+ * the function could be provided with an array of aforementioned
+ * parameters.
+ *
+ * Example: $dbr->buildLike( 'My_page_title/', $dbr->anyString() ) returns
+ * a LIKE clause that searches for subpages of 'My page title'.
+ * Alternatively:
+ * $pattern = [ 'My_page_title/', $dbr->anyString() ];
+ * $query .= $dbr->buildLike( $pattern );
+ *
+ * @since 1.16
+ * @return string Fully built LIKE statement
+ */
+ public function buildLike();
+
+ /**
+ * Returns a token for buildLike() that denotes a '_' to be used in a LIKE query
+ *
+ * @return LikeMatch
+ */
+ public function anyChar();
+
+ /**
+ * Returns a token for buildLike() that denotes a '%' to be used in a LIKE query
+ *
+ * @return LikeMatch
+ */
+ public function anyString();
+
+ /**
+ * Deprecated method, calls should be removed.
+ *
+ * This was formerly used for PostgreSQL and Oracle to handle
+ * self::insertId() auto-incrementing fields. It is no longer necessary
+ * since DatabasePostgres::insertId() has been reimplemented using
+ * `lastval()` and Oracle has been reimplemented using triggers.
+ *
+ * Implementations should return null if inserting `NULL` into an
+ * auto-incrementing field works, otherwise it should return an instance of
+ * NextSequenceValue and filter it on calls to relevant methods.
+ *
+ * @deprecated since 1.30, no longer needed
+ * @param string $seqName
+ * @return null|NextSequenceValue
+ */
+ public function nextSequenceValue( $seqName );
+
+ /**
+ * REPLACE query wrapper.
+ *
+ * REPLACE is a very handy MySQL extension, which functions like an INSERT
+ * except that when there is a duplicate key error, the old row is deleted
+ * and the new row is inserted in its place.
+ *
+ * We simulate this with standard SQL with a DELETE followed by INSERT. To
+ * perform the delete, we need to know what the unique indexes are so that
+ * we know how to find the conflicting rows.
+ *
+ * It may be more efficient to leave off unique indexes which are unlikely
+ * to collide. However if you do this, you run the risk of encountering
+ * errors which wouldn't have occurred in MySQL.
+ *
+ * @param string $table The table to replace the row(s) in.
+ * @param array $uniqueIndexes Is an array of indexes. Each element may be either
+ * a field name or an array of field names
+ * @param array $rows Can be either a single row to insert, or multiple rows,
+ * in the same format as for IDatabase::insert()
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ */
+ public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ );
+
+ /**
+ * INSERT ON DUPLICATE KEY UPDATE wrapper, upserts an array into a table.
+ *
+ * This updates any conflicting rows (according to the unique indexes) using
+ * the provided SET clause and inserts any remaining (non-conflicted) rows.
+ *
+ * $rows may be either:
+ * - A single associative array. The array keys are the field names, and
+ * the values are the values to insert. The values are treated as data
+ * and will be quoted appropriately. If NULL is inserted, this will be
+ * converted to a database NULL.
+ * - An array with numeric keys, holding a list of associative arrays.
+ * This causes a multi-row INSERT on DBMSs that support it. The keys in
+ * each subarray must be identical to each other, and in the same order.
+ *
+ * It may be more efficient to leave off unique indexes which are unlikely
+ * to collide. However if you do this, you run the risk of encountering
+ * errors which wouldn't have occurred in MySQL.
+ *
+ * Usually throws a DBQueryError on failure. If errors are explicitly ignored,
+ * returns success.
+ *
+ * @since 1.22
+ *
+ * @param string $table Table name. This will be passed through Database::tableName().
+ * @param array $rows A single row or list of rows to insert
+ * @param array $uniqueIndexes List of single field names or field name tuples
+ * @param array $set An array of values to SET. For each array element, the
+ * key gives the field name, and the value gives the data to set that
+ * field to. The data will be quoted by IDatabase::addQuotes().
+ * Values with integer keys form unquoted SET statements, which can be used for
+ * things like "field = field + 1" or similar computed values.
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @throws Exception
+ * @return bool
+ */
+ public function upsert(
+ $table, array $rows, array $uniqueIndexes, array $set, $fname = __METHOD__
+ );
+
+ /**
+ * DELETE where the condition is a join.
+ *
+ * MySQL overrides this to use a multi-table DELETE syntax, in other databases
+ * we use sub-selects
+ *
+ * For safety, an empty $conds will not delete everything. If you want to
+ * delete all rows where the join condition matches, set $conds='*'.
+ *
+ * DO NOT put the join condition in $conds.
+ *
+ * @param string $delTable The table to delete from.
+ * @param string $joinTable The other table.
+ * @param string $delVar The variable to join on, in the first table.
+ * @param string $joinVar The variable to join on, in the second table.
+ * @param array $conds Condition array of field names mapped to variables,
+ * ANDed together in the WHERE clause
+ * @param string $fname Calling function name (use __METHOD__) for logs/profiling
+ * @throws DBUnexpectedError
+ */
+ public function deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds,
+ $fname = __METHOD__
+ );
+
+ /**
+ * DELETE query wrapper.
+ *
+ * @param string $table Table name
+ * @param string|array $conds Array of conditions. See $conds in IDatabase::select()
+ * for the format. Use $conds == "*" to delete all rows
+ * @param string $fname Name of the calling function
+ * @throws DBUnexpectedError
+ * @return bool|IResultWrapper
+ */
+ public function delete( $table, $conds, $fname = __METHOD__ );
+
+ /**
+ * INSERT SELECT wrapper. Takes data from a SELECT query and inserts it
+ * into another table.
+ *
+ * @param string $destTable The table name to insert into
+ * @param string|array $srcTable May be either a table name, or an array of table names
+ * to include in a join.
+ *
+ * @param array $varMap Must be an associative array of the form
+ * [ 'dest1' => 'source1', ... ]. Source items may be literals
+ * rather than field names, but strings should be quoted with
+ * IDatabase::addQuotes()
+ *
+ * @param array $conds Condition array. See $conds in IDatabase::select() for
+ * the details of the format of condition arrays. May be "*" to copy the
+ * whole table.
+ *
+ * @param string $fname The function name of the caller, from __METHOD__
+ *
+ * @param array $insertOptions Options for the INSERT part of the query, see
+ * IDatabase::insert() for details.
+ * @param array $selectOptions Options for the SELECT part of the query, see
+ * IDatabase::select() for details.
+ * @param array $selectJoinConds Join conditions for the SELECT part of the query, see
+ * IDatabase::select() for details.
+ *
+ * @return bool
+ */
+ public function insertSelect( $destTable, $srcTable, $varMap, $conds,
+ $fname = __METHOD__,
+ $insertOptions = [], $selectOptions = [], $selectJoinConds = []
+ );
+
+ /**
+ * Returns true if current database backend supports ORDER BY or LIMIT for separate subqueries
+ * within the UNION construct.
+ * @return bool
+ */
+ public function unionSupportsOrderAndLimit();
+
+ /**
+ * Construct a UNION query
+ * This is used for providing overload point for other DB abstractions
+ * not compatible with the MySQL syntax.
+ * @param array $sqls SQL statements to combine
+ * @param bool $all Use UNION ALL
+ * @return string SQL fragment
+ */
+ public function unionQueries( $sqls, $all );
+
+ /**
+ * Construct a UNION query for permutations of conditions
+ *
+ * Databases sometimes have trouble with queries that have multiple values
+ * for multiple condition parameters combined with limits and ordering.
+ * This method constructs queries for the Cartesian product of the
+ * conditions and unions them all together.
+ *
+ * @see IDatabase::select()
+ * @since 1.30
+ * @param string|array $table Table name
+ * @param string|array $vars Field names
+ * @param array $permute_conds Conditions for the Cartesian product. Keys
+ * are field names, values are arrays of the possible values for that
+ * field.
+ * @param string|array $extra_conds Additional conditions to include in the
+ * query.
+ * @param string $fname Caller function name
+ * @param string|array $options Query options. In addition to the options
+ * recognized by IDatabase::select(), the following may be used:
+ * - NOTALL: Set to use UNION instead of UNION ALL.
+ * - INNER ORDER BY: If specified and supported, subqueries will use this
+ * instead of ORDER BY.
+ * @param string|array $join_conds Join conditions
+ * @return string SQL query string.
+ */
+ public function unionConditionPermutations(
+ $table, $vars, array $permute_conds, $extra_conds = '', $fname = __METHOD__,
+ $options = [], $join_conds = []
+ );
+
+ /**
+ * Returns an SQL expression for a simple conditional. This doesn't need
+ * to be overridden unless CASE isn't supported in your DBMS.
+ *
+ * @param string|array $cond SQL expression which will result in a boolean value
+ * @param string $trueVal SQL expression to return if true
+ * @param string $falseVal SQL expression to return if false
+ * @return string SQL fragment
+ */
+ public function conditional( $cond, $trueVal, $falseVal );
+
+ /**
+ * Returns a command for str_replace function in SQL query.
+ * Uses REPLACE() in MySQL
+ *
+ * @param string $orig Column to modify
+ * @param string $old Column to seek
+ * @param string $new Column to replace with
+ *
+ * @return string
+ */
+ public function strreplace( $orig, $old, $new );
+
+ /**
+ * Determines how long the server has been up
+ *
+ * @return int
+ */
+ public function getServerUptime();
+
+ /**
+ * Determines if the last failure was due to a deadlock
+ *
+ * @return bool
+ */
+ public function wasDeadlock();
+
+ /**
+ * Determines if the last failure was due to a lock timeout
+ *
+ * @return bool
+ */
+ public function wasLockTimeout();
+
+ /**
+ * Determines if the last query error was due to a dropped connection and should
+ * be dealt with by pinging the connection and reissuing the query.
+ *
+ * @return bool
+ */
+ public function wasErrorReissuable();
+
+ /**
+ * Determines if the last failure was due to the database being read-only.
+ *
+ * @return bool
+ */
+ public function wasReadOnlyError();
+
+ /**
+ * Wait for the replica DB to catch up to a given master position
+ *
+ * @param DBMasterPos $pos
+ * @param int $timeout The maximum number of seconds to wait for synchronisation
+ * @return int|null Zero if the replica DB was past that position already,
+ * greater than zero if we waited for some period of time, less than
+ * zero if it timed out, and null on error
+ */
+ public function masterPosWait( DBMasterPos $pos, $timeout );
+
+ /**
+ * Get the replication position of this replica DB
+ *
+ * @return DBMasterPos|bool False if this is not a replica DB.
+ */
+ public function getReplicaPos();
+
+ /**
+ * Get the position of this master
+ *
+ * @return DBMasterPos|bool False if this is not a master
+ */
+ public function getMasterPos();
+
+ /**
+ * @return bool Whether the DB is marked as read-only server-side
+ * @since 1.28
+ */
+ public function serverIsReadOnly();
+
+ /**
+ * Run a callback as soon as the current transaction commits or rolls back.
+ * An error is thrown if no transaction is pending. Queries in the function will run in
+ * AUTO-COMMIT mode unless there are begin() calls. Callbacks must commit any transactions
+ * that they begin.
+ *
+ * This is useful for combining cooperative locks and DB transactions.
+ *
+ * The callback takes one argument:
+ * - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_ROLLBACK)
+ *
+ * @param callable $callback
+ * @param string $fname Caller name
+ * @return mixed
+ * @since 1.28
+ */
+ public function onTransactionResolution( callable $callback, $fname = __METHOD__ );
+
+ /**
+ * Run a callback as soon as there is no transaction pending.
+ * If there is a transaction and it is rolled back, then the callback is cancelled.
+ * Queries in the function will run in AUTO-COMMIT mode unless there are begin() calls.
+ * Callbacks must commit any transactions that they begin.
+ *
+ * This is useful for updates to different systems or when separate transactions are needed.
+ * For example, one might want to enqueue jobs into a system outside the database, but only
+ * after the database is updated so that the jobs will see the data when they actually run.
+ * It can also be used for updates that easily cause deadlocks if locks are held too long.
+ *
+ * Updates will execute in the order they were enqueued.
+ *
+ * The callback takes one argument:
+ * - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_IDLE)
+ *
+ * @param callable $callback
+ * @param string $fname Caller name
+ * @since 1.20
+ */
+ public function onTransactionIdle( callable $callback, $fname = __METHOD__ );
+
+ /**
+ * Run a callback before the current transaction commits or now if there is none.
+ * If there is a transaction and it is rolled back, then the callback is cancelled.
+ * Callbacks must not start nor commit any transactions. If no transaction is active,
+ * then a transaction will wrap the callback.
+ *
+ * This is useful for updates that easily cause deadlocks if locks are held too long
+ * but where atomicity is strongly desired for these updates and some related updates.
+ *
+ * Updates will execute in the order they were enqueued.
+ *
+ * @param callable $callback
+ * @param string $fname Caller name
+ * @since 1.22
+ */
+ public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ );
+
+ /**
+ * Run a callback each time any transaction commits or rolls back
+ *
+ * The callback takes two arguments:
+ * - IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_ROLLBACK
+ * - This IDatabase object
+ * Callbacks must commit any transactions that they begin.
+ *
+ * Registering a callback here will not affect writesOrCallbacks() pending
+ *
+ * @param string $name Callback name
+ * @param callable|null $callback Use null to unset a listener
+ * @return mixed
+ * @since 1.28
+ */
+ public function setTransactionListener( $name, callable $callback = null );
+
+ /**
+ * Begin an atomic section of statements
+ *
+ * If a transaction has been started already, just keep track of the given
+ * section name to make sure the transaction is not committed pre-maturely.
+ * This function can be used in layers (with sub-sections), so use a stack
+ * to keep track of the different atomic sections. If there is no transaction,
+ * start one implicitly.
+ *
+ * The goal of this function is to create an atomic section of SQL queries
+ * without having to start a new transaction if it already exists.
+ *
+ * All atomic levels *must* be explicitly closed using IDatabase::endAtomic(),
+ * and any database transactions cannot be began or committed until all atomic
+ * levels are closed. There is no such thing as implicitly opening or closing
+ * an atomic section.
+ *
+ * @since 1.23
+ * @param string $fname
+ * @throws DBError
+ */
+ public function startAtomic( $fname = __METHOD__ );
+
+ /**
+ * Ends an atomic section of SQL statements
+ *
+ * Ends the next section of atomic SQL statements and commits the transaction
+ * if necessary.
+ *
+ * @since 1.23
+ * @see IDatabase::startAtomic
+ * @param string $fname
+ * @throws DBError
+ */
+ public function endAtomic( $fname = __METHOD__ );
+
+ /**
+ * Run a callback to do an atomic set of updates for this database
+ *
+ * The $callback takes the following arguments:
+ * - This database object
+ * - The value of $fname
+ *
+ * If any exception occurs in the callback, then rollback() will be called and the error will
+ * be re-thrown. It may also be that the rollback itself fails with an exception before then.
+ * In any case, such errors are expected to terminate the request, without any outside caller
+ * attempting to catch errors and commit anyway. Note that any rollback undoes all prior
+ * atomic section and uncommitted updates, which trashes the current request, requiring an
+ * error to be displayed.
+ *
+ * This can be an alternative to explicit startAtomic()/endAtomic() calls.
+ *
+ * @see Database::startAtomic
+ * @see Database::endAtomic
+ *
+ * @param string $fname Caller name (usually __METHOD__)
+ * @param callable $callback Callback that issues DB updates
+ * @return mixed $res Result of the callback (since 1.28)
+ * @throws DBError
+ * @throws RuntimeException
+ * @throws UnexpectedValueException
+ * @since 1.27
+ */
+ public function doAtomicSection( $fname, callable $callback );
+
+ /**
+ * Begin a transaction. If a transaction is already in progress,
+ * that transaction will be committed before the new transaction is started.
+ *
+ * Only call this from code with outer transcation scope.
+ * See https://www.mediawiki.org/wiki/Database_transactions for details.
+ * Nesting of transactions is not supported.
+ *
+ * Note that when the DBO_TRX flag is set (which is usually the case for web
+ * requests, but not for maintenance scripts), any previous database query
+ * will have started a transaction automatically.
+ *
+ * Nesting of transactions is not supported. Attempts to nest transactions
+ * will cause a warning, unless the current transaction was started
+ * automatically because of the DBO_TRX flag.
+ *
+ * @param string $fname Calling function name
+ * @param string $mode A situationally valid IDatabase::TRANSACTION_* constant [optional]
+ * @throws DBError
+ */
+ public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT );
+
+ /**
+ * Commits a transaction previously started using begin().
+ * If no transaction is in progress, a warning is issued.
+ *
+ * Only call this from code with outer transcation scope.
+ * See https://www.mediawiki.org/wiki/Database_transactions for details.
+ * Nesting of transactions is not supported.
+ *
+ * @param string $fname
+ * @param string $flush Flush flag, set to situationally valid IDatabase::FLUSHING_*
+ * constant to disable warnings about explicitly committing implicit transactions,
+ * or calling commit when no transaction is in progress.
+ *
+ * This will trigger an exception if there is an ongoing explicit transaction.
+ *
+ * Only set the flush flag if you are sure that these warnings are not applicable,
+ * and no explicit transactions are open.
+ *
+ * @throws DBUnexpectedError
+ */
+ public function commit( $fname = __METHOD__, $flush = '' );
+
+ /**
+ * Rollback a transaction previously started using begin().
+ * If no transaction is in progress, a warning is issued.
+ *
+ * Only call this from code with outer transcation scope.
+ * See https://www.mediawiki.org/wiki/Database_transactions for details.
+ * Nesting of transactions is not supported. If a serious unexpected error occurs,
+ * throwing an Exception is preferrable, using a pre-installed error handler to trigger
+ * rollback (in any case, failure to issue COMMIT will cause rollback server-side).
+ *
+ * @param string $fname Calling function name
+ * @param string $flush Flush flag, set to a situationally valid IDatabase::FLUSHING_*
+ * constant to disable warnings about calling rollback when no transaction is in
+ * progress. This will silently break any ongoing explicit transaction. Only set the
+ * flush flag if you are sure that it is safe to ignore these warnings in your context.
+ * @throws DBUnexpectedError
+ * @since 1.23 Added $flush parameter
+ */
+ public function rollback( $fname = __METHOD__, $flush = '' );
+
+ /**
+ * Commit any transaction but error out if writes or callbacks are pending
+ *
+ * This is intended for clearing out REPEATABLE-READ snapshots so that callers can
+ * see a new point-in-time of the database. This is useful when one of many transaction
+ * rounds finished and significant time will pass in the script's lifetime. It is also
+ * useful to call on a replica DB after waiting on replication to catch up to the master.
+ *
+ * @param string $fname Calling function name
+ * @throws DBUnexpectedError
+ * @since 1.28
+ */
+ public function flushSnapshot( $fname = __METHOD__ );
+
+ /**
+ * List all tables on the database
+ *
+ * @param string $prefix Only show tables with this prefix, e.g. mw_
+ * @param string $fname Calling function name
+ * @throws DBError
+ * @return array
+ */
+ public function listTables( $prefix = null, $fname = __METHOD__ );
+
+ /**
+ * Convert a timestamp in one of the formats accepted by wfTimestamp()
+ * to the format used for inserting into timestamp fields in this DBMS.
+ *
+ * The result is unquoted, and needs to be passed through addQuotes()
+ * before it can be included in raw SQL.
+ *
+ * @param string|int $ts
+ *
+ * @return string
+ */
+ public function timestamp( $ts = 0 );
+
+ /**
+ * Convert a timestamp in one of the formats accepted by wfTimestamp()
+ * to the format used for inserting into timestamp fields in this DBMS. If
+ * NULL is input, it is passed through, allowing NULL values to be inserted
+ * into timestamp fields.
+ *
+ * The result is unquoted, and needs to be passed through addQuotes()
+ * before it can be included in raw SQL.
+ *
+ * @param string|int $ts
+ *
+ * @return string
+ */
+ public function timestampOrNull( $ts = null );
+
+ /**
+ * Ping the server and try to reconnect if it there is no connection
+ *
+ * @param float|null &$rtt Value to store the estimated RTT [optional]
+ * @return bool Success or failure
+ */
+ public function ping( &$rtt = null );
+
+ /**
+ * Get replica DB lag. Currently supported only by MySQL.
+ *
+ * Note that this function will generate a fatal error on many
+ * installations. Most callers should use LoadBalancer::safeGetLag()
+ * instead.
+ *
+ * @return int|bool Database replication lag in seconds or false on error
+ */
+ public function getLag();
+
+ /**
+ * Get the replica DB lag when the current transaction started
+ * or a general lag estimate if not transaction is active
+ *
+ * This is useful when transactions might use snapshot isolation
+ * (e.g. REPEATABLE-READ in innodb), so the "real" lag of that data
+ * is this lag plus transaction duration. If they don't, it is still
+ * safe to be pessimistic. In AUTO-COMMIT mode, this still gives an
+ * indication of the staleness of subsequent reads.
+ *
+ * @return array ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN)
+ * @since 1.27
+ */
+ public function getSessionLagStatus();
+
+ /**
+ * Return the maximum number of items allowed in a list, or 0 for unlimited.
+ *
+ * @return int
+ */
+ public function maxListLen();
+
+ /**
+ * Some DBMSs have a special format for inserting into blob fields, they
+ * don't allow simple quoted strings to be inserted. To insert into such
+ * a field, pass the data through this function before passing it to
+ * IDatabase::insert().
+ *
+ * @param string $b
+ * @return string|Blob
+ */
+ public function encodeBlob( $b );
+
+ /**
+ * Some DBMSs return a special placeholder object representing blob fields
+ * in result objects. Pass the object through this function to return the
+ * original string.
+ *
+ * @param string|Blob $b
+ * @return string
+ */
+ public function decodeBlob( $b );
+
+ /**
+ * Override database's default behavior. $options include:
+ * 'connTimeout' : Set the connection timeout value in seconds.
+ * May be useful for very long batch queries such as
+ * full-wiki dumps, where a single query reads out over
+ * hours or days.
+ *
+ * @param array $options
+ * @return void
+ */
+ public function setSessionOptions( array $options );
+
+ /**
+ * Set variables to be used in sourceFile/sourceStream, in preference to the
+ * ones in $GLOBALS. If an array is set here, $GLOBALS will not be used at
+ * all. If it's set to false, $GLOBALS will be used.
+ *
+ * @param bool|array $vars Mapping variable name to value.
+ */
+ public function setSchemaVars( $vars );
+
+ /**
+ * Check to see if a named lock is available (non-blocking)
+ *
+ * @param string $lockName Name of lock to poll
+ * @param string $method Name of method calling us
+ * @return bool
+ * @since 1.20
+ */
+ public function lockIsFree( $lockName, $method );
+
+ /**
+ * Acquire a named lock
+ *
+ * Named locks are not related to transactions
+ *
+ * @param string $lockName Name of lock to aquire
+ * @param string $method Name of the calling method
+ * @param int $timeout Acquisition timeout in seconds
+ * @return bool
+ */
+ public function lock( $lockName, $method, $timeout = 5 );
+
+ /**
+ * Release a lock
+ *
+ * Named locks are not related to transactions
+ *
+ * @param string $lockName Name of lock to release
+ * @param string $method Name of the calling method
+ *
+ * @return int Returns 1 if the lock was released, 0 if the lock was not established
+ * by this thread (in which case the lock is not released), and NULL if the named
+ * lock did not exist
+ */
+ public function unlock( $lockName, $method );
+
+ /**
+ * Acquire a named lock, flush any transaction, and return an RAII style unlocker object
+ *
+ * Only call this from outer transcation scope and when only one DB will be affected.
+ * See https://www.mediawiki.org/wiki/Database_transactions for details.
+ *
+ * This is suitiable for transactions that need to be serialized using cooperative locks,
+ * where each transaction can see each others' changes. Any transaction is flushed to clear
+ * out stale REPEATABLE-READ snapshot data. Once the returned object falls out of PHP scope,
+ * the lock will be released unless a transaction is active. If one is active, then the lock
+ * will be released when it either commits or rolls back.
+ *
+ * If the lock acquisition failed, then no transaction flush happens, and null is returned.
+ *
+ * @param string $lockKey Name of lock to release
+ * @param string $fname Name of the calling method
+ * @param int $timeout Acquisition timeout in seconds
+ * @return ScopedCallback|null
+ * @throws DBUnexpectedError
+ * @since 1.27
+ */
+ public function getScopedLockAndFlush( $lockKey, $fname, $timeout );
+
+ /**
+ * Check to see if a named lock used by lock() use blocking queues
+ *
+ * @return bool
+ * @since 1.26
+ */
+ public function namedLocksEnqueue();
+
+ /**
+ * Find out when 'infinity' is. Most DBMSes support this. This is a special
+ * keyword for timestamps in PostgreSQL, and works with CHAR(14) as well
+ * because "i" sorts after all numbers.
+ *
+ * @return string
+ */
+ public function getInfinity();
+
+ /**
+ * Encode an expiry time into the DBMS dependent format
+ *
+ * @param string $expiry Timestamp for expiry, or the 'infinity' string
+ * @return string
+ */
+ public function encodeExpiry( $expiry );
+
+ /**
+ * Decode an expiry time into a DBMS independent format
+ *
+ * @param string $expiry DB timestamp field value for expiry
+ * @param int $format TS_* constant, defaults to TS_MW
+ * @return string
+ */
+ public function decodeExpiry( $expiry, $format = TS_MW );
+
+ /**
+ * Allow or deny "big selects" for this session only. This is done by setting
+ * the sql_big_selects session variable.
+ *
+ * This is a MySQL-specific feature.
+ *
+ * @param bool|string $value True for allow, false for deny, or "default" to
+ * restore the initial value
+ */
+ public function setBigSelects( $value = true );
+
+ /**
+ * @return bool Whether this DB is read-only
+ * @since 1.27
+ */
+ public function isReadOnly();
+
+ /**
+ * Make certain table names use their own database, schema, and table prefix
+ * when passed into SQL queries pre-escaped and without a qualified database name
+ *
+ * For example, "user" can be converted to "myschema.mydbname.user" for convenience.
+ * Appearances like `user`, somedb.user, somedb.someschema.user will used literally.
+ *
+ * Calling this twice will completely clear any old table aliases. Also, note that
+ * callers are responsible for making sure the schemas and databases actually exist.
+ *
+ * @param array[] $aliases Map of (table => (dbname, schema, prefix) map)
+ * @since 1.28
+ */
+ public function setTableAliases( array $aliases );
+}
+
+class_alias( IDatabase::class, 'IDatabase' );
diff --git a/www/wiki/includes/libs/rdbms/database/IMaintainableDatabase.php b/www/wiki/includes/libs/rdbms/database/IMaintainableDatabase.php
new file mode 100644
index 00000000..fbc2774b
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/IMaintainableDatabase.php
@@ -0,0 +1,281 @@
+<?php
+
+/**
+ * This file deals with database interface functions
+ * and query specifics/optimisations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use Exception;
+use RuntimeException;
+
+/**
+ * Advanced database interface for IDatabase handles that include maintenance methods
+ *
+ * This is useful for type-hints used by installer, upgrader, and background scripts
+ * that will make use of lower-level and longer-running queries, including schema changes.
+ *
+ * @ingroup Database
+ * @since 1.28
+ */
+interface IMaintainableDatabase extends IDatabase {
+ /**
+ * Format a table name ready for use in constructing an SQL query
+ *
+ * This does two important things: it quotes the table names to clean them up,
+ * and it adds a table prefix if only given a table name with no quotes.
+ *
+ * All functions of this object which require a table name call this function
+ * themselves. Pass the canonical name to such functions. This is only needed
+ * when calling query() directly.
+ *
+ * @note This function does not sanitize user input. It is not safe to use
+ * this function to escape user input.
+ * @param string $name Database table name
+ * @param string $format One of:
+ * quoted - Automatically pass the table name through addIdentifierQuotes()
+ * so that it can be used in a query.
+ * raw - Do not add identifier quotes to the table name
+ * @return string Full database name
+ */
+ public function tableName( $name, $format = 'quoted' );
+
+ /**
+ * Fetch a number of table names into an array
+ * This is handy when you need to construct SQL for joins
+ *
+ * Example:
+ * extract( $dbr->tableNames( 'user', 'watchlist' ) );
+ * $sql = "SELECT wl_namespace,wl_title FROM $watchlist,$user
+ * WHERE wl_user=user_id AND wl_user=$nameWithQuotes";
+ *
+ * @return array
+ */
+ public function tableNames();
+
+ /**
+ * Fetch a number of table names into an zero-indexed numerical array
+ * This is handy when you need to construct SQL for joins
+ *
+ * Example:
+ * list( $user, $watchlist ) = $dbr->tableNamesN( 'user', 'watchlist' );
+ * $sql = "SELECT wl_namespace,wl_title FROM $watchlist,$user
+ * WHERE wl_user=user_id AND wl_user=$nameWithQuotes";
+ *
+ * @return array
+ */
+ public function tableNamesN();
+
+ /**
+ * Returns the size of a text field, or -1 for "unlimited"
+ *
+ * @param string $table
+ * @param string $field
+ * @return int
+ */
+ public function textFieldSize( $table, $field );
+
+ /**
+ * Read and execute SQL commands from a file.
+ *
+ * Returns true on success, error string or exception on failure (depending
+ * on object's error ignore settings).
+ *
+ * @param string $filename File name to open
+ * @param callable|null $lineCallback Optional function called before reading each line
+ * @param callable|null $resultCallback Optional function called for each MySQL result
+ * @param bool|string $fname Calling function name or false if name should be
+ * generated dynamically using $filename
+ * @param callable|null $inputCallback Optional function called for each
+ * complete line sent
+ * @return bool|string
+ * @throws Exception
+ */
+ public function sourceFile(
+ $filename,
+ callable $lineCallback = null,
+ callable $resultCallback = null,
+ $fname = false,
+ callable $inputCallback = null
+ );
+
+ /**
+ * Read and execute commands from an open file handle.
+ *
+ * Returns true on success, error string or exception on failure (depending
+ * on object's error ignore settings).
+ *
+ * @param resource $fp File handle
+ * @param callable|null $lineCallback Optional function called before reading each query
+ * @param callable|null $resultCallback Optional function called for each MySQL result
+ * @param string $fname Calling function name
+ * @param callable|null $inputCallback Optional function called for each complete query sent
+ * @return bool|string
+ */
+ public function sourceStream(
+ $fp,
+ callable $lineCallback = null,
+ callable $resultCallback = null,
+ $fname = __METHOD__,
+ callable $inputCallback = null
+ );
+
+ /**
+ * Called by sourceStream() to check if we've reached a statement end
+ *
+ * @param string &$sql SQL assembled so far
+ * @param string &$newLine New line about to be added to $sql
+ * @return bool Whether $newLine contains end of the statement
+ */
+ public function streamStatementEnd( &$sql, &$newLine );
+
+ /**
+ * Delete a table
+ * @param string $tableName
+ * @param string $fName
+ * @return bool|ResultWrapper
+ */
+ public function dropTable( $tableName, $fName = __METHOD__ );
+
+ /**
+ * Perform a deadlock-prone transaction.
+ *
+ * This function invokes a callback function to perform a set of write
+ * queries. If a deadlock occurs during the processing, the transaction
+ * will be rolled back and the callback function will be called again.
+ *
+ * Avoid using this method outside of Job or Maintenance classes.
+ *
+ * Usage:
+ * $dbw->deadlockLoop( callback, ... );
+ *
+ * Extra arguments are passed through to the specified callback function.
+ * This method requires that no transactions are already active to avoid
+ * causing premature commits or exceptions.
+ *
+ * Returns whatever the callback function returned on its successful,
+ * iteration, or false on error, for example if the retry limit was
+ * reached.
+ *
+ * @return mixed
+ * @throws DBUnexpectedError
+ * @throws Exception
+ */
+ public function deadlockLoop();
+
+ /**
+ * Lists all the VIEWs in the database
+ *
+ * @param string $prefix Only show VIEWs with this prefix, eg. unit_test_
+ * @param string $fname Name of calling function
+ * @throws RuntimeException
+ * @return array
+ */
+ public function listViews( $prefix = null, $fname = __METHOD__ );
+
+ /**
+ * Creates a new table with structure copied from existing table
+ *
+ * Note that unlike most database abstraction functions, this function does not
+ * automatically append database prefix, because it works at a lower abstraction level.
+ * The table names passed to this function shall not be quoted (this function calls
+ * addIdentifierQuotes() when needed).
+ *
+ * @param string $oldName Name of table whose structure should be copied
+ * @param string $newName Name of table to be created
+ * @param bool $temporary Whether the new table should be temporary
+ * @param string $fname Calling function name
+ * @return bool True if operation was successful
+ * @throws RuntimeException
+ */
+ public function duplicateTableStructure(
+ $oldName, $newName, $temporary = false, $fname = __METHOD__
+ );
+
+ /**
+ * Checks if table locks acquired by lockTables() are transaction-bound in their scope
+ *
+ * Transaction-bound table locks will be released when the current transaction terminates.
+ * Table locks that are not bound to a transaction are not effected by BEGIN/COMMIT/ROLLBACK
+ * and will last until either lockTables()/unlockTables() is called or the TCP connection to
+ * the database is closed.
+ *
+ * @return bool
+ * @since 1.29
+ */
+ public function tableLocksHaveTransactionScope();
+
+ /**
+ * Lock specific tables
+ *
+ * Any pending transaction should be resolved before calling this method, since:
+ * a) Doing so resets any REPEATABLE-READ snapshot of the data to a fresh one.
+ * b) Previous row and table locks from the transaction or session may be released
+ * by LOCK TABLES, which may be unsafe for the changes in such a transaction.
+ * c) The main use case of lockTables() is to avoid deadlocks and timeouts by locking
+ * entire tables in order to do long-running, batched, and lag-aware, updates. Batching
+ * and replication lag checks do not work when all the updates happen in a transaction.
+ *
+ * Always get all relevant table locks up-front in one call, since LOCK TABLES might release
+ * any prior table locks on some RDBMes (e.g MySQL).
+ *
+ * For compatibility, callers should check tableLocksHaveTransactionScope() before using
+ * this method. If locks are scoped specifically to transactions then caller must either:
+ * - a) Start a new transaction and acquire table locks for the scope of that transaction,
+ * doing all row updates within that transaction. It will not be possible to update
+ * rows in batches; this might result in high replication lag.
+ * - b) Forgo table locks entirely and avoid calling this method. Careful use of hints like
+ * LOCK IN SHARE MODE and FOR UPDATE and the use of query batching may be preferrable
+ * to using table locks with a potentially large transaction. Use of MySQL and Postges
+ * style REPEATABLE-READ (Snapshot Isolation with or without First-Committer-Rule) can
+ * also be considered for certain tasks that require a consistent view of entire tables.
+ *
+ * If session scoped locks are not supported, then calling lockTables() will trigger
+ * startAtomic(), with unlockTables() triggering endAtomic(). This will automatically
+ * start a transaction if one is not already present and cause the locks to be released
+ * when the transaction finishes (normally during the unlockTables() call).
+ *
+ * In any case, avoid using begin()/commit() in code that runs while such table locks are
+ * acquired, as that breaks in case when a transaction is needed. The startAtomic() and
+ * endAtomic() methods are safe, however, since they will join any existing transaction.
+ *
+ * @param array $read Array of tables to lock for read access
+ * @param array $write Array of tables to lock for write access
+ * @param string $method Name of caller
+ * @return bool
+ * @since 1.29
+ */
+ public function lockTables( array $read, array $write, $method );
+
+ /**
+ * Unlock all tables locked via lockTables()
+ *
+ * If table locks are scoped to transactions, then locks might not be released until the
+ * transaction ends, which could happen after this method is called.
+ *
+ * @param string $method The caller
+ * @return bool
+ * @since 1.29
+ */
+ public function unlockTables( $method );
+}
+
+class_alias( IMaintainableDatabase::class, 'IMaintainableDatabase' );
diff --git a/www/wiki/includes/libs/rdbms/database/MaintainableDBConnRef.php b/www/wiki/includes/libs/rdbms/database/MaintainableDBConnRef.php
new file mode 100644
index 00000000..6c94eb9a
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/MaintainableDBConnRef.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Helper class to handle automatically marking connections as reusable (via RAII pattern)
+ * as well handling deferring the actual network connection until the handle is used
+ *
+ * @note: proxy methods are defined explicity to avoid interface errors
+ * @ingroup Database
+ * @since 1.29
+ */
+class MaintainableDBConnRef extends DBConnRef implements IMaintainableDatabase {
+ public function tableName( $name, $format = 'quoted' ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function tableNames() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function tableNamesN() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function sourceFile(
+ $filename,
+ callable $lineCallback = null,
+ callable $resultCallback = null,
+ $fname = false,
+ callable $inputCallback = null
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function sourceStream(
+ $fp,
+ callable $lineCallback = null,
+ callable $resultCallback = null,
+ $fname = __METHOD__,
+ callable $inputCallback = null
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function dropTable( $tableName, $fName = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function deadlockLoop() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function listViews( $prefix = null, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function textFieldSize( $table, $field ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function streamStatementEnd( &$sql, &$newLine ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function duplicateTableStructure(
+ $oldName, $newName, $temporary = false, $fname = __METHOD__
+ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function tableLocksHaveTransactionScope() {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function lockTables( array $read, array $write, $method ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function unlockTables( $method ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+}
+
+class_alias( MaintainableDBConnRef::class, 'MaintainableDBConnRef' );
diff --git a/www/wiki/includes/libs/rdbms/database/position/DBMasterPos.php b/www/wiki/includes/libs/rdbms/database/position/DBMasterPos.php
new file mode 100644
index 00000000..2f79ea9a
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/position/DBMasterPos.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * An object representing a master or replica DB position in a replicated setup.
+ *
+ * The implementation details of this opaque type are up to the database subclass.
+ */
+interface DBMasterPos {
+ /**
+ * @return float UNIX timestamp
+ * @since 1.25
+ */
+ public function asOfTime();
+
+ /**
+ * @param DBMasterPos $pos
+ * @return bool Whether this position is at or higher than $pos
+ * @since 1.27
+ */
+ public function hasReached( DBMasterPos $pos );
+
+ /**
+ * @param DBMasterPos $pos
+ * @return bool Whether this position appears to be for the same channel as another
+ * @since 1.27
+ */
+ public function channelsMatch( DBMasterPos $pos );
+
+ /**
+ * @return string
+ * @since 1.27
+ */
+ public function __toString();
+}
diff --git a/www/wiki/includes/libs/rdbms/database/position/MySQLMasterPos.php b/www/wiki/includes/libs/rdbms/database/position/MySQLMasterPos.php
new file mode 100644
index 00000000..0657cf3d
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/position/MySQLMasterPos.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
+/**
+ * DBMasterPos class for MySQL/MariaDB
+ *
+ * Note that master positions and sync logic here make some assumptions:
+ * - Binlog-based usage assumes single-source replication and non-hierarchical replication.
+ * - GTID-based usage allows getting/syncing with multi-source replication. It is assumed
+ * that GTID sets are complete (e.g. include all domains on the server).
+ */
+class MySQLMasterPos implements DBMasterPos {
+ /** @var string Binlog file */
+ public $file;
+ /** @var int Binglog file position */
+ public $pos;
+ /** @var string[] GTID list */
+ public $gtids = [];
+ /** @var float UNIX timestamp */
+ public $asOfTime = 0.0;
+
+ /**
+ * @param string $file Binlog file name
+ * @param int $pos Binlog position
+ * @param string $gtid Comma separated GTID set [optional]
+ */
+ function __construct( $file, $pos, $gtid = '' ) {
+ $this->file = $file;
+ $this->pos = $pos;
+ $this->gtids = array_map( 'trim', explode( ',', $gtid ) );
+ $this->asOfTime = microtime( true );
+ }
+
+ /**
+ * @return string <binlog file>/<position>, e.g db1034-bin.000976/843431247
+ */
+ function __toString() {
+ return "{$this->file}/{$this->pos}";
+ }
+
+ function asOfTime() {
+ return $this->asOfTime;
+ }
+
+ function hasReached( DBMasterPos $pos ) {
+ if ( !( $pos instanceof self ) ) {
+ throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
+ }
+
+ // Prefer GTID comparisons, which work with multi-tier replication
+ $thisPosByDomain = $this->getGtidCoordinates();
+ $thatPosByDomain = $pos->getGtidCoordinates();
+ if ( $thisPosByDomain && $thatPosByDomain ) {
+ $reached = true;
+ // Check that this has positions GTE all of those in $pos for all domains in $pos
+ foreach ( $thatPosByDomain as $domain => $thatPos ) {
+ $thisPos = isset( $thisPosByDomain[$domain] ) ? $thisPosByDomain[$domain] : -1;
+ $reached = $reached && ( $thatPos <= $thisPos );
+ }
+
+ return $reached;
+ }
+
+ // Fallback to the binlog file comparisons
+ $thisBinPos = $this->getBinlogCoordinates();
+ $thatBinPos = $pos->getBinlogCoordinates();
+ if ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] ) {
+ return ( $thisBinPos['pos'] >= $thatBinPos['pos'] );
+ }
+
+ // Comparing totally different binlogs does not make sense
+ return false;
+ }
+
+ function channelsMatch( DBMasterPos $pos ) {
+ if ( !( $pos instanceof self ) ) {
+ throw new InvalidArgumentException( "Position not an instance of " . __CLASS__ );
+ }
+
+ // Prefer GTID comparisons, which work with multi-tier replication
+ $thisPosDomains = array_keys( $this->getGtidCoordinates() );
+ $thatPosDomains = array_keys( $pos->getGtidCoordinates() );
+ if ( $thisPosDomains && $thatPosDomains ) {
+ // Check that this has GTIDs for all domains in $pos
+ return !array_diff( $thatPosDomains, $thisPosDomains );
+ }
+
+ // Fallback to the binlog file comparisons
+ $thisBinPos = $this->getBinlogCoordinates();
+ $thatBinPos = $pos->getBinlogCoordinates();
+
+ return ( $thisBinPos && $thatBinPos && $thisBinPos['binlog'] === $thatBinPos['binlog'] );
+ }
+
+ /**
+ * @note: this returns false for multi-source replication GTID sets
+ * @see https://mariadb.com/kb/en/mariadb/gtid
+ * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
+ * @return array Map of (domain => integer position) or false
+ */
+ protected function getGtidCoordinates() {
+ $gtidInfos = [];
+ foreach ( $this->gtids as $gtid ) {
+ $m = [];
+ // MariaDB style: <domain>-<server id>-<sequence number>
+ if ( preg_match( '!^(\d+)-\d+-(\d+)$!', $gtid, $m ) ) {
+ $gtidInfos[(int)$m[1]] = (int)$m[2];
+ // MySQL style: <UUID domain>:<sequence number>
+ } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(\d+)$!', $gtid, $m ) ) {
+ $gtidInfos[$m[1]] = (int)$m[2];
+ } else {
+ $gtidInfos = [];
+ break; // unrecognized GTID
+ }
+
+ }
+
+ return $gtidInfos;
+ }
+
+ /**
+ * @see https://dev.mysql.com/doc/refman/5.7/en/show-master-status.html
+ * @see https://dev.mysql.com/doc/refman/5.7/en/show-slave-status.html
+ * @return array|bool (binlog, (integer file number, integer position)) or false
+ */
+ protected function getBinlogCoordinates() {
+ $m = [];
+ if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', (string)$this, $m ) ) {
+ return [ 'binlog' => $m[1], 'pos' => [ (int)$m[2], (int)$m[3] ] ];
+ }
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php b/www/wiki/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php
new file mode 100644
index 00000000..12e59b59
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+use stdClass;
+
+/**
+ * Overloads the relevant methods of the real ResultsWrapper so it
+ * doesn't go anywhere near an actual database.
+ */
+class FakeResultWrapper extends ResultWrapper {
+ /** @var stdClass[] $result */
+
+ /**
+ * @param stdClass[] $rows
+ */
+ function __construct( array $rows ) {
+ parent::__construct( null, $rows );
+ }
+
+ function numRows() {
+ return count( $this->result );
+ }
+
+ function fetchRow() {
+ if ( $this->pos < count( $this->result ) ) {
+ $this->currentRow = $this->result[$this->pos];
+ } else {
+ $this->currentRow = false;
+ }
+ $this->pos++;
+ if ( is_object( $this->currentRow ) ) {
+ return get_object_vars( $this->currentRow );
+ } else {
+ return $this->currentRow;
+ }
+ }
+
+ function seek( $row ) {
+ $this->pos = $row;
+ }
+
+ function free() {
+ }
+
+ function fetchObject() {
+ $this->fetchRow();
+ if ( $this->currentRow ) {
+ return (object)$this->currentRow;
+ } else {
+ return false;
+ }
+ }
+
+ function rewind() {
+ $this->pos = 0;
+ $this->currentRow = null;
+ }
+
+ function next() {
+ return $this->fetchObject();
+ }
+}
+
+class_alias( FakeResultWrapper::class, 'FakeResultWrapper' );
diff --git a/www/wiki/includes/libs/rdbms/database/resultwrapper/IResultWrapper.php b/www/wiki/includes/libs/rdbms/database/resultwrapper/IResultWrapper.php
new file mode 100644
index 00000000..debf8a27
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/resultwrapper/IResultWrapper.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+use Iterator;
+use stdClass;
+
+/**
+ * Result wrapper for grabbing data queried from an IDatabase object
+ *
+ * Note that using the Iterator methods in combination with the non-Iterator
+ * DB result iteration functions may cause rows to be skipped or repeated.
+ *
+ * By default, this will use the iteration methods of the IDatabase handle if provided.
+ * Subclasses can override methods to make it solely work on the result resource instead.
+ * If no database is provided, and the subclass does not override the DB iteration methods,
+ * then a RuntimeException will be thrown when iteration is attempted.
+ *
+ * The result resource field should not be accessed from non-Database related classes.
+ * It is database class specific and is stored here to associate iterators with queries.
+ *
+ * @ingroup Database
+ */
+interface IResultWrapper extends Iterator {
+ /**
+ * Get the number of rows in a result object
+ *
+ * @return int
+ */
+ public function numRows();
+
+ /**
+ * Fetch the next row from the given result object, in object form. Fields can be retrieved with
+ * $row->fieldname, with fields acting like member variables. If no more rows are available,
+ * false is returned.
+ *
+ * @return stdClass|bool
+ * @throws DBUnexpectedError Thrown if the database returns an error
+ */
+ public function fetchObject();
+
+ /**
+ * Fetch the next row from the given result object, in associative array form. Fields are
+ * retrieved with $row['fieldname']. If no more rows are available, false is returned.
+ *
+ * @return array|bool
+ * @throws DBUnexpectedError Thrown if the database returns an error
+ */
+ public function fetchRow();
+
+ /**
+ * Change the position of the cursor in a result object.
+ * See mysql_data_seek()
+ *
+ * @param int $row
+ */
+ public function seek( $row );
+
+ /**
+ * Free a result object
+ *
+ * This either saves memory in PHP (buffered queries) or on the server (unbuffered queries).
+ * In general, queries are not large enough in result sets for this to be worth calling.
+ */
+ public function free();
+
+ /**
+ * @return stdClass|array|bool
+ */
+ public function current();
+
+ /**
+ * @return int
+ */
+ public function key();
+
+ /**
+ * @return stdClass
+ */
+ function next();
+}
diff --git a/www/wiki/includes/libs/rdbms/database/resultwrapper/MssqlResultWrapper.php b/www/wiki/includes/libs/rdbms/database/resultwrapper/MssqlResultWrapper.php
new file mode 100644
index 00000000..298ec619
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/resultwrapper/MssqlResultWrapper.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+use stdClass;
+
+class MssqlResultWrapper extends ResultWrapper {
+ /** @var int|null */
+ private $mSeekTo = null;
+
+ /**
+ * @return stdClass|bool
+ */
+ public function fetchObject() {
+ $res = $this->result;
+
+ if ( $this->mSeekTo !== null ) {
+ $result = sqlsrv_fetch_object( $res, 'stdClass', [],
+ SQLSRV_SCROLL_ABSOLUTE, $this->mSeekTo );
+ $this->mSeekTo = null;
+ } else {
+ $result = sqlsrv_fetch_object( $res );
+ }
+
+ // Return boolean false when there are no more rows instead of null
+ if ( $result === null ) {
+ return false;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array|bool
+ */
+ public function fetchRow() {
+ $res = $this->result;
+
+ if ( $this->mSeekTo !== null ) {
+ $result = sqlsrv_fetch_array( $res, SQLSRV_FETCH_BOTH,
+ SQLSRV_SCROLL_ABSOLUTE, $this->mSeekTo );
+ $this->mSeekTo = null;
+ } else {
+ $result = sqlsrv_fetch_array( $res );
+ }
+
+ // Return boolean false when there are no more rows instead of null
+ if ( $result === null ) {
+ return false;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param int $row
+ * @return bool
+ */
+ public function seek( $row ) {
+ $res = $this->result;
+
+ // check bounds
+ $numRows = $this->db->numRows( $res );
+ $row = intval( $row );
+
+ if ( $numRows === 0 ) {
+ return false;
+ } elseif ( $row < 0 || $row > $numRows - 1 ) {
+ return false;
+ }
+
+ // Unlike MySQL, the seek actually happens on the next access
+ $this->mSeekTo = $row;
+ return true;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php b/www/wiki/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php
new file mode 100644
index 00000000..df354af8
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/resultwrapper/ResultWrapper.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+use stdClass;
+use RuntimeException;
+
+/**
+ * Result wrapper for grabbing data queried from an IDatabase object
+ *
+ * Note that using the Iterator methods in combination with the non-Iterator
+ * DB result iteration functions may cause rows to be skipped or repeated.
+ *
+ * By default, this will use the iteration methods of the IDatabase handle if provided.
+ * Subclasses can override methods to make it solely work on the result resource instead.
+ * If no database is provided, and the subclass does not override the DB iteration methods,
+ * then a RuntimeException will be thrown when iteration is attempted.
+ *
+ * The result resource field should not be accessed from non-Database related classes.
+ * It is database class specific and is stored here to associate iterators with queries.
+ *
+ * @ingroup Database
+ */
+class ResultWrapper implements IResultWrapper {
+ /** @var resource|array|null Optional underlying result handle for subclass usage */
+ public $result;
+
+ /** @var IDatabase|null */
+ protected $db;
+
+ /** @var int */
+ protected $pos = 0;
+ /** @var stdClass|null */
+ protected $currentRow = null;
+
+ /**
+ * Create a row iterator from a result resource and an optional Database object
+ *
+ * Only Database-related classes should construct ResultWrapper. Other code may
+ * use the FakeResultWrapper subclass for convenience or compatibility shims, however.
+ *
+ * @param IDatabase|null $db Optional database handle
+ * @param ResultWrapper|array|resource $result Optional underlying result handle
+ */
+ public function __construct( IDatabase $db = null, $result ) {
+ $this->db = $db;
+ if ( $result instanceof ResultWrapper ) {
+ $this->result = $result->result;
+ } else {
+ $this->result = $result;
+ }
+ }
+
+ public function numRows() {
+ return $this->getDB()->numRows( $this );
+ }
+
+ public function fetchObject() {
+ return $this->getDB()->fetchObject( $this );
+ }
+
+ public function fetchRow() {
+ return $this->getDB()->fetchRow( $this );
+ }
+
+ public function seek( $row ) {
+ $this->getDB()->dataSeek( $this, $row );
+ }
+
+ public function free() {
+ if ( $this->db ) {
+ $this->db->freeResult( $this );
+ $this->db = null;
+ }
+ $this->result = null;
+ }
+
+ /**
+ * @return IDatabase
+ * @throws RuntimeException
+ */
+ private function getDB() {
+ if ( !$this->db ) {
+ throw new RuntimeException( static::class . ' needs a DB handle for iteration.' );
+ }
+
+ return $this->db;
+ }
+
+ function rewind() {
+ if ( $this->numRows() ) {
+ $this->getDB()->dataSeek( $this, 0 );
+ }
+ $this->pos = 0;
+ $this->currentRow = null;
+ }
+
+ function current() {
+ if ( is_null( $this->currentRow ) ) {
+ $this->next();
+ }
+
+ return $this->currentRow;
+ }
+
+ function key() {
+ return $this->pos;
+ }
+
+ function next() {
+ $this->pos++;
+ $this->currentRow = $this->fetchObject();
+
+ return $this->currentRow;
+ }
+
+ function valid() {
+ return $this->current() !== false;
+ }
+}
+
+class_alias( ResultWrapper::class, 'ResultWrapper' );
diff --git a/www/wiki/includes/libs/rdbms/database/utils/NextSequenceValue.php b/www/wiki/includes/libs/rdbms/database/utils/NextSequenceValue.php
new file mode 100644
index 00000000..44bf0ddc
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/utils/NextSequenceValue.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Used by Database::nextSequenceValue() so Database::insert() can detect
+ * values coming from the deprecated function.
+ * @since 1.30
+ * @deprecated since 1.30, only exists for backwards compatibility
+ */
+class NextSequenceValue {
+}
diff --git a/www/wiki/includes/libs/rdbms/database/utils/SavepointPostgres.php b/www/wiki/includes/libs/rdbms/database/utils/SavepointPostgres.php
new file mode 100644
index 00000000..cf5060e4
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/database/utils/SavepointPostgres.php
@@ -0,0 +1,104 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Manage savepoints within a transaction
+ * @ingroup Database
+ * @since 1.19
+ */
+class SavepointPostgres {
+ /** @var DatabasePostgres Establish a savepoint within a transaction */
+ protected $dbw;
+ /** @var LoggerInterface */
+ protected $logger;
+ /** @var int */
+ protected $id;
+ /** @var bool */
+ protected $didbegin;
+
+ /**
+ * @param DatabasePostgres $dbw
+ * @param int $id
+ * @param LoggerInterface $logger
+ */
+ public function __construct( DatabasePostgres $dbw, $id, LoggerInterface $logger ) {
+ $this->dbw = $dbw;
+ $this->logger = $logger;
+ $this->id = $id;
+ $this->didbegin = false;
+ /* If we are not in a transaction, we need to be for savepoint trickery */
+ if ( !$dbw->trxLevel() ) {
+ $dbw->begin( __CLASS__, DatabasePostgres::TRANSACTION_INTERNAL );
+ $this->didbegin = true;
+ }
+ }
+
+ public function __destruct() {
+ if ( $this->didbegin ) {
+ $this->dbw->rollback();
+ $this->didbegin = false;
+ }
+ }
+
+ public function commit() {
+ if ( $this->didbegin ) {
+ $this->dbw->commit( __CLASS__, DatabasePostgres::FLUSHING_INTERNAL );
+ $this->didbegin = false;
+ }
+ }
+
+ protected function query( $keyword, $msg_ok, $msg_failed ) {
+ if ( $this->dbw->doQuery( $keyword . " " . $this->id ) !== false ) {
+ $this->logger->debug( sprintf( $msg_ok, $this->id ) );
+ } else {
+ $this->logger->debug( sprintf( $msg_failed, $this->id ) );
+ }
+ }
+
+ public function savepoint() {
+ $this->query( "SAVEPOINT",
+ "Transaction state: savepoint \"%s\" established.\n",
+ "Transaction state: establishment of savepoint \"%s\" FAILED.\n"
+ );
+ }
+
+ public function release() {
+ $this->query( "RELEASE",
+ "Transaction state: savepoint \"%s\" released.\n",
+ "Transaction state: release of savepoint \"%s\" FAILED.\n"
+ );
+ }
+
+ public function rollback() {
+ $this->query( "ROLLBACK TO",
+ "Transaction state: savepoint \"%s\" rolled back.\n",
+ "Transaction state: rollback of savepoint \"%s\" FAILED.\n"
+ );
+ }
+
+ public function __toString() {
+ return (string)$this->id;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/defines.php b/www/wiki/includes/libs/rdbms/defines.php
new file mode 100644
index 00000000..cbc8ca31
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/defines.php
@@ -0,0 +1,27 @@
+<?php
+
+use Wikimedia\Rdbms\ILoadBalancer;
+use Wikimedia\Rdbms\IDatabase;
+
+/**@{
+ * Database related constants
+ */
+define( 'DBO_DEBUG', IDatabase::DBO_DEBUG );
+define( 'DBO_NOBUFFER', IDatabase::DBO_NOBUFFER );
+define( 'DBO_IGNORE', IDatabase::DBO_IGNORE );
+define( 'DBO_TRX', IDatabase::DBO_TRX );
+define( 'DBO_DEFAULT', IDatabase::DBO_DEFAULT );
+define( 'DBO_PERSISTENT', IDatabase::DBO_PERSISTENT );
+define( 'DBO_SYSDBA', IDatabase::DBO_SYSDBA );
+define( 'DBO_DDLMODE', IDatabase::DBO_DDLMODE );
+define( 'DBO_SSL', IDatabase::DBO_SSL );
+define( 'DBO_COMPRESS', IDatabase::DBO_COMPRESS );
+/**@}*/
+
+/**@{
+ * Valid database indexes
+ * Operation-based indexes
+ */
+define( 'DB_REPLICA', ILoadBalancer::DB_REPLICA );
+define( 'DB_MASTER', ILoadBalancer::DB_MASTER );
+/**@}*/
diff --git a/www/wiki/includes/libs/rdbms/encasing/Blob.php b/www/wiki/includes/libs/rdbms/encasing/Blob.php
new file mode 100644
index 00000000..e2d685cb
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/encasing/Blob.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+class Blob implements IBlob {
+ /** @var string */
+ protected $mData;
+
+ /**
+ * @param string $data
+ */
+ public function __construct( $data ) {
+ $this->mData = $data;
+ }
+
+ public function fetch() {
+ return $this->mData;
+ }
+}
+
+class_alias( Blob::class, 'Blob' );
diff --git a/www/wiki/includes/libs/rdbms/encasing/IBlob.php b/www/wiki/includes/libs/rdbms/encasing/IBlob.php
new file mode 100644
index 00000000..b1d7aae4
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/encasing/IBlob.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Wrapper allowing us to distinguish a blob from a normal string and an array of strings
+ * @ingroup Database
+ */
+interface IBlob {
+ /**
+ * @return string
+ */
+ public function fetch();
+}
diff --git a/www/wiki/includes/libs/rdbms/encasing/LikeMatch.php b/www/wiki/includes/libs/rdbms/encasing/LikeMatch.php
new file mode 100644
index 00000000..98812a5a
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/encasing/LikeMatch.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Used by Database::buildLike() to represent characters that have special
+ * meaning in SQL LIKE clauses and thus need no escaping. Don't instantiate it
+ * manually, use Database::anyChar() and anyString() instead.
+ */
+class LikeMatch {
+ /** @var string */
+ private $str;
+
+ /**
+ * Store a string into a LikeMatch marker object.
+ *
+ * @param string $s
+ */
+ public function __construct( $s ) {
+ $this->str = $s;
+ }
+
+ /**
+ * Return the original stored string.
+ *
+ * @return string
+ */
+ public function toString() {
+ return $this->str;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/encasing/MssqlBlob.php b/www/wiki/includes/libs/rdbms/encasing/MssqlBlob.php
new file mode 100644
index 00000000..aacdf402
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/encasing/MssqlBlob.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+class MssqlBlob extends Blob {
+ /** @noinspection PhpMissingParentConstructorInspection */
+
+ /**
+ * @param string $data
+ */
+ public function __construct( $data ) {
+ if ( $data instanceof MssqlBlob ) {
+ return $data;
+ } elseif ( $data instanceof Blob ) {
+ $this->mData = $data->fetch();
+ } elseif ( is_array( $data ) && is_object( $data ) ) {
+ $this->mData = serialize( $data );
+ } else {
+ $this->mData = $data;
+ }
+ }
+
+ /**
+ * Returns an unquoted hex representation of a binary string
+ * for insertion into varbinary-type fields
+ * @return string
+ */
+ public function fetch() {
+ if ( $this->mData === null ) {
+ return 'null';
+ }
+
+ $ret = '0x';
+ $dataLength = strlen( $this->mData );
+ for ( $i = 0; $i < $dataLength; $i++ ) {
+ $ret .= bin2hex( pack( 'C', ord( $this->mData[$i] ) ) );
+ }
+
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/encasing/PostgresBlob.php b/www/wiki/includes/libs/rdbms/encasing/PostgresBlob.php
new file mode 100644
index 00000000..7994b730
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/encasing/PostgresBlob.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+class PostgresBlob extends Blob {
+
+}
diff --git a/www/wiki/includes/libs/rdbms/exception/DBAccessError.php b/www/wiki/includes/libs/rdbms/exception/DBAccessError.php
new file mode 100644
index 00000000..97e03b26
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBAccessError.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Exception class for attempted DB access
+ * @ingroup Database
+ */
+class DBAccessError extends DBUnexpectedError {
+ public function __construct() {
+ parent::__construct( null, "Database access has been disabled." );
+ }
+}
+
+class_alias( DBAccessError::class, 'DBAccessError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBConnectionError.php b/www/wiki/includes/libs/rdbms/exception/DBConnectionError.php
new file mode 100644
index 00000000..91d98dc1
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBConnectionError.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+/**
+ * @ingroup Database
+ */
+class DBConnectionError extends DBExpectedError {
+ /**
+ * @param IDatabase $db Object throwing the error
+ * @param string $error Error text
+ */
+ public function __construct( IDatabase $db = null, $error = 'unknown error' ) {
+ $msg = 'Cannot access the database';
+ if ( trim( $error ) != '' ) {
+ $msg .= ": $error";
+ }
+
+ parent::__construct( $db, $msg );
+ }
+}
+
+class_alias( DBConnectionError::class, 'DBConnectionError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBError.php b/www/wiki/includes/libs/rdbms/exception/DBError.php
new file mode 100644
index 00000000..2f7499bc
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBError.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use Exception;
+
+/**
+ * Database error base class
+ * @ingroup Database
+ */
+class DBError extends Exception {
+ /** @var IDatabase|null */
+ public $db;
+
+ /**
+ * Construct a database error
+ * @param IDatabase $db Object which threw the error
+ * @param string $error A simple error message to be used for debugging
+ */
+ public function __construct( IDatabase $db = null, $error ) {
+ $this->db = $db;
+ parent::__construct( $error );
+ }
+}
+
+class_alias( DBError::class, 'DBError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBExpectedError.php b/www/wiki/includes/libs/rdbms/exception/DBExpectedError.php
new file mode 100644
index 00000000..31d8c27d
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBExpectedError.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use MessageSpecifier;
+use ILocalizedException;
+use Message;
+
+/**
+ * Base class for the more common types of database errors. These are known to occur
+ * frequently, so we try to give friendly error messages for them.
+ *
+ * @ingroup Database
+ * @since 1.23
+ */
+class DBExpectedError extends DBError implements MessageSpecifier, ILocalizedException {
+ /** @var string[] Message parameters */
+ protected $params;
+
+ public function __construct( IDatabase $db = null, $error, array $params = [] ) {
+ parent::__construct( $db, $error );
+ $this->params = $params;
+ }
+
+ public function getKey() {
+ return 'databaseerror-text';
+ }
+
+ public function getParams() {
+ return $this->params;
+ }
+
+ /**
+ * @inheritDoc
+ * @since 1.29
+ */
+ public function getMessageObject() {
+ return Message::newFromSpecifier( $this );
+ }
+}
+
+class_alias( DBExpectedError::class, 'DBExpectedError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBQueryError.php b/www/wiki/includes/libs/rdbms/exception/DBQueryError.php
new file mode 100644
index 00000000..a8ea3ade
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBQueryError.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * @ingroup Database
+ */
+class DBQueryError extends DBExpectedError {
+ /** @var string */
+ public $error;
+ /** @var int */
+ public $errno;
+ /** @var string */
+ public $sql;
+ /** @var string */
+ public $fname;
+
+ /**
+ * @param IDatabase $db
+ * @param string $error
+ * @param int|string $errno
+ * @param string $sql
+ * @param string $fname
+ */
+ public function __construct( IDatabase $db, $error, $errno, $sql, $fname ) {
+ if ( $db instanceof Database && $db->wasConnectionError( $errno ) ) {
+ $message = "A connection error occured. \n" .
+ "Query: $sql\n" .
+ "Function: $fname\n" .
+ "Error: $errno $error\n";
+ } else {
+ $message = "A database query error has occurred. Did you forget to run " .
+ "your application's database schema updater after upgrading? \n" .
+ "Query: $sql\n" .
+ "Function: $fname\n" .
+ "Error: $errno $error\n";
+ }
+
+ parent::__construct( $db, $message );
+
+ $this->error = $error;
+ $this->errno = $errno;
+ $this->sql = $sql;
+ $this->fname = $fname;
+ }
+}
+
+class_alias( DBQueryError::class, 'DBQueryError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBReadOnlyError.php b/www/wiki/includes/libs/rdbms/exception/DBReadOnlyError.php
new file mode 100644
index 00000000..43933439
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBReadOnlyError.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * @ingroup Database
+ */
+class DBReadOnlyError extends DBExpectedError {
+}
+
+class_alias( DBReadOnlyError::class, 'DBReadOnlyError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBReplicationWaitError.php b/www/wiki/includes/libs/rdbms/exception/DBReplicationWaitError.php
new file mode 100644
index 00000000..457431e9
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBReplicationWaitError.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Exception class for replica DB wait timeouts
+ * @ingroup Database
+ */
+class DBReplicationWaitError extends DBExpectedError {
+}
+
+class_alias( DBReplicationWaitError::class, 'DBReplicationWaitError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBTransactionError.php b/www/wiki/includes/libs/rdbms/exception/DBTransactionError.php
new file mode 100644
index 00000000..62a078cd
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBTransactionError.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * @ingroup Database
+ */
+class DBTransactionError extends DBExpectedError {
+}
+
+class_alias( DBTransactionError::class, 'DBTransactionError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBTransactionSizeError.php b/www/wiki/includes/libs/rdbms/exception/DBTransactionSizeError.php
new file mode 100644
index 00000000..d2622e11
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBTransactionSizeError.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * @ingroup Database
+ */
+class DBTransactionSizeError extends DBTransactionError {
+ public function getKey() {
+ return 'transaction-duration-limit-exceeded';
+ }
+}
+
+class_alias( DBTransactionSizeError::class, 'DBTransactionSizeError' );
diff --git a/www/wiki/includes/libs/rdbms/exception/DBUnexpectedError.php b/www/wiki/includes/libs/rdbms/exception/DBUnexpectedError.php
new file mode 100644
index 00000000..9c67eb5f
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/exception/DBUnexpectedError.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * @ingroup Database
+ */
+class DBUnexpectedError extends DBError {
+}
+
+class_alias( DBUnexpectedError::class, 'DBUnexpectedError' );
diff --git a/www/wiki/includes/libs/rdbms/field/Field.php b/www/wiki/includes/libs/rdbms/field/Field.php
new file mode 100644
index 00000000..7918f360
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/field/Field.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Base for all database-specific classes representing information about database fields
+ * @ingroup Database
+ */
+interface Field {
+ /**
+ * Field name
+ * @return string
+ */
+ function name();
+
+ /**
+ * Name of table this field belongs to
+ * @return string
+ */
+ function tableName();
+
+ /**
+ * Database type
+ * @return string
+ */
+ function type();
+
+ /**
+ * Whether this field can store NULL values
+ * @return bool
+ */
+ function isNullable();
+}
+
+class_alias( Field::class, 'Field' );
diff --git a/www/wiki/includes/libs/rdbms/field/MssqlField.php b/www/wiki/includes/libs/rdbms/field/MssqlField.php
new file mode 100644
index 00000000..98cc2b18
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/field/MssqlField.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+class MssqlField implements Field {
+ private $name, $tableName, $default, $max_length, $nullable, $type;
+
+ function __construct( $info ) {
+ $this->name = $info['COLUMN_NAME'];
+ $this->tableName = $info['TABLE_NAME'];
+ $this->default = $info['COLUMN_DEFAULT'];
+ $this->max_length = $info['CHARACTER_MAXIMUM_LENGTH'];
+ $this->nullable = !( strtolower( $info['IS_NULLABLE'] ) == 'no' );
+ $this->type = $info['DATA_TYPE'];
+ }
+
+ function name() {
+ return $this->name;
+ }
+
+ function tableName() {
+ return $this->tableName;
+ }
+
+ function defaultValue() {
+ return $this->default;
+ }
+
+ function maxLength() {
+ return $this->max_length;
+ }
+
+ function isNullable() {
+ return $this->nullable;
+ }
+
+ function type() {
+ return $this->type;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/field/MySQLField.php b/www/wiki/includes/libs/rdbms/field/MySQLField.php
new file mode 100644
index 00000000..709c61eb
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/field/MySQLField.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+class MySQLField implements Field {
+ private $name, $tablename, $default, $max_length, $nullable,
+ $is_pk, $is_unique, $is_multiple, $is_key, $type, $binary,
+ $is_numeric, $is_blob, $is_unsigned, $is_zerofill;
+
+ function __construct( $info ) {
+ $this->name = $info->name;
+ $this->tablename = $info->table;
+ $this->default = $info->def;
+ $this->max_length = $info->max_length;
+ $this->nullable = !$info->not_null;
+ $this->is_pk = $info->primary_key;
+ $this->is_unique = $info->unique_key;
+ $this->is_multiple = $info->multiple_key;
+ $this->is_key = ( $this->is_pk || $this->is_unique || $this->is_multiple );
+ $this->type = $info->type;
+ $this->binary = isset( $info->binary ) ? $info->binary : false;
+ $this->is_numeric = isset( $info->numeric ) ? $info->numeric : false;
+ $this->is_blob = isset( $info->blob ) ? $info->blob : false;
+ $this->is_unsigned = isset( $info->unsigned ) ? $info->unsigned : false;
+ $this->is_zerofill = isset( $info->zerofill ) ? $info->zerofill : false;
+ }
+
+ /**
+ * @return string
+ */
+ function name() {
+ return $this->name;
+ }
+
+ /**
+ * @return string
+ */
+ function tableName() {
+ return $this->tablename;
+ }
+
+ /**
+ * @return string
+ */
+ function type() {
+ return $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ function isNullable() {
+ return $this->nullable;
+ }
+
+ function defaultValue() {
+ return $this->default;
+ }
+
+ /**
+ * @return bool
+ */
+ function isKey() {
+ return $this->is_key;
+ }
+
+ /**
+ * @return bool
+ */
+ function isMultipleKey() {
+ return $this->is_multiple;
+ }
+
+ /**
+ * @return bool
+ */
+ function isBinary() {
+ return $this->binary;
+ }
+
+ /**
+ * @return bool
+ */
+ function isNumeric() {
+ return $this->is_numeric;
+ }
+
+ /**
+ * @return bool
+ */
+ function isBlob() {
+ return $this->is_blob;
+ }
+
+ /**
+ * @return bool
+ */
+ function isUnsigned() {
+ return $this->is_unsigned;
+ }
+
+ /**
+ * @return bool
+ */
+ function isZerofill() {
+ return $this->is_zerofill;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/field/PostgresField.php b/www/wiki/includes/libs/rdbms/field/PostgresField.php
new file mode 100644
index 00000000..600f34a4
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/field/PostgresField.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+class PostgresField implements Field {
+ private $name, $tablename, $type, $nullable, $max_length, $deferred, $deferrable, $conname,
+ $has_default, $default;
+
+ /**
+ * @param DatabasePostgres $db
+ * @param string $table
+ * @param string $field
+ * @return null|PostgresField
+ */
+ static function fromText( DatabasePostgres $db, $table, $field ) {
+ $q = <<<SQL
+SELECT
+ attnotnull, attlen, conname AS conname,
+ atthasdef,
+ adsrc,
+ COALESCE(condeferred, 'f') AS deferred,
+ COALESCE(condeferrable, 'f') AS deferrable,
+ CASE WHEN typname = 'int2' THEN 'smallint'
+ WHEN typname = 'int4' THEN 'integer'
+ WHEN typname = 'int8' THEN 'bigint'
+ WHEN typname = 'bpchar' THEN 'char'
+ ELSE typname END AS typname
+FROM pg_class c
+JOIN pg_namespace n ON (n.oid = c.relnamespace)
+JOIN pg_attribute a ON (a.attrelid = c.oid)
+JOIN pg_type t ON (t.oid = a.atttypid)
+LEFT JOIN pg_constraint o ON (o.conrelid = c.oid AND a.attnum = ANY(o.conkey) AND o.contype = 'f')
+LEFT JOIN pg_attrdef d on c.oid=d.adrelid and a.attnum=d.adnum
+WHERE relkind = 'r'
+AND nspname=%s
+AND relname=%s
+AND attname=%s;
+SQL;
+
+ $table = $db->remappedTableName( $table );
+ $res = $db->query(
+ sprintf( $q,
+ $db->addQuotes( $db->getCoreSchema() ),
+ $db->addQuotes( $table ),
+ $db->addQuotes( $field )
+ )
+ );
+ $row = $db->fetchObject( $res );
+ if ( !$row ) {
+ return null;
+ }
+ $n = new PostgresField;
+ $n->type = $row->typname;
+ $n->nullable = ( $row->attnotnull == 'f' );
+ $n->name = $field;
+ $n->tablename = $table;
+ $n->max_length = $row->attlen;
+ $n->deferrable = ( $row->deferrable == 't' );
+ $n->deferred = ( $row->deferred == 't' );
+ $n->conname = $row->conname;
+ $n->has_default = ( $row->atthasdef === 't' );
+ $n->default = $row->adsrc;
+
+ return $n;
+ }
+
+ function name() {
+ return $this->name;
+ }
+
+ function tableName() {
+ return $this->tablename;
+ }
+
+ function type() {
+ return $this->type;
+ }
+
+ function isNullable() {
+ return $this->nullable;
+ }
+
+ function maxLength() {
+ return $this->max_length;
+ }
+
+ function is_deferrable() {
+ return $this->deferrable;
+ }
+
+ function is_deferred() {
+ return $this->deferred;
+ }
+
+ function conname() {
+ return $this->conname;
+ }
+
+ /**
+ * @since 1.19
+ * @return bool|mixed
+ */
+ function defaultValue() {
+ if ( $this->has_default ) {
+ return $this->default;
+ } else {
+ return false;
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/field/SQLiteField.php b/www/wiki/includes/libs/rdbms/field/SQLiteField.php
new file mode 100644
index 00000000..39f8f011
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/field/SQLiteField.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Wikimedia\Rdbms;
+
+class SQLiteField implements Field {
+ private $info, $tableName;
+
+ function __construct( $info, $tableName ) {
+ $this->info = $info;
+ $this->tableName = $tableName;
+ }
+
+ function name() {
+ return $this->info->name;
+ }
+
+ function tableName() {
+ return $this->tableName;
+ }
+
+ function defaultValue() {
+ if ( is_string( $this->info->dflt_value ) ) {
+ // Typically quoted
+ if ( preg_match( '/^\'(.*)\'$', $this->info->dflt_value ) ) {
+ return str_replace( "''", "'", $this->info->dflt_value );
+ }
+ }
+
+ return $this->info->dflt_value;
+ }
+
+ /**
+ * @return bool
+ */
+ function isNullable() {
+ return !$this->info->notnull;
+ }
+
+ function type() {
+ return $this->info->type;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/lbfactory/ILBFactory.php b/www/wiki/includes/libs/rdbms/lbfactory/ILBFactory.php
new file mode 100644
index 00000000..f6d080e4
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/lbfactory/ILBFactory.php
@@ -0,0 +1,324 @@
+<?php
+/**
+ * Generator and manager of database load balancing objects
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
+/**
+ * An interface for generating database load balancers
+ * @ingroup Database
+ * @since 1.28
+ */
+interface ILBFactory {
+ const SHUTDOWN_NO_CHRONPROT = 0; // don't save DB positions at all
+ const SHUTDOWN_CHRONPROT_ASYNC = 1; // save DB positions, but don't wait on remote DCs
+ const SHUTDOWN_CHRONPROT_SYNC = 2; // save DB positions, waiting on all DCs
+
+ /**
+ * Construct a manager of ILoadBalancer objects
+ *
+ * Sub-classes will extend the required keys in $conf with additional parameters
+ *
+ * @param array $conf Array with keys:
+ * - localDomain: A DatabaseDomain or domain ID string.
+ * - readOnlyReason : Reason the master DB is read-only if so [optional]
+ * - srvCache : BagOStuff object for server cache [optional]
+ * - memStash : BagOStuff object for cross-datacenter memory storage [optional]
+ * - wanCache : WANObjectCache object [optional]
+ * - hostname : The name of the current server [optional]
+ * - cliMode: Whether the execution context is a CLI script. [optional]
+ * - profiler : Class name or instance with profileIn()/profileOut() methods. [optional]
+ * - trxProfiler: TransactionProfiler instance. [optional]
+ * - replLogger: PSR-3 logger instance. [optional]
+ * - connLogger: PSR-3 logger instance. [optional]
+ * - queryLogger: PSR-3 logger instance. [optional]
+ * - perfLogger: PSR-3 logger instance. [optional]
+ * - errorLogger : Callback that takes an Exception and logs it. [optional]
+ * @throws InvalidArgumentException
+ */
+ public function __construct( array $conf );
+
+ /**
+ * Disables all load balancers. All connections are closed, and any attempt to
+ * open a new connection will result in a DBAccessError.
+ * @see ILoadBalancer::disable()
+ */
+ public function destroy();
+
+ /**
+ * Create a new load balancer object. The resulting object will be untracked,
+ * not chronology-protected, and the caller is responsible for cleaning it up.
+ *
+ * This method is for only advanced usage and callers should almost always use
+ * getMainLB() instead. This method can be useful when a table is used as a key/value
+ * store. In that cases, one might want to query it in autocommit mode (DBO_TRX off)
+ * but still use DBO_TRX transaction rounds on other tables.
+ *
+ * @param bool|string $domain Domain ID, or false for the current domain
+ * @return ILoadBalancer
+ */
+ public function newMainLB( $domain = false );
+
+ /**
+ * Get a cached (tracked) load balancer object.
+ *
+ * @param bool|string $domain Domain ID, or false for the current domain
+ * @return ILoadBalancer
+ */
+ public function getMainLB( $domain = false );
+
+ /**
+ * Create a new load balancer for external storage. The resulting object will be
+ * untracked, not chronology-protected, and the caller is responsible for cleaning it up.
+ *
+ * This method is for only advanced usage and callers should almost always use
+ * getExternalLB() instead. This method can be useful when a table is used as a
+ * key/value store. In that cases, one might want to query it in autocommit mode
+ * (DBO_TRX off) but still use DBO_TRX transaction rounds on other tables.
+ *
+ * @param string $cluster External storage cluster name
+ * @return ILoadBalancer
+ */
+ public function newExternalLB( $cluster );
+
+ /**
+ * Get a cached (tracked) load balancer for external storage
+ *
+ * @param string $cluster External storage cluster name
+ * @return ILoadBalancer
+ */
+ public function getExternalLB( $cluster );
+
+ /**
+ * Get cached (tracked) load balancers for all main database clusters
+ *
+ * @return LoadBalancer[] Map of (cluster name => LoadBalancer)
+ * @since 1.29
+ */
+ public function getAllMainLBs();
+
+ /**
+ * Get cached (tracked) load balancers for all external database clusters
+ *
+ * @return LoadBalancer[] Map of (cluster name => LoadBalancer)
+ * @since 1.29
+ */
+ public function getAllExternalLBs();
+
+ /**
+ * Execute a function for each tracked load balancer
+ * The callback is called with the load balancer as the first parameter,
+ * and $params passed as the subsequent parameters.
+ *
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachLB( $callback, array $params = [] );
+
+ /**
+ * Prepare all tracked load balancers for shutdown
+ * @param int $mode One of the class SHUTDOWN_* constants
+ * @param callable|null $workCallback Work to mask ChronologyProtector writes
+ */
+ public function shutdown(
+ $mode = self::SHUTDOWN_CHRONPROT_SYNC, callable $workCallback = null
+ );
+
+ /**
+ * Commit all replica DB transactions so as to flush any REPEATABLE-READ or SSI snapshot
+ *
+ * @param string $fname Caller name
+ */
+ public function flushReplicaSnapshots( $fname = __METHOD__ );
+
+ /**
+ * Commit open transactions on all connections. This is useful for two main cases:
+ * - a) To commit changes to the masters.
+ * - b) To release the snapshot on all connections, master and replica DBs.
+ * @param string $fname Caller name
+ * @param array $options Options map:
+ * - maxWriteDuration: abort if more than this much time was spent in write queries
+ */
+ public function commitAll( $fname = __METHOD__, array $options = [] );
+
+ /**
+ * Flush any master transaction snapshots and set DBO_TRX (if DBO_DEFAULT is set)
+ *
+ * The DBO_TRX setting will be reverted to the default in each of these methods:
+ * - commitMasterChanges()
+ * - rollbackMasterChanges()
+ * - commitAll()
+ *
+ * This allows for custom transaction rounds from any outer transaction scope.
+ *
+ * @param string $fname
+ * @throws DBTransactionError
+ */
+ public function beginMasterChanges( $fname = __METHOD__ );
+
+ /**
+ * Commit changes on all master connections
+ * @param string $fname Caller name
+ * @param array $options Options map:
+ * - maxWriteDuration: abort if more than this much time was spent in write queries
+ * @throws DBTransactionError
+ */
+ public function commitMasterChanges( $fname = __METHOD__, array $options = [] );
+
+ /**
+ * Rollback changes on all master connections
+ * @param string $fname Caller name
+ */
+ public function rollbackMasterChanges( $fname = __METHOD__ );
+
+ /**
+ * Check if a transaction round is active
+ * @return bool
+ * @since 1.29
+ */
+ public function hasTransactionRound();
+
+ /**
+ * Determine if any master connection has pending changes
+ * @return bool
+ */
+ public function hasMasterChanges();
+
+ /**
+ * Detemine if any lagged replica DB connection was used
+ * @return bool
+ */
+ public function laggedReplicaUsed();
+
+ /**
+ * Determine if any master connection has pending/written changes from this request
+ * @param float $age How many seconds ago is "recent" [defaults to LB lag wait timeout]
+ * @return bool
+ */
+ public function hasOrMadeRecentMasterChanges( $age = null );
+
+ /**
+ * Waits for the replica DBs to catch up to the current master position
+ *
+ * Use this when updating very large numbers of rows, as in maintenance scripts,
+ * to avoid causing too much lag. Of course, this is a no-op if there are no replica DBs.
+ *
+ * By default this waits on all DB clusters actually used in this request.
+ * This makes sense when lag being waiting on is caused by the code that does this check.
+ * In that case, setting "ifWritesSince" can avoid the overhead of waiting for clusters
+ * that were not changed since the last wait check. To forcefully wait on a specific cluster
+ * for a given domain, use the 'domain' parameter. To forcefully wait on an "external" cluster,
+ * use the "cluster" parameter.
+ *
+ * Never call this function after a large DB write that is *still* in a transaction.
+ * It only makes sense to call this after the possible lag inducing changes were committed.
+ *
+ * @param array $opts Optional fields that include:
+ * - domain : wait on the load balancer DBs that handles the given domain ID
+ * - cluster : wait on the given external load balancer DBs
+ * - timeout : Max wait time. Default: ~60 seconds
+ * - ifWritesSince: Only wait if writes were done since this UNIX timestamp
+ * @throws DBReplicationWaitError If a timeout or error occurred waiting on a DB cluster
+ */
+ public function waitForReplication( array $opts = [] );
+
+ /**
+ * Add a callback to be run in every call to waitForReplication() before waiting
+ *
+ * Callbacks must clear any transactions that they start
+ *
+ * @param string $name Callback name
+ * @param callable|null $callback Use null to unset a callback
+ */
+ public function setWaitForReplicationListener( $name, callable $callback = null );
+
+ /**
+ * Get a token asserting that no transaction writes are active
+ *
+ * @param string $fname Caller name (e.g. __METHOD__)
+ * @return mixed A value to pass to commitAndWaitForReplication()
+ */
+ public function getEmptyTransactionTicket( $fname );
+
+ /**
+ * Convenience method for safely running commitMasterChanges()/waitForReplication()
+ *
+ * This will commit and wait unless $ticket indicates it is unsafe to do so
+ *
+ * @param string $fname Caller name (e.g. __METHOD__)
+ * @param mixed $ticket Result of getEmptyTransactionTicket()
+ * @param array $opts Options to waitForReplication()
+ * @throws DBReplicationWaitError
+ */
+ public function commitAndWaitForReplication( $fname, $ticket, array $opts = [] );
+
+ /**
+ * @param string $dbName DB master name (e.g. "db1052")
+ * @return float|bool UNIX timestamp when client last touched the DB or false if not recent
+ */
+ public function getChronologyProtectorTouched( $dbName );
+
+ /**
+ * Disable the ChronologyProtector for all load balancers
+ *
+ * This can be called at the start of special API entry points
+ */
+ public function disableChronologyProtection();
+
+ /**
+ * Set a new table prefix for the existing local domain ID for testing
+ *
+ * @param string $prefix
+ */
+ public function setDomainPrefix( $prefix );
+
+ /**
+ * Close all open database connections on all open load balancers.
+ */
+ public function closeAll();
+
+ /**
+ * @param string $agent Agent name for query profiling
+ */
+ public function setAgentName( $agent );
+
+ /**
+ * Append ?cpPosTime parameter to a URL for ChronologyProtector purposes if needed
+ *
+ * Note that unlike cookies, this works accross domains
+ *
+ * @param string $url
+ * @param float $time UNIX timestamp just before shutdown() was called
+ * @return string
+ */
+ public function appendPreShutdownTimeAsQuery( $url, $time );
+
+ /**
+ * @param array $info Map of fields, including:
+ * - IPAddress : IP address
+ * - UserAgent : User-Agent HTTP header
+ * - ChronologyProtection : cookie/header value specifying ChronologyProtector usage
+ */
+ public function setRequestInfo( array $info );
+}
diff --git a/www/wiki/includes/libs/rdbms/lbfactory/LBFactory.php b/www/wiki/includes/libs/rdbms/lbfactory/LBFactory.php
new file mode 100644
index 00000000..c891fb6b
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/lbfactory/LBFactory.php
@@ -0,0 +1,585 @@
+<?php
+/**
+ * Generator and manager of database load balancing objects
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerInterface;
+use Wikimedia\ScopedCallback;
+use BagOStuff;
+use EmptyBagOStuff;
+use WANObjectCache;
+use Exception;
+use RuntimeException;
+
+/**
+ * An interface for generating database load balancers
+ * @ingroup Database
+ */
+abstract class LBFactory implements ILBFactory {
+ /** @var ChronologyProtector */
+ protected $chronProt;
+ /** @var object|string Class name or object With profileIn/profileOut methods */
+ protected $profiler;
+ /** @var TransactionProfiler */
+ protected $trxProfiler;
+ /** @var LoggerInterface */
+ protected $replLogger;
+ /** @var LoggerInterface */
+ protected $connLogger;
+ /** @var LoggerInterface */
+ protected $queryLogger;
+ /** @var LoggerInterface */
+ protected $perfLogger;
+ /** @var callable Error logger */
+ protected $errorLogger;
+ /** @var BagOStuff */
+ protected $srvCache;
+ /** @var BagOStuff */
+ protected $memStash;
+ /** @var WANObjectCache */
+ protected $wanCache;
+
+ /** @var DatabaseDomain Local domain */
+ protected $localDomain;
+ /** @var string Local hostname of the app server */
+ protected $hostname;
+ /** @var array Web request information about the client */
+ protected $requestInfo;
+
+ /** @var mixed */
+ protected $ticket;
+ /** @var string|bool String if a requested DBO_TRX transaction round is active */
+ protected $trxRoundId = false;
+ /** @var string|bool Reason all LBs are read-only or false if not */
+ protected $readOnlyReason = false;
+ /** @var callable[] */
+ protected $replicationWaitCallbacks = [];
+
+ /** @var bool Whether this PHP instance is for a CLI script */
+ protected $cliMode;
+ /** @var string Agent name for query profiling */
+ protected $agent;
+
+ private static $loggerFields =
+ [ 'replLogger', 'connLogger', 'queryLogger', 'perfLogger' ];
+
+ public function __construct( array $conf ) {
+ $this->localDomain = isset( $conf['localDomain'] )
+ ? DatabaseDomain::newFromId( $conf['localDomain'] )
+ : DatabaseDomain::newUnspecified();
+
+ if ( isset( $conf['readOnlyReason'] ) && is_string( $conf['readOnlyReason'] ) ) {
+ $this->readOnlyReason = $conf['readOnlyReason'];
+ }
+
+ $this->srvCache = isset( $conf['srvCache'] ) ? $conf['srvCache'] : new EmptyBagOStuff();
+ $this->memStash = isset( $conf['memStash'] ) ? $conf['memStash'] : new EmptyBagOStuff();
+ $this->wanCache = isset( $conf['wanCache'] )
+ ? $conf['wanCache']
+ : WANObjectCache::newEmpty();
+
+ foreach ( self::$loggerFields as $key ) {
+ $this->$key = isset( $conf[$key] ) ? $conf[$key] : new \Psr\Log\NullLogger();
+ }
+ $this->errorLogger = isset( $conf['errorLogger'] )
+ ? $conf['errorLogger']
+ : function ( Exception $e ) {
+ trigger_error( E_USER_WARNING, get_class( $e ) . ': ' . $e->getMessage() );
+ };
+
+ $this->profiler = isset( $conf['profiler'] ) ? $conf['profiler'] : null;
+ $this->trxProfiler = isset( $conf['trxProfiler'] )
+ ? $conf['trxProfiler']
+ : new TransactionProfiler();
+
+ $this->requestInfo = [
+ 'IPAddress' => isset( $_SERVER[ 'REMOTE_ADDR' ] ) ? $_SERVER[ 'REMOTE_ADDR' ] : '',
+ 'UserAgent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '',
+ 'ChronologyProtection' => 'true'
+ ];
+
+ $this->cliMode = isset( $conf['cliMode'] ) ? $conf['cliMode'] : PHP_SAPI === 'cli';
+ $this->hostname = isset( $conf['hostname'] ) ? $conf['hostname'] : gethostname();
+ $this->agent = isset( $conf['agent'] ) ? $conf['agent'] : '';
+
+ $this->ticket = mt_rand();
+ }
+
+ public function destroy() {
+ $this->shutdown( self::SHUTDOWN_NO_CHRONPROT );
+ $this->forEachLBCallMethod( 'disable' );
+ }
+
+ public function shutdown(
+ $mode = self::SHUTDOWN_CHRONPROT_SYNC, callable $workCallback = null
+ ) {
+ $chronProt = $this->getChronologyProtector();
+ if ( $mode === self::SHUTDOWN_CHRONPROT_SYNC ) {
+ $this->shutdownChronologyProtector( $chronProt, $workCallback, 'sync' );
+ } elseif ( $mode === self::SHUTDOWN_CHRONPROT_ASYNC ) {
+ $this->shutdownChronologyProtector( $chronProt, null, 'async' );
+ }
+
+ $this->commitMasterChanges( __METHOD__ ); // sanity
+ }
+
+ /**
+ * @see ILBFactory::newMainLB()
+ * @param bool $domain
+ * @return LoadBalancer
+ */
+ abstract public function newMainLB( $domain = false );
+
+ /**
+ * @see ILBFactory::getMainLB()
+ * @param bool $domain
+ * @return LoadBalancer
+ */
+ abstract public function getMainLB( $domain = false );
+
+ /**
+ * @see ILBFactory::newExternalLB()
+ * @param string $cluster
+ * @return LoadBalancer
+ */
+ abstract public function newExternalLB( $cluster );
+
+ /**
+ * @see ILBFactory::getExternalLB()
+ * @param string $cluster
+ * @return LoadBalancer
+ */
+ abstract public function getExternalLB( $cluster );
+
+ /**
+ * Call a method of each tracked load balancer
+ *
+ * @param string $methodName
+ * @param array $args
+ */
+ protected function forEachLBCallMethod( $methodName, array $args = [] ) {
+ $this->forEachLB(
+ function ( ILoadBalancer $loadBalancer, $methodName, array $args ) {
+ call_user_func_array( [ $loadBalancer, $methodName ], $args );
+ },
+ [ $methodName, $args ]
+ );
+ }
+
+ public function flushReplicaSnapshots( $fname = __METHOD__ ) {
+ $this->forEachLBCallMethod( 'flushReplicaSnapshots', [ $fname ] );
+ }
+
+ public function commitAll( $fname = __METHOD__, array $options = [] ) {
+ $this->commitMasterChanges( $fname, $options );
+ $this->forEachLBCallMethod( 'commitAll', [ $fname ] );
+ }
+
+ public function beginMasterChanges( $fname = __METHOD__ ) {
+ if ( $this->trxRoundId !== false ) {
+ throw new DBTransactionError(
+ null,
+ "$fname: transaction round '{$this->trxRoundId}' already started."
+ );
+ }
+ $this->trxRoundId = $fname;
+ // Set DBO_TRX flags on all appropriate DBs
+ $this->forEachLBCallMethod( 'beginMasterChanges', [ $fname ] );
+ }
+
+ public function commitMasterChanges( $fname = __METHOD__, array $options = [] ) {
+ if ( $this->trxRoundId !== false && $this->trxRoundId !== $fname ) {
+ throw new DBTransactionError(
+ null,
+ "$fname: transaction round '{$this->trxRoundId}' still running."
+ );
+ }
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForCommit(); // try to ignore client aborts
+ // Run pre-commit callbacks and suppress post-commit callbacks, aborting on failure
+ $this->forEachLBCallMethod( 'finalizeMasterChanges' );
+ $this->trxRoundId = false;
+ // Perform pre-commit checks, aborting on failure
+ $this->forEachLBCallMethod( 'approveMasterChanges', [ $options ] );
+ // Log the DBs and methods involved in multi-DB transactions
+ $this->logIfMultiDbTransaction();
+ // Actually perform the commit on all master DB connections and revert DBO_TRX
+ $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] );
+ // Run all post-commit callbacks
+ /** @var Exception $e */
+ $e = null; // first callback exception
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) {
+ $ex = $lb->runMasterPostTrxCallbacks( IDatabase::TRIGGER_COMMIT );
+ $e = $e ?: $ex;
+ } );
+ // Commit any dangling DBO_TRX transactions from callbacks on one DB to another DB
+ $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] );
+ // Throw any last post-commit callback error
+ if ( $e instanceof Exception ) {
+ throw $e;
+ }
+ }
+
+ public function rollbackMasterChanges( $fname = __METHOD__ ) {
+ $this->trxRoundId = false;
+ $this->forEachLBCallMethod( 'suppressTransactionEndCallbacks' );
+ $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname ] );
+ // Run all post-rollback callbacks
+ $this->forEachLB( function ( ILoadBalancer $lb ) {
+ $lb->runMasterPostTrxCallbacks( IDatabase::TRIGGER_ROLLBACK );
+ } );
+ }
+
+ public function hasTransactionRound() {
+ return ( $this->trxRoundId !== false );
+ }
+
+ /**
+ * Log query info if multi DB transactions are going to be committed now
+ */
+ private function logIfMultiDbTransaction() {
+ $callersByDB = [];
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$callersByDB ) {
+ $masterName = $lb->getServerName( $lb->getWriterIndex() );
+ $callers = $lb->pendingMasterChangeCallers();
+ if ( $callers ) {
+ $callersByDB[$masterName] = $callers;
+ }
+ } );
+
+ if ( count( $callersByDB ) >= 2 ) {
+ $dbs = implode( ', ', array_keys( $callersByDB ) );
+ $msg = "Multi-DB transaction [{$dbs}]:\n";
+ foreach ( $callersByDB as $db => $callers ) {
+ $msg .= "$db: " . implode( '; ', $callers ) . "\n";
+ }
+ $this->queryLogger->info( $msg );
+ }
+ }
+
+ public function hasMasterChanges() {
+ $ret = false;
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$ret ) {
+ $ret = $ret || $lb->hasMasterChanges();
+ } );
+
+ return $ret;
+ }
+
+ public function laggedReplicaUsed() {
+ $ret = false;
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$ret ) {
+ $ret = $ret || $lb->laggedReplicaUsed();
+ } );
+
+ return $ret;
+ }
+
+ public function hasOrMadeRecentMasterChanges( $age = null ) {
+ $ret = false;
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( $age, &$ret ) {
+ $ret = $ret || $lb->hasOrMadeRecentMasterChanges( $age );
+ } );
+ return $ret;
+ }
+
+ public function waitForReplication( array $opts = [] ) {
+ $opts += [
+ 'domain' => false,
+ 'cluster' => false,
+ 'timeout' => 60,
+ 'ifWritesSince' => null
+ ];
+
+ if ( $opts['domain'] === false && isset( $opts['wiki'] ) ) {
+ $opts['domain'] = $opts['wiki']; // b/c
+ }
+
+ // Figure out which clusters need to be checked
+ /** @var ILoadBalancer[] $lbs */
+ $lbs = [];
+ if ( $opts['cluster'] !== false ) {
+ $lbs[] = $this->getExternalLB( $opts['cluster'] );
+ } elseif ( $opts['domain'] !== false ) {
+ $lbs[] = $this->getMainLB( $opts['domain'] );
+ } else {
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$lbs ) {
+ $lbs[] = $lb;
+ } );
+ if ( !$lbs ) {
+ return; // nothing actually used
+ }
+ }
+
+ // Get all the master positions of applicable DBs right now.
+ // This can be faster since waiting on one cluster reduces the
+ // time needed to wait on the next clusters.
+ $masterPositions = array_fill( 0, count( $lbs ), false );
+ foreach ( $lbs as $i => $lb ) {
+ if ( $lb->getServerCount() <= 1 ) {
+ // T29975 - Don't try to wait for replica DBs if there are none
+ // Prevents permission error when getting master position
+ continue;
+ } elseif ( $opts['ifWritesSince']
+ && $lb->lastMasterChangeTimestamp() < $opts['ifWritesSince']
+ ) {
+ continue; // no writes since the last wait
+ }
+ $masterPositions[$i] = $lb->getMasterPos();
+ }
+
+ // Run any listener callbacks *after* getting the DB positions. The more
+ // time spent in the callbacks, the less time is spent in waitForAll().
+ foreach ( $this->replicationWaitCallbacks as $callback ) {
+ $callback();
+ }
+
+ $failed = [];
+ foreach ( $lbs as $i => $lb ) {
+ if ( $masterPositions[$i] ) {
+ // The DBMS may not support getMasterPos()
+ if ( !$lb->waitForAll( $masterPositions[$i], $opts['timeout'] ) ) {
+ $failed[] = $lb->getServerName( $lb->getWriterIndex() );
+ }
+ }
+ }
+
+ if ( $failed ) {
+ throw new DBReplicationWaitError(
+ null,
+ "Could not wait for replica DBs to catch up to " .
+ implode( ', ', $failed )
+ );
+ }
+ }
+
+ public function setWaitForReplicationListener( $name, callable $callback = null ) {
+ if ( $callback ) {
+ $this->replicationWaitCallbacks[$name] = $callback;
+ } else {
+ unset( $this->replicationWaitCallbacks[$name] );
+ }
+ }
+
+ public function getEmptyTransactionTicket( $fname ) {
+ if ( $this->hasMasterChanges() ) {
+ $this->queryLogger->error( __METHOD__ . ": $fname does not have outer scope.\n" .
+ ( new RuntimeException() )->getTraceAsString() );
+
+ return null;
+ }
+
+ return $this->ticket;
+ }
+
+ public function commitAndWaitForReplication( $fname, $ticket, array $opts = [] ) {
+ if ( $ticket !== $this->ticket ) {
+ $this->perfLogger->error( __METHOD__ . ": $fname does not have outer scope.\n" .
+ ( new RuntimeException() )->getTraceAsString() );
+
+ return;
+ }
+
+ // The transaction owner and any caller with the empty transaction ticket can commit
+ // so that getEmptyTransactionTicket() callers don't risk seeing DBTransactionError.
+ if ( $this->trxRoundId !== false && $fname !== $this->trxRoundId ) {
+ $this->queryLogger->info( "$fname: committing on behalf of {$this->trxRoundId}." );
+ $fnameEffective = $this->trxRoundId;
+ } else {
+ $fnameEffective = $fname;
+ }
+
+ $this->commitMasterChanges( $fnameEffective );
+ $this->waitForReplication( $opts );
+ // If a nested caller committed on behalf of $fname, start another empty $fname
+ // transaction, leaving the caller with the same empty transaction state as before.
+ if ( $fnameEffective !== $fname ) {
+ $this->beginMasterChanges( $fnameEffective );
+ }
+ }
+
+ public function getChronologyProtectorTouched( $dbName ) {
+ return $this->getChronologyProtector()->getTouched( $dbName );
+ }
+
+ public function disableChronologyProtection() {
+ $this->getChronologyProtector()->setEnabled( false );
+ }
+
+ /**
+ * @return ChronologyProtector
+ */
+ protected function getChronologyProtector() {
+ if ( $this->chronProt ) {
+ return $this->chronProt;
+ }
+
+ $this->chronProt = new ChronologyProtector(
+ $this->memStash,
+ [
+ 'ip' => $this->requestInfo['IPAddress'],
+ 'agent' => $this->requestInfo['UserAgent'],
+ ],
+ isset( $_GET['cpPosTime'] ) ? $_GET['cpPosTime'] : null
+ );
+ $this->chronProt->setLogger( $this->replLogger );
+
+ if ( $this->cliMode ) {
+ $this->chronProt->setEnabled( false );
+ } elseif ( $this->requestInfo['ChronologyProtection'] === 'false' ) {
+ // Request opted out of using position wait logic. This is useful for requests
+ // done by the job queue or background ETL that do not have a meaningful session.
+ $this->chronProt->setWaitEnabled( false );
+ }
+
+ $this->replLogger->debug( __METHOD__ . ': using request info ' .
+ json_encode( $this->requestInfo, JSON_PRETTY_PRINT ) );
+
+ return $this->chronProt;
+ }
+
+ /**
+ * Get and record all of the staged DB positions into persistent memory storage
+ *
+ * @param ChronologyProtector $cp
+ * @param callable|null $workCallback Work to do instead of waiting on syncing positions
+ * @param string $mode One of (sync, async); whether to wait on remote datacenters
+ */
+ protected function shutdownChronologyProtector(
+ ChronologyProtector $cp, $workCallback, $mode
+ ) {
+ // Record all the master positions needed
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( $cp ) {
+ $cp->shutdownLB( $lb );
+ } );
+ // Write them to the persistent stash. Try to do something useful by running $work
+ // while ChronologyProtector waits for the stash write to replicate to all DCs.
+ $unsavedPositions = $cp->shutdown( $workCallback, $mode );
+ if ( $unsavedPositions && $workCallback ) {
+ // Invoke callback in case it did not cache the result yet
+ $workCallback(); // work now to block for less time in waitForAll()
+ }
+ // If the positions failed to write to the stash, at least wait on local datacenter
+ // replica DBs to catch up before responding. Even if there are several DCs, this increases
+ // the chance that the user will see their own changes immediately afterwards. As long
+ // as the sticky DC cookie applies (same domain), this is not even an issue.
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( $unsavedPositions ) {
+ $masterName = $lb->getServerName( $lb->getWriterIndex() );
+ if ( isset( $unsavedPositions[$masterName] ) ) {
+ $lb->waitForAll( $unsavedPositions[$masterName] );
+ }
+ } );
+ }
+
+ /**
+ * Base parameters to LoadBalancer::__construct()
+ * @return array
+ */
+ final protected function baseLoadBalancerParams() {
+ return [
+ 'localDomain' => $this->localDomain,
+ 'readOnlyReason' => $this->readOnlyReason,
+ 'srvCache' => $this->srvCache,
+ 'wanCache' => $this->wanCache,
+ 'profiler' => $this->profiler,
+ 'trxProfiler' => $this->trxProfiler,
+ 'queryLogger' => $this->queryLogger,
+ 'connLogger' => $this->connLogger,
+ 'replLogger' => $this->replLogger,
+ 'errorLogger' => $this->errorLogger,
+ 'hostname' => $this->hostname,
+ 'cliMode' => $this->cliMode,
+ 'agent' => $this->agent,
+ 'chronologyProtector' => $this->getChronologyProtector()
+ ];
+ }
+
+ /**
+ * @param ILoadBalancer $lb
+ */
+ protected function initLoadBalancer( ILoadBalancer $lb ) {
+ if ( $this->trxRoundId !== false ) {
+ $lb->beginMasterChanges( $this->trxRoundId ); // set DBO_TRX
+ }
+ }
+
+ public function setDomainPrefix( $prefix ) {
+ $this->localDomain = new DatabaseDomain(
+ $this->localDomain->getDatabase(),
+ null,
+ $prefix
+ );
+
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( $prefix ) {
+ $lb->setDomainPrefix( $prefix );
+ } );
+ }
+
+ public function closeAll() {
+ $this->forEachLBCallMethod( 'closeAll', [] );
+ }
+
+ public function setAgentName( $agent ) {
+ $this->agent = $agent;
+ }
+
+ public function appendPreShutdownTimeAsQuery( $url, $time ) {
+ $usedCluster = 0;
+ $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$usedCluster ) {
+ $usedCluster |= ( $lb->getServerCount() > 1 );
+ } );
+
+ if ( !$usedCluster ) {
+ return $url; // no master/replica clusters touched
+ }
+
+ return strpos( $url, '?' ) === false ? "$url?cpPosTime=$time" : "$url&cpPosTime=$time";
+ }
+
+ public function setRequestInfo( array $info ) {
+ $this->requestInfo = $info + $this->requestInfo;
+ }
+
+ /**
+ * Make PHP ignore user aborts/disconnects until the returned
+ * value leaves scope. This returns null and does nothing in CLI mode.
+ *
+ * @return ScopedCallback|null
+ */
+ final protected function getScopedPHPBehaviorForCommit() {
+ if ( PHP_SAPI != 'cli' ) { // https://bugs.php.net/bug.php?id=47540
+ $old = ignore_user_abort( true ); // avoid half-finished operations
+ return new ScopedCallback( function () use ( $old ) {
+ ignore_user_abort( $old );
+ } );
+ }
+
+ return null;
+ }
+
+ function __destruct() {
+ $this->destroy();
+ }
+}
+
+class_alias( LBFactory::class, 'LBFactory' );
diff --git a/www/wiki/includes/libs/rdbms/lbfactory/LBFactoryMulti.php b/www/wiki/includes/libs/rdbms/lbfactory/LBFactoryMulti.php
new file mode 100644
index 00000000..0384588d
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/lbfactory/LBFactoryMulti.php
@@ -0,0 +1,423 @@
+<?php
+/**
+ * Advanced generator of database load balancing objects for database farms.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
+/**
+ * A multi-database, multi-master factory for Wikimedia and similar installations.
+ * Ignores the old configuration globals.
+ *
+ * @ingroup Database
+ */
+class LBFactoryMulti extends LBFactory {
+ /** @var array A map of database names to section names */
+ private $sectionsByDB;
+
+ /**
+ * @var array A 2-d map. For each section, gives a map of server names to
+ * load ratios
+ */
+ private $sectionLoads;
+
+ /**
+ * @var array[] Server info associative array
+ * @note The host, hostName and load entries will be overridden
+ */
+ private $serverTemplate;
+
+ // Optional settings
+
+ /** @var array A 3-d map giving server load ratios for each section and group */
+ private $groupLoadsBySection = [];
+
+ /** @var array A 3-d map giving server load ratios by DB name */
+ private $groupLoadsByDB = [];
+
+ /** @var array A map of hostname to IP address */
+ private $hostsByName = [];
+
+ /** @var array A map of external storage cluster name to server load map */
+ private $externalLoads = [];
+
+ /**
+ * @var array A set of server info keys overriding serverTemplate for
+ * external storage
+ */
+ private $externalTemplateOverrides;
+
+ /**
+ * @var array A 2-d map overriding serverTemplate and
+ * externalTemplateOverrides on a server-by-server basis. Applies to both
+ * core and external storage
+ */
+ private $templateOverridesByServer;
+
+ /** @var array A 2-d map overriding the server info by section */
+ private $templateOverridesBySection;
+
+ /** @var array A 2-d map overriding the server info by external storage cluster */
+ private $templateOverridesByCluster;
+
+ /** @var array An override array for all master servers */
+ private $masterTemplateOverrides;
+
+ /**
+ * @var array|bool A map of section name to read-only message. Missing or
+ * false for read/write
+ */
+ private $readOnlyBySection = [];
+
+ /** @var array Load balancer factory configuration */
+ private $conf;
+
+ /** @var LoadBalancer[] */
+ private $mainLBs = [];
+
+ /** @var LoadBalancer[] */
+ private $extLBs = [];
+
+ /** @var string */
+ private $loadMonitorClass = 'LoadMonitor';
+
+ /** @var string */
+ private $lastDomain;
+
+ /** @var string */
+ private $lastSection;
+
+ /**
+ * @see LBFactory::__construct()
+ *
+ * Template override precedence (highest => lowest):
+ * - templateOverridesByServer
+ * - masterTemplateOverrides
+ * - templateOverridesBySection/templateOverridesByCluster
+ * - externalTemplateOverrides
+ * - serverTemplate
+ * Overrides only work on top level keys (so nested values will not be merged).
+ *
+ * Server configuration maps should be of the format Database::factory() requires.
+ * Additionally, a 'max lag' key should also be set on server maps, indicating how stale the
+ * data can be before the load balancer tries to avoid using it. The map can have 'is static'
+ * set to disable blocking replication sync checks (intended for archive servers with
+ * unchanging data).
+ *
+ * @param array $conf Parameters of LBFactory::__construct() as well as:
+ * - sectionsByDB Map of database names to section names.
+ * - sectionLoads 2-d map. For each section, gives a map of server names to
+ * load ratios. For example:
+ * [
+ * 'section1' => [
+ * 'db1' => 100,
+ * 'db2' => 100
+ * ]
+ * ]
+ * - serverTemplate Server configuration map intended for Database::factory().
+ * Note that "host", "hostName" and "load" entries will be
+ * overridden by "sectionLoads" and "hostsByName".
+ * - groupLoadsBySection 3-d map giving server load ratios for each section/group.
+ * For example:
+ * [
+ * 'section1' => [
+ * 'group1' => [
+ * 'db1' => 100,
+ * 'db2' => 100
+ * ]
+ * ]
+ * ]
+ * - groupLoadsByDB 3-d map giving server load ratios by DB name.
+ * - hostsByName Map of hostname to IP address.
+ * - externalLoads Map of external storage cluster name to server load map.
+ * - externalTemplateOverrides Set of server configuration maps overriding
+ * "serverTemplate" for external storage.
+ * - templateOverridesByServer 2-d map overriding "serverTemplate" and
+ * "externalTemplateOverrides" on a server-by-server basis.
+ * Applies to both core and external storage.
+ * - templateOverridesBySection 2-d map overriding the server configuration maps by section.
+ * - templateOverridesByCluster 2-d map overriding the server configuration maps by external
+ * storage cluster.
+ * - masterTemplateOverrides Server configuration map overrides for all master servers.
+ * - loadMonitorClass Name of the LoadMonitor class to always use.
+ * - readOnlyBySection A map of section name to read-only message.
+ * Missing or false for read/write.
+ */
+ public function __construct( array $conf ) {
+ parent::__construct( $conf );
+
+ $this->conf = $conf;
+ $required = [ 'sectionsByDB', 'sectionLoads', 'serverTemplate' ];
+ $optional = [ 'groupLoadsBySection', 'groupLoadsByDB', 'hostsByName',
+ 'externalLoads', 'externalTemplateOverrides', 'templateOverridesByServer',
+ 'templateOverridesByCluster', 'templateOverridesBySection', 'masterTemplateOverrides',
+ 'readOnlyBySection', 'loadMonitorClass' ];
+
+ foreach ( $required as $key ) {
+ if ( !isset( $conf[$key] ) ) {
+ throw new InvalidArgumentException( __CLASS__ . ": $key is required." );
+ }
+ $this->$key = $conf[$key];
+ }
+
+ foreach ( $optional as $key ) {
+ if ( isset( $conf[$key] ) ) {
+ $this->$key = $conf[$key];
+ }
+ }
+ }
+
+ /**
+ * @param bool|string $domain
+ * @return string
+ */
+ private function getSectionForDomain( $domain = false ) {
+ if ( $this->lastDomain === $domain ) {
+ return $this->lastSection;
+ }
+ list( $dbName, ) = $this->getDBNameAndPrefix( $domain );
+ if ( isset( $this->sectionsByDB[$dbName] ) ) {
+ $section = $this->sectionsByDB[$dbName];
+ } else {
+ $section = 'DEFAULT';
+ }
+ $this->lastSection = $section;
+ $this->lastDomain = $domain;
+
+ return $section;
+ }
+
+ /**
+ * @param bool|string $domain
+ * @return LoadBalancer
+ */
+ public function newMainLB( $domain = false ) {
+ list( $dbName, ) = $this->getDBNameAndPrefix( $domain );
+ $section = $this->getSectionForDomain( $domain );
+ if ( isset( $this->groupLoadsByDB[$dbName] ) ) {
+ $groupLoads = $this->groupLoadsByDB[$dbName];
+ } else {
+ $groupLoads = [];
+ }
+
+ if ( isset( $this->groupLoadsBySection[$section] ) ) {
+ $groupLoads = array_merge_recursive(
+ $groupLoads, $this->groupLoadsBySection[$section] );
+ }
+
+ $readOnlyReason = $this->readOnlyReason;
+ // Use the LB-specific read-only reason if everything isn't already read-only
+ if ( $readOnlyReason === false && isset( $this->readOnlyBySection[$section] ) ) {
+ $readOnlyReason = $this->readOnlyBySection[$section];
+ }
+
+ $template = $this->serverTemplate;
+ if ( isset( $this->templateOverridesBySection[$section] ) ) {
+ $template = $this->templateOverridesBySection[$section] + $template;
+ }
+
+ return $this->newLoadBalancer(
+ $template,
+ $this->sectionLoads[$section],
+ $groupLoads,
+ $readOnlyReason
+ );
+ }
+
+ /**
+ * @param DatabaseDomain|string|bool $domain Domain ID, or false for the current domain
+ * @return LoadBalancer
+ */
+ public function getMainLB( $domain = false ) {
+ $section = $this->getSectionForDomain( $domain );
+ if ( !isset( $this->mainLBs[$section] ) ) {
+ $this->mainLBs[$section] = $this->newMainLB( $domain );
+ }
+
+ return $this->mainLBs[$section];
+ }
+
+ public function newExternalLB( $cluster ) {
+ if ( !isset( $this->externalLoads[$cluster] ) ) {
+ throw new InvalidArgumentException( __METHOD__ . ": Unknown cluster \"$cluster\"" );
+ }
+ $template = $this->serverTemplate;
+ if ( $this->externalTemplateOverrides ) {
+ $template = $this->externalTemplateOverrides + $template;
+ }
+ if ( isset( $this->templateOverridesByCluster[$cluster] ) ) {
+ $template = $this->templateOverridesByCluster[$cluster] + $template;
+ }
+
+ return $this->newLoadBalancer(
+ $template,
+ $this->externalLoads[$cluster],
+ [],
+ $this->readOnlyReason
+ );
+ }
+
+ public function getExternalLB( $cluster ) {
+ if ( !isset( $this->extLBs[$cluster] ) ) {
+ $this->extLBs[$cluster] = $this->newExternalLB( $cluster );
+ }
+
+ return $this->extLBs[$cluster];
+ }
+
+ public function getAllMainLBs() {
+ $lbs = [];
+ foreach ( $this->sectionsByDB as $db => $section ) {
+ if ( !isset( $lbs[$section] ) ) {
+ $lbs[$section] = $this->getMainLB( $db );
+ }
+ }
+
+ return $lbs;
+ }
+
+ public function getAllExternalLBs() {
+ $lbs = [];
+ foreach ( $this->externalLoads as $cluster => $unused ) {
+ $lbs[$cluster] = $this->getExternalLB( $cluster );
+ }
+
+ return $lbs;
+ }
+
+ /**
+ * Make a new load balancer object based on template and load array
+ *
+ * @param array $template
+ * @param array $loads
+ * @param array $groupLoads
+ * @param string|bool $readOnlyReason
+ * @return LoadBalancer
+ */
+ private function newLoadBalancer( $template, $loads, $groupLoads, $readOnlyReason ) {
+ $lb = new LoadBalancer( array_merge(
+ $this->baseLoadBalancerParams(),
+ [
+ 'servers' => $this->makeServerArray( $template, $loads, $groupLoads ),
+ 'loadMonitor' => [ 'class' => $this->loadMonitorClass ],
+ 'readOnlyReason' => $readOnlyReason
+ ]
+ ) );
+ $this->initLoadBalancer( $lb );
+
+ return $lb;
+ }
+
+ /**
+ * Make a server array as expected by LoadBalancer::__construct, using a template and load array
+ *
+ * @param array $template
+ * @param array $loads
+ * @param array $groupLoads
+ * @return array
+ */
+ private function makeServerArray( $template, $loads, $groupLoads ) {
+ $servers = [];
+ $master = true;
+ $groupLoadsByServer = $this->reindexGroupLoads( $groupLoads );
+ foreach ( $groupLoadsByServer as $server => $stuff ) {
+ if ( !isset( $loads[$server] ) ) {
+ $loads[$server] = 0;
+ }
+ }
+ foreach ( $loads as $serverName => $load ) {
+ $serverInfo = $template;
+ if ( $master ) {
+ $serverInfo['master'] = true;
+ if ( $this->masterTemplateOverrides ) {
+ $serverInfo = $this->masterTemplateOverrides + $serverInfo;
+ }
+ $master = false;
+ } else {
+ $serverInfo['replica'] = true;
+ }
+ if ( isset( $this->templateOverridesByServer[$serverName] ) ) {
+ $serverInfo = $this->templateOverridesByServer[$serverName] + $serverInfo;
+ }
+ if ( isset( $groupLoadsByServer[$serverName] ) ) {
+ $serverInfo['groupLoads'] = $groupLoadsByServer[$serverName];
+ }
+ if ( isset( $this->hostsByName[$serverName] ) ) {
+ $serverInfo['host'] = $this->hostsByName[$serverName];
+ } else {
+ $serverInfo['host'] = $serverName;
+ }
+ $serverInfo['hostName'] = $serverName;
+ $serverInfo['load'] = $load;
+ $serverInfo += [ 'flags' => IDatabase::DBO_DEFAULT ];
+
+ $servers[] = $serverInfo;
+ }
+
+ return $servers;
+ }
+
+ /**
+ * Take a group load array indexed by group then server, and reindex it by server then group
+ * @param array $groupLoads
+ * @return array
+ */
+ private function reindexGroupLoads( $groupLoads ) {
+ $reindexed = [];
+ foreach ( $groupLoads as $group => $loads ) {
+ foreach ( $loads as $server => $load ) {
+ $reindexed[$server][$group] = $load;
+ }
+ }
+
+ return $reindexed;
+ }
+
+ /**
+ * @param DatabaseDomain|string|bool $domain Domain ID, or false for the current domain
+ * @return array [database name, table prefix]
+ */
+ private function getDBNameAndPrefix( $domain = false ) {
+ $domain = ( $domain === false )
+ ? $this->localDomain
+ : DatabaseDomain::newFromId( $domain );
+
+ return [ $domain->getDatabase(), $domain->getTablePrefix() ];
+ }
+
+ /**
+ * Execute a function for each tracked load balancer
+ * The callback is called with the load balancer as the first parameter,
+ * and $params passed as the subsequent parameters.
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachLB( $callback, array $params = [] ) {
+ foreach ( $this->mainLBs as $lb ) {
+ call_user_func_array( $callback, array_merge( [ $lb ], $params ) );
+ }
+ foreach ( $this->extLBs as $lb ) {
+ call_user_func_array( $callback, array_merge( [ $lb ], $params ) );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/lbfactory/LBFactorySimple.php b/www/wiki/includes/libs/rdbms/lbfactory/LBFactorySimple.php
new file mode 100644
index 00000000..df0a806b
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/lbfactory/LBFactorySimple.php
@@ -0,0 +1,155 @@
+<?php
+/**
+ * Generator of database load balancing objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
+/**
+ * A simple single-master LBFactory that gets its configuration from the b/c globals
+ */
+class LBFactorySimple extends LBFactory {
+ /** @var LoadBalancer */
+ private $mainLB;
+ /** @var LoadBalancer[] */
+ private $extLBs = [];
+
+ /** @var array[] Map of (server index => server config) */
+ private $servers = [];
+ /** @var array[] Map of (cluster => (server index => server config)) */
+ private $externalClusters = [];
+
+ /** @var string */
+ private $loadMonitorClass;
+
+ /**
+ * @see LBFactory::__construct()
+ * @param array $conf Parameters of LBFactory::__construct() as well as:
+ * - servers : list of server configuration maps to Database::factory().
+ * Additionally, the server maps should have a 'load' key, which is used to decide
+ * how often clients connect to one server verses the others. A 'max lag' key should
+ * also be set on server maps, indicating how stale the data can be before the load
+ * balancer tries to avoid using it. The map can have 'is static' set to disable blocking
+ * replication sync checks (intended for archive servers with unchanging data).
+ * - externalClusters : map of cluster names to server arrays. The servers arrays have the
+ * same format as "servers" above.
+ */
+ public function __construct( array $conf ) {
+ parent::__construct( $conf );
+
+ $this->servers = isset( $conf['servers'] ) ? $conf['servers'] : [];
+ foreach ( $this->servers as $i => $server ) {
+ if ( $i == 0 ) {
+ $this->servers[$i]['master'] = true;
+ } else {
+ $this->servers[$i]['replica'] = true;
+ }
+ }
+
+ $this->externalClusters = isset( $conf['externalClusters'] )
+ ? $conf['externalClusters']
+ : [];
+ $this->loadMonitorClass = isset( $conf['loadMonitorClass'] )
+ ? $conf['loadMonitorClass']
+ : 'LoadMonitor';
+ }
+
+ /**
+ * @param bool|string $domain
+ * @return LoadBalancer
+ */
+ public function newMainLB( $domain = false ) {
+ return $this->newLoadBalancer( $this->servers );
+ }
+
+ /**
+ * @param bool|string $domain
+ * @return LoadBalancer
+ */
+ public function getMainLB( $domain = false ) {
+ if ( !isset( $this->mainLB ) ) {
+ $this->mainLB = $this->newMainLB( $domain );
+ }
+
+ return $this->mainLB;
+ }
+
+ public function newExternalLB( $cluster ) {
+ if ( !isset( $this->externalClusters[$cluster] ) ) {
+ throw new InvalidArgumentException( __METHOD__ . ": Unknown cluster \"$cluster\"." );
+ }
+
+ return $this->newLoadBalancer( $this->externalClusters[$cluster] );
+ }
+
+ public function getExternalLB( $cluster ) {
+ if ( !isset( $this->extLBs[$cluster] ) ) {
+ $this->extLBs[$cluster] = $this->newExternalLB( $cluster );
+ }
+
+ return $this->extLBs[$cluster];
+ }
+
+ public function getAllMainLBs() {
+ return [ 'DEFAULT' => $this->getMainLB() ];
+ }
+
+ public function getAllExternalLBs() {
+ $lbs = [];
+ foreach ( $this->externalClusters as $cluster => $unused ) {
+ $lbs[$cluster] = $this->getExternalLB( $cluster );
+ }
+
+ return $lbs;
+ }
+
+ private function newLoadBalancer( array $servers ) {
+ $lb = new LoadBalancer( array_merge(
+ $this->baseLoadBalancerParams(),
+ [
+ 'servers' => $servers,
+ 'loadMonitor' => [ 'class' => $this->loadMonitorClass ],
+ ]
+ ) );
+ $this->initLoadBalancer( $lb );
+
+ return $lb;
+ }
+
+ /**
+ * Execute a function for each tracked load balancer
+ * The callback is called with the load balancer as the first parameter,
+ * and $params passed as the subsequent parameters.
+ *
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachLB( $callback, array $params = [] ) {
+ if ( isset( $this->mainLB ) ) {
+ call_user_func_array( $callback, array_merge( [ $this->mainLB ], $params ) );
+ }
+ foreach ( $this->extLBs as $lb ) {
+ call_user_func_array( $callback, array_merge( [ $lb ], $params ) );
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/lbfactory/LBFactorySingle.php b/www/wiki/includes/libs/rdbms/lbfactory/LBFactorySingle.php
new file mode 100644
index 00000000..cd998c3e
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/lbfactory/LBFactorySingle.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * Simple generator of database connections that always returns the same object.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+use BadMethodCallException;
+
+/**
+ * An LBFactory class that always returns a single database object.
+ */
+class LBFactorySingle extends LBFactory {
+ /** @var LoadBalancerSingle */
+ private $lb;
+
+ /**
+ * @param array $conf An associative array with one member:
+ * - connection: The IDatabase connection object
+ */
+ public function __construct( array $conf ) {
+ parent::__construct( $conf );
+
+ if ( !isset( $conf['connection'] ) ) {
+ throw new InvalidArgumentException( "Missing 'connection' argument." );
+ }
+
+ $lb = new LoadBalancerSingle( array_merge( $this->baseLoadBalancerParams(), $conf ) );
+ $this->initLoadBalancer( $lb );
+ $this->lb = $lb;
+ }
+
+ /**
+ * @param IDatabase $db Live connection handle
+ * @param array $params Parameter map to LBFactorySingle::__constructs()
+ * @return LBFactorySingle
+ * @since 1.28
+ */
+ public static function newFromConnection( IDatabase $db, array $params = [] ) {
+ return new static( [ 'connection' => $db ] + $params );
+ }
+
+ /**
+ * @param bool|string $domain (unused)
+ * @return LoadBalancerSingle
+ */
+ public function newMainLB( $domain = false ) {
+ return $this->lb;
+ }
+
+ /**
+ * @param bool|string $domain (unused)
+ * @return LoadBalancerSingle
+ */
+ public function getMainLB( $domain = false ) {
+ return $this->lb;
+ }
+
+ public function newExternalLB( $cluster ) {
+ throw new BadMethodCallException( "Method is not supported." );
+ }
+
+ public function getExternalLB( $cluster ) {
+ throw new BadMethodCallException( "Method is not supported." );
+ }
+
+ /**
+ * @return LoadBalancerSingle[] Map of (cluster name => LoadBalancer)
+ */
+ public function getAllMainLBs() {
+ return [ 'DEFAULT' => $this->lb ];
+ }
+
+ /**
+ * @return LoadBalancerSingle[] Map of (cluster name => LoadBalancer)
+ */
+ public function getAllExternalLBs() {
+ return [];
+ }
+
+ /**
+ * @param string|callable $callback
+ * @param array $params
+ */
+ public function forEachLB( $callback, array $params = [] ) {
+ call_user_func_array( $callback, array_merge( [ $this->lb ], $params ) );
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/loadbalancer/ILoadBalancer.php b/www/wiki/includes/libs/rdbms/loadbalancer/ILoadBalancer.php
new file mode 100644
index 00000000..22a58055
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/loadbalancer/ILoadBalancer.php
@@ -0,0 +1,609 @@
+<?php
+/**
+ * Database load balancing interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use Exception;
+use InvalidArgumentException;
+
+/**
+ * Database cluster connection, tracking, load balancing, and transaction manager interface
+ *
+ * A "cluster" is considered to be one master database and zero or more replica databases.
+ * Typically, the replica DBs replicate from the master asynchronously. The first node in the
+ * "servers" configuration array is always considered the "master". However, this class can still
+ * be used when all or some of the "replica" DBs are multi-master peers of the master or even
+ * when all the DBs are non-replicating clones of each other holding read-only data. Thus, the
+ * role of "master" is in some cases merely nominal.
+ *
+ * By default, each DB server uses DBO_DEFAULT for its 'flags' setting, unless explicitly set
+ * otherwise in configuration. DBO_DEFAULT behavior depends on whether 'cliMode' is set:
+ * - In CLI mode, the flag has no effect with regards to LoadBalancer.
+ * - In non-CLI mode, the flag causes implicit transactions to be used; the first query on
+ * a database starts a transaction on that database. The transactions are meant to remain
+ * pending until either commitMasterChanges() or rollbackMasterChanges() is called. The
+ * application must have some point where it calls commitMasterChanges() near the end of
+ * the PHP request.
+ * Every iteration of beginMasterChanges()/commitMasterChanges() is called a "transaction round".
+ * Rounds are useful on the master DB connections because they make single-DB (and by and large
+ * multi-DB) updates in web requests all-or-nothing. Also, transactions on replica DBs are useful
+ * when REPEATABLE-READ or SERIALIZABLE isolation is used because all foriegn keys and constraints
+ * hold across separate queries in the DB transaction since the data appears within a consistent
+ * point-in-time snapshot.
+ *
+ * The typical caller will use LoadBalancer::getConnection( DB_* ) to yield a live database
+ * connection handle. The choice of which DB server to use is based on pre-defined loads for
+ * weighted random selection, adjustments thereof by LoadMonitor, and the amount of replication
+ * lag on each DB server. Lag checks might cause problems in certain setups, so they should be
+ * tuned in the server configuration maps as follows:
+ * - Master + N Replica(s): set 'max lag' to an appropriate threshold for avoiding any database
+ * lagged by this much or more. If all DBs are this lagged, then the load balancer considers
+ * the cluster to be read-only.
+ * - Galera Cluster: Seconds_Behind_Master will be 0, so there probably is nothing to tune.
+ * Note that lag is still possible depending on how wsrep-sync-wait is set server-side.
+ * - Read-only archive clones: set 'is static' in the server configuration maps. This will
+ * treat all such DBs as having 0 lag.
+ * - SQL load balancing proxy: any proxy should handle lag checks on its own, so the 'max lag'
+ * parameter should probably be set to INF in the server configuration maps. This will make
+ * the load balancer ignore whatever it detects as the lag of the logical replica is (which
+ * would probably just randomly bounce around).
+ *
+ * If using a SQL proxy service, it would probably be best to have two proxy hosts for the
+ * load balancer to talk to. One would be the 'host' of the master server entry and another for
+ * the (logical) replica server entry. The proxy could map the load balancer's "replica" DB to
+ * any number of physical replica DBs.
+ *
+ * @since 1.28
+ * @ingroup Database
+ */
+interface ILoadBalancer {
+ /** @var int Request a replica DB connection */
+ const DB_REPLICA = -1;
+ /** @var int Request a master DB connection */
+ const DB_MASTER = -2;
+
+ /** @var string Domain specifier when no specific database needs to be selected */
+ const DOMAIN_ANY = '';
+
+ /** @var int DB handle should have DBO_TRX disabled and the caller will leave it as such */
+ const CONN_TRX_AUTO = 1;
+
+ /**
+ * Construct a manager of IDatabase connection objects
+ *
+ * @param array $params Parameter map with keys:
+ * - servers : Required. Array of server info structures.
+ * - localDomain: A DatabaseDomain or domain ID string.
+ * - loadMonitor : Name of a class used to fetch server lag and load.
+ * - readOnlyReason : Reason the master DB is read-only if so [optional]
+ * - waitTimeout : Maximum time to wait for replicas for consistency [optional]
+ * - srvCache : BagOStuff object for server cache [optional]
+ * - wanCache : WANObjectCache object [optional]
+ * - chronologyProtector: ChronologyProtector object [optional]
+ * - hostname : The name of the current server [optional]
+ * - cliMode: Whether the execution context is a CLI script. [optional]
+ * - profiler : Class name or instance with profileIn()/profileOut() methods. [optional]
+ * - trxProfiler: TransactionProfiler instance. [optional]
+ * - replLogger: PSR-3 logger instance. [optional]
+ * - connLogger: PSR-3 logger instance. [optional]
+ * - queryLogger: PSR-3 logger instance. [optional]
+ * - perfLogger: PSR-3 logger instance. [optional]
+ * - errorLogger : Callback that takes an Exception and logs it. [optional]
+ * @throws InvalidArgumentException
+ */
+ public function __construct( array $params );
+
+ /**
+ * Get the index of the reader connection, which may be a replica DB
+ *
+ * This takes into account load ratios and lag times. It should
+ * always return a consistent index during a given invocation.
+ *
+ * Side effect: opens connections to databases
+ * @param string|bool $group Query group, or false for the generic reader
+ * @param string|bool $domain Domain ID, or false for the current domain
+ * @throws DBError
+ * @return bool|int|string
+ */
+ public function getReaderIndex( $group = false, $domain = false );
+
+ /**
+ * Set the master wait position
+ *
+ * If a DB_REPLICA connection has been opened already, then wait immediately.
+ * Otherwise sets a variable telling it to wait if such a connection is opened.
+ *
+ * This only applies to connections to the generic replica DB for this request.
+ * If a timeout happens when waiting, then getLaggedReplicaMode()/laggedReplicaUsed()
+ * will return true.
+ *
+ * @param DBMasterPos|bool $pos Master position or false
+ */
+ public function waitFor( $pos );
+
+ /**
+ * Set the master wait position and wait for a "generic" replica DB to catch up to it
+ *
+ * This can be used a faster proxy for waitForAll()
+ *
+ * @param DBMasterPos|bool $pos Master position or false
+ * @param int $timeout Max seconds to wait; default is mWaitTimeout
+ * @return bool Success (able to connect and no timeouts reached)
+ */
+ public function waitForOne( $pos, $timeout = null );
+
+ /**
+ * Set the master wait position and wait for ALL replica DBs to catch up to it
+ *
+ * @param DBMasterPos|bool $pos Master position or false
+ * @param int $timeout Max seconds to wait; default is mWaitTimeout
+ * @return bool Success (able to connect and no timeouts reached)
+ */
+ public function waitForAll( $pos, $timeout = null );
+
+ /**
+ * Get any open connection to a given server index, local or foreign
+ *
+ * @param int $i Server index or DB_MASTER/DB_REPLICA
+ * @return Database|bool False if no such connection is open
+ */
+ public function getAnyOpenConnection( $i );
+
+ /**
+ * Get a connection by index
+ *
+ * Avoid using CONN_TRX_AUTO with sqlite (e.g. check getServerType() first)
+ *
+ * @param int $i Server index or DB_MASTER/DB_REPLICA
+ * @param array|string|bool $groups Query group(s), or false for the generic reader
+ * @param string|bool $domain Domain ID, or false for the current domain
+ * @param int $flags Bitfield of CONN_* class constants
+ *
+ * @throws DBError
+ * @return Database
+ */
+ public function getConnection( $i, $groups = [], $domain = false, $flags = 0 );
+
+ /**
+ * Mark a foreign connection as being available for reuse under a different DB domain
+ *
+ * This mechanism is reference-counted, and must be called the same number of times
+ * as getConnection() to work.
+ *
+ * @param IDatabase $conn
+ * @throws InvalidArgumentException
+ */
+ public function reuseConnection( $conn );
+
+ /**
+ * Get a database connection handle reference
+ *
+ * The handle's methods simply wrap those of a Database handle
+ *
+ * Avoid using CONN_TRX_AUTO with sqlite (e.g. check getServerType() first)
+ *
+ * @see ILoadBalancer::getConnection() for parameter information
+ *
+ * @param int $i Server index or DB_MASTER/DB_REPLICA
+ * @param array|string|bool $groups Query group(s), or false for the generic reader
+ * @param string|bool $domain Domain ID, or false for the current domain
+ * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO)
+ * @return DBConnRef
+ */
+ public function getConnectionRef( $i, $groups = [], $domain = false, $flags = 0 );
+
+ /**
+ * Get a database connection handle reference without connecting yet
+ *
+ * The handle's methods simply wrap those of a Database handle
+ *
+ * Avoid using CONN_TRX_AUTO with sqlite (e.g. check getServerType() first)
+ *
+ * @see ILoadBalancer::getConnection() for parameter information
+ *
+ * @param int $i Server index or DB_MASTER/DB_REPLICA
+ * @param array|string|bool $groups Query group(s), or false for the generic reader
+ * @param string|bool $domain Domain ID, or false for the current domain
+ * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO)
+ * @return DBConnRef
+ */
+ public function getLazyConnectionRef( $i, $groups = [], $domain = false, $flags = 0 );
+
+ /**
+ * Get a maintenance database connection handle reference for migrations and schema changes
+ *
+ * The handle's methods simply wrap those of a Database handle
+ *
+ * Avoid using CONN_TRX_AUTO with sqlite (e.g. check getServerType() first)
+ *
+ * @see ILoadBalancer::getConnection() for parameter information
+ *
+ * @param int $db Server index or DB_MASTER/DB_REPLICA
+ * @param array|string|bool $groups Query group(s), or false for the generic reader
+ * @param string|bool $domain Domain ID, or false for the current domain
+ * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO)
+ * @return MaintainableDBConnRef
+ */
+ public function getMaintenanceConnectionRef( $db, $groups = [], $domain = false, $flags = 0 );
+
+ /**
+ * Open a connection to the server given by the specified index
+ *
+ * The index must be an actual index into the array. If a connection to the server is
+ * already open and not considered an "in use" foreign connection, this simply returns it.
+ *
+ * Avoid using CONN_TRX_AUTO with sqlite (e.g. check getServerType() first)
+ *
+ * @note If disable() was called on this LoadBalancer, this method will throw a DBAccessError.
+ *
+ * @param int $i Server index (does not support DB_MASTER/DB_REPLICA)
+ * @param string|bool $domain Domain ID, or false for the current domain
+ * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO)
+ * @return Database|bool Returns false on errors
+ * @throws DBAccessError
+ */
+ public function openConnection( $i, $domain = false, $flags = 0 );
+
+ /**
+ * @return int
+ */
+ public function getWriterIndex();
+
+ /**
+ * Returns true if the specified index is a valid server index
+ *
+ * @param int $i
+ * @return bool
+ */
+ public function haveIndex( $i );
+
+ /**
+ * Returns true if the specified index is valid and has non-zero load
+ *
+ * @param int $i
+ * @return bool
+ */
+ public function isNonZeroLoad( $i );
+
+ /**
+ * Get the number of defined servers (not the number of open connections)
+ *
+ * @return int
+ */
+ public function getServerCount();
+
+ /**
+ * Get the host name or IP address of the server with the specified index
+ *
+ * @param int $i
+ * @return string Readable name if available or IP/host otherwise
+ */
+ public function getServerName( $i );
+
+ /**
+ * Get DB type of the server with the specified index
+ *
+ * @param int $i
+ * @return string One of (mysql,postgres,sqlite,...) or "unknown" for bad indexes
+ * @since 1.30
+ */
+ public function getServerType( $i );
+
+ /**
+ * Return the server info structure for a given index, or false if the index is invalid.
+ * @param int $i
+ * @return array|bool
+ *
+ * @deprecated Since 1.30, no alternative
+ */
+ public function getServerInfo( $i );
+
+ /**
+ * Sets the server info structure for the given index. Entry at index $i
+ * is created if it doesn't exist
+ * @param int $i
+ * @param array $serverInfo
+ *
+ * @deprecated Since 1.30, construct new object
+ */
+ public function setServerInfo( $i, array $serverInfo );
+
+ /**
+ * Get the current master position for chronology control purposes
+ * @return DBMasterPos|bool Returns false if not applicable
+ */
+ public function getMasterPos();
+
+ /**
+ * Disable this load balancer. All connections are closed, and any attempt to
+ * open a new connection will result in a DBAccessError.
+ */
+ public function disable();
+
+ /**
+ * Close all open connections
+ */
+ public function closeAll();
+
+ /**
+ * Close a connection
+ *
+ * Using this function makes sure the LoadBalancer knows the connection is closed.
+ * If you use $conn->close() directly, the load balancer won't update its state.
+ *
+ * @param IDatabase $conn
+ */
+ public function closeConnection( IDatabase $conn );
+
+ /**
+ * Commit transactions on all open connections
+ * @param string $fname Caller name
+ * @throws DBExpectedError
+ */
+ public function commitAll( $fname = __METHOD__ );
+
+ /**
+ * Perform all pre-commit callbacks that remain part of the atomic transactions
+ * and disable any post-commit callbacks until runMasterPostTrxCallbacks()
+ *
+ * Use this only for mutli-database commits
+ */
+ public function finalizeMasterChanges();
+
+ /**
+ * Perform all pre-commit checks for things like replication safety
+ *
+ * Use this only for mutli-database commits
+ *
+ * @param array $options Includes:
+ * - maxWriteDuration : max write query duration time in seconds
+ * @throws DBTransactionError
+ */
+ public function approveMasterChanges( array $options );
+
+ /**
+ * Flush any master transaction snapshots and set DBO_TRX (if DBO_DEFAULT is set)
+ *
+ * The DBO_TRX setting will be reverted to the default in each of these methods:
+ * - commitMasterChanges()
+ * - rollbackMasterChanges()
+ * - commitAll()
+ * This allows for custom transaction rounds from any outer transaction scope.
+ *
+ * @param string $fname
+ * @throws DBExpectedError
+ */
+ public function beginMasterChanges( $fname = __METHOD__ );
+
+ /**
+ * Issue COMMIT on all master connections where writes where done
+ * @param string $fname Caller name
+ * @throws DBExpectedError
+ */
+ public function commitMasterChanges( $fname = __METHOD__ );
+
+ /**
+ * Issue all pending post-COMMIT/ROLLBACK callbacks
+ *
+ * Use this only for mutli-database commits
+ *
+ * @param int $type IDatabase::TRIGGER_* constant
+ * @return Exception|null The first exception or null if there were none
+ */
+ public function runMasterPostTrxCallbacks( $type );
+
+ /**
+ * Issue ROLLBACK only on master, only if queries were done on connection
+ * @param string $fname Caller name
+ * @throws DBExpectedError
+ */
+ public function rollbackMasterChanges( $fname = __METHOD__ );
+
+ /**
+ * Suppress all pending post-COMMIT/ROLLBACK callbacks
+ *
+ * Use this only for mutli-database commits
+ *
+ * @return Exception|null The first exception or null if there were none
+ */
+ public function suppressTransactionEndCallbacks();
+
+ /**
+ * Commit all replica DB transactions so as to flush any REPEATABLE-READ or SSI snapshot
+ *
+ * @param string $fname Caller name
+ */
+ public function flushReplicaSnapshots( $fname = __METHOD__ );
+
+ /**
+ * @return bool Whether a master connection is already open
+ */
+ public function hasMasterConnection();
+
+ /**
+ * Determine if there are pending changes in a transaction by this thread
+ * @return bool
+ */
+ public function hasMasterChanges();
+
+ /**
+ * Get the timestamp of the latest write query done by this thread
+ * @return float|bool UNIX timestamp or false
+ */
+ public function lastMasterChangeTimestamp();
+
+ /**
+ * Check if this load balancer object had any recent or still
+ * pending writes issued against it by this PHP thread
+ *
+ * @param float $age How many seconds ago is "recent" [defaults to mWaitTimeout]
+ * @return bool
+ */
+ public function hasOrMadeRecentMasterChanges( $age = null );
+
+ /**
+ * Get the list of callers that have pending master changes
+ *
+ * @return string[] List of method names
+ */
+ public function pendingMasterChangeCallers();
+
+ /**
+ * @note This method will trigger a DB connection if not yet done
+ * @param string|bool $domain Domain ID, or false for the current domain
+ * @return bool Whether the database for generic connections this request is highly "lagged"
+ */
+ public function getLaggedReplicaMode( $domain = false );
+
+ /**
+ * Checks whether the database for generic connections this request was both:
+ * - a) Already choosen due to a prior connection attempt
+ * - b) Considered highly "lagged"
+ *
+ * @note This method will never cause a new DB connection
+ * @return bool
+ */
+ public function laggedReplicaUsed();
+
+ /**
+ * @note This method may trigger a DB connection if not yet done
+ * @param string|bool $domain Domain ID, or false for the current domain
+ * @param IDatabase|null $conn DB master connection; used to avoid loops [optional]
+ * @return string|bool Reason the master is read-only or false if it is not
+ */
+ public function getReadOnlyReason( $domain = false, IDatabase $conn = null );
+
+ /**
+ * Disables/enables lag checks
+ * @param null|bool $mode
+ * @return bool
+ */
+ public function allowLagged( $mode = null );
+
+ /**
+ * @return bool
+ */
+ public function pingAll();
+
+ /**
+ * Call a function with each open connection object
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachOpenConnection( $callback, array $params = [] );
+
+ /**
+ * Call a function with each open connection object to a master
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachOpenMasterConnection( $callback, array $params = [] );
+
+ /**
+ * Call a function with each open replica DB connection object
+ * @param callable $callback
+ * @param array $params
+ */
+ public function forEachOpenReplicaConnection( $callback, array $params = [] );
+
+ /**
+ * Get the hostname and lag time of the most-lagged replica DB
+ *
+ * This is useful for maintenance scripts that need to throttle their updates.
+ * May attempt to open connections to replica DBs on the default DB. If there is
+ * no lag, the maximum lag will be reported as -1.
+ *
+ * @param bool|string $domain Domain ID, or false for the default database
+ * @return array ( host, max lag, index of max lagged host )
+ */
+ public function getMaxLag( $domain = false );
+
+ /**
+ * Get an estimate of replication lag (in seconds) for each server
+ *
+ * Results are cached for a short time in memcached/process cache
+ *
+ * Values may be "false" if replication is too broken to estimate
+ *
+ * @param string|bool $domain
+ * @return int[] Map of (server index => float|int|bool)
+ */
+ public function getLagTimes( $domain = false );
+
+ /**
+ * Get the lag in seconds for a given connection, or zero if this load
+ * balancer does not have replication enabled.
+ *
+ * This should be used in preference to Database::getLag() in cases where
+ * replication may not be in use, since there is no way to determine if
+ * replication is in use at the connection level without running
+ * potentially restricted queries such as SHOW SLAVE STATUS. Using this
+ * function instead of Database::getLag() avoids a fatal error in this
+ * case on many installations.
+ *
+ * @param IDatabase $conn
+ * @return int|bool Returns false on error
+ */
+ public function safeGetLag( IDatabase $conn );
+
+ /**
+ * Wait for a replica DB to reach a specified master position
+ *
+ * This will connect to the master to get an accurate position if $pos is not given
+ *
+ * @param IDatabase $conn Replica DB
+ * @param DBMasterPos|bool $pos Master position; default: current position
+ * @param int $timeout Timeout in seconds [optional]
+ * @return bool Success
+ */
+ public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 );
+
+ /**
+ * Set a callback via IDatabase::setTransactionListener() on
+ * all current and future master connections of this load balancer
+ *
+ * @param string $name Callback name
+ * @param callable|null $callback
+ */
+ public function setTransactionListener( $name, callable $callback = null );
+
+ /**
+ * Set a new table prefix for the existing local domain ID for testing
+ *
+ * @param string $prefix
+ */
+ public function setDomainPrefix( $prefix );
+
+ /**
+ * Make certain table names use their own database, schema, and table prefix
+ * when passed into SQL queries pre-escaped and without a qualified database name
+ *
+ * For example, "user" can be converted to "myschema.mydbname.user" for convenience.
+ * Appearances like `user`, somedb.user, somedb.someschema.user will used literally.
+ *
+ * Calling this twice will completely clear any old table aliases. Also, note that
+ * callers are responsible for making sure the schemas and databases actually exist.
+ *
+ * @param array[] $aliases Map of (table => (dbname, schema, prefix) map)
+ */
+ public function setTableAliases( array $aliases );
+}
diff --git a/www/wiki/includes/libs/rdbms/loadbalancer/LoadBalancer.php b/www/wiki/includes/libs/rdbms/loadbalancer/LoadBalancer.php
new file mode 100644
index 00000000..8393e2bc
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/loadbalancer/LoadBalancer.php
@@ -0,0 +1,1723 @@
+<?php
+/**
+ * Database load balancing manager
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\ScopedCallback;
+use BagOStuff;
+use EmptyBagOStuff;
+use WANObjectCache;
+use ArrayUtils;
+use InvalidArgumentException;
+use RuntimeException;
+use Exception;
+
+/**
+ * Database connection, tracking, load balancing, and transaction manager for a cluster
+ *
+ * @ingroup Database
+ */
+class LoadBalancer implements ILoadBalancer {
+ /** @var array[] Map of (server index => server config array) */
+ private $mServers;
+ /** @var Database[][][] Map of (connection category => server index => IDatabase[]) */
+ private $mConns;
+ /** @var float[] Map of (server index => weight) */
+ private $mLoads;
+ /** @var array[] Map of (group => server index => weight) */
+ private $mGroupLoads;
+ /** @var bool Whether to disregard replica DB lag as a factor in replica DB selection */
+ private $mAllowLagged;
+ /** @var int Seconds to spend waiting on replica DB lag to resolve */
+ private $mWaitTimeout;
+ /** @var array The LoadMonitor configuration */
+ private $loadMonitorConfig;
+ /** @var array[] $aliases Map of (table => (dbname, schema, prefix) map) */
+ private $tableAliases = [];
+
+ /** @var ILoadMonitor */
+ private $loadMonitor;
+ /** @var ChronologyProtector|null */
+ private $chronProt;
+ /** @var BagOStuff */
+ private $srvCache;
+ /** @var WANObjectCache */
+ private $wanCache;
+ /** @var object|string Class name or object With profileIn/profileOut methods */
+ protected $profiler;
+ /** @var TransactionProfiler */
+ protected $trxProfiler;
+ /** @var LoggerInterface */
+ protected $replLogger;
+ /** @var LoggerInterface */
+ protected $connLogger;
+ /** @var LoggerInterface */
+ protected $queryLogger;
+ /** @var LoggerInterface */
+ protected $perfLogger;
+
+ /** @var Database DB connection object that caused a problem */
+ private $errorConnection;
+ /** @var int The generic (not query grouped) replica DB index (of $mServers) */
+ private $mReadIndex;
+ /** @var bool|DBMasterPos False if not set */
+ private $mWaitForPos;
+ /** @var bool Whether the generic reader fell back to a lagged replica DB */
+ private $laggedReplicaMode = false;
+ /** @var bool Whether the generic reader fell back to a lagged replica DB */
+ private $allReplicasDownMode = false;
+ /** @var string The last DB selection or connection error */
+ private $mLastError = 'Unknown error';
+ /** @var string|bool Reason the LB is read-only or false if not */
+ private $readOnlyReason = false;
+ /** @var int Total connections opened */
+ private $connsOpened = 0;
+ /** @var string|bool String if a requested DBO_TRX transaction round is active */
+ private $trxRoundId = false;
+ /** @var array[] Map of (name => callable) */
+ private $trxRecurringCallbacks = [];
+ /** @var DatabaseDomain Local Domain ID and default for selectDB() calls */
+ private $localDomain;
+ /** @var string Alternate ID string for the domain instead of DatabaseDomain::getId() */
+ private $localDomainIdAlias;
+ /** @var string Current server name */
+ private $host;
+ /** @var bool Whether this PHP instance is for a CLI script */
+ protected $cliMode;
+ /** @var string Agent name for query profiling */
+ protected $agent;
+
+ /** @var callable Exception logger */
+ private $errorLogger;
+
+ /** @var bool */
+ private $disabled = false;
+ /** @var bool */
+ private $chronProtInitialized = false;
+
+ /** @var int Warn when this many connection are held */
+ const CONN_HELD_WARN_THRESHOLD = 10;
+
+ /** @var int Default 'max lag' when unspecified */
+ const MAX_LAG_DEFAULT = 10;
+ /** @var int Seconds to cache master server read-only status */
+ const TTL_CACHE_READONLY = 5;
+
+ const KEY_LOCAL = 'local';
+ const KEY_FOREIGN_FREE = 'foreignFree';
+ const KEY_FOREIGN_INUSE = 'foreignInUse';
+
+ const KEY_LOCAL_NOROUND = 'localAutoCommit';
+ const KEY_FOREIGN_FREE_NOROUND = 'foreignFreeAutoCommit';
+ const KEY_FOREIGN_INUSE_NOROUND = 'foreignInUseAutoCommit';
+
+ public function __construct( array $params ) {
+ if ( !isset( $params['servers'] ) ) {
+ throw new InvalidArgumentException( __CLASS__ . ': missing servers parameter' );
+ }
+ $this->mServers = $params['servers'];
+ foreach ( $this->mServers as $i => $server ) {
+ if ( $i == 0 ) {
+ $this->mServers[$i]['master'] = true;
+ } else {
+ $this->mServers[$i]['replica'] = true;
+ }
+ }
+
+ $this->localDomain = isset( $params['localDomain'] )
+ ? DatabaseDomain::newFromId( $params['localDomain'] )
+ : DatabaseDomain::newUnspecified();
+ // In case a caller assumes that the domain ID is simply <db>-<prefix>, which is almost
+ // always true, gracefully handle the case when they fail to account for escaping.
+ if ( $this->localDomain->getTablePrefix() != '' ) {
+ $this->localDomainIdAlias =
+ $this->localDomain->getDatabase() . '-' . $this->localDomain->getTablePrefix();
+ } else {
+ $this->localDomainIdAlias = $this->localDomain->getDatabase();
+ }
+
+ $this->mWaitTimeout = isset( $params['waitTimeout'] ) ? $params['waitTimeout'] : 10;
+
+ $this->mReadIndex = -1;
+ $this->mConns = [
+ // Connection were transaction rounds may be applied
+ self::KEY_LOCAL => [],
+ self::KEY_FOREIGN_INUSE => [],
+ self::KEY_FOREIGN_FREE => [],
+ // Auto-committing counterpart connections that ignore transaction rounds
+ self::KEY_LOCAL_NOROUND => [],
+ self::KEY_FOREIGN_INUSE_NOROUND => [],
+ self::KEY_FOREIGN_FREE_NOROUND => []
+ ];
+ $this->mLoads = [];
+ $this->mWaitForPos = false;
+ $this->mAllowLagged = false;
+
+ if ( isset( $params['readOnlyReason'] ) && is_string( $params['readOnlyReason'] ) ) {
+ $this->readOnlyReason = $params['readOnlyReason'];
+ }
+
+ if ( isset( $params['loadMonitor'] ) ) {
+ $this->loadMonitorConfig = $params['loadMonitor'];
+ } else {
+ $this->loadMonitorConfig = [ 'class' => 'LoadMonitorNull' ];
+ }
+
+ foreach ( $params['servers'] as $i => $server ) {
+ $this->mLoads[$i] = $server['load'];
+ if ( isset( $server['groupLoads'] ) ) {
+ foreach ( $server['groupLoads'] as $group => $ratio ) {
+ if ( !isset( $this->mGroupLoads[$group] ) ) {
+ $this->mGroupLoads[$group] = [];
+ }
+ $this->mGroupLoads[$group][$i] = $ratio;
+ }
+ }
+ }
+
+ if ( isset( $params['srvCache'] ) ) {
+ $this->srvCache = $params['srvCache'];
+ } else {
+ $this->srvCache = new EmptyBagOStuff();
+ }
+ if ( isset( $params['wanCache'] ) ) {
+ $this->wanCache = $params['wanCache'];
+ } else {
+ $this->wanCache = WANObjectCache::newEmpty();
+ }
+ $this->profiler = isset( $params['profiler'] ) ? $params['profiler'] : null;
+ if ( isset( $params['trxProfiler'] ) ) {
+ $this->trxProfiler = $params['trxProfiler'];
+ } else {
+ $this->trxProfiler = new TransactionProfiler();
+ }
+
+ $this->errorLogger = isset( $params['errorLogger'] )
+ ? $params['errorLogger']
+ : function ( Exception $e ) {
+ trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
+ };
+
+ foreach ( [ 'replLogger', 'connLogger', 'queryLogger', 'perfLogger' ] as $key ) {
+ $this->$key = isset( $params[$key] ) ? $params[$key] : new NullLogger();
+ }
+
+ $this->host = isset( $params['hostname'] )
+ ? $params['hostname']
+ : ( gethostname() ?: 'unknown' );
+ $this->cliMode = isset( $params['cliMode'] ) ? $params['cliMode'] : PHP_SAPI === 'cli';
+ $this->agent = isset( $params['agent'] ) ? $params['agent'] : '';
+
+ if ( isset( $params['chronologyProtector'] ) ) {
+ $this->chronProt = $params['chronologyProtector'];
+ }
+ }
+
+ /**
+ * Get a LoadMonitor instance
+ *
+ * @return ILoadMonitor
+ */
+ private function getLoadMonitor() {
+ if ( !isset( $this->loadMonitor ) ) {
+ $compat = [
+ 'LoadMonitor' => LoadMonitor::class,
+ 'LoadMonitorNull' => LoadMonitorNull::class,
+ 'LoadMonitorMySQL' => LoadMonitorMySQL::class,
+ ];
+
+ $class = $this->loadMonitorConfig['class'];
+ if ( isset( $compat[$class] ) ) {
+ $class = $compat[$class];
+ }
+
+ $this->loadMonitor = new $class(
+ $this, $this->srvCache, $this->wanCache, $this->loadMonitorConfig );
+ $this->loadMonitor->setLogger( $this->replLogger );
+ }
+
+ return $this->loadMonitor;
+ }
+
+ /**
+ * @param array $loads
+ * @param bool|string $domain Domain to get non-lagged for
+ * @param int $maxLag Restrict the maximum allowed lag to this many seconds
+ * @return bool|int|string
+ */
+ private function getRandomNonLagged( array $loads, $domain = false, $maxLag = INF ) {
+ $lags = $this->getLagTimes( $domain );
+
+ # Unset excessively lagged servers
+ foreach ( $lags as $i => $lag ) {
+ if ( $i != 0 ) {
+ # How much lag this server nominally is allowed to have
+ $maxServerLag = isset( $this->mServers[$i]['max lag'] )
+ ? $this->mServers[$i]['max lag']
+ : self::MAX_LAG_DEFAULT; // default
+ # Constrain that futher by $maxLag argument
+ $maxServerLag = min( $maxServerLag, $maxLag );
+
+ $host = $this->getServerName( $i );
+ if ( $lag === false && !is_infinite( $maxServerLag ) ) {
+ $this->replLogger->error(
+ "Server {host} is not replicating?", [ 'host' => $host ] );
+ unset( $loads[$i] );
+ } elseif ( $lag > $maxServerLag ) {
+ $this->replLogger->warning(
+ "Server {host} has {lag} seconds of lag (>= {maxlag})",
+ [ 'host' => $host, 'lag' => $lag, 'maxlag' => $maxServerLag ]
+ );
+ unset( $loads[$i] );
+ }
+ }
+ }
+
+ # Find out if all the replica DBs with non-zero load are lagged
+ $sum = 0;
+ foreach ( $loads as $load ) {
+ $sum += $load;
+ }
+ if ( $sum == 0 ) {
+ # No appropriate DB servers except maybe the master and some replica DBs with zero load
+ # Do NOT use the master
+ # Instead, this function will return false, triggering read-only mode,
+ # and a lagged replica DB will be used instead.
+ return false;
+ }
+
+ if ( count( $loads ) == 0 ) {
+ return false;
+ }
+
+ # Return a random representative of the remainder
+ return ArrayUtils::pickRandom( $loads );
+ }
+
+ public function getReaderIndex( $group = false, $domain = false ) {
+ if ( count( $this->mServers ) == 1 ) {
+ // Skip the load balancing if there's only one server
+ return $this->getWriterIndex();
+ } elseif ( $group === false && $this->mReadIndex >= 0 ) {
+ // Shortcut if the generic reader index was already cached
+ return $this->mReadIndex;
+ }
+
+ if ( $group !== false ) {
+ // Use the server weight array for this load group
+ if ( isset( $this->mGroupLoads[$group] ) ) {
+ $loads = $this->mGroupLoads[$group];
+ } else {
+ // No loads for this group, return false and the caller can use some other group
+ $this->connLogger->info( __METHOD__ . ": no loads for group $group" );
+
+ return false;
+ }
+ } else {
+ // Use the generic load group
+ $loads = $this->mLoads;
+ }
+
+ // Scale the configured load ratios according to each server's load and state
+ $this->getLoadMonitor()->scaleLoads( $loads, $domain );
+
+ // Pick a server to use, accounting for weights, load, lag, and mWaitForPos
+ list( $i, $laggedReplicaMode ) = $this->pickReaderIndex( $loads, $domain );
+ if ( $i === false ) {
+ // Replica DB connection unsuccessful
+ return false;
+ }
+
+ if ( $this->mWaitForPos && $i != $this->getWriterIndex() ) {
+ // Before any data queries are run, wait for the server to catch up to the
+ // specified position. This is used to improve session consistency. Note that
+ // when LoadBalancer::waitFor() sets mWaitForPos, the waiting triggers here,
+ // so update laggedReplicaMode as needed for consistency.
+ if ( !$this->doWait( $i ) ) {
+ $laggedReplicaMode = true;
+ }
+ }
+
+ if ( $this->mReadIndex <= 0 && $this->mLoads[$i] > 0 && $group === false ) {
+ // Cache the generic reader index for future ungrouped DB_REPLICA handles
+ $this->mReadIndex = $i;
+ // Record if the generic reader index is in "lagged replica DB" mode
+ if ( $laggedReplicaMode ) {
+ $this->laggedReplicaMode = true;
+ }
+ }
+
+ $serverName = $this->getServerName( $i );
+ $this->connLogger->debug( __METHOD__ . ": using server $serverName for group '$group'" );
+
+ return $i;
+ }
+
+ /**
+ * @param array $loads List of server weights
+ * @param string|bool $domain
+ * @return array (reader index, lagged replica mode) or false on failure
+ */
+ private function pickReaderIndex( array $loads, $domain = false ) {
+ if ( !count( $loads ) ) {
+ throw new InvalidArgumentException( "Empty server array given to LoadBalancer" );
+ }
+
+ /** @var int|bool $i Index of selected server */
+ $i = false;
+ /** @var bool $laggedReplicaMode Whether server is considered lagged */
+ $laggedReplicaMode = false;
+
+ // Quickly look through the available servers for a server that meets criteria...
+ $currentLoads = $loads;
+ while ( count( $currentLoads ) ) {
+ if ( $this->mAllowLagged || $laggedReplicaMode ) {
+ $i = ArrayUtils::pickRandom( $currentLoads );
+ } else {
+ $i = false;
+ if ( $this->mWaitForPos && $this->mWaitForPos->asOfTime() ) {
+ // ChronologyProtecter sets mWaitForPos for session consistency.
+ // This triggers doWait() after connect, so it's especially good to
+ // avoid lagged servers so as to avoid excessive delay in that method.
+ $ago = microtime( true ) - $this->mWaitForPos->asOfTime();
+ // Aim for <= 1 second of waiting (being too picky can backfire)
+ $i = $this->getRandomNonLagged( $currentLoads, $domain, $ago + 1 );
+ }
+ if ( $i === false ) {
+ // Any server with less lag than it's 'max lag' param is preferable
+ $i = $this->getRandomNonLagged( $currentLoads, $domain );
+ }
+ if ( $i === false && count( $currentLoads ) != 0 ) {
+ // All replica DBs lagged. Switch to read-only mode
+ $this->replLogger->error( "All replica DBs lagged. Switch to read-only mode" );
+ $i = ArrayUtils::pickRandom( $currentLoads );
+ $laggedReplicaMode = true;
+ }
+ }
+
+ if ( $i === false ) {
+ // pickRandom() returned false.
+ // This is permanent and means the configuration or the load monitor
+ // wants us to return false.
+ $this->connLogger->debug( __METHOD__ . ": pickRandom() returned false" );
+
+ return [ false, false ];
+ }
+
+ $serverName = $this->getServerName( $i );
+ $this->connLogger->debug( __METHOD__ . ": Using reader #$i: $serverName..." );
+
+ $conn = $this->openConnection( $i, $domain );
+ if ( !$conn ) {
+ $this->connLogger->warning( __METHOD__ . ": Failed connecting to $i/$domain" );
+ unset( $currentLoads[$i] ); // avoid this server next iteration
+ $i = false;
+ continue;
+ }
+
+ // Decrement reference counter, we are finished with this connection.
+ // It will be incremented for the caller later.
+ if ( $domain !== false ) {
+ $this->reuseConnection( $conn );
+ }
+
+ // Return this server
+ break;
+ }
+
+ // If all servers were down, quit now
+ if ( !count( $currentLoads ) ) {
+ $this->connLogger->error( "All servers down" );
+ }
+
+ return [ $i, $laggedReplicaMode ];
+ }
+
+ public function waitFor( $pos ) {
+ $oldPos = $this->mWaitForPos;
+ try {
+ $this->mWaitForPos = $pos;
+ // If a generic reader connection was already established, then wait now
+ $i = $this->mReadIndex;
+ if ( $i > 0 ) {
+ if ( !$this->doWait( $i ) ) {
+ $this->laggedReplicaMode = true;
+ }
+ }
+ } finally {
+ // Restore the older position if it was higher since this is used for lag-protection
+ $this->setWaitForPositionIfHigher( $oldPos );
+ }
+ }
+
+ public function waitForOne( $pos, $timeout = null ) {
+ $oldPos = $this->mWaitForPos;
+ try {
+ $this->mWaitForPos = $pos;
+
+ $i = $this->mReadIndex;
+ if ( $i <= 0 ) {
+ // Pick a generic replica DB if there isn't one yet
+ $readLoads = $this->mLoads;
+ unset( $readLoads[$this->getWriterIndex()] ); // replica DBs only
+ $readLoads = array_filter( $readLoads ); // with non-zero load
+ $i = ArrayUtils::pickRandom( $readLoads );
+ }
+
+ if ( $i > 0 ) {
+ $ok = $this->doWait( $i, true, $timeout );
+ } else {
+ $ok = true; // no applicable loads
+ }
+ } finally {
+ # Restore the old position, as this is not used for lag-protection but for throttling
+ $this->mWaitForPos = $oldPos;
+ }
+
+ return $ok;
+ }
+
+ public function waitForAll( $pos, $timeout = null ) {
+ $oldPos = $this->mWaitForPos;
+ try {
+ $this->mWaitForPos = $pos;
+ $serverCount = count( $this->mServers );
+
+ $ok = true;
+ for ( $i = 1; $i < $serverCount; $i++ ) {
+ if ( $this->mLoads[$i] > 0 ) {
+ $ok = $this->doWait( $i, true, $timeout ) && $ok;
+ }
+ }
+ } finally {
+ # Restore the old position, as this is not used for lag-protection but for throttling
+ $this->mWaitForPos = $oldPos;
+ }
+
+ return $ok;
+ }
+
+ /**
+ * @param DBMasterPos|bool $pos
+ */
+ private function setWaitForPositionIfHigher( $pos ) {
+ if ( !$pos ) {
+ return;
+ }
+
+ if ( !$this->mWaitForPos || $pos->hasReached( $this->mWaitForPos ) ) {
+ $this->mWaitForPos = $pos;
+ }
+ }
+
+ /**
+ * @param int $i
+ * @return IDatabase|bool
+ */
+ public function getAnyOpenConnection( $i ) {
+ foreach ( $this->mConns as $connsByServer ) {
+ if ( !empty( $connsByServer[$i] ) ) {
+ /** @var IDatabase[] $serverConns */
+ $serverConns = $connsByServer[$i];
+
+ return reset( $serverConns );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Wait for a given replica DB to catch up to the master pos stored in $this
+ * @param int $index Server index
+ * @param bool $open Check the server even if a new connection has to be made
+ * @param int $timeout Max seconds to wait; default is mWaitTimeout
+ * @return bool
+ */
+ protected function doWait( $index, $open = false, $timeout = null ) {
+ $close = false; // close the connection afterwards
+
+ // Check if we already know that the DB has reached this point
+ $server = $this->getServerName( $index );
+ $key = $this->srvCache->makeGlobalKey( __CLASS__, 'last-known-pos', $server, 'v1' );
+ /** @var DBMasterPos $knownReachedPos */
+ $knownReachedPos = $this->srvCache->get( $key );
+ if (
+ $knownReachedPos instanceof DBMasterPos &&
+ $knownReachedPos->hasReached( $this->mWaitForPos )
+ ) {
+ $this->replLogger->debug( __METHOD__ .
+ ": replica DB $server known to be caught up (pos >= $knownReachedPos)." );
+ return true;
+ }
+
+ // Find a connection to wait on, creating one if needed and allowed
+ $conn = $this->getAnyOpenConnection( $index );
+ if ( !$conn ) {
+ if ( !$open ) {
+ $this->replLogger->debug( __METHOD__ . ": no connection open for $server" );
+
+ return false;
+ } else {
+ $conn = $this->openConnection( $index, self::DOMAIN_ANY );
+ if ( !$conn ) {
+ $this->replLogger->warning( __METHOD__ . ": failed to connect to $server" );
+
+ return false;
+ }
+ // Avoid connection spam in waitForAll() when connections
+ // are made just for the sake of doing this lag check.
+ $close = true;
+ }
+ }
+
+ $this->replLogger->info( __METHOD__ . ": Waiting for replica DB $server to catch up..." );
+ $timeout = $timeout ?: $this->mWaitTimeout;
+ $result = $conn->masterPosWait( $this->mWaitForPos, $timeout );
+
+ if ( $result == -1 || is_null( $result ) ) {
+ // Timed out waiting for replica DB, use master instead
+ $this->replLogger->warning(
+ __METHOD__ . ": Timed out waiting on {host} pos {$this->mWaitForPos}",
+ [ 'host' => $server ]
+ );
+ $ok = false;
+ } else {
+ $this->replLogger->info( __METHOD__ . ": Done" );
+ $ok = true;
+ // Remember that the DB reached this point
+ $this->srvCache->set( $key, $this->mWaitForPos, BagOStuff::TTL_DAY );
+ }
+
+ if ( $close ) {
+ $this->closeConnection( $conn );
+ }
+
+ return $ok;
+ }
+
+ public function getConnection( $i, $groups = [], $domain = false, $flags = 0 ) {
+ if ( $i === null || $i === false ) {
+ throw new InvalidArgumentException( 'Attempt to call ' . __METHOD__ .
+ ' with invalid server index' );
+ }
+
+ if ( $this->localDomain->equals( $domain ) || $domain === $this->localDomainIdAlias ) {
+ $domain = false; // local connection requested
+ }
+
+ $groups = ( $groups === false || $groups === [] )
+ ? [ false ] // check one "group": the generic pool
+ : (array)$groups;
+
+ $masterOnly = ( $i == self::DB_MASTER || $i == $this->getWriterIndex() );
+ $oldConnsOpened = $this->connsOpened; // connections open now
+
+ if ( $i == self::DB_MASTER ) {
+ $i = $this->getWriterIndex();
+ } else {
+ # Try to find an available server in any the query groups (in order)
+ foreach ( $groups as $group ) {
+ $groupIndex = $this->getReaderIndex( $group, $domain );
+ if ( $groupIndex !== false ) {
+ $i = $groupIndex;
+ break;
+ }
+ }
+ }
+
+ # Operation-based index
+ if ( $i == self::DB_REPLICA ) {
+ $this->mLastError = 'Unknown error'; // reset error string
+ # Try the general server pool if $groups are unavailable.
+ $i = ( $groups === [ false ] )
+ ? false // don't bother with this if that is what was tried above
+ : $this->getReaderIndex( false, $domain );
+ # Couldn't find a working server in getReaderIndex()?
+ if ( $i === false ) {
+ $this->mLastError = 'No working replica DB server: ' . $this->mLastError;
+ // Throw an exception
+ $this->reportConnectionError();
+ return null; // not reached
+ }
+ }
+
+ # Now we have an explicit index into the servers array
+ $conn = $this->openConnection( $i, $domain, $flags );
+ if ( !$conn ) {
+ // Throw an exception
+ $this->reportConnectionError();
+ return null; // not reached
+ }
+
+ # Profile any new connections that happen
+ if ( $this->connsOpened > $oldConnsOpened ) {
+ $host = $conn->getServer();
+ $dbname = $conn->getDBname();
+ $this->trxProfiler->recordConnection( $host, $dbname, $masterOnly );
+ }
+
+ if ( $masterOnly ) {
+ # Make master-requested DB handles inherit any read-only mode setting
+ $conn->setLBInfo( 'readOnlyReason', $this->getReadOnlyReason( $domain, $conn ) );
+ }
+
+ return $conn;
+ }
+
+ public function reuseConnection( $conn ) {
+ $serverIndex = $conn->getLBInfo( 'serverIndex' );
+ $refCount = $conn->getLBInfo( 'foreignPoolRefCount' );
+ if ( $serverIndex === null || $refCount === null ) {
+ /**
+ * This can happen in code like:
+ * foreach ( $dbs as $db ) {
+ * $conn = $lb->getConnection( $lb::DB_REPLICA, [], $db );
+ * ...
+ * $lb->reuseConnection( $conn );
+ * }
+ * When a connection to the local DB is opened in this way, reuseConnection()
+ * should be ignored
+ */
+ return;
+ } elseif ( $conn instanceof DBConnRef ) {
+ // DBConnRef already handles calling reuseConnection() and only passes the live
+ // Database instance to this method. Any caller passing in a DBConnRef is broken.
+ $this->connLogger->error( __METHOD__ . ": got DBConnRef instance.\n" .
+ ( new RuntimeException() )->getTraceAsString() );
+
+ return;
+ }
+
+ if ( $this->disabled ) {
+ return; // DBConnRef handle probably survived longer than the LoadBalancer
+ }
+
+ if ( $conn->getLBInfo( 'autoCommitOnly' ) ) {
+ $connFreeKey = self::KEY_FOREIGN_FREE_NOROUND;
+ $connInUseKey = self::KEY_FOREIGN_INUSE_NOROUND;
+ } else {
+ $connFreeKey = self::KEY_FOREIGN_FREE;
+ $connInUseKey = self::KEY_FOREIGN_INUSE;
+ }
+
+ $domain = $conn->getDomainID();
+ if ( !isset( $this->mConns[$connInUseKey][$serverIndex][$domain] ) ) {
+ throw new InvalidArgumentException( __METHOD__ .
+ ": connection $serverIndex/$domain not found; it may have already been freed." );
+ } elseif ( $this->mConns[$connInUseKey][$serverIndex][$domain] !== $conn ) {
+ throw new InvalidArgumentException( __METHOD__ .
+ ": connection $serverIndex/$domain mismatched; it may have already been freed." );
+ }
+
+ $conn->setLBInfo( 'foreignPoolRefCount', --$refCount );
+ if ( $refCount <= 0 ) {
+ $this->mConns[$connFreeKey][$serverIndex][$domain] = $conn;
+ unset( $this->mConns[$connInUseKey][$serverIndex][$domain] );
+ if ( !$this->mConns[$connInUseKey][$serverIndex] ) {
+ unset( $this->mConns[$connInUseKey][$serverIndex] ); // clean up
+ }
+ $this->connLogger->debug( __METHOD__ . ": freed connection $serverIndex/$domain" );
+ } else {
+ $this->connLogger->debug( __METHOD__ .
+ ": reference count for $serverIndex/$domain reduced to $refCount" );
+ }
+ }
+
+ public function getConnectionRef( $db, $groups = [], $domain = false, $flags = 0 ) {
+ $domain = ( $domain !== false ) ? $domain : $this->localDomain;
+
+ return new DBConnRef( $this, $this->getConnection( $db, $groups, $domain, $flags ) );
+ }
+
+ public function getLazyConnectionRef( $db, $groups = [], $domain = false, $flags = 0 ) {
+ $domain = ( $domain !== false ) ? $domain : $this->localDomain;
+
+ return new DBConnRef( $this, [ $db, $groups, $domain, $flags ] );
+ }
+
+ public function getMaintenanceConnectionRef( $db, $groups = [], $domain = false, $flags = 0 ) {
+ $domain = ( $domain !== false ) ? $domain : $this->localDomain;
+
+ return new MaintainableDBConnRef(
+ $this, $this->getConnection( $db, $groups, $domain, $flags ) );
+ }
+
+ public function openConnection( $i, $domain = false, $flags = 0 ) {
+ if ( $this->localDomain->equals( $domain ) || $domain === $this->localDomainIdAlias ) {
+ $domain = false; // local connection requested
+ }
+
+ if ( !$this->chronProtInitialized && $this->chronProt ) {
+ $this->connLogger->debug( __METHOD__ . ': calling initLB() before first connection.' );
+ // Load CP positions before connecting so that doWait() triggers later if needed
+ $this->chronProtInitialized = true;
+ $this->chronProt->initLB( $this );
+ }
+
+ // Check if an auto-commit connection is being requested. If so, it will not reuse the
+ // main set of DB connections but rather its own pool since:
+ // a) those are usually set to implicitly use transaction rounds via DBO_TRX
+ // b) those must support the use of explicit transaction rounds via beginMasterChanges()
+ $autoCommit = ( ( $flags & self::CONN_TRX_AUTO ) == self::CONN_TRX_AUTO );
+
+ if ( $domain !== false ) {
+ // Connection is to a foreign domain
+ $conn = $this->openForeignConnection( $i, $domain, $flags );
+ } else {
+ // Connection is to the local domain
+ $connKey = $autoCommit ? self::KEY_LOCAL_NOROUND : self::KEY_LOCAL;
+ if ( isset( $this->mConns[$connKey][$i][0] ) ) {
+ $conn = $this->mConns[$connKey][$i][0];
+ } else {
+ if ( !isset( $this->mServers[$i] ) || !is_array( $this->mServers[$i] ) ) {
+ throw new InvalidArgumentException( "No server with index '$i'." );
+ }
+ // Open a new connection
+ $server = $this->mServers[$i];
+ $server['serverIndex'] = $i;
+ $server['autoCommitOnly'] = $autoCommit;
+ $conn = $this->reallyOpenConnection( $server, false );
+ $host = $this->getServerName( $i );
+ if ( $conn->isOpen() ) {
+ $this->connLogger->debug( "Connected to database $i at '$host'." );
+ $this->mConns[$connKey][$i][0] = $conn;
+ } else {
+ $this->connLogger->warning( "Failed to connect to database $i at '$host'." );
+ $this->errorConnection = $conn;
+ $conn = false;
+ }
+ }
+ }
+
+ if ( $conn instanceof IDatabase && !$conn->isOpen() ) {
+ // Connection was made but later unrecoverably lost for some reason.
+ // Do not return a handle that will just throw exceptions on use,
+ // but let the calling code (e.g. getReaderIndex) try another server.
+ // See DatabaseMyslBase::ping() for how this can happen.
+ $this->errorConnection = $conn;
+ $conn = false;
+ }
+
+ if ( $autoCommit && $conn instanceof IDatabase ) {
+ $conn->clearFlag( $conn::DBO_TRX ); // auto-commit mode
+ }
+
+ return $conn;
+ }
+
+ /**
+ * Open a connection to a foreign DB, or return one if it is already open.
+ *
+ * Increments a reference count on the returned connection which locks the
+ * connection to the requested domain. This reference count can be
+ * decremented by calling reuseConnection().
+ *
+ * If a connection is open to the appropriate server already, but with the wrong
+ * database, it will be switched to the right database and returned, as long as
+ * it has been freed first with reuseConnection().
+ *
+ * On error, returns false, and the connection which caused the
+ * error will be available via $this->errorConnection.
+ *
+ * @note If disable() was called on this LoadBalancer, this method will throw a DBAccessError.
+ *
+ * @param int $i Server index
+ * @param string $domain Domain ID to open
+ * @param int $flags Class CONN_* constant bitfield
+ * @return Database
+ */
+ private function openForeignConnection( $i, $domain, $flags = 0 ) {
+ $domainInstance = DatabaseDomain::newFromId( $domain );
+ $dbName = $domainInstance->getDatabase();
+ $prefix = $domainInstance->getTablePrefix();
+ $autoCommit = ( ( $flags & self::CONN_TRX_AUTO ) == self::CONN_TRX_AUTO );
+
+ if ( $autoCommit ) {
+ $connFreeKey = self::KEY_FOREIGN_FREE_NOROUND;
+ $connInUseKey = self::KEY_FOREIGN_INUSE_NOROUND;
+ } else {
+ $connFreeKey = self::KEY_FOREIGN_FREE;
+ $connInUseKey = self::KEY_FOREIGN_INUSE;
+ }
+
+ if ( isset( $this->mConns[$connInUseKey][$i][$domain] ) ) {
+ // Reuse an in-use connection for the same domain
+ $conn = $this->mConns[$connInUseKey][$i][$domain];
+ $this->connLogger->debug( __METHOD__ . ": reusing connection $i/$domain" );
+ } elseif ( isset( $this->mConns[$connFreeKey][$i][$domain] ) ) {
+ // Reuse a free connection for the same domain
+ $conn = $this->mConns[$connFreeKey][$i][$domain];
+ unset( $this->mConns[$connFreeKey][$i][$domain] );
+ $this->mConns[$connInUseKey][$i][$domain] = $conn;
+ $this->connLogger->debug( __METHOD__ . ": reusing free connection $i/$domain" );
+ } elseif ( !empty( $this->mConns[$connFreeKey][$i] ) ) {
+ // Reuse a free connection from another domain
+ $conn = reset( $this->mConns[$connFreeKey][$i] );
+ $oldDomain = key( $this->mConns[$connFreeKey][$i] );
+ // The empty string as a DB name means "don't care".
+ // DatabaseMysqlBase::open() already handle this on connection.
+ if ( strlen( $dbName ) && !$conn->selectDB( $dbName ) ) {
+ $this->mLastError = "Error selecting database '$dbName' on server " .
+ $conn->getServer() . " from client host {$this->host}";
+ $this->errorConnection = $conn;
+ $conn = false;
+ } else {
+ $conn->tablePrefix( $prefix );
+ unset( $this->mConns[$connFreeKey][$i][$oldDomain] );
+ $this->mConns[$connInUseKey][$i][$domain] = $conn;
+ $this->connLogger->debug( __METHOD__ .
+ ": reusing free connection from $oldDomain for $domain" );
+ }
+ } else {
+ if ( !isset( $this->mServers[$i] ) || !is_array( $this->mServers[$i] ) ) {
+ throw new InvalidArgumentException( "No server with index '$i'." );
+ }
+ // Open a new connection
+ $server = $this->mServers[$i];
+ $server['serverIndex'] = $i;
+ $server['foreignPoolRefCount'] = 0;
+ $server['foreign'] = true;
+ $server['autoCommitOnly'] = $autoCommit;
+ $conn = $this->reallyOpenConnection( $server, $dbName );
+ if ( !$conn->isOpen() ) {
+ $this->connLogger->warning( __METHOD__ . ": connection error for $i/$domain" );
+ $this->errorConnection = $conn;
+ $conn = false;
+ } else {
+ $conn->tablePrefix( $prefix );
+ $this->mConns[$connInUseKey][$i][$domain] = $conn;
+ $this->connLogger->debug( __METHOD__ . ": opened new connection for $i/$domain" );
+ }
+ }
+
+ // Increment reference count
+ if ( $conn instanceof IDatabase ) {
+ $refCount = $conn->getLBInfo( 'foreignPoolRefCount' );
+ $conn->setLBInfo( 'foreignPoolRefCount', $refCount + 1 );
+ }
+
+ return $conn;
+ }
+
+ /**
+ * Test if the specified index represents an open connection
+ *
+ * @param int $index Server index
+ * @access private
+ * @return bool
+ */
+ private function isOpen( $index ) {
+ if ( !is_integer( $index ) ) {
+ return false;
+ }
+
+ return (bool)$this->getAnyOpenConnection( $index );
+ }
+
+ /**
+ * Really opens a connection. Uncached.
+ * Returns a Database object whether or not the connection was successful.
+ * @access private
+ *
+ * @param array $server
+ * @param string|bool $dbNameOverride Use "" to not select any database
+ * @return Database
+ * @throws DBAccessError
+ * @throws InvalidArgumentException
+ */
+ protected function reallyOpenConnection( array $server, $dbNameOverride = false ) {
+ if ( $this->disabled ) {
+ throw new DBAccessError();
+ }
+
+ if ( $dbNameOverride !== false ) {
+ $server['dbname'] = $dbNameOverride;
+ }
+
+ // Let the handle know what the cluster master is (e.g. "db1052")
+ $masterName = $this->getServerName( $this->getWriterIndex() );
+ $server['clusterMasterHost'] = $masterName;
+
+ // Log when many connection are made on requests
+ if ( ++$this->connsOpened >= self::CONN_HELD_WARN_THRESHOLD ) {
+ $this->perfLogger->warning( __METHOD__ . ": " .
+ "{$this->connsOpened}+ connections made (master=$masterName)" );
+ }
+
+ $server['srvCache'] = $this->srvCache;
+ // Set loggers and profilers
+ $server['connLogger'] = $this->connLogger;
+ $server['queryLogger'] = $this->queryLogger;
+ $server['errorLogger'] = $this->errorLogger;
+ $server['profiler'] = $this->profiler;
+ $server['trxProfiler'] = $this->trxProfiler;
+ // Use the same agent and PHP mode for all DB handles
+ $server['cliMode'] = $this->cliMode;
+ $server['agent'] = $this->agent;
+ // Use DBO_DEFAULT flags by default for LoadBalancer managed databases. Assume that the
+ // application calls LoadBalancer::commitMasterChanges() before the PHP script completes.
+ $server['flags'] = isset( $server['flags'] ) ? $server['flags'] : IDatabase::DBO_DEFAULT;
+
+ // Create a live connection object
+ try {
+ $db = Database::factory( $server['type'], $server );
+ } catch ( DBConnectionError $e ) {
+ // FIXME: This is probably the ugliest thing I have ever done to
+ // PHP. I'm half-expecting it to segfault, just out of disgust. -- TS
+ $db = $e->db;
+ }
+
+ $db->setLBInfo( $server );
+ $db->setLazyMasterHandle(
+ $this->getLazyConnectionRef( self::DB_MASTER, [], $db->getDomainID() )
+ );
+ $db->setTableAliases( $this->tableAliases );
+
+ if ( $server['serverIndex'] === $this->getWriterIndex() ) {
+ if ( $this->trxRoundId !== false ) {
+ $this->applyTransactionRoundFlags( $db );
+ }
+ foreach ( $this->trxRecurringCallbacks as $name => $callback ) {
+ $db->setTransactionListener( $name, $callback );
+ }
+ }
+
+ return $db;
+ }
+
+ /**
+ * @throws DBConnectionError
+ */
+ private function reportConnectionError() {
+ $conn = $this->errorConnection; // the connection which caused the error
+ $context = [
+ 'method' => __METHOD__,
+ 'last_error' => $this->mLastError,
+ ];
+
+ if ( $conn instanceof IDatabase ) {
+ $context['db_server'] = $conn->getServer();
+ $this->connLogger->warning(
+ "Connection error: {last_error} ({db_server})",
+ $context
+ );
+
+ // throws DBConnectionError
+ $conn->reportConnectionError( "{$this->mLastError} ({$context['db_server']})" );
+ } else {
+ // No last connection, probably due to all servers being too busy
+ $this->connLogger->error(
+ "LB failure with no last connection. Connection error: {last_error}",
+ $context
+ );
+
+ // If all servers were busy, mLastError will contain something sensible
+ throw new DBConnectionError( null, $this->mLastError );
+ }
+ }
+
+ public function getWriterIndex() {
+ return 0;
+ }
+
+ public function haveIndex( $i ) {
+ return array_key_exists( $i, $this->mServers );
+ }
+
+ public function isNonZeroLoad( $i ) {
+ return array_key_exists( $i, $this->mServers ) && $this->mLoads[$i] != 0;
+ }
+
+ public function getServerCount() {
+ return count( $this->mServers );
+ }
+
+ public function getServerName( $i ) {
+ if ( isset( $this->mServers[$i]['hostName'] ) ) {
+ $name = $this->mServers[$i]['hostName'];
+ } elseif ( isset( $this->mServers[$i]['host'] ) ) {
+ $name = $this->mServers[$i]['host'];
+ } else {
+ $name = '';
+ }
+
+ return ( $name != '' ) ? $name : 'localhost';
+ }
+
+ public function getServerType( $i ) {
+ return isset( $this->mServers[$i]['type'] ) ? $this->mServers[$i]['type'] : 'unknown';
+ }
+
+ /**
+ * @deprecated Since 1.30, no alternative
+ */
+ public function getServerInfo( $i ) {
+ wfDeprecated( __METHOD__, '1.30' );
+ if ( isset( $this->mServers[$i] ) ) {
+ return $this->mServers[$i];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @deprecated Since 1.30, construct new object
+ */
+ public function setServerInfo( $i, array $serverInfo ) {
+ wfDeprecated( __METHOD__, '1.30' );
+ $this->mServers[$i] = $serverInfo;
+ }
+
+ public function getMasterPos() {
+ # If this entire request was served from a replica DB without opening a connection to the
+ # master (however unlikely that may be), then we can fetch the position from the replica DB.
+ $masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() );
+ if ( !$masterConn ) {
+ $serverCount = count( $this->mServers );
+ for ( $i = 1; $i < $serverCount; $i++ ) {
+ $conn = $this->getAnyOpenConnection( $i );
+ if ( $conn ) {
+ return $conn->getReplicaPos();
+ }
+ }
+ } else {
+ return $masterConn->getMasterPos();
+ }
+
+ return false;
+ }
+
+ public function disable() {
+ $this->closeAll();
+ $this->disabled = true;
+ }
+
+ public function closeAll() {
+ $this->forEachOpenConnection( function ( IDatabase $conn ) {
+ $host = $conn->getServer();
+ $this->connLogger->debug( "Closing connection to database '$host'." );
+ $conn->close();
+ } );
+
+ $this->mConns = [
+ self::KEY_LOCAL => [],
+ self::KEY_FOREIGN_INUSE => [],
+ self::KEY_FOREIGN_FREE => [],
+ self::KEY_LOCAL_NOROUND => [],
+ self::KEY_FOREIGN_INUSE_NOROUND => [],
+ self::KEY_FOREIGN_FREE_NOROUND => []
+ ];
+ $this->connsOpened = 0;
+ }
+
+ public function closeConnection( IDatabase $conn ) {
+ $serverIndex = $conn->getLBInfo( 'serverIndex' ); // second index level of mConns
+ foreach ( $this->mConns as $type => $connsByServer ) {
+ if ( !isset( $connsByServer[$serverIndex] ) ) {
+ continue;
+ }
+
+ foreach ( $connsByServer[$serverIndex] as $i => $trackedConn ) {
+ if ( $conn === $trackedConn ) {
+ $host = $this->getServerName( $i );
+ $this->connLogger->debug( "Closing connection to database $i at '$host'." );
+ unset( $this->mConns[$type][$serverIndex][$i] );
+ --$this->connsOpened;
+ break 2;
+ }
+ }
+ }
+
+ $conn->close();
+ }
+
+ public function commitAll( $fname = __METHOD__ ) {
+ $failures = [];
+
+ $restore = ( $this->trxRoundId !== false );
+ $this->trxRoundId = false;
+ $this->forEachOpenConnection(
+ function ( IDatabase $conn ) use ( $fname, $restore, &$failures ) {
+ try {
+ $conn->commit( $fname, $conn::FLUSHING_ALL_PEERS );
+ } catch ( DBError $e ) {
+ call_user_func( $this->errorLogger, $e );
+ $failures[] = "{$conn->getServer()}: {$e->getMessage()}";
+ }
+ if ( $restore && $conn->getLBInfo( 'master' ) ) {
+ $this->undoTransactionRoundFlags( $conn );
+ }
+ }
+ );
+
+ if ( $failures ) {
+ throw new DBExpectedError(
+ null,
+ "Commit failed on server(s) " . implode( "\n", array_unique( $failures ) )
+ );
+ }
+ }
+
+ public function finalizeMasterChanges() {
+ $this->forEachOpenMasterConnection( function ( Database $conn ) {
+ // Any error should cause all DB transactions to be rolled back together
+ $conn->setTrxEndCallbackSuppression( false );
+ $conn->runOnTransactionPreCommitCallbacks();
+ // Defer post-commit callbacks until COMMIT finishes for all DBs
+ $conn->setTrxEndCallbackSuppression( true );
+ } );
+ }
+
+ public function approveMasterChanges( array $options ) {
+ $limit = isset( $options['maxWriteDuration'] ) ? $options['maxWriteDuration'] : 0;
+ $this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( $limit ) {
+ // If atomic sections or explicit transactions are still open, some caller must have
+ // caught an exception but failed to properly rollback any changes. Detect that and
+ // throw and error (causing rollback).
+ if ( $conn->explicitTrxActive() ) {
+ throw new DBTransactionError(
+ $conn,
+ "Explicit transaction still active. A caller may have caught an error."
+ );
+ }
+ // Assert that the time to replicate the transaction will be sane.
+ // If this fails, then all DB transactions will be rollback back together.
+ $time = $conn->pendingWriteQueryDuration( $conn::ESTIMATE_DB_APPLY );
+ if ( $limit > 0 && $time > $limit ) {
+ throw new DBTransactionSizeError(
+ $conn,
+ "Transaction spent $time second(s) in writes, exceeding the limit of $limit.",
+ [ $time, $limit ]
+ );
+ }
+ // If a connection sits idle while slow queries execute on another, that connection
+ // may end up dropped before the commit round is reached. Ping servers to detect this.
+ if ( $conn->writesOrCallbacksPending() && !$conn->ping() ) {
+ throw new DBTransactionError(
+ $conn,
+ "A connection to the {$conn->getDBname()} database was lost before commit."
+ );
+ }
+ } );
+ }
+
+ public function beginMasterChanges( $fname = __METHOD__ ) {
+ if ( $this->trxRoundId !== false ) {
+ throw new DBTransactionError(
+ null,
+ "$fname: Transaction round '{$this->trxRoundId}' already started."
+ );
+ }
+ $this->trxRoundId = $fname;
+
+ $failures = [];
+ $this->forEachOpenMasterConnection(
+ function ( Database $conn ) use ( $fname, &$failures ) {
+ $conn->setTrxEndCallbackSuppression( true );
+ try {
+ $conn->flushSnapshot( $fname );
+ } catch ( DBError $e ) {
+ call_user_func( $this->errorLogger, $e );
+ $failures[] = "{$conn->getServer()}: {$e->getMessage()}";
+ }
+ $conn->setTrxEndCallbackSuppression( false );
+ $this->applyTransactionRoundFlags( $conn );
+ }
+ );
+
+ if ( $failures ) {
+ throw new DBExpectedError(
+ null,
+ "$fname: Flush failed on server(s) " . implode( "\n", array_unique( $failures ) )
+ );
+ }
+ }
+
+ public function commitMasterChanges( $fname = __METHOD__ ) {
+ $failures = [];
+
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $scope = $this->getScopedPHPBehaviorForCommit(); // try to ignore client aborts
+
+ $restore = ( $this->trxRoundId !== false );
+ $this->trxRoundId = false;
+ $this->forEachOpenMasterConnection(
+ function ( IDatabase $conn ) use ( $fname, $restore, &$failures ) {
+ try {
+ if ( $conn->writesOrCallbacksPending() ) {
+ $conn->commit( $fname, $conn::FLUSHING_ALL_PEERS );
+ } elseif ( $restore ) {
+ $conn->flushSnapshot( $fname );
+ }
+ } catch ( DBError $e ) {
+ call_user_func( $this->errorLogger, $e );
+ $failures[] = "{$conn->getServer()}: {$e->getMessage()}";
+ }
+ if ( $restore ) {
+ $this->undoTransactionRoundFlags( $conn );
+ }
+ }
+ );
+
+ if ( $failures ) {
+ throw new DBExpectedError(
+ null,
+ "$fname: Commit failed on server(s) " . implode( "\n", array_unique( $failures ) )
+ );
+ }
+ }
+
+ public function runMasterPostTrxCallbacks( $type ) {
+ $e = null; // first exception
+ $this->forEachOpenMasterConnection( function ( Database $conn ) use ( $type, &$e ) {
+ $conn->setTrxEndCallbackSuppression( false );
+ if ( $conn->writesOrCallbacksPending() ) {
+ // This happens if onTransactionIdle() callbacks leave callbacks on *another* DB
+ // (which finished its callbacks already). Warn and recover in this case. Let the
+ // callbacks run in the final commitMasterChanges() in LBFactory::shutdown().
+ $this->queryLogger->info( __METHOD__ . ": found writes/callbacks pending." );
+ return;
+ } elseif ( $conn->trxLevel() ) {
+ // This happens for single-DB setups where DB_REPLICA uses the master DB,
+ // thus leaving an implicit read-only transaction open at this point. It
+ // also happens if onTransactionIdle() callbacks leave implicit transactions
+ // open on *other* DBs (which is slightly improper). Let these COMMIT on the
+ // next call to commitMasterChanges(), possibly in LBFactory::shutdown().
+ return;
+ }
+ try {
+ $conn->runOnTransactionIdleCallbacks( $type );
+ } catch ( Exception $ex ) {
+ $e = $e ?: $ex;
+ }
+ try {
+ $conn->runTransactionListenerCallbacks( $type );
+ } catch ( Exception $ex ) {
+ $e = $e ?: $ex;
+ }
+ } );
+
+ return $e;
+ }
+
+ public function rollbackMasterChanges( $fname = __METHOD__ ) {
+ $restore = ( $this->trxRoundId !== false );
+ $this->trxRoundId = false;
+ $this->forEachOpenMasterConnection(
+ function ( IDatabase $conn ) use ( $fname, $restore ) {
+ if ( $conn->writesOrCallbacksPending() ) {
+ $conn->rollback( $fname, $conn::FLUSHING_ALL_PEERS );
+ }
+ if ( $restore ) {
+ $this->undoTransactionRoundFlags( $conn );
+ }
+ }
+ );
+ }
+
+ public function suppressTransactionEndCallbacks() {
+ $this->forEachOpenMasterConnection( function ( Database $conn ) {
+ $conn->setTrxEndCallbackSuppression( true );
+ } );
+ }
+
+ /**
+ * @param IDatabase $conn
+ */
+ private function applyTransactionRoundFlags( IDatabase $conn ) {
+ if ( $conn->getLBInfo( 'autoCommitOnly' ) ) {
+ return; // transaction rounds do not apply to these connections
+ }
+
+ if ( $conn->getFlag( $conn::DBO_DEFAULT ) ) {
+ // DBO_TRX is controlled entirely by CLI mode presence with DBO_DEFAULT.
+ // Force DBO_TRX even in CLI mode since a commit round is expected soon.
+ $conn->setFlag( $conn::DBO_TRX, $conn::REMEMBER_PRIOR );
+ // If config has explicitly requested DBO_TRX be either on or off by not
+ // setting DBO_DEFAULT, then respect that. Forcing no transactions is useful
+ // for things like blob stores (ExternalStore) which want auto-commit mode.
+ }
+ }
+
+ /**
+ * @param IDatabase $conn
+ */
+ private function undoTransactionRoundFlags( IDatabase $conn ) {
+ if ( $conn->getLBInfo( 'autoCommitOnly' ) ) {
+ return; // transaction rounds do not apply to these connections
+ }
+
+ if ( $conn->getFlag( $conn::DBO_DEFAULT ) ) {
+ $conn->restoreFlags( $conn::RESTORE_PRIOR );
+ }
+ }
+
+ public function flushReplicaSnapshots( $fname = __METHOD__ ) {
+ $this->forEachOpenReplicaConnection( function ( IDatabase $conn ) {
+ $conn->flushSnapshot( __METHOD__ );
+ } );
+ }
+
+ public function hasMasterConnection() {
+ return $this->isOpen( $this->getWriterIndex() );
+ }
+
+ public function hasMasterChanges() {
+ $pending = 0;
+ $this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$pending ) {
+ $pending |= $conn->writesOrCallbacksPending();
+ } );
+
+ return (bool)$pending;
+ }
+
+ public function lastMasterChangeTimestamp() {
+ $lastTime = false;
+ $this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$lastTime ) {
+ $lastTime = max( $lastTime, $conn->lastDoneWrites() );
+ } );
+
+ return $lastTime;
+ }
+
+ public function hasOrMadeRecentMasterChanges( $age = null ) {
+ $age = ( $age === null ) ? $this->mWaitTimeout : $age;
+
+ return ( $this->hasMasterChanges()
+ || $this->lastMasterChangeTimestamp() > microtime( true ) - $age );
+ }
+
+ public function pendingMasterChangeCallers() {
+ $fnames = [];
+ $this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( &$fnames ) {
+ $fnames = array_merge( $fnames, $conn->pendingWriteCallers() );
+ } );
+
+ return $fnames;
+ }
+
+ public function getLaggedReplicaMode( $domain = false ) {
+ // No-op if there is only one DB (also avoids recursion)
+ if ( !$this->laggedReplicaMode && $this->getServerCount() > 1 ) {
+ try {
+ // See if laggedReplicaMode gets set
+ $conn = $this->getConnection( self::DB_REPLICA, false, $domain );
+ $this->reuseConnection( $conn );
+ } catch ( DBConnectionError $e ) {
+ // Avoid expensive re-connect attempts and failures
+ $this->allReplicasDownMode = true;
+ $this->laggedReplicaMode = true;
+ }
+ }
+
+ return $this->laggedReplicaMode;
+ }
+
+ /**
+ * @param bool $domain
+ * @return bool
+ * @deprecated 1.28; use getLaggedReplicaMode()
+ */
+ public function getLaggedSlaveMode( $domain = false ) {
+ return $this->getLaggedReplicaMode( $domain );
+ }
+
+ public function laggedReplicaUsed() {
+ return $this->laggedReplicaMode;
+ }
+
+ /**
+ * @return bool
+ * @since 1.27
+ * @deprecated Since 1.28; use laggedReplicaUsed()
+ */
+ public function laggedSlaveUsed() {
+ return $this->laggedReplicaUsed();
+ }
+
+ public function getReadOnlyReason( $domain = false, IDatabase $conn = null ) {
+ if ( $this->readOnlyReason !== false ) {
+ return $this->readOnlyReason;
+ } elseif ( $this->getLaggedReplicaMode( $domain ) ) {
+ if ( $this->allReplicasDownMode ) {
+ return 'The database has been automatically locked ' .
+ 'until the replica database servers become available';
+ } else {
+ return 'The database has been automatically locked ' .
+ 'while the replica database servers catch up to the master.';
+ }
+ } elseif ( $this->masterRunningReadOnly( $domain, $conn ) ) {
+ return 'The database master is running in read-only mode.';
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $domain Domain ID, or false for the current domain
+ * @param IDatabase|null $conn DB master connectionl used to avoid loops [optional]
+ * @return bool
+ */
+ private function masterRunningReadOnly( $domain, IDatabase $conn = null ) {
+ $cache = $this->wanCache;
+ $masterServer = $this->getServerName( $this->getWriterIndex() );
+
+ return (bool)$cache->getWithSetCallback(
+ $cache->makeGlobalKey( __CLASS__, 'server-read-only', $masterServer ),
+ self::TTL_CACHE_READONLY,
+ function () use ( $domain, $conn ) {
+ $old = $this->trxProfiler->setSilenced( true );
+ try {
+ $dbw = $conn ?: $this->getConnection( self::DB_MASTER, [], $domain );
+ $readOnly = (int)$dbw->serverIsReadOnly();
+ if ( !$conn ) {
+ $this->reuseConnection( $dbw );
+ }
+ } catch ( DBError $e ) {
+ $readOnly = 0;
+ }
+ $this->trxProfiler->setSilenced( $old );
+ return $readOnly;
+ },
+ [ 'pcTTL' => $cache::TTL_PROC_LONG, 'busyValue' => 0 ]
+ );
+ }
+
+ public function allowLagged( $mode = null ) {
+ if ( $mode === null ) {
+ return $this->mAllowLagged;
+ }
+ $this->mAllowLagged = $mode;
+
+ return $this->mAllowLagged;
+ }
+
+ public function pingAll() {
+ $success = true;
+ $this->forEachOpenConnection( function ( IDatabase $conn ) use ( &$success ) {
+ if ( !$conn->ping() ) {
+ $success = false;
+ }
+ } );
+
+ return $success;
+ }
+
+ public function forEachOpenConnection( $callback, array $params = [] ) {
+ foreach ( $this->mConns as $connsByServer ) {
+ foreach ( $connsByServer as $serverConns ) {
+ foreach ( $serverConns as $conn ) {
+ $mergedParams = array_merge( [ $conn ], $params );
+ call_user_func_array( $callback, $mergedParams );
+ }
+ }
+ }
+ }
+
+ public function forEachOpenMasterConnection( $callback, array $params = [] ) {
+ $masterIndex = $this->getWriterIndex();
+ foreach ( $this->mConns as $connsByServer ) {
+ if ( isset( $connsByServer[$masterIndex] ) ) {
+ /** @var IDatabase $conn */
+ foreach ( $connsByServer[$masterIndex] as $conn ) {
+ $mergedParams = array_merge( [ $conn ], $params );
+ call_user_func_array( $callback, $mergedParams );
+ }
+ }
+ }
+ }
+
+ public function forEachOpenReplicaConnection( $callback, array $params = [] ) {
+ foreach ( $this->mConns as $connsByServer ) {
+ foreach ( $connsByServer as $i => $serverConns ) {
+ if ( $i === $this->getWriterIndex() ) {
+ continue; // skip master
+ }
+ foreach ( $serverConns as $conn ) {
+ $mergedParams = array_merge( [ $conn ], $params );
+ call_user_func_array( $callback, $mergedParams );
+ }
+ }
+ }
+ }
+
+ public function getMaxLag( $domain = false ) {
+ $maxLag = -1;
+ $host = '';
+ $maxIndex = 0;
+
+ if ( $this->getServerCount() <= 1 ) {
+ return [ $host, $maxLag, $maxIndex ]; // no replication = no lag
+ }
+
+ $lagTimes = $this->getLagTimes( $domain );
+ foreach ( $lagTimes as $i => $lag ) {
+ if ( $this->mLoads[$i] > 0 && $lag > $maxLag ) {
+ $maxLag = $lag;
+ $host = $this->mServers[$i]['host'];
+ $maxIndex = $i;
+ }
+ }
+
+ return [ $host, $maxLag, $maxIndex ];
+ }
+
+ public function getLagTimes( $domain = false ) {
+ if ( $this->getServerCount() <= 1 ) {
+ return [ $this->getWriterIndex() => 0 ]; // no replication = no lag
+ }
+
+ $knownLagTimes = []; // map of (server index => 0 seconds)
+ $indexesWithLag = [];
+ foreach ( $this->mServers as $i => $server ) {
+ if ( empty( $server['is static'] ) ) {
+ $indexesWithLag[] = $i; // DB server might have replication lag
+ } else {
+ $knownLagTimes[$i] = 0; // DB server is a non-replicating and read-only archive
+ }
+ }
+
+ return $this->getLoadMonitor()->getLagTimes( $indexesWithLag, $domain ) + $knownLagTimes;
+ }
+
+ public function safeGetLag( IDatabase $conn ) {
+ if ( $this->getServerCount() <= 1 ) {
+ return 0;
+ } else {
+ return $conn->getLag();
+ }
+ }
+
+ /**
+ * @param IDatabase $conn
+ * @param DBMasterPos|bool $pos
+ * @param int $timeout
+ * @return bool
+ */
+ public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ) {
+ if ( $this->getServerCount() <= 1 || !$conn->getLBInfo( 'replica' ) ) {
+ return true; // server is not a replica DB
+ }
+
+ if ( !$pos ) {
+ // Get the current master position, opening a connection if needed
+ $masterConn = $this->getAnyOpenConnection( $this->getWriterIndex() );
+ if ( $masterConn ) {
+ $pos = $masterConn->getMasterPos();
+ } else {
+ $masterConn = $this->openConnection( $this->getWriterIndex(), self::DOMAIN_ANY );
+ $pos = $masterConn->getMasterPos();
+ $this->closeConnection( $masterConn );
+ }
+ }
+
+ if ( $pos instanceof DBMasterPos ) {
+ $result = $conn->masterPosWait( $pos, $timeout );
+ if ( $result == -1 || is_null( $result ) ) {
+ $msg = __METHOD__ . ": Timed out waiting on {$conn->getServer()} pos {$pos}";
+ $this->replLogger->warning( "$msg" );
+ $ok = false;
+ } else {
+ $this->replLogger->info( __METHOD__ . ": Done" );
+ $ok = true;
+ }
+ } else {
+ $ok = false; // something is misconfigured
+ $this->replLogger->error( "Could not get master pos for {$conn->getServer()}." );
+ }
+
+ return $ok;
+ }
+
+ public function setTransactionListener( $name, callable $callback = null ) {
+ if ( $callback ) {
+ $this->trxRecurringCallbacks[$name] = $callback;
+ } else {
+ unset( $this->trxRecurringCallbacks[$name] );
+ }
+ $this->forEachOpenMasterConnection(
+ function ( IDatabase $conn ) use ( $name, $callback ) {
+ $conn->setTransactionListener( $name, $callback );
+ }
+ );
+ }
+
+ public function setTableAliases( array $aliases ) {
+ $this->tableAliases = $aliases;
+ }
+
+ public function setDomainPrefix( $prefix ) {
+ // Find connections to explicit foreign domains still marked as in-use...
+ $domainsInUse = [];
+ $this->forEachOpenConnection( function ( IDatabase $conn ) use ( &$domainsInUse ) {
+ // Once reuseConnection() is called on a handle, its reference count goes from 1 to 0.
+ // Until then, it is still in use by the caller (explicitly or via DBConnRef scope).
+ if ( $conn->getLBInfo( 'foreignPoolRefCount' ) > 0 ) {
+ $domainsInUse[] = $conn->getDomainID();
+ }
+ } );
+
+ // Do not switch connections to explicit foreign domains unless marked as safe
+ if ( $domainsInUse ) {
+ $domains = implode( ', ', $domainsInUse );
+ throw new DBUnexpectedError( null,
+ "Foreign domain connections are still in use ($domains)." );
+ }
+
+ $this->localDomain = new DatabaseDomain(
+ $this->localDomain->getDatabase(),
+ null,
+ $prefix
+ );
+
+ $this->forEachOpenConnection( function ( IDatabase $db ) use ( $prefix ) {
+ $db->tablePrefix( $prefix );
+ } );
+ }
+
+ /**
+ * Make PHP ignore user aborts/disconnects until the returned
+ * value leaves scope. This returns null and does nothing in CLI mode.
+ *
+ * @return ScopedCallback|null
+ */
+ final protected function getScopedPHPBehaviorForCommit() {
+ if ( PHP_SAPI != 'cli' ) { // https://bugs.php.net/bug.php?id=47540
+ $old = ignore_user_abort( true ); // avoid half-finished operations
+ return new ScopedCallback( function () use ( $old ) {
+ ignore_user_abort( $old );
+ } );
+ }
+
+ return null;
+ }
+
+ function __destruct() {
+ // Avoid connection leaks for sanity
+ $this->disable();
+ }
+}
+
+class_alias( LoadBalancer::class, 'LoadBalancer' );
diff --git a/www/wiki/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php b/www/wiki/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php
new file mode 100644
index 00000000..79d250f6
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/loadbalancer/LoadBalancerSingle.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * Simple generator of database connections that always returns the same object.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
+/**
+ * Trivial LoadBalancer that always returns an injected connection handle
+ */
+class LoadBalancerSingle extends LoadBalancer {
+ /** @var IDatabase */
+ private $db;
+
+ /**
+ * @param array $params An associative array with one member:
+ * - connection: An IDatabase connection object
+ */
+ public function __construct( array $params ) {
+ if ( !isset( $params['connection'] ) ) {
+ throw new InvalidArgumentException( "Missing 'connection' argument." );
+ }
+
+ $this->db = $params['connection'];
+
+ parent::__construct( [
+ 'servers' => [
+ [
+ 'type' => $this->db->getType(),
+ 'host' => $this->db->getServer(),
+ 'dbname' => $this->db->getDBname(),
+ 'load' => 1,
+ ]
+ ],
+ 'trxProfiler' => isset( $params['trxProfiler'] ) ? $params['trxProfiler'] : null,
+ 'srvCache' => isset( $params['srvCache'] ) ? $params['srvCache'] : null,
+ 'wanCache' => isset( $params['wanCache'] ) ? $params['wanCache'] : null
+ ] );
+
+ if ( isset( $params['readOnlyReason'] ) ) {
+ $this->db->setLBInfo( 'readOnlyReason', $params['readOnlyReason'] );
+ }
+ }
+
+ /**
+ * @param IDatabase $db Live connection handle
+ * @param array $params Parameter map to LoadBalancerSingle::__constructs()
+ * @return LoadBalancerSingle
+ * @since 1.28
+ */
+ public static function newFromConnection( IDatabase $db, array $params = [] ) {
+ return new static( [ 'connection' => $db ] + $params );
+ }
+
+ protected function reallyOpenConnection( array $server, $dbNameOverride = false ) {
+ return $this->db;
+ }
+}
+
+class_alias( 'Wikimedia\Rdbms\LoadBalancerSingle', 'LoadBalancerSingle' );
diff --git a/www/wiki/includes/libs/rdbms/loadmonitor/ILoadMonitor.php b/www/wiki/includes/libs/rdbms/loadmonitor/ILoadMonitor.php
new file mode 100644
index 00000000..a0877a46
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/loadmonitor/ILoadMonitor.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Database load monitoring interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerAwareInterface;
+use BagOStuff;
+use WANObjectCache;
+
+/**
+ * An interface for database load monitoring
+ *
+ * @ingroup Database
+ */
+interface ILoadMonitor extends LoggerAwareInterface {
+ /**
+ * Construct a new LoadMonitor with a given LoadBalancer parent
+ *
+ * @param ILoadBalancer $lb LoadBalancer this instance serves
+ * @param BagOStuff $sCache Local server memory cache
+ * @param WANObjectCache $wCache Local cluster memory cache
+ * @param array $options Options map
+ */
+ public function __construct(
+ ILoadBalancer $lb, BagOStuff $sCache, WANObjectCache $wCache, array $options = []
+ );
+
+ /**
+ * Perform load ratio adjustment before deciding which server to use
+ *
+ * @param int[] &$weightByServer Map of (server index => float weight)
+ * @param string|bool $domain
+ */
+ public function scaleLoads( array &$weightByServer, $domain );
+
+ /**
+ * Get an estimate of replication lag (in seconds) for each server
+ *
+ * Values may be "false" if replication is too broken to estimate
+ *
+ * @param int[] $serverIndexes
+ * @param string $domain
+ * @return array Map of (server index => float|int|bool)
+ */
+ public function getLagTimes( array $serverIndexes, $domain );
+}
diff --git a/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitor.php b/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitor.php
new file mode 100644
index 00000000..8292c036
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitor.php
@@ -0,0 +1,226 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\ScopedCallback;
+use BagOStuff;
+use WANObjectCache;
+
+/**
+ * Basic DB load monitor with no external dependencies
+ * Uses memcached to cache the replication lag for a short time
+ *
+ * @ingroup Database
+ */
+class LoadMonitor implements ILoadMonitor {
+ /** @var ILoadBalancer */
+ protected $parent;
+ /** @var BagOStuff */
+ protected $srvCache;
+ /** @var WANObjectCache */
+ protected $wanCache;
+ /** @var LoggerInterface */
+ protected $replLogger;
+
+ /** @var float Moving average ratio (e.g. 0.1 for 10% weight to new weight) */
+ private $movingAveRatio;
+
+ const VERSION = 1; // cache key version
+
+ public function __construct(
+ ILoadBalancer $lb, BagOStuff $srvCache, WANObjectCache $wCache, array $options = []
+ ) {
+ $this->parent = $lb;
+ $this->srvCache = $srvCache;
+ $this->wanCache = $wCache;
+ $this->replLogger = new NullLogger();
+
+ $this->movingAveRatio = isset( $options['movingAveRatio'] )
+ ? $options['movingAveRatio']
+ : 0.1;
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->replLogger = $logger;
+ }
+
+ public function scaleLoads( array &$weightByServer, $domain ) {
+ $serverIndexes = array_keys( $weightByServer );
+ $states = $this->getServerStates( $serverIndexes, $domain );
+ $coefficientsByServer = $states['weightScales'];
+ foreach ( $weightByServer as $i => $weight ) {
+ if ( isset( $coefficientsByServer[$i] ) ) {
+ $weightByServer[$i] = $weight * $coefficientsByServer[$i];
+ } else { // server recently added to config?
+ $host = $this->parent->getServerName( $i );
+ $this->replLogger->error( __METHOD__ . ": host $host not in cache" );
+ }
+ }
+ }
+
+ public function getLagTimes( array $serverIndexes, $domain ) {
+ $states = $this->getServerStates( $serverIndexes, $domain );
+
+ return $states['lagTimes'];
+ }
+
+ protected function getServerStates( array $serverIndexes, $domain ) {
+ $writerIndex = $this->parent->getWriterIndex();
+ if ( count( $serverIndexes ) == 1 && reset( $serverIndexes ) == $writerIndex ) {
+ # Single server only, just return zero without caching
+ return [
+ 'lagTimes' => [ $writerIndex => 0 ],
+ 'weightScales' => [ $writerIndex => 1.0 ]
+ ];
+ }
+
+ $key = $this->getCacheKey( $serverIndexes );
+ # Randomize TTLs to reduce stampedes (4.0 - 5.0 sec)
+ $ttl = mt_rand( 4e6, 5e6 ) / 1e6;
+ # Keep keys around longer as fallbacks
+ $staleTTL = 60;
+
+ # (a) Check the local APC cache
+ $value = $this->srvCache->get( $key );
+ if ( $value && $value['timestamp'] > ( microtime( true ) - $ttl ) ) {
+ $this->replLogger->debug( __METHOD__ . ": got lag times ($key) from local cache" );
+ return $value; // cache hit
+ }
+ $staleValue = $value ?: false;
+
+ # (b) Check the shared cache and backfill APC
+ $value = $this->wanCache->get( $key );
+ if ( $value && $value['timestamp'] > ( microtime( true ) - $ttl ) ) {
+ $this->srvCache->set( $key, $value, $staleTTL );
+ $this->replLogger->debug( __METHOD__ . ": got lag times ($key) from main cache" );
+
+ return $value; // cache hit
+ }
+ $staleValue = $value ?: $staleValue;
+
+ # (c) Cache key missing or expired; regenerate and backfill
+ if ( $this->srvCache->lock( $key, 0, 10 ) ) {
+ # Let only this process update the cache value on this server
+ $sCache = $this->srvCache;
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $unlocker = new ScopedCallback( function () use ( $sCache, $key ) {
+ $sCache->unlock( $key );
+ } );
+ } elseif ( $staleValue ) {
+ # Could not acquire lock but an old cache exists, so use it
+ return $staleValue;
+ }
+
+ $lagTimes = [];
+ $weightScales = [];
+ $movAveRatio = $this->movingAveRatio;
+ foreach ( $serverIndexes as $i ) {
+ if ( $i == $this->parent->getWriterIndex() ) {
+ $lagTimes[$i] = 0; // master always has no lag
+ $weightScales[$i] = 1.0; // nominal weight
+ continue;
+ }
+
+ $conn = $this->parent->getAnyOpenConnection( $i );
+ if ( $conn ) {
+ $close = false; // already open
+ } else {
+ $conn = $this->parent->openConnection( $i, '' );
+ $close = true; // new connection
+ }
+
+ $lastWeight = isset( $staleValue['weightScales'][$i] )
+ ? $staleValue['weightScales'][$i]
+ : 1.0;
+ $coefficient = $this->getWeightScale( $i, $conn ?: null );
+ $newWeight = $movAveRatio * $coefficient + ( 1 - $movAveRatio ) * $lastWeight;
+
+ // Scale from 10% to 100% of nominal weight
+ $weightScales[$i] = max( $newWeight, 0.10 );
+
+ if ( !$conn ) {
+ $lagTimes[$i] = false;
+ $host = $this->parent->getServerName( $i );
+ $this->replLogger->error(
+ __METHOD__ . ": host {db_server} is unreachable",
+ [ 'db_server' => $host ]
+ );
+ continue;
+ }
+
+ if ( $conn->getLBInfo( 'is static' ) ) {
+ $lagTimes[$i] = 0;
+ } else {
+ $lagTimes[$i] = $conn->getLag();
+ if ( $lagTimes[$i] === false ) {
+ $host = $this->parent->getServerName( $i );
+ $this->replLogger->error(
+ __METHOD__ . ": host {db_server} is not replicating?",
+ [ 'db_server' => $host ]
+ );
+ }
+ }
+
+ if ( $close ) {
+ # Close the connection to avoid sleeper connections piling up.
+ # Note that the caller will pick one of these DBs and reconnect,
+ # which is slightly inefficient, but this only matters for the lag
+ # time cache miss cache, which is far less common that cache hits.
+ $this->parent->closeConnection( $conn );
+ }
+ }
+
+ # Add a timestamp key so we know when it was cached
+ $value = [
+ 'lagTimes' => $lagTimes,
+ 'weightScales' => $weightScales,
+ 'timestamp' => microtime( true )
+ ];
+ $this->wanCache->set( $key, $value, $staleTTL );
+ $this->srvCache->set( $key, $value, $staleTTL );
+ $this->replLogger->info( __METHOD__ . ": re-calculated lag times ($key)" );
+
+ return $value;
+ }
+
+ /**
+ * @param int $index Server index
+ * @param IDatabase|null $conn Connection handle or null on connection failure
+ * @return float
+ */
+ protected function getWeightScale( $index, IDatabase $conn = null ) {
+ return $conn ? 1.0 : 0.0;
+ }
+
+ private function getCacheKey( array $serverIndexes ) {
+ sort( $serverIndexes );
+ // Lag is per-server, not per-DB, so key on the master DB name
+ return $this->srvCache->makeGlobalKey(
+ 'lag-times',
+ self::VERSION,
+ $this->parent->getServerName( $this->parent->getWriterIndex() ),
+ implode( '-', $serverIndexes )
+ );
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php b/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php
new file mode 100644
index 00000000..f8ad329b
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use BagOStuff;
+use WANObjectCache;
+
+/**
+ * Basic MySQL load monitor with no external dependencies
+ * Uses memcached to cache the replication lag for a short time
+ *
+ * @ingroup Database
+ */
+class LoadMonitorMySQL extends LoadMonitor {
+ /** @var float What buffer pool use ratio counts as "warm" (e.g. 0.5 for 50% usage) */
+ private $warmCacheRatio;
+
+ public function __construct(
+ ILoadBalancer $lb, BagOStuff $srvCache, WANObjectCache $wCache, array $options = []
+ ) {
+ parent::__construct( $lb, $srvCache, $wCache, $options );
+
+ $this->warmCacheRatio = isset( $options['warmCacheRatio'] )
+ ? $options['warmCacheRatio']
+ : 0.0;
+ }
+
+ protected function getWeightScale( $index, IDatabase $conn = null ) {
+ if ( !$conn ) {
+ return 0.0;
+ }
+
+ $weight = 1.0;
+ if ( $this->warmCacheRatio > 0 ) {
+ $res = $conn->query( 'SHOW STATUS', false );
+ $s = $res ? $conn->fetchObject( $res ) : false;
+ if ( $s === false ) {
+ $host = $this->parent->getServerName( $index );
+ $this->replLogger->error( __METHOD__ . ": could not get status for $host" );
+ } else {
+ // https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html
+ if ( $s->Innodb_buffer_pool_pages_total > 0 ) {
+ $ratio = $s->Innodb_buffer_pool_pages_data / $s->Innodb_buffer_pool_pages_total;
+ } else {
+ $ratio = 1.0;
+ }
+ // Stop caring once $ratio >= $this->warmCacheRatio
+ $weight *= min( $ratio / $this->warmCacheRatio, 1.0 );
+ }
+ }
+
+ return $weight;
+ }
+}
diff --git a/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitorNull.php b/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitorNull.php
new file mode 100644
index 00000000..6dae8cc5
--- /dev/null
+++ b/www/wiki/includes/libs/rdbms/loadmonitor/LoadMonitorNull.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+use WANObjectCache;
+
+class LoadMonitorNull implements ILoadMonitor {
+ public function __construct(
+ ILoadBalancer $lb, BagOStuff $sCache, WANObjectCache $wCache, array $options = []
+ ) {
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ }
+
+ public function scaleLoads( array &$loads, $domain ) {
+ }
+
+ public function getLagTimes( array $serverIndexes, $domain ) {
+ return array_fill_keys( $serverIndexes, 0 );
+ }
+
+ public function clearCaches() {
+ }
+}
diff --git a/www/wiki/includes/libs/redis/RedisConnRef.php b/www/wiki/includes/libs/redis/RedisConnRef.php
new file mode 100644
index 00000000..d330d3c4
--- /dev/null
+++ b/www/wiki/includes/libs/redis/RedisConnRef.php
@@ -0,0 +1,181 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerAwareInterface;
+
+/**
+ * Helper class to handle automatically marking connectons as reusable (via RAII pattern)
+ *
+ * This class simply wraps the Redis class and can be used the same way
+ *
+ * @ingroup Redis
+ * @since 1.21
+ */
+class RedisConnRef implements LoggerAwareInterface {
+ /** @var RedisConnectionPool */
+ protected $pool;
+ /** @var Redis */
+ protected $conn;
+
+ protected $server; // string
+ protected $lastError; // string
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * @param RedisConnectionPool $pool
+ * @param string $server
+ * @param Redis $conn
+ * @param LoggerInterface $logger
+ */
+ public function __construct(
+ RedisConnectionPool $pool, $server, Redis $conn, LoggerInterface $logger
+ ) {
+ $this->pool = $pool;
+ $this->server = $server;
+ $this->conn = $conn;
+ $this->logger = $logger;
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @return string
+ * @since 1.23
+ */
+ public function getServer() {
+ return $this->server;
+ }
+
+ public function getLastError() {
+ return $this->lastError;
+ }
+
+ public function clearLastError() {
+ $this->lastError = null;
+ }
+
+ public function __call( $name, $arguments ) {
+ $conn = $this->conn; // convenience
+
+ // Work around https://github.com/nicolasff/phpredis/issues/70
+ $lname = strtolower( $name );
+ if ( ( $lname === 'blpop' || $lname == 'brpop' )
+ && is_array( $arguments[0] ) && isset( $arguments[1] )
+ ) {
+ $this->pool->resetTimeout( $conn, $arguments[1] + 1 );
+ } elseif ( $lname === 'brpoplpush' && isset( $arguments[2] ) ) {
+ $this->pool->resetTimeout( $conn, $arguments[2] + 1 );
+ }
+
+ $conn->clearLastError();
+ try {
+ $res = call_user_func_array( [ $conn, $name ], $arguments );
+ if ( preg_match( '/^ERR operation not permitted\b/', $conn->getLastError() ) ) {
+ $this->pool->reauthenticateConnection( $this->server, $conn );
+ $conn->clearLastError();
+ $res = call_user_func_array( [ $conn, $name ], $arguments );
+ $this->logger->info(
+ "Used automatic re-authentication for method '$name'.",
+ [ 'redis_server' => $this->server ]
+ );
+ }
+ } catch ( RedisException $e ) {
+ $this->pool->resetTimeout( $conn ); // restore
+ throw $e;
+ }
+
+ $this->lastError = $conn->getLastError() ?: $this->lastError;
+
+ $this->pool->resetTimeout( $conn ); // restore
+
+ return $res;
+ }
+
+ /**
+ * @param string $script
+ * @param array $params
+ * @param int $numKeys
+ * @return mixed
+ * @throws RedisException
+ */
+ public function luaEval( $script, array $params, $numKeys ) {
+ $sha1 = sha1( $script ); // 40 char hex
+ $conn = $this->conn; // convenience
+ $server = $this->server; // convenience
+
+ // Try to run the server-side cached copy of the script
+ $conn->clearLastError();
+ $res = $conn->evalSha( $sha1, $params, $numKeys );
+ // If we got a permission error reply that means that (a) we are not in
+ // multi()/pipeline() and (b) some connection problem likely occurred. If
+ // the password the client gave was just wrong, an exception should have
+ // been thrown back in getConnection() previously.
+ if ( preg_match( '/^ERR operation not permitted\b/', $conn->getLastError() ) ) {
+ $this->pool->reauthenticateConnection( $server, $conn );
+ $conn->clearLastError();
+ $res = $conn->eval( $script, $params, $numKeys );
+ $this->logger->info(
+ "Used automatic re-authentication for Lua script '$sha1'.",
+ [ 'redis_server' => $server ]
+ );
+ }
+ // If the script is not in cache, use eval() to retry and cache it
+ if ( preg_match( '/^NOSCRIPT/', $conn->getLastError() ) ) {
+ $conn->clearLastError();
+ $res = $conn->eval( $script, $params, $numKeys );
+ $this->logger->info(
+ "Used eval() for Lua script '$sha1'.",
+ [ 'redis_server' => $server ]
+ );
+ }
+
+ if ( $conn->getLastError() ) { // script bug?
+ $this->logger->error(
+ 'Lua script error on server "{redis_server}": {lua_error}',
+ [
+ 'redis_server' => $server,
+ 'lua_error' => $conn->getLastError()
+ ]
+ );
+ }
+
+ $this->lastError = $conn->getLastError() ?: $this->lastError;
+
+ return $res;
+ }
+
+ /**
+ * @param Redis $conn
+ * @return bool
+ */
+ public function isConnIdentical( Redis $conn ) {
+ return $this->conn === $conn;
+ }
+
+ function __destruct() {
+ $this->pool->freeConnection( $this->server, $this->conn );
+ }
+}
diff --git a/www/wiki/includes/libs/redis/RedisConnectionPool.php b/www/wiki/includes/libs/redis/RedisConnectionPool.php
new file mode 100644
index 00000000..509240f7
--- /dev/null
+++ b/www/wiki/includes/libs/redis/RedisConnectionPool.php
@@ -0,0 +1,410 @@
+<?php
+/**
+ * Redis client connection pooling manager.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @defgroup Redis Redis
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Helper class to manage Redis connections.
+ *
+ * This can be used to get handle wrappers that free the handle when the wrapper
+ * leaves scope. The maximum number of free handles (connections) is configurable.
+ * This provides an easy way to cache connection handles that may also have state,
+ * such as a handle does between multi() and exec(), and without hoarding connections.
+ * The wrappers use PHP magic methods so that calling functions on them calls the
+ * function of the actual Redis object handle.
+ *
+ * @ingroup Redis
+ * @since 1.21
+ */
+class RedisConnectionPool implements LoggerAwareInterface {
+ /** @var string Connection timeout in seconds */
+ protected $connectTimeout;
+ /** @var string Read timeout in seconds */
+ protected $readTimeout;
+ /** @var string Plaintext auth password */
+ protected $password;
+ /** @var bool Whether connections persist */
+ protected $persistent;
+ /** @var int Serializer to use (Redis::SERIALIZER_*) */
+ protected $serializer;
+ /** @var string ID for persistent connections */
+ protected $id;
+
+ /** @var int Current idle pool size */
+ protected $idlePoolSize = 0;
+
+ /** @var array (server name => ((connection info array),...) */
+ protected $connections = [];
+ /** @var array (server name => UNIX timestamp) */
+ protected $downServers = [];
+
+ /** @var array (pool ID => RedisConnectionPool) */
+ protected static $instances = [];
+
+ /** integer; seconds to cache servers as "down". */
+ const SERVER_DOWN_TTL = 30;
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * @param array $options
+ * @param string $id
+ * @throws Exception
+ */
+ protected function __construct( array $options, $id ) {
+ if ( !class_exists( 'Redis' ) ) {
+ throw new RuntimeException(
+ __CLASS__ . ' requires a Redis client library. ' .
+ 'See https://www.mediawiki.org/wiki/Redis#Setup' );
+ }
+ $this->logger = isset( $options['logger'] )
+ ? $options['logger']
+ : new \Psr\Log\NullLogger();
+ $this->connectTimeout = $options['connectTimeout'];
+ $this->readTimeout = $options['readTimeout'];
+ $this->persistent = $options['persistent'];
+ $this->password = $options['password'];
+ if ( !isset( $options['serializer'] ) || $options['serializer'] === 'php' ) {
+ $this->serializer = Redis::SERIALIZER_PHP;
+ } elseif ( $options['serializer'] === 'igbinary' ) {
+ $this->serializer = Redis::SERIALIZER_IGBINARY;
+ } elseif ( $options['serializer'] === 'none' ) {
+ $this->serializer = Redis::SERIALIZER_NONE;
+ } else {
+ throw new InvalidArgumentException( "Invalid serializer specified." );
+ }
+ $this->id = $id;
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ * @return null
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ protected static function applyDefaultConfig( array $options ) {
+ if ( !isset( $options['connectTimeout'] ) ) {
+ $options['connectTimeout'] = 1;
+ }
+ if ( !isset( $options['readTimeout'] ) ) {
+ $options['readTimeout'] = 1;
+ }
+ if ( !isset( $options['persistent'] ) ) {
+ $options['persistent'] = false;
+ }
+ if ( !isset( $options['password'] ) ) {
+ $options['password'] = null;
+ }
+
+ return $options;
+ }
+
+ /**
+ * @param array $options
+ * $options include:
+ * - connectTimeout : The timeout for new connections, in seconds.
+ * Optional, default is 1 second.
+ * - readTimeout : The timeout for operation reads, in seconds.
+ * Commands like BLPOP can fail if told to wait longer than this.
+ * Optional, default is 1 second.
+ * - persistent : Set this to true to allow connections to persist across
+ * multiple web requests. False by default.
+ * - password : The authentication password, will be sent to Redis in clear text.
+ * Optional, if it is unspecified, no AUTH command will be sent.
+ * - serializer : Set to "php", "igbinary", or "none". Default is "php".
+ * @return RedisConnectionPool
+ */
+ public static function singleton( array $options ) {
+ $options = self::applyDefaultConfig( $options );
+ // Map the options to a unique hash...
+ ksort( $options ); // normalize to avoid pool fragmentation
+ $id = sha1( serialize( $options ) );
+ // Initialize the object at the hash as needed...
+ if ( !isset( self::$instances[$id] ) ) {
+ self::$instances[$id] = new self( $options, $id );
+ }
+
+ return self::$instances[$id];
+ }
+
+ /**
+ * Destroy all singleton() instances
+ * @since 1.27
+ */
+ public static function destroySingletons() {
+ self::$instances = [];
+ }
+
+ /**
+ * Get a connection to a redis server. Based on code in RedisBagOStuff.php.
+ *
+ * @param string $server A hostname/port combination or the absolute path of a UNIX socket.
+ * If a hostname is specified but no port, port 6379 will be used.
+ * @param LoggerInterface $logger PSR-3 logger intance. [optional]
+ * @return RedisConnRef|bool Returns false on failure
+ * @throws MWException
+ */
+ public function getConnection( $server, LoggerInterface $logger = null ) {
+ $logger = $logger ?: $this->logger;
+ // Check the listing "dead" servers which have had a connection errors.
+ // Servers are marked dead for a limited period of time, to
+ // avoid excessive overhead from repeated connection timeouts.
+ if ( isset( $this->downServers[$server] ) ) {
+ $now = time();
+ if ( $now > $this->downServers[$server] ) {
+ // Dead time expired
+ unset( $this->downServers[$server] );
+ } else {
+ // Server is dead
+ $logger->debug(
+ 'Server "{redis_server}" is marked down for another ' .
+ ( $this->downServers[$server] - $now ) . 'seconds',
+ [ 'redis_server' => $server ]
+ );
+
+ return false;
+ }
+ }
+
+ // Check if a connection is already free for use
+ if ( isset( $this->connections[$server] ) ) {
+ foreach ( $this->connections[$server] as &$connection ) {
+ if ( $connection['free'] ) {
+ $connection['free'] = false;
+ --$this->idlePoolSize;
+
+ return new RedisConnRef(
+ $this, $server, $connection['conn'], $logger
+ );
+ }
+ }
+ }
+
+ if ( !$server ) {
+ throw new InvalidArgumentException(
+ __CLASS__ . ": invalid configured server \"$server\"" );
+ } elseif ( substr( $server, 0, 1 ) === '/' ) {
+ // UNIX domain socket
+ // These are required by the redis extension to start with a slash, but
+ // we still need to set the port to a special value to make it work.
+ $host = $server;
+ $port = 0;
+ } else {
+ // TCP connection
+ if ( preg_match( '/^\[(.+)\]:(\d+)$/', $server, $m ) ) {
+ list( $host, $port ) = [ $m[1], (int)$m[2] ]; // (ip, port)
+ } elseif ( preg_match( '/^([^:]+):(\d+)$/', $server, $m ) ) {
+ list( $host, $port ) = [ $m[1], (int)$m[2] ]; // (ip or path, port)
+ } else {
+ list( $host, $port ) = [ $server, 6379 ]; // (ip or path, port)
+ }
+ }
+
+ $conn = new Redis();
+ try {
+ if ( $this->persistent ) {
+ $result = $conn->pconnect( $host, $port, $this->connectTimeout, $this->id );
+ } else {
+ $result = $conn->connect( $host, $port, $this->connectTimeout );
+ }
+ if ( !$result ) {
+ $logger->error(
+ 'Could not connect to server "{redis_server}"',
+ [ 'redis_server' => $server ]
+ );
+ // Mark server down for some time to avoid further timeouts
+ $this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
+
+ return false;
+ }
+ if ( $this->password !== null ) {
+ if ( !$conn->auth( $this->password ) ) {
+ $logger->error(
+ 'Authentication error connecting to "{redis_server}"',
+ [ 'redis_server' => $server ]
+ );
+ }
+ }
+ } catch ( RedisException $e ) {
+ $this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
+ $logger->error(
+ 'Redis exception connecting to "{redis_server}"',
+ [
+ 'redis_server' => $server,
+ 'exception' => $e,
+ ]
+ );
+
+ return false;
+ }
+
+ if ( $conn ) {
+ $conn->setOption( Redis::OPT_READ_TIMEOUT, $this->readTimeout );
+ $conn->setOption( Redis::OPT_SERIALIZER, $this->serializer );
+ $this->connections[$server][] = [ 'conn' => $conn, 'free' => false ];
+
+ return new RedisConnRef( $this, $server, $conn, $logger );
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Mark a connection to a server as free to return to the pool
+ *
+ * @param string $server
+ * @param Redis $conn
+ * @return bool
+ */
+ public function freeConnection( $server, Redis $conn ) {
+ $found = false;
+
+ foreach ( $this->connections[$server] as &$connection ) {
+ if ( $connection['conn'] === $conn && !$connection['free'] ) {
+ $connection['free'] = true;
+ ++$this->idlePoolSize;
+ break;
+ }
+ }
+
+ $this->closeExcessIdleConections();
+
+ return $found;
+ }
+
+ /**
+ * Close any extra idle connections if there are more than the limit
+ */
+ protected function closeExcessIdleConections() {
+ if ( $this->idlePoolSize <= count( $this->connections ) ) {
+ return; // nothing to do (no more connections than servers)
+ }
+
+ foreach ( $this->connections as &$serverConnections ) {
+ foreach ( $serverConnections as $key => &$connection ) {
+ if ( $connection['free'] ) {
+ unset( $serverConnections[$key] );
+ if ( --$this->idlePoolSize <= count( $this->connections ) ) {
+ return; // done (no more connections than servers)
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * The redis extension throws an exception in response to various read, write
+ * and protocol errors. Sometimes it also closes the connection, sometimes
+ * not. The safest response for us is to explicitly destroy the connection
+ * object and let it be reopened during the next request.
+ *
+ * @param RedisConnRef $cref
+ * @param RedisException $e
+ */
+ public function handleError( RedisConnRef $cref, RedisException $e ) {
+ $server = $cref->getServer();
+ $this->logger->error(
+ 'Redis exception on server "{redis_server}"',
+ [
+ 'redis_server' => $server,
+ 'exception' => $e,
+ ]
+ );
+ foreach ( $this->connections[$server] as $key => $connection ) {
+ if ( $cref->isConnIdentical( $connection['conn'] ) ) {
+ $this->idlePoolSize -= $connection['free'] ? 1 : 0;
+ unset( $this->connections[$server][$key] );
+ break;
+ }
+ }
+ }
+
+ /**
+ * Re-send an AUTH request to the redis server (useful after disconnects).
+ *
+ * This works around an upstream bug in phpredis. phpredis hides disconnects by transparently
+ * reconnecting, but it neglects to re-authenticate the new connection. To the user of the
+ * phpredis client API this manifests as a seemingly random tendency of connections to lose
+ * their authentication status.
+ *
+ * This method is for internal use only.
+ *
+ * @see https://github.com/nicolasff/phpredis/issues/403
+ *
+ * @param string $server
+ * @param Redis $conn
+ * @return bool Success
+ */
+ public function reauthenticateConnection( $server, Redis $conn ) {
+ if ( $this->password !== null ) {
+ if ( !$conn->auth( $this->password ) ) {
+ $this->logger->error(
+ 'Authentication error connecting to "{redis_server}"',
+ [ 'redis_server' => $server ]
+ );
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Adjust or reset the connection handle read timeout value
+ *
+ * @param Redis $conn
+ * @param int $timeout Optional
+ */
+ public function resetTimeout( Redis $conn, $timeout = null ) {
+ $conn->setOption( Redis::OPT_READ_TIMEOUT, $timeout ?: $this->readTimeout );
+ }
+
+ /**
+ * Make sure connections are closed for sanity
+ */
+ function __destruct() {
+ foreach ( $this->connections as $server => &$serverConnections ) {
+ foreach ( $serverConnections as $key => &$connection ) {
+ try {
+ /** @var Redis $conn */
+ $conn = $connection['conn'];
+ $conn->close();
+ } catch ( RedisException $e ) {
+ // The destructor can be called on shutdown when random parts of the system
+ // have been destructed already, causing weird errors. Ignore them.
+ }
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/replacers/DoubleReplacer.php b/www/wiki/includes/libs/replacers/DoubleReplacer.php
new file mode 100644
index 00000000..fed023b1
--- /dev/null
+++ b/www/wiki/includes/libs/replacers/DoubleReplacer.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to perform secondary replacement within each replacement string
+ */
+class DoubleReplacer extends Replacer {
+ /**
+ * @param mixed $from
+ * @param mixed $to
+ * @param int $index
+ */
+ public function __construct( $from, $to, $index = 0 ) {
+ $this->from = $from;
+ $this->to = $to;
+ $this->index = $index;
+ }
+
+ /**
+ * @param array $matches
+ * @return mixed
+ */
+ public function replace( array $matches ) {
+ return str_replace( $this->from, $this->to, $matches[$this->index] );
+ }
+}
diff --git a/www/wiki/includes/libs/replacers/HashtableReplacer.php b/www/wiki/includes/libs/replacers/HashtableReplacer.php
new file mode 100644
index 00000000..11637d0d
--- /dev/null
+++ b/www/wiki/includes/libs/replacers/HashtableReplacer.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to perform replacement based on a simple hashtable lookup
+ */
+class HashtableReplacer extends Replacer {
+ private $table, $index;
+
+ /**
+ * @param array $table
+ * @param int $index
+ */
+ public function __construct( $table, $index = 0 ) {
+ $this->table = $table;
+ $this->index = $index;
+ }
+
+ /**
+ * @param array $matches
+ * @return mixed
+ */
+ public function replace( array $matches ) {
+ return $this->table[$matches[$this->index]];
+ }
+}
diff --git a/www/wiki/includes/libs/replacers/RegexlikeReplacer.php b/www/wiki/includes/libs/replacers/RegexlikeReplacer.php
new file mode 100644
index 00000000..9874f524
--- /dev/null
+++ b/www/wiki/includes/libs/replacers/RegexlikeReplacer.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to replace regex matches with a string similar to that used in preg_replace()
+ */
+class RegexlikeReplacer extends Replacer {
+ private $r;
+
+ /**
+ * @param string $r
+ */
+ public function __construct( $r ) {
+ $this->r = $r;
+ }
+
+ /**
+ * @param array $matches
+ * @return string
+ */
+ public function replace( array $matches ) {
+ $pairs = [];
+ foreach ( $matches as $i => $match ) {
+ $pairs["\$$i"] = $match;
+ }
+
+ return strtr( $this->r, $pairs );
+ }
+}
diff --git a/www/wiki/includes/libs/replacers/Replacer.php b/www/wiki/includes/libs/replacers/Replacer.php
new file mode 100644
index 00000000..655e7710
--- /dev/null
+++ b/www/wiki/includes/libs/replacers/Replacer.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Base class for "replacers", objects used in preg_replace_callback() and
+ * StringUtils::delimiterReplaceCallback()
+ */
+abstract class Replacer {
+ /**
+ * @return array
+ */
+ public function cb() {
+ return [ $this, 'replace' ];
+ }
+
+ /**
+ * @param array $matches
+ * @return string
+ */
+ abstract public function replace( array $matches );
+}
diff --git a/www/wiki/includes/libs/stats/BufferingStatsdDataFactory.php b/www/wiki/includes/libs/stats/BufferingStatsdDataFactory.php
new file mode 100644
index 00000000..d75d9c0b
--- /dev/null
+++ b/www/wiki/includes/libs/stats/BufferingStatsdDataFactory.php
@@ -0,0 +1,126 @@
+<?php
+/**
+ * Copyright 2015
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Liuggio\StatsdClient\Entity\StatsdData;
+use Liuggio\StatsdClient\Entity\StatsdDataInterface;
+use Liuggio\StatsdClient\Factory\StatsdDataFactory;
+
+/**
+ * A factory for application metric data.
+ *
+ * This class prepends a context-specific prefix to each metric key and keeps
+ * a reference to each constructed metric in an internal array buffer.
+ *
+ * @since 1.25
+ */
+class BufferingStatsdDataFactory extends StatsdDataFactory implements IBufferingStatsdDataFactory {
+ protected $buffer = [];
+ /**
+ * Collection enabled?
+ * @var bool
+ */
+ protected $enabled = true;
+ /**
+ * @var string
+ */
+ private $prefix;
+
+ public function __construct( $prefix ) {
+ parent::__construct();
+ $this->prefix = $prefix;
+ }
+
+ /**
+ * Normalize a metric key for StatsD
+ *
+ * Replace occurences of '::' with dots and any other non-alphanumeric
+ * characters with underscores. Combine runs of dots or underscores.
+ * Then trim leading or trailing dots or underscores.
+ *
+ * @param string $key
+ * @since 1.26
+ * @return string
+ */
+ private static function normalizeMetricKey( $key ) {
+ $key = preg_replace( '/[:.]+/', '.', $key );
+ $key = preg_replace( '/[^a-z0-9.]+/i', '_', $key );
+ $key = trim( $key, '_.' );
+ return str_replace( [ '._', '_.' ], '.', $key );
+ }
+
+ public function produceStatsdData(
+ $key, $value = 1, $metric = StatsdDataInterface::STATSD_METRIC_COUNT
+ ) {
+ $entity = $this->produceStatsdDataEntity();
+ if ( !$this->enabled ) {
+ return $entity;
+ }
+ if ( $key !== null ) {
+ $key = self::normalizeMetricKey( "{$this->prefix}.{$key}" );
+ $entity->setKey( $key );
+ }
+ if ( $value !== null ) {
+ $entity->setValue( $value );
+ }
+ if ( $metric !== null ) {
+ $entity->setMetric( $metric );
+ }
+ // Don't bother buffering a counter update with a delta of zero.
+ if ( !( $metric === StatsdDataInterface::STATSD_METRIC_COUNT && !$value ) ) {
+ $this->buffer[] = $entity;
+ }
+ return $entity;
+ }
+
+ /**
+ * @deprecated Use getData()
+ * @return StatsdData[]
+ */
+ public function getBuffer() {
+ return $this->buffer;
+ }
+
+ /**
+ * Check whether this data factory has any data.
+ * @return bool
+ */
+ public function hasData() {
+ return !empty( $this->buffer );
+ }
+
+ /**
+ * Return data from the factory.
+ * @return StatsdData[]
+ */
+ public function getData() {
+ return $this->buffer;
+ }
+
+ /**
+ * Set collection enable status.
+ * @param bool $enabled Will collection be enabled?
+ * @return void
+ */
+ public function setEnabled( $enabled ) {
+ $this->enabled = $enabled;
+ }
+}
diff --git a/www/wiki/includes/libs/stats/IBufferingStatsdDataFactory.php b/www/wiki/includes/libs/stats/IBufferingStatsdDataFactory.php
new file mode 100644
index 00000000..f77b26ce
--- /dev/null
+++ b/www/wiki/includes/libs/stats/IBufferingStatsdDataFactory.php
@@ -0,0 +1,30 @@
+<?php
+use Liuggio\StatsdClient\Entity\StatsdData;
+use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
+
+/**
+ * MediaWiki adaptation of StatsdDataFactory that provides buffering functionality.
+ *
+ * @see BufferingStatsdDataFactory
+ */
+interface IBufferingStatsdDataFactory extends StatsdDataFactoryInterface {
+ /**
+ * Check whether this data factory has any data.
+ * @return bool
+ */
+ public function hasData();
+
+ /**
+ * Return data from the factory.
+ * @return StatsdData[]
+ */
+ public function getData();
+
+ /**
+ * Set collection enable status.
+ * @param bool $enabled Will collection be enabled?
+ * @return void
+ */
+ public function setEnabled( $enabled );
+
+}
diff --git a/www/wiki/includes/libs/stats/NullStatsdDataFactory.php b/www/wiki/includes/libs/stats/NullStatsdDataFactory.php
new file mode 100644
index 00000000..d346f651
--- /dev/null
+++ b/www/wiki/includes/libs/stats/NullStatsdDataFactory.php
@@ -0,0 +1,133 @@
+<?php
+
+use Liuggio\StatsdClient\Entity\StatsdData;
+use Liuggio\StatsdClient\Entity\StatsdDataInterface;
+use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
+
+/**
+ * @author Addshore
+ * @since 1.27
+ */
+class NullStatsdDataFactory implements IBufferingStatsdDataFactory {
+
+ /**
+ * This function creates a 'timing' StatsdData.
+ *
+ * @param string|array $key The metric(s) to set.
+ * @param float $time The elapsed time (ms) to log
+ */
+ public function timing( $key, $time ) {
+ }
+
+ /**
+ * This function creates a 'gauge' StatsdData.
+ *
+ * @param string|array $key The metric(s) to set.
+ * @param float $value The value for the stats.
+ */
+ public function gauge( $key, $value ) {
+ }
+
+ /**
+ * This function creates a 'set' StatsdData object
+ * A "Set" is a count of unique events.
+ * This data type acts like a counter, but supports counting
+ * of unique occurrences of values between flushes. The backend
+ * receives the number of unique events that happened since
+ * the last flush.
+ *
+ * The reference use case involved tracking the number of active
+ * and logged in users by sending the current userId of a user
+ * with each request with a key of "uniques" (or similar).
+ *
+ * @param string|array $key The metric(s) to set.
+ * @param float $value The value for the stats.
+ *
+ * @return array
+ */
+ public function set( $key, $value ) {
+ return [];
+ }
+
+ /**
+ * This function creates a 'increment' StatsdData object.
+ *
+ * @param string|array $key The metric(s) to increment.
+ *
+ * @return array
+ */
+ public function increment( $key ) {
+ return [];
+ }
+
+ /**
+ * This function creates a 'decrement' StatsdData object.
+ *
+ *
+ * @param string|array $key The metric(s) to decrement.
+ *
+ * @return mixed
+ */
+ public function decrement( $key ) {
+ return [];
+ }
+
+ /**
+ * This function creates a 'updateCount' StatsdData object.
+ *
+ * @param string|array $key The metric(s) to decrement.
+ * @param int $delta The delta to add to the each metric
+ *
+ * @return mixed
+ */
+ public function updateCount( $key, $delta ) {
+ return [];
+ }
+
+ /**
+ * Produce a StatsdDataInterface Object.
+ *
+ * @param string $key The key of the metric
+ * @param int $value The amount to increment/decrement each metric by.
+ * @param string $metric The metric type
+ * ("c" for count, "ms" for timing, "g" for gauge, "s" for set)
+ *
+ * @return StatsdDataInterface
+ */
+ public function produceStatsdData(
+ $key,
+ $value = 1,
+ $metric = StatsdDataInterface::STATSD_METRIC_COUNT
+ ) {
+ $data = new StatsdData();
+ $data->setKey( $key );
+ $data->setValue( $value );
+ $data->setMetric( $metric );
+ return $data;
+ }
+
+ /**
+ * Check whether this data factory has any data.
+ * @return bool
+ */
+ public function hasData() {
+ return false;
+ }
+
+ /**
+ * Return data from the factory.
+ * @return StatsdData[]
+ */
+ public function getData() {
+ return [];
+ }
+
+ /**
+ * Set collection enable status.
+ * @param bool $enabled Will collection be enabled?
+ * @return void
+ */
+ public function setEnabled( $enabled ) {
+ // Nothing to do, null factory is always disabled.
+ }
+}
diff --git a/www/wiki/includes/libs/stats/SamplingStatsdClient.php b/www/wiki/includes/libs/stats/SamplingStatsdClient.php
new file mode 100644
index 00000000..6494c263
--- /dev/null
+++ b/www/wiki/includes/libs/stats/SamplingStatsdClient.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * Copyright 2015
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Liuggio\StatsdClient\StatsdClient;
+use Liuggio\StatsdClient\Entity\StatsdData;
+use Liuggio\StatsdClient\Entity\StatsdDataInterface;
+
+/**
+ * A statsd client that applies the sampling rate to the data items before sending them.
+ *
+ * @since 1.26
+ */
+class SamplingStatsdClient extends StatsdClient {
+ protected $samplingRates = [];
+
+ /**
+ * Sampling rates as an associative array of patterns and rates.
+ * Patterns are Unix shell patterns (e.g. 'MediaWiki.api.*').
+ * Rates are sampling probabilities (e.g. 0.1 means 1 in 10 events are sampled).
+ * @param array $samplingRates
+ * @since 1.28
+ */
+ public function setSamplingRates( array $samplingRates ) {
+ $this->samplingRates = $samplingRates;
+ }
+
+ /**
+ * Sets sampling rate for all items in $data.
+ * The sample rate specified in a StatsdData entity overrides the sample rate specified here.
+ *
+ * @inheritDoc
+ */
+ public function appendSampleRate( $data, $sampleRate = 1 ) {
+ $samplingRates = $this->samplingRates;
+ if ( !$samplingRates && $sampleRate !== 1 ) {
+ $samplingRates = [ '*' => $sampleRate ];
+ }
+ if ( $samplingRates ) {
+ array_walk( $data, function ( $item ) use ( $samplingRates ) {
+ /** @var StatsdData $item */
+ foreach ( $samplingRates as $pattern => $rate ) {
+ if ( fnmatch( $pattern, $item->getKey(), FNM_NOESCAPE ) ) {
+ $item->setSampleRate( $item->getSampleRate() * $rate );
+ break;
+ }
+ }
+ } );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Send the metrics over UDP
+ * Sample the metrics according to their sample rate and send the remaining ones.
+ *
+ * @param StatsdDataInterface|StatsdDataInterface[] $data message(s) to sent
+ * strings are not allowed here as sampleData requires a StatsdDataInterface
+ * @param int $sampleRate
+ *
+ * @return int the data sent in bytes
+ */
+ public function send( $data, $sampleRate = 1 ) {
+ if ( !is_array( $data ) ) {
+ $data = [ $data ];
+ }
+ if ( !$data ) {
+ return;
+ }
+ foreach ( $data as $item ) {
+ if ( !( $item instanceof StatsdDataInterface ) ) {
+ throw new InvalidArgumentException(
+ 'SamplingStatsdClient does not accept stringified messages' );
+ }
+ }
+
+ // add sampling
+ $data = $this->appendSampleRate( $data, $sampleRate );
+ $data = $this->sampleData( $data );
+
+ $data = array_map( 'strval', $data );
+
+ // reduce number of packets
+ if ( $this->getReducePacket() ) {
+ $data = $this->reduceCount( $data );
+ }
+
+ // failures in any of this should be silently ignored if ..
+ $written = 0;
+ try {
+ $fp = $this->getSender()->open();
+ if ( !$fp ) {
+ return;
+ }
+ foreach ( $data as $message ) {
+ $written += $this->getSender()->write( $fp, $message );
+ }
+ $this->getSender()->close( $fp );
+ } catch ( Exception $e ) {
+ $this->throwException( $e );
+ }
+
+ return $written;
+ }
+
+ /**
+ * Throw away some of the data according to the sample rate.
+ * @param StatsdDataInterface[] $data
+ * @return StatsdDataInterface[]
+ * @throws LogicException
+ */
+ protected function sampleData( $data ) {
+ $newData = [];
+ $mt_rand_max = mt_getrandmax();
+ foreach ( $data as $item ) {
+ $samplingRate = $item->getSampleRate();
+ if ( $samplingRate <= 0.0 || $samplingRate > 1.0 ) {
+ throw new LogicException( 'Sampling rate shall be within ]0, 1]' );
+ }
+ if (
+ $samplingRate === 1 ||
+ ( mt_rand() / $mt_rand_max <= $samplingRate )
+ ) {
+ $newData[] = $item;
+ }
+ }
+ return $newData;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function throwException( Exception $exception ) {
+ if ( !$this->getFailSilently() ) {
+ throw $exception;
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/stats/StatsdAwareInterface.php b/www/wiki/includes/libs/stats/StatsdAwareInterface.php
new file mode 100644
index 00000000..b0b941ae
--- /dev/null
+++ b/www/wiki/includes/libs/stats/StatsdAwareInterface.php
@@ -0,0 +1,21 @@
+<?php
+
+use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
+
+/**
+ * Describes a Statsd aware interface
+ *
+ * @since 1.27
+ * @author Addshore
+ */
+interface StatsdAwareInterface {
+
+ /**
+ * Sets a StatsdDataFactory instance on the object
+ *
+ * @param StatsdDataFactoryInterface $statsFactory
+ * @return null
+ */
+ public function setStatsdDataFactory( StatsdDataFactoryInterface $statsFactory );
+
+}
diff --git a/www/wiki/includes/libs/virtualrest/ParsoidVirtualRESTService.php b/www/wiki/includes/libs/virtualrest/ParsoidVirtualRESTService.php
new file mode 100644
index 00000000..37a967ff
--- /dev/null
+++ b/www/wiki/includes/libs/virtualrest/ParsoidVirtualRESTService.php
@@ -0,0 +1,227 @@
+<?php
+/**
+ * Virtual HTTP service client for Parsoid
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * Virtual REST service for Parsoid
+ * @since 1.25
+ */
+class ParsoidVirtualRESTService extends VirtualRESTService {
+ /**
+ * Example Parsoid v3 requests:
+ * GET /local/v3/page/html/$title/{$revision}
+ * * $revision is optional
+ * POST /local/v3/transform/html/to/wikitext/{$title}{/$revision}
+ * * body: array( 'html' => ... )
+ * * $title and $revision are optional
+ * POST /local/v3/transform/wikitext/to/html/{$title}{/$revision}
+ * * body: array( 'wikitext' => ... ) or array( 'wikitext' => ..., 'body_only' => true/false )
+ * * $title is optional
+ * * $revision is optional
+ *
+ * There are also deprecated "v1" requests; see onParsoid1Request
+ * for details.
+ * @param array $params Key/value map
+ * - url : Parsoid server URL
+ * - domain : Wiki domain to use
+ * - timeout : Parsoid timeout (optional)
+ * - forwardCookies : Cookies to forward to Parsoid, or false. (optional)
+ * - HTTPProxy : Parsoid HTTP proxy (optional)
+ * - restbaseCompat : whether to parse URL as if they were meant for RESTBase
+ * boolean (optional)
+ */
+ public function __construct( array $params ) {
+ // for backwards compatibility:
+ if ( isset( $params['URL'] ) ) {
+ $params['url'] = $params['URL'];
+ unset( $params['URL'] );
+ }
+ // set up defaults and merge them with the given params
+ $mparams = array_merge( [
+ 'name' => 'parsoid',
+ 'url' => 'http://localhost:8000/',
+ 'prefix' => 'localhost',
+ 'domain' => 'localhost',
+ 'timeout' => null,
+ 'forwardCookies' => false,
+ 'HTTPProxy' => null,
+ ], $params );
+ // Ensure that the url parameter has a trailing slash.
+ if ( substr( $mparams['url'], -1 ) !== '/' ) {
+ $mparams['url'] .= '/';
+ }
+ // Ensure the correct domain format: strip protocol, port,
+ // and trailing slash if present. This lets us use
+ // $wgCanonicalServer as a default value, which is very convenient.
+ $mparams['domain'] = preg_replace(
+ '/^(https?:\/\/)?([^\/:]+?)(:\d+)?\/?$/',
+ '$2',
+ $mparams['domain']
+ );
+ parent::__construct( $mparams );
+ }
+
+ public function onRequests( array $reqs, Closure $idGeneratorFunc ) {
+ $result = [];
+ foreach ( $reqs as $key => $req ) {
+ $parts = explode( '/', $req['url'] );
+
+ list(
+ $targetWiki, // 'local'
+ $version, // 'v3' ('v1' for restbase compatibility)
+ $reqType, // 'page' or 'transform'
+ $format, // 'html' or 'wikitext'
+ // $title (optional)
+ // $revision (optional)
+ ) = $parts;
+
+ if ( isset( $this->params['restbaseCompat'] ) && $this->params['restbaseCompat'] ) {
+ if ( $version !== 'v1' ) {
+ throw new Exception( "Only RESTBase v1 API is supported." );
+ }
+ # Map RESTBase v1 API to Parsoid v3 API (pretty easy)
+ $req['url'] = preg_replace( '#^local/v1/#', 'local/v3/', $req['url'] );
+ } elseif ( $version !== 'v3' ) {
+ $result[$key] = $this->onParsoid1Request( $req, $idGeneratorFunc );
+ continue;
+ }
+ if ( $targetWiki !== 'local' ) {
+ throw new Exception( "Only 'local' target wiki is currently supported" );
+ }
+ if ( $reqType !== 'page' && $reqType !== 'transform' ) {
+ throw new Exception( "Request action must be either 'page' or 'transform'" );
+ }
+ if ( $format !== 'html' && $format !== 'wikitext' ) {
+ throw new Exception( "Request format must be either 'html' or 'wt'" );
+ }
+ // replace /local/ with the current domain
+ $req['url'] = preg_replace( '#^local/#', $this->params['domain'] . '/', $req['url'] );
+ // and prefix it with the service URL
+ $req['url'] = $this->params['url'] . $req['url'];
+ // set the appropriate proxy, timeout and headers
+ if ( $this->params['HTTPProxy'] ) {
+ $req['proxy'] = $this->params['HTTPProxy'];
+ }
+ if ( $this->params['timeout'] != null ) {
+ $req['reqTimeout'] = $this->params['timeout'];
+ }
+ if ( $this->params['forwardCookies'] ) {
+ $req['headers']['Cookie'] = $this->params['forwardCookies'];
+ }
+ $result[$key] = $req;
+ }
+ return $result;
+ }
+
+ /**
+ * Remap a Parsoid v1 request to a Parsoid v3 request.
+ *
+ * Example Parsoid v1 requests:
+ * GET /local/v1/page/$title/html/$oldid
+ * * $oldid is optional
+ * POST /local/v1/transform/html/to/wikitext/$title/$oldid
+ * * body: array( 'html' => ... )
+ * * $title and $oldid are optional
+ * POST /local/v1/transform/wikitext/to/html/$title
+ * * body: array( 'wikitext' => ... ) or array( 'wikitext' => ..., 'body' => true/false )
+ * * $title is optional
+ *
+ * NOTE: the POST APIs aren't "real" Parsoid v1 APIs, they are just what
+ * Visual Editor "pretends" the V1 API is like. A previous version of
+ * ParsoidVirtualRESTService translated these to the "real" Parsoid v1
+ * API. We now translate these to the "real" Parsoid v3 API.
+ * @param array $req
+ * @param Closure $idGeneratorFunc
+ * @return array
+ * @throws Exception
+ */
+ public function onParsoid1Request( array $req, Closure $idGeneratorFunc ) {
+ $parts = explode( '/', $req['url'] );
+ list(
+ $targetWiki, // 'local'
+ $version, // 'v1'
+ $reqType // 'page' or 'transform'
+ ) = $parts;
+ if ( $targetWiki !== 'local' ) {
+ throw new Exception( "Only 'local' target wiki is currently supported" );
+ } elseif ( $version !== 'v1' ) {
+ throw new Exception( "Only v1 and v3 are supported." );
+ } elseif ( $reqType !== 'page' && $reqType !== 'transform' ) {
+ throw new Exception( "Request type must be either 'page' or 'transform'" );
+ }
+ $req['url'] = $this->params['url'] . $this->params['domain'] . '/v3/';
+ if ( $reqType === 'page' ) {
+ $title = $parts[3];
+ if ( $parts[4] !== 'html' ) {
+ throw new Exception( "Only 'html' output format is currently supported" );
+ }
+ $req['url'] .= 'page/html/' . $title;
+ if ( isset( $parts[5] ) ) {
+ $req['url'] .= '/' . $parts[5];
+ } elseif ( isset( $req['query']['oldid'] ) && $req['query']['oldid'] ) {
+ $req['url'] .= '/' . $req['query']['oldid'];
+ unset( $req['query']['oldid'] );
+ }
+ } elseif ( $reqType === 'transform' ) {
+ $req['url'] .= 'transform/'. $parts[3] . '/to/' . $parts[5];
+ // the title
+ if ( isset( $parts[6] ) ) {
+ $req['url'] .= '/' . $parts[6];
+ }
+ // revision id
+ if ( isset( $parts[7] ) ) {
+ $req['url'] .= '/' . $parts[7];
+ } elseif ( isset( $req['body']['oldid'] ) && $req['body']['oldid'] ) {
+ $req['url'] .= '/' . $req['body']['oldid'];
+ unset( $req['body']['oldid'] );
+ }
+ if ( $parts[4] !== 'to' ) {
+ throw new Exception( "Part index 4 is not 'to'" );
+ }
+ if ( $parts[3] === 'html' && $parts[5] === 'wikitext' ) {
+ if ( !isset( $req['body']['html'] ) ) {
+ throw new Exception( "You must set an 'html' body key for this request" );
+ }
+ } elseif ( $parts[3] == 'wikitext' && $parts[5] == 'html' ) {
+ if ( !isset( $req['body']['wikitext'] ) ) {
+ throw new Exception( "You must set a 'wikitext' body key for this request" );
+ }
+ if ( isset( $req['body']['body'] ) ) {
+ $req['body']['body_only'] = $req['body']['body'];
+ unset( $req['body']['body'] );
+ }
+ } else {
+ throw new Exception( "Transformation unsupported" );
+ }
+ }
+ // set the appropriate proxy, timeout and headers
+ if ( $this->params['HTTPProxy'] ) {
+ $req['proxy'] = $this->params['HTTPProxy'];
+ }
+ if ( $this->params['timeout'] != null ) {
+ $req['reqTimeout'] = $this->params['timeout'];
+ }
+ if ( $this->params['forwardCookies'] ) {
+ $req['headers']['Cookie'] = $this->params['forwardCookies'];
+ }
+
+ return $req;
+ }
+
+}
diff --git a/www/wiki/includes/libs/virtualrest/RestbaseVirtualRESTService.php b/www/wiki/includes/libs/virtualrest/RestbaseVirtualRESTService.php
new file mode 100644
index 00000000..192b4bd4
--- /dev/null
+++ b/www/wiki/includes/libs/virtualrest/RestbaseVirtualRESTService.php
@@ -0,0 +1,282 @@
+<?php
+/**
+ * Virtual HTTP service client for RESTBase
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * Virtual REST service for RESTBase
+ * @since 1.25
+ */
+class RestbaseVirtualRESTService extends VirtualRESTService {
+ /**
+ * Example RESTBase v1 requests:
+ * GET /local/v1/page/html/{title}{/revision}
+ * POST /local/v1/transform/html/to/wikitext{/title}{/revision}
+ * * body: array( 'html' => ... )
+ * POST /local/v1/transform/wikitext/to/html{/title}{/revision}
+ * * body: array( 'wikitext' => ... ) or array( 'wikitext' => ..., 'body_only' => true/false )
+ *
+ * @param array $params Key/value map
+ * - url : RESTBase server URL
+ * - domain : Wiki domain to use
+ * - timeout : request timeout in seconds (optional)
+ * - forwardCookies : cookies to forward to RESTBase/Parsoid (as a Cookie
+ * header string) or false (optional)
+ * Note: forwardCookies will in the future be a boolean
+ * only, signifing request cookies should be forwarded
+ * to the service; the current state is due to the way
+ * VE handles this particular parameter
+ * - HTTPProxy : HTTP proxy to use (optional)
+ * - parsoidCompat : whether to parse URL as if they were meant for Parsoid
+ * boolean (optional)
+ * - fixedUrl : Do not append domain to the url. For example to use
+ * English Wikipedia restbase, you would this to true
+ * and url to https://en.wikipedia.org/api/rest_#version#
+ */
+ public function __construct( array $params ) {
+ // set up defaults and merge them with the given params
+ $mparams = array_merge( [
+ 'name' => 'restbase',
+ 'url' => 'http://localhost:7231/',
+ 'domain' => 'localhost',
+ 'timeout' => 100,
+ 'forwardCookies' => false,
+ 'HTTPProxy' => null,
+ 'parsoidCompat' => false,
+ 'fixedUrl' => false,
+ ], $params );
+ // Ensure that the url parameter has a trailing slash.
+ if ( substr( $mparams['url'], -1 ) !== '/' ) {
+ $mparams['url'] .= '/';
+ }
+ // Ensure the correct domain format: strip protocol, port,
+ // and trailing slash if present. This lets us use
+ // $wgCanonicalServer as a default value, which is very convenient.
+ $mparams['domain'] = preg_replace(
+ '/^(https?:\/\/)?([^\/:]+?)(:\d+)?\/?$/',
+ '$2',
+ $mparams['domain']
+ );
+ parent::__construct( $mparams );
+ }
+
+ public function onRequests( array $reqs, Closure $idGenFunc ) {
+ if ( $this->params['parsoidCompat'] ) {
+ return $this->onParsoidRequests( $reqs, $idGenFunc );
+ }
+
+ $result = [];
+ foreach ( $reqs as $key => $req ) {
+ if ( $this->params['fixedUrl'] ) {
+ $version = explode( '/', $req['url'] )[1];
+ $req['url'] =
+ str_replace( '#version#', $version, $this->params['url'] ) .
+ preg_replace( '#^local/v./#', '', $req['url'] );
+ } else {
+ // replace /local/ with the current domain
+ $req['url'] = preg_replace( '#^local/#', $this->params['domain'] . '/', $req['url'] );
+ // and prefix it with the service URL
+ $req['url'] = $this->params['url'] . $req['url'];
+ }
+
+ // set the appropriate proxy, timeout and headers
+ if ( $this->params['HTTPProxy'] ) {
+ $req['proxy'] = $this->params['HTTPProxy'];
+ }
+ if ( $this->params['timeout'] != null ) {
+ $req['reqTimeout'] = $this->params['timeout'];
+ }
+ if ( $this->params['forwardCookies'] ) {
+ $req['headers']['Cookie'] = $this->params['forwardCookies'];
+ }
+ $result[$key] = $req;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Remaps Parsoid v1/v3 requests to RESTBase v1 requests.
+ * @param array $reqs
+ * @param Closure $idGeneratorFunc
+ * @return array
+ * @throws Exception
+ */
+ public function onParsoidRequests( array $reqs, Closure $idGeneratorFunc ) {
+ $result = [];
+ foreach ( $reqs as $key => $req ) {
+ $version = explode( '/', $req['url'] )[1];
+ if ( $version === 'v3' ) {
+ $result[$key] = $this->onParsoid3Request( $req, $idGeneratorFunc );
+ } elseif ( $version === 'v1' ) {
+ $result[$key] = $this->onParsoid1Request( $req, $idGeneratorFunc );
+ } else {
+ throw new Exception( "Only v1 and v3 are supported." );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Remap a Parsoid v1 request to a RESTBase v1 request.
+ *
+ * Example Parsoid v1 requests:
+ * GET /local/v1/page/$title/html/$oldid
+ * * $oldid is optional
+ * POST /local/v1/transform/html/to/wikitext/$title/$oldid
+ * * body: array( 'html' => ... )
+ * * $title and $oldid are optional
+ * POST /local/v1/transform/wikitext/to/html/$title
+ * * body: array( 'wikitext' => ... ) or array( 'wikitext' => ..., 'body' => true/false )
+ * * $title is optional
+ *
+ * NOTE: the POST APIs aren't "real" Parsoid v1 APIs, they are just what
+ * Visual Editor "pretends" the V1 API is like. (See
+ * ParsoidVirtualRESTService.)
+ * @param array $req
+ * @param Closure $idGeneratorFunc
+ * @return array
+ * @throws Exception
+ */
+ public function onParsoid1Request( array $req, Closure $idGeneratorFunc ) {
+ $parts = explode( '/', $req['url'] );
+ list(
+ $targetWiki, // 'local'
+ $version, // 'v1'
+ $reqType // 'page' or 'transform'
+ ) = $parts;
+ if ( $targetWiki !== 'local' ) {
+ throw new Exception( "Only 'local' target wiki is currently supported" );
+ } elseif ( $version !== 'v1' ) {
+ throw new Exception( "Version mismatch: should not happen." );
+ } elseif ( $reqType !== 'page' && $reqType !== 'transform' ) {
+ throw new Exception( "Request type must be either 'page' or 'transform'" );
+ }
+ $req['url'] = $this->params['url'] . $this->params['domain'] . '/v1/' . $reqType . '/';
+ if ( $reqType === 'page' ) {
+ $title = $parts[3];
+ if ( $parts[4] !== 'html' ) {
+ throw new Exception( "Only 'html' output format is currently supported" );
+ }
+ $req['url'] .= 'html/' . $title;
+ if ( isset( $parts[5] ) ) {
+ $req['url'] .= '/' . $parts[5];
+ } elseif ( isset( $req['query']['oldid'] ) && $req['query']['oldid'] ) {
+ $req['url'] .= '/' . $req['query']['oldid'];
+ unset( $req['query']['oldid'] );
+ }
+ } elseif ( $reqType === 'transform' ) {
+ // from / to transform
+ $req['url'] .= $parts[3] . '/to/' . $parts[5];
+ // the title
+ if ( isset( $parts[6] ) ) {
+ $req['url'] .= '/' . $parts[6];
+ }
+ // revision id
+ if ( isset( $parts[7] ) ) {
+ $req['url'] .= '/' . $parts[7];
+ } elseif ( isset( $req['body']['oldid'] ) && $req['body']['oldid'] ) {
+ $req['url'] .= '/' . $req['body']['oldid'];
+ unset( $req['body']['oldid'] );
+ }
+ if ( $parts[4] !== 'to' ) {
+ throw new Exception( "Part index 4 is not 'to'" );
+ }
+ if ( $parts[3] === 'html' && $parts[5] === 'wikitext' ) {
+ if ( !isset( $req['body']['html'] ) ) {
+ throw new Exception( "You must set an 'html' body key for this request" );
+ }
+ } elseif ( $parts[3] == 'wikitext' && $parts[5] == 'html' ) {
+ if ( !isset( $req['body']['wikitext'] ) ) {
+ throw new Exception( "You must set a 'wikitext' body key for this request" );
+ }
+ if ( isset( $req['body']['body'] ) ) {
+ $req['body']['body_only'] = $req['body']['body'];
+ unset( $req['body']['body'] );
+ }
+ } else {
+ throw new Exception( "Transformation unsupported" );
+ }
+ }
+ // set the appropriate proxy, timeout and headers
+ if ( $this->params['HTTPProxy'] ) {
+ $req['proxy'] = $this->params['HTTPProxy'];
+ }
+ if ( $this->params['timeout'] != null ) {
+ $req['reqTimeout'] = $this->params['timeout'];
+ }
+ if ( $this->params['forwardCookies'] ) {
+ $req['headers']['Cookie'] = $this->params['forwardCookies'];
+ }
+
+ return $req;
+ }
+
+ /**
+ * Remap a Parsoid v3 request to a RESTBase v1 request.
+ *
+ * Example Parsoid v3 requests:
+ * GET /local/v3/page/html/$title/{$revision}
+ * * $revision is optional
+ * POST /local/v3/transform/html/to/wikitext/{$title}{/$revision}
+ * * body: array( 'html' => ... )
+ * * $title and $revision are optional
+ * POST /local/v3/transform/wikitext/to/html/{$title}{/$revision}
+ * * body: array( 'wikitext' => ... ) or array( 'wikitext' => ..., 'body_only' => true/false )
+ * * $title is optional
+ * * $revision is optional
+ * @param array $req
+ * @param Closure $idGeneratorFunc
+ * @return array
+ * @throws Exception
+ */
+ public function onParsoid3Request( array $req, Closure $idGeneratorFunc ) {
+ $parts = explode( '/', $req['url'] );
+ list(
+ $targetWiki, // 'local'
+ $version, // 'v3'
+ $action, // 'transform' or 'page'
+ $format, // 'html' or 'wikitext'
+ // $title, // optional
+ // $revision, // optional
+ ) = $parts;
+ if ( $targetWiki !== 'local' ) {
+ throw new Exception( "Only 'local' target wiki is currently supported" );
+ } elseif ( $version !== 'v3' ) {
+ throw new Exception( "Version mismatch: should not happen." );
+ }
+ // replace /local/ with the current domain, change v3 to v1,
+ $req['url'] = preg_replace( '#^local/v3/#', $this->params['domain'] . '/v1/', $req['url'] );
+ // and prefix it with the service URL
+ $req['url'] = $this->params['url'] . $req['url'];
+ // set the appropriate proxy, timeout and headers
+ if ( $this->params['HTTPProxy'] ) {
+ $req['proxy'] = $this->params['HTTPProxy'];
+ }
+ if ( $this->params['timeout'] != null ) {
+ $req['reqTimeout'] = $this->params['timeout'];
+ }
+ if ( $this->params['forwardCookies'] ) {
+ $req['headers']['Cookie'] = $this->params['forwardCookies'];
+ }
+
+ return $req;
+ }
+
+}
diff --git a/www/wiki/includes/libs/virtualrest/SwiftVirtualRESTService.php b/www/wiki/includes/libs/virtualrest/SwiftVirtualRESTService.php
new file mode 100644
index 00000000..679d51c5
--- /dev/null
+++ b/www/wiki/includes/libs/virtualrest/SwiftVirtualRESTService.php
@@ -0,0 +1,179 @@
+<?php
+/**
+ * Virtual HTTP service client for Swift
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Example virtual rest service for OpenStack Swift
+ * @TODO: caching support (APC/memcached)
+ * @since 1.23
+ */
+class SwiftVirtualRESTService extends VirtualRESTService {
+ /** @var array */
+ protected $authCreds;
+ /** @var int UNIX timestamp */
+ protected $authSessionTimestamp = 0;
+ /** @var int UNIX timestamp */
+ protected $authErrorTimestamp = null;
+ /** @var int */
+ protected $authCachedStatus = null;
+ /** @var string */
+ protected $authCachedReason = null;
+
+ /**
+ * @param array $params Key/value map
+ * - swiftAuthUrl : Swift authentication server URL
+ * - swiftUser : Swift user used by MediaWiki (account:username)
+ * - swiftKey : Swift authentication key for the above user
+ * - swiftAuthTTL : Swift authentication TTL (seconds)
+ */
+ public function __construct( array $params ) {
+ // set up defaults and merge them with the given params
+ $mparams = array_merge( [
+ 'name' => 'swift'
+ ], $params );
+ parent::__construct( $mparams );
+ }
+
+ /**
+ * @return int|bool HTTP status on cached failure
+ */
+ protected function needsAuthRequest() {
+ if ( !$this->authCreds ) {
+ return true;
+ }
+ if ( $this->authErrorTimestamp !== null ) {
+ if ( ( time() - $this->authErrorTimestamp ) < 60 ) {
+ return $this->authCachedStatus; // failed last attempt; don't bother
+ } else { // actually retry this time
+ $this->authErrorTimestamp = null;
+ }
+ }
+ // Session keys expire after a while, so we renew them periodically
+ return ( ( time() - $this->authSessionTimestamp ) > $this->params['swiftAuthTTL'] );
+ }
+
+ protected function applyAuthResponse( array $req ) {
+ $this->authSessionTimestamp = 0;
+ list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response'];
+ if ( $rcode >= 200 && $rcode <= 299 ) { // OK
+ $this->authCreds = [
+ 'auth_token' => $rhdrs['x-auth-token'],
+ 'storage_url' => $rhdrs['x-storage-url']
+ ];
+ $this->authSessionTimestamp = time();
+ return true;
+ } elseif ( $rcode === 403 ) {
+ $this->authCachedStatus = 401;
+ $this->authCachedReason = 'Authorization Required';
+ $this->authErrorTimestamp = time();
+ return false;
+ } else {
+ $this->authCachedStatus = $rcode;
+ $this->authCachedReason = $rdesc;
+ $this->authErrorTimestamp = time();
+ return null;
+ }
+ }
+
+ public function onRequests( array $reqs, Closure $idGeneratorFunc ) {
+ $result = [];
+ $firstReq = reset( $reqs );
+ if ( $firstReq && count( $reqs ) == 1 && isset( $firstReq['isAuth'] ) ) {
+ // This was an authentication request for work requests...
+ $result = $reqs; // no change
+ } else {
+ // These are actual work requests...
+ $needsAuth = $this->needsAuthRequest();
+ if ( $needsAuth === true ) {
+ // These are work requests and we don't have any token to use.
+ // Replace the work requests with an authentication request.
+ $result = [
+ $idGeneratorFunc() => [
+ 'method' => 'GET',
+ 'url' => $this->params['swiftAuthUrl'] . "/v1.0",
+ 'headers' => [
+ 'x-auth-user' => $this->params['swiftUser'],
+ 'x-auth-key' => $this->params['swiftKey'] ],
+ 'isAuth' => true,
+ 'chain' => $reqs
+ ]
+ ];
+ } elseif ( $needsAuth !== false ) {
+ // These are work requests and authentication has previously failed.
+ // It is most efficient to just give failed pseudo responses back for
+ // the original work requests.
+ foreach ( $reqs as $key => $req ) {
+ $req['response'] = [
+ 'code' => $this->authCachedStatus,
+ 'reason' => $this->authCachedReason,
+ 'headers' => [],
+ 'body' => '',
+ 'error' => ''
+ ];
+ $result[$key] = $req;
+ }
+ } else {
+ // These are work requests and we have a token already.
+ // Go through and mangle each request to include a token.
+ foreach ( $reqs as $key => $req ) {
+ // The default encoding treats the URL as a REST style path that uses
+ // forward slash as a hierarchical delimiter (and never otherwise).
+ // Subclasses can override this, and should be documented in any case.
+ $parts = array_map( 'rawurlencode', explode( '/', $req['url'] ) );
+ $req['url'] = $this->authCreds['storage_url'] . '/' . implode( '/', $parts );
+ $req['headers']['x-auth-token'] = $this->authCreds['auth_token'];
+ $result[$key] = $req;
+ // @TODO: add ETag/Content-Length and such as needed
+ }
+ }
+ }
+ return $result;
+ }
+
+ public function onResponses( array $reqs, Closure $idGeneratorFunc ) {
+ $firstReq = reset( $reqs );
+ if ( $firstReq && count( $reqs ) == 1 && isset( $firstReq['isAuth'] ) ) {
+ $result = [];
+ // This was an authentication request for work requests...
+ if ( $this->applyAuthResponse( $firstReq ) ) {
+ // If it succeeded, we can subsitute the work requests back.
+ // Call this recursively in order to munge and add headers.
+ $result = $this->onRequests( $firstReq['chain'], $idGeneratorFunc );
+ } else {
+ // If it failed, it is most efficient to just give failing
+ // pseudo-responses back for the actual work requests.
+ foreach ( $firstReq['chain'] as $key => $req ) {
+ $req['response'] = [
+ 'code' => $this->authCachedStatus,
+ 'reason' => $this->authCachedReason,
+ 'headers' => [],
+ 'body' => '',
+ 'error' => ''
+ ];
+ $result[$key] = $req;
+ }
+ }
+ } else {
+ $result = $reqs; // no change
+ }
+ return $result;
+ }
+}
diff --git a/www/wiki/includes/libs/virtualrest/VirtualRESTService.php b/www/wiki/includes/libs/virtualrest/VirtualRESTService.php
new file mode 100644
index 00000000..2f160787
--- /dev/null
+++ b/www/wiki/includes/libs/virtualrest/VirtualRESTService.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * Virtual HTTP service client
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Virtual HTTP service instance that can be mounted on to a VirtualRESTService
+ *
+ * Sub-classes manage the logic of either:
+ * - a) Munging virtual HTTP request arrays to have qualified URLs and auth headers
+ * - b) Emulating the execution of virtual HTTP requests (e.g. brokering)
+ *
+ * Authentication information can be cached in instances of the class for performance.
+ * Such information should also be cached locally on the server and auth requests should
+ * have reasonable timeouts.
+ *
+ * @since 1.23
+ */
+abstract class VirtualRESTService {
+ /** @var array Key/value map */
+ protected $params = [];
+
+ /**
+ * @param array $params Key/value map
+ */
+ public function __construct( array $params ) {
+ $this->params = $params;
+ }
+
+ /**
+ * Return the name of this service, in a form suitable for error
+ * reporting or debugging.
+ *
+ * @return string The name of the service behind this VRS object.
+ */
+ public function getName() {
+ return isset( $this->params['name'] ) ? $this->params['name'] : static::class;
+ }
+
+ /**
+ * Prepare virtual HTTP(S) requests (for this service) for execution
+ *
+ * This method should mangle any of the $reqs entry fields as needed:
+ * - url : munge the URL to have an absolute URL with a protocol
+ * and encode path components as needed by the backend [required]
+ * - query : include any authentication signatures/parameters [as needed]
+ * - headers : include any authentication tokens/headers [as needed]
+ *
+ * The incoming URL parameter will be relative to the service mount point.
+ *
+ * This method can also remove some of the requests as well as add new ones
+ * (using $idGenerator to set each of the entries' array keys). For any existing
+ * or added request, the 'response' array can be filled in, which will prevent the
+ * client from executing it. If an original request is removed, at some point it
+ * must be added back (with the same key) in onRequests() or onResponses();
+ * it's reponse may be filled in as with other requests.
+ *
+ * @param array $reqs Map of Virtual HTTP request arrays
+ * @param Closure $idGeneratorFunc Method to generate unique keys for new requests
+ * @return array Modified HTTP request array map
+ */
+ public function onRequests( array $reqs, Closure $idGeneratorFunc ) {
+ $result = [];
+ foreach ( $reqs as $key => $req ) {
+ // The default encoding treats the URL as a REST style path that uses
+ // forward slash as a hierarchical delimiter (and never otherwise).
+ // Subclasses can override this, and should be documented in any case.
+ $parts = array_map( 'rawurlencode', explode( '/', $req['url'] ) );
+ $req['url'] = $this->params['baseUrl'] . '/' . implode( '/', $parts );
+ $result[$key] = $req;
+ }
+ return $result;
+ }
+
+ /**
+ * Mangle or replace virtual HTTP(S) requests which have been responded to
+ *
+ * This method may mangle any of the $reqs entry 'response' fields as needed:
+ * - code : perform any code normalization [as needed]
+ * - reason : perform any reason normalization [as needed]
+ * - headers : perform any header normalization [as needed]
+ *
+ * This method can also remove some of the requests as well as add new ones
+ * (using $idGenerator to set each of the entries' array keys). For any existing
+ * or added request, the 'response' array can be filled in, which will prevent the
+ * client from executing it. If an original request is removed, at some point it
+ * must be added back (with the same key) in onRequests() or onResponses();
+ * it's reponse may be filled in as with other requests. All requests added to $reqs
+ * will be passed through onRequests() to handle any munging required as normal.
+ *
+ * The incoming URL parameter will be relative to the service mount point.
+ *
+ * @param array $reqs Map of Virtual HTTP request arrays with 'response' set
+ * @param Closure $idGeneratorFunc Method to generate unique keys for new requests
+ * @return array Modified HTTP request array map
+ */
+ public function onResponses( array $reqs, Closure $idGeneratorFunc ) {
+ return $reqs;
+ }
+}
diff --git a/www/wiki/includes/libs/virtualrest/VirtualRESTServiceClient.php b/www/wiki/includes/libs/virtualrest/VirtualRESTServiceClient.php
new file mode 100644
index 00000000..e3b9376f
--- /dev/null
+++ b/www/wiki/includes/libs/virtualrest/VirtualRESTServiceClient.php
@@ -0,0 +1,321 @@
+<?php
+/**
+ * Virtual HTTP service client
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Virtual HTTP service client loosely styled after a Virtual File System
+ *
+ * Services can be mounted on path prefixes so that virtual HTTP operations
+ * against sub-paths will map to those services. Operations can actually be
+ * done using HTTP messages over the wire or may simple be emulated locally.
+ *
+ * Virtual HTTP request maps are arrays that use the following format:
+ * - method : GET/HEAD/PUT/POST/DELETE
+ * - url : HTTP/HTTPS URL or virtual service path with a registered prefix
+ * - query : <query parameter field/value associative array> (uses RFC 3986)
+ * - headers : <header name/value associative array>
+ * - body : source to get the HTTP request body from;
+ * this can simply be a string (always), a resource for
+ * PUT requests, and a field/value array for POST request;
+ * array bodies are encoded as multipart/form-data and strings
+ * use application/x-www-form-urlencoded (headers sent automatically)
+ * - stream : resource to stream the HTTP response body to
+ * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'.
+ *
+ * @since 1.23
+ */
+class VirtualRESTServiceClient {
+ /** @var MultiHttpClient */
+ private $http;
+ /** @var array Map of (prefix => VirtualRESTService|array) */
+ private $instances = [];
+
+ const VALID_MOUNT_REGEX = '#^/[0-9a-z]+/([0-9a-z]+/)*$#';
+
+ /**
+ * @param MultiHttpClient $http
+ */
+ public function __construct( MultiHttpClient $http ) {
+ $this->http = $http;
+ }
+
+ /**
+ * Map a prefix to service handler
+ *
+ * If $instance is in array, it must have these keys:
+ * - class : string; fully qualified VirtualRESTService class name
+ * - config : array; map of parameters that is the first __construct() argument
+ *
+ * @param string $prefix Virtual path
+ * @param VirtualRESTService|array $instance Service or info to yield the service
+ */
+ public function mount( $prefix, $instance ) {
+ if ( !preg_match( self::VALID_MOUNT_REGEX, $prefix ) ) {
+ throw new UnexpectedValueException( "Invalid service mount point '$prefix'." );
+ } elseif ( isset( $this->instances[$prefix] ) ) {
+ throw new UnexpectedValueException( "A service is already mounted on '$prefix'." );
+ }
+ if ( !( $instance instanceof VirtualRESTService ) ) {
+ if ( !isset( $instance['class'] ) || !isset( $instance['config'] ) ) {
+ throw new UnexpectedValueException( "Missing 'class' or 'config' ('$prefix')." );
+ }
+ }
+ $this->instances[$prefix] = $instance;
+ }
+
+ /**
+ * Unmap a prefix to service handler
+ *
+ * @param string $prefix Virtual path
+ */
+ public function unmount( $prefix ) {
+ if ( !preg_match( self::VALID_MOUNT_REGEX, $prefix ) ) {
+ throw new UnexpectedValueException( "Invalid service mount point '$prefix'." );
+ } elseif ( !isset( $this->instances[$prefix] ) ) {
+ throw new UnexpectedValueException( "No service is mounted on '$prefix'." );
+ }
+ unset( $this->instances[$prefix] );
+ }
+
+ /**
+ * Get the prefix and service that a virtual path is serviced by
+ *
+ * @param string $path
+ * @return array (prefix,VirtualRESTService) or (null,null) if none found
+ */
+ public function getMountAndService( $path ) {
+ $cmpFunc = function ( $a, $b ) {
+ $al = substr_count( $a, '/' );
+ $bl = substr_count( $b, '/' );
+ if ( $al === $bl ) {
+ return 0; // should not actually happen
+ }
+ return ( $al < $bl ) ? 1 : -1; // largest prefix first
+ };
+
+ $matches = []; // matching prefixes (mount points)
+ foreach ( $this->instances as $prefix => $unused ) {
+ if ( strpos( $path, $prefix ) === 0 ) {
+ $matches[] = $prefix;
+ }
+ }
+ usort( $matches, $cmpFunc );
+
+ // Return the most specific prefix and corresponding service
+ return $matches
+ ? [ $matches[0], $this->getInstance( $matches[0] ) ]
+ : [ null, null ];
+ }
+
+ /**
+ * Execute a virtual HTTP(S) request
+ *
+ * This method returns a response map of:
+ * - code : HTTP response code or 0 if there was a serious cURL error
+ * - reason : HTTP response reason (empty if there was a serious cURL error)
+ * - headers : <header name/value associative array>
+ * - body : HTTP response body or resource (if "stream" was set)
+ * - error : Any cURL error string
+ * The map also stores integer-indexed copies of these values. This lets callers do:
+ * @code
+ * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $client->run( $req );
+ * @endcode
+ * @param array $req Virtual HTTP request maps
+ * @return array Response array for request
+ */
+ public function run( array $req ) {
+ return $this->runMulti( [ $req ] )[0];
+ }
+
+ /**
+ * Execute a set of virtual HTTP(S) requests concurrently
+ *
+ * A map of requests keys to response maps is returned. Each response map has:
+ * - code : HTTP response code or 0 if there was a serious cURL error
+ * - reason : HTTP response reason (empty if there was a serious cURL error)
+ * - headers : <header name/value associative array>
+ * - body : HTTP response body or resource (if "stream" was set)
+ * - error : Any cURL error string
+ * The map also stores integer-indexed copies of these values. This lets callers do:
+ * @code
+ * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $responses[0];
+ * @endcode
+ *
+ * @param array $reqs Map of Virtual HTTP request maps
+ * @return array $reqs Map of corresponding response values with the same keys/order
+ * @throws Exception
+ */
+ public function runMulti( array $reqs ) {
+ foreach ( $reqs as $index => &$req ) {
+ if ( isset( $req[0] ) ) {
+ $req['method'] = $req[0]; // short-form
+ unset( $req[0] );
+ }
+ if ( isset( $req[1] ) ) {
+ $req['url'] = $req[1]; // short-form
+ unset( $req[1] );
+ }
+ $req['chain'] = []; // chain or list of replaced requests
+ }
+ unset( $req ); // don't assign over this by accident
+
+ $curUniqueId = 0;
+ $armoredIndexMap = []; // (original index => new index)
+
+ $doneReqs = []; // (index => request)
+ $executeReqs = []; // (index => request)
+ $replaceReqsByService = []; // (prefix => index => request)
+ $origPending = []; // (index => 1) for original requests
+
+ foreach ( $reqs as $origIndex => $req ) {
+ // Re-index keys to consecutive integers (they will be swapped back later)
+ $index = $curUniqueId++;
+ $armoredIndexMap[$origIndex] = $index;
+ $origPending[$index] = 1;
+ if ( preg_match( '#^(http|ftp)s?://#', $req['url'] ) ) {
+ // Absolute FTP/HTTP(S) URL, run it as normal
+ $executeReqs[$index] = $req;
+ } else {
+ // Must be a virtual HTTP URL; resolve it
+ list( $prefix, $service ) = $this->getMountAndService( $req['url'] );
+ if ( !$service ) {
+ throw new UnexpectedValueException( "Path '{$req['url']}' has no service." );
+ }
+ // Set the URL to the mount-relative portion
+ $req['url'] = substr( $req['url'], strlen( $prefix ) );
+ $replaceReqsByService[$prefix][$index] = $req;
+ }
+ }
+
+ // Function to get IDs that won't collide with keys in $armoredIndexMap
+ $idFunc = function () use ( &$curUniqueId ) {
+ return $curUniqueId++;
+ };
+
+ $rounds = 0;
+ do {
+ if ( ++$rounds > 5 ) { // sanity
+ throw new Exception( "Too many replacement rounds detected. Aborting." );
+ }
+ // Track requests executed this round that have a prefix/service.
+ // Note that this also includes requests where 'response' was forced.
+ $checkReqIndexesByPrefix = [];
+ // Resolve the virtual URLs valid and qualified HTTP(S) URLs
+ // and add any required authentication headers for the backend.
+ // Services can also replace requests with new ones, either to
+ // defer the original or to set a proxy response to the original.
+ $newReplaceReqsByService = [];
+ foreach ( $replaceReqsByService as $prefix => $servReqs ) {
+ $service = $this->getInstance( $prefix );
+ foreach ( $service->onRequests( $servReqs, $idFunc ) as $index => $req ) {
+ // Services use unique IDs for replacement requests
+ if ( isset( $servReqs[$index] ) || isset( $origPending[$index] ) ) {
+ // A current or original request which was not modified
+ } else {
+ // Replacement request that will convert to original requests
+ $newReplaceReqsByService[$prefix][$index] = $req;
+ }
+ if ( isset( $req['response'] ) ) {
+ // Replacement requests with pre-set responses should not execute
+ unset( $executeReqs[$index] );
+ unset( $origPending[$index] );
+ $doneReqs[$index] = $req;
+ } else {
+ // Original or mangled request included
+ $executeReqs[$index] = $req;
+ }
+ $checkReqIndexesByPrefix[$prefix][$index] = 1;
+ }
+ }
+ // Run the actual work HTTP requests
+ foreach ( $this->http->runMulti( $executeReqs ) as $index => $ranReq ) {
+ $doneReqs[$index] = $ranReq;
+ unset( $origPending[$index] );
+ }
+ $executeReqs = [];
+ // Services can also replace requests with new ones, either to
+ // defer the original or to set a proxy response to the original.
+ // Any replacement requests executed above will need to be replaced
+ // with new requests (eventually the original). The responses can be
+ // forced by setting 'response' rather than actually be sent over the wire.
+ $newReplaceReqsByService = [];
+ foreach ( $checkReqIndexesByPrefix as $prefix => $servReqIndexes ) {
+ $service = $this->getInstance( $prefix );
+ // $doneReqs actually has the requests (with 'response' set)
+ $servReqs = array_intersect_key( $doneReqs, $servReqIndexes );
+ foreach ( $service->onResponses( $servReqs, $idFunc ) as $index => $req ) {
+ // Services use unique IDs for replacement requests
+ if ( isset( $servReqs[$index] ) || isset( $origPending[$index] ) ) {
+ // A current or original request which was not modified
+ } else {
+ // Replacement requests with pre-set responses should not execute
+ $newReplaceReqsByService[$prefix][$index] = $req;
+ }
+ if ( isset( $req['response'] ) ) {
+ // Replacement requests with pre-set responses should not execute
+ unset( $origPending[$index] );
+ $doneReqs[$index] = $req;
+ } else {
+ // Update the request in case it was mangled
+ $executeReqs[$index] = $req;
+ }
+ }
+ }
+ // Update index of requests to inspect for replacement
+ $replaceReqsByService = $newReplaceReqsByService;
+ } while ( count( $origPending ) );
+
+ $responses = [];
+ // Update $reqs to include 'response' and normalized request 'headers'.
+ // This maintains the original order of $reqs.
+ foreach ( $reqs as $origIndex => $req ) {
+ $index = $armoredIndexMap[$origIndex];
+ if ( !isset( $doneReqs[$index] ) ) {
+ throw new UnexpectedValueException( "Response for request '$index' is NULL." );
+ }
+ $responses[$origIndex] = $doneReqs[$index]['response'];
+ }
+
+ return $responses;
+ }
+
+ /**
+ * @param string $prefix
+ * @return VirtualRESTService
+ */
+ private function getInstance( $prefix ) {
+ if ( !isset( $this->instances[$prefix] ) ) {
+ throw new RuntimeException( "No service registered at prefix '{$prefix}'." );
+ }
+
+ if ( !( $this->instances[$prefix] instanceof VirtualRESTService ) ) {
+ $config = $this->instances[$prefix]['config'];
+ $class = $this->instances[$prefix]['class'];
+ $service = new $class( $config );
+ if ( !( $service instanceof VirtualRESTService ) ) {
+ throw new UnexpectedValueException( "Registered service has the wrong class." );
+ }
+ $this->instances[$prefix] = $service;
+ }
+
+ return $this->instances[$prefix];
+ }
+}
diff --git a/www/wiki/includes/libs/xmp/XMP.php b/www/wiki/includes/libs/xmp/XMP.php
new file mode 100644
index 00000000..c46acc69
--- /dev/null
+++ b/www/wiki/includes/libs/xmp/XMP.php
@@ -0,0 +1,1372 @@
+<?php
+/**
+ * Reader for XMP data containing properties relevant to images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Class for reading xmp data containing properties relevant to
+ * images, and spitting out an array that FormatMetadata accepts.
+ *
+ * Note, this is not meant to recognize every possible thing you can
+ * encode in XMP. It should recognize all the properties we want.
+ * For example it doesn't have support for structures with multiple
+ * nesting levels, as none of the properties we're supporting use that
+ * feature. If it comes across properties it doesn't recognize, it should
+ * ignore them.
+ *
+ * The public methods one would call in this class are
+ * - parse( $content )
+ * Reads in xmp content.
+ * Can potentially be called multiple times with partial data each time.
+ * - parseExtended( $content )
+ * Reads XMPExtended blocks (jpeg files only).
+ * - getResults
+ * Outputs a results array.
+ *
+ * Note XMP kind of looks like rdf. They are not the same thing - XMP is
+ * encoded as a specific subset of rdf. This class can read XMP. It cannot
+ * read rdf.
+ */
+class XMPReader implements LoggerAwareInterface {
+ /** @var array XMP item configuration array */
+ protected $items;
+
+ /** @var array Array to hold the current element (and previous element, and so on) */
+ private $curItem = [];
+
+ /** @var bool|string The structure name when processing nested structures. */
+ private $ancestorStruct = false;
+
+ /** @var bool|string Temporary holder for character data that appears in xmp doc. */
+ private $charContent = false;
+
+ /** @var array Stores the state the xmpreader is in (see MODE_FOO constants) */
+ private $mode = [];
+
+ /** @var array Array to hold results */
+ private $results = [];
+
+ /** @var bool If we're doing a seq or bag. */
+ private $processingArray = false;
+
+ /** @var bool|string Used for lang alts only */
+ private $itemLang = false;
+
+ /** @var resource A resource handle for the XML parser */
+ private $xmlParser;
+
+ /** @var bool|string Character set like 'UTF-8' */
+ private $charset = false;
+
+ /** @var int */
+ private $extendedXMPOffset = 0;
+
+ /** @var int Flag determining if the XMP is safe to parse **/
+ private $parsable = 0;
+
+ /** @var string Buffer of XML to parse **/
+ private $xmlParsableBuffer = '';
+
+ /**
+ * These are various mode constants.
+ * they are used to figure out what to do
+ * with an element when its encountered.
+ *
+ * For example, MODE_IGNORE is used when processing
+ * a property we're not interested in. So if a new
+ * element pops up when we're in that mode, we ignore it.
+ */
+ const MODE_INITIAL = 0;
+ const MODE_IGNORE = 1;
+ const MODE_LI = 2;
+ const MODE_LI_LANG = 3;
+ const MODE_QDESC = 4;
+
+ // The following MODE constants are also used in the
+ // $items array to denote what type of property the item is.
+ const MODE_SIMPLE = 10;
+ const MODE_STRUCT = 11; // structure (associative array)
+ const MODE_SEQ = 12; // ordered list
+ const MODE_BAG = 13; // unordered list
+ const MODE_LANG = 14;
+ const MODE_ALT = 15; // non-language alt. Currently not implemented, and not needed atm.
+ const MODE_BAGSTRUCT = 16; // A BAG of Structs.
+
+ const NS_RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
+ const NS_XML = 'http://www.w3.org/XML/1998/namespace';
+
+ // States used while determining if XML is safe to parse
+ const PARSABLE_UNKNOWN = 0;
+ const PARSABLE_OK = 1;
+ const PARSABLE_BUFFERING = 2;
+ const PARSABLE_NO = 3;
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ /**
+ * Primary job is to initialize the XMLParser
+ * @param LoggerInterface|null $logger
+ */
+ function __construct( LoggerInterface $logger = null ) {
+ if ( !function_exists( 'xml_parser_create_ns' ) ) {
+ // this should already be checked by this point
+ throw new RuntimeException( 'XMP support requires XML Parser' );
+ }
+ if ( $logger ) {
+ $this->setLogger( $logger );
+ } else {
+ $this->setLogger( new NullLogger() );
+ }
+
+ $this->items = XMPInfo::getItems();
+
+ $this->resetXMLParser();
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * free the XML parser.
+ *
+ * @note It is unclear to me if we really need to do this ourselves
+ * or if php garbage collection will automatically free the xmlParser
+ * when it is no longer needed.
+ */
+ private function destroyXMLParser() {
+ if ( $this->xmlParser ) {
+ xml_parser_free( $this->xmlParser );
+ $this->xmlParser = null;
+ }
+ }
+
+ /**
+ * Main use is if a single item has multiple xmp documents describing it.
+ * For example in jpeg's with extendedXMP
+ */
+ private function resetXMLParser() {
+ $this->destroyXMLParser();
+
+ $this->xmlParser = xml_parser_create_ns( 'UTF-8', ' ' );
+ xml_parser_set_option( $this->xmlParser, XML_OPTION_CASE_FOLDING, 0 );
+ xml_parser_set_option( $this->xmlParser, XML_OPTION_SKIP_WHITE, 1 );
+
+ xml_set_element_handler( $this->xmlParser,
+ [ $this, 'startElement' ],
+ [ $this, 'endElement' ] );
+
+ xml_set_character_data_handler( $this->xmlParser, [ $this, 'char' ] );
+
+ $this->parsable = self::PARSABLE_UNKNOWN;
+ $this->xmlParsableBuffer = '';
+ }
+
+ /**
+ * Check if this instance supports using this class
+ * @return bool
+ */
+ public static function isSupported() {
+ return function_exists( 'xml_parser_create_ns' ) && class_exists( 'XMLReader' );
+ }
+
+ /** Get the result array. Do some post-processing before returning
+ * the array, and transform any metadata that is special-cased.
+ *
+ * @return array Array of results as an array of arrays suitable for
+ * FormatMetadata::getFormattedData().
+ */
+ public function getResults() {
+ // xmp-special is for metadata that affects how stuff
+ // is extracted. For example xmpNote:HasExtendedXMP.
+
+ // It is also used to handle photoshop:AuthorsPosition
+ // which is weird and really part of another property,
+ // see 2:85 in IPTC. See also pg 21 of IPTC4XMP standard.
+ // The location fields also use it.
+
+ $data = $this->results;
+
+ if ( isset( $data['xmp-special']['AuthorsPosition'] )
+ && is_string( $data['xmp-special']['AuthorsPosition'] )
+ && isset( $data['xmp-general']['Artist'][0] )
+ ) {
+ // Note, if there is more than one creator,
+ // this only applies to first. This also will
+ // only apply to the dc:Creator prop, not the
+ // exif:Artist prop.
+
+ $data['xmp-general']['Artist'][0] =
+ $data['xmp-special']['AuthorsPosition'] . ', '
+ . $data['xmp-general']['Artist'][0];
+ }
+
+ // Go through the LocationShown and LocationCreated
+ // changing it to the non-hierarchal form used by
+ // the other location fields.
+
+ if ( isset( $data['xmp-special']['LocationShown'][0] )
+ && is_array( $data['xmp-special']['LocationShown'][0] )
+ ) {
+ // the is_array is just paranoia. It should always
+ // be an array.
+ foreach ( $data['xmp-special']['LocationShown'] as $loc ) {
+ if ( !is_array( $loc ) ) {
+ // To avoid copying over the _type meta-fields.
+ continue;
+ }
+ foreach ( $loc as $field => $val ) {
+ $data['xmp-general'][$field . 'Dest'][] = $val;
+ }
+ }
+ }
+ if ( isset( $data['xmp-special']['LocationCreated'][0] )
+ && is_array( $data['xmp-special']['LocationCreated'][0] )
+ ) {
+ // the is_array is just paranoia. It should always
+ // be an array.
+ foreach ( $data['xmp-special']['LocationCreated'] as $loc ) {
+ if ( !is_array( $loc ) ) {
+ // To avoid copying over the _type meta-fields.
+ continue;
+ }
+ foreach ( $loc as $field => $val ) {
+ $data['xmp-general'][$field . 'Created'][] = $val;
+ }
+ }
+ }
+
+ // We don't want to return the special values, since they're
+ // special and not info to be stored about the file.
+ unset( $data['xmp-special'] );
+
+ // Convert GPSAltitude to negative if below sea level.
+ if ( isset( $data['xmp-exif']['GPSAltitudeRef'] )
+ && isset( $data['xmp-exif']['GPSAltitude'] )
+ ) {
+ // Must convert to a real before multiplying by -1
+ // XMPValidate guarantees there will always be a '/' in this value.
+ list( $nom, $denom ) = explode( '/', $data['xmp-exif']['GPSAltitude'] );
+ $data['xmp-exif']['GPSAltitude'] = $nom / $denom;
+
+ if ( $data['xmp-exif']['GPSAltitudeRef'] == '1' ) {
+ $data['xmp-exif']['GPSAltitude'] *= -1;
+ }
+ unset( $data['xmp-exif']['GPSAltitudeRef'] );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Main function to call to parse XMP. Use getResults to
+ * get results.
+ *
+ * Also catches any errors during processing, writes them to
+ * debug log, blanks result array and returns false.
+ *
+ * @param string $content XMP data
+ * @param bool $allOfIt If this is all the data (true) or if its split up (false). Default true
+ * @throws RuntimeException
+ * @return bool Success.
+ */
+ public function parse( $content, $allOfIt = true ) {
+ if ( !$this->xmlParser ) {
+ $this->resetXMLParser();
+ }
+ try {
+
+ // detect encoding by looking for BOM which is supposed to be in processing instruction.
+ // see page 12 of http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart3.pdf
+ if ( !$this->charset ) {
+ $bom = [];
+ if ( preg_match( '/\xEF\xBB\xBF|\xFE\xFF|\x00\x00\xFE\xFF|\xFF\xFE\x00\x00|\xFF\xFE/',
+ $content, $bom )
+ ) {
+ switch ( $bom[0] ) {
+ case "\xFE\xFF":
+ $this->charset = 'UTF-16BE';
+ break;
+ case "\xFF\xFE":
+ $this->charset = 'UTF-16LE';
+ break;
+ case "\x00\x00\xFE\xFF":
+ $this->charset = 'UTF-32BE';
+ break;
+ case "\xFF\xFE\x00\x00":
+ $this->charset = 'UTF-32LE';
+ break;
+ case "\xEF\xBB\xBF":
+ $this->charset = 'UTF-8';
+ break;
+ default:
+ // this should be impossible to get to
+ throw new RuntimeException( "Invalid BOM" );
+ }
+ } else {
+ // standard specifically says, if no bom assume utf-8
+ $this->charset = 'UTF-8';
+ }
+ }
+ if ( $this->charset !== 'UTF-8' ) {
+ // don't convert if already utf-8
+ MediaWiki\suppressWarnings();
+ $content = iconv( $this->charset, 'UTF-8//IGNORE', $content );
+ MediaWiki\restoreWarnings();
+ }
+
+ // Ensure the XMP block does not have an xml doctype declaration, which
+ // could declare entities unsafe to parse with xml_parse (T85848/T71210).
+ if ( $this->parsable !== self::PARSABLE_OK ) {
+ if ( $this->parsable === self::PARSABLE_NO ) {
+ throw new RuntimeException( 'Unsafe doctype declaration in XML.' );
+ }
+
+ $content = $this->xmlParsableBuffer . $content;
+ if ( !$this->checkParseSafety( $content ) ) {
+ if ( !$allOfIt && $this->parsable !== self::PARSABLE_NO ) {
+ // parse wasn't Unsuccessful yet, so return true
+ // in this case.
+ return true;
+ }
+ $msg = ( $this->parsable === self::PARSABLE_NO ) ?
+ 'Unsafe doctype declaration in XML.' :
+ 'No root element found in XML.';
+ throw new RuntimeException( $msg );
+ }
+ }
+
+ $ok = xml_parse( $this->xmlParser, $content, $allOfIt );
+ if ( !$ok ) {
+ $code = xml_get_error_code( $this->xmlParser );
+ $error = xml_error_string( $code );
+ $line = xml_get_current_line_number( $this->xmlParser );
+ $col = xml_get_current_column_number( $this->xmlParser );
+ $offset = xml_get_current_byte_index( $this->xmlParser );
+
+ $this->logger->warning(
+ '{method} : Error reading XMP content: {error} ' .
+ '(line: {line} column: {column} byte offset: {offset})',
+ [
+ 'method' => __METHOD__,
+ 'error_code' => $code,
+ 'error' => $error,
+ 'line' => $line,
+ 'column' => $col,
+ 'offset' => $offset,
+ 'content' => $content,
+ ] );
+ $this->results = []; // blank if error.
+ $this->destroyXMLParser();
+ return false;
+ }
+ } catch ( Exception $e ) {
+ $this->logger->warning(
+ '{method} Exception caught while parsing: ' . $e->getMessage(),
+ [
+ 'method' => __METHOD__,
+ 'exception' => $e,
+ 'content' => $content,
+ ]
+ );
+ $this->results = [];
+ return false;
+ }
+ if ( $allOfIt ) {
+ $this->destroyXMLParser();
+ }
+
+ return true;
+ }
+
+ /** Entry point for XMPExtended blocks in jpeg files
+ *
+ * @todo In serious need of testing
+ * @see http://www.adobe.ge/devnet/xmp/pdfs/XMPSpecificationPart3.pdf XMP spec part 3 page 20
+ * @param string $content XMPExtended block minus the namespace signature
+ * @return bool If it succeeded.
+ */
+ public function parseExtended( $content ) {
+ // @todo FIXME: This is untested. Hard to find example files
+ // or programs that make such files..
+ $guid = substr( $content, 0, 32 );
+ if ( !isset( $this->results['xmp-special']['HasExtendedXMP'] )
+ || $this->results['xmp-special']['HasExtendedXMP'] !== $guid
+ ) {
+ $this->logger->info( __METHOD__ .
+ " Ignoring XMPExtended block due to wrong guid (guid= '$guid')" );
+
+ return false;
+ }
+ $len = unpack( 'Nlength/Noffset', substr( $content, 32, 8 ) );
+
+ if ( !$len ||
+ $len['length'] < 4 ||
+ $len['offset'] < 0 ||
+ $len['offset'] > $len['length']
+ ) {
+ $this->logger->info(
+ __METHOD__ . 'Error reading extended XMP block, invalid length or offset.'
+ );
+
+ return false;
+ }
+
+ // we're not very robust here. we should accept it in the wrong order.
+ // To quote the XMP standard:
+ // "A JPEG writer should write the ExtendedXMP marker segments in order,
+ // immediately following the StandardXMP. However, the JPEG standard
+ // does not require preservation of marker segment order. A robust JPEG
+ // reader should tolerate the marker segments in any order."
+ // On the other hand, the probability that an image will have more than
+ // 128k of metadata is rather low... so the probability that it will have
+ // > 128k, and be in the wrong order is very low...
+
+ if ( $len['offset'] !== $this->extendedXMPOffset ) {
+ $this->logger->info( __METHOD__ . 'Ignoring XMPExtended block due to wrong order. (Offset was '
+ . $len['offset'] . ' but expected ' . $this->extendedXMPOffset . ')' );
+
+ return false;
+ }
+
+ if ( $len['offset'] === 0 ) {
+ // if we're starting the extended block, we've probably already
+ // done the XMPStandard block, so reset.
+ $this->resetXMLParser();
+ }
+
+ $this->extendedXMPOffset += $len['length'];
+
+ $actualContent = substr( $content, 40 );
+
+ if ( $this->extendedXMPOffset === strlen( $actualContent ) ) {
+ $atEnd = true;
+ } else {
+ $atEnd = false;
+ }
+
+ $this->logger->debug( __METHOD__ . 'Parsing a XMPExtended block' );
+
+ return $this->parse( $actualContent, $atEnd );
+ }
+
+ /**
+ * Character data handler
+ * Called whenever character data is found in the xmp document.
+ *
+ * does nothing if we're in MODE_IGNORE or if the data is whitespace
+ * throws an error if we're not in MODE_SIMPLE (as we're not allowed to have character
+ * data in the other modes).
+ *
+ * As an example, this happens when we encounter XMP like:
+ * <exif:DigitalZoomRatio>0/10</exif:DigitalZoomRatio>
+ * and are processing the 0/10 bit.
+ *
+ * @param resource $parser XMLParser reference to the xml parser
+ * @param string $data Character data
+ * @throws RuntimeException On invalid data
+ */
+ function char( $parser, $data ) {
+ $data = trim( $data );
+ if ( trim( $data ) === "" ) {
+ return;
+ }
+
+ if ( !isset( $this->mode[0] ) ) {
+ throw new RuntimeException( 'Unexpected character data before first rdf:Description element' );
+ }
+
+ if ( $this->mode[0] === self::MODE_IGNORE ) {
+ return;
+ }
+
+ if ( $this->mode[0] !== self::MODE_SIMPLE
+ && $this->mode[0] !== self::MODE_QDESC
+ ) {
+ throw new RuntimeException( 'character data where not expected. (mode ' . $this->mode[0] . ')' );
+ }
+
+ // to check, how does this handle w.s.
+ if ( $this->charContent === false ) {
+ $this->charContent = $data;
+ } else {
+ $this->charContent .= $data;
+ }
+ }
+
+ /**
+ * Check if a block of XML is safe to pass to xml_parse, i.e. doesn't
+ * contain a doctype declaration which could contain a dos attack if we
+ * parse it and expand internal entities (T85848).
+ *
+ * @param string $content xml string to check for parse safety
+ * @return bool true if the xml is safe to parse, false otherwise
+ */
+ private function checkParseSafety( $content ) {
+ $reader = new XMLReader();
+ $result = null;
+
+ // For XMLReader to parse incomplete/invalid XML, it has to be open()'ed
+ // instead of using XML().
+ $reader->open(
+ 'data://text/plain,' . urlencode( $content ),
+ null,
+ LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET
+ );
+
+ $oldDisable = libxml_disable_entity_loader( true );
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $reset = new ScopedCallback(
+ 'libxml_disable_entity_loader',
+ [ $oldDisable ]
+ );
+ $reader->setParserProperty( XMLReader::SUBST_ENTITIES, false );
+
+ // Even with LIBXML_NOWARNING set, XMLReader::read gives a warning
+ // when parsing truncated XML, which causes unit tests to fail.
+ MediaWiki\suppressWarnings();
+ while ( $reader->read() ) {
+ if ( $reader->nodeType === XMLReader::ELEMENT ) {
+ // Reached the first element without hitting a doctype declaration
+ $this->parsable = self::PARSABLE_OK;
+ $result = true;
+ break;
+ }
+ if ( $reader->nodeType === XMLReader::DOC_TYPE ) {
+ $this->parsable = self::PARSABLE_NO;
+ $result = false;
+ break;
+ }
+ }
+ MediaWiki\restoreWarnings();
+
+ if ( !is_null( $result ) ) {
+ return $result;
+ }
+
+ // Reached the end of the parsable xml without finding an element
+ // or doctype. Buffer and try again.
+ $this->parsable = self::PARSABLE_BUFFERING;
+ $this->xmlParsableBuffer = $content;
+ return false;
+ }
+
+ /** When we hit a closing element in MODE_IGNORE
+ * Check to see if this is the element we started to ignore,
+ * in which case we get out of MODE_IGNORE
+ *
+ * @param string $elm Namespace of element followed by a space and then tag name of element.
+ */
+ private function endElementModeIgnore( $elm ) {
+ if ( $this->curItem[0] === $elm ) {
+ array_shift( $this->curItem );
+ array_shift( $this->mode );
+ }
+ }
+
+ /**
+ * Hit a closing element when in MODE_SIMPLE.
+ * This generally means that we finished processing a
+ * property value, and now have to save the result to the
+ * results array
+ *
+ * For example, when processing:
+ * <exif:DigitalZoomRatio>0/10</exif:DigitalZoomRatio>
+ * this deals with when we hit </exif:DigitalZoomRatio>.
+ *
+ * Or it could be if we hit the end element of a property
+ * of a compound data structure (like a member of an array).
+ *
+ * @param string $elm Namespace, space, and tag name.
+ */
+ private function endElementModeSimple( $elm ) {
+ if ( $this->charContent !== false ) {
+ if ( $this->processingArray ) {
+ // if we're processing an array, use the original element
+ // name instead of rdf:li.
+ list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
+ } else {
+ list( $ns, $tag ) = explode( ' ', $elm, 2 );
+ }
+ $this->saveValue( $ns, $tag, $this->charContent );
+
+ $this->charContent = false; // reset
+ }
+ array_shift( $this->curItem );
+ array_shift( $this->mode );
+ }
+
+ /**
+ * Hit a closing element in MODE_STRUCT, MODE_SEQ, MODE_BAG
+ * generally means we've finished processing a nested structure.
+ * resets some internal variables to indicate that.
+ *
+ * Note this means we hit the closing element not the "</rdf:Seq>".
+ *
+ * @par For example, when processing:
+ * @code{,xml}
+ * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
+ * </rdf:Seq> </exif:ISOSpeedRatings>
+ * @endcode
+ *
+ * This method is called when we hit the "</exif:ISOSpeedRatings>" tag.
+ *
+ * @param string $elm Namespace . space . tag name.
+ * @throws RuntimeException
+ */
+ private function endElementNested( $elm ) {
+ /* cur item must be the same as $elm, unless if in MODE_STRUCT
+ * in which case it could also be rdf:Description */
+ if ( $this->curItem[0] !== $elm
+ && !( $elm === self::NS_RDF . ' Description'
+ && $this->mode[0] === self::MODE_STRUCT )
+ ) {
+ throw new RuntimeException( "nesting mismatch. got a </$elm> but expected a </" .
+ $this->curItem[0] . '>' );
+ }
+
+ // Validate structures.
+ list( $ns, $tag ) = explode( ' ', $elm, 2 );
+ if ( isset( $this->items[$ns][$tag]['validate'] ) ) {
+ $info =& $this->items[$ns][$tag];
+ $finalName = isset( $info['map_name'] )
+ ? $info['map_name'] : $tag;
+
+ if ( is_array( $info['validate'] ) ) {
+ $validate = $info['validate'];
+ } else {
+ $validator = new XMPValidate( $this->logger );
+ $validate = [ $validator, $info['validate'] ];
+ }
+
+ if ( !isset( $this->results['xmp-' . $info['map_group']][$finalName] ) ) {
+ // This can happen if all the members of the struct failed validation.
+ $this->logger->debug( __METHOD__ . " <$ns:$tag> has no valid members." );
+ } elseif ( is_callable( $validate ) ) {
+ $val =& $this->results['xmp-' . $info['map_group']][$finalName];
+ call_user_func_array( $validate, [ $info, &$val, false ] );
+ if ( is_null( $val ) ) {
+ // the idea being the validation function will unset the variable if
+ // its invalid.
+ $this->logger->info( __METHOD__ . " <$ns:$tag> failed validation." );
+ unset( $this->results['xmp-' . $info['map_group']][$finalName] );
+ }
+ } else {
+ $this->logger->warning( __METHOD__ . " Validation function for $finalName ("
+ . $validate[0] . '::' . $validate[1] . '()) is not callable.' );
+ }
+ }
+
+ array_shift( $this->curItem );
+ array_shift( $this->mode );
+ $this->ancestorStruct = false;
+ $this->processingArray = false;
+ $this->itemLang = false;
+ }
+
+ /**
+ * Hit a closing element in MODE_LI (either rdf:Seq, or rdf:Bag )
+ * Add information about what type of element this is.
+ *
+ * Note we still have to hit the outer "</property>"
+ *
+ * @par For example, when processing:
+ * @code{,xml}
+ * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
+ * </rdf:Seq> </exif:ISOSpeedRatings>
+ * @endcode
+ *
+ * This method is called when we hit the "</rdf:Seq>".
+ * (For comparison, we call endElementModeSimple when we
+ * hit the "</rdf:li>")
+ *
+ * @param string $elm Namespace . ' ' . element name
+ * @throws RuntimeException
+ */
+ private function endElementModeLi( $elm ) {
+ list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
+ $info = $this->items[$ns][$tag];
+ $finalName = isset( $info['map_name'] )
+ ? $info['map_name'] : $tag;
+
+ array_shift( $this->mode );
+
+ if ( !isset( $this->results['xmp-' . $info['map_group']][$finalName] ) ) {
+ $this->logger->debug( __METHOD__ . " Empty compund element $finalName." );
+
+ return;
+ }
+
+ if ( $elm === self::NS_RDF . ' Seq' ) {
+ $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'ol';
+ } elseif ( $elm === self::NS_RDF . ' Bag' ) {
+ $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'ul';
+ } elseif ( $elm === self::NS_RDF . ' Alt' ) {
+ // extra if needed as you could theoretically have a non-language alt.
+ if ( $info['mode'] === self::MODE_LANG ) {
+ $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'lang';
+ }
+ } else {
+ throw new RuntimeException(
+ __METHOD__ . " expected </rdf:seq> or </rdf:bag> but instead got $elm."
+ );
+ }
+ }
+
+ /**
+ * End element while in MODE_QDESC
+ * mostly when ending an element when we have a simple value
+ * that has qualifiers.
+ *
+ * Qualifiers aren't all that common, and we don't do anything
+ * with them.
+ *
+ * @param string $elm Namespace and element
+ */
+ private function endElementModeQDesc( $elm ) {
+ if ( $elm === self::NS_RDF . ' value' ) {
+ list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
+ $this->saveValue( $ns, $tag, $this->charContent );
+
+ return;
+ } else {
+ array_shift( $this->mode );
+ array_shift( $this->curItem );
+ }
+ }
+
+ /**
+ * Handler for hitting a closing element.
+ *
+ * generally just calls a helper function depending on what
+ * mode we're in.
+ *
+ * Ignores the outer wrapping elements that are optional in
+ * xmp and have no meaning.
+ *
+ * @param resource $parser
+ * @param string $elm Namespace . ' ' . element name
+ * @throws RuntimeException
+ */
+ function endElement( $parser, $elm ) {
+ if ( $elm === ( self::NS_RDF . ' RDF' )
+ || $elm === 'adobe:ns:meta/ xmpmeta'
+ || $elm === 'adobe:ns:meta/ xapmeta'
+ ) {
+ // ignore these.
+ return;
+ }
+
+ if ( $elm === self::NS_RDF . ' type' ) {
+ // these aren't really supported properly yet.
+ // However, it appears they almost never used.
+ $this->logger->info( __METHOD__ . ' encountered <rdf:type>' );
+ }
+
+ if ( strpos( $elm, ' ' ) === false ) {
+ // This probably shouldn't happen.
+ // However, there is a bug in an adobe product
+ // that forgets the namespace on some things.
+ // (Luckily they are unimportant things).
+ $this->logger->info( __METHOD__ . " Encountered </$elm> which has no namespace. Skipping." );
+
+ return;
+ }
+
+ if ( count( $this->mode[0] ) === 0 ) {
+ // This should never ever happen and means
+ // there is a pretty major bug in this class.
+ throw new RuntimeException( 'Encountered end element with no mode' );
+ }
+
+ if ( count( $this->curItem ) == 0 && $this->mode[0] !== self::MODE_INITIAL ) {
+ // just to be paranoid. Should always have a curItem, except for initially
+ // (aka during MODE_INITAL).
+ throw new RuntimeException( "Hit end element </$elm> but no curItem" );
+ }
+
+ switch ( $this->mode[0] ) {
+ case self::MODE_IGNORE:
+ $this->endElementModeIgnore( $elm );
+ break;
+ case self::MODE_SIMPLE:
+ $this->endElementModeSimple( $elm );
+ break;
+ case self::MODE_STRUCT:
+ case self::MODE_SEQ:
+ case self::MODE_BAG:
+ case self::MODE_LANG:
+ case self::MODE_BAGSTRUCT:
+ $this->endElementNested( $elm );
+ break;
+ case self::MODE_INITIAL:
+ if ( $elm === self::NS_RDF . ' Description' ) {
+ array_shift( $this->mode );
+ } else {
+ throw new RuntimeException( 'Element ended unexpectedly while in MODE_INITIAL' );
+ }
+ break;
+ case self::MODE_LI:
+ case self::MODE_LI_LANG:
+ $this->endElementModeLi( $elm );
+ break;
+ case self::MODE_QDESC:
+ $this->endElementModeQDesc( $elm );
+ break;
+ default:
+ $this->logger->warning( __METHOD__ . " no mode (elm = $elm)" );
+ break;
+ }
+ }
+
+ /**
+ * Hit an opening element while in MODE_IGNORE
+ *
+ * XMP is extensible, so ignore any tag we don't understand.
+ *
+ * Mostly ignores, unless we encounter the element that we are ignoring.
+ * in which case we add it to the item stack, so we can ignore things
+ * that are nested, correctly.
+ *
+ * @param string $elm Namespace . ' ' . tag name
+ */
+ private function startElementModeIgnore( $elm ) {
+ if ( $elm === $this->curItem[0] ) {
+ array_unshift( $this->curItem, $elm );
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ }
+ }
+
+ /**
+ * Start element in MODE_BAG (unordered array)
+ * this should always be <rdf:Bag>
+ *
+ * @param string $elm Namespace . ' ' . tag
+ * @throws RuntimeException If we have an element that's not <rdf:Bag>
+ */
+ private function startElementModeBag( $elm ) {
+ if ( $elm === self::NS_RDF . ' Bag' ) {
+ array_unshift( $this->mode, self::MODE_LI );
+ } else {
+ throw new RuntimeException( "Expected <rdf:Bag> but got $elm." );
+ }
+ }
+
+ /**
+ * Start element in MODE_SEQ (ordered array)
+ * this should always be <rdf:Seq>
+ *
+ * @param string $elm Namespace . ' ' . tag
+ * @throws RuntimeException If we have an element that's not <rdf:Seq>
+ */
+ private function startElementModeSeq( $elm ) {
+ if ( $elm === self::NS_RDF . ' Seq' ) {
+ array_unshift( $this->mode, self::MODE_LI );
+ } elseif ( $elm === self::NS_RDF . ' Bag' ) {
+ # T29105
+ $this->logger->info( __METHOD__ . ' Expected an rdf:Seq, but got an rdf:Bag. Pretending'
+ . ' it is a Seq, since some buggy software is known to screw this up.' );
+ array_unshift( $this->mode, self::MODE_LI );
+ } else {
+ throw new RuntimeException( "Expected <rdf:Seq> but got $elm." );
+ }
+ }
+
+ /**
+ * Start element in MODE_LANG (language alternative)
+ * this should always be <rdf:Alt>
+ *
+ * This tag tends to be used for metadata like describe this
+ * picture, which can be translated into multiple languages.
+ *
+ * XMP supports non-linguistic alternative selections,
+ * which are really only used for thumbnails, which
+ * we don't care about.
+ *
+ * @param string $elm Namespace . ' ' . tag
+ * @throws RuntimeException If we have an element that's not <rdf:Alt>
+ */
+ private function startElementModeLang( $elm ) {
+ if ( $elm === self::NS_RDF . ' Alt' ) {
+ array_unshift( $this->mode, self::MODE_LI_LANG );
+ } else {
+ throw new RuntimeException( "Expected <rdf:Seq> but got $elm." );
+ }
+ }
+
+ /**
+ * Handle an opening element when in MODE_SIMPLE
+ *
+ * This should not happen often. This is for if a simple element
+ * already opened has a child element. Could happen for a
+ * qualified element.
+ *
+ * For example:
+ * <exif:DigitalZoomRatio><rdf:Description><rdf:value>0/10</rdf:value>
+ * <foo:someQualifier>Bar</foo:someQualifier> </rdf:Description>
+ * </exif:DigitalZoomRatio>
+ *
+ * This method is called when processing the <rdf:Description> element
+ *
+ * @param string $elm Namespace and tag names separated by space.
+ * @param array $attribs Attributes of the element.
+ * @throws RuntimeException
+ */
+ private function startElementModeSimple( $elm, $attribs ) {
+ if ( $elm === self::NS_RDF . ' Description' ) {
+ // If this value has qualifiers
+ array_unshift( $this->mode, self::MODE_QDESC );
+ array_unshift( $this->curItem, $this->curItem[0] );
+
+ if ( isset( $attribs[self::NS_RDF . ' value'] ) ) {
+ list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
+ $this->saveValue( $ns, $tag, $attribs[self::NS_RDF . ' value'] );
+ }
+ } elseif ( $elm === self::NS_RDF . ' value' ) {
+ // This should not be here.
+ throw new RuntimeException( __METHOD__ . ' Encountered <rdf:value> where it was unexpected.' );
+ } else {
+ // something else we don't recognize, like a qualifier maybe.
+ $this->logger->info( __METHOD__ .
+ " Encountered element <$elm> where only expecting character data as value of " .
+ $this->curItem[0] );
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $elm );
+ }
+ }
+
+ /**
+ * Start an element when in MODE_QDESC.
+ * This generally happens when a simple element has an inner
+ * rdf:Description to hold qualifier elements.
+ *
+ * For example in:
+ * <exif:DigitalZoomRatio><rdf:Description><rdf:value>0/10</rdf:value>
+ * <foo:someQualifier>Bar</foo:someQualifier> </rdf:Description>
+ * </exif:DigitalZoomRatio>
+ * Called when processing the <rdf:value> or <foo:someQualifier>.
+ *
+ * @param string $elm Namespace and tag name separated by a space.
+ */
+ private function startElementModeQDesc( $elm ) {
+ if ( $elm === self::NS_RDF . ' value' ) {
+ return; // do nothing
+ } else {
+ // otherwise its a qualifier, which we ignore
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $elm );
+ }
+ }
+
+ /**
+ * Starting an element when in MODE_INITIAL
+ * This usually happens when we hit an element inside
+ * the outer rdf:Description
+ *
+ * This is generally where most properties start.
+ *
+ * @param string $ns Namespace
+ * @param string $tag Tag name (without namespace prefix)
+ * @param array $attribs Array of attributes
+ * @throws RuntimeException
+ */
+ private function startElementModeInitial( $ns, $tag, $attribs ) {
+ if ( $ns !== self::NS_RDF ) {
+ if ( isset( $this->items[$ns][$tag] ) ) {
+ if ( isset( $this->items[$ns][$tag]['structPart'] ) ) {
+ // If this element is supposed to appear only as
+ // a child of a structure, but appears here (not as
+ // a child of a struct), then something weird is
+ // happening, so ignore this element and its children.
+
+ $this->logger->warning( "Encountered <$ns:$tag> outside"
+ . " of its expected parent. Ignoring." );
+
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+
+ return;
+ }
+ $mode = $this->items[$ns][$tag]['mode'];
+ array_unshift( $this->mode, $mode );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+ if ( $mode === self::MODE_STRUCT ) {
+ $this->ancestorStruct = isset( $this->items[$ns][$tag]['map_name'] )
+ ? $this->items[$ns][$tag]['map_name'] : $tag;
+ }
+ if ( $this->charContent !== false ) {
+ // Something weird.
+ // Should not happen in valid XMP.
+ throw new RuntimeException( 'tag nested in non-whitespace characters.' );
+ }
+ } else {
+ // This element is not on our list of allowed elements so ignore.
+ $this->logger->debug( __METHOD__ . " Ignoring unrecognized element <$ns:$tag>." );
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+
+ return;
+ }
+ }
+ // process attributes
+ $this->doAttribs( $attribs );
+ }
+
+ /**
+ * Hit an opening element when in a Struct (MODE_STRUCT)
+ * This is generally for fields of a compound property.
+ *
+ * Example of a struct (abbreviated; flash has more properties):
+ *
+ * <exif:Flash> <rdf:Description> <exif:Fired>True</exif:Fired>
+ * <exif:Mode>1</exif:Mode></rdf:Description></exif:Flash>
+ *
+ * or:
+ *
+ * <exif:Flash rdf:parseType='Resource'> <exif:Fired>True</exif:Fired>
+ * <exif:Mode>1</exif:Mode></exif:Flash>
+ *
+ * @param string $ns Namespace
+ * @param string $tag Tag name (no ns)
+ * @param array $attribs Array of attribs w/ values.
+ * @throws RuntimeException
+ */
+ private function startElementModeStruct( $ns, $tag, $attribs ) {
+ if ( $ns !== self::NS_RDF ) {
+ if ( isset( $this->items[$ns][$tag] ) ) {
+ if ( isset( $this->items[$ns][$this->ancestorStruct]['children'] )
+ && !isset( $this->items[$ns][$this->ancestorStruct]['children'][$tag] )
+ ) {
+ // This assumes that we don't have inter-namespace nesting
+ // which we don't in all the properties we're interested in.
+ throw new RuntimeException( " <$tag> appeared nested in <" . $this->ancestorStruct
+ . "> where it is not allowed." );
+ }
+ array_unshift( $this->mode, $this->items[$ns][$tag]['mode'] );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+ if ( $this->charContent !== false ) {
+ // Something weird.
+ // Should not happen in valid XMP.
+ throw new RuntimeException( "tag <$tag> nested in non-whitespace characters (" .
+ $this->charContent . ")." );
+ }
+ } else {
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+
+ return;
+ }
+ }
+
+ if ( $ns === self::NS_RDF && $tag === 'Description' ) {
+ $this->doAttribs( $attribs );
+ array_unshift( $this->mode, self::MODE_STRUCT );
+ array_unshift( $this->curItem, $this->curItem[0] );
+ }
+ }
+
+ /**
+ * opening element in MODE_LI
+ * process elements of arrays.
+ *
+ * Example:
+ * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
+ * </rdf:Seq> </exif:ISOSpeedRatings>
+ * This method is called when we hit the <rdf:li> element.
+ *
+ * @param string $elm Namespace . ' ' . tagname
+ * @param array $attribs Attributes. (needed for BAGSTRUCTS)
+ * @throws RuntimeException If gets a tag other than <rdf:li>
+ */
+ private function startElementModeLi( $elm, $attribs ) {
+ if ( ( $elm ) !== self::NS_RDF . ' li' ) {
+ throw new RuntimeException( "<rdf:li> expected but got $elm." );
+ }
+
+ if ( !isset( $this->mode[1] ) ) {
+ // This should never ever ever happen. Checking for it
+ // to be paranoid.
+ throw new RuntimeException( 'In mode Li, but no 2xPrevious mode!' );
+ }
+
+ if ( $this->mode[1] === self::MODE_BAGSTRUCT ) {
+ // This list item contains a compound (STRUCT) value.
+ array_unshift( $this->mode, self::MODE_STRUCT );
+ array_unshift( $this->curItem, $elm );
+ $this->processingArray = true;
+
+ if ( !isset( $this->curItem[1] ) ) {
+ // be paranoid.
+ throw new RuntimeException( 'Can not find parent of BAGSTRUCT.' );
+ }
+ list( $curNS, $curTag ) = explode( ' ', $this->curItem[1] );
+ $this->ancestorStruct = isset( $this->items[$curNS][$curTag]['map_name'] )
+ ? $this->items[$curNS][$curTag]['map_name'] : $curTag;
+
+ $this->doAttribs( $attribs );
+ } else {
+ // Normal BAG or SEQ containing simple values.
+ array_unshift( $this->mode, self::MODE_SIMPLE );
+ // need to add curItem[0] on again since one is for the specific item
+ // and one is for the entire group.
+ array_unshift( $this->curItem, $this->curItem[0] );
+ $this->processingArray = true;
+ }
+ }
+
+ /**
+ * Opening element in MODE_LI_LANG.
+ * process elements of language alternatives
+ *
+ * Example:
+ * <dc:title> <rdf:Alt> <rdf:li xml:lang="x-default">My house
+ * </rdf:li> </rdf:Alt> </dc:title>
+ *
+ * This method is called when we hit the <rdf:li> element.
+ *
+ * @param string $elm Namespace . ' ' . tag
+ * @param array $attribs Array of elements (most importantly xml:lang)
+ * @throws RuntimeException If gets a tag other than <rdf:li> or if no xml:lang
+ */
+ private function startElementModeLiLang( $elm, $attribs ) {
+ if ( $elm !== self::NS_RDF . ' li' ) {
+ throw new RuntimeException( __METHOD__ . " <rdf:li> expected but got $elm." );
+ }
+ if ( !isset( $attribs[self::NS_XML . ' lang'] )
+ || !preg_match( '/^[-A-Za-z0-9]{2,}$/D', $attribs[self::NS_XML . ' lang'] )
+ ) {
+ throw new RuntimeException( __METHOD__
+ . " <rdf:li> did not contain, or has invalid xml:lang attribute in lang alternative" );
+ }
+
+ // Lang is case-insensitive.
+ $this->itemLang = strtolower( $attribs[self::NS_XML . ' lang'] );
+
+ // need to add curItem[0] on again since one is for the specific item
+ // and one is for the entire group.
+ array_unshift( $this->curItem, $this->curItem[0] );
+ array_unshift( $this->mode, self::MODE_SIMPLE );
+ $this->processingArray = true;
+ }
+
+ /**
+ * Hits an opening element.
+ * Generally just calls a helper based on what MODE we're in.
+ * Also does some initial set up for the wrapper element
+ *
+ * @param resource $parser
+ * @param string $elm Namespace "<space>" element
+ * @param array $attribs Attribute name => value
+ * @throws RuntimeException
+ */
+ function startElement( $parser, $elm, $attribs ) {
+ if ( $elm === self::NS_RDF . ' RDF'
+ || $elm === 'adobe:ns:meta/ xmpmeta'
+ || $elm === 'adobe:ns:meta/ xapmeta'
+ ) {
+ /* ignore. */
+ return;
+ } elseif ( $elm === self::NS_RDF . ' Description' ) {
+ if ( count( $this->mode ) === 0 ) {
+ // outer rdf:desc
+ array_unshift( $this->mode, self::MODE_INITIAL );
+ }
+ } elseif ( $elm === self::NS_RDF . ' type' ) {
+ // This doesn't support rdf:type properly.
+ // In practise I have yet to see a file that
+ // uses this element, however it is mentioned
+ // on page 25 of part 1 of the xmp standard.
+ // Also it seems as if exiv2 and exiftool do not support
+ // this either (That or I misunderstand the standard)
+ $this->logger->info( __METHOD__ . ' Encountered <rdf:type> which isn\'t currently supported' );
+ }
+
+ if ( strpos( $elm, ' ' ) === false ) {
+ // This probably shouldn't happen.
+ $this->logger->info( __METHOD__ . " Encountered <$elm> which has no namespace. Skipping." );
+
+ return;
+ }
+
+ list( $ns, $tag ) = explode( ' ', $elm, 2 );
+
+ if ( count( $this->mode ) === 0 ) {
+ // This should not happen.
+ throw new RuntimeException( 'Error extracting XMP, '
+ . "encountered <$elm> with no mode" );
+ }
+
+ switch ( $this->mode[0] ) {
+ case self::MODE_IGNORE:
+ $this->startElementModeIgnore( $elm );
+ break;
+ case self::MODE_SIMPLE:
+ $this->startElementModeSimple( $elm, $attribs );
+ break;
+ case self::MODE_INITIAL:
+ $this->startElementModeInitial( $ns, $tag, $attribs );
+ break;
+ case self::MODE_STRUCT:
+ $this->startElementModeStruct( $ns, $tag, $attribs );
+ break;
+ case self::MODE_BAG:
+ case self::MODE_BAGSTRUCT:
+ $this->startElementModeBag( $elm );
+ break;
+ case self::MODE_SEQ:
+ $this->startElementModeSeq( $elm );
+ break;
+ case self::MODE_LANG:
+ $this->startElementModeLang( $elm );
+ break;
+ case self::MODE_LI_LANG:
+ $this->startElementModeLiLang( $elm, $attribs );
+ break;
+ case self::MODE_LI:
+ $this->startElementModeLi( $elm, $attribs );
+ break;
+ case self::MODE_QDESC:
+ $this->startElementModeQDesc( $elm );
+ break;
+ default:
+ throw new RuntimeException( 'StartElement in unknown mode: ' . $this->mode[0] );
+ }
+ }
+
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ /**
+ * Process attributes.
+ * Simple values can be stored as either a tag or attribute
+ *
+ * Often the initial "<rdf:Description>" tag just has all the simple
+ * properties as attributes.
+ *
+ * @par Example:
+ * @code
+ * <rdf:Description rdf:about="" xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:DigitalZoomRatio="0/10">
+ * @endcode
+ *
+ * @param array $attribs Array attribute=>value
+ * @throws RuntimeException
+ */
+ // @codingStandardsIgnoreEnd
+ private function doAttribs( $attribs ) {
+ // first check for rdf:parseType attribute, as that can change
+ // how the attributes are interperted.
+
+ if ( isset( $attribs[self::NS_RDF . ' parseType'] )
+ && $attribs[self::NS_RDF . ' parseType'] === 'Resource'
+ && $this->mode[0] === self::MODE_SIMPLE
+ ) {
+ // this is equivalent to having an inner rdf:Description
+ $this->mode[0] = self::MODE_QDESC;
+ }
+ foreach ( $attribs as $name => $val ) {
+ if ( strpos( $name, ' ' ) === false ) {
+ // This shouldn't happen, but so far some old software forgets namespace
+ // on rdf:about.
+ $this->logger->info( __METHOD__ . ' Encountered non-namespaced attribute: '
+ . " $name=\"$val\". Skipping. " );
+ continue;
+ }
+ list( $ns, $tag ) = explode( ' ', $name, 2 );
+ if ( $ns === self::NS_RDF ) {
+ if ( $tag === 'value' || $tag === 'resource' ) {
+ // resource is for url.
+ // value attribute is a weird way of just putting the contents.
+ $this->char( $this->xmlParser, $val );
+ }
+ } elseif ( isset( $this->items[$ns][$tag] ) ) {
+ if ( $this->mode[0] === self::MODE_SIMPLE ) {
+ throw new RuntimeException( __METHOD__
+ . " $ns:$tag found as attribute where not allowed" );
+ }
+ $this->saveValue( $ns, $tag, $val );
+ } else {
+ $this->logger->debug( __METHOD__ . " Ignoring unrecognized element <$ns:$tag>." );
+ }
+ }
+ }
+
+ /**
+ * Given an extracted value, save it to results array
+ *
+ * note also uses $this->ancestorStruct and
+ * $this->processingArray to determine what name to
+ * save the value under. (in addition to $tag).
+ *
+ * @param string $ns Namespace of tag this is for
+ * @param string $tag Tag name
+ * @param string $val Value to save
+ */
+ private function saveValue( $ns, $tag, $val ) {
+ $info =& $this->items[$ns][$tag];
+ $finalName = isset( $info['map_name'] )
+ ? $info['map_name'] : $tag;
+ if ( isset( $info['validate'] ) ) {
+ if ( is_array( $info['validate'] ) ) {
+ $validate = $info['validate'];
+ } else {
+ $validator = new XMPValidate( $this->logger );
+ $validate = [ $validator, $info['validate'] ];
+ }
+
+ if ( is_callable( $validate ) ) {
+ call_user_func_array( $validate, [ $info, &$val, true ] );
+ // the reasoning behind using &$val instead of using the return value
+ // is to be consistent between here and validating structures.
+ if ( is_null( $val ) ) {
+ $this->logger->info( __METHOD__ . " <$ns:$tag> failed validation." );
+
+ return;
+ }
+ } else {
+ $this->logger->warning( __METHOD__ . " Validation function for $finalName ("
+ . $validate[0] . '::' . $validate[1] . '()) is not callable.' );
+ }
+ }
+
+ if ( $this->ancestorStruct && $this->processingArray ) {
+ // Aka both an array and a struct. ( self::MODE_BAGSTRUCT )
+ $this->results['xmp-' . $info['map_group']][$this->ancestorStruct][][$finalName] = $val;
+ } elseif ( $this->ancestorStruct ) {
+ $this->results['xmp-' . $info['map_group']][$this->ancestorStruct][$finalName] = $val;
+ } elseif ( $this->processingArray ) {
+ if ( $this->itemLang === false ) {
+ // normal array
+ $this->results['xmp-' . $info['map_group']][$finalName][] = $val;
+ } else {
+ // lang array.
+ $this->results['xmp-' . $info['map_group']][$finalName][$this->itemLang] = $val;
+ }
+ } else {
+ $this->results['xmp-' . $info['map_group']][$finalName] = $val;
+ }
+ }
+}
diff --git a/www/wiki/includes/libs/xmp/XMPInfo.php b/www/wiki/includes/libs/xmp/XMPInfo.php
new file mode 100644
index 00000000..5211a2cd
--- /dev/null
+++ b/www/wiki/includes/libs/xmp/XMPInfo.php
@@ -0,0 +1,1168 @@
+<?php
+/**
+ * Definitions for XMPReader class.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * This class is just a container for a big array
+ * used by XMPReader to determine which XMP items to
+ * extract.
+ */
+class XMPInfo {
+ /** Get the items array
+ * @return array XMP item configuration array.
+ */
+ public static function getItems() {
+ return self::$items;
+ }
+
+ /**
+ * XMPInfo::$items keeps a list of all the items
+ * we are interested to extract, as well as
+ * information about the item like what type
+ * it is.
+ *
+ * Format is an array of namespaces,
+ * each containing an array of tags
+ * each tag is an array of information about the
+ * tag, including:
+ * * map_group - What group (used for precedence during conflicts).
+ * * mode - What type of item (self::MODE_SIMPLE usually, see above for
+ * all values).
+ * * validate - Method to validate input. Could also post-process the
+ * input. A string value is assumed to be a method of
+ * XMPValidate. Can also take a array( 'className', 'methodName' ).
+ * * choices - Array of potential values (format of 'value' => true ).
+ * Only used with validateClosed.
+ * * rangeLow and rangeHigh - Alternative to choices for numeric ranges.
+ * Again for validateClosed only.
+ * * children - For MODE_STRUCT items, allowed children.
+ * * structPart - Indicates that this element can only appear as a member
+ * of a structure.
+ *
+ * Currently this just has a bunch of EXIF values as this class is only half-done.
+ */
+ static private $items = [
+ 'http://ns.adobe.com/exif/1.0/' => [
+ 'ApertureValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'BrightnessValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'CompressedBitsPerPixel' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'DigitalZoomRatio' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'ExposureBiasValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'ExposureIndex' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'ExposureTime' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'FlashEnergy' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational',
+ ],
+ 'FNumber' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'FocalLength' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'FocalPlaneXResolution' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'FocalPlaneYResolution' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSAltitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational',
+ ],
+ 'GPSDestBearing' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSDestDistance' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSDOP' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSImgDirection' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSSpeed' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSTrack' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'MaxApertureValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'ShutterSpeedValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'SubjectDistance' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ /* Flash */
+ 'Flash' => [
+ 'mode' => XMPReader::MODE_STRUCT,
+ 'children' => [
+ 'Fired' => true,
+ 'Function' => true,
+ 'Mode' => true,
+ 'RedEyeMode' => true,
+ 'Return' => true,
+ ],
+ 'validate' => 'validateFlash',
+ 'map_group' => 'exif',
+ ],
+ 'Fired' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateBoolean',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'Function' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateBoolean',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'Mode' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateClosed',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'choices' => [ '0' => true, '1' => true,
+ '2' => true, '3' => true ],
+ 'structPart' => true,
+ ],
+ 'Return' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateClosed',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'choices' => [ '0' => true,
+ '2' => true, '3' => true ],
+ 'structPart' => true,
+ ],
+ 'RedEyeMode' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateBoolean',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ /* End Flash */
+ 'ISOSpeedRatings' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateInteger'
+ ],
+ /* end rational things */
+ 'ColorSpace' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '65535' => true ],
+ ],
+ 'ComponentsConfiguration' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '2' => true, '3' => true, '4' => true,
+ '5' => true, '6' => true ]
+ ],
+ 'Contrast' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true, '2' => true ]
+ ],
+ 'CustomRendered' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true ]
+ ],
+ 'DateTimeOriginal' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ 'DateTimeDigitized' => [ /* xmp:CreateDate */
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ /* todo: there might be interesting information in
+ * exif:DeviceSettingDescription, but need to find an
+ * example
+ */
+ 'ExifVersion' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'ExposureMode' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 2,
+ ],
+ 'ExposureProgram' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 8,
+ ],
+ 'FileSource' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '3' => true ]
+ ],
+ 'FlashpixVersion' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'FocalLengthIn35mmFilm' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'FocalPlaneResolutionUnit' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '2' => true, '3' => true ],
+ ],
+ 'GainControl' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 4,
+ ],
+ /* this value is post-processed out later */
+ 'GPSAltitudeRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true ],
+ ],
+ 'GPSAreaInformation' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'GPSDestBearingRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'T' => true, 'M' => true ],
+ ],
+ 'GPSDestDistanceRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'K' => true, 'M' => true,
+ 'N' => true ],
+ ],
+ 'GPSDestLatitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateGPS',
+ ],
+ 'GPSDestLongitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateGPS',
+ ],
+ 'GPSDifferential' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true ],
+ ],
+ 'GPSImgDirectionRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'T' => true, 'M' => true ],
+ ],
+ 'GPSLatitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateGPS',
+ ],
+ 'GPSLongitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateGPS',
+ ],
+ 'GPSMapDatum' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'GPSMeasureMode' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '2' => true, '3' => true ]
+ ],
+ 'GPSProcessingMethod' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'GPSSatellites' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'GPSSpeedRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'K' => true, 'M' => true,
+ 'N' => true ],
+ ],
+ 'GPSStatus' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'A' => true, 'V' => true ]
+ ],
+ 'GPSTimeStamp' => [
+ 'map_group' => 'exif',
+ // Note: in exif, GPSDateStamp does not include
+ // the time, where here it does.
+ 'map_name' => 'GPSDateStamp',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ 'GPSTrackRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'T' => true, 'M' => true ]
+ ],
+ 'GPSVersionID' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'ImageUniqueID' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'LightSource' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ /* can't use a range, as it skips... */
+ 'choices' => [ '0' => true, '1' => true,
+ '2' => true, '3' => true, '4' => true,
+ '9' => true, '10' => true, '11' => true,
+ '12' => true, '13' => true,
+ '14' => true, '15' => true,
+ '17' => true, '18' => true,
+ '19' => true, '20' => true,
+ '21' => true, '22' => true,
+ '23' => true, '24' => true,
+ '255' => true,
+ ],
+ ],
+ 'MeteringMode' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 6,
+ 'choices' => [ '255' => true ],
+ ],
+ /* Pixel(X|Y)Dimension are rather useless, but for
+ * completeness since we do it with exif.
+ */
+ 'PixelXDimension' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'PixelYDimension' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'Saturation' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 2,
+ ],
+ 'SceneCaptureType' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 3,
+ ],
+ 'SceneType' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true ],
+ ],
+ // Note, 6 is not valid SensingMethod.
+ 'SensingMethod' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 1,
+ 'rangeHigh' => 5,
+ 'choices' => [ '7' => true, 8 => true ],
+ ],
+ 'Sharpness' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 2,
+ ],
+ 'SpectralSensitivity' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ // This tag should perhaps be displayed to user better.
+ 'SubjectArea' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateInteger',
+ ],
+ 'SubjectDistanceRange' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 3,
+ ],
+ 'SubjectLocation' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateInteger',
+ ],
+ 'UserComment' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ 'WhiteBalance' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true ]
+ ],
+ ],
+ 'http://ns.adobe.com/tiff/1.0/' => [
+ 'Artist' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'BitsPerSample' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateInteger',
+ ],
+ 'Compression' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '6' => true ],
+ ],
+ /* this prop should not be used in XMP. dc:rights is the correct prop */
+ 'Copyright' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ 'DateTime' => [ /* proper prop is xmp:ModifyDate */
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ 'ImageDescription' => [ /* proper one is dc:description */
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ 'ImageLength' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'ImageWidth' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'Make' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Model' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ /**** Do not extract this property
+ * It interferes with auto exif rotation.
+ * 'Orientation' => array(
+ * 'map_group' => 'exif',
+ * 'mode' => XMPReader::MODE_SIMPLE,
+ * 'validate' => 'validateClosed',
+ * 'choices' => array( '1' => true, '2' => true, '3' => true, '4' => true, 5 => true,
+ * '6' => true, '7' => true, '8' => true ),
+ *),
+ ******/
+ 'PhotometricInterpretation' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '2' => true, '6' => true ],
+ ],
+ 'PlanerConfiguration' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '2' => true ],
+ ],
+ 'PrimaryChromaticities' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateRational',
+ ],
+ 'ReferenceBlackWhite' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateRational',
+ ],
+ 'ResolutionUnit' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '2' => true, '3' => true ],
+ ],
+ 'SamplesPerPixel' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'Software' => [ /* see xmp:CreatorTool */
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ /* ignore TransferFunction */
+ 'WhitePoint' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateRational',
+ ],
+ 'XResolution' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational',
+ ],
+ 'YResolution' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational',
+ ],
+ 'YCbCrCoefficients' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateRational',
+ ],
+ 'YCbCrPositioning' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '2' => true ],
+ ],
+ /********
+ * Disable extracting this property (T33944)
+ * Several files have a string instead of a Seq
+ * for this property. XMPReader doesn't handle
+ * mismatched types very gracefully (it marks
+ * the entire file as invalid, instead of just
+ * the relavent prop). Since this prop
+ * doesn't communicate all that useful information
+ * just disable this prop for now, until such
+ * XMPReader is more graceful (T34172)
+ * 'YCbCrSubSampling' => array(
+ * 'map_group' => 'exif',
+ * 'mode' => XMPReader::MODE_SEQ,
+ * 'validate' => 'validateClosed',
+ * 'choices' => array( '1' => true, '2' => true ),
+ * ),
+ */
+ ],
+ 'http://ns.adobe.com/exif/1.0/aux/' => [
+ 'Lens' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'SerialNumber' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'OwnerName' => [
+ 'map_group' => 'exif',
+ 'map_name' => 'CameraOwnerName',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ ],
+ 'http://purl.org/dc/elements/1.1/' => [
+ 'title' => [
+ 'map_group' => 'general',
+ 'map_name' => 'ObjectName',
+ 'mode' => XMPReader::MODE_LANG
+ ],
+ 'description' => [
+ 'map_group' => 'general',
+ 'map_name' => 'ImageDescription',
+ 'mode' => XMPReader::MODE_LANG
+ ],
+ 'contributor' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-contributor',
+ 'mode' => XMPReader::MODE_BAG
+ ],
+ 'coverage' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-coverage',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'creator' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Artist', // map with exif Artist, iptc byline (2:80)
+ 'mode' => XMPReader::MODE_SEQ,
+ ],
+ 'date' => [
+ 'map_group' => 'general',
+ // Note, not mapped with other date properties, as this type of date is
+ // non-specific: "A point or period of time associated with an event in
+ // the lifecycle of the resource"
+ 'map_name' => 'dc-date',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateDate',
+ ],
+ /* Do not extract dc:format, as we've got better ways to determine MIME type */
+ 'identifier' => [
+ 'map_group' => 'deprecated',
+ 'map_name' => 'Identifier',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'language' => [
+ 'map_group' => 'general',
+ 'map_name' => 'LanguageCode', /* mapped with iptc 2:135 */
+ 'mode' => XMPReader::MODE_BAG,
+ 'validate' => 'validateLangCode',
+ ],
+ 'publisher' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-publisher',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ // for related images/resources
+ 'relation' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-relation',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ 'rights' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Copyright',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ // Note: source is not mapped with iptc source, since iptc
+ // source describes the source of the image in terms of a person
+ // who provided the image, where this is to describe an image that the
+ // current one is based on.
+ 'source' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-source',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'subject' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Keywords', /* maps to iptc 2:25 */
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ 'type' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-type',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ ],
+ 'http://ns.adobe.com/xap/1.0/' => [
+ 'CreateDate' => [
+ 'map_group' => 'general',
+ 'map_name' => 'DateTimeDigitized',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ 'CreatorTool' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Software',
+ 'mode' => XMPReader::MODE_SIMPLE
+ ],
+ 'Identifier' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ 'Label' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'ModifyDate' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'DateTime',
+ 'validate' => 'validateDate',
+ ],
+ 'MetadataDate' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ // map_name to be consistent with other date names.
+ 'map_name' => 'DateTimeMetadata',
+ 'validate' => 'validateDate',
+ ],
+ 'Nickname' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Rating' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRating',
+ ],
+ ],
+ 'http://ns.adobe.com/xap/1.0/rights/' => [
+ 'Certificate' => [
+ 'map_group' => 'general',
+ 'map_name' => 'RightsCertificate',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Marked' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Copyrighted',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateBoolean',
+ ],
+ 'Owner' => [
+ 'map_group' => 'general',
+ 'map_name' => 'CopyrightOwner',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ // this seems similar to dc:rights.
+ 'UsageTerms' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ 'WebStatement' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ ],
+ // XMP media management.
+ 'http://ns.adobe.com/xap/1.0/mm/' => [
+ // if we extract the exif UniqueImageID, might
+ // as well do this too.
+ 'OriginalDocumentID' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ // It might also be useful to do xmpMM:LastURL
+ // and xmpMM:DerivedFrom as you can potentially,
+ // get the url of this document/source for this
+ // document. However whats more likely is you'd
+ // get a file:// url for the path of the doc,
+ // which is somewhat of a privacy issue.
+ ],
+ 'http://creativecommons.org/ns#' => [
+ 'license' => [
+ 'map_name' => 'LicenseUrl',
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'morePermissions' => [
+ 'map_name' => 'MorePermissionsUrl',
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'attributionURL' => [
+ 'map_group' => 'general',
+ 'map_name' => 'AttributionUrl',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'attributionName' => [
+ 'map_group' => 'general',
+ 'map_name' => 'PreferredAttributionName',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ ],
+ // Note, this property affects how jpeg metadata is extracted.
+ 'http://ns.adobe.com/xmp/note/' => [
+ 'HasExtendedXMP' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ ],
+ /* Note, in iptc schemas, the legacy properties are denoted
+ * as deprecated, since other properties should used instead,
+ * and properties marked as deprecated in the standard are
+ * are marked as general here as they don't have replacements
+ */
+ 'http://ns.adobe.com/photoshop/1.0/' => [
+ 'City' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'CityDest',
+ ],
+ 'Country' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'CountryDest',
+ ],
+ 'State' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'ProvinceOrStateDest',
+ ],
+ 'DateCreated' => [
+ 'map_group' => 'deprecated',
+ // marking as deprecated as the xmp prop preferred
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'DateTimeOriginal',
+ 'validate' => 'validateDate',
+ // note this prop is an XMP, not IPTC date
+ ],
+ 'CaptionWriter' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'Writer',
+ ],
+ 'Instructions' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'SpecialInstructions',
+ ],
+ 'TransmissionReference' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'OriginalTransmissionRef',
+ ],
+ 'AuthorsPosition' => [
+ /* This corresponds with 2:85
+ * By-line Title, which needs to be
+ * handled weirdly to correspond
+ * with iptc/exif. */
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE
+ ],
+ 'Credit' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Source' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Urgency' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Category' => [
+ // Note, this prop is deprecated, but in general
+ // group since it doesn't have a replacement.
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'iimCategory',
+ ],
+ 'SupplementalCategories' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ 'map_name' => 'iimSupplementalCategory',
+ ],
+ 'Headline' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE
+ ],
+ ],
+ 'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/' => [
+ 'CountryCode' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'CountryCodeDest',
+ ],
+ 'IntellectualGenre' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ // Note, this is a six digit code.
+ // See: http://cv.iptc.org/newscodes/scene/
+ // Since these aren't really all that common,
+ // we just show the number.
+ 'Scene' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ 'validate' => 'validateInteger',
+ 'map_name' => 'SceneCode',
+ ],
+ /* Note: SubjectCode should be an 8 ascii digits.
+ * it is not really an integer (has leading 0's,
+ * cannot have a +/- sign), but validateInteger
+ * will let it through.
+ */
+ 'SubjectCode' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ 'map_name' => 'SubjectNewsCode',
+ 'validate' => 'validateInteger'
+ ],
+ 'Location' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'SublocationDest',
+ ],
+ 'CreatorContactInfo' => [
+ /* Note this maps to 2:118 in iim
+ * (Contact) field. However those field
+ * types are slightly different - 2:118
+ * is free form text field, where this
+ * is more structured.
+ */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_STRUCT,
+ 'map_name' => 'Contact',
+ 'children' => [
+ 'CiAdrExtadr' => true,
+ 'CiAdrCity' => true,
+ 'CiAdrCtry' => true,
+ 'CiEmailWork' => true,
+ 'CiTelWork' => true,
+ 'CiAdrPcode' => true,
+ 'CiAdrRegion' => true,
+ 'CiUrlWork' => true,
+ ],
+ ],
+ 'CiAdrExtadr' => [ /* address */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiAdrCity' => [ /* city */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiAdrCtry' => [ /* country */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiEmailWork' => [ /* email (possibly separated by ',') */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiTelWork' => [ /* telephone */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiAdrPcode' => [ /* postal code */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiAdrRegion' => [ /* province/state */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiUrlWork' => [ /* url. Multiple may be separated by comma. */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ /* End contact info struct properties */
+ ],
+ 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/' => [
+ 'Event' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'OrganisationInImageName' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ 'map_name' => 'OrganisationInImage'
+ ],
+ 'PersonInImage' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ 'MaxAvailHeight' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ 'map_name' => 'OriginalImageHeight',
+ ],
+ 'MaxAvailWidth' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ 'map_name' => 'OriginalImageWidth',
+ ],
+ // LocationShown and LocationCreated are handled
+ // specially because they are hierarchical, but we
+ // also want to merge with the old non-hierarchical.
+ 'LocationShown' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_BAGSTRUCT,
+ 'children' => [
+ 'WorldRegion' => true,
+ 'CountryCode' => true, /* iso code */
+ 'CountryName' => true,
+ 'ProvinceState' => true,
+ 'City' => true,
+ 'Sublocation' => true,
+ ],
+ ],
+ 'LocationCreated' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_BAGSTRUCT,
+ 'children' => [
+ 'WorldRegion' => true,
+ 'CountryCode' => true, /* iso code */
+ 'CountryName' => true,
+ 'ProvinceState' => true,
+ 'City' => true,
+ 'Sublocation' => true,
+ ],
+ ],
+ 'WorldRegion' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CountryCode' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CountryName' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ 'map_name' => 'Country',
+ ],
+ 'ProvinceState' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ 'map_name' => 'ProvinceOrState',
+ ],
+ 'City' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'Sublocation' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+
+ /* Other props that might be interesting but
+ * Not currently extracted:
+ * ArtworkOrObject, (info about objects in picture)
+ * DigitalSourceType
+ * RegistryId
+ */
+ ],
+
+ /* Plus props we might want to consider:
+ * (Note: some of these have unclear/incomplete definitions
+ * from the iptc4xmp standard).
+ * ImageSupplier (kind of like iptc source field)
+ * ImageSupplierId (id code for image from supplier)
+ * CopyrightOwner
+ * ImageCreator
+ * Licensor
+ * Various model release fields
+ * Property release fields.
+ */
+ ];
+}
diff --git a/www/wiki/includes/libs/xmp/XMPValidate.php b/www/wiki/includes/libs/xmp/XMPValidate.php
new file mode 100644
index 00000000..76ae279f
--- /dev/null
+++ b/www/wiki/includes/libs/xmp/XMPValidate.php
@@ -0,0 +1,401 @@
+<?php
+/**
+ * Methods for validating XMP properties.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerAwareInterface;
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+
+/**
+ * This contains some static methods for
+ * validating XMP properties. See XMPInfo and XMPReader classes.
+ *
+ * Each of these functions take the same parameters
+ * * an info array which is a subset of the XMPInfo::items array
+ * * A value (passed as reference) to validate. This can be either a
+ * simple value or an array
+ * * A boolean to determine if this is validating a simple or complex values
+ *
+ * It should be noted that when an array is being validated, typically the validation
+ * function is called once for each value, and then once at the end for the entire array.
+ *
+ * These validation functions can also be used to modify the data. See the gps and flash one's
+ * for example.
+ *
+ * @see http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart1.pdf starting at pg 28
+ * @see http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart2.pdf starting at pg 11
+ */
+class XMPValidate implements LoggerAwareInterface {
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ public function __construct( LoggerInterface $logger ) {
+ $this->setLogger( $logger );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+ /**
+ * Function to validate boolean properties ( True or False )
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateBoolean( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( $val !== 'True' && $val !== 'False' ) {
+ $this->logger->info( __METHOD__ . " Expected True or False but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate rational properties ( 12/10 )
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateRational( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( !preg_match( '/^(?:-?\d+)\/(?:\d+[1-9]|[1-9]\d*)$/D', $val ) ) {
+ $this->logger->info( __METHOD__ . " Expected rational but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate rating properties -1, 0-5
+ *
+ * if its outside of range put it into range.
+ *
+ * @see MWG spec
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateRating( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( !preg_match( '/^[-+]?\d*(?:\.?\d*)$/D', $val )
+ || !is_numeric( $val )
+ ) {
+ $this->logger->info( __METHOD__ . " Expected rating but got $val" );
+ $val = null;
+
+ return;
+ } else {
+ $nVal = (float)$val;
+ if ( $nVal < 0 ) {
+ // We do < 0 here instead of < -1 here, since
+ // the values between 0 and -1 are also illegal
+ // as -1 is meant as a special reject rating.
+ $this->logger->info( __METHOD__ . " Rating too low, setting to -1 (Rejected)" );
+ $val = '-1';
+
+ return;
+ }
+ if ( $nVal > 5 ) {
+ $this->logger->info( __METHOD__ . " Rating too high, setting to 5" );
+ $val = '5';
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * function to validate integers
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateInteger( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( !preg_match( '/^[-+]?\d+$/D', $val ) ) {
+ $this->logger->info( __METHOD__ . " Expected integer but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate properties with a fixed number of allowed
+ * choices. (closed choice)
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateClosed( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+
+ // check if its in a numeric range
+ $inRange = false;
+ if ( isset( $info['rangeLow'] )
+ && isset( $info['rangeHigh'] )
+ && is_numeric( $val )
+ && ( intval( $val ) <= $info['rangeHigh'] )
+ && ( intval( $val ) >= $info['rangeLow'] )
+ ) {
+ $inRange = true;
+ }
+
+ if ( !isset( $info['choices'][$val] ) && !$inRange ) {
+ $this->logger->info( __METHOD__ . " Expected closed choice, but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate and modify flash structure
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateFlash( $info, &$val, $standalone ) {
+ if ( $standalone ) {
+ // this only validates flash structs, not individual properties
+ return;
+ }
+ if ( !( isset( $val['Fired'] )
+ && isset( $val['Function'] )
+ && isset( $val['Mode'] )
+ && isset( $val['RedEyeMode'] )
+ && isset( $val['Return'] )
+ ) ) {
+ $this->logger->info( __METHOD__ . " Flash structure did not have all the required components" );
+ $val = null;
+ } else {
+ $val = ( "\0" | ( $val['Fired'] === 'True' )
+ | ( intval( $val['Return'] ) << 1 )
+ | ( intval( $val['Mode'] ) << 3 )
+ | ( ( $val['Function'] === 'True' ) << 5 )
+ | ( ( $val['RedEyeMode'] === 'True' ) << 6 ) );
+ }
+ }
+
+ /**
+ * function to validate LangCode properties ( en-GB, etc )
+ *
+ * This is just a naive check to make sure it somewhat looks like a lang code.
+ *
+ * @see BCP 47
+ * @see https://wwwimages2.adobe.com/content/dam/Adobe/en/devnet/xmp/pdfs/
+ * XMP%20SDK%20Release%20cc-2014-12/XMPSpecificationPart1.pdf page 22 (section 8.2.2.4)
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateLangCode( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( !preg_match( '/^[-A-Za-z0-9]{2,}$/D', $val ) ) {
+ // this is a rather naive check.
+ $this->logger->info( __METHOD__ . " Expected Lang code but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate date properties, and convert to (partial) Exif format.
+ *
+ * Dates can be one of the following formats:
+ * YYYY
+ * YYYY-MM
+ * YYYY-MM-DD
+ * YYYY-MM-DDThh:mmTZD
+ * YYYY-MM-DDThh:mm:ssTZD
+ * YYYY-MM-DDThh:mm:ss.sTZD
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate. Converts to TS_EXIF as a side-effect.
+ * in cases where there's only a partial date, it will give things like
+ * 2011:04.
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateDate( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ $res = [];
+ // @codingStandardsIgnoreStart Long line that cannot be broken
+ if ( !preg_match(
+ /* ahh! scary regex... */
+ '/^([0-3]\d{3})(?:-([01]\d)(?:-([0-3]\d)(?:T([0-2]\d):([0-6]\d)(?::([0-6]\d)(?:\.\d+)?)?([-+]\d{2}:\d{2}|Z)?)?)?)?$/D',
+ $val, $res )
+ ) {
+ // @codingStandardsIgnoreEnd
+
+ $this->logger->info( __METHOD__ . " Expected date but got $val" );
+ $val = null;
+ } else {
+ /*
+ * $res is formatted as follows:
+ * 0 -> full date.
+ * 1 -> year, 2-> month, 3-> day, 4-> hour, 5-> minute, 6->second
+ * 7-> Timezone specifier (Z or something like +12:30 )
+ * many parts are optional, some aren't. For example if you specify
+ * minute, you must specify hour, day, month, and year but not second or TZ.
+ */
+
+ /*
+ * First of all, if year = 0000, Something is wrongish,
+ * so don't extract. This seems to happen when
+ * some programs convert between metadata formats.
+ */
+ if ( $res[1] === '0000' ) {
+ $this->logger->info( __METHOD__ . " Invalid date (year 0): $val" );
+ $val = null;
+
+ return;
+ }
+
+ if ( !isset( $res[4] ) ) { // hour
+ // just have the year month day (if that)
+ $val = $res[1];
+ if ( isset( $res[2] ) ) {
+ $val .= ':' . $res[2];
+ }
+ if ( isset( $res[3] ) ) {
+ $val .= ':' . $res[3];
+ }
+
+ return;
+ }
+
+ if ( !isset( $res[7] ) || $res[7] === 'Z' ) {
+ // if hour is set, then minute must also be or regex above will fail.
+ $val = $res[1] . ':' . $res[2] . ':' . $res[3]
+ . ' ' . $res[4] . ':' . $res[5];
+ if ( isset( $res[6] ) && $res[6] !== '' ) {
+ $val .= ':' . $res[6];
+ }
+
+ return;
+ }
+
+ // Extra check for empty string necessary due to TZ but no second case.
+ $stripSeconds = false;
+ if ( !isset( $res[6] ) || $res[6] === '' ) {
+ $res[6] = '00';
+ $stripSeconds = true;
+ }
+
+ // Do timezone processing. We've already done the case that tz = Z.
+
+ // We know that if we got to this step, year, month day hour and min must be set
+ // by virtue of regex not failing.
+
+ $unix = ConvertibleTimestamp::convert( TS_UNIX,
+ $res[1] . $res[2] . $res[3] . $res[4] . $res[5] . $res[6]
+ );
+ $offset = intval( substr( $res[7], 1, 2 ) ) * 60 * 60;
+ $offset += intval( substr( $res[7], 4, 2 ) ) * 60;
+ if ( substr( $res[7], 0, 1 ) === '-' ) {
+ $offset = -$offset;
+ }
+ $val = ConvertibleTimestamp::convert( TS_EXIF, $unix + $offset );
+
+ if ( $stripSeconds ) {
+ // If seconds weren't specified, remove the trailing ':00'.
+ $val = substr( $val, 0, -3 );
+ }
+ }
+ }
+
+ /** function to validate, and more importantly
+ * translate the XMP DMS form of gps coords to
+ * the decimal form we use.
+ *
+ * @see http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart2.pdf
+ * section 1.2.7.4 on page 23
+ *
+ * @param array $info Unused (info about prop)
+ * @param string &$val GPS string in either DDD,MM,SSk or
+ * or DDD,MM.mmk form
+ * @param bool $standalone If its a simple prop (should always be true)
+ */
+ public function validateGPS( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ return;
+ }
+
+ $m = [];
+ if ( preg_match(
+ '/(\d{1,3}),(\d{1,2}),(\d{1,2})([NWSE])/D',
+ $val, $m )
+ ) {
+ $coord = intval( $m[1] );
+ $coord += intval( $m[2] ) * ( 1 / 60 );
+ $coord += intval( $m[3] ) * ( 1 / 3600 );
+ if ( $m[4] === 'S' || $m[4] === 'W' ) {
+ $coord = -$coord;
+ }
+ $val = $coord;
+
+ return;
+ } elseif ( preg_match(
+ '/(\d{1,3}),(\d{1,2}(?:.\d*)?)([NWSE])/D',
+ $val, $m )
+ ) {
+ $coord = intval( $m[1] );
+ $coord += floatval( $m[2] ) * ( 1 / 60 );
+ if ( $m[3] === 'S' || $m[3] === 'W' ) {
+ $coord = -$coord;
+ }
+ $val = $coord;
+
+ return;
+ } else {
+ $this->logger->info( __METHOD__
+ . " Expected GPSCoordinate, but got $val." );
+ $val = null;
+
+ return;
+ }
+ }
+}
diff --git a/www/wiki/includes/limit.sh b/www/wiki/includes/limit.sh
new file mode 100755
index 00000000..d71e6603
--- /dev/null
+++ b/www/wiki/includes/limit.sh
@@ -0,0 +1,122 @@
+#!/bin/bash
+#
+# Resource limiting wrapper for command execution
+#
+# Why is this in shell script? Because bash has a setrlimit() wrapper
+# and is available on most Linux systems. If Perl was distributed with
+# BSD::Resource included, we would happily use that instead, but it isn't.
+
+# Clean up cgroup
+cleanup() {
+ # First we have to move the current task into a "garbage" group, otherwise
+ # the cgroup will not be empty, and attempting to remove it will fail with
+ # "Device or resource busy"
+ if [ -w "$MW_CGROUP"/tasks ]; then
+ GARBAGE="$MW_CGROUP"
+ else
+ GARBAGE="$MW_CGROUP"/garbage-`id -un`
+ if [ ! -e "$GARBAGE" ]; then
+ mkdir -m 0700 "$GARBAGE"
+ fi
+ fi
+ echo $BASHPID > "$GARBAGE"/tasks
+
+ # Suppress errors in case the cgroup has disappeared due to a release script
+ rmdir "$MW_CGROUP"/$$ 2>/dev/null
+}
+
+updateTaskCount() {
+ # There are lots of ways to count lines in a file in shell script, but this
+ # is one of the few that doesn't create another process, which would
+ # increase the returned number of tasks.
+ readarray < "$MW_CGROUP"/$$/tasks
+ NUM_TASKS=${#MAPFILE[*]}
+}
+
+log() {
+ echo limit.sh: "$*" >&3
+ echo limit.sh: "$*" >&2
+}
+
+MW_INCLUDE_STDERR=
+MW_USE_LOG_PIPE=
+MW_CPU_LIMIT=0
+MW_CGROUP=
+MW_MEM_LIMIT=0
+MW_FILE_SIZE_LIMIT=0
+MW_WALL_CLOCK_LIMIT=0
+
+# Override settings
+eval "$2"
+
+if [ -n "$MW_INCLUDE_STDERR" ]; then
+ exec 2>&1
+fi
+if [ -z "$MW_USE_LOG_PIPE" ]; then
+ # Open a dummy log FD
+ exec 3>/dev/null
+fi
+
+if [ "$MW_CPU_LIMIT" -gt 0 ]; then
+ ulimit -t "$MW_CPU_LIMIT"
+fi
+if [ "$MW_MEM_LIMIT" -gt 0 ]; then
+ if [ -n "$MW_CGROUP" ]; then
+ # Create cgroup
+ if ! mkdir -m 0700 "$MW_CGROUP"/$$; then
+ log "failed to create the cgroup."
+ MW_CGROUP=""
+ fi
+ fi
+ if [ -n "$MW_CGROUP" ]; then
+ echo $$ > "$MW_CGROUP"/$$/tasks
+ if [ -n "$MW_CGROUP_NOTIFY" ]; then
+ echo "1" > "$MW_CGROUP"/$$/notify_on_release
+ fi
+ # Memory
+ echo $(($MW_MEM_LIMIT*1024)) > "$MW_CGROUP"/$$/memory.limit_in_bytes
+ # Memory+swap
+ # This will be missing if there is no swap
+ if [ -e "$MW_CGROUP"/$$/memory.memsw.limit_in_bytes ]; then
+ echo $(($MW_MEM_LIMIT*1024)) > "$MW_CGROUP"/$$/memory.memsw.limit_in_bytes
+ fi
+ else
+ ulimit -v "$MW_MEM_LIMIT"
+ fi
+else
+ MW_CGROUP=""
+fi
+if [ "$MW_FILE_SIZE_LIMIT" -gt 0 ]; then
+ ulimit -f "$MW_FILE_SIZE_LIMIT"
+fi
+if [ "$MW_WALL_CLOCK_LIMIT" -gt 0 -a -x "/usr/bin/timeout" ]; then
+ /usr/bin/timeout $MW_WALL_CLOCK_LIMIT /bin/bash -c "$1" 3>&-
+ STATUS="$?"
+ if [ "$STATUS" == 124 ]; then
+ log "timed out executing command \"$1\""
+ fi
+else
+ eval "$1" 3>&-
+ STATUS="$?"
+fi
+
+if [ -n "$MW_CGROUP" ]; then
+ updateTaskCount
+
+ if [ $NUM_TASKS -gt 1 ]; then
+ # Spawn a monitor process which will continue to poll for completion
+ # of all processes in the cgroup after termination of the parent shell
+ (
+ while [ $NUM_TASKS -gt 1 ]; do
+ sleep 10
+ updateTaskCount
+ done
+ cleanup
+ ) >&/dev/null < /dev/null 3>&- &
+ disown -a
+ else
+ cleanup
+ fi
+fi
+exit "$STATUS"
+
diff --git a/www/wiki/includes/linkeddata/PageDataRequestHandler.php b/www/wiki/includes/linkeddata/PageDataRequestHandler.php
new file mode 100644
index 00000000..43cb44c8
--- /dev/null
+++ b/www/wiki/includes/linkeddata/PageDataRequestHandler.php
@@ -0,0 +1,172 @@
+<?php
+
+use Wikimedia\Http\HttpAcceptParser;
+use Wikimedia\Http\HttpAcceptNegotiator;
+
+/**
+ * Request handler implementing a data interface for mediawiki pages.
+ *
+ * @license GPL-2.0+
+ * @author Daniel Kinzler
+ * @author Amir Sarabadanai
+ */
+
+class PageDataRequestHandler {
+
+ /**
+ * Checks whether the request is complete, i.e. whether it contains all information needed
+ * to reply with page data.
+ *
+ * This does not check whether the request is valid and will actually produce a successful
+ * response.
+ *
+ * @param string|null $subPage
+ * @param WebRequest $request
+ *
+ * @return bool
+ * @throws HttpError
+ */
+ public function canHandleRequest( $subPage, WebRequest $request ) {
+ if ( $subPage === '' || $subPage === null ) {
+ if ( $request->getText( 'target', '' ) === '' ) {
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ $parts = explode( '/', $subPage, 2 );
+ if ( $parts !== 2 ) {
+ $slot = $parts[0];
+ if ( $slot === 'main' || $slot === '' ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Main method for handling requests.
+ *
+ * @param string $subPage
+ * @param WebRequest $request The request parameters. Known parameters are:
+ * - title: the page title
+ * - format: the format
+ * - oldid|revision: the revision ID
+ * @param OutputPage $output
+ *
+ * @note: Instead of an output page, a WebResponse could be sufficient, but
+ * redirect logic is currently implemented in OutputPage.
+ *
+ * @throws HttpError
+ */
+ public function handleRequest( $subPage, WebRequest $request, OutputPage $output ) {
+ // No matter what: The response is always public
+ $output->getRequest()->response()->header( 'Access-Control-Allow-Origin: *' );
+
+ if ( !$this->canHandleRequest( $subPage, $request ) ) {
+ throw new HttpError( 400, wfMessage( 'pagedata-bad-title', $subPage ) );
+ }
+
+ $revision = 0;
+
+ $parts = explode( '/', $subPage, 2 );
+ if ( $subPage !== '' ) {
+ $title = $parts[1];
+ } else {
+ $title = $request->getText( 'target', '' );
+ }
+
+ $revision = $request->getInt( 'oldid', $revision );
+ $revision = $request->getInt( 'revision', $revision );
+
+ if ( $title === null || $title === '' ) {
+ //TODO: different error message?
+ throw new HttpError( 400, wfMessage( 'pagedata-bad-title', $title ) );
+ }
+
+ try {
+ $title = Title::newFromTextThrow( $title );
+ } catch ( MalformedTitleException $ex ) {
+ throw new HttpError( 400, wfMessage( 'pagedata-bad-title', $title ) );
+ }
+
+ $this->httpContentNegotiation( $request, $output, $title, $revision );
+ }
+
+ /**
+ * Applies HTTP content negotiation.
+ * If the negotiation is successful, this method will set the appropriate redirect
+ * in the OutputPage object and return. Otherwise, an HttpError is thrown.
+ *
+ * @param WebRequest $request
+ * @param OutputPage $output
+ * @param Title $title
+ * @param int $revision The desired revision
+ *
+ * @throws HttpError
+ */
+ public function httpContentNegotiation(
+ WebRequest $request,
+ OutputPage $output,
+ Title $title,
+ $revision = 0
+ ) {
+ $contentHandler = ContentHandler::getForTitle( $title );
+ $mimeTypes = $contentHandler->getSupportedFormats();
+
+ $headers = $request->getAllHeaders();
+ if ( isset( $headers['ACCEPT'] ) ) {
+ $parser = new HttpAcceptParser();
+ $accept = $parser->parseWeights( $headers['ACCEPT'] );
+ } else {
+ // anything goes
+ $accept = [
+ '*' => 0.1 // just to make extra sure
+ ];
+ // prefer the default
+ $accept[$mimeTypes[0]] = 1;
+ }
+
+ $negotiator = new HttpAcceptNegotiator( $mimeTypes );
+ $format = $negotiator->getBestSupportedKey( $accept, null );
+
+ if ( $format === null ) {
+ $format = isset( $accept['text/html'] ) ? 'text/html' : null;
+ }
+
+ if ( $format === null ) {
+ $msg = wfMessage( 'pagedata-not-acceptable', implode( ', ', $mimeTypes ) );
+ throw new HttpError( 406, $msg );
+ }
+
+ $url = $this->getDocUrl( $title, $format, $revision );
+ $output->redirect( $url, 303 );
+ }
+
+ /**
+ * Returns a url representing the given title.
+ *
+ * @param Title $title
+ * @param string|null $format The (normalized) format name, or ''
+ * @param int $revision
+ * @return string
+ */
+ private function getDocUrl( Title $title, $format = '', $revision = 0 ) {
+ $params = [];
+
+ if ( $revision > 0 ) {
+ $params['oldid'] = $revision;
+ }
+
+ if ( $format === 'text/html' ) {
+ return $title->getFullURL( $params );
+ }
+
+ $params[ 'action' ] = 'raw';
+
+ return $title->getFullURL( $params );
+ }
+
+}
diff --git a/www/wiki/includes/linker/LinkRenderer.php b/www/wiki/includes/linker/LinkRenderer.php
new file mode 100644
index 00000000..c203a16b
--- /dev/null
+++ b/www/wiki/includes/linker/LinkRenderer.php
@@ -0,0 +1,481 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL-2.0+
+ * @author Kunal Mehta <legoktm@member.fsf.org>
+ */
+namespace MediaWiki\Linker;
+
+use DummyLinker;
+use Hooks;
+use Html;
+use HtmlArmor;
+use LinkCache;
+use Linker;
+use MediaWiki\MediaWikiServices;
+use MWNamespace;
+use Sanitizer;
+use Title;
+use TitleFormatter;
+
+/**
+ * Class that generates HTML <a> links for pages.
+ *
+ * @see https://www.mediawiki.org/wiki/Manual:LinkRenderer
+ * @since 1.28
+ */
+class LinkRenderer {
+
+ /**
+ * Whether to force the pretty article path
+ *
+ * @var bool
+ */
+ private $forceArticlePath = false;
+
+ /**
+ * A PROTO_* constant or false
+ *
+ * @var string|bool|int
+ */
+ private $expandUrls = false;
+
+ /**
+ * @var int
+ */
+ private $stubThreshold = 0;
+
+ /**
+ * @var TitleFormatter
+ */
+ private $titleFormatter;
+
+ /**
+ * @var LinkCache
+ */
+ private $linkCache;
+
+ /**
+ * Whether to run the legacy Linker hooks
+ *
+ * @var bool
+ */
+ private $runLegacyBeginHook = true;
+
+ /**
+ * @param TitleFormatter $titleFormatter
+ * @param LinkCache $linkCache
+ */
+ public function __construct( TitleFormatter $titleFormatter, LinkCache $linkCache ) {
+ $this->titleFormatter = $titleFormatter;
+ $this->linkCache = $linkCache;
+ }
+
+ /**
+ * @param bool $force
+ */
+ public function setForceArticlePath( $force ) {
+ $this->forceArticlePath = $force;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getForceArticlePath() {
+ return $this->forceArticlePath;
+ }
+
+ /**
+ * @param string|bool|int $expand A PROTO_* constant or false
+ */
+ public function setExpandURLs( $expand ) {
+ $this->expandUrls = $expand;
+ }
+
+ /**
+ * @return string|bool|int a PROTO_* constant or false
+ */
+ public function getExpandURLs() {
+ return $this->expandUrls;
+ }
+
+ /**
+ * @param int $threshold
+ */
+ public function setStubThreshold( $threshold ) {
+ $this->stubThreshold = $threshold;
+ }
+
+ /**
+ * @return int
+ */
+ public function getStubThreshold() {
+ return $this->stubThreshold;
+ }
+
+ /**
+ * @param bool $run
+ */
+ public function setRunLegacyBeginHook( $run ) {
+ $this->runLegacyBeginHook = $run;
+ }
+
+ /**
+ * @param LinkTarget $target
+ * @param string|HtmlArmor|null $text
+ * @param array $extraAttribs
+ * @param array $query
+ * @return string
+ */
+ public function makeLink(
+ LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
+ ) {
+ $title = Title::newFromLinkTarget( $target );
+ if ( $title->isKnown() ) {
+ return $this->makeKnownLink( $target, $text, $extraAttribs, $query );
+ } else {
+ return $this->makeBrokenLink( $target, $text, $extraAttribs, $query );
+ }
+ }
+
+ /**
+ * Get the options in the legacy format
+ *
+ * @param bool $isKnown Whether the link is known or broken
+ * @return array
+ */
+ private function getLegacyOptions( $isKnown ) {
+ $options = [ 'stubThreshold' => $this->stubThreshold ];
+ if ( $this->forceArticlePath ) {
+ $options[] = 'forcearticlepath';
+ }
+ if ( $this->expandUrls === PROTO_HTTP ) {
+ $options[] = 'http';
+ } elseif ( $this->expandUrls === PROTO_HTTPS ) {
+ $options[] = 'https';
+ }
+
+ $options[] = $isKnown ? 'known' : 'broken';
+
+ return $options;
+ }
+
+ private function runBeginHook( LinkTarget $target, &$text, &$extraAttribs, &$query, $isKnown ) {
+ $ret = null;
+ if ( !Hooks::run( 'HtmlPageLinkRendererBegin',
+ [ $this, $target, &$text, &$extraAttribs, &$query, &$ret ] )
+ ) {
+ return $ret;
+ }
+
+ // Now run the legacy hook
+ return $this->runLegacyBeginHook( $target, $text, $extraAttribs, $query, $isKnown );
+ }
+
+ private function runLegacyBeginHook( LinkTarget $target, &$text, &$extraAttribs, &$query,
+ $isKnown
+ ) {
+ if ( !$this->runLegacyBeginHook || !Hooks::isRegistered( 'LinkBegin' ) ) {
+ // Disabled, or nothing registered
+ return null;
+ }
+
+ $realOptions = $options = $this->getLegacyOptions( $isKnown );
+ $ret = null;
+ $dummy = new DummyLinker();
+ $title = Title::newFromLinkTarget( $target );
+ if ( $text !== null ) {
+ $realHtml = $html = HtmlArmor::getHtml( $text );
+ } else {
+ $realHtml = $html = null;
+ }
+ if ( !Hooks::run( 'LinkBegin',
+ [ $dummy, $title, &$html, &$extraAttribs, &$query, &$options, &$ret ] )
+ ) {
+ return $ret;
+ }
+
+ if ( $html !== null && $html !== $realHtml ) {
+ // &$html was modified, so re-armor it as $text
+ $text = new HtmlArmor( $html );
+ }
+
+ // Check if they changed any of the options, hopefully not!
+ if ( $options !== $realOptions ) {
+ $factory = MediaWikiServices::getInstance()->getLinkRendererFactory();
+ // They did, so create a separate instance and have that take over the rest
+ $newRenderer = $factory->createFromLegacyOptions( $options );
+ // Don't recurse the hook...
+ $newRenderer->setRunLegacyBeginHook( false );
+ if ( in_array( 'known', $options, true ) ) {
+ return $newRenderer->makeKnownLink( $title, $text, $extraAttribs, $query );
+ } elseif ( in_array( 'broken', $options, true ) ) {
+ return $newRenderer->makeBrokenLink( $title, $text, $extraAttribs, $query );
+ } else {
+ return $newRenderer->makeLink( $title, $text, $extraAttribs, $query );
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * If you have already looked up the proper CSS classes using LinkRenderer::getLinkClasses()
+ * or some other method, use this to avoid looking it up again.
+ *
+ * @param LinkTarget $target
+ * @param string|HtmlArmor|null $text
+ * @param string $classes CSS classes to add
+ * @param array $extraAttribs
+ * @param array $query
+ * @return string
+ */
+ public function makePreloadedLink(
+ LinkTarget $target, $text = null, $classes, array $extraAttribs = [], array $query = []
+ ) {
+ // Run begin hook
+ $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query, true );
+ if ( $ret !== null ) {
+ return $ret;
+ }
+ $target = $this->normalizeTarget( $target );
+ $url = $this->getLinkURL( $target, $query );
+ $attribs = [ 'class' => $classes ];
+ $prefixedText = $this->titleFormatter->getPrefixedText( $target );
+ if ( $prefixedText !== '' ) {
+ $attribs['title'] = $prefixedText;
+ }
+
+ $attribs = [
+ 'href' => $url,
+ ] + $this->mergeAttribs( $attribs, $extraAttribs );
+
+ if ( $text === null ) {
+ $text = $this->getLinkText( $target );
+ }
+
+ return $this->buildAElement( $target, $text, $attribs, true );
+ }
+
+ /**
+ * @param LinkTarget $target
+ * @param string|HtmlArmor|null $text
+ * @param array $extraAttribs
+ * @param array $query
+ * @return string
+ */
+ public function makeKnownLink(
+ LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
+ ) {
+ $classes = [];
+ if ( $target->isExternal() ) {
+ $classes[] = 'extiw';
+ }
+ $colour = $this->getLinkClasses( $target );
+ if ( $colour !== '' ) {
+ $classes[] = $colour;
+ }
+
+ return $this->makePreloadedLink(
+ $target,
+ $text,
+ $classes ? implode( ' ', $classes ) : '',
+ $extraAttribs,
+ $query
+ );
+ }
+
+ /**
+ * @param LinkTarget $target
+ * @param string|HtmlArmor|null $text
+ * @param array $extraAttribs
+ * @param array $query
+ * @return string
+ */
+ public function makeBrokenLink(
+ LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
+ ) {
+ // Run legacy hook
+ $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query, false );
+ if ( $ret !== null ) {
+ return $ret;
+ }
+
+ # We don't want to include fragments for broken links, because they
+ # generally make no sense.
+ if ( $target->hasFragment() ) {
+ $target = $target->createFragmentTarget( '' );
+ }
+ $target = $this->normalizeTarget( $target );
+
+ if ( !isset( $query['action'] ) && $target->getNamespace() !== NS_SPECIAL ) {
+ $query['action'] = 'edit';
+ $query['redlink'] = '1';
+ }
+
+ $url = $this->getLinkURL( $target, $query );
+ $attribs = [ 'class' => 'new' ];
+ $prefixedText = $this->titleFormatter->getPrefixedText( $target );
+ if ( $prefixedText !== '' ) {
+ // This ends up in parser cache!
+ $attribs['title'] = wfMessage( 'red-link-title', $prefixedText )
+ ->inContentLanguage()
+ ->text();
+ }
+
+ $attribs = [
+ 'href' => $url,
+ ] + $this->mergeAttribs( $attribs, $extraAttribs );
+
+ if ( $text === null ) {
+ $text = $this->getLinkText( $target );
+ }
+
+ return $this->buildAElement( $target, $text, $attribs, false );
+ }
+
+ /**
+ * Builds the final <a> element
+ *
+ * @param LinkTarget $target
+ * @param string|HtmlArmor $text
+ * @param array $attribs
+ * @param bool $isKnown
+ * @return null|string
+ */
+ private function buildAElement( LinkTarget $target, $text, array $attribs, $isKnown ) {
+ $ret = null;
+ if ( !Hooks::run( 'HtmlPageLinkRendererEnd',
+ [ $this, $target, $isKnown, &$text, &$attribs, &$ret ] )
+ ) {
+ return $ret;
+ }
+
+ $html = HtmlArmor::getHtml( $text );
+
+ // Run legacy hook
+ if ( Hooks::isRegistered( 'LinkEnd' ) ) {
+ $dummy = new DummyLinker();
+ $title = Title::newFromLinkTarget( $target );
+ $options = $this->getLegacyOptions( $isKnown );
+ if ( !Hooks::run( 'LinkEnd',
+ [ $dummy, $title, $options, &$html, &$attribs, &$ret ] )
+ ) {
+ return $ret;
+ }
+ }
+
+ return Html::rawElement( 'a', $attribs, $html );
+ }
+
+ /**
+ * @param LinkTarget $target
+ * @return string non-escaped text
+ */
+ private function getLinkText( LinkTarget $target ) {
+ $prefixedText = $this->titleFormatter->getPrefixedText( $target );
+ // If the target is just a fragment, with no title, we return the fragment
+ // text. Otherwise, we return the title text itself.
+ if ( $prefixedText === '' && $target->hasFragment() ) {
+ return $target->getFragment();
+ }
+
+ return $prefixedText;
+ }
+
+ private function getLinkURL( LinkTarget $target, array $query = [] ) {
+ // TODO: Use a LinkTargetResolver service instead of Title
+ $title = Title::newFromLinkTarget( $target );
+ if ( $this->forceArticlePath ) {
+ $realQuery = $query;
+ $query = [];
+ } else {
+ $realQuery = [];
+ }
+ $url = $title->getLinkURL( $query, false, $this->expandUrls );
+
+ if ( $this->forceArticlePath && $realQuery ) {
+ $url = wfAppendQuery( $url, $realQuery );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Normalizes the provided target
+ *
+ * @todo move the code from Linker actually here
+ * @param LinkTarget $target
+ * @return LinkTarget
+ */
+ private function normalizeTarget( LinkTarget $target ) {
+ return Linker::normaliseSpecialPage( $target );
+ }
+
+ /**
+ * Merges two sets of attributes
+ *
+ * @param array $defaults
+ * @param array $attribs
+ *
+ * @return array
+ */
+ private function mergeAttribs( $defaults, $attribs ) {
+ if ( !$attribs ) {
+ return $defaults;
+ }
+ # Merge the custom attribs with the default ones, and iterate
+ # over that, deleting all "false" attributes.
+ $ret = [];
+ $merged = Sanitizer::mergeAttributes( $defaults, $attribs );
+ foreach ( $merged as $key => $val ) {
+ # A false value suppresses the attribute
+ if ( $val !== false ) {
+ $ret[$key] = $val;
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Return the CSS classes of a known link
+ *
+ * @param LinkTarget $target
+ * @return string CSS class
+ */
+ public function getLinkClasses( LinkTarget $target ) {
+ // Make sure the target is in the cache
+ $id = $this->linkCache->addLinkObj( $target );
+ if ( $id == 0 ) {
+ // Doesn't exist
+ return '';
+ }
+
+ if ( $this->linkCache->getGoodLinkFieldObj( $target, 'redirect' ) ) {
+ # Page is a redirect
+ return 'mw-redirect';
+ } elseif ( $this->stubThreshold > 0 && MWNamespace::isContent( $target->getNamespace() )
+ && $this->linkCache->getGoodLinkFieldObj( $target, 'length' ) < $this->stubThreshold
+ ) {
+ # Page is a stub
+ return 'stub';
+ }
+
+ return '';
+ }
+}
diff --git a/www/wiki/includes/linker/LinkRendererFactory.php b/www/wiki/includes/linker/LinkRendererFactory.php
new file mode 100644
index 00000000..b7c05c2f
--- /dev/null
+++ b/www/wiki/includes/linker/LinkRendererFactory.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL-2.0+
+ * @author Kunal Mehta <legoktm@member.fsf.org>
+ */
+namespace MediaWiki\Linker;
+
+use LinkCache;
+use TitleFormatter;
+use User;
+
+/**
+ * Factory to create LinkRender objects
+ * @since 1.28
+ */
+class LinkRendererFactory {
+
+ /**
+ * @var TitleFormatter
+ */
+ private $titleFormatter;
+
+ /**
+ * @var LinkCache
+ */
+ private $linkCache;
+
+ /**
+ * @param TitleFormatter $titleFormatter
+ * @param LinkCache $linkCache
+ */
+ public function __construct( TitleFormatter $titleFormatter, LinkCache $linkCache ) {
+ $this->titleFormatter = $titleFormatter;
+ $this->linkCache = $linkCache;
+ }
+
+ /**
+ * @return LinkRenderer
+ */
+ public function create() {
+ return new LinkRenderer( $this->titleFormatter, $this->linkCache );
+ }
+
+ /**
+ * @param User $user
+ * @return LinkRenderer
+ */
+ public function createForUser( User $user ) {
+ $linkRenderer = $this->create();
+ $linkRenderer->setStubThreshold( $user->getStubThreshold() );
+
+ return $linkRenderer;
+ }
+
+ /**
+ * @param array $options
+ * @return LinkRenderer
+ */
+ public function createFromLegacyOptions( array $options ) {
+ $linkRenderer = $this->create();
+
+ if ( in_array( 'forcearticlepath', $options, true ) ) {
+ $linkRenderer->setForceArticlePath( true );
+ }
+
+ if ( in_array( 'http', $options, true ) ) {
+ $linkRenderer->setExpandURLs( PROTO_HTTP );
+ } elseif ( in_array( 'https', $options, true ) ) {
+ $linkRenderer->setExpandURLs( PROTO_HTTPS );
+ }
+
+ if ( isset( $options['stubThreshold'] ) ) {
+ $linkRenderer->setStubThreshold(
+ $options['stubThreshold']
+ );
+ }
+
+ return $linkRenderer;
+ }
+}
diff --git a/www/wiki/includes/linker/LinkTarget.php b/www/wiki/includes/linker/LinkTarget.php
new file mode 100644
index 00000000..dbd97a7a
--- /dev/null
+++ b/www/wiki/includes/linker/LinkTarget.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Addshore
+ */
+namespace MediaWiki\Linker;
+
+/**
+ * @since 1.27
+ */
+interface LinkTarget {
+
+ /**
+ * Get the namespace index.
+ * @since 1.27
+ *
+ * @return int Namespace index
+ */
+ public function getNamespace();
+
+ /**
+ * Convenience function to test if it is in the namespace
+ * @since 1.27
+ *
+ * @param int $ns
+ * @return bool
+ */
+ public function inNamespace( $ns );
+
+ /**
+ * Get the link fragment (i.e. the bit after the #) in text form.
+ * @since 1.27
+ *
+ * @return string link fragment
+ */
+ public function getFragment();
+
+ /**
+ * Whether the link target has a fragment
+ * @since 1.27
+ *
+ * @return bool
+ */
+ public function hasFragment();
+
+ /**
+ * Get the main part with underscores.
+ * @since 1.27
+ *
+ * @return string Main part of the link, with underscores (for use in href attributes)
+ */
+ public function getDBkey();
+
+ /**
+ * Returns the link in text form, without namespace prefix or fragment.
+ * This is computed from the DB key by replacing any underscores with spaces.
+ * @since 1.27
+ *
+ * @return string
+ */
+ public function getText();
+
+ /**
+ * Creates a new LinkTarget for a different fragment of the same page.
+ * It is expected that the same type of object will be returned, but the
+ * only requirement is that it is a LinkTarget.
+ * @since 1.27
+ *
+ * @param string $fragment The fragment name, or "" for the entire page.
+ *
+ * @return LinkTarget
+ */
+ public function createFragmentTarget( $fragment );
+
+ /**
+ * Whether this LinkTarget has an interwiki component
+ * @since 1.27
+ *
+ * @return bool
+ */
+ public function isExternal();
+
+ /**
+ * The interwiki component of this LinkTarget
+ * @since 1.27
+ *
+ * @return string
+ */
+ public function getInterwiki();
+}
diff --git a/www/wiki/includes/logging/BlockLogFormatter.php b/www/wiki/includes/logging/BlockLogFormatter.php
new file mode 100644
index 00000000..1ed18cd0
--- /dev/null
+++ b/www/wiki/includes/logging/BlockLogFormatter.php
@@ -0,0 +1,234 @@
+<?php
+/**
+ * Formatter for block log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.25
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This class formats block log entries.
+ *
+ * @since 1.25
+ */
+class BlockLogFormatter extends LogFormatter {
+ protected function getMessageParameters() {
+ $params = parent::getMessageParameters();
+
+ $title = $this->entry->getTarget();
+ if ( substr( $title->getText(), 0, 1 ) === '#' ) {
+ // autoblock - no user link possible
+ $params[2] = $title->getText();
+ $params[3] = ''; // no user name for gender use
+ } else {
+ // Create a user link for the blocked
+ $username = $title->getText();
+ // @todo Store the user identifier in the parameters
+ // to make this faster for future log entries
+ $targetUser = User::newFromName( $username, false );
+ $params[2] = Message::rawParam( $this->makeUserLink( $targetUser, Linker::TOOL_LINKS_NOBLOCK ) );
+ $params[3] = $username; // plain user name for gender use
+ }
+
+ $subtype = $this->entry->getSubtype();
+ if ( $subtype === 'block' || $subtype === 'reblock' ) {
+ if ( !isset( $params[4] ) ) {
+ // Very old log entry without duration: means infinite
+ $params[4] = 'infinite';
+ }
+ // Localize the duration, and add a tooltip
+ // in English to help visitors from other wikis.
+ // The lrm is needed to make sure that the number
+ // is shown on the correct side of the tooltip text.
+ $durationTooltip = '&lrm;' . htmlspecialchars( $params[4] );
+ $params[4] = Message::rawParam(
+ "<span class=\"blockExpiry\" title=\"$durationTooltip\">" .
+ $this->context->getLanguage()->translateBlockExpiry(
+ $params[4],
+ $this->context->getUser(),
+ wfTimestamp( TS_UNIX, $this->entry->getTimestamp() )
+ ) .
+ '</span>'
+ );
+ $params[5] = isset( $params[5] ) ?
+ self::formatBlockFlags( $params[5], $this->context->getLanguage() ) : '';
+ }
+
+ return $params;
+ }
+
+ protected function extractParameters() {
+ $params = parent::extractParameters();
+ // Legacy log params returning the params in index 3 and 4, moved to 4 and 5
+ if ( $this->entry->isLegacy() && isset( $params[3] ) ) {
+ if ( isset( $params[4] ) ) {
+ $params[5] = $params[4];
+ }
+ $params[4] = $params[3];
+ $params[3] = '';
+ }
+ return $params;
+ }
+
+ public function getPreloadTitles() {
+ $title = $this->entry->getTarget();
+ // Preload user page for non-autoblocks
+ if ( substr( $title->getText(), 0, 1 ) !== '#' ) {
+ return [ $title->getTalkPage() ];
+ }
+ return [];
+ }
+
+ public function getActionLinks() {
+ $subtype = $this->entry->getSubtype();
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ if ( $this->entry->isDeleted( LogPage::DELETED_ACTION ) // Action is hidden
+ || !( $subtype === 'block' || $subtype === 'reblock' )
+ || !$this->context->getUser()->isAllowed( 'block' )
+ ) {
+ return '';
+ }
+
+ // Show unblock/change block link
+ $title = $this->entry->getTarget();
+ $links = [
+ $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Unblock', $title->getDBkey() ),
+ $this->msg( 'unblocklink' )->text()
+ ),
+ $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Block', $title->getDBkey() ),
+ $this->msg( 'change-blocklink' )->text()
+ )
+ ];
+
+ return $this->msg( 'parentheses' )->rawParams(
+ $this->context->getLanguage()->pipeList( $links ) )->escaped();
+ }
+
+ /**
+ * Convert a comma-delimited list of block log flags
+ * into a more readable (and translated) form
+ *
+ * @param string $flags Flags to format
+ * @param Language $lang
+ * @return string
+ */
+ public static function formatBlockFlags( $flags, $lang ) {
+ $flags = trim( $flags );
+ if ( $flags === '' ) {
+ return ''; // nothing to do
+ }
+ $flags = explode( ',', $flags );
+ $flagsCount = count( $flags );
+
+ for ( $i = 0; $i < $flagsCount; $i++ ) {
+ $flags[$i] = self::formatBlockFlag( $flags[$i], $lang );
+ }
+
+ return wfMessage( 'parentheses' )->inLanguage( $lang )
+ ->rawParams( $lang->commaList( $flags ) )->escaped();
+ }
+
+ /**
+ * Translate a block log flag if possible
+ *
+ * @param int $flag Flag to translate
+ * @param Language $lang Language object to use
+ * @return string
+ */
+ public static function formatBlockFlag( $flag, $lang ) {
+ static $messages = [];
+
+ if ( !isset( $messages[$flag] ) ) {
+ $messages[$flag] = htmlspecialchars( $flag ); // Fallback
+
+ // For grepping. The following core messages can be used here:
+ // * block-log-flags-angry-autoblock
+ // * block-log-flags-anononly
+ // * block-log-flags-hiddenname
+ // * block-log-flags-noautoblock
+ // * block-log-flags-nocreate
+ // * block-log-flags-noemail
+ // * block-log-flags-nousertalk
+ $msg = wfMessage( 'block-log-flags-' . $flag )->inLanguage( $lang );
+
+ if ( $msg->exists() ) {
+ $messages[$flag] = $msg->escaped();
+ }
+ }
+
+ return $messages[$flag];
+ }
+
+ protected function getParametersForApi() {
+ $entry = $this->entry;
+ $params = $entry->getParameters();
+
+ static $map = [
+ // While this looks wrong to be starting at 5 rather than 4, it's
+ // because getMessageParameters uses $4 for its own purposes.
+ '5::duration',
+ '6:array:flags',
+ '6::flags' => '6:array:flags',
+ ];
+ foreach ( $map as $index => $key ) {
+ if ( isset( $params[$index] ) ) {
+ $params[$key] = $params[$index];
+ unset( $params[$index] );
+ }
+ }
+
+ $subtype = $entry->getSubtype();
+ if ( $subtype === 'block' || $subtype === 'reblock' ) {
+ // Defaults for old log entries missing some fields
+ $params += [
+ '5::duration' => 'infinite',
+ '6:array:flags' => [],
+ ];
+
+ if ( !is_array( $params['6:array:flags'] ) ) {
+ $params['6:array:flags'] = $params['6:array:flags'] === ''
+ ? []
+ : explode( ',', $params['6:array:flags'] );
+ }
+
+ if ( !wfIsInfinity( $params['5::duration'] ) ) {
+ $ts = wfTimestamp( TS_UNIX, $entry->getTimestamp() );
+ $expiry = strtotime( $params['5::duration'], $ts );
+ if ( $expiry !== false && $expiry > 0 ) {
+ $params[':timestamp:expiry'] = $expiry;
+ }
+ }
+ }
+
+ return $params;
+ }
+
+ public function formatParametersForApi() {
+ $ret = parent::formatParametersForApi();
+ if ( isset( $ret['flags'] ) ) {
+ ApiResult::setIndexedTagName( $ret['flags'], 'f' );
+ }
+ return $ret;
+ }
+
+}
diff --git a/www/wiki/includes/logging/ContentModelLogFormatter.php b/www/wiki/includes/logging/ContentModelLogFormatter.php
new file mode 100644
index 00000000..861ea302
--- /dev/null
+++ b/www/wiki/includes/logging/ContentModelLogFormatter.php
@@ -0,0 +1,36 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+class ContentModelLogFormatter extends LogFormatter {
+ protected function getMessageParameters() {
+ $lang = $this->context->getLanguage();
+ $params = parent::getMessageParameters();
+ $params[3] = ContentHandler::getLocalizedName( $params[3], $lang );
+ $params[4] = ContentHandler::getLocalizedName( $params[4], $lang );
+ return $params;
+ }
+
+ public function getActionLinks() {
+ if ( $this->entry->isDeleted( LogPage::DELETED_ACTION ) // Action is hidden
+ || $this->entry->getSubtype() !== 'change'
+ || !$this->context->getUser()->isAllowed( 'editcontentmodel' )
+ ) {
+ return '';
+ }
+
+ $params = $this->extractParameters();
+ $revert = MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink(
+ SpecialPage::getTitleFor( 'ChangeContentModel' ),
+ $this->msg( 'logentry-contentmodel-change-revertlink' )->text(),
+ [],
+ [
+ 'pagetitle' => $this->entry->getTarget()->getPrefixedText(),
+ 'model' => $params[3],
+ 'reason' => $this->msg( 'logentry-contentmodel-change-revert' )->inContentLanguage()->text(),
+ ]
+ );
+
+ return $this->msg( 'parentheses' )->rawParams( $revert )->escaped();
+ }
+}
diff --git a/www/wiki/includes/logging/DeleteLogFormatter.php b/www/wiki/includes/logging/DeleteLogFormatter.php
new file mode 100644
index 00000000..ceb00520
--- /dev/null
+++ b/www/wiki/includes/logging/DeleteLogFormatter.php
@@ -0,0 +1,315 @@
+<?php
+/**
+ * Formatter for delete log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.22
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This class formats delete log entries.
+ *
+ * @since 1.19
+ */
+class DeleteLogFormatter extends LogFormatter {
+ protected function getMessageKey() {
+ $key = parent::getMessageKey();
+ if ( in_array( $this->entry->getSubtype(), [ 'event', 'revision' ] ) ) {
+ if ( count( $this->getMessageParameters() ) < 5 ) {
+ // Messages: logentry-delete-event-legacy, logentry-delete-revision-legacy,
+ // logentry-suppress-event-legacy, logentry-suppress-revision-legacy
+ return "$key-legacy";
+ }
+ } elseif ( $this->entry->getSubtype() === 'restore' ) {
+ $rawParams = $this->entry->getParameters();
+ if ( !isset( $rawParams[':assoc:count'] ) ) {
+ // Message: logentry-delete-restore-nocount
+ return $key . '-nocount';
+ }
+ }
+
+ return $key;
+ }
+
+ protected function getMessageParameters() {
+ if ( isset( $this->parsedParametersDeleteLog ) ) {
+ return $this->parsedParametersDeleteLog;
+ }
+
+ $params = parent::getMessageParameters();
+ $subtype = $this->entry->getSubtype();
+ if ( in_array( $subtype, [ 'event', 'revision' ] ) ) {
+ // $params[3] here is 'revision' or 'archive' for page revisions, 'oldimage' or
+ // 'filearchive' for file versions, or a comma-separated list of log_ids for log
+ // entries. $subtype here is 'revision' for page revisions and file
+ // versions, or 'event' for log entries.
+ if (
+ ( $subtype === 'event' && count( $params ) === 6 )
+ || (
+ $subtype === 'revision' && isset( $params[3] )
+ && in_array( $params[3], [ 'revision', 'archive', 'oldimage', 'filearchive' ] )
+ )
+ ) {
+ // See RevDelList::getLogParams()/RevDelLogList::getLogParams()
+ $paramStart = $subtype === 'revision' ? 4 : 3;
+
+ $old = $this->parseBitField( $params[$paramStart + 1] );
+ $new = $this->parseBitField( $params[$paramStart + 2] );
+ list( $hid, $unhid, $extra ) = RevisionDeleter::getChanges( $new, $old );
+ $changes = [];
+ // messages used: revdelete-content-hid, revdelete-summary-hid, revdelete-uname-hid
+ foreach ( $hid as $v ) {
+ $changes[] = $this->msg( "$v-hid" )->plain();
+ }
+ // messages used: revdelete-content-unhid, revdelete-summary-unhid,
+ // revdelete-uname-unhid
+ foreach ( $unhid as $v ) {
+ $changes[] = $this->msg( "$v-unhid" )->plain();
+ }
+ foreach ( $extra as $v ) {
+ $changes[] = $this->msg( $v )->plain();
+ }
+ $changeText = $this->context->getLanguage()->listToText( $changes );
+
+ $newParams = array_slice( $params, 0, 3 );
+ $newParams[3] = $changeText;
+ $ids = is_array( $params[$paramStart] )
+ ? $params[$paramStart]
+ : explode( ',', $params[$paramStart] );
+ $newParams[4] = $this->context->getLanguage()->formatNum( count( $ids ) );
+
+ $this->parsedParametersDeleteLog = $newParams;
+ return $this->parsedParametersDeleteLog;
+ } else {
+ $this->parsedParametersDeleteLog = array_slice( $params, 0, 3 );
+ return $this->parsedParametersDeleteLog;
+ }
+ } elseif ( $subtype === 'restore' ) {
+ $rawParams = $this->entry->getParameters();
+ if ( isset( $rawParams[':assoc:count'] ) ) {
+ $countList = [];
+ foreach ( $rawParams[':assoc:count'] as $type => $count ) {
+ if ( $count ) {
+ // Messages: restore-count-revisions, restore-count-files
+ $countList[] = $this->context->msg( 'restore-count-' . $type )
+ ->numParams( $count )->plain();
+ }
+ }
+ $params[3] = $this->context->getLanguage()->listToText( $countList );
+ }
+ }
+
+ $this->parsedParametersDeleteLog = $params;
+ return $this->parsedParametersDeleteLog;
+ }
+
+ protected function parseBitField( $string ) {
+ // Input is like ofield=2134 or just the number
+ if ( strpos( $string, 'field=' ) === 1 ) {
+ list( , $field ) = explode( '=', $string );
+
+ return (int)$field;
+ } else {
+ return (int)$string;
+ }
+ }
+
+ public function getActionLinks() {
+ $user = $this->context->getUser();
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ if ( !$user->isAllowed( 'deletedhistory' )
+ || $this->entry->isDeleted( LogPage::DELETED_ACTION )
+ ) {
+ return '';
+ }
+
+ switch ( $this->entry->getSubtype() ) {
+ case 'delete': // Show undelete link
+ case 'delete_redir':
+ if ( $user->isAllowed( 'undelete' ) ) {
+ $message = 'undeletelink';
+ } else {
+ $message = 'undeleteviewlink';
+ }
+ $revert = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Undelete' ),
+ $this->msg( $message )->text(),
+ [],
+ [ 'target' => $this->entry->getTarget()->getPrefixedDBkey() ]
+ );
+
+ return $this->msg( 'parentheses' )->rawParams( $revert )->escaped();
+
+ case 'revision': // If an edit was hidden from a page give a review link to the history
+ $params = $this->extractParameters();
+ if ( !isset( $params[3] ) || !isset( $params[4] ) ) {
+ return '';
+ }
+
+ // Different revision types use different URL params...
+ $key = $params[3];
+ // This is a array or CSV of the IDs
+ $ids = is_array( $params[4] )
+ ? $params[4]
+ : explode( ',', $params[4] );
+
+ $links = [];
+
+ // If there's only one item, we can show a diff link
+ if ( count( $ids ) == 1 ) {
+ // Live revision diffs...
+ if ( $key == 'oldid' || $key == 'revision' ) {
+ $links[] = $linkRenderer->makeKnownLink(
+ $this->entry->getTarget(),
+ $this->msg( 'diff' )->text(),
+ [],
+ [
+ 'diff' => intval( $ids[0] ),
+ 'unhide' => 1
+ ]
+ );
+ // Deleted revision diffs...
+ } elseif ( $key == 'artimestamp' || $key == 'archive' ) {
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Undelete' ),
+ $this->msg( 'diff' )->text(),
+ [],
+ [
+ 'target' => $this->entry->getTarget()->getPrefixedDBkey(),
+ 'diff' => 'prev',
+ 'timestamp' => $ids[0]
+ ]
+ );
+ }
+ }
+
+ // View/modify link...
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Revisiondelete' ),
+ $this->msg( 'revdel-restore' )->text(),
+ [],
+ [
+ 'target' => $this->entry->getTarget()->getPrefixedText(),
+ 'type' => $key,
+ 'ids' => implode( ',', $ids ),
+ ]
+ );
+
+ return $this->msg( 'parentheses' )->rawParams(
+ $this->context->getLanguage()->pipeList( $links ) )->escaped();
+
+ case 'event': // Hidden log items, give review link
+ $params = $this->extractParameters();
+ if ( !isset( $params[3] ) ) {
+ return '';
+ }
+ // This is a CSV of the IDs
+ $query = $params[3];
+ if ( is_array( $query ) ) {
+ $query = implode( ',', $query );
+ }
+ // Link to each hidden object ID, $params[1] is the url param
+ $revert = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Revisiondelete' ),
+ $this->msg( 'revdel-restore' )->text(),
+ [],
+ [
+ 'target' => $this->entry->getTarget()->getPrefixedText(),
+ 'type' => 'logging',
+ 'ids' => $query
+ ]
+ );
+
+ return $this->msg( 'parentheses' )->rawParams( $revert )->escaped();
+ default:
+ return '';
+ }
+ }
+
+ protected function getParametersForApi() {
+ $entry = $this->entry;
+ $params = [];
+
+ $subtype = $this->entry->getSubtype();
+ if ( in_array( $subtype, [ 'event', 'revision' ] ) ) {
+ $rawParams = $entry->getParameters();
+ if ( $subtype === 'event' ) {
+ array_unshift( $rawParams, 'logging' );
+ }
+
+ static $map = [
+ '4::type',
+ '5::ids',
+ '6::ofield',
+ '7::nfield',
+ '4::ids' => '5::ids',
+ '5::ofield' => '6::ofield',
+ '6::nfield' => '7::nfield',
+ ];
+ foreach ( $map as $index => $key ) {
+ if ( isset( $rawParams[$index] ) ) {
+ $rawParams[$key] = $rawParams[$index];
+ unset( $rawParams[$index] );
+ }
+ }
+
+ $old = $this->parseBitField( $rawParams['6::ofield'] );
+ $new = $this->parseBitField( $rawParams['7::nfield'] );
+ if ( !is_array( $rawParams['5::ids'] ) ) {
+ $rawParams['5::ids'] = explode( ',', $rawParams['5::ids'] );
+ }
+
+ $params = [
+ '::type' => $rawParams['4::type'],
+ ':array:ids' => $rawParams['5::ids'],
+ ':assoc:old' => [ 'bitmask' => $old ],
+ ':assoc:new' => [ 'bitmask' => $new ],
+ ];
+
+ static $fields = [
+ Revision::DELETED_TEXT => 'content',
+ Revision::DELETED_COMMENT => 'comment',
+ Revision::DELETED_USER => 'user',
+ Revision::DELETED_RESTRICTED => 'restricted',
+ ];
+ foreach ( $fields as $bit => $key ) {
+ $params[':assoc:old'][$key] = (bool)( $old & $bit );
+ $params[':assoc:new'][$key] = (bool)( $new & $bit );
+ }
+ } elseif ( $subtype === 'restore' ) {
+ $rawParams = $entry->getParameters();
+ if ( isset( $rawParams[':assoc:count'] ) ) {
+ $params[':assoc:count'] = $rawParams[':assoc:count'];
+ }
+ }
+
+ return $params;
+ }
+
+ public function formatParametersForApi() {
+ $ret = parent::formatParametersForApi();
+ if ( isset( $ret['ids'] ) ) {
+ ApiResult::setIndexedTagName( $ret['ids'], 'id' );
+ }
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/logging/ImportLogFormatter.php b/www/wiki/includes/logging/ImportLogFormatter.php
new file mode 100644
index 00000000..a2a899b0
--- /dev/null
+++ b/www/wiki/includes/logging/ImportLogFormatter.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * Formatter for import log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.27
+ */
+
+/**
+ * This class formats import log entries.
+ *
+ * @since 1.27
+ */
+class ImportLogFormatter extends LogFormatter {
+ protected function getMessageKey() {
+ $key = parent::getMessageKey();
+ $params = $this->extractParameters();
+ if ( isset( $params[3] ) ) {
+ // New log items with more details
+ // Messages: logentry-import-upload-details, logentry-import-interwiki-details
+ $key .= '-details';
+ }
+
+ return $key;
+ }
+}
diff --git a/www/wiki/includes/logging/LogEntry.php b/www/wiki/includes/logging/LogEntry.php
new file mode 100644
index 00000000..8b51932b
--- /dev/null
+++ b/www/wiki/includes/logging/LogEntry.php
@@ -0,0 +1,813 @@
+<?php
+/**
+ * Contain classes for dealing with individual log entries
+ *
+ * This is how I see the log system history:
+ * - appending to plain wiki pages
+ * - formatting log entries based on database fields
+ * - user is now part of the action message
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.19
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Interface for log entries. Every log entry has these methods.
+ *
+ * @since 1.19
+ */
+interface LogEntry {
+
+ /**
+ * The main log type.
+ *
+ * @return string
+ */
+ public function getType();
+
+ /**
+ * The log subtype.
+ *
+ * @return string
+ */
+ public function getSubtype();
+
+ /**
+ * The full logtype in format maintype/subtype.
+ *
+ * @return string
+ */
+ public function getFullType();
+
+ /**
+ * Get the extra parameters stored for this message.
+ *
+ * @return array
+ */
+ public function getParameters();
+
+ /**
+ * Get the user for performed this action.
+ *
+ * @return User
+ */
+ public function getPerformer();
+
+ /**
+ * Get the target page of this action.
+ *
+ * @return Title
+ */
+ public function getTarget();
+
+ /**
+ * Get the timestamp when the action was executed.
+ *
+ * @return string
+ */
+ public function getTimestamp();
+
+ /**
+ * Get the user provided comment.
+ *
+ * @return string
+ */
+ public function getComment();
+
+ /**
+ * Get the access restriction.
+ *
+ * @return string
+ */
+ public function getDeleted();
+
+ /**
+ * @param int $field One of LogPage::DELETED_* bitfield constants
+ * @return bool
+ */
+ public function isDeleted( $field );
+}
+
+/**
+ * Extends the LogEntryInterface with some basic functionality
+ *
+ * @since 1.19
+ */
+abstract class LogEntryBase implements LogEntry {
+
+ public function getFullType() {
+ return $this->getType() . '/' . $this->getSubtype();
+ }
+
+ public function isDeleted( $field ) {
+ return ( $this->getDeleted() & $field ) === $field;
+ }
+
+ /**
+ * Whether the parameters for this log are stored in new or
+ * old format.
+ *
+ * @return bool
+ */
+ public function isLegacy() {
+ return false;
+ }
+
+ /**
+ * Create a blob from a parameter array
+ *
+ * @since 1.26
+ * @param array $params
+ * @return string
+ */
+ public static function makeParamBlob( $params ) {
+ return serialize( (array)$params );
+ }
+
+ /**
+ * Extract a parameter array from a blob
+ *
+ * @since 1.26
+ * @param string $blob
+ * @return array
+ */
+ public static function extractParams( $blob ) {
+ return unserialize( $blob );
+ }
+}
+
+/**
+ * This class wraps around database result row.
+ *
+ * @since 1.19
+ */
+class DatabaseLogEntry extends LogEntryBase {
+
+ /**
+ * Returns array of information that is needed for querying
+ * log entries. Array contains the following keys:
+ * tables, fields, conds, options and join_conds
+ *
+ * @return array
+ */
+ public static function getSelectQueryData() {
+ $commentQuery = CommentStore::newKey( 'log_comment' )->getJoin();
+
+ $tables = [ 'logging', 'user' ] + $commentQuery['tables'];
+ $fields = [
+ 'log_id', 'log_type', 'log_action', 'log_timestamp',
+ 'log_user', 'log_user_text',
+ 'log_namespace', 'log_title', // unused log_page
+ 'log_params', 'log_deleted',
+ 'user_id', 'user_name', 'user_editcount',
+ ] + $commentQuery['fields'];
+
+ $joins = [
+ // IPs don't have an entry in user table
+ 'user' => [ 'LEFT JOIN', 'log_user=user_id' ],
+ ] + $commentQuery['joins'];
+
+ return [
+ 'tables' => $tables,
+ 'fields' => $fields,
+ 'conds' => [],
+ 'options' => [],
+ 'join_conds' => $joins,
+ ];
+ }
+
+ /**
+ * Constructs new LogEntry from database result row.
+ * Supports rows from both logging and recentchanges table.
+ *
+ * @param stdClass|array $row
+ * @return DatabaseLogEntry
+ */
+ public static function newFromRow( $row ) {
+ $row = (object)$row;
+ if ( isset( $row->rc_logid ) ) {
+ return new RCDatabaseLogEntry( $row );
+ } else {
+ return new self( $row );
+ }
+ }
+
+ /** @var stdClass Database result row. */
+ protected $row;
+
+ /** @var User */
+ protected $performer;
+
+ /** @var array Parameters for log entry */
+ protected $params;
+
+ /** @var int A rev id associated to the log entry */
+ protected $revId = null;
+
+ /** @var bool Whether the parameters for this log entry are stored in new or old format. */
+ protected $legacy;
+
+ protected function __construct( $row ) {
+ $this->row = $row;
+ }
+
+ /**
+ * Returns the unique database id.
+ *
+ * @return int
+ */
+ public function getId() {
+ return (int)$this->row->log_id;
+ }
+
+ /**
+ * Returns whatever is stored in the database field.
+ *
+ * @return string
+ */
+ protected function getRawParameters() {
+ return $this->row->log_params;
+ }
+
+ public function isLegacy() {
+ // This extracts the property
+ $this->getParameters();
+ return $this->legacy;
+ }
+
+ public function getType() {
+ return $this->row->log_type;
+ }
+
+ public function getSubtype() {
+ return $this->row->log_action;
+ }
+
+ public function getParameters() {
+ if ( !isset( $this->params ) ) {
+ $blob = $this->getRawParameters();
+ MediaWiki\suppressWarnings();
+ $params = LogEntryBase::extractParams( $blob );
+ MediaWiki\restoreWarnings();
+ if ( $params !== false ) {
+ $this->params = $params;
+ $this->legacy = false;
+ } else {
+ $this->params = LogPage::extractParams( $blob );
+ $this->legacy = true;
+ }
+
+ if ( isset( $this->params['associated_rev_id'] ) ) {
+ $this->revId = $this->params['associated_rev_id'];
+ unset( $this->params['associated_rev_id'] );
+ }
+ }
+
+ return $this->params;
+ }
+
+ public function getAssociatedRevId() {
+ // This extracts the property
+ $this->getParameters();
+ return $this->revId;
+ }
+
+ public function getPerformer() {
+ if ( !$this->performer ) {
+ $userId = (int)$this->row->log_user;
+ if ( $userId !== 0 ) {
+ // logged-in users
+ if ( isset( $this->row->user_name ) ) {
+ $this->performer = User::newFromRow( $this->row );
+ } else {
+ $this->performer = User::newFromId( $userId );
+ }
+ } else {
+ // IP users
+ $userText = $this->row->log_user_text;
+ $this->performer = User::newFromName( $userText, false );
+ }
+ }
+
+ return $this->performer;
+ }
+
+ public function getTarget() {
+ $namespace = $this->row->log_namespace;
+ $page = $this->row->log_title;
+ $title = Title::makeTitle( $namespace, $page );
+
+ return $title;
+ }
+
+ public function getTimestamp() {
+ return wfTimestamp( TS_MW, $this->row->log_timestamp );
+ }
+
+ public function getComment() {
+ return CommentStore::newKey( 'log_comment' )->getComment( $this->row )->text;
+ }
+
+ public function getDeleted() {
+ return $this->row->log_deleted;
+ }
+}
+
+class RCDatabaseLogEntry extends DatabaseLogEntry {
+
+ public function getId() {
+ return $this->row->rc_logid;
+ }
+
+ protected function getRawParameters() {
+ return $this->row->rc_params;
+ }
+
+ public function getAssociatedRevId() {
+ return $this->row->rc_this_oldid;
+ }
+
+ public function getType() {
+ return $this->row->rc_log_type;
+ }
+
+ public function getSubtype() {
+ return $this->row->rc_log_action;
+ }
+
+ public function getPerformer() {
+ if ( !$this->performer ) {
+ $userId = (int)$this->row->rc_user;
+ if ( $userId !== 0 ) {
+ $this->performer = User::newFromId( $userId );
+ } else {
+ $userText = $this->row->rc_user_text;
+ // Might be an IP, don't validate the username
+ $this->performer = User::newFromName( $userText, false );
+ }
+ }
+
+ return $this->performer;
+ }
+
+ public function getTarget() {
+ $namespace = $this->row->rc_namespace;
+ $page = $this->row->rc_title;
+ $title = Title::makeTitle( $namespace, $page );
+
+ return $title;
+ }
+
+ public function getTimestamp() {
+ return wfTimestamp( TS_MW, $this->row->rc_timestamp );
+ }
+
+ public function getComment() {
+ return CommentStore::newKey( 'rc_comment' )
+ // Legacy because the row probably used RecentChange::selectFields()
+ ->getCommentLegacy( wfGetDB( DB_REPLICA ), $this->row )->text;
+ }
+
+ public function getDeleted() {
+ return $this->row->rc_deleted;
+ }
+}
+
+/**
+ * Class for creating log entries manually, to inject them into the database.
+ *
+ * @since 1.19
+ */
+class ManualLogEntry extends LogEntryBase {
+ /** @var string Type of log entry */
+ protected $type;
+
+ /** @var string Sub type of log entry */
+ protected $subtype;
+
+ /** @var array Parameters for log entry */
+ protected $parameters = [];
+
+ /** @var array */
+ protected $relations = [];
+
+ /** @var User Performer of the action for the log entry */
+ protected $performer;
+
+ /** @var Title Target title for the log entry */
+ protected $target;
+
+ /** @var string Timestamp of creation of the log entry */
+ protected $timestamp;
+
+ /** @var string Comment for the log entry */
+ protected $comment = '';
+
+ /** @var int A rev id associated to the log entry */
+ protected $revId = 0;
+
+ /** @var array Change tags add to the log entry */
+ protected $tags = null;
+
+ /** @var int Deletion state of the log entry */
+ protected $deleted;
+
+ /** @var int ID of the log entry */
+ protected $id;
+
+ /** @var bool Can this log entry be patrolled? */
+ protected $isPatrollable = false;
+
+ /** @var bool Whether this is a legacy log entry */
+ protected $legacy = false;
+
+ /**
+ * @since 1.19
+ * @param string $type
+ * @param string $subtype
+ */
+ public function __construct( $type, $subtype ) {
+ $this->type = $type;
+ $this->subtype = $subtype;
+ }
+
+ /**
+ * Set extra log parameters.
+ *
+ * You can pass params to the log action message by prefixing the keys with
+ * a number and optional type, using colons to separate the fields. The
+ * numbering should start with number 4, the first three parameters are
+ * hardcoded for every message.
+ *
+ * If you want to store stuff that should not be available in messages, don't
+ * prefix the array key with a number and don't use the colons.
+ *
+ * Example:
+ * $entry->setParameters(
+ * '4::color' => 'blue',
+ * '5:number:count' => 3000,
+ * 'animal' => 'dog'
+ * );
+ *
+ * @since 1.19
+ * @param array $parameters Associative array
+ */
+ public function setParameters( $parameters ) {
+ $this->parameters = $parameters;
+ }
+
+ /**
+ * Declare arbitrary tag/value relations to this log entry.
+ * These can be used to filter log entries later on.
+ *
+ * @param array $relations Map of (tag => (list of values|value))
+ * @since 1.22
+ */
+ public function setRelations( array $relations ) {
+ $this->relations = $relations;
+ }
+
+ /**
+ * Set the user that performed the action being logged.
+ *
+ * @since 1.19
+ * @param User $performer
+ */
+ public function setPerformer( User $performer ) {
+ $this->performer = $performer;
+ }
+
+ /**
+ * Set the title of the object changed.
+ *
+ * @since 1.19
+ * @param Title $target
+ */
+ public function setTarget( Title $target ) {
+ $this->target = $target;
+ }
+
+ /**
+ * Set the timestamp of when the logged action took place.
+ *
+ * @since 1.19
+ * @param string $timestamp
+ */
+ public function setTimestamp( $timestamp ) {
+ $this->timestamp = $timestamp;
+ }
+
+ /**
+ * Set a comment associated with the action being logged.
+ *
+ * @since 1.19
+ * @param string $comment
+ */
+ public function setComment( $comment ) {
+ $this->comment = $comment;
+ }
+
+ /**
+ * Set an associated revision id.
+ *
+ * For example, the ID of the revision that was inserted to mark a page move
+ * or protection, file upload, etc.
+ *
+ * @since 1.27
+ * @param int $revId
+ */
+ public function setAssociatedRevId( $revId ) {
+ $this->revId = $revId;
+ }
+
+ /**
+ * Set change tags for the log entry.
+ *
+ * @since 1.27
+ * @param string|string[] $tags
+ */
+ public function setTags( $tags ) {
+ if ( is_string( $tags ) ) {
+ $tags = [ $tags ];
+ }
+ $this->tags = $tags;
+ }
+
+ /**
+ * Set whether this log entry should be made patrollable
+ * This shouldn't depend on config, only on whether there is full support
+ * in the software for patrolling this log entry.
+ * False by default
+ *
+ * @since 1.27
+ * @param bool $patrollable
+ */
+ public function setIsPatrollable( $patrollable ) {
+ $this->isPatrollable = (bool)$patrollable;
+ }
+
+ /**
+ * Set the 'legacy' flag
+ *
+ * @since 1.25
+ * @param bool $legacy
+ */
+ public function setLegacy( $legacy ) {
+ $this->legacy = $legacy;
+ }
+
+ /**
+ * Set the 'deleted' flag.
+ *
+ * @since 1.19
+ * @param int $deleted One of LogPage::DELETED_* bitfield constants
+ */
+ public function setDeleted( $deleted ) {
+ $this->deleted = $deleted;
+ }
+
+ /**
+ * Insert the entry into the `logging` table.
+ *
+ * @param IDatabase $dbw
+ * @return int ID of the log entry
+ * @throws MWException
+ */
+ public function insert( IDatabase $dbw = null ) {
+ $dbw = $dbw ?: wfGetDB( DB_MASTER );
+
+ if ( $this->timestamp === null ) {
+ $this->timestamp = wfTimestampNow();
+ }
+
+ // Trim spaces on user supplied text
+ $comment = trim( $this->getComment() );
+
+ $params = $this->getParameters();
+ $relations = $this->relations;
+
+ // Additional fields for which there's no space in the database table schema
+ $revId = $this->getAssociatedRevId();
+ if ( $revId ) {
+ $params['associated_rev_id'] = $revId;
+ $relations['associated_rev_id'] = $revId;
+ }
+
+ $data = [
+ 'log_type' => $this->getType(),
+ 'log_action' => $this->getSubtype(),
+ 'log_timestamp' => $dbw->timestamp( $this->getTimestamp() ),
+ 'log_user' => $this->getPerformer()->getId(),
+ 'log_user_text' => $this->getPerformer()->getName(),
+ 'log_namespace' => $this->getTarget()->getNamespace(),
+ 'log_title' => $this->getTarget()->getDBkey(),
+ 'log_page' => $this->getTarget()->getArticleID(),
+ 'log_params' => LogEntryBase::makeParamBlob( $params ),
+ ];
+ if ( isset( $this->deleted ) ) {
+ $data['log_deleted'] = $this->deleted;
+ }
+ $data += CommentStore::newKey( 'log_comment' )->insert( $dbw, $comment );
+
+ $dbw->insert( 'logging', $data, __METHOD__ );
+ $this->id = $dbw->insertId();
+
+ $rows = [];
+ foreach ( $relations as $tag => $values ) {
+ if ( !strlen( $tag ) ) {
+ throw new MWException( "Got empty log search tag." );
+ }
+
+ if ( !is_array( $values ) ) {
+ $values = [ $values ];
+ }
+
+ foreach ( $values as $value ) {
+ $rows[] = [
+ 'ls_field' => $tag,
+ 'ls_value' => $value,
+ 'ls_log_id' => $this->id
+ ];
+ }
+ }
+ if ( count( $rows ) ) {
+ $dbw->insert( 'log_search', $rows, __METHOD__, 'IGNORE' );
+ }
+
+ return $this->id;
+ }
+
+ /**
+ * Get a RecentChanges object for the log entry
+ *
+ * @param int $newId
+ * @return RecentChange
+ * @since 1.23
+ */
+ public function getRecentChange( $newId = 0 ) {
+ $formatter = LogFormatter::newFromEntry( $this );
+ $context = RequestContext::newExtraneousContext( $this->getTarget() );
+ $formatter->setContext( $context );
+
+ $logpage = SpecialPage::getTitleFor( 'Log', $this->getType() );
+ $user = $this->getPerformer();
+ $ip = "";
+ if ( $user->isAnon() ) {
+ // "MediaWiki default" and friends may have
+ // no IP address in their name
+ if ( IP::isIPAddress( $user->getName() ) ) {
+ $ip = $user->getName();
+ }
+ }
+
+ return RecentChange::newLogEntry(
+ $this->getTimestamp(),
+ $logpage,
+ $user,
+ $formatter->getPlainActionText(),
+ $ip,
+ $this->getType(),
+ $this->getSubtype(),
+ $this->getTarget(),
+ $this->getComment(),
+ LogEntryBase::makeParamBlob( $this->getParameters() ),
+ $newId,
+ $formatter->getIRCActionComment(), // Used for IRC feeds
+ $this->getAssociatedRevId(), // Used for e.g. moves and uploads
+ $this->getIsPatrollable()
+ );
+ }
+
+ /**
+ * Publish the log entry.
+ *
+ * @param int $newId Id of the log entry.
+ * @param string $to One of: rcandudp (default), rc, udp
+ */
+ public function publish( $newId, $to = 'rcandudp' ) {
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $newId, $to ) {
+ $log = new LogPage( $this->getType() );
+ if ( !$log->isRestricted() ) {
+ $rc = $this->getRecentChange( $newId );
+
+ if ( $to === 'rc' || $to === 'rcandudp' ) {
+ // save RC, passing tags so they are applied there
+ $tags = $this->getTags();
+ if ( is_null( $tags ) ) {
+ $tags = [];
+ }
+ $rc->addTags( $tags );
+ $rc->save( 'pleasedontudp' );
+ }
+
+ if ( $to === 'udp' || $to === 'rcandudp' ) {
+ $rc->notifyRCFeeds();
+ }
+
+ // Log the autopatrol if the log entry is patrollable
+ if ( $this->getIsPatrollable() &&
+ $rc->getAttribute( 'rc_patrolled' ) === 1
+ ) {
+ PatrolLog::record( $rc, true, $this->getPerformer() );
+ }
+ }
+ },
+ DeferredUpdates::POSTSEND,
+ wfGetDB( DB_MASTER )
+ );
+ }
+
+ public function getType() {
+ return $this->type;
+ }
+
+ public function getSubtype() {
+ return $this->subtype;
+ }
+
+ public function getParameters() {
+ return $this->parameters;
+ }
+
+ /**
+ * @return User
+ */
+ public function getPerformer() {
+ return $this->performer;
+ }
+
+ /**
+ * @return Title
+ */
+ public function getTarget() {
+ return $this->target;
+ }
+
+ public function getTimestamp() {
+ $ts = $this->timestamp !== null ? $this->timestamp : wfTimestampNow();
+
+ return wfTimestamp( TS_MW, $ts );
+ }
+
+ public function getComment() {
+ return $this->comment;
+ }
+
+ /**
+ * @since 1.27
+ * @return int
+ */
+ public function getAssociatedRevId() {
+ return $this->revId;
+ }
+
+ /**
+ * @since 1.27
+ * @return array
+ */
+ public function getTags() {
+ return $this->tags;
+ }
+
+ /**
+ * Whether this log entry is patrollable
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function getIsPatrollable() {
+ return $this->isPatrollable;
+ }
+
+ /**
+ * @since 1.25
+ * @return bool
+ */
+ public function isLegacy() {
+ return $this->legacy;
+ }
+
+ public function getDeleted() {
+ return (int)$this->deleted;
+ }
+}
diff --git a/www/wiki/includes/logging/LogEventsList.php b/www/wiki/includes/logging/LogEventsList.php
new file mode 100644
index 00000000..00d3bd33
--- /dev/null
+++ b/www/wiki/includes/logging/LogEventsList.php
@@ -0,0 +1,775 @@
+<?php
+/**
+ * Contain classes to list log entries
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IDatabase;
+
+class LogEventsList extends ContextSource {
+ const NO_ACTION_LINK = 1;
+ const NO_EXTRA_USER_LINKS = 2;
+ const USE_CHECKBOXES = 4;
+
+ public $flags;
+
+ /**
+ * @var array
+ */
+ protected $mDefaultQuery;
+
+ /**
+ * @var bool
+ */
+ protected $showTagEditUI;
+
+ /**
+ * @var array
+ */
+ protected $allowedActions = null;
+
+ /**
+ * @var LinkRenderer|null
+ */
+ private $linkRenderer;
+
+ /**
+ * The first two parameters used to be $skin and $out, but now only a context
+ * is needed, that's why there's a second unused parameter.
+ *
+ * @param IContextSource|Skin $context Context to use; formerly it was
+ * a Skin object. Use of Skin is deprecated.
+ * @param LinkRenderer|null $linkRenderer previously unused
+ * @param int $flags Can be a combination of self::NO_ACTION_LINK,
+ * self::NO_EXTRA_USER_LINKS or self::USE_CHECKBOXES.
+ */
+ public function __construct( $context, $linkRenderer = null, $flags = 0 ) {
+ if ( $context instanceof IContextSource ) {
+ $this->setContext( $context );
+ } else {
+ // Old parameters, $context should be a Skin object
+ $this->setContext( $context->getContext() );
+ }
+
+ $this->flags = $flags;
+ $this->showTagEditUI = ChangeTags::showTagEditingUI( $this->getUser() );
+ if ( $linkRenderer instanceof LinkRenderer ) {
+ $this->linkRenderer = $linkRenderer;
+ }
+ }
+
+ /**
+ * @since 1.30
+ * @return LinkRenderer
+ */
+ protected function getLinkRenderer() {
+ if ( $this->linkRenderer !== null ) {
+ return $this->linkRenderer;
+ } else {
+ return MediaWikiServices::getInstance()->getLinkRenderer();
+ }
+ }
+
+ /**
+ * Show options for the log list
+ *
+ * @param array|string $types
+ * @param string $user
+ * @param string $page
+ * @param string $pattern
+ * @param int $year Year
+ * @param int $month Month
+ * @param array $filter
+ * @param string $tagFilter Tag to select by default
+ * @param string $action
+ */
+ public function showOptions( $types = [], $user = '', $page = '', $pattern = '', $year = 0,
+ $month = 0, $filter = null, $tagFilter = '', $action = null
+ ) {
+ global $wgScript, $wgMiserMode;
+
+ $title = SpecialPage::getTitleFor( 'Log' );
+
+ // For B/C, we take strings, but make sure they are converted...
+ $types = ( $types === '' ) ? [] : (array)$types;
+
+ $tagSelector = ChangeTags::buildTagFilterSelector( $tagFilter, false, $this->getContext() );
+
+ $html = Html::hidden( 'title', $title->getPrefixedDBkey() );
+
+ // Basic selectors
+ $html .= $this->getTypeMenu( $types ) . "\n";
+ $html .= $this->getUserInput( $user ) . "\n";
+ $html .= $this->getTitleInput( $page ) . "\n";
+ $html .= $this->getExtraInputs( $types ) . "\n";
+
+ // Title pattern, if allowed
+ if ( !$wgMiserMode ) {
+ $html .= $this->getTitlePattern( $pattern ) . "\n";
+ }
+
+ // date menu
+ $html .= Xml::tags( 'p', null, Xml::dateMenu( (int)$year, (int)$month ) );
+
+ // Tag filter
+ if ( $tagSelector ) {
+ $html .= Xml::tags( 'p', null, implode( '&#160;', $tagSelector ) );
+ }
+
+ // Filter links
+ if ( $filter ) {
+ $html .= Xml::tags( 'p', null, $this->getFilterLinks( $filter ) );
+ }
+
+ // Action filter
+ if ( $action !== null ) {
+ $html .= Xml::tags( 'p', null, $this->getActionSelector( $types, $action ) );
+ }
+
+ // Submit button
+ $html .= Xml::submitButton( $this->msg( 'logeventslist-submit' )->text() );
+
+ // Fieldset
+ $html = Xml::fieldset( $this->msg( 'log' )->text(), $html );
+
+ // Form wrapping
+ $html = Xml::tags( 'form', [ 'action' => $wgScript, 'method' => 'get' ], $html );
+
+ $this->getOutput()->addHTML( $html );
+ }
+
+ /**
+ * @param array $filter
+ * @return string Formatted HTML
+ */
+ private function getFilterLinks( $filter ) {
+ // show/hide links
+ $messages = [ $this->msg( 'show' )->text(), $this->msg( 'hide' )->text() ];
+ // Option value -> message mapping
+ $links = [];
+ $hiddens = ''; // keep track for "go" button
+ $linkRenderer = $this->getLinkRenderer();
+ foreach ( $filter as $type => $val ) {
+ // Should the below assignment be outside the foreach?
+ // Then it would have to be copied. Not certain what is more expensive.
+ $query = $this->getDefaultQuery();
+ $queryKey = "hide_{$type}_log";
+
+ $hideVal = 1 - intval( $val );
+ $query[$queryKey] = $hideVal;
+
+ $link = $linkRenderer->makeKnownLink(
+ $this->getTitle(),
+ $messages[$hideVal],
+ [],
+ $query
+ );
+
+ // Message: log-show-hide-patrol
+ $links[$type] = $this->msg( "log-show-hide-{$type}" )->rawParams( $link )->escaped();
+ $hiddens .= Html::hidden( "hide_{$type}_log", $val ) . "\n";
+ }
+
+ // Build links
+ return '<small>' . $this->getLanguage()->pipeList( $links ) . '</small>' . $hiddens;
+ }
+
+ private function getDefaultQuery() {
+ if ( !isset( $this->mDefaultQuery ) ) {
+ $this->mDefaultQuery = $this->getRequest()->getQueryValues();
+ unset( $this->mDefaultQuery['title'] );
+ unset( $this->mDefaultQuery['dir'] );
+ unset( $this->mDefaultQuery['offset'] );
+ unset( $this->mDefaultQuery['limit'] );
+ unset( $this->mDefaultQuery['order'] );
+ unset( $this->mDefaultQuery['month'] );
+ unset( $this->mDefaultQuery['year'] );
+ }
+
+ return $this->mDefaultQuery;
+ }
+
+ /**
+ * @param array $queryTypes
+ * @return string Formatted HTML
+ */
+ private function getTypeMenu( $queryTypes ) {
+ $queryType = count( $queryTypes ) == 1 ? $queryTypes[0] : '';
+ $selector = $this->getTypeSelector();
+ $selector->setDefault( $queryType );
+
+ return $selector->getHTML();
+ }
+
+ /**
+ * Returns log page selector.
+ * @return XmlSelect
+ * @since 1.19
+ */
+ public function getTypeSelector() {
+ $typesByName = []; // Temporary array
+ // First pass to load the log names
+ foreach ( LogPage::validTypes() as $type ) {
+ $page = new LogPage( $type );
+ $restriction = $page->getRestriction();
+ if ( $this->getUser()->isAllowed( $restriction ) ) {
+ $typesByName[$type] = $page->getName()->text();
+ }
+ }
+
+ // Second pass to sort by name
+ asort( $typesByName );
+
+ // Always put "All public logs" on top
+ $public = $typesByName[''];
+ unset( $typesByName[''] );
+ $typesByName = [ '' => $public ] + $typesByName;
+
+ $select = new XmlSelect( 'type' );
+ foreach ( $typesByName as $type => $name ) {
+ $select->addOption( $name, $type );
+ }
+
+ return $select;
+ }
+
+ /**
+ * @param string $user
+ * @return string Formatted HTML
+ */
+ private function getUserInput( $user ) {
+ $label = Xml::inputLabel(
+ $this->msg( 'specialloguserlabel' )->text(),
+ 'user',
+ 'mw-log-user',
+ 15,
+ $user,
+ [ 'class' => 'mw-autocomplete-user' ]
+ );
+
+ return '<span class="mw-input-with-label">' . $label . '</span>';
+ }
+
+ /**
+ * @param string $title
+ * @return string Formatted HTML
+ */
+ private function getTitleInput( $title ) {
+ $label = Xml::inputLabel(
+ $this->msg( 'speciallogtitlelabel' )->text(),
+ 'page',
+ 'mw-log-page',
+ 20,
+ $title
+ );
+
+ return '<span class="mw-input-with-label">' . $label . '</span>';
+ }
+
+ /**
+ * @param string $pattern
+ * @return string Checkbox
+ */
+ private function getTitlePattern( $pattern ) {
+ return '<span class="mw-input-with-label">' .
+ Xml::checkLabel( $this->msg( 'log-title-wildcard' )->text(), 'pattern', 'pattern', $pattern ) .
+ '</span>';
+ }
+
+ /**
+ * @param array $types
+ * @return string
+ */
+ private function getExtraInputs( $types ) {
+ if ( count( $types ) == 1 ) {
+ if ( $types[0] == 'suppress' ) {
+ $offender = $this->getRequest()->getVal( 'offender' );
+ $user = User::newFromName( $offender, false );
+ if ( !$user || ( $user->getId() == 0 && !IP::isIPAddress( $offender ) ) ) {
+ $offender = ''; // Blank field if invalid
+ }
+ return Xml::inputLabel( $this->msg( 'revdelete-offender' )->text(), 'offender',
+ 'mw-log-offender', 20, $offender );
+ } else {
+ // Allow extensions to add their own extra inputs
+ $input = '';
+ Hooks::run( 'LogEventsListGetExtraInputs', [ $types[0], $this, &$input ] );
+ return $input;
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Drop down menu for selection of actions that can be used to filter the log
+ * @param array $types
+ * @param string $action
+ * @return string
+ * @since 1.27
+ */
+ private function getActionSelector( $types, $action ) {
+ if ( $this->allowedActions === null || !count( $this->allowedActions ) ) {
+ return '';
+ }
+ $html = '';
+ $html .= Xml::label( wfMessage( 'log-action-filter-' . $types[0] )->text(),
+ 'action-filter-' .$types[0] ) . "\n";
+ $select = new XmlSelect( 'subtype' );
+ $select->addOption( wfMessage( 'log-action-filter-all' )->text(), '' );
+ foreach ( $this->allowedActions as $value ) {
+ $msgKey = 'log-action-filter-' . $types[0] . '-' . $value;
+ $select->addOption( wfMessage( $msgKey )->text(), $value );
+ }
+ $select->setDefault( $action );
+ $html .= $select->getHTML();
+ return $html;
+ }
+
+ /**
+ * Sets the action types allowed for log filtering
+ * To one action type may correspond several log_actions
+ * @param array $actions
+ * @since 1.27
+ */
+ public function setAllowedActions( $actions ) {
+ $this->allowedActions = $actions;
+ }
+
+ /**
+ * @return string
+ */
+ public function beginLogEventsList() {
+ return "<ul>\n";
+ }
+
+ /**
+ * @return string
+ */
+ public function endLogEventsList() {
+ return "</ul>\n";
+ }
+
+ /**
+ * @param stdClass $row A single row from the result set
+ * @return string Formatted HTML list item
+ */
+ public function logLine( $row ) {
+ $entry = DatabaseLogEntry::newFromRow( $row );
+ $formatter = LogFormatter::newFromEntry( $entry );
+ $formatter->setContext( $this->getContext() );
+ $formatter->setLinkRenderer( $this->getLinkRenderer() );
+ $formatter->setShowUserToolLinks( !( $this->flags & self::NO_EXTRA_USER_LINKS ) );
+
+ $time = htmlspecialchars( $this->getLanguage()->userTimeAndDate(
+ $entry->getTimestamp(), $this->getUser() ) );
+
+ $action = $formatter->getActionText();
+
+ if ( $this->flags & self::NO_ACTION_LINK ) {
+ $revert = '';
+ } else {
+ $revert = $formatter->getActionLinks();
+ if ( $revert != '' ) {
+ $revert = '<span class="mw-logevent-actionlink">' . $revert . '</span>';
+ }
+ }
+
+ $comment = $formatter->getComment();
+
+ // Some user can hide log items and have review links
+ $del = $this->getShowHideLinks( $row );
+
+ // Any tags...
+ list( $tagDisplay, $newClasses ) = ChangeTags::formatSummaryRow(
+ $row->ts_tags,
+ 'logevent',
+ $this->getContext()
+ );
+ $classes = array_merge(
+ [ 'mw-logline-' . $entry->getType() ],
+ $newClasses
+ );
+ $attribs = [
+ 'data-mw-logid' => $entry->getId(),
+ 'data-mw-logaction' => $entry->getFullType(),
+ ];
+ $ret = "$del $time $action $comment $revert $tagDisplay";
+
+ // Let extensions add data
+ Hooks::run( 'LogEventsListLineEnding', [ $this, &$ret, $entry, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+ $attribs['class'] = implode( ' ', $classes );
+
+ return Html::rawElement( 'li', $attribs, $ret ) . "\n";
+ }
+
+ /**
+ * @param stdClass $row Row
+ * @return string
+ */
+ private function getShowHideLinks( $row ) {
+ // We don't want to see the links and
+ if ( $this->flags == self::NO_ACTION_LINK ) {
+ return '';
+ }
+
+ $user = $this->getUser();
+
+ // If change tag editing is available to this user, return the checkbox
+ if ( $this->flags & self::USE_CHECKBOXES && $this->showTagEditUI ) {
+ return Xml::check(
+ 'showhiderevisions',
+ false,
+ [ 'name' => 'ids[' . $row->log_id . ']' ]
+ );
+ }
+
+ // no one can hide items from the suppress log.
+ if ( $row->log_type == 'suppress' ) {
+ return '';
+ }
+
+ $del = '';
+ // Don't show useless checkbox to people who cannot hide log entries
+ if ( $user->isAllowed( 'deletedhistory' ) ) {
+ $canHide = $user->isAllowed( 'deletelogentry' );
+ $canViewSuppressedOnly = $user->isAllowed( 'viewsuppressed' ) &&
+ !$user->isAllowed( 'suppressrevision' );
+ $entryIsSuppressed = self::isDeleted( $row, LogPage::DELETED_RESTRICTED );
+ $canViewThisSuppressedEntry = $canViewSuppressedOnly && $entryIsSuppressed;
+ if ( $row->log_deleted || $canHide ) {
+ // Show checkboxes instead of links.
+ if ( $canHide && $this->flags & self::USE_CHECKBOXES && !$canViewThisSuppressedEntry ) {
+ // If event was hidden from sysops
+ if ( !self::userCan( $row, LogPage::DELETED_RESTRICTED, $user ) ) {
+ $del = Xml::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] );
+ } else {
+ $del = Xml::check(
+ 'showhiderevisions',
+ false,
+ [ 'name' => 'ids[' . $row->log_id . ']' ]
+ );
+ }
+ } else {
+ // If event was hidden from sysops
+ if ( !self::userCan( $row, LogPage::DELETED_RESTRICTED, $user ) ) {
+ $del = Linker::revDeleteLinkDisabled( $canHide );
+ } else {
+ $query = [
+ 'target' => SpecialPage::getTitleFor( 'Log', $row->log_type )->getPrefixedDBkey(),
+ 'type' => 'logging',
+ 'ids' => $row->log_id,
+ ];
+ $del = Linker::revDeleteLink(
+ $query,
+ $entryIsSuppressed,
+ $canHide && !$canViewThisSuppressedEntry
+ );
+ }
+ }
+ }
+ }
+
+ return $del;
+ }
+
+ /**
+ * @param stdClass $row Row
+ * @param string|array $type
+ * @param string|array $action
+ * @param string $right
+ * @return bool
+ */
+ public static function typeAction( $row, $type, $action, $right = '' ) {
+ $match = is_array( $type ) ?
+ in_array( $row->log_type, $type ) : $row->log_type == $type;
+ if ( $match ) {
+ $match = is_array( $action ) ?
+ in_array( $row->log_action, $action ) : $row->log_action == $action;
+ if ( $match && $right ) {
+ global $wgUser;
+ $match = $wgUser->isAllowed( $right );
+ }
+ }
+
+ return $match;
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this log row, if it's marked as deleted.
+ *
+ * @param stdClass $row Row
+ * @param int $field
+ * @param User $user User to check, or null to use $wgUser
+ * @return bool
+ */
+ public static function userCan( $row, $field, User $user = null ) {
+ return self::userCanBitfield( $row->log_deleted, $field, $user );
+ }
+
+ /**
+ * Determine if the current user is allowed to view a particular
+ * field of this log row, if it's marked as deleted.
+ *
+ * @param int $bitfield Current field
+ * @param int $field
+ * @param User $user User to check, or null to use $wgUser
+ * @return bool
+ */
+ public static function userCanBitfield( $bitfield, $field, User $user = null ) {
+ if ( $bitfield & $field ) {
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+ if ( $bitfield & LogPage::DELETED_RESTRICTED ) {
+ $permissions = [ 'suppressrevision', 'viewsuppressed' ];
+ } else {
+ $permissions = [ 'deletedhistory' ];
+ }
+ $permissionlist = implode( ', ', $permissions );
+ wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" );
+ return call_user_func_array( [ $user, 'isAllowedAny' ], $permissions );
+ }
+ return true;
+ }
+
+ /**
+ * @param stdClass $row Row
+ * @param int $field One of DELETED_* bitfield constants
+ * @return bool
+ */
+ public static function isDeleted( $row, $field ) {
+ return ( $row->log_deleted & $field ) == $field;
+ }
+
+ /**
+ * Show log extract. Either with text and a box (set $msgKey) or without (don't set $msgKey)
+ *
+ * @param OutputPage|string &$out
+ * @param string|array $types Log types to show
+ * @param string|Title $page The page title to show log entries for
+ * @param string $user The user who made the log entries
+ * @param array $param Associative Array with the following additional options:
+ * - lim Integer Limit of items to show, default is 50
+ * - conds Array Extra conditions for the query
+ * (e.g. 'log_action != ' . $dbr->addQuotes( 'revision' ))
+ * - showIfEmpty boolean Set to false if you don't want any output in case the loglist is empty
+ * if set to true (default), "No matching items in log" is displayed if loglist is empty
+ * - msgKey Array If you want a nice box with a message, set this to the key of the message.
+ * First element is the message key, additional optional elements are parameters for the key
+ * that are processed with wfMessage
+ * - offset Set to overwrite offset parameter in WebRequest
+ * set to '' to unset offset
+ * - wrap String Wrap the message in html (usually something like "<div ...>$1</div>").
+ * - flags Integer display flags (NO_ACTION_LINK,NO_EXTRA_USER_LINKS)
+ * - useRequestParams boolean Set true to use Pager-related parameters in the WebRequest
+ * - useMaster boolean Use master DB
+ * - extraUrlParams array|bool Additional url parameters for "full log" link (if it is shown)
+ * @return int Number of total log items (not limited by $lim)
+ */
+ public static function showLogExtract(
+ &$out, $types = [], $page = '', $user = '', $param = []
+ ) {
+ $defaultParameters = [
+ 'lim' => 25,
+ 'conds' => [],
+ 'showIfEmpty' => true,
+ 'msgKey' => [ '' ],
+ 'wrap' => "$1",
+ 'flags' => 0,
+ 'useRequestParams' => false,
+ 'useMaster' => false,
+ 'extraUrlParams' => false,
+ ];
+ # The + operator appends elements of remaining keys from the right
+ # handed array to the left handed, whereas duplicated keys are NOT overwritten.
+ $param += $defaultParameters;
+ # Convert $param array to individual variables
+ $lim = $param['lim'];
+ $conds = $param['conds'];
+ $showIfEmpty = $param['showIfEmpty'];
+ $msgKey = $param['msgKey'];
+ $wrap = $param['wrap'];
+ $flags = $param['flags'];
+ $extraUrlParams = $param['extraUrlParams'];
+
+ $useRequestParams = $param['useRequestParams'];
+ if ( !is_array( $msgKey ) ) {
+ $msgKey = [ $msgKey ];
+ }
+
+ if ( $out instanceof OutputPage ) {
+ $context = $out->getContext();
+ } else {
+ $context = RequestContext::getMain();
+ }
+
+ // FIXME: Figure out how to inject this
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ # Insert list of top 50 (or top $lim) items
+ $loglist = new LogEventsList( $context, $linkRenderer, $flags );
+ $pager = new LogPager( $loglist, $types, $user, $page, '', $conds );
+ if ( !$useRequestParams ) {
+ # Reset vars that may have been taken from the request
+ $pager->mLimit = 50;
+ $pager->mDefaultLimit = 50;
+ $pager->mOffset = "";
+ $pager->mIsBackwards = false;
+ }
+
+ if ( $param['useMaster'] ) {
+ $pager->mDb = wfGetDB( DB_MASTER );
+ }
+ if ( isset( $param['offset'] ) ) { # Tell pager to ignore WebRequest offset
+ $pager->setOffset( $param['offset'] );
+ }
+
+ if ( $lim > 0 ) {
+ $pager->mLimit = $lim;
+ }
+ // Fetch the log rows and build the HTML if needed
+ $logBody = $pager->getBody();
+ $numRows = $pager->getNumRows();
+
+ $s = '';
+
+ if ( $logBody ) {
+ if ( $msgKey[0] ) {
+ $dir = $context->getLanguage()->getDir();
+ $lang = $context->getLanguage()->getHtmlCode();
+
+ $s = Xml::openElement( 'div', [
+ 'class' => "mw-warning-with-logexcerpt mw-content-$dir",
+ 'dir' => $dir,
+ 'lang' => $lang,
+ ] );
+
+ if ( count( $msgKey ) == 1 ) {
+ $s .= $context->msg( $msgKey[0] )->parseAsBlock();
+ } else { // Process additional arguments
+ $args = $msgKey;
+ array_shift( $args );
+ $s .= $context->msg( $msgKey[0], $args )->parseAsBlock();
+ }
+ }
+ $s .= $loglist->beginLogEventsList() .
+ $logBody .
+ $loglist->endLogEventsList();
+ } elseif ( $showIfEmpty ) {
+ $s = Html::rawElement( 'div', [ 'class' => 'mw-warning-logempty' ],
+ $context->msg( 'logempty' )->parse() );
+ }
+
+ if ( $numRows > $pager->mLimit ) { # Show "Full log" link
+ $urlParam = [];
+ if ( $page instanceof Title ) {
+ $urlParam['page'] = $page->getPrefixedDBkey();
+ } elseif ( $page != '' ) {
+ $urlParam['page'] = $page;
+ }
+
+ if ( $user != '' ) {
+ $urlParam['user'] = $user;
+ }
+
+ if ( !is_array( $types ) ) { # Make it an array, if it isn't
+ $types = [ $types ];
+ }
+
+ # If there is exactly one log type, we can link to Special:Log?type=foo
+ if ( count( $types ) == 1 ) {
+ $urlParam['type'] = $types[0];
+ }
+
+ if ( $extraUrlParams !== false ) {
+ $urlParam = array_merge( $urlParam, $extraUrlParams );
+ }
+
+ $s .= $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Log' ),
+ $context->msg( 'log-fulllog' )->text(),
+ [],
+ $urlParam
+ );
+ }
+
+ if ( $logBody && $msgKey[0] ) {
+ $s .= '</div>';
+ }
+
+ if ( $wrap != '' ) { // Wrap message in html
+ $s = str_replace( '$1', $s, $wrap );
+ }
+
+ /* hook can return false, if we don't want the message to be emitted (Wikia BugId:7093) */
+ if ( Hooks::run( 'LogEventsListShowLogExtract', [ &$s, $types, $page, $user, $param ] ) ) {
+ // $out can be either an OutputPage object or a String-by-reference
+ if ( $out instanceof OutputPage ) {
+ $out->addHTML( $s );
+ } else {
+ $out = $s;
+ }
+ }
+
+ return $numRows;
+ }
+
+ /**
+ * SQL clause to skip forbidden log types for this user
+ *
+ * @param IDatabase $db
+ * @param string $audience Public/user
+ * @param User $user User to check, or null to use $wgUser
+ * @return string|bool String on success, false on failure.
+ */
+ public static function getExcludeClause( $db, $audience = 'public', User $user = null ) {
+ global $wgLogRestrictions;
+
+ if ( $audience != 'public' && $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ // Reset the array, clears extra "where" clauses when $par is used
+ $hiddenLogs = [];
+
+ // Don't show private logs to unprivileged users
+ foreach ( $wgLogRestrictions as $logType => $right ) {
+ if ( $audience == 'public' || !$user->isAllowed( $right ) ) {
+ $hiddenLogs[] = $logType;
+ }
+ }
+ if ( count( $hiddenLogs ) == 1 ) {
+ return 'log_type != ' . $db->addQuotes( $hiddenLogs[0] );
+ } elseif ( $hiddenLogs ) {
+ return 'log_type NOT IN (' . $db->makeList( $hiddenLogs ) . ')';
+ }
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/logging/LogFormatter.php b/www/wiki/includes/logging/LogFormatter.php
new file mode 100644
index 00000000..2a47943a
--- /dev/null
+++ b/www/wiki/includes/logging/LogFormatter.php
@@ -0,0 +1,989 @@
+<?php
+/**
+ * Contains classes for formatting log entries
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.19
+ */
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Implements the default log formatting.
+ *
+ * Can be overridden by subclassing and setting:
+ *
+ * $wgLogActionsHandlers['type/subtype'] = 'class'; or
+ * $wgLogActionsHandlers['type/*'] = 'class';
+ *
+ * @since 1.19
+ */
+class LogFormatter {
+ // Audience options for viewing usernames, comments, and actions
+ const FOR_PUBLIC = 1;
+ const FOR_THIS_USER = 2;
+
+ // Static->
+
+ /**
+ * Constructs a new formatter suitable for given entry.
+ * @param LogEntry $entry
+ * @return LogFormatter
+ */
+ public static function newFromEntry( LogEntry $entry ) {
+ global $wgLogActionsHandlers;
+ $fulltype = $entry->getFullType();
+ $wildcard = $entry->getType() . '/*';
+ $handler = '';
+
+ if ( isset( $wgLogActionsHandlers[$fulltype] ) ) {
+ $handler = $wgLogActionsHandlers[$fulltype];
+ } elseif ( isset( $wgLogActionsHandlers[$wildcard] ) ) {
+ $handler = $wgLogActionsHandlers[$wildcard];
+ }
+
+ if ( $handler !== '' && is_string( $handler ) && class_exists( $handler ) ) {
+ return new $handler( $entry );
+ }
+
+ return new LegacyLogFormatter( $entry );
+ }
+
+ /**
+ * Handy shortcut for constructing a formatter directly from
+ * database row.
+ * @param stdClass|array $row
+ * @see DatabaseLogEntry::getSelectQueryData
+ * @return LogFormatter
+ */
+ public static function newFromRow( $row ) {
+ return self::newFromEntry( DatabaseLogEntry::newFromRow( $row ) );
+ }
+
+ // Nonstatic->
+
+ /** @var LogEntryBase */
+ protected $entry;
+
+ /** @var int Constant for handling log_deleted */
+ protected $audience = self::FOR_PUBLIC;
+
+ /** @var IContextSource Context for logging */
+ public $context;
+
+ /** @var bool Whether to output user tool links */
+ protected $linkFlood = false;
+
+ /**
+ * Set to true if we are constructing a message text that is going to
+ * be included in page history or send to IRC feed. Links are replaced
+ * with plaintext or with [[pagename]] kind of syntax, that is parsed
+ * by page histories and IRC feeds.
+ * @var string
+ */
+ protected $plaintext = false;
+
+ /** @var string */
+ protected $irctext = false;
+
+ /**
+ * @var LinkRenderer|null
+ */
+ private $linkRenderer;
+
+ protected function __construct( LogEntry $entry ) {
+ $this->entry = $entry;
+ $this->context = RequestContext::getMain();
+ }
+
+ /**
+ * Replace the default context
+ * @param IContextSource $context
+ */
+ public function setContext( IContextSource $context ) {
+ $this->context = $context;
+ }
+
+ /**
+ * @since 1.30
+ * @param LinkRenderer $linkRenderer
+ */
+ public function setLinkRenderer( LinkRenderer $linkRenderer ) {
+ $this->linkRenderer = $linkRenderer;
+ }
+
+ /**
+ * @since 1.30
+ * @return LinkRenderer
+ */
+ public function getLinkRenderer() {
+ if ( $this->linkRenderer !== null ) {
+ return $this->linkRenderer;
+ } else {
+ return MediaWikiServices::getInstance()->getLinkRenderer();
+ }
+ }
+
+ /**
+ * Set the visibility restrictions for displaying content.
+ * If set to public, and an item is deleted, then it will be replaced
+ * with a placeholder even if the context user is allowed to view it.
+ * @param int $audience Const self::FOR_THIS_USER or self::FOR_PUBLIC
+ */
+ public function setAudience( $audience ) {
+ $this->audience = ( $audience == self::FOR_THIS_USER )
+ ? self::FOR_THIS_USER
+ : self::FOR_PUBLIC;
+ }
+
+ /**
+ * Check if a log item can be displayed
+ * @param int $field LogPage::DELETED_* constant
+ * @return bool
+ */
+ protected function canView( $field ) {
+ if ( $this->audience == self::FOR_THIS_USER ) {
+ return LogEventsList::userCanBitfield(
+ $this->entry->getDeleted(), $field, $this->context->getUser() );
+ } else {
+ return !$this->entry->isDeleted( $field );
+ }
+ }
+
+ /**
+ * If set to true, will produce user tool links after
+ * the user name. This should be replaced with generic
+ * CSS/JS solution.
+ * @param bool $value
+ */
+ public function setShowUserToolLinks( $value ) {
+ $this->linkFlood = $value;
+ }
+
+ /**
+ * Ugly hack to produce plaintext version of the message.
+ * Usually you also want to set extraneous request context
+ * to avoid formatting for any particular user.
+ * @see getActionText()
+ * @return string Plain text
+ */
+ public function getPlainActionText() {
+ $this->plaintext = true;
+ $text = $this->getActionText();
+ $this->plaintext = false;
+
+ return $text;
+ }
+
+ /**
+ * Even uglier hack to maintain backwards compatibilty with IRC bots
+ * (T36508).
+ * @see getActionText()
+ * @return string Text
+ */
+ public function getIRCActionComment() {
+ $actionComment = $this->getIRCActionText();
+ $comment = $this->entry->getComment();
+
+ if ( $comment != '' ) {
+ if ( $actionComment == '' ) {
+ $actionComment = $comment;
+ } else {
+ $actionComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $comment;
+ }
+ }
+
+ return $actionComment;
+ }
+
+ /**
+ * Even uglier hack to maintain backwards compatibilty with IRC bots
+ * (T36508).
+ * @see getActionText()
+ * @return string Text
+ */
+ public function getIRCActionText() {
+ global $wgContLang;
+
+ $this->plaintext = true;
+ $this->irctext = true;
+
+ $entry = $this->entry;
+ $parameters = $entry->getParameters();
+ // @see LogPage::actionText()
+ // Text of title the action is aimed at.
+ $target = $entry->getTarget()->getPrefixedText();
+ $text = null;
+ switch ( $entry->getType() ) {
+ case 'move':
+ switch ( $entry->getSubtype() ) {
+ case 'move':
+ $movesource = $parameters['4::target'];
+ $text = wfMessage( '1movedto2' )
+ ->rawParams( $target, $movesource )->inContentLanguage()->escaped();
+ break;
+ case 'move_redir':
+ $movesource = $parameters['4::target'];
+ $text = wfMessage( '1movedto2_redir' )
+ ->rawParams( $target, $movesource )->inContentLanguage()->escaped();
+ break;
+ case 'move-noredirect':
+ break;
+ case 'move_redir-noredirect':
+ break;
+ }
+ break;
+
+ case 'delete':
+ switch ( $entry->getSubtype() ) {
+ case 'delete':
+ $text = wfMessage( 'deletedarticle' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ case 'restore':
+ $text = wfMessage( 'undeletedarticle' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ // @codingStandardsIgnoreStart Long line
+ //case 'revision': // Revision deletion
+ //case 'event': // Log deletion
+ // see https://github.com/wikimedia/mediawiki/commit/a9c243b7b5289dad204278dbe7ed571fd914e395
+ //default:
+ // @codingStandardsIgnoreEnd
+ }
+ break;
+
+ case 'patrol':
+ // @codingStandardsIgnoreStart Long line
+ // https://github.com/wikimedia/mediawiki/commit/1a05f8faf78675dc85984f27f355b8825b43efff
+ // @codingStandardsIgnoreEnd
+ // Create a diff link to the patrolled revision
+ if ( $entry->getSubtype() === 'patrol' ) {
+ $diffLink = htmlspecialchars(
+ wfMessage( 'patrol-log-diff', $parameters['4::curid'] )
+ ->inContentLanguage()->text() );
+ $text = wfMessage( 'patrol-log-line', $diffLink, "[[$target]]", "" )
+ ->inContentLanguage()->text();
+ } else {
+ // broken??
+ }
+ break;
+
+ case 'protect':
+ switch ( $entry->getSubtype() ) {
+ case 'protect':
+ $text = wfMessage( 'protectedarticle' )
+ ->rawParams( $target . ' ' . $parameters['4::description'] )->inContentLanguage()->escaped();
+ break;
+ case 'unprotect':
+ $text = wfMessage( 'unprotectedarticle' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ case 'modify':
+ $text = wfMessage( 'modifiedarticleprotection' )
+ ->rawParams( $target . ' ' . $parameters['4::description'] )->inContentLanguage()->escaped();
+ break;
+ case 'move_prot':
+ $text = wfMessage( 'movedarticleprotection' )
+ ->rawParams( $target, $parameters['4::oldtitle'] )->inContentLanguage()->escaped();
+ break;
+ }
+ break;
+
+ case 'newusers':
+ switch ( $entry->getSubtype() ) {
+ case 'newusers':
+ case 'create':
+ $text = wfMessage( 'newuserlog-create-entry' )
+ ->inContentLanguage()->escaped();
+ break;
+ case 'create2':
+ case 'byemail':
+ $text = wfMessage( 'newuserlog-create2-entry' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ case 'autocreate':
+ $text = wfMessage( 'newuserlog-autocreate-entry' )
+ ->inContentLanguage()->escaped();
+ break;
+ }
+ break;
+
+ case 'upload':
+ switch ( $entry->getSubtype() ) {
+ case 'upload':
+ $text = wfMessage( 'uploadedimage' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ case 'overwrite':
+ $text = wfMessage( 'overwroteimage' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ }
+ break;
+
+ case 'rights':
+ if ( count( $parameters['4::oldgroups'] ) ) {
+ $oldgroups = implode( ', ', $parameters['4::oldgroups'] );
+ } else {
+ $oldgroups = wfMessage( 'rightsnone' )->inContentLanguage()->escaped();
+ }
+ if ( count( $parameters['5::newgroups'] ) ) {
+ $newgroups = implode( ', ', $parameters['5::newgroups'] );
+ } else {
+ $newgroups = wfMessage( 'rightsnone' )->inContentLanguage()->escaped();
+ }
+ switch ( $entry->getSubtype() ) {
+ case 'rights':
+ $text = wfMessage( 'rightslogentry' )
+ ->rawParams( $target, $oldgroups, $newgroups )->inContentLanguage()->escaped();
+ break;
+ case 'autopromote':
+ $text = wfMessage( 'rightslogentry-autopromote' )
+ ->rawParams( $target, $oldgroups, $newgroups )->inContentLanguage()->escaped();
+ break;
+ }
+ break;
+
+ case 'merge':
+ $text = wfMessage( 'pagemerge-logentry' )
+ ->rawParams( $target, $parameters['4::dest'], $parameters['5::mergepoint'] )
+ ->inContentLanguage()->escaped();
+ break;
+
+ case 'block':
+ switch ( $entry->getSubtype() ) {
+ case 'block':
+ // Keep compatibility with extensions by checking for
+ // new key (5::duration/6::flags) or old key (0/optional 1)
+ if ( $entry->isLegacy() ) {
+ $rawDuration = $parameters[0];
+ $rawFlags = isset( $parameters[1] ) ? $parameters[1] : '';
+ } else {
+ $rawDuration = $parameters['5::duration'];
+ $rawFlags = $parameters['6::flags'];
+ }
+ $duration = $wgContLang->translateBlockExpiry(
+ $rawDuration,
+ null,
+ wfTimestamp( TS_UNIX, $entry->getTimestamp() )
+ );
+ $flags = BlockLogFormatter::formatBlockFlags( $rawFlags, $wgContLang );
+ $text = wfMessage( 'blocklogentry' )
+ ->rawParams( $target, $duration, $flags )->inContentLanguage()->escaped();
+ break;
+ case 'unblock':
+ $text = wfMessage( 'unblocklogentry' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ case 'reblock':
+ $duration = $wgContLang->translateBlockExpiry(
+ $parameters['5::duration'],
+ null,
+ wfTimestamp( TS_UNIX, $entry->getTimestamp() )
+ );
+ $flags = BlockLogFormatter::formatBlockFlags( $parameters['6::flags'], $wgContLang );
+ $text = wfMessage( 'reblock-logentry' )
+ ->rawParams( $target, $duration, $flags )->inContentLanguage()->escaped();
+ break;
+ }
+ break;
+
+ case 'import':
+ switch ( $entry->getSubtype() ) {
+ case 'upload':
+ $text = wfMessage( 'import-logentry-upload' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ case 'interwiki':
+ $text = wfMessage( 'import-logentry-interwiki' )
+ ->rawParams( $target )->inContentLanguage()->escaped();
+ break;
+ }
+ break;
+ // case 'suppress' --private log -- aaron (so we know who to blame in a few years :-D)
+ // default:
+ }
+ if ( is_null( $text ) ) {
+ $text = $this->getPlainActionText();
+ }
+
+ $this->plaintext = false;
+ $this->irctext = false;
+
+ return $text;
+ }
+
+ /**
+ * Gets the log action, including username.
+ * @return string HTML
+ */
+ public function getActionText() {
+ if ( $this->canView( LogPage::DELETED_ACTION ) ) {
+ $element = $this->getActionMessage();
+ if ( $element instanceof Message ) {
+ $element = $this->plaintext ? $element->text() : $element->escaped();
+ }
+ if ( $this->entry->isDeleted( LogPage::DELETED_ACTION ) ) {
+ $element = $this->styleRestricedElement( $element );
+ }
+ } else {
+ $sep = $this->msg( 'word-separator' );
+ $sep = $this->plaintext ? $sep->text() : $sep->escaped();
+ $performer = $this->getPerformerElement();
+ $element = $performer . $sep . $this->getRestrictedElement( 'rev-deleted-event' );
+ }
+
+ return $element;
+ }
+
+ /**
+ * Returns a sentence describing the log action. Usually
+ * a Message object is returned, but old style log types
+ * and entries might return pre-escaped HTML string.
+ * @return Message|string Pre-escaped HTML
+ */
+ protected function getActionMessage() {
+ $message = $this->msg( $this->getMessageKey() );
+ $message->params( $this->getMessageParameters() );
+
+ return $message;
+ }
+
+ /**
+ * Returns a key to be used for formatting the action sentence.
+ * Default is logentry-TYPE-SUBTYPE for modern logs. Legacy log
+ * types will use custom keys, and subclasses can also alter the
+ * key depending on the entry itself.
+ * @return string Message key
+ */
+ protected function getMessageKey() {
+ $type = $this->entry->getType();
+ $subtype = $this->entry->getSubtype();
+
+ return "logentry-$type-$subtype";
+ }
+
+ /**
+ * Returns extra links that comes after the action text, like "revert", etc.
+ *
+ * @return string
+ */
+ public function getActionLinks() {
+ return '';
+ }
+
+ /**
+ * Extracts the optional extra parameters for use in action messages.
+ * The array indexes start from number 3.
+ * @return array
+ */
+ protected function extractParameters() {
+ $entry = $this->entry;
+ $params = [];
+
+ if ( $entry->isLegacy() ) {
+ foreach ( $entry->getParameters() as $index => $value ) {
+ $params[$index + 3] = $value;
+ }
+ }
+
+ // Filter out parameters which are not in format #:foo
+ foreach ( $entry->getParameters() as $key => $value ) {
+ if ( strpos( $key, ':' ) === false ) {
+ continue;
+ }
+ list( $index, $type, ) = explode( ':', $key, 3 );
+ if ( ctype_digit( $index ) ) {
+ $params[$index - 1] = $this->formatParameterValue( $type, $value );
+ }
+ }
+
+ /* Message class doesn't like non consecutive numbering.
+ * Fill in missing indexes with empty strings to avoid
+ * incorrect renumbering.
+ */
+ if ( count( $params ) ) {
+ $max = max( array_keys( $params ) );
+ // index 0 to 2 are added in getMessageParameters
+ for ( $i = 3; $i < $max; $i++ ) {
+ if ( !isset( $params[$i] ) ) {
+ $params[$i] = '';
+ }
+ }
+ }
+
+ return $params;
+ }
+
+ /**
+ * Formats parameters intented for action message from
+ * array of all parameters. There are three hardcoded
+ * parameters (array is zero-indexed, this list not):
+ * - 1: user name with premade link
+ * - 2: usable for gender magic function
+ * - 3: target page with premade link
+ * @return array
+ */
+ protected function getMessageParameters() {
+ if ( isset( $this->parsedParameters ) ) {
+ return $this->parsedParameters;
+ }
+
+ $entry = $this->entry;
+ $params = $this->extractParameters();
+ $params[0] = Message::rawParam( $this->getPerformerElement() );
+ $params[1] = $this->canView( LogPage::DELETED_USER ) ? $entry->getPerformer()->getName() : '';
+ $params[2] = Message::rawParam( $this->makePageLink( $entry->getTarget() ) );
+
+ // Bad things happens if the numbers are not in correct order
+ ksort( $params );
+
+ $this->parsedParameters = $params;
+ return $this->parsedParameters;
+ }
+
+ /**
+ * Formats parameters values dependent to their type
+ * @param string $type The type of the value.
+ * Valid are currently:
+ * * - (empty) or plain: The value is returned as-is
+ * * raw: The value will be added to the log message
+ * as raw parameter (e.g. no escaping)
+ * Use this only if there is no other working
+ * type like user-link or title-link
+ * * msg: The value is a message-key, the output is
+ * the message in user language
+ * * msg-content: The value is a message-key, the output
+ * is the message in content language
+ * * user: The value is a user name, e.g. for GENDER
+ * * user-link: The value is a user name, returns a
+ * link for the user
+ * * title: The value is a page title,
+ * returns name of page
+ * * title-link: The value is a page title,
+ * returns link to this page
+ * * number: Format value as number
+ * * list: Format value as a comma-separated list
+ * @param mixed $value The parameter value that should be formatted
+ * @return string|array Formated value
+ * @since 1.21
+ */
+ protected function formatParameterValue( $type, $value ) {
+ $saveLinkFlood = $this->linkFlood;
+
+ switch ( strtolower( trim( $type ) ) ) {
+ case 'raw':
+ $value = Message::rawParam( $value );
+ break;
+ case 'list':
+ $value = $this->context->getLanguage()->commaList( $value );
+ break;
+ case 'msg':
+ $value = $this->msg( $value )->text();
+ break;
+ case 'msg-content':
+ $value = $this->msg( $value )->inContentLanguage()->text();
+ break;
+ case 'number':
+ $value = Message::numParam( $value );
+ break;
+ case 'user':
+ $user = User::newFromName( $value );
+ $value = $user->getName();
+ break;
+ case 'user-link':
+ $this->setShowUserToolLinks( false );
+
+ $user = User::newFromName( $value );
+ $value = Message::rawParam( $this->makeUserLink( $user ) );
+
+ $this->setShowUserToolLinks( $saveLinkFlood );
+ break;
+ case 'title':
+ $title = Title::newFromText( $value );
+ $value = $title->getPrefixedText();
+ break;
+ case 'title-link':
+ $title = Title::newFromText( $value );
+ $value = Message::rawParam( $this->makePageLink( $title ) );
+ break;
+ case 'plain':
+ // Plain text, nothing to do
+ default:
+ // Catch other types and use the old behavior (return as-is)
+ }
+
+ return $value;
+ }
+
+ /**
+ * Helper to make a link to the page, taking the plaintext
+ * value in consideration.
+ * @param Title $title The page
+ * @param array $parameters Query parameters
+ * @param string|null $html Linktext of the link as raw html
+ * @throws MWException
+ * @return string
+ */
+ protected function makePageLink( Title $title = null, $parameters = [], $html = null ) {
+ if ( !$this->plaintext ) {
+ $link = Linker::link( $title, $html, [], $parameters );
+ } else {
+ if ( !$title instanceof Title ) {
+ throw new MWException( "Expected title, got null" );
+ }
+ $link = '[[' . $title->getPrefixedText() . ']]';
+ }
+
+ return $link;
+ }
+
+ /**
+ * Provides the name of the user who performed the log action.
+ * Used as part of log action message or standalone, depending
+ * which parts of the log entry has been hidden.
+ * @return string
+ */
+ public function getPerformerElement() {
+ if ( $this->canView( LogPage::DELETED_USER ) ) {
+ $performer = $this->entry->getPerformer();
+ $element = $this->makeUserLink( $performer );
+ if ( $this->entry->isDeleted( LogPage::DELETED_USER ) ) {
+ $element = $this->styleRestricedElement( $element );
+ }
+ } else {
+ $element = $this->getRestrictedElement( 'rev-deleted-user' );
+ }
+
+ return $element;
+ }
+
+ /**
+ * Gets the user provided comment
+ * @return string HTML
+ */
+ public function getComment() {
+ if ( $this->canView( LogPage::DELETED_COMMENT ) ) {
+ $comment = Linker::commentBlock( $this->entry->getComment() );
+ // No hard coded spaces thanx
+ $element = ltrim( $comment );
+ if ( $this->entry->isDeleted( LogPage::DELETED_COMMENT ) ) {
+ $element = $this->styleRestricedElement( $element );
+ }
+ } else {
+ $element = $this->getRestrictedElement( 'rev-deleted-comment' );
+ }
+
+ return $element;
+ }
+
+ /**
+ * Helper method for displaying restricted element.
+ * @param string $message
+ * @return string HTML or wiki text
+ */
+ protected function getRestrictedElement( $message ) {
+ if ( $this->plaintext ) {
+ return $this->msg( $message )->text();
+ }
+
+ $content = $this->msg( $message )->escaped();
+ $attribs = [ 'class' => 'history-deleted' ];
+
+ return Html::rawElement( 'span', $attribs, $content );
+ }
+
+ /**
+ * Helper method for styling restricted element.
+ * @param string $content
+ * @return string HTML or wiki text
+ */
+ protected function styleRestricedElement( $content ) {
+ if ( $this->plaintext ) {
+ return $content;
+ }
+ $attribs = [ 'class' => 'history-deleted' ];
+
+ return Html::rawElement( 'span', $attribs, $content );
+ }
+
+ /**
+ * Shortcut for wfMessage which honors local context.
+ * @param string $key
+ * @return Message
+ */
+ protected function msg( $key ) {
+ return $this->context->msg( $key );
+ }
+
+ protected function makeUserLink( User $user, $toolFlags = 0 ) {
+ if ( $this->plaintext ) {
+ $element = $user->getName();
+ } else {
+ $element = Linker::userLink(
+ $user->getId(),
+ $user->getName()
+ );
+
+ if ( $this->linkFlood ) {
+ $element .= Linker::userToolLinks(
+ $user->getId(),
+ $user->getName(),
+ true, // redContribsWhenNoEdits
+ $toolFlags,
+ $user->getEditCount()
+ );
+ }
+ }
+
+ return $element;
+ }
+
+ /**
+ * @return array Array of titles that should be preloaded with LinkBatch
+ */
+ public function getPreloadTitles() {
+ return [];
+ }
+
+ /**
+ * @return array Output of getMessageParameters() for testing
+ */
+ public function getMessageParametersForTesting() {
+ // This function was added because getMessageParameters() is
+ // protected and a change from protected to public caused
+ // problems with extensions
+ return $this->getMessageParameters();
+ }
+
+ /**
+ * Get the array of parameters, converted from legacy format if necessary.
+ * @since 1.25
+ * @return array
+ */
+ protected function getParametersForApi() {
+ return $this->entry->getParameters();
+ }
+
+ /**
+ * Format parameters for API output
+ *
+ * The result array should generally map named keys to values. Index and
+ * type should be omitted, e.g. "4::foo" should be returned as "foo" in the
+ * output. Values should generally be unformatted.
+ *
+ * Renames or removals of keys besides from the legacy numeric format to
+ * modern named style should be avoided. Any renames should be announced to
+ * the mediawiki-api-announce mailing list.
+ *
+ * @since 1.25
+ * @return array
+ */
+ public function formatParametersForApi() {
+ $logParams = [];
+ foreach ( $this->getParametersForApi() as $key => $value ) {
+ $vals = explode( ':', $key, 3 );
+ if ( count( $vals ) !== 3 ) {
+ $logParams[$key] = $value;
+ continue;
+ }
+ $logParams += $this->formatParameterValueForApi( $vals[2], $vals[1], $value );
+ }
+ ApiResult::setIndexedTagName( $logParams, 'param' );
+ ApiResult::setArrayType( $logParams, 'assoc' );
+
+ return $logParams;
+ }
+
+ /**
+ * Format a single parameter value for API output
+ *
+ * @since 1.25
+ * @param string $name
+ * @param string $type
+ * @param string $value
+ * @return array
+ */
+ protected function formatParameterValueForApi( $name, $type, $value ) {
+ $type = strtolower( trim( $type ) );
+ switch ( $type ) {
+ case 'bool':
+ $value = (bool)$value;
+ break;
+
+ case 'number':
+ if ( ctype_digit( $value ) || is_int( $value ) ) {
+ $value = (int)$value;
+ } else {
+ $value = (float)$value;
+ }
+ break;
+
+ case 'array':
+ case 'assoc':
+ case 'kvp':
+ if ( is_array( $value ) ) {
+ ApiResult::setArrayType( $value, $type );
+ }
+ break;
+
+ case 'timestamp':
+ $value = wfTimestamp( TS_ISO_8601, $value );
+ break;
+
+ case 'msg':
+ case 'msg-content':
+ $msg = $this->msg( $value );
+ if ( $type === 'msg-content' ) {
+ $msg->inContentLanguage();
+ }
+ $value = [];
+ $value["{$name}_key"] = $msg->getKey();
+ if ( $msg->getParams() ) {
+ $value["{$name}_params"] = $msg->getParams();
+ }
+ $value["{$name}_text"] = $msg->text();
+ return $value;
+
+ case 'title':
+ case 'title-link':
+ $title = Title::newFromText( $value );
+ if ( $title ) {
+ $value = [];
+ ApiQueryBase::addTitleInfo( $value, $title, "{$name}_" );
+ }
+ return $value;
+
+ case 'user':
+ case 'user-link':
+ $user = User::newFromName( $value );
+ if ( $user ) {
+ $value = $user->getName();
+ }
+ break;
+
+ default:
+ // do nothing
+ break;
+ }
+
+ return [ $name => $value ];
+ }
+}
+
+/**
+ * This class formats all log entries for log types
+ * which have not been converted to the new system.
+ * This is not about old log entries which store
+ * parameters in a different format - the new
+ * LogFormatter classes have code to support formatting
+ * those too.
+ * @since 1.19
+ */
+class LegacyLogFormatter extends LogFormatter {
+ /**
+ * Backward compatibility for extension changing the comment from
+ * the LogLine hook. This will be set by the first call on getComment(),
+ * then it might be modified by the hook when calling getActionLinks(),
+ * so that the modified value will be returned when calling getComment()
+ * a second time.
+ *
+ * @var string|null
+ */
+ private $comment = null;
+
+ /**
+ * Cache for the result of getActionLinks() so that it does not need to
+ * run multiple times depending on the order that getComment() and
+ * getActionLinks() are called.
+ *
+ * @var string|null
+ */
+ private $revert = null;
+
+ public function getComment() {
+ if ( $this->comment === null ) {
+ $this->comment = parent::getComment();
+ }
+
+ // Make sure we execute the LogLine hook so that we immediately return
+ // the correct value.
+ if ( $this->revert === null ) {
+ $this->getActionLinks();
+ }
+
+ return $this->comment;
+ }
+
+ protected function getActionMessage() {
+ $entry = $this->entry;
+ $action = LogPage::actionText(
+ $entry->getType(),
+ $entry->getSubtype(),
+ $entry->getTarget(),
+ $this->plaintext ? null : $this->context->getSkin(),
+ (array)$entry->getParameters(),
+ !$this->plaintext // whether to filter [[]] links
+ );
+
+ $performer = $this->getPerformerElement();
+ if ( !$this->irctext ) {
+ $sep = $this->msg( 'word-separator' );
+ $sep = $this->plaintext ? $sep->text() : $sep->escaped();
+ $action = $performer . $sep . $action;
+ }
+
+ return $action;
+ }
+
+ public function getActionLinks() {
+ if ( $this->revert !== null ) {
+ return $this->revert;
+ }
+
+ if ( $this->entry->isDeleted( LogPage::DELETED_ACTION ) ) {
+ $this->revert = '';
+ return $this->revert;
+ }
+
+ $title = $this->entry->getTarget();
+ $type = $this->entry->getType();
+ $subtype = $this->entry->getSubtype();
+
+ // Do nothing. The implementation is handled by the hook modifiying the
+ // passed-by-ref parameters. This also changes the default value so that
+ // getComment() and getActionLinks() do not call them indefinitely.
+ $this->revert = '';
+
+ // This is to populate the $comment member of this instance so that it
+ // can be modified when calling the hook just below.
+ if ( $this->comment === null ) {
+ $this->getComment();
+ }
+
+ $params = $this->entry->getParameters();
+
+ Hooks::run( 'LogLine', [ $type, $subtype, $title, $params,
+ &$this->comment, &$this->revert, $this->entry->getTimestamp() ] );
+
+ return $this->revert;
+ }
+}
diff --git a/www/wiki/includes/logging/LogPage.php b/www/wiki/includes/logging/LogPage.php
new file mode 100644
index 00000000..e4212092
--- /dev/null
+++ b/www/wiki/includes/logging/LogPage.php
@@ -0,0 +1,489 @@
+<?php
+/**
+ * Contain log classes
+ *
+ * Copyright © 2002, 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class to simplify the use of log pages.
+ * The logs are now kept in a table which is easier to manage and trim
+ * than ever-growing wiki pages.
+ */
+class LogPage {
+ const DELETED_ACTION = 1;
+ const DELETED_COMMENT = 2;
+ const DELETED_USER = 4;
+ const DELETED_RESTRICTED = 8;
+
+ // Convenience fields
+ const SUPPRESSED_USER = 12;
+ const SUPPRESSED_ACTION = 9;
+
+ /** @var bool */
+ public $updateRecentChanges;
+
+ /** @var bool */
+ public $sendToUDP;
+
+ /** @var string Plaintext version of the message for IRC */
+ private $ircActionText;
+
+ /** @var string Plaintext version of the message */
+ private $actionText;
+
+ /** @var string One of '', 'block', 'protect', 'rights', 'delete',
+ * 'upload', 'move'
+ */
+ private $type;
+
+ /** @var string One of '', 'block', 'protect', 'rights', 'delete',
+ * 'upload', 'move', 'move_redir' */
+ private $action;
+
+ /** @var string Comment associated with action */
+ private $comment;
+
+ /** @var string Blob made of a parameters array */
+ private $params;
+
+ /** @var User The user doing the action */
+ private $doer;
+
+ /** @var Title */
+ private $target;
+
+ /**
+ * @param string $type One of '', 'block', 'protect', 'rights', 'delete',
+ * 'upload', 'move'
+ * @param bool $rc Whether to update recent changes as well as the logging table
+ * @param string $udp Pass 'UDP' to send to the UDP feed if NOT sent to RC
+ */
+ public function __construct( $type, $rc = true, $udp = 'skipUDP' ) {
+ $this->type = $type;
+ $this->updateRecentChanges = $rc;
+ $this->sendToUDP = ( $udp == 'UDP' );
+ }
+
+ /**
+ * @return int The log_id of the inserted log entry
+ */
+ protected function saveContent() {
+ global $wgLogRestrictions;
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ // @todo FIXME private/protected/public property?
+ $this->timestamp = $now = wfTimestampNow();
+ $data = [
+ 'log_type' => $this->type,
+ 'log_action' => $this->action,
+ 'log_timestamp' => $dbw->timestamp( $now ),
+ 'log_user' => $this->doer->getId(),
+ 'log_user_text' => $this->doer->getName(),
+ 'log_namespace' => $this->target->getNamespace(),
+ 'log_title' => $this->target->getDBkey(),
+ 'log_page' => $this->target->getArticleID(),
+ 'log_params' => $this->params
+ ];
+ $data += CommentStore::newKey( 'log_comment' )->insert( $dbw, $this->comment );
+ $dbw->insert( 'logging', $data, __METHOD__ );
+ $newId = $dbw->insertId();
+
+ # And update recentchanges
+ if ( $this->updateRecentChanges ) {
+ $titleObj = SpecialPage::getTitleFor( 'Log', $this->type );
+
+ RecentChange::notifyLog(
+ $now, $titleObj, $this->doer, $this->getRcComment(), '',
+ $this->type, $this->action, $this->target, $this->comment,
+ $this->params, $newId, $this->getRcCommentIRC()
+ );
+ } elseif ( $this->sendToUDP ) {
+ # Don't send private logs to UDP
+ if ( isset( $wgLogRestrictions[$this->type] ) && $wgLogRestrictions[$this->type] != '*' ) {
+ return $newId;
+ }
+
+ # Notify external application via UDP.
+ # We send this to IRC but do not want to add it the RC table.
+ $titleObj = SpecialPage::getTitleFor( 'Log', $this->type );
+ $rc = RecentChange::newLogEntry(
+ $now, $titleObj, $this->doer, $this->getRcComment(), '',
+ $this->type, $this->action, $this->target, $this->comment,
+ $this->params, $newId, $this->getRcCommentIRC()
+ );
+ $rc->notifyRCFeeds();
+ }
+
+ return $newId;
+ }
+
+ /**
+ * Get the RC comment from the last addEntry() call
+ *
+ * @return string
+ */
+ public function getRcComment() {
+ $rcComment = $this->actionText;
+
+ if ( $this->comment != '' ) {
+ if ( $rcComment == '' ) {
+ $rcComment = $this->comment;
+ } else {
+ $rcComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() .
+ $this->comment;
+ }
+ }
+
+ return $rcComment;
+ }
+
+ /**
+ * Get the RC comment from the last addEntry() call for IRC
+ *
+ * @return string
+ */
+ public function getRcCommentIRC() {
+ $rcComment = $this->ircActionText;
+
+ if ( $this->comment != '' ) {
+ if ( $rcComment == '' ) {
+ $rcComment = $this->comment;
+ } else {
+ $rcComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() .
+ $this->comment;
+ }
+ }
+
+ return $rcComment;
+ }
+
+ /**
+ * Get the comment from the last addEntry() call
+ * @return string
+ */
+ public function getComment() {
+ return $this->comment;
+ }
+
+ /**
+ * Get the list of valid log types
+ *
+ * @return array Array of strings
+ */
+ public static function validTypes() {
+ global $wgLogTypes;
+
+ return $wgLogTypes;
+ }
+
+ /**
+ * Is $type a valid log type
+ *
+ * @param string $type Log type to check
+ * @return bool
+ */
+ public static function isLogType( $type ) {
+ return in_array( $type, self::validTypes() );
+ }
+
+ /**
+ * Generate text for a log entry.
+ * Only LogFormatter should call this function.
+ *
+ * @param string $type Log type
+ * @param string $action Log action
+ * @param Title|null $title Title object or null
+ * @param Skin|null $skin Skin object or null. If null, we want to use the wiki
+ * content language, since that will go to the IRC feed.
+ * @param array $params Parameters
+ * @param bool $filterWikilinks Whether to filter wiki links
+ * @return string HTML
+ */
+ public static function actionText( $type, $action, $title = null, $skin = null,
+ $params = [], $filterWikilinks = false
+ ) {
+ global $wgLang, $wgContLang, $wgLogActions;
+
+ if ( is_null( $skin ) ) {
+ $langObj = $wgContLang;
+ $langObjOrNull = null;
+ } else {
+ $langObj = $wgLang;
+ $langObjOrNull = $wgLang;
+ }
+
+ $key = "$type/$action";
+
+ if ( isset( $wgLogActions[$key] ) ) {
+ if ( is_null( $title ) ) {
+ $rv = wfMessage( $wgLogActions[$key] )->inLanguage( $langObj )->escaped();
+ } else {
+ $titleLink = self::getTitleLink( $type, $langObjOrNull, $title, $params );
+
+ if ( count( $params ) == 0 ) {
+ $rv = wfMessage( $wgLogActions[$key] )->rawParams( $titleLink )
+ ->inLanguage( $langObj )->escaped();
+ } else {
+ array_unshift( $params, $titleLink );
+
+ $rv = wfMessage( $wgLogActions[$key] )->rawParams( $params )
+ ->inLanguage( $langObj )->escaped();
+ }
+ }
+ } else {
+ global $wgLogActionsHandlers;
+
+ if ( isset( $wgLogActionsHandlers[$key] ) ) {
+ $args = func_get_args();
+ $rv = call_user_func_array( $wgLogActionsHandlers[$key], $args );
+ } else {
+ wfDebug( "LogPage::actionText - unknown action $key\n" );
+ $rv = "$action";
+ }
+ }
+
+ // For the perplexed, this feature was added in r7855 by Erik.
+ // The feature was added because we liked adding [[$1]] in our log entries
+ // but the log entries are parsed as Wikitext on RecentChanges but as HTML
+ // on Special:Log. The hack is essentially that [[$1]] represented a link
+ // to the title in question. The first parameter to the HTML version (Special:Log)
+ // is that link in HTML form, and so this just gets rid of the ugly [[]].
+ // However, this is a horrible hack and it doesn't work like you expect if, say,
+ // you want to link to something OTHER than the title of the log entry.
+ // The real problem, which Erik was trying to fix (and it sort-of works now) is
+ // that the same messages are being treated as both wikitext *and* HTML.
+ if ( $filterWikilinks ) {
+ $rv = str_replace( '[[', '', $rv );
+ $rv = str_replace( ']]', '', $rv );
+ }
+
+ return $rv;
+ }
+
+ /**
+ * @todo Document
+ * @param string $type
+ * @param Language|null $lang
+ * @param Title $title
+ * @param array &$params
+ * @return string
+ */
+ protected static function getTitleLink( $type, $lang, $title, &$params ) {
+ if ( !$lang ) {
+ return $title->getPrefixedText();
+ }
+
+ if ( $title->isSpecialPage() ) {
+ list( $name, $par ) = SpecialPageFactory::resolveAlias( $title->getDBkey() );
+
+ # Use the language name for log titles, rather than Log/X
+ if ( $name == 'Log' ) {
+ $logPage = new LogPage( $par );
+ $titleLink = Linker::link( $title, $logPage->getName()->escaped() );
+ $titleLink = wfMessage( 'parentheses' )
+ ->inLanguage( $lang )
+ ->rawParams( $titleLink )
+ ->escaped();
+ } else {
+ $titleLink = Linker::link( $title );
+ }
+ } else {
+ $titleLink = Linker::link( $title );
+ }
+
+ return $titleLink;
+ }
+
+ /**
+ * Add a log entry
+ *
+ * @param string $action One of '', 'block', 'protect', 'rights', 'delete',
+ * 'upload', 'move', 'move_redir'
+ * @param Title $target Title object
+ * @param string $comment Description associated
+ * @param array $params Parameters passed later to wfMessage function
+ * @param null|int|User $doer The user doing the action. null for $wgUser
+ *
+ * @return int The log_id of the inserted log entry
+ */
+ public function addEntry( $action, $target, $comment, $params = [], $doer = null ) {
+ if ( !is_array( $params ) ) {
+ $params = [ $params ];
+ }
+
+ if ( $comment === null ) {
+ $comment = '';
+ }
+
+ # Trim spaces on user supplied text
+ $comment = trim( $comment );
+
+ $this->action = $action;
+ $this->target = $target;
+ $this->comment = $comment;
+ $this->params = self::makeParamBlob( $params );
+
+ if ( $doer === null ) {
+ global $wgUser;
+ $doer = $wgUser;
+ } elseif ( !is_object( $doer ) ) {
+ $doer = User::newFromId( $doer );
+ }
+
+ $this->doer = $doer;
+
+ $logEntry = new ManualLogEntry( $this->type, $action );
+ $logEntry->setTarget( $target );
+ $logEntry->setPerformer( $doer );
+ $logEntry->setParameters( $params );
+ // All log entries using the LogPage to insert into the logging table
+ // are using the old logging system and therefore the legacy flag is
+ // needed to say the LogFormatter the parameters have numeric keys
+ $logEntry->setLegacy( true );
+
+ $formatter = LogFormatter::newFromEntry( $logEntry );
+ $context = RequestContext::newExtraneousContext( $target );
+ $formatter->setContext( $context );
+
+ $this->actionText = $formatter->getPlainActionText();
+ $this->ircActionText = $formatter->getIRCActionText();
+
+ return $this->saveContent();
+ }
+
+ /**
+ * Add relations to log_search table
+ *
+ * @param string $field
+ * @param array $values
+ * @param int $logid
+ * @return bool
+ */
+ public function addRelations( $field, $values, $logid ) {
+ if ( !strlen( $field ) || empty( $values ) ) {
+ return false; // nothing
+ }
+
+ $data = [];
+
+ foreach ( $values as $value ) {
+ $data[] = [
+ 'ls_field' => $field,
+ 'ls_value' => $value,
+ 'ls_log_id' => $logid
+ ];
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert( 'log_search', $data, __METHOD__, 'IGNORE' );
+
+ return true;
+ }
+
+ /**
+ * Create a blob from a parameter array
+ *
+ * @param array $params
+ * @return string
+ */
+ public static function makeParamBlob( $params ) {
+ return implode( "\n", $params );
+ }
+
+ /**
+ * Extract a parameter array from a blob
+ *
+ * @param string $blob
+ * @return array
+ */
+ public static function extractParams( $blob ) {
+ if ( $blob === '' ) {
+ return [];
+ } else {
+ return explode( "\n", $blob );
+ }
+ }
+
+ /**
+ * Name of the log.
+ * @return Message
+ * @since 1.19
+ */
+ public function getName() {
+ global $wgLogNames;
+
+ // BC
+ if ( isset( $wgLogNames[$this->type] ) ) {
+ $key = $wgLogNames[$this->type];
+ } else {
+ $key = 'log-name-' . $this->type;
+ }
+
+ return wfMessage( $key );
+ }
+
+ /**
+ * Description of this log type.
+ * @return Message
+ * @since 1.19
+ */
+ public function getDescription() {
+ global $wgLogHeaders;
+ // BC
+ if ( isset( $wgLogHeaders[$this->type] ) ) {
+ $key = $wgLogHeaders[$this->type];
+ } else {
+ $key = 'log-description-' . $this->type;
+ }
+
+ return wfMessage( $key );
+ }
+
+ /**
+ * Returns the right needed to read this log type.
+ * @return string
+ * @since 1.19
+ */
+ public function getRestriction() {
+ global $wgLogRestrictions;
+ if ( isset( $wgLogRestrictions[$this->type] ) ) {
+ $restriction = $wgLogRestrictions[$this->type];
+ } else {
+ // '' always returns true with $user->isAllowed()
+ $restriction = '';
+ }
+
+ return $restriction;
+ }
+
+ /**
+ * Tells if this log is not viewable by all.
+ * @return bool
+ * @since 1.19
+ */
+ public function isRestricted() {
+ $restriction = $this->getRestriction();
+
+ return $restriction !== '' && $restriction !== '*';
+ }
+}
diff --git a/www/wiki/includes/logging/LogPager.php b/www/wiki/includes/logging/LogPager.php
new file mode 100644
index 00000000..e62a7453
--- /dev/null
+++ b/www/wiki/includes/logging/LogPager.php
@@ -0,0 +1,438 @@
+<?php
+/**
+ * Contain classes to list log entries
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Pager
+ */
+class LogPager extends ReverseChronologicalPager {
+ /** @var array Log types */
+ private $types = [];
+
+ /** @var string Events limited to those by performer when set */
+ private $performer = '';
+
+ /** @var string|Title Events limited to those about Title when set */
+ private $title = '';
+
+ /** @var string */
+ private $pattern = '';
+
+ /** @var string */
+ private $typeCGI = '';
+
+ /** @var string */
+ private $action = '';
+
+ /** @var LogEventsList */
+ public $mLogEventsList;
+
+ /**
+ * @param LogEventsList $list
+ * @param string|array $types Log types to show
+ * @param string $performer The user who made the log entries
+ * @param string|Title $title The page title the log entries are for
+ * @param string $pattern Do a prefix search rather than an exact title match
+ * @param array $conds Extra conditions for the query
+ * @param int|bool $year The year to start from. Default: false
+ * @param int|bool $month The month to start from. Default: false
+ * @param string $tagFilter Tag
+ * @param string $action Specific action (subtype) requested
+ * @param int $logId Log entry ID, to limit to a single log entry.
+ */
+ public function __construct( $list, $types = [], $performer = '', $title = '',
+ $pattern = '', $conds = [], $year = false, $month = false, $tagFilter = '',
+ $action = '', $logId = false
+ ) {
+ parent::__construct( $list->getContext() );
+ $this->mConds = $conds;
+
+ $this->mLogEventsList = $list;
+
+ $this->limitType( $types ); // also excludes hidden types
+ $this->limitPerformer( $performer );
+ $this->limitTitle( $title, $pattern );
+ $this->limitAction( $action );
+ $this->getDateCond( $year, $month );
+ $this->mTagFilter = $tagFilter;
+ $this->limitLogId( $logId );
+
+ $this->mDb = wfGetDB( DB_REPLICA, 'logpager' );
+ }
+
+ public function getDefaultQuery() {
+ $query = parent::getDefaultQuery();
+ $query['type'] = $this->typeCGI; // arrays won't work here
+ $query['user'] = $this->performer;
+ $query['month'] = $this->mMonth;
+ $query['year'] = $this->mYear;
+
+ return $query;
+ }
+
+ // Call ONLY after calling $this->limitType() already!
+ public function getFilterParams() {
+ global $wgFilterLogTypes;
+ $filters = [];
+ if ( count( $this->types ) ) {
+ return $filters;
+ }
+ foreach ( $wgFilterLogTypes as $type => $default ) {
+ // Avoid silly filtering
+ if ( $type !== 'patrol' || $this->getUser()->useNPPatrol() ) {
+ $hide = $this->getRequest()->getInt( "hide_{$type}_log", $default );
+ $filters[$type] = $hide;
+ if ( $hide ) {
+ $this->mConds[] = 'log_type != ' . $this->mDb->addQuotes( $type );
+ }
+ }
+ }
+
+ return $filters;
+ }
+
+ /**
+ * Set the log reader to return only entries of the given type.
+ * Type restrictions enforced here
+ *
+ * @param string|array $types Log types ('upload', 'delete', etc);
+ * empty string means no restriction
+ */
+ private function limitType( $types ) {
+ global $wgLogRestrictions;
+
+ $user = $this->getUser();
+ // If $types is not an array, make it an array
+ $types = ( $types === '' ) ? [] : (array)$types;
+ // Don't even show header for private logs; don't recognize it...
+ $needReindex = false;
+ foreach ( $types as $type ) {
+ if ( isset( $wgLogRestrictions[$type] )
+ && !$user->isAllowed( $wgLogRestrictions[$type] )
+ ) {
+ $needReindex = true;
+ $types = array_diff( $types, [ $type ] );
+ }
+ }
+ if ( $needReindex ) {
+ // Lots of this code makes assumptions that
+ // the first entry in the array is $types[0].
+ $types = array_values( $types );
+ }
+ $this->types = $types;
+ // Don't show private logs to unprivileged users.
+ // Also, only show them upon specific request to avoid suprises.
+ $audience = $types ? 'user' : 'public';
+ $hideLogs = LogEventsList::getExcludeClause( $this->mDb, $audience, $user );
+ if ( $hideLogs !== false ) {
+ $this->mConds[] = $hideLogs;
+ }
+ if ( count( $types ) ) {
+ $this->mConds['log_type'] = $types;
+ // Set typeCGI; used in url param for paging
+ if ( count( $types ) == 1 ) {
+ $this->typeCGI = $types[0];
+ }
+ }
+ }
+
+ /**
+ * Set the log reader to return only entries by the given user.
+ *
+ * @param string $name (In)valid user name
+ * @return void
+ */
+ private function limitPerformer( $name ) {
+ if ( $name == '' ) {
+ return;
+ }
+ $usertitle = Title::makeTitleSafe( NS_USER, $name );
+ if ( is_null( $usertitle ) ) {
+ return;
+ }
+ // Normalize username first so that non-existent users used
+ // in maintenance scripts work
+ $name = $usertitle->getText();
+ /* Fetch userid at first, if known, provides awesome query plan afterwards */
+ $userid = User::idFromName( $name );
+ if ( !$userid ) {
+ $this->mConds['log_user_text'] = IP::sanitizeIP( $name );
+ } else {
+ $this->mConds['log_user'] = $userid;
+ }
+ // Paranoia: avoid brute force searches (T19342)
+ $user = $this->getUser();
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::DELETED_USER ) . ' = 0';
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::SUPPRESSED_USER ) .
+ ' != ' . LogPage::SUPPRESSED_USER;
+ }
+
+ $this->performer = $name;
+ }
+
+ /**
+ * Set the log reader to return only entries affecting the given page.
+ * (For the block and rights logs, this is a user page.)
+ *
+ * @param string|Title $page Title name
+ * @param string $pattern
+ * @return void
+ */
+ private function limitTitle( $page, $pattern ) {
+ global $wgMiserMode, $wgUserrightsInterwikiDelimiter;
+
+ if ( $page instanceof Title ) {
+ $title = $page;
+ } else {
+ $title = Title::newFromText( $page );
+ if ( strlen( $page ) == 0 || !$title instanceof Title ) {
+ return;
+ }
+ }
+
+ $this->title = $title->getPrefixedText();
+ $ns = $title->getNamespace();
+ $db = $this->mDb;
+
+ $doUserRightsLogLike = false;
+ if ( $this->types == [ 'rights' ] ) {
+ $parts = explode( $wgUserrightsInterwikiDelimiter, $title->getDBkey() );
+ if ( count( $parts ) == 2 ) {
+ list( $name, $database ) = array_map( 'trim', $parts );
+ if ( strstr( $database, '*' ) ) { // Search for wildcard in database name
+ $doUserRightsLogLike = true;
+ }
+ }
+ }
+
+ /**
+ * Using the (log_namespace, log_title, log_timestamp) index with a
+ * range scan (LIKE) on the first two parts, instead of simple equality,
+ * makes it unusable for sorting. Sorted retrieval using another index
+ * would be possible, but then we might have to scan arbitrarily many
+ * nodes of that index. Therefore, we need to avoid this if $wgMiserMode
+ * is on.
+ *
+ * This is not a problem with simple title matches, because then we can
+ * use the page_time index. That should have no more than a few hundred
+ * log entries for even the busiest pages, so it can be safely scanned
+ * in full to satisfy an impossible condition on user or similar.
+ */
+ $this->mConds['log_namespace'] = $ns;
+ if ( $doUserRightsLogLike ) {
+ $params = [ $name . $wgUserrightsInterwikiDelimiter ];
+ foreach ( explode( '*', $database ) as $databasepart ) {
+ $params[] = $databasepart;
+ $params[] = $db->anyString();
+ }
+ array_pop( $params ); // Get rid of the last % we added.
+ $this->mConds[] = 'log_title' . $db->buildLike( $params );
+ } elseif ( $pattern && !$wgMiserMode ) {
+ $this->mConds[] = 'log_title' . $db->buildLike( $title->getDBkey(), $db->anyString() );
+ $this->pattern = $pattern;
+ } else {
+ $this->mConds['log_title'] = $title->getDBkey();
+ }
+ // Paranoia: avoid brute force searches (T19342)
+ $user = $this->getUser();
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $this->mConds[] = $db->bitAnd( 'log_deleted', LogPage::DELETED_ACTION ) . ' = 0';
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $this->mConds[] = $db->bitAnd( 'log_deleted', LogPage::SUPPRESSED_ACTION ) .
+ ' != ' . LogPage::SUPPRESSED_ACTION;
+ }
+ }
+
+ /**
+ * Set the log_action field to a specified value (or values)
+ *
+ * @param string $action
+ */
+ private function limitAction( $action ) {
+ global $wgActionFilteredLogs;
+ // Allow to filter the log by actions
+ $type = $this->typeCGI;
+ if ( $type === '' ) {
+ // nothing to do
+ return;
+ }
+ $actions = $wgActionFilteredLogs;
+ if ( isset( $actions[$type] ) ) {
+ // log type can be filtered by actions
+ $this->mLogEventsList->setAllowedActions( array_keys( $actions[$type] ) );
+ if ( $action !== '' && isset( $actions[$type][$action] ) ) {
+ // add condition to query
+ $this->mConds['log_action'] = $actions[$type][$action];
+ $this->action = $action;
+ }
+ }
+ }
+
+ /**
+ * Limit to the (single) specified log ID.
+ * @param int $logId The log entry ID.
+ */
+ protected function limitLogId( $logId ) {
+ if ( !$logId ) {
+ return;
+ }
+ $this->mConds['log_id'] = $logId;
+ }
+
+ /**
+ * Constructs the most part of the query. Extra conditions are sprinkled in
+ * all over this class.
+ * @return array
+ */
+ public function getQueryInfo() {
+ $basic = DatabaseLogEntry::getSelectQueryData();
+
+ $tables = $basic['tables'];
+ $fields = $basic['fields'];
+ $conds = $basic['conds'];
+ $options = $basic['options'];
+ $joins = $basic['join_conds'];
+
+ # Add log_search table if there are conditions on it.
+ # This filters the results to only include log rows that have
+ # log_search records with the specified ls_field and ls_value values.
+ if ( array_key_exists( 'ls_field', $this->mConds ) ) {
+ $tables[] = 'log_search';
+ $options['IGNORE INDEX'] = [ 'log_search' => 'ls_log_id' ];
+ $options['USE INDEX'] = [ 'logging' => 'PRIMARY' ];
+ if ( !$this->hasEqualsClause( 'ls_field' )
+ || !$this->hasEqualsClause( 'ls_value' )
+ ) {
+ # Since (ls_field,ls_value,ls_logid) is unique, if the condition is
+ # to match a specific (ls_field,ls_value) tuple, then there will be
+ # no duplicate log rows. Otherwise, we need to remove the duplicates.
+ $options[] = 'DISTINCT';
+ }
+ }
+ # Don't show duplicate rows when using log_search
+ $joins['log_search'] = [ 'INNER JOIN', 'ls_log_id=log_id' ];
+
+ $info = [
+ 'tables' => $tables,
+ 'fields' => $fields,
+ 'conds' => array_merge( $conds, $this->mConds ),
+ 'options' => $options,
+ 'join_conds' => $joins,
+ ];
+ # Add ChangeTags filter query
+ ChangeTags::modifyDisplayQuery( $info['tables'], $info['fields'], $info['conds'],
+ $info['join_conds'], $info['options'], $this->mTagFilter );
+
+ return $info;
+ }
+
+ /**
+ * Checks if $this->mConds has $field matched to a *single* value
+ * @param string $field
+ * @return bool
+ */
+ protected function hasEqualsClause( $field ) {
+ return (
+ array_key_exists( $field, $this->mConds ) &&
+ ( !is_array( $this->mConds[$field] ) || count( $this->mConds[$field] ) == 1 )
+ );
+ }
+
+ function getIndexField() {
+ return 'log_timestamp';
+ }
+
+ public function getStartBody() {
+ # Do a link batch query
+ if ( $this->getNumRows() > 0 ) {
+ $lb = new LinkBatch;
+ foreach ( $this->mResult as $row ) {
+ $lb->add( $row->log_namespace, $row->log_title );
+ $lb->addObj( Title::makeTitleSafe( NS_USER, $row->user_name ) );
+ $lb->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->user_name ) );
+ $formatter = LogFormatter::newFromRow( $row );
+ foreach ( $formatter->getPreloadTitles() as $title ) {
+ $lb->addObj( $title );
+ }
+ }
+ $lb->execute();
+ $this->mResult->seek( 0 );
+ }
+
+ return '';
+ }
+
+ public function formatRow( $row ) {
+ return $this->mLogEventsList->logLine( $row );
+ }
+
+ public function getType() {
+ return $this->types;
+ }
+
+ /**
+ * Guaranteed to either return a valid title string or a Zero-Length String
+ *
+ * @return string
+ */
+ public function getPerformer() {
+ return $this->performer;
+ }
+
+ /**
+ * @return string
+ */
+ public function getPage() {
+ return $this->title;
+ }
+
+ public function getPattern() {
+ return $this->pattern;
+ }
+
+ public function getYear() {
+ return $this->mYear;
+ }
+
+ public function getMonth() {
+ return $this->mMonth;
+ }
+
+ public function getTagFilter() {
+ return $this->mTagFilter;
+ }
+
+ public function getAction() {
+ return $this->action;
+ }
+
+ public function doQuery() {
+ // Workaround MySQL optimizer bug
+ $this->mDb->setBigSelects();
+ parent::doQuery();
+ $this->mDb->setBigSelects( 'default' );
+ }
+}
diff --git a/www/wiki/includes/logging/MergeLogFormatter.php b/www/wiki/includes/logging/MergeLogFormatter.php
new file mode 100644
index 00000000..b0edd4c0
--- /dev/null
+++ b/www/wiki/includes/logging/MergeLogFormatter.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * Formatter for merge log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.25
+ */
+
+/**
+ * This class formats merge log entries.
+ *
+ * @since 1.25
+ */
+class MergeLogFormatter extends LogFormatter {
+ public function getPreloadTitles() {
+ $params = $this->extractParameters();
+
+ return [ Title::newFromText( $params[3] ) ];
+ }
+
+ protected function getMessageParameters() {
+ $params = parent::getMessageParameters();
+ $oldname = $this->makePageLink( $this->entry->getTarget(), [ 'redirect' => 'no' ] );
+ $newname = $this->makePageLink( Title::newFromText( $params[3] ) );
+ $params[2] = Message::rawParam( $oldname );
+ $params[3] = Message::rawParam( $newname );
+ $params[4] = $this->context->getLanguage()
+ ->userTimeAndDate( $params[4], $this->context->getUser() );
+ return $params;
+ }
+
+ public function getActionLinks() {
+ if ( $this->entry->isDeleted( LogPage::DELETED_ACTION ) // Action is hidden
+ || !$this->context->getUser()->isAllowed( 'mergehistory' )
+ ) {
+ return '';
+ }
+
+ // Show unmerge link
+ $params = $this->extractParameters();
+ $revert = Linker::linkKnown(
+ SpecialPage::getTitleFor( 'MergeHistory' ),
+ $this->msg( 'revertmerge' )->escaped(),
+ [],
+ [
+ 'target' => $params[3],
+ 'dest' => $this->entry->getTarget()->getPrefixedDBkey(),
+ 'mergepoint' => $params[4],
+ 'submitted' => 1 // show the revisions immediately
+ ]
+ );
+
+ return $this->msg( 'parentheses' )->rawParams( $revert )->escaped();
+ }
+
+ protected function getParametersForApi() {
+ $entry = $this->entry;
+ $params = $entry->getParameters();
+
+ static $map = [
+ '4:title:dest',
+ '5:timestamp:mergepoint',
+ '4::dest' => '4:title:dest',
+ '5::mergepoint' => '5:timestamp:mergepoint',
+ ];
+ foreach ( $map as $index => $key ) {
+ if ( isset( $params[$index] ) ) {
+ $params[$key] = $params[$index];
+ unset( $params[$index] );
+ }
+ }
+
+ return $params;
+ }
+}
diff --git a/www/wiki/includes/logging/MoveLogFormatter.php b/www/wiki/includes/logging/MoveLogFormatter.php
new file mode 100644
index 00000000..afbf8e95
--- /dev/null
+++ b/www/wiki/includes/logging/MoveLogFormatter.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * Formatter for move log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.22
+ */
+
+/**
+ * This class formats move log entries.
+ *
+ * @since 1.19
+ */
+class MoveLogFormatter extends LogFormatter {
+ public function getPreloadTitles() {
+ $params = $this->extractParameters();
+
+ return [ Title::newFromText( $params[3] ) ];
+ }
+
+ protected function getMessageKey() {
+ $key = parent::getMessageKey();
+ $params = $this->extractParameters();
+ if ( isset( $params[4] ) && $params[4] === '1' ) {
+ // Messages: logentry-move-move-noredirect, logentry-move-move_redir-noredirect
+ $key .= '-noredirect';
+ }
+
+ return $key;
+ }
+
+ protected function getMessageParameters() {
+ $params = parent::getMessageParameters();
+ $oldname = $this->makePageLink( $this->entry->getTarget(), [ 'redirect' => 'no' ] );
+ $newname = $this->makePageLink( Title::newFromText( $params[3] ) );
+ $params[2] = Message::rawParam( $oldname );
+ $params[3] = Message::rawParam( $newname );
+ unset( $params[4] ); // handled in getMessageKey
+
+ return $params;
+ }
+
+ public function getActionLinks() {
+ if ( $this->entry->isDeleted( LogPage::DELETED_ACTION ) // Action is hidden
+ || $this->entry->getSubtype() !== 'move'
+ || !$this->context->getUser()->isAllowed( 'move' )
+ ) {
+ return '';
+ }
+
+ $params = $this->extractParameters();
+ $destTitle = Title::newFromText( $params[3] );
+ if ( !$destTitle ) {
+ return '';
+ }
+
+ $revert = Linker::linkKnown(
+ SpecialPage::getTitleFor( 'Movepage' ),
+ $this->msg( 'revertmove' )->escaped(),
+ [],
+ [
+ 'wpOldTitle' => $destTitle->getPrefixedDBkey(),
+ 'wpNewTitle' => $this->entry->getTarget()->getPrefixedDBkey(),
+ 'wpReason' => $this->msg( 'revertmove' )->inContentLanguage()->text(),
+ 'wpMovetalk' => 0
+ ]
+ );
+
+ return $this->msg( 'parentheses' )->rawParams( $revert )->escaped();
+ }
+
+ protected function getParametersForApi() {
+ $entry = $this->entry;
+ $params = $entry->getParameters();
+
+ static $map = [
+ '4:title:target',
+ '5:bool:suppressredirect',
+ '4::target' => '4:title:target',
+ '5::noredir' => '5:bool:suppressredirect',
+ ];
+ foreach ( $map as $index => $key ) {
+ if ( isset( $params[$index] ) ) {
+ $params[$key] = $params[$index];
+ unset( $params[$index] );
+ }
+ }
+
+ if ( !isset( $params['5:bool:suppressredirect'] ) ) {
+ $params['5:bool:suppressredirect'] = false;
+ }
+
+ return $params;
+ }
+
+}
diff --git a/www/wiki/includes/logging/NewUsersLogFormatter.php b/www/wiki/includes/logging/NewUsersLogFormatter.php
new file mode 100644
index 00000000..382e4adb
--- /dev/null
+++ b/www/wiki/includes/logging/NewUsersLogFormatter.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Formatter for new user log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.22
+ */
+
+/**
+ * This class formats new user log entries.
+ *
+ * @since 1.19
+ */
+class NewUsersLogFormatter extends LogFormatter {
+ protected function getMessageParameters() {
+ $params = parent::getMessageParameters();
+ $subtype = $this->entry->getSubtype();
+ if ( $subtype === 'create2' || $subtype === 'byemail' ) {
+ if ( isset( $params[3] ) ) {
+ $target = User::newFromId( $params[3] );
+ } else {
+ $target = User::newFromName( $this->entry->getTarget()->getText(), false );
+ }
+ $params[2] = Message::rawParam( $this->makeUserLink( $target ) );
+ $params[3] = $target->getName();
+ }
+
+ return $params;
+ }
+
+ public function getComment() {
+ $timestamp = wfTimestamp( TS_MW, $this->entry->getTimestamp() );
+ if ( $timestamp < '20080129000000' ) {
+ # Suppress $comment from old entries (before 2008-01-29),
+ # not needed and can contain incorrect links
+ return '';
+ }
+
+ return parent::getComment();
+ }
+
+ public function getPreloadTitles() {
+ $subtype = $this->entry->getSubtype();
+ if ( $subtype === 'create2' || $subtype === 'byemail' ) {
+ // add the user talk to LinkBatch for the userLink
+ return [ Title::makeTitle( NS_USER_TALK, $this->entry->getTarget()->getText() ) ];
+ }
+
+ return [];
+ }
+}
diff --git a/www/wiki/includes/logging/PageLangLogFormatter.php b/www/wiki/includes/logging/PageLangLogFormatter.php
new file mode 100644
index 00000000..694fa7f3
--- /dev/null
+++ b/www/wiki/includes/logging/PageLangLogFormatter.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Formatter for changelang log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Kunal Grover
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.24
+ */
+
+/**
+ * This class formats language change log entries.
+ *
+ * @since 1.24
+ */
+class PageLangLogFormatter extends LogFormatter {
+ protected function getMessageParameters() {
+ // Get the user language for displaying language names
+ $userLang = $this->context->getLanguage()->getCode();
+ $params = parent::getMessageParameters();
+
+ // Get the language codes from log
+ $oldLang = $params[3];
+ $kOld = strrpos( $oldLang, '[' );
+ if ( $kOld ) {
+ $oldLang = substr( $oldLang, 0, $kOld );
+ }
+
+ $newLang = $params[4];
+ $kNew = strrpos( $newLang, '[' );
+ if ( $kNew ) {
+ $newLang = substr( $newLang, 0, $kNew );
+ }
+
+ // Convert language codes to names in user language
+ $logOld = Language::fetchLanguageName( $oldLang, $userLang )
+ . ' (' . $oldLang . ')';
+ $logNew = Language::fetchLanguageName( $newLang, $userLang )
+ . ' (' . $newLang . ')';
+
+ // Add the default message to languages if required
+ $params[3] = !$kOld ? $logOld : $logOld . ' [' . $this->msg( 'default' ) . ']';
+ $params[4] = !$kNew ? $logNew : $logNew . ' [' . $this->msg( 'default' ) . ']';
+ return $params;
+ }
+}
diff --git a/www/wiki/includes/logging/PatrolLog.php b/www/wiki/includes/logging/PatrolLog.php
new file mode 100644
index 00000000..d1de2cd3
--- /dev/null
+++ b/www/wiki/includes/logging/PatrolLog.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Specific methods for the patrol log.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Rob Church <robchur@gmail.com>
+ * @author Niklas Laxström
+ */
+
+/**
+ * Class containing static functions for working with
+ * logs of patrol events
+ */
+class PatrolLog {
+ /**
+ * Record a log event for a change being patrolled
+ *
+ * @param int|RecentChange $rc Change identifier or RecentChange object
+ * @param bool $auto Was this patrol event automatic?
+ * @param User $user User performing the action or null to use $wgUser
+ * @param string|string[] $tags Change tags to add to the patrol log entry
+ * ($user should be able to add the specified tags before this is called)
+ *
+ * @return bool
+ */
+ public static function record( $rc, $auto = false, User $user = null, $tags = null ) {
+ global $wgLogAutopatrol;
+
+ // do not log autopatrolled edits if setting disables it
+ if ( $auto && !$wgLogAutopatrol ) {
+ return false;
+ }
+
+ if ( !$rc instanceof RecentChange ) {
+ $rc = RecentChange::newFromId( $rc );
+ if ( !is_object( $rc ) ) {
+ return false;
+ }
+ }
+
+ if ( !$user ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ $action = $auto ? 'autopatrol' : 'patrol';
+
+ $entry = new ManualLogEntry( 'patrol', $action );
+ $entry->setTarget( $rc->getTitle() );
+ $entry->setParameters( self::buildParams( $rc, $auto ) );
+ $entry->setPerformer( $user );
+ $entry->setTags( $tags );
+ $logid = $entry->insert();
+ if ( !$auto ) {
+ $entry->publish( $logid, 'udp' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Prepare log parameters for a patrolled change
+ *
+ * @param RecentChange $change RecentChange to represent
+ * @param bool $auto Whether the patrol event was automatic
+ * @return array
+ */
+ private static function buildParams( $change, $auto ) {
+ return [
+ '4::curid' => $change->getAttribute( 'rc_this_oldid' ),
+ '5::previd' => $change->getAttribute( 'rc_last_oldid' ),
+ '6::auto' => (int)$auto
+ ];
+ }
+}
diff --git a/www/wiki/includes/logging/PatrolLogFormatter.php b/www/wiki/includes/logging/PatrolLogFormatter.php
new file mode 100644
index 00000000..bbd8badc
--- /dev/null
+++ b/www/wiki/includes/logging/PatrolLogFormatter.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Formatter for new user log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.22
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This class formats patrol log entries.
+ *
+ * @since 1.19
+ */
+class PatrolLogFormatter extends LogFormatter {
+ protected function getMessageKey() {
+ $params = $this->getMessageParameters();
+ if ( isset( $params[5] ) && $params[5] ) {
+ $key = 'logentry-patrol-patrol-auto';
+ } else {
+ $key = 'logentry-patrol-patrol';
+ }
+
+ return $key;
+ }
+
+ protected function getMessageParameters() {
+ $params = parent::getMessageParameters();
+
+ $target = $this->entry->getTarget();
+ $oldid = $params[3];
+ $revision = $this->context->getLanguage()->formatNum( $oldid, true );
+
+ if ( $this->plaintext ) {
+ $revlink = $revision;
+ } elseif ( $target->exists() ) {
+ $query = [
+ 'oldid' => $oldid,
+ 'diff' => 'prev'
+ ];
+ $revlink = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
+ $target, $revision, [], $query );
+ } else {
+ $revlink = htmlspecialchars( $revision );
+ }
+
+ $params[3] = Message::rawParam( $revlink );
+
+ return $params;
+ }
+
+ protected function getParametersForApi() {
+ $entry = $this->entry;
+ $params = $entry->getParameters();
+
+ static $map = [
+ '4:number:curid',
+ '5:number:previd',
+ '6:bool:auto',
+ '4::curid' => '4:number:curid',
+ '5::previd' => '5:number:previd',
+ '6::auto' => '6:bool:auto',
+ ];
+ foreach ( $map as $index => $key ) {
+ if ( isset( $params[$index] ) ) {
+ $params[$key] = $params[$index];
+ unset( $params[$index] );
+ }
+ }
+
+ return $params;
+ }
+}
diff --git a/www/wiki/includes/logging/ProtectLogFormatter.php b/www/wiki/includes/logging/ProtectLogFormatter.php
new file mode 100644
index 00000000..9e5eea54
--- /dev/null
+++ b/www/wiki/includes/logging/ProtectLogFormatter.php
@@ -0,0 +1,215 @@
+<?php
+/**
+ * Formatter for protect log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.26
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This class formats protect log entries.
+ *
+ * @since 1.26
+ */
+class ProtectLogFormatter extends LogFormatter {
+ public function getPreloadTitles() {
+ $subtype = $this->entry->getSubtype();
+ if ( $subtype === 'move_prot' ) {
+ $params = $this->extractParameters();
+ return [ Title::newFromText( $params[3] ) ];
+ }
+ return [];
+ }
+
+ protected function getMessageKey() {
+ $key = parent::getMessageKey();
+ $params = $this->extractParameters();
+ if ( isset( $params[4] ) && $params[4] ) {
+ // Messages: logentry-protect-protect-cascade, logentry-protect-modify-cascade
+ $key .= '-cascade';
+ }
+
+ return $key;
+ }
+
+ protected function getMessageParameters() {
+ $params = parent::getMessageParameters();
+
+ $subtype = $this->entry->getSubtype();
+ if ( $subtype === 'protect' || $subtype === 'modify' ) {
+ $rawParams = $this->entry->getParameters();
+ if ( isset( $rawParams['details'] ) ) {
+ $params[3] = $this->createProtectDescription( $rawParams['details'] );
+ } elseif ( isset( $params[3] ) ) {
+ // Old way of Restrictions and expiries
+ $params[3] = $this->context->getLanguage()->getDirMark() . $params[3];
+ } else {
+ // Very old way (nothing set)
+ $params[3] = '';
+ }
+ // Cascading flag
+ if ( isset( $params[4] ) ) {
+ // handled in getMessageKey
+ unset( $params[4] );
+ }
+ } elseif ( $subtype === 'move_prot' ) {
+ $oldname = $this->makePageLink( Title::newFromText( $params[3] ), [ 'redirect' => 'no' ] );
+ $params[3] = Message::rawParam( $oldname );
+ }
+
+ return $params;
+ }
+
+ public function getActionLinks() {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ $subtype = $this->entry->getSubtype();
+ if ( $this->entry->isDeleted( LogPage::DELETED_ACTION ) // Action is hidden
+ || $subtype === 'move_prot' // the move log entry has the right action link
+ ) {
+ return '';
+ }
+
+ // Show history link for all changes after the protection
+ $title = $this->entry->getTarget();
+ $links = [
+ $linkRenderer->makeLink( $title,
+ $this->msg( 'hist' )->text(),
+ [],
+ [
+ 'action' => 'history',
+ 'offset' => $this->entry->getTimestamp(),
+ ]
+ )
+ ];
+
+ // Show change protection link
+ if ( $this->context->getUser()->isAllowed( 'protect' ) ) {
+ $links[] = $linkRenderer->makeKnownLink(
+ $title,
+ $this->msg( 'protect_change' )->text(),
+ [],
+ [ 'action' => 'protect' ]
+ );
+ }
+
+ return $this->msg( 'parentheses' )->rawParams(
+ $this->context->getLanguage()->pipeList( $links ) )->escaped();
+ }
+
+ protected function getParametersForApi() {
+ $entry = $this->entry;
+ $subtype = $this->entry->getSubtype();
+ $params = $entry->getParameters();
+
+ $map = [];
+ if ( $subtype === 'protect' || $subtype === 'modify' ) {
+ $map = [
+ '4::description',
+ '5:bool:cascade',
+ 'details' => ':array:details',
+ ];
+ } elseif ( $subtype === 'move_prot' ) {
+ $map = [
+ '4:title:oldtitle',
+ '4::oldtitle' => '4:title:oldtitle',
+ ];
+ }
+ foreach ( $map as $index => $key ) {
+ if ( isset( $params[$index] ) ) {
+ $params[$key] = $params[$index];
+ unset( $params[$index] );
+ }
+ }
+
+ // Change string to explicit boolean
+ if ( isset( $params['5:bool:cascade'] ) && is_string( $params['5:bool:cascade'] ) ) {
+ $params['5:bool:cascade'] = $params['5:bool:cascade'] === 'cascade';
+ }
+
+ return $params;
+ }
+
+ public function formatParametersForApi() {
+ global $wgContLang;
+
+ $ret = parent::formatParametersForApi();
+ if ( isset( $ret['details'] ) && is_array( $ret['details'] ) ) {
+ foreach ( $ret['details'] as &$detail ) {
+ if ( isset( $detail['expiry'] ) ) {
+ $detail['expiry'] = $wgContLang->formatExpiry( $detail['expiry'], TS_ISO_8601, 'infinite' );
+ }
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Create the protect description to show in the log formatter
+ *
+ * @param array $details
+ * @return string
+ */
+ public function createProtectDescription( array $details ) {
+ $protectDescription = '';
+
+ foreach ( $details as $param ) {
+ $expiryText = $this->formatExpiry( $param['expiry'] );
+
+ // Messages: restriction-edit, restriction-move, restriction-create,
+ // restriction-upload
+ $action = $this->context->msg( 'restriction-' . $param['type'] )->escaped();
+
+ $protectionLevel = $param['level'];
+ // Messages: protect-level-autoconfirmed, protect-level-sysop
+ $message = $this->context->msg( 'protect-level-' . $protectionLevel );
+ if ( $message->isDisabled() ) {
+ // Require "$1" permission
+ $restrictions = $this->context->msg( "protect-fallback", $protectionLevel )->parse();
+ } else {
+ $restrictions = $message->escaped();
+ }
+
+ if ( $protectDescription !== '' ) {
+ $protectDescription .= $this->context->msg( 'word-separator' )->escaped();
+ }
+
+ $protectDescription .= $this->context->msg( 'protect-summary-desc' )
+ ->params( $action, $restrictions, $expiryText )->escaped();
+ }
+
+ return $protectDescription;
+ }
+
+ private function formatExpiry( $expiry ) {
+ if ( wfIsInfinity( $expiry ) ) {
+ return $this->context->msg( 'protect-expiry-indefinite' )->text();
+ }
+ $lang = $this->context->getLanguage();
+ $user = $this->context->getUser();
+ return $this->context->msg(
+ 'protect-expiring-local',
+ $lang->userTimeAndDate( $expiry, $user ),
+ $lang->userDate( $expiry, $user ),
+ $lang->userTime( $expiry, $user )
+ )->text();
+ }
+
+}
diff --git a/www/wiki/includes/logging/RightsLogFormatter.php b/www/wiki/includes/logging/RightsLogFormatter.php
new file mode 100644
index 00000000..4b4d19f4
--- /dev/null
+++ b/www/wiki/includes/logging/RightsLogFormatter.php
@@ -0,0 +1,240 @@
+<?php
+/**
+ * Formatter for user rights log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Alexandre Emsenhuber
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.22
+ */
+
+/**
+ * This class formats rights log entries.
+ *
+ * @since 1.21
+ */
+class RightsLogFormatter extends LogFormatter {
+ protected function makePageLink( Title $title = null, $parameters = [], $html = null ) {
+ global $wgContLang, $wgUserrightsInterwikiDelimiter;
+
+ if ( !$this->plaintext ) {
+ $text = $wgContLang->ucfirst( $title->getDBkey() );
+ $parts = explode( $wgUserrightsInterwikiDelimiter, $text, 2 );
+
+ if ( count( $parts ) === 2 ) {
+ $titleLink = WikiMap::foreignUserLink(
+ $parts[1],
+ $parts[0],
+ htmlspecialchars(
+ strtr( $parts[0], '_', ' ' ) .
+ $wgUserrightsInterwikiDelimiter .
+ $parts[1]
+ )
+ );
+
+ if ( $titleLink !== false ) {
+ return $titleLink;
+ }
+ }
+ }
+
+ return parent::makePageLink( $title, $parameters, $title ? $title->getText() : null );
+ }
+
+ protected function getMessageKey() {
+ $key = parent::getMessageKey();
+ $params = $this->getMessageParameters();
+ if ( !isset( $params[3] ) && !isset( $params[4] ) ) {
+ // Messages: logentry-rights-rights-legacy
+ $key .= '-legacy';
+ }
+
+ return $key;
+ }
+
+ protected function getMessageParameters() {
+ $params = parent::getMessageParameters();
+
+ // Really old entries that lack old/new groups
+ if ( !isset( $params[3] ) && !isset( $params[4] ) ) {
+ return $params;
+ }
+
+ $oldGroups = $this->makeGroupArray( $params[3] );
+ $newGroups = $this->makeGroupArray( $params[4] );
+
+ $userName = $this->entry->getTarget()->getText();
+ if ( !$this->plaintext && count( $oldGroups ) ) {
+ foreach ( $oldGroups as &$group ) {
+ $group = UserGroupMembership::getGroupMemberName( $group, $userName );
+ }
+ }
+ if ( !$this->plaintext && count( $newGroups ) ) {
+ foreach ( $newGroups as &$group ) {
+ $group = UserGroupMembership::getGroupMemberName( $group, $userName );
+ }
+ }
+
+ // fetch the metadata about each group membership
+ $allParams = $this->entry->getParameters();
+
+ if ( count( $oldGroups ) ) {
+ $params[3] = [ 'raw' => $this->formatRightsList( $oldGroups,
+ isset( $allParams['oldmetadata'] ) ? $allParams['oldmetadata'] : [] ) ];
+ } else {
+ $params[3] = $this->msg( 'rightsnone' )->text();
+ }
+ if ( count( $newGroups ) ) {
+ // Array_values is used here because of T44211
+ // see use of array_unique in UserrightsPage::doSaveUserGroups on $newGroups.
+ $params[4] = [ 'raw' => $this->formatRightsList( array_values( $newGroups ),
+ isset( $allParams['newmetadata'] ) ? $allParams['newmetadata'] : [] ) ];
+ } else {
+ $params[4] = $this->msg( 'rightsnone' )->text();
+ }
+
+ $params[5] = $userName;
+
+ return $params;
+ }
+
+ protected function formatRightsList( $groups, $serializedUGMs = [] ) {
+ $uiLanguage = $this->context->getLanguage();
+ $uiUser = $this->context->getUser();
+ // separate arrays of temporary and permanent memberships
+ $tempList = $permList = [];
+
+ reset( $groups );
+ reset( $serializedUGMs );
+ while ( current( $groups ) ) {
+ $group = current( $groups );
+
+ if ( current( $serializedUGMs ) &&
+ isset( current( $serializedUGMs )['expiry'] ) &&
+ current( $serializedUGMs )['expiry']
+ ) {
+ // there is an expiry date; format the group and expiry into a friendly string
+ $expiry = current( $serializedUGMs )['expiry'];
+ $expiryFormatted = $uiLanguage->userTimeAndDate( $expiry, $uiUser );
+ $expiryFormattedD = $uiLanguage->userDate( $expiry, $uiUser );
+ $expiryFormattedT = $uiLanguage->userTime( $expiry, $uiUser );
+ $tempList[] = $this->msg( 'rightslogentry-temporary-group' )->params( $group,
+ $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->parse();
+ } else {
+ // the right does not expire; just insert the group name
+ $permList[] = $group;
+ }
+
+ next( $groups );
+ next( $serializedUGMs );
+ }
+
+ // place all temporary memberships first, to avoid the ambiguity of
+ // "adinistrator, bureaucrat and importer (temporary, until X time)"
+ return $uiLanguage->listToText( array_merge( $tempList, $permList ) );
+ }
+
+ protected function getParametersForApi() {
+ $entry = $this->entry;
+ $params = $entry->getParameters();
+
+ static $map = [
+ '4:array:oldgroups',
+ '5:array:newgroups',
+ '4::oldgroups' => '4:array:oldgroups',
+ '5::newgroups' => '5:array:newgroups',
+ ];
+ foreach ( $map as $index => $key ) {
+ if ( isset( $params[$index] ) ) {
+ $params[$key] = $params[$index];
+ unset( $params[$index] );
+ }
+ }
+
+ // Really old entries do not have log params, so form them from whatever info
+ // we have.
+ // Also walk through the parallel arrays of groups and metadata, combining each
+ // metadata array with the name of the group it pertains to
+ if ( isset( $params['4:array:oldgroups'] ) ) {
+ $params['4:array:oldgroups'] = $this->makeGroupArray( $params['4:array:oldgroups'] );
+
+ $oldmetadata =& $params['oldmetadata'];
+ // unset old metadata entry to ensure metadata goes at the end of the params array
+ unset( $params['oldmetadata'] );
+ $params['oldmetadata'] = array_map( function ( $index ) use ( $params, $oldmetadata ) {
+ $result = [ 'group' => $params['4:array:oldgroups'][$index] ];
+ if ( isset( $oldmetadata[$index] ) ) {
+ $result += $oldmetadata[$index];
+ }
+ $result['expiry'] = ApiResult::formatExpiry( isset( $result['expiry'] ) ?
+ $result['expiry'] : null );
+
+ return $result;
+ }, array_keys( $params['4:array:oldgroups'] ) );
+ }
+
+ if ( isset( $params['5:array:newgroups'] ) ) {
+ $params['5:array:newgroups'] = $this->makeGroupArray( $params['5:array:newgroups'] );
+
+ $newmetadata =& $params['newmetadata'];
+ // unset old metadata entry to ensure metadata goes at the end of the params array
+ unset( $params['newmetadata'] );
+ $params['newmetadata'] = array_map( function ( $index ) use ( $params, $newmetadata ) {
+ $result = [ 'group' => $params['5:array:newgroups'][$index] ];
+ if ( isset( $newmetadata[$index] ) ) {
+ $result += $newmetadata[$index];
+ }
+ $result['expiry'] = ApiResult::formatExpiry( isset( $result['expiry'] ) ?
+ $result['expiry'] : null );
+
+ return $result;
+ }, array_keys( $params['5:array:newgroups'] ) );
+ }
+
+ return $params;
+ }
+
+ public function formatParametersForApi() {
+ $ret = parent::formatParametersForApi();
+ if ( isset( $ret['oldgroups'] ) ) {
+ ApiResult::setIndexedTagName( $ret['oldgroups'], 'g' );
+ }
+ if ( isset( $ret['newgroups'] ) ) {
+ ApiResult::setIndexedTagName( $ret['newgroups'], 'g' );
+ }
+ if ( isset( $ret['oldmetadata'] ) ) {
+ ApiResult::setArrayType( $ret['oldmetadata'], 'array' );
+ ApiResult::setIndexedTagName( $ret['oldmetadata'], 'g' );
+ }
+ if ( isset( $ret['newmetadata'] ) ) {
+ ApiResult::setArrayType( $ret['newmetadata'], 'array' );
+ ApiResult::setIndexedTagName( $ret['newmetadata'], 'g' );
+ }
+ return $ret;
+ }
+
+ private function makeGroupArray( $group ) {
+ // Migrate old group params from string to array
+ if ( $group === '' ) {
+ $group = [];
+ } elseif ( is_string( $group ) ) {
+ $group = array_map( 'trim', explode( ',', $group ) );
+ }
+ return $group;
+ }
+}
diff --git a/www/wiki/includes/logging/TagLogFormatter.php b/www/wiki/includes/logging/TagLogFormatter.php
new file mode 100644
index 00000000..230d13b6
--- /dev/null
+++ b/www/wiki/includes/logging/TagLogFormatter.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * This class formats tag log entries.
+ *
+ * Parameters (one-based indexes):
+ * 4::revid
+ * 5::logid
+ * 6:list:tagsAdded
+ * 7:number:tagsAddedCount
+ * 8:list:tagsRemoved
+ * 9:number:tagsRemovedCount
+ *
+ * @since 1.25
+ */
+class TagLogFormatter extends LogFormatter {
+ protected function getMessageKey() {
+ $key = parent::getMessageKey();
+ $params = $this->getMessageParameters();
+
+ $add = ( isset( $params[6] ) && isset( $params[6]['num'] ) && $params[6]['num'] );
+ $remove = ( isset( $params[8] ) && isset( $params[8]['num'] ) && $params[8]['num'] );
+ $key .= ( $remove ? ( $add ? '' : '-remove' ) : '-add' );
+
+ if ( isset( $params[3] ) && $params[3] ) {
+ // Messages: logentry-tag-update-add-revision, logentry-tag-update-remove-revision,
+ // logentry-tag-update-revision
+ $key .= '-revision';
+ } else {
+ // Messages: logentry-tag-update-add-logentry, logentry-tag-update-remove-logentry,
+ // logentry-tag-update-logentry
+ $key .= '-logentry';
+ }
+
+ return $key;
+ }
+}
diff --git a/www/wiki/includes/logging/UploadLogFormatter.php b/www/wiki/includes/logging/UploadLogFormatter.php
new file mode 100644
index 00000000..6c536717
--- /dev/null
+++ b/www/wiki/includes/logging/UploadLogFormatter.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * Formatter for upload log entries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ * @since 1.25
+ */
+
+/**
+ * This class formats upload log entries.
+ *
+ * @since 1.25
+ */
+class UploadLogFormatter extends LogFormatter {
+
+ protected function getParametersForApi() {
+ $entry = $this->entry;
+ $params = $entry->getParameters();
+
+ static $map = [
+ 'img_timestamp' => ':timestamp:img_timestamp',
+ ];
+ foreach ( $map as $index => $key ) {
+ if ( isset( $params[$index] ) ) {
+ $params[$key] = $params[$index];
+ unset( $params[$index] );
+ }
+ }
+
+ return $params;
+ }
+
+}
diff --git a/www/wiki/includes/mail/EmailNotification.php b/www/wiki/includes/mail/EmailNotification.php
new file mode 100644
index 00000000..2931d9dd
--- /dev/null
+++ b/www/wiki/includes/mail/EmailNotification.php
@@ -0,0 +1,513 @@
+<?php
+/**
+ * Classes used to send e-mails
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author <brion@pobox.com>
+ * @author <mail@tgries.de>
+ * @author Tim Starling
+ * @author Luke Welling lwelling@wikimedia.org
+ */
+use MediaWiki\Linker\LinkTarget;
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This module processes the email notifications when the current page is
+ * changed. It looks up the table watchlist to find out which users are watching
+ * that page.
+ *
+ * The current implementation sends independent emails to each watching user for
+ * the following reason:
+ *
+ * - Each watching user will be notified about the page edit time expressed in
+ * his/her local time (UTC is shown additionally). To achieve this, we need to
+ * find the individual timeoffset of each watching user from the preferences..
+ *
+ * Suggested improvement to slack down the number of sent emails: We could think
+ * of sending out bulk mails (bcc:user1,user2...) for all these users having the
+ * same timeoffset in their preferences.
+ *
+ * Visit the documentation pages under
+ * https://www.mediawiki.org/wiki/Help:Watching_pages
+ */
+class EmailNotification {
+
+ /**
+ * Notification is due to user's user talk being edited
+ */
+ const USER_TALK = 'user_talk';
+ /**
+ * Notification is due to a watchlisted page being edited
+ */
+ const WATCHLIST = 'watchlist';
+ /**
+ * Notification because user is notified for all changes
+ */
+ const ALL_CHANGES = 'all_changes';
+
+ protected $subject, $body, $replyto, $from;
+ protected $timestamp, $summary, $minorEdit, $oldid, $composed_common, $pageStatus;
+ protected $mailTargets = [];
+
+ /**
+ * @var Title
+ */
+ protected $title;
+
+ /**
+ * @var User
+ */
+ protected $editor;
+
+ /**
+ * @deprecated since 1.27 use WatchedItemStore::updateNotificationTimestamp directly
+ *
+ * @param User $editor The editor that triggered the update. Their notification
+ * timestamp will not be updated(they have already seen it)
+ * @param LinkTarget $linkTarget The link target of the title to update timestamps for
+ * @param string $timestamp Set the update timestamp to this value
+ *
+ * @return int[] Array of user IDs
+ */
+ public static function updateWatchlistTimestamp(
+ User $editor,
+ LinkTarget $linkTarget,
+ $timestamp
+ ) {
+ // wfDeprecated( __METHOD__, '1.27' );
+ $config = RequestContext::getMain()->getConfig();
+ if ( !$config->get( 'EnotifWatchlist' ) && !$config->get( 'ShowUpdatedMarker' ) ) {
+ return [];
+ }
+ return MediaWikiServices::getInstance()->getWatchedItemStore()->updateNotificationTimestamp(
+ $editor,
+ $linkTarget,
+ $timestamp
+ );
+ }
+
+ /**
+ * Send emails corresponding to the user $editor editing the page $title.
+ *
+ * May be deferred via the job queue.
+ *
+ * @param User $editor
+ * @param Title $title
+ * @param string $timestamp
+ * @param string $summary
+ * @param bool $minorEdit
+ * @param bool $oldid (default: false)
+ * @param string $pageStatus (default: 'changed')
+ */
+ public function notifyOnPageChange( $editor, $title, $timestamp, $summary,
+ $minorEdit, $oldid = false, $pageStatus = 'changed'
+ ) {
+ global $wgEnotifMinorEdits, $wgUsersNotifiedOnAllChanges, $wgEnotifUserTalk;
+
+ if ( $title->getNamespace() < 0 ) {
+ return;
+ }
+
+ // update wl_notificationtimestamp for watchers
+ $config = RequestContext::getMain()->getConfig();
+ $watchers = [];
+ if ( $config->get( 'EnotifWatchlist' ) || $config->get( 'ShowUpdatedMarker' ) ) {
+ $watchers = MediaWikiServices::getInstance()->getWatchedItemStore()->updateNotificationTimestamp(
+ $editor,
+ $title,
+ $timestamp
+ );
+ }
+
+ $sendEmail = true;
+ // $watchers deals with $wgEnotifWatchlist.
+ // If nobody is watching the page, and there are no users notified on all changes
+ // don't bother creating a job/trying to send emails, unless it's a
+ // talk page with an applicable notification.
+ if ( !count( $watchers ) && !count( $wgUsersNotifiedOnAllChanges ) ) {
+ $sendEmail = false;
+ // Only send notification for non minor edits, unless $wgEnotifMinorEdits
+ if ( !$minorEdit || ( $wgEnotifMinorEdits && !$editor->isAllowed( 'nominornewtalk' ) ) ) {
+ $isUserTalkPage = ( $title->getNamespace() == NS_USER_TALK );
+ if ( $wgEnotifUserTalk
+ && $isUserTalkPage
+ && $this->canSendUserTalkEmail( $editor, $title, $minorEdit )
+ ) {
+ $sendEmail = true;
+ }
+ }
+ }
+
+ if ( $sendEmail ) {
+ JobQueueGroup::singleton()->lazyPush( new EnotifNotifyJob(
+ $title,
+ [
+ 'editor' => $editor->getName(),
+ 'editorID' => $editor->getId(),
+ 'timestamp' => $timestamp,
+ 'summary' => $summary,
+ 'minorEdit' => $minorEdit,
+ 'oldid' => $oldid,
+ 'watchers' => $watchers,
+ 'pageStatus' => $pageStatus
+ ]
+ ) );
+ }
+ }
+
+ /**
+ * Immediate version of notifyOnPageChange().
+ *
+ * Send emails corresponding to the user $editor editing the page $title.
+ *
+ * @note Do not call directly. Use notifyOnPageChange so that wl_notificationtimestamp is updated.
+ * @param User $editor
+ * @param Title $title
+ * @param string $timestamp Edit timestamp
+ * @param string $summary Edit summary
+ * @param bool $minorEdit
+ * @param int $oldid Revision ID
+ * @param array $watchers Array of user IDs
+ * @param string $pageStatus
+ * @throws MWException
+ */
+ public function actuallyNotifyOnPageChange( $editor, $title, $timestamp, $summary, $minorEdit,
+ $oldid, $watchers, $pageStatus = 'changed' ) {
+ # we use $wgPasswordSender as sender's address
+ global $wgUsersNotifiedOnAllChanges;
+ global $wgEnotifWatchlist, $wgBlockDisablesLogin;
+ global $wgEnotifMinorEdits, $wgEnotifUserTalk;
+
+ # The following code is only run, if several conditions are met:
+ # 1. EmailNotification for pages (other than user_talk pages) must be enabled
+ # 2. minor edits (changes) are only regarded if the global flag indicates so
+
+ $isUserTalkPage = ( $title->getNamespace() == NS_USER_TALK );
+
+ $this->title = $title;
+ $this->timestamp = $timestamp;
+ $this->summary = $summary;
+ $this->minorEdit = $minorEdit;
+ $this->oldid = $oldid;
+ $this->editor = $editor;
+ $this->composed_common = false;
+ $this->pageStatus = $pageStatus;
+
+ $formattedPageStatus = [ 'deleted', 'created', 'moved', 'restored', 'changed' ];
+
+ Hooks::run( 'UpdateUserMailerFormattedPageStatus', [ &$formattedPageStatus ] );
+ if ( !in_array( $this->pageStatus, $formattedPageStatus ) ) {
+ throw new MWException( 'Not a valid page status!' );
+ }
+
+ $userTalkId = false;
+
+ if ( !$minorEdit || ( $wgEnotifMinorEdits && !$editor->isAllowed( 'nominornewtalk' ) ) ) {
+ if ( $wgEnotifUserTalk
+ && $isUserTalkPage
+ && $this->canSendUserTalkEmail( $editor, $title, $minorEdit )
+ ) {
+ $targetUser = User::newFromName( $title->getText() );
+ $this->compose( $targetUser, self::USER_TALK );
+ $userTalkId = $targetUser->getId();
+ }
+
+ if ( $wgEnotifWatchlist ) {
+ // Send updates to watchers other than the current editor
+ // and don't send to watchers who are blocked and cannot login
+ $userArray = UserArray::newFromIDs( $watchers );
+ foreach ( $userArray as $watchingUser ) {
+ if ( $watchingUser->getOption( 'enotifwatchlistpages' )
+ && ( !$minorEdit || $watchingUser->getOption( 'enotifminoredits' ) )
+ && $watchingUser->isEmailConfirmed()
+ && $watchingUser->getId() != $userTalkId
+ && !in_array( $watchingUser->getName(), $wgUsersNotifiedOnAllChanges )
+ && !( $wgBlockDisablesLogin && $watchingUser->isBlocked() )
+ ) {
+ if ( Hooks::run( 'SendWatchlistEmailNotification', [ $watchingUser, $title, $this ] ) ) {
+ $this->compose( $watchingUser, self::WATCHLIST );
+ }
+ }
+ }
+ }
+ }
+
+ foreach ( $wgUsersNotifiedOnAllChanges as $name ) {
+ if ( $editor->getName() == $name ) {
+ // No point notifying the user that actually made the change!
+ continue;
+ }
+ $user = User::newFromName( $name );
+ $this->compose( $user, self::ALL_CHANGES );
+ }
+
+ $this->sendMails();
+ }
+
+ /**
+ * @param User $editor
+ * @param Title $title
+ * @param bool $minorEdit
+ * @return bool
+ */
+ private function canSendUserTalkEmail( $editor, $title, $minorEdit ) {
+ global $wgEnotifUserTalk, $wgBlockDisablesLogin;
+ $isUserTalkPage = ( $title->getNamespace() == NS_USER_TALK );
+
+ if ( $wgEnotifUserTalk && $isUserTalkPage ) {
+ $targetUser = User::newFromName( $title->getText() );
+
+ if ( !$targetUser || $targetUser->isAnon() ) {
+ wfDebug( __METHOD__ . ": user talk page edited, but user does not exist\n" );
+ } elseif ( $targetUser->getId() == $editor->getId() ) {
+ wfDebug( __METHOD__ . ": user edited their own talk page, no notification sent\n" );
+ } elseif ( $wgBlockDisablesLogin && $targetUser->isBlocked() ) {
+ wfDebug( __METHOD__ . ": talk page owner is blocked and cannot login, no notification sent\n" );
+ } elseif ( $targetUser->getOption( 'enotifusertalkpages' )
+ && ( !$minorEdit || $targetUser->getOption( 'enotifminoredits' ) )
+ ) {
+ if ( !$targetUser->isEmailConfirmed() ) {
+ wfDebug( __METHOD__ . ": talk page owner doesn't have validated email\n" );
+ } elseif ( !Hooks::run( 'AbortTalkPageEmailNotification', [ $targetUser, $title ] ) ) {
+ wfDebug( __METHOD__ . ": talk page update notification is aborted for this user\n" );
+ } else {
+ wfDebug( __METHOD__ . ": sending talk page update notification\n" );
+ return true;
+ }
+ } else {
+ wfDebug( __METHOD__ . ": talk page owner doesn't want notifications\n" );
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Generate the generic "this page has been changed" e-mail text.
+ */
+ private function composeCommonMailtext() {
+ global $wgPasswordSender, $wgNoReplyAddress;
+ global $wgEnotifFromEditor, $wgEnotifRevealEditorAddress;
+ global $wgEnotifImpersonal, $wgEnotifUseRealName;
+
+ $this->composed_common = true;
+
+ # You as the WikiAdmin and Sysops can make use of plenty of
+ # named variables when composing your notification emails while
+ # simply editing the Meta pages
+
+ $keys = [];
+ $postTransformKeys = [];
+ $pageTitleUrl = $this->title->getCanonicalURL();
+ $pageTitle = $this->title->getPrefixedText();
+
+ if ( $this->oldid ) {
+ // Always show a link to the diff which triggered the mail. See T34210.
+ $keys['$NEWPAGE'] = "\n\n" . wfMessage( 'enotif_lastdiff',
+ $this->title->getCanonicalURL( [ 'diff' => 'next', 'oldid' => $this->oldid ] ) )
+ ->inContentLanguage()->text();
+
+ if ( !$wgEnotifImpersonal ) {
+ // For personal mail, also show a link to the diff of all changes
+ // since last visited.
+ $keys['$NEWPAGE'] .= "\n\n" . wfMessage( 'enotif_lastvisited',
+ $this->title->getCanonicalURL( [ 'diff' => '0', 'oldid' => $this->oldid ] ) )
+ ->inContentLanguage()->text();
+ }
+ $keys['$OLDID'] = $this->oldid;
+ // Deprecated since MediaWiki 1.21, not used by default. Kept for backwards-compatibility.
+ $keys['$CHANGEDORCREATED'] = wfMessage( 'changed' )->inContentLanguage()->text();
+ } else {
+ # clear $OLDID placeholder in the message template
+ $keys['$OLDID'] = '';
+ $keys['$NEWPAGE'] = '';
+ // Deprecated since MediaWiki 1.21, not used by default. Kept for backwards-compatibility.
+ $keys['$CHANGEDORCREATED'] = wfMessage( 'created' )->inContentLanguage()->text();
+ }
+
+ $keys['$PAGETITLE'] = $this->title->getPrefixedText();
+ $keys['$PAGETITLE_URL'] = $this->title->getCanonicalURL();
+ $keys['$PAGEMINOREDIT'] = $this->minorEdit ?
+ wfMessage( 'enotif_minoredit' )->inContentLanguage()->text() : '';
+ $keys['$UNWATCHURL'] = $this->title->getCanonicalURL( 'action=unwatch' );
+
+ if ( $this->editor->isAnon() ) {
+ # real anon (user:xxx.xxx.xxx.xxx)
+ $keys['$PAGEEDITOR'] = wfMessage( 'enotif_anon_editor', $this->editor->getName() )
+ ->inContentLanguage()->text();
+ $keys['$PAGEEDITOR_EMAIL'] = wfMessage( 'noemailtitle' )->inContentLanguage()->text();
+
+ } else {
+ $keys['$PAGEEDITOR'] = $wgEnotifUseRealName && $this->editor->getRealName() !== ''
+ ? $this->editor->getRealName() : $this->editor->getName();
+ $emailPage = SpecialPage::getSafeTitleFor( 'Emailuser', $this->editor->getName() );
+ $keys['$PAGEEDITOR_EMAIL'] = $emailPage->getCanonicalURL();
+ }
+
+ $keys['$PAGEEDITOR_WIKI'] = $this->editor->getUserPage()->getCanonicalURL();
+ $keys['$HELPPAGE'] = wfExpandUrl(
+ Skin::makeInternalOrExternalUrl( wfMessage( 'helppage' )->inContentLanguage()->text() )
+ );
+
+ # Replace this after transforming the message, T37019
+ $postTransformKeys['$PAGESUMMARY'] = $this->summary == '' ? ' - ' : $this->summary;
+
+ // Now build message's subject and body
+
+ // Messages:
+ // enotif_subject_deleted, enotif_subject_created, enotif_subject_moved,
+ // enotif_subject_restored, enotif_subject_changed
+ $this->subject = wfMessage( 'enotif_subject_' . $this->pageStatus )->inContentLanguage()
+ ->params( $pageTitle, $keys['$PAGEEDITOR'] )->text();
+
+ // Messages:
+ // enotif_body_intro_deleted, enotif_body_intro_created, enotif_body_intro_moved,
+ // enotif_body_intro_restored, enotif_body_intro_changed
+ $keys['$PAGEINTRO'] = wfMessage( 'enotif_body_intro_' . $this->pageStatus )
+ ->inContentLanguage()->params( $pageTitle, $keys['$PAGEEDITOR'], $pageTitleUrl )
+ ->text();
+
+ $body = wfMessage( 'enotif_body' )->inContentLanguage()->plain();
+ $body = strtr( $body, $keys );
+ $body = MessageCache::singleton()->transform( $body, false, null, $this->title );
+ $this->body = wordwrap( strtr( $body, $postTransformKeys ), 72 );
+
+ # Reveal the page editor's address as REPLY-TO address only if
+ # the user has not opted-out and the option is enabled at the
+ # global configuration level.
+ $adminAddress = new MailAddress( $wgPasswordSender,
+ wfMessage( 'emailsender' )->inContentLanguage()->text() );
+ if ( $wgEnotifRevealEditorAddress
+ && ( $this->editor->getEmail() != '' )
+ && $this->editor->getOption( 'enotifrevealaddr' )
+ ) {
+ $editorAddress = MailAddress::newFromUser( $this->editor );
+ if ( $wgEnotifFromEditor ) {
+ $this->from = $editorAddress;
+ } else {
+ $this->from = $adminAddress;
+ $this->replyto = $editorAddress;
+ }
+ } else {
+ $this->from = $adminAddress;
+ $this->replyto = new MailAddress( $wgNoReplyAddress );
+ }
+ }
+
+ /**
+ * Compose a mail to a given user and either queue it for sending, or send it now,
+ * depending on settings.
+ *
+ * Call sendMails() to send any mails that were queued.
+ * @param User $user
+ * @param string $source
+ */
+ function compose( $user, $source ) {
+ global $wgEnotifImpersonal;
+
+ if ( !$this->composed_common ) {
+ $this->composeCommonMailtext();
+ }
+
+ if ( $wgEnotifImpersonal ) {
+ $this->mailTargets[] = MailAddress::newFromUser( $user );
+ } else {
+ $this->sendPersonalised( $user, $source );
+ }
+ }
+
+ /**
+ * Send any queued mails
+ */
+ function sendMails() {
+ global $wgEnotifImpersonal;
+ if ( $wgEnotifImpersonal ) {
+ $this->sendImpersonal( $this->mailTargets );
+ }
+ }
+
+ /**
+ * Does the per-user customizations to a notification e-mail (name,
+ * timestamp in proper timezone, etc) and sends it out.
+ * Returns true if the mail was sent successfully.
+ *
+ * @param User $watchingUser
+ * @param string $source
+ * @return bool
+ * @private
+ */
+ function sendPersonalised( $watchingUser, $source ) {
+ global $wgContLang, $wgEnotifUseRealName;
+ // From the PHP manual:
+ // Note: The to parameter cannot be an address in the form of
+ // "Something <someone@example.com>". The mail command will not parse
+ // this properly while talking with the MTA.
+ $to = MailAddress::newFromUser( $watchingUser );
+
+ # $PAGEEDITDATE is the time and date of the page change
+ # expressed in terms of individual local time of the notification
+ # recipient, i.e. watching user
+ $body = str_replace(
+ [ '$WATCHINGUSERNAME',
+ '$PAGEEDITDATE',
+ '$PAGEEDITTIME' ],
+ [ $wgEnotifUseRealName && $watchingUser->getRealName() !== ''
+ ? $watchingUser->getRealName() : $watchingUser->getName(),
+ $wgContLang->userDate( $this->timestamp, $watchingUser ),
+ $wgContLang->userTime( $this->timestamp, $watchingUser ) ],
+ $this->body );
+
+ $headers = [];
+ if ( $source === self::WATCHLIST ) {
+ $headers['List-Help'] = 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Watchlist';
+ }
+
+ return UserMailer::send( $to, $this->from, $this->subject, $body, [
+ 'replyTo' => $this->replyto,
+ 'headers' => $headers,
+ ] );
+ }
+
+ /**
+ * Same as sendPersonalised but does impersonal mail suitable for bulk
+ * mailing. Takes an array of MailAddress objects.
+ * @param MailAddress[] $addresses
+ * @return Status|null
+ */
+ function sendImpersonal( $addresses ) {
+ global $wgContLang;
+
+ if ( empty( $addresses ) ) {
+ return null;
+ }
+
+ $body = str_replace(
+ [ '$WATCHINGUSERNAME',
+ '$PAGEEDITDATE',
+ '$PAGEEDITTIME' ],
+ [ wfMessage( 'enotif_impersonal_salutation' )->inContentLanguage()->text(),
+ $wgContLang->date( $this->timestamp, false, false ),
+ $wgContLang->time( $this->timestamp, false, false ) ],
+ $this->body );
+
+ return UserMailer::send( $addresses, $this->from, $this->subject, $body, [
+ 'replyTo' => $this->replyto,
+ ] );
+ }
+
+}
diff --git a/www/wiki/includes/mail/MailAddress.php b/www/wiki/includes/mail/MailAddress.php
new file mode 100644
index 00000000..ce1df0db
--- /dev/null
+++ b/www/wiki/includes/mail/MailAddress.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * Classes used to send e-mails
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author <brion@pobox.com>
+ * @author <mail@tgries.de>
+ * @author Tim Starling
+ * @author Luke Welling lwelling@wikimedia.org
+ */
+
+/**
+ * Stores a single person's name and email address.
+ * These are passed in via the constructor, and will be returned in SMTP
+ * header format when requested.
+ */
+class MailAddress {
+
+ /**
+ * @var string
+ */
+ public $name;
+
+ /**
+ * @var string
+ */
+ public $realName;
+
+ /**
+ * @var string
+ */
+ public $address;
+
+ /**
+ * @param string $address String with an email address, or a User object
+ * @param string $name Human-readable name if a string address is given
+ * @param string $realName Human-readable real name if a string address is given
+ */
+ function __construct( $address, $name = null, $realName = null ) {
+ if ( is_object( $address ) && $address instanceof User ) {
+ // Old calling format, now deprecated
+ wfDeprecated( __METHOD__ . ' with a User object', '1.24' );
+ $this->address = $address->getEmail();
+ $this->name = $address->getName();
+ $this->realName = $address->getRealName();
+ } else {
+ $this->address = strval( $address );
+ $this->name = strval( $name );
+ $this->realName = strval( $realName );
+ }
+ }
+
+ /**
+ * Create a new MailAddress object for the given user
+ *
+ * @since 1.24
+ * @param User $user
+ * @return MailAddress
+ */
+ public static function newFromUser( User $user ) {
+ return new MailAddress( $user->getEmail(), $user->getName(), $user->getRealName() );
+ }
+
+ /**
+ * Return formatted and quoted address to insert into SMTP headers
+ * @return string
+ */
+ function toString() {
+ # PHP's mail() implementation under Windows is somewhat shite, and
+ # can't handle "Joe Bloggs <joe@bloggs.com>" format email addresses,
+ # so don't bother generating them
+ if ( $this->address ) {
+ if ( $this->name != '' && !wfIsWindows() ) {
+ global $wgEnotifUseRealName;
+ $name = ( $wgEnotifUseRealName && $this->realName !== '' ) ? $this->realName : $this->name;
+ $quoted = UserMailer::quotedPrintable( $name );
+ if ( strpos( $quoted, '.' ) !== false || strpos( $quoted, ',' ) !== false ) {
+ $quoted = '"' . $quoted . '"';
+ }
+ return "$quoted <{$this->address}>";
+ } else {
+ return $this->address;
+ }
+ } else {
+ return "";
+ }
+ }
+
+ function __toString() {
+ return $this->toString();
+ }
+}
diff --git a/www/wiki/includes/mail/UserMailer.php b/www/wiki/includes/mail/UserMailer.php
new file mode 100644
index 00000000..93cbdf43
--- /dev/null
+++ b/www/wiki/includes/mail/UserMailer.php
@@ -0,0 +1,541 @@
+<?php
+/**
+ * Classes used to send e-mails
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author <brion@pobox.com>
+ * @author <mail@tgries.de>
+ * @author Tim Starling
+ * @author Luke Welling lwelling@wikimedia.org
+ */
+
+/**
+ * Collection of static functions for sending mail
+ */
+class UserMailer {
+ private static $mErrorString;
+
+ /**
+ * Send mail using a PEAR mailer
+ *
+ * @param Mail_smtp $mailer
+ * @param string $dest
+ * @param string $headers
+ * @param string $body
+ *
+ * @return Status
+ */
+ protected static function sendWithPear( $mailer, $dest, $headers, $body ) {
+ $mailResult = $mailer->send( $dest, $headers, $body );
+
+ // Based on the result return an error string,
+ if ( PEAR::isError( $mailResult ) ) {
+ wfDebug( "PEAR::Mail failed: " . $mailResult->getMessage() . "\n" );
+ return Status::newFatal( 'pear-mail-error', $mailResult->getMessage() );
+ } else {
+ return Status::newGood();
+ }
+ }
+
+ /**
+ * Creates a single string from an associative array
+ *
+ * @param array $headers Associative Array: keys are header field names,
+ * values are ... values.
+ * @param string $endl The end of line character. Defaults to "\n"
+ *
+ * Note RFC2822 says newlines must be CRLF (\r\n)
+ * but php mail naively "corrects" it and requires \n for the "correction" to work
+ *
+ * @return string
+ */
+ static function arrayToHeaderString( $headers, $endl = PHP_EOL ) {
+ $strings = [];
+ foreach ( $headers as $name => $value ) {
+ // Prevent header injection by stripping newlines from value
+ $value = self::sanitizeHeaderValue( $value );
+ $strings[] = "$name: $value";
+ }
+ return implode( $endl, $strings );
+ }
+
+ /**
+ * Create a value suitable for the MessageId Header
+ *
+ * @return string
+ */
+ static function makeMsgId() {
+ global $wgSMTP, $wgServer;
+
+ $msgid = uniqid( wfWikiID() . ".", true ); /* true required for cygwin */
+ if ( is_array( $wgSMTP ) && isset( $wgSMTP['IDHost'] ) && $wgSMTP['IDHost'] ) {
+ $domain = $wgSMTP['IDHost'];
+ } else {
+ $url = wfParseUrl( $wgServer );
+ $domain = $url['host'];
+ }
+ return "<$msgid@$domain>";
+ }
+
+ /**
+ * This function will perform a direct (authenticated) login to
+ * a SMTP Server to use for mail relaying if 'wgSMTP' specifies an
+ * array of parameters. It requires PEAR:Mail to do that.
+ * Otherwise it just uses the standard PHP 'mail' function.
+ *
+ * @param MailAddress|MailAddress[] $to Recipient's email (or an array of them)
+ * @param MailAddress $from Sender's email
+ * @param string $subject Email's subject.
+ * @param string $body Email's text or Array of two strings to be the text and html bodies
+ * @param array $options Keys:
+ * 'replyTo' MailAddress
+ * 'contentType' string default 'text/plain; charset=UTF-8'
+ * 'headers' array Extra headers to set
+ *
+ * @throws MWException
+ * @throws Exception
+ * @return Status
+ */
+ public static function send( $to, $from, $subject, $body, $options = [] ) {
+ global $wgAllowHTMLEmail;
+
+ if ( !isset( $options['contentType'] ) ) {
+ $options['contentType'] = 'text/plain; charset=UTF-8';
+ }
+
+ if ( !is_array( $to ) ) {
+ $to = [ $to ];
+ }
+
+ // mail body must have some content
+ $minBodyLen = 10;
+ // arbitrary but longer than Array or Object to detect casting error
+
+ // body must either be a string or an array with text and body
+ if (
+ !(
+ !is_array( $body ) &&
+ strlen( $body ) >= $minBodyLen
+ )
+ &&
+ !(
+ is_array( $body ) &&
+ isset( $body['text'] ) &&
+ isset( $body['html'] ) &&
+ strlen( $body['text'] ) >= $minBodyLen &&
+ strlen( $body['html'] ) >= $minBodyLen
+ )
+ ) {
+ // if it is neither we have a problem
+ return Status::newFatal( 'user-mail-no-body' );
+ }
+
+ if ( !$wgAllowHTMLEmail && is_array( $body ) ) {
+ // HTML not wanted. Dump it.
+ $body = $body['text'];
+ }
+
+ wfDebug( __METHOD__ . ': sending mail to ' . implode( ', ', $to ) . "\n" );
+
+ // Make sure we have at least one address
+ $has_address = false;
+ foreach ( $to as $u ) {
+ if ( $u->address ) {
+ $has_address = true;
+ break;
+ }
+ }
+ if ( !$has_address ) {
+ return Status::newFatal( 'user-mail-no-addy' );
+ }
+
+ // give a chance to UserMailerTransformContents subscribers who need to deal with each
+ // target differently to split up the address list
+ if ( count( $to ) > 1 ) {
+ $oldTo = $to;
+ Hooks::run( 'UserMailerSplitTo', [ &$to ] );
+ if ( $oldTo != $to ) {
+ $splitTo = array_diff( $oldTo, $to );
+ $to = array_diff( $oldTo, $splitTo ); // ignore new addresses added in the hook
+ // first send to non-split address list, then to split addresses one by one
+ $status = Status::newGood();
+ if ( $to ) {
+ $status->merge( self::sendInternal(
+ $to, $from, $subject, $body, $options ) );
+ }
+ foreach ( $splitTo as $newTo ) {
+ $status->merge( self::sendInternal(
+ [ $newTo ], $from, $subject, $body, $options ) );
+ }
+ return $status;
+ }
+ }
+
+ return self::sendInternal( $to, $from, $subject, $body, $options );
+ }
+
+ /**
+ * Whether the PEAR Mail_mime library is usable. This will
+ * try and load it if it is not already.
+ *
+ * @return bool
+ */
+ private static function isMailMimeUsable() {
+ static $usable = null;
+ if ( $usable === null ) {
+ // If the class is not already loaded, and it's in the include path,
+ // try requiring it.
+ if ( !class_exists( 'Mail_mime' ) && stream_resolve_include_path( 'Mail/mime.php' ) ) {
+ require_once 'Mail/mime.php';
+ }
+ $usable = class_exists( 'Mail_mime' );
+ }
+
+ return $usable;
+ }
+
+ /**
+ * Whether the PEAR Mail library is usable. This will
+ * try and load it if it is not already.
+ *
+ * @return bool
+ */
+ private static function isMailUsable() {
+ static $usable = null;
+ if ( $usable === null ) {
+ // If the class is not already loaded, and it's in the include path,
+ // try requiring it.
+ if ( !class_exists( 'Mail' ) && stream_resolve_include_path( 'Mail.php' ) ) {
+ require_once 'Mail.php';
+ }
+ $usable = class_exists( 'Mail' );
+ }
+
+ return $usable;
+ }
+
+ /**
+ * Helper function fo UserMailer::send() which does the actual sending. It expects a $to
+ * list which the UserMailerSplitTo hook would not split further.
+ * @param MailAddress[] $to Array of recipients' email addresses
+ * @param MailAddress $from Sender's email
+ * @param string $subject Email's subject.
+ * @param string $body Email's text or Array of two strings to be the text and html bodies
+ * @param array $options Keys:
+ * 'replyTo' MailAddress
+ * 'contentType' string default 'text/plain; charset=UTF-8'
+ * 'headers' array Extra headers to set
+ *
+ * @throws MWException
+ * @throws Exception
+ * @return Status
+ */
+ protected static function sendInternal(
+ array $to,
+ MailAddress $from,
+ $subject,
+ $body,
+ $options = []
+ ) {
+ global $wgSMTP, $wgEnotifMaxRecips, $wgAdditionalMailParams;
+ $mime = null;
+
+ $replyto = isset( $options['replyTo'] ) ? $options['replyTo'] : null;
+ $contentType = isset( $options['contentType'] ) ?
+ $options['contentType'] : 'text/plain; charset=UTF-8';
+ $headers = isset( $options['headers'] ) ? $options['headers'] : [];
+
+ // Allow transformation of content, such as encrypting/signing
+ $error = false;
+ if ( !Hooks::run( 'UserMailerTransformContent', [ $to, $from, &$body, &$error ] ) ) {
+ if ( $error ) {
+ return Status::newFatal( 'php-mail-error', $error );
+ } else {
+ return Status::newFatal( 'php-mail-error-unknown' );
+ }
+ }
+
+ /**
+ * Forge email headers
+ * -------------------
+ *
+ * WARNING
+ *
+ * DO NOT add To: or Subject: headers at this step. They need to be
+ * handled differently depending upon the mailer we are going to use.
+ *
+ * To:
+ * PHP mail() first argument is the mail receiver. The argument is
+ * used as a recipient destination and as a To header.
+ *
+ * PEAR mailer has a recipient argument which is only used to
+ * send the mail. If no To header is given, PEAR will set it to
+ * to 'undisclosed-recipients:'.
+ *
+ * NOTE: To: is for presentation, the actual recipient is specified
+ * by the mailer using the Rcpt-To: header.
+ *
+ * Subject:
+ * PHP mail() second argument to pass the subject, passing a Subject
+ * as an additional header will result in a duplicate header.
+ *
+ * PEAR mailer should be passed a Subject header.
+ *
+ * -- hashar 20120218
+ */
+
+ $headers['From'] = $from->toString();
+ $returnPath = $from->address;
+ $extraParams = $wgAdditionalMailParams;
+
+ // Hook to generate custom VERP address for 'Return-Path'
+ Hooks::run( 'UserMailerChangeReturnPath', [ $to, &$returnPath ] );
+ // Add the envelope sender address using the -f command line option when PHP mail() is used.
+ // Will default to the $from->address when the UserMailerChangeReturnPath hook fails and the
+ // generated VERP address when the hook runs effectively.
+
+ // PHP runs this through escapeshellcmd(). However that's not sufficient
+ // escaping (e.g. due to spaces). MediaWiki's email sanitizer should generally
+ // be good enough, but just in case, put in double quotes, and remove any
+ // double quotes present (" is not allowed in emails, so should have no
+ // effect, although this might cause apostrophees to be double escaped)
+ $returnPathCLI = '"' . str_replace( '"', '', $returnPath ) . '"';
+ $extraParams .= ' -f ' . $returnPathCLI;
+
+ $headers['Return-Path'] = $returnPath;
+
+ if ( $replyto ) {
+ $headers['Reply-To'] = $replyto->toString();
+ }
+
+ $headers['Date'] = MWTimestamp::getLocalInstance()->format( 'r' );
+ $headers['Message-ID'] = self::makeMsgId();
+ $headers['X-Mailer'] = 'MediaWiki mailer';
+ $headers['List-Unsubscribe'] = '<' . SpecialPage::getTitleFor( 'Preferences' )
+ ->getFullURL( '', false, PROTO_CANONICAL ) . '>';
+
+ // Line endings need to be different on Unix and Windows due to
+ // the bug described at https://core.trac.wordpress.org/ticket/2603
+ $endl = PHP_EOL;
+
+ if ( is_array( $body ) ) {
+ // we are sending a multipart message
+ wfDebug( "Assembling multipart mime email\n" );
+ if ( !self::isMailMimeUsable() ) {
+ wfDebug( "PEAR Mail_Mime package is not installed. Falling back to text email.\n" );
+ // remove the html body for text email fall back
+ $body = $body['text'];
+ } else {
+ // pear/mail_mime is already loaded by this point
+ if ( wfIsWindows() ) {
+ $body['text'] = str_replace( "\n", "\r\n", $body['text'] );
+ $body['html'] = str_replace( "\n", "\r\n", $body['html'] );
+ }
+ $mime = new Mail_mime( [
+ 'eol' => $endl,
+ 'text_charset' => 'UTF-8',
+ 'html_charset' => 'UTF-8'
+ ] );
+ $mime->setTXTBody( $body['text'] );
+ $mime->setHTMLBody( $body['html'] );
+ $body = $mime->get(); // must call get() before headers()
+ $headers = $mime->headers( $headers );
+ }
+ }
+ if ( $mime === null ) {
+ // sending text only, either deliberately or as a fallback
+ if ( wfIsWindows() ) {
+ $body = str_replace( "\n", "\r\n", $body );
+ }
+ $headers['MIME-Version'] = '1.0';
+ $headers['Content-type'] = $contentType;
+ $headers['Content-transfer-encoding'] = '8bit';
+ }
+
+ // allow transformation of MIME-encoded message
+ if ( !Hooks::run( 'UserMailerTransformMessage',
+ [ $to, $from, &$subject, &$headers, &$body, &$error ] )
+ ) {
+ if ( $error ) {
+ return Status::newFatal( 'php-mail-error', $error );
+ } else {
+ return Status::newFatal( 'php-mail-error-unknown' );
+ }
+ }
+
+ $ret = Hooks::run( 'AlternateUserMailer', [ $headers, $to, $from, $subject, $body ] );
+ if ( $ret === false ) {
+ // the hook implementation will return false to skip regular mail sending
+ return Status::newGood();
+ } elseif ( $ret !== true ) {
+ // the hook implementation will return a string to pass an error message
+ return Status::newFatal( 'php-mail-error', $ret );
+ }
+
+ if ( is_array( $wgSMTP ) ) {
+ // Check if pear/mail is already loaded (via composer)
+ if ( !self::isMailUsable() ) {
+ throw new MWException( 'PEAR mail package is not installed' );
+ }
+
+ MediaWiki\suppressWarnings();
+
+ // Create the mail object using the Mail::factory method
+ $mail_object =& Mail::factory( 'smtp', $wgSMTP );
+ if ( PEAR::isError( $mail_object ) ) {
+ wfDebug( "PEAR::Mail factory failed: " . $mail_object->getMessage() . "\n" );
+ MediaWiki\restoreWarnings();
+ return Status::newFatal( 'pear-mail-error', $mail_object->getMessage() );
+ }
+
+ wfDebug( "Sending mail via PEAR::Mail\n" );
+
+ $headers['Subject'] = self::quotedPrintable( $subject );
+
+ // When sending only to one recipient, shows it its email using To:
+ if ( count( $to ) == 1 ) {
+ $headers['To'] = $to[0]->toString();
+ }
+
+ // Split jobs since SMTP servers tends to limit the maximum
+ // number of possible recipients.
+ $chunks = array_chunk( $to, $wgEnotifMaxRecips );
+ foreach ( $chunks as $chunk ) {
+ $status = self::sendWithPear( $mail_object, $chunk, $headers, $body );
+ // FIXME : some chunks might be sent while others are not!
+ if ( !$status->isOK() ) {
+ MediaWiki\restoreWarnings();
+ return $status;
+ }
+ }
+ MediaWiki\restoreWarnings();
+ return Status::newGood();
+ } else {
+ // PHP mail()
+ if ( count( $to ) > 1 ) {
+ $headers['To'] = 'undisclosed-recipients:;';
+ }
+ $headers = self::arrayToHeaderString( $headers, $endl );
+
+ wfDebug( "Sending mail via internal mail() function\n" );
+
+ self::$mErrorString = '';
+ $html_errors = ini_get( 'html_errors' );
+ ini_set( 'html_errors', '0' );
+ set_error_handler( 'UserMailer::errorHandler' );
+
+ try {
+ foreach ( $to as $recip ) {
+ $sent = mail(
+ $recip,
+ self::quotedPrintable( $subject ),
+ $body,
+ $headers,
+ $extraParams
+ );
+ }
+ } catch ( Exception $e ) {
+ restore_error_handler();
+ throw $e;
+ }
+
+ restore_error_handler();
+ ini_set( 'html_errors', $html_errors );
+
+ if ( self::$mErrorString ) {
+ wfDebug( "Error sending mail: " . self::$mErrorString . "\n" );
+ return Status::newFatal( 'php-mail-error', self::$mErrorString );
+ } elseif ( !$sent ) {
+ // mail function only tells if there's an error
+ wfDebug( "Unknown error sending mail\n" );
+ return Status::newFatal( 'php-mail-error-unknown' );
+ } else {
+ return Status::newGood();
+ }
+ }
+ }
+
+ /**
+ * Set the mail error message in self::$mErrorString
+ *
+ * @param int $code Error number
+ * @param string $string Error message
+ */
+ static function errorHandler( $code, $string ) {
+ self::$mErrorString = preg_replace( '/^mail\(\)(\s*\[.*?\])?: /', '', $string );
+ }
+
+ /**
+ * Strips bad characters from a header value to prevent PHP mail header injection attacks
+ * @param string $val String to be santizied
+ * @return string
+ */
+ public static function sanitizeHeaderValue( $val ) {
+ return strtr( $val, [ "\r" => '', "\n" => '' ] );
+ }
+
+ /**
+ * Converts a string into a valid RFC 822 "phrase", such as is used for the sender name
+ * @param string $phrase
+ * @return string
+ */
+ public static function rfc822Phrase( $phrase ) {
+ // Remove line breaks
+ $phrase = self::sanitizeHeaderValue( $phrase );
+ // Remove quotes
+ $phrase = str_replace( '"', '', $phrase );
+ return '"' . $phrase . '"';
+ }
+
+ /**
+ * Converts a string into quoted-printable format
+ * @since 1.17
+ *
+ * From PHP5.3 there is a built in function quoted_printable_encode()
+ * This method does not duplicate that.
+ * This method is doing Q encoding inside encoded-words as defined by RFC 2047
+ * This is for email headers.
+ * The built in quoted_printable_encode() is for email bodies
+ * @param string $string
+ * @param string $charset
+ * @return string
+ */
+ public static function quotedPrintable( $string, $charset = '' ) {
+ // Probably incomplete; see RFC 2045
+ if ( empty( $charset ) ) {
+ $charset = 'UTF-8';
+ }
+ $charset = strtoupper( $charset );
+ $charset = str_replace( 'ISO-8859', 'ISO8859', $charset ); // ?
+
+ $illegal = '\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff=';
+ $replace = $illegal . '\t ?_';
+ if ( !preg_match( "/[$illegal]/", $string ) ) {
+ return $string;
+ }
+ $out = "=?$charset?Q?";
+ $out .= preg_replace_callback( "/([$replace])/",
+ function ( $matches ) {
+ return sprintf( "=%02X", ord( $matches[1] ) );
+ },
+ $string
+ );
+ $out .= '?=';
+ return $out;
+ }
+}
diff --git a/www/wiki/includes/media/BMP.php b/www/wiki/includes/media/BMP.php
new file mode 100644
index 00000000..0229ac11
--- /dev/null
+++ b/www/wiki/includes/media/BMP.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * Handler for Microsoft's bitmap format.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Handler for Microsoft's bitmap format; getimagesize() doesn't
+ * support these files
+ *
+ * @ingroup Media
+ */
+class BmpHandler extends BitmapHandler {
+ /**
+ * @param File $file
+ * @return bool
+ */
+ public function mustRender( $file ) {
+ return true;
+ }
+
+ /**
+ * Render files as PNG
+ *
+ * @param string $text
+ * @param string $mime
+ * @param array $params
+ * @return array
+ */
+ function getThumbType( $text, $mime, $params = null ) {
+ return [ 'png', 'image/png' ];
+ }
+
+ /**
+ * Get width and height from the bmp header.
+ *
+ * @param File|FSFile $image
+ * @param string $filename
+ * @return array
+ */
+ function getImageSize( $image, $filename ) {
+ $f = fopen( $filename, 'rb' );
+ if ( !$f ) {
+ return false;
+ }
+ $header = fread( $f, 54 );
+ fclose( $f );
+
+ // Extract binary form of width and height from the header
+ $w = substr( $header, 18, 4 );
+ $h = substr( $header, 22, 4 );
+
+ // Convert the unsigned long 32 bits (little endian):
+ try {
+ $w = wfUnpack( 'V', $w, 4 );
+ $h = wfUnpack( 'V', $h, 4 );
+ } catch ( Exception $e ) {
+ return false;
+ }
+
+ return [ $w[1], $h[1] ];
+ }
+}
diff --git a/www/wiki/includes/media/Bitmap.php b/www/wiki/includes/media/Bitmap.php
new file mode 100644
index 00000000..ac39e6f3
--- /dev/null
+++ b/www/wiki/includes/media/Bitmap.php
@@ -0,0 +1,588 @@
+<?php
+/**
+ * Generic handler for bitmap images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Generic handler for bitmap images
+ *
+ * @ingroup Media
+ */
+class BitmapHandler extends TransformationalImageHandler {
+
+ /**
+ * Returns which scaler type should be used. Creates parent directories
+ * for $dstPath and returns 'client' on error
+ *
+ * @param string $dstPath
+ * @param bool $checkDstPath
+ * @return string|Callable One of client, im, custom, gd, imext or an array( object, method )
+ */
+ protected function getScalerType( $dstPath, $checkDstPath = true ) {
+ global $wgUseImageResize, $wgUseImageMagick, $wgCustomConvertCommand;
+
+ if ( !$dstPath && $checkDstPath ) {
+ # No output path available, client side scaling only
+ $scaler = 'client';
+ } elseif ( !$wgUseImageResize ) {
+ $scaler = 'client';
+ } elseif ( $wgUseImageMagick ) {
+ $scaler = 'im';
+ } elseif ( $wgCustomConvertCommand ) {
+ $scaler = 'custom';
+ } elseif ( function_exists( 'imagecreatetruecolor' ) ) {
+ $scaler = 'gd';
+ } elseif ( class_exists( 'Imagick' ) ) {
+ $scaler = 'imext';
+ } else {
+ $scaler = 'client';
+ }
+
+ return $scaler;
+ }
+
+ public function makeParamString( $params ) {
+ $res = parent::makeParamString( $params );
+ if ( isset( $params['interlace'] ) && $params['interlace'] ) {
+ return "interlaced-{$res}";
+ } else {
+ return $res;
+ }
+ }
+
+ public function parseParamString( $str ) {
+ $remainder = preg_replace( '/^interlaced-/', '', $str );
+ $params = parent::parseParamString( $remainder );
+ if ( $params === false ) {
+ return false;
+ }
+ $params['interlace'] = $str !== $remainder;
+ return $params;
+ }
+
+ public function validateParam( $name, $value ) {
+ if ( $name === 'interlace' ) {
+ return $value === false || $value === true;
+ } else {
+ return parent::validateParam( $name, $value );
+ }
+ }
+
+ /**
+ * @param File $image
+ * @param array &$params
+ * @return bool
+ */
+ function normaliseParams( $image, &$params ) {
+ global $wgMaxInterlacingAreas;
+ if ( !parent::normaliseParams( $image, $params ) ) {
+ return false;
+ }
+ $mimeType = $image->getMimeType();
+ $interlace = isset( $params['interlace'] ) && $params['interlace']
+ && isset( $wgMaxInterlacingAreas[$mimeType] )
+ && $this->getImageArea( $image ) <= $wgMaxInterlacingAreas[$mimeType];
+ $params['interlace'] = $interlace;
+ return true;
+ }
+
+ /**
+ * Get ImageMagick subsampling factors for the target JPEG pixel format.
+ *
+ * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420'
+ * @return array of string keys
+ */
+ protected function imageMagickSubsampling( $pixelFormat ) {
+ switch ( $pixelFormat ) {
+ case 'yuv444':
+ return [ '1x1', '1x1', '1x1' ];
+ case 'yuv422':
+ return [ '2x1', '1x1', '1x1' ];
+ case 'yuv420':
+ return [ '2x2', '1x1', '1x1' ];
+ default:
+ throw new MWException( 'Invalid pixel format for JPEG output' );
+ }
+ }
+
+ /**
+ * Transform an image using ImageMagick
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $params Array with scaler params
+ *
+ * @return MediaTransformError|bool Error object if error occurred, false (=no error) otherwise
+ */
+ protected function transformImageMagick( $image, $params ) {
+ # use ImageMagick
+ global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea,
+ $wgImageMagickTempDir, $wgImageMagickConvertCommand, $wgJpegPixelFormat;
+
+ $quality = [];
+ $sharpen = [];
+ $scene = false;
+ $animation_pre = [];
+ $animation_post = [];
+ $decoderHint = [];
+ $subsampling = [];
+
+ if ( $params['mimeType'] == 'image/jpeg' ) {
+ $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null;
+ $quality = [ '-quality', $qualityVal ?: '80' ]; // 80%
+ if ( $params['interlace'] ) {
+ $animation_post = [ '-interlace', 'JPEG' ];
+ }
+ # Sharpening, see T8193
+ if ( ( $params['physicalWidth'] + $params['physicalHeight'] )
+ / ( $params['srcWidth'] + $params['srcHeight'] )
+ < $wgSharpenReductionThreshold
+ ) {
+ $sharpen = [ '-sharpen', $wgSharpenParameter ];
+ }
+ if ( version_compare( $this->getMagickVersion(), "6.5.6" ) >= 0 ) {
+ // JPEG decoder hint to reduce memory, available since IM 6.5.6-2
+ $decoderHint = [ '-define', "jpeg:size={$params['physicalDimensions']}" ];
+ }
+ if ( $wgJpegPixelFormat ) {
+ $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat );
+ $subsampling = [ '-sampling-factor', implode( ',', $factors ) ];
+ }
+ } elseif ( $params['mimeType'] == 'image/png' ) {
+ $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering
+ if ( $params['interlace'] ) {
+ $animation_post = [ '-interlace', 'PNG' ];
+ }
+ } elseif ( $params['mimeType'] == 'image/webp' ) {
+ $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering
+ } elseif ( $params['mimeType'] == 'image/gif' ) {
+ if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) {
+ // Extract initial frame only; we're so big it'll
+ // be a total drag. :P
+ $scene = 0;
+ } elseif ( $this->isAnimatedImage( $image ) ) {
+ // Coalesce is needed to scale animated GIFs properly (T3017).
+ $animation_pre = [ '-coalesce' ];
+ // We optimize the output, but -optimize is broken,
+ // use optimizeTransparency instead (T13822)
+ if ( version_compare( $this->getMagickVersion(), "6.3.5" ) >= 0 ) {
+ $animation_post = [ '-fuzz', '5%', '-layers', 'optimizeTransparency' ];
+ }
+ }
+ if ( $params['interlace'] && version_compare( $this->getMagickVersion(), "6.3.4" ) >= 0
+ && !$this->isAnimatedImage( $image ) ) { // interlacing animated GIFs is a bad idea
+ $animation_post[] = '-interlace';
+ $animation_post[] = 'GIF';
+ }
+ } elseif ( $params['mimeType'] == 'image/x-xcf' ) {
+ // Before merging layers, we need to set the background
+ // to be transparent to preserve alpha, as -layers merge
+ // merges all layers on to a canvas filled with the
+ // background colour. After merging we reset the background
+ // to be white for the default background colour setting
+ // in the PNG image (which is used in old IE)
+ $animation_pre = [
+ '-background', 'transparent',
+ '-layers', 'merge',
+ '-background', 'white',
+ ];
+ MediaWiki\suppressWarnings();
+ $xcfMeta = unserialize( $image->getMetadata() );
+ MediaWiki\restoreWarnings();
+ if ( $xcfMeta
+ && isset( $xcfMeta['colorType'] )
+ && $xcfMeta['colorType'] === 'greyscale-alpha'
+ && version_compare( $this->getMagickVersion(), "6.8.9-3" ) < 0
+ ) {
+ // T68323 - Greyscale images not rendered properly.
+ // So only take the "red" channel.
+ $channelOnly = [ '-channel', 'R', '-separate' ];
+ $animation_pre = array_merge( $animation_pre, $channelOnly );
+ }
+ }
+
+ // Use one thread only, to avoid deadlock bugs on OOM
+ $env = [ 'OMP_NUM_THREADS' => 1 ];
+ if ( strval( $wgImageMagickTempDir ) !== '' ) {
+ $env['MAGICK_TMPDIR'] = $wgImageMagickTempDir;
+ }
+
+ $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image );
+ list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation );
+
+ $cmd = call_user_func_array( 'wfEscapeShellArg', array_merge(
+ [ $wgImageMagickConvertCommand ],
+ $quality,
+ // Specify white background color, will be used for transparent images
+ // in Internet Explorer/Windows instead of default black.
+ [ '-background', 'white' ],
+ $decoderHint,
+ [ $this->escapeMagickInput( $params['srcPath'], $scene ) ],
+ $animation_pre,
+ // For the -thumbnail option a "!" is needed to force exact size,
+ // or ImageMagick may decide your ratio is wrong and slice off
+ // a pixel.
+ [ '-thumbnail', "{$width}x{$height}!" ],
+ // Add the source url as a comment to the thumb, but don't add the flag if there's no comment
+ ( $params['comment'] !== ''
+ ? [ '-set', 'comment', $this->escapeMagickProperty( $params['comment'] ) ]
+ : [] ),
+ // T108616: Avoid exposure of local file path
+ [ '+set', 'Thumb::URI' ],
+ [ '-depth', 8 ],
+ $sharpen,
+ [ '-rotate', "-$rotation" ],
+ $subsampling,
+ $animation_post,
+ [ $this->escapeMagickOutput( $params['dstPath'] ) ] ) );
+
+ wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" );
+ $retval = 0;
+ $err = wfShellExecWithStderr( $cmd, $retval, $env );
+
+ if ( $retval !== 0 ) {
+ $this->logErrorForExternalProcess( $retval, $err, $cmd );
+
+ return $this->getMediaTransformError( $params, "$err\nError code: $retval" );
+ }
+
+ return false; # No error
+ }
+
+ /**
+ * Transform an image using the Imagick PHP extension
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $params Array with scaler params
+ *
+ * @return MediaTransformError Error|bool object if error occurred, false (=no error) otherwise
+ */
+ protected function transformImageMagickExt( $image, $params ) {
+ global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea,
+ $wgJpegPixelFormat;
+
+ try {
+ $im = new Imagick();
+ $im->readImage( $params['srcPath'] );
+
+ if ( $params['mimeType'] == 'image/jpeg' ) {
+ // Sharpening, see T8193
+ if ( ( $params['physicalWidth'] + $params['physicalHeight'] )
+ / ( $params['srcWidth'] + $params['srcHeight'] )
+ < $wgSharpenReductionThreshold
+ ) {
+ // Hack, since $wgSharpenParameter is written specifically for the command line convert
+ list( $radius, $sigma ) = explode( 'x', $wgSharpenParameter );
+ $im->sharpenImage( $radius, $sigma );
+ }
+ $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null;
+ $im->setCompressionQuality( $qualityVal ?: 80 );
+ if ( $params['interlace'] ) {
+ $im->setInterlaceScheme( Imagick::INTERLACE_JPEG );
+ }
+ if ( $wgJpegPixelFormat ) {
+ $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat );
+ $im->setSamplingFactors( $factors );
+ }
+ } elseif ( $params['mimeType'] == 'image/png' ) {
+ $im->setCompressionQuality( 95 );
+ if ( $params['interlace'] ) {
+ $im->setInterlaceScheme( Imagick::INTERLACE_PNG );
+ }
+ } elseif ( $params['mimeType'] == 'image/gif' ) {
+ if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) {
+ // Extract initial frame only; we're so big it'll
+ // be a total drag. :P
+ $im->setImageScene( 0 );
+ } elseif ( $this->isAnimatedImage( $image ) ) {
+ // Coalesce is needed to scale animated GIFs properly (T3017).
+ $im = $im->coalesceImages();
+ }
+ // GIF interlacing is only available since 6.3.4
+ $v = Imagick::getVersion();
+ preg_match( '/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $v['versionString'], $v );
+
+ if ( $params['interlace'] && version_compare( $v[1], '6.3.4' ) >= 0 ) {
+ $im->setInterlaceScheme( Imagick::INTERLACE_GIF );
+ }
+ }
+
+ $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image );
+ list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation );
+
+ $im->setImageBackgroundColor( new ImagickPixel( 'white' ) );
+
+ // Call Imagick::thumbnailImage on each frame
+ foreach ( $im as $i => $frame ) {
+ if ( !$frame->thumbnailImage( $width, $height, /* fit */ false ) ) {
+ return $this->getMediaTransformError( $params, "Error scaling frame $i" );
+ }
+ }
+ $im->setImageDepth( 8 );
+
+ if ( $rotation ) {
+ if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) {
+ return $this->getMediaTransformError( $params, "Error rotating $rotation degrees" );
+ }
+ }
+
+ if ( $this->isAnimatedImage( $image ) ) {
+ wfDebug( __METHOD__ . ": Writing animated thumbnail\n" );
+ // This is broken somehow... can't find out how to fix it
+ $result = $im->writeImages( $params['dstPath'], true );
+ } else {
+ $result = $im->writeImage( $params['dstPath'] );
+ }
+ if ( !$result ) {
+ return $this->getMediaTransformError( $params,
+ "Unable to write thumbnail to {$params['dstPath']}" );
+ }
+ } catch ( ImagickException $e ) {
+ return $this->getMediaTransformError( $params, $e->getMessage() );
+ }
+
+ return false;
+ }
+
+ /**
+ * Transform an image using a custom command
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $params Array with scaler params
+ *
+ * @return MediaTransformError Error|bool object if error occurred, false (=no error) otherwise
+ */
+ protected function transformCustom( $image, $params ) {
+ # Use a custom convert command
+ global $wgCustomConvertCommand;
+
+ # Variables: %s %d %w %h
+ $src = wfEscapeShellArg( $params['srcPath'] );
+ $dst = wfEscapeShellArg( $params['dstPath'] );
+ $cmd = $wgCustomConvertCommand;
+ $cmd = str_replace( '%s', $src, str_replace( '%d', $dst, $cmd ) ); # Filenames
+ $cmd = str_replace( '%h', wfEscapeShellArg( $params['physicalHeight'] ),
+ str_replace( '%w', wfEscapeShellArg( $params['physicalWidth'] ), $cmd ) ); # Size
+ wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" );
+ $retval = 0;
+ $err = wfShellExecWithStderr( $cmd, $retval );
+
+ if ( $retval !== 0 ) {
+ $this->logErrorForExternalProcess( $retval, $err, $cmd );
+
+ return $this->getMediaTransformError( $params, $err );
+ }
+
+ return false; # No error
+ }
+
+ /**
+ * Transform an image using the built in GD library
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $params Array with scaler params
+ *
+ * @return MediaTransformError|bool Error object if error occurred, false (=no error) otherwise
+ */
+ protected function transformGd( $image, $params ) {
+ # Use PHP's builtin GD library functions.
+ # First find out what kind of file this is, and select the correct
+ # input routine for this.
+
+ $typemap = [
+ 'image/gif' => [ 'imagecreatefromgif', 'palette', false, 'imagegif' ],
+ 'image/jpeg' => [ 'imagecreatefromjpeg', 'truecolor', true,
+ [ __CLASS__, 'imageJpegWrapper' ] ],
+ 'image/png' => [ 'imagecreatefrompng', 'bits', false, 'imagepng' ],
+ 'image/vnd.wap.wbmp' => [ 'imagecreatefromwbmp', 'palette', false, 'imagewbmp' ],
+ 'image/xbm' => [ 'imagecreatefromxbm', 'palette', false, 'imagexbm' ],
+ ];
+
+ if ( !isset( $typemap[$params['mimeType']] ) ) {
+ $err = 'Image type not supported';
+ wfDebug( "$err\n" );
+ $errMsg = wfMessage( 'thumbnail_image-type' )->text();
+
+ return $this->getMediaTransformError( $params, $errMsg );
+ }
+ list( $loader, $colorStyle, $useQuality, $saveType ) = $typemap[$params['mimeType']];
+
+ if ( !function_exists( $loader ) ) {
+ $err = "Incomplete GD library configuration: missing function $loader";
+ wfDebug( "$err\n" );
+ $errMsg = wfMessage( 'thumbnail_gd-library', $loader )->text();
+
+ return $this->getMediaTransformError( $params, $errMsg );
+ }
+
+ if ( !file_exists( $params['srcPath'] ) ) {
+ $err = "File seems to be missing: {$params['srcPath']}";
+ wfDebug( "$err\n" );
+ $errMsg = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text();
+
+ return $this->getMediaTransformError( $params, $errMsg );
+ }
+
+ $src_image = call_user_func( $loader, $params['srcPath'] );
+
+ $rotation = function_exists( 'imagerotate' ) && !isset( $params['disableRotation'] ) ?
+ $this->getRotation( $image ) :
+ 0;
+ list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation );
+ $dst_image = imagecreatetruecolor( $width, $height );
+
+ // Initialise the destination image to transparent instead of
+ // the default solid black, to support PNG and GIF transparency nicely
+ $background = imagecolorallocate( $dst_image, 0, 0, 0 );
+ imagecolortransparent( $dst_image, $background );
+ imagealphablending( $dst_image, false );
+
+ if ( $colorStyle == 'palette' ) {
+ // Don't resample for paletted GIF images.
+ // It may just uglify them, and completely breaks transparency.
+ imagecopyresized( $dst_image, $src_image,
+ 0, 0, 0, 0,
+ $width, $height,
+ imagesx( $src_image ), imagesy( $src_image ) );
+ } else {
+ imagecopyresampled( $dst_image, $src_image,
+ 0, 0, 0, 0,
+ $width, $height,
+ imagesx( $src_image ), imagesy( $src_image ) );
+ }
+
+ if ( $rotation % 360 != 0 && $rotation % 90 == 0 ) {
+ $rot_image = imagerotate( $dst_image, $rotation, 0 );
+ imagedestroy( $dst_image );
+ $dst_image = $rot_image;
+ }
+
+ imagesavealpha( $dst_image, true );
+
+ $funcParams = [ $dst_image, $params['dstPath'] ];
+ if ( $useQuality && isset( $params['quality'] ) ) {
+ $funcParams[] = $params['quality'];
+ }
+ call_user_func_array( $saveType, $funcParams );
+
+ imagedestroy( $dst_image );
+ imagedestroy( $src_image );
+
+ return false; # No error
+ }
+
+ /**
+ * Callback for transformGd when transforming jpeg images.
+ */
+ // FIXME: transformImageMagick() & transformImageMagickExt() uses JPEG quality 80, here it's 95?
+ static function imageJpegWrapper( $dst_image, $thumbPath, $quality = 95 ) {
+ imageinterlace( $dst_image );
+ imagejpeg( $dst_image, $thumbPath, $quality );
+ }
+
+ /**
+ * Returns whether the current scaler supports rotation (im and gd do)
+ *
+ * @return bool
+ */
+ public function canRotate() {
+ $scaler = $this->getScalerType( null, false );
+ switch ( $scaler ) {
+ case 'im':
+ # ImageMagick supports autorotation
+ return true;
+ case 'imext':
+ # Imagick::rotateImage
+ return true;
+ case 'gd':
+ # GD's imagerotate function is used to rotate images, but not
+ # all precompiled PHP versions have that function
+ return function_exists( 'imagerotate' );
+ default:
+ # Other scalers don't support rotation
+ return false;
+ }
+ }
+
+ /**
+ * @see $wgEnableAutoRotation
+ * @return bool Whether auto rotation is enabled
+ */
+ public function autoRotateEnabled() {
+ global $wgEnableAutoRotation;
+
+ if ( $wgEnableAutoRotation === null ) {
+ // Only enable auto-rotation when we actually can
+ return $this->canRotate();
+ }
+
+ return $wgEnableAutoRotation;
+ }
+
+ /**
+ * @param File $file
+ * @param array $params Rotate parameters.
+ * 'rotation' clockwise rotation in degrees, allowed are multiples of 90
+ * @since 1.21
+ * @return bool|MediaTransformError
+ */
+ public function rotate( $file, $params ) {
+ global $wgImageMagickConvertCommand;
+
+ $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360;
+ $scene = false;
+
+ $scaler = $this->getScalerType( null, false );
+ switch ( $scaler ) {
+ case 'im':
+ $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " .
+ wfEscapeShellArg( $this->escapeMagickInput( $params['srcPath'], $scene ) ) .
+ " -rotate " . wfEscapeShellArg( "-$rotation" ) . " " .
+ wfEscapeShellArg( $this->escapeMagickOutput( $params['dstPath'] ) );
+ wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" );
+ $retval = 0;
+ $err = wfShellExecWithStderr( $cmd, $retval );
+ if ( $retval !== 0 ) {
+ $this->logErrorForExternalProcess( $retval, $err, $cmd );
+
+ return new MediaTransformError( 'thumbnail_error', 0, 0, $err );
+ }
+
+ return false;
+ case 'imext':
+ $im = new Imagick();
+ $im->readImage( $params['srcPath'] );
+ if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) {
+ return new MediaTransformError( 'thumbnail_error', 0, 0,
+ "Error rotating $rotation degrees" );
+ }
+ $result = $im->writeImage( $params['dstPath'] );
+ if ( !$result ) {
+ return new MediaTransformError( 'thumbnail_error', 0, 0,
+ "Unable to write image to {$params['dstPath']}" );
+ }
+
+ return false;
+ default:
+ return new MediaTransformError( 'thumbnail_error', 0, 0,
+ "$scaler rotation not implemented" );
+ }
+ }
+}
diff --git a/www/wiki/includes/media/BitmapMetadataHandler.php b/www/wiki/includes/media/BitmapMetadataHandler.php
new file mode 100644
index 00000000..35c97518
--- /dev/null
+++ b/www/wiki/includes/media/BitmapMetadataHandler.php
@@ -0,0 +1,316 @@
+<?php
+/**
+ * Extraction of metadata from different bitmap image types.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Class to deal with reconciling and extracting metadata from bitmap images.
+ * This is meant to comply with http://www.metadataworkinggroup.org/pdf/mwg_guidance.pdf
+ *
+ * This sort of acts as an intermediary between MediaHandler::getMetadata
+ * and the various metadata extractors.
+ *
+ * @todo Other image formats.
+ * @ingroup Media
+ */
+class BitmapMetadataHandler {
+ /** @var array */
+ private $metadata = [];
+
+ /** @var array Metadata priority */
+ private $metaPriority = [
+ 20 => [ 'other' ],
+ 40 => [ 'native' ],
+ 60 => [ 'iptc-good-hash', 'iptc-no-hash' ],
+ 70 => [ 'xmp-deprecated' ],
+ 80 => [ 'xmp-general' ],
+ 90 => [ 'xmp-exif' ],
+ 100 => [ 'iptc-bad-hash' ],
+ 120 => [ 'exif' ],
+ ];
+
+ /** @var string */
+ private $iptcType = 'iptc-no-hash';
+
+ /**
+ * This does the photoshop image resource app13 block
+ * of interest, IPTC-IIM metadata is stored here.
+ *
+ * Mostly just calls doPSIR and doIPTC
+ *
+ * @param string $app13 String containing app13 block from jpeg file
+ */
+ private function doApp13( $app13 ) {
+ try {
+ $this->iptcType = JpegMetadataExtractor::doPSIR( $app13 );
+ } catch ( Exception $e ) {
+ // Error reading the iptc hash information.
+ // This probably means the App13 segment is something other than what we expect.
+ // However, still try to read it, and treat it as if the hash didn't exist.
+ wfDebug( "Error parsing iptc data of file: " . $e->getMessage() . "\n" );
+ $this->iptcType = 'iptc-no-hash';
+ }
+
+ $iptc = IPTC::parse( $app13 );
+ $this->addMetadata( $iptc, $this->iptcType );
+ }
+
+ /**
+ * Get exif info using exif class.
+ * Basically what used to be in BitmapHandler::getMetadata().
+ * Just calls stuff in the Exif class.
+ *
+ * Parameters are passed to the Exif class.
+ *
+ * @param string $filename
+ * @param string $byteOrder
+ */
+ function getExif( $filename, $byteOrder ) {
+ global $wgShowEXIF;
+ if ( file_exists( $filename ) && $wgShowEXIF ) {
+ $exif = new Exif( $filename, $byteOrder );
+ $data = $exif->getFilteredData();
+ if ( $data ) {
+ $this->addMetadata( $data, 'exif' );
+ }
+ }
+ }
+
+ /** Add misc metadata. Warning: atm if the metadata category
+ * doesn't have a priority, it will be silently discarded.
+ *
+ * @param array $metaArray Array of metadata values
+ * @param string $type Type. defaults to other. if two things have the same type they're merged
+ */
+ function addMetadata( $metaArray, $type = 'other' ) {
+ if ( isset( $this->metadata[$type] ) ) {
+ /* merge with old data */
+ $metaArray = $metaArray + $this->metadata[$type];
+ }
+
+ $this->metadata[$type] = $metaArray;
+ }
+
+ /**
+ * Merge together the various types of metadata
+ * the different types have different priorites,
+ * and are merged in order.
+ *
+ * This function is generally called by the media handlers' getMetadata()
+ *
+ * @return array Metadata array
+ */
+ function getMetadataArray() {
+ // this seems a bit ugly... This is all so its merged in right order
+ // based on the MWG recomendation.
+ $temp = [];
+ krsort( $this->metaPriority );
+ foreach ( $this->metaPriority as $pri ) {
+ foreach ( $pri as $type ) {
+ if ( isset( $this->metadata[$type] ) ) {
+ // Do some special casing for multilingual values.
+ // Don't discard translations if also as a simple value.
+ foreach ( $this->metadata[$type] as $itemName => $item ) {
+ if ( is_array( $item ) && isset( $item['_type'] ) && $item['_type'] === 'lang' ) {
+ if ( isset( $temp[$itemName] ) && !is_array( $temp[$itemName] ) ) {
+ $default = $temp[$itemName];
+ $temp[$itemName] = $item;
+ $temp[$itemName]['x-default'] = $default;
+ unset( $this->metadata[$type][$itemName] );
+ }
+ }
+ }
+
+ $temp = $temp + $this->metadata[$type];
+ }
+ }
+ }
+
+ return $temp;
+ }
+
+ /** Main entry point for jpeg's.
+ *
+ * @param string $filename Filename (with full path)
+ * @return array Metadata result array.
+ * @throws MWException On invalid file.
+ */
+ static function Jpeg( $filename ) {
+ $showXMP = XMPReader::isSupported();
+ $meta = new self();
+
+ $seg = JpegMetadataExtractor::segmentSplitter( $filename );
+
+ if ( isset( $seg['COM'] ) && isset( $seg['COM'][0] ) ) {
+ $meta->addMetadata( [ 'JPEGFileComment' => $seg['COM'] ], 'native' );
+ }
+ if ( isset( $seg['PSIR'] ) && count( $seg['PSIR'] ) > 0 ) {
+ foreach ( $seg['PSIR'] as $curPSIRValue ) {
+ $meta->doApp13( $curPSIRValue );
+ }
+ }
+ if ( isset( $seg['XMP'] ) && $showXMP ) {
+ $xmp = new XMPReader( LoggerFactory::getInstance( 'XMP' ) );
+ $xmp->parse( $seg['XMP'] );
+ foreach ( $seg['XMP_ext'] as $xmpExt ) {
+ /* Support for extended xmp in jpeg files
+ * is not well tested and a bit fragile.
+ */
+ $xmp->parseExtended( $xmpExt );
+ }
+ $res = $xmp->getResults();
+ foreach ( $res as $type => $array ) {
+ $meta->addMetadata( $array, $type );
+ }
+ }
+
+ $meta->getExif( $filename, isset( $seg['byteOrder'] ) ? $seg['byteOrder'] : 'BE' );
+
+ return $meta->getMetadataArray();
+ }
+
+ /** Entry point for png
+ * At some point in the future this might
+ * merge the png various tEXt chunks to that
+ * are interesting, but for now it only does XMP
+ *
+ * @param string $filename Full path to file
+ * @return array Array for storage in img_metadata.
+ */
+ public static function PNG( $filename ) {
+ $showXMP = XMPReader::isSupported();
+
+ $meta = new self();
+ $array = PNGMetadataExtractor::getMetadata( $filename );
+ if ( isset( $array['text']['xmp']['x-default'] )
+ && $array['text']['xmp']['x-default'] !== '' && $showXMP
+ ) {
+ $xmp = new XMPReader( LoggerFactory::getInstance( 'XMP' ) );
+ $xmp->parse( $array['text']['xmp']['x-default'] );
+ $xmpRes = $xmp->getResults();
+ foreach ( $xmpRes as $type => $xmpSection ) {
+ $meta->addMetadata( $xmpSection, $type );
+ }
+ }
+ unset( $array['text']['xmp'] );
+ $meta->addMetadata( $array['text'], 'native' );
+ unset( $array['text'] );
+ $array['metadata'] = $meta->getMetadataArray();
+ $array['metadata']['_MW_PNG_VERSION'] = PNGMetadataExtractor::VERSION;
+
+ return $array;
+ }
+
+ /** function for gif images.
+ *
+ * They don't really have native metadata, so just merges together
+ * XMP and image comment.
+ *
+ * @param string $filename Full path to file
+ * @return array Metadata array
+ */
+ public static function GIF( $filename ) {
+ $meta = new self();
+ $baseArray = GIFMetadataExtractor::getMetadata( $filename );
+
+ if ( count( $baseArray['comment'] ) > 0 ) {
+ $meta->addMetadata( [ 'GIFFileComment' => $baseArray['comment'] ], 'native' );
+ }
+
+ if ( $baseArray['xmp'] !== '' && XMPReader::isSupported() ) {
+ $xmp = new XMPReader( LoggerFactory::getInstance( 'XMP' ) );
+ $xmp->parse( $baseArray['xmp'] );
+ $xmpRes = $xmp->getResults();
+ foreach ( $xmpRes as $type => $xmpSection ) {
+ $meta->addMetadata( $xmpSection, $type );
+ }
+ }
+
+ unset( $baseArray['comment'] );
+ unset( $baseArray['xmp'] );
+
+ $baseArray['metadata'] = $meta->getMetadataArray();
+ $baseArray['metadata']['_MW_GIF_VERSION'] = GIFMetadataExtractor::VERSION;
+
+ return $baseArray;
+ }
+
+ /**
+ * This doesn't do much yet, but eventually I plan to add
+ * XMP support for Tiff. (PHP's exif support already extracts
+ * but needs some further processing because PHP's exif support
+ * is stupid...)
+ *
+ * @todo Add XMP support, so this function actually makes sense to put here.
+ *
+ * The various exceptions this throws are caught later.
+ * @param string $filename
+ * @throws MWException
+ * @return array The metadata.
+ */
+ public static function Tiff( $filename ) {
+ if ( file_exists( $filename ) ) {
+ $byteOrder = self::getTiffByteOrder( $filename );
+ if ( !$byteOrder ) {
+ throw new MWException( "Error determining byte order of $filename" );
+ }
+ $exif = new Exif( $filename, $byteOrder );
+ $data = $exif->getFilteredData();
+ if ( $data ) {
+ $data['MEDIAWIKI_EXIF_VERSION'] = Exif::version();
+
+ return $data;
+ } else {
+ throw new MWException( "Could not extract data from tiff file $filename" );
+ }
+ } else {
+ throw new MWException( "File doesn't exist - $filename" );
+ }
+ }
+
+ /**
+ * Read the first 2 bytes of a tiff file to figure out
+ * Little Endian or Big Endian. Needed for exif stuff.
+ *
+ * @param string $filename The filename
+ * @return string 'BE' or 'LE' or false
+ */
+ static function getTiffByteOrder( $filename ) {
+ $fh = fopen( $filename, 'rb' );
+ if ( !$fh ) {
+ return false;
+ }
+ $head = fread( $fh, 2 );
+ fclose( $fh );
+
+ switch ( $head ) {
+ case 'II':
+ return 'LE'; // II for intel.
+ case 'MM':
+ return 'BE'; // MM for motorla.
+ default:
+ return false; // Something went wrong.
+
+ }
+ }
+}
diff --git a/www/wiki/includes/media/Bitmap_ClientOnly.php b/www/wiki/includes/media/Bitmap_ClientOnly.php
new file mode 100644
index 00000000..3ec87723
--- /dev/null
+++ b/www/wiki/includes/media/Bitmap_ClientOnly.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * Handler for bitmap images that will be resized by clients.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Handler for bitmap images that will be resized by clients.
+ *
+ * This is not used by default but can be assigned to some image types
+ * using $wgMediaHandlers.
+ *
+ * @ingroup Media
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class BitmapHandler_ClientOnly extends BitmapHandler {
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * @param File $image
+ * @param array &$params
+ * @return bool
+ */
+ function normaliseParams( $image, &$params ) {
+ return ImageHandler::normaliseParams( $image, $params );
+ }
+
+ /**
+ * @param File $image
+ * @param string $dstPath
+ * @param string $dstUrl
+ * @param array $params
+ * @param int $flags
+ * @return ThumbnailImage|TransformParameterError
+ */
+ function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
+ if ( !$this->normaliseParams( $image, $params ) ) {
+ return new TransformParameterError( $params );
+ }
+
+ return new ThumbnailImage( $image, $image->getUrl(), $image->getLocalRefPath(), $params );
+ }
+}
diff --git a/www/wiki/includes/media/DjVu.php b/www/wiki/includes/media/DjVu.php
new file mode 100644
index 00000000..aae66d37
--- /dev/null
+++ b/www/wiki/includes/media/DjVu.php
@@ -0,0 +1,464 @@
+<?php
+/**
+ * Handler for DjVu images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Handler for DjVu images
+ *
+ * @ingroup Media
+ */
+class DjVuHandler extends ImageHandler {
+ const EXPENSIVE_SIZE_LIMIT = 10485760; // 10MiB
+
+ /**
+ * @return bool
+ */
+ function isEnabled() {
+ global $wgDjvuRenderer, $wgDjvuDump, $wgDjvuToXML;
+ if ( !$wgDjvuRenderer || ( !$wgDjvuDump && !$wgDjvuToXML ) ) {
+ wfDebug( "DjVu is disabled, please set \$wgDjvuRenderer and \$wgDjvuDump\n" );
+
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * @param File $file
+ * @return bool
+ */
+ public function mustRender( $file ) {
+ return true;
+ }
+
+ /**
+ * True if creating thumbnails from the file is large or otherwise resource-intensive.
+ * @param File $file
+ * @return bool
+ */
+ public function isExpensiveToThumbnail( $file ) {
+ return $file->getSize() > static::EXPENSIVE_SIZE_LIMIT;
+ }
+
+ /**
+ * @param File $file
+ * @return bool
+ */
+ public function isMultiPage( $file ) {
+ return true;
+ }
+
+ /**
+ * @return array
+ */
+ public function getParamMap() {
+ return [
+ 'img_width' => 'width',
+ 'img_page' => 'page',
+ ];
+ }
+
+ /**
+ * @param string $name
+ * @param mixed $value
+ * @return bool
+ */
+ public function validateParam( $name, $value ) {
+ if ( $name === 'page' && trim( $value ) !== (string)intval( $value ) ) {
+ // Extra junk on the end of page, probably actually a caption
+ // e.g. [[File:Foo.djvu|thumb|Page 3 of the document shows foo]]
+ return false;
+ }
+ if ( in_array( $name, [ 'width', 'height', 'page' ] ) ) {
+ if ( $value <= 0 ) {
+ return false;
+ } else {
+ return true;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param array $params
+ * @return bool|string
+ */
+ public function makeParamString( $params ) {
+ $page = isset( $params['page'] ) ? $params['page'] : 1;
+ if ( !isset( $params['width'] ) ) {
+ return false;
+ }
+
+ return "page{$page}-{$params['width']}px";
+ }
+
+ /**
+ * @param string $str
+ * @return array|bool
+ */
+ public function parseParamString( $str ) {
+ $m = false;
+ if ( preg_match( '/^page(\d+)-(\d+)px$/', $str, $m ) ) {
+ return [ 'width' => $m[2], 'page' => $m[1] ];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param array $params
+ * @return array
+ */
+ function getScriptParams( $params ) {
+ return [
+ 'width' => $params['width'],
+ 'page' => $params['page'],
+ ];
+ }
+
+ /**
+ * @param File $image
+ * @param string $dstPath
+ * @param string $dstUrl
+ * @param array $params
+ * @param int $flags
+ * @return MediaTransformError|ThumbnailImage|TransformParameterError
+ */
+ function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
+ global $wgDjvuRenderer, $wgDjvuPostProcessor;
+
+ if ( !$this->normaliseParams( $image, $params ) ) {
+ return new TransformParameterError( $params );
+ }
+ $width = $params['width'];
+ $height = $params['height'];
+ $page = $params['page'];
+
+ if ( $flags & self::TRANSFORM_LATER ) {
+ $params = [
+ 'width' => $width,
+ 'height' => $height,
+ 'page' => $page
+ ];
+
+ return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
+ }
+
+ if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
+ return new MediaTransformError(
+ 'thumbnail_error',
+ $width,
+ $height,
+ wfMessage( 'thumbnail_dest_directory' )
+ );
+ }
+
+ // Get local copy source for shell scripts
+ // Thumbnail extraction is very inefficient for large files.
+ // Provide a way to pool count limit the number of downloaders.
+ if ( $image->getSize() >= 1e7 ) { // 10MB
+ $work = new PoolCounterWorkViaCallback( 'GetLocalFileCopy', sha1( $image->getName() ),
+ [
+ 'doWork' => function () use ( $image ) {
+ return $image->getLocalRefPath();
+ }
+ ]
+ );
+ $srcPath = $work->execute();
+ } else {
+ $srcPath = $image->getLocalRefPath();
+ }
+
+ if ( $srcPath === false ) { // Failed to get local copy
+ wfDebugLog( 'thumbnail',
+ sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
+ wfHostname(), $image->getName() ) );
+
+ return new MediaTransformError( 'thumbnail_error',
+ $params['width'], $params['height'],
+ wfMessage( 'filemissing' )
+ );
+ }
+
+ # Use a subshell (brackets) to aggregate stderr from both pipeline commands
+ # before redirecting it to the overall stdout. This works in both Linux and Windows XP.
+ $cmd = '(' . wfEscapeShellArg(
+ $wgDjvuRenderer,
+ "-format=ppm",
+ "-page={$page}",
+ "-size={$params['physicalWidth']}x{$params['physicalHeight']}",
+ $srcPath );
+ if ( $wgDjvuPostProcessor ) {
+ $cmd .= " | {$wgDjvuPostProcessor}";
+ }
+ $cmd .= ' > ' . wfEscapeShellArg( $dstPath ) . ') 2>&1';
+ wfDebug( __METHOD__ . ": $cmd\n" );
+ $retval = '';
+ $err = wfShellExec( $cmd, $retval );
+
+ $removed = $this->removeBadFile( $dstPath, $retval );
+ if ( $retval != 0 || $removed ) {
+ $this->logErrorForExternalProcess( $retval, $err, $cmd );
+ return new MediaTransformError( 'thumbnail_error', $width, $height, $err );
+ } else {
+ $params = [
+ 'width' => $width,
+ 'height' => $height,
+ 'page' => $page
+ ];
+
+ return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
+ }
+ }
+
+ /**
+ * Cache an instance of DjVuImage in an Image object, return that instance
+ *
+ * @param File|FSFile $image
+ * @param string $path
+ * @return DjVuImage
+ */
+ function getDjVuImage( $image, $path ) {
+ if ( !$image ) {
+ $deja = new DjVuImage( $path );
+ } elseif ( !isset( $image->dejaImage ) ) {
+ $deja = $image->dejaImage = new DjVuImage( $path );
+ } else {
+ $deja = $image->dejaImage;
+ }
+
+ return $deja;
+ }
+
+ /**
+ * Get metadata, unserializing it if neccessary.
+ *
+ * @param File $file The DjVu file in question
+ * @return string XML metadata as a string.
+ * @throws MWException
+ */
+ private function getUnserializedMetadata( File $file ) {
+ $metadata = $file->getMetadata();
+ if ( substr( $metadata, 0, 3 ) === '<?xml' ) {
+ // Old style. Not serialized but instead just a raw string of XML.
+ return $metadata;
+ }
+
+ MediaWiki\suppressWarnings();
+ $unser = unserialize( $metadata );
+ MediaWiki\restoreWarnings();
+ if ( is_array( $unser ) ) {
+ if ( isset( $unser['error'] ) ) {
+ return false;
+ } elseif ( isset( $unser['xml'] ) ) {
+ return $unser['xml'];
+ } else {
+ // Should never ever reach here.
+ throw new MWException( "Error unserializing DjVu metadata." );
+ }
+ }
+
+ // unserialize failed. Guess it wasn't really serialized after all,
+ return $metadata;
+ }
+
+ /**
+ * Cache a document tree for the DjVu XML metadata
+ * @param File $image
+ * @param bool $gettext DOCUMENT (Default: false)
+ * @return bool|SimpleXMLElement
+ */
+ public function getMetaTree( $image, $gettext = false ) {
+ if ( $gettext && isset( $image->djvuTextTree ) ) {
+ return $image->djvuTextTree;
+ }
+ if ( !$gettext && isset( $image->dejaMetaTree ) ) {
+ return $image->dejaMetaTree;
+ }
+
+ $metadata = $this->getUnserializedMetadata( $image );
+ if ( !$this->isMetadataValid( $image, $metadata ) ) {
+ wfDebug( "DjVu XML metadata is invalid or missing, should have been fixed in upgradeRow\n" );
+
+ return false;
+ }
+
+ $trees = $this->extractTreesFromMetadata( $metadata );
+ $image->djvuTextTree = $trees['TextTree'];
+ $image->dejaMetaTree = $trees['MetaTree'];
+
+ if ( $gettext ) {
+ return $image->djvuTextTree;
+ } else {
+ return $image->dejaMetaTree;
+ }
+ }
+
+ /**
+ * Extracts metadata and text trees from metadata XML in string form
+ * @param string $metadata XML metadata as a string
+ * @return array
+ */
+ protected function extractTreesFromMetadata( $metadata ) {
+ MediaWiki\suppressWarnings();
+ try {
+ // Set to false rather than null to avoid further attempts
+ $metaTree = false;
+ $textTree = false;
+ $tree = new SimpleXMLElement( $metadata, LIBXML_PARSEHUGE );
+ if ( $tree->getName() == 'mw-djvu' ) {
+ /** @var SimpleXMLElement $b */
+ foreach ( $tree->children() as $b ) {
+ if ( $b->getName() == 'DjVuTxt' ) {
+ // @todo File::djvuTextTree and File::dejaMetaTree are declared
+ // dynamically. Add a public File::$data to facilitate this?
+ $textTree = $b;
+ } elseif ( $b->getName() == 'DjVuXML' ) {
+ $metaTree = $b;
+ }
+ }
+ } else {
+ $metaTree = $tree;
+ }
+ } catch ( Exception $e ) {
+ wfDebug( "Bogus multipage XML metadata\n" );
+ }
+ MediaWiki\restoreWarnings();
+
+ return [ 'MetaTree' => $metaTree, 'TextTree' => $textTree ];
+ }
+
+ function getImageSize( $image, $path ) {
+ return $this->getDjVuImage( $image, $path )->getImageSize();
+ }
+
+ function getThumbType( $ext, $mime, $params = null ) {
+ global $wgDjvuOutputExtension;
+ static $mime;
+ if ( !isset( $mime ) ) {
+ $magic = MimeMagic::singleton();
+ $mime = $magic->guessTypesForExtension( $wgDjvuOutputExtension );
+ }
+
+ return [ $wgDjvuOutputExtension, $mime ];
+ }
+
+ function getMetadata( $image, $path ) {
+ wfDebug( "Getting DjVu metadata for $path\n" );
+
+ $xml = $this->getDjVuImage( $image, $path )->retrieveMetaData();
+ if ( $xml === false ) {
+ // Special value so that we don't repetitively try and decode a broken file.
+ return serialize( [ 'error' => 'Error extracting metadata' ] );
+ } else {
+ return serialize( [ 'xml' => $xml ] );
+ }
+ }
+
+ function getMetadataType( $image ) {
+ return 'djvuxml';
+ }
+
+ function isMetadataValid( $image, $metadata ) {
+ return !empty( $metadata ) && $metadata != serialize( [] );
+ }
+
+ function pageCount( File $image ) {
+ $info = $this->getDimensionInfo( $image );
+
+ return $info ? $info['pageCount'] : false;
+ }
+
+ function getPageDimensions( File $image, $page ) {
+ $index = $page - 1; // MW starts pages at 1
+
+ $info = $this->getDimensionInfo( $image );
+ if ( $info && isset( $info['dimensionsByPage'][$index] ) ) {
+ return $info['dimensionsByPage'][$index];
+ }
+
+ return false;
+ }
+
+ protected function getDimensionInfo( File $file ) {
+ $cache = ObjectCache::getMainWANInstance();
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'file-djvu', 'dimensions', $file->getSha1() ),
+ $cache::TTL_INDEFINITE,
+ function () use ( $file ) {
+ $tree = $this->getMetaTree( $file );
+ return $this->getDimensionInfoFromMetaTree( $tree );
+ },
+ [ 'pcTTL' => $cache::TTL_INDEFINITE ]
+ );
+ }
+
+ /**
+ * Given an XML metadata tree, returns dimension information about the document
+ * @param bool|SimpleXMLElement $metatree The file's XML metadata tree
+ * @return bool|array
+ */
+ protected function getDimensionInfoFromMetaTree( $metatree ) {
+ if ( !$metatree ) {
+ return false;
+ }
+
+ $dimsByPage = [];
+ $count = count( $metatree->xpath( '//OBJECT' ) );
+ for ( $i = 0; $i < $count; $i++ ) {
+ $o = $metatree->BODY[0]->OBJECT[$i];
+ if ( $o ) {
+ $dimsByPage[$i] = [
+ 'width' => (int)$o['width'],
+ 'height' => (int)$o['height'],
+ ];
+ } else {
+ $dimsByPage[$i] = false;
+ }
+ }
+
+ return [ 'pageCount' => $count, 'dimensionsByPage' => $dimsByPage ];
+ }
+
+ /**
+ * @param File $image
+ * @param int $page Page number to get information for
+ * @return bool|string Page text or false when no text found.
+ */
+ function getPageText( File $image, $page ) {
+ $tree = $this->getMetaTree( $image, true );
+ if ( !$tree ) {
+ return false;
+ }
+
+ $o = $tree->BODY[0]->PAGE[$page - 1];
+ if ( $o ) {
+ $txt = $o['value'];
+
+ return $txt;
+ } else {
+ return false;
+ }
+ }
+}
diff --git a/www/wiki/includes/media/DjVuImage.php b/www/wiki/includes/media/DjVuImage.php
new file mode 100644
index 00000000..d25111c4
--- /dev/null
+++ b/www/wiki/includes/media/DjVuImage.php
@@ -0,0 +1,408 @@
+<?php
+/**
+ * DjVu image handler.
+ *
+ * Copyright © 2006 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Support for detecting/validating DjVu image files and getting
+ * some basic file metadata (resolution etc)
+ *
+ * File format docs are available in source package for DjVuLibre:
+ * http://djvulibre.djvuzone.org/
+ *
+ * @ingroup Media
+ */
+class DjVuImage {
+ /**
+ * @const DJVUTXT_MEMORY_LIMIT Memory limit for the DjVu description software
+ */
+ const DJVUTXT_MEMORY_LIMIT = 300000;
+
+ /**
+ * @param string $filename The DjVu file name.
+ */
+ function __construct( $filename ) {
+ $this->mFilename = $filename;
+ }
+
+ /**
+ * Check if the given file is indeed a valid DjVu image file
+ * @return bool
+ */
+ public function isValid() {
+ $info = $this->getInfo();
+
+ return $info !== false;
+ }
+
+ /**
+ * Return data in the style of getimagesize()
+ * @return array|bool Array or false on failure
+ */
+ public function getImageSize() {
+ $data = $this->getInfo();
+
+ if ( $data !== false ) {
+ $width = $data['width'];
+ $height = $data['height'];
+
+ return [ $width, $height, 'DjVu',
+ "width=\"$width\" height=\"$height\"" ];
+ }
+
+ return false;
+ }
+
+ // ---------
+
+ /**
+ * For debugging; dump the IFF chunk structure
+ */
+ function dump() {
+ $file = fopen( $this->mFilename, 'rb' );
+ $header = fread( $file, 12 );
+ $arr = unpack( 'a4magic/a4chunk/NchunkLength', $header );
+ $chunk = $arr['chunk'];
+ $chunkLength = $arr['chunkLength'];
+ echo "$chunk $chunkLength\n";
+ $this->dumpForm( $file, $chunkLength, 1 );
+ fclose( $file );
+ }
+
+ private function dumpForm( $file, $length, $indent ) {
+ $start = ftell( $file );
+ $secondary = fread( $file, 4 );
+ echo str_repeat( ' ', $indent * 4 ) . "($secondary)\n";
+ while ( ftell( $file ) - $start < $length ) {
+ $chunkHeader = fread( $file, 8 );
+ if ( $chunkHeader == '' ) {
+ break;
+ }
+ $arr = unpack( 'a4chunk/NchunkLength', $chunkHeader );
+ $chunk = $arr['chunk'];
+ $chunkLength = $arr['chunkLength'];
+ echo str_repeat( ' ', $indent * 4 ) . "$chunk $chunkLength\n";
+
+ if ( $chunk == 'FORM' ) {
+ $this->dumpForm( $file, $chunkLength, $indent + 1 );
+ } else {
+ fseek( $file, $chunkLength, SEEK_CUR );
+ if ( $chunkLength & 1 == 1 ) {
+ // Padding byte between chunks
+ fseek( $file, 1, SEEK_CUR );
+ }
+ }
+ }
+ }
+
+ function getInfo() {
+ MediaWiki\suppressWarnings();
+ $file = fopen( $this->mFilename, 'rb' );
+ MediaWiki\restoreWarnings();
+ if ( $file === false ) {
+ wfDebug( __METHOD__ . ": missing or failed file read\n" );
+
+ return false;
+ }
+
+ $header = fread( $file, 16 );
+ $info = false;
+
+ if ( strlen( $header ) < 16 ) {
+ wfDebug( __METHOD__ . ": too short file header\n" );
+ } else {
+ $arr = unpack( 'a4magic/a4form/NformLength/a4subtype', $header );
+
+ $subtype = $arr['subtype'];
+ if ( $arr['magic'] != 'AT&T' ) {
+ wfDebug( __METHOD__ . ": not a DjVu file\n" );
+ } elseif ( $subtype == 'DJVU' ) {
+ // Single-page document
+ $info = $this->getPageInfo( $file );
+ } elseif ( $subtype == 'DJVM' ) {
+ // Multi-page document
+ $info = $this->getMultiPageInfo( $file, $arr['formLength'] );
+ } else {
+ wfDebug( __METHOD__ . ": unrecognized DJVU file type '{$arr['subtype']}'\n" );
+ }
+ }
+ fclose( $file );
+
+ return $info;
+ }
+
+ private function readChunk( $file ) {
+ $header = fread( $file, 8 );
+ if ( strlen( $header ) < 8 ) {
+ return [ false, 0 ];
+ } else {
+ $arr = unpack( 'a4chunk/Nlength', $header );
+
+ return [ $arr['chunk'], $arr['length'] ];
+ }
+ }
+
+ private function skipChunk( $file, $chunkLength ) {
+ fseek( $file, $chunkLength, SEEK_CUR );
+
+ if ( $chunkLength & 0x01 == 1 && !feof( $file ) ) {
+ // padding byte
+ fseek( $file, 1, SEEK_CUR );
+ }
+ }
+
+ private function getMultiPageInfo( $file, $formLength ) {
+ // For now, we'll just look for the first page in the file
+ // and report its information, hoping others are the same size.
+ $start = ftell( $file );
+ do {
+ list( $chunk, $length ) = $this->readChunk( $file );
+ if ( !$chunk ) {
+ break;
+ }
+
+ if ( $chunk == 'FORM' ) {
+ $subtype = fread( $file, 4 );
+ if ( $subtype == 'DJVU' ) {
+ wfDebug( __METHOD__ . ": found first subpage\n" );
+
+ return $this->getPageInfo( $file );
+ }
+ $this->skipChunk( $file, $length - 4 );
+ } else {
+ wfDebug( __METHOD__ . ": skipping '$chunk' chunk\n" );
+ $this->skipChunk( $file, $length );
+ }
+ } while ( $length != 0 && !feof( $file ) && ftell( $file ) - $start < $formLength );
+
+ wfDebug( __METHOD__ . ": multi-page DJVU file contained no pages\n" );
+
+ return false;
+ }
+
+ private function getPageInfo( $file ) {
+ list( $chunk, $length ) = $this->readChunk( $file );
+ if ( $chunk != 'INFO' ) {
+ wfDebug( __METHOD__ . ": expected INFO chunk, got '$chunk'\n" );
+
+ return false;
+ }
+
+ if ( $length < 9 ) {
+ wfDebug( __METHOD__ . ": INFO should be 9 or 10 bytes, found $length\n" );
+
+ return false;
+ }
+ $data = fread( $file, $length );
+ if ( strlen( $data ) < $length ) {
+ wfDebug( __METHOD__ . ": INFO chunk cut off\n" );
+
+ return false;
+ }
+
+ $arr = unpack(
+ 'nwidth/' .
+ 'nheight/' .
+ 'Cminor/' .
+ 'Cmajor/' .
+ 'vresolution/' .
+ 'Cgamma', $data );
+
+ # Newer files have rotation info in byte 10, but we don't use it yet.
+
+ return [
+ 'width' => $arr['width'],
+ 'height' => $arr['height'],
+ 'version' => "{$arr['major']}.{$arr['minor']}",
+ 'resolution' => $arr['resolution'],
+ 'gamma' => $arr['gamma'] / 10.0 ];
+ }
+
+ /**
+ * Return an XML string describing the DjVu image
+ * @return string|bool
+ */
+ function retrieveMetaData() {
+ global $wgDjvuToXML, $wgDjvuDump, $wgDjvuTxt;
+
+ if ( !$this->isValid() ) {
+ return false;
+ }
+
+ if ( isset( $wgDjvuDump ) ) {
+ # djvudump is faster as of version 3.5
+ # https://sourceforge.net/p/djvu/bugs/71/
+ $cmd = wfEscapeShellArg( $wgDjvuDump ) . ' ' . wfEscapeShellArg( $this->mFilename );
+ $dump = wfShellExec( $cmd );
+ $xml = $this->convertDumpToXML( $dump );
+ } elseif ( isset( $wgDjvuToXML ) ) {
+ $cmd = wfEscapeShellArg( $wgDjvuToXML ) . ' --without-anno --without-text ' .
+ wfEscapeShellArg( $this->mFilename );
+ $xml = wfShellExec( $cmd );
+ } else {
+ $xml = null;
+ }
+ # Text layer
+ if ( isset( $wgDjvuTxt ) ) {
+ $cmd = wfEscapeShellArg( $wgDjvuTxt ) . ' --detail=page ' . wfEscapeShellArg( $this->mFilename );
+ wfDebug( __METHOD__ . ": $cmd\n" );
+ $retval = '';
+ $txt = wfShellExec( $cmd, $retval, [], [ 'memory' => self::DJVUTXT_MEMORY_LIMIT ] );
+ if ( $retval == 0 ) {
+ # Strip some control characters
+ $txt = preg_replace( "/[\013\035\037]/", "", $txt );
+ $reg = <<<EOR
+ /\(page\s[\d-]*\s[\d-]*\s[\d-]*\s[\d-]*\s*"
+ ((?> # Text to match is composed of atoms of either:
+ \\\\. # - any escaped character
+ | # - any character different from " and \
+ [^"\\\\]+
+ )*?)
+ "\s*\)
+ | # Or page can be empty ; in this case, djvutxt dumps ()
+ \(\s*()\)/sx
+EOR;
+ $txt = preg_replace_callback( $reg, [ $this, 'pageTextCallback' ], $txt );
+ $txt = "<DjVuTxt>\n<HEAD></HEAD>\n<BODY>\n" . $txt . "</BODY>\n</DjVuTxt>\n";
+ $xml = preg_replace( "/<DjVuXML>/", "<mw-djvu><DjVuXML>", $xml, 1 );
+ $xml = $xml . $txt . '</mw-djvu>';
+ }
+ }
+
+ return $xml;
+ }
+
+ function pageTextCallback( $matches ) {
+ # Get rid of invalid UTF-8, strip control characters
+ $val = htmlspecialchars( UtfNormal\Validator::cleanUp( stripcslashes( $matches[1] ) ) );
+ $val = str_replace( [ "\n", '�' ], [ '&#10;', '' ], $val );
+ return '<PAGE value="' . $val . '" />';
+ }
+
+ /**
+ * Hack to temporarily work around djvutoxml bug
+ * @param string $dump
+ * @return string
+ */
+ function convertDumpToXML( $dump ) {
+ if ( strval( $dump ) == '' ) {
+ return false;
+ }
+
+ $xml = <<<EOT
+<?xml version="1.0" ?>
+<!DOCTYPE DjVuXML PUBLIC "-//W3C//DTD DjVuXML 1.1//EN" "pubtext/DjVuXML-s.dtd">
+<DjVuXML>
+<HEAD></HEAD>
+<BODY>
+EOT;
+
+ $dump = str_replace( "\r", '', $dump );
+ $line = strtok( $dump, "\n" );
+ $m = false;
+ $good = false;
+ if ( preg_match( '/^( *)FORM:DJVU/', $line, $m ) ) {
+ # Single-page
+ if ( $this->parseFormDjvu( $line, $xml ) ) {
+ $good = true;
+ } else {
+ return false;
+ }
+ } elseif ( preg_match( '/^( *)FORM:DJVM/', $line, $m ) ) {
+ # Multi-page
+ $parentLevel = strlen( $m[1] );
+ # Find DIRM
+ $line = strtok( "\n" );
+ while ( $line !== false ) {
+ $childLevel = strspn( $line, ' ' );
+ if ( $childLevel <= $parentLevel ) {
+ # End of chunk
+ break;
+ }
+
+ if ( preg_match( '/^ *DIRM.*indirect/', $line ) ) {
+ wfDebug( "Indirect multi-page DjVu document, bad for server!\n" );
+
+ return false;
+ }
+ if ( preg_match( '/^ *FORM:DJVU/', $line ) ) {
+ # Found page
+ if ( $this->parseFormDjvu( $line, $xml ) ) {
+ $good = true;
+ } else {
+ return false;
+ }
+ }
+ $line = strtok( "\n" );
+ }
+ }
+ if ( !$good ) {
+ return false;
+ }
+
+ $xml .= "</BODY>\n</DjVuXML>\n";
+
+ return $xml;
+ }
+
+ function parseFormDjvu( $line, &$xml ) {
+ $parentLevel = strspn( $line, ' ' );
+ $line = strtok( "\n" );
+
+ # Find INFO
+ while ( $line !== false ) {
+ $childLevel = strspn( $line, ' ' );
+ if ( $childLevel <= $parentLevel ) {
+ # End of chunk
+ break;
+ }
+
+ if ( preg_match(
+ '/^ *INFO *\[\d*\] *DjVu *(\d+)x(\d+), *\w*, *(\d+) *dpi, *gamma=([0-9.-]+)/',
+ $line,
+ $m
+ ) ) {
+ $xml .= Xml::tags(
+ 'OBJECT',
+ [
+ # 'data' => '',
+ # 'type' => 'image/x.djvu',
+ 'height' => $m[2],
+ 'width' => $m[1],
+ # 'usemap' => '',
+ ],
+ "\n" .
+ Xml::element( 'PARAM', [ 'name' => 'DPI', 'value' => $m[3] ] ) . "\n" .
+ Xml::element( 'PARAM', [ 'name' => 'GAMMA', 'value' => $m[4] ] ) . "\n"
+ ) . "\n";
+
+ return true;
+ }
+ $line = strtok( "\n" );
+ }
+
+ # Not found
+ return false;
+ }
+}
diff --git a/www/wiki/includes/media/Exif.php b/www/wiki/includes/media/Exif.php
new file mode 100644
index 00000000..cd457f0b
--- /dev/null
+++ b/www/wiki/includes/media/Exif.php
@@ -0,0 +1,854 @@
+<?php
+/**
+ * Extraction and validation of image metadata.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @ingroup Media
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason, 2009 Brent Garber
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ * @see http://exif.org/Exif2-2.PDF The Exif 2.2 specification
+ * @file
+ */
+
+/**
+ * Class to extract and validate Exif data from jpeg (and possibly tiff) files.
+ * @ingroup Media
+ */
+class Exif {
+ /** An 8-bit (1-byte) unsigned integer. */
+ const BYTE = 1;
+
+ /** An 8-bit byte containing one 7-bit ASCII code.
+ * The final byte is terminated with NULL.
+ */
+ const ASCII = 2;
+
+ /** A 16-bit (2-byte) unsigned integer. */
+ const SHORT = 3;
+
+ /** A 32-bit (4-byte) unsigned integer. */
+ const LONG = 4;
+
+ /** Two LONGs. The first LONG is the numerator and the second LONG expresses
+ * the denominator
+ */
+ const RATIONAL = 5;
+
+ /** A 16-bit (2-byte) or 32-bit (4-byte) unsigned integer. */
+ const SHORT_OR_LONG = 6;
+
+ /** An 8-bit byte that can take any value depending on the field definition */
+ const UNDEFINED = 7;
+
+ /** A 32-bit (4-byte) signed integer (2's complement notation), */
+ const SLONG = 9;
+
+ /** Two SLONGs. The first SLONG is the numerator and the second SLONG is
+ * the denominator.
+ */
+ const SRATIONAL = 10;
+
+ /** A fake value for things we don't want or don't support. */
+ const IGNORE = -1;
+
+ /** @var array Exif tags grouped by category, the tagname itself is the key
+ * and the type is the value, in the case of more than one possible value
+ * type they are separated by commas.
+ */
+ private $mExifTags;
+
+ /** @var array The raw Exif data returned by exif_read_data() */
+ private $mRawExifData;
+
+ /** @var array A Filtered version of $mRawExifData that has been pruned
+ * of invalid tags and tags that contain content they shouldn't contain
+ * according to the Exif specification
+ */
+ private $mFilteredExifData;
+
+ /** @var string The file being processed */
+ private $file;
+
+ /** @var string The basename of the file being processed */
+ private $basename;
+
+ /** @var string The private log to log to, e.g. 'exif' */
+ private $log = false;
+
+ /** @var string The byte order of the file. Needed because php's extension
+ * doesn't fully process some obscure props.
+ */
+ private $byteOrder;
+
+ /**
+ * @param string $file Filename.
+ * @param string $byteOrder Type of byte ordering either 'BE' (Big Endian)
+ * or 'LE' (Little Endian). Default ''.
+ * @throws MWException
+ * @todo FIXME: The following are broke:
+ * SubjectArea. Need to test the more obscure tags.
+ * DigitalZoomRatio = 0/0 is rejected. need to determine if that's valid.
+ * Possibly should treat 0/0 = 0. need to read exif spec on that.
+ */
+ function __construct( $file, $byteOrder = '' ) {
+ /**
+ * Page numbers here refer to pages in the Exif 2.2 standard
+ *
+ * Note, Exif::UNDEFINED is treated as a string, not as an array of bytes
+ * so don't put a count parameter for any UNDEFINED values.
+ *
+ * @link http://exif.org/Exif2-2.PDF The Exif 2.2 specification
+ */
+ $this->mExifTags = [
+ # TIFF Rev. 6.0 Attribute Information (p22)
+ 'IFD0' => [
+ # Tags relating to image structure
+ 'ImageWidth' => self::SHORT_OR_LONG, # Image width
+ 'ImageLength' => self::SHORT_OR_LONG, # Image height
+ 'BitsPerSample' => [ self::SHORT, 3 ], # Number of bits per component
+ # "When a primary image is JPEG compressed, this designation is not"
+ # "necessary and is omitted." (p23)
+ 'Compression' => self::SHORT, # Compression scheme #p23
+ 'PhotometricInterpretation' => self::SHORT, # Pixel composition #p23
+ 'Orientation' => self::SHORT, # Orientation of image #p24
+ 'SamplesPerPixel' => self::SHORT, # Number of components
+ 'PlanarConfiguration' => self::SHORT, # Image data arrangement #p24
+ 'YCbCrSubSampling' => [ self::SHORT, 2 ], # Subsampling ratio of Y to C #p24
+ 'YCbCrPositioning' => self::SHORT, # Y and C positioning #p24-25
+ 'XResolution' => self::RATIONAL, # Image resolution in width direction
+ 'YResolution' => self::RATIONAL, # Image resolution in height direction
+ 'ResolutionUnit' => self::SHORT, # Unit of X and Y resolution #(p26)
+
+ # Tags relating to recording offset
+ 'StripOffsets' => self::SHORT_OR_LONG, # Image data location
+ 'RowsPerStrip' => self::SHORT_OR_LONG, # Number of rows per strip
+ 'StripByteCounts' => self::SHORT_OR_LONG, # Bytes per compressed strip
+ 'JPEGInterchangeFormat' => self::SHORT_OR_LONG, # Offset to JPEG SOI
+ 'JPEGInterchangeFormatLength' => self::SHORT_OR_LONG, # Bytes of JPEG data
+
+ # Tags relating to image data characteristics
+ 'TransferFunction' => self::IGNORE, # Transfer function
+ 'WhitePoint' => [ self::RATIONAL, 2 ], # White point chromaticity
+ 'PrimaryChromaticities' => [ self::RATIONAL, 6 ], # Chromaticities of primarities
+ # Color space transformation matrix coefficients #p27
+ 'YCbCrCoefficients' => [ self::RATIONAL, 3 ],
+ 'ReferenceBlackWhite' => [ self::RATIONAL, 6 ], # Pair of black and white reference values
+
+ # Other tags
+ 'DateTime' => self::ASCII, # File change date and time
+ 'ImageDescription' => self::ASCII, # Image title
+ 'Make' => self::ASCII, # Image input equipment manufacturer
+ 'Model' => self::ASCII, # Image input equipment model
+ 'Software' => self::ASCII, # Software used
+ 'Artist' => self::ASCII, # Person who created the image
+ 'Copyright' => self::ASCII, # Copyright holder
+ ],
+
+ # Exif IFD Attribute Information (p30-31)
+ 'EXIF' => [
+ # @todo NOTE: Nonexistence of this field is taken to mean nonconformance
+ # to the Exif 2.1 AND 2.2 standards
+ 'ExifVersion' => self::UNDEFINED, # Exif version
+ 'FlashPixVersion' => self::UNDEFINED, # Supported Flashpix version #p32
+
+ # Tags relating to Image Data Characteristics
+ 'ColorSpace' => self::SHORT, # Color space information #p32
+
+ # Tags relating to image configuration
+ 'ComponentsConfiguration' => self::UNDEFINED, # Meaning of each component #p33
+ 'CompressedBitsPerPixel' => self::RATIONAL, # Image compression mode
+ 'PixelYDimension' => self::SHORT_OR_LONG, # Valid image height
+ 'PixelXDimension' => self::SHORT_OR_LONG, # Valid image width
+
+ # Tags relating to related user information
+ 'MakerNote' => self::IGNORE, # Manufacturer notes
+ 'UserComment' => self::UNDEFINED, # User comments #p34
+
+ # Tags relating to related file information
+ 'RelatedSoundFile' => self::ASCII, # Related audio file
+
+ # Tags relating to date and time
+ 'DateTimeOriginal' => self::ASCII, # Date and time of original data generation #p36
+ 'DateTimeDigitized' => self::ASCII, # Date and time of original data generation
+ 'SubSecTime' => self::ASCII, # DateTime subseconds
+ 'SubSecTimeOriginal' => self::ASCII, # DateTimeOriginal subseconds
+ 'SubSecTimeDigitized' => self::ASCII, # DateTimeDigitized subseconds
+
+ # Tags relating to picture-taking conditions (p31)
+ 'ExposureTime' => self::RATIONAL, # Exposure time
+ 'FNumber' => self::RATIONAL, # F Number
+ 'ExposureProgram' => self::SHORT, # Exposure Program #p38
+ 'SpectralSensitivity' => self::ASCII, # Spectral sensitivity
+ 'ISOSpeedRatings' => self::SHORT, # ISO speed rating
+ 'OECF' => self::IGNORE,
+ # Optoelectronic conversion factor. Note: We don't have support for this atm.
+ 'ShutterSpeedValue' => self::SRATIONAL, # Shutter speed
+ 'ApertureValue' => self::RATIONAL, # Aperture
+ 'BrightnessValue' => self::SRATIONAL, # Brightness
+ 'ExposureBiasValue' => self::SRATIONAL, # Exposure bias
+ 'MaxApertureValue' => self::RATIONAL, # Maximum land aperture
+ 'SubjectDistance' => self::RATIONAL, # Subject distance
+ 'MeteringMode' => self::SHORT, # Metering mode #p40
+ 'LightSource' => self::SHORT, # Light source #p40-41
+ 'Flash' => self::SHORT, # Flash #p41-42
+ 'FocalLength' => self::RATIONAL, # Lens focal length
+ 'SubjectArea' => [ self::SHORT, 4 ], # Subject area
+ 'FlashEnergy' => self::RATIONAL, # Flash energy
+ 'SpatialFrequencyResponse' => self::IGNORE, # Spatial frequency response. Not supported atm.
+ 'FocalPlaneXResolution' => self::RATIONAL, # Focal plane X resolution
+ 'FocalPlaneYResolution' => self::RATIONAL, # Focal plane Y resolution
+ 'FocalPlaneResolutionUnit' => self::SHORT, # Focal plane resolution unit #p46
+ 'SubjectLocation' => [ self::SHORT, 2 ], # Subject location
+ 'ExposureIndex' => self::RATIONAL, # Exposure index
+ 'SensingMethod' => self::SHORT, # Sensing method #p46
+ 'FileSource' => self::UNDEFINED, # File source #p47
+ 'SceneType' => self::UNDEFINED, # Scene type #p47
+ 'CFAPattern' => self::IGNORE, # CFA pattern. not supported atm.
+ 'CustomRendered' => self::SHORT, # Custom image processing #p48
+ 'ExposureMode' => self::SHORT, # Exposure mode #p48
+ 'WhiteBalance' => self::SHORT, # White Balance #p49
+ 'DigitalZoomRatio' => self::RATIONAL, # Digital zoom ration
+ 'FocalLengthIn35mmFilm' => self::SHORT, # Focal length in 35 mm film
+ 'SceneCaptureType' => self::SHORT, # Scene capture type #p49
+ 'GainControl' => self::SHORT, # Scene control #p49-50
+ 'Contrast' => self::SHORT, # Contrast #p50
+ 'Saturation' => self::SHORT, # Saturation #p50
+ 'Sharpness' => self::SHORT, # Sharpness #p50
+ 'DeviceSettingDescription' => self::IGNORE,
+ # Device settings description. This could maybe be supported. Need to find an
+ # example file that uses this to see if it has stuff of interest in it.
+ 'SubjectDistanceRange' => self::SHORT, # Subject distance range #p51
+
+ 'ImageUniqueID' => self::ASCII, # Unique image ID
+ ],
+
+ # GPS Attribute Information (p52)
+ 'GPS' => [
+ 'GPSVersion' => self::UNDEFINED,
+ # Should be an array of 4 Exif::BYTE's. However php treats it as an undefined
+ # Note exif standard calls this GPSVersionID, but php doesn't like the id suffix
+ 'GPSLatitudeRef' => self::ASCII, # North or South Latitude #p52-53
+ 'GPSLatitude' => [ self::RATIONAL, 3 ], # Latitude
+ 'GPSLongitudeRef' => self::ASCII, # East or West Longitude #p53
+ 'GPSLongitude' => [ self::RATIONAL, 3 ], # Longitude
+ 'GPSAltitudeRef' => self::UNDEFINED,
+ # Altitude reference. Note, the exif standard says this should be an EXIF::Byte,
+ # but php seems to disagree.
+ 'GPSAltitude' => self::RATIONAL, # Altitude
+ 'GPSTimeStamp' => [ self::RATIONAL, 3 ], # GPS time (atomic clock)
+ 'GPSSatellites' => self::ASCII, # Satellites used for measurement
+ 'GPSStatus' => self::ASCII, # Receiver status #p54
+ 'GPSMeasureMode' => self::ASCII, # Measurement mode #p54-55
+ 'GPSDOP' => self::RATIONAL, # Measurement precision
+ 'GPSSpeedRef' => self::ASCII, # Speed unit #p55
+ 'GPSSpeed' => self::RATIONAL, # Speed of GPS receiver
+ 'GPSTrackRef' => self::ASCII, # Reference for direction of movement #p55
+ 'GPSTrack' => self::RATIONAL, # Direction of movement
+ 'GPSImgDirectionRef' => self::ASCII, # Reference for direction of image #p56
+ 'GPSImgDirection' => self::RATIONAL, # Direction of image
+ 'GPSMapDatum' => self::ASCII, # Geodetic survey data used
+ 'GPSDestLatitudeRef' => self::ASCII, # Reference for latitude of destination #p56
+ 'GPSDestLatitude' => [ self::RATIONAL, 3 ], # Latitude destination
+ 'GPSDestLongitudeRef' => self::ASCII, # Reference for longitude of destination #p57
+ 'GPSDestLongitude' => [ self::RATIONAL, 3 ], # Longitude of destination
+ 'GPSDestBearingRef' => self::ASCII, # Reference for bearing of destination #p57
+ 'GPSDestBearing' => self::RATIONAL, # Bearing of destination
+ 'GPSDestDistanceRef' => self::ASCII, # Reference for distance to destination #p57-58
+ 'GPSDestDistance' => self::RATIONAL, # Distance to destination
+ 'GPSProcessingMethod' => self::UNDEFINED, # Name of GPS processing method
+ 'GPSAreaInformation' => self::UNDEFINED, # Name of GPS area
+ 'GPSDateStamp' => self::ASCII, # GPS date
+ 'GPSDifferential' => self::SHORT, # GPS differential correction
+ ],
+ ];
+
+ $this->file = $file;
+ $this->basename = wfBaseName( $this->file );
+ if ( $byteOrder === 'BE' || $byteOrder === 'LE' ) {
+ $this->byteOrder = $byteOrder;
+ } else {
+ // Only give a warning for b/c, since originally we didn't
+ // require this. The number of things affected by this is
+ // rather small.
+ wfWarn( 'Exif class did not have byte order specified. ' .
+ 'Some properties may be decoded incorrectly.' );
+ $this->byteOrder = 'BE'; // BE seems about twice as popular as LE in jpg's.
+ }
+
+ $this->debugFile( $this->basename, __FUNCTION__, true );
+ if ( function_exists( 'exif_read_data' ) ) {
+ MediaWiki\suppressWarnings();
+ $data = exif_read_data( $this->file, 0, true );
+ MediaWiki\restoreWarnings();
+ } else {
+ throw new MWException( "Internal error: exif_read_data not present. " .
+ "\$wgShowEXIF may be incorrectly set or not checked by an extension." );
+ }
+ /**
+ * exif_read_data() will return false on invalid input, such as
+ * when somebody uploads a file called something.jpeg
+ * containing random gibberish.
+ */
+ $this->mRawExifData = $data ?: [];
+ $this->makeFilteredData();
+ $this->collapseData();
+ $this->debugFile( __FUNCTION__, false );
+ }
+
+ /**
+ * Make $this->mFilteredExifData
+ */
+ function makeFilteredData() {
+ $this->mFilteredExifData = [];
+
+ foreach ( array_keys( $this->mRawExifData ) as $section ) {
+ if ( !array_key_exists( $section, $this->mExifTags ) ) {
+ $this->debug( $section, __FUNCTION__, "'$section' is not a valid Exif section" );
+ continue;
+ }
+
+ foreach ( array_keys( $this->mRawExifData[$section] ) as $tag ) {
+ if ( !array_key_exists( $tag, $this->mExifTags[$section] ) ) {
+ $this->debug( $tag, __FUNCTION__, "'$tag' is not a valid tag in '$section'" );
+ continue;
+ }
+
+ $this->mFilteredExifData[$tag] = $this->mRawExifData[$section][$tag];
+ // This is ok, as the tags in the different sections do not conflict.
+ // except in computed and thumbnail section, which we don't use.
+
+ $value = $this->mRawExifData[$section][$tag];
+ if ( !$this->validate( $section, $tag, $value ) ) {
+ $this->debug( $value, __FUNCTION__, "'$tag' contained invalid data" );
+ unset( $this->mFilteredExifData[$tag] );
+ }
+ }
+ }
+ }
+
+ /**
+ * Collapse some fields together.
+ * This converts some fields from exif form, to a more friendly form.
+ * For example GPS latitude to a single number.
+ *
+ * The rationale behind this is that we're storing data, not presenting to the user
+ * For example a longitude is a single number describing how far away you are from
+ * the prime meridian. Well it might be nice to split it up into minutes and seconds
+ * for the user, it doesn't really make sense to split a single number into 4 parts
+ * for storage. (degrees, minutes, second, direction vs single floating point number).
+ *
+ * Other things this might do (not really sure if they make sense or not):
+ * Dates -> mediawiki date format.
+ * convert values that can be in different units to be in one standardized unit.
+ *
+ * As an alternative approach, some of this could be done in the validate phase
+ * if we make up our own types like Exif::DATE.
+ */
+ function collapseData() {
+ $this->exifGPStoNumber( 'GPSLatitude' );
+ $this->exifGPStoNumber( 'GPSDestLatitude' );
+ $this->exifGPStoNumber( 'GPSLongitude' );
+ $this->exifGPStoNumber( 'GPSDestLongitude' );
+
+ if ( isset( $this->mFilteredExifData['GPSAltitude'] )
+ && isset( $this->mFilteredExifData['GPSAltitudeRef'] )
+ ) {
+ // We know altitude data is a <num>/<denom> from the validation
+ // functions ran earlier. But multiplying such a string by -1
+ // doesn't work well, so convert.
+ list( $num, $denom ) = explode( '/', $this->mFilteredExifData['GPSAltitude'] );
+ $this->mFilteredExifData['GPSAltitude'] = $num / $denom;
+
+ if ( $this->mFilteredExifData['GPSAltitudeRef'] === "\1" ) {
+ $this->mFilteredExifData['GPSAltitude'] *= -1;
+ }
+ unset( $this->mFilteredExifData['GPSAltitudeRef'] );
+ }
+
+ $this->exifPropToOrd( 'FileSource' );
+ $this->exifPropToOrd( 'SceneType' );
+
+ $this->charCodeString( 'UserComment' );
+ $this->charCodeString( 'GPSProcessingMethod' );
+ $this->charCodeString( 'GPSAreaInformation' );
+
+ // ComponentsConfiguration should really be an array instead of a string...
+ // This turns a string of binary numbers into an array of numbers.
+
+ if ( isset( $this->mFilteredExifData['ComponentsConfiguration'] ) ) {
+ $val = $this->mFilteredExifData['ComponentsConfiguration'];
+ $ccVals = [];
+
+ $strLen = strlen( $val );
+ for ( $i = 0; $i < $strLen; $i++ ) {
+ $ccVals[$i] = ord( substr( $val, $i, 1 ) );
+ }
+ $ccVals['_type'] = 'ol'; // this is for formatting later.
+ $this->mFilteredExifData['ComponentsConfiguration'] = $ccVals;
+ }
+
+ // GPSVersion(ID) is treated as the wrong type by php exif support.
+ // Go through each byte turning it into a version string.
+ // For example: "\x02\x02\x00\x00" -> "2.2.0.0"
+
+ // Also change exif tag name from GPSVersion (what php exif thinks it is)
+ // to GPSVersionID (what the exif standard thinks it is).
+
+ if ( isset( $this->mFilteredExifData['GPSVersion'] ) ) {
+ $val = $this->mFilteredExifData['GPSVersion'];
+ $newVal = '';
+
+ $strLen = strlen( $val );
+ for ( $i = 0; $i < $strLen; $i++ ) {
+ if ( $i !== 0 ) {
+ $newVal .= '.';
+ }
+ $newVal .= ord( substr( $val, $i, 1 ) );
+ }
+
+ if ( $this->byteOrder === 'LE' ) {
+ // Need to reverse the string
+ $newVal2 = '';
+ for ( $i = strlen( $newVal ) - 1; $i >= 0; $i-- ) {
+ $newVal2 .= substr( $newVal, $i, 1 );
+ }
+ $this->mFilteredExifData['GPSVersionID'] = $newVal2;
+ } else {
+ $this->mFilteredExifData['GPSVersionID'] = $newVal;
+ }
+ unset( $this->mFilteredExifData['GPSVersion'] );
+ }
+ }
+
+ /**
+ * Do userComment tags and similar. See pg. 34 of exif standard.
+ * basically first 8 bytes is charset, rest is value.
+ * This has not been tested on any shift-JIS strings.
+ * @param string $prop Prop name
+ */
+ private function charCodeString( $prop ) {
+ if ( isset( $this->mFilteredExifData[$prop] ) ) {
+ if ( strlen( $this->mFilteredExifData[$prop] ) <= 8 ) {
+ // invalid. Must be at least 9 bytes long.
+
+ $this->debug( $this->mFilteredExifData[$prop], __FUNCTION__, false );
+ unset( $this->mFilteredExifData[$prop] );
+
+ return;
+ }
+ $charCode = substr( $this->mFilteredExifData[$prop], 0, 8 );
+ $val = substr( $this->mFilteredExifData[$prop], 8 );
+
+ switch ( $charCode ) {
+ case "\x4A\x49\x53\x00\x00\x00\x00\x00":
+ // JIS
+ $charset = "Shift-JIS";
+ break;
+ case "UNICODE\x00":
+ $charset = "UTF-16" . $this->byteOrder;
+ break;
+ default: // ascii or undefined.
+ $charset = "";
+ break;
+ }
+ if ( $charset ) {
+ MediaWiki\suppressWarnings();
+ $val = iconv( $charset, 'UTF-8//IGNORE', $val );
+ MediaWiki\restoreWarnings();
+ } else {
+ // if valid utf-8, assume that, otherwise assume windows-1252
+ $valCopy = $val;
+ UtfNormal\Validator::quickIsNFCVerify( $valCopy ); // validates $valCopy.
+ if ( $valCopy !== $val ) {
+ MediaWiki\suppressWarnings();
+ $val = iconv( 'Windows-1252', 'UTF-8//IGNORE', $val );
+ MediaWiki\restoreWarnings();
+ }
+ }
+
+ // trim and check to make sure not only whitespace.
+ $val = trim( $val );
+ if ( strlen( $val ) === 0 ) {
+ // only whitespace.
+ $this->debug( $this->mFilteredExifData[$prop], __FUNCTION__, "$prop: Is only whitespace" );
+ unset( $this->mFilteredExifData[$prop] );
+
+ return;
+ }
+
+ // all's good.
+ $this->mFilteredExifData[$prop] = $val;
+ }
+ }
+
+ /**
+ * Convert an Exif::UNDEFINED from a raw binary string
+ * to its value. This is sometimes needed depending on
+ * the type of UNDEFINED field
+ * @param string $prop Name of property
+ */
+ private function exifPropToOrd( $prop ) {
+ if ( isset( $this->mFilteredExifData[$prop] ) ) {
+ $this->mFilteredExifData[$prop] = ord( $this->mFilteredExifData[$prop] );
+ }
+ }
+
+ /**
+ * Convert gps in exif form to a single floating point number
+ * for example 10 degress 20`40`` S -> -10.34444
+ * @param string $prop A GPS coordinate exif tag name (like GPSLongitude)
+ */
+ private function exifGPStoNumber( $prop ) {
+ $loc =& $this->mFilteredExifData[$prop];
+ $dir =& $this->mFilteredExifData[$prop . 'Ref'];
+ $res = false;
+
+ if ( isset( $loc ) && isset( $dir )
+ && ( $dir === 'N' || $dir === 'S' || $dir === 'E' || $dir === 'W' )
+ ) {
+ list( $num, $denom ) = explode( '/', $loc[0] );
+ $res = $num / $denom;
+ list( $num, $denom ) = explode( '/', $loc[1] );
+ $res += ( $num / $denom ) * ( 1 / 60 );
+ list( $num, $denom ) = explode( '/', $loc[2] );
+ $res += ( $num / $denom ) * ( 1 / 3600 );
+
+ if ( $dir === 'S' || $dir === 'W' ) {
+ $res *= -1; // make negative
+ }
+ }
+
+ // update the exif records.
+
+ if ( $res !== false ) { // using !== as $res could potentially be 0
+ $this->mFilteredExifData[$prop] = $res;
+ unset( $this->mFilteredExifData[$prop . 'Ref'] );
+ } else { // if invalid
+ unset( $this->mFilteredExifData[$prop] );
+ unset( $this->mFilteredExifData[$prop . 'Ref'] );
+ }
+ }
+
+ /**#@-*/
+
+ /**#@+
+ * @return array
+ */
+ /**
+ * Get $this->mRawExifData
+ * @return array
+ */
+ function getData() {
+ return $this->mRawExifData;
+ }
+
+ /**
+ * Get $this->mFilteredExifData
+ * @return array
+ */
+ function getFilteredData() {
+ return $this->mFilteredExifData;
+ }
+
+ /**#@-*/
+
+ /**
+ * The version of the output format
+ *
+ * Before the actual metadata information is saved in the database we
+ * strip some of it since we don't want to save things like thumbnails
+ * which usually accompany Exif data. This value gets saved in the
+ * database along with the actual Exif data, and if the version in the
+ * database doesn't equal the value returned by this function the Exif
+ * data is regenerated.
+ *
+ * @return int
+ */
+ public static function version() {
+ return 2; // We don't need no bloddy constants!
+ }
+
+ /**
+ * Validates if a tag value is of the type it should be according to the Exif spec
+ *
+ * @param mixed $in The input value to check
+ * @return bool
+ */
+ private function isByte( $in ) {
+ if ( !is_array( $in ) && sprintf( '%d', $in ) == $in && $in >= 0 && $in <= 255 ) {
+ $this->debug( $in, __FUNCTION__, true );
+
+ return true;
+ } else {
+ $this->debug( $in, __FUNCTION__, false );
+
+ return false;
+ }
+ }
+
+ /**
+ * @param mixed $in The input value to check
+ * @return bool
+ */
+ private function isASCII( $in ) {
+ if ( is_array( $in ) ) {
+ return false;
+ }
+
+ if ( preg_match( "/[^\x0a\x20-\x7e]/", $in ) ) {
+ $this->debug( $in, __FUNCTION__, 'found a character not in our whitelist' );
+
+ return false;
+ }
+
+ if ( preg_match( '/^\s*$/', $in ) ) {
+ $this->debug( $in, __FUNCTION__, 'input consisted solely of whitespace' );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param mixed $in The input value to check
+ * @return bool
+ */
+ private function isShort( $in ) {
+ if ( !is_array( $in ) && sprintf( '%d', $in ) == $in && $in >= 0 && $in <= 65536 ) {
+ $this->debug( $in, __FUNCTION__, true );
+
+ return true;
+ } else {
+ $this->debug( $in, __FUNCTION__, false );
+
+ return false;
+ }
+ }
+
+ /**
+ * @param mixed $in The input value to check
+ * @return bool
+ */
+ private function isLong( $in ) {
+ if ( !is_array( $in ) && sprintf( '%d', $in ) == $in && $in >= 0 && $in <= 4294967296 ) {
+ $this->debug( $in, __FUNCTION__, true );
+
+ return true;
+ } else {
+ $this->debug( $in, __FUNCTION__, false );
+
+ return false;
+ }
+ }
+
+ /**
+ * @param mixed $in The input value to check
+ * @return bool
+ */
+ private function isRational( $in ) {
+ $m = [];
+
+ # Avoid division by zero
+ if ( !is_array( $in )
+ && preg_match( '/^(\d+)\/(\d+[1-9]|[1-9]\d*)$/', $in, $m )
+ ) {
+ return $this->isLong( $m[1] ) && $this->isLong( $m[2] );
+ } else {
+ $this->debug( $in, __FUNCTION__, 'fed a non-fraction value' );
+
+ return false;
+ }
+ }
+
+ /**
+ * @param mixed $in The input value to check
+ * @return bool
+ */
+ private function isUndefined( $in ) {
+ $this->debug( $in, __FUNCTION__, true );
+
+ return true;
+ }
+
+ /**
+ * @param mixed $in The input value to check
+ * @return bool
+ */
+ private function isSlong( $in ) {
+ if ( $this->isLong( abs( $in ) ) ) {
+ $this->debug( $in, __FUNCTION__, true );
+
+ return true;
+ } else {
+ $this->debug( $in, __FUNCTION__, false );
+
+ return false;
+ }
+ }
+
+ /**
+ * @param mixed $in The input value to check
+ * @return bool
+ */
+ private function isSrational( $in ) {
+ $m = [];
+
+ # Avoid division by zero
+ if ( !is_array( $in ) &&
+ preg_match( '/^(-?\d+)\/(\d+[1-9]|[1-9]\d*)$/', $in, $m )
+ ) {
+ return $this->isSlong( $m[0] ) && $this->isSlong( $m[1] );
+ } else {
+ $this->debug( $in, __FUNCTION__, 'fed a non-fraction value' );
+
+ return false;
+ }
+ }
+
+ /**#@-*/
+
+ /**
+ * Validates if a tag has a legal value according to the Exif spec
+ *
+ * @param string $section Section where tag is located.
+ * @param string $tag The tag to check.
+ * @param mixed $val The value of the tag.
+ * @param bool $recursive True if called recursively for array types.
+ * @return bool
+ */
+ private function validate( $section, $tag, $val, $recursive = false ) {
+ $debug = "tag is '$tag'";
+ $etype = $this->mExifTags[$section][$tag];
+ $ecount = 1;
+ if ( is_array( $etype ) ) {
+ list( $etype, $ecount ) = $etype;
+ if ( $recursive ) {
+ $ecount = 1; // checking individual elements
+ }
+ }
+ $count = count( $val );
+ if ( $ecount != $count ) {
+ $this->debug( $val, __FUNCTION__, "Expected $ecount elements for $tag but got $count" );
+
+ return false;
+ }
+ if ( $count > 1 ) {
+ foreach ( $val as $v ) {
+ if ( !$this->validate( $section, $tag, $v, true ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ // Does not work if not typecast
+ switch ( (string)$etype ) {
+ case (string)self::BYTE:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isByte( $val );
+ case (string)self::ASCII:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isASCII( $val );
+ case (string)self::SHORT:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isShort( $val );
+ case (string)self::LONG:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isLong( $val );
+ case (string)self::RATIONAL:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isRational( $val );
+ case (string)self::SHORT_OR_LONG:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isShort( $val ) || $this->isLong( $val );
+ case (string)self::UNDEFINED:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isUndefined( $val );
+ case (string)self::SLONG:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isSlong( $val );
+ case (string)self::SRATIONAL:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return $this->isSrational( $val );
+ case (string)self::IGNORE:
+ $this->debug( $val, __FUNCTION__, $debug );
+
+ return false;
+ default:
+ $this->debug( $val, __FUNCTION__, "The tag '$tag' is unknown" );
+
+ return false;
+ }
+ }
+
+ /**
+ * Convenience function for debugging output
+ *
+ * @param mixed $in Arrays will be processed with print_r().
+ * @param string $fname Function name to log.
+ * @param string|bool|null $action Default null.
+ */
+ private function debug( $in, $fname, $action = null ) {
+ if ( !$this->log ) {
+ return;
+ }
+ $type = gettype( $in );
+ $class = ucfirst( __CLASS__ );
+ if ( is_array( $in ) ) {
+ $in = print_r( $in, true );
+ }
+
+ if ( $action === true ) {
+ wfDebugLog( $this->log, "$class::$fname: accepted: '$in' (type: $type)" );
+ } elseif ( $action === false ) {
+ wfDebugLog( $this->log, "$class::$fname: rejected: '$in' (type: $type)" );
+ } elseif ( $action === null ) {
+ wfDebugLog( $this->log, "$class::$fname: input was: '$in' (type: $type)" );
+ } else {
+ wfDebugLog( $this->log, "$class::$fname: $action (type: $type; content: '$in')" );
+ }
+ }
+
+ /**
+ * Convenience function for debugging output
+ *
+ * @param string $fname The name of the function calling this function
+ * @param bool $io Specify whether we're beginning or ending
+ */
+ private function debugFile( $fname, $io ) {
+ if ( !$this->log ) {
+ return;
+ }
+ $class = ucfirst( __CLASS__ );
+ if ( $io ) {
+ wfDebugLog( $this->log, "$class::$fname: begin processing: '{$this->basename}'" );
+ } else {
+ wfDebugLog( $this->log, "$class::$fname: end processing: '{$this->basename}'" );
+ }
+ }
+}
diff --git a/www/wiki/includes/media/ExifBitmap.php b/www/wiki/includes/media/ExifBitmap.php
new file mode 100644
index 00000000..0e10abb9
--- /dev/null
+++ b/www/wiki/includes/media/ExifBitmap.php
@@ -0,0 +1,245 @@
+<?php
+/**
+ * Handler for bitmap images with exif metadata.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Stuff specific to JPEG and (built-in) TIFF handler.
+ * All metadata related, since both JPEG and TIFF support Exif.
+ *
+ * @ingroup Media
+ */
+class ExifBitmapHandler extends BitmapHandler {
+ const BROKEN_FILE = '-1'; // error extracting metadata
+ const OLD_BROKEN_FILE = '0'; // outdated error extracting metadata.
+
+ function convertMetadataVersion( $metadata, $version = 1 ) {
+ // basically flattens arrays.
+ $version = intval( explode( ';', $version, 2 )[0] );
+ if ( $version < 1 || $version >= 2 ) {
+ return $metadata;
+ }
+
+ $avoidHtml = true;
+
+ if ( !is_array( $metadata ) ) {
+ $metadata = unserialize( $metadata );
+ }
+ if ( !isset( $metadata['MEDIAWIKI_EXIF_VERSION'] ) || $metadata['MEDIAWIKI_EXIF_VERSION'] != 2 ) {
+ return $metadata;
+ }
+
+ // Treat Software as a special case because in can contain
+ // an array of (SoftwareName, Version).
+ if ( isset( $metadata['Software'] )
+ && is_array( $metadata['Software'] )
+ && is_array( $metadata['Software'][0] )
+ && isset( $metadata['Software'][0][0] )
+ && isset( $metadata['Software'][0][1] )
+ ) {
+ $metadata['Software'] = $metadata['Software'][0][0] . ' (Version '
+ . $metadata['Software'][0][1] . ')';
+ }
+
+ $formatter = new FormatMetadata;
+
+ // ContactInfo also has to be dealt with specially
+ if ( isset( $metadata['Contact'] ) ) {
+ $metadata['Contact'] =
+ $formatter->collapseContactInfo(
+ $metadata['Contact'] );
+ }
+
+ foreach ( $metadata as &$val ) {
+ if ( is_array( $val ) ) {
+ $val = $formatter->flattenArrayReal( $val, 'ul', $avoidHtml );
+ }
+ }
+ $metadata['MEDIAWIKI_EXIF_VERSION'] = 1;
+
+ return $metadata;
+ }
+
+ /**
+ * @param File $image
+ * @param array $metadata
+ * @return bool|int
+ */
+ function isMetadataValid( $image, $metadata ) {
+ global $wgShowEXIF;
+ if ( !$wgShowEXIF ) {
+ # Metadata disabled and so an empty field is expected
+ return self::METADATA_GOOD;
+ }
+ if ( $metadata === self::OLD_BROKEN_FILE ) {
+ # Old special value indicating that there is no Exif data in the file.
+ # or that there was an error well extracting the metadata.
+ wfDebug( __METHOD__ . ": back-compat version\n" );
+
+ return self::METADATA_COMPATIBLE;
+ }
+ if ( $metadata === self::BROKEN_FILE ) {
+ return self::METADATA_GOOD;
+ }
+ MediaWiki\suppressWarnings();
+ $exif = unserialize( $metadata );
+ MediaWiki\restoreWarnings();
+ if ( !isset( $exif['MEDIAWIKI_EXIF_VERSION'] )
+ || $exif['MEDIAWIKI_EXIF_VERSION'] != Exif::version()
+ ) {
+ if ( isset( $exif['MEDIAWIKI_EXIF_VERSION'] )
+ && $exif['MEDIAWIKI_EXIF_VERSION'] == 1
+ ) {
+ // back-compatible but old
+ wfDebug( __METHOD__ . ": back-compat version\n" );
+
+ return self::METADATA_COMPATIBLE;
+ }
+ # Wrong (non-compatible) version
+ wfDebug( __METHOD__ . ": wrong version\n" );
+
+ return self::METADATA_BAD;
+ }
+
+ return self::METADATA_GOOD;
+ }
+
+ /**
+ * @param File $image
+ * @param bool|IContextSource $context Context to use (optional)
+ * @return array|bool
+ */
+ function formatMetadata( $image, $context = false ) {
+ $meta = $this->getCommonMetaArray( $image );
+ if ( count( $meta ) === 0 ) {
+ return false;
+ }
+
+ return $this->formatMetadataHelper( $meta, $context );
+ }
+
+ public function getCommonMetaArray( File $file ) {
+ $metadata = $file->getMetadata();
+ if ( $metadata === self::OLD_BROKEN_FILE
+ || $metadata === self::BROKEN_FILE
+ || $this->isMetadataValid( $file, $metadata ) === self::METADATA_BAD
+ ) {
+ // So we don't try and display metadata from PagedTiffHandler
+ // for example when using InstantCommons.
+ return [];
+ }
+
+ $exif = unserialize( $metadata );
+ if ( !$exif ) {
+ return [];
+ }
+ unset( $exif['MEDIAWIKI_EXIF_VERSION'] );
+
+ return $exif;
+ }
+
+ function getMetadataType( $image ) {
+ return 'exif';
+ }
+
+ /**
+ * Wrapper for base classes ImageHandler::getImageSize() that checks for
+ * rotation reported from metadata and swaps the sizes to match.
+ *
+ * @param File|FSFile $image
+ * @param string $path
+ * @return array
+ */
+ function getImageSize( $image, $path ) {
+ $gis = parent::getImageSize( $image, $path );
+
+ // Don't just call $image->getMetadata(); FSFile::getPropsFromPath() calls us with a bogus object.
+ // This may mean we read EXIF data twice on initial upload.
+ if ( $this->autoRotateEnabled() ) {
+ $meta = $this->getMetadata( $image, $path );
+ $rotation = $this->getRotationForExif( $meta );
+ } else {
+ $rotation = 0;
+ }
+
+ if ( $rotation == 90 || $rotation == 270 ) {
+ $width = $gis[0];
+ $gis[0] = $gis[1];
+ $gis[1] = $width;
+ }
+
+ return $gis;
+ }
+
+ /**
+ * On supporting image formats, try to read out the low-level orientation
+ * of the file and return the angle that the file needs to be rotated to
+ * be viewed.
+ *
+ * This information is only useful when manipulating the original file;
+ * the width and height we normally work with is logical, and will match
+ * any produced output views.
+ *
+ * @param File $file
+ * @return int 0, 90, 180 or 270
+ */
+ public function getRotation( $file ) {
+ if ( !$this->autoRotateEnabled() ) {
+ return 0;
+ }
+
+ $data = $file->getMetadata();
+
+ return $this->getRotationForExif( $data );
+ }
+
+ /**
+ * Given a chunk of serialized Exif metadata, return the orientation as
+ * degrees of rotation.
+ *
+ * @param string $data
+ * @return int 0, 90, 180 or 270
+ * @todo FIXME: Orientation can include flipping as well; see if this is an issue!
+ */
+ protected function getRotationForExif( $data ) {
+ if ( !$data ) {
+ return 0;
+ }
+ MediaWiki\suppressWarnings();
+ $data = unserialize( $data );
+ MediaWiki\restoreWarnings();
+ if ( isset( $data['Orientation'] ) ) {
+ # See http://sylvana.net/jpegcrop/exif_orientation.html
+ switch ( $data['Orientation'] ) {
+ case 8:
+ return 90;
+ case 3:
+ return 180;
+ case 6:
+ return 270;
+ default:
+ return 0;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/www/wiki/includes/media/FormatMetadata.php b/www/wiki/includes/media/FormatMetadata.php
new file mode 100644
index 00000000..79032232
--- /dev/null
+++ b/www/wiki/includes/media/FormatMetadata.php
@@ -0,0 +1,1890 @@
+<?php
+/**
+ * Formatting of image metadata values into human readable form.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @ingroup Media
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason, 2009 Brent Garber, 2010 Brian Wolff
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ * @see http://exif.org/Exif2-2.PDF The Exif 2.2 specification
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Timestamp\TimestampException;
+
+/**
+ * Format Image metadata values into a human readable form.
+ *
+ * Note lots of these messages use the prefix 'exif' even though
+ * they may not be exif properties. For example 'exif-ImageDescription'
+ * can be the Exif ImageDescription, or it could be the iptc-iim caption
+ * property, or it could be the xmp dc:description property. This
+ * is because these messages should be independent of how the data is
+ * stored, sine the user doesn't care if the description is stored in xmp,
+ * exif, etc only that its a description. (Additionally many of these properties
+ * are merged together following the MWG standard, such that for example,
+ * exif properties override XMP properties that mean the same thing if
+ * there is a conflict).
+ *
+ * It should perhaps use a prefix like 'metadata' instead, but there
+ * is already a large number of messages using the 'exif' prefix.
+ *
+ * @ingroup Media
+ * @since 1.23 the class extends ContextSource and various formerly-public
+ * internal methods are private
+ */
+class FormatMetadata extends ContextSource {
+ /**
+ * Only output a single language for multi-language fields
+ * @var bool
+ * @since 1.23
+ */
+ protected $singleLang = false;
+
+ /**
+ * Trigger only outputting single language for multilanguage fields
+ *
+ * @param bool $val
+ * @since 1.23
+ */
+ public function setSingleLanguage( $val ) {
+ $this->singleLang = $val;
+ }
+
+ /**
+ * Numbers given by Exif user agents are often magical, that is they
+ * should be replaced by a detailed explanation depending on their
+ * value which most of the time are plain integers. This function
+ * formats Exif (and other metadata) values into human readable form.
+ *
+ * This is the usual entry point for this class.
+ *
+ * @param array $tags The Exif data to format ( as returned by
+ * Exif::getFilteredData() or BitmapMetadataHandler )
+ * @param bool|IContextSource $context Context to use (optional)
+ * @return array
+ */
+ public static function getFormattedData( $tags, $context = false ) {
+ $obj = new FormatMetadata;
+ if ( $context ) {
+ $obj->setContext( $context );
+ }
+
+ return $obj->makeFormattedData( $tags );
+ }
+
+ /**
+ * Numbers given by Exif user agents are often magical, that is they
+ * should be replaced by a detailed explanation depending on their
+ * value which most of the time are plain integers. This function
+ * formats Exif (and other metadata) values into human readable form.
+ *
+ * @param array $tags The Exif data to format ( as returned by
+ * Exif::getFilteredData() or BitmapMetadataHandler )
+ * @return array
+ * @since 1.23
+ */
+ public function makeFormattedData( $tags ) {
+ $resolutionunit = !isset( $tags['ResolutionUnit'] ) || $tags['ResolutionUnit'] == 2 ? 2 : 3;
+ unset( $tags['ResolutionUnit'] );
+
+ foreach ( $tags as $tag => &$vals ) {
+ // This seems ugly to wrap non-array's in an array just to unwrap again,
+ // especially when most of the time it is not an array
+ if ( !is_array( $tags[$tag] ) ) {
+ $vals = [ $vals ];
+ }
+
+ // _type is a special value to say what array type
+ if ( isset( $tags[$tag]['_type'] ) ) {
+ $type = $tags[$tag]['_type'];
+ unset( $vals['_type'] );
+ } else {
+ $type = 'ul'; // default unordered list.
+ }
+
+ // This is done differently as the tag is an array.
+ if ( $tag == 'GPSTimeStamp' && count( $vals ) === 3 ) {
+ // hour min sec array
+
+ $h = explode( '/', $vals[0] );
+ $m = explode( '/', $vals[1] );
+ $s = explode( '/', $vals[2] );
+
+ // this should already be validated
+ // when loaded from file, but it could
+ // come from a foreign repo, so be
+ // paranoid.
+ if ( !isset( $h[1] )
+ || !isset( $m[1] )
+ || !isset( $s[1] )
+ || $h[1] == 0
+ || $m[1] == 0
+ || $s[1] == 0
+ ) {
+ continue;
+ }
+ $tags[$tag] = str_pad( intval( $h[0] / $h[1] ), 2, '0', STR_PAD_LEFT )
+ . ':' . str_pad( intval( $m[0] / $m[1] ), 2, '0', STR_PAD_LEFT )
+ . ':' . str_pad( intval( $s[0] / $s[1] ), 2, '0', STR_PAD_LEFT );
+
+ try {
+ $time = wfTimestamp( TS_MW, '1971:01:01 ' . $tags[$tag] );
+ // the 1971:01:01 is just a placeholder, and not shown to user.
+ if ( $time && intval( $time ) > 0 ) {
+ $tags[$tag] = $this->getLanguage()->time( $time );
+ }
+ } catch ( TimestampException $e ) {
+ // This shouldn't happen, but we've seen bad formats
+ // such as 4-digit seconds in the wild.
+ // leave $tags[$tag] as-is
+ }
+ continue;
+ }
+
+ // The contact info is a multi-valued field
+ // instead of the other props which are single
+ // valued (mostly) so handle as a special case.
+ if ( $tag === 'Contact' ) {
+ $vals = $this->collapseContactInfo( $vals );
+ continue;
+ }
+
+ foreach ( $vals as &$val ) {
+ switch ( $tag ) {
+ case 'Compression':
+ switch ( $val ) {
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 7:
+ case 8:
+ case 32773:
+ case 32946:
+ case 34712:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'PhotometricInterpretation':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 8:
+ case 9:
+ case 10:
+ case 32803:
+ case 34892:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'Orientation':
+ switch ( $val ) {
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 7:
+ case 8:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'PlanarConfiguration':
+ switch ( $val ) {
+ case 1:
+ case 2:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ // TODO: YCbCrSubSampling
+ case 'YCbCrPositioning':
+ switch ( $val ) {
+ case 1:
+ case 2:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'XResolution':
+ case 'YResolution':
+ switch ( $resolutionunit ) {
+ case 2:
+ $val = $this->exifMsg( 'XYResolution', 'i', $this->formatNum( $val ) );
+ break;
+ case 3:
+ $val = $this->exifMsg( 'XYResolution', 'c', $this->formatNum( $val ) );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ // TODO: YCbCrCoefficients #p27 (see annex E)
+ case 'ExifVersion':
+ case 'FlashpixVersion':
+ $val = "$val" / 100;
+ break;
+
+ case 'ColorSpace':
+ switch ( $val ) {
+ case 1:
+ case 65535:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'ComponentsConfiguration':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'DateTime':
+ case 'DateTimeOriginal':
+ case 'DateTimeDigitized':
+ case 'DateTimeReleased':
+ case 'DateTimeExpires':
+ case 'GPSDateStamp':
+ case 'dc-date':
+ case 'DateTimeMetadata':
+ if ( $val == '0000:00:00 00:00:00' || $val == ' : : : : ' ) {
+ $val = $this->msg( 'exif-unknowndate' )->text();
+ } elseif ( preg_match(
+ '/^(?:\d{4}):(?:\d\d):(?:\d\d) (?:\d\d):(?:\d\d):(?:\d\d)$/D',
+ $val
+ ) ) {
+ // Full date.
+ $time = wfTimestamp( TS_MW, $val );
+ if ( $time && intval( $time ) > 0 ) {
+ $val = $this->getLanguage()->timeanddate( $time );
+ }
+ } elseif ( preg_match( '/^(?:\d{4}):(?:\d\d):(?:\d\d) (?:\d\d):(?:\d\d)$/D', $val ) ) {
+ // No second field. Still format the same
+ // since timeanddate doesn't include seconds anyways,
+ // but second still available in api
+ $time = wfTimestamp( TS_MW, $val . ':00' );
+ if ( $time && intval( $time ) > 0 ) {
+ $val = $this->getLanguage()->timeanddate( $time );
+ }
+ } elseif ( preg_match( '/^(?:\d{4}):(?:\d\d):(?:\d\d)$/D', $val ) ) {
+ // If only the date but not the time is filled in.
+ $time = wfTimestamp( TS_MW, substr( $val, 0, 4 )
+ . substr( $val, 5, 2 )
+ . substr( $val, 8, 2 )
+ . '000000' );
+ if ( $time && intval( $time ) > 0 ) {
+ $val = $this->getLanguage()->date( $time );
+ }
+ }
+ // else it will just output $val without formatting it.
+ break;
+
+ case 'ExposureProgram':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 7:
+ case 8:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'SubjectDistance':
+ $val = $this->exifMsg( $tag, '', $this->formatNum( $val ) );
+ break;
+
+ case 'MeteringMode':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 7:
+ case 255:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'LightSource':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 9:
+ case 10:
+ case 11:
+ case 12:
+ case 13:
+ case 14:
+ case 15:
+ case 17:
+ case 18:
+ case 19:
+ case 20:
+ case 21:
+ case 22:
+ case 23:
+ case 24:
+ case 255:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'Flash':
+ $flashDecode = [
+ 'fired' => $val & 0b00000001,
+ 'return' => ( $val & 0b00000110 ) >> 1,
+ 'mode' => ( $val & 0b00011000 ) >> 3,
+ 'function' => ( $val & 0b00100000 ) >> 5,
+ 'redeye' => ( $val & 0b01000000 ) >> 6,
+ // 'reserved' => ( $val & 0b10000000 ) >> 7,
+ ];
+ $flashMsgs = [];
+ # We do not need to handle unknown values since all are used.
+ foreach ( $flashDecode as $subTag => $subValue ) {
+ # We do not need any message for zeroed values.
+ if ( $subTag != 'fired' && $subValue == 0 ) {
+ continue;
+ }
+ $fullTag = $tag . '-' . $subTag;
+ $flashMsgs[] = $this->exifMsg( $fullTag, $subValue );
+ }
+ $val = $this->getLanguage()->commaList( $flashMsgs );
+ break;
+
+ case 'FocalPlaneResolutionUnit':
+ switch ( $val ) {
+ case 2:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'SensingMethod':
+ switch ( $val ) {
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 7:
+ case 8:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'FileSource':
+ switch ( $val ) {
+ case 3:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'SceneType':
+ switch ( $val ) {
+ case 1:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'CustomRendered':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'ExposureMode':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'WhiteBalance':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'SceneCaptureType':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'GainControl':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'Contrast':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'Saturation':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'Sharpness':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'SubjectDistanceRange':
+ switch ( $val ) {
+ case 0:
+ case 1:
+ case 2:
+ case 3:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ // The GPS...Ref values are kept for compatibility, probably won't be reached.
+ case 'GPSLatitudeRef':
+ case 'GPSDestLatitudeRef':
+ switch ( $val ) {
+ case 'N':
+ case 'S':
+ $val = $this->exifMsg( 'GPSLatitude', $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'GPSLongitudeRef':
+ case 'GPSDestLongitudeRef':
+ switch ( $val ) {
+ case 'E':
+ case 'W':
+ $val = $this->exifMsg( 'GPSLongitude', $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'GPSAltitude':
+ if ( $val < 0 ) {
+ $val = $this->exifMsg( 'GPSAltitude', 'below-sealevel', $this->formatNum( -$val, 3 ) );
+ } else {
+ $val = $this->exifMsg( 'GPSAltitude', 'above-sealevel', $this->formatNum( $val, 3 ) );
+ }
+ break;
+
+ case 'GPSStatus':
+ switch ( $val ) {
+ case 'A':
+ case 'V':
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'GPSMeasureMode':
+ switch ( $val ) {
+ case 2:
+ case 3:
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'GPSTrackRef':
+ case 'GPSImgDirectionRef':
+ case 'GPSDestBearingRef':
+ switch ( $val ) {
+ case 'T':
+ case 'M':
+ $val = $this->exifMsg( 'GPSDirection', $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'GPSLatitude':
+ case 'GPSDestLatitude':
+ $val = $this->formatCoords( $val, 'latitude' );
+ break;
+ case 'GPSLongitude':
+ case 'GPSDestLongitude':
+ $val = $this->formatCoords( $val, 'longitude' );
+ break;
+
+ case 'GPSSpeedRef':
+ switch ( $val ) {
+ case 'K':
+ case 'M':
+ case 'N':
+ $val = $this->exifMsg( 'GPSSpeed', $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'GPSDestDistanceRef':
+ switch ( $val ) {
+ case 'K':
+ case 'M':
+ case 'N':
+ $val = $this->exifMsg( 'GPSDestDistance', $val );
+ break;
+ default:
+ /* If not recognized, display as is. */
+ break;
+ }
+ break;
+
+ case 'GPSDOP':
+ // See https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS)
+ if ( $val <= 2 ) {
+ $val = $this->exifMsg( $tag, 'excellent', $this->formatNum( $val ) );
+ } elseif ( $val <= 5 ) {
+ $val = $this->exifMsg( $tag, 'good', $this->formatNum( $val ) );
+ } elseif ( $val <= 10 ) {
+ $val = $this->exifMsg( $tag, 'moderate', $this->formatNum( $val ) );
+ } elseif ( $val <= 20 ) {
+ $val = $this->exifMsg( $tag, 'fair', $this->formatNum( $val ) );
+ } else {
+ $val = $this->exifMsg( $tag, 'poor', $this->formatNum( $val ) );
+ }
+ break;
+
+ // This is not in the Exif standard, just a special
+ // case for our purposes which enables wikis to wikify
+ // the make, model and software name to link to their articles.
+ case 'Make':
+ case 'Model':
+ $val = $this->exifMsg( $tag, '', $val );
+ break;
+
+ case 'Software':
+ if ( is_array( $val ) ) {
+ // if its a software, version array.
+ $val = $this->msg( 'exif-software-version-value', $val[0], $val[1] )->text();
+ } else {
+ $val = $this->exifMsg( $tag, '', $val );
+ }
+ break;
+
+ case 'ExposureTime':
+ // Show the pretty fraction as well as decimal version
+ $val = $this->msg( 'exif-exposuretime-format',
+ $this->formatFraction( $val ), $this->formatNum( $val ) )->text();
+ break;
+ case 'ISOSpeedRatings':
+ // If its = 65535 that means its at the
+ // limit of the size of Exif::short and
+ // is really higher.
+ if ( $val == '65535' ) {
+ $val = $this->exifMsg( $tag, 'overflow' );
+ } else {
+ $val = $this->formatNum( $val );
+ }
+ break;
+ case 'FNumber':
+ $val = $this->msg( 'exif-fnumber-format',
+ $this->formatNum( $val ) )->text();
+ break;
+
+ case 'FocalLength':
+ case 'FocalLengthIn35mmFilm':
+ $val = $this->msg( 'exif-focallength-format',
+ $this->formatNum( $val ) )->text();
+ break;
+
+ case 'MaxApertureValue':
+ if ( strpos( $val, '/' ) !== false ) {
+ // need to expand this earlier to calculate fNumber
+ list( $n, $d ) = explode( '/', $val );
+ if ( is_numeric( $n ) && is_numeric( $d ) ) {
+ $val = $n / $d;
+ }
+ }
+ if ( is_numeric( $val ) ) {
+ $fNumber = pow( 2, $val / 2 );
+ if ( $fNumber !== false ) {
+ $val = $this->msg( 'exif-maxaperturevalue-value',
+ $this->formatNum( $val ),
+ $this->formatNum( $fNumber, 2 )
+ )->text();
+ }
+ }
+ break;
+
+ case 'iimCategory':
+ switch ( strtolower( $val ) ) {
+ // See pg 29 of IPTC photo
+ // metadata standard.
+ case 'ace':
+ case 'clj':
+ case 'dis':
+ case 'fin':
+ case 'edu':
+ case 'evn':
+ case 'hth':
+ case 'hum':
+ case 'lab':
+ case 'lif':
+ case 'pol':
+ case 'rel':
+ case 'sci':
+ case 'soi':
+ case 'spo':
+ case 'war':
+ case 'wea':
+ $val = $this->exifMsg(
+ 'iimcategory',
+ $val
+ );
+ }
+ break;
+ case 'SubjectNewsCode':
+ // Essentially like iimCategory.
+ // 8 (numeric) digit hierarchical
+ // classification. We decode the
+ // first 2 digits, which provide
+ // a broad category.
+ $val = $this->convertNewsCode( $val );
+ break;
+ case 'Urgency':
+ // 1-8 with 1 being highest, 5 normal
+ // 0 is reserved, and 9 is 'user-defined'.
+ $urgency = '';
+ if ( $val == 0 || $val == 9 ) {
+ $urgency = 'other';
+ } elseif ( $val < 5 && $val > 1 ) {
+ $urgency = 'high';
+ } elseif ( $val == 5 ) {
+ $urgency = 'normal';
+ } elseif ( $val <= 8 && $val > 5 ) {
+ $urgency = 'low';
+ }
+
+ if ( $urgency !== '' ) {
+ $val = $this->exifMsg( 'urgency',
+ $urgency, $val
+ );
+ }
+ break;
+
+ // Things that have a unit of pixels.
+ case 'OriginalImageHeight':
+ case 'OriginalImageWidth':
+ case 'PixelXDimension':
+ case 'PixelYDimension':
+ case 'ImageWidth':
+ case 'ImageLength':
+ $val = $this->formatNum( $val ) . ' ' . $this->msg( 'unit-pixel' )->text();
+ break;
+
+ // Do not transform fields with pure text.
+ // For some languages the formatNum()
+ // conversion results to wrong output like
+ // foo,bar@example,com or foo٫bar@example٫com.
+ // Also some 'numeric' things like Scene codes
+ // are included here as we really don't want
+ // commas inserted.
+ case 'ImageDescription':
+ case 'UserComment':
+ case 'Artist':
+ case 'Copyright':
+ case 'RelatedSoundFile':
+ case 'ImageUniqueID':
+ case 'SpectralSensitivity':
+ case 'GPSSatellites':
+ case 'GPSVersionID':
+ case 'GPSMapDatum':
+ case 'Keywords':
+ case 'WorldRegionDest':
+ case 'CountryDest':
+ case 'CountryCodeDest':
+ case 'ProvinceOrStateDest':
+ case 'CityDest':
+ case 'SublocationDest':
+ case 'WorldRegionCreated':
+ case 'CountryCreated':
+ case 'CountryCodeCreated':
+ case 'ProvinceOrStateCreated':
+ case 'CityCreated':
+ case 'SublocationCreated':
+ case 'ObjectName':
+ case 'SpecialInstructions':
+ case 'Headline':
+ case 'Credit':
+ case 'Source':
+ case 'EditStatus':
+ case 'FixtureIdentifier':
+ case 'LocationDest':
+ case 'LocationDestCode':
+ case 'Writer':
+ case 'JPEGFileComment':
+ case 'iimSupplementalCategory':
+ case 'OriginalTransmissionRef':
+ case 'Identifier':
+ case 'dc-contributor':
+ case 'dc-coverage':
+ case 'dc-publisher':
+ case 'dc-relation':
+ case 'dc-rights':
+ case 'dc-source':
+ case 'dc-type':
+ case 'Lens':
+ case 'SerialNumber':
+ case 'CameraOwnerName':
+ case 'Label':
+ case 'Nickname':
+ case 'RightsCertificate':
+ case 'CopyrightOwner':
+ case 'UsageTerms':
+ case 'WebStatement':
+ case 'OriginalDocumentID':
+ case 'LicenseUrl':
+ case 'MorePermissionsUrl':
+ case 'AttributionUrl':
+ case 'PreferredAttributionName':
+ case 'PNGFileComment':
+ case 'Disclaimer':
+ case 'ContentWarning':
+ case 'GIFFileComment':
+ case 'SceneCode':
+ case 'IntellectualGenre':
+ case 'Event':
+ case 'OrginisationInImage':
+ case 'PersonInImage':
+
+ $val = htmlspecialchars( $val );
+ break;
+
+ case 'ObjectCycle':
+ switch ( $val ) {
+ case 'a':
+ case 'p':
+ case 'b':
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ default:
+ $val = htmlspecialchars( $val );
+ break;
+ }
+ break;
+ case 'Copyrighted':
+ switch ( $val ) {
+ case 'True':
+ case 'False':
+ $val = $this->exifMsg( $tag, $val );
+ break;
+ }
+ break;
+ case 'Rating':
+ if ( $val == '-1' ) {
+ $val = $this->exifMsg( $tag, 'rejected' );
+ } else {
+ $val = $this->formatNum( $val );
+ }
+ break;
+
+ case 'LanguageCode':
+ $lang = Language::fetchLanguageName( strtolower( $val ), $this->getLanguage()->getCode() );
+ if ( $lang ) {
+ $val = htmlspecialchars( $lang );
+ } else {
+ $val = htmlspecialchars( $val );
+ }
+ break;
+
+ default:
+ $val = $this->formatNum( $val );
+ break;
+ }
+ }
+ // End formatting values, start flattening arrays.
+ $vals = $this->flattenArrayReal( $vals, $type );
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Flatten an array, using the content language for any messages.
+ *
+ * @param array $vals Array of values
+ * @param string $type Type of array (either lang, ul, ol).
+ * lang = language assoc array with keys being the lang code
+ * ul = unordered list, ol = ordered list
+ * type can also come from the '_type' member of $vals.
+ * @param bool $noHtml If to avoid returning anything resembling HTML.
+ * (Ugly hack for backwards compatibility with old MediaWiki).
+ * @param bool|IContextSource $context
+ * @return string Single value (in wiki-syntax).
+ * @since 1.23
+ */
+ public static function flattenArrayContentLang( $vals, $type = 'ul',
+ $noHtml = false, $context = false
+ ) {
+ global $wgContLang;
+ $obj = new FormatMetadata;
+ if ( $context ) {
+ $obj->setContext( $context );
+ }
+ $context = new DerivativeContext( $obj->getContext() );
+ $context->setLanguage( $wgContLang );
+ $obj->setContext( $context );
+
+ return $obj->flattenArrayReal( $vals, $type, $noHtml );
+ }
+
+ /**
+ * A function to collapse multivalued tags into a single value.
+ * This turns an array of (for example) authors into a bulleted list.
+ *
+ * This is public on the basis it might be useful outside of this class.
+ *
+ * @param array $vals Array of values
+ * @param string $type Type of array (either lang, ul, ol).
+ * lang = language assoc array with keys being the lang code
+ * ul = unordered list, ol = ordered list
+ * type can also come from the '_type' member of $vals.
+ * @param bool $noHtml If to avoid returning anything resembling HTML.
+ * (Ugly hack for backwards compatibility with old mediawiki).
+ * @return string Single value (in wiki-syntax).
+ * @since 1.23
+ */
+ public function flattenArrayReal( $vals, $type = 'ul', $noHtml = false ) {
+ if ( !is_array( $vals ) ) {
+ return $vals; // do nothing if not an array;
+ }
+
+ if ( isset( $vals['_type'] ) ) {
+ $type = $vals['_type'];
+ unset( $vals['_type'] );
+ }
+
+ if ( !is_array( $vals ) ) {
+ return $vals; // do nothing if not an array;
+ } elseif ( count( $vals ) === 1 && $type !== 'lang' && isset( $vals[0] ) ) {
+ return $vals[0];
+ } elseif ( count( $vals ) === 0 ) {
+ wfDebug( __METHOD__ . " metadata array with 0 elements!\n" );
+
+ return ""; // paranoia. This should never happen
+ } else {
+ /* @todo FIXME: This should hide some of the list entries if there are
+ * say more than four. Especially if a field is translated into 20
+ * languages, we don't want to show them all by default
+ */
+ switch ( $type ) {
+ case 'lang':
+ // Display default, followed by ContLang,
+ // followed by the rest in no particular
+ // order.
+
+ // Todo: hide some items if really long list.
+
+ $content = '';
+
+ $priorityLanguages = $this->getPriorityLanguages();
+ $defaultItem = false;
+ $defaultLang = false;
+
+ // If default is set, save it for later,
+ // as we don't know if it's equal to
+ // one of the lang codes. (In xmp
+ // you specify the language for a
+ // default property by having both
+ // a default prop, and one in the language
+ // that are identical)
+ if ( isset( $vals['x-default'] ) ) {
+ $defaultItem = $vals['x-default'];
+ unset( $vals['x-default'] );
+ }
+ foreach ( $priorityLanguages as $pLang ) {
+ if ( isset( $vals[$pLang] ) ) {
+ $isDefault = false;
+ if ( $vals[$pLang] === $defaultItem ) {
+ $defaultItem = false;
+ $isDefault = true;
+ }
+ $content .= $this->langItem(
+ $vals[$pLang], $pLang,
+ $isDefault, $noHtml );
+
+ unset( $vals[$pLang] );
+
+ if ( $this->singleLang ) {
+ return Html::rawElement( 'span',
+ [ 'lang' => $pLang ], $vals[$pLang] );
+ }
+ }
+ }
+
+ // Now do the rest.
+ foreach ( $vals as $lang => $item ) {
+ if ( $item === $defaultItem ) {
+ $defaultLang = $lang;
+ continue;
+ }
+ $content .= $this->langItem( $item,
+ $lang, false, $noHtml );
+ if ( $this->singleLang ) {
+ return Html::rawElement( 'span',
+ [ 'lang' => $lang ], $item );
+ }
+ }
+ if ( $defaultItem !== false ) {
+ $content = $this->langItem( $defaultItem,
+ $defaultLang, true, $noHtml ) .
+ $content;
+ if ( $this->singleLang ) {
+ return $defaultItem;
+ }
+ }
+ if ( $noHtml ) {
+ return $content;
+ }
+
+ return '<ul class="metadata-langlist">' .
+ $content .
+ '</ul>';
+ case 'ol':
+ if ( $noHtml ) {
+ return "\n#" . implode( "\n#", $vals );
+ }
+
+ return "<ol><li>" . implode( "</li>\n<li>", $vals ) . '</li></ol>';
+ case 'ul':
+ default:
+ if ( $noHtml ) {
+ return "\n*" . implode( "\n*", $vals );
+ }
+
+ return "<ul><li>" . implode( "</li>\n<li>", $vals ) . '</li></ul>';
+ }
+ }
+ }
+
+ /** Helper function for creating lists of translations.
+ *
+ * @param string $value Value (this is not escaped)
+ * @param string $lang Lang code of item or false
+ * @param bool $default If it is default value.
+ * @param bool $noHtml If to avoid html (for back-compat)
+ * @throws MWException
+ * @return string Language item (Note: despite how this looks, this is
+ * treated as wikitext, not as HTML).
+ */
+ private function langItem( $value, $lang, $default = false, $noHtml = false ) {
+ if ( $lang === false && $default === false ) {
+ throw new MWException( '$lang and $default cannot both '
+ . 'be false.' );
+ }
+
+ if ( $noHtml ) {
+ $wrappedValue = $value;
+ } else {
+ $wrappedValue = '<span class="mw-metadata-lang-value">'
+ . $value . '</span>';
+ }
+
+ if ( $lang === false ) {
+ $msg = $this->msg( 'metadata-langitem-default', $wrappedValue );
+ if ( $noHtml ) {
+ return $msg->text() . "\n\n";
+ } /* else */
+
+ return '<li class="mw-metadata-lang-default">'
+ . $msg->text()
+ . "</li>\n";
+ }
+
+ $lowLang = strtolower( $lang );
+ $langName = Language::fetchLanguageName( $lowLang );
+ if ( $langName === '' ) {
+ // try just the base language name. (aka en-US -> en ).
+ list( $langPrefix ) = explode( '-', $lowLang, 2 );
+ $langName = Language::fetchLanguageName( $langPrefix );
+ if ( $langName === '' ) {
+ // give up.
+ $langName = $lang;
+ }
+ }
+ // else we have a language specified
+
+ $msg = $this->msg( 'metadata-langitem', $wrappedValue, $langName, $lang );
+ if ( $noHtml ) {
+ return '*' . $msg->text();
+ } /* else: */
+
+ $item = '<li class="mw-metadata-lang-code-'
+ . $lang;
+ if ( $default ) {
+ $item .= ' mw-metadata-lang-default';
+ }
+ $item .= '" lang="' . $lang . '">';
+ $item .= $msg->text();
+ $item .= "</li>\n";
+
+ return $item;
+ }
+
+ /**
+ * Convenience function for getFormattedData()
+ *
+ * @param string $tag The tag name to pass on
+ * @param string $val The value of the tag
+ * @param string $arg An argument to pass ($1)
+ * @param string $arg2 A 2nd argument to pass ($2)
+ * @return string The text content of "exif-$tag-$val" message in lower case
+ */
+ private function exifMsg( $tag, $val, $arg = null, $arg2 = null ) {
+ global $wgContLang;
+
+ if ( $val === '' ) {
+ $val = 'value';
+ }
+
+ return $this->msg( $wgContLang->lc( "exif-$tag-$val" ), $arg, $arg2 )->text();
+ }
+
+ /**
+ * Format a number, convert numbers from fractions into floating point
+ * numbers, joins arrays of numbers with commas.
+ *
+ * @param mixed $num The value to format
+ * @param float|int|bool $round Digits to round to or false.
+ * @return mixed A floating point number or whatever we were fed
+ */
+ private function formatNum( $num, $round = false ) {
+ $m = [];
+ if ( is_array( $num ) ) {
+ $out = [];
+ foreach ( $num as $number ) {
+ $out[] = $this->formatNum( $number );
+ }
+
+ return $this->getLanguage()->commaList( $out );
+ }
+ if ( preg_match( '/^(-?\d+)\/(\d+)$/', $num, $m ) ) {
+ if ( $m[2] != 0 ) {
+ $newNum = $m[1] / $m[2];
+ if ( $round !== false ) {
+ $newNum = round( $newNum, $round );
+ }
+ } else {
+ $newNum = $num;
+ }
+
+ return $this->getLanguage()->formatNum( $newNum );
+ } else {
+ if ( is_numeric( $num ) && $round !== false ) {
+ $num = round( $num, $round );
+ }
+
+ return $this->getLanguage()->formatNum( $num );
+ }
+ }
+
+ /**
+ * Format a rational number, reducing fractions
+ *
+ * @param mixed $num The value to format
+ * @return mixed A floating point number or whatever we were fed
+ */
+ private function formatFraction( $num ) {
+ $m = [];
+ if ( preg_match( '/^(-?\d+)\/(\d+)$/', $num, $m ) ) {
+ $numerator = intval( $m[1] );
+ $denominator = intval( $m[2] );
+ $gcd = $this->gcd( abs( $numerator ), $denominator );
+ if ( $gcd != 0 ) {
+ // 0 shouldn't happen! ;)
+ return $this->formatNum( $numerator / $gcd ) . '/' . $this->formatNum( $denominator / $gcd );
+ }
+ }
+
+ return $this->formatNum( $num );
+ }
+
+ /**
+ * Calculate the greatest common divisor of two integers.
+ *
+ * @param int $a Numerator
+ * @param int $b Denominator
+ * @return int
+ */
+ private function gcd( $a, $b ) {
+ /*
+ // https://en.wikipedia.org/wiki/Euclidean_algorithm
+ // Recursive form would be:
+ if( $b == 0 )
+ return $a;
+ else
+ return gcd( $b, $a % $b );
+ */
+ while ( $b != 0 ) {
+ $remainder = $a % $b;
+
+ // tail recursion...
+ $a = $b;
+ $b = $remainder;
+ }
+
+ return $a;
+ }
+
+ /**
+ * Fetch the human readable version of a news code.
+ * A news code is an 8 digit code. The first two
+ * digits are a general classification, so we just
+ * translate that.
+ *
+ * Note, leading 0's are significant, so this is
+ * a string, not an int.
+ *
+ * @param string $val The 8 digit news code.
+ * @return string The human readable form
+ */
+ private function convertNewsCode( $val ) {
+ if ( !preg_match( '/^\d{8}$/D', $val ) ) {
+ // Not a valid news code.
+ return $val;
+ }
+ $cat = '';
+ switch ( substr( $val, 0, 2 ) ) {
+ case '01':
+ $cat = 'ace';
+ break;
+ case '02':
+ $cat = 'clj';
+ break;
+ case '03':
+ $cat = 'dis';
+ break;
+ case '04':
+ $cat = 'fin';
+ break;
+ case '05':
+ $cat = 'edu';
+ break;
+ case '06':
+ $cat = 'evn';
+ break;
+ case '07':
+ $cat = 'hth';
+ break;
+ case '08':
+ $cat = 'hum';
+ break;
+ case '09':
+ $cat = 'lab';
+ break;
+ case '10':
+ $cat = 'lif';
+ break;
+ case '11':
+ $cat = 'pol';
+ break;
+ case '12':
+ $cat = 'rel';
+ break;
+ case '13':
+ $cat = 'sci';
+ break;
+ case '14':
+ $cat = 'soi';
+ break;
+ case '15':
+ $cat = 'spo';
+ break;
+ case '16':
+ $cat = 'war';
+ break;
+ case '17':
+ $cat = 'wea';
+ break;
+ }
+ if ( $cat !== '' ) {
+ $catMsg = $this->exifMsg( 'iimcategory', $cat );
+ $val = $this->exifMsg( 'subjectnewscode', '', $val, $catMsg );
+ }
+
+ return $val;
+ }
+
+ /**
+ * Format a coordinate value, convert numbers from floating point
+ * into degree minute second representation.
+ *
+ * @param int $coord Degrees, minutes and seconds
+ * @param string $type Latitude or longitude (for if its a NWS or E)
+ * @return mixed A floating point number or whatever we were fed
+ */
+ private function formatCoords( $coord, $type ) {
+ $ref = '';
+ if ( $coord < 0 ) {
+ $nCoord = -$coord;
+ if ( $type === 'latitude' ) {
+ $ref = 'S';
+ } elseif ( $type === 'longitude' ) {
+ $ref = 'W';
+ }
+ } else {
+ $nCoord = $coord;
+ if ( $type === 'latitude' ) {
+ $ref = 'N';
+ } elseif ( $type === 'longitude' ) {
+ $ref = 'E';
+ }
+ }
+
+ $deg = floor( $nCoord );
+ $min = floor( ( $nCoord - $deg ) * 60.0 );
+ $sec = round( ( ( $nCoord - $deg ) - $min / 60 ) * 3600, 2 );
+
+ $deg = $this->formatNum( $deg );
+ $min = $this->formatNum( $min );
+ $sec = $this->formatNum( $sec );
+
+ return $this->msg( 'exif-coordinate-format', $deg, $min, $sec, $ref, $coord )->text();
+ }
+
+ /**
+ * Format the contact info field into a single value.
+ *
+ * This function might be called from
+ * JpegHandler::convertMetadataVersion which is why it is
+ * public.
+ *
+ * @param array $vals Array with fields of the ContactInfo
+ * struct defined in the IPTC4XMP spec. Or potentially
+ * an array with one element that is a free form text
+ * value from the older iptc iim 1:118 prop.
+ * @return string HTML-ish looking wikitext
+ * @since 1.23 no longer static
+ */
+ public function collapseContactInfo( $vals ) {
+ if ( !( isset( $vals['CiAdrExtadr'] )
+ || isset( $vals['CiAdrCity'] )
+ || isset( $vals['CiAdrCtry'] )
+ || isset( $vals['CiEmailWork'] )
+ || isset( $vals['CiTelWork'] )
+ || isset( $vals['CiAdrPcode'] )
+ || isset( $vals['CiAdrRegion'] )
+ || isset( $vals['CiUrlWork'] )
+ ) ) {
+ // We don't have any sub-properties
+ // This could happen if its using old
+ // iptc that just had this as a free-form
+ // text value.
+ // Note: We run this through htmlspecialchars
+ // partially to be consistent, and partially
+ // because people often insert >, etc into
+ // the metadata which should not be interpreted
+ // but we still want to auto-link urls.
+ foreach ( $vals as &$val ) {
+ $val = htmlspecialchars( $val );
+ }
+
+ return $this->flattenArrayReal( $vals );
+ } else {
+ // We have a real ContactInfo field.
+ // Its unclear if all these fields have to be
+ // set, so assume they do not.
+ $url = $tel = $street = $city = $country = '';
+ $email = $postal = $region = '';
+
+ // Also note, some of the class names this uses
+ // are similar to those used by hCard. This is
+ // mostly because they're sensible names. This
+ // does not (and does not attempt to) output
+ // stuff in the hCard microformat. However it
+ // might output in the adr microformat.
+
+ if ( isset( $vals['CiAdrExtadr'] ) ) {
+ // Todo: This can potentially be multi-line.
+ // Need to check how that works in XMP.
+ $street = '<span class="extended-address">'
+ . htmlspecialchars(
+ $vals['CiAdrExtadr'] )
+ . '</span>';
+ }
+ if ( isset( $vals['CiAdrCity'] ) ) {
+ $city = '<span class="locality">'
+ . htmlspecialchars( $vals['CiAdrCity'] )
+ . '</span>';
+ }
+ if ( isset( $vals['CiAdrCtry'] ) ) {
+ $country = '<span class="country-name">'
+ . htmlspecialchars( $vals['CiAdrCtry'] )
+ . '</span>';
+ }
+ if ( isset( $vals['CiEmailWork'] ) ) {
+ $emails = [];
+ // Have to split multiple emails at commas/new lines.
+ $splitEmails = explode( "\n", $vals['CiEmailWork'] );
+ foreach ( $splitEmails as $e1 ) {
+ // Also split on comma
+ foreach ( explode( ',', $e1 ) as $e2 ) {
+ $finalEmail = trim( $e2 );
+ if ( $finalEmail == ',' || $finalEmail == '' ) {
+ continue;
+ }
+ if ( strpos( $finalEmail, '<' ) !== false ) {
+ // Don't do fancy formatting to
+ // "My name" <foo@bar.com> style stuff
+ $emails[] = $finalEmail;
+ } else {
+ $emails[] = '[mailto:'
+ . $finalEmail
+ . ' <span class="email">'
+ . $finalEmail
+ . '</span>]';
+ }
+ }
+ }
+ $email = implode( ', ', $emails );
+ }
+ if ( isset( $vals['CiTelWork'] ) ) {
+ $tel = '<span class="tel">'
+ . htmlspecialchars( $vals['CiTelWork'] )
+ . '</span>';
+ }
+ if ( isset( $vals['CiAdrPcode'] ) ) {
+ $postal = '<span class="postal-code">'
+ . htmlspecialchars(
+ $vals['CiAdrPcode'] )
+ . '</span>';
+ }
+ if ( isset( $vals['CiAdrRegion'] ) ) {
+ // Note this is province/state.
+ $region = '<span class="region">'
+ . htmlspecialchars(
+ $vals['CiAdrRegion'] )
+ . '</span>';
+ }
+ if ( isset( $vals['CiUrlWork'] ) ) {
+ $url = '<span class="url">'
+ . htmlspecialchars( $vals['CiUrlWork'] )
+ . '</span>';
+ }
+
+ return $this->msg( 'exif-contact-value', $email, $url,
+ $street, $city, $region, $postal, $country,
+ $tel )->text();
+ }
+ }
+
+ /**
+ * Get a list of fields that are visible by default.
+ *
+ * @return array
+ * @since 1.23
+ */
+ public static function getVisibleFields() {
+ $fields = [];
+ $lines = explode( "\n", wfMessage( 'metadata-fields' )->inContentLanguage()->text() );
+ foreach ( $lines as $line ) {
+ $matches = [];
+ if ( preg_match( '/^\\*\s*(.*?)\s*$/', $line, $matches ) ) {
+ $fields[] = $matches[1];
+ }
+ }
+ $fields = array_map( 'strtolower', $fields );
+
+ return $fields;
+ }
+
+ /**
+ * Get an array of extended metadata. (See the imageinfo API for format.)
+ *
+ * @param File $file File to use
+ * @return array [<property name> => ['value' => <value>]], or [] on error
+ * @since 1.23
+ */
+ public function fetchExtendedMetadata( File $file ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+
+ // If revision deleted, exit immediately
+ if ( $file->isDeleted( File::DELETED_FILE ) ) {
+ return [];
+ }
+
+ $cacheKey = $cache->makeKey(
+ 'getExtendedMetadata',
+ $this->getLanguage()->getCode(),
+ (int)$this->singleLang,
+ $file->getSha1()
+ );
+
+ $cachedValue = $cache->get( $cacheKey );
+ if (
+ $cachedValue
+ && Hooks::run( 'ValidateExtendedMetadataCache', [ $cachedValue['timestamp'], $file ] )
+ ) {
+ $extendedMetadata = $cachedValue['data'];
+ } else {
+ $maxCacheTime = ( $file instanceof ForeignAPIFile ) ? 60 * 60 * 12 : 60 * 60 * 24 * 30;
+ $fileMetadata = $this->getExtendedMetadataFromFile( $file );
+ $extendedMetadata = $this->getExtendedMetadataFromHook( $file, $fileMetadata, $maxCacheTime );
+ if ( $this->singleLang ) {
+ $this->resolveMultilangMetadata( $extendedMetadata );
+ }
+ $this->discardMultipleValues( $extendedMetadata );
+ // Make sure the metadata won't break the API when an XML format is used.
+ // This is an API-specific function so it would be cleaner to call it from
+ // outside fetchExtendedMetadata, but this way we don't need to redo the
+ // computation on a cache hit.
+ $this->sanitizeArrayForAPI( $extendedMetadata );
+ $valueToCache = [ 'data' => $extendedMetadata, 'timestamp' => wfTimestampNow() ];
+ $cache->set( $cacheKey, $valueToCache, $maxCacheTime );
+ }
+
+ return $extendedMetadata;
+ }
+
+ /**
+ * Get file-based metadata in standardized format.
+ *
+ * Note that for a remote file, this might return metadata supplied by extensions.
+ *
+ * @param File $file File to use
+ * @return array [<property name> => ['value' => <value>]], or [] on error
+ * @since 1.23
+ */
+ protected function getExtendedMetadataFromFile( File $file ) {
+ // If this is a remote file accessed via an API request, we already
+ // have remote metadata so we just ignore any local one
+ if ( $file instanceof ForeignAPIFile ) {
+ // In case of error we pretend no metadata - this will get cached.
+ // Might or might not be a good idea.
+ return $file->getExtendedMetadata() ?: [];
+ }
+
+ $uploadDate = wfTimestamp( TS_ISO_8601, $file->getTimestamp() );
+
+ $fileMetadata = [
+ // This is modification time, which is close to "upload" time.
+ 'DateTime' => [
+ 'value' => $uploadDate,
+ 'source' => 'mediawiki-metadata',
+ ],
+ ];
+
+ $title = $file->getTitle();
+ if ( $title ) {
+ $text = $title->getText();
+ $pos = strrpos( $text, '.' );
+
+ if ( $pos ) {
+ $name = substr( $text, 0, $pos );
+ } else {
+ $name = $text;
+ }
+
+ $fileMetadata['ObjectName'] = [
+ 'value' => $name,
+ 'source' => 'mediawiki-metadata',
+ ];
+ }
+
+ return $fileMetadata;
+ }
+
+ /**
+ * Get additional metadata from hooks in standardized format.
+ *
+ * @param File $file File to use
+ * @param array $extendedMetadata
+ * @param int &$maxCacheTime Hook handlers might use this parameter to override cache time
+ *
+ * @return array [<property name> => ['value' => <value>]], or [] on error
+ * @since 1.23
+ */
+ protected function getExtendedMetadataFromHook( File $file, array $extendedMetadata,
+ &$maxCacheTime
+ ) {
+ Hooks::run( 'GetExtendedMetadata', [
+ &$extendedMetadata,
+ $file,
+ $this->getContext(),
+ $this->singleLang,
+ &$maxCacheTime
+ ] );
+
+ $visible = array_flip( self::getVisibleFields() );
+ foreach ( $extendedMetadata as $key => $value ) {
+ if ( !isset( $visible[strtolower( $key )] ) ) {
+ $extendedMetadata[$key]['hidden'] = '';
+ }
+ }
+
+ return $extendedMetadata;
+ }
+
+ /**
+ * Turns an XMP-style multilang array into a single value.
+ * If the value is not a multilang array, it is returned unchanged.
+ * See mediawiki.org/wiki/Manual:File_metadata_handling#Multi-language_array_format
+ * @param mixed $value
+ * @return mixed Value in best language, null if there were no languages at all
+ * @since 1.23
+ */
+ protected function resolveMultilangValue( $value ) {
+ if (
+ !is_array( $value )
+ || !isset( $value['_type'] )
+ || $value['_type'] != 'lang'
+ ) {
+ return $value; // do nothing if not a multilang array
+ }
+
+ // choose the language best matching user or site settings
+ $priorityLanguages = $this->getPriorityLanguages();
+ foreach ( $priorityLanguages as $lang ) {
+ if ( isset( $value[$lang] ) ) {
+ return $value[$lang];
+ }
+ }
+
+ // otherwise go with the default language, if set
+ if ( isset( $value['x-default'] ) ) {
+ return $value['x-default'];
+ }
+
+ // otherwise just return any one language
+ unset( $value['_type'] );
+ if ( !empty( $value ) ) {
+ return reset( $value );
+ }
+
+ // this should not happen; signal error
+ return null;
+ }
+
+ /**
+ * Turns an XMP-style multivalue array into a single value by dropping all but the first
+ * value. If the value is not a multivalue array (or a multivalue array inside a multilang
+ * array), it is returned unchanged.
+ * See mediawiki.org/wiki/Manual:File_metadata_handling#Multi-language_array_format
+ * @param mixed $value
+ * @return mixed The value, or the first value if there were multiple ones
+ * @since 1.25
+ */
+ protected function resolveMultivalueValue( $value ) {
+ if ( !is_array( $value ) ) {
+ return $value;
+ } elseif ( isset( $value['_type'] ) && $value['_type'] === 'lang' ) {
+ // if this is a multilang array, process fields separately
+ $newValue = [];
+ foreach ( $value as $k => $v ) {
+ $newValue[$k] = $this->resolveMultivalueValue( $v );
+ }
+ return $newValue;
+ } else { // _type is 'ul' or 'ol' or missing in which case it defaults to 'ul'
+ list( $k, $v ) = each( $value );
+ if ( $k === '_type' ) {
+ $v = current( $value );
+ }
+ return $v;
+ }
+ }
+
+ /**
+ * Takes an array returned by the getExtendedMetadata* functions,
+ * and resolves multi-language values in it.
+ * @param array &$metadata
+ * @since 1.23
+ */
+ protected function resolveMultilangMetadata( &$metadata ) {
+ if ( !is_array( $metadata ) ) {
+ return;
+ }
+ foreach ( $metadata as &$field ) {
+ if ( isset( $field['value'] ) ) {
+ $field['value'] = $this->resolveMultilangValue( $field['value'] );
+ }
+ }
+ }
+
+ /**
+ * Takes an array returned by the getExtendedMetadata* functions,
+ * and turns all fields into single-valued ones by dropping extra values.
+ * @param array &$metadata
+ * @since 1.25
+ */
+ protected function discardMultipleValues( &$metadata ) {
+ if ( !is_array( $metadata ) ) {
+ return;
+ }
+ foreach ( $metadata as $key => &$field ) {
+ if ( $key === 'Software' || $key === 'Contact' ) {
+ // we skip some fields which have composite values. They are not particularly interesting
+ // and you can get them via the metadata / commonmetadata APIs anyway.
+ continue;
+ }
+ if ( isset( $field['value'] ) ) {
+ $field['value'] = $this->resolveMultivalueValue( $field['value'] );
+ }
+ }
+ }
+
+ /**
+ * Makes sure the given array is a valid API response fragment
+ * @param array &$arr
+ */
+ protected function sanitizeArrayForAPI( &$arr ) {
+ if ( !is_array( $arr ) ) {
+ return;
+ }
+
+ $counter = 1;
+ foreach ( $arr as $key => &$value ) {
+ $sanitizedKey = $this->sanitizeKeyForAPI( $key );
+ if ( $sanitizedKey !== $key ) {
+ if ( isset( $arr[$sanitizedKey] ) ) {
+ // Make the sanitized keys hopefully unique.
+ // To make it definitely unique would be too much effort, given that
+ // sanitizing is only needed for misformatted metadata anyway, but
+ // this at least covers the case when $arr is numeric.
+ $sanitizedKey .= $counter;
+ ++$counter;
+ }
+ $arr[$sanitizedKey] = $arr[$key];
+ unset( $arr[$key] );
+ }
+ if ( is_array( $value ) ) {
+ $this->sanitizeArrayForAPI( $value );
+ }
+ }
+
+ // Handle API metadata keys (particularly "_type")
+ $keys = array_filter( array_keys( $arr ), 'ApiResult::isMetadataKey' );
+ if ( $keys ) {
+ ApiResult::setPreserveKeysList( $arr, $keys );
+ }
+ }
+
+ /**
+ * Turns a string into a valid API identifier.
+ * @param string $key
+ * @return string
+ * @since 1.23
+ */
+ protected function sanitizeKeyForAPI( $key ) {
+ // drop all characters which are not valid in an XML tag name
+ // a bunch of non-ASCII letters would be valid but probably won't
+ // be used so we take the easy way
+ $key = preg_replace( '/[^a-zA-z0-9_:.-]/', '', $key );
+ // drop characters which are invalid at the first position
+ $key = preg_replace( '/^[\d-.]+/', '', $key );
+
+ if ( $key == '' ) {
+ $key = '_';
+ }
+
+ // special case for an internal keyword
+ if ( $key == '_element' ) {
+ $key = 'element';
+ }
+
+ return $key;
+ }
+
+ /**
+ * Returns a list of languages (first is best) to use when formatting multilang fields,
+ * based on user and site preferences.
+ * @return array
+ * @since 1.23
+ */
+ protected function getPriorityLanguages() {
+ $priorityLanguages =
+ Language::getFallbacksIncludingSiteLanguage( $this->getLanguage()->getCode() );
+ $priorityLanguages = array_merge(
+ (array)$this->getLanguage()->getCode(),
+ $priorityLanguages[0],
+ $priorityLanguages[1]
+ );
+
+ return $priorityLanguages;
+ }
+}
diff --git a/www/wiki/includes/media/GIF.php b/www/wiki/includes/media/GIF.php
new file mode 100644
index 00000000..5f23855c
--- /dev/null
+++ b/www/wiki/includes/media/GIF.php
@@ -0,0 +1,211 @@
+<?php
+/**
+ * Handler for GIF images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Handler for GIF images.
+ *
+ * @ingroup Media
+ */
+class GIFHandler extends BitmapHandler {
+ const BROKEN_FILE = '0'; // value to store in img_metadata if error extracting metadata.
+
+ function getMetadata( $image, $filename ) {
+ try {
+ $parsedGIFMetadata = BitmapMetadataHandler::GIF( $filename );
+ } catch ( Exception $e ) {
+ // Broken file?
+ wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" );
+
+ return self::BROKEN_FILE;
+ }
+
+ return serialize( $parsedGIFMetadata );
+ }
+
+ /**
+ * @param File $image
+ * @param bool|IContextSource $context Context to use (optional)
+ * @return array|bool
+ */
+ function formatMetadata( $image, $context = false ) {
+ $meta = $this->getCommonMetaArray( $image );
+ if ( count( $meta ) === 0 ) {
+ return false;
+ }
+
+ return $this->formatMetadataHelper( $meta, $context );
+ }
+
+ /**
+ * Return the standard metadata elements for #filemetadata parser func.
+ * @param File $image
+ * @return array|bool
+ */
+ public function getCommonMetaArray( File $image ) {
+ $meta = $image->getMetadata();
+
+ if ( !$meta ) {
+ return [];
+ }
+ $meta = unserialize( $meta );
+ if ( !isset( $meta['metadata'] ) ) {
+ return [];
+ }
+ unset( $meta['metadata']['_MW_GIF_VERSION'] );
+
+ return $meta['metadata'];
+ }
+
+ /**
+ * @todo Add unit tests
+ *
+ * @param File $image
+ * @return bool
+ */
+ function getImageArea( $image ) {
+ $ser = $image->getMetadata();
+ if ( $ser ) {
+ $metadata = unserialize( $ser );
+
+ return $image->getWidth() * $image->getHeight() * $metadata['frameCount'];
+ } else {
+ return $image->getWidth() * $image->getHeight();
+ }
+ }
+
+ /**
+ * @param File $image
+ * @return bool
+ */
+ function isAnimatedImage( $image ) {
+ $ser = $image->getMetadata();
+ if ( $ser ) {
+ $metadata = unserialize( $ser );
+ if ( $metadata['frameCount'] > 1 ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * We cannot animate thumbnails that are bigger than a particular size
+ * @param File $file
+ * @return bool
+ */
+ function canAnimateThumbnail( $file ) {
+ global $wgMaxAnimatedGifArea;
+ $answer = $this->getImageArea( $file ) <= $wgMaxAnimatedGifArea;
+
+ return $answer;
+ }
+
+ function getMetadataType( $image ) {
+ return 'parsed-gif';
+ }
+
+ function isMetadataValid( $image, $metadata ) {
+ if ( $metadata === self::BROKEN_FILE ) {
+ // Do not repetitivly regenerate metadata on broken file.
+ return self::METADATA_GOOD;
+ }
+
+ MediaWiki\suppressWarnings();
+ $data = unserialize( $metadata );
+ MediaWiki\restoreWarnings();
+
+ if ( !$data || !is_array( $data ) ) {
+ wfDebug( __METHOD__ . " invalid GIF metadata\n" );
+
+ return self::METADATA_BAD;
+ }
+
+ if ( !isset( $data['metadata']['_MW_GIF_VERSION'] )
+ || $data['metadata']['_MW_GIF_VERSION'] != GIFMetadataExtractor::VERSION
+ ) {
+ wfDebug( __METHOD__ . " old but compatible GIF metadata\n" );
+
+ return self::METADATA_COMPATIBLE;
+ }
+
+ return self::METADATA_GOOD;
+ }
+
+ /**
+ * @param File $image
+ * @return string
+ */
+ function getLongDesc( $image ) {
+ global $wgLang;
+
+ $original = parent::getLongDesc( $image );
+
+ MediaWiki\suppressWarnings();
+ $metadata = unserialize( $image->getMetadata() );
+ MediaWiki\restoreWarnings();
+
+ if ( !$metadata || $metadata['frameCount'] <= 1 ) {
+ return $original;
+ }
+
+ /* Preserve original image info string, but strip the last char ')' so we can add even more */
+ $info = [];
+ $info[] = $original;
+
+ if ( $metadata['looped'] ) {
+ $info[] = wfMessage( 'file-info-gif-looped' )->parse();
+ }
+
+ if ( $metadata['frameCount'] > 1 ) {
+ $info[] = wfMessage( 'file-info-gif-frames' )->numParams( $metadata['frameCount'] )->parse();
+ }
+
+ if ( $metadata['duration'] ) {
+ $info[] = $wgLang->formatTimePeriod( $metadata['duration'] );
+ }
+
+ return $wgLang->commaList( $info );
+ }
+
+ /**
+ * Return the duration of the GIF file.
+ *
+ * Shown in the &query=imageinfo&iiprop=size api query.
+ *
+ * @param File $file
+ * @return float The duration of the file.
+ */
+ public function getLength( $file ) {
+ $serMeta = $file->getMetadata();
+ MediaWiki\suppressWarnings();
+ $metadata = unserialize( $serMeta );
+ MediaWiki\restoreWarnings();
+
+ if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) {
+ return 0.0;
+ } else {
+ return (float)$metadata['duration'];
+ }
+ }
+}
diff --git a/www/wiki/includes/media/GIFMetadataExtractor.php b/www/wiki/includes/media/GIFMetadataExtractor.php
new file mode 100644
index 00000000..ac5fc81c
--- /dev/null
+++ b/www/wiki/includes/media/GIFMetadataExtractor.php
@@ -0,0 +1,347 @@
+<?php
+/**
+ * GIF frame counter.
+ *
+ * Originally written in Perl by Steve Sanbeg.
+ * Ported to PHP by Andrew Garrett
+ * Deliberately not using MWExceptions to avoid external dependencies, encouraging
+ * redistribution.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * GIF frame counter.
+ *
+ * @ingroup Media
+ */
+class GIFMetadataExtractor {
+ /** @var string */
+ private static $gifFrameSep;
+
+ /** @var string */
+ private static $gifExtensionSep;
+
+ /** @var string */
+ private static $gifTerm;
+
+ const VERSION = 1;
+
+ // Each sub-block is less than or equal to 255 bytes.
+ // Most of the time its 255 bytes, except for in XMP
+ // blocks, where it's usually between 32-127 bytes each.
+ const MAX_SUBBLOCKS = 262144; // 5mb divided by 20.
+
+ /**
+ * @throws Exception
+ * @param string $filename
+ * @return array
+ */
+ static function getMetadata( $filename ) {
+ self::$gifFrameSep = pack( "C", ord( "," ) ); // 2C
+ self::$gifExtensionSep = pack( "C", ord( "!" ) ); // 21
+ self::$gifTerm = pack( "C", ord( ";" ) ); // 3B
+
+ $frameCount = 0;
+ $duration = 0.0;
+ $isLooped = false;
+ $xmp = "";
+ $comment = [];
+
+ if ( !$filename ) {
+ throw new Exception( "No file name specified" );
+ } elseif ( !file_exists( $filename ) || is_dir( $filename ) ) {
+ throw new Exception( "File $filename does not exist" );
+ }
+
+ $fh = fopen( $filename, 'rb' );
+
+ if ( !$fh ) {
+ throw new Exception( "Unable to open file $filename" );
+ }
+
+ // Check for the GIF header
+ $buf = fread( $fh, 6 );
+ if ( !( $buf == 'GIF87a' || $buf == 'GIF89a' ) ) {
+ throw new Exception( "Not a valid GIF file; header: $buf" );
+ }
+
+ // Read width and height.
+ $buf = fread( $fh, 2 );
+ $width = unpack( 'v', $buf )[1];
+ $buf = fread( $fh, 2 );
+ $height = unpack( 'v', $buf )[1];
+
+ // Read BPP
+ $buf = fread( $fh, 1 );
+ $bpp = self::decodeBPP( $buf );
+
+ // Skip over background and aspect ratio
+ fread( $fh, 2 );
+
+ // Skip over the GCT
+ self::readGCT( $fh, $bpp );
+
+ while ( !feof( $fh ) ) {
+ $buf = fread( $fh, 1 );
+
+ if ( $buf == self::$gifFrameSep ) {
+ // Found a frame
+ $frameCount++;
+
+ # # Skip bounding box
+ fread( $fh, 8 );
+
+ # # Read BPP
+ $buf = fread( $fh, 1 );
+ $bpp = self::decodeBPP( $buf );
+
+ # # Read GCT
+ self::readGCT( $fh, $bpp );
+ fread( $fh, 1 );
+ self::skipBlock( $fh );
+ } elseif ( $buf == self::$gifExtensionSep ) {
+ $buf = fread( $fh, 1 );
+ if ( strlen( $buf ) < 1 ) {
+ throw new Exception( "Ran out of input" );
+ }
+ $extension_code = unpack( 'C', $buf )[1];
+
+ if ( $extension_code == 0xF9 ) {
+ // Graphics Control Extension.
+ fread( $fh, 1 ); // Block size
+
+ fread( $fh, 1 ); // Transparency, disposal method, user input
+
+ $buf = fread( $fh, 2 ); // Delay, in hundredths of seconds.
+ if ( strlen( $buf ) < 2 ) {
+ throw new Exception( "Ran out of input" );
+ }
+ $delay = unpack( 'v', $buf )[1];
+ $duration += $delay * 0.01;
+
+ fread( $fh, 1 ); // Transparent colour index
+
+ $term = fread( $fh, 1 ); // Should be a terminator
+ if ( strlen( $term ) < 1 ) {
+ throw new Exception( "Ran out of input" );
+ }
+ $term = unpack( 'C', $term )[1];
+ if ( $term != 0 ) {
+ throw new Exception( "Malformed Graphics Control Extension block" );
+ }
+ } elseif ( $extension_code == 0xFE ) {
+ // Comment block(s).
+ $data = self::readBlock( $fh );
+ if ( $data === "" ) {
+ throw new Exception( 'Read error, zero-length comment block' );
+ }
+
+ // The standard says this should be ASCII, however its unclear if
+ // thats true in practise. Check to see if its valid utf-8, if so
+ // assume its that, otherwise assume its windows-1252 (iso-8859-1)
+ $dataCopy = $data;
+ // quickIsNFCVerify has the side effect of replacing any invalid characters
+ UtfNormal\Validator::quickIsNFCVerify( $dataCopy );
+
+ if ( $dataCopy !== $data ) {
+ MediaWiki\suppressWarnings();
+ $data = iconv( 'windows-1252', 'UTF-8', $data );
+ MediaWiki\restoreWarnings();
+ }
+
+ $commentCount = count( $comment );
+ if ( $commentCount === 0
+ || $comment[$commentCount - 1] !== $data
+ ) {
+ // Some applications repeat the same comment on each
+ // frame of an animated GIF image, so if this comment
+ // is identical to the last, only extract once.
+ $comment[] = $data;
+ }
+ } elseif ( $extension_code == 0xFF ) {
+ // Application extension (Netscape info about the animated gif)
+ // or XMP (or theoretically any other type of extension block)
+ $blockLength = fread( $fh, 1 );
+ if ( strlen( $blockLength ) < 1 ) {
+ throw new Exception( "Ran out of input" );
+ }
+ $blockLength = unpack( 'C', $blockLength )[1];
+ $data = fread( $fh, $blockLength );
+
+ if ( $blockLength != 11 ) {
+ wfDebug( __METHOD__ . " GIF application block with wrong length\n" );
+ fseek( $fh, -( $blockLength + 1 ), SEEK_CUR );
+ self::skipBlock( $fh );
+ continue;
+ }
+
+ // NETSCAPE2.0 (application name for animated gif)
+ if ( $data == 'NETSCAPE2.0' ) {
+ $data = fread( $fh, 2 ); // Block length and introduction, should be 03 01
+
+ if ( $data != "\x03\x01" ) {
+ throw new Exception( "Expected \x03\x01, got $data" );
+ }
+
+ // Unsigned little-endian integer, loop count or zero for "forever"
+ $loopData = fread( $fh, 2 );
+ if ( strlen( $loopData ) < 2 ) {
+ throw new Exception( "Ran out of input" );
+ }
+ $loopCount = unpack( 'v', $loopData )[1];
+
+ if ( $loopCount != 1 ) {
+ $isLooped = true;
+ }
+
+ // Read out terminator byte
+ fread( $fh, 1 );
+ } elseif ( $data == 'XMP DataXMP' ) {
+ // application name for XMP data.
+ // see pg 18 of XMP spec part 3.
+
+ $xmp = self::readBlock( $fh, true );
+
+ if ( substr( $xmp, -257, 3 ) !== "\x01\xFF\xFE"
+ || substr( $xmp, -4 ) !== "\x03\x02\x01\x00"
+ ) {
+ // this is just a sanity check.
+ throw new Exception( "XMP does not have magic trailer!" );
+ }
+
+ // strip out trailer.
+ $xmp = substr( $xmp, 0, -257 );
+ } else {
+ // unrecognized extension block
+ fseek( $fh, -( $blockLength + 1 ), SEEK_CUR );
+ self::skipBlock( $fh );
+ continue;
+ }
+ } else {
+ self::skipBlock( $fh );
+ }
+ } elseif ( $buf == self::$gifTerm ) {
+ break;
+ } else {
+ if ( strlen( $buf ) < 1 ) {
+ throw new Exception( "Ran out of input" );
+ }
+ $byte = unpack( 'C', $buf )[1];
+ throw new Exception( "At position: " . ftell( $fh ) . ", Unknown byte " . $byte );
+ }
+ }
+
+ return [
+ 'frameCount' => $frameCount,
+ 'looped' => $isLooped,
+ 'duration' => $duration,
+ 'xmp' => $xmp,
+ 'comment' => $comment,
+ ];
+ }
+
+ /**
+ * @param resource $fh
+ * @param int $bpp
+ * @return void
+ */
+ static function readGCT( $fh, $bpp ) {
+ if ( $bpp > 0 ) {
+ $max = pow( 2, $bpp );
+ for ( $i = 1; $i <= $max; ++$i ) {
+ fread( $fh, 3 );
+ }
+ }
+ }
+
+ /**
+ * @param string $data
+ * @throws Exception
+ * @return int
+ */
+ static function decodeBPP( $data ) {
+ if ( strlen( $data ) < 1 ) {
+ throw new Exception( "Ran out of input" );
+ }
+ $buf = unpack( 'C', $data )[1];
+ $bpp = ( $buf & 7 ) + 1;
+ $buf >>= 7;
+
+ $have_map = $buf & 1;
+
+ return $have_map ? $bpp : 0;
+ }
+
+ /**
+ * @param resource $fh
+ * @throws Exception
+ */
+ static function skipBlock( $fh ) {
+ while ( !feof( $fh ) ) {
+ $buf = fread( $fh, 1 );
+ if ( strlen( $buf ) < 1 ) {
+ throw new Exception( "Ran out of input" );
+ }
+ $block_len = unpack( 'C', $buf )[1];
+ if ( $block_len == 0 ) {
+ return;
+ }
+ fread( $fh, $block_len );
+ }
+ }
+
+ /**
+ * Read a block. In the GIF format, a block is made up of
+ * several sub-blocks. Each sub block starts with one byte
+ * saying how long the sub-block is, followed by the sub-block.
+ * The entire block is terminated by a sub-block of length
+ * 0.
+ * @param resource $fh File handle
+ * @param bool $includeLengths Include the length bytes of the
+ * sub-blocks in the returned value. Normally this is false,
+ * except XMP is weird and does a hack where you need to keep
+ * these length bytes.
+ * @throws Exception
+ * @return string The data.
+ */
+ static function readBlock( $fh, $includeLengths = false ) {
+ $data = '';
+ $subLength = fread( $fh, 1 );
+ $blocks = 0;
+
+ while ( $subLength !== "\0" ) {
+ $blocks++;
+ if ( $blocks > self::MAX_SUBBLOCKS ) {
+ throw new Exception( "MAX_SUBBLOCKS exceeded (over $blocks sub-blocks)" );
+ }
+ if ( feof( $fh ) ) {
+ throw new Exception( "Read error: Unexpected EOF." );
+ }
+ if ( $includeLengths ) {
+ $data .= $subLength;
+ }
+
+ $data .= fread( $fh, ord( $subLength ) );
+ $subLength = fread( $fh, 1 );
+ }
+
+ return $data;
+ }
+}
diff --git a/www/wiki/includes/media/IPTC.php b/www/wiki/includes/media/IPTC.php
new file mode 100644
index 00000000..343adc20
--- /dev/null
+++ b/www/wiki/includes/media/IPTC.php
@@ -0,0 +1,601 @@
+<?php
+/**
+ * Class for some IPTC functions.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Class for some IPTC functions.
+ *
+ * @ingroup Media
+ */
+class IPTC {
+ /**
+ * This takes the results of iptcparse() and puts it into a
+ * form that can be handled by mediawiki. Generally called from
+ * BitmapMetadataHandler::doApp13.
+ *
+ * @see http://www.iptc.org/std/IIM/4.1/specification/IIMV4.1.pdf
+ *
+ * @param string $rawData The app13 block from jpeg containing iptc/iim data
+ * @return array IPTC metadata array
+ */
+ static function parse( $rawData ) {
+ $parsed = iptcparse( $rawData );
+ $data = [];
+ if ( !is_array( $parsed ) ) {
+ return $data;
+ }
+
+ $c = '';
+ // charset info contained in tag 1:90.
+ if ( isset( $parsed['1#090'] ) && isset( $parsed['1#090'][0] ) ) {
+ $c = self::getCharset( $parsed['1#090'][0] );
+ if ( $c === false ) {
+ // Unknown charset. refuse to parse.
+ // note: There is a different between
+ // unknown and no charset specified.
+ return [];
+ }
+ unset( $parsed['1#090'] );
+ }
+
+ foreach ( $parsed as $tag => $val ) {
+ if ( isset( $val[0] ) && trim( $val[0] ) == '' ) {
+ wfDebugLog( 'iptc', "IPTC tag $tag had only whitespace as its value." );
+ continue;
+ }
+ switch ( $tag ) {
+ case '2#120': /*IPTC caption. mapped with exif ImageDescription*/
+ $data['ImageDescription'] = self::convIPTC( $val, $c );
+ break;
+ case '2#116': /* copyright. Mapped with exif copyright */
+ $data['Copyright'] = self::convIPTC( $val, $c );
+ break;
+ case '2#080': /* byline. Mapped with exif Artist */
+ /* merge with byline title (2:85)
+ * like how exif does it with
+ * Title, person. Not sure if this is best
+ * approach since we no longer have the two fields
+ * separate. each byline title entry corresponds to a
+ * specific byline. */
+
+ $bylines = self::convIPTC( $val, $c );
+ if ( isset( $parsed['2#085'] ) ) {
+ $titles = self::convIPTC( $parsed['2#085'], $c );
+ } else {
+ $titles = [];
+ }
+
+ $titleCount = count( $titles );
+ for ( $i = 0; $i < $titleCount; $i++ ) {
+ if ( isset( $bylines[$i] ) ) {
+ // theoretically this should always be set
+ // but doesn't hurt to be careful.
+ $bylines[$i] = $titles[$i] . ', ' . $bylines[$i];
+ }
+ }
+ $data['Artist'] = $bylines;
+ break;
+ case '2#025': /* keywords */
+ $data['Keywords'] = self::convIPTC( $val, $c );
+ break;
+ case '2#101': /* Country (shown) */
+ $data['CountryDest'] = self::convIPTC( $val, $c );
+ break;
+ case '2#095': /* state/province (shown) */
+ $data['ProvinceOrStateDest'] = self::convIPTC( $val, $c );
+ break;
+ case '2#090': /* city (Shown) */
+ $data['CityDest'] = self::convIPTC( $val, $c );
+ break;
+ case '2#092': /* sublocation (shown) */
+ $data['SublocationDest'] = self::convIPTC( $val, $c );
+ break;
+ case '2#005': /* object name/title */
+ $data['ObjectName'] = self::convIPTC( $val, $c );
+ break;
+ case '2#040': /* special instructions */
+ $data['SpecialInstructions'] = self::convIPTC( $val, $c );
+ break;
+ case '2#105': /* headline */
+ $data['Headline'] = self::convIPTC( $val, $c );
+ break;
+ case '2#110': /* credit */
+ /*"Identifies the provider of the objectdata,
+ * not necessarily the owner/creator". */
+ $data['Credit'] = self::convIPTC( $val, $c );
+ break;
+ case '2#115': /* source */
+ /* "Identifies the original owner of the intellectual content of the
+ *objectdata. This could be an agency, a member of an agency or
+ *an individual." */
+ $data['Source'] = self::convIPTC( $val, $c );
+ break;
+
+ case '2#007': /* edit status (lead, correction, etc) */
+ $data['EditStatus'] = self::convIPTC( $val, $c );
+ break;
+ case '2#015': /* category. deprecated. max 3 letters in theory, often more */
+ $data['iimCategory'] = self::convIPTC( $val, $c );
+ break;
+ case '2#020': /* category. deprecated. */
+ $data['iimSupplementalCategory'] = self::convIPTC( $val, $c );
+ break;
+ case '2#010': /*urgency (1-8. 1 most, 5 normal, 8 low priority)*/
+ $data['Urgency'] = self::convIPTC( $val, $c );
+ break;
+ case '2#022':
+ /* "Identifies objectdata that recurs often and predictably...
+ * Example: Euroweather" */
+ $data['FixtureIdentifier'] = self::convIPTC( $val, $c );
+ break;
+ case '2#026':
+ /* Content location code (iso 3166 + some custom things)
+ * ex: TUR (for turkey), XUN (for UN), XSP (outer space)
+ * See wikipedia article on iso 3166 and appendix D of iim std. */
+ $data['LocationDestCode'] = self::convIPTC( $val, $c );
+ break;
+ case '2#027':
+ /* Content location name. Full printable name
+ * of location of photo. */
+ $data['LocationDest'] = self::convIPTC( $val, $c );
+ break;
+ case '2#065':
+ /* Originating Program.
+ * Combine with Program version (2:70) if present.
+ */
+ $software = self::convIPTC( $val, $c );
+
+ if ( count( $software ) !== 1 ) {
+ // according to iim standard this cannot have multiple values
+ // so if there is more than one, something weird is happening,
+ // and we skip it.
+ wfDebugLog( 'iptc', 'IPTC: Wrong count on 2:65 Software field' );
+ break;
+ }
+
+ if ( isset( $parsed['2#070'] ) ) {
+ // if a version is set for the software.
+ $softwareVersion = self::convIPTC( $parsed['2#070'], $c );
+ unset( $parsed['2#070'] );
+ $data['Software'] = [ [ $software[0], $softwareVersion[0] ] ];
+ } else {
+ $data['Software'] = $software;
+ }
+ break;
+ case '2#075':
+ /* Object cycle.
+ * a for morning (am), p for evening, b for both */
+ $data['ObjectCycle'] = self::convIPTC( $val, $c );
+ break;
+ case '2#100':
+ /* Country/Primary location code.
+ * "Indicates the code of the country/primary location where the
+ * intellectual property of the objectdata was created"
+ * unclear how this differs from 2#026
+ */
+ $data['CountryCodeDest'] = self::convIPTC( $val, $c );
+ break;
+ case '2#103':
+ /* original transmission ref.
+ * "A code representing the location of original transmission ac-
+ * cording to practises of the provider."
+ */
+ $data['OriginalTransmissionRef'] = self::convIPTC( $val, $c );
+ break;
+ case '2#118': /*contact*/
+ $data['Contact'] = self::convIPTC( $val, $c );
+ break;
+ case '2#122':
+ /* Writer/Editor
+ * "Identification of the name of the person involved in the writing,
+ * editing or correcting the objectdata or caption/abstract."
+ */
+ $data['Writer'] = self::convIPTC( $val, $c );
+ break;
+ case '2#135': /* lang code */
+ $data['LanguageCode'] = self::convIPTC( $val, $c );
+ break;
+
+ // Start date stuff.
+ // It doesn't accept incomplete dates even though they are valid
+ // according to spec.
+ // Should potentially store timezone as well.
+ case '2#055':
+ // Date created (not date digitized).
+ // Maps to exif DateTimeOriginal
+ if ( isset( $parsed['2#060'] ) ) {
+ $time = $parsed['2#060'];
+ } else {
+ $time = [];
+ }
+ $timestamp = self::timeHelper( $val, $time, $c );
+ if ( $timestamp ) {
+ $data['DateTimeOriginal'] = $timestamp;
+ }
+ break;
+
+ case '2#062':
+ // Date converted to digital representation.
+ // Maps to exif DateTimeDigitized
+ if ( isset( $parsed['2#063'] ) ) {
+ $time = $parsed['2#063'];
+ } else {
+ $time = [];
+ }
+ $timestamp = self::timeHelper( $val, $time, $c );
+ if ( $timestamp ) {
+ $data['DateTimeDigitized'] = $timestamp;
+ }
+ break;
+
+ case '2#030':
+ // Date released.
+ if ( isset( $parsed['2#035'] ) ) {
+ $time = $parsed['2#035'];
+ } else {
+ $time = [];
+ }
+ $timestamp = self::timeHelper( $val, $time, $c );
+ if ( $timestamp ) {
+ $data['DateTimeReleased'] = $timestamp;
+ }
+ break;
+
+ case '2#037':
+ // Date expires.
+ if ( isset( $parsed['2#038'] ) ) {
+ $time = $parsed['2#038'];
+ } else {
+ $time = [];
+ }
+ $timestamp = self::timeHelper( $val, $time, $c );
+ if ( $timestamp ) {
+ $data['DateTimeExpires'] = $timestamp;
+ }
+ break;
+
+ case '2#000': /* iim version */
+ // unlike other tags, this is a 2-byte binary number.
+ // technically this is required if there is iptc data
+ // but in practise it isn't always there.
+ if ( strlen( $val[0] ) == 2 ) {
+ // if is just to be paranoid.
+ $versionValue = ord( substr( $val[0], 0, 1 ) ) * 256;
+ $versionValue += ord( substr( $val[0], 1, 1 ) );
+ $data['iimVersion'] = $versionValue;
+ }
+ break;
+
+ case '2#004':
+ // IntellectualGenere.
+ // first 4 characters are an id code
+ // That we're not really interested in.
+
+ // This prop is weird, since it's
+ // allowed to have multiple values
+ // in iim 4.1, but not in the XMP
+ // stuff. We're going to just
+ // extract the first value.
+ $con = self::convIPTC( $val, $c );
+ if ( strlen( $con[0] ) < 5 ) {
+ wfDebugLog( 'iptc', 'IPTC: '
+ . '2:04 too short. '
+ . 'Ignoring.' );
+ break;
+ }
+ $extracted = substr( $con[0], 4 );
+ $data['IntellectualGenre'] = $extracted;
+ break;
+
+ case '2#012':
+ // Subject News code - this is a compound field
+ // at the moment we only extract the subject news
+ // code, which is an 8 digit (ascii) number
+ // describing the subject matter of the content.
+ $codes = self::convIPTC( $val, $c );
+ foreach ( $codes as $ic ) {
+ $fields = explode( ':', $ic, 3 );
+
+ if ( count( $fields ) < 2 || $fields[0] !== 'IPTC' ) {
+ wfDebugLog( 'IPTC', 'IPTC: '
+ . 'Invalid 2:12 - ' . $ic );
+ break;
+ }
+ $data['SubjectNewsCode'] = $fields[1];
+ }
+ break;
+
+ // purposely does not do 2:125, 2:130, 2:131,
+ // 2:47, 2:50, 2:45, 2:42, 2:8, 2:3
+ // 2:200, 2:201, 2:202
+ // or the audio stuff (2:150 to 2:154)
+
+ case '2#070':
+ case '2#060':
+ case '2#063':
+ case '2#085':
+ case '2#038':
+ case '2#035':
+ // ignore. Handled elsewhere.
+ break;
+
+ default:
+ wfDebugLog( 'iptc', "Unsupported iptc tag: $tag. Value: " . implode( ',', $val ) );
+ break;
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Convert an iptc date and time tags into the exif format
+ *
+ * @todo Potentially this should also capture the timezone offset.
+ * @param array $date The date tag
+ * @param array $time The time tag
+ * @param string $c The charset
+ * @return string Date in EXIF format.
+ */
+ private static function timeHelper( $date, $time, $c ) {
+ if ( count( $date ) === 1 ) {
+ // the standard says this should always be 1
+ // just double checking.
+ list( $date ) = self::convIPTC( $date, $c );
+ } else {
+ return null;
+ }
+
+ if ( count( $time ) === 1 ) {
+ list( $time ) = self::convIPTC( $time, $c );
+ $dateOnly = false;
+ } else {
+ $time = '000000+0000'; // placeholder
+ $dateOnly = true;
+ }
+
+ if ( !( preg_match( '/\d\d\d\d\d\d[-+]\d\d\d\d/', $time )
+ && preg_match( '/\d\d\d\d\d\d\d\d/', $date )
+ && substr( $date, 0, 4 ) !== '0000'
+ && substr( $date, 4, 2 ) !== '00'
+ && substr( $date, 6, 2 ) !== '00'
+ ) ) {
+ // something wrong.
+ // Note, this rejects some valid dates according to iptc spec
+ // for example: the date 00000400 means the photo was taken in
+ // April, but the year and day is unknown. We don't process these
+ // types of incomplete dates atm.
+ wfDebugLog( 'iptc', "IPTC: invalid time ( $time ) or date ( $date )" );
+
+ return null;
+ }
+
+ $unixTS = wfTimestamp( TS_UNIX, $date . substr( $time, 0, 6 ) );
+ if ( $unixTS === false ) {
+ wfDebugLog( 'iptc', "IPTC: can't convert date to TS_UNIX: $date $time." );
+
+ return null;
+ }
+
+ $tz = ( intval( substr( $time, 7, 2 ) ) * 60 * 60 )
+ + ( intval( substr( $time, 9, 2 ) ) * 60 );
+
+ if ( substr( $time, 6, 1 ) === '-' ) {
+ $tz = -$tz;
+ }
+
+ $finalTimestamp = wfTimestamp( TS_EXIF, $unixTS + $tz );
+ if ( $finalTimestamp === false ) {
+ wfDebugLog( 'iptc', "IPTC: can't make final timestamp. Date: " . ( $unixTS + $tz ) );
+
+ return null;
+ }
+ if ( $dateOnly ) {
+ // return the date only
+ return substr( $finalTimestamp, 0, 10 );
+ } else {
+ return $finalTimestamp;
+ }
+ }
+
+ /**
+ * Helper function to convert charset for iptc values.
+ * @param string|array $data The iptc string
+ * @param string $charset The charset
+ *
+ * @return string|array
+ */
+ private static function convIPTC( $data, $charset ) {
+ if ( is_array( $data ) ) {
+ foreach ( $data as &$val ) {
+ $val = self::convIPTCHelper( $val, $charset );
+ }
+ } else {
+ $data = self::convIPTCHelper( $data, $charset );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Helper function of a helper function to convert charset for iptc values.
+ * @param string|array $data The IPTC string
+ * @param string $charset The charset
+ *
+ * @return string
+ */
+ private static function convIPTCHelper( $data, $charset ) {
+ if ( $charset ) {
+ MediaWiki\suppressWarnings();
+ $data = iconv( $charset, "UTF-8//IGNORE", $data );
+ MediaWiki\restoreWarnings();
+ if ( $data === false ) {
+ $data = "";
+ wfDebugLog( 'iptc', __METHOD__ . " Error converting iptc data charset $charset to utf-8" );
+ }
+ } else {
+ // treat as utf-8 if is valid utf-8. otherwise pretend its windows-1252
+ // most of the time if there is no 1:90 tag, it is either ascii, latin1, or utf-8
+ $oldData = $data;
+ UtfNormal\Validator::quickIsNFCVerify( $data ); // make $data valid utf-8
+ if ( $data === $oldData ) {
+ return $data; // if validation didn't change $data
+ } else {
+ return self::convIPTCHelper( $oldData, 'Windows-1252' );
+ }
+ }
+
+ return trim( $data );
+ }
+
+ /**
+ * take the value of 1:90 tag and returns a charset
+ * @param string $tag 1:90 tag.
+ * @return string Charset name or "?"
+ * Warning, this function does not (and is not intended to) detect
+ * all iso 2022 escape codes. In practise, the code for utf-8 is the
+ * only code that seems to have wide use. It does detect that code.
+ */
+ static function getCharset( $tag ) {
+ // According to iim standard, charset is defined by the tag 1:90.
+ // in which there are iso 2022 escape sequences to specify the character set.
+ // the iim standard seems to encourage that all necessary escape sequences are
+ // in the 1:90 tag, but says it doesn't have to be.
+
+ // This is in need of more testing probably. This is definitely not complete.
+ // however reading the docs of some other iptc software, it appears that most iptc software
+ // only recognizes utf-8. If 1:90 tag is not present content is
+ // usually ascii or iso-8859-1 (and sometimes utf-8), but no guarantee.
+
+ // This also won't work if there are more than one escape sequence in the 1:90 tag
+ // or if something is put in the G2, or G3 charsets, etc. It will only reliably recognize utf-8.
+
+ // This is just going through the charsets mentioned in appendix C of the iim standard.
+
+ // \x1b = ESC.
+ switch ( $tag ) {
+ case "\x1b%G": // utf-8
+ // Also call things that are compatible with utf-8, utf-8 (e.g. ascii)
+ case "\x1b(B": // ascii
+ case "\x1b(@": // iso-646-IRV (ascii in latest version, $ different in older version)
+ $c = 'UTF-8';
+ break;
+ case "\x1b(A": // like ascii, but british.
+ $c = 'ISO646-GB';
+ break;
+ case "\x1b(C": // some obscure sweedish/finland encoding
+ $c = 'ISO-IR-8-1';
+ break;
+ case "\x1b(D":
+ $c = 'ISO-IR-8-2';
+ break;
+ case "\x1b(E": // some obscure danish/norway encoding
+ $c = 'ISO-IR-9-1';
+ break;
+ case "\x1b(F":
+ $c = 'ISO-IR-9-2';
+ break;
+ case "\x1b(G":
+ $c = 'SEN_850200_B'; // aka iso 646-SE; ascii-like
+ break;
+ case "\x1b(I":
+ $c = "ISO646-IT";
+ break;
+ case "\x1b(L":
+ $c = "ISO646-PT";
+ break;
+ case "\x1b(Z":
+ $c = "ISO646-ES";
+ break;
+ case "\x1b([":
+ $c = "GREEK7-OLD";
+ break;
+ case "\x1b(K":
+ $c = "ISO646-DE";
+ break;
+ case "\x1b(N": // crylic
+ $c = "ISO_5427";
+ break;
+ case "\x1b(`": // iso646-NO
+ $c = "NS_4551-1";
+ break;
+ case "\x1b(f": // iso646-FR
+ $c = "NF_Z_62-010";
+ break;
+ case "\x1b(g":
+ $c = "PT2"; // iso646-PT2
+ break;
+ case "\x1b(h":
+ $c = "ES2";
+ break;
+ case "\x1b(i": // iso646-HU
+ $c = "MSZ_7795.3";
+ break;
+ case "\x1b(w":
+ $c = "CSA_Z243.4-1985-1";
+ break;
+ case "\x1b(x":
+ $c = "CSA_Z243.4-1985-2";
+ break;
+ case "\x1b\$(B":
+ case "\x1b\$B":
+ case "\x1b&@\x1b\$B":
+ case "\x1b&@\x1b\$(B":
+ $c = "JIS_C6226-1983";
+ break;
+ case "\x1b-A": // iso-8859-1. at least for the high code characters.
+ case "\x1b(@\x1b-A":
+ case "\x1b(B\x1b-A":
+ $c = 'ISO-8859-1';
+ break;
+ case "\x1b-B": // iso-8859-2. at least for the high code characters.
+ $c = 'ISO-8859-2';
+ break;
+ case "\x1b-C": // iso-8859-3. at least for the high code characters.
+ $c = 'ISO-8859-3';
+ break;
+ case "\x1b-D": // iso-8859-4. at least for the high code characters.
+ $c = 'ISO-8859-4';
+ break;
+ case "\x1b-E": // iso-8859-5. at least for the high code characters.
+ $c = 'ISO-8859-5';
+ break;
+ case "\x1b-F": // iso-8859-6. at least for the high code characters.
+ $c = 'ISO-8859-6';
+ break;
+ case "\x1b-G": // iso-8859-7. at least for the high code characters.
+ $c = 'ISO-8859-7';
+ break;
+ case "\x1b-H": // iso-8859-8. at least for the high code characters.
+ $c = 'ISO-8859-8';
+ break;
+ case "\x1b-I": // CSN_369103. at least for the high code characters.
+ $c = 'CSN_369103';
+ break;
+ default:
+ wfDebugLog( 'iptc', __METHOD__ . 'Unknown charset in iptc 1:90: ' . bin2hex( $tag ) );
+ // at this point just give up and refuse to parse iptc?
+ $c = false;
+ }
+ return $c;
+ }
+}
diff --git a/www/wiki/includes/media/ImageHandler.php b/www/wiki/includes/media/ImageHandler.php
new file mode 100644
index 00000000..1eefddbd
--- /dev/null
+++ b/www/wiki/includes/media/ImageHandler.php
@@ -0,0 +1,288 @@
+<?php
+/**
+ * Media-handling base classes and generic functionality.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Media handler abstract base class for images
+ *
+ * @ingroup Media
+ */
+abstract class ImageHandler extends MediaHandler {
+ /**
+ * @param File $file
+ * @return bool
+ */
+ public function canRender( $file ) {
+ return ( $file->getWidth() && $file->getHeight() );
+ }
+
+ public function getParamMap() {
+ return [ 'img_width' => 'width' ];
+ }
+
+ public function validateParam( $name, $value ) {
+ if ( in_array( $name, [ 'width', 'height' ] ) ) {
+ if ( $value <= 0 ) {
+ return false;
+ } else {
+ return true;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ public function makeParamString( $params ) {
+ if ( isset( $params['physicalWidth'] ) ) {
+ $width = $params['physicalWidth'];
+ } elseif ( isset( $params['width'] ) ) {
+ $width = $params['width'];
+ } else {
+ throw new MediaTransformInvalidParametersException( 'No width specified to ' . __METHOD__ );
+ }
+
+ # Removed for ProofreadPage
+ # $width = intval( $width );
+ return "{$width}px";
+ }
+
+ public function parseParamString( $str ) {
+ $m = false;
+ if ( preg_match( '/^(\d+)px$/', $str, $m ) ) {
+ return [ 'width' => $m[1] ];
+ } else {
+ return false;
+ }
+ }
+
+ function getScriptParams( $params ) {
+ return [ 'width' => $params['width'] ];
+ }
+
+ /**
+ * @param File $image
+ * @param array &$params
+ * @return bool
+ */
+ function normaliseParams( $image, &$params ) {
+ $mimeType = $image->getMimeType();
+
+ if ( !isset( $params['width'] ) ) {
+ return false;
+ }
+
+ if ( !isset( $params['page'] ) ) {
+ $params['page'] = 1;
+ } else {
+ $params['page'] = intval( $params['page'] );
+ if ( $params['page'] > $image->pageCount() ) {
+ $params['page'] = $image->pageCount();
+ }
+
+ if ( $params['page'] < 1 ) {
+ $params['page'] = 1;
+ }
+ }
+
+ $srcWidth = $image->getWidth( $params['page'] );
+ $srcHeight = $image->getHeight( $params['page'] );
+
+ if ( isset( $params['height'] ) && $params['height'] != -1 ) {
+ # Height & width were both set
+ if ( $params['width'] * $srcHeight > $params['height'] * $srcWidth ) {
+ # Height is the relative smaller dimension, so scale width accordingly
+ $params['width'] = self::fitBoxWidth( $srcWidth, $srcHeight, $params['height'] );
+
+ if ( $params['width'] == 0 ) {
+ # Very small image, so we need to rely on client side scaling :(
+ $params['width'] = 1;
+ }
+
+ $params['physicalWidth'] = $params['width'];
+ } else {
+ # Height was crap, unset it so that it will be calculated later
+ unset( $params['height'] );
+ }
+ }
+
+ if ( !isset( $params['physicalWidth'] ) ) {
+ # Passed all validations, so set the physicalWidth
+ $params['physicalWidth'] = $params['width'];
+ }
+
+ # Because thumbs are only referred to by width, the height always needs
+ # to be scaled by the width to keep the thumbnail sizes consistent,
+ # even if it was set inside the if block above
+ $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight,
+ $params['physicalWidth'] );
+
+ # Set the height if it was not validated in the if block higher up
+ if ( !isset( $params['height'] ) || $params['height'] == -1 ) {
+ $params['height'] = $params['physicalHeight'];
+ }
+
+ if ( !$this->validateThumbParams( $params['physicalWidth'],
+ $params['physicalHeight'], $srcWidth, $srcHeight, $mimeType )
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate thumbnail parameters and fill in the correct height
+ *
+ * @param int &$width Specified width (input/output)
+ * @param int &$height Height (output only)
+ * @param int $srcWidth Width of the source image
+ * @param int $srcHeight Height of the source image
+ * @param string $mimeType Unused
+ * @return bool False to indicate that an error should be returned to the user.
+ */
+ function validateThumbParams( &$width, &$height, $srcWidth, $srcHeight, $mimeType ) {
+ $width = intval( $width );
+
+ # Sanity check $width
+ if ( $width <= 0 ) {
+ wfDebug( __METHOD__ . ": Invalid destination width: $width\n" );
+
+ return false;
+ }
+ if ( $srcWidth <= 0 ) {
+ wfDebug( __METHOD__ . ": Invalid source width: $srcWidth\n" );
+
+ return false;
+ }
+
+ $height = File::scaleHeight( $srcWidth, $srcHeight, $width );
+ if ( $height == 0 ) {
+ # Force height to be at least 1 pixel
+ $height = 1;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param File $image
+ * @param string $script
+ * @param array $params
+ * @return bool|MediaTransformOutput
+ */
+ function getScriptedTransform( $image, $script, $params ) {
+ if ( !$this->normaliseParams( $image, $params ) ) {
+ return false;
+ }
+ $url = wfAppendQuery( $script, $this->getScriptParams( $params ) );
+
+ if ( $image->mustRender() || $params['width'] < $image->getWidth() ) {
+ return new ThumbnailImage( $image, $url, false, $params );
+ }
+ }
+
+ function getImageSize( $image, $path ) {
+ MediaWiki\suppressWarnings();
+ $gis = getimagesize( $path );
+ MediaWiki\restoreWarnings();
+
+ return $gis;
+ }
+
+ /**
+ * Function that returns the number of pixels to be thumbnailed.
+ * Intended for animated GIFs to multiply by the number of frames.
+ *
+ * If the file doesn't support a notion of "area" return 0.
+ *
+ * @param File $image
+ * @return int
+ */
+ function getImageArea( $image ) {
+ return $image->getWidth() * $image->getHeight();
+ }
+
+ /**
+ * @param File $file
+ * @return string
+ */
+ function getShortDesc( $file ) {
+ global $wgLang;
+ $nbytes = htmlspecialchars( $wgLang->formatSize( $file->getSize() ) );
+ $widthheight = wfMessage( 'widthheight' )
+ ->numParams( $file->getWidth(), $file->getHeight() )->escaped();
+
+ return "$widthheight ($nbytes)";
+ }
+
+ /**
+ * @param File $file
+ * @return string
+ */
+ function getLongDesc( $file ) {
+ global $wgLang;
+ $pages = $file->pageCount();
+ $size = htmlspecialchars( $wgLang->formatSize( $file->getSize() ) );
+ if ( $pages === false || $pages <= 1 ) {
+ $msg = wfMessage( 'file-info-size' )->numParams( $file->getWidth(),
+ $file->getHeight() )->params( $size,
+ '<span class="mime-type">' . $file->getMimeType() . '</span>' )->parse();
+ } else {
+ $msg = wfMessage( 'file-info-size-pages' )->numParams( $file->getWidth(),
+ $file->getHeight() )->params( $size,
+ '<span class="mime-type">' . $file->getMimeType() . '</span>' )->numParams( $pages )->parse();
+ }
+
+ return $msg;
+ }
+
+ /**
+ * @param File $file
+ * @return string
+ */
+ function getDimensionsString( $file ) {
+ $pages = $file->pageCount();
+ if ( $pages > 1 ) {
+ return wfMessage( 'widthheightpage' )
+ ->numParams( $file->getWidth(), $file->getHeight(), $pages )->text();
+ } else {
+ return wfMessage( 'widthheight' )
+ ->numParams( $file->getWidth(), $file->getHeight() )->text();
+ }
+ }
+
+ public function sanitizeParamsForBucketing( $params ) {
+ $params = parent::sanitizeParamsForBucketing( $params );
+
+ // We unset the height parameters in order to let normaliseParams recalculate them
+ // Otherwise there might be a height discrepancy
+ if ( isset( $params['height'] ) ) {
+ unset( $params['height'] );
+ }
+
+ if ( isset( $params['physicalHeight'] ) ) {
+ unset( $params['physicalHeight'] );
+ }
+
+ return $params;
+ }
+}
diff --git a/www/wiki/includes/media/Jpeg.php b/www/wiki/includes/media/Jpeg.php
new file mode 100644
index 00000000..287c198c
--- /dev/null
+++ b/www/wiki/includes/media/Jpeg.php
@@ -0,0 +1,290 @@
+<?php
+/**
+ * Handler for JPEG images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * JPEG specific handler.
+ * Inherits most stuff from BitmapHandler, just here to do the metadata handler differently.
+ *
+ * Metadata stuff common to Jpeg and built-in Tiff (not PagedTiffHandler) is
+ * in ExifBitmapHandler.
+ *
+ * @ingroup Media
+ */
+class JpegHandler extends ExifBitmapHandler {
+ const SRGB_EXIF_COLOR_SPACE = 'sRGB';
+ const SRGB_ICC_PROFILE_DESCRIPTION = 'sRGB IEC61966-2.1';
+
+ function normaliseParams( $image, &$params ) {
+ if ( !parent::normaliseParams( $image, $params ) ) {
+ return false;
+ }
+ if ( isset( $params['quality'] ) && !self::validateQuality( $params['quality'] ) ) {
+ return false;
+ }
+ return true;
+ }
+
+ public function validateParam( $name, $value ) {
+ if ( $name === 'quality' ) {
+ return self::validateQuality( $value );
+ } else {
+ return parent::validateParam( $name, $value );
+ }
+ }
+
+ /** Validate and normalize quality value to be between 1 and 100 (inclusive).
+ * @param int $value Quality value, will be converted to integer or 0 if invalid
+ * @return bool True if the value is valid
+ */
+ private static function validateQuality( $value ) {
+ return $value === 'low';
+ }
+
+ public function makeParamString( $params ) {
+ // Prepend quality as "qValue-". This has to match parseParamString() below
+ $res = parent::makeParamString( $params );
+ if ( $res && isset( $params['quality'] ) ) {
+ $res = "q{$params['quality']}-$res";
+ }
+ return $res;
+ }
+
+ public function parseParamString( $str ) {
+ // $str contains "qlow-200px" or "200px" strings because thumb.php would strip the filename
+ // first - check if the string begins with "qlow-", and if so, treat it as quality.
+ // Pass the first portion, or the whole string if "qlow-" not found, to the parent
+ // The parsing must match the makeParamString() above
+ $res = false;
+ $m = false;
+ if ( preg_match( '/q([^-]+)-(.*)$/', $str, $m ) ) {
+ $v = $m[1];
+ if ( self::validateQuality( $v ) ) {
+ $res = parent::parseParamString( $m[2] );
+ if ( $res ) {
+ $res['quality'] = $v;
+ }
+ }
+ } else {
+ $res = parent::parseParamString( $str );
+ }
+ return $res;
+ }
+
+ function getScriptParams( $params ) {
+ $res = parent::getScriptParams( $params );
+ if ( isset( $params['quality'] ) ) {
+ $res['quality'] = $params['quality'];
+ }
+ return $res;
+ }
+
+ function getMetadata( $image, $filename ) {
+ try {
+ $meta = BitmapMetadataHandler::Jpeg( $filename );
+ if ( !is_array( $meta ) ) {
+ // This should never happen, but doesn't hurt to be paranoid.
+ throw new MWException( 'Metadata array is not an array' );
+ }
+ $meta['MEDIAWIKI_EXIF_VERSION'] = Exif::version();
+
+ return serialize( $meta );
+ } catch ( Exception $e ) {
+ // BitmapMetadataHandler throws an exception in certain exceptional
+ // cases like if file does not exist.
+ wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" );
+
+ /* This used to use 0 (ExifBitmapHandler::OLD_BROKEN_FILE) for the cases
+ * * No metadata in the file
+ * * Something is broken in the file.
+ * However, if the metadata support gets expanded then you can't tell if the 0 is from
+ * a broken file, or just no props found. A broken file is likely to stay broken, but
+ * a file which had no props could have props once the metadata support is improved.
+ * Thus switch to using -1 to denote only a broken file, and use an array with only
+ * MEDIAWIKI_EXIF_VERSION to denote no props.
+ */
+
+ return ExifBitmapHandler::BROKEN_FILE;
+ }
+ }
+
+ /**
+ * @param File $file
+ * @param array $params Rotate parameters.
+ * 'rotation' clockwise rotation in degrees, allowed are multiples of 90
+ * @since 1.21
+ * @return bool|MediaTransformError
+ */
+ public function rotate( $file, $params ) {
+ global $wgJpegTran;
+
+ $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360;
+
+ if ( $wgJpegTran && is_executable( $wgJpegTran ) ) {
+ $cmd = wfEscapeShellArg( $wgJpegTran ) .
+ " -rotate " . wfEscapeShellArg( $rotation ) .
+ " -outfile " . wfEscapeShellArg( $params['dstPath'] ) .
+ " " . wfEscapeShellArg( $params['srcPath'] );
+ wfDebug( __METHOD__ . ": running jpgtran: $cmd\n" );
+ $retval = 0;
+ $err = wfShellExecWithStderr( $cmd, $retval );
+ if ( $retval !== 0 ) {
+ $this->logErrorForExternalProcess( $retval, $err, $cmd );
+
+ return new MediaTransformError( 'thumbnail_error', 0, 0, $err );
+ }
+
+ return false;
+ } else {
+ return parent::rotate( $file, $params );
+ }
+ }
+
+ public function supportsBucketing() {
+ return true;
+ }
+
+ public function sanitizeParamsForBucketing( $params ) {
+ $params = parent::sanitizeParamsForBucketing( $params );
+
+ // Quality needs to be cleared for bucketing. Buckets need to be default quality
+ if ( isset( $params['quality'] ) ) {
+ unset( $params['quality'] );
+ }
+
+ return $params;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function transformImageMagick( $image, $params ) {
+ global $wgUseTinyRGBForJPGThumbnails;
+
+ $ret = parent::transformImageMagick( $image, $params );
+
+ if ( $ret ) {
+ return $ret;
+ }
+
+ if ( $wgUseTinyRGBForJPGThumbnails ) {
+ // T100976 If the profile embedded in the JPG is sRGB, swap it for the smaller
+ // (and free) TinyRGB
+
+ /**
+ * We'll want to replace the color profile for JPGs:
+ * * in the sRGB color space, or with the sRGB profile
+ * (other profiles will be left untouched)
+ * * without color space or profile, in which case browsers
+ * should assume sRGB, but don't always do (e.g. on wide-gamut
+ * monitors (unless it's meant for low bandwith)
+ * @see https://phabricator.wikimedia.org/T134498
+ */
+ $colorSpaces = [ self::SRGB_EXIF_COLOR_SPACE, '-' ];
+ $profiles = [ self::SRGB_ICC_PROFILE_DESCRIPTION ];
+
+ // we'll also add TinyRGB profile to images lacking a profile, but
+ // only if they're not low quality (which are meant to save bandwith
+ // and we don't want to increase the filesize by adding a profile)
+ if ( isset( $params['quality'] ) && $params['quality'] > 30 ) {
+ $profiles[] = '-';
+ }
+
+ $this->swapICCProfile(
+ $params['dstPath'],
+ $colorSpaces,
+ $profiles,
+ realpath( __DIR__ ) . '/tinyrgb.icc'
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Swaps an embedded ICC profile for another, if found.
+ * Depends on exiftool, no-op if not installed.
+ * @param string $filepath File to be manipulated (will be overwritten)
+ * @param array $colorSpaces Only process files with this/these Color Space(s)
+ * @param array $oldProfileStrings Exact name(s) of color profile to look for
+ * (the one that will be replaced)
+ * @param string $profileFilepath ICC profile file to apply to the file
+ * @since 1.26
+ * @return bool
+ */
+ public function swapICCProfile( $filepath, array $colorSpaces,
+ array $oldProfileStrings, $profileFilepath
+ ) {
+ global $wgExiftool;
+
+ if ( !$wgExiftool || !is_executable( $wgExiftool ) ) {
+ return false;
+ }
+
+ $cmd = wfEscapeShellArg( $wgExiftool,
+ '-EXIF:ColorSpace',
+ '-ICC_Profile:ProfileDescription',
+ '-S',
+ '-T',
+ $filepath
+ );
+
+ $output = wfShellExecWithStderr( $cmd, $retval );
+
+ // Explode EXIF data into an array with [0 => Color Space, 1 => Device Model Desc]
+ $data = explode( "\t", trim( $output ) );
+
+ if ( $retval !== 0 ) {
+ return false;
+ }
+
+ // Make a regex out of the source data to match it to an array of color
+ // spaces in a case-insensitive way
+ $colorSpaceRegex = '/'.preg_quote( $data[0], '/' ).'/i';
+ if ( empty( preg_grep( $colorSpaceRegex, $colorSpaces ) ) ) {
+ // We can't establish that this file matches the color space, don't process it
+ return false;
+ }
+
+ $profileRegex = '/'.preg_quote( $data[1], '/' ).'/i';
+ if ( empty( preg_grep( $profileRegex, $oldProfileStrings ) ) ) {
+ // We can't establish that this file has the expected ICC profile, don't process it
+ return false;
+ }
+
+ $cmd = wfEscapeShellArg( $wgExiftool,
+ '-overwrite_original',
+ '-icc_profile<=' . $profileFilepath,
+ $filepath
+ );
+
+ $output = wfShellExecWithStderr( $cmd, $retval );
+
+ if ( $retval !== 0 ) {
+ $this->logErrorForExternalProcess( $retval, $output, $cmd );
+
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/media/JpegMetadataExtractor.php b/www/wiki/includes/media/JpegMetadataExtractor.php
new file mode 100644
index 00000000..211845cc
--- /dev/null
+++ b/www/wiki/includes/media/JpegMetadataExtractor.php
@@ -0,0 +1,293 @@
+<?php
+/**
+ * Extraction of JPEG image metadata.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Class for reading jpegs and extracting metadata.
+ * see also BitmapMetadataHandler.
+ *
+ * Based somewhat on GIFMetadataExtractor.
+ *
+ * @ingroup Media
+ */
+class JpegMetadataExtractor {
+ const MAX_JPEG_SEGMENTS = 200;
+
+ // the max segment is a sanity check.
+ // A jpeg file should never even remotely have
+ // that many segments. Your average file has about 10.
+
+ /** Function to extract metadata segments of interest from jpeg files
+ * based on GIFMetadataExtractor.
+ *
+ * we can almost use getimagesize to do this
+ * but gis doesn't support having multiple app1 segments
+ * and those can't extract xmp on files containing both exif and xmp data
+ *
+ * @param string $filename Name of jpeg file
+ * @return array Array of interesting segments.
+ * @throws MWException If given invalid file.
+ */
+ static function segmentSplitter( $filename ) {
+ $showXMP = XMPReader::isSupported();
+
+ $segmentCount = 0;
+
+ $segments = [
+ 'XMP_ext' => [],
+ 'COM' => [],
+ 'PSIR' => [],
+ ];
+
+ if ( !$filename ) {
+ throw new MWException( "No filename specified for " . __METHOD__ );
+ }
+ if ( !file_exists( $filename ) || is_dir( $filename ) ) {
+ throw new MWException( "Invalid file $filename passed to " . __METHOD__ );
+ }
+
+ $fh = fopen( $filename, "rb" );
+
+ if ( !$fh ) {
+ throw new MWException( "Could not open file $filename" );
+ }
+
+ $buffer = fread( $fh, 2 );
+ if ( $buffer !== "\xFF\xD8" ) {
+ throw new MWException( "Not a jpeg, no SOI" );
+ }
+ while ( !feof( $fh ) ) {
+ $buffer = fread( $fh, 1 );
+ $segmentCount++;
+ if ( $segmentCount > self::MAX_JPEG_SEGMENTS ) {
+ // this is just a sanity check
+ throw new MWException( 'Too many jpeg segments. Aborting' );
+ }
+ while ( $buffer !== "\xFF" ) {
+ // In theory JPEG files are not allowed to contain anything between the sections,
+ // but in practice they sometimes do. It's customary to ignore the garbage data.
+ $buffer = fread( $fh, 1 );
+ }
+
+ $buffer = fread( $fh, 1 );
+ while ( $buffer === "\xFF" && !feof( $fh ) ) {
+ // Skip through any 0xFF padding bytes.
+ $buffer = fread( $fh, 1 );
+ }
+ if ( $buffer === "\xFE" ) {
+ // COM section -- file comment
+ // First see if valid utf-8,
+ // if not try to convert it to windows-1252.
+ $com = $oldCom = trim( self::jpegExtractMarker( $fh ) );
+ UtfNormal\Validator::quickIsNFCVerify( $com );
+ // turns $com to valid utf-8.
+ // thus if no change, its utf-8, otherwise its something else.
+ if ( $com !== $oldCom ) {
+ MediaWiki\suppressWarnings();
+ $com = $oldCom = iconv( 'windows-1252', 'UTF-8//IGNORE', $oldCom );
+ MediaWiki\restoreWarnings();
+ }
+ // Try it again, if its still not a valid string, then probably
+ // binary junk or some really weird encoding, so don't extract.
+ UtfNormal\Validator::quickIsNFCVerify( $com );
+ if ( $com === $oldCom ) {
+ $segments["COM"][] = $oldCom;
+ } else {
+ wfDebug( __METHOD__ . " Ignoring JPEG comment as is garbage.\n" );
+ }
+ } elseif ( $buffer === "\xE1" ) {
+ // APP1 section (Exif, XMP, and XMP extended)
+ // only extract if XMP is enabled.
+ $temp = self::jpegExtractMarker( $fh );
+ // check what type of app segment this is.
+ if ( substr( $temp, 0, 29 ) === "http://ns.adobe.com/xap/1.0/\x00" && $showXMP ) {
+ $segments["XMP"] = substr( $temp, 29 );
+ } elseif ( substr( $temp, 0, 35 ) === "http://ns.adobe.com/xmp/extension/\x00" && $showXMP ) {
+ $segments["XMP_ext"][] = substr( $temp, 35 );
+ } elseif ( substr( $temp, 0, 29 ) === "XMP\x00://ns.adobe.com/xap/1.0/\x00" && $showXMP ) {
+ // Some images (especially flickr images) seem to have this.
+ // I really have no idea what the deal is with them, but
+ // whatever...
+ $segments["XMP"] = substr( $temp, 29 );
+ wfDebug( __METHOD__ . ' Found XMP section with wrong app identifier '
+ . "Using anyways.\n" );
+ } elseif ( substr( $temp, 0, 6 ) === "Exif\0\0" ) {
+ // Just need to find out what the byte order is.
+ // because php's exif plugin sucks...
+ // This is a II for little Endian, MM for big. Not a unicode BOM.
+ $byteOrderMarker = substr( $temp, 6, 2 );
+ if ( $byteOrderMarker === 'MM' ) {
+ $segments['byteOrder'] = 'BE';
+ } elseif ( $byteOrderMarker === 'II' ) {
+ $segments['byteOrder'] = 'LE';
+ } else {
+ wfDebug( __METHOD__ . " Invalid byte ordering?!\n" );
+ }
+ }
+ } elseif ( $buffer === "\xED" ) {
+ // APP13 - PSIR. IPTC and some photoshop stuff
+ $temp = self::jpegExtractMarker( $fh );
+ if ( substr( $temp, 0, 14 ) === "Photoshop 3.0\x00" ) {
+ $segments["PSIR"][] = $temp;
+ }
+ } elseif ( $buffer === "\xD9" || $buffer === "\xDA" ) {
+ // EOI - end of image or SOS - start of scan. either way we're past any interesting segments
+ return $segments;
+ } else {
+ // segment we don't care about, so skip
+ $size = wfUnpack( "nint", fread( $fh, 2 ), 2 );
+ if ( $size['int'] < 2 ) {
+ throw new MWException( "invalid marker size in jpeg" );
+ }
+ fseek( $fh, $size['int'] - 2, SEEK_CUR );
+ }
+ }
+ // shouldn't get here.
+ throw new MWException( "Reached end of jpeg file unexpectedly" );
+ }
+
+ /**
+ * Helper function for jpegSegmentSplitter
+ * @param resource &$fh File handle for JPEG file
+ * @throws MWException
+ * @return string Data content of segment.
+ */
+ private static function jpegExtractMarker( &$fh ) {
+ $size = wfUnpack( "nint", fread( $fh, 2 ), 2 );
+ if ( $size['int'] < 2 ) {
+ throw new MWException( "invalid marker size in jpeg" );
+ }
+ if ( $size['int'] === 2 ) {
+ // fread( ..., 0 ) generates a warning
+ return '';
+ }
+ $segment = fread( $fh, $size['int'] - 2 );
+ if ( strlen( $segment ) !== $size['int'] - 2 ) {
+ throw new MWException( "Segment shorter than expected" );
+ }
+
+ return $segment;
+ }
+
+ /**
+ * This reads the photoshop image resource.
+ * Currently it only compares the iptc/iim hash
+ * with the stored hash, which is used to determine the precedence
+ * of the iptc data. In future it may extract some other info, like
+ * url of copyright license.
+ *
+ * This should generally be called by BitmapMetadataHandler::doApp13()
+ *
+ * @param string $app13 Photoshop psir app13 block from jpg.
+ * @throws MWException (It gets caught next level up though)
+ * @return string If the iptc hash is good or not. One of 'iptc-no-hash',
+ * 'iptc-good-hash', 'iptc-bad-hash'.
+ */
+ public static function doPSIR( $app13 ) {
+ if ( !$app13 ) {
+ throw new MWException( "No App13 segment given" );
+ }
+ // First compare hash with real thing
+ // 0x404 contains IPTC, 0x425 has hash
+ // This is used to determine if the iptc is newer than
+ // the xmp data, as xmp programs update the hash,
+ // where non-xmp programs don't.
+
+ $offset = 14; // skip past PHOTOSHOP 3.0 identifier. should already be checked.
+ $appLen = strlen( $app13 );
+ $realHash = "";
+ $recordedHash = "";
+
+ // the +12 is the length of an empty item.
+ while ( $offset + 12 <= $appLen ) {
+ $valid = true;
+ if ( substr( $app13, $offset, 4 ) !== '8BIM' ) {
+ // its supposed to be 8BIM
+ // but apparently sometimes isn't esp. in
+ // really old jpg's
+ $valid = false;
+ }
+ $offset += 4;
+ $id = substr( $app13, $offset, 2 );
+ // id is a 2 byte id number which identifies
+ // the piece of info this record contains.
+
+ $offset += 2;
+
+ // some record types can contain a name, which
+ // is a pascal string 0-padded to be an even
+ // number of bytes. Most times (and any time
+ // we care) this is empty, making it two null bytes.
+
+ $lenName = ord( substr( $app13, $offset, 1 ) ) + 1;
+ // we never use the name so skip it. +1 for length byte
+ if ( $lenName % 2 == 1 ) {
+ $lenName++;
+ } // pad to even.
+ $offset += $lenName;
+
+ // now length of data (unsigned long big endian)
+ $lenData = wfUnpack( 'Nlen', substr( $app13, $offset, 4 ), 4 );
+ // PHP can take issue with very large unsigned ints and make them negative.
+ // Which should never ever happen, as this has to be inside a segment
+ // which is limited to a 16 bit number.
+ if ( $lenData['len'] < 0 ) {
+ throw new MWException( "Too big PSIR (" . $lenData['len'] . ')' );
+ }
+
+ $offset += 4; // 4bytes length field;
+
+ // this should not happen, but check.
+ if ( $lenData['len'] + $offset > $appLen ) {
+ throw new MWException( "PSIR data too long. (item length=" . $lenData['len']
+ . "; offset=$offset; total length=$appLen)" );
+ }
+
+ if ( $valid ) {
+ switch ( $id ) {
+ case "\x04\x04":
+ // IPTC block
+ $realHash = md5( substr( $app13, $offset, $lenData['len'] ), true );
+ break;
+ case "\x04\x25":
+ $recordedHash = substr( $app13, $offset, $lenData['len'] );
+ break;
+ }
+ }
+
+ // if odd, add 1 to length to account for
+ // null pad byte.
+ if ( $lenData['len'] % 2 == 1 ) {
+ $lenData['len']++;
+ }
+ $offset += $lenData['len'];
+ }
+
+ if ( !$realHash || !$recordedHash ) {
+ return 'iptc-no-hash';
+ } elseif ( $realHash === $recordedHash ) {
+ return 'iptc-good-hash';
+ } else { /*$realHash !== $recordedHash */
+ return 'iptc-bad-hash';
+ }
+ }
+}
diff --git a/www/wiki/includes/media/MediaHandler.php b/www/wiki/includes/media/MediaHandler.php
new file mode 100644
index 00000000..aa7c62be
--- /dev/null
+++ b/www/wiki/includes/media/MediaHandler.php
@@ -0,0 +1,926 @@
+<?php
+/**
+ * Media-handling base classes and generic functionality.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Base media handler class
+ *
+ * @ingroup Media
+ */
+abstract class MediaHandler {
+ const TRANSFORM_LATER = 1;
+ const METADATA_GOOD = true;
+ const METADATA_BAD = false;
+ const METADATA_COMPATIBLE = 2; // for old but backwards compatible.
+ /**
+ * Max length of error logged by logErrorForExternalProcess()
+ */
+ const MAX_ERR_LOG_SIZE = 65535;
+
+ /**
+ * Get a MediaHandler for a given MIME type from the instance cache
+ *
+ * @param string $type
+ * @return MediaHandler|bool
+ */
+ static function getHandler( $type ) {
+ return MediaWikiServices::getInstance()
+ ->getMediaHandlerFactory()->getHandler( $type );
+ }
+
+ /**
+ * Get an associative array mapping magic word IDs to parameter names.
+ * Will be used by the parser to identify parameters.
+ */
+ abstract public function getParamMap();
+
+ /**
+ * Validate a thumbnail parameter at parse time.
+ * Return true to accept the parameter, and false to reject it.
+ * If you return false, the parser will do something quiet and forgiving.
+ *
+ * @param string $name
+ * @param mixed $value
+ */
+ abstract public function validateParam( $name, $value );
+
+ /**
+ * Merge a parameter array into a string appropriate for inclusion in filenames
+ *
+ * @param array $params Array of parameters that have been through normaliseParams.
+ * @return string
+ */
+ abstract public function makeParamString( $params );
+
+ /**
+ * Parse a param string made with makeParamString back into an array
+ *
+ * @param string $str The parameter string without file name (e.g. 122px)
+ * @return array|bool Array of parameters or false on failure.
+ */
+ abstract public function parseParamString( $str );
+
+ /**
+ * Changes the parameter array as necessary, ready for transformation.
+ * Should be idempotent.
+ * Returns false if the parameters are unacceptable and the transform should fail
+ * @param File $image
+ * @param array &$params
+ */
+ abstract function normaliseParams( $image, &$params );
+
+ /**
+ * Get an image size array like that returned by getimagesize(), or false if it
+ * can't be determined.
+ *
+ * This function is used for determining the width, height and bitdepth directly
+ * from an image. The results are stored in the database in the img_width,
+ * img_height, img_bits fields.
+ *
+ * @note If this is a multipage file, return the width and height of the
+ * first page.
+ *
+ * @param File|FSFile $image The image object, or false if there isn't one.
+ * Warning, FSFile::getPropsFromPath might pass an FSFile instead of File (!)
+ * @param string $path The filename
+ * @return array|bool Follow the format of PHP getimagesize() internal function.
+ * See https://secure.php.net/getimagesize. MediaWiki will only ever use the
+ * first two array keys (the width and height), and the 'bits' associative
+ * key. All other array keys are ignored. Returning a 'bits' key is optional
+ * as not all formats have a notion of "bitdepth". Returns false on failure.
+ */
+ abstract function getImageSize( $image, $path );
+
+ /**
+ * Get handler-specific metadata which will be saved in the img_metadata field.
+ *
+ * @param File|FSFile $image The image object, or false if there isn't one.
+ * Warning, FSFile::getPropsFromPath might pass an FSFile instead of File (!)
+ * @param string $path The filename
+ * @return string A string of metadata in php serialized form (Run through serialize())
+ */
+ function getMetadata( $image, $path ) {
+ return '';
+ }
+
+ /**
+ * Get metadata version.
+ *
+ * This is not used for validating metadata, this is used for the api when returning
+ * metadata, since api content formats should stay the same over time, and so things
+ * using ForeignApiRepo can keep backwards compatibility
+ *
+ * All core media handlers share a common version number, and extensions can
+ * use the GetMetadataVersion hook to append to the array (they should append a unique
+ * string so not to get confusing). If there was a media handler named 'foo' with metadata
+ * version 3 it might add to the end of the array the element 'foo=3'. if the core metadata
+ * version is 2, the end version string would look like '2;foo=3'.
+ *
+ * @return string Version string
+ */
+ static function getMetadataVersion() {
+ $version = [ '2' ]; // core metadata version
+ Hooks::run( 'GetMetadataVersion', [ &$version ] );
+
+ return implode( ';', $version );
+ }
+
+ /**
+ * Convert metadata version.
+ *
+ * By default just returns $metadata, but can be used to allow
+ * media handlers to convert between metadata versions.
+ *
+ * @param string|array $metadata Metadata array (serialized if string)
+ * @param int $version Target version
+ * @return array Serialized metadata in specified version, or $metadata on fail.
+ */
+ function convertMetadataVersion( $metadata, $version = 1 ) {
+ if ( !is_array( $metadata ) ) {
+ // unserialize to keep return parameter consistent.
+ MediaWiki\suppressWarnings();
+ $ret = unserialize( $metadata );
+ MediaWiki\restoreWarnings();
+
+ return $ret;
+ }
+
+ return $metadata;
+ }
+
+ /**
+ * Get a string describing the type of metadata, for display purposes.
+ *
+ * @note This method is currently unused.
+ * @param File $image
+ * @return string
+ */
+ function getMetadataType( $image ) {
+ return false;
+ }
+
+ /**
+ * Check if the metadata string is valid for this handler.
+ * If it returns MediaHandler::METADATA_BAD (or false), Image
+ * will reload the metadata from the file and update the database.
+ * MediaHandler::METADATA_GOOD for if the metadata is a-ok,
+ * MediaHandler::METADATA_COMPATIBLE if metadata is old but backwards
+ * compatible (which may or may not trigger a metadata reload).
+ *
+ * @note Returning self::METADATA_BAD will trigger a metadata reload from
+ * file on page view. Always returning this from a broken file, or suddenly
+ * triggering as bad metadata for a large number of files can cause
+ * performance problems.
+ * @param File $image
+ * @param string $metadata The metadata in serialized form
+ * @return bool
+ */
+ function isMetadataValid( $image, $metadata ) {
+ return self::METADATA_GOOD;
+ }
+
+ /**
+ * Get an array of standard (FormatMetadata type) metadata values.
+ *
+ * The returned data is largely the same as that from getMetadata(),
+ * but formatted in a standard, stable, handler-independent way.
+ * The idea being that some values like ImageDescription or Artist
+ * are universal and should be retrievable in a handler generic way.
+ *
+ * The specific properties are the type of properties that can be
+ * handled by the FormatMetadata class. These values are exposed to the
+ * user via the filemetadata parser function.
+ *
+ * Details of the response format of this function can be found at
+ * https://www.mediawiki.org/wiki/Manual:File_metadata_handling
+ * tl/dr: the response is an associative array of
+ * properties keyed by name, but the value can be complex. You probably
+ * want to call one of the FormatMetadata::flatten* functions on the
+ * property values before using them, or call
+ * FormatMetadata::getFormattedData() on the full response array, which
+ * transforms all values into prettified, human-readable text.
+ *
+ * Subclasses overriding this function must return a value which is a
+ * valid API response fragment (all associative array keys are valid
+ * XML tagnames).
+ *
+ * Note, if the file simply has no metadata, but the handler supports
+ * this interface, it should return an empty array, not false.
+ *
+ * @param File $file
+ * @return array|bool False if interface not supported
+ * @since 1.23
+ */
+ public function getCommonMetaArray( File $file ) {
+ return false;
+ }
+
+ /**
+ * Get a MediaTransformOutput object representing an alternate of the transformed
+ * output which will call an intermediary thumbnail assist script.
+ *
+ * Used when the repository has a thumbnailScriptUrl option configured.
+ *
+ * Return false to fall back to the regular getTransform().
+ * @param File $image
+ * @param string $script
+ * @param array $params
+ * @return bool|ThumbnailImage
+ */
+ function getScriptedTransform( $image, $script, $params ) {
+ return false;
+ }
+
+ /**
+ * Get a MediaTransformOutput object representing the transformed output. Does not
+ * actually do the transform.
+ *
+ * @param File $image The image object
+ * @param string $dstPath Filesystem destination path
+ * @param string $dstUrl Destination URL to use in output HTML
+ * @param array $params Arbitrary set of parameters validated by $this->validateParam()
+ * @return MediaTransformOutput
+ */
+ final function getTransform( $image, $dstPath, $dstUrl, $params ) {
+ return $this->doTransform( $image, $dstPath, $dstUrl, $params, self::TRANSFORM_LATER );
+ }
+
+ /**
+ * Get a MediaTransformOutput object representing the transformed output. Does the
+ * transform unless $flags contains self::TRANSFORM_LATER.
+ *
+ * @param File $image The image object
+ * @param string $dstPath Filesystem destination path
+ * @param string $dstUrl Destination URL to use in output HTML
+ * @param array $params Arbitrary set of parameters validated by $this->validateParam()
+ * Note: These parameters have *not* gone through $this->normaliseParams()
+ * @param int $flags A bitfield, may contain self::TRANSFORM_LATER
+ * @return MediaTransformOutput
+ */
+ abstract function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 );
+
+ /**
+ * Get the thumbnail extension and MIME type for a given source MIME type
+ *
+ * @param string $ext Extension of original file
+ * @param string $mime MIME type of original file
+ * @param array $params Handler specific rendering parameters
+ * @return array Thumbnail extension and MIME type
+ */
+ function getThumbType( $ext, $mime, $params = null ) {
+ $magic = MimeMagic::singleton();
+ if ( !$ext || $magic->isMatchingExtension( $ext, $mime ) === false ) {
+ // The extension is not valid for this MIME type and we do
+ // recognize the MIME type
+ $extensions = $magic->getExtensionsForType( $mime );
+ if ( $extensions ) {
+ return [ strtok( $extensions, ' ' ), $mime ];
+ }
+ }
+
+ // The extension is correct (true) or the MIME type is unknown to
+ // MediaWiki (null)
+ return [ $ext, $mime ];
+ }
+
+ /**
+ * @deprecated since 1.30, use MediaHandler::getContentHeaders instead
+ * @param array $metadata
+ * @return array
+ */
+ public function getStreamHeaders( $metadata ) {
+ wfDeprecated( __METHOD__, '1.30' );
+ return $this->getContentHeaders( $metadata );
+ }
+
+ /**
+ * True if the handled types can be transformed
+ *
+ * @param File $file
+ * @return bool
+ */
+ public function canRender( $file ) {
+ return true;
+ }
+
+ /**
+ * True if handled types cannot be displayed directly in a browser
+ * but can be rendered
+ *
+ * @param File $file
+ * @return bool
+ */
+ public function mustRender( $file ) {
+ return false;
+ }
+
+ /**
+ * True if the type has multi-page capabilities
+ *
+ * @param File $file
+ * @return bool
+ */
+ public function isMultiPage( $file ) {
+ return false;
+ }
+
+ /**
+ * Page count for a multi-page document, false if unsupported or unknown
+ *
+ * @param File $file
+ * @return bool
+ */
+ function pageCount( File $file ) {
+ return false;
+ }
+
+ /**
+ * The material is vectorized and thus scaling is lossless
+ *
+ * @param File $file
+ * @return bool
+ */
+ function isVectorized( $file ) {
+ return false;
+ }
+
+ /**
+ * The material is an image, and is animated.
+ * In particular, video material need not return true.
+ * @note Before 1.20, this was a method of ImageHandler only
+ *
+ * @param File $file
+ * @return bool
+ */
+ function isAnimatedImage( $file ) {
+ return false;
+ }
+
+ /**
+ * If the material is animated, we can animate the thumbnail
+ * @since 1.20
+ *
+ * @param File $file
+ * @return bool If material is not animated, handler may return any value.
+ */
+ function canAnimateThumbnail( $file ) {
+ return true;
+ }
+
+ /**
+ * False if the handler is disabled for all files
+ * @return bool
+ */
+ function isEnabled() {
+ return true;
+ }
+
+ /**
+ * Get an associative array of page dimensions
+ * Currently "width" and "height" are understood, but this might be
+ * expanded in the future.
+ * Returns false if unknown.
+ *
+ * It is expected that handlers for paged media (e.g. DjVuHandler)
+ * will override this method so that it gives the correct results
+ * for each specific page of the file, using the $page argument.
+ *
+ * @note For non-paged media, use getImageSize.
+ *
+ * @param File $image
+ * @param int $page What page to get dimensions of
+ * @return array|bool
+ */
+ function getPageDimensions( File $image, $page ) {
+ $gis = $this->getImageSize( $image, $image->getLocalRefPath() );
+ if ( $gis ) {
+ return [
+ 'width' => $gis[0],
+ 'height' => $gis[1]
+ ];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Generic getter for text layer.
+ * Currently overloaded by PDF and DjVu handlers
+ * @param File $image
+ * @param int $page Page number to get information for
+ * @return bool|string Page text or false when no text found or if
+ * unsupported.
+ */
+ function getPageText( File $image, $page ) {
+ return false;
+ }
+
+ /**
+ * Get the text of the entire document.
+ * @param File $file
+ * @return bool|string The text of the document or false if unsupported.
+ */
+ public function getEntireText( File $file ) {
+ $numPages = $file->pageCount();
+ if ( !$numPages ) {
+ // Not a multipage document
+ return $this->getPageText( $file, 1 );
+ }
+ $document = '';
+ for ( $i = 1; $i <= $numPages; $i++ ) {
+ $curPage = $this->getPageText( $file, $i );
+ if ( is_string( $curPage ) ) {
+ $document .= $curPage . "\n";
+ }
+ }
+ if ( $document !== '' ) {
+ return $document;
+ }
+ return false;
+ }
+
+ /**
+ * Get an array structure that looks like this:
+ *
+ * [
+ * 'visible' => [
+ * 'Human-readable name' => 'Human readable value',
+ * ...
+ * ],
+ * 'collapsed' => [
+ * 'Human-readable name' => 'Human readable value',
+ * ...
+ * ]
+ * ]
+ * The UI will format this into a table where the visible fields are always
+ * visible, and the collapsed fields are optionally visible.
+ *
+ * The function should return false if there is no metadata to display.
+ */
+
+ /**
+ * @todo FIXME: This interface is not very flexible. The media handler
+ * should generate HTML instead. It can do all the formatting according
+ * to some standard. That makes it possible to do things like visual
+ * indication of grouped and chained streams in ogg container files.
+ * @param File $image
+ * @param bool|IContextSource $context Context to use (optional)
+ * @return array|bool
+ */
+ function formatMetadata( $image, $context = false ) {
+ return false;
+ }
+
+ /** sorts the visible/invisible field.
+ * Split off from ImageHandler::formatMetadata, as used by more than
+ * one type of handler.
+ *
+ * This is used by the media handlers that use the FormatMetadata class
+ *
+ * @param array $metadataArray Metadata array
+ * @param bool|IContextSource $context Context to use (optional)
+ * @return array Array for use displaying metadata.
+ */
+ function formatMetadataHelper( $metadataArray, $context = false ) {
+ $result = [
+ 'visible' => [],
+ 'collapsed' => []
+ ];
+
+ $formatted = FormatMetadata::getFormattedData( $metadataArray, $context );
+ // Sort fields into visible and collapsed
+ $visibleFields = $this->visibleMetadataFields();
+ foreach ( $formatted as $name => $value ) {
+ $tag = strtolower( $name );
+ self::addMeta( $result,
+ in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed',
+ 'exif',
+ $tag,
+ $value
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get a list of metadata items which should be displayed when
+ * the metadata table is collapsed.
+ *
+ * @return array Array of strings
+ */
+ protected function visibleMetadataFields() {
+ return FormatMetadata::getVisibleFields();
+ }
+
+ /**
+ * This is used to generate an array element for each metadata value
+ * That array is then used to generate the table of metadata values
+ * on the image page
+ *
+ * @param array &$array An array containing elements for each type of visibility
+ * and each of those elements being an array of metadata items. This function adds
+ * a value to that array.
+ * @param string $visibility ('visible' or 'collapsed') if this value is hidden
+ * by default.
+ * @param string $type Type of metadata tag (currently always 'exif')
+ * @param string $id The name of the metadata tag (like 'artist' for example).
+ * its name in the table displayed is the message "$type-$id" (Ex exif-artist ).
+ * @param string $value Thingy goes into a wikitext table; it used to be escaped but
+ * that was incompatible with previous practise of customized display
+ * with wikitext formatting via messages such as 'exif-model-value'.
+ * So the escaping is taken back out, but generally this seems a confusing
+ * interface.
+ * @param bool|string $param Value to pass to the message for the name of the field
+ * as $1. Currently this parameter doesn't seem to ever be used.
+ *
+ * Note, everything here is passed through the parser later on (!)
+ */
+ protected static function addMeta( &$array, $visibility, $type, $id, $value, $param = false ) {
+ $msg = wfMessage( "$type-$id", $param );
+ if ( $msg->exists() ) {
+ $name = $msg->text();
+ } else {
+ // This is for future compatibility when using instant commons.
+ // So as to not display as ugly a name if a new metadata
+ // property is defined that we don't know about
+ // (not a major issue since such a property would be collapsed
+ // by default).
+ wfDebug( __METHOD__ . ' Unknown metadata name: ' . $id . "\n" );
+ $name = wfEscapeWikiText( $id );
+ }
+ $array[$visibility][] = [
+ 'id' => "$type-$id",
+ 'name' => $name,
+ 'value' => $value
+ ];
+ }
+
+ /**
+ * Short description. Shown on Special:Search results.
+ *
+ * @param File $file
+ * @return string
+ */
+ function getShortDesc( $file ) {
+ return self::getGeneralShortDesc( $file );
+ }
+
+ /**
+ * Long description. Shown under image on image description page surounded by ().
+ *
+ * @param File $file
+ * @return string
+ */
+ function getLongDesc( $file ) {
+ return self::getGeneralLongDesc( $file );
+ }
+
+ /**
+ * Used instead of getShortDesc if there is no handler registered for file.
+ *
+ * @param File $file
+ * @return string
+ */
+ static function getGeneralShortDesc( $file ) {
+ global $wgLang;
+
+ return htmlspecialchars( $wgLang->formatSize( $file->getSize() ) );
+ }
+
+ /**
+ * Used instead of getLongDesc if there is no handler registered for file.
+ *
+ * @param File $file
+ * @return string
+ */
+ static function getGeneralLongDesc( $file ) {
+ return wfMessage( 'file-info' )->sizeParams( $file->getSize() )
+ ->params( '<span class="mime-type">' . $file->getMimeType() . '</span>' )->parse();
+ }
+
+ /**
+ * Calculate the largest thumbnail width for a given original file size
+ * such that the thumbnail's height is at most $maxHeight.
+ * @param int $boxWidth Width of the thumbnail box.
+ * @param int $boxHeight Height of the thumbnail box.
+ * @param int $maxHeight Maximum height expected for the thumbnail.
+ * @return int
+ */
+ public static function fitBoxWidth( $boxWidth, $boxHeight, $maxHeight ) {
+ $idealWidth = $boxWidth * $maxHeight / $boxHeight;
+ $roundedUp = ceil( $idealWidth );
+ if ( round( $roundedUp * $boxHeight / $boxWidth ) > $maxHeight ) {
+ return floor( $idealWidth );
+ } else {
+ return $roundedUp;
+ }
+ }
+
+ /**
+ * Shown in file history box on image description page.
+ *
+ * @param File $file
+ * @return string Dimensions
+ */
+ function getDimensionsString( $file ) {
+ return '';
+ }
+
+ /**
+ * Modify the parser object post-transform.
+ *
+ * This is often used to do $parser->addOutputHook(),
+ * in order to add some javascript to render a viewer.
+ * See TimedMediaHandler or OggHandler for an example.
+ *
+ * @param Parser $parser
+ * @param File $file
+ */
+ function parserTransformHook( $parser, $file ) {
+ }
+
+ /**
+ * File validation hook called on upload.
+ *
+ * If the file at the given local path is not valid, or its MIME type does not
+ * match the handler class, a Status object should be returned containing
+ * relevant errors.
+ *
+ * @param string $fileName The local path to the file.
+ * @return Status
+ */
+ function verifyUpload( $fileName ) {
+ return Status::newGood();
+ }
+
+ /**
+ * Check for zero-sized thumbnails. These can be generated when
+ * no disk space is available or some other error occurs
+ *
+ * @param string $dstPath The location of the suspect file
+ * @param int $retval Return value of some shell process, file will be deleted if this is non-zero
+ * @return bool True if removed, false otherwise
+ */
+ function removeBadFile( $dstPath, $retval = 0 ) {
+ if ( file_exists( $dstPath ) ) {
+ $thumbstat = stat( $dstPath );
+ if ( $thumbstat['size'] == 0 || $retval != 0 ) {
+ $result = unlink( $dstPath );
+
+ if ( $result ) {
+ wfDebugLog( 'thumbnail',
+ sprintf( 'Removing bad %d-byte thumbnail "%s". unlink() succeeded',
+ $thumbstat['size'], $dstPath ) );
+ } else {
+ wfDebugLog( 'thumbnail',
+ sprintf( 'Removing bad %d-byte thumbnail "%s". unlink() failed',
+ $thumbstat['size'], $dstPath ) );
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Remove files from the purge list.
+ *
+ * This is used by some video handlers to prevent ?action=purge
+ * from removing a transcoded video, which is expensive to
+ * regenerate.
+ *
+ * @see LocalFile::purgeThumbnails
+ *
+ * @param array &$files
+ * @param array $options Purge options. Currently will always be
+ * an array with a single key 'forThumbRefresh' set to true.
+ */
+ public function filterThumbnailPurgeList( &$files, $options ) {
+ // Do nothing
+ }
+
+ /**
+ * True if the handler can rotate the media
+ * @since 1.24 non-static. From 1.21-1.23 was static
+ * @return bool
+ */
+ public function canRotate() {
+ return false;
+ }
+
+ /**
+ * On supporting image formats, try to read out the low-level orientation
+ * of the file and return the angle that the file needs to be rotated to
+ * be viewed.
+ *
+ * This information is only useful when manipulating the original file;
+ * the width and height we normally work with is logical, and will match
+ * any produced output views.
+ *
+ * For files we don't know, we return 0.
+ *
+ * @param File $file
+ * @return int 0, 90, 180 or 270
+ */
+ public function getRotation( $file ) {
+ return 0;
+ }
+
+ /**
+ * Log an error that occurred in an external process
+ *
+ * Moved from BitmapHandler to MediaHandler with MediaWiki 1.23
+ *
+ * @since 1.23
+ * @param int $retval
+ * @param string $err Error reported by command. Anything longer than
+ * MediaHandler::MAX_ERR_LOG_SIZE is stripped off.
+ * @param string $cmd
+ */
+ protected function logErrorForExternalProcess( $retval, $err, $cmd ) {
+ # Keep error output limited (T59985)
+ $errMessage = trim( substr( $err, 0, self::MAX_ERR_LOG_SIZE ) );
+
+ wfDebugLog( 'thumbnail',
+ sprintf( 'thumbnail failed on %s: error %d "%s" from "%s"',
+ wfHostname(), $retval, $errMessage, $cmd ) );
+ }
+
+ /**
+ * Get list of languages file can be viewed in.
+ *
+ * @param File $file
+ * @return string[] Array of language codes, or empty array if unsupported.
+ * @since 1.23
+ */
+ public function getAvailableLanguages( File $file ) {
+ return [];
+ }
+
+ /**
+ * On file types that support renderings in multiple languages,
+ * which language is used by default if unspecified.
+ *
+ * If getAvailableLanguages returns a non-empty array, this must return
+ * a valid language code. Otherwise can return null if files of this
+ * type do not support alternative language renderings.
+ *
+ * @param File $file
+ * @return string|null Language code or null if multi-language not supported for filetype.
+ * @since 1.23
+ */
+ public function getDefaultRenderLanguage( File $file ) {
+ return null;
+ }
+
+ /**
+ * If its an audio file, return the length of the file. Otherwise 0.
+ *
+ * File::getLength() existed for a long time, but was calling a method
+ * that only existed in some subclasses of this class (The TMH ones).
+ *
+ * @param File $file
+ * @return float Length in seconds
+ * @since 1.23
+ */
+ public function getLength( $file ) {
+ return 0.0;
+ }
+
+ /**
+ * True if creating thumbnails from the file is large or otherwise resource-intensive.
+ * @param File $file
+ * @return bool
+ */
+ public function isExpensiveToThumbnail( $file ) {
+ return false;
+ }
+
+ /**
+ * Returns whether or not this handler supports the chained generation of thumbnails according
+ * to buckets
+ * @return bool
+ * @since 1.24
+ */
+ public function supportsBucketing() {
+ return false;
+ }
+
+ /**
+ * Returns a normalised params array for which parameters have been cleaned up for bucketing
+ * purposes
+ * @param array $params
+ * @return array
+ */
+ public function sanitizeParamsForBucketing( $params ) {
+ return $params;
+ }
+
+ /**
+ * Gets configuration for the file warning message. Return value of
+ * the following structure:
+ * [
+ * // Required, module with messages loaded for the client
+ * 'module' => 'example.filewarning.messages',
+ * // Required, array of names of messages
+ * 'messages' => [
+ * // Required, main warning message
+ * 'main' => 'example-filewarning-main',
+ * // Optional, header for warning dialog
+ * 'header' => 'example-filewarning-header',
+ * // Optional, footer for warning dialog
+ * 'footer' => 'example-filewarning-footer',
+ * // Optional, text for more-information link (see below)
+ * 'info' => 'example-filewarning-info',
+ * ],
+ * // Optional, link for more information
+ * 'link' => 'http://example.com',
+ * ]
+ *
+ * Returns null if no warning is necessary.
+ * @param File $file
+ * @return array|null
+ */
+ public function getWarningConfig( $file ) {
+ return null;
+ }
+
+ /**
+ * Converts a dimensions array about a potentially multipage document from an
+ * exhaustive list of ordered page numbers to a list of page ranges
+ * @param Array $pagesByDimensions
+ * @return String
+ * @since 1.30
+ */
+ public static function getPageRangesByDimensions( $pagesByDimensions ) {
+ $pageRangesByDimensions = [];
+
+ foreach ( $pagesByDimensions as $dimensions => $pageList ) {
+ $ranges = [];
+ $firstPage = $pageList[0];
+ $lastPage = $firstPage - 1;
+
+ foreach ( $pageList as $page ) {
+ if ( $page > $lastPage + 1 ) {
+ if ( $firstPage != $lastPage ) {
+ $ranges[] = "$firstPage-$lastPage";
+ } else {
+ $ranges[] = "$firstPage";
+ }
+
+ $firstPage = $page;
+ }
+
+ $lastPage = $page;
+ }
+
+ if ( $firstPage != $lastPage ) {
+ $ranges[] = "$firstPage-$lastPage";
+ } else {
+ $ranges[] = "$firstPage";
+ }
+
+ $pageRangesByDimensions[ $dimensions ] = $ranges;
+ }
+
+ $dimensionsString = [];
+ foreach ( $pageRangesByDimensions as $dimensions => $pageRanges ) {
+ $dimensionsString[] = "$dimensions:" . implode( ',', $pageRanges );
+ }
+
+ return implode( '/', $dimensionsString );
+ }
+
+ /**
+ * Get useful response headers for GET/HEAD requests for a file with the given metadata
+ * @param array $metadata Contains this handler's unserialized getMetadata() for a file
+ * @return array
+ * @since 1.30
+ */
+ public function getContentHeaders( $metadata ) {
+ return [];
+ }
+}
diff --git a/www/wiki/includes/media/MediaHandlerFactory.php b/www/wiki/includes/media/MediaHandlerFactory.php
new file mode 100644
index 00000000..543dc80d
--- /dev/null
+++ b/www/wiki/includes/media/MediaHandlerFactory.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * Media-handling base classes and generic functionality.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Class to construct MediaHandler objects
+ *
+ * @since 1.28
+ */
+class MediaHandlerFactory {
+
+ /**
+ * Default, MediaWiki core media handlers
+ *
+ * @var array
+ */
+ private static $coreHandlers = [
+ 'image/jpeg' => JpegHandler::class,
+ 'image/png' => PNGHandler::class,
+ 'image/gif' => GIFHandler::class,
+ 'image/tiff' => TiffHandler::class,
+ 'image/webp' => WebPHandler::class,
+ 'image/x-ms-bmp' => BmpHandler::class,
+ 'image/x-bmp' => BmpHandler::class,
+ 'image/x-xcf' => XCFHandler::class,
+ 'image/svg+xml' => SvgHandler::class, // official
+ 'image/svg' => SvgHandler::class, // compat
+ 'image/vnd.djvu' => DjVuHandler::class, // official
+ 'image/x.djvu' => DjVuHandler::class, // compat
+ 'image/x-djvu' => DjVuHandler::class, // compat
+ ];
+
+ /**
+ * @var array
+ */
+ private $registry;
+
+ /**
+ * Instance cache of MediaHandler objects by mimetype
+ *
+ * @var MediaHandler[]
+ */
+ private $handlers;
+
+ public function __construct( array $registry ) {
+ $this->registry = $registry + self::$coreHandlers;
+ }
+
+ protected function getHandlerClass( $type ) {
+ if ( isset( $this->registry[$type] ) ) {
+ return $this->registry[$type];
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param string $type mimetype
+ * @return bool|MediaHandler
+ */
+ public function getHandler( $type ) {
+ if ( isset( $this->handlers[$type] ) ) {
+ return $this->handlers[$type];
+ }
+
+ $class = $this->getHandlerClass( $type );
+ if ( $class !== false ) {
+ /** @var MediaHandler $handler */
+ $handler = new $class;
+ if ( !$handler->isEnabled() ) {
+ wfDebug( __METHOD__ . ": $class is not enabled\n" );
+ $handler = false;
+ }
+ } else {
+ wfDebug( __METHOD__ . ": no handler found for $type.\n" );
+ $handler = false;
+ }
+
+ $this->handlers[$type] = $handler;
+ return $handler;
+ }
+}
diff --git a/www/wiki/includes/media/MediaTransformInvalidParametersException.php b/www/wiki/includes/media/MediaTransformInvalidParametersException.php
new file mode 100644
index 00000000..6f9c2916
--- /dev/null
+++ b/www/wiki/includes/media/MediaTransformInvalidParametersException.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * MediaWiki exception thrown by some methods when the transform parameter array is invalid
+ *
+ * @ingroup Exception
+ */
+class MediaTransformInvalidParametersException extends MWException {
+}
diff --git a/www/wiki/includes/media/MediaTransformOutput.php b/www/wiki/includes/media/MediaTransformOutput.php
new file mode 100644
index 00000000..5366c4fa
--- /dev/null
+++ b/www/wiki/includes/media/MediaTransformOutput.php
@@ -0,0 +1,524 @@
+<?php
+/**
+ * Base class for the output of file transformation methods.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Base class for the output of MediaHandler::doTransform() and File::transform().
+ *
+ * @ingroup Media
+ */
+abstract class MediaTransformOutput {
+ /** @var array Associative array mapping optional supplementary image files
+ * from pixel density (eg 1.5 or 2) to additional URLs.
+ */
+ public $responsiveUrls = [];
+
+ /** @var File */
+ protected $file;
+
+ /** @var int Image width */
+ protected $width;
+
+ /** @var int Image height */
+ protected $height;
+
+ /** @var string URL path to the thumb */
+ protected $url;
+
+ /** @var bool|string */
+ protected $page;
+
+ /** @var bool|string Filesystem path to the thumb */
+ protected $path;
+
+ /** @var bool|string Language code, false if not set */
+ protected $lang;
+
+ /** @var bool|string Permanent storage path */
+ protected $storagePath = false;
+
+ /**
+ * @return int Width of the output box
+ */
+ public function getWidth() {
+ return $this->width;
+ }
+
+ /**
+ * @return int Height of the output box
+ */
+ public function getHeight() {
+ return $this->height;
+ }
+
+ /**
+ * @return File
+ */
+ public function getFile() {
+ return $this->file;
+ }
+
+ /**
+ * Get the final extension of the thumbnail.
+ * Returns false for scripted transformations.
+ * @return string|bool
+ */
+ public function getExtension() {
+ return $this->path ? FileBackend::extensionFromPath( $this->path ) : false;
+ }
+
+ /**
+ * @return string|bool The thumbnail URL
+ */
+ public function getUrl() {
+ return $this->url;
+ }
+
+ /**
+ * @return string|bool The permanent thumbnail storage path
+ */
+ public function getStoragePath() {
+ return $this->storagePath;
+ }
+
+ /**
+ * @param string $storagePath The permanent storage path
+ * @return void
+ */
+ public function setStoragePath( $storagePath ) {
+ $this->storagePath = $storagePath;
+ if ( $this->path === false ) {
+ $this->path = $storagePath;
+ }
+ }
+
+ /**
+ * Fetch HTML for this transform output
+ *
+ * @param array $options Associative array of options. Boolean options
+ * should be indicated with a value of true for true, and false or
+ * absent for false.
+ *
+ * alt Alternate text or caption
+ * desc-link Boolean, show a description link
+ * file-link Boolean, show a file download link
+ * custom-url-link Custom URL to link to
+ * custom-title-link Custom Title object to link to
+ * valign vertical-align property, if the output is an inline element
+ * img-class Class applied to the "<img>" tag, if there is such a tag
+ *
+ * For images, desc-link and file-link are implemented as a click-through. For
+ * sounds and videos, they may be displayed in other ways.
+ *
+ * @return string
+ */
+ abstract public function toHtml( $options = [] );
+
+ /**
+ * This will be overridden to return true in error classes
+ * @return bool
+ */
+ public function isError() {
+ return false;
+ }
+
+ /**
+ * Check if an output thumbnail file actually exists.
+ *
+ * This will return false if there was an error, the
+ * thumbnail is to be handled client-side only, or if
+ * transformation was deferred via TRANSFORM_LATER.
+ * This file may exist as a new file in /tmp, a file
+ * in permanent storage, or even refer to the original.
+ *
+ * @return bool
+ */
+ public function hasFile() {
+ // If TRANSFORM_LATER, $this->path will be false.
+ // Note: a null path means "use the source file".
+ return ( !$this->isError() && ( $this->path || $this->path === null ) );
+ }
+
+ /**
+ * Check if the output thumbnail is the same as the source.
+ * This can occur if the requested width was bigger than the source.
+ *
+ * @return bool
+ */
+ public function fileIsSource() {
+ return ( !$this->isError() && $this->path === null );
+ }
+
+ /**
+ * Get the path of a file system copy of the thumbnail.
+ * Callers should never write to this path.
+ *
+ * @return string|bool Returns false if there isn't one
+ */
+ public function getLocalCopyPath() {
+ if ( $this->isError() ) {
+ return false;
+ } elseif ( $this->path === null ) {
+ return $this->file->getLocalRefPath(); // assume thumb was not scaled
+ } elseif ( FileBackend::isStoragePath( $this->path ) ) {
+ $be = $this->file->getRepo()->getBackend();
+ // The temp file will be process cached by FileBackend
+ $fsFile = $be->getLocalReference( [ 'src' => $this->path ] );
+
+ return $fsFile ? $fsFile->getPath() : false;
+ } else {
+ return $this->path; // may return false
+ }
+ }
+
+ /**
+ * Stream the file if there were no errors
+ *
+ * @param array $headers Additional HTTP headers to send on success
+ * @return Status
+ * @since 1.27
+ */
+ public function streamFileWithStatus( $headers = [] ) {
+ if ( !$this->path ) {
+ return Status::newFatal( 'backend-fail-stream', '<no path>' );
+ } elseif ( FileBackend::isStoragePath( $this->path ) ) {
+ $be = $this->file->getRepo()->getBackend();
+ return $be->streamFile( [ 'src' => $this->path, 'headers' => $headers ] );
+ } else { // FS-file
+ $success = StreamFile::stream( $this->getLocalCopyPath(), $headers );
+ return $success ? Status::newGood() : Status::newFatal( 'backend-fail-stream', $this->path );
+ }
+ }
+
+ /**
+ * Stream the file if there were no errors
+ *
+ * @deprecated since 1.26, use streamFileWithStatus
+ * @param array $headers Additional HTTP headers to send on success
+ * @return bool Success
+ */
+ public function streamFile( $headers = [] ) {
+ $this->streamFileWithStatus( $headers )->isOK();
+ }
+
+ /**
+ * Wrap some XHTML text in an anchor tag with the given attributes
+ *
+ * @param array $linkAttribs
+ * @param string $contents
+ * @return string
+ */
+ protected function linkWrap( $linkAttribs, $contents ) {
+ if ( $linkAttribs ) {
+ return Xml::tags( 'a', $linkAttribs, $contents );
+ } else {
+ return $contents;
+ }
+ }
+
+ /**
+ * @param string $title
+ * @param string|array $params Query parameters to add
+ * @return array
+ */
+ public function getDescLinkAttribs( $title = null, $params = [] ) {
+ if ( is_array( $params ) ) {
+ $query = $params;
+ } else {
+ $query = [];
+ }
+ if ( $this->page && $this->page !== 1 ) {
+ $query['page'] = $this->page;
+ }
+ if ( $this->lang ) {
+ $query['lang'] = $this->lang;
+ }
+
+ if ( is_string( $params ) && $params !== '' ) {
+ $query = $params . '&' . wfArrayToCgi( $query );
+ }
+
+ $attribs = [
+ 'href' => $this->file->getTitle()->getLocalURL( $query ),
+ 'class' => 'image',
+ ];
+ if ( $title ) {
+ $attribs['title'] = $title;
+ }
+
+ return $attribs;
+ }
+}
+
+/**
+ * Media transform output for images
+ *
+ * @ingroup Media
+ */
+class ThumbnailImage extends MediaTransformOutput {
+ /**
+ * Get a thumbnail object from a file and parameters.
+ * If $path is set to null, the output file is treated as a source copy.
+ * If $path is set to false, no output file will be created.
+ * $parameters should include, as a minimum, (file) 'width' and 'height'.
+ * It may also include a 'page' parameter for multipage files.
+ *
+ * @param File $file
+ * @param string $url URL path to the thumb
+ * @param string|bool $path Filesystem path to the thumb
+ * @param array $parameters Associative array of parameters
+ */
+ function __construct( $file, $url, $path = false, $parameters = [] ) {
+ # Previous parameters:
+ # $file, $url, $width, $height, $path = false, $page = false
+
+ $defaults = [
+ 'page' => false,
+ 'lang' => false
+ ];
+
+ if ( is_array( $parameters ) ) {
+ $actualParams = $parameters + $defaults;
+ } else {
+ # Using old format, should convert. Later a warning could be added here.
+ $numArgs = func_num_args();
+ $actualParams = [
+ 'width' => $path,
+ 'height' => $parameters,
+ 'page' => ( $numArgs > 5 ) ? func_get_arg( 5 ) : false
+ ] + $defaults;
+ $path = ( $numArgs > 4 ) ? func_get_arg( 4 ) : false;
+ }
+
+ $this->file = $file;
+ $this->url = $url;
+ $this->path = $path;
+
+ # These should be integers when they get here.
+ # If not, there's a bug somewhere. But let's at
+ # least produce valid HTML code regardless.
+ $this->width = round( $actualParams['width'] );
+ $this->height = round( $actualParams['height'] );
+
+ $this->page = $actualParams['page'];
+ $this->lang = $actualParams['lang'];
+ }
+
+ /**
+ * Return HTML <img ... /> tag for the thumbnail, will include
+ * width and height attributes and a blank alt text (as required).
+ *
+ * @param array $options Associative array of options. Boolean options
+ * should be indicated with a value of true for true, and false or
+ * absent for false.
+ *
+ * alt HTML alt attribute
+ * title HTML title attribute
+ * desc-link Boolean, show a description link
+ * file-link Boolean, show a file download link
+ * valign vertical-align property, if the output is an inline element
+ * img-class Class applied to the \<img\> tag, if there is such a tag
+ * desc-query String, description link query params
+ * override-width Override width attribute. Should generally not set
+ * override-height Override height attribute. Should generally not set
+ * no-dimensions Boolean, skip width and height attributes (useful if
+ * set in CSS)
+ * custom-url-link Custom URL to link to
+ * custom-title-link Custom Title object to link to
+ * custom target-link Value of the target attribute, for custom-target-link
+ * parser-extlink-* Attributes added by parser for external links:
+ * parser-extlink-rel: add rel="nofollow"
+ * parser-extlink-target: link target, but overridden by custom-target-link
+ *
+ * For images, desc-link and file-link are implemented as a click-through. For
+ * sounds and videos, they may be displayed in other ways.
+ *
+ * @throws MWException
+ * @return string
+ */
+ function toHtml( $options = [] ) {
+ if ( count( func_get_args() ) == 2 ) {
+ throw new MWException( __METHOD__ . ' called in the old style' );
+ }
+
+ $alt = isset( $options['alt'] ) ? $options['alt'] : '';
+
+ $query = isset( $options['desc-query'] ) ? $options['desc-query'] : '';
+
+ $attribs = [
+ 'alt' => $alt,
+ 'src' => $this->url,
+ ];
+
+ if ( !empty( $options['custom-url-link'] ) ) {
+ $linkAttribs = [ 'href' => $options['custom-url-link'] ];
+ if ( !empty( $options['title'] ) ) {
+ $linkAttribs['title'] = $options['title'];
+ }
+ if ( !empty( $options['custom-target-link'] ) ) {
+ $linkAttribs['target'] = $options['custom-target-link'];
+ } elseif ( !empty( $options['parser-extlink-target'] ) ) {
+ $linkAttribs['target'] = $options['parser-extlink-target'];
+ }
+ if ( !empty( $options['parser-extlink-rel'] ) ) {
+ $linkAttribs['rel'] = $options['parser-extlink-rel'];
+ }
+ } elseif ( !empty( $options['custom-title-link'] ) ) {
+ /** @var Title $title */
+ $title = $options['custom-title-link'];
+ $linkAttribs = [
+ 'href' => $title->getLinkURL(),
+ 'title' => empty( $options['title'] ) ? $title->getFullText() : $options['title']
+ ];
+ } elseif ( !empty( $options['desc-link'] ) ) {
+ $linkAttribs = $this->getDescLinkAttribs(
+ empty( $options['title'] ) ? null : $options['title'],
+ $query
+ );
+ } elseif ( !empty( $options['file-link'] ) ) {
+ $linkAttribs = [ 'href' => $this->file->getUrl() ];
+ } else {
+ $linkAttribs = false;
+ if ( !empty( $options['title'] ) ) {
+ $attribs['title'] = $options['title'];
+ }
+ }
+
+ if ( empty( $options['no-dimensions'] ) ) {
+ $attribs['width'] = $this->width;
+ $attribs['height'] = $this->height;
+ }
+ if ( !empty( $options['valign'] ) ) {
+ $attribs['style'] = "vertical-align: {$options['valign']}";
+ }
+ if ( !empty( $options['img-class'] ) ) {
+ $attribs['class'] = $options['img-class'];
+ }
+ if ( isset( $options['override-height'] ) ) {
+ $attribs['height'] = $options['override-height'];
+ }
+ if ( isset( $options['override-width'] ) ) {
+ $attribs['width'] = $options['override-width'];
+ }
+
+ // Additional densities for responsive images, if specified.
+ // If any of these urls is the same as src url, it'll be excluded.
+ $responsiveUrls = array_diff( $this->responsiveUrls, [ $this->url ] );
+ if ( !empty( $responsiveUrls ) ) {
+ $attribs['srcset'] = Html::srcSet( $responsiveUrls );
+ }
+
+ Hooks::run( 'ThumbnailBeforeProduceHTML', [ $this, &$attribs, &$linkAttribs ] );
+
+ return $this->linkWrap( $linkAttribs, Xml::element( 'img', $attribs ) );
+ }
+}
+
+/**
+ * Basic media transform error class
+ *
+ * @ingroup Media
+ */
+class MediaTransformError extends MediaTransformOutput {
+ /** @var Message */
+ private $msg;
+
+ function __construct( $msg, $width, $height /*, ... */ ) {
+ $args = array_slice( func_get_args(), 3 );
+ $this->msg = wfMessage( $msg )->params( $args );
+ $this->width = intval( $width );
+ $this->height = intval( $height );
+ $this->url = false;
+ $this->path = false;
+ }
+
+ function toHtml( $options = [] ) {
+ return "<div class=\"MediaTransformError\" style=\"" .
+ "width: {$this->width}px; height: {$this->height}px; display:inline-block;\">" .
+ $this->getHtmlMsg() .
+ "</div>";
+ }
+
+ function toText() {
+ return $this->msg->text();
+ }
+
+ function getHtmlMsg() {
+ return $this->msg->escaped();
+ }
+
+ function getMsg() {
+ return $this->msg;
+ }
+
+ function isError() {
+ return true;
+ }
+
+ function getHttpStatusCode() {
+ return 500;
+ }
+}
+
+/**
+ * Shortcut class for parameter validation errors
+ *
+ * @ingroup Media
+ */
+class TransformParameterError extends MediaTransformError {
+ function __construct( $params ) {
+ parent::__construct( 'thumbnail_error',
+ max( isset( $params['width'] ) ? $params['width'] : 0, 120 ),
+ max( isset( $params['height'] ) ? $params['height'] : 0, 120 ),
+ wfMessage( 'thumbnail_invalid_params' )
+ );
+ }
+
+ function getHttpStatusCode() {
+ return 400;
+ }
+}
+
+/**
+ * Shortcut class for parameter file size errors
+ *
+ * @ingroup Media
+ * @since 1.25
+ */
+class TransformTooBigImageAreaError extends MediaTransformError {
+ function __construct( $params, $maxImageArea ) {
+ $msg = wfMessage( 'thumbnail_toobigimagearea' );
+ $msg->rawParams(
+ $msg->getLanguage()->formatComputingNumbers( $maxImageArea, 1000, "size-$1pixel" )
+ );
+
+ parent::__construct( 'thumbnail_error',
+ max( isset( $params['width'] ) ? $params['width'] : 0, 120 ),
+ max( isset( $params['height'] ) ? $params['height'] : 0, 120 ),
+ $msg
+ );
+ }
+
+ function getHttpStatusCode() {
+ return 400;
+ }
+}
diff --git a/www/wiki/includes/media/PNG.php b/www/wiki/includes/media/PNG.php
new file mode 100644
index 00000000..b6288bc3
--- /dev/null
+++ b/www/wiki/includes/media/PNG.php
@@ -0,0 +1,203 @@
+<?php
+/**
+ * Handler for PNG images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Handler for PNG images.
+ *
+ * @ingroup Media
+ */
+class PNGHandler extends BitmapHandler {
+ const BROKEN_FILE = '0';
+
+ /**
+ * @param File|FSFile $image
+ * @param string $filename
+ * @return string
+ */
+ function getMetadata( $image, $filename ) {
+ try {
+ $metadata = BitmapMetadataHandler::PNG( $filename );
+ } catch ( Exception $e ) {
+ // Broken file?
+ wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" );
+
+ return self::BROKEN_FILE;
+ }
+
+ return serialize( $metadata );
+ }
+
+ /**
+ * @param File $image
+ * @param bool|IContextSource $context Context to use (optional)
+ * @return array|bool
+ */
+ function formatMetadata( $image, $context = false ) {
+ $meta = $this->getCommonMetaArray( $image );
+ if ( count( $meta ) === 0 ) {
+ return false;
+ }
+
+ return $this->formatMetadataHelper( $meta, $context );
+ }
+
+ /**
+ * Get a file type independent array of metadata.
+ *
+ * @param File $image
+ * @return array The metadata array
+ */
+ public function getCommonMetaArray( File $image ) {
+ $meta = $image->getMetadata();
+
+ if ( !$meta ) {
+ return [];
+ }
+ $meta = unserialize( $meta );
+ if ( !isset( $meta['metadata'] ) ) {
+ return [];
+ }
+ unset( $meta['metadata']['_MW_PNG_VERSION'] );
+
+ return $meta['metadata'];
+ }
+
+ /**
+ * @param File $image
+ * @return bool
+ */
+ function isAnimatedImage( $image ) {
+ $ser = $image->getMetadata();
+ if ( $ser ) {
+ $metadata = unserialize( $ser );
+ if ( $metadata['frameCount'] > 1 ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * We do not support making APNG thumbnails, so always false
+ * @param File $image
+ * @return bool False
+ */
+ function canAnimateThumbnail( $image ) {
+ return false;
+ }
+
+ function getMetadataType( $image ) {
+ return 'parsed-png';
+ }
+
+ function isMetadataValid( $image, $metadata ) {
+ if ( $metadata === self::BROKEN_FILE ) {
+ // Do not repetitivly regenerate metadata on broken file.
+ return self::METADATA_GOOD;
+ }
+
+ MediaWiki\suppressWarnings();
+ $data = unserialize( $metadata );
+ MediaWiki\restoreWarnings();
+
+ if ( !$data || !is_array( $data ) ) {
+ wfDebug( __METHOD__ . " invalid png metadata\n" );
+
+ return self::METADATA_BAD;
+ }
+
+ if ( !isset( $data['metadata']['_MW_PNG_VERSION'] )
+ || $data['metadata']['_MW_PNG_VERSION'] != PNGMetadataExtractor::VERSION
+ ) {
+ wfDebug( __METHOD__ . " old but compatible png metadata\n" );
+
+ return self::METADATA_COMPATIBLE;
+ }
+
+ return self::METADATA_GOOD;
+ }
+
+ /**
+ * @param File $image
+ * @return string
+ */
+ function getLongDesc( $image ) {
+ global $wgLang;
+ $original = parent::getLongDesc( $image );
+
+ MediaWiki\suppressWarnings();
+ $metadata = unserialize( $image->getMetadata() );
+ MediaWiki\restoreWarnings();
+
+ if ( !$metadata || $metadata['frameCount'] <= 0 ) {
+ return $original;
+ }
+
+ $info = [];
+ $info[] = $original;
+
+ if ( $metadata['loopCount'] == 0 ) {
+ $info[] = wfMessage( 'file-info-png-looped' )->parse();
+ } elseif ( $metadata['loopCount'] > 1 ) {
+ $info[] = wfMessage( 'file-info-png-repeat' )->numParams( $metadata['loopCount'] )->parse();
+ }
+
+ if ( $metadata['frameCount'] > 0 ) {
+ $info[] = wfMessage( 'file-info-png-frames' )->numParams( $metadata['frameCount'] )->parse();
+ }
+
+ if ( $metadata['duration'] ) {
+ $info[] = $wgLang->formatTimePeriod( $metadata['duration'] );
+ }
+
+ return $wgLang->commaList( $info );
+ }
+
+ /**
+ * Return the duration of an APNG file.
+ *
+ * Shown in the &query=imageinfo&iiprop=size api query.
+ *
+ * @param File $file
+ * @return float The duration of the file.
+ */
+ public function getLength( $file ) {
+ $serMeta = $file->getMetadata();
+ MediaWiki\suppressWarnings();
+ $metadata = unserialize( $serMeta );
+ MediaWiki\restoreWarnings();
+
+ if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) {
+ return 0.0;
+ } else {
+ return (float)$metadata['duration'];
+ }
+ }
+
+ // PNGs should be easy to support, but it will need some sharpening applied
+ // and another user test to check if the perceived quality change is noticeable
+ public function supportsBucketing() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/media/PNGMetadataExtractor.php b/www/wiki/includes/media/PNGMetadataExtractor.php
new file mode 100644
index 00000000..c12ca0bf
--- /dev/null
+++ b/www/wiki/includes/media/PNGMetadataExtractor.php
@@ -0,0 +1,428 @@
+<?php
+/**
+ * PNG frame counter and metadata extractor.
+ *
+ * Slightly derived from GIFMetadataExtractor.php
+ * Deliberately not using MWExceptions to avoid external dependencies, encouraging
+ * redistribution.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * PNG frame counter.
+ *
+ * @ingroup Media
+ */
+class PNGMetadataExtractor {
+ /** @var string */
+ private static $pngSig;
+
+ /** @var int */
+ private static $crcSize;
+
+ /** @var array */
+ private static $textChunks;
+
+ const VERSION = 1;
+ const MAX_CHUNK_SIZE = 3145728; // 3 megabytes
+
+ static function getMetadata( $filename ) {
+ self::$pngSig = pack( "C8", 137, 80, 78, 71, 13, 10, 26, 10 );
+ self::$crcSize = 4;
+ /* based on list at http://owl.phy.queensu.ca/~phil/exiftool/TagNames/PNG.html#TextualData
+ * and https://www.w3.org/TR/PNG/#11keywords
+ */
+ self::$textChunks = [
+ 'xml:com.adobe.xmp' => 'xmp',
+ # Artist is unofficial. Author is the recommended
+ # keyword in the PNG spec. However some people output
+ # Artist so support both.
+ 'artist' => 'Artist',
+ 'model' => 'Model',
+ 'make' => 'Make',
+ 'author' => 'Artist',
+ 'comment' => 'PNGFileComment',
+ 'description' => 'ImageDescription',
+ 'title' => 'ObjectName',
+ 'copyright' => 'Copyright',
+ # Source as in original device used to make image
+ # not as in who gave you the image
+ 'source' => 'Model',
+ 'software' => 'Software',
+ 'disclaimer' => 'Disclaimer',
+ 'warning' => 'ContentWarning',
+ 'url' => 'Identifier', # Not sure if this is best mapping. Maybe WebStatement.
+ 'label' => 'Label',
+ 'creation time' => 'DateTimeDigitized',
+ /* Other potentially useful things - Document */
+ ];
+
+ $frameCount = 0;
+ $loopCount = 1;
+ $text = [];
+ $duration = 0.0;
+ $bitDepth = 0;
+ $colorType = 'unknown';
+
+ if ( !$filename ) {
+ throw new Exception( __METHOD__ . ": No file name specified" );
+ } elseif ( !file_exists( $filename ) || is_dir( $filename ) ) {
+ throw new Exception( __METHOD__ . ": File $filename does not exist" );
+ }
+
+ $fh = fopen( $filename, 'rb' );
+
+ if ( !$fh ) {
+ throw new Exception( __METHOD__ . ": Unable to open file $filename" );
+ }
+
+ // Check for the PNG header
+ $buf = fread( $fh, 8 );
+ if ( $buf != self::$pngSig ) {
+ throw new Exception( __METHOD__ . ": Not a valid PNG file; header: $buf" );
+ }
+
+ // Read chunks
+ while ( !feof( $fh ) ) {
+ $buf = fread( $fh, 4 );
+ if ( !$buf || strlen( $buf ) < 4 ) {
+ throw new Exception( __METHOD__ . ": Read error" );
+ }
+ $chunk_size = unpack( "N", $buf )[1];
+
+ if ( $chunk_size < 0 ) {
+ throw new Exception( __METHOD__ . ": Chunk size too big for unpack" );
+ }
+
+ $chunk_type = fread( $fh, 4 );
+ if ( !$chunk_type || strlen( $chunk_type ) < 4 ) {
+ throw new Exception( __METHOD__ . ": Read error" );
+ }
+
+ if ( $chunk_type == "IHDR" ) {
+ $buf = self::read( $fh, $chunk_size );
+ if ( !$buf || strlen( $buf ) < $chunk_size ) {
+ throw new Exception( __METHOD__ . ": Read error" );
+ }
+ $width = unpack( 'N', substr( $buf, 0, 4 ) )[1];
+ $height = unpack( 'N', substr( $buf, 4, 4 ) )[1];
+ $bitDepth = ord( substr( $buf, 8, 1 ) );
+ // Detect the color type in British English as per the spec
+ // https://www.w3.org/TR/PNG/#11IHDR
+ switch ( ord( substr( $buf, 9, 1 ) ) ) {
+ case 0:
+ $colorType = 'greyscale';
+ break;
+ case 2:
+ $colorType = 'truecolour';
+ break;
+ case 3:
+ $colorType = 'index-coloured';
+ break;
+ case 4:
+ $colorType = 'greyscale-alpha';
+ break;
+ case 6:
+ $colorType = 'truecolour-alpha';
+ break;
+ default:
+ $colorType = 'unknown';
+ break;
+ }
+ } elseif ( $chunk_type == "acTL" ) {
+ $buf = fread( $fh, $chunk_size );
+ if ( !$buf || strlen( $buf ) < $chunk_size || $chunk_size < 4 ) {
+ throw new Exception( __METHOD__ . ": Read error" );
+ }
+
+ $actl = unpack( "Nframes/Nplays", $buf );
+ $frameCount = $actl['frames'];
+ $loopCount = $actl['plays'];
+ } elseif ( $chunk_type == "fcTL" ) {
+ $buf = self::read( $fh, $chunk_size );
+ if ( !$buf || strlen( $buf ) < $chunk_size ) {
+ throw new Exception( __METHOD__ . ": Read error" );
+ }
+ $buf = substr( $buf, 20 );
+ if ( strlen( $buf ) < 4 ) {
+ throw new Exception( __METHOD__ . ": Read error" );
+ }
+
+ $fctldur = unpack( "ndelay_num/ndelay_den", $buf );
+ if ( $fctldur['delay_den'] == 0 ) {
+ $fctldur['delay_den'] = 100;
+ }
+ if ( $fctldur['delay_num'] ) {
+ $duration += $fctldur['delay_num'] / $fctldur['delay_den'];
+ }
+ } elseif ( $chunk_type == "iTXt" ) {
+ // Extracts iTXt chunks, uncompressing if necessary.
+ $buf = self::read( $fh, $chunk_size );
+ $items = [];
+ if ( preg_match(
+ '/^([^\x00]{1,79})\x00(\x00|\x01)\x00([^\x00]*)(.)[^\x00]*\x00(.*)$/Ds',
+ $buf, $items )
+ ) {
+ /* $items[1] = text chunk name, $items[2] = compressed flag,
+ * $items[3] = lang code (or ""), $items[4]= compression type.
+ * $items[5] = content
+ */
+
+ // Theoretically should be case-sensitive, but in practise...
+ $items[1] = strtolower( $items[1] );
+ if ( !isset( self::$textChunks[$items[1]] ) ) {
+ // Only extract textual chunks on our list.
+ fseek( $fh, self::$crcSize, SEEK_CUR );
+ continue;
+ }
+
+ $items[3] = strtolower( $items[3] );
+ if ( $items[3] == '' ) {
+ // if no lang specified use x-default like in xmp.
+ $items[3] = 'x-default';
+ }
+
+ // if compressed
+ if ( $items[2] == "\x01" ) {
+ if ( function_exists( 'gzuncompress' ) && $items[4] === "\x00" ) {
+ MediaWiki\suppressWarnings();
+ $items[5] = gzuncompress( $items[5] );
+ MediaWiki\restoreWarnings();
+
+ if ( $items[5] === false ) {
+ // decompression failed
+ wfDebug( __METHOD__ . ' Error decompressing iTxt chunk - ' . $items[1] . "\n" );
+ fseek( $fh, self::$crcSize, SEEK_CUR );
+ continue;
+ }
+ } else {
+ wfDebug( __METHOD__ . ' Skipping compressed png iTXt chunk due to lack of zlib,'
+ . " or potentially invalid compression method\n" );
+ fseek( $fh, self::$crcSize, SEEK_CUR );
+ continue;
+ }
+ }
+ $finalKeyword = self::$textChunks[$items[1]];
+ $text[$finalKeyword][$items[3]] = $items[5];
+ $text[$finalKeyword]['_type'] = 'lang';
+ } else {
+ // Error reading iTXt chunk
+ throw new Exception( __METHOD__ . ": Read error on iTXt chunk" );
+ }
+ } elseif ( $chunk_type == 'tEXt' ) {
+ $buf = self::read( $fh, $chunk_size );
+
+ // In case there is no \x00 which will make explode fail.
+ if ( strpos( $buf, "\x00" ) === false ) {
+ throw new Exception( __METHOD__ . ": Read error on tEXt chunk" );
+ }
+
+ list( $keyword, $content ) = explode( "\x00", $buf, 2 );
+ if ( $keyword === '' || $content === '' ) {
+ throw new Exception( __METHOD__ . ": Read error on tEXt chunk" );
+ }
+
+ // Theoretically should be case-sensitive, but in practise...
+ $keyword = strtolower( $keyword );
+ if ( !isset( self::$textChunks[$keyword] ) ) {
+ // Don't recognize chunk, so skip.
+ fseek( $fh, self::$crcSize, SEEK_CUR );
+ continue;
+ }
+ MediaWiki\suppressWarnings();
+ $content = iconv( 'ISO-8859-1', 'UTF-8', $content );
+ MediaWiki\restoreWarnings();
+
+ if ( $content === false ) {
+ throw new Exception( __METHOD__ . ": Read error (error with iconv)" );
+ }
+
+ $finalKeyword = self::$textChunks[$keyword];
+ $text[$finalKeyword]['x-default'] = $content;
+ $text[$finalKeyword]['_type'] = 'lang';
+ } elseif ( $chunk_type == 'zTXt' ) {
+ if ( function_exists( 'gzuncompress' ) ) {
+ $buf = self::read( $fh, $chunk_size );
+
+ // In case there is no \x00 which will make explode fail.
+ if ( strpos( $buf, "\x00" ) === false ) {
+ throw new Exception( __METHOD__ . ": Read error on zTXt chunk" );
+ }
+
+ list( $keyword, $postKeyword ) = explode( "\x00", $buf, 2 );
+ if ( $keyword === '' || $postKeyword === '' ) {
+ throw new Exception( __METHOD__ . ": Read error on zTXt chunk" );
+ }
+ // Theoretically should be case-sensitive, but in practise...
+ $keyword = strtolower( $keyword );
+
+ if ( !isset( self::$textChunks[$keyword] ) ) {
+ // Don't recognize chunk, so skip.
+ fseek( $fh, self::$crcSize, SEEK_CUR );
+ continue;
+ }
+ $compression = substr( $postKeyword, 0, 1 );
+ $content = substr( $postKeyword, 1 );
+ if ( $compression !== "\x00" ) {
+ wfDebug( __METHOD__ . " Unrecognized compression method in zTXt ($keyword). Skipping.\n" );
+ fseek( $fh, self::$crcSize, SEEK_CUR );
+ continue;
+ }
+
+ MediaWiki\suppressWarnings();
+ $content = gzuncompress( $content );
+ MediaWiki\restoreWarnings();
+
+ if ( $content === false ) {
+ // decompression failed
+ wfDebug( __METHOD__ . ' Error decompressing zTXt chunk - ' . $keyword . "\n" );
+ fseek( $fh, self::$crcSize, SEEK_CUR );
+ continue;
+ }
+
+ MediaWiki\suppressWarnings();
+ $content = iconv( 'ISO-8859-1', 'UTF-8', $content );
+ MediaWiki\restoreWarnings();
+
+ if ( $content === false ) {
+ throw new Exception( __METHOD__ . ": Read error (error with iconv)" );
+ }
+
+ $finalKeyword = self::$textChunks[$keyword];
+ $text[$finalKeyword]['x-default'] = $content;
+ $text[$finalKeyword]['_type'] = 'lang';
+ } else {
+ wfDebug( __METHOD__ . " Cannot decompress zTXt chunk due to lack of zlib. Skipping.\n" );
+ fseek( $fh, $chunk_size, SEEK_CUR );
+ }
+ } elseif ( $chunk_type == 'tIME' ) {
+ // last mod timestamp.
+ if ( $chunk_size !== 7 ) {
+ throw new Exception( __METHOD__ . ": tIME wrong size" );
+ }
+ $buf = self::read( $fh, $chunk_size );
+ if ( !$buf || strlen( $buf ) < $chunk_size ) {
+ throw new Exception( __METHOD__ . ": Read error" );
+ }
+
+ // Note: spec says this should be UTC.
+ $t = unpack( "ny/Cm/Cd/Ch/Cmin/Cs", $buf );
+ $strTime = sprintf( "%04d%02d%02d%02d%02d%02d",
+ $t['y'], $t['m'], $t['d'], $t['h'],
+ $t['min'], $t['s'] );
+
+ $exifTime = wfTimestamp( TS_EXIF, $strTime );
+
+ if ( $exifTime ) {
+ $text['DateTime'] = $exifTime;
+ }
+ } elseif ( $chunk_type == 'pHYs' ) {
+ // how big pixels are (dots per meter).
+ if ( $chunk_size !== 9 ) {
+ throw new Exception( __METHOD__ . ": pHYs wrong size" );
+ }
+
+ $buf = self::read( $fh, $chunk_size );
+ if ( !$buf || strlen( $buf ) < $chunk_size ) {
+ throw new Exception( __METHOD__ . ": Read error" );
+ }
+
+ $dim = unpack( "Nwidth/Nheight/Cunit", $buf );
+ if ( $dim['unit'] == 1 ) {
+ // Need to check for negative because php
+ // doesn't deal with super-large unsigned 32-bit ints well
+ if ( $dim['width'] > 0 && $dim['height'] > 0 ) {
+ // unit is meters
+ // (as opposed to 0 = undefined )
+ $text['XResolution'] = $dim['width']
+ . '/100';
+ $text['YResolution'] = $dim['height']
+ . '/100';
+ $text['ResolutionUnit'] = 3;
+ // 3 = dots per cm (from Exif).
+ }
+ }
+ } elseif ( $chunk_type == "IEND" ) {
+ break;
+ } else {
+ fseek( $fh, $chunk_size, SEEK_CUR );
+ }
+ fseek( $fh, self::$crcSize, SEEK_CUR );
+ }
+ fclose( $fh );
+
+ if ( $loopCount > 1 ) {
+ $duration *= $loopCount;
+ }
+
+ if ( isset( $text['DateTimeDigitized'] ) ) {
+ // Convert date format from rfc2822 to exif.
+ foreach ( $text['DateTimeDigitized'] as $name => &$value ) {
+ if ( $name === '_type' ) {
+ continue;
+ }
+
+ // @todo FIXME: Currently timezones are ignored.
+ // possibly should be wfTimestamp's
+ // responsibility. (at least for numeric TZ)
+ $formatted = wfTimestamp( TS_EXIF, $value );
+ if ( $formatted ) {
+ // Only change if we could convert the
+ // date.
+ // The png standard says it should be
+ // in rfc2822 format, but not required.
+ // In general for the exif stuff we
+ // prettify the date if we can, but we
+ // display as-is if we cannot or if
+ // it is invalid.
+ // So do the same here.
+
+ $value = $formatted;
+ }
+ }
+ }
+
+ return [
+ 'frameCount' => $frameCount,
+ 'loopCount' => $loopCount,
+ 'duration' => $duration,
+ 'text' => $text,
+ 'bitDepth' => $bitDepth,
+ 'colorType' => $colorType,
+ ];
+ }
+
+ /**
+ * Read a chunk, checking to make sure its not too big.
+ *
+ * @param resource $fh The file handle
+ * @param int $size Size in bytes.
+ * @throws Exception If too big
+ * @return string The chunk.
+ */
+ private static function read( $fh, $size ) {
+ if ( $size > self::MAX_CHUNK_SIZE ) {
+ throw new Exception( __METHOD__ . ': Chunk size of ' . $size .
+ ' too big. Max size is: ' . self::MAX_CHUNK_SIZE );
+ }
+
+ return fread( $fh, $size );
+ }
+}
diff --git a/www/wiki/includes/media/SVG.php b/www/wiki/includes/media/SVG.php
new file mode 100644
index 00000000..bd78b49e
--- /dev/null
+++ b/www/wiki/includes/media/SVG.php
@@ -0,0 +1,565 @@
+<?php
+/**
+ * Handler for SVG images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+use Wikimedia\ScopedCallback;
+
+/**
+ * Handler for SVG images.
+ *
+ * @ingroup Media
+ */
+class SvgHandler extends ImageHandler {
+ const SVG_METADATA_VERSION = 2;
+
+ /** @var array A list of metadata tags that can be converted
+ * to the commonly used exif tags. This allows messages
+ * to be reused, and consistent tag names for {{#formatmetadata:..}}
+ */
+ private static $metaConversion = [
+ 'originalwidth' => 'ImageWidth',
+ 'originalheight' => 'ImageLength',
+ 'description' => 'ImageDescription',
+ 'title' => 'ObjectName',
+ ];
+
+ function isEnabled() {
+ global $wgSVGConverters, $wgSVGConverter;
+ if ( !isset( $wgSVGConverters[$wgSVGConverter] ) ) {
+ wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering.\n" );
+
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ public function mustRender( $file ) {
+ return true;
+ }
+
+ function isVectorized( $file ) {
+ return true;
+ }
+
+ /**
+ * @param File $file
+ * @return bool
+ */
+ function isAnimatedImage( $file ) {
+ # @todo Detect animated SVGs
+ $metadata = $file->getMetadata();
+ if ( $metadata ) {
+ $metadata = $this->unpackMetadata( $metadata );
+ if ( isset( $metadata['animated'] ) ) {
+ return $metadata['animated'];
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Which languages (systemLanguage attribute) is supported.
+ *
+ * @note This list is not guaranteed to be exhaustive.
+ * To avoid OOM errors, we only look at first bit of a file.
+ * Thus all languages on this list are present in the file,
+ * but its possible for the file to have a language not on
+ * this list.
+ *
+ * @param File $file
+ * @return array Array of language codes, or empty if no language switching supported.
+ */
+ public function getAvailableLanguages( File $file ) {
+ $metadata = $file->getMetadata();
+ $langList = [];
+ if ( $metadata ) {
+ $metadata = $this->unpackMetadata( $metadata );
+ if ( isset( $metadata['translations'] ) ) {
+ foreach ( $metadata['translations'] as $lang => $langType ) {
+ if ( $langType === SVGReader::LANG_FULL_MATCH ) {
+ $langList[] = $lang;
+ }
+ }
+ }
+ }
+ return $langList;
+ }
+
+ /**
+ * What language to render file in if none selected.
+ *
+ * @param File $file
+ * @return string Language code.
+ */
+ public function getDefaultRenderLanguage( File $file ) {
+ return 'en';
+ }
+
+ /**
+ * We do not support making animated svg thumbnails
+ * @param File $file
+ * @return bool
+ */
+ function canAnimateThumbnail( $file ) {
+ return false;
+ }
+
+ /**
+ * @param File $image
+ * @param array &$params
+ * @return bool
+ */
+ function normaliseParams( $image, &$params ) {
+ global $wgSVGMaxSize;
+ if ( !parent::normaliseParams( $image, $params ) ) {
+ return false;
+ }
+ # Don't make an image bigger than wgMaxSVGSize on the smaller side
+ if ( $params['physicalWidth'] <= $params['physicalHeight'] ) {
+ if ( $params['physicalWidth'] > $wgSVGMaxSize ) {
+ $srcWidth = $image->getWidth( $params['page'] );
+ $srcHeight = $image->getHeight( $params['page'] );
+ $params['physicalWidth'] = $wgSVGMaxSize;
+ $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $wgSVGMaxSize );
+ }
+ } else {
+ if ( $params['physicalHeight'] > $wgSVGMaxSize ) {
+ $srcWidth = $image->getWidth( $params['page'] );
+ $srcHeight = $image->getHeight( $params['page'] );
+ $params['physicalWidth'] = File::scaleHeight( $srcHeight, $srcWidth, $wgSVGMaxSize );
+ $params['physicalHeight'] = $wgSVGMaxSize;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @param File $image
+ * @param string $dstPath
+ * @param string $dstUrl
+ * @param array $params
+ * @param int $flags
+ * @return bool|MediaTransformError|ThumbnailImage|TransformParameterError
+ */
+ function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
+ if ( !$this->normaliseParams( $image, $params ) ) {
+ return new TransformParameterError( $params );
+ }
+ $clientWidth = $params['width'];
+ $clientHeight = $params['height'];
+ $physicalWidth = $params['physicalWidth'];
+ $physicalHeight = $params['physicalHeight'];
+ $lang = isset( $params['lang'] ) ? $params['lang'] : $this->getDefaultRenderLanguage( $image );
+
+ if ( $flags & self::TRANSFORM_LATER ) {
+ return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
+ }
+
+ $metadata = $this->unpackMetadata( $image->getMetadata() );
+ if ( isset( $metadata['error'] ) ) { // sanity check
+ $err = wfMessage( 'svg-long-error', $metadata['error']['message'] );
+
+ return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err );
+ }
+
+ if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
+ return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight,
+ wfMessage( 'thumbnail_dest_directory' ) );
+ }
+
+ $srcPath = $image->getLocalRefPath();
+ if ( $srcPath === false ) { // Failed to get local copy
+ wfDebugLog( 'thumbnail',
+ sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
+ wfHostname(), $image->getName() ) );
+
+ return new MediaTransformError( 'thumbnail_error',
+ $params['width'], $params['height'],
+ wfMessage( 'filemissing' )
+ );
+ }
+
+ // Make a temp dir with a symlink to the local copy in it.
+ // This plays well with rsvg-convert policy for external entities.
+ // https://git.gnome.org/browse/librsvg/commit/?id=f01aded72c38f0e18bc7ff67dee800e380251c8e
+ $tmpDir = wfTempDir() . '/svg_' . wfRandomString( 24 );
+ $lnPath = "$tmpDir/" . basename( $srcPath );
+ $ok = mkdir( $tmpDir, 0771 );
+ if ( !$ok ) {
+ wfDebugLog( 'thumbnail',
+ sprintf( 'Thumbnail failed on %s: could not create temporary directory %s',
+ wfHostname(), $tmpDir ) );
+ return new MediaTransformError( 'thumbnail_error',
+ $params['width'], $params['height'],
+ wfMessage( 'thumbnail-temp-create' )->text()
+ );
+ }
+ $ok = symlink( $srcPath, $lnPath );
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $cleaner = new ScopedCallback( function () use ( $tmpDir, $lnPath ) {
+ MediaWiki\suppressWarnings();
+ unlink( $lnPath );
+ rmdir( $tmpDir );
+ MediaWiki\restoreWarnings();
+ } );
+ if ( !$ok ) {
+ wfDebugLog( 'thumbnail',
+ sprintf( 'Thumbnail failed on %s: could not link %s to %s',
+ wfHostname(), $lnPath, $srcPath ) );
+ return new MediaTransformError( 'thumbnail_error',
+ $params['width'], $params['height'],
+ wfMessage( 'thumbnail-temp-create' )
+ );
+ }
+
+ $status = $this->rasterize( $lnPath, $dstPath, $physicalWidth, $physicalHeight, $lang );
+ if ( $status === true ) {
+ return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
+ } else {
+ return $status; // MediaTransformError
+ }
+ }
+
+ /**
+ * Transform an SVG file to PNG
+ * This function can be called outside of thumbnail contexts
+ * @param string $srcPath
+ * @param string $dstPath
+ * @param string $width
+ * @param string $height
+ * @param bool|string $lang Language code of the language to render the SVG in
+ * @throws MWException
+ * @return bool|MediaTransformError
+ */
+ public function rasterize( $srcPath, $dstPath, $width, $height, $lang = false ) {
+ global $wgSVGConverters, $wgSVGConverter, $wgSVGConverterPath;
+ $err = false;
+ $retval = '';
+ if ( isset( $wgSVGConverters[$wgSVGConverter] ) ) {
+ if ( is_array( $wgSVGConverters[$wgSVGConverter] ) ) {
+ // This is a PHP callable
+ $func = $wgSVGConverters[$wgSVGConverter][0];
+ $args = array_merge( [ $srcPath, $dstPath, $width, $height, $lang ],
+ array_slice( $wgSVGConverters[$wgSVGConverter], 1 ) );
+ if ( !is_callable( $func ) ) {
+ throw new MWException( "$func is not callable" );
+ }
+ $err = call_user_func_array( $func, $args );
+ $retval = (bool)$err;
+ } else {
+ // External command
+ $cmd = str_replace(
+ [ '$path/', '$width', '$height', '$input', '$output' ],
+ [ $wgSVGConverterPath ? wfEscapeShellArg( "$wgSVGConverterPath/" ) : "",
+ intval( $width ),
+ intval( $height ),
+ wfEscapeShellArg( $srcPath ),
+ wfEscapeShellArg( $dstPath ) ],
+ $wgSVGConverters[$wgSVGConverter]
+ );
+
+ $env = [];
+ if ( $lang !== false ) {
+ $env['LANG'] = $lang;
+ }
+
+ wfDebug( __METHOD__ . ": $cmd\n" );
+ $err = wfShellExecWithStderr( $cmd, $retval, $env );
+ }
+ }
+ $removed = $this->removeBadFile( $dstPath, $retval );
+ if ( $retval != 0 || $removed ) {
+ $this->logErrorForExternalProcess( $retval, $err, $cmd );
+ return new MediaTransformError( 'thumbnail_error', $width, $height, $err );
+ }
+
+ return true;
+ }
+
+ public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) {
+ $im = new Imagick( $srcPath );
+ $im->setImageFormat( 'png' );
+ $im->setBackgroundColor( 'transparent' );
+ $im->setImageDepth( 8 );
+
+ if ( !$im->thumbnailImage( intval( $width ), intval( $height ), /* fit */ false ) ) {
+ return 'Could not resize image';
+ }
+ if ( !$im->writeImage( $dstPath ) ) {
+ return "Could not write to $dstPath";
+ }
+ }
+
+ /**
+ * @param File|FSFile $file
+ * @param string $path Unused
+ * @param bool|array $metadata
+ * @return array
+ */
+ function getImageSize( $file, $path, $metadata = false ) {
+ if ( $metadata === false && $file instanceof File ) {
+ $metadata = $file->getMetadata();
+ }
+ $metadata = $this->unpackMetadata( $metadata );
+
+ if ( isset( $metadata['width'] ) && isset( $metadata['height'] ) ) {
+ return [ $metadata['width'], $metadata['height'], 'SVG',
+ "width=\"{$metadata['width']}\" height=\"{$metadata['height']}\"" ];
+ } else { // error
+ return [ 0, 0, 'SVG', "width=\"0\" height=\"0\"" ];
+ }
+ }
+
+ function getThumbType( $ext, $mime, $params = null ) {
+ return [ 'png', 'image/png' ];
+ }
+
+ /**
+ * Subtitle for the image. Different from the base
+ * class so it can be denoted that SVG's have
+ * a "nominal" resolution, and not a fixed one,
+ * as well as so animation can be denoted.
+ *
+ * @param File $file
+ * @return string
+ */
+ function getLongDesc( $file ) {
+ global $wgLang;
+
+ $metadata = $this->unpackMetadata( $file->getMetadata() );
+ if ( isset( $metadata['error'] ) ) {
+ return wfMessage( 'svg-long-error', $metadata['error']['message'] )->text();
+ }
+
+ $size = $wgLang->formatSize( $file->getSize() );
+
+ if ( $this->isAnimatedImage( $file ) ) {
+ $msg = wfMessage( 'svg-long-desc-animated' );
+ } else {
+ $msg = wfMessage( 'svg-long-desc' );
+ }
+
+ $msg->numParams( $file->getWidth(), $file->getHeight() )->params( $size );
+
+ return $msg->parse();
+ }
+
+ /**
+ * @param File|FSFile $file
+ * @param string $filename
+ * @return string Serialised metadata
+ */
+ function getMetadata( $file, $filename ) {
+ $metadata = [ 'version' => self::SVG_METADATA_VERSION ];
+ try {
+ $metadata += SVGMetadataExtractor::getMetadata( $filename );
+ } catch ( Exception $e ) { // @todo SVG specific exceptions
+ // File not found, broken, etc.
+ $metadata['error'] = [
+ 'message' => $e->getMessage(),
+ 'code' => $e->getCode()
+ ];
+ wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" );
+ }
+
+ return serialize( $metadata );
+ }
+
+ function unpackMetadata( $metadata ) {
+ MediaWiki\suppressWarnings();
+ $unser = unserialize( $metadata );
+ MediaWiki\restoreWarnings();
+ if ( isset( $unser['version'] ) && $unser['version'] == self::SVG_METADATA_VERSION ) {
+ return $unser;
+ } else {
+ return false;
+ }
+ }
+
+ function getMetadataType( $image ) {
+ return 'parsed-svg';
+ }
+
+ function isMetadataValid( $image, $metadata ) {
+ $meta = $this->unpackMetadata( $metadata );
+ if ( $meta === false ) {
+ return self::METADATA_BAD;
+ }
+ if ( !isset( $meta['originalWidth'] ) ) {
+ // Old but compatible
+ return self::METADATA_COMPATIBLE;
+ }
+
+ return self::METADATA_GOOD;
+ }
+
+ protected function visibleMetadataFields() {
+ $fields = [ 'objectname', 'imagedescription' ];
+
+ return $fields;
+ }
+
+ /**
+ * @param File $file
+ * @param bool|IContextSource $context Context to use (optional)
+ * @return array|bool
+ */
+ function formatMetadata( $file, $context = false ) {
+ $result = [
+ 'visible' => [],
+ 'collapsed' => []
+ ];
+ $metadata = $file->getMetadata();
+ if ( !$metadata ) {
+ return false;
+ }
+ $metadata = $this->unpackMetadata( $metadata );
+ if ( !$metadata || isset( $metadata['error'] ) ) {
+ return false;
+ }
+
+ /* @todo Add a formatter
+ $format = new FormatSVG( $metadata );
+ $formatted = $format->getFormattedData();
+ */
+
+ // Sort fields into visible and collapsed
+ $visibleFields = $this->visibleMetadataFields();
+
+ $showMeta = false;
+ foreach ( $metadata as $name => $value ) {
+ $tag = strtolower( $name );
+ if ( isset( self::$metaConversion[$tag] ) ) {
+ $tag = strtolower( self::$metaConversion[$tag] );
+ } else {
+ // Do not output other metadata not in list
+ continue;
+ }
+ $showMeta = true;
+ self::addMeta( $result,
+ in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed',
+ 'exif',
+ $tag,
+ $value
+ );
+ }
+
+ return $showMeta ? $result : false;
+ }
+
+ /**
+ * @param string $name Parameter name
+ * @param mixed $value Parameter value
+ * @return bool Validity
+ */
+ public function validateParam( $name, $value ) {
+ if ( in_array( $name, [ 'width', 'height' ] ) ) {
+ // Reject negative heights, widths
+ return ( $value > 0 );
+ } elseif ( $name == 'lang' ) {
+ // Validate $code
+ if ( $value === '' || !Language::isValidBuiltInCode( $value ) ) {
+ wfDebug( "Invalid user language code\n" );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ // Only lang, width and height are acceptable keys
+ return false;
+ }
+
+ /**
+ * @param array $params Name=>value pairs of parameters
+ * @return string Filename to use
+ */
+ public function makeParamString( $params ) {
+ $lang = '';
+ if ( isset( $params['lang'] ) && $params['lang'] !== 'en' ) {
+ $params['lang'] = strtolower( $params['lang'] );
+ $lang = "lang{$params['lang']}-";
+ }
+ if ( !isset( $params['width'] ) ) {
+ return false;
+ }
+
+ return "$lang{$params['width']}px";
+ }
+
+ public function parseParamString( $str ) {
+ $m = false;
+ if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/', $str, $m ) ) {
+ return [ 'width' => array_pop( $m ), 'lang' => $m[1] ];
+ } elseif ( preg_match( '/^(\d+)px$/', $str, $m ) ) {
+ return [ 'width' => $m[1], 'lang' => 'en' ];
+ } else {
+ return false;
+ }
+ }
+
+ public function getParamMap() {
+ return [ 'img_lang' => 'lang', 'img_width' => 'width' ];
+ }
+
+ /**
+ * @param array $params
+ * @return array
+ */
+ function getScriptParams( $params ) {
+ $scriptParams = [ 'width' => $params['width'] ];
+ if ( isset( $params['lang'] ) ) {
+ $scriptParams['lang'] = $params['lang'];
+ }
+
+ return $scriptParams;
+ }
+
+ public function getCommonMetaArray( File $file ) {
+ $metadata = $file->getMetadata();
+ if ( !$metadata ) {
+ return [];
+ }
+ $metadata = $this->unpackMetadata( $metadata );
+ if ( !$metadata || isset( $metadata['error'] ) ) {
+ return [];
+ }
+ $stdMetadata = [];
+ foreach ( $metadata as $name => $value ) {
+ $tag = strtolower( $name );
+ if ( $tag === 'originalwidth' || $tag === 'originalheight' ) {
+ // Skip these. In the exif metadata stuff, it is assumed these
+ // are measured in px, which is not the case here.
+ continue;
+ }
+ if ( isset( self::$metaConversion[$tag] ) ) {
+ $tag = self::$metaConversion[$tag];
+ $stdMetadata[$tag] = $value;
+ }
+ }
+
+ return $stdMetadata;
+ }
+}
diff --git a/www/wiki/includes/media/SVGMetadataExtractor.php b/www/wiki/includes/media/SVGMetadataExtractor.php
new file mode 100644
index 00000000..9b22cbee
--- /dev/null
+++ b/www/wiki/includes/media/SVGMetadataExtractor.php
@@ -0,0 +1,396 @@
+<?php
+/**
+ * Extraction of SVG image metadata.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ * @author "Derk-Jan Hartman <hartman _at_ videolan d0t org>"
+ * @author Brion Vibber
+ * @copyright Copyright © 2010-2010 Brion Vibber, Derk-Jan Hartman
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ */
+
+/**
+ * @ingroup Media
+ */
+class SVGMetadataExtractor {
+ static function getMetadata( $filename ) {
+ $svg = new SVGReader( $filename );
+
+ return $svg->getMetadata();
+ }
+}
+
+/**
+ * @ingroup Media
+ */
+class SVGReader {
+ const DEFAULT_WIDTH = 512;
+ const DEFAULT_HEIGHT = 512;
+ const NS_SVG = 'http://www.w3.org/2000/svg';
+ const LANG_PREFIX_MATCH = 1;
+ const LANG_FULL_MATCH = 2;
+
+ /** @var null|XMLReader */
+ private $reader = null;
+
+ /** @var bool */
+ private $mDebug = false;
+
+ /** @var array */
+ private $metadata = [];
+ private $languages = [];
+ private $languagePrefixes = [];
+
+ /**
+ * Creates an SVGReader drawing from the source provided
+ * @param string $source URI from which to read
+ * @throws MWException|Exception
+ */
+ function __construct( $source ) {
+ global $wgSVGMetadataCutoff;
+ $this->reader = new XMLReader();
+
+ // Don't use $file->getSize() since file object passed to SVGHandler::getMetadata is bogus.
+ $size = filesize( $source );
+ if ( $size === false ) {
+ throw new MWException( "Error getting filesize of SVG." );
+ }
+
+ if ( $size > $wgSVGMetadataCutoff ) {
+ $this->debug( "SVG is $size bytes, which is bigger than $wgSVGMetadataCutoff. Truncating." );
+ $contents = file_get_contents( $source, false, null, -1, $wgSVGMetadataCutoff );
+ if ( $contents === false ) {
+ throw new MWException( 'Error reading SVG file.' );
+ }
+ $this->reader->XML( $contents, null, LIBXML_NOERROR | LIBXML_NOWARNING );
+ } else {
+ $this->reader->open( $source, null, LIBXML_NOERROR | LIBXML_NOWARNING );
+ }
+
+ // Expand entities, since Adobe Illustrator uses them for xmlns
+ // attributes (T33719). Note that libxml2 has some protection
+ // against large recursive entity expansions so this is not as
+ // insecure as it might appear to be. However, it is still extremely
+ // insecure. It's necessary to wrap any read() calls with
+ // libxml_disable_entity_loader() to avoid arbitrary local file
+ // inclusion, or even arbitrary code execution if the expect
+ // extension is installed (T48859).
+ $oldDisable = libxml_disable_entity_loader( true );
+ $this->reader->setParserProperty( XMLReader::SUBST_ENTITIES, true );
+
+ $this->metadata['width'] = self::DEFAULT_WIDTH;
+ $this->metadata['height'] = self::DEFAULT_HEIGHT;
+
+ // The size in the units specified by the SVG file
+ // (for the metadata box)
+ // Per the SVG spec, if unspecified, default to '100%'
+ $this->metadata['originalWidth'] = '100%';
+ $this->metadata['originalHeight'] = '100%';
+
+ // Because we cut off the end of the svg making an invalid one. Complicated
+ // try catch thing to make sure warnings get restored. Seems like there should
+ // be a better way.
+ MediaWiki\suppressWarnings();
+ try {
+ $this->read();
+ } catch ( Exception $e ) {
+ // Note, if this happens, the width/height will be taken to be 0x0.
+ // Should we consider it the default 512x512 instead?
+ MediaWiki\restoreWarnings();
+ libxml_disable_entity_loader( $oldDisable );
+ throw $e;
+ }
+ MediaWiki\restoreWarnings();
+ libxml_disable_entity_loader( $oldDisable );
+ }
+
+ /**
+ * @return array Array with the known metadata
+ */
+ public function getMetadata() {
+ return $this->metadata;
+ }
+
+ /**
+ * Read the SVG
+ * @throws MWException
+ * @return bool
+ */
+ protected function read() {
+ $keepReading = $this->reader->read();
+
+ /* Skip until first element */
+ while ( $keepReading && $this->reader->nodeType != XMLReader::ELEMENT ) {
+ $keepReading = $this->reader->read();
+ }
+
+ if ( $this->reader->localName != 'svg' || $this->reader->namespaceURI != self::NS_SVG ) {
+ throw new MWException( "Expected <svg> tag, got " .
+ $this->reader->localName . " in NS " . $this->reader->namespaceURI );
+ }
+ $this->debug( "<svg> tag is correct." );
+ $this->handleSVGAttribs();
+
+ $exitDepth = $this->reader->depth;
+ $keepReading = $this->reader->read();
+ while ( $keepReading ) {
+ $tag = $this->reader->localName;
+ $type = $this->reader->nodeType;
+ $isSVG = ( $this->reader->namespaceURI == self::NS_SVG );
+
+ $this->debug( "$tag" );
+
+ if ( $isSVG && $tag == 'svg' && $type == XMLReader::END_ELEMENT
+ && $this->reader->depth <= $exitDepth
+ ) {
+ break;
+ } elseif ( $isSVG && $tag == 'title' ) {
+ $this->readField( $tag, 'title' );
+ } elseif ( $isSVG && $tag == 'desc' ) {
+ $this->readField( $tag, 'description' );
+ } elseif ( $isSVG && $tag == 'metadata' && $type == XMLReader::ELEMENT ) {
+ $this->readXml( $tag, 'metadata' );
+ } elseif ( $isSVG && $tag == 'script' ) {
+ // We normally do not allow scripted svgs.
+ // However its possible to configure MW to let them
+ // in, and such files should be considered animated.
+ $this->metadata['animated'] = true;
+ } elseif ( $tag !== '#text' ) {
+ $this->debug( "Unhandled top-level XML tag $tag" );
+
+ // Recurse into children of current tag, looking for animation and languages.
+ $this->animateFilterAndLang( $tag );
+ }
+
+ // Goto next element, which is sibling of current (Skip children).
+ $keepReading = $this->reader->next();
+ }
+
+ $this->reader->close();
+
+ $this->metadata['translations'] = $this->languages + $this->languagePrefixes;
+
+ return true;
+ }
+
+ /**
+ * Read a textelement from an element
+ *
+ * @param string $name Name of the element that we are reading from
+ * @param string $metafield Field that we will fill with the result
+ */
+ private function readField( $name, $metafield = null ) {
+ $this->debug( "Read field $metafield" );
+ if ( !$metafield || $this->reader->nodeType != XMLReader::ELEMENT ) {
+ return;
+ }
+ $keepReading = $this->reader->read();
+ while ( $keepReading ) {
+ if ( $this->reader->localName == $name
+ && $this->reader->namespaceURI == self::NS_SVG
+ && $this->reader->nodeType == XMLReader::END_ELEMENT
+ ) {
+ break;
+ } elseif ( $this->reader->nodeType == XMLReader::TEXT ) {
+ $this->metadata[$metafield] = trim( $this->reader->value );
+ }
+ $keepReading = $this->reader->read();
+ }
+ }
+
+ /**
+ * Read an XML snippet from an element
+ *
+ * @param string $metafield Field that we will fill with the result
+ * @throws MWException
+ */
+ private function readXml( $metafield = null ) {
+ $this->debug( "Read top level metadata" );
+ if ( !$metafield || $this->reader->nodeType != XMLReader::ELEMENT ) {
+ return;
+ }
+ // @todo Find and store type of xml snippet. metadata['metadataType'] = "rdf"
+ if ( method_exists( $this->reader, 'readInnerXML' ) ) {
+ $this->metadata[$metafield] = trim( $this->reader->readInnerXml() );
+ } else {
+ throw new MWException( "The PHP XMLReader extension does not come " .
+ "with readInnerXML() method. Your libxml is probably out of " .
+ "date (need 2.6.20 or later)." );
+ }
+ $this->reader->next();
+ }
+
+ /**
+ * Filter all children, looking for animated elements.
+ * Also get a list of languages that can be targeted.
+ *
+ * @param string $name Name of the element that we are reading from
+ */
+ private function animateFilterAndLang( $name ) {
+ $this->debug( "animate filter for tag $name" );
+ if ( $this->reader->nodeType != XMLReader::ELEMENT ) {
+ return;
+ }
+ if ( $this->reader->isEmptyElement ) {
+ return;
+ }
+ $exitDepth = $this->reader->depth;
+ $keepReading = $this->reader->read();
+ while ( $keepReading ) {
+ if ( $this->reader->localName == $name && $this->reader->depth <= $exitDepth
+ && $this->reader->nodeType == XMLReader::END_ELEMENT
+ ) {
+ break;
+ } elseif ( $this->reader->namespaceURI == self::NS_SVG
+ && $this->reader->nodeType == XMLReader::ELEMENT
+ ) {
+ $sysLang = $this->reader->getAttribute( 'systemLanguage' );
+ if ( !is_null( $sysLang ) && $sysLang !== '' ) {
+ // See https://www.w3.org/TR/SVG/struct.html#SystemLanguageAttribute
+ $langList = explode( ',', $sysLang );
+ foreach ( $langList as $langItem ) {
+ $langItem = trim( $langItem );
+ if ( Language::isWellFormedLanguageTag( $langItem ) ) {
+ $this->languages[$langItem] = self::LANG_FULL_MATCH;
+ }
+ // Note, the standard says that any prefix should work,
+ // here we do only the initial prefix, since that will catch
+ // 99% of cases, and we are going to compare against fallbacks.
+ // This differs mildly from how the spec says languages should be
+ // handled, however it matches better how the MediaWiki language
+ // preference is generally handled.
+ $dash = strpos( $langItem, '-' );
+ // Intentionally checking both !false and > 0 at the same time.
+ if ( $dash ) {
+ $itemPrefix = substr( $langItem, 0, $dash );
+ if ( Language::isWellFormedLanguageTag( $itemPrefix ) ) {
+ $this->languagePrefixes[$itemPrefix] = self::LANG_PREFIX_MATCH;
+ }
+ }
+ }
+ }
+ switch ( $this->reader->localName ) {
+ case 'script':
+ // Normally we disallow files with
+ // <script>, but its possible
+ // to configure MW to disable
+ // such checks.
+ case 'animate':
+ case 'set':
+ case 'animateMotion':
+ case 'animateColor':
+ case 'animateTransform':
+ $this->debug( "HOUSTON WE HAVE ANIMATION" );
+ $this->metadata['animated'] = true;
+ break;
+ }
+ }
+ $keepReading = $this->reader->read();
+ }
+ }
+
+ private function debug( $data ) {
+ if ( $this->mDebug ) {
+ wfDebug( "SVGReader: $data\n" );
+ }
+ }
+
+ /**
+ * Parse the attributes of an SVG element
+ *
+ * The parser has to be in the start element of "<svg>"
+ */
+ private function handleSVGAttribs() {
+ $defaultWidth = self::DEFAULT_WIDTH;
+ $defaultHeight = self::DEFAULT_HEIGHT;
+ $aspect = 1.0;
+ $width = null;
+ $height = null;
+
+ if ( $this->reader->getAttribute( 'viewBox' ) ) {
+ // min-x min-y width height
+ $viewBox = preg_split( '/\s+/', trim( $this->reader->getAttribute( 'viewBox' ) ) );
+ if ( count( $viewBox ) == 4 ) {
+ $viewWidth = $this->scaleSVGUnit( $viewBox[2] );
+ $viewHeight = $this->scaleSVGUnit( $viewBox[3] );
+ if ( $viewWidth > 0 && $viewHeight > 0 ) {
+ $aspect = $viewWidth / $viewHeight;
+ $defaultHeight = $defaultWidth / $aspect;
+ }
+ }
+ }
+ if ( $this->reader->getAttribute( 'width' ) ) {
+ $width = $this->scaleSVGUnit( $this->reader->getAttribute( 'width' ), $defaultWidth );
+ $this->metadata['originalWidth'] = $this->reader->getAttribute( 'width' );
+ }
+ if ( $this->reader->getAttribute( 'height' ) ) {
+ $height = $this->scaleSVGUnit( $this->reader->getAttribute( 'height' ), $defaultHeight );
+ $this->metadata['originalHeight'] = $this->reader->getAttribute( 'height' );
+ }
+
+ if ( !isset( $width ) && !isset( $height ) ) {
+ $width = $defaultWidth;
+ $height = $width / $aspect;
+ } elseif ( isset( $width ) && !isset( $height ) ) {
+ $height = $width / $aspect;
+ } elseif ( isset( $height ) && !isset( $width ) ) {
+ $width = $height * $aspect;
+ }
+
+ if ( $width > 0 && $height > 0 ) {
+ $this->metadata['width'] = intval( round( $width ) );
+ $this->metadata['height'] = intval( round( $height ) );
+ }
+ }
+
+ /**
+ * Return a rounded pixel equivalent for a labeled CSS/SVG length.
+ * https://www.w3.org/TR/SVG11/coords.html#Units
+ *
+ * @param string $length CSS/SVG length.
+ * @param float|int $viewportSize Optional scale for percentage units...
+ * @return float Length in pixels
+ */
+ static function scaleSVGUnit( $length, $viewportSize = 512 ) {
+ static $unitLength = [
+ 'px' => 1.0,
+ 'pt' => 1.25,
+ 'pc' => 15.0,
+ 'mm' => 3.543307,
+ 'cm' => 35.43307,
+ 'in' => 90.0,
+ 'em' => 16.0, // fake it?
+ 'ex' => 12.0, // fake it?
+ '' => 1.0, // "User units" pixels by default
+ ];
+ $matches = [];
+ if ( preg_match( '/^\s*(\d+(?:\.\d+)?)(em|ex|px|pt|pc|cm|mm|in|%|)\s*$/', $length, $matches ) ) {
+ $length = floatval( $matches[1] );
+ $unit = $matches[2];
+ if ( $unit == '%' ) {
+ return $length * 0.01 * $viewportSize;
+ } else {
+ return $length * $unitLength[$unit];
+ }
+ } else {
+ // Assume pixels
+ return floatval( $length );
+ }
+ }
+}
diff --git a/www/wiki/includes/media/Tiff.php b/www/wiki/includes/media/Tiff.php
new file mode 100644
index 00000000..f0f4cdad
--- /dev/null
+++ b/www/wiki/includes/media/Tiff.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * Handler for Tiff images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Handler for Tiff images.
+ *
+ * @ingroup Media
+ */
+class TiffHandler extends ExifBitmapHandler {
+ const EXPENSIVE_SIZE_LIMIT = 10485760; // TIFF files over 10M are considered expensive to thumbnail
+
+ /**
+ * Conversion to PNG for inline display can be disabled here...
+ * Note scaling should work with ImageMagick, but may not with GD scaling.
+ *
+ * Files pulled from an another MediaWiki instance via ForeignAPIRepo /
+ * InstantCommons will have thumbnails managed from the remote instance,
+ * so we can skip this check.
+ *
+ * @param File $file
+ * @return bool
+ */
+ public function canRender( $file ) {
+ global $wgTiffThumbnailType;
+
+ return (bool)$wgTiffThumbnailType
+ || $file->getRepo() instanceof ForeignAPIRepo;
+ }
+
+ /**
+ * Browsers don't support TIFF inline generally...
+ * For inline display, we need to convert to PNG.
+ *
+ * @param File $file
+ * @return bool
+ */
+ public function mustRender( $file ) {
+ return true;
+ }
+
+ /**
+ * @param string $ext
+ * @param string $mime
+ * @param array $params
+ * @return bool
+ */
+ function getThumbType( $ext, $mime, $params = null ) {
+ global $wgTiffThumbnailType;
+
+ return $wgTiffThumbnailType;
+ }
+
+ /**
+ * @param File|FSFile $image
+ * @param string $filename
+ * @throws MWException
+ * @return string
+ */
+ function getMetadata( $image, $filename ) {
+ global $wgShowEXIF;
+
+ if ( $wgShowEXIF ) {
+ try {
+ $meta = BitmapMetadataHandler::Tiff( $filename );
+ if ( !is_array( $meta ) ) {
+ // This should never happen, but doesn't hurt to be paranoid.
+ throw new MWException( 'Metadata array is not an array' );
+ }
+ $meta['MEDIAWIKI_EXIF_VERSION'] = Exif::version();
+
+ return serialize( $meta );
+ } catch ( Exception $e ) {
+ // BitmapMetadataHandler throws an exception in certain exceptional
+ // cases like if file does not exist.
+ wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" );
+
+ return ExifBitmapHandler::BROKEN_FILE;
+ }
+ } else {
+ return '';
+ }
+ }
+
+ public function isExpensiveToThumbnail( $file ) {
+ return $file->getSize() > static::EXPENSIVE_SIZE_LIMIT;
+ }
+}
diff --git a/www/wiki/includes/media/TransformationalImageHandler.php b/www/wiki/includes/media/TransformationalImageHandler.php
new file mode 100644
index 00000000..de438da2
--- /dev/null
+++ b/www/wiki/includes/media/TransformationalImageHandler.php
@@ -0,0 +1,623 @@
+<?php
+/**
+ * Base class for handlers which require transforming images in a
+ * similar way as BitmapHandler does.
+ *
+ * This was split from BitmapHandler on the basis that some extensions
+ * might want to work in a similar way to BitmapHandler, but for
+ * different formats.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Handler for images that need to be transformed
+ *
+ * @since 1.24
+ * @ingroup Media
+ */
+abstract class TransformationalImageHandler extends ImageHandler {
+ /**
+ * @param File $image
+ * @param array &$params Transform parameters. Entries with the keys 'width'
+ * and 'height' are the respective screen width and height, while the keys
+ * 'physicalWidth' and 'physicalHeight' indicate the thumbnail dimensions.
+ * @return bool
+ */
+ function normaliseParams( $image, &$params ) {
+ if ( !parent::normaliseParams( $image, $params ) ) {
+ return false;
+ }
+
+ # Obtain the source, pre-rotation dimensions
+ $srcWidth = $image->getWidth( $params['page'] );
+ $srcHeight = $image->getHeight( $params['page'] );
+
+ # Don't make an image bigger than the source
+ if ( $params['physicalWidth'] >= $srcWidth ) {
+ $params['physicalWidth'] = $srcWidth;
+ $params['physicalHeight'] = $srcHeight;
+
+ # Skip scaling limit checks if no scaling is required
+ # due to requested size being bigger than source.
+ if ( !$image->mustRender() ) {
+ return true;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Extracts the width/height if the image will be scaled before rotating
+ *
+ * This will match the physical size/aspect ratio of the original image
+ * prior to application of the rotation -- so for a portrait image that's
+ * stored as raw landscape with 90-degress rotation, the resulting size
+ * will be wider than it is tall.
+ *
+ * @param array $params Parameters as returned by normaliseParams
+ * @param int $rotation The rotation angle that will be applied
+ * @return array ($width, $height) array
+ */
+ public function extractPreRotationDimensions( $params, $rotation ) {
+ if ( $rotation == 90 || $rotation == 270 ) {
+ # We'll resize before rotation, so swap the dimensions again
+ $width = $params['physicalHeight'];
+ $height = $params['physicalWidth'];
+ } else {
+ $width = $params['physicalWidth'];
+ $height = $params['physicalHeight'];
+ }
+
+ return [ $width, $height ];
+ }
+
+ /**
+ * Create a thumbnail.
+ *
+ * This sets up various parameters, and then calls a helper method
+ * based on $this->getScalerType in order to scale the image.
+ *
+ * @param File $image
+ * @param string $dstPath
+ * @param string $dstUrl
+ * @param array $params
+ * @param int $flags
+ * @return MediaTransformError|ThumbnailImage|TransformParameterError
+ */
+ function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
+ if ( !$this->normaliseParams( $image, $params ) ) {
+ return new TransformParameterError( $params );
+ }
+
+ # Create a parameter array to pass to the scaler
+ $scalerParams = [
+ # The size to which the image will be resized
+ 'physicalWidth' => $params['physicalWidth'],
+ 'physicalHeight' => $params['physicalHeight'],
+ 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
+ # The size of the image on the page
+ 'clientWidth' => $params['width'],
+ 'clientHeight' => $params['height'],
+ # Comment as will be added to the Exif of the thumbnail
+ 'comment' => isset( $params['descriptionUrl'] )
+ ? "File source: {$params['descriptionUrl']}"
+ : '',
+ # Properties of the original image
+ 'srcWidth' => $image->getWidth(),
+ 'srcHeight' => $image->getHeight(),
+ 'mimeType' => $image->getMimeType(),
+ 'dstPath' => $dstPath,
+ 'dstUrl' => $dstUrl,
+ 'interlace' => isset( $params['interlace'] ) ? $params['interlace'] : false,
+ ];
+
+ if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
+ $scalerParams['quality'] = 30;
+ }
+
+ // For subclasses that might be paged.
+ if ( $image->isMultipage() && isset( $params['page'] ) ) {
+ $scalerParams['page'] = intval( $params['page'] );
+ }
+
+ # Determine scaler type
+ $scaler = $this->getScalerType( $dstPath );
+
+ if ( is_array( $scaler ) ) {
+ $scalerName = get_class( $scaler[0] );
+ } else {
+ $scalerName = $scaler;
+ }
+
+ wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
+ "thumbnail at $dstPath using scaler $scalerName\n" );
+
+ if ( !$image->mustRender() &&
+ $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
+ && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
+ && !isset( $scalerParams['quality'] )
+ ) {
+ # normaliseParams (or the user) wants us to return the unscaled image
+ wfDebug( __METHOD__ . ": returning unscaled image\n" );
+
+ return $this->getClientScalingThumbnailImage( $image, $scalerParams );
+ }
+
+ if ( $scaler == 'client' ) {
+ # Client-side image scaling, use the source URL
+ # Using the destination URL in a TRANSFORM_LATER request would be incorrect
+ return $this->getClientScalingThumbnailImage( $image, $scalerParams );
+ }
+
+ if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
+ global $wgMaxImageArea;
+ return new TransformTooBigImageAreaError( $params, $wgMaxImageArea );
+ }
+
+ if ( $flags & self::TRANSFORM_LATER ) {
+ wfDebug( __METHOD__ . ": Transforming later per flags.\n" );
+ $newParams = [
+ 'width' => $scalerParams['clientWidth'],
+ 'height' => $scalerParams['clientHeight']
+ ];
+ if ( isset( $params['quality'] ) ) {
+ $newParams['quality'] = $params['quality'];
+ }
+ if ( isset( $params['page'] ) && $params['page'] ) {
+ $newParams['page'] = $params['page'];
+ }
+ return new ThumbnailImage( $image, $dstUrl, false, $newParams );
+ }
+
+ # Try to make a target path for the thumbnail
+ if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
+ wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
+ "directory, falling back to client scaling\n" );
+
+ return $this->getClientScalingThumbnailImage( $image, $scalerParams );
+ }
+
+ # Transform functions and binaries need a FS source file
+ $thumbnailSource = $this->getThumbnailSource( $image, $params );
+
+ // If the source isn't the original, disable EXIF rotation because it's already been applied
+ if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
+ || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
+ $scalerParams['disableRotation'] = true;
+ }
+
+ $scalerParams['srcPath'] = $thumbnailSource['path'];
+ $scalerParams['srcWidth'] = $thumbnailSource['width'];
+ $scalerParams['srcHeight'] = $thumbnailSource['height'];
+
+ if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
+ wfDebugLog( 'thumbnail',
+ sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
+ wfHostname(), $image->getName() ) );
+
+ return new MediaTransformError( 'thumbnail_error',
+ $scalerParams['clientWidth'], $scalerParams['clientHeight'],
+ wfMessage( 'filemissing' )
+ );
+ }
+
+ # Try a hook. Called "Bitmap" for historical reasons.
+ /** @var MediaTransformOutput $mto */
+ $mto = null;
+ Hooks::run( 'BitmapHandlerTransform', [ $this, $image, &$scalerParams, &$mto ] );
+ if ( !is_null( $mto ) ) {
+ wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto\n" );
+ $scaler = 'hookaborted';
+ }
+
+ // $scaler will return a MediaTransformError on failure, or false on success.
+ // If the scaler is succesful, it will have created a thumbnail at the destination
+ // path.
+ if ( is_array( $scaler ) && is_callable( $scaler ) ) {
+ // Allow subclasses to specify their own rendering methods.
+ $err = call_user_func( $scaler, $image, $scalerParams );
+ } else {
+ switch ( $scaler ) {
+ case 'hookaborted':
+ # Handled by the hook above
+ $err = $mto->isError() ? $mto : false;
+ break;
+ case 'im':
+ $err = $this->transformImageMagick( $image, $scalerParams );
+ break;
+ case 'custom':
+ $err = $this->transformCustom( $image, $scalerParams );
+ break;
+ case 'imext':
+ $err = $this->transformImageMagickExt( $image, $scalerParams );
+ break;
+ case 'gd':
+ default:
+ $err = $this->transformGd( $image, $scalerParams );
+ break;
+ }
+ }
+
+ # Remove the file if a zero-byte thumbnail was created, or if there was an error
+ $removed = $this->removeBadFile( $dstPath, (bool)$err );
+ if ( $err ) {
+ # transform returned MediaTransforError
+ return $err;
+ } elseif ( $removed ) {
+ # Thumbnail was zero-byte and had to be removed
+ return new MediaTransformError( 'thumbnail_error',
+ $scalerParams['clientWidth'], $scalerParams['clientHeight'],
+ wfMessage( 'unknown-error' )
+ );
+ } elseif ( $mto ) {
+ return $mto;
+ } else {
+ $newParams = [
+ 'width' => $scalerParams['clientWidth'],
+ 'height' => $scalerParams['clientHeight']
+ ];
+ if ( isset( $params['quality'] ) ) {
+ $newParams['quality'] = $params['quality'];
+ }
+ if ( isset( $params['page'] ) && $params['page'] ) {
+ $newParams['page'] = $params['page'];
+ }
+ return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams );
+ }
+ }
+
+ /**
+ * Get the source file for the transform
+ *
+ * @param File $file
+ * @param array $params
+ * @return array Array with keys width, height and path.
+ */
+ protected function getThumbnailSource( $file, $params ) {
+ return $file->getThumbnailSource( $params );
+ }
+
+ /**
+ * Returns what sort of scaler type should be used.
+ *
+ * Values can be one of client, im, custom, gd, imext, or an array
+ * of object, method-name to call that specific method.
+ *
+ * If specifying a custom scaler command with [ Obj, method ],
+ * the method in question should take 2 parameters, a File object,
+ * and a $scalerParams array with various options (See doTransform
+ * for what is in $scalerParams). On error it should return a
+ * MediaTransformError object. On success it should return false,
+ * and simply make sure the thumbnail file is located at
+ * $scalerParams['dstPath'].
+ *
+ * If there is a problem with the output path, it returns "client"
+ * to do client side scaling.
+ *
+ * @param string $dstPath
+ * @param bool $checkDstPath Check that $dstPath is valid
+ * @return string|Callable One of client, im, custom, gd, imext, or a Callable array.
+ */
+ abstract protected function getScalerType( $dstPath, $checkDstPath = true );
+
+ /**
+ * Get a ThumbnailImage that respresents an image that will be scaled
+ * client side
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $scalerParams Array with scaler params
+ * @return ThumbnailImage
+ *
+ * @todo FIXME: No rotation support
+ */
+ protected function getClientScalingThumbnailImage( $image, $scalerParams ) {
+ $params = [
+ 'width' => $scalerParams['clientWidth'],
+ 'height' => $scalerParams['clientHeight']
+ ];
+
+ return new ThumbnailImage( $image, $image->getUrl(), null, $params );
+ }
+
+ /**
+ * Transform an image using ImageMagick
+ *
+ * This is a stub method. The real method is in BitmapHander.
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $params Array with scaler params
+ *
+ * @return MediaTransformError Error object if error occurred, false (=no error) otherwise
+ */
+ protected function transformImageMagick( $image, $params ) {
+ return $this->getMediaTransformError( $params, "Unimplemented" );
+ }
+
+ /**
+ * Transform an image using the Imagick PHP extension
+ *
+ * This is a stub method. The real method is in BitmapHander.
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $params Array with scaler params
+ *
+ * @return MediaTransformError Error object if error occurred, false (=no error) otherwise
+ */
+ protected function transformImageMagickExt( $image, $params ) {
+ return $this->getMediaTransformError( $params, "Unimplemented" );
+ }
+
+ /**
+ * Transform an image using a custom command
+ *
+ * This is a stub method. The real method is in BitmapHander.
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $params Array with scaler params
+ *
+ * @return MediaTransformError Error object if error occurred, false (=no error) otherwise
+ */
+ protected function transformCustom( $image, $params ) {
+ return $this->getMediaTransformError( $params, "Unimplemented" );
+ }
+
+ /**
+ * Get a MediaTransformError with error 'thumbnail_error'
+ *
+ * @param array $params Parameter array as passed to the transform* functions
+ * @param string $errMsg Error message
+ * @return MediaTransformError
+ */
+ public function getMediaTransformError( $params, $errMsg ) {
+ return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
+ $params['clientHeight'], $errMsg );
+ }
+
+ /**
+ * Transform an image using the built in GD library
+ *
+ * This is a stub method. The real method is in BitmapHander.
+ *
+ * @param File $image File associated with this thumbnail
+ * @param array $params Array with scaler params
+ *
+ * @return MediaTransformError Error object if error occurred, false (=no error) otherwise
+ */
+ protected function transformGd( $image, $params ) {
+ return $this->getMediaTransformError( $params, "Unimplemented" );
+ }
+
+ /**
+ * Escape a string for ImageMagick's property input (e.g. -set -comment)
+ * See InterpretImageProperties() in magick/property.c
+ * @param string $s
+ * @return string
+ */
+ function escapeMagickProperty( $s ) {
+ // Double the backslashes
+ $s = str_replace( '\\', '\\\\', $s );
+ // Double the percents
+ $s = str_replace( '%', '%%', $s );
+ // Escape initial - or @
+ if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
+ $s = '\\' . $s;
+ }
+
+ return $s;
+ }
+
+ /**
+ * Escape a string for ImageMagick's input filenames. See ExpandFilenames()
+ * and GetPathComponent() in magick/utility.c.
+ *
+ * This won't work with an initial ~ or @, so input files should be prefixed
+ * with the directory name.
+ *
+ * Glob character unescaping is broken in ImageMagick before 6.6.1-5, but
+ * it's broken in a way that doesn't involve trying to convert every file
+ * in a directory, so we're better off escaping and waiting for the bugfix
+ * to filter down to users.
+ *
+ * @param string $path The file path
+ * @param bool|string $scene The scene specification, or false if there is none
+ * @throws MWException
+ * @return string
+ */
+ function escapeMagickInput( $path, $scene = false ) {
+ # Die on initial metacharacters (caller should prepend path)
+ $firstChar = substr( $path, 0, 1 );
+ if ( $firstChar === '~' || $firstChar === '@' ) {
+ throw new MWException( __METHOD__ . ': cannot escape this path name' );
+ }
+
+ # Escape glob chars
+ $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
+
+ return $this->escapeMagickPath( $path, $scene );
+ }
+
+ /**
+ * Escape a string for ImageMagick's output filename. See
+ * InterpretImageFilename() in magick/image.c.
+ * @param string $path The file path
+ * @param bool|string $scene The scene specification, or false if there is none
+ * @return string
+ */
+ function escapeMagickOutput( $path, $scene = false ) {
+ $path = str_replace( '%', '%%', $path );
+
+ return $this->escapeMagickPath( $path, $scene );
+ }
+
+ /**
+ * Armour a string against ImageMagick's GetPathComponent(). This is a
+ * helper function for escapeMagickInput() and escapeMagickOutput().
+ *
+ * @param string $path The file path
+ * @param bool|string $scene The scene specification, or false if there is none
+ * @throws MWException
+ * @return string
+ */
+ protected function escapeMagickPath( $path, $scene = false ) {
+ # Die on format specifiers (other than drive letters). The regex is
+ # meant to match all the formats you get from "convert -list format"
+ if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
+ if ( wfIsWindows() && is_dir( $m[0] ) ) {
+ // OK, it's a drive letter
+ // ImageMagick has a similar exception, see IsMagickConflict()
+ } else {
+ throw new MWException( __METHOD__ . ': unexpected colon character in path name' );
+ }
+ }
+
+ # If there are square brackets, add a do-nothing scene specification
+ # to force a literal interpretation
+ if ( $scene === false ) {
+ if ( strpos( $path, '[' ) !== false ) {
+ $path .= '[0--1]';
+ }
+ } else {
+ $path .= "[$scene]";
+ }
+
+ return $path;
+ }
+
+ /**
+ * Retrieve the version of the installed ImageMagick
+ * You can use PHPs version_compare() to use this value
+ * Value is cached for one hour.
+ * @return string|bool Representing the IM version; false on error
+ */
+ protected function getMagickVersion() {
+ $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
+ $method = __METHOD__;
+ return $cache->getWithSetCallback(
+ 'imagemagick-version',
+ $cache::TTL_HOUR,
+ function () use ( $method ) {
+ global $wgImageMagickConvertCommand;
+
+ $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . ' -version';
+ wfDebug( $method . ": Running convert -version\n" );
+ $retval = '';
+ $return = wfShellExecWithStderr( $cmd, $retval );
+ $x = preg_match(
+ '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
+ );
+ if ( $x != 1 ) {
+ wfDebug( $method . ": ImageMagick version check failed\n" );
+ return false;
+ }
+
+ return $matches[1];
+ }
+ );
+ }
+
+ /**
+ * Returns whether the current scaler supports rotation.
+ *
+ * @since 1.24 No longer static
+ * @return bool
+ */
+ public function canRotate() {
+ return false;
+ }
+
+ /**
+ * Should we automatically rotate an image based on exif
+ *
+ * @since 1.24 No longer static
+ * @see $wgEnableAutoRotation
+ * @return bool Whether auto rotation is enabled
+ */
+ public function autoRotateEnabled() {
+ return false;
+ }
+
+ /**
+ * Rotate a thumbnail.
+ *
+ * This is a stub. See BitmapHandler::rotate.
+ *
+ * @param File $file
+ * @param array $params Rotate parameters.
+ * 'rotation' clockwise rotation in degrees, allowed are multiples of 90
+ * @since 1.24 Is non-static. From 1.21 it was static
+ * @return bool|MediaTransformError
+ */
+ public function rotate( $file, $params ) {
+ return new MediaTransformError( 'thumbnail_error', 0, 0,
+ static::class . ' rotation not implemented' );
+ }
+
+ /**
+ * Returns whether the file needs to be rendered. Returns true if the
+ * file requires rotation and we are able to rotate it.
+ *
+ * @param File $file
+ * @return bool
+ */
+ public function mustRender( $file ) {
+ return $this->canRotate() && $this->getRotation( $file ) != 0;
+ }
+
+ /**
+ * Check if the file is smaller than the maximum image area for thumbnailing.
+ *
+ * Runs the 'BitmapHandlerCheckImageArea' hook.
+ *
+ * @param File $file
+ * @param array &$params
+ * @return bool
+ * @since 1.25
+ */
+ public function isImageAreaOkForThumbnaling( $file, &$params ) {
+ global $wgMaxImageArea;
+
+ # For historical reasons, hook starts with BitmapHandler
+ $checkImageAreaHookResult = null;
+ Hooks::run(
+ 'BitmapHandlerCheckImageArea',
+ [ $file, &$params, &$checkImageAreaHookResult ]
+ );
+
+ if ( !is_null( $checkImageAreaHookResult ) ) {
+ // was set by hook, so return that value
+ return (bool)$checkImageAreaHookResult;
+ }
+
+ $srcWidth = $file->getWidth( $params['page'] );
+ $srcHeight = $file->getHeight( $params['page'] );
+
+ if ( $srcWidth * $srcHeight > $wgMaxImageArea
+ && !( $file->getMimeType() == 'image/jpeg'
+ && $this->getScalerType( false, false ) == 'im' )
+ ) {
+ # Only ImageMagick can efficiently downsize jpg images without loading
+ # the entire file in memory
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/www/wiki/includes/media/WebP.php b/www/wiki/includes/media/WebP.php
new file mode 100644
index 00000000..e23989df
--- /dev/null
+++ b/www/wiki/includes/media/WebP.php
@@ -0,0 +1,309 @@
+<?php
+/**
+ * Handler for Google's WebP format <https://developers.google.com/speed/webp/>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Handler for Google's WebP format <https://developers.google.com/speed/webp/>
+ *
+ * @ingroup Media
+ */
+class WebPHandler extends BitmapHandler {
+ const BROKEN_FILE = '0'; // value to store in img_metadata if error extracting metadata.
+ /**
+ * @var int Minimum chunk header size to be able to read all header types
+ */
+ const MINIMUM_CHUNK_HEADER_LENGTH = 18;
+ /**
+ * @var int version of the metadata stored in db records
+ */
+ const _MW_WEBP_VERSION = 1;
+
+ const VP8X_ICC = 32;
+ const VP8X_ALPHA = 16;
+ const VP8X_EXIF = 8;
+ const VP8X_XMP = 4;
+ const VP8X_ANIM = 2;
+
+ public function getMetadata( $image, $filename ) {
+ $parsedWebPData = self::extractMetadata( $filename );
+ if ( !$parsedWebPData ) {
+ return self::BROKEN_FILE;
+ }
+
+ $parsedWebPData['metadata']['_MW_WEBP_VERSION'] = self::_MW_WEBP_VERSION;
+ return serialize( $parsedWebPData );
+ }
+
+ public function getMetadataType( $image ) {
+ return 'parsed-webp';
+ }
+
+ public function isMetadataValid( $image, $metadata ) {
+ if ( $metadata === self::BROKEN_FILE ) {
+ // Do not repetitivly regenerate metadata on broken file.
+ return self::METADATA_GOOD;
+ }
+
+ MediaWiki\suppressWarnings();
+ $data = unserialize( $metadata );
+ MediaWiki\restoreWarnings();
+
+ if ( !$data || !is_array( $data ) ) {
+ wfDebug( __METHOD__ . " invalid WebP metadata\n" );
+
+ return self::METADATA_BAD;
+ }
+
+ if ( !isset( $data['metadata']['_MW_WEBP_VERSION'] )
+ || $data['metadata']['_MW_WEBP_VERSION'] != self::_MW_WEBP_VERSION
+ ) {
+ wfDebug( __METHOD__ . " old but compatible WebP metadata\n" );
+
+ return self::METADATA_COMPATIBLE;
+ }
+ return self::METADATA_GOOD;
+ }
+
+ /**
+ * Extracts the image size and WebP type from a file
+ *
+ * @param string $filename
+ * @return array|bool Header data array with entries 'compression', 'width' and 'height',
+ * where 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'. False if
+ * file is not a valid WebP file.
+ */
+ public static function extractMetadata( $filename ) {
+ wfDebugLog( 'WebP', __METHOD__ . ": Extracting metadata from $filename\n" );
+
+ $info = RiffExtractor::findChunksFromFile( $filename, 100 );
+ if ( $info === false ) {
+ wfDebugLog( 'WebP', __METHOD__ . ": Not a valid RIFF file\n" );
+ return false;
+ }
+
+ if ( $info['fourCC'] != 'WEBP' ) {
+ wfDebugLog( 'WebP', __METHOD__ . ': FourCC was not WEBP: ' .
+ bin2hex( $info['fourCC'] ) . " \n" );
+ return false;
+ }
+
+ $metadata = self::extractMetadataFromChunks( $info['chunks'], $filename );
+ if ( !$metadata ) {
+ wfDebugLog( 'WebP', __METHOD__ . ": No VP8 chunks found\n" );
+ return false;
+ }
+
+ return $metadata;
+ }
+
+ /**
+ * Extracts the image size and WebP type from a file based on the chunk list
+ * @param array $chunks Chunks as extracted by RiffExtractor
+ * @param string $filename
+ * @return array Header data array with entries 'compression', 'width' and 'height', where
+ * 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'
+ */
+ public static function extractMetadataFromChunks( $chunks, $filename ) {
+ $vp8Info = [];
+
+ foreach ( $chunks as $chunk ) {
+ if ( !in_array( $chunk['fourCC'], [ 'VP8 ', 'VP8L', 'VP8X' ] ) ) {
+ // Not a chunk containing interesting metadata
+ continue;
+ }
+
+ $chunkHeader = file_get_contents( $filename, false, null,
+ $chunk['start'], self::MINIMUM_CHUNK_HEADER_LENGTH );
+ wfDebugLog( 'WebP', __METHOD__ . ": {$chunk['fourCC']}\n" );
+
+ switch ( $chunk['fourCC'] ) {
+ case 'VP8 ':
+ return array_merge( $vp8Info,
+ self::decodeLossyChunkHeader( $chunkHeader ) );
+ case 'VP8L':
+ return array_merge( $vp8Info,
+ self::decodeLosslessChunkHeader( $chunkHeader ) );
+ case 'VP8X':
+ $vp8Info = array_merge( $vp8Info,
+ self::decodeExtendedChunkHeader( $chunkHeader ) );
+ // Continue looking for other chunks to improve the metadata
+ break;
+ }
+ }
+ return $vp8Info;
+ }
+
+ /**
+ * Decodes a lossy chunk header
+ * @param string $header Header string
+ * @return bool|array See WebPHandler::decodeHeader
+ */
+ protected static function decodeLossyChunkHeader( $header ) {
+ // Bytes 0-3 are 'VP8 '
+ // Bytes 4-7 are the VP8 stream size
+ // Bytes 8-10 are the frame tag
+ // Bytes 11-13 are 0x9D 0x01 0x2A called the sync code
+ $syncCode = substr( $header, 11, 3 );
+ if ( $syncCode != "\x9D\x01\x2A" ) {
+ wfDebugLog( 'WebP', __METHOD__ . ': Invalid sync code: ' .
+ bin2hex( $syncCode ) . "\n" );
+ return [];
+ }
+ // Bytes 14-17 are image size
+ $imageSize = unpack( 'v2', substr( $header, 14, 4 ) );
+ // Image sizes are 14 bit, 2 MSB are scaling parameters which are ignored here
+ return [
+ 'compression' => 'lossy',
+ 'width' => $imageSize[1] & 0x3FFF,
+ 'height' => $imageSize[2] & 0x3FFF
+ ];
+ }
+
+ /**
+ * Decodes a lossless chunk header
+ * @param string $header Header string
+ * @return bool|array See WebPHandler::decodeHeader
+ */
+ public static function decodeLosslessChunkHeader( $header ) {
+ // Bytes 0-3 are 'VP8L'
+ // Bytes 4-7 are chunk stream size
+ // Byte 8 is 0x2F called the signature
+ if ( $header{8} != "\x2F" ) {
+ wfDebugLog( 'WebP', __METHOD__ . ': Invalid signature: ' .
+ bin2hex( $header{8} ) . "\n" );
+ return [];
+ }
+ // Bytes 9-12 contain the image size
+ // Bits 0-13 are width-1; bits 15-27 are height-1
+ $imageSize = unpack( 'C4', substr( $header, 9, 4 ) );
+ return [
+ 'compression' => 'lossless',
+ 'width' => ( $imageSize[1] | ( ( $imageSize[2] & 0x3F ) << 8 ) ) + 1,
+ 'height' => ( ( ( $imageSize[2] & 0xC0 ) >> 6 ) |
+ ( $imageSize[3] << 2 ) | ( ( $imageSize[4] & 0x03 ) << 10 ) ) + 1
+ ];
+ }
+
+ /**
+ * Decodes an extended chunk header
+ * @param string $header Header string
+ * @return bool|array See WebPHandler::decodeHeader
+ */
+ public static function decodeExtendedChunkHeader( $header ) {
+ // Bytes 0-3 are 'VP8X'
+ // Byte 4-7 are chunk length
+ // Byte 8-11 are a flag bytes
+ $flags = unpack( 'c', substr( $header, 8, 1 ) );
+
+ // Byte 12-17 are image size (24 bits)
+ $width = unpack( 'V', substr( $header, 12, 3 ) . "\x00" );
+ $height = unpack( 'V', substr( $header, 15, 3 ) . "\x00" );
+
+ return [
+ 'compression' => 'unknown',
+ 'animated' => ( $flags[1] & self::VP8X_ANIM ) == self::VP8X_ANIM,
+ 'transparency' => ( $flags[1] & self::VP8X_ALPHA ) == self::VP8X_ALPHA,
+ 'width' => ( $width[1] & 0xFFFFFF ) + 1,
+ 'height' => ( $height[1] & 0xFFFFFF ) + 1
+ ];
+ }
+
+ public function getImageSize( $file, $path, $metadata = false ) {
+ if ( $file === null ) {
+ $metadata = self::getMetadata( $file, $path );
+ }
+ if ( $metadata === false && $file instanceof File ) {
+ $metadata = $file->getMetadata();
+ }
+
+ MediaWiki\suppressWarnings();
+ $metadata = unserialize( $metadata );
+ MediaWiki\restoreWarnings();
+
+ if ( $metadata == false ) {
+ return false;
+ }
+ return [ $metadata['width'], $metadata['height'] ];
+ }
+
+ /**
+ * @param File $file
+ * @return bool True, not all browsers support WebP
+ */
+ public function mustRender( $file ) {
+ return true;
+ }
+
+ /**
+ * @param File $file
+ * @return bool False if we are unable to render this image
+ */
+ public function canRender( $file ) {
+ if ( self::isAnimatedImage( $file ) ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @param File $image
+ * @return bool
+ */
+ public function isAnimatedImage( $image ) {
+ $ser = $image->getMetadata();
+ if ( $ser ) {
+ $metadata = unserialize( $ser );
+ if ( isset( $metadata['animated'] ) && $metadata['animated'] === true ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function canAnimateThumbnail( $file ) {
+ return false;
+ }
+
+ /**
+ * Render files as PNG
+ *
+ * @param string $ext
+ * @param string $mime
+ * @param array|null $params
+ * @return array
+ */
+ public function getThumbType( $ext, $mime, $params = null ) {
+ return [ 'png', 'image/png' ];
+ }
+
+ /**
+ * Must use "im" for XCF
+ *
+ * @param string $dstPath
+ * @param bool $checkDstPath
+ * @return string
+ */
+ protected function getScalerType( $dstPath, $checkDstPath = true ) {
+ return 'im';
+ }
+}
diff --git a/www/wiki/includes/media/XCF.php b/www/wiki/includes/media/XCF.php
new file mode 100644
index 00000000..c4195240
--- /dev/null
+++ b/www/wiki/includes/media/XCF.php
@@ -0,0 +1,229 @@
+<?php
+/**
+ * Handler for the Gimp's native file format (XCF)
+ *
+ * Overview:
+ * https://en.wikipedia.org/wiki/XCF_(file_format)
+ * Specification in Gnome repository:
+ * http://svn.gnome.org/viewvc/gimp/trunk/devel-docs/xcf.txt?view=markup
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * Handler for the Gimp's native file format; getimagesize() doesn't
+ * support these files
+ *
+ * @ingroup Media
+ */
+class XCFHandler extends BitmapHandler {
+ /**
+ * @param File $file
+ * @return bool
+ */
+ public function mustRender( $file ) {
+ return true;
+ }
+
+ /**
+ * Render files as PNG
+ *
+ * @param string $ext
+ * @param string $mime
+ * @param array $params
+ * @return array
+ */
+ function getThumbType( $ext, $mime, $params = null ) {
+ return [ 'png', 'image/png' ];
+ }
+
+ /**
+ * Get width and height from the XCF header.
+ *
+ * @param File|FSFile $image
+ * @param string $filename
+ * @return array
+ */
+ function getImageSize( $image, $filename ) {
+ $header = self::getXCFMetaData( $filename );
+ if ( !$header ) {
+ return false;
+ }
+
+ # Forge a return array containing metadata information just like getimagesize()
+ # See PHP documentation at: https://secure.php.net/getimagesize
+ return [
+ 0 => $header['width'],
+ 1 => $header['height'],
+ 2 => null, # IMAGETYPE constant, none exist for XCF.
+ 3 => "height=\"{$header['height']}\" width=\"{$header['width']}\"",
+ 'mime' => 'image/x-xcf',
+ 'channels' => null,
+ 'bits' => 8, # Always 8-bits per color
+ ];
+ }
+
+ /**
+ * Metadata for a given XCF file
+ *
+ * Will return false if file magic signature is not recognized
+ * @author Hexmode
+ * @author Hashar
+ *
+ * @param string $filename Full path to a XCF file
+ * @return bool|array Metadata Array just like PHP getimagesize()
+ */
+ static function getXCFMetaData( $filename ) {
+ # Decode master structure
+ $f = fopen( $filename, 'rb' );
+ if ( !$f ) {
+ return false;
+ }
+ # The image structure always starts at offset 0 in the XCF file.
+ # So we just read it :-)
+ $binaryHeader = fread( $f, 26 );
+ fclose( $f );
+
+ /**
+ * Master image structure:
+ *
+ * byte[9] "gimp xcf " File type magic
+ * byte[4] version XCF version
+ * "file" - version 0
+ * "v001" - version 1
+ * "v002" - version 2
+ * byte 0 Zero-terminator for version tag
+ * uint32 width With of canvas
+ * uint32 height Height of canvas
+ * uint32 base_type Color mode of the image; one of
+ * 0: RGB color
+ * 1: Grayscale
+ * 2: Indexed color
+ * (enum GimpImageBaseType in libgimpbase/gimpbaseenums.h)
+ */
+ try {
+ $header = wfUnpack(
+ "A9magic" . # A: space padded
+ "/a5version" . # a: zero padded
+ "/Nwidth" . # \
+ "/Nheight" . # N: unsigned long 32bit big endian
+ "/Nbase_type", # /
+ $binaryHeader
+ );
+ } catch ( Exception $mwe ) {
+ return false;
+ }
+
+ # Check values
+ if ( $header['magic'] !== 'gimp xcf' ) {
+ wfDebug( __METHOD__ . " '$filename' has invalid magic signature.\n" );
+
+ return false;
+ }
+ # TODO: we might want to check for sane values of width and height
+
+ wfDebug( __METHOD__ .
+ ": canvas size of '$filename' is {$header['width']} x {$header['height']} px\n" );
+
+ return $header;
+ }
+
+ /**
+ * Store the channel type
+ *
+ * Greyscale files need different command line options.
+ *
+ * @param File|FSFile $file The image object, or false if there isn't one.
+ * Warning, FSFile::getPropsFromPath might pass an (object)array() instead (!)
+ * @param string $filename The filename
+ * @return string
+ */
+ public function getMetadata( $file, $filename ) {
+ $header = self::getXCFMetaData( $filename );
+ $metadata = [];
+ if ( $header ) {
+ // Try to be consistent with the names used by PNG files.
+ // Unclear from base media type if it has an alpha layer,
+ // so just assume that it does since it "potentially" could.
+ switch ( $header['base_type'] ) {
+ case 0:
+ $metadata['colorType'] = 'truecolour-alpha';
+ break;
+ case 1:
+ $metadata['colorType'] = 'greyscale-alpha';
+ break;
+ case 2:
+ $metadata['colorType'] = 'index-coloured';
+ break;
+ default:
+ $metadata['colorType'] = 'unknown';
+
+ }
+ } else {
+ // Marker to prevent repeated attempted extraction
+ $metadata['error'] = true;
+ }
+ return serialize( $metadata );
+ }
+
+ /**
+ * Should we refresh the metadata
+ *
+ * @param File $file The file object for the file in question
+ * @param string $metadata Serialized metadata
+ * @return bool One of the self::METADATA_(BAD|GOOD|COMPATIBLE) constants
+ */
+ public function isMetadataValid( $file, $metadata ) {
+ if ( !$metadata ) {
+ // Old metadata when we just put an empty string in there
+ return self::METADATA_BAD;
+ } else {
+ return self::METADATA_GOOD;
+ }
+ }
+
+ /**
+ * Must use "im" for XCF
+ *
+ * @param string $dstPath
+ * @param bool $checkDstPath
+ * @return string
+ */
+ protected function getScalerType( $dstPath, $checkDstPath = true ) {
+ return "im";
+ }
+
+ /**
+ * Can we render this file?
+ *
+ * Image magick doesn't support indexed xcf files as of current
+ * writing (as of 6.8.9-3)
+ * @param File $file
+ * @return bool
+ */
+ public function canRender( $file ) {
+ MediaWiki\suppressWarnings();
+ $xcfMeta = unserialize( $file->getMetadata() );
+ MediaWiki\restoreWarnings();
+ if ( isset( $xcfMeta['colorType'] ) && $xcfMeta['colorType'] === 'index-coloured' ) {
+ return false;
+ }
+ return parent::canRender( $file );
+ }
+}
diff --git a/www/wiki/includes/media/XMP.php b/www/wiki/includes/media/XMP.php
new file mode 100644
index 00000000..70f67b78
--- /dev/null
+++ b/www/wiki/includes/media/XMP.php
@@ -0,0 +1,1383 @@
+<?php
+/**
+ * Reader for XMP data containing properties relevant to images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * Class for reading xmp data containing properties relevant to
+ * images, and spitting out an array that FormatMetadata accepts.
+ *
+ * Note, this is not meant to recognize every possible thing you can
+ * encode in XMP. It should recognize all the properties we want.
+ * For example it doesn't have support for structures with multiple
+ * nesting levels, as none of the properties we're supporting use that
+ * feature. If it comes across properties it doesn't recognize, it should
+ * ignore them.
+ *
+ * The public methods one would call in this class are
+ * - parse( $content )
+ * Reads in xmp content.
+ * Can potentially be called multiple times with partial data each time.
+ * - parseExtended( $content )
+ * Reads XMPExtended blocks (jpeg files only).
+ * - getResults
+ * Outputs a results array.
+ *
+ * Note XMP kind of looks like rdf. They are not the same thing - XMP is
+ * encoded as a specific subset of rdf. This class can read XMP. It cannot
+ * read rdf.
+ *
+ */
+class XMPReader implements LoggerAwareInterface {
+ /** @var array XMP item configuration array */
+ protected $items;
+
+ /** @var array Array to hold the current element (and previous element, and so on) */
+ private $curItem = [];
+
+ /** @var bool|string The structure name when processing nested structures. */
+ private $ancestorStruct = false;
+
+ /** @var bool|string Temporary holder for character data that appears in xmp doc. */
+ private $charContent = false;
+
+ /** @var array Stores the state the xmpreader is in (see MODE_FOO constants) */
+ private $mode = [];
+
+ /** @var array Array to hold results */
+ private $results = [];
+
+ /** @var bool If we're doing a seq or bag. */
+ private $processingArray = false;
+
+ /** @var bool|string Used for lang alts only */
+ private $itemLang = false;
+
+ /** @var resource A resource handle for the XML parser */
+ private $xmlParser;
+
+ /** @var bool|string Character set like 'UTF-8' */
+ private $charset = false;
+
+ /** @var int */
+ private $extendedXMPOffset = 0;
+
+ /** @var int Flag determining if the XMP is safe to parse **/
+ private $parsable = 0;
+
+ /** @var string Buffer of XML to parse **/
+ private $xmlParsableBuffer = '';
+
+ /**
+ * These are various mode constants.
+ * they are used to figure out what to do
+ * with an element when its encountered.
+ *
+ * For example, MODE_IGNORE is used when processing
+ * a property we're not interested in. So if a new
+ * element pops up when we're in that mode, we ignore it.
+ */
+ const MODE_INITIAL = 0;
+ const MODE_IGNORE = 1;
+ const MODE_LI = 2;
+ const MODE_LI_LANG = 3;
+ const MODE_QDESC = 4;
+
+ // The following MODE constants are also used in the
+ // $items array to denote what type of property the item is.
+ const MODE_SIMPLE = 10;
+ const MODE_STRUCT = 11; // structure (associative array)
+ const MODE_SEQ = 12; // ordered list
+ const MODE_BAG = 13; // unordered list
+ const MODE_LANG = 14;
+ const MODE_ALT = 15; // non-language alt. Currently not implemented, and not needed atm.
+ const MODE_BAGSTRUCT = 16; // A BAG of Structs.
+
+ const NS_RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
+ const NS_XML = 'http://www.w3.org/XML/1998/namespace';
+
+ // States used while determining if XML is safe to parse
+ const PARSABLE_UNKNOWN = 0;
+ const PARSABLE_OK = 1;
+ const PARSABLE_BUFFERING = 2;
+ const PARSABLE_NO = 3;
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ /**
+ * Constructor.
+ *
+ * Primary job is to initialize the XMLParser
+ */
+ function __construct( LoggerInterface $logger = null ) {
+
+ if ( !function_exists( 'xml_parser_create_ns' ) ) {
+ // this should already be checked by this point
+ throw new RuntimeException( 'XMP support requires XML Parser' );
+ }
+ if ( $logger ) {
+ $this->setLogger( $logger );
+ } else {
+ $this->setLogger( new NullLogger() );
+ }
+
+ $this->items = XMPInfo::getItems();
+
+ $this->resetXMLParser();
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * free the XML parser.
+ *
+ * @note It is unclear to me if we really need to do this ourselves
+ * or if php garbage collection will automatically free the xmlParser
+ * when it is no longer needed.
+ */
+ private function destroyXMLParser() {
+ if ( $this->xmlParser ) {
+ xml_parser_free( $this->xmlParser );
+ $this->xmlParser = null;
+ }
+ }
+
+ /**
+ * Main use is if a single item has multiple xmp documents describing it.
+ * For example in jpeg's with extendedXMP
+ */
+ private function resetXMLParser() {
+
+ $this->destroyXMLParser();
+
+ $this->xmlParser = xml_parser_create_ns( 'UTF-8', ' ' );
+ xml_parser_set_option( $this->xmlParser, XML_OPTION_CASE_FOLDING, 0 );
+ xml_parser_set_option( $this->xmlParser, XML_OPTION_SKIP_WHITE, 1 );
+
+ xml_set_element_handler( $this->xmlParser,
+ [ $this, 'startElement' ],
+ [ $this, 'endElement' ] );
+
+ xml_set_character_data_handler( $this->xmlParser, [ $this, 'char' ] );
+
+ $this->parsable = self::PARSABLE_UNKNOWN;
+ $this->xmlParsableBuffer = '';
+ }
+
+ /**
+ * Check if this instance supports using this class
+ */
+ public static function isSupported() {
+ return function_exists( 'xml_parser_create_ns' ) && class_exists( 'XMLReader' );
+ }
+
+ /** Get the result array. Do some post-processing before returning
+ * the array, and transform any metadata that is special-cased.
+ *
+ * @return array Array of results as an array of arrays suitable for
+ * FormatMetadata::getFormattedData().
+ */
+ public function getResults() {
+ // xmp-special is for metadata that affects how stuff
+ // is extracted. For example xmpNote:HasExtendedXMP.
+
+ // It is also used to handle photoshop:AuthorsPosition
+ // which is weird and really part of another property,
+ // see 2:85 in IPTC. See also pg 21 of IPTC4XMP standard.
+ // The location fields also use it.
+
+ $data = $this->results;
+
+ if ( isset( $data['xmp-special']['AuthorsPosition'] )
+ && is_string( $data['xmp-special']['AuthorsPosition'] )
+ && isset( $data['xmp-general']['Artist'][0] )
+ ) {
+ // Note, if there is more than one creator,
+ // this only applies to first. This also will
+ // only apply to the dc:Creator prop, not the
+ // exif:Artist prop.
+
+ $data['xmp-general']['Artist'][0] =
+ $data['xmp-special']['AuthorsPosition'] . ', '
+ . $data['xmp-general']['Artist'][0];
+ }
+
+ // Go through the LocationShown and LocationCreated
+ // changing it to the non-hierarchal form used by
+ // the other location fields.
+
+ if ( isset( $data['xmp-special']['LocationShown'][0] )
+ && is_array( $data['xmp-special']['LocationShown'][0] )
+ ) {
+ // the is_array is just paranoia. It should always
+ // be an array.
+ foreach ( $data['xmp-special']['LocationShown'] as $loc ) {
+ if ( !is_array( $loc ) ) {
+ // To avoid copying over the _type meta-fields.
+ continue;
+ }
+ foreach ( $loc as $field => $val ) {
+ $data['xmp-general'][$field . 'Dest'][] = $val;
+ }
+ }
+ }
+ if ( isset( $data['xmp-special']['LocationCreated'][0] )
+ && is_array( $data['xmp-special']['LocationCreated'][0] )
+ ) {
+ // the is_array is just paranoia. It should always
+ // be an array.
+ foreach ( $data['xmp-special']['LocationCreated'] as $loc ) {
+ if ( !is_array( $loc ) ) {
+ // To avoid copying over the _type meta-fields.
+ continue;
+ }
+ foreach ( $loc as $field => $val ) {
+ $data['xmp-general'][$field . 'Created'][] = $val;
+ }
+ }
+ }
+
+ // We don't want to return the special values, since they're
+ // special and not info to be stored about the file.
+ unset( $data['xmp-special'] );
+
+ // Convert GPSAltitude to negative if below sea level.
+ if ( isset( $data['xmp-exif']['GPSAltitudeRef'] )
+ && isset( $data['xmp-exif']['GPSAltitude'] )
+ ) {
+
+ // Must convert to a real before multiplying by -1
+ // XMPValidate guarantees there will always be a '/' in this value.
+ list( $nom, $denom ) = explode( '/', $data['xmp-exif']['GPSAltitude'] );
+ $data['xmp-exif']['GPSAltitude'] = $nom / $denom;
+
+ if ( $data['xmp-exif']['GPSAltitudeRef'] == '1' ) {
+ $data['xmp-exif']['GPSAltitude'] *= -1;
+ }
+ unset( $data['xmp-exif']['GPSAltitudeRef'] );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Main function to call to parse XMP. Use getResults to
+ * get results.
+ *
+ * Also catches any errors during processing, writes them to
+ * debug log, blanks result array and returns false.
+ *
+ * @param string $content XMP data
+ * @param bool $allOfIt If this is all the data (true) or if its split up (false). Default true
+ * @throws RuntimeException
+ * @return bool Success.
+ */
+ public function parse( $content, $allOfIt = true ) {
+ if ( !$this->xmlParser ) {
+ $this->resetXMLParser();
+ }
+ try {
+
+ // detect encoding by looking for BOM which is supposed to be in processing instruction.
+ // see page 12 of http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart3.pdf
+ if ( !$this->charset ) {
+ $bom = [];
+ if ( preg_match( '/\xEF\xBB\xBF|\xFE\xFF|\x00\x00\xFE\xFF|\xFF\xFE\x00\x00|\xFF\xFE/',
+ $content, $bom )
+ ) {
+ switch ( $bom[0] ) {
+ case "\xFE\xFF":
+ $this->charset = 'UTF-16BE';
+ break;
+ case "\xFF\xFE":
+ $this->charset = 'UTF-16LE';
+ break;
+ case "\x00\x00\xFE\xFF":
+ $this->charset = 'UTF-32BE';
+ break;
+ case "\xFF\xFE\x00\x00":
+ $this->charset = 'UTF-32LE';
+ break;
+ case "\xEF\xBB\xBF":
+ $this->charset = 'UTF-8';
+ break;
+ default:
+ // this should be impossible to get to
+ throw new RuntimeException( "Invalid BOM" );
+ }
+ } else {
+ // standard specifically says, if no bom assume utf-8
+ $this->charset = 'UTF-8';
+ }
+ }
+ if ( $this->charset !== 'UTF-8' ) {
+ // don't convert if already utf-8
+ MediaWiki\suppressWarnings();
+ $content = iconv( $this->charset, 'UTF-8//IGNORE', $content );
+ MediaWiki\restoreWarnings();
+ }
+
+ // Ensure the XMP block does not have an xml doctype declaration, which
+ // could declare entities unsafe to parse with xml_parse (T85848/T71210).
+ if ( $this->parsable !== self::PARSABLE_OK ) {
+ if ( $this->parsable === self::PARSABLE_NO ) {
+ throw new RuntimeException( 'Unsafe doctype declaration in XML.' );
+ }
+
+ $content = $this->xmlParsableBuffer . $content;
+ if ( !$this->checkParseSafety( $content ) ) {
+ if ( !$allOfIt && $this->parsable !== self::PARSABLE_NO ) {
+ // parse wasn't Unsuccessful yet, so return true
+ // in this case.
+ return true;
+ }
+ $msg = ( $this->parsable === self::PARSABLE_NO ) ?
+ 'Unsafe doctype declaration in XML.' :
+ 'No root element found in XML.';
+ throw new RuntimeException( $msg );
+ }
+ }
+
+ $ok = xml_parse( $this->xmlParser, $content, $allOfIt );
+ if ( !$ok ) {
+ $code = xml_get_error_code( $this->xmlParser );
+ $error = xml_error_string( $code );
+ $line = xml_get_current_line_number( $this->xmlParser );
+ $col = xml_get_current_column_number( $this->xmlParser );
+ $offset = xml_get_current_byte_index( $this->xmlParser );
+
+ $this->logger->warning(
+ '{method} : Error reading XMP content: {error} ' .
+ '(line: {line} column: {column} byte offset: {offset})',
+ [
+ 'method' => __METHOD__,
+ 'error_code' => $code,
+ 'error' => $error,
+ 'line' => $line,
+ 'column' => $col,
+ 'offset' => $offset,
+ 'content' => $content,
+ ] );
+ $this->results = []; // blank if error.
+ $this->destroyXMLParser();
+ return false;
+ }
+ } catch ( Exception $e ) {
+ $this->logger->warning(
+ '{method} Exception caught while parsing: ' . $e->getMessage(),
+ [
+ 'method' => __METHOD__,
+ 'exception' => $e,
+ 'content' => $content,
+ ]
+ );
+ $this->results = [];
+ return false;
+ }
+ if ( $allOfIt ) {
+ $this->destroyXMLParser();
+ }
+
+ return true;
+ }
+
+ /** Entry point for XMPExtended blocks in jpeg files
+ *
+ * @todo In serious need of testing
+ * @see http://www.adobe.ge/devnet/xmp/pdfs/XMPSpecificationPart3.pdf XMP spec part 3 page 20
+ * @param string $content XMPExtended block minus the namespace signature
+ * @return bool If it succeeded.
+ */
+ public function parseExtended( $content ) {
+ // @todo FIXME: This is untested. Hard to find example files
+ // or programs that make such files..
+ $guid = substr( $content, 0, 32 );
+ if ( !isset( $this->results['xmp-special']['HasExtendedXMP'] )
+ || $this->results['xmp-special']['HasExtendedXMP'] !== $guid
+ ) {
+ $this->logger->info( __METHOD__ .
+ " Ignoring XMPExtended block due to wrong guid (guid= '$guid')" );
+
+ return false;
+ }
+ $len = unpack( 'Nlength/Noffset', substr( $content, 32, 8 ) );
+
+ if ( !$len ||
+ $len['length'] < 4 ||
+ $len['offset'] < 0 ||
+ $len['offset'] > $len['length']
+ ) {
+ $this->logger->info(
+ __METHOD__ . 'Error reading extended XMP block, invalid length or offset.'
+ );
+
+ return false;
+ }
+
+ // we're not very robust here. we should accept it in the wrong order.
+ // To quote the XMP standard:
+ // "A JPEG writer should write the ExtendedXMP marker segments in order,
+ // immediately following the StandardXMP. However, the JPEG standard
+ // does not require preservation of marker segment order. A robust JPEG
+ // reader should tolerate the marker segments in any order."
+ // On the other hand, the probability that an image will have more than
+ // 128k of metadata is rather low... so the probability that it will have
+ // > 128k, and be in the wrong order is very low...
+
+ if ( $len['offset'] !== $this->extendedXMPOffset ) {
+ $this->logger->info( __METHOD__ . 'Ignoring XMPExtended block due to wrong order. (Offset was '
+ . $len['offset'] . ' but expected ' . $this->extendedXMPOffset . ')' );
+
+ return false;
+ }
+
+ if ( $len['offset'] === 0 ) {
+ // if we're starting the extended block, we've probably already
+ // done the XMPStandard block, so reset.
+ $this->resetXMLParser();
+ }
+
+ $this->extendedXMPOffset += $len['length'];
+
+ $actualContent = substr( $content, 40 );
+
+ if ( $this->extendedXMPOffset === strlen( $actualContent ) ) {
+ $atEnd = true;
+ } else {
+ $atEnd = false;
+ }
+
+ $this->logger->debug( __METHOD__ . 'Parsing a XMPExtended block' );
+
+ return $this->parse( $actualContent, $atEnd );
+ }
+
+ /**
+ * Character data handler
+ * Called whenever character data is found in the xmp document.
+ *
+ * does nothing if we're in MODE_IGNORE or if the data is whitespace
+ * throws an error if we're not in MODE_SIMPLE (as we're not allowed to have character
+ * data in the other modes).
+ *
+ * As an example, this happens when we encounter XMP like:
+ * <exif:DigitalZoomRatio>0/10</exif:DigitalZoomRatio>
+ * and are processing the 0/10 bit.
+ *
+ * @param XMLParser $parser XMLParser reference to the xml parser
+ * @param string $data Character data
+ * @throws RuntimeException On invalid data
+ */
+ function char( $parser, $data ) {
+
+ $data = trim( $data );
+ if ( trim( $data ) === "" ) {
+ return;
+ }
+
+ if ( !isset( $this->mode[0] ) ) {
+ throw new RuntimeException( 'Unexpected character data before first rdf:Description element' );
+ }
+
+ if ( $this->mode[0] === self::MODE_IGNORE ) {
+ return;
+ }
+
+ if ( $this->mode[0] !== self::MODE_SIMPLE
+ && $this->mode[0] !== self::MODE_QDESC
+ ) {
+ throw new RuntimeException( 'character data where not expected. (mode ' . $this->mode[0] . ')' );
+ }
+
+ // to check, how does this handle w.s.
+ if ( $this->charContent === false ) {
+ $this->charContent = $data;
+ } else {
+ $this->charContent .= $data;
+ }
+ }
+
+ /**
+ * Check if a block of XML is safe to pass to xml_parse, i.e. doesn't
+ * contain a doctype declaration which could contain a dos attack if we
+ * parse it and expand internal entities (T85848).
+ *
+ * @param string $content xml string to check for parse safety
+ * @return bool true if the xml is safe to parse, false otherwise
+ */
+ private function checkParseSafety( $content ) {
+ $reader = new XMLReader();
+ $result = null;
+
+ // For XMLReader to parse incomplete/invalid XML, it has to be open()'ed
+ // instead of using XML().
+ $reader->open(
+ 'data://text/plain,' . urlencode( $content ),
+ null,
+ LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET
+ );
+
+ $oldDisable = libxml_disable_entity_loader( true );
+ /** @noinspection PhpUnusedLocalVariableInspection */
+ $reset = new ScopedCallback(
+ 'libxml_disable_entity_loader',
+ [ $oldDisable ]
+ );
+ $reader->setParserProperty( XMLReader::SUBST_ENTITIES, false );
+
+ // Even with LIBXML_NOWARNING set, XMLReader::read gives a warning
+ // when parsing truncated XML, which causes unit tests to fail.
+ MediaWiki\suppressWarnings();
+ while ( $reader->read() ) {
+ if ( $reader->nodeType === XMLReader::ELEMENT ) {
+ // Reached the first element without hitting a doctype declaration
+ $this->parsable = self::PARSABLE_OK;
+ $result = true;
+ break;
+ }
+ if ( $reader->nodeType === XMLReader::DOC_TYPE ) {
+ $this->parsable = self::PARSABLE_NO;
+ $result = false;
+ break;
+ }
+ }
+ MediaWiki\restoreWarnings();
+
+ if ( !is_null( $result ) ) {
+ return $result;
+ }
+
+ // Reached the end of the parsable xml without finding an element
+ // or doctype. Buffer and try again.
+ $this->parsable = self::PARSABLE_BUFFERING;
+ $this->xmlParsableBuffer = $content;
+ return false;
+ }
+
+ /** When we hit a closing element in MODE_IGNORE
+ * Check to see if this is the element we started to ignore,
+ * in which case we get out of MODE_IGNORE
+ *
+ * @param string $elm Namespace of element followed by a space and then tag name of element.
+ */
+ private function endElementModeIgnore( $elm ) {
+ if ( $this->curItem[0] === $elm ) {
+ array_shift( $this->curItem );
+ array_shift( $this->mode );
+ }
+ }
+
+ /**
+ * Hit a closing element when in MODE_SIMPLE.
+ * This generally means that we finished processing a
+ * property value, and now have to save the result to the
+ * results array
+ *
+ * For example, when processing:
+ * <exif:DigitalZoomRatio>0/10</exif:DigitalZoomRatio>
+ * this deals with when we hit </exif:DigitalZoomRatio>.
+ *
+ * Or it could be if we hit the end element of a property
+ * of a compound data structure (like a member of an array).
+ *
+ * @param string $elm Namespace, space, and tag name.
+ */
+ private function endElementModeSimple( $elm ) {
+ if ( $this->charContent !== false ) {
+ if ( $this->processingArray ) {
+ // if we're processing an array, use the original element
+ // name instead of rdf:li.
+ list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
+ } else {
+ list( $ns, $tag ) = explode( ' ', $elm, 2 );
+ }
+ $this->saveValue( $ns, $tag, $this->charContent );
+
+ $this->charContent = false; // reset
+ }
+ array_shift( $this->curItem );
+ array_shift( $this->mode );
+ }
+
+ /**
+ * Hit a closing element in MODE_STRUCT, MODE_SEQ, MODE_BAG
+ * generally means we've finished processing a nested structure.
+ * resets some internal variables to indicate that.
+ *
+ * Note this means we hit the closing element not the "</rdf:Seq>".
+ *
+ * @par For example, when processing:
+ * @code{,xml}
+ * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
+ * </rdf:Seq> </exif:ISOSpeedRatings>
+ * @endcode
+ *
+ * This method is called when we hit the "</exif:ISOSpeedRatings>" tag.
+ *
+ * @param string $elm Namespace . space . tag name.
+ * @throws RuntimeException
+ */
+ private function endElementNested( $elm ) {
+
+ /* cur item must be the same as $elm, unless if in MODE_STRUCT
+ in which case it could also be rdf:Description */
+ if ( $this->curItem[0] !== $elm
+ && !( $elm === self::NS_RDF . ' Description'
+ && $this->mode[0] === self::MODE_STRUCT )
+ ) {
+ throw new RuntimeException( "nesting mismatch. got a </$elm> but expected a </" .
+ $this->curItem[0] . '>' );
+ }
+
+ // Validate structures.
+ list( $ns, $tag ) = explode( ' ', $elm, 2 );
+ if ( isset( $this->items[$ns][$tag]['validate'] ) ) {
+ $info =& $this->items[$ns][$tag];
+ $finalName = isset( $info['map_name'] )
+ ? $info['map_name'] : $tag;
+
+ if ( is_array( $info['validate'] ) ) {
+ $validate = $info['validate'];
+ } else {
+ $validator = new XMPValidate( $this->logger );
+ $validate = [ $validator, $info['validate'] ];
+ }
+
+ if ( !isset( $this->results['xmp-' . $info['map_group']][$finalName] ) ) {
+ // This can happen if all the members of the struct failed validation.
+ $this->logger->debug( __METHOD__ . " <$ns:$tag> has no valid members." );
+ } elseif ( is_callable( $validate ) ) {
+ $val =& $this->results['xmp-' . $info['map_group']][$finalName];
+ call_user_func_array( $validate, [ $info, &$val, false ] );
+ if ( is_null( $val ) ) {
+ // the idea being the validation function will unset the variable if
+ // its invalid.
+ $this->logger->info( __METHOD__ . " <$ns:$tag> failed validation." );
+ unset( $this->results['xmp-' . $info['map_group']][$finalName] );
+ }
+ } else {
+ $this->logger->warning( __METHOD__ . " Validation function for $finalName ("
+ . $validate[0] . '::' . $validate[1] . '()) is not callable.' );
+ }
+ }
+
+ array_shift( $this->curItem );
+ array_shift( $this->mode );
+ $this->ancestorStruct = false;
+ $this->processingArray = false;
+ $this->itemLang = false;
+ }
+
+ /**
+ * Hit a closing element in MODE_LI (either rdf:Seq, or rdf:Bag )
+ * Add information about what type of element this is.
+ *
+ * Note we still have to hit the outer "</property>"
+ *
+ * @par For example, when processing:
+ * @code{,xml}
+ * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
+ * </rdf:Seq> </exif:ISOSpeedRatings>
+ * @endcode
+ *
+ * This method is called when we hit the "</rdf:Seq>".
+ * (For comparison, we call endElementModeSimple when we
+ * hit the "</rdf:li>")
+ *
+ * @param string $elm Namespace . ' ' . element name
+ * @throws RuntimeException
+ */
+ private function endElementModeLi( $elm ) {
+ list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
+ $info = $this->items[$ns][$tag];
+ $finalName = isset( $info['map_name'] )
+ ? $info['map_name'] : $tag;
+
+ array_shift( $this->mode );
+
+ if ( !isset( $this->results['xmp-' . $info['map_group']][$finalName] ) ) {
+ $this->logger->debug( __METHOD__ . " Empty compund element $finalName." );
+
+ return;
+ }
+
+ if ( $elm === self::NS_RDF . ' Seq' ) {
+ $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'ol';
+ } elseif ( $elm === self::NS_RDF . ' Bag' ) {
+ $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'ul';
+ } elseif ( $elm === self::NS_RDF . ' Alt' ) {
+ // extra if needed as you could theoretically have a non-language alt.
+ if ( $info['mode'] === self::MODE_LANG ) {
+ $this->results['xmp-' . $info['map_group']][$finalName]['_type'] = 'lang';
+ }
+ } else {
+ throw new RuntimeException(
+ __METHOD__ . " expected </rdf:seq> or </rdf:bag> but instead got $elm."
+ );
+ }
+ }
+
+ /**
+ * End element while in MODE_QDESC
+ * mostly when ending an element when we have a simple value
+ * that has qualifiers.
+ *
+ * Qualifiers aren't all that common, and we don't do anything
+ * with them.
+ *
+ * @param string $elm Namespace and element
+ */
+ private function endElementModeQDesc( $elm ) {
+
+ if ( $elm === self::NS_RDF . ' value' ) {
+ list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
+ $this->saveValue( $ns, $tag, $this->charContent );
+
+ return;
+ } else {
+ array_shift( $this->mode );
+ array_shift( $this->curItem );
+ }
+ }
+
+ /**
+ * Handler for hitting a closing element.
+ *
+ * generally just calls a helper function depending on what
+ * mode we're in.
+ *
+ * Ignores the outer wrapping elements that are optional in
+ * xmp and have no meaning.
+ *
+ * @param XMLParser $parser
+ * @param string $elm Namespace . ' ' . element name
+ * @throws RuntimeException
+ */
+ function endElement( $parser, $elm ) {
+ if ( $elm === ( self::NS_RDF . ' RDF' )
+ || $elm === 'adobe:ns:meta/ xmpmeta'
+ || $elm === 'adobe:ns:meta/ xapmeta'
+ ) {
+ // ignore these.
+ return;
+ }
+
+ if ( $elm === self::NS_RDF . ' type' ) {
+ // these aren't really supported properly yet.
+ // However, it appears they almost never used.
+ $this->logger->info( __METHOD__ . ' encountered <rdf:type>' );
+ }
+
+ if ( strpos( $elm, ' ' ) === false ) {
+ // This probably shouldn't happen.
+ // However, there is a bug in an adobe product
+ // that forgets the namespace on some things.
+ // (Luckily they are unimportant things).
+ $this->logger->info( __METHOD__ . " Encountered </$elm> which has no namespace. Skipping." );
+
+ return;
+ }
+
+ if ( count( $this->mode[0] ) === 0 ) {
+ // This should never ever happen and means
+ // there is a pretty major bug in this class.
+ throw new RuntimeException( 'Encountered end element with no mode' );
+ }
+
+ if ( count( $this->curItem ) == 0 && $this->mode[0] !== self::MODE_INITIAL ) {
+ // just to be paranoid. Should always have a curItem, except for initially
+ // (aka during MODE_INITAL).
+ throw new RuntimeException( "Hit end element </$elm> but no curItem" );
+ }
+
+ switch ( $this->mode[0] ) {
+ case self::MODE_IGNORE:
+ $this->endElementModeIgnore( $elm );
+ break;
+ case self::MODE_SIMPLE:
+ $this->endElementModeSimple( $elm );
+ break;
+ case self::MODE_STRUCT:
+ case self::MODE_SEQ:
+ case self::MODE_BAG:
+ case self::MODE_LANG:
+ case self::MODE_BAGSTRUCT:
+ $this->endElementNested( $elm );
+ break;
+ case self::MODE_INITIAL:
+ if ( $elm === self::NS_RDF . ' Description' ) {
+ array_shift( $this->mode );
+ } else {
+ throw new RuntimeException( 'Element ended unexpectedly while in MODE_INITIAL' );
+ }
+ break;
+ case self::MODE_LI:
+ case self::MODE_LI_LANG:
+ $this->endElementModeLi( $elm );
+ break;
+ case self::MODE_QDESC:
+ $this->endElementModeQDesc( $elm );
+ break;
+ default:
+ $this->logger->warning( __METHOD__ . " no mode (elm = $elm)" );
+ break;
+ }
+ }
+
+ /**
+ * Hit an opening element while in MODE_IGNORE
+ *
+ * XMP is extensible, so ignore any tag we don't understand.
+ *
+ * Mostly ignores, unless we encounter the element that we are ignoring.
+ * in which case we add it to the item stack, so we can ignore things
+ * that are nested, correctly.
+ *
+ * @param string $elm Namespace . ' ' . tag name
+ */
+ private function startElementModeIgnore( $elm ) {
+ if ( $elm === $this->curItem[0] ) {
+ array_unshift( $this->curItem, $elm );
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ }
+ }
+
+ /**
+ * Start element in MODE_BAG (unordered array)
+ * this should always be <rdf:Bag>
+ *
+ * @param string $elm Namespace . ' ' . tag
+ * @throws RuntimeException If we have an element that's not <rdf:Bag>
+ */
+ private function startElementModeBag( $elm ) {
+ if ( $elm === self::NS_RDF . ' Bag' ) {
+ array_unshift( $this->mode, self::MODE_LI );
+ } else {
+ throw new RuntimeException( "Expected <rdf:Bag> but got $elm." );
+ }
+ }
+
+ /**
+ * Start element in MODE_SEQ (ordered array)
+ * this should always be <rdf:Seq>
+ *
+ * @param string $elm Namespace . ' ' . tag
+ * @throws RuntimeException If we have an element that's not <rdf:Seq>
+ */
+ private function startElementModeSeq( $elm ) {
+ if ( $elm === self::NS_RDF . ' Seq' ) {
+ array_unshift( $this->mode, self::MODE_LI );
+ } elseif ( $elm === self::NS_RDF . ' Bag' ) {
+ # bug 27105
+ $this->logger->info( __METHOD__ . ' Expected an rdf:Seq, but got an rdf:Bag. Pretending'
+ . ' it is a Seq, since some buggy software is known to screw this up.' );
+ array_unshift( $this->mode, self::MODE_LI );
+ } else {
+ throw new RuntimeException( "Expected <rdf:Seq> but got $elm." );
+ }
+ }
+
+ /**
+ * Start element in MODE_LANG (language alternative)
+ * this should always be <rdf:Alt>
+ *
+ * This tag tends to be used for metadata like describe this
+ * picture, which can be translated into multiple languages.
+ *
+ * XMP supports non-linguistic alternative selections,
+ * which are really only used for thumbnails, which
+ * we don't care about.
+ *
+ * @param string $elm Namespace . ' ' . tag
+ * @throws RuntimeException If we have an element that's not <rdf:Alt>
+ */
+ private function startElementModeLang( $elm ) {
+ if ( $elm === self::NS_RDF . ' Alt' ) {
+ array_unshift( $this->mode, self::MODE_LI_LANG );
+ } else {
+ throw new RuntimeException( "Expected <rdf:Seq> but got $elm." );
+ }
+ }
+
+ /**
+ * Handle an opening element when in MODE_SIMPLE
+ *
+ * This should not happen often. This is for if a simple element
+ * already opened has a child element. Could happen for a
+ * qualified element.
+ *
+ * For example:
+ * <exif:DigitalZoomRatio><rdf:Description><rdf:value>0/10</rdf:value>
+ * <foo:someQualifier>Bar</foo:someQualifier> </rdf:Description>
+ * </exif:DigitalZoomRatio>
+ *
+ * This method is called when processing the <rdf:Description> element
+ *
+ * @param string $elm Namespace and tag names separated by space.
+ * @param array $attribs Attributes of the element.
+ * @throws RuntimeException
+ */
+ private function startElementModeSimple( $elm, $attribs ) {
+ if ( $elm === self::NS_RDF . ' Description' ) {
+ // If this value has qualifiers
+ array_unshift( $this->mode, self::MODE_QDESC );
+ array_unshift( $this->curItem, $this->curItem[0] );
+
+ if ( isset( $attribs[self::NS_RDF . ' value'] ) ) {
+ list( $ns, $tag ) = explode( ' ', $this->curItem[0], 2 );
+ $this->saveValue( $ns, $tag, $attribs[self::NS_RDF . ' value'] );
+ }
+ } elseif ( $elm === self::NS_RDF . ' value' ) {
+ // This should not be here.
+ throw new RuntimeException( __METHOD__ . ' Encountered <rdf:value> where it was unexpected.' );
+ } else {
+ // something else we don't recognize, like a qualifier maybe.
+ $this->logger->info( __METHOD__ .
+ " Encountered element <$elm> where only expecting character data as value of " .
+ $this->curItem[0] );
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $elm );
+ }
+ }
+
+ /**
+ * Start an element when in MODE_QDESC.
+ * This generally happens when a simple element has an inner
+ * rdf:Description to hold qualifier elements.
+ *
+ * For example in:
+ * <exif:DigitalZoomRatio><rdf:Description><rdf:value>0/10</rdf:value>
+ * <foo:someQualifier>Bar</foo:someQualifier> </rdf:Description>
+ * </exif:DigitalZoomRatio>
+ * Called when processing the <rdf:value> or <foo:someQualifier>.
+ *
+ * @param string $elm Namespace and tag name separated by a space.
+ *
+ */
+ private function startElementModeQDesc( $elm ) {
+ if ( $elm === self::NS_RDF . ' value' ) {
+ return; // do nothing
+ } else {
+ // otherwise its a qualifier, which we ignore
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $elm );
+ }
+ }
+
+ /**
+ * Starting an element when in MODE_INITIAL
+ * This usually happens when we hit an element inside
+ * the outer rdf:Description
+ *
+ * This is generally where most properties start.
+ *
+ * @param string $ns Namespace
+ * @param string $tag Tag name (without namespace prefix)
+ * @param array $attribs Array of attributes
+ * @throws RuntimeException
+ */
+ private function startElementModeInitial( $ns, $tag, $attribs ) {
+ if ( $ns !== self::NS_RDF ) {
+
+ if ( isset( $this->items[$ns][$tag] ) ) {
+ if ( isset( $this->items[$ns][$tag]['structPart'] ) ) {
+ // If this element is supposed to appear only as
+ // a child of a structure, but appears here (not as
+ // a child of a struct), then something weird is
+ // happening, so ignore this element and its children.
+
+ $this->logger->warning( "Encountered <$ns:$tag> outside"
+ . " of its expected parent. Ignoring." );
+
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+
+ return;
+ }
+ $mode = $this->items[$ns][$tag]['mode'];
+ array_unshift( $this->mode, $mode );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+ if ( $mode === self::MODE_STRUCT ) {
+ $this->ancestorStruct = isset( $this->items[$ns][$tag]['map_name'] )
+ ? $this->items[$ns][$tag]['map_name'] : $tag;
+ }
+ if ( $this->charContent !== false ) {
+ // Something weird.
+ // Should not happen in valid XMP.
+ throw new RuntimeException( 'tag nested in non-whitespace characters.' );
+ }
+ } else {
+ // This element is not on our list of allowed elements so ignore.
+ $this->logger->debug( __METHOD__ . " Ignoring unrecognized element <$ns:$tag>." );
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+
+ return;
+ }
+ }
+ // process attributes
+ $this->doAttribs( $attribs );
+ }
+
+ /**
+ * Hit an opening element when in a Struct (MODE_STRUCT)
+ * This is generally for fields of a compound property.
+ *
+ * Example of a struct (abbreviated; flash has more properties):
+ *
+ * <exif:Flash> <rdf:Description> <exif:Fired>True</exif:Fired>
+ * <exif:Mode>1</exif:Mode></rdf:Description></exif:Flash>
+ *
+ * or:
+ *
+ * <exif:Flash rdf:parseType='Resource'> <exif:Fired>True</exif:Fired>
+ * <exif:Mode>1</exif:Mode></exif:Flash>
+ *
+ * @param string $ns Namespace
+ * @param string $tag Tag name (no ns)
+ * @param array $attribs Array of attribs w/ values.
+ * @throws RuntimeException
+ */
+ private function startElementModeStruct( $ns, $tag, $attribs ) {
+ if ( $ns !== self::NS_RDF ) {
+
+ if ( isset( $this->items[$ns][$tag] ) ) {
+ if ( isset( $this->items[$ns][$this->ancestorStruct]['children'] )
+ && !isset( $this->items[$ns][$this->ancestorStruct]['children'][$tag] )
+ ) {
+ // This assumes that we don't have inter-namespace nesting
+ // which we don't in all the properties we're interested in.
+ throw new RuntimeException( " <$tag> appeared nested in <" . $this->ancestorStruct
+ . "> where it is not allowed." );
+ }
+ array_unshift( $this->mode, $this->items[$ns][$tag]['mode'] );
+ array_unshift( $this->curItem, $ns . ' ' . $tag );
+ if ( $this->charContent !== false ) {
+ // Something weird.
+ // Should not happen in valid XMP.
+ throw new RuntimeException( "tag <$tag> nested in non-whitespace characters (" .
+ $this->charContent . ")." );
+ }
+ } else {
+ array_unshift( $this->mode, self::MODE_IGNORE );
+ array_unshift( $this->curItem, $elm );
+
+ return;
+ }
+ }
+
+ if ( $ns === self::NS_RDF && $tag === 'Description' ) {
+ $this->doAttribs( $attribs );
+ array_unshift( $this->mode, self::MODE_STRUCT );
+ array_unshift( $this->curItem, $this->curItem[0] );
+ }
+ }
+
+ /**
+ * opening element in MODE_LI
+ * process elements of arrays.
+ *
+ * Example:
+ * <exif:ISOSpeedRatings> <rdf:Seq> <rdf:li>64</rdf:li>
+ * </rdf:Seq> </exif:ISOSpeedRatings>
+ * This method is called when we hit the <rdf:li> element.
+ *
+ * @param string $elm Namespace . ' ' . tagname
+ * @param array $attribs Attributes. (needed for BAGSTRUCTS)
+ * @throws RuntimeException If gets a tag other than <rdf:li>
+ */
+ private function startElementModeLi( $elm, $attribs ) {
+ if ( ( $elm ) !== self::NS_RDF . ' li' ) {
+ throw new RuntimeException( "<rdf:li> expected but got $elm." );
+ }
+
+ if ( !isset( $this->mode[1] ) ) {
+ // This should never ever ever happen. Checking for it
+ // to be paranoid.
+ throw new RuntimeException( 'In mode Li, but no 2xPrevious mode!' );
+ }
+
+ if ( $this->mode[1] === self::MODE_BAGSTRUCT ) {
+ // This list item contains a compound (STRUCT) value.
+ array_unshift( $this->mode, self::MODE_STRUCT );
+ array_unshift( $this->curItem, $elm );
+ $this->processingArray = true;
+
+ if ( !isset( $this->curItem[1] ) ) {
+ // be paranoid.
+ throw new RuntimeException( 'Can not find parent of BAGSTRUCT.' );
+ }
+ list( $curNS, $curTag ) = explode( ' ', $this->curItem[1] );
+ $this->ancestorStruct = isset( $this->items[$curNS][$curTag]['map_name'] )
+ ? $this->items[$curNS][$curTag]['map_name'] : $curTag;
+
+ $this->doAttribs( $attribs );
+ } else {
+ // Normal BAG or SEQ containing simple values.
+ array_unshift( $this->mode, self::MODE_SIMPLE );
+ // need to add curItem[0] on again since one is for the specific item
+ // and one is for the entire group.
+ array_unshift( $this->curItem, $this->curItem[0] );
+ $this->processingArray = true;
+ }
+ }
+
+ /**
+ * Opening element in MODE_LI_LANG.
+ * process elements of language alternatives
+ *
+ * Example:
+ * <dc:title> <rdf:Alt> <rdf:li xml:lang="x-default">My house
+ * </rdf:li> </rdf:Alt> </dc:title>
+ *
+ * This method is called when we hit the <rdf:li> element.
+ *
+ * @param string $elm Namespace . ' ' . tag
+ * @param array $attribs Array of elements (most importantly xml:lang)
+ * @throws RuntimeException If gets a tag other than <rdf:li> or if no xml:lang
+ */
+ private function startElementModeLiLang( $elm, $attribs ) {
+ if ( $elm !== self::NS_RDF . ' li' ) {
+ throw new RuntimeException( __METHOD__ . " <rdf:li> expected but got $elm." );
+ }
+ if ( !isset( $attribs[self::NS_XML . ' lang'] )
+ || !preg_match( '/^[-A-Za-z0-9]{2,}$/D', $attribs[self::NS_XML . ' lang'] )
+ ) {
+ throw new RuntimeException( __METHOD__
+ . " <rdf:li> did not contain, or has invalid xml:lang attribute in lang alternative" );
+ }
+
+ // Lang is case-insensitive.
+ $this->itemLang = strtolower( $attribs[self::NS_XML . ' lang'] );
+
+ // need to add curItem[0] on again since one is for the specific item
+ // and one is for the entire group.
+ array_unshift( $this->curItem, $this->curItem[0] );
+ array_unshift( $this->mode, self::MODE_SIMPLE );
+ $this->processingArray = true;
+ }
+
+ /**
+ * Hits an opening element.
+ * Generally just calls a helper based on what MODE we're in.
+ * Also does some initial set up for the wrapper element
+ *
+ * @param XMLParser $parser
+ * @param string $elm Namespace "<space>" element
+ * @param array $attribs Attribute name => value
+ * @throws RuntimeException
+ */
+ function startElement( $parser, $elm, $attribs ) {
+
+ if ( $elm === self::NS_RDF . ' RDF'
+ || $elm === 'adobe:ns:meta/ xmpmeta'
+ || $elm === 'adobe:ns:meta/ xapmeta'
+ ) {
+ /* ignore. */
+ return;
+ } elseif ( $elm === self::NS_RDF . ' Description' ) {
+ if ( count( $this->mode ) === 0 ) {
+ // outer rdf:desc
+ array_unshift( $this->mode, self::MODE_INITIAL );
+ }
+ } elseif ( $elm === self::NS_RDF . ' type' ) {
+ // This doesn't support rdf:type properly.
+ // In practise I have yet to see a file that
+ // uses this element, however it is mentioned
+ // on page 25 of part 1 of the xmp standard.
+ // Also it seems as if exiv2 and exiftool do not support
+ // this either (That or I misunderstand the standard)
+ $this->logger->info( __METHOD__ . ' Encountered <rdf:type> which isn\'t currently supported' );
+ }
+
+ if ( strpos( $elm, ' ' ) === false ) {
+ // This probably shouldn't happen.
+ $this->logger->info( __METHOD__ . " Encountered <$elm> which has no namespace. Skipping." );
+
+ return;
+ }
+
+ list( $ns, $tag ) = explode( ' ', $elm, 2 );
+
+ if ( count( $this->mode ) === 0 ) {
+ // This should not happen.
+ throw new RuntimeException( 'Error extracting XMP, '
+ . "encountered <$elm> with no mode" );
+ }
+
+ switch ( $this->mode[0] ) {
+ case self::MODE_IGNORE:
+ $this->startElementModeIgnore( $elm );
+ break;
+ case self::MODE_SIMPLE:
+ $this->startElementModeSimple( $elm, $attribs );
+ break;
+ case self::MODE_INITIAL:
+ $this->startElementModeInitial( $ns, $tag, $attribs );
+ break;
+ case self::MODE_STRUCT:
+ $this->startElementModeStruct( $ns, $tag, $attribs );
+ break;
+ case self::MODE_BAG:
+ case self::MODE_BAGSTRUCT:
+ $this->startElementModeBag( $elm );
+ break;
+ case self::MODE_SEQ:
+ $this->startElementModeSeq( $elm );
+ break;
+ case self::MODE_LANG:
+ $this->startElementModeLang( $elm );
+ break;
+ case self::MODE_LI_LANG:
+ $this->startElementModeLiLang( $elm, $attribs );
+ break;
+ case self::MODE_LI:
+ $this->startElementModeLi( $elm, $attribs );
+ break;
+ case self::MODE_QDESC:
+ $this->startElementModeQDesc( $elm );
+ break;
+ default:
+ throw new RuntimeException( 'StartElement in unknown mode: ' . $this->mode[0] );
+ }
+ }
+
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ /**
+ * Process attributes.
+ * Simple values can be stored as either a tag or attribute
+ *
+ * Often the initial "<rdf:Description>" tag just has all the simple
+ * properties as attributes.
+ *
+ * @par Example:
+ * @code
+ * <rdf:Description rdf:about="" xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:DigitalZoomRatio="0/10">
+ * @endcode
+ *
+ * @param array $attribs Array attribute=>value
+ * @throws RuntimeException
+ */
+ // @codingStandardsIgnoreEnd
+ private function doAttribs( $attribs ) {
+ // first check for rdf:parseType attribute, as that can change
+ // how the attributes are interperted.
+
+ if ( isset( $attribs[self::NS_RDF . ' parseType'] )
+ && $attribs[self::NS_RDF . ' parseType'] === 'Resource'
+ && $this->mode[0] === self::MODE_SIMPLE
+ ) {
+ // this is equivalent to having an inner rdf:Description
+ $this->mode[0] = self::MODE_QDESC;
+ }
+ foreach ( $attribs as $name => $val ) {
+ if ( strpos( $name, ' ' ) === false ) {
+ // This shouldn't happen, but so far some old software forgets namespace
+ // on rdf:about.
+ $this->logger->info( __METHOD__ . ' Encountered non-namespaced attribute: '
+ . " $name=\"$val\". Skipping. " );
+ continue;
+ }
+ list( $ns, $tag ) = explode( ' ', $name, 2 );
+ if ( $ns === self::NS_RDF ) {
+ if ( $tag === 'value' || $tag === 'resource' ) {
+ // resource is for url.
+ // value attribute is a weird way of just putting the contents.
+ $this->char( $this->xmlParser, $val );
+ }
+ } elseif ( isset( $this->items[$ns][$tag] ) ) {
+ if ( $this->mode[0] === self::MODE_SIMPLE ) {
+ throw new RuntimeException( __METHOD__
+ . " $ns:$tag found as attribute where not allowed" );
+ }
+ $this->saveValue( $ns, $tag, $val );
+ } else {
+ $this->logger->debug( __METHOD__ . " Ignoring unrecognized element <$ns:$tag>." );
+ }
+ }
+ }
+
+ /**
+ * Given an extracted value, save it to results array
+ *
+ * note also uses $this->ancestorStruct and
+ * $this->processingArray to determine what name to
+ * save the value under. (in addition to $tag).
+ *
+ * @param string $ns Namespace of tag this is for
+ * @param string $tag Tag name
+ * @param string $val Value to save
+ */
+ private function saveValue( $ns, $tag, $val ) {
+
+ $info =& $this->items[$ns][$tag];
+ $finalName = isset( $info['map_name'] )
+ ? $info['map_name'] : $tag;
+ if ( isset( $info['validate'] ) ) {
+ if ( is_array( $info['validate'] ) ) {
+ $validate = $info['validate'];
+ } else {
+ $validator = new XMPValidate( $this->logger );
+ $validate = [ $validator, $info['validate'] ];
+ }
+
+ if ( is_callable( $validate ) ) {
+ call_user_func_array( $validate, [ $info, &$val, true ] );
+ // the reasoning behind using &$val instead of using the return value
+ // is to be consistent between here and validating structures.
+ if ( is_null( $val ) ) {
+ $this->logger->info( __METHOD__ . " <$ns:$tag> failed validation." );
+
+ return;
+ }
+ } else {
+ $this->logger->warning( __METHOD__ . " Validation function for $finalName ("
+ . $validate[0] . '::' . $validate[1] . '()) is not callable.' );
+ }
+ }
+
+ if ( $this->ancestorStruct && $this->processingArray ) {
+ // Aka both an array and a struct. ( self::MODE_BAGSTRUCT )
+ $this->results['xmp-' . $info['map_group']][$this->ancestorStruct][][$finalName] = $val;
+ } elseif ( $this->ancestorStruct ) {
+ $this->results['xmp-' . $info['map_group']][$this->ancestorStruct][$finalName] = $val;
+ } elseif ( $this->processingArray ) {
+ if ( $this->itemLang === false ) {
+ // normal array
+ $this->results['xmp-' . $info['map_group']][$finalName][] = $val;
+ } else {
+ // lang array.
+ $this->results['xmp-' . $info['map_group']][$finalName][$this->itemLang] = $val;
+ }
+ } else {
+ $this->results['xmp-' . $info['map_group']][$finalName] = $val;
+ }
+ }
+}
diff --git a/www/wiki/includes/media/XMPInfo.php b/www/wiki/includes/media/XMPInfo.php
new file mode 100644
index 00000000..052be33a
--- /dev/null
+++ b/www/wiki/includes/media/XMPInfo.php
@@ -0,0 +1,1168 @@
+<?php
+/**
+ * Definitions for XMPReader class.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+/**
+ * This class is just a container for a big array
+ * used by XMPReader to determine which XMP items to
+ * extract.
+ */
+class XMPInfo {
+ /** Get the items array
+ * @return array XMP item configuration array.
+ */
+ public static function getItems() {
+ return self::$items;
+ }
+
+ /**
+ * XMPInfo::$items keeps a list of all the items
+ * we are interested to extract, as well as
+ * information about the item like what type
+ * it is.
+ *
+ * Format is an array of namespaces,
+ * each containing an array of tags
+ * each tag is an array of information about the
+ * tag, including:
+ * * map_group - What group (used for precedence during conflicts).
+ * * mode - What type of item (self::MODE_SIMPLE usually, see above for
+ * all values).
+ * * validate - Method to validate input. Could also post-process the
+ * input. A string value is assumed to be a method of
+ * XMPValidate. Can also take a array( 'className', 'methodName' ).
+ * * choices - Array of potential values (format of 'value' => true ).
+ * Only used with validateClosed.
+ * * rangeLow and rangeHigh - Alternative to choices for numeric ranges.
+ * Again for validateClosed only.
+ * * children - For MODE_STRUCT items, allowed children.
+ * * structPart - Indicates that this element can only appear as a member
+ * of a structure.
+ *
+ * Currently this just has a bunch of EXIF values as this class is only half-done.
+ */
+ static private $items = [
+ 'http://ns.adobe.com/exif/1.0/' => [
+ 'ApertureValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'BrightnessValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'CompressedBitsPerPixel' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'DigitalZoomRatio' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'ExposureBiasValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'ExposureIndex' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'ExposureTime' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'FlashEnergy' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational',
+ ],
+ 'FNumber' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'FocalLength' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'FocalPlaneXResolution' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'FocalPlaneYResolution' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSAltitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational',
+ ],
+ 'GPSDestBearing' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSDestDistance' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSDOP' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSImgDirection' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSSpeed' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'GPSTrack' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'MaxApertureValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'ShutterSpeedValue' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ 'SubjectDistance' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational'
+ ],
+ /* Flash */
+ 'Flash' => [
+ 'mode' => XMPReader::MODE_STRUCT,
+ 'children' => [
+ 'Fired' => true,
+ 'Function' => true,
+ 'Mode' => true,
+ 'RedEyeMode' => true,
+ 'Return' => true,
+ ],
+ 'validate' => 'validateFlash',
+ 'map_group' => 'exif',
+ ],
+ 'Fired' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateBoolean',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'Function' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateBoolean',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'Mode' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateClosed',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'choices' => [ '0' => true, '1' => true,
+ '2' => true, '3' => true ],
+ 'structPart' => true,
+ ],
+ 'Return' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateClosed',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'choices' => [ '0' => true,
+ '2' => true, '3' => true ],
+ 'structPart' => true,
+ ],
+ 'RedEyeMode' => [
+ 'map_group' => 'exif',
+ 'validate' => 'validateBoolean',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ /* End Flash */
+ 'ISOSpeedRatings' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateInteger'
+ ],
+ /* end rational things */
+ 'ColorSpace' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '65535' => true ],
+ ],
+ 'ComponentsConfiguration' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '2' => true, '3' => true, '4' => true,
+ '5' => true, '6' => true ]
+ ],
+ 'Contrast' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true, '2' => true ]
+ ],
+ 'CustomRendered' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true ]
+ ],
+ 'DateTimeOriginal' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ 'DateTimeDigitized' => [ /* xmp:CreateDate */
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ /* todo: there might be interesting information in
+ * exif:DeviceSettingDescription, but need to find an
+ * example
+ */
+ 'ExifVersion' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'ExposureMode' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 2,
+ ],
+ 'ExposureProgram' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 8,
+ ],
+ 'FileSource' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '3' => true ]
+ ],
+ 'FlashpixVersion' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'FocalLengthIn35mmFilm' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'FocalPlaneResolutionUnit' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '2' => true, '3' => true ],
+ ],
+ 'GainControl' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 4,
+ ],
+ /* this value is post-processed out later */
+ 'GPSAltitudeRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true ],
+ ],
+ 'GPSAreaInformation' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'GPSDestBearingRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'T' => true, 'M' => true ],
+ ],
+ 'GPSDestDistanceRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'K' => true, 'M' => true,
+ 'N' => true ],
+ ],
+ 'GPSDestLatitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateGPS',
+ ],
+ 'GPSDestLongitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateGPS',
+ ],
+ 'GPSDifferential' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true ],
+ ],
+ 'GPSImgDirectionRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'T' => true, 'M' => true ],
+ ],
+ 'GPSLatitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateGPS',
+ ],
+ 'GPSLongitude' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateGPS',
+ ],
+ 'GPSMapDatum' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'GPSMeasureMode' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '2' => true, '3' => true ]
+ ],
+ 'GPSProcessingMethod' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'GPSSatellites' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'GPSSpeedRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'K' => true, 'M' => true,
+ 'N' => true ],
+ ],
+ 'GPSStatus' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'A' => true, 'V' => true ]
+ ],
+ 'GPSTimeStamp' => [
+ 'map_group' => 'exif',
+ // Note: in exif, GPSDateStamp does not include
+ // the time, where here it does.
+ 'map_name' => 'GPSDateStamp',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ 'GPSTrackRef' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ 'T' => true, 'M' => true ]
+ ],
+ 'GPSVersionID' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'ImageUniqueID' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'LightSource' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ /* can't use a range, as it skips... */
+ 'choices' => [ '0' => true, '1' => true,
+ '2' => true, '3' => true, '4' => true,
+ '9' => true, '10' => true, '11' => true,
+ '12' => true, '13' => true,
+ '14' => true, '15' => true,
+ '17' => true, '18' => true,
+ '19' => true, '20' => true,
+ '21' => true, '22' => true,
+ '23' => true, '24' => true,
+ '255' => true,
+ ],
+ ],
+ 'MeteringMode' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 6,
+ 'choices' => [ '255' => true ],
+ ],
+ /* Pixel(X|Y)Dimension are rather useless, but for
+ * completeness since we do it with exif.
+ */
+ 'PixelXDimension' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'PixelYDimension' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'Saturation' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 2,
+ ],
+ 'SceneCaptureType' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 3,
+ ],
+ 'SceneType' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true ],
+ ],
+ // Note, 6 is not valid SensingMethod.
+ 'SensingMethod' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 1,
+ 'rangeHigh' => 5,
+ 'choices' => [ '7' => true, 8 => true ],
+ ],
+ 'Sharpness' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 2,
+ ],
+ 'SpectralSensitivity' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ // This tag should perhaps be displayed to user better.
+ 'SubjectArea' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateInteger',
+ ],
+ 'SubjectDistanceRange' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'rangeLow' => 0,
+ 'rangeHigh' => 3,
+ ],
+ 'SubjectLocation' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateInteger',
+ ],
+ 'UserComment' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ 'WhiteBalance' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '0' => true, '1' => true ]
+ ],
+ ],
+ 'http://ns.adobe.com/tiff/1.0/' => [
+ 'Artist' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'BitsPerSample' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateInteger',
+ ],
+ 'Compression' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '6' => true ],
+ ],
+ /* this prop should not be used in XMP. dc:rights is the correct prop */
+ 'Copyright' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ 'DateTime' => [ /* proper prop is xmp:ModifyDate */
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ 'ImageDescription' => [ /* proper one is dc:description */
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ 'ImageLength' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'ImageWidth' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'Make' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Model' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ /**** Do not extract this property
+ * It interferes with auto exif rotation.
+ * 'Orientation' => array(
+ * 'map_group' => 'exif',
+ * 'mode' => XMPReader::MODE_SIMPLE,
+ * 'validate' => 'validateClosed',
+ * 'choices' => array( '1' => true, '2' => true, '3' => true, '4' => true, 5 => true,
+ * '6' => true, '7' => true, '8' => true ),
+ *),
+ ******/
+ 'PhotometricInterpretation' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '2' => true, '6' => true ],
+ ],
+ 'PlanerConfiguration' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '2' => true ],
+ ],
+ 'PrimaryChromaticities' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateRational',
+ ],
+ 'ReferenceBlackWhite' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateRational',
+ ],
+ 'ResolutionUnit' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '2' => true, '3' => true ],
+ ],
+ 'SamplesPerPixel' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ ],
+ 'Software' => [ /* see xmp:CreatorTool */
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ /* ignore TransferFunction */
+ 'WhitePoint' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateRational',
+ ],
+ 'XResolution' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational',
+ ],
+ 'YResolution' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRational',
+ ],
+ 'YCbCrCoefficients' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateRational',
+ ],
+ 'YCbCrPositioning' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateClosed',
+ 'choices' => [ '1' => true, '2' => true ],
+ ],
+ /********
+ * Disable extracting this property (bug 31944)
+ * Several files have a string instead of a Seq
+ * for this property. XMPReader doesn't handle
+ * mismatched types very gracefully (it marks
+ * the entire file as invalid, instead of just
+ * the relavent prop). Since this prop
+ * doesn't communicate all that useful information
+ * just disable this prop for now, until such
+ * XMPReader is more graceful (bug 32172)
+ * 'YCbCrSubSampling' => array(
+ * 'map_group' => 'exif',
+ * 'mode' => XMPReader::MODE_SEQ,
+ * 'validate' => 'validateClosed',
+ * 'choices' => array( '1' => true, '2' => true ),
+ * ),
+ */
+ ],
+ 'http://ns.adobe.com/exif/1.0/aux/' => [
+ 'Lens' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'SerialNumber' => [
+ 'map_group' => 'exif',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'OwnerName' => [
+ 'map_group' => 'exif',
+ 'map_name' => 'CameraOwnerName',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ ],
+ 'http://purl.org/dc/elements/1.1/' => [
+ 'title' => [
+ 'map_group' => 'general',
+ 'map_name' => 'ObjectName',
+ 'mode' => XMPReader::MODE_LANG
+ ],
+ 'description' => [
+ 'map_group' => 'general',
+ 'map_name' => 'ImageDescription',
+ 'mode' => XMPReader::MODE_LANG
+ ],
+ 'contributor' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-contributor',
+ 'mode' => XMPReader::MODE_BAG
+ ],
+ 'coverage' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-coverage',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'creator' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Artist', // map with exif Artist, iptc byline (2:80)
+ 'mode' => XMPReader::MODE_SEQ,
+ ],
+ 'date' => [
+ 'map_group' => 'general',
+ // Note, not mapped with other date properties, as this type of date is
+ // non-specific: "A point or period of time associated with an event in
+ // the lifecycle of the resource"
+ 'map_name' => 'dc-date',
+ 'mode' => XMPReader::MODE_SEQ,
+ 'validate' => 'validateDate',
+ ],
+ /* Do not extract dc:format, as we've got better ways to determine MIME type */
+ 'identifier' => [
+ 'map_group' => 'deprecated',
+ 'map_name' => 'Identifier',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'language' => [
+ 'map_group' => 'general',
+ 'map_name' => 'LanguageCode', /* mapped with iptc 2:135 */
+ 'mode' => XMPReader::MODE_BAG,
+ 'validate' => 'validateLangCode',
+ ],
+ 'publisher' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-publisher',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ // for related images/resources
+ 'relation' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-relation',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ 'rights' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Copyright',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ // Note: source is not mapped with iptc source, since iptc
+ // source describes the source of the image in terms of a person
+ // who provided the image, where this is to describe an image that the
+ // current one is based on.
+ 'source' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-source',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'subject' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Keywords', /* maps to iptc 2:25 */
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ 'type' => [
+ 'map_group' => 'general',
+ 'map_name' => 'dc-type',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ ],
+ 'http://ns.adobe.com/xap/1.0/' => [
+ 'CreateDate' => [
+ 'map_group' => 'general',
+ 'map_name' => 'DateTimeDigitized',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateDate',
+ ],
+ 'CreatorTool' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Software',
+ 'mode' => XMPReader::MODE_SIMPLE
+ ],
+ 'Identifier' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ 'Label' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'ModifyDate' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'DateTime',
+ 'validate' => 'validateDate',
+ ],
+ 'MetadataDate' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ // map_name to be consistent with other date names.
+ 'map_name' => 'DateTimeMetadata',
+ 'validate' => 'validateDate',
+ ],
+ 'Nickname' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Rating' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateRating',
+ ],
+ ],
+ 'http://ns.adobe.com/xap/1.0/rights/' => [
+ 'Certificate' => [
+ 'map_group' => 'general',
+ 'map_name' => 'RightsCertificate',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Marked' => [
+ 'map_group' => 'general',
+ 'map_name' => 'Copyrighted',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateBoolean',
+ ],
+ 'Owner' => [
+ 'map_group' => 'general',
+ 'map_name' => 'CopyrightOwner',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ // this seems similar to dc:rights.
+ 'UsageTerms' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_LANG,
+ ],
+ 'WebStatement' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ ],
+ // XMP media management.
+ 'http://ns.adobe.com/xap/1.0/mm/' => [
+ // if we extract the exif UniqueImageID, might
+ // as well do this too.
+ 'OriginalDocumentID' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ // It might also be useful to do xmpMM:LastURL
+ // and xmpMM:DerivedFrom as you can potentially,
+ // get the url of this document/source for this
+ // document. However whats more likely is you'd
+ // get a file:// url for the path of the doc,
+ // which is somewhat of a privacy issue.
+ ],
+ 'http://creativecommons.org/ns#' => [
+ 'license' => [
+ 'map_name' => 'LicenseUrl',
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'morePermissions' => [
+ 'map_name' => 'MorePermissionsUrl',
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'attributionURL' => [
+ 'map_group' => 'general',
+ 'map_name' => 'AttributionUrl',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'attributionName' => [
+ 'map_group' => 'general',
+ 'map_name' => 'PreferredAttributionName',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ ],
+ // Note, this property affects how jpeg metadata is extracted.
+ 'http://ns.adobe.com/xmp/note/' => [
+ 'HasExtendedXMP' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ ],
+ /* Note, in iptc schemas, the legacy properties are denoted
+ * as deprecated, since other properties should used instead,
+ * and properties marked as deprecated in the standard are
+ * are marked as general here as they don't have replacements
+ */
+ 'http://ns.adobe.com/photoshop/1.0/' => [
+ 'City' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'CityDest',
+ ],
+ 'Country' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'CountryDest',
+ ],
+ 'State' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'ProvinceOrStateDest',
+ ],
+ 'DateCreated' => [
+ 'map_group' => 'deprecated',
+ // marking as deprecated as the xmp prop preferred
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'DateTimeOriginal',
+ 'validate' => 'validateDate',
+ // note this prop is an XMP, not IPTC date
+ ],
+ 'CaptionWriter' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'Writer',
+ ],
+ 'Instructions' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'SpecialInstructions',
+ ],
+ 'TransmissionReference' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'OriginalTransmissionRef',
+ ],
+ 'AuthorsPosition' => [
+ /* This corresponds with 2:85
+ * By-line Title, which needs to be
+ * handled weirdly to correspond
+ * with iptc/exif. */
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE
+ ],
+ 'Credit' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Source' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Urgency' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'Category' => [
+ // Note, this prop is deprecated, but in general
+ // group since it doesn't have a replacement.
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'iimCategory',
+ ],
+ 'SupplementalCategories' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ 'map_name' => 'iimSupplementalCategory',
+ ],
+ 'Headline' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE
+ ],
+ ],
+ 'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/' => [
+ 'CountryCode' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'CountryCodeDest',
+ ],
+ 'IntellectualGenre' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ // Note, this is a six digit code.
+ // See: http://cv.iptc.org/newscodes/scene/
+ // Since these aren't really all that common,
+ // we just show the number.
+ 'Scene' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ 'validate' => 'validateInteger',
+ 'map_name' => 'SceneCode',
+ ],
+ /* Note: SubjectCode should be an 8 ascii digits.
+ * it is not really an integer (has leading 0's,
+ * cannot have a +/- sign), but validateInteger
+ * will let it through.
+ */
+ 'SubjectCode' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ 'map_name' => 'SubjectNewsCode',
+ 'validate' => 'validateInteger'
+ ],
+ 'Location' => [
+ 'map_group' => 'deprecated',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'map_name' => 'SublocationDest',
+ ],
+ 'CreatorContactInfo' => [
+ /* Note this maps to 2:118 in iim
+ * (Contact) field. However those field
+ * types are slightly different - 2:118
+ * is free form text field, where this
+ * is more structured.
+ */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_STRUCT,
+ 'map_name' => 'Contact',
+ 'children' => [
+ 'CiAdrExtadr' => true,
+ 'CiAdrCity' => true,
+ 'CiAdrCtry' => true,
+ 'CiEmailWork' => true,
+ 'CiTelWork' => true,
+ 'CiAdrPcode' => true,
+ 'CiAdrRegion' => true,
+ 'CiUrlWork' => true,
+ ],
+ ],
+ 'CiAdrExtadr' => [ /* address */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiAdrCity' => [ /* city */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiAdrCtry' => [ /* country */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiEmailWork' => [ /* email (possibly separated by ',') */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiTelWork' => [ /* telephone */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiAdrPcode' => [ /* postal code */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiAdrRegion' => [ /* province/state */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CiUrlWork' => [ /* url. Multiple may be separated by comma. */
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ /* End contact info struct properties */
+ ],
+ 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/' => [
+ 'Event' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ ],
+ 'OrganisationInImageName' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ 'map_name' => 'OrganisationInImage'
+ ],
+ 'PersonInImage' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_BAG,
+ ],
+ 'MaxAvailHeight' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ 'map_name' => 'OriginalImageHeight',
+ ],
+ 'MaxAvailWidth' => [
+ 'map_group' => 'general',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'validate' => 'validateInteger',
+ 'map_name' => 'OriginalImageWidth',
+ ],
+ // LocationShown and LocationCreated are handled
+ // specially because they are hierarchical, but we
+ // also want to merge with the old non-hierarchical.
+ 'LocationShown' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_BAGSTRUCT,
+ 'children' => [
+ 'WorldRegion' => true,
+ 'CountryCode' => true, /* iso code */
+ 'CountryName' => true,
+ 'ProvinceState' => true,
+ 'City' => true,
+ 'Sublocation' => true,
+ ],
+ ],
+ 'LocationCreated' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_BAGSTRUCT,
+ 'children' => [
+ 'WorldRegion' => true,
+ 'CountryCode' => true, /* iso code */
+ 'CountryName' => true,
+ 'ProvinceState' => true,
+ 'City' => true,
+ 'Sublocation' => true,
+ ],
+ ],
+ 'WorldRegion' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CountryCode' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'CountryName' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ 'map_name' => 'Country',
+ ],
+ 'ProvinceState' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ 'map_name' => 'ProvinceOrState',
+ ],
+ 'City' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+ 'Sublocation' => [
+ 'map_group' => 'special',
+ 'mode' => XMPReader::MODE_SIMPLE,
+ 'structPart' => true,
+ ],
+
+ /* Other props that might be interesting but
+ * Not currently extracted:
+ * ArtworkOrObject, (info about objects in picture)
+ * DigitalSourceType
+ * RegistryId
+ */
+ ],
+
+ /* Plus props we might want to consider:
+ * (Note: some of these have unclear/incomplete definitions
+ * from the iptc4xmp standard).
+ * ImageSupplier (kind of like iptc source field)
+ * ImageSupplierId (id code for image from supplier)
+ * CopyrightOwner
+ * ImageCreator
+ * Licensor
+ * Various model release fields
+ * Property release fields.
+ */
+ ];
+}
diff --git a/www/wiki/includes/media/XMPValidate.php b/www/wiki/includes/media/XMPValidate.php
new file mode 100644
index 00000000..fe47f474
--- /dev/null
+++ b/www/wiki/includes/media/XMPValidate.php
@@ -0,0 +1,398 @@
+<?php
+/**
+ * Methods for validating XMP properties.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Media
+ */
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerAwareInterface;
+
+/**
+ * This contains some static methods for
+ * validating XMP properties. See XMPInfo and XMPReader classes.
+ *
+ * Each of these functions take the same parameters
+ * * an info array which is a subset of the XMPInfo::items array
+ * * A value (passed as reference) to validate. This can be either a
+ * simple value or an array
+ * * A boolean to determine if this is validating a simple or complex values
+ *
+ * It should be noted that when an array is being validated, typically the validation
+ * function is called once for each value, and then once at the end for the entire array.
+ *
+ * These validation functions can also be used to modify the data. See the gps and flash one's
+ * for example.
+ *
+ * @see http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart1.pdf starting at pg 28
+ * @see http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart2.pdf starting at pg 11
+ */
+class XMPValidate implements LoggerAwareInterface {
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ public function __construct( LoggerInterface $logger ) {
+ $this->setLogger( $logger );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+ /**
+ * Function to validate boolean properties ( True or False )
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateBoolean( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( $val !== 'True' && $val !== 'False' ) {
+ $this->logger->info( __METHOD__ . " Expected True or False but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate rational properties ( 12/10 )
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateRational( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( !preg_match( '/^(?:-?\d+)\/(?:\d+[1-9]|[1-9]\d*)$/D', $val ) ) {
+ $this->logger->info( __METHOD__ . " Expected rational but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate rating properties -1, 0-5
+ *
+ * if its outside of range put it into range.
+ *
+ * @see MWG spec
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateRating( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( !preg_match( '/^[-+]?\d*(?:\.?\d*)$/D', $val )
+ || !is_numeric( $val )
+ ) {
+ $this->logger->info( __METHOD__ . " Expected rating but got $val" );
+ $val = null;
+
+ return;
+ } else {
+ $nVal = (float)$val;
+ if ( $nVal < 0 ) {
+ // We do < 0 here instead of < -1 here, since
+ // the values between 0 and -1 are also illegal
+ // as -1 is meant as a special reject rating.
+ $this->logger->info( __METHOD__ . " Rating too low, setting to -1 (Rejected)" );
+ $val = '-1';
+
+ return;
+ }
+ if ( $nVal > 5 ) {
+ $this->logger->info( __METHOD__ . " Rating too high, setting to 5" );
+ $val = '5';
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * function to validate integers
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateInteger( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( !preg_match( '/^[-+]?\d+$/D', $val ) ) {
+ $this->logger->info( __METHOD__ . " Expected integer but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate properties with a fixed number of allowed
+ * choices. (closed choice)
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateClosed( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+
+ // check if its in a numeric range
+ $inRange = false;
+ if ( isset( $info['rangeLow'] )
+ && isset( $info['rangeHigh'] )
+ && is_numeric( $val )
+ && ( intval( $val ) <= $info['rangeHigh'] )
+ && ( intval( $val ) >= $info['rangeLow'] )
+ ) {
+ $inRange = true;
+ }
+
+ if ( !isset( $info['choices'][$val] ) && !$inRange ) {
+ $this->logger->info( __METHOD__ . " Expected closed choice, but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate and modify flash structure
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateFlash( $info, &$val, $standalone ) {
+ if ( $standalone ) {
+ // this only validates flash structs, not individual properties
+ return;
+ }
+ if ( !( isset( $val['Fired'] )
+ && isset( $val['Function'] )
+ && isset( $val['Mode'] )
+ && isset( $val['RedEyeMode'] )
+ && isset( $val['Return'] )
+ ) ) {
+ $this->logger->info( __METHOD__ . " Flash structure did not have all the required components" );
+ $val = null;
+ } else {
+ $val = ( "\0" | ( $val['Fired'] === 'True' )
+ | ( intval( $val['Return'] ) << 1 )
+ | ( intval( $val['Mode'] ) << 3 )
+ | ( ( $val['Function'] === 'True' ) << 5 )
+ | ( ( $val['RedEyeMode'] === 'True' ) << 6 ) );
+ }
+ }
+
+ /**
+ * function to validate LangCode properties ( en-GB, etc )
+ *
+ * This is just a naive check to make sure it somewhat looks like a lang code.
+ *
+ * @see BCP 47
+ * @see https://wwwimages2.adobe.com/content/dam/Adobe/en/devnet/xmp/pdfs/
+ * XMP%20SDK%20Release%20cc-2014-12/XMPSpecificationPart1.pdf page 22 (section 8.2.2.4)
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateLangCode( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ if ( !preg_match( '/^[-A-Za-z0-9]{2,}$/D', $val ) ) {
+ // this is a rather naive check.
+ $this->logger->info( __METHOD__ . " Expected Lang code but got $val" );
+ $val = null;
+ }
+ }
+
+ /**
+ * function to validate date properties, and convert to (partial) Exif format.
+ *
+ * Dates can be one of the following formats:
+ * YYYY
+ * YYYY-MM
+ * YYYY-MM-DD
+ * YYYY-MM-DDThh:mmTZD
+ * YYYY-MM-DDThh:mm:ssTZD
+ * YYYY-MM-DDThh:mm:ss.sTZD
+ *
+ * @param array $info Information about current property
+ * @param mixed &$val Current value to validate. Converts to TS_EXIF as a side-effect.
+ * in cases where there's only a partial date, it will give things like
+ * 2011:04.
+ * @param bool $standalone If this is a simple property or array
+ */
+ public function validateDate( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ // this only validates standalone properties, not arrays, etc
+ return;
+ }
+ $res = [];
+ // @codingStandardsIgnoreStart Long line that cannot be broken
+ if ( !preg_match(
+ /* ahh! scary regex... */
+ '/^([0-3]\d{3})(?:-([01]\d)(?:-([0-3]\d)(?:T([0-2]\d):([0-6]\d)(?::([0-6]\d)(?:\.\d+)?)?([-+]\d{2}:\d{2}|Z)?)?)?)?$/D',
+ $val, $res )
+ ) {
+ // @codingStandardsIgnoreEnd
+
+ $this->logger->info( __METHOD__ . " Expected date but got $val" );
+ $val = null;
+ } else {
+ /*
+ * $res is formatted as follows:
+ * 0 -> full date.
+ * 1 -> year, 2-> month, 3-> day, 4-> hour, 5-> minute, 6->second
+ * 7-> Timezone specifier (Z or something like +12:30 )
+ * many parts are optional, some aren't. For example if you specify
+ * minute, you must specify hour, day, month, and year but not second or TZ.
+ */
+
+ /*
+ * First of all, if year = 0000, Something is wrongish,
+ * so don't extract. This seems to happen when
+ * some programs convert between metadata formats.
+ */
+ if ( $res[1] === '0000' ) {
+ $this->logger->info( __METHOD__ . " Invalid date (year 0): $val" );
+ $val = null;
+
+ return;
+ }
+
+ if ( !isset( $res[4] ) ) { // hour
+ // just have the year month day (if that)
+ $val = $res[1];
+ if ( isset( $res[2] ) ) {
+ $val .= ':' . $res[2];
+ }
+ if ( isset( $res[3] ) ) {
+ $val .= ':' . $res[3];
+ }
+
+ return;
+ }
+
+ if ( !isset( $res[7] ) || $res[7] === 'Z' ) {
+ // if hour is set, then minute must also be or regex above will fail.
+ $val = $res[1] . ':' . $res[2] . ':' . $res[3]
+ . ' ' . $res[4] . ':' . $res[5];
+ if ( isset( $res[6] ) && $res[6] !== '' ) {
+ $val .= ':' . $res[6];
+ }
+
+ return;
+ }
+
+ // Extra check for empty string necessary due to TZ but no second case.
+ $stripSeconds = false;
+ if ( !isset( $res[6] ) || $res[6] === '' ) {
+ $res[6] = '00';
+ $stripSeconds = true;
+ }
+
+ // Do timezone processing. We've already done the case that tz = Z.
+
+ // We know that if we got to this step, year, month day hour and min must be set
+ // by virtue of regex not failing.
+
+ $unix = wfTimestamp( TS_UNIX, $res[1] . $res[2] . $res[3] . $res[4] . $res[5] . $res[6] );
+ $offset = intval( substr( $res[7], 1, 2 ) ) * 60 * 60;
+ $offset += intval( substr( $res[7], 4, 2 ) ) * 60;
+ if ( substr( $res[7], 0, 1 ) === '-' ) {
+ $offset = -$offset;
+ }
+ $val = wfTimestamp( TS_EXIF, $unix + $offset );
+
+ if ( $stripSeconds ) {
+ // If seconds weren't specified, remove the trailing ':00'.
+ $val = substr( $val, 0, -3 );
+ }
+ }
+ }
+
+ /** function to validate, and more importantly
+ * translate the XMP DMS form of gps coords to
+ * the decimal form we use.
+ *
+ * @see http://www.adobe.com/devnet/xmp/pdfs/XMPSpecificationPart2.pdf
+ * section 1.2.7.4 on page 23
+ *
+ * @param array $info Unused (info about prop)
+ * @param string &$val GPS string in either DDD,MM,SSk or
+ * or DDD,MM.mmk form
+ * @param bool $standalone If its a simple prop (should always be true)
+ */
+ public function validateGPS( $info, &$val, $standalone ) {
+ if ( !$standalone ) {
+ return;
+ }
+
+ $m = [];
+ if ( preg_match(
+ '/(\d{1,3}),(\d{1,2}),(\d{1,2})([NWSE])/D',
+ $val, $m )
+ ) {
+ $coord = intval( $m[1] );
+ $coord += intval( $m[2] ) * ( 1 / 60 );
+ $coord += intval( $m[3] ) * ( 1 / 3600 );
+ if ( $m[4] === 'S' || $m[4] === 'W' ) {
+ $coord = -$coord;
+ }
+ $val = $coord;
+
+ return;
+ } elseif ( preg_match(
+ '/(\d{1,3}),(\d{1,2}(?:.\d*)?)([NWSE])/D',
+ $val, $m )
+ ) {
+ $coord = intval( $m[1] );
+ $coord += floatval( $m[2] ) * ( 1 / 60 );
+ if ( $m[3] === 'S' || $m[3] === 'W' ) {
+ $coord = -$coord;
+ }
+ $val = $coord;
+
+ return;
+ } else {
+ $this->logger->info( __METHOD__
+ . " Expected GPSCoordinate, but got $val." );
+ $val = null;
+
+ return;
+ }
+ }
+}
diff --git a/www/wiki/includes/media/tinyrgb.icc b/www/wiki/includes/media/tinyrgb.icc
new file mode 100644
index 00000000..eab973f5
--- /dev/null
+++ b/www/wiki/includes/media/tinyrgb.icc
Binary files differ
diff --git a/www/wiki/includes/mime.info b/www/wiki/includes/mime.info
new file mode 100644
index 00000000..b04d3c68
--- /dev/null
+++ b/www/wiki/includes/mime.info
@@ -0,0 +1,119 @@
+# MIME type info file.
+# the first MIME type in each line is the "main" MIME type,
+# the others are aliases for this type
+# the media type is given in upper case and square brackets,
+# like [BITMAP], and must indicate a media type as defined by
+# the MEDIATYPE_xxx constants in Defines.php
+
+
+image/gif [BITMAP]
+image/png image/x-png [BITMAP]
+image/ief [BITMAP]
+image/jpeg image/pjpeg [BITMAP]
+image/jp2 [BITMAP]
+image/xbm [BITMAP]
+image/tiff [BITMAP]
+image/x-icon image/x-ico image/vnd.microsoft.icon [BITMAP]
+image/x-rgb [BITMAP]
+image/x-portable-pixmap [BITMAP]
+image/x-portable-graymap image/x-portable-greymap [BITMAP]
+image/x-bmp image/x-ms-bmp image/bmp application/x-bmp application/bmp [BITMAP]
+image/x-photoshop image/psd image/x-psd image/photoshop image/vnd.adobe.photoshop [BITMAP]
+image/vnd.djvu image/x.djvu image/x-djvu [BITMAP]
+image/webp [BITMAP]
+
+image/svg+xml application/svg+xml application/svg image/svg [DRAWING]
+application/postscript [DRAWING]
+application/x-latex [DRAWING]
+application/x-tex [DRAWING]
+application/x-dia-diagram [DRAWING]
+
+
+audio/mpeg audio/mp3 audio/mpeg3 [AUDIO]
+audio/mp4 [AUDIO]
+audio/wav audio/x-wav audio/wave [AUDIO]
+audio/midi audio/mid [AUDIO]
+audio/basic [AUDIO]
+audio/ogg [AUDIO]
+audio/x-aiff [AUDIO]
+audio/x-pn-realaudio [AUDIO]
+audio/x-realaudio [AUDIO]
+audio/webm [AUDIO]
+audio/x-matroska [AUDIO]
+audio/x-flac [AUDIO]
+audio/flac [AUDIO]
+
+video/mpeg application/mpeg [VIDEO]
+video/ogg [VIDEO]
+video/x-sgi-video [VIDEO]
+video/x-flv [VIDEO]
+video/webm [VIDEO]
+video/x-matroska [VIDEO]
+video/mp4 [VIDEO]
+
+application/ogg application/x-ogg audio/ogg audio/x-ogg video/ogg video/x-ogg [MULTIMEDIA]
+
+application/x-shockwave-flash [MULTIMEDIA]
+audio/x-pn-realaudio-plugin [MULTIMEDIA]
+model/iges [MULTIMEDIA]
+model/mesh [MULTIMEDIA]
+model/vrml [MULTIMEDIA]
+video/quicktime [MULTIMEDIA]
+video/x-msvideo [MULTIMEDIA]
+
+text/plain [TEXT]
+text/html application/xhtml+xml [TEXT]
+application/xml text/xml [TEXT]
+text [TEXT]
+application/json [TEXT]
+text/csv [TEXT]
+text/tab-separated-values [TEXT]
+
+application/zip application/x-zip [ARCHIVE]
+application/x-gzip [ARCHIVE]
+application/x-bzip [ARCHIVE]
+application/x-bzip2 [ARCHIVE]
+application/x-tar [ARCHIVE]
+application/x-stuffit [ARCHIVE]
+application/x-opc+zip [ARCHIVE]
+application/x-7z-compressed [ARCHIVE]
+
+application/javascript text/javascript application/x-javascript application/x-ecmascript text/ecmascript [EXECUTABLE]
+application/x-bash [EXECUTABLE]
+application/x-sh [EXECUTABLE]
+application/x-csh [EXECUTABLE]
+application/x-tcsh [EXECUTABLE]
+application/x-tcl [EXECUTABLE]
+application/x-perl [EXECUTABLE]
+application/x-python [EXECUTABLE]
+
+application/pdf application/acrobat [OFFICE]
+application/msword [OFFICE]
+application/vnd.ms-excel [OFFICE]
+application/vnd.ms-powerpoint [OFFICE]
+application/x-director [OFFICE]
+text/rtf [OFFICE]
+
+application/vnd.openxmlformats-officedocument.wordprocessingml.document [OFFICE]
+application/vnd.openxmlformats-officedocument.wordprocessingml.template [OFFICE]
+application/vnd.ms-word.document.macroEnabled.12 [OFFICE]
+application/vnd.ms-word.template.macroEnabled.12 [OFFICE]
+application/vnd.openxmlformats-officedocument.presentationml.template [OFFICE]
+application/vnd.openxmlformats-officedocument.presentationml.slideshow [OFFICE]
+application/vnd.openxmlformats-officedocument.presentationml.presentation [OFFICE]
+application/vnd.ms-powerpoint.addin.macroEnabled.12 [OFFICE]
+application/vnd.ms-powerpoint.presentation.macroEnabled.12 [OFFICE]
+application/vnd.ms-powerpoint.presentation.macroEnabled.12 [OFFICE]
+application/vnd.ms-powerpoint.slideshow.macroEnabled.12 [OFFICE]
+application/vnd.openxmlformats-officedocument.spreadsheetml.sheet [OFFICE]
+application/vnd.openxmlformats-officedocument.spreadsheetml.template [OFFICE]
+application/vnd.ms-excel.sheet.macroEnabled.12 [OFFICE]
+application/vnd.ms-excel.template.macroEnabled.12 [OFFICE]
+application/vnd.ms-excel.addin.macroEnabled.12 [OFFICE]
+application/vnd.ms-excel.sheet.binary.macroEnabled.12 [OFFICE]
+application/acad application/x-acad application/autocad_dwg image/x-dwg application/dwg application/x-dwg application/x-autocad image/vnd.dwg drawing/dwg [DRAWING]
+chemical/x-mdl-molfile [DRAWING]
+chemical/x-mdl-sdfile [DRAWING]
+chemical/x-mdl-rxnfile [DRAWING]
+chemical/x-mdl-rdfile [DRAWING]
+chemical/x-mdl-rgfile [DRAWING]
diff --git a/www/wiki/includes/mime.types b/www/wiki/includes/mime.types
new file mode 100644
index 00000000..1ef4d26f
--- /dev/null
+++ b/www/wiki/includes/mime.types
@@ -0,0 +1,186 @@
+application/acad dwg
+application/andrew-inset ez
+application/mac-binhex40 hqx
+application/mac-compactpro cpt
+application/mathml+xml mathml
+application/msword doc dot
+application/octet-stream bin dms lha lzh exe class so dll
+application/oda oda
+application/ogg ogx ogg ogm ogv oga spx opus
+application/pdf pdf
+application/postscript ai eps ps
+application/rdf+xml rdf
+application/smil smi smil
+application/srgs gram
+application/srgs+xml grxml
+application/vnd.mif mif
+application/vnd.ms-excel xls xlt xla
+application/vnd.ms-powerpoint ppt pot pps ppa
+application/vnd.wap.wbxml wbxml
+application/vnd.wap.wmlc wmlc
+application/vnd.wap.wmlscriptc wmlsc
+application/voicexml+xml vxml
+application/x-7z-compressed 7z
+application/x-bcpio bcpio
+application/x-bzip bz
+application/x-bzip2 bz2
+application/x-cdlink vcd
+application/x-chess-pgn pgn
+application/x-cpio cpio
+application/x-csh csh
+application/x-dia-diagram dia
+application/x-director dcr dir dxr
+application/x-dvi dvi
+application/x-futuresplash spl
+application/x-gtar gtar tar
+application/x-gzip gz
+application/x-hdf hdf
+application/x-jar jar
+application/javascript js
+application/json json
+application/x-koan skp skd skt skm
+application/x-latex latex
+application/x-netcdf nc cdf
+application/x-sh sh
+application/x-shar shar
+application/x-shockwave-flash swf
+application/x-stuffit sit
+application/x-sv4cpio sv4cpio
+application/x-sv4crc sv4crc
+application/x-tar tar
+application/x-tcl tcl
+application/x-tex tex
+application/x-texinfo texinfo texi
+application/x-troff t tr roff
+application/x-troff-man man
+application/x-troff-me me
+application/x-troff-ms ms
+application/x-ustar ustar
+application/x-wais-source src
+application/x-xpinstall xpi
+application/xhtml+xml xhtml xht
+application/xslt+xml xslt
+application/xml xml xsl xsd kml
+application/xml-dtd dtd
+application/zip zip jar xpi sxc stc sxd std sxi sti sxm stm sxw stw
+application/x-rar rar
+application/font-woff woff
+application/font-woff2 woff2
+application/vnd.ms-fontobject eot
+application/x-font-ttf ttf
+audio/basic au snd
+audio/midi mid midi kar
+audio/mpeg mpga mp2 mp3
+audio/ogg oga ogg spx opus
+video/webm webm
+audio/webm webm
+audio/x-aiff aif aiff aifc
+audio/x-matroska mka mkv
+audio/x-mpegurl m3u
+audio/x-ogg oga ogg spx opus
+audio/x-pn-realaudio ram rm
+audio/x-pn-realaudio-plugin rpm
+audio/x-realaudio ra
+audio/x-wav wav
+audio/wav wav
+audio/x-flac flac
+audio/flac flac
+chemical/x-pdb pdb
+chemical/x-xyz xyz
+image/bmp bmp
+image/cgm cgm
+image/gif gif
+image/ief ief
+image/jp2 j2k jp2 jpg2
+image/jpeg jpeg jpg jpe
+image/png png apng
+image/svg+xml svg
+image/tiff tiff tif
+image/vnd.djvu djvu djv
+image/vnd.microsoft.icon ico
+image/vnd.wap.wbmp wbmp
+image/webp webp
+image/x-cmu-raster ras
+image/x-icon ico
+image/x-ms-bmp bmp
+image/x-portable-anymap pnm
+image/x-portable-bitmap pbm
+image/x-portable-graymap pgm
+image/x-portable-pixmap ppm
+image/x-rgb rgb
+image/x-photoshop psd
+image/x-xbitmap xbm
+image/x-xpixmap xpm
+image/x-xwindowdump xwd
+model/iges igs iges
+model/mesh msh mesh silo
+model/vrml wrl vrml
+text/calendar ics ifb
+text/css css
+text/csv csv
+text/html html htm
+text/plain txt
+text/richtext rtx
+text/rtf rtf
+text/sgml sgml sgm
+text/tab-separated-values tsv
+text/vnd.wap.wml wml
+text/vnd.wap.wmlscript wmls
+text/xml xml xsl xslt rss rdf
+text/x-component htc
+text/x-setext etx
+text/x-sawfish jl
+video/mpeg mpeg mpg mpe
+video/mp4 mp4 m4a m4p m4b m4r m4v
+audio/mp4 m4a
+video/ogg ogv ogm ogg
+video/quicktime qt mov
+video/vnd.mpegurl mxu
+video/x-flv flv
+video/x-matroska mkv mka
+video/x-msvideo avi
+video/x-ogg ogv ogm ogg
+video/x-sgi-movie movie
+x-conference/x-cooltalk ice
+application/vnd.oasis.opendocument.chart odc
+application/vnd.oasis.opendocument.chart-template otc
+application/vnd.oasis.opendocument.database odb
+application/vnd.oasis.opendocument.formula odf
+application/vnd.oasis.opendocument.formula-template otf
+application/vnd.oasis.opendocument.graphics odg
+application/vnd.oasis.opendocument.graphics-template otg
+application/vnd.oasis.opendocument.image odi
+application/vnd.oasis.opendocument.image-template oti
+application/vnd.oasis.opendocument.presentation odp
+application/vnd.oasis.opendocument.presentation-template otp
+application/vnd.oasis.opendocument.spreadsheet ods
+application/vnd.oasis.opendocument.spreadsheet-template ots
+application/vnd.oasis.opendocument.text odt
+application/vnd.oasis.opendocument.text-master odm
+application/vnd.oasis.opendocument.text-template ott
+application/vnd.oasis.opendocument.text-web oth
+application/vnd.openxmlformats-officedocument.wordprocessingml.document docx
+application/vnd.openxmlformats-officedocument.wordprocessingml.template dotx
+application/vnd.ms-word.document.macroEnabled.12 docm
+application/vnd.ms-word.template.macroEnabled.12 dotm
+application/vnd.openxmlformats-officedocument.presentationml.template potx
+application/vnd.openxmlformats-officedocument.presentationml.slideshow ppsx
+application/vnd.openxmlformats-officedocument.presentationml.presentation pptx
+application/vnd.ms-powerpoint.addin.macroEnabled.12 ppam
+application/vnd.ms-powerpoint.presentation.macroEnabled.12 pptm
+application/vnd.ms-powerpoint.presentation.macroEnabled.12 potm
+application/vnd.ms-powerpoint.slideshow.macroEnabled.12 ppsm
+application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx
+application/vnd.openxmlformats-officedocument.spreadsheetml.template xltx
+application/vnd.ms-excel.sheet.macroEnabled.12 xlsm
+application/vnd.ms-excel.template.macroEnabled.12 xltm
+application/vnd.ms-excel.addin.macroEnabled.12 xlam
+application/vnd.ms-excel.sheet.binary.macroEnabled.12 xlsb
+model/vnd.dwfx+xps dwfx
+application/vnd.ms-xpsdocument xps
+application/x-opc+zip docx dotx docm dotm potx ppsx pptx ppam pptm potm ppsm xlsx xltx xlsm xltm xlam xlsb dwfx xps
+chemical/x-mdl-molfile mol
+chemical/x-mdl-sdfile sdf
+chemical/x-mdl-rxnfile rxn
+chemical/x-mdl-rdfile rd
+chemical/x-mdl-rgfile rg
diff --git a/www/wiki/includes/objectcache/MemcachedPeclBagOStuff.php b/www/wiki/includes/objectcache/MemcachedPeclBagOStuff.php
new file mode 100644
index 00000000..5ca8560f
--- /dev/null
+++ b/www/wiki/includes/objectcache/MemcachedPeclBagOStuff.php
@@ -0,0 +1,241 @@
+<?php
+/**
+ * Object caching using memcached.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+/**
+ * A wrapper class for the PECL memcached client
+ *
+ * @ingroup Cache
+ */
+class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
+
+ /**
+ * Constructor
+ *
+ * Available parameters are:
+ * - servers: The list of IP:port combinations holding the memcached servers.
+ * - persistent: Whether to use a persistent connection
+ * - compress_threshold: The minimum size an object must be before it is compressed
+ * - timeout: The read timeout in microseconds
+ * - connect_timeout: The connect timeout in seconds
+ * - retry_timeout: Time in seconds to wait before retrying a failed connect attempt
+ * - server_failure_limit: Limit for server connect failures before it is removed
+ * - serializer: May be either "php" or "igbinary". Igbinary produces more compact
+ * values, but serialization is much slower unless the php.ini option
+ * igbinary.compact_strings is off.
+ * @param array $params
+ * @throws InvalidArgumentException
+ */
+ function __construct( $params ) {
+ parent::__construct( $params );
+ $params = $this->applyDefaultParams( $params );
+
+ if ( $params['persistent'] ) {
+ // The pool ID must be unique to the server/option combination.
+ // The Memcached object is essentially shared for each pool ID.
+ // We can only reuse a pool ID if we keep the config consistent.
+ $this->client = new Memcached( md5( serialize( $params ) ) );
+ if ( count( $this->client->getServerList() ) ) {
+ $this->logger->debug( __METHOD__ . ": persistent Memcached object already loaded." );
+ return; // already initialized; don't add duplicate servers
+ }
+ } else {
+ $this->client = new Memcached;
+ }
+
+ if ( !isset( $params['serializer'] ) ) {
+ $params['serializer'] = 'php';
+ }
+
+ if ( isset( $params['retry_timeout'] ) ) {
+ $this->client->setOption( Memcached::OPT_RETRY_TIMEOUT, $params['retry_timeout'] );
+ }
+
+ if ( isset( $params['server_failure_limit'] ) ) {
+ $this->client->setOption( Memcached::OPT_SERVER_FAILURE_LIMIT, $params['server_failure_limit'] );
+ }
+
+ // The compression threshold is an undocumented php.ini option for some
+ // reason. There's probably not much harm in setting it globally, for
+ // compatibility with the settings for the PHP client.
+ ini_set( 'memcached.compression_threshold', $params['compress_threshold'] );
+
+ // Set timeouts
+ $this->client->setOption( Memcached::OPT_CONNECT_TIMEOUT, $params['connect_timeout'] * 1000 );
+ $this->client->setOption( Memcached::OPT_SEND_TIMEOUT, $params['timeout'] );
+ $this->client->setOption( Memcached::OPT_RECV_TIMEOUT, $params['timeout'] );
+ $this->client->setOption( Memcached::OPT_POLL_TIMEOUT, $params['timeout'] / 1000 );
+
+ // Set libketama mode since it's recommended by the documentation and
+ // is as good as any. There's no way to configure libmemcached to use
+ // hashes identical to the ones currently in use by the PHP client, and
+ // even implementing one of the libmemcached hashes in pure PHP for
+ // forwards compatibility would require MemcachedClient::get_sock() to be
+ // rewritten.
+ $this->client->setOption( Memcached::OPT_LIBKETAMA_COMPATIBLE, true );
+
+ // Set the serializer
+ switch ( $params['serializer'] ) {
+ case 'php':
+ $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP );
+ break;
+ case 'igbinary':
+ if ( !Memcached::HAVE_IGBINARY ) {
+ throw new InvalidArgumentException(
+ __CLASS__ . ': the igbinary extension is not available ' .
+ 'but igbinary serialization was requested.'
+ );
+ }
+ $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY );
+ break;
+ default:
+ throw new InvalidArgumentException(
+ __CLASS__ . ': invalid value for serializer parameter'
+ );
+ }
+ $servers = [];
+ foreach ( $params['servers'] as $host ) {
+ $servers[] = IP::splitHostAndPort( $host ); // (ip, port)
+ }
+ $this->client->addServers( $servers );
+ }
+
+ protected function getWithToken( $key, &$casToken, $flags = 0 ) {
+ $this->debugLog( "get($key)" );
+ if ( defined( Memcached::class . '::GET_EXTENDED' ) ) { // v3.0.0
+ $flags = Memcached::GET_EXTENDED;
+ $res = $this->client->get( $this->validateKeyEncoding( $key ), null, $flags );
+ if ( is_array( $res ) ) {
+ $result = $res['value'];
+ $casToken = $res['cas'];
+ } else {
+ $result = false;
+ $casToken = null;
+ }
+ } else {
+ $result = $this->client->get( $this->validateKeyEncoding( $key ), null, $casToken );
+ }
+ $result = $this->checkResult( $key, $result );
+ return $result;
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ $this->debugLog( "set($key)" );
+ return $this->checkResult( $key, parent::set( $key, $value, $exptime ) );
+ }
+
+ protected function cas( $casToken, $key, $value, $exptime = 0 ) {
+ $this->debugLog( "cas($key)" );
+ return $this->checkResult( $key, parent::cas( $casToken, $key, $value, $exptime ) );
+ }
+
+ public function delete( $key ) {
+ $this->debugLog( "delete($key)" );
+ $result = parent::delete( $key );
+ if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTFOUND ) {
+ // "Not found" is counted as success in our interface
+ return true;
+ } else {
+ return $this->checkResult( $key, $result );
+ }
+ }
+
+ public function add( $key, $value, $exptime = 0 ) {
+ $this->debugLog( "add($key)" );
+ return $this->checkResult( $key, parent::add( $key, $value, $exptime ) );
+ }
+
+ public function incr( $key, $value = 1 ) {
+ $this->debugLog( "incr($key)" );
+ $result = $this->client->increment( $key, $value );
+ return $this->checkResult( $key, $result );
+ }
+
+ public function decr( $key, $value = 1 ) {
+ $this->debugLog( "decr($key)" );
+ $result = $this->client->decrement( $key, $value );
+ return $this->checkResult( $key, $result );
+ }
+
+ /**
+ * Check the return value from a client method call and take any necessary
+ * action. Returns the value that the wrapper function should return. At
+ * present, the return value is always the same as the return value from
+ * the client, but some day we might find a case where it should be
+ * different.
+ *
+ * @param string $key The key used by the caller, or false if there wasn't one.
+ * @param mixed $result The return value
+ * @return mixed
+ */
+ protected function checkResult( $key, $result ) {
+ if ( $result !== false ) {
+ return $result;
+ }
+ switch ( $this->client->getResultCode() ) {
+ case Memcached::RES_SUCCESS:
+ break;
+ case Memcached::RES_DATA_EXISTS:
+ case Memcached::RES_NOTSTORED:
+ case Memcached::RES_NOTFOUND:
+ $this->debugLog( "result: " . $this->client->getResultMessage() );
+ break;
+ default:
+ $msg = $this->client->getResultMessage();
+ $logCtx = [];
+ if ( $key !== false ) {
+ $server = $this->client->getServerByKey( $key );
+ $logCtx['memcached-server'] = "{$server['host']}:{$server['port']}";
+ $logCtx['memcached-key'] = $key;
+ $msg = "Memcached error for key \"{memcached-key}\" on server \"{memcached-server}\": $msg";
+ } else {
+ $msg = "Memcached error: $msg";
+ }
+ $this->logger->error( $msg, $logCtx );
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
+ }
+ return $result;
+ }
+
+ public function getMulti( array $keys, $flags = 0 ) {
+ $this->debugLog( 'getMulti(' . implode( ', ', $keys ) . ')' );
+ foreach ( $keys as $key ) {
+ $this->validateKeyEncoding( $key );
+ }
+ $result = $this->client->getMulti( $keys ) ?: [];
+ return $this->checkResult( false, $result );
+ }
+
+ /**
+ * @param array $data
+ * @param int $exptime
+ * @return bool
+ */
+ public function setMulti( array $data, $exptime = 0 ) {
+ $this->debugLog( 'setMulti(' . implode( ', ', array_keys( $data ) ) . ')' );
+ foreach ( array_keys( $data ) as $key ) {
+ $this->validateKeyEncoding( $key );
+ }
+ $result = $this->client->setMulti( $data, $this->fixExpiry( $exptime ) );
+ return $this->checkResult( false, $result );
+ }
+}
diff --git a/www/wiki/includes/objectcache/ObjectCache.php b/www/wiki/includes/objectcache/ObjectCache.php
new file mode 100644
index 00000000..3370e5b9
--- /dev/null
+++ b/www/wiki/includes/objectcache/ObjectCache.php
@@ -0,0 +1,404 @@
+<?php
+/**
+ * Functions to get cache objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Functions to get cache objects
+ *
+ * The word "cache" has two main dictionary meanings, and both
+ * are used in this factory class. They are:
+ *
+ * - a) Cache (the computer science definition).
+ * A place to store copies or computations on existing data for
+ * higher access speeds.
+ * - b) Storage.
+ * A place to store lightweight data that is not canonically
+ * stored anywhere else (e.g. a "hoard" of objects).
+ *
+ * The former should always use strongly consistent stores, so callers don't
+ * have to deal with stale reads. The latter may be eventually consistent, but
+ * callers can use BagOStuff:READ_LATEST to see the latest available data.
+ *
+ * Primary entry points:
+ *
+ * - ObjectCache::getMainWANInstance()
+ * Purpose: Memory cache.
+ * Stored in the local data-center's main cache (keyspace different from local-cluster cache).
+ * Delete events are broadcasted to other DCs main cache. See WANObjectCache for details.
+ *
+ * - ObjectCache::getLocalServerInstance( $fallbackType )
+ * Purpose: Memory cache for very hot keys.
+ * Stored only on the individual web server (typically APC or APCu for web requests,
+ * and EmptyBagOStuff in CLI mode).
+ * Not replicated to the other servers.
+ *
+ * - ObjectCache::getLocalClusterInstance()
+ * Purpose: Memory storage for per-cluster coordination and tracking.
+ * A typical use case would be a rate limit counter or cache regeneration mutex.
+ * Stored centrally within the local data-center. Not replicated to other DCs.
+ * Configured by $wgMainCacheType.
+ *
+ * - ObjectCache::getMainStashInstance()
+ * Purpose: Ephemeral global storage.
+ * Stored centrally within the primary data-center.
+ * Changes are applied there first and replicated to other DCs (best-effort).
+ * To retrieve the latest value (e.g. not from a replica DB), use BagOStuff::READ_LATEST.
+ * This store may be subject to LRU style evictions.
+ *
+ * - ObjectCache::getInstance( $cacheType )
+ * Purpose: Special cases (like tiered memory/disk caches).
+ * Get a specific cache type by key in $wgObjectCaches.
+ *
+ * All the above cache instances (BagOStuff and WANObjectCache) have their makeKey()
+ * method scoped to the *current* wiki ID. Use makeGlobalKey() to avoid this scoping
+ * when using keys that need to be shared amongst wikis.
+ *
+ * @ingroup Cache
+ */
+class ObjectCache {
+ /** @var BagOStuff[] Map of (id => BagOStuff) */
+ public static $instances = [];
+ /** @var WANObjectCache[] Map of (id => WANObjectCache) */
+ public static $wanInstances = [];
+
+ /**
+ * Get a cached instance of the specified type of cache object.
+ *
+ * @param string $id A key in $wgObjectCaches.
+ * @return BagOStuff
+ */
+ public static function getInstance( $id ) {
+ if ( !isset( self::$instances[$id] ) ) {
+ self::$instances[$id] = self::newFromId( $id );
+ }
+
+ return self::$instances[$id];
+ }
+
+ /**
+ * Get a cached instance of the specified type of WAN cache object.
+ *
+ * @since 1.26
+ * @param string $id A key in $wgWANObjectCaches.
+ * @return WANObjectCache
+ */
+ public static function getWANInstance( $id ) {
+ if ( !isset( self::$wanInstances[$id] ) ) {
+ self::$wanInstances[$id] = self::newWANCacheFromId( $id );
+ }
+
+ return self::$wanInstances[$id];
+ }
+
+ /**
+ * Create a new cache object of the specified type.
+ *
+ * @param string $id A key in $wgObjectCaches.
+ * @return BagOStuff
+ * @throws InvalidArgumentException
+ */
+ public static function newFromId( $id ) {
+ global $wgObjectCaches;
+
+ if ( !isset( $wgObjectCaches[$id] ) ) {
+ // Always recognize these ones
+ if ( $id === CACHE_NONE ) {
+ return new EmptyBagOStuff();
+ } elseif ( $id === 'hash' ) {
+ return new HashBagOStuff();
+ }
+
+ throw new InvalidArgumentException( "Invalid object cache type \"$id\" requested. " .
+ "It is not present in \$wgObjectCaches." );
+ }
+
+ return self::newFromParams( $wgObjectCaches[$id] );
+ }
+
+ /**
+ * Get the default keyspace for this wiki.
+ *
+ * This is either the value of the `CachePrefix` configuration variable,
+ * or (if the former is unset) the `DBname` configuration variable, with
+ * `DBprefix` (if defined).
+ *
+ * @return string
+ */
+ public static function getDefaultKeyspace() {
+ global $wgCachePrefix;
+
+ $keyspace = $wgCachePrefix;
+ if ( is_string( $keyspace ) && $keyspace !== '' ) {
+ return $keyspace;
+ }
+
+ return wfWikiID();
+ }
+
+ /**
+ * Create a new cache object from parameters.
+ *
+ * @param array $params Must have 'factory' or 'class' property.
+ * - factory: Callback passed $params that returns BagOStuff.
+ * - class: BagOStuff subclass constructed with $params.
+ * - loggroup: Alias to set 'logger' key with LoggerFactory group.
+ * - .. Other parameters passed to factory or class.
+ * @return BagOStuff
+ * @throws InvalidArgumentException
+ */
+ public static function newFromParams( $params ) {
+ if ( isset( $params['loggroup'] ) ) {
+ $params['logger'] = LoggerFactory::getInstance( $params['loggroup'] );
+ } else {
+ $params['logger'] = LoggerFactory::getInstance( 'objectcache' );
+ }
+ if ( !isset( $params['keyspace'] ) ) {
+ $params['keyspace'] = self::getDefaultKeyspace();
+ }
+ if ( isset( $params['factory'] ) ) {
+ return call_user_func( $params['factory'], $params );
+ } elseif ( isset( $params['class'] ) ) {
+ $class = $params['class'];
+ // Automatically set the 'async' update handler
+ $params['asyncHandler'] = isset( $params['asyncHandler'] )
+ ? $params['asyncHandler']
+ : 'DeferredUpdates::addCallableUpdate';
+ // Enable reportDupes by default
+ $params['reportDupes'] = isset( $params['reportDupes'] )
+ ? $params['reportDupes']
+ : true;
+ // Do b/c logic for SqlBagOStuff
+ if ( is_a( $class, SqlBagOStuff::class, true ) ) {
+ if ( isset( $params['server'] ) && !isset( $params['servers'] ) ) {
+ $params['servers'] = [ $params['server'] ];
+ unset( $params['server'] );
+ }
+ // In the past it was not required to set 'dbDirectory' in $wgObjectCaches
+ if ( isset( $params['servers'] ) ) {
+ foreach ( $params['servers'] as &$server ) {
+ if ( $server['type'] === 'sqlite' && !isset( $server['dbDirectory'] ) ) {
+ $server['dbDirectory'] = MediaWikiServices::getInstance()
+ ->getMainConfig()->get( 'SQLiteDataDir' );
+ }
+ }
+ }
+ }
+
+ // Do b/c logic for MemcachedBagOStuff
+ if ( is_subclass_of( $class, MemcachedBagOStuff::class ) ) {
+ if ( !isset( $params['servers'] ) ) {
+ $params['servers'] = $GLOBALS['wgMemCachedServers'];
+ }
+ if ( !isset( $params['debug'] ) ) {
+ $params['debug'] = $GLOBALS['wgMemCachedDebug'];
+ }
+ if ( !isset( $params['persistent'] ) ) {
+ $params['persistent'] = $GLOBALS['wgMemCachedPersistent'];
+ }
+ if ( !isset( $params['timeout'] ) ) {
+ $params['timeout'] = $GLOBALS['wgMemCachedTimeout'];
+ }
+ }
+ return new $class( $params );
+ } else {
+ throw new InvalidArgumentException( "The definition of cache type \""
+ . print_r( $params, true ) . "\" lacks both "
+ . "factory and class parameters." );
+ }
+ }
+
+ /**
+ * Factory function for CACHE_ANYTHING (referenced from DefaultSettings.php)
+ *
+ * CACHE_ANYTHING means that stuff has to be cached, not caching is not an option.
+ * If a caching method is configured for any of the main caches ($wgMainCacheType,
+ * $wgMessageCacheType, $wgParserCacheType), then CACHE_ANYTHING will effectively
+ * be an alias to the configured cache choice for that.
+ * If no cache choice is configured (by default $wgMainCacheType is CACHE_NONE),
+ * then CACHE_ANYTHING will forward to CACHE_DB.
+ *
+ * @param array $params
+ * @return BagOStuff
+ */
+ public static function newAnything( $params ) {
+ global $wgMainCacheType, $wgMessageCacheType, $wgParserCacheType;
+ $candidates = [ $wgMainCacheType, $wgMessageCacheType, $wgParserCacheType ];
+ foreach ( $candidates as $candidate ) {
+ $cache = false;
+ if ( $candidate !== CACHE_NONE && $candidate !== CACHE_ANYTHING ) {
+ $cache = self::getInstance( $candidate );
+ // CACHE_ACCEL might default to nothing if no APCu
+ // See includes/ServiceWiring.php
+ if ( !( $cache instanceof EmptyBagOStuff ) ) {
+ return $cache;
+ }
+ }
+ }
+
+ if ( MediaWikiServices::getInstance()->isServiceDisabled( 'DBLoadBalancer' ) ) {
+ // The LoadBalancer is disabled, probably because
+ // MediaWikiServices::disableStorageBackend was called.
+ $candidate = CACHE_NONE;
+ } else {
+ $candidate = CACHE_DB;
+ }
+
+ return self::getInstance( $candidate );
+ }
+
+ /**
+ * Factory function for CACHE_ACCEL (referenced from DefaultSettings.php)
+ *
+ * This will look for any APC or APCu style server-local cache.
+ * A fallback cache can be specified if none is found.
+ *
+ * // Direct calls
+ * ObjectCache::getLocalServerInstance( $fallbackType );
+ *
+ * // From $wgObjectCaches via newFromParams()
+ * ObjectCache::getLocalServerInstance( [ 'fallback' => $fallbackType ] );
+ *
+ * @param int|string|array $fallback Fallback cache or parameter map with 'fallback'
+ * @return BagOStuff
+ * @throws InvalidArgumentException
+ * @since 1.27
+ */
+ public static function getLocalServerInstance( $fallback = CACHE_NONE ) {
+ $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
+ if ( $cache instanceof EmptyBagOStuff ) {
+ if ( is_array( $fallback ) ) {
+ $fallback = isset( $fallback['fallback'] ) ? $fallback['fallback'] : CACHE_NONE;
+ }
+ $cache = self::getInstance( $fallback );
+ }
+
+ return $cache;
+ }
+
+ /**
+ * Create a new cache object of the specified type.
+ *
+ * @since 1.26
+ * @param string $id A key in $wgWANObjectCaches.
+ * @return WANObjectCache
+ * @throws UnexpectedValueException
+ */
+ public static function newWANCacheFromId( $id ) {
+ global $wgWANObjectCaches, $wgObjectCaches;
+
+ if ( !isset( $wgWANObjectCaches[$id] ) ) {
+ throw new UnexpectedValueException(
+ "Cache type \"$id\" requested is not present in \$wgWANObjectCaches." );
+ }
+
+ $params = $wgWANObjectCaches[$id];
+ if ( !isset( $wgObjectCaches[$params['cacheId']] ) ) {
+ throw new UnexpectedValueException(
+ "Cache type \"{$params['cacheId']}\" is not present in \$wgObjectCaches." );
+ }
+ $params['store'] = $wgObjectCaches[$params['cacheId']];
+
+ return self::newWANCacheFromParams( $params );
+ }
+
+ /**
+ * Create a new cache object of the specified type.
+ *
+ * @since 1.28
+ * @param array $params
+ * @return WANObjectCache
+ * @throws UnexpectedValueException
+ */
+ public static function newWANCacheFromParams( array $params ) {
+ $erGroup = MediaWikiServices::getInstance()->getEventRelayerGroup();
+ foreach ( $params['channels'] as $action => $channel ) {
+ $params['relayers'][$action] = $erGroup->getRelayer( $channel );
+ $params['channels'][$action] = $channel;
+ }
+ $params['cache'] = self::newFromParams( $params['store'] );
+ if ( isset( $params['loggroup'] ) ) {
+ $params['logger'] = LoggerFactory::getInstance( $params['loggroup'] );
+ } else {
+ $params['logger'] = LoggerFactory::getInstance( 'objectcache' );
+ }
+ $class = $params['class'];
+
+ return new $class( $params );
+ }
+
+ /**
+ * Get the main cluster-local cache object.
+ *
+ * @since 1.27
+ * @return BagOStuff
+ */
+ public static function getLocalClusterInstance() {
+ global $wgMainCacheType;
+
+ return self::getInstance( $wgMainCacheType );
+ }
+
+ /**
+ * Get the main WAN cache object.
+ *
+ * @since 1.26
+ * @return WANObjectCache
+ * @deprecated Since 1.28 Use MediaWikiServices::getMainWANObjectCache()
+ */
+ public static function getMainWANInstance() {
+ return MediaWikiServices::getInstance()->getMainWANObjectCache();
+ }
+
+ /**
+ * Get the cache object for the main stash.
+ *
+ * Stash objects are BagOStuff instances suitable for storing light
+ * weight data that is not canonically stored elsewhere (such as RDBMS).
+ * Stashes should be configured to propagate changes to all data-centers.
+ *
+ * Callers should be prepared for:
+ * - a) Writes to be slower in non-"primary" (e.g. HTTP GET/HEAD only) DCs
+ * - b) Reads to be eventually consistent, e.g. for get()/getMulti()
+ * In general, this means avoiding updates on idempotent HTTP requests and
+ * avoiding an assumption of perfect serializability (or accepting anomalies).
+ * Reads may be eventually consistent or data might rollback as nodes flap.
+ * Callers can use BagOStuff:READ_LATEST to see the latest available data.
+ *
+ * @return BagOStuff
+ * @since 1.26
+ * @deprecated Since 1.28 Use MediaWikiServices::getMainObjectStash
+ */
+ public static function getMainStashInstance() {
+ return MediaWikiServices::getInstance()->getMainObjectStash();
+ }
+
+ /**
+ * Clear all the cached instances.
+ */
+ public static function clear() {
+ self::$instances = [];
+ self::$wanInstances = [];
+ }
+}
diff --git a/www/wiki/includes/objectcache/RedisBagOStuff.php b/www/wiki/includes/objectcache/RedisBagOStuff.php
new file mode 100644
index 00000000..61e6926c
--- /dev/null
+++ b/www/wiki/includes/objectcache/RedisBagOStuff.php
@@ -0,0 +1,412 @@
+<?php
+/**
+ * Object caching using Redis (http://redis.io/).
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Redis-based caching module for redis server >= 2.6.12
+ *
+ * @note: avoid use of Redis::MULTI transactions for twemproxy support
+ */
+class RedisBagOStuff extends BagOStuff {
+ /** @var RedisConnectionPool */
+ protected $redisPool;
+ /** @var array List of server names */
+ protected $servers;
+ /** @var array Map of (tag => server name) */
+ protected $serverTagMap;
+ /** @var bool */
+ protected $automaticFailover;
+
+ /**
+ * Construct a RedisBagOStuff object. Parameters are:
+ *
+ * - servers: An array of server names. A server name may be a hostname,
+ * a hostname/port combination or the absolute path of a UNIX socket.
+ * If a hostname is specified but no port, the standard port number
+ * 6379 will be used. Arrays keys can be used to specify the tag to
+ * hash on in place of the host/port. Required.
+ *
+ * - connectTimeout: The timeout for new connections, in seconds. Optional,
+ * default is 1 second.
+ *
+ * - persistent: Set this to true to allow connections to persist across
+ * multiple web requests. False by default.
+ *
+ * - password: The authentication password, will be sent to Redis in
+ * clear text. Optional, if it is unspecified, no AUTH command will be
+ * sent.
+ *
+ * - automaticFailover: If this is false, then each key will be mapped to
+ * a single server, and if that server is down, any requests for that key
+ * will fail. If this is true, a connection failure will cause the client
+ * to immediately try the next server in the list (as determined by a
+ * consistent hashing algorithm). True by default. This has the
+ * potential to create consistency issues if a server is slow enough to
+ * flap, for example if it is in swap death.
+ * @param array $params
+ */
+ function __construct( $params ) {
+ parent::__construct( $params );
+ $redisConf = [ 'serializer' => 'none' ]; // manage that in this class
+ foreach ( [ 'connectTimeout', 'persistent', 'password' ] as $opt ) {
+ if ( isset( $params[$opt] ) ) {
+ $redisConf[$opt] = $params[$opt];
+ }
+ }
+ $this->redisPool = RedisConnectionPool::singleton( $redisConf );
+
+ $this->servers = $params['servers'];
+ foreach ( $this->servers as $key => $server ) {
+ $this->serverTagMap[is_int( $key ) ? $server : $key] = $server;
+ }
+
+ if ( isset( $params['automaticFailover'] ) ) {
+ $this->automaticFailover = $params['automaticFailover'];
+ } else {
+ $this->automaticFailover = true;
+ }
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ try {
+ $value = $conn->get( $key );
+ $result = $this->unserialize( $value );
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'get', $key, $server, $result );
+ return $result;
+ }
+
+ public function set( $key, $value, $expiry = 0, $flags = 0 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ $expiry = $this->convertToRelative( $expiry );
+ try {
+ if ( $expiry ) {
+ $result = $conn->setex( $key, $expiry, $this->serialize( $value ) );
+ } else {
+ // No expiry, that is very different from zero expiry in Redis
+ $result = $conn->set( $key, $this->serialize( $value ) );
+ }
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'set', $key, $server, $result );
+ return $result;
+ }
+
+ public function delete( $key ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ try {
+ $conn->delete( $key );
+ // Return true even if the key didn't exist
+ $result = true;
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'delete', $key, $server, $result );
+ return $result;
+ }
+
+ public function getMulti( array $keys, $flags = 0 ) {
+ $batches = [];
+ $conns = [];
+ foreach ( $keys as $key ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ continue;
+ }
+ $conns[$server] = $conn;
+ $batches[$server][] = $key;
+ }
+ $result = [];
+ foreach ( $batches as $server => $batchKeys ) {
+ $conn = $conns[$server];
+ try {
+ $conn->multi( Redis::PIPELINE );
+ foreach ( $batchKeys as $key ) {
+ $conn->get( $key );
+ }
+ $batchResult = $conn->exec();
+ if ( $batchResult === false ) {
+ $this->debug( "multi request to $server failed" );
+ continue;
+ }
+ foreach ( $batchResult as $i => $value ) {
+ if ( $value !== false ) {
+ $result[$batchKeys[$i]] = $this->unserialize( $value );
+ }
+ }
+ } catch ( RedisException $e ) {
+ $this->handleException( $conn, $e );
+ }
+ }
+
+ $this->debug( "getMulti for " . count( $keys ) . " keys " .
+ "returned " . count( $result ) . " results" );
+ return $result;
+ }
+
+ /**
+ * @param array $data
+ * @param int $expiry
+ * @return bool
+ */
+ public function setMulti( array $data, $expiry = 0 ) {
+ $batches = [];
+ $conns = [];
+ foreach ( $data as $key => $value ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ continue;
+ }
+ $conns[$server] = $conn;
+ $batches[$server][] = $key;
+ }
+
+ $expiry = $this->convertToRelative( $expiry );
+ $result = true;
+ foreach ( $batches as $server => $batchKeys ) {
+ $conn = $conns[$server];
+ try {
+ $conn->multi( Redis::PIPELINE );
+ foreach ( $batchKeys as $key ) {
+ if ( $expiry ) {
+ $conn->setex( $key, $expiry, $this->serialize( $data[$key] ) );
+ } else {
+ $conn->set( $key, $this->serialize( $data[$key] ) );
+ }
+ }
+ $batchResult = $conn->exec();
+ if ( $batchResult === false ) {
+ $this->debug( "setMulti request to $server failed" );
+ continue;
+ }
+ foreach ( $batchResult as $value ) {
+ if ( $value === false ) {
+ $result = false;
+ }
+ }
+ } catch ( RedisException $e ) {
+ $this->handleException( $server, $conn, $e );
+ $result = false;
+ }
+ }
+
+ return $result;
+ }
+
+ public function add( $key, $value, $expiry = 0 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ $expiry = $this->convertToRelative( $expiry );
+ try {
+ if ( $expiry ) {
+ $result = $conn->set(
+ $key,
+ $this->serialize( $value ),
+ [ 'nx', 'ex' => $expiry ]
+ );
+ } else {
+ $result = $conn->setnx( $key, $this->serialize( $value ) );
+ }
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'add', $key, $server, $result );
+ return $result;
+ }
+
+ /**
+ * Non-atomic implementation of incr().
+ *
+ * Probably all callers actually want incr() to atomically initialise
+ * values to zero if they don't exist, as provided by the Redis INCR
+ * command. But we are constrained by the memcached-like interface to
+ * return null in that case. Once the key exists, further increments are
+ * atomic.
+ * @param string $key Key to increase
+ * @param int $value Value to add to $key (Default 1)
+ * @return int|bool New value or false on failure
+ */
+ public function incr( $key, $value = 1 ) {
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ return false;
+ }
+ if ( !$conn->exists( $key ) ) {
+ return null;
+ }
+ try {
+ // @FIXME: on races, the key may have a 0 TTL
+ $result = $conn->incrBy( $key, $value );
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $conn, $e );
+ }
+
+ $this->logRequest( 'incr', $key, $server, $result );
+ return $result;
+ }
+
+ public function modifySimpleRelayEvent( array $event ) {
+ if ( array_key_exists( 'val', $event ) ) {
+ $event['val'] = serialize( $event['val'] ); // this class uses PHP serialization
+ }
+
+ return $event;
+ }
+
+ /**
+ * @param mixed $data
+ * @return string
+ */
+ protected function serialize( $data ) {
+ // Serialize anything but integers so INCR/DECR work
+ // Do not store integer-like strings as integers to avoid type confusion (bug 60563)
+ return is_int( $data ) ? $data : serialize( $data );
+ }
+
+ /**
+ * @param string $data
+ * @return mixed
+ */
+ protected function unserialize( $data ) {
+ return ctype_digit( $data ) ? intval( $data ) : unserialize( $data );
+ }
+
+ /**
+ * Get a Redis object with a connection suitable for fetching the specified key
+ * @param string $key
+ * @return array (server, RedisConnRef) or (false, false)
+ */
+ protected function getConnection( $key ) {
+ $candidates = array_keys( $this->serverTagMap );
+
+ if ( count( $this->servers ) > 1 ) {
+ ArrayUtils::consistentHashSort( $candidates, $key, '/' );
+ if ( !$this->automaticFailover ) {
+ $candidates = array_slice( $candidates, 0, 1 );
+ }
+ }
+
+ while ( ( $tag = array_shift( $candidates ) ) !== null ) {
+ $server = $this->serverTagMap[$tag];
+ $conn = $this->redisPool->getConnection( $server );
+ if ( !$conn ) {
+ continue;
+ }
+
+ // If automatic failover is enabled, check that the server's link
+ // to its master (if any) is up -- but only if there are other
+ // viable candidates left to consider. Also, getMasterLinkStatus()
+ // does not work with twemproxy, though $candidates will be empty
+ // by now in such cases.
+ if ( $this->automaticFailover && $candidates ) {
+ try {
+ if ( $this->getMasterLinkStatus( $conn ) === 'down' ) {
+ // If the master cannot be reached, fail-over to the next server.
+ // If masters are in data-center A, and slaves in data-center B,
+ // this helps avoid the case were fail-over happens in A but not
+ // to the corresponding server in B (e.g. read/write mismatch).
+ continue;
+ }
+ } catch ( RedisException $e ) {
+ // Server is not accepting commands
+ $this->handleException( $conn, $e );
+ continue;
+ }
+ }
+
+ return [ $server, $conn ];
+ }
+
+ $this->setLastError( BagOStuff::ERR_UNREACHABLE );
+
+ return [ false, false ];
+ }
+
+ /**
+ * Check the master link status of a Redis server that is configured as a slave.
+ * @param RedisConnRef $conn
+ * @return string|null Master link status (either 'up' or 'down'), or null
+ * if the server is not a slave.
+ */
+ protected function getMasterLinkStatus( RedisConnRef $conn ) {
+ $info = $conn->info();
+ return isset( $info['master_link_status'] )
+ ? $info['master_link_status']
+ : null;
+ }
+
+ /**
+ * Log a fatal error
+ * @param string $msg
+ */
+ protected function logError( $msg ) {
+ $this->logger->error( "Redis error: $msg" );
+ }
+
+ /**
+ * The redis extension throws an exception in response to various read, write
+ * and protocol errors. Sometimes it also closes the connection, sometimes
+ * not. The safest response for us is to explicitly destroy the connection
+ * object and let it be reopened during the next request.
+ * @param RedisConnRef $conn
+ * @param Exception $e
+ */
+ protected function handleException( RedisConnRef $conn, $e ) {
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
+ $this->redisPool->handleError( $conn, $e );
+ }
+
+ /**
+ * Send information about a single request to the debug log
+ * @param string $method
+ * @param string $key
+ * @param string $server
+ * @param bool $result
+ */
+ public function logRequest( $method, $key, $server, $result ) {
+ $this->debug( "$method $key on $server: " .
+ ( $result === false ? "failure" : "success" ) );
+ }
+}
diff --git a/www/wiki/includes/objectcache/SqlBagOStuff.php b/www/wiki/includes/objectcache/SqlBagOStuff.php
new file mode 100644
index 00000000..2cfd2a1d
--- /dev/null
+++ b/www/wiki/includes/objectcache/SqlBagOStuff.php
@@ -0,0 +1,822 @@
+<?php
+/**
+ * Object caching using a SQL database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBError;
+use Wikimedia\Rdbms\DBQueryError;
+use Wikimedia\Rdbms\DBConnectionError;
+use \MediaWiki\MediaWikiServices;
+use \Wikimedia\WaitConditionLoop;
+use \Wikimedia\Rdbms\TransactionProfiler;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Class to store objects in the database
+ *
+ * @ingroup Cache
+ */
+class SqlBagOStuff extends BagOStuff {
+ /** @var array[] (server index => server config) */
+ protected $serverInfos;
+ /** @var string[] (server index => tag/host name) */
+ protected $serverTags;
+ /** @var int */
+ protected $numServers;
+ /** @var int */
+ protected $lastExpireAll = 0;
+ /** @var int */
+ protected $purgePeriod = 100;
+ /** @var int */
+ protected $shards = 1;
+ /** @var string */
+ protected $tableName = 'objectcache';
+ /** @var bool */
+ protected $replicaOnly = false;
+ /** @var int */
+ protected $syncTimeout = 3;
+
+ /** @var LoadBalancer|null */
+ protected $separateMainLB;
+ /** @var array */
+ protected $conns;
+ /** @var array UNIX timestamps */
+ protected $connFailureTimes = [];
+ /** @var array Exceptions */
+ protected $connFailureErrors = [];
+
+ /**
+ * Constructor. Parameters are:
+ * - server: A server info structure in the format required by each
+ * element in $wgDBServers.
+ *
+ * - servers: An array of server info structures describing a set of database servers
+ * to distribute keys to. If this is specified, the "server" option will be
+ * ignored. If string keys are used, then they will be used for consistent
+ * hashing *instead* of the host name (from the server config). This is useful
+ * when a cluster is replicated to another site (with different host names)
+ * but each server has a corresponding replica in the other cluster.
+ *
+ * - purgePeriod: The average number of object cache requests in between
+ * garbage collection operations, where expired entries
+ * are removed from the database. Or in other words, the
+ * reciprocal of the probability of purging on any given
+ * request. If this is set to zero, purging will never be
+ * done.
+ *
+ * - tableName: The table name to use, default is "objectcache".
+ *
+ * - shards: The number of tables to use for data storage on each server.
+ * If this is more than 1, table names will be formed in the style
+ * objectcacheNNN where NNN is the shard index, between 0 and
+ * shards-1. The number of digits will be the minimum number
+ * required to hold the largest shard index. Data will be
+ * distributed across all tables by key hash. This is for
+ * MySQL bugs 61735 and 61736.
+ * - slaveOnly: Whether to only use replica DBs and avoid triggering
+ * garbage collection logic of expired items. This only
+ * makes sense if the primary DB is used and only if get()
+ * calls will be used. This is used by ReplicatedBagOStuff.
+ * - syncTimeout: Max seconds to wait for replica DBs to catch up for WRITE_SYNC.
+ *
+ * @param array $params
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ $this->attrMap[self::ATTR_EMULATION] = self::QOS_EMULATION_SQL;
+ $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_NONE;
+
+ if ( isset( $params['servers'] ) ) {
+ $this->serverInfos = [];
+ $this->serverTags = [];
+ $this->numServers = count( $params['servers'] );
+ $index = 0;
+ foreach ( $params['servers'] as $tag => $info ) {
+ $this->serverInfos[$index] = $info;
+ if ( is_string( $tag ) ) {
+ $this->serverTags[$index] = $tag;
+ } else {
+ $this->serverTags[$index] = isset( $info['host'] ) ? $info['host'] : "#$index";
+ }
+ ++$index;
+ }
+ } elseif ( isset( $params['server'] ) ) {
+ $this->serverInfos = [ $params['server'] ];
+ $this->numServers = count( $this->serverInfos );
+ } else {
+ // Default to using the main wiki's database servers
+ $this->serverInfos = false;
+ $this->numServers = 1;
+ $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_BE;
+ }
+ if ( isset( $params['purgePeriod'] ) ) {
+ $this->purgePeriod = intval( $params['purgePeriod'] );
+ }
+ if ( isset( $params['tableName'] ) ) {
+ $this->tableName = $params['tableName'];
+ }
+ if ( isset( $params['shards'] ) ) {
+ $this->shards = intval( $params['shards'] );
+ }
+ if ( isset( $params['syncTimeout'] ) ) {
+ $this->syncTimeout = $params['syncTimeout'];
+ }
+ $this->replicaOnly = !empty( $params['slaveOnly'] );
+ }
+
+ /**
+ * Get a connection to the specified database
+ *
+ * @param int $serverIndex
+ * @return Database
+ * @throws MWException
+ */
+ protected function getDB( $serverIndex ) {
+ if ( !isset( $this->conns[$serverIndex] ) ) {
+ if ( $serverIndex >= $this->numServers ) {
+ throw new MWException( __METHOD__ . ": Invalid server index \"$serverIndex\"" );
+ }
+
+ # Don't keep timing out trying to connect for each call if the DB is down
+ if ( isset( $this->connFailureErrors[$serverIndex] )
+ && ( time() - $this->connFailureTimes[$serverIndex] ) < 60
+ ) {
+ throw $this->connFailureErrors[$serverIndex];
+ }
+
+ if ( $this->serverInfos ) {
+ // Use custom database defined by server connection info
+ $info = $this->serverInfos[$serverIndex];
+ $type = isset( $info['type'] ) ? $info['type'] : 'mysql';
+ $host = isset( $info['host'] ) ? $info['host'] : '[unknown]';
+ $this->logger->debug( __CLASS__ . ": connecting to $host" );
+ // Use a blank trx profiler to ignore expections as this is a cache
+ $info['trxProfiler'] = new TransactionProfiler();
+ $db = Database::factory( $type, $info );
+ $db->clearFlag( DBO_TRX ); // auto-commit mode
+ } else {
+ // Use the main LB database
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $index = $this->replicaOnly ? DB_REPLICA : DB_MASTER;
+ if ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) {
+ // Keep a separate connection to avoid contention and deadlocks
+ $db = $lb->getConnection( $index, [], false, $lb::CONN_TRX_AUTO );
+ // @TODO: Use a blank trx profiler to ignore expections as this is a cache
+ } else {
+ // However, SQLite has the opposite behavior due to DB-level locking.
+ // Stock sqlite MediaWiki installs use a separate sqlite cache DB instead.
+ $db = $lb->getConnection( $index );
+ }
+ }
+
+ $this->logger->debug( sprintf( "Connection %s will be used for SqlBagOStuff", $db ) );
+ $this->conns[$serverIndex] = $db;
+ }
+
+ return $this->conns[$serverIndex];
+ }
+
+ /**
+ * Get the server index and table name for a given key
+ * @param string $key
+ * @return array Server index and table name
+ */
+ protected function getTableByKey( $key ) {
+ if ( $this->shards > 1 ) {
+ $hash = hexdec( substr( md5( $key ), 0, 8 ) ) & 0x7fffffff;
+ $tableIndex = $hash % $this->shards;
+ } else {
+ $tableIndex = 0;
+ }
+ if ( $this->numServers > 1 ) {
+ $sortedServers = $this->serverTags;
+ ArrayUtils::consistentHashSort( $sortedServers, $key );
+ reset( $sortedServers );
+ $serverIndex = key( $sortedServers );
+ } else {
+ $serverIndex = 0;
+ }
+ return [ $serverIndex, $this->getTableNameByShard( $tableIndex ) ];
+ }
+
+ /**
+ * Get the table name for a given shard index
+ * @param int $index
+ * @return string
+ */
+ protected function getTableNameByShard( $index ) {
+ if ( $this->shards > 1 ) {
+ $decimals = strlen( $this->shards - 1 );
+ return $this->tableName .
+ sprintf( "%0{$decimals}d", $index );
+ } else {
+ return $this->tableName;
+ }
+ }
+
+ protected function doGet( $key, $flags = 0 ) {
+ $casToken = null;
+
+ return $this->getWithToken( $key, $casToken, $flags );
+ }
+
+ protected function getWithToken( $key, &$casToken, $flags = 0 ) {
+ $values = $this->getMulti( [ $key ] );
+ if ( array_key_exists( $key, $values ) ) {
+ $casToken = $values[$key];
+ return $values[$key];
+ }
+ return false;
+ }
+
+ public function getMulti( array $keys, $flags = 0 ) {
+ $values = []; // array of (key => value)
+
+ $keysByTable = [];
+ foreach ( $keys as $key ) {
+ list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $keysByTable[$serverIndex][$tableName][] = $key;
+ }
+
+ $this->garbageCollect(); // expire old entries if any
+
+ $dataRows = [];
+ foreach ( $keysByTable as $serverIndex => $serverKeys ) {
+ try {
+ $db = $this->getDB( $serverIndex );
+ foreach ( $serverKeys as $tableName => $tableKeys ) {
+ $res = $db->select( $tableName,
+ [ 'keyname', 'value', 'exptime' ],
+ [ 'keyname' => $tableKeys ],
+ __METHOD__,
+ // Approximate write-on-the-fly BagOStuff API via blocking.
+ // This approximation fails if a ROLLBACK happens (which is rare).
+ // We do not want to flush the TRX as that can break callers.
+ $db->trxLevel() ? [ 'LOCK IN SHARE MODE' ] : []
+ );
+ if ( $res === false ) {
+ continue;
+ }
+ foreach ( $res as $row ) {
+ $row->serverIndex = $serverIndex;
+ $row->tableName = $tableName;
+ $dataRows[$row->keyname] = $row;
+ }
+ }
+ } catch ( DBError $e ) {
+ $this->handleReadError( $e, $serverIndex );
+ }
+ }
+
+ foreach ( $keys as $key ) {
+ if ( isset( $dataRows[$key] ) ) { // HIT?
+ $row = $dataRows[$key];
+ $this->debug( "get: retrieved data; expiry time is " . $row->exptime );
+ $db = null;
+ try {
+ $db = $this->getDB( $row->serverIndex );
+ if ( $this->isExpired( $db, $row->exptime ) ) { // MISS
+ $this->debug( "get: key has expired" );
+ } else { // HIT
+ $values[$key] = $this->unserialize( $db->decodeBlob( $row->value ) );
+ }
+ } catch ( DBQueryError $e ) {
+ $this->handleWriteError( $e, $db, $row->serverIndex );
+ }
+ } else { // MISS
+ $this->debug( 'get: no matching rows' );
+ }
+ }
+
+ return $values;
+ }
+
+ public function setMulti( array $data, $expiry = 0 ) {
+ $keysByTable = [];
+ foreach ( $data as $key => $value ) {
+ list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $keysByTable[$serverIndex][$tableName][] = $key;
+ }
+
+ $this->garbageCollect(); // expire old entries if any
+
+ $result = true;
+ $exptime = (int)$expiry;
+ foreach ( $keysByTable as $serverIndex => $serverKeys ) {
+ $db = null;
+ try {
+ $db = $this->getDB( $serverIndex );
+ } catch ( DBError $e ) {
+ $this->handleWriteError( $e, $db, $serverIndex );
+ $result = false;
+ continue;
+ }
+
+ if ( $exptime < 0 ) {
+ $exptime = 0;
+ }
+
+ if ( $exptime == 0 ) {
+ $encExpiry = $this->getMaxDateTime( $db );
+ } else {
+ $exptime = $this->convertExpiry( $exptime );
+ $encExpiry = $db->timestamp( $exptime );
+ }
+ foreach ( $serverKeys as $tableName => $tableKeys ) {
+ $rows = [];
+ foreach ( $tableKeys as $key ) {
+ $rows[] = [
+ 'keyname' => $key,
+ 'value' => $db->encodeBlob( $this->serialize( $data[$key] ) ),
+ 'exptime' => $encExpiry,
+ ];
+ }
+
+ try {
+ $db->replace(
+ $tableName,
+ [ 'keyname' ],
+ $rows,
+ __METHOD__
+ );
+ } catch ( DBError $e ) {
+ $this->handleWriteError( $e, $db, $serverIndex );
+ $result = false;
+ }
+
+ }
+
+ }
+
+ return $result;
+ }
+
+ public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+ $ok = $this->setMulti( [ $key => $value ], $exptime );
+ if ( ( $flags & self::WRITE_SYNC ) == self::WRITE_SYNC ) {
+ $ok = $this->waitForReplication() && $ok;
+ }
+
+ return $ok;
+ }
+
+ protected function cas( $casToken, $key, $value, $exptime = 0 ) {
+ list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $db = null;
+ try {
+ $db = $this->getDB( $serverIndex );
+ $exptime = intval( $exptime );
+
+ if ( $exptime < 0 ) {
+ $exptime = 0;
+ }
+
+ if ( $exptime == 0 ) {
+ $encExpiry = $this->getMaxDateTime( $db );
+ } else {
+ $exptime = $this->convertExpiry( $exptime );
+ $encExpiry = $db->timestamp( $exptime );
+ }
+ // (T26425) use a replace if the db supports it instead of
+ // delete/insert to avoid clashes with conflicting keynames
+ $db->update(
+ $tableName,
+ [
+ 'keyname' => $key,
+ 'value' => $db->encodeBlob( $this->serialize( $value ) ),
+ 'exptime' => $encExpiry
+ ],
+ [
+ 'keyname' => $key,
+ 'value' => $db->encodeBlob( $this->serialize( $casToken ) )
+ ],
+ __METHOD__
+ );
+ } catch ( DBQueryError $e ) {
+ $this->handleWriteError( $e, $db, $serverIndex );
+
+ return false;
+ }
+
+ return (bool)$db->affectedRows();
+ }
+
+ public function delete( $key ) {
+ list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $db = null;
+ try {
+ $db = $this->getDB( $serverIndex );
+ $db->delete(
+ $tableName,
+ [ 'keyname' => $key ],
+ __METHOD__ );
+ } catch ( DBError $e ) {
+ $this->handleWriteError( $e, $db, $serverIndex );
+ return false;
+ }
+
+ return true;
+ }
+
+ public function incr( $key, $step = 1 ) {
+ list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $db = null;
+ try {
+ $db = $this->getDB( $serverIndex );
+ $step = intval( $step );
+ $row = $db->selectRow(
+ $tableName,
+ [ 'value', 'exptime' ],
+ [ 'keyname' => $key ],
+ __METHOD__,
+ [ 'FOR UPDATE' ] );
+ if ( $row === false ) {
+ // Missing
+
+ return null;
+ }
+ $db->delete( $tableName, [ 'keyname' => $key ], __METHOD__ );
+ if ( $this->isExpired( $db, $row->exptime ) ) {
+ // Expired, do not reinsert
+
+ return null;
+ }
+
+ $oldValue = intval( $this->unserialize( $db->decodeBlob( $row->value ) ) );
+ $newValue = $oldValue + $step;
+ $db->insert( $tableName,
+ [
+ 'keyname' => $key,
+ 'value' => $db->encodeBlob( $this->serialize( $newValue ) ),
+ 'exptime' => $row->exptime
+ ], __METHOD__, 'IGNORE' );
+
+ if ( $db->affectedRows() == 0 ) {
+ // Race condition. See T30611
+ $newValue = null;
+ }
+ } catch ( DBError $e ) {
+ $this->handleWriteError( $e, $db, $serverIndex );
+ return null;
+ }
+
+ return $newValue;
+ }
+
+ public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
+ $ok = $this->mergeViaCas( $key, $callback, $exptime, $attempts );
+ if ( ( $flags & self::WRITE_SYNC ) == self::WRITE_SYNC ) {
+ $ok = $this->waitForReplication() && $ok;
+ }
+
+ return $ok;
+ }
+
+ public function changeTTL( $key, $expiry = 0 ) {
+ list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+ $db = null;
+ try {
+ $db = $this->getDB( $serverIndex );
+ $db->update(
+ $tableName,
+ [ 'exptime' => $db->timestamp( $this->convertExpiry( $expiry ) ) ],
+ [ 'keyname' => $key, 'exptime > ' . $db->addQuotes( $db->timestamp( time() ) ) ],
+ __METHOD__
+ );
+ if ( $db->affectedRows() == 0 ) {
+ return false;
+ }
+ } catch ( DBError $e ) {
+ $this->handleWriteError( $e, $db, $serverIndex );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param IDatabase $db
+ * @param string $exptime
+ * @return bool
+ */
+ protected function isExpired( $db, $exptime ) {
+ return $exptime != $this->getMaxDateTime( $db ) && wfTimestamp( TS_UNIX, $exptime ) < time();
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return string
+ */
+ protected function getMaxDateTime( $db ) {
+ if ( time() > 0x7fffffff ) {
+ return $db->timestamp( 1 << 62 );
+ } else {
+ return $db->timestamp( 0x7fffffff );
+ }
+ }
+
+ protected function garbageCollect() {
+ if ( !$this->purgePeriod || $this->replicaOnly ) {
+ // Disabled
+ return;
+ }
+ // Only purge on one in every $this->purgePeriod requests.
+ if ( $this->purgePeriod !== 1 && mt_rand( 0, $this->purgePeriod - 1 ) ) {
+ return;
+ }
+ $now = time();
+ // Avoid repeating the delete within a few seconds
+ if ( $now > ( $this->lastExpireAll + 1 ) ) {
+ $this->lastExpireAll = $now;
+ $this->expireAll();
+ }
+ }
+
+ public function expireAll() {
+ $this->deleteObjectsExpiringBefore( wfTimestampNow() );
+ }
+
+ /**
+ * Delete objects from the database which expire before a certain date.
+ * @param string $timestamp
+ * @param bool|callable $progressCallback
+ * @return bool
+ */
+ public function deleteObjectsExpiringBefore( $timestamp, $progressCallback = false ) {
+ for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
+ $db = null;
+ try {
+ $db = $this->getDB( $serverIndex );
+ $dbTimestamp = $db->timestamp( $timestamp );
+ $totalSeconds = false;
+ $baseConds = [ 'exptime < ' . $db->addQuotes( $dbTimestamp ) ];
+ for ( $i = 0; $i < $this->shards; $i++ ) {
+ $maxExpTime = false;
+ while ( true ) {
+ $conds = $baseConds;
+ if ( $maxExpTime !== false ) {
+ $conds[] = 'exptime >= ' . $db->addQuotes( $maxExpTime );
+ }
+ $rows = $db->select(
+ $this->getTableNameByShard( $i ),
+ [ 'keyname', 'exptime' ],
+ $conds,
+ __METHOD__,
+ [ 'LIMIT' => 100, 'ORDER BY' => 'exptime' ] );
+ if ( $rows === false || !$rows->numRows() ) {
+ break;
+ }
+ $keys = [];
+ $row = $rows->current();
+ $minExpTime = $row->exptime;
+ if ( $totalSeconds === false ) {
+ $totalSeconds = wfTimestamp( TS_UNIX, $timestamp )
+ - wfTimestamp( TS_UNIX, $minExpTime );
+ }
+ foreach ( $rows as $row ) {
+ $keys[] = $row->keyname;
+ $maxExpTime = $row->exptime;
+ }
+
+ $db->delete(
+ $this->getTableNameByShard( $i ),
+ [
+ 'exptime >= ' . $db->addQuotes( $minExpTime ),
+ 'exptime < ' . $db->addQuotes( $dbTimestamp ),
+ 'keyname' => $keys
+ ],
+ __METHOD__ );
+
+ if ( $progressCallback ) {
+ if ( intval( $totalSeconds ) === 0 ) {
+ $percent = 0;
+ } else {
+ $remainingSeconds = wfTimestamp( TS_UNIX, $timestamp )
+ - wfTimestamp( TS_UNIX, $maxExpTime );
+ if ( $remainingSeconds > $totalSeconds ) {
+ $totalSeconds = $remainingSeconds;
+ }
+ $processedSeconds = $totalSeconds - $remainingSeconds;
+ $percent = ( $i + $processedSeconds / $totalSeconds )
+ / $this->shards * 100;
+ }
+ $percent = ( $percent / $this->numServers )
+ + ( $serverIndex / $this->numServers * 100 );
+ call_user_func( $progressCallback, $percent );
+ }
+ }
+ }
+ } catch ( DBError $e ) {
+ $this->handleWriteError( $e, $db, $serverIndex );
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Delete content of shard tables in every server.
+ * Return true if the operation is successful, false otherwise.
+ * @return bool
+ */
+ public function deleteAll() {
+ for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
+ $db = null;
+ try {
+ $db = $this->getDB( $serverIndex );
+ for ( $i = 0; $i < $this->shards; $i++ ) {
+ $db->delete( $this->getTableNameByShard( $i ), '*', __METHOD__ );
+ }
+ } catch ( DBError $e ) {
+ $this->handleWriteError( $e, $db, $serverIndex );
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Serialize an object and, if possible, compress the representation.
+ * On typical message and page data, this can provide a 3X decrease
+ * in storage requirements.
+ *
+ * @param mixed &$data
+ * @return string
+ */
+ protected function serialize( &$data ) {
+ $serial = serialize( $data );
+
+ if ( function_exists( 'gzdeflate' ) ) {
+ return gzdeflate( $serial );
+ } else {
+ return $serial;
+ }
+ }
+
+ /**
+ * Unserialize and, if necessary, decompress an object.
+ * @param string $serial
+ * @return mixed
+ */
+ protected function unserialize( $serial ) {
+ if ( function_exists( 'gzinflate' ) ) {
+ MediaWiki\suppressWarnings();
+ $decomp = gzinflate( $serial );
+ MediaWiki\restoreWarnings();
+
+ if ( false !== $decomp ) {
+ $serial = $decomp;
+ }
+ }
+
+ $ret = unserialize( $serial );
+
+ return $ret;
+ }
+
+ /**
+ * Handle a DBError which occurred during a read operation.
+ *
+ * @param DBError $exception
+ * @param int $serverIndex
+ */
+ protected function handleReadError( DBError $exception, $serverIndex ) {
+ if ( $exception instanceof DBConnectionError ) {
+ $this->markServerDown( $exception, $serverIndex );
+ }
+ $this->logger->error( "DBError: {$exception->getMessage()}" );
+ if ( $exception instanceof DBConnectionError ) {
+ $this->setLastError( BagOStuff::ERR_UNREACHABLE );
+ $this->logger->debug( __METHOD__ . ": ignoring connection error" );
+ } else {
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
+ $this->logger->debug( __METHOD__ . ": ignoring query error" );
+ }
+ }
+
+ /**
+ * Handle a DBQueryError which occurred during a write operation.
+ *
+ * @param DBError $exception
+ * @param IDatabase|null $db DB handle or null if connection failed
+ * @param int $serverIndex
+ * @throws Exception
+ */
+ protected function handleWriteError( DBError $exception, IDatabase $db = null, $serverIndex ) {
+ if ( !$db ) {
+ $this->markServerDown( $exception, $serverIndex );
+ } elseif ( $db->wasReadOnlyError() ) {
+ if ( $db->trxLevel() && $this->usesMainDB() ) {
+ // Errors like deadlocks and connection drops already cause rollback.
+ // For consistency, we have no choice but to throw an error and trigger
+ // complete rollback if the main DB is also being used as the cache DB.
+ throw $exception;
+ }
+ }
+
+ $this->logger->error( "DBError: {$exception->getMessage()}" );
+ if ( $exception instanceof DBConnectionError ) {
+ $this->setLastError( BagOStuff::ERR_UNREACHABLE );
+ $this->logger->debug( __METHOD__ . ": ignoring connection error" );
+ } else {
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
+ $this->logger->debug( __METHOD__ . ": ignoring query error" );
+ }
+ }
+
+ /**
+ * Mark a server down due to a DBConnectionError exception
+ *
+ * @param DBError $exception
+ * @param int $serverIndex
+ */
+ protected function markServerDown( DBError $exception, $serverIndex ) {
+ unset( $this->conns[$serverIndex] ); // bug T103435
+
+ if ( isset( $this->connFailureTimes[$serverIndex] ) ) {
+ if ( time() - $this->connFailureTimes[$serverIndex] >= 60 ) {
+ unset( $this->connFailureTimes[$serverIndex] );
+ unset( $this->connFailureErrors[$serverIndex] );
+ } else {
+ $this->logger->debug( __METHOD__ . ": Server #$serverIndex already down" );
+ return;
+ }
+ }
+ $now = time();
+ $this->logger->info( __METHOD__ . ": Server #$serverIndex down until " . ( $now + 60 ) );
+ $this->connFailureTimes[$serverIndex] = $now;
+ $this->connFailureErrors[$serverIndex] = $exception;
+ }
+
+ /**
+ * Create shard tables. For use from eval.php.
+ */
+ public function createTables() {
+ for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
+ $db = $this->getDB( $serverIndex );
+ if ( $db->getType() !== 'mysql' ) {
+ throw new MWException( __METHOD__ . ' is not supported on this DB server' );
+ }
+
+ for ( $i = 0; $i < $this->shards; $i++ ) {
+ $db->query(
+ 'CREATE TABLE ' . $db->tableName( $this->getTableNameByShard( $i ) ) .
+ ' LIKE ' . $db->tableName( 'objectcache' ),
+ __METHOD__ );
+ }
+ }
+ }
+
+ /**
+ * @return bool Whether the main DB is used, e.g. wfGetDB( DB_MASTER )
+ */
+ protected function usesMainDB() {
+ return !$this->serverInfos;
+ }
+
+ protected function waitForReplication() {
+ if ( !$this->usesMainDB() ) {
+ // Custom DB server list; probably doesn't use replication
+ return true;
+ }
+
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ if ( $lb->getServerCount() <= 1 ) {
+ return true; // no replica DBs
+ }
+
+ // Main LB is used; wait for any replica DBs to catch up
+ $masterPos = $lb->getMasterPos();
+
+ $loop = new WaitConditionLoop(
+ function () use ( $lb, $masterPos ) {
+ return $lb->waitForAll( $masterPos, 1 );
+ },
+ $this->syncTimeout,
+ $this->busyCallbacks
+ );
+
+ return ( $loop->invoke() === $loop::CONDITION_REACHED );
+ }
+}
diff --git a/www/wiki/includes/page/Article.php b/www/wiki/includes/page/Article.php
new file mode 100644
index 00000000..f03bcc20
--- /dev/null
+++ b/www/wiki/includes/page/Article.php
@@ -0,0 +1,2700 @@
+<?php
+/**
+ * User interface for page actions.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Class for viewing MediaWiki article and history.
+ *
+ * This maintains WikiPage functions for backwards compatibility.
+ *
+ * @todo Move and rewrite code to an Action class
+ *
+ * See design.txt for an overview.
+ * Note: edit user interface and cache support functions have been
+ * moved to separate EditPage and HTMLFileCache classes.
+ */
+class Article implements Page {
+ /** @var IContextSource The context this Article is executed in */
+ protected $mContext;
+
+ /** @var WikiPage The WikiPage object of this instance */
+ protected $mPage;
+
+ /** @var ParserOptions ParserOptions object for $wgUser articles */
+ public $mParserOptions;
+
+ /**
+ * @var string Text of the revision we are working on
+ * @todo BC cruft
+ */
+ public $mContent;
+
+ /**
+ * @var Content Content of the revision we are working on
+ * @since 1.21
+ */
+ public $mContentObject;
+
+ /** @var bool Is the content ($mContent) already loaded? */
+ public $mContentLoaded = false;
+
+ /** @var int|null The oldid of the article that is to be shown, 0 for the current revision */
+ public $mOldId;
+
+ /** @var Title Title from which we were redirected here */
+ public $mRedirectedFrom = null;
+
+ /** @var string|bool URL to redirect to or false if none */
+ public $mRedirectUrl = false;
+
+ /** @var int Revision ID of revision we are working on */
+ public $mRevIdFetched = 0;
+
+ /** @var Revision Revision we are working on */
+ public $mRevision = null;
+
+ /** @var ParserOutput */
+ public $mParserOutput;
+
+ /**
+ * Constructor and clear the article
+ * @param Title $title Reference to a Title object.
+ * @param int $oldId Revision ID, null to fetch from request, zero for current
+ */
+ public function __construct( Title $title, $oldId = null ) {
+ $this->mOldId = $oldId;
+ $this->mPage = $this->newPage( $title );
+ }
+
+ /**
+ * @param Title $title
+ * @return WikiPage
+ */
+ protected function newPage( Title $title ) {
+ return new WikiPage( $title );
+ }
+
+ /**
+ * Constructor from a page id
+ * @param int $id Article ID to load
+ * @return Article|null
+ */
+ public static function newFromID( $id ) {
+ $t = Title::newFromID( $id );
+ return $t == null ? null : new static( $t );
+ }
+
+ /**
+ * Create an Article object of the appropriate class for the given page.
+ *
+ * @param Title $title
+ * @param IContextSource $context
+ * @return Article
+ */
+ public static function newFromTitle( $title, IContextSource $context ) {
+ if ( NS_MEDIA == $title->getNamespace() ) {
+ // FIXME: where should this go?
+ $title = Title::makeTitle( NS_FILE, $title->getDBkey() );
+ }
+
+ $page = null;
+ Hooks::run( 'ArticleFromTitle', [ &$title, &$page, $context ] );
+ if ( !$page ) {
+ switch ( $title->getNamespace() ) {
+ case NS_FILE:
+ $page = new ImagePage( $title );
+ break;
+ case NS_CATEGORY:
+ $page = new CategoryPage( $title );
+ break;
+ default:
+ $page = new Article( $title );
+ }
+ }
+ $page->setContext( $context );
+
+ return $page;
+ }
+
+ /**
+ * Create an Article object of the appropriate class for the given page.
+ *
+ * @param WikiPage $page
+ * @param IContextSource $context
+ * @return Article
+ */
+ public static function newFromWikiPage( WikiPage $page, IContextSource $context ) {
+ $article = self::newFromTitle( $page->getTitle(), $context );
+ $article->mPage = $page; // override to keep process cached vars
+ return $article;
+ }
+
+ /**
+ * Get the page this view was redirected from
+ * @return Title|null
+ * @since 1.28
+ */
+ public function getRedirectedFrom() {
+ return $this->mRedirectedFrom;
+ }
+
+ /**
+ * Tell the page view functions that this view was redirected
+ * from another page on the wiki.
+ * @param Title $from
+ */
+ public function setRedirectedFrom( Title $from ) {
+ $this->mRedirectedFrom = $from;
+ }
+
+ /**
+ * Get the title object of the article
+ *
+ * @return Title Title object of this page
+ */
+ public function getTitle() {
+ return $this->mPage->getTitle();
+ }
+
+ /**
+ * Get the WikiPage object of this instance
+ *
+ * @since 1.19
+ * @return WikiPage
+ */
+ public function getPage() {
+ return $this->mPage;
+ }
+
+ /**
+ * Clear the object
+ */
+ public function clear() {
+ $this->mContentLoaded = false;
+
+ $this->mRedirectedFrom = null; # Title object if set
+ $this->mRevIdFetched = 0;
+ $this->mRedirectUrl = false;
+
+ $this->mPage->clear();
+ }
+
+ /**
+ * Returns a Content object representing the pages effective display content,
+ * not necessarily the revision's content!
+ *
+ * Note that getContent does not follow redirects anymore.
+ * If you need to fetch redirectable content easily, try
+ * the shortcut in WikiPage::getRedirectTarget()
+ *
+ * This function has side effects! Do not use this function if you
+ * only want the real revision text if any.
+ *
+ * @return Content Return the content of this revision
+ *
+ * @since 1.21
+ */
+ protected function getContentObject() {
+ if ( $this->mPage->getId() === 0 ) {
+ # If this is a MediaWiki:x message, then load the messages
+ # and return the message value for x.
+ if ( $this->getTitle()->getNamespace() == NS_MEDIAWIKI ) {
+ $text = $this->getTitle()->getDefaultMessageText();
+ if ( $text === false ) {
+ $text = '';
+ }
+
+ $content = ContentHandler::makeContent( $text, $this->getTitle() );
+ } else {
+ $message = $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon';
+ $content = new MessageContent( $message, null, 'parsemag' );
+ }
+ } else {
+ $this->fetchContentObject();
+ $content = $this->mContentObject;
+ }
+
+ return $content;
+ }
+
+ /**
+ * @return int The oldid of the article that is to be shown, 0 for the current revision
+ */
+ public function getOldID() {
+ if ( is_null( $this->mOldId ) ) {
+ $this->mOldId = $this->getOldIDFromRequest();
+ }
+
+ return $this->mOldId;
+ }
+
+ /**
+ * Sets $this->mRedirectUrl to a correct URL if the query parameters are incorrect
+ *
+ * @return int The old id for the request
+ */
+ public function getOldIDFromRequest() {
+ $this->mRedirectUrl = false;
+
+ $request = $this->getContext()->getRequest();
+ $oldid = $request->getIntOrNull( 'oldid' );
+
+ if ( $oldid === null ) {
+ return 0;
+ }
+
+ if ( $oldid !== 0 ) {
+ # Load the given revision and check whether the page is another one.
+ # In that case, update this instance to reflect the change.
+ if ( $oldid === $this->mPage->getLatest() ) {
+ $this->mRevision = $this->mPage->getRevision();
+ } else {
+ $this->mRevision = Revision::newFromId( $oldid );
+ if ( $this->mRevision !== null ) {
+ // Revision title doesn't match the page title given?
+ if ( $this->mPage->getId() != $this->mRevision->getPage() ) {
+ $function = [ get_class( $this->mPage ), 'newFromID' ];
+ $this->mPage = call_user_func( $function, $this->mRevision->getPage() );
+ }
+ }
+ }
+ }
+
+ if ( $request->getVal( 'direction' ) == 'next' ) {
+ $nextid = $this->getTitle()->getNextRevisionID( $oldid );
+ if ( $nextid ) {
+ $oldid = $nextid;
+ $this->mRevision = null;
+ } else {
+ $this->mRedirectUrl = $this->getTitle()->getFullURL( 'redirect=no' );
+ }
+ } elseif ( $request->getVal( 'direction' ) == 'prev' ) {
+ $previd = $this->getTitle()->getPreviousRevisionID( $oldid );
+ if ( $previd ) {
+ $oldid = $previd;
+ $this->mRevision = null;
+ }
+ }
+
+ return $oldid;
+ }
+
+ /**
+ * Get text content object
+ * Does *NOT* follow redirects.
+ * @todo When is this null?
+ *
+ * @note Code that wants to retrieve page content from the database should
+ * use WikiPage::getContent().
+ *
+ * @return Content|null|bool
+ *
+ * @since 1.21
+ */
+ protected function fetchContentObject() {
+ if ( $this->mContentLoaded ) {
+ return $this->mContentObject;
+ }
+
+ $this->mContentLoaded = true;
+ $this->mContent = null;
+
+ $oldid = $this->getOldID();
+
+ # Pre-fill content with error message so that if something
+ # fails we'll have something telling us what we intended.
+ // XXX: this isn't page content but a UI message. horrible.
+ $this->mContentObject = new MessageContent( 'missing-revision', [ $oldid ] );
+
+ if ( $oldid ) {
+ # $this->mRevision might already be fetched by getOldIDFromRequest()
+ if ( !$this->mRevision ) {
+ $this->mRevision = Revision::newFromId( $oldid );
+ if ( !$this->mRevision ) {
+ wfDebug( __METHOD__ . " failed to retrieve specified revision, id $oldid\n" );
+ return false;
+ }
+ }
+ } else {
+ $oldid = $this->mPage->getLatest();
+ if ( !$oldid ) {
+ wfDebug( __METHOD__ . " failed to find page data for title " .
+ $this->getTitle()->getPrefixedText() . "\n" );
+ return false;
+ }
+
+ # Update error message with correct oldid
+ $this->mContentObject = new MessageContent( 'missing-revision', [ $oldid ] );
+
+ $this->mRevision = $this->mPage->getRevision();
+
+ if ( !$this->mRevision ) {
+ wfDebug( __METHOD__ . " failed to retrieve current page, rev_id $oldid\n" );
+ return false;
+ }
+ }
+
+ // @todo FIXME: Horrible, horrible! This content-loading interface just plain sucks.
+ // We should instead work with the Revision object when we need it...
+ // Loads if user is allowed
+ $content = $this->mRevision->getContent(
+ Revision::FOR_THIS_USER,
+ $this->getContext()->getUser()
+ );
+
+ if ( !$content ) {
+ wfDebug( __METHOD__ . " failed to retrieve content of revision " .
+ $this->mRevision->getId() . "\n" );
+ return false;
+ }
+
+ $this->mContentObject = $content;
+ $this->mRevIdFetched = $this->mRevision->getId();
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+
+ Hooks::run(
+ 'ArticleAfterFetchContentObject',
+ [ &$articlePage, &$this->mContentObject ]
+ );
+
+ return $this->mContentObject;
+ }
+
+ /**
+ * Returns true if the currently-referenced revision is the current edit
+ * to this page (and it exists).
+ * @return bool
+ */
+ public function isCurrent() {
+ # If no oldid, this is the current version.
+ if ( $this->getOldID() == 0 ) {
+ return true;
+ }
+
+ return $this->mPage->exists() && $this->mRevision && $this->mRevision->isCurrent();
+ }
+
+ /**
+ * Get the fetched Revision object depending on request parameters or null
+ * on failure.
+ *
+ * @since 1.19
+ * @return Revision|null
+ */
+ public function getRevisionFetched() {
+ $this->fetchContentObject();
+
+ return $this->mRevision;
+ }
+
+ /**
+ * Use this to fetch the rev ID used on page views
+ *
+ * @return int Revision ID of last article revision
+ */
+ public function getRevIdFetched() {
+ if ( $this->mRevIdFetched ) {
+ return $this->mRevIdFetched;
+ } else {
+ return $this->mPage->getLatest();
+ }
+ }
+
+ /**
+ * This is the default action of the index.php entry point: just view the
+ * page of the given title.
+ */
+ public function view() {
+ global $wgUseFileCache, $wgDebugToolbar;
+
+ # Get variables from query string
+ # As side effect this will load the revision and update the title
+ # in a revision ID is passed in the request, so this should remain
+ # the first call of this method even if $oldid is used way below.
+ $oldid = $this->getOldID();
+
+ $user = $this->getContext()->getUser();
+ # Another whitelist check in case getOldID() is altering the title
+ $permErrors = $this->getTitle()->getUserPermissionsErrors( 'read', $user );
+ if ( count( $permErrors ) ) {
+ wfDebug( __METHOD__ . ": denied on secondary read check\n" );
+ throw new PermissionsError( 'read', $permErrors );
+ }
+
+ $outputPage = $this->getContext()->getOutput();
+ # getOldID() may as well want us to redirect somewhere else
+ if ( $this->mRedirectUrl ) {
+ $outputPage->redirect( $this->mRedirectUrl );
+ wfDebug( __METHOD__ . ": redirecting due to oldid\n" );
+
+ return;
+ }
+
+ # If we got diff in the query, we want to see a diff page instead of the article.
+ if ( $this->getContext()->getRequest()->getCheck( 'diff' ) ) {
+ wfDebug( __METHOD__ . ": showing diff page\n" );
+ $this->showDiffPage();
+
+ return;
+ }
+
+ # Set page title (may be overridden by DISPLAYTITLE)
+ $outputPage->setPageTitle( $this->getTitle()->getPrefixedText() );
+
+ $outputPage->setArticleFlag( true );
+ # Allow frames by default
+ $outputPage->allowClickjacking();
+
+ $parserCache = MediaWikiServices::getInstance()->getParserCache();
+
+ $parserOptions = $this->getParserOptions();
+ # Render printable version, use printable version cache
+ if ( $outputPage->isPrintable() ) {
+ $parserOptions->setIsPrintable( true );
+ $parserOptions->setEditSection( false );
+ } elseif ( !$this->isCurrent() || !$this->getTitle()->quickUserCan( 'edit', $user ) ) {
+ $parserOptions->setEditSection( false );
+ }
+
+ # Try client and file cache
+ if ( !$wgDebugToolbar && $oldid === 0 && $this->mPage->checkTouched() ) {
+ # Try to stream the output from file cache
+ if ( $wgUseFileCache && $this->tryFileCache() ) {
+ wfDebug( __METHOD__ . ": done file cache\n" );
+ # tell wgOut that output is taken care of
+ $outputPage->disable();
+ $this->mPage->doViewUpdates( $user, $oldid );
+
+ return;
+ }
+ }
+
+ # Should the parser cache be used?
+ $useParserCache = $this->mPage->shouldCheckParserCache( $parserOptions, $oldid );
+ wfDebug( 'Article::view using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" );
+ if ( $user->getStubThreshold() ) {
+ MediaWikiServices::getInstance()->getStatsdDataFactory()->increment( 'pcache_miss_stub' );
+ }
+
+ $this->showRedirectedFromHeader();
+ $this->showNamespaceHeader();
+
+ # Iterate through the possible ways of constructing the output text.
+ # Keep going until $outputDone is set, or we run out of things to do.
+ $pass = 0;
+ $outputDone = false;
+ $this->mParserOutput = false;
+
+ while ( !$outputDone && ++$pass ) {
+ switch ( $pass ) {
+ case 1:
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+ Hooks::run( 'ArticleViewHeader', [ &$articlePage, &$outputDone, &$useParserCache ] );
+ break;
+ case 2:
+ # Early abort if the page doesn't exist
+ if ( !$this->mPage->exists() ) {
+ wfDebug( __METHOD__ . ": showing missing article\n" );
+ $this->showMissingArticle();
+ $this->mPage->doViewUpdates( $user );
+ return;
+ }
+
+ # Try the parser cache
+ if ( $useParserCache ) {
+ $this->mParserOutput = $parserCache->get( $this->mPage, $parserOptions );
+
+ if ( $this->mParserOutput !== false ) {
+ if ( $oldid ) {
+ wfDebug( __METHOD__ . ": showing parser cache contents for current rev permalink\n" );
+ $this->setOldSubtitle( $oldid );
+ } else {
+ wfDebug( __METHOD__ . ": showing parser cache contents\n" );
+ }
+ $outputPage->addParserOutput( $this->mParserOutput );
+ # Ensure that UI elements requiring revision ID have
+ # the correct version information.
+ $outputPage->setRevisionId( $this->mPage->getLatest() );
+ # Preload timestamp to avoid a DB hit
+ $cachedTimestamp = $this->mParserOutput->getTimestamp();
+ if ( $cachedTimestamp !== null ) {
+ $outputPage->setRevisionTimestamp( $cachedTimestamp );
+ $this->mPage->setTimestamp( $cachedTimestamp );
+ }
+ $outputDone = true;
+ }
+ }
+ break;
+ case 3:
+ # This will set $this->mRevision if needed
+ $this->fetchContentObject();
+
+ # Are we looking at an old revision
+ if ( $oldid && $this->mRevision ) {
+ $this->setOldSubtitle( $oldid );
+
+ if ( !$this->showDeletedRevisionHeader() ) {
+ wfDebug( __METHOD__ . ": cannot view deleted revision\n" );
+ return;
+ }
+ }
+
+ # Ensure that UI elements requiring revision ID have
+ # the correct version information.
+ $outputPage->setRevisionId( $this->getRevIdFetched() );
+ # Preload timestamp to avoid a DB hit
+ $outputPage->setRevisionTimestamp( $this->mPage->getTimestamp() );
+
+ # Pages containing custom CSS or JavaScript get special treatment
+ if ( $this->getTitle()->isCssOrJsPage() || $this->getTitle()->isCssJsSubpage() ) {
+ $dir = $this->getContext()->getLanguage()->getDir();
+ $lang = $this->getContext()->getLanguage()->getHtmlCode();
+
+ $outputPage->wrapWikiMsg(
+ "<div id='mw-clearyourcache' lang='$lang' dir='$dir' class='mw-content-$dir'>\n$1\n</div>",
+ 'clearyourcache'
+ );
+ } elseif ( !Hooks::run( 'ArticleContentViewCustom',
+ [ $this->fetchContentObject(), $this->getTitle(), $outputPage ] )
+ ) {
+ # Allow extensions do their own custom view for certain pages
+ $outputDone = true;
+ }
+ break;
+ case 4:
+ # Run the parse, protected by a pool counter
+ wfDebug( __METHOD__ . ": doing uncached parse\n" );
+
+ $content = $this->getContentObject();
+ $poolArticleView = new PoolWorkArticleView( $this->getPage(), $parserOptions,
+ $this->getRevIdFetched(), $useParserCache, $content );
+
+ if ( !$poolArticleView->execute() ) {
+ $error = $poolArticleView->getError();
+ if ( $error ) {
+ $outputPage->clearHTML(); // for release() errors
+ $outputPage->enableClientCache( false );
+ $outputPage->setRobotPolicy( 'noindex,nofollow' );
+
+ $errortext = $error->getWikiText( false, 'view-pool-error' );
+ $outputPage->addWikiText( '<div class="errorbox">' . $errortext . '</div>' );
+ }
+ # Connection or timeout error
+ return;
+ }
+
+ $this->mParserOutput = $poolArticleView->getParserOutput();
+ $outputPage->addParserOutput( $this->mParserOutput );
+ if ( $content->getRedirectTarget() ) {
+ $outputPage->addSubtitle( "<span id=\"redirectsub\">" .
+ $this->getContext()->msg( 'redirectpagesub' )->parse() . "</span>" );
+ }
+
+ # Don't cache a dirty ParserOutput object
+ if ( $poolArticleView->getIsDirty() ) {
+ $outputPage->setCdnMaxage( 0 );
+ $outputPage->addHTML( "<!-- parser cache is expired, " .
+ "sending anyway due to pool overload-->\n" );
+ }
+
+ $outputDone = true;
+ break;
+ # Should be unreachable, but just in case...
+ default:
+ break 2;
+ }
+ }
+
+ # Get the ParserOutput actually *displayed* here.
+ # Note that $this->mParserOutput is the *current*/oldid version output.
+ $pOutput = ( $outputDone instanceof ParserOutput )
+ ? $outputDone // object fetched by hook
+ : $this->mParserOutput;
+
+ # Adjust title for main page & pages with displaytitle
+ if ( $pOutput ) {
+ $this->adjustDisplayTitle( $pOutput );
+ }
+
+ # For the main page, overwrite the <title> element with the con-
+ # tents of 'pagetitle-view-mainpage' instead of the default (if
+ # that's not empty).
+ # This message always exists because it is in the i18n files
+ if ( $this->getTitle()->isMainPage() ) {
+ $msg = wfMessage( 'pagetitle-view-mainpage' )->inContentLanguage();
+ if ( !$msg->isDisabled() ) {
+ $outputPage->setHTMLTitle( $msg->title( $this->getTitle() )->text() );
+ }
+ }
+
+ # Use adaptive TTLs for CDN so delayed/failed purges are noticed less often.
+ # This could use getTouched(), but that could be scary for major template edits.
+ $outputPage->adaptCdnTTL( $this->mPage->getTimestamp(), IExpiringStore::TTL_DAY );
+
+ # Check for any __NOINDEX__ tags on the page using $pOutput
+ $policy = $this->getRobotPolicy( 'view', $pOutput );
+ $outputPage->setIndexPolicy( $policy['index'] );
+ $outputPage->setFollowPolicy( $policy['follow'] );
+
+ $this->showViewFooter();
+ $this->mPage->doViewUpdates( $user, $oldid );
+
+ # Load the postEdit module if the user just saved this revision
+ # See also EditPage::setPostEditCookie
+ $request = $this->getContext()->getRequest();
+ $cookieKey = EditPage::POST_EDIT_COOKIE_KEY_PREFIX . $this->getRevIdFetched();
+ $postEdit = $request->getCookie( $cookieKey );
+ if ( $postEdit ) {
+ # Clear the cookie. This also prevents caching of the response.
+ $request->response()->clearCookie( $cookieKey );
+ $outputPage->addJsConfigVars( 'wgPostEdit', $postEdit );
+ $outputPage->addModules( 'mediawiki.action.view.postEdit' );
+ }
+ }
+
+ /**
+ * Adjust title for pages with displaytitle, -{T|}- or language conversion
+ * @param ParserOutput $pOutput
+ */
+ public function adjustDisplayTitle( ParserOutput $pOutput ) {
+ # Adjust the title if it was set by displaytitle, -{T|}- or language conversion
+ $titleText = $pOutput->getTitleText();
+ if ( strval( $titleText ) !== '' ) {
+ $this->getContext()->getOutput()->setPageTitle( $titleText );
+ }
+ }
+
+ /**
+ * Show a diff page according to current request variables. For use within
+ * Article::view() only, other callers should use the DifferenceEngine class.
+ */
+ protected function showDiffPage() {
+ $request = $this->getContext()->getRequest();
+ $user = $this->getContext()->getUser();
+ $diff = $request->getVal( 'diff' );
+ $rcid = $request->getVal( 'rcid' );
+ $diffOnly = $request->getBool( 'diffonly', $user->getOption( 'diffonly' ) );
+ $purge = $request->getVal( 'action' ) == 'purge';
+ $unhide = $request->getInt( 'unhide' ) == 1;
+ $oldid = $this->getOldID();
+
+ $rev = $this->getRevisionFetched();
+
+ if ( !$rev ) {
+ $this->getContext()->getOutput()->setPageTitle( wfMessage( 'errorpagetitle' ) );
+ $msg = $this->getContext()->msg( 'difference-missing-revision' )
+ ->params( $oldid )
+ ->numParams( 1 )
+ ->parseAsBlock();
+ $this->getContext()->getOutput()->addHTML( $msg );
+ return;
+ }
+
+ $contentHandler = $rev->getContentHandler();
+ $de = $contentHandler->createDifferenceEngine(
+ $this->getContext(),
+ $oldid,
+ $diff,
+ $rcid,
+ $purge,
+ $unhide
+ );
+
+ // DifferenceEngine directly fetched the revision:
+ $this->mRevIdFetched = $de->mNewid;
+ $de->showDiffPage( $diffOnly );
+
+ // Run view updates for the newer revision being diffed (and shown
+ // below the diff if not $diffOnly).
+ list( $old, $new ) = $de->mapDiffPrevNext( $oldid, $diff );
+ // New can be false, convert it to 0 - this conveniently means the latest revision
+ $this->mPage->doViewUpdates( $user, (int)$new );
+ }
+
+ /**
+ * Get the robot policy to be used for the current view
+ * @param string $action The action= GET parameter
+ * @param ParserOutput|null $pOutput
+ * @return array The policy that should be set
+ * @todo actions other than 'view'
+ */
+ public function getRobotPolicy( $action, $pOutput = null ) {
+ global $wgArticleRobotPolicies, $wgNamespaceRobotPolicies, $wgDefaultRobotPolicy;
+
+ $ns = $this->getTitle()->getNamespace();
+
+ # Don't index user and user talk pages for blocked users (T13443)
+ if ( ( $ns == NS_USER || $ns == NS_USER_TALK ) && !$this->getTitle()->isSubpage() ) {
+ $specificTarget = null;
+ $vagueTarget = null;
+ $titleText = $this->getTitle()->getText();
+ if ( IP::isValid( $titleText ) ) {
+ $vagueTarget = $titleText;
+ } else {
+ $specificTarget = $titleText;
+ }
+ if ( Block::newFromTarget( $specificTarget, $vagueTarget ) instanceof Block ) {
+ return [
+ 'index' => 'noindex',
+ 'follow' => 'nofollow'
+ ];
+ }
+ }
+
+ if ( $this->mPage->getId() === 0 || $this->getOldID() ) {
+ # Non-articles (special pages etc), and old revisions
+ return [
+ 'index' => 'noindex',
+ 'follow' => 'nofollow'
+ ];
+ } elseif ( $this->getContext()->getOutput()->isPrintable() ) {
+ # Discourage indexing of printable versions, but encourage following
+ return [
+ 'index' => 'noindex',
+ 'follow' => 'follow'
+ ];
+ } elseif ( $this->getContext()->getRequest()->getInt( 'curid' ) ) {
+ # For ?curid=x urls, disallow indexing
+ return [
+ 'index' => 'noindex',
+ 'follow' => 'follow'
+ ];
+ }
+
+ # Otherwise, construct the policy based on the various config variables.
+ $policy = self::formatRobotPolicy( $wgDefaultRobotPolicy );
+
+ if ( isset( $wgNamespaceRobotPolicies[$ns] ) ) {
+ # Honour customised robot policies for this namespace
+ $policy = array_merge(
+ $policy,
+ self::formatRobotPolicy( $wgNamespaceRobotPolicies[$ns] )
+ );
+ }
+ if ( $this->getTitle()->canUseNoindex() && is_object( $pOutput ) && $pOutput->getIndexPolicy() ) {
+ # __INDEX__ and __NOINDEX__ magic words, if allowed. Incorporates
+ # a final sanity check that we have really got the parser output.
+ $policy = array_merge(
+ $policy,
+ [ 'index' => $pOutput->getIndexPolicy() ]
+ );
+ }
+
+ if ( isset( $wgArticleRobotPolicies[$this->getTitle()->getPrefixedText()] ) ) {
+ # (T16900) site config can override user-defined __INDEX__ or __NOINDEX__
+ $policy = array_merge(
+ $policy,
+ self::formatRobotPolicy( $wgArticleRobotPolicies[$this->getTitle()->getPrefixedText()] )
+ );
+ }
+
+ return $policy;
+ }
+
+ /**
+ * Converts a String robot policy into an associative array, to allow
+ * merging of several policies using array_merge().
+ * @param array|string $policy Returns empty array on null/false/'', transparent
+ * to already-converted arrays, converts string.
+ * @return array 'index' => \<indexpolicy\>, 'follow' => \<followpolicy\>
+ */
+ public static function formatRobotPolicy( $policy ) {
+ if ( is_array( $policy ) ) {
+ return $policy;
+ } elseif ( !$policy ) {
+ return [];
+ }
+
+ $policy = explode( ',', $policy );
+ $policy = array_map( 'trim', $policy );
+
+ $arr = [];
+ foreach ( $policy as $var ) {
+ if ( in_array( $var, [ 'index', 'noindex' ] ) ) {
+ $arr['index'] = $var;
+ } elseif ( in_array( $var, [ 'follow', 'nofollow' ] ) ) {
+ $arr['follow'] = $var;
+ }
+ }
+
+ return $arr;
+ }
+
+ /**
+ * If this request is a redirect view, send "redirected from" subtitle to
+ * the output. Returns true if the header was needed, false if this is not
+ * a redirect view. Handles both local and remote redirects.
+ *
+ * @return bool
+ */
+ public function showRedirectedFromHeader() {
+ global $wgRedirectSources;
+
+ $context = $this->getContext();
+ $outputPage = $context->getOutput();
+ $request = $context->getRequest();
+ $rdfrom = $request->getVal( 'rdfrom' );
+
+ // Construct a URL for the current page view, but with the target title
+ $query = $request->getValues();
+ unset( $query['rdfrom'] );
+ unset( $query['title'] );
+ if ( $this->getTitle()->isRedirect() ) {
+ // Prevent double redirects
+ $query['redirect'] = 'no';
+ }
+ $redirectTargetUrl = $this->getTitle()->getLinkURL( $query );
+
+ if ( isset( $this->mRedirectedFrom ) ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+
+ // This is an internally redirected page view.
+ // We'll need a backlink to the source page for navigation.
+ if ( Hooks::run( 'ArticleViewRedirect', [ &$articlePage ] ) ) {
+ $redir = Linker::linkKnown(
+ $this->mRedirectedFrom,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+
+ $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" .
+ $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse()
+ . "</span>" );
+
+ // Add the script to update the displayed URL and
+ // set the fragment if one was specified in the redirect
+ $outputPage->addJsConfigVars( [
+ 'wgInternalRedirectTargetUrl' => $redirectTargetUrl,
+ ] );
+ $outputPage->addModules( 'mediawiki.action.view.redirect' );
+
+ // Add a <link rel="canonical"> tag
+ $outputPage->setCanonicalUrl( $this->getTitle()->getCanonicalURL() );
+
+ // Tell the output object that the user arrived at this article through a redirect
+ $outputPage->setRedirectedFrom( $this->mRedirectedFrom );
+
+ return true;
+ }
+ } elseif ( $rdfrom ) {
+ // This is an externally redirected view, from some other wiki.
+ // If it was reported from a trusted site, supply a backlink.
+ if ( $wgRedirectSources && preg_match( $wgRedirectSources, $rdfrom ) ) {
+ $redir = Linker::makeExternalLink( $rdfrom, $rdfrom );
+ $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" .
+ $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse()
+ . "</span>" );
+
+ // Add the script to update the displayed URL
+ $outputPage->addJsConfigVars( [
+ 'wgInternalRedirectTargetUrl' => $redirectTargetUrl,
+ ] );
+ $outputPage->addModules( 'mediawiki.action.view.redirect' );
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Show a header specific to the namespace currently being viewed, like
+ * [[MediaWiki:Talkpagetext]]. For Article::view().
+ */
+ public function showNamespaceHeader() {
+ if ( $this->getTitle()->isTalkPage() ) {
+ if ( !wfMessage( 'talkpageheader' )->isDisabled() ) {
+ $this->getContext()->getOutput()->wrapWikiMsg(
+ "<div class=\"mw-talkpageheader\">\n$1\n</div>",
+ [ 'talkpageheader' ]
+ );
+ }
+ }
+ }
+
+ /**
+ * Show the footer section of an ordinary page view
+ */
+ public function showViewFooter() {
+ # check if we're displaying a [[User talk:x.x.x.x]] anonymous talk page
+ if ( $this->getTitle()->getNamespace() == NS_USER_TALK
+ && IP::isValid( $this->getTitle()->getText() )
+ ) {
+ $this->getContext()->getOutput()->addWikiMsg( 'anontalkpagetext' );
+ }
+
+ // Show a footer allowing the user to patrol the shown revision or page if possible
+ $patrolFooterShown = $this->showPatrolFooter();
+
+ Hooks::run( 'ArticleViewFooter', [ $this, $patrolFooterShown ] );
+ }
+
+ /**
+ * If patrol is possible, output a patrol UI box. This is called from the
+ * footer section of ordinary page views. If patrol is not possible or not
+ * desired, does nothing.
+ * Side effect: When the patrol link is build, this method will call
+ * OutputPage::preventClickjacking() and load mediawiki.page.patrol.ajax.
+ *
+ * @return bool
+ */
+ public function showPatrolFooter() {
+ global $wgUseNPPatrol, $wgUseRCPatrol, $wgUseFilePatrol, $wgEnableAPI, $wgEnableWriteAPI;
+
+ $outputPage = $this->getContext()->getOutput();
+ $user = $this->getContext()->getUser();
+ $title = $this->getTitle();
+ $rc = false;
+
+ if ( !$title->quickUserCan( 'patrol', $user )
+ || !( $wgUseRCPatrol || $wgUseNPPatrol
+ || ( $wgUseFilePatrol && $title->inNamespace( NS_FILE ) ) )
+ ) {
+ // Patrolling is disabled or the user isn't allowed to
+ return false;
+ }
+
+ if ( $this->mRevision
+ && !RecentChange::isInRCLifespan( $this->mRevision->getTimestamp(), 21600 )
+ ) {
+ // The current revision is already older than what could be in the RC table
+ // 6h tolerance because the RC might not be cleaned out regularly
+ return false;
+ }
+
+ // Check for cached results
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $key = $cache->makeKey( 'unpatrollable-page', $title->getArticleID() );
+ if ( $cache->get( $key ) ) {
+ return false;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $oldestRevisionTimestamp = $dbr->selectField(
+ 'revision',
+ 'MIN( rev_timestamp )',
+ [ 'rev_page' => $title->getArticleID() ],
+ __METHOD__
+ );
+
+ // New page patrol: Get the timestamp of the oldest revison which
+ // the revision table holds for the given page. Then we look
+ // whether it's within the RC lifespan and if it is, we try
+ // to get the recentchanges row belonging to that entry
+ // (with rc_new = 1).
+ $recentPageCreation = false;
+ if ( $oldestRevisionTimestamp
+ && RecentChange::isInRCLifespan( $oldestRevisionTimestamp, 21600 )
+ ) {
+ // 6h tolerance because the RC might not be cleaned out regularly
+ $recentPageCreation = true;
+ $rc = RecentChange::newFromConds(
+ [
+ 'rc_new' => 1,
+ 'rc_timestamp' => $oldestRevisionTimestamp,
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_cur_id' => $title->getArticleID()
+ ],
+ __METHOD__
+ );
+ if ( $rc ) {
+ // Use generic patrol message for new pages
+ $markPatrolledMsg = wfMessage( 'markaspatrolledtext' );
+ }
+ }
+
+ // File patrol: Get the timestamp of the latest upload for this page,
+ // check whether it is within the RC lifespan and if it is, we try
+ // to get the recentchanges row belonging to that entry
+ // (with rc_type = RC_LOG, rc_log_type = upload).
+ $recentFileUpload = false;
+ if ( ( !$rc || $rc->getAttribute( 'rc_patrolled' ) ) && $wgUseFilePatrol
+ && $title->getNamespace() === NS_FILE ) {
+ // Retrieve timestamp of most recent upload
+ $newestUploadTimestamp = $dbr->selectField(
+ 'image',
+ 'MAX( img_timestamp )',
+ [ 'img_name' => $title->getDBkey() ],
+ __METHOD__
+ );
+ if ( $newestUploadTimestamp
+ && RecentChange::isInRCLifespan( $newestUploadTimestamp, 21600 )
+ ) {
+ // 6h tolerance because the RC might not be cleaned out regularly
+ $recentFileUpload = true;
+ $rc = RecentChange::newFromConds(
+ [
+ 'rc_type' => RC_LOG,
+ 'rc_log_type' => 'upload',
+ 'rc_timestamp' => $newestUploadTimestamp,
+ 'rc_namespace' => NS_FILE,
+ 'rc_cur_id' => $title->getArticleID()
+ ],
+ __METHOD__
+ );
+ if ( $rc ) {
+ // Use patrol message specific to files
+ $markPatrolledMsg = wfMessage( 'markaspatrolledtext-file' );
+ }
+ }
+ }
+
+ if ( !$recentPageCreation && !$recentFileUpload ) {
+ // Page creation and latest upload (for files) is too old to be in RC
+
+ // We definitely can't patrol so cache the information
+ // When a new file version is uploaded, the cache is cleared
+ $cache->set( $key, '1' );
+
+ return false;
+ }
+
+ if ( !$rc ) {
+ // Don't cache: This can be hit if the page gets accessed very fast after
+ // its creation / latest upload or in case we have high replica DB lag. In case
+ // the revision is too old, we will already return above.
+ return false;
+ }
+
+ if ( $rc->getAttribute( 'rc_patrolled' ) ) {
+ // Patrolled RC entry around
+
+ // Cache the information we gathered above in case we can't patrol
+ // Don't cache in case we can patrol as this could change
+ $cache->set( $key, '1' );
+
+ return false;
+ }
+
+ if ( $rc->getPerformer()->equals( $user ) ) {
+ // Don't show a patrol link for own creations/uploads. If the user could
+ // patrol them, they already would be patrolled
+ return false;
+ }
+
+ $outputPage->preventClickjacking();
+ if ( $wgEnableAPI && $wgEnableWriteAPI && $user->isAllowed( 'writeapi' ) ) {
+ $outputPage->addModules( 'mediawiki.page.patrol.ajax' );
+ }
+
+ $link = Linker::linkKnown(
+ $title,
+ $markPatrolledMsg->escaped(),
+ [],
+ [
+ 'action' => 'markpatrolled',
+ 'rcid' => $rc->getAttribute( 'rc_id' ),
+ ]
+ );
+
+ $outputPage->addHTML(
+ "<div class='patrollink' data-mw='interface'>" .
+ wfMessage( 'markaspatrolledlink' )->rawParams( $link )->escaped() .
+ '</div>'
+ );
+
+ return true;
+ }
+
+ /**
+ * Purge the cache used to check if it is worth showing the patrol footer
+ * For example, it is done during re-uploads when file patrol is used.
+ * @param int $articleID ID of the article to purge
+ * @since 1.27
+ */
+ public static function purgePatrolFooterCache( $articleID ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $cache->delete( $cache->makeKey( 'unpatrollable-page', $articleID ) );
+ }
+
+ /**
+ * Show the error text for a missing article. For articles in the MediaWiki
+ * namespace, show the default message text. To be called from Article::view().
+ */
+ public function showMissingArticle() {
+ global $wgSend404Code;
+
+ $outputPage = $this->getContext()->getOutput();
+ // Whether the page is a root user page of an existing user (but not a subpage)
+ $validUserPage = false;
+
+ $title = $this->getTitle();
+
+ # Show info in user (talk) namespace. Does the user exist? Is he blocked?
+ if ( $title->getNamespace() == NS_USER
+ || $title->getNamespace() == NS_USER_TALK
+ ) {
+ $rootPart = explode( '/', $title->getText() )[0];
+ $user = User::newFromName( $rootPart, false /* allow IP users */ );
+ $ip = User::isIP( $rootPart );
+ $block = Block::newFromTarget( $user, $user );
+
+ if ( !( $user && $user->isLoggedIn() ) && !$ip ) { # User does not exist
+ $outputPage->wrapWikiMsg( "<div class=\"mw-userpage-userdoesnotexist error\">\n\$1\n</div>",
+ [ 'userpage-userdoesnotexist-view', wfEscapeWikiText( $rootPart ) ] );
+ } elseif ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) {
+ # Show log extract if the user is currently blocked
+ LogEventsList::showLogExtract(
+ $outputPage,
+ 'block',
+ MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget(),
+ '',
+ [
+ 'lim' => 1,
+ 'showIfEmpty' => false,
+ 'msgKey' => [
+ 'blocked-notice-logextract',
+ $user->getName() # Support GENDER in notice
+ ]
+ ]
+ );
+ $validUserPage = !$title->isSubpage();
+ } else {
+ $validUserPage = !$title->isSubpage();
+ }
+ }
+
+ Hooks::run( 'ShowMissingArticle', [ $this ] );
+
+ # Show delete and move logs if there were any such events.
+ # The logging query can DOS the site when bots/crawlers cause 404 floods,
+ # so be careful showing this. 404 pages must be cheap as they are hard to cache.
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+ $key = $cache->makeKey( 'page-recent-delete', md5( $title->getPrefixedText() ) );
+ $loggedIn = $this->getContext()->getUser()->isLoggedIn();
+ if ( $loggedIn || $cache->get( $key ) ) {
+ $logTypes = [ 'delete', 'move', 'protect' ];
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $conds = [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ];
+ // Give extensions a chance to hide their (unrelated) log entries
+ Hooks::run( 'Article::MissingArticleConditions', [ &$conds, $logTypes ] );
+ LogEventsList::showLogExtract(
+ $outputPage,
+ $logTypes,
+ $title,
+ '',
+ [
+ 'lim' => 10,
+ 'conds' => $conds,
+ 'showIfEmpty' => false,
+ 'msgKey' => [ $loggedIn
+ ? 'moveddeleted-notice'
+ : 'moveddeleted-notice-recent'
+ ]
+ ]
+ );
+ }
+
+ if ( !$this->mPage->hasViewableContent() && $wgSend404Code && !$validUserPage ) {
+ // If there's no backing content, send a 404 Not Found
+ // for better machine handling of broken links.
+ $this->getContext()->getRequest()->response()->statusHeader( 404 );
+ }
+
+ // Also apply the robot policy for nonexisting pages (even if a 404 was used for sanity)
+ $policy = $this->getRobotPolicy( 'view' );
+ $outputPage->setIndexPolicy( $policy['index'] );
+ $outputPage->setFollowPolicy( $policy['follow'] );
+
+ $hookResult = Hooks::run( 'BeforeDisplayNoArticleText', [ $this ] );
+
+ if ( !$hookResult ) {
+ return;
+ }
+
+ # Show error message
+ $oldid = $this->getOldID();
+ if ( !$oldid && $title->getNamespace() === NS_MEDIAWIKI && $title->hasSourceText() ) {
+ $outputPage->addParserOutput( $this->getContentObject()->getParserOutput( $title ) );
+ } else {
+ if ( $oldid ) {
+ $text = wfMessage( 'missing-revision', $oldid )->plain();
+ } elseif ( $title->quickUserCan( 'create', $this->getContext()->getUser() )
+ && $title->quickUserCan( 'edit', $this->getContext()->getUser() )
+ ) {
+ $message = $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon';
+ $text = wfMessage( $message )->plain();
+ } else {
+ $text = wfMessage( 'noarticletext-nopermission' )->plain();
+ }
+
+ $dir = $this->getContext()->getLanguage()->getDir();
+ $lang = $this->getContext()->getLanguage()->getCode();
+ $outputPage->addWikiText( Xml::openElement( 'div', [
+ 'class' => "noarticletext mw-content-$dir",
+ 'dir' => $dir,
+ 'lang' => $lang,
+ ] ) . "\n$text\n</div>" );
+ }
+ }
+
+ /**
+ * If the revision requested for view is deleted, check permissions.
+ * Send either an error message or a warning header to the output.
+ *
+ * @return bool True if the view is allowed, false if not.
+ */
+ public function showDeletedRevisionHeader() {
+ if ( !$this->mRevision->isDeleted( Revision::DELETED_TEXT ) ) {
+ // Not deleted
+ return true;
+ }
+
+ $outputPage = $this->getContext()->getOutput();
+ $user = $this->getContext()->getUser();
+ // If the user is not allowed to see it...
+ if ( !$this->mRevision->userCan( Revision::DELETED_TEXT, $user ) ) {
+ $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ 'rev-deleted-text-permission' );
+
+ return false;
+ // If the user needs to confirm that they want to see it...
+ } elseif ( $this->getContext()->getRequest()->getInt( 'unhide' ) != 1 ) {
+ # Give explanation and add a link to view the revision...
+ $oldid = intval( $this->getOldID() );
+ $link = $this->getTitle()->getFullURL( "oldid={$oldid}&unhide=1" );
+ $msg = $this->mRevision->isDeleted( Revision::DELETED_RESTRICTED ) ?
+ 'rev-suppressed-text-unhide' : 'rev-deleted-text-unhide';
+ $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ [ $msg, $link ] );
+
+ return false;
+ // We are allowed to see...
+ } else {
+ $msg = $this->mRevision->isDeleted( Revision::DELETED_RESTRICTED ) ?
+ 'rev-suppressed-text-view' : 'rev-deleted-text-view';
+ $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", $msg );
+
+ return true;
+ }
+ }
+
+ /**
+ * Generate the navigation links when browsing through an article revisions
+ * It shows the information as:
+ * Revision as of \<date\>; view current revision
+ * \<- Previous version | Next Version -\>
+ *
+ * @param int $oldid Revision ID of this article revision
+ */
+ public function setOldSubtitle( $oldid = 0 ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+
+ if ( !Hooks::run( 'DisplayOldSubtitle', [ &$articlePage, &$oldid ] ) ) {
+ return;
+ }
+
+ $context = $this->getContext();
+ $unhide = $context->getRequest()->getInt( 'unhide' ) == 1;
+
+ # Cascade unhide param in links for easy deletion browsing
+ $extraParams = [];
+ if ( $unhide ) {
+ $extraParams['unhide'] = 1;
+ }
+
+ if ( $this->mRevision && $this->mRevision->getId() === $oldid ) {
+ $revision = $this->mRevision;
+ } else {
+ $revision = Revision::newFromId( $oldid );
+ }
+
+ $timestamp = $revision->getTimestamp();
+
+ $current = ( $oldid == $this->mPage->getLatest() );
+ $language = $context->getLanguage();
+ $user = $context->getUser();
+
+ $td = $language->userTimeAndDate( $timestamp, $user );
+ $tddate = $language->userDate( $timestamp, $user );
+ $tdtime = $language->userTime( $timestamp, $user );
+
+ # Show user links if allowed to see them. If hidden, then show them only if requested...
+ $userlinks = Linker::revUserTools( $revision, !$unhide );
+
+ $infomsg = $current && !$context->msg( 'revision-info-current' )->isDisabled()
+ ? 'revision-info-current'
+ : 'revision-info';
+
+ $outputPage = $context->getOutput();
+ $revisionInfo = "<div id=\"mw-{$infomsg}\">" .
+ $context->msg( $infomsg, $td )
+ ->rawParams( $userlinks )
+ ->params( $revision->getId(), $tddate, $tdtime, $revision->getUserText() )
+ ->rawParams( Linker::revComment( $revision, true, true ) )
+ ->parse() .
+ "</div>";
+
+ $lnk = $current
+ ? $context->msg( 'currentrevisionlink' )->escaped()
+ : Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'currentrevisionlink' )->escaped(),
+ [],
+ $extraParams
+ );
+ $curdiff = $current
+ ? $context->msg( 'diff' )->escaped()
+ : Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'diff' )->escaped(),
+ [],
+ [
+ 'diff' => 'cur',
+ 'oldid' => $oldid
+ ] + $extraParams
+ );
+ $prev = $this->getTitle()->getPreviousRevisionID( $oldid );
+ $prevlink = $prev
+ ? Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'previousrevision' )->escaped(),
+ [],
+ [
+ 'direction' => 'prev',
+ 'oldid' => $oldid
+ ] + $extraParams
+ )
+ : $context->msg( 'previousrevision' )->escaped();
+ $prevdiff = $prev
+ ? Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'diff' )->escaped(),
+ [],
+ [
+ 'diff' => 'prev',
+ 'oldid' => $oldid
+ ] + $extraParams
+ )
+ : $context->msg( 'diff' )->escaped();
+ $nextlink = $current
+ ? $context->msg( 'nextrevision' )->escaped()
+ : Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'nextrevision' )->escaped(),
+ [],
+ [
+ 'direction' => 'next',
+ 'oldid' => $oldid
+ ] + $extraParams
+ );
+ $nextdiff = $current
+ ? $context->msg( 'diff' )->escaped()
+ : Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'diff' )->escaped(),
+ [],
+ [
+ 'diff' => 'next',
+ 'oldid' => $oldid
+ ] + $extraParams
+ );
+
+ $cdel = Linker::getRevDeleteLink( $user, $revision, $this->getTitle() );
+ if ( $cdel !== '' ) {
+ $cdel .= ' ';
+ }
+
+ // the outer div is need for styling the revision info and nav in MobileFrontend
+ $outputPage->addSubtitle( "<div class=\"mw-revision\">" . $revisionInfo .
+ "<div id=\"mw-revision-nav\">" . $cdel .
+ $context->msg( 'revision-nav' )->rawParams(
+ $prevdiff, $prevlink, $lnk, $curdiff, $nextlink, $nextdiff
+ )->escaped() . "</div></div>" );
+ }
+
+ /**
+ * Return the HTML for the top of a redirect page
+ *
+ * Chances are you should just be using the ParserOutput from
+ * WikitextContent::getParserOutput instead of calling this for redirects.
+ *
+ * @param Title|array $target Destination(s) to redirect
+ * @param bool $appendSubtitle [optional]
+ * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence?
+ * @return string Containing HTML with redirect link
+ *
+ * @deprecated since 1.30
+ */
+ public function viewRedirect( $target, $appendSubtitle = true, $forceKnown = false ) {
+ $lang = $this->getTitle()->getPageLanguage();
+ $out = $this->getContext()->getOutput();
+ if ( $appendSubtitle ) {
+ $out->addSubtitle( wfMessage( 'redirectpagesub' ) );
+ }
+ $out->addModuleStyles( 'mediawiki.action.view.redirectPage' );
+ return static::getRedirectHeaderHtml( $lang, $target, $forceKnown );
+ }
+
+ /**
+ * Return the HTML for the top of a redirect page
+ *
+ * Chances are you should just be using the ParserOutput from
+ * WikitextContent::getParserOutput instead of calling this for redirects.
+ *
+ * @since 1.23
+ * @param Language $lang
+ * @param Title|array $target Destination(s) to redirect
+ * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence?
+ * @return string Containing HTML with redirect link
+ */
+ public static function getRedirectHeaderHtml( Language $lang, $target, $forceKnown = false ) {
+ if ( !is_array( $target ) ) {
+ $target = [ $target ];
+ }
+
+ $html = '<ul class="redirectText">';
+ /** @var Title $title */
+ foreach ( $target as $title ) {
+ $html .= '<li>' . Linker::link(
+ $title,
+ htmlspecialchars( $title->getFullText() ),
+ [],
+ // Make sure wiki page redirects are not followed
+ $title->isRedirect() ? [ 'redirect' => 'no' ] : [],
+ ( $forceKnown ? [ 'known', 'noclasses' ] : [] )
+ ) . '</li>';
+ }
+ $html .= '</ul>';
+
+ $redirectToText = wfMessage( 'redirectto' )->inLanguage( $lang )->escaped();
+
+ return '<div class="redirectMsg">' .
+ '<p>' . $redirectToText . '</p>' .
+ $html .
+ '</div>';
+ }
+
+ /**
+ * Adds help link with an icon via page indicators.
+ * Link target can be overridden by a local message containing a wikilink:
+ * the message key is: 'namespace-' + namespace number + '-helppage'.
+ * @param string $to Target MediaWiki.org page title or encoded URL.
+ * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o.
+ * @since 1.25
+ */
+ public function addHelpLink( $to, $overrideBaseUrl = false ) {
+ $msg = wfMessage(
+ 'namespace-' . $this->getTitle()->getNamespace() . '-helppage'
+ );
+
+ $out = $this->getContext()->getOutput();
+ if ( !$msg->isDisabled() ) {
+ $helpUrl = Skin::makeUrl( $msg->plain() );
+ $out->addHelpLink( $helpUrl, true );
+ } else {
+ $out->addHelpLink( $to, $overrideBaseUrl );
+ }
+ }
+
+ /**
+ * Handle action=render
+ */
+ public function render() {
+ $this->getContext()->getRequest()->response()->header( 'X-Robots-Tag: noindex' );
+ $this->getContext()->getOutput()->setArticleBodyOnly( true );
+ $this->getContext()->getOutput()->enableSectionEditLinks( false );
+ $this->view();
+ }
+
+ /**
+ * action=protect handler
+ */
+ public function protect() {
+ $form = new ProtectionForm( $this );
+ $form->execute();
+ }
+
+ /**
+ * action=unprotect handler (alias)
+ */
+ public function unprotect() {
+ $this->protect();
+ }
+
+ /**
+ * UI entry point for page deletion
+ */
+ public function delete() {
+ # This code desperately needs to be totally rewritten
+
+ $title = $this->getTitle();
+ $context = $this->getContext();
+ $user = $context->getUser();
+ $request = $context->getRequest();
+
+ # Check permissions
+ $permissionErrors = $title->getUserPermissionsErrors( 'delete', $user );
+ if ( count( $permissionErrors ) ) {
+ throw new PermissionsError( 'delete', $permissionErrors );
+ }
+
+ # Read-only check...
+ if ( wfReadOnly() ) {
+ throw new ReadOnlyError;
+ }
+
+ # Better double-check that it hasn't been deleted yet!
+ $this->mPage->loadPageData(
+ $request->wasPosted() ? WikiPage::READ_LATEST : WikiPage::READ_NORMAL
+ );
+ if ( !$this->mPage->exists() ) {
+ $deleteLogPage = new LogPage( 'delete' );
+ $outputPage = $context->getOutput();
+ $outputPage->setPageTitle( $context->msg( 'cannotdelete-title', $title->getPrefixedText() ) );
+ $outputPage->wrapWikiMsg( "<div class=\"error mw-error-cannotdelete\">\n$1\n</div>",
+ [ 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) ]
+ );
+ $outputPage->addHTML(
+ Xml::element( 'h2', null, $deleteLogPage->getName()->text() )
+ );
+ LogEventsList::showLogExtract(
+ $outputPage,
+ 'delete',
+ $title
+ );
+
+ return;
+ }
+
+ $deleteReasonList = $request->getText( 'wpDeleteReasonList', 'other' );
+ $deleteReason = $request->getText( 'wpReason' );
+
+ if ( $deleteReasonList == 'other' ) {
+ $reason = $deleteReason;
+ } elseif ( $deleteReason != '' ) {
+ // Entry from drop down menu + additional comment
+ $colonseparator = wfMessage( 'colon-separator' )->inContentLanguage()->text();
+ $reason = $deleteReasonList . $colonseparator . $deleteReason;
+ } else {
+ $reason = $deleteReasonList;
+ }
+
+ if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ),
+ [ 'delete', $this->getTitle()->getPrefixedText() ] )
+ ) {
+ # Flag to hide all contents of the archived revisions
+ $suppress = $request->getCheck( 'wpSuppress' ) && $user->isAllowed( 'suppressrevision' );
+
+ $this->doDelete( $reason, $suppress );
+
+ WatchAction::doWatchOrUnwatch( $request->getCheck( 'wpWatch' ), $title, $user );
+
+ return;
+ }
+
+ // Generate deletion reason
+ $hasHistory = false;
+ if ( !$reason ) {
+ try {
+ $reason = $this->generateReason( $hasHistory );
+ } catch ( Exception $e ) {
+ # if a page is horribly broken, we still want to be able to
+ # delete it. So be lenient about errors here.
+ wfDebug( "Error while building auto delete summary: $e" );
+ $reason = '';
+ }
+ }
+
+ // If the page has a history, insert a warning
+ if ( $hasHistory ) {
+ $title = $this->getTitle();
+
+ // The following can use the real revision count as this is only being shown for users
+ // that can delete this page.
+ // This, as a side-effect, also makes sure that the following query isn't being run for
+ // pages with a larger history, unless the user has the 'bigdelete' right
+ // (and is about to delete this page).
+ $dbr = wfGetDB( DB_REPLICA );
+ $revisions = $edits = (int)$dbr->selectField(
+ 'revision',
+ 'COUNT(rev_page)',
+ [ 'rev_page' => $title->getArticleID() ],
+ __METHOD__
+ );
+
+ // @todo FIXME: i18n issue/patchwork message
+ $context->getOutput()->addHTML(
+ '<strong class="mw-delete-warning-revisions">' .
+ $context->msg( 'historywarning' )->numParams( $revisions )->parse() .
+ $context->msg( 'word-separator' )->escaped() . Linker::linkKnown( $title,
+ $context->msg( 'history' )->escaped(),
+ [],
+ [ 'action' => 'history' ] ) .
+ '</strong>'
+ );
+
+ if ( $title->isBigDeletion() ) {
+ global $wgDeleteRevisionsLimit;
+ $context->getOutput()->wrapWikiMsg( "<div class='error'>\n$1\n</div>\n",
+ [
+ 'delete-warning-toobig',
+ $context->getLanguage()->formatNum( $wgDeleteRevisionsLimit )
+ ]
+ );
+ }
+ }
+
+ $this->confirmDelete( $reason );
+ }
+
+ /**
+ * Output deletion confirmation dialog
+ * @todo FIXME: Move to another file?
+ * @param string $reason Prefilled reason
+ */
+ public function confirmDelete( $reason ) {
+ wfDebug( "Article::confirmDelete\n" );
+
+ $title = $this->getTitle();
+ $ctx = $this->getContext();
+ $outputPage = $ctx->getOutput();
+ $outputPage->setPageTitle( wfMessage( 'delete-confirm', $title->getPrefixedText() ) );
+ $outputPage->addBacklinkSubtitle( $title );
+ $outputPage->setRobotPolicy( 'noindex,nofollow' );
+
+ $backlinkCache = $title->getBacklinkCache();
+ if ( $backlinkCache->hasLinks( 'pagelinks' ) || $backlinkCache->hasLinks( 'templatelinks' ) ) {
+ $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ 'deleting-backlinks-warning' );
+ }
+
+ $subpageQueryLimit = 51;
+ $subpages = $title->getSubpages( $subpageQueryLimit );
+ $subpageCount = count( $subpages );
+ if ( $subpageCount > 0 ) {
+ $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ [ 'deleting-subpages-warning', Message::numParam( $subpageCount ) ] );
+ }
+ $outputPage->addWikiMsg( 'confirmdeletetext' );
+
+ Hooks::run( 'ArticleConfirmDelete', [ $this, $outputPage, &$reason ] );
+
+ $user = $this->getContext()->getUser();
+ $checkWatch = $user->getBoolOption( 'watchdeletion' ) || $user->isWatched( $title );
+
+ $outputPage->enableOOUI();
+
+ $options = Xml::listDropDownOptions(
+ $ctx->msg( 'deletereason-dropdown' )->inContentLanguage()->text(),
+ [ 'other' => $ctx->msg( 'deletereasonotherlist' )->inContentLanguage()->text() ]
+ );
+ $options = Xml::listDropDownOptionsOoui( $options );
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\DropdownInputWidget( [
+ 'name' => 'wpDeleteReasonList',
+ 'inputId' => 'wpDeleteReasonList',
+ 'tabIndex' => 1,
+ 'infusable' => true,
+ 'value' => '',
+ 'options' => $options
+ ] ),
+ [
+ 'label' => $ctx->msg( 'deletecomment' )->text(),
+ 'align' => 'top',
+ ]
+ );
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\TextInputWidget( [
+ 'name' => 'wpReason',
+ 'inputId' => 'wpReason',
+ 'tabIndex' => 2,
+ 'maxLength' => 255,
+ 'infusable' => true,
+ 'value' => $reason,
+ 'autofocus' => true,
+ ] ),
+ [
+ 'label' => $ctx->msg( 'deleteotherreason' )->text(),
+ 'align' => 'top',
+ ]
+ );
+
+ if ( $user->isLoggedIn() ) {
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpWatch',
+ 'inputId' => 'wpWatch',
+ 'tabIndex' => 3,
+ 'selected' => $checkWatch,
+ ] ),
+ [
+ 'label' => $ctx->msg( 'watchthis' )->text(),
+ 'align' => 'inline',
+ 'infusable' => true,
+ ]
+ );
+ }
+
+ if ( $user->isAllowed( 'suppressrevision' ) ) {
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpSuppress',
+ 'inputId' => 'wpSuppress',
+ 'tabIndex' => 4,
+ ] ),
+ [
+ 'label' => $ctx->msg( 'revdelete-suppress' )->text(),
+ 'align' => 'inline',
+ 'infusable' => true,
+ ]
+ );
+ }
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\ButtonInputWidget( [
+ 'name' => 'wpConfirmB',
+ 'inputId' => 'wpConfirmB',
+ 'tabIndex' => 5,
+ 'value' => $ctx->msg( 'deletepage' )->text(),
+ 'label' => $ctx->msg( 'deletepage' )->text(),
+ 'flags' => [ 'primary', 'destructive' ],
+ 'type' => 'submit',
+ ] ),
+ [
+ 'align' => 'top',
+ ]
+ );
+
+ $fieldset = new OOUI\FieldsetLayout( [
+ 'label' => $ctx->msg( 'delete-legend' )->text(),
+ 'id' => 'mw-delete-table',
+ 'items' => $fields,
+ ] );
+
+ $form = new OOUI\FormLayout( [
+ 'method' => 'post',
+ 'action' => $title->getLocalURL( 'action=delete' ),
+ 'id' => 'deleteconfirm',
+ ] );
+ $form->appendContent(
+ $fieldset,
+ new OOUI\HtmlSnippet(
+ Html::hidden( 'wpEditToken', $user->getEditToken( [ 'delete', $title->getPrefixedText() ] ) )
+ )
+ );
+
+ $outputPage->addHTML(
+ new OOUI\PanelLayout( [
+ 'classes' => [ 'deletepage-wrapper' ],
+ 'expanded' => false,
+ 'padded' => true,
+ 'framed' => true,
+ 'content' => $form,
+ ] )
+ );
+
+ if ( $user->isAllowed( 'editinterface' ) ) {
+ $link = Linker::linkKnown(
+ $ctx->msg( 'deletereason-dropdown' )->inContentLanguage()->getTitle(),
+ wfMessage( 'delete-edit-reasonlist' )->escaped(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ $outputPage->addHTML( '<p class="mw-delete-editreasons">' . $link . '</p>' );
+ }
+
+ $deleteLogPage = new LogPage( 'delete' );
+ $outputPage->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) );
+ LogEventsList::showLogExtract( $outputPage, 'delete', $title );
+ }
+
+ /**
+ * Perform a deletion and output success or failure messages
+ * @param string $reason
+ * @param bool $suppress
+ */
+ public function doDelete( $reason, $suppress = false ) {
+ $error = '';
+ $context = $this->getContext();
+ $outputPage = $context->getOutput();
+ $user = $context->getUser();
+ $status = $this->mPage->doDeleteArticleReal( $reason, $suppress, 0, true, $error, $user );
+
+ if ( $status->isGood() ) {
+ $deleted = $this->getTitle()->getPrefixedText();
+
+ $outputPage->setPageTitle( wfMessage( 'actioncomplete' ) );
+ $outputPage->setRobotPolicy( 'noindex,nofollow' );
+
+ $loglink = '[[Special:Log/delete|' . wfMessage( 'deletionlog' )->text() . ']]';
+
+ $outputPage->addWikiMsg( 'deletedtext', wfEscapeWikiText( $deleted ), $loglink );
+
+ Hooks::run( 'ArticleDeleteAfterSuccess', [ $this->getTitle(), $outputPage ] );
+
+ $outputPage->returnToMain( false );
+ } else {
+ $outputPage->setPageTitle(
+ wfMessage( 'cannotdelete-title',
+ $this->getTitle()->getPrefixedText() )
+ );
+
+ if ( $error == '' ) {
+ $outputPage->addWikiText(
+ "<div class=\"error mw-error-cannotdelete\">\n" . $status->getWikiText() . "\n</div>"
+ );
+ $deleteLogPage = new LogPage( 'delete' );
+ $outputPage->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) );
+
+ LogEventsList::showLogExtract(
+ $outputPage,
+ 'delete',
+ $this->getTitle()
+ );
+ } else {
+ $outputPage->addHTML( $error );
+ }
+ }
+ }
+
+ /* Caching functions */
+
+ /**
+ * checkLastModified returns true if it has taken care of all
+ * output to the client that is necessary for this request.
+ * (that is, it has sent a cached version of the page)
+ *
+ * @return bool True if cached version send, false otherwise
+ */
+ protected function tryFileCache() {
+ static $called = false;
+
+ if ( $called ) {
+ wfDebug( "Article::tryFileCache(): called twice!?\n" );
+ return false;
+ }
+
+ $called = true;
+ if ( $this->isFileCacheable() ) {
+ $cache = new HTMLFileCache( $this->getTitle(), 'view' );
+ if ( $cache->isCacheGood( $this->mPage->getTouched() ) ) {
+ wfDebug( "Article::tryFileCache(): about to load file\n" );
+ $cache->loadFromFileCache( $this->getContext() );
+ return true;
+ } else {
+ wfDebug( "Article::tryFileCache(): starting buffer\n" );
+ ob_start( [ &$cache, 'saveToFileCache' ] );
+ }
+ } else {
+ wfDebug( "Article::tryFileCache(): not cacheable\n" );
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if the page can be cached
+ * @param int $mode One of the HTMLFileCache::MODE_* constants (since 1.28)
+ * @return bool
+ */
+ public function isFileCacheable( $mode = HTMLFileCache::MODE_NORMAL ) {
+ $cacheable = false;
+
+ if ( HTMLFileCache::useFileCache( $this->getContext(), $mode ) ) {
+ $cacheable = $this->mPage->getId()
+ && !$this->mRedirectedFrom && !$this->getTitle()->isRedirect();
+ // Extension may have reason to disable file caching on some pages.
+ if ( $cacheable ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+ $cacheable = Hooks::run( 'IsFileCacheable', [ &$articlePage ] );
+ }
+ }
+
+ return $cacheable;
+ }
+
+ /**#@-*/
+
+ /**
+ * Lightweight method to get the parser output for a page, checking the parser cache
+ * and so on. Doesn't consider most of the stuff that WikiPage::view is forced to
+ * consider, so it's not appropriate to use there.
+ *
+ * @since 1.16 (r52326) for LiquidThreads
+ *
+ * @param int|null $oldid Revision ID or null
+ * @param User $user The relevant user
+ * @return ParserOutput|bool ParserOutput or false if the given revision ID is not found
+ */
+ public function getParserOutput( $oldid = null, User $user = null ) {
+ // XXX: bypasses mParserOptions and thus setParserOptions()
+
+ if ( $user === null ) {
+ $parserOptions = $this->getParserOptions();
+ } else {
+ $parserOptions = $this->mPage->makeParserOptions( $user );
+ }
+
+ return $this->mPage->getParserOutput( $parserOptions, $oldid );
+ }
+
+ /**
+ * Override the ParserOptions used to render the primary article wikitext.
+ *
+ * @param ParserOptions $options
+ * @throws MWException If the parser options where already initialized.
+ */
+ public function setParserOptions( ParserOptions $options ) {
+ if ( $this->mParserOptions ) {
+ throw new MWException( "can't change parser options after they have already been set" );
+ }
+
+ // clone, so if $options is modified later, it doesn't confuse the parser cache.
+ $this->mParserOptions = clone $options;
+ }
+
+ /**
+ * Get parser options suitable for rendering the primary article wikitext
+ * @return ParserOptions
+ */
+ public function getParserOptions() {
+ if ( !$this->mParserOptions ) {
+ $this->mParserOptions = $this->mPage->makeParserOptions( $this->getContext() );
+ }
+ // Clone to allow modifications of the return value without affecting cache
+ return clone $this->mParserOptions;
+ }
+
+ /**
+ * Sets the context this Article is executed in
+ *
+ * @param IContextSource $context
+ * @since 1.18
+ */
+ public function setContext( $context ) {
+ $this->mContext = $context;
+ }
+
+ /**
+ * Gets the context this Article is executed in
+ *
+ * @return IContextSource
+ * @since 1.18
+ */
+ public function getContext() {
+ if ( $this->mContext instanceof IContextSource ) {
+ return $this->mContext;
+ } else {
+ wfDebug( __METHOD__ . " called and \$mContext is null. " .
+ "Return RequestContext::getMain(); for sanity\n" );
+ return RequestContext::getMain();
+ }
+ }
+
+ /**
+ * Use PHP's magic __get handler to handle accessing of
+ * raw WikiPage fields for backwards compatibility.
+ *
+ * @param string $fname Field name
+ * @return mixed
+ */
+ public function __get( $fname ) {
+ if ( property_exists( $this->mPage, $fname ) ) {
+ # wfWarn( "Access to raw $fname field " . __CLASS__ );
+ return $this->mPage->$fname;
+ }
+ trigger_error( 'Inaccessible property via __get(): ' . $fname, E_USER_NOTICE );
+ }
+
+ /**
+ * Use PHP's magic __set handler to handle setting of
+ * raw WikiPage fields for backwards compatibility.
+ *
+ * @param string $fname Field name
+ * @param mixed $fvalue New value
+ */
+ public function __set( $fname, $fvalue ) {
+ if ( property_exists( $this->mPage, $fname ) ) {
+ # wfWarn( "Access to raw $fname field of " . __CLASS__ );
+ $this->mPage->$fname = $fvalue;
+ // Note: extensions may want to toss on new fields
+ } elseif ( !in_array( $fname, [ 'mContext', 'mPage' ] ) ) {
+ $this->mPage->$fname = $fvalue;
+ } else {
+ trigger_error( 'Inaccessible property via __set(): ' . $fname, E_USER_NOTICE );
+ }
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::checkFlags
+ */
+ public function checkFlags( $flags ) {
+ return $this->mPage->checkFlags( $flags );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::checkTouched
+ */
+ public function checkTouched() {
+ return $this->mPage->checkTouched();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::clearPreparedEdit
+ */
+ public function clearPreparedEdit() {
+ $this->mPage->clearPreparedEdit();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doDeleteArticleReal
+ */
+ public function doDeleteArticleReal(
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null,
+ $tags = []
+ ) {
+ return $this->mPage->doDeleteArticleReal(
+ $reason, $suppress, $u1, $u2, $error, $user, $tags
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doDeleteUpdates
+ */
+ public function doDeleteUpdates( $id, Content $content = null ) {
+ return $this->mPage->doDeleteUpdates( $id, $content );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @deprecated since 1.29. Use WikiPage::doEditContent() directly instead
+ * @see WikiPage::doEditContent
+ */
+ public function doEditContent( Content $content, $summary, $flags = 0, $baseRevId = false,
+ User $user = null, $serialFormat = null
+ ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ return $this->mPage->doEditContent( $content, $summary, $flags, $baseRevId,
+ $user, $serialFormat
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doEditUpdates
+ */
+ public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
+ return $this->mPage->doEditUpdates( $revision, $user, $options );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doPurge
+ * @note In 1.28 (and only 1.28), this took a $flags parameter that
+ * controlled how much purging was done.
+ */
+ public function doPurge() {
+ return $this->mPage->doPurge();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getLastPurgeTimestamp
+ * @deprecated since 1.29
+ */
+ public function getLastPurgeTimestamp() {
+ wfDeprecated( __METHOD__, '1.29' );
+ return $this->mPage->getLastPurgeTimestamp();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doViewUpdates
+ */
+ public function doViewUpdates( User $user, $oldid = 0 ) {
+ $this->mPage->doViewUpdates( $user, $oldid );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::exists
+ */
+ public function exists() {
+ return $this->mPage->exists();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::followRedirect
+ */
+ public function followRedirect() {
+ return $this->mPage->followRedirect();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see ContentHandler::getActionOverrides
+ */
+ public function getActionOverrides() {
+ return $this->mPage->getActionOverrides();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getAutoDeleteReason
+ */
+ public function getAutoDeleteReason( &$hasHistory ) {
+ return $this->mPage->getAutoDeleteReason( $hasHistory );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getCategories
+ */
+ public function getCategories() {
+ return $this->mPage->getCategories();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getComment
+ */
+ public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ return $this->mPage->getComment( $audience, $user );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getContentHandler
+ */
+ public function getContentHandler() {
+ return $this->mPage->getContentHandler();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getContentModel
+ */
+ public function getContentModel() {
+ return $this->mPage->getContentModel();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getContributors
+ */
+ public function getContributors() {
+ return $this->mPage->getContributors();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getCreator
+ */
+ public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ return $this->mPage->getCreator( $audience, $user );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getDeletionUpdates
+ */
+ public function getDeletionUpdates( Content $content = null ) {
+ return $this->mPage->getDeletionUpdates( $content );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getHiddenCategories
+ */
+ public function getHiddenCategories() {
+ return $this->mPage->getHiddenCategories();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getId
+ */
+ public function getId() {
+ return $this->mPage->getId();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getLatest
+ */
+ public function getLatest() {
+ return $this->mPage->getLatest();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getLinksTimestamp
+ */
+ public function getLinksTimestamp() {
+ return $this->mPage->getLinksTimestamp();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getMinorEdit
+ */
+ public function getMinorEdit() {
+ return $this->mPage->getMinorEdit();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getOldestRevision
+ */
+ public function getOldestRevision() {
+ return $this->mPage->getOldestRevision();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getRedirectTarget
+ */
+ public function getRedirectTarget() {
+ return $this->mPage->getRedirectTarget();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getRedirectURL
+ */
+ public function getRedirectURL( $rt ) {
+ return $this->mPage->getRedirectURL( $rt );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getRevision
+ */
+ public function getRevision() {
+ return $this->mPage->getRevision();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getTimestamp
+ */
+ public function getTimestamp() {
+ return $this->mPage->getTimestamp();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getTouched
+ */
+ public function getTouched() {
+ return $this->mPage->getTouched();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getUndoContent
+ */
+ public function getUndoContent( Revision $undo, Revision $undoafter = null ) {
+ return $this->mPage->getUndoContent( $undo, $undoafter );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getUser
+ */
+ public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ return $this->mPage->getUser( $audience, $user );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getUserText
+ */
+ public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ return $this->mPage->getUserText( $audience, $user );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::hasViewableContent
+ */
+ public function hasViewableContent() {
+ return $this->mPage->hasViewableContent();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::insertOn
+ */
+ public function insertOn( $dbw, $pageId = null ) {
+ return $this->mPage->insertOn( $dbw, $pageId );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::insertProtectNullRevision
+ */
+ public function insertProtectNullRevision( $revCommentMsg, array $limit,
+ array $expiry, $cascade, $reason, $user = null
+ ) {
+ return $this->mPage->insertProtectNullRevision( $revCommentMsg, $limit,
+ $expiry, $cascade, $reason, $user
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::insertRedirect
+ */
+ public function insertRedirect() {
+ return $this->mPage->insertRedirect();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::insertRedirectEntry
+ */
+ public function insertRedirectEntry( Title $rt, $oldLatest = null ) {
+ return $this->mPage->insertRedirectEntry( $rt, $oldLatest );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::isCountable
+ */
+ public function isCountable( $editInfo = false ) {
+ return $this->mPage->isCountable( $editInfo );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::isRedirect
+ */
+ public function isRedirect() {
+ return $this->mPage->isRedirect();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::loadFromRow
+ */
+ public function loadFromRow( $data, $from ) {
+ return $this->mPage->loadFromRow( $data, $from );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::loadPageData
+ */
+ public function loadPageData( $from = 'fromdb' ) {
+ $this->mPage->loadPageData( $from );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::lockAndGetLatest
+ */
+ public function lockAndGetLatest() {
+ return $this->mPage->lockAndGetLatest();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::makeParserOptions
+ */
+ public function makeParserOptions( $context ) {
+ return $this->mPage->makeParserOptions( $context );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::pageDataFromId
+ */
+ public function pageDataFromId( $dbr, $id, $options = [] ) {
+ return $this->mPage->pageDataFromId( $dbr, $id, $options );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::pageDataFromTitle
+ */
+ public function pageDataFromTitle( $dbr, $title, $options = [] ) {
+ return $this->mPage->pageDataFromTitle( $dbr, $title, $options );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::prepareContentForEdit
+ */
+ public function prepareContentForEdit(
+ Content $content, $revision = null, User $user = null,
+ $serialFormat = null, $useCache = true
+ ) {
+ return $this->mPage->prepareContentForEdit(
+ $content, $revision, $user,
+ $serialFormat, $useCache
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::protectDescription
+ */
+ public function protectDescription( array $limit, array $expiry ) {
+ return $this->mPage->protectDescription( $limit, $expiry );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::protectDescriptionLog
+ */
+ public function protectDescriptionLog( array $limit, array $expiry ) {
+ return $this->mPage->protectDescriptionLog( $limit, $expiry );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::replaceSectionAtRev
+ */
+ public function replaceSectionAtRev( $sectionId, Content $sectionContent,
+ $sectionTitle = '', $baseRevId = null
+ ) {
+ return $this->mPage->replaceSectionAtRev( $sectionId, $sectionContent,
+ $sectionTitle, $baseRevId
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::replaceSectionContent
+ */
+ public function replaceSectionContent(
+ $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null
+ ) {
+ return $this->mPage->replaceSectionContent(
+ $sectionId, $sectionContent, $sectionTitle, $edittime
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::setTimestamp
+ */
+ public function setTimestamp( $ts ) {
+ return $this->mPage->setTimestamp( $ts );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::shouldCheckParserCache
+ */
+ public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
+ return $this->mPage->shouldCheckParserCache( $parserOptions, $oldId );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::supportsSections
+ */
+ public function supportsSections() {
+ return $this->mPage->supportsSections();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::triggerOpportunisticLinksUpdate
+ */
+ public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
+ return $this->mPage->triggerOpportunisticLinksUpdate( $parserOutput );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::updateCategoryCounts
+ */
+ public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
+ return $this->mPage->updateCategoryCounts( $added, $deleted, $id );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::updateIfNewerOn
+ */
+ public function updateIfNewerOn( $dbw, $revision ) {
+ return $this->mPage->updateIfNewerOn( $dbw, $revision );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::updateRedirectOn
+ */
+ public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) {
+ return $this->mPage->updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::updateRevisionOn
+ */
+ public function updateRevisionOn( $dbw, $revision, $lastRevision = null,
+ $lastRevIsRedirect = null
+ ) {
+ return $this->mPage->updateRevisionOn( $dbw, $revision, $lastRevision,
+ $lastRevIsRedirect
+ );
+ }
+
+ /**
+ * @param array $limit
+ * @param array $expiry
+ * @param bool &$cascade
+ * @param string $reason
+ * @param User $user
+ * @return Status
+ */
+ public function doUpdateRestrictions( array $limit, array $expiry, &$cascade,
+ $reason, User $user
+ ) {
+ return $this->mPage->doUpdateRestrictions( $limit, $expiry, $cascade, $reason, $user );
+ }
+
+ /**
+ * @param array $limit
+ * @param string $reason
+ * @param int &$cascade
+ * @param array $expiry
+ * @return bool
+ */
+ public function updateRestrictions( $limit = [], $reason = '',
+ &$cascade = 0, $expiry = []
+ ) {
+ return $this->mPage->doUpdateRestrictions(
+ $limit,
+ $expiry,
+ $cascade,
+ $reason,
+ $this->getContext()->getUser()
+ );
+ }
+
+ /**
+ * @param string $reason
+ * @param bool $suppress
+ * @param int $u1 Unused
+ * @param bool $u2 Unused
+ * @param string &$error
+ * @return bool
+ */
+ public function doDeleteArticle(
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = ''
+ ) {
+ return $this->mPage->doDeleteArticle( $reason, $suppress, $u1, $u2, $error );
+ }
+
+ /**
+ * @param string $fromP
+ * @param string $summary
+ * @param string $token
+ * @param bool $bot
+ * @param array &$resultDetails
+ * @param User|null $user
+ * @return array
+ */
+ public function doRollback( $fromP, $summary, $token, $bot, &$resultDetails, User $user = null ) {
+ $user = is_null( $user ) ? $this->getContext()->getUser() : $user;
+ return $this->mPage->doRollback( $fromP, $summary, $token, $bot, $resultDetails, $user );
+ }
+
+ /**
+ * @param string $fromP
+ * @param string $summary
+ * @param bool $bot
+ * @param array &$resultDetails
+ * @param User|null $guser
+ * @return array
+ */
+ public function commitRollback( $fromP, $summary, $bot, &$resultDetails, User $guser = null ) {
+ $guser = is_null( $guser ) ? $this->getContext()->getUser() : $guser;
+ return $this->mPage->commitRollback( $fromP, $summary, $bot, $resultDetails, $guser );
+ }
+
+ /**
+ * @param bool &$hasHistory
+ * @return mixed
+ */
+ public function generateReason( &$hasHistory ) {
+ $title = $this->mPage->getTitle();
+ $handler = ContentHandler::getForTitle( $title );
+ return $handler->getAutoDeleteReason( $title, $hasHistory );
+ }
+
+ /**
+ * @return array
+ *
+ * @deprecated since 1.24, use WikiPage::selectFields() instead
+ */
+ public static function selectFields() {
+ wfDeprecated( __METHOD__, '1.24' );
+ return WikiPage::selectFields();
+ }
+
+ /**
+ * @param Title $title
+ *
+ * @deprecated since 1.24, use WikiPage::onArticleCreate() instead
+ */
+ public static function onArticleCreate( $title ) {
+ wfDeprecated( __METHOD__, '1.24' );
+ WikiPage::onArticleCreate( $title );
+ }
+
+ /**
+ * @param Title $title
+ *
+ * @deprecated since 1.24, use WikiPage::onArticleDelete() instead
+ */
+ public static function onArticleDelete( $title ) {
+ wfDeprecated( __METHOD__, '1.24' );
+ WikiPage::onArticleDelete( $title );
+ }
+
+ /**
+ * @param Title $title
+ *
+ * @deprecated since 1.24, use WikiPage::onArticleEdit() instead
+ */
+ public static function onArticleEdit( $title ) {
+ wfDeprecated( __METHOD__, '1.24' );
+ WikiPage::onArticleEdit( $title );
+ }
+
+ // ******
+}
diff --git a/www/wiki/includes/page/CategoryPage.php b/www/wiki/includes/page/CategoryPage.php
new file mode 100644
index 00000000..2d7e8f24
--- /dev/null
+++ b/www/wiki/includes/page/CategoryPage.php
@@ -0,0 +1,128 @@
+<?php
+/**
+ * Special handling for category description pages.
+ * Modelled after ImagePage.php.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Special handling for category description pages, showing pages,
+ * subcategories and file that belong to the category
+ */
+class CategoryPage extends Article {
+ # Subclasses can change this to override the viewer class.
+ protected $mCategoryViewerClass = 'CategoryViewer';
+
+ /**
+ * @var WikiCategoryPage
+ */
+ protected $mPage;
+
+ /**
+ * @param Title $title
+ * @return WikiCategoryPage
+ */
+ protected function newPage( Title $title ) {
+ // Overload mPage with a category-specific page
+ return new WikiCategoryPage( $title );
+ }
+
+ function view() {
+ $request = $this->getContext()->getRequest();
+ $diff = $request->getVal( 'diff' );
+ $diffOnly = $request->getBool( 'diffonly',
+ $this->getContext()->getUser()->getOption( 'diffonly' ) );
+
+ if ( $diff !== null && $diffOnly ) {
+ parent::view();
+ return;
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $categoryPage = $this;
+
+ if ( !Hooks::run( 'CategoryPageView', [ &$categoryPage ] ) ) {
+ return;
+ }
+
+ $title = $this->getTitle();
+ if ( $title->inNamespace( NS_CATEGORY ) ) {
+ $this->openShowCategory();
+ }
+
+ parent::view();
+
+ if ( $title->inNamespace( NS_CATEGORY ) ) {
+ $this->closeShowCategory();
+ }
+
+ # Use adaptive TTLs for CDN so delayed/failed purges are noticed less often
+ $outputPage = $this->getContext()->getOutput();
+ $outputPage->adaptCdnTTL( $this->mPage->getTouched(), IExpiringStore::TTL_MINUTE );
+ }
+
+ function openShowCategory() {
+ # For overloading
+ }
+
+ function closeShowCategory() {
+ // Use these as defaults for back compat --catrope
+ $request = $this->getContext()->getRequest();
+ $oldFrom = $request->getVal( 'from' );
+ $oldUntil = $request->getVal( 'until' );
+
+ $reqArray = $request->getValues();
+
+ $from = $until = [];
+ foreach ( [ 'page', 'subcat', 'file' ] as $type ) {
+ $from[$type] = $request->getVal( "{$type}from", $oldFrom );
+ $until[$type] = $request->getVal( "{$type}until", $oldUntil );
+
+ // Do not want old-style from/until propagating in nav links.
+ if ( !isset( $reqArray["{$type}from"] ) && isset( $reqArray["from"] ) ) {
+ $reqArray["{$type}from"] = $reqArray["from"];
+ }
+ if ( !isset( $reqArray["{$type}to"] ) && isset( $reqArray["to"] ) ) {
+ $reqArray["{$type}to"] = $reqArray["to"];
+ }
+ }
+
+ unset( $reqArray["from"] );
+ unset( $reqArray["to"] );
+
+ $viewer = new $this->mCategoryViewerClass(
+ $this->getContext()->getTitle(),
+ $this->getContext(),
+ $from,
+ $until,
+ $reqArray
+ );
+ $out = $this->getContext()->getOutput();
+ $out->addHTML( $viewer->getHTML() );
+ $this->addHelpLink( 'Help:Categories' );
+ }
+
+ function getCategoryViewerClass() {
+ return $this->mCategoryViewerClass;
+ }
+
+ function setCategoryViewerClass( $class ) {
+ $this->mCategoryViewerClass = $class;
+ }
+}
diff --git a/www/wiki/includes/page/ImageHistoryList.php b/www/wiki/includes/page/ImageHistoryList.php
new file mode 100644
index 00000000..bb8ed242
--- /dev/null
+++ b/www/wiki/includes/page/ImageHistoryList.php
@@ -0,0 +1,326 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Builds the image revision log shown on image pages
+ *
+ * @ingroup Media
+ */
+class ImageHistoryList extends ContextSource {
+
+ /**
+ * @var Title
+ */
+ protected $title;
+
+ /**
+ * @var File
+ */
+ protected $img;
+
+ /**
+ * @var ImagePage
+ */
+ protected $imagePage;
+
+ /**
+ * @var File
+ */
+ protected $current;
+
+ protected $repo, $showThumb;
+ protected $preventClickjacking = false;
+
+ /**
+ * @param ImagePage $imagePage
+ */
+ public function __construct( $imagePage ) {
+ global $wgShowArchiveThumbnails;
+ $this->current = $imagePage->getPage()->getFile();
+ $this->img = $imagePage->getDisplayedFile();
+ $this->title = $imagePage->getTitle();
+ $this->imagePage = $imagePage;
+ $this->showThumb = $wgShowArchiveThumbnails && $this->img->canRender();
+ $this->setContext( $imagePage->getContext() );
+ }
+
+ /**
+ * @return ImagePage
+ */
+ public function getImagePage() {
+ return $this->imagePage;
+ }
+
+ /**
+ * @return File
+ */
+ public function getFile() {
+ return $this->img;
+ }
+
+ /**
+ * @param string $navLinks
+ * @return string
+ */
+ public function beginImageHistoryList( $navLinks = '' ) {
+ return Xml::element( 'h2', [ 'id' => 'filehistory' ], $this->msg( 'filehist' )->text() )
+ . "\n"
+ . "<div id=\"mw-imagepage-section-filehistory\">\n"
+ . $this->msg( 'filehist-help' )->parseAsBlock()
+ . $navLinks . "\n"
+ . Xml::openElement( 'table', [ 'class' => 'wikitable filehistory' ] ) . "\n"
+ . '<tr><th></th>'
+ . ( $this->current->isLocal()
+ && ( $this->getUser()->isAllowedAny( 'delete', 'deletedhistory' ) ) ? '<th></th>' : '' )
+ . '<th>' . $this->msg( 'filehist-datetime' )->escaped() . '</th>'
+ . ( $this->showThumb ? '<th>' . $this->msg( 'filehist-thumb' )->escaped() . '</th>' : '' )
+ . '<th>' . $this->msg( 'filehist-dimensions' )->escaped() . '</th>'
+ . '<th>' . $this->msg( 'filehist-user' )->escaped() . '</th>'
+ . '<th>' . $this->msg( 'filehist-comment' )->escaped() . '</th>'
+ . "</tr>\n";
+ }
+
+ /**
+ * @param string $navLinks
+ * @return string
+ */
+ public function endImageHistoryList( $navLinks = '' ) {
+ return "</table>\n$navLinks\n</div>\n";
+ }
+
+ /**
+ * @param bool $iscur
+ * @param File $file
+ * @return string
+ */
+ public function imageHistoryLine( $iscur, $file ) {
+ global $wgContLang;
+
+ $user = $this->getUser();
+ $lang = $this->getLanguage();
+ $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() );
+ $img = $iscur ? $file->getName() : $file->getArchiveName();
+ $userId = $file->getUser( 'id' );
+ $userText = $file->getUser( 'text' );
+ $description = $file->getDescription( File::FOR_THIS_USER, $user );
+
+ $local = $this->current->isLocal();
+ $row = $selected = '';
+
+ // Deletion link
+ if ( $local && ( $user->isAllowedAny( 'delete', 'deletedhistory' ) ) ) {
+ $row .= '<td>';
+ # Link to remove from history
+ if ( $user->isAllowed( 'delete' ) ) {
+ $q = [ 'action' => 'delete' ];
+ if ( !$iscur ) {
+ $q['oldimage'] = $img;
+ }
+ $row .= Linker::linkKnown(
+ $this->title,
+ $this->msg( $iscur ? 'filehist-deleteall' : 'filehist-deleteone' )->escaped(),
+ [], $q
+ );
+ }
+ # Link to hide content. Don't show useless link to people who cannot hide revisions.
+ $canHide = $user->isAllowed( 'deleterevision' );
+ if ( $canHide || ( $user->isAllowed( 'deletedhistory' ) && $file->getVisibility() ) ) {
+ if ( $user->isAllowed( 'delete' ) ) {
+ $row .= '<br />';
+ }
+ // If file is top revision or locked from this user, don't link
+ if ( $iscur || !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
+ $del = Linker::revDeleteLinkDisabled( $canHide );
+ } else {
+ list( $ts, ) = explode( '!', $img, 2 );
+ $query = [
+ 'type' => 'oldimage',
+ 'target' => $this->title->getPrefixedText(),
+ 'ids' => $ts,
+ ];
+ $del = Linker::revDeleteLink( $query,
+ $file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
+ }
+ $row .= $del;
+ }
+ $row .= '</td>';
+ }
+
+ // Reversion link/current indicator
+ $row .= '<td>';
+ if ( $iscur ) {
+ $row .= $this->msg( 'filehist-current' )->escaped();
+ } elseif ( $local && $this->title->quickUserCan( 'edit', $user )
+ && $this->title->quickUserCan( 'upload', $user )
+ ) {
+ if ( $file->isDeleted( File::DELETED_FILE ) ) {
+ $row .= $this->msg( 'filehist-revert' )->escaped();
+ } else {
+ $row .= Linker::linkKnown(
+ $this->title,
+ $this->msg( 'filehist-revert' )->escaped(),
+ [],
+ [
+ 'action' => 'revert',
+ 'oldimage' => $img,
+ ]
+ );
+ }
+ }
+ $row .= '</td>';
+
+ // Date/time and image link
+ if ( $file->getTimestamp() === $this->img->getTimestamp() ) {
+ $selected = "class='filehistory-selected'";
+ }
+ $row .= "<td $selected style='white-space: nowrap;'>";
+ if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
+ # Don't link to unviewable files
+ $row .= '<span class="history-deleted">'
+ . $lang->userTimeAndDate( $timestamp, $user ) . '</span>';
+ } elseif ( $file->isDeleted( File::DELETED_FILE ) ) {
+ if ( $local ) {
+ $this->preventClickjacking();
+ $revdel = SpecialPage::getTitleFor( 'Revisiondelete' );
+ # Make a link to review the image
+ $url = Linker::linkKnown(
+ $revdel,
+ $lang->userTimeAndDate( $timestamp, $user ),
+ [],
+ [
+ 'target' => $this->title->getPrefixedText(),
+ 'file' => $img,
+ 'token' => $user->getEditToken( $img )
+ ]
+ );
+ } else {
+ $url = $lang->userTimeAndDate( $timestamp, $user );
+ }
+ $row .= '<span class="history-deleted">' . $url . '</span>';
+ } elseif ( !$file->exists() ) {
+ $row .= '<span class="mw-file-missing">'
+ . $lang->userTimeAndDate( $timestamp, $user ) . '</span>';
+ } else {
+ $url = $iscur ? $this->current->getUrl() : $this->current->getArchiveUrl( $img );
+ $row .= Xml::element(
+ 'a',
+ [ 'href' => $url ],
+ $lang->userTimeAndDate( $timestamp, $user )
+ );
+ }
+ $row .= "</td>";
+
+ // Thumbnail
+ if ( $this->showThumb ) {
+ $row .= '<td>' . $this->getThumbForLine( $file ) . '</td>';
+ }
+
+ // Image dimensions + size
+ $row .= '<td>';
+ $row .= htmlspecialchars( $file->getDimensionsString() );
+ $row .= $this->msg( 'word-separator' )->escaped();
+ $row .= '<span style="white-space: nowrap;">';
+ $row .= $this->msg( 'parentheses' )->sizeParams( $file->getSize() )->escaped();
+ $row .= '</span>';
+ $row .= '</td>';
+
+ // Uploading user
+ $row .= '<td>';
+ // Hide deleted usernames
+ if ( $file->isDeleted( File::DELETED_USER ) ) {
+ $row .= '<span class="history-deleted">'
+ . $this->msg( 'rev-deleted-user' )->escaped() . '</span>';
+ } else {
+ if ( $local ) {
+ $row .= Linker::userLink( $userId, $userText );
+ $row .= '<span style="white-space: nowrap;">';
+ $row .= Linker::userToolLinks( $userId, $userText );
+ $row .= '</span>';
+ } else {
+ $row .= htmlspecialchars( $userText );
+ }
+ }
+ $row .= '</td>';
+
+ // Don't show deleted descriptions
+ if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
+ $row .= '<td><span class="history-deleted">' .
+ $this->msg( 'rev-deleted-comment' )->escaped() . '</span></td>';
+ } else {
+ $row .= '<td dir="' . $wgContLang->getDir() . '">' .
+ Linker::formatComment( $description, $this->title ) . '</td>';
+ }
+
+ $rowClass = null;
+ Hooks::run( 'ImagePageFileHistoryLine', [ $this, $file, &$row, &$rowClass ] );
+ $classAttr = $rowClass ? " class='$rowClass'" : '';
+
+ return "<tr{$classAttr}>{$row}</tr>\n";
+ }
+
+ /**
+ * @param File $file
+ * @return string
+ */
+ protected function getThumbForLine( $file ) {
+ $lang = $this->getLanguage();
+ $user = $this->getUser();
+ if ( $file->allowInlineDisplay() && $file->userCan( File::DELETED_FILE, $user )
+ && !$file->isDeleted( File::DELETED_FILE )
+ ) {
+ $params = [
+ 'width' => '120',
+ 'height' => '120',
+ ];
+ $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() );
+
+ $thumbnail = $file->transform( $params );
+ $options = [
+ 'alt' => $this->msg( 'filehist-thumbtext',
+ $lang->userTimeAndDate( $timestamp, $user ),
+ $lang->userDate( $timestamp, $user ),
+ $lang->userTime( $timestamp, $user ) )->text(),
+ 'file-link' => true,
+ ];
+
+ if ( !$thumbnail ) {
+ return $this->msg( 'filehist-nothumb' )->escaped();
+ }
+
+ return $thumbnail->toHtml( $options );
+ } else {
+ return $this->msg( 'filehist-nothumb' )->escaped();
+ }
+ }
+
+ /**
+ * @param bool $enable
+ */
+ protected function preventClickjacking( $enable = true ) {
+ $this->preventClickjacking = $enable;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getPreventClickjacking() {
+ return $this->preventClickjacking;
+ }
+}
diff --git a/www/wiki/includes/page/ImageHistoryPseudoPager.php b/www/wiki/includes/page/ImageHistoryPseudoPager.php
new file mode 100644
index 00000000..20bc614b
--- /dev/null
+++ b/www/wiki/includes/page/ImageHistoryPseudoPager.php
@@ -0,0 +1,228 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class ImageHistoryPseudoPager extends ReverseChronologicalPager {
+ protected $preventClickjacking = false;
+
+ /**
+ * @var File
+ */
+ protected $mImg;
+
+ /**
+ * @var Title
+ */
+ protected $mTitle;
+
+ /**
+ * @since 1.14
+ * @var ImagePage
+ */
+ public $mImagePage;
+
+ /**
+ * @since 1.14
+ * @var File[]
+ */
+ public $mHist;
+
+ /**
+ * @since 1.14
+ * @var int[]
+ */
+ public $mRange;
+
+ /**
+ * @param ImagePage $imagePage
+ */
+ public function __construct( $imagePage ) {
+ parent::__construct( $imagePage->getContext() );
+ $this->mImagePage = $imagePage;
+ $this->mTitle = $imagePage->getTitle()->createFragmentTarget( 'filehistory' );
+ $this->mImg = null;
+ $this->mHist = [];
+ $this->mRange = [ 0, 0 ]; // display range
+
+ // Only display 10 revisions at once by default, otherwise the list is overwhelming
+ $this->mLimitsShown = array_merge( [ 10 ], $this->mLimitsShown );
+ $this->mDefaultLimit = 10;
+ list( $this->mLimit, /* $offset */ ) =
+ $this->mRequest->getLimitOffset( $this->mDefaultLimit, '' );
+ }
+
+ /**
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->mTitle;
+ }
+
+ public function getQueryInfo() {
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ public function getIndexField() {
+ return '';
+ }
+
+ /**
+ * @param object $row
+ * @return string
+ */
+ public function formatRow( $row ) {
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ public function getBody() {
+ $s = '';
+ $this->doQuery();
+ if ( count( $this->mHist ) ) {
+ if ( $this->mImg->isLocal() ) {
+ // Do a batch existence check for user pages and talkpages
+ $linkBatch = new LinkBatch();
+ for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) {
+ $file = $this->mHist[$i];
+ $user = $file->getUser( 'text' );
+ $linkBatch->add( NS_USER, $user );
+ $linkBatch->add( NS_USER_TALK, $user );
+ }
+ $linkBatch->execute();
+ }
+
+ $list = new ImageHistoryList( $this->mImagePage );
+ # Generate prev/next links
+ $navLink = $this->getNavigationBar();
+ $s = $list->beginImageHistoryList( $navLink );
+ // Skip rows there just for paging links
+ for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) {
+ $file = $this->mHist[$i];
+ $s .= $list->imageHistoryLine( !$file->isOld(), $file );
+ }
+ $s .= $list->endImageHistoryList( $navLink );
+
+ if ( $list->getPreventClickjacking() ) {
+ $this->preventClickjacking();
+ }
+ }
+ return $s;
+ }
+
+ public function doQuery() {
+ if ( $this->mQueryDone ) {
+ return;
+ }
+ $this->mImg = $this->mImagePage->getPage()->getFile(); // ensure loading
+ if ( !$this->mImg->exists() ) {
+ return;
+ }
+ $queryLimit = $this->mLimit + 1; // limit plus extra row
+ if ( $this->mIsBackwards ) {
+ // Fetch the file history
+ $this->mHist = $this->mImg->getHistory( $queryLimit, null, $this->mOffset, false );
+ // The current rev may not meet the offset/limit
+ $numRows = count( $this->mHist );
+ if ( $numRows <= $this->mLimit && $this->mImg->getTimestamp() > $this->mOffset ) {
+ $this->mHist = array_merge( [ $this->mImg ], $this->mHist );
+ }
+ } else {
+ // The current rev may not meet the offset
+ if ( !$this->mOffset || $this->mImg->getTimestamp() < $this->mOffset ) {
+ $this->mHist[] = $this->mImg;
+ }
+ // Old image versions (fetch extra row for nav links)
+ $oiLimit = count( $this->mHist ) ? $this->mLimit : $this->mLimit + 1;
+ // Fetch the file history
+ $this->mHist = array_merge( $this->mHist,
+ $this->mImg->getHistory( $oiLimit, $this->mOffset, null, false ) );
+ }
+ $numRows = count( $this->mHist ); // Total number of query results
+ if ( $numRows ) {
+ # Index value of top item in the list
+ $firstIndex = $this->mIsBackwards ?
+ $this->mHist[$numRows - 1]->getTimestamp() : $this->mHist[0]->getTimestamp();
+ # Discard the extra result row if there is one
+ if ( $numRows > $this->mLimit && $numRows > 1 ) {
+ if ( $this->mIsBackwards ) {
+ # Index value of item past the index
+ $this->mPastTheEndIndex = $this->mHist[0]->getTimestamp();
+ # Index value of bottom item in the list
+ $lastIndex = $this->mHist[1]->getTimestamp();
+ # Display range
+ $this->mRange = [ 1, $numRows - 1 ];
+ } else {
+ # Index value of item past the index
+ $this->mPastTheEndIndex = $this->mHist[$numRows - 1]->getTimestamp();
+ # Index value of bottom item in the list
+ $lastIndex = $this->mHist[$numRows - 2]->getTimestamp();
+ # Display range
+ $this->mRange = [ 0, $numRows - 2 ];
+ }
+ } else {
+ # Setting indexes to an empty string means that they will be
+ # omitted if they would otherwise appear in URLs. It just so
+ # happens that this is the right thing to do in the standard
+ # UI, in all the relevant cases.
+ $this->mPastTheEndIndex = '';
+ # Index value of bottom item in the list
+ $lastIndex = $this->mIsBackwards ?
+ $this->mHist[0]->getTimestamp() : $this->mHist[$numRows - 1]->getTimestamp();
+ # Display range
+ $this->mRange = [ 0, $numRows - 1 ];
+ }
+ } else {
+ $firstIndex = '';
+ $lastIndex = '';
+ $this->mPastTheEndIndex = '';
+ }
+ if ( $this->mIsBackwards ) {
+ $this->mIsFirst = ( $numRows < $queryLimit );
+ $this->mIsLast = ( $this->mOffset == '' );
+ $this->mLastShown = $firstIndex;
+ $this->mFirstShown = $lastIndex;
+ } else {
+ $this->mIsFirst = ( $this->mOffset == '' );
+ $this->mIsLast = ( $numRows < $queryLimit );
+ $this->mLastShown = $lastIndex;
+ $this->mFirstShown = $firstIndex;
+ }
+ $this->mQueryDone = true;
+ }
+
+ /**
+ * @param bool $enable
+ */
+ protected function preventClickjacking( $enable = true ) {
+ $this->preventClickjacking = $enable;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getPreventClickjacking() {
+ return $this->preventClickjacking;
+ }
+
+}
diff --git a/www/wiki/includes/page/ImagePage.php b/www/wiki/includes/page/ImagePage.php
new file mode 100644
index 00000000..0e3eaa5b
--- /dev/null
+++ b/www/wiki/includes/page/ImagePage.php
@@ -0,0 +1,1230 @@
+<?php
+/**
+ * Special handling for file description pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * Class for viewing MediaWiki file description pages
+ *
+ * @ingroup Media
+ */
+class ImagePage extends Article {
+ /** @var File */
+ private $displayImg;
+
+ /** @var FileRepo */
+ private $repo;
+
+ /** @var bool */
+ private $fileLoaded;
+
+ /** @var bool */
+ protected $mExtraDescription = false;
+
+ /**
+ * @var WikiFilePage
+ */
+ protected $mPage;
+
+ /**
+ * @param Title $title
+ * @return WikiFilePage
+ */
+ protected function newPage( Title $title ) {
+ // Overload mPage with a file-specific page
+ return new WikiFilePage( $title );
+ }
+
+ /**
+ * @param File $file
+ * @return void
+ */
+ public function setFile( $file ) {
+ $this->mPage->setFile( $file );
+ $this->displayImg = $file;
+ $this->fileLoaded = true;
+ }
+
+ protected function loadFile() {
+ if ( $this->fileLoaded ) {
+ return;
+ }
+ $this->fileLoaded = true;
+
+ $this->displayImg = $img = false;
+
+ Hooks::run( 'ImagePageFindFile', [ $this, &$img, &$this->displayImg ] );
+ if ( !$img ) { // not set by hook?
+ $img = wfFindFile( $this->getTitle() );
+ if ( !$img ) {
+ $img = wfLocalFile( $this->getTitle() );
+ }
+ }
+ $this->mPage->setFile( $img );
+ if ( !$this->displayImg ) { // not set by hook?
+ $this->displayImg = $img;
+ }
+ $this->repo = $img->getRepo();
+ }
+
+ /**
+ * Handler for action=render
+ * Include body text only; none of the image extras
+ */
+ public function render() {
+ $this->getContext()->getOutput()->setArticleBodyOnly( true );
+ parent::view();
+ }
+
+ public function view() {
+ global $wgShowEXIF;
+
+ $out = $this->getContext()->getOutput();
+ $request = $this->getContext()->getRequest();
+ $diff = $request->getVal( 'diff' );
+ $diffOnly = $request->getBool(
+ 'diffonly',
+ $this->getContext()->getUser()->getOption( 'diffonly' )
+ );
+
+ if ( $this->getTitle()->getNamespace() != NS_FILE || ( $diff !== null && $diffOnly ) ) {
+ parent::view();
+ return;
+ }
+
+ $this->loadFile();
+
+ if ( $this->getTitle()->getNamespace() == NS_FILE && $this->mPage->getFile()->getRedirected() ) {
+ if ( $this->getTitle()->getDBkey() == $this->mPage->getFile()->getName() || $diff !== null ) {
+ $request->setVal( 'diffonly', 'true' );
+ }
+
+ parent::view();
+ return;
+ }
+
+ if ( $wgShowEXIF && $this->displayImg->exists() ) {
+ // @todo FIXME: Bad interface, see note on MediaHandler::formatMetadata().
+ $formattedMetadata = $this->displayImg->formatMetadata( $this->getContext() );
+ $showmeta = $formattedMetadata !== false;
+ } else {
+ $showmeta = false;
+ }
+
+ if ( !$diff && $this->displayImg->exists() ) {
+ $out->addHTML( $this->showTOC( $showmeta ) );
+ }
+
+ if ( !$diff ) {
+ $this->openShowImage();
+ }
+
+ # No need to display noarticletext, we use our own message, output in openShowImage()
+ if ( $this->mPage->getId() ) {
+ # NS_FILE is in the user language, but this section (the actual wikitext)
+ # should be in page content language
+ $pageLang = $this->getTitle()->getPageViewLanguage();
+ $out->addHTML( Xml::openElement( 'div', [ 'id' => 'mw-imagepage-content',
+ 'lang' => $pageLang->getHtmlCode(), 'dir' => $pageLang->getDir(),
+ 'class' => 'mw-content-' . $pageLang->getDir() ] ) );
+
+ parent::view();
+
+ $out->addHTML( Xml::closeElement( 'div' ) );
+ } else {
+ # Just need to set the right headers
+ $out->setArticleFlag( true );
+ $out->setPageTitle( $this->getTitle()->getPrefixedText() );
+ $this->mPage->doViewUpdates( $this->getContext()->getUser(), $this->getOldID() );
+ }
+
+ # Show shared description, if needed
+ if ( $this->mExtraDescription ) {
+ $fol = $this->getContext()->msg( 'shareddescriptionfollows' );
+ if ( !$fol->isDisabled() ) {
+ $out->addWikiText( $fol->plain() );
+ }
+ $out->addHTML( '<div id="shared-image-desc">' . $this->mExtraDescription . "</div>\n" );
+ }
+
+ $this->closeShowImage();
+ $this->imageHistory();
+ // TODO: Cleanup the following
+
+ $out->addHTML( Xml::element( 'h2',
+ [ 'id' => 'filelinks' ],
+ $this->getContext()->msg( 'imagelinks' )->text() ) . "\n" );
+ $this->imageDupes();
+ # @todo FIXME: For some freaky reason, we can't redirect to foreign images.
+ # Yet we return metadata about the target. Definitely an issue in the FileRepo
+ $this->imageLinks();
+
+ # Allow extensions to add something after the image links
+ $html = '';
+ Hooks::run( 'ImagePageAfterImageLinks', [ $this, &$html ] );
+ if ( $html ) {
+ $out->addHTML( $html );
+ }
+
+ if ( $showmeta ) {
+ $out->addHTML( Xml::element(
+ 'h2',
+ [ 'id' => 'metadata' ],
+ $this->getContext()->msg( 'metadata' )->text() ) . "\n" );
+ $out->addWikiText( $this->makeMetadataTable( $formattedMetadata ) );
+ $out->addModules( [ 'mediawiki.action.view.metadata' ] );
+ }
+
+ // Add remote Filepage.css
+ if ( !$this->repo->isLocal() ) {
+ $css = $this->repo->getDescriptionStylesheetUrl();
+ if ( $css ) {
+ $out->addStyle( $css );
+ }
+ }
+
+ $out->addModuleStyles( [
+ 'filepage', // always show the local local Filepage.css, T31277
+ 'mediawiki.action.view.filepage', // Add MediaWiki styles for a file page
+ ] );
+ }
+
+ /**
+ * @return File
+ */
+ public function getDisplayedFile() {
+ $this->loadFile();
+ return $this->displayImg;
+ }
+
+ /**
+ * Create the TOC
+ *
+ * @param bool $metadata Whether or not to show the metadata link
+ * @return string
+ */
+ protected function showTOC( $metadata ) {
+ $r = [
+ '<li><a href="#file">' . $this->getContext()->msg( 'file-anchor-link' )->escaped() . '</a></li>',
+ '<li><a href="#filehistory">' . $this->getContext()->msg( 'filehist' )->escaped() . '</a></li>',
+ '<li><a href="#filelinks">' . $this->getContext()->msg( 'imagelinks' )->escaped() . '</a></li>',
+ ];
+
+ Hooks::run( 'ImagePageShowTOC', [ $this, &$r ] );
+
+ if ( $metadata ) {
+ $r[] = '<li><a href="#metadata">' .
+ $this->getContext()->msg( 'metadata' )->escaped() .
+ '</a></li>';
+ }
+
+ return '<ul id="filetoc">' . implode( "\n", $r ) . '</ul>';
+ }
+
+ /**
+ * Make a table with metadata to be shown in the output page.
+ *
+ * @todo FIXME: Bad interface, see note on MediaHandler::formatMetadata().
+ *
+ * @param array $metadata The array containing the Exif data
+ * @return string The metadata table. This is treated as Wikitext (!)
+ */
+ protected function makeMetadataTable( $metadata ) {
+ $r = "<div class=\"mw-imagepage-section-metadata\">";
+ $r .= $this->getContext()->msg( 'metadata-help' )->plain();
+ $r .= "<table id=\"mw_metadata\" class=\"mw_metadata\">\n";
+ foreach ( $metadata as $type => $stuff ) {
+ foreach ( $stuff as $v ) {
+ $class = str_replace( ' ', '_', $v['id'] );
+ if ( $type == 'collapsed' ) {
+ // Handled by mediawiki.action.view.metadata module.
+ $class .= ' collapsable';
+ }
+ $r .= Html::rawElement( 'tr',
+ [ 'class' => $class ],
+ Html::rawElement( 'th', [], $v['name'] )
+ . Html::rawElement( 'td', [], $v['value'] )
+ );
+ }
+ }
+ $r .= "</table>\n</div>\n";
+ return $r;
+ }
+
+ /**
+ * Overloading Article's getContentObject method.
+ *
+ * Omit noarticletext if sharedupload; text will be fetched from the
+ * shared upload server if possible.
+ * @return string
+ */
+ public function getContentObject() {
+ $this->loadFile();
+ if ( $this->mPage->getFile() && !$this->mPage->getFile()->isLocal() && 0 == $this->getId() ) {
+ return null;
+ }
+ return parent::getContentObject();
+ }
+
+ protected function openShowImage() {
+ global $wgEnableUploads, $wgSend404Code, $wgSVGMaxSize;
+
+ $this->loadFile();
+ $out = $this->getContext()->getOutput();
+ $user = $this->getContext()->getUser();
+ $lang = $this->getContext()->getLanguage();
+ $dirmark = $lang->getDirMarkEntity();
+ $request = $this->getContext()->getRequest();
+
+ $max = $this->getImageLimitsFromOption( $user, 'imagesize' );
+ $maxWidth = $max[0];
+ $maxHeight = $max[1];
+
+ if ( $this->displayImg->exists() ) {
+ # image
+ $page = $request->getIntOrNull( 'page' );
+ if ( is_null( $page ) ) {
+ $params = [];
+ $page = 1;
+ } else {
+ $params = [ 'page' => $page ];
+ }
+
+ $renderLang = $request->getVal( 'lang' );
+ if ( !is_null( $renderLang ) ) {
+ $handler = $this->displayImg->getHandler();
+ if ( $handler && $handler->validateParam( 'lang', $renderLang ) ) {
+ $params['lang'] = $renderLang;
+ } else {
+ $renderLang = null;
+ }
+ }
+
+ $width_orig = $this->displayImg->getWidth( $page );
+ $width = $width_orig;
+ $height_orig = $this->displayImg->getHeight( $page );
+ $height = $height_orig;
+
+ $filename = wfEscapeWikiText( $this->displayImg->getName() );
+ $linktext = $filename;
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $imagePage = $this;
+
+ Hooks::run( 'ImageOpenShowImageInlineBefore', [ &$imagePage, &$out ] );
+
+ if ( $this->displayImg->allowInlineDisplay() ) {
+ # image
+ # "Download high res version" link below the image
+ # $msgsize = $this->getContext()->msg( 'file-info-size', $width_orig, $height_orig,
+ # Linker::formatSize( $this->displayImg->getSize() ), $mime )->escaped();
+ # We'll show a thumbnail of this image
+ if ( $width > $maxWidth || $height > $maxHeight || $this->displayImg->isVectorized() ) {
+ list( $width, $height ) = $this->getDisplayWidthHeight(
+ $maxWidth, $maxHeight, $width, $height
+ );
+ $linktext = $this->getContext()->msg( 'show-big-image' )->escaped();
+
+ $thumbSizes = $this->getThumbSizes( $width_orig, $height_orig );
+ # Generate thumbnails or thumbnail links as needed...
+ $otherSizes = [];
+ foreach ( $thumbSizes as $size ) {
+ // We include a thumbnail size in the list, if it is
+ // less than or equal to the original size of the image
+ // asset ($width_orig/$height_orig). We also exclude
+ // the current thumbnail's size ($width/$height)
+ // since that is added to the message separately, so
+ // it can be denoted as the current size being shown.
+ // Vectorized images are limited by $wgSVGMaxSize big,
+ // so all thumbs less than or equal that are shown.
+ if ( ( ( $size[0] <= $width_orig && $size[1] <= $height_orig )
+ || ( $this->displayImg->isVectorized()
+ && max( $size[0], $size[1] ) <= $wgSVGMaxSize )
+ )
+ && $size[0] != $width && $size[1] != $height
+ ) {
+ $sizeLink = $this->makeSizeLink( $params, $size[0], $size[1] );
+ if ( $sizeLink ) {
+ $otherSizes[] = $sizeLink;
+ }
+ }
+ }
+ $otherSizes = array_unique( $otherSizes );
+
+ $sizeLinkBigImagePreview = $this->makeSizeLink( $params, $width, $height );
+ $msgsmall = $this->getThumbPrevText( $params, $sizeLinkBigImagePreview );
+ if ( count( $otherSizes ) ) {
+ $msgsmall .= ' ' .
+ Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-filepage-other-resolutions' ],
+ $this->getContext()->msg( 'show-big-image-other' )
+ ->rawParams( $lang->pipeList( $otherSizes ) )
+ ->params( count( $otherSizes ) )
+ ->parse()
+ );
+ }
+ } elseif ( $width == 0 && $height == 0 ) {
+ # Some sort of audio file that doesn't have dimensions
+ # Don't output a no hi res message for such a file
+ $msgsmall = '';
+ } else {
+ # Image is small enough to show full size on image page
+ $msgsmall = $this->getContext()->msg( 'file-nohires' )->parse();
+ }
+
+ $params['width'] = $width;
+ $params['height'] = $height;
+ $thumbnail = $this->displayImg->transform( $params );
+ Linker::processResponsiveImages( $this->displayImg, $thumbnail, $params );
+
+ $anchorclose = Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-filepage-resolutioninfo' ],
+ $msgsmall
+ );
+
+ $isMulti = $this->displayImg->isMultipage() && $this->displayImg->pageCount() > 1;
+ if ( $isMulti ) {
+ $out->addModules( 'mediawiki.page.image.pagination' );
+ $out->addHTML( '<table class="multipageimage"><tr><td>' );
+ }
+
+ if ( $thumbnail ) {
+ $options = [
+ 'alt' => $this->displayImg->getTitle()->getPrefixedText(),
+ 'file-link' => true,
+ ];
+ $out->addHTML( '<div class="fullImageLink" id="file">' .
+ $thumbnail->toHtml( $options ) .
+ $anchorclose . "</div>\n" );
+ }
+
+ if ( $isMulti ) {
+ $count = $this->displayImg->pageCount();
+
+ if ( $page > 1 ) {
+ $label = $this->getContext()->msg( 'imgmultipageprev' )->text();
+ // on the client side, this link is generated in ajaxifyPageNavigation()
+ // in the mediawiki.page.image.pagination module
+ $link = Linker::linkKnown(
+ $this->getTitle(),
+ $label,
+ [],
+ [ 'page' => $page - 1 ]
+ );
+ $thumb1 = Linker::makeThumbLinkObj(
+ $this->getTitle(),
+ $this->displayImg,
+ $link,
+ $label,
+ 'none',
+ [ 'page' => $page - 1 ]
+ );
+ } else {
+ $thumb1 = '';
+ }
+
+ if ( $page < $count ) {
+ $label = $this->getContext()->msg( 'imgmultipagenext' )->text();
+ $link = Linker::linkKnown(
+ $this->getTitle(),
+ $label,
+ [],
+ [ 'page' => $page + 1 ]
+ );
+ $thumb2 = Linker::makeThumbLinkObj(
+ $this->getTitle(),
+ $this->displayImg,
+ $link,
+ $label,
+ 'none',
+ [ 'page' => $page + 1 ]
+ );
+ } else {
+ $thumb2 = '';
+ }
+
+ global $wgScript;
+
+ $formParams = [
+ 'name' => 'pageselector',
+ 'action' => $wgScript,
+ ];
+ $options = [];
+ for ( $i = 1; $i <= $count; $i++ ) {
+ $options[] = Xml::option( $lang->formatNum( $i ), $i, $i == $page );
+ }
+ $select = Xml::tags( 'select',
+ [ 'id' => 'pageselector', 'name' => 'page' ],
+ implode( "\n", $options ) );
+
+ $out->addHTML(
+ '</td><td><div class="multipageimagenavbox">' .
+ Xml::openElement( 'form', $formParams ) .
+ Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) .
+ $this->getContext()->msg( 'imgmultigoto' )->rawParams( $select )->parse() .
+ $this->getContext()->msg( 'word-separator' )->escaped() .
+ Xml::submitButton( $this->getContext()->msg( 'imgmultigo' )->text() ) .
+ Xml::closeElement( 'form' ) .
+ "<hr />$thumb1\n$thumb2<br style=\"clear: both\" /></div></td></tr></table>"
+ );
+ }
+ } elseif ( $this->displayImg->isSafeFile() ) {
+ # if direct link is allowed but it's not a renderable image, show an icon.
+ $icon = $this->displayImg->iconThumb();
+
+ $out->addHTML( '<div class="fullImageLink" id="file">' .
+ $icon->toHtml( [ 'file-link' => true ] ) .
+ "</div>\n" );
+ }
+
+ $longDesc = $this->getContext()->msg( 'parentheses', $this->displayImg->getLongDesc() )->text();
+
+ $handler = $this->displayImg->getHandler();
+
+ // If this is a filetype with potential issues, warn the user.
+ if ( $handler ) {
+ $warningConfig = $handler->getWarningConfig( $this->displayImg );
+
+ if ( $warningConfig !== null ) {
+ // The warning will be displayed via CSS and JavaScript.
+ // We just need to tell the client side what message to use.
+ $output = $this->getContext()->getOutput();
+ $output->addJsConfigVars( 'wgFileWarning', $warningConfig );
+ $output->addModules( $warningConfig['module'] );
+ $output->addModules( 'mediawiki.filewarning' );
+ }
+ }
+
+ $medialink = "[[Media:$filename|$linktext]]";
+
+ if ( !$this->displayImg->isSafeFile() ) {
+ $warning = $this->getContext()->msg( 'mediawarning' )->plain();
+ // dirmark is needed here to separate the file name, which
+ // most likely ends in Latin characters, from the description,
+ // which may begin with the file type. In RTL environment
+ // this will get messy.
+ // The dirmark, however, must not be immediately adjacent
+ // to the filename, because it can get copied with it.
+ // See T27277.
+ // @codingStandardsIgnoreStart Ignore long line
+ $out->addWikiText( <<<EOT
+<div class="fullMedia"><span class="dangerousLink">{$medialink}</span> $dirmark<span class="fileInfo">$longDesc</span></div>
+<div class="mediaWarning">$warning</div>
+EOT
+ );
+ // @codingStandardsIgnoreEnd
+ } else {
+ $out->addWikiText( <<<EOT
+<div class="fullMedia">{$medialink} {$dirmark}<span class="fileInfo">$longDesc</span>
+</div>
+EOT
+ );
+ }
+
+ $renderLangOptions = $this->displayImg->getAvailableLanguages();
+ if ( count( $renderLangOptions ) >= 1 ) {
+ $currentLanguage = $renderLang;
+ $defaultLang = $this->displayImg->getDefaultRenderLanguage();
+ if ( is_null( $currentLanguage ) ) {
+ $currentLanguage = $defaultLang;
+ }
+ $out->addHTML( $this->doRenderLangOpt( $renderLangOptions, $currentLanguage, $defaultLang ) );
+ }
+
+ // Add cannot animate thumbnail warning
+ if ( !$this->displayImg->canAnimateThumbIfAppropriate() ) {
+ // Include the extension so wiki admins can
+ // customize it on a per file-type basis
+ // (aka say things like use format X instead).
+ // additionally have a specific message for
+ // file-no-thumb-animation-gif
+ $ext = $this->displayImg->getExtension();
+ $noAnimMesg = wfMessageFallback(
+ 'file-no-thumb-animation-' . $ext,
+ 'file-no-thumb-animation'
+ )->plain();
+
+ $out->addWikiText( <<<EOT
+<div class="mw-noanimatethumb">{$noAnimMesg}</div>
+EOT
+ );
+ }
+
+ if ( !$this->displayImg->isLocal() ) {
+ $this->printSharedImageText();
+ }
+ } else {
+ # Image does not exist
+ if ( !$this->getId() ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ # No article exists either
+ # Show deletion log to be consistent with normal articles
+ LogEventsList::showLogExtract(
+ $out,
+ [ 'delete', 'move' ],
+ $this->getTitle()->getPrefixedText(),
+ '',
+ [ 'lim' => 10,
+ 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ],
+ 'showIfEmpty' => false,
+ 'msgKey' => [ 'moveddeleted-notice' ]
+ ]
+ );
+ }
+
+ if ( $wgEnableUploads && $user->isAllowed( 'upload' ) ) {
+ // Only show an upload link if the user can upload
+ $uploadTitle = SpecialPage::getTitleFor( 'Upload' );
+ $nofile = [
+ 'filepage-nofile-link',
+ $uploadTitle->getFullURL( [ 'wpDestFile' => $this->mPage->getFile()->getName() ] )
+ ];
+ } else {
+ $nofile = 'filepage-nofile';
+ }
+ // Note, if there is an image description page, but
+ // no image, then this setRobotPolicy is overridden
+ // by Article::View().
+ $out->setRobotPolicy( 'noindex,nofollow' );
+ $out->wrapWikiMsg( "<div id='mw-imagepage-nofile' class='plainlinks'>\n$1\n</div>", $nofile );
+ if ( !$this->getId() && $wgSend404Code ) {
+ // If there is no image, no shared image, and no description page,
+ // output a 404, to be consistent with Article::showMissingArticle.
+ $request->response()->statusHeader( 404 );
+ }
+ }
+ $out->setFileVersion( $this->displayImg );
+ }
+
+ /**
+ * Make the text under the image to say what size preview
+ *
+ * @param array $params parameters for thumbnail
+ * @param string $sizeLinkBigImagePreview HTML for the current size
+ * @return string HTML output
+ */
+ private function getThumbPrevText( $params, $sizeLinkBigImagePreview ) {
+ if ( $sizeLinkBigImagePreview ) {
+ // Show a different message of preview is different format from original.
+ $previewTypeDiffers = false;
+ $origExt = $thumbExt = $this->displayImg->getExtension();
+ if ( $this->displayImg->getHandler() ) {
+ $origMime = $this->displayImg->getMimeType();
+ $typeParams = $params;
+ $this->displayImg->getHandler()->normaliseParams( $this->displayImg, $typeParams );
+ list( $thumbExt, $thumbMime ) = $this->displayImg->getHandler()->getThumbType(
+ $origExt, $origMime, $typeParams );
+ if ( $thumbMime !== $origMime ) {
+ $previewTypeDiffers = true;
+ }
+ }
+ if ( $previewTypeDiffers ) {
+ return $this->getContext()->msg( 'show-big-image-preview-differ' )->
+ rawParams( $sizeLinkBigImagePreview )->
+ params( strtoupper( $origExt ) )->
+ params( strtoupper( $thumbExt ) )->
+ parse();
+ } else {
+ return $this->getContext()->msg( 'show-big-image-preview' )->
+ rawParams( $sizeLinkBigImagePreview )->
+ parse();
+ }
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Creates an thumbnail of specified size and returns an HTML link to it
+ * @param array $params Scaler parameters
+ * @param int $width
+ * @param int $height
+ * @return string
+ */
+ private function makeSizeLink( $params, $width, $height ) {
+ $params['width'] = $width;
+ $params['height'] = $height;
+ $thumbnail = $this->displayImg->transform( $params );
+ if ( $thumbnail && !$thumbnail->isError() ) {
+ return Html::rawElement( 'a', [
+ 'href' => $thumbnail->getUrl(),
+ 'class' => 'mw-thumbnail-link'
+ ], $this->getContext()->msg( 'show-big-image-size' )->numParams(
+ $thumbnail->getWidth(), $thumbnail->getHeight()
+ )->parse() );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Show a notice that the file is from a shared repository
+ */
+ protected function printSharedImageText() {
+ $out = $this->getContext()->getOutput();
+ $this->loadFile();
+
+ $descUrl = $this->mPage->getFile()->getDescriptionUrl();
+ $descText = $this->mPage->getFile()->getDescriptionText( $this->getContext()->getLanguage() );
+
+ /* Add canonical to head if there is no local page for this shared file */
+ if ( $descUrl && $this->mPage->getId() == 0 ) {
+ $out->setCanonicalUrl( $descUrl );
+ }
+
+ $wrap = "<div class=\"sharedUploadNotice\">\n$1\n</div>\n";
+ $repo = $this->mPage->getFile()->getRepo()->getDisplayName();
+
+ if ( $descUrl &&
+ $descText &&
+ $this->getContext()->msg( 'sharedupload-desc-here' )->plain() !== '-'
+ ) {
+ $out->wrapWikiMsg( $wrap, [ 'sharedupload-desc-here', $repo, $descUrl ] );
+ } elseif ( $descUrl &&
+ $this->getContext()->msg( 'sharedupload-desc-there' )->plain() !== '-'
+ ) {
+ $out->wrapWikiMsg( $wrap, [ 'sharedupload-desc-there', $repo, $descUrl ] );
+ } else {
+ $out->wrapWikiMsg( $wrap, [ 'sharedupload', $repo ], ''/*BACKCOMPAT*/ );
+ }
+
+ if ( $descText ) {
+ $this->mExtraDescription = $descText;
+ }
+ }
+
+ public function getUploadUrl() {
+ $this->loadFile();
+ $uploadTitle = SpecialPage::getTitleFor( 'Upload' );
+ return $uploadTitle->getFullURL( [
+ 'wpDestFile' => $this->mPage->getFile()->getName(),
+ 'wpForReUpload' => 1
+ ] );
+ }
+
+ /**
+ * Print out the various links at the bottom of the image page, e.g. reupload,
+ * external editing (and instructions link) etc.
+ */
+ protected function uploadLinksBox() {
+ global $wgEnableUploads;
+
+ if ( !$wgEnableUploads ) {
+ return;
+ }
+
+ $this->loadFile();
+ if ( !$this->mPage->getFile()->isLocal() ) {
+ return;
+ }
+
+ $out = $this->getContext()->getOutput();
+ $out->addHTML( "<ul>\n" );
+
+ # "Upload a new version of this file" link
+ $canUpload = $this->getTitle()->quickUserCan( 'upload', $this->getContext()->getUser() );
+ if ( $canUpload && UploadBase::userCanReUpload(
+ $this->getContext()->getUser(),
+ $this->mPage->getFile() )
+ ) {
+ $ulink = Linker::makeExternalLink(
+ $this->getUploadUrl(),
+ $this->getContext()->msg( 'uploadnewversion-linktext' )->text()
+ );
+ $out->addHTML( "<li id=\"mw-imagepage-reupload-link\">"
+ . "<div class=\"plainlinks\">{$ulink}</div></li>\n" );
+ } else {
+ $out->addHTML( "<li id=\"mw-imagepage-upload-disallowed\">"
+ . $this->getContext()->msg( 'upload-disallowed-here' )->escaped() . "</li>\n" );
+ }
+
+ $out->addHTML( "</ul>\n" );
+ }
+
+ /**
+ * For overloading
+ */
+ protected function closeShowImage() {
+ }
+
+ /**
+ * If the page we've just displayed is in the "Image" namespace,
+ * we follow it with an upload history of the image and its usage.
+ */
+ protected function imageHistory() {
+ $this->loadFile();
+ $out = $this->getContext()->getOutput();
+ $pager = new ImageHistoryPseudoPager( $this );
+ $out->addHTML( $pager->getBody() );
+ $out->preventClickjacking( $pager->getPreventClickjacking() );
+
+ $this->mPage->getFile()->resetHistory(); // free db resources
+
+ # Exist check because we don't want to show this on pages where an image
+ # doesn't exist along with the noimage message, that would suck. -ævar
+ if ( $this->mPage->getFile()->exists() ) {
+ $this->uploadLinksBox();
+ }
+ }
+
+ /**
+ * @param string $target
+ * @param int $limit
+ * @return ResultWrapper
+ */
+ protected function queryImageLinks( $target, $limit ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ return $dbr->select(
+ [ 'imagelinks', 'page' ],
+ [ 'page_namespace', 'page_title', 'il_to' ],
+ [ 'il_to' => $target, 'il_from = page_id' ],
+ __METHOD__,
+ [ 'LIMIT' => $limit + 1, 'ORDER BY' => 'il_from', ]
+ );
+ }
+
+ protected function imageLinks() {
+ $limit = 100;
+
+ $out = $this->getContext()->getOutput();
+
+ $rows = [];
+ $redirects = [];
+ foreach ( $this->getTitle()->getRedirectsHere( NS_FILE ) as $redir ) {
+ $redirects[$redir->getDBkey()] = [];
+ $rows[] = (object)[
+ 'page_namespace' => NS_FILE,
+ 'page_title' => $redir->getDBkey(),
+ ];
+ }
+
+ $res = $this->queryImageLinks( $this->getTitle()->getDBkey(), $limit + 1 );
+ foreach ( $res as $row ) {
+ $rows[] = $row;
+ }
+ $count = count( $rows );
+
+ $hasMore = $count > $limit;
+ if ( !$hasMore && count( $redirects ) ) {
+ $res = $this->queryImageLinks( array_keys( $redirects ),
+ $limit - count( $rows ) + 1 );
+ foreach ( $res as $row ) {
+ $redirects[$row->il_to][] = $row;
+ $count++;
+ }
+ $hasMore = ( $res->numRows() + count( $rows ) ) > $limit;
+ }
+
+ if ( $count == 0 ) {
+ $out->wrapWikiMsg(
+ Html::rawElement( 'div',
+ [ 'id' => 'mw-imagepage-nolinkstoimage' ], "\n$1\n" ),
+ 'nolinkstoimage'
+ );
+ return;
+ }
+
+ $out->addHTML( "<div id='mw-imagepage-section-linkstoimage'>\n" );
+ if ( !$hasMore ) {
+ $out->addWikiMsg( 'linkstoimage', $count );
+ } else {
+ // More links than the limit. Add a link to [[Special:Whatlinkshere]]
+ $out->addWikiMsg( 'linkstoimage-more',
+ $this->getContext()->getLanguage()->formatNum( $limit ),
+ $this->getTitle()->getPrefixedDBkey()
+ );
+ }
+
+ $out->addHTML(
+ Html::openElement( 'ul',
+ [ 'class' => 'mw-imagepage-linkstoimage' ] ) . "\n"
+ );
+ $count = 0;
+
+ // Sort the list by namespace:title
+ usort( $rows, [ $this, 'compare' ] );
+
+ // Create links for every element
+ $currentCount = 0;
+ foreach ( $rows as $element ) {
+ $currentCount++;
+ if ( $currentCount > $limit ) {
+ break;
+ }
+
+ $query = [];
+ # Add a redirect=no to make redirect pages reachable
+ if ( isset( $redirects[$element->page_title] ) ) {
+ $query['redirect'] = 'no';
+ }
+ $link = Linker::linkKnown(
+ Title::makeTitle( $element->page_namespace, $element->page_title ),
+ null, [], $query
+ );
+ if ( !isset( $redirects[$element->page_title] ) ) {
+ # No redirects
+ $liContents = $link;
+ } elseif ( count( $redirects[$element->page_title] ) === 0 ) {
+ # Redirect without usages
+ $liContents = $this->getContext()->msg( 'linkstoimage-redirect' )
+ ->rawParams( $link, '' )
+ ->parse();
+ } else {
+ # Redirect with usages
+ $li = '';
+ foreach ( $redirects[$element->page_title] as $row ) {
+ $currentCount++;
+ if ( $currentCount > $limit ) {
+ break;
+ }
+
+ $link2 = Linker::linkKnown( Title::makeTitle( $row->page_namespace, $row->page_title ) );
+ $li .= Html::rawElement(
+ 'li',
+ [ 'class' => 'mw-imagepage-linkstoimage-ns' . $element->page_namespace ],
+ $link2
+ ) . "\n";
+ }
+
+ $ul = Html::rawElement(
+ 'ul',
+ [ 'class' => 'mw-imagepage-redirectstofile' ],
+ $li
+ ) . "\n";
+ $liContents = $this->getContext()->msg( 'linkstoimage-redirect' )->rawParams(
+ $link, $ul )->parse();
+ }
+ $out->addHTML( Html::rawElement(
+ 'li',
+ [ 'class' => 'mw-imagepage-linkstoimage-ns' . $element->page_namespace ],
+ $liContents
+ ) . "\n"
+ );
+
+ };
+ $out->addHTML( Html::closeElement( 'ul' ) . "\n" );
+ $res->free();
+
+ // Add a links to [[Special:Whatlinkshere]]
+ if ( $count > $limit ) {
+ $out->addWikiMsg( 'morelinkstoimage', $this->getTitle()->getPrefixedDBkey() );
+ }
+ $out->addHTML( Html::closeElement( 'div' ) . "\n" );
+ }
+
+ protected function imageDupes() {
+ $this->loadFile();
+ $out = $this->getContext()->getOutput();
+
+ $dupes = $this->mPage->getDuplicates();
+ if ( count( $dupes ) == 0 ) {
+ return;
+ }
+
+ $out->addHTML( "<div id='mw-imagepage-section-duplicates'>\n" );
+ $out->addWikiMsg( 'duplicatesoffile',
+ $this->getContext()->getLanguage()->formatNum( count( $dupes ) ), $this->getTitle()->getDBkey()
+ );
+ $out->addHTML( "<ul class='mw-imagepage-duplicates'>\n" );
+
+ /**
+ * @var $file File
+ */
+ foreach ( $dupes as $file ) {
+ $fromSrc = '';
+ if ( $file->isLocal() ) {
+ $link = Linker::linkKnown( $file->getTitle() );
+ } else {
+ $link = Linker::makeExternalLink( $file->getDescriptionUrl(),
+ $file->getTitle()->getPrefixedText() );
+ $fromSrc = $this->getContext()->msg(
+ 'shared-repo-from',
+ $file->getRepo()->getDisplayName()
+ )->text();
+ }
+ $out->addHTML( "<li>{$link} {$fromSrc}</li>\n" );
+ }
+ $out->addHTML( "</ul></div>\n" );
+ }
+
+ /**
+ * Delete the file, or an earlier version of it
+ */
+ public function delete() {
+ $file = $this->mPage->getFile();
+ if ( !$file->exists() || !$file->isLocal() || $file->getRedirected() ) {
+ // Standard article deletion
+ parent::delete();
+ return;
+ }
+
+ $deleter = new FileDeleteForm( $file );
+ $deleter->execute();
+ }
+
+ /**
+ * Display an error with a wikitext description
+ *
+ * @param string $description
+ */
+ function showError( $description ) {
+ $out = $this->getContext()->getOutput();
+ $out->setPageTitle( $this->getContext()->msg( 'internalerror' ) );
+ $out->setRobotPolicy( 'noindex,nofollow' );
+ $out->setArticleRelated( false );
+ $out->enableClientCache( false );
+ $out->addWikiText( $description );
+ }
+
+ /**
+ * Callback for usort() to do link sorts by (namespace, title)
+ * Function copied from Title::compare()
+ *
+ * @param object $a Object page to compare with
+ * @param object $b Object page to compare with
+ * @return int Result of string comparison, or namespace comparison
+ */
+ protected function compare( $a, $b ) {
+ if ( $a->page_namespace == $b->page_namespace ) {
+ return strcmp( $a->page_title, $b->page_title );
+ } else {
+ return $a->page_namespace - $b->page_namespace;
+ }
+ }
+
+ /**
+ * Returns the corresponding $wgImageLimits entry for the selected user option
+ *
+ * @param User $user
+ * @param string $optionName Name of a option to check, typically imagesize or thumbsize
+ * @return array
+ * @since 1.21
+ */
+ public function getImageLimitsFromOption( $user, $optionName ) {
+ global $wgImageLimits;
+
+ $option = $user->getIntOption( $optionName );
+ if ( !isset( $wgImageLimits[$option] ) ) {
+ $option = User::getDefaultOption( $optionName );
+ }
+
+ // The user offset might still be incorrect, specially if
+ // $wgImageLimits got changed (see bug #8858).
+ if ( !isset( $wgImageLimits[$option] ) ) {
+ // Default to the first offset in $wgImageLimits
+ $option = 0;
+ }
+
+ return isset( $wgImageLimits[$option] )
+ ? $wgImageLimits[$option]
+ : [ 800, 600 ]; // if nothing is set, fallback to a hardcoded default
+ }
+
+ /**
+ * Output a drop-down box for language options for the file
+ *
+ * @param array $langChoices Array of string language codes
+ * @param string $curLang Language code file is being viewed in.
+ * @param string $defaultLang Language code that image is rendered in by default
+ * @return string HTML to insert underneath image.
+ */
+ protected function doRenderLangOpt( array $langChoices, $curLang, $defaultLang ) {
+ global $wgScript;
+ sort( $langChoices );
+ $curLang = wfBCP47( $curLang );
+ $defaultLang = wfBCP47( $defaultLang );
+ $opts = '';
+ $haveCurrentLang = false;
+ $haveDefaultLang = false;
+
+ // We make a list of all the language choices in the file.
+ // Additionally if the default language to render this file
+ // is not included as being in this file (for example, in svgs
+ // usually the fallback content is the english content) also
+ // include a choice for that. Last of all, if we're viewing
+ // the file in a language not on the list, add it as a choice.
+ foreach ( $langChoices as $lang ) {
+ $code = wfBCP47( $lang );
+ $name = Language::fetchLanguageName( $code, $this->getContext()->getLanguage()->getCode() );
+ if ( $name !== '' ) {
+ $display = $this->getContext()->msg( 'img-lang-opt', $code, $name )->text();
+ } else {
+ $display = $code;
+ }
+ $opts .= "\n" . Xml::option( $display, $code, $curLang === $code );
+ if ( $curLang === $code ) {
+ $haveCurrentLang = true;
+ }
+ if ( $defaultLang === $code ) {
+ $haveDefaultLang = true;
+ }
+ }
+ if ( !$haveDefaultLang ) {
+ // Its hard to know if the content is really in the default language, or
+ // if its just unmarked content that could be in any language.
+ $opts = Xml::option(
+ $this->getContext()->msg( 'img-lang-default' )->text(),
+ $defaultLang,
+ $defaultLang === $curLang
+ ) . $opts;
+ }
+ if ( !$haveCurrentLang && $defaultLang !== $curLang ) {
+ $name = Language::fetchLanguageName( $curLang, $this->getContext()->getLanguage()->getCode() );
+ if ( $name !== '' ) {
+ $display = $this->getContext()->msg( 'img-lang-opt', $curLang, $name )->text();
+ } else {
+ $display = $curLang;
+ }
+ $opts = Xml::option( $display, $curLang, true ) . $opts;
+ }
+
+ $select = Html::rawElement(
+ 'select',
+ [ 'id' => 'mw-imglangselector', 'name' => 'lang' ],
+ $opts
+ );
+ $submit = Xml::submitButton( $this->getContext()->msg( 'img-lang-go' )->text() );
+
+ $formContents = $this->getContext()->msg( 'img-lang-info' )
+ ->rawParams( $select, $submit )
+ ->parse();
+ $formContents .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() );
+
+ $langSelectLine = Html::rawElement( 'div', [ 'id' => 'mw-imglangselector-line' ],
+ Html::rawElement( 'form', [ 'action' => $wgScript ], $formContents )
+ );
+ return $langSelectLine;
+ }
+
+ /**
+ * Get the width and height to display image at.
+ *
+ * @note This method assumes that it is only called if one
+ * of the dimensions are bigger than the max, or if the
+ * image is vectorized.
+ *
+ * @param int $maxWidth Max width to display at
+ * @param int $maxHeight Max height to display at
+ * @param int $width Actual width of the image
+ * @param int $height Actual height of the image
+ * @throws MWException
+ * @return array Array (width, height)
+ */
+ protected function getDisplayWidthHeight( $maxWidth, $maxHeight, $width, $height ) {
+ if ( !$maxWidth || !$maxHeight ) {
+ // should never happen
+ throw new MWException( 'Using a choice from $wgImageLimits that is 0x0' );
+ }
+
+ if ( !$width || !$height ) {
+ return [ 0, 0 ];
+ }
+
+ # Calculate the thumbnail size.
+ if ( $width <= $maxWidth && $height <= $maxHeight ) {
+ // Vectorized image, do nothing.
+ } elseif ( $width / $height >= $maxWidth / $maxHeight ) {
+ # The limiting factor is the width, not the height.
+ $height = round( $height * $maxWidth / $width );
+ $width = $maxWidth;
+ # Note that $height <= $maxHeight now.
+ } else {
+ $newwidth = floor( $width * $maxHeight / $height );
+ $height = round( $height * $newwidth / $width );
+ $width = $newwidth;
+ # Note that $height <= $maxHeight now, but might not be identical
+ # because of rounding.
+ }
+ return [ $width, $height ];
+ }
+
+ /**
+ * Get alternative thumbnail sizes.
+ *
+ * @note This will only list several alternatives if thumbnails are rendered on 404
+ * @param int $origWidth Actual width of image
+ * @param int $origHeight Actual height of image
+ * @return array An array of [width, height] pairs.
+ */
+ protected function getThumbSizes( $origWidth, $origHeight ) {
+ global $wgImageLimits;
+ if ( $this->displayImg->getRepo()->canTransformVia404() ) {
+ $thumbSizes = $wgImageLimits;
+ // Also include the full sized resolution in the list, so
+ // that users know they can get it. This will link to the
+ // original file asset if mustRender() === false. In the case
+ // that we mustRender, some users have indicated that they would
+ // find it useful to have the full size image in the rendered
+ // image format.
+ $thumbSizes[] = [ $origWidth, $origHeight ];
+ } else {
+ # Creating thumb links triggers thumbnail generation.
+ # Just generate the thumb for the current users prefs.
+ $thumbSizes = [
+ $this->getImageLimitsFromOption( $this->getContext()->getUser(), 'thumbsize' )
+ ];
+ if ( !$this->displayImg->mustRender() ) {
+ // We can safely include a link to the "full-size" preview,
+ // without actually rendering.
+ $thumbSizes[] = [ $origWidth, $origHeight ];
+ }
+ }
+ return $thumbSizes;
+ }
+
+ /**
+ * @see WikiFilePage::getFile
+ * @return bool|File
+ */
+ public function getFile() {
+ return $this->mPage->getFile();
+ }
+
+ /**
+ * @see WikiFilePage::isLocal
+ * @return bool
+ */
+ public function isLocal() {
+ return $this->mPage->isLocal();
+ }
+
+ /**
+ * @see WikiFilePage::getDuplicates
+ * @return array|null
+ */
+ public function getDuplicates() {
+ return $this->mPage->getDuplicates();
+ }
+
+ /**
+ * @see WikiFilePage::getForeignCategories
+ * @return TitleArray|Title[]
+ */
+ public function getForeignCategories() {
+ $this->mPage->getForeignCategories();
+ }
+
+}
diff --git a/www/wiki/includes/page/Page.php b/www/wiki/includes/page/Page.php
new file mode 100644
index 00000000..2cb1fc03
--- /dev/null
+++ b/www/wiki/includes/page/Page.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Interface for type hinting (accepts WikiPage, Article, ImagePage, CategoryPage)
+ */
+interface Page {
+}
diff --git a/www/wiki/includes/page/PageArchive.php b/www/wiki/includes/page/PageArchive.php
new file mode 100644
index 00000000..af936cc7
--- /dev/null
+++ b/www/wiki/includes/page/PageArchive.php
@@ -0,0 +1,810 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Used to show archived pages and eventually restore them.
+ */
+class PageArchive {
+ /** @var Title */
+ protected $title;
+
+ /** @var Status */
+ protected $fileStatus;
+
+ /** @var Status */
+ protected $revisionStatus;
+
+ /** @var Config */
+ protected $config;
+
+ public function __construct( $title, Config $config = null ) {
+ if ( is_null( $title ) ) {
+ throw new MWException( __METHOD__ . ' given a null title.' );
+ }
+ $this->title = $title;
+ if ( $config === null ) {
+ wfDebug( __METHOD__ . ' did not have a Config object passed to it' );
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+ $this->config = $config;
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * List all deleted pages recorded in the archive table. Returns result
+ * wrapper with (ar_namespace, ar_title, count) fields, ordered by page
+ * namespace/title.
+ *
+ * @return ResultWrapper
+ */
+ public static function listAllPages() {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ return self::listPages( $dbr, '' );
+ }
+
+ /**
+ * List deleted pages recorded in the archive matching the
+ * given term, using search engine archive.
+ * Returns result wrapper with (ar_namespace, ar_title, count) fields.
+ *
+ * @param string $term Search term
+ * @return ResultWrapper
+ */
+ public static function listPagesBySearch( $term ) {
+ $title = Title::newFromText( $term );
+ if ( $title ) {
+ $ns = $title->getNamespace();
+ $termMain = $title->getText();
+ $termDb = $title->getDBkey();
+ } else {
+ // Prolly won't work too good
+ // @todo handle bare namespace names cleanly?
+ $ns = 0;
+ $termMain = $termDb = $term;
+ }
+
+ // Try search engine first
+ $engine = MediaWikiServices::getInstance()->newSearchEngine();
+ $engine->setLimitOffset( 100 );
+ $engine->setNamespaces( [ $ns ] );
+ $results = $engine->searchArchiveTitle( $termMain );
+ if ( !$results->isOK() ) {
+ $results = [];
+ } else {
+ $results = $results->getValue();
+ }
+
+ if ( !$results ) {
+ // Fall back to regular prefix search
+ return self::listPagesByPrefix( $term );
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $condTitles = array_unique( array_map( function ( Title $t ) {
+ return $t->getDBkey();
+ }, $results ) );
+ $conds = [
+ 'ar_namespace' => $ns,
+ $dbr->makeList( [ 'ar_title' => $condTitles ], LIST_OR ) . " OR ar_title " .
+ $dbr->buildLike( $termDb, $dbr->anyString() )
+ ];
+
+ return self::listPages( $dbr, $conds );
+ }
+
+ /**
+ * List deleted pages recorded in the archive table matching the
+ * given title prefix.
+ * Returns result wrapper with (ar_namespace, ar_title, count) fields.
+ *
+ * @param string $prefix Title prefix
+ * @return ResultWrapper
+ */
+ public static function listPagesByPrefix( $prefix ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $title = Title::newFromText( $prefix );
+ if ( $title ) {
+ $ns = $title->getNamespace();
+ $prefix = $title->getDBkey();
+ } else {
+ // Prolly won't work too good
+ // @todo handle bare namespace names cleanly?
+ $ns = 0;
+ }
+
+ $conds = [
+ 'ar_namespace' => $ns,
+ 'ar_title' . $dbr->buildLike( $prefix, $dbr->anyString() ),
+ ];
+
+ return self::listPages( $dbr, $conds );
+ }
+
+ /**
+ * @param IDatabase $dbr
+ * @param string|array $condition
+ * @return bool|ResultWrapper
+ */
+ protected static function listPages( $dbr, $condition ) {
+ return $dbr->select(
+ [ 'archive' ],
+ [
+ 'ar_namespace',
+ 'ar_title',
+ 'count' => 'COUNT(*)'
+ ],
+ $condition,
+ __METHOD__,
+ [
+ 'GROUP BY' => [ 'ar_namespace', 'ar_title' ],
+ 'ORDER BY' => [ 'ar_namespace', 'ar_title' ],
+ 'LIMIT' => 100,
+ ]
+ );
+ }
+
+ /**
+ * List the revisions of the given page. Returns result wrapper with
+ * various archive table fields.
+ *
+ * @return ResultWrapper
+ */
+ public function listRevisions() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $commentQuery = CommentStore::newKey( 'ar_comment' )->getJoin();
+
+ $tables = [ 'archive' ] + $commentQuery['tables'];
+
+ $fields = [
+ 'ar_minor_edit', 'ar_timestamp', 'ar_user', 'ar_user_text',
+ 'ar_len', 'ar_deleted', 'ar_rev_id', 'ar_sha1',
+ 'ar_page_id'
+ ] + $commentQuery['fields'];
+
+ if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+ $fields[] = 'ar_content_format';
+ $fields[] = 'ar_content_model';
+ }
+
+ $conds = [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey() ];
+
+ $options = [ 'ORDER BY' => 'ar_timestamp DESC' ];
+
+ $join_conds = [] + $commentQuery['joins'];
+
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $fields,
+ $conds,
+ $join_conds,
+ $options,
+ ''
+ );
+
+ return $dbr->select( $tables,
+ $fields,
+ $conds,
+ __METHOD__,
+ $options,
+ $join_conds
+ );
+ }
+
+ /**
+ * List the deleted file revisions for this page, if it's a file page.
+ * Returns a result wrapper with various filearchive fields, or null
+ * if not a file page.
+ *
+ * @return ResultWrapper
+ * @todo Does this belong in Image for fuller encapsulation?
+ */
+ public function listFiles() {
+ if ( $this->title->getNamespace() != NS_FILE ) {
+ return null;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ return $dbr->select(
+ 'filearchive',
+ ArchivedFile::selectFields(),
+ [ 'fa_name' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'ORDER BY' => 'fa_timestamp DESC' ]
+ );
+ }
+
+ /**
+ * Return a Revision object containing data for the deleted revision.
+ * Note that the result *may* or *may not* have a null page ID.
+ *
+ * @param string $timestamp
+ * @return Revision|null
+ */
+ public function getRevision( $timestamp ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $commentQuery = CommentStore::newKey( 'ar_comment' )->getJoin();
+
+ $tables = [ 'archive' ] + $commentQuery['tables'];
+
+ $fields = [
+ 'ar_rev_id',
+ 'ar_text',
+ 'ar_user',
+ 'ar_user_text',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_flags',
+ 'ar_text_id',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_sha1',
+ ] + $commentQuery['fields'];
+
+ if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+ $fields[] = 'ar_content_format';
+ $fields[] = 'ar_content_model';
+ }
+
+ $join_conds = [] + $commentQuery['joins'];
+
+ $row = $dbr->selectRow(
+ $tables,
+ $fields,
+ [
+ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ 'ar_timestamp' => $dbr->timestamp( $timestamp )
+ ],
+ __METHOD__,
+ [],
+ $join_conds
+ );
+
+ if ( $row ) {
+ return Revision::newFromArchiveRow( $row, [ 'title' => $this->title ] );
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the most-previous revision, either live or deleted, against
+ * the deleted revision given by timestamp.
+ *
+ * May produce unexpected results in case of history merges or other
+ * unusual time issues.
+ *
+ * @param string $timestamp
+ * @return Revision|null Null when there is no previous revision
+ */
+ public function getPreviousRevision( $timestamp ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ // Check the previous deleted revision...
+ $row = $dbr->selectRow( 'archive',
+ 'ar_timestamp',
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ 'ar_timestamp < ' .
+ $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'ar_timestamp DESC',
+ 'LIMIT' => 1 ] );
+ $prevDeleted = $row ? wfTimestamp( TS_MW, $row->ar_timestamp ) : false;
+
+ $row = $dbr->selectRow( [ 'page', 'revision' ],
+ [ 'rev_id', 'rev_timestamp' ],
+ [
+ 'page_namespace' => $this->title->getNamespace(),
+ 'page_title' => $this->title->getDBkey(),
+ 'page_id = rev_page',
+ 'rev_timestamp < ' .
+ $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'rev_timestamp DESC',
+ 'LIMIT' => 1 ] );
+ $prevLive = $row ? wfTimestamp( TS_MW, $row->rev_timestamp ) : false;
+ $prevLiveId = $row ? intval( $row->rev_id ) : null;
+
+ if ( $prevLive && $prevLive > $prevDeleted ) {
+ // Most prior revision was live
+ return Revision::newFromId( $prevLiveId );
+ } elseif ( $prevDeleted ) {
+ // Most prior revision was deleted
+ return $this->getRevision( $prevDeleted );
+ }
+
+ // No prior revision on this page.
+ return null;
+ }
+
+ /**
+ * Get the text from an archive row containing ar_text, ar_flags and ar_text_id
+ *
+ * @param object $row Database row
+ * @return string
+ */
+ public function getTextFromRow( $row ) {
+ if ( is_null( $row->ar_text_id ) ) {
+ // An old row from MediaWiki 1.4 or previous.
+ // Text is embedded in this row in classic compression format.
+ return Revision::getRevisionText( $row, 'ar_' );
+ }
+
+ // New-style: keyed to the text storage backend.
+ $dbr = wfGetDB( DB_REPLICA );
+ $text = $dbr->selectRow( 'text',
+ [ 'old_text', 'old_flags' ],
+ [ 'old_id' => $row->ar_text_id ],
+ __METHOD__ );
+
+ return Revision::getRevisionText( $text );
+ }
+
+ /**
+ * Fetch (and decompress if necessary) the stored text of the most
+ * recently edited deleted revision of the page.
+ *
+ * If there are no archived revisions for the page, returns NULL.
+ *
+ * @return string|null
+ */
+ public function getLastRevisionText() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow( 'archive',
+ [ 'ar_text', 'ar_flags', 'ar_text_id' ],
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'ORDER BY' => 'ar_timestamp DESC' ] );
+
+ if ( $row ) {
+ return $this->getTextFromRow( $row );
+ }
+
+ return null;
+ }
+
+ /**
+ * Quick check if any archived revisions are present for the page.
+ *
+ * @return bool
+ */
+ public function isDeleted() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $n = $dbr->selectField( 'archive', 'COUNT(ar_title)',
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey() ],
+ __METHOD__
+ );
+
+ return ( $n > 0 );
+ }
+
+ /**
+ * Restore the given (or all) text and file revisions for the page.
+ * Once restored, the items will be removed from the archive tables.
+ * The deletion log will be updated with an undeletion notice.
+ *
+ * This also sets Status objects, $this->fileStatus and $this->revisionStatus
+ * (depending what operations are attempted).
+ *
+ * @param array $timestamps Pass an empty array to restore all revisions,
+ * otherwise list the ones to undelete.
+ * @param string $comment
+ * @param array $fileVersions
+ * @param bool $unsuppress
+ * @param User $user User performing the action, or null to use $wgUser
+ * @param string|string[] $tags Change tags to add to log entry
+ * ($user should be able to add the specified tags before this is called)
+ * @return array|bool array(number of file revisions restored, number of image revisions
+ * restored, log message) on success, false on failure.
+ */
+ public function undelete( $timestamps, $comment = '', $fileVersions = [],
+ $unsuppress = false, User $user = null, $tags = null
+ ) {
+ // If both the set of text revisions and file revisions are empty,
+ // restore everything. Otherwise, just restore the requested items.
+ $restoreAll = empty( $timestamps ) && empty( $fileVersions );
+
+ $restoreText = $restoreAll || !empty( $timestamps );
+ $restoreFiles = $restoreAll || !empty( $fileVersions );
+
+ if ( $restoreFiles && $this->title->getNamespace() == NS_FILE ) {
+ $img = wfLocalFile( $this->title );
+ $img->load( File::READ_LATEST );
+ $this->fileStatus = $img->restore( $fileVersions, $unsuppress );
+ if ( !$this->fileStatus->isOK() ) {
+ return false;
+ }
+ $filesRestored = $this->fileStatus->successCount;
+ } else {
+ $filesRestored = 0;
+ }
+
+ if ( $restoreText ) {
+ $this->revisionStatus = $this->undeleteRevisions( $timestamps, $unsuppress, $comment );
+ if ( !$this->revisionStatus->isOK() ) {
+ return false;
+ }
+
+ $textRestored = $this->revisionStatus->getValue();
+ } else {
+ $textRestored = 0;
+ }
+
+ // Touch the log!
+
+ if ( !$textRestored && !$filesRestored ) {
+ wfDebug( "Undelete: nothing undeleted...\n" );
+
+ return false;
+ }
+
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ $logEntry = new ManualLogEntry( 'delete', 'restore' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( $this->title );
+ $logEntry->setComment( $comment );
+ $logEntry->setTags( $tags );
+ $logEntry->setParameters( [
+ ':assoc:count' => [
+ 'revisions' => $textRestored,
+ 'files' => $filesRestored,
+ ],
+ ] );
+
+ Hooks::run( 'ArticleUndeleteLogEntry', [ $this, &$logEntry, $user ] );
+
+ $logid = $logEntry->insert();
+ $logEntry->publish( $logid );
+
+ return [ $textRestored, $filesRestored, $comment ];
+ }
+
+ /**
+ * This is the meaty bit -- It restores archived revisions of the given page
+ * to the revision table.
+ *
+ * @param array $timestamps Pass an empty array to restore all revisions,
+ * otherwise list the ones to undelete.
+ * @param bool $unsuppress Remove all ar_deleted/fa_deleted restrictions of seletected revs
+ * @param string $comment
+ * @throws ReadOnlyError
+ * @return Status Status object containing the number of revisions restored on success
+ */
+ private function undeleteRevisions( $timestamps, $unsuppress = false, $comment = '' ) {
+ if ( wfReadOnly() ) {
+ throw new ReadOnlyError();
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ $restoreAll = empty( $timestamps );
+
+ # Does this page already exist? We'll have to update it...
+ $article = WikiPage::factory( $this->title );
+ # Load latest data for the current page (T33179)
+ $article->loadPageData( 'fromdbmaster' );
+ $oldcountable = $article->isCountable();
+
+ $page = $dbw->selectRow( 'page',
+ [ 'page_id', 'page_latest' ],
+ [ 'page_namespace' => $this->title->getNamespace(),
+ 'page_title' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'FOR UPDATE' ] // lock page
+ );
+
+ if ( $page ) {
+ $makepage = false;
+ # Page already exists. Import the history, and if necessary
+ # we'll update the latest revision field in the record.
+
+ # Get the time span of this page
+ $previousTimestamp = $dbw->selectField( 'revision', 'rev_timestamp',
+ [ 'rev_id' => $page->page_latest ],
+ __METHOD__ );
+
+ if ( $previousTimestamp === false ) {
+ wfDebug( __METHOD__ . ": existing page refers to a page_latest that does not exist\n" );
+
+ $status = Status::newGood( 0 );
+ $status->warning( 'undeleterevision-missing' );
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+ } else {
+ # Have to create a new article...
+ $makepage = true;
+ $previousTimestamp = 0;
+ }
+
+ $oldWhere = [
+ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ ];
+ if ( !$restoreAll ) {
+ $oldWhere['ar_timestamp'] = array_map( [ &$dbw, 'timestamp' ], $timestamps );
+ }
+
+ $commentQuery = CommentStore::newKey( 'ar_comment' )->getJoin();
+
+ $tables = [ 'archive', 'revision' ] + $commentQuery['tables'];
+
+ $fields = [
+ 'ar_id',
+ 'ar_rev_id',
+ 'rev_id',
+ 'ar_text',
+ 'ar_user',
+ 'ar_user_text',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_flags',
+ 'ar_text_id',
+ 'ar_deleted',
+ 'ar_page_id',
+ 'ar_len',
+ 'ar_sha1'
+ ] + $commentQuery['fields'];
+
+ if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+ $fields[] = 'ar_content_format';
+ $fields[] = 'ar_content_model';
+ }
+
+ $join_conds = [
+ 'revision' => [ 'LEFT JOIN', 'ar_rev_id=rev_id' ],
+ ] + $commentQuery['joins'];
+
+ /**
+ * Select each archived revision...
+ */
+ $result = $dbw->select(
+ $tables,
+ $fields,
+ $oldWhere,
+ __METHOD__,
+ /* options */
+ [ 'ORDER BY' => 'ar_timestamp' ],
+ $join_conds
+ );
+
+ $rev_count = $result->numRows();
+ if ( !$rev_count ) {
+ wfDebug( __METHOD__ . ": no revisions to restore\n" );
+
+ $status = Status::newGood( 0 );
+ $status->warning( "undelete-no-results" );
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+
+ // We use ar_id because there can be duplicate ar_rev_id even for the same
+ // page. In this case, we may be able to restore the first one.
+ $restoreFailedArIds = [];
+
+ // Map rev_id to the ar_id that is allowed to use it. When checking later,
+ // if it doesn't match, the current ar_id can not be restored.
+
+ // Value can be an ar_id or -1 (-1 means no ar_id can use it, since the
+ // rev_id is taken before we even start the restore).
+ $allowedRevIdToArIdMap = [];
+
+ $latestRestorableRow = null;
+
+ foreach ( $result as $row ) {
+ if ( $row->ar_rev_id ) {
+ // rev_id is taken even before we start restoring.
+ if ( $row->ar_rev_id === $row->rev_id ) {
+ $restoreFailedArIds[] = $row->ar_id;
+ $allowedRevIdToArIdMap[$row->ar_rev_id] = -1;
+ } else {
+ // rev_id is not taken yet in the DB, but it might be taken
+ // by a prior revision in the same restore operation. If
+ // not, we need to reserve it.
+ if ( isset( $allowedRevIdToArIdMap[$row->ar_rev_id] ) ) {
+ $restoreFailedArIds[] = $row->ar_id;
+ } else {
+ $allowedRevIdToArIdMap[$row->ar_rev_id] = $row->ar_id;
+ $latestRestorableRow = $row;
+ }
+ }
+ } else {
+ // If ar_rev_id is null, there can't be a collision, and a
+ // rev_id will be chosen automatically.
+ $latestRestorableRow = $row;
+ }
+ }
+
+ $result->seek( 0 ); // move back
+
+ $oldPageId = 0;
+ if ( $latestRestorableRow !== null ) {
+ $oldPageId = (int)$latestRestorableRow->ar_page_id; // pass this to ArticleUndelete hook
+
+ // grab the content to check consistency with global state before restoring the page.
+ $revision = Revision::newFromArchiveRow( $latestRestorableRow,
+ [
+ 'title' => $article->getTitle(), // used to derive default content model
+ ]
+ );
+ $user = User::newFromName( $revision->getUserText( Revision::RAW ), false );
+ $content = $revision->getContent( Revision::RAW );
+
+ // NOTE: article ID may not be known yet. prepareSave() should not modify the database.
+ $status = $content->prepareSave( $article, 0, -1, $user );
+ if ( !$status->isOK() ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+ }
+
+ $newid = false; // newly created page ID
+ $restored = 0; // number of revisions restored
+ /** @var Revision $revision */
+ $revision = null;
+ $restoredPages = [];
+ // If there are no restorable revisions, we can skip most of the steps.
+ if ( $latestRestorableRow === null ) {
+ $failedRevisionCount = $rev_count;
+ } else {
+ if ( $makepage ) {
+ // Check the state of the newest to-be version...
+ if ( !$unsuppress
+ && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
+ ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return Status::newFatal( "undeleterevdel" );
+ }
+ // Safe to insert now...
+ $newid = $article->insertOn( $dbw, $latestRestorableRow->ar_page_id );
+ if ( $newid === false ) {
+ // The old ID is reserved; let's pick another
+ $newid = $article->insertOn( $dbw );
+ }
+ $pageId = $newid;
+ } else {
+ // Check if a deleted revision will become the current revision...
+ if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
+ // Check the state of the newest to-be version...
+ if ( !$unsuppress
+ && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
+ ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return Status::newFatal( "undeleterevdel" );
+ }
+ }
+
+ $newid = false;
+ $pageId = $article->getId();
+ }
+
+ foreach ( $result as $row ) {
+ // Check for key dupes due to needed archive integrity.
+ if ( $row->ar_rev_id && $allowedRevIdToArIdMap[$row->ar_rev_id] !== $row->ar_id ) {
+ continue;
+ }
+ // Insert one revision at a time...maintaining deletion status
+ // unless we are specifically removing all restrictions...
+ $revision = Revision::newFromArchiveRow( $row,
+ [
+ 'page' => $pageId,
+ 'title' => $this->title,
+ 'deleted' => $unsuppress ? 0 : $row->ar_deleted
+ ] );
+
+ // This will also copy the revision to ip_changes if it was an IP edit.
+ $revision->insertOn( $dbw );
+
+ $restored++;
+
+ Hooks::run( 'ArticleRevisionUndeleted',
+ [ &$this->title, $revision, $row->ar_page_id ] );
+ $restoredPages[$row->ar_page_id] = true;
+ }
+
+ // Now that it's safely stored, take it out of the archive
+ // Don't delete rows that we failed to restore
+ $toDeleteConds = $oldWhere;
+ $failedRevisionCount = count( $restoreFailedArIds );
+ if ( $failedRevisionCount > 0 ) {
+ $toDeleteConds[] = 'ar_id NOT IN ( ' . $dbw->makeList( $restoreFailedArIds ) . ' )';
+ }
+
+ $dbw->delete( 'archive',
+ $toDeleteConds,
+ __METHOD__ );
+ }
+
+ $status = Status::newGood( $restored );
+
+ if ( $failedRevisionCount > 0 ) {
+ $status->warning(
+ wfMessage( 'undeleterevision-duplicate-revid', $failedRevisionCount ) );
+ }
+
+ // Was anything restored at all?
+ if ( $restored ) {
+ $created = (bool)$newid;
+ // Attach the latest revision to the page...
+ $wasnew = $article->updateIfNewerOn( $dbw, $revision );
+ if ( $created || $wasnew ) {
+ // Update site stats, link tables, etc
+ $article->doEditUpdates(
+ $revision,
+ User::newFromName( $revision->getUserText( Revision::RAW ), false ),
+ [
+ 'created' => $created,
+ 'oldcountable' => $oldcountable,
+ 'restored' => true
+ ]
+ );
+ }
+
+ Hooks::run( 'ArticleUndelete',
+ [ &$this->title, $created, $comment, $oldPageId, $restoredPages ] );
+ if ( $this->title->getNamespace() == NS_FILE ) {
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this->title, 'imagelinks' ) );
+ }
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+
+ /**
+ * @return Status
+ */
+ public function getFileStatus() {
+ return $this->fileStatus;
+ }
+
+ /**
+ * @return Status
+ */
+ public function getRevisionStatus() {
+ return $this->revisionStatus;
+ }
+}
diff --git a/www/wiki/includes/page/WikiCategoryPage.php b/www/wiki/includes/page/WikiCategoryPage.php
new file mode 100644
index 00000000..6c932029
--- /dev/null
+++ b/www/wiki/includes/page/WikiCategoryPage.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Special handling for category pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Special handling for category pages
+ */
+class WikiCategoryPage extends WikiPage {
+
+ /**
+ * Don't return a 404 for categories in use.
+ * In use defined as: either the actual page exists
+ * or the category currently has members.
+ *
+ * @return bool
+ */
+ public function hasViewableContent() {
+ if ( parent::hasViewableContent() ) {
+ return true;
+ } else {
+ $cat = Category::newFromTitle( $this->mTitle );
+ // If any of these are not 0, then has members
+ if ( $cat->getPageCount()
+ || $cat->getSubcatCount()
+ || $cat->getFileCount()
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks if a category is hidden.
+ *
+ * @since 1.27
+ *
+ * @return bool
+ */
+ public function isHidden() {
+ $pageId = $this->getTitle()->getArticleID();
+ $pageProps = PageProps::getInstance()->getProperties( $this->getTitle(), 'hiddencat' );
+
+ return isset( $pageProps[$pageId] ) ? true : false;
+ }
+}
diff --git a/www/wiki/includes/page/WikiFilePage.php b/www/wiki/includes/page/WikiFilePage.php
new file mode 100644
index 00000000..972a397c
--- /dev/null
+++ b/www/wiki/includes/page/WikiFilePage.php
@@ -0,0 +1,257 @@
+<?php
+/**
+ * Special handling for file pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+/**
+ * Special handling for file pages
+ *
+ * @ingroup Media
+ */
+class WikiFilePage extends WikiPage {
+ /** @var File */
+ protected $mFile = false;
+ /** @var LocalRepo */
+ protected $mRepo = null;
+ /** @var bool */
+ protected $mFileLoaded = false;
+ /** @var array */
+ protected $mDupes = null;
+
+ public function __construct( $title ) {
+ parent::__construct( $title );
+ $this->mDupes = null;
+ $this->mRepo = null;
+ }
+
+ /**
+ * @param File $file
+ */
+ public function setFile( $file ) {
+ $this->mFile = $file;
+ $this->mFileLoaded = true;
+ }
+
+ /**
+ * @return bool
+ */
+ protected function loadFile() {
+ if ( $this->mFileLoaded ) {
+ return true;
+ }
+ $this->mFileLoaded = true;
+
+ $this->mFile = wfFindFile( $this->mTitle );
+ if ( !$this->mFile ) {
+ $this->mFile = wfLocalFile( $this->mTitle ); // always a File
+ }
+ $this->mRepo = $this->mFile->getRepo();
+ return true;
+ }
+
+ /**
+ * @return mixed|null|Title
+ */
+ public function getRedirectTarget() {
+ $this->loadFile();
+ if ( $this->mFile->isLocal() ) {
+ return parent::getRedirectTarget();
+ }
+ // Foreign image page
+ $from = $this->mFile->getRedirected();
+ $to = $this->mFile->getName();
+ if ( $from == $to ) {
+ return null;
+ }
+ $this->mRedirectTarget = Title::makeTitle( NS_FILE, $to );
+ return $this->mRedirectTarget;
+ }
+
+ /**
+ * @return bool|mixed|Title
+ */
+ public function followRedirect() {
+ $this->loadFile();
+ if ( $this->mFile->isLocal() ) {
+ return parent::followRedirect();
+ }
+ $from = $this->mFile->getRedirected();
+ $to = $this->mFile->getName();
+ if ( $from == $to ) {
+ return false;
+ }
+ return Title::makeTitle( NS_FILE, $to );
+ }
+
+ /**
+ * @return bool
+ */
+ public function isRedirect() {
+ $this->loadFile();
+ if ( $this->mFile->isLocal() ) {
+ return parent::isRedirect();
+ }
+
+ return (bool)$this->mFile->getRedirected();
+ }
+
+ /**
+ * @return bool
+ */
+ public function isLocal() {
+ $this->loadFile();
+ return $this->mFile->isLocal();
+ }
+
+ /**
+ * @return bool|File
+ */
+ public function getFile() {
+ $this->loadFile();
+ return $this->mFile;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getDuplicates() {
+ $this->loadFile();
+ if ( !is_null( $this->mDupes ) ) {
+ return $this->mDupes;
+ }
+ $hash = $this->mFile->getSha1();
+ if ( !( $hash ) ) {
+ $this->mDupes = [];
+ return $this->mDupes;
+ }
+ $dupes = RepoGroup::singleton()->findBySha1( $hash );
+ // Remove duplicates with self and non matching file sizes
+ $self = $this->mFile->getRepoName() . ':' . $this->mFile->getName();
+ $size = $this->mFile->getSize();
+
+ /**
+ * @var $file File
+ */
+ foreach ( $dupes as $index => $file ) {
+ $key = $file->getRepoName() . ':' . $file->getName();
+ if ( $key == $self ) {
+ unset( $dupes[$index] );
+ }
+ if ( $file->getSize() != $size ) {
+ unset( $dupes[$index] );
+ }
+ }
+ $this->mDupes = $dupes;
+ return $this->mDupes;
+ }
+
+ /**
+ * Override handling of action=purge
+ * @return bool
+ */
+ public function doPurge() {
+ $this->loadFile();
+
+ if ( $this->mFile->exists() ) {
+ wfDebug( 'ImagePage::doPurge purging ' . $this->mFile->getName() . "\n" );
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this->mTitle, 'imagelinks' ) );
+ } else {
+ wfDebug( 'ImagePage::doPurge no image for '
+ . $this->mFile->getName() . "; limiting purge to cache only\n" );
+ }
+
+ // even if the file supposedly doesn't exist, force any cached information
+ // to be updated (in case the cached information is wrong)
+
+ // Purge current version and its thumbnails
+ $this->mFile->purgeCache( [ 'forThumbRefresh' => true ] );
+
+ // Purge the old versions and their thumbnails
+ foreach ( $this->mFile->getHistory() as $oldFile ) {
+ $oldFile->purgeCache( [ 'forThumbRefresh' => true ] );
+ }
+
+ if ( $this->mRepo ) {
+ // Purge redirect cache
+ $this->mRepo->invalidateImageRedirect( $this->mTitle );
+ }
+
+ return parent::doPurge();
+ }
+
+ /**
+ * Get the categories this file is a member of on the wiki where it was uploaded.
+ * For local files, this is the same as getCategories().
+ * For foreign API files (InstantCommons), this is not supported currently.
+ * Results will include hidden categories.
+ *
+ * @return TitleArray|Title[]
+ * @since 1.23
+ */
+ public function getForeignCategories() {
+ $this->loadFile();
+ $title = $this->mTitle;
+ $file = $this->mFile;
+
+ if ( !$file instanceof LocalFile ) {
+ wfDebug( __CLASS__ . '::' . __METHOD__ . " is not supported for this file\n" );
+ return TitleArray::newFromResult( new FakeResultWrapper( [] ) );
+ }
+
+ /** @var LocalRepo $repo */
+ $repo = $file->getRepo();
+ $dbr = $repo->getReplicaDB();
+
+ $res = $dbr->select(
+ [ 'page', 'categorylinks' ],
+ [
+ 'page_title' => 'cl_to',
+ 'page_namespace' => NS_CATEGORY,
+ ],
+ [
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey(),
+ ],
+ __METHOD__,
+ [],
+ [ 'categorylinks' => [ 'INNER JOIN', 'page_id = cl_from' ] ]
+ );
+
+ return TitleArray::newFromResult( $res );
+ }
+
+ /**
+ * @since 1.28
+ * @return string
+ */
+ public function getWikiDisplayName() {
+ return $this->getFile()->getRepo()->getDisplayName();
+ }
+
+ /**
+ * @since 1.28
+ * @return string
+ */
+ public function getSourceURL() {
+ return $this->getFile()->getDescriptionUrl();
+ }
+}
diff --git a/www/wiki/includes/page/WikiPage.php b/www/wiki/includes/page/WikiPage.php
new file mode 100644
index 00000000..5eb41b5e
--- /dev/null
+++ b/www/wiki/includes/page/WikiPage.php
@@ -0,0 +1,3667 @@
+<?php
+/**
+ * Base representation for a MediaWiki page.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Edit\PreparedEdit;
+use \MediaWiki\Logger\LoggerFactory;
+use \MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBError;
+use Wikimedia\Rdbms\DBUnexpectedError;
+
+/**
+ * Class representing a MediaWiki article and history.
+ *
+ * Some fields are public only for backwards-compatibility. Use accessors.
+ * In the past, this class was part of Article.php and everything was public.
+ */
+class WikiPage implements Page, IDBAccessObject {
+ // Constants for $mDataLoadedFrom and related
+
+ /**
+ * @var Title
+ */
+ public $mTitle = null;
+
+ /**@{{
+ * @protected
+ */
+ public $mDataLoaded = false; // !< Boolean
+ public $mIsRedirect = false; // !< Boolean
+ public $mLatest = false; // !< Integer (false means "not loaded")
+ /**@}}*/
+
+ /** @var PreparedEdit Map of cache fields (text, parser output, ect) for a proposed/new edit */
+ public $mPreparedEdit = false;
+
+ /**
+ * @var int
+ */
+ protected $mId = null;
+
+ /**
+ * @var int One of the READ_* constants
+ */
+ protected $mDataLoadedFrom = self::READ_NONE;
+
+ /**
+ * @var Title
+ */
+ protected $mRedirectTarget = null;
+
+ /**
+ * @var Revision
+ */
+ protected $mLastRevision = null;
+
+ /**
+ * @var string Timestamp of the current revision or empty string if not loaded
+ */
+ protected $mTimestamp = '';
+
+ /**
+ * @var string
+ */
+ protected $mTouched = '19700101000000';
+
+ /**
+ * @var string
+ */
+ protected $mLinksUpdated = '19700101000000';
+
+ /** @deprecated since 1.29. Added in 1.28 for partial purging, no longer used. */
+ const PURGE_CDN_CACHE = 1;
+ const PURGE_CLUSTER_PCACHE = 2;
+ const PURGE_GLOBAL_PCACHE = 4;
+ const PURGE_ALL = 7;
+
+ /**
+ * Constructor and clear the article
+ * @param Title $title Reference to a Title object.
+ */
+ public function __construct( Title $title ) {
+ $this->mTitle = $title;
+ }
+
+ /**
+ * Makes sure that the mTitle object is cloned
+ * to the newly cloned WikiPage.
+ */
+ public function __clone() {
+ $this->mTitle = clone $this->mTitle;
+ }
+
+ /**
+ * Create a WikiPage object of the appropriate class for the given title.
+ *
+ * @param Title $title
+ *
+ * @throws MWException
+ * @return WikiPage|WikiCategoryPage|WikiFilePage
+ */
+ public static function factory( Title $title ) {
+ $ns = $title->getNamespace();
+
+ if ( $ns == NS_MEDIA ) {
+ throw new MWException( "NS_MEDIA is a virtual namespace; use NS_FILE." );
+ } elseif ( $ns < 0 ) {
+ throw new MWException( "Invalid or virtual namespace $ns given." );
+ }
+
+ $page = null;
+ if ( !Hooks::run( 'WikiPageFactory', [ $title, &$page ] ) ) {
+ return $page;
+ }
+
+ switch ( $ns ) {
+ case NS_FILE:
+ $page = new WikiFilePage( $title );
+ break;
+ case NS_CATEGORY:
+ $page = new WikiCategoryPage( $title );
+ break;
+ default:
+ $page = new WikiPage( $title );
+ }
+
+ return $page;
+ }
+
+ /**
+ * Constructor from a page id
+ *
+ * @param int $id Article ID to load
+ * @param string|int $from One of the following values:
+ * - "fromdb" or WikiPage::READ_NORMAL to select from a replica DB
+ * - "fromdbmaster" or WikiPage::READ_LATEST to select from the master database
+ *
+ * @return WikiPage|null
+ */
+ public static function newFromID( $id, $from = 'fromdb' ) {
+ // page ids are never 0 or negative, see T63166
+ if ( $id < 1 ) {
+ return null;
+ }
+
+ $from = self::convertSelectType( $from );
+ $db = wfGetDB( $from === self::READ_LATEST ? DB_MASTER : DB_REPLICA );
+ $row = $db->selectRow(
+ 'page', self::selectFields(), [ 'page_id' => $id ], __METHOD__ );
+ if ( !$row ) {
+ return null;
+ }
+ return self::newFromRow( $row, $from );
+ }
+
+ /**
+ * Constructor from a database row
+ *
+ * @since 1.20
+ * @param object $row Database row containing at least fields returned by selectFields().
+ * @param string|int $from Source of $data:
+ * - "fromdb" or WikiPage::READ_NORMAL: from a replica DB
+ * - "fromdbmaster" or WikiPage::READ_LATEST: from the master DB
+ * - "forupdate" or WikiPage::READ_LOCKING: from the master DB using SELECT FOR UPDATE
+ * @return WikiPage
+ */
+ public static function newFromRow( $row, $from = 'fromdb' ) {
+ $page = self::factory( Title::newFromRow( $row ) );
+ $page->loadFromRow( $row, $from );
+ return $page;
+ }
+
+ /**
+ * Convert 'fromdb', 'fromdbmaster' and 'forupdate' to READ_* constants.
+ *
+ * @param object|string|int $type
+ * @return mixed
+ */
+ private static function convertSelectType( $type ) {
+ switch ( $type ) {
+ case 'fromdb':
+ return self::READ_NORMAL;
+ case 'fromdbmaster':
+ return self::READ_LATEST;
+ case 'forupdate':
+ return self::READ_LOCKING;
+ default:
+ // It may already be an integer or whatever else
+ return $type;
+ }
+ }
+
+ /**
+ * @todo Move this UI stuff somewhere else
+ *
+ * @see ContentHandler::getActionOverrides
+ * @return array
+ */
+ public function getActionOverrides() {
+ return $this->getContentHandler()->getActionOverrides();
+ }
+
+ /**
+ * Returns the ContentHandler instance to be used to deal with the content of this WikiPage.
+ *
+ * Shorthand for ContentHandler::getForModelID( $this->getContentModel() );
+ *
+ * @return ContentHandler
+ *
+ * @since 1.21
+ */
+ public function getContentHandler() {
+ return ContentHandler::getForModelID( $this->getContentModel() );
+ }
+
+ /**
+ * Get the title object of the article
+ * @return Title Title object of this page
+ */
+ public function getTitle() {
+ return $this->mTitle;
+ }
+
+ /**
+ * Clear the object
+ * @return void
+ */
+ public function clear() {
+ $this->mDataLoaded = false;
+ $this->mDataLoadedFrom = self::READ_NONE;
+
+ $this->clearCacheFields();
+ }
+
+ /**
+ * Clear the object cache fields
+ * @return void
+ */
+ protected function clearCacheFields() {
+ $this->mId = null;
+ $this->mRedirectTarget = null; // Title object if set
+ $this->mLastRevision = null; // Latest revision
+ $this->mTouched = '19700101000000';
+ $this->mLinksUpdated = '19700101000000';
+ $this->mTimestamp = '';
+ $this->mIsRedirect = false;
+ $this->mLatest = false;
+ // T59026: do not clear mPreparedEdit since prepareTextForEdit() already checks
+ // the requested rev ID and content against the cached one for equality. For most
+ // content types, the output should not change during the lifetime of this cache.
+ // Clearing it can cause extra parses on edit for no reason.
+ }
+
+ /**
+ * Clear the mPreparedEdit cache field, as may be needed by mutable content types
+ * @return void
+ * @since 1.23
+ */
+ public function clearPreparedEdit() {
+ $this->mPreparedEdit = false;
+ }
+
+ /**
+ * Return the list of revision fields that should be selected to create
+ * a new page.
+ *
+ * @return array
+ */
+ public static function selectFields() {
+ global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
+
+ $fields = [
+ 'page_id',
+ 'page_namespace',
+ 'page_title',
+ 'page_restrictions',
+ 'page_is_redirect',
+ 'page_is_new',
+ 'page_random',
+ 'page_touched',
+ 'page_links_updated',
+ 'page_latest',
+ 'page_len',
+ ];
+
+ if ( $wgContentHandlerUseDB ) {
+ $fields[] = 'page_content_model';
+ }
+
+ if ( $wgPageLanguageUseDB ) {
+ $fields[] = 'page_lang';
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Fetch a page record with the given conditions
+ * @param IDatabase $dbr
+ * @param array $conditions
+ * @param array $options
+ * @return object|bool Database result resource, or false on failure
+ */
+ protected function pageData( $dbr, $conditions, $options = [] ) {
+ $fields = self::selectFields();
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ Hooks::run( 'ArticlePageDataBefore', [ &$wikiPage, &$fields ] );
+
+ $row = $dbr->selectRow( 'page', $fields, $conditions, __METHOD__, $options );
+
+ Hooks::run( 'ArticlePageDataAfter', [ &$wikiPage, &$row ] );
+
+ return $row;
+ }
+
+ /**
+ * Fetch a page record matching the Title object's namespace and title
+ * using a sanitized title string
+ *
+ * @param IDatabase $dbr
+ * @param Title $title
+ * @param array $options
+ * @return object|bool Database result resource, or false on failure
+ */
+ public function pageDataFromTitle( $dbr, $title, $options = [] ) {
+ return $this->pageData( $dbr, [
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey() ], $options );
+ }
+
+ /**
+ * Fetch a page record matching the requested ID
+ *
+ * @param IDatabase $dbr
+ * @param int $id
+ * @param array $options
+ * @return object|bool Database result resource, or false on failure
+ */
+ public function pageDataFromId( $dbr, $id, $options = [] ) {
+ return $this->pageData( $dbr, [ 'page_id' => $id ], $options );
+ }
+
+ /**
+ * Load the object from a given source by title
+ *
+ * @param object|string|int $from One of the following:
+ * - A DB query result object.
+ * - "fromdb" or WikiPage::READ_NORMAL to get from a replica DB.
+ * - "fromdbmaster" or WikiPage::READ_LATEST to get from the master DB.
+ * - "forupdate" or WikiPage::READ_LOCKING to get from the master DB
+ * using SELECT FOR UPDATE.
+ *
+ * @return void
+ */
+ public function loadPageData( $from = 'fromdb' ) {
+ $from = self::convertSelectType( $from );
+ if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) {
+ // We already have the data from the correct location, no need to load it twice.
+ return;
+ }
+
+ if ( is_int( $from ) ) {
+ list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
+ $data = $this->pageDataFromTitle( wfGetDB( $index ), $this->mTitle, $opts );
+ $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer();
+
+ if ( !$data
+ && $index == DB_REPLICA
+ && $loadBalancer->getServerCount() > 1
+ && $loadBalancer->hasOrMadeRecentMasterChanges()
+ ) {
+ $from = self::READ_LATEST;
+ list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
+ $data = $this->pageDataFromTitle( wfGetDB( $index ), $this->mTitle, $opts );
+ }
+ } else {
+ // No idea from where the caller got this data, assume replica DB.
+ $data = $from;
+ $from = self::READ_NORMAL;
+ }
+
+ $this->loadFromRow( $data, $from );
+ }
+
+ /**
+ * Load the object from a database row
+ *
+ * @since 1.20
+ * @param object|bool $data DB row containing fields returned by selectFields() or false
+ * @param string|int $from One of the following:
+ * - "fromdb" or WikiPage::READ_NORMAL if the data comes from a replica DB
+ * - "fromdbmaster" or WikiPage::READ_LATEST if the data comes from the master DB
+ * - "forupdate" or WikiPage::READ_LOCKING if the data comes from
+ * the master DB using SELECT FOR UPDATE
+ */
+ public function loadFromRow( $data, $from ) {
+ $lc = LinkCache::singleton();
+ $lc->clearLink( $this->mTitle );
+
+ if ( $data ) {
+ $lc->addGoodLinkObjFromRow( $this->mTitle, $data );
+
+ $this->mTitle->loadFromRow( $data );
+
+ // Old-fashioned restrictions
+ $this->mTitle->loadRestrictions( $data->page_restrictions );
+
+ $this->mId = intval( $data->page_id );
+ $this->mTouched = wfTimestamp( TS_MW, $data->page_touched );
+ $this->mLinksUpdated = wfTimestampOrNull( TS_MW, $data->page_links_updated );
+ $this->mIsRedirect = intval( $data->page_is_redirect );
+ $this->mLatest = intval( $data->page_latest );
+ // T39225: $latest may no longer match the cached latest Revision object.
+ // Double-check the ID of any cached latest Revision object for consistency.
+ if ( $this->mLastRevision && $this->mLastRevision->getId() != $this->mLatest ) {
+ $this->mLastRevision = null;
+ $this->mTimestamp = '';
+ }
+ } else {
+ $lc->addBadLinkObj( $this->mTitle );
+
+ $this->mTitle->loadFromRow( false );
+
+ $this->clearCacheFields();
+
+ $this->mId = 0;
+ }
+
+ $this->mDataLoaded = true;
+ $this->mDataLoadedFrom = self::convertSelectType( $from );
+ }
+
+ /**
+ * @return int Page ID
+ */
+ public function getId() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+ return $this->mId;
+ }
+
+ /**
+ * @return bool Whether or not the page exists in the database
+ */
+ public function exists() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+ return $this->mId > 0;
+ }
+
+ /**
+ * Check if this page is something we're going to be showing
+ * some sort of sensible content for. If we return false, page
+ * views (plain action=view) will return an HTTP 404 response,
+ * so spiders and robots can know they're following a bad link.
+ *
+ * @return bool
+ */
+ public function hasViewableContent() {
+ return $this->mTitle->isKnown();
+ }
+
+ /**
+ * Tests if the article content represents a redirect
+ *
+ * @return bool
+ */
+ public function isRedirect() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+
+ return (bool)$this->mIsRedirect;
+ }
+
+ /**
+ * Returns the page's content model id (see the CONTENT_MODEL_XXX constants).
+ *
+ * Will use the revisions actual content model if the page exists,
+ * and the page's default if the page doesn't exist yet.
+ *
+ * @return string
+ *
+ * @since 1.21
+ */
+ public function getContentModel() {
+ if ( $this->exists() ) {
+ $cache = ObjectCache::getMainWANInstance();
+
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'page', 'content-model', $this->getLatest() ),
+ $cache::TTL_MONTH,
+ function () {
+ $rev = $this->getRevision();
+ if ( $rev ) {
+ // Look at the revision's actual content model
+ return $rev->getContentModel();
+ } else {
+ $title = $this->mTitle->getPrefixedDBkey();
+ wfWarn( "Page $title exists but has no (visible) revisions!" );
+ return $this->mTitle->getContentModel();
+ }
+ }
+ );
+ }
+
+ // use the default model for this page
+ return $this->mTitle->getContentModel();
+ }
+
+ /**
+ * Loads page_touched and returns a value indicating if it should be used
+ * @return bool True if this page exists and is not a redirect
+ */
+ public function checkTouched() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+ return ( $this->mId && !$this->mIsRedirect );
+ }
+
+ /**
+ * Get the page_touched field
+ * @return string Containing GMT timestamp
+ */
+ public function getTouched() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+ return $this->mTouched;
+ }
+
+ /**
+ * Get the page_links_updated field
+ * @return string|null Containing GMT timestamp
+ */
+ public function getLinksTimestamp() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+ return $this->mLinksUpdated;
+ }
+
+ /**
+ * Get the page_latest field
+ * @return int The rev_id of current revision
+ */
+ public function getLatest() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+ return (int)$this->mLatest;
+ }
+
+ /**
+ * Get the Revision object of the oldest revision
+ * @return Revision|null
+ */
+ public function getOldestRevision() {
+ // Try using the replica DB first, then try the master
+ $rev = $this->mTitle->getFirstRevision();
+ if ( !$rev ) {
+ $rev = $this->mTitle->getFirstRevision( Title::GAID_FOR_UPDATE );
+ }
+ return $rev;
+ }
+
+ /**
+ * Loads everything except the text
+ * This isn't necessary for all uses, so it's only done if needed.
+ */
+ protected function loadLastEdit() {
+ if ( $this->mLastRevision !== null ) {
+ return; // already loaded
+ }
+
+ $latest = $this->getLatest();
+ if ( !$latest ) {
+ return; // page doesn't exist or is missing page_latest info
+ }
+
+ if ( $this->mDataLoadedFrom == self::READ_LOCKING ) {
+ // T39225: if session S1 loads the page row FOR UPDATE, the result always
+ // includes the latest changes committed. This is true even within REPEATABLE-READ
+ // transactions, where S1 normally only sees changes committed before the first S1
+ // SELECT. Thus we need S1 to also gets the revision row FOR UPDATE; otherwise, it
+ // may not find it since a page row UPDATE and revision row INSERT by S2 may have
+ // happened after the first S1 SELECT.
+ // https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html#isolevel_repeatable-read
+ $flags = Revision::READ_LOCKING;
+ $revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
+ } elseif ( $this->mDataLoadedFrom == self::READ_LATEST ) {
+ // Bug T93976: if page_latest was loaded from the master, fetch the
+ // revision from there as well, as it may not exist yet on a replica DB.
+ // Also, this keeps the queries in the same REPEATABLE-READ snapshot.
+ $flags = Revision::READ_LATEST;
+ $revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
+ } else {
+ $dbr = wfGetDB( DB_REPLICA );
+ $revision = Revision::newKnownCurrent( $dbr, $this->getId(), $latest );
+ }
+
+ if ( $revision ) { // sanity
+ $this->setLastEdit( $revision );
+ }
+ }
+
+ /**
+ * Set the latest revision
+ * @param Revision $revision
+ */
+ protected function setLastEdit( Revision $revision ) {
+ $this->mLastRevision = $revision;
+ $this->mTimestamp = $revision->getTimestamp();
+ }
+
+ /**
+ * Get the latest revision
+ * @return Revision|null
+ */
+ public function getRevision() {
+ $this->loadLastEdit();
+ if ( $this->mLastRevision ) {
+ return $this->mLastRevision;
+ }
+ return null;
+ }
+
+ /**
+ * Get the content of the current revision. No side-effects...
+ *
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to $wgUser
+ * Revision::RAW get the text regardless of permissions
+ * @param User $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return Content|null The content of the current revision
+ *
+ * @since 1.21
+ */
+ public function getContent( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ $this->loadLastEdit();
+ if ( $this->mLastRevision ) {
+ return $this->mLastRevision->getContent( $audience, $user );
+ }
+ return null;
+ }
+
+ /**
+ * @return string MW timestamp of last article revision
+ */
+ public function getTimestamp() {
+ // Check if the field has been filled by WikiPage::setTimestamp()
+ if ( !$this->mTimestamp ) {
+ $this->loadLastEdit();
+ }
+
+ return wfTimestamp( TS_MW, $this->mTimestamp );
+ }
+
+ /**
+ * Set the page timestamp (use only to avoid DB queries)
+ * @param string $ts MW timestamp of last article revision
+ * @return void
+ */
+ public function setTimestamp( $ts ) {
+ $this->mTimestamp = wfTimestamp( TS_MW, $ts );
+ }
+
+ /**
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to the given user
+ * Revision::RAW get the text regardless of permissions
+ * @param User $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return int User ID for the user that made the last article revision
+ */
+ public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ $this->loadLastEdit();
+ if ( $this->mLastRevision ) {
+ return $this->mLastRevision->getUser( $audience, $user );
+ } else {
+ return -1;
+ }
+ }
+
+ /**
+ * Get the User object of the user who created the page
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to the given user
+ * Revision::RAW get the text regardless of permissions
+ * @param User $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return User|null
+ */
+ public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ $revision = $this->getOldestRevision();
+ if ( $revision ) {
+ $userName = $revision->getUserText( $audience, $user );
+ return User::newFromName( $userName, false );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to the given user
+ * Revision::RAW get the text regardless of permissions
+ * @param User $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return string Username of the user that made the last article revision
+ */
+ public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ $this->loadLastEdit();
+ if ( $this->mLastRevision ) {
+ return $this->mLastRevision->getUserText( $audience, $user );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * @param int $audience One of:
+ * Revision::FOR_PUBLIC to be displayed to all users
+ * Revision::FOR_THIS_USER to be displayed to the given user
+ * Revision::RAW get the text regardless of permissions
+ * @param User $user User object to check for, only if FOR_THIS_USER is passed
+ * to the $audience parameter
+ * @return string Comment stored for the last article revision
+ */
+ public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ $this->loadLastEdit();
+ if ( $this->mLastRevision ) {
+ return $this->mLastRevision->getComment( $audience, $user );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Returns true if last revision was marked as "minor edit"
+ *
+ * @return bool Minor edit indicator for the last article revision.
+ */
+ public function getMinorEdit() {
+ $this->loadLastEdit();
+ if ( $this->mLastRevision ) {
+ return $this->mLastRevision->isMinor();
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Determine whether a page would be suitable for being counted as an
+ * article in the site_stats table based on the title & its content
+ *
+ * @param PreparedEdit|bool $editInfo (false): object returned by prepareTextForEdit(),
+ * if false, the current database state will be used
+ * @return bool
+ */
+ public function isCountable( $editInfo = false ) {
+ global $wgArticleCountMethod;
+
+ if ( !$this->mTitle->isContentPage() ) {
+ return false;
+ }
+
+ if ( $editInfo ) {
+ $content = $editInfo->pstContent;
+ } else {
+ $content = $this->getContent();
+ }
+
+ if ( !$content || $content->isRedirect() ) {
+ return false;
+ }
+
+ $hasLinks = null;
+
+ if ( $wgArticleCountMethod === 'link' ) {
+ // nasty special case to avoid re-parsing to detect links
+
+ if ( $editInfo ) {
+ // ParserOutput::getLinks() is a 2D array of page links, so
+ // to be really correct we would need to recurse in the array
+ // but the main array should only have items in it if there are
+ // links.
+ $hasLinks = (bool)count( $editInfo->output->getLinks() );
+ } else {
+ $hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1,
+ [ 'pl_from' => $this->getId() ], __METHOD__ );
+ }
+ }
+
+ return $content->isCountable( $hasLinks );
+ }
+
+ /**
+ * If this page is a redirect, get its target
+ *
+ * The target will be fetched from the redirect table if possible.
+ * If this page doesn't have an entry there, call insertRedirect()
+ * @return Title|null Title object, or null if this page is not a redirect
+ */
+ public function getRedirectTarget() {
+ if ( !$this->mTitle->isRedirect() ) {
+ return null;
+ }
+
+ if ( $this->mRedirectTarget !== null ) {
+ return $this->mRedirectTarget;
+ }
+
+ // Query the redirect table
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow( 'redirect',
+ [ 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
+ [ 'rd_from' => $this->getId() ],
+ __METHOD__
+ );
+
+ // rd_fragment and rd_interwiki were added later, populate them if empty
+ if ( $row && !is_null( $row->rd_fragment ) && !is_null( $row->rd_interwiki ) ) {
+ $this->mRedirectTarget = Title::makeTitle(
+ $row->rd_namespace, $row->rd_title,
+ $row->rd_fragment, $row->rd_interwiki
+ );
+ return $this->mRedirectTarget;
+ }
+
+ // This page doesn't have an entry in the redirect table
+ $this->mRedirectTarget = $this->insertRedirect();
+ return $this->mRedirectTarget;
+ }
+
+ /**
+ * Insert an entry for this page into the redirect table if the content is a redirect
+ *
+ * The database update will be deferred via DeferredUpdates
+ *
+ * Don't call this function directly unless you know what you're doing.
+ * @return Title|null Title object or null if not a redirect
+ */
+ public function insertRedirect() {
+ $content = $this->getContent();
+ $retval = $content ? $content->getUltimateRedirectTarget() : null;
+ if ( !$retval ) {
+ return null;
+ }
+
+ // Update the DB post-send if the page has not cached since now
+ $latest = $this->getLatest();
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $retval, $latest ) {
+ $this->insertRedirectEntry( $retval, $latest );
+ },
+ DeferredUpdates::POSTSEND,
+ wfGetDB( DB_MASTER )
+ );
+
+ return $retval;
+ }
+
+ /**
+ * Insert or update the redirect table entry for this page to indicate it redirects to $rt
+ * @param Title $rt Redirect target
+ * @param int|null $oldLatest Prior page_latest for check and set
+ */
+ public function insertRedirectEntry( Title $rt, $oldLatest = null ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ if ( !$oldLatest || $oldLatest == $this->lockAndGetLatest() ) {
+ $dbw->upsert(
+ 'redirect',
+ [
+ 'rd_from' => $this->getId(),
+ 'rd_namespace' => $rt->getNamespace(),
+ 'rd_title' => $rt->getDBkey(),
+ 'rd_fragment' => $rt->getFragment(),
+ 'rd_interwiki' => $rt->getInterwiki(),
+ ],
+ [ 'rd_from' ],
+ [
+ 'rd_namespace' => $rt->getNamespace(),
+ 'rd_title' => $rt->getDBkey(),
+ 'rd_fragment' => $rt->getFragment(),
+ 'rd_interwiki' => $rt->getInterwiki(),
+ ],
+ __METHOD__
+ );
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+ }
+
+ /**
+ * Get the Title object or URL this page redirects to
+ *
+ * @return bool|Title|string False, Title of in-wiki target, or string with URL
+ */
+ public function followRedirect() {
+ return $this->getRedirectURL( $this->getRedirectTarget() );
+ }
+
+ /**
+ * Get the Title object or URL to use for a redirect. We use Title
+ * objects for same-wiki, non-special redirects and URLs for everything
+ * else.
+ * @param Title $rt Redirect target
+ * @return bool|Title|string False, Title object of local target, or string with URL
+ */
+ public function getRedirectURL( $rt ) {
+ if ( !$rt ) {
+ return false;
+ }
+
+ if ( $rt->isExternal() ) {
+ if ( $rt->isLocal() ) {
+ // Offsite wikis need an HTTP redirect.
+ // This can be hard to reverse and may produce loops,
+ // so they may be disabled in the site configuration.
+ $source = $this->mTitle->getFullURL( 'redirect=no' );
+ return $rt->getFullURL( [ 'rdfrom' => $source ] );
+ } else {
+ // External pages without "local" bit set are not valid
+ // redirect targets
+ return false;
+ }
+ }
+
+ if ( $rt->isSpecialPage() ) {
+ // Gotta handle redirects to special pages differently:
+ // Fill the HTTP response "Location" header and ignore the rest of the page we're on.
+ // Some pages are not valid targets.
+ if ( $rt->isValidRedirectTarget() ) {
+ return $rt->getFullURL();
+ } else {
+ return false;
+ }
+ }
+
+ return $rt;
+ }
+
+ /**
+ * Get a list of users who have edited this article, not including the user who made
+ * the most recent revision, which you can get from $article->getUser() if you want it
+ * @return UserArrayFromResult
+ */
+ public function getContributors() {
+ // @todo FIXME: This is expensive; cache this info somewhere.
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ if ( $dbr->implicitGroupby() ) {
+ $realNameField = 'user_real_name';
+ } else {
+ $realNameField = 'MIN(user_real_name) AS user_real_name';
+ }
+
+ $tables = [ 'revision', 'user' ];
+
+ $fields = [
+ 'user_id' => 'rev_user',
+ 'user_name' => 'rev_user_text',
+ $realNameField,
+ 'timestamp' => 'MAX(rev_timestamp)',
+ ];
+
+ $conds = [ 'rev_page' => $this->getId() ];
+
+ // The user who made the top revision gets credited as "this page was last edited by
+ // John, based on contributions by Tom, Dick and Harry", so don't include them twice.
+ $user = $this->getUser();
+ if ( $user ) {
+ $conds[] = "rev_user != $user";
+ } else {
+ $conds[] = "rev_user_text != {$dbr->addQuotes( $this->getUserText() )}";
+ }
+
+ // Username hidden?
+ $conds[] = "{$dbr->bitAnd( 'rev_deleted', Revision::DELETED_USER )} = 0";
+
+ $jconds = [
+ 'user' => [ 'LEFT JOIN', 'rev_user = user_id' ],
+ ];
+
+ $options = [
+ 'GROUP BY' => [ 'rev_user', 'rev_user_text' ],
+ 'ORDER BY' => 'timestamp DESC',
+ ];
+
+ $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options, $jconds );
+ return new UserArrayFromResult( $res );
+ }
+
+ /**
+ * Should the parser cache be used?
+ *
+ * @param ParserOptions $parserOptions ParserOptions to check
+ * @param int $oldId
+ * @return bool
+ */
+ public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
+ return $parserOptions->getStubThreshold() == 0
+ && $this->exists()
+ && ( $oldId === null || $oldId === 0 || $oldId === $this->getLatest() )
+ && $this->getContentHandler()->isParserCacheSupported();
+ }
+
+ /**
+ * Get a ParserOutput for the given ParserOptions and revision ID.
+ *
+ * The parser cache will be used if possible. Cache misses that result
+ * in parser runs are debounced with PoolCounter.
+ *
+ * @since 1.19
+ * @param ParserOptions $parserOptions ParserOptions to use for the parse operation
+ * @param null|int $oldid Revision ID to get the text from, passing null or 0 will
+ * get the current revision (default value)
+ * @param bool $forceParse Force reindexing, regardless of cache settings
+ * @return bool|ParserOutput ParserOutput or false if the revision was not found
+ */
+ public function getParserOutput(
+ ParserOptions $parserOptions, $oldid = null, $forceParse = false
+ ) {
+ $useParserCache =
+ ( !$forceParse ) && $this->shouldCheckParserCache( $parserOptions, $oldid );
+
+ if ( $useParserCache && !$parserOptions->isSafeToCache() ) {
+ throw new InvalidArgumentException(
+ 'The supplied ParserOptions are not safe to cache. Fix the options or set $forceParse = true.'
+ );
+ }
+
+ wfDebug( __METHOD__ .
+ ': using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" );
+ if ( $parserOptions->getStubThreshold() ) {
+ wfIncrStats( 'pcache.miss.stub' );
+ }
+
+ if ( $useParserCache ) {
+ $parserOutput = MediaWikiServices::getInstance()->getParserCache()
+ ->get( $this, $parserOptions );
+ if ( $parserOutput !== false ) {
+ return $parserOutput;
+ }
+ }
+
+ if ( $oldid === null || $oldid === 0 ) {
+ $oldid = $this->getLatest();
+ }
+
+ $pool = new PoolWorkArticleView( $this, $parserOptions, $oldid, $useParserCache );
+ $pool->execute();
+
+ return $pool->getParserOutput();
+ }
+
+ /**
+ * Do standard deferred updates after page view (existing or missing page)
+ * @param User $user The relevant user
+ * @param int $oldid Revision id being viewed; if not given or 0, latest revision is assumed
+ */
+ public function doViewUpdates( User $user, $oldid = 0 ) {
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ Hooks::run( 'PageViewUpdates', [ $this, $user ] );
+ // Update newtalk / watchlist notification status
+ try {
+ $user->clearNotification( $this->mTitle, $oldid );
+ } catch ( DBError $e ) {
+ // Avoid outage if the master is not reachable
+ MWExceptionHandler::logException( $e );
+ }
+ }
+
+ /**
+ * Perform the actions of a page purging
+ * @return bool
+ * @note In 1.28 (and only 1.28), this took a $flags parameter that
+ * controlled how much purging was done.
+ */
+ public function doPurge() {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ if ( !Hooks::run( 'ArticlePurge', [ &$wikiPage ] ) ) {
+ return false;
+ }
+
+ $this->mTitle->invalidateCache();
+
+ // Clear file cache
+ HTMLFileCache::clearFileCache( $this->getTitle() );
+ // Send purge after above page_touched update was committed
+ DeferredUpdates::addUpdate(
+ new CdnCacheUpdate( $this->mTitle->getCdnUrls() ),
+ DeferredUpdates::PRESEND
+ );
+
+ if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
+ $messageCache = MessageCache::singleton();
+ $messageCache->updateMessageOverride( $this->mTitle, $this->getContent() );
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the last time a user explicitly purged the page via action=purge
+ *
+ * @return string|bool TS_MW timestamp or false
+ * @since 1.28
+ * @deprecated since 1.29. It will always return false.
+ */
+ public function getLastPurgeTimestamp() {
+ wfDeprecated( __METHOD__, '1.29' );
+ return false;
+ }
+
+ /**
+ * Insert a new empty page record for this article.
+ * This *must* be followed up by creating a revision
+ * and running $this->updateRevisionOn( ... );
+ * or else the record will be left in a funky state.
+ * Best if all done inside a transaction.
+ *
+ * @param IDatabase $dbw
+ * @param int|null $pageId Custom page ID that will be used for the insert statement
+ *
+ * @return bool|int The newly created page_id key; false if the row was not
+ * inserted, e.g. because the title already existed or because the specified
+ * page ID is already in use.
+ */
+ public function insertOn( $dbw, $pageId = null ) {
+ $pageIdForInsert = $pageId ? [ 'page_id' => $pageId ] : [];
+ $dbw->insert(
+ 'page',
+ [
+ 'page_namespace' => $this->mTitle->getNamespace(),
+ 'page_title' => $this->mTitle->getDBkey(),
+ 'page_restrictions' => '',
+ 'page_is_redirect' => 0, // Will set this shortly...
+ 'page_is_new' => 1,
+ 'page_random' => wfRandom(),
+ 'page_touched' => $dbw->timestamp(),
+ 'page_latest' => 0, // Fill this in shortly...
+ 'page_len' => 0, // Fill this in shortly...
+ ] + $pageIdForInsert,
+ __METHOD__,
+ 'IGNORE'
+ );
+
+ if ( $dbw->affectedRows() > 0 ) {
+ $newid = $pageId ? (int)$pageId : $dbw->insertId();
+ $this->mId = $newid;
+ $this->mTitle->resetArticleID( $newid );
+
+ return $newid;
+ } else {
+ return false; // nothing changed
+ }
+ }
+
+ /**
+ * Update the page record to point to a newly saved revision.
+ *
+ * @param IDatabase $dbw
+ * @param Revision $revision For ID number, and text used to set
+ * length and redirect status fields
+ * @param int $lastRevision If given, will not overwrite the page field
+ * when different from the currently set value.
+ * Giving 0 indicates the new page flag should be set on.
+ * @param bool $lastRevIsRedirect If given, will optimize adding and
+ * removing rows in redirect table.
+ * @return bool Success; false if the page row was missing or page_latest changed
+ */
+ public function updateRevisionOn( $dbw, $revision, $lastRevision = null,
+ $lastRevIsRedirect = null
+ ) {
+ global $wgContentHandlerUseDB;
+
+ // Assertion to try to catch T92046
+ if ( (int)$revision->getId() === 0 ) {
+ throw new InvalidArgumentException(
+ __METHOD__ . ': Revision has ID ' . var_export( $revision->getId(), 1 )
+ );
+ }
+
+ $content = $revision->getContent();
+ $len = $content ? $content->getSize() : 0;
+ $rt = $content ? $content->getUltimateRedirectTarget() : null;
+
+ $conditions = [ 'page_id' => $this->getId() ];
+
+ if ( !is_null( $lastRevision ) ) {
+ // An extra check against threads stepping on each other
+ $conditions['page_latest'] = $lastRevision;
+ }
+
+ $row = [ /* SET */
+ 'page_latest' => $revision->getId(),
+ 'page_touched' => $dbw->timestamp( $revision->getTimestamp() ),
+ 'page_is_new' => ( $lastRevision === 0 ) ? 1 : 0,
+ 'page_is_redirect' => $rt !== null ? 1 : 0,
+ 'page_len' => $len,
+ ];
+
+ if ( $wgContentHandlerUseDB ) {
+ $row['page_content_model'] = $revision->getContentModel();
+ }
+
+ $dbw->update( 'page',
+ $row,
+ $conditions,
+ __METHOD__ );
+
+ $result = $dbw->affectedRows() > 0;
+ if ( $result ) {
+ $this->updateRedirectOn( $dbw, $rt, $lastRevIsRedirect );
+ $this->setLastEdit( $revision );
+ $this->mLatest = $revision->getId();
+ $this->mIsRedirect = (bool)$rt;
+ // Update the LinkCache.
+ LinkCache::singleton()->addGoodLinkObj(
+ $this->getId(),
+ $this->mTitle,
+ $len,
+ $this->mIsRedirect,
+ $this->mLatest,
+ $revision->getContentModel()
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Add row to the redirect table if this is a redirect, remove otherwise.
+ *
+ * @param IDatabase $dbw
+ * @param Title $redirectTitle Title object pointing to the redirect target,
+ * or NULL if this is not a redirect
+ * @param null|bool $lastRevIsRedirect If given, will optimize adding and
+ * removing rows in redirect table.
+ * @return bool True on success, false on failure
+ * @private
+ */
+ public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) {
+ // Always update redirects (target link might have changed)
+ // Update/Insert if we don't know if the last revision was a redirect or not
+ // Delete if changing from redirect to non-redirect
+ $isRedirect = !is_null( $redirectTitle );
+
+ if ( !$isRedirect && $lastRevIsRedirect === false ) {
+ return true;
+ }
+
+ if ( $isRedirect ) {
+ $this->insertRedirectEntry( $redirectTitle );
+ } else {
+ // This is not a redirect, remove row from redirect table
+ $where = [ 'rd_from' => $this->getId() ];
+ $dbw->delete( 'redirect', $where, __METHOD__ );
+ }
+
+ if ( $this->getTitle()->getNamespace() == NS_FILE ) {
+ RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $this->getTitle() );
+ }
+
+ return ( $dbw->affectedRows() != 0 );
+ }
+
+ /**
+ * If the given revision is newer than the currently set page_latest,
+ * update the page record. Otherwise, do nothing.
+ *
+ * @deprecated since 1.24, use updateRevisionOn instead
+ *
+ * @param IDatabase $dbw
+ * @param Revision $revision
+ * @return bool
+ */
+ public function updateIfNewerOn( $dbw, $revision ) {
+ $row = $dbw->selectRow(
+ [ 'revision', 'page' ],
+ [ 'rev_id', 'rev_timestamp', 'page_is_redirect' ],
+ [
+ 'page_id' => $this->getId(),
+ 'page_latest=rev_id' ],
+ __METHOD__ );
+
+ if ( $row ) {
+ if ( wfTimestamp( TS_MW, $row->rev_timestamp ) >= $revision->getTimestamp() ) {
+ return false;
+ }
+ $prev = $row->rev_id;
+ $lastRevIsRedirect = (bool)$row->page_is_redirect;
+ } else {
+ // No or missing previous revision; mark the page as new
+ $prev = 0;
+ $lastRevIsRedirect = null;
+ }
+
+ $ret = $this->updateRevisionOn( $dbw, $revision, $prev, $lastRevIsRedirect );
+
+ return $ret;
+ }
+
+ /**
+ * Get the content that needs to be saved in order to undo all revisions
+ * between $undo and $undoafter. Revisions must belong to the same page,
+ * must exist and must not be deleted
+ * @param Revision $undo
+ * @param Revision $undoafter Must be an earlier revision than $undo
+ * @return Content|bool Content on success, false on failure
+ * @since 1.21
+ * Before we had the Content object, this was done in getUndoText
+ */
+ public function getUndoContent( Revision $undo, Revision $undoafter = null ) {
+ $handler = $undo->getContentHandler();
+ return $handler->getUndoContent( $this->getRevision(), $undo, $undoafter );
+ }
+
+ /**
+ * Returns true if this page's content model supports sections.
+ *
+ * @return bool
+ *
+ * @todo The skin should check this and not offer section functionality if
+ * sections are not supported.
+ * @todo The EditPage should check this and not offer section functionality
+ * if sections are not supported.
+ */
+ public function supportsSections() {
+ return $this->getContentHandler()->supportsSections();
+ }
+
+ /**
+ * @param string|int|null|bool $sectionId Section identifier as a number or string
+ * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
+ * or 'new' for a new section.
+ * @param Content $sectionContent New content of the section.
+ * @param string $sectionTitle New section's subject, only if $section is "new".
+ * @param string $edittime Revision timestamp or null to use the current revision.
+ *
+ * @throws MWException
+ * @return Content|null New complete article content, or null if error.
+ *
+ * @since 1.21
+ * @deprecated since 1.24, use replaceSectionAtRev instead
+ */
+ public function replaceSectionContent(
+ $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null
+ ) {
+ $baseRevId = null;
+ if ( $edittime && $sectionId !== 'new' ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime );
+ // Try the master if this thread may have just added it.
+ // This could be abstracted into a Revision method, but we don't want
+ // to encourage loading of revisions by timestamp.
+ if ( !$rev
+ && wfGetLB()->getServerCount() > 1
+ && wfGetLB()->hasOrMadeRecentMasterChanges()
+ ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $rev = Revision::loadFromTimestamp( $dbw, $this->mTitle, $edittime );
+ }
+ if ( $rev ) {
+ $baseRevId = $rev->getId();
+ }
+ }
+
+ return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId );
+ }
+
+ /**
+ * @param string|int|null|bool $sectionId Section identifier as a number or string
+ * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page
+ * or 'new' for a new section.
+ * @param Content $sectionContent New content of the section.
+ * @param string $sectionTitle New section's subject, only if $section is "new".
+ * @param int|null $baseRevId
+ *
+ * @throws MWException
+ * @return Content|null New complete article content, or null if error.
+ *
+ * @since 1.24
+ */
+ public function replaceSectionAtRev( $sectionId, Content $sectionContent,
+ $sectionTitle = '', $baseRevId = null
+ ) {
+ if ( strval( $sectionId ) === '' ) {
+ // Whole-page edit; let the whole text through
+ $newContent = $sectionContent;
+ } else {
+ if ( !$this->supportsSections() ) {
+ throw new MWException( "sections not supported for content model " .
+ $this->getContentHandler()->getModelID() );
+ }
+
+ // T32711: always use current version when adding a new section
+ if ( is_null( $baseRevId ) || $sectionId === 'new' ) {
+ $oldContent = $this->getContent();
+ } else {
+ $rev = Revision::newFromId( $baseRevId );
+ if ( !$rev ) {
+ wfDebug( __METHOD__ . " asked for bogus section (page: " .
+ $this->getId() . "; section: $sectionId)\n" );
+ return null;
+ }
+
+ $oldContent = $rev->getContent();
+ }
+
+ if ( !$oldContent ) {
+ wfDebug( __METHOD__ . ": no page text\n" );
+ return null;
+ }
+
+ $newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle );
+ }
+
+ return $newContent;
+ }
+
+ /**
+ * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
+ * @param int $flags
+ * @return int Updated $flags
+ */
+ public function checkFlags( $flags ) {
+ if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
+ if ( $this->exists() ) {
+ $flags |= EDIT_UPDATE;
+ } else {
+ $flags |= EDIT_NEW;
+ }
+ }
+
+ return $flags;
+ }
+
+ /**
+ * Change an existing article or create a new article. Updates RC and all necessary caches,
+ * optionally via the deferred update array.
+ *
+ * @param Content $content New content
+ * @param string $summary Edit summary
+ * @param int $flags Bitfield:
+ * EDIT_NEW
+ * Article is known or assumed to be non-existent, create a new one
+ * EDIT_UPDATE
+ * Article is known or assumed to be pre-existing, update it
+ * EDIT_MINOR
+ * Mark this edit minor, if the user is allowed to do so
+ * EDIT_SUPPRESS_RC
+ * Do not log the change in recentchanges
+ * EDIT_FORCE_BOT
+ * Mark the edit a "bot" edit regardless of user rights
+ * EDIT_AUTOSUMMARY
+ * Fill in blank summaries with generated text where possible
+ * EDIT_INTERNAL
+ * Signal that the page retrieve/save cycle happened entirely in this request.
+ *
+ * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the
+ * article will be detected. If EDIT_UPDATE is specified and the article
+ * doesn't exist, the function will return an edit-gone-missing error. If
+ * EDIT_NEW is specified and the article does exist, an edit-already-exists
+ * error will be returned. These two conditions are also possible with
+ * auto-detection due to MediaWiki's performance-optimised locking strategy.
+ *
+ * @param bool|int $baseRevId The revision ID this edit was based off, if any.
+ * This is not the parent revision ID, rather the revision ID for older
+ * content used as the source for a rollback, for example.
+ * @param User $user The user doing the edit
+ * @param string $serialFormat Format for storing the content in the
+ * database.
+ * @param array|null $tags Change tags to apply to this edit
+ * Callers are responsible for permission checks
+ * (with ChangeTags::canAddTagsAccompanyingChange)
+ * @param Int $undidRevId Id of revision that was undone or 0
+ *
+ * @throws MWException
+ * @return Status Possible errors:
+ * edit-hook-aborted: The ArticleSave hook aborted the edit but didn't
+ * set the fatal flag of $status.
+ * edit-gone-missing: In update mode, but the article didn't exist.
+ * edit-conflict: In update mode, the article changed unexpectedly.
+ * edit-no-change: Warning that the text was the same as before.
+ * edit-already-exists: In creation mode, but the article already exists.
+ *
+ * Extensions may define additional errors.
+ *
+ * $return->value will contain an associative array with members as follows:
+ * new: Boolean indicating if the function attempted to create a new article.
+ * revision: The revision object for the inserted revision, or null.
+ *
+ * @since 1.21
+ * @throws MWException
+ */
+ public function doEditContent(
+ Content $content, $summary, $flags = 0, $baseRevId = false,
+ User $user = null, $serialFormat = null, $tags = [], $undidRevId = 0
+ ) {
+ global $wgUser, $wgUseAutomaticEditSummaries;
+
+ // Old default parameter for $tags was null
+ if ( $tags === null ) {
+ $tags = [];
+ }
+
+ // Low-level sanity check
+ if ( $this->mTitle->getText() === '' ) {
+ throw new MWException( 'Something is trying to edit an article with an empty title' );
+ }
+ // Make sure the given content type is allowed for this page
+ if ( !$content->getContentHandler()->canBeUsedOn( $this->mTitle ) ) {
+ return Status::newFatal( 'content-not-allowed-here',
+ ContentHandler::getLocalizedName( $content->getModel() ),
+ $this->mTitle->getPrefixedText()
+ );
+ }
+
+ // Load the data from the master database if needed.
+ // The caller may already loaded it from the master or even loaded it using
+ // SELECT FOR UPDATE, so do not override that using clear().
+ $this->loadPageData( 'fromdbmaster' );
+
+ $user = $user ?: $wgUser;
+ $flags = $this->checkFlags( $flags );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ // Trigger pre-save hook (using provided edit summary)
+ $hookStatus = Status::newGood( [] );
+ $hook_args = [ &$wikiPage, &$user, &$content, &$summary,
+ $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
+ // Check if the hook rejected the attempted save
+ if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
+ if ( $hookStatus->isOK() ) {
+ // Hook returned false but didn't call fatal(); use generic message
+ $hookStatus->fatal( 'edit-hook-aborted' );
+ }
+
+ return $hookStatus;
+ }
+
+ $old_revision = $this->getRevision(); // current revision
+ $old_content = $this->getContent( Revision::RAW ); // current revision's content
+
+ if ( $old_content && $old_content->getModel() !== $content->getModel() ) {
+ $tags[] = 'mw-contentmodelchange';
+ }
+
+ // Provide autosummaries if one is not provided and autosummaries are enabled
+ if ( $wgUseAutomaticEditSummaries && ( $flags & EDIT_AUTOSUMMARY ) && $summary == '' ) {
+ $handler = $content->getContentHandler();
+ $summary = $handler->getAutosummary( $old_content, $content, $flags );
+ }
+
+ // Avoid statsd noise and wasted cycles check the edit stash (T136678)
+ if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
+ $useCache = false;
+ } else {
+ $useCache = true;
+ }
+
+ // Get the pre-save transform content and final parser output
+ $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialFormat, $useCache );
+ $pstContent = $editInfo->pstContent; // Content object
+ $meta = [
+ 'bot' => ( $flags & EDIT_FORCE_BOT ),
+ 'minor' => ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ),
+ 'serialized' => $pstContent->serialize( $serialFormat ),
+ 'serialFormat' => $serialFormat,
+ 'baseRevId' => $baseRevId,
+ 'oldRevision' => $old_revision,
+ 'oldContent' => $old_content,
+ 'oldId' => $this->getLatest(),
+ 'oldIsRedirect' => $this->isRedirect(),
+ 'oldCountable' => $this->isCountable(),
+ 'tags' => ( $tags !== null ) ? (array)$tags : [],
+ 'undidRevId' => $undidRevId
+ ];
+
+ // Actually create the revision and create/update the page
+ if ( $flags & EDIT_UPDATE ) {
+ $status = $this->doModify( $pstContent, $flags, $user, $summary, $meta );
+ } else {
+ $status = $this->doCreate( $pstContent, $flags, $user, $summary, $meta );
+ }
+
+ // Promote user to any groups they meet the criteria for
+ DeferredUpdates::addCallableUpdate( function () use ( $user ) {
+ $user->addAutopromoteOnceGroups( 'onEdit' );
+ $user->addAutopromoteOnceGroups( 'onView' ); // b/c
+ } );
+
+ return $status;
+ }
+
+ /**
+ * @param Content $content Pre-save transform content
+ * @param int $flags
+ * @param User $user
+ * @param string $summary
+ * @param array $meta
+ * @return Status
+ * @throws DBUnexpectedError
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ */
+ private function doModify(
+ Content $content, $flags, User $user, $summary, array $meta
+ ) {
+ global $wgUseRCPatrol;
+
+ // Update article, but only if changed.
+ $status = Status::newGood( [ 'new' => false, 'revision' => null ] );
+
+ // Convenience variables
+ $now = wfTimestampNow();
+ $oldid = $meta['oldId'];
+ /** @var Content|null $oldContent */
+ $oldContent = $meta['oldContent'];
+ $newsize = $content->getSize();
+
+ if ( !$oldid ) {
+ // Article gone missing
+ $status->fatal( 'edit-gone-missing' );
+
+ return $status;
+ } elseif ( !$oldContent ) {
+ // Sanity check for T39225
+ throw new MWException( "Could not find text for current revision {$oldid}." );
+ }
+
+ // @TODO: pass content object?!
+ $revision = new Revision( [
+ 'page' => $this->getId(),
+ 'title' => $this->mTitle, // for determining the default content model
+ 'comment' => $summary,
+ 'minor_edit' => $meta['minor'],
+ 'text' => $meta['serialized'],
+ 'len' => $newsize,
+ 'parent_id' => $oldid,
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => $now,
+ 'content_model' => $content->getModel(),
+ 'content_format' => $meta['serialFormat'],
+ ] );
+
+ $changed = !$content->equals( $oldContent );
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ if ( $changed ) {
+ $prepStatus = $content->prepareSave( $this, $flags, $oldid, $user );
+ $status->merge( $prepStatus );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $dbw->startAtomic( __METHOD__ );
+ // Get the latest page_latest value while locking it.
+ // Do a CAS style check to see if it's the same as when this method
+ // started. If it changed then bail out before touching the DB.
+ $latestNow = $this->lockAndGetLatest();
+ if ( $latestNow != $oldid ) {
+ $dbw->endAtomic( __METHOD__ );
+ // Page updated or deleted in the mean time
+ $status->fatal( 'edit-conflict' );
+
+ return $status;
+ }
+
+ // At this point we are now comitted to returning an OK
+ // status unless some DB query error or other exception comes up.
+ // This way callers don't have to call rollback() if $status is bad
+ // unless they actually try to catch exceptions (which is rare).
+
+ // Save the revision text
+ $revisionId = $revision->insertOn( $dbw );
+ // Update page_latest and friends to reflect the new revision
+ if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) {
+ throw new MWException( "Failed to update page row to use new revision." );
+ }
+
+ Hooks::run( 'NewRevisionFromEditComplete',
+ [ $this, $revision, $meta['baseRevId'], $user ] );
+
+ // Update recentchanges
+ if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
+ // Mark as patrolled if the user can do so
+ $patrolled = $wgUseRCPatrol && !count(
+ $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
+ // Add RC row to the DB
+ RecentChange::notifyEdit(
+ $now,
+ $this->mTitle,
+ $revision->isMinor(),
+ $user,
+ $summary,
+ $oldid,
+ $this->getTimestamp(),
+ $meta['bot'],
+ '',
+ $oldContent ? $oldContent->getSize() : 0,
+ $newsize,
+ $revisionId,
+ $patrolled,
+ $meta['tags']
+ );
+ }
+
+ $user->incEditCount();
+
+ $dbw->endAtomic( __METHOD__ );
+ $this->mTimestamp = $now;
+ } else {
+ // T34948: revision ID must be set to page {{REVISIONID}} and
+ // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
+ $revision->setId( $this->getLatest() );
+ $revision->setUserIdAndName(
+ $this->getUser( Revision::RAW ),
+ $this->getUserText( Revision::RAW )
+ );
+ }
+
+ if ( $changed ) {
+ // Return the new revision to the caller
+ $status->value['revision'] = $revision;
+ } else {
+ $status->warning( 'edit-no-change' );
+ // Update page_touched as updateRevisionOn() was not called.
+ // Other cache updates are managed in onArticleEdit() via doEditUpdates().
+ $this->mTitle->invalidateCache( $now );
+ }
+
+ // Do secondary updates once the main changes have been committed...
+ DeferredUpdates::addUpdate(
+ new AtomicSectionUpdate(
+ $dbw,
+ __METHOD__,
+ function () use (
+ $revision, &$user, $content, $summary, &$flags,
+ $changed, $meta, &$status
+ ) {
+ // Update links tables, site stats, etc.
+ $this->doEditUpdates(
+ $revision,
+ $user,
+ [
+ 'changed' => $changed,
+ 'oldcountable' => $meta['oldCountable'],
+ 'oldrevision' => $meta['oldRevision']
+ ]
+ );
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+ // Trigger post-save hook
+ $params = [ &$wikiPage, &$user, $content, $summary, $flags & EDIT_MINOR,
+ null, null, &$flags, $revision, &$status, $meta['baseRevId'],
+ $meta['undidRevId'] ];
+ Hooks::run( 'PageContentSaveComplete', $params );
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ return $status;
+ }
+
+ /**
+ * @param Content $content Pre-save transform content
+ * @param int $flags
+ * @param User $user
+ * @param string $summary
+ * @param array $meta
+ * @return Status
+ * @throws DBUnexpectedError
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ */
+ private function doCreate(
+ Content $content, $flags, User $user, $summary, array $meta
+ ) {
+ global $wgUseRCPatrol, $wgUseNPPatrol;
+
+ $status = Status::newGood( [ 'new' => true, 'revision' => null ] );
+
+ $now = wfTimestampNow();
+ $newsize = $content->getSize();
+ $prepStatus = $content->prepareSave( $this, $flags, $meta['oldId'], $user );
+ $status->merge( $prepStatus );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ // Add the page record unless one already exists for the title
+ $newid = $this->insertOn( $dbw );
+ if ( $newid === false ) {
+ $dbw->endAtomic( __METHOD__ ); // nothing inserted
+ $status->fatal( 'edit-already-exists' );
+
+ return $status; // nothing done
+ }
+
+ // At this point we are now comitted to returning an OK
+ // status unless some DB query error or other exception comes up.
+ // This way callers don't have to call rollback() if $status is bad
+ // unless they actually try to catch exceptions (which is rare).
+
+ // @TODO: pass content object?!
+ $revision = new Revision( [
+ 'page' => $newid,
+ 'title' => $this->mTitle, // for determining the default content model
+ 'comment' => $summary,
+ 'minor_edit' => $meta['minor'],
+ 'text' => $meta['serialized'],
+ 'len' => $newsize,
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => $now,
+ 'content_model' => $content->getModel(),
+ 'content_format' => $meta['serialFormat'],
+ ] );
+
+ // Save the revision text...
+ $revisionId = $revision->insertOn( $dbw );
+ // Update the page record with revision data
+ if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) {
+ throw new MWException( "Failed to update page row to use new revision." );
+ }
+
+ Hooks::run( 'NewRevisionFromEditComplete', [ $this, $revision, false, $user ] );
+
+ // Update recentchanges
+ if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
+ // Mark as patrolled if the user can do so
+ $patrolled = ( $wgUseRCPatrol || $wgUseNPPatrol ) &&
+ !count( $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
+ // Add RC row to the DB
+ RecentChange::notifyNew(
+ $now,
+ $this->mTitle,
+ $revision->isMinor(),
+ $user,
+ $summary,
+ $meta['bot'],
+ '',
+ $newsize,
+ $revisionId,
+ $patrolled,
+ $meta['tags']
+ );
+ }
+
+ $user->incEditCount();
+
+ $dbw->endAtomic( __METHOD__ );
+ $this->mTimestamp = $now;
+
+ // Return the new revision to the caller
+ $status->value['revision'] = $revision;
+
+ // Do secondary updates once the main changes have been committed...
+ DeferredUpdates::addUpdate(
+ new AtomicSectionUpdate(
+ $dbw,
+ __METHOD__,
+ function () use (
+ $revision, &$user, $content, $summary, &$flags, $meta, &$status
+ ) {
+ // Update links, etc.
+ $this->doEditUpdates( $revision, $user, [ 'created' => true ] );
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+ // Trigger post-create hook
+ $params = [ &$wikiPage, &$user, $content, $summary,
+ $flags & EDIT_MINOR, null, null, &$flags, $revision ];
+ Hooks::run( 'PageContentInsertComplete', $params );
+ // Trigger post-save hook
+ $params = array_merge( $params, [ &$status, $meta['baseRevId'], 0 ] );
+ Hooks::run( 'PageContentSaveComplete', $params );
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ return $status;
+ }
+
+ /**
+ * Get parser options suitable for rendering the primary article wikitext
+ *
+ * @see ContentHandler::makeParserOptions
+ *
+ * @param IContextSource|User|string $context One of the following:
+ * - IContextSource: Use the User and the Language of the provided
+ * context
+ * - User: Use the provided User object and $wgLang for the language,
+ * so use an IContextSource object if possible.
+ * - 'canonical': Canonical options (anonymous user with default
+ * preferences and content language).
+ * @return ParserOptions
+ */
+ public function makeParserOptions( $context ) {
+ $options = $this->getContentHandler()->makeParserOptions( $context );
+
+ if ( $this->getTitle()->isConversionTable() ) {
+ // @todo ConversionTable should become a separate content model, so
+ // we don't need special cases like this one.
+ $options->disableContentConversion();
+ }
+
+ return $options;
+ }
+
+ /**
+ * Prepare content which is about to be saved.
+ *
+ * Prior to 1.30, this returned a stdClass object with the same class
+ * members.
+ *
+ * @param Content $content
+ * @param Revision|int|null $revision Revision object. For backwards compatibility, a
+ * revision ID is also accepted, but this is deprecated.
+ * @param User|null $user
+ * @param string|null $serialFormat
+ * @param bool $useCache Check shared prepared edit cache
+ *
+ * @return PreparedEdit
+ *
+ * @since 1.21
+ */
+ public function prepareContentForEdit(
+ Content $content, $revision = null, User $user = null,
+ $serialFormat = null, $useCache = true
+ ) {
+ global $wgContLang, $wgUser, $wgAjaxEditStash;
+
+ if ( is_object( $revision ) ) {
+ $revid = $revision->getId();
+ } else {
+ $revid = $revision;
+ // This code path is deprecated, and nothing is known to
+ // use it, so performance here shouldn't be a worry.
+ if ( $revid !== null ) {
+ wfDeprecated( __METHOD__ . ' with $revision = revision ID', '1.25' );
+ $revision = Revision::newFromId( $revid, Revision::READ_LATEST );
+ } else {
+ $revision = null;
+ }
+ }
+
+ $user = is_null( $user ) ? $wgUser : $user;
+ // XXX: check $user->getId() here???
+
+ // Use a sane default for $serialFormat, see T59026
+ if ( $serialFormat === null ) {
+ $serialFormat = $content->getContentHandler()->getDefaultFormat();
+ }
+
+ if ( $this->mPreparedEdit
+ && isset( $this->mPreparedEdit->newContent )
+ && $this->mPreparedEdit->newContent->equals( $content )
+ && $this->mPreparedEdit->revid == $revid
+ && $this->mPreparedEdit->format == $serialFormat
+ // XXX: also check $user here?
+ ) {
+ // Already prepared
+ return $this->mPreparedEdit;
+ }
+
+ // The edit may have already been prepared via api.php?action=stashedit
+ $cachedEdit = $useCache && $wgAjaxEditStash
+ ? ApiStashEdit::checkCache( $this->getTitle(), $content, $user )
+ : false;
+
+ $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
+ Hooks::run( 'ArticlePrepareTextForEdit', [ $this, $popts ] );
+
+ $edit = new PreparedEdit();
+ if ( $cachedEdit ) {
+ $edit->timestamp = $cachedEdit->timestamp;
+ } else {
+ $edit->timestamp = wfTimestampNow();
+ }
+ // @note: $cachedEdit is safely not used if the rev ID was referenced in the text
+ $edit->revid = $revid;
+
+ if ( $cachedEdit ) {
+ $edit->pstContent = $cachedEdit->pstContent;
+ } else {
+ $edit->pstContent = $content
+ ? $content->preSaveTransform( $this->mTitle, $user, $popts )
+ : null;
+ }
+
+ $edit->format = $serialFormat;
+ $edit->popts = $this->makeParserOptions( 'canonical' );
+ if ( $cachedEdit ) {
+ $edit->output = $cachedEdit->output;
+ } else {
+ if ( $revision ) {
+ // We get here if vary-revision is set. This means that this page references
+ // itself (such as via self-transclusion). In this case, we need to make sure
+ // that any such self-references refer to the newly-saved revision, and not
+ // to the previous one, which could otherwise happen due to replica DB lag.
+ $oldCallback = $edit->popts->getCurrentRevisionCallback();
+ $edit->popts->setCurrentRevisionCallback(
+ function ( Title $title, $parser = false ) use ( $revision, &$oldCallback ) {
+ if ( $title->equals( $revision->getTitle() ) ) {
+ return $revision;
+ } else {
+ return call_user_func( $oldCallback, $title, $parser );
+ }
+ }
+ );
+ } else {
+ // Try to avoid a second parse if {{REVISIONID}} is used
+ $dbIndex = ( $this->mDataLoadedFrom & self::READ_LATEST ) === self::READ_LATEST
+ ? DB_MASTER // use the best possible guess
+ : DB_REPLICA; // T154554
+
+ $edit->popts->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
+ return 1 + (int)wfGetDB( $dbIndex )->selectField(
+ 'revision',
+ 'MAX(rev_id)',
+ [],
+ __METHOD__
+ );
+ } );
+ }
+ $edit->output = $edit->pstContent
+ ? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts )
+ : null;
+ }
+
+ $edit->newContent = $content;
+ $edit->oldContent = $this->getContent( Revision::RAW );
+
+ // NOTE: B/C for hooks! don't use these fields!
+ $edit->newText = $edit->newContent
+ ? ContentHandler::getContentText( $edit->newContent )
+ : '';
+ $edit->oldText = $edit->oldContent
+ ? ContentHandler::getContentText( $edit->oldContent )
+ : '';
+ $edit->pst = $edit->pstContent ? $edit->pstContent->serialize( $serialFormat ) : '';
+
+ if ( $edit->output ) {
+ $edit->output->setCacheTime( wfTimestampNow() );
+ }
+
+ // Process cache the result
+ $this->mPreparedEdit = $edit;
+
+ return $edit;
+ }
+
+ /**
+ * Do standard deferred updates after page edit.
+ * Update links tables, site stats, search index and message cache.
+ * Purges pages that include this page if the text was changed here.
+ * Every 100th edit, prune the recent changes table.
+ *
+ * @param Revision $revision
+ * @param User $user User object that did the revision
+ * @param array $options Array of options, following indexes are used:
+ * - changed: bool, whether the revision changed the content (default true)
+ * - created: bool, whether the revision created the page (default false)
+ * - moved: bool, whether the page was moved (default false)
+ * - restored: bool, whether the page was undeleted (default false)
+ * - oldrevision: Revision object for the pre-update revision (default null)
+ * - oldcountable: bool, null, or string 'no-change' (default null):
+ * - bool: whether the page was counted as an article before that
+ * revision, only used in changed is true and created is false
+ * - null: if created is false, don't update the article count; if created
+ * is true, do update the article count
+ * - 'no-change': don't update the article count, ever
+ */
+ public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
+ global $wgRCWatchCategoryMembership;
+
+ $options += [
+ 'changed' => true,
+ 'created' => false,
+ 'moved' => false,
+ 'restored' => false,
+ 'oldrevision' => null,
+ 'oldcountable' => null
+ ];
+ $content = $revision->getContent();
+
+ $logger = LoggerFactory::getInstance( 'SaveParse' );
+
+ // See if the parser output before $revision was inserted is still valid
+ $editInfo = false;
+ if ( !$this->mPreparedEdit ) {
+ $logger->debug( __METHOD__ . ": No prepared edit...\n" );
+ } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) {
+ $logger->info( __METHOD__ . ": Prepared edit has vary-revision...\n" );
+ } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision-id' )
+ && $this->mPreparedEdit->output->getSpeculativeRevIdUsed() !== $revision->getId()
+ ) {
+ $logger->info( __METHOD__ . ": Prepared edit has vary-revision-id with wrong ID...\n" );
+ } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-user' ) && !$options['changed'] ) {
+ $logger->info( __METHOD__ . ": Prepared edit has vary-user and is null...\n" );
+ } else {
+ wfDebug( __METHOD__ . ": Using prepared edit...\n" );
+ $editInfo = $this->mPreparedEdit;
+ }
+
+ if ( !$editInfo ) {
+ // Parse the text again if needed. Be careful not to do pre-save transform twice:
+ // $text is usually already pre-save transformed once. Avoid using the edit stash
+ // as any prepared content from there or in doEditContent() was already rejected.
+ $editInfo = $this->prepareContentForEdit( $content, $revision, $user, null, false );
+ }
+
+ // Save it to the parser cache.
+ // Make sure the cache time matches page_touched to avoid double parsing.
+ MediaWikiServices::getInstance()->getParserCache()->save(
+ $editInfo->output, $this, $editInfo->popts,
+ $revision->getTimestamp(), $editInfo->revid
+ );
+
+ // Update the links tables and other secondary data
+ if ( $content ) {
+ $recursive = $options['changed']; // T52785
+ $updates = $content->getSecondaryDataUpdates(
+ $this->getTitle(), null, $recursive, $editInfo->output
+ );
+ foreach ( $updates as $update ) {
+ if ( $update instanceof LinksUpdate ) {
+ $update->setRevision( $revision );
+ $update->setTriggeringUser( $user );
+ }
+ DeferredUpdates::addUpdate( $update );
+ }
+ if ( $wgRCWatchCategoryMembership
+ && $this->getContentHandler()->supportsCategories() === true
+ && ( $options['changed'] || $options['created'] )
+ && !$options['restored']
+ ) {
+ // Note: jobs are pushed after deferred updates, so the job should be able to see
+ // the recent change entry (also done via deferred updates) and carry over any
+ // bot/deletion/IP flags, ect.
+ JobQueueGroup::singleton()->lazyPush( new CategoryMembershipChangeJob(
+ $this->getTitle(),
+ [
+ 'pageId' => $this->getId(),
+ 'revTimestamp' => $revision->getTimestamp()
+ ]
+ ) );
+ }
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $options['changed'] ] );
+
+ if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
+ // Flush old entries from the `recentchanges` table
+ if ( mt_rand( 0, 9 ) == 0 ) {
+ JobQueueGroup::singleton()->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
+ }
+ }
+
+ if ( !$this->exists() ) {
+ return;
+ }
+
+ $id = $this->getId();
+ $title = $this->mTitle->getPrefixedDBkey();
+ $shortTitle = $this->mTitle->getDBkey();
+
+ if ( $options['oldcountable'] === 'no-change' ||
+ ( !$options['changed'] && !$options['moved'] )
+ ) {
+ $good = 0;
+ } elseif ( $options['created'] ) {
+ $good = (int)$this->isCountable( $editInfo );
+ } elseif ( $options['oldcountable'] !== null ) {
+ $good = (int)$this->isCountable( $editInfo ) - (int)$options['oldcountable'];
+ } else {
+ $good = 0;
+ }
+ $edits = $options['changed'] ? 1 : 0;
+ $total = $options['created'] ? 1 : 0;
+
+ DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, $edits, $good, $total ) );
+ DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $content ) );
+
+ // If this is another user's talk page, update newtalk.
+ // Don't do this if $options['changed'] = false (null-edits) nor if
+ // it's a minor edit and the user doesn't want notifications for those.
+ if ( $options['changed']
+ && $this->mTitle->getNamespace() == NS_USER_TALK
+ && $shortTitle != $user->getTitleKey()
+ && !( $revision->isMinor() && $user->isAllowed( 'nominornewtalk' ) )
+ ) {
+ $recipient = User::newFromName( $shortTitle, false );
+ if ( !$recipient ) {
+ wfDebug( __METHOD__ . ": invalid username\n" );
+ } else {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ // Allow extensions to prevent user notification
+ // when a new message is added to their talk page
+ if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
+ if ( User::isIP( $shortTitle ) ) {
+ // An anonymous user
+ $recipient->setNewtalk( true, $revision );
+ } elseif ( $recipient->isLoggedIn() ) {
+ $recipient->setNewtalk( true, $revision );
+ } else {
+ wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
+ }
+ }
+ }
+ }
+
+ if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
+ MessageCache::singleton()->updateMessageOverride( $this->mTitle, $content );
+ }
+
+ if ( $options['created'] ) {
+ self::onArticleCreate( $this->mTitle );
+ } elseif ( $options['changed'] ) { // T52785
+ self::onArticleEdit( $this->mTitle, $revision );
+ }
+
+ ResourceLoaderWikiModule::invalidateModuleCache(
+ $this->mTitle, $options['oldrevision'], $revision, wfWikiID()
+ );
+ }
+
+ /**
+ * Update the article's restriction field, and leave a log entry.
+ * This works for protection both existing and non-existing pages.
+ *
+ * @param array $limit Set of restriction keys
+ * @param array $expiry Per restriction type expiration
+ * @param int &$cascade Set to false if cascading protection isn't allowed.
+ * @param string $reason
+ * @param User $user The user updating the restrictions
+ * @param string|string[] $tags Change tags to add to the pages and protection log entries
+ * ($user should be able to add the specified tags before this is called)
+ * @return Status Status object; if action is taken, $status->value is the log_id of the
+ * protection log entry.
+ */
+ public function doUpdateRestrictions( array $limit, array $expiry,
+ &$cascade, $reason, User $user, $tags = null
+ ) {
+ global $wgCascadingRestrictionLevels;
+
+ if ( wfReadOnly() ) {
+ return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) );
+ }
+
+ $this->loadPageData( 'fromdbmaster' );
+ $restrictionTypes = $this->mTitle->getRestrictionTypes();
+ $id = $this->getId();
+
+ if ( !$cascade ) {
+ $cascade = false;
+ }
+
+ // Take this opportunity to purge out expired restrictions
+ Title::purgeExpiredRestrictions();
+
+ // @todo FIXME: Same limitations as described in ProtectionForm.php (line 37);
+ // we expect a single selection, but the schema allows otherwise.
+ $isProtected = false;
+ $protect = false;
+ $changed = false;
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ foreach ( $restrictionTypes as $action ) {
+ if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) {
+ $expiry[$action] = 'infinity';
+ }
+ if ( !isset( $limit[$action] ) ) {
+ $limit[$action] = '';
+ } elseif ( $limit[$action] != '' ) {
+ $protect = true;
+ }
+
+ // Get current restrictions on $action
+ $current = implode( '', $this->mTitle->getRestrictions( $action ) );
+ if ( $current != '' ) {
+ $isProtected = true;
+ }
+
+ if ( $limit[$action] != $current ) {
+ $changed = true;
+ } elseif ( $limit[$action] != '' ) {
+ // Only check expiry change if the action is actually being
+ // protected, since expiry does nothing on an not-protected
+ // action.
+ if ( $this->mTitle->getRestrictionExpiry( $action ) != $expiry[$action] ) {
+ $changed = true;
+ }
+ }
+ }
+
+ if ( !$changed && $protect && $this->mTitle->areRestrictionsCascading() != $cascade ) {
+ $changed = true;
+ }
+
+ // If nothing has changed, do nothing
+ if ( !$changed ) {
+ return Status::newGood();
+ }
+
+ if ( !$protect ) { // No protection at all means unprotection
+ $revCommentMsg = 'unprotectedarticle-comment';
+ $logAction = 'unprotect';
+ } elseif ( $isProtected ) {
+ $revCommentMsg = 'modifiedarticleprotection-comment';
+ $logAction = 'modify';
+ } else {
+ $revCommentMsg = 'protectedarticle-comment';
+ $logAction = 'protect';
+ }
+
+ $logRelationsValues = [];
+ $logRelationsField = null;
+ $logParamsDetails = [];
+
+ // Null revision (used for change tag insertion)
+ $nullRevision = null;
+
+ if ( $id ) { // Protection of existing page
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ if ( !Hooks::run( 'ArticleProtect', [ &$wikiPage, &$user, $limit, $reason ] ) ) {
+ return Status::newGood();
+ }
+
+ // Only certain restrictions can cascade...
+ $editrestriction = isset( $limit['edit'] )
+ ? [ $limit['edit'] ]
+ : $this->mTitle->getRestrictions( 'edit' );
+ foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) {
+ $editrestriction[$key] = 'editprotected'; // backwards compatibility
+ }
+ foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) {
+ $editrestriction[$key] = 'editsemiprotected'; // backwards compatibility
+ }
+
+ $cascadingRestrictionLevels = $wgCascadingRestrictionLevels;
+ foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) {
+ $cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility
+ }
+ foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) {
+ $cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility
+ }
+
+ // The schema allows multiple restrictions
+ if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) {
+ $cascade = false;
+ }
+
+ // insert null revision to identify the page protection change as edit summary
+ $latest = $this->getLatest();
+ $nullRevision = $this->insertProtectNullRevision(
+ $revCommentMsg,
+ $limit,
+ $expiry,
+ $cascade,
+ $reason,
+ $user
+ );
+
+ if ( $nullRevision === null ) {
+ return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() );
+ }
+
+ $logRelationsField = 'pr_id';
+
+ // Update restrictions table
+ foreach ( $limit as $action => $restrictions ) {
+ $dbw->delete(
+ 'page_restrictions',
+ [
+ 'pr_page' => $id,
+ 'pr_type' => $action
+ ],
+ __METHOD__
+ );
+ if ( $restrictions != '' ) {
+ $cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0;
+ $dbw->insert(
+ 'page_restrictions',
+ [
+ 'pr_page' => $id,
+ 'pr_type' => $action,
+ 'pr_level' => $restrictions,
+ 'pr_cascade' => $cascadeValue,
+ 'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] )
+ ],
+ __METHOD__
+ );
+ $logRelationsValues[] = $dbw->insertId();
+ $logParamsDetails[] = [
+ 'type' => $action,
+ 'level' => $restrictions,
+ 'expiry' => $expiry[$action],
+ 'cascade' => (bool)$cascadeValue,
+ ];
+ }
+ }
+
+ // Clear out legacy restriction fields
+ $dbw->update(
+ 'page',
+ [ 'page_restrictions' => '' ],
+ [ 'page_id' => $id ],
+ __METHOD__
+ );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ Hooks::run( 'NewRevisionFromEditComplete',
+ [ $this, $nullRevision, $latest, $user ] );
+ Hooks::run( 'ArticleProtectComplete', [ &$wikiPage, &$user, $limit, $reason ] );
+ } else { // Protection of non-existing page (also known as "title protection")
+ // Cascade protection is meaningless in this case
+ $cascade = false;
+
+ if ( $limit['create'] != '' ) {
+ $commentFields = CommentStore::newKey( 'pt_reason' )->insert( $dbw, $reason );
+ $dbw->replace( 'protected_titles',
+ [ [ 'pt_namespace', 'pt_title' ] ],
+ [
+ 'pt_namespace' => $this->mTitle->getNamespace(),
+ 'pt_title' => $this->mTitle->getDBkey(),
+ 'pt_create_perm' => $limit['create'],
+ 'pt_timestamp' => $dbw->timestamp(),
+ 'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ),
+ 'pt_user' => $user->getId(),
+ ] + $commentFields, __METHOD__
+ );
+ $logParamsDetails[] = [
+ 'type' => 'create',
+ 'level' => $limit['create'],
+ 'expiry' => $expiry['create'],
+ ];
+ } else {
+ $dbw->delete( 'protected_titles',
+ [
+ 'pt_namespace' => $this->mTitle->getNamespace(),
+ 'pt_title' => $this->mTitle->getDBkey()
+ ], __METHOD__
+ );
+ }
+ }
+
+ $this->mTitle->flushRestrictions();
+ InfoAction::invalidateCache( $this->mTitle );
+
+ if ( $logAction == 'unprotect' ) {
+ $params = [];
+ } else {
+ $protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry );
+ $params = [
+ '4::description' => $protectDescriptionLog, // parameter for IRC
+ '5:bool:cascade' => $cascade,
+ 'details' => $logParamsDetails, // parameter for localize and api
+ ];
+ }
+
+ // Update the protection log
+ $logEntry = new ManualLogEntry( 'protect', $logAction );
+ $logEntry->setTarget( $this->mTitle );
+ $logEntry->setComment( $reason );
+ $logEntry->setPerformer( $user );
+ $logEntry->setParameters( $params );
+ if ( !is_null( $nullRevision ) ) {
+ $logEntry->setAssociatedRevId( $nullRevision->getId() );
+ }
+ $logEntry->setTags( $tags );
+ if ( $logRelationsField !== null && count( $logRelationsValues ) ) {
+ $logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] );
+ }
+ $logId = $logEntry->insert();
+ $logEntry->publish( $logId );
+
+ return Status::newGood( $logId );
+ }
+
+ /**
+ * Insert a new null revision for this page.
+ *
+ * @param string $revCommentMsg Comment message key for the revision
+ * @param array $limit Set of restriction keys
+ * @param array $expiry Per restriction type expiration
+ * @param int $cascade Set to false if cascading protection isn't allowed.
+ * @param string $reason
+ * @param User|null $user
+ * @return Revision|null Null on error
+ */
+ public function insertProtectNullRevision( $revCommentMsg, array $limit,
+ array $expiry, $cascade, $reason, $user = null
+ ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ // Prepare a null revision to be added to the history
+ $editComment = wfMessage(
+ $revCommentMsg,
+ $this->mTitle->getPrefixedText(),
+ $user ? $user->getName() : ''
+ )->inContentLanguage()->text();
+ if ( $reason ) {
+ $editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
+ }
+ $protectDescription = $this->protectDescription( $limit, $expiry );
+ if ( $protectDescription ) {
+ $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
+ $editComment .= wfMessage( 'parentheses' )->params( $protectDescription )
+ ->inContentLanguage()->text();
+ }
+ if ( $cascade ) {
+ $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
+ $editComment .= wfMessage( 'brackets' )->params(
+ wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text()
+ )->inContentLanguage()->text();
+ }
+
+ $nullRev = Revision::newNullRevision( $dbw, $this->getId(), $editComment, true, $user );
+ if ( $nullRev ) {
+ $nullRev->insertOn( $dbw );
+
+ // Update page record and touch page
+ $oldLatest = $nullRev->getParentId();
+ $this->updateRevisionOn( $dbw, $nullRev, $oldLatest );
+ }
+
+ return $nullRev;
+ }
+
+ /**
+ * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid
+ * @return string
+ */
+ protected function formatExpiry( $expiry ) {
+ global $wgContLang;
+
+ if ( $expiry != 'infinity' ) {
+ return wfMessage(
+ 'protect-expiring',
+ $wgContLang->timeanddate( $expiry, false, false ),
+ $wgContLang->date( $expiry, false, false ),
+ $wgContLang->time( $expiry, false, false )
+ )->inContentLanguage()->text();
+ } else {
+ return wfMessage( 'protect-expiry-indefinite' )
+ ->inContentLanguage()->text();
+ }
+ }
+
+ /**
+ * Builds the description to serve as comment for the edit.
+ *
+ * @param array $limit Set of restriction keys
+ * @param array $expiry Per restriction type expiration
+ * @return string
+ */
+ public function protectDescription( array $limit, array $expiry ) {
+ $protectDescription = '';
+
+ foreach ( array_filter( $limit ) as $action => $restrictions ) {
+ # $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ].
+ # All possible message keys are listed here for easier grepping:
+ # * restriction-create
+ # * restriction-edit
+ # * restriction-move
+ # * restriction-upload
+ $actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text();
+ # $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ],
+ # with '' filtered out. All possible message keys are listed below:
+ # * protect-level-autoconfirmed
+ # * protect-level-sysop
+ $restrictionsText = wfMessage( 'protect-level-' . $restrictions )
+ ->inContentLanguage()->text();
+
+ $expiryText = $this->formatExpiry( $expiry[$action] );
+
+ if ( $protectDescription !== '' ) {
+ $protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text();
+ }
+ $protectDescription .= wfMessage( 'protect-summary-desc' )
+ ->params( $actionText, $restrictionsText, $expiryText )
+ ->inContentLanguage()->text();
+ }
+
+ return $protectDescription;
+ }
+
+ /**
+ * Builds the description to serve as comment for the log entry.
+ *
+ * Some bots may parse IRC lines, which are generated from log entries which contain plain
+ * protect description text. Keep them in old format to avoid breaking compatibility.
+ * TODO: Fix protection log to store structured description and format it on-the-fly.
+ *
+ * @param array $limit Set of restriction keys
+ * @param array $expiry Per restriction type expiration
+ * @return string
+ */
+ public function protectDescriptionLog( array $limit, array $expiry ) {
+ global $wgContLang;
+
+ $protectDescriptionLog = '';
+
+ foreach ( array_filter( $limit ) as $action => $restrictions ) {
+ $expiryText = $this->formatExpiry( $expiry[$action] );
+ $protectDescriptionLog .= $wgContLang->getDirMark() .
+ "[$action=$restrictions] ($expiryText)";
+ }
+
+ return trim( $protectDescriptionLog );
+ }
+
+ /**
+ * Take an array of page restrictions and flatten it to a string
+ * suitable for insertion into the page_restrictions field.
+ *
+ * @param string[] $limit
+ *
+ * @throws MWException
+ * @return string
+ */
+ protected static function flattenRestrictions( $limit ) {
+ if ( !is_array( $limit ) ) {
+ throw new MWException( __METHOD__ . ' given non-array restriction set' );
+ }
+
+ $bits = [];
+ ksort( $limit );
+
+ foreach ( array_filter( $limit ) as $action => $restrictions ) {
+ $bits[] = "$action=$restrictions";
+ }
+
+ return implode( ':', $bits );
+ }
+
+ /**
+ * Same as doDeleteArticleReal(), but returns a simple boolean. This is kept around for
+ * backwards compatibility, if you care about error reporting you should use
+ * doDeleteArticleReal() instead.
+ *
+ * Deletes the article with database consistency, writes logs, purges caches
+ *
+ * @param string $reason Delete reason for deletion log
+ * @param bool $suppress Suppress all revisions and log the deletion in
+ * the suppression log instead of the deletion log
+ * @param int $u1 Unused
+ * @param bool $u2 Unused
+ * @param array|string &$error Array of errors to append to
+ * @param User $user The deleting user
+ * @return bool True if successful
+ */
+ public function doDeleteArticle(
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null
+ ) {
+ $status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $error, $user );
+ return $status->isGood();
+ }
+
+ /**
+ * Back-end article deletion
+ * Deletes the article with database consistency, writes logs, purges caches
+ *
+ * @since 1.19
+ *
+ * @param string $reason Delete reason for deletion log
+ * @param bool $suppress Suppress all revisions and log the deletion in
+ * the suppression log instead of the deletion log
+ * @param int $u1 Unused
+ * @param bool $u2 Unused
+ * @param array|string &$error Array of errors to append to
+ * @param User $user The deleting user
+ * @param array $tags Tags to apply to the deletion action
+ * @param string $logsubtype
+ * @return Status Status object; if successful, $status->value is the log_id of the
+ * deletion log entry. If the page couldn't be deleted because it wasn't
+ * found, $status is a non-fatal 'cannotdelete' error
+ */
+ public function doDeleteArticleReal(
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null,
+ $tags = [], $logsubtype = 'delete'
+ ) {
+ global $wgUser, $wgContentHandlerUseDB, $wgCommentTableSchemaMigrationStage;
+
+ wfDebug( __METHOD__ . "\n" );
+
+ $status = Status::newGood();
+
+ if ( $this->mTitle->getDBkey() === '' ) {
+ $status->error( 'cannotdelete',
+ wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
+ return $status;
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ $user = is_null( $user ) ? $wgUser : $user;
+ if ( !Hooks::run( 'ArticleDelete',
+ [ &$wikiPage, &$user, &$reason, &$error, &$status, $suppress ]
+ ) ) {
+ if ( $status->isOK() ) {
+ // Hook aborted but didn't set a fatal status
+ $status->fatal( 'delete-hook-aborted' );
+ }
+ return $status;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ $this->loadPageData( self::READ_LATEST );
+ $id = $this->getId();
+ // T98706: lock the page from various other updates but avoid using
+ // WikiPage::READ_LOCKING as that will carry over the FOR UPDATE to
+ // the revisions queries (which also JOIN on user). Only lock the page
+ // row and CAS check on page_latest to see if the trx snapshot matches.
+ $lockedLatest = $this->lockAndGetLatest();
+ if ( $id == 0 || $this->getLatest() != $lockedLatest ) {
+ $dbw->endAtomic( __METHOD__ );
+ // Page not there or trx snapshot is stale
+ $status->error( 'cannotdelete',
+ wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
+ return $status;
+ }
+
+ // Given the lock above, we can be confident in the title and page ID values
+ $namespace = $this->getTitle()->getNamespace();
+ $dbKey = $this->getTitle()->getDBkey();
+
+ // At this point we are now comitted to returning an OK
+ // status unless some DB query error or other exception comes up.
+ // This way callers don't have to call rollback() if $status is bad
+ // unless they actually try to catch exceptions (which is rare).
+
+ // we need to remember the old content so we can use it to generate all deletion updates.
+ $revision = $this->getRevision();
+ try {
+ $content = $this->getContent( Revision::RAW );
+ } catch ( Exception $ex ) {
+ wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
+ . $ex->getMessage() );
+
+ $content = null;
+ }
+
+ $revCommentStore = new CommentStore( 'rev_comment' );
+ $arCommentStore = new CommentStore( 'ar_comment' );
+
+ $fields = Revision::selectFields();
+ $bitfield = false;
+
+ // Bitfields to further suppress the content
+ if ( $suppress ) {
+ $bitfield = Revision::SUPPRESSED_ALL;
+ $fields = array_diff( $fields, [ 'rev_deleted' ] );
+ }
+
+ // For now, shunt the revision data into the archive table.
+ // Text is *not* removed from the text table; bulk storage
+ // is left intact to avoid breaking block-compression or
+ // immutable storage schemes.
+ // In the future, we may keep revisions and mark them with
+ // the rev_deleted field, which is reserved for this purpose.
+
+ // Get all of the page revisions
+ $commentQuery = $revCommentStore->getJoin();
+ $res = $dbw->select(
+ [ 'revision' ] + $commentQuery['tables'],
+ $fields + $commentQuery['fields'],
+ [ 'rev_page' => $id ],
+ __METHOD__,
+ 'FOR UPDATE',
+ $commentQuery['joins']
+ );
+
+ // Build their equivalent archive rows
+ $rowsInsert = [];
+ $revids = [];
+
+ /** @var int[] Revision IDs of edits that were made by IPs */
+ $ipRevIds = [];
+
+ foreach ( $res as $row ) {
+ $comment = $revCommentStore->getComment( $row );
+ $rowInsert = [
+ 'ar_namespace' => $namespace,
+ 'ar_title' => $dbKey,
+ 'ar_user' => $row->rev_user,
+ 'ar_user_text' => $row->rev_user_text,
+ 'ar_timestamp' => $row->rev_timestamp,
+ 'ar_minor_edit' => $row->rev_minor_edit,
+ 'ar_rev_id' => $row->rev_id,
+ 'ar_parent_id' => $row->rev_parent_id,
+ 'ar_text_id' => $row->rev_text_id,
+ 'ar_text' => '',
+ 'ar_flags' => '',
+ 'ar_len' => $row->rev_len,
+ 'ar_page_id' => $id,
+ 'ar_deleted' => $suppress ? $bitfield : $row->rev_deleted,
+ 'ar_sha1' => $row->rev_sha1,
+ ] + $arCommentStore->insert( $dbw, $comment );
+ if ( $wgContentHandlerUseDB ) {
+ $rowInsert['ar_content_model'] = $row->rev_content_model;
+ $rowInsert['ar_content_format'] = $row->rev_content_format;
+ }
+ $rowsInsert[] = $rowInsert;
+ $revids[] = $row->rev_id;
+
+ // Keep track of IP edits, so that the corresponding rows can
+ // be deleted in the ip_changes table.
+ if ( (int)$row->rev_user === 0 && IP::isValid( $row->rev_user_text ) ) {
+ $ipRevIds[] = $row->rev_id;
+ }
+ }
+ // Copy them into the archive table
+ $dbw->insert( 'archive', $rowsInsert, __METHOD__ );
+ // Save this so we can pass it to the ArticleDeleteComplete hook.
+ $archivedRevisionCount = $dbw->affectedRows();
+
+ // Clone the title and wikiPage, so we have the information we need when
+ // we log and run the ArticleDeleteComplete hook.
+ $logTitle = clone $this->mTitle;
+ $wikiPageBeforeDelete = clone $this;
+
+ // Now that it's safely backed up, delete it
+ $dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
+ $dbw->delete( 'revision', [ 'rev_page' => $id ], __METHOD__ );
+ if ( $wgCommentTableSchemaMigrationStage > MIGRATION_OLD ) {
+ $dbw->delete( 'revision_comment_temp', [ 'revcomment_rev' => $revids ], __METHOD__ );
+ }
+
+ // Also delete records from ip_changes as applicable.
+ if ( count( $ipRevIds ) > 0 ) {
+ $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $ipRevIds ], __METHOD__ );
+ }
+
+ // Log the deletion, if the page was suppressed, put it in the suppression log instead
+ $logtype = $suppress ? 'suppress' : 'delete';
+
+ $logEntry = new ManualLogEntry( $logtype, $logsubtype );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( $logTitle );
+ $logEntry->setComment( $reason );
+ $logEntry->setTags( $tags );
+ $logid = $logEntry->insert();
+
+ $dbw->onTransactionPreCommitOrIdle(
+ function () use ( $dbw, $logEntry, $logid ) {
+ // T58776: avoid deadlocks (especially from FileDeleteForm)
+ $logEntry->publish( $logid );
+ },
+ __METHOD__
+ );
+
+ $dbw->endAtomic( __METHOD__ );
+
+ $this->doDeleteUpdates( $id, $content, $revision );
+
+ Hooks::run( 'ArticleDeleteComplete', [
+ &$wikiPageBeforeDelete,
+ &$user,
+ $reason,
+ $id,
+ $content,
+ $logEntry,
+ $archivedRevisionCount
+ ] );
+ $status->value = $logid;
+
+ // Show log excerpt on 404 pages rather than just a link
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+ $key = $cache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
+ $cache->set( $key, 1, $cache::TTL_DAY );
+
+ return $status;
+ }
+
+ /**
+ * Lock the page row for this title+id and return page_latest (or 0)
+ *
+ * @return int Returns 0 if no row was found with this title+id
+ * @since 1.27
+ */
+ public function lockAndGetLatest() {
+ return (int)wfGetDB( DB_MASTER )->selectField(
+ 'page',
+ 'page_latest',
+ [
+ 'page_id' => $this->getId(),
+ // Typically page_id is enough, but some code might try to do
+ // updates assuming the title is the same, so verify that
+ 'page_namespace' => $this->getTitle()->getNamespace(),
+ 'page_title' => $this->getTitle()->getDBkey()
+ ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ }
+
+ /**
+ * Do some database updates after deletion
+ *
+ * @param int $id The page_id value of the page being deleted
+ * @param Content|null $content Optional page content to be used when determining
+ * the required updates. This may be needed because $this->getContent()
+ * may already return null when the page proper was deleted.
+ * @param Revision|null $revision The latest page revision
+ */
+ public function doDeleteUpdates( $id, Content $content = null, Revision $revision = null ) {
+ try {
+ $countable = $this->isCountable();
+ } catch ( Exception $ex ) {
+ // fallback for deleting broken pages for which we cannot load the content for
+ // some reason. Note that doDeleteArticleReal() already logged this problem.
+ $countable = false;
+ }
+
+ // Update site status
+ DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 1, - (int)$countable, -1 ) );
+
+ // Delete pagelinks, update secondary indexes, etc
+ $updates = $this->getDeletionUpdates( $content );
+ foreach ( $updates as $update ) {
+ DeferredUpdates::addUpdate( $update );
+ }
+
+ // Reparse any pages transcluding this page
+ LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'templatelinks' );
+
+ // Reparse any pages including this image
+ if ( $this->mTitle->getNamespace() == NS_FILE ) {
+ LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'imagelinks' );
+ }
+
+ // Clear caches
+ self::onArticleDelete( $this->mTitle );
+ ResourceLoaderWikiModule::invalidateModuleCache(
+ $this->mTitle, $revision, null, wfWikiID()
+ );
+
+ // Reset this object and the Title object
+ $this->loadFromRow( false, self::READ_LATEST );
+
+ // Search engine
+ DeferredUpdates::addUpdate( new SearchUpdate( $id, $this->mTitle ) );
+ }
+
+ /**
+ * Roll back the most recent consecutive set of edits to a page
+ * from the same user; fails if there are no eligible edits to
+ * roll back to, e.g. user is the sole contributor. This function
+ * performs permissions checks on $user, then calls commitRollback()
+ * to do the dirty work
+ *
+ * @todo Separate the business/permission stuff out from backend code
+ * @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback.
+ *
+ * @param string $fromP Name of the user whose edits to rollback.
+ * @param string $summary Custom summary. Set to default summary if empty.
+ * @param string $token Rollback token.
+ * @param bool $bot If true, mark all reverted edits as bot.
+ *
+ * @param array &$resultDetails Array contains result-specific array of additional values
+ * 'alreadyrolled' : 'current' (rev)
+ * success : 'summary' (str), 'current' (rev), 'target' (rev)
+ *
+ * @param User $user The user performing the rollback
+ * @param array|null $tags Change tags to apply to the rollback
+ * Callers are responsible for permission checks
+ * (with ChangeTags::canAddTagsAccompanyingChange)
+ *
+ * @return array Array of errors, each error formatted as
+ * array(messagekey, param1, param2, ...).
+ * On success, the array is empty. This array can also be passed to
+ * OutputPage::showPermissionsErrorPage().
+ */
+ public function doRollback(
+ $fromP, $summary, $token, $bot, &$resultDetails, User $user, $tags = null
+ ) {
+ $resultDetails = null;
+
+ // Check permissions
+ $editErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user );
+ $rollbackErrors = $this->mTitle->getUserPermissionsErrors( 'rollback', $user );
+ $errors = array_merge( $editErrors, wfArrayDiff2( $rollbackErrors, $editErrors ) );
+
+ if ( !$user->matchEditToken( $token, 'rollback' ) ) {
+ $errors[] = [ 'sessionfailure' ];
+ }
+
+ if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
+ $errors[] = [ 'actionthrottledtext' ];
+ }
+
+ // If there were errors, bail out now
+ if ( !empty( $errors ) ) {
+ return $errors;
+ }
+
+ return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $user, $tags );
+ }
+
+ /**
+ * Backend implementation of doRollback(), please refer there for parameter
+ * and return value documentation
+ *
+ * NOTE: This function does NOT check ANY permissions, it just commits the
+ * rollback to the DB. Therefore, you should only call this function direct-
+ * ly if you want to use custom permissions checks. If you don't, use
+ * doRollback() instead.
+ * @param string $fromP Name of the user whose edits to rollback.
+ * @param string $summary Custom summary. Set to default summary if empty.
+ * @param bool $bot If true, mark all reverted edits as bot.
+ *
+ * @param array &$resultDetails Contains result-specific array of additional values
+ * @param User $guser The user performing the rollback
+ * @param array|null $tags Change tags to apply to the rollback
+ * Callers are responsible for permission checks
+ * (with ChangeTags::canAddTagsAccompanyingChange)
+ *
+ * @return array
+ */
+ public function commitRollback( $fromP, $summary, $bot,
+ &$resultDetails, User $guser, $tags = null
+ ) {
+ global $wgUseRCPatrol, $wgContLang;
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ if ( wfReadOnly() ) {
+ return [ [ 'readonlytext' ] ];
+ }
+
+ // Get the last editor
+ $current = $this->getRevision();
+ if ( is_null( $current ) ) {
+ // Something wrong... no page?
+ return [ [ 'notanarticle' ] ];
+ }
+
+ $from = str_replace( '_', ' ', $fromP );
+ // User name given should match up with the top revision.
+ // If the user was deleted then $from should be empty.
+ if ( $from != $current->getUserText() ) {
+ $resultDetails = [ 'current' => $current ];
+ return [ [ 'alreadyrolled',
+ htmlspecialchars( $this->mTitle->getPrefixedText() ),
+ htmlspecialchars( $fromP ),
+ htmlspecialchars( $current->getUserText() )
+ ] ];
+ }
+
+ // Get the last edit not by this person...
+ // Note: these may not be public values
+ $user = intval( $current->getUser( Revision::RAW ) );
+ $user_text = $dbw->addQuotes( $current->getUserText( Revision::RAW ) );
+ $s = $dbw->selectRow( 'revision',
+ [ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
+ [ 'rev_page' => $current->getPage(),
+ "rev_user != {$user} OR rev_user_text != {$user_text}"
+ ], __METHOD__,
+ [ 'USE INDEX' => 'page_timestamp',
+ 'ORDER BY' => 'rev_timestamp DESC' ]
+ );
+ if ( $s === false ) {
+ // No one else ever edited this page
+ return [ [ 'cantrollback' ] ];
+ } elseif ( $s->rev_deleted & Revision::DELETED_TEXT
+ || $s->rev_deleted & Revision::DELETED_USER
+ ) {
+ // Only admins can see this text
+ return [ [ 'notvisiblerev' ] ];
+ }
+
+ // Generate the edit summary if necessary
+ $target = Revision::newFromId( $s->rev_id, Revision::READ_LATEST );
+ if ( empty( $summary ) ) {
+ if ( $from == '' ) { // no public user name
+ $summary = wfMessage( 'revertpage-nouser' );
+ } else {
+ $summary = wfMessage( 'revertpage' );
+ }
+ }
+
+ // Allow the custom summary to use the same args as the default message
+ $args = [
+ $target->getUserText(), $from, $s->rev_id,
+ $wgContLang->timeanddate( wfTimestamp( TS_MW, $s->rev_timestamp ) ),
+ $current->getId(), $wgContLang->timeanddate( $current->getTimestamp() )
+ ];
+ if ( $summary instanceof Message ) {
+ $summary = $summary->params( $args )->inContentLanguage()->text();
+ } else {
+ $summary = wfMsgReplaceArgs( $summary, $args );
+ }
+
+ // Trim spaces on user supplied text
+ $summary = trim( $summary );
+
+ // Save
+ $flags = EDIT_UPDATE | EDIT_INTERNAL;
+
+ if ( $guser->isAllowed( 'minoredit' ) ) {
+ $flags |= EDIT_MINOR;
+ }
+
+ if ( $bot && ( $guser->isAllowedAny( 'markbotedits', 'bot' ) ) ) {
+ $flags |= EDIT_FORCE_BOT;
+ }
+
+ $targetContent = $target->getContent();
+ $changingContentModel = $targetContent->getModel() !== $current->getContentModel();
+
+ // Actually store the edit
+ $status = $this->doEditContent(
+ $targetContent,
+ $summary,
+ $flags,
+ $target->getId(),
+ $guser,
+ null,
+ $tags
+ );
+
+ // Set patrolling and bot flag on the edits, which gets rollbacked.
+ // This is done even on edit failure to have patrolling in that case (T64157).
+ $set = [];
+ if ( $bot && $guser->isAllowed( 'markbotedits' ) ) {
+ // Mark all reverted edits as bot
+ $set['rc_bot'] = 1;
+ }
+
+ if ( $wgUseRCPatrol ) {
+ // Mark all reverted edits as patrolled
+ $set['rc_patrolled'] = 1;
+ }
+
+ if ( count( $set ) ) {
+ $dbw->update( 'recentchanges', $set,
+ [ /* WHERE */
+ 'rc_cur_id' => $current->getPage(),
+ 'rc_user_text' => $current->getUserText(),
+ 'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ),
+ ],
+ __METHOD__
+ );
+ }
+
+ if ( !$status->isOK() ) {
+ return $status->getErrorsArray();
+ }
+
+ // raise error, when the edit is an edit without a new version
+ $statusRev = isset( $status->value['revision'] )
+ ? $status->value['revision']
+ : null;
+ if ( !( $statusRev instanceof Revision ) ) {
+ $resultDetails = [ 'current' => $current ];
+ return [ [ 'alreadyrolled',
+ htmlspecialchars( $this->mTitle->getPrefixedText() ),
+ htmlspecialchars( $fromP ),
+ htmlspecialchars( $current->getUserText() )
+ ] ];
+ }
+
+ if ( $changingContentModel ) {
+ // If the content model changed during the rollback,
+ // make sure it gets logged to Special:Log/contentmodel
+ $log = new ManualLogEntry( 'contentmodel', 'change' );
+ $log->setPerformer( $guser );
+ $log->setTarget( $this->mTitle );
+ $log->setComment( $summary );
+ $log->setParameters( [
+ '4::oldmodel' => $current->getContentModel(),
+ '5::newmodel' => $targetContent->getModel(),
+ ] );
+
+ $logId = $log->insert( $dbw );
+ $log->publish( $logId );
+ }
+
+ $revId = $statusRev->getId();
+
+ Hooks::run( 'ArticleRollbackComplete', [ $this, $guser, $target, $current ] );
+
+ $resultDetails = [
+ 'summary' => $summary,
+ 'current' => $current,
+ 'target' => $target,
+ 'newid' => $revId
+ ];
+
+ return [];
+ }
+
+ /**
+ * The onArticle*() functions are supposed to be a kind of hooks
+ * which should be called whenever any of the specified actions
+ * are done.
+ *
+ * This is a good place to put code to clear caches, for instance.
+ *
+ * This is called on page move and undelete, as well as edit
+ *
+ * @param Title $title
+ */
+ public static function onArticleCreate( Title $title ) {
+ // Update existence markers on article/talk tabs...
+ $other = $title->getOtherPage();
+
+ $other->purgeSquid();
+
+ $title->touchLinks();
+ $title->purgeSquid();
+ $title->deleteTitleProtection();
+
+ MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
+
+ // Invalidate caches of articles which include this page
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'templatelinks' ) );
+
+ if ( $title->getNamespace() == NS_CATEGORY ) {
+ // Load the Category object, which will schedule a job to create
+ // the category table row if necessary. Checking a replica DB is ok
+ // here, in the worst case it'll run an unnecessary recount job on
+ // a category that probably doesn't have many members.
+ Category::newFromTitle( $title )->getID();
+ }
+ }
+
+ /**
+ * Clears caches when article is deleted
+ *
+ * @param Title $title
+ */
+ public static function onArticleDelete( Title $title ) {
+ // Update existence markers on article/talk tabs...
+ $other = $title->getOtherPage();
+
+ $other->purgeSquid();
+
+ $title->touchLinks();
+ $title->purgeSquid();
+
+ MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
+
+ // File cache
+ HTMLFileCache::clearFileCache( $title );
+ InfoAction::invalidateCache( $title );
+
+ // Messages
+ if ( $title->getNamespace() == NS_MEDIAWIKI ) {
+ MessageCache::singleton()->updateMessageOverride( $title, null );
+ }
+
+ // Images
+ if ( $title->getNamespace() == NS_FILE ) {
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'imagelinks' ) );
+ }
+
+ // User talk pages
+ if ( $title->getNamespace() == NS_USER_TALK ) {
+ $user = User::newFromName( $title->getText(), false );
+ if ( $user ) {
+ $user->setNewtalk( false );
+ }
+ }
+
+ // Image redirects
+ RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $title );
+ }
+
+ /**
+ * Purge caches on page update etc
+ *
+ * @param Title $title
+ * @param Revision|null $revision Revision that was just saved, may be null
+ */
+ public static function onArticleEdit( Title $title, Revision $revision = null ) {
+ // Invalidate caches of articles which include this page
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'templatelinks' ) );
+
+ // Invalidate the caches of all pages which redirect here
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $title, 'redirect' ) );
+
+ MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
+
+ // Purge CDN for this page only
+ $title->purgeSquid();
+ // Clear file cache for this page only
+ HTMLFileCache::clearFileCache( $title );
+
+ $revid = $revision ? $revision->getId() : null;
+ DeferredUpdates::addCallableUpdate( function () use ( $title, $revid ) {
+ InfoAction::invalidateCache( $title, $revid );
+ } );
+ }
+
+ /**#@-*/
+
+ /**
+ * Returns a list of categories this page is a member of.
+ * Results will include hidden categories
+ *
+ * @return TitleArray
+ */
+ public function getCategories() {
+ $id = $this->getId();
+ if ( $id == 0 ) {
+ return TitleArray::newFromResult( new FakeResultWrapper( [] ) );
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'categorylinks',
+ [ 'cl_to AS page_title, ' . NS_CATEGORY . ' AS page_namespace' ],
+ // Have to do that since Database::fieldNamesWithAlias treats numeric indexes
+ // as not being aliases, and NS_CATEGORY is numeric
+ [ 'cl_from' => $id ],
+ __METHOD__ );
+
+ return TitleArray::newFromResult( $res );
+ }
+
+ /**
+ * Returns a list of hidden categories this page is a member of.
+ * Uses the page_props and categorylinks tables.
+ *
+ * @return array Array of Title objects
+ */
+ public function getHiddenCategories() {
+ $result = [];
+ $id = $this->getId();
+
+ if ( $id == 0 ) {
+ return [];
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( [ 'categorylinks', 'page_props', 'page' ],
+ [ 'cl_to' ],
+ [ 'cl_from' => $id, 'pp_page=page_id', 'pp_propname' => 'hiddencat',
+ 'page_namespace' => NS_CATEGORY, 'page_title=cl_to' ],
+ __METHOD__ );
+
+ if ( $res !== false ) {
+ foreach ( $res as $row ) {
+ $result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Auto-generates a deletion reason
+ *
+ * @param bool &$hasHistory Whether the page has a history
+ * @return string|bool String containing deletion reason or empty string, or boolean false
+ * if no revision occurred
+ */
+ public function getAutoDeleteReason( &$hasHistory ) {
+ return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory );
+ }
+
+ /**
+ * Update all the appropriate counts in the category table, given that
+ * we've added the categories $added and deleted the categories $deleted.
+ *
+ * This should only be called from deferred updates or jobs to avoid contention.
+ *
+ * @param array $added The names of categories that were added
+ * @param array $deleted The names of categories that were deleted
+ * @param int $id Page ID (this should be the original deleted page ID)
+ */
+ public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
+ $id = $id ?: $this->getId();
+ $ns = $this->getTitle()->getNamespace();
+
+ $addFields = [ 'cat_pages = cat_pages + 1' ];
+ $removeFields = [ 'cat_pages = cat_pages - 1' ];
+ if ( $ns == NS_CATEGORY ) {
+ $addFields[] = 'cat_subcats = cat_subcats + 1';
+ $removeFields[] = 'cat_subcats = cat_subcats - 1';
+ } elseif ( $ns == NS_FILE ) {
+ $addFields[] = 'cat_files = cat_files + 1';
+ $removeFields[] = 'cat_files = cat_files - 1';
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ if ( count( $added ) ) {
+ $existingAdded = $dbw->selectFieldValues(
+ 'category',
+ 'cat_title',
+ [ 'cat_title' => $added ],
+ __METHOD__
+ );
+
+ // For category rows that already exist, do a plain
+ // UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE
+ // to avoid creating gaps in the cat_id sequence.
+ if ( count( $existingAdded ) ) {
+ $dbw->update(
+ 'category',
+ $addFields,
+ [ 'cat_title' => $existingAdded ],
+ __METHOD__
+ );
+ }
+
+ $missingAdded = array_diff( $added, $existingAdded );
+ if ( count( $missingAdded ) ) {
+ $insertRows = [];
+ foreach ( $missingAdded as $cat ) {
+ $insertRows[] = [
+ 'cat_title' => $cat,
+ 'cat_pages' => 1,
+ 'cat_subcats' => ( $ns == NS_CATEGORY ) ? 1 : 0,
+ 'cat_files' => ( $ns == NS_FILE ) ? 1 : 0,
+ ];
+ }
+ $dbw->upsert(
+ 'category',
+ $insertRows,
+ [ 'cat_title' ],
+ $addFields,
+ __METHOD__
+ );
+ }
+ }
+
+ if ( count( $deleted ) ) {
+ $dbw->update(
+ 'category',
+ $removeFields,
+ [ 'cat_title' => $deleted ],
+ __METHOD__
+ );
+ }
+
+ foreach ( $added as $catName ) {
+ $cat = Category::newFromName( $catName );
+ Hooks::run( 'CategoryAfterPageAdded', [ $cat, $this ] );
+ }
+
+ foreach ( $deleted as $catName ) {
+ $cat = Category::newFromName( $catName );
+ Hooks::run( 'CategoryAfterPageRemoved', [ $cat, $this, $id ] );
+ }
+
+ // Refresh counts on categories that should be empty now, to
+ // trigger possible deletion. Check master for the most
+ // up-to-date cat_pages.
+ if ( count( $deleted ) ) {
+ $rows = $dbw->select(
+ 'category',
+ [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
+ [ 'cat_title' => $deleted, 'cat_pages <= 0' ],
+ __METHOD__
+ );
+ foreach ( $rows as $row ) {
+ $cat = Category::newFromRow( $row );
+ // T166757: do the update after this DB commit
+ DeferredUpdates::addCallableUpdate( function () use ( $cat ) {
+ $cat->refreshCounts();
+ } );
+ }
+ }
+ }
+
+ /**
+ * Opportunistically enqueue link update jobs given fresh parser output if useful
+ *
+ * @param ParserOutput $parserOutput Current version page output
+ * @since 1.25
+ */
+ public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ if ( !Hooks::run( 'OpportunisticLinksUpdate',
+ [ $this, $this->mTitle, $parserOutput ]
+ ) ) {
+ return;
+ }
+
+ $config = RequestContext::getMain()->getConfig();
+
+ $params = [
+ 'isOpportunistic' => true,
+ 'rootJobTimestamp' => $parserOutput->getCacheTime()
+ ];
+
+ if ( $this->mTitle->areRestrictionsCascading() ) {
+ // If the page is cascade protecting, the links should really be up-to-date
+ JobQueueGroup::singleton()->lazyPush(
+ RefreshLinksJob::newPrioritized( $this->mTitle, $params )
+ );
+ } elseif ( !$config->get( 'MiserMode' ) && $parserOutput->hasDynamicContent() ) {
+ // Assume the output contains "dynamic" time/random based magic words.
+ // Only update pages that expired due to dynamic content and NOT due to edits
+ // to referenced templates/files. When the cache expires due to dynamic content,
+ // page_touched is unchanged. We want to avoid triggering redundant jobs due to
+ // views of pages that were just purged via HTMLCacheUpdateJob. In that case, the
+ // template/file edit already triggered recursive RefreshLinksJob jobs.
+ if ( $this->getLinksTimestamp() > $this->getTouched() ) {
+ // If a page is uncacheable, do not keep spamming a job for it.
+ // Although it would be de-duplicated, it would still waste I/O.
+ $cache = ObjectCache::getLocalClusterInstance();
+ $key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() );
+ $ttl = max( $parserOutput->getCacheExpiry(), 3600 );
+ if ( $cache->add( $key, time(), $ttl ) ) {
+ JobQueueGroup::singleton()->lazyPush(
+ RefreshLinksJob::newDynamic( $this->mTitle, $params )
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns a list of updates to be performed when this page is deleted. The
+ * updates should remove any information about this page from secondary data
+ * stores such as links tables.
+ *
+ * @param Content|null $content Optional Content object for determining the
+ * necessary updates.
+ * @return DeferrableUpdate[]
+ */
+ public function getDeletionUpdates( Content $content = null ) {
+ if ( !$content ) {
+ // load content object, which may be used to determine the necessary updates.
+ // XXX: the content may not be needed to determine the updates.
+ try {
+ $content = $this->getContent( Revision::RAW );
+ } catch ( Exception $ex ) {
+ // If we can't load the content, something is wrong. Perhaps that's why
+ // the user is trying to delete the page, so let's not fail in that case.
+ // Note that doDeleteArticleReal() will already have logged an issue with
+ // loading the content.
+ }
+ }
+
+ if ( !$content ) {
+ $updates = [];
+ } else {
+ $updates = $content->getDeletionUpdates( $this );
+ }
+
+ Hooks::run( 'WikiPageDeletionUpdates', [ $this, $content, &$updates ] );
+ return $updates;
+ }
+
+ /**
+ * Whether this content displayed on this page
+ * comes from the local database
+ *
+ * @since 1.28
+ * @return bool
+ */
+ public function isLocal() {
+ return true;
+ }
+
+ /**
+ * The display name for the site this content
+ * come from. If a subclass overrides isLocal(),
+ * this could return something other than the
+ * current site name
+ *
+ * @since 1.28
+ * @return string
+ */
+ public function getWikiDisplayName() {
+ global $wgSitename;
+ return $wgSitename;
+ }
+
+ /**
+ * Get the source URL for the content on this page,
+ * typically the canonical URL, but may be a remote
+ * link if the content comes from another site
+ *
+ * @since 1.28
+ * @return string
+ */
+ public function getSourceURL() {
+ return $this->getTitle()->getCanonicalURL();
+ }
+
+ /**
+ * @param WANObjectCache $cache
+ * @return string[]
+ * @since 1.28
+ */
+ public function getMutableCacheKeys( WANObjectCache $cache ) {
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+
+ return $linkCache->getMutableCacheKeys( $cache, $this->getTitle()->getTitleValue() );
+ }
+}
diff --git a/www/wiki/includes/pager/AlphabeticPager.php b/www/wiki/includes/pager/AlphabeticPager.php
new file mode 100644
index 00000000..54036eb8
--- /dev/null
+++ b/www/wiki/includes/pager/AlphabeticPager.php
@@ -0,0 +1,108 @@
+<?php
+/**
+ * Efficient paging for SQL queries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * IndexPager with an alphabetic list and a formatted navigation bar
+ * @ingroup Pager
+ */
+abstract class AlphabeticPager extends IndexPager {
+
+ /**
+ * Shamelessly stolen bits from ReverseChronologicalPager,
+ * didn't want to do class magic as may be still revamped
+ *
+ * @return string HTML
+ */
+ function getNavigationBar() {
+ if ( !$this->isNavigationBarShown() ) {
+ return '';
+ }
+
+ if ( isset( $this->mNavigationBar ) ) {
+ return $this->mNavigationBar;
+ }
+
+ $linkTexts = [
+ 'prev' => $this->msg( 'prevn' )->numParams( $this->mLimit )->escaped(),
+ 'next' => $this->msg( 'nextn' )->numParams( $this->mLimit )->escaped(),
+ 'first' => $this->msg( 'page_first' )->escaped(),
+ 'last' => $this->msg( 'page_last' )->escaped()
+ ];
+
+ $lang = $this->getLanguage();
+
+ $pagingLinks = $this->getPagingLinks( $linkTexts );
+ $limitLinks = $this->getLimitLinks();
+ $limits = $lang->pipeList( $limitLinks );
+
+ $this->mNavigationBar = $this->msg( 'parentheses' )->rawParams(
+ $lang->pipeList( [ $pagingLinks['first'],
+ $pagingLinks['last'] ] ) )->escaped() . " " .
+ $this->msg( 'viewprevnext' )->rawParams( $pagingLinks['prev'],
+ $pagingLinks['next'], $limits )->escaped();
+
+ if ( !is_array( $this->getIndexField() ) ) {
+ # Early return to avoid undue nesting
+ return $this->mNavigationBar;
+ }
+
+ $extra = '';
+ $first = true;
+ $msgs = $this->getOrderTypeMessages();
+ foreach ( array_keys( $msgs ) as $order ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $extra .= $this->msg( 'pipe-separator' )->escaped();
+ }
+
+ if ( $order == $this->mOrderType ) {
+ $extra .= $this->msg( $msgs[$order] )->escaped();
+ } else {
+ $extra .= $this->makeLink(
+ $this->msg( $msgs[$order] )->escaped(),
+ [ 'order' => $order ]
+ );
+ }
+ }
+
+ if ( $extra !== '' ) {
+ $extra = ' ' . $this->msg( 'parentheses' )->rawParams( $extra )->escaped();
+ $this->mNavigationBar .= $extra;
+ }
+
+ return $this->mNavigationBar;
+ }
+
+ /**
+ * If this supports multiple order type messages, give the message key for
+ * enabling each one in getNavigationBar. The return type is an associative
+ * array whose keys must exactly match the keys of the array returned
+ * by getIndexField(), and whose values are message keys.
+ *
+ * @return array
+ */
+ protected function getOrderTypeMessages() {
+ return null;
+ }
+}
diff --git a/www/wiki/includes/pager/IndexPager.php b/www/wiki/includes/pager/IndexPager.php
new file mode 100644
index 00000000..6620c475
--- /dev/null
+++ b/www/wiki/includes/pager/IndexPager.php
@@ -0,0 +1,742 @@
+<?php
+/**
+ * Efficient paging for SQL queries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * IndexPager is an efficient pager which uses a (roughly unique) index in the
+ * data set to implement paging, rather than a "LIMIT offset,limit" clause.
+ * In MySQL, such a limit/offset clause requires counting through the
+ * specified number of offset rows to find the desired data, which can be
+ * expensive for large offsets.
+ *
+ * ReverseChronologicalPager is a child class of the abstract IndexPager, and
+ * contains some formatting and display code which is specific to the use of
+ * timestamps as indexes. Here is a synopsis of its operation:
+ *
+ * * The query is specified by the offset, limit and direction (dir)
+ * parameters, in addition to any subclass-specific parameters.
+ * * The offset is the non-inclusive start of the DB query. A row with an
+ * index value equal to the offset will never be shown.
+ * * The query may either be done backwards, where the rows are returned by
+ * the database in the opposite order to which they are displayed to the
+ * user, or forwards. This is specified by the "dir" parameter, dir=prev
+ * means backwards, anything else means forwards. The offset value
+ * specifies the start of the database result set, which may be either
+ * the start or end of the displayed data set. This allows "previous"
+ * links to be implemented without knowledge of the index value at the
+ * start of the previous page.
+ * * An additional row beyond the user-specified limit is always requested.
+ * This allows us to tell whether we should display a "next" link in the
+ * case of forwards mode, or a "previous" link in the case of backwards
+ * mode. Determining whether to display the other link (the one for the
+ * page before the start of the database result set) can be done
+ * heuristically by examining the offset.
+ *
+ * * An empty offset indicates that the offset condition should be omitted
+ * from the query. This naturally produces either the first page or the
+ * last page depending on the dir parameter.
+ *
+ * Subclassing the pager to implement concrete functionality should be fairly
+ * simple, please see the examples in HistoryAction.php and
+ * SpecialBlockList.php. You just need to override formatRow(),
+ * getQueryInfo() and getIndexField(). Don't forget to call the parent
+ * constructor if you override it.
+ *
+ * @ingroup Pager
+ */
+abstract class IndexPager extends ContextSource implements Pager {
+ /**
+ * Constants for the $mDefaultDirection field.
+ *
+ * These are boolean for historical reasons and should stay boolean for backwards-compatibility.
+ */
+ const DIR_ASCENDING = false;
+ const DIR_DESCENDING = true;
+
+ public $mRequest;
+ public $mLimitsShown = [ 20, 50, 100, 250, 500 ];
+ public $mDefaultLimit = 50;
+ public $mOffset, $mLimit;
+ public $mQueryDone = false;
+ public $mDb;
+ public $mPastTheEndRow;
+
+ /**
+ * The index to actually be used for ordering. This is a single column,
+ * for one ordering, even if multiple orderings are supported.
+ */
+ protected $mIndexField;
+ /**
+ * An array of secondary columns to order by. These fields are not part of the offset.
+ * This is a column list for one ordering, even if multiple orderings are supported.
+ */
+ protected $mExtraSortFields;
+ /** For pages that support multiple types of ordering, which one to use.
+ */
+ protected $mOrderType;
+ /**
+ * $mDefaultDirection gives the direction to use when sorting results:
+ * DIR_ASCENDING or DIR_DESCENDING. If $mIsBackwards is set, we
+ * start from the opposite end, but we still sort the page itself according
+ * to $mDefaultDirection. E.g., if $mDefaultDirection is false but we're
+ * going backwards, we'll display the last page of results, but the last
+ * result will be at the bottom, not the top.
+ *
+ * Like $mIndexField, $mDefaultDirection will be a single value even if the
+ * class supports multiple default directions for different order types.
+ */
+ public $mDefaultDirection;
+ public $mIsBackwards;
+
+ /** True if the current result set is the first one */
+ public $mIsFirst;
+ public $mIsLast;
+
+ protected $mLastShown, $mFirstShown, $mPastTheEndIndex, $mDefaultQuery, $mNavigationBar;
+
+ /**
+ * Whether to include the offset in the query
+ */
+ protected $mIncludeOffset = false;
+
+ /**
+ * Result object for the query. Warning: seek before use.
+ *
+ * @var ResultWrapper
+ */
+ public $mResult;
+
+ public function __construct( IContextSource $context = null ) {
+ if ( $context ) {
+ $this->setContext( $context );
+ }
+
+ $this->mRequest = $this->getRequest();
+
+ # NB: the offset is quoted, not validated. It is treated as an
+ # arbitrary string to support the widest variety of index types. Be
+ # careful outputting it into HTML!
+ $this->mOffset = $this->mRequest->getText( 'offset' );
+
+ # Use consistent behavior for the limit options
+ $this->mDefaultLimit = $this->getUser()->getIntOption( 'rclimit' );
+ if ( !$this->mLimit ) {
+ // Don't override if a subclass calls $this->setLimit() in its constructor.
+ list( $this->mLimit, /* $offset */ ) = $this->mRequest->getLimitOffset();
+ }
+
+ $this->mIsBackwards = ( $this->mRequest->getVal( 'dir' ) == 'prev' );
+ # Let the subclass set the DB here; otherwise use a replica DB for the current wiki
+ $this->mDb = $this->mDb ?: wfGetDB( DB_REPLICA );
+
+ $index = $this->getIndexField(); // column to sort on
+ $extraSort = $this->getExtraSortFields(); // extra columns to sort on for query planning
+ $order = $this->mRequest->getVal( 'order' );
+ if ( is_array( $index ) && isset( $index[$order] ) ) {
+ $this->mOrderType = $order;
+ $this->mIndexField = $index[$order];
+ $this->mExtraSortFields = isset( $extraSort[$order] )
+ ? (array)$extraSort[$order]
+ : [];
+ } elseif ( is_array( $index ) ) {
+ # First element is the default
+ reset( $index );
+ list( $this->mOrderType, $this->mIndexField ) = each( $index );
+ $this->mExtraSortFields = isset( $extraSort[$this->mOrderType] )
+ ? (array)$extraSort[$this->mOrderType]
+ : [];
+ } else {
+ # $index is not an array
+ $this->mOrderType = null;
+ $this->mIndexField = $index;
+ $this->mExtraSortFields = (array)$extraSort;
+ }
+
+ if ( !isset( $this->mDefaultDirection ) ) {
+ $dir = $this->getDefaultDirections();
+ $this->mDefaultDirection = is_array( $dir )
+ ? $dir[$this->mOrderType]
+ : $dir;
+ }
+ }
+
+ /**
+ * Get the Database object in use
+ *
+ * @return IDatabase
+ */
+ public function getDatabase() {
+ return $this->mDb;
+ }
+
+ /**
+ * Do the query, using information from the object context. This function
+ * has been kept minimal to make it overridable if necessary, to allow for
+ * result sets formed from multiple DB queries.
+ */
+ public function doQuery() {
+ # Use the child class name for profiling
+ $fname = __METHOD__ . ' (' . static::class . ')';
+ $section = Profiler::instance()->scopedProfileIn( $fname );
+
+ // @todo This should probably compare to DIR_DESCENDING and DIR_ASCENDING constants
+ $descending = ( $this->mIsBackwards == $this->mDefaultDirection );
+ # Plus an extra row so that we can tell the "next" link should be shown
+ $queryLimit = $this->mLimit + 1;
+
+ if ( $this->mOffset == '' ) {
+ $isFirst = true;
+ } else {
+ // If there's an offset, we may or may not be at the first entry.
+ // The only way to tell is to run the query in the opposite
+ // direction see if we get a row.
+ $oldIncludeOffset = $this->mIncludeOffset;
+ $this->mIncludeOffset = !$this->mIncludeOffset;
+ $isFirst = !$this->reallyDoQuery( $this->mOffset, 1, !$descending )->numRows();
+ $this->mIncludeOffset = $oldIncludeOffset;
+ }
+
+ $this->mResult = $this->reallyDoQuery(
+ $this->mOffset,
+ $queryLimit,
+ $descending
+ );
+
+ $this->extractResultInfo( $isFirst, $queryLimit, $this->mResult );
+ $this->mQueryDone = true;
+
+ $this->preprocessResults( $this->mResult );
+ $this->mResult->rewind(); // Paranoia
+ }
+
+ /**
+ * @return ResultWrapper The result wrapper.
+ */
+ function getResult() {
+ return $this->mResult;
+ }
+
+ /**
+ * Set the offset from an other source than the request
+ *
+ * @param int|string $offset
+ */
+ function setOffset( $offset ) {
+ $this->mOffset = $offset;
+ }
+
+ /**
+ * Set the limit from an other source than the request
+ *
+ * Verifies limit is between 1 and 5000
+ *
+ * @param int|string $limit
+ */
+ function setLimit( $limit ) {
+ $limit = (int)$limit;
+ // WebRequest::getLimitOffset() puts a cap of 5000, so do same here.
+ if ( $limit > 5000 ) {
+ $limit = 5000;
+ }
+ if ( $limit > 0 ) {
+ $this->mLimit = $limit;
+ }
+ }
+
+ /**
+ * Get the current limit
+ *
+ * @return int
+ */
+ function getLimit() {
+ return $this->mLimit;
+ }
+
+ /**
+ * Set whether a row matching exactly the offset should be also included
+ * in the result or not. By default this is not the case, but when the
+ * offset is user-supplied this might be wanted.
+ *
+ * @param bool $include
+ */
+ public function setIncludeOffset( $include ) {
+ $this->mIncludeOffset = $include;
+ }
+
+ /**
+ * Extract some useful data from the result object for use by
+ * the navigation bar, put it into $this
+ *
+ * @param bool $isFirst False if there are rows before those fetched (i.e.
+ * if a "previous" link would make sense)
+ * @param int $limit Exact query limit
+ * @param ResultWrapper $res
+ */
+ function extractResultInfo( $isFirst, $limit, ResultWrapper $res ) {
+ $numRows = $res->numRows();
+ if ( $numRows ) {
+ # Remove any table prefix from index field
+ $parts = explode( '.', $this->mIndexField );
+ $indexColumn = end( $parts );
+
+ $row = $res->fetchRow();
+ $firstIndex = $row[$indexColumn];
+
+ # Discard the extra result row if there is one
+ if ( $numRows > $this->mLimit && $numRows > 1 ) {
+ $res->seek( $numRows - 1 );
+ $this->mPastTheEndRow = $res->fetchObject();
+ $this->mPastTheEndIndex = $this->mPastTheEndRow->$indexColumn;
+ $res->seek( $numRows - 2 );
+ $row = $res->fetchRow();
+ $lastIndex = $row[$indexColumn];
+ } else {
+ $this->mPastTheEndRow = null;
+ # Setting indexes to an empty string means that they will be
+ # omitted if they would otherwise appear in URLs. It just so
+ # happens that this is the right thing to do in the standard
+ # UI, in all the relevant cases.
+ $this->mPastTheEndIndex = '';
+ $res->seek( $numRows - 1 );
+ $row = $res->fetchRow();
+ $lastIndex = $row[$indexColumn];
+ }
+ } else {
+ $firstIndex = '';
+ $lastIndex = '';
+ $this->mPastTheEndRow = null;
+ $this->mPastTheEndIndex = '';
+ }
+
+ if ( $this->mIsBackwards ) {
+ $this->mIsFirst = ( $numRows < $limit );
+ $this->mIsLast = $isFirst;
+ $this->mLastShown = $firstIndex;
+ $this->mFirstShown = $lastIndex;
+ } else {
+ $this->mIsFirst = $isFirst;
+ $this->mIsLast = ( $numRows < $limit );
+ $this->mLastShown = $lastIndex;
+ $this->mFirstShown = $firstIndex;
+ }
+ }
+
+ /**
+ * Get some text to go in brackets in the "function name" part of the SQL comment
+ *
+ * @return string
+ */
+ function getSqlComment() {
+ return static::class;
+ }
+
+ /**
+ * Do a query with specified parameters, rather than using the object
+ * context
+ *
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return ResultWrapper
+ */
+ public function reallyDoQuery( $offset, $limit, $descending ) {
+ list( $tables, $fields, $conds, $fname, $options, $join_conds ) =
+ $this->buildQueryInfo( $offset, $limit, $descending );
+
+ return $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
+ }
+
+ /**
+ * Build variables to use by the database wrapper.
+ *
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return array
+ */
+ protected function buildQueryInfo( $offset, $limit, $descending ) {
+ $fname = __METHOD__ . ' (' . $this->getSqlComment() . ')';
+ $info = $this->getQueryInfo();
+ $tables = $info['tables'];
+ $fields = $info['fields'];
+ $conds = isset( $info['conds'] ) ? $info['conds'] : [];
+ $options = isset( $info['options'] ) ? $info['options'] : [];
+ $join_conds = isset( $info['join_conds'] ) ? $info['join_conds'] : [];
+ $sortColumns = array_merge( [ $this->mIndexField ], $this->mExtraSortFields );
+ if ( $descending ) {
+ $options['ORDER BY'] = $sortColumns;
+ $operator = $this->mIncludeOffset ? '>=' : '>';
+ } else {
+ $orderBy = [];
+ foreach ( $sortColumns as $col ) {
+ $orderBy[] = $col . ' DESC';
+ }
+ $options['ORDER BY'] = $orderBy;
+ $operator = $this->mIncludeOffset ? '<=' : '<';
+ }
+ if ( $offset != '' ) {
+ $conds[] = $this->mIndexField . $operator . $this->mDb->addQuotes( $offset );
+ }
+ $options['LIMIT'] = intval( $limit );
+ return [ $tables, $fields, $conds, $fname, $options, $join_conds ];
+ }
+
+ /**
+ * Pre-process results; useful for performing batch existence checks, etc.
+ *
+ * @param ResultWrapper $result
+ */
+ protected function preprocessResults( $result ) {
+ }
+
+ /**
+ * Get the formatted result list. Calls getStartBody(), formatRow() and
+ * getEndBody(), concatenates the results and returns them.
+ *
+ * @return string
+ */
+ public function getBody() {
+ if ( !$this->mQueryDone ) {
+ $this->doQuery();
+ }
+
+ if ( $this->mResult->numRows() ) {
+ # Do any special query batches before display
+ $this->doBatchLookups();
+ }
+
+ # Don't use any extra rows returned by the query
+ $numRows = min( $this->mResult->numRows(), $this->mLimit );
+
+ $s = $this->getStartBody();
+ if ( $numRows ) {
+ if ( $this->mIsBackwards ) {
+ for ( $i = $numRows - 1; $i >= 0; $i-- ) {
+ $this->mResult->seek( $i );
+ $row = $this->mResult->fetchObject();
+ $s .= $this->formatRow( $row );
+ }
+ } else {
+ $this->mResult->seek( 0 );
+ for ( $i = 0; $i < $numRows; $i++ ) {
+ $row = $this->mResult->fetchObject();
+ $s .= $this->formatRow( $row );
+ }
+ }
+ } else {
+ $s .= $this->getEmptyBody();
+ }
+ $s .= $this->getEndBody();
+ return $s;
+ }
+
+ /**
+ * Make a self-link
+ *
+ * @param string $text Text displayed on the link
+ * @param array $query Associative array of parameter to be in the query string
+ * @param string $type Link type used to create additional attributes, like "rel", "class" or
+ * "title". Valid values (non-exhaustive list): 'first', 'last', 'prev', 'next', 'asc', 'desc'.
+ * @return string HTML fragment
+ */
+ function makeLink( $text, array $query = null, $type = null ) {
+ if ( $query === null ) {
+ return $text;
+ }
+
+ $attrs = [];
+ if ( in_array( $type, [ 'prev', 'next' ] ) ) {
+ $attrs['rel'] = $type;
+ }
+
+ if ( in_array( $type, [ 'asc', 'desc' ] ) ) {
+ $attrs['title'] = wfMessage( $type == 'asc' ? 'sort-ascending' : 'sort-descending' )->text();
+ }
+
+ if ( $type ) {
+ $attrs['class'] = "mw-{$type}link";
+ }
+
+ return Linker::linkKnown(
+ $this->getTitle(),
+ $text,
+ $attrs,
+ $query + $this->getDefaultQuery()
+ );
+ }
+
+ /**
+ * Called from getBody(), before getStartBody() is called and
+ * after doQuery() was called. This will be called only if there
+ * are rows in the result set.
+ *
+ * @return void
+ */
+ protected function doBatchLookups() {
+ }
+
+ /**
+ * Hook into getBody(), allows text to be inserted at the start. This
+ * will be called even if there are no rows in the result set.
+ *
+ * @return string
+ */
+ protected function getStartBody() {
+ return '';
+ }
+
+ /**
+ * Hook into getBody() for the end of the list
+ *
+ * @return string
+ */
+ protected function getEndBody() {
+ return '';
+ }
+
+ /**
+ * Hook into getBody(), for the bit between the start and the
+ * end when there are no rows
+ *
+ * @return string
+ */
+ protected function getEmptyBody() {
+ return '';
+ }
+
+ /**
+ * Get an array of query parameters that should be put into self-links.
+ * By default, all parameters passed in the URL are used, except for a
+ * short blacklist.
+ *
+ * @return array Associative array
+ */
+ function getDefaultQuery() {
+ if ( !isset( $this->mDefaultQuery ) ) {
+ $this->mDefaultQuery = $this->getRequest()->getQueryValues();
+ unset( $this->mDefaultQuery['title'] );
+ unset( $this->mDefaultQuery['dir'] );
+ unset( $this->mDefaultQuery['offset'] );
+ unset( $this->mDefaultQuery['limit'] );
+ unset( $this->mDefaultQuery['order'] );
+ unset( $this->mDefaultQuery['month'] );
+ unset( $this->mDefaultQuery['year'] );
+ }
+ return $this->mDefaultQuery;
+ }
+
+ /**
+ * Get the number of rows in the result set
+ *
+ * @return int
+ */
+ function getNumRows() {
+ if ( !$this->mQueryDone ) {
+ $this->doQuery();
+ }
+ return $this->mResult->numRows();
+ }
+
+ /**
+ * Get a URL query array for the prev, next, first and last links.
+ *
+ * @return array
+ */
+ function getPagingQueries() {
+ if ( !$this->mQueryDone ) {
+ $this->doQuery();
+ }
+
+ # Don't announce the limit everywhere if it's the default
+ $urlLimit = $this->mLimit == $this->mDefaultLimit ? null : $this->mLimit;
+
+ if ( $this->mIsFirst ) {
+ $prev = false;
+ $first = false;
+ } else {
+ $prev = [
+ 'dir' => 'prev',
+ 'offset' => $this->mFirstShown,
+ 'limit' => $urlLimit
+ ];
+ $first = [ 'limit' => $urlLimit ];
+ }
+ if ( $this->mIsLast ) {
+ $next = false;
+ $last = false;
+ } else {
+ $next = [ 'offset' => $this->mLastShown, 'limit' => $urlLimit ];
+ $last = [ 'dir' => 'prev', 'limit' => $urlLimit ];
+ }
+ return [
+ 'prev' => $prev,
+ 'next' => $next,
+ 'first' => $first,
+ 'last' => $last
+ ];
+ }
+
+ /**
+ * Returns whether to show the "navigation bar"
+ *
+ * @return bool
+ */
+ function isNavigationBarShown() {
+ if ( !$this->mQueryDone ) {
+ $this->doQuery();
+ }
+ // Hide navigation by default if there is nothing to page
+ return !( $this->mIsFirst && $this->mIsLast );
+ }
+
+ /**
+ * Get paging links. If a link is disabled, the item from $disabledTexts
+ * will be used. If there is no such item, the unlinked text from
+ * $linkTexts will be used. Both $linkTexts and $disabledTexts are arrays
+ * of HTML.
+ *
+ * @param array $linkTexts
+ * @param array $disabledTexts
+ * @return array
+ */
+ function getPagingLinks( $linkTexts, $disabledTexts = [] ) {
+ $queries = $this->getPagingQueries();
+ $links = [];
+
+ foreach ( $queries as $type => $query ) {
+ if ( $query !== false ) {
+ $links[$type] = $this->makeLink(
+ $linkTexts[$type],
+ $queries[$type],
+ $type
+ );
+ } elseif ( isset( $disabledTexts[$type] ) ) {
+ $links[$type] = $disabledTexts[$type];
+ } else {
+ $links[$type] = $linkTexts[$type];
+ }
+ }
+
+ return $links;
+ }
+
+ function getLimitLinks() {
+ $links = [];
+ if ( $this->mIsBackwards ) {
+ $offset = $this->mPastTheEndIndex;
+ } else {
+ $offset = $this->mOffset;
+ }
+ foreach ( $this->mLimitsShown as $limit ) {
+ $links[] = $this->makeLink(
+ $this->getLanguage()->formatNum( $limit ),
+ [ 'offset' => $offset, 'limit' => $limit ],
+ 'num'
+ );
+ }
+ return $links;
+ }
+
+ /**
+ * Abstract formatting function. This should return an HTML string
+ * representing the result row $row. Rows will be concatenated and
+ * returned by getBody()
+ *
+ * @param array|stdClass $row Database row
+ * @return string
+ */
+ abstract function formatRow( $row );
+
+ /**
+ * This function should be overridden to provide all parameters
+ * needed for the main paged query. It returns an associative
+ * array with the following elements:
+ * tables => Table(s) for passing to Database::select()
+ * fields => Field(s) for passing to Database::select(), may be *
+ * conds => WHERE conditions
+ * options => option array
+ * join_conds => JOIN conditions
+ *
+ * @return array
+ */
+ abstract function getQueryInfo();
+
+ /**
+ * This function should be overridden to return the name of the index fi-
+ * eld. If the pager supports multiple orders, it may return an array of
+ * 'querykey' => 'indexfield' pairs, so that a request with &count=querykey
+ * will use indexfield to sort. In this case, the first returned key is
+ * the default.
+ *
+ * Needless to say, it's really not a good idea to use a non-unique index
+ * for this! That won't page right.
+ *
+ * @return string|array
+ */
+ abstract function getIndexField();
+
+ /**
+ * This function should be overridden to return the names of secondary columns
+ * to order by in addition to the column in getIndexField(). These fields will
+ * not be used in the pager offset or in any links for users.
+ *
+ * If getIndexField() returns an array of 'querykey' => 'indexfield' pairs then
+ * this must return a corresponding array of 'querykey' => [ fields... ] pairs
+ * in order for a request with &count=querykey to use [ fields... ] to sort.
+ *
+ * This is useful for pagers that GROUP BY a unique column (say page_id)
+ * and ORDER BY another (say page_len). Using GROUP BY and ORDER BY both on
+ * page_len,page_id avoids temp tables (given a page_len index). This would
+ * also work if page_id was non-unique but we had a page_len,page_id index.
+ *
+ * @return array
+ */
+ protected function getExtraSortFields() {
+ return [];
+ }
+
+ /**
+ * Return the default sorting direction: DIR_ASCENDING or DIR_DESCENDING.
+ * You can also have an associative array of ordertype => dir,
+ * if multiple order types are supported. In this case getIndexField()
+ * must return an array, and the keys of that must exactly match the keys
+ * of this.
+ *
+ * For backward compatibility, this method's return value will be ignored
+ * if $this->mDefaultDirection is already set when the constructor is
+ * called, for instance if it's statically initialized. In that case the
+ * value of that variable (which must be a boolean) will be used.
+ *
+ * Note that despite its name, this does not return the value of the
+ * $this->mDefaultDirection member variable. That's the default for this
+ * particular instantiation, which is a single value. This is the set of
+ * all defaults for the class.
+ *
+ * @return bool
+ */
+ protected function getDefaultDirections() {
+ return self::DIR_ASCENDING;
+ }
+}
diff --git a/www/wiki/includes/pager/Pager.php b/www/wiki/includes/pager/Pager.php
new file mode 100644
index 00000000..edec490c
--- /dev/null
+++ b/www/wiki/includes/pager/Pager.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Efficient paging for SQL queries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * @defgroup Pager Pager
+ */
+
+/**
+ * Basic pager interface.
+ * @ingroup Pager
+ */
+interface Pager {
+ function getNavigationBar();
+ function getBody();
+}
diff --git a/www/wiki/includes/pager/RangeChronologicalPager.php b/www/wiki/includes/pager/RangeChronologicalPager.php
new file mode 100644
index 00000000..d3cb5668
--- /dev/null
+++ b/www/wiki/includes/pager/RangeChronologicalPager.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+use Wikimedia\Timestamp\TimestampException;
+
+/**
+ * Pager for filtering by a range of dates.
+ * @ingroup Pager
+ */
+abstract class RangeChronologicalPager extends ReverseChronologicalPager {
+
+ protected $rangeConds = [];
+
+ /**
+ * Set and return a date range condition using timestamps provided by the user.
+ * We want the revisions between the two timestamps.
+ * Also supports only having a start or end timestamp.
+ * Assumes that the start timestamp comes before the end timestamp.
+ *
+ * @param string $startStamp Timestamp of the beginning of the date range (or empty)
+ * @param string $endStamp Timestamp of the end of the date range (or empty)
+ * @return array|null Database conditions to satisfy the specified date range
+ * or null if dates are invalid
+ */
+ public function getDateRangeCond( $startStamp, $endStamp ) {
+ $this->rangeConds = [];
+
+ try {
+ if ( $startStamp !== '' ) {
+ $startTimestamp = MWTimestamp::getInstance( $startStamp );
+ $startTimestamp->setTimezone( $this->getConfig()->get( 'Localtimezone' ) );
+ $startOffset = $this->mDb->timestamp( $startTimestamp->getTimestamp() );
+ $this->rangeConds[] = $this->mIndexField . '>=' . $this->mDb->addQuotes( $startOffset );
+ }
+
+ if ( $endStamp !== '' ) {
+ $endTimestamp = MWTimestamp::getInstance( $endStamp );
+ $endTimestamp->setTimezone( $this->getConfig()->get( 'Localtimezone' ) );
+ $endOffset = $this->mDb->timestamp( $endTimestamp->getTimestamp() );
+ $this->rangeConds[] = $this->mIndexField . '<=' . $this->mDb->addQuotes( $endOffset );
+
+ // populate existing variables for compatibility with parent
+ $this->mYear = (int)$endTimestamp->format( 'Y' );
+ $this->mMonth = (int)$endTimestamp->format( 'm' );
+ $this->mDay = (int)$endTimestamp->format( 'd' );
+ $this->mOffset = $endOffset;
+ }
+ } catch ( TimestampException $ex ) {
+ return null;
+ }
+
+ return $this->rangeConds;
+ }
+
+ /**
+ * Takes ReverseChronologicalPager::getDateCond parameters and repurposes
+ * them to work with timestamp-based getDateRangeCond.
+ *
+ * @param int $year Year up to which we want revisions
+ * @param int $month Month up to which we want revisions
+ * @param int $day [optional] Day up to which we want revisions. Default is end of month.
+ * @return string|null Timestamp or null if year and month are false/invalid
+ */
+ public function getDateCond( $year, $month, $day = -1 ) {
+ // run through getDateRangeCond so rangeConds, mOffset, ... are set
+ $legacyTimestamp = self::getOffsetDate( $year, $month, $day );
+ // ReverseChronologicalPager uses strict inequality for the end date ('<'),
+ // but this class uses '<=' and expects extending classes to handle modifying the end date.
+ // Therefore, we need to subtract one second from the output of getOffsetDate to make it
+ // work with the '<=' inequality used in this class.
+ $legacyTimestamp->timestamp = $legacyTimestamp->timestamp->modify( '-1 second' );
+ $this->getDateRangeCond( '', $legacyTimestamp->getTimestamp( TS_MW ) );
+ return $this->mOffset;
+ }
+
+ /**
+ * Build variables to use by the database wrapper.
+ *
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return array
+ */
+ protected function buildQueryInfo( $offset, $limit, $descending ) {
+ list( $tables, $fields, $conds, $fname, $options, $join_conds ) = parent::buildQueryInfo(
+ $offset,
+ $limit,
+ $descending
+ );
+
+ if ( $this->rangeConds ) {
+ $conds = array_merge( $conds, $this->rangeConds );
+ }
+
+ return [ $tables, $fields, $conds, $fname, $options, $join_conds ];
+ }
+}
diff --git a/www/wiki/includes/pager/ReverseChronologicalPager.php b/www/wiki/includes/pager/ReverseChronologicalPager.php
new file mode 100644
index 00000000..9eef728a
--- /dev/null
+++ b/www/wiki/includes/pager/ReverseChronologicalPager.php
@@ -0,0 +1,178 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+use Wikimedia\Timestamp\TimestampException;
+
+/**
+ * Efficient paging for SQL queries.
+ * IndexPager with a formatted navigation bar.
+ * @ingroup Pager
+ */
+abstract class ReverseChronologicalPager extends IndexPager {
+ public $mDefaultDirection = IndexPager::DIR_DESCENDING;
+ public $mYear;
+ public $mMonth;
+ public $mDay;
+
+ public function getNavigationBar() {
+ if ( !$this->isNavigationBarShown() ) {
+ return '';
+ }
+
+ if ( isset( $this->mNavigationBar ) ) {
+ return $this->mNavigationBar;
+ }
+
+ $linkTexts = [
+ 'prev' => $this->msg( 'pager-newer-n' )->numParams( $this->mLimit )->escaped(),
+ 'next' => $this->msg( 'pager-older-n' )->numParams( $this->mLimit )->escaped(),
+ 'first' => $this->msg( 'histlast' )->escaped(),
+ 'last' => $this->msg( 'histfirst' )->escaped()
+ ];
+
+ $pagingLinks = $this->getPagingLinks( $linkTexts );
+ $limitLinks = $this->getLimitLinks();
+ $limits = $this->getLanguage()->pipeList( $limitLinks );
+ $firstLastLinks = $this->msg( 'parentheses' )->rawParams( "{$pagingLinks['first']}" .
+ $this->msg( 'pipe-separator' )->escaped() .
+ "{$pagingLinks['last']}" )->escaped();
+
+ $this->mNavigationBar = $firstLastLinks . ' ' .
+ $this->msg( 'viewprevnext' )->rawParams(
+ $pagingLinks['prev'], $pagingLinks['next'], $limits )->escaped();
+
+ return $this->mNavigationBar;
+ }
+
+ /**
+ * Set and return the mOffset timestamp such that we can get all revisions with
+ * a timestamp up to the specified parameters.
+ *
+ * @param int $year Year up to which we want revisions
+ * @param int $month Month up to which we want revisions
+ * @param int $day [optional] Day up to which we want revisions. Default is end of month.
+ * @return string|null Timestamp or null if year and month are false/invalid
+ */
+ public function getDateCond( $year, $month, $day = -1 ) {
+ $year = (int)$year;
+ $month = (int)$month;
+ $day = (int)$day;
+
+ // Basic validity checks for year and month
+ // If year and month are invalid, don't update the mOffset
+ if ( $year <= 0 && ( $month <= 0 || $month >= 13 ) ) {
+ return null;
+ }
+
+ // Treat the given time in the wiki timezone and get a UTC timestamp for the database lookup
+ $timestamp = self::getOffsetDate( $year, $month, $day );
+ $timestamp->setTimezone( $this->getConfig()->get( 'Localtimezone' ) );
+
+ try {
+ $this->mYear = (int)$timestamp->format( 'Y' );
+ $this->mMonth = (int)$timestamp->format( 'm' );
+ $this->mDay = (int)$timestamp->format( 'd' );
+ $this->mOffset = $this->mDb->timestamp( $timestamp->getTimestamp() );
+ } catch ( TimestampException $e ) {
+ // Invalid user provided timestamp (T149257)
+ return null;
+ }
+
+ return $this->mOffset;
+ }
+
+ /**
+ * Core logic of determining the mOffset timestamp such that we can get all items with
+ * a timestamp up to the specified parameters. Given parameters for a day up to which to get
+ * items, this function finds the timestamp of the day just after the end of the range for use
+ * in an database strict inequality filter.
+ *
+ * This is separate from getDateCond so we can use this logic in other places, such as in
+ * RangeChronologicalPager, where this function is used to convert year/month/day filter options
+ * into a timestamp.
+ *
+ * @param int $year Year up to which we want revisions
+ * @param int $month Month up to which we want revisions
+ * @param int $day [optional] Day up to which we want revisions. Default is end of month.
+ * @return MWTimestamp Timestamp or null if year and month are false/invalid
+ */
+ public static function getOffsetDate( $year, $month, $day = -1 ) {
+ // Given an optional year, month, and day, we need to generate a timestamp
+ // to use as "WHERE rev_timestamp <= result"
+ // Examples: year = 2006 equals < 20070101 (+000000)
+ // year=2005, month=1 equals < 20050201
+ // year=2005, month=12 equals < 20060101
+ // year=2005, month=12, day=5 equals < 20051206
+ if ( $year <= 0 ) {
+ // If no year given, assume the current one
+ $timestamp = MWTimestamp::getInstance();
+ $year = $timestamp->format( 'Y' );
+ // If this month hasn't happened yet this year, go back to last year's month
+ if ( $month > $timestamp->format( 'n' ) ) {
+ $year--;
+ }
+ }
+
+ if ( $month && $month > 0 && $month < 13 ) {
+ // Day validity check after we have month and year checked
+ $day = checkdate( $month, $day, $year ) ? $day : false;
+
+ if ( $day && $day > 0 ) {
+ // If we have a day, we want up to the day immediately afterward
+ $day++;
+
+ // Did we overflow the current month?
+ if ( !checkdate( $month, $day, $year ) ) {
+ $day = 1;
+ $month++;
+ }
+ } else {
+ // If no day, assume beginning of next month
+ $day = 1;
+ $month++;
+ }
+
+ // Did we overflow the current year?
+ if ( $month > 12 ) {
+ $month = 1;
+ $year++;
+ }
+
+ } else {
+ // No month implies we want up to the end of the year in question
+ $month = 1;
+ $day = 1;
+ $year++;
+ }
+
+ // Y2K38 bug
+ if ( $year > 2032 ) {
+ $year = 2032;
+ }
+
+ $ymd = (int)sprintf( "%04d%02d%02d", $year, $month, $day );
+
+ if ( $ymd > 20320101 ) {
+ $ymd = 20320101;
+ }
+
+ return MWTimestamp::getInstance( "${ymd}000000" );
+ }
+}
diff --git a/www/wiki/includes/pager/TablePager.php b/www/wiki/includes/pager/TablePager.php
new file mode 100644
index 00000000..70055da3
--- /dev/null
+++ b/www/wiki/includes/pager/TablePager.php
@@ -0,0 +1,474 @@
+<?php
+/**
+ * Efficient paging for SQL queries.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * Table-based display with a user-selectable sort order
+ * @ingroup Pager
+ */
+abstract class TablePager extends IndexPager {
+ protected $mSort;
+
+ protected $mCurrentRow;
+
+ public function __construct( IContextSource $context = null ) {
+ if ( $context ) {
+ $this->setContext( $context );
+ }
+
+ $this->mSort = $this->getRequest()->getText( 'sort' );
+ if ( !array_key_exists( $this->mSort, $this->getFieldNames() )
+ || !$this->isFieldSortable( $this->mSort )
+ ) {
+ $this->mSort = $this->getDefaultSort();
+ }
+ if ( $this->getRequest()->getBool( 'asc' ) ) {
+ $this->mDefaultDirection = IndexPager::DIR_ASCENDING;
+ } elseif ( $this->getRequest()->getBool( 'desc' ) ) {
+ $this->mDefaultDirection = IndexPager::DIR_DESCENDING;
+ } /* Else leave it at whatever the class default is */
+
+ parent::__construct();
+ }
+
+ /**
+ * Get the formatted result list. Calls getStartBody(), formatRow() and getEndBody(), concatenates
+ * the results and returns them.
+ *
+ * Also adds the required styles to our OutputPage object (this means that if context wasn't
+ * passed to constructor or otherwise set up, you will get a pager with missing styles).
+ *
+ * This method has been made 'final' in 1.24. There's no reason to override it, and if there exist
+ * any subclasses that do, the style loading hack is probably broken in them. Let's fail fast
+ * rather than mysteriously render things wrong.
+ *
+ * @deprecated since 1.24, use getBodyOutput() or getFullOutput() instead
+ * @return string
+ */
+ final public function getBody() {
+ $this->getOutput()->addModuleStyles( $this->getModuleStyles() );
+ return parent::getBody();
+ }
+
+ /**
+ * Get the formatted result list.
+ *
+ * Calls getBody() and getModuleStyles() and builds a ParserOutput object. (This is a bit hacky
+ * but works well.)
+ *
+ * @since 1.24
+ * @return ParserOutput
+ */
+ public function getBodyOutput() {
+ $body = parent::getBody();
+
+ $pout = new ParserOutput;
+ $pout->setText( $body );
+ $pout->addModuleStyles( $this->getModuleStyles() );
+ return $pout;
+ }
+
+ /**
+ * Get the formatted result list, with navigation bars.
+ *
+ * Calls getBody(), getNavigationBar() and getModuleStyles() and
+ * builds a ParserOutput object. (This is a bit hacky but works well.)
+ *
+ * @since 1.24
+ * @return ParserOutput
+ */
+ public function getFullOutput() {
+ $navigation = $this->getNavigationBar();
+ $body = parent::getBody();
+
+ $pout = new ParserOutput;
+ $pout->setText( $navigation . $body . $navigation );
+ $pout->addModuleStyles( $this->getModuleStyles() );
+ return $pout;
+ }
+
+ /**
+ * @protected
+ * @return string
+ */
+ function getStartBody() {
+ $sortClass = $this->getSortHeaderClass();
+
+ $s = '';
+ $fields = $this->getFieldNames();
+
+ // Make table header
+ foreach ( $fields as $field => $name ) {
+ if ( strval( $name ) == '' ) {
+ $s .= Html::rawElement( 'th', [], '&#160;' ) . "\n";
+ } elseif ( $this->isFieldSortable( $field ) ) {
+ $query = [ 'sort' => $field, 'limit' => $this->mLimit ];
+ $linkType = null;
+ $class = null;
+
+ if ( $this->mSort == $field ) {
+ // The table is sorted by this field already, make a link to sort in the other direction
+ // We don't actually know in which direction other fields will be sorted by default…
+ if ( $this->mDefaultDirection == IndexPager::DIR_DESCENDING ) {
+ $linkType = 'asc';
+ $class = "$sortClass TablePager_sort-descending";
+ $query['asc'] = '1';
+ $query['desc'] = '';
+ } else {
+ $linkType = 'desc';
+ $class = "$sortClass TablePager_sort-ascending";
+ $query['asc'] = '';
+ $query['desc'] = '1';
+ }
+ }
+
+ $link = $this->makeLink( htmlspecialchars( $name ), $query, $linkType );
+ $s .= Html::rawElement( 'th', [ 'class' => $class ], $link ) . "\n";
+ } else {
+ $s .= Html::element( 'th', [], $name ) . "\n";
+ }
+ }
+
+ $tableClass = $this->getTableClass();
+ $ret = Html::openElement( 'table', [
+ 'class' => "mw-datatable $tableClass" ]
+ );
+ $ret .= Html::rawElement( 'thead', [], Html::rawElement( 'tr', [], "\n" . $s . "\n" ) );
+ $ret .= Html::openElement( 'tbody' ) . "\n";
+
+ return $ret;
+ }
+
+ /**
+ * @protected
+ * @return string
+ */
+ function getEndBody() {
+ return "</tbody></table>\n";
+ }
+
+ /**
+ * @protected
+ * @return string
+ */
+ function getEmptyBody() {
+ $colspan = count( $this->getFieldNames() );
+ $msgEmpty = $this->msg( 'table_pager_empty' )->text();
+ return Html::rawElement( 'tr', [],
+ Html::element( 'td', [ 'colspan' => $colspan ], $msgEmpty ) );
+ }
+
+ /**
+ * @protected
+ * @param stdClass $row
+ * @return string HTML
+ */
+ function formatRow( $row ) {
+ $this->mCurrentRow = $row; // In case formatValue etc need to know
+ $s = Html::openElement( 'tr', $this->getRowAttrs( $row ) ) . "\n";
+ $fieldNames = $this->getFieldNames();
+
+ foreach ( $fieldNames as $field => $name ) {
+ $value = isset( $row->$field ) ? $row->$field : null;
+ $formatted = strval( $this->formatValue( $field, $value ) );
+
+ if ( $formatted == '' ) {
+ $formatted = '&#160;';
+ }
+
+ $s .= Html::rawElement( 'td', $this->getCellAttrs( $field, $value ), $formatted ) . "\n";
+ }
+
+ $s .= Html::closeElement( 'tr' ) . "\n";
+
+ return $s;
+ }
+
+ /**
+ * Get a class name to be applied to the given row.
+ *
+ * @protected
+ *
+ * @param object $row The database result row
+ * @return string
+ */
+ function getRowClass( $row ) {
+ return '';
+ }
+
+ /**
+ * Get attributes to be applied to the given row.
+ *
+ * @protected
+ *
+ * @param object $row The database result row
+ * @return array Array of attribute => value
+ */
+ function getRowAttrs( $row ) {
+ $class = $this->getRowClass( $row );
+ if ( $class === '' ) {
+ // Return an empty array to avoid clutter in HTML like class=""
+ return [];
+ } else {
+ return [ 'class' => $this->getRowClass( $row ) ];
+ }
+ }
+
+ /**
+ * @return stdClass
+ */
+ protected function getCurrentRow() {
+ return $this->mCurrentRow;
+ }
+
+ /**
+ * Get any extra attributes to be applied to the given cell. Don't
+ * take this as an excuse to hardcode styles; use classes and
+ * CSS instead. Row context is available in $this->mCurrentRow
+ *
+ * @protected
+ *
+ * @param string $field The column
+ * @param string $value The cell contents
+ * @return array Array of attr => value
+ */
+ function getCellAttrs( $field, $value ) {
+ return [ 'class' => 'TablePager_col_' . $field ];
+ }
+
+ /**
+ * @protected
+ * @return string
+ */
+ function getIndexField() {
+ return $this->mSort;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getTableClass() {
+ return 'TablePager';
+ }
+
+ /**
+ * @return string
+ */
+ protected function getNavClass() {
+ return 'TablePager_nav';
+ }
+
+ /**
+ * @return string
+ */
+ protected function getSortHeaderClass() {
+ return 'TablePager_sort';
+ }
+
+ /**
+ * A navigation bar with images
+ * @return string HTML
+ */
+ public function getNavigationBar() {
+ if ( !$this->isNavigationBarShown() ) {
+ return '';
+ }
+
+ $this->getOutput()->enableOOUI();
+
+ $types = [ 'first', 'prev', 'next', 'last' ];
+
+ $queries = $this->getPagingQueries();
+ $links = [];
+
+ $buttons = [];
+
+ $title = $this->getTitle();
+
+ foreach ( $types as $type ) {
+ $buttons[] = new \OOUI\ButtonWidget( [
+ // Messages used here:
+ // * table_pager_first
+ // * table_pager_prev
+ // * table_pager_next
+ // * table_pager_last
+ 'label' => $this->msg( 'table_pager_' . $type )->text(),
+ 'href' => $queries[ $type ] ?
+ $title->getLinkURL( $queries[ $type ] + $this->getDefaultQuery() ) :
+ null,
+ 'icon' => $type === 'prev' ? 'previous' : $type,
+ 'disabled' => $queries[ $type ] === false
+ ] );
+ }
+ return new \OOUI\ButtonGroupWidget( [
+ 'classes' => [ $this->getNavClass() ],
+ 'items' => $buttons,
+ ] );
+ }
+
+ /**
+ * ResourceLoader modules that must be loaded to provide correct styling for this pager
+ * @since 1.24
+ * @return string[]
+ */
+ public function getModuleStyles() {
+ return [ 'mediawiki.pager.tablePager', 'oojs-ui.styles.icons-movement' ];
+ }
+
+ /**
+ * Get a "<select>" element which has options for each of the allowed limits
+ *
+ * @param string[] $attribs Extra attributes to set
+ * @return string HTML fragment
+ */
+ public function getLimitSelect( $attribs = [] ) {
+ $select = new XmlSelect( 'limit', false, $this->mLimit );
+ $select->addOptions( $this->getLimitSelectList() );
+ foreach ( $attribs as $name => $value ) {
+ $select->setAttribute( $name, $value );
+ }
+ return $select->getHTML();
+ }
+
+ /**
+ * Get a list of items to show in a "<select>" element of limits.
+ * This can be passed directly to XmlSelect::addOptions().
+ *
+ * @since 1.22
+ * @return array
+ */
+ public function getLimitSelectList() {
+ # Add the current limit from the query string
+ # to avoid that the limit is lost after clicking Go next time
+ if ( !in_array( $this->mLimit, $this->mLimitsShown ) ) {
+ $this->mLimitsShown[] = $this->mLimit;
+ sort( $this->mLimitsShown );
+ }
+ $ret = [];
+ foreach ( $this->mLimitsShown as $key => $value ) {
+ # The pair is either $index => $limit, in which case the $value
+ # will be numeric, or $limit => $text, in which case the $value
+ # will be a string.
+ if ( is_int( $value ) ) {
+ $limit = $value;
+ $text = $this->getLanguage()->formatNum( $limit );
+ } else {
+ $limit = $key;
+ $text = $value;
+ }
+ $ret[$text] = $limit;
+ }
+ return $ret;
+ }
+
+ /**
+ * Get \<input type="hidden"\> elements for use in a method="get" form.
+ * Resubmits all defined elements of the query string, except for a
+ * blacklist, passed in the $blacklist parameter.
+ *
+ * @param array $blacklist Parameters from the request query which should not be resubmitted
+ * @return string HTML fragment
+ */
+ function getHiddenFields( $blacklist = [] ) {
+ $blacklist = (array)$blacklist;
+ $query = $this->getRequest()->getQueryValues();
+ foreach ( $blacklist as $name ) {
+ unset( $query[$name] );
+ }
+ $s = '';
+ foreach ( $query as $name => $value ) {
+ $s .= Html::hidden( $name, $value ) . "\n";
+ }
+ return $s;
+ }
+
+ /**
+ * Get a form containing a limit selection dropdown
+ *
+ * @return string HTML fragment
+ */
+ function getLimitForm() {
+ return Html::rawElement(
+ 'form',
+ [
+ 'method' => 'get',
+ 'action' => wfScript(),
+ ],
+ "\n" . $this->getLimitDropdown()
+ ) . "\n";
+ }
+
+ /**
+ * Gets a limit selection dropdown
+ *
+ * @return string
+ */
+ function getLimitDropdown() {
+ # Make the select with some explanatory text
+ $msgSubmit = $this->msg( 'table_pager_limit_submit' )->escaped();
+
+ return $this->msg( 'table_pager_limit' )
+ ->rawParams( $this->getLimitSelect() )->escaped() .
+ "\n<input type=\"submit\" value=\"$msgSubmit\"/>\n" .
+ $this->getHiddenFields( [ 'limit' ] );
+ }
+
+ /**
+ * Return true if the named field should be sortable by the UI, false
+ * otherwise
+ *
+ * @param string $field
+ */
+ abstract function isFieldSortable( $field );
+
+ /**
+ * Format a table cell. The return value should be HTML, but use an empty
+ * string not &#160; for empty cells. Do not include the <td> and </td>.
+ *
+ * The current result row is available as $this->mCurrentRow, in case you
+ * need more context.
+ *
+ * @protected
+ *
+ * @param string $name The database field name
+ * @param string $value The value retrieved from the database
+ */
+ abstract function formatValue( $name, $value );
+
+ /**
+ * The database field name used as a default sort order.
+ *
+ * @protected
+ *
+ * @return string
+ */
+ abstract function getDefaultSort();
+
+ /**
+ * An array mapping database field names to a textual description of the
+ * field name, for use in the table header. The description should be plain
+ * text, it will be HTML-escaped later.
+ *
+ * @return array
+ */
+ abstract function getFieldNames();
+}
diff --git a/www/wiki/includes/parser/BlockLevelPass.php b/www/wiki/includes/parser/BlockLevelPass.php
new file mode 100644
index 00000000..fab9ab7f
--- /dev/null
+++ b/www/wiki/includes/parser/BlockLevelPass.php
@@ -0,0 +1,555 @@
+<?php
+
+/**
+ * This is the part of the wikitext parser which handles automatic paragraphs
+ * and conversion of start-of-line prefixes to HTML lists.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+class BlockLevelPass {
+ private $DTopen = false;
+ private $inPre = false;
+ private $lastSection = '';
+ private $linestart;
+ private $text;
+
+ # State constants for the definition list colon extraction
+ const COLON_STATE_TEXT = 0;
+ const COLON_STATE_TAG = 1;
+ const COLON_STATE_TAGSTART = 2;
+ const COLON_STATE_CLOSETAG = 3;
+ const COLON_STATE_TAGSLASH = 4;
+ const COLON_STATE_COMMENT = 5;
+ const COLON_STATE_COMMENTDASH = 6;
+ const COLON_STATE_COMMENTDASHDASH = 7;
+ const COLON_STATE_LC = 8;
+
+ /**
+ * Make lists from lines starting with ':', '*', '#', etc.
+ *
+ * @param string $text
+ * @param bool $lineStart Whether or not this is at the start of a line.
+ * @return string The lists rendered as HTML
+ */
+ public static function doBlockLevels( $text, $lineStart ) {
+ $pass = new self( $text, $lineStart );
+ return $pass->execute();
+ }
+
+ /**
+ * Private constructor
+ */
+ private function __construct( $text, $lineStart ) {
+ $this->text = $text;
+ $this->lineStart = $lineStart;
+ }
+
+ /**
+ * If a pre or p is open, return the corresponding close tag and update
+ * the state. If no tag is open, return an empty string.
+ * @return string
+ */
+ private function closeParagraph() {
+ $result = '';
+ if ( $this->lastSection !== '' ) {
+ $result = '</' . $this->lastSection . ">\n";
+ }
+ $this->inPre = false;
+ $this->lastSection = '';
+ return $result;
+ }
+
+ /**
+ * getCommon() returns the length of the longest common substring
+ * of both arguments, starting at the beginning of both.
+ *
+ * @param string $st1
+ * @param string $st2
+ *
+ * @return int
+ */
+ private function getCommon( $st1, $st2 ) {
+ $shorter = min( strlen( $st1 ), strlen( $st2 ) );
+
+ for ( $i = 0; $i < $shorter; ++$i ) {
+ if ( $st1[$i] !== $st2[$i] ) {
+ break;
+ }
+ }
+ return $i;
+ }
+
+ /**
+ * Open the list item element identified by the prefix character.
+ *
+ * @param string $char
+ *
+ * @return string
+ */
+ private function openList( $char ) {
+ $result = $this->closeParagraph();
+
+ if ( '*' === $char ) {
+ $result .= "<ul><li>";
+ } elseif ( '#' === $char ) {
+ $result .= "<ol><li>";
+ } elseif ( ':' === $char ) {
+ $result .= "<dl><dd>";
+ } elseif ( ';' === $char ) {
+ $result .= "<dl><dt>";
+ $this->DTopen = true;
+ } else {
+ $result = '<!-- ERR 1 -->';
+ }
+
+ return $result;
+ }
+
+ /**
+ * Close the current list item and open the next one.
+ * @param string $char
+ *
+ * @return string
+ */
+ private function nextItem( $char ) {
+ if ( '*' === $char || '#' === $char ) {
+ return "</li>\n<li>";
+ } elseif ( ':' === $char || ';' === $char ) {
+ $close = "</dd>\n";
+ if ( $this->DTopen ) {
+ $close = "</dt>\n";
+ }
+ if ( ';' === $char ) {
+ $this->DTopen = true;
+ return $close . '<dt>';
+ } else {
+ $this->DTopen = false;
+ return $close . '<dd>';
+ }
+ }
+ return '<!-- ERR 2 -->';
+ }
+
+ /**
+ * Close the current list item identified by the prefix character.
+ * @param string $char
+ *
+ * @return string
+ */
+ private function closeList( $char ) {
+ if ( '*' === $char ) {
+ $text = "</li></ul>";
+ } elseif ( '#' === $char ) {
+ $text = "</li></ol>";
+ } elseif ( ':' === $char ) {
+ if ( $this->DTopen ) {
+ $this->DTopen = false;
+ $text = "</dt></dl>";
+ } else {
+ $text = "</dd></dl>";
+ }
+ } else {
+ return '<!-- ERR 3 -->';
+ }
+ return $text;
+ }
+
+ /**
+ * Execute the pass.
+ * @return string
+ */
+ private function execute() {
+ $text = $this->text;
+ # Parsing through the text line by line. The main thing
+ # happening here is handling of block-level elements p, pre,
+ # and making lists from lines starting with * # : etc.
+ $textLines = StringUtils::explode( "\n", $text );
+
+ $lastPrefix = $output = '';
+ $this->DTopen = $inBlockElem = false;
+ $prefixLength = 0;
+ $pendingPTag = false;
+ $inBlockquote = false;
+
+ foreach ( $textLines as $inputLine ) {
+ # Fix up $lineStart
+ if ( !$this->lineStart ) {
+ $output .= $inputLine;
+ $this->lineStart = true;
+ continue;
+ }
+ # * = ul
+ # # = ol
+ # ; = dt
+ # : = dd
+
+ $lastPrefixLength = strlen( $lastPrefix );
+ $preCloseMatch = preg_match( '/<\\/pre/i', $inputLine );
+ $preOpenMatch = preg_match( '/<pre/i', $inputLine );
+ # If not in a <pre> element, scan for and figure out what prefixes are there.
+ if ( !$this->inPre ) {
+ # Multiple prefixes may abut each other for nested lists.
+ $prefixLength = strspn( $inputLine, '*#:;' );
+ $prefix = substr( $inputLine, 0, $prefixLength );
+
+ # eh?
+ # ; and : are both from definition-lists, so they're equivalent
+ # for the purposes of determining whether or not we need to open/close
+ # elements.
+ $prefix2 = str_replace( ';', ':', $prefix );
+ $t = substr( $inputLine, $prefixLength );
+ $this->inPre = (bool)$preOpenMatch;
+ } else {
+ # Don't interpret any other prefixes in preformatted text
+ $prefixLength = 0;
+ $prefix = $prefix2 = '';
+ $t = $inputLine;
+ }
+
+ # List generation
+ if ( $prefixLength && $lastPrefix === $prefix2 ) {
+ # Same as the last item, so no need to deal with nesting or opening stuff
+ $output .= $this->nextItem( substr( $prefix, -1 ) );
+ $pendingPTag = false;
+
+ if ( substr( $prefix, -1 ) === ';' ) {
+ # The one nasty exception: definition lists work like this:
+ # ; title : definition text
+ # So we check for : in the remainder text to split up the
+ # title and definition, without b0rking links.
+ $term = $t2 = '';
+ if ( $this->findColonNoLinks( $t, $term, $t2 ) !== false ) {
+ $t = $t2;
+ $output .= $term . $this->nextItem( ':' );
+ }
+ }
+ } elseif ( $prefixLength || $lastPrefixLength ) {
+ # We need to open or close prefixes, or both.
+
+ # Either open or close a level...
+ $commonPrefixLength = $this->getCommon( $prefix, $lastPrefix );
+ $pendingPTag = false;
+
+ # Close all the prefixes which aren't shared.
+ while ( $commonPrefixLength < $lastPrefixLength ) {
+ $output .= $this->closeList( $lastPrefix[$lastPrefixLength - 1] );
+ --$lastPrefixLength;
+ }
+
+ # Continue the current prefix if appropriate.
+ if ( $prefixLength <= $commonPrefixLength && $commonPrefixLength > 0 ) {
+ $output .= $this->nextItem( $prefix[$commonPrefixLength - 1] );
+ }
+
+ # Close an open <dt> if we have a <dd> (":") starting on this line
+ if ( $this->DTopen && $commonPrefixLength > 0 && $prefix[$commonPrefixLength - 1] === ':' ) {
+ $output .= $this->nextItem( ':' );
+ }
+
+ # Open prefixes where appropriate.
+ if ( $lastPrefix && $prefixLength > $commonPrefixLength ) {
+ $output .= "\n";
+ }
+ while ( $prefixLength > $commonPrefixLength ) {
+ $char = $prefix[$commonPrefixLength];
+ $output .= $this->openList( $char );
+
+ if ( ';' === $char ) {
+ # @todo FIXME: This is dupe of code above
+ if ( $this->findColonNoLinks( $t, $term, $t2 ) !== false ) {
+ $t = $t2;
+ $output .= $term . $this->nextItem( ':' );
+ }
+ }
+ ++$commonPrefixLength;
+ }
+ if ( !$prefixLength && $lastPrefix ) {
+ $output .= "\n";
+ }
+ $lastPrefix = $prefix2;
+ }
+
+ # If we have no prefixes, go to paragraph mode.
+ if ( 0 == $prefixLength ) {
+ # No prefix (not in list)--go to paragraph mode
+ # @todo consider using a stack for nestable elements like span, table and div
+ $openMatch = preg_match(
+ '/(?:<table|<h1|<h2|<h3|<h4|<h5|<h6|<pre|<tr|'
+ . '<p|<ul|<ol|<dl|<li|<\\/tr|<\\/td|<\\/th)\\b/iS',
+ $t
+ );
+ $closeMatch = preg_match(
+ '/(?:<\\/table|<\\/h1|<\\/h2|<\\/h3|<\\/h4|<\\/h5|<\\/h6|'
+ . '<td|<th|<\\/?blockquote|<\\/?div|<hr|<\\/pre|<\\/p|<\\/mw:|'
+ . Parser::MARKER_PREFIX
+ . '-pre|<\\/li|<\\/ul|<\\/ol|<\\/dl|<\\/?center)\\b/iS',
+ $t
+ );
+
+ if ( $openMatch || $closeMatch ) {
+ $pendingPTag = false;
+ # @todo T7718: paragraph closed
+ $output .= $this->closeParagraph();
+ if ( $preOpenMatch && !$preCloseMatch ) {
+ $this->inPre = true;
+ }
+ $bqOffset = 0;
+ while ( preg_match( '/<(\\/?)blockquote[\s>]/i', $t,
+ $bqMatch, PREG_OFFSET_CAPTURE, $bqOffset )
+ ) {
+ $inBlockquote = !$bqMatch[1][0]; // is this a close tag?
+ $bqOffset = $bqMatch[0][1] + strlen( $bqMatch[0][0] );
+ }
+ $inBlockElem = !$closeMatch;
+ } elseif ( !$inBlockElem && !$this->inPre ) {
+ if ( ' ' == substr( $t, 0, 1 )
+ && ( $this->lastSection === 'pre' || trim( $t ) != '' )
+ && !$inBlockquote
+ ) {
+ # pre
+ if ( $this->lastSection !== 'pre' ) {
+ $pendingPTag = false;
+ $output .= $this->closeParagraph() . '<pre>';
+ $this->lastSection = 'pre';
+ }
+ $t = substr( $t, 1 );
+ } else {
+ # paragraph
+ if ( trim( $t ) === '' ) {
+ if ( $pendingPTag ) {
+ $output .= $pendingPTag . '<br />';
+ $pendingPTag = false;
+ $this->lastSection = 'p';
+ } else {
+ if ( $this->lastSection !== 'p' ) {
+ $output .= $this->closeParagraph();
+ $this->lastSection = '';
+ $pendingPTag = '<p>';
+ } else {
+ $pendingPTag = '</p><p>';
+ }
+ }
+ } else {
+ if ( $pendingPTag ) {
+ $output .= $pendingPTag;
+ $pendingPTag = false;
+ $this->lastSection = 'p';
+ } elseif ( $this->lastSection !== 'p' ) {
+ $output .= $this->closeParagraph() . '<p>';
+ $this->lastSection = 'p';
+ }
+ }
+ }
+ }
+ }
+ # somewhere above we forget to get out of pre block (T2785)
+ if ( $preCloseMatch && $this->inPre ) {
+ $this->inPre = false;
+ }
+ if ( $pendingPTag === false ) {
+ $output .= $t;
+ if ( $prefixLength === 0 ) {
+ $output .= "\n";
+ }
+ }
+ }
+ while ( $prefixLength ) {
+ $output .= $this->closeList( $prefix2[$prefixLength - 1] );
+ --$prefixLength;
+ if ( !$prefixLength ) {
+ $output .= "\n";
+ }
+ }
+ if ( $this->lastSection !== '' ) {
+ $output .= '</' . $this->lastSection . '>';
+ $this->lastSection = '';
+ }
+
+ return $output;
+ }
+
+ /**
+ * Split up a string on ':', ignoring any occurrences inside tags
+ * to prevent illegal overlapping.
+ *
+ * @param string $str The string to split
+ * @param string &$before Set to everything before the ':'
+ * @param string &$after Set to everything after the ':'
+ * @throws MWException
+ * @return string The position of the ':', or false if none found
+ */
+ private function findColonNoLinks( $str, &$before, &$after ) {
+ if ( !preg_match( '/:|<|-\{/', $str, $m, PREG_OFFSET_CAPTURE ) ) {
+ # Nothing to find!
+ return false;
+ }
+
+ if ( $m[0][0] === ':' ) {
+ # Easy; no tag nesting to worry about
+ $colonPos = $m[0][1];
+ $before = substr( $str, 0, $colonPos );
+ $after = substr( $str, $colonPos + 1 );
+ return $colonPos;
+ }
+
+ # Ugly state machine to walk through avoiding tags.
+ $state = self::COLON_STATE_TEXT;
+ $ltLevel = 0;
+ $lcLevel = 0;
+ $len = strlen( $str );
+ for ( $i = $m[0][1]; $i < $len; $i++ ) {
+ $c = $str[$i];
+
+ switch ( $state ) {
+ case self::COLON_STATE_TEXT:
+ switch ( $c ) {
+ case "<":
+ # Could be either a <start> tag or an </end> tag
+ $state = self::COLON_STATE_TAGSTART;
+ break;
+ case ":":
+ if ( $ltLevel === 0 ) {
+ # We found it!
+ $before = substr( $str, 0, $i );
+ $after = substr( $str, $i + 1 );
+ return $i;
+ }
+ # Embedded in a tag; don't break it.
+ break;
+ default:
+ # Skip ahead looking for something interesting
+ if ( !preg_match( '/:|<|-\{/', $str, $m, PREG_OFFSET_CAPTURE, $i ) ) {
+ # Nothing else interesting
+ return false;
+ }
+ if ( $m[0][0] === '-{' ) {
+ $state = self::COLON_STATE_LC;
+ $lcLevel++;
+ $i = $m[0][1] + 1;
+ } else {
+ # Skip ahead to next interesting character.
+ $i = $m[0][1] - 1;
+ }
+ break;
+ }
+ break;
+ case self::COLON_STATE_LC:
+ # In language converter markup -{ ... }-
+ if ( !preg_match( '/-\{|\}-/', $str, $m, PREG_OFFSET_CAPTURE, $i ) ) {
+ # Nothing else interesting to find; abort!
+ # We're nested in language converter markup, but there
+ # are no close tags left. Abort!
+ break 2;
+ } elseif ( $m[0][0] === '-{' ) {
+ $i = $m[0][1] + 1;
+ $lcLevel++;
+ } elseif ( $m[0][0] === '}-' ) {
+ $i = $m[0][1] + 1;
+ $lcLevel--;
+ if ( $lcLevel === 0 ) {
+ $state = self::COLON_STATE_TEXT;
+ }
+ }
+ break;
+ case self::COLON_STATE_TAG:
+ # In a <tag>
+ switch ( $c ) {
+ case ">":
+ $ltLevel++;
+ $state = self::COLON_STATE_TEXT;
+ break;
+ case "/":
+ # Slash may be followed by >?
+ $state = self::COLON_STATE_TAGSLASH;
+ break;
+ default:
+ # ignore
+ }
+ break;
+ case self::COLON_STATE_TAGSTART:
+ switch ( $c ) {
+ case "/":
+ $state = self::COLON_STATE_CLOSETAG;
+ break;
+ case "!":
+ $state = self::COLON_STATE_COMMENT;
+ break;
+ case ">":
+ # Illegal early close? This shouldn't happen D:
+ $state = self::COLON_STATE_TEXT;
+ break;
+ default:
+ $state = self::COLON_STATE_TAG;
+ }
+ break;
+ case self::COLON_STATE_CLOSETAG:
+ # In a </tag>
+ if ( $c === ">" ) {
+ if ( $ltLevel > 0 ) {
+ $ltLevel--;
+ } else {
+ # ignore the excess close tag, but keep looking for
+ # colons. (This matches Parsoid behavior.)
+ wfDebug( __METHOD__ . ": Invalid input; too many close tags\n" );
+ }
+ $state = self::COLON_STATE_TEXT;
+ }
+ break;
+ case self::COLON_STATE_TAGSLASH:
+ if ( $c === ">" ) {
+ # Yes, a self-closed tag <blah/>
+ $state = self::COLON_STATE_TEXT;
+ } else {
+ # Probably we're jumping the gun, and this is an attribute
+ $state = self::COLON_STATE_TAG;
+ }
+ break;
+ case self::COLON_STATE_COMMENT:
+ if ( $c === "-" ) {
+ $state = self::COLON_STATE_COMMENTDASH;
+ }
+ break;
+ case self::COLON_STATE_COMMENTDASH:
+ if ( $c === "-" ) {
+ $state = self::COLON_STATE_COMMENTDASHDASH;
+ } else {
+ $state = self::COLON_STATE_COMMENT;
+ }
+ break;
+ case self::COLON_STATE_COMMENTDASHDASH:
+ if ( $c === ">" ) {
+ $state = self::COLON_STATE_TEXT;
+ } else {
+ $state = self::COLON_STATE_COMMENT;
+ }
+ break;
+ default:
+ throw new MWException( "State machine error in " . __METHOD__ );
+ }
+ }
+ if ( $ltLevel > 0 || $lcLevel > 0 ) {
+ wfDebug(
+ __METHOD__ . ": Invalid input; not enough close tags " .
+ "(level $ltLevel/$lcLevel, state $state)\n"
+ );
+ return false;
+ }
+ return false;
+ }
+}
diff --git a/www/wiki/includes/parser/CacheTime.php b/www/wiki/includes/parser/CacheTime.php
new file mode 100644
index 00000000..05bcebef
--- /dev/null
+++ b/www/wiki/includes/parser/CacheTime.php
@@ -0,0 +1,175 @@
+<?php
+/**
+ * Parser cache specific expiry check.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * Parser cache specific expiry check.
+ *
+ * @ingroup Parser
+ */
+class CacheTime {
+ /** @var array|bool ParserOptions which have been taken into account to
+ * produce output or false if not available.
+ */
+ public $mUsedOptions;
+
+ # Compatibility check
+ public $mVersion = Parser::VERSION;
+
+ # Time when this object was generated, or -1 for uncacheable. Used in ParserCache.
+ public $mCacheTime = '';
+
+ # Seconds after which the object should expire, use 0 for uncacheable. Used in ParserCache.
+ public $mCacheExpiry = null;
+
+ # Revision ID that was parsed
+ public $mCacheRevisionId = null;
+
+ /**
+ * @return string TS_MW timestamp
+ */
+ public function getCacheTime() {
+ return wfTimestamp( TS_MW, $this->mCacheTime );
+ }
+
+ /**
+ * setCacheTime() sets the timestamp expressing when the page has been rendered.
+ * This does not control expiry, see updateCacheExpiry() for that!
+ * @param string $t TS_MW timestamp
+ * @return string
+ */
+ public function setCacheTime( $t ) {
+ return wfSetVar( $this->mCacheTime, $t );
+ }
+
+ /**
+ * @since 1.23
+ * @return int|null Revision id, if any was set
+ */
+ public function getCacheRevisionId() {
+ return $this->mCacheRevisionId;
+ }
+
+ /**
+ * @since 1.23
+ * @param int $id Revision id
+ */
+ public function setCacheRevisionId( $id ) {
+ $this->mCacheRevisionId = $id;
+ }
+
+ /**
+ * Sets the number of seconds after which this object should expire.
+ *
+ * This value is used with the ParserCache.
+ * If called with a value greater than the value provided at any previous call,
+ * the new call has no effect. The value returned by getCacheExpiry is smaller
+ * or equal to the smallest number that was provided as an argument to
+ * updateCacheExpiry().
+ *
+ * Avoid using 0 if at all possible. Consider JavaScript for highly dynamic content.
+ *
+ * @param int $seconds
+ */
+ public function updateCacheExpiry( $seconds ) {
+ $seconds = (int)$seconds;
+
+ if ( $this->mCacheExpiry === null || $this->mCacheExpiry > $seconds ) {
+ $this->mCacheExpiry = $seconds;
+ }
+ }
+
+ /**
+ * Returns the number of seconds after which this object should expire.
+ * This method is used by ParserCache to determine how long the ParserOutput can be cached.
+ * The timestamp of expiry can be calculated by adding getCacheExpiry() to getCacheTime().
+ * The value returned by getCacheExpiry is smaller or equal to the smallest number
+ * that was provided to a call of updateCacheExpiry(), and smaller or equal to the
+ * value of $wgParserCacheExpireTime.
+ * @return int|mixed|null
+ */
+ public function getCacheExpiry() {
+ global $wgParserCacheExpireTime;
+
+ if ( $this->mCacheTime < 0 ) {
+ return 0;
+ } // old-style marker for "not cacheable"
+
+ $expire = $this->mCacheExpiry;
+
+ if ( $expire === null ) {
+ $expire = $wgParserCacheExpireTime;
+ } else {
+ $expire = min( $expire, $wgParserCacheExpireTime );
+ }
+
+ if ( $expire <= 0 ) {
+ return 0; // not cacheable
+ } else {
+ return $expire;
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function isCacheable() {
+ return $this->getCacheExpiry() > 0;
+ }
+
+ /**
+ * Return true if this cached output object predates the global or
+ * per-article cache invalidation timestamps, or if it comes from
+ * an incompatible older version.
+ *
+ * @param string $touched The affected article's last touched timestamp
+ * @return bool
+ */
+ public function expired( $touched ) {
+ global $wgCacheEpoch;
+
+ return !$this->isCacheable() // parser says it's uncacheable
+ || $this->getCacheTime() < $touched
+ || $this->getCacheTime() <= $wgCacheEpoch
+ || $this->getCacheTime() <
+ wfTimestamp( TS_MW, time() - $this->getCacheExpiry() ) // expiry period has passed
+ || !isset( $this->mVersion )
+ || version_compare( $this->mVersion, Parser::VERSION, "lt" );
+ }
+
+ /**
+ * Return true if this cached output object is for a different revision of
+ * the page.
+ *
+ * @todo We always return false if $this->getCacheRevisionId() is null;
+ * this prevents invalidating the whole parser cache when this change is
+ * deployed. Someday that should probably be changed.
+ *
+ * @since 1.23
+ * @param int $id The affected article's current revision id
+ * @return bool
+ */
+ public function isDifferentRevision( $id ) {
+ $cached = $this->getCacheRevisionId();
+ return $cached !== null && $id !== $cached;
+ }
+}
diff --git a/www/wiki/includes/parser/CoreParserFunctions.php b/www/wiki/includes/parser/CoreParserFunctions.php
new file mode 100644
index 00000000..45a5092c
--- /dev/null
+++ b/www/wiki/includes/parser/CoreParserFunctions.php
@@ -0,0 +1,1351 @@
+<?php
+/**
+ * Parser functions provided by MediaWiki core
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Various core parser functions, registered in Parser::firstCallInit()
+ * @ingroup Parser
+ */
+class CoreParserFunctions {
+ /**
+ * @param Parser $parser
+ * @return void
+ */
+ public static function register( $parser ) {
+ global $wgAllowDisplayTitle, $wgAllowSlowParserFunctions;
+
+ # Syntax for arguments (see Parser::setFunctionHook):
+ # "name for lookup in localized magic words array",
+ # function callback,
+ # optional Parser::SFH_NO_HASH to omit the hash from calls (e.g. {{int:...}}
+ # instead of {{#int:...}})
+ $noHashFunctions = [
+ 'ns', 'nse', 'urlencode', 'lcfirst', 'ucfirst', 'lc', 'uc',
+ 'localurl', 'localurle', 'fullurl', 'fullurle', 'canonicalurl',
+ 'canonicalurle', 'formatnum', 'grammar', 'gender', 'plural', 'bidi',
+ 'numberofpages', 'numberofusers', 'numberofactiveusers',
+ 'numberofarticles', 'numberoffiles', 'numberofadmins',
+ 'numberingroup', 'numberofedits', 'language',
+ 'padleft', 'padright', 'anchorencode', 'defaultsort', 'filepath',
+ 'pagesincategory', 'pagesize', 'protectionlevel', 'protectionexpiry',
+ 'namespacee', 'namespacenumber', 'talkspace', 'talkspacee',
+ 'subjectspace', 'subjectspacee', 'pagename', 'pagenamee',
+ 'fullpagename', 'fullpagenamee', 'rootpagename', 'rootpagenamee',
+ 'basepagename', 'basepagenamee', 'subpagename', 'subpagenamee',
+ 'talkpagename', 'talkpagenamee', 'subjectpagename',
+ 'subjectpagenamee', 'pageid', 'revisionid', 'revisionday',
+ 'revisionday2', 'revisionmonth', 'revisionmonth1', 'revisionyear',
+ 'revisiontimestamp', 'revisionuser', 'cascadingsources',
+ ];
+ foreach ( $noHashFunctions as $func ) {
+ $parser->setFunctionHook( $func, [ __CLASS__, $func ], Parser::SFH_NO_HASH );
+ }
+
+ $parser->setFunctionHook(
+ 'namespace',
+ [ __CLASS__, 'mwnamespace' ],
+ Parser::SFH_NO_HASH
+ );
+ $parser->setFunctionHook( 'int', [ __CLASS__, 'intFunction' ], Parser::SFH_NO_HASH );
+ $parser->setFunctionHook( 'special', [ __CLASS__, 'special' ] );
+ $parser->setFunctionHook( 'speciale', [ __CLASS__, 'speciale' ] );
+ $parser->setFunctionHook( 'tag', [ __CLASS__, 'tagObj' ], Parser::SFH_OBJECT_ARGS );
+ $parser->setFunctionHook( 'formatdate', [ __CLASS__, 'formatDate' ] );
+
+ if ( $wgAllowDisplayTitle ) {
+ $parser->setFunctionHook(
+ 'displaytitle',
+ [ __CLASS__, 'displaytitle' ],
+ Parser::SFH_NO_HASH
+ );
+ }
+ if ( $wgAllowSlowParserFunctions ) {
+ $parser->setFunctionHook(
+ 'pagesinnamespace',
+ [ __CLASS__, 'pagesinnamespace' ],
+ Parser::SFH_NO_HASH
+ );
+ }
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $part1
+ * @return array
+ */
+ public static function intFunction( $parser, $part1 = '' /*, ... */ ) {
+ if ( strval( $part1 ) !== '' ) {
+ $args = array_slice( func_get_args(), 2 );
+ $message = wfMessage( $part1, $args )
+ ->inLanguage( $parser->getOptions()->getUserLangObj() );
+ if ( !$message->exists() ) {
+ // When message does not exists, the message name is surrounded by angle
+ // and can result in a tag, therefore escape the angles
+ return $message->escaped();
+ }
+ return [ $message->plain(), 'noparse' => false ];
+ } else {
+ return [ 'found' => false ];
+ }
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $date
+ * @param string $defaultPref
+ *
+ * @return string
+ */
+ public static function formatDate( $parser, $date, $defaultPref = null ) {
+ $lang = $parser->getFunctionLang();
+ $df = DateFormatter::getInstance( $lang );
+
+ $date = trim( $date );
+
+ $pref = $parser->getOptions()->getDateFormat();
+
+ // Specify a different default date format other than the normal default
+ // if the user has 'default' for their setting
+ if ( $pref == 'default' && $defaultPref ) {
+ $pref = $defaultPref;
+ }
+
+ $date = $df->reformat( $pref, $date, [ 'match-whole' ] );
+ return $date;
+ }
+
+ public static function ns( $parser, $part1 = '' ) {
+ global $wgContLang;
+ if ( intval( $part1 ) || $part1 == "0" ) {
+ $index = intval( $part1 );
+ } else {
+ $index = $wgContLang->getNsIndex( str_replace( ' ', '_', $part1 ) );
+ }
+ if ( $index !== false ) {
+ return $wgContLang->getFormattedNsText( $index );
+ } else {
+ return [ 'found' => false ];
+ }
+ }
+
+ public static function nse( $parser, $part1 = '' ) {
+ $ret = self::ns( $parser, $part1 );
+ if ( is_string( $ret ) ) {
+ $ret = wfUrlencode( str_replace( ' ', '_', $ret ) );
+ }
+ return $ret;
+ }
+
+ /**
+ * urlencodes a string according to one of three patterns: (T24474)
+ *
+ * By default (for HTTP "query" strings), spaces are encoded as '+'.
+ * Or to encode a value for the HTTP "path", spaces are encoded as '%20'.
+ * For links to "wiki"s, or similar software, spaces are encoded as '_',
+ *
+ * @param Parser $parser
+ * @param string $s The text to encode.
+ * @param string $arg (optional): The type of encoding.
+ * @return string
+ */
+ public static function urlencode( $parser, $s = '', $arg = null ) {
+ static $magicWords = null;
+ if ( is_null( $magicWords ) ) {
+ $magicWords = new MagicWordArray( [ 'url_path', 'url_query', 'url_wiki' ] );
+ }
+ switch ( $magicWords->matchStartToEnd( $arg ) ) {
+ // Encode as though it's a wiki page, '_' for ' '.
+ case 'url_wiki':
+ $func = 'wfUrlencode';
+ $s = str_replace( ' ', '_', $s );
+ break;
+
+ // Encode for an HTTP Path, '%20' for ' '.
+ case 'url_path':
+ $func = 'rawurlencode';
+ break;
+
+ // Encode for HTTP query, '+' for ' '.
+ case 'url_query':
+ default:
+ $func = 'urlencode';
+ }
+ // See T105242, where the choice to kill markers and various
+ // other options were discussed.
+ return $func( $parser->killMarkers( $s ) );
+ }
+
+ public static function lcfirst( $parser, $s = '' ) {
+ global $wgContLang;
+ return $wgContLang->lcfirst( $s );
+ }
+
+ public static function ucfirst( $parser, $s = '' ) {
+ global $wgContLang;
+ return $wgContLang->ucfirst( $s );
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $s
+ * @return string
+ */
+ public static function lc( $parser, $s = '' ) {
+ global $wgContLang;
+ return $parser->markerSkipCallback( $s, [ $wgContLang, 'lc' ] );
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $s
+ * @return string
+ */
+ public static function uc( $parser, $s = '' ) {
+ global $wgContLang;
+ return $parser->markerSkipCallback( $s, [ $wgContLang, 'uc' ] );
+ }
+
+ public static function localurl( $parser, $s = '', $arg = null ) {
+ return self::urlFunction( 'getLocalURL', $s, $arg );
+ }
+
+ public static function localurle( $parser, $s = '', $arg = null ) {
+ $temp = self::urlFunction( 'getLocalURL', $s, $arg );
+ if ( !is_string( $temp ) ) {
+ return $temp;
+ } else {
+ return htmlspecialchars( $temp );
+ }
+ }
+
+ public static function fullurl( $parser, $s = '', $arg = null ) {
+ return self::urlFunction( 'getFullURL', $s, $arg );
+ }
+
+ public static function fullurle( $parser, $s = '', $arg = null ) {
+ $temp = self::urlFunction( 'getFullURL', $s, $arg );
+ if ( !is_string( $temp ) ) {
+ return $temp;
+ } else {
+ return htmlspecialchars( $temp );
+ }
+ }
+
+ public static function canonicalurl( $parser, $s = '', $arg = null ) {
+ return self::urlFunction( 'getCanonicalURL', $s, $arg );
+ }
+
+ public static function canonicalurle( $parser, $s = '', $arg = null ) {
+ $temp = self::urlFunction( 'getCanonicalURL', $s, $arg );
+ if ( !is_string( $temp ) ) {
+ return $temp;
+ } else {
+ return htmlspecialchars( $temp );
+ }
+ }
+
+ public static function urlFunction( $func, $s = '', $arg = null ) {
+ $title = Title::newFromText( $s );
+ # Due to order of execution of a lot of bits, the values might be encoded
+ # before arriving here; if that's true, then the title can't be created
+ # and the variable will fail. If we can't get a decent title from the first
+ # attempt, url-decode and try for a second.
+ if ( is_null( $title ) ) {
+ $title = Title::newFromURL( urldecode( $s ) );
+ }
+ if ( !is_null( $title ) ) {
+ # Convert NS_MEDIA -> NS_FILE
+ if ( $title->inNamespace( NS_MEDIA ) ) {
+ $title = Title::makeTitle( NS_FILE, $title->getDBkey() );
+ }
+ if ( !is_null( $arg ) ) {
+ $text = $title->$func( $arg );
+ } else {
+ $text = $title->$func();
+ }
+ return $text;
+ } else {
+ return [ 'found' => false ];
+ }
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $num
+ * @param string $arg
+ * @return string
+ */
+ public static function formatnum( $parser, $num = '', $arg = null ) {
+ if ( self::matchAgainstMagicword( 'rawsuffix', $arg ) ) {
+ $func = [ $parser->getFunctionLang(), 'parseFormattedNumber' ];
+ } elseif ( self::matchAgainstMagicword( 'nocommafysuffix', $arg ) ) {
+ $func = [ $parser->getFunctionLang(), 'formatNumNoSeparators' ];
+ } else {
+ $func = [ $parser->getFunctionLang(), 'formatNum' ];
+ }
+ return $parser->markerSkipCallback( $num, $func );
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $case
+ * @param string $word
+ * @return string
+ */
+ public static function grammar( $parser, $case = '', $word = '' ) {
+ $word = $parser->killMarkers( $word );
+ return $parser->getFunctionLang()->convertGrammar( $word, $case );
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $username
+ * @return string
+ */
+ public static function gender( $parser, $username ) {
+ $forms = array_slice( func_get_args(), 2 );
+
+ // Some shortcuts to avoid loading user data unnecessarily
+ if ( count( $forms ) === 0 ) {
+ return '';
+ } elseif ( count( $forms ) === 1 ) {
+ return $forms[0];
+ }
+
+ $username = trim( $username );
+
+ // default
+ $gender = User::getDefaultOption( 'gender' );
+
+ // allow prefix.
+ $title = Title::newFromText( $username );
+
+ if ( $title && $title->inNamespace( NS_USER ) ) {
+ $username = $title->getText();
+ }
+
+ // check parameter, or use the ParserOptions if in interface message
+ $user = User::newFromName( $username );
+ $genderCache = MediaWikiServices::getInstance()->getGenderCache();
+ if ( $user ) {
+ $gender = $genderCache->getGenderOf( $user, __METHOD__ );
+ } elseif ( $username === '' && $parser->getOptions()->getInterfaceMessage() ) {
+ $gender = $genderCache->getGenderOf( $parser->getOptions()->getUser(), __METHOD__ );
+ }
+ $ret = $parser->getFunctionLang()->gender( $gender, $forms );
+ return $ret;
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $text
+ * @return string
+ */
+ public static function plural( $parser, $text = '' ) {
+ $forms = array_slice( func_get_args(), 2 );
+ $text = $parser->getFunctionLang()->parseFormattedNumber( $text );
+ settype( $text, ctype_digit( $text ) ? 'int' : 'float' );
+ return $parser->getFunctionLang()->convertPlural( $text, $forms );
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $text
+ * @return string
+ */
+ public static function bidi( $parser, $text = '' ) {
+ return $parser->getFunctionLang()->embedBidi( $text );
+ }
+
+ /**
+ * Override the title of the page when viewed, provided we've been given a
+ * title which will normalise to the canonical title
+ *
+ * @param Parser $parser Parent parser
+ * @param string $text Desired title text
+ * @param string $uarg
+ * @return string
+ */
+ public static function displaytitle( $parser, $text = '', $uarg = '' ) {
+ global $wgRestrictDisplayTitle;
+
+ static $magicWords = null;
+ if ( is_null( $magicWords ) ) {
+ $magicWords = new MagicWordArray( [ 'displaytitle_noerror', 'displaytitle_noreplace' ] );
+ }
+ $arg = $magicWords->matchStartToEnd( $uarg );
+
+ // parse a limited subset of wiki markup (just the single quote items)
+ $text = $parser->doQuotes( $text );
+
+ // remove stripped text (e.g. the UNIQ-QINU stuff) that was generated by tag extensions/whatever
+ $text = $parser->killMarkers( $text );
+
+ // list of disallowed tags for DISPLAYTITLE
+ // these will be escaped even though they are allowed in normal wiki text
+ $bad = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'blockquote', 'ol', 'ul', 'li', 'hr',
+ 'table', 'tr', 'th', 'td', 'dl', 'dd', 'caption', 'p', 'ruby', 'rb', 'rt', 'rtc', 'rp', 'br' ];
+
+ // disallow some styles that could be used to bypass $wgRestrictDisplayTitle
+ if ( $wgRestrictDisplayTitle ) {
+ $htmlTagsCallback = function ( &$params ) {
+ $decoded = Sanitizer::decodeTagAttributes( $params );
+
+ if ( isset( $decoded['style'] ) ) {
+ // this is called later anyway, but we need it right now for the regexes below to be safe
+ // calling it twice doesn't hurt
+ $decoded['style'] = Sanitizer::checkCss( $decoded['style'] );
+
+ if ( preg_match( '/(display|user-select|visibility)\s*:/i', $decoded['style'] ) ) {
+ $decoded['style'] = '/* attempt to bypass $wgRestrictDisplayTitle */';
+ }
+ }
+
+ $params = Sanitizer::safeEncodeTagAttributes( $decoded );
+ };
+ } else {
+ $htmlTagsCallback = null;
+ }
+
+ // only requested titles that normalize to the actual title are allowed through
+ // if $wgRestrictDisplayTitle is true (it is by default)
+ // mimic the escaping process that occurs in OutputPage::setPageTitle
+ $text = Sanitizer::normalizeCharReferences( Sanitizer::removeHTMLtags(
+ $text,
+ $htmlTagsCallback,
+ [],
+ [],
+ $bad
+ ) );
+ $title = Title::newFromText( Sanitizer::stripAllTags( $text ) );
+
+ if ( !$wgRestrictDisplayTitle ||
+ ( $title instanceof Title
+ && !$title->hasFragment()
+ && $title->equals( $parser->mTitle ) )
+ ) {
+ $old = $parser->mOutput->getProperty( 'displaytitle' );
+ if ( $old === false || $arg !== 'displaytitle_noreplace' ) {
+ $parser->mOutput->setDisplayTitle( $text );
+ }
+ if ( $old !== false && $old !== $text && !$arg ) {
+ $converter = $parser->getConverterLanguage()->getConverter();
+ return '<span class="error">' .
+ wfMessage( 'duplicate-displaytitle',
+ // Message should be parsed, but these params should only be escaped.
+ $converter->markNoConversion( wfEscapeWikiText( $old ) ),
+ $converter->markNoConversion( wfEscapeWikiText( $text ) )
+ )->inContentLanguage()->text() .
+ '</span>';
+ } else {
+ return '';
+ }
+ } else {
+ $converter = $parser->getConverterLanguage()->getConverter();
+ $parser->getOutput()->addWarning(
+ wfMessage( 'restricted-displaytitle',
+ // Message should be parsed, but this param should only be escaped.
+ $converter->markNoConversion( wfEscapeWikiText( $text ) )
+ )->text()
+ );
+ $parser->addTrackingCategory( 'restricted-displaytitle-ignored' );
+ }
+ }
+
+ /**
+ * Matches the given value against the value of given magic word
+ *
+ * @param string $magicword Magic word key
+ * @param string $value Value to match
+ * @return bool True on successful match
+ */
+ private static function matchAgainstMagicword( $magicword, $value ) {
+ $value = trim( strval( $value ) );
+ if ( $value === '' ) {
+ return false;
+ }
+ $mwObject = MagicWord::get( $magicword );
+ return $mwObject->matchStartToEnd( $value );
+ }
+
+ /**
+ * Formats a number according to a language.
+ *
+ * @param int|float $num
+ * @param string $raw
+ * @param Language|StubUserLang $language
+ * @return string
+ */
+ public static function formatRaw( $num, $raw, $language ) {
+ if ( self::matchAgainstMagicword( 'rawsuffix', $raw ) ) {
+ return $num;
+ } else {
+ return $language->formatNum( $num );
+ }
+ }
+
+ public static function numberofpages( $parser, $raw = null ) {
+ return self::formatRaw( SiteStats::pages(), $raw, $parser->getFunctionLang() );
+ }
+
+ public static function numberofusers( $parser, $raw = null ) {
+ return self::formatRaw( SiteStats::users(), $raw, $parser->getFunctionLang() );
+ }
+ public static function numberofactiveusers( $parser, $raw = null ) {
+ return self::formatRaw( SiteStats::activeUsers(), $raw, $parser->getFunctionLang() );
+ }
+
+ public static function numberofarticles( $parser, $raw = null ) {
+ return self::formatRaw( SiteStats::articles(), $raw, $parser->getFunctionLang() );
+ }
+
+ public static function numberoffiles( $parser, $raw = null ) {
+ return self::formatRaw( SiteStats::images(), $raw, $parser->getFunctionLang() );
+ }
+
+ public static function numberofadmins( $parser, $raw = null ) {
+ return self::formatRaw(
+ SiteStats::numberingroup( 'sysop' ),
+ $raw,
+ $parser->getFunctionLang()
+ );
+ }
+
+ public static function numberofedits( $parser, $raw = null ) {
+ return self::formatRaw( SiteStats::edits(), $raw, $parser->getFunctionLang() );
+ }
+
+ public static function pagesinnamespace( $parser, $namespace = 0, $raw = null ) {
+ return self::formatRaw(
+ SiteStats::pagesInNs( intval( $namespace ) ),
+ $raw,
+ $parser->getFunctionLang()
+ );
+ }
+ public static function numberingroup( $parser, $name = '', $raw = null ) {
+ return self::formatRaw(
+ SiteStats::numberingroup( strtolower( $name ) ),
+ $raw,
+ $parser->getFunctionLang()
+ );
+ }
+
+ /**
+ * Given a title, return the namespace name that would be given by the
+ * corresponding magic word
+ * Note: function name changed to "mwnamespace" rather than "namespace"
+ * to not break PHP 5.3
+ * @param Parser $parser
+ * @param string $title
+ * @return mixed|string
+ */
+ public static function mwnamespace( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return str_replace( '_', ' ', $t->getNsText() );
+ }
+ public static function namespacee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfUrlencode( $t->getNsText() );
+ }
+ public static function namespacenumber( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return $t->getNamespace();
+ }
+ public static function talkspace( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) || !$t->canHaveTalkPage() ) {
+ return '';
+ }
+ return str_replace( '_', ' ', $t->getTalkNsText() );
+ }
+ public static function talkspacee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) || !$t->canHaveTalkPage() ) {
+ return '';
+ }
+ return wfUrlencode( $t->getTalkNsText() );
+ }
+ public static function subjectspace( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return str_replace( '_', ' ', $t->getSubjectNsText() );
+ }
+ public static function subjectspacee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfUrlencode( $t->getSubjectNsText() );
+ }
+
+ /**
+ * Functions to get and normalize pagenames, corresponding to the magic words
+ * of the same names
+ * @param Parser $parser
+ * @param string $title
+ * @return string
+ */
+ public static function pagename( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getText() );
+ }
+ public static function pagenamee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getPartialURL() );
+ }
+ public static function fullpagename( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) || !$t->canHaveTalkPage() ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getPrefixedText() );
+ }
+ public static function fullpagenamee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) || !$t->canHaveTalkPage() ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getPrefixedURL() );
+ }
+ public static function subpagename( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getSubpageText() );
+ }
+ public static function subpagenamee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getSubpageUrlForm() );
+ }
+ public static function rootpagename( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getRootText() );
+ }
+ public static function rootpagenamee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( wfUrlencode( str_replace( ' ', '_', $t->getRootText() ) ) );
+ }
+ public static function basepagename( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getBaseText() );
+ }
+ public static function basepagenamee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( wfUrlencode( str_replace( ' ', '_', $t->getBaseText() ) ) );
+ }
+ public static function talkpagename( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) || !$t->canHaveTalkPage() ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getTalkPage()->getPrefixedText() );
+ }
+ public static function talkpagenamee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) || !$t->canHaveTalkPage() ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getTalkPage()->getPrefixedURL() );
+ }
+ public static function subjectpagename( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getSubjectPage()->getPrefixedText() );
+ }
+ public static function subjectpagenamee( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ return wfEscapeWikiText( $t->getSubjectPage()->getPrefixedURL() );
+ }
+
+ /**
+ * Return the number of pages, files or subcats in the given category,
+ * or 0 if it's nonexistent. This is an expensive parser function and
+ * can't be called too many times per page.
+ * @param Parser $parser
+ * @param string $name
+ * @param string $arg1
+ * @param string $arg2
+ * @return string
+ */
+ public static function pagesincategory( $parser, $name = '', $arg1 = null, $arg2 = null ) {
+ global $wgContLang;
+ static $magicWords = null;
+ if ( is_null( $magicWords ) ) {
+ $magicWords = new MagicWordArray( [
+ 'pagesincategory_all',
+ 'pagesincategory_pages',
+ 'pagesincategory_subcats',
+ 'pagesincategory_files'
+ ] );
+ }
+ static $cache = [];
+
+ // split the given option to its variable
+ if ( self::matchAgainstMagicword( 'rawsuffix', $arg1 ) ) {
+ // {{pagesincategory:|raw[|type]}}
+ $raw = $arg1;
+ $type = $magicWords->matchStartToEnd( $arg2 );
+ } else {
+ // {{pagesincategory:[|type[|raw]]}}
+ $type = $magicWords->matchStartToEnd( $arg1 );
+ $raw = $arg2;
+ }
+ if ( !$type ) { // backward compatibility
+ $type = 'pagesincategory_all';
+ }
+
+ $title = Title::makeTitleSafe( NS_CATEGORY, $name );
+ if ( !$title ) { # invalid title
+ return self::formatRaw( 0, $raw, $parser->getFunctionLang() );
+ }
+ $wgContLang->findVariantLink( $name, $title, true );
+
+ // Normalize name for cache
+ $name = $title->getDBkey();
+
+ if ( !isset( $cache[$name] ) ) {
+ $category = Category::newFromTitle( $title );
+
+ $allCount = $subcatCount = $fileCount = $pagesCount = 0;
+ if ( $parser->incrementExpensiveFunctionCount() ) {
+ // $allCount is the total number of cat members,
+ // not the count of how many members are normal pages.
+ $allCount = (int)$category->getPageCount();
+ $subcatCount = (int)$category->getSubcatCount();
+ $fileCount = (int)$category->getFileCount();
+ $pagesCount = $allCount - $subcatCount - $fileCount;
+ }
+ $cache[$name]['pagesincategory_all'] = $allCount;
+ $cache[$name]['pagesincategory_pages'] = $pagesCount;
+ $cache[$name]['pagesincategory_subcats'] = $subcatCount;
+ $cache[$name]['pagesincategory_files'] = $fileCount;
+ }
+
+ $count = $cache[$name][$type];
+ return self::formatRaw( $count, $raw, $parser->getFunctionLang() );
+ }
+
+ /**
+ * Return the size of the given page, or 0 if it's nonexistent. This is an
+ * expensive parser function and can't be called too many times per page.
+ *
+ * @param Parser $parser
+ * @param string $page Name of page to check (Default: empty string)
+ * @param string $raw Should number be human readable with commas or just number
+ * @return string
+ */
+ public static function pagesize( $parser, $page = '', $raw = null ) {
+ $title = Title::newFromText( $page );
+
+ if ( !is_object( $title ) ) {
+ return self::formatRaw( 0, $raw, $parser->getFunctionLang() );
+ }
+
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $title );
+ $length = $rev ? $rev->getSize() : 0;
+ if ( $length === null ) {
+ // We've had bugs where rev_len was not being recorded for empty pages, see T135414
+ $length = 0;
+ }
+ return self::formatRaw( $length, $raw, $parser->getFunctionLang() );
+ }
+
+ /**
+ * Returns the requested protection level for the current page. This
+ * is an expensive parser function and can't be called too many times
+ * per page, unless the protection levels/expiries for the given title
+ * have already been retrieved
+ *
+ * @param Parser $parser
+ * @param string $type
+ * @param string $title
+ *
+ * @return string
+ */
+ public static function protectionlevel( $parser, $type = '', $title = '' ) {
+ $titleObject = Title::newFromText( $title );
+ if ( !( $titleObject instanceof Title ) ) {
+ $titleObject = $parser->mTitle;
+ }
+ if ( $titleObject->areRestrictionsLoaded() || $parser->incrementExpensiveFunctionCount() ) {
+ $restrictions = $titleObject->getRestrictions( strtolower( $type ) );
+ # Title::getRestrictions returns an array, its possible it may have
+ # multiple values in the future
+ return implode( $restrictions, ',' );
+ }
+ return '';
+ }
+
+ /**
+ * Returns the requested protection expiry for the current page. This
+ * is an expensive parser function and can't be called too many times
+ * per page, unless the protection levels/expiries for the given title
+ * have already been retrieved
+ *
+ * @param Parser $parser
+ * @param string $type
+ * @param string $title
+ *
+ * @return string
+ */
+ public static function protectionexpiry( $parser, $type = '', $title = '' ) {
+ $titleObject = Title::newFromText( $title );
+ if ( !( $titleObject instanceof Title ) ) {
+ $titleObject = $parser->mTitle;
+ }
+ if ( $titleObject->areRestrictionsLoaded() || $parser->incrementExpensiveFunctionCount() ) {
+ $expiry = $titleObject->getRestrictionExpiry( strtolower( $type ) );
+ // getRestrictionExpiry() returns false on invalid type; trying to
+ // match protectionlevel() function that returns empty string instead
+ if ( $expiry === false ) {
+ $expiry = '';
+ }
+ return $expiry;
+ }
+ return '';
+ }
+
+ /**
+ * Gives language names.
+ * @param Parser $parser
+ * @param string $code Language code (of which to get name)
+ * @param string $inLanguage Language code (in which to get name)
+ * @return string
+ */
+ public static function language( $parser, $code = '', $inLanguage = '' ) {
+ $code = strtolower( $code );
+ $inLanguage = strtolower( $inLanguage );
+ $lang = Language::fetchLanguageName( $code, $inLanguage );
+ return $lang !== '' ? $lang : wfBCP47( $code );
+ }
+
+ /**
+ * Unicode-safe str_pad with the restriction that $length is forced to be <= 500
+ * @param Parser $parser
+ * @param string $string
+ * @param string $length
+ * @param string $padding
+ * @param int $direction
+ * @return string
+ */
+ public static function pad(
+ $parser, $string, $length, $padding = '0', $direction = STR_PAD_RIGHT
+ ) {
+ $padding = $parser->killMarkers( $padding );
+ $lengthOfPadding = mb_strlen( $padding );
+ if ( $lengthOfPadding == 0 ) {
+ return $string;
+ }
+
+ # The remaining length to add counts down to 0 as padding is added
+ $length = min( (int)$length, 500 ) - mb_strlen( $string );
+ if ( $length <= 0 ) {
+ // Nothing to add
+ return $string;
+ }
+
+ # $finalPadding is just $padding repeated enough times so that
+ # mb_strlen( $string ) + mb_strlen( $finalPadding ) == $length
+ $finalPadding = '';
+ while ( $length > 0 ) {
+ # If $length < $lengthofPadding, truncate $padding so we get the
+ # exact length desired.
+ $finalPadding .= mb_substr( $padding, 0, $length );
+ $length -= $lengthOfPadding;
+ }
+
+ if ( $direction == STR_PAD_LEFT ) {
+ return $finalPadding . $string;
+ } else {
+ return $string . $finalPadding;
+ }
+ }
+
+ public static function padleft( $parser, $string = '', $length = 0, $padding = '0' ) {
+ return self::pad( $parser, $string, $length, $padding, STR_PAD_LEFT );
+ }
+
+ public static function padright( $parser, $string = '', $length = 0, $padding = '0' ) {
+ return self::pad( $parser, $string, $length, $padding );
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $text
+ * @return string
+ */
+ public static function anchorencode( $parser, $text ) {
+ $text = $parser->killMarkers( $text );
+ return (string)substr( $parser->guessSectionNameFromWikiText( $text ), 1 );
+ }
+
+ public static function special( $parser, $text ) {
+ list( $page, $subpage ) = SpecialPageFactory::resolveAlias( $text );
+ if ( $page ) {
+ $title = SpecialPage::getTitleFor( $page, $subpage );
+ return $title->getPrefixedText();
+ } else {
+ // unknown special page, just use the given text as its title, if at all possible
+ $title = Title::makeTitleSafe( NS_SPECIAL, $text );
+ return $title ? $title->getPrefixedText() : self::special( $parser, 'Badtitle' );
+ }
+ }
+
+ public static function speciale( $parser, $text ) {
+ return wfUrlencode( str_replace( ' ', '_', self::special( $parser, $text ) ) );
+ }
+
+ /**
+ * @param Parser $parser
+ * @param string $text The sortkey to use
+ * @param string $uarg Either "noreplace" or "noerror" (in en)
+ * both suppress errors, and noreplace does nothing if
+ * a default sortkey already exists.
+ * @return string
+ */
+ public static function defaultsort( $parser, $text, $uarg = '' ) {
+ static $magicWords = null;
+ if ( is_null( $magicWords ) ) {
+ $magicWords = new MagicWordArray( [ 'defaultsort_noerror', 'defaultsort_noreplace' ] );
+ }
+ $arg = $magicWords->matchStartToEnd( $uarg );
+
+ $text = trim( $text );
+ if ( strlen( $text ) == 0 ) {
+ return '';
+ }
+ $old = $parser->getCustomDefaultSort();
+ if ( $old === false || $arg !== 'defaultsort_noreplace' ) {
+ $parser->setDefaultSort( $text );
+ }
+
+ if ( $old === false || $old == $text || $arg ) {
+ return '';
+ } else {
+ $converter = $parser->getConverterLanguage()->getConverter();
+ return '<span class="error">' .
+ wfMessage( 'duplicate-defaultsort',
+ // Message should be parsed, but these params should only be escaped.
+ $converter->markNoConversion( wfEscapeWikiText( $old ) ),
+ $converter->markNoConversion( wfEscapeWikiText( $text ) )
+ )->inContentLanguage()->text() .
+ '</span>';
+ }
+ }
+
+ /**
+ * Usage {{filepath|300}}, {{filepath|nowiki}}, {{filepath|nowiki|300}}
+ * or {{filepath|300|nowiki}} or {{filepath|300px}}, {{filepath|200x300px}},
+ * {{filepath|nowiki|200x300px}}, {{filepath|200x300px|nowiki}}.
+ *
+ * @param Parser $parser
+ * @param string $name
+ * @param string $argA
+ * @param string $argB
+ * @return array|string
+ */
+ public static function filepath( $parser, $name = '', $argA = '', $argB = '' ) {
+ $file = wfFindFile( $name );
+
+ if ( $argA == 'nowiki' ) {
+ // {{filepath: | option [| size] }}
+ $isNowiki = true;
+ $parsedWidthParam = $parser->parseWidthParam( $argB );
+ } else {
+ // {{filepath: [| size [|option]] }}
+ $parsedWidthParam = $parser->parseWidthParam( $argA );
+ $isNowiki = ( $argB == 'nowiki' );
+ }
+
+ if ( $file ) {
+ $url = $file->getFullUrl();
+
+ // If a size is requested...
+ if ( count( $parsedWidthParam ) ) {
+ $mto = $file->transform( $parsedWidthParam );
+ // ... and we can
+ if ( $mto && !$mto->isError() ) {
+ // ... change the URL to point to a thumbnail.
+ $url = wfExpandUrl( $mto->getUrl(), PROTO_RELATIVE );
+ }
+ }
+ if ( $isNowiki ) {
+ return [ $url, 'nowiki' => true ];
+ }
+ return $url;
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Parser function to extension tag adaptor
+ * @param Parser $parser
+ * @param PPFrame $frame
+ * @param PPNode[] $args
+ * @return string
+ */
+ public static function tagObj( $parser, $frame, $args ) {
+ if ( !count( $args ) ) {
+ return '';
+ }
+ $tagName = strtolower( trim( $frame->expand( array_shift( $args ) ) ) );
+
+ if ( count( $args ) ) {
+ $inner = $frame->expand( array_shift( $args ) );
+ } else {
+ $inner = null;
+ }
+
+ $attributes = [];
+ foreach ( $args as $arg ) {
+ $bits = $arg->splitArg();
+ if ( strval( $bits['index'] ) === '' ) {
+ $name = trim( $frame->expand( $bits['name'], PPFrame::STRIP_COMMENTS ) );
+ $value = trim( $frame->expand( $bits['value'] ) );
+ if ( preg_match( '/^(?:["\'](.+)["\']|""|\'\')$/s', $value, $m ) ) {
+ $value = isset( $m[1] ) ? $m[1] : '';
+ }
+ $attributes[$name] = $value;
+ }
+ }
+
+ $stripList = $parser->getStripList();
+ if ( !in_array( $tagName, $stripList ) ) {
+ // we can't handle this tag (at least not now), so just re-emit it as an ordinary tag
+ $attrText = '';
+ foreach ( $attributes as $name => $value ) {
+ $attrText .= ' ' . htmlspecialchars( $name ) . '="' . htmlspecialchars( $value ) . '"';
+ }
+ if ( $inner === null ) {
+ return "<$tagName$attrText/>";
+ }
+ return "<$tagName$attrText>$inner</$tagName>";
+ }
+
+ $params = [
+ 'name' => $tagName,
+ 'inner' => $inner,
+ 'attributes' => $attributes,
+ 'close' => "</$tagName>",
+ ];
+ return $parser->extensionSubstitution( $params, $frame );
+ }
+
+ /**
+ * Fetched the current revision of the given title and return this.
+ * Will increment the expensive function count and
+ * add a template link to get the value refreshed on changes.
+ * For a given title, which is equal to the current parser title,
+ * the revision object from the parser is used, when that is the current one
+ *
+ * @param Parser $parser
+ * @param Title $title
+ * @return Revision
+ * @since 1.23
+ */
+ private static function getCachedRevisionObject( $parser, $title = null ) {
+ if ( is_null( $title ) ) {
+ return null;
+ }
+
+ // Use the revision from the parser itself, when param is the current page
+ // and the revision is the current one
+ if ( $title->equals( $parser->getTitle() ) ) {
+ $parserRev = $parser->getRevisionObject();
+ if ( $parserRev && $parserRev->isCurrent() ) {
+ // force reparse after edit with vary-revision flag
+ $parser->getOutput()->setFlag( 'vary-revision' );
+ wfDebug( __METHOD__ . ": use current revision from parser, setting vary-revision...\n" );
+ return $parserRev;
+ }
+ }
+
+ // Normalize name for cache
+ $page = $title->getPrefixedDBkey();
+
+ if ( !( $parser->currentRevisionCache && $parser->currentRevisionCache->has( $page ) )
+ && !$parser->incrementExpensiveFunctionCount() ) {
+ return null;
+ }
+ $rev = $parser->fetchCurrentRevisionOfTitle( $title );
+ $pageID = $rev ? $rev->getPage() : 0;
+ $revID = $rev ? $rev->getId() : 0;
+
+ // Register dependency in templatelinks
+ $parser->getOutput()->addTemplate( $title, $pageID, $revID );
+
+ return $rev;
+ }
+
+ /**
+ * Get the pageid of a specified page
+ * @param Parser $parser
+ * @param string $title Title to get the pageid from
+ * @return int|null|string
+ * @since 1.23
+ */
+ public static function pageid( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // Use title from parser to have correct pageid after edit
+ if ( $t->equals( $parser->getTitle() ) ) {
+ $t = $parser->getTitle();
+ return $t->getArticleID();
+ }
+
+ // These can't have ids
+ if ( !$t->canExist() || $t->isExternal() ) {
+ return 0;
+ }
+
+ // Check the link cache, maybe something already looked it up.
+ $linkCache = LinkCache::singleton();
+ $pdbk = $t->getPrefixedDBkey();
+ $id = $linkCache->getGoodLinkID( $pdbk );
+ if ( $id != 0 ) {
+ $parser->mOutput->addLink( $t, $id );
+ return $id;
+ }
+ if ( $linkCache->isBadLink( $pdbk ) ) {
+ $parser->mOutput->addLink( $t, 0 );
+ return $id;
+ }
+
+ // We need to load it from the DB, so mark expensive
+ if ( $parser->incrementExpensiveFunctionCount() ) {
+ $id = $t->getArticleID();
+ $parser->mOutput->addLink( $t, $id );
+ return $id;
+ }
+ return null;
+ }
+
+ /**
+ * Get the id from the last revision of a specified page.
+ * @param Parser $parser
+ * @param string $title Title to get the id from
+ * @return int|null|string
+ * @since 1.23
+ */
+ public static function revisionid( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $t );
+ return $rev ? $rev->getId() : '';
+ }
+
+ /**
+ * Get the day from the last revision of a specified page.
+ * @param Parser $parser
+ * @param string $title Title to get the day from
+ * @return string
+ * @since 1.23
+ */
+ public static function revisionday( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $t );
+ return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'j' ) : '';
+ }
+
+ /**
+ * Get the day with leading zeros from the last revision of a specified page.
+ * @param Parser $parser
+ * @param string $title Title to get the day from
+ * @return string
+ * @since 1.23
+ */
+ public static function revisionday2( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $t );
+ return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'd' ) : '';
+ }
+
+ /**
+ * Get the month with leading zeros from the last revision of a specified page.
+ * @param Parser $parser
+ * @param string $title Title to get the month from
+ * @return string
+ * @since 1.23
+ */
+ public static function revisionmonth( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $t );
+ return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'm' ) : '';
+ }
+
+ /**
+ * Get the month from the last revision of a specified page.
+ * @param Parser $parser
+ * @param string $title Title to get the month from
+ * @return string
+ * @since 1.23
+ */
+ public static function revisionmonth1( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $t );
+ return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'n' ) : '';
+ }
+
+ /**
+ * Get the year from the last revision of a specified page.
+ * @param Parser $parser
+ * @param string $title Title to get the year from
+ * @return string
+ * @since 1.23
+ */
+ public static function revisionyear( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $t );
+ return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'Y' ) : '';
+ }
+
+ /**
+ * Get the timestamp from the last revision of a specified page.
+ * @param Parser $parser
+ * @param string $title Title to get the timestamp from
+ * @return string
+ * @since 1.23
+ */
+ public static function revisiontimestamp( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $t );
+ return $rev ? MWTimestamp::getLocalInstance( $rev->getTimestamp() )->format( 'YmdHis' ) : '';
+ }
+
+ /**
+ * Get the user from the last revision of a specified page.
+ * @param Parser $parser
+ * @param string $title Title to get the user from
+ * @return string
+ * @since 1.23
+ */
+ public static function revisionuser( $parser, $title = null ) {
+ $t = Title::newFromText( $title );
+ if ( is_null( $t ) ) {
+ return '';
+ }
+ // fetch revision from cache/database and return the value
+ $rev = self::getCachedRevisionObject( $parser, $t );
+ return $rev ? $rev->getUserText() : '';
+ }
+
+ /**
+ * Returns the sources of any cascading protection acting on a specified page.
+ * Pages will not return their own title unless they transclude themselves.
+ * This is an expensive parser function and can't be called too many times per page,
+ * unless cascading protection sources for the page have already been loaded.
+ *
+ * @param Parser $parser
+ * @param string $title
+ *
+ * @return string
+ * @since 1.23
+ */
+ public static function cascadingsources( $parser, $title = '' ) {
+ $titleObject = Title::newFromText( $title );
+ if ( !( $titleObject instanceof Title ) ) {
+ $titleObject = $parser->mTitle;
+ }
+ if ( $titleObject->areCascadeProtectionSourcesLoaded()
+ || $parser->incrementExpensiveFunctionCount()
+ ) {
+ $names = [];
+ $sources = $titleObject->getCascadeProtectionSources();
+ foreach ( $sources[0] as $sourceTitle ) {
+ $names[] = $sourceTitle->getPrefixedText();
+ }
+ return implode( $names, '|' );
+ }
+ return '';
+ }
+
+}
diff --git a/www/wiki/includes/parser/CoreTagHooks.php b/www/wiki/includes/parser/CoreTagHooks.php
new file mode 100644
index 00000000..438603a8
--- /dev/null
+++ b/www/wiki/includes/parser/CoreTagHooks.php
@@ -0,0 +1,176 @@
+<?php
+/**
+ * Tag hooks provided by MediaWiki core
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * Various tag hooks, registered in Parser::firstCallInit()
+ * @ingroup Parser
+ */
+class CoreTagHooks {
+ /**
+ * @param Parser $parser
+ * @return void
+ */
+ public static function register( $parser ) {
+ global $wgRawHtml;
+ $parser->setHook( 'pre', [ __CLASS__, 'pre' ] );
+ $parser->setHook( 'nowiki', [ __CLASS__, 'nowiki' ] );
+ $parser->setHook( 'gallery', [ __CLASS__, 'gallery' ] );
+ $parser->setHook( 'indicator', [ __CLASS__, 'indicator' ] );
+ if ( $wgRawHtml ) {
+ $parser->setHook( 'html', [ __CLASS__, 'html' ] );
+ }
+ }
+
+ /**
+ * Core parser tag hook function for 'pre'.
+ * Text is treated roughly as 'nowiki' wrapped in an HTML 'pre' tag;
+ * valid HTML attributes are passed on.
+ *
+ * @param string $text
+ * @param array $attribs
+ * @param Parser $parser
+ * @return string HTML
+ */
+ public static function pre( $text, $attribs, $parser ) {
+ // Backwards-compatibility hack
+ $content = StringUtils::delimiterReplace( '<nowiki>', '</nowiki>', '$1', $text, 'i' );
+
+ $attribs = Sanitizer::validateTagAttributes( $attribs, 'pre' );
+ // We need to let both '"' and '&' through,
+ // for strip markers and entities respectively.
+ $content = str_replace(
+ [ '>', '<' ],
+ [ '&gt;', '&lt;' ],
+ $content
+ );
+ return Html::rawElement( 'pre', $attribs, $content );
+ }
+
+ /**
+ * Core parser tag hook function for 'html', used only when
+ * $wgRawHtml is enabled.
+ *
+ * This is potentially unsafe and should be used only in very careful
+ * circumstances, as the contents are emitted as raw HTML.
+ *
+ * Uses undocumented extended tag hook return values, introduced in r61913.
+ *
+ * @param string $content
+ * @param array $attributes
+ * @param Parser $parser
+ * @throws MWException
+ * @return array|string Output of tag hook
+ */
+ public static function html( $content, $attributes, $parser ) {
+ global $wgRawHtml;
+ if ( $wgRawHtml ) {
+ if ( $parser->getOptions()->getAllowUnsafeRawHtml() ) {
+ return [ $content, 'markerType' => 'nowiki' ];
+ } else {
+ // In a system message where raw html is
+ // not allowed (but it is allowed in other
+ // contexts).
+ return Html::rawElement(
+ 'span',
+ [ 'class' => 'error' ],
+ // Using ->text() not ->parse() as
+ // a paranoia measure against a loop.
+ wfMessage( 'rawhtml-notallowed' )->escaped()
+ );
+ }
+ } else {
+ throw new MWException( '<html> extension tag encountered unexpectedly' );
+ }
+ }
+
+ /**
+ * Core parser tag hook function for 'nowiki'. Text within this section
+ * gets interpreted as a string of text with HTML-compatible character
+ * references, and wiki markup within it will not be expanded.
+ *
+ * Uses undocumented extended tag hook return values, introduced in r61913.
+ *
+ * @param string $content
+ * @param array $attributes
+ * @param Parser $parser
+ * @return array
+ */
+ public static function nowiki( $content, $attributes, $parser ) {
+ $content = strtr( $content, [
+ // lang converter
+ '-{' => '-&#123;',
+ '}-' => '&#125;-',
+ // html tags
+ '<' => '&lt;',
+ '>' => '&gt;'
+ // Note: Both '"' and '&' are not converted.
+ // This allows strip markers and entities through.
+ ] );
+ return [ $content, 'markerType' => 'nowiki' ];
+ }
+
+ /**
+ * Core parser tag hook function for 'gallery'.
+ *
+ * Renders a thumbnail list of the given images, with optional captions.
+ * Full syntax documented on the wiki:
+ *
+ * https://www.mediawiki.org/wiki/Help:Images#Gallery_syntax
+ *
+ * @todo break Parser::renderImageGallery out here too.
+ *
+ * @param string $content
+ * @param array $attributes
+ * @param Parser $parser
+ * @return string HTML
+ */
+ public static function gallery( $content, $attributes, $parser ) {
+ return $parser->renderImageGallery( $content, $attributes );
+ }
+
+ /**
+ * XML-style tag for page status indicators: icons (or short text snippets) usually displayed in
+ * the top-right corner of the page, outside of the main content.
+ *
+ * @param string $content
+ * @param array $attributes
+ * @param Parser $parser
+ * @param PPFrame $frame
+ * @return string
+ * @since 1.25
+ */
+ public static function indicator( $content, array $attributes, Parser $parser, PPFrame $frame ) {
+ if ( !isset( $attributes['name'] ) || trim( $attributes['name'] ) === '' ) {
+ return '<span class="error">' .
+ wfMessage( 'invalid-indicator-name' )->inContentLanguage()->parse() .
+ '</span>';
+ }
+
+ $parser->getOutput()->setIndicator(
+ trim( $attributes['name'] ),
+ Parser::stripOuterParagraph( $parser->recursiveTagParseFully( $content, $frame ) )
+ );
+
+ return '';
+ }
+}
diff --git a/www/wiki/includes/parser/DateFormatter.php b/www/wiki/includes/parser/DateFormatter.php
new file mode 100644
index 00000000..605a873b
--- /dev/null
+++ b/www/wiki/includes/parser/DateFormatter.php
@@ -0,0 +1,388 @@
+<?php
+/**
+ * Date formatter
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * Date formatter, recognises dates in plain text and formats them according to user preferences.
+ * @todo preferences, OutputPage
+ * @ingroup Parser
+ */
+class DateFormatter {
+ private $mSource, $mTarget;
+ private $monthNames = '';
+
+ private $regexes;
+ private $rules, $xMonths, $preferences;
+
+ private $lang, $mLinked;
+
+ /** @var string[] */
+ private $keys;
+
+ /** @var string[] */
+ private $targets;
+
+ const ALL = -1;
+ const NONE = 0;
+ const MDY = 1;
+ const DMY = 2;
+ const YMD = 3;
+ const ISO1 = 4;
+ const LASTPREF = 4;
+ const ISO2 = 5;
+ const YDM = 6;
+ const DM = 7;
+ const MD = 8;
+ const LAST = 8;
+
+ /**
+ * @param Language $lang In which language to format the date
+ */
+ public function __construct( Language $lang ) {
+ $this->lang = $lang;
+
+ $this->monthNames = $this->getMonthRegex();
+ for ( $i = 1; $i <= 12; $i++ ) {
+ $this->xMonths[$this->lang->lc( $this->lang->getMonthName( $i ) )] = $i;
+ $this->xMonths[$this->lang->lc( $this->lang->getMonthAbbreviation( $i ) )] = $i;
+ }
+
+ $this->regexTrail = '(?![a-z])/iu';
+
+ # Partial regular expressions
+ $this->prxDM = '\[\[(\d{1,2})[ _](' . $this->monthNames . ')\]\]';
+ $this->prxMD = '\[\[(' . $this->monthNames . ')[ _](\d{1,2})\]\]';
+ $this->prxY = '\[\[(\d{1,4}([ _]BC|))\]\]';
+ $this->prxISO1 = '\[\[(-?\d{4})]]-\[\[(\d{2})-(\d{2})\]\]';
+ $this->prxISO2 = '\[\[(-?\d{4})-(\d{2})-(\d{2})\]\]';
+
+ # Real regular expressions
+ $this->regexes[self::DMY] = "/{$this->prxDM}(?: *, *| +){$this->prxY}{$this->regexTrail}";
+ $this->regexes[self::YDM] = "/{$this->prxY}(?: *, *| +){$this->prxDM}{$this->regexTrail}";
+ $this->regexes[self::MDY] = "/{$this->prxMD}(?: *, *| +){$this->prxY}{$this->regexTrail}";
+ $this->regexes[self::YMD] = "/{$this->prxY}(?: *, *| +){$this->prxMD}{$this->regexTrail}";
+ $this->regexes[self::DM] = "/{$this->prxDM}{$this->regexTrail}";
+ $this->regexes[self::MD] = "/{$this->prxMD}{$this->regexTrail}";
+ $this->regexes[self::ISO1] = "/{$this->prxISO1}{$this->regexTrail}";
+ $this->regexes[self::ISO2] = "/{$this->prxISO2}{$this->regexTrail}";
+
+ # Extraction keys
+ # See the comments in replace() for the meaning of the letters
+ $this->keys[self::DMY] = 'jFY';
+ $this->keys[self::YDM] = 'Y jF';
+ $this->keys[self::MDY] = 'FjY';
+ $this->keys[self::YMD] = 'Y Fj';
+ $this->keys[self::DM] = 'jF';
+ $this->keys[self::MD] = 'Fj';
+ $this->keys[self::ISO1] = 'ymd'; # y means ISO year
+ $this->keys[self::ISO2] = 'ymd';
+
+ # Target date formats
+ $this->targets[self::DMY] = '[[F j|j F]] [[Y]]';
+ $this->targets[self::YDM] = '[[Y]], [[F j|j F]]';
+ $this->targets[self::MDY] = '[[F j]], [[Y]]';
+ $this->targets[self::YMD] = '[[Y]] [[F j]]';
+ $this->targets[self::DM] = '[[F j|j F]]';
+ $this->targets[self::MD] = '[[F j]]';
+ $this->targets[self::ISO1] = '[[Y|y]]-[[F j|m-d]]';
+ $this->targets[self::ISO2] = '[[y-m-d]]';
+
+ # Rules
+ # pref source target
+ $this->rules[self::DMY][self::MD] = self::DM;
+ $this->rules[self::ALL][self::MD] = self::MD;
+ $this->rules[self::MDY][self::DM] = self::MD;
+ $this->rules[self::ALL][self::DM] = self::DM;
+ $this->rules[self::NONE][self::ISO2] = self::ISO1;
+
+ $this->preferences = [
+ 'default' => self::NONE,
+ 'dmy' => self::DMY,
+ 'mdy' => self::MDY,
+ 'ymd' => self::YMD,
+ 'ISO 8601' => self::ISO1,
+ ];
+ }
+
+ /**
+ * Get a DateFormatter object
+ *
+ * @param Language|string|null $lang In which language to format the date
+ * Defaults to the site content language
+ * @return DateFormatter
+ */
+ public static function getInstance( $lang = null ) {
+ global $wgContLang, $wgMainCacheType;
+
+ $lang = $lang ? wfGetLangObj( $lang ) : $wgContLang;
+ $cache = ObjectCache::getLocalServerInstance( $wgMainCacheType );
+
+ static $dateFormatter = false;
+ if ( !$dateFormatter ) {
+ $dateFormatter = $cache->getWithSetCallback(
+ $cache->makeKey( 'dateformatter', $lang->getCode() ),
+ $cache::TTL_HOUR,
+ function () use ( $lang ) {
+ return new DateFormatter( $lang );
+ }
+ );
+ }
+
+ return $dateFormatter;
+ }
+
+ /**
+ * @param string $preference User preference
+ * @param string $text Text to reformat
+ * @param array $options Array can contain 'linked' and/or 'match-whole'
+ *
+ * @return string
+ */
+ public function reformat( $preference, $text, $options = [ 'linked' ] ) {
+ $linked = in_array( 'linked', $options );
+ $match_whole = in_array( 'match-whole', $options );
+
+ if ( isset( $this->preferences[$preference] ) ) {
+ $preference = $this->preferences[$preference];
+ } else {
+ $preference = self::NONE;
+ }
+ for ( $i = 1; $i <= self::LAST; $i++ ) {
+ $this->mSource = $i;
+ if ( isset( $this->rules[$preference][$i] ) ) {
+ # Specific rules
+ $this->mTarget = $this->rules[$preference][$i];
+ } elseif ( isset( $this->rules[self::ALL][$i] ) ) {
+ # General rules
+ $this->mTarget = $this->rules[self::ALL][$i];
+ } elseif ( $preference ) {
+ # User preference
+ $this->mTarget = $preference;
+ } else {
+ # Default
+ $this->mTarget = $i;
+ }
+ $regex = $this->regexes[$i];
+
+ // Horrible hack
+ if ( !$linked ) {
+ $regex = str_replace( [ '\[\[', '\]\]' ], '', $regex );
+ }
+
+ if ( $match_whole ) {
+ // Let's hope this works
+ $regex = preg_replace( '!^/!', '/^', $regex );
+ $regex = str_replace( $this->regexTrail,
+ '$' . $this->regexTrail, $regex );
+ }
+
+ // Another horrible hack
+ $this->mLinked = $linked;
+ $text = preg_replace_callback( $regex, [ $this, 'replace' ], $text );
+ unset( $this->mLinked );
+ }
+ return $text;
+ }
+
+ /**
+ * Regexp replacement callback
+ *
+ * @param array $matches
+ * @return string
+ */
+ private function replace( $matches ) {
+ # Extract information from $matches
+ $linked = true;
+ if ( isset( $this->mLinked ) ) {
+ $linked = $this->mLinked;
+ }
+
+ $bits = [];
+ $key = $this->keys[$this->mSource];
+ $keyLength = strlen( $key );
+ for ( $p = 0; $p < $keyLength; $p++ ) {
+ if ( $key[$p] != ' ' ) {
+ $bits[$key[$p]] = $matches[$p + 1];
+ }
+ }
+
+ return $this->formatDate( $bits, $matches[0], $linked );
+ }
+
+ /**
+ * @param array $bits
+ * @param string $orig Original input string, to be returned
+ * on formatting failure.
+ * @param bool $link
+ * @return string
+ */
+ private function formatDate( $bits, $orig, $link = true ) {
+ $format = $this->targets[$this->mTarget];
+
+ if ( !$link ) {
+ // strip piped links
+ $format = preg_replace( '/\[\[[^|]+\|([^\]]+)\]\]/', '$1', $format );
+ // strip remaining links
+ $format = str_replace( [ '[[', ']]' ], '', $format );
+ }
+
+ # Construct new date
+ $text = '';
+ $fail = false;
+
+ // Pre-generate y/Y stuff because we need the year for the <span> title.
+ if ( !isset( $bits['y'] ) && isset( $bits['Y'] ) ) {
+ $bits['y'] = $this->makeIsoYear( $bits['Y'] );
+ }
+ if ( !isset( $bits['Y'] ) && isset( $bits['y'] ) ) {
+ $bits['Y'] = $this->makeNormalYear( $bits['y'] );
+ }
+
+ if ( !isset( $bits['m'] ) ) {
+ $m = $this->makeIsoMonth( $bits['F'] );
+ if ( !$m || $m == '00' ) {
+ $fail = true;
+ } else {
+ $bits['m'] = $m;
+ }
+ }
+
+ if ( !isset( $bits['d'] ) ) {
+ $bits['d'] = sprintf( '%02d', $bits['j'] );
+ }
+
+ $formatLength = strlen( $format );
+ for ( $p = 0; $p < $formatLength; $p++ ) {
+ $char = $format[$p];
+ switch ( $char ) {
+ case 'd': # ISO day of month
+ $text .= $bits['d'];
+ break;
+ case 'm': # ISO month
+ $text .= $bits['m'];
+ break;
+ case 'y': # ISO year
+ $text .= $bits['y'];
+ break;
+ case 'j': # ordinary day of month
+ if ( !isset( $bits['j'] ) ) {
+ $text .= intval( $bits['d'] );
+ } else {
+ $text .= $bits['j'];
+ }
+ break;
+ case 'F': # long month
+ if ( !isset( $bits['F'] ) ) {
+ $m = intval( $bits['m'] );
+ if ( $m > 12 || $m < 1 ) {
+ $fail = true;
+ } else {
+ $text .= $this->lang->getMonthName( $m );
+ }
+ } else {
+ $text .= ucfirst( $bits['F'] );
+ }
+ break;
+ case 'Y': # ordinary (optional BC) year
+ $text .= $bits['Y'];
+ break;
+ default:
+ $text .= $char;
+ }
+ }
+ if ( $fail ) {
+ // This occurs when parsing a date with day or month outside the bounds
+ // of possibilities.
+ $text = $orig;
+ }
+
+ $isoBits = [];
+ if ( isset( $bits['y'] ) ) {
+ $isoBits[] = $bits['y'];
+ }
+ $isoBits[] = $bits['m'];
+ $isoBits[] = $bits['d'];
+ $isoDate = implode( '-', $isoBits );
+
+ // Output is not strictly HTML (it's wikitext), but <span> is whitelisted.
+ $text = Html::rawElement( 'span',
+ [ 'class' => 'mw-formatted-date', 'title' => $isoDate ], $text );
+
+ return $text;
+ }
+
+ /**
+ * Return a regex that can be used to find month names in string
+ * @return string regex to find the months with
+ */
+ private function getMonthRegex() {
+ $names = [];
+ for ( $i = 1; $i <= 12; $i++ ) {
+ $names[] = $this->lang->getMonthName( $i );
+ $names[] = $this->lang->getMonthAbbreviation( $i );
+ }
+ return implode( '|', $names );
+ }
+
+ /**
+ * Makes an ISO month, e.g. 02, from a month name
+ * @param string $monthName Month name
+ * @return string ISO month name
+ */
+ private function makeIsoMonth( $monthName ) {
+ $n = $this->xMonths[$this->lang->lc( $monthName )];
+ return sprintf( '%02d', $n );
+ }
+
+ /**
+ * Make an ISO year from a year name, for instance: '-1199' from '1200 BC'
+ * @param string $year Year name
+ * @return string ISO year name
+ */
+ private function makeIsoYear( $year ) {
+ # Assumes the year is in a nice format, as enforced by the regex
+ if ( substr( $year, -2 ) == 'BC' ) {
+ $num = intval( substr( $year, 0, -3 ) ) - 1;
+ # PHP bug note: sprintf( "%04d", -1 ) fails poorly
+ $text = sprintf( '-%04d', $num );
+
+ } else {
+ $text = sprintf( '%04d', $year );
+ }
+ return $text;
+ }
+
+ /**
+ * Make a year one from an ISO year, for instance: '400 BC' from '-0399'.
+ * @param string $iso ISO year
+ * @return int|string int representing year number in case of AD dates, or string containing
+ * year number and 'BC' at the end otherwise.
+ */
+ private function makeNormalYear( $iso ) {
+ if ( $iso[0] == '-' ) {
+ $text = ( intval( substr( $iso, 1 ) ) + 1 ) . ' BC';
+ } else {
+ $text = intval( $iso );
+ }
+ return $text;
+ }
+}
diff --git a/www/wiki/includes/parser/LinkHolderArray.php b/www/wiki/includes/parser/LinkHolderArray.php
new file mode 100644
index 00000000..bc5182c1
--- /dev/null
+++ b/www/wiki/includes/parser/LinkHolderArray.php
@@ -0,0 +1,644 @@
+<?php
+/**
+ * Holder of replacement pairs for wiki links
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * @ingroup Parser
+ */
+class LinkHolderArray {
+ public $internals = [];
+ public $interwikis = [];
+ public $size = 0;
+
+ /**
+ * @var Parser
+ */
+ public $parent;
+ protected $tempIdOffset;
+
+ /**
+ * @param Parser $parent
+ */
+ public function __construct( $parent ) {
+ $this->parent = $parent;
+ }
+
+ /**
+ * Reduce memory usage to reduce the impact of circular references
+ */
+ public function __destruct() {
+ foreach ( $this as $name => $value ) {
+ unset( $this->$name );
+ }
+ }
+
+ /**
+ * Don't serialize the parent object, it is big, and not needed when it is
+ * a parameter to mergeForeign(), which is the only application of
+ * serializing at present.
+ *
+ * Compact the titles, only serialize the text form.
+ * @return array
+ */
+ public function __sleep() {
+ foreach ( $this->internals as &$nsLinks ) {
+ foreach ( $nsLinks as &$entry ) {
+ unset( $entry['title'] );
+ }
+ }
+ unset( $nsLinks );
+ unset( $entry );
+
+ foreach ( $this->interwikis as &$entry ) {
+ unset( $entry['title'] );
+ }
+ unset( $entry );
+
+ return [ 'internals', 'interwikis', 'size' ];
+ }
+
+ /**
+ * Recreate the Title objects
+ */
+ public function __wakeup() {
+ foreach ( $this->internals as &$nsLinks ) {
+ foreach ( $nsLinks as &$entry ) {
+ $entry['title'] = Title::newFromText( $entry['pdbk'] );
+ }
+ }
+ unset( $nsLinks );
+ unset( $entry );
+
+ foreach ( $this->interwikis as &$entry ) {
+ $entry['title'] = Title::newFromText( $entry['pdbk'] );
+ }
+ unset( $entry );
+ }
+
+ /**
+ * Merge another LinkHolderArray into this one
+ * @param LinkHolderArray $other
+ */
+ public function merge( $other ) {
+ foreach ( $other->internals as $ns => $entries ) {
+ $this->size += count( $entries );
+ if ( !isset( $this->internals[$ns] ) ) {
+ $this->internals[$ns] = $entries;
+ } else {
+ $this->internals[$ns] += $entries;
+ }
+ }
+ $this->interwikis += $other->interwikis;
+ }
+
+ /**
+ * Merge a LinkHolderArray from another parser instance into this one. The
+ * keys will not be preserved. Any text which went with the old
+ * LinkHolderArray and needs to work with the new one should be passed in
+ * the $texts array. The strings in this array will have their link holders
+ * converted for use in the destination link holder. The resulting array of
+ * strings will be returned.
+ *
+ * @param LinkHolderArray $other
+ * @param array $texts Array of strings
+ * @return array
+ */
+ public function mergeForeign( $other, $texts ) {
+ $this->tempIdOffset = $idOffset = $this->parent->nextLinkID();
+ $maxId = 0;
+
+ # Renumber internal links
+ foreach ( $other->internals as $ns => $nsLinks ) {
+ foreach ( $nsLinks as $key => $entry ) {
+ $newKey = $idOffset + $key;
+ $this->internals[$ns][$newKey] = $entry;
+ $maxId = $newKey > $maxId ? $newKey : $maxId;
+ }
+ }
+ $texts = preg_replace_callback( '/(<!--LINK \d+:)(\d+)(-->)/',
+ [ $this, 'mergeForeignCallback' ], $texts );
+
+ # Renumber interwiki links
+ foreach ( $other->interwikis as $key => $entry ) {
+ $newKey = $idOffset + $key;
+ $this->interwikis[$newKey] = $entry;
+ $maxId = $newKey > $maxId ? $newKey : $maxId;
+ }
+ $texts = preg_replace_callback( '/(<!--IWLINK )(\d+)(-->)/',
+ [ $this, 'mergeForeignCallback' ], $texts );
+
+ # Set the parent link ID to be beyond the highest used ID
+ $this->parent->setLinkID( $maxId + 1 );
+ $this->tempIdOffset = null;
+ return $texts;
+ }
+
+ /**
+ * @param array $m
+ * @return string
+ */
+ protected function mergeForeignCallback( $m ) {
+ return $m[1] . ( $m[2] + $this->tempIdOffset ) . $m[3];
+ }
+
+ /**
+ * Get a subset of the current LinkHolderArray which is sufficient to
+ * interpret the given text.
+ * @param string $text
+ * @return LinkHolderArray
+ */
+ public function getSubArray( $text ) {
+ $sub = new LinkHolderArray( $this->parent );
+
+ # Internal links
+ $pos = 0;
+ while ( $pos < strlen( $text ) ) {
+ if ( !preg_match( '/<!--LINK (\d+):(\d+)-->/',
+ $text, $m, PREG_OFFSET_CAPTURE, $pos )
+ ) {
+ break;
+ }
+ $ns = $m[1][0];
+ $key = $m[2][0];
+ $sub->internals[$ns][$key] = $this->internals[$ns][$key];
+ $pos = $m[0][1] + strlen( $m[0][0] );
+ }
+
+ # Interwiki links
+ $pos = 0;
+ while ( $pos < strlen( $text ) ) {
+ if ( !preg_match( '/<!--IWLINK (\d+)-->/', $text, $m, PREG_OFFSET_CAPTURE, $pos ) ) {
+ break;
+ }
+ $key = $m[1][0];
+ $sub->interwikis[$key] = $this->interwikis[$key];
+ $pos = $m[0][1] + strlen( $m[0][0] );
+ }
+ return $sub;
+ }
+
+ /**
+ * Returns true if the memory requirements of this object are getting large
+ * @return bool
+ */
+ public function isBig() {
+ global $wgLinkHolderBatchSize;
+ return $this->size > $wgLinkHolderBatchSize;
+ }
+
+ /**
+ * Clear all stored link holders.
+ * Make sure you don't have any text left using these link holders, before you call this
+ */
+ public function clear() {
+ $this->internals = [];
+ $this->interwikis = [];
+ $this->size = 0;
+ }
+
+ /**
+ * Make a link placeholder. The text returned can be later resolved to a real link with
+ * replaceLinkHolders(). This is done for two reasons: firstly to avoid further
+ * parsing of interwiki links, and secondly to allow all existence checks and
+ * article length checks (for stub links) to be bundled into a single query.
+ *
+ * @param Title $nt
+ * @param string $text
+ * @param array $query [optional]
+ * @param string $trail [optional]
+ * @param string $prefix [optional]
+ * @return string
+ */
+ public function makeHolder( $nt, $text = '', $query = [], $trail = '', $prefix = '' ) {
+ if ( !is_object( $nt ) ) {
+ # Fail gracefully
+ $retVal = "<!-- ERROR -->{$prefix}{$text}{$trail}";
+ } else {
+ # Separate the link trail from the rest of the link
+ list( $inside, $trail ) = Linker::splitTrail( $trail );
+
+ $entry = [
+ 'title' => $nt,
+ 'text' => $prefix . $text . $inside,
+ 'pdbk' => $nt->getPrefixedDBkey(),
+ ];
+ if ( $query !== [] ) {
+ $entry['query'] = $query;
+ }
+
+ if ( $nt->isExternal() ) {
+ // Use a globally unique ID to keep the objects mergable
+ $key = $this->parent->nextLinkID();
+ $this->interwikis[$key] = $entry;
+ $retVal = "<!--IWLINK $key-->{$trail}";
+ } else {
+ $key = $this->parent->nextLinkID();
+ $ns = $nt->getNamespace();
+ $this->internals[$ns][$key] = $entry;
+ $retVal = "<!--LINK $ns:$key-->{$trail}";
+ }
+ $this->size++;
+ }
+ return $retVal;
+ }
+
+ /**
+ * Replace <!--LINK--> link placeholders with actual links, in the buffer
+ *
+ * @param string &$text
+ */
+ public function replace( &$text ) {
+ $this->replaceInternal( $text );
+ $this->replaceInterwiki( $text );
+ }
+
+ /**
+ * Replace internal links
+ * @param string &$text
+ */
+ protected function replaceInternal( &$text ) {
+ if ( !$this->internals ) {
+ return;
+ }
+
+ global $wgContLang;
+
+ $colours = [];
+ $linkCache = LinkCache::singleton();
+ $output = $this->parent->getOutput();
+ $linkRenderer = $this->parent->getLinkRenderer();
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ # Sort by namespace
+ ksort( $this->internals );
+
+ $linkcolour_ids = [];
+
+ # Generate query
+ $lb = new LinkBatch();
+ $lb->setCaller( __METHOD__ );
+
+ foreach ( $this->internals as $ns => $entries ) {
+ foreach ( $entries as $entry ) {
+ /** @var Title $title */
+ $title = $entry['title'];
+ $pdbk = $entry['pdbk'];
+
+ # Skip invalid entries.
+ # Result will be ugly, but prevents crash.
+ if ( is_null( $title ) ) {
+ continue;
+ }
+
+ # Check if it's a static known link, e.g. interwiki
+ if ( $title->isAlwaysKnown() ) {
+ $colours[$pdbk] = '';
+ } elseif ( $ns == NS_SPECIAL ) {
+ $colours[$pdbk] = 'new';
+ } else {
+ $id = $linkCache->getGoodLinkID( $pdbk );
+ if ( $id != 0 ) {
+ $colours[$pdbk] = $linkRenderer->getLinkClasses( $title );
+ $output->addLink( $title, $id );
+ $linkcolour_ids[$id] = $pdbk;
+ } elseif ( $linkCache->isBadLink( $pdbk ) ) {
+ $colours[$pdbk] = 'new';
+ } else {
+ # Not in the link cache, add it to the query
+ $lb->addObj( $title );
+ }
+ }
+ }
+ }
+ if ( !$lb->isEmpty() ) {
+ $fields = array_merge(
+ LinkCache::getSelectFields(),
+ [ 'page_namespace', 'page_title' ]
+ );
+
+ $res = $dbr->select(
+ 'page',
+ $fields,
+ $lb->constructSet( 'page', $dbr ),
+ __METHOD__
+ );
+
+ # Fetch data and form into an associative array
+ # non-existent = broken
+ foreach ( $res as $s ) {
+ $title = Title::makeTitle( $s->page_namespace, $s->page_title );
+ $pdbk = $title->getPrefixedDBkey();
+ $linkCache->addGoodLinkObjFromRow( $title, $s );
+ $output->addLink( $title, $s->page_id );
+ $colours[$pdbk] = $linkRenderer->getLinkClasses( $title );
+ // add id to the extension todolist
+ $linkcolour_ids[$s->page_id] = $pdbk;
+ }
+ unset( $res );
+ }
+ if ( count( $linkcolour_ids ) ) {
+ // pass an array of page_ids to an extension
+ Hooks::run( 'GetLinkColours', [ $linkcolour_ids, &$colours ] );
+ }
+
+ # Do a second query for different language variants of links and categories
+ if ( $wgContLang->hasVariants() ) {
+ $this->doVariants( $colours );
+ }
+
+ # Construct search and replace arrays
+ $replacePairs = [];
+ foreach ( $this->internals as $ns => $entries ) {
+ foreach ( $entries as $index => $entry ) {
+ $pdbk = $entry['pdbk'];
+ $title = $entry['title'];
+ $query = isset( $entry['query'] ) ? $entry['query'] : [];
+ $key = "$ns:$index";
+ $searchkey = "<!--LINK $key-->";
+ $displayText = $entry['text'];
+ if ( isset( $entry['selflink'] ) ) {
+ $replacePairs[$searchkey] = Linker::makeSelfLinkObj( $title, $displayText, $query );
+ continue;
+ }
+ if ( $displayText === '' ) {
+ $displayText = null;
+ } else {
+ $displayText = new HtmlArmor( $displayText );
+ }
+ if ( !isset( $colours[$pdbk] ) ) {
+ $colours[$pdbk] = 'new';
+ }
+ $attribs = [];
+ if ( $colours[$pdbk] == 'new' ) {
+ $linkCache->addBadLinkObj( $title );
+ $output->addLink( $title, 0 );
+ $link = $linkRenderer->makeBrokenLink(
+ $title, $displayText, $attribs, $query
+ );
+ } else {
+ $link = $linkRenderer->makePreloadedLink(
+ $title, $displayText, $colours[$pdbk], $attribs, $query
+ );
+ }
+
+ $replacePairs[$searchkey] = $link;
+ }
+ }
+ $replacer = new HashtableReplacer( $replacePairs, 1 );
+
+ # Do the thing
+ $text = preg_replace_callback(
+ '/(<!--LINK .*?-->)/',
+ $replacer->cb(),
+ $text
+ );
+ }
+
+ /**
+ * Replace interwiki links
+ * @param string &$text
+ */
+ protected function replaceInterwiki( &$text ) {
+ if ( empty( $this->interwikis ) ) {
+ return;
+ }
+
+ # Make interwiki link HTML
+ $output = $this->parent->getOutput();
+ $replacePairs = [];
+ $linkRenderer = $this->parent->getLinkRenderer();
+ foreach ( $this->interwikis as $key => $link ) {
+ $replacePairs[$key] = $linkRenderer->makeLink(
+ $link['title'],
+ new HtmlArmor( $link['text'] )
+ );
+ $output->addInterwikiLink( $link['title'] );
+ }
+ $replacer = new HashtableReplacer( $replacePairs, 1 );
+
+ $text = preg_replace_callback(
+ '/<!--IWLINK (.*?)-->/',
+ $replacer->cb(),
+ $text );
+ }
+
+ /**
+ * Modify $this->internals and $colours according to language variant linking rules
+ * @param array &$colours
+ */
+ protected function doVariants( &$colours ) {
+ global $wgContLang;
+ $linkBatch = new LinkBatch();
+ $variantMap = []; // maps $pdbkey_Variant => $keys (of link holders)
+ $output = $this->parent->getOutput();
+ $linkCache = LinkCache::singleton();
+ $titlesToBeConverted = '';
+ $titlesAttrs = [];
+
+ // Concatenate titles to a single string, thus we only need auto convert the
+ // single string to all variants. This would improve parser's performance
+ // significantly.
+ foreach ( $this->internals as $ns => $entries ) {
+ if ( $ns == NS_SPECIAL ) {
+ continue;
+ }
+ foreach ( $entries as $index => $entry ) {
+ $pdbk = $entry['pdbk'];
+ // we only deal with new links (in its first query)
+ if ( !isset( $colours[$pdbk] ) || $colours[$pdbk] === 'new' ) {
+ $titlesAttrs[] = [ $index, $entry['title'] ];
+ // separate titles with \0 because it would never appears
+ // in a valid title
+ $titlesToBeConverted .= $entry['title']->getText() . "\0";
+ }
+ }
+ }
+
+ // Now do the conversion and explode string to text of titles
+ $titlesAllVariants = $wgContLang->autoConvertToAllVariants( rtrim( $titlesToBeConverted, "\0" ) );
+ $allVariantsName = array_keys( $titlesAllVariants );
+ foreach ( $titlesAllVariants as &$titlesVariant ) {
+ $titlesVariant = explode( "\0", $titlesVariant );
+ }
+
+ // Then add variants of links to link batch
+ $parentTitle = $this->parent->getTitle();
+ foreach ( $titlesAttrs as $i => $attrs ) {
+ /** @var Title $title */
+ list( $index, $title ) = $attrs;
+ $ns = $title->getNamespace();
+ $text = $title->getText();
+
+ foreach ( $allVariantsName as $variantName ) {
+ $textVariant = $titlesAllVariants[$variantName][$i];
+ if ( $textVariant === $text ) {
+ continue;
+ }
+
+ $variantTitle = Title::makeTitle( $ns, $textVariant );
+
+ // Self-link checking for mixed/different variant titles. At this point, we
+ // already know the exact title does not exist, so the link cannot be to a
+ // variant of the current title that exists as a separate page.
+ if ( $variantTitle->equals( $parentTitle ) && !$title->hasFragment() ) {
+ $this->internals[$ns][$index]['selflink'] = true;
+ continue 2;
+ }
+
+ $linkBatch->addObj( $variantTitle );
+ $variantMap[$variantTitle->getPrefixedDBkey()][] = "$ns:$index";
+ }
+ }
+
+ // process categories, check if a category exists in some variant
+ $categoryMap = []; // maps $category_variant => $category (dbkeys)
+ $varCategories = []; // category replacements oldDBkey => newDBkey
+ foreach ( $output->getCategoryLinks() as $category ) {
+ $categoryTitle = Title::makeTitleSafe( NS_CATEGORY, $category );
+ $linkBatch->addObj( $categoryTitle );
+ $variants = $wgContLang->autoConvertToAllVariants( $category );
+ foreach ( $variants as $variant ) {
+ if ( $variant !== $category ) {
+ $variantTitle = Title::makeTitleSafe( NS_CATEGORY, $variant );
+ if ( is_null( $variantTitle ) ) {
+ continue;
+ }
+ $linkBatch->addObj( $variantTitle );
+ $categoryMap[$variant] = [ $category, $categoryTitle ];
+ }
+ }
+ }
+
+ if ( !$linkBatch->isEmpty() ) {
+ // construct query
+ $dbr = wfGetDB( DB_REPLICA );
+ $fields = array_merge(
+ LinkCache::getSelectFields(),
+ [ 'page_namespace', 'page_title' ]
+ );
+
+ $varRes = $dbr->select( 'page',
+ $fields,
+ $linkBatch->constructSet( 'page', $dbr ),
+ __METHOD__
+ );
+
+ $linkcolour_ids = [];
+ $linkRenderer = $this->parent->getLinkRenderer();
+
+ // for each found variants, figure out link holders and replace
+ foreach ( $varRes as $s ) {
+ $variantTitle = Title::makeTitle( $s->page_namespace, $s->page_title );
+ $varPdbk = $variantTitle->getPrefixedDBkey();
+ $vardbk = $variantTitle->getDBkey();
+
+ $holderKeys = [];
+ if ( isset( $variantMap[$varPdbk] ) ) {
+ $holderKeys = $variantMap[$varPdbk];
+ $linkCache->addGoodLinkObjFromRow( $variantTitle, $s );
+ $output->addLink( $variantTitle, $s->page_id );
+ }
+
+ // loop over link holders
+ foreach ( $holderKeys as $key ) {
+ list( $ns, $index ) = explode( ':', $key, 2 );
+ $entry =& $this->internals[$ns][$index];
+ $pdbk = $entry['pdbk'];
+
+ if ( !isset( $colours[$pdbk] ) || $colours[$pdbk] === 'new' ) {
+ // found link in some of the variants, replace the link holder data
+ $entry['title'] = $variantTitle;
+ $entry['pdbk'] = $varPdbk;
+
+ // set pdbk and colour
+ $colours[$varPdbk] = $linkRenderer->getLinkClasses( $variantTitle );
+ $linkcolour_ids[$s->page_id] = $pdbk;
+ }
+ }
+
+ // check if the object is a variant of a category
+ if ( isset( $categoryMap[$vardbk] ) ) {
+ list( $oldkey, $oldtitle ) = $categoryMap[$vardbk];
+ if ( !isset( $varCategories[$oldkey] ) && !$oldtitle->exists() ) {
+ $varCategories[$oldkey] = $vardbk;
+ }
+ }
+ }
+ Hooks::run( 'GetLinkColours', [ $linkcolour_ids, &$colours ] );
+
+ // rebuild the categories in original order (if there are replacements)
+ if ( count( $varCategories ) > 0 ) {
+ $newCats = [];
+ $originalCats = $output->getCategories();
+ foreach ( $originalCats as $cat => $sortkey ) {
+ // make the replacement
+ if ( array_key_exists( $cat, $varCategories ) ) {
+ $newCats[$varCategories[$cat]] = $sortkey;
+ } else {
+ $newCats[$cat] = $sortkey;
+ }
+ }
+ $output->setCategoryLinks( $newCats );
+ }
+ }
+ }
+
+ /**
+ * Replace <!--LINK--> link placeholders with plain text of links
+ * (not HTML-formatted).
+ *
+ * @param string $text
+ * @return string
+ */
+ public function replaceText( $text ) {
+ $text = preg_replace_callback(
+ '/<!--(LINK|IWLINK) (.*?)-->/',
+ [ $this, 'replaceTextCallback' ],
+ $text );
+
+ return $text;
+ }
+
+ /**
+ * Callback for replaceText()
+ *
+ * @param array $matches
+ * @return string
+ * @private
+ */
+ public function replaceTextCallback( $matches ) {
+ $type = $matches[1];
+ $key = $matches[2];
+ if ( $type == 'LINK' ) {
+ list( $ns, $index ) = explode( ':', $key, 2 );
+ if ( isset( $this->internals[$ns][$index]['text'] ) ) {
+ return $this->internals[$ns][$index]['text'];
+ }
+ } elseif ( $type == 'IWLINK' ) {
+ if ( isset( $this->interwikis[$key]['text'] ) ) {
+ return $this->interwikis[$key]['text'];
+ }
+ }
+ return $matches[0];
+ }
+}
diff --git a/www/wiki/includes/parser/MWTidy.php b/www/wiki/includes/parser/MWTidy.php
new file mode 100644
index 00000000..ffc884eb
--- /dev/null
+++ b/www/wiki/includes/parser/MWTidy.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * HTML validation and correction
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * Class to interact with HTML tidy
+ *
+ * Either the external tidy program or the in-process tidy extension
+ * will be used depending on availability. Override the default
+ * $wgTidyInternal setting to disable the internal if it's not working.
+ *
+ * @ingroup Parser
+ */
+class MWTidy {
+ private static $instance;
+
+ /**
+ * Interface with html tidy.
+ * If tidy isn't able to correct the markup, the original will be
+ * returned in all its glory with a warning comment appended.
+ *
+ * @param string $text HTML input fragment. This should not contain a
+ * <body> or <html> tag.
+ * @return string Corrected HTML output
+ * @throws MWException
+ */
+ public static function tidy( $text ) {
+ $driver = self::singleton();
+ if ( !$driver ) {
+ throw new MWException( __METHOD__ .
+ ': tidy is disabled, caller should have checked MWTidy::isEnabled()' );
+ }
+ return $driver->tidy( $text );
+ }
+
+ /**
+ * Check HTML for errors, used if $wgValidateAllHtml = true.
+ *
+ * @param string $text
+ * @param string &$errorStr Return the error string
+ * @return bool Whether the HTML is valid
+ * @throws MWException
+ */
+ public static function checkErrors( $text, &$errorStr = null ) {
+ $driver = self::singleton();
+ if ( !$driver ) {
+ throw new MWException( __METHOD__ .
+ ': tidy is disabled, caller should have checked MWTidy::isEnabled()' );
+ }
+ if ( $driver->supportsValidate() ) {
+ return $driver->validate( $text, $errorStr );
+ } else {
+ throw new MWException( __METHOD__ . ": error text return from HHVM tidy is not supported" );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public static function isEnabled() {
+ return self::singleton() !== false;
+ }
+
+ /**
+ * @return bool|\MediaWiki\Tidy\TidyDriverBase
+ */
+ public static function singleton() {
+ global $wgUseTidy, $wgTidyInternal, $wgTidyConf, $wgDebugTidy, $wgTidyConfig,
+ $wgTidyBin, $wgTidyOpts;
+
+ if ( self::$instance === null ) {
+ if ( $wgTidyConfig !== null ) {
+ $config = $wgTidyConfig;
+ } elseif ( $wgUseTidy ) {
+ // b/c configuration
+ $config = [
+ 'tidyConfigFile' => $wgTidyConf,
+ 'debugComment' => $wgDebugTidy,
+ 'tidyBin' => $wgTidyBin,
+ 'tidyCommandLine' => $wgTidyOpts ];
+ if ( $wgTidyInternal ) {
+ if ( wfIsHHVM() ) {
+ $config['driver'] = 'RaggettInternalHHVM';
+ } else {
+ $config['driver'] = 'RaggettInternalPHP';
+ }
+ } else {
+ $config['driver'] = 'RaggettExternal';
+ }
+ } else {
+ return false;
+ }
+ self::$instance = self::factory( $config );
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Create a new Tidy driver object from configuration.
+ * @see $wgTidyConfig
+ * @param array $config
+ * @return bool|\MediaWiki\Tidy\TidyDriverBase
+ * @throws MWException
+ */
+ public static function factory( array $config ) {
+ switch ( $config['driver'] ) {
+ case 'RaggettInternalHHVM':
+ $instance = new MediaWiki\Tidy\RaggettInternalHHVM( $config );
+ break;
+ case 'RaggettInternalPHP':
+ $instance = new MediaWiki\Tidy\RaggettInternalPHP( $config );
+ break;
+ case 'RaggettExternal':
+ $instance = new MediaWiki\Tidy\RaggettExternal( $config );
+ break;
+ case 'Html5Depurate':
+ $instance = new MediaWiki\Tidy\Html5Depurate( $config );
+ break;
+ case 'Html5Internal':
+ $instance = new MediaWiki\Tidy\Html5Internal( $config );
+ break;
+ case 'RemexHtml':
+ $instance = new MediaWiki\Tidy\RemexDriver( $config );
+ break;
+ case 'disabled':
+ return false;
+ default:
+ throw new MWException( "Invalid tidy driver: \"{$config['driver']}\"" );
+ }
+ return $instance;
+ }
+
+ /**
+ * Set the driver to be used. This is for testing.
+ * @param MediaWiki\Tidy\TidyDriverBase|false|null $instance
+ */
+ public static function setInstance( $instance ) {
+ self::$instance = $instance;
+ }
+
+ /**
+ * Destroy the current singleton instance
+ */
+ public static function destroySingleton() {
+ self::$instance = null;
+ }
+}
diff --git a/www/wiki/includes/parser/Parser.php b/www/wiki/includes/parser/Parser.php
new file mode 100644
index 00000000..e901f6f3
--- /dev/null
+++ b/www/wiki/includes/parser/Parser.php
@@ -0,0 +1,6100 @@
+<?php
+/**
+ * PHP parser that converts wiki markup to HTML.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ScopedCallback;
+
+/**
+ * @defgroup Parser Parser
+ */
+
+/**
+ * PHP Parser - Processes wiki markup (which uses a more user-friendly
+ * syntax, such as "[[link]]" for making links), and provides a one-way
+ * transformation of that wiki markup it into (X)HTML output / markup
+ * (which in turn the browser understands, and can display).
+ *
+ * There are seven main entry points into the Parser class:
+ *
+ * - Parser::parse()
+ * produces HTML output
+ * - Parser::preSaveTransform()
+ * produces altered wiki markup
+ * - Parser::preprocess()
+ * removes HTML comments and expands templates
+ * - Parser::cleanSig() and Parser::cleanSigInSig()
+ * cleans a signature before saving it to preferences
+ * - Parser::getSection()
+ * return the content of a section from an article for section editing
+ * - Parser::replaceSection()
+ * replaces a section by number inside an article
+ * - Parser::getPreloadText()
+ * removes <noinclude> sections and <includeonly> tags
+ *
+ * Globals used:
+ * object: $wgContLang
+ *
+ * @warning $wgUser or $wgTitle or $wgRequest or $wgLang. Keep them away!
+ *
+ * @par Settings:
+ * $wgNamespacesWithSubpages
+ *
+ * @par Settings only within ParserOptions:
+ * $wgAllowExternalImages
+ * $wgAllowSpecialInclusion
+ * $wgInterwikiMagic
+ * $wgMaxArticleSize
+ *
+ * @ingroup Parser
+ */
+class Parser {
+ /**
+ * Update this version number when the ParserOutput format
+ * changes in an incompatible way, so the parser cache
+ * can automatically discard old data.
+ */
+ const VERSION = '1.6.4';
+
+ /**
+ * Update this version number when the output of serialiseHalfParsedText()
+ * changes in an incompatible way
+ */
+ const HALF_PARSED_VERSION = 2;
+
+ # Flags for Parser::setFunctionHook
+ const SFH_NO_HASH = 1;
+ const SFH_OBJECT_ARGS = 2;
+
+ # Constants needed for external link processing
+ # Everything except bracket, space, or control characters
+ # \p{Zs} is unicode 'separator, space' category. It covers the space 0x20
+ # as well as U+3000 is IDEOGRAPHIC SPACE for T21052
+ # \x{FFFD} is the Unicode replacement character, which Preprocessor_DOM
+ # uses to replace invalid HTML characters.
+ const EXT_LINK_URL_CLASS = '[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}]';
+ # Simplified expression to match an IPv4 or IPv6 address, or
+ # at least one character of a host name (embeds EXT_LINK_URL_CLASS)
+ const EXT_LINK_ADDR = '(?:[0-9.]+|\\[(?i:[0-9a-f:.]+)\\]|[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}])';
+ # RegExp to make image URLs (embeds IPv6 part of EXT_LINK_ADDR)
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ const EXT_IMAGE_REGEX = '/^(http:\/\/|https:\/\/)((?:\\[(?i:[0-9a-f:.]+)\\])?[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}]+)
+ \\/([A-Za-z0-9_.,~%\\-+&;#*?!=()@\\x80-\\xFF]+)\\.((?i)gif|png|jpg|jpeg)$/Sxu';
+ // @codingStandardsIgnoreEnd
+
+ # Regular expression for a non-newline space
+ const SPACE_NOT_NL = '(?:\t|&nbsp;|&\#0*160;|&\#[Xx]0*[Aa]0;|\p{Zs})';
+
+ # Flags for preprocessToDom
+ const PTD_FOR_INCLUSION = 1;
+
+ # Allowed values for $this->mOutputType
+ # Parameter to startExternalParse().
+ const OT_HTML = 1; # like parse()
+ const OT_WIKI = 2; # like preSaveTransform()
+ const OT_PREPROCESS = 3; # like preprocess()
+ const OT_MSG = 3;
+ const OT_PLAIN = 4; # like extractSections() - portions of the original are returned unchanged.
+
+ /**
+ * @var string Prefix and suffix for temporary replacement strings
+ * for the multipass parser.
+ *
+ * \x7f should never appear in input as it's disallowed in XML.
+ * Using it at the front also gives us a little extra robustness
+ * since it shouldn't match when butted up against identifier-like
+ * string constructs.
+ *
+ * Must not consist of all title characters, or else it will change
+ * the behavior of <nowiki> in a link.
+ *
+ * Must have a character that needs escaping in attributes, otherwise
+ * someone could put a strip marker in an attribute, to get around
+ * escaping quote marks, and break out of the attribute. Thus we add
+ * `'".
+ */
+ const MARKER_SUFFIX = "-QINU`\"'\x7f";
+ const MARKER_PREFIX = "\x7f'\"`UNIQ-";
+
+ # Markers used for wrapping the table of contents
+ const TOC_START = '<mw:toc>';
+ const TOC_END = '</mw:toc>';
+
+ # Persistent:
+ public $mTagHooks = [];
+ public $mTransparentTagHooks = [];
+ public $mFunctionHooks = [];
+ public $mFunctionSynonyms = [ 0 => [], 1 => [] ];
+ public $mFunctionTagHooks = [];
+ public $mStripList = [];
+ public $mDefaultStripList = [];
+ public $mVarCache = [];
+ public $mImageParams = [];
+ public $mImageParamsMagicArray = [];
+ public $mMarkerIndex = 0;
+ public $mFirstCall = true;
+
+ # Initialised by initialiseVariables()
+
+ /**
+ * @var MagicWordArray
+ */
+ public $mVariables;
+
+ /**
+ * @var MagicWordArray
+ */
+ public $mSubstWords;
+ # Initialised in constructor
+ public $mConf, $mExtLinkBracketedRegex, $mUrlProtocols;
+
+ # Initialized in getPreprocessor()
+ /** @var Preprocessor */
+ public $mPreprocessor;
+
+ # Cleared with clearState():
+ /**
+ * @var ParserOutput
+ */
+ public $mOutput;
+ public $mAutonumber;
+
+ /**
+ * @var StripState
+ */
+ public $mStripState;
+
+ public $mIncludeCount;
+ /**
+ * @var LinkHolderArray
+ */
+ public $mLinkHolders;
+
+ public $mLinkID;
+ public $mIncludeSizes, $mPPNodeCount, $mGeneratedPPNodeCount, $mHighestExpansionDepth;
+ public $mDefaultSort;
+ public $mTplRedirCache, $mTplDomCache, $mHeadings, $mDoubleUnderscores;
+ public $mExpensiveFunctionCount; # number of expensive parser function calls
+ public $mShowToc, $mForceTocPosition;
+
+ /**
+ * @var User
+ */
+ public $mUser; # User object; only used when doing pre-save transform
+
+ # Temporary
+ # These are variables reset at least once per parse regardless of $clearState
+
+ /**
+ * @var ParserOptions
+ */
+ public $mOptions;
+
+ /**
+ * @var Title
+ */
+ public $mTitle; # Title context, used for self-link rendering and similar things
+ public $mOutputType; # Output type, one of the OT_xxx constants
+ public $ot; # Shortcut alias, see setOutputType()
+ public $mRevisionObject; # The revision object of the specified revision ID
+ public $mRevisionId; # ID to display in {{REVISIONID}} tags
+ public $mRevisionTimestamp; # The timestamp of the specified revision ID
+ public $mRevisionUser; # User to display in {{REVISIONUSER}} tag
+ public $mRevisionSize; # Size to display in {{REVISIONSIZE}} variable
+ public $mRevIdForTs; # The revision ID which was used to fetch the timestamp
+ public $mInputSize = false; # For {{PAGESIZE}} on current page.
+
+ /**
+ * @var string Deprecated accessor for the strip marker prefix.
+ * @deprecated since 1.26; use Parser::MARKER_PREFIX instead.
+ */
+ public $mUniqPrefix = self::MARKER_PREFIX;
+
+ /**
+ * @var array Array with the language name of each language link (i.e. the
+ * interwiki prefix) in the key, value arbitrary. Used to avoid sending
+ * duplicate language links to the ParserOutput.
+ */
+ public $mLangLinkLanguages;
+
+ /**
+ * @var MapCacheLRU|null
+ * @since 1.24
+ *
+ * A cache of the current revisions of titles. Keys are $title->getPrefixedDbKey()
+ */
+ public $currentRevisionCache;
+
+ /**
+ * @var bool|string Recursive call protection.
+ * This variable should be treated as if it were private.
+ */
+ public $mInParse = false;
+
+ /** @var SectionProfiler */
+ protected $mProfiler;
+
+ /**
+ * @var LinkRenderer
+ */
+ protected $mLinkRenderer;
+
+ /**
+ * @param array $conf
+ */
+ public function __construct( $conf = [] ) {
+ $this->mConf = $conf;
+ $this->mUrlProtocols = wfUrlProtocols();
+ $this->mExtLinkBracketedRegex = '/\[(((?i)' . $this->mUrlProtocols . ')' .
+ self::EXT_LINK_ADDR .
+ self::EXT_LINK_URL_CLASS . '*)\p{Zs}*([^\]\\x00-\\x08\\x0a-\\x1F\\x{FFFD}]*?)\]/Su';
+ if ( isset( $conf['preprocessorClass'] ) ) {
+ $this->mPreprocessorClass = $conf['preprocessorClass'];
+ } elseif ( defined( 'HPHP_VERSION' ) ) {
+ # Preprocessor_Hash is much faster than Preprocessor_DOM under HipHop
+ $this->mPreprocessorClass = 'Preprocessor_Hash';
+ } elseif ( extension_loaded( 'domxml' ) ) {
+ # PECL extension that conflicts with the core DOM extension (T15770)
+ wfDebug( "Warning: you have the obsolete domxml extension for PHP. Please remove it!\n" );
+ $this->mPreprocessorClass = 'Preprocessor_Hash';
+ } elseif ( extension_loaded( 'dom' ) ) {
+ $this->mPreprocessorClass = 'Preprocessor_DOM';
+ } else {
+ $this->mPreprocessorClass = 'Preprocessor_Hash';
+ }
+ wfDebug( __CLASS__ . ": using preprocessor: {$this->mPreprocessorClass}\n" );
+ }
+
+ /**
+ * Reduce memory usage to reduce the impact of circular references
+ */
+ public function __destruct() {
+ if ( isset( $this->mLinkHolders ) ) {
+ unset( $this->mLinkHolders );
+ }
+ foreach ( $this as $name => $value ) {
+ unset( $this->$name );
+ }
+ }
+
+ /**
+ * Allow extensions to clean up when the parser is cloned
+ */
+ public function __clone() {
+ $this->mInParse = false;
+
+ // T58226: When you create a reference "to" an object field, that
+ // makes the object field itself be a reference too (until the other
+ // reference goes out of scope). When cloning, any field that's a
+ // reference is copied as a reference in the new object. Both of these
+ // are defined PHP5 behaviors, as inconvenient as it is for us when old
+ // hooks from PHP4 days are passing fields by reference.
+ foreach ( [ 'mStripState', 'mVarCache' ] as $k ) {
+ // Make a non-reference copy of the field, then rebind the field to
+ // reference the new copy.
+ $tmp = $this->$k;
+ $this->$k =& $tmp;
+ unset( $tmp );
+ }
+
+ Hooks::run( 'ParserCloned', [ $this ] );
+ }
+
+ /**
+ * Do various kinds of initialisation on the first call of the parser
+ */
+ public function firstCallInit() {
+ if ( !$this->mFirstCall ) {
+ return;
+ }
+ $this->mFirstCall = false;
+
+ CoreParserFunctions::register( $this );
+ CoreTagHooks::register( $this );
+ $this->initialiseVariables();
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+ Hooks::run( 'ParserFirstCallInit', [ &$parser ] );
+ }
+
+ /**
+ * Clear Parser state
+ *
+ * @private
+ */
+ public function clearState() {
+ if ( $this->mFirstCall ) {
+ $this->firstCallInit();
+ }
+ $this->mOutput = new ParserOutput;
+ $this->mOptions->registerWatcher( [ $this->mOutput, 'recordOption' ] );
+ $this->mAutonumber = 0;
+ $this->mIncludeCount = [];
+ $this->mLinkHolders = new LinkHolderArray( $this );
+ $this->mLinkID = 0;
+ $this->mRevisionObject = $this->mRevisionTimestamp =
+ $this->mRevisionId = $this->mRevisionUser = $this->mRevisionSize = null;
+ $this->mVarCache = [];
+ $this->mUser = null;
+ $this->mLangLinkLanguages = [];
+ $this->currentRevisionCache = null;
+
+ $this->mStripState = new StripState;
+
+ # Clear these on every parse, T6549
+ $this->mTplRedirCache = $this->mTplDomCache = [];
+
+ $this->mShowToc = true;
+ $this->mForceTocPosition = false;
+ $this->mIncludeSizes = [
+ 'post-expand' => 0,
+ 'arg' => 0,
+ ];
+ $this->mPPNodeCount = 0;
+ $this->mGeneratedPPNodeCount = 0;
+ $this->mHighestExpansionDepth = 0;
+ $this->mDefaultSort = false;
+ $this->mHeadings = [];
+ $this->mDoubleUnderscores = [];
+ $this->mExpensiveFunctionCount = 0;
+
+ # Fix cloning
+ if ( isset( $this->mPreprocessor ) && $this->mPreprocessor->parser !== $this ) {
+ $this->mPreprocessor = null;
+ }
+
+ $this->mProfiler = new SectionProfiler();
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+ Hooks::run( 'ParserClearState', [ &$parser ] );
+ }
+
+ /**
+ * Convert wikitext to HTML
+ * Do not call this function recursively.
+ *
+ * @param string $text Text we want to parse
+ * @param Title $title
+ * @param ParserOptions $options
+ * @param bool $linestart
+ * @param bool $clearState
+ * @param int $revid Number to pass in {{REVISIONID}}
+ * @return ParserOutput A ParserOutput
+ */
+ public function parse(
+ $text, Title $title, ParserOptions $options,
+ $linestart = true, $clearState = true, $revid = null
+ ) {
+ /**
+ * First pass--just handle <nowiki> sections, pass the rest off
+ * to internalParse() which does all the real work.
+ */
+
+ global $wgShowHostnames;
+
+ if ( $clearState ) {
+ // We use U+007F DELETE to construct strip markers, so we have to make
+ // sure that this character does not occur in the input text.
+ $text = strtr( $text, "\x7f", "?" );
+ $magicScopeVariable = $this->lock();
+ }
+ // Strip U+0000 NULL (T159174)
+ $text = str_replace( "\000", '', $text );
+
+ $this->startParse( $title, $options, self::OT_HTML, $clearState );
+
+ $this->currentRevisionCache = null;
+ $this->mInputSize = strlen( $text );
+ if ( $this->mOptions->getEnableLimitReport() ) {
+ $this->mOutput->resetParseStartTime();
+ }
+
+ $oldRevisionId = $this->mRevisionId;
+ $oldRevisionObject = $this->mRevisionObject;
+ $oldRevisionTimestamp = $this->mRevisionTimestamp;
+ $oldRevisionUser = $this->mRevisionUser;
+ $oldRevisionSize = $this->mRevisionSize;
+ if ( $revid !== null ) {
+ $this->mRevisionId = $revid;
+ $this->mRevisionObject = null;
+ $this->mRevisionTimestamp = null;
+ $this->mRevisionUser = null;
+ $this->mRevisionSize = null;
+ }
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+ Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
+ # No more strip!
+ Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
+ $text = $this->internalParse( $text );
+ Hooks::run( 'ParserAfterParse', [ &$parser, &$text, &$this->mStripState ] );
+
+ $text = $this->internalParseHalfParsed( $text, true, $linestart );
+
+ /**
+ * A converted title will be provided in the output object if title and
+ * content conversion are enabled, the article text does not contain
+ * a conversion-suppressing double-underscore tag, and no
+ * {{DISPLAYTITLE:...}} is present. DISPLAYTITLE takes precedence over
+ * automatic link conversion.
+ */
+ if ( !( $options->getDisableTitleConversion()
+ || isset( $this->mDoubleUnderscores['nocontentconvert'] )
+ || isset( $this->mDoubleUnderscores['notitleconvert'] )
+ || $this->mOutput->getDisplayTitle() !== false )
+ ) {
+ $convruletitle = $this->getConverterLanguage()->getConvRuleTitle();
+ if ( $convruletitle ) {
+ $this->mOutput->setTitleText( $convruletitle );
+ } else {
+ $titleText = $this->getConverterLanguage()->convertTitle( $title );
+ $this->mOutput->setTitleText( $titleText );
+ }
+ }
+
+ # Done parsing! Compute runtime adaptive expiry if set
+ $this->mOutput->finalizeAdaptiveCacheExpiry();
+
+ # Warn if too many heavyweight parser functions were used
+ if ( $this->mExpensiveFunctionCount > $this->mOptions->getExpensiveParserFunctionLimit() ) {
+ $this->limitationWarn( 'expensive-parserfunction',
+ $this->mExpensiveFunctionCount,
+ $this->mOptions->getExpensiveParserFunctionLimit()
+ );
+ }
+
+ # Information on include size limits, for the benefit of users who try to skirt them
+ if ( $this->mOptions->getEnableLimitReport() ) {
+ $max = $this->mOptions->getMaxIncludeSize();
+
+ $cpuTime = $this->mOutput->getTimeSinceStart( 'cpu' );
+ if ( $cpuTime !== null ) {
+ $this->mOutput->setLimitReportData( 'limitreport-cputime',
+ sprintf( "%.3f", $cpuTime )
+ );
+ }
+
+ $wallTime = $this->mOutput->getTimeSinceStart( 'wall' );
+ $this->mOutput->setLimitReportData( 'limitreport-walltime',
+ sprintf( "%.3f", $wallTime )
+ );
+
+ $this->mOutput->setLimitReportData( 'limitreport-ppvisitednodes',
+ [ $this->mPPNodeCount, $this->mOptions->getMaxPPNodeCount() ]
+ );
+ $this->mOutput->setLimitReportData( 'limitreport-ppgeneratednodes',
+ [ $this->mGeneratedPPNodeCount, $this->mOptions->getMaxGeneratedPPNodeCount() ]
+ );
+ $this->mOutput->setLimitReportData( 'limitreport-postexpandincludesize',
+ [ $this->mIncludeSizes['post-expand'], $max ]
+ );
+ $this->mOutput->setLimitReportData( 'limitreport-templateargumentsize',
+ [ $this->mIncludeSizes['arg'], $max ]
+ );
+ $this->mOutput->setLimitReportData( 'limitreport-expansiondepth',
+ [ $this->mHighestExpansionDepth, $this->mOptions->getMaxPPExpandDepth() ]
+ );
+ $this->mOutput->setLimitReportData( 'limitreport-expensivefunctioncount',
+ [ $this->mExpensiveFunctionCount, $this->mOptions->getExpensiveParserFunctionLimit() ]
+ );
+ Hooks::run( 'ParserLimitReportPrepare', [ $this, $this->mOutput ] );
+
+ $limitReport = "NewPP limit report\n";
+ if ( $wgShowHostnames ) {
+ $limitReport .= 'Parsed by ' . wfHostname() . "\n";
+ }
+ $limitReport .= 'Cached time: ' . $this->mOutput->getCacheTime() . "\n";
+ $limitReport .= 'Cache expiry: ' . $this->mOutput->getCacheExpiry() . "\n";
+ $limitReport .= 'Dynamic content: ' .
+ ( $this->mOutput->hasDynamicContent() ? 'true' : 'false' ) .
+ "\n";
+
+ foreach ( $this->mOutput->getLimitReportData() as $key => $value ) {
+ if ( Hooks::run( 'ParserLimitReportFormat',
+ [ $key, &$value, &$limitReport, false, false ]
+ ) ) {
+ $keyMsg = wfMessage( $key )->inLanguage( 'en' )->useDatabase( false );
+ $valueMsg = wfMessage( [ "$key-value-text", "$key-value" ] )
+ ->inLanguage( 'en' )->useDatabase( false );
+ if ( !$valueMsg->exists() ) {
+ $valueMsg = new RawMessage( '$1' );
+ }
+ if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) {
+ $valueMsg->params( $value );
+ $limitReport .= "{$keyMsg->text()}: {$valueMsg->text()}\n";
+ }
+ }
+ }
+ // Since we're not really outputting HTML, decode the entities and
+ // then re-encode the things that need hiding inside HTML comments.
+ $limitReport = htmlspecialchars_decode( $limitReport );
+ // Run deprecated hook
+ Hooks::run( 'ParserLimitReport', [ $this, &$limitReport ], '1.22' );
+
+ // Sanitize for comment. Note '‐' in the replacement is U+2010,
+ // which looks much like the problematic '-'.
+ $limitReport = str_replace( [ '-', '&' ], [ '‐', '&amp;' ], $limitReport );
+ $text .= "\n<!-- \n$limitReport-->\n";
+
+ // Add on template profiling data in human/machine readable way
+ $dataByFunc = $this->mProfiler->getFunctionStats();
+ uasort( $dataByFunc, function ( $a, $b ) {
+ return $a['real'] < $b['real']; // descending order
+ } );
+ $profileReport = [];
+ foreach ( array_slice( $dataByFunc, 0, 10 ) as $item ) {
+ $profileReport[] = sprintf( "%6.2f%% %8.3f %6d %s",
+ $item['%real'], $item['real'], $item['calls'],
+ htmlspecialchars( $item['name'] ) );
+ }
+ $text .= "<!--\nTransclusion expansion time report (%,ms,calls,template)\n";
+ $text .= implode( "\n", $profileReport ) . "\n-->\n";
+
+ $this->mOutput->setLimitReportData( 'limitreport-timingprofile', $profileReport );
+
+ // Add other cache related metadata
+ if ( $wgShowHostnames ) {
+ $this->mOutput->setLimitReportData( 'cachereport-origin', wfHostname() );
+ }
+ $this->mOutput->setLimitReportData( 'cachereport-timestamp',
+ $this->mOutput->getCacheTime() );
+ $this->mOutput->setLimitReportData( 'cachereport-ttl',
+ $this->mOutput->getCacheExpiry() );
+ $this->mOutput->setLimitReportData( 'cachereport-transientcontent',
+ $this->mOutput->hasDynamicContent() );
+
+ if ( $this->mGeneratedPPNodeCount > $this->mOptions->getMaxGeneratedPPNodeCount() / 10 ) {
+ wfDebugLog( 'generated-pp-node-count', $this->mGeneratedPPNodeCount . ' ' .
+ $this->mTitle->getPrefixedDBkey() );
+ }
+ }
+
+ # Wrap non-interface parser output in a <div> so it can be targeted
+ # with CSS (T37247)
+ $class = $this->mOptions->getWrapOutputClass();
+ if ( $class !== false && !$this->mOptions->getInterfaceMessage() ) {
+ $text = Html::rawElement( 'div', [ 'class' => $class ], $text );
+ }
+
+ $this->mOutput->setText( $text );
+
+ $this->mRevisionId = $oldRevisionId;
+ $this->mRevisionObject = $oldRevisionObject;
+ $this->mRevisionTimestamp = $oldRevisionTimestamp;
+ $this->mRevisionUser = $oldRevisionUser;
+ $this->mRevisionSize = $oldRevisionSize;
+ $this->mInputSize = false;
+ $this->currentRevisionCache = null;
+
+ return $this->mOutput;
+ }
+
+ /**
+ * Half-parse wikitext to half-parsed HTML. This recursive parser entry point
+ * can be called from an extension tag hook.
+ *
+ * The output of this function IS NOT SAFE PARSED HTML; it is "half-parsed"
+ * instead, which means that lists and links have not been fully parsed yet,
+ * and strip markers are still present.
+ *
+ * Use recursiveTagParseFully() to fully parse wikitext to output-safe HTML.
+ *
+ * Use this function if you're a parser tag hook and you want to parse
+ * wikitext before or after applying additional transformations, and you
+ * intend to *return the result as hook output*, which will cause it to go
+ * through the rest of parsing process automatically.
+ *
+ * If $frame is not provided, then template variables (e.g., {{{1}}}) within
+ * $text are not expanded
+ *
+ * @param string $text Text extension wants to have parsed
+ * @param bool|PPFrame $frame The frame to use for expanding any template variables
+ * @return string UNSAFE half-parsed HTML
+ */
+ public function recursiveTagParse( $text, $frame = false ) {
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+ Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
+ Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
+ $text = $this->internalParse( $text, false, $frame );
+ return $text;
+ }
+
+ /**
+ * Fully parse wikitext to fully parsed HTML. This recursive parser entry
+ * point can be called from an extension tag hook.
+ *
+ * The output of this function is fully-parsed HTML that is safe for output.
+ * If you're a parser tag hook, you might want to use recursiveTagParse()
+ * instead.
+ *
+ * If $frame is not provided, then template variables (e.g., {{{1}}}) within
+ * $text are not expanded
+ *
+ * @since 1.25
+ *
+ * @param string $text Text extension wants to have parsed
+ * @param bool|PPFrame $frame The frame to use for expanding any template variables
+ * @return string Fully parsed HTML
+ */
+ public function recursiveTagParseFully( $text, $frame = false ) {
+ $text = $this->recursiveTagParse( $text, $frame );
+ $text = $this->internalParseHalfParsed( $text, false );
+ return $text;
+ }
+
+ /**
+ * Expand templates and variables in the text, producing valid, static wikitext.
+ * Also removes comments.
+ * Do not call this function recursively.
+ * @param string $text
+ * @param Title $title
+ * @param ParserOptions $options
+ * @param int|null $revid
+ * @param bool|PPFrame $frame
+ * @return mixed|string
+ */
+ public function preprocess( $text, Title $title = null,
+ ParserOptions $options, $revid = null, $frame = false
+ ) {
+ $magicScopeVariable = $this->lock();
+ $this->startParse( $title, $options, self::OT_PREPROCESS, true );
+ if ( $revid !== null ) {
+ $this->mRevisionId = $revid;
+ }
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+ Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
+ Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
+ $text = $this->replaceVariables( $text, $frame );
+ $text = $this->mStripState->unstripBoth( $text );
+ return $text;
+ }
+
+ /**
+ * Recursive parser entry point that can be called from an extension tag
+ * hook.
+ *
+ * @param string $text Text to be expanded
+ * @param bool|PPFrame $frame The frame to use for expanding any template variables
+ * @return string
+ * @since 1.19
+ */
+ public function recursivePreprocess( $text, $frame = false ) {
+ $text = $this->replaceVariables( $text, $frame );
+ $text = $this->mStripState->unstripBoth( $text );
+ return $text;
+ }
+
+ /**
+ * Process the wikitext for the "?preload=" feature. (T7210)
+ *
+ * "<noinclude>", "<includeonly>" etc. are parsed as for template
+ * transclusion, comments, templates, arguments, tags hooks and parser
+ * functions are untouched.
+ *
+ * @param string $text
+ * @param Title $title
+ * @param ParserOptions $options
+ * @param array $params
+ * @return string
+ */
+ public function getPreloadText( $text, Title $title, ParserOptions $options, $params = [] ) {
+ $msg = new RawMessage( $text );
+ $text = $msg->params( $params )->plain();
+
+ # Parser (re)initialisation
+ $magicScopeVariable = $this->lock();
+ $this->startParse( $title, $options, self::OT_PLAIN, true );
+
+ $flags = PPFrame::NO_ARGS | PPFrame::NO_TEMPLATES;
+ $dom = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
+ $text = $this->getPreprocessor()->newFrame()->expand( $dom, $flags );
+ $text = $this->mStripState->unstripBoth( $text );
+ return $text;
+ }
+
+ /**
+ * Set the current user.
+ * Should only be used when doing pre-save transform.
+ *
+ * @param User|null $user User object or null (to reset)
+ */
+ public function setUser( $user ) {
+ $this->mUser = $user;
+ }
+
+ /**
+ * Set the context title
+ *
+ * @param Title $t
+ */
+ public function setTitle( $t ) {
+ if ( !$t ) {
+ $t = Title::newFromText( 'NO TITLE' );
+ }
+
+ if ( $t->hasFragment() ) {
+ # Strip the fragment to avoid various odd effects
+ $this->mTitle = $t->createFragmentTarget( '' );
+ } else {
+ $this->mTitle = $t;
+ }
+ }
+
+ /**
+ * Accessor for the Title object
+ *
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->mTitle;
+ }
+
+ /**
+ * Accessor/mutator for the Title object
+ *
+ * @param Title $x Title object or null to just get the current one
+ * @return Title
+ */
+ public function Title( $x = null ) {
+ return wfSetVar( $this->mTitle, $x );
+ }
+
+ /**
+ * Set the output type
+ *
+ * @param int $ot New value
+ */
+ public function setOutputType( $ot ) {
+ $this->mOutputType = $ot;
+ # Shortcut alias
+ $this->ot = [
+ 'html' => $ot == self::OT_HTML,
+ 'wiki' => $ot == self::OT_WIKI,
+ 'pre' => $ot == self::OT_PREPROCESS,
+ 'plain' => $ot == self::OT_PLAIN,
+ ];
+ }
+
+ /**
+ * Accessor/mutator for the output type
+ *
+ * @param int|null $x New value or null to just get the current one
+ * @return int
+ */
+ public function OutputType( $x = null ) {
+ return wfSetVar( $this->mOutputType, $x );
+ }
+
+ /**
+ * Get the ParserOutput object
+ *
+ * @return ParserOutput
+ */
+ public function getOutput() {
+ return $this->mOutput;
+ }
+
+ /**
+ * Get the ParserOptions object
+ *
+ * @return ParserOptions
+ */
+ public function getOptions() {
+ return $this->mOptions;
+ }
+
+ /**
+ * Accessor/mutator for the ParserOptions object
+ *
+ * @param ParserOptions $x New value or null to just get the current one
+ * @return ParserOptions Current ParserOptions object
+ */
+ public function Options( $x = null ) {
+ return wfSetVar( $this->mOptions, $x );
+ }
+
+ /**
+ * @return int
+ */
+ public function nextLinkID() {
+ return $this->mLinkID++;
+ }
+
+ /**
+ * @param int $id
+ */
+ public function setLinkID( $id ) {
+ $this->mLinkID = $id;
+ }
+
+ /**
+ * Get a language object for use in parser functions such as {{FORMATNUM:}}
+ * @return Language
+ */
+ public function getFunctionLang() {
+ return $this->getTargetLanguage();
+ }
+
+ /**
+ * Get the target language for the content being parsed. This is usually the
+ * language that the content is in.
+ *
+ * @since 1.19
+ *
+ * @throws MWException
+ * @return Language
+ */
+ public function getTargetLanguage() {
+ $target = $this->mOptions->getTargetLanguage();
+
+ if ( $target !== null ) {
+ return $target;
+ } elseif ( $this->mOptions->getInterfaceMessage() ) {
+ return $this->mOptions->getUserLangObj();
+ } elseif ( is_null( $this->mTitle ) ) {
+ throw new MWException( __METHOD__ . ': $this->mTitle is null' );
+ }
+
+ return $this->mTitle->getPageLanguage();
+ }
+
+ /**
+ * Get the language object for language conversion
+ * @return Language|null
+ */
+ public function getConverterLanguage() {
+ return $this->getTargetLanguage();
+ }
+
+ /**
+ * Get a User object either from $this->mUser, if set, or from the
+ * ParserOptions object otherwise
+ *
+ * @return User
+ */
+ public function getUser() {
+ if ( !is_null( $this->mUser ) ) {
+ return $this->mUser;
+ }
+ return $this->mOptions->getUser();
+ }
+
+ /**
+ * Get a preprocessor object
+ *
+ * @return Preprocessor
+ */
+ public function getPreprocessor() {
+ if ( !isset( $this->mPreprocessor ) ) {
+ $class = $this->mPreprocessorClass;
+ $this->mPreprocessor = new $class( $this );
+ }
+ return $this->mPreprocessor;
+ }
+
+ /**
+ * Get a LinkRenderer instance to make links with
+ *
+ * @since 1.28
+ * @return LinkRenderer
+ */
+ public function getLinkRenderer() {
+ if ( !$this->mLinkRenderer ) {
+ $this->mLinkRenderer = MediaWikiServices::getInstance()
+ ->getLinkRendererFactory()->create();
+ $this->mLinkRenderer->setStubThreshold(
+ $this->getOptions()->getStubThreshold()
+ );
+ }
+
+ return $this->mLinkRenderer;
+ }
+
+ /**
+ * Replaces all occurrences of HTML-style comments and the given tags
+ * in the text with a random marker and returns the next text. The output
+ * parameter $matches will be an associative array filled with data in
+ * the form:
+ *
+ * @code
+ * 'UNIQ-xxxxx' => [
+ * 'element',
+ * 'tag content',
+ * [ 'param' => 'x' ],
+ * '<element param="x">tag content</element>' ]
+ * @endcode
+ *
+ * @param array $elements List of element names. Comments are always extracted.
+ * @param string $text Source text string.
+ * @param array &$matches Out parameter, Array: extracted tags
+ * @return string Stripped text
+ */
+ public static function extractTagsAndParams( $elements, $text, &$matches ) {
+ static $n = 1;
+ $stripped = '';
+ $matches = [];
+
+ $taglist = implode( '|', $elements );
+ $start = "/<($taglist)(\\s+[^>]*?|\\s*?)(\/?" . ">)|<(!--)/i";
+
+ while ( $text != '' ) {
+ $p = preg_split( $start, $text, 2, PREG_SPLIT_DELIM_CAPTURE );
+ $stripped .= $p[0];
+ if ( count( $p ) < 5 ) {
+ break;
+ }
+ if ( count( $p ) > 5 ) {
+ # comment
+ $element = $p[4];
+ $attributes = '';
+ $close = '';
+ $inside = $p[5];
+ } else {
+ # tag
+ $element = $p[1];
+ $attributes = $p[2];
+ $close = $p[3];
+ $inside = $p[4];
+ }
+
+ $marker = self::MARKER_PREFIX . "-$element-" . sprintf( '%08X', $n++ ) . self::MARKER_SUFFIX;
+ $stripped .= $marker;
+
+ if ( $close === '/>' ) {
+ # Empty element tag, <tag />
+ $content = null;
+ $text = $inside;
+ $tail = null;
+ } else {
+ if ( $element === '!--' ) {
+ $end = '/(-->)/';
+ } else {
+ $end = "/(<\\/$element\\s*>)/i";
+ }
+ $q = preg_split( $end, $inside, 2, PREG_SPLIT_DELIM_CAPTURE );
+ $content = $q[0];
+ if ( count( $q ) < 3 ) {
+ # No end tag -- let it run out to the end of the text.
+ $tail = '';
+ $text = '';
+ } else {
+ $tail = $q[1];
+ $text = $q[2];
+ }
+ }
+
+ $matches[$marker] = [ $element,
+ $content,
+ Sanitizer::decodeTagAttributes( $attributes ),
+ "<$element$attributes$close$content$tail" ];
+ }
+ return $stripped;
+ }
+
+ /**
+ * Get a list of strippable XML-like elements
+ *
+ * @return array
+ */
+ public function getStripList() {
+ return $this->mStripList;
+ }
+
+ /**
+ * Add an item to the strip state
+ * Returns the unique tag which must be inserted into the stripped text
+ * The tag will be replaced with the original text in unstrip()
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ public function insertStripItem( $text ) {
+ $marker = self::MARKER_PREFIX . "-item-{$this->mMarkerIndex}-" . self::MARKER_SUFFIX;
+ $this->mMarkerIndex++;
+ $this->mStripState->addGeneral( $marker, $text );
+ return $marker;
+ }
+
+ /**
+ * parse the wiki syntax used to render tables
+ *
+ * @private
+ * @param string $text
+ * @return string
+ */
+ public function doTableStuff( $text ) {
+ $lines = StringUtils::explode( "\n", $text );
+ $out = '';
+ $td_history = []; # Is currently a td tag open?
+ $last_tag_history = []; # Save history of last lag activated (td, th or caption)
+ $tr_history = []; # Is currently a tr tag open?
+ $tr_attributes = []; # history of tr attributes
+ $has_opened_tr = []; # Did this table open a <tr> element?
+ $indent_level = 0; # indent level of the table
+
+ foreach ( $lines as $outLine ) {
+ $line = trim( $outLine );
+
+ if ( $line === '' ) { # empty line, go to next line
+ $out .= $outLine . "\n";
+ continue;
+ }
+
+ $first_character = $line[0];
+ $first_two = substr( $line, 0, 2 );
+ $matches = [];
+
+ if ( preg_match( '/^(:*)\s*\{\|(.*)$/', $line, $matches ) ) {
+ # First check if we are starting a new table
+ $indent_level = strlen( $matches[1] );
+
+ $attributes = $this->mStripState->unstripBoth( $matches[2] );
+ $attributes = Sanitizer::fixTagAttributes( $attributes, 'table' );
+
+ $outLine = str_repeat( '<dl><dd>', $indent_level ) . "<table{$attributes}>";
+ array_push( $td_history, false );
+ array_push( $last_tag_history, '' );
+ array_push( $tr_history, false );
+ array_push( $tr_attributes, '' );
+ array_push( $has_opened_tr, false );
+ } elseif ( count( $td_history ) == 0 ) {
+ # Don't do any of the following
+ $out .= $outLine . "\n";
+ continue;
+ } elseif ( $first_two === '|}' ) {
+ # We are ending a table
+ $line = '</table>' . substr( $line, 2 );
+ $last_tag = array_pop( $last_tag_history );
+
+ if ( !array_pop( $has_opened_tr ) ) {
+ $line = "<tr><td></td></tr>{$line}";
+ }
+
+ if ( array_pop( $tr_history ) ) {
+ $line = "</tr>{$line}";
+ }
+
+ if ( array_pop( $td_history ) ) {
+ $line = "</{$last_tag}>{$line}";
+ }
+ array_pop( $tr_attributes );
+ $outLine = $line . str_repeat( '</dd></dl>', $indent_level );
+ } elseif ( $first_two === '|-' ) {
+ # Now we have a table row
+ $line = preg_replace( '#^\|-+#', '', $line );
+
+ # Whats after the tag is now only attributes
+ $attributes = $this->mStripState->unstripBoth( $line );
+ $attributes = Sanitizer::fixTagAttributes( $attributes, 'tr' );
+ array_pop( $tr_attributes );
+ array_push( $tr_attributes, $attributes );
+
+ $line = '';
+ $last_tag = array_pop( $last_tag_history );
+ array_pop( $has_opened_tr );
+ array_push( $has_opened_tr, true );
+
+ if ( array_pop( $tr_history ) ) {
+ $line = '</tr>';
+ }
+
+ if ( array_pop( $td_history ) ) {
+ $line = "</{$last_tag}>{$line}";
+ }
+
+ $outLine = $line;
+ array_push( $tr_history, false );
+ array_push( $td_history, false );
+ array_push( $last_tag_history, '' );
+ } elseif ( $first_character === '|'
+ || $first_character === '!'
+ || $first_two === '|+'
+ ) {
+ # This might be cell elements, td, th or captions
+ if ( $first_two === '|+' ) {
+ $first_character = '+';
+ $line = substr( $line, 2 );
+ } else {
+ $line = substr( $line, 1 );
+ }
+
+ // Implies both are valid for table headings.
+ if ( $first_character === '!' ) {
+ $line = StringUtils::replaceMarkup( '!!', '||', $line );
+ }
+
+ # Split up multiple cells on the same line.
+ # FIXME : This can result in improper nesting of tags processed
+ # by earlier parser steps.
+ $cells = explode( '||', $line );
+
+ $outLine = '';
+
+ # Loop through each table cell
+ foreach ( $cells as $cell ) {
+ $previous = '';
+ if ( $first_character !== '+' ) {
+ $tr_after = array_pop( $tr_attributes );
+ if ( !array_pop( $tr_history ) ) {
+ $previous = "<tr{$tr_after}>\n";
+ }
+ array_push( $tr_history, true );
+ array_push( $tr_attributes, '' );
+ array_pop( $has_opened_tr );
+ array_push( $has_opened_tr, true );
+ }
+
+ $last_tag = array_pop( $last_tag_history );
+
+ if ( array_pop( $td_history ) ) {
+ $previous = "</{$last_tag}>\n{$previous}";
+ }
+
+ if ( $first_character === '|' ) {
+ $last_tag = 'td';
+ } elseif ( $first_character === '!' ) {
+ $last_tag = 'th';
+ } elseif ( $first_character === '+' ) {
+ $last_tag = 'caption';
+ } else {
+ $last_tag = '';
+ }
+
+ array_push( $last_tag_history, $last_tag );
+
+ # A cell could contain both parameters and data
+ $cell_data = explode( '|', $cell, 2 );
+
+ # T2553: Note that a '|' inside an invalid link should not
+ # be mistaken as delimiting cell parameters
+ # Bug T153140: Neither should language converter markup.
+ if ( preg_match( '/\[\[|-\{/', $cell_data[0] ) === 1 ) {
+ $cell = "{$previous}<{$last_tag}>{$cell}";
+ } elseif ( count( $cell_data ) == 1 ) {
+ $cell = "{$previous}<{$last_tag}>{$cell_data[0]}";
+ } else {
+ $attributes = $this->mStripState->unstripBoth( $cell_data[0] );
+ $attributes = Sanitizer::fixTagAttributes( $attributes, $last_tag );
+ $cell = "{$previous}<{$last_tag}{$attributes}>{$cell_data[1]}";
+ }
+
+ $outLine .= $cell;
+ array_push( $td_history, true );
+ }
+ }
+ $out .= $outLine . "\n";
+ }
+
+ # Closing open td, tr && table
+ while ( count( $td_history ) > 0 ) {
+ if ( array_pop( $td_history ) ) {
+ $out .= "</td>\n";
+ }
+ if ( array_pop( $tr_history ) ) {
+ $out .= "</tr>\n";
+ }
+ if ( !array_pop( $has_opened_tr ) ) {
+ $out .= "<tr><td></td></tr>\n";
+ }
+
+ $out .= "</table>\n";
+ }
+
+ # Remove trailing line-ending (b/c)
+ if ( substr( $out, -1 ) === "\n" ) {
+ $out = substr( $out, 0, -1 );
+ }
+
+ # special case: don't return empty table
+ if ( $out === "<table>\n<tr><td></td></tr>\n</table>" ) {
+ $out = '';
+ }
+
+ return $out;
+ }
+
+ /**
+ * Helper function for parse() that transforms wiki markup into half-parsed
+ * HTML. Only called for $mOutputType == self::OT_HTML.
+ *
+ * @private
+ *
+ * @param string $text The text to parse
+ * @param bool $isMain Whether this is being called from the main parse() function
+ * @param PPFrame|bool $frame A pre-processor frame
+ *
+ * @return string
+ */
+ public function internalParse( $text, $isMain = true, $frame = false ) {
+ $origText = $text;
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+
+ # Hook to suspend the parser in this state
+ if ( !Hooks::run( 'ParserBeforeInternalParse', [ &$parser, &$text, &$this->mStripState ] ) ) {
+ return $text;
+ }
+
+ # if $frame is provided, then use $frame for replacing any variables
+ if ( $frame ) {
+ # use frame depth to infer how include/noinclude tags should be handled
+ # depth=0 means this is the top-level document; otherwise it's an included document
+ if ( !$frame->depth ) {
+ $flag = 0;
+ } else {
+ $flag = self::PTD_FOR_INCLUSION;
+ }
+ $dom = $this->preprocessToDom( $text, $flag );
+ $text = $frame->expand( $dom );
+ } else {
+ # if $frame is not provided, then use old-style replaceVariables
+ $text = $this->replaceVariables( $text );
+ }
+
+ Hooks::run( 'InternalParseBeforeSanitize', [ &$parser, &$text, &$this->mStripState ] );
+ $text = Sanitizer::removeHTMLtags(
+ $text,
+ [ $this, 'attributeStripCallback' ],
+ false,
+ array_keys( $this->mTransparentTagHooks ),
+ [],
+ [ $this, 'addTrackingCategory' ]
+ );
+ Hooks::run( 'InternalParseBeforeLinks', [ &$parser, &$text, &$this->mStripState ] );
+
+ # Tables need to come after variable replacement for things to work
+ # properly; putting them before other transformations should keep
+ # exciting things like link expansions from showing up in surprising
+ # places.
+ $text = $this->doTableStuff( $text );
+
+ $text = preg_replace( '/(^|\n)-----*/', '\\1<hr />', $text );
+
+ $text = $this->doDoubleUnderscore( $text );
+
+ $text = $this->doHeadings( $text );
+ $text = $this->replaceInternalLinks( $text );
+ $text = $this->doAllQuotes( $text );
+ $text = $this->replaceExternalLinks( $text );
+
+ # replaceInternalLinks may sometimes leave behind
+ # absolute URLs, which have to be masked to hide them from replaceExternalLinks
+ $text = str_replace( self::MARKER_PREFIX . 'NOPARSE', '', $text );
+
+ $text = $this->doMagicLinks( $text );
+ $text = $this->formatHeadings( $text, $origText, $isMain );
+
+ return $text;
+ }
+
+ /**
+ * Helper function for parse() that transforms half-parsed HTML into fully
+ * parsed HTML.
+ *
+ * @param string $text
+ * @param bool $isMain
+ * @param bool $linestart
+ * @return string
+ */
+ private function internalParseHalfParsed( $text, $isMain = true, $linestart = true ) {
+ $text = $this->mStripState->unstripGeneral( $text );
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+
+ if ( $isMain ) {
+ Hooks::run( 'ParserAfterUnstrip', [ &$parser, &$text ] );
+ }
+
+ # Clean up special characters, only run once, next-to-last before doBlockLevels
+ $fixtags = [
+ # French spaces, last one Guillemet-left
+ # only if there is something before the space
+ '/(.) (?=\\?|:|;|!|%|\\302\\273)/' => '\\1&#160;',
+ # french spaces, Guillemet-right
+ '/(\\302\\253) /' => '\\1&#160;',
+ '/&#160;(!\s*important)/' => ' \\1', # Beware of CSS magic word !important, T13874.
+ ];
+ $text = preg_replace( array_keys( $fixtags ), array_values( $fixtags ), $text );
+
+ $text = $this->doBlockLevels( $text, $linestart );
+
+ $this->replaceLinkHolders( $text );
+
+ /**
+ * The input doesn't get language converted if
+ * a) It's disabled
+ * b) Content isn't converted
+ * c) It's a conversion table
+ * d) it is an interface message (which is in the user language)
+ */
+ if ( !( $this->mOptions->getDisableContentConversion()
+ || isset( $this->mDoubleUnderscores['nocontentconvert'] ) )
+ ) {
+ if ( !$this->mOptions->getInterfaceMessage() ) {
+ # The position of the convert() call should not be changed. it
+ # assumes that the links are all replaced and the only thing left
+ # is the <nowiki> mark.
+ $text = $this->getConverterLanguage()->convert( $text );
+ }
+ }
+
+ $text = $this->mStripState->unstripNoWiki( $text );
+
+ if ( $isMain ) {
+ Hooks::run( 'ParserBeforeTidy', [ &$parser, &$text ] );
+ }
+
+ $text = $this->replaceTransparentTags( $text );
+ $text = $this->mStripState->unstripGeneral( $text );
+
+ $text = Sanitizer::normalizeCharReferences( $text );
+
+ if ( MWTidy::isEnabled() ) {
+ if ( $this->mOptions->getTidy() ) {
+ $text = MWTidy::tidy( $text );
+ }
+ } else {
+ # attempt to sanitize at least some nesting problems
+ # (T4702 and quite a few others)
+ $tidyregs = [
+ # ''Something [http://www.cool.com cool''] -->
+ # <i>Something</i><a href="http://www.cool.com"..><i>cool></i></a>
+ '/(<([bi])>)(<([bi])>)?([^<]*)(<\/?a[^<]*>)([^<]*)(<\/\\4>)?(<\/\\2>)/' =>
+ '\\1\\3\\5\\8\\9\\6\\1\\3\\7\\8\\9',
+ # fix up an anchor inside another anchor, only
+ # at least for a single single nested link (T5695)
+ '/(<a[^>]+>)([^<]*)(<a[^>]+>[^<]*)<\/a>(.*)<\/a>/' =>
+ '\\1\\2</a>\\3</a>\\1\\4</a>',
+ # fix div inside inline elements- doBlockLevels won't wrap a line which
+ # contains a div, so fix it up here; replace
+ # div with escaped text
+ '/(<([aib]) [^>]+>)([^<]*)(<div([^>]*)>)(.*)(<\/div>)([^<]*)(<\/\\2>)/' =>
+ '\\1\\3&lt;div\\5&gt;\\6&lt;/div&gt;\\8\\9',
+ # remove empty italic or bold tag pairs, some
+ # introduced by rules above
+ '/<([bi])><\/\\1>/' => '',
+ ];
+
+ $text = preg_replace(
+ array_keys( $tidyregs ),
+ array_values( $tidyregs ),
+ $text );
+ }
+
+ if ( $isMain ) {
+ Hooks::run( 'ParserAfterTidy', [ &$parser, &$text ] );
+ }
+
+ return $text;
+ }
+
+ /**
+ * Replace special strings like "ISBN xxx" and "RFC xxx" with
+ * magic external links.
+ *
+ * DML
+ * @private
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ public function doMagicLinks( $text ) {
+ $prots = wfUrlProtocolsWithoutProtRel();
+ $urlChar = self::EXT_LINK_URL_CLASS;
+ $addr = self::EXT_LINK_ADDR;
+ $space = self::SPACE_NOT_NL; # non-newline space
+ $spdash = "(?:-|$space)"; # a dash or a non-newline space
+ $spaces = "$space++"; # possessive match of 1 or more spaces
+ $text = preg_replace_callback(
+ '!(?: # Start cases
+ (<a[ \t\r\n>].*?</a>) | # m[1]: Skip link text
+ (<.*?>) | # m[2]: Skip stuff inside HTML elements' . "
+ (\b # m[3]: Free external links
+ (?i:$prots)
+ ($addr$urlChar*) # m[4]: Post-protocol path
+ ) |
+ \b(?:RFC|PMID) $spaces # m[5]: RFC or PMID, capture number
+ ([0-9]+)\b |
+ \bISBN $spaces ( # m[6]: ISBN, capture number
+ (?: 97[89] $spdash? )? # optional 13-digit ISBN prefix
+ (?: [0-9] $spdash? ){9} # 9 digits with opt. delimiters
+ [0-9Xx] # check digit
+ )\b
+ )!xu", [ $this, 'magicLinkCallback' ], $text );
+ return $text;
+ }
+
+ /**
+ * @throws MWException
+ * @param array $m
+ * @return HTML|string
+ */
+ public function magicLinkCallback( $m ) {
+ if ( isset( $m[1] ) && $m[1] !== '' ) {
+ # Skip anchor
+ return $m[0];
+ } elseif ( isset( $m[2] ) && $m[2] !== '' ) {
+ # Skip HTML element
+ return $m[0];
+ } elseif ( isset( $m[3] ) && $m[3] !== '' ) {
+ # Free external link
+ return $this->makeFreeExternalLink( $m[0], strlen( $m[4] ) );
+ } elseif ( isset( $m[5] ) && $m[5] !== '' ) {
+ # RFC or PMID
+ if ( substr( $m[0], 0, 3 ) === 'RFC' ) {
+ if ( !$this->mOptions->getMagicRFCLinks() ) {
+ return $m[0];
+ }
+ $keyword = 'RFC';
+ $urlmsg = 'rfcurl';
+ $cssClass = 'mw-magiclink-rfc';
+ $trackingCat = 'magiclink-tracking-rfc';
+ $id = $m[5];
+ } elseif ( substr( $m[0], 0, 4 ) === 'PMID' ) {
+ if ( !$this->mOptions->getMagicPMIDLinks() ) {
+ return $m[0];
+ }
+ $keyword = 'PMID';
+ $urlmsg = 'pubmedurl';
+ $cssClass = 'mw-magiclink-pmid';
+ $trackingCat = 'magiclink-tracking-pmid';
+ $id = $m[5];
+ } else {
+ throw new MWException( __METHOD__ . ': unrecognised match type "' .
+ substr( $m[0], 0, 20 ) . '"' );
+ }
+ $url = wfMessage( $urlmsg, $id )->inContentLanguage()->text();
+ $this->addTrackingCategory( $trackingCat );
+ return Linker::makeExternalLink( $url, "{$keyword} {$id}", true, $cssClass, [], $this->mTitle );
+ } elseif ( isset( $m[6] ) && $m[6] !== ''
+ && $this->mOptions->getMagicISBNLinks()
+ ) {
+ # ISBN
+ $isbn = $m[6];
+ $space = self::SPACE_NOT_NL; # non-newline space
+ $isbn = preg_replace( "/$space/", ' ', $isbn );
+ $num = strtr( $isbn, [
+ '-' => '',
+ ' ' => '',
+ 'x' => 'X',
+ ] );
+ $this->addTrackingCategory( 'magiclink-tracking-isbn' );
+ return $this->getLinkRenderer()->makeKnownLink(
+ SpecialPage::getTitleFor( 'Booksources', $num ),
+ "ISBN $isbn",
+ [
+ 'class' => 'internal mw-magiclink-isbn',
+ 'title' => false // suppress title attribute
+ ]
+ );
+ } else {
+ return $m[0];
+ }
+ }
+
+ /**
+ * Make a free external link, given a user-supplied URL
+ *
+ * @param string $url
+ * @param int $numPostProto
+ * The number of characters after the protocol.
+ * @return string HTML
+ * @private
+ */
+ public function makeFreeExternalLink( $url, $numPostProto ) {
+ $trail = '';
+
+ # The characters '<' and '>' (which were escaped by
+ # removeHTMLtags()) should not be included in
+ # URLs, per RFC 2396.
+ # Make &nbsp; terminate a URL as well (bug T84937)
+ $m2 = [];
+ if ( preg_match(
+ '/&(lt|gt|nbsp|#x0*(3[CcEe]|[Aa]0)|#0*(60|62|160));/',
+ $url,
+ $m2,
+ PREG_OFFSET_CAPTURE
+ ) ) {
+ $trail = substr( $url, $m2[0][1] ) . $trail;
+ $url = substr( $url, 0, $m2[0][1] );
+ }
+
+ # Move trailing punctuation to $trail
+ $sep = ',;\.:!?';
+ # If there is no left bracket, then consider right brackets fair game too
+ if ( strpos( $url, '(' ) === false ) {
+ $sep .= ')';
+ }
+
+ $urlRev = strrev( $url );
+ $numSepChars = strspn( $urlRev, $sep );
+ # Don't break a trailing HTML entity by moving the ; into $trail
+ # This is in hot code, so use substr_compare to avoid having to
+ # create a new string object for the comparison
+ if ( $numSepChars && substr_compare( $url, ";", -$numSepChars, 1 ) === 0 ) {
+ # more optimization: instead of running preg_match with a $
+ # anchor, which can be slow, do the match on the reversed
+ # string starting at the desired offset.
+ # un-reversed regexp is: /&([a-z]+|#x[\da-f]+|#\d+)$/i
+ if ( preg_match( '/\G([a-z]+|[\da-f]+x#|\d+#)&/i', $urlRev, $m2, 0, $numSepChars ) ) {
+ $numSepChars--;
+ }
+ }
+ if ( $numSepChars ) {
+ $trail = substr( $url, -$numSepChars ) . $trail;
+ $url = substr( $url, 0, -$numSepChars );
+ }
+
+ # Verify that we still have a real URL after trail removal, and
+ # not just lone protocol
+ if ( strlen( $trail ) >= $numPostProto ) {
+ return $url . $trail;
+ }
+
+ $url = Sanitizer::cleanUrl( $url );
+
+ # Is this an external image?
+ $text = $this->maybeMakeExternalImage( $url );
+ if ( $text === false ) {
+ # Not an image, make a link
+ $text = Linker::makeExternalLink( $url,
+ $this->getConverterLanguage()->markNoConversion( $url, true ),
+ true, 'free',
+ $this->getExternalLinkAttribs( $url ), $this->mTitle );
+ # Register it in the output object...
+ $this->mOutput->addExternalLink( $url );
+ }
+ return $text . $trail;
+ }
+
+ /**
+ * Parse headers and return html
+ *
+ * @private
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ public function doHeadings( $text ) {
+ for ( $i = 6; $i >= 1; --$i ) {
+ $h = str_repeat( '=', $i );
+ $text = preg_replace( "/^$h(.+)$h\\s*$/m", "<h$i>\\1</h$i>", $text );
+ }
+ return $text;
+ }
+
+ /**
+ * Replace single quotes with HTML markup
+ * @private
+ *
+ * @param string $text
+ *
+ * @return string The altered text
+ */
+ public function doAllQuotes( $text ) {
+ $outtext = '';
+ $lines = StringUtils::explode( "\n", $text );
+ foreach ( $lines as $line ) {
+ $outtext .= $this->doQuotes( $line ) . "\n";
+ }
+ $outtext = substr( $outtext, 0, -1 );
+ return $outtext;
+ }
+
+ /**
+ * Helper function for doAllQuotes()
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ public function doQuotes( $text ) {
+ $arr = preg_split( "/(''+)/", $text, -1, PREG_SPLIT_DELIM_CAPTURE );
+ $countarr = count( $arr );
+ if ( $countarr == 1 ) {
+ return $text;
+ }
+
+ // First, do some preliminary work. This may shift some apostrophes from
+ // being mark-up to being text. It also counts the number of occurrences
+ // of bold and italics mark-ups.
+ $numbold = 0;
+ $numitalics = 0;
+ for ( $i = 1; $i < $countarr; $i += 2 ) {
+ $thislen = strlen( $arr[$i] );
+ // If there are ever four apostrophes, assume the first is supposed to
+ // be text, and the remaining three constitute mark-up for bold text.
+ // (T15227: ''''foo'''' turns into ' ''' foo ' ''')
+ if ( $thislen == 4 ) {
+ $arr[$i - 1] .= "'";
+ $arr[$i] = "'''";
+ $thislen = 3;
+ } elseif ( $thislen > 5 ) {
+ // If there are more than 5 apostrophes in a row, assume they're all
+ // text except for the last 5.
+ // (T15227: ''''''foo'''''' turns into ' ''''' foo ' ''''')
+ $arr[$i - 1] .= str_repeat( "'", $thislen - 5 );
+ $arr[$i] = "'''''";
+ $thislen = 5;
+ }
+ // Count the number of occurrences of bold and italics mark-ups.
+ if ( $thislen == 2 ) {
+ $numitalics++;
+ } elseif ( $thislen == 3 ) {
+ $numbold++;
+ } elseif ( $thislen == 5 ) {
+ $numitalics++;
+ $numbold++;
+ }
+ }
+
+ // If there is an odd number of both bold and italics, it is likely
+ // that one of the bold ones was meant to be an apostrophe followed
+ // by italics. Which one we cannot know for certain, but it is more
+ // likely to be one that has a single-letter word before it.
+ if ( ( $numbold % 2 == 1 ) && ( $numitalics % 2 == 1 ) ) {
+ $firstsingleletterword = -1;
+ $firstmultiletterword = -1;
+ $firstspace = -1;
+ for ( $i = 1; $i < $countarr; $i += 2 ) {
+ if ( strlen( $arr[$i] ) == 3 ) {
+ $x1 = substr( $arr[$i - 1], -1 );
+ $x2 = substr( $arr[$i - 1], -2, 1 );
+ if ( $x1 === ' ' ) {
+ if ( $firstspace == -1 ) {
+ $firstspace = $i;
+ }
+ } elseif ( $x2 === ' ' ) {
+ $firstsingleletterword = $i;
+ // if $firstsingleletterword is set, we don't
+ // look at the other options, so we can bail early.
+ break;
+ } else {
+ if ( $firstmultiletterword == -1 ) {
+ $firstmultiletterword = $i;
+ }
+ }
+ }
+ }
+
+ // If there is a single-letter word, use it!
+ if ( $firstsingleletterword > -1 ) {
+ $arr[$firstsingleletterword] = "''";
+ $arr[$firstsingleletterword - 1] .= "'";
+ } elseif ( $firstmultiletterword > -1 ) {
+ // If not, but there's a multi-letter word, use that one.
+ $arr[$firstmultiletterword] = "''";
+ $arr[$firstmultiletterword - 1] .= "'";
+ } elseif ( $firstspace > -1 ) {
+ // ... otherwise use the first one that has neither.
+ // (notice that it is possible for all three to be -1 if, for example,
+ // there is only one pentuple-apostrophe in the line)
+ $arr[$firstspace] = "''";
+ $arr[$firstspace - 1] .= "'";
+ }
+ }
+
+ // Now let's actually convert our apostrophic mush to HTML!
+ $output = '';
+ $buffer = '';
+ $state = '';
+ $i = 0;
+ foreach ( $arr as $r ) {
+ if ( ( $i % 2 ) == 0 ) {
+ if ( $state === 'both' ) {
+ $buffer .= $r;
+ } else {
+ $output .= $r;
+ }
+ } else {
+ $thislen = strlen( $r );
+ if ( $thislen == 2 ) {
+ if ( $state === 'i' ) {
+ $output .= '</i>';
+ $state = '';
+ } elseif ( $state === 'bi' ) {
+ $output .= '</i>';
+ $state = 'b';
+ } elseif ( $state === 'ib' ) {
+ $output .= '</b></i><b>';
+ $state = 'b';
+ } elseif ( $state === 'both' ) {
+ $output .= '<b><i>' . $buffer . '</i>';
+ $state = 'b';
+ } else { // $state can be 'b' or ''
+ $output .= '<i>';
+ $state .= 'i';
+ }
+ } elseif ( $thislen == 3 ) {
+ if ( $state === 'b' ) {
+ $output .= '</b>';
+ $state = '';
+ } elseif ( $state === 'bi' ) {
+ $output .= '</i></b><i>';
+ $state = 'i';
+ } elseif ( $state === 'ib' ) {
+ $output .= '</b>';
+ $state = 'i';
+ } elseif ( $state === 'both' ) {
+ $output .= '<i><b>' . $buffer . '</b>';
+ $state = 'i';
+ } else { // $state can be 'i' or ''
+ $output .= '<b>';
+ $state .= 'b';
+ }
+ } elseif ( $thislen == 5 ) {
+ if ( $state === 'b' ) {
+ $output .= '</b><i>';
+ $state = 'i';
+ } elseif ( $state === 'i' ) {
+ $output .= '</i><b>';
+ $state = 'b';
+ } elseif ( $state === 'bi' ) {
+ $output .= '</i></b>';
+ $state = '';
+ } elseif ( $state === 'ib' ) {
+ $output .= '</b></i>';
+ $state = '';
+ } elseif ( $state === 'both' ) {
+ $output .= '<i><b>' . $buffer . '</b></i>';
+ $state = '';
+ } else { // ($state == '')
+ $buffer = '';
+ $state = 'both';
+ }
+ }
+ }
+ $i++;
+ }
+ // Now close all remaining tags. Notice that the order is important.
+ if ( $state === 'b' || $state === 'ib' ) {
+ $output .= '</b>';
+ }
+ if ( $state === 'i' || $state === 'bi' || $state === 'ib' ) {
+ $output .= '</i>';
+ }
+ if ( $state === 'bi' ) {
+ $output .= '</b>';
+ }
+ // There might be lonely ''''', so make sure we have a buffer
+ if ( $state === 'both' && $buffer ) {
+ $output .= '<b><i>' . $buffer . '</i></b>';
+ }
+ return $output;
+ }
+
+ /**
+ * Replace external links (REL)
+ *
+ * Note: this is all very hackish and the order of execution matters a lot.
+ * Make sure to run tests/parser/parserTests.php if you change this code.
+ *
+ * @private
+ *
+ * @param string $text
+ *
+ * @throws MWException
+ * @return string
+ */
+ public function replaceExternalLinks( $text ) {
+ $bits = preg_split( $this->mExtLinkBracketedRegex, $text, -1, PREG_SPLIT_DELIM_CAPTURE );
+ if ( $bits === false ) {
+ throw new MWException( "PCRE needs to be compiled with "
+ . "--enable-unicode-properties in order for MediaWiki to function" );
+ }
+ $s = array_shift( $bits );
+
+ $i = 0;
+ while ( $i < count( $bits ) ) {
+ $url = $bits[$i++];
+ $i++; // protocol
+ $text = $bits[$i++];
+ $trail = $bits[$i++];
+
+ # The characters '<' and '>' (which were escaped by
+ # removeHTMLtags()) should not be included in
+ # URLs, per RFC 2396.
+ $m2 = [];
+ if ( preg_match( '/&(lt|gt);/', $url, $m2, PREG_OFFSET_CAPTURE ) ) {
+ $text = substr( $url, $m2[0][1] ) . ' ' . $text;
+ $url = substr( $url, 0, $m2[0][1] );
+ }
+
+ # If the link text is an image URL, replace it with an <img> tag
+ # This happened by accident in the original parser, but some people used it extensively
+ $img = $this->maybeMakeExternalImage( $text );
+ if ( $img !== false ) {
+ $text = $img;
+ }
+
+ $dtrail = '';
+
+ # Set linktype for CSS - if URL==text, link is essentially free
+ $linktype = ( $text === $url ) ? 'free' : 'text';
+
+ # No link text, e.g. [http://domain.tld/some.link]
+ if ( $text == '' ) {
+ # Autonumber
+ $langObj = $this->getTargetLanguage();
+ $text = '[' . $langObj->formatNum( ++$this->mAutonumber ) . ']';
+ $linktype = 'autonumber';
+ } else {
+ # Have link text, e.g. [http://domain.tld/some.link text]s
+ # Check for trail
+ list( $dtrail, $trail ) = Linker::splitTrail( $trail );
+ }
+
+ $text = $this->getConverterLanguage()->markNoConversion( $text );
+
+ $url = Sanitizer::cleanUrl( $url );
+
+ # Use the encoded URL
+ # This means that users can paste URLs directly into the text
+ # Funny characters like ö aren't valid in URLs anyway
+ # This was changed in August 2004
+ $s .= Linker::makeExternalLink( $url, $text, false, $linktype,
+ $this->getExternalLinkAttribs( $url ), $this->mTitle ) . $dtrail . $trail;
+
+ # Register link in the output object.
+ $this->mOutput->addExternalLink( $url );
+ }
+
+ return $s;
+ }
+
+ /**
+ * Get the rel attribute for a particular external link.
+ *
+ * @since 1.21
+ * @param string|bool $url Optional URL, to extract the domain from for rel =>
+ * nofollow if appropriate
+ * @param Title $title Optional Title, for wgNoFollowNsExceptions lookups
+ * @return string|null Rel attribute for $url
+ */
+ public static function getExternalLinkRel( $url = false, $title = null ) {
+ global $wgNoFollowLinks, $wgNoFollowNsExceptions, $wgNoFollowDomainExceptions;
+ $ns = $title ? $title->getNamespace() : false;
+ if ( $wgNoFollowLinks && !in_array( $ns, $wgNoFollowNsExceptions )
+ && !wfMatchesDomainList( $url, $wgNoFollowDomainExceptions )
+ ) {
+ return 'nofollow';
+ }
+ return null;
+ }
+
+ /**
+ * Get an associative array of additional HTML attributes appropriate for a
+ * particular external link. This currently may include rel => nofollow
+ * (depending on configuration, namespace, and the URL's domain) and/or a
+ * target attribute (depending on configuration).
+ *
+ * @param string $url URL to extract the domain from for rel =>
+ * nofollow if appropriate
+ * @return array Associative array of HTML attributes
+ */
+ public function getExternalLinkAttribs( $url ) {
+ $attribs = [];
+ $rel = self::getExternalLinkRel( $url, $this->mTitle );
+
+ $target = $this->mOptions->getExternalLinkTarget();
+ if ( $target ) {
+ $attribs['target'] = $target;
+ if ( !in_array( $target, [ '_self', '_parent', '_top' ] ) ) {
+ // T133507. New windows can navigate parent cross-origin.
+ // Including noreferrer due to lacking browser
+ // support of noopener. Eventually noreferrer should be removed.
+ if ( $rel !== '' ) {
+ $rel .= ' ';
+ }
+ $rel .= 'noreferrer noopener';
+ }
+ }
+ $attribs['rel'] = $rel;
+ return $attribs;
+ }
+
+ /**
+ * Replace unusual escape codes in a URL with their equivalent characters
+ *
+ * This generally follows the syntax defined in RFC 3986, with special
+ * consideration for HTTP query strings.
+ *
+ * @param string $url
+ * @return string
+ */
+ public static function normalizeLinkUrl( $url ) {
+ # First, make sure unsafe characters are encoded
+ $url = preg_replace_callback( '/[\x00-\x20"<>\[\\\\\]^`{|}\x7F-\xFF]/',
+ function ( $m ) {
+ return rawurlencode( $m[0] );
+ },
+ $url
+ );
+
+ $ret = '';
+ $end = strlen( $url );
+
+ # Fragment part - 'fragment'
+ $start = strpos( $url, '#' );
+ if ( $start !== false && $start < $end ) {
+ $ret = self::normalizeUrlComponent(
+ substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}' ) . $ret;
+ $end = $start;
+ }
+
+ # Query part - 'query' minus &=+;
+ $start = strpos( $url, '?' );
+ if ( $start !== false && $start < $end ) {
+ $ret = self::normalizeUrlComponent(
+ substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}&=+;' ) . $ret;
+ $end = $start;
+ }
+
+ # Scheme and path part - 'pchar'
+ # (we assume no userinfo or encoded colons in the host)
+ $ret = self::normalizeUrlComponent(
+ substr( $url, 0, $end ), '"#%<>[\]^`{|}/?' ) . $ret;
+
+ return $ret;
+ }
+
+ private static function normalizeUrlComponent( $component, $unsafe ) {
+ $callback = function ( $matches ) use ( $unsafe ) {
+ $char = urldecode( $matches[0] );
+ $ord = ord( $char );
+ if ( $ord > 32 && $ord < 127 && strpos( $unsafe, $char ) === false ) {
+ # Unescape it
+ return $char;
+ } else {
+ # Leave it escaped, but use uppercase for a-f
+ return strtoupper( $matches[0] );
+ }
+ };
+ return preg_replace_callback( '/%[0-9A-Fa-f]{2}/', $callback, $component );
+ }
+
+ /**
+ * make an image if it's allowed, either through the global
+ * option, through the exception, or through the on-wiki whitelist
+ *
+ * @param string $url
+ *
+ * @return string
+ */
+ private function maybeMakeExternalImage( $url ) {
+ $imagesfrom = $this->mOptions->getAllowExternalImagesFrom();
+ $imagesexception = !empty( $imagesfrom );
+ $text = false;
+ # $imagesfrom could be either a single string or an array of strings, parse out the latter
+ if ( $imagesexception && is_array( $imagesfrom ) ) {
+ $imagematch = false;
+ foreach ( $imagesfrom as $match ) {
+ if ( strpos( $url, $match ) === 0 ) {
+ $imagematch = true;
+ break;
+ }
+ }
+ } elseif ( $imagesexception ) {
+ $imagematch = ( strpos( $url, $imagesfrom ) === 0 );
+ } else {
+ $imagematch = false;
+ }
+
+ if ( $this->mOptions->getAllowExternalImages()
+ || ( $imagesexception && $imagematch )
+ ) {
+ if ( preg_match( self::EXT_IMAGE_REGEX, $url ) ) {
+ # Image found
+ $text = Linker::makeExternalImage( $url );
+ }
+ }
+ if ( !$text && $this->mOptions->getEnableImageWhitelist()
+ && preg_match( self::EXT_IMAGE_REGEX, $url )
+ ) {
+ $whitelist = explode(
+ "\n",
+ wfMessage( 'external_image_whitelist' )->inContentLanguage()->text()
+ );
+
+ foreach ( $whitelist as $entry ) {
+ # Sanitize the regex fragment, make it case-insensitive, ignore blank entries/comments
+ if ( strpos( $entry, '#' ) === 0 || $entry === '' ) {
+ continue;
+ }
+ if ( preg_match( '/' . str_replace( '/', '\\/', $entry ) . '/i', $url ) ) {
+ # Image matches a whitelist entry
+ $text = Linker::makeExternalImage( $url );
+ break;
+ }
+ }
+ }
+ return $text;
+ }
+
+ /**
+ * Process [[ ]] wikilinks
+ *
+ * @param string $s
+ *
+ * @return string Processed text
+ *
+ * @private
+ */
+ public function replaceInternalLinks( $s ) {
+ $this->mLinkHolders->merge( $this->replaceInternalLinks2( $s ) );
+ return $s;
+ }
+
+ /**
+ * Process [[ ]] wikilinks (RIL)
+ * @param string &$s
+ * @throws MWException
+ * @return LinkHolderArray
+ *
+ * @private
+ */
+ public function replaceInternalLinks2( &$s ) {
+ global $wgExtraInterlanguageLinkPrefixes;
+
+ static $tc = false, $e1, $e1_img;
+ # the % is needed to support urlencoded titles as well
+ if ( !$tc ) {
+ $tc = Title::legalChars() . '#%';
+ # Match a link having the form [[namespace:link|alternate]]trail
+ $e1 = "/^([{$tc}]+)(?:\\|(.+?))?]](.*)\$/sD";
+ # Match cases where there is no "]]", which might still be images
+ $e1_img = "/^([{$tc}]+)\\|(.*)\$/sD";
+ }
+
+ $holders = new LinkHolderArray( $this );
+
+ # split the entire text string on occurrences of [[
+ $a = StringUtils::explode( '[[', ' ' . $s );
+ # get the first element (all text up to first [[), and remove the space we added
+ $s = $a->current();
+ $a->next();
+ $line = $a->current(); # Workaround for broken ArrayIterator::next() that returns "void"
+ $s = substr( $s, 1 );
+
+ $useLinkPrefixExtension = $this->getTargetLanguage()->linkPrefixExtension();
+ $e2 = null;
+ if ( $useLinkPrefixExtension ) {
+ # Match the end of a line for a word that's not followed by whitespace,
+ # e.g. in the case of 'The Arab al[[Razi]]', 'al' will be matched
+ global $wgContLang;
+ $charset = $wgContLang->linkPrefixCharset();
+ $e2 = "/^((?>.*[^$charset]|))(.+)$/sDu";
+ }
+
+ if ( is_null( $this->mTitle ) ) {
+ throw new MWException( __METHOD__ . ": \$this->mTitle is null\n" );
+ }
+ $nottalk = !$this->mTitle->isTalkPage();
+
+ if ( $useLinkPrefixExtension ) {
+ $m = [];
+ if ( preg_match( $e2, $s, $m ) ) {
+ $first_prefix = $m[2];
+ } else {
+ $first_prefix = false;
+ }
+ } else {
+ $prefix = '';
+ }
+
+ $useSubpages = $this->areSubpagesAllowed();
+
+ // @codingStandardsIgnoreStart Squiz.WhiteSpace.SemicolonSpacing.Incorrect
+ # Loop for each link
+ for ( ; $line !== false && $line !== null; $a->next(), $line = $a->current() ) {
+ // @codingStandardsIgnoreEnd
+
+ # Check for excessive memory usage
+ if ( $holders->isBig() ) {
+ # Too big
+ # Do the existence check, replace the link holders and clear the array
+ $holders->replace( $s );
+ $holders->clear();
+ }
+
+ if ( $useLinkPrefixExtension ) {
+ if ( preg_match( $e2, $s, $m ) ) {
+ $prefix = $m[2];
+ $s = $m[1];
+ } else {
+ $prefix = '';
+ }
+ # first link
+ if ( $first_prefix ) {
+ $prefix = $first_prefix;
+ $first_prefix = false;
+ }
+ }
+
+ $might_be_img = false;
+
+ if ( preg_match( $e1, $line, $m ) ) { # page with normal text or alt
+ $text = $m[2];
+ # If we get a ] at the beginning of $m[3] that means we have a link that's something like:
+ # [[Image:Foo.jpg|[http://example.com desc]]] <- having three ] in a row fucks up,
+ # the real problem is with the $e1 regex
+ # See T1500.
+ # Still some problems for cases where the ] is meant to be outside punctuation,
+ # and no image is in sight. See T4095.
+ if ( $text !== ''
+ && substr( $m[3], 0, 1 ) === ']'
+ && strpos( $text, '[' ) !== false
+ ) {
+ $text .= ']'; # so that replaceExternalLinks($text) works later
+ $m[3] = substr( $m[3], 1 );
+ }
+ # fix up urlencoded title texts
+ if ( strpos( $m[1], '%' ) !== false ) {
+ # Should anchors '#' also be rejected?
+ $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
+ }
+ $trail = $m[3];
+ } elseif ( preg_match( $e1_img, $line, $m ) ) {
+ # Invalid, but might be an image with a link in its caption
+ $might_be_img = true;
+ $text = $m[2];
+ if ( strpos( $m[1], '%' ) !== false ) {
+ $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
+ }
+ $trail = "";
+ } else { # Invalid form; output directly
+ $s .= $prefix . '[[' . $line;
+ continue;
+ }
+
+ $origLink = ltrim( $m[1], ' ' );
+
+ # Don't allow internal links to pages containing
+ # PROTO: where PROTO is a valid URL protocol; these
+ # should be external links.
+ if ( preg_match( '/^(?i:' . $this->mUrlProtocols . ')/', $origLink ) ) {
+ $s .= $prefix . '[[' . $line;
+ continue;
+ }
+
+ # Make subpage if necessary
+ if ( $useSubpages ) {
+ $link = $this->maybeDoSubpageLink( $origLink, $text );
+ } else {
+ $link = $origLink;
+ }
+
+ $unstrip = $this->mStripState->unstripNoWiki( $link );
+ $nt = is_string( $unstrip ) ? Title::newFromText( $unstrip ) : null;
+ if ( $nt === null ) {
+ $s .= $prefix . '[[' . $line;
+ continue;
+ }
+
+ $ns = $nt->getNamespace();
+ $iw = $nt->getInterwiki();
+
+ $noforce = ( substr( $origLink, 0, 1 ) !== ':' );
+
+ if ( $might_be_img ) { # if this is actually an invalid link
+ if ( $ns == NS_FILE && $noforce ) { # but might be an image
+ $found = false;
+ while ( true ) {
+ # look at the next 'line' to see if we can close it there
+ $a->next();
+ $next_line = $a->current();
+ if ( $next_line === false || $next_line === null ) {
+ break;
+ }
+ $m = explode( ']]', $next_line, 3 );
+ if ( count( $m ) == 3 ) {
+ # the first ]] closes the inner link, the second the image
+ $found = true;
+ $text .= "[[{$m[0]}]]{$m[1]}";
+ $trail = $m[2];
+ break;
+ } elseif ( count( $m ) == 2 ) {
+ # if there's exactly one ]] that's fine, we'll keep looking
+ $text .= "[[{$m[0]}]]{$m[1]}";
+ } else {
+ # if $next_line is invalid too, we need look no further
+ $text .= '[[' . $next_line;
+ break;
+ }
+ }
+ if ( !$found ) {
+ # we couldn't find the end of this imageLink, so output it raw
+ # but don't ignore what might be perfectly normal links in the text we've examined
+ $holders->merge( $this->replaceInternalLinks2( $text ) );
+ $s .= "{$prefix}[[$link|$text";
+ # note: no $trail, because without an end, there *is* no trail
+ continue;
+ }
+ } else { # it's not an image, so output it raw
+ $s .= "{$prefix}[[$link|$text";
+ # note: no $trail, because without an end, there *is* no trail
+ continue;
+ }
+ }
+
+ $wasblank = ( $text == '' );
+ if ( $wasblank ) {
+ $text = $link;
+ if ( !$noforce ) {
+ # Strip off leading ':'
+ $text = substr( $text, 1 );
+ }
+ } else {
+ # T6598 madness. Handle the quotes only if they come from the alternate part
+ # [[Lista d''e paise d''o munno]] -> <a href="...">Lista d''e paise d''o munno</a>
+ # [[Criticism of Harry Potter|Criticism of ''Harry Potter'']]
+ # -> <a href="Criticism of Harry Potter">Criticism of <i>Harry Potter</i></a>
+ $text = $this->doQuotes( $text );
+ }
+
+ # Link not escaped by : , create the various objects
+ if ( $noforce && !$nt->wasLocalInterwiki() ) {
+ # Interwikis
+ if (
+ $iw && $this->mOptions->getInterwikiMagic() && $nottalk && (
+ Language::fetchLanguageName( $iw, null, 'mw' ) ||
+ in_array( $iw, $wgExtraInterlanguageLinkPrefixes )
+ )
+ ) {
+ # T26502: filter duplicates
+ if ( !isset( $this->mLangLinkLanguages[$iw] ) ) {
+ $this->mLangLinkLanguages[$iw] = true;
+ $this->mOutput->addLanguageLink( $nt->getFullText() );
+ }
+
+ $s = rtrim( $s . $prefix );
+ $s .= trim( $trail, "\n" ) == '' ? '' : $prefix . $trail;
+ continue;
+ }
+
+ if ( $ns == NS_FILE ) {
+ if ( !wfIsBadImage( $nt->getDBkey(), $this->mTitle ) ) {
+ if ( $wasblank ) {
+ # if no parameters were passed, $text
+ # becomes something like "File:Foo.png",
+ # which we don't want to pass on to the
+ # image generator
+ $text = '';
+ } else {
+ # recursively parse links inside the image caption
+ # actually, this will parse them in any other parameters, too,
+ # but it might be hard to fix that, and it doesn't matter ATM
+ $text = $this->replaceExternalLinks( $text );
+ $holders->merge( $this->replaceInternalLinks2( $text ) );
+ }
+ # cloak any absolute URLs inside the image markup, so replaceExternalLinks() won't touch them
+ $s .= $prefix . $this->armorLinks(
+ $this->makeImage( $nt, $text, $holders ) ) . $trail;
+ continue;
+ }
+ } elseif ( $ns == NS_CATEGORY ) {
+ $s = rtrim( $s . "\n" ); # T2087
+
+ if ( $wasblank ) {
+ $sortkey = $this->getDefaultSort();
+ } else {
+ $sortkey = $text;
+ }
+ $sortkey = Sanitizer::decodeCharReferences( $sortkey );
+ $sortkey = str_replace( "\n", '', $sortkey );
+ $sortkey = $this->getConverterLanguage()->convertCategoryKey( $sortkey );
+ $this->mOutput->addCategory( $nt->getDBkey(), $sortkey );
+
+ /**
+ * Strip the whitespace Category links produce, see T2087
+ */
+ $s .= trim( $prefix . $trail, "\n" ) == '' ? '' : $prefix . $trail;
+
+ continue;
+ }
+ }
+
+ # Self-link checking. For some languages, variants of the title are checked in
+ # LinkHolderArray::doVariants() to allow batching the existence checks necessary
+ # for linking to a different variant.
+ if ( $ns != NS_SPECIAL && $nt->equals( $this->mTitle ) && !$nt->hasFragment() ) {
+ $s .= $prefix . Linker::makeSelfLinkObj( $nt, $text, '', $trail );
+ continue;
+ }
+
+ # NS_MEDIA is a pseudo-namespace for linking directly to a file
+ # @todo FIXME: Should do batch file existence checks, see comment below
+ if ( $ns == NS_MEDIA ) {
+ # Give extensions a chance to select the file revision for us
+ $options = [];
+ $descQuery = false;
+ Hooks::run( 'BeforeParserFetchFileAndTitle',
+ [ $this, $nt, &$options, &$descQuery ] );
+ # Fetch and register the file (file title may be different via hooks)
+ list( $file, $nt ) = $this->fetchFileAndTitle( $nt, $options );
+ # Cloak with NOPARSE to avoid replacement in replaceExternalLinks
+ $s .= $prefix . $this->armorLinks(
+ Linker::makeMediaLinkFile( $nt, $file, $text ) ) . $trail;
+ continue;
+ }
+
+ # Some titles, such as valid special pages or files in foreign repos, should
+ # be shown as bluelinks even though they're not included in the page table
+ # @todo FIXME: isAlwaysKnown() can be expensive for file links; we should really do
+ # batch file existence checks for NS_FILE and NS_MEDIA
+ if ( $iw == '' && $nt->isAlwaysKnown() ) {
+ $this->mOutput->addLink( $nt );
+ $s .= $this->makeKnownLinkHolder( $nt, $text, $trail, $prefix );
+ } else {
+ # Links will be added to the output link list after checking
+ $s .= $holders->makeHolder( $nt, $text, [], $trail, $prefix );
+ }
+ }
+ return $holders;
+ }
+
+ /**
+ * Render a forced-blue link inline; protect against double expansion of
+ * URLs if we're in a mode that prepends full URL prefixes to internal links.
+ * Since this little disaster has to split off the trail text to avoid
+ * breaking URLs in the following text without breaking trails on the
+ * wiki links, it's been made into a horrible function.
+ *
+ * @param Title $nt
+ * @param string $text
+ * @param string $trail
+ * @param string $prefix
+ * @return string HTML-wikitext mix oh yuck
+ */
+ protected function makeKnownLinkHolder( $nt, $text = '', $trail = '', $prefix = '' ) {
+ list( $inside, $trail ) = Linker::splitTrail( $trail );
+
+ if ( $text == '' ) {
+ $text = htmlspecialchars( $nt->getPrefixedText() );
+ }
+
+ $link = $this->getLinkRenderer()->makeKnownLink(
+ $nt, new HtmlArmor( "$prefix$text$inside" )
+ );
+
+ return $this->armorLinks( $link ) . $trail;
+ }
+
+ /**
+ * Insert a NOPARSE hacky thing into any inline links in a chunk that's
+ * going to go through further parsing steps before inline URL expansion.
+ *
+ * Not needed quite as much as it used to be since free links are a bit
+ * more sensible these days. But bracketed links are still an issue.
+ *
+ * @param string $text More-or-less HTML
+ * @return string Less-or-more HTML with NOPARSE bits
+ */
+ public function armorLinks( $text ) {
+ return preg_replace( '/\b((?i)' . $this->mUrlProtocols . ')/',
+ self::MARKER_PREFIX . "NOPARSE$1", $text );
+ }
+
+ /**
+ * Return true if subpage links should be expanded on this page.
+ * @return bool
+ */
+ public function areSubpagesAllowed() {
+ # Some namespaces don't allow subpages
+ return MWNamespace::hasSubpages( $this->mTitle->getNamespace() );
+ }
+
+ /**
+ * Handle link to subpage if necessary
+ *
+ * @param string $target The source of the link
+ * @param string &$text The link text, modified as necessary
+ * @return string The full name of the link
+ * @private
+ */
+ public function maybeDoSubpageLink( $target, &$text ) {
+ return Linker::normalizeSubpageLink( $this->mTitle, $target, $text );
+ }
+
+ /**
+ * Make lists from lines starting with ':', '*', '#', etc. (DBL)
+ *
+ * @param string $text
+ * @param bool $linestart Whether or not this is at the start of a line.
+ * @private
+ * @return string The lists rendered as HTML
+ */
+ public function doBlockLevels( $text, $linestart ) {
+ return BlockLevelPass::doBlockLevels( $text, $linestart );
+ }
+
+ /**
+ * Return value of a magic variable (like PAGENAME)
+ *
+ * @private
+ *
+ * @param string $index Magic variable identifier as mapped in MagicWord::$mVariableIDs
+ * @param bool|PPFrame $frame
+ *
+ * @throws MWException
+ * @return string
+ */
+ public function getVariableValue( $index, $frame = false ) {
+ global $wgContLang, $wgSitename, $wgServer, $wgServerName;
+ global $wgArticlePath, $wgScriptPath, $wgStylePath;
+
+ if ( is_null( $this->mTitle ) ) {
+ // If no title set, bad things are going to happen
+ // later. Title should always be set since this
+ // should only be called in the middle of a parse
+ // operation (but the unit-tests do funky stuff)
+ throw new MWException( __METHOD__ . ' Should only be '
+ . ' called while parsing (no title set)' );
+ }
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+
+ /**
+ * Some of these require message or data lookups and can be
+ * expensive to check many times.
+ */
+ if ( Hooks::run( 'ParserGetVariableValueVarCache', [ &$parser, &$this->mVarCache ] ) ) {
+ if ( isset( $this->mVarCache[$index] ) ) {
+ return $this->mVarCache[$index];
+ }
+ }
+
+ $ts = wfTimestamp( TS_UNIX, $this->mOptions->getTimestamp() );
+ Hooks::run( 'ParserGetVariableValueTs', [ &$parser, &$ts ] );
+
+ $pageLang = $this->getFunctionLang();
+
+ switch ( $index ) {
+ case '!':
+ $value = '|';
+ break;
+ case 'currentmonth':
+ $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'm' ) );
+ break;
+ case 'currentmonth1':
+ $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'n' ) );
+ break;
+ case 'currentmonthname':
+ $value = $pageLang->getMonthName( MWTimestamp::getInstance( $ts )->format( 'n' ) );
+ break;
+ case 'currentmonthnamegen':
+ $value = $pageLang->getMonthNameGen( MWTimestamp::getInstance( $ts )->format( 'n' ) );
+ break;
+ case 'currentmonthabbrev':
+ $value = $pageLang->getMonthAbbreviation( MWTimestamp::getInstance( $ts )->format( 'n' ) );
+ break;
+ case 'currentday':
+ $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'j' ) );
+ break;
+ case 'currentday2':
+ $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'd' ) );
+ break;
+ case 'localmonth':
+ $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'm' ) );
+ break;
+ case 'localmonth1':
+ $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
+ break;
+ case 'localmonthname':
+ $value = $pageLang->getMonthName( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
+ break;
+ case 'localmonthnamegen':
+ $value = $pageLang->getMonthNameGen( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
+ break;
+ case 'localmonthabbrev':
+ $value = $pageLang->getMonthAbbreviation( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
+ break;
+ case 'localday':
+ $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'j' ) );
+ break;
+ case 'localday2':
+ $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'd' ) );
+ break;
+ case 'pagename':
+ $value = wfEscapeWikiText( $this->mTitle->getText() );
+ break;
+ case 'pagenamee':
+ $value = wfEscapeWikiText( $this->mTitle->getPartialURL() );
+ break;
+ case 'fullpagename':
+ $value = wfEscapeWikiText( $this->mTitle->getPrefixedText() );
+ break;
+ case 'fullpagenamee':
+ $value = wfEscapeWikiText( $this->mTitle->getPrefixedURL() );
+ break;
+ case 'subpagename':
+ $value = wfEscapeWikiText( $this->mTitle->getSubpageText() );
+ break;
+ case 'subpagenamee':
+ $value = wfEscapeWikiText( $this->mTitle->getSubpageUrlForm() );
+ break;
+ case 'rootpagename':
+ $value = wfEscapeWikiText( $this->mTitle->getRootText() );
+ break;
+ case 'rootpagenamee':
+ $value = wfEscapeWikiText( wfUrlencode( str_replace(
+ ' ',
+ '_',
+ $this->mTitle->getRootText()
+ ) ) );
+ break;
+ case 'basepagename':
+ $value = wfEscapeWikiText( $this->mTitle->getBaseText() );
+ break;
+ case 'basepagenamee':
+ $value = wfEscapeWikiText( wfUrlencode( str_replace(
+ ' ',
+ '_',
+ $this->mTitle->getBaseText()
+ ) ) );
+ break;
+ case 'talkpagename':
+ if ( $this->mTitle->canHaveTalkPage() ) {
+ $talkPage = $this->mTitle->getTalkPage();
+ $value = wfEscapeWikiText( $talkPage->getPrefixedText() );
+ } else {
+ $value = '';
+ }
+ break;
+ case 'talkpagenamee':
+ if ( $this->mTitle->canHaveTalkPage() ) {
+ $talkPage = $this->mTitle->getTalkPage();
+ $value = wfEscapeWikiText( $talkPage->getPrefixedURL() );
+ } else {
+ $value = '';
+ }
+ break;
+ case 'subjectpagename':
+ $subjPage = $this->mTitle->getSubjectPage();
+ $value = wfEscapeWikiText( $subjPage->getPrefixedText() );
+ break;
+ case 'subjectpagenamee':
+ $subjPage = $this->mTitle->getSubjectPage();
+ $value = wfEscapeWikiText( $subjPage->getPrefixedURL() );
+ break;
+ case 'pageid': // requested in T25427
+ $pageid = $this->getTitle()->getArticleID();
+ if ( $pageid == 0 ) {
+ # 0 means the page doesn't exist in the database,
+ # which means the user is previewing a new page.
+ # The vary-revision flag must be set, because the magic word
+ # will have a different value once the page is saved.
+ $this->mOutput->setFlag( 'vary-revision' );
+ wfDebug( __METHOD__ . ": {{PAGEID}} used in a new page, setting vary-revision...\n" );
+ }
+ $value = $pageid ? $pageid : null;
+ break;
+ case 'revisionid':
+ # Let the edit saving system know we should parse the page
+ # *after* a revision ID has been assigned.
+ $this->mOutput->setFlag( 'vary-revision-id' );
+ wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id...\n" );
+ $value = $this->mRevisionId;
+ if ( !$value && $this->mOptions->getSpeculativeRevIdCallback() ) {
+ $value = call_user_func( $this->mOptions->getSpeculativeRevIdCallback() );
+ $this->mOutput->setSpeculativeRevIdUsed( $value );
+ }
+ break;
+ case 'revisionday':
+ # Let the edit saving system know we should parse the page
+ # *after* a revision ID has been assigned. This is for null edits.
+ $this->mOutput->setFlag( 'vary-revision' );
+ wfDebug( __METHOD__ . ": {{REVISIONDAY}} used, setting vary-revision...\n" );
+ $value = intval( substr( $this->getRevisionTimestamp(), 6, 2 ) );
+ break;
+ case 'revisionday2':
+ # Let the edit saving system know we should parse the page
+ # *after* a revision ID has been assigned. This is for null edits.
+ $this->mOutput->setFlag( 'vary-revision' );
+ wfDebug( __METHOD__ . ": {{REVISIONDAY2}} used, setting vary-revision...\n" );
+ $value = substr( $this->getRevisionTimestamp(), 6, 2 );
+ break;
+ case 'revisionmonth':
+ # Let the edit saving system know we should parse the page
+ # *after* a revision ID has been assigned. This is for null edits.
+ $this->mOutput->setFlag( 'vary-revision' );
+ wfDebug( __METHOD__ . ": {{REVISIONMONTH}} used, setting vary-revision...\n" );
+ $value = substr( $this->getRevisionTimestamp(), 4, 2 );
+ break;
+ case 'revisionmonth1':
+ # Let the edit saving system know we should parse the page
+ # *after* a revision ID has been assigned. This is for null edits.
+ $this->mOutput->setFlag( 'vary-revision' );
+ wfDebug( __METHOD__ . ": {{REVISIONMONTH1}} used, setting vary-revision...\n" );
+ $value = intval( substr( $this->getRevisionTimestamp(), 4, 2 ) );
+ break;
+ case 'revisionyear':
+ # Let the edit saving system know we should parse the page
+ # *after* a revision ID has been assigned. This is for null edits.
+ $this->mOutput->setFlag( 'vary-revision' );
+ wfDebug( __METHOD__ . ": {{REVISIONYEAR}} used, setting vary-revision...\n" );
+ $value = substr( $this->getRevisionTimestamp(), 0, 4 );
+ break;
+ case 'revisiontimestamp':
+ # Let the edit saving system know we should parse the page
+ # *after* a revision ID has been assigned. This is for null edits.
+ $this->mOutput->setFlag( 'vary-revision' );
+ wfDebug( __METHOD__ . ": {{REVISIONTIMESTAMP}} used, setting vary-revision...\n" );
+ $value = $this->getRevisionTimestamp();
+ break;
+ case 'revisionuser':
+ # Let the edit saving system know we should parse the page
+ # *after* a revision ID has been assigned for null edits.
+ $this->mOutput->setFlag( 'vary-user' );
+ wfDebug( __METHOD__ . ": {{REVISIONUSER}} used, setting vary-user...\n" );
+ $value = $this->getRevisionUser();
+ break;
+ case 'revisionsize':
+ $value = $this->getRevisionSize();
+ break;
+ case 'namespace':
+ $value = str_replace( '_', ' ', $wgContLang->getNsText( $this->mTitle->getNamespace() ) );
+ break;
+ case 'namespacee':
+ $value = wfUrlencode( $wgContLang->getNsText( $this->mTitle->getNamespace() ) );
+ break;
+ case 'namespacenumber':
+ $value = $this->mTitle->getNamespace();
+ break;
+ case 'talkspace':
+ $value = $this->mTitle->canHaveTalkPage()
+ ? str_replace( '_', ' ', $this->mTitle->getTalkNsText() )
+ : '';
+ break;
+ case 'talkspacee':
+ $value = $this->mTitle->canHaveTalkPage() ? wfUrlencode( $this->mTitle->getTalkNsText() ) : '';
+ break;
+ case 'subjectspace':
+ $value = str_replace( '_', ' ', $this->mTitle->getSubjectNsText() );
+ break;
+ case 'subjectspacee':
+ $value = ( wfUrlencode( $this->mTitle->getSubjectNsText() ) );
+ break;
+ case 'currentdayname':
+ $value = $pageLang->getWeekdayName( (int)MWTimestamp::getInstance( $ts )->format( 'w' ) + 1 );
+ break;
+ case 'currentyear':
+ $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'Y' ), true );
+ break;
+ case 'currenttime':
+ $value = $pageLang->time( wfTimestamp( TS_MW, $ts ), false, false );
+ break;
+ case 'currenthour':
+ $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'H' ), true );
+ break;
+ case 'currentweek':
+ # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
+ # int to remove the padding
+ $value = $pageLang->formatNum( (int)MWTimestamp::getInstance( $ts )->format( 'W' ) );
+ break;
+ case 'currentdow':
+ $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'w' ) );
+ break;
+ case 'localdayname':
+ $value = $pageLang->getWeekdayName(
+ (int)MWTimestamp::getLocalInstance( $ts )->format( 'w' ) + 1
+ );
+ break;
+ case 'localyear':
+ $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'Y' ), true );
+ break;
+ case 'localtime':
+ $value = $pageLang->time(
+ MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' ),
+ false,
+ false
+ );
+ break;
+ case 'localhour':
+ $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'H' ), true );
+ break;
+ case 'localweek':
+ # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
+ # int to remove the padding
+ $value = $pageLang->formatNum( (int)MWTimestamp::getLocalInstance( $ts )->format( 'W' ) );
+ break;
+ case 'localdow':
+ $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'w' ) );
+ break;
+ case 'numberofarticles':
+ $value = $pageLang->formatNum( SiteStats::articles() );
+ break;
+ case 'numberoffiles':
+ $value = $pageLang->formatNum( SiteStats::images() );
+ break;
+ case 'numberofusers':
+ $value = $pageLang->formatNum( SiteStats::users() );
+ break;
+ case 'numberofactiveusers':
+ $value = $pageLang->formatNum( SiteStats::activeUsers() );
+ break;
+ case 'numberofpages':
+ $value = $pageLang->formatNum( SiteStats::pages() );
+ break;
+ case 'numberofadmins':
+ $value = $pageLang->formatNum( SiteStats::numberingroup( 'sysop' ) );
+ break;
+ case 'numberofedits':
+ $value = $pageLang->formatNum( SiteStats::edits() );
+ break;
+ case 'currenttimestamp':
+ $value = wfTimestamp( TS_MW, $ts );
+ break;
+ case 'localtimestamp':
+ $value = MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' );
+ break;
+ case 'currentversion':
+ $value = SpecialVersion::getVersion();
+ break;
+ case 'articlepath':
+ return $wgArticlePath;
+ case 'sitename':
+ return $wgSitename;
+ case 'server':
+ return $wgServer;
+ case 'servername':
+ return $wgServerName;
+ case 'scriptpath':
+ return $wgScriptPath;
+ case 'stylepath':
+ return $wgStylePath;
+ case 'directionmark':
+ return $pageLang->getDirMark();
+ case 'contentlanguage':
+ global $wgLanguageCode;
+ return $wgLanguageCode;
+ case 'pagelanguage':
+ $value = $pageLang->getCode();
+ break;
+ case 'cascadingsources':
+ $value = CoreParserFunctions::cascadingsources( $this );
+ break;
+ default:
+ $ret = null;
+ Hooks::run(
+ 'ParserGetVariableValueSwitch',
+ [ &$parser, &$this->mVarCache, &$index, &$ret, &$frame ]
+ );
+
+ return $ret;
+ }
+
+ if ( $index ) {
+ $this->mVarCache[$index] = $value;
+ }
+
+ return $value;
+ }
+
+ /**
+ * initialise the magic variables (like CURRENTMONTHNAME) and substitution modifiers
+ *
+ * @private
+ */
+ public function initialiseVariables() {
+ $variableIDs = MagicWord::getVariableIDs();
+ $substIDs = MagicWord::getSubstIDs();
+
+ $this->mVariables = new MagicWordArray( $variableIDs );
+ $this->mSubstWords = new MagicWordArray( $substIDs );
+ }
+
+ /**
+ * Preprocess some wikitext and return the document tree.
+ * This is the ghost of replace_variables().
+ *
+ * @param string $text The text to parse
+ * @param int $flags Bitwise combination of:
+ * - self::PTD_FOR_INCLUSION: Handle "<noinclude>" and "<includeonly>" as if the text is being
+ * included. Default is to assume a direct page view.
+ *
+ * The generated DOM tree must depend only on the input text and the flags.
+ * The DOM tree must be the same in OT_HTML and OT_WIKI mode, to avoid a regression of T6899.
+ *
+ * Any flag added to the $flags parameter here, or any other parameter liable to cause a
+ * change in the DOM tree for a given text, must be passed through the section identifier
+ * in the section edit link and thus back to extractSections().
+ *
+ * The output of this function is currently only cached in process memory, but a persistent
+ * cache may be implemented at a later date which takes further advantage of these strict
+ * dependency requirements.
+ *
+ * @return PPNode
+ */
+ public function preprocessToDom( $text, $flags = 0 ) {
+ $dom = $this->getPreprocessor()->preprocessToObj( $text, $flags );
+ return $dom;
+ }
+
+ /**
+ * Return a three-element array: leading whitespace, string contents, trailing whitespace
+ *
+ * @param string $s
+ *
+ * @return array
+ */
+ public static function splitWhitespace( $s ) {
+ $ltrimmed = ltrim( $s );
+ $w1 = substr( $s, 0, strlen( $s ) - strlen( $ltrimmed ) );
+ $trimmed = rtrim( $ltrimmed );
+ $diff = strlen( $ltrimmed ) - strlen( $trimmed );
+ if ( $diff > 0 ) {
+ $w2 = substr( $ltrimmed, -$diff );
+ } else {
+ $w2 = '';
+ }
+ return [ $w1, $trimmed, $w2 ];
+ }
+
+ /**
+ * Replace magic variables, templates, and template arguments
+ * with the appropriate text. Templates are substituted recursively,
+ * taking care to avoid infinite loops.
+ *
+ * Note that the substitution depends on value of $mOutputType:
+ * self::OT_WIKI: only {{subst:}} templates
+ * self::OT_PREPROCESS: templates but not extension tags
+ * self::OT_HTML: all templates and extension tags
+ *
+ * @param string $text The text to transform
+ * @param bool|PPFrame $frame Object describing the arguments passed to the
+ * template. Arguments may also be provided as an associative array, as
+ * was the usual case before MW1.12. Providing arguments this way may be
+ * useful for extensions wishing to perform variable replacement
+ * explicitly.
+ * @param bool $argsOnly Only do argument (triple-brace) expansion, not
+ * double-brace expansion.
+ * @return string
+ */
+ public function replaceVariables( $text, $frame = false, $argsOnly = false ) {
+ # Is there any text? Also, Prevent too big inclusions!
+ $textSize = strlen( $text );
+ if ( $textSize < 1 || $textSize > $this->mOptions->getMaxIncludeSize() ) {
+ return $text;
+ }
+
+ if ( $frame === false ) {
+ $frame = $this->getPreprocessor()->newFrame();
+ } elseif ( !( $frame instanceof PPFrame ) ) {
+ wfDebug( __METHOD__ . " called using plain parameters instead of "
+ . "a PPFrame instance. Creating custom frame.\n" );
+ $frame = $this->getPreprocessor()->newCustomFrame( $frame );
+ }
+
+ $dom = $this->preprocessToDom( $text );
+ $flags = $argsOnly ? PPFrame::NO_TEMPLATES : 0;
+ $text = $frame->expand( $dom, $flags );
+
+ return $text;
+ }
+
+ /**
+ * Clean up argument array - refactored in 1.9 so parserfunctions can use it, too.
+ *
+ * @param array $args
+ *
+ * @return array
+ */
+ public static function createAssocArgs( $args ) {
+ $assocArgs = [];
+ $index = 1;
+ foreach ( $args as $arg ) {
+ $eqpos = strpos( $arg, '=' );
+ if ( $eqpos === false ) {
+ $assocArgs[$index++] = $arg;
+ } else {
+ $name = trim( substr( $arg, 0, $eqpos ) );
+ $value = trim( substr( $arg, $eqpos + 1 ) );
+ if ( $value === false ) {
+ $value = '';
+ }
+ if ( $name !== false ) {
+ $assocArgs[$name] = $value;
+ }
+ }
+ }
+
+ return $assocArgs;
+ }
+
+ /**
+ * Warn the user when a parser limitation is reached
+ * Will warn at most once the user per limitation type
+ *
+ * The results are shown during preview and run through the Parser (See EditPage.php)
+ *
+ * @param string $limitationType Should be one of:
+ * 'expensive-parserfunction' (corresponding messages:
+ * 'expensive-parserfunction-warning',
+ * 'expensive-parserfunction-category')
+ * 'post-expand-template-argument' (corresponding messages:
+ * 'post-expand-template-argument-warning',
+ * 'post-expand-template-argument-category')
+ * 'post-expand-template-inclusion' (corresponding messages:
+ * 'post-expand-template-inclusion-warning',
+ * 'post-expand-template-inclusion-category')
+ * 'node-count-exceeded' (corresponding messages:
+ * 'node-count-exceeded-warning',
+ * 'node-count-exceeded-category')
+ * 'expansion-depth-exceeded' (corresponding messages:
+ * 'expansion-depth-exceeded-warning',
+ * 'expansion-depth-exceeded-category')
+ * @param string|int|null $current Current value
+ * @param string|int|null $max Maximum allowed, when an explicit limit has been
+ * exceeded, provide the values (optional)
+ */
+ public function limitationWarn( $limitationType, $current = '', $max = '' ) {
+ # does no harm if $current and $max are present but are unnecessary for the message
+ # Not doing ->inLanguage( $this->mOptions->getUserLangObj() ), since this is shown
+ # only during preview, and that would split the parser cache unnecessarily.
+ $warning = wfMessage( "$limitationType-warning" )->numParams( $current, $max )
+ ->text();
+ $this->mOutput->addWarning( $warning );
+ $this->addTrackingCategory( "$limitationType-category" );
+ }
+
+ /**
+ * Return the text of a template, after recursively
+ * replacing any variables or templates within the template.
+ *
+ * @param array $piece The parts of the template
+ * $piece['title']: the title, i.e. the part before the |
+ * $piece['parts']: the parameter array
+ * $piece['lineStart']: whether the brace was at the start of a line
+ * @param PPFrame $frame The current frame, contains template arguments
+ * @throws Exception
+ * @return string The text of the template
+ */
+ public function braceSubstitution( $piece, $frame ) {
+ // Flags
+
+ // $text has been filled
+ $found = false;
+ // wiki markup in $text should be escaped
+ $nowiki = false;
+ // $text is HTML, armour it against wikitext transformation
+ $isHTML = false;
+ // Force interwiki transclusion to be done in raw mode not rendered
+ $forceRawInterwiki = false;
+ // $text is a DOM node needing expansion in a child frame
+ $isChildObj = false;
+ // $text is a DOM node needing expansion in the current frame
+ $isLocalObj = false;
+
+ # Title object, where $text came from
+ $title = false;
+
+ # $part1 is the bit before the first |, and must contain only title characters.
+ # Various prefixes will be stripped from it later.
+ $titleWithSpaces = $frame->expand( $piece['title'] );
+ $part1 = trim( $titleWithSpaces );
+ $titleText = false;
+
+ # Original title text preserved for various purposes
+ $originalTitle = $part1;
+
+ # $args is a list of argument nodes, starting from index 0, not including $part1
+ # @todo FIXME: If piece['parts'] is null then the call to getLength()
+ # below won't work b/c this $args isn't an object
+ $args = ( null == $piece['parts'] ) ? [] : $piece['parts'];
+
+ $profileSection = null; // profile templates
+
+ # SUBST
+ if ( !$found ) {
+ $substMatch = $this->mSubstWords->matchStartAndRemove( $part1 );
+
+ # Possibilities for substMatch: "subst", "safesubst" or FALSE
+ # Decide whether to expand template or keep wikitext as-is.
+ if ( $this->ot['wiki'] ) {
+ if ( $substMatch === false ) {
+ $literal = true; # literal when in PST with no prefix
+ } else {
+ $literal = false; # expand when in PST with subst: or safesubst:
+ }
+ } else {
+ if ( $substMatch == 'subst' ) {
+ $literal = true; # literal when not in PST with plain subst:
+ } else {
+ $literal = false; # expand when not in PST with safesubst: or no prefix
+ }
+ }
+ if ( $literal ) {
+ $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
+ $isLocalObj = true;
+ $found = true;
+ }
+ }
+
+ # Variables
+ if ( !$found && $args->getLength() == 0 ) {
+ $id = $this->mVariables->matchStartToEnd( $part1 );
+ if ( $id !== false ) {
+ $text = $this->getVariableValue( $id, $frame );
+ if ( MagicWord::getCacheTTL( $id ) > -1 ) {
+ $this->mOutput->updateCacheExpiry( MagicWord::getCacheTTL( $id ) );
+ }
+ $found = true;
+ }
+ }
+
+ # MSG, MSGNW and RAW
+ if ( !$found ) {
+ # Check for MSGNW:
+ $mwMsgnw = MagicWord::get( 'msgnw' );
+ if ( $mwMsgnw->matchStartAndRemove( $part1 ) ) {
+ $nowiki = true;
+ } else {
+ # Remove obsolete MSG:
+ $mwMsg = MagicWord::get( 'msg' );
+ $mwMsg->matchStartAndRemove( $part1 );
+ }
+
+ # Check for RAW:
+ $mwRaw = MagicWord::get( 'raw' );
+ if ( $mwRaw->matchStartAndRemove( $part1 ) ) {
+ $forceRawInterwiki = true;
+ }
+ }
+
+ # Parser functions
+ if ( !$found ) {
+ $colonPos = strpos( $part1, ':' );
+ if ( $colonPos !== false ) {
+ $func = substr( $part1, 0, $colonPos );
+ $funcArgs = [ trim( substr( $part1, $colonPos + 1 ) ) ];
+ $argsLength = $args->getLength();
+ for ( $i = 0; $i < $argsLength; $i++ ) {
+ $funcArgs[] = $args->item( $i );
+ }
+ try {
+ $result = $this->callParserFunction( $frame, $func, $funcArgs );
+ } catch ( Exception $ex ) {
+ throw $ex;
+ }
+
+ # The interface for parser functions allows for extracting
+ # flags into the local scope. Extract any forwarded flags
+ # here.
+ extract( $result );
+ }
+ }
+
+ # Finish mangling title and then check for loops.
+ # Set $title to a Title object and $titleText to the PDBK
+ if ( !$found ) {
+ $ns = NS_TEMPLATE;
+ # Split the title into page and subpage
+ $subpage = '';
+ $relative = $this->maybeDoSubpageLink( $part1, $subpage );
+ if ( $part1 !== $relative ) {
+ $part1 = $relative;
+ $ns = $this->mTitle->getNamespace();
+ }
+ $title = Title::newFromText( $part1, $ns );
+ if ( $title ) {
+ $titleText = $title->getPrefixedText();
+ # Check for language variants if the template is not found
+ if ( $this->getConverterLanguage()->hasVariants() && $title->getArticleID() == 0 ) {
+ $this->getConverterLanguage()->findVariantLink( $part1, $title, true );
+ }
+ # Do recursion depth check
+ $limit = $this->mOptions->getMaxTemplateDepth();
+ if ( $frame->depth >= $limit ) {
+ $found = true;
+ $text = '<span class="error">'
+ . wfMessage( 'parser-template-recursion-depth-warning' )
+ ->numParams( $limit )->inContentLanguage()->text()
+ . '</span>';
+ }
+ }
+ }
+
+ # Load from database
+ if ( !$found && $title ) {
+ $profileSection = $this->mProfiler->scopedProfileIn( $title->getPrefixedDBkey() );
+ if ( !$title->isExternal() ) {
+ if ( $title->isSpecialPage()
+ && $this->mOptions->getAllowSpecialInclusion()
+ && $this->ot['html']
+ ) {
+ $specialPage = SpecialPageFactory::getPage( $title->getDBkey() );
+ // Pass the template arguments as URL parameters.
+ // "uselang" will have no effect since the Language object
+ // is forced to the one defined in ParserOptions.
+ $pageArgs = [];
+ $argsLength = $args->getLength();
+ for ( $i = 0; $i < $argsLength; $i++ ) {
+ $bits = $args->item( $i )->splitArg();
+ if ( strval( $bits['index'] ) === '' ) {
+ $name = trim( $frame->expand( $bits['name'], PPFrame::STRIP_COMMENTS ) );
+ $value = trim( $frame->expand( $bits['value'] ) );
+ $pageArgs[$name] = $value;
+ }
+ }
+
+ // Create a new context to execute the special page
+ $context = new RequestContext;
+ $context->setTitle( $title );
+ $context->setRequest( new FauxRequest( $pageArgs ) );
+ if ( $specialPage && $specialPage->maxIncludeCacheTime() === 0 ) {
+ $context->setUser( $this->getUser() );
+ } else {
+ // If this page is cached, then we better not be per user.
+ $context->setUser( User::newFromName( '127.0.0.1', false ) );
+ }
+ $context->setLanguage( $this->mOptions->getUserLangObj() );
+ $ret = SpecialPageFactory::capturePath(
+ $title, $context, $this->getLinkRenderer() );
+ if ( $ret ) {
+ $text = $context->getOutput()->getHTML();
+ $this->mOutput->addOutputPageMetadata( $context->getOutput() );
+ $found = true;
+ $isHTML = true;
+ if ( $specialPage && $specialPage->maxIncludeCacheTime() !== false ) {
+ $this->mOutput->updateRuntimeAdaptiveExpiry(
+ $specialPage->maxIncludeCacheTime()
+ );
+ }
+ }
+ } elseif ( MWNamespace::isNonincludable( $title->getNamespace() ) ) {
+ $found = false; # access denied
+ wfDebug( __METHOD__ . ": template inclusion denied for " .
+ $title->getPrefixedDBkey() . "\n" );
+ } else {
+ list( $text, $title ) = $this->getTemplateDom( $title );
+ if ( $text !== false ) {
+ $found = true;
+ $isChildObj = true;
+ }
+ }
+
+ # If the title is valid but undisplayable, make a link to it
+ if ( !$found && ( $this->ot['html'] || $this->ot['pre'] ) ) {
+ $text = "[[:$titleText]]";
+ $found = true;
+ }
+ } elseif ( $title->isTrans() ) {
+ # Interwiki transclusion
+ if ( $this->ot['html'] && !$forceRawInterwiki ) {
+ $text = $this->interwikiTransclude( $title, 'render' );
+ $isHTML = true;
+ } else {
+ $text = $this->interwikiTransclude( $title, 'raw' );
+ # Preprocess it like a template
+ $text = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
+ $isChildObj = true;
+ }
+ $found = true;
+ }
+
+ # Do infinite loop check
+ # This has to be done after redirect resolution to avoid infinite loops via redirects
+ if ( !$frame->loopCheck( $title ) ) {
+ $found = true;
+ $text = '<span class="error">'
+ . wfMessage( 'parser-template-loop-warning', $titleText )->inContentLanguage()->text()
+ . '</span>';
+ $this->addTrackingCategory( 'template-loop-category' );
+ $this->mOutput->addWarning( wfMessage( 'template-loop-warning',
+ wfEscapeWikiText( $titleText ) )->text() );
+ wfDebug( __METHOD__ . ": template loop broken at '$titleText'\n" );
+ }
+ }
+
+ # If we haven't found text to substitute by now, we're done
+ # Recover the source wikitext and return it
+ if ( !$found ) {
+ $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
+ if ( $profileSection ) {
+ $this->mProfiler->scopedProfileOut( $profileSection );
+ }
+ return [ 'object' => $text ];
+ }
+
+ # Expand DOM-style return values in a child frame
+ if ( $isChildObj ) {
+ # Clean up argument array
+ $newFrame = $frame->newChild( $args, $title );
+
+ if ( $nowiki ) {
+ $text = $newFrame->expand( $text, PPFrame::RECOVER_ORIG );
+ } elseif ( $titleText !== false && $newFrame->isEmpty() ) {
+ # Expansion is eligible for the empty-frame cache
+ $text = $newFrame->cachedExpand( $titleText, $text );
+ } else {
+ # Uncached expansion
+ $text = $newFrame->expand( $text );
+ }
+ }
+ if ( $isLocalObj && $nowiki ) {
+ $text = $frame->expand( $text, PPFrame::RECOVER_ORIG );
+ $isLocalObj = false;
+ }
+
+ if ( $profileSection ) {
+ $this->mProfiler->scopedProfileOut( $profileSection );
+ }
+
+ # Replace raw HTML by a placeholder
+ if ( $isHTML ) {
+ $text = $this->insertStripItem( $text );
+ } elseif ( $nowiki && ( $this->ot['html'] || $this->ot['pre'] ) ) {
+ # Escape nowiki-style return values
+ $text = wfEscapeWikiText( $text );
+ } elseif ( is_string( $text )
+ && !$piece['lineStart']
+ && preg_match( '/^(?:{\\||:|;|#|\*)/', $text )
+ ) {
+ # T2529: if the template begins with a table or block-level
+ # element, it should be treated as beginning a new line.
+ # This behavior is somewhat controversial.
+ $text = "\n" . $text;
+ }
+
+ if ( is_string( $text ) && !$this->incrementIncludeSize( 'post-expand', strlen( $text ) ) ) {
+ # Error, oversize inclusion
+ if ( $titleText !== false ) {
+ # Make a working, properly escaped link if possible (T25588)
+ $text = "[[:$titleText]]";
+ } else {
+ # This will probably not be a working link, but at least it may
+ # provide some hint of where the problem is
+ preg_replace( '/^:/', '', $originalTitle );
+ $text = "[[:$originalTitle]]";
+ }
+ $text .= $this->insertStripItem( '<!-- WARNING: template omitted, '
+ . 'post-expand include size too large -->' );
+ $this->limitationWarn( 'post-expand-template-inclusion' );
+ }
+
+ if ( $isLocalObj ) {
+ $ret = [ 'object' => $text ];
+ } else {
+ $ret = [ 'text' => $text ];
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Call a parser function and return an array with text and flags.
+ *
+ * The returned array will always contain a boolean 'found', indicating
+ * whether the parser function was found or not. It may also contain the
+ * following:
+ * text: string|object, resulting wikitext or PP DOM object
+ * isHTML: bool, $text is HTML, armour it against wikitext transformation
+ * isChildObj: bool, $text is a DOM node needing expansion in a child frame
+ * isLocalObj: bool, $text is a DOM node needing expansion in the current frame
+ * nowiki: bool, wiki markup in $text should be escaped
+ *
+ * @since 1.21
+ * @param PPFrame $frame The current frame, contains template arguments
+ * @param string $function Function name
+ * @param array $args Arguments to the function
+ * @throws MWException
+ * @return array
+ */
+ public function callParserFunction( $frame, $function, array $args = [] ) {
+ global $wgContLang;
+
+ # Case sensitive functions
+ if ( isset( $this->mFunctionSynonyms[1][$function] ) ) {
+ $function = $this->mFunctionSynonyms[1][$function];
+ } else {
+ # Case insensitive functions
+ $function = $wgContLang->lc( $function );
+ if ( isset( $this->mFunctionSynonyms[0][$function] ) ) {
+ $function = $this->mFunctionSynonyms[0][$function];
+ } else {
+ return [ 'found' => false ];
+ }
+ }
+
+ list( $callback, $flags ) = $this->mFunctionHooks[$function];
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+
+ $allArgs = [ &$parser ];
+ if ( $flags & self::SFH_OBJECT_ARGS ) {
+ # Convert arguments to PPNodes and collect for appending to $allArgs
+ $funcArgs = [];
+ foreach ( $args as $k => $v ) {
+ if ( $v instanceof PPNode || $k === 0 ) {
+ $funcArgs[] = $v;
+ } else {
+ $funcArgs[] = $this->mPreprocessor->newPartNodeArray( [ $k => $v ] )->item( 0 );
+ }
+ }
+
+ # Add a frame parameter, and pass the arguments as an array
+ $allArgs[] = $frame;
+ $allArgs[] = $funcArgs;
+ } else {
+ # Convert arguments to plain text and append to $allArgs
+ foreach ( $args as $k => $v ) {
+ if ( $v instanceof PPNode ) {
+ $allArgs[] = trim( $frame->expand( $v ) );
+ } elseif ( is_int( $k ) && $k >= 0 ) {
+ $allArgs[] = trim( $v );
+ } else {
+ $allArgs[] = trim( "$k=$v" );
+ }
+ }
+ }
+
+ $result = call_user_func_array( $callback, $allArgs );
+
+ # The interface for function hooks allows them to return a wikitext
+ # string or an array containing the string and any flags. This mungs
+ # things around to match what this method should return.
+ if ( !is_array( $result ) ) {
+ $result = [
+ 'found' => true,
+ 'text' => $result,
+ ];
+ } else {
+ if ( isset( $result[0] ) && !isset( $result['text'] ) ) {
+ $result['text'] = $result[0];
+ }
+ unset( $result[0] );
+ $result += [
+ 'found' => true,
+ ];
+ }
+
+ $noparse = true;
+ $preprocessFlags = 0;
+ if ( isset( $result['noparse'] ) ) {
+ $noparse = $result['noparse'];
+ }
+ if ( isset( $result['preprocessFlags'] ) ) {
+ $preprocessFlags = $result['preprocessFlags'];
+ }
+
+ if ( !$noparse ) {
+ $result['text'] = $this->preprocessToDom( $result['text'], $preprocessFlags );
+ $result['isChildObj'] = true;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the semi-parsed DOM representation of a template with a given title,
+ * and its redirect destination title. Cached.
+ *
+ * @param Title $title
+ *
+ * @return array
+ */
+ public function getTemplateDom( $title ) {
+ $cacheTitle = $title;
+ $titleText = $title->getPrefixedDBkey();
+
+ if ( isset( $this->mTplRedirCache[$titleText] ) ) {
+ list( $ns, $dbk ) = $this->mTplRedirCache[$titleText];
+ $title = Title::makeTitle( $ns, $dbk );
+ $titleText = $title->getPrefixedDBkey();
+ }
+ if ( isset( $this->mTplDomCache[$titleText] ) ) {
+ return [ $this->mTplDomCache[$titleText], $title ];
+ }
+
+ # Cache miss, go to the database
+ list( $text, $title ) = $this->fetchTemplateAndTitle( $title );
+
+ if ( $text === false ) {
+ $this->mTplDomCache[$titleText] = false;
+ return [ false, $title ];
+ }
+
+ $dom = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
+ $this->mTplDomCache[$titleText] = $dom;
+
+ if ( !$title->equals( $cacheTitle ) ) {
+ $this->mTplRedirCache[$cacheTitle->getPrefixedDBkey()] =
+ [ $title->getNamespace(), $title->getDBkey() ];
+ }
+
+ return [ $dom, $title ];
+ }
+
+ /**
+ * Fetch the current revision of a given title. Note that the revision
+ * (and even the title) may not exist in the database, so everything
+ * contributing to the output of the parser should use this method
+ * where possible, rather than getting the revisions themselves. This
+ * method also caches its results, so using it benefits performance.
+ *
+ * @since 1.24
+ * @param Title $title
+ * @return Revision
+ */
+ public function fetchCurrentRevisionOfTitle( $title ) {
+ $cacheKey = $title->getPrefixedDBkey();
+ if ( !$this->currentRevisionCache ) {
+ $this->currentRevisionCache = new MapCacheLRU( 100 );
+ }
+ if ( !$this->currentRevisionCache->has( $cacheKey ) ) {
+ $this->currentRevisionCache->set( $cacheKey,
+ // Defaults to Parser::statelessFetchRevision()
+ call_user_func( $this->mOptions->getCurrentRevisionCallback(), $title, $this )
+ );
+ }
+ return $this->currentRevisionCache->get( $cacheKey );
+ }
+
+ /**
+ * Wrapper around Revision::newFromTitle to allow passing additional parameters
+ * without passing them on to it.
+ *
+ * @since 1.24
+ * @param Title $title
+ * @param Parser|bool $parser
+ * @return Revision|bool False if missing
+ */
+ public static function statelessFetchRevision( Title $title, $parser = false ) {
+ $pageId = $title->getArticleID();
+ $revId = $title->getLatestRevID();
+
+ $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $pageId, $revId );
+ if ( $rev ) {
+ $rev->setTitle( $title );
+ }
+
+ return $rev;
+ }
+
+ /**
+ * Fetch the unparsed text of a template and register a reference to it.
+ * @param Title $title
+ * @return array ( string or false, Title )
+ */
+ public function fetchTemplateAndTitle( $title ) {
+ // Defaults to Parser::statelessFetchTemplate()
+ $templateCb = $this->mOptions->getTemplateCallback();
+ $stuff = call_user_func( $templateCb, $title, $this );
+ // We use U+007F DELETE to distinguish strip markers from regular text.
+ $text = $stuff['text'];
+ if ( is_string( $stuff['text'] ) ) {
+ $text = strtr( $text, "\x7f", "?" );
+ }
+ $finalTitle = isset( $stuff['finalTitle'] ) ? $stuff['finalTitle'] : $title;
+ if ( isset( $stuff['deps'] ) ) {
+ foreach ( $stuff['deps'] as $dep ) {
+ $this->mOutput->addTemplate( $dep['title'], $dep['page_id'], $dep['rev_id'] );
+ if ( $dep['title']->equals( $this->getTitle() ) ) {
+ // If we transclude ourselves, the final result
+ // will change based on the new version of the page
+ $this->mOutput->setFlag( 'vary-revision' );
+ }
+ }
+ }
+ return [ $text, $finalTitle ];
+ }
+
+ /**
+ * Fetch the unparsed text of a template and register a reference to it.
+ * @param Title $title
+ * @return string|bool
+ */
+ public function fetchTemplate( $title ) {
+ return $this->fetchTemplateAndTitle( $title )[0];
+ }
+
+ /**
+ * Static function to get a template
+ * Can be overridden via ParserOptions::setTemplateCallback().
+ *
+ * @param Title $title
+ * @param bool|Parser $parser
+ *
+ * @return array
+ */
+ public static function statelessFetchTemplate( $title, $parser = false ) {
+ $text = $skip = false;
+ $finalTitle = $title;
+ $deps = [];
+
+ # Loop to fetch the article, with up to 1 redirect
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $i = 0; $i < 2 && is_object( $title ); $i++ ) {
+ // @codingStandardsIgnoreEnd
+ # Give extensions a chance to select the revision instead
+ $id = false; # Assume current
+ Hooks::run( 'BeforeParserFetchTemplateAndtitle',
+ [ $parser, $title, &$skip, &$id ] );
+
+ if ( $skip ) {
+ $text = false;
+ $deps[] = [
+ 'title' => $title,
+ 'page_id' => $title->getArticleID(),
+ 'rev_id' => null
+ ];
+ break;
+ }
+ # Get the revision
+ if ( $id ) {
+ $rev = Revision::newFromId( $id );
+ } elseif ( $parser ) {
+ $rev = $parser->fetchCurrentRevisionOfTitle( $title );
+ } else {
+ $rev = Revision::newFromTitle( $title );
+ }
+ $rev_id = $rev ? $rev->getId() : 0;
+ # If there is no current revision, there is no page
+ if ( $id === false && !$rev ) {
+ $linkCache = LinkCache::singleton();
+ $linkCache->addBadLinkObj( $title );
+ }
+
+ $deps[] = [
+ 'title' => $title,
+ 'page_id' => $title->getArticleID(),
+ 'rev_id' => $rev_id ];
+ if ( $rev && !$title->equals( $rev->getTitle() ) ) {
+ # We fetched a rev from a different title; register it too...
+ $deps[] = [
+ 'title' => $rev->getTitle(),
+ 'page_id' => $rev->getPage(),
+ 'rev_id' => $rev_id ];
+ }
+
+ if ( $rev ) {
+ $content = $rev->getContent();
+ $text = $content ? $content->getWikitextForTransclusion() : null;
+
+ Hooks::run( 'ParserFetchTemplate',
+ [ $parser, $title, $rev, &$text, &$deps ] );
+
+ if ( $text === false || $text === null ) {
+ $text = false;
+ break;
+ }
+ } elseif ( $title->getNamespace() == NS_MEDIAWIKI ) {
+ global $wgContLang;
+ $message = wfMessage( $wgContLang->lcfirst( $title->getText() ) )->inContentLanguage();
+ if ( !$message->exists() ) {
+ $text = false;
+ break;
+ }
+ $content = $message->content();
+ $text = $message->plain();
+ } else {
+ break;
+ }
+ if ( !$content ) {
+ break;
+ }
+ # Redirect?
+ $finalTitle = $title;
+ $title = $content->getRedirectTarget();
+ }
+ return [
+ 'text' => $text,
+ 'finalTitle' => $finalTitle,
+ 'deps' => $deps ];
+ }
+
+ /**
+ * Fetch a file and its title and register a reference to it.
+ * If 'broken' is a key in $options then the file will appear as a broken thumbnail.
+ * @param Title $title
+ * @param array $options Array of options to RepoGroup::findFile
+ * @return File|bool
+ */
+ public function fetchFile( $title, $options = [] ) {
+ return $this->fetchFileAndTitle( $title, $options )[0];
+ }
+
+ /**
+ * Fetch a file and its title and register a reference to it.
+ * If 'broken' is a key in $options then the file will appear as a broken thumbnail.
+ * @param Title $title
+ * @param array $options Array of options to RepoGroup::findFile
+ * @return array ( File or false, Title of file )
+ */
+ public function fetchFileAndTitle( $title, $options = [] ) {
+ $file = $this->fetchFileNoRegister( $title, $options );
+
+ $time = $file ? $file->getTimestamp() : false;
+ $sha1 = $file ? $file->getSha1() : false;
+ # Register the file as a dependency...
+ $this->mOutput->addImage( $title->getDBkey(), $time, $sha1 );
+ if ( $file && !$title->equals( $file->getTitle() ) ) {
+ # Update fetched file title
+ $title = $file->getTitle();
+ $this->mOutput->addImage( $title->getDBkey(), $time, $sha1 );
+ }
+ return [ $file, $title ];
+ }
+
+ /**
+ * Helper function for fetchFileAndTitle.
+ *
+ * Also useful if you need to fetch a file but not use it yet,
+ * for example to get the file's handler.
+ *
+ * @param Title $title
+ * @param array $options Array of options to RepoGroup::findFile
+ * @return File|bool
+ */
+ protected function fetchFileNoRegister( $title, $options = [] ) {
+ if ( isset( $options['broken'] ) ) {
+ $file = false; // broken thumbnail forced by hook
+ } elseif ( isset( $options['sha1'] ) ) { // get by (sha1,timestamp)
+ $file = RepoGroup::singleton()->findFileFromKey( $options['sha1'], $options );
+ } else { // get by (name,timestamp)
+ $file = wfFindFile( $title, $options );
+ }
+ return $file;
+ }
+
+ /**
+ * Transclude an interwiki link.
+ *
+ * @param Title $title
+ * @param string $action
+ *
+ * @return string
+ */
+ public function interwikiTransclude( $title, $action ) {
+ global $wgEnableScaryTranscluding;
+
+ if ( !$wgEnableScaryTranscluding ) {
+ return wfMessage( 'scarytranscludedisabled' )->inContentLanguage()->text();
+ }
+
+ $url = $title->getFullURL( [ 'action' => $action ] );
+
+ if ( strlen( $url ) > 255 ) {
+ return wfMessage( 'scarytranscludetoolong' )->inContentLanguage()->text();
+ }
+ return $this->fetchScaryTemplateMaybeFromCache( $url );
+ }
+
+ /**
+ * @param string $url
+ * @return mixed|string
+ */
+ public function fetchScaryTemplateMaybeFromCache( $url ) {
+ global $wgTranscludeCacheExpiry;
+ $dbr = wfGetDB( DB_REPLICA );
+ $tsCond = $dbr->timestamp( time() - $wgTranscludeCacheExpiry );
+ $obj = $dbr->selectRow( 'transcache', [ 'tc_time', 'tc_contents' ],
+ [ 'tc_url' => $url, "tc_time >= " . $dbr->addQuotes( $tsCond ) ] );
+ if ( $obj ) {
+ return $obj->tc_contents;
+ }
+
+ $req = MWHttpRequest::factory( $url, [], __METHOD__ );
+ $status = $req->execute(); // Status object
+ if ( $status->isOK() ) {
+ $text = $req->getContent();
+ } elseif ( $req->getStatus() != 200 ) {
+ // Though we failed to fetch the content, this status is useless.
+ return wfMessage( 'scarytranscludefailed-httpstatus' )
+ ->params( $url, $req->getStatus() /* HTTP status */ )->inContentLanguage()->text();
+ } else {
+ return wfMessage( 'scarytranscludefailed', $url )->inContentLanguage()->text();
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->replace( 'transcache', [ 'tc_url' ], [
+ 'tc_url' => $url,
+ 'tc_time' => $dbw->timestamp( time() ),
+ 'tc_contents' => $text
+ ] );
+ return $text;
+ }
+
+ /**
+ * Triple brace replacement -- used for template arguments
+ * @private
+ *
+ * @param array $piece
+ * @param PPFrame $frame
+ *
+ * @return array
+ */
+ public function argSubstitution( $piece, $frame ) {
+ $error = false;
+ $parts = $piece['parts'];
+ $nameWithSpaces = $frame->expand( $piece['title'] );
+ $argName = trim( $nameWithSpaces );
+ $object = false;
+ $text = $frame->getArgument( $argName );
+ if ( $text === false && $parts->getLength() > 0
+ && ( $this->ot['html']
+ || $this->ot['pre']
+ || ( $this->ot['wiki'] && $frame->isTemplate() )
+ )
+ ) {
+ # No match in frame, use the supplied default
+ $object = $parts->item( 0 )->getChildren();
+ }
+ if ( !$this->incrementIncludeSize( 'arg', strlen( $text ) ) ) {
+ $error = '<!-- WARNING: argument omitted, expansion size too large -->';
+ $this->limitationWarn( 'post-expand-template-argument' );
+ }
+
+ if ( $text === false && $object === false ) {
+ # No match anywhere
+ $object = $frame->virtualBracketedImplode( '{{{', '|', '}}}', $nameWithSpaces, $parts );
+ }
+ if ( $error !== false ) {
+ $text .= $error;
+ }
+ if ( $object !== false ) {
+ $ret = [ 'object' => $object ];
+ } else {
+ $ret = [ 'text' => $text ];
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Return the text to be used for a given extension tag.
+ * This is the ghost of strip().
+ *
+ * @param array $params Associative array of parameters:
+ * name PPNode for the tag name
+ * attr PPNode for unparsed text where tag attributes are thought to be
+ * attributes Optional associative array of parsed attributes
+ * inner Contents of extension element
+ * noClose Original text did not have a close tag
+ * @param PPFrame $frame
+ *
+ * @throws MWException
+ * @return string
+ */
+ public function extensionSubstitution( $params, $frame ) {
+ static $errorStr = '<span class="error">';
+ static $errorLen = 20;
+
+ $name = $frame->expand( $params['name'] );
+ if ( substr( $name, 0, $errorLen ) === $errorStr ) {
+ // Probably expansion depth or node count exceeded. Just punt the
+ // error up.
+ return $name;
+ }
+
+ $attrText = !isset( $params['attr'] ) ? null : $frame->expand( $params['attr'] );
+ if ( substr( $attrText, 0, $errorLen ) === $errorStr ) {
+ // See above
+ return $attrText;
+ }
+
+ // We can't safely check if the expansion for $content resulted in an
+ // error, because the content could happen to be the error string
+ // (T149622).
+ $content = !isset( $params['inner'] ) ? null : $frame->expand( $params['inner'] );
+
+ $marker = self::MARKER_PREFIX . "-$name-"
+ . sprintf( '%08X', $this->mMarkerIndex++ ) . self::MARKER_SUFFIX;
+
+ $isFunctionTag = isset( $this->mFunctionTagHooks[strtolower( $name )] ) &&
+ ( $this->ot['html'] || $this->ot['pre'] );
+ if ( $isFunctionTag ) {
+ $markerType = 'none';
+ } else {
+ $markerType = 'general';
+ }
+ if ( $this->ot['html'] || $isFunctionTag ) {
+ $name = strtolower( $name );
+ $attributes = Sanitizer::decodeTagAttributes( $attrText );
+ if ( isset( $params['attributes'] ) ) {
+ $attributes = $attributes + $params['attributes'];
+ }
+
+ if ( isset( $this->mTagHooks[$name] ) ) {
+ $output = call_user_func_array( $this->mTagHooks[$name],
+ [ $content, $attributes, $this, $frame ] );
+ } elseif ( isset( $this->mFunctionTagHooks[$name] ) ) {
+ list( $callback, ) = $this->mFunctionTagHooks[$name];
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+ $output = call_user_func_array( $callback, [ &$parser, $frame, $content, $attributes ] );
+ } else {
+ $output = '<span class="error">Invalid tag extension name: ' .
+ htmlspecialchars( $name ) . '</span>';
+ }
+
+ if ( is_array( $output ) ) {
+ # Extract flags to local scope (to override $markerType)
+ $flags = $output;
+ $output = $flags[0];
+ unset( $flags[0] );
+ extract( $flags );
+ }
+ } else {
+ if ( is_null( $attrText ) ) {
+ $attrText = '';
+ }
+ if ( isset( $params['attributes'] ) ) {
+ foreach ( $params['attributes'] as $attrName => $attrValue ) {
+ $attrText .= ' ' . htmlspecialchars( $attrName ) . '="' .
+ htmlspecialchars( $attrValue ) . '"';
+ }
+ }
+ if ( $content === null ) {
+ $output = "<$name$attrText/>";
+ } else {
+ $close = is_null( $params['close'] ) ? '' : $frame->expand( $params['close'] );
+ if ( substr( $close, 0, $errorLen ) === $errorStr ) {
+ // See above
+ return $close;
+ }
+ $output = "<$name$attrText>$content$close";
+ }
+ }
+
+ if ( $markerType === 'none' ) {
+ return $output;
+ } elseif ( $markerType === 'nowiki' ) {
+ $this->mStripState->addNoWiki( $marker, $output );
+ } elseif ( $markerType === 'general' ) {
+ $this->mStripState->addGeneral( $marker, $output );
+ } else {
+ throw new MWException( __METHOD__ . ': invalid marker type' );
+ }
+ return $marker;
+ }
+
+ /**
+ * Increment an include size counter
+ *
+ * @param string $type The type of expansion
+ * @param int $size The size of the text
+ * @return bool False if this inclusion would take it over the maximum, true otherwise
+ */
+ public function incrementIncludeSize( $type, $size ) {
+ if ( $this->mIncludeSizes[$type] + $size > $this->mOptions->getMaxIncludeSize() ) {
+ return false;
+ } else {
+ $this->mIncludeSizes[$type] += $size;
+ return true;
+ }
+ }
+
+ /**
+ * Increment the expensive function count
+ *
+ * @return bool False if the limit has been exceeded
+ */
+ public function incrementExpensiveFunctionCount() {
+ $this->mExpensiveFunctionCount++;
+ return $this->mExpensiveFunctionCount <= $this->mOptions->getExpensiveParserFunctionLimit();
+ }
+
+ /**
+ * Strip double-underscore items like __NOGALLERY__ and __NOTOC__
+ * Fills $this->mDoubleUnderscores, returns the modified text
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ public function doDoubleUnderscore( $text ) {
+ # The position of __TOC__ needs to be recorded
+ $mw = MagicWord::get( 'toc' );
+ if ( $mw->match( $text ) ) {
+ $this->mShowToc = true;
+ $this->mForceTocPosition = true;
+
+ # Set a placeholder. At the end we'll fill it in with the TOC.
+ $text = $mw->replace( '<!--MWTOC-->', $text, 1 );
+
+ # Only keep the first one.
+ $text = $mw->replace( '', $text );
+ }
+
+ # Now match and remove the rest of them
+ $mwa = MagicWord::getDoubleUnderscoreArray();
+ $this->mDoubleUnderscores = $mwa->matchAndRemove( $text );
+
+ if ( isset( $this->mDoubleUnderscores['nogallery'] ) ) {
+ $this->mOutput->mNoGallery = true;
+ }
+ if ( isset( $this->mDoubleUnderscores['notoc'] ) && !$this->mForceTocPosition ) {
+ $this->mShowToc = false;
+ }
+ if ( isset( $this->mDoubleUnderscores['hiddencat'] )
+ && $this->mTitle->getNamespace() == NS_CATEGORY
+ ) {
+ $this->addTrackingCategory( 'hidden-category-category' );
+ }
+ # (T10068) Allow control over whether robots index a page.
+ # __INDEX__ always overrides __NOINDEX__, see T16899
+ if ( isset( $this->mDoubleUnderscores['noindex'] ) && $this->mTitle->canUseNoindex() ) {
+ $this->mOutput->setIndexPolicy( 'noindex' );
+ $this->addTrackingCategory( 'noindex-category' );
+ }
+ if ( isset( $this->mDoubleUnderscores['index'] ) && $this->mTitle->canUseNoindex() ) {
+ $this->mOutput->setIndexPolicy( 'index' );
+ $this->addTrackingCategory( 'index-category' );
+ }
+
+ # Cache all double underscores in the database
+ foreach ( $this->mDoubleUnderscores as $key => $val ) {
+ $this->mOutput->setProperty( $key, '' );
+ }
+
+ return $text;
+ }
+
+ /**
+ * @see ParserOutput::addTrackingCategory()
+ * @param string $msg Message key
+ * @return bool Whether the addition was successful
+ */
+ public function addTrackingCategory( $msg ) {
+ return $this->mOutput->addTrackingCategory( $msg, $this->mTitle );
+ }
+
+ /**
+ * This function accomplishes several tasks:
+ * 1) Auto-number headings if that option is enabled
+ * 2) Add an [edit] link to sections for users who have enabled the option and can edit the page
+ * 3) Add a Table of contents on the top for users who have enabled the option
+ * 4) Auto-anchor headings
+ *
+ * It loops through all headlines, collects the necessary data, then splits up the
+ * string and re-inserts the newly formatted headlines.
+ *
+ * @param string $text
+ * @param string $origText Original, untouched wikitext
+ * @param bool $isMain
+ * @return mixed|string
+ * @private
+ */
+ public function formatHeadings( $text, $origText, $isMain = true ) {
+ global $wgMaxTocLevel;
+
+ # Inhibit editsection links if requested in the page
+ if ( isset( $this->mDoubleUnderscores['noeditsection'] ) ) {
+ $maybeShowEditLink = $showEditLink = false;
+ } else {
+ $maybeShowEditLink = true; /* Actual presence will depend on ParserOptions option */
+ $showEditLink = $this->mOptions->getEditSection();
+ }
+ if ( $showEditLink ) {
+ $this->mOutput->setEditSectionTokens( true );
+ }
+
+ # Get all headlines for numbering them and adding funky stuff like [edit]
+ # links - this is for later, but we need the number of headlines right now
+ $matches = [];
+ $numMatches = preg_match_all(
+ '/<H(?P<level>[1-6])(?P<attrib>.*?>)\s*(?P<header>[\s\S]*?)\s*<\/H[1-6] *>/i',
+ $text,
+ $matches
+ );
+
+ # if there are fewer than 4 headlines in the article, do not show TOC
+ # unless it's been explicitly enabled.
+ $enoughToc = $this->mShowToc &&
+ ( ( $numMatches >= 4 ) || $this->mForceTocPosition );
+
+ # Allow user to stipulate that a page should have a "new section"
+ # link added via __NEWSECTIONLINK__
+ if ( isset( $this->mDoubleUnderscores['newsectionlink'] ) ) {
+ $this->mOutput->setNewSection( true );
+ }
+
+ # Allow user to remove the "new section"
+ # link via __NONEWSECTIONLINK__
+ if ( isset( $this->mDoubleUnderscores['nonewsectionlink'] ) ) {
+ $this->mOutput->hideNewSection( true );
+ }
+
+ # if the string __FORCETOC__ (not case-sensitive) occurs in the HTML,
+ # override above conditions and always show TOC above first header
+ if ( isset( $this->mDoubleUnderscores['forcetoc'] ) ) {
+ $this->mShowToc = true;
+ $enoughToc = true;
+ }
+
+ # headline counter
+ $headlineCount = 0;
+ $numVisible = 0;
+
+ # Ugh .. the TOC should have neat indentation levels which can be
+ # passed to the skin functions. These are determined here
+ $toc = '';
+ $full = '';
+ $head = [];
+ $sublevelCount = [];
+ $levelCount = [];
+ $level = 0;
+ $prevlevel = 0;
+ $toclevel = 0;
+ $prevtoclevel = 0;
+ $markerRegex = self::MARKER_PREFIX . "-h-(\d+)-" . self::MARKER_SUFFIX;
+ $baseTitleText = $this->mTitle->getPrefixedDBkey();
+ $oldType = $this->mOutputType;
+ $this->setOutputType( self::OT_WIKI );
+ $frame = $this->getPreprocessor()->newFrame();
+ $root = $this->preprocessToDom( $origText );
+ $node = $root->getFirstChild();
+ $byteOffset = 0;
+ $tocraw = [];
+ $refers = [];
+
+ $headlines = $numMatches !== false ? $matches[3] : [];
+
+ foreach ( $headlines as $headline ) {
+ $isTemplate = false;
+ $titleText = false;
+ $sectionIndex = false;
+ $numbering = '';
+ $markerMatches = [];
+ if ( preg_match( "/^$markerRegex/", $headline, $markerMatches ) ) {
+ $serial = $markerMatches[1];
+ list( $titleText, $sectionIndex ) = $this->mHeadings[$serial];
+ $isTemplate = ( $titleText != $baseTitleText );
+ $headline = preg_replace( "/^$markerRegex\\s*/", "", $headline );
+ }
+
+ if ( $toclevel ) {
+ $prevlevel = $level;
+ }
+ $level = $matches[1][$headlineCount];
+
+ if ( $level > $prevlevel ) {
+ # Increase TOC level
+ $toclevel++;
+ $sublevelCount[$toclevel] = 0;
+ if ( $toclevel < $wgMaxTocLevel ) {
+ $prevtoclevel = $toclevel;
+ $toc .= Linker::tocIndent();
+ $numVisible++;
+ }
+ } elseif ( $level < $prevlevel && $toclevel > 1 ) {
+ # Decrease TOC level, find level to jump to
+
+ for ( $i = $toclevel; $i > 0; $i-- ) {
+ if ( $levelCount[$i] == $level ) {
+ # Found last matching level
+ $toclevel = $i;
+ break;
+ } elseif ( $levelCount[$i] < $level ) {
+ # Found first matching level below current level
+ $toclevel = $i + 1;
+ break;
+ }
+ }
+ if ( $i == 0 ) {
+ $toclevel = 1;
+ }
+ if ( $toclevel < $wgMaxTocLevel ) {
+ if ( $prevtoclevel < $wgMaxTocLevel ) {
+ # Unindent only if the previous toc level was shown :p
+ $toc .= Linker::tocUnindent( $prevtoclevel - $toclevel );
+ $prevtoclevel = $toclevel;
+ } else {
+ $toc .= Linker::tocLineEnd();
+ }
+ }
+ } else {
+ # No change in level, end TOC line
+ if ( $toclevel < $wgMaxTocLevel ) {
+ $toc .= Linker::tocLineEnd();
+ }
+ }
+
+ $levelCount[$toclevel] = $level;
+
+ # count number of headlines for each level
+ $sublevelCount[$toclevel]++;
+ $dot = 0;
+ for ( $i = 1; $i <= $toclevel; $i++ ) {
+ if ( !empty( $sublevelCount[$i] ) ) {
+ if ( $dot ) {
+ $numbering .= '.';
+ }
+ $numbering .= $this->getTargetLanguage()->formatNum( $sublevelCount[$i] );
+ $dot = 1;
+ }
+ }
+
+ # The safe header is a version of the header text safe to use for links
+
+ # Remove link placeholders by the link text.
+ # <!--LINK number-->
+ # turns into
+ # link text with suffix
+ # Do this before unstrip since link text can contain strip markers
+ $safeHeadline = $this->replaceLinkHoldersText( $headline );
+
+ # Avoid insertion of weird stuff like <math> by expanding the relevant sections
+ $safeHeadline = $this->mStripState->unstripBoth( $safeHeadline );
+
+ # Strip out HTML (first regex removes any tag not allowed)
+ # Allowed tags are:
+ # * <sup> and <sub> (T10393)
+ # * <i> (T28375)
+ # * <b> (r105284)
+ # * <bdi> (T74884)
+ # * <span dir="rtl"> and <span dir="ltr"> (T37167)
+ # * <s> and <strike> (T35715)
+ # We strip any parameter from accepted tags (second regex), except dir="rtl|ltr" from <span>,
+ # to allow setting directionality in toc items.
+ $tocline = preg_replace(
+ [
+ '#<(?!/?(span|sup|sub|bdi|i|b|s|strike)(?: [^>]*)?>).*?>#',
+ '#<(/?(?:span(?: dir="(?:rtl|ltr)")?|sup|sub|bdi|i|b|s|strike))(?: .*?)?>#'
+ ],
+ [ '', '<$1>' ],
+ $safeHeadline
+ );
+
+ # Strip '<span></span>', which is the result from the above if
+ # <span id="foo"></span> is used to produce an additional anchor
+ # for a section.
+ $tocline = str_replace( '<span></span>', '', $tocline );
+
+ $tocline = trim( $tocline );
+
+ # For the anchor, strip out HTML-y stuff period
+ $safeHeadline = preg_replace( '/<.*?>/', '', $safeHeadline );
+ $safeHeadline = Sanitizer::normalizeSectionNameWhitespace( $safeHeadline );
+
+ # Save headline for section edit hint before it's escaped
+ $headlineHint = $safeHeadline;
+
+ # Decode HTML entities
+ $safeHeadline = Sanitizer::decodeCharReferences( $safeHeadline );
+ $fallbackHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_FALLBACK );
+ $linkAnchor = Sanitizer::escapeIdForLink( $safeHeadline );
+ $safeHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_PRIMARY );
+ if ( $fallbackHeadline === $safeHeadline ) {
+ # No reason to have both (in fact, we can't)
+ $fallbackHeadline = false;
+ }
+
+ # HTML IDs must be case-insensitively unique for IE compatibility (T12721).
+ # @todo FIXME: We may be changing them depending on the current locale.
+ $arrayKey = strtolower( $safeHeadline );
+ if ( $fallbackHeadline === false ) {
+ $fallbackArrayKey = false;
+ } else {
+ $fallbackArrayKey = strtolower( $fallbackHeadline );
+ }
+
+ # Create the anchor for linking from the TOC to the section
+ $anchor = $safeHeadline;
+ $fallbackAnchor = $fallbackHeadline;
+ if ( isset( $refers[$arrayKey] ) ) {
+ // @codingStandardsIgnoreStart
+ for ( $i = 2; isset( $refers["${arrayKey}_$i"] ); ++$i );
+ // @codingStandardsIgnoreEnd
+ $anchor .= "_$i";
+ $linkAnchor .= "_$i";
+ $refers["${arrayKey}_$i"] = true;
+ } else {
+ $refers[$arrayKey] = true;
+ }
+ if ( $fallbackHeadline !== false && isset( $refers[$fallbackArrayKey] ) ) {
+ // @codingStandardsIgnoreStart
+ for ( $i = 2; isset( $refers["${fallbackArrayKey}_$i"] ); ++$i );
+ // @codingStandardsIgnoreEnd
+ $fallbackAnchor .= "_$i";
+ $refers["${fallbackArrayKey}_$i"] = true;
+ } else {
+ $refers[$fallbackArrayKey] = true;
+ }
+
+ # Don't number the heading if it is the only one (looks silly)
+ if ( count( $matches[3] ) > 1 && $this->mOptions->getNumberHeadings() ) {
+ # the two are different if the line contains a link
+ $headline = Html::element(
+ 'span',
+ [ 'class' => 'mw-headline-number' ],
+ $numbering
+ ) . ' ' . $headline;
+ }
+
+ if ( $enoughToc && ( !isset( $wgMaxTocLevel ) || $toclevel < $wgMaxTocLevel ) ) {
+ $toc .= Linker::tocLine( $linkAnchor, $tocline,
+ $numbering, $toclevel, ( $isTemplate ? false : $sectionIndex ) );
+ }
+
+ # Add the section to the section tree
+ # Find the DOM node for this header
+ $noOffset = ( $isTemplate || $sectionIndex === false );
+ while ( $node && !$noOffset ) {
+ if ( $node->getName() === 'h' ) {
+ $bits = $node->splitHeading();
+ if ( $bits['i'] == $sectionIndex ) {
+ break;
+ }
+ }
+ $byteOffset += mb_strlen( $this->mStripState->unstripBoth(
+ $frame->expand( $node, PPFrame::RECOVER_ORIG ) ) );
+ $node = $node->getNextSibling();
+ }
+ $tocraw[] = [
+ 'toclevel' => $toclevel,
+ 'level' => $level,
+ 'line' => $tocline,
+ 'number' => $numbering,
+ 'index' => ( $isTemplate ? 'T-' : '' ) . $sectionIndex,
+ 'fromtitle' => $titleText,
+ 'byteoffset' => ( $noOffset ? null : $byteOffset ),
+ 'anchor' => $anchor,
+ ];
+
+ # give headline the correct <h#> tag
+ if ( $maybeShowEditLink && $sectionIndex !== false ) {
+ // Output edit section links as markers with styles that can be customized by skins
+ if ( $isTemplate ) {
+ # Put a T flag in the section identifier, to indicate to extractSections()
+ # that sections inside <includeonly> should be counted.
+ $editsectionPage = $titleText;
+ $editsectionSection = "T-$sectionIndex";
+ $editsectionContent = null;
+ } else {
+ $editsectionPage = $this->mTitle->getPrefixedText();
+ $editsectionSection = $sectionIndex;
+ $editsectionContent = $headlineHint;
+ }
+ // We use a bit of pesudo-xml for editsection markers. The
+ // language converter is run later on. Using a UNIQ style marker
+ // leads to the converter screwing up the tokens when it
+ // converts stuff. And trying to insert strip tags fails too. At
+ // this point all real inputted tags have already been escaped,
+ // so we don't have to worry about a user trying to input one of
+ // these markers directly. We use a page and section attribute
+ // to stop the language converter from converting these
+ // important bits of data, but put the headline hint inside a
+ // content block because the language converter is supposed to
+ // be able to convert that piece of data.
+ // Gets replaced with html in ParserOutput::getText
+ $editlink = '<mw:editsection page="' . htmlspecialchars( $editsectionPage );
+ $editlink .= '" section="' . htmlspecialchars( $editsectionSection ) . '"';
+ if ( $editsectionContent !== null ) {
+ $editlink .= '>' . $editsectionContent . '</mw:editsection>';
+ } else {
+ $editlink .= '/>';
+ }
+ } else {
+ $editlink = '';
+ }
+ $head[$headlineCount] = Linker::makeHeadline( $level,
+ $matches['attrib'][$headlineCount], $anchor, $headline,
+ $editlink, $fallbackAnchor );
+
+ $headlineCount++;
+ }
+
+ $this->setOutputType( $oldType );
+
+ # Never ever show TOC if no headers
+ if ( $numVisible < 1 ) {
+ $enoughToc = false;
+ }
+
+ if ( $enoughToc ) {
+ if ( $prevtoclevel > 0 && $prevtoclevel < $wgMaxTocLevel ) {
+ $toc .= Linker::tocUnindent( $prevtoclevel - 1 );
+ }
+ $toc = Linker::tocList( $toc, $this->mOptions->getUserLangObj() );
+ $this->mOutput->setTOCHTML( $toc );
+ $toc = self::TOC_START . $toc . self::TOC_END;
+ }
+
+ if ( $isMain ) {
+ $this->mOutput->setSections( $tocraw );
+ }
+
+ # split up and insert constructed headlines
+ $blocks = preg_split( '/<H[1-6].*?>[\s\S]*?<\/H[1-6]>/i', $text );
+ $i = 0;
+
+ // build an array of document sections
+ $sections = [];
+ foreach ( $blocks as $block ) {
+ // $head is zero-based, sections aren't.
+ if ( empty( $head[$i - 1] ) ) {
+ $sections[$i] = $block;
+ } else {
+ $sections[$i] = $head[$i - 1] . $block;
+ }
+
+ /**
+ * Send a hook, one per section.
+ * The idea here is to be able to make section-level DIVs, but to do so in a
+ * lower-impact, more correct way than r50769
+ *
+ * $this : caller
+ * $section : the section number
+ * &$sectionContent : ref to the content of the section
+ * $showEditLinks : boolean describing whether this section has an edit link
+ */
+ Hooks::run( 'ParserSectionCreate', [ $this, $i, &$sections[$i], $showEditLink ] );
+
+ $i++;
+ }
+
+ if ( $enoughToc && $isMain && !$this->mForceTocPosition ) {
+ // append the TOC at the beginning
+ // Top anchor now in skin
+ $sections[0] = $sections[0] . $toc . "\n";
+ }
+
+ $full .= implode( '', $sections );
+
+ if ( $this->mForceTocPosition ) {
+ return str_replace( '<!--MWTOC-->', $toc, $full );
+ } else {
+ return $full;
+ }
+ }
+
+ /**
+ * Transform wiki markup when saving a page by doing "\r\n" -> "\n"
+ * conversion, substituting signatures, {{subst:}} templates, etc.
+ *
+ * @param string $text The text to transform
+ * @param Title $title The Title object for the current article
+ * @param User $user The User object describing the current user
+ * @param ParserOptions $options Parsing options
+ * @param bool $clearState Whether to clear the parser state first
+ * @return string The altered wiki markup
+ */
+ public function preSaveTransform( $text, Title $title, User $user,
+ ParserOptions $options, $clearState = true
+ ) {
+ if ( $clearState ) {
+ $magicScopeVariable = $this->lock();
+ }
+ $this->startParse( $title, $options, self::OT_WIKI, $clearState );
+ $this->setUser( $user );
+
+ // Strip U+0000 NULL (T159174)
+ $text = str_replace( "\000", '', $text );
+
+ // We still normalize line endings for backwards-compatibility
+ // with other code that just calls PST, but this should already
+ // be handled in TextContent subclasses
+ $text = TextContent::normalizeLineEndings( $text );
+
+ if ( $options->getPreSaveTransform() ) {
+ $text = $this->pstPass2( $text, $user );
+ }
+ $text = $this->mStripState->unstripBoth( $text );
+
+ $this->setUser( null ); # Reset
+
+ return $text;
+ }
+
+ /**
+ * Pre-save transform helper function
+ *
+ * @param string $text
+ * @param User $user
+ *
+ * @return string
+ */
+ private function pstPass2( $text, $user ) {
+ global $wgContLang;
+
+ # Note: This is the timestamp saved as hardcoded wikitext to
+ # the database, we use $wgContLang here in order to give
+ # everyone the same signature and use the default one rather
+ # than the one selected in each user's preferences.
+ # (see also T14815)
+ $ts = $this->mOptions->getTimestamp();
+ $timestamp = MWTimestamp::getLocalInstance( $ts );
+ $ts = $timestamp->format( 'YmdHis' );
+ $tzMsg = $timestamp->getTimezoneMessage()->inContentLanguage()->text();
+
+ $d = $wgContLang->timeanddate( $ts, false, false ) . " ($tzMsg)";
+
+ # Variable replacement
+ # Because mOutputType is OT_WIKI, this will only process {{subst:xxx}} type tags
+ $text = $this->replaceVariables( $text );
+
+ # This works almost by chance, as the replaceVariables are done before the getUserSig(),
+ # which may corrupt this parser instance via its wfMessage()->text() call-
+
+ # Signatures
+ if ( strpos( $text, '~~~' ) !== false ) {
+ $sigText = $this->getUserSig( $user );
+ $text = strtr( $text, [
+ '~~~~~' => $d,
+ '~~~~' => "$sigText $d",
+ '~~~' => $sigText
+ ] );
+ # The main two signature forms used above are time-sensitive
+ $this->mOutput->setFlag( 'user-signature' );
+ }
+
+ # Context links ("pipe tricks"): [[|name]] and [[name (context)|]]
+ $tc = '[' . Title::legalChars() . ']';
+ $nc = '[ _0-9A-Za-z\x80-\xff-]'; # Namespaces can use non-ascii!
+
+ // [[ns:page (context)|]]
+ $p1 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\))\\|]]/";
+ // [[ns:page(context)|]] (double-width brackets, added in r40257)
+ $p4 = "/\[\[(:?$nc+:|:|)($tc+?)( ?($tc+))\\|]]/";
+ // [[ns:page (context), context|]] (using either single or double-width comma)
+ $p3 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\)|)((?:, |,)$tc+|)\\|]]/";
+ // [[|page]] (reverse pipe trick: add context from page title)
+ $p2 = "/\[\[\\|($tc+)]]/";
+
+ # try $p1 first, to turn "[[A, B (C)|]]" into "[[A, B (C)|A, B]]"
+ $text = preg_replace( $p1, '[[\\1\\2\\3|\\2]]', $text );
+ $text = preg_replace( $p4, '[[\\1\\2\\3|\\2]]', $text );
+ $text = preg_replace( $p3, '[[\\1\\2\\3\\4|\\2]]', $text );
+
+ $t = $this->mTitle->getText();
+ $m = [];
+ if ( preg_match( "/^($nc+:|)$tc+?( \\($tc+\\))$/", $t, $m ) ) {
+ $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
+ } elseif ( preg_match( "/^($nc+:|)$tc+?(, $tc+|)$/", $t, $m ) && "$m[1]$m[2]" != '' ) {
+ $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
+ } else {
+ # if there's no context, don't bother duplicating the title
+ $text = preg_replace( $p2, '[[\\1]]', $text );
+ }
+
+ return $text;
+ }
+
+ /**
+ * Fetch the user's signature text, if any, and normalize to
+ * validated, ready-to-insert wikitext.
+ * If you have pre-fetched the nickname or the fancySig option, you can
+ * specify them here to save a database query.
+ * Do not reuse this parser instance after calling getUserSig(),
+ * as it may have changed if it's the $wgParser.
+ *
+ * @param User &$user
+ * @param string|bool $nickname Nickname to use or false to use user's default nickname
+ * @param bool|null $fancySig whether the nicknname is the complete signature
+ * or null to use default value
+ * @return string
+ */
+ public function getUserSig( &$user, $nickname = false, $fancySig = null ) {
+ global $wgMaxSigChars;
+
+ $username = $user->getName();
+
+ # If not given, retrieve from the user object.
+ if ( $nickname === false ) {
+ $nickname = $user->getOption( 'nickname' );
+ }
+
+ if ( is_null( $fancySig ) ) {
+ $fancySig = $user->getBoolOption( 'fancysig' );
+ }
+
+ $nickname = $nickname == null ? $username : $nickname;
+
+ if ( mb_strlen( $nickname ) > $wgMaxSigChars ) {
+ $nickname = $username;
+ wfDebug( __METHOD__ . ": $username has overlong signature.\n" );
+ } elseif ( $fancySig !== false ) {
+ # Sig. might contain markup; validate this
+ if ( $this->validateSig( $nickname ) !== false ) {
+ # Validated; clean up (if needed) and return it
+ return $this->cleanSig( $nickname, true );
+ } else {
+ # Failed to validate; fall back to the default
+ $nickname = $username;
+ wfDebug( __METHOD__ . ": $username has bad XML tags in signature.\n" );
+ }
+ }
+
+ # Make sure nickname doesnt get a sig in a sig
+ $nickname = self::cleanSigInSig( $nickname );
+
+ # If we're still here, make it a link to the user page
+ $userText = wfEscapeWikiText( $username );
+ $nickText = wfEscapeWikiText( $nickname );
+ $msgName = $user->isAnon() ? 'signature-anon' : 'signature';
+
+ return wfMessage( $msgName, $userText, $nickText )->inContentLanguage()
+ ->title( $this->getTitle() )->text();
+ }
+
+ /**
+ * Check that the user's signature contains no bad XML
+ *
+ * @param string $text
+ * @return string|bool An expanded string, or false if invalid.
+ */
+ public function validateSig( $text ) {
+ return Xml::isWellFormedXmlFragment( $text ) ? $text : false;
+ }
+
+ /**
+ * Clean up signature text
+ *
+ * 1) Strip 3, 4 or 5 tildes out of signatures @see cleanSigInSig
+ * 2) Substitute all transclusions
+ *
+ * @param string $text
+ * @param bool $parsing Whether we're cleaning (preferences save) or parsing
+ * @return string Signature text
+ */
+ public function cleanSig( $text, $parsing = false ) {
+ if ( !$parsing ) {
+ global $wgTitle;
+ $magicScopeVariable = $this->lock();
+ $this->startParse( $wgTitle, new ParserOptions, self::OT_PREPROCESS, true );
+ }
+
+ # Option to disable this feature
+ if ( !$this->mOptions->getCleanSignatures() ) {
+ return $text;
+ }
+
+ # @todo FIXME: Regex doesn't respect extension tags or nowiki
+ # => Move this logic to braceSubstitution()
+ $substWord = MagicWord::get( 'subst' );
+ $substRegex = '/\{\{(?!(?:' . $substWord->getBaseRegex() . '))/x' . $substWord->getRegexCase();
+ $substText = '{{' . $substWord->getSynonym( 0 );
+
+ $text = preg_replace( $substRegex, $substText, $text );
+ $text = self::cleanSigInSig( $text );
+ $dom = $this->preprocessToDom( $text );
+ $frame = $this->getPreprocessor()->newFrame();
+ $text = $frame->expand( $dom );
+
+ if ( !$parsing ) {
+ $text = $this->mStripState->unstripBoth( $text );
+ }
+
+ return $text;
+ }
+
+ /**
+ * Strip 3, 4 or 5 tildes out of signatures.
+ *
+ * @param string $text
+ * @return string Signature text with /~{3,5}/ removed
+ */
+ public static function cleanSigInSig( $text ) {
+ $text = preg_replace( '/~{3,5}/', '', $text );
+ return $text;
+ }
+
+ /**
+ * Set up some variables which are usually set up in parse()
+ * so that an external function can call some class members with confidence
+ *
+ * @param Title|null $title
+ * @param ParserOptions $options
+ * @param int $outputType
+ * @param bool $clearState
+ */
+ public function startExternalParse( Title $title = null, ParserOptions $options,
+ $outputType, $clearState = true
+ ) {
+ $this->startParse( $title, $options, $outputType, $clearState );
+ }
+
+ /**
+ * @param Title|null $title
+ * @param ParserOptions $options
+ * @param int $outputType
+ * @param bool $clearState
+ */
+ private function startParse( Title $title = null, ParserOptions $options,
+ $outputType, $clearState = true
+ ) {
+ $this->setTitle( $title );
+ $this->mOptions = $options;
+ $this->setOutputType( $outputType );
+ if ( $clearState ) {
+ $this->clearState();
+ }
+ }
+
+ /**
+ * Wrapper for preprocess()
+ *
+ * @param string $text The text to preprocess
+ * @param ParserOptions $options Options
+ * @param Title|null $title Title object or null to use $wgTitle
+ * @return string
+ */
+ public function transformMsg( $text, $options, $title = null ) {
+ static $executing = false;
+
+ # Guard against infinite recursion
+ if ( $executing ) {
+ return $text;
+ }
+ $executing = true;
+
+ if ( !$title ) {
+ global $wgTitle;
+ $title = $wgTitle;
+ }
+
+ $text = $this->preprocess( $text, $title, $options );
+
+ $executing = false;
+ return $text;
+ }
+
+ /**
+ * Create an HTML-style tag, e.g. "<yourtag>special text</yourtag>"
+ * The callback should have the following form:
+ * function myParserHook( $text, $params, $parser, $frame ) { ... }
+ *
+ * Transform and return $text. Use $parser for any required context, e.g. use
+ * $parser->getTitle() and $parser->getOptions() not $wgTitle or $wgOut->mParserOptions
+ *
+ * Hooks may return extended information by returning an array, of which the
+ * first numbered element (index 0) must be the return string, and all other
+ * entries are extracted into local variables within an internal function
+ * in the Parser class.
+ *
+ * This interface (introduced r61913) appears to be undocumented, but
+ * 'markerType' is used by some core tag hooks to override which strip
+ * array their results are placed in. **Use great caution if attempting
+ * this interface, as it is not documented and injudicious use could smash
+ * private variables.**
+ *
+ * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
+ * @param callable $callback The callback function (and object) to use for the tag
+ * @throws MWException
+ * @return callable|null The old value of the mTagHooks array associated with the hook
+ */
+ public function setHook( $tag, callable $callback ) {
+ $tag = strtolower( $tag );
+ if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
+ throw new MWException( "Invalid character {$m[0]} in setHook('$tag', ...) call" );
+ }
+ $oldVal = isset( $this->mTagHooks[$tag] ) ? $this->mTagHooks[$tag] : null;
+ $this->mTagHooks[$tag] = $callback;
+ if ( !in_array( $tag, $this->mStripList ) ) {
+ $this->mStripList[] = $tag;
+ }
+
+ return $oldVal;
+ }
+
+ /**
+ * As setHook(), but letting the contents be parsed.
+ *
+ * Transparent tag hooks are like regular XML-style tag hooks, except they
+ * operate late in the transformation sequence, on HTML instead of wikitext.
+ *
+ * This is probably obsoleted by things dealing with parser frames?
+ * The only extension currently using it is geoserver.
+ *
+ * @since 1.10
+ * @todo better document or deprecate this
+ *
+ * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
+ * @param callable $callback The callback function (and object) to use for the tag
+ * @throws MWException
+ * @return callable|null The old value of the mTagHooks array associated with the hook
+ */
+ public function setTransparentTagHook( $tag, callable $callback ) {
+ $tag = strtolower( $tag );
+ if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
+ throw new MWException( "Invalid character {$m[0]} in setTransparentHook('$tag', ...) call" );
+ }
+ $oldVal = isset( $this->mTransparentTagHooks[$tag] ) ? $this->mTransparentTagHooks[$tag] : null;
+ $this->mTransparentTagHooks[$tag] = $callback;
+
+ return $oldVal;
+ }
+
+ /**
+ * Remove all tag hooks
+ */
+ public function clearTagHooks() {
+ $this->mTagHooks = [];
+ $this->mFunctionTagHooks = [];
+ $this->mStripList = $this->mDefaultStripList;
+ }
+
+ /**
+ * Create a function, e.g. {{sum:1|2|3}}
+ * The callback function should have the form:
+ * function myParserFunction( &$parser, $arg1, $arg2, $arg3 ) { ... }
+ *
+ * Or with Parser::SFH_OBJECT_ARGS:
+ * function myParserFunction( $parser, $frame, $args ) { ... }
+ *
+ * The callback may either return the text result of the function, or an array with the text
+ * in element 0, and a number of flags in the other elements. The names of the flags are
+ * specified in the keys. Valid flags are:
+ * found The text returned is valid, stop processing the template. This
+ * is on by default.
+ * nowiki Wiki markup in the return value should be escaped
+ * isHTML The returned text is HTML, armour it against wikitext transformation
+ *
+ * @param string $id The magic word ID
+ * @param callable $callback The callback function (and object) to use
+ * @param int $flags A combination of the following flags:
+ * Parser::SFH_NO_HASH No leading hash, i.e. {{plural:...}} instead of {{#if:...}}
+ *
+ * Parser::SFH_OBJECT_ARGS Pass the template arguments as PPNode objects instead of text.
+ * This allows for conditional expansion of the parse tree, allowing you to eliminate dead
+ * branches and thus speed up parsing. It is also possible to analyse the parse tree of
+ * the arguments, and to control the way they are expanded.
+ *
+ * The $frame parameter is a PPFrame. This can be used to produce expanded text from the
+ * arguments, for instance:
+ * $text = isset( $args[0] ) ? $frame->expand( $args[0] ) : '';
+ *
+ * For technical reasons, $args[0] is pre-expanded and will be a string. This may change in
+ * future versions. Please call $frame->expand() on it anyway so that your code keeps
+ * working if/when this is changed.
+ *
+ * If you want whitespace to be trimmed from $args, you need to do it yourself, post-
+ * expansion.
+ *
+ * Please read the documentation in includes/parser/Preprocessor.php for more information
+ * about the methods available in PPFrame and PPNode.
+ *
+ * @throws MWException
+ * @return string|callable The old callback function for this name, if any
+ */
+ public function setFunctionHook( $id, callable $callback, $flags = 0 ) {
+ global $wgContLang;
+
+ $oldVal = isset( $this->mFunctionHooks[$id] ) ? $this->mFunctionHooks[$id][0] : null;
+ $this->mFunctionHooks[$id] = [ $callback, $flags ];
+
+ # Add to function cache
+ $mw = MagicWord::get( $id );
+ if ( !$mw ) {
+ throw new MWException( __METHOD__ . '() expecting a magic word identifier.' );
+ }
+
+ $synonyms = $mw->getSynonyms();
+ $sensitive = intval( $mw->isCaseSensitive() );
+
+ foreach ( $synonyms as $syn ) {
+ # Case
+ if ( !$sensitive ) {
+ $syn = $wgContLang->lc( $syn );
+ }
+ # Add leading hash
+ if ( !( $flags & self::SFH_NO_HASH ) ) {
+ $syn = '#' . $syn;
+ }
+ # Remove trailing colon
+ if ( substr( $syn, -1, 1 ) === ':' ) {
+ $syn = substr( $syn, 0, -1 );
+ }
+ $this->mFunctionSynonyms[$sensitive][$syn] = $id;
+ }
+ return $oldVal;
+ }
+
+ /**
+ * Get all registered function hook identifiers
+ *
+ * @return array
+ */
+ public function getFunctionHooks() {
+ return array_keys( $this->mFunctionHooks );
+ }
+
+ /**
+ * Create a tag function, e.g. "<test>some stuff</test>".
+ * Unlike tag hooks, tag functions are parsed at preprocessor level.
+ * Unlike parser functions, their content is not preprocessed.
+ * @param string $tag
+ * @param callable $callback
+ * @param int $flags
+ * @throws MWException
+ * @return null
+ */
+ public function setFunctionTagHook( $tag, callable $callback, $flags ) {
+ $tag = strtolower( $tag );
+ if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
+ throw new MWException( "Invalid character {$m[0]} in setFunctionTagHook('$tag', ...) call" );
+ }
+ $old = isset( $this->mFunctionTagHooks[$tag] ) ?
+ $this->mFunctionTagHooks[$tag] : null;
+ $this->mFunctionTagHooks[$tag] = [ $callback, $flags ];
+
+ if ( !in_array( $tag, $this->mStripList ) ) {
+ $this->mStripList[] = $tag;
+ }
+
+ return $old;
+ }
+
+ /**
+ * Replace "<!--LINK-->" link placeholders with actual links, in the buffer
+ * Placeholders created in Linker::link()
+ *
+ * @param string &$text
+ * @param int $options
+ */
+ public function replaceLinkHolders( &$text, $options = 0 ) {
+ $this->mLinkHolders->replace( $text );
+ }
+
+ /**
+ * Replace "<!--LINK-->" link placeholders with plain text of links
+ * (not HTML-formatted).
+ *
+ * @param string $text
+ * @return string
+ */
+ public function replaceLinkHoldersText( $text ) {
+ return $this->mLinkHolders->replaceText( $text );
+ }
+
+ /**
+ * Renders an image gallery from a text with one line per image.
+ * text labels may be given by using |-style alternative text. E.g.
+ * Image:one.jpg|The number "1"
+ * Image:tree.jpg|A tree
+ * given as text will return the HTML of a gallery with two images,
+ * labeled 'The number "1"' and
+ * 'A tree'.
+ *
+ * @param string $text
+ * @param array $params
+ * @return string HTML
+ */
+ public function renderImageGallery( $text, $params ) {
+ $mode = false;
+ if ( isset( $params['mode'] ) ) {
+ $mode = $params['mode'];
+ }
+
+ try {
+ $ig = ImageGalleryBase::factory( $mode );
+ } catch ( Exception $e ) {
+ // If invalid type set, fallback to default.
+ $ig = ImageGalleryBase::factory( false );
+ }
+
+ $ig->setContextTitle( $this->mTitle );
+ $ig->setShowBytes( false );
+ $ig->setShowDimensions( false );
+ $ig->setShowFilename( false );
+ $ig->setParser( $this );
+ $ig->setHideBadImages();
+ $ig->setAttributes( Sanitizer::validateTagAttributes( $params, 'ul' ) );
+
+ if ( isset( $params['showfilename'] ) ) {
+ $ig->setShowFilename( true );
+ } else {
+ $ig->setShowFilename( false );
+ }
+ if ( isset( $params['caption'] ) ) {
+ $caption = $params['caption'];
+ $caption = htmlspecialchars( $caption );
+ $caption = $this->replaceInternalLinks( $caption );
+ $ig->setCaptionHtml( $caption );
+ }
+ if ( isset( $params['perrow'] ) ) {
+ $ig->setPerRow( $params['perrow'] );
+ }
+ if ( isset( $params['widths'] ) ) {
+ $ig->setWidths( $params['widths'] );
+ }
+ if ( isset( $params['heights'] ) ) {
+ $ig->setHeights( $params['heights'] );
+ }
+ $ig->setAdditionalOptions( $params );
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $parser = $this;
+ Hooks::run( 'BeforeParserrenderImageGallery', [ &$parser, &$ig ] );
+
+ $lines = StringUtils::explode( "\n", $text );
+ foreach ( $lines as $line ) {
+ # match lines like these:
+ # Image:someimage.jpg|This is some image
+ $matches = [];
+ preg_match( "/^([^|]+)(\\|(.*))?$/", $line, $matches );
+ # Skip empty lines
+ if ( count( $matches ) == 0 ) {
+ continue;
+ }
+
+ if ( strpos( $matches[0], '%' ) !== false ) {
+ $matches[1] = rawurldecode( $matches[1] );
+ }
+ $title = Title::newFromText( $matches[1], NS_FILE );
+ if ( is_null( $title ) ) {
+ # Bogus title. Ignore these so we don't bomb out later.
+ continue;
+ }
+
+ # We need to get what handler the file uses, to figure out parameters.
+ # Note, a hook can overide the file name, and chose an entirely different
+ # file (which potentially could be of a different type and have different handler).
+ $options = [];
+ $descQuery = false;
+ Hooks::run( 'BeforeParserFetchFileAndTitle',
+ [ $this, $title, &$options, &$descQuery ] );
+ # Don't register it now, as TraditionalImageGallery does that later.
+ $file = $this->fetchFileNoRegister( $title, $options );
+ $handler = $file ? $file->getHandler() : false;
+
+ $paramMap = [
+ 'img_alt' => 'gallery-internal-alt',
+ 'img_link' => 'gallery-internal-link',
+ ];
+ if ( $handler ) {
+ $paramMap = $paramMap + $handler->getParamMap();
+ // We don't want people to specify per-image widths.
+ // Additionally the width parameter would need special casing anyhow.
+ unset( $paramMap['img_width'] );
+ }
+
+ $mwArray = new MagicWordArray( array_keys( $paramMap ) );
+
+ $label = '';
+ $alt = '';
+ $link = '';
+ $handlerOptions = [];
+ if ( isset( $matches[3] ) ) {
+ // look for an |alt= definition while trying not to break existing
+ // captions with multiple pipes (|) in it, until a more sensible grammar
+ // is defined for images in galleries
+
+ // FIXME: Doing recursiveTagParse at this stage, and the trim before
+ // splitting on '|' is a bit odd, and different from makeImage.
+ $matches[3] = $this->recursiveTagParse( trim( $matches[3] ) );
+ // Protect LanguageConverter markup
+ $parameterMatches = StringUtils::delimiterExplode(
+ '-{', '}-', '|', $matches[3], true /* nested */
+ );
+
+ foreach ( $parameterMatches as $parameterMatch ) {
+ list( $magicName, $match ) = $mwArray->matchVariableStartToEnd( $parameterMatch );
+ if ( $magicName ) {
+ $paramName = $paramMap[$magicName];
+
+ switch ( $paramName ) {
+ case 'gallery-internal-alt':
+ $alt = $this->stripAltText( $match, false );
+ break;
+ case 'gallery-internal-link':
+ $linkValue = strip_tags( $this->replaceLinkHoldersText( $match ) );
+ $chars = self::EXT_LINK_URL_CLASS;
+ $addr = self::EXT_LINK_ADDR;
+ $prots = $this->mUrlProtocols;
+ // check to see if link matches an absolute url, if not then it must be a wiki link.
+ if ( preg_match( '/^-{R|(.*)}-$/', $linkValue ) ) {
+ // Result of LanguageConverter::markNoConversion
+ // invoked on an external link.
+ $linkValue = substr( $linkValue, 4, -2 );
+ }
+ if ( preg_match( "/^($prots)$addr$chars*$/u", $linkValue ) ) {
+ $link = $linkValue;
+ $this->mOutput->addExternalLink( $link );
+ } else {
+ $localLinkTitle = Title::newFromText( $linkValue );
+ if ( $localLinkTitle !== null ) {
+ $this->mOutput->addLink( $localLinkTitle );
+ $link = $localLinkTitle->getLinkURL();
+ }
+ }
+ break;
+ default:
+ // Must be a handler specific parameter.
+ if ( $handler->validateParam( $paramName, $match ) ) {
+ $handlerOptions[$paramName] = $match;
+ } else {
+ // Guess not, consider it as caption.
+ wfDebug( "$parameterMatch failed parameter validation\n" );
+ $label = '|' . $parameterMatch;
+ }
+ }
+
+ } else {
+ // Last pipe wins.
+ $label = '|' . $parameterMatch;
+ }
+ }
+ // Remove the pipe.
+ $label = substr( $label, 1 );
+ }
+
+ $ig->add( $title, $label, $alt, $link, $handlerOptions );
+ }
+ $html = $ig->toHTML();
+ Hooks::run( 'AfterParserFetchFileAndTitle', [ $this, $ig, &$html ] );
+ return $html;
+ }
+
+ /**
+ * @param MediaHandler $handler
+ * @return array
+ */
+ public function getImageParams( $handler ) {
+ if ( $handler ) {
+ $handlerClass = get_class( $handler );
+ } else {
+ $handlerClass = '';
+ }
+ if ( !isset( $this->mImageParams[$handlerClass] ) ) {
+ # Initialise static lists
+ static $internalParamNames = [
+ 'horizAlign' => [ 'left', 'right', 'center', 'none' ],
+ 'vertAlign' => [ 'baseline', 'sub', 'super', 'top', 'text-top', 'middle',
+ 'bottom', 'text-bottom' ],
+ 'frame' => [ 'thumbnail', 'manualthumb', 'framed', 'frameless',
+ 'upright', 'border', 'link', 'alt', 'class' ],
+ ];
+ static $internalParamMap;
+ if ( !$internalParamMap ) {
+ $internalParamMap = [];
+ foreach ( $internalParamNames as $type => $names ) {
+ foreach ( $names as $name ) {
+ // For grep: img_left, img_right, img_center, img_none,
+ // img_baseline, img_sub, img_super, img_top, img_text_top, img_middle,
+ // img_bottom, img_text_bottom,
+ // img_thumbnail, img_manualthumb, img_framed, img_frameless, img_upright,
+ // img_border, img_link, img_alt, img_class
+ $magicName = str_replace( '-', '_', "img_$name" );
+ $internalParamMap[$magicName] = [ $type, $name ];
+ }
+ }
+ }
+
+ # Add handler params
+ $paramMap = $internalParamMap;
+ if ( $handler ) {
+ $handlerParamMap = $handler->getParamMap();
+ foreach ( $handlerParamMap as $magic => $paramName ) {
+ $paramMap[$magic] = [ 'handler', $paramName ];
+ }
+ }
+ $this->mImageParams[$handlerClass] = $paramMap;
+ $this->mImageParamsMagicArray[$handlerClass] = new MagicWordArray( array_keys( $paramMap ) );
+ }
+ return [ $this->mImageParams[$handlerClass], $this->mImageParamsMagicArray[$handlerClass] ];
+ }
+
+ /**
+ * Parse image options text and use it to make an image
+ *
+ * @param Title $title
+ * @param string $options
+ * @param LinkHolderArray|bool $holders
+ * @return string HTML
+ */
+ public function makeImage( $title, $options, $holders = false ) {
+ # Check if the options text is of the form "options|alt text"
+ # Options are:
+ # * thumbnail make a thumbnail with enlarge-icon and caption, alignment depends on lang
+ # * left no resizing, just left align. label is used for alt= only
+ # * right same, but right aligned
+ # * none same, but not aligned
+ # * ___px scale to ___ pixels width, no aligning. e.g. use in taxobox
+ # * center center the image
+ # * frame Keep original image size, no magnify-button.
+ # * framed Same as "frame"
+ # * frameless like 'thumb' but without a frame. Keeps user preferences for width
+ # * upright reduce width for upright images, rounded to full __0 px
+ # * border draw a 1px border around the image
+ # * alt Text for HTML alt attribute (defaults to empty)
+ # * class Set a class for img node
+ # * link Set the target of the image link. Can be external, interwiki, or local
+ # vertical-align values (no % or length right now):
+ # * baseline
+ # * sub
+ # * super
+ # * top
+ # * text-top
+ # * middle
+ # * bottom
+ # * text-bottom
+
+ # Protect LanguageConverter markup when splitting into parts
+ $parts = StringUtils::delimiterExplode(
+ '-{', '}-', '|', $options, true /* allow nesting */
+ );
+
+ # Give extensions a chance to select the file revision for us
+ $options = [];
+ $descQuery = false;
+ Hooks::run( 'BeforeParserFetchFileAndTitle',
+ [ $this, $title, &$options, &$descQuery ] );
+ # Fetch and register the file (file title may be different via hooks)
+ list( $file, $title ) = $this->fetchFileAndTitle( $title, $options );
+
+ # Get parameter map
+ $handler = $file ? $file->getHandler() : false;
+
+ list( $paramMap, $mwArray ) = $this->getImageParams( $handler );
+
+ if ( !$file ) {
+ $this->addTrackingCategory( 'broken-file-category' );
+ }
+
+ # Process the input parameters
+ $caption = '';
+ $params = [ 'frame' => [], 'handler' => [],
+ 'horizAlign' => [], 'vertAlign' => [] ];
+ $seenformat = false;
+ foreach ( $parts as $part ) {
+ $part = trim( $part );
+ list( $magicName, $value ) = $mwArray->matchVariableStartToEnd( $part );
+ $validated = false;
+ if ( isset( $paramMap[$magicName] ) ) {
+ list( $type, $paramName ) = $paramMap[$magicName];
+
+ # Special case; width and height come in one variable together
+ if ( $type === 'handler' && $paramName === 'width' ) {
+ $parsedWidthParam = $this->parseWidthParam( $value );
+ if ( isset( $parsedWidthParam['width'] ) ) {
+ $width = $parsedWidthParam['width'];
+ if ( $handler->validateParam( 'width', $width ) ) {
+ $params[$type]['width'] = $width;
+ $validated = true;
+ }
+ }
+ if ( isset( $parsedWidthParam['height'] ) ) {
+ $height = $parsedWidthParam['height'];
+ if ( $handler->validateParam( 'height', $height ) ) {
+ $params[$type]['height'] = $height;
+ $validated = true;
+ }
+ }
+ # else no validation -- T15436
+ } else {
+ if ( $type === 'handler' ) {
+ # Validate handler parameter
+ $validated = $handler->validateParam( $paramName, $value );
+ } else {
+ # Validate internal parameters
+ switch ( $paramName ) {
+ case 'manualthumb':
+ case 'alt':
+ case 'class':
+ # @todo FIXME: Possibly check validity here for
+ # manualthumb? downstream behavior seems odd with
+ # missing manual thumbs.
+ $validated = true;
+ $value = $this->stripAltText( $value, $holders );
+ break;
+ case 'link':
+ $chars = self::EXT_LINK_URL_CLASS;
+ $addr = self::EXT_LINK_ADDR;
+ $prots = $this->mUrlProtocols;
+ if ( $value === '' ) {
+ $paramName = 'no-link';
+ $value = true;
+ $validated = true;
+ } elseif ( preg_match( "/^((?i)$prots)/", $value ) ) {
+ if ( preg_match( "/^((?i)$prots)$addr$chars*$/u", $value, $m ) ) {
+ $paramName = 'link-url';
+ $this->mOutput->addExternalLink( $value );
+ if ( $this->mOptions->getExternalLinkTarget() ) {
+ $params[$type]['link-target'] = $this->mOptions->getExternalLinkTarget();
+ }
+ $validated = true;
+ }
+ } else {
+ $linkTitle = Title::newFromText( $value );
+ if ( $linkTitle ) {
+ $paramName = 'link-title';
+ $value = $linkTitle;
+ $this->mOutput->addLink( $linkTitle );
+ $validated = true;
+ }
+ }
+ break;
+ case 'frameless':
+ case 'framed':
+ case 'thumbnail':
+ // use first appearing option, discard others.
+ $validated = !$seenformat;
+ $seenformat = true;
+ break;
+ default:
+ # Most other things appear to be empty or numeric...
+ $validated = ( $value === false || is_numeric( trim( $value ) ) );
+ }
+ }
+
+ if ( $validated ) {
+ $params[$type][$paramName] = $value;
+ }
+ }
+ }
+ if ( !$validated ) {
+ $caption = $part;
+ }
+ }
+
+ # Process alignment parameters
+ if ( $params['horizAlign'] ) {
+ $params['frame']['align'] = key( $params['horizAlign'] );
+ }
+ if ( $params['vertAlign'] ) {
+ $params['frame']['valign'] = key( $params['vertAlign'] );
+ }
+
+ $params['frame']['caption'] = $caption;
+
+ # Will the image be presented in a frame, with the caption below?
+ $imageIsFramed = isset( $params['frame']['frame'] )
+ || isset( $params['frame']['framed'] )
+ || isset( $params['frame']['thumbnail'] )
+ || isset( $params['frame']['manualthumb'] );
+
+ # In the old days, [[Image:Foo|text...]] would set alt text. Later it
+ # came to also set the caption, ordinary text after the image -- which
+ # makes no sense, because that just repeats the text multiple times in
+ # screen readers. It *also* came to set the title attribute.
+ # Now that we have an alt attribute, we should not set the alt text to
+ # equal the caption: that's worse than useless, it just repeats the
+ # text. This is the framed/thumbnail case. If there's no caption, we
+ # use the unnamed parameter for alt text as well, just for the time be-
+ # ing, if the unnamed param is set and the alt param is not.
+ # For the future, we need to figure out if we want to tweak this more,
+ # e.g., introducing a title= parameter for the title; ignoring the un-
+ # named parameter entirely for images without a caption; adding an ex-
+ # plicit caption= parameter and preserving the old magic unnamed para-
+ # meter for BC; ...
+ if ( $imageIsFramed ) { # Framed image
+ if ( $caption === '' && !isset( $params['frame']['alt'] ) ) {
+ # No caption or alt text, add the filename as the alt text so
+ # that screen readers at least get some description of the image
+ $params['frame']['alt'] = $title->getText();
+ }
+ # Do not set $params['frame']['title'] because tooltips don't make sense
+ # for framed images
+ } else { # Inline image
+ if ( !isset( $params['frame']['alt'] ) ) {
+ # No alt text, use the "caption" for the alt text
+ if ( $caption !== '' ) {
+ $params['frame']['alt'] = $this->stripAltText( $caption, $holders );
+ } else {
+ # No caption, fall back to using the filename for the
+ # alt text
+ $params['frame']['alt'] = $title->getText();
+ }
+ }
+ # Use the "caption" for the tooltip text
+ $params['frame']['title'] = $this->stripAltText( $caption, $holders );
+ }
+
+ Hooks::run( 'ParserMakeImageParams', [ $title, $file, &$params, $this ] );
+
+ # Linker does the rest
+ $time = isset( $options['time'] ) ? $options['time'] : false;
+ $ret = Linker::makeImageLink( $this, $title, $file, $params['frame'], $params['handler'],
+ $time, $descQuery, $this->mOptions->getThumbSize() );
+
+ # Give the handler a chance to modify the parser object
+ if ( $handler ) {
+ $handler->parserTransformHook( $this, $file );
+ }
+
+ return $ret;
+ }
+
+ /**
+ * @param string $caption
+ * @param LinkHolderArray|bool $holders
+ * @return mixed|string
+ */
+ protected function stripAltText( $caption, $holders ) {
+ # Strip bad stuff out of the title (tooltip). We can't just use
+ # replaceLinkHoldersText() here, because if this function is called
+ # from replaceInternalLinks2(), mLinkHolders won't be up-to-date.
+ if ( $holders ) {
+ $tooltip = $holders->replaceText( $caption );
+ } else {
+ $tooltip = $this->replaceLinkHoldersText( $caption );
+ }
+
+ # make sure there are no placeholders in thumbnail attributes
+ # that are later expanded to html- so expand them now and
+ # remove the tags
+ $tooltip = $this->mStripState->unstripBoth( $tooltip );
+ $tooltip = Sanitizer::stripAllTags( $tooltip );
+
+ return $tooltip;
+ }
+
+ /**
+ * Set a flag in the output object indicating that the content is dynamic and
+ * shouldn't be cached.
+ * @deprecated since 1.28; use getOutput()->updateCacheExpiry()
+ */
+ public function disableCache() {
+ wfDebug( "Parser output marked as uncacheable.\n" );
+ if ( !$this->mOutput ) {
+ throw new MWException( __METHOD__ .
+ " can only be called when actually parsing something" );
+ }
+ $this->mOutput->updateCacheExpiry( 0 ); // new style, for consistency
+ }
+
+ /**
+ * Callback from the Sanitizer for expanding items found in HTML attribute
+ * values, so they can be safely tested and escaped.
+ *
+ * @param string &$text
+ * @param bool|PPFrame $frame
+ * @return string
+ */
+ public function attributeStripCallback( &$text, $frame = false ) {
+ $text = $this->replaceVariables( $text, $frame );
+ $text = $this->mStripState->unstripBoth( $text );
+ return $text;
+ }
+
+ /**
+ * Accessor
+ *
+ * @return array
+ */
+ public function getTags() {
+ return array_merge(
+ array_keys( $this->mTransparentTagHooks ),
+ array_keys( $this->mTagHooks ),
+ array_keys( $this->mFunctionTagHooks )
+ );
+ }
+
+ /**
+ * Replace transparent tags in $text with the values given by the callbacks.
+ *
+ * Transparent tag hooks are like regular XML-style tag hooks, except they
+ * operate late in the transformation sequence, on HTML instead of wikitext.
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ public function replaceTransparentTags( $text ) {
+ $matches = [];
+ $elements = array_keys( $this->mTransparentTagHooks );
+ $text = self::extractTagsAndParams( $elements, $text, $matches );
+ $replacements = [];
+
+ foreach ( $matches as $marker => $data ) {
+ list( $element, $content, $params, $tag ) = $data;
+ $tagName = strtolower( $element );
+ if ( isset( $this->mTransparentTagHooks[$tagName] ) ) {
+ $output = call_user_func_array(
+ $this->mTransparentTagHooks[$tagName],
+ [ $content, $params, $this ]
+ );
+ } else {
+ $output = $tag;
+ }
+ $replacements[$marker] = $output;
+ }
+ return strtr( $text, $replacements );
+ }
+
+ /**
+ * Break wikitext input into sections, and either pull or replace
+ * some particular section's text.
+ *
+ * External callers should use the getSection and replaceSection methods.
+ *
+ * @param string $text Page wikitext
+ * @param string|int $sectionId A section identifier string of the form:
+ * "<flag1> - <flag2> - ... - <section number>"
+ *
+ * Currently the only recognised flag is "T", which means the target section number
+ * was derived during a template inclusion parse, in other words this is a template
+ * section edit link. If no flags are given, it was an ordinary section edit link.
+ * This flag is required to avoid a section numbering mismatch when a section is
+ * enclosed by "<includeonly>" (T8563).
+ *
+ * The section number 0 pulls the text before the first heading; other numbers will
+ * pull the given section along with its lower-level subsections. If the section is
+ * not found, $mode=get will return $newtext, and $mode=replace will return $text.
+ *
+ * Section 0 is always considered to exist, even if it only contains the empty
+ * string. If $text is the empty string and section 0 is replaced, $newText is
+ * returned.
+ *
+ * @param string $mode One of "get" or "replace"
+ * @param string $newText Replacement text for section data.
+ * @return string For "get", the extracted section text.
+ * for "replace", the whole page with the section replaced.
+ */
+ private function extractSections( $text, $sectionId, $mode, $newText = '' ) {
+ global $wgTitle; # not generally used but removes an ugly failure mode
+
+ $magicScopeVariable = $this->lock();
+ $this->startParse( $wgTitle, new ParserOptions, self::OT_PLAIN, true );
+ $outText = '';
+ $frame = $this->getPreprocessor()->newFrame();
+
+ # Process section extraction flags
+ $flags = 0;
+ $sectionParts = explode( '-', $sectionId );
+ $sectionIndex = array_pop( $sectionParts );
+ foreach ( $sectionParts as $part ) {
+ if ( $part === 'T' ) {
+ $flags |= self::PTD_FOR_INCLUSION;
+ }
+ }
+
+ # Check for empty input
+ if ( strval( $text ) === '' ) {
+ # Only sections 0 and T-0 exist in an empty document
+ if ( $sectionIndex == 0 ) {
+ if ( $mode === 'get' ) {
+ return '';
+ } else {
+ return $newText;
+ }
+ } else {
+ if ( $mode === 'get' ) {
+ return $newText;
+ } else {
+ return $text;
+ }
+ }
+ }
+
+ # Preprocess the text
+ $root = $this->preprocessToDom( $text, $flags );
+
+ # <h> nodes indicate section breaks
+ # They can only occur at the top level, so we can find them by iterating the root's children
+ $node = $root->getFirstChild();
+
+ # Find the target section
+ if ( $sectionIndex == 0 ) {
+ # Section zero doesn't nest, level=big
+ $targetLevel = 1000;
+ } else {
+ while ( $node ) {
+ if ( $node->getName() === 'h' ) {
+ $bits = $node->splitHeading();
+ if ( $bits['i'] == $sectionIndex ) {
+ $targetLevel = $bits['level'];
+ break;
+ }
+ }
+ if ( $mode === 'replace' ) {
+ $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
+ }
+ $node = $node->getNextSibling();
+ }
+ }
+
+ if ( !$node ) {
+ # Not found
+ if ( $mode === 'get' ) {
+ return $newText;
+ } else {
+ return $text;
+ }
+ }
+
+ # Find the end of the section, including nested sections
+ do {
+ if ( $node->getName() === 'h' ) {
+ $bits = $node->splitHeading();
+ $curLevel = $bits['level'];
+ if ( $bits['i'] != $sectionIndex && $curLevel <= $targetLevel ) {
+ break;
+ }
+ }
+ if ( $mode === 'get' ) {
+ $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
+ }
+ $node = $node->getNextSibling();
+ } while ( $node );
+
+ # Write out the remainder (in replace mode only)
+ if ( $mode === 'replace' ) {
+ # Output the replacement text
+ # Add two newlines on -- trailing whitespace in $newText is conventionally
+ # stripped by the editor, so we need both newlines to restore the paragraph gap
+ # Only add trailing whitespace if there is newText
+ if ( $newText != "" ) {
+ $outText .= $newText . "\n\n";
+ }
+
+ while ( $node ) {
+ $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
+ $node = $node->getNextSibling();
+ }
+ }
+
+ if ( is_string( $outText ) ) {
+ # Re-insert stripped tags
+ $outText = rtrim( $this->mStripState->unstripBoth( $outText ) );
+ }
+
+ return $outText;
+ }
+
+ /**
+ * This function returns the text of a section, specified by a number ($section).
+ * A section is text under a heading like == Heading == or \<h1\>Heading\</h1\>, or
+ * the first section before any such heading (section 0).
+ *
+ * If a section contains subsections, these are also returned.
+ *
+ * @param string $text Text to look in
+ * @param string|int $sectionId Section identifier as a number or string
+ * (e.g. 0, 1 or 'T-1').
+ * @param string $defaultText Default to return if section is not found
+ *
+ * @return string Text of the requested section
+ */
+ public function getSection( $text, $sectionId, $defaultText = '' ) {
+ return $this->extractSections( $text, $sectionId, 'get', $defaultText );
+ }
+
+ /**
+ * This function returns $oldtext after the content of the section
+ * specified by $section has been replaced with $text. If the target
+ * section does not exist, $oldtext is returned unchanged.
+ *
+ * @param string $oldText Former text of the article
+ * @param string|int $sectionId Section identifier as a number or string
+ * (e.g. 0, 1 or 'T-1').
+ * @param string $newText Replacing text
+ *
+ * @return string Modified text
+ */
+ public function replaceSection( $oldText, $sectionId, $newText ) {
+ return $this->extractSections( $oldText, $sectionId, 'replace', $newText );
+ }
+
+ /**
+ * Get the ID of the revision we are parsing
+ *
+ * @return int|null
+ */
+ public function getRevisionId() {
+ return $this->mRevisionId;
+ }
+
+ /**
+ * Get the revision object for $this->mRevisionId
+ *
+ * @return Revision|null Either a Revision object or null
+ * @since 1.23 (public since 1.23)
+ */
+ public function getRevisionObject() {
+ if ( !is_null( $this->mRevisionObject ) ) {
+ return $this->mRevisionObject;
+ }
+ if ( is_null( $this->mRevisionId ) ) {
+ return null;
+ }
+
+ $rev = call_user_func(
+ $this->mOptions->getCurrentRevisionCallback(), $this->getTitle(), $this
+ );
+
+ # If the parse is for a new revision, then the callback should have
+ # already been set to force the object and should match mRevisionId.
+ # If not, try to fetch by mRevisionId for sanity.
+ if ( $rev && $rev->getId() != $this->mRevisionId ) {
+ $rev = Revision::newFromId( $this->mRevisionId );
+ }
+
+ $this->mRevisionObject = $rev;
+
+ return $this->mRevisionObject;
+ }
+
+ /**
+ * Get the timestamp associated with the current revision, adjusted for
+ * the default server-local timestamp
+ * @return string
+ */
+ public function getRevisionTimestamp() {
+ if ( is_null( $this->mRevisionTimestamp ) ) {
+ global $wgContLang;
+
+ $revObject = $this->getRevisionObject();
+ $timestamp = $revObject ? $revObject->getTimestamp() : wfTimestampNow();
+
+ # The cryptic '' timezone parameter tells to use the site-default
+ # timezone offset instead of the user settings.
+ # Since this value will be saved into the parser cache, served
+ # to other users, and potentially even used inside links and such,
+ # it needs to be consistent for all visitors.
+ $this->mRevisionTimestamp = $wgContLang->userAdjust( $timestamp, '' );
+
+ }
+ return $this->mRevisionTimestamp;
+ }
+
+ /**
+ * Get the name of the user that edited the last revision
+ *
+ * @return string User name
+ */
+ public function getRevisionUser() {
+ if ( is_null( $this->mRevisionUser ) ) {
+ $revObject = $this->getRevisionObject();
+
+ # if this template is subst: the revision id will be blank,
+ # so just use the current user's name
+ if ( $revObject ) {
+ $this->mRevisionUser = $revObject->getUserText();
+ } elseif ( $this->ot['wiki'] || $this->mOptions->getIsPreview() ) {
+ $this->mRevisionUser = $this->getUser()->getName();
+ }
+ }
+ return $this->mRevisionUser;
+ }
+
+ /**
+ * Get the size of the revision
+ *
+ * @return int|null Revision size
+ */
+ public function getRevisionSize() {
+ if ( is_null( $this->mRevisionSize ) ) {
+ $revObject = $this->getRevisionObject();
+
+ # if this variable is subst: the revision id will be blank,
+ # so just use the parser input size, because the own substituation
+ # will change the size.
+ if ( $revObject ) {
+ $this->mRevisionSize = $revObject->getSize();
+ } else {
+ $this->mRevisionSize = $this->mInputSize;
+ }
+ }
+ return $this->mRevisionSize;
+ }
+
+ /**
+ * Mutator for $mDefaultSort
+ *
+ * @param string $sort New value
+ */
+ public function setDefaultSort( $sort ) {
+ $this->mDefaultSort = $sort;
+ $this->mOutput->setProperty( 'defaultsort', $sort );
+ }
+
+ /**
+ * Accessor for $mDefaultSort
+ * Will use the empty string if none is set.
+ *
+ * This value is treated as a prefix, so the
+ * empty string is equivalent to sorting by
+ * page name.
+ *
+ * @return string
+ */
+ public function getDefaultSort() {
+ if ( $this->mDefaultSort !== false ) {
+ return $this->mDefaultSort;
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Accessor for $mDefaultSort
+ * Unlike getDefaultSort(), will return false if none is set
+ *
+ * @return string|bool
+ */
+ public function getCustomDefaultSort() {
+ return $this->mDefaultSort;
+ }
+
+ /**
+ * Try to guess the section anchor name based on a wikitext fragment
+ * presumably extracted from a heading, for example "Header" from
+ * "== Header ==".
+ *
+ * @param string $text
+ *
+ * @return string
+ */
+ public function guessSectionNameFromWikiText( $text ) {
+ # Strip out wikitext links(they break the anchor)
+ $text = $this->stripSectionName( $text );
+ $text = Sanitizer::normalizeSectionNameWhitespace( $text );
+ $text = Sanitizer::decodeCharReferences( $text );
+ return '#' . Sanitizer::escapeIdForLink( $text );
+ }
+
+ /**
+ * Same as guessSectionNameFromWikiText(), but produces legacy anchors
+ * instead, if possible. For use in redirects, since various versions
+ * of Microsoft browsers interpret Location: headers as something other
+ * than UTF-8, resulting in breakage.
+ *
+ * @param string $text The section name
+ * @return string An anchor
+ */
+ public function guessLegacySectionNameFromWikiText( $text ) {
+ global $wgFragmentMode;
+
+ # Strip out wikitext links(they break the anchor)
+ $text = $this->stripSectionName( $text );
+ $text = Sanitizer::normalizeSectionNameWhitespace( $text );
+ $text = Sanitizer::decodeCharReferences( $text );
+
+ if ( isset( $wgFragmentMode[1] ) && $wgFragmentMode[1] === 'legacy' ) {
+ // ForAttribute() and ForLink() are the same for legacy encoding
+ $id = Sanitizer::escapeIdForAttribute( $text, Sanitizer::ID_FALLBACK );
+ } else {
+ $id = Sanitizer::escapeIdForLink( $text );
+ }
+
+ return "#$id";
+ }
+
+ /**
+ * Strips a text string of wikitext for use in a section anchor
+ *
+ * Accepts a text string and then removes all wikitext from the
+ * string and leaves only the resultant text (i.e. the result of
+ * [[User:WikiSysop|Sysop]] would be "Sysop" and the result of
+ * [[User:WikiSysop]] would be "User:WikiSysop") - this is intended
+ * to create valid section anchors by mimicing the output of the
+ * parser when headings are parsed.
+ *
+ * @param string $text Text string to be stripped of wikitext
+ * for use in a Section anchor
+ * @return string Filtered text string
+ */
+ public function stripSectionName( $text ) {
+ # Strip internal link markup
+ $text = preg_replace( '/\[\[:?([^[|]+)\|([^[]+)\]\]/', '$2', $text );
+ $text = preg_replace( '/\[\[:?([^[]+)\|?\]\]/', '$1', $text );
+
+ # Strip external link markup
+ # @todo FIXME: Not tolerant to blank link text
+ # I.E. [https://www.mediawiki.org] will render as [1] or something depending
+ # on how many empty links there are on the page - need to figure that out.
+ $text = preg_replace( '/\[(?i:' . $this->mUrlProtocols . ')([^ ]+?) ([^[]+)\]/', '$2', $text );
+
+ # Parse wikitext quotes (italics & bold)
+ $text = $this->doQuotes( $text );
+
+ # Strip HTML tags
+ $text = StringUtils::delimiterReplace( '<', '>', '', $text );
+ return $text;
+ }
+
+ /**
+ * strip/replaceVariables/unstrip for preprocessor regression testing
+ *
+ * @param string $text
+ * @param Title $title
+ * @param ParserOptions $options
+ * @param int $outputType
+ *
+ * @return string
+ */
+ public function testSrvus( $text, Title $title, ParserOptions $options,
+ $outputType = self::OT_HTML
+ ) {
+ $magicScopeVariable = $this->lock();
+ $this->startParse( $title, $options, $outputType, true );
+
+ $text = $this->replaceVariables( $text );
+ $text = $this->mStripState->unstripBoth( $text );
+ $text = Sanitizer::removeHTMLtags( $text );
+ return $text;
+ }
+
+ /**
+ * @param string $text
+ * @param Title $title
+ * @param ParserOptions $options
+ * @return string
+ */
+ public function testPst( $text, Title $title, ParserOptions $options ) {
+ return $this->preSaveTransform( $text, $title, $options->getUser(), $options );
+ }
+
+ /**
+ * @param string $text
+ * @param Title $title
+ * @param ParserOptions $options
+ * @return string
+ */
+ public function testPreprocess( $text, Title $title, ParserOptions $options ) {
+ return $this->testSrvus( $text, $title, $options, self::OT_PREPROCESS );
+ }
+
+ /**
+ * Call a callback function on all regions of the given text that are not
+ * inside strip markers, and replace those regions with the return value
+ * of the callback. For example, with input:
+ *
+ * aaa<MARKER>bbb
+ *
+ * This will call the callback function twice, with 'aaa' and 'bbb'. Those
+ * two strings will be replaced with the value returned by the callback in
+ * each case.
+ *
+ * @param string $s
+ * @param callable $callback
+ *
+ * @return string
+ */
+ public function markerSkipCallback( $s, $callback ) {
+ $i = 0;
+ $out = '';
+ while ( $i < strlen( $s ) ) {
+ $markerStart = strpos( $s, self::MARKER_PREFIX, $i );
+ if ( $markerStart === false ) {
+ $out .= call_user_func( $callback, substr( $s, $i ) );
+ break;
+ } else {
+ $out .= call_user_func( $callback, substr( $s, $i, $markerStart - $i ) );
+ $markerEnd = strpos( $s, self::MARKER_SUFFIX, $markerStart );
+ if ( $markerEnd === false ) {
+ $out .= substr( $s, $markerStart );
+ break;
+ } else {
+ $markerEnd += strlen( self::MARKER_SUFFIX );
+ $out .= substr( $s, $markerStart, $markerEnd - $markerStart );
+ $i = $markerEnd;
+ }
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Remove any strip markers found in the given text.
+ *
+ * @param string $text Input string
+ * @return string
+ */
+ public function killMarkers( $text ) {
+ return $this->mStripState->killMarkers( $text );
+ }
+
+ /**
+ * Save the parser state required to convert the given half-parsed text to
+ * HTML. "Half-parsed" in this context means the output of
+ * recursiveTagParse() or internalParse(). This output has strip markers
+ * from replaceVariables (extensionSubstitution() etc.), and link
+ * placeholders from replaceLinkHolders().
+ *
+ * Returns an array which can be serialized and stored persistently. This
+ * array can later be loaded into another parser instance with
+ * unserializeHalfParsedText(). The text can then be safely incorporated into
+ * the return value of a parser hook.
+ *
+ * @param string $text
+ *
+ * @return array
+ */
+ public function serializeHalfParsedText( $text ) {
+ $data = [
+ 'text' => $text,
+ 'version' => self::HALF_PARSED_VERSION,
+ 'stripState' => $this->mStripState->getSubState( $text ),
+ 'linkHolders' => $this->mLinkHolders->getSubArray( $text )
+ ];
+ return $data;
+ }
+
+ /**
+ * Load the parser state given in the $data array, which is assumed to
+ * have been generated by serializeHalfParsedText(). The text contents is
+ * extracted from the array, and its markers are transformed into markers
+ * appropriate for the current Parser instance. This transformed text is
+ * returned, and can be safely included in the return value of a parser
+ * hook.
+ *
+ * If the $data array has been stored persistently, the caller should first
+ * check whether it is still valid, by calling isValidHalfParsedText().
+ *
+ * @param array $data Serialized data
+ * @throws MWException
+ * @return string
+ */
+ public function unserializeHalfParsedText( $data ) {
+ if ( !isset( $data['version'] ) || $data['version'] != self::HALF_PARSED_VERSION ) {
+ throw new MWException( __METHOD__ . ': invalid version' );
+ }
+
+ # First, extract the strip state.
+ $texts = [ $data['text'] ];
+ $texts = $this->mStripState->merge( $data['stripState'], $texts );
+
+ # Now renumber links
+ $texts = $this->mLinkHolders->mergeForeign( $data['linkHolders'], $texts );
+
+ # Should be good to go.
+ return $texts[0];
+ }
+
+ /**
+ * Returns true if the given array, presumed to be generated by
+ * serializeHalfParsedText(), is compatible with the current version of the
+ * parser.
+ *
+ * @param array $data
+ *
+ * @return bool
+ */
+ public function isValidHalfParsedText( $data ) {
+ return isset( $data['version'] ) && $data['version'] == self::HALF_PARSED_VERSION;
+ }
+
+ /**
+ * Parsed a width param of imagelink like 300px or 200x300px
+ *
+ * @param string $value
+ *
+ * @return array
+ * @since 1.20
+ */
+ public function parseWidthParam( $value ) {
+ $parsedWidthParam = [];
+ if ( $value === '' ) {
+ return $parsedWidthParam;
+ }
+ $m = [];
+ # (T15500) In both cases (width/height and width only),
+ # permit trailing "px" for backward compatibility.
+ if ( preg_match( '/^([0-9]*)x([0-9]*)\s*(?:px)?\s*$/', $value, $m ) ) {
+ $width = intval( $m[1] );
+ $height = intval( $m[2] );
+ $parsedWidthParam['width'] = $width;
+ $parsedWidthParam['height'] = $height;
+ } elseif ( preg_match( '/^[0-9]*\s*(?:px)?\s*$/', $value ) ) {
+ $width = intval( $value );
+ $parsedWidthParam['width'] = $width;
+ }
+ return $parsedWidthParam;
+ }
+
+ /**
+ * Lock the current instance of the parser.
+ *
+ * This is meant to stop someone from calling the parser
+ * recursively and messing up all the strip state.
+ *
+ * @throws MWException If parser is in a parse
+ * @return ScopedCallback The lock will be released once the return value goes out of scope.
+ */
+ protected function lock() {
+ if ( $this->mInParse ) {
+ throw new MWException( "Parser state cleared while parsing. "
+ . "Did you call Parser::parse recursively? Lock is held by: " . $this->mInParse );
+ }
+
+ // Save the backtrace when locking, so that if some code tries locking again,
+ // we can print the lock owner's backtrace for easier debugging
+ $e = new Exception;
+ $this->mInParse = $e->getTraceAsString();
+
+ $recursiveCheck = new ScopedCallback( function () {
+ $this->mInParse = false;
+ } );
+
+ return $recursiveCheck;
+ }
+
+ /**
+ * Strip outer <p></p> tag from the HTML source of a single paragraph.
+ *
+ * Returns original HTML if the <p/> tag has any attributes, if there's no wrapping <p/> tag,
+ * or if there is more than one <p/> tag in the input HTML.
+ *
+ * @param string $html
+ * @return string
+ * @since 1.24
+ */
+ public static function stripOuterParagraph( $html ) {
+ $m = [];
+ if ( preg_match( '/^<p>(.*)\n?<\/p>\n?$/sU', $html, $m ) ) {
+ if ( strpos( $m[1], '</p>' ) === false ) {
+ $html = $m[1];
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * Return this parser if it is not doing anything, otherwise
+ * get a fresh parser. You can use this method by doing
+ * $myParser = $wgParser->getFreshParser(), or more simply
+ * $wgParser->getFreshParser()->parse( ... );
+ * if you're unsure if $wgParser is safe to use.
+ *
+ * @since 1.24
+ * @return Parser A parser object that is not parsing anything
+ */
+ public function getFreshParser() {
+ global $wgParserConf;
+ if ( $this->mInParse ) {
+ return new $wgParserConf['class']( $wgParserConf );
+ } else {
+ return $this;
+ }
+ }
+
+ /**
+ * Set's up the PHP implementation of OOUI for use in this request
+ * and instructs OutputPage to enable OOUI for itself.
+ *
+ * @since 1.26
+ */
+ public function enableOOUI() {
+ OutputPage::setupOOUI();
+ $this->mOutput->setEnableOOUI( true );
+ }
+}
diff --git a/www/wiki/includes/parser/ParserCache.php b/www/wiki/includes/parser/ParserCache.php
new file mode 100644
index 00000000..c6801299
--- /dev/null
+++ b/www/wiki/includes/parser/ParserCache.php
@@ -0,0 +1,359 @@
+<?php
+/**
+ * Cache for outputs of the PHP parser
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache Parser
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @ingroup Cache Parser
+ * @todo document
+ */
+class ParserCache {
+ /**
+ * Constants for self::getKey()
+ * @since 1.30
+ */
+
+ /** Use only current data */
+ const USE_CURRENT_ONLY = 0;
+
+ /** Use expired data if current data is unavailable */
+ const USE_EXPIRED = 1;
+
+ /** Use expired data or data from different revisions if current data is unavailable */
+ const USE_OUTDATED = 2;
+
+ /**
+ * Use expired data and data from different revisions, and if all else
+ * fails vary on all variable options
+ */
+ const USE_ANYTHING = 3;
+
+ /** @var BagOStuff */
+ private $mMemc;
+
+ /**
+ * Anything cached prior to this is invalidated
+ *
+ * @var string
+ */
+ private $cacheEpoch;
+ /**
+ * Get an instance of this object
+ *
+ * @deprecated since 1.30, use MediaWikiServices instead
+ * @return ParserCache
+ */
+ public static function singleton() {
+ return MediaWikiServices::getInstance()->getParserCache();
+ }
+
+ /**
+ * Setup a cache pathway with a given back-end storage mechanism.
+ *
+ * This class use an invalidation strategy that is compatible with
+ * MultiWriteBagOStuff in async replication mode.
+ *
+ * @param BagOStuff $cache
+ * @param string $cacheEpoch Anything before this timestamp is invalidated
+ * @throws MWException
+ */
+ public function __construct( BagOStuff $cache, $cacheEpoch = '20030516000000' ) {
+ $this->mMemc = $cache;
+ $this->cacheEpoch = $cacheEpoch;
+ }
+
+ /**
+ * @param WikiPage $article
+ * @param string $hash
+ * @return mixed|string
+ */
+ protected function getParserOutputKey( $article, $hash ) {
+ global $wgRequest;
+
+ // idhash seem to mean 'page id' + 'rendering hash' (r3710)
+ $pageid = $article->getId();
+ $renderkey = (int)( $wgRequest->getVal( 'action' ) == 'render' );
+
+ $key = $this->mMemc->makeKey( 'pcache', 'idhash', "{$pageid}-{$renderkey}!{$hash}" );
+ return $key;
+ }
+
+ /**
+ * @param WikiPage $page
+ * @return mixed|string
+ */
+ protected function getOptionsKey( $page ) {
+ return $this->mMemc->makeKey( 'pcache', 'idoptions', $page->getId() );
+ }
+
+ /**
+ * @param WikiPage $page
+ * @since 1.28
+ */
+ public function deleteOptionsKey( $page ) {
+ $this->mMemc->delete( $this->getOptionsKey( $page ) );
+ }
+
+ /**
+ * Provides an E-Tag suitable for the whole page. Note that $article
+ * is just the main wikitext. The E-Tag has to be unique to the whole
+ * page, even if the article itself is the same, so it uses the
+ * complete set of user options. We don't want to use the preference
+ * of a different user on a message just because it wasn't used in
+ * $article. For example give a Chinese interface to a user with
+ * English preferences. That's why we take into account *all* user
+ * options. (r70809 CR)
+ *
+ * @param WikiPage $article
+ * @param ParserOptions $popts
+ * @return string
+ */
+ public function getETag( $article, $popts ) {
+ return 'W/"' . $this->getParserOutputKey( $article,
+ $popts->optionsHash( ParserOptions::allCacheVaryingOptions(), $article->getTitle() ) ) .
+ "--" . $article->getTouched() . '"';
+ }
+
+ /**
+ * Retrieve the ParserOutput from ParserCache, even if it's outdated.
+ * @param WikiPage $article
+ * @param ParserOptions $popts
+ * @return ParserOutput|bool False on failure
+ */
+ public function getDirty( $article, $popts ) {
+ $value = $this->get( $article, $popts, true );
+ return is_object( $value ) ? $value : false;
+ }
+
+ /**
+ * Generates a key for caching the given article considering
+ * the given parser options.
+ *
+ * @note Which parser options influence the cache key
+ * is controlled via ParserOutput::recordOption() or
+ * ParserOptions::addExtraKey().
+ *
+ * @note Used by Article to provide a unique id for the PoolCounter.
+ * It would be preferable to have this code in get()
+ * instead of having Article looking in our internals.
+ *
+ * @param WikiPage $article
+ * @param ParserOptions $popts
+ * @param int|bool $useOutdated One of the USE constants. For backwards
+ * compatibility, boolean false is treated as USE_CURRENT_ONLY and
+ * boolean true is treated as USE_ANYTHING.
+ * @return bool|mixed|string
+ * @since 1.30 Changed $useOutdated to an int and added the non-boolean values
+ */
+ public function getKey( $article, $popts, $useOutdated = self::USE_ANYTHING ) {
+ if ( is_bool( $useOutdated ) ) {
+ $useOutdated = $useOutdated ? self::USE_ANYTHING : self::USE_CURRENT_ONLY;
+ }
+
+ if ( $popts instanceof User ) {
+ wfWarn( "Use of outdated prototype ParserCache::getKey( &\$article, &\$user )\n" );
+ $popts = ParserOptions::newFromUser( $popts );
+ }
+
+ // Determine the options which affect this article
+ $casToken = null;
+ $optionsKey = $this->mMemc->get(
+ $this->getOptionsKey( $article ), $casToken, BagOStuff::READ_VERIFIED );
+ if ( $optionsKey instanceof CacheTime ) {
+ if ( $useOutdated < self::USE_EXPIRED && $optionsKey->expired( $article->getTouched() ) ) {
+ wfIncrStats( "pcache.miss.expired" );
+ $cacheTime = $optionsKey->getCacheTime();
+ wfDebugLog( "ParserCache",
+ "Parser options key expired, touched " . $article->getTouched()
+ . ", epoch {$this->cacheEpoch}, cached $cacheTime\n" );
+ return false;
+ } elseif ( $useOutdated < self::USE_OUTDATED &&
+ $optionsKey->isDifferentRevision( $article->getLatest() )
+ ) {
+ wfIncrStats( "pcache.miss.revid" );
+ $revId = $article->getLatest();
+ $cachedRevId = $optionsKey->getCacheRevisionId();
+ wfDebugLog( "ParserCache",
+ "ParserOutput key is for an old revision, latest $revId, cached $cachedRevId\n"
+ );
+ return false;
+ }
+
+ // $optionsKey->mUsedOptions is set by save() by calling ParserOutput::getUsedOptions()
+ $usedOptions = $optionsKey->mUsedOptions;
+ wfDebug( "Parser cache options found.\n" );
+ } else {
+ if ( $useOutdated < self::USE_ANYTHING ) {
+ return false;
+ }
+ $usedOptions = ParserOptions::allCacheVaryingOptions();
+ }
+
+ return $this->getParserOutputKey(
+ $article,
+ $popts->optionsHash( $usedOptions, $article->getTitle() )
+ );
+ }
+
+ /**
+ * Retrieve the ParserOutput from ParserCache.
+ * false if not found or outdated.
+ *
+ * @param WikiPage|Article $article
+ * @param ParserOptions $popts
+ * @param bool $useOutdated (default false)
+ *
+ * @return ParserOutput|bool False on failure
+ */
+ public function get( $article, $popts, $useOutdated = false ) {
+ $canCache = $article->checkTouched();
+ if ( !$canCache ) {
+ // It's a redirect now
+ return false;
+ }
+
+ $touched = $article->getTouched();
+
+ $parserOutputKey = $this->getKey( $article, $popts,
+ $useOutdated ? self::USE_OUTDATED : self::USE_CURRENT_ONLY
+ );
+ if ( $parserOutputKey === false ) {
+ wfIncrStats( 'pcache.miss.absent' );
+ return false;
+ }
+
+ $casToken = null;
+ /** @var ParserOutput $value */
+ $value = $this->mMemc->get( $parserOutputKey, $casToken, BagOStuff::READ_VERIFIED );
+ if ( !$value ) {
+ wfDebug( "ParserOutput cache miss.\n" );
+ wfIncrStats( "pcache.miss.absent" );
+ return false;
+ }
+
+ wfDebug( "ParserOutput cache found.\n" );
+
+ // The edit section preference may not be the appropiate one in
+ // the ParserOutput, as we are not storing it in the parsercache
+ // key. Force it here. See T33445.
+ $value->setEditSectionTokens( $popts->getEditSection() );
+
+ $wikiPage = method_exists( $article, 'getPage' )
+ ? $article->getPage()
+ : $article;
+
+ if ( !$useOutdated && $value->expired( $touched ) ) {
+ wfIncrStats( "pcache.miss.expired" );
+ $cacheTime = $value->getCacheTime();
+ wfDebugLog( "ParserCache",
+ "ParserOutput key expired, touched $touched, "
+ . "epoch {$this->cacheEpoch}, cached $cacheTime\n" );
+ $value = false;
+ } elseif ( !$useOutdated && $value->isDifferentRevision( $article->getLatest() ) ) {
+ wfIncrStats( "pcache.miss.revid" );
+ $revId = $article->getLatest();
+ $cachedRevId = $value->getCacheRevisionId();
+ wfDebugLog( "ParserCache",
+ "ParserOutput key is for an old revision, latest $revId, cached $cachedRevId\n"
+ );
+ $value = false;
+ } elseif (
+ Hooks::run( 'RejectParserCacheValue', [ $value, $wikiPage, $popts ] ) === false
+ ) {
+ wfIncrStats( 'pcache.miss.rejected' );
+ wfDebugLog( "ParserCache",
+ "ParserOutput key valid, but rejected by RejectParserCacheValue hook handler.\n"
+ );
+ $value = false;
+ } else {
+ wfIncrStats( "pcache.hit" );
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param ParserOutput $parserOutput
+ * @param WikiPage $page
+ * @param ParserOptions $popts
+ * @param string $cacheTime Time when the cache was generated
+ * @param int $revId Revision ID that was parsed
+ */
+ public function save( $parserOutput, $page, $popts, $cacheTime = null, $revId = null ) {
+ $expire = $parserOutput->getCacheExpiry();
+ if ( $expire > 0 && !$this->mMemc instanceof EmptyBagOStuff ) {
+ $cacheTime = $cacheTime ?: wfTimestampNow();
+ if ( !$revId ) {
+ $revision = $page->getRevision();
+ $revId = $revision ? $revision->getId() : null;
+ }
+
+ $optionsKey = new CacheTime;
+ $optionsKey->mUsedOptions = $parserOutput->getUsedOptions();
+ $optionsKey->updateCacheExpiry( $expire );
+
+ $optionsKey->setCacheTime( $cacheTime );
+ $parserOutput->setCacheTime( $cacheTime );
+ $optionsKey->setCacheRevisionId( $revId );
+ $parserOutput->setCacheRevisionId( $revId );
+
+ $parserOutputKey = $this->getParserOutputKey( $page,
+ $popts->optionsHash( $optionsKey->mUsedOptions, $page->getTitle() ) );
+
+ // Save the timestamp so that we don't have to load the revision row on view
+ $parserOutput->setTimestamp( $page->getTimestamp() );
+
+ $msg = "Saved in parser cache with key $parserOutputKey" .
+ " and timestamp $cacheTime" .
+ " and revision id $revId" .
+ "\n";
+
+ $parserOutput->mText .= "\n<!-- $msg -->\n";
+ wfDebug( $msg );
+
+ // Save the parser output
+ $this->mMemc->set( $parserOutputKey, $parserOutput, $expire );
+
+ // ...and its pointer
+ $this->mMemc->set( $this->getOptionsKey( $page ), $optionsKey, $expire );
+
+ Hooks::run(
+ 'ParserCacheSaveComplete',
+ [ $this, $parserOutput, $page->getTitle(), $popts, $revId ]
+ );
+ } elseif ( $expire <= 0 ) {
+ wfDebug( "Parser output was marked as uncacheable and has not been saved.\n" );
+ }
+ }
+
+ /**
+ * Get the backend BagOStuff instance that
+ * powers the parser cache
+ *
+ * @since 1.30
+ * @return BagOStuff
+ */
+ public function getCacheStorage() {
+ return $this->mMemc;
+ }
+}
diff --git a/www/wiki/includes/parser/ParserDiffTest.php b/www/wiki/includes/parser/ParserDiffTest.php
new file mode 100644
index 00000000..353825a8
--- /dev/null
+++ b/www/wiki/includes/parser/ParserDiffTest.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * Fake parser that output the difference of two different parsers
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * @ingroup Parser
+ */
+class ParserDiffTest {
+ public $parsers;
+ public $conf;
+ public $shortOutput = false;
+
+ public function __construct( $conf ) {
+ if ( !isset( $conf['parsers'] ) ) {
+ throw new MWException( __METHOD__ . ': no parsers specified' );
+ }
+ $this->conf = $conf;
+ }
+
+ public function init() {
+ if ( !is_null( $this->parsers ) ) {
+ return;
+ }
+
+ if ( isset( $this->conf['shortOutput'] ) ) {
+ $this->shortOutput = $this->conf['shortOutput'];
+ }
+
+ foreach ( $this->conf['parsers'] as $i => $parserConf ) {
+ if ( !is_array( $parserConf ) ) {
+ $class = $parserConf;
+ $parserConf = [ 'class' => $parserConf ];
+ } else {
+ $class = $parserConf['class'];
+ }
+ $this->parsers[$i] = new $class( $parserConf );
+ }
+ }
+
+ public function __call( $name, $args ) {
+ $this->init();
+ $results = [];
+ $mismatch = false;
+ $lastResult = null;
+ $first = true;
+ foreach ( $this->parsers as $i => $parser ) {
+ $currentResult = call_user_func_array( [ &$this->parsers[$i], $name ], $args );
+ if ( $first ) {
+ $first = false;
+ } else {
+ if ( is_object( $lastResult ) ) {
+ if ( $lastResult != $currentResult ) {
+ $mismatch = true;
+ }
+ } else {
+ if ( $lastResult !== $currentResult ) {
+ $mismatch = true;
+ }
+ }
+ }
+ $results[$i] = $currentResult;
+ $lastResult = $currentResult;
+ }
+ if ( $mismatch ) {
+ if ( count( $results ) == 2 ) {
+ $resultsList = [];
+ foreach ( $this->parsers as $i => $parser ) {
+ $resultsList[] = var_export( $results[$i], true );
+ }
+ $diff = wfDiff( $resultsList[0], $resultsList[1] );
+ } else {
+ $diff = '[too many parsers]';
+ }
+ $msg = "ParserDiffTest: results mismatch on call to $name\n";
+ if ( !$this->shortOutput ) {
+ $msg .= 'Arguments: ' . $this->formatArray( $args ) . "\n";
+ }
+ $msg .= 'Results: ' . $this->formatArray( $results ) . "\n" .
+ "Diff: $diff\n";
+ throw new MWException( $msg );
+ }
+ return $lastResult;
+ }
+
+ public function formatArray( $array ) {
+ if ( $this->shortOutput ) {
+ foreach ( $array as $key => $value ) {
+ if ( $value instanceof ParserOutput ) {
+ $array[$key] = "ParserOutput: {$value->getText()}";
+ }
+ }
+ }
+ return var_export( $array, true );
+ }
+
+ public function setFunctionHook( $id, $callback, $flags = 0 ) {
+ $this->init();
+ foreach ( $this->parsers as $parser ) {
+ $parser->setFunctionHook( $id, $callback, $flags );
+ }
+ }
+}
diff --git a/www/wiki/includes/parser/ParserOptions.php b/www/wiki/includes/parser/ParserOptions.php
new file mode 100644
index 00000000..c7146a13
--- /dev/null
+++ b/www/wiki/includes/parser/ParserOptions.php
@@ -0,0 +1,1409 @@
+<?php
+/**
+ * Options for the PHP parser
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+use Wikimedia\ScopedCallback;
+
+/**
+ * @brief Set options of the Parser
+ *
+ * How to add an option in core:
+ * 1. Add it to one of the arrays in ParserOptions::setDefaults()
+ * 2. If necessary, add an entry to ParserOptions::$inCacheKey
+ * 3. Add a getter and setter in the section for that.
+ *
+ * How to add an option in an extension:
+ * 1. Use the 'ParserOptionsRegister' hook to register it.
+ * 2. Where necessary, use $popt->getOption() and $popt->setOption()
+ * to access it.
+ *
+ * @ingroup Parser
+ */
+class ParserOptions {
+
+ /**
+ * Default values for all options that are relevant for caching.
+ * @see self::getDefaults()
+ * @var array|null
+ */
+ private static $defaults = null;
+
+ /**
+ * Lazy-loaded options
+ * @var callback[]
+ */
+ private static $lazyOptions = [
+ 'dateformat' => [ __CLASS__, 'initDateFormat' ],
+ ];
+
+ /**
+ * Specify options that are included in the cache key
+ * @var array
+ */
+ private static $inCacheKey = [
+ 'dateformat' => true,
+ 'numberheadings' => true,
+ 'thumbsize' => true,
+ 'stubthreshold' => true,
+ 'printable' => true,
+ 'userlang' => true,
+ 'wrapclass' => true,
+ ];
+
+ /**
+ * Current values for all options that are relevant for caching.
+ * @var array
+ */
+ private $options;
+
+ /**
+ * Timestamp used for {{CURRENTDAY}} etc.
+ * @var string|null
+ * @note Caching based on parse time is handled externally
+ */
+ private $mTimestamp;
+
+ /**
+ * The edit section flag is in ParserOptions for historical reasons, but
+ * doesn't actually affect the parser output since Feb 2015.
+ * @var bool
+ */
+ private $mEditSection = true;
+
+ /**
+ * Stored user object
+ * @var User
+ * @todo Track this for caching somehow without fragmenting the cache insanely
+ */
+ private $mUser;
+
+ /**
+ * Function to be called when an option is accessed.
+ * @var callable|null
+ * @note Used for collecting used options, does not affect caching
+ */
+ private $onAccessCallback = null;
+
+ /**
+ * If the page being parsed is a redirect, this should hold the redirect
+ * target.
+ * @var Title|null
+ * @todo Track this for caching somehow
+ */
+ private $redirectTarget = null;
+
+ /**
+ * Appended to the options hash
+ */
+ private $mExtraKey = '';
+
+ /**
+ * @name Option accessors
+ * @{
+ */
+
+ /**
+ * Fetch an option, generically
+ * @since 1.30
+ * @param string $name Option name
+ * @return mixed
+ */
+ public function getOption( $name ) {
+ if ( !array_key_exists( $name, $this->options ) ) {
+ throw new InvalidArgumentException( "Unknown parser option $name" );
+ }
+
+ if ( isset( self::$lazyOptions[$name] ) && $this->options[$name] === null ) {
+ $this->options[$name] = call_user_func( self::$lazyOptions[$name], $this, $name );
+ }
+ if ( !empty( self::$inCacheKey[$name] ) ) {
+ $this->optionUsed( $name );
+ }
+ return $this->options[$name];
+ }
+
+ /**
+ * Set an option, generically
+ * @since 1.30
+ * @param string $name Option name
+ * @param mixed $value New value. Passing null will set null, unlike many
+ * of the existing accessors which ignore null for historical reasons.
+ * @return mixed Old value
+ */
+ public function setOption( $name, $value ) {
+ if ( !array_key_exists( $name, $this->options ) ) {
+ throw new InvalidArgumentException( "Unknown parser option $name" );
+ }
+ $old = $this->options[$name];
+ $this->options[$name] = $value;
+ return $old;
+ }
+
+ /**
+ * Legacy implementation
+ * @since 1.30 For implementing legacy setters only. Don't use this in new code.
+ * @deprecated since 1.30
+ * @param string $name Option name
+ * @param mixed $value New value. Passing null does not set the value.
+ * @return mixed Old value
+ */
+ protected function setOptionLegacy( $name, $value ) {
+ if ( !array_key_exists( $name, $this->options ) ) {
+ throw new InvalidArgumentException( "Unknown parser option $name" );
+ }
+ return wfSetVar( $this->options[$name], $value );
+ }
+
+ /**
+ * Whether to extract interlanguage links
+ *
+ * When true, interlanguage links will be returned by
+ * ParserOutput::getLanguageLinks() instead of generating link HTML.
+ *
+ * @return bool
+ */
+ public function getInterwikiMagic() {
+ return $this->getOption( 'interwikiMagic' );
+ }
+
+ /**
+ * Specify whether to extract interlanguage links
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setInterwikiMagic( $x ) {
+ return $this->setOptionLegacy( 'interwikiMagic', $x );
+ }
+
+ /**
+ * Allow all external images inline?
+ * @return bool
+ */
+ public function getAllowExternalImages() {
+ return $this->getOption( 'allowExternalImages' );
+ }
+
+ /**
+ * Allow all external images inline?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setAllowExternalImages( $x ) {
+ return $this->setOptionLegacy( 'allowExternalImages', $x );
+ }
+
+ /**
+ * External images to allow
+ *
+ * When self::getAllowExternalImages() is false
+ *
+ * @return string|string[] URLs to allow
+ */
+ public function getAllowExternalImagesFrom() {
+ return $this->getOption( 'allowExternalImagesFrom' );
+ }
+
+ /**
+ * External images to allow
+ *
+ * When self::getAllowExternalImages() is false
+ *
+ * @param string|string[]|null $x New value (null is no change)
+ * @return string|string[] Old value
+ */
+ public function setAllowExternalImagesFrom( $x ) {
+ return $this->setOptionLegacy( 'allowExternalImagesFrom', $x );
+ }
+
+ /**
+ * Use the on-wiki external image whitelist?
+ * @return bool
+ */
+ public function getEnableImageWhitelist() {
+ return $this->getOption( 'enableImageWhitelist' );
+ }
+
+ /**
+ * Use the on-wiki external image whitelist?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setEnableImageWhitelist( $x ) {
+ return $this->setOptionLegacy( 'enableImageWhitelist', $x );
+ }
+
+ /**
+ * Automatically number headings?
+ * @return bool
+ */
+ public function getNumberHeadings() {
+ return $this->getOption( 'numberheadings' );
+ }
+
+ /**
+ * Automatically number headings?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setNumberHeadings( $x ) {
+ return $this->setOptionLegacy( 'numberheadings', $x );
+ }
+
+ /**
+ * Allow inclusion of special pages?
+ * @return bool
+ */
+ public function getAllowSpecialInclusion() {
+ return $this->getOption( 'allowSpecialInclusion' );
+ }
+
+ /**
+ * Allow inclusion of special pages?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setAllowSpecialInclusion( $x ) {
+ return $this->setOptionLegacy( 'allowSpecialInclusion', $x );
+ }
+
+ /**
+ * Use tidy to cleanup output HTML?
+ * @return bool
+ */
+ public function getTidy() {
+ return $this->getOption( 'tidy' );
+ }
+
+ /**
+ * Use tidy to cleanup output HTML?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setTidy( $x ) {
+ return $this->setOptionLegacy( 'tidy', $x );
+ }
+
+ /**
+ * Parsing an interface message?
+ * @return bool
+ */
+ public function getInterfaceMessage() {
+ return $this->getOption( 'interfaceMessage' );
+ }
+
+ /**
+ * Parsing an interface message?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setInterfaceMessage( $x ) {
+ return $this->setOptionLegacy( 'interfaceMessage', $x );
+ }
+
+ /**
+ * Target language for the parse
+ * @return Language|null
+ */
+ public function getTargetLanguage() {
+ return $this->getOption( 'targetLanguage' );
+ }
+
+ /**
+ * Target language for the parse
+ * @param Language|null $x New value
+ * @return Language|null Old value
+ */
+ public function setTargetLanguage( $x ) {
+ return $this->setOption( 'targetLanguage', $x );
+ }
+
+ /**
+ * Maximum size of template expansions, in bytes
+ * @return int
+ */
+ public function getMaxIncludeSize() {
+ return $this->getOption( 'maxIncludeSize' );
+ }
+
+ /**
+ * Maximum size of template expansions, in bytes
+ * @param int|null $x New value (null is no change)
+ * @return int Old value
+ */
+ public function setMaxIncludeSize( $x ) {
+ return $this->setOptionLegacy( 'maxIncludeSize', $x );
+ }
+
+ /**
+ * Maximum number of nodes touched by PPFrame::expand()
+ * @return int
+ */
+ public function getMaxPPNodeCount() {
+ return $this->getOption( 'maxPPNodeCount' );
+ }
+
+ /**
+ * Maximum number of nodes touched by PPFrame::expand()
+ * @param int|null $x New value (null is no change)
+ * @return int Old value
+ */
+ public function setMaxPPNodeCount( $x ) {
+ return $this->setOptionLegacy( 'maxPPNodeCount', $x );
+ }
+
+ /**
+ * Maximum number of nodes generated by Preprocessor::preprocessToObj()
+ * @return int
+ */
+ public function getMaxGeneratedPPNodeCount() {
+ return $this->getOption( 'maxGeneratedPPNodeCount' );
+ }
+
+ /**
+ * Maximum number of nodes generated by Preprocessor::preprocessToObj()
+ * @param int|null $x New value (null is no change)
+ * @return int
+ */
+ public function setMaxGeneratedPPNodeCount( $x ) {
+ return $this->setOptionLegacy( 'maxGeneratedPPNodeCount', $x );
+ }
+
+ /**
+ * Maximum recursion depth in PPFrame::expand()
+ * @return int
+ */
+ public function getMaxPPExpandDepth() {
+ return $this->getOption( 'maxPPExpandDepth' );
+ }
+
+ /**
+ * Maximum recursion depth for templates within templates
+ * @return int
+ */
+ public function getMaxTemplateDepth() {
+ return $this->getOption( 'maxTemplateDepth' );
+ }
+
+ /**
+ * Maximum recursion depth for templates within templates
+ * @param int|null $x New value (null is no change)
+ * @return int Old value
+ */
+ public function setMaxTemplateDepth( $x ) {
+ return $this->setOptionLegacy( 'maxTemplateDepth', $x );
+ }
+
+ /**
+ * Maximum number of calls per parse to expensive parser functions
+ * @since 1.20
+ * @return int
+ */
+ public function getExpensiveParserFunctionLimit() {
+ return $this->getOption( 'expensiveParserFunctionLimit' );
+ }
+
+ /**
+ * Maximum number of calls per parse to expensive parser functions
+ * @since 1.20
+ * @param int|null $x New value (null is no change)
+ * @return int Old value
+ */
+ public function setExpensiveParserFunctionLimit( $x ) {
+ return $this->setOptionLegacy( 'expensiveParserFunctionLimit', $x );
+ }
+
+ /**
+ * Remove HTML comments
+ * @warning Only applies to preprocess operations
+ * @return bool
+ */
+ public function getRemoveComments() {
+ return $this->getOption( 'removeComments' );
+ }
+
+ /**
+ * Remove HTML comments
+ * @warning Only applies to preprocess operations
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setRemoveComments( $x ) {
+ return $this->setOptionLegacy( 'removeComments', $x );
+ }
+
+ /**
+ * Enable limit report in an HTML comment on output
+ * @return bool
+ */
+ public function getEnableLimitReport() {
+ return $this->getOption( 'enableLimitReport' );
+ }
+
+ /**
+ * Enable limit report in an HTML comment on output
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function enableLimitReport( $x = true ) {
+ return $this->setOptionLegacy( 'enableLimitReport', $x );
+ }
+
+ /**
+ * Clean up signature texts?
+ * @see Parser::cleanSig
+ * @return bool
+ */
+ public function getCleanSignatures() {
+ return $this->getOption( 'cleanSignatures' );
+ }
+
+ /**
+ * Clean up signature texts?
+ * @see Parser::cleanSig
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setCleanSignatures( $x ) {
+ return $this->setOptionLegacy( 'cleanSignatures', $x );
+ }
+
+ /**
+ * Target attribute for external links
+ * @return string
+ */
+ public function getExternalLinkTarget() {
+ return $this->getOption( 'externalLinkTarget' );
+ }
+
+ /**
+ * Target attribute for external links
+ * @param string|null $x New value (null is no change)
+ * @return string Old value
+ */
+ public function setExternalLinkTarget( $x ) {
+ return $this->setOptionLegacy( 'externalLinkTarget', $x );
+ }
+
+ /**
+ * Whether content conversion should be disabled
+ * @return bool
+ */
+ public function getDisableContentConversion() {
+ return $this->getOption( 'disableContentConversion' );
+ }
+
+ /**
+ * Whether content conversion should be disabled
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function disableContentConversion( $x = true ) {
+ return $this->setOptionLegacy( 'disableContentConversion', $x );
+ }
+
+ /**
+ * Whether title conversion should be disabled
+ * @return bool
+ */
+ public function getDisableTitleConversion() {
+ return $this->getOption( 'disableTitleConversion' );
+ }
+
+ /**
+ * Whether title conversion should be disabled
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function disableTitleConversion( $x = true ) {
+ return $this->setOptionLegacy( 'disableTitleConversion', $x );
+ }
+
+ /**
+ * Thumb size preferred by the user.
+ * @return int
+ */
+ public function getThumbSize() {
+ return $this->getOption( 'thumbsize' );
+ }
+
+ /**
+ * Thumb size preferred by the user.
+ * @param int|null $x New value (null is no change)
+ * @return int Old value
+ */
+ public function setThumbSize( $x ) {
+ return $this->setOptionLegacy( 'thumbsize', $x );
+ }
+
+ /**
+ * Thumb size preferred by the user.
+ * @return int
+ */
+ public function getStubThreshold() {
+ return $this->getOption( 'stubthreshold' );
+ }
+
+ /**
+ * Thumb size preferred by the user.
+ * @param int|null $x New value (null is no change)
+ * @return int Old value
+ */
+ public function setStubThreshold( $x ) {
+ return $this->setOptionLegacy( 'stubthreshold', $x );
+ }
+
+ /**
+ * Parsing the page for a "preview" operation?
+ * @return bool
+ */
+ public function getIsPreview() {
+ return $this->getOption( 'isPreview' );
+ }
+
+ /**
+ * Parsing the page for a "preview" operation?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setIsPreview( $x ) {
+ return $this->setOptionLegacy( 'isPreview', $x );
+ }
+
+ /**
+ * Parsing the page for a "preview" operation on a single section?
+ * @return bool
+ */
+ public function getIsSectionPreview() {
+ return $this->getOption( 'isSectionPreview' );
+ }
+
+ /**
+ * Parsing the page for a "preview" operation on a single section?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setIsSectionPreview( $x ) {
+ return $this->setOptionLegacy( 'isSectionPreview', $x );
+ }
+
+ /**
+ * Parsing the printable version of the page?
+ * @return bool
+ */
+ public function getIsPrintable() {
+ return $this->getOption( 'printable' );
+ }
+
+ /**
+ * Parsing the printable version of the page?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setIsPrintable( $x ) {
+ return $this->setOptionLegacy( 'printable', $x );
+ }
+
+ /**
+ * Transform wiki markup when saving the page?
+ * @return bool
+ */
+ public function getPreSaveTransform() {
+ return $this->getOption( 'preSaveTransform' );
+ }
+
+ /**
+ * Transform wiki markup when saving the page?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setPreSaveTransform( $x ) {
+ return $this->setOptionLegacy( 'preSaveTransform', $x );
+ }
+
+ /**
+ * Date format index
+ * @return string
+ */
+ public function getDateFormat() {
+ return $this->getOption( 'dateformat' );
+ }
+
+ /**
+ * Lazy initializer for dateFormat
+ */
+ private static function initDateFormat( $popt ) {
+ return $popt->mUser->getDatePreference();
+ }
+
+ /**
+ * Date format index
+ * @param string|null $x New value (null is no change)
+ * @return string Old value
+ */
+ public function setDateFormat( $x ) {
+ return $this->setOptionLegacy( 'dateformat', $x );
+ }
+
+ /**
+ * Get the user language used by the parser for this page and split the parser cache.
+ *
+ * @warning: Calling this causes the parser cache to be fragmented by user language!
+ * To avoid cache fragmentation, output should not depend on the user language.
+ * Use Parser::getFunctionLang() or Parser::getTargetLanguage() instead!
+ *
+ * @note This function will trigger a cache fragmentation by recording the
+ * 'userlang' option, see optionUsed(). This is done to avoid cache pollution
+ * when the page is rendered based on the language of the user.
+ *
+ * @note When saving, this will return the default language instead of the user's.
+ * {{int: }} uses this which used to produce inconsistent link tables (T16404).
+ *
+ * @return Language
+ * @since 1.19
+ */
+ public function getUserLangObj() {
+ return $this->getOption( 'userlang' );
+ }
+
+ /**
+ * Same as getUserLangObj() but returns a string instead.
+ *
+ * @warning: Calling this causes the parser cache to be fragmented by user language!
+ * To avoid cache fragmentation, output should not depend on the user language.
+ * Use Parser::getFunctionLang() or Parser::getTargetLanguage() instead!
+ *
+ * @see getUserLangObj()
+ *
+ * @return string Language code
+ * @since 1.17
+ */
+ public function getUserLang() {
+ return $this->getUserLangObj()->getCode();
+ }
+
+ /**
+ * Set the user language used by the parser for this page and split the parser cache.
+ * @param string|Language $x New value
+ * @return Language Old value
+ */
+ public function setUserLang( $x ) {
+ if ( is_string( $x ) ) {
+ $x = Language::factory( $x );
+ }
+
+ return $this->setOptionLegacy( 'userlang', $x );
+ }
+
+ /**
+ * Are magic ISBN links enabled?
+ * @since 1.28
+ * @return bool
+ */
+ public function getMagicISBNLinks() {
+ return $this->getOption( 'magicISBNLinks' );
+ }
+
+ /**
+ * Are magic PMID links enabled?
+ * @since 1.28
+ * @return bool
+ */
+ public function getMagicPMIDLinks() {
+ return $this->getOption( 'magicPMIDLinks' );
+ }
+ /**
+ * Are magic RFC links enabled?
+ * @since 1.28
+ * @return bool
+ */
+ public function getMagicRFCLinks() {
+ return $this->getOption( 'magicRFCLinks' );
+ }
+
+ /**
+ * If the wiki is configured to allow raw html ($wgRawHtml = true)
+ * is it allowed in the specific case of parsing this page.
+ *
+ * This is meant to disable unsafe parser tags in cases where
+ * a malicious user may control the input to the parser.
+ *
+ * @note This is expected to be true for normal pages even if the
+ * wiki has $wgRawHtml disabled in general. The setting only
+ * signifies that raw html would be unsafe in the current context
+ * provided that raw html is allowed at all.
+ * @since 1.29
+ * @return bool
+ */
+ public function getAllowUnsafeRawHtml() {
+ return $this->getOption( 'allowUnsafeRawHtml' );
+ }
+
+ /**
+ * If the wiki is configured to allow raw html ($wgRawHtml = true)
+ * is it allowed in the specific case of parsing this page.
+ * @see self::getAllowUnsafeRawHtml()
+ * @since 1.29
+ * @param bool|null $x Value to set or null to get current value
+ * @return bool Current value for allowUnsafeRawHtml
+ */
+ public function setAllowUnsafeRawHtml( $x ) {
+ return $this->setOptionLegacy( 'allowUnsafeRawHtml', $x );
+ }
+
+ /**
+ * Class to use to wrap output from Parser::parse()
+ * @since 1.30
+ * @return string|bool
+ */
+ public function getWrapOutputClass() {
+ return $this->getOption( 'wrapclass' );
+ }
+
+ /**
+ * CSS class to use to wrap output from Parser::parse()
+ * @since 1.30
+ * @param string|bool $className Set false to disable wrapping.
+ * @return string|bool Current value
+ */
+ public function setWrapOutputClass( $className ) {
+ if ( $className === true ) { // DWIM, they probably want the default class name
+ $className = 'mw-parser-output';
+ }
+ return $this->setOption( 'wrapclass', $className );
+ }
+
+ /**
+ * Callback for current revision fetching; first argument to call_user_func().
+ * @since 1.24
+ * @return callable
+ */
+ public function getCurrentRevisionCallback() {
+ return $this->getOption( 'currentRevisionCallback' );
+ }
+
+ /**
+ * Callback for current revision fetching; first argument to call_user_func().
+ * @since 1.24
+ * @param callable|null $x New value (null is no change)
+ * @return callable Old value
+ */
+ public function setCurrentRevisionCallback( $x ) {
+ return $this->setOptionLegacy( 'currentRevisionCallback', $x );
+ }
+
+ /**
+ * Callback for template fetching; first argument to call_user_func().
+ * @return callable
+ */
+ public function getTemplateCallback() {
+ return $this->getOption( 'templateCallback' );
+ }
+
+ /**
+ * Callback for template fetching; first argument to call_user_func().
+ * @param callable|null $x New value (null is no change)
+ * @return callable Old value
+ */
+ public function setTemplateCallback( $x ) {
+ return $this->setOptionLegacy( 'templateCallback', $x );
+ }
+
+ /**
+ * Callback to generate a guess for {{REVISIONID}}
+ * @since 1.28
+ * @return callable|null
+ */
+ public function getSpeculativeRevIdCallback() {
+ return $this->getOption( 'speculativeRevIdCallback' );
+ }
+
+ /**
+ * Callback to generate a guess for {{REVISIONID}}
+ * @since 1.28
+ * @param callable|null $x New value (null is no change)
+ * @return callable|null Old value
+ */
+ public function setSpeculativeRevIdCallback( $x ) {
+ return $this->setOptionLegacy( 'speculativeRevIdCallback', $x );
+ }
+
+ /**@}*/
+
+ /**
+ * Timestamp used for {{CURRENTDAY}} etc.
+ * @return string
+ */
+ public function getTimestamp() {
+ if ( !isset( $this->mTimestamp ) ) {
+ $this->mTimestamp = wfTimestampNow();
+ }
+ return $this->mTimestamp;
+ }
+
+ /**
+ * Timestamp used for {{CURRENTDAY}} etc.
+ * @param string|null $x New value (null is no change)
+ * @return string Old value
+ */
+ public function setTimestamp( $x ) {
+ return wfSetVar( $this->mTimestamp, $x );
+ }
+
+ /**
+ * Create "edit section" links?
+ * @return bool
+ */
+ public function getEditSection() {
+ return $this->mEditSection;
+ }
+
+ /**
+ * Create "edit section" links?
+ * @param bool|null $x New value (null is no change)
+ * @return bool Old value
+ */
+ public function setEditSection( $x ) {
+ return wfSetVar( $this->mEditSection, $x );
+ }
+
+ /**
+ * Set the redirect target.
+ *
+ * Note that setting or changing this does not *make* the page a redirect
+ * or change its target, it merely records the information for reference
+ * during the parse.
+ *
+ * @since 1.24
+ * @param Title|null $title
+ */
+ function setRedirectTarget( $title ) {
+ $this->redirectTarget = $title;
+ }
+
+ /**
+ * Get the previously-set redirect target.
+ *
+ * @since 1.24
+ * @return Title|null
+ */
+ function getRedirectTarget() {
+ return $this->redirectTarget;
+ }
+
+ /**
+ * Extra key that should be present in the parser cache key.
+ * @warning Consider registering your additional options with the
+ * ParserOptionsRegister hook instead of using this method.
+ * @param string $key
+ */
+ public function addExtraKey( $key ) {
+ $this->mExtraKey .= '!' . $key;
+ }
+
+ /**
+ * Current user
+ * @return User
+ */
+ public function getUser() {
+ return $this->mUser;
+ }
+
+ /**
+ * @warning For interaction with the parser cache, use
+ * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or
+ * ParserOptions::newCanonical() instead.
+ * @param User $user
+ * @param Language $lang
+ */
+ public function __construct( $user = null, $lang = null ) {
+ if ( $user === null ) {
+ global $wgUser;
+ if ( $wgUser === null ) {
+ $user = new User;
+ } else {
+ $user = $wgUser;
+ }
+ }
+ if ( $lang === null ) {
+ global $wgLang;
+ if ( !StubObject::isRealObject( $wgLang ) ) {
+ $wgLang->_unstub();
+ }
+ $lang = $wgLang;
+ }
+ $this->initialiseFromUser( $user, $lang );
+ }
+
+ /**
+ * Get a ParserOptions object for an anonymous user
+ * @warning For interaction with the parser cache, use
+ * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or
+ * ParserOptions::newCanonical() instead.
+ * @since 1.27
+ * @return ParserOptions
+ */
+ public static function newFromAnon() {
+ global $wgContLang;
+ return new ParserOptions( new User, $wgContLang );
+ }
+
+ /**
+ * Get a ParserOptions object from a given user.
+ * Language will be taken from $wgLang.
+ *
+ * @warning For interaction with the parser cache, use
+ * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or
+ * ParserOptions::newCanonical() instead.
+ * @param User $user
+ * @return ParserOptions
+ */
+ public static function newFromUser( $user ) {
+ return new ParserOptions( $user );
+ }
+
+ /**
+ * Get a ParserOptions object from a given user and language
+ *
+ * @warning For interaction with the parser cache, use
+ * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or
+ * ParserOptions::newCanonical() instead.
+ * @param User $user
+ * @param Language $lang
+ * @return ParserOptions
+ */
+ public static function newFromUserAndLang( User $user, Language $lang ) {
+ return new ParserOptions( $user, $lang );
+ }
+
+ /**
+ * Get a ParserOptions object from a IContextSource object
+ *
+ * @warning For interaction with the parser cache, use
+ * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or
+ * ParserOptions::newCanonical() instead.
+ * @param IContextSource $context
+ * @return ParserOptions
+ */
+ public static function newFromContext( IContextSource $context ) {
+ return new ParserOptions( $context->getUser(), $context->getLanguage() );
+ }
+
+ /**
+ * Creates a "canonical" ParserOptions object
+ *
+ * For historical reasons, certain options have default values that are
+ * different from the canonical values used for caching.
+ *
+ * @since 1.30
+ * @param User|null $user
+ * @param Language|StubObject|null $lang
+ * @return ParserOptions
+ */
+ public static function newCanonical( User $user = null, $lang = null ) {
+ $ret = new ParserOptions( $user, $lang );
+ foreach ( self::getCanonicalOverrides() as $k => $v ) {
+ $ret->setOption( $k, $v );
+ }
+ return $ret;
+ }
+
+ /**
+ * Get default option values
+ * @warning If you change the default for an existing option (unless it's
+ * being overridden by self::getCanonicalOverrides()), all existing parser
+ * cache entries will be invalid. To avoid bugs, you'll need to handle
+ * that somehow (e.g. with the RejectParserCacheValue hook) because
+ * MediaWiki won't do it for you.
+ * @return array
+ */
+ private static function getDefaults() {
+ global $wgInterwikiMagic, $wgAllowExternalImages,
+ $wgAllowExternalImagesFrom, $wgEnableImageWhitelist, $wgAllowSpecialInclusion,
+ $wgMaxArticleSize, $wgMaxPPNodeCount, $wgMaxTemplateDepth, $wgMaxPPExpandDepth,
+ $wgCleanSignatures, $wgExternalLinkTarget, $wgExpensiveParserFunctionLimit,
+ $wgMaxGeneratedPPNodeCount, $wgDisableLangConversion, $wgDisableTitleConversion,
+ $wgEnableMagicLinks, $wgContLang;
+
+ if ( self::$defaults === null ) {
+ // *UPDATE* ParserOptions::matches() if any of this changes as needed
+ self::$defaults = [
+ 'dateformat' => null,
+ 'tidy' => false,
+ 'interfaceMessage' => false,
+ 'targetLanguage' => null,
+ 'removeComments' => true,
+ 'enableLimitReport' => false,
+ 'preSaveTransform' => true,
+ 'isPreview' => false,
+ 'isSectionPreview' => false,
+ 'printable' => false,
+ 'allowUnsafeRawHtml' => true,
+ 'wrapclass' => 'mw-parser-output',
+ 'currentRevisionCallback' => [ 'Parser', 'statelessFetchRevision' ],
+ 'templateCallback' => [ 'Parser', 'statelessFetchTemplate' ],
+ 'speculativeRevIdCallback' => null,
+ ];
+
+ // @codingStandardsIgnoreStart Squiz.WhiteSpace.OperatorSpacing.NoSpaceAfterAmp
+ Hooks::run( 'ParserOptionsRegister', [
+ &self::$defaults,
+ &self::$inCacheKey,
+ &self::$lazyOptions,
+ ] );
+ // @codingStandardsIgnoreEnd
+
+ ksort( self::$inCacheKey );
+ }
+
+ // Unit tests depend on being able to modify the globals at will
+ return self::$defaults + [
+ 'interwikiMagic' => $wgInterwikiMagic,
+ 'allowExternalImages' => $wgAllowExternalImages,
+ 'allowExternalImagesFrom' => $wgAllowExternalImagesFrom,
+ 'enableImageWhitelist' => $wgEnableImageWhitelist,
+ 'allowSpecialInclusion' => $wgAllowSpecialInclusion,
+ 'maxIncludeSize' => $wgMaxArticleSize * 1024,
+ 'maxPPNodeCount' => $wgMaxPPNodeCount,
+ 'maxGeneratedPPNodeCount' => $wgMaxGeneratedPPNodeCount,
+ 'maxPPExpandDepth' => $wgMaxPPExpandDepth,
+ 'maxTemplateDepth' => $wgMaxTemplateDepth,
+ 'expensiveParserFunctionLimit' => $wgExpensiveParserFunctionLimit,
+ 'externalLinkTarget' => $wgExternalLinkTarget,
+ 'cleanSignatures' => $wgCleanSignatures,
+ 'disableContentConversion' => $wgDisableLangConversion,
+ 'disableTitleConversion' => $wgDisableLangConversion || $wgDisableTitleConversion,
+ 'magicISBNLinks' => $wgEnableMagicLinks['ISBN'],
+ 'magicPMIDLinks' => $wgEnableMagicLinks['PMID'],
+ 'magicRFCLinks' => $wgEnableMagicLinks['RFC'],
+ 'numberheadings' => User::getDefaultOption( 'numberheadings' ),
+ 'thumbsize' => User::getDefaultOption( 'thumbsize' ),
+ 'stubthreshold' => 0,
+ 'userlang' => $wgContLang,
+ ];
+ }
+
+ /**
+ * Get "canonical" non-default option values
+ * @see self::newCanonical
+ * @warning If you change the override for an existing option, all existing
+ * parser cache entries will be invalid. To avoid bugs, you'll need to
+ * handle that somehow (e.g. with the RejectParserCacheValue hook) because
+ * MediaWiki won't do it for you.
+ * @return array
+ */
+ private static function getCanonicalOverrides() {
+ global $wgEnableParserLimitReporting;
+
+ return [
+ 'tidy' => true,
+ 'enableLimitReport' => $wgEnableParserLimitReporting,
+ ];
+ }
+
+ /**
+ * Get user options
+ *
+ * @param User $user
+ * @param Language $lang
+ */
+ private function initialiseFromUser( $user, $lang ) {
+ $this->options = self::getDefaults();
+
+ $this->mUser = $user;
+ $this->options['numberheadings'] = $user->getOption( 'numberheadings' );
+ $this->options['thumbsize'] = $user->getOption( 'thumbsize' );
+ $this->options['stubthreshold'] = $user->getStubThreshold();
+ $this->options['userlang'] = $lang;
+ }
+
+ /**
+ * Check if these options match that of another options set
+ *
+ * This ignores report limit settings that only affect HTML comments
+ *
+ * @param ParserOptions $other
+ * @return bool
+ * @since 1.25
+ */
+ public function matches( ParserOptions $other ) {
+ // Populate lazy options
+ foreach ( self::$lazyOptions as $name => $callback ) {
+ if ( $this->options[$name] === null ) {
+ $this->options[$name] = call_user_func( $callback, $this, $name );
+ }
+ if ( $other->options[$name] === null ) {
+ $other->options[$name] = call_user_func( $callback, $other, $name );
+ }
+ }
+
+ // Compare most options
+ $options = array_keys( $this->options );
+ $options = array_diff( $options, [
+ 'enableLimitReport', // only affects HTML comments
+ ] );
+ foreach ( $options as $option ) {
+ $o1 = $this->optionToString( $this->options[$option] );
+ $o2 = $this->optionToString( $other->options[$option] );
+ if ( $o1 !== $o2 ) {
+ return false;
+ }
+ }
+
+ // Compare most other fields
+ $fields = array_keys( get_class_vars( __CLASS__ ) );
+ $fields = array_diff( $fields, [
+ 'defaults', // static
+ 'lazyOptions', // static
+ 'inCacheKey', // static
+ 'options', // Already checked above
+ 'onAccessCallback', // only used for ParserOutput option tracking
+ ] );
+ foreach ( $fields as $field ) {
+ if ( !is_object( $this->$field ) && $this->$field !== $other->$field ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Registers a callback for tracking which ParserOptions which are used.
+ * This is a private API with the parser.
+ * @param callable $callback
+ */
+ public function registerWatcher( $callback ) {
+ $this->onAccessCallback = $callback;
+ }
+
+ /**
+ * Called when an option is accessed.
+ * Calls the watcher that was set using registerWatcher().
+ * Typically, the watcher callback is ParserOutput::registerOption().
+ * The information registered that way will be used by ParserCache::save().
+ *
+ * @param string $optionName Name of the option
+ */
+ public function optionUsed( $optionName ) {
+ if ( $this->onAccessCallback ) {
+ call_user_func( $this->onAccessCallback, $optionName );
+ }
+ }
+
+ /**
+ * Returns the full array of options that would have been used by
+ * in 1.16.
+ * Used to get the old parser cache entries when available.
+ * @deprecated since 1.30. You probably want self::allCacheVaryingOptions() instead.
+ * @return array
+ */
+ public static function legacyOptions() {
+ wfDeprecated( __METHOD__, '1.30' );
+ return [
+ 'stubthreshold',
+ 'numberheadings',
+ 'userlang',
+ 'thumbsize',
+ 'editsection',
+ 'printable'
+ ];
+ }
+
+ /**
+ * Return all option keys that vary the options hash
+ * @since 1.30
+ * @return string[]
+ */
+ public static function allCacheVaryingOptions() {
+ // Trigger a call to the 'ParserOptionsRegister' hook if it hasn't
+ // already been called.
+ if ( self::$defaults === null ) {
+ self::getDefaults();
+ }
+ return array_keys( array_filter( self::$inCacheKey ) );
+ }
+
+ /**
+ * Convert an option to a string value
+ * @param mixed $value
+ * @return string
+ */
+ private function optionToString( $value ) {
+ if ( $value === true ) {
+ return '1';
+ } elseif ( $value === false ) {
+ return '0';
+ } elseif ( $value === null ) {
+ return '';
+ } elseif ( $value instanceof Language ) {
+ return $value->getCode();
+ } elseif ( is_array( $value ) ) {
+ return '[' . join( ',', array_map( [ $this, 'optionToString' ], $value ) ) . ']';
+ } else {
+ return (string)$value;
+ }
+ }
+
+ /**
+ * Generate a hash string with the values set on these ParserOptions
+ * for the keys given in the array.
+ * This will be used as part of the hash key for the parser cache,
+ * so users sharing the options with vary for the same page share
+ * the same cached data safely.
+ *
+ * @since 1.17
+ * @param array $forOptions
+ * @param Title $title Used to get the content language of the page (since r97636)
+ * @return string Page rendering hash
+ */
+ public function optionsHash( $forOptions, $title = null ) {
+ global $wgRenderHashAppend;
+
+ $options = $this->options;
+ $defaults = self::getCanonicalOverrides() + self::getDefaults();
+ $inCacheKey = self::$inCacheKey;
+
+ // Historical hack: 'editsection' hasn't been a true parser option since
+ // Feb 2015 (instead the parser outputs a constant placeholder and post-parse
+ // processing handles the option). But Wikibase forces it in $forOptions
+ // and expects the cache key to still vary on it for T85252.
+ // @deprecated since 1.30, Wikibase should use addExtraKey() or something instead.
+ if ( in_array( 'editsection', $forOptions, true ) ) {
+ $options['editsection'] = $this->mEditSection;
+ $defaults['editsection'] = true;
+ $inCacheKey['editsection'] = true;
+ ksort( $inCacheKey );
+ }
+
+ // We only include used options with non-canonical values in the key
+ // so adding a new option doesn't invalidate the entire parser cache.
+ // The drawback to this is that changing the default value of an option
+ // requires manual invalidation of existing cache entries, as mentioned
+ // in the docs on the relevant methods and hooks.
+ $values = [];
+ foreach ( $inCacheKey as $option => $include ) {
+ if ( $include && in_array( $option, $forOptions, true ) ) {
+ $v = $this->optionToString( $options[$option] );
+ $d = $this->optionToString( $defaults[$option] );
+ if ( $v !== $d ) {
+ $values[] = "$option=$v";
+ }
+ }
+ }
+
+ $confstr = $values ? join( '!', $values ) : 'canonical';
+
+ // add in language specific options, if any
+ // @todo FIXME: This is just a way of retrieving the url/user preferred variant
+ if ( !is_null( $title ) ) {
+ $confstr .= $title->getPageLanguage()->getExtraHashOptions();
+ } else {
+ global $wgContLang;
+ $confstr .= $wgContLang->getExtraHashOptions();
+ }
+
+ $confstr .= $wgRenderHashAppend;
+
+ if ( $this->mExtraKey != '' ) {
+ $confstr .= $this->mExtraKey;
+ }
+
+ // Give a chance for extensions to modify the hash, if they have
+ // extra options or other effects on the parser cache.
+ Hooks::run( 'PageRenderingHash', [ &$confstr, $this->getUser(), &$forOptions ] );
+
+ // Make it a valid memcached key fragment
+ $confstr = str_replace( ' ', '_', $confstr );
+
+ return $confstr;
+ }
+
+ /**
+ * Test whether these options are safe to cache
+ * @since 1.30
+ * @return bool
+ */
+ public function isSafeToCache() {
+ $defaults = self::getCanonicalOverrides() + self::getDefaults();
+ foreach ( $this->options as $option => $value ) {
+ if ( empty( self::$inCacheKey[$option] ) ) {
+ $v = $this->optionToString( $value );
+ $d = $this->optionToString( $defaults[$option] );
+ if ( $v !== $d ) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Sets a hook to force that a page exists, and sets a current revision callback to return
+ * a revision with custom content when the current revision of the page is requested.
+ *
+ * @since 1.25
+ * @param Title $title
+ * @param Content $content
+ * @param User $user The user that the fake revision is attributed to
+ * @return ScopedCallback to unset the hook
+ */
+ public function setupFakeRevision( $title, $content, $user ) {
+ $oldCallback = $this->setCurrentRevisionCallback(
+ function (
+ $titleToCheck, $parser = false ) use ( $title, $content, $user, &$oldCallback
+ ) {
+ if ( $titleToCheck->equals( $title ) ) {
+ return new Revision( [
+ 'page' => $title->getArticleID(),
+ 'user_text' => $user->getName(),
+ 'user' => $user->getId(),
+ 'parent_id' => $title->getLatestRevID(),
+ 'title' => $title,
+ 'content' => $content
+ ] );
+ } else {
+ return call_user_func( $oldCallback, $titleToCheck, $parser );
+ }
+ }
+ );
+
+ global $wgHooks;
+ $wgHooks['TitleExists'][] =
+ function ( $titleToCheck, &$exists ) use ( $title ) {
+ if ( $titleToCheck->equals( $title ) ) {
+ $exists = true;
+ }
+ };
+ end( $wgHooks['TitleExists'] );
+ $key = key( $wgHooks['TitleExists'] );
+ LinkCache::singleton()->clearBadLink( $title->getPrefixedDBkey() );
+ return new ScopedCallback( function () use ( $title, $key ) {
+ global $wgHooks;
+ unset( $wgHooks['TitleExists'][$key] );
+ LinkCache::singleton()->clearLink( $title );
+ } );
+ }
+}
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ */
diff --git a/www/wiki/includes/parser/ParserOutput.php b/www/wiki/includes/parser/ParserOutput.php
new file mode 100644
index 00000000..3480a51f
--- /dev/null
+++ b/www/wiki/includes/parser/ParserOutput.php
@@ -0,0 +1,1129 @@
+<?php
+
+/**
+ * Output of the PHP parser.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+class ParserOutput extends CacheTime {
+ /**
+ * @var string $mText The output text
+ */
+ public $mText;
+
+ /**
+ * @var array $mLanguageLinks List of the full text of language links,
+ * in the order they appear.
+ */
+ public $mLanguageLinks;
+
+ /**
+ * @var array $mCategoriesMap of category names to sort keys
+ */
+ public $mCategories;
+
+ /**
+ * @var array $mIndicators Page status indicators, usually displayed in top-right corner.
+ */
+ public $mIndicators = [];
+
+ /**
+ * @var string $mTitleText Title text of the chosen language variant, as HTML.
+ */
+ public $mTitleText;
+
+ /**
+ * @var array $mLinks 2-D map of NS/DBK to ID for the links in the document.
+ * ID=zero for broken.
+ */
+ public $mLinks = [];
+
+ /**
+ * @var array $mTemplates 2-D map of NS/DBK to ID for the template references.
+ * ID=zero for broken.
+ */
+ public $mTemplates = [];
+
+ /**
+ * @var array $mTemplateIds 2-D map of NS/DBK to rev ID for the template references.
+ * ID=zero for broken.
+ */
+ public $mTemplateIds = [];
+
+ /**
+ * @var array $mImages DB keys of the images used, in the array key only
+ */
+ public $mImages = [];
+
+ /**
+ * @var array $mFileSearchOptions DB keys of the images used mapped to sha1 and MW timestamp.
+ */
+ public $mFileSearchOptions = [];
+
+ /**
+ * @var array $mExternalLinks External link URLs, in the key only.
+ */
+ public $mExternalLinks = [];
+
+ /**
+ * @var array $mInterwikiLinks 2-D map of prefix/DBK (in keys only)
+ * for the inline interwiki links in the document.
+ */
+ public $mInterwikiLinks = [];
+
+ /**
+ * @var bool $mNewSection Show a new section link?
+ */
+ public $mNewSection = false;
+
+ /**
+ * @var bool $mHideNewSection Hide the new section link?
+ */
+ public $mHideNewSection = false;
+
+ /**
+ * @var bool $mNoGallery No gallery on category page? (__NOGALLERY__).
+ */
+ public $mNoGallery = false;
+
+ /**
+ * @var array $mHeadItems Items to put in the <head> section
+ */
+ public $mHeadItems = [];
+
+ /**
+ * @var array $mModules Modules to be loaded by ResourceLoader
+ */
+ public $mModules = [];
+
+ /**
+ * @var array $mModuleScripts Modules of which only the JS will be loaded by ResourceLoader.
+ */
+ public $mModuleScripts = [];
+
+ /**
+ * @var array $mModuleStyles Modules of which only the CSSS will be loaded by ResourceLoader.
+ */
+ public $mModuleStyles = [];
+
+ /**
+ * @var array $mJsConfigVars JavaScript config variable for mw.config combined with this page.
+ */
+ public $mJsConfigVars = [];
+
+ /**
+ * @var array $mOutputHooks Hook tags as per $wgParserOutputHooks.
+ */
+ public $mOutputHooks = [];
+
+ /**
+ * @var array $mWarnings Warning text to be returned to the user.
+ * Wikitext formatted, in the key only.
+ */
+ public $mWarnings = [];
+
+ /**
+ * @var array $mSections Table of contents
+ */
+ public $mSections = [];
+
+ /**
+ * @var bool $mEditSectionTokens prefix/suffix markers if edit sections were output as tokens.
+ */
+ public $mEditSectionTokens = false;
+
+ /**
+ * @var array $mProperties Name/value pairs to be cached in the DB.
+ */
+ public $mProperties = [];
+
+ /**
+ * @var string $mTOCHTML HTML of the TOC.
+ */
+ public $mTOCHTML = '';
+
+ /**
+ * @var string $mTimestamp Timestamp of the revision.
+ */
+ public $mTimestamp;
+
+ /**
+ * @var bool $mTOCEnabled Whether TOC should be shown, can't override __NOTOC__.
+ */
+ public $mTOCEnabled = true;
+
+ /**
+ * @var bool $mEnableOOUI Whether OOUI should be enabled.
+ */
+ public $mEnableOOUI = false;
+
+ /**
+ * @var string $mIndexPolicy 'index' or 'noindex'? Any other value will result in no change.
+ */
+ private $mIndexPolicy = '';
+
+ /**
+ * @var array $mAccessedOptions List of ParserOptions (stored in the keys).
+ */
+ private $mAccessedOptions = [];
+
+ /**
+ * @var array $mExtensionData extra data used by extensions.
+ */
+ private $mExtensionData = [];
+
+ /**
+ * @var array $mLimitReportData Parser limit report data.
+ */
+ private $mLimitReportData = [];
+
+ /** @var array Parser limit report data for JSON */
+ private $mLimitReportJSData = [];
+
+ /**
+ * @var array $mParseStartTime Timestamps for getTimeSinceStart().
+ */
+ private $mParseStartTime = [];
+
+ /**
+ * @var bool $mPreventClickjacking Whether to emit X-Frame-Options: DENY.
+ */
+ private $mPreventClickjacking = false;
+
+ /**
+ * @var array $mFlags Generic flags.
+ */
+ private $mFlags = [];
+
+ /** @var int|null Assumed rev ID for {{REVISIONID}} if no revision is set */
+ private $mSpeculativeRevId;
+
+ /** @var int Upper bound of expiry based on parse duration */
+ private $mMaxAdaptiveExpiry = INF;
+
+ const EDITSECTION_REGEX =
+ '#<(?:mw:)?editsection page="(.*?)" section="(.*?)"(?:/>|>(.*?)(</(?:mw:)?editsection>))#s';
+
+ // finalizeAdaptiveCacheExpiry() uses TTL = MAX( m * PARSE_TIME + b, MIN_AR_TTL)
+ // Current values imply that m=3933.333333 and b=-333.333333
+ // See https://www.nngroup.com/articles/website-response-times/
+ const PARSE_FAST_SEC = 0.100; // perceived "fast" page parse
+ const PARSE_SLOW_SEC = 1.0; // perceived "slow" page parse
+ const FAST_AR_TTL = 60; // adaptive TTL for "fast" pages
+ const SLOW_AR_TTL = 3600; // adaptive TTL for "slow" pages
+ const MIN_AR_TTL = 15; // min adaptive TTL (for sanity, pool counter, and edit stashing)
+
+ public function __construct( $text = '', $languageLinks = [], $categoryLinks = [],
+ $unused = false, $titletext = ''
+ ) {
+ $this->mText = $text;
+ $this->mLanguageLinks = $languageLinks;
+ $this->mCategories = $categoryLinks;
+ $this->mTitleText = $titletext;
+ }
+
+ /**
+ * Get the cacheable text with <mw:editsection> markers still in it. The
+ * return value is suitable for writing back via setText() but is not valid
+ * for display to the user.
+ *
+ * @return string
+ * @since 1.27
+ */
+ public function getRawText() {
+ return $this->mText;
+ }
+
+ public function getText() {
+ $text = $this->mText;
+ if ( $this->mEditSectionTokens ) {
+ $text = preg_replace_callback(
+ self::EDITSECTION_REGEX,
+ function ( $m ) {
+ global $wgOut, $wgLang;
+ $editsectionPage = Title::newFromText( htmlspecialchars_decode( $m[1] ) );
+ $editsectionSection = htmlspecialchars_decode( $m[2] );
+ $editsectionContent = isset( $m[4] ) ? Sanitizer::decodeCharReferences( $m[3] ) : null;
+
+ if ( !is_object( $editsectionPage ) ) {
+ throw new MWException( "Bad parser output text." );
+ }
+
+ $skin = $wgOut->getSkin();
+ return call_user_func_array(
+ [ $skin, 'doEditSectionLink' ],
+ [ $editsectionPage, $editsectionSection,
+ $editsectionContent, $wgLang->getCode() ]
+ );
+ },
+ $text
+ );
+ } else {
+ $text = preg_replace( self::EDITSECTION_REGEX, '', $text );
+ }
+
+ // If you have an old cached version of this class - sorry, you can't disable the TOC
+ if ( isset( $this->mTOCEnabled ) && $this->mTOCEnabled ) {
+ $text = str_replace( [ Parser::TOC_START, Parser::TOC_END ], '', $text );
+ } else {
+ $text = preg_replace(
+ '#' . preg_quote( Parser::TOC_START, '#' ) . '.*?' . preg_quote( Parser::TOC_END, '#' ) . '#s',
+ '',
+ $text
+ );
+ }
+ return $text;
+ }
+
+ /**
+ * @param int $id
+ * @since 1.28
+ */
+ public function setSpeculativeRevIdUsed( $id ) {
+ $this->mSpeculativeRevId = $id;
+ }
+
+ /**
+ * @return int|null
+ * @since 1.28
+ */
+ public function getSpeculativeRevIdUsed() {
+ return $this->mSpeculativeRevId;
+ }
+
+ public function &getLanguageLinks() {
+ return $this->mLanguageLinks;
+ }
+
+ public function getInterwikiLinks() {
+ return $this->mInterwikiLinks;
+ }
+
+ public function getCategoryLinks() {
+ return array_keys( $this->mCategories );
+ }
+
+ public function &getCategories() {
+ return $this->mCategories;
+ }
+
+ /**
+ * @return array
+ * @since 1.25
+ */
+ public function getIndicators() {
+ return $this->mIndicators;
+ }
+
+ public function getTitleText() {
+ return $this->mTitleText;
+ }
+
+ public function getSections() {
+ return $this->mSections;
+ }
+
+ public function getEditSectionTokens() {
+ return $this->mEditSectionTokens;
+ }
+
+ public function &getLinks() {
+ return $this->mLinks;
+ }
+
+ public function &getTemplates() {
+ return $this->mTemplates;
+ }
+
+ public function &getTemplateIds() {
+ return $this->mTemplateIds;
+ }
+
+ public function &getImages() {
+ return $this->mImages;
+ }
+
+ public function &getFileSearchOptions() {
+ return $this->mFileSearchOptions;
+ }
+
+ public function &getExternalLinks() {
+ return $this->mExternalLinks;
+ }
+
+ public function getNoGallery() {
+ return $this->mNoGallery;
+ }
+
+ public function getHeadItems() {
+ return $this->mHeadItems;
+ }
+
+ public function getModules() {
+ return $this->mModules;
+ }
+
+ public function getModuleScripts() {
+ return $this->mModuleScripts;
+ }
+
+ public function getModuleStyles() {
+ return $this->mModuleStyles;
+ }
+
+ /**
+ * @return array
+ * @since 1.23
+ */
+ public function getJsConfigVars() {
+ return $this->mJsConfigVars;
+ }
+
+ public function getOutputHooks() {
+ return (array)$this->mOutputHooks;
+ }
+
+ public function getWarnings() {
+ return array_keys( $this->mWarnings );
+ }
+
+ public function getIndexPolicy() {
+ return $this->mIndexPolicy;
+ }
+
+ public function getTOCHTML() {
+ return $this->mTOCHTML;
+ }
+
+ /**
+ * @return string|null TS_MW timestamp of the revision content
+ */
+ public function getTimestamp() {
+ return $this->mTimestamp;
+ }
+
+ public function getLimitReportData() {
+ return $this->mLimitReportData;
+ }
+
+ public function getLimitReportJSData() {
+ return $this->mLimitReportJSData;
+ }
+
+ public function getTOCEnabled() {
+ return $this->mTOCEnabled;
+ }
+
+ public function getEnableOOUI() {
+ return $this->mEnableOOUI;
+ }
+
+ public function setText( $text ) {
+ return wfSetVar( $this->mText, $text );
+ }
+
+ public function setLanguageLinks( $ll ) {
+ return wfSetVar( $this->mLanguageLinks, $ll );
+ }
+
+ public function setCategoryLinks( $cl ) {
+ return wfSetVar( $this->mCategories, $cl );
+ }
+
+ public function setTitleText( $t ) {
+ return wfSetVar( $this->mTitleText, $t );
+ }
+
+ public function setSections( $toc ) {
+ return wfSetVar( $this->mSections, $toc );
+ }
+
+ public function setEditSectionTokens( $t ) {
+ return wfSetVar( $this->mEditSectionTokens, $t );
+ }
+
+ public function setIndexPolicy( $policy ) {
+ return wfSetVar( $this->mIndexPolicy, $policy );
+ }
+
+ public function setTOCHTML( $tochtml ) {
+ return wfSetVar( $this->mTOCHTML, $tochtml );
+ }
+
+ public function setTimestamp( $timestamp ) {
+ return wfSetVar( $this->mTimestamp, $timestamp );
+ }
+
+ public function setTOCEnabled( $flag ) {
+ return wfSetVar( $this->mTOCEnabled, $flag );
+ }
+
+ public function addCategory( $c, $sort ) {
+ $this->mCategories[$c] = $sort;
+ }
+
+ /**
+ * @param string $id
+ * @param string $content
+ * @since 1.25
+ */
+ public function setIndicator( $id, $content ) {
+ $this->mIndicators[$id] = $content;
+ }
+
+ /**
+ * Enables OOUI, if true, in any OutputPage instance this ParserOutput
+ * object is added to.
+ *
+ * @since 1.26
+ * @param bool $enable If OOUI should be enabled or not
+ */
+ public function setEnableOOUI( $enable = false ) {
+ $this->mEnableOOUI = $enable;
+ }
+
+ public function addLanguageLink( $t ) {
+ $this->mLanguageLinks[] = $t;
+ }
+
+ public function addWarning( $s ) {
+ $this->mWarnings[$s] = 1;
+ }
+
+ public function addOutputHook( $hook, $data = false ) {
+ $this->mOutputHooks[] = [ $hook, $data ];
+ }
+
+ public function setNewSection( $value ) {
+ $this->mNewSection = (bool)$value;
+ }
+ public function hideNewSection( $value ) {
+ $this->mHideNewSection = (bool)$value;
+ }
+ public function getHideNewSection() {
+ return (bool)$this->mHideNewSection;
+ }
+ public function getNewSection() {
+ return (bool)$this->mNewSection;
+ }
+
+ /**
+ * Checks, if a url is pointing to the own server
+ *
+ * @param string $internal The server to check against
+ * @param string $url The url to check
+ * @return bool
+ */
+ public static function isLinkInternal( $internal, $url ) {
+ return (bool)preg_match( '/^' .
+ # If server is proto relative, check also for http/https links
+ ( substr( $internal, 0, 2 ) === '//' ? '(?:https?:)?' : '' ) .
+ preg_quote( $internal, '/' ) .
+ # check for query/path/anchor or end of link in each case
+ '(?:[\?\/\#]|$)/i',
+ $url
+ );
+ }
+
+ public function addExternalLink( $url ) {
+ # We don't register links pointing to our own server, unless... :-)
+ global $wgServer, $wgRegisterInternalExternals;
+
+ # Replace unnecessary URL escape codes with the referenced character
+ # This prevents spammers from hiding links from the filters
+ $url = parser::normalizeLinkUrl( $url );
+
+ $registerExternalLink = true;
+ if ( !$wgRegisterInternalExternals ) {
+ $registerExternalLink = !self::isLinkInternal( $wgServer, $url );
+ }
+ if ( $registerExternalLink ) {
+ $this->mExternalLinks[$url] = 1;
+ }
+ }
+
+ /**
+ * Record a local or interwiki inline link for saving in future link tables.
+ *
+ * @param Title $title
+ * @param int|null $id Optional known page_id so we can skip the lookup
+ */
+ public function addLink( Title $title, $id = null ) {
+ if ( $title->isExternal() ) {
+ // Don't record interwikis in pagelinks
+ $this->addInterwikiLink( $title );
+ return;
+ }
+ $ns = $title->getNamespace();
+ $dbk = $title->getDBkey();
+ if ( $ns == NS_MEDIA ) {
+ // Normalize this pseudo-alias if it makes it down here...
+ $ns = NS_FILE;
+ } elseif ( $ns == NS_SPECIAL ) {
+ // We don't record Special: links currently
+ // It might actually be wise to, but we'd need to do some normalization.
+ return;
+ } elseif ( $dbk === '' ) {
+ // Don't record self links - [[#Foo]]
+ return;
+ }
+ if ( !isset( $this->mLinks[$ns] ) ) {
+ $this->mLinks[$ns] = [];
+ }
+ if ( is_null( $id ) ) {
+ $id = $title->getArticleID();
+ }
+ $this->mLinks[$ns][$dbk] = $id;
+ }
+
+ /**
+ * Register a file dependency for this output
+ * @param string $name Title dbKey
+ * @param string $timestamp MW timestamp of file creation (or false if non-existing)
+ * @param string $sha1 Base 36 SHA-1 of file (or false if non-existing)
+ * @return void
+ */
+ public function addImage( $name, $timestamp = null, $sha1 = null ) {
+ $this->mImages[$name] = 1;
+ if ( $timestamp !== null && $sha1 !== null ) {
+ $this->mFileSearchOptions[$name] = [ 'time' => $timestamp, 'sha1' => $sha1 ];
+ }
+ }
+
+ /**
+ * Register a template dependency for this output
+ * @param Title $title
+ * @param int $page_id
+ * @param int $rev_id
+ * @return void
+ */
+ public function addTemplate( $title, $page_id, $rev_id ) {
+ $ns = $title->getNamespace();
+ $dbk = $title->getDBkey();
+ if ( !isset( $this->mTemplates[$ns] ) ) {
+ $this->mTemplates[$ns] = [];
+ }
+ $this->mTemplates[$ns][$dbk] = $page_id;
+ if ( !isset( $this->mTemplateIds[$ns] ) ) {
+ $this->mTemplateIds[$ns] = [];
+ }
+ $this->mTemplateIds[$ns][$dbk] = $rev_id; // For versioning
+ }
+
+ /**
+ * @param Title $title Title object, must be an interwiki link
+ * @throws MWException If given invalid input
+ */
+ public function addInterwikiLink( $title ) {
+ if ( !$title->isExternal() ) {
+ throw new MWException( 'Non-interwiki link passed, internal parser error.' );
+ }
+ $prefix = $title->getInterwiki();
+ if ( !isset( $this->mInterwikiLinks[$prefix] ) ) {
+ $this->mInterwikiLinks[$prefix] = [];
+ }
+ $this->mInterwikiLinks[$prefix][$title->getDBkey()] = 1;
+ }
+
+ /**
+ * Add some text to the "<head>".
+ * If $tag is set, the section with that tag will only be included once
+ * in a given page.
+ * @param string $section
+ * @param string|bool $tag
+ */
+ public function addHeadItem( $section, $tag = false ) {
+ if ( $tag !== false ) {
+ $this->mHeadItems[$tag] = $section;
+ } else {
+ $this->mHeadItems[] = $section;
+ }
+ }
+
+ public function addModules( $modules ) {
+ $this->mModules = array_merge( $this->mModules, (array)$modules );
+ }
+
+ public function addModuleScripts( $modules ) {
+ $this->mModuleScripts = array_merge( $this->mModuleScripts, (array)$modules );
+ }
+
+ public function addModuleStyles( $modules ) {
+ $this->mModuleStyles = array_merge( $this->mModuleStyles, (array)$modules );
+ }
+
+ /**
+ * Add one or more variables to be set in mw.config in JavaScript.
+ *
+ * @param string|array $keys Key or array of key/value pairs.
+ * @param mixed $value [optional] Value of the configuration variable.
+ * @since 1.23
+ */
+ public function addJsConfigVars( $keys, $value = null ) {
+ if ( is_array( $keys ) ) {
+ foreach ( $keys as $key => $value ) {
+ $this->mJsConfigVars[$key] = $value;
+ }
+ return;
+ }
+
+ $this->mJsConfigVars[$keys] = $value;
+ }
+
+ /**
+ * Copy items from the OutputPage object into this one
+ *
+ * @param OutputPage $out
+ */
+ public function addOutputPageMetadata( OutputPage $out ) {
+ $this->addModules( $out->getModules() );
+ $this->addModuleScripts( $out->getModuleScripts() );
+ $this->addModuleStyles( $out->getModuleStyles() );
+ $this->addJsConfigVars( $out->getJsConfigVars() );
+
+ $this->mHeadItems = array_merge( $this->mHeadItems, $out->getHeadItemsArray() );
+ $this->mPreventClickjacking = $this->mPreventClickjacking || $out->getPreventClickjacking();
+ }
+
+ /**
+ * Add a tracking category, getting the title from a system message,
+ * or print a debug message if the title is invalid.
+ *
+ * Any message used with this function should be registered so it will
+ * show up on Special:TrackingCategories. Core messages should be added
+ * to SpecialTrackingCategories::$coreTrackingCategories, and extensions
+ * should add to "TrackingCategories" in their extension.json.
+ *
+ * @todo Migrate some code to TrackingCategories
+ *
+ * @param string $msg Message key
+ * @param Title $title title of the page which is being tracked
+ * @return bool Whether the addition was successful
+ * @since 1.25
+ */
+ public function addTrackingCategory( $msg, $title ) {
+ if ( $title->isSpecialPage() ) {
+ wfDebug( __METHOD__ . ": Not adding tracking category $msg to special page!\n" );
+ return false;
+ }
+
+ // Important to parse with correct title (T33469)
+ $cat = wfMessage( $msg )
+ ->title( $title )
+ ->inContentLanguage()
+ ->text();
+
+ # Allow tracking categories to be disabled by setting them to "-"
+ if ( $cat === '-' ) {
+ return false;
+ }
+
+ $containerCategory = Title::makeTitleSafe( NS_CATEGORY, $cat );
+ if ( $containerCategory ) {
+ $this->addCategory( $containerCategory->getDBkey(), $this->getProperty( 'defaultsort' ) ?: '' );
+ return true;
+ } else {
+ wfDebug( __METHOD__ . ": [[MediaWiki:$msg]] is not a valid title!\n" );
+ return false;
+ }
+ }
+
+ /**
+ * Override the title to be used for display
+ *
+ * @note this is assumed to have been validated
+ * (check equal normalisation, etc.)
+ *
+ * @note this is expected to be safe HTML,
+ * ready to be served to the client.
+ *
+ * @param string $text Desired title text
+ */
+ public function setDisplayTitle( $text ) {
+ $this->setTitleText( $text );
+ $this->setProperty( 'displaytitle', $text );
+ }
+
+ /**
+ * Get the title to be used for display.
+ *
+ * As per the contract of setDisplayTitle(), this is safe HTML,
+ * ready to be served to the client.
+ *
+ * @return string HTML
+ */
+ public function getDisplayTitle() {
+ $t = $this->getTitleText();
+ if ( $t === '' ) {
+ return false;
+ }
+ return $t;
+ }
+
+ /**
+ * Fairly generic flag setter thingy.
+ * @param string $flag
+ */
+ public function setFlag( $flag ) {
+ $this->mFlags[$flag] = true;
+ }
+
+ public function getFlag( $flag ) {
+ return isset( $this->mFlags[$flag] );
+ }
+
+ /**
+ * Set a property to be stored in the page_props database table.
+ *
+ * page_props is a key value store indexed by the page ID. This allows
+ * the parser to set a property on a page which can then be quickly
+ * retrieved given the page ID or via a DB join when given the page
+ * title.
+ *
+ * Since 1.23, page_props are also indexed by numeric value, to allow
+ * for efficient "top k" queries of pages wrt a given property.
+ *
+ * setProperty() is thus used to propagate properties from the parsed
+ * page to request contexts other than a page view of the currently parsed
+ * article.
+ *
+ * Some applications examples:
+ *
+ * * To implement hidden categories, hiding pages from category listings
+ * by storing a property.
+ *
+ * * Overriding the displayed article title.
+ * @see ParserOutput::setDisplayTitle()
+ *
+ * * To implement image tagging, for example displaying an icon on an
+ * image thumbnail to indicate that it is listed for deletion on
+ * Wikimedia Commons.
+ * This is not actually implemented, yet but would be pretty cool.
+ *
+ * @note Do not use setProperty() to set a property which is only used
+ * in a context where the ParserOutput object itself is already available,
+ * for example a normal page view. There is no need to save such a property
+ * in the database since the text is already parsed. You can just hook
+ * OutputPageParserOutput and get your data out of the ParserOutput object.
+ *
+ * If you are writing an extension where you want to set a property in the
+ * parser which is used by an OutputPageParserOutput hook, you have to
+ * associate the extension data directly with the ParserOutput object.
+ * Since MediaWiki 1.21, you can use setExtensionData() to do this:
+ *
+ * @par Example:
+ * @code
+ * $parser->getOutput()->setExtensionData( 'my_ext_foo', '...' );
+ * @endcode
+ *
+ * And then later, in OutputPageParserOutput or similar:
+ *
+ * @par Example:
+ * @code
+ * $output->getExtensionData( 'my_ext_foo' );
+ * @endcode
+ *
+ * In MediaWiki 1.20 and older, you have to use a custom member variable
+ * within the ParserOutput object:
+ *
+ * @par Example:
+ * @code
+ * $parser->getOutput()->my_ext_foo = '...';
+ * @endcode
+ * @param string $name
+ * @param mixed $value
+ */
+ public function setProperty( $name, $value ) {
+ $this->mProperties[$name] = $value;
+ }
+
+ /**
+ * @param string $name The property name to look up.
+ *
+ * @return mixed|bool The value previously set using setProperty(). False if null or no value
+ * was set for the given property name.
+ *
+ * @note You need to use getProperties() to check for boolean and null properties.
+ */
+ public function getProperty( $name ) {
+ return isset( $this->mProperties[$name] ) ? $this->mProperties[$name] : false;
+ }
+
+ public function unsetProperty( $name ) {
+ unset( $this->mProperties[$name] );
+ }
+
+ public function getProperties() {
+ if ( !isset( $this->mProperties ) ) {
+ $this->mProperties = [];
+ }
+ return $this->mProperties;
+ }
+
+ /**
+ * Returns the options from its ParserOptions which have been taken
+ * into account to produce this output or false if not available.
+ * @return array
+ */
+ public function getUsedOptions() {
+ if ( !isset( $this->mAccessedOptions ) ) {
+ return [];
+ }
+ return array_keys( $this->mAccessedOptions );
+ }
+
+ /**
+ * Tags a parser option for use in the cache key for this parser output.
+ * Registered as a watcher at ParserOptions::registerWatcher() by Parser::clearState().
+ * The information gathered here is available via getUsedOptions(),
+ * and is used by ParserCache::save().
+ *
+ * @see ParserCache::getKey
+ * @see ParserCache::save
+ * @see ParserOptions::addExtraKey
+ * @see ParserOptions::optionsHash
+ * @param string $option
+ */
+ public function recordOption( $option ) {
+ $this->mAccessedOptions[$option] = true;
+ }
+
+ /**
+ * Attaches arbitrary data to this ParserObject. This can be used to store some information in
+ * the ParserOutput object for later use during page output. The data will be cached along with
+ * the ParserOutput object, but unlike data set using setProperty(), it is not recorded in the
+ * database.
+ *
+ * This method is provided to overcome the unsafe practice of attaching extra information to a
+ * ParserObject by directly assigning member variables.
+ *
+ * To use setExtensionData() to pass extension information from a hook inside the parser to a
+ * hook in the page output, use this in the parser hook:
+ *
+ * @par Example:
+ * @code
+ * $parser->getOutput()->setExtensionData( 'my_ext_foo', '...' );
+ * @endcode
+ *
+ * And then later, in OutputPageParserOutput or similar:
+ *
+ * @par Example:
+ * @code
+ * $output->getExtensionData( 'my_ext_foo' );
+ * @endcode
+ *
+ * In MediaWiki 1.20 and older, you have to use a custom member variable
+ * within the ParserOutput object:
+ *
+ * @par Example:
+ * @code
+ * $parser->getOutput()->my_ext_foo = '...';
+ * @endcode
+ *
+ * @since 1.21
+ *
+ * @param string $key The key for accessing the data. Extensions should take care to avoid
+ * conflicts in naming keys. It is suggested to use the extension's name as a prefix.
+ *
+ * @param mixed $value The value to set. Setting a value to null is equivalent to removing
+ * the value.
+ */
+ public function setExtensionData( $key, $value ) {
+ if ( $value === null ) {
+ unset( $this->mExtensionData[$key] );
+ } else {
+ $this->mExtensionData[$key] = $value;
+ }
+ }
+
+ /**
+ * Gets extensions data previously attached to this ParserOutput using setExtensionData().
+ * Typically, such data would be set while parsing the page, e.g. by a parser function.
+ *
+ * @since 1.21
+ *
+ * @param string $key The key to look up.
+ *
+ * @return mixed|null The value previously set for the given key using setExtensionData()
+ * or null if no value was set for this key.
+ */
+ public function getExtensionData( $key ) {
+ if ( isset( $this->mExtensionData[$key] ) ) {
+ return $this->mExtensionData[$key];
+ }
+
+ return null;
+ }
+
+ private static function getTimes( $clock = null ) {
+ $ret = [];
+ if ( !$clock || $clock === 'wall' ) {
+ $ret['wall'] = microtime( true );
+ }
+ if ( !$clock || $clock === 'cpu' ) {
+ $ru = wfGetRusage();
+ if ( $ru ) {
+ $ret['cpu'] = $ru['ru_utime.tv_sec'] + $ru['ru_utime.tv_usec'] / 1e6;
+ $ret['cpu'] += $ru['ru_stime.tv_sec'] + $ru['ru_stime.tv_usec'] / 1e6;
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Resets the parse start timestamps for future calls to getTimeSinceStart()
+ * @since 1.22
+ */
+ public function resetParseStartTime() {
+ $this->mParseStartTime = self::getTimes();
+ }
+
+ /**
+ * Returns the time since resetParseStartTime() was last called
+ *
+ * Clocks available are:
+ * - wall: Wall clock time
+ * - cpu: CPU time (requires getrusage)
+ *
+ * @since 1.22
+ * @param string $clock
+ * @return float|null
+ */
+ public function getTimeSinceStart( $clock ) {
+ if ( !isset( $this->mParseStartTime[$clock] ) ) {
+ return null;
+ }
+
+ $end = self::getTimes( $clock );
+ return $end[$clock] - $this->mParseStartTime[$clock];
+ }
+
+ /**
+ * Sets parser limit report data for a key
+ *
+ * The key is used as the prefix for various messages used for formatting:
+ * - $key: The label for the field in the limit report
+ * - $key-value-text: Message used to format the value in the "NewPP limit
+ * report" HTML comment. If missing, uses $key-format.
+ * - $key-value-html: Message used to format the value in the preview
+ * limit report table. If missing, uses $key-format.
+ * - $key-value: Message used to format the value. If missing, uses "$1".
+ *
+ * Note that all values are interpreted as wikitext, and so should be
+ * encoded with htmlspecialchars() as necessary, but should avoid complex
+ * HTML for sanity of display in the "NewPP limit report" comment.
+ *
+ * @since 1.22
+ * @param string $key Message key
+ * @param mixed $value Appropriate for Message::params()
+ */
+ public function setLimitReportData( $key, $value ) {
+ $this->mLimitReportData[$key] = $value;
+
+ if ( is_array( $value ) ) {
+ if ( array_keys( $value ) === [ 0, 1 ]
+ && is_numeric( $value[0] )
+ && is_numeric( $value[1] )
+ ) {
+ $data = [ 'value' => $value[0], 'limit' => $value[1] ];
+ } else {
+ $data = $value;
+ }
+ } else {
+ $data = $value;
+ }
+
+ if ( strpos( $key, '-' ) ) {
+ list( $ns, $name ) = explode( '-', $key, 2 );
+ $this->mLimitReportJSData[$ns][$name] = $data;
+ } else {
+ $this->mLimitReportJSData[$key] = $data;
+ }
+ }
+
+ /**
+ * Check whether the cache TTL was lowered due to dynamic content
+ *
+ * When content is determined by more than hard state (e.g. page edits),
+ * such as template/file transclusions based on the current timestamp or
+ * extension tags that generate lists based on queries, this return true.
+ *
+ * @return bool
+ * @since 1.25
+ */
+ public function hasDynamicContent() {
+ global $wgParserCacheExpireTime;
+
+ return $this->getCacheExpiry() < $wgParserCacheExpireTime;
+ }
+
+ /**
+ * Get or set the prevent-clickjacking flag
+ *
+ * @since 1.24
+ * @param bool|null $flag New flag value, or null to leave it unchanged
+ * @return bool Old flag value
+ */
+ public function preventClickjacking( $flag = null ) {
+ return wfSetVar( $this->mPreventClickjacking, $flag );
+ }
+
+ /**
+ * Lower the runtime adaptive TTL to at most this value
+ *
+ * @param int $ttl
+ * @since 1.28
+ */
+ public function updateRuntimeAdaptiveExpiry( $ttl ) {
+ $this->mMaxAdaptiveExpiry = min( $ttl, $this->mMaxAdaptiveExpiry );
+ $this->updateCacheExpiry( $ttl );
+ }
+
+ /**
+ * Call this when parsing is done to lower the TTL based on low parse times
+ *
+ * @since 1.28
+ */
+ public function finalizeAdaptiveCacheExpiry() {
+ if ( is_infinite( $this->mMaxAdaptiveExpiry ) ) {
+ return; // not set
+ }
+
+ $runtime = $this->getTimeSinceStart( 'wall' );
+ if ( is_float( $runtime ) ) {
+ $slope = ( self::SLOW_AR_TTL - self::FAST_AR_TTL )
+ / ( self::PARSE_SLOW_SEC - self::PARSE_FAST_SEC );
+ // SLOW_AR_TTL = PARSE_SLOW_SEC * $slope + $point
+ $point = self::SLOW_AR_TTL - self::PARSE_SLOW_SEC * $slope;
+
+ $adaptiveTTL = min(
+ max( $slope * $runtime + $point, self::MIN_AR_TTL ),
+ $this->mMaxAdaptiveExpiry
+ );
+ $this->updateCacheExpiry( $adaptiveTTL );
+ }
+ }
+
+ public function __sleep() {
+ return array_diff(
+ array_keys( get_object_vars( $this ) ),
+ [ 'mParseStartTime' ]
+ );
+ }
+}
diff --git a/www/wiki/includes/parser/Preprocessor.php b/www/wiki/includes/parser/Preprocessor.php
new file mode 100644
index 00000000..49e961ae
--- /dev/null
+++ b/www/wiki/includes/parser/Preprocessor.php
@@ -0,0 +1,436 @@
+<?php
+/**
+ * Interfaces for preprocessors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * @ingroup Parser
+ */
+abstract class Preprocessor {
+
+ const CACHE_VERSION = 1;
+
+ /**
+ * @var array Brace matching rules.
+ */
+ protected $rules = [
+ '{' => [
+ 'end' => '}',
+ 'names' => [
+ 2 => 'template',
+ 3 => 'tplarg',
+ ],
+ 'min' => 2,
+ 'max' => 3,
+ ],
+ '[' => [
+ 'end' => ']',
+ 'names' => [ 2 => null ],
+ 'min' => 2,
+ 'max' => 2,
+ ],
+ '-{' => [
+ 'end' => '}-',
+ 'names' => [ 2 => null ],
+ 'min' => 2,
+ 'max' => 2,
+ ],
+ ];
+
+ /**
+ * Store a document tree in the cache.
+ *
+ * @param string $text
+ * @param int $flags
+ * @param string $tree
+ */
+ protected function cacheSetTree( $text, $flags, $tree ) {
+ $config = RequestContext::getMain()->getConfig();
+
+ $length = strlen( $text );
+ $threshold = $config->get( 'PreprocessorCacheThreshold' );
+ if ( $threshold === false || $length < $threshold || $length > 1e6 ) {
+ return;
+ }
+
+ $cache = ObjectCache::getLocalClusterInstance();
+ $key = $cache->makeKey(
+ defined( 'static::CACHE_PREFIX' ) ? static::CACHE_PREFIX : static::class,
+ md5( $text ), $flags );
+ $value = sprintf( "%08d", static::CACHE_VERSION ) . $tree;
+
+ $cache->set( $key, $value, 86400 );
+
+ LoggerFactory::getInstance( 'Preprocessor' )
+ ->info( "Cached preprocessor output (key: $key)" );
+ }
+
+ /**
+ * Attempt to load a precomputed document tree for some given wikitext
+ * from the cache.
+ *
+ * @param string $text
+ * @param int $flags
+ * @return PPNode_Hash_Tree|bool
+ */
+ protected function cacheGetTree( $text, $flags ) {
+ $config = RequestContext::getMain()->getConfig();
+
+ $length = strlen( $text );
+ $threshold = $config->get( 'PreprocessorCacheThreshold' );
+ if ( $threshold === false || $length < $threshold || $length > 1e6 ) {
+ return false;
+ }
+
+ $cache = ObjectCache::getLocalClusterInstance();
+
+ $key = $cache->makeKey(
+ defined( 'static::CACHE_PREFIX' ) ? static::CACHE_PREFIX : static::class,
+ md5( $text ), $flags );
+
+ $value = $cache->get( $key );
+ if ( !$value ) {
+ return false;
+ }
+
+ $version = intval( substr( $value, 0, 8 ) );
+ if ( $version !== static::CACHE_VERSION ) {
+ return false;
+ }
+
+ LoggerFactory::getInstance( 'Preprocessor' )
+ ->info( "Loaded preprocessor output from cache (key: $key)" );
+
+ return substr( $value, 8 );
+ }
+
+ /**
+ * Create a new top-level frame for expansion of a page
+ *
+ * @return PPFrame
+ */
+ abstract public function newFrame();
+
+ /**
+ * Create a new custom frame for programmatic use of parameter replacement
+ * as used in some extensions.
+ *
+ * @param array $args
+ *
+ * @return PPFrame
+ */
+ abstract public function newCustomFrame( $args );
+
+ /**
+ * Create a new custom node for programmatic use of parameter replacement
+ * as used in some extensions.
+ *
+ * @param array $values
+ */
+ abstract public function newPartNodeArray( $values );
+
+ /**
+ * Preprocess text to a PPNode
+ *
+ * @param string $text
+ * @param int $flags
+ *
+ * @return PPNode
+ */
+ abstract public function preprocessToObj( $text, $flags = 0 );
+}
+
+/**
+ * @ingroup Parser
+ */
+interface PPFrame {
+ const NO_ARGS = 1;
+ const NO_TEMPLATES = 2;
+ const STRIP_COMMENTS = 4;
+ const NO_IGNORE = 8;
+ const RECOVER_COMMENTS = 16;
+ const NO_TAGS = 32;
+
+ const RECOVER_ORIG = 59; // = 1|2|8|16|32 no constant expression support in PHP yet
+
+ /** This constant exists when $indexOffset is supported in newChild() */
+ const SUPPORTS_INDEX_OFFSET = 1;
+
+ /**
+ * Create a child frame
+ *
+ * @param array|bool $args
+ * @param bool|Title $title
+ * @param int $indexOffset A number subtracted from the index attributes of the arguments
+ *
+ * @return PPFrame
+ */
+ public function newChild( $args = false, $title = false, $indexOffset = 0 );
+
+ /**
+ * Expand a document tree node, caching the result on its parent with the given key
+ * @param string|int $key
+ * @param string|PPNode $root
+ * @param int $flags
+ * @return string
+ */
+ public function cachedExpand( $key, $root, $flags = 0 );
+
+ /**
+ * Expand a document tree node
+ * @param string|PPNode $root
+ * @param int $flags
+ * @return string
+ */
+ public function expand( $root, $flags = 0 );
+
+ /**
+ * Implode with flags for expand()
+ * @param string $sep
+ * @param int $flags
+ * @param string|PPNode $args,...
+ * @return string
+ */
+ public function implodeWithFlags( $sep, $flags /*, ... */ );
+
+ /**
+ * Implode with no flags specified
+ * @param string $sep
+ * @param string|PPNode $args,...
+ * @return string
+ */
+ public function implode( $sep /*, ... */ );
+
+ /**
+ * Makes an object that, when expand()ed, will be the same as one obtained
+ * with implode()
+ * @param string $sep
+ * @param string|PPNode $args,...
+ * @return PPNode
+ */
+ public function virtualImplode( $sep /*, ... */ );
+
+ /**
+ * Virtual implode with brackets
+ * @param string $start
+ * @param string $sep
+ * @param string $end
+ * @param string|PPNode $args,...
+ * @return PPNode
+ */
+ public function virtualBracketedImplode( $start, $sep, $end /*, ... */ );
+
+ /**
+ * Returns true if there are no arguments in this frame
+ *
+ * @return bool
+ */
+ public function isEmpty();
+
+ /**
+ * Returns all arguments of this frame
+ * @return array
+ */
+ public function getArguments();
+
+ /**
+ * Returns all numbered arguments of this frame
+ * @return array
+ */
+ public function getNumberedArguments();
+
+ /**
+ * Returns all named arguments of this frame
+ * @return array
+ */
+ public function getNamedArguments();
+
+ /**
+ * Get an argument to this frame by name
+ * @param int|string $name
+ * @return string|bool
+ */
+ public function getArgument( $name );
+
+ /**
+ * Returns true if the infinite loop check is OK, false if a loop is detected
+ *
+ * @param Title $title
+ * @return bool
+ */
+ public function loopCheck( $title );
+
+ /**
+ * Return true if the frame is a template frame
+ * @return bool
+ */
+ public function isTemplate();
+
+ /**
+ * Set the "volatile" flag.
+ *
+ * Note that this is somewhat of a "hack" in order to make extensions
+ * with side effects (such as Cite) work with the PHP parser. New
+ * extensions should be written in a way that they do not need this
+ * function, because other parsers (such as Parsoid) are not guaranteed
+ * to respect it, and it may be removed in the future.
+ *
+ * @param bool $flag
+ */
+ public function setVolatile( $flag = true );
+
+ /**
+ * Get the "volatile" flag.
+ *
+ * Callers should avoid caching the result of an expansion if it has the
+ * volatile flag set.
+ *
+ * @see self::setVolatile()
+ * @return bool
+ */
+ public function isVolatile();
+
+ /**
+ * Get the TTL of the frame's output.
+ *
+ * This is the maximum amount of time, in seconds, that this frame's
+ * output should be cached for. A value of null indicates that no
+ * maximum has been specified.
+ *
+ * Note that this TTL only applies to caching frames as parts of pages.
+ * It is not relevant to caching the entire rendered output of a page.
+ *
+ * @return int|null
+ */
+ public function getTTL();
+
+ /**
+ * Set the TTL of the output of this frame and all of its ancestors.
+ * Has no effect if the new TTL is greater than the one already set.
+ * Note that it is the caller's responsibility to change the cache
+ * expiry of the page as a whole, if such behavior is desired.
+ *
+ * @see self::getTTL()
+ * @param int $ttl
+ */
+ public function setTTL( $ttl );
+
+ /**
+ * Get a title of frame
+ *
+ * @return Title
+ */
+ public function getTitle();
+}
+
+/**
+ * There are three types of nodes:
+ * * Tree nodes, which have a name and contain other nodes as children
+ * * Array nodes, which also contain other nodes but aren't considered part of a tree
+ * * Leaf nodes, which contain the actual data
+ *
+ * This interface provides access to the tree structure and to the contents of array nodes,
+ * but it does not provide access to the internal structure of leaf nodes. Access to leaf
+ * data is provided via two means:
+ * * PPFrame::expand(), which provides expanded text
+ * * The PPNode::split*() functions, which provide metadata about certain types of tree node
+ * @ingroup Parser
+ */
+interface PPNode {
+ /**
+ * Get an array-type node containing the children of this node.
+ * Returns false if this is not a tree node.
+ * @return PPNode
+ */
+ public function getChildren();
+
+ /**
+ * Get the first child of a tree node. False if there isn't one.
+ *
+ * @return PPNode
+ */
+ public function getFirstChild();
+
+ /**
+ * Get the next sibling of any node. False if there isn't one
+ * @return PPNode
+ */
+ public function getNextSibling();
+
+ /**
+ * Get all children of this tree node which have a given name.
+ * Returns an array-type node, or false if this is not a tree node.
+ * @param string $type
+ * @return bool|PPNode
+ */
+ public function getChildrenOfType( $type );
+
+ /**
+ * Returns the length of the array, or false if this is not an array-type node
+ */
+ public function getLength();
+
+ /**
+ * Returns an item of an array-type node
+ * @param int $i
+ * @return bool|PPNode
+ */
+ public function item( $i );
+
+ /**
+ * Get the name of this node. The following names are defined here:
+ *
+ * h A heading node.
+ * template A double-brace node.
+ * tplarg A triple-brace node.
+ * title The first argument to a template or tplarg node.
+ * part Subsequent arguments to a template or tplarg node.
+ * #nodelist An array-type node
+ *
+ * The subclass may define various other names for tree and leaf nodes.
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * Split a "<part>" node into an associative array containing:
+ * name PPNode name
+ * index String index
+ * value PPNode value
+ * @return array
+ */
+ public function splitArg();
+
+ /**
+ * Split an "<ext>" node into an associative array containing name, attr, inner and close
+ * All values in the resulting array are PPNodes. Inner and close are optional.
+ * @return array
+ */
+ public function splitExt();
+
+ /**
+ * Split an "<h>" node
+ * @return array
+ */
+ public function splitHeading();
+}
diff --git a/www/wiki/includes/parser/Preprocessor_DOM.php b/www/wiki/includes/parser/Preprocessor_DOM.php
new file mode 100644
index 00000000..25889626
--- /dev/null
+++ b/www/wiki/includes/parser/Preprocessor_DOM.php
@@ -0,0 +1,2009 @@
+<?php
+/**
+ * Preprocessor using PHP's dom extension
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class Preprocessor_DOM extends Preprocessor {
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * @var Parser
+ */
+ public $parser;
+
+ public $memoryLimit;
+
+ const CACHE_PREFIX = 'preprocess-xml';
+
+ public function __construct( $parser ) {
+ $this->parser = $parser;
+ $mem = ini_get( 'memory_limit' );
+ $this->memoryLimit = false;
+ if ( strval( $mem ) !== '' && $mem != -1 ) {
+ if ( preg_match( '/^\d+$/', $mem ) ) {
+ $this->memoryLimit = $mem;
+ } elseif ( preg_match( '/^(\d+)M$/i', $mem, $m ) ) {
+ $this->memoryLimit = $m[1] * 1048576;
+ }
+ }
+ }
+
+ /**
+ * @return PPFrame_DOM
+ */
+ public function newFrame() {
+ return new PPFrame_DOM( $this );
+ }
+
+ /**
+ * @param array $args
+ * @return PPCustomFrame_DOM
+ */
+ public function newCustomFrame( $args ) {
+ return new PPCustomFrame_DOM( $this, $args );
+ }
+
+ /**
+ * @param array $values
+ * @return PPNode_DOM
+ * @throws MWException
+ */
+ public function newPartNodeArray( $values ) {
+ // NOTE: DOM manipulation is slower than building & parsing XML! (or so Tim sais)
+ $xml = "<list>";
+
+ foreach ( $values as $k => $val ) {
+ if ( is_int( $k ) ) {
+ $xml .= "<part><name index=\"$k\"/><value>"
+ . htmlspecialchars( $val ) . "</value></part>";
+ } else {
+ $xml .= "<part><name>" . htmlspecialchars( $k )
+ . "</name>=<value>" . htmlspecialchars( $val ) . "</value></part>";
+ }
+ }
+
+ $xml .= "</list>";
+
+ $dom = new DOMDocument();
+ MediaWiki\suppressWarnings();
+ $result = $dom->loadXML( $xml );
+ MediaWiki\restoreWarnings();
+ if ( !$result ) {
+ // Try running the XML through UtfNormal to get rid of invalid characters
+ $xml = UtfNormal\Validator::cleanUp( $xml );
+ // 1 << 19 == XML_PARSE_HUGE, needed so newer versions of libxml2
+ // don't barf when the XML is >256 levels deep
+ $result = $dom->loadXML( $xml, 1 << 19 );
+ }
+
+ if ( !$result ) {
+ throw new MWException( 'Parameters passed to ' . __METHOD__ . ' result in invalid XML' );
+ }
+
+ $root = $dom->documentElement;
+ $node = new PPNode_DOM( $root->childNodes );
+ return $node;
+ }
+
+ /**
+ * @throws MWException
+ * @return bool
+ */
+ public function memCheck() {
+ if ( $this->memoryLimit === false ) {
+ return true;
+ }
+ $usage = memory_get_usage();
+ if ( $usage > $this->memoryLimit * 0.9 ) {
+ $limit = intval( $this->memoryLimit * 0.9 / 1048576 + 0.5 );
+ throw new MWException( "Preprocessor hit 90% memory limit ($limit MB)" );
+ }
+ return $usage <= $this->memoryLimit * 0.8;
+ }
+
+ /**
+ * Preprocess some wikitext and return the document tree.
+ * This is the ghost of Parser::replace_variables().
+ *
+ * @param string $text The text to parse
+ * @param int $flags Bitwise combination of:
+ * Parser::PTD_FOR_INCLUSION Handle "<noinclude>" and "<includeonly>"
+ * as if the text is being included. Default
+ * is to assume a direct page view.
+ *
+ * The generated DOM tree must depend only on the input text and the flags.
+ * The DOM tree must be the same in OT_HTML and OT_WIKI mode, to avoid a regression of T6899.
+ *
+ * Any flag added to the $flags parameter here, or any other parameter liable to cause a
+ * change in the DOM tree for a given text, must be passed through the section identifier
+ * in the section edit link and thus back to extractSections().
+ *
+ * The output of this function is currently only cached in process memory, but a persistent
+ * cache may be implemented at a later date which takes further advantage of these strict
+ * dependency requirements.
+ *
+ * @throws MWException
+ * @return PPNode_DOM
+ */
+ public function preprocessToObj( $text, $flags = 0 ) {
+ $xml = $this->cacheGetTree( $text, $flags );
+ if ( $xml === false ) {
+ $xml = $this->preprocessToXml( $text, $flags );
+ $this->cacheSetTree( $text, $flags, $xml );
+ }
+
+ // Fail if the number of elements exceeds acceptable limits
+ // Do not attempt to generate the DOM
+ $this->parser->mGeneratedPPNodeCount += substr_count( $xml, '<' );
+ $max = $this->parser->mOptions->getMaxGeneratedPPNodeCount();
+ if ( $this->parser->mGeneratedPPNodeCount > $max ) {
+ // if ( $cacheable ) { ... }
+ throw new MWException( __METHOD__ . ': generated node count limit exceeded' );
+ }
+
+ $dom = new DOMDocument;
+ MediaWiki\suppressWarnings();
+ $result = $dom->loadXML( $xml );
+ MediaWiki\restoreWarnings();
+ if ( !$result ) {
+ // Try running the XML through UtfNormal to get rid of invalid characters
+ $xml = UtfNormal\Validator::cleanUp( $xml );
+ // 1 << 19 == XML_PARSE_HUGE, needed so newer versions of libxml2
+ // don't barf when the XML is >256 levels deep.
+ $result = $dom->loadXML( $xml, 1 << 19 );
+ }
+ if ( $result ) {
+ $obj = new PPNode_DOM( $dom->documentElement );
+ }
+
+ // if ( $cacheable ) { ... }
+
+ if ( !$result ) {
+ throw new MWException( __METHOD__ . ' generated invalid XML' );
+ }
+ return $obj;
+ }
+
+ /**
+ * @param string $text
+ * @param int $flags
+ * @return string
+ */
+ public function preprocessToXml( $text, $flags = 0 ) {
+ global $wgDisableLangConversion;
+
+ $forInclusion = $flags & Parser::PTD_FOR_INCLUSION;
+
+ $xmlishElements = $this->parser->getStripList();
+ $xmlishAllowMissingEndTag = [ 'includeonly', 'noinclude', 'onlyinclude' ];
+ $enableOnlyinclude = false;
+ if ( $forInclusion ) {
+ $ignoredTags = [ 'includeonly', '/includeonly' ];
+ $ignoredElements = [ 'noinclude' ];
+ $xmlishElements[] = 'noinclude';
+ if ( strpos( $text, '<onlyinclude>' ) !== false
+ && strpos( $text, '</onlyinclude>' ) !== false
+ ) {
+ $enableOnlyinclude = true;
+ }
+ } else {
+ $ignoredTags = [ 'noinclude', '/noinclude', 'onlyinclude', '/onlyinclude' ];
+ $ignoredElements = [ 'includeonly' ];
+ $xmlishElements[] = 'includeonly';
+ }
+ $xmlishRegex = implode( '|', array_merge( $xmlishElements, $ignoredTags ) );
+
+ // Use "A" modifier (anchored) instead of "^", because ^ doesn't work with an offset
+ $elementsRegex = "~($xmlishRegex)(?:\s|\/>|>)|(!--)~iA";
+
+ $stack = new PPDStack;
+
+ $searchBase = "[{<\n"; # }
+ if ( !$wgDisableLangConversion ) {
+ $searchBase .= '-';
+ }
+
+ // For fast reverse searches
+ $revText = strrev( $text );
+ $lengthText = strlen( $text );
+
+ // Input pointer, starts out pointing to a pseudo-newline before the start
+ $i = 0;
+ // Current accumulator
+ $accum =& $stack->getAccum();
+ $accum = '<root>';
+ // True to find equals signs in arguments
+ $findEquals = false;
+ // True to take notice of pipe characters
+ $findPipe = false;
+ $headingIndex = 1;
+ // True if $i is inside a possible heading
+ $inHeading = false;
+ // True if there are no more greater-than (>) signs right of $i
+ $noMoreGT = false;
+ // Map of tag name => true if there are no more closing tags of given type right of $i
+ $noMoreClosingTag = [];
+ // True to ignore all input up to the next <onlyinclude>
+ $findOnlyinclude = $enableOnlyinclude;
+ // Do a line-start run without outputting an LF character
+ $fakeLineStart = true;
+
+ while ( true ) {
+ // $this->memCheck();
+
+ if ( $findOnlyinclude ) {
+ // Ignore all input up to the next <onlyinclude>
+ $startPos = strpos( $text, '<onlyinclude>', $i );
+ if ( $startPos === false ) {
+ // Ignored section runs to the end
+ $accum .= '<ignore>' . htmlspecialchars( substr( $text, $i ) ) . '</ignore>';
+ break;
+ }
+ $tagEndPos = $startPos + strlen( '<onlyinclude>' ); // past-the-end
+ $accum .= '<ignore>' . htmlspecialchars( substr( $text, $i, $tagEndPos - $i ) ) . '</ignore>';
+ $i = $tagEndPos;
+ $findOnlyinclude = false;
+ }
+
+ if ( $fakeLineStart ) {
+ $found = 'line-start';
+ $curChar = '';
+ } else {
+ # Find next opening brace, closing brace or pipe
+ $search = $searchBase;
+ if ( $stack->top === false ) {
+ $currentClosing = '';
+ } elseif (
+ $stack->top->close === '}-' &&
+ $stack->top->count > 2
+ ) {
+ # adjust closing for -{{{...{{
+ $currentClosing = '}';
+ $search .= $currentClosing;
+ } else {
+ $currentClosing = $stack->top->close;
+ $search .= $currentClosing;
+ }
+ if ( $findPipe ) {
+ $search .= '|';
+ }
+ if ( $findEquals ) {
+ // First equals will be for the template
+ $search .= '=';
+ }
+ $rule = null;
+ # Output literal section, advance input counter
+ $literalLength = strcspn( $text, $search, $i );
+ if ( $literalLength > 0 ) {
+ $accum .= htmlspecialchars( substr( $text, $i, $literalLength ) );
+ $i += $literalLength;
+ }
+ if ( $i >= $lengthText ) {
+ if ( $currentClosing == "\n" ) {
+ // Do a past-the-end run to finish off the heading
+ $curChar = '';
+ $found = 'line-end';
+ } else {
+ # All done
+ break;
+ }
+ } else {
+ $curChar = $curTwoChar = $text[$i];
+ if ( ( $i + 1 ) < $lengthText ) {
+ $curTwoChar .= $text[$i + 1];
+ }
+ if ( $curChar == '|' ) {
+ $found = 'pipe';
+ } elseif ( $curChar == '=' ) {
+ $found = 'equals';
+ } elseif ( $curChar == '<' ) {
+ $found = 'angle';
+ } elseif ( $curChar == "\n" ) {
+ if ( $inHeading ) {
+ $found = 'line-end';
+ } else {
+ $found = 'line-start';
+ }
+ } elseif ( $curTwoChar == $currentClosing ) {
+ $found = 'close';
+ $curChar = $curTwoChar;
+ } elseif ( $curChar == $currentClosing ) {
+ $found = 'close';
+ } elseif ( isset( $this->rules[$curTwoChar] ) ) {
+ $curChar = $curTwoChar;
+ $found = 'open';
+ $rule = $this->rules[$curChar];
+ } elseif ( isset( $this->rules[$curChar] ) ) {
+ $found = 'open';
+ $rule = $this->rules[$curChar];
+ } else {
+ # Some versions of PHP have a strcspn which stops on
+ # null characters; ignore these and continue.
+ # We also may get '-' and '}' characters here which
+ # don't match -{ or $currentClosing. Add these to
+ # output and continue.
+ if ( $curChar == '-' || $curChar == '}' ) {
+ $accum .= $curChar;
+ }
+ ++$i;
+ continue;
+ }
+ }
+ }
+
+ if ( $found == 'angle' ) {
+ $matches = false;
+ // Handle </onlyinclude>
+ if ( $enableOnlyinclude
+ && substr( $text, $i, strlen( '</onlyinclude>' ) ) == '</onlyinclude>'
+ ) {
+ $findOnlyinclude = true;
+ continue;
+ }
+
+ // Determine element name
+ if ( !preg_match( $elementsRegex, $text, $matches, 0, $i + 1 ) ) {
+ // Element name missing or not listed
+ $accum .= '&lt;';
+ ++$i;
+ continue;
+ }
+ // Handle comments
+ if ( isset( $matches[2] ) && $matches[2] == '!--' ) {
+ // To avoid leaving blank lines, when a sequence of
+ // space-separated comments is both preceded and followed by
+ // a newline (ignoring spaces), then
+ // trim leading and trailing spaces and the trailing newline.
+
+ // Find the end
+ $endPos = strpos( $text, '-->', $i + 4 );
+ if ( $endPos === false ) {
+ // Unclosed comment in input, runs to end
+ $inner = substr( $text, $i );
+ $accum .= '<comment>' . htmlspecialchars( $inner ) . '</comment>';
+ $i = $lengthText;
+ } else {
+ // Search backwards for leading whitespace
+ $wsStart = $i ? ( $i - strspn( $revText, " \t", $lengthText - $i ) ) : 0;
+
+ // Search forwards for trailing whitespace
+ // $wsEnd will be the position of the last space (or the '>' if there's none)
+ $wsEnd = $endPos + 2 + strspn( $text, " \t", $endPos + 3 );
+
+ // Keep looking forward as long as we're finding more
+ // comments.
+ $comments = [ [ $wsStart, $wsEnd ] ];
+ while ( substr( $text, $wsEnd + 1, 4 ) == '<!--' ) {
+ $c = strpos( $text, '-->', $wsEnd + 4 );
+ if ( $c === false ) {
+ break;
+ }
+ $c = $c + 2 + strspn( $text, " \t", $c + 3 );
+ $comments[] = [ $wsEnd + 1, $c ];
+ $wsEnd = $c;
+ }
+
+ // Eat the line if possible
+ // TODO: This could theoretically be done if $wsStart == 0, i.e. for comments at
+ // the overall start. That's not how Sanitizer::removeHTMLcomments() did it, but
+ // it's a possible beneficial b/c break.
+ if ( $wsStart > 0 && substr( $text, $wsStart - 1, 1 ) == "\n"
+ && substr( $text, $wsEnd + 1, 1 ) == "\n"
+ ) {
+ // Remove leading whitespace from the end of the accumulator
+ // Sanity check first though
+ $wsLength = $i - $wsStart;
+ if ( $wsLength > 0
+ && strspn( $accum, " \t", -$wsLength ) === $wsLength
+ ) {
+ $accum = substr( $accum, 0, -$wsLength );
+ }
+
+ // Dump all but the last comment to the accumulator
+ foreach ( $comments as $j => $com ) {
+ $startPos = $com[0];
+ $endPos = $com[1] + 1;
+ if ( $j == ( count( $comments ) - 1 ) ) {
+ break;
+ }
+ $inner = substr( $text, $startPos, $endPos - $startPos );
+ $accum .= '<comment>' . htmlspecialchars( $inner ) . '</comment>';
+ }
+
+ // Do a line-start run next time to look for headings after the comment
+ $fakeLineStart = true;
+ } else {
+ // No line to eat, just take the comment itself
+ $startPos = $i;
+ $endPos += 2;
+ }
+
+ if ( $stack->top ) {
+ $part = $stack->top->getCurrentPart();
+ if ( !( isset( $part->commentEnd ) && $part->commentEnd == $wsStart - 1 ) ) {
+ $part->visualEnd = $wsStart;
+ }
+ // Else comments abutting, no change in visual end
+ $part->commentEnd = $endPos;
+ }
+ $i = $endPos + 1;
+ $inner = substr( $text, $startPos, $endPos - $startPos + 1 );
+ $accum .= '<comment>' . htmlspecialchars( $inner ) . '</comment>';
+ }
+ continue;
+ }
+ $name = $matches[1];
+ $lowerName = strtolower( $name );
+ $attrStart = $i + strlen( $name ) + 1;
+
+ // Find end of tag
+ $tagEndPos = $noMoreGT ? false : strpos( $text, '>', $attrStart );
+ if ( $tagEndPos === false ) {
+ // Infinite backtrack
+ // Disable tag search to prevent worst-case O(N^2) performance
+ $noMoreGT = true;
+ $accum .= '&lt;';
+ ++$i;
+ continue;
+ }
+
+ // Handle ignored tags
+ if ( in_array( $lowerName, $ignoredTags ) ) {
+ $accum .= '<ignore>'
+ . htmlspecialchars( substr( $text, $i, $tagEndPos - $i + 1 ) )
+ . '</ignore>';
+ $i = $tagEndPos + 1;
+ continue;
+ }
+
+ $tagStartPos = $i;
+ if ( $text[$tagEndPos - 1] == '/' ) {
+ $attrEnd = $tagEndPos - 1;
+ $inner = null;
+ $i = $tagEndPos + 1;
+ $close = '';
+ } else {
+ $attrEnd = $tagEndPos;
+ // Find closing tag
+ if (
+ !isset( $noMoreClosingTag[$name] ) &&
+ preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
+ $text, $matches, PREG_OFFSET_CAPTURE, $tagEndPos + 1 )
+ ) {
+ $inner = substr( $text, $tagEndPos + 1, $matches[0][1] - $tagEndPos - 1 );
+ $i = $matches[0][1] + strlen( $matches[0][0] );
+ $close = '<close>' . htmlspecialchars( $matches[0][0] ) . '</close>';
+ } else {
+ // No end tag
+ if ( in_array( $name, $xmlishAllowMissingEndTag ) ) {
+ // Let it run out to the end of the text.
+ $inner = substr( $text, $tagEndPos + 1 );
+ $i = $lengthText;
+ $close = '';
+ } else {
+ // Don't match the tag, treat opening tag as literal and resume parsing.
+ $i = $tagEndPos + 1;
+ $accum .= htmlspecialchars( substr( $text, $tagStartPos, $tagEndPos + 1 - $tagStartPos ) );
+ // Cache results, otherwise we have O(N^2) performance for input like <foo><foo><foo>...
+ $noMoreClosingTag[$name] = true;
+ continue;
+ }
+ }
+ }
+ // <includeonly> and <noinclude> just become <ignore> tags
+ if ( in_array( $lowerName, $ignoredElements ) ) {
+ $accum .= '<ignore>' . htmlspecialchars( substr( $text, $tagStartPos, $i - $tagStartPos ) )
+ . '</ignore>';
+ continue;
+ }
+
+ $accum .= '<ext>';
+ if ( $attrEnd <= $attrStart ) {
+ $attr = '';
+ } else {
+ $attr = substr( $text, $attrStart, $attrEnd - $attrStart );
+ }
+ $accum .= '<name>' . htmlspecialchars( $name ) . '</name>' .
+ // Note that the attr element contains the whitespace between name and attribute,
+ // this is necessary for precise reconstruction during pre-save transform.
+ '<attr>' . htmlspecialchars( $attr ) . '</attr>';
+ if ( $inner !== null ) {
+ $accum .= '<inner>' . htmlspecialchars( $inner ) . '</inner>';
+ }
+ $accum .= $close . '</ext>';
+ } elseif ( $found == 'line-start' ) {
+ // Is this the start of a heading?
+ // Line break belongs before the heading element in any case
+ if ( $fakeLineStart ) {
+ $fakeLineStart = false;
+ } else {
+ $accum .= $curChar;
+ $i++;
+ }
+
+ $count = strspn( $text, '=', $i, 6 );
+ if ( $count == 1 && $findEquals ) {
+ // DWIM: This looks kind of like a name/value separator.
+ // Let's let the equals handler have it and break the
+ // potential heading. This is heuristic, but AFAICT the
+ // methods for completely correct disambiguation are very
+ // complex.
+ } elseif ( $count > 0 ) {
+ $piece = [
+ 'open' => "\n",
+ 'close' => "\n",
+ 'parts' => [ new PPDPart( str_repeat( '=', $count ) ) ],
+ 'startPos' => $i,
+ 'count' => $count ];
+ $stack->push( $piece );
+ $accum =& $stack->getAccum();
+ $flags = $stack->getFlags();
+ extract( $flags );
+ $i += $count;
+ }
+ } elseif ( $found == 'line-end' ) {
+ $piece = $stack->top;
+ // A heading must be open, otherwise \n wouldn't have been in the search list
+ assert( $piece->open === "\n" );
+ $part = $piece->getCurrentPart();
+ // Search back through the input to see if it has a proper close.
+ // Do this using the reversed string since the other solutions
+ // (end anchor, etc.) are inefficient.
+ $wsLength = strspn( $revText, " \t", $lengthText - $i );
+ $searchStart = $i - $wsLength;
+ if ( isset( $part->commentEnd ) && $searchStart - 1 == $part->commentEnd ) {
+ // Comment found at line end
+ // Search for equals signs before the comment
+ $searchStart = $part->visualEnd;
+ $searchStart -= strspn( $revText, " \t", $lengthText - $searchStart );
+ }
+ $count = $piece->count;
+ $equalsLength = strspn( $revText, '=', $lengthText - $searchStart );
+ if ( $equalsLength > 0 ) {
+ if ( $searchStart - $equalsLength == $piece->startPos ) {
+ // This is just a single string of equals signs on its own line
+ // Replicate the doHeadings behavior /={count}(.+)={count}/
+ // First find out how many equals signs there really are (don't stop at 6)
+ $count = $equalsLength;
+ if ( $count < 3 ) {
+ $count = 0;
+ } else {
+ $count = min( 6, intval( ( $count - 1 ) / 2 ) );
+ }
+ } else {
+ $count = min( $equalsLength, $count );
+ }
+ if ( $count > 0 ) {
+ // Normal match, output <h>
+ $element = "<h level=\"$count\" i=\"$headingIndex\">$accum</h>";
+ $headingIndex++;
+ } else {
+ // Single equals sign on its own line, count=0
+ $element = $accum;
+ }
+ } else {
+ // No match, no <h>, just pass down the inner text
+ $element = $accum;
+ }
+ // Unwind the stack
+ $stack->pop();
+ $accum =& $stack->getAccum();
+ $flags = $stack->getFlags();
+ extract( $flags );
+
+ // Append the result to the enclosing accumulator
+ $accum .= $element;
+ // Note that we do NOT increment the input pointer.
+ // This is because the closing linebreak could be the opening linebreak of
+ // another heading. Infinite loops are avoided because the next iteration MUST
+ // hit the heading open case above, which unconditionally increments the
+ // input pointer.
+ } elseif ( $found == 'open' ) {
+ # count opening brace characters
+ $curLen = strlen( $curChar );
+ $count = ( $curLen > 1 ) ?
+ # allow the final character to repeat
+ strspn( $text, $curChar[$curLen - 1], $i + 1 ) + 1 :
+ strspn( $text, $curChar, $i );
+
+ # we need to add to stack only if opening brace count is enough for one of the rules
+ if ( $count >= $rule['min'] ) {
+ # Add it to the stack
+ $piece = [
+ 'open' => $curChar,
+ 'close' => $rule['end'],
+ 'count' => $count,
+ 'lineStart' => ( $i > 0 && $text[$i - 1] == "\n" ),
+ ];
+
+ $stack->push( $piece );
+ $accum =& $stack->getAccum();
+ $flags = $stack->getFlags();
+ extract( $flags );
+ } else {
+ # Add literal brace(s)
+ $accum .= htmlspecialchars( str_repeat( $curChar, $count ) );
+ }
+ $i += $count;
+ } elseif ( $found == 'close' ) {
+ $piece = $stack->top;
+ # lets check if there are enough characters for closing brace
+ $maxCount = $piece->count;
+ if ( $piece->close === '}-' && $curChar === '}' ) {
+ $maxCount--; # don't try to match closing '-' as a '}'
+ }
+ $curLen = strlen( $curChar );
+ $count = ( $curLen > 1 ) ? $curLen :
+ strspn( $text, $curChar, $i, $maxCount );
+
+ # check for maximum matching characters (if there are 5 closing
+ # characters, we will probably need only 3 - depending on the rules)
+ $rule = $this->rules[$piece->open];
+ if ( $piece->close === '}-' && $piece->count > 2 ) {
+ # tweak for -{..{{ }}..}-
+ $rule = $this->rules['{'];
+ }
+ if ( $count > $rule['max'] ) {
+ # The specified maximum exists in the callback array, unless the caller
+ # has made an error
+ $matchingCount = $rule['max'];
+ } else {
+ # Count is less than the maximum
+ # Skip any gaps in the callback array to find the true largest match
+ # Need to use array_key_exists not isset because the callback can be null
+ $matchingCount = $count;
+ while ( $matchingCount > 0 && !array_key_exists( $matchingCount, $rule['names'] ) ) {
+ --$matchingCount;
+ }
+ }
+
+ if ( $matchingCount <= 0 ) {
+ # No matching element found in callback array
+ # Output a literal closing brace and continue
+ $endText = substr( $text, $i, $count );
+ $accum .= htmlspecialchars( $endText );
+ $i += $count;
+ continue;
+ }
+ $name = $rule['names'][$matchingCount];
+ if ( $name === null ) {
+ // No element, just literal text
+ $endText = substr( $text, $i, $matchingCount );
+ $element = $piece->breakSyntax( $matchingCount ) . $endText;
+ } else {
+ # Create XML element
+ # Note: $parts is already XML, does not need to be encoded further
+ $parts = $piece->parts;
+ $title = $parts[0]->out;
+ unset( $parts[0] );
+
+ # The invocation is at the start of the line if lineStart is set in
+ # the stack, and all opening brackets are used up.
+ if ( $maxCount == $matchingCount && !empty( $piece->lineStart ) ) {
+ $attr = ' lineStart="1"';
+ } else {
+ $attr = '';
+ }
+
+ $element = "<$name$attr>";
+ $element .= "<title>$title</title>";
+ $argIndex = 1;
+ foreach ( $parts as $part ) {
+ if ( isset( $part->eqpos ) ) {
+ $argName = substr( $part->out, 0, $part->eqpos );
+ $argValue = substr( $part->out, $part->eqpos + 1 );
+ $element .= "<part><name>$argName</name>=<value>$argValue</value></part>";
+ } else {
+ $element .= "<part><name index=\"$argIndex\" /><value>{$part->out}</value></part>";
+ $argIndex++;
+ }
+ }
+ $element .= "</$name>";
+ }
+
+ # Advance input pointer
+ $i += $matchingCount;
+
+ # Unwind the stack
+ $stack->pop();
+ $accum =& $stack->getAccum();
+
+ # Re-add the old stack element if it still has unmatched opening characters remaining
+ if ( $matchingCount < $piece->count ) {
+ $piece->parts = [ new PPDPart ];
+ $piece->count -= $matchingCount;
+ # do we still qualify for any callback with remaining count?
+ $min = $this->rules[$piece->open]['min'];
+ if ( $piece->count >= $min ) {
+ $stack->push( $piece );
+ $accum =& $stack->getAccum();
+ } else {
+ $s = substr( $piece->open, 0, -1 );
+ $s .= str_repeat(
+ substr( $piece->open, -1 ),
+ $piece->count - strlen( $s )
+ );
+ $accum .= $s;
+ }
+ }
+ $flags = $stack->getFlags();
+ extract( $flags );
+
+ # Add XML element to the enclosing accumulator
+ $accum .= $element;
+ } elseif ( $found == 'pipe' ) {
+ $findEquals = true; // shortcut for getFlags()
+ $stack->addPart();
+ $accum =& $stack->getAccum();
+ ++$i;
+ } elseif ( $found == 'equals' ) {
+ $findEquals = false; // shortcut for getFlags()
+ $stack->getCurrentPart()->eqpos = strlen( $accum );
+ $accum .= '=';
+ ++$i;
+ } elseif ( $found == 'dash' ) {
+ $accum .= '-';
+ ++$i;
+ }
+ }
+
+ # Output any remaining unclosed brackets
+ foreach ( $stack->stack as $piece ) {
+ $stack->rootAccum .= $piece->breakSyntax();
+ }
+ $stack->rootAccum .= '</root>';
+ $xml = $stack->rootAccum;
+
+ return $xml;
+ }
+}
+
+/**
+ * Stack class to help Preprocessor::preprocessToObj()
+ * @ingroup Parser
+ */
+class PPDStack {
+ public $stack, $rootAccum;
+
+ /**
+ * @var PPDStack
+ */
+ public $top;
+ public $out;
+ public $elementClass = 'PPDStackElement';
+
+ public static $false = false;
+
+ public function __construct() {
+ $this->stack = [];
+ $this->top = false;
+ $this->rootAccum = '';
+ $this->accum =& $this->rootAccum;
+ }
+
+ /**
+ * @return int
+ */
+ public function count() {
+ return count( $this->stack );
+ }
+
+ public function &getAccum() {
+ return $this->accum;
+ }
+
+ public function getCurrentPart() {
+ if ( $this->top === false ) {
+ return false;
+ } else {
+ return $this->top->getCurrentPart();
+ }
+ }
+
+ public function push( $data ) {
+ if ( $data instanceof $this->elementClass ) {
+ $this->stack[] = $data;
+ } else {
+ $class = $this->elementClass;
+ $this->stack[] = new $class( $data );
+ }
+ $this->top = $this->stack[count( $this->stack ) - 1];
+ $this->accum =& $this->top->getAccum();
+ }
+
+ public function pop() {
+ if ( !count( $this->stack ) ) {
+ throw new MWException( __METHOD__ . ': no elements remaining' );
+ }
+ $temp = array_pop( $this->stack );
+
+ if ( count( $this->stack ) ) {
+ $this->top = $this->stack[count( $this->stack ) - 1];
+ $this->accum =& $this->top->getAccum();
+ } else {
+ $this->top = self::$false;
+ $this->accum =& $this->rootAccum;
+ }
+ return $temp;
+ }
+
+ public function addPart( $s = '' ) {
+ $this->top->addPart( $s );
+ $this->accum =& $this->top->getAccum();
+ }
+
+ /**
+ * @return array
+ */
+ public function getFlags() {
+ if ( !count( $this->stack ) ) {
+ return [
+ 'findEquals' => false,
+ 'findPipe' => false,
+ 'inHeading' => false,
+ ];
+ } else {
+ return $this->top->getFlags();
+ }
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+class PPDStackElement {
+ /**
+ * @var string Opening character (\n for heading)
+ */
+ public $open;
+
+ /**
+ * @var string Matching closing character
+ */
+ public $close;
+
+ /**
+ * @var int Number of opening characters found (number of "=" for heading)
+ */
+ public $count;
+
+ /**
+ * @var PPDPart[] Array of PPDPart objects describing pipe-separated parts.
+ */
+ public $parts;
+
+ /**
+ * @var bool True if the open char appeared at the start of the input line.
+ * Not set for headings.
+ */
+ public $lineStart;
+
+ public $partClass = 'PPDPart';
+
+ public function __construct( $data = [] ) {
+ $class = $this->partClass;
+ $this->parts = [ new $class ];
+
+ foreach ( $data as $name => $value ) {
+ $this->$name = $value;
+ }
+ }
+
+ public function &getAccum() {
+ return $this->parts[count( $this->parts ) - 1]->out;
+ }
+
+ public function addPart( $s = '' ) {
+ $class = $this->partClass;
+ $this->parts[] = new $class( $s );
+ }
+
+ public function getCurrentPart() {
+ return $this->parts[count( $this->parts ) - 1];
+ }
+
+ /**
+ * @return array
+ */
+ public function getFlags() {
+ $partCount = count( $this->parts );
+ $findPipe = $this->open != "\n" && $this->open != '[';
+ return [
+ 'findPipe' => $findPipe,
+ 'findEquals' => $findPipe && $partCount > 1 && !isset( $this->parts[$partCount - 1]->eqpos ),
+ 'inHeading' => $this->open == "\n",
+ ];
+ }
+
+ /**
+ * Get the output string that would result if the close is not found.
+ *
+ * @param bool|int $openingCount
+ * @return string
+ */
+ public function breakSyntax( $openingCount = false ) {
+ if ( $this->open == "\n" ) {
+ $s = $this->parts[0]->out;
+ } else {
+ if ( $openingCount === false ) {
+ $openingCount = $this->count;
+ }
+ $s = substr( $this->open, 0, -1 );
+ $s .= str_repeat(
+ substr( $this->open, -1 ),
+ $openingCount - strlen( $s )
+ );
+ $first = true;
+ foreach ( $this->parts as $part ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= '|';
+ }
+ $s .= $part->out;
+ }
+ }
+ return $s;
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+class PPDPart {
+ /**
+ * @var string Output accumulator string
+ */
+ public $out;
+
+ // Optional member variables:
+ // eqpos Position of equals sign in output accumulator
+ // commentEnd Past-the-end input pointer for the last comment encountered
+ // visualEnd Past-the-end input pointer for the end of the accumulator minus comments
+
+ public function __construct( $out = '' ) {
+ $this->out = $out;
+ }
+}
+
+/**
+ * An expansion frame, used as a context to expand the result of preprocessToObj()
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPFrame_DOM implements PPFrame {
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * @var Preprocessor
+ */
+ public $preprocessor;
+
+ /**
+ * @var Parser
+ */
+ public $parser;
+
+ /**
+ * @var Title
+ */
+ public $title;
+ public $titleCache;
+
+ /**
+ * Hashtable listing templates which are disallowed for expansion in this frame,
+ * having been encountered previously in parent frames.
+ */
+ public $loopCheckHash;
+
+ /**
+ * Recursion depth of this frame, top = 0
+ * Note that this is NOT the same as expansion depth in expand()
+ */
+ public $depth;
+
+ private $volatile = false;
+ private $ttl = null;
+
+ /**
+ * @var array
+ */
+ protected $childExpansionCache;
+
+ /**
+ * Construct a new preprocessor frame.
+ * @param Preprocessor $preprocessor The parent preprocessor
+ */
+ public function __construct( $preprocessor ) {
+ $this->preprocessor = $preprocessor;
+ $this->parser = $preprocessor->parser;
+ $this->title = $this->parser->mTitle;
+ $this->titleCache = [ $this->title ? $this->title->getPrefixedDBkey() : false ];
+ $this->loopCheckHash = [];
+ $this->depth = 0;
+ $this->childExpansionCache = [];
+ }
+
+ /**
+ * Create a new child frame
+ * $args is optionally a multi-root PPNode or array containing the template arguments
+ *
+ * @param bool|array $args
+ * @param Title|bool $title
+ * @param int $indexOffset
+ * @return PPTemplateFrame_DOM
+ */
+ public function newChild( $args = false, $title = false, $indexOffset = 0 ) {
+ $namedArgs = [];
+ $numberedArgs = [];
+ if ( $title === false ) {
+ $title = $this->title;
+ }
+ if ( $args !== false ) {
+ $xpath = false;
+ if ( $args instanceof PPNode ) {
+ $args = $args->node;
+ }
+ foreach ( $args as $arg ) {
+ if ( $arg instanceof PPNode ) {
+ $arg = $arg->node;
+ }
+ if ( !$xpath || $xpath->document !== $arg->ownerDocument ) {
+ $xpath = new DOMXPath( $arg->ownerDocument );
+ }
+
+ $nameNodes = $xpath->query( 'name', $arg );
+ $value = $xpath->query( 'value', $arg );
+ if ( $nameNodes->item( 0 )->hasAttributes() ) {
+ // Numbered parameter
+ $index = $nameNodes->item( 0 )->attributes->getNamedItem( 'index' )->textContent;
+ $index = $index - $indexOffset;
+ if ( isset( $namedArgs[$index] ) || isset( $numberedArgs[$index] ) ) {
+ $this->parser->getOutput()->addWarning( wfMessage( 'duplicate-args-warning',
+ wfEscapeWikiText( $this->title ),
+ wfEscapeWikiText( $title ),
+ wfEscapeWikiText( $index ) )->text() );
+ $this->parser->addTrackingCategory( 'duplicate-args-category' );
+ }
+ $numberedArgs[$index] = $value->item( 0 );
+ unset( $namedArgs[$index] );
+ } else {
+ // Named parameter
+ $name = trim( $this->expand( $nameNodes->item( 0 ), PPFrame::STRIP_COMMENTS ) );
+ if ( isset( $namedArgs[$name] ) || isset( $numberedArgs[$name] ) ) {
+ $this->parser->getOutput()->addWarning( wfMessage( 'duplicate-args-warning',
+ wfEscapeWikiText( $this->title ),
+ wfEscapeWikiText( $title ),
+ wfEscapeWikiText( $name ) )->text() );
+ $this->parser->addTrackingCategory( 'duplicate-args-category' );
+ }
+ $namedArgs[$name] = $value->item( 0 );
+ unset( $numberedArgs[$name] );
+ }
+ }
+ }
+ return new PPTemplateFrame_DOM( $this->preprocessor, $this, $numberedArgs, $namedArgs, $title );
+ }
+
+ /**
+ * @throws MWException
+ * @param string|int $key
+ * @param string|PPNode_DOM|DOMDocument $root
+ * @param int $flags
+ * @return string
+ */
+ public function cachedExpand( $key, $root, $flags = 0 ) {
+ // we don't have a parent, so we don't have a cache
+ return $this->expand( $root, $flags );
+ }
+
+ /**
+ * @throws MWException
+ * @param string|PPNode_DOM|DOMDocument $root
+ * @param int $flags
+ * @return string
+ */
+ public function expand( $root, $flags = 0 ) {
+ static $expansionDepth = 0;
+ if ( is_string( $root ) ) {
+ return $root;
+ }
+
+ if ( ++$this->parser->mPPNodeCount > $this->parser->mOptions->getMaxPPNodeCount() ) {
+ $this->parser->limitationWarn( 'node-count-exceeded',
+ $this->parser->mPPNodeCount,
+ $this->parser->mOptions->getMaxPPNodeCount()
+ );
+ return '<span class="error">Node-count limit exceeded</span>';
+ }
+
+ if ( $expansionDepth > $this->parser->mOptions->getMaxPPExpandDepth() ) {
+ $this->parser->limitationWarn( 'expansion-depth-exceeded',
+ $expansionDepth,
+ $this->parser->mOptions->getMaxPPExpandDepth()
+ );
+ return '<span class="error">Expansion depth limit exceeded</span>';
+ }
+ ++$expansionDepth;
+ if ( $expansionDepth > $this->parser->mHighestExpansionDepth ) {
+ $this->parser->mHighestExpansionDepth = $expansionDepth;
+ }
+
+ if ( $root instanceof PPNode_DOM ) {
+ $root = $root->node;
+ }
+ if ( $root instanceof DOMDocument ) {
+ $root = $root->documentElement;
+ }
+
+ $outStack = [ '', '' ];
+ $iteratorStack = [ false, $root ];
+ $indexStack = [ 0, 0 ];
+
+ while ( count( $iteratorStack ) > 1 ) {
+ $level = count( $outStack ) - 1;
+ $iteratorNode =& $iteratorStack[$level];
+ $out =& $outStack[$level];
+ $index =& $indexStack[$level];
+
+ if ( $iteratorNode instanceof PPNode_DOM ) {
+ $iteratorNode = $iteratorNode->node;
+ }
+
+ if ( is_array( $iteratorNode ) ) {
+ if ( $index >= count( $iteratorNode ) ) {
+ // All done with this iterator
+ $iteratorStack[$level] = false;
+ $contextNode = false;
+ } else {
+ $contextNode = $iteratorNode[$index];
+ $index++;
+ }
+ } elseif ( $iteratorNode instanceof DOMNodeList ) {
+ if ( $index >= $iteratorNode->length ) {
+ // All done with this iterator
+ $iteratorStack[$level] = false;
+ $contextNode = false;
+ } else {
+ $contextNode = $iteratorNode->item( $index );
+ $index++;
+ }
+ } else {
+ // Copy to $contextNode and then delete from iterator stack,
+ // because this is not an iterator but we do have to execute it once
+ $contextNode = $iteratorStack[$level];
+ $iteratorStack[$level] = false;
+ }
+
+ if ( $contextNode instanceof PPNode_DOM ) {
+ $contextNode = $contextNode->node;
+ }
+
+ $newIterator = false;
+
+ if ( $contextNode === false ) {
+ // nothing to do
+ } elseif ( is_string( $contextNode ) ) {
+ $out .= $contextNode;
+ } elseif ( is_array( $contextNode ) || $contextNode instanceof DOMNodeList ) {
+ $newIterator = $contextNode;
+ } elseif ( $contextNode instanceof DOMNode ) {
+ if ( $contextNode->nodeType == XML_TEXT_NODE ) {
+ $out .= $contextNode->nodeValue;
+ } elseif ( $contextNode->nodeName == 'template' ) {
+ # Double-brace expansion
+ $xpath = new DOMXPath( $contextNode->ownerDocument );
+ $titles = $xpath->query( 'title', $contextNode );
+ $title = $titles->item( 0 );
+ $parts = $xpath->query( 'part', $contextNode );
+ if ( $flags & PPFrame::NO_TEMPLATES ) {
+ $newIterator = $this->virtualBracketedImplode( '{{', '|', '}}', $title, $parts );
+ } else {
+ $lineStart = $contextNode->getAttribute( 'lineStart' );
+ $params = [
+ 'title' => new PPNode_DOM( $title ),
+ 'parts' => new PPNode_DOM( $parts ),
+ 'lineStart' => $lineStart ];
+ $ret = $this->parser->braceSubstitution( $params, $this );
+ if ( isset( $ret['object'] ) ) {
+ $newIterator = $ret['object'];
+ } else {
+ $out .= $ret['text'];
+ }
+ }
+ } elseif ( $contextNode->nodeName == 'tplarg' ) {
+ # Triple-brace expansion
+ $xpath = new DOMXPath( $contextNode->ownerDocument );
+ $titles = $xpath->query( 'title', $contextNode );
+ $title = $titles->item( 0 );
+ $parts = $xpath->query( 'part', $contextNode );
+ if ( $flags & PPFrame::NO_ARGS ) {
+ $newIterator = $this->virtualBracketedImplode( '{{{', '|', '}}}', $title, $parts );
+ } else {
+ $params = [
+ 'title' => new PPNode_DOM( $title ),
+ 'parts' => new PPNode_DOM( $parts ) ];
+ $ret = $this->parser->argSubstitution( $params, $this );
+ if ( isset( $ret['object'] ) ) {
+ $newIterator = $ret['object'];
+ } else {
+ $out .= $ret['text'];
+ }
+ }
+ } elseif ( $contextNode->nodeName == 'comment' ) {
+ # HTML-style comment
+ # Remove it in HTML, pre+remove and STRIP_COMMENTS modes
+ # Not in RECOVER_COMMENTS mode (msgnw) though.
+ if ( ( $this->parser->ot['html']
+ || ( $this->parser->ot['pre'] && $this->parser->mOptions->getRemoveComments() )
+ || ( $flags & PPFrame::STRIP_COMMENTS )
+ ) && !( $flags & PPFrame::RECOVER_COMMENTS )
+ ) {
+ $out .= '';
+ } elseif ( $this->parser->ot['wiki'] && !( $flags & PPFrame::RECOVER_COMMENTS ) ) {
+ # Add a strip marker in PST mode so that pstPass2() can
+ # run some old-fashioned regexes on the result.
+ # Not in RECOVER_COMMENTS mode (extractSections) though.
+ $out .= $this->parser->insertStripItem( $contextNode->textContent );
+ } else {
+ # Recover the literal comment in RECOVER_COMMENTS and pre+no-remove
+ $out .= $contextNode->textContent;
+ }
+ } elseif ( $contextNode->nodeName == 'ignore' ) {
+ # Output suppression used by <includeonly> etc.
+ # OT_WIKI will only respect <ignore> in substed templates.
+ # The other output types respect it unless NO_IGNORE is set.
+ # extractSections() sets NO_IGNORE and so never respects it.
+ if ( ( !isset( $this->parent ) && $this->parser->ot['wiki'] )
+ || ( $flags & PPFrame::NO_IGNORE )
+ ) {
+ $out .= $contextNode->textContent;
+ } else {
+ $out .= '';
+ }
+ } elseif ( $contextNode->nodeName == 'ext' ) {
+ # Extension tag
+ $xpath = new DOMXPath( $contextNode->ownerDocument );
+ $names = $xpath->query( 'name', $contextNode );
+ $attrs = $xpath->query( 'attr', $contextNode );
+ $inners = $xpath->query( 'inner', $contextNode );
+ $closes = $xpath->query( 'close', $contextNode );
+ if ( $flags & PPFrame::NO_TAGS ) {
+ $s = '<' . $this->expand( $names->item( 0 ), $flags );
+ if ( $attrs->length > 0 ) {
+ $s .= $this->expand( $attrs->item( 0 ), $flags );
+ }
+ if ( $inners->length > 0 ) {
+ $s .= '>' . $this->expand( $inners->item( 0 ), $flags );
+ if ( $closes->length > 0 ) {
+ $s .= $this->expand( $closes->item( 0 ), $flags );
+ }
+ } else {
+ $s .= '/>';
+ }
+ $out .= $s;
+ } else {
+ $params = [
+ 'name' => new PPNode_DOM( $names->item( 0 ) ),
+ 'attr' => $attrs->length > 0 ? new PPNode_DOM( $attrs->item( 0 ) ) : null,
+ 'inner' => $inners->length > 0 ? new PPNode_DOM( $inners->item( 0 ) ) : null,
+ 'close' => $closes->length > 0 ? new PPNode_DOM( $closes->item( 0 ) ) : null,
+ ];
+ $out .= $this->parser->extensionSubstitution( $params, $this );
+ }
+ } elseif ( $contextNode->nodeName == 'h' ) {
+ # Heading
+ $s = $this->expand( $contextNode->childNodes, $flags );
+
+ # Insert a heading marker only for <h> children of <root>
+ # This is to stop extractSections from going over multiple tree levels
+ if ( $contextNode->parentNode->nodeName == 'root' && $this->parser->ot['html'] ) {
+ # Insert heading index marker
+ $headingIndex = $contextNode->getAttribute( 'i' );
+ $titleText = $this->title->getPrefixedDBkey();
+ $this->parser->mHeadings[] = [ $titleText, $headingIndex ];
+ $serial = count( $this->parser->mHeadings ) - 1;
+ $marker = Parser::MARKER_PREFIX . "-h-$serial-" . Parser::MARKER_SUFFIX;
+ $count = $contextNode->getAttribute( 'level' );
+ $s = substr( $s, 0, $count ) . $marker . substr( $s, $count );
+ $this->parser->mStripState->addGeneral( $marker, '' );
+ }
+ $out .= $s;
+ } else {
+ # Generic recursive expansion
+ $newIterator = $contextNode->childNodes;
+ }
+ } else {
+ throw new MWException( __METHOD__ . ': Invalid parameter type' );
+ }
+
+ if ( $newIterator !== false ) {
+ if ( $newIterator instanceof PPNode_DOM ) {
+ $newIterator = $newIterator->node;
+ }
+ $outStack[] = '';
+ $iteratorStack[] = $newIterator;
+ $indexStack[] = 0;
+ } elseif ( $iteratorStack[$level] === false ) {
+ // Return accumulated value to parent
+ // With tail recursion
+ while ( $iteratorStack[$level] === false && $level > 0 ) {
+ $outStack[$level - 1] .= $out;
+ array_pop( $outStack );
+ array_pop( $iteratorStack );
+ array_pop( $indexStack );
+ $level--;
+ }
+ }
+ }
+ --$expansionDepth;
+ return $outStack[0];
+ }
+
+ /**
+ * @param string $sep
+ * @param int $flags
+ * @param string|PPNode_DOM|DOMDocument $args,...
+ * @return string
+ */
+ public function implodeWithFlags( $sep, $flags /*, ... */ ) {
+ $args = array_slice( func_get_args(), 2 );
+
+ $first = true;
+ $s = '';
+ foreach ( $args as $root ) {
+ if ( $root instanceof PPNode_DOM ) {
+ $root = $root->node;
+ }
+ if ( !is_array( $root ) && !( $root instanceof DOMNodeList ) ) {
+ $root = [ $root ];
+ }
+ foreach ( $root as $node ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= $sep;
+ }
+ $s .= $this->expand( $node, $flags );
+ }
+ }
+ return $s;
+ }
+
+ /**
+ * Implode with no flags specified
+ * This previously called implodeWithFlags but has now been inlined to reduce stack depth
+ *
+ * @param string $sep
+ * @param string|PPNode_DOM|DOMDocument $args,...
+ * @return string
+ */
+ public function implode( $sep /*, ... */ ) {
+ $args = array_slice( func_get_args(), 1 );
+
+ $first = true;
+ $s = '';
+ foreach ( $args as $root ) {
+ if ( $root instanceof PPNode_DOM ) {
+ $root = $root->node;
+ }
+ if ( !is_array( $root ) && !( $root instanceof DOMNodeList ) ) {
+ $root = [ $root ];
+ }
+ foreach ( $root as $node ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= $sep;
+ }
+ $s .= $this->expand( $node );
+ }
+ }
+ return $s;
+ }
+
+ /**
+ * Makes an object that, when expand()ed, will be the same as one obtained
+ * with implode()
+ *
+ * @param string $sep
+ * @param string|PPNode_DOM|DOMDocument $args,...
+ * @return array
+ */
+ public function virtualImplode( $sep /*, ... */ ) {
+ $args = array_slice( func_get_args(), 1 );
+ $out = [];
+ $first = true;
+
+ foreach ( $args as $root ) {
+ if ( $root instanceof PPNode_DOM ) {
+ $root = $root->node;
+ }
+ if ( !is_array( $root ) && !( $root instanceof DOMNodeList ) ) {
+ $root = [ $root ];
+ }
+ foreach ( $root as $node ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $out[] = $sep;
+ }
+ $out[] = $node;
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Virtual implode with brackets
+ * @param string $start
+ * @param string $sep
+ * @param string $end
+ * @param string|PPNode_DOM|DOMDocument $args,...
+ * @return array
+ */
+ public function virtualBracketedImplode( $start, $sep, $end /*, ... */ ) {
+ $args = array_slice( func_get_args(), 3 );
+ $out = [ $start ];
+ $first = true;
+
+ foreach ( $args as $root ) {
+ if ( $root instanceof PPNode_DOM ) {
+ $root = $root->node;
+ }
+ if ( !is_array( $root ) && !( $root instanceof DOMNodeList ) ) {
+ $root = [ $root ];
+ }
+ foreach ( $root as $node ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $out[] = $sep;
+ }
+ $out[] = $node;
+ }
+ }
+ $out[] = $end;
+ return $out;
+ }
+
+ public function __toString() {
+ return 'frame{}';
+ }
+
+ public function getPDBK( $level = false ) {
+ if ( $level === false ) {
+ return $this->title->getPrefixedDBkey();
+ } else {
+ return isset( $this->titleCache[$level] ) ? $this->titleCache[$level] : false;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function getArguments() {
+ return [];
+ }
+
+ /**
+ * @return array
+ */
+ public function getNumberedArguments() {
+ return [];
+ }
+
+ /**
+ * @return array
+ */
+ public function getNamedArguments() {
+ return [];
+ }
+
+ /**
+ * Returns true if there are no arguments in this frame
+ *
+ * @return bool
+ */
+ public function isEmpty() {
+ return true;
+ }
+
+ /**
+ * @param int|string $name
+ * @return bool Always false in this implementation.
+ */
+ public function getArgument( $name ) {
+ return false;
+ }
+
+ /**
+ * Returns true if the infinite loop check is OK, false if a loop is detected
+ *
+ * @param Title $title
+ * @return bool
+ */
+ public function loopCheck( $title ) {
+ return !isset( $this->loopCheckHash[$title->getPrefixedDBkey()] );
+ }
+
+ /**
+ * Return true if the frame is a template frame
+ *
+ * @return bool
+ */
+ public function isTemplate() {
+ return false;
+ }
+
+ /**
+ * Get a title of frame
+ *
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * Set the volatile flag
+ *
+ * @param bool $flag
+ */
+ public function setVolatile( $flag = true ) {
+ $this->volatile = $flag;
+ }
+
+ /**
+ * Get the volatile flag
+ *
+ * @return bool
+ */
+ public function isVolatile() {
+ return $this->volatile;
+ }
+
+ /**
+ * Set the TTL
+ *
+ * @param int $ttl
+ */
+ public function setTTL( $ttl ) {
+ if ( $ttl !== null && ( $this->ttl === null || $ttl < $this->ttl ) ) {
+ $this->ttl = $ttl;
+ }
+ }
+
+ /**
+ * Get the TTL
+ *
+ * @return int|null
+ */
+ public function getTTL() {
+ return $this->ttl;
+ }
+}
+
+/**
+ * Expansion frame with template arguments
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPTemplateFrame_DOM extends PPFrame_DOM {
+ // @codingStandardsIgnoreEnd
+
+ public $numberedArgs, $namedArgs;
+
+ /**
+ * @var PPFrame_DOM
+ */
+ public $parent;
+ public $numberedExpansionCache, $namedExpansionCache;
+
+ /**
+ * @param Preprocessor $preprocessor
+ * @param bool|PPFrame_DOM $parent
+ * @param array $numberedArgs
+ * @param array $namedArgs
+ * @param bool|Title $title
+ */
+ public function __construct( $preprocessor, $parent = false, $numberedArgs = [],
+ $namedArgs = [], $title = false
+ ) {
+ parent::__construct( $preprocessor );
+
+ $this->parent = $parent;
+ $this->numberedArgs = $numberedArgs;
+ $this->namedArgs = $namedArgs;
+ $this->title = $title;
+ $pdbk = $title ? $title->getPrefixedDBkey() : false;
+ $this->titleCache = $parent->titleCache;
+ $this->titleCache[] = $pdbk;
+ $this->loopCheckHash = /*clone*/ $parent->loopCheckHash;
+ if ( $pdbk !== false ) {
+ $this->loopCheckHash[$pdbk] = true;
+ }
+ $this->depth = $parent->depth + 1;
+ $this->numberedExpansionCache = $this->namedExpansionCache = [];
+ }
+
+ public function __toString() {
+ $s = 'tplframe{';
+ $first = true;
+ $args = $this->numberedArgs + $this->namedArgs;
+ foreach ( $args as $name => $value ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= ', ';
+ }
+ $s .= "\"$name\":\"" .
+ str_replace( '"', '\\"', $value->ownerDocument->saveXML( $value ) ) . '"';
+ }
+ $s .= '}';
+ return $s;
+ }
+
+ /**
+ * @throws MWException
+ * @param string|int $key
+ * @param string|PPNode_DOM|DOMDocument $root
+ * @param int $flags
+ * @return string
+ */
+ public function cachedExpand( $key, $root, $flags = 0 ) {
+ if ( isset( $this->parent->childExpansionCache[$key] ) ) {
+ return $this->parent->childExpansionCache[$key];
+ }
+ $retval = $this->expand( $root, $flags );
+ if ( !$this->isVolatile() ) {
+ $this->parent->childExpansionCache[$key] = $retval;
+ }
+ return $retval;
+ }
+
+ /**
+ * Returns true if there are no arguments in this frame
+ *
+ * @return bool
+ */
+ public function isEmpty() {
+ return !count( $this->numberedArgs ) && !count( $this->namedArgs );
+ }
+
+ public function getArguments() {
+ $arguments = [];
+ foreach ( array_merge(
+ array_keys( $this->numberedArgs ),
+ array_keys( $this->namedArgs ) ) as $key ) {
+ $arguments[$key] = $this->getArgument( $key );
+ }
+ return $arguments;
+ }
+
+ public function getNumberedArguments() {
+ $arguments = [];
+ foreach ( array_keys( $this->numberedArgs ) as $key ) {
+ $arguments[$key] = $this->getArgument( $key );
+ }
+ return $arguments;
+ }
+
+ public function getNamedArguments() {
+ $arguments = [];
+ foreach ( array_keys( $this->namedArgs ) as $key ) {
+ $arguments[$key] = $this->getArgument( $key );
+ }
+ return $arguments;
+ }
+
+ /**
+ * @param int $index
+ * @return string|bool
+ */
+ public function getNumberedArgument( $index ) {
+ if ( !isset( $this->numberedArgs[$index] ) ) {
+ return false;
+ }
+ if ( !isset( $this->numberedExpansionCache[$index] ) ) {
+ # No trimming for unnamed arguments
+ $this->numberedExpansionCache[$index] = $this->parent->expand(
+ $this->numberedArgs[$index],
+ PPFrame::STRIP_COMMENTS
+ );
+ }
+ return $this->numberedExpansionCache[$index];
+ }
+
+ /**
+ * @param string $name
+ * @return string|bool
+ */
+ public function getNamedArgument( $name ) {
+ if ( !isset( $this->namedArgs[$name] ) ) {
+ return false;
+ }
+ if ( !isset( $this->namedExpansionCache[$name] ) ) {
+ # Trim named arguments post-expand, for backwards compatibility
+ $this->namedExpansionCache[$name] = trim(
+ $this->parent->expand( $this->namedArgs[$name], PPFrame::STRIP_COMMENTS ) );
+ }
+ return $this->namedExpansionCache[$name];
+ }
+
+ /**
+ * @param int|string $name
+ * @return string|bool
+ */
+ public function getArgument( $name ) {
+ $text = $this->getNumberedArgument( $name );
+ if ( $text === false ) {
+ $text = $this->getNamedArgument( $name );
+ }
+ return $text;
+ }
+
+ /**
+ * Return true if the frame is a template frame
+ *
+ * @return bool
+ */
+ public function isTemplate() {
+ return true;
+ }
+
+ public function setVolatile( $flag = true ) {
+ parent::setVolatile( $flag );
+ $this->parent->setVolatile( $flag );
+ }
+
+ public function setTTL( $ttl ) {
+ parent::setTTL( $ttl );
+ $this->parent->setTTL( $ttl );
+ }
+}
+
+/**
+ * Expansion frame with custom arguments
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPCustomFrame_DOM extends PPFrame_DOM {
+ // @codingStandardsIgnoreEnd
+
+ public $args;
+
+ public function __construct( $preprocessor, $args ) {
+ parent::__construct( $preprocessor );
+ $this->args = $args;
+ }
+
+ public function __toString() {
+ $s = 'cstmframe{';
+ $first = true;
+ foreach ( $this->args as $name => $value ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= ', ';
+ }
+ $s .= "\"$name\":\"" .
+ str_replace( '"', '\\"', $value->__toString() ) . '"';
+ }
+ $s .= '}';
+ return $s;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isEmpty() {
+ return !count( $this->args );
+ }
+
+ /**
+ * @param int|string $index
+ * @return string|bool
+ */
+ public function getArgument( $index ) {
+ if ( !isset( $this->args[$index] ) ) {
+ return false;
+ }
+ return $this->args[$index];
+ }
+
+ public function getArguments() {
+ return $this->args;
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPNode_DOM implements PPNode {
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * @var DOMElement
+ */
+ public $node;
+ public $xpath;
+
+ public function __construct( $node, $xpath = false ) {
+ $this->node = $node;
+ }
+
+ /**
+ * @return DOMXPath
+ */
+ public function getXPath() {
+ if ( $this->xpath === null ) {
+ $this->xpath = new DOMXPath( $this->node->ownerDocument );
+ }
+ return $this->xpath;
+ }
+
+ public function __toString() {
+ if ( $this->node instanceof DOMNodeList ) {
+ $s = '';
+ foreach ( $this->node as $node ) {
+ $s .= $node->ownerDocument->saveXML( $node );
+ }
+ } else {
+ $s = $this->node->ownerDocument->saveXML( $this->node );
+ }
+ return $s;
+ }
+
+ /**
+ * @return bool|PPNode_DOM
+ */
+ public function getChildren() {
+ return $this->node->childNodes ? new self( $this->node->childNodes ) : false;
+ }
+
+ /**
+ * @return bool|PPNode_DOM
+ */
+ public function getFirstChild() {
+ return $this->node->firstChild ? new self( $this->node->firstChild ) : false;
+ }
+
+ /**
+ * @return bool|PPNode_DOM
+ */
+ public function getNextSibling() {
+ return $this->node->nextSibling ? new self( $this->node->nextSibling ) : false;
+ }
+
+ /**
+ * @param string $type
+ *
+ * @return bool|PPNode_DOM
+ */
+ public function getChildrenOfType( $type ) {
+ return new self( $this->getXPath()->query( $type, $this->node ) );
+ }
+
+ /**
+ * @return int
+ */
+ public function getLength() {
+ if ( $this->node instanceof DOMNodeList ) {
+ return $this->node->length;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param int $i
+ * @return bool|PPNode_DOM
+ */
+ public function item( $i ) {
+ $item = $this->node->item( $i );
+ return $item ? new self( $item ) : false;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName() {
+ if ( $this->node instanceof DOMNodeList ) {
+ return '#nodelist';
+ } else {
+ return $this->node->nodeName;
+ }
+ }
+
+ /**
+ * Split a "<part>" node into an associative array containing:
+ * - name PPNode name
+ * - index String index
+ * - value PPNode value
+ *
+ * @throws MWException
+ * @return array
+ */
+ public function splitArg() {
+ $xpath = $this->getXPath();
+ $names = $xpath->query( 'name', $this->node );
+ $values = $xpath->query( 'value', $this->node );
+ if ( !$names->length || !$values->length ) {
+ throw new MWException( 'Invalid brace node passed to ' . __METHOD__ );
+ }
+ $name = $names->item( 0 );
+ $index = $name->getAttribute( 'index' );
+ return [
+ 'name' => new self( $name ),
+ 'index' => $index,
+ 'value' => new self( $values->item( 0 ) ) ];
+ }
+
+ /**
+ * Split an "<ext>" node into an associative array containing name, attr, inner and close
+ * All values in the resulting array are PPNodes. Inner and close are optional.
+ *
+ * @throws MWException
+ * @return array
+ */
+ public function splitExt() {
+ $xpath = $this->getXPath();
+ $names = $xpath->query( 'name', $this->node );
+ $attrs = $xpath->query( 'attr', $this->node );
+ $inners = $xpath->query( 'inner', $this->node );
+ $closes = $xpath->query( 'close', $this->node );
+ if ( !$names->length || !$attrs->length ) {
+ throw new MWException( 'Invalid ext node passed to ' . __METHOD__ );
+ }
+ $parts = [
+ 'name' => new self( $names->item( 0 ) ),
+ 'attr' => new self( $attrs->item( 0 ) ) ];
+ if ( $inners->length ) {
+ $parts['inner'] = new self( $inners->item( 0 ) );
+ }
+ if ( $closes->length ) {
+ $parts['close'] = new self( $closes->item( 0 ) );
+ }
+ return $parts;
+ }
+
+ /**
+ * Split a "<h>" node
+ * @throws MWException
+ * @return array
+ */
+ public function splitHeading() {
+ if ( $this->getName() !== 'h' ) {
+ throw new MWException( 'Invalid h node passed to ' . __METHOD__ );
+ }
+ return [
+ 'i' => $this->node->getAttribute( 'i' ),
+ 'level' => $this->node->getAttribute( 'level' ),
+ 'contents' => $this->getChildren()
+ ];
+ }
+}
diff --git a/www/wiki/includes/parser/Preprocessor_Hash.php b/www/wiki/includes/parser/Preprocessor_Hash.php
new file mode 100644
index 00000000..332f8e9f
--- /dev/null
+++ b/www/wiki/includes/parser/Preprocessor_Hash.php
@@ -0,0 +1,2223 @@
+<?php
+/**
+ * Preprocessor using PHP arrays
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * Differences from DOM schema:
+ * * attribute nodes are children
+ * * "<h>" nodes that aren't at the top are replaced with <possible-h>
+ *
+ * Nodes are stored in a recursive array data structure. A node store is an
+ * array where each element may be either a scalar (representing a text node)
+ * or a "descriptor", which is a two-element array where the first element is
+ * the node name and the second element is the node store for the children.
+ *
+ * Attributes are represented as children that have a node name starting with
+ * "@", and a single text node child.
+ *
+ * @todo: Consider replacing descriptor arrays with objects of a new class.
+ * Benchmark and measure resulting memory impact.
+ *
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class Preprocessor_Hash extends Preprocessor {
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * @var Parser
+ */
+ public $parser;
+
+ const CACHE_PREFIX = 'preprocess-hash';
+ const CACHE_VERSION = 2;
+
+ public function __construct( $parser ) {
+ $this->parser = $parser;
+ }
+
+ /**
+ * @return PPFrame_Hash
+ */
+ public function newFrame() {
+ return new PPFrame_Hash( $this );
+ }
+
+ /**
+ * @param array $args
+ * @return PPCustomFrame_Hash
+ */
+ public function newCustomFrame( $args ) {
+ return new PPCustomFrame_Hash( $this, $args );
+ }
+
+ /**
+ * @param array $values
+ * @return PPNode_Hash_Array
+ */
+ public function newPartNodeArray( $values ) {
+ $list = [];
+
+ foreach ( $values as $k => $val ) {
+ if ( is_int( $k ) ) {
+ $store = [ [ 'part', [
+ [ 'name', [ [ '@index', [ $k ] ] ] ],
+ [ 'value', [ strval( $val ) ] ],
+ ] ] ];
+ } else {
+ $store = [ [ 'part', [
+ [ 'name', [ strval( $k ) ] ],
+ '=',
+ [ 'value', [ strval( $val ) ] ],
+ ] ] ];
+ }
+
+ $list[] = new PPNode_Hash_Tree( $store, 0 );
+ }
+
+ $node = new PPNode_Hash_Array( $list );
+ return $node;
+ }
+
+ /**
+ * Preprocess some wikitext and return the document tree.
+ *
+ * @param string $text The text to parse
+ * @param int $flags Bitwise combination of:
+ * Parser::PTD_FOR_INCLUSION Handle "<noinclude>" and "<includeonly>" as if the text is being
+ * included. Default is to assume a direct page view.
+ *
+ * The generated DOM tree must depend only on the input text and the flags.
+ * The DOM tree must be the same in OT_HTML and OT_WIKI mode, to avoid a regression of T6899.
+ *
+ * Any flag added to the $flags parameter here, or any other parameter liable to cause a
+ * change in the DOM tree for a given text, must be passed through the section identifier
+ * in the section edit link and thus back to extractSections().
+ *
+ * @throws MWException
+ * @return PPNode_Hash_Tree
+ */
+ public function preprocessToObj( $text, $flags = 0 ) {
+ global $wgDisableLangConversion;
+
+ $tree = $this->cacheGetTree( $text, $flags );
+ if ( $tree !== false ) {
+ $store = json_decode( $tree );
+ if ( is_array( $store ) ) {
+ return new PPNode_Hash_Tree( $store, 0 );
+ }
+ }
+
+ $forInclusion = $flags & Parser::PTD_FOR_INCLUSION;
+
+ $xmlishElements = $this->parser->getStripList();
+ $xmlishAllowMissingEndTag = [ 'includeonly', 'noinclude', 'onlyinclude' ];
+ $enableOnlyinclude = false;
+ if ( $forInclusion ) {
+ $ignoredTags = [ 'includeonly', '/includeonly' ];
+ $ignoredElements = [ 'noinclude' ];
+ $xmlishElements[] = 'noinclude';
+ if ( strpos( $text, '<onlyinclude>' ) !== false
+ && strpos( $text, '</onlyinclude>' ) !== false
+ ) {
+ $enableOnlyinclude = true;
+ }
+ } else {
+ $ignoredTags = [ 'noinclude', '/noinclude', 'onlyinclude', '/onlyinclude' ];
+ $ignoredElements = [ 'includeonly' ];
+ $xmlishElements[] = 'includeonly';
+ }
+ $xmlishRegex = implode( '|', array_merge( $xmlishElements, $ignoredTags ) );
+
+ // Use "A" modifier (anchored) instead of "^", because ^ doesn't work with an offset
+ $elementsRegex = "~($xmlishRegex)(?:\s|\/>|>)|(!--)~iA";
+
+ $stack = new PPDStack_Hash;
+
+ $searchBase = "[{<\n";
+ if ( !$wgDisableLangConversion ) {
+ $searchBase .= '-';
+ }
+
+ // For fast reverse searches
+ $revText = strrev( $text );
+ $lengthText = strlen( $text );
+
+ // Input pointer, starts out pointing to a pseudo-newline before the start
+ $i = 0;
+ // Current accumulator. See the doc comment for Preprocessor_Hash for the format.
+ $accum =& $stack->getAccum();
+ // True to find equals signs in arguments
+ $findEquals = false;
+ // True to take notice of pipe characters
+ $findPipe = false;
+ $headingIndex = 1;
+ // True if $i is inside a possible heading
+ $inHeading = false;
+ // True if there are no more greater-than (>) signs right of $i
+ $noMoreGT = false;
+ // Map of tag name => true if there are no more closing tags of given type right of $i
+ $noMoreClosingTag = [];
+ // True to ignore all input up to the next <onlyinclude>
+ $findOnlyinclude = $enableOnlyinclude;
+ // Do a line-start run without outputting an LF character
+ $fakeLineStart = true;
+
+ while ( true ) {
+ // $this->memCheck();
+
+ if ( $findOnlyinclude ) {
+ // Ignore all input up to the next <onlyinclude>
+ $startPos = strpos( $text, '<onlyinclude>', $i );
+ if ( $startPos === false ) {
+ // Ignored section runs to the end
+ $accum[] = [ 'ignore', [ substr( $text, $i ) ] ];
+ break;
+ }
+ $tagEndPos = $startPos + strlen( '<onlyinclude>' ); // past-the-end
+ $accum[] = [ 'ignore', [ substr( $text, $i, $tagEndPos - $i ) ] ];
+ $i = $tagEndPos;
+ $findOnlyinclude = false;
+ }
+
+ if ( $fakeLineStart ) {
+ $found = 'line-start';
+ $curChar = '';
+ } else {
+ # Find next opening brace, closing brace or pipe
+ $search = $searchBase;
+ if ( $stack->top === false ) {
+ $currentClosing = '';
+ } elseif (
+ $stack->top->close === '}-' &&
+ $stack->top->count > 2
+ ) {
+ # adjust closing for -{{{...{{
+ $currentClosing = '}';
+ $search .= $currentClosing;
+ } else {
+ $currentClosing = $stack->top->close;
+ $search .= $currentClosing;
+ }
+ if ( $findPipe ) {
+ $search .= '|';
+ }
+ if ( $findEquals ) {
+ // First equals will be for the template
+ $search .= '=';
+ }
+ $rule = null;
+ # Output literal section, advance input counter
+ $literalLength = strcspn( $text, $search, $i );
+ if ( $literalLength > 0 ) {
+ self::addLiteral( $accum, substr( $text, $i, $literalLength ) );
+ $i += $literalLength;
+ }
+ if ( $i >= $lengthText ) {
+ if ( $currentClosing == "\n" ) {
+ // Do a past-the-end run to finish off the heading
+ $curChar = '';
+ $found = 'line-end';
+ } else {
+ # All done
+ break;
+ }
+ } else {
+ $curChar = $curTwoChar = $text[$i];
+ if ( ( $i + 1 ) < $lengthText ) {
+ $curTwoChar .= $text[$i + 1];
+ }
+ if ( $curChar == '|' ) {
+ $found = 'pipe';
+ } elseif ( $curChar == '=' ) {
+ $found = 'equals';
+ } elseif ( $curChar == '<' ) {
+ $found = 'angle';
+ } elseif ( $curChar == "\n" ) {
+ if ( $inHeading ) {
+ $found = 'line-end';
+ } else {
+ $found = 'line-start';
+ }
+ } elseif ( $curTwoChar == $currentClosing ) {
+ $found = 'close';
+ $curChar = $curTwoChar;
+ } elseif ( $curChar == $currentClosing ) {
+ $found = 'close';
+ } elseif ( isset( $this->rules[$curTwoChar] ) ) {
+ $curChar = $curTwoChar;
+ $found = 'open';
+ $rule = $this->rules[$curChar];
+ } elseif ( isset( $this->rules[$curChar] ) ) {
+ $found = 'open';
+ $rule = $this->rules[$curChar];
+ } else {
+ # Some versions of PHP have a strcspn which stops on
+ # null characters; ignore these and continue.
+ # We also may get '-' and '}' characters here which
+ # don't match -{ or $currentClosing. Add these to
+ # output and continue.
+ if ( $curChar == '-' || $curChar == '}' ) {
+ self::addLiteral( $accum, $curChar );
+ }
+ ++$i;
+ continue;
+ }
+ }
+ }
+
+ if ( $found == 'angle' ) {
+ $matches = false;
+ // Handle </onlyinclude>
+ if ( $enableOnlyinclude
+ && substr( $text, $i, strlen( '</onlyinclude>' ) ) == '</onlyinclude>'
+ ) {
+ $findOnlyinclude = true;
+ continue;
+ }
+
+ // Determine element name
+ if ( !preg_match( $elementsRegex, $text, $matches, 0, $i + 1 ) ) {
+ // Element name missing or not listed
+ self::addLiteral( $accum, '<' );
+ ++$i;
+ continue;
+ }
+ // Handle comments
+ if ( isset( $matches[2] ) && $matches[2] == '!--' ) {
+ // To avoid leaving blank lines, when a sequence of
+ // space-separated comments is both preceded and followed by
+ // a newline (ignoring spaces), then
+ // trim leading and trailing spaces and the trailing newline.
+
+ // Find the end
+ $endPos = strpos( $text, '-->', $i + 4 );
+ if ( $endPos === false ) {
+ // Unclosed comment in input, runs to end
+ $inner = substr( $text, $i );
+ $accum[] = [ 'comment', [ $inner ] ];
+ $i = $lengthText;
+ } else {
+ // Search backwards for leading whitespace
+ $wsStart = $i ? ( $i - strspn( $revText, " \t", $lengthText - $i ) ) : 0;
+
+ // Search forwards for trailing whitespace
+ // $wsEnd will be the position of the last space (or the '>' if there's none)
+ $wsEnd = $endPos + 2 + strspn( $text, " \t", $endPos + 3 );
+
+ // Keep looking forward as long as we're finding more
+ // comments.
+ $comments = [ [ $wsStart, $wsEnd ] ];
+ while ( substr( $text, $wsEnd + 1, 4 ) == '<!--' ) {
+ $c = strpos( $text, '-->', $wsEnd + 4 );
+ if ( $c === false ) {
+ break;
+ }
+ $c = $c + 2 + strspn( $text, " \t", $c + 3 );
+ $comments[] = [ $wsEnd + 1, $c ];
+ $wsEnd = $c;
+ }
+
+ // Eat the line if possible
+ // TODO: This could theoretically be done if $wsStart == 0, i.e. for comments at
+ // the overall start. That's not how Sanitizer::removeHTMLcomments() did it, but
+ // it's a possible beneficial b/c break.
+ if ( $wsStart > 0 && substr( $text, $wsStart - 1, 1 ) == "\n"
+ && substr( $text, $wsEnd + 1, 1 ) == "\n"
+ ) {
+ // Remove leading whitespace from the end of the accumulator
+ $wsLength = $i - $wsStart;
+ $endIndex = count( $accum ) - 1;
+
+ // Sanity check
+ if ( $wsLength > 0
+ && $endIndex >= 0
+ && is_string( $accum[$endIndex] )
+ && strspn( $accum[$endIndex], " \t", -$wsLength ) === $wsLength
+ ) {
+ $accum[$endIndex] = substr( $accum[$endIndex], 0, -$wsLength );
+ }
+
+ // Dump all but the last comment to the accumulator
+ foreach ( $comments as $j => $com ) {
+ $startPos = $com[0];
+ $endPos = $com[1] + 1;
+ if ( $j == ( count( $comments ) - 1 ) ) {
+ break;
+ }
+ $inner = substr( $text, $startPos, $endPos - $startPos );
+ $accum[] = [ 'comment', [ $inner ] ];
+ }
+
+ // Do a line-start run next time to look for headings after the comment
+ $fakeLineStart = true;
+ } else {
+ // No line to eat, just take the comment itself
+ $startPos = $i;
+ $endPos += 2;
+ }
+
+ if ( $stack->top ) {
+ $part = $stack->top->getCurrentPart();
+ if ( !( isset( $part->commentEnd ) && $part->commentEnd == $wsStart - 1 ) ) {
+ $part->visualEnd = $wsStart;
+ }
+ // Else comments abutting, no change in visual end
+ $part->commentEnd = $endPos;
+ }
+ $i = $endPos + 1;
+ $inner = substr( $text, $startPos, $endPos - $startPos + 1 );
+ $accum[] = [ 'comment', [ $inner ] ];
+ }
+ continue;
+ }
+ $name = $matches[1];
+ $lowerName = strtolower( $name );
+ $attrStart = $i + strlen( $name ) + 1;
+
+ // Find end of tag
+ $tagEndPos = $noMoreGT ? false : strpos( $text, '>', $attrStart );
+ if ( $tagEndPos === false ) {
+ // Infinite backtrack
+ // Disable tag search to prevent worst-case O(N^2) performance
+ $noMoreGT = true;
+ self::addLiteral( $accum, '<' );
+ ++$i;
+ continue;
+ }
+
+ // Handle ignored tags
+ if ( in_array( $lowerName, $ignoredTags ) ) {
+ $accum[] = [ 'ignore', [ substr( $text, $i, $tagEndPos - $i + 1 ) ] ];
+ $i = $tagEndPos + 1;
+ continue;
+ }
+
+ $tagStartPos = $i;
+ if ( $text[$tagEndPos - 1] == '/' ) {
+ // Short end tag
+ $attrEnd = $tagEndPos - 1;
+ $inner = null;
+ $i = $tagEndPos + 1;
+ $close = null;
+ } else {
+ $attrEnd = $tagEndPos;
+ // Find closing tag
+ if (
+ !isset( $noMoreClosingTag[$name] ) &&
+ preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
+ $text, $matches, PREG_OFFSET_CAPTURE, $tagEndPos + 1 )
+ ) {
+ $inner = substr( $text, $tagEndPos + 1, $matches[0][1] - $tagEndPos - 1 );
+ $i = $matches[0][1] + strlen( $matches[0][0] );
+ $close = $matches[0][0];
+ } else {
+ // No end tag
+ if ( in_array( $name, $xmlishAllowMissingEndTag ) ) {
+ // Let it run out to the end of the text.
+ $inner = substr( $text, $tagEndPos + 1 );
+ $i = $lengthText;
+ $close = null;
+ } else {
+ // Don't match the tag, treat opening tag as literal and resume parsing.
+ $i = $tagEndPos + 1;
+ self::addLiteral( $accum,
+ substr( $text, $tagStartPos, $tagEndPos + 1 - $tagStartPos ) );
+ // Cache results, otherwise we have O(N^2) performance for input like <foo><foo><foo>...
+ $noMoreClosingTag[$name] = true;
+ continue;
+ }
+ }
+ }
+ // <includeonly> and <noinclude> just become <ignore> tags
+ if ( in_array( $lowerName, $ignoredElements ) ) {
+ $accum[] = [ 'ignore', [ substr( $text, $tagStartPos, $i - $tagStartPos ) ] ];
+ continue;
+ }
+
+ if ( $attrEnd <= $attrStart ) {
+ $attr = '';
+ } else {
+ // Note that the attr element contains the whitespace between name and attribute,
+ // this is necessary for precise reconstruction during pre-save transform.
+ $attr = substr( $text, $attrStart, $attrEnd - $attrStart );
+ }
+
+ $children = [
+ [ 'name', [ $name ] ],
+ [ 'attr', [ $attr ] ] ];
+ if ( $inner !== null ) {
+ $children[] = [ 'inner', [ $inner ] ];
+ }
+ if ( $close !== null ) {
+ $children[] = [ 'close', [ $close ] ];
+ }
+ $accum[] = [ 'ext', $children ];
+ } elseif ( $found == 'line-start' ) {
+ // Is this the start of a heading?
+ // Line break belongs before the heading element in any case
+ if ( $fakeLineStart ) {
+ $fakeLineStart = false;
+ } else {
+ self::addLiteral( $accum, $curChar );
+ $i++;
+ }
+
+ $count = strspn( $text, '=', $i, 6 );
+ if ( $count == 1 && $findEquals ) {
+ // DWIM: This looks kind of like a name/value separator.
+ // Let's let the equals handler have it and break the potential
+ // heading. This is heuristic, but AFAICT the methods for
+ // completely correct disambiguation are very complex.
+ } elseif ( $count > 0 ) {
+ $piece = [
+ 'open' => "\n",
+ 'close' => "\n",
+ 'parts' => [ new PPDPart_Hash( str_repeat( '=', $count ) ) ],
+ 'startPos' => $i,
+ 'count' => $count ];
+ $stack->push( $piece );
+ $accum =& $stack->getAccum();
+ extract( $stack->getFlags() );
+ $i += $count;
+ }
+ } elseif ( $found == 'line-end' ) {
+ $piece = $stack->top;
+ // A heading must be open, otherwise \n wouldn't have been in the search list
+ assert( $piece->open === "\n" );
+ $part = $piece->getCurrentPart();
+ // Search back through the input to see if it has a proper close.
+ // Do this using the reversed string since the other solutions
+ // (end anchor, etc.) are inefficient.
+ $wsLength = strspn( $revText, " \t", $lengthText - $i );
+ $searchStart = $i - $wsLength;
+ if ( isset( $part->commentEnd ) && $searchStart - 1 == $part->commentEnd ) {
+ // Comment found at line end
+ // Search for equals signs before the comment
+ $searchStart = $part->visualEnd;
+ $searchStart -= strspn( $revText, " \t", $lengthText - $searchStart );
+ }
+ $count = $piece->count;
+ $equalsLength = strspn( $revText, '=', $lengthText - $searchStart );
+ if ( $equalsLength > 0 ) {
+ if ( $searchStart - $equalsLength == $piece->startPos ) {
+ // This is just a single string of equals signs on its own line
+ // Replicate the doHeadings behavior /={count}(.+)={count}/
+ // First find out how many equals signs there really are (don't stop at 6)
+ $count = $equalsLength;
+ if ( $count < 3 ) {
+ $count = 0;
+ } else {
+ $count = min( 6, intval( ( $count - 1 ) / 2 ) );
+ }
+ } else {
+ $count = min( $equalsLength, $count );
+ }
+ if ( $count > 0 ) {
+ // Normal match, output <h>
+ $element = [ [ 'possible-h',
+ array_merge(
+ [
+ [ '@level', [ $count ] ],
+ [ '@i', [ $headingIndex++ ] ]
+ ],
+ $accum
+ )
+ ] ];
+ } else {
+ // Single equals sign on its own line, count=0
+ $element = $accum;
+ }
+ } else {
+ // No match, no <h>, just pass down the inner text
+ $element = $accum;
+ }
+ // Unwind the stack
+ $stack->pop();
+ $accum =& $stack->getAccum();
+ extract( $stack->getFlags() );
+
+ // Append the result to the enclosing accumulator
+ array_splice( $accum, count( $accum ), 0, $element );
+
+ // Note that we do NOT increment the input pointer.
+ // This is because the closing linebreak could be the opening linebreak of
+ // another heading. Infinite loops are avoided because the next iteration MUST
+ // hit the heading open case above, which unconditionally increments the
+ // input pointer.
+ } elseif ( $found == 'open' ) {
+ # count opening brace characters
+ $curLen = strlen( $curChar );
+ $count = ( $curLen > 1 ) ?
+ # allow the final character to repeat
+ strspn( $text, $curChar[$curLen - 1], $i + 1 ) + 1 :
+ strspn( $text, $curChar, $i );
+
+ # we need to add to stack only if opening brace count is enough for one of the rules
+ if ( $count >= $rule['min'] ) {
+ # Add it to the stack
+ $piece = [
+ 'open' => $curChar,
+ 'close' => $rule['end'],
+ 'count' => $count,
+ 'lineStart' => ( $i > 0 && $text[$i - 1] == "\n" ),
+ ];
+
+ $stack->push( $piece );
+ $accum =& $stack->getAccum();
+ extract( $stack->getFlags() );
+ } else {
+ # Add literal brace(s)
+ self::addLiteral( $accum, str_repeat( $curChar, $count ) );
+ }
+ $i += $count;
+ } elseif ( $found == 'close' ) {
+ $piece = $stack->top;
+ # lets check if there are enough characters for closing brace
+ $maxCount = $piece->count;
+ if ( $piece->close === '}-' && $curChar === '}' ) {
+ $maxCount--; # don't try to match closing '-' as a '}'
+ }
+ $curLen = strlen( $curChar );
+ $count = ( $curLen > 1 ) ? $curLen :
+ strspn( $text, $curChar, $i, $maxCount );
+
+ # check for maximum matching characters (if there are 5 closing
+ # characters, we will probably need only 3 - depending on the rules)
+ $rule = $this->rules[$piece->open];
+ if ( $piece->close === '}-' && $piece->count > 2 ) {
+ # tweak for -{..{{ }}..}-
+ $rule = $this->rules['{'];
+ }
+ if ( $count > $rule['max'] ) {
+ # The specified maximum exists in the callback array, unless the caller
+ # has made an error
+ $matchingCount = $rule['max'];
+ } else {
+ # Count is less than the maximum
+ # Skip any gaps in the callback array to find the true largest match
+ # Need to use array_key_exists not isset because the callback can be null
+ $matchingCount = $count;
+ while ( $matchingCount > 0 && !array_key_exists( $matchingCount, $rule['names'] ) ) {
+ --$matchingCount;
+ }
+ }
+
+ if ( $matchingCount <= 0 ) {
+ # No matching element found in callback array
+ # Output a literal closing brace and continue
+ $endText = substr( $text, $i, $count );
+ self::addLiteral( $accum, $endText );
+ $i += $count;
+ continue;
+ }
+ $name = $rule['names'][$matchingCount];
+ if ( $name === null ) {
+ // No element, just literal text
+ $endText = substr( $text, $i, $matchingCount );
+ $element = $piece->breakSyntax( $matchingCount );
+ self::addLiteral( $element, $endText );
+ } else {
+ # Create XML element
+ $parts = $piece->parts;
+ $titleAccum = $parts[0]->out;
+ unset( $parts[0] );
+
+ $children = [];
+
+ # The invocation is at the start of the line if lineStart is set in
+ # the stack, and all opening brackets are used up.
+ if ( $maxCount == $matchingCount && !empty( $piece->lineStart ) ) {
+ $children[] = [ '@lineStart', [ 1 ] ];
+ }
+ $titleNode = [ 'title', $titleAccum ];
+ $children[] = $titleNode;
+ $argIndex = 1;
+ foreach ( $parts as $part ) {
+ if ( isset( $part->eqpos ) ) {
+ $equalsNode = $part->out[$part->eqpos];
+ $nameNode = [ 'name', array_slice( $part->out, 0, $part->eqpos ) ];
+ $valueNode = [ 'value', array_slice( $part->out, $part->eqpos + 1 ) ];
+ $partNode = [ 'part', [ $nameNode, $equalsNode, $valueNode ] ];
+ $children[] = $partNode;
+ } else {
+ $nameNode = [ 'name', [ [ '@index', [ $argIndex++ ] ] ] ];
+ $valueNode = [ 'value', $part->out ];
+ $partNode = [ 'part', [ $nameNode, $valueNode ] ];
+ $children[] = $partNode;
+ }
+ }
+ $element = [ [ $name, $children ] ];
+ }
+
+ # Advance input pointer
+ $i += $matchingCount;
+
+ # Unwind the stack
+ $stack->pop();
+ $accum =& $stack->getAccum();
+
+ # Re-add the old stack element if it still has unmatched opening characters remaining
+ if ( $matchingCount < $piece->count ) {
+ $piece->parts = [ new PPDPart_Hash ];
+ $piece->count -= $matchingCount;
+ # do we still qualify for any callback with remaining count?
+ $min = $this->rules[$piece->open]['min'];
+ if ( $piece->count >= $min ) {
+ $stack->push( $piece );
+ $accum =& $stack->getAccum();
+ } else {
+ $s = substr( $piece->open, 0, -1 );
+ $s .= str_repeat(
+ substr( $piece->open, -1 ),
+ $piece->count - strlen( $s )
+ );
+ self::addLiteral( $accum, $s );
+ }
+ }
+
+ extract( $stack->getFlags() );
+
+ # Add XML element to the enclosing accumulator
+ array_splice( $accum, count( $accum ), 0, $element );
+ } elseif ( $found == 'pipe' ) {
+ $findEquals = true; // shortcut for getFlags()
+ $stack->addPart();
+ $accum =& $stack->getAccum();
+ ++$i;
+ } elseif ( $found == 'equals' ) {
+ $findEquals = false; // shortcut for getFlags()
+ $accum[] = [ 'equals', [ '=' ] ];
+ $stack->getCurrentPart()->eqpos = count( $accum ) - 1;
+ ++$i;
+ } elseif ( $found == 'dash' ) {
+ self::addLiteral( $accum, '-' );
+ ++$i;
+ }
+ }
+
+ # Output any remaining unclosed brackets
+ foreach ( $stack->stack as $piece ) {
+ array_splice( $stack->rootAccum, count( $stack->rootAccum ), 0, $piece->breakSyntax() );
+ }
+
+ # Enable top-level headings
+ foreach ( $stack->rootAccum as &$node ) {
+ if ( is_array( $node ) && $node[PPNode_Hash_Tree::NAME] === 'possible-h' ) {
+ $node[PPNode_Hash_Tree::NAME] = 'h';
+ }
+ }
+
+ $rootStore = [ [ 'root', $stack->rootAccum ] ];
+ $rootNode = new PPNode_Hash_Tree( $rootStore, 0 );
+
+ // Cache
+ $tree = json_encode( $rootStore, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+ if ( $tree !== false ) {
+ $this->cacheSetTree( $text, $flags, $tree );
+ }
+
+ return $rootNode;
+ }
+
+ private static function addLiteral( array &$accum, $text ) {
+ $n = count( $accum );
+ if ( $n && is_string( $accum[$n - 1] ) ) {
+ $accum[$n - 1] .= $text;
+ } else {
+ $accum[] = $text;
+ }
+ }
+}
+
+/**
+ * Stack class to help Preprocessor::preprocessToObj()
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPDStack_Hash extends PPDStack {
+ // @codingStandardsIgnoreEnd
+
+ public function __construct() {
+ $this->elementClass = 'PPDStackElement_Hash';
+ parent::__construct();
+ $this->rootAccum = [];
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPDStackElement_Hash extends PPDStackElement {
+ // @codingStandardsIgnoreEnd
+
+ public function __construct( $data = [] ) {
+ $this->partClass = 'PPDPart_Hash';
+ parent::__construct( $data );
+ }
+
+ /**
+ * Get the accumulator that would result if the close is not found.
+ *
+ * @param int|bool $openingCount
+ * @return array
+ */
+ public function breakSyntax( $openingCount = false ) {
+ if ( $this->open == "\n" ) {
+ $accum = $this->parts[0]->out;
+ } else {
+ if ( $openingCount === false ) {
+ $openingCount = $this->count;
+ }
+ $s = substr( $this->open, 0, -1 );
+ $s .= str_repeat(
+ substr( $this->open, -1 ),
+ $openingCount - strlen( $s )
+ );
+ $accum = [ $s ];
+ $lastIndex = 0;
+ $first = true;
+ foreach ( $this->parts as $part ) {
+ if ( $first ) {
+ $first = false;
+ } elseif ( is_string( $accum[$lastIndex] ) ) {
+ $accum[$lastIndex] .= '|';
+ } else {
+ $accum[++$lastIndex] = '|';
+ }
+ foreach ( $part->out as $node ) {
+ if ( is_string( $node ) && is_string( $accum[$lastIndex] ) ) {
+ $accum[$lastIndex] .= $node;
+ } else {
+ $accum[++$lastIndex] = $node;
+ }
+ }
+ }
+ }
+ return $accum;
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPDPart_Hash extends PPDPart {
+ // @codingStandardsIgnoreEnd
+
+ public function __construct( $out = '' ) {
+ if ( $out !== '' ) {
+ $accum = [ $out ];
+ } else {
+ $accum = [];
+ }
+ parent::__construct( $accum );
+ }
+}
+
+/**
+ * An expansion frame, used as a context to expand the result of preprocessToObj()
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPFrame_Hash implements PPFrame {
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * @var Parser
+ */
+ public $parser;
+
+ /**
+ * @var Preprocessor
+ */
+ public $preprocessor;
+
+ /**
+ * @var Title
+ */
+ public $title;
+ public $titleCache;
+
+ /**
+ * Hashtable listing templates which are disallowed for expansion in this frame,
+ * having been encountered previously in parent frames.
+ */
+ public $loopCheckHash;
+
+ /**
+ * Recursion depth of this frame, top = 0
+ * Note that this is NOT the same as expansion depth in expand()
+ */
+ public $depth;
+
+ private $volatile = false;
+ private $ttl = null;
+
+ /**
+ * @var array
+ */
+ protected $childExpansionCache;
+
+ /**
+ * Construct a new preprocessor frame.
+ * @param Preprocessor $preprocessor The parent preprocessor
+ */
+ public function __construct( $preprocessor ) {
+ $this->preprocessor = $preprocessor;
+ $this->parser = $preprocessor->parser;
+ $this->title = $this->parser->mTitle;
+ $this->titleCache = [ $this->title ? $this->title->getPrefixedDBkey() : false ];
+ $this->loopCheckHash = [];
+ $this->depth = 0;
+ $this->childExpansionCache = [];
+ }
+
+ /**
+ * Create a new child frame
+ * $args is optionally a multi-root PPNode or array containing the template arguments
+ *
+ * @param array|bool|PPNode_Hash_Array $args
+ * @param Title|bool $title
+ * @param int $indexOffset
+ * @throws MWException
+ * @return PPTemplateFrame_Hash
+ */
+ public function newChild( $args = false, $title = false, $indexOffset = 0 ) {
+ $namedArgs = [];
+ $numberedArgs = [];
+ if ( $title === false ) {
+ $title = $this->title;
+ }
+ if ( $args !== false ) {
+ if ( $args instanceof PPNode_Hash_Array ) {
+ $args = $args->value;
+ } elseif ( !is_array( $args ) ) {
+ throw new MWException( __METHOD__ . ': $args must be array or PPNode_Hash_Array' );
+ }
+ foreach ( $args as $arg ) {
+ $bits = $arg->splitArg();
+ if ( $bits['index'] !== '' ) {
+ // Numbered parameter
+ $index = $bits['index'] - $indexOffset;
+ if ( isset( $namedArgs[$index] ) || isset( $numberedArgs[$index] ) ) {
+ $this->parser->getOutput()->addWarning( wfMessage( 'duplicate-args-warning',
+ wfEscapeWikiText( $this->title ),
+ wfEscapeWikiText( $title ),
+ wfEscapeWikiText( $index ) )->text() );
+ $this->parser->addTrackingCategory( 'duplicate-args-category' );
+ }
+ $numberedArgs[$index] = $bits['value'];
+ unset( $namedArgs[$index] );
+ } else {
+ // Named parameter
+ $name = trim( $this->expand( $bits['name'], PPFrame::STRIP_COMMENTS ) );
+ if ( isset( $namedArgs[$name] ) || isset( $numberedArgs[$name] ) ) {
+ $this->parser->getOutput()->addWarning( wfMessage( 'duplicate-args-warning',
+ wfEscapeWikiText( $this->title ),
+ wfEscapeWikiText( $title ),
+ wfEscapeWikiText( $name ) )->text() );
+ $this->parser->addTrackingCategory( 'duplicate-args-category' );
+ }
+ $namedArgs[$name] = $bits['value'];
+ unset( $numberedArgs[$name] );
+ }
+ }
+ }
+ return new PPTemplateFrame_Hash( $this->preprocessor, $this, $numberedArgs, $namedArgs, $title );
+ }
+
+ /**
+ * @throws MWException
+ * @param string|int $key
+ * @param string|PPNode $root
+ * @param int $flags
+ * @return string
+ */
+ public function cachedExpand( $key, $root, $flags = 0 ) {
+ // we don't have a parent, so we don't have a cache
+ return $this->expand( $root, $flags );
+ }
+
+ /**
+ * @throws MWException
+ * @param string|PPNode $root
+ * @param int $flags
+ * @return string
+ */
+ public function expand( $root, $flags = 0 ) {
+ static $expansionDepth = 0;
+ if ( is_string( $root ) ) {
+ return $root;
+ }
+
+ if ( ++$this->parser->mPPNodeCount > $this->parser->mOptions->getMaxPPNodeCount() ) {
+ $this->parser->limitationWarn( 'node-count-exceeded',
+ $this->parser->mPPNodeCount,
+ $this->parser->mOptions->getMaxPPNodeCount()
+ );
+ return '<span class="error">Node-count limit exceeded</span>';
+ }
+ if ( $expansionDepth > $this->parser->mOptions->getMaxPPExpandDepth() ) {
+ $this->parser->limitationWarn( 'expansion-depth-exceeded',
+ $expansionDepth,
+ $this->parser->mOptions->getMaxPPExpandDepth()
+ );
+ return '<span class="error">Expansion depth limit exceeded</span>';
+ }
+ ++$expansionDepth;
+ if ( $expansionDepth > $this->parser->mHighestExpansionDepth ) {
+ $this->parser->mHighestExpansionDepth = $expansionDepth;
+ }
+
+ $outStack = [ '', '' ];
+ $iteratorStack = [ false, $root ];
+ $indexStack = [ 0, 0 ];
+
+ while ( count( $iteratorStack ) > 1 ) {
+ $level = count( $outStack ) - 1;
+ $iteratorNode =& $iteratorStack[$level];
+ $out =& $outStack[$level];
+ $index =& $indexStack[$level];
+
+ if ( is_array( $iteratorNode ) ) {
+ if ( $index >= count( $iteratorNode ) ) {
+ // All done with this iterator
+ $iteratorStack[$level] = false;
+ $contextNode = false;
+ } else {
+ $contextNode = $iteratorNode[$index];
+ $index++;
+ }
+ } elseif ( $iteratorNode instanceof PPNode_Hash_Array ) {
+ if ( $index >= $iteratorNode->getLength() ) {
+ // All done with this iterator
+ $iteratorStack[$level] = false;
+ $contextNode = false;
+ } else {
+ $contextNode = $iteratorNode->item( $index );
+ $index++;
+ }
+ } else {
+ // Copy to $contextNode and then delete from iterator stack,
+ // because this is not an iterator but we do have to execute it once
+ $contextNode = $iteratorStack[$level];
+ $iteratorStack[$level] = false;
+ }
+
+ $newIterator = false;
+ $contextName = false;
+ $contextChildren = false;
+
+ if ( $contextNode === false ) {
+ // nothing to do
+ } elseif ( is_string( $contextNode ) ) {
+ $out .= $contextNode;
+ } elseif ( $contextNode instanceof PPNode_Hash_Array ) {
+ $newIterator = $contextNode;
+ } elseif ( $contextNode instanceof PPNode_Hash_Attr ) {
+ // No output
+ } elseif ( $contextNode instanceof PPNode_Hash_Text ) {
+ $out .= $contextNode->value;
+ } elseif ( $contextNode instanceof PPNode_Hash_Tree ) {
+ $contextName = $contextNode->name;
+ $contextChildren = $contextNode->getRawChildren();
+ } elseif ( is_array( $contextNode ) ) {
+ // Node descriptor array
+ if ( count( $contextNode ) !== 2 ) {
+ throw new MWException( __METHOD__.
+ ': found an array where a node descriptor should be' );
+ }
+ list( $contextName, $contextChildren ) = $contextNode;
+ } else {
+ throw new MWException( __METHOD__ . ': Invalid parameter type' );
+ }
+
+ // Handle node descriptor array or tree object
+ if ( $contextName === false ) {
+ // Not a node, already handled above
+ } elseif ( $contextName[0] === '@' ) {
+ // Attribute: no output
+ } elseif ( $contextName === 'template' ) {
+ # Double-brace expansion
+ $bits = PPNode_Hash_Tree::splitRawTemplate( $contextChildren );
+ if ( $flags & PPFrame::NO_TEMPLATES ) {
+ $newIterator = $this->virtualBracketedImplode(
+ '{{', '|', '}}',
+ $bits['title'],
+ $bits['parts']
+ );
+ } else {
+ $ret = $this->parser->braceSubstitution( $bits, $this );
+ if ( isset( $ret['object'] ) ) {
+ $newIterator = $ret['object'];
+ } else {
+ $out .= $ret['text'];
+ }
+ }
+ } elseif ( $contextName === 'tplarg' ) {
+ # Triple-brace expansion
+ $bits = PPNode_Hash_Tree::splitRawTemplate( $contextChildren );
+ if ( $flags & PPFrame::NO_ARGS ) {
+ $newIterator = $this->virtualBracketedImplode(
+ '{{{', '|', '}}}',
+ $bits['title'],
+ $bits['parts']
+ );
+ } else {
+ $ret = $this->parser->argSubstitution( $bits, $this );
+ if ( isset( $ret['object'] ) ) {
+ $newIterator = $ret['object'];
+ } else {
+ $out .= $ret['text'];
+ }
+ }
+ } elseif ( $contextName === 'comment' ) {
+ # HTML-style comment
+ # Remove it in HTML, pre+remove and STRIP_COMMENTS modes
+ # Not in RECOVER_COMMENTS mode (msgnw) though.
+ if ( ( $this->parser->ot['html']
+ || ( $this->parser->ot['pre'] && $this->parser->mOptions->getRemoveComments() )
+ || ( $flags & PPFrame::STRIP_COMMENTS )
+ ) && !( $flags & PPFrame::RECOVER_COMMENTS )
+ ) {
+ $out .= '';
+ } elseif ( $this->parser->ot['wiki'] && !( $flags & PPFrame::RECOVER_COMMENTS ) ) {
+ # Add a strip marker in PST mode so that pstPass2() can
+ # run some old-fashioned regexes on the result.
+ # Not in RECOVER_COMMENTS mode (extractSections) though.
+ $out .= $this->parser->insertStripItem( $contextChildren[0] );
+ } else {
+ # Recover the literal comment in RECOVER_COMMENTS and pre+no-remove
+ $out .= $contextChildren[0];
+ }
+ } elseif ( $contextName === 'ignore' ) {
+ # Output suppression used by <includeonly> etc.
+ # OT_WIKI will only respect <ignore> in substed templates.
+ # The other output types respect it unless NO_IGNORE is set.
+ # extractSections() sets NO_IGNORE and so never respects it.
+ if ( ( !isset( $this->parent ) && $this->parser->ot['wiki'] )
+ || ( $flags & PPFrame::NO_IGNORE )
+ ) {
+ $out .= $contextChildren[0];
+ } else {
+ // $out .= '';
+ }
+ } elseif ( $contextName === 'ext' ) {
+ # Extension tag
+ $bits = PPNode_Hash_Tree::splitRawExt( $contextChildren ) +
+ [ 'attr' => null, 'inner' => null, 'close' => null ];
+ if ( $flags & PPFrame::NO_TAGS ) {
+ $s = '<' . $bits['name']->getFirstChild()->value;
+ if ( $bits['attr'] ) {
+ $s .= $bits['attr']->getFirstChild()->value;
+ }
+ if ( $bits['inner'] ) {
+ $s .= '>' . $bits['inner']->getFirstChild()->value;
+ if ( $bits['close'] ) {
+ $s .= $bits['close']->getFirstChild()->value;
+ }
+ } else {
+ $s .= '/>';
+ }
+ $out .= $s;
+ } else {
+ $out .= $this->parser->extensionSubstitution( $bits, $this );
+ }
+ } elseif ( $contextName === 'h' ) {
+ # Heading
+ if ( $this->parser->ot['html'] ) {
+ # Expand immediately and insert heading index marker
+ $s = $this->expand( $contextChildren, $flags );
+ $bits = PPNode_Hash_Tree::splitRawHeading( $contextChildren );
+ $titleText = $this->title->getPrefixedDBkey();
+ $this->parser->mHeadings[] = [ $titleText, $bits['i'] ];
+ $serial = count( $this->parser->mHeadings ) - 1;
+ $marker = Parser::MARKER_PREFIX . "-h-$serial-" . Parser::MARKER_SUFFIX;
+ $s = substr( $s, 0, $bits['level'] ) . $marker . substr( $s, $bits['level'] );
+ $this->parser->mStripState->addGeneral( $marker, '' );
+ $out .= $s;
+ } else {
+ # Expand in virtual stack
+ $newIterator = $contextChildren;
+ }
+ } else {
+ # Generic recursive expansion
+ $newIterator = $contextChildren;
+ }
+
+ if ( $newIterator !== false ) {
+ $outStack[] = '';
+ $iteratorStack[] = $newIterator;
+ $indexStack[] = 0;
+ } elseif ( $iteratorStack[$level] === false ) {
+ // Return accumulated value to parent
+ // With tail recursion
+ while ( $iteratorStack[$level] === false && $level > 0 ) {
+ $outStack[$level - 1] .= $out;
+ array_pop( $outStack );
+ array_pop( $iteratorStack );
+ array_pop( $indexStack );
+ $level--;
+ }
+ }
+ }
+ --$expansionDepth;
+ return $outStack[0];
+ }
+
+ /**
+ * @param string $sep
+ * @param int $flags
+ * @param string|PPNode $args,...
+ * @return string
+ */
+ public function implodeWithFlags( $sep, $flags /*, ... */ ) {
+ $args = array_slice( func_get_args(), 2 );
+
+ $first = true;
+ $s = '';
+ foreach ( $args as $root ) {
+ if ( $root instanceof PPNode_Hash_Array ) {
+ $root = $root->value;
+ }
+ if ( !is_array( $root ) ) {
+ $root = [ $root ];
+ }
+ foreach ( $root as $node ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= $sep;
+ }
+ $s .= $this->expand( $node, $flags );
+ }
+ }
+ return $s;
+ }
+
+ /**
+ * Implode with no flags specified
+ * This previously called implodeWithFlags but has now been inlined to reduce stack depth
+ * @param string $sep
+ * @param string|PPNode $args,...
+ * @return string
+ */
+ public function implode( $sep /*, ... */ ) {
+ $args = array_slice( func_get_args(), 1 );
+
+ $first = true;
+ $s = '';
+ foreach ( $args as $root ) {
+ if ( $root instanceof PPNode_Hash_Array ) {
+ $root = $root->value;
+ }
+ if ( !is_array( $root ) ) {
+ $root = [ $root ];
+ }
+ foreach ( $root as $node ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= $sep;
+ }
+ $s .= $this->expand( $node );
+ }
+ }
+ return $s;
+ }
+
+ /**
+ * Makes an object that, when expand()ed, will be the same as one obtained
+ * with implode()
+ *
+ * @param string $sep
+ * @param string|PPNode $args,...
+ * @return PPNode_Hash_Array
+ */
+ public function virtualImplode( $sep /*, ... */ ) {
+ $args = array_slice( func_get_args(), 1 );
+ $out = [];
+ $first = true;
+
+ foreach ( $args as $root ) {
+ if ( $root instanceof PPNode_Hash_Array ) {
+ $root = $root->value;
+ }
+ if ( !is_array( $root ) ) {
+ $root = [ $root ];
+ }
+ foreach ( $root as $node ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $out[] = $sep;
+ }
+ $out[] = $node;
+ }
+ }
+ return new PPNode_Hash_Array( $out );
+ }
+
+ /**
+ * Virtual implode with brackets
+ *
+ * @param string $start
+ * @param string $sep
+ * @param string $end
+ * @param string|PPNode $args,...
+ * @return PPNode_Hash_Array
+ */
+ public function virtualBracketedImplode( $start, $sep, $end /*, ... */ ) {
+ $args = array_slice( func_get_args(), 3 );
+ $out = [ $start ];
+ $first = true;
+
+ foreach ( $args as $root ) {
+ if ( $root instanceof PPNode_Hash_Array ) {
+ $root = $root->value;
+ }
+ if ( !is_array( $root ) ) {
+ $root = [ $root ];
+ }
+ foreach ( $root as $node ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $out[] = $sep;
+ }
+ $out[] = $node;
+ }
+ }
+ $out[] = $end;
+ return new PPNode_Hash_Array( $out );
+ }
+
+ public function __toString() {
+ return 'frame{}';
+ }
+
+ /**
+ * @param bool $level
+ * @return array|bool|string
+ */
+ public function getPDBK( $level = false ) {
+ if ( $level === false ) {
+ return $this->title->getPrefixedDBkey();
+ } else {
+ return isset( $this->titleCache[$level] ) ? $this->titleCache[$level] : false;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function getArguments() {
+ return [];
+ }
+
+ /**
+ * @return array
+ */
+ public function getNumberedArguments() {
+ return [];
+ }
+
+ /**
+ * @return array
+ */
+ public function getNamedArguments() {
+ return [];
+ }
+
+ /**
+ * Returns true if there are no arguments in this frame
+ *
+ * @return bool
+ */
+ public function isEmpty() {
+ return true;
+ }
+
+ /**
+ * @param int|string $name
+ * @return bool Always false in this implementation.
+ */
+ public function getArgument( $name ) {
+ return false;
+ }
+
+ /**
+ * Returns true if the infinite loop check is OK, false if a loop is detected
+ *
+ * @param Title $title
+ *
+ * @return bool
+ */
+ public function loopCheck( $title ) {
+ return !isset( $this->loopCheckHash[$title->getPrefixedDBkey()] );
+ }
+
+ /**
+ * Return true if the frame is a template frame
+ *
+ * @return bool
+ */
+ public function isTemplate() {
+ return false;
+ }
+
+ /**
+ * Get a title of frame
+ *
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * Set the volatile flag
+ *
+ * @param bool $flag
+ */
+ public function setVolatile( $flag = true ) {
+ $this->volatile = $flag;
+ }
+
+ /**
+ * Get the volatile flag
+ *
+ * @return bool
+ */
+ public function isVolatile() {
+ return $this->volatile;
+ }
+
+ /**
+ * Set the TTL
+ *
+ * @param int $ttl
+ */
+ public function setTTL( $ttl ) {
+ if ( $ttl !== null && ( $this->ttl === null || $ttl < $this->ttl ) ) {
+ $this->ttl = $ttl;
+ }
+ }
+
+ /**
+ * Get the TTL
+ *
+ * @return int|null
+ */
+ public function getTTL() {
+ return $this->ttl;
+ }
+}
+
+/**
+ * Expansion frame with template arguments
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPTemplateFrame_Hash extends PPFrame_Hash {
+ // @codingStandardsIgnoreEnd
+
+ public $numberedArgs, $namedArgs, $parent;
+ public $numberedExpansionCache, $namedExpansionCache;
+
+ /**
+ * @param Preprocessor $preprocessor
+ * @param bool|PPFrame $parent
+ * @param array $numberedArgs
+ * @param array $namedArgs
+ * @param bool|Title $title
+ */
+ public function __construct( $preprocessor, $parent = false, $numberedArgs = [],
+ $namedArgs = [], $title = false
+ ) {
+ parent::__construct( $preprocessor );
+
+ $this->parent = $parent;
+ $this->numberedArgs = $numberedArgs;
+ $this->namedArgs = $namedArgs;
+ $this->title = $title;
+ $pdbk = $title ? $title->getPrefixedDBkey() : false;
+ $this->titleCache = $parent->titleCache;
+ $this->titleCache[] = $pdbk;
+ $this->loopCheckHash = /*clone*/ $parent->loopCheckHash;
+ if ( $pdbk !== false ) {
+ $this->loopCheckHash[$pdbk] = true;
+ }
+ $this->depth = $parent->depth + 1;
+ $this->numberedExpansionCache = $this->namedExpansionCache = [];
+ }
+
+ public function __toString() {
+ $s = 'tplframe{';
+ $first = true;
+ $args = $this->numberedArgs + $this->namedArgs;
+ foreach ( $args as $name => $value ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= ', ';
+ }
+ $s .= "\"$name\":\"" .
+ str_replace( '"', '\\"', $value->__toString() ) . '"';
+ }
+ $s .= '}';
+ return $s;
+ }
+
+ /**
+ * @throws MWException
+ * @param string|int $key
+ * @param string|PPNode $root
+ * @param int $flags
+ * @return string
+ */
+ public function cachedExpand( $key, $root, $flags = 0 ) {
+ if ( isset( $this->parent->childExpansionCache[$key] ) ) {
+ return $this->parent->childExpansionCache[$key];
+ }
+ $retval = $this->expand( $root, $flags );
+ if ( !$this->isVolatile() ) {
+ $this->parent->childExpansionCache[$key] = $retval;
+ }
+ return $retval;
+ }
+
+ /**
+ * Returns true if there are no arguments in this frame
+ *
+ * @return bool
+ */
+ public function isEmpty() {
+ return !count( $this->numberedArgs ) && !count( $this->namedArgs );
+ }
+
+ /**
+ * @return array
+ */
+ public function getArguments() {
+ $arguments = [];
+ foreach ( array_merge(
+ array_keys( $this->numberedArgs ),
+ array_keys( $this->namedArgs ) ) as $key ) {
+ $arguments[$key] = $this->getArgument( $key );
+ }
+ return $arguments;
+ }
+
+ /**
+ * @return array
+ */
+ public function getNumberedArguments() {
+ $arguments = [];
+ foreach ( array_keys( $this->numberedArgs ) as $key ) {
+ $arguments[$key] = $this->getArgument( $key );
+ }
+ return $arguments;
+ }
+
+ /**
+ * @return array
+ */
+ public function getNamedArguments() {
+ $arguments = [];
+ foreach ( array_keys( $this->namedArgs ) as $key ) {
+ $arguments[$key] = $this->getArgument( $key );
+ }
+ return $arguments;
+ }
+
+ /**
+ * @param int $index
+ * @return string|bool
+ */
+ public function getNumberedArgument( $index ) {
+ if ( !isset( $this->numberedArgs[$index] ) ) {
+ return false;
+ }
+ if ( !isset( $this->numberedExpansionCache[$index] ) ) {
+ # No trimming for unnamed arguments
+ $this->numberedExpansionCache[$index] = $this->parent->expand(
+ $this->numberedArgs[$index],
+ PPFrame::STRIP_COMMENTS
+ );
+ }
+ return $this->numberedExpansionCache[$index];
+ }
+
+ /**
+ * @param string $name
+ * @return string|bool
+ */
+ public function getNamedArgument( $name ) {
+ if ( !isset( $this->namedArgs[$name] ) ) {
+ return false;
+ }
+ if ( !isset( $this->namedExpansionCache[$name] ) ) {
+ # Trim named arguments post-expand, for backwards compatibility
+ $this->namedExpansionCache[$name] = trim(
+ $this->parent->expand( $this->namedArgs[$name], PPFrame::STRIP_COMMENTS ) );
+ }
+ return $this->namedExpansionCache[$name];
+ }
+
+ /**
+ * @param int|string $name
+ * @return string|bool
+ */
+ public function getArgument( $name ) {
+ $text = $this->getNumberedArgument( $name );
+ if ( $text === false ) {
+ $text = $this->getNamedArgument( $name );
+ }
+ return $text;
+ }
+
+ /**
+ * Return true if the frame is a template frame
+ *
+ * @return bool
+ */
+ public function isTemplate() {
+ return true;
+ }
+
+ public function setVolatile( $flag = true ) {
+ parent::setVolatile( $flag );
+ $this->parent->setVolatile( $flag );
+ }
+
+ public function setTTL( $ttl ) {
+ parent::setTTL( $ttl );
+ $this->parent->setTTL( $ttl );
+ }
+}
+
+/**
+ * Expansion frame with custom arguments
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPCustomFrame_Hash extends PPFrame_Hash {
+ // @codingStandardsIgnoreEnd
+
+ public $args;
+
+ public function __construct( $preprocessor, $args ) {
+ parent::__construct( $preprocessor );
+ $this->args = $args;
+ }
+
+ public function __toString() {
+ $s = 'cstmframe{';
+ $first = true;
+ foreach ( $this->args as $name => $value ) {
+ if ( $first ) {
+ $first = false;
+ } else {
+ $s .= ', ';
+ }
+ $s .= "\"$name\":\"" .
+ str_replace( '"', '\\"', $value->__toString() ) . '"';
+ }
+ $s .= '}';
+ return $s;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isEmpty() {
+ return !count( $this->args );
+ }
+
+ /**
+ * @param int|string $index
+ * @return string|bool
+ */
+ public function getArgument( $index ) {
+ if ( !isset( $this->args[$index] ) ) {
+ return false;
+ }
+ return $this->args[$index];
+ }
+
+ public function getArguments() {
+ return $this->args;
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPNode_Hash_Tree implements PPNode {
+ // @codingStandardsIgnoreEnd
+
+ public $name;
+
+ /**
+ * The store array for children of this node. It is "raw" in the sense that
+ * nodes are two-element arrays ("descriptors") rather than PPNode_Hash_*
+ * objects.
+ */
+ private $rawChildren;
+
+ /**
+ * The store array for the siblings of this node, including this node itself.
+ */
+ private $store;
+
+ /**
+ * The index into $this->store which contains the descriptor of this node.
+ */
+ private $index;
+
+ /**
+ * The offset of the name within descriptors, used in some places for
+ * readability.
+ */
+ const NAME = 0;
+
+ /**
+ * The offset of the child list within descriptors, used in some places for
+ * readability.
+ */
+ const CHILDREN = 1;
+
+ /**
+ * Construct an object using the data from $store[$index]. The rest of the
+ * store array can be accessed via getNextSibling().
+ *
+ * @param array $store
+ * @param int $index
+ */
+ public function __construct( array $store, $index ) {
+ $this->store = $store;
+ $this->index = $index;
+ list( $this->name, $this->rawChildren ) = $this->store[$index];
+ }
+
+ /**
+ * Construct an appropriate PPNode_Hash_* object with a class that depends
+ * on what is at the relevant store index.
+ *
+ * @param array $store
+ * @param int $index
+ * @return PPNode_Hash_Tree|PPNode_Hash_Attr|PPNode_Hash_Text
+ */
+ public static function factory( array $store, $index ) {
+ if ( !isset( $store[$index] ) ) {
+ return false;
+ }
+
+ $descriptor = $store[$index];
+ if ( is_string( $descriptor ) ) {
+ $class = 'PPNode_Hash_Text';
+ } elseif ( is_array( $descriptor ) ) {
+ if ( $descriptor[self::NAME][0] === '@' ) {
+ $class = 'PPNode_Hash_Attr';
+ } else {
+ $class = 'PPNode_Hash_Tree';
+ }
+ } else {
+ throw new MWException( __METHOD__.': invalid node descriptor' );
+ }
+ return new $class( $store, $index );
+ }
+
+ /**
+ * Convert a node to XML, for debugging
+ */
+ public function __toString() {
+ $inner = '';
+ $attribs = '';
+ for ( $node = $this->getFirstChild(); $node; $node = $node->getNextSibling() ) {
+ if ( $node instanceof PPNode_Hash_Attr ) {
+ $attribs .= ' ' . $node->name . '="' . htmlspecialchars( $node->value ) . '"';
+ } else {
+ $inner .= $node->__toString();
+ }
+ }
+ if ( $inner === '' ) {
+ return "<{$this->name}$attribs/>";
+ } else {
+ return "<{$this->name}$attribs>$inner</{$this->name}>";
+ }
+ }
+
+ /**
+ * @return PPNode_Hash_Array
+ */
+ public function getChildren() {
+ $children = [];
+ foreach ( $this->rawChildren as $i => $child ) {
+ $children[] = self::factory( $this->rawChildren, $i );
+ }
+ return new PPNode_Hash_Array( $children );
+ }
+
+ /**
+ * Get the first child, or false if there is none. Note that this will
+ * return a temporary proxy object: different instances will be returned
+ * if this is called more than once on the same node.
+ *
+ * @return PPNode_Hash_Tree|PPNode_Hash_Attr|PPNode_Hash_Text|bool
+ */
+ public function getFirstChild() {
+ if ( !isset( $this->rawChildren[0] ) ) {
+ return false;
+ } else {
+ return self::factory( $this->rawChildren, 0 );
+ }
+ }
+
+ /**
+ * Get the next sibling, or false if there is none. Note that this will
+ * return a temporary proxy object: different instances will be returned
+ * if this is called more than once on the same node.
+ *
+ * @return PPNode_Hash_Tree|PPNode_Hash_Attr|PPNode_Hash_Text|bool
+ */
+ public function getNextSibling() {
+ return self::factory( $this->store, $this->index + 1 );
+ }
+
+ /**
+ * Get an array of the children with a given node name
+ *
+ * @param string $name
+ * @return PPNode_Hash_Array
+ */
+ public function getChildrenOfType( $name ) {
+ $children = [];
+ foreach ( $this->rawChildren as $i => $child ) {
+ if ( is_array( $child ) && $child[self::NAME] === $name ) {
+ $children[] = self::factory( $this->rawChildren, $i );
+ }
+ }
+ return new PPNode_Hash_Array( $children );
+ }
+
+ /**
+ * Get the raw child array. For internal use.
+ * @return array
+ */
+ public function getRawChildren() {
+ return $this->rawChildren;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getLength() {
+ return false;
+ }
+
+ /**
+ * @param int $i
+ * @return bool
+ */
+ public function item( $i ) {
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Split a "<part>" node into an associative array containing:
+ * - name PPNode name
+ * - index String index
+ * - value PPNode value
+ *
+ * @throws MWException
+ * @return array
+ */
+ public function splitArg() {
+ return self::splitRawArg( $this->rawChildren );
+ }
+
+ /**
+ * Like splitArg() but for a raw child array. For internal use only.
+ * @param array $children
+ * @return array
+ */
+ public static function splitRawArg( array $children ) {
+ $bits = [];
+ foreach ( $children as $i => $child ) {
+ if ( !is_array( $child ) ) {
+ continue;
+ }
+ if ( $child[self::NAME] === 'name' ) {
+ $bits['name'] = new self( $children, $i );
+ if ( isset( $child[self::CHILDREN][0][self::NAME] )
+ && $child[self::CHILDREN][0][self::NAME] === '@index'
+ ) {
+ $bits['index'] = $child[self::CHILDREN][0][self::CHILDREN][0];
+ }
+ } elseif ( $child[self::NAME] === 'value' ) {
+ $bits['value'] = new self( $children, $i );
+ }
+ }
+
+ if ( !isset( $bits['name'] ) ) {
+ throw new MWException( 'Invalid brace node passed to ' . __METHOD__ );
+ }
+ if ( !isset( $bits['index'] ) ) {
+ $bits['index'] = '';
+ }
+ return $bits;
+ }
+
+ /**
+ * Split an "<ext>" node into an associative array containing name, attr, inner and close
+ * All values in the resulting array are PPNodes. Inner and close are optional.
+ *
+ * @throws MWException
+ * @return array
+ */
+ public function splitExt() {
+ return self::splitRawExt( $this->rawChildren );
+ }
+
+ /**
+ * Like splitExt() but for a raw child array. For internal use only.
+ * @param array $children
+ * @return array
+ */
+ public static function splitRawExt( array $children ) {
+ $bits = [];
+ foreach ( $children as $i => $child ) {
+ if ( !is_array( $child ) ) {
+ continue;
+ }
+ switch ( $child[self::NAME] ) {
+ case 'name':
+ $bits['name'] = new self( $children, $i );
+ break;
+ case 'attr':
+ $bits['attr'] = new self( $children, $i );
+ break;
+ case 'inner':
+ $bits['inner'] = new self( $children, $i );
+ break;
+ case 'close':
+ $bits['close'] = new self( $children, $i );
+ break;
+ }
+ }
+ if ( !isset( $bits['name'] ) ) {
+ throw new MWException( 'Invalid ext node passed to ' . __METHOD__ );
+ }
+ return $bits;
+ }
+
+ /**
+ * Split an "<h>" node
+ *
+ * @throws MWException
+ * @return array
+ */
+ public function splitHeading() {
+ if ( $this->name !== 'h' ) {
+ throw new MWException( 'Invalid h node passed to ' . __METHOD__ );
+ }
+ return self::splitRawHeading( $this->rawChildren );
+ }
+
+ /**
+ * Like splitHeading() but for a raw child array. For internal use only.
+ * @param array $children
+ * @return array
+ */
+ public static function splitRawHeading( array $children ) {
+ $bits = [];
+ foreach ( $children as $i => $child ) {
+ if ( !is_array( $child ) ) {
+ continue;
+ }
+ if ( $child[self::NAME] === '@i' ) {
+ $bits['i'] = $child[self::CHILDREN][0];
+ } elseif ( $child[self::NAME] === '@level' ) {
+ $bits['level'] = $child[self::CHILDREN][0];
+ }
+ }
+ if ( !isset( $bits['i'] ) ) {
+ throw new MWException( 'Invalid h node passed to ' . __METHOD__ );
+ }
+ return $bits;
+ }
+
+ /**
+ * Split a "<template>" or "<tplarg>" node
+ *
+ * @throws MWException
+ * @return array
+ */
+ public function splitTemplate() {
+ return self::splitRawTemplate( $this->rawChildren );
+ }
+
+ /**
+ * Like splitTemplate() but for a raw child array. For internal use only.
+ * @param array $children
+ * @return array
+ */
+ public static function splitRawTemplate( array $children ) {
+ $parts = [];
+ $bits = [ 'lineStart' => '' ];
+ foreach ( $children as $i => $child ) {
+ if ( !is_array( $child ) ) {
+ continue;
+ }
+ switch ( $child[self::NAME] ) {
+ case 'title':
+ $bits['title'] = new self( $children, $i );
+ break;
+ case 'part':
+ $parts[] = new self( $children, $i );
+ break;
+ case '@lineStart':
+ $bits['lineStart'] = '1';
+ break;
+ }
+ }
+ if ( !isset( $bits['title'] ) ) {
+ throw new MWException( 'Invalid node passed to ' . __METHOD__ );
+ }
+ $bits['parts'] = new PPNode_Hash_Array( $parts );
+ return $bits;
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPNode_Hash_Text implements PPNode {
+ // @codingStandardsIgnoreEnd
+
+ public $value;
+ private $store, $index;
+
+ /**
+ * Construct an object using the data from $store[$index]. The rest of the
+ * store array can be accessed via getNextSibling().
+ *
+ * @param array $store
+ * @param int $index
+ */
+ public function __construct( array $store, $index ) {
+ $this->value = $store[$index];
+ if ( !is_scalar( $this->value ) ) {
+ throw new MWException( __CLASS__ . ' given object instead of string' );
+ }
+ $this->store = $store;
+ $this->index = $index;
+ }
+
+ public function __toString() {
+ return htmlspecialchars( $this->value );
+ }
+
+ public function getNextSibling() {
+ return PPNode_Hash_Tree::factory( $this->store, $this->index + 1 );
+ }
+
+ public function getChildren() {
+ return false;
+ }
+
+ public function getFirstChild() {
+ return false;
+ }
+
+ public function getChildrenOfType( $name ) {
+ return false;
+ }
+
+ public function getLength() {
+ return false;
+ }
+
+ public function item( $i ) {
+ return false;
+ }
+
+ public function getName() {
+ return '#text';
+ }
+
+ public function splitArg() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+
+ public function splitExt() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+
+ public function splitHeading() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPNode_Hash_Array implements PPNode {
+ // @codingStandardsIgnoreEnd
+
+ public $value;
+
+ public function __construct( $value ) {
+ $this->value = $value;
+ }
+
+ public function __toString() {
+ return var_export( $this, true );
+ }
+
+ public function getLength() {
+ return count( $this->value );
+ }
+
+ public function item( $i ) {
+ return $this->value[$i];
+ }
+
+ public function getName() {
+ return '#nodelist';
+ }
+
+ public function getNextSibling() {
+ return false;
+ }
+
+ public function getChildren() {
+ return false;
+ }
+
+ public function getFirstChild() {
+ return false;
+ }
+
+ public function getChildrenOfType( $name ) {
+ return false;
+ }
+
+ public function splitArg() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+
+ public function splitExt() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+
+ public function splitHeading() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+}
+
+/**
+ * @ingroup Parser
+ */
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PPNode_Hash_Attr implements PPNode {
+ // @codingStandardsIgnoreEnd
+
+ public $name, $value;
+ private $store, $index;
+
+ /**
+ * Construct an object using the data from $store[$index]. The rest of the
+ * store array can be accessed via getNextSibling().
+ *
+ * @param array $store
+ * @param int $index
+ */
+ public function __construct( array $store, $index ) {
+ $descriptor = $store[$index];
+ if ( $descriptor[PPNode_Hash_Tree::NAME][0] !== '@' ) {
+ throw new MWException( __METHOD__.': invalid name in attribute descriptor' );
+ }
+ $this->name = substr( $descriptor[PPNode_Hash_Tree::NAME], 1 );
+ $this->value = $descriptor[PPNode_Hash_Tree::CHILDREN][0];
+ $this->store = $store;
+ $this->index = $index;
+ }
+
+ public function __toString() {
+ return "<@{$this->name}>" . htmlspecialchars( $this->value ) . "</@{$this->name}>";
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+
+ public function getNextSibling() {
+ return PPNode_Hash_Tree::factory( $this->store, $this->index + 1 );
+ }
+
+ public function getChildren() {
+ return false;
+ }
+
+ public function getFirstChild() {
+ return false;
+ }
+
+ public function getChildrenOfType( $name ) {
+ return false;
+ }
+
+ public function getLength() {
+ return false;
+ }
+
+ public function item( $i ) {
+ return false;
+ }
+
+ public function splitArg() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+
+ public function splitExt() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+
+ public function splitHeading() {
+ throw new MWException( __METHOD__ . ': not supported' );
+ }
+}
diff --git a/www/wiki/includes/parser/StripState.php b/www/wiki/includes/parser/StripState.php
new file mode 100644
index 00000000..4ed176ce
--- /dev/null
+++ b/www/wiki/includes/parser/StripState.php
@@ -0,0 +1,242 @@
+<?php
+/**
+ * Holder for stripped items when parsing wiki markup.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ */
+
+/**
+ * @todo document, briefly.
+ * @ingroup Parser
+ */
+class StripState {
+ protected $prefix;
+ protected $data;
+ protected $regex;
+
+ protected $tempType, $tempMergePrefix;
+ protected $circularRefGuard;
+ protected $recursionLevel = 0;
+
+ const UNSTRIP_RECURSION_LIMIT = 20;
+
+ /**
+ * @param string|null $prefix
+ * @since 1.26 The prefix argument should be omitted, as the strip marker
+ * prefix string is now a constant.
+ */
+ public function __construct( $prefix = null ) {
+ if ( $prefix !== null ) {
+ wfDeprecated( __METHOD__ . ' with called with $prefix argument' .
+ ' (call with no arguments instead)', '1.26' );
+ }
+ $this->data = [
+ 'nowiki' => [],
+ 'general' => []
+ ];
+ $this->regex = '/' . Parser::MARKER_PREFIX . "([^\x7f<>&'\"]+)" . Parser::MARKER_SUFFIX . '/';
+ $this->circularRefGuard = [];
+ }
+
+ /**
+ * Add a nowiki strip item
+ * @param string $marker
+ * @param string $value
+ */
+ public function addNoWiki( $marker, $value ) {
+ $this->addItem( 'nowiki', $marker, $value );
+ }
+
+ /**
+ * @param string $marker
+ * @param string $value
+ */
+ public function addGeneral( $marker, $value ) {
+ $this->addItem( 'general', $marker, $value );
+ }
+
+ /**
+ * @throws MWException
+ * @param string $type
+ * @param string $marker
+ * @param string $value
+ */
+ protected function addItem( $type, $marker, $value ) {
+ if ( !preg_match( $this->regex, $marker, $m ) ) {
+ throw new MWException( "Invalid marker: $marker" );
+ }
+
+ $this->data[$type][$m[1]] = $value;
+ }
+
+ /**
+ * @param string $text
+ * @return mixed
+ */
+ public function unstripGeneral( $text ) {
+ return $this->unstripType( 'general', $text );
+ }
+
+ /**
+ * @param string $text
+ * @return mixed
+ */
+ public function unstripNoWiki( $text ) {
+ return $this->unstripType( 'nowiki', $text );
+ }
+
+ /**
+ * @param string $text
+ * @return mixed
+ */
+ public function unstripBoth( $text ) {
+ $text = $this->unstripType( 'general', $text );
+ $text = $this->unstripType( 'nowiki', $text );
+ return $text;
+ }
+
+ /**
+ * @param string $type
+ * @param string $text
+ * @return mixed
+ */
+ protected function unstripType( $type, $text ) {
+ // Shortcut
+ if ( !count( $this->data[$type] ) ) {
+ return $text;
+ }
+
+ $oldType = $this->tempType;
+ $this->tempType = $type;
+ $text = preg_replace_callback( $this->regex, [ $this, 'unstripCallback' ], $text );
+ $this->tempType = $oldType;
+ return $text;
+ }
+
+ /**
+ * @param array $m
+ * @return array
+ */
+ protected function unstripCallback( $m ) {
+ $marker = $m[1];
+ if ( isset( $this->data[$this->tempType][$marker] ) ) {
+ if ( isset( $this->circularRefGuard[$marker] ) ) {
+ return '<span class="error">'
+ . wfMessage( 'parser-unstrip-loop-warning' )->inContentLanguage()->text()
+ . '</span>';
+ }
+ if ( $this->recursionLevel >= self::UNSTRIP_RECURSION_LIMIT ) {
+ return '<span class="error">' .
+ wfMessage( 'parser-unstrip-recursion-limit' )
+ ->numParams( self::UNSTRIP_RECURSION_LIMIT )->inContentLanguage()->text() .
+ '</span>';
+ }
+ $this->circularRefGuard[$marker] = true;
+ $this->recursionLevel++;
+ $value = $this->data[$this->tempType][$marker];
+ if ( $value instanceof Closure ) {
+ $value = $value();
+ }
+ $ret = $this->unstripType( $this->tempType, $value );
+ $this->recursionLevel--;
+ unset( $this->circularRefGuard[$marker] );
+ return $ret;
+ } else {
+ return $m[0];
+ }
+ }
+
+ /**
+ * Get a StripState object which is sufficient to unstrip the given text.
+ * It will contain the minimum subset of strip items necessary.
+ *
+ * @param string $text
+ *
+ * @return StripState
+ */
+ public function getSubState( $text ) {
+ $subState = new StripState();
+ $pos = 0;
+ while ( true ) {
+ $startPos = strpos( $text, Parser::MARKER_PREFIX, $pos );
+ $endPos = strpos( $text, Parser::MARKER_SUFFIX, $pos );
+ if ( $startPos === false || $endPos === false ) {
+ break;
+ }
+
+ $endPos += strlen( Parser::MARKER_SUFFIX );
+ $marker = substr( $text, $startPos, $endPos - $startPos );
+ if ( !preg_match( $this->regex, $marker, $m ) ) {
+ continue;
+ }
+
+ $key = $m[1];
+ if ( isset( $this->data['nowiki'][$key] ) ) {
+ $subState->data['nowiki'][$key] = $this->data['nowiki'][$key];
+ } elseif ( isset( $this->data['general'][$key] ) ) {
+ $subState->data['general'][$key] = $this->data['general'][$key];
+ }
+ $pos = $endPos;
+ }
+ return $subState;
+ }
+
+ /**
+ * Merge another StripState object into this one. The strip marker keys
+ * will not be preserved. The strings in the $texts array will have their
+ * strip markers rewritten, the resulting array of strings will be returned.
+ *
+ * @param StripState $otherState
+ * @param array $texts
+ * @return array
+ */
+ public function merge( $otherState, $texts ) {
+ $mergePrefix = wfRandomString( 16 );
+
+ foreach ( $otherState->data as $type => $items ) {
+ foreach ( $items as $key => $value ) {
+ $this->data[$type]["$mergePrefix-$key"] = $value;
+ }
+ }
+
+ $this->tempMergePrefix = $mergePrefix;
+ $texts = preg_replace_callback( $otherState->regex, [ $this, 'mergeCallback' ], $texts );
+ $this->tempMergePrefix = null;
+ return $texts;
+ }
+
+ /**
+ * @param array $m
+ * @return string
+ */
+ protected function mergeCallback( $m ) {
+ $key = $m[1];
+ return Parser::MARKER_PREFIX . $this->tempMergePrefix . '-' . $key . Parser::MARKER_SUFFIX;
+ }
+
+ /**
+ * Remove any strip markers found in the given text.
+ *
+ * @param string $text Input string
+ * @return string
+ */
+ public function killMarkers( $text ) {
+ return preg_replace( $this->regex, '', $text );
+ }
+}
diff --git a/www/wiki/includes/password/BcryptPassword.php b/www/wiki/includes/password/BcryptPassword.php
new file mode 100644
index 00000000..f811e3f5
--- /dev/null
+++ b/www/wiki/includes/password/BcryptPassword.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * Implements the BcryptPassword class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A Bcrypt-hashed password
+ *
+ * This is a computationally complex password hash for use in modern applications.
+ * The number of rounds can be configured by $wgPasswordConfig['bcrypt']['cost'].
+ *
+ * @since 1.24
+ */
+class BcryptPassword extends ParameterizedPassword {
+ protected function getDefaultParams() {
+ return [
+ 'rounds' => $this->config['cost'],
+ ];
+ }
+
+ protected function getDelimiter() {
+ return '$';
+ }
+
+ protected function parseHash( $hash ) {
+ parent::parseHash( $hash );
+
+ $this->params['rounds'] = (int)$this->params['rounds'];
+ }
+
+ /**
+ * @param string $password Password to encrypt
+ *
+ * @throws PasswordError If bcrypt has an unknown error
+ * @throws MWException If bcrypt is not supported by PHP
+ */
+ public function crypt( $password ) {
+ if ( !defined( 'CRYPT_BLOWFISH' ) ) {
+ throw new MWException( 'Bcrypt is not supported.' );
+ }
+
+ // Either use existing hash or make a new salt
+ // Bcrypt expects 22 characters of base64-encoded salt
+ // Note: bcrypt does not use MIME base64. It uses its own base64 without any '=' padding.
+ // It expects a 128 bit salt, so it will ignore anything after the first 128 bits
+ if ( !isset( $this->args[0] ) ) {
+ $this->args[] = substr(
+ // Replace + with ., because bcrypt uses a non-MIME base64 format
+ strtr(
+ // Random base64 encoded string
+ base64_encode( MWCryptRand::generate( 16, true ) ),
+ '+', '.'
+ ),
+ 0, 22
+ );
+ }
+
+ $hash = crypt( $password,
+ sprintf( '$2y$%02d$%s', (int)$this->params['rounds'], $this->args[0] ) );
+
+ if ( !is_string( $hash ) || strlen( $hash ) <= 13 ) {
+ throw new PasswordError( 'Error when hashing password.' );
+ }
+
+ // Strip the $2y$
+ $parts = explode( $this->getDelimiter(), substr( $hash, 4 ) );
+ $this->params['rounds'] = (int)$parts[0];
+ $this->args[0] = substr( $parts[1], 0, 22 );
+ $this->hash = substr( $parts[1], 22 );
+ }
+}
diff --git a/www/wiki/includes/password/EncryptedPassword.php b/www/wiki/includes/password/EncryptedPassword.php
new file mode 100644
index 00000000..0ea3c631
--- /dev/null
+++ b/www/wiki/includes/password/EncryptedPassword.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * Implements the EncryptedPassword class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Helper class for passwords that use another password hash underneath it
+ * and encrypts that hash with a configured secret.
+ *
+ * @since 1.24
+ */
+class EncryptedPassword extends ParameterizedPassword {
+ protected function getDelimiter() {
+ return ':';
+ }
+
+ protected function getDefaultParams() {
+ return [
+ 'cipher' => $this->config['cipher'],
+ 'secret' => count( $this->config['secrets'] ) - 1
+ ];
+ }
+
+ public function crypt( $password ) {
+ $secret = $this->config['secrets'][$this->params['secret']];
+
+ // Clear error string
+ while ( openssl_error_string() !== false );
+
+ if ( $this->hash ) {
+ $decrypted = openssl_decrypt(
+ $this->hash, $this->params['cipher'],
+ $secret, 0, base64_decode( $this->args[0] ) );
+ if ( $decrypted === false ) {
+ throw new PasswordError( 'Error decrypting password: ' . openssl_error_string() );
+ }
+ $underlyingPassword = $this->factory->newFromCiphertext( $decrypted );
+ } else {
+ $underlyingPassword = $this->factory->newFromType( $this->config['underlying'] );
+ }
+
+ $underlyingPassword->crypt( $password );
+ if ( count( $this->args ) ) {
+ $iv = base64_decode( $this->args[0] );
+ } else {
+ $iv = MWCryptRand::generate( openssl_cipher_iv_length( $this->params['cipher'] ), true );
+ }
+
+ $this->hash = openssl_encrypt(
+ $underlyingPassword->toString(), $this->params['cipher'], $secret, 0, $iv );
+ if ( $this->hash === false ) {
+ throw new PasswordError( 'Error encrypting password: ' . openssl_error_string() );
+ }
+ $this->args = [ base64_encode( $iv ) ];
+ }
+
+ /**
+ * Updates the underlying hash by encrypting it with the newest secret.
+ *
+ * @throws MWException If the configuration is not valid
+ * @return bool True if the password was updated
+ */
+ public function update() {
+ if ( count( $this->args ) != 1 || $this->params == $this->getDefaultParams() ) {
+ // Hash does not need updating
+ return false;
+ }
+
+ // Clear error string
+ while ( openssl_error_string() !== false );
+
+ // Decrypt the underlying hash
+ $underlyingHash = openssl_decrypt(
+ $this->hash,
+ $this->params['cipher'],
+ $this->config['secrets'][$this->params['secret']],
+ 0,
+ base64_decode( $this->args[0] )
+ );
+ if ( $underlyingHash === false ) {
+ throw new PasswordError( 'Error decrypting password: ' . openssl_error_string() );
+ }
+
+ // Reset the params
+ $this->params = $this->getDefaultParams();
+
+ // Check the key size with the new params
+ $iv = MWCryptRand::generate( openssl_cipher_iv_length( $this->params['cipher'] ), true );
+ $this->hash = openssl_encrypt(
+ $underlyingHash,
+ $this->params['cipher'],
+ $this->config['secrets'][$this->params['secret']],
+ 0,
+ $iv
+ );
+ if ( $this->hash === false ) {
+ throw new PasswordError( 'Error encrypting password: ' . openssl_error_string() );
+ }
+
+ $this->args = [ base64_encode( $iv ) ];
+
+ return true;
+ }
+}
diff --git a/www/wiki/includes/password/InvalidPassword.php b/www/wiki/includes/password/InvalidPassword.php
new file mode 100644
index 00000000..e45b7744
--- /dev/null
+++ b/www/wiki/includes/password/InvalidPassword.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Implements the InvalidPassword class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Represents an invalid password hash. It is represented as the empty string (i.e.,
+ * a password hash with no type).
+ *
+ * No two invalid passwords are equal. Comparing anything to an invalid password will
+ * return false.
+ *
+ * @since 1.24
+ */
+class InvalidPassword extends Password {
+ public function crypt( $plaintext ) {
+ }
+
+ public function toString() {
+ return '';
+ }
+
+ public function equals( $other ) {
+ return false;
+ }
+
+ public function needsUpdate() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/password/LayeredParameterizedPassword.php b/www/wiki/includes/password/LayeredParameterizedPassword.php
new file mode 100644
index 00000000..84130548
--- /dev/null
+++ b/www/wiki/includes/password/LayeredParameterizedPassword.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Implements the LayeredParameterizedPassword class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This password hash type layers one or more parameterized password types
+ * on top of each other.
+ *
+ * The underlying types must be parameterized. This wrapping type accumulates
+ * all the parameters and arguments from each hash and then passes the hash of
+ * the last layer as the password for the next layer.
+ *
+ * @since 1.24
+ */
+class LayeredParameterizedPassword extends ParameterizedPassword {
+ protected function getDelimiter() {
+ return '!';
+ }
+
+ protected function getDefaultParams() {
+ $params = [];
+
+ foreach ( $this->config['types'] as $type ) {
+ $passObj = $this->factory->newFromType( $type );
+
+ if ( !$passObj instanceof ParameterizedPassword ) {
+ throw new MWException( 'Underlying type must be a parameterized password.' );
+ } elseif ( $passObj->getDelimiter() === $this->getDelimiter() ) {
+ throw new MWException( 'Underlying type cannot use same delimiter as encapsulating type.' );
+ }
+
+ $params[] = implode( $passObj->getDelimiter(), $passObj->getDefaultParams() );
+ }
+
+ return $params;
+ }
+
+ public function crypt( $password ) {
+ $lastHash = $password;
+ foreach ( $this->config['types'] as $i => $type ) {
+ // Construct pseudo-hash based on params and arguments
+ /** @var ParameterizedPassword $passObj */
+ $passObj = $this->factory->newFromType( $type );
+
+ $params = '';
+ $args = '';
+ if ( $this->params[$i] !== '' ) {
+ $params = $this->params[$i] . $passObj->getDelimiter();
+ }
+ if ( isset( $this->args[$i] ) && $this->args[$i] !== '' ) {
+ $args = $this->args[$i] . $passObj->getDelimiter();
+ }
+ $existingHash = ":$type:" . $params . $args . $this->hash;
+
+ // Hash the last hash with the next type in the layer
+ $passObj = $this->factory->newFromCiphertext( $existingHash );
+ $passObj->crypt( $lastHash );
+
+ // Move over the params and args
+ $this->params[$i] = implode( $passObj->getDelimiter(), $passObj->params );
+ $this->args[$i] = implode( $passObj->getDelimiter(), $passObj->args );
+ $lastHash = $passObj->hash;
+ }
+
+ $this->hash = $lastHash;
+ }
+
+ /**
+ * Finish the hashing of a partially hashed layered hash
+ *
+ * Given a password hash that is hashed using the first layer of this object's
+ * configuration, perform the remaining layers of password hashing in order to
+ * get an updated hash with all the layers.
+ *
+ * @param ParameterizedPassword $passObj Password hash of the first layer
+ *
+ * @throws MWException If the first parameter is not of the correct type
+ */
+ public function partialCrypt( ParameterizedPassword $passObj ) {
+ $type = $passObj->config['type'];
+ if ( $type !== $this->config['types'][0] ) {
+ throw new MWException( 'Only a hash in the first layer can be finished.' );
+ }
+
+ // Gather info from the existing hash
+ $this->params[0] = implode( $passObj->getDelimiter(), $passObj->params );
+ $this->args[0] = implode( $passObj->getDelimiter(), $passObj->args );
+ $lastHash = $passObj->hash;
+
+ // Layer the remaining types
+ foreach ( $this->config['types'] as $i => $type ) {
+ if ( $i == 0 ) {
+ continue;
+ };
+
+ // Construct pseudo-hash based on params and arguments
+ /** @var ParameterizedPassword $passObj */
+ $passObj = $this->factory->newFromType( $type );
+
+ $params = '';
+ $args = '';
+ if ( $this->params[$i] !== '' ) {
+ $params = $this->params[$i] . $passObj->getDelimiter();
+ }
+ if ( isset( $this->args[$i] ) && $this->args[$i] !== '' ) {
+ $args = $this->args[$i] . $passObj->getDelimiter();
+ }
+ $existingHash = ":$type:" . $params . $args . $this->hash;
+
+ // Hash the last hash with the next type in the layer
+ $passObj = $this->factory->newFromCiphertext( $existingHash );
+ $passObj->crypt( $lastHash );
+
+ // Move over the params and args
+ $this->params[$i] = implode( $passObj->getDelimiter(), $passObj->params );
+ $this->args[$i] = implode( $passObj->getDelimiter(), $passObj->args );
+ $lastHash = $passObj->hash;
+ }
+
+ $this->hash = $lastHash;
+ }
+}
diff --git a/www/wiki/includes/password/MWOldPassword.php b/www/wiki/includes/password/MWOldPassword.php
new file mode 100644
index 00000000..c48b6e61
--- /dev/null
+++ b/www/wiki/includes/password/MWOldPassword.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Implements the MWOldPassword class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * The old style of MediaWiki password hashing. It involves
+ * running MD5 on the password.
+ *
+ * @since 1.24
+ */
+class MWOldPassword extends ParameterizedPassword {
+ protected function getDefaultParams() {
+ return [];
+ }
+
+ protected function getDelimiter() {
+ return ':';
+ }
+
+ public function crypt( $plaintext ) {
+ if ( count( $this->args ) === 1 ) {
+ // Accept (but do not generate) salted passwords with :A: prefix.
+ // These are actually B-type passwords, but an error in a previous
+ // version of MediaWiki caused them to be written with an :A:
+ // prefix.
+ $this->hash = md5( $this->args[0] . '-' . md5( $plaintext ) );
+ } else {
+ $this->args = [];
+ $this->hash = md5( $plaintext );
+ }
+
+ if ( !is_string( $this->hash ) || strlen( $this->hash ) < 32 ) {
+ throw new PasswordError( 'Error when hashing password.' );
+ }
+ }
+}
diff --git a/www/wiki/includes/password/MWSaltedPassword.php b/www/wiki/includes/password/MWSaltedPassword.php
new file mode 100644
index 00000000..733984cf
--- /dev/null
+++ b/www/wiki/includes/password/MWSaltedPassword.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * Implements the MWSaltedPassword class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * The old style of MediaWiki password hashing, with a salt. It involves
+ * running MD5 on the password, and then running MD5 on the salt concatenated
+ * with the first hash.
+ *
+ * @since 1.24
+ */
+class MWSaltedPassword extends ParameterizedPassword {
+ protected function getDefaultParams() {
+ return [];
+ }
+
+ protected function getDelimiter() {
+ return ':';
+ }
+
+ public function crypt( $plaintext ) {
+ if ( count( $this->args ) == 0 ) {
+ $this->args[] = MWCryptRand::generateHex( 8 );
+ }
+
+ $this->hash = md5( $this->args[0] . '-' . md5( $plaintext ) );
+
+ if ( !is_string( $this->hash ) || strlen( $this->hash ) < 32 ) {
+ throw new PasswordError( 'Error when hashing password.' );
+ }
+ }
+}
diff --git a/www/wiki/includes/password/ParameterizedPassword.php b/www/wiki/includes/password/ParameterizedPassword.php
new file mode 100644
index 00000000..78d624ce
--- /dev/null
+++ b/www/wiki/includes/password/ParameterizedPassword.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * Implements the ParameterizedPassword class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Helper class for password hash types that have a delimited set of parameters
+ * inside of the hash.
+ *
+ * All passwords are in the form of :<TYPE>:... as explained in the main Password
+ * class. This class is for hashes in the form of :<TYPE>:<PARAM1>:<PARAM2>:... where
+ * <PARAM1>, <PARAM2>, etc. are parameters that determine how the password was hashed.
+ * Of course, the internal delimiter (which is : by convention and default), can be
+ * changed by overriding the ParameterizedPassword::getDelimiter() function.
+ *
+ * This class requires overriding an additional function: ParameterizedPassword::getDefaultParams().
+ * See the function description for more details on the implementation.
+ *
+ * @since 1.24
+ */
+abstract class ParameterizedPassword extends Password {
+ /**
+ * Named parameters that have default values for this password type
+ * @var array
+ */
+ protected $params = [];
+
+ /**
+ * Extra arguments that were found in the hash. This may or may not make
+ * the hash invalid.
+ * @var array
+ */
+ protected $args = [];
+
+ protected function parseHash( $hash ) {
+ parent::parseHash( $hash );
+
+ if ( $hash === null ) {
+ $this->params = $this->getDefaultParams();
+ return;
+ }
+
+ $parts = explode( $this->getDelimiter(), $hash );
+ $paramKeys = array_keys( $this->getDefaultParams() );
+
+ if ( count( $parts ) < count( $paramKeys ) ) {
+ throw new PasswordError( 'Hash is missing required parameters.' );
+ }
+
+ if ( $paramKeys ) {
+ $this->args = array_splice( $parts, count( $paramKeys ) );
+ $this->params = array_combine( $paramKeys, $parts );
+ } else {
+ $this->args = $parts;
+ }
+
+ if ( $this->args ) {
+ $this->hash = array_pop( $this->args );
+ } else {
+ $this->hash = null;
+ }
+ }
+
+ public function needsUpdate() {
+ return $this->params !== $this->getDefaultParams();
+ }
+
+ public function toString() {
+ $str = ':' . $this->config['type'] . ':';
+
+ if ( count( $this->params ) || count( $this->args ) ) {
+ $str .= implode( $this->getDelimiter(), array_merge( $this->params, $this->args ) );
+ $str .= $this->getDelimiter();
+ }
+
+ $res = $str . $this->hash;
+ $this->assertIsSafeSize( $res );
+ return $res;
+ }
+
+ /**
+ * Returns the delimiter for the parameters inside the hash
+ *
+ * @return string
+ */
+ abstract protected function getDelimiter();
+
+ /**
+ * Return an ordered array of default parameters for this password hash
+ *
+ * The keys should be the parameter names and the values should be the default
+ * values. Additionally, the order of the array should be the order in which they
+ * appear in the hash.
+ *
+ * When parsing a password hash, the constructor will split the hash based on
+ * the delimiter, and consume as many parts as it can, matching each to a parameter
+ * in this list. Once all the parameters have been filled, all remaining parts will
+ * be considered extra arguments, except, of course, for the very last part, which
+ * is the hash itself.
+ *
+ * @return array
+ */
+ abstract protected function getDefaultParams();
+}
diff --git a/www/wiki/includes/password/Password.php b/www/wiki/includes/password/Password.php
new file mode 100644
index 00000000..c8a0267c
--- /dev/null
+++ b/www/wiki/includes/password/Password.php
@@ -0,0 +1,209 @@
+<?php
+/**
+ * Implements the Password class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Represents a password hash for use in authentication
+ *
+ * Note: All password types are transparently prefixed with :<TYPE>:, where <TYPE>
+ * is the registered type of the hash. This prefix is stripped in the constructor
+ * and is added back in the toString() function.
+ *
+ * When inheriting this class, there are a couple of expectations
+ * to be fulfilled:
+ * * If Password::toString() is called on an object, and the result is passed back in
+ * to PasswordFactory::newFromCiphertext(), the result will be identical to the original.
+ * * The string representations of two Password objects are equal only if
+ * the original plaintext passwords match. In other words, if the toString() result of
+ * two objects match, the passwords are the same, and the user will be logged in.
+ * Since the string representation of a hash includes its type name (@see Password::toString),
+ * this property is preserved across all classes that inherit Password.
+ * If a hashing scheme does not fulfill this expectation, it must make sure to override the
+ * Password::equals() function and use custom comparison logic. However, this is not
+ * recommended unless absolutely required by the hashing mechanism.
+ * With these two points in mind, when creating a new Password sub-class, there are some functions
+ * you have to override (because they are abstract) and others that you may want to override.
+ *
+ * The abstract functions that must be overridden are:
+ * * Password::crypt(), which takes a plaintext password and hashes it into a string hash suitable
+ * for being passed to the constructor of that class, and then stores that hash (and whatever
+ * other data) into the internal state of the object.
+ * The functions that can optionally be overridden are:
+ * * Password::parseHash(), which can be useful to override if you need to extract values from or
+ * otherwise parse a password hash when it's passed to the constructor.
+ * * Password::needsUpdate(), which can be useful if a specific password hash has different
+ * logic for when the hash needs to be updated.
+ * * Password::toString(), which can be useful if the hash was changed in the constructor and
+ * needs to be re-assembled before being returned as a string. This function is expected to add
+ * the type back on to the hash, so make sure to do that if you override the function.
+ * * Password::equals() - This function compares two Password objects to see if they are equal.
+ * The default is to just do a timing-safe string comparison on the $this->hash values.
+ *
+ * After creating a new password hash type, it can be registered using the static
+ * Password::register() method. The default type is set using the Password::setDefaultType() type.
+ * Types must be registered before they can be set as the default.
+ *
+ * @since 1.24
+ */
+abstract class Password {
+ /**
+ * @var PasswordFactory Factory that created the object
+ */
+ protected $factory;
+
+ /**
+ * String representation of the hash without the type
+ * @var string
+ */
+ protected $hash;
+
+ /**
+ * Array of configuration variables injected from the constructor
+ * @var array
+ */
+ protected $config;
+
+ /**
+ * Hash must fit in user_password, which is a tinyblob
+ */
+ const MAX_HASH_SIZE = 255;
+
+ /**
+ * Construct the Password object using a string hash
+ *
+ * It is strongly recommended not to call this function directly unless you
+ * have a reason to. Use the PasswordFactory class instead.
+ *
+ * @throws MWException If $config does not contain required parameters
+ *
+ * @param PasswordFactory $factory Factory object that created the password
+ * @param array $config Array of engine configuration options for hashing
+ * @param string|null $hash The raw hash, including the type
+ */
+ final public function __construct( PasswordFactory $factory, array $config, $hash = null ) {
+ if ( !isset( $config['type'] ) ) {
+ throw new MWException( 'Password configuration must contain a type name.' );
+ }
+ $this->config = $config;
+ $this->factory = $factory;
+
+ if ( $hash !== null && strlen( $hash ) >= 3 ) {
+ // Strip the type from the hash for parsing
+ $hash = substr( $hash, strpos( $hash, ':', 1 ) + 1 );
+ }
+
+ $this->hash = $hash;
+ $this->parseHash( $hash );
+ }
+
+ /**
+ * Get the type name of the password
+ *
+ * @return string Password type
+ */
+ final public function getType() {
+ return $this->config['type'];
+ }
+
+ /**
+ * Perform any parsing necessary on the hash to see if the hash is valid
+ * and/or to perform logic for seeing if the hash needs updating.
+ *
+ * @param string $hash The hash, with the :<TYPE>: prefix stripped
+ * @throws PasswordError If there is an error in parsing the hash
+ */
+ protected function parseHash( $hash ) {
+ }
+
+ /**
+ * Determine if the hash needs to be updated
+ *
+ * @return bool True if needs update, false otherwise
+ */
+ abstract public function needsUpdate();
+
+ /**
+ * Compare one Password object to this object
+ *
+ * By default, do a timing-safe string comparison on the result of
+ * Password::toString() for each object. This can be overridden to do
+ * custom comparison, but it is not recommended unless necessary.
+ *
+ * @param Password|string $other The other password
+ * @return bool True if equal, false otherwise
+ */
+ public function equals( $other ) {
+ if ( !$other instanceof self ) {
+ // No need to use the factory because we're definitely making
+ // an object of the same type.
+ $obj = clone $this;
+ $obj->crypt( $other );
+ $other = $obj;
+ }
+
+ return hash_equals( $this->toString(), $other->toString() );
+ }
+
+ /**
+ * Convert this hash to a string that can be stored in the database
+ *
+ * The resulting string should be considered the seralized representation
+ * of this hash, i.e., if the return value were recycled back into
+ * PasswordFactory::newFromCiphertext, the returned object would be equivalent to
+ * this; also, if two objects return the same value from this function, they
+ * are considered equivalent.
+ *
+ * @return string
+ * @throws PasswordError if password cannot be serialized to fit a tinyblob.
+ */
+ public function toString() {
+ $result = ':' . $this->config['type'] . ':' . $this->hash;
+ $this->assertIsSafeSize( $result );
+ return $result;
+ }
+
+ /**
+ * Assert that hash will fit in a tinyblob field.
+ *
+ * This prevents MW from inserting it into the DB
+ * and having MySQL silently truncating it, locking
+ * the user out of their account.
+ *
+ * @param string $hash The hash in question.
+ * @throws PasswordError If hash does not fit in DB.
+ */
+ final protected function assertIsSafeSize( $hash ) {
+ if ( strlen( $hash ) > self::MAX_HASH_SIZE ) {
+ throw new PasswordError( "Password hash is too big" );
+ }
+ }
+
+ /**
+ * Hash a password and store the result in this object
+ *
+ * The result of the password hash should be put into the internal
+ * state of the hash object.
+ *
+ * @param string $password Password to hash
+ * @throws PasswordError If an internal error occurs in hashing
+ */
+ abstract public function crypt( $password );
+}
diff --git a/www/wiki/includes/password/PasswordError.php b/www/wiki/includes/password/PasswordError.php
new file mode 100644
index 00000000..c9707adb
--- /dev/null
+++ b/www/wiki/includes/password/PasswordError.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Show an error when any operation involving passwords fails to run.
+ *
+ * @ingroup Exception
+ */
+class PasswordError extends MWException {
+ // NOP
+}
diff --git a/www/wiki/includes/password/PasswordFactory.php b/www/wiki/includes/password/PasswordFactory.php
new file mode 100644
index 00000000..3383fe38
--- /dev/null
+++ b/www/wiki/includes/password/PasswordFactory.php
@@ -0,0 +1,224 @@
+<?php
+/**
+ * Implements the Password class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Factory class for creating and checking Password objects
+ *
+ * @since 1.24
+ */
+final class PasswordFactory {
+ /**
+ * The default PasswordHash type
+ *
+ * @var string
+ * @see PasswordFactory::setDefaultType
+ */
+ private $default = '';
+
+ /**
+ * Mapping of password types to classes
+ * @var array
+ * @see PasswordFactory::register
+ * @see Setup.php
+ */
+ private $types = [
+ '' => [ 'type' => '', 'class' => 'InvalidPassword' ],
+ ];
+
+ /**
+ * Register a new type of password hash
+ *
+ * @param string $type Unique type name for the hash
+ * @param array $config Array of configuration options
+ */
+ public function register( $type, array $config ) {
+ $config['type'] = $type;
+ $this->types[$type] = $config;
+ }
+
+ /**
+ * Set the default password type
+ *
+ * @throws InvalidArgumentException If the type is not registered
+ * @param string $type Password hash type
+ */
+ public function setDefaultType( $type ) {
+ if ( !isset( $this->types[$type] ) ) {
+ throw new InvalidArgumentException( "Invalid password type $type." );
+ }
+ $this->default = $type;
+ }
+
+ /**
+ * Get the default password type
+ *
+ * @return string
+ */
+ public function getDefaultType() {
+ return $this->default;
+ }
+
+ /**
+ * Initialize the internal static variables using the global variables
+ *
+ * @param Config $config Configuration object to load data from
+ */
+ public function init( Config $config ) {
+ foreach ( $config->get( 'PasswordConfig' ) as $type => $options ) {
+ $this->register( $type, $options );
+ }
+
+ $this->setDefaultType( $config->get( 'PasswordDefault' ) );
+ }
+
+ /**
+ * Get the list of types of passwords
+ *
+ * @return array
+ */
+ public function getTypes() {
+ return $this->types;
+ }
+
+ /**
+ * Create a new Hash object from an existing string hash
+ *
+ * Parse the type of a hash and create a new hash object based on the parsed type.
+ * Pass the raw hash to the constructor of the new object. Use InvalidPassword type
+ * if a null hash is given.
+ *
+ * @param string|null $hash Existing hash or null for an invalid password
+ * @return Password
+ * @throws PasswordError If hash is invalid or type is not recognized
+ */
+ public function newFromCiphertext( $hash ) {
+ if ( $hash === null || $hash === false || $hash === '' ) {
+ return new InvalidPassword( $this, [ 'type' => '' ], null );
+ } elseif ( $hash[0] !== ':' ) {
+ throw new PasswordError( 'Invalid hash given' );
+ }
+
+ $type = substr( $hash, 1, strpos( $hash, ':', 1 ) - 1 );
+ if ( !isset( $this->types[$type] ) ) {
+ throw new PasswordError( "Unrecognized password hash type $type." );
+ }
+
+ $config = $this->types[$type];
+
+ return new $config['class']( $this, $config, $hash );
+ }
+
+ /**
+ * Make a new default password of the given type.
+ *
+ * @param string $type Existing type
+ * @return Password
+ * @throws PasswordError If hash is invalid or type is not recognized
+ */
+ public function newFromType( $type ) {
+ if ( !isset( $this->types[$type] ) ) {
+ throw new PasswordError( "Unrecognized password hash type $type." );
+ }
+
+ $config = $this->types[$type];
+
+ return new $config['class']( $this, $config );
+ }
+
+ /**
+ * Create a new Hash object from a plaintext password
+ *
+ * If no existing object is given, make a new default object. If one is given, clone that
+ * object. Then pass the plaintext to Password::crypt().
+ *
+ * @param string|null $password Plaintext password, or null for an invalid password
+ * @param Password|null $existing Optional existing hash to get options from
+ * @return Password
+ */
+ public function newFromPlaintext( $password, Password $existing = null ) {
+ if ( $password === null ) {
+ return new InvalidPassword( $this, [ 'type' => '' ], null );
+ }
+
+ if ( $existing === null ) {
+ $config = $this->types[$this->default];
+ $obj = new $config['class']( $this, $config );
+ } else {
+ $obj = clone $existing;
+ }
+
+ $obj->crypt( $password );
+
+ return $obj;
+ }
+
+ /**
+ * Determine whether a password object needs updating
+ *
+ * Check whether the given password is of the default type. If it is,
+ * pass off further needsUpdate checks to Password::needsUpdate.
+ *
+ * @param Password $password
+ *
+ * @return bool True if needs update, false otherwise
+ */
+ public function needsUpdate( Password $password ) {
+ if ( $password->getType() !== $this->default ) {
+ return true;
+ } else {
+ return $password->needsUpdate();
+ }
+ }
+
+ /**
+ * Generate a random string suitable for a password
+ *
+ * @param int $minLength Minimum length of password to generate
+ * @return string
+ */
+ public static function generateRandomPasswordString( $minLength = 10 ) {
+ // Decide the final password length based on our min password length,
+ // stopping at a minimum of 10 chars.
+ $length = max( 10, $minLength );
+ // Multiply by 1.25 to get the number of hex characters we need
+ // Generate random hex chars
+ $hex = MWCryptRand::generateHex( ceil( $length * 1.25 ) );
+ // Convert from base 16 to base 32 to get a proper password like string
+ return substr( Wikimedia\base_convert( $hex, 16, 32, $length ), -$length );
+ }
+
+ /**
+ * Create an InvalidPassword
+ *
+ * @return InvalidPassword
+ */
+ public static function newInvalidPassword() {
+ static $password = null;
+
+ if ( $password === null ) {
+ $factory = new self();
+ $password = new InvalidPassword( $factory, [ 'type' => '' ], null );
+ }
+
+ return $password;
+ }
+}
diff --git a/www/wiki/includes/password/PasswordPolicyChecks.php b/www/wiki/includes/password/PasswordPolicyChecks.php
new file mode 100644
index 00000000..b3776bd8
--- /dev/null
+++ b/www/wiki/includes/password/PasswordPolicyChecks.php
@@ -0,0 +1,167 @@
+<?php
+/**
+ * Password policy checks
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use \Cdb\Reader as CdbReader;
+
+/**
+ * Functions to check passwords against a policy requirement
+ * @since 1.26
+ */
+class PasswordPolicyChecks {
+
+ /**
+ * Check password is longer than minimum, not fatal
+ * @param int $policyVal minimal length
+ * @param User $user
+ * @param string $password
+ * @return Status error if $password is shorter than $policyVal
+ */
+ public static function checkMinimalPasswordLength( $policyVal, User $user, $password ) {
+ $status = Status::newGood();
+ if ( $policyVal > strlen( $password ) ) {
+ $status->error( 'passwordtooshort', $policyVal );
+ }
+ return $status;
+ }
+
+ /**
+ * Check password is longer than minimum, fatal
+ * @param int $policyVal minimal length
+ * @param User $user
+ * @param string $password
+ * @return Status fatal if $password is shorter than $policyVal
+ */
+ public static function checkMinimumPasswordLengthToLogin( $policyVal, User $user, $password ) {
+ $status = Status::newGood();
+ if ( $policyVal > strlen( $password ) ) {
+ $status->fatal( 'passwordtooshort', $policyVal );
+ }
+ return $status;
+ }
+
+ /**
+ * Check password is shorter than maximum, fatal
+ * @param int $policyVal maximum length
+ * @param User $user
+ * @param string $password
+ * @return Status fatal if $password is shorter than $policyVal
+ */
+ public static function checkMaximalPasswordLength( $policyVal, User $user, $password ) {
+ $status = Status::newGood();
+ if ( $policyVal < strlen( $password ) ) {
+ $status->fatal( 'passwordtoolong', $policyVal );
+ }
+ return $status;
+ }
+
+ /**
+ * Check if username and password match
+ * @param bool $policyVal true to force compliance.
+ * @param User $user
+ * @param string $password
+ * @return Status error if username and password match, and policy is true
+ */
+ public static function checkPasswordCannotMatchUsername( $policyVal, User $user, $password ) {
+ global $wgContLang;
+ $status = Status::newGood();
+ $username = $user->getName();
+ if ( $policyVal && $wgContLang->lc( $password ) === $wgContLang->lc( $username ) ) {
+ $status->error( 'password-name-match' );
+ }
+ return $status;
+ }
+
+ /**
+ * Check if username and password are on a blacklist
+ * @param bool $policyVal true to force compliance.
+ * @param User $user
+ * @param string $password
+ * @return Status error if username and password match, and policy is true
+ */
+ public static function checkPasswordCannotMatchBlacklist( $policyVal, User $user, $password ) {
+ static $blockedLogins = [
+ 'Useruser' => 'Passpass', 'Useruser1' => 'Passpass1', # r75589
+ 'Apitestsysop' => 'testpass', 'Apitestuser' => 'testpass' # r75605
+ ];
+
+ $status = Status::newGood();
+ $username = $user->getName();
+ if ( $policyVal ) {
+ if ( isset( $blockedLogins[$username] ) && $password == $blockedLogins[$username] ) {
+ $status->error( 'password-login-forbidden' );
+ }
+
+ // Example from ApiChangeAuthenticationRequest
+ if ( $password === 'ExamplePassword' ) {
+ $status->error( 'password-login-forbidden' );
+ }
+ }
+ return $status;
+ }
+
+ /**
+ * Ensure that password isn't in top X most popular passwords
+ *
+ * @param int $policyVal Cut off to use. Will automatically shrink to the max
+ * supported for error messages if set to more than max number of passwords on file,
+ * so you can use the PHP_INT_MAX constant here safely.
+ * @param User $user
+ * @param string $password
+ * @since 1.27
+ * @return Status
+ */
+ public static function checkPopularPasswordBlacklist( $policyVal, User $user, $password ) {
+ global $wgPopularPasswordFile, $wgSitename;
+ $status = Status::newGood();
+ if ( $policyVal > 0 ) {
+ $langEn = Language::factory( 'en' );
+ $passwordKey = $langEn->lc( trim( $password ) );
+
+ // People often use the name of the current site, which won't be
+ // in the common password file. Also check '' for people who use
+ // just whitespace.
+ $sitename = $langEn->lc( trim( $wgSitename ) );
+ $hardcodedCommonPasswords = [ '', 'wiki', 'mediawiki', $sitename ];
+ if ( in_array( $passwordKey, $hardcodedCommonPasswords ) ) {
+ $status->error( 'passwordtoopopular' );
+ return $status;
+ }
+
+ // This could throw an exception, but there's not a good way
+ // of failing gracefully, if say the file is missing, so just
+ // let the exception fall through.
+ // Format of cdb file is mapping password => popularity rank.
+ // See maintenance/createCommonPasswordCdb.php
+ $db = CdbReader::open( $wgPopularPasswordFile );
+
+ $res = $db->get( $passwordKey );
+ if ( $res && (int)$res <= $policyVal ) {
+ // Note: If you want to find the true number of common
+ // passwords stored (for reporting the error), you have to take
+ // the max of the policyVal and $db->get( '_TOTALENTRIES' ).
+ $status->error( 'passwordtoopopular' );
+ }
+ }
+ return $status;
+ }
+
+}
diff --git a/www/wiki/includes/password/Pbkdf2Password.php b/www/wiki/includes/password/Pbkdf2Password.php
new file mode 100644
index 00000000..4a8831e3
--- /dev/null
+++ b/www/wiki/includes/password/Pbkdf2Password.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * Implements the Pbkdf2Password class for the MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A PBKDF2-hashed password
+ *
+ * This is a computationally complex password hash for use in modern applications.
+ * The number of rounds can be configured by $wgPasswordConfig['pbkdf2']['cost'].
+ *
+ * @since 1.24
+ */
+class Pbkdf2Password extends ParameterizedPassword {
+ protected function getDefaultParams() {
+ return [
+ 'algo' => $this->config['algo'],
+ 'rounds' => $this->config['cost'],
+ 'length' => $this->config['length']
+ ];
+ }
+
+ protected function getDelimiter() {
+ return ':';
+ }
+
+ protected function shouldUseHashExtension() {
+ return isset( $this->config['use-hash-extension'] ) ?
+ $this->config['use-hash-extension'] : function_exists( 'hash_pbkdf2' );
+ }
+
+ public function crypt( $password ) {
+ if ( count( $this->args ) == 0 ) {
+ $this->args[] = base64_encode( MWCryptRand::generate( 16, true ) );
+ }
+
+ if ( $this->shouldUseHashExtension() ) {
+ $hash = hash_pbkdf2(
+ $this->params['algo'],
+ $password,
+ base64_decode( $this->args[0] ),
+ (int)$this->params['rounds'],
+ (int)$this->params['length'],
+ true
+ );
+ if ( !is_string( $hash ) ) {
+ throw new PasswordError( 'Error when hashing password.' );
+ }
+ } else {
+ $hashLenHash = hash( $this->params['algo'], '', true );
+ if ( !is_string( $hashLenHash ) ) {
+ throw new PasswordError( 'Error when hashing password.' );
+ }
+ $hashLen = strlen( $hashLenHash );
+ $blockCount = ceil( $this->params['length'] / $hashLen );
+
+ $hash = '';
+ $salt = base64_decode( $this->args[0] );
+ for ( $i = 1; $i <= $blockCount; ++$i ) {
+ $roundTotal = $lastRound = hash_hmac(
+ $this->params['algo'],
+ $salt . pack( 'N', $i ),
+ $password,
+ true
+ );
+
+ for ( $j = 1; $j < $this->params['rounds']; ++$j ) {
+ $lastRound = hash_hmac( $this->params['algo'], $lastRound, $password, true );
+ $roundTotal ^= $lastRound;
+ }
+
+ $hash .= $roundTotal;
+ }
+
+ $hash = substr( $hash, 0, $this->params['length'] );
+ }
+
+ $this->hash = base64_encode( $hash );
+ }
+}
diff --git a/www/wiki/includes/password/UserPasswordPolicy.php b/www/wiki/includes/password/UserPasswordPolicy.php
new file mode 100644
index 00000000..bf1f8acf
--- /dev/null
+++ b/www/wiki/includes/password/UserPasswordPolicy.php
@@ -0,0 +1,196 @@
+<?php
+/**
+ * Password policy checking for a user
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Check if a user's password complies with any password policies that apply to that
+ * user, based on the user's group membership.
+ * @since 1.26
+ */
+class UserPasswordPolicy {
+
+ /**
+ * @var array
+ */
+ private $policies;
+
+ /**
+ * Mapping of statements to the function that will test the password for compliance. The
+ * checking functions take the policy value, the user, and password, and return a Status
+ * object indicating compliance.
+ * @var array
+ */
+ private $policyCheckFunctions;
+
+ /**
+ * @param array $policies
+ * @param array $checks mapping statement to its checking function. Checking functions are
+ * called with the policy value for this user, the user object, and the password to check.
+ */
+ public function __construct( array $policies, array $checks ) {
+ if ( !isset( $policies['default'] ) ) {
+ throw new InvalidArgumentException(
+ 'Must include a \'default\' password policy'
+ );
+ }
+ $this->policies = $policies;
+
+ foreach ( $checks as $statement => $check ) {
+ if ( !is_callable( $check ) ) {
+ throw new InvalidArgumentException(
+ "Policy check functions must be callable. '$statement' isn't callable."
+ );
+ }
+ $this->policyCheckFunctions[$statement] = $check;
+ }
+ }
+
+ /**
+ * Check if a passwords meets the effective password policy for a User.
+ * @param User $user who's policy we are checking
+ * @param string $password the password to check
+ * @return Status error to indicate the password didn't meet the policy, or fatal to
+ * indicate the user shouldn't be allowed to login.
+ */
+ public function checkUserPassword( User $user, $password ) {
+ $effectivePolicy = $this->getPoliciesForUser( $user );
+ return $this->checkPolicies(
+ $user,
+ $password,
+ $effectivePolicy,
+ $this->policyCheckFunctions
+ );
+ }
+
+ /**
+ * Check if a passwords meets the effective password policy for a User, using a set
+ * of groups they may or may not belong to. This function does not use the DB, so can
+ * be used in the installer.
+ * @param User $user who's policy we are checking
+ * @param string $password the password to check
+ * @param array $groups list of groups to which we assume the user belongs
+ * @return Status error to indicate the password didn't meet the policy, or fatal to
+ * indicate the user shouldn't be allowed to login.
+ */
+ public function checkUserPasswordForGroups( User $user, $password, array $groups ) {
+ $effectivePolicy = self::getPoliciesForGroups(
+ $this->policies,
+ $groups,
+ $this->policies['default']
+ );
+ return $this->checkPolicies(
+ $user,
+ $password,
+ $effectivePolicy,
+ $this->policyCheckFunctions
+ );
+ }
+
+ /**
+ * @param User $user
+ * @param string $password
+ * @param array $policies
+ * @param array $policyCheckFunctions
+ * @return Status
+ */
+ private function checkPolicies( User $user, $password, $policies, $policyCheckFunctions ) {
+ $status = Status::newGood();
+ foreach ( $policies as $policy => $value ) {
+ if ( !isset( $policyCheckFunctions[$policy] ) ) {
+ throw new DomainException( "Invalid password policy config. No check defined for '$policy'." );
+ }
+ $status->merge(
+ call_user_func(
+ $policyCheckFunctions[$policy],
+ $value,
+ $user,
+ $password
+ )
+ );
+ }
+ return $status;
+ }
+
+ /**
+ * Get the policy for a user, based on their group membership. Public so
+ * UI elements can access and inform the user.
+ * @param User $user
+ * @return array the effective policy for $user
+ */
+ public function getPoliciesForUser( User $user ) {
+ $effectivePolicy = self::getPoliciesForGroups(
+ $this->policies,
+ $user->getEffectiveGroups(),
+ $this->policies['default']
+ );
+
+ Hooks::run( 'PasswordPoliciesForUser', [ $user, &$effectivePolicy ] );
+
+ return $effectivePolicy;
+ }
+
+ /**
+ * Utility function to get the effective policy from a list of policies, based
+ * on a list of groups.
+ * @param array $policies list of policies to consider
+ * @param array $userGroups the groups from which we calculate the effective policy
+ * @param array $defaultPolicy the default policy to start from
+ * @return array effective policy
+ */
+ public static function getPoliciesForGroups( array $policies, array $userGroups,
+ array $defaultPolicy
+ ) {
+ $effectivePolicy = $defaultPolicy;
+ foreach ( $policies as $group => $policy ) {
+ if ( in_array( $group, $userGroups ) ) {
+ $effectivePolicy = self::maxOfPolicies(
+ $effectivePolicy,
+ $policies[$group]
+ );
+ }
+ }
+
+ return $effectivePolicy;
+ }
+
+ /**
+ * Utility function to get a policy that is the most restrictive of $p1 and $p2. For
+ * simplicity, we setup the policy values so the maximum value is always more restrictive.
+ * @param array $p1
+ * @param array $p2
+ * @return array containing the more restrictive values of $p1 and $p2
+ */
+ public static function maxOfPolicies( array $p1, array $p2 ) {
+ $ret = [];
+ $keys = array_merge( array_keys( $p1 ), array_keys( $p2 ) );
+ foreach ( $keys as $key ) {
+ if ( !isset( $p1[$key] ) ) {
+ $ret[$key] = $p2[$key];
+ } elseif ( !isset( $p2[$key] ) ) {
+ $ret[$key] = $p1[$key];
+ } else {
+ $ret[$key] = max( $p1[$key], $p2[$key] );
+ }
+ }
+ return $ret;
+ }
+
+}
diff --git a/www/wiki/includes/poolcounter/PoolCounter.php b/www/wiki/includes/poolcounter/PoolCounter.php
new file mode 100644
index 00000000..bd7072ab
--- /dev/null
+++ b/www/wiki/includes/poolcounter/PoolCounter.php
@@ -0,0 +1,231 @@
+<?php
+/**
+ * Provides of semaphore semantics for restricting the number
+ * of workers that may be concurrently performing the same task.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * When you have many workers (threads/servers) giving service, and a
+ * cached item expensive to produce expires, you may get several workers
+ * doing the job at the same time.
+ *
+ * Given enough requests and the item expiring fast (non-cacheable,
+ * lots of edits...) that single work can end up unfairly using most (all)
+ * of the cpu of the pool. This is also known as 'Michael Jackson effect'
+ * since this effect triggered on the english wikipedia on the day Michael
+ * Jackson died, the biographical article got hit with several edits per
+ * minutes and hundreds of read hits.
+ *
+ * The PoolCounter provides semaphore semantics for restricting the number
+ * of workers that may be concurrently performing such single task. Only one
+ * key can be locked by any PoolCounter instance of a process, except for keys
+ * that start with "nowait:". However, only 0 timeouts (non-blocking requests)
+ * can be used with "nowait:" keys.
+ *
+ * By default PoolCounter_Stub is used, which provides no locking. You
+ * can get a useful one in the PoolCounter extension.
+ */
+abstract class PoolCounter {
+ /* Return codes */
+ const LOCKED = 1; /* Lock acquired */
+ const RELEASED = 2; /* Lock released */
+ const DONE = 3; /* Another worker did the work for you */
+
+ const ERROR = -1; /* Indeterminate error */
+ const NOT_LOCKED = -2; /* Called release() with no lock held */
+ const QUEUE_FULL = -3; /* There are already maxqueue workers on this lock */
+ const TIMEOUT = -4; /* Timeout exceeded */
+ const LOCK_HELD = -5; /* Cannot acquire another lock while you have one lock held */
+
+ /** @var string All workers with the same key share the lock */
+ protected $key;
+ /** @var int Maximum number of workers working on tasks with the same key simultaneously */
+ protected $workers;
+ /**
+ * Maximum number of workers working on this task type, regardless of key.
+ * 0 means unlimited. Max allowed value is 65536.
+ * The way the slot limit is enforced is overzealous - this option should be used with caution.
+ * @var int
+ */
+ protected $slots = 0;
+ /** @var int If this number of workers are already working/waiting, fail instead of wait */
+ protected $maxqueue;
+ /** @var float Maximum time in seconds to wait for the lock */
+ protected $timeout;
+
+ /**
+ * @var bool Whether the key is a "might wait" key
+ */
+ private $isMightWaitKey;
+ /**
+ * @var bool Whether this process holds a "might wait" lock key
+ */
+ private static $acquiredMightWaitKey = 0;
+
+ /**
+ * @param array $conf
+ * @param string $type The class of actions to limit concurrency for (task type)
+ * @param string $key
+ */
+ protected function __construct( $conf, $type, $key ) {
+ $this->workers = $conf['workers'];
+ $this->maxqueue = $conf['maxqueue'];
+ $this->timeout = $conf['timeout'];
+ if ( isset( $conf['slots'] ) ) {
+ $this->slots = $conf['slots'];
+ }
+
+ if ( $this->slots ) {
+ $key = $this->hashKeyIntoSlots( $type, $key, $this->slots );
+ }
+
+ $this->key = $key;
+ $this->isMightWaitKey = !preg_match( '/^nowait:/', $this->key );
+ }
+
+ /**
+ * Create a Pool counter. This should only be called from the PoolWorks.
+ *
+ * @param string $type The class of actions to limit concurrency for (task type)
+ * @param string $key
+ *
+ * @return PoolCounter
+ */
+ public static function factory( $type, $key ) {
+ global $wgPoolCounterConf;
+ if ( !isset( $wgPoolCounterConf[$type] ) ) {
+ return new PoolCounter_Stub;
+ }
+ $conf = $wgPoolCounterConf[$type];
+ $class = $conf['class'];
+
+ return new $class( $conf, $type, $key );
+ }
+
+ /**
+ * @return string
+ */
+ public function getKey() {
+ return $this->key;
+ }
+
+ /**
+ * I want to do this task and I need to do it myself.
+ *
+ * @return Status Value is one of Locked/Error
+ */
+ abstract public function acquireForMe();
+
+ /**
+ * I want to do this task, but if anyone else does it
+ * instead, it's also fine for me. I will read its cached data.
+ *
+ * @return Status Value is one of Locked/Done/Error
+ */
+ abstract public function acquireForAnyone();
+
+ /**
+ * I have successfully finished my task.
+ * Lets another one grab the lock, and returns the workers
+ * waiting on acquireForAnyone()
+ *
+ * @return Status Value is one of Released/NotLocked/Error
+ */
+ abstract public function release();
+
+ /**
+ * Checks that the lock request is sane.
+ * @return Status - good for sane requests fatal for insane
+ * @since 1.25
+ */
+ final protected function precheckAcquire() {
+ if ( $this->isMightWaitKey ) {
+ if ( self::$acquiredMightWaitKey ) {
+ /*
+ * The poolcounter itself is quite happy to allow you to wait
+ * on another lock while you have a lock you waited on already
+ * but we think that it is unlikely to be a good idea. So we
+ * made it an error. If you are _really_ _really_ sure it is a
+ * good idea then feel free to implement an unsafe flag or
+ * something.
+ */
+ return Status::newFatal( 'poolcounter-usage-error',
+ 'You may only aquire a single non-nowait lock.' );
+ }
+ } elseif ( $this->timeout !== 0 ) {
+ return Status::newFatal( 'poolcounter-usage-error',
+ 'Locks starting in nowait: must have 0 timeout.' );
+ }
+ return Status::newGood();
+ }
+
+ /**
+ * Update any lock tracking information when the lock is acquired
+ * @since 1.25
+ */
+ final protected function onAcquire() {
+ self::$acquiredMightWaitKey |= $this->isMightWaitKey;
+ }
+
+ /**
+ * Update any lock tracking information when the lock is released
+ * @since 1.25
+ */
+ final protected function onRelease() {
+ self::$acquiredMightWaitKey &= !$this->isMightWaitKey;
+ }
+
+ /**
+ * Given a key (any string) and the number of lots, returns a slot key (a prefix with a suffix
+ * integer from the [0..($slots-1)] range). This is used for a global limit on the number of
+ * instances of a given type that can acquire a lock. The hashing is deterministic so that
+ * PoolCounter::$workers is always an upper limit of how many instances with the same key
+ * can acquire a lock.
+ *
+ * @param string $type The class of actions to limit concurrency for (task type)
+ * @param string $key PoolCounter instance key (any string)
+ * @param int $slots The number of slots (max allowed value is 65536)
+ * @return string Slot key with the type and slot number
+ */
+ protected function hashKeyIntoSlots( $type, $key, $slots ) {
+ return $type . ':' . ( hexdec( substr( sha1( $key ), 0, 4 ) ) % $slots );
+ }
+}
+
+// @codingStandardsIgnoreStart Squiz.Classes.ValidClassName.NotCamelCaps
+class PoolCounter_Stub extends PoolCounter {
+ // @codingStandardsIgnoreEnd
+
+ public function __construct() {
+ /* No parameters needed */
+ }
+
+ public function acquireForMe() {
+ return Status::newGood( PoolCounter::LOCKED );
+ }
+
+ public function acquireForAnyone() {
+ return Status::newGood( PoolCounter::LOCKED );
+ }
+
+ public function release() {
+ return Status::newGood( PoolCounter::RELEASED );
+ }
+}
diff --git a/www/wiki/includes/poolcounter/PoolCounterRedis.php b/www/wiki/includes/poolcounter/PoolCounterRedis.php
new file mode 100644
index 00000000..65ea8333
--- /dev/null
+++ b/www/wiki/includes/poolcounter/PoolCounterRedis.php
@@ -0,0 +1,434 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Psr\Log\LoggerInterface;
+
+/**
+ * Version of PoolCounter that uses Redis
+ *
+ * There are four main redis keys used to track each pool counter key:
+ * - poolcounter:l-slots-* : A list of available slot IDs for a pool.
+ * - poolcounter:z-renewtime-* : A sorted set of (slot ID, UNIX timestamp as score)
+ * used for tracking the next time a slot should be
+ * released. This is -1 when a slot is created, and is
+ * set when released (expired), locked, and unlocked.
+ * - poolcounter:z-wait-* : A sorted set of (slot ID, UNIX timestamp as score)
+ * used for tracking waiting processes (and wait time).
+ * - poolcounter:l-wakeup-* : A list pushed to for the sake of waking up processes
+ * when a any process in the pool finishes (lasts for 1ms).
+ * For a given pool key, all the redis keys start off non-existing and are deleted if not
+ * used for a while to prevent garbage from building up on the server. They are atomically
+ * re-initialized as needed. The "z-renewtime" key is used for detecting sessions which got
+ * slots but then disappeared. Stale entries from there have their timestamp updated and the
+ * corresponding slots freed up. The "z-wait" key is used for detecting processes registered
+ * as waiting but that disappeared. Stale entries from there are deleted and the corresponding
+ * slots are freed up. The worker count is included in all the redis key names as it does not
+ * vary within each $wgPoolCounterConf type and doing so handles configuration changes.
+ *
+ * This class requires Redis 2.6 as it makes use Lua scripts for fast atomic operations.
+ * Also this should be on a server plenty of RAM for the working set to avoid evictions.
+ * Evictions could temporarily allow wait queues to double in size or temporarily cause
+ * pools to appear as full when they are not. Using volatile-ttl and bumping memory-samples
+ * in redis.conf can be helpful otherwise.
+ *
+ * @ingroup Redis
+ * @since 1.23
+ */
+class PoolCounterRedis extends PoolCounter {
+ /** @var HashRing */
+ protected $ring;
+ /** @var RedisConnectionPool */
+ protected $pool;
+ /** @var LoggerInterface */
+ protected $logger;
+ /** @var array (server label => host) map */
+ protected $serversByLabel;
+ /** @var string SHA-1 of the key */
+ protected $keySha1;
+ /** @var int TTL for locks to expire (work should finish in this time) */
+ protected $lockTTL;
+
+ /** @var RedisConnRef */
+ protected $conn;
+ /** @var string Pool slot value */
+ protected $slot;
+ /** @var int AWAKE_* constant */
+ protected $onRelease;
+ /** @var string Unique string to identify this process */
+ protected $session;
+ /** @var int UNIX timestamp */
+ protected $slotTime;
+
+ const AWAKE_ONE = 1; // wake-up if when a slot can be taken from an existing process
+ const AWAKE_ALL = 2; // wake-up if an existing process finishes and wake up such others
+
+ /** @var PoolCounterRedis[] List of active PoolCounterRedis objects in this script */
+ protected static $active = null;
+
+ function __construct( $conf, $type, $key ) {
+ parent::__construct( $conf, $type, $key );
+
+ $this->serversByLabel = $conf['servers'];
+ $this->ring = new HashRing( array_fill_keys( array_keys( $conf['servers'] ), 100 ) );
+
+ $conf['redisConfig']['serializer'] = 'none'; // for use with Lua
+ $this->pool = RedisConnectionPool::singleton( $conf['redisConfig'] );
+ $this->logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'redis' );
+
+ $this->keySha1 = sha1( $this->key );
+ $met = ini_get( 'max_execution_time' ); // usually 0 in CLI mode
+ $this->lockTTL = $met ? 2 * $met : 3600;
+
+ if ( self::$active === null ) {
+ self::$active = [];
+ register_shutdown_function( [ __CLASS__, 'releaseAll' ] );
+ }
+ }
+
+ /**
+ * @return Status Uses RediConnRef as value on success
+ */
+ protected function getConnection() {
+ if ( !isset( $this->conn ) ) {
+ $conn = false;
+ $servers = $this->ring->getLocations( $this->key, 3 );
+ ArrayUtils::consistentHashSort( $servers, $this->key );
+ foreach ( $servers as $server ) {
+ $conn = $this->pool->getConnection( $this->serversByLabel[$server], $this->logger );
+ if ( $conn ) {
+ break;
+ }
+ }
+ if ( !$conn ) {
+ return Status::newFatal( 'pool-servererror', implode( ', ', $servers ) );
+ }
+ $this->conn = $conn;
+ }
+ return Status::newGood( $this->conn );
+ }
+
+ function acquireForMe() {
+ $status = $this->precheckAcquire();
+ if ( !$status->isGood() ) {
+ return $status;
+ }
+
+ return $this->waitForSlotOrNotif( self::AWAKE_ONE );
+ }
+
+ function acquireForAnyone() {
+ $status = $this->precheckAcquire();
+ if ( !$status->isGood() ) {
+ return $status;
+ }
+
+ return $this->waitForSlotOrNotif( self::AWAKE_ALL );
+ }
+
+ function release() {
+ if ( $this->slot === null ) {
+ return Status::newGood( PoolCounter::NOT_LOCKED ); // not locked
+ }
+
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $conn = $status->value;
+
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ static $script =
+ /** @lang Lua */
+<<<LUA
+ local kSlots,kSlotsNextRelease,kWakeup,kWaiting = unpack(KEYS)
+ local rMaxWorkers,rExpiry,rSlot,rSlotTime,rAwakeAll,rTime = unpack(ARGV)
+ -- Add the slots back to the list (if rSlot is "w" then it is not a slot).
+ -- Treat the list as expired if the "next release" time sorted-set is missing.
+ if rSlot ~= 'w' and redis.call('exists',kSlotsNextRelease) == 1 then
+ if 1*redis.call('zScore',kSlotsNextRelease,rSlot) ~= (rSlotTime + rExpiry) then
+ -- Slot lock expired and was released already
+ elseif redis.call('lLen',kSlots) >= 1*rMaxWorkers then
+ -- Slots somehow got out of sync; reset the list for sanity
+ redis.call('del',kSlots,kSlotsNextRelease)
+ elseif redis.call('lLen',kSlots) == (1*rMaxWorkers - 1) and redis.call('zCard',kWaiting) == 0 then
+ -- Slot list will be made full; clear it to save space (it re-inits as needed)
+ -- since nothing is waiting on being unblocked by a push to the list
+ redis.call('del',kSlots,kSlotsNextRelease)
+ else
+ -- Add slot back to pool and update the "next release" time
+ redis.call('rPush',kSlots,rSlot)
+ redis.call('zAdd',kSlotsNextRelease,rTime + 30,rSlot)
+ -- Always keep renewing the expiry on use
+ redis.call('expireAt',kSlots,math.ceil(rTime + rExpiry))
+ redis.call('expireAt',kSlotsNextRelease,math.ceil(rTime + rExpiry))
+ end
+ end
+ -- Update an ephemeral list to wake up other clients that can
+ -- reuse any cached work from this process. Only do this if no
+ -- slots are currently free (e.g. clients could be waiting).
+ if 1*rAwakeAll == 1 then
+ local count = redis.call('zCard',kWaiting)
+ for i = 1,count do
+ redis.call('rPush',kWakeup,'w')
+ end
+ redis.call('pexpire',kWakeup,1)
+ end
+ return 1
+LUA;
+ // @codingStandardsIgnoreEnd
+
+ try {
+ $conn->luaEval( $script,
+ [
+ $this->getSlotListKey(),
+ $this->getSlotRTimeSetKey(),
+ $this->getWakeupListKey(),
+ $this->getWaitSetKey(),
+ $this->workers,
+ $this->lockTTL,
+ $this->slot,
+ $this->slotTime, // used for CAS-style sanity check
+ ( $this->onRelease === self::AWAKE_ALL ) ? 1 : 0,
+ microtime( true )
+ ],
+ 4 # number of first argument(s) that are keys
+ );
+ } catch ( RedisException $e ) {
+ return Status::newFatal( 'pool-error-unknown', $e->getMessage() );
+ }
+
+ $this->slot = null;
+ $this->slotTime = null;
+ $this->onRelease = null;
+ unset( self::$active[$this->session] );
+
+ $this->onRelease();
+
+ return Status::newGood( PoolCounter::RELEASED );
+ }
+
+ /**
+ * @param int $doWakeup AWAKE_* constant
+ * @return Status
+ */
+ protected function waitForSlotOrNotif( $doWakeup ) {
+ if ( $this->slot !== null ) {
+ return Status::newGood( PoolCounter::LOCK_HELD ); // already acquired
+ }
+
+ $status = $this->getConnection();
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ $conn = $status->value;
+
+ $now = microtime( true );
+ try {
+ $slot = $this->initAndPopPoolSlotList( $conn, $now );
+ if ( ctype_digit( $slot ) ) {
+ // Pool slot acquired by this process
+ $slotTime = $now;
+ } elseif ( $slot === 'QUEUE_FULL' ) {
+ // Too many processes are waiting for pooled processes to finish
+ return Status::newGood( PoolCounter::QUEUE_FULL );
+ } elseif ( $slot === 'QUEUE_WAIT' ) {
+ // This process is now registered as waiting
+ $keys = ( $doWakeup == self::AWAKE_ALL )
+ // Wait for an open slot or wake-up signal (preferring the latter)
+ ? [ $this->getWakeupListKey(), $this->getSlotListKey() ]
+ // Just wait for an actual pool slot
+ : [ $this->getSlotListKey() ];
+
+ $res = $conn->blPop( $keys, $this->timeout );
+ if ( $res === [] ) {
+ $conn->zRem( $this->getWaitSetKey(), $this->session ); // no longer waiting
+ return Status::newGood( PoolCounter::TIMEOUT );
+ }
+
+ $slot = $res[1]; // pool slot or "w" for wake-up notifications
+ $slotTime = microtime( true ); // last microtime() was a few RTTs ago
+ // Unregister this process as waiting and bump slot "next release" time
+ $this->registerAcquisitionTime( $conn, $slot, $slotTime );
+ } else {
+ return Status::newFatal( 'pool-error-unknown', "Server gave slot '$slot'." );
+ }
+ } catch ( RedisException $e ) {
+ return Status::newFatal( 'pool-error-unknown', $e->getMessage() );
+ }
+
+ if ( $slot !== 'w' ) {
+ $this->slot = $slot;
+ $this->slotTime = $slotTime;
+ $this->onRelease = $doWakeup;
+ self::$active[$this->session] = $this;
+ }
+
+ $this->onAcquire();
+
+ return Status::newGood( $slot === 'w' ? PoolCounter::DONE : PoolCounter::LOCKED );
+ }
+
+ /**
+ * @param RedisConnRef $conn
+ * @param float $now UNIX timestamp
+ * @return string|bool False on failure
+ */
+ protected function initAndPopPoolSlotList( RedisConnRef $conn, $now ) {
+ static $script =
+ /** @lang Lua */
+<<<LUA
+ local kSlots,kSlotsNextRelease,kSlotWaits = unpack(KEYS)
+ local rMaxWorkers,rMaxQueue,rTimeout,rExpiry,rSess,rTime = unpack(ARGV)
+ -- Initialize if the "next release" time sorted-set is empty. The slot key
+ -- itself is empty if all slots are busy or when nothing is initialized.
+ -- If the list is empty but the set is not, then it is the latter case.
+ -- For sanity, if the list exists but not the set, then reset everything.
+ if redis.call('exists',kSlotsNextRelease) == 0 then
+ redis.call('del',kSlots)
+ for i = 1,1*rMaxWorkers do
+ redis.call('rPush',kSlots,i)
+ redis.call('zAdd',kSlotsNextRelease,-1,i)
+ end
+ -- Otherwise do maintenance to clean up after network partitions
+ else
+ -- Find stale slot locks and add free them (avoid duplicates for sanity)
+ local staleLocks = redis.call('zRangeByScore',kSlotsNextRelease,0,rTime)
+ for k,slot in ipairs(staleLocks) do
+ redis.call('lRem',kSlots,0,slot)
+ redis.call('rPush',kSlots,slot)
+ redis.call('zAdd',kSlotsNextRelease,rTime + 30,slot)
+ end
+ -- Find stale wait slot entries and remove them
+ redis.call('zRemRangeByScore',kSlotWaits,0,rTime - 2*rTimeout)
+ end
+ local slot
+ -- Try to acquire a slot if possible now
+ if redis.call('lLen',kSlots) > 0 then
+ slot = redis.call('lPop',kSlots)
+ -- Update the slot "next release" time
+ redis.call('zAdd',kSlotsNextRelease,rTime + rExpiry,slot)
+ elseif redis.call('zCard',kSlotWaits) >= 1*rMaxQueue then
+ slot = 'QUEUE_FULL'
+ else
+ slot = 'QUEUE_WAIT'
+ -- Register this process as waiting
+ redis.call('zAdd',kSlotWaits,rTime,rSess)
+ redis.call('expireAt',kSlotWaits,math.ceil(rTime + 2*rTimeout))
+ end
+ -- Always keep renewing the expiry on use
+ redis.call('expireAt',kSlots,math.ceil(rTime + rExpiry))
+ redis.call('expireAt',kSlotsNextRelease,math.ceil(rTime + rExpiry))
+ return slot
+LUA;
+ return $conn->luaEval( $script,
+ [
+ $this->getSlotListKey(),
+ $this->getSlotRTimeSetKey(),
+ $this->getWaitSetKey(),
+ $this->workers,
+ $this->maxqueue,
+ $this->timeout,
+ $this->lockTTL,
+ $this->session,
+ $now
+ ],
+ 3 # number of first argument(s) that are keys
+ );
+ }
+
+ /**
+ * @param RedisConnRef $conn
+ * @param string $slot
+ * @param float $now
+ * @return int|bool False on failure
+ */
+ protected function registerAcquisitionTime( RedisConnRef $conn, $slot, $now ) {
+ static $script =
+ /** @lang Lua */
+<<<LUA
+ local kSlots,kSlotsNextRelease,kSlotWaits = unpack(KEYS)
+ local rSlot,rExpiry,rSess,rTime = unpack(ARGV)
+ -- If rSlot is 'w' then the client was told to wake up but got no slot
+ if rSlot ~= 'w' then
+ -- Update the slot "next release" time
+ redis.call('zAdd',kSlotsNextRelease,rTime + rExpiry,rSlot)
+ -- Always keep renewing the expiry on use
+ redis.call('expireAt',kSlots,math.ceil(rTime + rExpiry))
+ redis.call('expireAt',kSlotsNextRelease,math.ceil(rTime + rExpiry))
+ end
+ -- Unregister this process as waiting
+ redis.call('zRem',kSlotWaits,rSess)
+ return 1
+LUA;
+ return $conn->luaEval( $script,
+ [
+ $this->getSlotListKey(),
+ $this->getSlotRTimeSetKey(),
+ $this->getWaitSetKey(),
+ $slot,
+ $this->lockTTL,
+ $this->session,
+ $now
+ ],
+ 3 # number of first argument(s) that are keys
+ );
+ }
+
+ /**
+ * @return string
+ */
+ protected function getSlotListKey() {
+ return "poolcounter:l-slots-{$this->keySha1}-{$this->workers}";
+ }
+
+ /**
+ * @return string
+ */
+ protected function getSlotRTimeSetKey() {
+ return "poolcounter:z-renewtime-{$this->keySha1}-{$this->workers}";
+ }
+
+ /**
+ * @return string
+ */
+ protected function getWaitSetKey() {
+ return "poolcounter:z-wait-{$this->keySha1}-{$this->workers}";
+ }
+
+ /**
+ * @return string
+ */
+ protected function getWakeupListKey() {
+ return "poolcounter:l-wakeup-{$this->keySha1}-{$this->workers}";
+ }
+
+ /**
+ * Try to make sure that locks get released (even with exceptions and fatals)
+ */
+ public static function releaseAll() {
+ foreach ( self::$active as $poolCounter ) {
+ try {
+ if ( $poolCounter->slot !== null ) {
+ $poolCounter->release();
+ }
+ } catch ( Exception $e ) {
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/poolcounter/PoolCounterWork.php b/www/wiki/includes/poolcounter/PoolCounterWork.php
new file mode 100644
index 00000000..a570d78c
--- /dev/null
+++ b/www/wiki/includes/poolcounter/PoolCounterWork.php
@@ -0,0 +1,160 @@
+<?php
+/**
+ * Provides of semaphore semantics for restricting the number
+ * of workers that may be concurrently performing the same task.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class for dealing with PoolCounters using class members
+ */
+abstract class PoolCounterWork {
+ /** @var string */
+ protected $type = 'generic';
+ /** @var bool */
+ protected $cacheable = false; // does this override getCachedWork() ?
+
+ /**
+ * @param string $type The class of actions to limit concurrency for (task type)
+ * @param string $key Key that identifies the queue this work is placed on
+ */
+ public function __construct( $type, $key ) {
+ $this->type = $type;
+ $this->poolCounter = PoolCounter::factory( $type, $key );
+ }
+
+ /**
+ * Actually perform the work, caching it if needed
+ * @return mixed Work result or false
+ */
+ abstract public function doWork();
+
+ /**
+ * Retrieve the work from cache
+ * @return mixed Work result or false
+ */
+ public function getCachedWork() {
+ return false;
+ }
+
+ /**
+ * A work not so good (eg. expired one) but better than an error
+ * message.
+ * @return mixed Work result or false
+ */
+ public function fallback() {
+ return false;
+ }
+
+ /**
+ * Do something with the error, like showing it to the user.
+ *
+ * @param Status $status
+ *
+ * @return bool
+ */
+ public function error( $status ) {
+ return false;
+ }
+
+ /**
+ * Log an error
+ *
+ * @param Status $status
+ * @return void
+ */
+ public function logError( $status ) {
+ $key = $this->poolCounter->getKey();
+
+ wfDebugLog( 'poolcounter', "Pool key '$key' ({$this->type}): "
+ . $status->getMessage()->inLanguage( 'en' )->useDatabase( false )->text() );
+ }
+
+ /**
+ * Get the result of the work (whatever it is), or the result of the error() function.
+ * This returns the result of the first applicable method that returns a non-false value,
+ * where the methods are checked in the following order:
+ * - a) doWork() : Applies if the work is exclusive or no another process
+ * is doing it, and on the condition that either this process
+ * successfully entered the pool or the pool counter is down.
+ * - b) doCachedWork() : Applies if the work is cacheable and this blocked on another
+ * process which finished the work.
+ * - c) fallback() : Applies for all remaining cases.
+ * If these all fall through (by returning false), then the result of error() is returned.
+ *
+ * @param bool $skipcache
+ * @return mixed
+ */
+ public function execute( $skipcache = false ) {
+ if ( $this->cacheable && !$skipcache ) {
+ $status = $this->poolCounter->acquireForAnyone();
+ } else {
+ $status = $this->poolCounter->acquireForMe();
+ }
+
+ if ( !$status->isOK() ) {
+ // Respond gracefully to complete server breakage: just log it and do the work
+ $this->logError( $status );
+ return $this->doWork();
+ }
+
+ switch ( $status->value ) {
+ case PoolCounter::LOCK_HELD:
+ // Better to ignore nesting pool counter limits than to fail.
+ // Assume that the outer pool limiting is reasonable enough.
+ /* no break */
+ case PoolCounter::LOCKED:
+ $result = $this->doWork();
+ $this->poolCounter->release();
+ return $result;
+
+ case PoolCounter::DONE:
+ $result = $this->getCachedWork();
+ if ( $result === false ) {
+ /* That someone else work didn't serve us.
+ * Acquire the lock for me
+ */
+ return $this->execute( true );
+ }
+ return $result;
+
+ case PoolCounter::QUEUE_FULL:
+ case PoolCounter::TIMEOUT:
+ $result = $this->fallback();
+
+ if ( $result !== false ) {
+ return $result;
+ }
+ /* no break */
+
+ /* These two cases should never be hit... */
+ case PoolCounter::ERROR:
+ default:
+ $errors = [
+ PoolCounter::QUEUE_FULL => 'pool-queuefull',
+ PoolCounter::TIMEOUT => 'pool-timeout' ];
+
+ $status = Status::newFatal( isset( $errors[$status->value] )
+ ? $errors[$status->value]
+ : 'pool-errorunknown' );
+ $this->logError( $status );
+ return $this->error( $status );
+ }
+ }
+}
diff --git a/www/wiki/includes/poolcounter/PoolCounterWorkViaCallback.php b/www/wiki/includes/poolcounter/PoolCounterWorkViaCallback.php
new file mode 100644
index 00000000..834b8b1f
--- /dev/null
+++ b/www/wiki/includes/poolcounter/PoolCounterWorkViaCallback.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * Provides of semaphore semantics for restricting the number
+ * of workers that may be concurrently performing the same task.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Convenience class for dealing with PoolCounters using callbacks
+ * @since 1.22
+ */
+class PoolCounterWorkViaCallback extends PoolCounterWork {
+ /** @var callable */
+ protected $doWork;
+ /** @var callable|null */
+ protected $doCachedWork;
+ /** @var callable|null */
+ protected $fallback;
+ /** @var callable|null */
+ protected $error;
+
+ /**
+ * Build a PoolCounterWork class from a type, key, and callback map.
+ *
+ * The callback map must at least have a callback for the 'doWork' method.
+ * Additionally, callbacks can be provided for the 'doCachedWork', 'fallback',
+ * and 'error' methods. Methods without callbacks will be no-ops that return false.
+ * If a 'doCachedWork' callback is provided, then execute() may wait for any prior
+ * process in the pool to finish and reuse its cached result.
+ *
+ * @param string $type The class of actions to limit concurrency for
+ * @param string $key
+ * @param array $callbacks Map of callbacks
+ * @throws MWException
+ */
+ public function __construct( $type, $key, array $callbacks ) {
+ parent::__construct( $type, $key );
+ foreach ( [ 'doWork', 'doCachedWork', 'fallback', 'error' ] as $name ) {
+ if ( isset( $callbacks[$name] ) ) {
+ if ( !is_callable( $callbacks[$name] ) ) {
+ throw new MWException( "Invalid callback provided for '$name' function." );
+ }
+ $this->$name = $callbacks[$name];
+ }
+ }
+ if ( !isset( $this->doWork ) ) {
+ throw new MWException( "No callback provided for 'doWork' function." );
+ }
+ $this->cacheable = isset( $this->doCachedWork );
+ }
+
+ public function doWork() {
+ return call_user_func_array( $this->doWork, [] );
+ }
+
+ public function getCachedWork() {
+ if ( $this->doCachedWork ) {
+ return call_user_func_array( $this->doCachedWork, [] );
+ }
+ return false;
+ }
+
+ public function fallback() {
+ if ( $this->fallback ) {
+ return call_user_func_array( $this->fallback, [] );
+ }
+ return false;
+ }
+
+ public function error( $status ) {
+ if ( $this->error ) {
+ return call_user_func_array( $this->error, [ $status ] );
+ }
+ return false;
+ }
+}
diff --git a/www/wiki/includes/poolcounter/PoolWorkArticleView.php b/www/wiki/includes/poolcounter/PoolWorkArticleView.php
new file mode 100644
index 00000000..17b62d77
--- /dev/null
+++ b/www/wiki/includes/poolcounter/PoolWorkArticleView.php
@@ -0,0 +1,220 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+class PoolWorkArticleView extends PoolCounterWork {
+ /** @var WikiPage */
+ private $page;
+
+ /** @var string */
+ private $cacheKey;
+
+ /** @var int */
+ private $revid;
+
+ /** @var ParserCache */
+ private $parserCache;
+
+ /** @var ParserOptions */
+ private $parserOptions;
+
+ /** @var Content|null */
+ private $content = null;
+
+ /** @var ParserOutput|bool */
+ private $parserOutput = false;
+
+ /** @var bool */
+ private $isDirty = false;
+
+ /** @var Status|bool */
+ private $error = false;
+
+ /**
+ * @param WikiPage $page
+ * @param ParserOptions $parserOptions ParserOptions to use for the parse
+ * @param int $revid ID of the revision being parsed.
+ * @param bool $useParserCache Whether to use the parser cache.
+ * operation.
+ * @param Content|string $content Content to parse or null to load it; may
+ * also be given as a wikitext string, for BC.
+ */
+ public function __construct( WikiPage $page, ParserOptions $parserOptions,
+ $revid, $useParserCache, $content = null
+ ) {
+ if ( is_string( $content ) ) { // BC: old style call
+ $modelId = $page->getRevision()->getContentModel();
+ $format = $page->getRevision()->getContentFormat();
+ $content = ContentHandler::makeContent( $content, $page->getTitle(), $modelId, $format );
+ }
+
+ $this->page = $page;
+ $this->revid = $revid;
+ $this->cacheable = $useParserCache;
+ $this->parserOptions = $parserOptions;
+ $this->content = $content;
+ $this->parserCache = MediaWikiServices::getInstance()->getParserCache();
+ $this->cacheKey = $this->parserCache->getKey( $page, $parserOptions );
+ $keyPrefix = $this->cacheKey ?: wfMemcKey( 'articleview', 'missingcachekey' );
+ parent::__construct( 'ArticleView', $keyPrefix . ':revid:' . $revid );
+ }
+
+ /**
+ * Get the ParserOutput from this object, or false in case of failure
+ *
+ * @return ParserOutput
+ */
+ public function getParserOutput() {
+ return $this->parserOutput;
+ }
+
+ /**
+ * Get whether the ParserOutput is a dirty one (i.e. expired)
+ *
+ * @return bool
+ */
+ public function getIsDirty() {
+ return $this->isDirty;
+ }
+
+ /**
+ * Get a Status object in case of error or false otherwise
+ *
+ * @return Status|bool
+ */
+ public function getError() {
+ return $this->error;
+ }
+
+ /**
+ * @return bool
+ */
+ public function doWork() {
+ global $wgUseFileCache;
+
+ // @todo several of the methods called on $this->page are not declared in Page, but present
+ // in WikiPage and delegated by Article.
+
+ $isCurrent = $this->revid === $this->page->getLatest();
+
+ if ( $this->content !== null ) {
+ $content = $this->content;
+ } elseif ( $isCurrent ) {
+ // XXX: why use RAW audience here, and PUBLIC (default) below?
+ $content = $this->page->getContent( Revision::RAW );
+ } else {
+ $rev = Revision::newFromTitle( $this->page->getTitle(), $this->revid );
+
+ if ( $rev === null ) {
+ $content = null;
+ } else {
+ // XXX: why use PUBLIC audience here (default), and RAW above?
+ $content = $rev->getContent();
+ }
+ }
+
+ if ( $content === null ) {
+ return false;
+ }
+
+ // Reduce effects of race conditions for slow parses (T48014)
+ $cacheTime = wfTimestampNow();
+
+ $time = - microtime( true );
+ $this->parserOutput = $content->getParserOutput(
+ $this->page->getTitle(),
+ $this->revid,
+ $this->parserOptions
+ );
+ $time += microtime( true );
+
+ // Timing hack
+ if ( $time > 3 ) {
+ // TODO: Use Parser's logger (once it has one)
+ $logger = MediaWiki\Logger\LoggerFactory::getInstance( 'slow-parse' );
+ $logger->info( '{time} {title}', [
+ 'time' => number_format( $time, 2 ),
+ 'title' => $this->page->getTitle()->getPrefixedDBkey(),
+ 'ns' => $this->page->getTitle()->getNamespace(),
+ 'trigger' => 'view',
+ ] );
+ }
+
+ if ( $this->cacheable && $this->parserOutput->isCacheable() && $isCurrent ) {
+ $this->parserCache->save(
+ $this->parserOutput, $this->page, $this->parserOptions, $cacheTime, $this->revid );
+ }
+
+ // Make sure file cache is not used on uncacheable content.
+ // Output that has magic words in it can still use the parser cache
+ // (if enabled), though it will generally expire sooner.
+ if ( !$this->parserOutput->isCacheable() ) {
+ $wgUseFileCache = false;
+ }
+
+ if ( $isCurrent ) {
+ $this->page->triggerOpportunisticLinksUpdate( $this->parserOutput );
+ }
+
+ return true;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getCachedWork() {
+ $this->parserOutput = $this->parserCache->get( $this->page, $this->parserOptions );
+
+ if ( $this->parserOutput === false ) {
+ wfDebug( __METHOD__ . ": parser cache miss\n" );
+ return false;
+ } else {
+ wfDebug( __METHOD__ . ": parser cache hit\n" );
+ return true;
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function fallback() {
+ $this->parserOutput = $this->parserCache->getDirty( $this->page, $this->parserOptions );
+
+ if ( $this->parserOutput === false ) {
+ wfDebugLog( 'dirty', 'dirty missing' );
+ wfDebug( __METHOD__ . ": no dirty cache\n" );
+ return false;
+ } else {
+ wfDebug( __METHOD__ . ": sending dirty output\n" );
+ wfDebugLog( 'dirty', "dirty output {$this->cacheKey}" );
+ $this->isDirty = true;
+ return true;
+ }
+ }
+
+ /**
+ * @param Status $status
+ * @return bool
+ */
+ public function error( $status ) {
+ $this->error = $status;
+ return false;
+ }
+}
diff --git a/www/wiki/includes/profiler/ProfileSection.php b/www/wiki/includes/profiler/ProfileSection.php
new file mode 100644
index 00000000..d48f7442
--- /dev/null
+++ b/www/wiki/includes/profiler/ProfileSection.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Function scope profiling assistant
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+
+/**
+ * Class for handling function-scope profiling
+ *
+ * @since 1.22
+ * @deprecated since 1.25 No-op now
+ */
+class ProfileSection {
+ /**
+ * Begin profiling of a function and return an object that ends profiling
+ * of the function when that object leaves scope. As long as the object is
+ * not specifically linked to other objects, it will fall out of scope at
+ * the same moment that the function to be profiled terminates.
+ *
+ * This is typically called like:
+ * @code$section = new ProfileSection( __METHOD__ );@endcode
+ *
+ * @param string $name Name of the function to profile
+ */
+ public function __construct( $name ) {
+ wfDeprecated( __CLASS__, '1.25' );
+ }
+}
diff --git a/www/wiki/includes/profiler/Profiler.php b/www/wiki/includes/profiler/Profiler.php
new file mode 100644
index 00000000..4da7976d
--- /dev/null
+++ b/www/wiki/includes/profiler/Profiler.php
@@ -0,0 +1,320 @@
+<?php
+/**
+ * Base class for profiling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ * @defgroup Profiler Profiler
+ */
+use Wikimedia\ScopedCallback;
+use Wikimedia\Rdbms\TransactionProfiler;
+
+/**
+ * Profiler base class that defines the interface and some trivial
+ * functionality
+ *
+ * @ingroup Profiler
+ */
+abstract class Profiler {
+ /** @var string|bool Profiler ID for bucketing data */
+ protected $profileID = false;
+ /** @var bool Whether MediaWiki is in a SkinTemplate output context */
+ protected $templated = false;
+ /** @var array All of the params passed from $wgProfiler */
+ protected $params = [];
+ /** @var IContextSource Current request context */
+ protected $context = null;
+ /** @var TransactionProfiler */
+ protected $trxProfiler;
+ /** @var Profiler */
+ private static $instance = null;
+
+ /**
+ * @param array $params
+ */
+ public function __construct( array $params ) {
+ if ( isset( $params['profileID'] ) ) {
+ $this->profileID = $params['profileID'];
+ }
+ $this->params = $params;
+ $this->trxProfiler = new TransactionProfiler();
+ }
+
+ /**
+ * Singleton
+ * @return Profiler
+ */
+ final public static function instance() {
+ if ( self::$instance === null ) {
+ global $wgProfiler, $wgProfileLimit;
+
+ $params = [
+ 'class' => 'ProfilerStub',
+ 'sampling' => 1,
+ 'threshold' => $wgProfileLimit,
+ 'output' => [],
+ ];
+ if ( is_array( $wgProfiler ) ) {
+ $params = array_merge( $params, $wgProfiler );
+ }
+
+ $inSample = mt_rand( 0, $params['sampling'] - 1 ) === 0;
+ if ( PHP_SAPI === 'cli' || !$inSample ) {
+ $params['class'] = 'ProfilerStub';
+ }
+
+ if ( !is_array( $params['output'] ) ) {
+ $params['output'] = [ $params['output'] ];
+ }
+
+ self::$instance = new $params['class']( $params );
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Replace the current profiler with $profiler if no non-stub profiler is set
+ *
+ * @param Profiler $profiler
+ * @throws MWException
+ * @since 1.25
+ */
+ final public static function replaceStubInstance( Profiler $profiler ) {
+ if ( self::$instance && !( self::$instance instanceof ProfilerStub ) ) {
+ throw new MWException( 'Could not replace non-stub profiler instance.' );
+ } else {
+ self::$instance = $profiler;
+ }
+ }
+
+ /**
+ * @param string $id
+ */
+ public function setProfileID( $id ) {
+ $this->profileID = $id;
+ }
+
+ /**
+ * @return string
+ */
+ public function getProfileID() {
+ if ( $this->profileID === false ) {
+ return wfWikiID();
+ } else {
+ return $this->profileID;
+ }
+ }
+
+ /**
+ * Sets the context for this Profiler
+ *
+ * @param IContextSource $context
+ * @since 1.25
+ */
+ public function setContext( $context ) {
+ $this->context = $context;
+ }
+
+ /**
+ * Gets the context for this Profiler
+ *
+ * @return IContextSource
+ * @since 1.25
+ */
+ public function getContext() {
+ if ( $this->context ) {
+ return $this->context;
+ } else {
+ wfDebug( __METHOD__ . " called and \$context is null. " .
+ "Return RequestContext::getMain(); for sanity\n" );
+ return RequestContext::getMain();
+ }
+ }
+
+ // Kept BC for now, remove when possible
+ public function profileIn( $functionname ) {
+ }
+
+ public function profileOut( $functionname ) {
+ }
+
+ /**
+ * Mark the start of a custom profiling frame (e.g. DB queries).
+ * The frame ends when the result of this method falls out of scope.
+ *
+ * @param string $section
+ * @return ScopedCallback|null
+ * @since 1.25
+ */
+ abstract public function scopedProfileIn( $section );
+
+ /**
+ * @param SectionProfileCallback &$section
+ */
+ public function scopedProfileOut( SectionProfileCallback &$section = null ) {
+ $section = null;
+ }
+
+ /**
+ * @return TransactionProfiler
+ * @since 1.25
+ */
+ public function getTransactionProfiler() {
+ return $this->trxProfiler;
+ }
+
+ /**
+ * Close opened profiling sections
+ */
+ abstract public function close();
+
+ /**
+ * Get all usable outputs.
+ *
+ * @throws MWException
+ * @return array Array of ProfilerOutput instances.
+ * @since 1.25
+ */
+ private function getOutputs() {
+ $outputs = [];
+ foreach ( $this->params['output'] as $outputType ) {
+ // The class may be specified as either the full class name (for
+ // example, 'ProfilerOutputStats') or (for backward compatibility)
+ // the trailing portion of the class name (for example, 'stats').
+ $outputClass = strpos( $outputType, 'ProfilerOutput' ) === false
+ ? 'ProfilerOutput' . ucfirst( $outputType )
+ : $outputType;
+ if ( !class_exists( $outputClass ) ) {
+ throw new MWException( "'$outputType' is an invalid output type" );
+ }
+ $outputInstance = new $outputClass( $this, $this->params );
+ if ( $outputInstance->canUse() ) {
+ $outputs[] = $outputInstance;
+ }
+ }
+ return $outputs;
+ }
+
+ /**
+ * Log the data to some store or even the page output
+ *
+ * @since 1.25
+ */
+ public function logData() {
+ $request = $this->getContext()->getRequest();
+
+ $timeElapsed = $request->getElapsedTime();
+ $timeElapsedThreshold = $this->params['threshold'];
+ if ( $timeElapsed <= $timeElapsedThreshold ) {
+ return;
+ }
+
+ $outputs = $this->getOutputs();
+ if ( !$outputs ) {
+ return;
+ }
+
+ $stats = $this->getFunctionStats();
+ foreach ( $outputs as $output ) {
+ $output->log( $stats );
+ }
+ }
+
+ /**
+ * Output current data to the page output if configured to do so
+ *
+ * @throws MWException
+ * @since 1.26
+ */
+ public function logDataPageOutputOnly() {
+ foreach ( $this->getOutputs() as $output ) {
+ if ( $output instanceof ProfilerOutputText ) {
+ $stats = $this->getFunctionStats();
+ $output->log( $stats );
+ }
+ }
+ }
+
+ /**
+ * Get the content type sent out to the client.
+ * Used for profilers that output instead of store data.
+ * @return string
+ * @since 1.25
+ */
+ public function getContentType() {
+ foreach ( headers_list() as $header ) {
+ if ( preg_match( '#^content-type: (\w+/\w+);?#i', $header, $m ) ) {
+ return $m[1];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Mark this call as templated or not
+ *
+ * @param bool $t
+ */
+ public function setTemplated( $t ) {
+ $this->templated = $t;
+ }
+
+ /**
+ * Was this call as templated or not
+ *
+ * @return bool
+ */
+ public function getTemplated() {
+ return $this->templated;
+ }
+
+ /**
+ * Get the aggregated inclusive profiling data for each method
+ *
+ * The percent time for each time is based on the current "total" time
+ * used is based on all methods so far. This method can therefore be
+ * called several times in between several profiling calls without the
+ * delays in usage of the profiler skewing the results. A "-total" entry
+ * is always included in the results.
+ *
+ * When a call chain involves a method invoked within itself, any
+ * entries for the cyclic invocation should be be demarked with "@".
+ * This makes filtering them out easier and follows the xhprof style.
+ *
+ * @return array List of method entries arrays, each having:
+ * - name : method name
+ * - calls : the number of invoking calls
+ * - real : real time elapsed (ms)
+ * - %real : percent real time
+ * - cpu : CPU time elapsed (ms)
+ * - %cpu : percent CPU time
+ * - memory : memory used (bytes)
+ * - %memory : percent memory used
+ * - min_real : min real time in a call (ms)
+ * - max_real : max real time in a call (ms)
+ * @since 1.25
+ */
+ abstract public function getFunctionStats();
+
+ /**
+ * Returns a profiling output to be stored in debug file
+ *
+ * @return string
+ */
+ abstract public function getOutput();
+}
diff --git a/www/wiki/includes/profiler/ProfilerFunctions.php b/www/wiki/includes/profiler/ProfilerFunctions.php
new file mode 100644
index 00000000..cc716300
--- /dev/null
+++ b/www/wiki/includes/profiler/ProfilerFunctions.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * Core profiling functions. Have to exist before basically anything.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+
+/**
+ * Get system resource usage of current request context.
+ * Invokes the getrusage(2) system call, requesting RUSAGE_SELF if on PHP5
+ * or RUSAGE_THREAD if on HHVM. Returns false if getrusage is not available.
+ *
+ * @since 1.24
+ * @return array|bool Resource usage data or false if no data available.
+ */
+function wfGetRusage() {
+ if ( !function_exists( 'getrusage' ) ) {
+ return false;
+ } elseif ( defined( 'HHVM_VERSION' ) && PHP_OS === 'Linux' ) {
+ return getrusage( 2 /* RUSAGE_THREAD */ );
+ } else {
+ return getrusage( 0 /* RUSAGE_SELF */ );
+ }
+}
+
+/**
+ * Begin profiling of a function
+ * @param string $functionname Name of the function we will profile
+ * @deprecated since 1.25
+ */
+function wfProfileIn( $functionname ) {
+}
+
+/**
+ * Stop profiling of a function
+ * @param string $functionname Name of the function we have profiled
+ * @deprecated since 1.25
+ */
+function wfProfileOut( $functionname = 'missing' ) {
+}
diff --git a/www/wiki/includes/profiler/ProfilerSectionOnly.php b/www/wiki/includes/profiler/ProfilerSectionOnly.php
new file mode 100644
index 00000000..41260a83
--- /dev/null
+++ b/www/wiki/includes/profiler/ProfilerSectionOnly.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Profiler that only tracks explicit profiling sections
+ *
+ * @code
+ * $wgProfiler['class'] = 'ProfilerSectionOnly';
+ * $wgProfiler['output'] = 'text';
+ * $wgProfiler['visible'] = true;
+ * @endcode
+ *
+ * @ingroup Profiler
+ * @since 1.25
+ */
+class ProfilerSectionOnly extends Profiler {
+ /** @var SectionProfiler */
+ protected $sprofiler;
+
+ public function __construct( array $params = [] ) {
+ parent::__construct( $params );
+ $this->sprofiler = new SectionProfiler();
+ }
+
+ public function scopedProfileIn( $section ) {
+ return $this->sprofiler->scopedProfileIn( $section );
+ }
+
+ public function close() {
+ }
+
+ public function getFunctionStats() {
+ return $this->sprofiler->getFunctionStats();
+ }
+
+ public function getOutput() {
+ return $this->getFunctionReport();
+ }
+
+ /**
+ * Get a report of profiled functions sorted by inclusive wall clock time
+ * in descending order.
+ *
+ * Each line of the report includes this data:
+ * - Function name
+ * - Number of times function was called
+ * - Total wall clock time spent in function in microseconds
+ * - Minimum wall clock time spent in function in microseconds
+ * - Average wall clock time spent in function in microseconds
+ * - Maximum wall clock time spent in function in microseconds
+ * - Percentage of total wall clock time spent in function
+ * - Total delta of memory usage from start to end of function in bytes
+ *
+ * @return string
+ */
+ protected function getFunctionReport() {
+ $data = $this->getFunctionStats();
+ usort( $data, function ( $a, $b ) {
+ if ( $a['real'] === $b['real'] ) {
+ return 0;
+ }
+ return ( $a['real'] > $b['real'] ) ? -1 : 1; // descending
+ } );
+
+ $width = 140;
+ $nameWidth = $width - 65;
+ $format = "%-{$nameWidth}s %6d %9d %9d %9d %9d %7.3f%% %9d";
+ $out = [];
+ $out[] = sprintf( "%-{$nameWidth}s %6s %9s %9s %9s %9s %7s %9s",
+ 'Name', 'Calls', 'Total', 'Min', 'Each', 'Max', '%', 'Mem'
+ );
+ foreach ( $data as $stats ) {
+ $out[] = sprintf( $format,
+ $stats['name'],
+ $stats['calls'],
+ $stats['real'] * 1000,
+ $stats['min_real'] * 1000,
+ $stats['real'] / $stats['calls'] * 1000,
+ $stats['max_real'] * 1000,
+ $stats['%real'],
+ $stats['memory']
+ );
+ }
+ return implode( "\n", $out );
+ }
+}
diff --git a/www/wiki/includes/profiler/ProfilerStub.php b/www/wiki/includes/profiler/ProfilerStub.php
new file mode 100644
index 00000000..1017e446
--- /dev/null
+++ b/www/wiki/includes/profiler/ProfilerStub.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * Stub profiling functions.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+
+/**
+ * Stub profiler that does nothing
+ *
+ * @ingroup Profiler
+ */
+class ProfilerStub extends Profiler {
+ public function scopedProfileIn( $section ) {
+ return null; // no-op
+ }
+
+ public function getFunctionStats() {
+ }
+
+ public function getOutput() {
+ }
+
+ public function close() {
+ }
+
+ public function logData() {
+ }
+
+ public function logDataPageOutputOnly() {
+ }
+}
diff --git a/www/wiki/includes/profiler/ProfilerXhprof.php b/www/wiki/includes/profiler/ProfilerXhprof.php
new file mode 100644
index 00000000..09191ee5
--- /dev/null
+++ b/www/wiki/includes/profiler/ProfilerXhprof.php
@@ -0,0 +1,239 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Profiler wrapper for XHProf extension.
+ *
+ * @code
+ * $wgProfiler['class'] = 'ProfilerXhprof';
+ * $wgProfiler['flags'] = XHPROF_FLAGS_NO_BUILTINS;
+ * $wgProfiler['output'] = 'text';
+ * $wgProfiler['visible'] = true;
+ * @endcode
+ *
+ * @code
+ * $wgProfiler['class'] = 'ProfilerXhprof';
+ * $wgProfiler['flags'] = XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY | XHPROF_FLAGS_NO_BUILTINS;
+ * $wgProfiler['output'] = 'udp';
+ * @endcode
+ *
+ * ProfilerXhprof profiles all functions using the XHProf PHP extenstion.
+ * For PHP5 users, this extension can be installed via PECL or your operating
+ * system's package manager. XHProf support is built into HHVM.
+ *
+ * To restrict the functions for which profiling data is collected, you can
+ * use either a whitelist ($wgProfiler['include']) or a blacklist
+ * ($wgProfiler['exclude']) containing an array of function names.
+ * Shell-style patterns are also accepted.
+ *
+ * It is also possible to use the Tideways PHP extension, which is mostly
+ * a drop-in replacement for Xhprof. Just change the XHPROF_FLAGS_* constants
+ * to TIDEWAYS_FLAGS_*.
+ *
+ * @copyright © 2014 Wikimedia Foundation and contributors
+ * @ingroup Profiler
+ * @see Xhprof
+ * @see https://php.net/xhprof
+ * @see https://github.com/facebook/hhvm/blob/master/hphp/doc/profiling.md
+ * @see https://github.com/tideways/php-profiler-extension
+ */
+class ProfilerXhprof extends Profiler {
+ /**
+ * @var XhprofData|null $xhprofData
+ */
+ protected $xhprofData;
+
+ /**
+ * Profiler for explicit, arbitrary, frame labels
+ * @var SectionProfiler
+ */
+ protected $sprofiler;
+
+ /**
+ * @param array $params
+ * @see Xhprof::__construct()
+ */
+ public function __construct( array $params = [] ) {
+ parent::__construct( $params );
+
+ $flags = isset( $params['flags'] ) ? $params['flags'] : 0;
+ $options = isset( $params['exclude'] )
+ ? [ 'ignored_functions' => $params['exclude'] ] : [];
+ Xhprof::enable( $flags, $options );
+ $this->sprofiler = new SectionProfiler();
+ }
+
+ /**
+ * @return XhprofData
+ */
+ public function getXhprofData() {
+ if ( !$this->xhprofData ) {
+ $this->xhprofData = new XhprofData( Xhprof::disable(), $this->params );
+ }
+ return $this->xhprofData;
+ }
+
+ public function scopedProfileIn( $section ) {
+ $key = 'section.' . ltrim( $section, '.' );
+ return $this->sprofiler->scopedProfileIn( $key );
+ }
+
+ /**
+ * No-op for xhprof profiling.
+ */
+ public function close() {
+ }
+
+ /**
+ * Check if a function or section should be excluded from the output.
+ *
+ * @param string $name Function or section name.
+ * @return bool
+ */
+ private function shouldExclude( $name ) {
+ if ( $name === '-total' ) {
+ return true;
+ }
+ if ( !empty( $this->params['include'] ) ) {
+ foreach ( $this->params['include'] as $pattern ) {
+ if ( fnmatch( $pattern, $name, FNM_NOESCAPE ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ if ( !empty( $this->params['exclude'] ) ) {
+ foreach ( $this->params['exclude'] as $pattern ) {
+ if ( fnmatch( $pattern, $name, FNM_NOESCAPE ) ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ public function getFunctionStats() {
+ $metrics = $this->getXhprofData()->getCompleteMetrics();
+ $profile = [];
+
+ $main = null; // units in ms
+ foreach ( $metrics as $fname => $stats ) {
+ if ( $this->shouldExclude( $fname ) ) {
+ continue;
+ }
+ // Convert elapsed times from μs to ms to match interface
+ $entry = [
+ 'name' => $fname,
+ 'calls' => $stats['ct'],
+ 'real' => $stats['wt']['total'] / 1000,
+ '%real' => $stats['wt']['percent'],
+ 'cpu' => isset( $stats['cpu'] ) ? $stats['cpu']['total'] / 1000 : 0,
+ '%cpu' => isset( $stats['cpu'] ) ? $stats['cpu']['percent'] : 0,
+ 'memory' => isset( $stats['mu'] ) ? $stats['mu']['total'] : 0,
+ '%memory' => isset( $stats['mu'] ) ? $stats['mu']['percent'] : 0,
+ 'min_real' => $stats['wt']['min'] / 1000,
+ 'max_real' => $stats['wt']['max'] / 1000
+ ];
+ $profile[] = $entry;
+ if ( $fname === 'main()' ) {
+ $main = $entry;
+ }
+ }
+
+ // Merge in all of the custom profile sections
+ foreach ( $this->sprofiler->getFunctionStats() as $stats ) {
+ if ( $this->shouldExclude( $stats['name'] ) ) {
+ continue;
+ }
+
+ // @note: getFunctionStats() values already in ms
+ $stats['%real'] = $main['real'] ? $stats['real'] / $main['real'] * 100 : 0;
+ $stats['%cpu'] = $main['cpu'] ? $stats['cpu'] / $main['cpu'] * 100 : 0;
+ $stats['%memory'] = $main['memory'] ? $stats['memory'] / $main['memory'] * 100 : 0;
+ $profile[] = $stats; // assume no section names collide with $metrics
+ }
+
+ return $profile;
+ }
+
+ /**
+ * Returns a profiling output to be stored in debug file
+ *
+ * @return string
+ */
+ public function getOutput() {
+ return $this->getFunctionReport();
+ }
+
+ /**
+ * Get a report of profiled functions sorted by inclusive wall clock time
+ * in descending order.
+ *
+ * Each line of the report includes this data:
+ * - Function name
+ * - Number of times function was called
+ * - Total wall clock time spent in function in microseconds
+ * - Minimum wall clock time spent in function in microseconds
+ * - Average wall clock time spent in function in microseconds
+ * - Maximum wall clock time spent in function in microseconds
+ * - Percentage of total wall clock time spent in function
+ * - Total delta of memory usage from start to end of function in bytes
+ *
+ * @return string
+ */
+ protected function getFunctionReport() {
+ $data = $this->getFunctionStats();
+ usort( $data, function ( $a, $b ) {
+ if ( $a['real'] === $b['real'] ) {
+ return 0;
+ }
+ return ( $a['real'] > $b['real'] ) ? -1 : 1; // descending
+ } );
+
+ $width = 140;
+ $nameWidth = $width - 65;
+ $format = "%-{$nameWidth}s %6d %9d %9d %9d %9d %7.3f%% %9d";
+ $out = [];
+ $out[] = sprintf( "%-{$nameWidth}s %6s %9s %9s %9s %9s %7s %9s",
+ 'Name', 'Calls', 'Total', 'Min', 'Each', 'Max', '%', 'Mem'
+ );
+ foreach ( $data as $stats ) {
+ $out[] = sprintf( $format,
+ $stats['name'],
+ $stats['calls'],
+ $stats['real'] * 1000,
+ $stats['min_real'] * 1000,
+ $stats['real'] / $stats['calls'] * 1000,
+ $stats['max_real'] * 1000,
+ $stats['%real'],
+ $stats['memory']
+ );
+ }
+ return implode( "\n", $out );
+ }
+
+ /**
+ * Retrieve raw data from xhprof
+ * @return array
+ */
+ public function getRawData() {
+ return $this->getXhprofData()->getRawData();
+ }
+}
diff --git a/www/wiki/includes/profiler/SectionProfiler.php b/www/wiki/includes/profiler/SectionProfiler.php
new file mode 100644
index 00000000..57bd01f8
--- /dev/null
+++ b/www/wiki/includes/profiler/SectionProfiler.php
@@ -0,0 +1,524 @@
+<?php
+/**
+ * Arbitrary section name based PHP profiling.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+use Wikimedia\ScopedCallback;
+
+/**
+ * Custom PHP profiler for parser/DB type section names that xhprof/xdebug can't handle
+ *
+ * @since 1.25
+ */
+class SectionProfiler {
+ /** @var array Map of (mem,real,cpu) */
+ protected $start;
+ /** @var array Map of (mem,real,cpu) */
+ protected $end;
+ /** @var array List of resolved profile calls with start/end data */
+ protected $stack = [];
+ /** @var array Queue of open profile calls with start data */
+ protected $workStack = [];
+
+ /** @var array Map of (function name => aggregate data array) */
+ protected $collated = [];
+ /** @var bool */
+ protected $collateDone = false;
+
+ /** @var bool Whether to collect the full stack trace or just aggregates */
+ protected $collateOnly = true;
+ /** @var array Cache of a standard broken collation entry */
+ protected $errorEntry;
+
+ /**
+ * @param array $params
+ */
+ public function __construct( array $params = [] ) {
+ $this->errorEntry = $this->getErrorEntry();
+ $this->collateOnly = empty( $params['trace'] );
+ }
+
+ /**
+ * @param string $section
+ * @return SectionProfileCallback
+ */
+ public function scopedProfileIn( $section ) {
+ $this->profileInInternal( $section );
+
+ return new SectionProfileCallback( $this, $section );
+ }
+
+ /**
+ * @param ScopedCallback &$section
+ */
+ public function scopedProfileOut( ScopedCallback &$section ) {
+ $section = null;
+ }
+
+ /**
+ * Get the aggregated inclusive profiling data for each method
+ *
+ * The percent time for each time is based on the current "total" time
+ * used is based on all methods so far. This method can therefore be
+ * called several times in between several profiling calls without the
+ * delays in usage of the profiler skewing the results. A "-total" entry
+ * is always included in the results.
+ *
+ * @return array List of method entries arrays, each having:
+ * - name : method name
+ * - calls : the number of invoking calls
+ * - real : real time elapsed (ms)
+ * - %real : percent real time
+ * - cpu : real time elapsed (ms)
+ * - %cpu : percent real time
+ * - memory : memory used (bytes)
+ * - %memory : percent memory used
+ * - min_real : min real time in a call (ms)
+ * - max_real : max real time in a call (ms)
+ */
+ public function getFunctionStats() {
+ $this->collateData();
+
+ $totalCpu = max( $this->end['cpu'] - $this->start['cpu'], 0 );
+ $totalReal = max( $this->end['real'] - $this->start['real'], 0 );
+ $totalMem = max( $this->end['memory'] - $this->start['memory'], 0 );
+
+ $profile = [];
+ foreach ( $this->collated as $fname => $data ) {
+ $profile[] = [
+ 'name' => $fname,
+ 'calls' => $data['count'],
+ 'real' => $data['real'] * 1000,
+ '%real' => $totalReal ? 100 * $data['real'] / $totalReal : 0,
+ 'cpu' => $data['cpu'] * 1000,
+ '%cpu' => $totalCpu ? 100 * $data['cpu'] / $totalCpu : 0,
+ 'memory' => $data['memory'],
+ '%memory' => $totalMem ? 100 * $data['memory'] / $totalMem : 0,
+ 'min_real' => 1000 * $data['min_real'],
+ 'max_real' => 1000 * $data['max_real']
+ ];
+ }
+
+ $profile[] = [
+ 'name' => '-total',
+ 'calls' => 1,
+ 'real' => 1000 * $totalReal,
+ '%real' => 100,
+ 'cpu' => 1000 * $totalCpu,
+ '%cpu' => 100,
+ 'memory' => $totalMem,
+ '%memory' => 100,
+ 'min_real' => 1000 * $totalReal,
+ 'max_real' => 1000 * $totalReal
+ ];
+
+ return $profile;
+ }
+
+ /**
+ * Clear all of the profiling data for another run
+ */
+ public function reset() {
+ $this->start = null;
+ $this->end = null;
+ $this->stack = [];
+ $this->workStack = [];
+ $this->collated = [];
+ $this->collateDone = false;
+ }
+
+ /**
+ * @return array Initial collation entry
+ */
+ protected function getZeroEntry() {
+ return [
+ 'cpu' => 0.0,
+ 'real' => 0.0,
+ 'memory' => 0,
+ 'count' => 0,
+ 'min_real' => 0.0,
+ 'max_real' => 0.0
+ ];
+ }
+
+ /**
+ * @return array Initial collation entry for errors
+ */
+ protected function getErrorEntry() {
+ $entry = $this->getZeroEntry();
+ $entry['count'] = 1;
+ return $entry;
+ }
+
+ /**
+ * Update the collation entry for a given method name
+ *
+ * @param string $name
+ * @param float $elapsedCpu
+ * @param float $elapsedReal
+ * @param int $memChange
+ */
+ protected function updateEntry( $name, $elapsedCpu, $elapsedReal, $memChange ) {
+ $entry =& $this->collated[$name];
+ if ( !is_array( $entry ) ) {
+ $entry = $this->getZeroEntry();
+ $this->collated[$name] =& $entry;
+ }
+ $entry['cpu'] += $elapsedCpu;
+ $entry['real'] += $elapsedReal;
+ $entry['memory'] += $memChange > 0 ? $memChange : 0;
+ $entry['count']++;
+ $entry['min_real'] = min( $entry['min_real'], $elapsedReal );
+ $entry['max_real'] = max( $entry['max_real'], $elapsedReal );
+ }
+
+ /**
+ * This method should not be called outside SectionProfiler
+ *
+ * @param string $functionname
+ */
+ public function profileInInternal( $functionname ) {
+ // Once the data is collated for reports, any future calls
+ // should clear the collation cache so the next report will
+ // reflect them. This matters when trace mode is used.
+ $this->collateDone = false;
+
+ $cpu = $this->getTime( 'cpu' );
+ $real = $this->getTime( 'wall' );
+ $memory = memory_get_usage();
+
+ if ( $this->start === null ) {
+ $this->start = [ 'cpu' => $cpu, 'real' => $real, 'memory' => $memory ];
+ }
+
+ $this->workStack[] = [
+ $functionname,
+ count( $this->workStack ),
+ $real,
+ $cpu,
+ $memory
+ ];
+ }
+
+ /**
+ * This method should not be called outside SectionProfiler
+ *
+ * @param string $functionname
+ */
+ public function profileOutInternal( $functionname ) {
+ $item = array_pop( $this->workStack );
+ if ( $item === null ) {
+ $this->debugGroup( 'profileerror', "Profiling error: $functionname" );
+ return;
+ }
+ list( $ofname, /* $ocount */, $ortime, $octime, $omem ) = $item;
+
+ if ( $functionname === 'close' ) {
+ $message = "Profile section ended by close(): {$ofname}";
+ $this->debugGroup( 'profileerror', $message );
+ if ( $this->collateOnly ) {
+ $this->collated[$message] = $this->errorEntry;
+ } else {
+ $this->stack[] = [ $message, 0, 0.0, 0.0, 0, 0.0, 0.0, 0 ];
+ }
+ $functionname = $ofname;
+ } elseif ( $ofname !== $functionname ) {
+ $message = "Profiling error: in({$ofname}), out($functionname)";
+ $this->debugGroup( 'profileerror', $message );
+ if ( $this->collateOnly ) {
+ $this->collated[$message] = $this->errorEntry;
+ } else {
+ $this->stack[] = [ $message, 0, 0.0, 0.0, 0, 0.0, 0.0, 0 ];
+ }
+ }
+
+ $realTime = $this->getTime( 'wall' );
+ $cpuTime = $this->getTime( 'cpu' );
+ $memUsage = memory_get_usage();
+
+ if ( $this->collateOnly ) {
+ $elapsedcpu = $cpuTime - $octime;
+ $elapsedreal = $realTime - $ortime;
+ $memchange = $memUsage - $omem;
+ $this->updateEntry( $functionname, $elapsedcpu, $elapsedreal, $memchange );
+ } else {
+ $this->stack[] = array_merge( $item, [ $realTime, $cpuTime, $memUsage ] );
+ }
+
+ $this->end = [
+ 'cpu' => $cpuTime,
+ 'real' => $realTime,
+ 'memory' => $memUsage
+ ];
+ }
+
+ /**
+ * Returns a tree of function calls with their real times
+ * @return string
+ * @throws Exception
+ */
+ public function getCallTreeReport() {
+ if ( $this->collateOnly ) {
+ throw new Exception( "Tree is only available for trace profiling." );
+ }
+ return implode( '', array_map(
+ [ $this, 'getCallTreeLine' ], $this->remapCallTree( $this->stack )
+ ) );
+ }
+
+ /**
+ * Recursive function the format the current profiling array into a tree
+ *
+ * @param array $stack Profiling array
+ * @return array
+ */
+ protected function remapCallTree( array $stack ) {
+ if ( count( $stack ) < 2 ) {
+ return $stack;
+ }
+ $outputs = [];
+ for ( $max = count( $stack ) - 1; $max > 0; ) {
+ /* Find all items under this entry */
+ $level = $stack[$max][1];
+ $working = [];
+ for ( $i = $max - 1; $i >= 0; $i-- ) {
+ if ( $stack[$i][1] > $level ) {
+ $working[] = $stack[$i];
+ } else {
+ break;
+ }
+ }
+ $working = $this->remapCallTree( array_reverse( $working ) );
+ $output = [];
+ foreach ( $working as $item ) {
+ array_push( $output, $item );
+ }
+ array_unshift( $output, $stack[$max] );
+ $max = $i;
+
+ array_unshift( $outputs, $output );
+ }
+ $final = [];
+ foreach ( $outputs as $output ) {
+ foreach ( $output as $item ) {
+ $final[] = $item;
+ }
+ }
+ return $final;
+ }
+
+ /**
+ * Callback to get a formatted line for the call tree
+ * @param array $entry
+ * @return string
+ */
+ protected function getCallTreeLine( $entry ) {
+ // $entry has (name, level, stime, scpu, smem, etime, ecpu, emem)
+ list( $fname, $level, $startreal, , , $endreal ) = $entry;
+ $delta = $endreal - $startreal;
+ $space = str_repeat( ' ', $level );
+ # The ugly double sprintf is to work around a PHP bug,
+ # which has been fixed in recent releases.
+ return sprintf( "%10s %s %s\n",
+ trim( sprintf( "%7.3f", $delta * 1000.0 ) ), $space, $fname );
+ }
+
+ /**
+ * Populate collated data
+ */
+ protected function collateData() {
+ if ( $this->collateDone ) {
+ return;
+ }
+ $this->collateDone = true;
+ // Close opened profiling sections
+ while ( count( $this->workStack ) ) {
+ $this->profileOutInternal( 'close' );
+ }
+
+ if ( $this->collateOnly ) {
+ return; // already collated as methods exited
+ }
+
+ $this->collated = [];
+
+ # Estimate profiling overhead
+ $oldEnd = $this->end;
+ $profileCount = count( $this->stack );
+ $this->calculateOverhead( $profileCount );
+
+ # First, subtract the overhead!
+ $overheadTotal = $overheadMemory = $overheadInternal = [];
+ foreach ( $this->stack as $entry ) {
+ // $entry is (name,pos,rtime0,cputime0,mem0,rtime1,cputime1,mem1)
+ $fname = $entry[0];
+ $elapsed = $entry[5] - $entry[2];
+ $memchange = $entry[7] - $entry[4];
+
+ if ( $fname === '-overhead-total' ) {
+ $overheadTotal[] = $elapsed;
+ $overheadMemory[] = max( 0, $memchange );
+ } elseif ( $fname === '-overhead-internal' ) {
+ $overheadInternal[] = $elapsed;
+ }
+ }
+ $overheadTotal = $overheadTotal ?
+ array_sum( $overheadTotal ) / count( $overheadInternal ) : 0;
+ $overheadMemory = $overheadMemory ?
+ array_sum( $overheadMemory ) / count( $overheadInternal ) : 0;
+ $overheadInternal = $overheadInternal ?
+ array_sum( $overheadInternal ) / count( $overheadInternal ) : 0;
+
+ # Collate
+ foreach ( $this->stack as $index => $entry ) {
+ // $entry is (name,pos,rtime0,cputime0,mem0,rtime1,cputime1,mem1)
+ $fname = $entry[0];
+ $elapsedCpu = $entry[6] - $entry[3];
+ $elapsedReal = $entry[5] - $entry[2];
+ $memchange = $entry[7] - $entry[4];
+ $subcalls = $this->calltreeCount( $this->stack, $index );
+
+ if ( substr( $fname, 0, 9 ) !== '-overhead' ) {
+ # Adjust for profiling overhead (except special values with elapsed=0)
+ if ( $elapsed ) {
+ $elapsed -= $overheadInternal;
+ $elapsed -= ( $subcalls * $overheadTotal );
+ $memchange -= ( $subcalls * $overheadMemory );
+ }
+ }
+
+ $this->updateEntry( $fname, $elapsedCpu, $elapsedReal, $memchange );
+ }
+
+ $this->collated['-overhead-total']['count'] = $profileCount;
+ arsort( $this->collated, SORT_NUMERIC );
+
+ // Unclobber the end info map (the overhead checking alters it)
+ $this->end = $oldEnd;
+ }
+
+ /**
+ * Dummy calls to calculate profiling overhead
+ *
+ * @param int $profileCount
+ */
+ protected function calculateOverhead( $profileCount ) {
+ $this->profileInInternal( '-overhead-total' );
+ for ( $i = 0; $i < $profileCount; $i++ ) {
+ $this->profileInInternal( '-overhead-internal' );
+ $this->profileOutInternal( '-overhead-internal' );
+ }
+ $this->profileOutInternal( '-overhead-total' );
+ }
+
+ /**
+ * Counts the number of profiled function calls sitting under
+ * the given point in the call graph. Not the most efficient algo.
+ *
+ * @param array $stack
+ * @param int $start
+ * @return int
+ */
+ protected function calltreeCount( $stack, $start ) {
+ $level = $stack[$start][1];
+ $count = 0;
+ for ( $i = $start - 1; $i >= 0 && $stack[$i][1] > $level; $i-- ) {
+ $count ++;
+ }
+ return $count;
+ }
+
+ /**
+ * Get the initial time of the request, based on getrusage()
+ *
+ * @param string|bool $metric Metric to use, with the following possibilities:
+ * - user: User CPU time (without system calls)
+ * - cpu: Total CPU time (user and system calls)
+ * - wall (or any other string): elapsed time
+ * - false (default): will fall back to default metric
+ * @return float
+ */
+ protected function getTime( $metric = 'wall' ) {
+ if ( $metric === 'cpu' || $metric === 'user' ) {
+ $ru = wfGetRusage();
+ if ( !$ru ) {
+ return 0;
+ }
+ $time = $ru['ru_utime.tv_sec'] + $ru['ru_utime.tv_usec'] / 1e6;
+ if ( $metric === 'cpu' ) {
+ # This is the time of system calls, added to the user time
+ # it gives the total CPU time
+ $time += $ru['ru_stime.tv_sec'] + $ru['ru_stime.tv_usec'] / 1e6;
+ }
+ return $time;
+ } else {
+ return microtime( true );
+ }
+ }
+
+ /**
+ * Add an entry in the debug log file
+ *
+ * @param string $s String to output
+ */
+ protected function debug( $s ) {
+ if ( function_exists( 'wfDebug' ) ) {
+ wfDebug( $s );
+ }
+ }
+
+ /**
+ * Add an entry in the debug log group
+ *
+ * @param string $group Group to send the message to
+ * @param string $s String to output
+ */
+ protected function debugGroup( $group, $s ) {
+ if ( function_exists( 'wfDebugLog' ) ) {
+ wfDebugLog( $group, $s );
+ }
+ }
+}
+
+/**
+ * Subclass ScopedCallback to avoid call_user_func_array(), which is slow
+ *
+ * This class should not be used outside of SectionProfiler
+ */
+class SectionProfileCallback extends ScopedCallback {
+ /** @var SectionProfiler */
+ protected $profiler;
+ /** @var string */
+ protected $section;
+
+ /**
+ * @param SectionProfiler $profiler
+ * @param string $section
+ */
+ public function __construct( SectionProfiler $profiler, $section ) {
+ parent::__construct( null );
+ $this->profiler = $profiler;
+ $this->section = $section;
+ }
+
+ function __destruct() {
+ $this->profiler->profileOutInternal( $this->section );
+ }
+}
diff --git a/www/wiki/includes/profiler/TransactionProfiler.php b/www/wiki/includes/profiler/TransactionProfiler.php
new file mode 100644
index 00000000..1aba71c3
--- /dev/null
+++ b/www/wiki/includes/profiler/TransactionProfiler.php
@@ -0,0 +1,314 @@
+<?php
+/**
+ * Transaction profiling for contention
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ * @author Aaron Schulz
+ */
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\NullLogger;
+
+/**
+ * Helper class that detects high-contention DB queries via profiling calls
+ *
+ * This class is meant to work with a DatabaseBase object, which manages queries
+ *
+ * @since 1.24
+ */
+class TransactionProfiler implements LoggerAwareInterface {
+ /** @var float Seconds */
+ protected $dbLockThreshold = 3.0;
+ /** @var float Seconds */
+ protected $eventThreshold = .25;
+
+ /** @var array transaction ID => (write start time, list of DBs involved) */
+ protected $dbTrxHoldingLocks = [];
+ /** @var array transaction ID => list of (query name, start time, end time) */
+ protected $dbTrxMethodTimes = [];
+
+ /** @var array */
+ protected $hits = [
+ 'writes' => 0,
+ 'queries' => 0,
+ 'conns' => 0,
+ 'masterConns' => 0
+ ];
+ /** @var array */
+ protected $expect = [
+ 'writes' => INF,
+ 'queries' => INF,
+ 'conns' => INF,
+ 'masterConns' => INF,
+ 'maxAffected' => INF,
+ 'readQueryTime' => INF,
+ 'writeQueryTime' => INF
+ ];
+ /** @var array */
+ protected $expectBy = [];
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ public function __construct() {
+ $this->setLogger( new NullLogger() );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Set performance expectations
+ *
+ * With conflicting expectations, the most narrow ones will be used
+ *
+ * @param string $event (writes,queries,conns,mConns)
+ * @param integer $value Maximum count of the event
+ * @param string $fname Caller
+ * @since 1.25
+ */
+ public function setExpectation( $event, $value, $fname ) {
+ $this->expect[$event] = isset( $this->expect[$event] )
+ ? min( $this->expect[$event], $value )
+ : $value;
+ if ( $this->expect[$event] == $value ) {
+ $this->expectBy[$event] = $fname;
+ }
+ }
+
+ /**
+ * Set multiple performance expectations
+ *
+ * With conflicting expectations, the most narrow ones will be used
+ *
+ * @param array $expects Map of (event => limit)
+ * @param $fname
+ * @since 1.26
+ */
+ public function setExpectations( array $expects, $fname ) {
+ foreach ( $expects as $event => $value ) {
+ $this->setExpectation( $event, $value, $fname );
+ }
+ }
+
+ /**
+ * Reset performance expectations and hit counters
+ *
+ * @since 1.25
+ */
+ public function resetExpectations() {
+ foreach ( $this->hits as &$val ) {
+ $val = 0;
+ }
+ unset( $val );
+ foreach ( $this->expect as &$val ) {
+ $val = INF;
+ }
+ unset( $val );
+ $this->expectBy = [];
+ }
+
+ /**
+ * Mark a DB as having been connected to with a new handle
+ *
+ * Note that there can be multiple connections to a single DB.
+ *
+ * @param string $server DB server
+ * @param string $db DB name
+ * @param bool $isMaster
+ */
+ public function recordConnection( $server, $db, $isMaster ) {
+ // Report when too many connections happen...
+ if ( $this->hits['conns']++ == $this->expect['conns'] ) {
+ $this->reportExpectationViolated( 'conns', "[connect to $server ($db)]" );
+ }
+ if ( $isMaster && $this->hits['masterConns']++ == $this->expect['masterConns'] ) {
+ $this->reportExpectationViolated( 'masterConns', "[connect to $server ($db)]" );
+ }
+ }
+
+ /**
+ * Mark a DB as in a transaction with one or more writes pending
+ *
+ * Note that there can be multiple connections to a single DB.
+ *
+ * @param string $server DB server
+ * @param string $db DB name
+ * @param string $id ID string of transaction
+ */
+ public function transactionWritingIn( $server, $db, $id ) {
+ $name = "{$server} ({$db}) (TRX#$id)";
+ if ( isset( $this->dbTrxHoldingLocks[$name] ) ) {
+ $this->logger->info( "Nested transaction for '$name' - out of sync." );
+ }
+ $this->dbTrxHoldingLocks[$name] = [
+ 'start' => microtime( true ),
+ 'conns' => [], // all connections involved
+ ];
+ $this->dbTrxMethodTimes[$name] = [];
+
+ foreach ( $this->dbTrxHoldingLocks as $name => &$info ) {
+ // Track all DBs in transactions for this transaction
+ $info['conns'][$name] = 1;
+ }
+ }
+
+ /**
+ * Register the name and time of a method for slow DB trx detection
+ *
+ * This assumes that all queries are synchronous (non-overlapping)
+ *
+ * @param string $query Function name or generalized SQL
+ * @param float $sTime Starting UNIX wall time
+ * @param bool $isWrite Whether this is a write query
+ * @param integer $n Number of affected rows
+ */
+ public function recordQueryCompletion( $query, $sTime, $isWrite = false, $n = 0 ) {
+ $eTime = microtime( true );
+ $elapsed = ( $eTime - $sTime );
+
+ if ( $isWrite && $n > $this->expect['maxAffected'] ) {
+ $this->logger->info( "Query affected $n row(s):\n" . $query . "\n" .
+ wfBacktrace( true ) );
+ }
+
+ // Report when too many writes/queries happen...
+ if ( $this->hits['queries']++ == $this->expect['queries'] ) {
+ $this->reportExpectationViolated( 'queries', $query );
+ }
+ if ( $isWrite && $this->hits['writes']++ == $this->expect['writes'] ) {
+ $this->reportExpectationViolated( 'writes', $query );
+ }
+ // Report slow queries...
+ if ( !$isWrite && $elapsed > $this->expect['readQueryTime'] ) {
+ $this->reportExpectationViolated( 'readQueryTime', $query, $elapsed );
+ }
+ if ( $isWrite && $elapsed > $this->expect['writeQueryTime'] ) {
+ $this->reportExpectationViolated( 'writeQueryTime', $query, $elapsed );
+ }
+
+ if ( !$this->dbTrxHoldingLocks ) {
+ // Short-circuit
+ return;
+ } elseif ( !$isWrite && $elapsed < $this->eventThreshold ) {
+ // Not an important query nor slow enough
+ return;
+ }
+
+ foreach ( $this->dbTrxHoldingLocks as $name => $info ) {
+ $lastQuery = end( $this->dbTrxMethodTimes[$name] );
+ if ( $lastQuery ) {
+ // Additional query in the trx...
+ $lastEnd = $lastQuery[2];
+ if ( $sTime >= $lastEnd ) { // sanity check
+ if ( ( $sTime - $lastEnd ) > $this->eventThreshold ) {
+ // Add an entry representing the time spent doing non-queries
+ $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $sTime ];
+ }
+ $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
+ }
+ } else {
+ // First query in the trx...
+ if ( $sTime >= $info['start'] ) { // sanity check
+ $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
+ }
+ }
+ }
+ }
+
+ /**
+ * Mark a DB as no longer in a transaction
+ *
+ * This will check if locks are possibly held for longer than
+ * needed and log any affected transactions to a special DB log.
+ * Note that there can be multiple connections to a single DB.
+ *
+ * @param string $server DB server
+ * @param string $db DB name
+ * @param string $id ID string of transaction
+ * @param float $writeTime Time spent in write queries
+ */
+ public function transactionWritingOut( $server, $db, $id, $writeTime = 0.0 ) {
+ $name = "{$server} ({$db}) (TRX#$id)";
+ if ( !isset( $this->dbTrxMethodTimes[$name] ) ) {
+ $this->logger->info( "Detected no transaction for '$name' - out of sync." );
+ return;
+ }
+
+ $slow = false;
+
+ // Warn if too much time was spend writing...
+ if ( $writeTime > $this->expect['writeQueryTime'] ) {
+ $this->reportExpectationViolated(
+ 'writeQueryTime',
+ "[transaction $id writes to {$server} ({$db})]",
+ $writeTime
+ );
+ $slow = true;
+ }
+ // Fill in the last non-query period...
+ $lastQuery = end( $this->dbTrxMethodTimes[$name] );
+ if ( $lastQuery ) {
+ $now = microtime( true );
+ $lastEnd = $lastQuery[2];
+ if ( ( $now - $lastEnd ) > $this->eventThreshold ) {
+ $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $now ];
+ }
+ }
+ // Check for any slow queries or non-query periods...
+ foreach ( $this->dbTrxMethodTimes[$name] as $info ) {
+ $elapsed = ( $info[2] - $info[1] );
+ if ( $elapsed >= $this->dbLockThreshold ) {
+ $slow = true;
+ break;
+ }
+ }
+ if ( $slow ) {
+ $dbs = implode( ', ', array_keys( $this->dbTrxHoldingLocks[$name]['conns'] ) );
+ $msg = "Sub-optimal transaction on DB(s) [{$dbs}]:\n";
+ foreach ( $this->dbTrxMethodTimes[$name] as $i => $info ) {
+ list( $query, $sTime, $end ) = $info;
+ $msg .= sprintf( "%d\t%.6f\t%s\n", $i, ( $end - $sTime ), $query );
+ }
+ $this->logger->info( $msg );
+ }
+ unset( $this->dbTrxHoldingLocks[$name] );
+ unset( $this->dbTrxMethodTimes[$name] );
+ }
+
+ /**
+ * @param string $expect
+ * @param string $query
+ * @param string|float|int $actual [optional]
+ */
+ protected function reportExpectationViolated( $expect, $query, $actual = null ) {
+ $n = $this->expect[$expect];
+ $by = $this->expectBy[$expect];
+ $actual = ( $actual !== null ) ? " (actual: $actual)" : "";
+
+ $this->logger->info(
+ "Expectation ($expect <= $n) by $by not met$actual:\n$query\n" .
+ wfBacktrace( true )
+ );
+ }
+}
diff --git a/www/wiki/includes/profiler/output/ProfilerOutput.php b/www/wiki/includes/profiler/output/ProfilerOutput.php
new file mode 100644
index 00000000..20b07801
--- /dev/null
+++ b/www/wiki/includes/profiler/output/ProfilerOutput.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+
+/**
+ * Base class for profiling output
+ *
+ * Since 1.25
+ */
+abstract class ProfilerOutput {
+ /** @var Profiler */
+ protected $collector;
+ /** @var array Configuration of $wgProfiler */
+ protected $params = [];
+
+ /**
+ * @param Profiler $collector The actual profiler
+ * @param array $params Configuration array, passed down from $wgProfiler
+ */
+ public function __construct( Profiler $collector, array $params ) {
+ $this->collector = $collector;
+ $this->params = $params;
+ }
+
+ /**
+ * Can this output type be used?
+ * @return bool
+ */
+ public function canUse() {
+ return true;
+ }
+
+ /**
+ * Log MediaWiki-style profiling data
+ *
+ * @param array $stats Result of Profiler::getFunctionStats()
+ */
+ abstract public function log( array $stats );
+}
diff --git a/www/wiki/includes/profiler/output/ProfilerOutputDb.php b/www/wiki/includes/profiler/output/ProfilerOutputDb.php
new file mode 100644
index 00000000..2225e3f6
--- /dev/null
+++ b/www/wiki/includes/profiler/output/ProfilerOutputDb.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Profiler storing information in the DB.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * Logs profiling data into the local DB
+ *
+ * @ingroup Profiler
+ * @since 1.25
+ */
+class ProfilerOutputDb extends ProfilerOutput {
+ /** @var bool Whether to store host data with profiling calls */
+ private $perHost = false;
+
+ public function __construct( Profiler $collector, array $params ) {
+ parent::__construct( $collector, $params );
+
+ // Initialize per-host profiling from config, back-compat if available
+ if ( isset( $this->params['perHost'] ) ) {
+ $this->perHost = $this->params['perHost'];
+ }
+ }
+
+ public function canUse() {
+ # Do not log anything if database is readonly (T7375)
+ return !wfReadOnly();
+ }
+
+ public function log( array $stats ) {
+ $pfhost = $this->perHost ? wfHostname() : '';
+
+ try {
+ $dbw = wfGetDB( DB_MASTER );
+ $useTrx = ( $dbw->getType() === 'sqlite' ); // much faster
+ if ( $useTrx ) {
+ $dbw->startAtomic( __METHOD__ );
+ }
+ foreach ( $stats as $data ) {
+ $name = $data['name'];
+ $eventCount = $data['calls'];
+ $timeSum = (float)$data['real'];
+ $memorySum = (float)$data['memory'];
+ $name = substr( $name, 0, 255 );
+
+ // Kludge
+ $timeSum = $timeSum >= 0 ? $timeSum : 0;
+ $memorySum = $memorySum >= 0 ? $memorySum : 0;
+
+ $dbw->upsert( 'profiling',
+ [
+ 'pf_name' => $name,
+ 'pf_count' => $eventCount,
+ 'pf_time' => $timeSum,
+ 'pf_memory' => $memorySum,
+ 'pf_server' => $pfhost
+ ],
+ [ [ 'pf_name', 'pf_server' ] ],
+ [
+ "pf_count=pf_count+{$eventCount}",
+ "pf_time=pf_time+{$timeSum}",
+ "pf_memory=pf_memory+{$memorySum}",
+ ],
+ __METHOD__
+ );
+ }
+ if ( $useTrx ) {
+ $dbw->endAtomic( __METHOD__ );
+ }
+ } catch ( DBError $e ) {
+ }
+ }
+}
diff --git a/www/wiki/includes/profiler/output/ProfilerOutputDump.php b/www/wiki/includes/profiler/output/ProfilerOutputDump.php
new file mode 100644
index 00000000..09f56887
--- /dev/null
+++ b/www/wiki/includes/profiler/output/ProfilerOutputDump.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * Profiler dumping output in xhprof dump file
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+
+/**
+ * Profiler dumping output in xhprof dump file
+ * @ingroup Profiler
+ *
+ * @since 1.25
+ */
+class ProfilerOutputDump extends ProfilerOutput {
+
+ protected $suffix = ".xhprof";
+
+ /**
+ * Can this output type be used?
+ *
+ * @return bool
+ */
+ public function canUse() {
+ if ( empty( $this->params['outputDir'] ) ) {
+ return false;
+ }
+ return true;
+ }
+
+ public function log( array $stats ) {
+ $data = $this->collector->getRawData();
+ $filename = sprintf( "%s/%s.%s%s",
+ $this->params['outputDir'],
+ uniqid(),
+ $this->collector->getProfileID(),
+ $this->suffix );
+ file_put_contents( $filename, serialize( $data ) );
+ }
+}
diff --git a/www/wiki/includes/profiler/output/ProfilerOutputStats.php b/www/wiki/includes/profiler/output/ProfilerOutputStats.php
new file mode 100644
index 00000000..bb865518
--- /dev/null
+++ b/www/wiki/includes/profiler/output/ProfilerOutputStats.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * ProfilerOutput class that flushes profiling data to the profiling
+ * context's stats buffer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * ProfilerOutput class that flushes profiling data to the profiling
+ * context's stats buffer.
+ *
+ * @ingroup Profiler
+ * @since 1.25
+ */
+class ProfilerOutputStats extends ProfilerOutput {
+
+ /**
+ * Flush profiling data to the current profiling context's stats buffer.
+ *
+ * @param array $stats
+ */
+ public function log( array $stats ) {
+ $prefix = isset( $this->params['prefix'] ) ? $this->params['prefix'] : '';
+ $contextStats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+
+ foreach ( $stats as $stat ) {
+ $key = "{$prefix}.{$stat['name']}";
+
+ // Convert fractional seconds to whole milliseconds
+ $cpu = round( $stat['cpu'] * 1000 );
+ $real = round( $stat['real'] * 1000 );
+
+ $contextStats->increment( "{$key}.calls" );
+ $contextStats->timing( "{$key}.cpu", $cpu );
+ $contextStats->timing( "{$key}.real", $real );
+ }
+ }
+}
diff --git a/www/wiki/includes/profiler/output/ProfilerOutputText.php b/www/wiki/includes/profiler/output/ProfilerOutputText.php
new file mode 100644
index 00000000..dc24f181
--- /dev/null
+++ b/www/wiki/includes/profiler/output/ProfilerOutputText.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * Profiler showing output in page source.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Profiler
+ */
+
+/**
+ * The least sophisticated profiler output class possible, view your source! :)
+ *
+ * @ingroup Profiler
+ * @since 1.25
+ */
+class ProfilerOutputText extends ProfilerOutput {
+ /** @var float Min real time display threshold */
+ protected $thresholdMs;
+
+ function __construct( Profiler $collector, array $params ) {
+ parent::__construct( $collector, $params );
+ $this->thresholdMs = isset( $params['thresholdMs'] )
+ ? $params['thresholdMs']
+ : 1.0;
+ }
+ public function log( array $stats ) {
+ if ( $this->collector->getTemplated() ) {
+ $out = '';
+
+ // Filter out really tiny entries
+ $min = $this->thresholdMs;
+ $stats = array_filter( $stats, function ( $a ) use ( $min ) {
+ return $a['real'] > $min;
+ } );
+ // Sort descending by time elapsed
+ usort( $stats, function ( $a, $b ) {
+ return $a['real'] < $b['real'];
+ } );
+
+ array_walk( $stats,
+ function ( $item ) use ( &$out ) {
+ $out .= sprintf( "%6.2f%% %3.3f %6d - %s\n",
+ $item['%real'], $item['real'], $item['calls'], $item['name'] );
+ }
+ );
+
+ $contentType = $this->collector->getContentType();
+ if ( PHP_SAPI === 'cli' ) {
+ print "<!--\n{$out}\n-->\n";
+ } elseif ( $contentType === 'text/html' ) {
+ $visible = isset( $this->params['visible'] ) ?
+ $this->params['visible'] : false;
+ if ( $visible ) {
+ print "<pre>{$out}</pre>";
+ } else {
+ print "<!--\n{$out}\n-->\n";
+ }
+ } elseif ( $contentType === 'text/javascript' || $contentType === 'text/css' ) {
+ print "\n/*\n{$out}*/\n";
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/rcfeed/FormattedRCFeed.php b/www/wiki/includes/rcfeed/FormattedRCFeed.php
new file mode 100644
index 00000000..afe900d0
--- /dev/null
+++ b/www/wiki/includes/rcfeed/FormattedRCFeed.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Base class for RC feed engines that send messages in a freely configurable
+ * format to a uri-addressed engine set in $wgRCEngines.
+ * @since 1.29
+ */
+abstract class FormattedRCFeed extends RCFeed {
+ private $params;
+
+ /**
+ * @param array $params
+ * - 'uri'
+ * - 'formatter'
+ * @see $wgRCFeeds
+ */
+ public function __construct( array $params = [] ) {
+ $this->params = $params;
+ }
+
+ /**
+ * Send some text to the specified feed.
+ *
+ * @param array $feed The feed, as configured in an associative array
+ * @param string $line The text to send
+ * @return bool Success
+ */
+ abstract public function send( array $feed, $line );
+
+ /**
+ * @param RecentChange $rc
+ * @param string|null $actionComment
+ * @return bool Success
+ */
+ public function notify( RecentChange $rc, $actionComment = null ) {
+ $params = $this->params;
+ /** @var RCFeedFormatter $formatter */
+ $formatter = is_object( $params['formatter'] ) ? $params['formatter'] : new $params['formatter'];
+
+ $line = $formatter->getLine( $params, $rc, $actionComment );
+ if ( !$line ) {
+ // @codeCoverageIgnoreStart
+ // T109544 - If a feed formatter returns null, this will otherwise cause an
+ // error in at least RedisPubSubFeedEngine. Not sure best to handle this.
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+ return $this->send( $params, $line );
+ }
+}
diff --git a/www/wiki/includes/rcfeed/IRCColourfulRCFeedFormatter.php b/www/wiki/includes/rcfeed/IRCColourfulRCFeedFormatter.php
new file mode 100644
index 00000000..531a3eb2
--- /dev/null
+++ b/www/wiki/includes/rcfeed/IRCColourfulRCFeedFormatter.php
@@ -0,0 +1,143 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Generates a colourful notification intended for humans on IRC.
+ *
+ * @since 1.22
+ */
+
+class IRCColourfulRCFeedFormatter implements RCFeedFormatter {
+ /**
+ * @see RCFeedFormatter::getLine
+ * @param array $feed
+ * @param RecentChange $rc
+ * @param string|null $actionComment
+ * @return string|null
+ */
+ public function getLine( array $feed, RecentChange $rc, $actionComment ) {
+ global $wgUseRCPatrol, $wgUseNPPatrol, $wgLocalInterwikis,
+ $wgCanonicalServer, $wgScript;
+ $attribs = $rc->getAttributes();
+ if ( $attribs['rc_type'] == RC_CATEGORIZE ) {
+ // Don't send RC_CATEGORIZE events to IRC feed (T127360)
+ return null;
+ }
+
+ if ( $attribs['rc_type'] == RC_LOG ) {
+ // Don't use SpecialPage::getTitleFor, backwards compatibility with
+ // IRC API which expects "Log".
+ $titleObj = Title::newFromText( 'Log/' . $attribs['rc_log_type'], NS_SPECIAL );
+ } else {
+ $titleObj =& $rc->getTitle();
+ }
+ $title = $titleObj->getPrefixedText();
+ $title = self::cleanupForIRC( $title );
+
+ if ( $attribs['rc_type'] == RC_LOG ) {
+ $url = '';
+ } else {
+ $url = $wgCanonicalServer . $wgScript;
+ if ( $attribs['rc_type'] == RC_NEW ) {
+ $query = '?oldid=' . $attribs['rc_this_oldid'];
+ } else {
+ $query = '?diff=' . $attribs['rc_this_oldid'] . '&oldid=' . $attribs['rc_last_oldid'];
+ }
+ if ( $wgUseRCPatrol || ( $attribs['rc_type'] == RC_NEW && $wgUseNPPatrol ) ) {
+ $query .= '&rcid=' . $attribs['rc_id'];
+ }
+ // HACK: We need this hook for WMF's secure server setup
+ Hooks::run( 'IRCLineURL', [ &$url, &$query, $rc ] );
+ $url .= $query;
+ }
+
+ if ( $attribs['rc_old_len'] !== null && $attribs['rc_new_len'] !== null ) {
+ $szdiff = $attribs['rc_new_len'] - $attribs['rc_old_len'];
+ if ( $szdiff < -500 ) {
+ $szdiff = "\002$szdiff\002";
+ } elseif ( $szdiff >= 0 ) {
+ $szdiff = '+' . $szdiff;
+ }
+ // @todo i18n with parentheses in content language?
+ $szdiff = '(' . $szdiff . ')';
+ } else {
+ $szdiff = '';
+ }
+
+ $user = self::cleanupForIRC( $attribs['rc_user_text'] );
+
+ if ( $attribs['rc_type'] == RC_LOG ) {
+ $targetText = $rc->getTitle()->getPrefixedText();
+ $comment = self::cleanupForIRC( str_replace(
+ "[[$targetText]]",
+ "[[\00302$targetText\00310]]",
+ $actionComment
+ ) );
+ $flag = $attribs['rc_log_action'];
+ } else {
+ $comment = self::cleanupForIRC(
+ CommentStore::newKey( 'rc_comment' )->getComment( $attribs )->text
+ );
+ $flag = '';
+ if ( !$attribs['rc_patrolled']
+ && ( $wgUseRCPatrol || $attribs['rc_type'] == RC_NEW && $wgUseNPPatrol )
+ ) {
+ $flag .= '!';
+ }
+ $flag .= ( $attribs['rc_type'] == RC_NEW ? "N" : "" )
+ . ( $attribs['rc_minor'] ? "M" : "" ) . ( $attribs['rc_bot'] ? "B" : "" );
+ }
+
+ if ( $feed['add_interwiki_prefix'] === true && $wgLocalInterwikis ) {
+ // we use the first entry in $wgLocalInterwikis in recent changes feeds
+ $prefix = $wgLocalInterwikis[0];
+ } elseif ( $feed['add_interwiki_prefix'] ) {
+ $prefix = $feed['add_interwiki_prefix'];
+ } else {
+ $prefix = false;
+ }
+ if ( $prefix !== false ) {
+ $titleString = "\00314[[\00303$prefix:\00307$title\00314]]";
+ } else {
+ $titleString = "\00314[[\00307$title\00314]]";
+ }
+
+ # see http://www.irssi.org/documentation/formats for some colour codes. prefix is \003,
+ # no colour (\003) switches back to the term default
+ $fullString = "$titleString\0034 $flag\00310 " .
+ "\00302$url\003 \0035*\003 \00303$user\003 \0035*\003 $szdiff \00310$comment\003\n";
+
+ return $fullString;
+ }
+
+ /**
+ * Remove newlines, carriage returns and decode html entites
+ * @param string $text
+ * @return string
+ */
+ public static function cleanupForIRC( $text ) {
+ return str_replace(
+ [ "\n", "\r" ],
+ [ " ", "" ],
+ Sanitizer::decodeCharReferences( $text )
+ );
+ }
+}
diff --git a/www/wiki/includes/rcfeed/JSONRCFeedFormatter.php b/www/wiki/includes/rcfeed/JSONRCFeedFormatter.php
new file mode 100644
index 00000000..98d3f025
--- /dev/null
+++ b/www/wiki/includes/rcfeed/JSONRCFeedFormatter.php
@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Formats a notification into the JSON format (http://www.json.org)
+ *
+ * @since 1.22
+ */
+class JSONRCFeedFormatter extends MachineReadableRCFeedFormatter {
+
+ protected function formatArray( array $packet ) {
+ return FormatJson::encode( $packet );
+ }
+}
diff --git a/www/wiki/includes/rcfeed/MachineReadableRCFeedFormatter.php b/www/wiki/includes/rcfeed/MachineReadableRCFeedFormatter.php
new file mode 100644
index 00000000..a90d648e
--- /dev/null
+++ b/www/wiki/includes/rcfeed/MachineReadableRCFeedFormatter.php
@@ -0,0 +1,132 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Abstract class so there can be multiple formatters outputting the same data
+ *
+ * @since 1.23
+ */
+abstract class MachineReadableRCFeedFormatter implements RCFeedFormatter {
+
+ /**
+ * Take the packet and return the formatted string
+ * @param array $packet
+ * @return string
+ */
+ abstract protected function formatArray( array $packet );
+
+ /**
+ * Generates a notification that can be easily interpreted by a machine.
+ * @see RCFeedFormatter::getLine
+ * @param array $feed
+ * @param RecentChange $rc
+ * @param string|null $actionComment
+ * @return string|null
+ */
+ public function getLine( array $feed, RecentChange $rc, $actionComment ) {
+ global $wgCanonicalServer, $wgServerName, $wgScriptPath;
+
+ $packet = [
+ // Usually, RC ID is exposed only for patrolling purposes,
+ // but there is no real reason not to expose it in other cases,
+ // and I can see how this may be potentially useful for clients.
+ 'id' => $rc->getAttribute( 'rc_id' ),
+ 'type' => RecentChange::parseFromRCType( $rc->getAttribute( 'rc_type' ) ),
+ 'namespace' => $rc->getTitle()->getNamespace(),
+ 'title' => $rc->getTitle()->getPrefixedText(),
+ 'comment' => $rc->getAttribute( 'rc_comment' ),
+ 'timestamp' => (int)wfTimestamp( TS_UNIX, $rc->getAttribute( 'rc_timestamp' ) ),
+ 'user' => $rc->getAttribute( 'rc_user_text' ),
+ 'bot' => (bool)$rc->getAttribute( 'rc_bot' ),
+ ];
+
+ if ( isset( $feed['channel'] ) ) {
+ $packet['channel'] = $feed['channel'];
+ }
+
+ $type = $rc->getAttribute( 'rc_type' );
+ if ( $type == RC_EDIT || $type == RC_NEW ) {
+ global $wgUseRCPatrol, $wgUseNPPatrol;
+
+ $packet['minor'] = (bool)$rc->getAttribute( 'rc_minor' );
+ if ( $wgUseRCPatrol || ( $type == RC_NEW && $wgUseNPPatrol ) ) {
+ $packet['patrolled'] = (bool)$rc->getAttribute( 'rc_patrolled' );
+ }
+ }
+
+ switch ( $type ) {
+ case RC_EDIT:
+ $packet['length'] = [
+ 'old' => $rc->getAttribute( 'rc_old_len' ),
+ 'new' => $rc->getAttribute( 'rc_new_len' )
+ ];
+ $packet['revision'] = [
+ 'old' => $rc->getAttribute( 'rc_last_oldid' ),
+ 'new' => $rc->getAttribute( 'rc_this_oldid' )
+ ];
+ break;
+
+ case RC_NEW:
+ $packet['length'] = [ 'old' => null, 'new' => $rc->getAttribute( 'rc_new_len' ) ];
+ $packet['revision'] = [ 'old' => null, 'new' => $rc->getAttribute( 'rc_this_oldid' ) ];
+ break;
+
+ case RC_LOG:
+ $packet['log_id'] = $rc->getAttribute( 'rc_logid' );
+ $packet['log_type'] = $rc->getAttribute( 'rc_log_type' );
+ $packet['log_action'] = $rc->getAttribute( 'rc_log_action' );
+ if ( $rc->getAttribute( 'rc_params' ) ) {
+ $params = $rc->parseParams();
+ if (
+ // If it's an actual serialised false...
+ $rc->getAttribute( 'rc_params' ) == serialize( false ) ||
+ // Or if we did not get false back when trying to unserialise
+ $params !== false
+ ) {
+ // From ApiQueryLogEvents::addLogParams
+ $logParams = [];
+ // Keys like "4::paramname" can't be used for output so we change them to "paramname"
+ foreach ( $params as $key => $value ) {
+ if ( strpos( $key, ':' ) === false ) {
+ $logParams[$key] = $value;
+ continue;
+ }
+ $logParam = explode( ':', $key, 3 );
+ $logParams[$logParam[2]] = $value;
+ }
+ $packet['log_params'] = $logParams;
+ } else {
+ $packet['log_params'] = explode( "\n", $rc->getAttribute( 'rc_params' ) );
+ }
+ }
+ $packet['log_action_comment'] = $actionComment;
+ break;
+ }
+
+ $packet['server_url'] = $wgCanonicalServer;
+ $packet['server_name'] = $wgServerName;
+
+ $packet['server_script_path'] = $wgScriptPath ?: '/';
+ $packet['wiki'] = wfWikiID();
+
+ return $this->formatArray( $packet );
+ }
+}
diff --git a/www/wiki/includes/rcfeed/RCFeed.php b/www/wiki/includes/rcfeed/RCFeed.php
new file mode 100644
index 00000000..284f68a2
--- /dev/null
+++ b/www/wiki/includes/rcfeed/RCFeed.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @see $wgRCFeeds
+ * @since 1.29
+ */
+abstract class RCFeed {
+ /**
+ * @param array $params
+ */
+ public function __construct( array $params = [] ) {
+ }
+
+ /**
+ * Dispatch the recent changes notification.
+ *
+ * @param RecentChange $rc
+ * @param string|null $actionComment
+ * @return bool Success
+ */
+ abstract public function notify( RecentChange $rc, $actionComment = null );
+
+ /**
+ * @param array $params
+ * @return RCFeed
+ * @throws Exception
+ */
+ final public static function factory( array $params ) {
+ if ( !isset( $params['class'] ) ) {
+ if ( !isset( $params['uri'] ) ) {
+ throw new Exception( "RCFeeds must have a 'class' or 'uri' set." );
+ }
+ return RecentChange::getEngine( $params['uri'], $params );
+ }
+ $class = $params['class'];
+ if ( !class_exists( $class ) ) {
+ throw new Exception( "Unknown class '$class'." );
+ }
+ return new $class( $params );
+ }
+}
diff --git a/www/wiki/includes/rcfeed/RCFeedEngine.php b/www/wiki/includes/rcfeed/RCFeedEngine.php
new file mode 100644
index 00000000..49436fa1
--- /dev/null
+++ b/www/wiki/includes/rcfeed/RCFeedEngine.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Backward-compatibility alias.
+ * @since 1.22
+ * @deprecated since 1.29 Use FormattedRCFeed instead
+ */
+abstract class RCFeedEngine extends FormattedRCFeed {
+}
diff --git a/www/wiki/includes/rcfeed/RCFeedFormatter.php b/www/wiki/includes/rcfeed/RCFeedFormatter.php
new file mode 100644
index 00000000..f7e62ee6
--- /dev/null
+++ b/www/wiki/includes/rcfeed/RCFeedFormatter.php
@@ -0,0 +1,39 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Interface for RC feed formatters
+ *
+ * @since 1.22
+ */
+interface RCFeedFormatter {
+ /**
+ * Formats the line to be sent by an engine
+ *
+ * @param array $feed The feed, as configured in an associative array.
+ * @param RecentChange $rc The RecentChange object showing what sort
+ * of event has taken place.
+ * @param string|null $actionComment
+ * @return string|null The text to send. If the formatter returns null,
+ * the line will not be sent.
+ */
+ public function getLine( array $feed, RecentChange $rc, $actionComment );
+}
diff --git a/www/wiki/includes/rcfeed/RedisPubSubFeedEngine.php b/www/wiki/includes/rcfeed/RedisPubSubFeedEngine.php
new file mode 100644
index 00000000..8a3aa0c1
--- /dev/null
+++ b/www/wiki/includes/rcfeed/RedisPubSubFeedEngine.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Send recent change notifications via Redis Pub/Sub
+ *
+ * If the feed URI contains a path component, it will be used to generate a
+ * channel name by stripping the leading slash and replacing any remaining
+ * slashes with '.'. If no path component is present, the channel is set to
+ * 'rc'. If the URI contains a query string, its parameters will be parsed
+ * as RedisConnectionPool options.
+ *
+ * @par Example:
+ * @code
+ * $wgRCFeeds['redis'] = array(
+ * 'formatter' => 'JSONRCFeedFormatter',
+ * 'uri' => "redis://127.0.0.1:6379/rc.$wgDBname",
+ * );
+ * @endcode
+ *
+ * @since 1.22
+ */
+class RedisPubSubFeedEngine extends RCFeedEngine {
+
+ /**
+ * @see FormattedRCFeed::send
+ * @param array $feed
+ * @param string $line
+ * @return bool
+ */
+ public function send( array $feed, $line ) {
+ $parsed = wfParseUrl( $feed['uri'] );
+ $server = $parsed['host'];
+ $options = [ 'serializer' => 'none' ];
+ $channel = 'rc';
+
+ if ( isset( $parsed['port'] ) ) {
+ $server .= ":{$parsed['port']}";
+ }
+ if ( isset( $parsed['query'] ) ) {
+ parse_str( $parsed['query'], $options );
+ }
+ if ( isset( $parsed['pass'] ) ) {
+ $options['password'] = $parsed['pass'];
+ }
+ if ( isset( $parsed['path'] ) ) {
+ $channel = str_replace( '/', '.', ltrim( $parsed['path'], '/' ) );
+ }
+ $pool = RedisConnectionPool::singleton( $options );
+ $conn = $pool->getConnection( $server );
+ if ( $conn !== false ) {
+ $conn->publish( $channel, $line );
+ return true;
+ } else {
+ return false;
+ }
+ }
+}
diff --git a/www/wiki/includes/rcfeed/UDPRCFeedEngine.php b/www/wiki/includes/rcfeed/UDPRCFeedEngine.php
new file mode 100644
index 00000000..f76d771e
--- /dev/null
+++ b/www/wiki/includes/rcfeed/UDPRCFeedEngine.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Send recent change notifications in a UDP packet.
+ * @since 1.22
+ */
+class UDPRCFeedEngine extends RCFeedEngine {
+ /**
+ * @see RCFeedEngine::send
+ * @param array $feed
+ * @param string $line
+ * @return bool
+ */
+ public function send( array $feed, $line ) {
+ $transport = UDPTransport::newFromString( $feed['uri'] );
+ $transport->emit( $line );
+ }
+}
diff --git a/www/wiki/includes/rcfeed/XMLRCFeedFormatter.php b/www/wiki/includes/rcfeed/XMLRCFeedFormatter.php
new file mode 100644
index 00000000..d4df91a4
--- /dev/null
+++ b/www/wiki/includes/rcfeed/XMLRCFeedFormatter.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @since 1.23
+ */
+class XMLRCFeedFormatter extends MachineReadableRCFeedFormatter {
+
+ protected function formatArray( array $packet ) {
+ return ApiFormatXml::recXmlPrint( 'recentchange', $packet, 0 );
+ }
+}
diff --git a/www/wiki/includes/registration/CoreVersionChecker.php b/www/wiki/includes/registration/CoreVersionChecker.php
new file mode 100644
index 00000000..f64d826d
--- /dev/null
+++ b/www/wiki/includes/registration/CoreVersionChecker.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+use Composer\Semver\VersionParser;
+use Composer\Semver\Constraint\Constraint;
+
+/**
+ * @since 1.26
+ */
+class CoreVersionChecker {
+
+ /**
+ * @var Constraint|bool representing $wgVersion
+ */
+ private $coreVersion = false;
+
+ /**
+ * @var VersionParser
+ */
+ private $versionParser;
+
+ /**
+ * @param string $coreVersion Current version of core
+ */
+ public function __construct( $coreVersion ) {
+ $this->versionParser = new VersionParser();
+ try {
+ $this->coreVersion = new Constraint(
+ '==',
+ $this->versionParser->normalize( $coreVersion )
+ );
+ } catch ( UnexpectedValueException $e ) {
+ // Non-parsable version, don't fatal.
+ }
+ }
+
+ /**
+ * Check that the provided constraint is compatible with the current version of core
+ *
+ * @param string $constraint Something like ">= 1.26"
+ * @return bool
+ */
+ public function check( $constraint ) {
+ if ( $this->coreVersion === false ) {
+ // Couldn't parse the core version, so we can't check anything
+ return true;
+ }
+
+ return $this->versionParser->parseConstraints( $constraint )
+ ->matches( $this->coreVersion );
+ }
+}
diff --git a/www/wiki/includes/registration/ExtensionJsonValidationError.php b/www/wiki/includes/registration/ExtensionJsonValidationError.php
new file mode 100644
index 00000000..897d2840
--- /dev/null
+++ b/www/wiki/includes/registration/ExtensionJsonValidationError.php
@@ -0,0 +1,22 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+class ExtensionJsonValidationError extends Exception {
+}
diff --git a/www/wiki/includes/registration/ExtensionJsonValidator.php b/www/wiki/includes/registration/ExtensionJsonValidator.php
new file mode 100644
index 00000000..b860a172
--- /dev/null
+++ b/www/wiki/includes/registration/ExtensionJsonValidator.php
@@ -0,0 +1,130 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Composer\Spdx\SpdxLicenses;
+use JsonSchema\Validator;
+use Seld\JsonLint\JsonParser;
+use Seld\JsonLint\ParsingException;
+
+/**
+ * @since 1.29
+ */
+class ExtensionJsonValidator {
+
+ /**
+ * @var callable
+ */
+ private $missingDepCallback;
+
+ /**
+ * @param callable $missingDepCallback
+ */
+ public function __construct( callable $missingDepCallback ) {
+ $this->missingDepCallback = $missingDepCallback;
+ }
+
+ /**
+ * @return bool
+ */
+ public function checkDependencies() {
+ if ( !class_exists( Validator::class ) ) {
+ call_user_func( $this->missingDepCallback,
+ 'The JsonSchema library cannot be found, please install it through composer.'
+ );
+ return false;
+ } elseif ( !class_exists( SpdxLicenses::class ) ) {
+ call_user_func( $this->missingDepCallback,
+ 'The spdx-licenses library cannot be found, please install it through composer.'
+ );
+ return false;
+ } elseif ( !class_exists( JsonParser::class ) ) {
+ call_user_func( $this->missingDepCallback,
+ 'The JSON lint library cannot be found, please install it through composer.'
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * @param string $path file to validate
+ * @return bool true if passes validation
+ * @throws ExtensionJsonValidationError on any failure
+ */
+ public function validate( $path ) {
+ $contents = file_get_contents( $path );
+ $jsonParser = new JsonParser();
+ try {
+ $data = $jsonParser->parse( $contents, JsonParser::DETECT_KEY_CONFLICTS );
+ } catch ( ParsingException $e ) {
+ if ( $e instanceof \Seld\JsonLint\DuplicateKeyException ) {
+ throw new ExtensionJsonValidationError( $e->getMessage() );
+ }
+ throw new ExtensionJsonValidationError( "$path is not valid JSON" );
+ }
+
+ if ( !isset( $data->manifest_version ) ) {
+ throw new ExtensionJsonValidationError(
+ "$path does not have manifest_version set." );
+ }
+
+ $version = $data->manifest_version;
+ $schemaPath = __DIR__ . "/../../docs/extension.schema.v$version.json";
+
+ // Not too old
+ if ( $version < ExtensionRegistry::OLDEST_MANIFEST_VERSION ) {
+ throw new ExtensionJsonValidationError(
+ "$path is using a non-supported schema version"
+ );
+ } elseif ( $version > ExtensionRegistry::MANIFEST_VERSION ) {
+ throw new ExtensionJsonValidationError(
+ "$path is using a non-supported schema version"
+ );
+ }
+
+ $licenseError = false;
+ // Check if it's a string, if not, schema validation will display an error
+ if ( isset( $data->{'license-name'} ) && is_string( $data->{'license-name'} ) ) {
+ $licenses = new SpdxLicenses();
+ $valid = $licenses->validate( $data->{'license-name'} );
+ if ( !$valid ) {
+ $licenseError = '[license-name] Invalid SPDX license identifier, '
+ . 'see <https://spdx.org/licenses/>';
+ }
+ }
+
+ $validator = new Validator;
+ $validator->check( $data, (object)[ '$ref' => 'file://' . $schemaPath ] );
+ if ( $validator->isValid() && !$licenseError ) {
+ // All good.
+ return true;
+ } else {
+ $out = "$path did not pass validation.\n";
+ foreach ( $validator->getErrors() as $error ) {
+ $out .= "[{$error['property']}] {$error['message']}\n";
+ }
+ if ( $licenseError ) {
+ $out .= "$licenseError\n";
+ }
+ throw new ExtensionJsonValidationError( $out );
+ }
+ }
+}
diff --git a/www/wiki/includes/registration/ExtensionProcessor.php b/www/wiki/includes/registration/ExtensionProcessor.php
new file mode 100644
index 00000000..ce262bd2
--- /dev/null
+++ b/www/wiki/includes/registration/ExtensionProcessor.php
@@ -0,0 +1,530 @@
+<?php
+
+class ExtensionProcessor implements Processor {
+
+ /**
+ * Keys that should be set to $GLOBALS
+ *
+ * @var array
+ */
+ protected static $globalSettings = [
+ 'ActionFilteredLogs',
+ 'Actions',
+ 'AddGroups',
+ 'APIFormatModules',
+ 'APIListModules',
+ 'APIMetaModules',
+ 'APIModules',
+ 'APIPropModules',
+ 'AuthManagerAutoConfig',
+ 'AvailableRights',
+ 'CentralIdLookupProviders',
+ 'ChangeCredentialsBlacklist',
+ 'ConfigRegistry',
+ 'ContentHandlers',
+ 'DefaultUserOptions',
+ 'ExtensionEntryPointListFiles',
+ 'ExtensionFunctions',
+ 'FeedClasses',
+ 'FileExtensions',
+ 'FilterLogTypes',
+ 'GrantPermissionGroups',
+ 'GrantPermissions',
+ 'GroupPermissions',
+ 'GroupsAddToSelf',
+ 'GroupsRemoveFromSelf',
+ 'HiddenPrefs',
+ 'ImplicitGroups',
+ 'JobClasses',
+ 'LogActions',
+ 'LogActionsHandlers',
+ 'LogHeaders',
+ 'LogNames',
+ 'LogRestrictions',
+ 'LogTypes',
+ 'MediaHandlers',
+ 'PasswordPolicy',
+ 'RateLimits',
+ 'RecentChangesFlags',
+ 'RemoveCredentialsBlacklist',
+ 'RemoveGroups',
+ 'ResourceLoaderLESSVars',
+ 'ResourceLoaderSources',
+ 'RevokePermissions',
+ 'SessionProviders',
+ 'SpecialPages',
+ 'ValidSkinNames',
+ ];
+
+ /**
+ * Top-level attributes that come from MW core
+ *
+ * @var string[]
+ */
+ protected static $coreAttributes = [
+ 'SkinOOUIThemes',
+ 'TrackingCategories',
+ ];
+
+ /**
+ * Mapping of global settings to their specific merge strategies.
+ *
+ * @see ExtensionRegistry::exportExtractedData
+ * @see getExtractedInfo
+ * @var array
+ */
+ protected static $mergeStrategies = [
+ 'wgAuthManagerAutoConfig' => 'array_plus_2d',
+ 'wgCapitalLinkOverrides' => 'array_plus',
+ 'wgExtensionCredits' => 'array_merge_recursive',
+ 'wgExtraGenderNamespaces' => 'array_plus',
+ 'wgGrantPermissions' => 'array_plus_2d',
+ 'wgGroupPermissions' => 'array_plus_2d',
+ 'wgHooks' => 'array_merge_recursive',
+ 'wgNamespaceContentModels' => 'array_plus',
+ 'wgNamespaceProtection' => 'array_plus',
+ 'wgNamespacesWithSubpages' => 'array_plus',
+ 'wgPasswordPolicy' => 'array_merge_recursive',
+ 'wgRateLimits' => 'array_plus_2d',
+ 'wgRevokePermissions' => 'array_plus_2d',
+ ];
+
+ /**
+ * Keys that are part of the extension credits
+ *
+ * @var array
+ */
+ protected static $creditsAttributes = [
+ 'name',
+ 'namemsg',
+ 'author',
+ 'version',
+ 'url',
+ 'description',
+ 'descriptionmsg',
+ 'license-name',
+ ];
+
+ /**
+ * Things that are not 'attributes', but are not in
+ * $globalSettings or $creditsAttributes.
+ *
+ * @var array
+ */
+ protected static $notAttributes = [
+ 'callback',
+ 'Hooks',
+ 'namespaces',
+ 'ResourceFileModulePaths',
+ 'ResourceModules',
+ 'ResourceModuleSkinStyles',
+ 'ExtensionMessagesFiles',
+ 'MessagesDirs',
+ 'type',
+ 'config',
+ 'config_prefix',
+ 'ServiceWiringFiles',
+ 'ParserTestFiles',
+ 'AutoloadClasses',
+ 'manifest_version',
+ 'load_composer_autoloader',
+ ];
+
+ /**
+ * Stuff that is going to be set to $GLOBALS
+ *
+ * Some keys are pre-set to arrays so we can += to them
+ *
+ * @var array
+ */
+ protected $globals = [
+ 'wgExtensionMessagesFiles' => [],
+ 'wgMessagesDirs' => [],
+ ];
+
+ /**
+ * Things that should be define()'d
+ *
+ * @var array
+ */
+ protected $defines = [];
+
+ /**
+ * Things to be called once registration of these extensions are done
+ * keyed by the name of the extension that it belongs to
+ *
+ * @var callable[]
+ */
+ protected $callbacks = [];
+
+ /**
+ * @var array
+ */
+ protected $credits = [];
+
+ /**
+ * Any thing else in the $info that hasn't
+ * already been processed
+ *
+ * @var array
+ */
+ protected $attributes = [];
+
+ /**
+ * Extension attributes, keyed by name =>
+ * settings.
+ *
+ * @var array
+ */
+ protected $extAttributes = [];
+
+ /**
+ * @param string $path
+ * @param array $info
+ * @param int $version manifest_version for info
+ * @return array
+ */
+ public function extractInfo( $path, array $info, $version ) {
+ $dir = dirname( $path );
+ if ( $version === 2 ) {
+ $this->extractConfig2( $info, $dir );
+ } else {
+ // $version === 1
+ $this->extractConfig1( $info );
+ }
+ $this->extractHooks( $info );
+ $this->extractExtensionMessagesFiles( $dir, $info );
+ $this->extractMessagesDirs( $dir, $info );
+ $this->extractNamespaces( $info );
+ $this->extractResourceLoaderModules( $dir, $info );
+ $this->extractServiceWiringFiles( $dir, $info );
+ $this->extractParserTestFiles( $dir, $info );
+ $name = $this->extractCredits( $path, $info );
+ if ( isset( $info['callback'] ) ) {
+ $this->callbacks[$name] = $info['callback'];
+ }
+
+ if ( $version === 2 ) {
+ $this->extractAttributes( $path, $info );
+ }
+
+ foreach ( $info as $key => $val ) {
+ // If it's a global setting,
+ if ( in_array( $key, self::$globalSettings ) ) {
+ $this->storeToArray( $path, "wg$key", $val, $this->globals );
+ continue;
+ }
+ // Ignore anything that starts with a @
+ if ( $key[0] === '@' ) {
+ continue;
+ }
+
+ if ( $version === 2 ) {
+ // Only whitelisted attributes are set
+ if ( in_array( $key, self::$coreAttributes ) ) {
+ $this->storeToArray( $path, $key, $val, $this->attributes );
+ }
+ } else {
+ // version === 1
+ if ( !in_array( $key, self::$notAttributes )
+ && !in_array( $key, self::$creditsAttributes )
+ ) {
+ // If it's not blacklisted, it's an attribute
+ $this->storeToArray( $path, $key, $val, $this->attributes );
+ }
+ }
+
+ }
+ }
+
+ /**
+ * @param string $path
+ * @param array $info
+ */
+ protected function extractAttributes( $path, array $info ) {
+ if ( isset( $info['attributes'] ) ) {
+ foreach ( $info['attributes'] as $extName => $value ) {
+ $this->storeToArray( $path, $extName, $value, $this->extAttributes );
+ }
+ }
+ }
+
+ public function getExtractedInfo() {
+ // Make sure the merge strategies are set
+ foreach ( $this->globals as $key => $val ) {
+ if ( isset( self::$mergeStrategies[$key] ) ) {
+ $this->globals[$key][ExtensionRegistry::MERGE_STRATEGY] = self::$mergeStrategies[$key];
+ }
+ }
+
+ // Merge $this->extAttributes into $this->attributes depending on what is loaded
+ foreach ( $this->extAttributes as $extName => $value ) {
+ // Only set the attribute if $extName is loaded (and hence present in credits)
+ if ( isset( $this->credits[$extName] ) ) {
+ foreach ( $value as $attrName => $attrValue ) {
+ $this->storeToArray(
+ '', // Don't provide a path since it's impossible to generate an error here
+ $extName . $attrName,
+ $attrValue,
+ $this->attributes
+ );
+ }
+ unset( $this->extAttributes[$extName] );
+ }
+ }
+
+ return [
+ 'globals' => $this->globals,
+ 'defines' => $this->defines,
+ 'callbacks' => $this->callbacks,
+ 'credits' => $this->credits,
+ 'attributes' => $this->attributes,
+ ];
+ }
+
+ public function getRequirements( array $info ) {
+ return isset( $info['requires'] ) ? $info['requires'] : [];
+ }
+
+ protected function extractHooks( array $info ) {
+ if ( isset( $info['Hooks'] ) ) {
+ foreach ( $info['Hooks'] as $name => $value ) {
+ if ( is_array( $value ) ) {
+ foreach ( $value as $callback ) {
+ $this->globals['wgHooks'][$name][] = $callback;
+ }
+ } else {
+ $this->globals['wgHooks'][$name][] = $value;
+ }
+ }
+ }
+ }
+
+ /**
+ * Register namespaces with the appropriate global settings
+ *
+ * @param array $info
+ */
+ protected function extractNamespaces( array $info ) {
+ if ( isset( $info['namespaces'] ) ) {
+ foreach ( $info['namespaces'] as $ns ) {
+ if ( defined( $ns['constant'] ) ) {
+ // If the namespace constant is already defined, use it.
+ // This allows namespace IDs to be overwritten locally.
+ $id = constant( $ns['constant'] );
+ } else {
+ $id = $ns['id'];
+ $this->defines[ $ns['constant'] ] = $id;
+ }
+
+ if ( !( isset( $ns['conditional'] ) && $ns['conditional'] ) ) {
+ // If it is not conditional, register it
+ $this->attributes['ExtensionNamespaces'][$id] = $ns['name'];
+ }
+ if ( isset( $ns['gender'] ) ) {
+ $this->globals['wgExtraGenderNamespaces'][$id] = $ns['gender'];
+ }
+ if ( isset( $ns['subpages'] ) && $ns['subpages'] ) {
+ $this->globals['wgNamespacesWithSubpages'][$id] = true;
+ }
+ if ( isset( $ns['content'] ) && $ns['content'] ) {
+ $this->globals['wgContentNamespaces'][] = $id;
+ }
+ if ( isset( $ns['defaultcontentmodel'] ) ) {
+ $this->globals['wgNamespaceContentModels'][$id] = $ns['defaultcontentmodel'];
+ }
+ if ( isset( $ns['protection'] ) ) {
+ $this->globals['wgNamespaceProtection'][$id] = $ns['protection'];
+ }
+ if ( isset( $ns['capitallinkoverride'] ) ) {
+ $this->globals['wgCapitalLinkOverrides'][$id] = $ns['capitallinkoverride'];
+ }
+ }
+ }
+ }
+
+ protected function extractResourceLoaderModules( $dir, array $info ) {
+ $defaultPaths = isset( $info['ResourceFileModulePaths'] )
+ ? $info['ResourceFileModulePaths']
+ : false;
+ if ( isset( $defaultPaths['localBasePath'] ) ) {
+ if ( $defaultPaths['localBasePath'] === '' ) {
+ // Avoid double slashes (e.g. /extensions/Example//path)
+ $defaultPaths['localBasePath'] = $dir;
+ } else {
+ $defaultPaths['localBasePath'] = "$dir/{$defaultPaths['localBasePath']}";
+ }
+ }
+
+ foreach ( [ 'ResourceModules', 'ResourceModuleSkinStyles' ] as $setting ) {
+ if ( isset( $info[$setting] ) ) {
+ foreach ( $info[$setting] as $name => $data ) {
+ if ( isset( $data['localBasePath'] ) ) {
+ if ( $data['localBasePath'] === '' ) {
+ // Avoid double slashes (e.g. /extensions/Example//path)
+ $data['localBasePath'] = $dir;
+ } else {
+ $data['localBasePath'] = "$dir/{$data['localBasePath']}";
+ }
+ }
+ if ( $defaultPaths ) {
+ $data += $defaultPaths;
+ }
+ $this->globals["wg$setting"][$name] = $data;
+ }
+ }
+ }
+ }
+
+ protected function extractExtensionMessagesFiles( $dir, array $info ) {
+ if ( isset( $info['ExtensionMessagesFiles'] ) ) {
+ $this->globals["wgExtensionMessagesFiles"] += array_map( function ( $file ) use ( $dir ) {
+ return "$dir/$file";
+ }, $info['ExtensionMessagesFiles'] );
+ }
+ }
+
+ /**
+ * Set message-related settings, which need to be expanded to use
+ * absolute paths
+ *
+ * @param string $dir
+ * @param array $info
+ */
+ protected function extractMessagesDirs( $dir, array $info ) {
+ if ( isset( $info['MessagesDirs'] ) ) {
+ foreach ( $info['MessagesDirs'] as $name => $files ) {
+ foreach ( (array)$files as $file ) {
+ $this->globals["wgMessagesDirs"][$name][] = "$dir/$file";
+ }
+ }
+ }
+ }
+
+ /**
+ * @param string $path
+ * @param array $info
+ * @return string Name of thing
+ * @throws Exception
+ */
+ protected function extractCredits( $path, array $info ) {
+ $credits = [
+ 'path' => $path,
+ 'type' => isset( $info['type'] ) ? $info['type'] : 'other',
+ ];
+ foreach ( self::$creditsAttributes as $attr ) {
+ if ( isset( $info[$attr] ) ) {
+ $credits[$attr] = $info[$attr];
+ }
+ }
+
+ $name = $credits['name'];
+
+ // If someone is loading the same thing twice, throw
+ // a nice error (T121493)
+ if ( isset( $this->credits[$name] ) ) {
+ $firstPath = $this->credits[$name]['path'];
+ $secondPath = $credits['path'];
+ throw new Exception( "It was attempted to load $name twice, from $firstPath and $secondPath." );
+ }
+
+ $this->credits[$name] = $credits;
+ $this->globals['wgExtensionCredits'][$credits['type']][] = $credits;
+
+ return $name;
+ }
+
+ /**
+ * Set configuration settings for manifest_version == 1
+ * @todo In the future, this should be done via Config interfaces
+ *
+ * @param array $info
+ */
+ protected function extractConfig1( array $info ) {
+ if ( isset( $info['config'] ) ) {
+ if ( isset( $info['config']['_prefix'] ) ) {
+ $prefix = $info['config']['_prefix'];
+ unset( $info['config']['_prefix'] );
+ } else {
+ $prefix = 'wg';
+ }
+ foreach ( $info['config'] as $key => $val ) {
+ if ( $key[0] !== '@' ) {
+ $this->globals["$prefix$key"] = $val;
+ }
+ }
+ }
+ }
+
+ /**
+ * Set configuration settings for manifest_version == 2
+ * @todo In the future, this should be done via Config interfaces
+ *
+ * @param array $info
+ * @param string $dir
+ */
+ protected function extractConfig2( array $info, $dir ) {
+ if ( isset( $info['config_prefix'] ) ) {
+ $prefix = $info['config_prefix'];
+ } else {
+ $prefix = 'wg';
+ }
+ if ( isset( $info['config'] ) ) {
+ foreach ( $info['config'] as $key => $data ) {
+ $value = $data['value'];
+ if ( isset( $data['merge_strategy'] ) ) {
+ $value[ExtensionRegistry::MERGE_STRATEGY] = $data['merge_strategy'];
+ }
+ if ( isset( $data['path'] ) && $data['path'] ) {
+ $value = "$dir/$value";
+ }
+ $this->globals["$prefix$key"] = $value;
+ }
+ }
+ }
+
+ protected function extractServiceWiringFiles( $dir, array $info ) {
+ if ( isset( $info['ServiceWiringFiles'] ) ) {
+ foreach ( $info['ServiceWiringFiles'] as $path ) {
+ $this->globals['wgServiceWiringFiles'][] = "$dir/$path";
+ }
+ }
+ }
+
+ protected function extractParserTestFiles( $dir, array $info ) {
+ if ( isset( $info['ParserTestFiles'] ) ) {
+ foreach ( $info['ParserTestFiles'] as $path ) {
+ $this->globals['wgParserTestFiles'][] = "$dir/$path";
+ }
+ }
+ }
+
+ /**
+ * @param string $path
+ * @param string $name
+ * @param array $value
+ * @param array &$array
+ * @throws InvalidArgumentException
+ */
+ protected function storeToArray( $path, $name, $value, &$array ) {
+ if ( !is_array( $value ) ) {
+ throw new InvalidArgumentException( "The value for '$name' should be an array (from $path)" );
+ }
+ if ( isset( $array[$name] ) ) {
+ $array[$name] = array_merge_recursive( $array[$name], $value );
+ } else {
+ $array[$name] = $value;
+ }
+ }
+
+ public function getExtraAutoloaderPaths( $dir, array $info ) {
+ $paths = [];
+ if ( isset( $info['load_composer_autoloader'] ) && $info['load_composer_autoloader'] === true ) {
+ $path = "$dir/vendor/autoload.php";
+ if ( file_exists( $path ) ) {
+ $paths[] = $path;
+ }
+ }
+ return $paths;
+ }
+}
diff --git a/www/wiki/includes/registration/ExtensionRegistry.php b/www/wiki/includes/registration/ExtensionRegistry.php
new file mode 100644
index 00000000..b2267321
--- /dev/null
+++ b/www/wiki/includes/registration/ExtensionRegistry.php
@@ -0,0 +1,417 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * ExtensionRegistry class
+ *
+ * The Registry loads JSON files, and uses a Processor
+ * to extract information from them. It also registers
+ * classes with the autoloader.
+ *
+ * @since 1.25
+ */
+class ExtensionRegistry {
+
+ /**
+ * "requires" key that applies to MediaWiki core/$wgVersion
+ */
+ const MEDIAWIKI_CORE = 'MediaWiki';
+
+ /**
+ * Version of the highest supported manifest version
+ */
+ const MANIFEST_VERSION = 2;
+
+ /**
+ * Version of the oldest supported manifest version
+ */
+ const OLDEST_MANIFEST_VERSION = 1;
+
+ /**
+ * Bump whenever the registration cache needs resetting
+ */
+ const CACHE_VERSION = 6;
+
+ /**
+ * Special key that defines the merge strategy
+ *
+ * @since 1.26
+ */
+ const MERGE_STRATEGY = '_merge_strategy';
+
+ /**
+ * Array of loaded things, keyed by name, values are credits information
+ *
+ * @var array
+ */
+ private $loaded = [];
+
+ /**
+ * List of paths that should be loaded
+ *
+ * @var array
+ */
+ protected $queued = [];
+
+ /**
+ * Whether we are done loading things
+ *
+ * @var bool
+ */
+ private $finished = false;
+
+ /**
+ * Items in the JSON file that aren't being
+ * set as globals
+ *
+ * @var array
+ */
+ protected $attributes = [];
+
+ /**
+ * @var ExtensionRegistry
+ */
+ private static $instance;
+
+ /**
+ * @return ExtensionRegistry
+ */
+ public static function getInstance() {
+ if ( self::$instance === null ) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * @param string $path Absolute path to the JSON file
+ */
+ public function queue( $path ) {
+ global $wgExtensionInfoMTime;
+
+ $mtime = $wgExtensionInfoMTime;
+ if ( $mtime === false ) {
+ if ( file_exists( $path ) ) {
+ $mtime = filemtime( $path );
+ } else {
+ throw new Exception( "$path does not exist!" );
+ }
+
+ if ( $mtime === false ) {
+ $err = error_get_last();
+ throw new Exception( "Couldn't stat $path: {$err['message']}" );
+ }
+ }
+ $this->queued[$path] = $mtime;
+ }
+
+ /**
+ * @throws MWException If the queue is already marked as finished (no further things should
+ * be loaded then).
+ */
+ public function loadFromQueue() {
+ global $wgVersion, $wgDevelopmentWarnings;
+ if ( !$this->queued ) {
+ return;
+ }
+
+ if ( $this->finished ) {
+ throw new MWException(
+ "The following paths tried to load late: "
+ . implode( ', ', array_keys( $this->queued ) )
+ );
+ }
+
+ // A few more things to vary the cache on
+ $versions = [
+ 'registration' => self::CACHE_VERSION,
+ 'mediawiki' => $wgVersion
+ ];
+
+ // We use a try/catch because we don't want to fail here
+ // if $wgObjectCaches is not configured properly for APC setup
+ try {
+ $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
+ } catch ( MWException $e ) {
+ $cache = new EmptyBagOStuff();
+ }
+ // See if this queue is in APC
+ $key = $cache->makeKey(
+ 'registration',
+ md5( json_encode( $this->queued + $versions ) )
+ );
+ $data = $cache->get( $key );
+ if ( $data ) {
+ $this->exportExtractedData( $data );
+ } else {
+ $data = $this->readFromQueue( $this->queued );
+ $this->exportExtractedData( $data );
+ // Do this late since we don't want to extract it since we already
+ // did that, but it should be cached
+ $data['globals']['wgAutoloadClasses'] += $data['autoload'];
+ unset( $data['autoload'] );
+ if ( !( $data['warnings'] && $wgDevelopmentWarnings ) ) {
+ // If there were no warnings that were shown, cache it
+ $cache->set( $key, $data, 60 * 60 * 24 );
+ }
+ }
+ $this->queued = [];
+ }
+
+ /**
+ * Get the current load queue. Not intended to be used
+ * outside of the installer.
+ *
+ * @return array
+ */
+ public function getQueue() {
+ return $this->queued;
+ }
+
+ /**
+ * Clear the current load queue. Not intended to be used
+ * outside of the installer.
+ */
+ public function clearQueue() {
+ $this->queued = [];
+ }
+
+ /**
+ * After this is called, no more extensions can be loaded
+ *
+ * @since 1.29
+ */
+ public function finish() {
+ $this->finished = true;
+ }
+
+ /**
+ * Process a queue of extensions and return their extracted data
+ *
+ * @param array $queue keys are filenames, values are ignored
+ * @return array extracted info
+ * @throws Exception
+ */
+ public function readFromQueue( array $queue ) {
+ global $wgVersion;
+ $autoloadClasses = [];
+ $autoloaderPaths = [];
+ $processor = new ExtensionProcessor();
+ $versionChecker = new VersionChecker( $wgVersion );
+ $extDependencies = [];
+ $incompatible = [];
+ $warnings = false;
+ foreach ( $queue as $path => $mtime ) {
+ $json = file_get_contents( $path );
+ if ( $json === false ) {
+ throw new Exception( "Unable to read $path, does it exist?" );
+ }
+ $info = json_decode( $json, /* $assoc = */ true );
+ if ( !is_array( $info ) ) {
+ throw new Exception( "$path is not a valid JSON file." );
+ }
+
+ if ( !isset( $info['manifest_version'] ) ) {
+ wfDeprecated(
+ "{$info['name']}'s extension.json or skin.json does not have manifest_version",
+ '1.29'
+ );
+ $warnings = true;
+ // For backwards-compatability, assume a version of 1
+ $info['manifest_version'] = 1;
+ }
+ $version = $info['manifest_version'];
+ if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
+ $incompatible[] = "$path: unsupported manifest_version: {$version}";
+ }
+
+ $autoload = $this->processAutoLoader( dirname( $path ), $info );
+ // Set up the autoloader now so custom processors will work
+ $GLOBALS['wgAutoloadClasses'] += $autoload;
+ $autoloadClasses += $autoload;
+
+ // get all requirements/dependencies for this extension
+ $requires = $processor->getRequirements( $info );
+
+ // validate the information needed and add the requirements
+ if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
+ $extDependencies[$info['name']] = $requires;
+ }
+
+ // Get extra paths for later inclusion
+ $autoloaderPaths = array_merge( $autoloaderPaths,
+ $processor->getExtraAutoloaderPaths( dirname( $path ), $info ) );
+ // Compatible, read and extract info
+ $processor->extractInfo( $path, $info, $version );
+ }
+ $data = $processor->getExtractedInfo();
+ $data['warnings'] = $warnings;
+
+ // check for incompatible extensions
+ $incompatible = array_merge(
+ $incompatible,
+ $versionChecker
+ ->setLoadedExtensionsAndSkins( $data['credits'] )
+ ->checkArray( $extDependencies )
+ );
+
+ if ( $incompatible ) {
+ if ( count( $incompatible ) === 1 ) {
+ throw new Exception( $incompatible[0] );
+ } else {
+ throw new Exception( implode( "\n", $incompatible ) );
+ }
+ }
+
+ // Need to set this so we can += to it later
+ $data['globals']['wgAutoloadClasses'] = [];
+ $data['autoload'] = $autoloadClasses;
+ $data['autoloaderPaths'] = $autoloaderPaths;
+ return $data;
+ }
+
+ protected function exportExtractedData( array $info ) {
+ foreach ( $info['globals'] as $key => $val ) {
+ // If a merge strategy is set, read it and remove it from the value
+ // so it doesn't accidentally end up getting set.
+ if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
+ $mergeStrategy = $val[self::MERGE_STRATEGY];
+ unset( $val[self::MERGE_STRATEGY] );
+ } else {
+ $mergeStrategy = 'array_merge';
+ }
+
+ // Optimistic: If the global is not set, or is an empty array, replace it entirely.
+ // Will be O(1) performance.
+ if ( !isset( $GLOBALS[$key] ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
+ $GLOBALS[$key] = $val;
+ continue;
+ }
+
+ if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
+ // config setting that has already been overridden, don't set it
+ continue;
+ }
+
+ switch ( $mergeStrategy ) {
+ case 'array_merge_recursive':
+ $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
+ break;
+ case 'array_replace_recursive':
+ $GLOBALS[$key] = array_replace_recursive( $GLOBALS[$key], $val );
+ break;
+ case 'array_plus_2d':
+ $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
+ break;
+ case 'array_plus':
+ $GLOBALS[$key] += $val;
+ break;
+ case 'array_merge':
+ $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
+ break;
+ default:
+ throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
+ }
+ }
+
+ foreach ( $info['defines'] as $name => $val ) {
+ define( $name, $val );
+ }
+ foreach ( $info['autoloaderPaths'] as $path ) {
+ require_once $path;
+ }
+
+ $this->loaded += $info['credits'];
+ if ( $info['attributes'] ) {
+ if ( !$this->attributes ) {
+ $this->attributes = $info['attributes'];
+ } else {
+ $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
+ }
+ }
+
+ foreach ( $info['callbacks'] as $name => $cb ) {
+ if ( !is_callable( $cb ) ) {
+ if ( is_array( $cb ) ) {
+ $cb = '[ ' . implode( ', ', $cb ) . ' ]';
+ }
+ throw new UnexpectedValueException( "callback '$cb' is not callable" );
+ }
+ call_user_func( $cb, $info['credits'][$name] );
+ }
+ }
+
+ /**
+ * Loads and processes the given JSON file without delay
+ *
+ * If some extensions are already queued, this will load
+ * those as well.
+ *
+ * @param string $path Absolute path to the JSON file
+ */
+ public function load( $path ) {
+ $this->loadFromQueue(); // First clear the queue
+ $this->queue( $path );
+ $this->loadFromQueue();
+ }
+
+ /**
+ * Whether a thing has been loaded
+ * @param string $name
+ * @return bool
+ */
+ public function isLoaded( $name ) {
+ return isset( $this->loaded[$name] );
+ }
+
+ /**
+ * @param string $name
+ * @return array
+ */
+ public function getAttribute( $name ) {
+ if ( isset( $this->attributes[$name] ) ) {
+ return $this->attributes[$name];
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * Get information about all things
+ *
+ * @return array
+ */
+ public function getAllThings() {
+ return $this->loaded;
+ }
+
+ /**
+ * Mark a thing as loaded
+ *
+ * @param string $name
+ * @param array $credits
+ */
+ protected function markLoaded( $name, array $credits ) {
+ $this->loaded[$name] = $credits;
+ }
+
+ /**
+ * Register classes with the autoloader
+ *
+ * @param string $dir
+ * @param array $info
+ * @return array
+ */
+ protected function processAutoLoader( $dir, array $info ) {
+ if ( isset( $info['AutoloadClasses'] ) ) {
+ // Make paths absolute, relative to the JSON file
+ return array_map( function ( $file ) use ( $dir ) {
+ return "$dir/$file";
+ }, $info['AutoloadClasses'] );
+ } else {
+ return [];
+ }
+ }
+}
diff --git a/www/wiki/includes/registration/Processor.php b/www/wiki/includes/registration/Processor.php
new file mode 100644
index 00000000..210deb1b
--- /dev/null
+++ b/www/wiki/includes/registration/Processor.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * Processors read associated arrays and register
+ * whatever is required
+ *
+ * @since 1.25
+ */
+interface Processor {
+
+ /**
+ * Main entry point, processes the information
+ * provided.
+ * Callers should call "callback" after calling
+ * this function.
+ *
+ * @param string $path Absolute path of JSON file
+ * @param array $info
+ * @param int $version manifest_version for info
+ * @return array "credits" information to store
+ */
+ public function extractInfo( $path, array $info, $version );
+
+ /**
+ * @return array With following keys:
+ * 'globals' - variables to be set to $GLOBALS
+ * 'defines' - constants to define
+ * 'callbacks' - functions to be executed by the registry
+ * 'credits' - metadata to be stored by registry
+ * 'attributes' - registration info which isn't a global variable
+ */
+ public function getExtractedInfo();
+
+ /**
+ * Get the requirements for the provided info
+ *
+ * @since 1.26
+ * @param array $info
+ * @return array Where keys are the name to have a constraint on,
+ * like 'MediaWiki'. Values are a constraint string like "1.26.1".
+ */
+ public function getRequirements( array $info );
+
+ /**
+ * Get the path for additional autoloaders, e.g. the one of Composer.
+ *
+ * @param string $dir
+ * @param array $info
+ * @return array Containing the paths for autoloader file(s).
+ * @since 1.27
+ */
+ public function getExtraAutoloaderPaths( $dir, array $info );
+}
diff --git a/www/wiki/includes/registration/VersionChecker.php b/www/wiki/includes/registration/VersionChecker.php
new file mode 100644
index 00000000..a31551c3
--- /dev/null
+++ b/www/wiki/includes/registration/VersionChecker.php
@@ -0,0 +1,211 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @author Legoktm
+ * @author Florian Schmidt
+ */
+
+use Composer\Semver\VersionParser;
+use Composer\Semver\Constraint\Constraint;
+
+/**
+ * Provides functions to check a set of extensions with dependencies against
+ * a set of loaded extensions and given version information.
+ *
+ * @since 1.29
+ */
+class VersionChecker {
+ /**
+ * @var Constraint|bool representing $wgVersion
+ */
+ private $coreVersion = false;
+
+ /**
+ * @var array Loaded extensions
+ */
+ private $loaded = [];
+
+ /**
+ * @var VersionParser
+ */
+ private $versionParser;
+
+ /**
+ * @param string $coreVersion Current version of core
+ */
+ public function __construct( $coreVersion ) {
+ $this->versionParser = new VersionParser();
+ $this->setCoreVersion( $coreVersion );
+ }
+
+ /**
+ * Set an array with credits of all loaded extensions and skins.
+ *
+ * @param array $credits An array of installed extensions with credits of them
+ * @return VersionChecker $this
+ */
+ public function setLoadedExtensionsAndSkins( array $credits ) {
+ $this->loaded = $credits;
+
+ return $this;
+ }
+
+ /**
+ * Set MediaWiki core version.
+ *
+ * @param string $coreVersion Current version of core
+ */
+ private function setCoreVersion( $coreVersion ) {
+ try {
+ $this->coreVersion = new Constraint(
+ '==',
+ $this->versionParser->normalize( $coreVersion )
+ );
+ $this->coreVersion->setPrettyString( $coreVersion );
+ } catch ( UnexpectedValueException $e ) {
+ // Non-parsable version, don't fatal.
+ }
+ }
+
+ /**
+ * Check all given dependencies if they are compatible with the named
+ * installed extensions in the $credits array.
+ *
+ * Example $extDependencies:
+ * {
+ * 'FooBar' => {
+ * 'MediaWiki' => '>= 1.25.0',
+ * 'extensions' => {
+ * 'FooBaz' => '>= 1.25.0'
+ * },
+ * 'skins' => {
+ * 'BazBar' => '>= 1.0.0'
+ * }
+ * }
+ * }
+ *
+ * @param array $extDependencies All extensions that depend on other ones
+ * @return array
+ */
+ public function checkArray( array $extDependencies ) {
+ $errors = [];
+ foreach ( $extDependencies as $extension => $dependencies ) {
+ foreach ( $dependencies as $dependencyType => $values ) {
+ switch ( $dependencyType ) {
+ case ExtensionRegistry::MEDIAWIKI_CORE:
+ $mwError = $this->handleMediaWikiDependency( $values, $extension );
+ if ( $mwError !== false ) {
+ $errors[] = $mwError;
+ }
+ break;
+ case 'extensions':
+ case 'skin':
+ foreach ( $values as $dependency => $constraint ) {
+ $extError = $this->handleExtensionDependency( $dependency, $constraint, $extension );
+ if ( $extError !== false ) {
+ $errors[] = $extError;
+ }
+ }
+ break;
+ default:
+ throw new UnexpectedValueException( 'Dependency type ' . $dependencyType .
+ ' unknown in ' . $extension );
+ }
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Handle a dependency to MediaWiki core. It will check, if a MediaWiki version constraint was
+ * set with self::setCoreVersion before this call (if not, it will return an empty array) and
+ * checks the version constraint given against it.
+ *
+ * @param string $constraint The required version constraint for this dependency
+ * @param string $checkedExt The Extension, which depends on this dependency
+ * @return bool|string false if no error, or a string with the message
+ */
+ private function handleMediaWikiDependency( $constraint, $checkedExt ) {
+ if ( $this->coreVersion === false ) {
+ // Couldn't parse the core version, so we can't check anything
+ return false;
+ }
+
+ // if the installed and required version are compatible, return an empty array
+ if ( $this->versionParser->parseConstraints( $constraint )
+ ->matches( $this->coreVersion ) ) {
+ return false;
+ }
+ // otherwise mark this as incompatible.
+ return "{$checkedExt} is not compatible with the current "
+ . "MediaWiki core (version {$this->coreVersion->getPrettyString()}), it requires: "
+ . "$constraint.";
+ }
+
+ /**
+ * Handle a dependency to another extension.
+ *
+ * @param string $dependencyName The name of the dependency
+ * @param string $constraint The required version constraint for this dependency
+ * @param string $checkedExt The Extension, which depends on this dependency
+ * @return bool|string false for no errors, or a string message
+ */
+ private function handleExtensionDependency( $dependencyName, $constraint, $checkedExt ) {
+ // Check if the dependency is even installed
+ if ( !isset( $this->loaded[$dependencyName] ) ) {
+ return "{$checkedExt} requires {$dependencyName} to be installed.";
+ }
+ // Check if the dependency has specified a version
+ if ( !isset( $this->loaded[$dependencyName]['version'] ) ) {
+ // If we depend upon any version, and none is set, that's fine.
+ if ( $constraint === '*' ) {
+ wfDebug( "{$dependencyName} does not expose it's version, but {$checkedExt}
+ mentions it with constraint '*'. Assume it's ok so." );
+ return false;
+ } else {
+ // Otherwise, mark it as incompatible.
+ return "{$dependencyName} does not expose it's version, but {$checkedExt}
+ requires: {$constraint}.";
+ }
+ } else {
+ // Try to get a constraint for the dependency version
+ try {
+ $installedVersion = new Constraint(
+ '==',
+ $this->versionParser->normalize( $this->loaded[$dependencyName]['version'] )
+ );
+ } catch ( UnexpectedValueException $e ) {
+ // Non-parsable version, output an error message that the version
+ // string is invalid
+ return "$dependencyName does not have a valid version string.";
+ }
+ // Check if the constraint actually matches...
+ if (
+ !$this->versionParser->parseConstraints( $constraint )->matches( $installedVersion )
+ ) {
+ return "{$checkedExt} is not compatible with the current "
+ . "installed version of {$dependencyName} "
+ . "({$this->loaded[$dependencyName]['version']}), "
+ . "it requires: " . $constraint . '.';
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/DerivativeResourceLoaderContext.php b/www/wiki/includes/resourceloader/DerivativeResourceLoaderContext.php
new file mode 100644
index 00000000..418d17f3
--- /dev/null
+++ b/www/wiki/includes/resourceloader/DerivativeResourceLoaderContext.php
@@ -0,0 +1,199 @@
+<?php
+/**
+ * Derivative context for ResourceLoader modules.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Kunal Mehta
+ */
+
+/**
+ * Allows changing specific properties of a context object,
+ * without changing the main one. Inspired by DerivativeContext.
+ *
+ * @since 1.24
+ */
+class DerivativeResourceLoaderContext extends ResourceLoaderContext {
+ const INHERIT_VALUE = -1;
+
+ /**
+ * @var ResourceLoaderContext
+ */
+ private $context;
+
+ protected $modules = self::INHERIT_VALUE;
+ protected $language = self::INHERIT_VALUE;
+ protected $direction = self::INHERIT_VALUE;
+ protected $skin = self::INHERIT_VALUE;
+ protected $user = self::INHERIT_VALUE;
+ protected $debug = self::INHERIT_VALUE;
+ protected $only = self::INHERIT_VALUE;
+ protected $version = self::INHERIT_VALUE;
+ protected $raw = self::INHERIT_VALUE;
+
+ public function __construct( ResourceLoaderContext $context ) {
+ $this->context = $context;
+ }
+
+ public function getModules() {
+ if ( $this->modules === self::INHERIT_VALUE ) {
+ return $this->context->getModules();
+ }
+ return $this->modules;
+ }
+
+ /**
+ * @param string[] $modules
+ */
+ public function setModules( array $modules ) {
+ $this->modules = $modules;
+ }
+
+ public function getLanguage() {
+ if ( $this->language === self::INHERIT_VALUE ) {
+ return $this->context->getLanguage();
+ }
+ return $this->language;
+ }
+
+ /**
+ * @param string $language
+ */
+ public function setLanguage( $language ) {
+ $this->language = $language;
+ // Invalidate direction since it is based on language
+ $this->direction = null;
+ $this->hash = null;
+ }
+
+ public function getDirection() {
+ if ( $this->direction === self::INHERIT_VALUE ) {
+ return $this->context->getDirection();
+ }
+ if ( $this->direction === null ) {
+ $this->direction = Language::factory( $this->getLanguage() )->getDir();
+ }
+ return $this->direction;
+ }
+
+ /**
+ * @param string $direction
+ */
+ public function setDirection( $direction ) {
+ $this->direction = $direction;
+ $this->hash = null;
+ }
+
+ public function getSkin() {
+ if ( $this->skin === self::INHERIT_VALUE ) {
+ return $this->context->getSkin();
+ }
+ return $this->skin;
+ }
+
+ /**
+ * @param string $skin
+ */
+ public function setSkin( $skin ) {
+ $this->skin = $skin;
+ $this->hash = null;
+ }
+
+ public function getUser() {
+ if ( $this->user === self::INHERIT_VALUE ) {
+ return $this->context->getUser();
+ }
+ return $this->user;
+ }
+
+ /**
+ * @param string|null $user
+ */
+ public function setUser( $user ) {
+ $this->user = $user;
+ $this->hash = null;
+ $this->userObj = null;
+ }
+
+ public function getDebug() {
+ if ( $this->debug === self::INHERIT_VALUE ) {
+ return $this->context->getDebug();
+ }
+ return $this->debug;
+ }
+
+ /**
+ * @param bool $debug
+ */
+ public function setDebug( $debug ) {
+ $this->debug = $debug;
+ $this->hash = null;
+ }
+
+ public function getOnly() {
+ if ( $this->only === self::INHERIT_VALUE ) {
+ return $this->context->getOnly();
+ }
+ return $this->only;
+ }
+
+ /**
+ * @param string|null $only
+ */
+ public function setOnly( $only ) {
+ $this->only = $only;
+ $this->hash = null;
+ }
+
+ public function getVersion() {
+ if ( $this->version === self::INHERIT_VALUE ) {
+ return $this->context->getVersion();
+ }
+ return $this->version;
+ }
+
+ /**
+ * @param string|null $version
+ */
+ public function setVersion( $version ) {
+ $this->version = $version;
+ $this->hash = null;
+ }
+
+ public function getRaw() {
+ if ( $this->raw === self::INHERIT_VALUE ) {
+ return $this->context->getRaw();
+ }
+ return $this->raw;
+ }
+
+ /**
+ * @param bool $raw
+ */
+ public function setRaw( $raw ) {
+ $this->raw = $raw;
+ }
+
+ public function getRequest() {
+ return $this->context->getRequest();
+ }
+
+ public function getResourceLoader() {
+ return $this->context->getResourceLoader();
+ }
+
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoader.php b/www/wiki/includes/resourceloader/ResourceLoader.php
new file mode 100644
index 00000000..c58bb00b
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoader.php
@@ -0,0 +1,1720 @@
+<?php
+/**
+ * Base class for resource loading system.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Roan Kattouw
+ * @author Trevor Parscal
+ */
+
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use WrappedString\WrappedString;
+use Wikimedia\Rdbms\DBConnectionError;
+
+/**
+ * Dynamic JavaScript and CSS resource loading system.
+ *
+ * Most of the documentation is on the MediaWiki documentation wiki starting at:
+ * https://www.mediawiki.org/wiki/ResourceLoader
+ */
+class ResourceLoader implements LoggerAwareInterface {
+ /** @var int */
+ protected static $filterCacheVersion = 7;
+
+ /** @var bool */
+ protected static $debugMode = null;
+
+ /** @var array */
+ private $lessVars = null;
+
+ /**
+ * Module name/ResourceLoaderModule object pairs
+ * @var array
+ */
+ protected $modules = [];
+
+ /**
+ * Associative array mapping module name to info associative array
+ * @var array
+ */
+ protected $moduleInfos = [];
+
+ /** @var Config $config */
+ protected $config;
+
+ /**
+ * Associative array mapping framework ids to a list of names of test suite modules
+ * like [ 'qunit' => [ 'mediawiki.tests.qunit.suites', 'ext.foo.tests', ... ], ... ]
+ * @var array
+ */
+ protected $testModuleNames = [];
+
+ /**
+ * E.g. [ 'source-id' => 'http://.../load.php' ]
+ * @var array
+ */
+ protected $sources = [];
+
+ /**
+ * Errors accumulated during current respond() call.
+ * @var array
+ */
+ protected $errors = [];
+
+ /**
+ * List of extra HTTP response headers provided by loaded modules.
+ *
+ * Populated by makeModuleResponse().
+ *
+ * @var array
+ */
+ protected $extraHeaders = [];
+
+ /**
+ * @var MessageBlobStore
+ */
+ protected $blobStore;
+
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
+ /** @var string JavaScript / CSS pragma to disable minification. **/
+ const FILTER_NOMIN = '/*@nomin*/';
+
+ /**
+ * Load information stored in the database about modules.
+ *
+ * This method grabs modules dependencies from the database and updates modules
+ * objects.
+ *
+ * This is not inside the module code because it is much faster to
+ * request all of the information at once than it is to have each module
+ * requests its own information. This sacrifice of modularity yields a substantial
+ * performance improvement.
+ *
+ * @param array $moduleNames List of module names to preload information for
+ * @param ResourceLoaderContext $context Context to load the information within
+ */
+ public function preloadModuleInfo( array $moduleNames, ResourceLoaderContext $context ) {
+ if ( !$moduleNames ) {
+ // Or else Database*::select() will explode, plus it's cheaper!
+ return;
+ }
+ $dbr = wfGetDB( DB_REPLICA );
+ $skin = $context->getSkin();
+ $lang = $context->getLanguage();
+
+ // Batched version of ResourceLoaderModule::getFileDependencies
+ $vary = "$skin|$lang";
+ $res = $dbr->select( 'module_deps', [ 'md_module', 'md_deps' ], [
+ 'md_module' => $moduleNames,
+ 'md_skin' => $vary,
+ ], __METHOD__
+ );
+
+ // Prime in-object cache for file dependencies
+ $modulesWithDeps = [];
+ foreach ( $res as $row ) {
+ $module = $this->getModule( $row->md_module );
+ if ( $module ) {
+ $module->setFileDependencies( $context, ResourceLoaderModule::expandRelativePaths(
+ FormatJson::decode( $row->md_deps, true )
+ ) );
+ $modulesWithDeps[] = $row->md_module;
+ }
+ }
+ // Register the absence of a dependency row too
+ foreach ( array_diff( $moduleNames, $modulesWithDeps ) as $name ) {
+ $module = $this->getModule( $name );
+ if ( $module ) {
+ $this->getModule( $name )->setFileDependencies( $context, [] );
+ }
+ }
+
+ // Batched version of ResourceLoaderWikiModule::getTitleInfo
+ ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $moduleNames );
+
+ // Prime in-object cache for message blobs for modules with messages
+ $modules = [];
+ foreach ( $moduleNames as $name ) {
+ $module = $this->getModule( $name );
+ if ( $module && $module->getMessages() ) {
+ $modules[$name] = $module;
+ }
+ }
+ $store = $this->getMessageBlobStore();
+ $blobs = $store->getBlobs( $modules, $lang );
+ foreach ( $blobs as $name => $blob ) {
+ $modules[$name]->setMessageBlob( $blob, $lang );
+ }
+ }
+
+ /**
+ * Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
+ *
+ * Available filters are:
+ *
+ * - minify-js \see JavaScriptMinifier::minify
+ * - minify-css \see CSSMin::minify
+ *
+ * If $data is empty, only contains whitespace or the filter was unknown,
+ * $data is returned unmodified.
+ *
+ * @param string $filter Name of filter to run
+ * @param string $data Text to filter, such as JavaScript or CSS text
+ * @param array $options Keys:
+ * - (bool) cache: Whether to allow caching this data. Default: true.
+ * @return string Filtered data, or a comment containing an error message
+ */
+ public static function filter( $filter, $data, array $options = [] ) {
+ if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
+ return $data;
+ }
+
+ if ( isset( $options['cache'] ) && $options['cache'] === false ) {
+ return self::applyFilter( $filter, $data );
+ }
+
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
+
+ $key = $cache->makeGlobalKey(
+ 'resourceloader',
+ 'filter',
+ $filter,
+ self::$filterCacheVersion, md5( $data )
+ );
+
+ $result = $cache->get( $key );
+ if ( $result === false ) {
+ $stats->increment( "resourceloader_cache.$filter.miss" );
+ $result = self::applyFilter( $filter, $data );
+ $cache->set( $key, $result, 24 * 3600 );
+ } else {
+ $stats->increment( "resourceloader_cache.$filter.hit" );
+ }
+ if ( $result === null ) {
+ // Cached failure
+ $result = $data;
+ }
+
+ return $result;
+ }
+
+ private static function applyFilter( $filter, $data ) {
+ $data = trim( $data );
+ if ( $data ) {
+ try {
+ $data = ( $filter === 'minify-css' )
+ ? CSSMin::minify( $data )
+ : JavaScriptMinifier::minify( $data );
+ } catch ( Exception $e ) {
+ MWExceptionHandler::logException( $e );
+ return null;
+ }
+ }
+ return $data;
+ }
+
+ /* Methods */
+
+ /**
+ * Register core modules and runs registration hooks.
+ * @param Config $config [optional]
+ * @param LoggerInterface $logger [optional]
+ */
+ public function __construct( Config $config = null, LoggerInterface $logger = null ) {
+ global $IP;
+
+ $this->logger = $logger ?: new NullLogger();
+
+ if ( !$config ) {
+ $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' );
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+ $this->config = $config;
+
+ // Add 'local' source first
+ $this->addSource( 'local', $config->get( 'LoadScript' ) );
+
+ // Add other sources
+ $this->addSource( $config->get( 'ResourceLoaderSources' ) );
+
+ // Register core modules
+ $this->register( include "$IP/resources/Resources.php" );
+ // Register extension modules
+ $this->register( $config->get( 'ResourceModules' ) );
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $rl = $this;
+ Hooks::run( 'ResourceLoaderRegisterModules', [ &$rl ] );
+
+ if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
+ $this->registerTestModules();
+ }
+
+ $this->setMessageBlobStore( new MessageBlobStore( $this, $this->logger ) );
+ }
+
+ /**
+ * @return Config
+ */
+ public function getConfig() {
+ return $this->config;
+ }
+
+ /**
+ * @since 1.26
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @since 1.27
+ * @return LoggerInterface
+ */
+ public function getLogger() {
+ return $this->logger;
+ }
+
+ /**
+ * @since 1.26
+ * @return MessageBlobStore
+ */
+ public function getMessageBlobStore() {
+ return $this->blobStore;
+ }
+
+ /**
+ * @since 1.25
+ * @param MessageBlobStore $blobStore
+ */
+ public function setMessageBlobStore( MessageBlobStore $blobStore ) {
+ $this->blobStore = $blobStore;
+ }
+
+ /**
+ * Register a module with the ResourceLoader system.
+ *
+ * @param mixed $name Name of module as a string or List of name/object pairs as an array
+ * @param array $info Module info array. For backwards compatibility with 1.17alpha,
+ * this may also be a ResourceLoaderModule object. Optional when using
+ * multiple-registration calling style.
+ * @throws MWException If a duplicate module registration is attempted
+ * @throws MWException If a module name contains illegal characters (pipes or commas)
+ * @throws MWException If something other than a ResourceLoaderModule is being registered
+ * @return bool False if there were any errors, in which case one or more modules were
+ * not registered
+ */
+ public function register( $name, $info = null ) {
+ $moduleSkinStyles = $this->config->get( 'ResourceModuleSkinStyles' );
+
+ // Allow multiple modules to be registered in one call
+ $registrations = is_array( $name ) ? $name : [ $name => $info ];
+ foreach ( $registrations as $name => $info ) {
+ // Warn on duplicate registrations
+ if ( isset( $this->moduleInfos[$name] ) ) {
+ // A module has already been registered by this name
+ $this->logger->warning(
+ 'ResourceLoader duplicate registration warning. ' .
+ 'Another module has already been registered as ' . $name
+ );
+ }
+
+ // Check $name for validity
+ if ( !self::isValidModuleName( $name ) ) {
+ throw new MWException( "ResourceLoader module name '$name' is invalid, "
+ . "see ResourceLoader::isValidModuleName()" );
+ }
+
+ // Attach module
+ if ( $info instanceof ResourceLoaderModule ) {
+ $this->moduleInfos[$name] = [ 'object' => $info ];
+ $info->setName( $name );
+ $this->modules[$name] = $info;
+ } elseif ( is_array( $info ) ) {
+ // New calling convention
+ $this->moduleInfos[$name] = $info;
+ } else {
+ throw new MWException(
+ 'ResourceLoader module info type error for module \'' . $name .
+ '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
+ );
+ }
+
+ // Last-minute changes
+
+ // Apply custom skin-defined styles to existing modules.
+ if ( $this->isFileModule( $name ) ) {
+ foreach ( $moduleSkinStyles as $skinName => $skinStyles ) {
+ // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
+ if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
+ continue;
+ }
+
+ // If $name is preceded with a '+', the defined style files will be added to 'default'
+ // skinStyles, otherwise 'default' will be ignored as it normally would be.
+ if ( isset( $skinStyles[$name] ) ) {
+ $paths = (array)$skinStyles[$name];
+ $styleFiles = [];
+ } elseif ( isset( $skinStyles['+' . $name] ) ) {
+ $paths = (array)$skinStyles['+' . $name];
+ $styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
+ (array)$this->moduleInfos[$name]['skinStyles']['default'] :
+ [];
+ } else {
+ continue;
+ }
+
+ // Add new file paths, remapping them to refer to our directories and not use settings
+ // from the module we're modifying, which come from the base definition.
+ list( $localBasePath, $remoteBasePath ) =
+ ResourceLoaderFileModule::extractBasePaths( $skinStyles );
+
+ foreach ( $paths as $path ) {
+ $styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
+ }
+
+ $this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
+ }
+ }
+ }
+ }
+
+ public function registerTestModules() {
+ global $IP;
+
+ if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
+ throw new MWException( 'Attempt to register JavaScript test modules '
+ . 'but <code>$wgEnableJavaScriptTest</code> is false. '
+ . 'Edit your <code>LocalSettings.php</code> to enable it.' );
+ }
+
+ // Get core test suites
+ $testModules = [];
+ $testModules['qunit'] = [];
+ // Get other test suites (e.g. from extensions)
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $rl = $this;
+ Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
+
+ // Add the testrunner (which configures QUnit) to the dependencies.
+ // Since it must be ready before any of the test suites are executed.
+ foreach ( $testModules['qunit'] as &$module ) {
+ // Make sure all test modules are top-loading so that when QUnit starts
+ // on document-ready, it will run once and finish. If some tests arrive
+ // later (possibly after QUnit has already finished) they will be ignored.
+ $module['position'] = 'top';
+ $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
+ }
+
+ $testModules['qunit'] =
+ ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
+
+ foreach ( $testModules as $id => $names ) {
+ // Register test modules
+ $this->register( $testModules[$id] );
+
+ // Keep track of their names so that they can be loaded together
+ $this->testModuleNames[$id] = array_keys( $testModules[$id] );
+ }
+ }
+
+ /**
+ * Add a foreign source of modules.
+ *
+ * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
+ *
+ * @param array|string $id Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ]
+ * @param string|array $loadUrl load.php url (string), or array with loadUrl key for
+ * backwards-compatibility.
+ * @throws MWException
+ */
+ public function addSource( $id, $loadUrl = null ) {
+ // Allow multiple sources to be registered in one call
+ if ( is_array( $id ) ) {
+ foreach ( $id as $key => $value ) {
+ $this->addSource( $key, $value );
+ }
+ return;
+ }
+
+ // Disallow duplicates
+ if ( isset( $this->sources[$id] ) ) {
+ throw new MWException(
+ 'ResourceLoader duplicate source addition error. ' .
+ 'Another source has already been registered as ' . $id
+ );
+ }
+
+ // Pre 1.24 backwards-compatibility
+ if ( is_array( $loadUrl ) ) {
+ if ( !isset( $loadUrl['loadScript'] ) ) {
+ throw new MWException(
+ __METHOD__ . ' was passed an array with no "loadScript" key.'
+ );
+ }
+
+ $loadUrl = $loadUrl['loadScript'];
+ }
+
+ $this->sources[$id] = $loadUrl;
+ }
+
+ /**
+ * Get a list of module names.
+ *
+ * @return array List of module names
+ */
+ public function getModuleNames() {
+ return array_keys( $this->moduleInfos );
+ }
+
+ /**
+ * Get a list of test module names for one (or all) frameworks.
+ *
+ * If the given framework id is unknkown, or if the in-object variable is not an array,
+ * then it will return an empty array.
+ *
+ * @param string $framework Get only the test module names for one
+ * particular framework (optional)
+ * @return array
+ */
+ public function getTestModuleNames( $framework = 'all' ) {
+ /** @todo api siteinfo prop testmodulenames modulenames */
+ if ( $framework == 'all' ) {
+ return $this->testModuleNames;
+ } elseif ( isset( $this->testModuleNames[$framework] )
+ && is_array( $this->testModuleNames[$framework] )
+ ) {
+ return $this->testModuleNames[$framework];
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * Check whether a ResourceLoader module is registered
+ *
+ * @since 1.25
+ * @param string $name
+ * @return bool
+ */
+ public function isModuleRegistered( $name ) {
+ return isset( $this->moduleInfos[$name] );
+ }
+
+ /**
+ * Get the ResourceLoaderModule object for a given module name.
+ *
+ * If an array of module parameters exists but a ResourceLoaderModule object has not
+ * yet been instantiated, this method will instantiate and cache that object such that
+ * subsequent calls simply return the same object.
+ *
+ * @param string $name Module name
+ * @return ResourceLoaderModule|null If module has been registered, return a
+ * ResourceLoaderModule instance. Otherwise, return null.
+ */
+ public function getModule( $name ) {
+ if ( !isset( $this->modules[$name] ) ) {
+ if ( !isset( $this->moduleInfos[$name] ) ) {
+ // No such module
+ return null;
+ }
+ // Construct the requested object
+ $info = $this->moduleInfos[$name];
+ /** @var ResourceLoaderModule $object */
+ if ( isset( $info['object'] ) ) {
+ // Object given in info array
+ $object = $info['object'];
+ } elseif ( isset( $info['factory'] ) ) {
+ $object = call_user_func( $info['factory'], $info );
+ $object->setConfig( $this->getConfig() );
+ $object->setLogger( $this->logger );
+ } else {
+ if ( !isset( $info['class'] ) ) {
+ $class = 'ResourceLoaderFileModule';
+ } else {
+ $class = $info['class'];
+ }
+ /** @var ResourceLoaderModule $object */
+ $object = new $class( $info );
+ $object->setConfig( $this->getConfig() );
+ $object->setLogger( $this->logger );
+ }
+ $object->setName( $name );
+ $this->modules[$name] = $object;
+ }
+
+ return $this->modules[$name];
+ }
+
+ /**
+ * Return whether the definition of a module corresponds to a simple ResourceLoaderFileModule
+ * or one of its subclasses.
+ *
+ * @param string $name Module name
+ * @return bool
+ */
+ protected function isFileModule( $name ) {
+ if ( !isset( $this->moduleInfos[$name] ) ) {
+ return false;
+ }
+ $info = $this->moduleInfos[$name];
+ if ( isset( $info['object'] ) ) {
+ return false;
+ }
+ if (
+ isset( $info['class'] ) &&
+ $info['class'] !== 'ResourceLoaderFileModule' &&
+ !is_subclass_of( $info['class'], 'ResourceLoaderFileModule' )
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Get the list of sources.
+ *
+ * @return array Like [ id => load.php url, ... ]
+ */
+ public function getSources() {
+ return $this->sources;
+ }
+
+ /**
+ * Get the URL to the load.php endpoint for the given
+ * ResourceLoader source
+ *
+ * @since 1.24
+ * @param string $source
+ * @throws MWException On an invalid $source name
+ * @return string
+ */
+ public function getLoadScript( $source ) {
+ if ( !isset( $this->sources[$source] ) ) {
+ throw new MWException( "The $source source was never registered in ResourceLoader." );
+ }
+ return $this->sources[$source];
+ }
+
+ /**
+ * @since 1.26
+ * @param string $value
+ * @return string Hash
+ */
+ public static function makeHash( $value ) {
+ $hash = hash( 'fnv132', $value );
+ return Wikimedia\base_convert( $hash, 16, 36, 7 );
+ }
+
+ /**
+ * Add an error to the 'errors' array and log it.
+ *
+ * Should only be called from within respond().
+ *
+ * @since 1.29
+ * @param Exception $e
+ * @param string $msg
+ * @param array $context
+ */
+ protected function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
+ MWExceptionHandler::logException( $e );
+ $this->logger->warning(
+ $msg,
+ $context + [ 'exception' => $e ]
+ );
+ $this->errors[] = self::formatExceptionNoComment( $e );
+ }
+
+ /**
+ * Helper method to get and combine versions of multiple modules.
+ *
+ * @since 1.26
+ * @param ResourceLoaderContext $context
+ * @param string[] $moduleNames List of known module names
+ * @return string Hash
+ */
+ public function getCombinedVersion( ResourceLoaderContext $context, array $moduleNames ) {
+ if ( !$moduleNames ) {
+ return '';
+ }
+ $hashes = array_map( function ( $module ) use ( $context ) {
+ try {
+ return $this->getModule( $module )->getVersionHash( $context );
+ } catch ( Exception $e ) {
+ // If modules fail to compute a version, do still consider the versions
+ // of other modules - don't set an empty string E-Tag for the whole request.
+ // See also T152266 and StartupModule::getModuleRegistrations().
+ $this->outputErrorAndLog( $e,
+ 'Calculating version for "{module}" failed: {exception}',
+ [
+ 'module' => $module,
+ ]
+ );
+ return '';
+ }
+ }, $moduleNames );
+ return self::makeHash( implode( '', $hashes ) );
+ }
+
+ /**
+ * Get the expected value of the 'version' query parameter.
+ *
+ * This is used by respond() to set a short Cache-Control header for requests with
+ * information newer than the current server has. This avoids pollution of edge caches.
+ * Typically during deployment. (T117587)
+ *
+ * This MUST match return value of `mw.loader#getCombinedVersion()` client-side.
+ *
+ * @since 1.28
+ * @param ResourceLoaderContext $context
+ * @param string[] $modules List of module names
+ * @return string Hash
+ */
+ public function makeVersionQuery( ResourceLoaderContext $context ) {
+ // As of MediaWiki 1.28, the server and client use the same algorithm for combining
+ // version hashes. There is no technical reason for this to be same, and for years the
+ // implementations differed. If getCombinedVersion in PHP (used for StartupModule and
+ // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
+ // query parameter), then this method must continue to match the JS one.
+ $moduleNames = [];
+ foreach ( $context->getModules() as $name ) {
+ if ( !$this->getModule( $name ) ) {
+ // If a versioned request contains a missing module, the version is a mismatch
+ // as the client considered a module (and version) we don't have.
+ return '';
+ }
+ $moduleNames[] = $name;
+ }
+ return $this->getCombinedVersion( $context, $moduleNames );
+ }
+
+ /**
+ * Output a response to a load request, including the content-type header.
+ *
+ * @param ResourceLoaderContext $context Context in which a response should be formed
+ */
+ public function respond( ResourceLoaderContext $context ) {
+ // Buffer output to catch warnings. Normally we'd use ob_clean() on the
+ // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
+ // is used: ob_clean() will clear the GZIP header in that case and it won't come
+ // back for subsequent output, resulting in invalid GZIP. So we have to wrap
+ // the whole thing in our own output buffer to be sure the active buffer
+ // doesn't use ob_gzhandler.
+ // See https://bugs.php.net/bug.php?id=36514
+ ob_start();
+
+ // Find out which modules are missing and instantiate the others
+ $modules = [];
+ $missing = [];
+ foreach ( $context->getModules() as $name ) {
+ $module = $this->getModule( $name );
+ if ( $module ) {
+ // Do not allow private modules to be loaded from the web.
+ // This is a security issue, see T36907.
+ if ( $module->getGroup() === 'private' ) {
+ $this->logger->debug( "Request for private module '$name' denied" );
+ $this->errors[] = "Cannot show private module \"$name\"";
+ continue;
+ }
+ $modules[$name] = $module;
+ } else {
+ $missing[] = $name;
+ }
+ }
+
+ try {
+ // Preload for getCombinedVersion() and for batch makeModuleResponse()
+ $this->preloadModuleInfo( array_keys( $modules ), $context );
+ } catch ( Exception $e ) {
+ $this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
+ }
+
+ // Combine versions to propagate cache invalidation
+ $versionHash = '';
+ try {
+ $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
+ } catch ( Exception $e ) {
+ $this->outputErrorAndLog( $e, 'Calculating version hash failed: {exception}' );
+ }
+
+ // See RFC 2616 § 3.11 Entity Tags
+ // https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
+ $etag = 'W/"' . $versionHash . '"';
+
+ // Try the client-side cache first
+ if ( $this->tryRespondNotModified( $context, $etag ) ) {
+ return; // output handled (buffers cleared)
+ }
+
+ // Use file cache if enabled and available...
+ if ( $this->config->get( 'UseFileCache' ) ) {
+ $fileCache = ResourceFileCache::newFromContext( $context );
+ if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
+ return; // output handled
+ }
+ }
+
+ // Generate a response
+ $response = $this->makeModuleResponse( $context, $modules, $missing );
+
+ // Capture any PHP warnings from the output buffer and append them to the
+ // error list if we're in debug mode.
+ if ( $context->getDebug() ) {
+ $warnings = ob_get_contents();
+ if ( strlen( $warnings ) ) {
+ $this->errors[] = $warnings;
+ }
+ }
+
+ // Save response to file cache unless there are errors
+ if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) {
+ // Cache single modules and images...and other requests if there are enough hits
+ if ( ResourceFileCache::useFileCache( $context ) ) {
+ if ( $fileCache->isCacheWorthy() ) {
+ $fileCache->saveText( $response );
+ } else {
+ $fileCache->incrMissesRecent( $context->getRequest() );
+ }
+ }
+ }
+
+ $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders );
+
+ // Remove the output buffer and output the response
+ ob_end_clean();
+
+ if ( $context->getImageObj() && $this->errors ) {
+ // We can't show both the error messages and the response when it's an image.
+ $response = implode( "\n\n", $this->errors );
+ } elseif ( $this->errors ) {
+ $errorText = implode( "\n\n", $this->errors );
+ $errorResponse = self::makeComment( $errorText );
+ if ( $context->shouldIncludeScripts() ) {
+ $errorResponse .= 'if (window.console && console.error) {'
+ . Xml::encodeJsCall( 'console.error', [ $errorText ] )
+ . "}\n";
+ }
+
+ // Prepend error info to the response
+ $response = $errorResponse . $response;
+ }
+
+ $this->errors = [];
+ echo $response;
+ }
+
+ /**
+ * Send main response headers to the client.
+ *
+ * Deals with Content-Type, CORS (for stylesheets), and caching.
+ *
+ * @param ResourceLoaderContext $context
+ * @param string $etag ETag header value
+ * @param bool $errors Whether there are errors in the response
+ * @param string[] $extra Array of extra HTTP response headers
+ * @return void
+ */
+ protected function sendResponseHeaders(
+ ResourceLoaderContext $context, $etag, $errors, array $extra = []
+ ) {
+ \MediaWiki\HeaderCallback::warnIfHeadersSent();
+ $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
+ // Use a short cache expiry so that updates propagate to clients quickly, if:
+ // - No version specified (shared resources, e.g. stylesheets)
+ // - There were errors (recover quickly)
+ // - Version mismatch (T117587, T47877)
+ if ( is_null( $context->getVersion() )
+ || $errors
+ || $context->getVersion() !== $this->makeVersionQuery( $context )
+ ) {
+ $maxage = $rlMaxage['unversioned']['client'];
+ $smaxage = $rlMaxage['unversioned']['server'];
+ // If a version was specified we can use a longer expiry time since changing
+ // version numbers causes cache misses
+ } else {
+ $maxage = $rlMaxage['versioned']['client'];
+ $smaxage = $rlMaxage['versioned']['server'];
+ }
+ if ( $context->getImageObj() ) {
+ // Output different headers if we're outputting textual errors.
+ if ( $errors ) {
+ header( 'Content-Type: text/plain; charset=utf-8' );
+ } else {
+ $context->getImageObj()->sendResponseHeaders( $context );
+ }
+ } elseif ( $context->getOnly() === 'styles' ) {
+ header( 'Content-Type: text/css; charset=utf-8' );
+ header( 'Access-Control-Allow-Origin: *' );
+ } else {
+ header( 'Content-Type: text/javascript; charset=utf-8' );
+ }
+ // See RFC 2616 § 14.19 ETag
+ // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
+ header( 'ETag: ' . $etag );
+ if ( $context->getDebug() ) {
+ // Do not cache debug responses
+ header( 'Cache-Control: private, no-cache, must-revalidate' );
+ header( 'Pragma: no-cache' );
+ } else {
+ header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
+ $exp = min( $maxage, $smaxage );
+ header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
+ }
+ foreach ( $extra as $header ) {
+ header( $header );
+ }
+ }
+
+ /**
+ * Respond with HTTP 304 Not Modified if appropiate.
+ *
+ * If there's an If-None-Match header, respond with a 304 appropriately
+ * and clear out the output buffer. If the client cache is too old then do nothing.
+ *
+ * @param ResourceLoaderContext $context
+ * @param string $etag ETag header value
+ * @return bool True if HTTP 304 was sent and output handled
+ */
+ protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
+ // See RFC 2616 § 14.26 If-None-Match
+ // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
+ $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
+ // Never send 304s in debug mode
+ if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
+ // There's another bug in ob_gzhandler (see also the comment at
+ // the top of this function) that causes it to gzip even empty
+ // responses, meaning it's impossible to produce a truly empty
+ // response (because the gzip header is always there). This is
+ // a problem because 304 responses have to be completely empty
+ // per the HTTP spec, and Firefox behaves buggily when they're not.
+ // See also https://bugs.php.net/bug.php?id=51579
+ // To work around this, we tear down all output buffering before
+ // sending the 304.
+ wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
+
+ HttpStatus::header( 304 );
+
+ $this->sendResponseHeaders( $context, $etag, false );
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Send out code for a response from file cache if possible.
+ *
+ * @param ResourceFileCache $fileCache Cache object for this request URL
+ * @param ResourceLoaderContext $context Context in which to generate a response
+ * @param string $etag ETag header value
+ * @return bool If this found a cache file and handled the response
+ */
+ protected function tryRespondFromFileCache(
+ ResourceFileCache $fileCache,
+ ResourceLoaderContext $context,
+ $etag
+ ) {
+ $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
+ // Buffer output to catch warnings.
+ ob_start();
+ // Get the maximum age the cache can be
+ $maxage = is_null( $context->getVersion() )
+ ? $rlMaxage['unversioned']['server']
+ : $rlMaxage['versioned']['server'];
+ // Minimum timestamp the cache file must have
+ $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
+ if ( !$good ) {
+ try { // RL always hits the DB on file cache miss...
+ wfGetDB( DB_REPLICA );
+ } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
+ $good = $fileCache->isCacheGood(); // cache existence check
+ }
+ }
+ if ( $good ) {
+ $ts = $fileCache->cacheTimestamp();
+ // Send content type and cache headers
+ $this->sendResponseHeaders( $context, $etag, false );
+ $response = $fileCache->fetchText();
+ // Capture any PHP warnings from the output buffer and append them to the
+ // response in a comment if we're in debug mode.
+ if ( $context->getDebug() ) {
+ $warnings = ob_get_contents();
+ if ( strlen( $warnings ) ) {
+ $response = self::makeComment( $warnings ) . $response;
+ }
+ }
+ // Remove the output buffer and output the response
+ ob_end_clean();
+ echo $response . "\n/* Cached {$ts} */";
+ return true; // cache hit
+ }
+ // Clear buffer
+ ob_end_clean();
+
+ return false; // cache miss
+ }
+
+ /**
+ * Generate a CSS or JS comment block.
+ *
+ * Only use this for public data, not error message details.
+ *
+ * @param string $text
+ * @return string
+ */
+ public static function makeComment( $text ) {
+ $encText = str_replace( '*/', '* /', $text );
+ return "/*\n$encText\n*/\n";
+ }
+
+ /**
+ * Handle exception display.
+ *
+ * @param Exception $e Exception to be shown to the user
+ * @return string Sanitized text in a CSS/JS comment that can be returned to the user
+ */
+ public static function formatException( $e ) {
+ return self::makeComment( self::formatExceptionNoComment( $e ) );
+ }
+
+ /**
+ * Handle exception display.
+ *
+ * @since 1.25
+ * @param Exception $e Exception to be shown to the user
+ * @return string Sanitized text that can be returned to the user
+ */
+ protected static function formatExceptionNoComment( $e ) {
+ global $wgShowExceptionDetails;
+
+ if ( !$wgShowExceptionDetails ) {
+ return MWExceptionHandler::getPublicLogMessage( $e );
+ }
+
+ return MWExceptionHandler::getLogMessage( $e ) .
+ "\nBacktrace:\n" .
+ MWExceptionHandler::getRedactedTraceAsString( $e );
+ }
+
+ /**
+ * Generate code for a response.
+ *
+ * Calling this method also populates the `errors` and `headers` members,
+ * later used by respond().
+ *
+ * @param ResourceLoaderContext $context Context in which to generate a response
+ * @param ResourceLoaderModule[] $modules List of module objects keyed by module name
+ * @param string[] $missing List of requested module names that are unregistered (optional)
+ * @return string Response data
+ */
+ public function makeModuleResponse( ResourceLoaderContext $context,
+ array $modules, array $missing = []
+ ) {
+ $out = '';
+ $states = [];
+
+ if ( !count( $modules ) && !count( $missing ) ) {
+ return <<<MESSAGE
+/* This file is the Web entry point for MediaWiki's ResourceLoader:
+ <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
+ no modules were requested. Max made me put this here. */
+MESSAGE;
+ }
+
+ $image = $context->getImageObj();
+ if ( $image ) {
+ $data = $image->getImageData( $context );
+ if ( $data === false ) {
+ $data = '';
+ $this->errors[] = 'Image generation failed';
+ }
+ return $data;
+ }
+
+ foreach ( $missing as $name ) {
+ $states[$name] = 'missing';
+ }
+
+ // Generate output
+ $isRaw = false;
+
+ $filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
+
+ foreach ( $modules as $name => $module ) {
+ try {
+ $content = $module->getModuleContent( $context );
+ $implementKey = $name . '@' . $module->getVersionHash( $context );
+ $strContent = '';
+
+ if ( isset( $content['headers'] ) ) {
+ $this->extraHeaders = array_merge( $this->extraHeaders, $content['headers'] );
+ }
+
+ // Append output
+ switch ( $context->getOnly() ) {
+ case 'scripts':
+ $scripts = $content['scripts'];
+ if ( is_string( $scripts ) ) {
+ // Load scripts raw...
+ $strContent = $scripts;
+ } elseif ( is_array( $scripts ) ) {
+ // ...except when $scripts is an array of URLs
+ $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
+ }
+ break;
+ case 'styles':
+ $styles = $content['styles'];
+ // We no longer seperate into media, they are all combined now with
+ // custom media type groups into @media .. {} sections as part of the css string.
+ // Module returns either an empty array or a numerical array with css strings.
+ $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
+ break;
+ default:
+ $scripts = isset( $content['scripts'] ) ? $content['scripts'] : '';
+ if ( is_string( $scripts ) ) {
+ if ( $name === 'site' || $name === 'user' ) {
+ // Legacy scripts that run in the global scope without a closure.
+ // mw.loader.implement will use globalEval if scripts is a string.
+ // Minify manually here, because general response minification is
+ // not effective due it being a string literal, not a function.
+ if ( !self::inDebugMode() ) {
+ $scripts = self::filter( 'minify-js', $scripts ); // T107377
+ }
+ } else {
+ $scripts = new XmlJsCode( $scripts );
+ }
+ }
+ $strContent = self::makeLoaderImplementScript(
+ $implementKey,
+ $scripts,
+ isset( $content['styles'] ) ? $content['styles'] : [],
+ isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
+ isset( $content['templates'] ) ? $content['templates'] : []
+ );
+ break;
+ }
+
+ if ( !$context->getDebug() ) {
+ $strContent = self::filter( $filter, $strContent );
+ }
+
+ if ( $context->getOnly() === 'scripts' ) {
+ // Use a linebreak between module scripts (T162719)
+ $out .= $this->ensureNewline( $strContent );
+ } else {
+ $out .= $strContent;
+ }
+
+ } catch ( Exception $e ) {
+ $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
+
+ // Respond to client with error-state instead of module implementation
+ $states[$name] = 'error';
+ unset( $modules[$name] );
+ }
+ $isRaw |= $module->isRaw();
+ }
+
+ // Update module states
+ if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
+ if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
+ // Set the state of modules loaded as only scripts to ready as
+ // they don't have an mw.loader.implement wrapper that sets the state
+ foreach ( $modules as $name => $module ) {
+ $states[$name] = 'ready';
+ }
+ }
+
+ // Set the state of modules we didn't respond to with mw.loader.implement
+ if ( count( $states ) ) {
+ $stateScript = self::makeLoaderStateScript( $states );
+ if ( !$context->getDebug() ) {
+ $stateScript = self::filter( 'minify-js', $stateScript );
+ }
+ // Use a linebreak between module script and state script (T162719)
+ $out = $this->ensureNewline( $out ) . $stateScript;
+ }
+ } else {
+ if ( count( $states ) ) {
+ $this->errors[] = 'Problematic modules: ' .
+ FormatJson::encode( $states, self::inDebugMode() );
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Ensure the string is either empty or ends in a line break
+ * @param string $str
+ * @return string
+ */
+ private function ensureNewline( $str ) {
+ $end = substr( $str, -1 );
+ if ( $end === false || $end === "\n" ) {
+ return $str;
+ }
+ return $str . "\n";
+ }
+
+ /**
+ * Get names of modules that use a certain message.
+ *
+ * @param string $messageKey
+ * @return array List of module names
+ */
+ public function getModulesByMessage( $messageKey ) {
+ $moduleNames = [];
+ foreach ( $this->getModuleNames() as $moduleName ) {
+ $module = $this->getModule( $moduleName );
+ if ( in_array( $messageKey, $module->getMessages() ) ) {
+ $moduleNames[] = $moduleName;
+ }
+ }
+ return $moduleNames;
+ }
+
+ /* Static Methods */
+
+ /**
+ * Return JS code that calls mw.loader.implement with given module properties.
+ *
+ * @param string $name Module name or implement key (format "`[name]@[version]`")
+ * @param XmlJsCode|array|string $scripts Code as XmlJsCode (to be wrapped in a closure),
+ * list of URLs to JavaScript files, or a string of JavaScript for `$.globalEval`.
+ * @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
+ * to CSS files keyed by media type
+ * @param mixed $messages List of messages associated with this module. May either be an
+ * associative array mapping message key to value, or a JSON-encoded message blob containing
+ * the same data, wrapped in an XmlJsCode object.
+ * @param array $templates Keys are name of templates and values are the source of
+ * the template.
+ * @throws MWException
+ * @return string JavaScript code
+ */
+ protected static function makeLoaderImplementScript(
+ $name, $scripts, $styles, $messages, $templates
+ ) {
+ if ( $scripts instanceof XmlJsCode ) {
+ $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
+ } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
+ throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
+ }
+ // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
+ // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
+ // of "{}". Force them to objects.
+ $module = [
+ $name,
+ $scripts,
+ (object)$styles,
+ (object)$messages,
+ (object)$templates,
+ ];
+ self::trimArray( $module );
+
+ return Xml::encodeJsCall( 'mw.loader.implement', $module, self::inDebugMode() );
+ }
+
+ /**
+ * Returns JS code which, when called, will register a given list of messages.
+ *
+ * @param mixed $messages Either an associative array mapping message key to value, or a
+ * JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object.
+ * @return string JavaScript code
+ */
+ public static function makeMessageSetScript( $messages ) {
+ return Xml::encodeJsCall(
+ 'mw.messages.set',
+ [ (object)$messages ],
+ self::inDebugMode()
+ );
+ }
+
+ /**
+ * Combines an associative array mapping media type to CSS into a
+ * single stylesheet with "@media" blocks.
+ *
+ * @param array $stylePairs Array keyed by media type containing (arrays of) CSS strings
+ * @return array
+ */
+ public static function makeCombinedStyles( array $stylePairs ) {
+ $out = [];
+ foreach ( $stylePairs as $media => $styles ) {
+ // ResourceLoaderFileModule::getStyle can return the styles
+ // as a string or an array of strings. This is to allow separation in
+ // the front-end.
+ $styles = (array)$styles;
+ foreach ( $styles as $style ) {
+ $style = trim( $style );
+ // Don't output an empty "@media print { }" block (T42498)
+ if ( $style !== '' ) {
+ // Transform the media type based on request params and config
+ // The way that this relies on $wgRequest to propagate request params is slightly evil
+ $media = OutputPage::transformCssMedia( $media );
+
+ if ( $media === '' || $media == 'all' ) {
+ $out[] = $style;
+ } elseif ( is_string( $media ) ) {
+ $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
+ }
+ // else: skip
+ }
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Returns a JS call to mw.loader.state, which sets the state of a
+ * module or modules to a given value. Has two calling conventions:
+ *
+ * - ResourceLoader::makeLoaderStateScript( $name, $state ):
+ * Set the state of a single module called $name to $state
+ *
+ * - ResourceLoader::makeLoaderStateScript( [ $name => $state, ... ] ):
+ * Set the state of modules with the given names to the given states
+ *
+ * @param string $name
+ * @param string $state
+ * @return string JavaScript code
+ */
+ public static function makeLoaderStateScript( $name, $state = null ) {
+ if ( is_array( $name ) ) {
+ return Xml::encodeJsCall(
+ 'mw.loader.state',
+ [ $name ],
+ self::inDebugMode()
+ );
+ } else {
+ return Xml::encodeJsCall(
+ 'mw.loader.state',
+ [ $name, $state ],
+ self::inDebugMode()
+ );
+ }
+ }
+
+ /**
+ * Returns JS code which calls the script given by $script. The script will
+ * be called with local variables name, version, dependencies and group,
+ * which will have values corresponding to $name, $version, $dependencies
+ * and $group as supplied.
+ *
+ * @param string $name Module name
+ * @param string $version Module version hash
+ * @param array $dependencies List of module names on which this module depends
+ * @param string $group Group which the module is in.
+ * @param string $source Source of the module, or 'local' if not foreign.
+ * @param string $script JavaScript code
+ * @return string JavaScript code
+ */
+ public static function makeCustomLoaderScript( $name, $version, $dependencies,
+ $group, $source, $script
+ ) {
+ $script = str_replace( "\n", "\n\t", trim( $script ) );
+ return Xml::encodeJsCall(
+ "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
+ [ $name, $version, $dependencies, $group, $source ],
+ self::inDebugMode()
+ );
+ }
+
+ private static function isEmptyObject( stdClass $obj ) {
+ foreach ( $obj as $key => $value ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Remove empty values from the end of an array.
+ *
+ * Values considered empty:
+ *
+ * - null
+ * - []
+ * - new XmlJsCode( '{}' )
+ * - new stdClass() // (object) []
+ *
+ * @param Array $array
+ */
+ private static function trimArray( array &$array ) {
+ $i = count( $array );
+ while ( $i-- ) {
+ if ( $array[$i] === null
+ || $array[$i] === []
+ || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
+ || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
+ ) {
+ unset( $array[$i] );
+ } else {
+ break;
+ }
+ }
+ }
+
+ /**
+ * Returns JS code which calls mw.loader.register with the given
+ * parameters. Has three calling conventions:
+ *
+ * - ResourceLoader::makeLoaderRegisterScript( $name, $version,
+ * $dependencies, $group, $source, $skip
+ * ):
+ * Register a single module.
+ *
+ * - ResourceLoader::makeLoaderRegisterScript( [ $name1, $name2 ] ):
+ * Register modules with the given names.
+ *
+ * - ResourceLoader::makeLoaderRegisterScript( [
+ * [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
+ * [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
+ * ...
+ * ] ):
+ * Registers modules with the given names and parameters.
+ *
+ * @param string $name Module name
+ * @param string $version Module version hash
+ * @param array $dependencies List of module names on which this module depends
+ * @param string $group Group which the module is in
+ * @param string $source Source of the module, or 'local' if not foreign
+ * @param string $skip Script body of the skip function
+ * @return string JavaScript code
+ */
+ public static function makeLoaderRegisterScript( $name, $version = null,
+ $dependencies = null, $group = null, $source = null, $skip = null
+ ) {
+ if ( is_array( $name ) ) {
+ // Build module name index
+ $index = [];
+ foreach ( $name as $i => &$module ) {
+ $index[$module[0]] = $i;
+ }
+
+ // Transform dependency names into indexes when possible, they will be resolved by
+ // mw.loader.register on the other end
+ foreach ( $name as &$module ) {
+ if ( isset( $module[2] ) ) {
+ foreach ( $module[2] as &$dependency ) {
+ if ( isset( $index[$dependency] ) ) {
+ $dependency = $index[$dependency];
+ }
+ }
+ }
+ }
+
+ array_walk( $name, [ 'self', 'trimArray' ] );
+
+ return Xml::encodeJsCall(
+ 'mw.loader.register',
+ [ $name ],
+ self::inDebugMode()
+ );
+ } else {
+ $registration = [ $name, $version, $dependencies, $group, $source, $skip ];
+ self::trimArray( $registration );
+ return Xml::encodeJsCall(
+ 'mw.loader.register',
+ $registration,
+ self::inDebugMode()
+ );
+ }
+ }
+
+ /**
+ * Returns JS code which calls mw.loader.addSource() with the given
+ * parameters. Has two calling conventions:
+ *
+ * - ResourceLoader::makeLoaderSourcesScript( $id, $properties ):
+ * Register a single source
+ *
+ * - ResourceLoader::makeLoaderSourcesScript( [ $id1 => $loadUrl, $id2 => $loadUrl, ... ] );
+ * Register sources with the given IDs and properties.
+ *
+ * @param string $id Source ID
+ * @param string $loadUrl load.php url
+ * @return string JavaScript code
+ */
+ public static function makeLoaderSourcesScript( $id, $loadUrl = null ) {
+ if ( is_array( $id ) ) {
+ return Xml::encodeJsCall(
+ 'mw.loader.addSource',
+ [ $id ],
+ self::inDebugMode()
+ );
+ } else {
+ return Xml::encodeJsCall(
+ 'mw.loader.addSource',
+ [ $id, $loadUrl ],
+ self::inDebugMode()
+ );
+ }
+ }
+
+ /**
+ * Returns JS code which runs given JS code if the client-side framework is
+ * present.
+ *
+ * @deprecated since 1.25; use makeInlineScript instead
+ * @param string $script JavaScript code
+ * @return string JavaScript code
+ */
+ public static function makeLoaderConditionalScript( $script ) {
+ return '(window.RLQ=window.RLQ||[]).push(function(){' .
+ trim( $script ) . '});';
+ }
+
+ /**
+ * Construct an inline script tag with given JS code.
+ *
+ * The code will be wrapped in a closure, and it will be executed by ResourceLoader
+ * only if the client has adequate support for MediaWiki JavaScript code.
+ *
+ * @param string $script JavaScript code
+ * @return WrappedString HTML
+ */
+ public static function makeInlineScript( $script ) {
+ $js = self::makeLoaderConditionalScript( $script );
+ return new WrappedString(
+ Html::inlineScript( $js ),
+ '<script>(window.RLQ=window.RLQ||[]).push(function(){',
+ '});</script>'
+ );
+ }
+
+ /**
+ * Returns JS code which will set the MediaWiki configuration array to
+ * the given value.
+ *
+ * @param array $configuration List of configuration values keyed by variable name
+ * @return string JavaScript code
+ */
+ public static function makeConfigSetScript( array $configuration ) {
+ return Xml::encodeJsCall(
+ 'mw.config.set',
+ [ $configuration ],
+ self::inDebugMode()
+ );
+ }
+
+ /**
+ * Convert an array of module names to a packed query string.
+ *
+ * For example, [ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]
+ * becomes 'foo.bar,baz|bar.baz,quux'
+ * @param array $modules List of module names (strings)
+ * @return string Packed query string
+ */
+ public static function makePackedModulesString( $modules ) {
+ $groups = []; // [ prefix => [ suffixes ] ]
+ foreach ( $modules as $module ) {
+ $pos = strrpos( $module, '.' );
+ $prefix = $pos === false ? '' : substr( $module, 0, $pos );
+ $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
+ $groups[$prefix][] = $suffix;
+ }
+
+ $arr = [];
+ foreach ( $groups as $prefix => $suffixes ) {
+ $p = $prefix === '' ? '' : $prefix . '.';
+ $arr[] = $p . implode( ',', $suffixes );
+ }
+ $str = implode( '|', $arr );
+ return $str;
+ }
+
+ /**
+ * Determine whether debug mode was requested
+ * Order of priority is 1) request param, 2) cookie, 3) $wg setting
+ * @return bool
+ */
+ public static function inDebugMode() {
+ if ( self::$debugMode === null ) {
+ global $wgRequest, $wgResourceLoaderDebug;
+ self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
+ $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
+ );
+ }
+ return self::$debugMode;
+ }
+
+ /**
+ * Reset static members used for caching.
+ *
+ * Global state and $wgRequest are evil, but we're using it right
+ * now and sometimes we need to be able to force ResourceLoader to
+ * re-evaluate the context because it has changed (e.g. in the test suite).
+ */
+ public static function clearCache() {
+ self::$debugMode = null;
+ }
+
+ /**
+ * Build a load.php URL
+ *
+ * @since 1.24
+ * @param string $source Name of the ResourceLoader source
+ * @param ResourceLoaderContext $context
+ * @param array $extraQuery
+ * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
+ */
+ public function createLoaderURL( $source, ResourceLoaderContext $context,
+ $extraQuery = []
+ ) {
+ $query = self::createLoaderQuery( $context, $extraQuery );
+ $script = $this->getLoadScript( $source );
+
+ return wfAppendQuery( $script, $query );
+ }
+
+ /**
+ * Helper for createLoaderURL()
+ *
+ * @since 1.24
+ * @see makeLoaderQuery
+ * @param ResourceLoaderContext $context
+ * @param array $extraQuery
+ * @return array
+ */
+ protected static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = [] ) {
+ return self::makeLoaderQuery(
+ $context->getModules(),
+ $context->getLanguage(),
+ $context->getSkin(),
+ $context->getUser(),
+ $context->getVersion(),
+ $context->getDebug(),
+ $context->getOnly(),
+ $context->getRequest()->getBool( 'printable' ),
+ $context->getRequest()->getBool( 'handheld' ),
+ $extraQuery
+ );
+ }
+
+ /**
+ * Build a query array (array representation of query string) for load.php. Helper
+ * function for createLoaderURL().
+ *
+ * @param array $modules
+ * @param string $lang
+ * @param string $skin
+ * @param string $user
+ * @param string $version
+ * @param bool $debug
+ * @param string $only
+ * @param bool $printable
+ * @param bool $handheld
+ * @param array $extraQuery
+ *
+ * @return array
+ */
+ public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
+ $version = null, $debug = false, $only = null, $printable = false,
+ $handheld = false, $extraQuery = []
+ ) {
+ $query = [
+ 'modules' => self::makePackedModulesString( $modules ),
+ 'lang' => $lang,
+ 'skin' => $skin,
+ 'debug' => $debug ? 'true' : 'false',
+ ];
+ if ( $user !== null ) {
+ $query['user'] = $user;
+ }
+ if ( $version !== null ) {
+ $query['version'] = $version;
+ }
+ if ( $only !== null ) {
+ $query['only'] = $only;
+ }
+ if ( $printable ) {
+ $query['printable'] = 1;
+ }
+ if ( $handheld ) {
+ $query['handheld'] = 1;
+ }
+ $query += $extraQuery;
+
+ // Make queries uniform in order
+ ksort( $query );
+ return $query;
+ }
+
+ /**
+ * Check a module name for validity.
+ *
+ * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
+ * at most 255 bytes.
+ *
+ * @param string $moduleName Module name to check
+ * @return bool Whether $moduleName is a valid module name
+ */
+ public static function isValidModuleName( $moduleName ) {
+ return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName );
+ }
+
+ /**
+ * Returns LESS compiler set up for use with MediaWiki
+ *
+ * @since 1.27
+ * @param array $extraVars Associative array of extra (i.e., other than the
+ * globally-configured ones) that should be used for compilation.
+ * @throws MWException
+ * @return Less_Parser
+ */
+ public function getLessCompiler( $extraVars = [] ) {
+ // When called from the installer, it is possible that a required PHP extension
+ // is missing (at least for now; see T49564). If this is the case, throw an
+ // exception (caught by the installer) to prevent a fatal error later on.
+ if ( !class_exists( 'Less_Parser' ) ) {
+ throw new MWException( 'MediaWiki requires the less.php parser' );
+ }
+
+ $parser = new Less_Parser;
+ $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) );
+ $parser->SetImportDirs(
+ array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' )
+ );
+ $parser->SetOption( 'relativeUrls', false );
+
+ return $parser;
+ }
+
+ /**
+ * Get global LESS variables.
+ *
+ * @since 1.27
+ * @return array Map of variable names to string CSS values.
+ */
+ public function getLessVars() {
+ if ( !$this->lessVars ) {
+ $lessVars = $this->config->get( 'ResourceLoaderLESSVars' );
+ Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] );
+ $this->lessVars = $lessVars;
+ }
+ return $this->lessVars;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderClientHtml.php b/www/wiki/includes/resourceloader/ResourceLoaderClientHtml.php
new file mode 100644
index 00000000..06f9841d
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderClientHtml.php
@@ -0,0 +1,467 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use WrappedString\WrappedStringList;
+
+/**
+ * Bootstrap a ResourceLoader client on an HTML page.
+ *
+ * @since 1.28
+ */
+class ResourceLoaderClientHtml {
+
+ /** @var ResourceLoaderContext */
+ private $context;
+
+ /** @var ResourceLoader */
+ private $resourceLoader;
+
+ /** @var string|null */
+ private $target;
+
+ /** @var array */
+ private $config = [];
+
+ /** @var array */
+ private $modules = [];
+
+ /** @var array */
+ private $moduleStyles = [];
+
+ /** @var array */
+ private $moduleScripts = [];
+
+ /** @var array */
+ private $exemptStates = [];
+
+ /** @var array */
+ private $data;
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @param string|null $target [optional] Custom 'target' parameter for the startup module
+ */
+ public function __construct( ResourceLoaderContext $context, $target = null ) {
+ $this->context = $context;
+ $this->resourceLoader = $context->getResourceLoader();
+ $this->target = $target;
+ }
+
+ /**
+ * Set mw.config variables.
+ *
+ * @param array $vars Array of key/value pairs
+ */
+ public function setConfig( array $vars ) {
+ foreach ( $vars as $key => $value ) {
+ $this->config[$key] = $value;
+ }
+ }
+
+ /**
+ * Ensure one or more modules are loaded.
+ *
+ * @param array $modules Array of module names
+ */
+ public function setModules( array $modules ) {
+ $this->modules = $modules;
+ }
+
+ /**
+ * Ensure the styles of one or more modules are loaded.
+ *
+ * @deprecated since 1.28
+ * @param array $modules Array of module names
+ */
+ public function setModuleStyles( array $modules ) {
+ $this->moduleStyles = $modules;
+ }
+
+ /**
+ * Ensure the scripts of one or more modules are loaded.
+ *
+ * @deprecated since 1.28
+ * @param array $modules Array of module names
+ */
+ public function setModuleScripts( array $modules ) {
+ $this->moduleScripts = $modules;
+ }
+
+ /**
+ * Set state of special modules that are handled by the caller manually.
+ *
+ * See OutputPage::buildExemptModules() for use cases.
+ *
+ * @param array $states Module state keyed by module name
+ */
+ public function setExemptStates( array $states ) {
+ $this->exemptStates = $states;
+ }
+
+ /**
+ * @return array
+ */
+ private function getData() {
+ if ( $this->data ) {
+ // @codeCoverageIgnoreStart
+ return $this->data;
+ // @codeCoverageIgnoreEnd
+ }
+
+ $rl = $this->resourceLoader;
+ $data = [
+ 'states' => [
+ // moduleName => state
+ ],
+ 'general' => [],
+ 'styles' => [
+ // moduleName
+ ],
+ 'scripts' => [],
+ // Embedding for private modules
+ 'embed' => [
+ 'styles' => [],
+ 'general' => [],
+ ],
+
+ ];
+
+ foreach ( $this->modules as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ continue;
+ }
+
+ if ( $module->shouldEmbedModule( $this->context ) ) {
+ // Embed via mw.loader.implement per T36907.
+ $data['embed']['general'][] = $name;
+ // Avoid duplicate request from mw.loader
+ $data['states'][$name] = 'loading';
+ } else {
+ // Load via mw.loader.load()
+ $data['general'][] = $name;
+ }
+ }
+
+ foreach ( $this->moduleStyles as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ continue;
+ }
+
+ if ( $module->getType() !== ResourceLoaderModule::LOAD_STYLES ) {
+ $logger = $rl->getLogger();
+ $logger->error( 'Unexpected general module "{module}" in styles queue.', [
+ 'module' => $name,
+ ] );
+ continue;
+ }
+
+ // Stylesheet doesn't trigger mw.loader callback.
+ // Set "ready" state to allow dependencies and avoid duplicate requests. (T87871)
+ $data['states'][$name] = 'ready';
+
+ $group = $module->getGroup();
+ $context = $this->getContext( $group, ResourceLoaderModule::TYPE_STYLES );
+ if ( $module->isKnownEmpty( $context ) ) {
+ // Avoid needless request for empty module
+ $data['states'][$name] = 'ready';
+ } else {
+ if ( $module->shouldEmbedModule( $this->context ) ) {
+ // Embed via style element
+ $data['embed']['styles'][] = $name;
+ // Avoid duplicate request from mw.loader
+ $data['states'][$name] = 'ready';
+ } else {
+ // Load from load.php?only=styles via <link rel=stylesheet>
+ $data['styles'][] = $name;
+ }
+ }
+ }
+
+ foreach ( $this->moduleScripts as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ continue;
+ }
+
+ $group = $module->getGroup();
+ $context = $this->getContext( $group, ResourceLoaderModule::TYPE_SCRIPTS );
+ if ( $module->isKnownEmpty( $context ) ) {
+ // Avoid needless request for empty module
+ $data['states'][$name] = 'ready';
+ } else {
+ // Load from load.php?only=scripts via <script src></script>
+ $data['scripts'][] = $name;
+
+ // Avoid duplicate request from mw.loader
+ $data['states'][$name] = 'loading';
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @return array Attribute key-value pairs for the HTML document element
+ */
+ public function getDocumentAttributes() {
+ return [ 'class' => 'client-nojs' ];
+ }
+
+ /**
+ * The order of elements in the head is as follows:
+ * - Inline scripts.
+ * - Stylesheets.
+ * - Async external script-src.
+ *
+ * Reasons:
+ * - Script execution may be blocked on preceeding stylesheets.
+ * - Async scripts are not blocked on stylesheets.
+ * - Inline scripts can't be asynchronous.
+ * - For styles, earlier is better.
+ *
+ * @return string|WrappedStringList HTML
+ */
+ public function getHeadHtml() {
+ $data = $this->getData();
+ $chunks = [];
+
+ // Change "client-nojs" class to client-js. This allows easy toggling of UI components.
+ // This happens synchronously on every page view to avoid flashes of wrong content.
+ // See also #getDocumentAttributes() and /resources/src/startup.js.
+ $chunks[] = Html::inlineScript(
+ 'document.documentElement.className = document.documentElement.className'
+ . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );'
+ );
+
+ // Inline RLQ: Set page variables
+ if ( $this->config ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ ResourceLoader::makeConfigSetScript( $this->config )
+ );
+ }
+
+ // Inline RLQ: Initial module states
+ $states = array_merge( $this->exemptStates, $data['states'] );
+ if ( $states ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ ResourceLoader::makeLoaderStateScript( $states )
+ );
+ }
+
+ // Inline RLQ: Embedded modules
+ if ( $data['embed']['general'] ) {
+ $chunks[] = $this->getLoad(
+ $data['embed']['general'],
+ ResourceLoaderModule::TYPE_COMBINED
+ );
+ }
+
+ // Inline RLQ: Load general modules
+ if ( $data['general'] ) {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ Xml::encodeJsCall( 'mw.loader.load', [ $data['general'] ] )
+ );
+ }
+
+ // Inline RLQ: Load only=scripts
+ if ( $data['scripts'] ) {
+ $chunks[] = $this->getLoad(
+ $data['scripts'],
+ ResourceLoaderModule::TYPE_SCRIPTS
+ );
+ }
+
+ // External stylesheets
+ if ( $data['styles'] ) {
+ $chunks[] = $this->getLoad(
+ $data['styles'],
+ ResourceLoaderModule::TYPE_STYLES
+ );
+ }
+
+ // Inline stylesheets (embedded only=styles)
+ if ( $data['embed']['styles'] ) {
+ $chunks[] = $this->getLoad(
+ $data['embed']['styles'],
+ ResourceLoaderModule::TYPE_STYLES
+ );
+ }
+
+ // Async scripts. Once the startup is loaded, inline RLQ scripts will run.
+ // Pass-through a custom target from OutputPage (T143066).
+ $startupQuery = $this->target ? [ 'target' => $this->target ] : [];
+ $chunks[] = $this->getLoad(
+ 'startup',
+ ResourceLoaderModule::TYPE_SCRIPTS,
+ $startupQuery
+ );
+
+ return WrappedStringList::join( "\n", $chunks );
+ }
+
+ /**
+ * @return string|WrappedStringList HTML
+ */
+ public function getBodyHtml() {
+ return '';
+ }
+
+ private function getContext( $group, $type ) {
+ return self::makeContext( $this->context, $group, $type );
+ }
+
+ private function getLoad( $modules, $only, array $extraQuery = [] ) {
+ return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery );
+ }
+
+ private static function makeContext( ResourceLoaderContext $mainContext, $group, $type,
+ array $extraQuery = []
+ ) {
+ // Create new ResourceLoaderContext so that $extraQuery may trigger isRaw().
+ $req = new FauxRequest( array_merge( $mainContext->getRequest()->getValues(), $extraQuery ) );
+ // Set 'only' if not combined
+ $req->setVal( 'only', $type === ResourceLoaderModule::TYPE_COMBINED ? null : $type );
+ // Remove user parameter in most cases
+ if ( $group !== 'user' && $group !== 'private' ) {
+ $req->setVal( 'user', null );
+ }
+ $context = new ResourceLoaderContext( $mainContext->getResourceLoader(), $req );
+ // Allow caller to setVersion() and setModules()
+ return new DerivativeResourceLoaderContext( $context );
+ }
+
+ /**
+ * Explicily load or embed modules on a page.
+ *
+ * @param ResourceLoaderContext $mainContext
+ * @param array $modules One or more module names
+ * @param string $only ResourceLoaderModule TYPE_ class constant
+ * @param array $extraQuery [optional] Array with extra query parameters for the request
+ * @return string|WrappedStringList HTML
+ */
+ public static function makeLoad( ResourceLoaderContext $mainContext, array $modules, $only,
+ array $extraQuery = []
+ ) {
+ $rl = $mainContext->getResourceLoader();
+ $chunks = [];
+
+ // Sort module names so requests are more uniform
+ sort( $modules );
+
+ if ( $mainContext->getDebug() && count( $modules ) > 1 ) {
+ $chunks = [];
+ // Recursively call us for every item
+ foreach ( $modules as $name ) {
+ $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery );
+ }
+ return new WrappedStringList( "\n", $chunks );
+ }
+
+ // Create keyed-by-source and then keyed-by-group list of module objects from modules list
+ $sortedModules = [];
+ foreach ( $modules as $name ) {
+ $module = $rl->getModule( $name );
+ if ( !$module ) {
+ $rl->getLogger()->warning( 'Unknown module "{module}"', [ 'module' => $name ] );
+ continue;
+ }
+ $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module;
+ }
+
+ foreach ( $sortedModules as $source => $groups ) {
+ foreach ( $groups as $group => $grpModules ) {
+ $context = self::makeContext( $mainContext, $group, $only, $extraQuery );
+
+ // Separate sets of linked and embedded modules while preserving order
+ $moduleSets = [];
+ $idx = -1;
+ foreach ( $grpModules as $name => $module ) {
+ $shouldEmbed = $module->shouldEmbedModule( $context );
+ if ( !$moduleSets || $moduleSets[$idx][0] !== $shouldEmbed ) {
+ $moduleSets[++$idx] = [ $shouldEmbed, [] ];
+ }
+ $moduleSets[$idx][1][$name] = $module;
+ }
+
+ // Link/embed each set
+ foreach ( $moduleSets as list( $embed, $moduleSet ) ) {
+ $context->setModules( array_keys( $moduleSet ) );
+ if ( $embed ) {
+ // Decide whether to use style or script element
+ if ( $only == ResourceLoaderModule::TYPE_STYLES ) {
+ $chunks[] = Html::inlineStyle(
+ $rl->makeModuleResponse( $context, $moduleSet )
+ );
+ } else {
+ $chunks[] = ResourceLoader::makeInlineScript(
+ $rl->makeModuleResponse( $context, $moduleSet )
+ );
+ }
+ } else {
+ // See if we have one or more raw modules
+ $isRaw = false;
+ foreach ( $moduleSet as $key => $module ) {
+ $isRaw |= $module->isRaw();
+ }
+
+ // Special handling for the user group; because users might change their stuff
+ // on-wiki like user pages, or user preferences; we need to find the highest
+ // timestamp of these user-changeable modules so we can ensure cache misses on change
+ // This should NOT be done for the site group (T29564) because anons get that too
+ // and we shouldn't be putting timestamps in CDN-cached HTML
+ if ( $group === 'user' ) {
+ // Must setModules() before makeVersionQuery()
+ $context->setVersion( $rl->makeVersionQuery( $context ) );
+ }
+
+ $url = $rl->createLoaderURL( $source, $context, $extraQuery );
+
+ // Decide whether to use 'style' or 'script' element
+ if ( $only === ResourceLoaderModule::TYPE_STYLES ) {
+ $chunk = Html::linkedStyle( $url );
+ } else {
+ if ( $context->getRaw() || $isRaw ) {
+ $chunk = Html::element( 'script', [
+ // In SpecialJavaScriptTest, QUnit must load synchronous
+ 'async' => !isset( $extraQuery['sync'] ),
+ 'src' => $url
+ ] );
+ } else {
+ $chunk = ResourceLoader::makeInlineScript(
+ Xml::encodeJsCall( 'mw.loader.load', [ $url ] )
+ );
+ }
+ }
+
+ if ( $group == 'noscript' ) {
+ $chunks[] = Html::rawElement( 'noscript', [], $chunk );
+ } else {
+ $chunks[] = $chunk;
+ }
+ }
+ }
+ }
+ }
+
+ return new WrappedStringList( "\n", $chunks );
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderContext.php b/www/wiki/includes/resourceloader/ResourceLoaderContext.php
new file mode 100644
index 00000000..cbb0beca
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderContext.php
@@ -0,0 +1,397 @@
+<?php
+/**
+ * Context for ResourceLoader modules.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Object passed around to modules which contains information about the state
+ * of a specific loader request.
+ */
+class ResourceLoaderContext implements MessageLocalizer {
+ protected $resourceLoader;
+ protected $request;
+ protected $logger;
+
+ // Module content vary
+ protected $skin;
+ protected $language;
+ protected $debug;
+ protected $user;
+
+ // Request vary (in addition to cache vary)
+ protected $modules;
+ protected $only;
+ protected $version;
+ protected $raw;
+ protected $image;
+ protected $variant;
+ protected $format;
+
+ protected $direction;
+ protected $hash;
+ protected $userObj;
+ protected $imageObj;
+
+ /**
+ * @param ResourceLoader $resourceLoader
+ * @param WebRequest $request
+ */
+ public function __construct( ResourceLoader $resourceLoader, WebRequest $request ) {
+ $this->resourceLoader = $resourceLoader;
+ $this->request = $request;
+ $this->logger = $resourceLoader->getLogger();
+
+ // Future developers: Avoid use of getVal() in this class, which performs
+ // expensive UTF normalisation by default. Use getRawVal() instead.
+ // Values here are either one of a finite number of internal IDs,
+ // or previously-stored user input (e.g. titles, user names) that were passed
+ // to this endpoint by ResourceLoader itself from the canonical value.
+ // Values do not come directly from user input and need not match.
+
+ // List of modules
+ $modules = $request->getRawVal( 'modules' );
+ $this->modules = $modules ? self::expandModuleNames( $modules ) : [];
+
+ // Various parameters
+ $this->user = $request->getRawVal( 'user' );
+ $this->debug = $request->getFuzzyBool(
+ 'debug',
+ $resourceLoader->getConfig()->get( 'ResourceLoaderDebug' )
+ );
+ $this->only = $request->getRawVal( 'only', null );
+ $this->version = $request->getRawVal( 'version', null );
+ $this->raw = $request->getFuzzyBool( 'raw' );
+
+ // Image requests
+ $this->image = $request->getRawVal( 'image' );
+ $this->variant = $request->getRawVal( 'variant' );
+ $this->format = $request->getRawVal( 'format' );
+
+ $this->skin = $request->getRawVal( 'skin' );
+ $skinnames = Skin::getSkinNames();
+ // If no skin is specified, or we don't recognize the skin, use the default skin
+ if ( !$this->skin || !isset( $skinnames[$this->skin] ) ) {
+ $this->skin = $resourceLoader->getConfig()->get( 'DefaultSkin' );
+ }
+ }
+
+ /**
+ * Expand a string of the form jquery.foo,bar|jquery.ui.baz,quux to
+ * an array of module names like [ 'jquery.foo', 'jquery.bar',
+ * 'jquery.ui.baz', 'jquery.ui.quux' ]
+ * @param string $modules Packed module name list
+ * @return array Array of module names
+ */
+ public static function expandModuleNames( $modules ) {
+ $retval = [];
+ $exploded = explode( '|', $modules );
+ foreach ( $exploded as $group ) {
+ if ( strpos( $group, ',' ) === false ) {
+ // This is not a set of modules in foo.bar,baz notation
+ // but a single module
+ $retval[] = $group;
+ } else {
+ // This is a set of modules in foo.bar,baz notation
+ $pos = strrpos( $group, '.' );
+ if ( $pos === false ) {
+ // Prefixless modules, i.e. without dots
+ $retval = array_merge( $retval, explode( ',', $group ) );
+ } else {
+ // We have a prefix and a bunch of suffixes
+ $prefix = substr( $group, 0, $pos ); // 'foo'
+ $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
+ foreach ( $suffixes as $suffix ) {
+ $retval[] = "$prefix.$suffix";
+ }
+ }
+ }
+ }
+ return $retval;
+ }
+
+ /**
+ * Return a dummy ResourceLoaderContext object suitable for passing into
+ * things that don't "really" need a context.
+ * @return ResourceLoaderContext
+ */
+ public static function newDummyContext() {
+ return new self( new ResourceLoader(
+ MediaWikiServices::getInstance()->getMainConfig(),
+ LoggerFactory::getInstance( 'resourceloader' )
+ ), new FauxRequest( [] ) );
+ }
+
+ /**
+ * @return ResourceLoader
+ */
+ public function getResourceLoader() {
+ return $this->resourceLoader;
+ }
+
+ /**
+ * @return WebRequest
+ */
+ public function getRequest() {
+ return $this->request;
+ }
+
+ /**
+ * @since 1.27
+ * @return \Psr\Log\LoggerInterface
+ */
+ public function getLogger() {
+ return $this->logger;
+ }
+
+ /**
+ * @return array
+ */
+ public function getModules() {
+ return $this->modules;
+ }
+
+ /**
+ * @return string
+ */
+ public function getLanguage() {
+ if ( $this->language === null ) {
+ // Must be a valid language code after this point (T64849)
+ // Only support uselang values that follow built-in conventions (T102058)
+ $lang = $this->getRequest()->getRawVal( 'lang', '' );
+ // Stricter version of RequestContext::sanitizeLangCode()
+ if ( !Language::isValidBuiltInCode( $lang ) ) {
+ wfDebug( "Invalid user language code\n" );
+ $lang = $this->getResourceLoader()->getConfig()->get( 'LanguageCode' );
+ }
+ $this->language = $lang;
+ }
+ return $this->language;
+ }
+
+ /**
+ * @return string
+ */
+ public function getDirection() {
+ if ( $this->direction === null ) {
+ $this->direction = $this->getRequest()->getRawVal( 'dir' );
+ if ( !$this->direction ) {
+ // Determine directionality based on user language (T8100)
+ $this->direction = Language::factory( $this->getLanguage() )->getDir();
+ }
+ }
+ return $this->direction;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSkin() {
+ return $this->skin;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getUser() {
+ return $this->user;
+ }
+
+ /**
+ * Get a Message object with context set. See wfMessage for parameters.
+ *
+ * @since 1.27
+ * @param string|string[]|MessageSpecifier $key Message key, or array of keys,
+ * or a MessageSpecifier.
+ * @param mixed $args,...
+ * @return Message
+ */
+ public function msg( $key ) {
+ return call_user_func_array( 'wfMessage', func_get_args() )
+ ->inLanguage( $this->getLanguage() )
+ // Use a dummy title because there is no real title
+ // for this endpoint, and the cache won't vary on it
+ // anyways.
+ ->title( Title::newFromText( 'Dwimmerlaik' ) );
+ }
+
+ /**
+ * Get the possibly-cached User object for the specified username
+ *
+ * @since 1.25
+ * @return User
+ */
+ public function getUserObj() {
+ if ( $this->userObj === null ) {
+ $username = $this->getUser();
+ if ( $username ) {
+ // Use provided username if valid, fallback to anonymous user
+ $this->userObj = User::newFromName( $username ) ?: new User;
+ } else {
+ // Anonymous user
+ $this->userObj = new User;
+ }
+ }
+
+ return $this->userObj;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getDebug() {
+ return $this->debug;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getOnly() {
+ return $this->only;
+ }
+
+ /**
+ * @see ResourceLoaderModule::getVersionHash
+ * @see ResourceLoaderClientHtml::makeLoad
+ * @return string|null
+ */
+ public function getVersion() {
+ return $this->version;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getRaw() {
+ return $this->raw;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getImage() {
+ return $this->image;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getVariant() {
+ return $this->variant;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getFormat() {
+ return $this->format;
+ }
+
+ /**
+ * If this is a request for an image, get the ResourceLoaderImage object.
+ *
+ * @since 1.25
+ * @return ResourceLoaderImage|bool false if a valid object cannot be created
+ */
+ public function getImageObj() {
+ if ( $this->imageObj === null ) {
+ $this->imageObj = false;
+
+ if ( !$this->image ) {
+ return $this->imageObj;
+ }
+
+ $modules = $this->getModules();
+ if ( count( $modules ) !== 1 ) {
+ return $this->imageObj;
+ }
+
+ $module = $this->getResourceLoader()->getModule( $modules[0] );
+ if ( !$module || !$module instanceof ResourceLoaderImageModule ) {
+ return $this->imageObj;
+ }
+
+ $image = $module->getImage( $this->image, $this );
+ if ( !$image ) {
+ return $this->imageObj;
+ }
+
+ $this->imageObj = $image;
+ }
+
+ return $this->imageObj;
+ }
+
+ /**
+ * @return bool
+ */
+ public function shouldIncludeScripts() {
+ return $this->getOnly() === null || $this->getOnly() === 'scripts';
+ }
+
+ /**
+ * @return bool
+ */
+ public function shouldIncludeStyles() {
+ return $this->getOnly() === null || $this->getOnly() === 'styles';
+ }
+
+ /**
+ * @return bool
+ */
+ public function shouldIncludeMessages() {
+ return $this->getOnly() === null;
+ }
+
+ /**
+ * All factors that uniquely identify this request, except 'modules'.
+ *
+ * The list of modules is excluded here for legacy reasons as most callers already
+ * split up handling of individual modules. Including it here would massively fragment
+ * the cache and decrease its usefulness.
+ *
+ * E.g. Used by RequestFileCache to form a cache key for storing the reponse output.
+ *
+ * @return string
+ */
+ public function getHash() {
+ if ( !isset( $this->hash ) ) {
+ $this->hash = implode( '|', [
+ // Module content vary
+ $this->getLanguage(),
+ $this->getSkin(),
+ $this->getDebug(),
+ $this->getUser(),
+ // Request vary
+ $this->getOnly(),
+ $this->getVersion(),
+ $this->getRaw(),
+ $this->getImage(),
+ $this->getVariant(),
+ $this->getFormat(),
+ ] );
+ }
+ return $this->hash;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderEditToolbarModule.php b/www/wiki/includes/resourceloader/ResourceLoaderEditToolbarModule.php
new file mode 100644
index 00000000..2a6af715
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderEditToolbarModule.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * ResourceLoader module for the edit toolbar.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * ResourceLoader module for the edit toolbar.
+ *
+ * @since 1.24
+ */
+class ResourceLoaderEditToolbarModule extends ResourceLoaderFileModule {
+ /**
+ * Get language-specific LESS variables for this module.
+ *
+ * @since 1.27
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ protected function getLessVars( ResourceLoaderContext $context ) {
+ $vars = parent::getLessVars( $context );
+ $language = Language::factory( $context->getLanguage() );
+ foreach ( $language->getImageFiles() as $key => $value ) {
+ $vars[$key] = CSSMin::serializeStringValue( $value );
+ }
+ return $vars;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderFileModule.php b/www/wiki/includes/resourceloader/ResourceLoaderFileModule.php
new file mode 100644
index 00000000..46751918
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderFileModule.php
@@ -0,0 +1,1041 @@
+<?php
+/**
+ * ResourceLoader module based on local JavaScript/CSS files.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * ResourceLoader module based on local JavaScript/CSS files.
+ */
+class ResourceLoaderFileModule extends ResourceLoaderModule {
+ /* Protected Members */
+
+ /** @var string Local base path, see __construct() */
+ protected $localBasePath = '';
+
+ /** @var string Remote base path, see __construct() */
+ protected $remoteBasePath = '';
+
+ /** @var array Saves a list of the templates named by the modules. */
+ protected $templates = [];
+
+ /**
+ * @var array List of paths to JavaScript files to always include
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $scripts = [];
+
+ /**
+ * @var array List of JavaScript files to include when using a specific language
+ * @par Usage:
+ * @code
+ * [ [language-code] => [ [file-path], [file-path], ... ], ... ]
+ * @endcode
+ */
+ protected $languageScripts = [];
+
+ /**
+ * @var array List of JavaScript files to include when using a specific skin
+ * @par Usage:
+ * @code
+ * [ [skin-name] => [ [file-path], [file-path], ... ], ... ]
+ * @endcode
+ */
+ protected $skinScripts = [];
+
+ /**
+ * @var array List of paths to JavaScript files to include in debug mode
+ * @par Usage:
+ * @code
+ * [ [skin-name] => [ [file-path], [file-path], ... ], ... ]
+ * @endcode
+ */
+ protected $debugScripts = [];
+
+ /**
+ * @var array List of paths to CSS files to always include
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $styles = [];
+
+ /**
+ * @var array List of paths to CSS files to include when using specific skins
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $skinStyles = [];
+
+ /**
+ * @var array List of modules this module depends on
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $dependencies = [];
+
+ /**
+ * @var string File name containing the body of the skip function
+ */
+ protected $skipFunction = null;
+
+ /**
+ * @var array List of message keys used by this module
+ * @par Usage:
+ * @code
+ * [ [message-key], [message-key], ... ]
+ * @endcode
+ */
+ protected $messages = [];
+
+ /** @var string Name of group to load this module in */
+ protected $group;
+
+ /** @var bool Link to raw files in debug mode */
+ protected $debugRaw = true;
+
+ /** @var bool Whether mw.loader.state() call should be omitted */
+ protected $raw = false;
+
+ protected $targets = [ 'desktop' ];
+
+ /** @var bool Whether CSSJanus flipping should be skipped for this module */
+ protected $noflip = false;
+
+ /**
+ * @var bool Whether getStyleURLsForDebug should return raw file paths,
+ * or return load.php urls
+ */
+ protected $hasGeneratedStyles = false;
+
+ /**
+ * @var array Place where readStyleFile() tracks file dependencies
+ * @par Usage:
+ * @code
+ * [ [file-path], [file-path], ... ]
+ * @endcode
+ */
+ protected $localFileRefs = [];
+
+ /**
+ * @var array Place where readStyleFile() tracks file dependencies for non-existent files.
+ * Used in tests to detect missing dependencies.
+ */
+ protected $missingLocalFileRefs = [];
+
+ /* Methods */
+
+ /**
+ * Constructs a new module from an options array.
+ *
+ * @param array $options List of options; if not given or empty, an empty module will be
+ * constructed
+ * @param string $localBasePath Base path to prepend to all local paths in $options. Defaults
+ * to $IP
+ * @param string $remoteBasePath Base path to prepend to all remote paths in $options. Defaults
+ * to $wgResourceBasePath
+ *
+ * Below is a description for the $options array:
+ * @throws InvalidArgumentException
+ * @par Construction options:
+ * @code
+ * [
+ * // Base path to prepend to all local paths in $options. Defaults to $IP
+ * 'localBasePath' => [base path],
+ * // Base path to prepend to all remote paths in $options. Defaults to $wgResourceBasePath
+ * 'remoteBasePath' => [base path],
+ * // Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath
+ * 'remoteExtPath' => [base path],
+ * // Equivalent of remoteBasePath, but relative to $wgStylePath
+ * 'remoteSkinPath' => [base path],
+ * // Scripts to always include
+ * 'scripts' => [file path string or array of file path strings],
+ * // Scripts to include in specific language contexts
+ * 'languageScripts' => [
+ * [language code] => [file path string or array of file path strings],
+ * ],
+ * // Scripts to include in specific skin contexts
+ * 'skinScripts' => [
+ * [skin name] => [file path string or array of file path strings],
+ * ],
+ * // Scripts to include in debug contexts
+ * 'debugScripts' => [file path string or array of file path strings],
+ * // Modules which must be loaded before this module
+ * 'dependencies' => [module name string or array of module name strings],
+ * 'templates' => [
+ * [template alias with file.ext] => [file path to a template file],
+ * ],
+ * // Styles to always load
+ * 'styles' => [file path string or array of file path strings],
+ * // Styles to include in specific skin contexts
+ * 'skinStyles' => [
+ * [skin name] => [file path string or array of file path strings],
+ * ],
+ * // Messages to always load
+ * 'messages' => [array of message key strings],
+ * // Group which this module should be loaded together with
+ * 'group' => [group name string],
+ * // Function that, if it returns true, makes the loader skip this module.
+ * // The file must contain valid JavaScript for execution in a private function.
+ * // The file must not contain the "function () {" and "}" wrapper though.
+ * 'skipFunction' => [file path]
+ * ]
+ * @endcode
+ */
+ public function __construct(
+ $options = [],
+ $localBasePath = null,
+ $remoteBasePath = null
+ ) {
+ // Flag to decide whether to automagically add the mediawiki.template module
+ $hasTemplates = false;
+ // localBasePath and remoteBasePath both have unbelievably long fallback chains
+ // and need to be handled separately.
+ list( $this->localBasePath, $this->remoteBasePath ) =
+ self::extractBasePaths( $options, $localBasePath, $remoteBasePath );
+
+ // Extract, validate and normalise remaining options
+ foreach ( $options as $member => $option ) {
+ switch ( $member ) {
+ // Lists of file paths
+ case 'scripts':
+ case 'debugScripts':
+ case 'styles':
+ $this->{$member} = (array)$option;
+ break;
+ case 'templates':
+ $hasTemplates = true;
+ $this->{$member} = (array)$option;
+ break;
+ // Collated lists of file paths
+ case 'languageScripts':
+ case 'skinScripts':
+ case 'skinStyles':
+ if ( !is_array( $option ) ) {
+ throw new InvalidArgumentException(
+ "Invalid collated file path list error. " .
+ "'$option' given, array expected."
+ );
+ }
+ foreach ( $option as $key => $value ) {
+ if ( !is_string( $key ) ) {
+ throw new InvalidArgumentException(
+ "Invalid collated file path list key error. " .
+ "'$key' given, string expected."
+ );
+ }
+ $this->{$member}[$key] = (array)$value;
+ }
+ break;
+ case 'deprecated':
+ $this->deprecated = $option;
+ break;
+ // Lists of strings
+ case 'dependencies':
+ case 'messages':
+ case 'targets':
+ // Normalise
+ $option = array_values( array_unique( (array)$option ) );
+ sort( $option );
+
+ $this->{$member} = $option;
+ break;
+ // Single strings
+ case 'group':
+ case 'skipFunction':
+ $this->{$member} = (string)$option;
+ break;
+ // Single booleans
+ case 'debugRaw':
+ case 'raw':
+ case 'noflip':
+ $this->{$member} = (bool)$option;
+ break;
+ }
+ }
+ if ( $hasTemplates ) {
+ $this->dependencies[] = 'mediawiki.template';
+ // Ensure relevant template compiler module gets loaded
+ foreach ( $this->templates as $alias => $templatePath ) {
+ if ( is_int( $alias ) ) {
+ $alias = $templatePath;
+ }
+ $suffix = explode( '.', $alias );
+ $suffix = end( $suffix );
+ $compilerModule = 'mediawiki.template.' . $suffix;
+ if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) {
+ $this->dependencies[] = $compilerModule;
+ }
+ }
+ }
+ }
+
+ /**
+ * Extract a pair of local and remote base paths from module definition information.
+ * Implementation note: the amount of global state used in this function is staggering.
+ *
+ * @param array $options Module definition
+ * @param string $localBasePath Path to use if not provided in module definition. Defaults
+ * to $IP
+ * @param string $remoteBasePath Path to use if not provided in module definition. Defaults
+ * to $wgResourceBasePath
+ * @return array Array( localBasePath, remoteBasePath )
+ */
+ public static function extractBasePaths(
+ $options = [],
+ $localBasePath = null,
+ $remoteBasePath = null
+ ) {
+ global $IP, $wgResourceBasePath;
+
+ // The different ways these checks are done, and their ordering, look very silly,
+ // but were preserved for backwards-compatibility just in case. Tread lightly.
+
+ if ( $localBasePath === null ) {
+ $localBasePath = $IP;
+ }
+ if ( $remoteBasePath === null ) {
+ $remoteBasePath = $wgResourceBasePath;
+ }
+
+ if ( isset( $options['remoteExtPath'] ) ) {
+ global $wgExtensionAssetsPath;
+ $remoteBasePath = $wgExtensionAssetsPath . '/' . $options['remoteExtPath'];
+ }
+
+ if ( isset( $options['remoteSkinPath'] ) ) {
+ global $wgStylePath;
+ $remoteBasePath = $wgStylePath . '/' . $options['remoteSkinPath'];
+ }
+
+ if ( array_key_exists( 'localBasePath', $options ) ) {
+ $localBasePath = (string)$options['localBasePath'];
+ }
+
+ if ( array_key_exists( 'remoteBasePath', $options ) ) {
+ $remoteBasePath = (string)$options['remoteBasePath'];
+ }
+
+ return [ $localBasePath, $remoteBasePath ];
+ }
+
+ /**
+ * Gets all scripts for a given context concatenated together.
+ *
+ * @param ResourceLoaderContext $context Context in which to generate script
+ * @return string JavaScript code for $context
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ $files = $this->getScriptFiles( $context );
+ return $this->getDeprecationInformation() . $this->readScriptFiles( $files );
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getScriptURLsForDebug( ResourceLoaderContext $context ) {
+ $urls = [];
+ foreach ( $this->getScriptFiles( $context ) as $file ) {
+ $urls[] = OutputPage::transformResourcePath(
+ $this->getConfig(),
+ $this->getRemotePath( $file )
+ );
+ }
+ return $urls;
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return $this->debugRaw;
+ }
+
+ /**
+ * Get all styles for a given context.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array CSS code for $context as an associative array mapping media type to CSS text.
+ */
+ public function getStyles( ResourceLoaderContext $context ) {
+ $styles = $this->readStyleFiles(
+ $this->getStyleFiles( $context ),
+ $this->getFlip( $context ),
+ $context
+ );
+ // Collect referenced files
+ $this->saveFileDependencies( $context, $this->localFileRefs );
+
+ return $styles;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getStyleURLsForDebug( ResourceLoaderContext $context ) {
+ if ( $this->hasGeneratedStyles ) {
+ // Do the default behaviour of returning a url back to load.php
+ // but with only=styles.
+ return parent::getStyleURLsForDebug( $context );
+ }
+ // Our module consists entirely of real css files,
+ // in debug mode we can load those directly.
+ $urls = [];
+ foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
+ $urls[$mediaType] = [];
+ foreach ( $list as $file ) {
+ $urls[$mediaType][] = OutputPage::transformResourcePath(
+ $this->getConfig(),
+ $this->getRemotePath( $file )
+ );
+ }
+ }
+ return $urls;
+ }
+
+ /**
+ * Gets list of message keys used by this module.
+ *
+ * @return array List of message keys
+ */
+ public function getMessages() {
+ return $this->messages;
+ }
+
+ /**
+ * Gets the name of the group this module should be loaded in.
+ *
+ * @return string Group name
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * Gets list of names of modules this module depends on.
+ * @param ResourceLoaderContext|null $context
+ * @return array List of module names
+ */
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ return $this->dependencies;
+ }
+
+ /**
+ * Get the skip function.
+ * @return null|string
+ * @throws MWException
+ */
+ public function getSkipFunction() {
+ if ( !$this->skipFunction ) {
+ return null;
+ }
+
+ $localPath = $this->getLocalPath( $this->skipFunction );
+ if ( !file_exists( $localPath ) ) {
+ throw new MWException( __METHOD__ . ": skip function file not found: \"$localPath\"" );
+ }
+ $contents = $this->stripBom( file_get_contents( $localPath ) );
+ if ( $this->getConfig()->get( 'ResourceLoaderValidateStaticJS' ) ) {
+ $contents = $this->validateScriptFile( $localPath, $contents );
+ }
+ return $contents;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isRaw() {
+ return $this->raw;
+ }
+
+ /**
+ * Disable module content versioning.
+ *
+ * This class uses getDefinitionSummary() instead, to avoid filesystem overhead
+ * involved with building the full module content inside a startup request.
+ *
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return false;
+ }
+
+ /**
+ * Helper method to gather file hashes for getDefinitionSummary.
+ *
+ * This function is context-sensitive, only computing hashes of files relevant to the
+ * given language, skin, etc.
+ *
+ * @see ResourceLoaderModule::getFileDependencies
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ protected function getFileHashes( ResourceLoaderContext $context ) {
+ $files = [];
+
+ // Flatten style files into $files
+ $styles = self::collateFilePathListByOption( $this->styles, 'media', 'all' );
+ foreach ( $styles as $styleFiles ) {
+ $files = array_merge( $files, $styleFiles );
+ }
+
+ $skinFiles = self::collateFilePathListByOption(
+ self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ),
+ 'media',
+ 'all'
+ );
+ foreach ( $skinFiles as $styleFiles ) {
+ $files = array_merge( $files, $styleFiles );
+ }
+
+ // Final merge, this should result in a master list of dependent files
+ $files = array_merge(
+ $files,
+ $this->scripts,
+ $this->templates,
+ $context->getDebug() ? $this->debugScripts : [],
+ $this->getLanguageScripts( $context->getLanguage() ),
+ self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
+ );
+ if ( $this->skipFunction ) {
+ $files[] = $this->skipFunction;
+ }
+ $files = array_map( [ $this, 'getLocalPath' ], $files );
+ // File deps need to be treated separately because they're already prefixed
+ $files = array_merge( $files, $this->getFileDependencies( $context ) );
+ // Filter out any duplicates from getFileDependencies() and others.
+ // Most commonly introduced by compileLessFile(), which always includes the
+ // entry point Less file we already know about.
+ $files = array_values( array_unique( $files ) );
+
+ // Don't include keys or file paths here, only the hashes. Including that would needlessly
+ // cause global cache invalidation when files move or if e.g. the MediaWiki path changes.
+ // Any significant ordering is already detected by the definition summary.
+ return array_map( [ __CLASS__, 'safeFileHash' ], $files );
+ }
+
+ /**
+ * Get the definition summary for this module.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getDefinitionSummary( ResourceLoaderContext $context ) {
+ $summary = parent::getDefinitionSummary( $context );
+
+ $options = [];
+ foreach ( [
+ // The following properties are omitted because they don't affect the module reponse:
+ // - localBasePath (Per T104950; Changes when absolute directory name changes. If
+ // this affects 'scripts' and other file paths, getFileHashes accounts for that.)
+ // - remoteBasePath (Per T104950)
+ // - dependencies (provided via startup module)
+ // - targets
+ // - group (provided via startup module)
+ 'scripts',
+ 'debugScripts',
+ 'styles',
+ 'languageScripts',
+ 'skinScripts',
+ 'skinStyles',
+ 'messages',
+ 'templates',
+ 'skipFunction',
+ 'debugRaw',
+ 'raw',
+ ] as $member ) {
+ $options[$member] = $this->{$member};
+ };
+
+ $summary[] = [
+ 'options' => $options,
+ 'fileHashes' => $this->getFileHashes( $context ),
+ 'messageBlob' => $this->getMessageBlob( $context ),
+ ];
+
+ $lessVars = $this->getLessVars( $context );
+ if ( $lessVars ) {
+ $summary[] = [ 'lessVars' => $lessVars ];
+ }
+
+ return $summary;
+ }
+
+ /**
+ * @param string|ResourceLoaderFilePath $path
+ * @return string
+ */
+ protected function getLocalPath( $path ) {
+ if ( $path instanceof ResourceLoaderFilePath ) {
+ return $path->getLocalPath();
+ }
+
+ return "{$this->localBasePath}/$path";
+ }
+
+ /**
+ * @param string|ResourceLoaderFilePath $path
+ * @return string
+ */
+ protected function getRemotePath( $path ) {
+ if ( $path instanceof ResourceLoaderFilePath ) {
+ return $path->getRemotePath();
+ }
+
+ return "{$this->remoteBasePath}/$path";
+ }
+
+ /**
+ * Infer the stylesheet language from a stylesheet file path.
+ *
+ * @since 1.22
+ * @param string $path
+ * @return string The stylesheet language name
+ */
+ public function getStyleSheetLang( $path ) {
+ return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
+ }
+
+ /**
+ * Collates file paths by option (where provided).
+ *
+ * @param array $list List of file paths in any combination of index/path
+ * or path/options pairs
+ * @param string $option Option name
+ * @param mixed $default Default value if the option isn't set
+ * @return array List of file paths, collated by $option
+ */
+ protected static function collateFilePathListByOption( array $list, $option, $default ) {
+ $collatedFiles = [];
+ foreach ( (array)$list as $key => $value ) {
+ if ( is_int( $key ) ) {
+ // File name as the value
+ if ( !isset( $collatedFiles[$default] ) ) {
+ $collatedFiles[$default] = [];
+ }
+ $collatedFiles[$default][] = $value;
+ } elseif ( is_array( $value ) ) {
+ // File name as the key, options array as the value
+ $optionValue = isset( $value[$option] ) ? $value[$option] : $default;
+ if ( !isset( $collatedFiles[$optionValue] ) ) {
+ $collatedFiles[$optionValue] = [];
+ }
+ $collatedFiles[$optionValue][] = $key;
+ }
+ }
+ return $collatedFiles;
+ }
+
+ /**
+ * Get a list of element that match a key, optionally using a fallback key.
+ *
+ * @param array $list List of lists to select from
+ * @param string $key Key to look for in $map
+ * @param string $fallback Key to look for in $list if $key doesn't exist
+ * @return array List of elements from $map which matched $key or $fallback,
+ * or an empty list in case of no match
+ */
+ protected static function tryForKey( array $list, $key, $fallback = null ) {
+ if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
+ return $list[$key];
+ } elseif ( is_string( $fallback )
+ && isset( $list[$fallback] )
+ && is_array( $list[$fallback] )
+ ) {
+ return $list[$fallback];
+ }
+ return [];
+ }
+
+ /**
+ * Get a list of file paths for all scripts in this module, in order of proper execution.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array List of file paths
+ */
+ protected function getScriptFiles( ResourceLoaderContext $context ) {
+ $files = array_merge(
+ $this->scripts,
+ $this->getLanguageScripts( $context->getLanguage() ),
+ self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' )
+ );
+ if ( $context->getDebug() ) {
+ $files = array_merge( $files, $this->debugScripts );
+ }
+
+ return array_unique( $files, SORT_REGULAR );
+ }
+
+ /**
+ * Get the set of language scripts for the given language,
+ * possibly using a fallback language.
+ *
+ * @param string $lang
+ * @return array
+ */
+ private function getLanguageScripts( $lang ) {
+ $scripts = self::tryForKey( $this->languageScripts, $lang );
+ if ( $scripts ) {
+ return $scripts;
+ }
+ $fallbacks = Language::getFallbacksFor( $lang );
+ foreach ( $fallbacks as $lang ) {
+ $scripts = self::tryForKey( $this->languageScripts, $lang );
+ if ( $scripts ) {
+ return $scripts;
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * Get a list of file paths for all styles in this module, in order of proper inclusion.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array List of file paths
+ */
+ public function getStyleFiles( ResourceLoaderContext $context ) {
+ return array_merge_recursive(
+ self::collateFilePathListByOption( $this->styles, 'media', 'all' ),
+ self::collateFilePathListByOption(
+ self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ),
+ 'media',
+ 'all'
+ )
+ );
+ }
+
+ /**
+ * Gets a list of file paths for all skin styles in the module used by
+ * the skin.
+ *
+ * @param string $skinName The name of the skin
+ * @return array A list of file paths collated by media type
+ */
+ protected function getSkinStyleFiles( $skinName ) {
+ return self::collateFilePathListByOption(
+ self::tryForKey( $this->skinStyles, $skinName ),
+ 'media',
+ 'all'
+ );
+ }
+
+ /**
+ * Gets a list of file paths for all skin style files in the module,
+ * for all available skins.
+ *
+ * @return array A list of file paths collated by media type
+ */
+ protected function getAllSkinStyleFiles() {
+ $styleFiles = [];
+ $internalSkinNames = array_keys( Skin::getSkinNames() );
+ $internalSkinNames[] = 'default';
+
+ foreach ( $internalSkinNames as $internalSkinName ) {
+ $styleFiles = array_merge_recursive(
+ $styleFiles,
+ $this->getSkinStyleFiles( $internalSkinName )
+ );
+ }
+
+ return $styleFiles;
+ }
+
+ /**
+ * Returns all style files and all skin style files used by this module.
+ *
+ * @return array
+ */
+ public function getAllStyleFiles() {
+ $collatedStyleFiles = array_merge_recursive(
+ self::collateFilePathListByOption( $this->styles, 'media', 'all' ),
+ $this->getAllSkinStyleFiles()
+ );
+
+ $result = [];
+
+ foreach ( $collatedStyleFiles as $media => $styleFiles ) {
+ foreach ( $styleFiles as $styleFile ) {
+ $result[] = $this->getLocalPath( $styleFile );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Gets the contents of a list of JavaScript files.
+ *
+ * @param array $scripts List of file paths to scripts to read, remap and concetenate
+ * @throws MWException
+ * @return string Concatenated and remapped JavaScript data from $scripts
+ */
+ protected function readScriptFiles( array $scripts ) {
+ if ( empty( $scripts ) ) {
+ return '';
+ }
+ $js = '';
+ foreach ( array_unique( $scripts, SORT_REGULAR ) as $fileName ) {
+ $localPath = $this->getLocalPath( $fileName );
+ if ( !file_exists( $localPath ) ) {
+ throw new MWException( __METHOD__ . ": script file not found: \"$localPath\"" );
+ }
+ $contents = $this->stripBom( file_get_contents( $localPath ) );
+ if ( $this->getConfig()->get( 'ResourceLoaderValidateStaticJS' ) ) {
+ // Static files don't really need to be checked as often; unlike
+ // on-wiki module they shouldn't change unexpectedly without
+ // admin interference.
+ $contents = $this->validateScriptFile( $fileName, $contents );
+ }
+ $js .= $contents . "\n";
+ }
+ return $js;
+ }
+
+ /**
+ * Gets the contents of a list of CSS files.
+ *
+ * @param array $styles List of media type/list of file paths pairs, to read, remap and
+ * concetenate
+ * @param bool $flip
+ * @param ResourceLoaderContext $context
+ *
+ * @throws MWException
+ * @return array List of concatenated and remapped CSS data from $styles,
+ * keyed by media type
+ *
+ * @since 1.27 Calling this method without a ResourceLoaderContext instance
+ * is deprecated.
+ */
+ public function readStyleFiles( array $styles, $flip, $context = null ) {
+ if ( $context === null ) {
+ wfDeprecated( __METHOD__ . ' without a ResourceLoader context', '1.27' );
+ $context = ResourceLoaderContext::newDummyContext();
+ }
+
+ if ( empty( $styles ) ) {
+ return [];
+ }
+ foreach ( $styles as $media => $files ) {
+ $uniqueFiles = array_unique( $files, SORT_REGULAR );
+ $styleFiles = [];
+ foreach ( $uniqueFiles as $file ) {
+ $styleFiles[] = $this->readStyleFile( $file, $flip, $context );
+ }
+ $styles[$media] = implode( "\n", $styleFiles );
+ }
+ return $styles;
+ }
+
+ /**
+ * Reads a style file.
+ *
+ * This method can be used as a callback for array_map()
+ *
+ * @param string $path File path of style file to read
+ * @param bool $flip
+ * @param ResourceLoaderContext $context
+ *
+ * @return string CSS data in script file
+ * @throws MWException If the file doesn't exist
+ */
+ protected function readStyleFile( $path, $flip, $context ) {
+ $localPath = $this->getLocalPath( $path );
+ $remotePath = $this->getRemotePath( $path );
+ if ( !file_exists( $localPath ) ) {
+ $msg = __METHOD__ . ": style file not found: \"$localPath\"";
+ wfDebugLog( 'resourceloader', $msg );
+ throw new MWException( $msg );
+ }
+
+ if ( $this->getStyleSheetLang( $localPath ) === 'less' ) {
+ $style = $this->compileLessFile( $localPath, $context );
+ $this->hasGeneratedStyles = true;
+ } else {
+ $style = $this->stripBom( file_get_contents( $localPath ) );
+ }
+
+ if ( $flip ) {
+ $style = CSSJanus::transform( $style, true, false );
+ }
+ $localDir = dirname( $localPath );
+ $remoteDir = dirname( $remotePath );
+ // Get and register local file references
+ $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
+ foreach ( $localFileRefs as $file ) {
+ if ( file_exists( $file ) ) {
+ $this->localFileRefs[] = $file;
+ } else {
+ $this->missingLocalFileRefs[] = $file;
+ }
+ }
+ // Don't cache this call. remap() ensures data URIs embeds are up to date,
+ // and urls contain correct content hashes in their query string. (T128668)
+ return CSSMin::remap( $style, $localDir, $remoteDir, true );
+ }
+
+ /**
+ * Get whether CSS for this module should be flipped
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public function getFlip( $context ) {
+ return $context->getDirection() === 'rtl' && !$this->noflip;
+ }
+
+ /**
+ * Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile']
+ *
+ * @return array Array of strings
+ */
+ public function getTargets() {
+ return $this->targets;
+ }
+
+ /**
+ * Get the module's load type.
+ *
+ * @since 1.28
+ * @return string
+ */
+ public function getType() {
+ $canBeStylesOnly = !(
+ // All options except 'styles', 'skinStyles' and 'debugRaw'
+ $this->scripts
+ || $this->debugScripts
+ || $this->templates
+ || $this->languageScripts
+ || $this->skinScripts
+ || $this->dependencies
+ || $this->messages
+ || $this->skipFunction
+ || $this->raw
+ );
+ return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
+ }
+
+ /**
+ * Compile a LESS file into CSS.
+ *
+ * Keeps track of all used files and adds them to localFileRefs.
+ *
+ * @since 1.22
+ * @since 1.27 Added $context paramter.
+ * @throws Exception If less.php encounters a parse error
+ * @param string $fileName File path of LESS source
+ * @param ResourceLoaderContext $context Context in which to generate script
+ * @return string CSS source
+ */
+ protected function compileLessFile( $fileName, ResourceLoaderContext $context ) {
+ static $cache;
+
+ if ( !$cache ) {
+ $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
+ }
+
+ // Construct a cache key from the LESS file name and a hash digest
+ // of the LESS variables used for compilation.
+ $vars = $this->getLessVars( $context );
+ ksort( $vars );
+ $varsHash = hash( 'md4', serialize( $vars ) );
+ $cacheKey = $cache->makeGlobalKey( 'LESS', $fileName, $varsHash );
+ $cachedCompile = $cache->get( $cacheKey );
+
+ // If we got a cached value, we have to validate it by getting a
+ // checksum of all the files that were loaded by the parser and
+ // ensuring it matches the cached entry's.
+ if ( isset( $cachedCompile['hash'] ) ) {
+ $contentHash = FileContentsHasher::getFileContentsHash( $cachedCompile['files'] );
+ if ( $contentHash === $cachedCompile['hash'] ) {
+ $this->localFileRefs = array_merge( $this->localFileRefs, $cachedCompile['files'] );
+ return $cachedCompile['css'];
+ }
+ }
+
+ $compiler = $context->getResourceLoader()->getLessCompiler( $vars );
+ $css = $compiler->parseFile( $fileName )->getCss();
+ $files = $compiler->AllParsedFiles();
+ $this->localFileRefs = array_merge( $this->localFileRefs, $files );
+
+ // Cache for 24 hours (86400 seconds).
+ $cache->set( $cacheKey, [
+ 'css' => $css,
+ 'files' => $files,
+ 'hash' => FileContentsHasher::getFileContentsHash( $files ),
+ ], 3600 * 24 );
+
+ return $css;
+ }
+
+ /**
+ * Takes named templates by the module and returns an array mapping.
+ * @return array Templates mapping template alias to content
+ * @throws MWException
+ */
+ public function getTemplates() {
+ $templates = [];
+
+ foreach ( $this->templates as $alias => $templatePath ) {
+ // Alias is optional
+ if ( is_int( $alias ) ) {
+ $alias = $templatePath;
+ }
+ $localPath = $this->getLocalPath( $templatePath );
+ if ( file_exists( $localPath ) ) {
+ $content = file_get_contents( $localPath );
+ $templates[$alias] = $this->stripBom( $content );
+ } else {
+ $msg = __METHOD__ . ": template file not found: \"$localPath\"";
+ wfDebugLog( 'resourceloader', $msg );
+ throw new MWException( $msg );
+ }
+ }
+ return $templates;
+ }
+
+ /**
+ * Takes an input string and removes the UTF-8 BOM character if present
+ *
+ * We need to remove these after reading a file, because we concatenate our files and
+ * the BOM character is not valid in the middle of a string.
+ * We already assume UTF-8 everywhere, so this should be safe.
+ *
+ * @param string $input
+ * @return string Input minus the intial BOM char
+ */
+ protected function stripBom( $input ) {
+ if ( substr_compare( "\xef\xbb\xbf", $input, 0, 3 ) === 0 ) {
+ return substr( $input, 3 );
+ }
+ return $input;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderFilePath.php b/www/wiki/includes/resourceloader/ResourceLoaderFilePath.php
new file mode 100644
index 00000000..dd239d09
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderFilePath.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * An object to represent a path to a JavaScript/CSS file, along with a remote
+ * and local base path, for use with ResourceLoaderFileModule.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * An object to represent a path to a JavaScript/CSS file, along with a remote
+ * and local base path, for use with ResourceLoaderFileModule.
+ */
+class ResourceLoaderFilePath {
+ /* Protected Members */
+
+ /** @var string Local base path */
+ protected $localBasePath;
+
+ /** @var string Remote base path */
+ protected $remoteBasePath;
+
+ /**
+ * @var string Path to the file */
+ protected $path;
+
+ /* Methods */
+
+ /**
+ * @param string $path Path to the file.
+ * @param string $localBasePath Base path to prepend when generating a local path.
+ * @param string $remoteBasePath Base path to prepend when generating a remote path.
+ */
+ public function __construct( $path, $localBasePath, $remoteBasePath ) {
+ $this->path = $path;
+ $this->localBasePath = $localBasePath;
+ $this->remoteBasePath = $remoteBasePath;
+ }
+
+ /**
+ * @return string
+ */
+ public function getLocalPath() {
+ return "{$this->localBasePath}/{$this->path}";
+ }
+
+ /**
+ * @return string
+ */
+ public function getRemotePath() {
+ return "{$this->remoteBasePath}/{$this->path}";
+ }
+
+ /**
+ * @return string
+ */
+ public function getPath() {
+ return $this->path;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderForeignApiModule.php b/www/wiki/includes/resourceloader/ResourceLoaderForeignApiModule.php
new file mode 100644
index 00000000..4d215d6f
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderForeignApiModule.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * ResourceLoader module for mediawiki.ForeignApi that has dynamically
+ * generated dependencies, via a hook usable by extensions.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * ResourceLoader module for mediawiki.ForeignApi and its generated data
+ */
+class ResourceLoaderForeignApiModule extends ResourceLoaderFileModule {
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ $dependencies = $this->dependencies;
+ Hooks::run( 'ResourceLoaderForeignApiModules', [ &$dependencies, $context ] );
+ return $dependencies;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderImage.php b/www/wiki/includes/resourceloader/ResourceLoaderImage.php
new file mode 100644
index 00000000..072ae794
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderImage.php
@@ -0,0 +1,399 @@
+<?php
+/**
+ * Class encapsulating an image used in a ResourceLoaderImageModule.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Class encapsulating an image used in a ResourceLoaderImageModule.
+ *
+ * @since 1.25
+ */
+class ResourceLoaderImage {
+
+ /**
+ * Map of allowed file extensions to their MIME types.
+ * @var array
+ */
+ protected static $fileTypes = [
+ 'svg' => 'image/svg+xml',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'jpg' => 'image/jpg',
+ ];
+
+ /**
+ * @param string $name Image name
+ * @param string $module Module name
+ * @param string|array $descriptor Path to image file, or array structure containing paths
+ * @param string $basePath Directory to which paths in descriptor refer
+ * @param array $variants
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $name, $module, $descriptor, $basePath, $variants ) {
+ $this->name = $name;
+ $this->module = $module;
+ $this->descriptor = $descriptor;
+ $this->basePath = $basePath;
+ $this->variants = $variants;
+
+ // Expand shorthands:
+ // [ "en,de,fr" => "foo.svg" ]
+ // → [ "en" => "foo.svg", "de" => "foo.svg", "fr" => "foo.svg" ]
+ if ( is_array( $this->descriptor ) && isset( $this->descriptor['lang'] ) ) {
+ foreach ( array_keys( $this->descriptor['lang'] ) as $langList ) {
+ if ( strpos( $langList, ',' ) !== false ) {
+ $this->descriptor['lang'] += array_fill_keys(
+ explode( ',', $langList ),
+ $this->descriptor['lang'][$langList]
+ );
+ unset( $this->descriptor['lang'][$langList] );
+ }
+ }
+ }
+ // Remove 'deprecated' key
+ if ( is_array( $this->descriptor ) ) {
+ unset( $this->descriptor[ 'deprecated' ] );
+ }
+
+ // Ensure that all files have common extension.
+ $extensions = [];
+ $descriptor = (array)$this->descriptor;
+ array_walk_recursive( $descriptor, function ( $path ) use ( &$extensions ) {
+ $extensions[] = pathinfo( $path, PATHINFO_EXTENSION );
+ } );
+ $extensions = array_unique( $extensions );
+ if ( count( $extensions ) !== 1 ) {
+ throw new InvalidArgumentException(
+ "File type for different image files of '$name' not the same in module '$module'"
+ );
+ }
+ $ext = $extensions[0];
+ if ( !isset( self::$fileTypes[$ext] ) ) {
+ throw new InvalidArgumentException(
+ "Invalid file type for image files of '$name' (valid: svg, png, gif, jpg) in module '$module'"
+ );
+ }
+ $this->extension = $ext;
+ }
+
+ /**
+ * Get name of this image.
+ *
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Get name of the module this image belongs to.
+ *
+ * @return string
+ */
+ public function getModule() {
+ return $this->module;
+ }
+
+ /**
+ * Get the list of variants this image can be converted to.
+ *
+ * @return string[]
+ */
+ public function getVariants() {
+ return array_keys( $this->variants );
+ }
+
+ /**
+ * Get the path to image file for given context.
+ *
+ * @param ResourceLoaderContext $context Any context
+ * @return string
+ */
+ public function getPath( ResourceLoaderContext $context ) {
+ $desc = $this->descriptor;
+ if ( is_string( $desc ) ) {
+ return $this->basePath . '/' . $desc;
+ } elseif ( isset( $desc['lang'][$context->getLanguage()] ) ) {
+ return $this->basePath . '/' . $desc['lang'][$context->getLanguage()];
+ } elseif ( isset( $desc[$context->getDirection()] ) ) {
+ return $this->basePath . '/' . $desc[$context->getDirection()];
+ } else {
+ return $this->basePath . '/' . $desc['default'];
+ }
+ }
+
+ /**
+ * Get the extension of the image.
+ *
+ * @param string $format Format to get the extension for, 'original' or 'rasterized'
+ * @return string Extension without leading dot, e.g. 'png'
+ */
+ public function getExtension( $format = 'original' ) {
+ if ( $format === 'rasterized' && $this->extension === 'svg' ) {
+ return 'png';
+ }
+ return $this->extension;
+ }
+
+ /**
+ * Get the MIME type of the image.
+ *
+ * @param string $format Format to get the MIME type for, 'original' or 'rasterized'
+ * @return string
+ */
+ public function getMimeType( $format = 'original' ) {
+ $ext = $this->getExtension( $format );
+ return self::$fileTypes[$ext];
+ }
+
+ /**
+ * Get the load.php URL that will produce this image.
+ *
+ * @param ResourceLoaderContext $context Any context
+ * @param string $script URL to load.php
+ * @param string|null $variant Variant to get the URL for
+ * @param string $format Format to get the URL for, 'original' or 'rasterized'
+ * @return string
+ */
+ public function getUrl( ResourceLoaderContext $context, $script, $variant, $format ) {
+ $query = [
+ 'modules' => $this->getModule(),
+ 'image' => $this->getName(),
+ 'variant' => $variant,
+ 'format' => $format,
+ 'lang' => $context->getLanguage(),
+ 'skin' => $context->getSkin(),
+ 'version' => $context->getVersion(),
+ ];
+
+ return wfAppendQuery( $script, $query );
+ }
+
+ /**
+ * Get the data: URI that will produce this image.
+ *
+ * @param ResourceLoaderContext $context Any context
+ * @param string|null $variant Variant to get the URI for
+ * @param string $format Format to get the URI for, 'original' or 'rasterized'
+ * @return string
+ */
+ public function getDataUri( ResourceLoaderContext $context, $variant, $format ) {
+ $type = $this->getMimeType( $format );
+ $contents = $this->getImageData( $context, $variant, $format );
+ return CSSMin::encodeStringAsDataURI( $contents, $type );
+ }
+
+ /**
+ * Get actual image data for this image. This can be saved to a file or sent to the browser to
+ * produce the converted image.
+ *
+ * Call getExtension() or getMimeType() with the same $format argument to learn what file type the
+ * returned data uses.
+ *
+ * @param ResourceLoaderContext $context Image context, or any context if $variant and $format
+ * given.
+ * @param string|null $variant Variant to get the data for. Optional; if given, overrides the data
+ * from $context.
+ * @param string $format Format to get the data for, 'original' or 'rasterized'. Optional; if
+ * given, overrides the data from $context.
+ * @return string|false Possibly binary image data, or false on failure
+ * @throws MWException If the image file doesn't exist
+ */
+ public function getImageData( ResourceLoaderContext $context, $variant = false, $format = false ) {
+ if ( $variant === false ) {
+ $variant = $context->getVariant();
+ }
+ if ( $format === false ) {
+ $format = $context->getFormat();
+ }
+
+ $path = $this->getPath( $context );
+ if ( !file_exists( $path ) ) {
+ throw new MWException( "File '$path' does not exist" );
+ }
+
+ if ( $this->getExtension() !== 'svg' ) {
+ return file_get_contents( $path );
+ }
+
+ if ( $variant && isset( $this->variants[$variant] ) ) {
+ $data = $this->variantize( $this->variants[$variant], $context );
+ } else {
+ $data = file_get_contents( $path );
+ }
+
+ if ( $format === 'rasterized' ) {
+ $data = $this->rasterize( $data );
+ if ( !$data ) {
+ wfDebugLog( 'ResourceLoaderImage', __METHOD__ . " failed to rasterize for $path" );
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Send response headers (using the header() function) that are necessary to correctly serve the
+ * image data for this image, as returned by getImageData().
+ *
+ * Note that the headers are independent of the language or image variant.
+ *
+ * @param ResourceLoaderContext $context Image context
+ */
+ public function sendResponseHeaders( ResourceLoaderContext $context ) {
+ $format = $context->getFormat();
+ $mime = $this->getMimeType( $format );
+ $filename = $this->getName() . '.' . $this->getExtension( $format );
+
+ header( 'Content-Type: ' . $mime );
+ header( 'Content-Disposition: ' .
+ FileBackend::makeContentDisposition( 'inline', $filename ) );
+ }
+
+ /**
+ * Convert this image, which is assumed to be SVG, to given variant.
+ *
+ * @param array $variantConf Array with a 'color' key, its value will be used as fill color
+ * @param ResourceLoaderContext $context Image context
+ * @return string New SVG file data
+ */
+ protected function variantize( $variantConf, ResourceLoaderContext $context ) {
+ $dom = new DomDocument;
+ $dom->loadXML( file_get_contents( $this->getPath( $context ) ) );
+ $root = $dom->documentElement;
+ $wrapper = $dom->createElement( 'g' );
+ while ( $root->firstChild ) {
+ $wrapper->appendChild( $root->firstChild );
+ }
+ $root->appendChild( $wrapper );
+ $wrapper->setAttribute( 'fill', $variantConf['color'] );
+ return $dom->saveXML();
+ }
+
+ /**
+ * Massage the SVG image data for converters which don't understand some path data syntax.
+ *
+ * This is necessary for rsvg and ImageMagick when compiled with rsvg support.
+ * Upstream bug is https://bugzilla.gnome.org/show_bug.cgi?id=620923, fixed 2014-11-10, so
+ * this will be needed for a while. (T76852)
+ *
+ * @param string $svg SVG image data
+ * @return string Massaged SVG image data
+ */
+ protected function massageSvgPathdata( $svg ) {
+ $dom = new DomDocument;
+ $dom->loadXML( $svg );
+ foreach ( $dom->getElementsByTagName( 'path' ) as $node ) {
+ $pathData = $node->getAttribute( 'd' );
+ // Make sure there is at least one space between numbers, and that leading zero is not omitted.
+ // rsvg has issues with syntax like "M-1-2" and "M.445.483" and especially "M-.445-.483".
+ $pathData = preg_replace( '/(-?)(\d*\.\d+|\d+)/', ' ${1}0$2 ', $pathData );
+ // Strip unnecessary leading zeroes for prettiness, not strictly necessary
+ $pathData = preg_replace( '/([ -])0(\d)/', '$1$2', $pathData );
+ $node->setAttribute( 'd', $pathData );
+ }
+ return $dom->saveXML();
+ }
+
+ /**
+ * Convert passed image data, which is assumed to be SVG, to PNG.
+ *
+ * @param string $svg SVG image data
+ * @return string|bool PNG image data, or false on failure
+ */
+ protected function rasterize( $svg ) {
+ /**
+ * This code should be factored out to a separate method on SvgHandler, or perhaps a separate
+ * class, with a separate set of configuration settings.
+ *
+ * This is a distinct use case from regular SVG rasterization:
+ * * We can skip many sanity and security checks (as the images come from a trusted source,
+ * rather than from the user).
+ * * We need to provide extra options to some converters to achieve acceptable quality for very
+ * small images, which might cause performance issues in the general case.
+ * * We want to directly pass image data to the converter, rather than a file path.
+ *
+ * See https://phabricator.wikimedia.org/T76473#801446 for examples of what happens with the
+ * default settings.
+ *
+ * For now, we special-case rsvg (used in WMF production) and do a messy workaround for other
+ * converters.
+ */
+
+ global $wgSVGConverter, $wgSVGConverterPath;
+
+ $svg = $this->massageSvgPathdata( $svg );
+
+ // Sometimes this might be 'rsvg-secure'. Long as it's rsvg.
+ if ( strpos( $wgSVGConverter, 'rsvg' ) === 0 ) {
+ $command = 'rsvg-convert';
+ if ( $wgSVGConverterPath ) {
+ $command = wfEscapeShellArg( "$wgSVGConverterPath/" ) . $command;
+ }
+
+ $process = proc_open(
+ $command,
+ [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ] ],
+ $pipes
+ );
+
+ if ( is_resource( $process ) ) {
+ fwrite( $pipes[0], $svg );
+ fclose( $pipes[0] );
+ $png = stream_get_contents( $pipes[1] );
+ fclose( $pipes[1] );
+ proc_close( $process );
+
+ return $png ?: false;
+ }
+ return false;
+
+ } else {
+ // Write input to and read output from a temporary file
+ $tempFilenameSvg = tempnam( wfTempDir(), 'ResourceLoaderImage' );
+ $tempFilenamePng = tempnam( wfTempDir(), 'ResourceLoaderImage' );
+
+ file_put_contents( $tempFilenameSvg, $svg );
+
+ $metadata = SVGMetadataExtractor::getMetadata( $tempFilenameSvg );
+ if ( !isset( $metadata['width'] ) || !isset( $metadata['height'] ) ) {
+ unlink( $tempFilenameSvg );
+ return false;
+ }
+
+ $handler = new SvgHandler;
+ $res = $handler->rasterize(
+ $tempFilenameSvg,
+ $tempFilenamePng,
+ $metadata['width'],
+ $metadata['height']
+ );
+ unlink( $tempFilenameSvg );
+
+ $png = null;
+ if ( $res === true ) {
+ $png = file_get_contents( $tempFilenamePng );
+ unlink( $tempFilenamePng );
+ }
+
+ return $png ?: false;
+ }
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderImageModule.php b/www/wiki/includes/resourceloader/ResourceLoaderImageModule.php
new file mode 100644
index 00000000..8b549595
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderImageModule.php
@@ -0,0 +1,473 @@
+<?php
+/**
+ * ResourceLoader module for generated and embedded images.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ */
+
+/**
+ * ResourceLoader module for generated and embedded images.
+ *
+ * @since 1.25
+ */
+class ResourceLoaderImageModule extends ResourceLoaderModule {
+
+ protected $definition = null;
+
+ /**
+ * Local base path, see __construct()
+ * @var string
+ */
+ protected $localBasePath = '';
+
+ protected $origin = self::ORIGIN_CORE_SITEWIDE;
+
+ protected $images = [];
+ protected $variants = [];
+ protected $prefix = null;
+ protected $selectorWithoutVariant = '.{prefix}-{name}';
+ protected $selectorWithVariant = '.{prefix}-{name}-{variant}';
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * Constructs a new module from an options array.
+ *
+ * @param array $options List of options; if not given or empty, an empty module will be
+ * constructed
+ * @param string $localBasePath Base path to prepend to all local paths in $options. Defaults
+ * to $IP
+ *
+ * Below is a description for the $options array:
+ * @par Construction options:
+ * @code
+ * [
+ * // Base path to prepend to all local paths in $options. Defaults to $IP
+ * 'localBasePath' => [base path],
+ * // Path to JSON file that contains any of the settings below
+ * 'data' => [file path string]
+ * // CSS class prefix to use in all style rules
+ * 'prefix' => [CSS class prefix],
+ * // Alternatively: Format of CSS selector to use in all style rules
+ * 'selector' => [CSS selector template, variables: {prefix} {name} {variant}],
+ * // Alternatively: When using variants
+ * 'selectorWithoutVariant' => [CSS selector template, variables: {prefix} {name}],
+ * 'selectorWithVariant' => [CSS selector template, variables: {prefix} {name} {variant}],
+ * // List of variants that may be used for the image files
+ * 'variants' => [
+ * // This level of nesting can be omitted if you use the same images for every skin
+ * [skin name (or 'default')] => [
+ * [variant name] => [
+ * 'color' => [color string, e.g. '#ffff00'],
+ * 'global' => [boolean, if true, this variant is available
+ * for all images of this type],
+ * ],
+ * ...
+ * ],
+ * ...
+ * ],
+ * // List of image files and their options
+ * 'images' => [
+ * // This level of nesting can be omitted if you use the same images for every skin
+ * [skin name (or 'default')] => [
+ * [icon name] => [
+ * 'file' => [file path string or array whose values are file path strings
+ * and whose keys are 'default', 'ltr', 'rtl', a single
+ * language code like 'en', or a list of language codes like
+ * 'en,de,ar'],
+ * 'variants' => [array of variant name strings, variants
+ * available for this image],
+ * ],
+ * ...
+ * ],
+ * ...
+ * ],
+ * ]
+ * @endcode
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $options = [], $localBasePath = null ) {
+ $this->localBasePath = self::extractLocalBasePath( $options, $localBasePath );
+
+ $this->definition = $options;
+ }
+
+ /**
+ * Parse definition and external JSON data, if referenced.
+ */
+ protected function loadFromDefinition() {
+ if ( $this->definition === null ) {
+ return;
+ }
+
+ $options = $this->definition;
+ $this->definition = null;
+
+ if ( isset( $options['data'] ) ) {
+ $dataPath = $this->localBasePath . '/' . $options['data'];
+ $data = json_decode( file_get_contents( $dataPath ), true );
+ $options = array_merge( $data, $options );
+ }
+
+ // Accepted combinations:
+ // * prefix
+ // * selector
+ // * selectorWithoutVariant + selectorWithVariant
+ // * prefix + selector
+ // * prefix + selectorWithoutVariant + selectorWithVariant
+
+ $prefix = isset( $options['prefix'] ) && $options['prefix'];
+ $selector = isset( $options['selector'] ) && $options['selector'];
+ $selectorWithoutVariant = isset( $options['selectorWithoutVariant'] )
+ && $options['selectorWithoutVariant'];
+ $selectorWithVariant = isset( $options['selectorWithVariant'] )
+ && $options['selectorWithVariant'];
+
+ if ( $selectorWithoutVariant && !$selectorWithVariant ) {
+ throw new InvalidArgumentException(
+ "Given 'selectorWithoutVariant' but no 'selectorWithVariant'."
+ );
+ }
+ if ( $selectorWithVariant && !$selectorWithoutVariant ) {
+ throw new InvalidArgumentException(
+ "Given 'selectorWithVariant' but no 'selectorWithoutVariant'."
+ );
+ }
+ if ( $selector && $selectorWithVariant ) {
+ throw new InvalidArgumentException(
+ "Incompatible 'selector' and 'selectorWithVariant'+'selectorWithoutVariant' given."
+ );
+ }
+ if ( !$prefix && !$selector && !$selectorWithVariant ) {
+ throw new InvalidArgumentException(
+ "None of 'prefix', 'selector' or 'selectorWithVariant'+'selectorWithoutVariant' given."
+ );
+ }
+
+ foreach ( $options as $member => $option ) {
+ switch ( $member ) {
+ case 'images':
+ case 'variants':
+ if ( !is_array( $option ) ) {
+ throw new InvalidArgumentException(
+ "Invalid list error. '$option' given, array expected."
+ );
+ }
+ if ( !isset( $option['default'] ) ) {
+ // Backwards compatibility
+ $option = [ 'default' => $option ];
+ }
+ foreach ( $option as $skin => $data ) {
+ if ( !is_array( $option ) ) {
+ throw new InvalidArgumentException(
+ "Invalid list error. '$option' given, array expected."
+ );
+ }
+ }
+ $this->{$member} = $option;
+ break;
+
+ case 'prefix':
+ case 'selectorWithoutVariant':
+ case 'selectorWithVariant':
+ $this->{$member} = (string)$option;
+ break;
+
+ case 'selector':
+ $this->selectorWithoutVariant = $this->selectorWithVariant = (string)$option;
+ }
+ }
+ }
+
+ /**
+ * Get CSS class prefix used by this module.
+ * @return string
+ */
+ public function getPrefix() {
+ $this->loadFromDefinition();
+ return $this->prefix;
+ }
+
+ /**
+ * Get CSS selector templates used by this module.
+ * @return string
+ */
+ public function getSelectors() {
+ $this->loadFromDefinition();
+ return [
+ 'selectorWithoutVariant' => $this->selectorWithoutVariant,
+ 'selectorWithVariant' => $this->selectorWithVariant,
+ ];
+ }
+
+ /**
+ * Get a ResourceLoaderImage object for given image.
+ * @param string $name Image name
+ * @param ResourceLoaderContext $context
+ * @return ResourceLoaderImage|null
+ */
+ public function getImage( $name, ResourceLoaderContext $context ) {
+ $this->loadFromDefinition();
+ $images = $this->getImages( $context );
+ return isset( $images[$name] ) ? $images[$name] : null;
+ }
+
+ /**
+ * Get ResourceLoaderImage objects for all images.
+ * @param ResourceLoaderContext $context
+ * @return ResourceLoaderImage[] Array keyed by image name
+ */
+ public function getImages( ResourceLoaderContext $context ) {
+ $skin = $context->getSkin();
+ if ( !isset( $this->imageObjects ) ) {
+ $this->loadFromDefinition();
+ $this->imageObjects = [];
+ }
+ if ( !isset( $this->imageObjects[$skin] ) ) {
+ $this->imageObjects[$skin] = [];
+ if ( !isset( $this->images[$skin] ) ) {
+ $this->images[$skin] = isset( $this->images['default'] ) ?
+ $this->images['default'] :
+ [];
+ }
+ foreach ( $this->images[$skin] as $name => $options ) {
+ $fileDescriptor = is_string( $options ) ? $options : $options['file'];
+
+ $allowedVariants = array_merge(
+ is_array( $options ) && isset( $options['variants'] ) ? $options['variants'] : [],
+ $this->getGlobalVariants( $context )
+ );
+ if ( isset( $this->variants[$skin] ) ) {
+ $variantConfig = array_intersect_key(
+ $this->variants[$skin],
+ array_fill_keys( $allowedVariants, true )
+ );
+ } else {
+ $variantConfig = [];
+ }
+
+ $image = new ResourceLoaderImage(
+ $name,
+ $this->getName(),
+ $fileDescriptor,
+ $this->localBasePath,
+ $variantConfig
+ );
+ $this->imageObjects[$skin][$image->getName()] = $image;
+ }
+ }
+
+ return $this->imageObjects[$skin];
+ }
+
+ /**
+ * Get list of variants in this module that are 'global', i.e., available
+ * for every image regardless of image options.
+ * @param ResourceLoaderContext $context
+ * @return string[]
+ */
+ public function getGlobalVariants( ResourceLoaderContext $context ) {
+ $skin = $context->getSkin();
+ if ( !isset( $this->globalVariants ) ) {
+ $this->loadFromDefinition();
+ $this->globalVariants = [];
+ }
+ if ( !isset( $this->globalVariants[$skin] ) ) {
+ $this->globalVariants[$skin] = [];
+ if ( !isset( $this->variants[$skin] ) ) {
+ $this->variants[$skin] = isset( $this->variants['default'] ) ?
+ $this->variants['default'] :
+ [];
+ }
+ foreach ( $this->variants[$skin] as $name => $config ) {
+ if ( isset( $config['global'] ) && $config['global'] ) {
+ $this->globalVariants[$skin][] = $name;
+ }
+ }
+ }
+
+ return $this->globalVariants[$skin];
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getStyles( ResourceLoaderContext $context ) {
+ $this->loadFromDefinition();
+
+ // Build CSS rules
+ $rules = [];
+ $script = $context->getResourceLoader()->getLoadScript( $this->getSource() );
+ $selectors = $this->getSelectors();
+
+ foreach ( $this->getImages( $context ) as $name => $image ) {
+ $declarations = $this->getStyleDeclarations( $context, $image, $script );
+ $selector = strtr(
+ $selectors['selectorWithoutVariant'],
+ [
+ '{prefix}' => $this->getPrefix(),
+ '{name}' => $name,
+ '{variant}' => '',
+ ]
+ );
+ $rules[] = "$selector {\n\t$declarations\n}";
+
+ foreach ( $image->getVariants() as $variant ) {
+ $declarations = $this->getStyleDeclarations( $context, $image, $script, $variant );
+ $selector = strtr(
+ $selectors['selectorWithVariant'],
+ [
+ '{prefix}' => $this->getPrefix(),
+ '{name}' => $name,
+ '{variant}' => $variant,
+ ]
+ );
+ $rules[] = "$selector {\n\t$declarations\n}";
+ }
+ }
+
+ $style = implode( "\n", $rules );
+ return [ 'all' => $style ];
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @param ResourceLoaderImage $image Image to get the style for
+ * @param string $script URL to load.php
+ * @param string|null $variant Variant to get the style for
+ * @return string
+ */
+ private function getStyleDeclarations(
+ ResourceLoaderContext $context,
+ ResourceLoaderImage $image,
+ $script,
+ $variant = null
+ ) {
+ $imageDataUri = $image->getDataUri( $context, $variant, 'original' );
+ $primaryUrl = $imageDataUri ?: $image->getUrl( $context, $script, $variant, 'original' );
+ $declarations = $this->getCssDeclarations(
+ $primaryUrl,
+ $image->getUrl( $context, $script, $variant, 'rasterized' )
+ );
+ return implode( "\n\t", $declarations );
+ }
+
+ /**
+ * 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
+ *
+ * Keep synchronized with the .background-image-svg LESS mixin in
+ * /resources/src/mediawiki.less/mediawiki.mixins.less.
+ *
+ * @param string $primary Primary URI
+ * @param string $fallback Fallback URI
+ * @return string[] CSS declarations to use given URIs as background-image
+ */
+ protected function getCssDeclarations( $primary, $fallback ) {
+ $primaryUrl = CSSMin::buildUrlValue( $primary );
+ $fallbackUrl = CSSMin::buildUrlValue( $fallback );
+ return [
+ "background-image: $fallbackUrl;",
+ "background-image: linear-gradient(transparent, transparent), $primaryUrl;",
+ // Do not serve SVG to Opera 12, bad rendering with border-radius or background-size (T87504)
+ "background-image: -o-linear-gradient(transparent, transparent), $fallbackUrl;",
+ ];
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return false;
+ }
+
+ /**
+ * Get the definition summary for this module.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getDefinitionSummary( ResourceLoaderContext $context ) {
+ $this->loadFromDefinition();
+ $summary = parent::getDefinitionSummary( $context );
+
+ $options = [];
+ foreach ( [
+ 'localBasePath',
+ 'images',
+ 'variants',
+ 'prefix',
+ 'selectorWithoutVariant',
+ 'selectorWithVariant',
+ ] as $member ) {
+ $options[$member] = $this->{$member};
+ };
+
+ $summary[] = [
+ 'options' => $options,
+ 'fileHashes' => $this->getFileHashes( $context ),
+ ];
+ return $summary;
+ }
+
+ /**
+ * Helper method for getDefinitionSummary.
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ protected function getFileHashes( ResourceLoaderContext $context ) {
+ $this->loadFromDefinition();
+ $files = [];
+ foreach ( $this->getImages( $context ) as $name => $image ) {
+ $files[] = $image->getPath( $context );
+ }
+ $files = array_values( array_unique( $files ) );
+ return array_map( [ __CLASS__, 'safeFileHash' ], $files );
+ }
+
+ /**
+ * Extract a local base path from module definition information.
+ *
+ * @param array $options Module definition
+ * @param string $localBasePath Path to use if not provided in module definition. Defaults
+ * to $IP
+ * @return string Local base path
+ */
+ public static function extractLocalBasePath( $options, $localBasePath = null ) {
+ global $IP;
+
+ if ( $localBasePath === null ) {
+ $localBasePath = $IP;
+ }
+
+ if ( array_key_exists( 'localBasePath', $options ) ) {
+ $localBasePath = (string)$options['localBasePath'];
+ }
+
+ return $localBasePath;
+ }
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return self::LOAD_STYLES;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderJqueryMsgModule.php b/www/wiki/includes/resourceloader/ResourceLoaderJqueryMsgModule.php
new file mode 100644
index 00000000..bef34f99
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderJqueryMsgModule.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * ResourceLoader module for mediawiki.jqueryMsg that provides generated data.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * ResourceLoader module for mediawiki.jqueryMsg and its generated data
+ */
+class ResourceLoaderJqueryMsgModule extends ResourceLoaderFileModule {
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ $fileScript = parent::getScript( $context );
+
+ $tagData = Sanitizer::getRecognizedTagData();
+ $parserDefaults = [];
+ $parserDefaults['allowedHtmlElements'] = array_merge(
+ array_keys( $tagData['htmlpairs'] ),
+ array_diff(
+ array_keys( $tagData['htmlsingle'] ),
+ array_keys( $tagData['htmlsingleonly'] )
+ )
+ );
+
+ $mainDataScript = Xml::encodeJsCall( 'mw.jqueryMsg.setParserDefaults', [ $parserDefaults ] );
+
+ // Associative array mapping magic words (e.g. SITENAME)
+ // to their values.
+ $magicWords = [
+ 'SITENAME' => $this->getConfig()->get( 'Sitename' ),
+ ];
+
+ Hooks::run( 'ResourceLoaderJqueryMsgModuleMagicWords', [ $context, &$magicWords ] );
+
+ $magicWordExtendData = [
+ 'magic' => $magicWords,
+ ];
+
+ $magicWordDataScript = Xml::encodeJsCall( 'mw.jqueryMsg.setParserDefaults', [
+ $magicWordExtendData,
+ /* deep= */ true
+ ] );
+
+ return $fileScript . $mainDataScript . $magicWordDataScript;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getScriptURLsForDebug( ResourceLoaderContext $context ) {
+ // Bypass file module urls
+ return ResourceLoaderModule::getScriptURLsForDebug( $context );
+ }
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderLanguageDataModule.php b/www/wiki/includes/resourceloader/ResourceLoaderLanguageDataModule.php
new file mode 100644
index 00000000..ef942faf
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderLanguageDataModule.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * ResourceLoader module for populating language specific data.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Santhosh Thottingal
+ * @author Timo Tijhof
+ */
+
+/**
+ * ResourceLoader module for populating language specific data.
+ */
+class ResourceLoaderLanguageDataModule extends ResourceLoaderModule {
+
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * Get all the dynamic data for the content language to an array.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ protected function getData( ResourceLoaderContext $context ) {
+ $language = Language::factory( $context->getLanguage() );
+ return [
+ 'digitTransformTable' => $language->digitTransformTable(),
+ 'separatorTransformTable' => $language->separatorTransformTable(),
+ 'grammarForms' => $language->getGrammarForms(),
+ 'grammarTransformations' => $language->getGrammarTransformations(),
+ 'pluralRules' => $language->getPluralRules(),
+ 'digitGroupingPattern' => $language->digitGroupingPattern(),
+ 'fallbackLanguages' => $language->getFallbackLanguages(),
+ ];
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ return Xml::encodeJsCall(
+ 'mw.language.setData',
+ [
+ $context->getLanguage(),
+ $this->getData( $context )
+ ],
+ ResourceLoader::inDebugMode()
+ );
+ }
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ return [ 'mediawiki.language.init' ];
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderLanguageNamesModule.php b/www/wiki/includes/resourceloader/ResourceLoaderLanguageNamesModule.php
new file mode 100644
index 00000000..57260ba5
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderLanguageNamesModule.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * ResourceLoader module for providing language names.
+ *
+ * By default these names will be autonyms however other extensions may
+ * provided language names in the context language (e.g. cldr extension)
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Ed Sanders
+ * @author Trevor Parscal
+ */
+
+/**
+ * ResourceLoader module for populating language specific data.
+ */
+class ResourceLoaderLanguageNamesModule extends ResourceLoaderModule {
+
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ protected function getData( ResourceLoaderContext $context ) {
+ return Language::fetchLanguageNames(
+ $context->getLanguage(),
+ 'all'
+ );
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ return Xml::encodeJsCall(
+ 'mw.language.setData',
+ [
+ $context->getLanguage(),
+ 'languageNames',
+ $this->getData( $context )
+ ],
+ ResourceLoader::inDebugMode()
+ );
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ return [ 'mediawiki.language.init' ];
+ }
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderMediaWikiUtilModule.php b/www/wiki/includes/resourceloader/ResourceLoaderMediaWikiUtilModule.php
new file mode 100644
index 00000000..d16a4ff7
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderMediaWikiUtilModule.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * ResourceLoader mediawiki.util module
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * ResourceLoader module for mediawiki.util
+ *
+ * @since 1.30
+ */
+class ResourceLoaderMediaWikiUtilModule extends ResourceLoaderFileModule {
+ /**
+ * @inheritDoc
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ return ResourceLoader::makeConfigSetScript(
+ [ 'wgFragmentMode' => $this->getConfig()->get( 'FragmentMode' ) ]
+ )
+ . "\n"
+ . parent::getScript( $context );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function supportsURLLoading() {
+ return false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderModule.php b/www/wiki/includes/resourceloader/ResourceLoaderModule.php
new file mode 100644
index 00000000..b3c1cd14
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderModule.php
@@ -0,0 +1,1092 @@
+<?php
+/**
+ * Abstraction for ResourceLoader modules.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Abstraction for ResourceLoader modules, with name registration and maxage functionality.
+ */
+abstract class ResourceLoaderModule implements LoggerAwareInterface {
+ # Type of resource
+ const TYPE_SCRIPTS = 'scripts';
+ const TYPE_STYLES = 'styles';
+ const TYPE_COMBINED = 'combined';
+
+ # Desired load type
+ // Module only has styles (loaded via <style> or <link rel=stylesheet>)
+ const LOAD_STYLES = 'styles';
+ // Module may have other resources (loaded via mw.loader from a script)
+ const LOAD_GENERAL = 'general';
+
+ # sitewide core module like a skin file or jQuery component
+ const ORIGIN_CORE_SITEWIDE = 1;
+
+ # per-user module generated by the software
+ const ORIGIN_CORE_INDIVIDUAL = 2;
+
+ # sitewide module generated from user-editable files, like MediaWiki:Common.js, or
+ # modules accessible to multiple users, such as those generated by the Gadgets extension.
+ const ORIGIN_USER_SITEWIDE = 3;
+
+ # per-user module generated from user-editable files, like User:Me/vector.js
+ const ORIGIN_USER_INDIVIDUAL = 4;
+
+ # an access constant; make sure this is kept as the largest number in this group
+ const ORIGIN_ALL = 10;
+
+ # script and style modules form a hierarchy of trustworthiness, with core modules like
+ # skins and jQuery as most trustworthy, and user scripts as least trustworthy. We can
+ # limit the types of scripts and styles we allow to load on, say, sensitive special
+ # pages like Special:UserLogin and Special:Preferences
+ protected $origin = self::ORIGIN_CORE_SITEWIDE;
+
+ /* Protected Members */
+
+ protected $name = null;
+ protected $targets = [ 'desktop' ];
+
+ // In-object cache for file dependencies
+ protected $fileDeps = [];
+ // In-object cache for message blob (keyed by language)
+ protected $msgBlobs = [];
+ // In-object cache for version hash
+ protected $versionHash = [];
+ // In-object cache for module content
+ protected $contents = [];
+
+ /**
+ * @var Config
+ */
+ protected $config;
+
+ /**
+ * @var array|bool
+ */
+ protected $deprecated = false;
+
+ /**
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /* Methods */
+
+ /**
+ * Get this module's name. This is set when the module is registered
+ * with ResourceLoader::register()
+ *
+ * @return string|null Name (string) or null if no name was set
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Set this module's name. This is called by ResourceLoader::register()
+ * when registering the module. Other code should not call this.
+ *
+ * @param string $name Name
+ */
+ public function setName( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get this module's origin. This is set when the module is registered
+ * with ResourceLoader::register()
+ *
+ * @return int ResourceLoaderModule class constant, the subclass default
+ * if not set manually
+ */
+ public function getOrigin() {
+ return $this->origin;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public function getFlip( $context ) {
+ global $wgContLang;
+
+ return $wgContLang->getDir() !== $context->getDirection();
+ }
+
+ /**
+ * Get JS representing deprecation information for the current module if available
+ *
+ * @return string JavaScript code
+ */
+ protected function getDeprecationInformation() {
+ $deprecationInfo = $this->deprecated;
+ if ( $deprecationInfo ) {
+ $name = $this->getName();
+ $warning = 'This page is using the deprecated ResourceLoader module "' . $name . '".';
+ if ( is_string( $deprecationInfo ) ) {
+ $warning .= "\n" . $deprecationInfo;
+ }
+ return Xml::encodeJsCall(
+ 'mw.log.warn',
+ [ $warning ]
+ );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Get all JS for this module for a given language and skin.
+ * Includes all relevant JS except loader scripts.
+ *
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ // Stub, override expected
+ return '';
+ }
+
+ /**
+ * Takes named templates by the module and returns an array mapping.
+ *
+ * @return array of templates mapping template alias to content
+ */
+ public function getTemplates() {
+ // Stub, override expected.
+ return [];
+ }
+
+ /**
+ * @return Config
+ * @since 1.24
+ */
+ public function getConfig() {
+ if ( $this->config === null ) {
+ // Ugh, fall back to default
+ $this->config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+
+ return $this->config;
+ }
+
+ /**
+ * @param Config $config
+ * @since 1.24
+ */
+ public function setConfig( Config $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * @since 1.27
+ * @param LoggerInterface $logger
+ * @return null
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @since 1.27
+ * @return LoggerInterface
+ */
+ protected function getLogger() {
+ if ( !$this->logger ) {
+ $this->logger = new NullLogger();
+ }
+ return $this->logger;
+ }
+
+ /**
+ * Get the URL or URLs to load for this module's JS in debug mode.
+ * The default behavior is to return a load.php?only=scripts URL for
+ * the module, but file-based modules will want to override this to
+ * load the files directly.
+ *
+ * This function is called only when 1) we're in debug mode, 2) there
+ * is no only= parameter and 3) supportsURLLoading() returns true.
+ * #2 is important to prevent an infinite loop, therefore this function
+ * MUST return either an only= URL or a non-load.php URL.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array Array of URLs
+ */
+ public function getScriptURLsForDebug( ResourceLoaderContext $context ) {
+ $resourceLoader = $context->getResourceLoader();
+ $derivative = new DerivativeResourceLoaderContext( $context );
+ $derivative->setModules( [ $this->getName() ] );
+ $derivative->setOnly( 'scripts' );
+ $derivative->setDebug( true );
+
+ $url = $resourceLoader->createLoaderURL(
+ $this->getSource(),
+ $derivative
+ );
+
+ return [ $url ];
+ }
+
+ /**
+ * Whether this module supports URL loading. If this function returns false,
+ * getScript() will be used even in cases (debug mode, no only param) where
+ * getScriptURLsForDebug() would normally be used instead.
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return true;
+ }
+
+ /**
+ * Get all CSS for this module for a given skin.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array List of CSS strings or array of CSS strings keyed by media type.
+ * like [ 'screen' => '.foo { width: 0 }' ];
+ * or [ 'screen' => [ '.foo { width: 0 }' ] ];
+ */
+ public function getStyles( ResourceLoaderContext $context ) {
+ // Stub, override expected
+ return [];
+ }
+
+ /**
+ * Get the URL or URLs to load for this module's CSS in debug mode.
+ * The default behavior is to return a load.php?only=styles URL for
+ * the module, but file-based modules will want to override this to
+ * load the files directly. See also getScriptURLsForDebug()
+ *
+ * @param ResourceLoaderContext $context
+ * @return array [ mediaType => [ URL1, URL2, ... ], ... ]
+ */
+ public function getStyleURLsForDebug( ResourceLoaderContext $context ) {
+ $resourceLoader = $context->getResourceLoader();
+ $derivative = new DerivativeResourceLoaderContext( $context );
+ $derivative->setModules( [ $this->getName() ] );
+ $derivative->setOnly( 'styles' );
+ $derivative->setDebug( true );
+
+ $url = $resourceLoader->createLoaderURL(
+ $this->getSource(),
+ $derivative
+ );
+
+ return [ 'all' => [ $url ] ];
+ }
+
+ /**
+ * Get the messages needed for this module.
+ *
+ * To get a JSON blob with messages, use MessageBlobStore::get()
+ *
+ * @return array List of message keys. Keys may occur more than once
+ */
+ public function getMessages() {
+ // Stub, override expected
+ return [];
+ }
+
+ /**
+ * Get the group this module is in.
+ *
+ * @return string Group name
+ */
+ public function getGroup() {
+ // Stub, override expected
+ return null;
+ }
+
+ /**
+ * Get the origin of this module. Should only be overridden for foreign modules.
+ *
+ * @return string Origin name, 'local' for local modules
+ */
+ public function getSource() {
+ // Stub, override expected
+ return 'local';
+ }
+
+ /**
+ * From where in the page HTML should this module be loaded?
+ *
+ * @deprecated since 1.29 Obsolete. All modules load async from `<head>`.
+ * @return string
+ */
+ public function getPosition() {
+ return 'top';
+ }
+
+ /**
+ * Whether this module's JS expects to work without the client-side ResourceLoader module.
+ * Returning true from this function will prevent mw.loader.state() call from being
+ * appended to the bottom of the script.
+ *
+ * @return bool
+ */
+ public function isRaw() {
+ return false;
+ }
+
+ /**
+ * Get a list of modules this module depends on.
+ *
+ * Dependency information is taken into account when loading a module
+ * on the client side.
+ *
+ * Note: It is expected that $context will be made non-optional in the near
+ * future.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array List of module names as strings
+ */
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ // Stub, override expected
+ return [];
+ }
+
+ /**
+ * Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile']
+ *
+ * @return array Array of strings
+ */
+ public function getTargets() {
+ return $this->targets;
+ }
+
+ /**
+ * Get the module's load type.
+ *
+ * @since 1.28
+ * @return string ResourceLoaderModule LOAD_* constant
+ */
+ public function getType() {
+ return self::LOAD_GENERAL;
+ }
+
+ /**
+ * Get the skip function.
+ *
+ * Modules that provide fallback functionality can provide a "skip function". This
+ * function, if provided, will be passed along to the module registry on the client.
+ * When this module is loaded (either directly or as a dependency of another module),
+ * then this function is executed first. If the function returns true, the module will
+ * instantly be considered "ready" without requesting the associated module resources.
+ *
+ * The value returned here must be valid javascript for execution in a private function.
+ * It must not contain the "function () {" and "}" wrapper though.
+ *
+ * @return string|null A JavaScript function body returning a boolean value, or null
+ */
+ public function getSkipFunction() {
+ return null;
+ }
+
+ /**
+ * Get the files this module depends on indirectly for a given skin.
+ *
+ * These are only image files referenced by the module's stylesheet.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array List of files
+ */
+ protected function getFileDependencies( ResourceLoaderContext $context ) {
+ $vary = $context->getSkin() . '|' . $context->getLanguage();
+
+ // Try in-object cache first
+ if ( !isset( $this->fileDeps[$vary] ) ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $deps = $dbr->selectField( 'module_deps',
+ 'md_deps',
+ [
+ 'md_module' => $this->getName(),
+ 'md_skin' => $vary,
+ ],
+ __METHOD__
+ );
+
+ if ( !is_null( $deps ) ) {
+ $this->fileDeps[$vary] = self::expandRelativePaths(
+ (array)FormatJson::decode( $deps, true )
+ );
+ } else {
+ $this->fileDeps[$vary] = [];
+ }
+ }
+ return $this->fileDeps[$vary];
+ }
+
+ /**
+ * Set in-object cache for file dependencies.
+ *
+ * This is used to retrieve data in batches. See ResourceLoader::preloadModuleInfo().
+ * To save the data, use saveFileDependencies().
+ *
+ * @param ResourceLoaderContext $context
+ * @param string[] $files Array of file names
+ */
+ public function setFileDependencies( ResourceLoaderContext $context, $files ) {
+ $vary = $context->getSkin() . '|' . $context->getLanguage();
+ $this->fileDeps[$vary] = $files;
+ }
+
+ /**
+ * Set the files this module depends on indirectly for a given skin.
+ *
+ * @since 1.27
+ * @param ResourceLoaderContext $context
+ * @param array $localFileRefs List of files
+ */
+ protected function saveFileDependencies( ResourceLoaderContext $context, $localFileRefs ) {
+ try {
+ // Related bugs and performance considerations:
+ // 1. Don't needlessly change the database value with the same list in a
+ // different order or with duplicates.
+ // 2. Use relative paths to avoid ghost entries when $IP changes. (T111481)
+ // 3. Don't needlessly replace the database with the same value
+ // just because $IP changed (e.g. when upgrading a wiki).
+ // 4. Don't create an endless replace loop on every request for this
+ // module when '../' is used anywhere. Even though both are expanded
+ // (one expanded by getFileDependencies from the DB, the other is
+ // still raw as originally read by RL), the latter has not
+ // been normalized yet.
+
+ // Normalise
+ $localFileRefs = array_values( array_unique( $localFileRefs ) );
+ sort( $localFileRefs );
+ $localPaths = self::getRelativePaths( $localFileRefs );
+
+ $storedPaths = self::getRelativePaths( $this->getFileDependencies( $context ) );
+ // If the list has been modified since last time we cached it, update the cache
+ if ( $localPaths !== $storedPaths ) {
+ $vary = $context->getSkin() . '|' . $context->getLanguage();
+ $cache = ObjectCache::getLocalClusterInstance();
+ $key = $cache->makeKey( __METHOD__, $this->getName(), $vary );
+ $scopeLock = $cache->getScopedLock( $key, 0 );
+ if ( !$scopeLock ) {
+ return; // T124649; avoid write slams
+ }
+
+ $deps = FormatJson::encode( $localPaths );
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->upsert( 'module_deps',
+ [
+ 'md_module' => $this->getName(),
+ 'md_skin' => $vary,
+ 'md_deps' => $deps,
+ ],
+ [ 'md_module', 'md_skin' ],
+ [
+ 'md_deps' => $deps,
+ ]
+ );
+
+ if ( $dbw->trxLevel() ) {
+ $dbw->onTransactionResolution(
+ function () use ( &$scopeLock ) {
+ ScopedCallback::consume( $scopeLock ); // release after commit
+ },
+ __METHOD__
+ );
+ }
+ }
+ } catch ( Exception $e ) {
+ wfDebugLog( 'resourceloader', __METHOD__ . ": failed to update DB: $e" );
+ }
+ }
+
+ /**
+ * Make file paths relative to MediaWiki directory.
+ *
+ * This is used to make file paths safe for storing in a database without the paths
+ * becoming stale or incorrect when MediaWiki is moved or upgraded (T111481).
+ *
+ * @since 1.27
+ * @param array $filePaths
+ * @return array
+ */
+ public static function getRelativePaths( array $filePaths ) {
+ global $IP;
+ return array_map( function ( $path ) use ( $IP ) {
+ return RelPath\getRelativePath( $path, $IP );
+ }, $filePaths );
+ }
+
+ /**
+ * Expand directories relative to $IP.
+ *
+ * @since 1.27
+ * @param array $filePaths
+ * @return array
+ */
+ public static function expandRelativePaths( array $filePaths ) {
+ global $IP;
+ return array_map( function ( $path ) use ( $IP ) {
+ return RelPath\joinPath( $IP, $path );
+ }, $filePaths );
+ }
+
+ /**
+ * Get the hash of the message blob.
+ *
+ * @since 1.27
+ * @param ResourceLoaderContext $context
+ * @return string|null JSON blob or null if module has no messages
+ */
+ protected function getMessageBlob( ResourceLoaderContext $context ) {
+ if ( !$this->getMessages() ) {
+ // Don't bother consulting MessageBlobStore
+ return null;
+ }
+ // Message blobs may only vary language, not by context keys
+ $lang = $context->getLanguage();
+ if ( !isset( $this->msgBlobs[$lang] ) ) {
+ $this->getLogger()->warning( 'Message blob for {module} should have been preloaded', [
+ 'module' => $this->getName(),
+ ] );
+ $store = $context->getResourceLoader()->getMessageBlobStore();
+ $this->msgBlobs[$lang] = $store->getBlob( $this, $lang );
+ }
+ return $this->msgBlobs[$lang];
+ }
+
+ /**
+ * Set in-object cache for message blobs.
+ *
+ * Used to allow fetching of message blobs in batches. See ResourceLoader::preloadModuleInfo().
+ *
+ * @since 1.27
+ * @param string|null $blob JSON blob or null
+ * @param string $lang Language code
+ */
+ public function setMessageBlob( $blob, $lang ) {
+ $this->msgBlobs[$lang] = $blob;
+ }
+
+ /**
+ * Get headers to send as part of a module web response.
+ *
+ * It is not supported to send headers through this method that are
+ * required to be unique or otherwise sent once in an HTTP response
+ * because clients may make batch requests for multiple modules (as
+ * is the default behaviour for ResourceLoader clients).
+ *
+ * For exclusive or aggregated headers, see ResourceLoader::sendResponseHeaders().
+ *
+ * @since 1.30
+ * @param ResourceLoaderContext $context
+ * @return string[] Array of HTTP response headers
+ */
+ final public function getHeaders( ResourceLoaderContext $context ) {
+ $headers = [];
+
+ $formattedLinks = [];
+ foreach ( $this->getPreloadLinks( $context ) as $url => $attribs ) {
+ $link = "<{$url}>;rel=preload";
+ foreach ( $attribs as $key => $val ) {
+ $link .= ";{$key}={$val}";
+ }
+ $formattedLinks[] = $link;
+ }
+ if ( $formattedLinks ) {
+ $headers[] = 'Link: ' . implode( ',', $formattedLinks );
+ }
+
+ return $headers;
+ }
+
+ /**
+ * Get a list of resources that web browsers may preload.
+ *
+ * Behaviour of rel=preload link is specified at <https://www.w3.org/TR/preload/>.
+ *
+ * Use case for ResourceLoader originally part of T164299.
+ *
+ * @par Example
+ * @code
+ * protected function getPreloadLinks() {
+ * return [
+ * 'https://example.org/script.js' => [ 'as' => 'script' ],
+ * 'https://example.org/image.png' => [ 'as' => 'image' ],
+ * ];
+ * }
+ * @encode
+ *
+ * @par Example using HiDPI image variants
+ * @code
+ * protected function getPreloadLinks() {
+ * return [
+ * 'https://example.org/logo.png' => [
+ * 'as' => 'image',
+ * 'media' => 'not all and (min-resolution: 2dppx)',
+ * ],
+ * 'https://example.org/logo@2x.png' => [
+ * 'as' => 'image',
+ * 'media' => '(min-resolution: 2dppx)',
+ * ],
+ * ];
+ * }
+ * @encode
+ *
+ * @see ResourceLoaderModule::getHeaders
+ * @since 1.30
+ * @param ResourceLoaderContext $context
+ * @return array Keyed by url, values must be an array containing
+ * at least an 'as' key. Optionally a 'media' key as well.
+ */
+ protected function getPreloadLinks( ResourceLoaderContext $context ) {
+ return [];
+ }
+
+ /**
+ * Get module-specific LESS variables, if any.
+ *
+ * @since 1.27
+ * @param ResourceLoaderContext $context
+ * @return array Module-specific LESS variables.
+ */
+ protected function getLessVars( ResourceLoaderContext $context ) {
+ return [];
+ }
+
+ /**
+ * Get an array of this module's resources. Ready for serving to the web.
+ *
+ * @since 1.26
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getModuleContent( ResourceLoaderContext $context ) {
+ $contextHash = $context->getHash();
+ // Cache this expensive operation. This calls builds the scripts, styles, and messages
+ // content which typically involves filesystem and/or database access.
+ if ( !array_key_exists( $contextHash, $this->contents ) ) {
+ $this->contents[$contextHash] = $this->buildContent( $context );
+ }
+ return $this->contents[$contextHash];
+ }
+
+ /**
+ * Bundle all resources attached to this module into an array.
+ *
+ * @since 1.26
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ final protected function buildContent( ResourceLoaderContext $context ) {
+ $rl = $context->getResourceLoader();
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $statStart = microtime( true );
+
+ // Only include properties that are relevant to this context (e.g. only=scripts)
+ // and that are non-empty (e.g. don't include "templates" for modules without
+ // templates). This helps prevent invalidating cache for all modules when new
+ // optional properties are introduced.
+ $content = [];
+
+ // Scripts
+ if ( $context->shouldIncludeScripts() ) {
+ // If we are in debug mode, we'll want to return an array of URLs if possible
+ // However, we can't do this if the module doesn't support it
+ // We also can't do this if there is an only= parameter, because we have to give
+ // the module a way to return a load.php URL without causing an infinite loop
+ if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
+ $scripts = $this->getScriptURLsForDebug( $context );
+ } else {
+ $scripts = $this->getScript( $context );
+ // Make the script safe to concatenate by making sure there is at least one
+ // trailing new line at the end of the content. Previously, this looked for
+ // a semi-colon instead, but that breaks concatenation if the semicolon
+ // is inside a comment like "// foo();". Instead, simply use a
+ // line break as separator which matches JavaScript native logic for implicitly
+ // ending statements even if a semi-colon is missing.
+ // Bugs: T29054, T162719.
+ if ( is_string( $scripts )
+ && strlen( $scripts )
+ && substr( $scripts, -1 ) !== "\n"
+ ) {
+ $scripts .= "\n";
+ }
+ }
+ $content['scripts'] = $scripts;
+ }
+
+ // Styles
+ if ( $context->shouldIncludeStyles() ) {
+ $styles = [];
+ // Don't create empty stylesheets like [ '' => '' ] for modules
+ // that don't *have* any stylesheets (T40024).
+ $stylePairs = $this->getStyles( $context );
+ if ( count( $stylePairs ) ) {
+ // If we are in debug mode without &only= set, we'll want to return an array of URLs
+ // See comment near shouldIncludeScripts() for more details
+ if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
+ $styles = [
+ 'url' => $this->getStyleURLsForDebug( $context )
+ ];
+ } else {
+ // Minify CSS before embedding in mw.loader.implement call
+ // (unless in debug mode)
+ if ( !$context->getDebug() ) {
+ foreach ( $stylePairs as $media => $style ) {
+ // Can be either a string or an array of strings.
+ if ( is_array( $style ) ) {
+ $stylePairs[$media] = [];
+ foreach ( $style as $cssText ) {
+ if ( is_string( $cssText ) ) {
+ $stylePairs[$media][] =
+ ResourceLoader::filter( 'minify-css', $cssText );
+ }
+ }
+ } elseif ( is_string( $style ) ) {
+ $stylePairs[$media] = ResourceLoader::filter( 'minify-css', $style );
+ }
+ }
+ }
+ // Wrap styles into @media groups as needed and flatten into a numerical array
+ $styles = [
+ 'css' => $rl->makeCombinedStyles( $stylePairs )
+ ];
+ }
+ }
+ $content['styles'] = $styles;
+ }
+
+ // Messages
+ $blob = $this->getMessageBlob( $context );
+ if ( $blob ) {
+ $content['messagesBlob'] = $blob;
+ }
+
+ $templates = $this->getTemplates();
+ if ( $templates ) {
+ $content['templates'] = $templates;
+ }
+
+ $headers = $this->getHeaders( $context );
+ if ( $headers ) {
+ $content['headers'] = $headers;
+ }
+
+ $statTiming = microtime( true ) - $statStart;
+ $statName = strtr( $this->getName(), '.', '_' );
+ $stats->timing( "resourceloader_build.all", 1000 * $statTiming );
+ $stats->timing( "resourceloader_build.$statName", 1000 * $statTiming );
+
+ return $content;
+ }
+
+ /**
+ * Get a string identifying the current version of this module in a given context.
+ *
+ * Whenever anything happens that changes the module's response (e.g. scripts, styles, and
+ * messages) this value must change. This value is used to store module responses in cache.
+ * (Both client-side and server-side.)
+ *
+ * It is not recommended to override this directly. Use getDefinitionSummary() instead.
+ * If overridden, one must call the parent getVersionHash(), append data and re-hash.
+ *
+ * This method should be quick because it is frequently run by ResourceLoaderStartUpModule to
+ * propagate changes to the client and effectively invalidate cache.
+ *
+ * For backward-compatibility, the following optional data providers are automatically included:
+ *
+ * - getModifiedTime()
+ * - getModifiedHash()
+ *
+ * @since 1.26
+ * @param ResourceLoaderContext $context
+ * @return string Hash (should use ResourceLoader::makeHash)
+ */
+ public function getVersionHash( ResourceLoaderContext $context ) {
+ // The startup module produces a manifest with versions representing the entire module.
+ // Typically, the request for the startup module itself has only=scripts. That must apply
+ // only to the startup module content, and not to the module version computed here.
+ $context = new DerivativeResourceLoaderContext( $context );
+ $context->setModules( [] );
+ // Version hash must cover all resources, regardless of startup request itself.
+ $context->setOnly( null );
+ // Compute version hash based on content, not debug urls.
+ $context->setDebug( false );
+
+ // Cache this somewhat expensive operation. Especially because some classes
+ // (e.g. startup module) iterate more than once over all modules to get versions.
+ $contextHash = $context->getHash();
+ if ( !array_key_exists( $contextHash, $this->versionHash ) ) {
+ if ( $this->enableModuleContentVersion() ) {
+ // Detect changes directly
+ $str = json_encode( $this->getModuleContent( $context ) );
+ } else {
+ // Infer changes based on definition and other metrics
+ $summary = $this->getDefinitionSummary( $context );
+ if ( !isset( $summary['_cacheEpoch'] ) ) {
+ throw new LogicException( 'getDefinitionSummary must call parent method' );
+ }
+ $str = json_encode( $summary );
+
+ $mtime = $this->getModifiedTime( $context );
+ if ( $mtime !== null ) {
+ // Support: MediaWiki 1.25 and earlier
+ $str .= strval( $mtime );
+ }
+
+ $mhash = $this->getModifiedHash( $context );
+ if ( $mhash !== null ) {
+ // Support: MediaWiki 1.25 and earlier
+ $str .= strval( $mhash );
+ }
+ }
+
+ $this->versionHash[$contextHash] = ResourceLoader::makeHash( $str );
+ }
+ return $this->versionHash[$contextHash];
+ }
+
+ /**
+ * Whether to generate version hash based on module content.
+ *
+ * If a module requires database or file system access to build the module
+ * content, consider disabling this in favour of manually tracking relevant
+ * aspects in getDefinitionSummary(). See getVersionHash() for how this is used.
+ *
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return false;
+ }
+
+ /**
+ * Get the definition summary for this module.
+ *
+ * This is the method subclasses are recommended to use to track values in their
+ * version hash. Call this in getVersionHash() and pass it to e.g. json_encode.
+ *
+ * Subclasses must call the parent getDefinitionSummary() and build on that.
+ * It is recommended that each subclass appends its own new array. This prevents
+ * clashes or accidental overwrites of existing keys and gives each subclass
+ * its own scope for simple array keys.
+ *
+ * @code
+ * $summary = parent::getDefinitionSummary( $context );
+ * $summary[] = [
+ * 'foo' => 123,
+ * 'bar' => 'quux',
+ * ];
+ * return $summary;
+ * @endcode
+ *
+ * Return an array containing values from all significant properties of this
+ * module's definition.
+ *
+ * Be careful not to normalise too much. Especially preserve the order of things
+ * that carry significance in getScript and getStyles (T39812).
+ *
+ * Avoid including things that are insiginificant (e.g. order of message keys is
+ * insignificant and should be sorted to avoid unnecessary cache invalidation).
+ *
+ * This data structure must exclusively contain arrays and scalars as values (avoid
+ * object instances) to allow simple serialisation using json_encode.
+ *
+ * If modules have a hash or timestamp from another source, that may be incuded as-is.
+ *
+ * A number of utility methods are available to help you gather data. These are not
+ * called by default and must be included by the subclass' getDefinitionSummary().
+ *
+ * - getMessageBlob()
+ *
+ * @since 1.23
+ * @param ResourceLoaderContext $context
+ * @return array|null
+ */
+ public function getDefinitionSummary( ResourceLoaderContext $context ) {
+ return [
+ '_class' => static::class,
+ '_cacheEpoch' => $this->getConfig()->get( 'CacheEpoch' ),
+ ];
+ }
+
+ /**
+ * Get this module's last modification timestamp for a given context.
+ *
+ * @deprecated since 1.26 Use getDefinitionSummary() instead
+ * @param ResourceLoaderContext $context Context object
+ * @return int|null UNIX timestamp
+ */
+ public function getModifiedTime( ResourceLoaderContext $context ) {
+ return null;
+ }
+
+ /**
+ * Helper method for providing a version hash to getVersionHash().
+ *
+ * @deprecated since 1.26 Use getDefinitionSummary() instead
+ * @param ResourceLoaderContext $context
+ * @return string|null Hash
+ */
+ public function getModifiedHash( ResourceLoaderContext $context ) {
+ return null;
+ }
+
+ /**
+ * Back-compat dummy for old subclass implementations of getModifiedTime().
+ *
+ * This method used to use ObjectCache to track when a hash was first seen. That principle
+ * stems from a time that ResourceLoader could only identify module versions by timestamp.
+ * That is no longer the case. Use getDefinitionSummary() directly.
+ *
+ * @deprecated since 1.26 Superseded by getVersionHash()
+ * @param ResourceLoaderContext $context
+ * @return int UNIX timestamp
+ */
+ public function getHashMtime( ResourceLoaderContext $context ) {
+ if ( !is_string( $this->getModifiedHash( $context ) ) ) {
+ return 1;
+ }
+ // Dummy that is > 1
+ return 2;
+ }
+
+ /**
+ * Back-compat dummy for old subclass implementations of getModifiedTime().
+ *
+ * @since 1.23
+ * @deprecated since 1.26 Superseded by getVersionHash()
+ * @param ResourceLoaderContext $context
+ * @return int UNIX timestamp
+ */
+ public function getDefinitionMtime( ResourceLoaderContext $context ) {
+ if ( $this->getDefinitionSummary( $context ) === null ) {
+ return 1;
+ }
+ // Dummy that is > 1
+ return 2;
+ }
+
+ /**
+ * Check whether this module is known to be empty. If a child class
+ * has an easy and cheap way to determine that this module is
+ * definitely going to be empty, it should override this method to
+ * return true in that case. Callers may optimize the request for this
+ * module away if this function returns true.
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public function isKnownEmpty( ResourceLoaderContext $context ) {
+ return false;
+ }
+
+ /**
+ * Check whether this module should be embeded rather than linked
+ *
+ * Modules returning true here will be embedded rather than loaded by
+ * ResourceLoaderClientHtml.
+ *
+ * @since 1.30
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public function shouldEmbedModule( ResourceLoaderContext $context ) {
+ return $this->getGroup() === 'private';
+ }
+
+ /** @var JSParser Lazy-initialized; use self::javaScriptParser() */
+ private static $jsParser;
+ private static $parseCacheVersion = 1;
+
+ /**
+ * Validate a given script file; if valid returns the original source.
+ * If invalid, returns replacement JS source that throws an exception.
+ *
+ * @param string $fileName
+ * @param string $contents
+ * @return string JS with the original, or a replacement error
+ */
+ protected function validateScriptFile( $fileName, $contents ) {
+ if ( !$this->getConfig()->get( 'ResourceLoaderValidateJS' ) ) {
+ return $contents;
+ }
+ $cache = ObjectCache::getMainWANInstance();
+ return $cache->getWithSetCallback(
+ $cache->makeGlobalKey(
+ 'resourceloader',
+ 'jsparse',
+ self::$parseCacheVersion,
+ md5( $contents ),
+ $fileName
+ ),
+ $cache::TTL_WEEK,
+ function () use ( $contents, $fileName ) {
+ $parser = self::javaScriptParser();
+ try {
+ $parser->parse( $contents, $fileName, 1 );
+ $result = $contents;
+ } catch ( Exception $e ) {
+ // We'll save this to cache to avoid having to re-validate broken JS
+ $err = $e->getMessage();
+ $result = "mw.log.error(" .
+ Xml::encodeJsVar( "JavaScript parse error: $err" ) . ");";
+ }
+ return $result;
+ }
+ );
+ }
+
+ /**
+ * @return JSParser
+ */
+ protected static function javaScriptParser() {
+ if ( !self::$jsParser ) {
+ self::$jsParser = new JSParser();
+ }
+ return self::$jsParser;
+ }
+
+ /**
+ * Safe version of filemtime(), which doesn't throw a PHP warning if the file doesn't exist.
+ * Defaults to 1.
+ *
+ * @param string $filePath File path
+ * @return int UNIX timestamp
+ */
+ protected static function safeFilemtime( $filePath ) {
+ MediaWiki\suppressWarnings();
+ $mtime = filemtime( $filePath ) ?: 1;
+ MediaWiki\restoreWarnings();
+ return $mtime;
+ }
+
+ /**
+ * Compute a non-cryptographic string hash of a file's contents.
+ * If the file does not exist or cannot be read, returns an empty string.
+ *
+ * @since 1.26 Uses MD4 instead of SHA1.
+ * @param string $filePath File path
+ * @return string Hash
+ */
+ protected static function safeFileHash( $filePath ) {
+ return FileContentsHasher::getFileContentsHash( $filePath );
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderOOUIFileModule.php b/www/wiki/includes/resourceloader/ResourceLoaderOOUIFileModule.php
new file mode 100644
index 00000000..e97e0742
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderOOUIFileModule.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * ResourceLoaderFileModule which magically loads the right skinScripts and skinStyles for every
+ * skin, using the specified OOUI theme for each.
+ *
+ * @since 1.30
+ */
+class ResourceLoaderOOUIFileModule extends ResourceLoaderFileModule {
+ use ResourceLoaderOOUIModule;
+
+ public function __construct( $options = [] ) {
+ if ( isset( $options[ 'themeScripts' ] ) ) {
+ $skinScripts = $this->getSkinSpecific( $options[ 'themeScripts' ], 'scripts' );
+ if ( !isset( $options['skinScripts'] ) ) {
+ $options['skinScripts'] = [];
+ }
+ $this->extendSkinSpecific( $options['skinScripts'], $skinScripts );
+ }
+ if ( isset( $options[ 'themeStyles' ] ) ) {
+ $skinStyles = $this->getSkinSpecific( $options[ 'themeStyles' ], 'styles' );
+ if ( !isset( $options['skinStyles'] ) ) {
+ $options['skinStyles'] = [];
+ }
+ $this->extendSkinSpecific( $options['skinStyles'], $skinStyles );
+ }
+
+ parent::__construct( $options );
+ }
+
+ /**
+ * Helper function to generate values for 'skinStyles' and 'skinScripts'.
+ *
+ * @param string $module Module to generate skinStyles/skinScripts for:
+ * 'core', 'widgets', 'toolbars', 'windows'
+ * @param string $which 'scripts' or 'styles'
+ * @return array
+ */
+ private function getSkinSpecific( $module, $which ) {
+ $themes = self::getSkinThemeMap();
+
+ return array_combine(
+ array_keys( $themes ),
+ array_map( function ( $theme ) use ( $module, $which ) {
+ if ( $which === 'scripts' ) {
+ return $this->getThemeScriptsPath( $theme, $module );
+ } else {
+ return $this->getThemeStylesPath( $theme, $module );
+ }
+ }, array_values( $themes ) )
+ );
+ }
+
+ /**
+ * Prepend the $extraSkinSpecific assoc. array to the $skinSpecific assoc. array.
+ * Both of them represent a 'skinScripts' or 'skinStyles' definition.
+ *
+ * @param array &$skinSpecific
+ * @param array $extraSkinSpecific
+ */
+ private function extendSkinSpecific( &$skinSpecific, $extraSkinSpecific ) {
+ // For each skin where skinStyles/skinScripts are defined, add our ones at the beginning
+ foreach ( $skinSpecific as $skin => $files ) {
+ if ( !is_array( $files ) ) {
+ $files = [ $files ];
+ }
+ if ( isset( $extraSkinSpecific[$skin] ) ) {
+ $skinSpecific[$skin] = array_merge( [ $extraSkinSpecific[$skin] ], $files );
+ } elseif ( isset( $extraSkinSpecific['default'] ) ) {
+ $skinSpecific[$skin] = array_merge( [ $extraSkinSpecific['default'] ], $files );
+ }
+ }
+ // Add our remaining skinStyles/skinScripts for skins that did not have them defined
+ foreach ( $extraSkinSpecific as $skin => $file ) {
+ if ( !isset( $skinSpecific[$skin] ) ) {
+ $skinSpecific[$skin] = $file;
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderOOUIImageModule.php b/www/wiki/includes/resourceloader/ResourceLoaderOOUIImageModule.php
new file mode 100644
index 00000000..ee87d8d8
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderOOUIImageModule.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Secret special sauce.
+ *
+ * @since 1.26
+ */
+class ResourceLoaderOOUIImageModule extends ResourceLoaderImageModule {
+ use ResourceLoaderOOUIModule;
+
+ protected function loadFromDefinition() {
+ if ( $this->definition === null ) {
+ // Do nothing if definition was already processed
+ return;
+ }
+
+ $themes = self::getSkinThemeMap();
+
+ // For backwards-compatibility, allow missing 'themeImages'
+ $module = isset( $this->definition['themeImages'] ) ? $this->definition['themeImages'] : '';
+
+ $definition = [];
+ foreach ( $themes as $skin => $theme ) {
+ // Find the path to the JSON file which contains the actual image definitions for this theme
+ if ( $module ) {
+ $dataPath = $this->getThemeImagesPath( $theme, $module );
+ } else {
+ // Backwards-compatibility for things that probably shouldn't have used this class...
+ $dataPath =
+ $this->definition['rootPath'] . '/' .
+ strtolower( $theme ) . '/' .
+ $this->definition['name'] . '.json';
+ }
+ $localDataPath = $this->localBasePath . '/' . $dataPath;
+
+ // If there's no file for this module of this theme, that's okay, it will just use the defaults
+ if ( !file_exists( $localDataPath ) ) {
+ continue;
+ }
+ $data = json_decode( file_get_contents( $localDataPath ), true );
+
+ // Expand the paths to images (since they are relative to the JSON file that defines them, not
+ // our base directory)
+ $fixPath = function ( &$path ) use ( $dataPath ) {
+ $path = dirname( $dataPath ) . '/' . $path;
+ };
+ array_walk( $data['images'], function ( &$value ) use ( $fixPath ) {
+ if ( is_string( $value['file'] ) ) {
+ $fixPath( $value['file'] );
+ } elseif ( is_array( $value['file'] ) ) {
+ array_walk_recursive( $value['file'], $fixPath );
+ }
+ } );
+
+ // Convert into a definition compatible with the parent vanilla ResourceLoaderImageModule
+ foreach ( $data as $key => $value ) {
+ switch ( $key ) {
+ // Images and color variants are defined per-theme, here converted to per-skin
+ case 'images':
+ case 'variants':
+ $definition[$key][$skin] = $data[$key];
+ break;
+
+ // Other options must be identical for each theme (or only defined in the default one)
+ default:
+ if ( !isset( $definition[$key] ) ) {
+ $definition[$key] = $data[$key];
+ } elseif ( $definition[$key] !== $data[$key] ) {
+ throw new Exception(
+ "Mismatched OOUI theme images definition: " .
+ "key '$key' of theme '$theme' for module '$module' " .
+ "does not match other themes"
+ );
+ }
+ break;
+ }
+ }
+ }
+
+ // Extra selectors to allow using the same icons for old-style MediaWiki UI code
+ if ( substr( $module, 0, 5 ) === 'icons' ) {
+ $definition['selectorWithoutVariant'] = '.oo-ui-icon-{name}, .mw-ui-icon-{name}:before';
+ $definition['selectorWithVariant'] = '
+ .oo-ui-image-{variant}.oo-ui-icon-{name}, .mw-ui-icon-{name}-{variant}:before,
+ /* Hack for Flow, see T110051 */
+ .mw-ui-hovericon:hover .mw-ui-icon-{name}-{variant}-hover:before,
+ .mw-ui-hovericon.mw-ui-icon-{name}-{variant}-hover:hover:before';
+ }
+
+ // Fields from module definition silently override keys from JSON files
+ $this->definition += $definition;
+
+ parent::loadFromDefinition();
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderOOUIModule.php b/www/wiki/includes/resourceloader/ResourceLoaderOOUIModule.php
new file mode 100644
index 00000000..4228a45f
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderOOUIModule.php
@@ -0,0 +1,146 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Convenience methods for dealing with OOUI themes and their relations to MW skins.
+ *
+ * @since 1.30
+ */
+trait ResourceLoaderOOUIModule {
+ protected static $knownScriptsModules = [ 'core' ];
+ protected static $knownStylesModules = [ 'core', 'widgets', 'toolbars', 'windows' ];
+ protected static $knownImagesModules = [
+ 'indicators', 'textures',
+ // Extra icons
+ 'icons-accessibility',
+ 'icons-alerts',
+ 'icons-content',
+ 'icons-editing-advanced',
+ 'icons-editing-core',
+ 'icons-editing-list',
+ 'icons-editing-styling',
+ 'icons-interactions',
+ 'icons-layout',
+ 'icons-location',
+ 'icons-media',
+ 'icons-moderation',
+ 'icons-movement',
+ 'icons-user',
+ 'icons-wikimedia',
+ ];
+
+ // Note that keys must be lowercase, values TitleCase.
+ protected static $builtinSkinThemeMap = [
+ 'default' => 'WikimediaUI',
+ ];
+
+ // Note that keys must be TitleCase.
+ protected static $builtinThemePaths = [
+ 'WikimediaUI' => [
+ 'scripts' => 'resources/lib/oojs-ui/oojs-ui-wikimediaui.js',
+ 'styles' => 'resources/lib/oojs-ui/oojs-ui-{module}-wikimediaui.css',
+ 'images' => 'resources/lib/oojs-ui/themes/wikimediaui/{module}.json',
+ ],
+ 'Apex' => [
+ 'scripts' => 'resources/lib/oojs-ui/oojs-ui-apex.js',
+ 'styles' => 'resources/lib/oojs-ui/oojs-ui-{module}-apex.css',
+ 'images' => 'resources/lib/oojs-ui/themes/apex/{module}.json',
+ ],
+ ];
+
+ /**
+ * Return a map of skin names (in lowercase) to OOUI theme names, defining which theme a given
+ * skin should use.
+ *
+ * @return array
+ */
+ public static function getSkinThemeMap() {
+ $themeMap = self::$builtinSkinThemeMap;
+ $themeMap += ExtensionRegistry::getInstance()->getAttribute( 'SkinOOUIThemes' );
+ return $themeMap;
+ }
+
+ /**
+ * Return a map of theme names to lists of paths from which a given theme should be loaded.
+ *
+ * Keys are theme names, values are associative arrays. Keys of the inner array are 'scripts',
+ * 'styles', or 'images', and values are string paths.
+ *
+ * Additionally, the string '{module}' in paths represents the name of the module to load.
+ *
+ * @return array
+ */
+ protected static function getThemePaths() {
+ $themePaths = self::$builtinThemePaths;
+ return $themePaths;
+ }
+
+ /**
+ * Return a path to load given module of given theme from.
+ *
+ * @param string $theme OOUI theme name, for example 'WikimediaUI' or 'Apex'
+ * @param string $kind Kind of the module: 'scripts', 'styles', or 'images'
+ * @param string $module Module name, for valid values see $knownScriptsModules,
+ * $knownStylesModules, $knownImagesModules
+ * @return string
+ */
+ protected function getThemePath( $theme, $kind, $module ) {
+ $paths = self::getThemePaths();
+ $path = $paths[ $theme ][ $kind ];
+ $path = str_replace( '{module}', $module, $path );
+ return $path;
+ }
+
+ /**
+ * @param string $theme See getThemePath()
+ * @param string $module See getThemePath()
+ * @return string
+ */
+ protected function getThemeScriptsPath( $theme, $module ) {
+ if ( !in_array( $module, self::$knownScriptsModules ) ) {
+ throw new InvalidArgumentException( "Invalid OOUI scripts module '$module'" );
+ }
+ return $this->getThemePath( $theme, 'scripts', $module );
+ }
+
+ /**
+ * @param string $theme See getThemePath()
+ * @param string $module See getThemePath()
+ * @return string
+ */
+ protected function getThemeStylesPath( $theme, $module ) {
+ if ( !in_array( $module, self::$knownStylesModules ) ) {
+ throw new InvalidArgumentException( "Invalid OOUI styles module '$module'" );
+ }
+ return $this->getThemePath( $theme, 'styles', $module );
+ }
+
+ /**
+ * @param string $theme See getThemePath()
+ * @param string $module See getThemePath()
+ * @return string
+ */
+ protected function getThemeImagesPath( $theme, $module ) {
+ if ( !in_array( $module, self::$knownImagesModules ) ) {
+ throw new InvalidArgumentException( "Invalid OOUI images module '$module'" );
+ }
+ return $this->getThemePath( $theme, 'images', $module );
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderRawFileModule.php b/www/wiki/includes/resourceloader/ResourceLoaderRawFileModule.php
new file mode 100644
index 00000000..beab53eb
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderRawFileModule.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * Module containing files that are loaded without ResourceLoader.
+ *
+ * Primary usecase being "base" modules loaded by the startup module,
+ * such as jquery and the mw.loader client itself. These make use of
+ * ResourceLoaderModule and load.php for convenience but aren't actually
+ * registered in the startup module (as it would have to load itself).
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Timo Tijhof
+ */
+
+class ResourceLoaderRawFileModule extends ResourceLoaderFileModule {
+
+ /**
+ * Enable raw mode to omit mw.loader.state() call as mw.loader
+ * does not yet exist when these modules execute.
+ * @var bool
+ */
+ protected $raw = true;
+
+ /**
+ * Get all JavaScript code.
+ *
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ $script = parent::getScript( $context );
+ // Add closure explicitly because raw modules can't be wrapped mw.loader.implement.
+ // Unlike with mw.loader.implement, this closure is immediately invoked.
+ // @see ResourceLoader::makeModuleResponse
+ // @see ResourceLoader::makeLoaderImplementScript
+ return "(function () {\n{$script}\n}());";
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderSiteModule.php b/www/wiki/includes/resourceloader/ResourceLoaderSiteModule.php
new file mode 100644
index 00000000..236112ea
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderSiteModule.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * ResourceLoader module for site customizations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for site customizations
+ */
+class ResourceLoaderSiteModule extends ResourceLoaderWikiModule {
+
+ /**
+ * Get list of pages used by this module
+ *
+ * @param ResourceLoaderContext $context
+ * @return array List of pages
+ */
+ protected function getPages( ResourceLoaderContext $context ) {
+ $pages = [];
+ if ( $this->getConfig()->get( 'UseSiteJs' ) ) {
+ $pages['MediaWiki:Common.js'] = [ 'type' => 'script' ];
+ $pages['MediaWiki:' . ucfirst( $context->getSkin() ) . '.js'] = [ 'type' => 'script' ];
+ }
+ return $pages;
+ }
+
+ /**
+ * @param ResourceLoaderContext|null $context
+ * @return array
+ */
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ return [ 'site.styles' ];
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderSiteStylesModule.php b/www/wiki/includes/resourceloader/ResourceLoaderSiteStylesModule.php
new file mode 100644
index 00000000..79922bfe
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderSiteStylesModule.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * ResourceLoader module for site style customizations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for site style customizations
+ */
+class ResourceLoaderSiteStylesModule extends ResourceLoaderWikiModule {
+
+ /**
+ * Get list of pages used by this module
+ *
+ * @param ResourceLoaderContext $context
+ * @return array List of pages
+ */
+ protected function getPages( ResourceLoaderContext $context ) {
+ $pages = [];
+ if ( $this->getConfig()->get( 'UseSiteCss' ) ) {
+ $pages['MediaWiki:Common.css'] = [ 'type' => 'style' ];
+ $pages['MediaWiki:' . ucfirst( $context->getSkin() ) . '.css'] = [ 'type' => 'style' ];
+ $pages['MediaWiki:Print.css'] = [ 'type' => 'style', 'media' => 'print' ];
+
+ }
+ return $pages;
+ }
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return self::LOAD_STYLES;
+ }
+
+ /**
+ * @return string
+ */
+ public function getGroup() {
+ return 'site';
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderSkinModule.php b/www/wiki/includes/resourceloader/ResourceLoaderSkinModule.php
new file mode 100644
index 00000000..ca6e59f2
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderSkinModule.php
@@ -0,0 +1,141 @@
+<?php
+/**
+ * ResourceLoader module for skin stylesheets.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Timo Tijhof
+ */
+
+class ResourceLoaderSkinModule extends ResourceLoaderFileModule {
+ /**
+ * All skins are assumed to be compatible with mobile
+ */
+ public $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getStyles( ResourceLoaderContext $context ) {
+ $logo = $this->getLogo( $this->getConfig() );
+ $styles = parent::getStyles( $context );
+ $this->normalizeStyles( $styles );
+
+ $default = !is_array( $logo ) ? $logo : $logo['1x'];
+ $styles['all'][] = '.mw-wiki-logo { background-image: ' .
+ CSSMin::buildUrlValue( $default ) .
+ '; }';
+
+ if ( is_array( $logo ) ) {
+ if ( isset( $logo['1.5x'] ) ) {
+ $styles[
+ '(-webkit-min-device-pixel-ratio: 1.5), ' .
+ '(min--moz-device-pixel-ratio: 1.5), ' .
+ '(min-resolution: 1.5dppx), ' .
+ '(min-resolution: 144dpi)'
+ ][] = '.mw-wiki-logo { background-image: ' .
+ CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
+ 'background-size: 135px auto; }';
+ }
+ if ( isset( $logo['2x'] ) ) {
+ $styles[
+ '(-webkit-min-device-pixel-ratio: 2), ' .
+ '(min--moz-device-pixel-ratio: 2),' .
+ '(min-resolution: 2dppx), ' .
+ '(min-resolution: 192dpi)'
+ ][] = '.mw-wiki-logo { background-image: ' .
+ CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
+ 'background-size: 135px auto; }';
+ }
+ }
+
+ return $styles;
+ }
+
+ /**
+ * Ensure all media keys use array values.
+ *
+ * Normalises arrays returned by the ResourceLoaderFileModule::getStyles() method.
+ *
+ * @param array &$styles Associative array, keys are strings (media queries),
+ * values are strings or arrays
+ */
+ private function normalizeStyles( &$styles ) {
+ foreach ( $styles as $key => $val ) {
+ if ( !is_array( $val ) ) {
+ $styles[$key] = [ $val ];
+ }
+ }
+ }
+
+ /**
+ * @param Config $conf
+ * @return string|array Single url if no variants are defined
+ * or array of logo urls keyed by dppx in form "<float>x".
+ * Key "1x" is always defined.
+ */
+ public static function getLogo( Config $conf ) {
+ $logo = $conf->get( 'Logo' );
+ $logoHD = $conf->get( 'LogoHD' );
+
+ $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
+
+ if ( !$logoHD ) {
+ return $logo1Url;
+ }
+
+ $logoUrls = [
+ '1x' => $logo1Url,
+ ];
+
+ // Only 1.5x and 2x are supported
+ if ( isset( $logoHD['1.5x'] ) ) {
+ $logoUrls['1.5x'] = OutputPage::transformResourcePath(
+ $conf,
+ $logoHD['1.5x']
+ );
+ }
+ if ( isset( $logoHD['2x'] ) ) {
+ $logoUrls['2x'] = OutputPage::transformResourcePath(
+ $conf,
+ $logoHD['2x']
+ );
+ }
+
+ return $logoUrls;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public function isKnownEmpty( ResourceLoaderContext $context ) {
+ // Regardless of whether the files are specified, we always
+ // provide mw-wiki-logo styles.
+ return false;
+ }
+
+ public function getDefinitionSummary( ResourceLoaderContext $context ) {
+ $summary = parent::getDefinitionSummary( $context );
+ $summary[] = [
+ 'logo' => $this->getConfig()->get( 'Logo' ),
+ 'logoHD' => $this->getConfig()->get( 'LogoHD' ),
+ ];
+ return $summary;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php b/www/wiki/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php
new file mode 100644
index 00000000..a0061e35
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * ResourceLoader module for populating special characters data for some
+ * editing extensions to use.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * ResourceLoader module for populating special characters data for some
+ * editing extensions to use.
+ */
+class ResourceLoaderSpecialCharacterDataModule extends ResourceLoaderModule {
+ private $path = "resources/src/mediawiki.language/specialcharacters.json";
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * Get all the dynamic data.
+ *
+ * @return array
+ */
+ protected function getData() {
+ global $IP;
+ return json_decode( file_get_contents( "$IP/{$this->path}" ) );
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ return Xml::encodeJsCall(
+ 'mw.language.setSpecialCharacters',
+ [
+ $this->getData()
+ ],
+ ResourceLoader::inDebugMode()
+ );
+ }
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ return [ 'mediawiki.language' ];
+ }
+
+ /**
+ * @return array
+ */
+ public function getMessages() {
+ return [
+ 'special-characters-group-latin',
+ 'special-characters-group-latinextended',
+ 'special-characters-group-ipa',
+ 'special-characters-group-symbols',
+ 'special-characters-group-greek',
+ 'special-characters-group-greekextended',
+ 'special-characters-group-cyrillic',
+ 'special-characters-group-arabic',
+ 'special-characters-group-arabicextended',
+ 'special-characters-group-persian',
+ 'special-characters-group-hebrew',
+ 'special-characters-group-bangla',
+ 'special-characters-group-tamil',
+ 'special-characters-group-telugu',
+ 'special-characters-group-sinhala',
+ 'special-characters-group-devanagari',
+ 'special-characters-group-gujarati',
+ 'special-characters-group-thai',
+ 'special-characters-group-lao',
+ 'special-characters-group-khmer',
+ 'special-characters-group-canadianaboriginal',
+ 'special-characters-title-endash',
+ 'special-characters-title-emdash',
+ 'special-characters-title-minus'
+ ];
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderStartUpModule.php b/www/wiki/includes/resourceloader/ResourceLoaderStartUpModule.php
new file mode 100644
index 00000000..8b9feeb8
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderStartUpModule.php
@@ -0,0 +1,443 @@
+<?php
+/**
+ * Module for ResourceLoader initialization.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+class ResourceLoaderStartUpModule extends ResourceLoaderModule {
+
+ // Cache for getConfigSettings() as it's called by multiple methods
+ protected $configVars = [];
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ protected function getConfigSettings( $context ) {
+ $hash = $context->getHash();
+ if ( isset( $this->configVars[$hash] ) ) {
+ return $this->configVars[$hash];
+ }
+
+ global $wgContLang;
+ $conf = $this->getConfig();
+
+ // We can't use Title::newMainPage() if 'mainpage' is in
+ // $wgForceUIMsgAsContentMsg because that will try to use the session
+ // user's language and we have no session user. This does the
+ // equivalent but falling back to our ResourceLoaderContext language
+ // instead.
+ $mainPage = Title::newFromText( $context->msg( 'mainpage' )->inContentLanguage()->text() );
+ if ( !$mainPage ) {
+ $mainPage = Title::newFromText( 'Main Page' );
+ }
+
+ /**
+ * Namespace related preparation
+ * - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
+ * - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
+ */
+ $namespaceIds = $wgContLang->getNamespaceIds();
+ $caseSensitiveNamespaces = [];
+ foreach ( MWNamespace::getCanonicalNamespaces() as $index => $name ) {
+ $namespaceIds[$wgContLang->lc( $name )] = $index;
+ if ( !MWNamespace::isCapitalized( $index ) ) {
+ $caseSensitiveNamespaces[] = $index;
+ }
+ }
+
+ $illegalFileChars = $conf->get( 'IllegalFileChars' );
+
+ // Build list of variables
+ $vars = [
+ 'wgLoadScript' => wfScript( 'load' ),
+ 'debug' => $context->getDebug(),
+ 'skin' => $context->getSkin(),
+ 'stylepath' => $conf->get( 'StylePath' ),
+ 'wgUrlProtocols' => wfUrlProtocols(),
+ 'wgArticlePath' => $conf->get( 'ArticlePath' ),
+ 'wgScriptPath' => $conf->get( 'ScriptPath' ),
+ 'wgScriptExtension' => '.php',
+ 'wgScript' => wfScript(),
+ 'wgSearchType' => $conf->get( 'SearchType' ),
+ 'wgVariantArticlePath' => $conf->get( 'VariantArticlePath' ),
+ // Force object to avoid "empty" associative array from
+ // becoming [] instead of {} in JS (T36604)
+ 'wgActionPaths' => (object)$conf->get( 'ActionPaths' ),
+ 'wgServer' => $conf->get( 'Server' ),
+ 'wgServerName' => $conf->get( 'ServerName' ),
+ 'wgUserLanguage' => $context->getLanguage(),
+ 'wgContentLanguage' => $wgContLang->getCode(),
+ 'wgTranslateNumerals' => $conf->get( 'TranslateNumerals' ),
+ 'wgVersion' => $conf->get( 'Version' ),
+ 'wgEnableAPI' => $conf->get( 'EnableAPI' ),
+ 'wgEnableWriteAPI' => $conf->get( 'EnableWriteAPI' ),
+ 'wgMainPageTitle' => $mainPage->getPrefixedText(),
+ 'wgFormattedNamespaces' => $wgContLang->getFormattedNamespaces(),
+ 'wgNamespaceIds' => $namespaceIds,
+ 'wgContentNamespaces' => MWNamespace::getContentNamespaces(),
+ 'wgSiteName' => $conf->get( 'Sitename' ),
+ 'wgDBname' => $conf->get( 'DBname' ),
+ 'wgExtraSignatureNamespaces' => $conf->get( 'ExtraSignatureNamespaces' ),
+ 'wgAvailableSkins' => Skin::getSkinNames(),
+ 'wgExtensionAssetsPath' => $conf->get( 'ExtensionAssetsPath' ),
+ // MediaWiki sets cookies to have this prefix by default
+ 'wgCookiePrefix' => $conf->get( 'CookiePrefix' ),
+ 'wgCookieDomain' => $conf->get( 'CookieDomain' ),
+ 'wgCookiePath' => $conf->get( 'CookiePath' ),
+ 'wgCookieExpiration' => $conf->get( 'CookieExpiration' ),
+ 'wgResourceLoaderMaxQueryLength' => $conf->get( 'ResourceLoaderMaxQueryLength' ),
+ 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
+ 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
+ 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
+ 'wgResourceLoaderStorageVersion' => $conf->get( 'ResourceLoaderStorageVersion' ),
+ 'wgResourceLoaderStorageEnabled' => $conf->get( 'ResourceLoaderStorageEnabled' ),
+ 'wgForeignUploadTargets' => $conf->get( 'ForeignUploadTargets' ),
+ 'wgEnableUploads' => $conf->get( 'EnableUploads' ),
+ ];
+
+ Hooks::run( 'ResourceLoaderGetConfigVars', [ &$vars ] );
+
+ $this->configVars[$hash] = $vars;
+ return $this->configVars[$hash];
+ }
+
+ /**
+ * Recursively get all explicit and implicit dependencies for to the given module.
+ *
+ * @param array $registryData
+ * @param string $moduleName
+ * @return array
+ */
+ protected static function getImplicitDependencies( array $registryData, $moduleName ) {
+ static $dependencyCache = [];
+
+ // The list of implicit dependencies won't be altered, so we can
+ // cache them without having to worry.
+ if ( !isset( $dependencyCache[$moduleName] ) ) {
+ if ( !isset( $registryData[$moduleName] ) ) {
+ // Dependencies may not exist
+ $dependencyCache[$moduleName] = [];
+ } else {
+ $data = $registryData[$moduleName];
+ $dependencyCache[$moduleName] = $data['dependencies'];
+
+ foreach ( $data['dependencies'] as $dependency ) {
+ // Recursively get the dependencies of the dependencies
+ $dependencyCache[$moduleName] = array_merge(
+ $dependencyCache[$moduleName],
+ self::getImplicitDependencies( $registryData, $dependency )
+ );
+ }
+ }
+ }
+
+ return $dependencyCache[$moduleName];
+ }
+
+ /**
+ * Optimize the dependency tree in $this->modules.
+ *
+ * The optimization basically works like this:
+ * Given we have module A with the dependencies B and C
+ * and module B with the dependency C.
+ * Now we don't have to tell the client to explicitly fetch module
+ * C as that's already included in module B.
+ *
+ * This way we can reasonably reduce the amount of module registration
+ * data send to the client.
+ *
+ * @param array &$registryData Modules keyed by name with properties:
+ * - string 'version'
+ * - array 'dependencies'
+ * - string|null 'group'
+ * - string 'source'
+ */
+ public static function compileUnresolvedDependencies( array &$registryData ) {
+ foreach ( $registryData as $name => &$data ) {
+ $dependencies = $data['dependencies'];
+ foreach ( $data['dependencies'] as $dependency ) {
+ $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency );
+ $dependencies = array_diff( $dependencies, $implicitDependencies );
+ }
+ // Rebuild keys
+ $data['dependencies'] = array_values( $dependencies );
+ }
+ }
+
+ /**
+ * Get registration code for all modules.
+ *
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code for registering all modules with the client loader
+ */
+ public function getModuleRegistrations( ResourceLoaderContext $context ) {
+ $resourceLoader = $context->getResourceLoader();
+ $target = $context->getRequest()->getVal( 'target', 'desktop' );
+ // Bypass target filter if this request is Special:JavaScriptTest.
+ // To prevent misuse in production, this is only allowed if testing is enabled server-side.
+ $byPassTargetFilter = $this->getConfig()->get( 'EnableJavaScriptTest' ) && $target === 'test';
+
+ $out = '';
+ $states = [];
+ $registryData = [];
+
+ // Get registry data
+ foreach ( $resourceLoader->getModuleNames() as $name ) {
+ $module = $resourceLoader->getModule( $name );
+ $moduleTargets = $module->getTargets();
+ if ( !$byPassTargetFilter && !in_array( $target, $moduleTargets ) ) {
+ continue;
+ }
+
+ if ( $module->isRaw() ) {
+ // Don't register "raw" modules (like 'jquery' and 'mediawiki') client-side because
+ // depending on them is illegal anyway and would only lead to them being reloaded
+ // causing any state to be lost (like jQuery plugins, mw.config etc.)
+ continue;
+ }
+
+ try {
+ $versionHash = $module->getVersionHash( $context );
+ } catch ( Exception $e ) {
+ // See also T152266 and ResourceLoader::getCombinedVersion()
+ MWExceptionHandler::logException( $e );
+ $context->getLogger()->warning(
+ 'Calculating version for "{module}" failed: {exception}',
+ [
+ 'module' => $name,
+ 'exception' => $e,
+ ]
+ );
+ $versionHash = '';
+ $states[$name] = 'error';
+ }
+
+ if ( $versionHash !== '' && strlen( $versionHash ) !== 7 ) {
+ $context->getLogger()->warning(
+ "Module '{module}' produced an invalid version hash: '{version}'.",
+ [
+ 'module' => $name,
+ 'version' => $versionHash,
+ ]
+ );
+ // Module implementation either broken or deviated from ResourceLoader::makeHash
+ // Asserted by tests/phpunit/structure/ResourcesTest.
+ $versionHash = ResourceLoader::makeHash( $versionHash );
+ }
+
+ $skipFunction = $module->getSkipFunction();
+ if ( $skipFunction !== null && !ResourceLoader::inDebugMode() ) {
+ $skipFunction = ResourceLoader::filter( 'minify-js', $skipFunction );
+ }
+
+ $registryData[$name] = [
+ 'version' => $versionHash,
+ 'dependencies' => $module->getDependencies( $context ),
+ 'group' => $module->getGroup(),
+ 'source' => $module->getSource(),
+ 'skip' => $skipFunction,
+ ];
+ }
+
+ self::compileUnresolvedDependencies( $registryData );
+
+ // Register sources
+ $out .= ResourceLoader::makeLoaderSourcesScript( $resourceLoader->getSources() );
+
+ // Figure out the different call signatures for mw.loader.register
+ $registrations = [];
+ foreach ( $registryData as $name => $data ) {
+ // Call mw.loader.register(name, version, dependencies, group, source, skip)
+ $registrations[] = [
+ $name,
+ $data['version'],
+ $data['dependencies'],
+ $data['group'],
+ // Swap default (local) for null
+ $data['source'] === 'local' ? null : $data['source'],
+ $data['skip']
+ ];
+ }
+
+ // Register modules
+ $out .= "\n" . ResourceLoader::makeLoaderRegisterScript( $registrations );
+
+ if ( $states ) {
+ $out .= "\n" . ResourceLoader::makeLoaderStateScript( $states );
+ }
+
+ return $out;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isRaw() {
+ return true;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getPreloadLinks( ResourceLoaderContext $context ) {
+ $url = self::getStartupModulesUrl( $context );
+ return [
+ $url => [ 'as' => 'script' ]
+ ];
+ }
+
+ /**
+ * Base modules required for the base environment of ResourceLoader
+ *
+ * @return array
+ */
+ public static function getStartupModules() {
+ return [ 'jquery', 'mediawiki' ];
+ }
+
+ public static function getLegacyModules() {
+ global $wgIncludeLegacyJavaScript;
+
+ $legacyModules = [];
+ if ( $wgIncludeLegacyJavaScript ) {
+ $legacyModules[] = 'mediawiki.legacy.wikibits';
+ }
+
+ return $legacyModules;
+ }
+
+ /**
+ * Get the load URL of the startup modules.
+ *
+ * This is a helper for getScript(), but can also be called standalone, such
+ * as when generating an AppCache manifest.
+ *
+ * @param ResourceLoaderContext $context
+ * @return string
+ */
+ public static function getStartupModulesUrl( ResourceLoaderContext $context ) {
+ $rl = $context->getResourceLoader();
+ $derivative = new DerivativeResourceLoaderContext( $context );
+ $derivative->setModules( array_merge(
+ self::getStartupModules(),
+ self::getLegacyModules()
+ ) );
+ $derivative->setOnly( 'scripts' );
+ // Must setModules() before makeVersionQuery()
+ $derivative->setVersion( $rl->makeVersionQuery( $derivative ) );
+
+ return $rl->createLoaderURL( 'local', $derivative );
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ global $IP;
+ if ( $context->getOnly() !== 'scripts' ) {
+ return '/* Requires only=script */';
+ }
+
+ $out = file_get_contents( "$IP/resources/src/startup.js" );
+
+ $pairs = array_map( function ( $value ) {
+ $value = FormatJson::encode( $value, ResourceLoader::inDebugMode(), FormatJson::ALL_OK );
+ // Fix indentation
+ $value = str_replace( "\n", "\n\t", $value );
+ return $value;
+ }, [
+ '$VARS.wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ),
+ '$VARS.configuration' => $this->getConfigSettings( $context ),
+ // This url may be preloaded. See getPreloadLinks().
+ '$VARS.baseModulesUri' => self::getStartupModulesUrl( $context ),
+ ] );
+ $pairs['$CODE.registrations()'] = str_replace(
+ "\n",
+ "\n\t",
+ trim( $this->getModuleRegistrations( $context ) )
+ );
+
+ return strtr( $out, $pairs );
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return false;
+ }
+
+ /**
+ * Get the definition summary for this module.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getDefinitionSummary( ResourceLoaderContext $context ) {
+ global $IP;
+ $summary = parent::getDefinitionSummary( $context );
+ $summary[] = [
+ // Detect changes to variables exposed in mw.config (T30899).
+ 'vars' => $this->getConfigSettings( $context ),
+ // Changes how getScript() creates mw.Map for mw.config
+ 'wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ),
+ // Detect changes to the module registrations
+ 'moduleHashes' => $this->getAllModuleHashes( $context ),
+
+ 'fileMtimes' => [
+ filemtime( "$IP/resources/src/startup.js" ),
+ ],
+ ];
+ return $summary;
+ }
+
+ /**
+ * Helper method for getDefinitionSummary().
+ *
+ * @param ResourceLoaderContext $context
+ * @return string SHA-1
+ */
+ protected function getAllModuleHashes( ResourceLoaderContext $context ) {
+ $rl = $context->getResourceLoader();
+ // Preload for getCombinedVersion()
+ $rl->preloadModuleInfo( $rl->getModuleNames(), $context );
+
+ // ATTENTION: Because of the line below, this is not going to cause infinite recursion.
+ // Think carefully before making changes to this code!
+ // Pre-populate versionHash with something because the loop over all modules below includes
+ // the startup module (this module).
+ // See ResourceLoaderModule::getVersionHash() for usage of this cache.
+ $this->versionHash[$context->getHash()] = null;
+
+ return $rl->getCombinedVersion( $context, $rl->getModuleNames() );
+ }
+
+ /**
+ * @return string
+ */
+ public function getGroup() {
+ return 'startup';
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUploadDialogModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUploadDialogModule.php
new file mode 100644
index 00000000..1a390cf1
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderUploadDialogModule.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * ResourceLoader module for the upload dialog configuration data.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * ResourceLoader module for the upload dialog configuration data.
+ *
+ * @since 1.27
+ */
+class ResourceLoaderUploadDialogModule extends ResourceLoaderModule {
+
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ $config = $context->getResourceLoader()->getConfig();
+ return ResourceLoader::makeConfigSetScript( [
+ 'wgUploadDialog' => $config->get( 'UploadDialog' ),
+ ] );
+ }
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php
new file mode 100644
index 00000000..9c198d14
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderUserCSSPrefsModule.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * ResourceLoader module for user preference customizations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for user preference customizations
+ */
+class ResourceLoaderUserCSSPrefsModule extends ResourceLoaderModule {
+
+ protected $origin = self::ORIGIN_CORE_INDIVIDUAL;
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getStyles( ResourceLoaderContext $context ) {
+ if ( !$this->getConfig()->get( 'AllowUserCssPrefs' ) ) {
+ return [];
+ }
+
+ $options = $context->getUserObj()->getOptions();
+
+ // Build CSS rules
+ $rules = [];
+
+ // Underline: 2 = skin default, 1 = always, 0 = never
+ if ( $options['underline'] < 2 ) {
+ $rules[] = "a { text-decoration: " .
+ ( $options['underline'] ? 'underline' : 'none' ) . "; }";
+ }
+ if ( $options['editfont'] !== 'default' ) {
+ // Double-check that $options['editfont'] consists of safe characters only
+ if ( preg_match( '/^[a-zA-Z0-9_, -]+$/', $options['editfont'] ) ) {
+ $rules[] = "textarea { font-family: {$options['editfont']}; }\n";
+ }
+ }
+ $style = implode( "\n", $rules );
+ if ( $this->getFlip( $context ) ) {
+ $style = CSSJanus::transform( $style, true, false );
+ }
+ return [ 'all' => $style ];
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public function isKnownEmpty( ResourceLoaderContext $context ) {
+ $styles = $this->getStyles( $context );
+ return isset( $styles['all'] ) && $styles['all'] === '';
+ }
+
+ /**
+ * @return string
+ */
+ public function getGroup() {
+ return 'private';
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserDefaultsModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserDefaultsModule.php
new file mode 100644
index 00000000..b9dc0982
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderUserDefaultsModule.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * ResourceLoader module for default user preferences.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Ori Livneh
+ */
+
+/**
+ * Module for default user preferences.
+ */
+class ResourceLoaderUserDefaultsModule extends ResourceLoaderModule {
+
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ return Xml::encodeJsCall(
+ 'mw.user.options.set',
+ [ User::getDefaultOptions() ],
+ ResourceLoader::inDebugMode()
+ );
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserGroupsModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserGroupsModule.php
new file mode 100644
index 00000000..e2a8e410
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderUserGroupsModule.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * ResourceLoader module for user customizations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Module for user customizations
+ */
+class ResourceLoaderUserGroupsModule extends ResourceLoaderWikiModule {
+
+ protected $origin = self::ORIGIN_USER_SITEWIDE;
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ protected function getPages( ResourceLoaderContext $context ) {
+ $useSiteJs = $this->getConfig()->get( 'UseSiteJs' );
+ $useSiteCss = $this->getConfig()->get( 'UseSiteCss' );
+ if ( !$useSiteJs && !$useSiteCss ) {
+ return [];
+ }
+
+ $user = $context->getUserObj();
+ if ( !$user || $user->isAnon() ) {
+ return [];
+ }
+
+ $pages = [];
+ foreach ( $user->getEffectiveGroups() as $group ) {
+ if ( $group == '*' ) {
+ continue;
+ }
+ if ( $useSiteJs ) {
+ $pages["MediaWiki:Group-$group.js"] = [ 'type' => 'script' ];
+ }
+ if ( $useSiteCss ) {
+ $pages["MediaWiki:Group-$group.css"] = [ 'type' => 'style' ];
+ }
+ }
+ return $pages;
+ }
+
+ /**
+ * Get group name
+ *
+ * @return string
+ */
+ public function getGroup() {
+ return 'user';
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserModule.php
new file mode 100644
index 00000000..8e213819
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderUserModule.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for user customizations scripts
+ */
+class ResourceLoaderUserModule extends ResourceLoaderWikiModule {
+
+ protected $origin = self::ORIGIN_USER_INDIVIDUAL;
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array List of pages
+ */
+ protected function getPages( ResourceLoaderContext $context ) {
+ $config = $this->getConfig();
+ $user = $context->getUserObj();
+ if ( $user->isAnon() ) {
+ return [];
+ }
+
+ // Use localised/normalised variant to ensure $excludepage matches
+ $userPage = $user->getUserPage()->getPrefixedDBkey();
+ $pages = [];
+
+ if ( $config->get( 'AllowUserJs' ) ) {
+ $pages["$userPage/common.js"] = [ 'type' => 'script' ];
+ $pages["$userPage/" . $context->getSkin() . '.js'] = [ 'type' => 'script' ];
+ }
+
+ // User group pages are maintained site-wide and enabled with site JS/CSS.
+ if ( $config->get( 'UseSiteJs' ) ) {
+ foreach ( $user->getEffectiveGroups() as $group ) {
+ if ( $group == '*' ) {
+ continue;
+ }
+ $pages["MediaWiki:Group-$group.js"] = [ 'type' => 'script' ];
+ }
+ }
+
+ // Hack for T28283: Allow excluding pages for preview on a CSS/JS page.
+ // The excludepage parameter is set by OutputPage.
+ $excludepage = $context->getRequest()->getVal( 'excludepage' );
+ if ( isset( $pages[$excludepage] ) ) {
+ unset( $pages[$excludepage] );
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Get group name
+ *
+ * @return string
+ */
+ public function getGroup() {
+ return 'user';
+ }
+
+ /**
+ * @param ResourceLoaderContext|null $context
+ * @return array
+ */
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ return [ 'user.styles' ];
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserOptionsModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserOptionsModule.php
new file mode 100644
index 00000000..0c332cff
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderUserOptionsModule.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * ResourceLoader module for user preference customizations.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for user preference customizations
+ */
+class ResourceLoaderUserOptionsModule extends ResourceLoaderModule {
+
+ protected $origin = self::ORIGIN_CORE_INDIVIDUAL;
+
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array List of module names as strings
+ */
+ public function getDependencies( ResourceLoaderContext $context = null ) {
+ return [ 'user.defaults' ];
+ }
+
+ /**
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return true;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ return Xml::encodeJsCall( 'mw.user.options.set',
+ [ $context->getUserObj()->getOptions( User::GETOPTIONS_EXCLUDE_DEFAULTS ) ],
+ ResourceLoader::inDebugMode()
+ );
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ public function getGroup() {
+ return 'private';
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserStylesModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserStylesModule.php
new file mode 100644
index 00000000..8d8e0085
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderUserStylesModule.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+/**
+ * Module for user customizations styles
+ */
+class ResourceLoaderUserStylesModule extends ResourceLoaderWikiModule {
+
+ protected $origin = self::ORIGIN_USER_INDIVIDUAL;
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array List of pages
+ */
+ protected function getPages( ResourceLoaderContext $context ) {
+ $config = $this->getConfig();
+ $user = $context->getUserObj();
+ if ( $user->isAnon() ) {
+ return [];
+ }
+
+ // Use localised/normalised variant to ensure $excludepage matches
+ $userPage = $user->getUserPage()->getPrefixedDBkey();
+ $pages = [];
+
+ if ( $config->get( 'AllowUserCss' ) ) {
+ $pages["$userPage/common.css"] = [ 'type' => 'style' ];
+ $pages["$userPage/" . $context->getSkin() . '.css'] = [ 'type' => 'style' ];
+ }
+
+ // User group pages are maintained site-wide and enabled with site JS/CSS.
+ if ( $config->get( 'UseSiteCss' ) ) {
+ foreach ( $user->getEffectiveGroups() as $group ) {
+ if ( $group == '*' ) {
+ continue;
+ }
+ $pages["MediaWiki:Group-$group.css"] = [ 'type' => 'style' ];
+ }
+ }
+
+ // Hack for T28283: Allow excluding pages for preview on a CSS/JS page.
+ // The excludepage parameter is set by OutputPage.
+ $excludepage = $context->getRequest()->getVal( 'excludepage' );
+ if ( isset( $pages[$excludepage] ) ) {
+ unset( $pages[$excludepage] );
+ }
+
+ return $pages;
+ }
+
+ /**
+ * @return string
+ */
+ public function getType() {
+ return self::LOAD_STYLES;
+ }
+
+ /**
+ * Get group name
+ *
+ * @return string
+ */
+ public function getGroup() {
+ return 'user';
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserTokensModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserTokensModule.php
new file mode 100644
index 00000000..bfa7326d
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderUserTokensModule.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * ResourceLoader module for user tokens.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Krinkle
+ */
+
+/**
+ * Module for user tokens
+ */
+class ResourceLoaderUserTokensModule extends ResourceLoaderModule {
+
+ /* Protected Members */
+
+ protected $origin = self::ORIGIN_CORE_INDIVIDUAL;
+
+ protected $targets = [ 'desktop', 'mobile' ];
+
+ /* Methods */
+
+ /**
+ * Fetch the tokens for the current user.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array List of tokens keyed by token type
+ */
+ protected function contextUserTokens( ResourceLoaderContext $context ) {
+ $user = $context->getUserObj();
+
+ return [
+ 'editToken' => $user->getEditToken(),
+ 'patrolToken' => $user->getEditToken( 'patrol' ),
+ 'watchToken' => $user->getEditToken( 'watch' ),
+ 'csrfToken' => $user->getEditToken(),
+ ];
+ }
+
+ /**
+ * Generate the JavaScript content of this module.
+ *
+ * Add FILTER_NOMIN annotation to prevent needless minification and caching (T84960).
+ *
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ return Xml::encodeJsCall(
+ 'mw.user.tokens.set',
+ [ $this->contextUserTokens( $context ) ],
+ ResourceLoader::inDebugMode()
+ ) . ResourceLoader::FILTER_NOMIN;
+ }
+
+ /**
+ * @return bool
+ */
+ public function supportsURLLoading() {
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ public function getGroup() {
+ return 'private';
+ }
+}
diff --git a/www/wiki/includes/resourceloader/ResourceLoaderWikiModule.php b/www/wiki/includes/resourceloader/ResourceLoaderWikiModule.php
new file mode 100644
index 00000000..bebc1887
--- /dev/null
+++ b/www/wiki/includes/resourceloader/ResourceLoaderWikiModule.php
@@ -0,0 +1,475 @@
+<?php
+/**
+ * Abstraction for ResourceLoader modules that pull from wiki pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Trevor Parscal
+ * @author Roan Kattouw
+ */
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Abstraction for ResourceLoader modules which pull from wiki pages
+ *
+ * This can only be used for wiki pages in the MediaWiki and User namespaces,
+ * because of its dependence on the functionality of Title::isCssJsSubpage
+ * and Title::isCssOrJsPage().
+ *
+ * This module supports being used as a placeholder for a module on a remote wiki.
+ * To do so, getDB() must be overloaded to return a foreign database object that
+ * allows local wikis to query page metadata.
+ *
+ * Safe for calls on local wikis are:
+ * - Option getters:
+ * - getGroup()
+ * - getPages()
+ * - Basic methods that strictly involve the foreign database
+ * - getDB()
+ * - isKnownEmpty()
+ * - getTitleInfo()
+ */
+class ResourceLoaderWikiModule extends ResourceLoaderModule {
+
+ // Origin defaults to users with sitewide authority
+ protected $origin = self::ORIGIN_USER_SITEWIDE;
+
+ // In-process cache for title info
+ protected $titleInfo = [];
+
+ // List of page names that contain CSS
+ protected $styles = [];
+
+ // List of page names that contain JavaScript
+ protected $scripts = [];
+
+ // Group of module
+ protected $group;
+
+ /**
+ * @param array $options For back-compat, this can be omitted in favour of overwriting getPages.
+ */
+ public function __construct( array $options = null ) {
+ if ( is_null( $options ) ) {
+ return;
+ }
+
+ foreach ( $options as $member => $option ) {
+ switch ( $member ) {
+ case 'styles':
+ case 'scripts':
+ case 'group':
+ case 'targets':
+ $this->{$member} = $option;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Subclasses should return an associative array of resources in the module.
+ * Keys should be the title of a page in the MediaWiki or User namespace.
+ *
+ * Values should be a nested array of options. The supported keys are 'type' and
+ * (CSS only) 'media'.
+ *
+ * For scripts, 'type' should be 'script'.
+ *
+ * For stylesheets, 'type' should be 'style'.
+ * There is an optional media key, the value of which can be the
+ * medium ('screen', 'print', etc.) of the stylesheet.
+ *
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ protected function getPages( ResourceLoaderContext $context ) {
+ $config = $this->getConfig();
+ $pages = [];
+
+ // Filter out pages from origins not allowed by the current wiki configuration.
+ if ( $config->get( 'UseSiteJs' ) ) {
+ foreach ( $this->scripts as $script ) {
+ $pages[$script] = [ 'type' => 'script' ];
+ }
+ }
+
+ if ( $config->get( 'UseSiteCss' ) ) {
+ foreach ( $this->styles as $style ) {
+ $pages[$style] = [ 'type' => 'style' ];
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Get group name
+ *
+ * @return string
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * Get the Database object used in getTitleInfo().
+ *
+ * Defaults to the local replica DB. Subclasses may want to override this to return a foreign
+ * database object, or null if getTitleInfo() shouldn't access the database.
+ *
+ * NOTE: This ONLY works for getTitleInfo() and isKnownEmpty(), NOT FOR ANYTHING ELSE.
+ * In particular, it doesn't work for getContent() or getScript() etc.
+ *
+ * @return IDatabase|null
+ */
+ protected function getDB() {
+ return wfGetDB( DB_REPLICA );
+ }
+
+ /**
+ * @param string $titleText
+ * @return null|string
+ */
+ protected function getContent( $titleText ) {
+ $title = Title::newFromText( $titleText );
+ if ( !$title ) {
+ return null; // Bad title
+ }
+
+ // If the page is a redirect, follow the redirect.
+ if ( $title->isRedirect() ) {
+ $content = $this->getContentObj( $title );
+ $title = $content ? $content->getUltimateRedirectTarget() : null;
+ if ( !$title ) {
+ return null; // Dead redirect
+ }
+ }
+
+ $handler = ContentHandler::getForTitle( $title );
+ if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) {
+ $format = CONTENT_FORMAT_CSS;
+ } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) {
+ $format = CONTENT_FORMAT_JAVASCRIPT;
+ } else {
+ return null; // Bad content model
+ }
+
+ $content = $this->getContentObj( $title );
+ if ( !$content ) {
+ return null; // No content found
+ }
+
+ return $content->serialize( $format );
+ }
+
+ /**
+ * @param Title $title
+ * @return Content|null
+ */
+ protected function getContentObj( Title $title ) {
+ $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title->getArticleID(),
+ $title->getLatestRevID() );
+ if ( !$revision ) {
+ return null;
+ }
+ $revision->setTitle( $title );
+ $content = $revision->getContent( Revision::RAW );
+ if ( !$content ) {
+ wfDebugLog( 'resourceloader', __METHOD__ . ': failed to load content of JS/CSS page!' );
+ return null;
+ }
+ return $content;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return string JavaScript code
+ */
+ public function getScript( ResourceLoaderContext $context ) {
+ $scripts = '';
+ foreach ( $this->getPages( $context ) as $titleText => $options ) {
+ if ( $options['type'] !== 'script' ) {
+ continue;
+ }
+ $script = $this->getContent( $titleText );
+ if ( strval( $script ) !== '' ) {
+ $script = $this->validateScriptFile( $titleText, $script );
+ $scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n";
+ }
+ }
+ return $scripts;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getStyles( ResourceLoaderContext $context ) {
+ $styles = [];
+ foreach ( $this->getPages( $context ) as $titleText => $options ) {
+ if ( $options['type'] !== 'style' ) {
+ continue;
+ }
+ $media = isset( $options['media'] ) ? $options['media'] : 'all';
+ $style = $this->getContent( $titleText );
+ if ( strval( $style ) === '' ) {
+ continue;
+ }
+ if ( $this->getFlip( $context ) ) {
+ $style = CSSJanus::transform( $style, true, false );
+ }
+ $style = MemoizedCallable::call( 'CSSMin::remap',
+ [ $style, false, $this->getConfig()->get( 'ScriptPath' ), true ] );
+ if ( !isset( $styles[$media] ) ) {
+ $styles[$media] = [];
+ }
+ $style = ResourceLoader::makeComment( $titleText ) . $style;
+ $styles[$media][] = $style;
+ }
+ return $styles;
+ }
+
+ /**
+ * Disable module content versioning.
+ *
+ * This class does not support generating content outside of a module
+ * request due to foreign database support.
+ *
+ * See getDefinitionSummary() for meta-data versioning.
+ *
+ * @return bool
+ */
+ public function enableModuleContentVersion() {
+ return false;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return array
+ */
+ public function getDefinitionSummary( ResourceLoaderContext $context ) {
+ $summary = parent::getDefinitionSummary( $context );
+ $summary[] = [
+ 'pages' => $this->getPages( $context ),
+ // Includes meta data of current revisions
+ 'titleInfo' => $this->getTitleInfo( $context ),
+ ];
+ return $summary;
+ }
+
+ /**
+ * @param ResourceLoaderContext $context
+ * @return bool
+ */
+ public function isKnownEmpty( ResourceLoaderContext $context ) {
+ $revisions = $this->getTitleInfo( $context );
+
+ // For user modules, don't needlessly load if there are no non-empty pages
+ if ( $this->getGroup() === 'user' ) {
+ foreach ( $revisions as $revision ) {
+ if ( $revision['page_len'] > 0 ) {
+ // At least one non-empty page, module should be loaded
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // T70488: For other modules (i.e. ones that are called in cached html output) only check
+ // page existance. This ensures that, if some pages in a module are temporarily blanked,
+ // we don't end omit the module's script or link tag on some pages.
+ return count( $revisions ) === 0;
+ }
+
+ private function setTitleInfo( $key, array $titleInfo ) {
+ $this->titleInfo[$key] = $titleInfo;
+ }
+
+ /**
+ * Get the information about the wiki pages for a given context.
+ * @param ResourceLoaderContext $context
+ * @return array Keyed by page name
+ */
+ protected function getTitleInfo( ResourceLoaderContext $context ) {
+ $dbr = $this->getDB();
+ if ( !$dbr ) {
+ // We're dealing with a subclass that doesn't have a DB
+ return [];
+ }
+
+ $pageNames = array_keys( $this->getPages( $context ) );
+ sort( $pageNames );
+ $key = implode( '|', $pageNames );
+ if ( !isset( $this->titleInfo[$key] ) ) {
+ $this->titleInfo[$key] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ );
+ }
+ return $this->titleInfo[$key];
+ }
+
+ protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) {
+ $titleInfo = [];
+ $batch = new LinkBatch;
+ foreach ( $pages as $titleText ) {
+ $title = Title::newFromText( $titleText );
+ if ( $title ) {
+ // Page name may be invalid if user-provided (e.g. gadgets)
+ $batch->addObj( $title );
+ }
+ }
+ if ( !$batch->isEmpty() ) {
+ $res = $db->select( 'page',
+ // Include page_touched to allow purging if cache is poisoned (T117587, T113916)
+ [ 'page_namespace', 'page_title', 'page_touched', 'page_len', 'page_latest' ],
+ $batch->constructSet( 'page', $db ),
+ $fname
+ );
+ foreach ( $res as $row ) {
+ // Avoid including ids or timestamps of revision/page tables so
+ // that versions are not wasted
+ $title = Title::makeTitle( $row->page_namespace, $row->page_title );
+ $titleInfo[$title->getPrefixedText()] = [
+ 'page_len' => $row->page_len,
+ 'page_latest' => $row->page_latest,
+ 'page_touched' => $row->page_touched,
+ ];
+ }
+ }
+ return $titleInfo;
+ }
+
+ /**
+ * @since 1.28
+ * @param ResourceLoaderContext $context
+ * @param IDatabase $db
+ * @param string[] $moduleNames
+ */
+ public static function preloadTitleInfo(
+ ResourceLoaderContext $context, IDatabase $db, array $moduleNames
+ ) {
+ $rl = $context->getResourceLoader();
+ // getDB() can be overridden to point to a foreign database.
+ // For now, only preload local. In the future, we could preload by wikiID.
+ $allPages = [];
+ /** @var ResourceLoaderWikiModule[] $wikiModules */
+ $wikiModules = [];
+ foreach ( $moduleNames as $name ) {
+ $module = $rl->getModule( $name );
+ if ( $module instanceof self ) {
+ $mDB = $module->getDB();
+ // Subclasses may disable getDB and implement getTitleInfo differently
+ if ( $mDB && $mDB->getDomainID() === $db->getDomainID() ) {
+ $wikiModules[] = $module;
+ $allPages += $module->getPages( $context );
+ }
+ }
+ }
+
+ if ( !$wikiModules ) {
+ // Nothing to preload
+ return;
+ }
+
+ $pageNames = array_keys( $allPages );
+ sort( $pageNames );
+ $hash = sha1( implode( '|', $pageNames ) );
+
+ // Avoid Zend bug where "static::" does not apply LSB in the closure
+ $func = [ static::class, 'fetchTitleInfo' ];
+ $fname = __METHOD__;
+
+ $cache = ObjectCache::getMainWANInstance();
+ $allInfo = $cache->getWithSetCallback(
+ $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID(), $hash ),
+ $cache::TTL_HOUR,
+ function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) {
+ $setOpts += Database::getCacheSetOptions( $db );
+
+ return call_user_func( $func, $db, $pageNames, $fname );
+ },
+ [
+ 'checkKeys' => [
+ $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID() ) ]
+ ]
+ );
+
+ foreach ( $wikiModules as $wikiModule ) {
+ $pages = $wikiModule->getPages( $context );
+ // Before we intersect, map the names to canonical form (T145673).
+ $intersect = [];
+ foreach ( $pages as $page => $unused ) {
+ $title = Title::newFromText( $page );
+ if ( $title ) {
+ $intersect[ $title->getPrefixedText() ] = 1;
+ } else {
+ // Page name may be invalid if user-provided (e.g. gadgets)
+ $rl->getLogger()->info(
+ 'Invalid wiki page title "{title}" in ' . __METHOD__,
+ [ 'title' => $page ]
+ );
+ }
+ }
+ $info = array_intersect_key( $allInfo, $intersect );
+ $pageNames = array_keys( $pages );
+ sort( $pageNames );
+ $key = implode( '|', $pageNames );
+ $wikiModule->setTitleInfo( $key, $info );
+ }
+ }
+
+ /**
+ * Clear the preloadTitleInfo() cache for all wiki modules on this wiki on
+ * page change if it was a JS or CSS page
+ *
+ * @param Title $title
+ * @param Revision|null $old Prior page revision
+ * @param Revision|null $new New page revision
+ * @param string $wikiId
+ * @since 1.28
+ */
+ public static function invalidateModuleCache(
+ Title $title, Revision $old = null, Revision $new = null, $wikiId
+ ) {
+ static $formats = [ CONTENT_FORMAT_CSS, CONTENT_FORMAT_JAVASCRIPT ];
+
+ if ( $old && in_array( $old->getContentFormat(), $formats ) ) {
+ $purge = true;
+ } elseif ( $new && in_array( $new->getContentFormat(), $formats ) ) {
+ $purge = true;
+ } else {
+ $purge = ( $title->isCssOrJsPage() || $title->isCssJsSubpage() );
+ }
+
+ if ( $purge ) {
+ $cache = ObjectCache::getMainWANInstance();
+ $key = $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $wikiId );
+ $cache->touchCheckKey( $key );
+ }
+ }
+
+ /**
+ * @since 1.28
+ * @return string
+ */
+ public function getType() {
+ // Check both because subclasses don't always pass pages via the constructor,
+ // they may also override getPages() instead, in which case we should keep
+ // defaulting to LOAD_GENERAL and allow them to override getType() separately.
+ return ( $this->styles && !$this->scripts ) ? self::LOAD_STYLES : self::LOAD_GENERAL;
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelArchiveItem.php b/www/wiki/includes/revisiondelete/RevDelArchiveItem.php
new file mode 100644
index 00000000..ab74dbd2
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelArchiveItem.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+/**
+ * Item class for a archive table row
+ */
+class RevDelArchiveItem extends RevDelRevisionItem {
+ public function __construct( $list, $row ) {
+ RevDelItem::__construct( $list, $row );
+ $this->revision = Revision::newFromArchiveRow( $row,
+ [ 'page' => $this->list->title->getArticleID() ] );
+ }
+
+ public function getIdField() {
+ return 'ar_timestamp';
+ }
+
+ public function getTimestampField() {
+ return 'ar_timestamp';
+ }
+
+ public function getAuthorIdField() {
+ return 'ar_user';
+ }
+
+ public function getAuthorNameField() {
+ return 'ar_user_text';
+ }
+
+ public function getId() {
+ # Convert DB timestamp to MW timestamp
+ return $this->revision->getTimestamp();
+ }
+
+ public function setBits( $bits ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update( 'archive',
+ [ 'ar_deleted' => $bits ],
+ [
+ 'ar_namespace' => $this->list->title->getNamespace(),
+ 'ar_title' => $this->list->title->getDBkey(),
+ // use timestamp for index
+ 'ar_timestamp' => $this->row->ar_timestamp,
+ 'ar_rev_id' => $this->row->ar_rev_id,
+ 'ar_deleted' => $this->getBits()
+ ],
+ __METHOD__ );
+
+ return (bool)$dbw->affectedRows();
+ }
+
+ protected function getRevisionLink() {
+ $date = $this->list->getLanguage()->userTimeAndDate(
+ $this->revision->getTimestamp(), $this->list->getUser() );
+
+ if ( $this->isDeleted() && !$this->canViewContent() ) {
+ return htmlspecialchars( $date );
+ }
+
+ return $this->getLinkRenderer()->makeLink(
+ SpecialPage::getTitleFor( 'Undelete' ),
+ $date,
+ [],
+ [
+ 'target' => $this->list->title->getPrefixedText(),
+ 'timestamp' => $this->revision->getTimestamp()
+ ]
+ );
+ }
+
+ protected function getDiffLink() {
+ if ( $this->isDeleted() && !$this->canViewContent() ) {
+ return $this->list->msg( 'diff' )->escaped();
+ }
+
+ return $this->getLinkRenderer()->makeLink(
+ SpecialPage::getTitleFor( 'Undelete' ),
+ $this->list->msg( 'diff' )->text(),
+ [],
+ [
+ 'target' => $this->list->title->getPrefixedText(),
+ 'diff' => 'prev',
+ 'timestamp' => $this->revision->getTimestamp()
+ ]
+ );
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelArchiveList.php b/www/wiki/includes/revisiondelete/RevDelArchiveList.php
new file mode 100644
index 00000000..9afaf404
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelArchiveList.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * List for archive table items, i.e. revisions deleted via action=delete
+ */
+class RevDelArchiveList extends RevDelRevisionList {
+ public function getType() {
+ return 'archive';
+ }
+
+ public static function getRelationType() {
+ return 'ar_timestamp';
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return mixed
+ */
+ public function doQuery( $db ) {
+ $timestamps = [];
+ foreach ( $this->ids as $id ) {
+ $timestamps[] = $db->timestamp( $id );
+ }
+
+ $tables = [ 'archive' ];
+ $fields = Revision::selectArchiveFields();
+ $conds = [
+ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ 'ar_timestamp' => $timestamps,
+ ];
+ $join_conds = [];
+ $options = [ 'ORDER BY' => 'ar_timestamp DESC' ];
+
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $fields,
+ $conds,
+ $join_conds,
+ $options,
+ ''
+ );
+
+ return $db->select( $tables,
+ $fields,
+ $conds,
+ __METHOD__,
+ $options,
+ $join_conds
+ );
+ }
+
+ public function newItem( $row ) {
+ return new RevDelArchiveItem( $this, $row );
+ }
+
+ public function doPreCommitUpdates() {
+ return Status::newGood();
+ }
+
+ public function doPostCommitUpdates( array $visibilityChangeMap ) {
+ return Status::newGood();
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelArchivedFileItem.php b/www/wiki/includes/revisiondelete/RevDelArchivedFileItem.php
new file mode 100644
index 00000000..b0984224
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelArchivedFileItem.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+/**
+ * Item class for a filearchive table row
+ */
+class RevDelArchivedFileItem extends RevDelFileItem {
+ /** @var RevDelArchivedFileList $list */
+ /** @var ArchivedFile $file */
+ /** @var LocalFile */
+ protected $lockFile;
+
+ public function __construct( $list, $row ) {
+ RevDelItem::__construct( $list, $row );
+ $this->file = ArchivedFile::newFromRow( $row );
+ $this->lockFile = RepoGroup::singleton()->getLocalRepo()->newFile( $row->fa_name );
+ }
+
+ public function getIdField() {
+ return 'fa_id';
+ }
+
+ public function getTimestampField() {
+ return 'fa_timestamp';
+ }
+
+ public function getAuthorIdField() {
+ return 'fa_user';
+ }
+
+ public function getAuthorNameField() {
+ return 'fa_user_text';
+ }
+
+ public function getId() {
+ return $this->row->fa_id;
+ }
+
+ public function setBits( $bits ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update( 'filearchive',
+ [ 'fa_deleted' => $bits ],
+ [
+ 'fa_id' => $this->row->fa_id,
+ 'fa_deleted' => $this->getBits(),
+ ],
+ __METHOD__
+ );
+
+ return (bool)$dbw->affectedRows();
+ }
+
+ protected function getLink() {
+ $date = $this->list->getLanguage()->userTimeAndDate(
+ $this->file->getTimestamp(), $this->list->getUser() );
+
+ # Hidden files...
+ if ( !$this->canViewContent() ) {
+ $link = htmlspecialchars( $date );
+ } else {
+ $undelete = SpecialPage::getTitleFor( 'Undelete' );
+ $key = $this->file->getKey();
+ $link = $this->getLinkRenderer()->makeLink( $undelete, $date, [],
+ [
+ 'target' => $this->list->title->getPrefixedText(),
+ 'file' => $key,
+ 'token' => $this->list->getUser()->getEditToken( $key )
+ ]
+ );
+ }
+ if ( $this->isDeleted() ) {
+ $link = '<span class="history-deleted">' . $link . '</span>';
+ }
+
+ return $link;
+ }
+
+ public function getApiData( ApiResult $result ) {
+ $file = $this->file;
+ $user = $this->list->getUser();
+ $ret = [
+ 'title' => $this->list->title->getPrefixedText(),
+ 'timestamp' => wfTimestamp( TS_ISO_8601, $file->getTimestamp() ),
+ 'width' => $file->getWidth(),
+ 'height' => $file->getHeight(),
+ 'size' => $file->getSize(),
+ 'userhidden' => (bool)$file->isDeleted( Revision::DELETED_USER ),
+ 'commenthidden' => (bool)$file->isDeleted( Revision::DELETED_COMMENT ),
+ 'contenthidden' => (bool)$this->isDeleted(),
+ ];
+ if ( $this->canViewContent() ) {
+ $ret += [
+ 'url' => SpecialPage::getTitleFor( 'Revisiondelete' )->getLinkURL(
+ [
+ 'target' => $this->list->title->getPrefixedText(),
+ 'file' => $file->getKey(),
+ 'token' => $user->getEditToken( $file->getKey() )
+ ]
+ ),
+ ];
+ }
+ if ( $file->userCan( Revision::DELETED_USER, $user ) ) {
+ $ret += [
+ 'userid' => $file->getUser( 'id' ),
+ 'user' => $file->getUser( 'text' ),
+ ];
+ }
+ if ( $file->userCan( Revision::DELETED_COMMENT, $user ) ) {
+ $ret += [
+ 'comment' => $file->getRawDescription(),
+ ];
+ }
+
+ return $ret;
+ }
+
+ public function lock() {
+ return $this->lockFile->acquireFileLock();
+ }
+
+ public function unlock() {
+ return $this->lockFile->releaseFileLock();
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelArchivedFileList.php b/www/wiki/includes/revisiondelete/RevDelArchivedFileList.php
new file mode 100644
index 00000000..1d80d869
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelArchivedFileList.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * List for filearchive table items
+ */
+class RevDelArchivedFileList extends RevDelFileList {
+ public function getType() {
+ return 'filearchive';
+ }
+
+ public static function getRelationType() {
+ return 'fa_id';
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return mixed
+ */
+ public function doQuery( $db ) {
+ $ids = array_map( 'intval', $this->ids );
+
+ return $db->select(
+ 'filearchive',
+ ArchivedFile::selectFields(),
+ [
+ 'fa_name' => $this->title->getDBkey(),
+ 'fa_id' => $ids
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'fa_id DESC' ]
+ );
+ }
+
+ public function newItem( $row ) {
+ return new RevDelArchivedFileItem( $this, $row );
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelArchivedRevisionItem.php b/www/wiki/includes/revisiondelete/RevDelArchivedRevisionItem.php
new file mode 100644
index 00000000..d839fcfc
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelArchivedRevisionItem.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+/**
+ * Item class for a archive table row by ar_rev_id -- actually
+ * used via RevDelRevisionList.
+ */
+class RevDelArchivedRevisionItem extends RevDelArchiveItem {
+ public function __construct( $list, $row ) {
+ RevDelItem::__construct( $list, $row );
+
+ $this->revision = Revision::newFromArchiveRow( $row,
+ [ 'page' => $this->list->title->getArticleID() ] );
+ }
+
+ public function getIdField() {
+ return 'ar_rev_id';
+ }
+
+ public function getId() {
+ return $this->revision->getId();
+ }
+
+ public function setBits( $bits ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update( 'archive',
+ [ 'ar_deleted' => $bits ],
+ [ 'ar_rev_id' => $this->row->ar_rev_id,
+ 'ar_deleted' => $this->getBits()
+ ],
+ __METHOD__ );
+
+ return (bool)$dbw->affectedRows();
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelFileItem.php b/www/wiki/includes/revisiondelete/RevDelFileItem.php
new file mode 100644
index 00000000..9beafc98
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelFileItem.php
@@ -0,0 +1,246 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+/**
+ * Item class for an oldimage table row
+ */
+class RevDelFileItem extends RevDelItem {
+ /** @var RevDelFileList */
+ protected $list;
+ /** @var OldLocalFile */
+ protected $file;
+
+ public function __construct( $list, $row ) {
+ parent::__construct( $list, $row );
+ $this->file = RepoGroup::singleton()->getLocalRepo()->newFileFromRow( $row );
+ }
+
+ public function getIdField() {
+ return 'oi_archive_name';
+ }
+
+ public function getTimestampField() {
+ return 'oi_timestamp';
+ }
+
+ public function getAuthorIdField() {
+ return 'oi_user';
+ }
+
+ public function getAuthorNameField() {
+ return 'oi_user_text';
+ }
+
+ public function getId() {
+ $parts = explode( '!', $this->row->oi_archive_name );
+
+ return $parts[0];
+ }
+
+ public function canView() {
+ return $this->file->userCan( File::DELETED_RESTRICTED, $this->list->getUser() );
+ }
+
+ public function canViewContent() {
+ return $this->file->userCan( File::DELETED_FILE, $this->list->getUser() );
+ }
+
+ public function getBits() {
+ return $this->file->getVisibility();
+ }
+
+ public function setBits( $bits ) {
+ # Queue the file op
+ # @todo FIXME: Move to LocalFile.php
+ if ( $this->isDeleted() ) {
+ if ( $bits & File::DELETED_FILE ) {
+ # Still deleted
+ } else {
+ # Newly undeleted
+ $key = $this->file->getStorageKey();
+ $srcRel = $this->file->repo->getDeletedHashPath( $key ) . $key;
+ $this->list->storeBatch[] = [
+ $this->file->repo->getVirtualUrl( 'deleted' ) . '/' . $srcRel,
+ 'public',
+ $this->file->getRel()
+ ];
+ $this->list->cleanupBatch[] = $key;
+ }
+ } elseif ( $bits & File::DELETED_FILE ) {
+ # Newly deleted
+ $key = $this->file->getStorageKey();
+ $dstRel = $this->file->repo->getDeletedHashPath( $key ) . $key;
+ $this->list->deleteBatch[] = [ $this->file->getRel(), $dstRel ];
+ }
+
+ # Do the database operations
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update( 'oldimage',
+ [ 'oi_deleted' => $bits ],
+ [
+ 'oi_name' => $this->row->oi_name,
+ 'oi_timestamp' => $this->row->oi_timestamp,
+ 'oi_deleted' => $this->getBits()
+ ],
+ __METHOD__
+ );
+
+ return (bool)$dbw->affectedRows();
+ }
+
+ public function isDeleted() {
+ return $this->file->isDeleted( File::DELETED_FILE );
+ }
+
+ /**
+ * Get the link to the file.
+ * Overridden by RevDelArchivedFileItem.
+ * @return string
+ */
+ protected function getLink() {
+ $date = $this->list->getLanguage()->userTimeAndDate(
+ $this->file->getTimestamp(), $this->list->getUser() );
+
+ if ( !$this->isDeleted() ) {
+ # Regular files...
+ return Html::element( 'a', [ 'href' => $this->file->getUrl() ], $date );
+ }
+
+ # Hidden files...
+ if ( !$this->canViewContent() ) {
+ $link = htmlspecialchars( $date );
+ } else {
+ $link = $this->getLinkRenderer()->makeLink(
+ SpecialPage::getTitleFor( 'Revisiondelete' ),
+ $date,
+ [],
+ [
+ 'target' => $this->list->title->getPrefixedText(),
+ 'file' => $this->file->getArchiveName(),
+ 'token' => $this->list->getUser()->getEditToken(
+ $this->file->getArchiveName() )
+ ]
+ );
+ }
+
+ return '<span class="history-deleted">' . $link . '</span>';
+ }
+
+ /**
+ * Generate a user tool link cluster if the current user is allowed to view it
+ * @return string HTML
+ */
+ protected function getUserTools() {
+ if ( $this->file->userCan( Revision::DELETED_USER, $this->list->getUser() ) ) {
+ $uid = $this->file->getUser( 'id' );
+ $name = $this->file->getUser( 'text' );
+ $link = Linker::userLink( $uid, $name ) . Linker::userToolLinks( $uid, $name );
+ } else {
+ $link = $this->list->msg( 'rev-deleted-user' )->escaped();
+ }
+ if ( $this->file->isDeleted( Revision::DELETED_USER ) ) {
+ return '<span class="history-deleted">' . $link . '</span>';
+ }
+
+ return $link;
+ }
+
+ /**
+ * Wrap and format the file's comment block, if the current
+ * user is allowed to view it.
+ *
+ * @return string HTML
+ */
+ protected function getComment() {
+ if ( $this->file->userCan( File::DELETED_COMMENT, $this->list->getUser() ) ) {
+ $block = Linker::commentBlock( $this->file->getDescription() );
+ } else {
+ $block = ' ' . $this->list->msg( 'rev-deleted-comment' )->escaped();
+ }
+ if ( $this->file->isDeleted( File::DELETED_COMMENT ) ) {
+ return "<span class=\"history-deleted\">$block</span>";
+ }
+
+ return $block;
+ }
+
+ public function getHTML() {
+ $data =
+ $this->list->msg( 'widthheight' )->numParams(
+ $this->file->getWidth(), $this->file->getHeight() )->text() .
+ ' (' . $this->list->msg( 'nbytes' )->numParams( $this->file->getSize() )->text() . ')';
+
+ return '<li>' . $this->getLink() . ' ' . $this->getUserTools() . ' ' .
+ $data . ' ' . $this->getComment() . '</li>';
+ }
+
+ public function getApiData( ApiResult $result ) {
+ $file = $this->file;
+ $user = $this->list->getUser();
+ $ret = [
+ 'title' => $this->list->title->getPrefixedText(),
+ 'archivename' => $file->getArchiveName(),
+ 'timestamp' => wfTimestamp( TS_ISO_8601, $file->getTimestamp() ),
+ 'width' => $file->getWidth(),
+ 'height' => $file->getHeight(),
+ 'size' => $file->getSize(),
+ 'userhidden' => (bool)$file->isDeleted( Revision::DELETED_USER ),
+ 'commenthidden' => (bool)$file->isDeleted( Revision::DELETED_COMMENT ),
+ 'contenthidden' => (bool)$this->isDeleted(),
+ ];
+ if ( !$this->isDeleted() ) {
+ $ret += [
+ 'url' => $file->getUrl(),
+ ];
+ } elseif ( $this->canViewContent() ) {
+ $ret += [
+ 'url' => SpecialPage::getTitleFor( 'Revisiondelete' )->getLinkURL(
+ [
+ 'target' => $this->list->title->getPrefixedText(),
+ 'file' => $file->getArchiveName(),
+ 'token' => $user->getEditToken( $file->getArchiveName() )
+ ]
+ ),
+ ];
+ }
+ if ( $file->userCan( Revision::DELETED_USER, $user ) ) {
+ $ret += [
+ 'userid' => $file->user,
+ 'user' => $file->user_text,
+ ];
+ }
+ if ( $file->userCan( Revision::DELETED_COMMENT, $user ) ) {
+ $ret += [
+ 'comment' => $file->description,
+ ];
+ }
+
+ return $ret;
+ }
+
+ public function lock() {
+ return $this->file->acquireFileLock();
+ }
+
+ public function unlock() {
+ return $this->file->releaseFileLock();
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelFileList.php b/www/wiki/includes/revisiondelete/RevDelFileList.php
new file mode 100644
index 00000000..77cf9767
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelFileList.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * List for oldimage table items
+ */
+class RevDelFileList extends RevDelList {
+ /** @var array */
+ public $storeBatch;
+
+ /** @var array */
+ public $deleteBatch;
+
+ /** @var array */
+ public $cleanupBatch;
+
+ public function getType() {
+ return 'oldimage';
+ }
+
+ public static function getRelationType() {
+ return 'oi_archive_name';
+ }
+
+ public static function getRestriction() {
+ return 'deleterevision';
+ }
+
+ public static function getRevdelConstant() {
+ return File::DELETED_FILE;
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return mixed
+ */
+ public function doQuery( $db ) {
+ $archiveNames = [];
+ foreach ( $this->ids as $timestamp ) {
+ $archiveNames[] = $timestamp . '!' . $this->title->getDBkey();
+ }
+
+ return $db->select(
+ 'oldimage',
+ OldLocalFile::selectFields(),
+ [
+ 'oi_name' => $this->title->getDBkey(),
+ 'oi_archive_name' => $archiveNames
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'oi_timestamp DESC' ]
+ );
+ }
+
+ public function newItem( $row ) {
+ return new RevDelFileItem( $this, $row );
+ }
+
+ public function clearFileOps() {
+ $this->deleteBatch = [];
+ $this->storeBatch = [];
+ $this->cleanupBatch = [];
+ }
+
+ public function doPreCommitUpdates() {
+ $status = Status::newGood();
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ if ( $this->storeBatch ) {
+ $status->merge( $repo->storeBatch( $this->storeBatch, FileRepo::OVERWRITE_SAME ) );
+ }
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+ if ( $this->deleteBatch ) {
+ $status->merge( $repo->deleteBatch( $this->deleteBatch ) );
+ }
+ if ( !$status->isOK() ) {
+ // Running cleanupDeletedBatch() after a failed storeBatch() with the DB already
+ // modified (but destined for rollback) causes data loss
+ return $status;
+ }
+ if ( $this->cleanupBatch ) {
+ $status->merge( $repo->cleanupDeletedBatch( $this->cleanupBatch ) );
+ }
+
+ return $status;
+ }
+
+ public function doPostCommitUpdates( array $visibilityChangeMap ) {
+ $file = wfLocalFile( $this->title );
+ $file->purgeCache();
+ $file->purgeDescription();
+
+ // Purge full images from cache
+ $purgeUrls = [];
+ foreach ( $this->ids as $timestamp ) {
+ $archiveName = $timestamp . '!' . $this->title->getDBkey();
+ $file->purgeOldThumbnails( $archiveName );
+ $purgeUrls[] = $file->getArchiveUrl( $archiveName );
+ }
+ DeferredUpdates::addUpdate(
+ new CdnCacheUpdate( $purgeUrls ),
+ DeferredUpdates::PRESEND
+ );
+
+ return Status::newGood();
+ }
+
+ public function getSuppressBit() {
+ return File::DELETED_RESTRICTED;
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelItem.php b/www/wiki/includes/revisiondelete/RevDelItem.php
new file mode 100644
index 00000000..bf97bd41
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelItem.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+/**
+ * Abstract base class for deletable items
+ */
+abstract class RevDelItem extends RevisionItemBase {
+ /**
+ * Returns true if the item is "current", and the operation to set the given
+ * bits can't be executed for that reason
+ * STUB
+ * @param int $newBits
+ * @return bool
+ */
+ public function isHideCurrentOp( $newBits ) {
+ return false;
+ }
+
+ /**
+ * Get the current deletion bitfield value
+ *
+ * @return int
+ */
+ abstract public function getBits();
+
+ /**
+ * Set the visibility of the item. This should do any necessary DB queries.
+ *
+ * The DB update query should have a condition which forces it to only update
+ * if the value in the DB matches the value fetched earlier with the SELECT.
+ * If the update fails because it did not match, the function should return
+ * false. This prevents concurrency problems.
+ *
+ * @param int $newBits
+ * @return bool Success
+ */
+ abstract public function setBits( $newBits );
+
+ /**
+ * Get the return information about the revision for the API
+ * @since 1.23
+ * @param ApiResult $result API result object
+ * @return array Data for the API result
+ */
+ abstract public function getApiData( ApiResult $result );
+
+ /**
+ * Lock the item against changes outside of the DB
+ * @return Status
+ * @since 1.28
+ */
+ public function lock() {
+ return Status::newGood();
+ }
+
+ /**
+ * Unlock the item against changes outside of the DB
+ * @return Status
+ * @since 1.28
+ */
+ public function unlock() {
+ return Status::newGood();
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelList.php b/www/wiki/includes/revisiondelete/RevDelList.php
new file mode 100644
index 00000000..011c7b09
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelList.php
@@ -0,0 +1,416 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Abstract base class for a list of deletable items. The list class
+ * needs to be able to make a query from a set of identifiers to pull
+ * relevant rows, to return RevDelItem subclasses wrapping them, and
+ * to wrap bulk update operations.
+ */
+abstract class RevDelList extends RevisionListBase {
+ function __construct( IContextSource $context, Title $title, array $ids ) {
+ parent::__construct( $context, $title );
+ $this->ids = $ids;
+ }
+
+ /**
+ * Get the DB field name associated with the ID list.
+ * This used to populate the log_search table for finding log entries.
+ * Override this function.
+ * @return string|null
+ */
+ public static function getRelationType() {
+ return null;
+ }
+
+ /**
+ * Get the user right required for this list type
+ * Override this function.
+ * @since 1.22
+ * @return string|null
+ */
+ public static function getRestriction() {
+ return null;
+ }
+
+ /**
+ * Get the revision deletion constant for this list type
+ * Override this function.
+ * @since 1.22
+ * @return int|null
+ */
+ public static function getRevdelConstant() {
+ return null;
+ }
+
+ /**
+ * Suggest a target for the revision deletion
+ * Optionally override this function.
+ * @since 1.22
+ * @param Title|null $target User-supplied target
+ * @param array $ids
+ * @return Title|null
+ */
+ public static function suggestTarget( $target, array $ids ) {
+ return $target;
+ }
+
+ /**
+ * Indicate whether any item in this list is suppressed
+ * @since 1.25
+ * @return bool
+ */
+ public function areAnySuppressed() {
+ $bit = $this->getSuppressBit();
+
+ /** @var RevDelItem $item */
+ foreach ( $this as $item ) {
+ if ( $item->getBits() & $bit ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Set the visibility for the revisions in this list. Logging and
+ * transactions are done here.
+ *
+ * @param array $params Associative array of parameters. Members are:
+ * value: ExtractBitParams() bitfield array
+ * comment: The log comment
+ * perItemStatus: Set if you want per-item status reports
+ * tags: The array of change tags to apply to the log entry
+ * @return Status
+ * @since 1.23 Added 'perItemStatus' param
+ */
+ public function setVisibility( array $params ) {
+ $status = Status::newGood();
+
+ $bitPars = $params['value'];
+ $comment = $params['comment'];
+ $perItemStatus = isset( $params['perItemStatus'] ) ? $params['perItemStatus'] : false;
+
+ // CAS-style checks are done on the _deleted fields so the select
+ // does not need to use FOR UPDATE nor be in the atomic section
+ $dbw = wfGetDB( DB_MASTER );
+ $this->res = $this->doQuery( $dbw );
+
+ $status->merge( $this->acquireItemLocks() );
+ if ( !$status->isGood() ) {
+ return $status;
+ }
+
+ $dbw->startAtomic( __METHOD__ );
+ $dbw->onTransactionResolution(
+ function () {
+ // Release locks on commit or error
+ $this->releaseItemLocks();
+ },
+ __METHOD__
+ );
+
+ $missing = array_flip( $this->ids );
+ $this->clearFileOps();
+ $idsForLog = [];
+ $authorIds = $authorIPs = [];
+
+ if ( $perItemStatus ) {
+ $status->itemStatuses = [];
+ }
+
+ // For multi-item deletions, set the old/new bitfields in log_params such that "hid X"
+ // shows in logs if field X was hidden from ANY item and likewise for "unhid Y". Note the
+ // form does not let the same field get hidden and unhidden in different items at once.
+ $virtualOldBits = 0;
+ $virtualNewBits = 0;
+ $logType = 'delete';
+
+ // Will be filled with id => [old, new bits] information and
+ // passed to doPostCommitUpdates().
+ $visibilityChangeMap = [];
+
+ /** @var RevDelItem $item */
+ foreach ( $this as $item ) {
+ unset( $missing[$item->getId()] );
+
+ if ( $perItemStatus ) {
+ $itemStatus = Status::newGood();
+ $status->itemStatuses[$item->getId()] = $itemStatus;
+ } else {
+ $itemStatus = $status;
+ }
+
+ $oldBits = $item->getBits();
+ // Build the actual new rev_deleted bitfield
+ $newBits = RevisionDeleter::extractBitfield( $bitPars, $oldBits );
+
+ if ( $oldBits == $newBits ) {
+ $itemStatus->warning(
+ 'revdelete-no-change', $item->formatDate(), $item->formatTime() );
+ $status->failCount++;
+ continue;
+ } elseif ( $oldBits == 0 && $newBits != 0 ) {
+ $opType = 'hide';
+ } elseif ( $oldBits != 0 && $newBits == 0 ) {
+ $opType = 'show';
+ } else {
+ $opType = 'modify';
+ }
+
+ if ( $item->isHideCurrentOp( $newBits ) ) {
+ // Cannot hide current version text
+ $itemStatus->error(
+ 'revdelete-hide-current', $item->formatDate(), $item->formatTime() );
+ $status->failCount++;
+ continue;
+ } elseif ( !$item->canView() ) {
+ // Cannot access this revision
+ $msg = ( $opType == 'show' ) ?
+ 'revdelete-show-no-access' : 'revdelete-modify-no-access';
+ $itemStatus->error( $msg, $item->formatDate(), $item->formatTime() );
+ $status->failCount++;
+ continue;
+ // Cannot just "hide from Sysops" without hiding any fields
+ } elseif ( $newBits == Revision::DELETED_RESTRICTED ) {
+ $itemStatus->warning(
+ 'revdelete-only-restricted', $item->formatDate(), $item->formatTime() );
+ $status->failCount++;
+ continue;
+ }
+
+ // Update the revision
+ $ok = $item->setBits( $newBits );
+
+ if ( $ok ) {
+ $idsForLog[] = $item->getId();
+ // If any item field was suppressed or unsupressed
+ if ( ( $oldBits | $newBits ) & $this->getSuppressBit() ) {
+ $logType = 'suppress';
+ }
+ // Track which fields where (un)hidden for each item
+ $addedBits = ( $oldBits ^ $newBits ) & $newBits;
+ $removedBits = ( $oldBits ^ $newBits ) & $oldBits;
+ $virtualNewBits |= $addedBits;
+ $virtualOldBits |= $removedBits;
+
+ $status->successCount++;
+ if ( $item->getAuthorId() > 0 ) {
+ $authorIds[] = $item->getAuthorId();
+ } elseif ( IP::isIPAddress( $item->getAuthorName() ) ) {
+ $authorIPs[] = $item->getAuthorName();
+ }
+
+ // Save the old and new bits in $visibilityChangeMap for
+ // later use.
+ $visibilityChangeMap[$item->getId()] = [
+ 'oldBits' => $oldBits,
+ 'newBits' => $newBits,
+ ];
+ } else {
+ $itemStatus->error(
+ 'revdelete-concurrent-change', $item->formatDate(), $item->formatTime() );
+ $status->failCount++;
+ }
+ }
+
+ // Handle missing revisions
+ foreach ( $missing as $id => $unused ) {
+ if ( $perItemStatus ) {
+ $status->itemStatuses[$id] = Status::newFatal( 'revdelete-modify-missing', $id );
+ } else {
+ $status->error( 'revdelete-modify-missing', $id );
+ }
+ $status->failCount++;
+ }
+
+ if ( $status->successCount == 0 ) {
+ $dbw->endAtomic( __METHOD__ );
+ return $status;
+ }
+
+ // Save success count
+ $successCount = $status->successCount;
+
+ // Move files, if there are any
+ $status->merge( $this->doPreCommitUpdates() );
+ if ( !$status->isOK() ) {
+ // Fatal error, such as no configured archive directory or I/O failures
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $lbFactory->rollbackMasterChanges( __METHOD__ );
+ return $status;
+ }
+
+ // Log it
+ $this->updateLog(
+ $logType,
+ [
+ 'title' => $this->title,
+ 'count' => $successCount,
+ 'newBits' => $virtualNewBits,
+ 'oldBits' => $virtualOldBits,
+ 'comment' => $comment,
+ 'ids' => $idsForLog,
+ 'authorIds' => $authorIds,
+ 'authorIPs' => $authorIPs,
+ 'tags' => isset( $params['tags'] ) ? $params['tags'] : [],
+ ]
+ );
+
+ // Clear caches after commit
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $visibilityChangeMap ) {
+ $this->doPostCommitUpdates( $visibilityChangeMap );
+ },
+ DeferredUpdates::PRESEND,
+ $dbw
+ );
+
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+
+ final protected function acquireItemLocks() {
+ $status = Status::newGood();
+ /** @var RevDelItem $item */
+ foreach ( $this as $item ) {
+ $status->merge( $item->lock() );
+ }
+
+ return $status;
+ }
+
+ final protected function releaseItemLocks() {
+ $status = Status::newGood();
+ /** @var RevDelItem $item */
+ foreach ( $this as $item ) {
+ $status->merge( $item->unlock() );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Reload the list data from the master DB. This can be done after setVisibility()
+ * to allow $item->getHTML() to show the new data.
+ */
+ function reloadFromMaster() {
+ $dbw = wfGetDB( DB_MASTER );
+ $this->res = $this->doQuery( $dbw );
+ }
+
+ /**
+ * Record a log entry on the action
+ * @param string $logType One of (delete,suppress)
+ * @param array $params Associative array of parameters:
+ * newBits: The new value of the *_deleted bitfield
+ * oldBits: The old value of the *_deleted bitfield.
+ * title: The target title
+ * ids: The ID list
+ * comment: The log comment
+ * authorsIds: The array of the user IDs of the offenders
+ * authorsIPs: The array of the IP/anon user offenders
+ * tags: The array of change tags to apply to the log entry
+ * @throws MWException
+ */
+ private function updateLog( $logType, $params ) {
+ // Get the URL param's corresponding DB field
+ $field = RevisionDeleter::getRelationType( $this->getType() );
+ if ( !$field ) {
+ throw new MWException( "Bad log URL param type!" );
+ }
+ // Add params for affected page and ids
+ $logParams = $this->getLogParams( $params );
+ // Actually add the deletion log entry
+ $logEntry = new ManualLogEntry( $logType, $this->getLogAction() );
+ $logEntry->setTarget( $params['title'] );
+ $logEntry->setComment( $params['comment'] );
+ $logEntry->setParameters( $logParams );
+ $logEntry->setPerformer( $this->getUser() );
+ // Allow for easy searching of deletion log items for revision/log items
+ $logEntry->setRelations( [
+ $field => $params['ids'],
+ 'target_author_id' => $params['authorIds'],
+ 'target_author_ip' => $params['authorIPs'],
+ ] );
+ // Apply change tags to the log entry
+ $logEntry->setTags( $params['tags'] );
+ $logId = $logEntry->insert();
+ $logEntry->publish( $logId );
+ }
+
+ /**
+ * Get the log action for this list type
+ * @return string
+ */
+ public function getLogAction() {
+ return 'revision';
+ }
+
+ /**
+ * Get log parameter array.
+ * @param array $params Associative array of log parameters, same as updateLog()
+ * @return array
+ */
+ public function getLogParams( $params ) {
+ return [
+ '4::type' => $this->getType(),
+ '5::ids' => $params['ids'],
+ '6::ofield' => $params['oldBits'],
+ '7::nfield' => $params['newBits'],
+ ];
+ }
+
+ /**
+ * Clear any data structures needed for doPreCommitUpdates() and doPostCommitUpdates()
+ * STUB
+ */
+ public function clearFileOps() {
+ }
+
+ /**
+ * A hook for setVisibility(): do batch updates pre-commit.
+ * STUB
+ * @return Status
+ */
+ public function doPreCommitUpdates() {
+ return Status::newGood();
+ }
+
+ /**
+ * A hook for setVisibility(): do any necessary updates post-commit.
+ * STUB
+ * @param array $visibilityChangeMap [id => ['oldBits' => $oldBits, 'newBits' => $newBits], ... ]
+ * @return Status
+ */
+ public function doPostCommitUpdates( array $visibilityChangeMap ) {
+ return Status::newGood();
+ }
+
+ /**
+ * Get the integer value of the flag used for suppression
+ */
+ abstract public function getSuppressBit();
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelLogItem.php b/www/wiki/includes/revisiondelete/RevDelLogItem.php
new file mode 100644
index 00000000..998c695f
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelLogItem.php
@@ -0,0 +1,145 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+/**
+ * Item class for a logging table row
+ */
+class RevDelLogItem extends RevDelItem {
+ public function getIdField() {
+ return 'log_id';
+ }
+
+ public function getTimestampField() {
+ return 'log_timestamp';
+ }
+
+ public function getAuthorIdField() {
+ return 'log_user';
+ }
+
+ public function getAuthorNameField() {
+ return 'log_user_text';
+ }
+
+ public function canView() {
+ return LogEventsList::userCan( $this->row, Revision::DELETED_RESTRICTED, $this->list->getUser() );
+ }
+
+ public function canViewContent() {
+ return true; // none
+ }
+
+ public function getBits() {
+ return (int)$this->row->log_deleted;
+ }
+
+ public function setBits( $bits ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->update( 'logging',
+ [ 'log_deleted' => $bits ],
+ [
+ 'log_id' => $this->row->log_id,
+ 'log_deleted' => $this->getBits() // cas
+ ],
+ __METHOD__
+ );
+
+ if ( !$dbw->affectedRows() ) {
+ // Concurrent fail!
+ return false;
+ }
+
+ $dbw->update( 'recentchanges',
+ [
+ 'rc_deleted' => $bits,
+ 'rc_patrolled' => 1
+ ],
+ [
+ 'rc_logid' => $this->row->log_id,
+ 'rc_timestamp' => $this->row->log_timestamp // index
+ ],
+ __METHOD__
+ );
+
+ return true;
+ }
+
+ public function getHTML() {
+ $date = htmlspecialchars( $this->list->getLanguage()->userTimeAndDate(
+ $this->row->log_timestamp, $this->list->getUser() ) );
+ $title = Title::makeTitle( $this->row->log_namespace, $this->row->log_title );
+ $formatter = LogFormatter::newFromRow( $this->row );
+ $formatter->setContext( $this->list->getContext() );
+ $formatter->setAudience( LogFormatter::FOR_THIS_USER );
+
+ // Log link for this page
+ $loglink = $this->getLinkRenderer()->makeLink(
+ SpecialPage::getTitleFor( 'Log' ),
+ $this->list->msg( 'log' )->text(),
+ [],
+ [ 'page' => $title->getPrefixedText() ]
+ );
+ $loglink = $this->list->msg( 'parentheses' )->rawParams( $loglink )->escaped();
+ // User links and action text
+ $action = $formatter->getActionText();
+ // Comment
+ $comment = CommentStore::newKey( 'log_comment' )->getComment( $this->row )->text;
+ $comment = $this->list->getLanguage()->getDirMark()
+ . Linker::commentBlock( $comment );
+
+ if ( LogEventsList::isDeleted( $this->row, LogPage::DELETED_COMMENT ) ) {
+ $comment = '<span class="history-deleted">' . $comment . '</span>';
+ }
+
+ return "<li>$loglink $date $action $comment</li>";
+ }
+
+ public function getApiData( ApiResult $result ) {
+ $logEntry = DatabaseLogEntry::newFromRow( $this->row );
+ $user = $this->list->getUser();
+ $ret = [
+ 'id' => $logEntry->getId(),
+ 'type' => $logEntry->getType(),
+ 'action' => $logEntry->getSubtype(),
+ 'userhidden' => (bool)$logEntry->isDeleted( LogPage::DELETED_USER ),
+ 'commenthidden' => (bool)$logEntry->isDeleted( LogPage::DELETED_COMMENT ),
+ 'actionhidden' => (bool)$logEntry->isDeleted( LogPage::DELETED_ACTION ),
+ ];
+
+ if ( LogEventsList::userCan( $this->row, LogPage::DELETED_ACTION, $user ) ) {
+ $ret['params'] = LogFormatter::newFromEntry( $logEntry )->formatParametersForApi();
+ }
+ if ( LogEventsList::userCan( $this->row, LogPage::DELETED_USER, $user ) ) {
+ $ret += [
+ 'userid' => $this->row->log_user,
+ 'user' => $this->row->log_user_text,
+ ];
+ }
+ if ( LogEventsList::userCan( $this->row, LogPage::DELETED_COMMENT, $user ) ) {
+ $ret += [
+ 'comment' => CommentStore::newKey( 'log_comment' )->getComment( $this->row )->text,
+ ];
+ }
+
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelLogList.php b/www/wiki/includes/revisiondelete/RevDelLogList.php
new file mode 100644
index 00000000..728deb8e
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelLogList.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * List for logging table items
+ */
+class RevDelLogList extends RevDelList {
+ public function getType() {
+ return 'logging';
+ }
+
+ public static function getRelationType() {
+ return 'log_id';
+ }
+
+ public static function getRestriction() {
+ return 'deletelogentry';
+ }
+
+ public static function getRevdelConstant() {
+ return LogPage::DELETED_ACTION;
+ }
+
+ public static function suggestTarget( $target, array $ids ) {
+ $result = wfGetDB( DB_REPLICA )->select( 'logging',
+ 'log_type',
+ [ 'log_id' => $ids ],
+ __METHOD__,
+ [ 'DISTINCT' ]
+ );
+ if ( $result->numRows() == 1 ) {
+ // If there's only one type, the target can be set to include it.
+ return SpecialPage::getTitleFor( 'Log', $result->current()->log_type );
+ }
+
+ return SpecialPage::getTitleFor( 'Log' );
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return mixed
+ */
+ public function doQuery( $db ) {
+ $ids = array_map( 'intval', $this->ids );
+
+ $commentQuery = CommentStore::newKey( 'log_comment' )->getJoin();
+
+ return $db->select(
+ [ 'logging' ] + $commentQuery['tables'],
+ [
+ 'log_id',
+ 'log_type',
+ 'log_action',
+ 'log_timestamp',
+ 'log_user',
+ 'log_user_text',
+ 'log_namespace',
+ 'log_title',
+ 'log_page',
+ 'log_params',
+ 'log_deleted'
+ ] + $commentQuery['fields'],
+ [ 'log_id' => $ids ],
+ __METHOD__,
+ [ 'ORDER BY' => 'log_id DESC' ],
+ $commentQuery['joins']
+ );
+ }
+
+ public function newItem( $row ) {
+ return new RevDelLogItem( $this, $row );
+ }
+
+ public function getSuppressBit() {
+ return Revision::DELETED_RESTRICTED;
+ }
+
+ public function getLogAction() {
+ return 'event';
+ }
+
+ public function getLogParams( $params ) {
+ return [
+ '4::ids' => $params['ids'],
+ '5::ofield' => $params['oldBits'],
+ '6::nfield' => $params['newBits'],
+ ];
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelRevisionItem.php b/www/wiki/includes/revisiondelete/RevDelRevisionItem.php
new file mode 100644
index 00000000..a9753b44
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelRevisionItem.php
@@ -0,0 +1,209 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+/**
+ * Item class for a live revision table row
+ */
+class RevDelRevisionItem extends RevDelItem {
+ /** @var Revision */
+ public $revision;
+
+ public function __construct( $list, $row ) {
+ parent::__construct( $list, $row );
+ $this->revision = new Revision( $row );
+ }
+
+ public function getIdField() {
+ return 'rev_id';
+ }
+
+ public function getTimestampField() {
+ return 'rev_timestamp';
+ }
+
+ public function getAuthorIdField() {
+ return 'rev_user';
+ }
+
+ public function getAuthorNameField() {
+ return 'rev_user_text';
+ }
+
+ public function canView() {
+ return $this->revision->userCan( Revision::DELETED_RESTRICTED, $this->list->getUser() );
+ }
+
+ public function canViewContent() {
+ return $this->revision->userCan( Revision::DELETED_TEXT, $this->list->getUser() );
+ }
+
+ public function getBits() {
+ return $this->revision->getVisibility();
+ }
+
+ public function setBits( $bits ) {
+ $dbw = wfGetDB( DB_MASTER );
+ // Update revision table
+ $dbw->update( 'revision',
+ [ 'rev_deleted' => $bits ],
+ [
+ 'rev_id' => $this->revision->getId(),
+ 'rev_page' => $this->revision->getPage(),
+ 'rev_deleted' => $this->getBits() // cas
+ ],
+ __METHOD__
+ );
+ if ( !$dbw->affectedRows() ) {
+ // Concurrent fail!
+ return false;
+ }
+ // Update recentchanges table
+ $dbw->update( 'recentchanges',
+ [
+ 'rc_deleted' => $bits,
+ 'rc_patrolled' => 1
+ ],
+ [
+ 'rc_this_oldid' => $this->revision->getId(), // condition
+ // non-unique timestamp index
+ 'rc_timestamp' => $dbw->timestamp( $this->revision->getTimestamp() ),
+ ],
+ __METHOD__
+ );
+
+ return true;
+ }
+
+ public function isDeleted() {
+ return $this->revision->isDeleted( Revision::DELETED_TEXT );
+ }
+
+ public function isHideCurrentOp( $newBits ) {
+ return ( $newBits & Revision::DELETED_TEXT )
+ && $this->list->getCurrent() == $this->getId();
+ }
+
+ /**
+ * Get the HTML link to the revision text.
+ * Overridden by RevDelArchiveItem.
+ * @return string
+ */
+ protected function getRevisionLink() {
+ $date = $this->list->getLanguage()->userTimeAndDate(
+ $this->revision->getTimestamp(), $this->list->getUser() );
+
+ if ( $this->isDeleted() && !$this->canViewContent() ) {
+ return htmlspecialchars( $date );
+ }
+
+ return $this->getLinkRenderer()->makeKnownLink(
+ $this->list->title,
+ $date,
+ [],
+ [
+ 'oldid' => $this->revision->getId(),
+ 'unhide' => 1
+ ]
+ );
+ }
+
+ /**
+ * Get the HTML link to the diff.
+ * Overridden by RevDelArchiveItem
+ * @return string
+ */
+ protected function getDiffLink() {
+ if ( $this->isDeleted() && !$this->canViewContent() ) {
+ return $this->list->msg( 'diff' )->escaped();
+ } else {
+ return $this->getLinkRenderer()->makeKnownLink(
+ $this->list->title,
+ $this->list->msg( 'diff' )->text(),
+ [],
+ [
+ 'diff' => $this->revision->getId(),
+ 'oldid' => 'prev',
+ 'unhide' => 1
+ ]
+ );
+ }
+ }
+
+ /**
+ * @return string A HTML <li> element representing this revision, showing
+ * change tags and everything
+ */
+ public function getHTML() {
+ $difflink = $this->list->msg( 'parentheses' )
+ ->rawParams( $this->getDiffLink() )->escaped();
+ $revlink = $this->getRevisionLink();
+ $userlink = Linker::revUserLink( $this->revision );
+ $comment = Linker::revComment( $this->revision );
+ if ( $this->isDeleted() ) {
+ $revlink = "<span class=\"history-deleted\">$revlink</span>";
+ }
+ $content = "$difflink $revlink $userlink $comment";
+ $attribs = [];
+ $tags = $this->getTags();
+ if ( $tags ) {
+ list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow(
+ $tags,
+ 'revisiondelete',
+ $this->list->getContext()
+ );
+ $content .= " $tagSummary";
+ $attribs['class'] = implode( ' ', $classes );
+ }
+ return Xml::tags( 'li', $attribs, $content );
+ }
+
+ /**
+ * @return string Comma-separated list of tags
+ */
+ public function getTags() {
+ return $this->row->ts_tags;
+ }
+
+ public function getApiData( ApiResult $result ) {
+ $rev = $this->revision;
+ $user = $this->list->getUser();
+ $ret = [
+ 'id' => $rev->getId(),
+ 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ),
+ 'userhidden' => (bool)$rev->isDeleted( Revision::DELETED_USER ),
+ 'commenthidden' => (bool)$rev->isDeleted( Revision::DELETED_COMMENT ),
+ 'texthidden' => (bool)$rev->isDeleted( Revision::DELETED_TEXT ),
+ ];
+ if ( $rev->userCan( Revision::DELETED_USER, $user ) ) {
+ $ret += [
+ 'userid' => $rev->getUser( Revision::FOR_THIS_USER ),
+ 'user' => $rev->getUserText( Revision::FOR_THIS_USER ),
+ ];
+ }
+ if ( $rev->userCan( Revision::DELETED_COMMENT, $user ) ) {
+ $ret += [
+ 'comment' => $rev->getComment( Revision::FOR_THIS_USER ),
+ ];
+ }
+
+ return $ret;
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevDelRevisionList.php b/www/wiki/includes/revisiondelete/RevDelRevisionList.php
new file mode 100644
index 00000000..1ea6a381
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevDelRevisionList.php
@@ -0,0 +1,185 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * List for revision table items
+ *
+ * This will check both the 'revision' table for live revisions and the
+ * 'archive' table for traditionally-deleted revisions that have an
+ * ar_rev_id saved.
+ *
+ * See RevDelRevisionItem and RevDelArchivedRevisionItem for items.
+ */
+class RevDelRevisionList extends RevDelList {
+ /** @var int */
+ public $currentRevId;
+
+ public function getType() {
+ return 'revision';
+ }
+
+ public static function getRelationType() {
+ return 'rev_id';
+ }
+
+ public static function getRestriction() {
+ return 'deleterevision';
+ }
+
+ public static function getRevdelConstant() {
+ return Revision::DELETED_TEXT;
+ }
+
+ public static function suggestTarget( $target, array $ids ) {
+ $rev = Revision::newFromId( $ids[0] );
+ return $rev ? $rev->getTitle() : $target;
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return mixed
+ */
+ public function doQuery( $db ) {
+ $ids = array_map( 'intval', $this->ids );
+ $queryInfo = [
+ 'tables' => [ 'revision', 'page', 'user' ],
+ 'fields' => array_merge( Revision::selectFields(), Revision::selectUserFields() ),
+ 'conds' => [
+ 'rev_page' => $this->title->getArticleID(),
+ 'rev_id' => $ids,
+ ],
+ 'options' => [
+ 'ORDER BY' => 'rev_id DESC',
+ 'USE INDEX' => [ 'revision' => 'PRIMARY' ] // workaround for MySQL bug (T104313)
+ ],
+ 'join_conds' => [
+ 'page' => Revision::pageJoinCond(),
+ 'user' => Revision::userJoinCond(),
+ ],
+ ];
+ ChangeTags::modifyDisplayQuery(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ $queryInfo['join_conds'],
+ $queryInfo['options'],
+ ''
+ );
+
+ $live = $db->select(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ __METHOD__,
+ $queryInfo['options'],
+ $queryInfo['join_conds']
+ );
+ if ( $live->numRows() >= count( $ids ) ) {
+ // All requested revisions are live, keeps things simple!
+ return $live;
+ }
+
+ $archiveQueryInfo = [
+ 'tables' => [ 'archive' ],
+ 'fields' => Revision::selectArchiveFields(),
+ 'conds' => [
+ 'ar_rev_id' => $ids,
+ ],
+ 'options' => [ 'ORDER BY' => 'ar_rev_id DESC' ],
+ 'join_conds' => [],
+ ];
+
+ ChangeTags::modifyDisplayQuery(
+ $archiveQueryInfo['tables'],
+ $archiveQueryInfo['fields'],
+ $archiveQueryInfo['conds'],
+ $archiveQueryInfo['join_conds'],
+ $archiveQueryInfo['options'],
+ ''
+ );
+
+ // Check if any requested revisions are available fully deleted.
+ $archived = $db->select(
+ $archiveQueryInfo['tables'],
+ $archiveQueryInfo['fields'],
+ $archiveQueryInfo['conds'],
+ __METHOD__,
+ $archiveQueryInfo['options'],
+ $archiveQueryInfo['join_conds']
+ );
+
+ if ( $archived->numRows() == 0 ) {
+ return $live;
+ } elseif ( $live->numRows() == 0 ) {
+ return $archived;
+ } else {
+ // Combine the two! Whee
+ $rows = [];
+ foreach ( $live as $row ) {
+ $rows[$row->rev_id] = $row;
+ }
+ foreach ( $archived as $row ) {
+ $rows[$row->ar_rev_id] = $row;
+ }
+ krsort( $rows );
+ return new FakeResultWrapper( array_values( $rows ) );
+ }
+ }
+
+ public function newItem( $row ) {
+ if ( isset( $row->rev_id ) ) {
+ return new RevDelRevisionItem( $this, $row );
+ } elseif ( isset( $row->ar_rev_id ) ) {
+ return new RevDelArchivedRevisionItem( $this, $row );
+ } else {
+ // This shouldn't happen. :)
+ throw new MWException( 'Invalid row type in RevDelRevisionList' );
+ }
+ }
+
+ public function getCurrent() {
+ if ( is_null( $this->currentRevId ) ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $this->currentRevId = $dbw->selectField(
+ 'page', 'page_latest', $this->title->pageCond(), __METHOD__ );
+ }
+ return $this->currentRevId;
+ }
+
+ public function getSuppressBit() {
+ return Revision::DELETED_RESTRICTED;
+ }
+
+ public function doPreCommitUpdates() {
+ $this->title->invalidateCache();
+ return Status::newGood();
+ }
+
+ public function doPostCommitUpdates( array $visibilityChangeMap ) {
+ $this->title->purgeSquid();
+ // Extensions that require referencing previous revisions may need this
+ Hooks::run( 'ArticleRevisionVisibilitySet', [ $this->title, $this->ids, $visibilityChangeMap ] );
+ return Status::newGood();
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevisionDeleteUser.php b/www/wiki/includes/revisiondelete/RevisionDeleteUser.php
new file mode 100644
index 00000000..7812fb98
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevisionDeleteUser.php
@@ -0,0 +1,144 @@
+<?php
+/**
+ * Backend functions for suppressing and unsuppressing all references to a given user.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Backend functions for suppressing and unsuppressing all references to a given user,
+ * used when blocking with HideUser enabled. This was spun out of SpecialBlockip.php
+ * in 1.18; at some point it needs to be rewritten to either use RevisionDelete abstraction,
+ * or at least schema abstraction.
+ *
+ * @ingroup RevisionDelete
+ */
+class RevisionDeleteUser {
+
+ /**
+ * Update *_deleted bitfields in various tables to hide or unhide usernames
+ * @param string $name Username
+ * @param int $userId User id
+ * @param string $op Operator '|' or '&'
+ * @param null|IDatabase $dbw If you happen to have one lying around
+ * @return bool
+ */
+ private static function setUsernameBitfields( $name, $userId, $op, $dbw ) {
+ if ( !$userId || ( $op !== '|' && $op !== '&' ) ) {
+ return false; // sanity check
+ }
+ if ( !$dbw instanceof IDatabase ) {
+ $dbw = wfGetDB( DB_MASTER );
+ }
+
+ # To suppress, we OR the current bitfields with Revision::DELETED_USER
+ # to put a 1 in the username *_deleted bit. To unsuppress we AND the
+ # current bitfields with the inverse of Revision::DELETED_USER. The
+ # username bit is made to 0 (x & 0 = 0), while others are unchanged (x & 1 = x).
+ # The same goes for the sysop-restricted *_deleted bit.
+ $delUser = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
+ $delAction = LogPage::DELETED_ACTION | Revision::DELETED_RESTRICTED;
+ if ( $op == '&' ) {
+ $delUser = $dbw->bitNot( $delUser );
+ $delAction = $dbw->bitNot( $delAction );
+ }
+
+ # Normalize user name
+ $userTitle = Title::makeTitleSafe( NS_USER, $name );
+ $userDbKey = $userTitle->getDBkey();
+
+ # Hide name from live edits
+ $dbw->update(
+ 'revision',
+ [ self::buildSetBitDeletedField( 'rev_deleted', $op, $delUser, $dbw ) ],
+ [ 'rev_user' => $userId ],
+ __METHOD__ );
+
+ # Hide name from deleted edits
+ $dbw->update(
+ 'archive',
+ [ self::buildSetBitDeletedField( 'ar_deleted', $op, $delUser, $dbw ) ],
+ [ 'ar_user_text' => $name ],
+ __METHOD__
+ );
+
+ # Hide name from logs
+ $dbw->update(
+ 'logging',
+ [ self::buildSetBitDeletedField( 'log_deleted', $op, $delUser, $dbw ) ],
+ [ 'log_user' => $userId, 'log_type != ' . $dbw->addQuotes( 'suppress' ) ],
+ __METHOD__
+ );
+ $dbw->update(
+ 'logging',
+ [ self::buildSetBitDeletedField( 'log_deleted', $op, $delAction, $dbw ) ],
+ [ 'log_namespace' => NS_USER, 'log_title' => $userDbKey,
+ 'log_type != ' . $dbw->addQuotes( 'suppress' ) ],
+ __METHOD__
+ );
+
+ # Hide name from RC
+ $dbw->update(
+ 'recentchanges',
+ [ self::buildSetBitDeletedField( 'rc_deleted', $op, $delUser, $dbw ) ],
+ [ 'rc_user_text' => $name ],
+ __METHOD__
+ );
+ $dbw->update(
+ 'recentchanges',
+ [ self::buildSetBitDeletedField( 'rc_deleted', $op, $delAction, $dbw ) ],
+ [ 'rc_namespace' => NS_USER, 'rc_title' => $userDbKey, 'rc_logid > 0' ],
+ __METHOD__
+ );
+
+ # Hide name from live images
+ $dbw->update(
+ 'oldimage',
+ [ self::buildSetBitDeletedField( 'oi_deleted', $op, $delUser, $dbw ) ],
+ [ 'oi_user_text' => $name ],
+ __METHOD__
+ );
+
+ # Hide name from deleted images
+ $dbw->update(
+ 'filearchive',
+ [ self::buildSetBitDeletedField( 'fa_deleted', $op, $delUser, $dbw ) ],
+ [ 'fa_user_text' => $name ],
+ __METHOD__
+ );
+ # Done!
+ return true;
+ }
+
+ private static function buildSetBitDeletedField( $field, $op, $value, $dbw ) {
+ return $field . ' = ' . ( $op === '&'
+ ? $dbw->bitAnd( $field, $value )
+ : $dbw->bitOr( $field, $value ) );
+ }
+
+ public static function suppressUserName( $name, $userId, $dbw = null ) {
+ return self::setUsernameBitfields( $name, $userId, '|', $dbw );
+ }
+
+ public static function unsuppressUserName( $name, $userId, $dbw = null ) {
+ return self::setUsernameBitfields( $name, $userId, '&', $dbw );
+ }
+}
diff --git a/www/wiki/includes/revisiondelete/RevisionDeleter.php b/www/wiki/includes/revisiondelete/RevisionDeleter.php
new file mode 100644
index 00000000..76fa590d
--- /dev/null
+++ b/www/wiki/includes/revisiondelete/RevisionDeleter.php
@@ -0,0 +1,251 @@
+<?php
+/**
+ * Revision/log/file deletion backend
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup RevisionDelete
+ */
+
+/**
+ * General controller for RevDel, used by both SpecialRevisiondelete and
+ * ApiRevisionDelete.
+ * @ingroup RevisionDelete
+ */
+class RevisionDeleter {
+ /** List of known revdel types, with their corresponding list classes */
+ private static $allowedTypes = [
+ 'revision' => 'RevDelRevisionList',
+ 'archive' => 'RevDelArchiveList',
+ 'oldimage' => 'RevDelFileList',
+ 'filearchive' => 'RevDelArchivedFileList',
+ 'logging' => 'RevDelLogList',
+ ];
+
+ /** Type map to support old log entries */
+ private static $deprecatedTypeMap = [
+ 'oldid' => 'revision',
+ 'artimestamp' => 'archive',
+ 'oldimage' => 'oldimage',
+ 'fileid' => 'filearchive',
+ 'logid' => 'logging',
+ ];
+
+ /**
+ * Lists the valid possible types for revision deletion.
+ *
+ * @since 1.22
+ * @return array
+ */
+ public static function getTypes() {
+ return array_keys( self::$allowedTypes );
+ }
+
+ /**
+ * Gets the canonical type name, if any.
+ *
+ * @since 1.22
+ * @param string $typeName
+ * @return string|null
+ */
+ public static function getCanonicalTypeName( $typeName ) {
+ if ( isset( self::$deprecatedTypeMap[$typeName] ) ) {
+ $typeName = self::$deprecatedTypeMap[$typeName];
+ }
+ return isset( self::$allowedTypes[$typeName] ) ? $typeName : null;
+ }
+
+ /**
+ * Instantiate the appropriate list class for a given list of IDs.
+ *
+ * @since 1.22
+ * @param string $typeName RevDel type, see RevisionDeleter::getTypes()
+ * @param IContextSource $context
+ * @param Title $title
+ * @param array $ids
+ * @return RevDelList
+ * @throws MWException
+ */
+ public static function createList( $typeName, IContextSource $context, Title $title, array $ids ) {
+ $typeName = self::getCanonicalTypeName( $typeName );
+ if ( !$typeName ) {
+ throw new MWException( __METHOD__ . ": Unknown RevDel type '$typeName'" );
+ }
+ $class = self::$allowedTypes[$typeName];
+ return new $class( $context, $title, $ids );
+ }
+
+ /**
+ * Checks for a change in the bitfield for a certain option and updates the
+ * provided array accordingly.
+ *
+ * @param string $desc Description to add to the array if the option was
+ * enabled / disabled.
+ * @param int $field The bitmask describing the single option.
+ * @param int $diff The xor of the old and new bitfields.
+ * @param int $new The new bitfield
+ * @param array &$arr The array to update.
+ */
+ protected static function checkItem( $desc, $field, $diff, $new, &$arr ) {
+ if ( $diff & $field ) {
+ $arr[( $new & $field ) ? 0 : 1][] = $desc;
+ }
+ }
+
+ /**
+ * Gets an array of message keys describing the changes made to the
+ * visibility of the revision.
+ *
+ * If the resulting array is $arr, then $arr[0] will contain an array of
+ * keys describing the items that were hidden, $arr[1] will contain
+ * an array of keys describing the items that were unhidden, and $arr[2]
+ * will contain an array with a single message key, which can be one of
+ * "revdelete-restricted", "revdelete-unrestricted" indicating (un)suppression
+ * or null to indicate nothing in particular.
+ * You can turn the keys in $arr[0] and $arr[1] into message keys by
+ * appending -hid and -unhid to the keys respectively.
+ *
+ * @param int $n The new bitfield.
+ * @param int $o The old bitfield.
+ * @return array An array as described above.
+ * @since 1.19 public
+ */
+ public static function getChanges( $n, $o ) {
+ $diff = $n ^ $o;
+ $ret = [ 0 => [], 1 => [], 2 => [] ];
+ // Build bitfield changes in language
+ self::checkItem( 'revdelete-content',
+ Revision::DELETED_TEXT, $diff, $n, $ret );
+ self::checkItem( 'revdelete-summary',
+ Revision::DELETED_COMMENT, $diff, $n, $ret );
+ self::checkItem( 'revdelete-uname',
+ Revision::DELETED_USER, $diff, $n, $ret );
+ // Restriction application to sysops
+ if ( $diff & Revision::DELETED_RESTRICTED ) {
+ if ( $n & Revision::DELETED_RESTRICTED ) {
+ $ret[2][] = 'revdelete-restricted';
+ } else {
+ $ret[2][] = 'revdelete-unrestricted';
+ }
+ }
+ return $ret;
+ }
+
+ /** Get DB field name for URL param...
+ * Future code for other things may also track
+ * other types of revision-specific changes.
+ * @param string $typeName
+ * @return string One of log_id/rev_id/fa_id/ar_timestamp/oi_archive_name
+ */
+ public static function getRelationType( $typeName ) {
+ $typeName = self::getCanonicalTypeName( $typeName );
+ if ( !$typeName ) {
+ return null;
+ }
+ return call_user_func( [ self::$allowedTypes[$typeName], 'getRelationType' ] );
+ }
+
+ /**
+ * Get the user right required for the RevDel type
+ * @since 1.22
+ * @param string $typeName
+ * @return string User right
+ */
+ public static function getRestriction( $typeName ) {
+ $typeName = self::getCanonicalTypeName( $typeName );
+ if ( !$typeName ) {
+ return null;
+ }
+ return call_user_func( [ self::$allowedTypes[$typeName], 'getRestriction' ] );
+ }
+
+ /**
+ * Get the revision deletion constant for the RevDel type
+ * @since 1.22
+ * @param string $typeName
+ * @return int RevDel constant
+ */
+ public static function getRevdelConstant( $typeName ) {
+ $typeName = self::getCanonicalTypeName( $typeName );
+ if ( !$typeName ) {
+ return null;
+ }
+ return call_user_func( [ self::$allowedTypes[$typeName], 'getRevdelConstant' ] );
+ }
+
+ /**
+ * Suggest a target for the revision deletion
+ * @since 1.22
+ * @param string $typeName
+ * @param Title|null $target User-supplied target
+ * @param array $ids
+ * @return Title|null
+ */
+ public static function suggestTarget( $typeName, $target, array $ids ) {
+ $typeName = self::getCanonicalTypeName( $typeName );
+ if ( !$typeName ) {
+ return $target;
+ }
+ return call_user_func( [ self::$allowedTypes[$typeName], 'suggestTarget' ], $target, $ids );
+ }
+
+ /**
+ * Checks if a revision still exists in the revision table.
+ * If it doesn't, returns the corresponding ar_timestamp field
+ * so that this key can be used instead.
+ *
+ * @param Title $title
+ * @param int $revid
+ * @return bool|mixed
+ */
+ public static function checkRevisionExistence( $title, $revid ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $exists = $dbr->selectField( 'revision', '1',
+ [ 'rev_id' => $revid ], __METHOD__ );
+
+ if ( $exists ) {
+ return true;
+ }
+
+ $timestamp = $dbr->selectField( 'archive', 'ar_timestamp',
+ [ 'ar_namespace' => $title->getNamespace(),
+ 'ar_title' => $title->getDBkey(),
+ 'ar_rev_id' => $revid ], __METHOD__ );
+
+ return $timestamp;
+ }
+
+ /**
+ * Put together a rev_deleted bitfield
+ * @since 1.22
+ * @param array $bitPars ExtractBitParams() params
+ * @param int $oldfield Current bitfield
+ * @return int
+ */
+ public static function extractBitfield( array $bitPars, $oldfield ) {
+ // Build the actual new rev_deleted bitfield
+ $newBits = 0;
+ foreach ( $bitPars as $const => $val ) {
+ if ( $val == 1 ) {
+ $newBits |= $const; // $const is the *_deleted const
+ } elseif ( $val == -1 ) {
+ $newBits |= ( $oldfield & $const ); // use existing
+ }
+ }
+ return $newBits;
+ }
+}
diff --git a/www/wiki/includes/search/AugmentPageProps.php b/www/wiki/includes/search/AugmentPageProps.php
new file mode 100644
index 00000000..29bd463d
--- /dev/null
+++ b/www/wiki/includes/search/AugmentPageProps.php
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * Augment search result set with values of certain page props.
+ */
+class AugmentPageProps implements ResultSetAugmentor {
+ /**
+ * @var array List of properties.
+ */
+ private $propnames;
+
+ public function __construct( $propnames ) {
+ $this->propnames = $propnames;
+ }
+
+ public function augmentAll( SearchResultSet $resultSet ) {
+ $titles = $resultSet->extractTitles();
+ return PageProps::getInstance()->getProperties( $titles, $this->propnames );
+ }
+}
diff --git a/www/wiki/includes/search/DummySearchIndexFieldDefinition.php b/www/wiki/includes/search/DummySearchIndexFieldDefinition.php
new file mode 100644
index 00000000..3ee3ed5a
--- /dev/null
+++ b/www/wiki/includes/search/DummySearchIndexFieldDefinition.php
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * Dummy implementation of SearchIndexFieldDefinition for testing purposes.
+ *
+ * @since 1.28
+ */
+class DummySearchIndexFieldDefinition extends SearchIndexFieldDefinition {
+
+ /**
+ * @param SearchEngine $engine
+ *
+ * @return array
+ */
+ public function getMapping( SearchEngine $engine ) {
+ $mapping = [
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'flags' => $this->flags,
+ 'subfields' => []
+ ];
+
+ foreach ( $this->subfields as $subfield ) {
+ $mapping['subfields'][] = $subfield->getMapping( $engine );
+ }
+
+ return $mapping;
+ }
+
+}
diff --git a/www/wiki/includes/search/NullIndexField.php b/www/wiki/includes/search/NullIndexField.php
new file mode 100644
index 00000000..ff1e8cbf
--- /dev/null
+++ b/www/wiki/includes/search/NullIndexField.php
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * Null index field - means search engine does not implement this field.
+ */
+class NullIndexField implements SearchIndexField {
+
+ /**
+ * Get mapping for specific search engine
+ * @param SearchEngine $engine
+ * @return array|null Null means this field does not map to anything
+ */
+ public function getMapping( SearchEngine $engine ) {
+ return null;
+ }
+
+ /**
+ * Set global flag for this field.
+ *
+ * @param int $flag Bit flag to set/unset
+ * @param bool $unset True if flag should be unset, false by default
+ * @return $this
+ */
+ public function setFlag( $flag, $unset = false ) {
+ }
+
+ /**
+ * Check if flag is set.
+ * @param int $flag
+ * @return int 0 if unset, !=0 if set
+ */
+ public function checkFlag( $flag ) {
+ return 0;
+ }
+
+ /**
+ * Merge two field definitions if possible.
+ *
+ * @param SearchIndexField $that
+ * @return SearchIndexField|false New definition or false if not mergeable.
+ */
+ public function merge( SearchIndexField $that ) {
+ return $that;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getEngineHints( SearchEngine $engine ) {
+ return [];
+ }
+}
diff --git a/www/wiki/includes/search/ParserOutputSearchDataExtractor.php b/www/wiki/includes/search/ParserOutputSearchDataExtractor.php
new file mode 100644
index 00000000..4b60a0c5
--- /dev/null
+++ b/www/wiki/includes/search/ParserOutputSearchDataExtractor.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace MediaWiki\Search;
+
+use Category;
+use ParserOutput;
+use Title;
+
+/**
+ * Extracts data from ParserOutput for indexing in the search engine.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.28
+ */
+class ParserOutputSearchDataExtractor {
+
+ /**
+ * Get a list of categories, as an array with title text strings.
+ *
+ * @param ParserOutput $parserOutput
+ * @return string[]
+ */
+ public function getCategories( ParserOutput $parserOutput ) {
+ $categories = [];
+
+ foreach ( $parserOutput->getCategoryLinks() as $key ) {
+ $categories[] = Category::newFromName( $key )->getTitle()->getText();
+ }
+
+ return $categories;
+ }
+
+ /**
+ * Get a list of external links from ParserOutput, as an array of strings.
+ *
+ * @param ParserOutput $parserOutput
+ * @return string[]
+ */
+ public function getExternalLinks( ParserOutput $parserOutput ) {
+ return array_keys( $parserOutput->getExternalLinks() );
+ }
+
+ /**
+ * Get a list of outgoing wiki links (including interwiki links), as
+ * an array of prefixed title strings.
+ *
+ * @param ParserOutput $parserOutput
+ * @return string[]
+ */
+ public function getOutgoingLinks( ParserOutput $parserOutput ) {
+ $outgoingLinks = [];
+
+ foreach ( $parserOutput->getLinks() as $linkedNamespace => $namespaceLinks ) {
+ foreach ( array_keys( $namespaceLinks ) as $linkedDbKey ) {
+ $outgoingLinks[] =
+ Title::makeTitle( $linkedNamespace, $linkedDbKey )->getPrefixedDBkey();
+ }
+ }
+
+ return $outgoingLinks;
+ }
+
+ /**
+ * Get a list of templates used in the ParserOutput content, as prefixed title strings
+ *
+ * @param ParserOutput $parserOutput
+ * @return string[]
+ */
+ public function getTemplates( ParserOutput $parserOutput ) {
+ $templates = [];
+
+ foreach ( $parserOutput->getTemplates() as $tNS => $templatesInNS ) {
+ foreach ( array_keys( $templatesInNS ) as $tDbKey ) {
+ $templateTitle = Title::makeTitle( $tNS, $tDbKey );
+ $templates[] = $templateTitle->getPrefixedText();
+ }
+ }
+
+ return $templates;
+ }
+
+}
diff --git a/www/wiki/includes/search/PerRowAugmentor.php b/www/wiki/includes/search/PerRowAugmentor.php
new file mode 100644
index 00000000..a3979f7b
--- /dev/null
+++ b/www/wiki/includes/search/PerRowAugmentor.php
@@ -0,0 +1,37 @@
+<?php
+
+/**
+ * Perform augmentation of each row and return composite result,
+ * indexed by ID.
+ */
+class PerRowAugmentor implements ResultSetAugmentor {
+
+ /**
+ * @var ResultAugmentor
+ */
+ private $rowAugmentor;
+
+ /**
+ * @param ResultAugmentor $augmentor Per-result augmentor to use.
+ */
+ public function __construct( ResultAugmentor $augmentor ) {
+ $this->rowAugmentor = $augmentor;
+ }
+
+ /**
+ * Produce data to augment search result set.
+ * @param SearchResultSet $resultSet
+ * @return array Data for all results
+ */
+ public function augmentAll( SearchResultSet $resultSet ) {
+ $data = [];
+ foreach ( $resultSet->extractResults() as $result ) {
+ $id = $result->getTitle()->getArticleID();
+ if ( !$id ) {
+ continue;
+ }
+ $data[$id] = $this->rowAugmentor->augment( $result );
+ }
+ return $data;
+ }
+}
diff --git a/www/wiki/includes/search/ResultAugmentor.php b/www/wiki/includes/search/ResultAugmentor.php
new file mode 100644
index 00000000..df58e71a
--- /dev/null
+++ b/www/wiki/includes/search/ResultAugmentor.php
@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * Augment search results.
+ */
+interface ResultAugmentor {
+ /**
+ * Produce data to augment search result set.
+ * @param SearchResult $result
+ * @return mixed Data for this result
+ */
+ public function augment( SearchResult $result );
+}
diff --git a/www/wiki/includes/search/ResultSetAugmentor.php b/www/wiki/includes/search/ResultSetAugmentor.php
new file mode 100644
index 00000000..e2d79a9c
--- /dev/null
+++ b/www/wiki/includes/search/ResultSetAugmentor.php
@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * Augment search results.
+ */
+interface ResultSetAugmentor {
+ /**
+ * Produce data to augment search result set.
+ * @param SearchResultSet $resultSet
+ * @return array Data for all results
+ */
+ public function augmentAll( SearchResultSet $resultSet );
+}
diff --git a/www/wiki/includes/search/SearchDatabase.php b/www/wiki/includes/search/SearchDatabase.php
new file mode 100644
index 00000000..643c2c13
--- /dev/null
+++ b/www/wiki/includes/search/SearchDatabase.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Database search engine
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Base search engine base class for database-backed searches
+ * @ingroup Search
+ * @since 1.23
+ */
+class SearchDatabase extends SearchEngine {
+ /**
+ * @var IDatabase Slave database for reading from for results
+ */
+ protected $db;
+
+ /**
+ * @param IDatabase $db The database to search from
+ */
+ public function __construct( IDatabase $db = null ) {
+ if ( $db ) {
+ $this->db = $db;
+ } else {
+ $this->db = wfGetDB( DB_REPLICA );
+ }
+ }
+
+ /**
+ * Return a 'cleaned up' search string
+ *
+ * @param string $text
+ * @return string
+ */
+ protected function filter( $text ) {
+ // List of chars allowed in the search query.
+ // This must include chars used in the search syntax.
+ // Usually " (phrase) or * (wildcards) if supported by the engine
+ $lc = $this->legalSearchChars( self::CHARS_ALL );
+ return trim( preg_replace( "/[^{$lc}]/", " ", $text ) );
+ }
+}
diff --git a/www/wiki/includes/search/SearchEngine.php b/www/wiki/includes/search/SearchEngine.php
new file mode 100644
index 00000000..3c8fe608
--- /dev/null
+++ b/www/wiki/includes/search/SearchEngine.php
@@ -0,0 +1,821 @@
+<?php
+/**
+ * Basic search engine
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+/**
+ * @defgroup Search Search
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Contain a class for special pages
+ * @ingroup Search
+ */
+abstract class SearchEngine {
+ /** @var string */
+ public $prefix = '';
+
+ /** @var int[]|null */
+ public $namespaces = [ NS_MAIN ];
+
+ /** @var int */
+ protected $limit = 10;
+
+ /** @var int */
+ protected $offset = 0;
+
+ /** @var array|string */
+ protected $searchTerms = [];
+
+ /** @var bool */
+ protected $showSuggestion = true;
+ private $sort = 'relevance';
+
+ /** @var array Feature values */
+ protected $features = [];
+
+ /** @const string profile type for completionSearch */
+ const COMPLETION_PROFILE_TYPE = 'completionSearchProfile';
+
+ /** @const string profile type for query independent ranking features */
+ const FT_QUERY_INDEP_PROFILE_TYPE = 'fulltextQueryIndepProfile';
+
+ /** @const int flag for legalSearchChars: includes all chars allowed in a search query */
+ const CHARS_ALL = 1;
+
+ /** @const int flag for legalSearchChars: includes all chars allowed in a search term */
+ const CHARS_NO_SYNTAX = 2;
+
+ /**
+ * Perform a full text search query and return a result set.
+ * If full text searches are not supported or disabled, return null.
+ * STUB
+ *
+ * @param string $term Raw search term
+ * @return SearchResultSet|Status|null
+ */
+ function searchText( $term ) {
+ return null;
+ }
+
+ /**
+ * Perform a title search in the article archive.
+ * NOTE: these results still should be filtered by
+ * matching against PageArchive, permissions checks etc
+ * The results returned by this methods are only sugegstions and
+ * may not end up being shown to the user.
+ *
+ * @param string $term Raw search term
+ * @return Status<Title[]>
+ * @since 1.29
+ */
+ function searchArchiveTitle( $term ) {
+ return Status::newGood( [] );
+ }
+
+ /**
+ * Perform a title-only search query and return a result set.
+ * If title searches are not supported or disabled, return null.
+ * STUB
+ *
+ * @param string $term Raw search term
+ * @return SearchResultSet|null
+ */
+ function searchTitle( $term ) {
+ return null;
+ }
+
+ /**
+ * @since 1.18
+ * @param string $feature
+ * @return bool
+ */
+ public function supports( $feature ) {
+ switch ( $feature ) {
+ case 'search-update':
+ return true;
+ case 'title-suffix-filter':
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Way to pass custom data for engines
+ * @since 1.18
+ * @param string $feature
+ * @param mixed $data
+ */
+ public function setFeatureData( $feature, $data ) {
+ $this->features[$feature] = $data;
+ }
+
+ /**
+ * Way to retrieve custom data set by setFeatureData
+ * or by the engine itself.
+ * @since 1.29
+ * @param string $feature feature name
+ * @return mixed the feature value or null if unset
+ */
+ public function getFeatureData( $feature ) {
+ if ( isset( $this->features[$feature] ) ) {
+ return $this->features[$feature];
+ }
+ return null;
+ }
+
+ /**
+ * When overridden in derived class, performs database-specific conversions
+ * on text to be used for searching or updating search index.
+ * Default implementation does nothing (simply returns $string).
+ *
+ * @param string $string String to process
+ * @return string
+ */
+ public function normalizeText( $string ) {
+ global $wgContLang;
+
+ // Some languages such as Chinese require word segmentation
+ return $wgContLang->segmentByWord( $string );
+ }
+
+ /**
+ * Transform search term in cases when parts of the query came as different
+ * GET params (when supported), e.g. for prefix queries:
+ * search=test&prefix=Main_Page/Archive -> test prefix:Main Page/Archive
+ * @param string $term
+ * @return string
+ */
+ public function transformSearchTerm( $term ) {
+ return $term;
+ }
+
+ /**
+ * Get service class to finding near matches.
+ * @param Config $config Configuration to use for the matcher.
+ * @return SearchNearMatcher
+ */
+ public function getNearMatcher( Config $config ) {
+ global $wgContLang;
+ return new SearchNearMatcher( $config, $wgContLang );
+ }
+
+ /**
+ * Get near matcher for default SearchEngine.
+ * @return SearchNearMatcher
+ */
+ protected static function defaultNearMatcher() {
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ return MediaWikiServices::getInstance()->newSearchEngine()->getNearMatcher( $config );
+ }
+
+ /**
+ * If an exact title match can be found, or a very slightly close match,
+ * return the title. If no match, returns NULL.
+ * @deprecated since 1.27; Use SearchEngine::getNearMatcher()
+ * @param string $searchterm
+ * @return Title
+ */
+ public static function getNearMatch( $searchterm ) {
+ return static::defaultNearMatcher()->getNearMatch( $searchterm );
+ }
+
+ /**
+ * Do a near match (see SearchEngine::getNearMatch) and wrap it into a
+ * SearchResultSet.
+ * @deprecated since 1.27; Use SearchEngine::getNearMatcher()
+ * @param string $searchterm
+ * @return SearchResultSet
+ */
+ public static function getNearMatchResultSet( $searchterm ) {
+ return static::defaultNearMatcher()->getNearMatchResultSet( $searchterm );
+ }
+
+ /**
+ * Get chars legal for search
+ * NOTE: usage as static is deprecated and preserved only as BC measure
+ * @param int $type type of search chars (see self::CHARS_ALL
+ * and self::CHARS_NO_SYNTAX). Defaults to CHARS_ALL
+ * @return string
+ */
+ public static function legalSearchChars( $type = self::CHARS_ALL ) {
+ return "A-Za-z_'.0-9\\x80-\\xFF\\-";
+ }
+
+ /**
+ * Set the maximum number of results to return
+ * and how many to skip before returning the first.
+ *
+ * @param int $limit
+ * @param int $offset
+ */
+ function setLimitOffset( $limit, $offset = 0 ) {
+ $this->limit = intval( $limit );
+ $this->offset = intval( $offset );
+ }
+
+ /**
+ * Set which namespaces the search should include.
+ * Give an array of namespace index numbers.
+ *
+ * @param int[]|null $namespaces
+ */
+ function setNamespaces( $namespaces ) {
+ if ( $namespaces ) {
+ // Filter namespaces to only keep valid ones
+ $validNs = $this->searchableNamespaces();
+ $namespaces = array_filter( $namespaces, function ( $ns ) use( $validNs ) {
+ return $ns < 0 || isset( $validNs[$ns] );
+ } );
+ } else {
+ $namespaces = [];
+ }
+ $this->namespaces = $namespaces;
+ }
+
+ /**
+ * Set whether the searcher should try to build a suggestion. Note: some searchers
+ * don't support building a suggestion in the first place and others don't respect
+ * this flag.
+ *
+ * @param bool $showSuggestion Should the searcher try to build suggestions
+ */
+ function setShowSuggestion( $showSuggestion ) {
+ $this->showSuggestion = $showSuggestion;
+ }
+
+ /**
+ * Get the valid sort directions. All search engines support 'relevance' but others
+ * might support more. The default in all implementations should be 'relevance.'
+ *
+ * @since 1.25
+ * @return string[] the valid sort directions for setSort
+ */
+ public function getValidSorts() {
+ return [ 'relevance' ];
+ }
+
+ /**
+ * Set the sort direction of the search results. Must be one returned by
+ * SearchEngine::getValidSorts()
+ *
+ * @since 1.25
+ * @throws InvalidArgumentException
+ * @param string $sort sort direction for query result
+ */
+ public function setSort( $sort ) {
+ if ( !in_array( $sort, $this->getValidSorts() ) ) {
+ throw new InvalidArgumentException( "Invalid sort: $sort. " .
+ "Must be one of: " . implode( ', ', $this->getValidSorts() ) );
+ }
+ $this->sort = $sort;
+ }
+
+ /**
+ * Get the sort direction of the search results
+ *
+ * @since 1.25
+ * @return string
+ */
+ public function getSort() {
+ return $this->sort;
+ }
+
+ /**
+ * Parse some common prefixes: all (search everything)
+ * or namespace names and set the list of namespaces
+ * of this class accordingly.
+ *
+ * @param string $query
+ * @return string
+ */
+ function replacePrefixes( $query ) {
+ $queryAndNs = self::parseNamespacePrefixes( $query );
+ if ( $queryAndNs === false ) {
+ return $query;
+ }
+ $this->namespaces = $queryAndNs[1];
+ return $queryAndNs[0];
+ }
+
+ /**
+ * Parse some common prefixes: all (search everything)
+ * or namespace names
+ *
+ * @param string $query
+ * @return false|array false if no namespace was extracted, an array
+ * with the parsed query at index 0 and an array of namespaces at index
+ * 1 (or null for all namespaces).
+ */
+ public static function parseNamespacePrefixes( $query ) {
+ global $wgContLang;
+
+ $parsed = $query;
+ if ( strpos( $query, ':' ) === false ) { // nothing to do
+ return false;
+ }
+ $extractedNamespace = null;
+
+ $allkeyword = wfMessage( 'searchall' )->inContentLanguage()->text() . ":";
+ if ( strncmp( $query, $allkeyword, strlen( $allkeyword ) ) == 0 ) {
+ $extractedNamespace = null;
+ $parsed = substr( $query, strlen( $allkeyword ) );
+ } elseif ( strpos( $query, ':' ) !== false ) {
+ // TODO: should we unify with PrefixSearch::extractNamespace ?
+ $prefix = str_replace( ' ', '_', substr( $query, 0, strpos( $query, ':' ) ) );
+ $index = $wgContLang->getNsIndex( $prefix );
+ if ( $index !== false ) {
+ $extractedNamespace = [ $index ];
+ $parsed = substr( $query, strlen( $prefix ) + 1 );
+ } else {
+ return false;
+ }
+ }
+
+ if ( trim( $parsed ) == '' ) {
+ $parsed = $query; // prefix was the whole query
+ }
+
+ return [ $parsed, $extractedNamespace ];
+ }
+
+ /**
+ * Find snippet highlight settings for all users
+ * @return array Contextlines, contextchars
+ */
+ public static function userHighlightPrefs() {
+ $contextlines = 2; // Hardcode this. Old defaults sucked. :)
+ $contextchars = 75; // same as above.... :P
+ return [ $contextlines, $contextchars ];
+ }
+
+ /**
+ * Create or update the search index record for the given page.
+ * Title and text should be pre-processed.
+ * STUB
+ *
+ * @param int $id
+ * @param string $title
+ * @param string $text
+ */
+ function update( $id, $title, $text ) {
+ // no-op
+ }
+
+ /**
+ * Update a search index record's title only.
+ * Title should be pre-processed.
+ * STUB
+ *
+ * @param int $id
+ * @param string $title
+ */
+ function updateTitle( $id, $title ) {
+ // no-op
+ }
+
+ /**
+ * Delete an indexed page
+ * Title should be pre-processed.
+ * STUB
+ *
+ * @param int $id Page id that was deleted
+ * @param string $title Title of page that was deleted
+ */
+ function delete( $id, $title ) {
+ // no-op
+ }
+
+ /**
+ * Get OpenSearch suggestion template
+ *
+ * @deprecated since 1.25
+ * @return string
+ */
+ public static function getOpenSearchTemplate() {
+ wfDeprecated( __METHOD__, '1.25' );
+ return ApiOpenSearch::getOpenSearchTemplate( 'application/x-suggestions+json' );
+ }
+
+ /**
+ * Get the raw text for updating the index from a content object
+ * Nicer search backends could possibly do something cooler than
+ * just returning raw text
+ *
+ * @todo This isn't ideal, we'd really like to have content-specific handling here
+ * @param Title $t Title we're indexing
+ * @param Content $c Content of the page to index
+ * @return string
+ */
+ public function getTextFromContent( Title $t, Content $c = null ) {
+ return $c ? $c->getTextForSearchIndex() : '';
+ }
+
+ /**
+ * If an implementation of SearchEngine handles all of its own text processing
+ * in getTextFromContent() and doesn't require SearchUpdate::updateText()'s
+ * rather silly handling, it should return true here instead.
+ *
+ * @return bool
+ */
+ public function textAlreadyUpdatedForIndex() {
+ return false;
+ }
+
+ /**
+ * Makes search simple string if it was namespaced.
+ * Sets namespaces of the search to namespaces extracted from string.
+ * @param string $search
+ * @return string Simplified search string
+ */
+ protected function normalizeNamespaces( $search ) {
+ // Find a Title which is not an interwiki and is in NS_MAIN
+ $title = Title::newFromText( $search );
+ $ns = $this->namespaces;
+ if ( $title && !$title->isExternal() ) {
+ $ns = [ $title->getNamespace() ];
+ $search = $title->getText();
+ if ( $ns[0] == NS_MAIN ) {
+ $ns = $this->namespaces; // no explicit prefix, use default namespaces
+ Hooks::run( 'PrefixSearchExtractNamespace', [ &$ns, &$search ] );
+ }
+ } else {
+ $title = Title::newFromText( $search . 'Dummy' );
+ if ( $title && $title->getText() == 'Dummy'
+ && $title->getNamespace() != NS_MAIN
+ && !$title->isExternal()
+ ) {
+ $ns = [ $title->getNamespace() ];
+ $search = '';
+ } else {
+ Hooks::run( 'PrefixSearchExtractNamespace', [ &$ns, &$search ] );
+ }
+ }
+
+ $ns = array_map( function ( $space ) {
+ return $space == NS_MEDIA ? NS_FILE : $space;
+ }, $ns );
+
+ $this->setNamespaces( $ns );
+ return $search;
+ }
+
+ /**
+ * Perform a completion search.
+ * Does not resolve namespaces and does not check variants.
+ * Search engine implementations may want to override this function.
+ * @param string $search
+ * @return SearchSuggestionSet
+ */
+ protected function completionSearchBackend( $search ) {
+ $results = [];
+
+ $search = trim( $search );
+
+ if ( !in_array( NS_SPECIAL, $this->namespaces ) && // We do not run hook on Special: search
+ !Hooks::run( 'PrefixSearchBackend',
+ [ $this->namespaces, $search, $this->limit, &$results, $this->offset ]
+ ) ) {
+ // False means hook worked.
+ // FIXME: Yes, the API is weird. That's why it is going to be deprecated.
+
+ return SearchSuggestionSet::fromStrings( $results );
+ } else {
+ // Hook did not do the job, use default simple search
+ $results = $this->simplePrefixSearch( $search );
+ return SearchSuggestionSet::fromTitles( $results );
+ }
+ }
+
+ /**
+ * Perform a completion search.
+ * @param string $search
+ * @return SearchSuggestionSet
+ */
+ public function completionSearch( $search ) {
+ if ( trim( $search ) === '' ) {
+ return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
+ }
+ $search = $this->normalizeNamespaces( $search );
+ return $this->processCompletionResults( $search, $this->completionSearchBackend( $search ) );
+ }
+
+ /**
+ * Perform a completion search with variants.
+ * @param string $search
+ * @return SearchSuggestionSet
+ */
+ public function completionSearchWithVariants( $search ) {
+ if ( trim( $search ) === '' ) {
+ return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
+ }
+ $search = $this->normalizeNamespaces( $search );
+
+ $results = $this->completionSearchBackend( $search );
+ $fallbackLimit = $this->limit - $results->getSize();
+ if ( $fallbackLimit > 0 ) {
+ global $wgContLang;
+
+ $fallbackSearches = $wgContLang->autoConvertToAllVariants( $search );
+ $fallbackSearches = array_diff( array_unique( $fallbackSearches ), [ $search ] );
+
+ foreach ( $fallbackSearches as $fbs ) {
+ $this->setLimitOffset( $fallbackLimit );
+ $fallbackSearchResult = $this->completionSearch( $fbs );
+ $results->appendAll( $fallbackSearchResult );
+ $fallbackLimit -= count( $fallbackSearchResult );
+ if ( $fallbackLimit <= 0 ) {
+ break;
+ }
+ }
+ }
+ return $this->processCompletionResults( $search, $results );
+ }
+
+ /**
+ * Extract titles from completion results
+ * @param SearchSuggestionSet $completionResults
+ * @return Title[]
+ */
+ public function extractTitles( SearchSuggestionSet $completionResults ) {
+ return $completionResults->map( function ( SearchSuggestion $sugg ) {
+ return $sugg->getSuggestedTitle();
+ } );
+ }
+
+ /**
+ * Process completion search results.
+ * Resolves the titles and rescores.
+ * @param string $search
+ * @param SearchSuggestionSet $suggestions
+ * @return SearchSuggestionSet
+ */
+ protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) {
+ $search = trim( $search );
+ // preload the titles with LinkBatch
+ $titles = $suggestions->map( function ( SearchSuggestion $sugg ) {
+ return $sugg->getSuggestedTitle();
+ } );
+ $lb = new LinkBatch( $titles );
+ $lb->setCaller( __METHOD__ );
+ $lb->execute();
+
+ $results = $suggestions->map( function ( SearchSuggestion $sugg ) {
+ return $sugg->getSuggestedTitle()->getPrefixedText();
+ } );
+
+ if ( $this->offset === 0 ) {
+ // Rescore results with an exact title match
+ // NOTE: in some cases like cross-namespace redirects
+ // (frequently used as shortcuts e.g. WP:WP on huwiki) some
+ // backends like Cirrus will return no results. We should still
+ // try an exact title match to workaround this limitation
+ $rescorer = new SearchExactMatchRescorer();
+ $rescoredResults = $rescorer->rescore( $search, $this->namespaces, $results, $this->limit );
+ } else {
+ // No need to rescore if offset is not 0
+ // The exact match must have been returned at position 0
+ // if it existed.
+ $rescoredResults = $results;
+ }
+
+ if ( count( $rescoredResults ) > 0 ) {
+ $found = array_search( $rescoredResults[0], $results );
+ if ( $found === false ) {
+ // If the first result is not in the previous array it
+ // means that we found a new exact match
+ $exactMatch = SearchSuggestion::fromTitle( 0, Title::newFromText( $rescoredResults[0] ) );
+ $suggestions->prepend( $exactMatch );
+ $suggestions->shrink( $this->limit );
+ } else {
+ // if the first result is not the same we need to rescore
+ if ( $found > 0 ) {
+ $suggestions->rescore( $found );
+ }
+ }
+ }
+
+ return $suggestions;
+ }
+
+ /**
+ * Simple prefix search for subpages.
+ * @param string $search
+ * @return Title[]
+ */
+ public function defaultPrefixSearch( $search ) {
+ if ( trim( $search ) === '' ) {
+ return [];
+ }
+
+ $search = $this->normalizeNamespaces( $search );
+ return $this->simplePrefixSearch( $search );
+ }
+
+ /**
+ * Call out to simple search backend.
+ * Defaults to TitlePrefixSearch.
+ * @param string $search
+ * @return Title[]
+ */
+ protected function simplePrefixSearch( $search ) {
+ // Use default database prefix search
+ $backend = new TitlePrefixSearch;
+ return $backend->defaultSearchBackend( $this->namespaces, $search, $this->limit, $this->offset );
+ }
+
+ /**
+ * Make a list of searchable namespaces and their canonical names.
+ * @deprecated since 1.27; use SearchEngineConfig::searchableNamespaces()
+ * @return array
+ */
+ public static function searchableNamespaces() {
+ return MediaWikiServices::getInstance()->getSearchEngineConfig()->searchableNamespaces();
+ }
+
+ /**
+ * Extract default namespaces to search from the given user's
+ * settings, returning a list of index numbers.
+ * @deprecated since 1.27; use SearchEngineConfig::userNamespaces()
+ * @param user $user
+ * @return array
+ */
+ public static function userNamespaces( $user ) {
+ return MediaWikiServices::getInstance()->getSearchEngineConfig()->userNamespaces( $user );
+ }
+
+ /**
+ * An array of namespaces indexes to be searched by default
+ * @deprecated since 1.27; use SearchEngineConfig::defaultNamespaces()
+ * @return array
+ */
+ public static function defaultNamespaces() {
+ return MediaWikiServices::getInstance()->getSearchEngineConfig()->defaultNamespaces();
+ }
+
+ /**
+ * Get a list of namespace names useful for showing in tooltips
+ * and preferences
+ * @deprecated since 1.27; use SearchEngineConfig::namespacesAsText()
+ * @param array $namespaces
+ * @return array
+ */
+ public static function namespacesAsText( $namespaces ) {
+ return MediaWikiServices::getInstance()->getSearchEngineConfig()->namespacesAsText( $namespaces );
+ }
+
+ /**
+ * Load up the appropriate search engine class for the currently
+ * active database backend, and return a configured instance.
+ * @deprecated since 1.27; Use SearchEngineFactory::create
+ * @param string $type Type of search backend, if not the default
+ * @return SearchEngine
+ */
+ public static function create( $type = null ) {
+ return MediaWikiServices::getInstance()->getSearchEngineFactory()->create( $type );
+ }
+
+ /**
+ * Return the search engines we support. If only $wgSearchType
+ * is set, it'll be an array of just that one item.
+ * @deprecated since 1.27; use SearchEngineConfig::getSearchTypes()
+ * @return array
+ */
+ public static function getSearchTypes() {
+ return MediaWikiServices::getInstance()->getSearchEngineConfig()->getSearchTypes();
+ }
+
+ /**
+ * Get a list of supported profiles.
+ * Some search engine implementations may expose specific profiles to fine-tune
+ * its behaviors.
+ * The profile can be passed as a feature data with setFeatureData( $profileType, $profileName )
+ * The array returned by this function contains the following keys:
+ * - name: the profile name to use with setFeatureData
+ * - desc-message: the i18n description
+ * - default: set to true if this profile is the default
+ *
+ * @since 1.28
+ * @param string $profileType the type of profiles
+ * @param User|null $user the user requesting the list of profiles
+ * @return array|null the list of profiles or null if none available
+ */
+ public function getProfiles( $profileType, User $user = null ) {
+ return null;
+ }
+
+ /**
+ * Create a search field definition.
+ * Specific search engines should override this method to create search fields.
+ * @param string $name
+ * @param int $type One of the types in SearchIndexField::INDEX_TYPE_*
+ * @return SearchIndexField
+ * @since 1.28
+ */
+ public function makeSearchFieldMapping( $name, $type ) {
+ return new NullIndexField();
+ }
+
+ /**
+ * Get fields for search index
+ * @since 1.28
+ * @return SearchIndexField[] Index field definitions for all content handlers
+ */
+ public function getSearchIndexFields() {
+ $models = ContentHandler::getContentModels();
+ $fields = [];
+ $seenHandlers = new SplObjectStorage();
+ foreach ( $models as $model ) {
+ try {
+ $handler = ContentHandler::getForModelID( $model );
+ }
+ catch ( MWUnknownContentModelException $e ) {
+ // If we can find no handler, ignore it
+ continue;
+ }
+ // Several models can have the same handler, so avoid processing it repeatedly
+ if ( $seenHandlers->contains( $handler ) ) {
+ // We already did this one
+ continue;
+ }
+ $seenHandlers->attach( $handler );
+ $handlerFields = $handler->getFieldsForSearchIndex( $this );
+ foreach ( $handlerFields as $fieldName => $fieldData ) {
+ if ( empty( $fields[$fieldName] ) ) {
+ $fields[$fieldName] = $fieldData;
+ } else {
+ // TODO: do we allow some clashes with the same type or reject all of them?
+ $mergeDef = $fields[$fieldName]->merge( $fieldData );
+ if ( !$mergeDef ) {
+ throw new InvalidArgumentException( "Duplicate field $fieldName for model $model" );
+ }
+ $fields[$fieldName] = $mergeDef;
+ }
+ }
+ }
+ // Hook to allow extensions to produce search mapping fields
+ Hooks::run( 'SearchIndexFields', [ &$fields, $this ] );
+ return $fields;
+ }
+
+ /**
+ * Augment search results with extra data.
+ *
+ * @param SearchResultSet $resultSet
+ */
+ public function augmentSearchResults( SearchResultSet $resultSet ) {
+ $setAugmentors = [];
+ $rowAugmentors = [];
+ Hooks::run( "SearchResultsAugment", [ &$setAugmentors, &$rowAugmentors ] );
+
+ if ( !$setAugmentors && !$rowAugmentors ) {
+ // We're done here
+ return;
+ }
+
+ // Convert row augmentors to set augmentor
+ foreach ( $rowAugmentors as $name => $row ) {
+ if ( isset( $setAugmentors[$name] ) ) {
+ throw new InvalidArgumentException( "Both row and set augmentors are defined for $name" );
+ }
+ $setAugmentors[$name] = new PerRowAugmentor( $row );
+ }
+
+ foreach ( $setAugmentors as $name => $augmentor ) {
+ $data = $augmentor->augmentAll( $resultSet );
+ if ( $data ) {
+ $resultSet->setAugmentedData( $name, $data );
+ }
+ }
+ }
+}
+
+/**
+ * Dummy class to be used when non-supported Database engine is present.
+ * @todo FIXME: Dummy class should probably try something at least mildly useful,
+ * such as a LIKE search through titles.
+ * @ingroup Search
+ */
+class SearchEngineDummy extends SearchEngine {
+ // no-op
+}
diff --git a/www/wiki/includes/search/SearchEngineConfig.php b/www/wiki/includes/search/SearchEngineConfig.php
new file mode 100644
index 00000000..90f85c3d
--- /dev/null
+++ b/www/wiki/includes/search/SearchEngineConfig.php
@@ -0,0 +1,117 @@
+<?php
+
+/**
+ * Configuration handling class for SearchEngine.
+ * Provides added service over plain configuration.
+ *
+ * @since 1.27
+ */
+class SearchEngineConfig {
+
+ /**
+ * Config object from which the settings will be derived.
+ * @var Config
+ */
+ private $config;
+
+ /**
+ * Current language
+ * @var Language
+ */
+ private $language;
+
+ public function __construct( Config $config, Language $lang ) {
+ $this->config = $config;
+ $this->language = $lang;
+ }
+
+ /**
+ * Retrieve original config.
+ * @return Config
+ */
+ public function getConfig() {
+ return $this->config;
+ }
+
+ /**
+ * Make a list of searchable namespaces and their canonical names.
+ * @return array Namespace ID => name
+ */
+ public function searchableNamespaces() {
+ $arr = [];
+ foreach ( $this->language->getNamespaces() as $ns => $name ) {
+ if ( $ns >= NS_MAIN ) {
+ $arr[$ns] = $name;
+ }
+ }
+
+ Hooks::run( 'SearchableNamespaces', [ &$arr ] );
+ return $arr;
+ }
+
+ /**
+ * Extract default namespaces to search from the given user's
+ * settings, returning a list of index numbers.
+ *
+ * @param user $user
+ * @return int[]
+ */
+ public function userNamespaces( $user ) {
+ $arr = [];
+ foreach ( $this->searchableNamespaces() as $ns => $name ) {
+ if ( $user->getOption( 'searchNs' . $ns ) ) {
+ $arr[] = $ns;
+ }
+ }
+
+ return $arr;
+ }
+
+ /**
+ * An array of namespaces indexes to be searched by default
+ *
+ * @return int[] Namespace IDs
+ */
+ public function defaultNamespaces() {
+ return array_keys( $this->config->get( 'NamespacesToBeSearchedDefault' ), true );
+ }
+
+ /**
+ * Return the search engines we support. If only $wgSearchType
+ * is set, it'll be an array of just that one item.
+ *
+ * @return array
+ */
+ public function getSearchTypes() {
+ $alternatives = $this->config->get( 'SearchTypeAlternatives' ) ?: [];
+ array_unshift( $alternatives, $this->config->get( 'SearchType' ) );
+
+ return $alternatives;
+ }
+
+ /**
+ * Return the search engine configured in $wgSearchType, etc.
+ *
+ * @return string|null
+ */
+ public function getSearchType() {
+ return $this->config->get( 'SearchType' );
+ }
+
+ /**
+ * Get a list of namespace names useful for showing in tooltips
+ * and preferences.
+ *
+ * @param int[] $namespaces
+ * @return string[] List of names
+ */
+ public function namespacesAsText( $namespaces ) {
+ $formatted = array_map( [ $this->language, 'getFormattedNsText' ], $namespaces );
+ foreach ( $formatted as $key => $ns ) {
+ if ( empty( $ns ) ) {
+ $formatted[$key] = wfMessage( 'blanknamespace' )->text();
+ }
+ }
+ return $formatted;
+ }
+}
diff --git a/www/wiki/includes/search/SearchEngineFactory.php b/www/wiki/includes/search/SearchEngineFactory.php
new file mode 100644
index 00000000..613d33ca
--- /dev/null
+++ b/www/wiki/includes/search/SearchEngineFactory.php
@@ -0,0 +1,65 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Factory class for SearchEngine.
+ * Allows to create engine of the specific type.
+ */
+class SearchEngineFactory {
+ /**
+ * Configuration for SearchEngine classes.
+ * @var SearchEngineConfig
+ */
+ private $config;
+
+ public function __construct( SearchEngineConfig $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * Create SearchEngine of the given type.
+ * @param string $type
+ * @return SearchEngine
+ */
+ public function create( $type = null ) {
+ $dbr = null;
+
+ $configType = $this->config->getSearchType();
+ $alternatives = $this->config->getSearchTypes();
+
+ if ( $type && in_array( $type, $alternatives ) ) {
+ $class = $type;
+ } elseif ( $configType !== null ) {
+ $class = $configType;
+ } else {
+ $dbr = wfGetDB( DB_REPLICA );
+ $class = self::getSearchEngineClass( $dbr );
+ }
+
+ $search = new $class( $dbr );
+ return $search;
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return string SearchEngine subclass name
+ * @since 1.28
+ */
+ public static function getSearchEngineClass( IDatabase $db ) {
+ switch ( $db->getType() ) {
+ case 'sqlite':
+ return 'SearchSqlite';
+ case 'mysql':
+ return 'SearchMySQL';
+ case 'postgres':
+ return 'SearchPostgres';
+ case 'mssql':
+ return 'SearchMssql';
+ case 'oracle':
+ return 'SearchOracle';
+ default:
+ return 'SearchEngineDummy';
+ }
+ }
+}
diff --git a/www/wiki/includes/search/SearchExactMatchRescorer.php b/www/wiki/includes/search/SearchExactMatchRescorer.php
new file mode 100644
index 00000000..0e99ba91
--- /dev/null
+++ b/www/wiki/includes/search/SearchExactMatchRescorer.php
@@ -0,0 +1,144 @@
+<?php
+/**
+ * Rescores results from a prefix search/opensearch to make sure the
+ * exact match is the first result.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * An utility class to rescore search results by looking for an exact match
+ * in the db and add the page found to the first position.
+ *
+ * NOTE: extracted from TitlePrefixSearch
+ * @ingroup Search
+ */
+class SearchExactMatchRescorer {
+ /**
+ * Default search backend does proper prefix searching, but custom backends
+ * may sort based on other algorithms that may cause the exact title match
+ * to not be in the results or be lower down the list.
+ * @param string $search the query
+ * @param int[] $namespaces the namespaces
+ * @param string[] $srchres results
+ * @param int $limit the max number of results to return
+ * @return string[] munged results
+ */
+ public function rescore( $search, $namespaces, $srchres, $limit ) {
+ // Pick namespace (based on PrefixSearch::defaultSearchBackend)
+ $ns = in_array( NS_MAIN, $namespaces ) ? NS_MAIN : reset( $namespaces );
+ $t = Title::newFromText( $search, $ns );
+ if ( !$t || !$t->exists() ) {
+ // No exact match so just return the search results
+ return $srchres;
+ }
+ $string = $t->getPrefixedText();
+ $key = array_search( $string, $srchres );
+ if ( $key !== false ) {
+ // Exact match was in the results so just move it to the front
+ return $this->pullFront( $key, $srchres );
+ }
+ // Exact match not in the search results so check for some redirect handling cases
+ if ( $t->isRedirect() ) {
+ $target = $this->getRedirectTarget( $t );
+ $key = array_search( $target, $srchres );
+ if ( $key !== false ) {
+ // Exact match is a redirect to one of the returned matches so pull the
+ // returned match to the front. This might look odd but the alternative
+ // is to put the redirect in front and drop the match. The name of the
+ // found match is often more descriptive/better formed than the name of
+ // the redirect AND by definition they share a prefix. Hopefully this
+ // choice is less confusing and more helpful. But it might not be. But
+ // it is the choice we're going with for now.
+ return $this->pullFront( $key, $srchres );
+ }
+ $redirectTargetsToRedirect = $this->redirectTargetsToRedirect( $srchres );
+ if ( isset( $redirectTargetsToRedirect[$target] ) ) {
+ // The exact match and something in the results list are both redirects
+ // to the same thing! In this case we'll pull the returned match to the
+ // top following the same logic above. Again, it might not be a perfect
+ // choice but it'll do.
+ return $this->pullFront( $redirectTargetsToRedirect[$target], $srchres );
+ }
+ } else {
+ $redirectTargetsToRedirect = $this->redirectTargetsToRedirect( $srchres );
+ if ( isset( $redirectTargetsToRedirect[$string] ) ) {
+ // The exact match is the target of a redirect already in the results list so remove
+ // the redirect from the results list and push the exact match to the front
+ array_splice( $srchres, $redirectTargetsToRedirect[$string], 1 );
+ array_unshift( $srchres, $string );
+ return $srchres;
+ }
+ }
+
+ // Exact match is totally unique from the other results so just add it to the front
+ array_unshift( $srchres, $string );
+ // And roll one off the end if the results are too long
+ if ( count( $srchres ) > $limit ) {
+ array_pop( $srchres );
+ }
+ return $srchres;
+ }
+
+ /**
+ * @param string[] $titles as strings
+ * @return array redirect target prefixedText to index of title in titles
+ * that is a redirect to it.
+ */
+ private function redirectTargetsToRedirect( $titles ) {
+ $result = [];
+ foreach ( $titles as $key => $titleText ) {
+ $title = Title::newFromText( $titleText );
+ if ( !$title || !$title->isRedirect() ) {
+ continue;
+ }
+ $target = $this->getRedirectTarget( $title );
+ if ( !$target ) {
+ continue;
+ }
+ $result[$target] = $key;
+ }
+ return $result;
+ }
+
+ /**
+ * Returns an array where the element of $array at index $key becomes
+ * the first element.
+ * @param int $key key to pull to the front
+ * @return array $array with the item at $key pulled to the front
+ */
+ private function pullFront( $key, $array ) {
+ $cut = array_splice( $array, $key, 1 );
+ array_unshift( $array, $cut[0] );
+ return $array;
+ }
+
+ /**
+ * Get a redirect's destination from a title
+ * @param Title $title A title to redirect. It may not redirect or even exist
+ * @return null|string If title exists and redirects, get the destination's prefixed name
+ */
+ private function getRedirectTarget( $title ) {
+ $page = WikiPage::factory( $title );
+ if ( !$page->exists() ) {
+ return null;
+ }
+ $redir = $page->getRedirectTarget();
+ return $redir ? $redir->getPrefixedText() : null;
+ }
+}
diff --git a/www/wiki/includes/search/SearchHighlighter.php b/www/wiki/includes/search/SearchHighlighter.php
new file mode 100644
index 00000000..20462cf1
--- /dev/null
+++ b/www/wiki/includes/search/SearchHighlighter.php
@@ -0,0 +1,566 @@
+<?php
+/**
+ * Basic search engine highlighting
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+/**
+ * Highlight bits of wikitext
+ *
+ * @ingroup Search
+ */
+class SearchHighlighter {
+ protected $mCleanWikitext = true;
+
+ /**
+ * @warning If you pass false to this constructor, then
+ * the caller is responsible for HTML escaping.
+ * @param bool $cleanupWikitext
+ */
+ function __construct( $cleanupWikitext = true ) {
+ $this->mCleanWikitext = $cleanupWikitext;
+ }
+
+ /**
+ * Wikitext highlighting when $wgAdvancedSearchHighlighting = true
+ *
+ * @param string $text
+ * @param array $terms Terms to highlight (not html escaped but
+ * regex escaped via SearchDatabase::regexTerm())
+ * @param int $contextlines
+ * @param int $contextchars
+ * @return string
+ */
+ public function highlightText( $text, $terms, $contextlines, $contextchars ) {
+ global $wgContLang, $wgSearchHighlightBoundaries;
+
+ if ( $text == '' ) {
+ return '';
+ }
+
+ // spli text into text + templates/links/tables
+ $spat = "/(\\{\\{)|(\\[\\[[^\\]:]+:)|(\n\\{\\|)";
+ // first capture group is for detecting nested templates/links/tables/references
+ $endPatterns = [
+ 1 => '/(\{\{)|(\}\})/', // template
+ 2 => '/(\[\[)|(\]\])/', // image
+ 3 => "/(\n\\{\\|)|(\n\\|\\})/" ]; // table
+
+ // @todo FIXME: This should prolly be a hook or something
+ // instead of hardcoding a class name from the Cite extension
+ if ( class_exists( 'Cite' ) ) {
+ $spat .= '|(<ref>)'; // references via cite extension
+ $endPatterns[4] = '/(<ref>)|(<\/ref>)/';
+ }
+ $spat .= '/';
+ $textExt = []; // text extracts
+ $otherExt = []; // other extracts
+ $start = 0;
+ $textLen = strlen( $text );
+ $count = 0; // sequence number to maintain ordering
+ while ( $start < $textLen ) {
+ // find start of template/image/table
+ if ( preg_match( $spat, $text, $matches, PREG_OFFSET_CAPTURE, $start ) ) {
+ $epat = '';
+ foreach ( $matches as $key => $val ) {
+ if ( $key > 0 && $val[1] != -1 ) {
+ if ( $key == 2 ) {
+ // see if this is an image link
+ $ns = substr( $val[0], 2, -1 );
+ if ( $wgContLang->getNsIndex( $ns ) != NS_FILE ) {
+ break;
+ }
+
+ }
+ $epat = $endPatterns[$key];
+ $this->splitAndAdd( $textExt, $count, substr( $text, $start, $val[1] - $start ) );
+ $start = $val[1];
+ break;
+ }
+ }
+ if ( $epat ) {
+ // find end (and detect any nested elements)
+ $level = 0;
+ $offset = $start + 1;
+ $found = false;
+ while ( preg_match( $epat, $text, $endMatches, PREG_OFFSET_CAPTURE, $offset ) ) {
+ if ( array_key_exists( 2, $endMatches ) ) {
+ // found end
+ if ( $level == 0 ) {
+ $len = strlen( $endMatches[2][0] );
+ $off = $endMatches[2][1];
+ $this->splitAndAdd( $otherExt, $count,
+ substr( $text, $start, $off + $len - $start ) );
+ $start = $off + $len;
+ $found = true;
+ break;
+ } else {
+ // end of nested element
+ $level -= 1;
+ }
+ } else {
+ // nested
+ $level += 1;
+ }
+ $offset = $endMatches[0][1] + strlen( $endMatches[0][0] );
+ }
+ if ( !$found ) {
+ // couldn't find appropriate closing tag, skip
+ $this->splitAndAdd( $textExt, $count, substr( $text, $start, strlen( $matches[0][0] ) ) );
+ $start += strlen( $matches[0][0] );
+ }
+ continue;
+ }
+ }
+ // else: add as text extract
+ $this->splitAndAdd( $textExt, $count, substr( $text, $start ) );
+ break;
+ }
+
+ $all = $textExt + $otherExt; // these have disjunct key sets
+
+ // prepare regexps
+ foreach ( $terms as $index => $term ) {
+ // manually do upper/lowercase stuff for utf-8 since PHP won't do it
+ if ( preg_match( '/[\x80-\xff]/', $term ) ) {
+ $terms[$index] = preg_replace_callback(
+ '/./us',
+ [ $this, 'caseCallback' ],
+ $terms[$index]
+ );
+ } else {
+ $terms[$index] = $term;
+ }
+ }
+ $anyterm = implode( '|', $terms );
+ $phrase = implode( "$wgSearchHighlightBoundaries+", $terms );
+ // @todo FIXME: A hack to scale contextchars, a correct solution
+ // would be to have contextchars actually be char and not byte
+ // length, and do proper utf-8 substrings and lengths everywhere,
+ // but PHP is making that very hard and unclean to implement :(
+ $scale = strlen( $anyterm ) / mb_strlen( $anyterm );
+ $contextchars = intval( $contextchars * $scale );
+
+ $patPre = "(^|$wgSearchHighlightBoundaries)";
+ $patPost = "($wgSearchHighlightBoundaries|$)";
+
+ $pat1 = "/(" . $phrase . ")/ui";
+ $pat2 = "/$patPre(" . $anyterm . ")$patPost/ui";
+
+ $left = $contextlines;
+
+ $snippets = [];
+ $offsets = [];
+
+ // show beginning only if it contains all words
+ $first = 0;
+ $firstText = '';
+ foreach ( $textExt as $index => $line ) {
+ if ( strlen( $line ) > 0 && $line[0] != ';' && $line[0] != ':' ) {
+ $firstText = $this->extract( $line, 0, $contextchars * $contextlines );
+ $first = $index;
+ break;
+ }
+ }
+ if ( $firstText ) {
+ $succ = true;
+ // check if first text contains all terms
+ foreach ( $terms as $term ) {
+ if ( !preg_match( "/$patPre" . $term . "$patPost/ui", $firstText ) ) {
+ $succ = false;
+ break;
+ }
+ }
+ if ( $succ ) {
+ $snippets[$first] = $firstText;
+ $offsets[$first] = 0;
+ }
+ }
+ if ( !$snippets ) {
+ // match whole query on text
+ $this->process( $pat1, $textExt, $left, $contextchars, $snippets, $offsets );
+ // match whole query on templates/tables/images
+ $this->process( $pat1, $otherExt, $left, $contextchars, $snippets, $offsets );
+ // match any words on text
+ $this->process( $pat2, $textExt, $left, $contextchars, $snippets, $offsets );
+ // match any words on templates/tables/images
+ $this->process( $pat2, $otherExt, $left, $contextchars, $snippets, $offsets );
+
+ ksort( $snippets );
+ }
+
+ // add extra chars to each snippet to make snippets constant size
+ $extended = [];
+ if ( count( $snippets ) == 0 ) {
+ // couldn't find the target words, just show beginning of article
+ if ( array_key_exists( $first, $all ) ) {
+ $targetchars = $contextchars * $contextlines;
+ $snippets[$first] = '';
+ $offsets[$first] = 0;
+ }
+ } else {
+ // if begin of the article contains the whole phrase, show only that !!
+ if ( array_key_exists( $first, $snippets ) && preg_match( $pat1, $snippets[$first] )
+ && $offsets[$first] < $contextchars * 2 ) {
+ $snippets = [ $first => $snippets[$first] ];
+ }
+
+ // calc by how much to extend existing snippets
+ $targetchars = intval( ( $contextchars * $contextlines ) / count( $snippets ) );
+ }
+
+ foreach ( $snippets as $index => $line ) {
+ $extended[$index] = $line;
+ $len = strlen( $line );
+ if ( $len < $targetchars - 20 ) {
+ // complete this line
+ if ( $len < strlen( $all[$index] ) ) {
+ $extended[$index] = $this->extract(
+ $all[$index],
+ $offsets[$index],
+ $offsets[$index] + $targetchars,
+ $offsets[$index]
+ );
+ $len = strlen( $extended[$index] );
+ }
+
+ // add more lines
+ $add = $index + 1;
+ while ( $len < $targetchars - 20
+ && array_key_exists( $add, $all )
+ && !array_key_exists( $add, $snippets ) ) {
+ $offsets[$add] = 0;
+ $tt = "\n" . $this->extract( $all[$add], 0, $targetchars - $len, $offsets[$add] );
+ $extended[$add] = $tt;
+ $len += strlen( $tt );
+ $add++;
+ }
+ }
+ }
+
+ // $snippets = array_map( 'htmlspecialchars', $extended );
+ $snippets = $extended;
+ $last = -1;
+ $extract = '';
+ foreach ( $snippets as $index => $line ) {
+ if ( $last == -1 ) {
+ $extract .= $line; // first line
+ } elseif ( $last + 1 == $index
+ && $offsets[$last] + strlen( $snippets[$last] ) >= strlen( $all[$last] )
+ ) {
+ $extract .= " " . $line; // continous lines
+ } else {
+ $extract .= '<b> ... </b>' . $line;
+ }
+
+ $last = $index;
+ }
+ if ( $extract ) {
+ $extract .= '<b> ... </b>';
+ }
+
+ $processed = [];
+ foreach ( $terms as $term ) {
+ if ( !isset( $processed[$term] ) ) {
+ $pat3 = "/$patPre(" . $term . ")$patPost/ui"; // highlight word
+ $extract = preg_replace( $pat3,
+ "\\1<span class='searchmatch'>\\2</span>\\3", $extract );
+ $processed[$term] = true;
+ }
+ }
+
+ return $extract;
+ }
+
+ /**
+ * Split text into lines and add it to extracts array
+ *
+ * @param array &$extracts Index -> $line
+ * @param int &$count
+ * @param string $text
+ */
+ function splitAndAdd( &$extracts, &$count, $text ) {
+ $split = explode( "\n", $this->mCleanWikitext ? $this->removeWiki( $text ) : $text );
+ foreach ( $split as $line ) {
+ $tt = trim( $line );
+ if ( $tt ) {
+ $extracts[$count++] = $tt;
+ }
+ }
+ }
+
+ /**
+ * Do manual case conversion for non-ascii chars
+ *
+ * @param array $matches
+ * @return string
+ */
+ function caseCallback( $matches ) {
+ global $wgContLang;
+ if ( strlen( $matches[0] ) > 1 ) {
+ return '[' . $wgContLang->lc( $matches[0] ) . $wgContLang->uc( $matches[0] ) . ']';
+ } else {
+ return $matches[0];
+ }
+ }
+
+ /**
+ * Extract part of the text from start to end, but by
+ * not chopping up words
+ * @param string $text
+ * @param int $start
+ * @param int $end
+ * @param int &$posStart (out) actual start position
+ * @param int &$posEnd (out) actual end position
+ * @return string
+ */
+ function extract( $text, $start, $end, &$posStart = null, &$posEnd = null ) {
+ if ( $start != 0 ) {
+ $start = $this->position( $text, $start, 1 );
+ }
+ if ( $end >= strlen( $text ) ) {
+ $end = strlen( $text );
+ } else {
+ $end = $this->position( $text, $end );
+ }
+
+ if ( !is_null( $posStart ) ) {
+ $posStart = $start;
+ }
+ if ( !is_null( $posEnd ) ) {
+ $posEnd = $end;
+ }
+
+ if ( $end > $start ) {
+ return substr( $text, $start, $end - $start );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Find a nonletter near a point (index) in the text
+ *
+ * @param string $text
+ * @param int $point
+ * @param int $offset Offset to found index
+ * @return int Nearest nonletter index, or beginning of utf8 char if none
+ */
+ function position( $text, $point, $offset = 0 ) {
+ $tolerance = 10;
+ $s = max( 0, $point - $tolerance );
+ $l = min( strlen( $text ), $point + $tolerance ) - $s;
+ $m = [];
+
+ if ( preg_match(
+ '/[ ,.!?~!@#$%^&*\(\)+=\-\\\|\[\]"\'<>]/',
+ substr( $text, $s, $l ),
+ $m,
+ PREG_OFFSET_CAPTURE
+ ) ) {
+ return $m[0][1] + $s + $offset;
+ } else {
+ // check if point is on a valid first UTF8 char
+ $char = ord( $text[$point] );
+ while ( $char >= 0x80 && $char < 0xc0 ) {
+ // skip trailing bytes
+ $point++;
+ if ( $point >= strlen( $text ) ) {
+ return strlen( $text );
+ }
+ $char = ord( $text[$point] );
+ }
+
+ return $point;
+
+ }
+ }
+
+ /**
+ * Search extracts for a pattern, and return snippets
+ *
+ * @param string $pattern Regexp for matching lines
+ * @param array $extracts Extracts to search
+ * @param int &$linesleft Number of extracts to make
+ * @param int &$contextchars Length of snippet
+ * @param array &$out Map for highlighted snippets
+ * @param array &$offsets Map of starting points of snippets
+ * @protected
+ */
+ function process( $pattern, $extracts, &$linesleft, &$contextchars, &$out, &$offsets ) {
+ if ( $linesleft == 0 ) {
+ return; // nothing to do
+ }
+ foreach ( $extracts as $index => $line ) {
+ if ( array_key_exists( $index, $out ) ) {
+ continue; // this line already highlighted
+ }
+
+ $m = [];
+ if ( !preg_match( $pattern, $line, $m, PREG_OFFSET_CAPTURE ) ) {
+ continue;
+ }
+
+ $offset = $m[0][1];
+ $len = strlen( $m[0][0] );
+ if ( $offset + $len < $contextchars ) {
+ $begin = 0;
+ } elseif ( $len > $contextchars ) {
+ $begin = $offset;
+ } else {
+ $begin = $offset + intval( ( $len - $contextchars ) / 2 );
+ }
+
+ $end = $begin + $contextchars;
+
+ $posBegin = $begin;
+ // basic snippet from this line
+ $out[$index] = $this->extract( $line, $begin, $end, $posBegin );
+ $offsets[$index] = $posBegin;
+ $linesleft--;
+ if ( $linesleft == 0 ) {
+ return;
+ }
+ }
+ }
+
+ /**
+ * Basic wikitext removal
+ * @protected
+ * @param string $text
+ * @return mixed
+ */
+ function removeWiki( $text ) {
+ $text = preg_replace( "/\\{\\{([^|]+?)\\}\\}/", "", $text );
+ $text = preg_replace( "/\\{\\{([^|]+\\|)(.*?)\\}\\}/", "\\2", $text );
+ $text = preg_replace( "/\\[\\[([^|]+?)\\]\\]/", "\\1", $text );
+ $text = preg_replace_callback(
+ "/\\[\\[([^|]+\\|)(.*?)\\]\\]/",
+ [ $this, 'linkReplace' ],
+ $text
+ );
+ $text = preg_replace( "/<\/?[^>]+>/", "", $text );
+ $text = preg_replace( "/'''''/", "", $text );
+ $text = preg_replace( "/('''|<\/?[iIuUbB]>)/", "", $text );
+ $text = preg_replace( "/''/", "", $text );
+
+ // Note, the previous /<\/?[^>]+>/ is insufficient
+ // for XSS safety as the HTML tag can span multiple
+ // search results (T144845).
+ $text = Sanitizer::escapeHtmlAllowEntities( $text );
+ return $text;
+ }
+
+ /**
+ * callback to replace [[target|caption]] kind of links, if
+ * the target is category or image, leave it
+ *
+ * @param array $matches
+ * @return string
+ */
+ function linkReplace( $matches ) {
+ $colon = strpos( $matches[1], ':' );
+ if ( $colon === false ) {
+ return $matches[2]; // replace with caption
+ }
+ global $wgContLang;
+ $ns = substr( $matches[1], 0, $colon );
+ $index = $wgContLang->getNsIndex( $ns );
+ if ( $index !== false && ( $index == NS_FILE || $index == NS_CATEGORY ) ) {
+ return $matches[0]; // return the whole thing
+ } else {
+ return $matches[2];
+ }
+ }
+
+ /**
+ * Simple & fast snippet extraction, but gives completely unrelevant
+ * snippets
+ *
+ * Used when $wgAdvancedSearchHighlighting is false.
+ *
+ * @param string $text
+ * @param array $terms Escaped for regex by SearchDatabase::regexTerm()
+ * @param int $contextlines
+ * @param int $contextchars
+ * @return string
+ */
+ public function highlightSimple( $text, $terms, $contextlines, $contextchars ) {
+ global $wgContLang;
+
+ $lines = explode( "\n", $text );
+
+ $terms = implode( '|', $terms );
+ $max = intval( $contextchars ) + 1;
+ $pat1 = "/(.*)($terms)(.{0,$max})/i";
+
+ $lineno = 0;
+
+ $extract = "";
+ foreach ( $lines as $line ) {
+ if ( 0 == $contextlines ) {
+ break;
+ }
+ ++$lineno;
+ $m = [];
+ if ( !preg_match( $pat1, $line, $m ) ) {
+ continue;
+ }
+ --$contextlines;
+ // truncate function changes ... to relevant i18n message.
+ $pre = $wgContLang->truncate( $m[1], - $contextchars, '...', false );
+
+ if ( count( $m ) < 3 ) {
+ $post = '';
+ } else {
+ $post = $wgContLang->truncate( $m[3], $contextchars, '...', false );
+ }
+
+ $found = $m[2];
+
+ $line = htmlspecialchars( $pre . $found . $post );
+ $pat2 = '/(' . $terms . ")/i";
+ $line = preg_replace( $pat2, "<span class='searchmatch'>\\1</span>", $line );
+
+ $extract .= "${line}\n";
+ }
+
+ return $extract;
+ }
+
+ /**
+ * Returns the first few lines of the text
+ *
+ * @param string $text
+ * @param int $contextlines Max number of returned lines
+ * @param int $contextchars Average number of characters per line
+ * @return string
+ */
+ public function highlightNone( $text, $contextlines, $contextchars ) {
+ $match = [];
+ $text = ltrim( $text ) . "\n"; // make sure the preg_match may find the last line
+ $text = str_replace( "\n\n", "\n", $text ); // remove empty lines
+ preg_match( "/^(.*\n){0,$contextlines}/", $text, $match );
+
+ // Trim and limit to max number of chars
+ $text = htmlspecialchars( substr( trim( $match[0] ), 0, $contextlines * $contextchars ) );
+ return str_replace( "\n", '<br>', $text );
+ }
+}
diff --git a/www/wiki/includes/search/SearchIndexField.php b/www/wiki/includes/search/SearchIndexField.php
new file mode 100644
index 00000000..6f3b2078
--- /dev/null
+++ b/www/wiki/includes/search/SearchIndexField.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Definition of a mapping for the search index field.
+ * @since 1.28
+ */
+interface SearchIndexField {
+ /**
+ * Field types
+ */
+ const INDEX_TYPE_TEXT = 0;
+ const INDEX_TYPE_KEYWORD = 1;
+ const INDEX_TYPE_INTEGER = 2;
+ const INDEX_TYPE_NUMBER = 3;
+ const INDEX_TYPE_DATETIME = 4;
+ const INDEX_TYPE_NESTED = 5;
+ const INDEX_TYPE_BOOL = 6;
+
+ /**
+ * SHORT_TEXT is meant to be used with short text made of mostly ascii
+ * technical information. Generally a language agnostic analysis chain
+ * is used and aggressive splitting to increase recall.
+ * E.g suited for mime/type
+ */
+ const INDEX_TYPE_SHORT_TEXT = 7;
+
+ /**
+ * Generic field flags.
+ */
+ /**
+ * This field is case-insensitive.
+ */
+ const FLAG_CASEFOLD = 1;
+
+ /**
+ * This field contains secondary information, which is
+ * already present in other fields, but can be used for
+ * scoring.
+ */
+ const FLAG_SCORING = 2;
+
+ /**
+ * This field does not need highlight handling.
+ */
+ const FLAG_NO_HIGHLIGHT = 4;
+
+ /**
+ * Do not index this field, just store it.
+ */
+ const FLAG_NO_INDEX = 8;
+
+ /**
+ * Get mapping for specific search engine
+ * @param SearchEngine $engine
+ * @return array|null Null means this field does not map to anything
+ */
+ public function getMapping( SearchEngine $engine );
+
+ /**
+ * Set global flag for this field.
+ *
+ * @param int $flag Bit flag to set/unset
+ * @param bool $unset True if flag should be unset, false by default
+ * @return $this
+ */
+ public function setFlag( $flag, $unset = false );
+
+ /**
+ * Check if flag is set.
+ * @param int $flag
+ * @return int 0 if unset, !=0 if set
+ */
+ public function checkFlag( $flag );
+
+ /**
+ * Merge two field definitions if possible.
+ *
+ * @param SearchIndexField $that
+ * @return SearchIndexField|false New definition or false if not mergeable.
+ */
+ public function merge( SearchIndexField $that );
+
+ /**
+ * A list of search engine hints for this field.
+ * Hints are usually specific to a search engine implementation
+ * and allow to fine control how the search engine will handle this
+ * particular field.
+ *
+ * For example some search engine permits some optimizations
+ * at index time by ignoring an update if the updated value
+ * does not change by more than X% on a numeric value.
+ *
+ * @param SearchEngine $engine
+ * @return array an array of hints generally indexed by hint name. The type of
+ * values is search engine specific
+ * @since 1.30
+ */
+ public function getEngineHints( SearchEngine $engine );
+}
diff --git a/www/wiki/includes/search/SearchIndexFieldDefinition.php b/www/wiki/includes/search/SearchIndexFieldDefinition.php
new file mode 100644
index 00000000..a11dff9f
--- /dev/null
+++ b/www/wiki/includes/search/SearchIndexFieldDefinition.php
@@ -0,0 +1,153 @@
+<?php
+
+/**
+ * Basic infrastructure of the field definition.
+ *
+ * Specific engines should extend this class and at at least,
+ * override the getMapping method, but can reuse other parts.
+ *
+ * @since 1.28
+ */
+abstract class SearchIndexFieldDefinition implements SearchIndexField {
+
+ /**
+ * Name of the field
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * Type of the field, one of the constants above
+ *
+ * @var int
+ */
+ protected $type;
+
+ /**
+ * Bit flags for the field.
+ *
+ * @var int
+ */
+ protected $flags = 0;
+
+ /**
+ * Subfields
+ * @var SearchIndexFieldDefinition[]
+ */
+ protected $subfields = [];
+
+ /**
+ * @var callable
+ */
+ private $mergeCallback;
+
+ /**
+ * @param string $name Field name
+ * @param int $type Index type
+ */
+ public function __construct( $name, $type ) {
+ $this->name = $name;
+ $this->type = $type;
+ }
+
+ /**
+ * Get field name
+ * @return string
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Get index type
+ * @return int
+ */
+ public function getIndexType() {
+ return $this->type;
+ }
+
+ /**
+ * Set global flag for this field.
+ *
+ * @param int $flag Bit flag to set/unset
+ * @param bool $unset True if flag should be unset, false by default
+ * @return $this
+ */
+ public function setFlag( $flag, $unset = false ) {
+ if ( $unset ) {
+ $this->flags &= ~$flag;
+ } else {
+ $this->flags |= $flag;
+ }
+ return $this;
+ }
+
+ /**
+ * Check if flag is set.
+ * @param int $flag
+ * @return int 0 if unset, !=0 if set
+ */
+ public function checkFlag( $flag ) {
+ return $this->flags & $flag;
+ }
+
+ /**
+ * Merge two field definitions if possible.
+ *
+ * @param SearchIndexField $that
+ * @return SearchIndexField|false New definition or false if not mergeable.
+ */
+ public function merge( SearchIndexField $that ) {
+ if ( !empty( $this->mergeCallback ) ) {
+ return call_user_func( $this->mergeCallback, $this, $that );
+ }
+ // TODO: which definitions may be compatible?
+ if ( ( $that instanceof self ) && $this->type === $that->type &&
+ $this->flags === $that->flags && $this->type !== self::INDEX_TYPE_NESTED
+ ) {
+ return $that;
+ }
+ return false;
+ }
+
+ /**
+ * Get subfields
+ * @return SearchIndexFieldDefinition[]
+ */
+ public function getSubfields() {
+ return $this->subfields;
+ }
+
+ /**
+ * Set subfields
+ * @param SearchIndexFieldDefinition[] $subfields
+ * @return $this
+ */
+ public function setSubfields( array $subfields ) {
+ $this->subfields = $subfields;
+ return $this;
+ }
+
+ /**
+ * @param SearchEngine $engine
+ *
+ * @return array
+ */
+ abstract public function getMapping( SearchEngine $engine );
+
+ /**
+ * Set field-specific merge strategy.
+ * @param callable $callback
+ */
+ public function setMergeCallback( $callback ) {
+ $this->mergeCallback = $callback;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getEngineHints( SearchEngine $engine ) {
+ return [];
+ }
+}
diff --git a/www/wiki/includes/search/SearchMssql.php b/www/wiki/includes/search/SearchMssql.php
new file mode 100644
index 00000000..57ca06e3
--- /dev/null
+++ b/www/wiki/includes/search/SearchMssql.php
@@ -0,0 +1,210 @@
+<?php
+/**
+ * Mssql search engine
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+/**
+ * Search engine hook base class for Mssql (ConText).
+ * @ingroup Search
+ */
+class SearchMssql extends SearchDatabase {
+ /**
+ * Perform a full text search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ * @access public
+ */
+ function searchText( $term ) {
+ $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), true ) );
+ return new SqlSearchResultSet( $resultSet, $this->searchTerms );
+ }
+
+ /**
+ * Perform a title-only search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ * @access public
+ */
+ function searchTitle( $term ) {
+ $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), false ) );
+ return new SqlSearchResultSet( $resultSet, $this->searchTerms );
+ }
+
+ /**
+ * Return a partial WHERE clause to limit the search to the given namespaces
+ *
+ * @return string
+ * @private
+ */
+ function queryNamespaces() {
+ $namespaces = implode( ',', $this->namespaces );
+ if ( $namespaces == '' ) {
+ $namespaces = '0';
+ }
+ return 'AND page_namespace IN (' . $namespaces . ')';
+ }
+
+ /**
+ * Return a LIMIT clause to limit results on the query.
+ *
+ * @param string $sql
+ *
+ * @return string
+ */
+ function queryLimit( $sql ) {
+ return $this->db->limitResult( $sql, $this->limit, $this->offset );
+ }
+
+ /**
+ * Does not do anything for generic search engine
+ * subclasses may define this though
+ *
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return string
+ */
+ function queryRanking( $filteredTerm, $fulltext ) {
+ return ' ORDER BY ftindex.[RANK] DESC'; // return ' ORDER BY score(1)';
+ }
+
+ /**
+ * Construct the full SQL query to do the search.
+ * The guts shoulds be constructed in queryMain()
+ *
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return string
+ */
+ function getQuery( $filteredTerm, $fulltext ) {
+ return $this->queryLimit( $this->queryMain( $filteredTerm, $fulltext ) . ' ' .
+ $this->queryNamespaces() . ' ' .
+ $this->queryRanking( $filteredTerm, $fulltext ) . ' ' );
+ }
+
+ /**
+ * Picks which field to index on, depending on what type of query.
+ *
+ * @param bool $fulltext
+ * @return string
+ */
+ function getIndexField( $fulltext ) {
+ return $fulltext ? 'si_text' : 'si_title';
+ }
+
+ /**
+ * Get the base part of the search query.
+ *
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return string
+ * @private
+ */
+ function queryMain( $filteredTerm, $fulltext ) {
+ $match = $this->parseQuery( $filteredTerm, $fulltext );
+ $page = $this->db->tableName( 'page' );
+ $searchindex = $this->db->tableName( 'searchindex' );
+
+ return 'SELECT page_id, page_namespace, page_title, ftindex.[RANK]' .
+ "FROM $page,FREETEXTTABLE($searchindex , $match, LANGUAGE 'English') as ftindex " .
+ 'WHERE page_id=ftindex.[KEY] ';
+ }
+
+ /** @todo document
+ * @param string $filteredText
+ * @param bool $fulltext
+ * @return string
+ */
+ function parseQuery( $filteredText, $fulltext ) {
+ global $wgContLang;
+ $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX );
+ $this->searchTerms = [];
+
+ # @todo FIXME: This doesn't handle parenthetical expressions.
+ $m = [];
+ $q = [];
+
+ if ( preg_match_all( '/([-+<>~]?)(([' . $lc . ']+)(\*?)|"[^"]*")/',
+ $filteredText, $m, PREG_SET_ORDER ) ) {
+ foreach ( $m as $terms ) {
+ $q[] = $terms[1] . $wgContLang->normalizeForSearch( $terms[2] );
+
+ if ( !empty( $terms[3] ) ) {
+ $regexp = preg_quote( $terms[3], '/' );
+ if ( $terms[4] ) {
+ $regexp .= "[0-9A-Za-z_]+";
+ }
+ } else {
+ $regexp = preg_quote( str_replace( '"', '', $terms[2] ), '/' );
+ }
+ $this->searchTerms[] = $regexp;
+ }
+ }
+
+ $searchon = $this->db->addQuotes( implode( ',', $q ) );
+ $field = $this->getIndexField( $fulltext );
+ return "$field, $searchon";
+ }
+
+ /**
+ * Create or update the search index record for the given page.
+ * Title and text should be pre-processed.
+ *
+ * @param int $id
+ * @param string $title
+ * @param string $text
+ * @return bool|ResultWrapper
+ */
+ function update( $id, $title, $text ) {
+ // We store the column data as UTF-8 byte order marked binary stream
+ // because we are invoking the plain text IFilter on it so that, and we want it
+ // to properly decode the stream as UTF-8. SQL doesn't support UTF8 as a data type
+ // but the indexer will correctly handle it by this method. Since all we are doing
+ // is passing this data to the indexer and never retrieving it via PHP, this will save space
+ $table = $this->db->tableName( 'searchindex' );
+ $utf8bom = '0xEFBBBF';
+ $si_title = $utf8bom . bin2hex( $title );
+ $si_text = $utf8bom . bin2hex( $text );
+ $sql = "DELETE FROM $table WHERE si_page = $id;";
+ $sql .= "INSERT INTO $table (si_page, si_title, si_text) VALUES ($id, $si_title, $si_text)";
+ return $this->db->query( $sql, 'SearchMssql::update' );
+ }
+
+ /**
+ * Update a search index record's title only.
+ * Title should be pre-processed.
+ *
+ * @param int $id
+ * @param string $title
+ * @return bool|ResultWrapper
+ */
+ function updateTitle( $id, $title ) {
+ $table = $this->db->tableName( 'searchindex' );
+
+ // see update for why we are using the utf8bom
+ $utf8bom = '0xEFBBBF';
+ $si_title = $utf8bom . bin2hex( $title );
+ $sql = "DELETE FROM $table WHERE si_page = $id;";
+ $sql .= "INSERT INTO $table (si_page, si_title, si_text) VALUES ($id, $si_title, 0x00)";
+ return $this->db->query( $sql, 'SearchMssql::updateTitle' );
+ }
+}
diff --git a/www/wiki/includes/search/SearchMySQL.php b/www/wiki/includes/search/SearchMySQL.php
new file mode 100644
index 00000000..77dcfe9c
--- /dev/null
+++ b/www/wiki/includes/search/SearchMySQL.php
@@ -0,0 +1,458 @@
+<?php
+/**
+ * MySQL search engine
+ *
+ * Copyright (C) 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+/**
+ * Search engine hook for MySQL 4+
+ * @ingroup Search
+ */
+class SearchMySQL extends SearchDatabase {
+ protected $strictMatching = true;
+
+ private static $mMinSearchLength;
+
+ /**
+ * Parse the user's query and transform it into an SQL fragment which will
+ * become part of a WHERE clause
+ *
+ * @param string $filteredText
+ * @param string $fulltext
+ *
+ * @return string
+ */
+ function parseQuery( $filteredText, $fulltext ) {
+ global $wgContLang;
+
+ $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX ); // Minus syntax chars (" and *)
+ $searchon = '';
+ $this->searchTerms = [];
+
+ # @todo FIXME: This doesn't handle parenthetical expressions.
+ $m = [];
+ if ( preg_match_all( '/([-+<>~]?)(([' . $lc . ']+)(\*?)|"[^"]*")/',
+ $filteredText, $m, PREG_SET_ORDER ) ) {
+ foreach ( $m as $bits ) {
+ MediaWiki\suppressWarnings();
+ list( /* all */, $modifier, $term, $nonQuoted, $wildcard ) = $bits;
+ MediaWiki\restoreWarnings();
+
+ if ( $nonQuoted != '' ) {
+ $term = $nonQuoted;
+ $quote = '';
+ } else {
+ $term = str_replace( '"', '', $term );
+ $quote = '"';
+ }
+
+ if ( $searchon !== '' ) {
+ $searchon .= ' ';
+ }
+ if ( $this->strictMatching && ( $modifier == '' ) ) {
+ // If we leave this out, boolean op defaults to OR which is rarely helpful.
+ $modifier = '+';
+ }
+
+ // Some languages such as Serbian store the input form in the search index,
+ // so we may need to search for matches in multiple writing system variants.
+ $convertedVariants = $wgContLang->autoConvertToAllVariants( $term );
+ if ( is_array( $convertedVariants ) ) {
+ $variants = array_unique( array_values( $convertedVariants ) );
+ } else {
+ $variants = [ $term ];
+ }
+
+ // The low-level search index does some processing on input to work
+ // around problems with minimum lengths and encoding in MySQL's
+ // fulltext engine.
+ // For Chinese this also inserts spaces between adjacent Han characters.
+ $strippedVariants = array_map(
+ [ $wgContLang, 'normalizeForSearch' ],
+ $variants );
+
+ // Some languages such as Chinese force all variants to a canonical
+ // form when stripping to the low-level search index, so to be sure
+ // let's check our variants list for unique items after stripping.
+ $strippedVariants = array_unique( $strippedVariants );
+
+ $searchon .= $modifier;
+ if ( count( $strippedVariants ) > 1 ) {
+ $searchon .= '(';
+ }
+ foreach ( $strippedVariants as $stripped ) {
+ $stripped = $this->normalizeText( $stripped );
+ if ( $nonQuoted && strpos( $stripped, ' ' ) !== false ) {
+ // Hack for Chinese: we need to toss in quotes for
+ // multiple-character phrases since normalizeForSearch()
+ // added spaces between them to make word breaks.
+ $stripped = '"' . trim( $stripped ) . '"';
+ }
+ $searchon .= "$quote$stripped$quote$wildcard ";
+ }
+ if ( count( $strippedVariants ) > 1 ) {
+ $searchon .= ')';
+ }
+
+ // Match individual terms or quoted phrase in result highlighting...
+ // Note that variants will be introduced in a later stage for highlighting!
+ $regexp = $this->regexTerm( $term, $wildcard );
+ $this->searchTerms[] = $regexp;
+ }
+ wfDebug( __METHOD__ . ": Would search with '$searchon'\n" );
+ wfDebug( __METHOD__ . ': Match with /' . implode( '|', $this->searchTerms ) . "/\n" );
+ } else {
+ wfDebug( __METHOD__ . ": Can't understand search query '{$filteredText}'\n" );
+ }
+
+ $searchon = $this->db->addQuotes( $searchon );
+ $field = $this->getIndexField( $fulltext );
+ return " MATCH($field) AGAINST($searchon IN BOOLEAN MODE) ";
+ }
+
+ function regexTerm( $string, $wildcard ) {
+ global $wgContLang;
+
+ $regex = preg_quote( $string, '/' );
+ if ( $wgContLang->hasWordBreaks() ) {
+ if ( $wildcard ) {
+ // Don't cut off the final bit!
+ $regex = "\b$regex";
+ } else {
+ $regex = "\b$regex\b";
+ }
+ } else {
+ // For Chinese, words may legitimately abut other words in the text literal.
+ // Don't add \b boundary checks... note this could cause false positives
+ // for latin chars.
+ }
+ return $regex;
+ }
+
+ public static function legalSearchChars( $type = self::CHARS_ALL ) {
+ $searchChars = parent::legalSearchChars( $type );
+ if ( $type === self::CHARS_ALL ) {
+ // " for phrase, * for wildcard
+ $searchChars = "\"*" . $searchChars;
+ }
+ return $searchChars;
+ }
+
+ /**
+ * Perform a full text search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ function searchText( $term ) {
+ return $this->searchInternal( $term, true );
+ }
+
+ /**
+ * Perform a title-only search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ function searchTitle( $term ) {
+ return $this->searchInternal( $term, false );
+ }
+
+ protected function searchInternal( $term, $fulltext ) {
+ // This seems out of place, why is this called with empty term?
+ if ( trim( $term ) === '' ) {
+ return null;
+ }
+
+ $filteredTerm = $this->filter( $term );
+ $query = $this->getQuery( $filteredTerm, $fulltext );
+ $resultSet = $this->db->select(
+ $query['tables'], $query['fields'], $query['conds'],
+ __METHOD__, $query['options'], $query['joins']
+ );
+
+ $total = null;
+ $query = $this->getCountQuery( $filteredTerm, $fulltext );
+ $totalResult = $this->db->select(
+ $query['tables'], $query['fields'], $query['conds'],
+ __METHOD__, $query['options'], $query['joins']
+ );
+
+ $row = $totalResult->fetchObject();
+ if ( $row ) {
+ $total = intval( $row->c );
+ }
+ $totalResult->free();
+
+ return new SqlSearchResultSet( $resultSet, $this->searchTerms, $total );
+ }
+
+ public function supports( $feature ) {
+ switch ( $feature ) {
+ case 'title-suffix-filter':
+ return true;
+ default:
+ return parent::supports( $feature );
+ }
+ }
+
+ /**
+ * Add special conditions
+ * @param array &$query
+ * @since 1.18
+ */
+ protected function queryFeatures( &$query ) {
+ foreach ( $this->features as $feature => $value ) {
+ if ( $feature === 'title-suffix-filter' && $value ) {
+ $query['conds'][] = 'page_title' . $this->db->buildLike( $this->db->anyString(), $value );
+ }
+ }
+ }
+
+ /**
+ * Add namespace conditions
+ * @param array &$query
+ * @since 1.18 (changed)
+ */
+ function queryNamespaces( &$query ) {
+ if ( is_array( $this->namespaces ) ) {
+ if ( count( $this->namespaces ) === 0 ) {
+ $this->namespaces[] = '0';
+ }
+ $query['conds']['page_namespace'] = $this->namespaces;
+ }
+ }
+
+ /**
+ * Add limit options
+ * @param array &$query
+ * @since 1.18
+ */
+ protected function limitResult( &$query ) {
+ $query['options']['LIMIT'] = $this->limit;
+ $query['options']['OFFSET'] = $this->offset;
+ }
+
+ /**
+ * Construct the SQL query to do the search.
+ * The guts shoulds be constructed in queryMain()
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return array
+ * @since 1.18 (changed)
+ */
+ function getQuery( $filteredTerm, $fulltext ) {
+ $query = [
+ 'tables' => [],
+ 'fields' => [],
+ 'conds' => [],
+ 'options' => [],
+ 'joins' => [],
+ ];
+
+ $this->queryMain( $query, $filteredTerm, $fulltext );
+ $this->queryFeatures( $query );
+ $this->queryNamespaces( $query );
+ $this->limitResult( $query );
+
+ return $query;
+ }
+
+ /**
+ * Picks which field to index on, depending on what type of query.
+ * @param bool $fulltext
+ * @return string
+ */
+ function getIndexField( $fulltext ) {
+ return $fulltext ? 'si_text' : 'si_title';
+ }
+
+ /**
+ * Get the base part of the search query.
+ *
+ * @param array &$query Search query array
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @since 1.18 (changed)
+ */
+ function queryMain( &$query, $filteredTerm, $fulltext ) {
+ $match = $this->parseQuery( $filteredTerm, $fulltext );
+ $query['tables'][] = 'page';
+ $query['tables'][] = 'searchindex';
+ $query['fields'][] = 'page_id';
+ $query['fields'][] = 'page_namespace';
+ $query['fields'][] = 'page_title';
+ $query['conds'][] = 'page_id=si_page';
+ $query['conds'][] = $match;
+ }
+
+ /**
+ * @since 1.18 (changed)
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return array
+ */
+ function getCountQuery( $filteredTerm, $fulltext ) {
+ $match = $this->parseQuery( $filteredTerm, $fulltext );
+
+ $query = [
+ 'tables' => [ 'page', 'searchindex' ],
+ 'fields' => [ 'COUNT(*) as c' ],
+ 'conds' => [ 'page_id=si_page', $match ],
+ 'options' => [],
+ 'joins' => [],
+ ];
+
+ $this->queryFeatures( $query );
+ $this->queryNamespaces( $query );
+
+ return $query;
+ }
+
+ /**
+ * Create or update the search index record for the given page.
+ * Title and text should be pre-processed.
+ *
+ * @param int $id
+ * @param string $title
+ * @param string $text
+ */
+ function update( $id, $title, $text ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->replace( 'searchindex',
+ [ 'si_page' ],
+ [
+ 'si_page' => $id,
+ 'si_title' => $this->normalizeText( $title ),
+ 'si_text' => $this->normalizeText( $text )
+ ], __METHOD__ );
+ }
+
+ /**
+ * Update a search index record's title only.
+ * Title should be pre-processed.
+ *
+ * @param int $id
+ * @param string $title
+ */
+ function updateTitle( $id, $title ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->update( 'searchindex',
+ [ 'si_title' => $this->normalizeText( $title ) ],
+ [ 'si_page' => $id ],
+ __METHOD__,
+ [ $dbw->lowPriorityOption() ] );
+ }
+
+ /**
+ * Delete an indexed page
+ * Title should be pre-processed.
+ *
+ * @param int $id Page id that was deleted
+ * @param string $title Title of page that was deleted
+ */
+ function delete( $id, $title ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->delete( 'searchindex', [ 'si_page' => $id ], __METHOD__ );
+ }
+
+ /**
+ * Converts some characters for MySQL's indexing to grok it correctly,
+ * and pads short words to overcome limitations.
+ * @param string $string
+ * @return mixed|string
+ */
+ function normalizeText( $string ) {
+ global $wgContLang;
+
+ $out = parent::normalizeText( $string );
+
+ // MySQL fulltext index doesn't grok utf-8, so we
+ // need to fold cases and convert to hex
+ $out = preg_replace_callback(
+ "/([\\xc0-\\xff][\\x80-\\xbf]*)/",
+ [ $this, 'stripForSearchCallback' ],
+ $wgContLang->lc( $out ) );
+
+ // And to add insult to injury, the default indexing
+ // ignores short words... Pad them so we can pass them
+ // through without reconfiguring the server...
+ $minLength = $this->minSearchLength();
+ if ( $minLength > 1 ) {
+ $n = $minLength - 1;
+ $out = preg_replace(
+ "/\b(\w{1,$n})\b/",
+ "$1u800",
+ $out );
+ }
+
+ // Periods within things like hostnames and IP addresses
+ // are also important -- we want a search for "example.com"
+ // or "192.168.1.1" to work sanely.
+ // MySQL's search seems to ignore them, so you'd match on
+ // "example.wikipedia.com" and "192.168.83.1" as well.
+ $out = preg_replace(
+ "/(\w)\.(\w|\*)/u",
+ "$1u82e$2",
+ $out );
+
+ return $out;
+ }
+
+ /**
+ * Armor a case-folded UTF-8 string to get through MySQL's
+ * fulltext search without being mucked up by funny charset
+ * settings or anything else of the sort.
+ * @param array $matches
+ * @return string
+ */
+ protected function stripForSearchCallback( $matches ) {
+ return 'u8' . bin2hex( $matches[1] );
+ }
+
+ /**
+ * Check MySQL server's ft_min_word_len setting so we know
+ * if we need to pad short words...
+ *
+ * @return int
+ */
+ protected function minSearchLength() {
+ if ( is_null( self::$mMinSearchLength ) ) {
+ $sql = "SHOW GLOBAL VARIABLES LIKE 'ft\\_min\\_word\\_len'";
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $result = $dbr->query( $sql, __METHOD__ );
+ $row = $result->fetchObject();
+ $result->free();
+
+ if ( $row && $row->Variable_name == 'ft_min_word_len' ) {
+ self::$mMinSearchLength = intval( $row->Value );
+ } else {
+ self::$mMinSearchLength = 0;
+ }
+ }
+ return self::$mMinSearchLength;
+ }
+}
diff --git a/www/wiki/includes/search/SearchNearMatchResultSet.php b/www/wiki/includes/search/SearchNearMatchResultSet.php
new file mode 100644
index 00000000..31417974
--- /dev/null
+++ b/www/wiki/includes/search/SearchNearMatchResultSet.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * A SearchResultSet wrapper for SearchNearMatcher
+ */
+class SearchNearMatchResultSet extends SearchResultSet {
+ private $fetched = false;
+
+ /**
+ * @param Title|null $match Title if matched, else null
+ */
+ public function __construct( $match ) {
+ $this->result = $match;
+ }
+
+ public function numRows() {
+ return $this->result ? 1 : 0;
+ }
+
+ public function next() {
+ if ( $this->fetched || !$this->result ) {
+ return false;
+ }
+ $this->fetched = true;
+ return SearchResult::newFromTitle( $this->result, $this );
+ }
+
+ public function rewind() {
+ $this->fetched = false;
+ }
+}
diff --git a/www/wiki/includes/search/SearchNearMatcher.php b/www/wiki/includes/search/SearchNearMatcher.php
new file mode 100644
index 00000000..27046f31
--- /dev/null
+++ b/www/wiki/includes/search/SearchNearMatcher.php
@@ -0,0 +1,167 @@
+<?php
+
+/**
+ * Implementation of near match title search.
+ * TODO: split into service/implementation.
+ */
+class SearchNearMatcher {
+ /**
+ * @var Config
+ */
+ protected $config;
+
+ /**
+ * Current language
+ * @var Language
+ */
+ private $language;
+
+ public function __construct( Config $config, Language $lang ) {
+ $this->config = $config;
+ $this->language = $lang;
+ }
+
+ /**
+ * If an exact title match can be found, or a very slightly close match,
+ * return the title. If no match, returns NULL.
+ *
+ * @param string $searchterm
+ * @return Title
+ */
+ public function getNearMatch( $searchterm ) {
+ $title = $this->getNearMatchInternal( $searchterm );
+
+ Hooks::run( 'SearchGetNearMatchComplete', [ $searchterm, &$title ] );
+ return $title;
+ }
+
+ /**
+ * Do a near match (see SearchEngine::getNearMatch) and wrap it into a
+ * SearchResultSet.
+ *
+ * @param string $searchterm
+ * @return SearchResultSet
+ */
+ public function getNearMatchResultSet( $searchterm ) {
+ return new SearchNearMatchResultSet( $this->getNearMatch( $searchterm ) );
+ }
+
+ /**
+ * Really find the title match.
+ * @param string $searchterm
+ * @return null|Title
+ */
+ protected function getNearMatchInternal( $searchterm ) {
+ $lang = $this->language;
+
+ $allSearchTerms = [ $searchterm ];
+
+ if ( $lang->hasVariants() ) {
+ $allSearchTerms = array_unique( array_merge(
+ $allSearchTerms,
+ $lang->autoConvertToAllVariants( $searchterm )
+ ) );
+ }
+
+ $titleResult = null;
+ if ( !Hooks::run( 'SearchGetNearMatchBefore', [ $allSearchTerms, &$titleResult ] ) ) {
+ return $titleResult;
+ }
+
+ foreach ( $allSearchTerms as $term ) {
+ # Exact match? No need to look further.
+ $title = Title::newFromText( $term );
+ if ( is_null( $title ) ) {
+ return null;
+ }
+
+ # Try files if searching in the Media: namespace
+ if ( $title->getNamespace() == NS_MEDIA ) {
+ $title = Title::makeTitle( NS_FILE, $title->getText() );
+ }
+
+ if ( $title->isSpecialPage() || $title->isExternal() || $title->exists() ) {
+ return $title;
+ }
+
+ # See if it still otherwise has content is some sane sense
+ $page = WikiPage::factory( $title );
+ if ( $page->hasViewableContent() ) {
+ return $title;
+ }
+
+ if ( !Hooks::run( 'SearchAfterNoDirectMatch', [ $term, &$title ] ) ) {
+ return $title;
+ }
+
+ # Now try all lower case (i.e. first letter capitalized)
+ $title = Title::newFromText( $lang->lc( $term ) );
+ if ( $title && $title->exists() ) {
+ return $title;
+ }
+
+ # Now try capitalized string
+ $title = Title::newFromText( $lang->ucwords( $term ) );
+ if ( $title && $title->exists() ) {
+ return $title;
+ }
+
+ # Now try all upper case
+ $title = Title::newFromText( $lang->uc( $term ) );
+ if ( $title && $title->exists() ) {
+ return $title;
+ }
+
+ # Now try Word-Caps-Breaking-At-Word-Breaks, for hyphenated names etc
+ $title = Title::newFromText( $lang->ucwordbreaks( $term ) );
+ if ( $title && $title->exists() ) {
+ return $title;
+ }
+
+ // Give hooks a chance at better match variants
+ $title = null;
+ if ( !Hooks::run( 'SearchGetNearMatch', [ $term, &$title ] ) ) {
+ return $title;
+ }
+ }
+
+ $title = Title::newFromText( $searchterm );
+
+ # Entering an IP address goes to the contributions page
+ if ( $this->config->get( 'EnableSearchContributorsByIP' ) ) {
+ if ( ( $title->getNamespace() == NS_USER && User::isIP( $title->getText() ) )
+ || User::isIP( trim( $searchterm ) ) ) {
+ return SpecialPage::getTitleFor( 'Contributions', $title->getDBkey() );
+ }
+ }
+
+ # Entering a user goes to the user page whether it's there or not
+ if ( $title->getNamespace() == NS_USER ) {
+ return $title;
+ }
+
+ # Go to images that exist even if there's no local page.
+ # There may have been a funny upload, or it may be on a shared
+ # file repository such as Wikimedia Commons.
+ if ( $title->getNamespace() == NS_FILE ) {
+ $image = wfFindFile( $title );
+ if ( $image ) {
+ return $title;
+ }
+ }
+
+ # MediaWiki namespace? Page may be "implied" if not customized.
+ # Just return it, with caps forced as the message system likes it.
+ if ( $title->getNamespace() == NS_MEDIAWIKI ) {
+ return Title::makeTitle( NS_MEDIAWIKI, $lang->ucfirst( $title->getText() ) );
+ }
+
+ # Quoted term? Try without the quotes...
+ $matches = [];
+ if ( preg_match( '/^"([^"]+)"$/', $searchterm, $matches ) ) {
+ return self::getNearMatch( $matches[1] );
+ }
+
+ return null;
+ }
+}
diff --git a/www/wiki/includes/search/SearchOracle.php b/www/wiki/includes/search/SearchOracle.php
new file mode 100644
index 00000000..8bcd78fa
--- /dev/null
+++ b/www/wiki/includes/search/SearchOracle.php
@@ -0,0 +1,276 @@
+<?php
+/**
+ * Oracle search engine
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+/**
+ * Search engine hook base class for Oracle (ConText).
+ * @ingroup Search
+ */
+class SearchOracle extends SearchDatabase {
+ private $reservedWords = [
+ 'ABOUT' => 1,
+ 'ACCUM' => 1,
+ 'AND' => 1,
+ 'BT' => 1,
+ 'BTG' => 1,
+ 'BTI' => 1,
+ 'BTP' => 1,
+ 'FUZZY' => 1,
+ 'HASPATH' => 1,
+ 'INPATH' => 1,
+ 'MINUS' => 1,
+ 'NEAR' => 1,
+ 'NOT' => 1,
+ 'NT' => 1,
+ 'NTG' => 1,
+ 'NTI' => 1,
+ 'NTP' => 1,
+ 'OR' => 1,
+ 'PT' => 1,
+ 'RT' => 1,
+ 'SQE' => 1,
+ 'SYN' => 1,
+ 'TR' => 1,
+ 'TRSYN' => 1,
+ 'TT' => 1,
+ 'WITHIN' => 1,
+ ];
+
+ /**
+ * Perform a full text search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ function searchText( $term ) {
+ if ( $term == '' ) {
+ return new SqlSearchResultSet( false, '' );
+ }
+
+ $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), true ) );
+ return new SqlSearchResultSet( $resultSet, $this->searchTerms );
+ }
+
+ /**
+ * Perform a title-only search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ function searchTitle( $term ) {
+ if ( $term == '' ) {
+ return new SqlSearchResultSet( false, '' );
+ }
+
+ $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), false ) );
+ return new SqlSearchResultSet( $resultSet, $this->searchTerms );
+ }
+
+ /**
+ * Return a partial WHERE clause to limit the search to the given namespaces
+ * @return string
+ */
+ function queryNamespaces() {
+ if ( is_null( $this->namespaces ) ) {
+ return '';
+ }
+ if ( !count( $this->namespaces ) ) {
+ $namespaces = '0';
+ } else {
+ $namespaces = $this->db->makeList( $this->namespaces );
+ }
+ return 'AND page_namespace IN (' . $namespaces . ')';
+ }
+
+ /**
+ * Return a LIMIT clause to limit results on the query.
+ *
+ * @param string $sql
+ *
+ * @return string
+ */
+ function queryLimit( $sql ) {
+ return $this->db->limitResult( $sql, $this->limit, $this->offset );
+ }
+
+ /**
+ * Does not do anything for generic search engine
+ * subclasses may define this though
+ *
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return string
+ */
+ function queryRanking( $filteredTerm, $fulltext ) {
+ return ' ORDER BY score(1)';
+ }
+
+ /**
+ * Construct the full SQL query to do the search.
+ * The guts shoulds be constructed in queryMain()
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return string
+ */
+ function getQuery( $filteredTerm, $fulltext ) {
+ return $this->queryLimit( $this->queryMain( $filteredTerm, $fulltext ) . ' ' .
+ $this->queryNamespaces() . ' ' .
+ $this->queryRanking( $filteredTerm, $fulltext ) . ' ' );
+ }
+
+ /**
+ * Picks which field to index on, depending on what type of query.
+ * @param bool $fulltext
+ * @return string
+ */
+ function getIndexField( $fulltext ) {
+ return $fulltext ? 'si_text' : 'si_title';
+ }
+
+ /**
+ * Get the base part of the search query.
+ *
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return string
+ */
+ function queryMain( $filteredTerm, $fulltext ) {
+ $match = $this->parseQuery( $filteredTerm, $fulltext );
+ $page = $this->db->tableName( 'page' );
+ $searchindex = $this->db->tableName( 'searchindex' );
+ return 'SELECT page_id, page_namespace, page_title ' .
+ "FROM $page,$searchindex " .
+ 'WHERE page_id=si_page AND ' . $match;
+ }
+
+ /**
+ * Parse a user input search string, and return an SQL fragment to be used
+ * as part of a WHERE clause
+ * @param string $filteredText
+ * @param bool $fulltext
+ * @return string
+ */
+ function parseQuery( $filteredText, $fulltext ) {
+ global $wgContLang;
+ $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX );
+ $this->searchTerms = [];
+
+ # @todo FIXME: This doesn't handle parenthetical expressions.
+ $m = [];
+ $searchon = '';
+ if ( preg_match_all( '/([-+<>~]?)(([' . $lc . ']+)(\*?)|"[^"]*")/',
+ $filteredText, $m, PREG_SET_ORDER ) ) {
+ foreach ( $m as $terms ) {
+ // Search terms in all variant forms, only
+ // apply on wiki with LanguageConverter
+ $temp_terms = $wgContLang->autoConvertToAllVariants( $terms[2] );
+ if ( is_array( $temp_terms ) ) {
+ $temp_terms = array_unique( array_values( $temp_terms ) );
+ foreach ( $temp_terms as $t ) {
+ $searchon .= ( $terms[1] == '-' ? ' ~' : ' & ' ) . $this->escapeTerm( $t );
+ }
+ } else {
+ $searchon .= ( $terms[1] == '-' ? ' ~' : ' & ' ) . $this->escapeTerm( $terms[2] );
+ }
+ if ( !empty( $terms[3] ) ) {
+ $regexp = preg_quote( $terms[3], '/' );
+ if ( $terms[4] ) {
+ $regexp .= "[0-9A-Za-z_]+";
+ }
+ } else {
+ $regexp = preg_quote( str_replace( '"', '', $terms[2] ), '/' );
+ }
+ $this->searchTerms[] = $regexp;
+ }
+ }
+
+ $searchon = $this->db->addQuotes( ltrim( $searchon, ' &' ) );
+ $field = $this->getIndexField( $fulltext );
+ return " CONTAINS($field, $searchon, 1) > 0 ";
+ }
+
+ private function escapeTerm( $t ) {
+ global $wgContLang;
+ $t = $wgContLang->normalizeForSearch( $t );
+ $t = isset( $this->reservedWords[strtoupper( $t )] ) ? '{' . $t . '}' : $t;
+ $t = preg_replace( '/^"(.*)"$/', '($1)', $t );
+ $t = preg_replace( '/([-&|])/', '\\\\$1', $t );
+ return $t;
+ }
+
+ /**
+ * Create or update the search index record for the given page.
+ * Title and text should be pre-processed.
+ *
+ * @param int $id
+ * @param string $title
+ * @param string $text
+ */
+ function update( $id, $title, $text ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->replace( 'searchindex',
+ [ 'si_page' ],
+ [
+ 'si_page' => $id,
+ 'si_title' => $title,
+ 'si_text' => $text
+ ], 'SearchOracle::update' );
+
+ // Sync the index
+ // We need to specify the DB name (i.e. user/schema) here so that
+ // it can work from the installer, where
+ // ALTER SESSION SET CURRENT_SCHEMA = ...
+ // was used.
+ $dbw->query( "CALL ctx_ddl.sync_index(" .
+ $dbw->addQuotes( $dbw->getDBname() . '.' . $dbw->tableName( 'si_text_idx', 'raw' ) ) . ")" );
+ $dbw->query( "CALL ctx_ddl.sync_index(" .
+ $dbw->addQuotes( $dbw->getDBname() . '.' . $dbw->tableName( 'si_title_idx', 'raw' ) ) . ")" );
+ }
+
+ /**
+ * Update a search index record's title only.
+ * Title should be pre-processed.
+ *
+ * @param int $id
+ * @param string $title
+ */
+ function updateTitle( $id, $title ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->update( 'searchindex',
+ [ 'si_title' => $title ],
+ [ 'si_page' => $id ],
+ 'SearchOracle::updateTitle',
+ [] );
+ }
+
+ public static function legalSearchChars( $type = self::CHARS_ALL ) {
+ $searchChars = parent::legalSearchChars( $type );
+ if ( $type === self::CHARS_ALL ) {
+ $searchChars = "\"" . $searchChars;
+ }
+ return $searchChars;
+ }
+}
diff --git a/www/wiki/includes/search/SearchPostgres.php b/www/wiki/includes/search/SearchPostgres.php
new file mode 100644
index 00000000..5a50b176
--- /dev/null
+++ b/www/wiki/includes/search/SearchPostgres.php
@@ -0,0 +1,192 @@
+<?php
+/**
+ * PostgreSQL search engine
+ *
+ * Copyright © 2006-2007 Greg Sabino Mullane <greg@turnstep.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+/**
+ * Search engine hook base class for Postgres
+ * @ingroup Search
+ */
+class SearchPostgres extends SearchDatabase {
+ /**
+ * Perform a full text search query via tsearch2 and return a result set.
+ * Currently searches a page's current title (page.page_title) and
+ * latest revision article text (pagecontent.old_text)
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ function searchTitle( $term ) {
+ $q = $this->searchQuery( $term, 'titlevector', 'page_title' );
+ $olderror = error_reporting( E_ERROR );
+ $resultSet = $this->db->query( $q, 'SearchPostgres', true );
+ error_reporting( $olderror );
+ return new SqlSearchResultSet( $resultSet, $this->searchTerms );
+ }
+
+ function searchText( $term ) {
+ $q = $this->searchQuery( $term, 'textvector', 'old_text' );
+ $olderror = error_reporting( E_ERROR );
+ $resultSet = $this->db->query( $q, 'SearchPostgres', true );
+ error_reporting( $olderror );
+ return new SqlSearchResultSet( $resultSet, $this->searchTerms );
+ }
+
+ /**
+ * Transform the user's search string into a better form for tsearch2
+ * Returns an SQL fragment consisting of quoted text to search for.
+ *
+ * @param string $term
+ *
+ * @return string
+ */
+ function parseQuery( $term ) {
+ wfDebug( "parseQuery received: $term \n" );
+
+ # # No backslashes allowed
+ $term = preg_replace( '/\\\/', '', $term );
+
+ # # Collapse parens into nearby words:
+ $term = preg_replace( '/\s*\(\s*/', ' (', $term );
+ $term = preg_replace( '/\s*\)\s*/', ') ', $term );
+
+ # # Treat colons as word separators:
+ $term = preg_replace( '/:/', ' ', $term );
+
+ $searchstring = '';
+ $m = [];
+ if ( preg_match_all( '/([-!]?)(\S+)\s*/', $term, $m, PREG_SET_ORDER ) ) {
+ foreach ( $m as $terms ) {
+ if ( strlen( $terms[1] ) ) {
+ $searchstring .= ' & !';
+ }
+ if ( strtolower( $terms[2] ) === 'and' ) {
+ $searchstring .= ' & ';
+ } elseif ( strtolower( $terms[2] ) === 'or' || $terms[2] === '|' ) {
+ $searchstring .= ' | ';
+ } elseif ( strtolower( $terms[2] ) === 'not' ) {
+ $searchstring .= ' & !';
+ } else {
+ $searchstring .= " & $terms[2]";
+ }
+ }
+ }
+
+ # # Strip out leading junk
+ $searchstring = preg_replace( '/^[\s\&\|]+/', '', $searchstring );
+
+ # # Remove any doubled-up operators
+ $searchstring = preg_replace( '/([\!\&\|]) +(?:[\&\|] +)+/', "$1 ", $searchstring );
+
+ # # Remove any non-spaced operators (e.g. "Zounds!")
+ $searchstring = preg_replace( '/([^ ])[\!\&\|]/', "$1", $searchstring );
+
+ # # Remove any trailing whitespace or operators
+ $searchstring = preg_replace( '/[\s\!\&\|]+$/', '', $searchstring );
+
+ # # Remove unnecessary quotes around everything
+ $searchstring = preg_replace( '/^[\'"](.*)[\'"]$/', "$1", $searchstring );
+
+ # # Quote the whole thing
+ $searchstring = $this->db->addQuotes( $searchstring );
+
+ wfDebug( "parseQuery returned: $searchstring \n" );
+
+ return $searchstring;
+ }
+
+ /**
+ * Construct the full SQL query to do the search.
+ * @param string $term
+ * @param string $fulltext
+ * @param string $colname
+ * @return string
+ */
+ function searchQuery( $term, $fulltext, $colname ) {
+ # Get the SQL fragment for the given term
+ $searchstring = $this->parseQuery( $term );
+
+ # # We need a separate query here so gin does not complain about empty searches
+ $sql = "SELECT to_tsquery($searchstring)";
+ $res = $this->db->query( $sql );
+ if ( !$res ) {
+ # # TODO: Better output (example to catch: one 'two)
+ die( "Sorry, that was not a valid search string. Please go back and try again" );
+ }
+ $top = $res->fetchRow()[0];
+
+ $this->searchTerms = [];
+ if ( $top === "" ) { # # e.g. if only stopwords are used XXX return something better
+ $query = "SELECT page_id, page_namespace, page_title, 0 AS score " .
+ "FROM page p, revision r, pagecontent c WHERE p.page_latest = r.rev_id " .
+ "AND r.rev_text_id = c.old_id AND 1=0";
+ } else {
+ $m = [];
+ if ( preg_match_all( "/'([^']+)'/", $top, $m, PREG_SET_ORDER ) ) {
+ foreach ( $m as $terms ) {
+ $this->searchTerms[$terms[1]] = $terms[1];
+ }
+ }
+
+ $query = "SELECT page_id, page_namespace, page_title, " .
+ "ts_rank($fulltext, to_tsquery($searchstring), 5) AS score " .
+ "FROM page p, revision r, pagecontent c WHERE p.page_latest = r.rev_id " .
+ "AND r.rev_text_id = c.old_id AND $fulltext @@ to_tsquery($searchstring)";
+ }
+
+ # # Namespaces - defaults to 0
+ if ( !is_null( $this->namespaces ) ) { // null -> search all
+ if ( count( $this->namespaces ) < 1 ) {
+ $query .= ' AND page_namespace = 0';
+ } else {
+ $namespaces = $this->db->makeList( $this->namespaces );
+ $query .= " AND page_namespace IN ($namespaces)";
+ }
+ }
+
+ $query .= " ORDER BY score DESC, page_id DESC";
+
+ $query .= $this->db->limitResult( '', $this->limit, $this->offset );
+
+ wfDebug( "searchQuery returned: $query \n" );
+
+ return $query;
+ }
+
+ # # Most of the work of these two functions are done automatically via triggers
+
+ function update( $pageid, $title, $text ) {
+ # # We don't want to index older revisions
+ $sql = "UPDATE pagecontent SET textvector = NULL WHERE textvector IS NOT NULL and old_id IN " .
+ "(SELECT DISTINCT rev_text_id FROM revision WHERE rev_page = " . intval( $pageid ) .
+ " ORDER BY rev_text_id DESC OFFSET 1)";
+ $this->db->query( $sql );
+ return true;
+ }
+
+ function updateTitle( $id, $title ) {
+ return true;
+ }
+
+}
diff --git a/www/wiki/includes/search/SearchResult.php b/www/wiki/includes/search/SearchResult.php
new file mode 100644
index 00000000..dc294c32
--- /dev/null
+++ b/www/wiki/includes/search/SearchResult.php
@@ -0,0 +1,283 @@
+<?php
+/**
+ * Search engine result
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @todo FIXME: This class is horribly factored. It would probably be better to
+ * have a useful base class to which you pass some standard information, then
+ * let the fancy self-highlighters extend that.
+ * @ingroup Search
+ */
+class SearchResult {
+
+ /**
+ * @var Revision
+ */
+ protected $mRevision = null;
+
+ /**
+ * @var File
+ */
+ protected $mImage = null;
+
+ /**
+ * @var Title
+ */
+ protected $mTitle;
+
+ /**
+ * @var string
+ */
+ protected $mText;
+
+ /**
+ * @var SearchEngine
+ */
+ protected $searchEngine;
+
+ /**
+ * A set of extension data.
+ * @var array[]
+ */
+ protected $extensionData;
+
+ /**
+ * Return a new SearchResult and initializes it with a title.
+ *
+ * @param Title $title
+ * @param SearchResultSet $parentSet
+ * @return SearchResult
+ */
+ public static function newFromTitle( $title, SearchResultSet $parentSet = null ) {
+ $result = new static();
+ $result->initFromTitle( $title );
+ if ( $parentSet ) {
+ $parentSet->augmentResult( $result );
+ }
+ return $result;
+ }
+
+ /**
+ * Initialize from a Title and if possible initializes a corresponding
+ * Revision and File.
+ *
+ * @param Title $title
+ */
+ protected function initFromTitle( $title ) {
+ $this->mTitle = $title;
+ if ( !is_null( $this->mTitle ) ) {
+ $id = false;
+ Hooks::run( 'SearchResultInitFromTitle', [ $title, &$id ] );
+ $this->mRevision = Revision::newFromTitle(
+ $this->mTitle, $id, Revision::READ_NORMAL );
+ if ( $this->mTitle->getNamespace() === NS_FILE ) {
+ $this->mImage = wfFindFile( $this->mTitle );
+ }
+ }
+ $this->searchEngine = MediaWikiServices::getInstance()->newSearchEngine();
+ }
+
+ /**
+ * Check if this is result points to an invalid title
+ *
+ * @return bool
+ */
+ function isBrokenTitle() {
+ return is_null( $this->mTitle );
+ }
+
+ /**
+ * Check if target page is missing, happens when index is out of date
+ *
+ * @return bool
+ */
+ function isMissingRevision() {
+ return !$this->mRevision && !$this->mImage;
+ }
+
+ /**
+ * @return Title
+ */
+ function getTitle() {
+ return $this->mTitle;
+ }
+
+ /**
+ * Get the file for this page, if one exists
+ * @return File|null
+ */
+ function getFile() {
+ return $this->mImage;
+ }
+
+ /**
+ * Lazy initialization of article text from DB
+ */
+ protected function initText() {
+ if ( !isset( $this->mText ) ) {
+ if ( $this->mRevision != null ) {
+ $this->mText = $this->searchEngine->getTextFromContent(
+ $this->mTitle, $this->mRevision->getContent() );
+ } else { // TODO: can we fetch raw wikitext for commons images?
+ $this->mText = '';
+ }
+ }
+ }
+
+ /**
+ * @param array $terms Terms to highlight
+ * @return string Highlighted text snippet, null (and not '') if not supported
+ */
+ function getTextSnippet( $terms ) {
+ global $wgAdvancedSearchHighlighting;
+ $this->initText();
+
+ // TODO: make highliter take a content object. Make ContentHandler a factory for SearchHighliter.
+ list( $contextlines, $contextchars ) = $this->searchEngine->userHighlightPrefs();
+
+ $h = new SearchHighlighter();
+ if ( count( $terms ) > 0 ) {
+ if ( $wgAdvancedSearchHighlighting ) {
+ return $h->highlightText( $this->mText, $terms, $contextlines, $contextchars );
+ } else {
+ return $h->highlightSimple( $this->mText, $terms, $contextlines, $contextchars );
+ }
+ } else {
+ return $h->highlightNone( $this->mText, $contextlines, $contextchars );
+ }
+ }
+
+ /**
+ * @return string Highlighted title, '' if not supported
+ */
+ function getTitleSnippet() {
+ return '';
+ }
+
+ /**
+ * @return string Highlighted redirect name (redirect to this page), '' if none or not supported
+ */
+ function getRedirectSnippet() {
+ return '';
+ }
+
+ /**
+ * @return Title|null Title object for the redirect to this page, null if none or not supported
+ */
+ function getRedirectTitle() {
+ return null;
+ }
+
+ /**
+ * @return string Highlighted relevant section name, null if none or not supported
+ */
+ function getSectionSnippet() {
+ return '';
+ }
+
+ /**
+ * @return Title|null Title object (pagename+fragment) for the section,
+ * null if none or not supported
+ */
+ function getSectionTitle() {
+ return null;
+ }
+
+ /**
+ * @return string Highlighted relevant category name or '' if none or not supported
+ */
+ public function getCategorySnippet() {
+ return '';
+ }
+
+ /**
+ * @return string Timestamp
+ */
+ function getTimestamp() {
+ if ( $this->mRevision ) {
+ return $this->mRevision->getTimestamp();
+ } elseif ( $this->mImage ) {
+ return $this->mImage->getTimestamp();
+ }
+ return '';
+ }
+
+ /**
+ * @return int Number of words
+ */
+ function getWordCount() {
+ $this->initText();
+ return str_word_count( $this->mText );
+ }
+
+ /**
+ * @return int Size in bytes
+ */
+ function getByteSize() {
+ $this->initText();
+ return strlen( $this->mText );
+ }
+
+ /**
+ * @return string Interwiki prefix of the title (return iw even if title is broken)
+ */
+ function getInterwikiPrefix() {
+ return '';
+ }
+
+ /**
+ * @return string Interwiki namespace of the title (since we likely can't resolve it locally)
+ */
+ function getInterwikiNamespaceText() {
+ return '';
+ }
+
+ /**
+ * Did this match file contents (eg: PDF/DJVU)?
+ * @return bool
+ */
+ function isFileMatch() {
+ return false;
+ }
+
+ /**
+ * Get the extension data as:
+ * augmentor name => data
+ * @return array[]
+ */
+ public function getExtensionData() {
+ return $this->extensionData;
+ }
+
+ /**
+ * Set extension data for this result.
+ * The data is:
+ * augmentor name => data
+ * @param array[] $extensionData
+ */
+ public function setExtensionData( array $extensionData ) {
+ $this->extensionData = $extensionData;
+ }
+
+}
diff --git a/www/wiki/includes/search/SearchResultSet.php b/www/wiki/includes/search/SearchResultSet.php
new file mode 100644
index 00000000..f25c7283
--- /dev/null
+++ b/www/wiki/includes/search/SearchResultSet.php
@@ -0,0 +1,279 @@
+<?php
+/**
+ * Search result sets
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+/**
+ * @ingroup Search
+ */
+class SearchResultSet {
+
+ /**
+ * Types of interwiki results
+ */
+ /**
+ * Results that are displayed only together with existing main wiki results
+ * @var int
+ */
+ const SECONDARY_RESULTS = 0;
+ /**
+ * Results that can displayed even if no existing main wiki results exist
+ * @var int
+ */
+ const INLINE_RESULTS = 1;
+
+ protected $containedSyntax = false;
+
+ /**
+ * Cache of titles.
+ * Lists titles of the result set, in the same order as results.
+ * @var Title[]
+ */
+ private $titles;
+
+ /**
+ * Cache of results - serialization of the result iterator
+ * as an array.
+ * @var SearchResult[]
+ */
+ private $results;
+
+ /**
+ * Set of result's extra data, indexed per result id
+ * and then per data item name.
+ * The structure is:
+ * PAGE_ID => [ augmentor name => data, ... ]
+ * @var array[]
+ */
+ protected $extraData = [];
+
+ public function __construct( $containedSyntax = false ) {
+ $this->containedSyntax = $containedSyntax;
+ }
+
+ /**
+ * Fetch an array of regular expression fragments for matching
+ * the search terms as parsed by this engine in a text extract.
+ * STUB
+ *
+ * @return array
+ */
+ function termMatches() {
+ return [];
+ }
+
+ function numRows() {
+ return 0;
+ }
+
+ /**
+ * Some search modes return a total hit count for the query
+ * in the entire article database. This may include pages
+ * in namespaces that would not be matched on the given
+ * settings.
+ *
+ * Return null if no total hits number is supported.
+ *
+ * @return int
+ */
+ function getTotalHits() {
+ return null;
+ }
+
+ /**
+ * Some search modes will run an alternative query that it thinks gives
+ * a better result than the provided search. Returns true if this has
+ * occured.
+ *
+ * @return bool
+ */
+ function hasRewrittenQuery() {
+ return false;
+ }
+
+ /**
+ * @return string|null The search the query was internally rewritten to,
+ * or null when the result of the original query was returned.
+ */
+ function getQueryAfterRewrite() {
+ return null;
+ }
+
+ /**
+ * @return string|null Same as self::getQueryAfterRewrite(), but in HTML
+ * and with changes highlighted. Null when the query was not rewritten.
+ */
+ function getQueryAfterRewriteSnippet() {
+ return null;
+ }
+
+ /**
+ * Some search modes return a suggested alternate term if there are
+ * no exact hits. Returns true if there is one on this set.
+ *
+ * @return bool
+ */
+ function hasSuggestion() {
+ return false;
+ }
+
+ /**
+ * @return string|null Suggested query, null if none
+ */
+ function getSuggestionQuery() {
+ return null;
+ }
+
+ /**
+ * @return string HTML highlighted suggested query, '' if none
+ */
+ function getSuggestionSnippet() {
+ return '';
+ }
+
+ /**
+ * Return a result set of hits on other (multiple) wikis associated with this one
+ *
+ * @param int $type
+ * @return SearchResultSet[]
+ */
+ function getInterwikiResults( $type = self::SECONDARY_RESULTS ) {
+ return null;
+ }
+
+ /**
+ * Check if there are results on other wikis
+ *
+ * @param int $type
+ * @return bool
+ */
+ function hasInterwikiResults( $type = self::SECONDARY_RESULTS ) {
+ return false;
+ }
+
+ /**
+ * Fetches next search result, or false.
+ * STUB
+ * FIXME: refactor as iterator, so we could use nicer interfaces.
+ * @return SearchResult|false
+ */
+ function next() {
+ return false;
+ }
+
+ /**
+ * Rewind result set back to beginning
+ */
+ function rewind() {
+ }
+
+ /**
+ * Frees the result set, if applicable.
+ */
+ function free() {
+ // ...
+ }
+
+ /**
+ * Did the search contain search syntax? If so, Special:Search won't offer
+ * the user a link to a create a page named by the search string because the
+ * name would contain the search syntax.
+ * @return bool
+ */
+ public function searchContainedSyntax() {
+ return $this->containedSyntax;
+ }
+
+ /**
+ * Extract all the results in the result set as array.
+ * @return SearchResult[]
+ */
+ public function extractResults() {
+ if ( is_null( $this->results ) ) {
+ $this->results = [];
+ if ( $this->numRows() == 0 ) {
+ // Don't bother if we've got empty result
+ return $this->results;
+ }
+ $this->rewind();
+ while ( ( $result = $this->next() ) != false ) {
+ $this->results[] = $result;
+ }
+ $this->rewind();
+ }
+ return $this->results;
+ }
+
+ /**
+ * Extract all the titles in the result set.
+ * @return Title[]
+ */
+ public function extractTitles() {
+ if ( is_null( $this->titles ) ) {
+ if ( $this->numRows() == 0 ) {
+ // Don't bother if we've got empty result
+ $this->titles = [];
+ } else {
+ $this->titles = array_map(
+ function ( SearchResult $result ) {
+ return $result->getTitle();
+ },
+ $this->extractResults() );
+ }
+ }
+ return $this->titles;
+ }
+
+ /**
+ * Sets augmented data for result set.
+ * @param string $name Extra data item name
+ * @param array[] $data Extra data as PAGEID => data
+ */
+ public function setAugmentedData( $name, $data ) {
+ foreach ( $data as $id => $resultData ) {
+ $this->extraData[$id][$name] = $resultData;
+ }
+ }
+
+ /**
+ * Returns extra data for specific result and store it in SearchResult object.
+ * @param SearchResult $result
+ * @return array|null List of data as name => value or null if none present.
+ */
+ public function augmentResult( SearchResult $result ) {
+ $id = $result->getTitle()->getArticleID();
+ if ( !$id || !isset( $this->extraData[$id] ) ) {
+ return null;
+ }
+ $result->setExtensionData( $this->extraData[$id] );
+ return $this->extraData[$id];
+ }
+
+ /**
+ * @return int|null The offset the current page starts at. Typically
+ * this should be null to allow the UI to decide on its own, but in
+ * special cases like interleaved AB tests specifying explicitly is
+ * necessary.
+ */
+ public function getOffset() {
+ return null;
+ }
+}
diff --git a/www/wiki/includes/search/SearchSqlite.php b/www/wiki/includes/search/SearchSqlite.php
new file mode 100644
index 00000000..3d4da42c
--- /dev/null
+++ b/www/wiki/includes/search/SearchSqlite.php
@@ -0,0 +1,312 @@
+<?php
+/**
+ * SQLite search backend, based upon SearchMysql
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Search
+ */
+
+/**
+ * Search engine hook for SQLite
+ * @ingroup Search
+ */
+class SearchSqlite extends SearchDatabase {
+ /**
+ * Whether fulltext search is supported by current schema
+ * @return bool
+ */
+ function fulltextSearchSupported() {
+ return $this->db->checkForEnabledSearch();
+ }
+
+ /**
+ * Parse the user's query and transform it into an SQL fragment which will
+ * become part of a WHERE clause
+ *
+ * @param string $filteredText
+ * @param bool $fulltext
+ * @return string
+ */
+ function parseQuery( $filteredText, $fulltext ) {
+ global $wgContLang;
+ $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX ); // Minus syntax chars (" and *)
+ $searchon = '';
+ $this->searchTerms = [];
+
+ $m = [];
+ if ( preg_match_all( '/([-+<>~]?)(([' . $lc . ']+)(\*?)|"[^"]*")/',
+ $filteredText, $m, PREG_SET_ORDER ) ) {
+ foreach ( $m as $bits ) {
+ MediaWiki\suppressWarnings();
+ list( /* all */, $modifier, $term, $nonQuoted, $wildcard ) = $bits;
+ MediaWiki\restoreWarnings();
+
+ if ( $nonQuoted != '' ) {
+ $term = $nonQuoted;
+ $quote = '';
+ } else {
+ $term = str_replace( '"', '', $term );
+ $quote = '"';
+ }
+
+ if ( $searchon !== '' ) {
+ $searchon .= ' ';
+ }
+
+ // Some languages such as Serbian store the input form in the search index,
+ // so we may need to search for matches in multiple writing system variants.
+ $convertedVariants = $wgContLang->autoConvertToAllVariants( $term );
+ if ( is_array( $convertedVariants ) ) {
+ $variants = array_unique( array_values( $convertedVariants ) );
+ } else {
+ $variants = [ $term ];
+ }
+
+ // The low-level search index does some processing on input to work
+ // around problems with minimum lengths and encoding in MySQL's
+ // fulltext engine.
+ // For Chinese this also inserts spaces between adjacent Han characters.
+ $strippedVariants = array_map(
+ [ $wgContLang, 'normalizeForSearch' ],
+ $variants );
+
+ // Some languages such as Chinese force all variants to a canonical
+ // form when stripping to the low-level search index, so to be sure
+ // let's check our variants list for unique items after stripping.
+ $strippedVariants = array_unique( $strippedVariants );
+
+ $searchon .= $modifier;
+ if ( count( $strippedVariants ) > 1 ) {
+ $searchon .= '(';
+ }
+ foreach ( $strippedVariants as $stripped ) {
+ if ( $nonQuoted && strpos( $stripped, ' ' ) !== false ) {
+ // Hack for Chinese: we need to toss in quotes for
+ // multiple-character phrases since normalizeForSearch()
+ // added spaces between them to make word breaks.
+ $stripped = '"' . trim( $stripped ) . '"';
+ }
+ $searchon .= "$quote$stripped$quote$wildcard ";
+ }
+ if ( count( $strippedVariants ) > 1 ) {
+ $searchon .= ')';
+ }
+
+ // Match individual terms or quoted phrase in result highlighting...
+ // Note that variants will be introduced in a later stage for highlighting!
+ $regexp = $this->regexTerm( $term, $wildcard );
+ $this->searchTerms[] = $regexp;
+ }
+
+ } else {
+ wfDebug( __METHOD__ . ": Can't understand search query '{$filteredText}'\n" );
+ }
+
+ $searchon = $this->db->addQuotes( $searchon );
+ $field = $this->getIndexField( $fulltext );
+ return " $field MATCH $searchon ";
+ }
+
+ function regexTerm( $string, $wildcard ) {
+ global $wgContLang;
+
+ $regex = preg_quote( $string, '/' );
+ if ( $wgContLang->hasWordBreaks() ) {
+ if ( $wildcard ) {
+ // Don't cut off the final bit!
+ $regex = "\b$regex";
+ } else {
+ $regex = "\b$regex\b";
+ }
+ } else {
+ // For Chinese, words may legitimately abut other words in the text literal.
+ // Don't add \b boundary checks... note this could cause false positives
+ // for latin chars.
+ }
+ return $regex;
+ }
+
+ public static function legalSearchChars( $type = self::CHARS_ALL ) {
+ $searchChars = parent::legalSearchChars( $type );
+ if ( $type === self::CHARS_ALL ) {
+ // " for phrase, * for wildcard
+ $searchChars = "\"*" . $searchChars;
+ }
+ return $searchChars;
+ }
+
+ /**
+ * Perform a full text search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ function searchText( $term ) {
+ return $this->searchInternal( $term, true );
+ }
+
+ /**
+ * Perform a title-only search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ function searchTitle( $term ) {
+ return $this->searchInternal( $term, false );
+ }
+
+ protected function searchInternal( $term, $fulltext ) {
+ global $wgContLang;
+
+ if ( !$this->fulltextSearchSupported() ) {
+ return null;
+ }
+
+ $filteredTerm = $this->filter( $wgContLang->lc( $term ) );
+ $resultSet = $this->db->query( $this->getQuery( $filteredTerm, $fulltext ) );
+
+ $total = null;
+ $totalResult = $this->db->query( $this->getCountQuery( $filteredTerm, $fulltext ) );
+ $row = $totalResult->fetchObject();
+ if ( $row ) {
+ $total = intval( $row->c );
+ }
+ $totalResult->free();
+
+ return new SqlSearchResultSet( $resultSet, $this->searchTerms, $total );
+ }
+
+ /**
+ * Return a partial WHERE clause to limit the search to the given namespaces
+ * @return string
+ */
+ function queryNamespaces() {
+ if ( is_null( $this->namespaces ) ) {
+ return ''; # search all
+ }
+ if ( !count( $this->namespaces ) ) {
+ $namespaces = '0';
+ } else {
+ $namespaces = $this->db->makeList( $this->namespaces );
+ }
+ return 'AND page_namespace IN (' . $namespaces . ')';
+ }
+
+ /**
+ * Returns a query with limit for number of results set.
+ * @param string $sql
+ * @return string
+ */
+ function limitResult( $sql ) {
+ return $this->db->limitResult( $sql, $this->limit, $this->offset );
+ }
+
+ /**
+ * Construct the full SQL query to do the search.
+ * The guts shoulds be constructed in queryMain()
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return string
+ */
+ function getQuery( $filteredTerm, $fulltext ) {
+ return $this->limitResult(
+ $this->queryMain( $filteredTerm, $fulltext ) . ' ' .
+ $this->queryNamespaces()
+ );
+ }
+
+ /**
+ * Picks which field to index on, depending on what type of query.
+ * @param bool $fulltext
+ * @return string
+ */
+ function getIndexField( $fulltext ) {
+ return $fulltext ? 'si_text' : 'si_title';
+ }
+
+ /**
+ * Get the base part of the search query.
+ *
+ * @param string $filteredTerm
+ * @param bool $fulltext
+ * @return string
+ */
+ function queryMain( $filteredTerm, $fulltext ) {
+ $match = $this->parseQuery( $filteredTerm, $fulltext );
+ $page = $this->db->tableName( 'page' );
+ $searchindex = $this->db->tableName( 'searchindex' );
+ return "SELECT $searchindex.rowid, page_namespace, page_title " .
+ "FROM $page,$searchindex " .
+ "WHERE page_id=$searchindex.rowid AND $match";
+ }
+
+ function getCountQuery( $filteredTerm, $fulltext ) {
+ $match = $this->parseQuery( $filteredTerm, $fulltext );
+ $page = $this->db->tableName( 'page' );
+ $searchindex = $this->db->tableName( 'searchindex' );
+ return "SELECT COUNT(*) AS c " .
+ "FROM $page,$searchindex " .
+ "WHERE page_id=$searchindex.rowid AND $match " .
+ $this->queryNamespaces();
+ }
+
+ /**
+ * Create or update the search index record for the given page.
+ * Title and text should be pre-processed.
+ *
+ * @param int $id
+ * @param string $title
+ * @param string $text
+ */
+ function update( $id, $title, $text ) {
+ if ( !$this->fulltextSearchSupported() ) {
+ return;
+ }
+ // @todo find a method to do it in a single request,
+ // couldn't do it so far due to typelessness of FTS3 tables.
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->delete( 'searchindex', [ 'rowid' => $id ], __METHOD__ );
+
+ $dbw->insert( 'searchindex',
+ [
+ 'rowid' => $id,
+ 'si_title' => $title,
+ 'si_text' => $text
+ ], __METHOD__ );
+ }
+
+ /**
+ * Update a search index record's title only.
+ * Title should be pre-processed.
+ *
+ * @param int $id
+ * @param string $title
+ */
+ function updateTitle( $id, $title ) {
+ if ( !$this->fulltextSearchSupported() ) {
+ return;
+ }
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->update( 'searchindex',
+ [ 'si_title' => $title ],
+ [ 'rowid' => $id ],
+ __METHOD__ );
+ }
+}
diff --git a/www/wiki/includes/search/SearchSuggestion.php b/www/wiki/includes/search/SearchSuggestion.php
new file mode 100644
index 00000000..7f433db4
--- /dev/null
+++ b/www/wiki/includes/search/SearchSuggestion.php
@@ -0,0 +1,185 @@
+<?php
+
+/**
+ * Search suggestion
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * A search suggestion
+ */
+class SearchSuggestion {
+ /**
+ * @var string the suggestion
+ */
+ private $text;
+
+ /**
+ * @var string the suggestion URL
+ */
+ private $url;
+
+ /**
+ * @var Title|null the suggested title
+ */
+ private $suggestedTitle;
+
+ /**
+ * NOTE: even if suggestedTitle is a redirect suggestedTitleID
+ * is the ID of the target page.
+ * @var int|null the suggested title ID
+ */
+ private $suggestedTitleID;
+
+ /**
+ * @var float|null The suggestion score
+ */
+ private $score;
+
+ /**
+ * Construct a new suggestion
+ * @param float $score the suggestion score
+ * @param string|null $text the suggestion text
+ * @param Title|null $suggestedTitle the suggested title
+ * @param int|null $suggestedTitleID the suggested title ID
+ */
+ public function __construct( $score, $text = null, Title $suggestedTitle = null,
+ $suggestedTitleID = null ) {
+ $this->score = $score;
+ $this->text = $text;
+ if ( $suggestedTitle ) {
+ $this->setSuggestedTitle( $suggestedTitle );
+ }
+ $this->suggestedTitleID = $suggestedTitleID;
+ }
+
+ /**
+ * The suggestion text
+ * @return string
+ */
+ public function getText() {
+ return $this->text;
+ }
+
+ /**
+ * Set the suggestion text.
+ * @param string $text
+ * @param bool $setTitle Should we also update the title?
+ */
+ public function setText( $text, $setTitle = true ) {
+ $this->text = $text;
+ if ( $setTitle && $text !== '' && $text !== null ) {
+ $this->setSuggestedTitle( Title::makeTitle( 0, $text ) );
+ }
+ }
+
+ /**
+ * Title object in the case this suggestion is based on a title.
+ * May return null if the suggestion is not a Title.
+ * @return Title|null
+ */
+ public function getSuggestedTitle() {
+ return $this->suggestedTitle;
+ }
+
+ /**
+ * Set the suggested title
+ * @param Title|null $title
+ */
+ public function setSuggestedTitle( Title $title = null ) {
+ $this->suggestedTitle = $title;
+ if ( $title !== null ) {
+ $this->url = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+ }
+ }
+
+ /**
+ * Title ID in the case this suggestion is based on a title.
+ * May return null if the suggestion is not a Title.
+ * @return int|null
+ */
+ public function getSuggestedTitleID() {
+ return $this->suggestedTitleID;
+ }
+
+ /**
+ * Set the suggested title ID
+ * @param int|null $suggestedTitleID
+ */
+ public function setSuggestedTitleID( $suggestedTitleID = null ) {
+ $this->suggestedTitleID = $suggestedTitleID;
+ }
+
+ /**
+ * Suggestion score
+ * @return float Suggestion score
+ */
+ public function getScore() {
+ return $this->score;
+ }
+
+ /**
+ * Set the suggestion score
+ * @param float $score
+ */
+ public function setScore( $score ) {
+ $this->score = $score;
+ }
+
+ /**
+ * Suggestion URL, can be the link to the Title or maybe in the
+ * future a link to the search results for this search suggestion.
+ * @return string Suggestion URL
+ */
+ public function getURL() {
+ return $this->url;
+ }
+
+ /**
+ * Set the suggestion URL
+ * @param string $url
+ */
+ public function setURL( $url ) {
+ $this->url = $url;
+ }
+
+ /**
+ * Create suggestion from Title
+ * @param float $score Suggestions score
+ * @param Title $title
+ * @return SearchSuggestion
+ */
+ public static function fromTitle( $score, Title $title ) {
+ return new self( $score, $title->getPrefixedText(), $title, $title->getArticleID() );
+ }
+
+ /**
+ * Create suggestion from text
+ * Will also create a title if text if not empty.
+ * @param float $score Suggestions score
+ * @param string $text
+ * @return SearchSuggestion
+ */
+ public static function fromText( $score, $text ) {
+ $suggestion = new self( $score, $text );
+ if ( $text ) {
+ $suggestion->setSuggestedTitle( Title::makeTitle( 0, $text ) );
+ }
+ return $suggestion;
+ }
+
+}
diff --git a/www/wiki/includes/search/SearchSuggestionSet.php b/www/wiki/includes/search/SearchSuggestionSet.php
new file mode 100644
index 00000000..aced5e18
--- /dev/null
+++ b/www/wiki/includes/search/SearchSuggestionSet.php
@@ -0,0 +1,212 @@
+<?php
+
+/**
+ * Search suggestion sets
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * A set of search suggestions.
+ * The set is always ordered by score, with the best match first.
+ */
+class SearchSuggestionSet {
+ /**
+ * @var SearchSuggestion[]
+ */
+ private $suggestions = [];
+
+ /**
+ *
+ * @var array
+ */
+ private $pageMap = [];
+
+ /**
+ * Builds a new set of suggestions.
+ *
+ * NOTE: the array should be sorted by score (higher is better),
+ * in descending order.
+ * SearchSuggestionSet will not try to re-order this input array.
+ * Providing an unsorted input array is a mistake and will lead to
+ * unexpected behaviors.
+ *
+ * @param SearchSuggestion[] $suggestions (must be sorted by score)
+ */
+ public function __construct( array $suggestions ) {
+ foreach ( $suggestions as $suggestion ) {
+ $pageID = $suggestion->getSuggestedTitleID();
+ if ( $pageID && empty( $this->pageMap[$pageID] ) ) {
+ $this->pageMap[$pageID] = true;
+ }
+ $this->suggestions[] = $suggestion;
+ }
+ }
+
+ /**
+ * Get the list of suggestions.
+ * @return SearchSuggestion[]
+ */
+ public function getSuggestions() {
+ return $this->suggestions;
+ }
+
+ /**
+ * Call array_map on the suggestions array
+ * @param callback $callback
+ * @return array
+ */
+ public function map( $callback ) {
+ return array_map( $callback, $this->suggestions );
+ }
+
+ /**
+ * Add a new suggestion at the end.
+ * If the score of the new suggestion is greater than the worst one,
+ * the new suggestion score will be updated (worst - 1).
+ *
+ * @param SearchSuggestion $suggestion
+ */
+ public function append( SearchSuggestion $suggestion ) {
+ $pageID = $suggestion->getSuggestedTitleID();
+ if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
+ return;
+ }
+ if ( $this->getSize() > 0 && $suggestion->getScore() >= $this->getWorstScore() ) {
+ $suggestion->setScore( $this->getWorstScore() - 1 );
+ }
+ $this->suggestions[] = $suggestion;
+ if ( $pageID ) {
+ $this->pageMap[$pageID] = true;
+ }
+ }
+
+ /**
+ * Add suggestion set to the end of the current one.
+ * @param SearchSuggestionSet $set
+ */
+ public function appendAll( SearchSuggestionSet $set ) {
+ foreach ( $set->getSuggestions() as $sugg ) {
+ $this->append( $sugg );
+ }
+ }
+
+ /**
+ * Move the suggestion at index $key to the first position
+ * @param string $key
+ */
+ public function rescore( $key ) {
+ $removed = array_splice( $this->suggestions, $key, 1 );
+ unset( $this->pageMap[$removed[0]->getSuggestedTitleID()] );
+ $this->prepend( $removed[0] );
+ }
+
+ /**
+ * Add a new suggestion at the top. If the new suggestion score
+ * is lower than the best one its score will be updated (best + 1)
+ * @param SearchSuggestion $suggestion
+ */
+ public function prepend( SearchSuggestion $suggestion ) {
+ $pageID = $suggestion->getSuggestedTitleID();
+ if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
+ return;
+ }
+ if ( $this->getSize() > 0 && $suggestion->getScore() <= $this->getBestScore() ) {
+ $suggestion->setScore( $this->getBestScore() + 1 );
+ }
+ array_unshift( $this->suggestions, $suggestion );
+ if ( $pageID ) {
+ $this->pageMap[$pageID] = true;
+ }
+ }
+
+ /**
+ * @return float the best score in this suggestion set
+ */
+ public function getBestScore() {
+ if ( empty( $this->suggestions ) ) {
+ return 0;
+ }
+ return $this->suggestions[0]->getScore();
+ }
+
+ /**
+ * @return float the worst score in this set
+ */
+ public function getWorstScore() {
+ if ( empty( $this->suggestions ) ) {
+ return 0;
+ }
+ return end( $this->suggestions )->getScore();
+ }
+
+ /**
+ * @return int the number of suggestion in this set
+ */
+ public function getSize() {
+ return count( $this->suggestions );
+ }
+
+ /**
+ * Remove any extra elements in the suggestions set
+ * @param int $limit the max size of this set.
+ */
+ public function shrink( $limit ) {
+ if ( count( $this->suggestions ) > $limit ) {
+ $this->suggestions = array_slice( $this->suggestions, 0, $limit );
+ }
+ }
+
+ /**
+ * Builds a new set of suggestion based on a title array.
+ * Useful when using a backend that supports only Titles.
+ *
+ * NOTE: Suggestion scores will be generated.
+ *
+ * @param Title[] $titles
+ * @return SearchSuggestionSet
+ */
+ public static function fromTitles( array $titles ) {
+ $score = count( $titles );
+ $suggestions = array_map( function ( $title ) use ( &$score ) {
+ return SearchSuggestion::fromTitle( $score--, $title );
+ }, $titles );
+ return new SearchSuggestionSet( $suggestions );
+ }
+
+ /**
+ * Builds a new set of suggestion based on a string array.
+ *
+ * NOTE: Suggestion scores will be generated.
+ *
+ * @param string[] $titles
+ * @return SearchSuggestionSet
+ */
+ public static function fromStrings( array $titles ) {
+ $score = count( $titles );
+ $suggestions = array_map( function ( $title ) use ( &$score ) {
+ return SearchSuggestion::fromText( $score--, $title );
+ }, $titles );
+ return new SearchSuggestionSet( $suggestions );
+ }
+
+ /**
+ * @return SearchSuggestionSet an empty suggestion set
+ */
+ public static function emptySuggestionSet() {
+ return new SearchSuggestionSet( [] );
+ }
+}
diff --git a/www/wiki/includes/search/SqlSearchResultSet.php b/www/wiki/includes/search/SqlSearchResultSet.php
new file mode 100644
index 00000000..53d09e82
--- /dev/null
+++ b/www/wiki/includes/search/SqlSearchResultSet.php
@@ -0,0 +1,69 @@
+<?php
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * This class is used for different SQL-based search engines shipped with MediaWiki
+ * @ingroup Search
+ */
+class SqlSearchResultSet extends SearchResultSet {
+ protected $resultSet;
+ protected $terms;
+ protected $totalHits;
+
+ function __construct( ResultWrapper $resultSet, $terms, $total = null ) {
+ $this->resultSet = $resultSet;
+ $this->terms = $terms;
+ $this->totalHits = $total;
+ }
+
+ function termMatches() {
+ return $this->terms;
+ }
+
+ function numRows() {
+ if ( $this->resultSet === false ) {
+ return false;
+ }
+
+ return $this->resultSet->numRows();
+ }
+
+ function next() {
+ if ( $this->resultSet === false ) {
+ return false;
+ }
+
+ $row = $this->resultSet->fetchObject();
+ if ( $row === false ) {
+ return false;
+ }
+
+ return SearchResult::newFromTitle(
+ Title::makeTitle( $row->page_namespace, $row->page_title ), $this
+ );
+ }
+
+ function rewind() {
+ if ( $this->resultSet ) {
+ $this->resultSet->rewind();
+ }
+ }
+
+ function free() {
+ if ( $this->resultSet === false ) {
+ return false;
+ }
+
+ $this->resultSet->free();
+ }
+
+ function getTotalHits() {
+ if ( !is_null( $this->totalHits ) ) {
+ return $this->totalHits;
+ } else {
+ // Special:Search expects a number here.
+ return $this->numRows();
+ }
+ }
+}
diff --git a/www/wiki/includes/services/CannotReplaceActiveServiceException.php b/www/wiki/includes/services/CannotReplaceActiveServiceException.php
new file mode 100644
index 00000000..49930737
--- /dev/null
+++ b/www/wiki/includes/services/CannotReplaceActiveServiceException.php
@@ -0,0 +1,43 @@
+<?php
+namespace MediaWiki\Services;
+
+use Exception;
+use RuntimeException;
+
+/**
+ * Exception thrown when trying to replace an already active service.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * Exception thrown when trying to replace an already active service.
+ */
+class CannotReplaceActiveServiceException extends RuntimeException {
+
+ /**
+ * @param string $serviceName
+ * @param Exception|null $previous
+ */
+ public function __construct( $serviceName, Exception $previous = null ) {
+ parent::__construct( "Cannot replace an active service: $serviceName", 0, $previous );
+ }
+
+}
diff --git a/www/wiki/includes/services/ContainerDisabledException.php b/www/wiki/includes/services/ContainerDisabledException.php
new file mode 100644
index 00000000..ede076d9
--- /dev/null
+++ b/www/wiki/includes/services/ContainerDisabledException.php
@@ -0,0 +1,42 @@
+<?php
+namespace MediaWiki\Services;
+
+use Exception;
+use RuntimeException;
+
+/**
+ * Exception thrown when trying to access a service on a disabled container or factory.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * Exception thrown when trying to access a service on a disabled container or factory.
+ */
+class ContainerDisabledException extends RuntimeException {
+
+ /**
+ * @param Exception|null $previous
+ */
+ public function __construct( Exception $previous = null ) {
+ parent::__construct( 'Container disabled!', 0, $previous );
+ }
+
+}
diff --git a/www/wiki/includes/services/DestructibleService.php b/www/wiki/includes/services/DestructibleService.php
new file mode 100644
index 00000000..6ce9af2a
--- /dev/null
+++ b/www/wiki/includes/services/DestructibleService.php
@@ -0,0 +1,45 @@
+<?php
+namespace MediaWiki\Services;
+
+/**
+ * Interface for destructible services.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * DestructibleService defines a standard interface for shutting down a service instance.
+ * The intended use is for a service container to be able to shut down services that should
+ * no longer be used, and allow such services to release any system resources.
+ *
+ * @note There is no expectation that services will be destroyed when the process (or web request)
+ * terminates.
+ */
+interface DestructibleService {
+
+ /**
+ * Notifies the service object that it should expect to no longer be used, and should release
+ * any system resources it may own. The behavior of all service methods becomes undefined after
+ * destroy() has been called. It is recommended that implementing classes should throw an
+ * exception when service methods are accessed after destroy() has been called.
+ */
+ public function destroy();
+
+}
diff --git a/www/wiki/includes/services/NoSuchServiceException.php b/www/wiki/includes/services/NoSuchServiceException.php
new file mode 100644
index 00000000..36e50d2f
--- /dev/null
+++ b/www/wiki/includes/services/NoSuchServiceException.php
@@ -0,0 +1,43 @@
+<?php
+namespace MediaWiki\Services;
+
+use Exception;
+use RuntimeException;
+
+/**
+ * Exception thrown when the requested service is not known.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * Exception thrown when the requested service is not known.
+ */
+class NoSuchServiceException extends RuntimeException {
+
+ /**
+ * @param string $serviceName
+ * @param Exception|null $previous
+ */
+ public function __construct( $serviceName, Exception $previous = null ) {
+ parent::__construct( "No such service: $serviceName", 0, $previous );
+ }
+
+}
diff --git a/www/wiki/includes/services/SalvageableService.php b/www/wiki/includes/services/SalvageableService.php
new file mode 100644
index 00000000..a613050d
--- /dev/null
+++ b/www/wiki/includes/services/SalvageableService.php
@@ -0,0 +1,58 @@
+<?php
+namespace MediaWiki\Services;
+
+/**
+ * Interface for salvageable services.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.28
+ */
+
+/**
+ * SalvageableService defines an interface for services that are able to salvage state from a
+ * previous instance of the same class. The intent is to allow new service instances to re-use
+ * resources that would be expensive to re-create, such as cached data or network connections.
+ *
+ * @note There is no expectation that services will be destroyed when the process (or web request)
+ * terminates.
+ */
+interface SalvageableService {
+
+ /**
+ * Re-uses state from $other. $other must not be used after being passed to salvage(),
+ * and should be considered to be destroyed.
+ *
+ * @note Implementations are responsible for determining what parts of $other can be re-used
+ * safely. In particular, implementations should check that the relevant configuration of
+ * $other is the same as in $this before re-using resources from $other.
+ *
+ * @note Implementations must take care to detach any re-used resources from the original
+ * service instance. If $other is destroyed later, resources that are now used by the
+ * new service instance must not be affected.
+ *
+ * @note If $other is a DestructibleService, implementations should make sure that $other
+ * is in destroyed state after salvage finished. This may be done by calling $other->destroy()
+ * after carefully detaching all relevant resources.
+ *
+ * @param SalvageableService $other The object to salvage state from. $other must have the
+ * exact same type as $this.
+ */
+ public function salvage( SalvageableService $other );
+
+}
diff --git a/www/wiki/includes/services/ServiceAlreadyDefinedException.php b/www/wiki/includes/services/ServiceAlreadyDefinedException.php
new file mode 100644
index 00000000..c6344d39
--- /dev/null
+++ b/www/wiki/includes/services/ServiceAlreadyDefinedException.php
@@ -0,0 +1,45 @@
+<?php
+namespace MediaWiki\Services;
+
+use Exception;
+use RuntimeException;
+
+/**
+ * Exception thrown when a service was already defined, but the
+ * caller expected it to not exist.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * Exception thrown when a service was already defined, but the
+ * caller expected it to not exist.
+ */
+class ServiceAlreadyDefinedException extends RuntimeException {
+
+ /**
+ * @param string $serviceName
+ * @param Exception|null $previous
+ */
+ public function __construct( $serviceName, Exception $previous = null ) {
+ parent::__construct( "Service already defined: $serviceName", 0, $previous );
+ }
+
+}
diff --git a/www/wiki/includes/services/ServiceContainer.php b/www/wiki/includes/services/ServiceContainer.php
new file mode 100644
index 00000000..9f09e22f
--- /dev/null
+++ b/www/wiki/includes/services/ServiceContainer.php
@@ -0,0 +1,378 @@
+<?php
+namespace MediaWiki\Services;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Generic service container.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * ServiceContainer provides a generic service to manage named services using
+ * lazy instantiation based on instantiator callback functions.
+ *
+ * Services managed by an instance of ServiceContainer may or may not implement
+ * a common interface.
+ *
+ * @note When using ServiceContainer to manage a set of services, consider
+ * creating a wrapper or a subclass that provides access to the services via
+ * getter methods with more meaningful names and more specific return type
+ * declarations.
+ *
+ * @see docs/injection.txt for an overview of using dependency injection in the
+ * MediaWiki code base.
+ */
+class ServiceContainer implements DestructibleService {
+
+ /**
+ * @var object[]
+ */
+ private $services = [];
+
+ /**
+ * @var callable[]
+ */
+ private $serviceInstantiators = [];
+
+ /**
+ * @var bool[] disabled status, per service name
+ */
+ private $disabled = [];
+
+ /**
+ * @var array
+ */
+ private $extraInstantiationParams;
+
+ /**
+ * @var bool
+ */
+ private $destroyed = false;
+
+ /**
+ * @param array $extraInstantiationParams Any additional parameters to be passed to the
+ * instantiator function when creating a service. This is typically used to provide
+ * access to additional ServiceContainers or Config objects.
+ */
+ public function __construct( array $extraInstantiationParams = [] ) {
+ $this->extraInstantiationParams = $extraInstantiationParams;
+ }
+
+ /**
+ * Destroys all contained service instances that implement the DestructibleService
+ * interface. This will render all services obtained from this MediaWikiServices
+ * instance unusable. In particular, this will disable access to the storage backend
+ * via any of these services. Any future call to getService() will throw an exception.
+ *
+ * @see resetGlobalInstance()
+ */
+ public function destroy() {
+ foreach ( $this->getServiceNames() as $name ) {
+ $service = $this->peekService( $name );
+ if ( $service !== null && $service instanceof DestructibleService ) {
+ $service->destroy();
+ }
+ }
+
+ $this->destroyed = true;
+ }
+
+ /**
+ * @param array $wiringFiles A list of PHP files to load wiring information from.
+ * Each file is loaded using PHP's include mechanism. Each file is expected to
+ * return an associative array that maps service names to instantiator functions.
+ */
+ public function loadWiringFiles( array $wiringFiles ) {
+ foreach ( $wiringFiles as $file ) {
+ // the wiring file is required to return an array of instantiators.
+ $wiring = require $file;
+
+ Assert::postcondition(
+ is_array( $wiring ),
+ "Wiring file $file is expected to return an array!"
+ );
+
+ $this->applyWiring( $wiring );
+ }
+ }
+
+ /**
+ * Registers multiple services (aka a "wiring").
+ *
+ * @param array $serviceInstantiators An associative array mapping service names to
+ * instantiator functions.
+ */
+ public function applyWiring( array $serviceInstantiators ) {
+ Assert::parameterElementType( 'callable', $serviceInstantiators, '$serviceInstantiators' );
+
+ foreach ( $serviceInstantiators as $name => $instantiator ) {
+ $this->defineService( $name, $instantiator );
+ }
+ }
+
+ /**
+ * Imports all wiring defined in $container. Wiring defined in $container
+ * will override any wiring already defined locally. However, already
+ * existing service instances will be preserved.
+ *
+ * @since 1.28
+ *
+ * @param ServiceContainer $container
+ * @param string[] $skip A list of service names to skip during import
+ */
+ public function importWiring( ServiceContainer $container, $skip = [] ) {
+ $newInstantiators = array_diff_key(
+ $container->serviceInstantiators,
+ array_flip( $skip )
+ );
+
+ $this->serviceInstantiators = array_merge(
+ $this->serviceInstantiators,
+ $newInstantiators
+ );
+ }
+
+ /**
+ * Returns true if a service is defined for $name, that is, if a call to getService( $name )
+ * would return a service instance.
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function hasService( $name ) {
+ return isset( $this->serviceInstantiators[$name] );
+ }
+
+ /**
+ * Returns the service instance for $name only if that service has already been instantiated.
+ * This is intended for situations where services get destroyed/cleaned up, so we can
+ * avoid creating a service just to destroy it again.
+ *
+ * @note This is intended for internal use and for test fixtures.
+ * Application logic should use getService() instead.
+ *
+ * @see getService().
+ *
+ * @param string $name
+ *
+ * @return object|null The service instance, or null if the service has not yet been instantiated.
+ * @throws RuntimeException if $name does not refer to a known service.
+ */
+ public function peekService( $name ) {
+ if ( !$this->hasService( $name ) ) {
+ throw new NoSuchServiceException( $name );
+ }
+
+ return isset( $this->services[$name] ) ? $this->services[$name] : null;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getServiceNames() {
+ return array_keys( $this->serviceInstantiators );
+ }
+
+ /**
+ * Define a new service. The service must not be known already.
+ *
+ * @see getService().
+ * @see replaceService().
+ *
+ * @param string $name The name of the service to register, for use with getService().
+ * @param callable $instantiator Callback that returns a service instance.
+ * Will be called with this MediaWikiServices instance as the only parameter.
+ * Any extra instantiation parameters provided to the constructor will be
+ * passed as subsequent parameters when invoking the instantiator.
+ *
+ * @throws RuntimeException if there is already a service registered as $name.
+ */
+ public function defineService( $name, callable $instantiator ) {
+ Assert::parameterType( 'string', $name, '$name' );
+
+ if ( $this->hasService( $name ) ) {
+ throw new ServiceAlreadyDefinedException( $name );
+ }
+
+ $this->serviceInstantiators[$name] = $instantiator;
+ }
+
+ /**
+ * Replace an already defined service.
+ *
+ * @see defineService().
+ *
+ * @note This causes any previously instantiated instance of the service to be discarded.
+ *
+ * @param string $name The name of the service to register.
+ * @param callable $instantiator Callback function that returns a service instance.
+ * Will be called with this MediaWikiServices instance as the only parameter.
+ * The instantiator must return a service compatible with the originally defined service.
+ * Any extra instantiation parameters provided to the constructor will be
+ * passed as subsequent parameters when invoking the instantiator.
+ *
+ * @throws RuntimeException if $name is not a known service.
+ */
+ public function redefineService( $name, callable $instantiator ) {
+ Assert::parameterType( 'string', $name, '$name' );
+
+ if ( !$this->hasService( $name ) ) {
+ throw new NoSuchServiceException( $name );
+ }
+
+ if ( isset( $this->services[$name] ) ) {
+ throw new CannotReplaceActiveServiceException( $name );
+ }
+
+ $this->serviceInstantiators[$name] = $instantiator;
+ unset( $this->disabled[$name] );
+ }
+
+ /**
+ * Disables a service.
+ *
+ * @note Attempts to call getService() for a disabled service will result
+ * in a DisabledServiceException. Calling peekService for a disabled service will
+ * return null. Disabled services are listed by getServiceNames(). A disabled service
+ * can be enabled again using redefineService().
+ *
+ * @note If the service was already active (that is, instantiated) when getting disabled,
+ * and the service instance implements DestructibleService, destroy() is called on the
+ * service instance.
+ *
+ * @see redefineService()
+ * @see resetService()
+ *
+ * @param string $name The name of the service to disable.
+ *
+ * @throws RuntimeException if $name is not a known service.
+ */
+ public function disableService( $name ) {
+ $this->resetService( $name );
+
+ $this->disabled[$name] = true;
+ }
+
+ /**
+ * Resets a service by dropping the service instance.
+ * If the service instances implements DestructibleService, destroy()
+ * is called on the service instance.
+ *
+ * @warning This is generally unsafe! Other services may still retain references
+ * to the stale service instance, leading to failures and inconsistencies. Subclasses
+ * may use this method to reset specific services under specific instances, but
+ * it should not be exposed to application logic.
+ *
+ * @note This is declared final so subclasses can not interfere with the expectations
+ * disableService() has when calling resetService().
+ *
+ * @see redefineService()
+ * @see disableService().
+ *
+ * @param string $name The name of the service to reset.
+ * @param bool $destroy Whether the service instance should be destroyed if it exists.
+ * When set to false, any existing service instance will effectively be detached
+ * from the container.
+ *
+ * @throws RuntimeException if $name is not a known service.
+ */
+ final protected function resetService( $name, $destroy = true ) {
+ Assert::parameterType( 'string', $name, '$name' );
+
+ $instance = $this->peekService( $name );
+
+ if ( $destroy && $instance instanceof DestructibleService ) {
+ $instance->destroy();
+ }
+
+ unset( $this->services[$name] );
+ unset( $this->disabled[$name] );
+ }
+
+ /**
+ * Returns a service object of the kind associated with $name.
+ * Services instances are instantiated lazily, on demand.
+ * This method may or may not return the same service instance
+ * when called multiple times with the same $name.
+ *
+ * @note Rather than calling this method directly, it is recommended to provide
+ * getters with more meaningful names and more specific return types, using
+ * a subclass or wrapper.
+ *
+ * @see redefineService().
+ *
+ * @param string $name The service name
+ *
+ * @throws NoSuchServiceException if $name is not a known service.
+ * @throws ContainerDisabledException if this container has already been destroyed.
+ * @throws ServiceDisabledException if the requested service has been disabled.
+ *
+ * @return object The service instance
+ */
+ public function getService( $name ) {
+ if ( $this->destroyed ) {
+ throw new ContainerDisabledException();
+ }
+
+ if ( isset( $this->disabled[$name] ) ) {
+ throw new ServiceDisabledException( $name );
+ }
+
+ if ( !isset( $this->services[$name] ) ) {
+ $this->services[$name] = $this->createService( $name );
+ }
+
+ return $this->services[$name];
+ }
+
+ /**
+ * @param string $name
+ *
+ * @throws InvalidArgumentException if $name is not a known service.
+ * @return object
+ */
+ private function createService( $name ) {
+ if ( isset( $this->serviceInstantiators[$name] ) ) {
+ $service = call_user_func_array(
+ $this->serviceInstantiators[$name],
+ array_merge( [ $this ], $this->extraInstantiationParams )
+ );
+ // NOTE: when adding more wiring logic here, make sure copyWiring() is kept in sync!
+ } else {
+ throw new NoSuchServiceException( $name );
+ }
+
+ return $service;
+ }
+
+ /**
+ * @param string $name
+ * @return bool Whether the service is disabled
+ * @since 1.28
+ */
+ public function isServiceDisabled( $name ) {
+ return isset( $this->disabled[$name] );
+ }
+}
diff --git a/www/wiki/includes/services/ServiceDisabledException.php b/www/wiki/includes/services/ServiceDisabledException.php
new file mode 100644
index 00000000..ae15b7ce
--- /dev/null
+++ b/www/wiki/includes/services/ServiceDisabledException.php
@@ -0,0 +1,43 @@
+<?php
+namespace MediaWiki\Services;
+
+use Exception;
+use RuntimeException;
+
+/**
+ * Exception thrown when trying to access a disabled service.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @since 1.27
+ */
+
+/**
+ * Exception thrown when trying to access a disabled service.
+ */
+class ServiceDisabledException extends RuntimeException {
+
+ /**
+ * @param string $serviceName
+ * @param Exception|null $previous
+ */
+ public function __construct( $serviceName, Exception $previous = null ) {
+ parent::__construct( "Service disabled: $serviceName", 0, $previous );
+ }
+
+}
diff --git a/www/wiki/includes/session/BotPasswordSessionProvider.php b/www/wiki/includes/session/BotPasswordSessionProvider.php
new file mode 100644
index 00000000..a588aeea
--- /dev/null
+++ b/www/wiki/includes/session/BotPasswordSessionProvider.php
@@ -0,0 +1,189 @@
+<?php
+/**
+ * Session provider for bot passwords
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use BotPassword;
+use User;
+use WebRequest;
+
+/**
+ * Session provider for bot passwords
+ * @since 1.27
+ */
+class BotPasswordSessionProvider extends ImmutableSessionProviderWithCookie {
+
+ /**
+ * @param array $params Keys include:
+ * - priority: (required) Set the priority
+ * - sessionCookieName: Session cookie name. Default is '_BPsession'.
+ * - sessionCookieOptions: Options to pass to WebResponse::setCookie().
+ */
+ public function __construct( array $params = [] ) {
+ if ( !isset( $params['sessionCookieName'] ) ) {
+ $params['sessionCookieName'] = '_BPsession';
+ }
+ parent::__construct( $params );
+
+ if ( !isset( $params['priority'] ) ) {
+ throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
+ }
+ if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
+ $params['priority'] > SessionInfo::MAX_PRIORITY
+ ) {
+ throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
+ }
+
+ $this->priority = $params['priority'];
+ }
+
+ public function provideSessionInfo( WebRequest $request ) {
+ // Only relevant for the API
+ if ( !defined( 'MW_API' ) ) {
+ return null;
+ }
+
+ // Enabled?
+ if ( !$this->config->get( 'EnableBotPasswords' ) ) {
+ return null;
+ }
+
+ // Have a session ID?
+ $id = $this->getSessionIdFromCookie( $request );
+ if ( $id === null ) {
+ return null;
+ }
+
+ return new SessionInfo( $this->priority, [
+ 'provider' => $this,
+ 'id' => $id,
+ 'persisted' => true
+ ] );
+ }
+
+ public function newSessionInfo( $id = null ) {
+ // We don't activate by default
+ return null;
+ }
+
+ /**
+ * Create a new session for a request
+ * @param User $user
+ * @param BotPassword $bp
+ * @param WebRequest $request
+ * @return Session
+ */
+ public function newSessionForRequest( User $user, BotPassword $bp, WebRequest $request ) {
+ $id = $this->getSessionIdFromCookie( $request );
+ $info = new SessionInfo( SessionInfo::MAX_PRIORITY, [
+ 'provider' => $this,
+ 'id' => $id,
+ 'userInfo' => UserInfo::newFromUser( $user, true ),
+ 'persisted' => $id !== null,
+ 'metadata' => [
+ 'centralId' => $bp->getUserCentralId(),
+ 'appId' => $bp->getAppId(),
+ 'token' => $bp->getToken(),
+ 'rights' => \MWGrants::getGrantRights( $bp->getGrants() ),
+ ],
+ ] );
+ $session = $this->getManager()->getSessionFromInfo( $info, $request );
+ $session->persist();
+ return $session;
+ }
+
+ public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
+ $missingKeys = array_diff(
+ [ 'centralId', 'appId', 'token' ],
+ array_keys( $metadata )
+ );
+ if ( $missingKeys ) {
+ $this->logger->info( 'Session "{session}": Missing metadata: {missing}', [
+ 'session' => $info,
+ 'missing' => implode( ', ', $missingKeys ),
+ ] );
+ return false;
+ }
+
+ $bp = BotPassword::newFromCentralId( $metadata['centralId'], $metadata['appId'] );
+ if ( !$bp ) {
+ $this->logger->info(
+ 'Session "{session}": No BotPassword for {centralId} {appId}',
+ [
+ 'session' => $info,
+ 'centralId' => $metadata['centralId'],
+ 'appId' => $metadata['appId'],
+ ] );
+ return false;
+ }
+
+ if ( !hash_equals( $metadata['token'], $bp->getToken() ) ) {
+ $this->logger->info( 'Session "{session}": BotPassword token check failed', [
+ 'session' => $info,
+ 'centralId' => $metadata['centralId'],
+ 'appId' => $metadata['appId'],
+ ] );
+ return false;
+ }
+
+ $status = $bp->getRestrictions()->check( $request );
+ if ( !$status->isOK() ) {
+ $this->logger->info(
+ 'Session "{session}": Restrictions check failed',
+ [
+ 'session' => $info,
+ 'restrictions' => $status->getValue(),
+ 'centralId' => $metadata['centralId'],
+ 'appId' => $metadata['appId'],
+ ] );
+ return false;
+ }
+
+ // Update saved rights
+ $metadata['rights'] = \MWGrants::getGrantRights( $bp->getGrants() );
+
+ return true;
+ }
+
+ /**
+ * @codeCoverageIgnore
+ * @inheritDoc
+ */
+ public function preventSessionsForUser( $username ) {
+ BotPassword::removeAllPasswordsForUser( $username );
+ }
+
+ public function getAllowedUserRights( SessionBackend $backend ) {
+ if ( $backend->getProvider() !== $this ) {
+ throw new \InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
+ }
+ $data = $backend->getProviderMetadata();
+ if ( $data && isset( $data['rights'] ) && is_array( $data['rights'] ) ) {
+ return $data['rights'];
+ }
+
+ // Should never happen
+ $this->logger->debug( __METHOD__ . ': No provider metadata, returning no rights allowed' );
+ return [];
+ }
+}
diff --git a/www/wiki/includes/session/CookieSessionProvider.php b/www/wiki/includes/session/CookieSessionProvider.php
new file mode 100644
index 00000000..74925bd7
--- /dev/null
+++ b/www/wiki/includes/session/CookieSessionProvider.php
@@ -0,0 +1,439 @@
+<?php
+/**
+ * MediaWiki cookie-based session provider interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Config;
+use User;
+use WebRequest;
+
+/**
+ * A CookieSessionProvider persists sessions using cookies
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+class CookieSessionProvider extends SessionProvider {
+
+ protected $params = [];
+ protected $cookieOptions = [];
+
+ /**
+ * @param array $params Keys include:
+ * - priority: (required) Priority of the returned sessions
+ * - callUserSetCookiesHook: Whether to call the deprecated hook
+ * - sessionName: Session cookie name. Doesn't honor 'prefix'. Defaults to
+ * $wgSessionName, or $wgCookiePrefix . '_session' if that is unset.
+ * - cookieOptions: Options to pass to WebRequest::setCookie():
+ * - prefix: Cookie prefix, defaults to $wgCookiePrefix
+ * - path: Cookie path, defaults to $wgCookiePath
+ * - domain: Cookie domain, defaults to $wgCookieDomain
+ * - secure: Cookie secure flag, defaults to $wgCookieSecure
+ * - httpOnly: Cookie httpOnly flag, defaults to $wgCookieHttpOnly
+ */
+ public function __construct( $params = [] ) {
+ parent::__construct();
+
+ $params += [
+ 'cookieOptions' => [],
+ // @codeCoverageIgnoreStart
+ ];
+ // @codeCoverageIgnoreEnd
+
+ if ( !isset( $params['priority'] ) ) {
+ throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
+ }
+ if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
+ $params['priority'] > SessionInfo::MAX_PRIORITY
+ ) {
+ throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
+ }
+
+ if ( !is_array( $params['cookieOptions'] ) ) {
+ throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
+ }
+
+ $this->priority = $params['priority'];
+ $this->cookieOptions = $params['cookieOptions'];
+ $this->params = $params;
+ unset( $this->params['priority'] );
+ unset( $this->params['cookieOptions'] );
+ }
+
+ public function setConfig( Config $config ) {
+ parent::setConfig( $config );
+
+ // @codeCoverageIgnoreStart
+ $this->params += [
+ // @codeCoverageIgnoreEnd
+ 'callUserSetCookiesHook' => false,
+ 'sessionName' =>
+ $config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session',
+ ];
+
+ // @codeCoverageIgnoreStart
+ $this->cookieOptions += [
+ // @codeCoverageIgnoreEnd
+ 'prefix' => $config->get( 'CookiePrefix' ),
+ 'path' => $config->get( 'CookiePath' ),
+ 'domain' => $config->get( 'CookieDomain' ),
+ 'secure' => $config->get( 'CookieSecure' ),
+ 'httpOnly' => $config->get( 'CookieHttpOnly' ),
+ ];
+ }
+
+ public function provideSessionInfo( WebRequest $request ) {
+ $sessionId = $this->getCookie( $request, $this->params['sessionName'], '' );
+ $info = [
+ 'provider' => $this,
+ 'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
+ ];
+ if ( SessionManager::validateSessionId( $sessionId ) ) {
+ $info['id'] = $sessionId;
+ $info['persisted'] = true;
+ }
+
+ list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request );
+ if ( $userId !== null ) {
+ try {
+ $userInfo = UserInfo::newFromId( $userId );
+ } catch ( \InvalidArgumentException $ex ) {
+ return null;
+ }
+
+ // Sanity check
+ if ( $userName !== null && $userInfo->getName() !== $userName ) {
+ $this->logger->warning(
+ 'Session "{session}" requested with mismatched UserID and UserName cookies.',
+ [
+ 'session' => $sessionId,
+ 'mismatch' => [
+ 'userid' => $userId,
+ 'cookie_username' => $userName,
+ 'username' => $userInfo->getName(),
+ ],
+ ] );
+ return null;
+ }
+
+ if ( $token !== null ) {
+ if ( !hash_equals( $userInfo->getToken(), $token ) ) {
+ $this->logger->warning(
+ 'Session "{session}" requested with invalid Token cookie.',
+ [
+ 'session' => $sessionId,
+ 'userid' => $userId,
+ 'username' => $userInfo->getName(),
+ ] );
+ return null;
+ }
+ $info['userInfo'] = $userInfo->verified();
+ $info['persisted'] = true; // If we have user+token, it should be
+ } elseif ( isset( $info['id'] ) ) {
+ $info['userInfo'] = $userInfo;
+ } else {
+ // No point in returning, loadSessionInfoFromStore() will
+ // reject it anyway.
+ return null;
+ }
+ } elseif ( isset( $info['id'] ) ) {
+ // No UserID cookie, so insist that the session is anonymous.
+ // Note: this event occurs for several normal activities:
+ // * anon visits Special:UserLogin
+ // * anon browsing after seeing Special:UserLogin
+ // * anon browsing after edit or preview
+ $this->logger->debug(
+ 'Session "{session}" requested without UserID cookie',
+ [
+ 'session' => $info['id'],
+ ] );
+ $info['userInfo'] = UserInfo::newAnonymous();
+ } else {
+ // No session ID and no user is the same as an empty session, so
+ // there's no point.
+ return null;
+ }
+
+ return new SessionInfo( $this->priority, $info );
+ }
+
+ public function persistsSessionId() {
+ return true;
+ }
+
+ public function canChangeUser() {
+ return true;
+ }
+
+ public function persistSession( SessionBackend $session, WebRequest $request ) {
+ $response = $request->response();
+ if ( $response->headersSent() ) {
+ // Can't do anything now
+ $this->logger->debug( __METHOD__ . ': Headers already sent' );
+ return;
+ }
+
+ $user = $session->getUser();
+
+ $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
+ $sessionData = $this->sessionDataToExport( $user );
+
+ // Legacy hook
+ if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
+ \Hooks::run( 'UserSetCookies', [ $user, &$sessionData, &$cookies ] );
+ }
+
+ $options = $this->cookieOptions;
+
+ $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
+ if ( $forceHTTPS ) {
+ // Don't set the secure flag if the request came in
+ // over "http", for backwards compat.
+ // @todo Break that backwards compat properly.
+ $options['secure'] = $this->config->get( 'CookieSecure' );
+ }
+
+ $response->setCookie( $this->params['sessionName'], $session->getId(), null,
+ [ 'prefix' => '' ] + $options
+ );
+
+ foreach ( $cookies as $key => $value ) {
+ if ( $value === false ) {
+ $response->clearCookie( $key, $options );
+ } else {
+ $expirationDuration = $this->getLoginCookieExpiration( $key, $session->shouldRememberUser() );
+ $expiration = $expirationDuration ? $expirationDuration + time() : null;
+ $response->setCookie( $key, (string)$value, $expiration, $options );
+ }
+ }
+
+ $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
+ $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
+
+ if ( $sessionData ) {
+ $session->addData( $sessionData );
+ }
+ }
+
+ public function unpersistSession( WebRequest $request ) {
+ $response = $request->response();
+ if ( $response->headersSent() ) {
+ // Can't do anything now
+ $this->logger->debug( __METHOD__ . ': Headers already sent' );
+ return;
+ }
+
+ $cookies = [
+ 'UserID' => false,
+ 'Token' => false,
+ ];
+
+ $response->clearCookie(
+ $this->params['sessionName'], [ 'prefix' => '' ] + $this->cookieOptions
+ );
+
+ foreach ( $cookies as $key => $value ) {
+ $response->clearCookie( $key, $this->cookieOptions );
+ }
+
+ $this->setForceHTTPSCookie( false, null, $request );
+ }
+
+ /**
+ * Set the "forceHTTPS" cookie
+ * @param bool $set Whether the cookie should be set or not
+ * @param SessionBackend|null $backend
+ * @param WebRequest $request
+ */
+ protected function setForceHTTPSCookie(
+ $set, SessionBackend $backend = null, WebRequest $request
+ ) {
+ $response = $request->response();
+ if ( $set ) {
+ if ( $backend->shouldRememberUser() ) {
+ $expirationDuration = $this->getLoginCookieExpiration(
+ 'forceHTTPS',
+ true
+ );
+ $expiration = $expirationDuration ? $expirationDuration + time() : null;
+ } else {
+ $expiration = null;
+ }
+ $response->setCookie( 'forceHTTPS', 'true', $expiration,
+ [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
+ } else {
+ $response->clearCookie( 'forceHTTPS',
+ [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
+ }
+ }
+
+ /**
+ * Set the "logged out" cookie
+ * @param int $loggedOut timestamp
+ * @param WebRequest $request
+ */
+ protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
+ if ( $loggedOut + 86400 > time() &&
+ $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
+ ) {
+ $request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400,
+ $this->cookieOptions );
+ }
+ }
+
+ public function getVaryCookies() {
+ return [
+ // Vary on token and session because those are the real authn
+ // determiners. UserID and UserName don't matter without those.
+ $this->cookieOptions['prefix'] . 'Token',
+ $this->cookieOptions['prefix'] . 'LoggedOut',
+ $this->params['sessionName'],
+ 'forceHTTPS',
+ ];
+ }
+
+ public function suggestLoginUsername( WebRequest $request ) {
+ $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
+ if ( $name !== null ) {
+ $name = User::getCanonicalName( $name, 'usable' );
+ }
+ return $name === false ? null : $name;
+ }
+
+ /**
+ * Fetch the user identity from cookies
+ * @param \WebRequest $request
+ * @return array (string|null $id, string|null $username, string|null $token)
+ */
+ protected function getUserInfoFromCookies( $request ) {
+ $prefix = $this->cookieOptions['prefix'];
+ return [
+ $this->getCookie( $request, 'UserID', $prefix ),
+ $this->getCookie( $request, 'UserName', $prefix ),
+ $this->getCookie( $request, 'Token', $prefix ),
+ ];
+ }
+
+ /**
+ * Get a cookie. Contains an auth-specific hack.
+ * @param \WebRequest $request
+ * @param string $key
+ * @param string $prefix
+ * @param mixed $default
+ * @return mixed
+ */
+ protected function getCookie( $request, $key, $prefix, $default = null ) {
+ $value = $request->getCookie( $key, $prefix, $default );
+ if ( $value === 'deleted' ) {
+ // PHP uses this value when deleting cookies. A legitimate cookie will never have
+ // this value (usernames start with uppercase, token is longer, other auth cookies
+ // are booleans or integers). Seeing this means that in a previous request we told the
+ // client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
+ // not there to avoid invalidating the session.
+ return null;
+ }
+ return $value;
+ }
+
+ /**
+ * Return the data to store in cookies
+ * @param User $user
+ * @param bool $remember
+ * @return array $cookies Set value false to unset the cookie
+ */
+ protected function cookieDataToExport( $user, $remember ) {
+ if ( $user->isAnon() ) {
+ return [
+ 'UserID' => false,
+ 'Token' => false,
+ ];
+ } else {
+ return [
+ 'UserID' => $user->getId(),
+ 'UserName' => $user->getName(),
+ 'Token' => $remember ? (string)$user->getToken() : false,
+ ];
+ }
+ }
+
+ /**
+ * Return extra data to store in the session
+ * @param User $user
+ * @return array $session
+ */
+ protected function sessionDataToExport( $user ) {
+ // If we're calling the legacy hook, we should populate $session
+ // like User::setCookies() did.
+ if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
+ return [
+ 'wsUserID' => $user->getId(),
+ 'wsToken' => $user->getToken(),
+ 'wsUserName' => $user->getName(),
+ ];
+ }
+
+ return [];
+ }
+
+ public function whyNoSession() {
+ return wfMessage( 'sessionprovider-nocookies' );
+ }
+
+ public function getRememberUserDuration() {
+ return min( $this->getLoginCookieExpiration( 'UserID', true ),
+ $this->getLoginCookieExpiration( 'Token', true ) ) ?: null;
+ }
+
+ /**
+ * Gets the list of cookies that must be set to the 'remember me' duration,
+ * if $wgExtendedLoginCookieExpiration is in use.
+ *
+ * @return string[] Array of unprefixed cookie keys
+ */
+ protected function getExtendedLoginCookies() {
+ return [ 'UserID', 'UserName', 'Token' ];
+ }
+
+ /**
+ * Returns the lifespan of the login cookies, in seconds. 0 means until the end of the session.
+ *
+ * Cookies that are session-length do not call this function.
+ *
+ * @param string $cookieName
+ * @param bool $shouldRememberUser Whether the user should be remembered
+ * long-term
+ * @return int Cookie expiration time in seconds; 0 for session cookies
+ */
+ protected function getLoginCookieExpiration( $cookieName, $shouldRememberUser ) {
+ $extendedCookies = $this->getExtendedLoginCookies();
+ $normalExpiration = $this->config->get( 'CookieExpiration' );
+
+ if ( $shouldRememberUser && in_array( $cookieName, $extendedCookies, true ) ) {
+ $extendedExpiration = $this->config->get( 'ExtendedLoginCookieExpiration' );
+
+ return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
+ } else {
+ return (int)$normalExpiration;
+ }
+ }
+}
diff --git a/www/wiki/includes/session/ImmutableSessionProviderWithCookie.php b/www/wiki/includes/session/ImmutableSessionProviderWithCookie.php
new file mode 100644
index 00000000..1cab3d3c
--- /dev/null
+++ b/www/wiki/includes/session/ImmutableSessionProviderWithCookie.php
@@ -0,0 +1,153 @@
+<?php
+/**
+ * MediaWiki session provider base class
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use WebRequest;
+
+/**
+ * An ImmutableSessionProviderWithCookie doesn't persist the user, but
+ * optionally can use a cookie to support multiple IDs per session.
+ *
+ * As mentioned in the documentation for SessionProvider, many methods that are
+ * technically "cannot persist ID" could be turned into "can persist ID but
+ * not changing User" using a session cookie. This class implements such an
+ * optional session cookie.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+abstract class ImmutableSessionProviderWithCookie extends SessionProvider {
+
+ /** @var string|null */
+ protected $sessionCookieName = null;
+ protected $sessionCookieOptions = [];
+
+ /**
+ * @param array $params Keys include:
+ * - sessionCookieName: Session cookie name, if multiple sessions per
+ * client are to be supported.
+ * - sessionCookieOptions: Options to pass to WebResponse::setCookie().
+ */
+ public function __construct( $params = [] ) {
+ parent::__construct();
+
+ if ( isset( $params['sessionCookieName'] ) ) {
+ if ( !is_string( $params['sessionCookieName'] ) ) {
+ throw new \InvalidArgumentException( 'sessionCookieName must be a string' );
+ }
+ $this->sessionCookieName = $params['sessionCookieName'];
+ }
+ if ( isset( $params['sessionCookieOptions'] ) ) {
+ if ( !is_array( $params['sessionCookieOptions'] ) ) {
+ throw new \InvalidArgumentException( 'sessionCookieOptions must be an array' );
+ }
+ $this->sessionCookieOptions = $params['sessionCookieOptions'];
+ }
+ }
+
+ /**
+ * Get the session ID from the cookie, if any.
+ *
+ * Only call this if $this->sessionCookieName !== null. If
+ * sessionCookieName is null, do some logic (probably involving a call to
+ * $this->hashToSessionId()) to create the single session ID corresponding
+ * to this WebRequest instead of calling this method.
+ *
+ * @param WebRequest $request
+ * @return string|null
+ */
+ protected function getSessionIdFromCookie( WebRequest $request ) {
+ if ( $this->sessionCookieName === null ) {
+ throw new \BadMethodCallException(
+ __METHOD__ . ' may not be called when $this->sessionCookieName === null'
+ );
+ }
+
+ $prefix = isset( $this->sessionCookieOptions['prefix'] )
+ ? $this->sessionCookieOptions['prefix']
+ : $this->config->get( 'CookiePrefix' );
+ $id = $request->getCookie( $this->sessionCookieName, $prefix );
+ return SessionManager::validateSessionId( $id ) ? $id : null;
+ }
+
+ public function persistsSessionId() {
+ return $this->sessionCookieName !== null;
+ }
+
+ public function canChangeUser() {
+ return false;
+ }
+
+ public function persistSession( SessionBackend $session, WebRequest $request ) {
+ if ( $this->sessionCookieName === null ) {
+ return;
+ }
+
+ $response = $request->response();
+ if ( $response->headersSent() ) {
+ // Can't do anything now
+ $this->logger->debug( __METHOD__ . ': Headers already sent' );
+ return;
+ }
+
+ $options = $this->sessionCookieOptions;
+ if ( $session->shouldForceHTTPS() || $session->getUser()->requiresHTTPS() ) {
+ $response->setCookie( 'forceHTTPS', 'true', null,
+ [ 'prefix' => '', 'secure' => false ] + $options );
+ $options['secure'] = true;
+ }
+
+ $response->setCookie( $this->sessionCookieName, $session->getId(), null, $options );
+ }
+
+ public function unpersistSession( WebRequest $request ) {
+ if ( $this->sessionCookieName === null ) {
+ return;
+ }
+
+ $response = $request->response();
+ if ( $response->headersSent() ) {
+ // Can't do anything now
+ $this->logger->debug( __METHOD__ . ': Headers already sent' );
+ return;
+ }
+
+ $response->clearCookie( $this->sessionCookieName, $this->sessionCookieOptions );
+ }
+
+ public function getVaryCookies() {
+ if ( $this->sessionCookieName === null ) {
+ return [];
+ }
+
+ $prefix = isset( $this->sessionCookieOptions['prefix'] )
+ ? $this->sessionCookieOptions['prefix']
+ : $this->config->get( 'CookiePrefix' );
+ return [ $prefix . $this->sessionCookieName ];
+ }
+
+ public function whyNoSession() {
+ return wfMessage( 'sessionprovider-nocookies' );
+ }
+}
diff --git a/www/wiki/includes/session/MetadataMergeException.php b/www/wiki/includes/session/MetadataMergeException.php
new file mode 100644
index 00000000..074afe36
--- /dev/null
+++ b/www/wiki/includes/session/MetadataMergeException.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * @section LICENSE
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Exception;
+use UnexpectedValueException;
+
+/**
+ * Subclass of UnexpectedValueException that can be annotated with additional
+ * data for debug logging.
+ *
+ * @copyright © 2016 Wikimedia Foundation and contributors
+ * @since 1.27
+ */
+class MetadataMergeException extends UnexpectedValueException {
+ /** @var array $context */
+ protected $context;
+
+ /**
+ * @param string $message
+ * @param int $code
+ * @param Exception|null $previous
+ * @param array $context Additional context data
+ */
+ public function __construct(
+ $message = '',
+ $code = 0,
+ Exception $previous = null,
+ array $context = []
+ ) {
+ parent::__construct( $message, $code, $previous );
+ $this->context = $context;
+ }
+
+ /**
+ * Get context data.
+ * @return array
+ */
+ public function getContext() {
+ return $this->context;
+ }
+
+ /**
+ * Set context data.
+ * @param array $context
+ */
+ public function setContext( array $context ) {
+ $this->context = $context;
+ }
+}
diff --git a/www/wiki/includes/session/PHPSessionHandler.php b/www/wiki/includes/session/PHPSessionHandler.php
new file mode 100644
index 00000000..b76f0ff6
--- /dev/null
+++ b/www/wiki/includes/session/PHPSessionHandler.php
@@ -0,0 +1,391 @@
+<?php
+/**
+ * Session storage in object cache.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+
+/**
+ * Adapter for PHP's session handling
+ * @ingroup Session
+ * @since 1.27
+ */
+class PHPSessionHandler implements \SessionHandlerInterface {
+ /** @var PHPSessionHandler */
+ protected static $instance = null;
+
+ /** @var bool Whether PHP session handling is enabled */
+ protected $enable = false;
+ protected $warn = true;
+
+ /** @var SessionManager|null */
+ protected $manager;
+
+ /** @var BagOStuff|null */
+ protected $store;
+
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var array Track original session fields for later modification check */
+ protected $sessionFieldCache = [];
+
+ protected function __construct( SessionManager $manager ) {
+ $this->setEnableFlags(
+ \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' )
+ );
+ $manager->setupPHPSessionHandler( $this );
+ }
+
+ /**
+ * Set $this->enable and $this->warn
+ *
+ * Separate just because there doesn't seem to be a good way to test it
+ * otherwise.
+ *
+ * @param string $PHPSessionHandling See $wgPHPSessionHandling
+ */
+ private function setEnableFlags( $PHPSessionHandling ) {
+ switch ( $PHPSessionHandling ) {
+ case 'enable':
+ $this->enable = true;
+ $this->warn = false;
+ break;
+
+ case 'warn':
+ $this->enable = true;
+ $this->warn = true;
+ break;
+
+ case 'disable':
+ $this->enable = false;
+ $this->warn = false;
+ break;
+ }
+ }
+
+ /**
+ * Test whether the handler is installed
+ * @return bool
+ */
+ public static function isInstalled() {
+ return (bool)self::$instance;
+ }
+
+ /**
+ * Test whether the handler is installed and enabled
+ * @return bool
+ */
+ public static function isEnabled() {
+ return self::$instance && self::$instance->enable;
+ }
+
+ /**
+ * Install a session handler for the current web request
+ * @param SessionManager $manager
+ */
+ public static function install( SessionManager $manager ) {
+ if ( self::$instance ) {
+ $manager->setupPHPSessionHandler( self::$instance );
+ return;
+ }
+
+ // @codeCoverageIgnoreStart
+ if ( defined( 'MW_NO_SESSION_HANDLER' ) ) {
+ throw new \BadMethodCallException( 'MW_NO_SESSION_HANDLER is defined' );
+ }
+ // @codeCoverageIgnoreEnd
+
+ self::$instance = new self( $manager );
+
+ // Close any auto-started session, before we replace it
+ session_write_close();
+
+ // Tell PHP not to mess with cookies itself
+ ini_set( 'session.use_cookies', 0 );
+ ini_set( 'session.use_trans_sid', 0 );
+
+ // T124510: Disable automatic PHP session related cache headers.
+ // MediaWiki adds it's own headers and the default PHP behavior may
+ // set headers such as 'Pragma: no-cache' that cause problems with
+ // some user agents.
+ session_cache_limiter( '' );
+
+ // Also set a sane serialization handler
+ \Wikimedia\PhpSessionSerializer::setSerializeHandler();
+
+ // Register this as the save handler, and register an appropriate
+ // shutdown function.
+ session_set_save_handler( self::$instance, true );
+ }
+
+ /**
+ * Set the manager, store, and logger
+ * @private Use self::install().
+ * @param SessionManager $manager
+ * @param BagOStuff $store
+ * @param LoggerInterface $logger
+ */
+ public function setManager(
+ SessionManager $manager, BagOStuff $store, LoggerInterface $logger
+ ) {
+ if ( $this->manager !== $manager ) {
+ // Close any existing session before we change stores
+ if ( $this->manager ) {
+ session_write_close();
+ }
+ $this->manager = $manager;
+ $this->store = $store;
+ $this->logger = $logger;
+ \Wikimedia\PhpSessionSerializer::setLogger( $this->logger );
+ }
+ }
+
+ /**
+ * Workaround for PHP5 bug
+ *
+ * PHP5 has a bug in handling boolean return values for
+ * SessionHandlerInterface methods, it expects 0 or -1 instead of true or
+ * false. See <https://wiki.php.net/rfc/session.user.return-value>.
+ *
+ * PHP7 and HHVM are not affected.
+ *
+ * @todo When we drop support for Zend PHP 5, this can be removed.
+ * @return bool|int
+ * @codeCoverageIgnore
+ */
+ protected static function returnSuccess() {
+ return defined( 'HHVM_VERSION' ) || version_compare( PHP_VERSION, '7.0.0', '>=' ) ? true : 0;
+ }
+
+ /**
+ * Workaround for PHP5 bug
+ * @see self::returnSuccess()
+ * @return bool|int
+ * @codeCoverageIgnore
+ */
+ protected static function returnFailure() {
+ return defined( 'HHVM_VERSION' ) || version_compare( PHP_VERSION, '7.0.0', '>=' ) ? false : -1;
+ }
+
+ /**
+ * Initialize the session (handler)
+ * @private For internal use only
+ * @param string $save_path Path used to store session files (ignored)
+ * @param string $session_name Session name (ignored)
+ * @return bool|int Success (see self::returnSuccess())
+ */
+ public function open( $save_path, $session_name ) {
+ if ( self::$instance !== $this ) {
+ throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+ }
+ if ( !$this->enable ) {
+ throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+ }
+ return self::returnSuccess();
+ }
+
+ /**
+ * Close the session (handler)
+ * @private For internal use only
+ * @return bool|int Success (see self::returnSuccess())
+ */
+ public function close() {
+ if ( self::$instance !== $this ) {
+ throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+ }
+ $this->sessionFieldCache = [];
+ return self::returnSuccess();
+ }
+
+ /**
+ * Read session data
+ * @private For internal use only
+ * @param string $id Session id
+ * @return string Session data
+ */
+ public function read( $id ) {
+ if ( self::$instance !== $this ) {
+ throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+ }
+ if ( !$this->enable ) {
+ throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+ }
+
+ $session = $this->manager->getSessionById( $id, false );
+ if ( !$session ) {
+ return '';
+ }
+ $session->persist();
+
+ $data = iterator_to_array( $session );
+ $this->sessionFieldCache[$id] = $data;
+ return (string)\Wikimedia\PhpSessionSerializer::encode( $data );
+ }
+
+ /**
+ * Write session data
+ * @private For internal use only
+ * @param string $id Session id
+ * @param string $dataStr Session data. Not that you should ever call this
+ * directly, but note that this has the same issues with code injection
+ * via user-controlled data as does PHP's unserialize function.
+ * @return bool|int Success (see self::returnSuccess())
+ */
+ public function write( $id, $dataStr ) {
+ if ( self::$instance !== $this ) {
+ throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+ }
+ if ( !$this->enable ) {
+ throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+ }
+
+ $session = $this->manager->getSessionById( $id, true );
+ if ( !$session ) {
+ // This can happen under normal circumstances, if the session exists but is
+ // invalid. Let's emit a log warning instead of a PHP warning.
+ $this->logger->warning(
+ __METHOD__ . ': Session "{session}" cannot be loaded, skipping write.',
+ [
+ 'session' => $id,
+ ] );
+ return self::returnSuccess();
+ }
+
+ // First, decode the string PHP handed us
+ $data = \Wikimedia\PhpSessionSerializer::decode( $dataStr );
+ if ( $data === null ) {
+ // @codeCoverageIgnoreStart
+ return self::returnFailure();
+ // @codeCoverageIgnoreEnd
+ }
+
+ // Now merge the data into the Session object.
+ $changed = false;
+ $cache = isset( $this->sessionFieldCache[$id] ) ? $this->sessionFieldCache[$id] : [];
+ foreach ( $data as $key => $value ) {
+ if ( !array_key_exists( $key, $cache ) ) {
+ if ( $session->exists( $key ) ) {
+ // New in both, so ignore and log
+ $this->logger->warning(
+ __METHOD__ . ": Key \"$key\" added in both Session and \$_SESSION!"
+ );
+ } else {
+ // New in $_SESSION, keep it
+ $session->set( $key, $value );
+ $changed = true;
+ }
+ } elseif ( $cache[$key] === $value ) {
+ // Unchanged in $_SESSION, so ignore it
+ } elseif ( !$session->exists( $key ) ) {
+ // Deleted in Session, keep but log
+ $this->logger->warning(
+ __METHOD__ . ": Key \"$key\" deleted in Session and changed in \$_SESSION!"
+ );
+ $session->set( $key, $value );
+ $changed = true;
+ } elseif ( $cache[$key] === $session->get( $key ) ) {
+ // Unchanged in Session, so keep it
+ $session->set( $key, $value );
+ $changed = true;
+ } else {
+ // Changed in both, so ignore and log
+ $this->logger->warning(
+ __METHOD__ . ": Key \"$key\" changed in both Session and \$_SESSION!"
+ );
+ }
+ }
+ // Anything deleted in $_SESSION and unchanged in Session should be deleted too
+ // (but not if $_SESSION can't represent it at all)
+ \Wikimedia\PhpSessionSerializer::setLogger( new \Psr\Log\NullLogger() );
+ foreach ( $cache as $key => $value ) {
+ if ( !array_key_exists( $key, $data ) && $session->exists( $key ) &&
+ \Wikimedia\PhpSessionSerializer::encode( [ $key => true ] )
+ ) {
+ if ( $cache[$key] === $session->get( $key ) ) {
+ // Unchanged in Session, delete it
+ $session->remove( $key );
+ $changed = true;
+ } else {
+ // Changed in Session, ignore deletion and log
+ $this->logger->warning(
+ __METHOD__ . ": Key \"$key\" changed in Session and deleted in \$_SESSION!"
+ );
+ }
+ }
+ }
+ \Wikimedia\PhpSessionSerializer::setLogger( $this->logger );
+
+ // Save and update cache if anything changed
+ if ( $changed ) {
+ if ( $this->warn ) {
+ wfDeprecated( '$_SESSION', '1.27' );
+ $this->logger->warning( 'Something wrote to $_SESSION!' );
+ }
+
+ $session->save();
+ $this->sessionFieldCache[$id] = iterator_to_array( $session );
+ }
+
+ $session->persist();
+
+ return self::returnSuccess();
+ }
+
+ /**
+ * Destroy a session
+ * @private For internal use only
+ * @param string $id Session id
+ * @return bool|int Success (see self::returnSuccess())
+ */
+ public function destroy( $id ) {
+ if ( self::$instance !== $this ) {
+ throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+ }
+ if ( !$this->enable ) {
+ throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+ }
+ $session = $this->manager->getSessionById( $id, false );
+ if ( $session ) {
+ $session->clear();
+ }
+ return self::returnSuccess();
+ }
+
+ /**
+ * Execute garbage collection.
+ * @private For internal use only
+ * @param int $maxlifetime Maximum session life time (ignored)
+ * @return bool|int Success (see self::returnSuccess())
+ * @codeCoverageIgnore See T135576
+ */
+ public function gc( $maxlifetime ) {
+ if ( self::$instance !== $this ) {
+ throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+ }
+ $before = date( 'YmdHis', time() );
+ $this->store->deleteObjectsExpiringBefore( $before );
+ return self::returnSuccess();
+ }
+}
diff --git a/www/wiki/includes/session/Session.php b/www/wiki/includes/session/Session.php
new file mode 100644
index 00000000..23d9ab38
--- /dev/null
+++ b/www/wiki/includes/session/Session.php
@@ -0,0 +1,691 @@
+<?php
+/**
+ * MediaWiki session
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use User;
+use WebRequest;
+
+/**
+ * Manages data for an an authenticated session
+ *
+ * A Session represents the fact that the current HTTP request is part of a
+ * session. There are two broad types of Sessions, based on whether they
+ * return true or false from self::canSetUser():
+ * * When true (mutable), the Session identifies multiple requests as part of
+ * a session generically, with no tie to a particular user.
+ * * When false (immutable), the Session identifies multiple requests as part
+ * of a session by identifying and authenticating the request itself as
+ * belonging to a particular user.
+ *
+ * The Session object also serves as a replacement for PHP's $_SESSION,
+ * managing access to per-session data.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class Session implements \Countable, \Iterator, \ArrayAccess {
+ /** @var null|string[] Encryption algorithm to use */
+ private static $encryptionAlgorithm = null;
+
+ /** @var SessionBackend Session backend */
+ private $backend;
+
+ /** @var int Session index */
+ private $index;
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ /**
+ * @param SessionBackend $backend
+ * @param int $index
+ * @param LoggerInterface $logger
+ */
+ public function __construct( SessionBackend $backend, $index, LoggerInterface $logger ) {
+ $this->backend = $backend;
+ $this->index = $index;
+ $this->logger = $logger;
+ }
+
+ public function __destruct() {
+ $this->backend->deregisterSession( $this->index );
+ }
+
+ /**
+ * Returns the session ID
+ * @return string
+ */
+ public function getId() {
+ return $this->backend->getId();
+ }
+
+ /**
+ * Returns the SessionId object
+ * @private For internal use by WebRequest
+ * @return SessionId
+ */
+ public function getSessionId() {
+ return $this->backend->getSessionId();
+ }
+
+ /**
+ * Changes the session ID
+ * @return string New ID (might be the same as the old)
+ */
+ public function resetId() {
+ return $this->backend->resetId();
+ }
+
+ /**
+ * Fetch the SessionProvider for this session
+ * @return SessionProviderInterface
+ */
+ public function getProvider() {
+ return $this->backend->getProvider();
+ }
+
+ /**
+ * Indicate whether this session is persisted across requests
+ *
+ * For example, if cookies are set.
+ *
+ * @return bool
+ */
+ public function isPersistent() {
+ return $this->backend->isPersistent();
+ }
+
+ /**
+ * Make this session persisted across requests
+ *
+ * If the session is already persistent, equivalent to calling
+ * $this->renew().
+ */
+ public function persist() {
+ $this->backend->persist();
+ }
+
+ /**
+ * Make this session not be persisted across requests
+ *
+ * This will remove persistence information (e.g. delete cookies)
+ * from the associated WebRequest(s), and delete session data in the
+ * backend. The session data will still be available via get() until
+ * the end of the request.
+ */
+ public function unpersist() {
+ $this->backend->unpersist();
+ }
+
+ /**
+ * Indicate whether the user should be remembered independently of the
+ * session ID.
+ * @return bool
+ */
+ public function shouldRememberUser() {
+ return $this->backend->shouldRememberUser();
+ }
+
+ /**
+ * Set whether the user should be remembered independently of the session
+ * ID.
+ * @param bool $remember
+ */
+ public function setRememberUser( $remember ) {
+ $this->backend->setRememberUser( $remember );
+ }
+
+ /**
+ * Returns the request associated with this session
+ * @return WebRequest
+ */
+ public function getRequest() {
+ return $this->backend->getRequest( $this->index );
+ }
+
+ /**
+ * Returns the authenticated user for this session
+ * @return User
+ */
+ public function getUser() {
+ return $this->backend->getUser();
+ }
+
+ /**
+ * Fetch the rights allowed the user when this session is active.
+ * @return null|string[] Allowed user rights, or null to allow all.
+ */
+ public function getAllowedUserRights() {
+ return $this->backend->getAllowedUserRights();
+ }
+
+ /**
+ * Indicate whether the session user info can be changed
+ * @return bool
+ */
+ public function canSetUser() {
+ return $this->backend->canSetUser();
+ }
+
+ /**
+ * Set a new user for this session
+ * @note This should only be called when the user has been authenticated
+ * @param User $user User to set on the session.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ */
+ public function setUser( $user ) {
+ $this->backend->setUser( $user );
+ }
+
+ /**
+ * Get a suggested username for the login form
+ * @return string|null
+ */
+ public function suggestLoginUsername() {
+ return $this->backend->suggestLoginUsername( $this->index );
+ }
+
+ /**
+ * Whether HTTPS should be forced
+ * @return bool
+ */
+ public function shouldForceHTTPS() {
+ return $this->backend->shouldForceHTTPS();
+ }
+
+ /**
+ * Set whether HTTPS should be forced
+ * @param bool $force
+ */
+ public function setForceHTTPS( $force ) {
+ $this->backend->setForceHTTPS( $force );
+ }
+
+ /**
+ * Fetch the "logged out" timestamp
+ * @return int
+ */
+ public function getLoggedOutTimestamp() {
+ return $this->backend->getLoggedOutTimestamp();
+ }
+
+ /**
+ * Set the "logged out" timestamp
+ * @param int $ts
+ */
+ public function setLoggedOutTimestamp( $ts ) {
+ $this->backend->setLoggedOutTimestamp( $ts );
+ }
+
+ /**
+ * Fetch provider metadata
+ * @protected For use by SessionProvider subclasses only
+ * @return mixed
+ */
+ public function getProviderMetadata() {
+ return $this->backend->getProviderMetadata();
+ }
+
+ /**
+ * Delete all session data and clear the user (if possible)
+ */
+ public function clear() {
+ $data = &$this->backend->getData();
+ if ( $data ) {
+ $data = [];
+ $this->backend->dirty();
+ }
+ if ( $this->backend->canSetUser() ) {
+ $this->backend->setUser( new User );
+ }
+ $this->backend->save();
+ }
+
+ /**
+ * Renew the session
+ *
+ * Resets the TTL in the backend store if the session is near expiring, and
+ * re-persists the session to any active WebRequests if persistent.
+ */
+ public function renew() {
+ $this->backend->renew();
+ }
+
+ /**
+ * Fetch a copy of this session attached to an alternative WebRequest
+ *
+ * Actions on the copy will affect this session too, and vice versa.
+ *
+ * @param WebRequest $request Any existing session associated with this
+ * WebRequest object will be overwritten.
+ * @return Session
+ */
+ public function sessionWithRequest( WebRequest $request ) {
+ $request->setSessionId( $this->backend->getSessionId() );
+ return $this->backend->getSession( $request );
+ }
+
+ /**
+ * Fetch a value from the session
+ * @param string|int $key
+ * @param mixed $default Returned if $this->exists( $key ) would be false
+ * @return mixed
+ */
+ public function get( $key, $default = null ) {
+ $data = &$this->backend->getData();
+ return array_key_exists( $key, $data ) ? $data[$key] : $default;
+ }
+
+ /**
+ * Test if a value exists in the session
+ * @note Unlike isset(), null values are considered to exist.
+ * @param string|int $key
+ * @return bool
+ */
+ public function exists( $key ) {
+ $data = &$this->backend->getData();
+ return array_key_exists( $key, $data );
+ }
+
+ /**
+ * Set a value in the session
+ * @param string|int $key
+ * @param mixed $value
+ */
+ public function set( $key, $value ) {
+ $data = &$this->backend->getData();
+ if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
+ $data[$key] = $value;
+ $this->backend->dirty();
+ }
+ }
+
+ /**
+ * Remove a value from the session
+ * @param string|int $key
+ */
+ public function remove( $key ) {
+ $data = &$this->backend->getData();
+ if ( array_key_exists( $key, $data ) ) {
+ unset( $data[$key] );
+ $this->backend->dirty();
+ }
+ }
+
+ /**
+ * Fetch a CSRF token from the session
+ *
+ * Note that this does not persist the session, which you'll probably want
+ * to do if you want the token to actually be useful.
+ *
+ * @param string|string[] $salt Token salt
+ * @param string $key Token key
+ * @return Token
+ */
+ public function getToken( $salt = '', $key = 'default' ) {
+ $new = false;
+ $secrets = $this->get( 'wsTokenSecrets' );
+ if ( !is_array( $secrets ) ) {
+ $secrets = [];
+ }
+ if ( isset( $secrets[$key] ) && is_string( $secrets[$key] ) ) {
+ $secret = $secrets[$key];
+ } else {
+ $secret = \MWCryptRand::generateHex( 32 );
+ $secrets[$key] = $secret;
+ $this->set( 'wsTokenSecrets', $secrets );
+ $new = true;
+ }
+ if ( is_array( $salt ) ) {
+ $salt = implode( '|', $salt );
+ }
+ return new Token( $secret, (string)$salt, $new );
+ }
+
+ /**
+ * Remove a CSRF token from the session
+ *
+ * The next call to self::getToken() with $key will generate a new secret.
+ *
+ * @param string $key Token key
+ */
+ public function resetToken( $key = 'default' ) {
+ $secrets = $this->get( 'wsTokenSecrets' );
+ if ( is_array( $secrets ) && isset( $secrets[$key] ) ) {
+ unset( $secrets[$key] );
+ $this->set( 'wsTokenSecrets', $secrets );
+ }
+ }
+
+ /**
+ * Remove all CSRF tokens from the session
+ */
+ public function resetAllTokens() {
+ $this->remove( 'wsTokenSecrets' );
+ }
+
+ /**
+ * Fetch the secret keys for self::setSecret() and self::getSecret().
+ * @return string[] Encryption key, HMAC key
+ */
+ private function getSecretKeys() {
+ global $wgSessionSecret, $wgSecretKey, $wgSessionPbkdf2Iterations;
+
+ $wikiSecret = $wgSessionSecret ?: $wgSecretKey;
+ $userSecret = $this->get( 'wsSessionSecret', null );
+ if ( $userSecret === null ) {
+ $userSecret = \MWCryptRand::generateHex( 32 );
+ $this->set( 'wsSessionSecret', $userSecret );
+ }
+ $iterations = $this->get( 'wsSessionPbkdf2Iterations', null );
+ if ( $iterations === null ) {
+ $iterations = $wgSessionPbkdf2Iterations;
+ $this->set( 'wsSessionPbkdf2Iterations', $iterations );
+ }
+
+ $keymats = hash_pbkdf2( 'sha256', $wikiSecret, $userSecret, $iterations, 64, true );
+ return [
+ substr( $keymats, 0, 32 ),
+ substr( $keymats, 32, 32 ),
+ ];
+ }
+
+ /**
+ * Decide what type of encryption to use, based on system capabilities.
+ * @return array
+ */
+ private static function getEncryptionAlgorithm() {
+ global $wgSessionInsecureSecrets;
+
+ if ( self::$encryptionAlgorithm === null ) {
+ if ( function_exists( 'openssl_encrypt' ) ) {
+ $methods = openssl_get_cipher_methods();
+ if ( in_array( 'aes-256-ctr', $methods, true ) ) {
+ self::$encryptionAlgorithm = [ 'openssl', 'aes-256-ctr' ];
+ return self::$encryptionAlgorithm;
+ }
+ if ( in_array( 'aes-256-cbc', $methods, true ) ) {
+ self::$encryptionAlgorithm = [ 'openssl', 'aes-256-cbc' ];
+ return self::$encryptionAlgorithm;
+ }
+ }
+
+ if ( function_exists( 'mcrypt_encrypt' )
+ && in_array( 'rijndael-128', mcrypt_list_algorithms(), true )
+ ) {
+ $modes = mcrypt_list_modes();
+ if ( in_array( 'ctr', $modes, true ) ) {
+ self::$encryptionAlgorithm = [ 'mcrypt', 'rijndael-128', 'ctr' ];
+ return self::$encryptionAlgorithm;
+ }
+ if ( in_array( 'cbc', $modes, true ) ) {
+ self::$encryptionAlgorithm = [ 'mcrypt', 'rijndael-128', 'cbc' ];
+ return self::$encryptionAlgorithm;
+ }
+ }
+
+ if ( $wgSessionInsecureSecrets ) {
+ // @todo: import a pure-PHP library for AES instead of this
+ self::$encryptionAlgorithm = [ 'insecure' ];
+ return self::$encryptionAlgorithm;
+ }
+
+ throw new \BadMethodCallException(
+ 'Encryption is not available. You really should install the PHP OpenSSL extension, ' .
+ 'or failing that the mcrypt extension. But if you really can\'t and you\'re willing ' .
+ 'to accept insecure storage of sensitive session data, set ' .
+ '$wgSessionInsecureSecrets = true in LocalSettings.php to make this exception go away.'
+ );
+ }
+
+ return self::$encryptionAlgorithm;
+ }
+
+ /**
+ * Set a value in the session, encrypted
+ *
+ * This relies on the secrecy of $wgSecretKey (by default), or $wgSessionSecret.
+ *
+ * @param string|int $key
+ * @param mixed $value
+ */
+ public function setSecret( $key, $value ) {
+ list( $encKey, $hmacKey ) = $this->getSecretKeys();
+ $serialized = serialize( $value );
+
+ // The code for encryption (with OpenSSL) and sealing is taken from
+ // Chris Steipp's OATHAuthUtils class in Extension::OATHAuth.
+
+ // Encrypt
+ // @todo: import a pure-PHP library for AES instead of doing $wgSessionInsecureSecrets
+ $iv = \MWCryptRand::generate( 16, true );
+ $algorithm = self::getEncryptionAlgorithm();
+ switch ( $algorithm[0] ) {
+ case 'openssl':
+ $ciphertext = openssl_encrypt( $serialized, $algorithm[1], $encKey, OPENSSL_RAW_DATA, $iv );
+ if ( $ciphertext === false ) {
+ throw new \UnexpectedValueException( 'Encryption failed: ' . openssl_error_string() );
+ }
+ break;
+ case 'mcrypt':
+ // PKCS7 padding
+ $blocksize = mcrypt_get_block_size( $algorithm[1], $algorithm[2] );
+ $pad = $blocksize - ( strlen( $serialized ) % $blocksize );
+ $serialized .= str_repeat( chr( $pad ), $pad );
+
+ $ciphertext = mcrypt_encrypt( $algorithm[1], $encKey, $serialized, $algorithm[2], $iv );
+ if ( $ciphertext === false ) {
+ throw new \UnexpectedValueException( 'Encryption failed' );
+ }
+ break;
+ case 'insecure':
+ $ex = new \Exception( 'No encryption is available, storing data as plain text' );
+ $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
+ $ciphertext = $serialized;
+ break;
+ default:
+ throw new \LogicException( 'invalid algorithm' );
+ }
+
+ // Seal
+ $sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext );
+ $hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true );
+ $encrypted = base64_encode( $hmac ) . '.' . $sealed;
+
+ // Store
+ $this->set( $key, $encrypted );
+ }
+
+ /**
+ * Fetch a value from the session that was set with self::setSecret()
+ * @param string|int $key
+ * @param mixed $default Returned if $this->exists( $key ) would be false or decryption fails
+ * @return mixed
+ */
+ public function getSecret( $key, $default = null ) {
+ // Fetch
+ $encrypted = $this->get( $key, null );
+ if ( $encrypted === null ) {
+ return $default;
+ }
+
+ // The code for unsealing, checking, and decrypting (with OpenSSL) is
+ // taken from Chris Steipp's OATHAuthUtils class in
+ // Extension::OATHAuth.
+
+ // Unseal and check
+ $pieces = explode( '.', $encrypted );
+ if ( count( $pieces ) !== 3 ) {
+ $ex = new \Exception( 'Invalid sealed-secret format' );
+ $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
+ return $default;
+ }
+ list( $hmac, $iv, $ciphertext ) = $pieces;
+ list( $encKey, $hmacKey ) = $this->getSecretKeys();
+ $integCalc = hash_hmac( 'sha256', $iv . '.' . $ciphertext, $hmacKey, true );
+ if ( !hash_equals( $integCalc, base64_decode( $hmac ) ) ) {
+ $ex = new \Exception( 'Sealed secret has been tampered with, aborting.' );
+ $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
+ return $default;
+ }
+
+ // Decrypt
+ $algorithm = self::getEncryptionAlgorithm();
+ switch ( $algorithm[0] ) {
+ case 'openssl':
+ $serialized = openssl_decrypt( base64_decode( $ciphertext ), $algorithm[1], $encKey,
+ OPENSSL_RAW_DATA, base64_decode( $iv ) );
+ if ( $serialized === false ) {
+ $ex = new \Exception( 'Decyption failed: ' . openssl_error_string() );
+ $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
+ return $default;
+ }
+ break;
+ case 'mcrypt':
+ $serialized = mcrypt_decrypt( $algorithm[1], $encKey, base64_decode( $ciphertext ),
+ $algorithm[2], base64_decode( $iv ) );
+ if ( $serialized === false ) {
+ $ex = new \Exception( 'Decyption failed' );
+ $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
+ return $default;
+ }
+
+ // Remove PKCS7 padding
+ $pad = ord( substr( $serialized, -1 ) );
+ $serialized = substr( $serialized, 0, -$pad );
+ break;
+ case 'insecure':
+ $ex = new \Exception(
+ 'No encryption is available, retrieving data that was stored as plain text'
+ );
+ $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
+ $serialized = base64_decode( $ciphertext );
+ break;
+ default:
+ throw new \LogicException( 'invalid algorithm' );
+ }
+
+ $value = unserialize( $serialized );
+ if ( $value === false && $serialized !== serialize( false ) ) {
+ $value = $default;
+ }
+ return $value;
+ }
+
+ /**
+ * Delay automatic saving while multiple updates are being made
+ *
+ * Calls to save() or clear() will not be delayed.
+ *
+ * @return \Wikimedia\ScopedCallback When this goes out of scope, a save will be triggered
+ */
+ public function delaySave() {
+ return $this->backend->delaySave();
+ }
+
+ /**
+ * Save the session
+ *
+ * This will update the backend data and might re-persist the session
+ * if needed.
+ */
+ public function save() {
+ $this->backend->save();
+ }
+
+ /**
+ * @name Interface methods
+ * @{
+ */
+
+ public function count() {
+ $data = &$this->backend->getData();
+ return count( $data );
+ }
+
+ public function current() {
+ $data = &$this->backend->getData();
+ return current( $data );
+ }
+
+ public function key() {
+ $data = &$this->backend->getData();
+ return key( $data );
+ }
+
+ public function next() {
+ $data = &$this->backend->getData();
+ next( $data );
+ }
+
+ public function rewind() {
+ $data = &$this->backend->getData();
+ reset( $data );
+ }
+
+ public function valid() {
+ $data = &$this->backend->getData();
+ return key( $data ) !== null;
+ }
+
+ /**
+ * @note Despite the name, this seems to be intended to implement isset()
+ * rather than array_key_exists(). So do that.
+ * @inheritDoc
+ */
+ public function offsetExists( $offset ) {
+ $data = &$this->backend->getData();
+ return isset( $data[$offset] );
+ }
+
+ /**
+ * @note This supports indirect modifications but can't mark the session
+ * dirty when those happen. SessionBackend::save() checks the hash of the
+ * data to detect such changes.
+ * @note Accessing a nonexistent key via this mechanism causes that key to
+ * be created with a null value, and does not raise a PHP warning.
+ * @inheritDoc
+ */
+ public function &offsetGet( $offset ) {
+ $data = &$this->backend->getData();
+ if ( !array_key_exists( $offset, $data ) ) {
+ $ex = new \Exception( "Undefined index (auto-adds to session with a null value): $offset" );
+ $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
+ }
+ return $data[$offset];
+ }
+
+ public function offsetSet( $offset, $value ) {
+ $this->set( $offset, $value );
+ }
+
+ public function offsetUnset( $offset ) {
+ $this->remove( $offset );
+ }
+
+ /**@}*/
+
+}
diff --git a/www/wiki/includes/session/SessionBackend.php b/www/wiki/includes/session/SessionBackend.php
new file mode 100644
index 00000000..d37b73b5
--- /dev/null
+++ b/www/wiki/includes/session/SessionBackend.php
@@ -0,0 +1,772 @@
+<?php
+/**
+ * MediaWiki session backend
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use CachedBagOStuff;
+use Psr\Log\LoggerInterface;
+use User;
+use WebRequest;
+
+/**
+ * This is the actual workhorse for Session.
+ *
+ * Most code does not need to use this class, you want \MediaWiki\Session\Session.
+ * The exceptions are SessionProviders and SessionMetadata hook functions,
+ * which get an instance of this class rather than Session.
+ *
+ * The reasons for this split are:
+ * 1. A session can be attached to multiple requests, but we want the Session
+ * object to have some features that correspond to just one of those
+ * requests.
+ * 2. We want reasonable garbage collection behavior, but we also want the
+ * SessionManager to hold a reference to every active session so it can be
+ * saved when the request ends.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class SessionBackend {
+ /** @var SessionId */
+ private $id;
+
+ private $persist = false;
+ private $remember = false;
+ private $forceHTTPS = false;
+
+ /** @var array|null */
+ private $data = null;
+
+ private $forcePersist = false;
+ private $metaDirty = false;
+ private $dataDirty = false;
+
+ /** @var string Used to detect subarray modifications */
+ private $dataHash = null;
+
+ /** @var CachedBagOStuff */
+ private $store;
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ /** @var int */
+ private $lifetime;
+
+ /** @var User */
+ private $user;
+
+ private $curIndex = 0;
+
+ /** @var WebRequest[] Session requests */
+ private $requests = [];
+
+ /** @var SessionProvider provider */
+ private $provider;
+
+ /** @var array|null provider-specified metadata */
+ private $providerMetadata = null;
+
+ private $expires = 0;
+ private $loggedOut = 0;
+ private $delaySave = 0;
+
+ private $usePhpSessionHandling = true;
+ private $checkPHPSessionRecursionGuard = false;
+
+ private $shutdown = false;
+
+ /**
+ * @param SessionId $id Session ID object
+ * @param SessionInfo $info Session info to populate from
+ * @param CachedBagOStuff $store Backend data store
+ * @param LoggerInterface $logger
+ * @param int $lifetime Session data lifetime in seconds
+ */
+ public function __construct(
+ SessionId $id, SessionInfo $info, CachedBagOStuff $store, LoggerInterface $logger, $lifetime
+ ) {
+ $phpSessionHandling = \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' );
+ $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
+
+ if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
+ throw new \InvalidArgumentException(
+ "Refusing to create session for unverified user {$info->getUserInfo()}"
+ );
+ }
+ if ( $info->getProvider() === null ) {
+ throw new \InvalidArgumentException( 'Cannot create session without a provider' );
+ }
+ if ( $info->getId() !== $id->getId() ) {
+ throw new \InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
+ }
+
+ $this->id = $id;
+ $this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
+ $this->store = $store;
+ $this->logger = $logger;
+ $this->lifetime = $lifetime;
+ $this->provider = $info->getProvider();
+ $this->persist = $info->wasPersisted();
+ $this->remember = $info->wasRemembered();
+ $this->forceHTTPS = $info->forceHTTPS();
+ $this->providerMetadata = $info->getProviderMetadata();
+
+ $blob = $store->get( $store->makeKey( 'MWSession', (string)$this->id ) );
+ if ( !is_array( $blob ) ||
+ !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
+ !isset( $blob['data'] ) || !is_array( $blob['data'] )
+ ) {
+ $this->data = [];
+ $this->dataDirty = true;
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" is unsaved, marking dirty in constructor',
+ [
+ 'session' => $this->id,
+ ] );
+ } else {
+ $this->data = $blob['data'];
+ if ( isset( $blob['metadata']['loggedOut'] ) ) {
+ $this->loggedOut = (int)$blob['metadata']['loggedOut'];
+ }
+ if ( isset( $blob['metadata']['expires'] ) ) {
+ $this->expires = (int)$blob['metadata']['expires'];
+ } else {
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" metadata dirty due to missing expiration timestamp',
+ [
+ 'session' => $this->id,
+ ] );
+ }
+ }
+ $this->dataHash = md5( serialize( $this->data ) );
+ }
+
+ /**
+ * Return a new Session for this backend
+ * @param WebRequest $request
+ * @return Session
+ */
+ public function getSession( WebRequest $request ) {
+ $index = ++$this->curIndex;
+ $this->requests[$index] = $request;
+ $session = new Session( $this, $index, $this->logger );
+ return $session;
+ }
+
+ /**
+ * Deregister a Session
+ * @private For use by \MediaWiki\Session\Session::__destruct() only
+ * @param int $index
+ */
+ public function deregisterSession( $index ) {
+ unset( $this->requests[$index] );
+ if ( !$this->shutdown && !count( $this->requests ) ) {
+ $this->save( true );
+ $this->provider->getManager()->deregisterSessionBackend( $this );
+ }
+ }
+
+ /**
+ * Shut down a session
+ * @private For use by \MediaWiki\Session\SessionManager::shutdown() only
+ */
+ public function shutdown() {
+ $this->save( true );
+ $this->shutdown = true;
+ }
+
+ /**
+ * Returns the session ID.
+ * @return string
+ */
+ public function getId() {
+ return (string)$this->id;
+ }
+
+ /**
+ * Fetch the SessionId object
+ * @private For internal use by WebRequest
+ * @return SessionId
+ */
+ public function getSessionId() {
+ return $this->id;
+ }
+
+ /**
+ * Changes the session ID
+ * @return string New ID (might be the same as the old)
+ */
+ public function resetId() {
+ if ( $this->provider->persistsSessionId() ) {
+ $oldId = (string)$this->id;
+ $restart = $this->usePhpSessionHandling && $oldId === session_id() &&
+ PHPSessionHandler::isEnabled();
+
+ if ( $restart ) {
+ // If this session is the one behind PHP's $_SESSION, we need
+ // to close then reopen it.
+ session_write_close();
+ }
+
+ $this->provider->getManager()->changeBackendId( $this );
+ $this->provider->sessionIdWasReset( $this, $oldId );
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" metadata dirty due to ID reset (formerly "{oldId}")',
+ [
+ 'session' => $this->id,
+ 'oldId' => $oldId,
+ ] );
+
+ if ( $restart ) {
+ session_id( (string)$this->id );
+ \MediaWiki\quietCall( 'session_start' );
+ }
+
+ $this->autosave();
+
+ // Delete the data for the old session ID now
+ $this->store->delete( $this->store->makeKey( 'MWSession', $oldId ) );
+ }
+ }
+
+ /**
+ * Fetch the SessionProvider for this session
+ * @return SessionProviderInterface
+ */
+ public function getProvider() {
+ return $this->provider;
+ }
+
+ /**
+ * Indicate whether this session is persisted across requests
+ *
+ * For example, if cookies are set.
+ *
+ * @return bool
+ */
+ public function isPersistent() {
+ return $this->persist;
+ }
+
+ /**
+ * Make this session persisted across requests
+ *
+ * If the session is already persistent, equivalent to calling
+ * $this->renew().
+ */
+ public function persist() {
+ if ( !$this->persist ) {
+ $this->persist = true;
+ $this->forcePersist = true;
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" force-persist due to persist()',
+ [
+ 'session' => $this->id,
+ ] );
+ $this->autosave();
+ } else {
+ $this->renew();
+ }
+ }
+
+ /**
+ * Make this session not persisted across requests
+ */
+ public function unpersist() {
+ if ( $this->persist ) {
+ // Close the PHP session, if we're the one that's open
+ if ( $this->usePhpSessionHandling && PHPSessionHandler::isEnabled() &&
+ session_id() === (string)$this->id
+ ) {
+ $this->logger->debug(
+ 'SessionBackend "{session}" Closing PHP session for unpersist',
+ [ 'session' => $this->id ]
+ );
+ session_write_close();
+ session_id( '' );
+ }
+
+ $this->persist = false;
+ $this->forcePersist = true;
+ $this->metaDirty = true;
+
+ // Delete the session data, so the local cache-only write in
+ // self::save() doesn't get things out of sync with the backend.
+ $this->store->delete( $this->store->makeKey( 'MWSession', (string)$this->id ) );
+
+ $this->autosave();
+ }
+ }
+
+ /**
+ * Indicate whether the user should be remembered independently of the
+ * session ID.
+ * @return bool
+ */
+ public function shouldRememberUser() {
+ return $this->remember;
+ }
+
+ /**
+ * Set whether the user should be remembered independently of the session
+ * ID.
+ * @param bool $remember
+ */
+ public function setRememberUser( $remember ) {
+ if ( $this->remember !== (bool)$remember ) {
+ $this->remember = (bool)$remember;
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" metadata dirty due to remember-user change',
+ [
+ 'session' => $this->id,
+ ] );
+ $this->autosave();
+ }
+ }
+
+ /**
+ * Returns the request associated with a Session
+ * @param int $index Session index
+ * @return WebRequest
+ */
+ public function getRequest( $index ) {
+ if ( !isset( $this->requests[$index] ) ) {
+ throw new \InvalidArgumentException( 'Invalid session index' );
+ }
+ return $this->requests[$index];
+ }
+
+ /**
+ * Returns the authenticated user for this session
+ * @return User
+ */
+ public function getUser() {
+ return $this->user;
+ }
+
+ /**
+ * Fetch the rights allowed the user when this session is active.
+ * @return null|string[] Allowed user rights, or null to allow all.
+ */
+ public function getAllowedUserRights() {
+ return $this->provider->getAllowedUserRights( $this );
+ }
+
+ /**
+ * Indicate whether the session user info can be changed
+ * @return bool
+ */
+ public function canSetUser() {
+ return $this->provider->canChangeUser();
+ }
+
+ /**
+ * Set a new user for this session
+ * @note This should only be called when the user has been authenticated via a login process
+ * @param User $user User to set on the session.
+ * This may become a "UserValue" in the future, or User may be refactored
+ * into such.
+ */
+ public function setUser( $user ) {
+ if ( !$this->canSetUser() ) {
+ throw new \BadMethodCallException(
+ 'Cannot set user on this session; check $session->canSetUser() first'
+ );
+ }
+
+ $this->user = $user;
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" metadata dirty due to user change',
+ [
+ 'session' => $this->id,
+ ] );
+ $this->autosave();
+ }
+
+ /**
+ * Get a suggested username for the login form
+ * @param int $index Session index
+ * @return string|null
+ */
+ public function suggestLoginUsername( $index ) {
+ if ( !isset( $this->requests[$index] ) ) {
+ throw new \InvalidArgumentException( 'Invalid session index' );
+ }
+ return $this->provider->suggestLoginUsername( $this->requests[$index] );
+ }
+
+ /**
+ * Whether HTTPS should be forced
+ * @return bool
+ */
+ public function shouldForceHTTPS() {
+ return $this->forceHTTPS;
+ }
+
+ /**
+ * Set whether HTTPS should be forced
+ * @param bool $force
+ */
+ public function setForceHTTPS( $force ) {
+ if ( $this->forceHTTPS !== (bool)$force ) {
+ $this->forceHTTPS = (bool)$force;
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" metadata dirty due to force-HTTPS change',
+ [
+ 'session' => $this->id,
+ ] );
+ $this->autosave();
+ }
+ }
+
+ /**
+ * Fetch the "logged out" timestamp
+ * @return int
+ */
+ public function getLoggedOutTimestamp() {
+ return $this->loggedOut;
+ }
+
+ /**
+ * Set the "logged out" timestamp
+ * @param int $ts
+ */
+ public function setLoggedOutTimestamp( $ts = null ) {
+ $ts = (int)$ts;
+ if ( $this->loggedOut !== $ts ) {
+ $this->loggedOut = $ts;
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" metadata dirty due to logged-out-timestamp change',
+ [
+ 'session' => $this->id,
+ ] );
+ $this->autosave();
+ }
+ }
+
+ /**
+ * Fetch provider metadata
+ * @protected For use by SessionProvider subclasses only
+ * @return array|null
+ */
+ public function getProviderMetadata() {
+ return $this->providerMetadata;
+ }
+
+ /**
+ * Set provider metadata
+ * @protected For use by SessionProvider subclasses only
+ * @param array|null $metadata
+ */
+ public function setProviderMetadata( $metadata ) {
+ if ( $metadata !== null && !is_array( $metadata ) ) {
+ throw new \InvalidArgumentException( '$metadata must be an array or null' );
+ }
+ if ( $this->providerMetadata !== $metadata ) {
+ $this->providerMetadata = $metadata;
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" metadata dirty due to provider metadata change',
+ [
+ 'session' => $this->id,
+ ] );
+ $this->autosave();
+ }
+ }
+
+ /**
+ * Fetch the session data array
+ *
+ * Note the caller is responsible for calling $this->dirty() if anything in
+ * the array is changed.
+ *
+ * @private For use by \MediaWiki\Session\Session only.
+ * @return array
+ */
+ public function &getData() {
+ return $this->data;
+ }
+
+ /**
+ * Add data to the session.
+ *
+ * Overwrites any existing data under the same keys.
+ *
+ * @param array $newData Key-value pairs to add to the session
+ */
+ public function addData( array $newData ) {
+ $data = &$this->getData();
+ foreach ( $newData as $key => $value ) {
+ if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
+ $data[$key] = $value;
+ $this->dataDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" data dirty due to addData(): {callers}',
+ [
+ 'session' => $this->id,
+ 'callers' => wfGetAllCallers( 5 ),
+ ] );
+ }
+ }
+ }
+
+ /**
+ * Mark data as dirty
+ * @private For use by \MediaWiki\Session\Session only.
+ */
+ public function dirty() {
+ $this->dataDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" data dirty due to dirty(): {callers}',
+ [
+ 'session' => $this->id,
+ 'callers' => wfGetAllCallers( 5 ),
+ ] );
+ }
+
+ /**
+ * Renew the session by resaving everything
+ *
+ * Resets the TTL in the backend store if the session is near expiring, and
+ * re-persists the session to any active WebRequests if persistent.
+ */
+ public function renew() {
+ if ( time() + $this->lifetime / 2 > $this->expires ) {
+ $this->metaDirty = true;
+ $this->logger->debug(
+ 'SessionBackend "{callers}" metadata dirty for renew(): {callers}',
+ [
+ 'session' => $this->id,
+ 'callers' => wfGetAllCallers( 5 ),
+ ] );
+ if ( $this->persist ) {
+ $this->forcePersist = true;
+ $this->logger->debug(
+ 'SessionBackend "{session}" force-persist for renew(): {callers}',
+ [
+ 'session' => $this->id,
+ 'callers' => wfGetAllCallers( 5 ),
+ ] );
+ }
+ }
+ $this->autosave();
+ }
+
+ /**
+ * Delay automatic saving while multiple updates are being made
+ *
+ * Calls to save() will not be delayed.
+ *
+ * @return \Wikimedia\ScopedCallback When this goes out of scope, a save will be triggered
+ */
+ public function delaySave() {
+ $this->delaySave++;
+ return new \Wikimedia\ScopedCallback( function () {
+ if ( --$this->delaySave <= 0 ) {
+ $this->delaySave = 0;
+ $this->save();
+ }
+ } );
+ }
+
+ /**
+ * Save the session, unless delayed
+ * @see SessionBackend::save()
+ */
+ private function autosave() {
+ if ( $this->delaySave <= 0 ) {
+ $this->save();
+ }
+ }
+
+ /**
+ * Save the session
+ *
+ * Update both the backend data and the associated WebRequest(s) to
+ * reflect the state of the the SessionBackend. This might include
+ * persisting or unpersisting the session.
+ *
+ * @param bool $closing Whether the session is being closed
+ */
+ public function save( $closing = false ) {
+ $anon = $this->user->isAnon();
+
+ if ( !$anon && $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
+ $this->logger->debug(
+ 'SessionBackend "{session}" not saving, user {user} was ' .
+ 'passed to SessionManager::preventSessionsForUser',
+ [
+ 'session' => $this->id,
+ 'user' => $this->user,
+ ] );
+ return;
+ }
+
+ // Ensure the user has a token
+ // @codeCoverageIgnoreStart
+ if ( !$anon && !$this->user->getToken( false ) ) {
+ $this->logger->debug(
+ 'SessionBackend "{session}" creating token for user {user} on save',
+ [
+ 'session' => $this->id,
+ 'user' => $this->user,
+ ] );
+ $this->user->setToken();
+ if ( !wfReadOnly() ) {
+ // Promise that the token set here will be valid; save it at end of request
+ $user = $this->user;
+ \DeferredUpdates::addCallableUpdate( function () use ( $user ) {
+ $user->saveSettings();
+ } );
+ }
+ $this->metaDirty = true;
+ }
+ // @codeCoverageIgnoreEnd
+
+ if ( !$this->metaDirty && !$this->dataDirty &&
+ $this->dataHash !== md5( serialize( $this->data ) )
+ ) {
+ $this->logger->debug(
+ 'SessionBackend "{session}" data dirty due to hash mismatch, {expected} !== {got}',
+ [
+ 'session' => $this->id,
+ 'expected' => $this->dataHash,
+ 'got' => md5( serialize( $this->data ) ),
+ ] );
+ $this->dataDirty = true;
+ }
+
+ if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
+ return;
+ }
+
+ $this->logger->debug(
+ 'SessionBackend "{session}" save: dataDirty={dataDirty} ' .
+ 'metaDirty={metaDirty} forcePersist={forcePersist}',
+ [
+ 'session' => $this->id,
+ 'dataDirty' => (int)$this->dataDirty,
+ 'metaDirty' => (int)$this->metaDirty,
+ 'forcePersist' => (int)$this->forcePersist,
+ ] );
+
+ // Persist or unpersist to the provider, if necessary
+ if ( $this->metaDirty || $this->forcePersist ) {
+ if ( $this->persist ) {
+ foreach ( $this->requests as $request ) {
+ $request->setSessionId( $this->getSessionId() );
+ $this->provider->persistSession( $this, $request );
+ }
+ if ( !$closing ) {
+ $this->checkPHPSession();
+ }
+ } else {
+ foreach ( $this->requests as $request ) {
+ if ( $request->getSessionId() === $this->id ) {
+ $this->provider->unpersistSession( $request );
+ }
+ }
+ }
+ }
+
+ $this->forcePersist = false;
+
+ if ( !$this->metaDirty && !$this->dataDirty ) {
+ return;
+ }
+
+ // Save session data to store, if necessary
+ $metadata = $origMetadata = [
+ 'provider' => (string)$this->provider,
+ 'providerMetadata' => $this->providerMetadata,
+ 'userId' => $anon ? 0 : $this->user->getId(),
+ 'userName' => User::isValidUserName( $this->user->getName() ) ? $this->user->getName() : null,
+ 'userToken' => $anon ? null : $this->user->getToken(),
+ 'remember' => !$anon && $this->remember,
+ 'forceHTTPS' => $this->forceHTTPS,
+ 'expires' => time() + $this->lifetime,
+ 'loggedOut' => $this->loggedOut,
+ 'persisted' => $this->persist,
+ ];
+
+ \Hooks::run( 'SessionMetadata', [ $this, &$metadata, $this->requests ] );
+
+ foreach ( $origMetadata as $k => $v ) {
+ if ( $metadata[$k] !== $v ) {
+ throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
+ }
+ }
+
+ $flags = $this->persist ? 0 : CachedBagOStuff::WRITE_CACHE_ONLY;
+ $flags |= CachedBagOStuff::WRITE_SYNC; // write to all datacenters
+ $this->store->set(
+ $this->store->makeKey( 'MWSession', (string)$this->id ),
+ [
+ 'data' => $this->data,
+ 'metadata' => $metadata,
+ ],
+ $metadata['expires'],
+ $flags
+ );
+
+ $this->metaDirty = false;
+ $this->dataDirty = false;
+ $this->dataHash = md5( serialize( $this->data ) );
+ $this->expires = $metadata['expires'];
+ }
+
+ /**
+ * For backwards compatibility, open the PHP session when the global
+ * session is persisted
+ */
+ private function checkPHPSession() {
+ if ( !$this->checkPHPSessionRecursionGuard ) {
+ $this->checkPHPSessionRecursionGuard = true;
+ $reset = new \Wikimedia\ScopedCallback( function () {
+ $this->checkPHPSessionRecursionGuard = false;
+ } );
+
+ if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() &&
+ SessionManager::getGlobalSession()->getId() === (string)$this->id
+ ) {
+ $this->logger->debug(
+ 'SessionBackend "{session}" Taking over PHP session',
+ [
+ 'session' => $this->id,
+ ] );
+ session_id( (string)$this->id );
+ \MediaWiki\quietCall( 'session_start' );
+ }
+ }
+ }
+
+}
diff --git a/www/wiki/includes/session/SessionId.php b/www/wiki/includes/session/SessionId.php
new file mode 100644
index 00000000..33ea046c
--- /dev/null
+++ b/www/wiki/includes/session/SessionId.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * MediaWiki session ID holder
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+/**
+ * Value object holding the session ID in a manner that can be globally
+ * updated.
+ *
+ * This class exists because we want WebRequest to refer to the session, but it
+ * can't hold the Session itself due to issues with circular references and it
+ * can't just hold the ID as a string because we need to be able to update the
+ * ID when SessionBackend::resetId() is called.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class SessionId {
+ /** @var string */
+ private $id;
+
+ /**
+ * @param string $id
+ */
+ public function __construct( $id ) {
+ $this->id = $id;
+ }
+
+ /**
+ * Get the ID
+ * @return string
+ */
+ public function getId() {
+ return $this->id;
+ }
+
+ /**
+ * Set the ID
+ * @private For use by \MediaWiki\Session\SessionManager only
+ * @param string $id
+ */
+ public function setId( $id ) {
+ $this->id = $id;
+ }
+
+ public function __toString() {
+ return $this->id;
+ }
+
+}
diff --git a/www/wiki/includes/session/SessionInfo.php b/www/wiki/includes/session/SessionInfo.php
new file mode 100644
index 00000000..287da9dd
--- /dev/null
+++ b/www/wiki/includes/session/SessionInfo.php
@@ -0,0 +1,288 @@
+<?php
+/**
+ * MediaWiki session info
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+/**
+ * Value object returned by SessionProvider
+ *
+ * This holds the data necessary to construct a Session.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+class SessionInfo {
+ /** Minimum allowed priority */
+ const MIN_PRIORITY = 1;
+
+ /** Maximum allowed priority */
+ const MAX_PRIORITY = 100;
+
+ /** @var SessionProvider|null */
+ private $provider;
+
+ /** @var string */
+ private $id;
+
+ /** @var int */
+ private $priority;
+
+ /** @var UserInfo|null */
+ private $userInfo = null;
+
+ private $persisted = false;
+ private $remembered = false;
+ private $forceHTTPS = false;
+ private $idIsSafe = false;
+ private $forceUse = false;
+
+ /** @var array|null */
+ private $providerMetadata = null;
+
+ /**
+ * @param int $priority Session priority
+ * @param array $data
+ * - provider: (SessionProvider|null) If not given, the provider will be
+ * determined from the saved session data.
+ * - id: (string|null) Session ID
+ * - userInfo: (UserInfo|null) User known from the request. If
+ * $provider->canChangeUser() is false, a verified user
+ * must be provided.
+ * - persisted: (bool) Whether this session was persisted
+ * - remembered: (bool) Whether the verified user was remembered.
+ * Defaults to true.
+ * - forceHTTPS: (bool) Whether to force HTTPS for this session
+ * - metadata: (array) Provider metadata, to be returned by
+ * Session::getProviderMetadata(). See SessionProvider::mergeMetadata()
+ * and SessionProvider::refreshSessionInfo().
+ * - idIsSafe: (bool) Set true if the 'id' did not come from the user.
+ * Generally you'll use this from SessionProvider::newEmptySession(),
+ * and not from any other method.
+ * - forceUse: (bool) Set true if the 'id' is from
+ * SessionProvider::hashToSessionId() to delete conflicting session
+ * store data instead of discarding this SessionInfo. Ignored unless
+ * both 'provider' and 'id' are given.
+ * - copyFrom: (SessionInfo) SessionInfo to copy other data items from.
+ */
+ public function __construct( $priority, array $data ) {
+ if ( $priority < self::MIN_PRIORITY || $priority > self::MAX_PRIORITY ) {
+ throw new \InvalidArgumentException( 'Invalid priority' );
+ }
+
+ if ( isset( $data['copyFrom'] ) ) {
+ $from = $data['copyFrom'];
+ if ( !$from instanceof SessionInfo ) {
+ throw new \InvalidArgumentException( 'Invalid copyFrom' );
+ }
+ $data += [
+ 'provider' => $from->provider,
+ 'id' => $from->id,
+ 'userInfo' => $from->userInfo,
+ 'persisted' => $from->persisted,
+ 'remembered' => $from->remembered,
+ 'forceHTTPS' => $from->forceHTTPS,
+ 'metadata' => $from->providerMetadata,
+ 'idIsSafe' => $from->idIsSafe,
+ 'forceUse' => $from->forceUse,
+ // @codeCoverageIgnoreStart
+ ];
+ // @codeCoverageIgnoreEnd
+ } else {
+ $data += [
+ 'provider' => null,
+ 'id' => null,
+ 'userInfo' => null,
+ 'persisted' => false,
+ 'remembered' => true,
+ 'forceHTTPS' => false,
+ 'metadata' => null,
+ 'idIsSafe' => false,
+ 'forceUse' => false,
+ // @codeCoverageIgnoreStart
+ ];
+ // @codeCoverageIgnoreEnd
+ }
+
+ if ( $data['id'] !== null && !SessionManager::validateSessionId( $data['id'] ) ) {
+ throw new \InvalidArgumentException( 'Invalid session ID' );
+ }
+
+ if ( $data['userInfo'] !== null && !$data['userInfo'] instanceof UserInfo ) {
+ throw new \InvalidArgumentException( 'Invalid userInfo' );
+ }
+
+ if ( !$data['provider'] && $data['id'] === null ) {
+ throw new \InvalidArgumentException(
+ 'Must supply an ID when no provider is given'
+ );
+ }
+
+ if ( $data['metadata'] !== null && !is_array( $data['metadata'] ) ) {
+ throw new \InvalidArgumentException( 'Invalid metadata' );
+ }
+
+ $this->provider = $data['provider'];
+ if ( $data['id'] !== null ) {
+ $this->id = $data['id'];
+ $this->idIsSafe = $data['idIsSafe'];
+ $this->forceUse = $data['forceUse'] && $this->provider;
+ } else {
+ $this->id = $this->provider->getManager()->generateSessionId();
+ $this->idIsSafe = true;
+ $this->forceUse = false;
+ }
+ $this->priority = (int)$priority;
+ $this->userInfo = $data['userInfo'];
+ $this->persisted = (bool)$data['persisted'];
+ if ( $data['provider'] !== null ) {
+ if ( $this->userInfo !== null && !$this->userInfo->isAnon() && $this->userInfo->isVerified() ) {
+ $this->remembered = (bool)$data['remembered'];
+ }
+ $this->providerMetadata = $data['metadata'];
+ }
+ $this->forceHTTPS = (bool)$data['forceHTTPS'];
+ }
+
+ /**
+ * Return the provider
+ * @return SessionProvider|null
+ */
+ final public function getProvider() {
+ return $this->provider;
+ }
+
+ /**
+ * Return the session ID
+ * @return string
+ */
+ final public function getId() {
+ return $this->id;
+ }
+
+ /**
+ * Indicate whether the ID is "safe"
+ *
+ * The ID is safe in the following cases:
+ * - The ID was randomly generated by the constructor.
+ * - The ID was found in the backend data store.
+ * - $this->getProvider()->persistsSessionId() is false.
+ * - The constructor was explicitly told it's safe using the 'idIsSafe'
+ * parameter.
+ *
+ * @return bool
+ */
+ final public function isIdSafe() {
+ return $this->idIsSafe;
+ }
+
+ /**
+ * Force use of this SessionInfo if validation fails
+ *
+ * The normal behavior is to discard the SessionInfo if validation against
+ * the data stored in the session store fails. If this returns true,
+ * SessionManager will instead delete the session store data so this
+ * SessionInfo may still be used. This is important for providers which use
+ * deterministic IDs and so cannot just generate a random new one.
+ *
+ * @return bool
+ */
+ final public function forceUse() {
+ return $this->forceUse;
+ }
+
+ /**
+ * Return the priority
+ * @return int
+ */
+ final public function getPriority() {
+ return $this->priority;
+ }
+
+ /**
+ * Return the user
+ * @return UserInfo|null
+ */
+ final public function getUserInfo() {
+ return $this->userInfo;
+ }
+
+ /**
+ * Return whether the session is persisted
+ * @return bool
+ */
+ final public function wasPersisted() {
+ return $this->persisted;
+ }
+
+ /**
+ * Return provider metadata
+ * @return array|null
+ */
+ final public function getProviderMetadata() {
+ return $this->providerMetadata;
+ }
+
+ /**
+ * Return whether the user was remembered
+ *
+ * For providers that can persist the user separately from the session,
+ * the human using it may not actually *want* that to be done. For example,
+ * a cookie-based provider can set cookies that are longer-lived than the
+ * backend session data, but on a public terminal the human likely doesn't
+ * want those cookies set.
+ *
+ * This is false unless a non-anonymous verified user was passed to
+ * the SessionInfo constructor by the provider, and the provider didn't
+ * pass false for the 'remembered' data item.
+ *
+ * @return bool
+ */
+ final public function wasRemembered() {
+ return $this->remembered;
+ }
+
+ /**
+ * Whether this session should only be used over HTTPS
+ * @return bool
+ */
+ final public function forceHTTPS() {
+ return $this->forceHTTPS;
+ }
+
+ public function __toString() {
+ return '[' . $this->getPriority() . ']' .
+ ( $this->getProvider() ?: 'null' ) .
+ ( $this->userInfo ?: '<null>' ) . $this->getId();
+ }
+
+ /**
+ * Compare two SessionInfo objects by priority
+ * @param SessionInfo $a
+ * @param SessionInfo $b
+ * @return int Negative if $a < $b, positive if $a > $b, zero if equal
+ */
+ public static function compare( $a, $b ) {
+ return $a->getPriority() - $b->getPriority();
+ }
+
+}
diff --git a/www/wiki/includes/session/SessionManager.php b/www/wiki/includes/session/SessionManager.php
new file mode 100644
index 00000000..40a568ff
--- /dev/null
+++ b/www/wiki/includes/session/SessionManager.php
@@ -0,0 +1,968 @@
+<?php
+/**
+ * MediaWiki\Session entry point
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use MediaWiki\MediaWikiServices;
+use MWException;
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+use CachedBagOStuff;
+use Config;
+use FauxRequest;
+use User;
+use WebRequest;
+
+/**
+ * This serves as the entry point to the MediaWiki session handling system.
+ *
+ * Most methods here are for internal use by session handling code. Other callers
+ * should only use getGlobalSession and the methods of SessionManagerInterface;
+ * the rest of the functionality is exposed via MediaWiki\Session\Session methods.
+ *
+ * To provide custom session handling, implement a MediaWiki\Session\SessionProvider.
+ *
+ * @ingroup Session
+ * @since 1.27
+ * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
+ */
+final class SessionManager implements SessionManagerInterface {
+ /** @var SessionManager|null */
+ private static $instance = null;
+
+ /** @var Session|null */
+ private static $globalSession = null;
+
+ /** @var WebRequest|null */
+ private static $globalSessionRequest = null;
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ /** @var Config */
+ private $config;
+
+ /** @var CachedBagOStuff|null */
+ private $store;
+
+ /** @var SessionProvider[] */
+ private $sessionProviders = null;
+
+ /** @var string[] */
+ private $varyCookies = null;
+
+ /** @var array */
+ private $varyHeaders = null;
+
+ /** @var SessionBackend[] */
+ private $allSessionBackends = [];
+
+ /** @var SessionId[] */
+ private $allSessionIds = [];
+
+ /** @var string[] */
+ private $preventUsers = [];
+
+ /**
+ * Get the global SessionManager
+ * @return SessionManagerInterface
+ * (really a SessionManager, but this is to make IDEs less confused)
+ */
+ public static function singleton() {
+ if ( self::$instance === null ) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Get the "global" session
+ *
+ * If PHP's session_id() has been set, returns that session. Otherwise
+ * returns the session for RequestContext::getMain()->getRequest().
+ *
+ * @return Session
+ */
+ public static function getGlobalSession() {
+ if ( !PHPSessionHandler::isEnabled() ) {
+ $id = '';
+ } else {
+ $id = session_id();
+ }
+
+ $request = \RequestContext::getMain()->getRequest();
+ if (
+ !self::$globalSession // No global session is set up yet
+ || self::$globalSessionRequest !== $request // The global WebRequest changed
+ || $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id()
+ ) {
+ self::$globalSessionRequest = $request;
+ if ( $id === '' ) {
+ // session_id() wasn't used, so fetch the Session from the WebRequest.
+ // We use $request->getSession() instead of $singleton->getSessionForRequest()
+ // because doing the latter would require a public
+ // "$request->getSessionId()" method that would confuse end
+ // users by returning SessionId|null where they'd expect it to
+ // be short for $request->getSession()->getId(), and would
+ // wind up being a duplicate of the code in
+ // $request->getSession() anyway.
+ self::$globalSession = $request->getSession();
+ } else {
+ // Someone used session_id(), so we need to follow suit.
+ // Note this overwrites whatever session might already be
+ // associated with $request with the one for $id.
+ self::$globalSession = self::singleton()->getSessionById( $id, true, $request )
+ ?: $request->getSession();
+ }
+ }
+ return self::$globalSession;
+ }
+
+ /**
+ * @param array $options
+ * - config: Config to fetch configuration from. Defaults to the default 'main' config.
+ * - logger: LoggerInterface to use for logging. Defaults to the 'session' channel.
+ * - store: BagOStuff to store session data in.
+ */
+ public function __construct( $options = [] ) {
+ if ( isset( $options['config'] ) ) {
+ $this->config = $options['config'];
+ if ( !$this->config instanceof Config ) {
+ throw new \InvalidArgumentException(
+ '$options[\'config\'] must be an instance of Config'
+ );
+ }
+ } else {
+ $this->config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+
+ if ( isset( $options['logger'] ) ) {
+ if ( !$options['logger'] instanceof LoggerInterface ) {
+ throw new \InvalidArgumentException(
+ '$options[\'logger\'] must be an instance of LoggerInterface'
+ );
+ }
+ $this->setLogger( $options['logger'] );
+ } else {
+ $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
+ }
+
+ if ( isset( $options['store'] ) ) {
+ if ( !$options['store'] instanceof BagOStuff ) {
+ throw new \InvalidArgumentException(
+ '$options[\'store\'] must be an instance of BagOStuff'
+ );
+ }
+ $store = $options['store'];
+ } else {
+ $store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
+ }
+ $this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
+
+ register_shutdown_function( [ $this, 'shutdown' ] );
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ public function getSessionForRequest( WebRequest $request ) {
+ $info = $this->getSessionInfoForRequest( $request );
+
+ if ( !$info ) {
+ $session = $this->getEmptySession( $request );
+ } else {
+ $session = $this->getSessionFromInfo( $info, $request );
+ }
+ return $session;
+ }
+
+ public function getSessionById( $id, $create = false, WebRequest $request = null ) {
+ if ( !self::validateSessionId( $id ) ) {
+ throw new \InvalidArgumentException( 'Invalid session ID' );
+ }
+ if ( !$request ) {
+ $request = new FauxRequest;
+ }
+
+ $session = null;
+ $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => $id, 'idIsSafe' => true ] );
+
+ // If we already have the backend loaded, use it directly
+ if ( isset( $this->allSessionBackends[$id] ) ) {
+ return $this->getSessionFromInfo( $info, $request );
+ }
+
+ // Test if the session is in storage, and if so try to load it.
+ $key = $this->store->makeKey( 'MWSession', $id );
+ if ( is_array( $this->store->get( $key ) ) ) {
+ $create = false; // If loading fails, don't bother creating because it probably will fail too.
+ if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+ $session = $this->getSessionFromInfo( $info, $request );
+ }
+ }
+
+ if ( $create && $session === null ) {
+ $ex = null;
+ try {
+ $session = $this->getEmptySessionInternal( $request, $id );
+ } catch ( \Exception $ex ) {
+ $this->logger->error( 'Failed to create empty session: {exception}',
+ [
+ 'method' => __METHOD__,
+ 'exception' => $ex,
+ ] );
+ $session = null;
+ }
+ }
+
+ return $session;
+ }
+
+ public function getEmptySession( WebRequest $request = null ) {
+ return $this->getEmptySessionInternal( $request );
+ }
+
+ /**
+ * @see SessionManagerInterface::getEmptySession
+ * @param WebRequest|null $request
+ * @param string|null $id ID to force on the new session
+ * @return Session
+ */
+ private function getEmptySessionInternal( WebRequest $request = null, $id = null ) {
+ if ( $id !== null ) {
+ if ( !self::validateSessionId( $id ) ) {
+ throw new \InvalidArgumentException( 'Invalid session ID' );
+ }
+
+ $key = $this->store->makeKey( 'MWSession', $id );
+ if ( is_array( $this->store->get( $key ) ) ) {
+ throw new \InvalidArgumentException( 'Session ID already exists' );
+ }
+ }
+ if ( !$request ) {
+ $request = new FauxRequest;
+ }
+
+ $infos = [];
+ foreach ( $this->getProviders() as $provider ) {
+ $info = $provider->newSessionInfo( $id );
+ if ( !$info ) {
+ continue;
+ }
+ if ( $info->getProvider() !== $provider ) {
+ throw new \UnexpectedValueException(
+ "$provider returned an empty session info for a different provider: $info"
+ );
+ }
+ if ( $id !== null && $info->getId() !== $id ) {
+ throw new \UnexpectedValueException(
+ "$provider returned empty session info with a wrong id: " .
+ $info->getId() . ' != ' . $id
+ );
+ }
+ if ( !$info->isIdSafe() ) {
+ throw new \UnexpectedValueException(
+ "$provider returned empty session info with id flagged unsafe"
+ );
+ }
+ $compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
+ if ( $compare > 0 ) {
+ continue;
+ }
+ if ( $compare === 0 ) {
+ $infos[] = $info;
+ } else {
+ $infos = [ $info ];
+ }
+ }
+
+ // Make sure there's exactly one
+ if ( count( $infos ) > 1 ) {
+ throw new \UnexpectedValueException(
+ 'Multiple empty sessions tied for top priority: ' . implode( ', ', $infos )
+ );
+ } elseif ( count( $infos ) < 1 ) {
+ throw new \UnexpectedValueException( 'No provider could provide an empty session!' );
+ }
+
+ return $this->getSessionFromInfo( $infos[0], $request );
+ }
+
+ public function invalidateSessionsForUser( User $user ) {
+ $user->setToken();
+ $user->saveSettings();
+
+ $authUser = \MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$user ] );
+ if ( $authUser ) {
+ $authUser->resetAuthToken();
+ }
+
+ foreach ( $this->getProviders() as $provider ) {
+ $provider->invalidateSessionsForUser( $user );
+ }
+ }
+
+ public function getVaryHeaders() {
+ // @codeCoverageIgnoreStart
+ if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
+ return [];
+ }
+ // @codeCoverageIgnoreEnd
+ if ( $this->varyHeaders === null ) {
+ $headers = [];
+ foreach ( $this->getProviders() as $provider ) {
+ foreach ( $provider->getVaryHeaders() as $header => $options ) {
+ if ( !isset( $headers[$header] ) ) {
+ $headers[$header] = [];
+ }
+ if ( is_array( $options ) ) {
+ $headers[$header] = array_unique( array_merge( $headers[$header], $options ) );
+ }
+ }
+ }
+ $this->varyHeaders = $headers;
+ }
+ return $this->varyHeaders;
+ }
+
+ public function getVaryCookies() {
+ // @codeCoverageIgnoreStart
+ if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
+ return [];
+ }
+ // @codeCoverageIgnoreEnd
+ if ( $this->varyCookies === null ) {
+ $cookies = [];
+ foreach ( $this->getProviders() as $provider ) {
+ $cookies = array_merge( $cookies, $provider->getVaryCookies() );
+ }
+ $this->varyCookies = array_values( array_unique( $cookies ) );
+ }
+ return $this->varyCookies;
+ }
+
+ /**
+ * Validate a session ID
+ * @param string $id
+ * @return bool
+ */
+ public static function validateSessionId( $id ) {
+ return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
+ }
+
+ /**
+ * @name Internal methods
+ * @{
+ */
+
+ /**
+ * Auto-create the given user, if necessary
+ * @private Don't call this yourself. Let Setup.php do it for you at the right time.
+ * @deprecated since 1.27, use MediaWiki\Auth\AuthManager::autoCreateUser instead
+ * @param User $user User to auto-create
+ * @return bool Success
+ * @codeCoverageIgnore
+ */
+ public static function autoCreateUser( User $user ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ return \MediaWiki\Auth\AuthManager::singleton()->autoCreateUser(
+ $user,
+ \MediaWiki\Auth\AuthManager::AUTOCREATE_SOURCE_SESSION,
+ false
+ )->isGood();
+ }
+
+ /**
+ * Prevent future sessions for the user
+ *
+ * The intention is that the named account will never again be usable for
+ * normal login (i.e. there is no way to undo the prevention of access).
+ *
+ * @private For use from \User::newSystemUser only
+ * @param string $username
+ */
+ public function preventSessionsForUser( $username ) {
+ $this->preventUsers[$username] = true;
+
+ // Instruct the session providers to kill any other sessions too.
+ foreach ( $this->getProviders() as $provider ) {
+ $provider->preventSessionsForUser( $username );
+ }
+ }
+
+ /**
+ * Test if a user is prevented
+ * @private For use from SessionBackend only
+ * @param string $username
+ * @return bool
+ */
+ public function isUserSessionPrevented( $username ) {
+ return !empty( $this->preventUsers[$username] );
+ }
+
+ /**
+ * Get the available SessionProviders
+ * @return SessionProvider[]
+ */
+ protected function getProviders() {
+ if ( $this->sessionProviders === null ) {
+ $this->sessionProviders = [];
+ foreach ( $this->config->get( 'SessionProviders' ) as $spec ) {
+ $provider = \ObjectFactory::getObjectFromSpec( $spec );
+ $provider->setLogger( $this->logger );
+ $provider->setConfig( $this->config );
+ $provider->setManager( $this );
+ if ( isset( $this->sessionProviders[(string)$provider] ) ) {
+ throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
+ }
+ $this->sessionProviders[(string)$provider] = $provider;
+ }
+ }
+ return $this->sessionProviders;
+ }
+
+ /**
+ * Get a session provider by name
+ *
+ * Generally, this will only be used by internal implementation of some
+ * special session-providing mechanism. General purpose code, if it needs
+ * to access a SessionProvider at all, will use Session::getProvider().
+ *
+ * @param string $name
+ * @return SessionProvider|null
+ */
+ public function getProvider( $name ) {
+ $providers = $this->getProviders();
+ return isset( $providers[$name] ) ? $providers[$name] : null;
+ }
+
+ /**
+ * Save all active sessions on shutdown
+ * @private For internal use with register_shutdown_function()
+ */
+ public function shutdown() {
+ if ( $this->allSessionBackends ) {
+ $this->logger->debug( 'Saving all sessions on shutdown' );
+ if ( session_id() !== '' ) {
+ // @codeCoverageIgnoreStart
+ session_write_close();
+ }
+ // @codeCoverageIgnoreEnd
+ foreach ( $this->allSessionBackends as $backend ) {
+ $backend->shutdown();
+ }
+ }
+ }
+
+ /**
+ * Fetch the SessionInfo(s) for a request
+ * @param WebRequest $request
+ * @return SessionInfo|null
+ */
+ private function getSessionInfoForRequest( WebRequest $request ) {
+ // Call all providers to fetch "the" session
+ $infos = [];
+ foreach ( $this->getProviders() as $provider ) {
+ $info = $provider->provideSessionInfo( $request );
+ if ( !$info ) {
+ continue;
+ }
+ if ( $info->getProvider() !== $provider ) {
+ throw new \UnexpectedValueException(
+ "$provider returned session info for a different provider: $info"
+ );
+ }
+ $infos[] = $info;
+ }
+
+ // Sort the SessionInfos. Then find the first one that can be
+ // successfully loaded, and then all the ones after it with the same
+ // priority.
+ usort( $infos, 'MediaWiki\\Session\\SessionInfo::compare' );
+ $retInfos = [];
+ while ( $infos ) {
+ $info = array_pop( $infos );
+ if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+ $retInfos[] = $info;
+ while ( $infos ) {
+ $info = array_pop( $infos );
+ if ( SessionInfo::compare( $retInfos[0], $info ) ) {
+ // We hit a lower priority, stop checking.
+ break;
+ }
+ if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+ // This is going to error out below, but we want to
+ // provide a complete list.
+ $retInfos[] = $info;
+ } else {
+ // Session load failed, so unpersist it from this request
+ $info->getProvider()->unpersistSession( $request );
+ }
+ }
+ } else {
+ // Session load failed, so unpersist it from this request
+ $info->getProvider()->unpersistSession( $request );
+ }
+ }
+
+ if ( count( $retInfos ) > 1 ) {
+ $ex = new \OverflowException(
+ 'Multiple sessions for this request tied for top priority: ' . implode( ', ', $retInfos )
+ );
+ $ex->sessionInfos = $retInfos;
+ throw $ex;
+ }
+
+ return $retInfos ? $retInfos[0] : null;
+ }
+
+ /**
+ * Load and verify the session info against the store
+ *
+ * @param SessionInfo &$info Will likely be replaced with an updated SessionInfo instance
+ * @param WebRequest $request
+ * @return bool Whether the session info matches the stored data (if any)
+ */
+ private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
+ $key = $this->store->makeKey( 'MWSession', $info->getId() );
+ $blob = $this->store->get( $key );
+
+ // If we got data from the store and the SessionInfo says to force use,
+ // "fail" means to delete the data from the store and retry. Otherwise,
+ // "fail" is just return false.
+ if ( $info->forceUse() && $blob !== false ) {
+ $failHandler = function () use ( $key, &$info, $request ) {
+ $this->store->delete( $key );
+ return $this->loadSessionInfoFromStore( $info, $request );
+ };
+ } else {
+ $failHandler = function () {
+ return false;
+ };
+ }
+
+ $newParams = [];
+
+ if ( $blob !== false ) {
+ // Sanity check: blob must be an array, if it's saved at all
+ if ( !is_array( $blob ) ) {
+ $this->logger->warning( 'Session "{session}": Bad data', [
+ 'session' => $info,
+ ] );
+ $this->store->delete( $key );
+ return $failHandler();
+ }
+
+ // Sanity check: blob has data and metadata arrays
+ if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
+ !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
+ ) {
+ $this->logger->warning( 'Session "{session}": Bad data structure', [
+ 'session' => $info,
+ ] );
+ $this->store->delete( $key );
+ return $failHandler();
+ }
+
+ $data = $blob['data'];
+ $metadata = $blob['metadata'];
+
+ // Sanity check: metadata must be an array and must contain certain
+ // keys, if it's saved at all
+ if ( !array_key_exists( 'userId', $metadata ) ||
+ !array_key_exists( 'userName', $metadata ) ||
+ !array_key_exists( 'userToken', $metadata ) ||
+ !array_key_exists( 'provider', $metadata )
+ ) {
+ $this->logger->warning( 'Session "{session}": Bad metadata', [
+ 'session' => $info,
+ ] );
+ $this->store->delete( $key );
+ return $failHandler();
+ }
+
+ // First, load the provider from metadata, or validate it against the metadata.
+ $provider = $info->getProvider();
+ if ( $provider === null ) {
+ $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
+ if ( !$provider ) {
+ $this->logger->warning(
+ 'Session "{session}": Unknown provider ' . $metadata['provider'],
+ [
+ 'session' => $info,
+ ]
+ );
+ $this->store->delete( $key );
+ return $failHandler();
+ }
+ } elseif ( $metadata['provider'] !== (string)$provider ) {
+ $this->logger->warning( 'Session "{session}": Wrong provider ' .
+ $metadata['provider'] . ' !== ' . $provider,
+ [
+ 'session' => $info,
+ ] );
+ return $failHandler();
+ }
+
+ // Load provider metadata from metadata, or validate it against the metadata
+ $providerMetadata = $info->getProviderMetadata();
+ if ( isset( $metadata['providerMetadata'] ) ) {
+ if ( $providerMetadata === null ) {
+ $newParams['metadata'] = $metadata['providerMetadata'];
+ } else {
+ try {
+ $newProviderMetadata = $provider->mergeMetadata(
+ $metadata['providerMetadata'], $providerMetadata
+ );
+ if ( $newProviderMetadata !== $providerMetadata ) {
+ $newParams['metadata'] = $newProviderMetadata;
+ }
+ } catch ( MetadataMergeException $ex ) {
+ $this->logger->warning(
+ 'Session "{session}": Metadata merge failed: {exception}',
+ [
+ 'session' => $info,
+ 'exception' => $ex,
+ ] + $ex->getContext()
+ );
+ return $failHandler();
+ }
+ }
+ }
+
+ // Next, load the user from metadata, or validate it against the metadata.
+ $userInfo = $info->getUserInfo();
+ if ( !$userInfo ) {
+ // For loading, id is preferred to name.
+ try {
+ if ( $metadata['userId'] ) {
+ $userInfo = UserInfo::newFromId( $metadata['userId'] );
+ } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
+ $userInfo = UserInfo::newFromName( $metadata['userName'] );
+ } else {
+ $userInfo = UserInfo::newAnonymous();
+ }
+ } catch ( \InvalidArgumentException $ex ) {
+ $this->logger->error( 'Session "{session}": {exception}', [
+ 'session' => $info,
+ 'exception' => $ex,
+ ] );
+ return $failHandler();
+ }
+ $newParams['userInfo'] = $userInfo;
+ } else {
+ // User validation passes if user ID matches, or if there
+ // is no saved ID and the names match.
+ if ( $metadata['userId'] ) {
+ if ( $metadata['userId'] !== $userInfo->getId() ) {
+ $this->logger->warning(
+ 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
+ [
+ 'session' => $info,
+ 'uid_a' => $metadata['userId'],
+ 'uid_b' => $userInfo->getId(),
+ ] );
+ return $failHandler();
+ }
+
+ // If the user was renamed, probably best to fail here.
+ if ( $metadata['userName'] !== null &&
+ $userInfo->getName() !== $metadata['userName']
+ ) {
+ $this->logger->warning(
+ 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
+ [
+ 'session' => $info,
+ 'uname_a' => $metadata['userName'],
+ 'uname_b' => $userInfo->getName(),
+ ] );
+ return $failHandler();
+ }
+
+ } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
+ if ( $metadata['userName'] !== $userInfo->getName() ) {
+ $this->logger->warning(
+ 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
+ [
+ 'session' => $info,
+ 'uname_a' => $metadata['userName'],
+ 'uname_b' => $userInfo->getName(),
+ ] );
+ return $failHandler();
+ }
+ } elseif ( !$userInfo->isAnon() ) {
+ // Metadata specifies an anonymous user, but the passed-in
+ // user isn't anonymous.
+ $this->logger->warning(
+ 'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
+ [
+ 'session' => $info,
+ ] );
+ return $failHandler();
+ }
+ }
+
+ // And if we have a token in the metadata, it must match the loaded/provided user.
+ if ( $metadata['userToken'] !== null &&
+ $userInfo->getToken() !== $metadata['userToken']
+ ) {
+ $this->logger->warning( 'Session "{session}": User token mismatch', [
+ 'session' => $info,
+ ] );
+ return $failHandler();
+ }
+ if ( !$userInfo->isVerified() ) {
+ $newParams['userInfo'] = $userInfo->verified();
+ }
+
+ if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
+ $newParams['remembered'] = true;
+ }
+ if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
+ $newParams['forceHTTPS'] = true;
+ }
+ if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
+ $newParams['persisted'] = true;
+ }
+
+ if ( !$info->isIdSafe() ) {
+ $newParams['idIsSafe'] = true;
+ }
+ } else {
+ // No metadata, so we can't load the provider if one wasn't given.
+ if ( $info->getProvider() === null ) {
+ $this->logger->warning(
+ 'Session "{session}": Null provider and no metadata',
+ [
+ 'session' => $info,
+ ] );
+ return $failHandler();
+ }
+
+ // If no user was provided and no metadata, it must be anon.
+ if ( !$info->getUserInfo() ) {
+ if ( $info->getProvider()->canChangeUser() ) {
+ $newParams['userInfo'] = UserInfo::newAnonymous();
+ } else {
+ $this->logger->info(
+ 'Session "{session}": No user provided and provider cannot set user',
+ [
+ 'session' => $info,
+ ] );
+ return $failHandler();
+ }
+ } elseif ( !$info->getUserInfo()->isVerified() ) {
+ // probably just a session timeout
+ $this->logger->info(
+ 'Session "{session}": Unverified user provided and no metadata to auth it',
+ [
+ 'session' => $info,
+ ] );
+ return $failHandler();
+ }
+
+ $data = false;
+ $metadata = false;
+
+ if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
+ // The ID doesn't come from the user, so it should be safe
+ // (and if not, nothing we can do about it anyway)
+ $newParams['idIsSafe'] = true;
+ }
+ }
+
+ // Construct the replacement SessionInfo, if necessary
+ if ( $newParams ) {
+ $newParams['copyFrom'] = $info;
+ $info = new SessionInfo( $info->getPriority(), $newParams );
+ }
+
+ // Allow the provider to check the loaded SessionInfo
+ $providerMetadata = $info->getProviderMetadata();
+ if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
+ return $failHandler();
+ }
+ if ( $providerMetadata !== $info->getProviderMetadata() ) {
+ $info = new SessionInfo( $info->getPriority(), [
+ 'metadata' => $providerMetadata,
+ 'copyFrom' => $info,
+ ] );
+ }
+
+ // Give hooks a chance to abort. Combined with the SessionMetadata
+ // hook, this can allow for tying a session to an IP address or the
+ // like.
+ $reason = 'Hook aborted';
+ if ( !\Hooks::run(
+ 'SessionCheckInfo',
+ [ &$reason, $info, $request, $metadata, $data ]
+ ) ) {
+ $this->logger->warning( 'Session "{session}": ' . $reason, [
+ 'session' => $info,
+ ] );
+ return $failHandler();
+ }
+
+ return true;
+ }
+
+ /**
+ * Create a Session corresponding to the passed SessionInfo
+ * @private For use by a SessionProvider that needs to specially create its
+ * own Session. Most session providers won't need this.
+ * @param SessionInfo $info
+ * @param WebRequest $request
+ * @return Session
+ */
+ public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
+ // @codeCoverageIgnoreStart
+ if ( defined( 'MW_NO_SESSION' ) ) {
+ if ( MW_NO_SESSION === 'warn' ) {
+ // Undocumented safety case for converting existing entry points
+ $this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
+ 'exception' => new \BadMethodCallException( 'Sessions are disabled for this entry point' ),
+ ] );
+ } else {
+ throw new \BadMethodCallException( 'Sessions are disabled for this entry point' );
+ }
+ }
+ // @codeCoverageIgnoreEnd
+
+ $id = $info->getId();
+
+ if ( !isset( $this->allSessionBackends[$id] ) ) {
+ if ( !isset( $this->allSessionIds[$id] ) ) {
+ $this->allSessionIds[$id] = new SessionId( $id );
+ }
+ $backend = new SessionBackend(
+ $this->allSessionIds[$id],
+ $info,
+ $this->store,
+ $this->logger,
+ $this->config->get( 'ObjectCacheSessionExpiry' )
+ );
+ $this->allSessionBackends[$id] = $backend;
+ $delay = $backend->delaySave();
+ } else {
+ $backend = $this->allSessionBackends[$id];
+ $delay = $backend->delaySave();
+ if ( $info->wasPersisted() ) {
+ $backend->persist();
+ }
+ if ( $info->wasRemembered() ) {
+ $backend->setRememberUser( true );
+ }
+ }
+
+ $request->setSessionId( $backend->getSessionId() );
+ $session = $backend->getSession( $request );
+
+ if ( !$info->isIdSafe() ) {
+ $session->resetId();
+ }
+
+ \Wikimedia\ScopedCallback::consume( $delay );
+ return $session;
+ }
+
+ /**
+ * Deregister a SessionBackend
+ * @private For use from \MediaWiki\Session\SessionBackend only
+ * @param SessionBackend $backend
+ */
+ public function deregisterSessionBackend( SessionBackend $backend ) {
+ $id = $backend->getId();
+ if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
+ $this->allSessionBackends[$id] !== $backend ||
+ $this->allSessionIds[$id] !== $backend->getSessionId()
+ ) {
+ throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
+ }
+
+ unset( $this->allSessionBackends[$id] );
+ // Explicitly do not unset $this->allSessionIds[$id]
+ }
+
+ /**
+ * Change a SessionBackend's ID
+ * @private For use from \MediaWiki\Session\SessionBackend only
+ * @param SessionBackend $backend
+ */
+ public function changeBackendId( SessionBackend $backend ) {
+ $sessionId = $backend->getSessionId();
+ $oldId = (string)$sessionId;
+ if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
+ $this->allSessionBackends[$oldId] !== $backend ||
+ $this->allSessionIds[$oldId] !== $sessionId
+ ) {
+ throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
+ }
+
+ $newId = $this->generateSessionId();
+
+ unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
+ $sessionId->setId( $newId );
+ $this->allSessionBackends[$newId] = $backend;
+ $this->allSessionIds[$newId] = $sessionId;
+ }
+
+ /**
+ * Generate a new random session ID
+ * @return string
+ */
+ public function generateSessionId() {
+ do {
+ $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
+ $key = $this->store->makeKey( 'MWSession', $id );
+ } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
+ return $id;
+ }
+
+ /**
+ * Call setters on a PHPSessionHandler
+ * @private Use PhpSessionHandler::install()
+ * @param PHPSessionHandler $handler
+ */
+ public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
+ $handler->setManager( $this, $this->store, $this->logger );
+ }
+
+ /**
+ * Reset the internal caching for unit testing
+ * @protected Unit tests only
+ */
+ public static function resetCache() {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ // @codeCoverageIgnoreStart
+ throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
+ // @codeCoverageIgnoreEnd
+ }
+
+ self::$globalSession = null;
+ self::$globalSessionRequest = null;
+ }
+
+ /**@}*/
+
+}
diff --git a/www/wiki/includes/session/SessionManagerInterface.php b/www/wiki/includes/session/SessionManagerInterface.php
new file mode 100644
index 00000000..c6990fef
--- /dev/null
+++ b/www/wiki/includes/session/SessionManagerInterface.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * MediaWiki\Session entry point interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerAwareInterface;
+use User;
+use WebRequest;
+
+/**
+ * This exists to make IDEs happy, so they don't see the
+ * internal-but-required-to-be-public methods on SessionManager.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+interface SessionManagerInterface extends LoggerAwareInterface {
+ /**
+ * Fetch the session for a request (or a new empty session if none is
+ * attached to it)
+ *
+ * @note You probably want to use $request->getSession() instead. It's more
+ * efficient and doesn't break FauxRequests or sessions that were changed
+ * by $this->getSessionById() or $this->getEmptySession().
+ * @param WebRequest $request Any existing associated session will be reset
+ * to the session corresponding to the data in the request itself.
+ * @return Session
+ * @throws \OverflowException if there are multiple sessions tied for top
+ * priority in the request. Exception has a property "sessionInfos"
+ * holding the SessionInfo objects for the sessions involved.
+ */
+ public function getSessionForRequest( WebRequest $request );
+
+ /**
+ * Fetch a session by ID
+ *
+ * @param string $id
+ * @param bool $create If no session exists for $id, try to create a new one.
+ * May still return null if a session for $id exists but cannot be loaded.
+ * @param WebRequest|null $request Corresponding request. Any existing
+ * session associated with this WebRequest object will be overwritten.
+ * @return Session|null
+ */
+ public function getSessionById( $id, $create = false, WebRequest $request = null );
+
+ /**
+ * Create a new, empty session
+ *
+ * The first provider configured that is able to provide an empty session
+ * will be used.
+ *
+ * @param WebRequest|null $request Corresponding request. Any existing
+ * session associated with this WebRequest object will be overwritten.
+ * @return Session
+ */
+ public function getEmptySession( WebRequest $request = null );
+
+ /**
+ * Invalidate sessions for a user
+ *
+ * After calling this, existing sessions should be invalid. For mutable
+ * session providers, this generally means the user has to log in again;
+ * for immutable providers, it generally means the loss of session data.
+ *
+ * @param User $user
+ */
+ public function invalidateSessionsForUser( User $user );
+
+ /**
+ * Return the HTTP headers that need varying on.
+ *
+ * The return value is such that someone could theoretically do this:
+ * @code
+ * foreach ( $provider->getVaryHeaders() as $header => $options ) {
+ * $outputPage->addVaryHeader( $header, $options );
+ * }
+ * @endcode
+ *
+ * @return array
+ */
+ public function getVaryHeaders();
+
+ /**
+ * Return the list of cookies that need varying on.
+ * @return string[]
+ */
+ public function getVaryCookies();
+
+}
diff --git a/www/wiki/includes/session/SessionProvider.php b/www/wiki/includes/session/SessionProvider.php
new file mode 100644
index 00000000..ba075e0c
--- /dev/null
+++ b/www/wiki/includes/session/SessionProvider.php
@@ -0,0 +1,533 @@
+<?php
+/**
+ * MediaWiki session provider base class
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Config;
+use Language;
+use User;
+use WebRequest;
+
+/**
+ * A SessionProvider provides SessionInfo and support for Session
+ *
+ * A SessionProvider is responsible for taking a WebRequest and determining
+ * the authenticated session that it's a part of. It does this by returning an
+ * SessionInfo object with basic information about the session it thinks is
+ * associated with the request, namely the session ID and possibly the
+ * authenticated user the session belongs to.
+ *
+ * The SessionProvider also provides for updating the WebResponse with
+ * information necessary to provide the client with data that the client will
+ * send with later requests, and for populating the Vary and Key headers with
+ * the data necessary to correctly vary the cache on these client requests.
+ *
+ * An important part of the latter is indicating whether it even *can* tell the
+ * client to include such data in future requests, via the persistsSessionId()
+ * and canChangeUser() methods. The cases are (in order of decreasing
+ * commonness):
+ * - Cannot persist ID, no changing User: The request identifies and
+ * authenticates a particular local user, and the client cannot be
+ * instructed to include an arbitrary session ID with future requests. For
+ * example, OAuth or SSL certificate auth.
+ * - Can persist ID and can change User: The client can be instructed to
+ * return at least one piece of arbitrary data, that being the session ID.
+ * The user identity might also be given to the client, otherwise it's saved
+ * in the session data. For example, cookie-based sessions.
+ * - Can persist ID but no changing User: The request uniquely identifies and
+ * authenticates a local user, and the client can be instructed to return an
+ * arbitrary session ID with future requests. For example, HTTP Digest
+ * authentication might somehow use the 'opaque' field as a session ID
+ * (although getting MediaWiki to return 401 responses without breaking
+ * other stuff might be a challenge).
+ * - Cannot persist ID but can change User: I can't think of a way this
+ * would make sense.
+ *
+ * Note that many methods that are technically "cannot persist ID" could be
+ * turned into "can persist ID but not change User" using a session cookie,
+ * as implemented by ImmutableSessionProviderWithCookie. If doing so, different
+ * session cookie names should be used for different providers to avoid
+ * collisions.
+ *
+ * @ingroup Session
+ * @since 1.27
+ * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
+ */
+abstract class SessionProvider implements SessionProviderInterface, LoggerAwareInterface {
+
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /** @var Config */
+ protected $config;
+
+ /** @var SessionManager */
+ protected $manager;
+
+ /** @var int Session priority. Used for the default newSessionInfo(), but
+ * could be used by subclasses too.
+ */
+ protected $priority;
+
+ /**
+ * @note To fully initialize a SessionProvider, the setLogger(),
+ * setConfig(), and setManager() methods must be called (and should be
+ * called in that order). Failure to do so is liable to cause things to
+ * fail unexpectedly.
+ */
+ public function __construct() {
+ $this->priority = SessionInfo::MIN_PRIORITY + 10;
+ }
+
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Set configuration
+ * @param Config $config
+ */
+ public function setConfig( Config $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * Set the session manager
+ * @param SessionManager $manager
+ */
+ public function setManager( SessionManager $manager ) {
+ $this->manager = $manager;
+ }
+
+ /**
+ * Get the session manager
+ * @return SessionManager
+ */
+ public function getManager() {
+ return $this->manager;
+ }
+
+ /**
+ * Provide session info for a request
+ *
+ * If no session exists for the request, return null. Otherwise return an
+ * SessionInfo object identifying the session.
+ *
+ * If multiple SessionProviders provide sessions, the one with highest
+ * priority wins. In case of a tie, an exception is thrown.
+ * SessionProviders are encouraged to make priorities user-configurable
+ * unless only max-priority makes sense.
+ *
+ * @warning This will be called early in the MediaWiki setup process,
+ * before $wgUser, $wgLang, $wgOut, $wgParser, $wgTitle, and corresponding
+ * pieces of the main RequestContext are set up! If you try to use these,
+ * things *will* break.
+ * @note The SessionProvider must not attempt to auto-create users.
+ * MediaWiki will do this later (when it's safe) if the chosen session has
+ * a user with a valid name but no ID.
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @param WebRequest $request
+ * @return SessionInfo|null
+ */
+ abstract public function provideSessionInfo( WebRequest $request );
+
+ /**
+ * Provide session info for a new, empty session
+ *
+ * Return null if such a session cannot be created. This base
+ * implementation assumes that it only makes sense if a session ID can be
+ * persisted and changing users is allowed.
+ *
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @param string|null $id ID to force for the new session
+ * @return SessionInfo|null
+ * If non-null, must return true for $info->isIdSafe(); pass true for
+ * $data['idIsSafe'] to ensure this.
+ */
+ public function newSessionInfo( $id = null ) {
+ if ( $this->canChangeUser() && $this->persistsSessionId() ) {
+ return new SessionInfo( $this->priority, [
+ 'id' => $id,
+ 'provider' => $this,
+ 'persisted' => false,
+ 'idIsSafe' => true,
+ ] );
+ }
+ return null;
+ }
+
+ /**
+ * Merge saved session provider metadata
+ *
+ * This method will be used to compare the metadata returned by
+ * provideSessionInfo() with the saved metadata (which has been returned by
+ * provideSessionInfo() the last time the session was saved), and merge the two
+ * into the new saved metadata, or abort if the current request is not a valid
+ * continuation of the session.
+ *
+ * The default implementation checks that anything in both arrays is
+ * identical, then returns $providedMetadata.
+ *
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @param array $savedMetadata Saved provider metadata
+ * @param array $providedMetadata Provided provider metadata (from the SessionInfo)
+ * @return array Resulting metadata
+ * @throws MetadataMergeException If the metadata cannot be merged.
+ * Such exceptions will be handled by SessionManager and are a safe way of rejecting
+ * a suspicious or incompatible session. The provider is expected to write an
+ * appropriate message to its logger.
+ */
+ public function mergeMetadata( array $savedMetadata, array $providedMetadata ) {
+ foreach ( $providedMetadata as $k => $v ) {
+ if ( array_key_exists( $k, $savedMetadata ) && $savedMetadata[$k] !== $v ) {
+ $e = new MetadataMergeException( "Key \"$k\" changed" );
+ $e->setContext( [
+ 'old_value' => $savedMetadata[$k],
+ 'new_value' => $v,
+ ] );
+ throw $e;
+ }
+ }
+ return $providedMetadata;
+ }
+
+ /**
+ * Validate a loaded SessionInfo and refresh provider metadata
+ *
+ * This is similar in purpose to the 'SessionCheckInfo' hook, and also
+ * allows for updating the provider metadata. On failure, the provider is
+ * expected to write an appropriate message to its logger.
+ *
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @param SessionInfo $info Any changes by mergeMetadata() will already be reflected here.
+ * @param WebRequest $request
+ * @param array|null &$metadata Provider metadata, may be altered.
+ * @return bool Return false to reject the SessionInfo after all.
+ */
+ public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
+ return true;
+ }
+
+ /**
+ * Indicate whether self::persistSession() can save arbitrary session IDs
+ *
+ * If false, any session passed to self::persistSession() will have an ID
+ * that was originally provided by self::provideSessionInfo().
+ *
+ * If true, the provider may be passed sessions with arbitrary session IDs,
+ * and will be expected to manipulate the request in such a way that future
+ * requests will cause self::provideSessionInfo() to provide a SessionInfo
+ * with that ID.
+ *
+ * For example, a session provider for OAuth would function by matching the
+ * OAuth headers to a particular user, and then would use self::hashToSessionId()
+ * to turn the user and OAuth client ID (and maybe also the user token and
+ * client secret) into a session ID, and therefore can't easily assign that
+ * user+client a different ID. Similarly, a session provider for SSL client
+ * certificates would function by matching the certificate to a particular
+ * user, and then would use self::hashToSessionId() to turn the user and
+ * certificate fingerprint into a session ID, and therefore can't easily
+ * assign a different ID either. On the other hand, a provider that saves
+ * the session ID into a cookie can easily just set the cookie to a
+ * different value.
+ *
+ * @protected For use by \MediaWiki\Session\SessionBackend only
+ * @return bool
+ */
+ abstract public function persistsSessionId();
+
+ /**
+ * Indicate whether the user associated with the request can be changed
+ *
+ * If false, any session passed to self::persistSession() will have a user
+ * that was originally provided by self::provideSessionInfo(). Further,
+ * self::provideSessionInfo() may only provide sessions that have a user
+ * already set.
+ *
+ * If true, the provider may be passed sessions with arbitrary users, and
+ * will be expected to manipulate the request in such a way that future
+ * requests will cause self::provideSessionInfo() to provide a SessionInfo
+ * with that ID. This can be as simple as not passing any 'userInfo' into
+ * SessionInfo's constructor, in which case SessionInfo will load the user
+ * from the saved session's metadata.
+ *
+ * For example, a session provider for OAuth or SSL client certificates
+ * would function by matching the OAuth headers or certificate to a
+ * particular user, and thus would return false here since it can't
+ * arbitrarily assign those OAuth credentials or that certificate to a
+ * different user. A session provider that shoves information into cookies,
+ * on the other hand, could easily do so.
+ *
+ * @protected For use by \MediaWiki\Session\SessionBackend only
+ * @return bool
+ */
+ abstract public function canChangeUser();
+
+ /**
+ * Returns the duration (in seconds) for which users will be remembered when
+ * Session::setRememberUser() is set. Null means setting the remember flag will
+ * have no effect (and endpoints should not offer that option).
+ * @return int|null
+ */
+ public function getRememberUserDuration() {
+ return null;
+ }
+
+ /**
+ * Notification that the session ID was reset
+ *
+ * No need to persist here, persistSession() will be called if appropriate.
+ *
+ * @protected For use by \MediaWiki\Session\SessionBackend only
+ * @param SessionBackend $session Session to persist
+ * @param string $oldId Old session ID
+ * @codeCoverageIgnore
+ */
+ public function sessionIdWasReset( SessionBackend $session, $oldId ) {
+ }
+
+ /**
+ * Persist a session into a request/response
+ *
+ * For example, you might set cookies for the session's ID, user ID, user
+ * name, and user token on the passed request.
+ *
+ * To correctly persist a user independently of the session ID, the
+ * provider should persist both the user ID (or name, but preferably the
+ * ID) and the user token. When reading the data from the request, it
+ * should construct a User object from the ID/name and then verify that the
+ * User object's token matches the token included in the request. Should
+ * the tokens not match, an anonymous user *must* be passed to
+ * SessionInfo::__construct().
+ *
+ * When persisting a user independently of the session ID,
+ * $session->shouldRememberUser() should be checked first. If this returns
+ * false, the user token *must not* be saved to cookies. The user name
+ * and/or ID may be persisted, and should be used to construct an
+ * unverified UserInfo to pass to SessionInfo::__construct().
+ *
+ * A backend that cannot persist sesison ID or user info should implement
+ * this as a no-op.
+ *
+ * @protected For use by \MediaWiki\Session\SessionBackend only
+ * @param SessionBackend $session Session to persist
+ * @param WebRequest $request Request into which to persist the session
+ */
+ abstract public function persistSession( SessionBackend $session, WebRequest $request );
+
+ /**
+ * Remove any persisted session from a request/response
+ *
+ * For example, blank and expire any cookies set by self::persistSession().
+ *
+ * A backend that cannot persist sesison ID or user info should implement
+ * this as a no-op.
+ *
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @param WebRequest $request Request from which to remove any session data
+ */
+ abstract public function unpersistSession( WebRequest $request );
+
+ /**
+ * Prevent future sessions for the user
+ *
+ * If the provider is capable of returning a SessionInfo with a verified
+ * UserInfo for the named user in some manner other than by validating
+ * against $user->getToken(), steps must be taken to prevent that from
+ * occurring in the future. This might add the username to a blacklist, or
+ * it might just delete whatever authentication credentials would allow
+ * such a session in the first place (e.g. remove all OAuth grants or
+ * delete record of the SSL client certificate).
+ *
+ * The intention is that the named account will never again be usable for
+ * normal login (i.e. there is no way to undo the prevention of access).
+ *
+ * Note that the passed user name might not exist locally (i.e.
+ * User::idFromName( $username ) === 0); the name should still be
+ * prevented, if applicable.
+ *
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @param string $username
+ */
+ public function preventSessionsForUser( $username ) {
+ if ( !$this->canChangeUser() ) {
+ throw new \BadMethodCallException(
+ __METHOD__ . ' must be implmented when canChangeUser() is false'
+ );
+ }
+ }
+
+ /**
+ * Invalidate existing sessions for a user
+ *
+ * If the provider has its own equivalent of CookieSessionProvider's Token
+ * cookie (and doesn't use User::getToken() to implement it), it should
+ * reset whatever token it does use here.
+ *
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @param User $user
+ */
+ public function invalidateSessionsForUser( User $user ) {
+ }
+
+ /**
+ * Return the HTTP headers that need varying on.
+ *
+ * The return value is such that someone could theoretically do this:
+ * @code
+ * foreach ( $provider->getVaryHeaders() as $header => $options ) {
+ * $outputPage->addVaryHeader( $header, $options );
+ * }
+ * @endcode
+ *
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @return array
+ */
+ public function getVaryHeaders() {
+ return [];
+ }
+
+ /**
+ * Return the list of cookies that need varying on.
+ * @protected For use by \MediaWiki\Session\SessionManager only
+ * @return string[]
+ */
+ public function getVaryCookies() {
+ return [];
+ }
+
+ /**
+ * Get a suggested username for the login form
+ * @protected For use by \MediaWiki\Session\SessionBackend only
+ * @param WebRequest $request
+ * @return string|null
+ */
+ public function suggestLoginUsername( WebRequest $request ) {
+ return null;
+ }
+
+ /**
+ * Fetch the rights allowed the user when the specified session is active.
+ *
+ * This is mainly meant for allowing the user to restrict access to the account
+ * by certain methods; you probably want to use this with MWGrants. The returned
+ * rights will be intersected with the user's actual rights.
+ *
+ * @param SessionBackend $backend
+ * @return null|string[] Allowed user rights, or null to allow all.
+ */
+ public function getAllowedUserRights( SessionBackend $backend ) {
+ if ( $backend->getProvider() !== $this ) {
+ // Not that this should ever happen...
+ throw new \InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
+ }
+
+ return null;
+ }
+
+ /**
+ * @note Only override this if it makes sense to instantiate multiple
+ * instances of the provider. Value returned must be unique across
+ * configured providers. If you override this, you'll likely need to
+ * override self::describeMessage() as well.
+ * @return string
+ */
+ public function __toString() {
+ return static::class;
+ }
+
+ /**
+ * Return a Message identifying this session type
+ *
+ * This default implementation takes the class name, lowercases it,
+ * replaces backslashes with dashes, and prefixes 'sessionprovider-' to
+ * determine the message key. For example, MediaWiki\Session\CookieSessionProvider
+ * produces 'sessionprovider-mediawiki-session-cookiesessionprovider'.
+ *
+ * @note If self::__toString() is overridden, this will likely need to be
+ * overridden as well.
+ * @warning This will be called early during MediaWiki startup. Do not
+ * use $wgUser, $wgLang, $wgOut, $wgParser, or their equivalents via
+ * RequestContext from this method!
+ * @return \Message
+ */
+ protected function describeMessage() {
+ return wfMessage(
+ 'sessionprovider-' . str_replace( '\\', '-', strtolower( static::class ) )
+ );
+ }
+
+ public function describe( Language $lang ) {
+ $msg = $this->describeMessage();
+ $msg->inLanguage( $lang );
+ if ( $msg->isDisabled() ) {
+ $msg = wfMessage( 'sessionprovider-generic', (string)$this )->inLanguage( $lang );
+ }
+ return $msg->plain();
+ }
+
+ public function whyNoSession() {
+ return null;
+ }
+
+ /**
+ * Hash data as a session ID
+ *
+ * Generally this will only be used when self::persistsSessionId() is false and
+ * the provider has to base the session ID on the verified user's identity
+ * or other static data. The SessionInfo should then typically have the
+ * 'forceUse' flag set to avoid persistent session failure if validation of
+ * the stored data fails.
+ *
+ * @param string $data
+ * @param string|null $key Defaults to $this->config->get( 'SecretKey' )
+ * @return string
+ */
+ final protected function hashToSessionId( $data, $key = null ) {
+ if ( !is_string( $data ) ) {
+ throw new \InvalidArgumentException(
+ '$data must be a string, ' . gettype( $data ) . ' was passed'
+ );
+ }
+ if ( $key !== null && !is_string( $key ) ) {
+ throw new \InvalidArgumentException(
+ '$key must be a string or null, ' . gettype( $key ) . ' was passed'
+ );
+ }
+
+ $hash = \MWCryptHash::hmac( "$this\n$data", $key ?: $this->config->get( 'SecretKey' ), false );
+ if ( strlen( $hash ) < 32 ) {
+ // Should never happen, even md5 is 128 bits
+ // @codeCoverageIgnoreStart
+ throw new \UnexpectedValueException( 'Hash fuction returned less than 128 bits' );
+ // @codeCoverageIgnoreEnd
+ }
+ if ( strlen( $hash ) >= 40 ) {
+ $hash = \Wikimedia\base_convert( $hash, 16, 32, 32 );
+ }
+ return substr( $hash, -32 );
+ }
+
+}
diff --git a/www/wiki/includes/session/SessionProviderInterface.php b/www/wiki/includes/session/SessionProviderInterface.php
new file mode 100644
index 00000000..02ae23d5
--- /dev/null
+++ b/www/wiki/includes/session/SessionProviderInterface.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * MediaWiki\Session\Provider interface
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Language;
+
+/**
+ * This exists to make IDEs happy, so they don't see the
+ * internal-but-required-to-be-public methods on SessionProvider.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+interface SessionProviderInterface {
+
+ /**
+ * Return an identifier for this session type
+ *
+ * @param Language $lang Language to use.
+ * @return string
+ */
+ public function describe( Language $lang );
+
+ /**
+ * Return a Message for why sessions might not be being persisted.
+ *
+ * For example, "check whether you're blocking our cookies".
+ *
+ * @return Message|null
+ */
+ public function whyNoSession();
+
+}
diff --git a/www/wiki/includes/session/Token.php b/www/wiki/includes/session/Token.php
new file mode 100644
index 00000000..14d239d5
--- /dev/null
+++ b/www/wiki/includes/session/Token.php
@@ -0,0 +1,125 @@
+<?php
+/**
+ * MediaWiki session token
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+/**
+ * Value object representing a CSRF token
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+class Token {
+ /** CSRF token suffix. Plus and terminal backslash are included to stop
+ * editing from certain broken proxies. */
+ const SUFFIX = '+\\';
+
+ private $secret = '';
+ private $salt = '';
+ private $new = false;
+
+ /**
+ * @param string $secret Token secret
+ * @param string $salt Token salt
+ * @param bool $new Whether the secret was newly-created
+ */
+ public function __construct( $secret, $salt, $new = false ) {
+ $this->secret = $secret;
+ $this->salt = $salt;
+ $this->new = $new;
+ }
+
+ /**
+ * Decode the timestamp from a token string
+ *
+ * Does not validate the token beyond the syntactic checks necessary to
+ * be able to extract the timestamp.
+ *
+ * @param string $token
+ * @return int|null
+ */
+ public static function getTimestamp( $token ) {
+ $suffixLen = strlen( self::SUFFIX );
+ $len = strlen( $token );
+ if ( $len <= 32 + $suffixLen ||
+ substr( $token, -$suffixLen ) !== self::SUFFIX ||
+ strspn( $token, '0123456789abcdef' ) + $suffixLen !== $len
+ ) {
+ return null;
+ }
+
+ return hexdec( substr( $token, 32, -$suffixLen ) );
+ }
+
+ /**
+ * Get the string representation of the token at a timestamp
+ * @param int $timestamp
+ * @return string
+ */
+ protected function toStringAtTimestamp( $timestamp ) {
+ return hash_hmac( 'md5', $timestamp . $this->salt, $this->secret, false ) .
+ dechex( $timestamp ) .
+ self::SUFFIX;
+ }
+
+ /**
+ * Get the string representation of the token
+ * @return string
+ */
+ public function toString() {
+ return $this->toStringAtTimestamp( wfTimestamp() );
+ }
+
+ public function __toString() {
+ return $this->toString();
+ }
+
+ /**
+ * Test if the token-string matches this token
+ * @param string $userToken
+ * @param int|null $maxAge Return false if $userToken is older than this many seconds
+ * @return bool
+ */
+ public function match( $userToken, $maxAge = null ) {
+ $timestamp = self::getTimestamp( $userToken );
+ if ( $timestamp === null ) {
+ return false;
+ }
+ if ( $maxAge !== null && $timestamp < wfTimestamp() - $maxAge ) {
+ // Expired token
+ return false;
+ }
+
+ $sessionToken = $this->toStringAtTimestamp( $timestamp );
+ return hash_equals( $sessionToken, $userToken );
+ }
+
+ /**
+ * Indicate whether this token was just created
+ * @return bool
+ */
+ public function wasNew() {
+ return $this->new;
+ }
+
+}
diff --git a/www/wiki/includes/session/UserInfo.php b/www/wiki/includes/session/UserInfo.php
new file mode 100644
index 00000000..c01b9eca
--- /dev/null
+++ b/www/wiki/includes/session/UserInfo.php
@@ -0,0 +1,187 @@
+<?php
+/**
+ * MediaWiki session user info
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use User;
+
+/**
+ * Object holding data about a session's user
+ *
+ * In general, this class exists for two purposes:
+ * - User doesn't distinguish between "anonymous user" and "non-anonymous user
+ * that doesn't exist locally", while we do need to.
+ * - We also need the "verified" property described below; tracking it via
+ * another data item to SessionInfo's constructor makes things much more
+ * confusing.
+ *
+ * A UserInfo may be "verified". This indicates that the creator knows that the
+ * request really comes from that user, whether that's by validating OAuth
+ * credentials, SSL client certificates, or by having both the user ID and
+ * token available from cookies.
+ *
+ * An "unverified" UserInfo should be used when it's not possible to
+ * authenticate the user, e.g. the user ID cookie is set but the user Token
+ * cookie isn't. If the Token is available but doesn't match, don't return a
+ * UserInfo at all.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class UserInfo {
+ private $verified = false;
+
+ /** @var User|null */
+ private $user = null;
+
+ private function __construct( User $user = null, $verified ) {
+ if ( $user && $user->isAnon() && !User::isUsableName( $user->getName() ) ) {
+ $this->verified = true;
+ $this->user = null;
+ } else {
+ $this->verified = $verified;
+ $this->user = $user;
+ }
+ }
+
+ /**
+ * Create an instance for an anonymous (i.e. not logged in) user
+ *
+ * Logged-out users are always "verified".
+ *
+ * @return UserInfo
+ */
+ public static function newAnonymous() {
+ return new self( null, true );
+ }
+
+ /**
+ * Create an instance for a logged-in user by ID
+ * @param int $id User ID
+ * @param bool $verified True if the user is verified
+ * @return UserInfo
+ */
+ public static function newFromId( $id, $verified = false ) {
+ $user = User::newFromId( $id );
+
+ // Ensure the ID actually exists
+ $user->load();
+ if ( $user->isAnon() ) {
+ throw new \InvalidArgumentException( 'Invalid ID' );
+ }
+
+ return new self( $user, $verified );
+ }
+
+ /**
+ * Create an instance for a logged-in user by name
+ * @param string $name User name (need not exist locally)
+ * @param bool $verified True if the user is verified
+ * @return UserInfo
+ */
+ public static function newFromName( $name, $verified = false ) {
+ $user = User::newFromName( $name, 'usable' );
+ if ( !$user ) {
+ throw new \InvalidArgumentException( 'Invalid user name' );
+ }
+ return new self( $user, $verified );
+ }
+
+ /**
+ * Create an instance from an existing User object
+ * @param User $user (need not exist locally)
+ * @param bool $verified True if the user is verified
+ * @return UserInfo
+ */
+ public static function newFromUser( User $user, $verified = false ) {
+ return new self( $user, $verified );
+ }
+
+ /**
+ * Return whether this is an anonymous user
+ * @return bool
+ */
+ public function isAnon() {
+ return $this->user === null;
+ }
+
+ /**
+ * Return whether this represents a verified user
+ * @return bool
+ */
+ public function isVerified() {
+ return $this->verified;
+ }
+
+ /**
+ * Return the user ID
+ * @note Do not use this to test for anonymous users!
+ * @return int
+ */
+ public function getId() {
+ return $this->user === null ? 0 : $this->user->getId();
+ }
+
+ /**
+ * Return the user name
+ * @return string|null
+ */
+ public function getName() {
+ return $this->user === null ? null : $this->user->getName();
+ }
+
+ /**
+ * Return the user token
+ * @return string
+ */
+ public function getToken() {
+ return $this->user === null || $this->user->getId() === 0 ? '' : $this->user->getToken( false );
+ }
+
+ /**
+ * Return a User object
+ * @return User
+ */
+ public function getUser() {
+ return $this->user === null ? new User : $this->user;
+ }
+
+ /**
+ * Return a verified version of this object
+ * @return UserInfo
+ */
+ public function verified() {
+ return $this->verified ? $this : new self( $this->user, true );
+ }
+
+ public function __toString() {
+ if ( $this->user === null ) {
+ return '<anon>';
+ }
+ return '<' .
+ ( $this->verified ? '+' : '-' ) . ':' .
+ $this->getId() . ':' . $this->getName() .
+ '>';
+ }
+
+}
diff --git a/www/wiki/includes/shell/Command.php b/www/wiki/includes/shell/Command.php
new file mode 100644
index 00000000..7eaf61ce
--- /dev/null
+++ b/www/wiki/includes/shell/Command.php
@@ -0,0 +1,413 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Shell;
+
+use Exception;
+use MediaWiki\ProcOpenError;
+use MediaWiki\ShellDisabledError;
+use Profiler;
+use Psr\Log\LoggerAwareTrait;
+use Psr\Log\NullLogger;
+
+/**
+ * Class used for executing shell commands
+ *
+ * @since 1.30
+ */
+class Command {
+ use LoggerAwareTrait;
+
+ /** @var string */
+ private $command = '';
+
+ /** @var array */
+ private $limits = [
+ // seconds
+ 'time' => 180,
+ // seconds
+ 'walltime' => 180,
+ // KB
+ 'memory' => 307200,
+ // KB
+ 'filesize' => 102400,
+ ];
+
+ /** @var string[] */
+ private $env = [];
+
+ /** @var string */
+ private $method;
+
+ /** @var bool */
+ private $useStderr = false;
+
+ /** @var bool */
+ private $everExecuted = false;
+
+ /** @var string|false */
+ private $cgroup = false;
+
+ /**
+ * Constructor. Don't call directly, instead use Shell::command()
+ *
+ * @throws ShellDisabledError
+ */
+ public function __construct() {
+ if ( Shell::isDisabled() ) {
+ throw new ShellDisabledError();
+ }
+
+ $this->setLogger( new NullLogger() );
+ }
+
+ /**
+ * Destructor. Makes sure programmer didn't forget to execute the command after all
+ */
+ public function __destruct() {
+ if ( !$this->everExecuted ) {
+ $context = [ 'command' => $this->command ];
+ $message = __CLASS__ . " was instantiated, but execute() was never called.";
+ if ( $this->method ) {
+ $message .= ' Calling method: {method}.';
+ $context['method'] = $this->method;
+ }
+ $message .= ' Command: {command}';
+ $this->logger->warning( $message, $context );
+ }
+ }
+
+ /**
+ * Adds parameters to the command. All parameters are sanitized via Shell::escape().
+ * Null values are ignored.
+ *
+ * @param string|string[] $args,...
+ * @return $this
+ */
+ public function params( /* ... */ ) {
+ $args = func_get_args();
+ if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
+ // If only one argument has been passed, and that argument is an array,
+ // treat it as a list of arguments
+ $args = reset( $args );
+ }
+ $this->command = trim( $this->command . ' ' . Shell::escape( $args ) );
+
+ return $this;
+ }
+
+ /**
+ * Adds unsafe parameters to the command. These parameters are NOT sanitized in any way.
+ * Null values are ignored.
+ *
+ * @param string|string[] $args,...
+ * @return $this
+ */
+ public function unsafeParams( /* ... */ ) {
+ $args = func_get_args();
+ if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
+ // If only one argument has been passed, and that argument is an array,
+ // treat it as a list of arguments
+ $args = reset( $args );
+ }
+ $args = array_filter( $args,
+ function ( $value ) {
+ return $value !== null;
+ }
+ );
+ $this->command = trim( $this->command . ' ' . implode( ' ', $args ) );
+
+ return $this;
+ }
+
+ /**
+ * Sets execution limits
+ *
+ * @param array $limits Associative array of limits. Keys (all optional):
+ * filesize (for ulimit -f), memory, time, walltime.
+ * @return $this
+ */
+ public function limits( array $limits ) {
+ if ( !isset( $limits['walltime'] ) && isset( $limits['time'] ) ) {
+ // Emulate the behavior of old wfShellExec() where walltime fell back on time
+ // if the latter was overridden and the former wasn't
+ $limits['walltime'] = $limits['time'];
+ }
+ $this->limits = $limits + $this->limits;
+
+ return $this;
+ }
+
+ /**
+ * Sets environment variables which should be added to the executed command environment
+ *
+ * @param string[] $env array of variable name => value
+ * @return $this
+ */
+ public function environment( array $env ) {
+ $this->env = $env;
+
+ return $this;
+ }
+
+ /**
+ * Sets calling function for profiler. By default, the caller for execute() will be used.
+ *
+ * @param string $method
+ * @return $this
+ */
+ public function profileMethod( $method ) {
+ $this->method = $method;
+
+ return $this;
+ }
+
+ /**
+ * Controls whether stderr should be included in stdout, including errors from limit.sh.
+ * Default: don't include.
+ *
+ * @param bool $yesno
+ * @return $this
+ */
+ public function includeStderr( $yesno = true ) {
+ $this->useStderr = $yesno;
+
+ return $this;
+ }
+
+ /**
+ * Sets cgroup for this command
+ *
+ * @param string|false $cgroup Absolute file path to the cgroup, or false to not use a cgroup
+ * @return $this
+ */
+ public function cgroup( $cgroup ) {
+ $this->cgroup = $cgroup;
+
+ return $this;
+ }
+
+ /**
+ * Executes command. Afterwards, getExitCode() and getOutput() can be used to access execution
+ * results.
+ *
+ * @return Result
+ * @throws Exception
+ * @throws ProcOpenError
+ * @throws ShellDisabledError
+ */
+ public function execute() {
+ $this->everExecuted = true;
+
+ $profileMethod = $this->method ?: wfGetCaller();
+
+ $envcmd = '';
+ foreach ( $this->env as $k => $v ) {
+ if ( wfIsWindows() ) {
+ /* Surrounding a set in quotes (method used by wfEscapeShellArg) makes the quotes themselves
+ * appear in the environment variable, so we must use carat escaping as documented in
+ * https://technet.microsoft.com/en-us/library/cc723564.aspx
+ * Note however that the quote isn't listed there, but is needed, and the parentheses
+ * are listed there but doesn't appear to need it.
+ */
+ $envcmd .= "set $k=" . preg_replace( '/([&|()<>^"])/', '^\\1', $v ) . '&& ';
+ } else {
+ /* Assume this is a POSIX shell, thus required to accept variable assignments before the command
+ * http://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_09_01
+ */
+ $envcmd .= "$k=" . escapeshellarg( $v ) . ' ';
+ }
+ }
+
+ $cmd = $envcmd . trim( $this->command );
+
+ $useLogPipe = false;
+ if ( is_executable( '/bin/bash' ) ) {
+ $time = intval( $this->limits['time'] );
+ $wallTime = intval( $this->limits['walltime'] );
+ $mem = intval( $this->limits['memory'] );
+ $filesize = intval( $this->limits['filesize'] );
+
+ if ( $time > 0 || $mem > 0 || $filesize > 0 || $wallTime > 0 ) {
+ $cmd = '/bin/bash ' . escapeshellarg( __DIR__ . '/limit.sh' ) . ' ' .
+ escapeshellarg( $cmd ) . ' ' .
+ escapeshellarg(
+ "MW_INCLUDE_STDERR=" . ( $this->useStderr ? '1' : '' ) . ';' .
+ "MW_CPU_LIMIT=$time; " .
+ 'MW_CGROUP=' . escapeshellarg( $this->cgroup ) . '; ' .
+ "MW_MEM_LIMIT=$mem; " .
+ "MW_FILE_SIZE_LIMIT=$filesize; " .
+ "MW_WALL_CLOCK_LIMIT=$wallTime; " .
+ "MW_USE_LOG_PIPE=yes"
+ );
+ $useLogPipe = true;
+ } elseif ( $this->useStderr ) {
+ $cmd .= ' 2>&1';
+ }
+ } elseif ( $this->useStderr ) {
+ $cmd .= ' 2>&1';
+ }
+ wfDebug( __METHOD__ . ": $cmd\n" );
+
+ // Don't try to execute commands that exceed Linux's MAX_ARG_STRLEN.
+ // Other platforms may be more accomodating, but we don't want to be
+ // accomodating, because very long commands probably include user
+ // input. See T129506.
+ if ( strlen( $cmd ) > SHELL_MAX_ARG_STRLEN ) {
+ throw new Exception( __METHOD__ .
+ '(): total length of $cmd must not exceed SHELL_MAX_ARG_STRLEN' );
+ }
+
+ $desc = [
+ 0 => [ 'file', 'php://stdin', 'r' ],
+ 1 => [ 'pipe', 'w' ],
+ 2 => [ 'file', 'php://stderr', 'w' ],
+ ];
+ if ( $useLogPipe ) {
+ $desc[3] = [ 'pipe', 'w' ];
+ }
+ $pipes = null;
+ $scoped = Profiler::instance()->scopedProfileIn( __FUNCTION__ . '-' . $profileMethod );
+ $proc = proc_open( $cmd, $desc, $pipes );
+ if ( !$proc ) {
+ $this->logger->error( "proc_open() failed: {command}", [ 'command' => $cmd ] );
+ throw new ProcOpenError();
+ }
+ $outBuffer = $logBuffer = '';
+ $emptyArray = [];
+ $status = false;
+ $logMsg = false;
+
+ /* According to the documentation, it is possible for stream_select()
+ * to fail due to EINTR. I haven't managed to induce this in testing
+ * despite sending various signals. If it did happen, the error
+ * message would take the form:
+ *
+ * stream_select(): unable to select [4]: Interrupted system call (max_fd=5)
+ *
+ * where [4] is the value of the macro EINTR and "Interrupted system
+ * call" is string which according to the Linux manual is "possibly"
+ * localised according to LC_MESSAGES.
+ */
+ $eintr = defined( 'SOCKET_EINTR' ) ? SOCKET_EINTR : 4;
+ $eintrMessage = "stream_select(): unable to select [$eintr]";
+
+ $running = true;
+ $timeout = null;
+ $numReadyPipes = 0;
+
+ while ( $running === true || $numReadyPipes !== 0 ) {
+ if ( $running ) {
+ $status = proc_get_status( $proc );
+ // If the process has terminated, switch to nonblocking selects
+ // for getting any data still waiting to be read.
+ if ( !$status['running'] ) {
+ $running = false;
+ $timeout = 0;
+ }
+ }
+
+ $readyPipes = $pipes;
+
+ \MediaWiki\suppressWarnings();
+ trigger_error( '' );
+ $numReadyPipes = stream_select( $readyPipes, $emptyArray, $emptyArray, $timeout );
+ \MediaWiki\restoreWarnings();
+
+ if ( $numReadyPipes === false ) {
+ // @codingStandardsIgnoreEnd
+ $error = error_get_last();
+ if ( strncmp( $error['message'], $eintrMessage, strlen( $eintrMessage ) ) == 0 ) {
+ continue;
+ } else {
+ trigger_error( $error['message'], E_USER_WARNING );
+ $logMsg = $error['message'];
+ break;
+ }
+ }
+ foreach ( $readyPipes as $fd => $pipe ) {
+ $block = fread( $pipe, 65536 );
+ if ( $block === '' ) {
+ // End of file
+ fclose( $pipes[$fd] );
+ unset( $pipes[$fd] );
+ if ( !$pipes ) {
+ break 2;
+ }
+ } elseif ( $block === false ) {
+ // Read error
+ $logMsg = "Error reading from pipe";
+ break 2;
+ } elseif ( $fd == 1 ) {
+ // From stdout
+ $outBuffer .= $block;
+ } elseif ( $fd == 3 ) {
+ // From log FD
+ $logBuffer .= $block;
+ if ( strpos( $block, "\n" ) !== false ) {
+ $lines = explode( "\n", $logBuffer );
+ $logBuffer = array_pop( $lines );
+ foreach ( $lines as $line ) {
+ $this->logger->info( $line );
+ }
+ }
+ }
+ }
+ }
+
+ foreach ( $pipes as $pipe ) {
+ fclose( $pipe );
+ }
+
+ // Use the status previously collected if possible, since proc_get_status()
+ // just calls waitpid() which will not return anything useful the second time.
+ if ( $running ) {
+ $status = proc_get_status( $proc );
+ }
+
+ if ( $logMsg !== false ) {
+ // Read/select error
+ $retval = -1;
+ proc_close( $proc );
+ } elseif ( $status['signaled'] ) {
+ $logMsg = "Exited with signal {$status['termsig']}";
+ $retval = 128 + $status['termsig'];
+ proc_close( $proc );
+ } else {
+ if ( $status['running'] ) {
+ $retval = proc_close( $proc );
+ } else {
+ $retval = $status['exitcode'];
+ proc_close( $proc );
+ }
+ if ( $retval == 127 ) {
+ $logMsg = "Possibly missing executable file";
+ } elseif ( $retval >= 129 && $retval <= 192 ) {
+ $logMsg = "Probably exited with signal " . ( $retval - 128 );
+ }
+ }
+
+ if ( $logMsg !== false ) {
+ $this->logger->warning( "$logMsg: {command}", [ 'command' => $cmd ] );
+ }
+
+ return new Result( $retval, $outBuffer );
+ }
+}
diff --git a/www/wiki/includes/shell/CommandFactory.php b/www/wiki/includes/shell/CommandFactory.php
new file mode 100644
index 00000000..c0b8f899
--- /dev/null
+++ b/www/wiki/includes/shell/CommandFactory.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Shell;
+
+use Psr\Log\LoggerAwareTrait;
+use Psr\Log\NullLogger;
+
+/**
+ * Factory facilitating dependency injection for Command
+ *
+ * @since 1.30
+ */
+class CommandFactory {
+ use LoggerAwareTrait;
+
+ /** @var array */
+ private $limits;
+
+ /** @var string|bool */
+ private $cgroup;
+
+ /**
+ * Constructor
+ *
+ * @param array $limits See {@see Command::limits()}
+ * @param string|bool $cgroup See {@see Command::cgroup()}
+ */
+ public function __construct( array $limits, $cgroup ) {
+ $this->limits = $limits;
+ $this->cgroup = $cgroup;
+ $this->setLogger( new NullLogger() );
+ }
+
+ /**
+ * Instantiates a new Command
+ *
+ * @return Command
+ */
+ public function create() {
+ $command = new Command();
+ $command->setLogger( $this->logger );
+
+ return $command
+ ->limits( $this->limits )
+ ->cgroup( $this->cgroup );
+ }
+}
diff --git a/www/wiki/includes/shell/Result.php b/www/wiki/includes/shell/Result.php
new file mode 100644
index 00000000..c1429dfc
--- /dev/null
+++ b/www/wiki/includes/shell/Result.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Shell;
+
+/**
+ * Returned by MediaWiki\Shell\Command::execute()
+ *
+ * @since 1.30
+ */
+class Result {
+ /** @var int */
+ private $exitCode;
+
+ /** @var string */
+ private $stdout;
+
+ /**
+ * @param int $exitCode
+ * @param string $stdout
+ */
+ public function __construct( $exitCode, $stdout ) {
+ $this->exitCode = $exitCode;
+ $this->stdout = $stdout;
+ }
+
+ /**
+ * Returns exit code of the process
+ *
+ * @return int
+ */
+ public function getExitCode() {
+ return $this->exitCode;
+ }
+
+ /**
+ * Returns stdout of the process
+ *
+ * @return string
+ */
+ public function getStdout() {
+ return $this->stdout;
+ }
+}
diff --git a/www/wiki/includes/shell/Shell.php b/www/wiki/includes/shell/Shell.php
new file mode 100644
index 00000000..cef9ffa3
--- /dev/null
+++ b/www/wiki/includes/shell/Shell.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * Class used for executing shell commands
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Shell;
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Executes shell commands
+ *
+ * @since 1.30
+ *
+ * Use call chaining with this class for expressiveness:
+ * $result = Shell::command( 'some command' )
+ * ->environment( [ 'ENVIRONMENT_VARIABLE' => 'VALUE' ] )
+ * ->limits( [ 'time' => 300 ] )
+ * ->execute();
+ *
+ * ... = $result->getExitCode();
+ * ... = $result->getStdout();
+ */
+class Shell {
+
+ /**
+ * Returns a new instance of Command class
+ *
+ * @param string|string[] $command String or array of strings representing the command to
+ * be executed, each value will be escaped.
+ * Example: [ 'convert', '-font', 'font name' ] would produce "'convert' '-font' 'font name'"
+ * @return Command
+ */
+ public static function command( $command ) {
+ $args = func_get_args();
+ if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
+ // If only one argument has been passed, and that argument is an array,
+ // treat it as a list of arguments
+ $args = reset( $args );
+ }
+ $command = MediaWikiServices::getInstance()
+ ->getShellCommandFactory()
+ ->create();
+
+ return $command->params( $args );
+ }
+
+ /**
+ * Check if this class is effectively disabled via php.ini config
+ *
+ * @return bool
+ */
+ public static function isDisabled() {
+ static $disabled = null;
+
+ if ( is_null( $disabled ) ) {
+ if ( !function_exists( 'proc_open' ) ) {
+ wfDebug( "proc_open() is disabled\n" );
+ $disabled = true;
+ } else {
+ $disabled = false;
+ }
+ }
+
+ return $disabled;
+ }
+
+ /**
+ * Version of escapeshellarg() that works better on Windows.
+ *
+ * Originally, this fixed the incorrect use of single quotes on Windows
+ * (https://bugs.php.net/bug.php?id=26285) and the locale problems on Linux in
+ * PHP 5.2.6+ (bug backported to earlier distro releases of PHP).
+ *
+ * @param string $args,... strings to escape and glue together, or a single array of
+ * strings parameter. Null values are ignored.
+ * @return string
+ */
+ public static function escape( /* ... */ ) {
+ $args = func_get_args();
+ if ( count( $args ) === 1 && is_array( reset( $args ) ) ) {
+ // If only one argument has been passed, and that argument is an array,
+ // treat it as a list of arguments
+ $args = reset( $args );
+ }
+
+ $first = true;
+ $retVal = '';
+ foreach ( $args as $arg ) {
+ if ( $arg === null ) {
+ continue;
+ }
+ if ( !$first ) {
+ $retVal .= ' ';
+ } else {
+ $first = false;
+ }
+
+ if ( wfIsWindows() ) {
+ // Escaping for an MSVC-style command line parser and CMD.EXE
+ // @codingStandardsIgnoreStart For long URLs
+ // Refs:
+ // * https://web.archive.org/web/20020708081031/http://mailman.lyra.org/pipermail/scite-interest/2002-March/000436.html
+ // * https://technet.microsoft.com/en-us/library/cc723564.aspx
+ // * T15518
+ // * CR r63214
+ // Double the backslashes before any double quotes. Escape the double quotes.
+ // @codingStandardsIgnoreEnd
+ $tokens = preg_split( '/(\\\\*")/', $arg, -1, PREG_SPLIT_DELIM_CAPTURE );
+ $arg = '';
+ $iteration = 0;
+ foreach ( $tokens as $token ) {
+ if ( $iteration % 2 == 1 ) {
+ // Delimiter, a double quote preceded by zero or more slashes
+ $arg .= str_replace( '\\', '\\\\', substr( $token, 0, -1 ) ) . '\\"';
+ } elseif ( $iteration % 4 == 2 ) {
+ // ^ in $token will be outside quotes, need to be escaped
+ $arg .= str_replace( '^', '^^', $token );
+ } else { // $iteration % 4 == 0
+ // ^ in $token will appear inside double quotes, so leave as is
+ $arg .= $token;
+ }
+ $iteration++;
+ }
+ // Double the backslashes before the end of the string, because
+ // we will soon add a quote
+ $m = [];
+ if ( preg_match( '/^(.*?)(\\\\+)$/', $arg, $m ) ) {
+ $arg = $m[1] . str_replace( '\\', '\\\\', $m[2] );
+ }
+
+ // Add surrounding quotes
+ $retVal .= '"' . $arg . '"';
+ } else {
+ $retVal .= escapeshellarg( $arg );
+ }
+ }
+ return $retVal;
+ }
+}
diff --git a/www/wiki/includes/shell/limit.sh b/www/wiki/includes/shell/limit.sh
new file mode 100755
index 00000000..d71e6603
--- /dev/null
+++ b/www/wiki/includes/shell/limit.sh
@@ -0,0 +1,122 @@
+#!/bin/bash
+#
+# Resource limiting wrapper for command execution
+#
+# Why is this in shell script? Because bash has a setrlimit() wrapper
+# and is available on most Linux systems. If Perl was distributed with
+# BSD::Resource included, we would happily use that instead, but it isn't.
+
+# Clean up cgroup
+cleanup() {
+ # First we have to move the current task into a "garbage" group, otherwise
+ # the cgroup will not be empty, and attempting to remove it will fail with
+ # "Device or resource busy"
+ if [ -w "$MW_CGROUP"/tasks ]; then
+ GARBAGE="$MW_CGROUP"
+ else
+ GARBAGE="$MW_CGROUP"/garbage-`id -un`
+ if [ ! -e "$GARBAGE" ]; then
+ mkdir -m 0700 "$GARBAGE"
+ fi
+ fi
+ echo $BASHPID > "$GARBAGE"/tasks
+
+ # Suppress errors in case the cgroup has disappeared due to a release script
+ rmdir "$MW_CGROUP"/$$ 2>/dev/null
+}
+
+updateTaskCount() {
+ # There are lots of ways to count lines in a file in shell script, but this
+ # is one of the few that doesn't create another process, which would
+ # increase the returned number of tasks.
+ readarray < "$MW_CGROUP"/$$/tasks
+ NUM_TASKS=${#MAPFILE[*]}
+}
+
+log() {
+ echo limit.sh: "$*" >&3
+ echo limit.sh: "$*" >&2
+}
+
+MW_INCLUDE_STDERR=
+MW_USE_LOG_PIPE=
+MW_CPU_LIMIT=0
+MW_CGROUP=
+MW_MEM_LIMIT=0
+MW_FILE_SIZE_LIMIT=0
+MW_WALL_CLOCK_LIMIT=0
+
+# Override settings
+eval "$2"
+
+if [ -n "$MW_INCLUDE_STDERR" ]; then
+ exec 2>&1
+fi
+if [ -z "$MW_USE_LOG_PIPE" ]; then
+ # Open a dummy log FD
+ exec 3>/dev/null
+fi
+
+if [ "$MW_CPU_LIMIT" -gt 0 ]; then
+ ulimit -t "$MW_CPU_LIMIT"
+fi
+if [ "$MW_MEM_LIMIT" -gt 0 ]; then
+ if [ -n "$MW_CGROUP" ]; then
+ # Create cgroup
+ if ! mkdir -m 0700 "$MW_CGROUP"/$$; then
+ log "failed to create the cgroup."
+ MW_CGROUP=""
+ fi
+ fi
+ if [ -n "$MW_CGROUP" ]; then
+ echo $$ > "$MW_CGROUP"/$$/tasks
+ if [ -n "$MW_CGROUP_NOTIFY" ]; then
+ echo "1" > "$MW_CGROUP"/$$/notify_on_release
+ fi
+ # Memory
+ echo $(($MW_MEM_LIMIT*1024)) > "$MW_CGROUP"/$$/memory.limit_in_bytes
+ # Memory+swap
+ # This will be missing if there is no swap
+ if [ -e "$MW_CGROUP"/$$/memory.memsw.limit_in_bytes ]; then
+ echo $(($MW_MEM_LIMIT*1024)) > "$MW_CGROUP"/$$/memory.memsw.limit_in_bytes
+ fi
+ else
+ ulimit -v "$MW_MEM_LIMIT"
+ fi
+else
+ MW_CGROUP=""
+fi
+if [ "$MW_FILE_SIZE_LIMIT" -gt 0 ]; then
+ ulimit -f "$MW_FILE_SIZE_LIMIT"
+fi
+if [ "$MW_WALL_CLOCK_LIMIT" -gt 0 -a -x "/usr/bin/timeout" ]; then
+ /usr/bin/timeout $MW_WALL_CLOCK_LIMIT /bin/bash -c "$1" 3>&-
+ STATUS="$?"
+ if [ "$STATUS" == 124 ]; then
+ log "timed out executing command \"$1\""
+ fi
+else
+ eval "$1" 3>&-
+ STATUS="$?"
+fi
+
+if [ -n "$MW_CGROUP" ]; then
+ updateTaskCount
+
+ if [ $NUM_TASKS -gt 1 ]; then
+ # Spawn a monitor process which will continue to poll for completion
+ # of all processes in the cgroup after termination of the parent shell
+ (
+ while [ $NUM_TASKS -gt 1 ]; do
+ sleep 10
+ updateTaskCount
+ done
+ cleanup
+ ) >&/dev/null < /dev/null 3>&- &
+ disown -a
+ else
+ cleanup
+ fi
+fi
+exit "$STATUS"
+
diff --git a/www/wiki/includes/site/CachingSiteStore.php b/www/wiki/includes/site/CachingSiteStore.php
new file mode 100644
index 00000000..f3cd1e81
--- /dev/null
+++ b/www/wiki/includes/site/CachingSiteStore.php
@@ -0,0 +1,199 @@
+<?php
+
+/**
+ * Represents the site configuration of a wiki.
+ * Holds a list of sites (ie SiteList), with a caching layer.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.25
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ */
+class CachingSiteStore implements SiteStore {
+
+ /**
+ * @var SiteList|null
+ */
+ private $sites = null;
+
+ /**
+ * @var string|null
+ */
+ private $cacheKey;
+
+ /**
+ * @var int
+ */
+ private $cacheTimeout;
+
+ /**
+ * @var BagOStuff
+ */
+ private $cache;
+
+ /**
+ * @var SiteStore
+ */
+ private $siteStore;
+
+ /**
+ * @param SiteStore $siteStore
+ * @param BagOStuff $cache
+ * @param string|null $cacheKey
+ * @param int $cacheTimeout
+ */
+ public function __construct(
+ SiteStore $siteStore,
+ BagOStuff $cache,
+ $cacheKey = null,
+ $cacheTimeout = 3600
+ ) {
+ $this->siteStore = $siteStore;
+ $this->cache = $cache;
+ $this->cacheKey = $cacheKey;
+ $this->cacheTimeout = $cacheTimeout;
+ }
+
+ /**
+ * Constructs a cache key to use for caching the list of sites.
+ *
+ * This includes the concrete class name of the site list as well as a version identifier
+ * for the list's serialization, to avoid problems when unserializing site lists serialized
+ * by an older version, e.g. when reading from a cache.
+ *
+ * The cache key also includes information about where the sites were loaded from, e.g.
+ * the name of a database table.
+ *
+ * @see SiteList::getSerialVersionId
+ *
+ * @return string The cache key.
+ */
+ private function getCacheKey() {
+ if ( $this->cacheKey === null ) {
+ $type = 'SiteList#' . SiteList::getSerialVersionId();
+ $this->cacheKey = $this->cache->makeKey( "sites/$type" );
+ }
+
+ return $this->cacheKey;
+ }
+
+ /**
+ * @see SiteStore::getSites
+ *
+ * @since 1.25
+ *
+ * @return SiteList
+ */
+ public function getSites() {
+ if ( $this->sites === null ) {
+ $this->sites = $this->cache->get( $this->getCacheKey() );
+
+ if ( !is_object( $this->sites ) ) {
+ $this->sites = $this->siteStore->getSites();
+
+ $this->cache->set( $this->getCacheKey(), $this->sites, $this->cacheTimeout );
+ }
+ }
+
+ return $this->sites;
+ }
+
+ /**
+ * @see SiteStore::getSite
+ *
+ * @since 1.25
+ *
+ * @param string $globalId
+ *
+ * @return Site|null
+ */
+ public function getSite( $globalId ) {
+ $sites = $this->getSites();
+
+ return $sites->hasSite( $globalId ) ? $sites->getSite( $globalId ) : null;
+ }
+
+ /**
+ * @see SiteStore::saveSite
+ *
+ * @since 1.25
+ *
+ * @param Site $site
+ *
+ * @return bool Success indicator
+ */
+ public function saveSite( Site $site ) {
+ return $this->saveSites( [ $site ] );
+ }
+
+ /**
+ * @see SiteStore::saveSites
+ *
+ * @since 1.25
+ *
+ * @param Site[] $sites
+ *
+ * @return bool Success indicator
+ */
+ public function saveSites( array $sites ) {
+ if ( empty( $sites ) ) {
+ return true;
+ }
+
+ $success = $this->siteStore->saveSites( $sites );
+
+ // purge cache
+ $this->reset();
+
+ return $success;
+ }
+
+ /**
+ * Purges the internal and external cache of the site list, forcing the list.
+ * of sites to be reloaded.
+ *
+ * Only use this for testing, as APC is typically used and is per-server
+ *
+ * @since 1.25
+ */
+ public function reset() {
+ // purge cache
+ $this->cache->delete( $this->getCacheKey() );
+ $this->sites = null;
+ }
+
+ /**
+ * Clears the list of sites stored.
+ *
+ * Only use this for testing, as APC is typically used and is per-server.
+ *
+ * @see SiteStore::clear()
+ *
+ * @return bool Success
+ */
+ public function clear() {
+ $this->reset();
+
+ return $this->siteStore->clear();
+ }
+
+}
diff --git a/www/wiki/includes/site/DBSiteStore.php b/www/wiki/includes/site/DBSiteStore.php
new file mode 100644
index 00000000..7fcfbe59
--- /dev/null
+++ b/www/wiki/includes/site/DBSiteStore.php
@@ -0,0 +1,284 @@
+<?php
+
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Represents the site configuration of a wiki.
+ * Holds a list of sites (ie SiteList), stored in the database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.25
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ * @author Daniel Kinzler
+ */
+class DBSiteStore implements SiteStore {
+
+ /**
+ * @var SiteList|null
+ */
+ protected $sites = null;
+
+ /**
+ * @var LoadBalancer
+ */
+ private $dbLoadBalancer;
+
+ /**
+ * @since 1.27
+ *
+ * @todo: inject some kind of connection manager that is aware of the target wiki,
+ * instead of injecting a LoadBalancer.
+ *
+ * @param LoadBalancer $dbLoadBalancer
+ */
+ public function __construct( LoadBalancer $dbLoadBalancer ) {
+ $this->dbLoadBalancer = $dbLoadBalancer;
+ }
+
+ /**
+ * @see SiteStore::getSites
+ *
+ * @since 1.25
+ *
+ * @return SiteList
+ */
+ public function getSites() {
+ $this->loadSites();
+
+ return $this->sites;
+ }
+
+ /**
+ * Fetches the site from the database and loads them into the sites field.
+ *
+ * @since 1.25
+ */
+ protected function loadSites() {
+ $this->sites = new SiteList();
+
+ $dbr = $this->dbLoadBalancer->getConnection( DB_REPLICA );
+
+ $res = $dbr->select(
+ 'sites',
+ [
+ 'site_id',
+ 'site_global_key',
+ 'site_type',
+ 'site_group',
+ 'site_source',
+ 'site_language',
+ 'site_protocol',
+ 'site_domain',
+ 'site_data',
+ 'site_forward',
+ 'site_config',
+ ],
+ '',
+ __METHOD__,
+ [ 'ORDER BY' => 'site_global_key' ]
+ );
+
+ foreach ( $res as $row ) {
+ $site = Site::newForType( $row->site_type );
+ $site->setGlobalId( $row->site_global_key );
+ $site->setInternalId( (int)$row->site_id );
+ $site->setForward( (bool)$row->site_forward );
+ $site->setGroup( $row->site_group );
+ $site->setLanguageCode( $row->site_language === ''
+ ? null
+ : $row->site_language
+ );
+ $site->setSource( $row->site_source );
+ $site->setExtraData( unserialize( $row->site_data ) );
+ $site->setExtraConfig( unserialize( $row->site_config ) );
+ $this->sites[] = $site;
+ }
+
+ // Batch load the local site identifiers.
+ $ids = $dbr->select(
+ 'site_identifiers',
+ [
+ 'si_site',
+ 'si_type',
+ 'si_key',
+ ],
+ [],
+ __METHOD__
+ );
+
+ foreach ( $ids as $id ) {
+ if ( $this->sites->hasInternalId( $id->si_site ) ) {
+ $site = $this->sites->getSiteByInternalId( $id->si_site );
+ $site->addLocalId( $id->si_type, $id->si_key );
+ $this->sites->setSite( $site );
+ }
+ }
+ }
+
+ /**
+ * @see SiteStore::getSite
+ *
+ * @since 1.25
+ *
+ * @param string $globalId
+ *
+ * @return Site|null
+ */
+ public function getSite( $globalId ) {
+ if ( $this->sites === null ) {
+ $this->sites = $this->getSites();
+ }
+
+ return $this->sites->hasSite( $globalId ) ? $this->sites->getSite( $globalId ) : null;
+ }
+
+ /**
+ * @see SiteStore::saveSite
+ *
+ * @since 1.25
+ *
+ * @param Site $site
+ *
+ * @return bool Success indicator
+ */
+ public function saveSite( Site $site ) {
+ return $this->saveSites( [ $site ] );
+ }
+
+ /**
+ * @see SiteStore::saveSites
+ *
+ * @since 1.25
+ *
+ * @param Site[] $sites
+ *
+ * @return bool Success indicator
+ */
+ public function saveSites( array $sites ) {
+ if ( empty( $sites ) ) {
+ return true;
+ }
+
+ $dbw = $this->dbLoadBalancer->getConnection( DB_MASTER );
+
+ $dbw->startAtomic( __METHOD__ );
+
+ $success = true;
+
+ $internalIds = [];
+ $localIds = [];
+
+ foreach ( $sites as $site ) {
+ if ( $site->getInternalId() !== null ) {
+ $internalIds[] = $site->getInternalId();
+ }
+
+ $fields = [
+ // Site data
+ 'site_global_key' => $site->getGlobalId(), // TODO: check not null
+ 'site_type' => $site->getType(),
+ 'site_group' => $site->getGroup(),
+ 'site_source' => $site->getSource(),
+ 'site_language' => $site->getLanguageCode() === null ? '' : $site->getLanguageCode(),
+ 'site_protocol' => $site->getProtocol(),
+ 'site_domain' => strrev( $site->getDomain() ) . '.',
+ 'site_data' => serialize( $site->getExtraData() ),
+
+ // Site config
+ 'site_forward' => $site->shouldForward() ? 1 : 0,
+ 'site_config' => serialize( $site->getExtraConfig() ),
+ ];
+
+ $rowId = $site->getInternalId();
+ if ( $rowId !== null ) {
+ $success = $dbw->update(
+ 'sites', $fields, [ 'site_id' => $rowId ], __METHOD__
+ ) && $success;
+ } else {
+ $success = $dbw->insert( 'sites', $fields, __METHOD__ ) && $success;
+ $rowId = $dbw->insertId();
+ }
+
+ foreach ( $site->getLocalIds() as $idType => $ids ) {
+ foreach ( $ids as $id ) {
+ $localIds[] = [ $rowId, $idType, $id ];
+ }
+ }
+ }
+
+ if ( $internalIds !== [] ) {
+ $dbw->delete(
+ 'site_identifiers',
+ [ 'si_site' => $internalIds ],
+ __METHOD__
+ );
+ }
+
+ foreach ( $localIds as $localId ) {
+ $dbw->insert(
+ 'site_identifiers',
+ [
+ 'si_site' => $localId[0],
+ 'si_type' => $localId[1],
+ 'si_key' => $localId[2],
+ ],
+ __METHOD__
+ );
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+
+ $this->reset();
+
+ return $success;
+ }
+
+ /**
+ * Resets the SiteList
+ *
+ * @since 1.25
+ */
+ public function reset() {
+ $this->sites = null;
+ }
+
+ /**
+ * Clears the list of sites stored in the database.
+ *
+ * @see SiteStore::clear()
+ *
+ * @return bool Success
+ */
+ public function clear() {
+ $dbw = $this->dbLoadBalancer->getConnection( DB_MASTER );
+
+ $dbw->startAtomic( __METHOD__ );
+ $ok = $dbw->delete( 'sites', '*', __METHOD__ );
+ $ok = $dbw->delete( 'site_identifiers', '*', __METHOD__ ) && $ok;
+ $dbw->endAtomic( __METHOD__ );
+
+ $this->reset();
+
+ return $ok;
+ }
+
+}
diff --git a/www/wiki/includes/site/FileBasedSiteLookup.php b/www/wiki/includes/site/FileBasedSiteLookup.php
new file mode 100644
index 00000000..96544403
--- /dev/null
+++ b/www/wiki/includes/site/FileBasedSiteLookup.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ *
+ * @license GNU GPL v2+
+ */
+
+/**
+ * Provides a file-based cache of a SiteStore. The sites are stored in
+ * a json file. (see docs/sitescache.txt regarding format)
+ *
+ * The cache can be built with the rebuildSitesCache.php maintenance script,
+ * and a MediaWiki instance can be setup to use this by setting the
+ * 'wgSitesCacheFile' configuration to the cache file location.
+ *
+ * @since 1.25
+ */
+class FileBasedSiteLookup implements SiteLookup {
+
+ /**
+ * @var SiteList
+ */
+ private $sites = null;
+
+ /**
+ * @var string
+ */
+ private $cacheFile;
+
+ /**
+ * @param string $cacheFile
+ */
+ public function __construct( $cacheFile ) {
+ $this->cacheFile = $cacheFile;
+ }
+
+ /**
+ * @since 1.25
+ *
+ * @return SiteList
+ */
+ public function getSites() {
+ if ( $this->sites === null ) {
+ $this->sites = $this->loadSitesFromCache();
+ }
+
+ return $this->sites;
+ }
+
+ /**
+ * @param string $globalId
+ *
+ * @since 1.25
+ *
+ * @return Site|null
+ */
+ public function getSite( $globalId ) {
+ $sites = $this->getSites();
+
+ return $sites->hasSite( $globalId ) ? $sites->getSite( $globalId ) : null;
+ }
+
+ /**
+ * @return SiteList
+ */
+ private function loadSitesFromCache() {
+ $data = $this->loadJsonFile();
+
+ $sites = new SiteList();
+
+ // @todo lazy initialize the site objects in the site list (e.g. only when needed to access)
+ foreach ( $data['sites'] as $siteArray ) {
+ $sites[] = $this->newSiteFromArray( $siteArray );
+ }
+
+ return $sites;
+ }
+
+ /**
+ * @throws MWException
+ * @return array see docs/sitescache.txt for format of the array.
+ */
+ private function loadJsonFile() {
+ if ( !is_readable( $this->cacheFile ) ) {
+ throw new MWException( 'SiteList cache file not found.' );
+ }
+
+ $contents = file_get_contents( $this->cacheFile );
+ $data = json_decode( $contents, true );
+
+ if ( !is_array( $data ) || !is_array( $data['sites'] )
+ || !array_key_exists( 'sites', $data )
+ ) {
+ throw new MWException( 'SiteStore json cache data is invalid.' );
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param array $data
+ *
+ * @return Site
+ */
+ private function newSiteFromArray( array $data ) {
+ $siteType = array_key_exists( 'type', $data ) ? $data['type'] : Site::TYPE_UNKNOWN;
+ $site = Site::newForType( $siteType );
+
+ $site->setGlobalId( $data['globalid'] );
+ $site->setForward( $data['forward'] );
+ $site->setGroup( $data['group'] );
+ $site->setLanguageCode( $data['language'] );
+ $site->setSource( $data['source'] );
+ $site->setExtraData( $data['data'] );
+ $site->setExtraConfig( $data['config'] );
+
+ foreach ( $data['identifiers'] as $identifier ) {
+ $site->addLocalId( $identifier['type'], $identifier['key'] );
+ }
+
+ return $site;
+ }
+
+}
diff --git a/www/wiki/includes/site/HashSiteStore.php b/www/wiki/includes/site/HashSiteStore.php
new file mode 100644
index 00000000..6d98e725
--- /dev/null
+++ b/www/wiki/includes/site/HashSiteStore.php
@@ -0,0 +1,124 @@
+<?php
+/**
+ * In-memory implementation of SiteStore.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * In-memory SiteStore implementation, storing sites in an associative array.
+ *
+ * @author Daniel Kinzler
+ * @author Katie Filbert < aude.wiki@gmail.com >
+ *
+ * @since 1.25
+ * @ingroup Site
+ */
+class HashSiteStore implements SiteStore {
+
+ /**
+ * @var Site[]
+ */
+ private $sites = [];
+
+ /**
+ * @param Site[] $sites
+ */
+ public function __construct( $sites = [] ) {
+ $this->saveSites( $sites );
+ }
+
+ /**
+ * Saves the provided site.
+ *
+ * @since 1.25
+ *
+ * @param Site $site
+ *
+ * @return bool Success indicator
+ */
+ public function saveSite( Site $site ) {
+ $this->sites[$site->getGlobalId()] = $site;
+
+ return true;
+ }
+
+ /**
+ * Saves the provided sites.
+ *
+ * @since 1.25
+ *
+ * @param Site[] $sites
+ *
+ * @return bool Success indicator
+ */
+ public function saveSites( array $sites ) {
+ foreach ( $sites as $site ) {
+ $this->saveSite( $site );
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the site with provided global id, or null if there is no such site.
+ *
+ * @since 1.25
+ *
+ * @param string $globalId
+ * @param string $source either 'cache' or 'recache'.
+ * If 'cache', the values can (but not obliged) come from a cache.
+ *
+ * @return Site|null
+ */
+ public function getSite( $globalId, $source = 'cache' ) {
+ if ( isset( $this->sites[$globalId] ) ) {
+ return $this->sites[$globalId];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns a list of all sites. By default this site is
+ * fetched from the cache, which can be changed to loading
+ * the list from the database using the $useCache parameter.
+ *
+ * @since 1.25
+ *
+ * @param string $source either 'cache' or 'recache'.
+ * If 'cache', the values can (but not obliged) come from a cache.
+ *
+ * @return SiteList
+ */
+ public function getSites( $source = 'cache' ) {
+ return new SiteList( $this->sites );
+ }
+
+ /**
+ * Deletes all sites from the database. After calling clear(), getSites() will return an empty
+ * list and getSite() will return null until saveSite() or saveSites() is called.
+ * @return bool
+ */
+ public function clear() {
+ $this->sites = [];
+
+ return true;
+ }
+
+}
diff --git a/www/wiki/includes/site/MediaWikiPageNameNormalizer.php b/www/wiki/includes/site/MediaWikiPageNameNormalizer.php
new file mode 100644
index 00000000..c4e490a4
--- /dev/null
+++ b/www/wiki/includes/site/MediaWikiPageNameNormalizer.php
@@ -0,0 +1,211 @@
+<?php
+
+namespace MediaWiki\Site;
+
+use FormatJson;
+use Http;
+use UtfNormal\Validator;
+
+/**
+ * Service for normalizing a page name using a MediaWiki api.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.27
+ *
+ * @license GNU GPL v2+
+ * @author John Erling Blad < jeblad@gmail.com >
+ * @author Daniel Kinzler
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ * @author Marius Hoch
+ */
+class MediaWikiPageNameNormalizer {
+
+ /**
+ * @var Http
+ */
+ private $http;
+
+ /**
+ * @param Http|null $http
+ */
+ public function __construct( Http $http = null ) {
+ if ( !$http ) {
+ $http = new Http();
+ }
+
+ $this->http = $http;
+ }
+
+ /**
+ * Returns the normalized form of the given page title, using the
+ * normalization rules of the given site. If the given title is a redirect,
+ * the redirect weill be resolved and the redirect target is returned.
+ *
+ * @note This actually makes an API request to the remote site, so beware
+ * that this function is slow and depends on an external service.
+ *
+ * @see Site::normalizePageName
+ *
+ * @since 1.27
+ *
+ * @param string $pageName
+ * @param string $apiUrl
+ *
+ * @return string
+ * @throws \MWException
+ */
+ public function normalizePageName( $pageName, $apiUrl ) {
+ // Check if we have strings as arguments.
+ if ( !is_string( $pageName ) ) {
+ throw new \MWException( '$pageName must be a string' );
+ }
+
+ // Go on call the external site
+
+ // Make sure the string is normalized into NFC (due to T42017)
+ // but do nothing to the whitespaces, that should work appropriately.
+ // @see https://phabricator.wikimedia.org/T42017
+ $pageName = Validator::cleanUp( $pageName );
+
+ // Build the args for the specific call
+ $args = [
+ 'action' => 'query',
+ 'prop' => 'info',
+ 'redirects' => true,
+ 'converttitles' => true,
+ 'format' => 'json',
+ 'titles' => $pageName,
+ // @todo options for maxlag and maxage
+ // Note that maxlag will lead to a long delay before a reply is made,
+ // but that maxage can avoid the extreme delay. On the other hand
+ // maxage could be nice to use anyhow as it stops unnecessary requests.
+ // Also consider smaxage if maxage is used.
+ ];
+
+ $url = wfAppendQuery( $apiUrl, $args );
+
+ // Go on call the external site
+ // @todo we need a good way to specify a timeout here.
+ $ret = $this->http->get( $url, [], __METHOD__ );
+
+ if ( $ret === false ) {
+ wfDebugLog( "MediaWikiSite", "call to external site failed: $url" );
+ return false;
+ }
+
+ $data = FormatJson::decode( $ret, true );
+
+ if ( !is_array( $data ) ) {
+ wfDebugLog( "MediaWikiSite", "call to <$url> returned bad json: " . $ret );
+ return false;
+ }
+
+ $page = static::extractPageRecord( $data, $pageName );
+
+ if ( isset( $page['missing'] ) ) {
+ wfDebugLog( "MediaWikiSite", "call to <$url> returned a marker for a missing page title! "
+ . $ret );
+ return false;
+ }
+
+ if ( isset( $page['invalid'] ) ) {
+ wfDebugLog( "MediaWikiSite", "call to <$url> returned a marker for an invalid page title! "
+ . $ret );
+ return false;
+ }
+
+ if ( !isset( $page['title'] ) ) {
+ wfDebugLog( "MediaWikiSite", "call to <$url> did not return a page title! " . $ret );
+ return false;
+ }
+
+ return $page['title'];
+ }
+
+ /**
+ * Get normalization record for a given page title from an API response.
+ *
+ * @param array $externalData A reply from the API on a external server.
+ * @param string $pageTitle Identifies the page at the external site, needing normalization.
+ *
+ * @return array|bool A 'page' structure representing the page identified by $pageTitle.
+ */
+ private static function extractPageRecord( $externalData, $pageTitle ) {
+ // If there is a special case with only one returned page
+ // we can cheat, and only return
+ // the single page in the "pages" substructure.
+ if ( isset( $externalData['query']['pages'] ) ) {
+ $pages = array_values( $externalData['query']['pages'] );
+ if ( count( $pages ) === 1 ) {
+ return $pages[0];
+ }
+ }
+ // This is only used during internal testing, as it is assumed
+ // a more optimal (and lossfree) storage.
+ // Make initial checks and return if prerequisites are not meet.
+ if ( !is_array( $externalData ) || !isset( $externalData['query'] ) ) {
+ return false;
+ }
+ // Loop over the tree different named structures, that otherwise are similar
+ $structs = [
+ 'normalized' => 'from',
+ 'converted' => 'from',
+ 'redirects' => 'from',
+ 'pages' => 'title'
+ ];
+ foreach ( $structs as $listId => $fieldId ) {
+ // Check if the substructure exist at all.
+ if ( !isset( $externalData['query'][$listId] ) ) {
+ continue;
+ }
+ // Filter the substructure down to what we actually are using.
+ $collectedHits = array_filter(
+ array_values( $externalData['query'][$listId] ),
+ function ( $a ) use ( $fieldId, $pageTitle ) {
+ return $a[$fieldId] === $pageTitle;
+ }
+ );
+ // If still looping over normalization, conversion or redirects,
+ // then we need to keep the new page title for later rounds.
+ if ( $fieldId === 'from' && is_array( $collectedHits ) ) {
+ switch ( count( $collectedHits ) ) {
+ case 0:
+ break;
+ case 1:
+ $pageTitle = $collectedHits[0]['to'];
+ break;
+ default:
+ return false;
+ }
+ } elseif ( $fieldId === 'title' && is_array( $collectedHits ) ) {
+ // If on the pages structure we should prepare for returning.
+
+ switch ( count( $collectedHits ) ) {
+ case 0:
+ return false;
+ case 1:
+ return array_shift( $collectedHits );
+ default:
+ return false;
+ }
+ }
+ }
+ // should never be here
+ return false;
+ }
+
+}
diff --git a/www/wiki/includes/site/MediaWikiSite.php b/www/wiki/includes/site/MediaWikiSite.php
new file mode 100644
index 00000000..f31a77d3
--- /dev/null
+++ b/www/wiki/includes/site/MediaWikiSite.php
@@ -0,0 +1,213 @@
+<?php
+
+use MediaWiki\Site\MediaWikiPageNameNormalizer;
+
+/**
+ * Class representing a MediaWiki site.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Site
+ * @license GNU GPL v2+
+ * @author John Erling Blad < jeblad@gmail.com >
+ * @author Daniel Kinzler
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+
+/**
+ * Class representing a MediaWiki site.
+ *
+ * @since 1.21
+ *
+ * @ingroup Site
+ */
+class MediaWikiSite extends Site {
+ const PATH_FILE = 'file_path';
+ const PATH_PAGE = 'page_path';
+
+ /**
+ * @since 1.21
+ *
+ * @param string $type
+ */
+ public function __construct( $type = self::TYPE_MEDIAWIKI ) {
+ parent::__construct( $type );
+ }
+
+ /**
+ * Returns the database form of the given title.
+ *
+ * @since 1.21
+ *
+ * @param string $title The target page's title, in normalized form.
+ *
+ * @return string
+ */
+ public function toDBKey( $title ) {
+ return str_replace( ' ', '_', $title );
+ }
+
+ /**
+ * Returns the normalized form of the given page title, using the
+ * normalization rules of the given site. If the given title is a redirect,
+ * the redirect weill be resolved and the redirect target is returned.
+ *
+ * @note This actually makes an API request to the remote site, so beware
+ * that this function is slow and depends on an external service.
+ *
+ * @note If MW_PHPUNIT_TEST is defined, the call to the external site is
+ * skipped, and the title is normalized using the local normalization
+ * rules as implemented by the Title class.
+ *
+ * @see Site::normalizePageName
+ *
+ * @since 1.21
+ *
+ * @param string $pageName
+ *
+ * @return string
+ * @throws MWException
+ */
+ public function normalizePageName( $pageName ) {
+ if ( defined( 'MW_PHPUNIT_TEST' ) ) {
+ // If the code is under test, don't call out to other sites, just
+ // normalize locally.
+ // Note: this may cause results to be inconsistent with the actual
+ // normalization used by the respective remote site!
+
+ $t = Title::newFromText( $pageName );
+ return $t->getPrefixedText();
+ } else {
+ static $mediaWikiPageNameNormalizer = null;
+
+ if ( $mediaWikiPageNameNormalizer === null ) {
+ $mediaWikiPageNameNormalizer = new MediaWikiPageNameNormalizer();
+ }
+
+ return $mediaWikiPageNameNormalizer->normalizePageName(
+ $pageName,
+ $this->getFileUrl( 'api.php' )
+ );
+ }
+ }
+
+ /**
+ * @see Site::getLinkPathType
+ * Returns Site::PATH_PAGE
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getLinkPathType() {
+ return self::PATH_PAGE;
+ }
+
+ /**
+ * Returns the relative page path.
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getRelativePagePath() {
+ return parse_url( $this->getPath( self::PATH_PAGE ), PHP_URL_PATH );
+ }
+
+ /**
+ * Returns the relative file path.
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getRelativeFilePath() {
+ return parse_url( $this->getPath( self::PATH_FILE ), PHP_URL_PATH );
+ }
+
+ /**
+ * Sets the relative page path.
+ *
+ * @since 1.21
+ *
+ * @param string $path
+ */
+ public function setPagePath( $path ) {
+ $this->setPath( self::PATH_PAGE, $path );
+ }
+
+ /**
+ * Sets the relative file path.
+ *
+ * @since 1.21
+ *
+ * @param string $path
+ */
+ public function setFilePath( $path ) {
+ $this->setPath( self::PATH_FILE, $path );
+ }
+
+ /**
+ * @see Site::getPageUrl
+ *
+ * This implementation returns a URL constructed using the path returned by getLinkPath().
+ * In addition to the default behavior implemented by Site::getPageUrl(), this
+ * method converts the $pageName to DBKey-format by replacing spaces with underscores
+ * before using it in the URL.
+ *
+ * @since 1.21
+ *
+ * @param string|bool $pageName Page name or false (default: false)
+ *
+ * @return string
+ */
+ public function getPageUrl( $pageName = false ) {
+ $url = $this->getLinkPath();
+
+ if ( $url === false ) {
+ return false;
+ }
+
+ if ( $pageName !== false ) {
+ $pageName = $this->toDBKey( trim( $pageName ) );
+ $url = str_replace( '$1', wfUrlencode( $pageName ), $url );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Returns the full file path (ie site url + relative file path).
+ * The path should go at the $1 marker. If the $path
+ * argument is provided, the marker will be replaced by it's value.
+ *
+ * @since 1.21
+ *
+ * @param string|bool $path
+ *
+ * @return string
+ */
+ public function getFileUrl( $path = false ) {
+ $filePath = $this->getPath( self::PATH_FILE );
+
+ if ( $filePath !== false ) {
+ $filePath = str_replace( '$1', $path, $filePath );
+ }
+
+ return $filePath;
+ }
+}
diff --git a/www/wiki/includes/site/Site.php b/www/wiki/includes/site/Site.php
new file mode 100644
index 00000000..a6e63391
--- /dev/null
+++ b/www/wiki/includes/site/Site.php
@@ -0,0 +1,701 @@
+<?php
+
+/**
+ * Represents a single site.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class Site implements Serializable {
+ const TYPE_UNKNOWN = 'unknown';
+ const TYPE_MEDIAWIKI = 'mediawiki';
+
+ const GROUP_NONE = 'none';
+
+ const ID_INTERWIKI = 'interwiki';
+ const ID_EQUIVALENT = 'equivalent';
+
+ const SOURCE_LOCAL = 'local';
+
+ const PATH_LINK = 'link';
+
+ /**
+ * A version ID that identifies the serialization structure used by getSerializationData()
+ * and unserialize(). This is useful for constructing cache keys in cases where the cache relies
+ * on serialization for storing the SiteList.
+ *
+ * @var string A string uniquely identifying the version of the serialization structure.
+ */
+ const SERIAL_VERSION_ID = '2013-01-23';
+
+ /**
+ * @since 1.21
+ *
+ * @var string|null
+ */
+ protected $globalId = null;
+
+ /**
+ * @since 1.21
+ *
+ * @var string
+ */
+ protected $type = self::TYPE_UNKNOWN;
+
+ /**
+ * @since 1.21
+ *
+ * @var string
+ */
+ protected $group = self::GROUP_NONE;
+
+ /**
+ * @since 1.21
+ *
+ * @var string
+ */
+ protected $source = self::SOURCE_LOCAL;
+
+ /**
+ * @since 1.21
+ *
+ * @var string|null
+ */
+ protected $languageCode = null;
+
+ /**
+ * Holds the local ids for this site.
+ * local id type => [ ids for this type (strings) ]
+ *
+ * @since 1.21
+ *
+ * @var array[]
+ */
+ protected $localIds = [];
+
+ /**
+ * @since 1.21
+ *
+ * @var array
+ */
+ protected $extraData = [];
+
+ /**
+ * @since 1.21
+ *
+ * @var array
+ */
+ protected $extraConfig = [];
+
+ /**
+ * @since 1.21
+ *
+ * @var bool
+ */
+ protected $forward = false;
+
+ /**
+ * @since 1.21
+ *
+ * @var int|null
+ */
+ protected $internalId = null;
+
+ /**
+ * @since 1.21
+ *
+ * @param string $type
+ */
+ public function __construct( $type = self::TYPE_UNKNOWN ) {
+ $this->type = $type;
+ }
+
+ /**
+ * Returns the global site identifier (ie enwiktionary).
+ *
+ * @since 1.21
+ *
+ * @return string|null
+ */
+ public function getGlobalId() {
+ return $this->globalId;
+ }
+
+ /**
+ * Sets the global site identifier (ie enwiktionary).
+ *
+ * @since 1.21
+ *
+ * @param string|null $globalId
+ *
+ * @throws MWException
+ */
+ public function setGlobalId( $globalId ) {
+ if ( $globalId !== null && !is_string( $globalId ) ) {
+ throw new MWException( '$globalId needs to be string or null' );
+ }
+
+ $this->globalId = $globalId;
+ }
+
+ /**
+ * Returns the type of the site (ie mediawiki).
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * Gets the group of the site (ie wikipedia).
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * Sets the group of the site (ie wikipedia).
+ *
+ * @since 1.21
+ *
+ * @param string $group
+ *
+ * @throws MWException
+ */
+ public function setGroup( $group ) {
+ if ( !is_string( $group ) ) {
+ throw new MWException( '$group needs to be a string' );
+ }
+
+ $this->group = $group;
+ }
+
+ /**
+ * Returns the source of the site data (ie 'local', 'wikidata', 'my-magical-repo').
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getSource() {
+ return $this->source;
+ }
+
+ /**
+ * Sets the source of the site data (ie 'local', 'wikidata', 'my-magical-repo').
+ *
+ * @since 1.21
+ *
+ * @param string $source
+ *
+ * @throws MWException
+ */
+ public function setSource( $source ) {
+ if ( !is_string( $source ) ) {
+ throw new MWException( '$source needs to be a string' );
+ }
+
+ $this->source = $source;
+ }
+
+ /**
+ * Gets if site.tld/path/key:pageTitle should forward users to the page on
+ * the actual site, where "key" is the local identifier.
+ *
+ * @since 1.21
+ *
+ * @return bool
+ */
+ public function shouldForward() {
+ return $this->forward;
+ }
+
+ /**
+ * Sets if site.tld/path/key:pageTitle should forward users to the page on
+ * the actual site, where "key" is the local identifier.
+ *
+ * @since 1.21
+ *
+ * @param bool $shouldForward
+ *
+ * @throws MWException
+ */
+ public function setForward( $shouldForward ) {
+ if ( !is_bool( $shouldForward ) ) {
+ throw new MWException( '$shouldForward needs to be a boolean' );
+ }
+
+ $this->forward = $shouldForward;
+ }
+
+ /**
+ * Returns the domain of the site, ie en.wikipedia.org
+ * Or false if it's not known.
+ *
+ * @since 1.21
+ *
+ * @return string|null
+ */
+ public function getDomain() {
+ $path = $this->getLinkPath();
+
+ if ( $path === null ) {
+ return null;
+ }
+
+ return parse_url( $path, PHP_URL_HOST );
+ }
+
+ /**
+ * Returns the protocol of the site.
+ *
+ * @since 1.21
+ *
+ * @throws MWException
+ * @return string
+ */
+ public function getProtocol() {
+ $path = $this->getLinkPath();
+
+ if ( $path === null ) {
+ return '';
+ }
+
+ $protocol = parse_url( $path, PHP_URL_SCHEME );
+
+ // Malformed URL
+ if ( $protocol === false ) {
+ throw new MWException( "failed to parse URL '$path'" );
+ }
+
+ // No schema
+ if ( $protocol === null ) {
+ // Used for protocol relative URLs
+ $protocol = '';
+ }
+
+ return $protocol;
+ }
+
+ /**
+ * Sets the path used to construct links with.
+ * Shall be equivalent to setPath( getLinkPathType(), $fullUrl ).
+ *
+ * @param string $fullUrl
+ *
+ * @since 1.21
+ *
+ * @throws MWException
+ */
+ public function setLinkPath( $fullUrl ) {
+ $type = $this->getLinkPathType();
+
+ if ( $type === null ) {
+ throw new MWException( "This Site does not support link paths." );
+ }
+
+ $this->setPath( $type, $fullUrl );
+ }
+
+ /**
+ * Returns the path used to construct links with or false if there is no such path.
+ *
+ * Shall be equivalent to getPath( getLinkPathType() ).
+ *
+ * @return string|null
+ */
+ public function getLinkPath() {
+ $type = $this->getLinkPathType();
+ return $type === null ? null : $this->getPath( $type );
+ }
+
+ /**
+ * Returns the main path type, that is the type of the path that should
+ * generally be used to construct links to the target site.
+ *
+ * This default implementation returns Site::PATH_LINK as the default path
+ * type. Subclasses can override this to define a different default path
+ * type, or return false to disable site links.
+ *
+ * @since 1.21
+ *
+ * @return string|null
+ */
+ public function getLinkPathType() {
+ return self::PATH_LINK;
+ }
+
+ /**
+ * Returns the full URL for the given page on the site.
+ * Or false if the needed information is not known.
+ *
+ * This generated URL is usually based upon the path returned by getLinkPath(),
+ * but this is not a requirement.
+ *
+ * This implementation returns a URL constructed using the path returned by getLinkPath().
+ *
+ * @since 1.21
+ *
+ * @param bool|string $pageName
+ *
+ * @return string|bool
+ */
+ public function getPageUrl( $pageName = false ) {
+ $url = $this->getLinkPath();
+
+ if ( $url === false ) {
+ return false;
+ }
+
+ if ( $pageName !== false ) {
+ $url = str_replace( '$1', rawurlencode( $pageName ), $url );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Returns $pageName without changes.
+ * Subclasses may override this to apply some kind of normalization.
+ *
+ * @see Site::normalizePageName
+ *
+ * @since 1.21
+ *
+ * @param string $pageName
+ *
+ * @return string
+ */
+ public function normalizePageName( $pageName ) {
+ return $pageName;
+ }
+
+ /**
+ * Returns the type specific fields.
+ *
+ * @since 1.21
+ *
+ * @return array
+ */
+ public function getExtraData() {
+ return $this->extraData;
+ }
+
+ /**
+ * Sets the type specific fields.
+ *
+ * @since 1.21
+ *
+ * @param array $extraData
+ */
+ public function setExtraData( array $extraData ) {
+ $this->extraData = $extraData;
+ }
+
+ /**
+ * Returns the type specific config.
+ *
+ * @since 1.21
+ *
+ * @return array
+ */
+ public function getExtraConfig() {
+ return $this->extraConfig;
+ }
+
+ /**
+ * Sets the type specific config.
+ *
+ * @since 1.21
+ *
+ * @param array $extraConfig
+ */
+ public function setExtraConfig( array $extraConfig ) {
+ $this->extraConfig = $extraConfig;
+ }
+
+ /**
+ * Returns language code of the sites primary language.
+ * Or null if it's not known.
+ *
+ * @since 1.21
+ *
+ * @return string|null
+ */
+ public function getLanguageCode() {
+ return $this->languageCode;
+ }
+
+ /**
+ * Sets language code of the sites primary language.
+ *
+ * @since 1.21
+ *
+ * @param string $languageCode
+ */
+ public function setLanguageCode( $languageCode ) {
+ if ( !Language::isValidCode( $languageCode ) ) {
+ throw new InvalidArgumentException( "$languageCode is not a valid language code." );
+ }
+ $this->languageCode = $languageCode;
+ }
+
+ /**
+ * Returns the set internal identifier for the site.
+ *
+ * @since 1.21
+ *
+ * @return string|null
+ */
+ public function getInternalId() {
+ return $this->internalId;
+ }
+
+ /**
+ * Sets the internal identifier for the site.
+ * This typically is a primary key in a db table.
+ *
+ * @since 1.21
+ *
+ * @param int|null $internalId
+ */
+ public function setInternalId( $internalId = null ) {
+ $this->internalId = $internalId;
+ }
+
+ /**
+ * Adds a local identifier.
+ *
+ * @since 1.21
+ *
+ * @param string $type
+ * @param string $identifier
+ */
+ public function addLocalId( $type, $identifier ) {
+ if ( $this->localIds === false ) {
+ $this->localIds = [];
+ }
+
+ if ( !array_key_exists( $type, $this->localIds ) ) {
+ $this->localIds[$type] = [];
+ }
+
+ if ( !in_array( $identifier, $this->localIds[$type] ) ) {
+ $this->localIds[$type][] = $identifier;
+ }
+ }
+
+ /**
+ * Adds an interwiki id to the site.
+ *
+ * @since 1.21
+ *
+ * @param string $identifier
+ */
+ public function addInterwikiId( $identifier ) {
+ $this->addLocalId( self::ID_INTERWIKI, $identifier );
+ }
+
+ /**
+ * Adds a navigation id to the site.
+ *
+ * @since 1.21
+ *
+ * @param string $identifier
+ */
+ public function addNavigationId( $identifier ) {
+ $this->addLocalId( self::ID_EQUIVALENT, $identifier );
+ }
+
+ /**
+ * Returns the interwiki link identifiers that can be used for this site.
+ *
+ * @since 1.21
+ *
+ * @return string[]
+ */
+ public function getInterwikiIds() {
+ return array_key_exists( self::ID_INTERWIKI, $this->localIds )
+ ? $this->localIds[self::ID_INTERWIKI]
+ : [];
+ }
+
+ /**
+ * Returns the equivalent link identifiers that can be used to make
+ * the site show up in interfaces such as the "language links" section.
+ *
+ * @since 1.21
+ *
+ * @return string[]
+ */
+ public function getNavigationIds() {
+ return array_key_exists( self::ID_EQUIVALENT, $this->localIds )
+ ? $this->localIds[self::ID_EQUIVALENT] :
+ [];
+ }
+
+ /**
+ * Returns all local ids
+ *
+ * @since 1.21
+ *
+ * @return array[]
+ */
+ public function getLocalIds() {
+ return $this->localIds;
+ }
+
+ /**
+ * Sets the path used to construct links with.
+ * Shall be equivalent to setPath( getLinkPathType(), $fullUrl ).
+ *
+ * @since 1.21
+ *
+ * @param string $pathType
+ * @param string $fullUrl
+ *
+ * @throws MWException
+ */
+ public function setPath( $pathType, $fullUrl ) {
+ if ( !is_string( $fullUrl ) ) {
+ throw new MWException( '$fullUrl needs to be a string' );
+ }
+
+ if ( !array_key_exists( 'paths', $this->extraData ) ) {
+ $this->extraData['paths'] = [];
+ }
+
+ $this->extraData['paths'][$pathType] = $fullUrl;
+ }
+
+ /**
+ * Returns the path of the provided type or false if there is no such path.
+ *
+ * @since 1.21
+ *
+ * @param string $pathType
+ *
+ * @return string|null
+ */
+ public function getPath( $pathType ) {
+ $paths = $this->getAllPaths();
+ return array_key_exists( $pathType, $paths ) ? $paths[$pathType] : null;
+ }
+
+ /**
+ * Returns the paths as associative array.
+ * The keys are path types, the values are the path urls.
+ *
+ * @since 1.21
+ *
+ * @return string[]
+ */
+ public function getAllPaths() {
+ return array_key_exists( 'paths', $this->extraData ) ? $this->extraData['paths'] : [];
+ }
+
+ /**
+ * Removes the path of the provided type if it's set.
+ *
+ * @since 1.21
+ *
+ * @param string $pathType
+ */
+ public function removePath( $pathType ) {
+ if ( array_key_exists( 'paths', $this->extraData ) ) {
+ unset( $this->extraData['paths'][$pathType] );
+ }
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @param string $siteType
+ *
+ * @return Site
+ */
+ public static function newForType( $siteType ) {
+ global $wgSiteTypes;
+
+ if ( array_key_exists( $siteType, $wgSiteTypes ) ) {
+ return new $wgSiteTypes[$siteType]();
+ }
+
+ return new Site();
+ }
+
+ /**
+ * @see Serializable::serialize
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function serialize() {
+ $fields = [
+ 'globalid' => $this->globalId,
+ 'type' => $this->type,
+ 'group' => $this->group,
+ 'source' => $this->source,
+ 'language' => $this->languageCode,
+ 'localids' => $this->localIds,
+ 'config' => $this->extraConfig,
+ 'data' => $this->extraData,
+ 'forward' => $this->forward,
+ 'internalid' => $this->internalId,
+
+ ];
+
+ return serialize( $fields );
+ }
+
+ /**
+ * @see Serializable::unserialize
+ *
+ * @since 1.21
+ *
+ * @param string $serialized
+ */
+ public function unserialize( $serialized ) {
+ $fields = unserialize( $serialized );
+
+ $this->__construct( $fields['type'] );
+
+ $this->setGlobalId( $fields['globalid'] );
+ $this->setGroup( $fields['group'] );
+ $this->setSource( $fields['source'] );
+ $this->setLanguageCode( $fields['language'] );
+ $this->localIds = $fields['localids'];
+ $this->setExtraConfig( $fields['config'] );
+ $this->setExtraData( $fields['data'] );
+ $this->setForward( $fields['forward'] );
+ $this->setInternalId( $fields['internalid'] );
+ }
+}
diff --git a/www/wiki/includes/site/SiteExporter.php b/www/wiki/includes/site/SiteExporter.php
new file mode 100644
index 00000000..01b838ef
--- /dev/null
+++ b/www/wiki/includes/site/SiteExporter.php
@@ -0,0 +1,114 @@
+<?php
+
+/**
+ * Utility for exporting site entries to XML.
+ * For the output file format, see docs/sitelist.txt and docs/sitelist-1.0.xsd.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.25
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ * @author Daniel Kinzler
+ */
+class SiteExporter {
+
+ /**
+ * @var resource
+ */
+ private $sink;
+
+ /**
+ * @param resource $sink A file handle open for writing
+ */
+ public function __construct( $sink ) {
+ if ( !is_resource( $sink ) || get_resource_type( $sink ) !== 'stream' ) {
+ throw new InvalidArgumentException( '$sink must be a file handle' );
+ }
+
+ $this->sink = $sink;
+ }
+
+ /**
+ * Writes a <site> tag for each Site object in $sites, and encloses the entire list
+ * between <sites> tags.
+ *
+ * @param Site[]|SiteList $sites
+ */
+ public function exportSites( $sites ) {
+ $attributes = [
+ 'version' => '1.0',
+ 'xmlns' => 'http://www.mediawiki.org/xml/sitelist-1.0/',
+ ];
+
+ fwrite( $this->sink, Xml::openElement( 'sites', $attributes ) . "\n" );
+
+ foreach ( $sites as $site ) {
+ $this->exportSite( $site );
+ }
+
+ fwrite( $this->sink, Xml::closeElement( 'sites' ) . "\n" );
+ fflush( $this->sink );
+ }
+
+ /**
+ * Writes a <site> tag representing the given Site object.
+ *
+ * @param Site $site
+ */
+ private function exportSite( Site $site ) {
+ if ( $site->getType() !== Site::TYPE_UNKNOWN ) {
+ $siteAttr = [ 'type' => $site->getType() ];
+ } else {
+ $siteAttr = null;
+ }
+
+ fwrite( $this->sink, "\t" . Xml::openElement( 'site', $siteAttr ) . "\n" );
+
+ fwrite( $this->sink, "\t\t" . Xml::element( 'globalid', null, $site->getGlobalId() ) . "\n" );
+
+ if ( $site->getGroup() !== Site::GROUP_NONE ) {
+ fwrite( $this->sink, "\t\t" . Xml::element( 'group', null, $site->getGroup() ) . "\n" );
+ }
+
+ if ( $site->getSource() !== Site::SOURCE_LOCAL ) {
+ fwrite( $this->sink, "\t\t" . Xml::element( 'source', null, $site->getSource() ) . "\n" );
+ }
+
+ if ( $site->shouldForward() ) {
+ fwrite( $this->sink, "\t\t" . Xml::element( 'forward', null, '' ) . "\n" );
+ }
+
+ foreach ( $site->getAllPaths() as $type => $path ) {
+ fwrite( $this->sink, "\t\t" . Xml::element( 'path', [ 'type' => $type ], $path ) . "\n" );
+ }
+
+ foreach ( $site->getLocalIds() as $type => $ids ) {
+ foreach ( $ids as $id ) {
+ fwrite( $this->sink, "\t\t" . Xml::element( 'localid', [ 'type' => $type ], $id ) . "\n" );
+ }
+ }
+
+ // @todo: export <data>
+ // @todo: export <config>
+
+ fwrite( $this->sink, "\t" . Xml::closeElement( 'site' ) . "\n" );
+ }
+
+}
diff --git a/www/wiki/includes/site/SiteImporter.php b/www/wiki/includes/site/SiteImporter.php
new file mode 100644
index 00000000..5e13d061
--- /dev/null
+++ b/www/wiki/includes/site/SiteImporter.php
@@ -0,0 +1,263 @@
+<?php
+
+/**
+ * Utility for importing site entries from XML.
+ * For the expected format of the input, see docs/sitelist.txt and docs/sitelist-1.0.xsd.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.25
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ * @author Daniel Kinzler
+ */
+class SiteImporter {
+
+ /**
+ * @var SiteStore
+ */
+ private $store;
+
+ /**
+ * @var callable|null
+ */
+ private $exceptionCallback;
+
+ /**
+ * @param SiteStore $store
+ */
+ public function __construct( SiteStore $store ) {
+ $this->store = $store;
+ }
+
+ /**
+ * @return callable
+ */
+ public function getExceptionCallback() {
+ return $this->exceptionCallback;
+ }
+
+ /**
+ * @param callable $exceptionCallback
+ */
+ public function setExceptionCallback( $exceptionCallback ) {
+ $this->exceptionCallback = $exceptionCallback;
+ }
+
+ /**
+ * @param string $file
+ */
+ public function importFromFile( $file ) {
+ $xml = file_get_contents( $file );
+
+ if ( $xml === false ) {
+ throw new RuntimeException( 'Failed to read ' . $file . '!' );
+ }
+
+ $this->importFromXML( $xml );
+ }
+
+ /**
+ * @param string $xml
+ *
+ * @throws InvalidArgumentException
+ */
+ public function importFromXML( $xml ) {
+ $document = new DOMDocument();
+
+ $oldLibXmlErrors = libxml_use_internal_errors( true );
+ $ok = $document->loadXML( $xml, LIBXML_NONET );
+
+ if ( !$ok ) {
+ $errors = libxml_get_errors();
+ libxml_use_internal_errors( $oldLibXmlErrors );
+
+ foreach ( $errors as $error ) {
+ /** @var LibXMLError $error */
+ throw new InvalidArgumentException(
+ 'Malformed XML: ' . $error->message . ' in line ' . $error->line
+ );
+ }
+
+ throw new InvalidArgumentException( 'Malformed XML!' );
+ }
+
+ libxml_use_internal_errors( $oldLibXmlErrors );
+ $this->importFromDOM( $document->documentElement );
+ }
+
+ /**
+ * @param DOMElement $root
+ */
+ private function importFromDOM( DOMElement $root ) {
+ $sites = $this->makeSiteList( $root );
+ $this->store->saveSites( $sites );
+ }
+
+ /**
+ * @param DOMElement $root
+ *
+ * @return Site[]
+ */
+ private function makeSiteList( DOMElement $root ) {
+ $sites = [];
+
+ // Old sites, to get the row IDs that correspond to the global site IDs.
+ // TODO: Get rid of internal row IDs, they just get in the way. Get rid of ORMRow, too.
+ $oldSites = $this->store->getSites();
+
+ $current = $root->firstChild;
+ while ( $current ) {
+ if ( $current instanceof DOMElement && $current->tagName === 'site' ) {
+ try {
+ $site = $this->makeSite( $current );
+ $key = $site->getGlobalId();
+
+ if ( $oldSites->hasSite( $key ) ) {
+ $oldSite = $oldSites->getSite( $key );
+ $site->setInternalId( $oldSite->getInternalId() );
+ }
+
+ $sites[$key] = $site;
+ } catch ( Exception $ex ) {
+ $this->handleException( $ex );
+ }
+ }
+
+ $current = $current->nextSibling;
+ }
+
+ return $sites;
+ }
+
+ /**
+ * @param DOMElement $siteElement
+ *
+ * @return Site
+ * @throws InvalidArgumentException
+ */
+ public function makeSite( DOMElement $siteElement ) {
+ if ( $siteElement->tagName !== 'site' ) {
+ throw new InvalidArgumentException( 'Expected <site> tag, found ' . $siteElement->tagName );
+ }
+
+ $type = $this->getAttributeValue( $siteElement, 'type', Site::TYPE_UNKNOWN );
+ $site = Site::newForType( $type );
+
+ $site->setForward( $this->hasChild( $siteElement, 'forward' ) );
+ $site->setGlobalId( $this->getChildText( $siteElement, 'globalid' ) );
+ $site->setGroup( $this->getChildText( $siteElement, 'group', Site::GROUP_NONE ) );
+ $site->setSource( $this->getChildText( $siteElement, 'source', Site::SOURCE_LOCAL ) );
+
+ $pathTags = $siteElement->getElementsByTagName( 'path' );
+ for ( $i = 0; $i < $pathTags->length; $i++ ) {
+ $pathElement = $pathTags->item( $i );
+ $pathType = $this->getAttributeValue( $pathElement, 'type' );
+ $path = $pathElement->textContent;
+
+ $site->setPath( $pathType, $path );
+ }
+
+ $idTags = $siteElement->getElementsByTagName( 'localid' );
+ for ( $i = 0; $i < $idTags->length; $i++ ) {
+ $idElement = $idTags->item( $i );
+ $idType = $this->getAttributeValue( $idElement, 'type' );
+ $id = $idElement->textContent;
+
+ $site->addLocalId( $idType, $id );
+ }
+
+ // @todo: import <data>
+ // @todo: import <config>
+
+ return $site;
+ }
+
+ /**
+ * @param DOMElement $element
+ * @param string $name
+ * @param string|null|bool $default
+ *
+ * @return null|string
+ * @throws MWException If the attribute is not found and no default is provided
+ */
+ private function getAttributeValue( DOMElement $element, $name, $default = false ) {
+ $node = $element->getAttributeNode( $name );
+
+ if ( !$node ) {
+ if ( $default !== false ) {
+ return $default;
+ } else {
+ throw new MWException(
+ 'Required ' . $name . ' attribute not found in <' . $element->tagName . '> tag'
+ );
+ }
+ }
+
+ return $node->textContent;
+ }
+
+ /**
+ * @param DOMElement $element
+ * @param string $name
+ * @param string|null|bool $default
+ *
+ * @return null|string
+ * @throws MWException If the child element is not found and no default is provided
+ */
+ private function getChildText( DOMElement $element, $name, $default = false ) {
+ $elements = $element->getElementsByTagName( $name );
+
+ if ( $elements->length < 1 ) {
+ if ( $default !== false ) {
+ return $default;
+ } else {
+ throw new MWException(
+ 'Required <' . $name . '> tag not found inside <' . $element->tagName . '> tag'
+ );
+ }
+ }
+
+ $node = $elements->item( 0 );
+ return $node->textContent;
+ }
+
+ /**
+ * @param DOMElement $element
+ * @param string $name
+ *
+ * @return bool
+ * @throws MWException
+ */
+ private function hasChild( DOMElement $element, $name ) {
+ return $this->getChildText( $element, $name, null ) !== null;
+ }
+
+ /**
+ * @param Exception $ex
+ */
+ private function handleException( Exception $ex ) {
+ if ( $this->exceptionCallback ) {
+ call_user_func( $this->exceptionCallback, $ex );
+ } else {
+ wfLogWarning( $ex->getMessage() );
+ }
+ }
+
+}
diff --git a/www/wiki/includes/site/SiteList.php b/www/wiki/includes/site/SiteList.php
new file mode 100644
index 00000000..a94aa0b9
--- /dev/null
+++ b/www/wiki/includes/site/SiteList.php
@@ -0,0 +1,352 @@
+<?php
+
+/**
+ * Collection of Site objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+class SiteList extends GenericArrayObject {
+ /**
+ * Internal site identifiers pointing to their sites offset value.
+ *
+ * @since 1.21
+ *
+ * @var array Array of integer
+ */
+ protected $byInternalId = [];
+
+ /**
+ * Global site identifiers pointing to their sites offset value.
+ *
+ * @since 1.21
+ *
+ * @var array Array of string
+ */
+ protected $byGlobalId = [];
+
+ /**
+ * Navigational site identifiers alias inter-language prefixes
+ * pointing to their sites offset value.
+ *
+ * @since 1.23
+ *
+ * @var array Array of string
+ */
+ protected $byNavigationId = [];
+
+ /**
+ * @see GenericArrayObject::getObjectType
+ *
+ * @since 1.21
+ *
+ * @return string
+ */
+ public function getObjectType() {
+ return 'Site';
+ }
+
+ /**
+ * @see GenericArrayObject::preSetElement
+ *
+ * @since 1.21
+ *
+ * @param int|string $index
+ * @param Site $site
+ *
+ * @return bool
+ */
+ protected function preSetElement( $index, $site ) {
+ if ( $this->hasSite( $site->getGlobalId() ) ) {
+ $this->removeSite( $site->getGlobalId() );
+ }
+
+ $this->byGlobalId[$site->getGlobalId()] = $index;
+ $this->byInternalId[$site->getInternalId()] = $index;
+
+ $ids = $site->getNavigationIds();
+ foreach ( $ids as $navId ) {
+ $this->byNavigationId[$navId] = $index;
+ }
+
+ return true;
+ }
+
+ /**
+ * @see ArrayObject::offsetUnset()
+ *
+ * @since 1.21
+ *
+ * @param mixed $index
+ */
+ public function offsetUnset( $index ) {
+ if ( $this->offsetExists( $index ) ) {
+ /**
+ * @var Site $site
+ */
+ $site = $this->offsetGet( $index );
+
+ unset( $this->byGlobalId[$site->getGlobalId()] );
+ unset( $this->byInternalId[$site->getInternalId()] );
+
+ $ids = $site->getNavigationIds();
+ foreach ( $ids as $navId ) {
+ unset( $this->byNavigationId[$navId] );
+ }
+ }
+
+ parent::offsetUnset( $index );
+ }
+
+ /**
+ * Returns all the global site identifiers.
+ * Optionally only those belonging to the specified group.
+ *
+ * @since 1.21
+ *
+ * @return array
+ */
+ public function getGlobalIdentifiers() {
+ return array_keys( $this->byGlobalId );
+ }
+
+ /**
+ * Returns if the list contains the site with the provided global site identifier.
+ *
+ * @param string $globalSiteId
+ *
+ * @return bool
+ */
+ public function hasSite( $globalSiteId ) {
+ return array_key_exists( $globalSiteId, $this->byGlobalId );
+ }
+
+ /**
+ * Returns the Site with the provided global site identifier.
+ * The site needs to exist, so if not sure, call hasGlobalId first.
+ *
+ * @since 1.21
+ *
+ * @param string $globalSiteId
+ *
+ * @return Site
+ */
+ public function getSite( $globalSiteId ) {
+ return $this->offsetGet( $this->byGlobalId[$globalSiteId] );
+ }
+
+ /**
+ * Removes the site with the specified global site identifier.
+ * The site needs to exist, so if not sure, call hasGlobalId first.
+ *
+ * @since 1.21
+ *
+ * @param string $globalSiteId
+ */
+ public function removeSite( $globalSiteId ) {
+ $this->offsetUnset( $this->byGlobalId[$globalSiteId] );
+ }
+
+ /**
+ * Returns if the list contains no sites.
+ *
+ * @since 1.21
+ *
+ * @return bool
+ */
+ public function isEmpty() {
+ return $this->byGlobalId === [];
+ }
+
+ /**
+ * Returns if the list contains the site with the provided site id.
+ *
+ * @param int $id
+ *
+ * @return bool
+ */
+ public function hasInternalId( $id ) {
+ return array_key_exists( $id, $this->byInternalId );
+ }
+
+ /**
+ * Returns the Site with the provided site id.
+ * The site needs to exist, so if not sure, call has first.
+ *
+ * @since 1.21
+ *
+ * @param int $id
+ *
+ * @return Site
+ */
+ public function getSiteByInternalId( $id ) {
+ return $this->offsetGet( $this->byInternalId[$id] );
+ }
+
+ /**
+ * Removes the site with the specified site id.
+ * The site needs to exist, so if not sure, call has first.
+ *
+ * @since 1.21
+ *
+ * @param int $id
+ */
+ public function removeSiteByInternalId( $id ) {
+ $this->offsetUnset( $this->byInternalId[$id] );
+ }
+
+ /**
+ * Returns if the list contains the site with the provided navigational site id.
+ *
+ * @param string $id
+ *
+ * @return bool
+ */
+ public function hasNavigationId( $id ) {
+ return array_key_exists( $id, $this->byNavigationId );
+ }
+
+ /**
+ * Returns the Site with the provided navigational site id.
+ * The site needs to exist, so if not sure, call has first.
+ *
+ * @since 1.23
+ *
+ * @param string $id
+ *
+ * @return Site
+ */
+ public function getSiteByNavigationId( $id ) {
+ return $this->offsetGet( $this->byNavigationId[$id] );
+ }
+
+ /**
+ * Removes the site with the specified navigational site id.
+ * The site needs to exist, so if not sure, call has first.
+ *
+ * @since 1.23
+ *
+ * @param string $id
+ */
+ public function removeSiteByNavigationId( $id ) {
+ $this->offsetUnset( $this->byNavigationId[$id] );
+ }
+
+ /**
+ * Sets a site in the list. If the site was not there,
+ * it will be added. If it was, it will be updated.
+ *
+ * @since 1.21
+ *
+ * @param Site $site
+ */
+ public function setSite( Site $site ) {
+ $this[] = $site;
+ }
+
+ /**
+ * Returns the sites that are in the provided group.
+ *
+ * @since 1.21
+ *
+ * @param string $groupName
+ *
+ * @return SiteList
+ */
+ public function getGroup( $groupName ) {
+ $group = new self();
+
+ /**
+ * @var Site $site
+ */
+ foreach ( $this as $site ) {
+ if ( $site->getGroup() === $groupName ) {
+ $group[] = $site;
+ }
+ }
+
+ return $group;
+ }
+
+ /**
+ * A version ID that identifies the serialization structure used by getSerializationData()
+ * and unserialize(). This is useful for constructing cache keys in cases where the cache relies
+ * on serialization for storing the SiteList.
+ *
+ * @var string A string uniquely identifying the version of the serialization structure,
+ * not including any sub-structures.
+ */
+ const SERIAL_VERSION_ID = '2014-03-17';
+
+ /**
+ * Returns the version ID that identifies the serialization structure used by
+ * getSerializationData() and unserialize(), including the structure of any nested structures.
+ * This is useful for constructing cache keys in cases where the cache relies
+ * on serialization for storing the SiteList.
+ *
+ * @return string A string uniquely identifying the version of the serialization structure,
+ * including any sub-structures.
+ */
+ public static function getSerialVersionId() {
+ return self::SERIAL_VERSION_ID . '+Site:' . Site::SERIAL_VERSION_ID;
+ }
+
+ /**
+ * @see GenericArrayObject::getSerializationData
+ *
+ * @since 1.21
+ *
+ * @return array
+ */
+ protected function getSerializationData() {
+ // NOTE: When changing the structure, either implement unserialize() to handle the
+ // old structure too, or update SERIAL_VERSION_ID to kill any caches.
+ return array_merge(
+ parent::getSerializationData(),
+ [
+ 'internalIds' => $this->byInternalId,
+ 'globalIds' => $this->byGlobalId,
+ 'navigationIds' => $this->byNavigationId
+ ]
+ );
+ }
+
+ /**
+ * @see GenericArrayObject::unserialize
+ *
+ * @since 1.21
+ *
+ * @param string $serialization
+ *
+ * @return array
+ */
+ public function unserialize( $serialization ) {
+ $serializationData = parent::unserialize( $serialization );
+
+ $this->byInternalId = $serializationData['internalIds'];
+ $this->byGlobalId = $serializationData['globalIds'];
+ $this->byNavigationId = $serializationData['navigationIds'];
+
+ return $serializationData;
+ }
+}
diff --git a/www/wiki/includes/site/SiteLookup.php b/www/wiki/includes/site/SiteLookup.php
new file mode 100644
index 00000000..610bf0b7
--- /dev/null
+++ b/www/wiki/includes/site/SiteLookup.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * Interface for service objects providing a lookup of Site objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.25
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ */
+interface SiteLookup {
+
+ /**
+ * Returns the site with provided global id, or null if there is no such site.
+ *
+ * @since 1.25
+ *
+ * @param string $globalId
+ *
+ * @return Site|null
+ */
+ public function getSite( $globalId );
+
+ /**
+ * Returns a list of all sites.
+ *
+ * @since 1.25
+ *
+ * @return SiteList
+ */
+ public function getSites();
+
+}
diff --git a/www/wiki/includes/site/SiteSQLStore.php b/www/wiki/includes/site/SiteSQLStore.php
new file mode 100644
index 00000000..2f8a113c
--- /dev/null
+++ b/www/wiki/includes/site/SiteSQLStore.php
@@ -0,0 +1,60 @@
+<?php
+
+/**
+ * Dummy class for accessing the global SiteStore instance.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ * @author Daniel Kinzler
+ */
+class SiteSQLStore {
+
+ /**
+ * Returns the global SiteStore instance. This is a relict of the first implementation
+ * of SiteStore, and is kept around for compatibility.
+ *
+ * @note This does not return an instance of SiteSQLStore!
+ *
+ * @since 1.21
+ * @deprecated since 1.27 use MediaWikiServices::getSiteStore()
+ * or MediaWikiServices::getSiteLookup() instead.
+ *
+ * @param null $sitesTable IGNORED
+ * @param null $cache IGNORED
+ *
+ * @return SiteStore
+ */
+ public static function newInstance( $sitesTable = null, BagOStuff $cache = null ) {
+ if ( $sitesTable !== null ) {
+ throw new InvalidArgumentException(
+ __METHOD__ . ': $sitesTable parameter is unused and must be null'
+ );
+ }
+
+ // NOTE: we silently ignore $cache for now, since some existing callers
+ // specify it. If we break compatibility with them, we could just as
+ // well just remove this class.
+
+ return \MediaWiki\MediaWikiServices::getInstance()->getSiteStore();
+ }
+
+}
diff --git a/www/wiki/includes/site/SiteStore.php b/www/wiki/includes/site/SiteStore.php
new file mode 100644
index 00000000..10e0c1b9
--- /dev/null
+++ b/www/wiki/includes/site/SiteStore.php
@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * Interface for service objects providing a storage interface for Site objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ *
+ * @file
+ * @ingroup Site
+ *
+ * @license GNU GPL v2+
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ */
+interface SiteStore extends SiteLookup {
+
+ /**
+ * Saves the provided site.
+ *
+ * @since 1.21
+ *
+ * @param Site $site
+ *
+ * @return bool Success indicator
+ */
+ public function saveSite( Site $site );
+
+ /**
+ * Saves the provided sites.
+ *
+ * @since 1.21
+ *
+ * @param Site[] $sites
+ *
+ * @return bool Success indicator
+ */
+ public function saveSites( array $sites );
+
+ /**
+ * Deletes all sites from the database. After calling clear(), getSites() will return an empty
+ * list and getSite() will return null until saveSite() or saveSites() is called.
+ */
+ public function clear();
+}
diff --git a/www/wiki/includes/site/SitesCacheFileBuilder.php b/www/wiki/includes/site/SitesCacheFileBuilder.php
new file mode 100644
index 00000000..b4046e36
--- /dev/null
+++ b/www/wiki/includes/site/SitesCacheFileBuilder.php
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.25
+ *
+ * @file
+ *
+ * @license GNU GPL v2+
+ */
+class SitesCacheFileBuilder {
+
+ /**
+ * @var SiteLookup
+ */
+ private $siteLookup;
+
+ /**
+ * @var string
+ */
+ private $cacheFile;
+
+ /**
+ * @param SiteLookup $siteLookup
+ * @param string $cacheFile
+ */
+ public function __construct( SiteLookup $siteLookup, $cacheFile ) {
+ $this->siteLookup = $siteLookup;
+ $this->cacheFile = $cacheFile;
+ }
+
+ public function build() {
+ $this->sites = $this->siteLookup->getSites();
+ $this->cacheSites( $this->sites->getArrayCopy() );
+ }
+
+ /**
+ * @param Site[] $sites
+ *
+ * @throws MWException if in manualRecache mode
+ * @return bool
+ */
+ private function cacheSites( array $sites ) {
+ $sitesArray = [];
+
+ foreach ( $sites as $site ) {
+ $globalId = $site->getGlobalId();
+ $sitesArray[$globalId] = $this->getSiteAsArray( $site );
+ }
+
+ $json = json_encode( [
+ 'sites' => $sitesArray
+ ] );
+
+ $result = file_put_contents( $this->cacheFile, $json );
+
+ return $result !== false;
+ }
+
+ /**
+ * @param Site $site
+ *
+ * @return array
+ */
+ private function getSiteAsArray( Site $site ) {
+ $siteEntry = unserialize( $site->serialize() );
+ $siteIdentifiers = $this->buildLocalIdentifiers( $site );
+ $identifiersArray = [];
+
+ foreach ( $siteIdentifiers as $identifier ) {
+ $identifiersArray[] = $identifier;
+ }
+
+ $siteEntry['identifiers'] = $identifiersArray;
+
+ return $siteEntry;
+ }
+
+ /**
+ * @param Site $site
+ *
+ * @return array Site local identifiers
+ */
+ private function buildLocalIdentifiers( Site $site ) {
+ $localIds = [];
+
+ foreach ( $site->getLocalIds() as $idType => $ids ) {
+ foreach ( $ids as $id ) {
+ $localIds[] = [
+ 'type' => $idType,
+ 'key' => $id
+ ];
+ }
+ }
+
+ return $localIds;
+ }
+
+}
diff --git a/www/wiki/includes/skins/BaseTemplate.php b/www/wiki/includes/skins/BaseTemplate.php
new file mode 100644
index 00000000..8d5ce10d
--- /dev/null
+++ b/www/wiki/includes/skins/BaseTemplate.php
@@ -0,0 +1,769 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * New base template for a skin's template extended from QuickTemplate
+ * this class features helper methods that provide common ways of interacting
+ * with the data stored in the QuickTemplate
+ */
+abstract class BaseTemplate extends QuickTemplate {
+
+ /**
+ * Get a Message object with its context set
+ *
+ * @param string $name Message name
+ * @param mixed $params,... Message params
+ * @return Message
+ */
+ public function getMsg( $name /* ... */ ) {
+ return call_user_func_array( [ $this->getSkin(), 'msg' ], func_get_args() );
+ }
+
+ function msg( $str ) {
+ echo $this->getMsg( $str )->escaped();
+ }
+
+ function msgHtml( $str ) {
+ echo $this->getMsg( $str )->text();
+ }
+
+ function msgWiki( $str ) {
+ echo $this->getMsg( $str )->parseAsBlock();
+ }
+
+ /**
+ * Create an array of common toolbox items from the data in the quicktemplate
+ * stored by SkinTemplate.
+ * The resulting array is built according to a format intended to be passed
+ * through makeListItem to generate the html.
+ * @return array
+ */
+ function getToolbox() {
+ $toolbox = [];
+ if ( isset( $this->data['nav_urls']['whatlinkshere'] )
+ && $this->data['nav_urls']['whatlinkshere']
+ ) {
+ $toolbox['whatlinkshere'] = $this->data['nav_urls']['whatlinkshere'];
+ $toolbox['whatlinkshere']['id'] = 't-whatlinkshere';
+ }
+ if ( isset( $this->data['nav_urls']['recentchangeslinked'] )
+ && $this->data['nav_urls']['recentchangeslinked']
+ ) {
+ $toolbox['recentchangeslinked'] = $this->data['nav_urls']['recentchangeslinked'];
+ $toolbox['recentchangeslinked']['msg'] = 'recentchangeslinked-toolbox';
+ $toolbox['recentchangeslinked']['id'] = 't-recentchangeslinked';
+ $toolbox['recentchangeslinked']['rel'] = 'nofollow';
+ }
+ if ( isset( $this->data['feeds'] ) && $this->data['feeds'] ) {
+ $toolbox['feeds']['id'] = 'feedlinks';
+ $toolbox['feeds']['links'] = [];
+ foreach ( $this->data['feeds'] as $key => $feed ) {
+ $toolbox['feeds']['links'][$key] = $feed;
+ $toolbox['feeds']['links'][$key]['id'] = "feed-$key";
+ $toolbox['feeds']['links'][$key]['rel'] = 'alternate';
+ $toolbox['feeds']['links'][$key]['type'] = "application/{$key}+xml";
+ $toolbox['feeds']['links'][$key]['class'] = 'feedlink';
+ }
+ }
+ foreach ( [ 'contributions', 'log', 'blockip', 'emailuser',
+ 'userrights', 'upload', 'specialpages' ] as $special
+ ) {
+ if ( isset( $this->data['nav_urls'][$special] ) && $this->data['nav_urls'][$special] ) {
+ $toolbox[$special] = $this->data['nav_urls'][$special];
+ $toolbox[$special]['id'] = "t-$special";
+ }
+ }
+ if ( isset( $this->data['nav_urls']['print'] ) && $this->data['nav_urls']['print'] ) {
+ $toolbox['print'] = $this->data['nav_urls']['print'];
+ $toolbox['print']['id'] = 't-print';
+ $toolbox['print']['rel'] = 'alternate';
+ $toolbox['print']['msg'] = 'printableversion';
+ }
+ if ( isset( $this->data['nav_urls']['permalink'] ) && $this->data['nav_urls']['permalink'] ) {
+ $toolbox['permalink'] = $this->data['nav_urls']['permalink'];
+ if ( $toolbox['permalink']['href'] === '' ) {
+ unset( $toolbox['permalink']['href'] );
+ $toolbox['ispermalink']['tooltiponly'] = true;
+ $toolbox['ispermalink']['id'] = 't-ispermalink';
+ $toolbox['ispermalink']['msg'] = 'permalink';
+ } else {
+ $toolbox['permalink']['id'] = 't-permalink';
+ }
+ }
+ if ( isset( $this->data['nav_urls']['info'] ) && $this->data['nav_urls']['info'] ) {
+ $toolbox['info'] = $this->data['nav_urls']['info'];
+ $toolbox['info']['id'] = 't-info';
+ }
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $template = $this;
+ Hooks::run( 'BaseTemplateToolbox', [ &$template, &$toolbox ] );
+ return $toolbox;
+ }
+
+ /**
+ * Create an array of personal tools items from the data in the quicktemplate
+ * stored by SkinTemplate.
+ * The resulting array is built according to a format intended to be passed
+ * through makeListItem to generate the html.
+ * This is in reality the same list as already stored in personal_urls
+ * however it is reformatted so that you can just pass the individual items
+ * to makeListItem instead of hardcoding the element creation boilerplate.
+ * @return array
+ */
+ function getPersonalTools() {
+ $personal_tools = [];
+ foreach ( $this->get( 'personal_urls' ) as $key => $plink ) {
+ # The class on a personal_urls item is meant to go on the <a> instead
+ # of the <li> so we have to use a single item "links" array instead
+ # of using most of the personal_url's keys directly.
+ $ptool = [
+ 'links' => [
+ [ 'single-id' => "pt-$key" ],
+ ],
+ 'id' => "pt-$key",
+ ];
+ if ( isset( $plink['active'] ) ) {
+ $ptool['active'] = $plink['active'];
+ }
+ foreach ( [ 'href', 'class', 'text', 'dir', 'data' ] as $k ) {
+ if ( isset( $plink[$k] ) ) {
+ $ptool['links'][0][$k] = $plink[$k];
+ }
+ }
+ $personal_tools[$key] = $ptool;
+ }
+ return $personal_tools;
+ }
+
+ function getSidebar( $options = [] ) {
+ // Force the rendering of the following portals
+ $sidebar = $this->data['sidebar'];
+ if ( !isset( $sidebar['SEARCH'] ) ) {
+ $sidebar['SEARCH'] = true;
+ }
+ if ( !isset( $sidebar['TOOLBOX'] ) ) {
+ $sidebar['TOOLBOX'] = true;
+ }
+ if ( !isset( $sidebar['LANGUAGES'] ) ) {
+ $sidebar['LANGUAGES'] = true;
+ }
+
+ if ( !isset( $options['search'] ) || $options['search'] !== true ) {
+ unset( $sidebar['SEARCH'] );
+ }
+ if ( isset( $options['toolbox'] ) && $options['toolbox'] === false ) {
+ unset( $sidebar['TOOLBOX'] );
+ }
+ if ( isset( $options['languages'] ) && $options['languages'] === false ) {
+ unset( $sidebar['LANGUAGES'] );
+ }
+
+ $boxes = [];
+ foreach ( $sidebar as $boxName => $content ) {
+ if ( $content === false ) {
+ continue;
+ }
+ switch ( $boxName ) {
+ case 'SEARCH':
+ // Search is a special case, skins should custom implement this
+ $boxes[$boxName] = [
+ 'id' => 'p-search',
+ 'header' => $this->getMsg( 'search' )->text(),
+ 'generated' => false,
+ 'content' => true,
+ ];
+ break;
+ case 'TOOLBOX':
+ $msgObj = $this->getMsg( 'toolbox' );
+ $boxes[$boxName] = [
+ 'id' => 'p-tb',
+ 'header' => $msgObj->exists() ? $msgObj->text() : 'toolbox',
+ 'generated' => false,
+ 'content' => $this->getToolbox(),
+ ];
+ break;
+ case 'LANGUAGES':
+ if ( $this->data['language_urls'] !== false ) {
+ $msgObj = $this->getMsg( 'otherlanguages' );
+ $boxes[$boxName] = [
+ 'id' => 'p-lang',
+ 'header' => $msgObj->exists() ? $msgObj->text() : 'otherlanguages',
+ 'generated' => false,
+ 'content' => $this->data['language_urls'] ?: [],
+ ];
+ }
+ break;
+ default:
+ $msgObj = $this->getMsg( $boxName );
+ $boxes[$boxName] = [
+ 'id' => "p-$boxName",
+ 'header' => $msgObj->exists() ? $msgObj->text() : $boxName,
+ 'generated' => true,
+ 'content' => $content,
+ ];
+ break;
+ }
+ }
+
+ // HACK: Compatibility with extensions still using SkinTemplateToolboxEnd
+ $hookContents = null;
+ if ( isset( $boxes['TOOLBOX'] ) ) {
+ ob_start();
+ // We pass an extra 'true' at the end so extensions using BaseTemplateToolbox
+ // can abort and avoid outputting double toolbox links
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $template = $this;
+ Hooks::run( 'SkinTemplateToolboxEnd', [ &$template, true ] );
+ $hookContents = ob_get_contents();
+ ob_end_clean();
+ if ( !trim( $hookContents ) ) {
+ $hookContents = null;
+ }
+ }
+ // END hack
+
+ if ( isset( $options['htmlOnly'] ) && $options['htmlOnly'] === true ) {
+ foreach ( $boxes as $boxName => $box ) {
+ if ( is_array( $box['content'] ) ) {
+ $content = '<ul>';
+ foreach ( $box['content'] as $key => $val ) {
+ $content .= "\n " . $this->makeListItem( $key, $val );
+ }
+ // HACK, shove the toolbox end onto the toolbox if we're rendering itself
+ if ( $hookContents ) {
+ $content .= "\n $hookContents";
+ }
+ // END hack
+ $content .= "\n</ul>\n";
+ $boxes[$boxName]['content'] = $content;
+ }
+ }
+ } else {
+ if ( $hookContents ) {
+ $boxes['TOOLBOXEND'] = [
+ 'id' => 'p-toolboxend',
+ 'header' => $boxes['TOOLBOX']['header'],
+ 'generated' => false,
+ 'content' => "<ul>{$hookContents}</ul>",
+ ];
+ // HACK: Make sure that TOOLBOXEND is sorted next to TOOLBOX
+ $boxes2 = [];
+ foreach ( $boxes as $key => $box ) {
+ if ( $key === 'TOOLBOXEND' ) {
+ continue;
+ }
+ $boxes2[$key] = $box;
+ if ( $key === 'TOOLBOX' ) {
+ $boxes2['TOOLBOXEND'] = $boxes['TOOLBOXEND'];
+ }
+ }
+ $boxes = $boxes2;
+ // END hack
+ }
+ }
+
+ return $boxes;
+ }
+
+ /**
+ * @param string $name
+ */
+ protected function renderAfterPortlet( $name ) {
+ echo $this->getAfterPortlet( $name );
+ }
+
+ /**
+ * Allows extensions to hook into known portlets and add stuff to them
+ *
+ * @param string $name
+ *
+ * @return string html
+ * @since 1.29
+ */
+ protected function getAfterPortlet( $name ) {
+ $html = '';
+ $content = '';
+ Hooks::run( 'BaseTemplateAfterPortlet', [ $this, $name, &$content ] );
+
+ if ( $content !== '' ) {
+ $html = Html::rawElement(
+ 'div',
+ [ 'class' => [ 'after-portlet', 'after-portlet-' . $name ] ],
+ $content
+ );
+ }
+
+ return $html;
+ }
+
+ /**
+ * Makes a link, usually used by makeListItem to generate a link for an item
+ * in a list used in navigation lists, portlets, portals, sidebars, etc...
+ *
+ * @param string $key Usually a key from the list you are generating this
+ * link from.
+ * @param array $item Contains some of a specific set of keys.
+ *
+ * The text of the link will be generated either from the contents of the
+ * "text" key in the $item array, if a "msg" key is present a message by
+ * that name will be used, and if neither of those are set the $key will be
+ * used as a message name.
+ *
+ * If a "href" key is not present makeLink will just output htmlescaped text.
+ * The "href", "id", "class", "rel", and "type" keys are used as attributes
+ * for the link if present.
+ *
+ * If an "id" or "single-id" (if you don't want the actual id to be output
+ * on the link) is present it will be used to generate a tooltip and
+ * accesskey for the link.
+ *
+ * The keys "context" and "primary" are ignored; these keys are used
+ * internally by skins and are not supposed to be included in the HTML
+ * output.
+ *
+ * If you don't want an accesskey, set $item['tooltiponly'] = true;
+ *
+ * If a "data" key is present, it must be an array, where the keys represent
+ * the data-xxx properties with their provided values. For example,
+ * $item['data'] = [
+ * 'foo' => 1,
+ * 'bar' => 'baz',
+ * ];
+ * will render as element properties:
+ * data-foo='1' data-bar='baz'
+ *
+ * @param array $options Can be used to affect the output of a link.
+ * Possible options are:
+ * - 'text-wrapper' key to specify a list of elements to wrap the text of
+ * a link in. This should be an array of arrays containing a 'tag' and
+ * optionally an 'attributes' key. If you only have one element you don't
+ * need to wrap it in another array. eg: To use <a><span>...</span></a>
+ * in all links use [ 'text-wrapper' => [ 'tag' => 'span' ] ]
+ * for your options.
+ * - 'link-class' key can be used to specify additional classes to apply
+ * to all links.
+ * - 'link-fallback' can be used to specify a tag to use instead of "<a>"
+ * if there is no link. eg: If you specify 'link-fallback' => 'span' than
+ * any non-link will output a "<span>" instead of just text.
+ *
+ * @return string
+ */
+ function makeLink( $key, $item, $options = [] ) {
+ if ( isset( $item['text'] ) ) {
+ $text = $item['text'];
+ } else {
+ $text = $this->translator->translate( isset( $item['msg'] ) ? $item['msg'] : $key );
+ }
+
+ $html = htmlspecialchars( $text );
+
+ if ( isset( $options['text-wrapper'] ) ) {
+ $wrapper = $options['text-wrapper'];
+ if ( isset( $wrapper['tag'] ) ) {
+ $wrapper = [ $wrapper ];
+ }
+ while ( count( $wrapper ) > 0 ) {
+ $element = array_pop( $wrapper );
+ $html = Html::rawElement( $element['tag'], isset( $element['attributes'] )
+ ? $element['attributes']
+ : null, $html );
+ }
+ }
+
+ if ( isset( $item['href'] ) || isset( $options['link-fallback'] ) ) {
+ $attrs = $item;
+ foreach ( [ 'single-id', 'text', 'msg', 'tooltiponly', 'context', 'primary',
+ 'tooltip-params' ] as $k ) {
+ unset( $attrs[$k] );
+ }
+
+ if ( isset( $attrs['data'] ) ) {
+ foreach ( $attrs['data'] as $key => $value ) {
+ $attrs[ 'data-' . $key ] = $value;
+ }
+ unset( $attrs[ 'data' ] );
+ }
+
+ if ( isset( $item['id'] ) && !isset( $item['single-id'] ) ) {
+ $item['single-id'] = $item['id'];
+ }
+
+ $tooltipParams = [];
+ if ( isset( $item['tooltip-params'] ) ) {
+ $tooltipParams = $item['tooltip-params'];
+ }
+
+ if ( isset( $item['single-id'] ) ) {
+ if ( isset( $item['tooltiponly'] ) && $item['tooltiponly'] ) {
+ $title = Linker::titleAttrib( $item['single-id'], null, $tooltipParams );
+ if ( $title !== false ) {
+ $attrs['title'] = $title;
+ }
+ } else {
+ $tip = Linker::tooltipAndAccesskeyAttribs( $item['single-id'], $tooltipParams );
+ if ( isset( $tip['title'] ) && $tip['title'] !== false ) {
+ $attrs['title'] = $tip['title'];
+ }
+ if ( isset( $tip['accesskey'] ) && $tip['accesskey'] !== false ) {
+ $attrs['accesskey'] = $tip['accesskey'];
+ }
+ }
+ }
+ if ( isset( $options['link-class'] ) ) {
+ if ( isset( $attrs['class'] ) ) {
+ $attrs['class'] .= " {$options['link-class']}";
+ } else {
+ $attrs['class'] = $options['link-class'];
+ }
+ }
+ $html = Html::rawElement( isset( $attrs['href'] )
+ ? 'a'
+ : $options['link-fallback'], $attrs, $html );
+ }
+
+ return $html;
+ }
+
+ /**
+ * Generates a list item for a navigation, portlet, portal, sidebar... list
+ *
+ * @param string $key Usually a key from the list you are generating this link from.
+ * @param array $item Array of list item data containing some of a specific set of keys.
+ * The "id", "class" and "itemtitle" keys will be used as attributes for the list item,
+ * if "active" contains a value of true a "active" class will also be appended to class.
+ *
+ * @param array $options
+ *
+ * If you want something other than a "<li>" you can pass a tag name such as
+ * "tag" => "span" in the $options array to change the tag used.
+ * link/content data for the list item may come in one of two forms
+ * A "links" key may be used, in which case it should contain an array with
+ * a list of links to include inside the list item, see makeLink for the
+ * format of individual links array items.
+ *
+ * Otherwise the relevant keys from the list item $item array will be passed
+ * to makeLink instead. Note however that "id" and "class" are used by the
+ * list item directly so they will not be passed to makeLink
+ * (however the link will still support a tooltip and accesskey from it)
+ * If you need an id or class on a single link you should include a "links"
+ * array with just one link item inside of it. You can also set "link-class" in
+ * $item to set a class on the link itself. If you want to add a title
+ * to the list item itself, you can set "itemtitle" to the value.
+ * $options is also passed on to makeLink calls
+ *
+ * @return string
+ */
+ function makeListItem( $key, $item, $options = [] ) {
+ if ( isset( $item['links'] ) ) {
+ $links = [];
+ foreach ( $item['links'] as $linkKey => $link ) {
+ $links[] = $this->makeLink( $linkKey, $link, $options );
+ }
+ $html = implode( ' ', $links );
+ } else {
+ $link = $item;
+ // These keys are used by makeListItem and shouldn't be passed on to the link
+ foreach ( [ 'id', 'class', 'active', 'tag', 'itemtitle' ] as $k ) {
+ unset( $link[$k] );
+ }
+ if ( isset( $item['id'] ) && !isset( $item['single-id'] ) ) {
+ // The id goes on the <li> not on the <a> for single links
+ // but makeSidebarLink still needs to know what id to use when
+ // generating tooltips and accesskeys.
+ $link['single-id'] = $item['id'];
+ }
+ if ( isset( $link['link-class'] ) ) {
+ // link-class should be set on the <a> itself,
+ // so pass it in as 'class'
+ $link['class'] = $link['link-class'];
+ unset( $link['link-class'] );
+ }
+ $html = $this->makeLink( $key, $link, $options );
+ }
+
+ $attrs = [];
+ foreach ( [ 'id', 'class' ] as $attr ) {
+ if ( isset( $item[$attr] ) ) {
+ $attrs[$attr] = $item[$attr];
+ }
+ }
+ if ( isset( $item['active'] ) && $item['active'] ) {
+ if ( !isset( $attrs['class'] ) ) {
+ $attrs['class'] = '';
+ }
+ $attrs['class'] .= ' active';
+ $attrs['class'] = trim( $attrs['class'] );
+ }
+ if ( isset( $item['itemtitle'] ) ) {
+ $attrs['title'] = $item['itemtitle'];
+ }
+ return Html::rawElement( isset( $options['tag'] ) ? $options['tag'] : 'li', $attrs, $html );
+ }
+
+ function makeSearchInput( $attrs = [] ) {
+ $realAttrs = [
+ 'type' => 'search',
+ 'name' => 'search',
+ 'placeholder' => wfMessage( 'searchsuggest-search' )->text(),
+ ];
+ $realAttrs = array_merge( $realAttrs, Linker::tooltipAndAccesskeyAttribs( 'search' ), $attrs );
+ return Html::element( 'input', $realAttrs );
+ }
+
+ function makeSearchButton( $mode, $attrs = [] ) {
+ switch ( $mode ) {
+ case 'go':
+ case 'fulltext':
+ $realAttrs = [
+ 'type' => 'submit',
+ 'name' => $mode,
+ 'value' => $this->translator->translate(
+ $mode == 'go' ? 'searcharticle' : 'searchbutton' ),
+ ];
+ $realAttrs = array_merge(
+ $realAttrs,
+ Linker::tooltipAndAccesskeyAttribs( "search-$mode" ),
+ $attrs
+ );
+ return Html::element( 'input', $realAttrs );
+ case 'image':
+ $buttonAttrs = [
+ 'type' => 'submit',
+ 'name' => 'button',
+ ];
+ $buttonAttrs = array_merge(
+ $buttonAttrs,
+ Linker::tooltipAndAccesskeyAttribs( 'search-fulltext' ),
+ $attrs
+ );
+ unset( $buttonAttrs['src'] );
+ unset( $buttonAttrs['alt'] );
+ unset( $buttonAttrs['width'] );
+ unset( $buttonAttrs['height'] );
+ $imgAttrs = [
+ 'src' => $attrs['src'],
+ 'alt' => isset( $attrs['alt'] )
+ ? $attrs['alt']
+ : $this->translator->translate( 'searchbutton' ),
+ 'width' => isset( $attrs['width'] ) ? $attrs['width'] : null,
+ 'height' => isset( $attrs['height'] ) ? $attrs['height'] : null,
+ ];
+ return Html::rawElement( 'button', $buttonAttrs, Html::element( 'img', $imgAttrs ) );
+ default:
+ throw new MWException( 'Unknown mode passed to BaseTemplate::makeSearchButton' );
+ }
+ }
+
+ /**
+ * Returns an array of footerlinks trimmed down to only those footer links that
+ * are valid.
+ * If you pass "flat" as an option then the returned array will be a flat array
+ * of footer icons instead of a key/value array of footerlinks arrays broken
+ * up into categories.
+ * @param string $option
+ * @return array|mixed
+ */
+ function getFooterLinks( $option = null ) {
+ $footerlinks = $this->get( 'footerlinks' );
+
+ // Reduce footer links down to only those which are being used
+ $validFooterLinks = [];
+ foreach ( $footerlinks as $category => $links ) {
+ $validFooterLinks[$category] = [];
+ foreach ( $links as $link ) {
+ if ( isset( $this->data[$link] ) && $this->data[$link] ) {
+ $validFooterLinks[$category][] = $link;
+ }
+ }
+ if ( count( $validFooterLinks[$category] ) <= 0 ) {
+ unset( $validFooterLinks[$category] );
+ }
+ }
+
+ if ( $option == 'flat' ) {
+ // fold footerlinks into a single array using a bit of trickery
+ $validFooterLinks = call_user_func_array(
+ 'array_merge',
+ array_values( $validFooterLinks )
+ );
+ }
+
+ return $validFooterLinks;
+ }
+
+ /**
+ * Returns an array of footer icons filtered down by options relevant to how
+ * the skin wishes to display them.
+ * If you pass "icononly" as the option all footer icons which do not have an
+ * image icon set will be filtered out.
+ * If you pass "nocopyright" then MediaWiki's copyright icon will not be included
+ * in the list of footer icons. This is mostly useful for skins which only
+ * display the text from footericons instead of the images and don't want a
+ * duplicate copyright statement because footerlinks already rendered one.
+ * @param string $option
+ * @return array
+ */
+ function getFooterIcons( $option = null ) {
+ // Generate additional footer icons
+ $footericons = $this->get( 'footericons' );
+
+ if ( $option == 'icononly' ) {
+ // Unset any icons which don't have an image
+ foreach ( $footericons as &$footerIconsBlock ) {
+ foreach ( $footerIconsBlock as $footerIconKey => $footerIcon ) {
+ if ( !is_string( $footerIcon ) && !isset( $footerIcon['src'] ) ) {
+ unset( $footerIconsBlock[$footerIconKey] );
+ }
+ }
+ }
+ // Redo removal of any empty blocks
+ foreach ( $footericons as $footerIconsKey => &$footerIconsBlock ) {
+ if ( count( $footerIconsBlock ) <= 0 ) {
+ unset( $footericons[$footerIconsKey] );
+ }
+ }
+ } elseif ( $option == 'nocopyright' ) {
+ unset( $footericons['copyright']['copyright'] );
+ if ( count( $footericons['copyright'] ) <= 0 ) {
+ unset( $footericons['copyright'] );
+ }
+ }
+
+ return $footericons;
+ }
+
+ /**
+ * Renderer for getFooterIcons and getFooterLinks
+ *
+ * @param string $iconStyle $option for getFooterIcons: "icononly", "nocopyright"
+ * @param string $linkStyle $option for getFooterLinks: "flat"
+ *
+ * @return string html
+ * @since 1.29
+ */
+ protected function getFooter( $iconStyle = 'icononly', $linkStyle = 'flat' ) {
+ $validFooterIcons = $this->getFooterIcons( $iconStyle );
+ $validFooterLinks = $this->getFooterLinks( $linkStyle );
+
+ $html = '';
+
+ if ( count( $validFooterIcons ) + count( $validFooterLinks ) > 0 ) {
+ $html .= Html::openElement( 'div', [
+ 'id' => 'footer-bottom',
+ 'role' => 'contentinfo',
+ 'lang' => $this->get( 'userlang' ),
+ 'dir' => $this->get( 'dir' )
+ ] );
+ $footerEnd = Html::closeElement( 'div' );
+ } else {
+ $footerEnd = '';
+ }
+ foreach ( $validFooterIcons as $blockName => $footerIcons ) {
+ $html .= Html::openElement( 'div', [
+ 'id' => Sanitizer::escapeIdForAttribute( "f-{$blockName}ico" ),
+ 'class' => 'footer-icons'
+ ] );
+ foreach ( $footerIcons as $icon ) {
+ $html .= $this->getSkin()->makeFooterIcon( $icon );
+ }
+ $html .= Html::closeElement( 'div' );
+ }
+ if ( count( $validFooterLinks ) > 0 ) {
+ $html .= Html::openElement( 'ul', [ 'id' => 'f-list', 'class' => 'footer-places' ] );
+ foreach ( $validFooterLinks as $aLink ) {
+ $html .= Html::rawElement(
+ 'li',
+ [ 'id' => Sanitizer::escapeIdForAttribute( $aLink ) ],
+ $this->get( $aLink )
+ );
+ }
+ $html .= Html::closeElement( 'ul' );
+ }
+
+ $html .= $this->getClear() . $footerEnd;
+
+ return $html;
+ }
+
+ /**
+ * Get a div with the core visualClear class, for clearing floats
+ *
+ * @return string html
+ * @since 1.29
+ */
+ protected function getClear() {
+ return Html::element( 'div', [ 'class' => 'visualClear' ] );
+ }
+
+ /**
+ * Get the suggested HTML for page status indicators: icons (or short text snippets) usually
+ * displayed in the top-right corner of the page, outside of the main content.
+ *
+ * Your skin may implement this differently, for example by handling some indicator names
+ * specially with a different UI. However, it is recommended to use a `<div class="mw-indicator"
+ * id="mw-indicator-<id>" />` as a wrapper element for each indicator, for better compatibility
+ * with extensions and user scripts.
+ *
+ * The raw data is available in `$this->data['indicators']` as an associative array (keys:
+ * identifiers, values: contents) internally ordered by keys.
+ *
+ * @return string HTML
+ * @since 1.25
+ */
+ public function getIndicators() {
+ $out = "<div class=\"mw-indicators mw-body-content\">\n";
+ foreach ( $this->data['indicators'] as $id => $content ) {
+ $out .= Html::rawElement(
+ 'div',
+ [
+ 'id' => Sanitizer::escapeIdForAttribute( "mw-indicator-$id" ),
+ 'class' => 'mw-indicator',
+ ],
+ $content
+ ) . "\n";
+ }
+ $out .= "</div>\n";
+ return $out;
+ }
+
+ /**
+ * Output getTrail
+ */
+ function printTrail() {
+ echo $this->getTrail();
+ }
+
+ /**
+ * Get the basic end-page trail including bottomscripts, reporttime, and
+ * debug stuff. This should be called right before outputting the closing
+ * body and html tags.
+ *
+ * @return string
+ * @since 1.29
+ */
+ function getTrail() {
+ $html = MWDebug::getDebugHTML( $this->getSkin()->getContext() );
+ $html .= $this->get( 'bottomscripts' );
+ $html .= $this->get( 'reporttime' );
+
+ return $html;
+ }
+}
diff --git a/www/wiki/includes/skins/MediaWikiI18N.php b/www/wiki/includes/skins/MediaWikiI18N.php
new file mode 100644
index 00000000..7fcdb3c9
--- /dev/null
+++ b/www/wiki/includes/skins/MediaWikiI18N.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Wrapper object for MediaWiki's localization functions,
+ * to be passed to the template engine.
+ *
+ * @private
+ * @ingroup Skins
+ */
+class MediaWikiI18N {
+ private $context = [];
+
+ function set( $varName, $value ) {
+ $this->context[$varName] = $value;
+ }
+
+ function translate( $value ) {
+ // Hack for i18n:attributes in PHPTAL 1.0.0 dev version as of 2004-10-23
+ $value = preg_replace( '/^string:/', '', $value );
+
+ $value = wfMessage( $value )->text();
+ // interpolate variables
+ $m = [];
+ while ( preg_match( '/\$([0-9]*?)/sm', $value, $m ) ) {
+ list( $src, $var ) = $m;
+ MediaWiki\suppressWarnings();
+ $varValue = $this->context[$var];
+ MediaWiki\restoreWarnings();
+ $value = str_replace( $src, $varValue, $value );
+ }
+ return $value;
+ }
+}
diff --git a/www/wiki/includes/skins/QuickTemplate.php b/www/wiki/includes/skins/QuickTemplate.php
new file mode 100644
index 00000000..d1be4bb0
--- /dev/null
+++ b/www/wiki/includes/skins/QuickTemplate.php
@@ -0,0 +1,200 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Generic wrapper for template functions, with interface
+ * compatible with what we use of PHPTAL 0.7.
+ * @ingroup Skins
+ */
+abstract class QuickTemplate {
+
+ /**
+ * @var array
+ */
+ public $data;
+
+ /**
+ * @var MediaWikiI18N
+ */
+ public $translator;
+
+ /** @var Config $config */
+ protected $config;
+
+ /**
+ * @param Config $config
+ */
+ function __construct( Config $config = null ) {
+ $this->data = [];
+ $this->translator = new MediaWikiI18N();
+ if ( $config === null ) {
+ wfDebug( __METHOD__ . ' was called with no Config instance passed to it' );
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+ $this->config = $config;
+ }
+
+ /**
+ * Sets the value $value to $name
+ * @param string $name
+ * @param mixed $value
+ */
+ public function set( $name, $value ) {
+ $this->data[$name] = $value;
+ }
+
+ /**
+ * extends the value of data with name $name with the value $value
+ * @since 1.25
+ * @param string $name
+ * @param mixed $value
+ */
+ public function extend( $name, $value ) {
+ if ( $this->haveData( $name ) ) {
+ $this->data[$name] = $this->data[$name] . $value;
+ } else {
+ $this->data[$name] = $value;
+ }
+ }
+
+ /**
+ * Gets the template data requested
+ * @since 1.22
+ * @param string $name Key for the data
+ * @param mixed $default Optional default (or null)
+ * @return mixed The value of the data requested or the deafult
+ */
+ public function get( $name, $default = null ) {
+ if ( isset( $this->data[$name] ) ) {
+ return $this->data[$name];
+ } else {
+ return $default;
+ }
+ }
+
+ /**
+ * @param string $name
+ * @param mixed &$value
+ */
+ public function setRef( $name, &$value ) {
+ $this->data[$name] =& $value;
+ }
+
+ /**
+ * @param MediaWikiI18N &$t
+ */
+ public function setTranslator( &$t ) {
+ $this->translator = &$t;
+ }
+
+ /**
+ * Main function, used by classes that subclass QuickTemplate
+ * to show the actual HTML output
+ */
+ abstract public function execute();
+
+ /**
+ * @private
+ * @param string $str
+ */
+ function text( $str ) {
+ echo htmlspecialchars( $this->data[$str] );
+ }
+
+ /**
+ * @private
+ * @param string $str
+ */
+ function html( $str ) {
+ echo $this->data[$str];
+ }
+
+ /**
+ * @private
+ * @param string $str
+ */
+ function msg( $str ) {
+ echo htmlspecialchars( $this->translator->translate( $str ) );
+ }
+
+ /**
+ * @private
+ * @param string $str
+ */
+ function msgHtml( $str ) {
+ echo $this->translator->translate( $str );
+ }
+
+ /**
+ * An ugly, ugly hack.
+ * @private
+ * @param string $str
+ */
+ function msgWiki( $str ) {
+ global $wgOut;
+
+ $text = $this->translator->translate( $str );
+ echo $wgOut->parse( $text );
+ }
+
+ /**
+ * @private
+ * @param string $str
+ * @return bool
+ */
+ function haveData( $str ) {
+ return isset( $this->data[$str] );
+ }
+
+ /**
+ * @private
+ *
+ * @param string $str
+ * @return bool
+ */
+ function haveMsg( $str ) {
+ $msg = $this->translator->translate( $str );
+ return ( $msg != '-' ) && ( $msg != '' ); # ????
+ }
+
+ /**
+ * Get the Skin object related to this object
+ *
+ * @return Skin
+ */
+ public function getSkin() {
+ return $this->data['skin'];
+ }
+
+ /**
+ * Fetch the output of a QuickTemplate and return it
+ *
+ * @since 1.23
+ * @return string
+ */
+ public function getHTML() {
+ ob_start();
+ $this->execute();
+ $html = ob_get_contents();
+ ob_end_clean();
+ return $html;
+ }
+}
diff --git a/www/wiki/includes/skins/Skin.php b/www/wiki/includes/skins/Skin.php
new file mode 100644
index 00000000..8fb0d1c0
--- /dev/null
+++ b/www/wiki/includes/skins/Skin.php
@@ -0,0 +1,1627 @@
+<?php
+/**
+ * Base class for all skins.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @defgroup Skins Skins
+ */
+
+/**
+ * The main skin class which provides methods and properties for all other skins.
+ *
+ * See docs/skin.txt for more information.
+ *
+ * @ingroup Skins
+ */
+abstract class Skin extends ContextSource {
+ protected $skinname = null;
+ protected $mRelevantTitle = null;
+ protected $mRelevantUser = null;
+
+ /**
+ * @var string Stylesheets set to use. Subdirectory in skins/ where various stylesheets are
+ * located. Only needs to be set if you intend to use the getSkinStylePath() method.
+ */
+ public $stylename = null;
+
+ /**
+ * Fetch the set of available skins.
+ * @return array Associative array of strings
+ */
+ static function getSkinNames() {
+ return SkinFactory::getDefaultInstance()->getSkinNames();
+ }
+
+ /**
+ * Fetch the skinname messages for available skins.
+ * @return string[]
+ */
+ static function getSkinNameMessages() {
+ $messages = [];
+ foreach ( self::getSkinNames() as $skinKey => $skinName ) {
+ $messages[] = "skinname-$skinKey";
+ }
+ return $messages;
+ }
+
+ /**
+ * Fetch the list of user-selectable skins in regards to $wgSkipSkins.
+ * Useful for Special:Preferences and other places where you
+ * only want to show skins users _can_ use.
+ * @return string[]
+ * @since 1.23
+ */
+ public static function getAllowedSkins() {
+ global $wgSkipSkins;
+
+ $allowedSkins = self::getSkinNames();
+
+ foreach ( $wgSkipSkins as $skip ) {
+ unset( $allowedSkins[$skip] );
+ }
+
+ return $allowedSkins;
+ }
+
+ /**
+ * Normalize a skin preference value to a form that can be loaded.
+ *
+ * If a skin can't be found, it will fall back to the configured default ($wgDefaultSkin), or the
+ * hardcoded default ($wgFallbackSkin) if the default skin is unavailable too.
+ *
+ * @param string $key 'monobook', 'vector', etc.
+ * @return string
+ */
+ static function normalizeKey( $key ) {
+ global $wgDefaultSkin, $wgFallbackSkin;
+
+ $skinNames = self::getSkinNames();
+
+ // Make keys lowercase for case-insensitive matching.
+ $skinNames = array_change_key_case( $skinNames, CASE_LOWER );
+ $key = strtolower( $key );
+ $defaultSkin = strtolower( $wgDefaultSkin );
+ $fallbackSkin = strtolower( $wgFallbackSkin );
+
+ if ( $key == '' || $key == 'default' ) {
+ // Don't return the default immediately;
+ // in a misconfiguration we need to fall back.
+ $key = $defaultSkin;
+ }
+
+ if ( isset( $skinNames[$key] ) ) {
+ return $key;
+ }
+
+ // Older versions of the software used a numeric setting
+ // in the user preferences.
+ $fallback = [
+ 0 => $defaultSkin,
+ 2 => 'cologneblue'
+ ];
+
+ if ( isset( $fallback[$key] ) ) {
+ $key = $fallback[$key];
+ }
+
+ if ( isset( $skinNames[$key] ) ) {
+ return $key;
+ } elseif ( isset( $skinNames[$defaultSkin] ) ) {
+ return $defaultSkin;
+ } else {
+ return $fallbackSkin;
+ }
+ }
+
+ /**
+ * @return string Skin name
+ */
+ public function getSkinName() {
+ return $this->skinname;
+ }
+
+ /**
+ * @param OutputPage $out
+ */
+ public function initPage( OutputPage $out ) {
+ $this->preloadExistence();
+ }
+
+ /**
+ * Defines the ResourceLoader modules that should be added to the skin
+ * It is recommended that skins wishing to override call parent::getDefaultModules()
+ * and substitute out any modules they wish to change by using a key to look them up
+ *
+ * For style modules, use setupSkinUserCss() instead.
+ *
+ * @return array Array of modules with helper keys for easy overriding
+ */
+ public function getDefaultModules() {
+ global $wgUseAjax, $wgEnableAPI, $wgEnableWriteAPI;
+
+ $out = $this->getOutput();
+ $config = $this->getConfig();
+ $user = $out->getUser();
+ $modules = [
+ // modules not specific to any specific skin or page
+ 'core' => [
+ // Enforce various default modules for all pages and all skins
+ // Keep this list as small as possible
+ 'site',
+ 'mediawiki.page.startup',
+ 'mediawiki.user',
+ ],
+ // modules that enhance the page content in some way
+ 'content' => [
+ 'mediawiki.page.ready',
+ ],
+ // modules relating to search functionality
+ 'search' => [],
+ // modules relating to functionality relating to watching an article
+ 'watch' => [],
+ // modules which relate to the current users preferences
+ 'user' => [],
+ ];
+
+ // Support for high-density display images if enabled
+ if ( $config->get( 'ResponsiveImages' ) ) {
+ $modules['core'][] = 'mediawiki.hidpi';
+ }
+
+ // Preload jquery.tablesorter for mediawiki.page.ready
+ if ( strpos( $out->getHTML(), 'sortable' ) !== false ) {
+ $modules['content'][] = 'jquery.tablesorter';
+ }
+
+ // Preload jquery.makeCollapsible for mediawiki.page.ready
+ if ( strpos( $out->getHTML(), 'mw-collapsible' ) !== false ) {
+ $modules['content'][] = 'jquery.makeCollapsible';
+ }
+
+ if ( $out->isTOCEnabled() ) {
+ $modules['content'][] = 'mediawiki.toc';
+ }
+
+ // Add various resources if required
+ if ( $wgUseAjax && $wgEnableAPI ) {
+ if ( $wgEnableWriteAPI && $user->isLoggedIn()
+ && $user->isAllowedAll( 'writeapi', 'viewmywatchlist', 'editmywatchlist' )
+ && $this->getRelevantTitle()->canExist()
+ ) {
+ $modules['watch'][] = 'mediawiki.page.watch.ajax';
+ }
+
+ $modules['search'][] = 'mediawiki.searchSuggest';
+ }
+
+ if ( $user->getBoolOption( 'editsectiononrightclick' ) ) {
+ $modules['user'][] = 'mediawiki.action.view.rightClickEdit';
+ }
+
+ // Crazy edit-on-double-click stuff
+ if ( $out->isArticle() && $user->getOption( 'editondblclick' ) ) {
+ $modules['user'][] = 'mediawiki.action.view.dblClickEdit';
+ }
+ return $modules;
+ }
+
+ /**
+ * Preload the existence of three commonly-requested pages in a single query
+ */
+ protected function preloadExistence() {
+ $titles = [];
+
+ // User/talk link
+ $user = $this->getUser();
+ if ( $user->isLoggedIn() ) {
+ $titles[] = $user->getUserPage();
+ $titles[] = $user->getTalkPage();
+ }
+
+ // Check, if the page can hold some kind of content, otherwise do nothing
+ $title = $this->getRelevantTitle();
+ if ( $title->canExist() ) {
+ if ( $title->isTalkPage() ) {
+ $titles[] = $title->getSubjectPage();
+ } else {
+ $titles[] = $title->getTalkPage();
+ }
+ }
+
+ // Footer links (used by SkinTemplate::prepareQuickTemplate)
+ foreach ( [
+ $this->footerLinkTitle( 'privacy', 'privacypage' ),
+ $this->footerLinkTitle( 'aboutsite', 'aboutpage' ),
+ $this->footerLinkTitle( 'disclaimers', 'disclaimerpage' ),
+ ] as $title ) {
+ if ( $title ) {
+ $titles[] = $title;
+ }
+ }
+
+ Hooks::run( 'SkinPreloadExistence', [ &$titles, $this ] );
+
+ if ( $titles ) {
+ $lb = new LinkBatch( $titles );
+ $lb->setCaller( __METHOD__ );
+ $lb->execute();
+ }
+ }
+
+ /**
+ * Get the current revision ID
+ *
+ * @return int
+ */
+ public function getRevisionId() {
+ return $this->getOutput()->getRevisionId();
+ }
+
+ /**
+ * Whether the revision displayed is the latest revision of the page
+ *
+ * @return bool
+ */
+ public function isRevisionCurrent() {
+ $revID = $this->getRevisionId();
+ return $revID == 0 || $revID == $this->getTitle()->getLatestRevID();
+ }
+
+ /**
+ * Set the "relevant" title
+ * @see self::getRelevantTitle()
+ * @param Title $t
+ */
+ public function setRelevantTitle( $t ) {
+ $this->mRelevantTitle = $t;
+ }
+
+ /**
+ * Return the "relevant" title.
+ * A "relevant" title is not necessarily the actual title of the page.
+ * Special pages like Special:MovePage use set the page they are acting on
+ * as their "relevant" title, this allows the skin system to display things
+ * such as content tabs which belong to to that page instead of displaying
+ * a basic special page tab which has almost no meaning.
+ *
+ * @return Title
+ */
+ public function getRelevantTitle() {
+ if ( isset( $this->mRelevantTitle ) ) {
+ return $this->mRelevantTitle;
+ }
+ return $this->getTitle();
+ }
+
+ /**
+ * Set the "relevant" user
+ * @see self::getRelevantUser()
+ * @param User $u
+ */
+ public function setRelevantUser( $u ) {
+ $this->mRelevantUser = $u;
+ }
+
+ /**
+ * Return the "relevant" user.
+ * A "relevant" user is similar to a relevant title. Special pages like
+ * Special:Contributions mark the user which they are relevant to so that
+ * things like the toolbox can display the information they usually are only
+ * able to display on a user's userpage and talkpage.
+ * @return User
+ */
+ public function getRelevantUser() {
+ if ( isset( $this->mRelevantUser ) ) {
+ return $this->mRelevantUser;
+ }
+ $title = $this->getRelevantTitle();
+ if ( $title->hasSubjectNamespace( NS_USER ) ) {
+ $rootUser = $title->getRootText();
+ if ( User::isIP( $rootUser ) ) {
+ $this->mRelevantUser = User::newFromName( $rootUser, false );
+ } else {
+ $user = User::newFromName( $rootUser, false );
+
+ if ( $user ) {
+ $user->load( User::READ_NORMAL );
+
+ if ( $user->isLoggedIn() ) {
+ $this->mRelevantUser = $user;
+ }
+ }
+ }
+ return $this->mRelevantUser;
+ }
+ return null;
+ }
+
+ /**
+ * Outputs the HTML generated by other functions.
+ * @param OutputPage $out
+ */
+ abstract function outputPage( OutputPage $out = null );
+
+ /**
+ * @param array $data
+ * @return string
+ */
+ static function makeVariablesScript( $data ) {
+ if ( $data ) {
+ return ResourceLoader::makeInlineScript(
+ ResourceLoader::makeConfigSetScript( $data )
+ );
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Get the query to generate a dynamic stylesheet
+ *
+ * @return array
+ */
+ public static function getDynamicStylesheetQuery() {
+ global $wgSquidMaxage;
+
+ return [
+ 'action' => 'raw',
+ 'maxage' => $wgSquidMaxage,
+ 'usemsgcache' => 'yes',
+ 'ctype' => 'text/css',
+ 'smaxage' => $wgSquidMaxage,
+ ];
+ }
+
+ /**
+ * Add skin specific stylesheets
+ * Calling this method with an $out of anything but the same OutputPage
+ * inside ->getOutput() is deprecated. The $out arg is kept
+ * for compatibility purposes with skins.
+ * @param OutputPage $out
+ * @todo delete
+ */
+ abstract function setupSkinUserCss( OutputPage $out );
+
+ /**
+ * TODO: document
+ * @param Title $title
+ * @return string
+ */
+ function getPageClasses( $title ) {
+ $numeric = 'ns-' . $title->getNamespace();
+
+ if ( $title->isSpecialPage() ) {
+ $type = 'ns-special';
+ // T25315: provide a class based on the canonical special page name without subpages
+ list( $canonicalName ) = SpecialPageFactory::resolveAlias( $title->getDBkey() );
+ if ( $canonicalName ) {
+ $type .= ' ' . Sanitizer::escapeClass( "mw-special-$canonicalName" );
+ } else {
+ $type .= ' mw-invalidspecialpage';
+ }
+ } elseif ( $title->isTalkPage() ) {
+ $type = 'ns-talk';
+ } else {
+ $type = 'ns-subject';
+ }
+
+ $name = Sanitizer::escapeClass( 'page-' . $title->getPrefixedText() );
+ $root = Sanitizer::escapeClass( 'rootpage-' . $title->getRootTitle()->getPrefixedText() );
+
+ return "$numeric $type $name $root";
+ }
+
+ /**
+ * Return values for <html> element
+ * @return array Array of associative name-to-value elements for <html> element
+ */
+ public function getHtmlElementAttributes() {
+ $lang = $this->getLanguage();
+ return [
+ 'lang' => $lang->getHtmlCode(),
+ 'dir' => $lang->getDir(),
+ 'class' => 'client-nojs',
+ ];
+ }
+
+ /**
+ * This will be called by OutputPage::headElement when it is creating the
+ * "<body>" tag, skins can override it if they have a need to add in any
+ * body attributes or classes of their own.
+ * @param OutputPage $out
+ * @param array &$bodyAttrs
+ */
+ function addToBodyAttributes( $out, &$bodyAttrs ) {
+ // does nothing by default
+ }
+
+ /**
+ * URL to the logo
+ * @return string
+ */
+ function getLogo() {
+ global $wgLogo;
+ return $wgLogo;
+ }
+
+ /**
+ * Whether the logo should be preloaded with an HTTP link header or not
+ * @since 1.29
+ * @return bool
+ */
+ public function shouldPreloadLogo() {
+ return false;
+ }
+
+ /**
+ * @return string HTML
+ */
+ function getCategoryLinks() {
+ global $wgUseCategoryBrowser;
+
+ $out = $this->getOutput();
+ $allCats = $out->getCategoryLinks();
+
+ if ( !count( $allCats ) ) {
+ return '';
+ }
+
+ $embed = "<li>";
+ $pop = "</li>";
+
+ $s = '';
+ $colon = $this->msg( 'colon-separator' )->escaped();
+
+ if ( !empty( $allCats['normal'] ) ) {
+ $t = $embed . implode( "{$pop}{$embed}", $allCats['normal'] ) . $pop;
+
+ $msg = $this->msg( 'pagecategories' )->numParams( count( $allCats['normal'] ) )->escaped();
+ $linkPage = wfMessage( 'pagecategorieslink' )->inContentLanguage()->text();
+ $title = Title::newFromText( $linkPage );
+ $link = $title ? Linker::link( $title, $msg ) : $msg;
+ $s .= '<div id="mw-normal-catlinks" class="mw-normal-catlinks">' .
+ $link . $colon . '<ul>' . $t . '</ul>' . '</div>';
+ }
+
+ # Hidden categories
+ if ( isset( $allCats['hidden'] ) ) {
+ if ( $this->getUser()->getBoolOption( 'showhiddencats' ) ) {
+ $class = ' mw-hidden-cats-user-shown';
+ } elseif ( $this->getTitle()->getNamespace() == NS_CATEGORY ) {
+ $class = ' mw-hidden-cats-ns-shown';
+ } else {
+ $class = ' mw-hidden-cats-hidden';
+ }
+
+ $s .= "<div id=\"mw-hidden-catlinks\" class=\"mw-hidden-catlinks$class\">" .
+ $this->msg( 'hidden-categories' )->numParams( count( $allCats['hidden'] ) )->escaped() .
+ $colon . '<ul>' . $embed . implode( "{$pop}{$embed}", $allCats['hidden'] ) . $pop . '</ul>' .
+ '</div>';
+ }
+
+ # optional 'dmoz-like' category browser. Will be shown under the list
+ # of categories an article belong to
+ if ( $wgUseCategoryBrowser ) {
+ $s .= '<br /><hr />';
+
+ # get a big array of the parents tree
+ $parenttree = $this->getTitle()->getParentCategoryTree();
+ # Skin object passed by reference cause it can not be
+ # accessed under the method subfunction drawCategoryBrowser
+ $tempout = explode( "\n", $this->drawCategoryBrowser( $parenttree ) );
+ # Clean out bogus first entry and sort them
+ unset( $tempout[0] );
+ asort( $tempout );
+ # Output one per line
+ $s .= implode( "<br />\n", $tempout );
+ }
+
+ return $s;
+ }
+
+ /**
+ * Render the array as a series of links.
+ * @param array $tree Categories tree returned by Title::getParentCategoryTree
+ * @return string Separated by &gt;, terminate with "\n"
+ */
+ function drawCategoryBrowser( $tree ) {
+ $return = '';
+
+ foreach ( $tree as $element => $parent ) {
+ if ( empty( $parent ) ) {
+ # element start a new list
+ $return .= "\n";
+ } else {
+ # grab the others elements
+ $return .= $this->drawCategoryBrowser( $parent ) . ' &gt; ';
+ }
+
+ # add our current element to the list
+ $eltitle = Title::newFromText( $element );
+ $return .= Linker::link( $eltitle, htmlspecialchars( $eltitle->getText() ) );
+ }
+
+ return $return;
+ }
+
+ /**
+ * @return string HTML
+ */
+ function getCategories() {
+ $out = $this->getOutput();
+ $catlinks = $this->getCategoryLinks();
+
+ // Check what we're showing
+ $allCats = $out->getCategoryLinks();
+ $showHidden = $this->getUser()->getBoolOption( 'showhiddencats' ) ||
+ $this->getTitle()->getNamespace() == NS_CATEGORY;
+
+ $classes = [ 'catlinks' ];
+ if ( empty( $allCats['normal'] ) && !( !empty( $allCats['hidden'] ) && $showHidden ) ) {
+ $classes[] = 'catlinks-allhidden';
+ }
+
+ return Html::rawElement(
+ 'div',
+ [ 'id' => 'catlinks', 'class' => $classes, 'data-mw' => 'interface' ],
+ $catlinks
+ );
+ }
+
+ /**
+ * This runs a hook to allow extensions placing their stuff after content
+ * and article metadata (e.g. categories).
+ * Note: This function has nothing to do with afterContent().
+ *
+ * This hook is placed here in order to allow using the same hook for all
+ * skins, both the SkinTemplate based ones and the older ones, which directly
+ * use this class to get their data.
+ *
+ * The output of this function gets processed in SkinTemplate::outputPage() for
+ * the SkinTemplate based skins, all other skins should directly echo it.
+ *
+ * @return string Empty by default, if not changed by any hook function.
+ */
+ protected function afterContentHook() {
+ $data = '';
+
+ if ( Hooks::run( 'SkinAfterContent', [ &$data, $this ] ) ) {
+ // adding just some spaces shouldn't toggle the output
+ // of the whole <div/>, so we use trim() here
+ if ( trim( $data ) != '' ) {
+ // Doing this here instead of in the skins to
+ // ensure that the div has the same ID in all
+ // skins
+ $data = "<div id='mw-data-after-content'>\n" .
+ "\t$data\n" .
+ "</div>\n";
+ }
+ } else {
+ wfDebug( "Hook SkinAfterContent changed output processing.\n" );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Generate debug data HTML for displaying at the bottom of the main content
+ * area.
+ * @return string HTML containing debug data, if enabled (otherwise empty).
+ */
+ protected function generateDebugHTML() {
+ return MWDebug::getHTMLDebugLog();
+ }
+
+ /**
+ * This gets called shortly before the "</body>" tag.
+ *
+ * @return string HTML-wrapped JS code to be put before "</body>"
+ */
+ function bottomScripts() {
+ // TODO and the suckage continues. This function is really just a wrapper around
+ // OutputPage::getBottomScripts() which takes a Skin param. This should be cleaned
+ // up at some point
+ $bottomScriptText = $this->getOutput()->getBottomScripts();
+ Hooks::run( 'SkinAfterBottomScripts', [ $this, &$bottomScriptText ] );
+
+ return $bottomScriptText;
+ }
+
+ /**
+ * Text with the permalink to the source page,
+ * usually shown on the footer of a printed page
+ *
+ * @return string HTML text with an URL
+ */
+ function printSource() {
+ $oldid = $this->getRevisionId();
+ if ( $oldid ) {
+ $canonicalUrl = $this->getTitle()->getCanonicalURL( 'oldid=' . $oldid );
+ $url = htmlspecialchars( wfExpandIRI( $canonicalUrl ) );
+ } else {
+ // oldid not available for non existing pages
+ $url = htmlspecialchars( wfExpandIRI( $this->getTitle()->getCanonicalURL() ) );
+ }
+
+ return $this->msg( 'retrievedfrom' )
+ ->rawParams( '<a dir="ltr" href="' . $url . '">' . $url . '</a>' )
+ ->parse();
+ }
+
+ /**
+ * @return string HTML
+ */
+ function getUndeleteLink() {
+ $action = $this->getRequest()->getVal( 'action', 'view' );
+
+ if ( $this->getTitle()->userCan( 'deletedhistory', $this->getUser() ) &&
+ ( !$this->getTitle()->exists() || $action == 'history' ) ) {
+ $n = $this->getTitle()->isDeleted();
+
+ if ( $n ) {
+ if ( $this->getTitle()->quickUserCan( 'undelete', $this->getUser() ) ) {
+ $msg = 'thisisdeleted';
+ } else {
+ $msg = 'viewdeleted';
+ }
+
+ return $this->msg( $msg )->rawParams(
+ Linker::linkKnown(
+ SpecialPage::getTitleFor( 'Undelete', $this->getTitle()->getPrefixedDBkey() ),
+ $this->msg( 'restorelink' )->numParams( $n )->escaped() )
+ )->escaped();
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * @param OutputPage $out Defaults to $this->getOutput() if left as null
+ * @return string
+ */
+ function subPageSubtitle( $out = null ) {
+ if ( $out === null ) {
+ $out = $this->getOutput();
+ }
+ $title = $out->getTitle();
+ $subpages = '';
+
+ if ( !Hooks::run( 'SkinSubPageSubtitle', [ &$subpages, $this, $out ] ) ) {
+ return $subpages;
+ }
+
+ if ( $out->isArticle() && MWNamespace::hasSubpages( $title->getNamespace() ) ) {
+ $ptext = $title->getPrefixedText();
+ if ( strpos( $ptext, '/' ) !== false ) {
+ $links = explode( '/', $ptext );
+ array_pop( $links );
+ $c = 0;
+ $growinglink = '';
+ $display = '';
+ $lang = $this->getLanguage();
+
+ foreach ( $links as $link ) {
+ $growinglink .= $link;
+ $display .= $link;
+ $linkObj = Title::newFromText( $growinglink );
+
+ if ( is_object( $linkObj ) && $linkObj->isKnown() ) {
+ $getlink = Linker::linkKnown(
+ $linkObj,
+ htmlspecialchars( $display )
+ );
+
+ $c++;
+
+ if ( $c > 1 ) {
+ $subpages .= $lang->getDirMarkEntity() . $this->msg( 'pipe-separator' )->escaped();
+ } else {
+ $subpages .= '&lt; ';
+ }
+
+ $subpages .= $getlink;
+ $display = '';
+ } else {
+ $display .= '/';
+ }
+ $growinglink .= '/';
+ }
+ }
+ }
+
+ return $subpages;
+ }
+
+ /**
+ * @deprecated since 1.27, feature removed
+ * @return bool Always false
+ */
+ function showIPinHeader() {
+ wfDeprecated( __METHOD__, '1.27' );
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ function getSearchLink() {
+ $searchPage = SpecialPage::getTitleFor( 'Search' );
+ return $searchPage->getLocalURL();
+ }
+
+ /**
+ * @return string
+ */
+ function escapeSearchLink() {
+ return htmlspecialchars( $this->getSearchLink() );
+ }
+
+ /**
+ * @param string $type
+ * @return string
+ */
+ function getCopyright( $type = 'detect' ) {
+ global $wgRightsPage, $wgRightsUrl, $wgRightsText;
+
+ if ( $type == 'detect' ) {
+ if ( !$this->isRevisionCurrent()
+ && !$this->msg( 'history_copyright' )->inContentLanguage()->isDisabled()
+ ) {
+ $type = 'history';
+ } else {
+ $type = 'normal';
+ }
+ }
+
+ if ( $type == 'history' ) {
+ $msg = 'history_copyright';
+ } else {
+ $msg = 'copyright';
+ }
+
+ if ( $wgRightsPage ) {
+ $title = Title::newFromText( $wgRightsPage );
+ $link = Linker::linkKnown( $title, $wgRightsText );
+ } elseif ( $wgRightsUrl ) {
+ $link = Linker::makeExternalLink( $wgRightsUrl, $wgRightsText );
+ } elseif ( $wgRightsText ) {
+ $link = $wgRightsText;
+ } else {
+ # Give up now
+ return '';
+ }
+
+ // Allow for site and per-namespace customization of copyright notice.
+ // @todo Remove deprecated $forContent param from hook handlers and then remove here.
+ $forContent = true;
+
+ Hooks::run(
+ 'SkinCopyrightFooter',
+ [ $this->getTitle(), $type, &$msg, &$link, &$forContent ]
+ );
+
+ return $this->msg( $msg )->rawParams( $link )->text();
+ }
+
+ /**
+ * @return null|string
+ */
+ function getCopyrightIcon() {
+ global $wgRightsUrl, $wgRightsText, $wgRightsIcon, $wgFooterIcons;
+
+ $out = '';
+
+ if ( $wgFooterIcons['copyright']['copyright'] ) {
+ $out = $wgFooterIcons['copyright']['copyright'];
+ } elseif ( $wgRightsIcon ) {
+ $icon = htmlspecialchars( $wgRightsIcon );
+
+ if ( $wgRightsUrl ) {
+ $url = htmlspecialchars( $wgRightsUrl );
+ $out .= '<a href="' . $url . '">';
+ }
+
+ $text = htmlspecialchars( $wgRightsText );
+ $out .= "<img src=\"$icon\" alt=\"$text\" width=\"88\" height=\"31\" />";
+
+ if ( $wgRightsUrl ) {
+ $out .= '</a>';
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Gets the powered by MediaWiki icon.
+ * @return string
+ */
+ function getPoweredBy() {
+ global $wgResourceBasePath;
+
+ $url1 = htmlspecialchars(
+ "$wgResourceBasePath/resources/assets/poweredby_mediawiki_88x31.png"
+ );
+ $url1_5 = htmlspecialchars(
+ "$wgResourceBasePath/resources/assets/poweredby_mediawiki_132x47.png"
+ );
+ $url2 = htmlspecialchars(
+ "$wgResourceBasePath/resources/assets/poweredby_mediawiki_176x62.png"
+ );
+ $text = '<a href="//www.mediawiki.org/"><img src="' . $url1
+ . '" srcset="' . $url1_5 . ' 1.5x, ' . $url2 . ' 2x" '
+ . 'height="31" width="88" alt="Powered by MediaWiki" /></a>';
+ Hooks::run( 'SkinGetPoweredBy', [ &$text, $this ] );
+ return $text;
+ }
+
+ /**
+ * Get the timestamp of the latest revision, formatted in user language
+ *
+ * @return string
+ */
+ protected function lastModified() {
+ $timestamp = $this->getOutput()->getRevisionTimestamp();
+
+ # No cached timestamp, load it from the database
+ if ( $timestamp === null ) {
+ $timestamp = Revision::getTimestampFromId( $this->getTitle(), $this->getRevisionId() );
+ }
+
+ if ( $timestamp ) {
+ $d = $this->getLanguage()->userDate( $timestamp, $this->getUser() );
+ $t = $this->getLanguage()->userTime( $timestamp, $this->getUser() );
+ $s = ' ' . $this->msg( 'lastmodifiedat', $d, $t )->parse();
+ } else {
+ $s = '';
+ }
+
+ if ( wfGetLB()->getLaggedReplicaMode() ) {
+ $s .= ' <strong>' . $this->msg( 'laggedslavemode' )->parse() . '</strong>';
+ }
+
+ return $s;
+ }
+
+ /**
+ * @param string $align
+ * @return string
+ */
+ function logoText( $align = '' ) {
+ if ( $align != '' ) {
+ $a = " style='float: {$align};'";
+ } else {
+ $a = '';
+ }
+
+ $mp = $this->msg( 'mainpage' )->escaped();
+ $mptitle = Title::newMainPage();
+ $url = ( is_object( $mptitle ) ? htmlspecialchars( $mptitle->getLocalURL() ) : '' );
+
+ $logourl = $this->getLogo();
+ $s = "<a href='{$url}'><img{$a} src='{$logourl}' alt='[{$mp}]' /></a>";
+
+ return $s;
+ }
+
+ /**
+ * Renders a $wgFooterIcons icon according to the method's arguments
+ * @param array $icon The icon to build the html for, see $wgFooterIcons
+ * for the format of this array.
+ * @param bool|string $withImage Whether to use the icon's image or output
+ * a text-only footericon.
+ * @return string HTML
+ */
+ function makeFooterIcon( $icon, $withImage = 'withImage' ) {
+ if ( is_string( $icon ) ) {
+ $html = $icon;
+ } else { // Assuming array
+ $url = isset( $icon["url"] ) ? $icon["url"] : null;
+ unset( $icon["url"] );
+ if ( isset( $icon["src"] ) && $withImage === 'withImage' ) {
+ // do this the lazy way, just pass icon data as an attribute array
+ $html = Html::element( 'img', $icon );
+ } else {
+ $html = htmlspecialchars( $icon["alt"] );
+ }
+ if ( $url ) {
+ global $wgExternalLinkTarget;
+ $html = Html::rawElement( 'a',
+ [ "href" => $url, "target" => $wgExternalLinkTarget ],
+ $html );
+ }
+ }
+ return $html;
+ }
+
+ /**
+ * Gets the link to the wiki's main page.
+ * @return string
+ */
+ function mainPageLink() {
+ $s = Linker::linkKnown(
+ Title::newMainPage(),
+ $this->msg( 'mainpage' )->escaped()
+ );
+
+ return $s;
+ }
+
+ /**
+ * Returns an HTML link for use in the footer
+ * @param string $desc The i18n message key for the link text
+ * @param string $page The i18n message key for the page to link to
+ * @return string HTML anchor
+ */
+ public function footerLink( $desc, $page ) {
+ $title = $this->footerLinkTitle( $desc, $page );
+ if ( !$title ) {
+ return '';
+ }
+
+ return Linker::linkKnown(
+ $title,
+ $this->msg( $desc )->escaped()
+ );
+ }
+
+ /**
+ * @param string $desc
+ * @param string $page
+ * @return Title|null
+ */
+ private function footerLinkTitle( $desc, $page ) {
+ // If the link description has been set to "-" in the default language,
+ if ( $this->msg( $desc )->inContentLanguage()->isDisabled() ) {
+ // then it is disabled, for all languages.
+ return null;
+ }
+ // Otherwise, we display the link for the user, described in their
+ // language (which may or may not be the same as the default language),
+ // but we make the link target be the one site-wide page.
+ $title = Title::newFromText( $this->msg( $page )->inContentLanguage()->text() );
+
+ return $title ?: null;
+ }
+
+ /**
+ * Gets the link to the wiki's privacy policy page.
+ * @return string HTML
+ */
+ function privacyLink() {
+ return $this->footerLink( 'privacy', 'privacypage' );
+ }
+
+ /**
+ * Gets the link to the wiki's about page.
+ * @return string HTML
+ */
+ function aboutLink() {
+ return $this->footerLink( 'aboutsite', 'aboutpage' );
+ }
+
+ /**
+ * Gets the link to the wiki's general disclaimers page.
+ * @return string HTML
+ */
+ function disclaimerLink() {
+ return $this->footerLink( 'disclaimers', 'disclaimerpage' );
+ }
+
+ /**
+ * Return URL options for the 'edit page' link.
+ * This may include an 'oldid' specifier, if the current page view is such.
+ *
+ * @return array
+ * @private
+ */
+ function editUrlOptions() {
+ $options = [ 'action' => 'edit' ];
+
+ if ( !$this->isRevisionCurrent() ) {
+ $options['oldid'] = intval( $this->getRevisionId() );
+ }
+
+ return $options;
+ }
+
+ /**
+ * @param User|int $id
+ * @return bool
+ */
+ function showEmailUser( $id ) {
+ if ( $id instanceof User ) {
+ $targetUser = $id;
+ } else {
+ $targetUser = User::newFromId( $id );
+ }
+
+ # The sending user must have a confirmed email address and the receiving
+ # user must accept emails from the sender.
+ return $this->getUser()->canSendEmail()
+ && SpecialEmailUser::validateTarget( $targetUser, $this->getUser() ) === '';
+ }
+
+ /**
+ * Return a fully resolved style path url to images or styles stored in the current skins's folder.
+ * This method returns a url resolved using the configured skin style path
+ * and includes the style version inside of the url.
+ *
+ * Requires $stylename to be set, otherwise throws MWException.
+ *
+ * @param string $name The name or path of a skin resource file
+ * @return string The fully resolved style path url including styleversion
+ * @throws MWException
+ */
+ function getSkinStylePath( $name ) {
+ global $wgStylePath, $wgStyleVersion;
+
+ if ( $this->stylename === null ) {
+ $class = static::class;
+ throw new MWException( "$class::\$stylename must be set to use getSkinStylePath()" );
+ }
+
+ return "$wgStylePath/{$this->stylename}/$name?$wgStyleVersion";
+ }
+
+ /* these are used extensively in SkinTemplate, but also some other places */
+
+ /**
+ * @param string $urlaction
+ * @return string
+ */
+ static function makeMainPageUrl( $urlaction = '' ) {
+ $title = Title::newMainPage();
+ self::checkTitle( $title, '' );
+
+ return $title->getLocalURL( $urlaction );
+ }
+
+ /**
+ * Make a URL for a Special Page using the given query and protocol.
+ *
+ * If $proto is set to null, make a local URL. Otherwise, make a full
+ * URL with the protocol specified.
+ *
+ * @param string $name Name of the Special page
+ * @param string $urlaction Query to append
+ * @param string|null $proto Protocol to use or null for a local URL
+ * @return string
+ */
+ static function makeSpecialUrl( $name, $urlaction = '', $proto = null ) {
+ $title = SpecialPage::getSafeTitleFor( $name );
+ if ( is_null( $proto ) ) {
+ return $title->getLocalURL( $urlaction );
+ } else {
+ return $title->getFullURL( $urlaction, false, $proto );
+ }
+ }
+
+ /**
+ * @param string $name
+ * @param string $subpage
+ * @param string $urlaction
+ * @return string
+ */
+ static function makeSpecialUrlSubpage( $name, $subpage, $urlaction = '' ) {
+ $title = SpecialPage::getSafeTitleFor( $name, $subpage );
+ return $title->getLocalURL( $urlaction );
+ }
+
+ /**
+ * @param string $name
+ * @param string $urlaction
+ * @return string
+ */
+ static function makeI18nUrl( $name, $urlaction = '' ) {
+ $title = Title::newFromText( wfMessage( $name )->inContentLanguage()->text() );
+ self::checkTitle( $title, $name );
+ return $title->getLocalURL( $urlaction );
+ }
+
+ /**
+ * @param string $name
+ * @param string $urlaction
+ * @return string
+ */
+ static function makeUrl( $name, $urlaction = '' ) {
+ $title = Title::newFromText( $name );
+ self::checkTitle( $title, $name );
+
+ return $title->getLocalURL( $urlaction );
+ }
+
+ /**
+ * If url string starts with http, consider as external URL, else
+ * internal
+ * @param string $name
+ * @return string URL
+ */
+ static function makeInternalOrExternalUrl( $name ) {
+ if ( preg_match( '/^(?i:' . wfUrlProtocols() . ')/', $name ) ) {
+ return $name;
+ } else {
+ return self::makeUrl( $name );
+ }
+ }
+
+ /**
+ * this can be passed the NS number as defined in Language.php
+ * @param string $name
+ * @param string $urlaction
+ * @param int $namespace
+ * @return string
+ */
+ static function makeNSUrl( $name, $urlaction = '', $namespace = NS_MAIN ) {
+ $title = Title::makeTitleSafe( $namespace, $name );
+ self::checkTitle( $title, $name );
+
+ return $title->getLocalURL( $urlaction );
+ }
+
+ /**
+ * these return an array with the 'href' and boolean 'exists'
+ * @param string $name
+ * @param string $urlaction
+ * @return array
+ */
+ static function makeUrlDetails( $name, $urlaction = '' ) {
+ $title = Title::newFromText( $name );
+ self::checkTitle( $title, $name );
+
+ return [
+ 'href' => $title->getLocalURL( $urlaction ),
+ 'exists' => $title->isKnown(),
+ ];
+ }
+
+ /**
+ * Make URL details where the article exists (or at least it's convenient to think so)
+ * @param string $name Article name
+ * @param string $urlaction
+ * @return array
+ */
+ static function makeKnownUrlDetails( $name, $urlaction = '' ) {
+ $title = Title::newFromText( $name );
+ self::checkTitle( $title, $name );
+
+ return [
+ 'href' => $title->getLocalURL( $urlaction ),
+ 'exists' => true
+ ];
+ }
+
+ /**
+ * make sure we have some title to operate on
+ *
+ * @param Title &$title
+ * @param string $name
+ */
+ static function checkTitle( &$title, $name ) {
+ if ( !is_object( $title ) ) {
+ $title = Title::newFromText( $name );
+ if ( !is_object( $title ) ) {
+ $title = Title::newFromText( '--error: link target missing--' );
+ }
+ }
+ }
+
+ /**
+ * Build an array that represents the sidebar(s), the navigation bar among them.
+ *
+ * BaseTemplate::getSidebar can be used to simplify the format and id generation in new skins.
+ *
+ * The format of the returned array is [ heading => content, ... ], where:
+ * - heading is the heading of a navigation portlet. It is either:
+ * - magic string to be handled by the skins ('SEARCH' / 'LANGUAGES' / 'TOOLBOX' / ...)
+ * - a message name (e.g. 'navigation'), the message should be HTML-escaped by the skin
+ * - plain text, which should be HTML-escaped by the skin
+ * - content is the contents of the portlet. It is either:
+ * - HTML text (<ul><li>...</li>...</ul>)
+ * - array of link data in a format accepted by BaseTemplate::makeListItem()
+ * - (for a magic string as a key, any value)
+ *
+ * Note that extensions can control the sidebar contents using the SkinBuildSidebar hook
+ * and can technically insert anything in here; skin creators are expected to handle
+ * values described above.
+ *
+ * @return array
+ */
+ function buildSidebar() {
+ global $wgEnableSidebarCache, $wgSidebarCacheExpiry;
+
+ $callback = function () {
+ $bar = [];
+ $this->addToSidebar( $bar, 'sidebar' );
+ Hooks::run( 'SkinBuildSidebar', [ $this, &$bar ] );
+
+ return $bar;
+ };
+
+ if ( $wgEnableSidebarCache ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $sidebar = $cache->getWithSetCallback(
+ $cache->makeKey( 'sidebar', $this->getLanguage()->getCode() ),
+ MessageCache::singleton()->isDisabled()
+ ? $cache::TTL_UNCACHEABLE // bug T133069
+ : $wgSidebarCacheExpiry,
+ $callback,
+ [ 'lockTSE' => 30 ]
+ );
+ } else {
+ $sidebar = $callback();
+ }
+
+ // Apply post-processing to the cached value
+ Hooks::run( 'SidebarBeforeOutput', [ $this, &$sidebar ] );
+
+ return $sidebar;
+ }
+
+ /**
+ * Add content from a sidebar system message
+ * Currently only used for MediaWiki:Sidebar (but may be used by Extensions)
+ *
+ * This is just a wrapper around addToSidebarPlain() for backwards compatibility
+ *
+ * @param array &$bar
+ * @param string $message
+ */
+ public function addToSidebar( &$bar, $message ) {
+ $this->addToSidebarPlain( $bar, wfMessage( $message )->inContentLanguage()->plain() );
+ }
+
+ /**
+ * Add content from plain text
+ * @since 1.17
+ * @param array &$bar
+ * @param string $text
+ * @return array
+ */
+ function addToSidebarPlain( &$bar, $text ) {
+ $lines = explode( "\n", $text );
+
+ $heading = '';
+ $messageTitle = $this->getConfig()->get( 'EnableSidebarCache' )
+ ? Title::newMainPage() : $this->getTitle();
+
+ foreach ( $lines as $line ) {
+ if ( strpos( $line, '*' ) !== 0 ) {
+ continue;
+ }
+ $line = rtrim( $line, "\r" ); // for Windows compat
+
+ if ( strpos( $line, '**' ) !== 0 ) {
+ $heading = trim( $line, '* ' );
+ if ( !array_key_exists( $heading, $bar ) ) {
+ $bar[$heading] = [];
+ }
+ } else {
+ $line = trim( $line, '* ' );
+
+ if ( strpos( $line, '|' ) !== false ) { // sanity check
+ $line = MessageCache::singleton()->transform( $line, false, null, $messageTitle );
+ $line = array_map( 'trim', explode( '|', $line, 2 ) );
+ if ( count( $line ) !== 2 ) {
+ // Second sanity check, could be hit by people doing
+ // funky stuff with parserfuncs... (T35321)
+ continue;
+ }
+
+ $extraAttribs = [];
+
+ $msgLink = $this->msg( $line[0] )->title( $messageTitle )->inContentLanguage();
+ if ( $msgLink->exists() ) {
+ $link = $msgLink->text();
+ if ( $link == '-' ) {
+ continue;
+ }
+ } else {
+ $link = $line[0];
+ }
+ $msgText = $this->msg( $line[1] )->title( $messageTitle );
+ if ( $msgText->exists() ) {
+ $text = $msgText->text();
+ } else {
+ $text = $line[1];
+ }
+
+ if ( preg_match( '/^(?i:' . wfUrlProtocols() . ')/', $link ) ) {
+ $href = $link;
+
+ // Parser::getExternalLinkAttribs won't work here because of the Namespace things
+ global $wgNoFollowLinks, $wgNoFollowDomainExceptions;
+ if ( $wgNoFollowLinks && !wfMatchesDomainList( $href, $wgNoFollowDomainExceptions ) ) {
+ $extraAttribs['rel'] = 'nofollow';
+ }
+
+ global $wgExternalLinkTarget;
+ if ( $wgExternalLinkTarget ) {
+ $extraAttribs['target'] = $wgExternalLinkTarget;
+ }
+ } else {
+ $title = Title::newFromText( $link );
+
+ if ( $title ) {
+ $title = $title->fixSpecialName();
+ $href = $title->getLinkURL();
+ } else {
+ $href = 'INVALID-TITLE';
+ }
+ }
+
+ $bar[$heading][] = array_merge( [
+ 'text' => $text,
+ 'href' => $href,
+ 'id' => Sanitizer::escapeIdForAttribute( 'n-' . strtr( $line[1], ' ', '-' ) ),
+ 'active' => false,
+ ], $extraAttribs );
+ } else {
+ continue;
+ }
+ }
+ }
+
+ return $bar;
+ }
+
+ /**
+ * Gets new talk page messages for the current user and returns an
+ * appropriate alert message (or an empty string if there are no messages)
+ * @return string
+ */
+ function getNewtalks() {
+ $newMessagesAlert = '';
+ $user = $this->getUser();
+ $newtalks = $user->getNewMessageLinks();
+ $out = $this->getOutput();
+
+ // Allow extensions to disable or modify the new messages alert
+ if ( !Hooks::run( 'GetNewMessagesAlert', [ &$newMessagesAlert, $newtalks, $user, $out ] ) ) {
+ return '';
+ }
+ if ( $newMessagesAlert ) {
+ return $newMessagesAlert;
+ }
+
+ if ( count( $newtalks ) == 1 && $newtalks[0]['wiki'] === wfWikiID() ) {
+ $uTalkTitle = $user->getTalkPage();
+ $lastSeenRev = isset( $newtalks[0]['rev'] ) ? $newtalks[0]['rev'] : null;
+ $nofAuthors = 0;
+ if ( $lastSeenRev !== null ) {
+ $plural = true; // Default if we have a last seen revision: if unknown, use plural
+ $latestRev = Revision::newFromTitle( $uTalkTitle, false, Revision::READ_NORMAL );
+ if ( $latestRev !== null ) {
+ // Singular if only 1 unseen revision, plural if several unseen revisions.
+ $plural = $latestRev->getParentId() !== $lastSeenRev->getId();
+ $nofAuthors = $uTalkTitle->countAuthorsBetween(
+ $lastSeenRev, $latestRev, 10, 'include_new' );
+ }
+ } else {
+ // Singular if no revision -> diff link will show latest change only in any case
+ $plural = false;
+ }
+ $plural = $plural ? 999 : 1;
+ // 999 signifies "more than one revision". We don't know how many, and even if we did,
+ // the number of revisions or authors is not necessarily the same as the number of
+ // "messages".
+ $newMessagesLink = Linker::linkKnown(
+ $uTalkTitle,
+ $this->msg( 'newmessageslinkplural' )->params( $plural )->escaped(),
+ [],
+ [ 'redirect' => 'no' ]
+ );
+
+ $newMessagesDiffLink = Linker::linkKnown(
+ $uTalkTitle,
+ $this->msg( 'newmessagesdifflinkplural' )->params( $plural )->escaped(),
+ [],
+ $lastSeenRev !== null
+ ? [ 'oldid' => $lastSeenRev->getId(), 'diff' => 'cur' ]
+ : [ 'diff' => 'cur' ]
+ );
+
+ if ( $nofAuthors >= 1 && $nofAuthors <= 10 ) {
+ $newMessagesAlert = $this->msg(
+ 'youhavenewmessagesfromusers',
+ $newMessagesLink,
+ $newMessagesDiffLink
+ )->numParams( $nofAuthors, $plural );
+ } else {
+ // $nofAuthors === 11 signifies "11 or more" ("more than 10")
+ $newMessagesAlert = $this->msg(
+ $nofAuthors > 10 ? 'youhavenewmessagesmanyusers' : 'youhavenewmessages',
+ $newMessagesLink,
+ $newMessagesDiffLink
+ )->numParams( $plural );
+ }
+ $newMessagesAlert = $newMessagesAlert->text();
+ # Disable CDN cache
+ $out->setCdnMaxage( 0 );
+ } elseif ( count( $newtalks ) ) {
+ $sep = $this->msg( 'newtalkseparator' )->escaped();
+ $msgs = [];
+
+ foreach ( $newtalks as $newtalk ) {
+ $msgs[] = Xml::element(
+ 'a',
+ [ 'href' => $newtalk['link'] ], $newtalk['wiki']
+ );
+ }
+ $parts = implode( $sep, $msgs );
+ $newMessagesAlert = $this->msg( 'youhavenewmessagesmulti' )->rawParams( $parts )->escaped();
+ $out->setCdnMaxage( 0 );
+ }
+
+ return $newMessagesAlert;
+ }
+
+ /**
+ * Get a cached notice
+ *
+ * @param string $name Message name, or 'default' for $wgSiteNotice
+ * @return string|bool HTML fragment, or false to indicate that the caller
+ * should fall back to the next notice in its sequence
+ */
+ private function getCachedNotice( $name ) {
+ global $wgRenderHashAppend, $wgContLang;
+
+ $needParse = false;
+
+ if ( $name === 'default' ) {
+ // special case
+ global $wgSiteNotice;
+ $notice = $wgSiteNotice;
+ if ( empty( $notice ) ) {
+ return false;
+ }
+ } else {
+ $msg = $this->msg( $name )->inContentLanguage();
+ if ( $msg->isBlank() ) {
+ return '';
+ } elseif ( $msg->isDisabled() ) {
+ return false;
+ }
+ $notice = $msg->plain();
+ }
+
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $parsed = $cache->getWithSetCallback(
+ // Use the extra hash appender to let eg SSL variants separately cache
+ // Key is verified with md5 hash of unparsed wikitext
+ $cache->makeKey( $name, $wgRenderHashAppend, md5( $notice ) ),
+ // TTL in seconds
+ 600,
+ function () use ( $notice ) {
+ return $this->getOutput()->parse( $notice );
+ }
+ );
+
+ return Html::rawElement(
+ 'div',
+ [
+ 'id' => 'localNotice',
+ 'lang' => $wgContLang->getHtmlCode(),
+ 'dir' => $wgContLang->getDir()
+ ],
+ $parsed
+ );
+ }
+
+ /**
+ * Get the site notice
+ *
+ * @return string HTML fragment
+ */
+ function getSiteNotice() {
+ $siteNotice = '';
+
+ if ( Hooks::run( 'SiteNoticeBefore', [ &$siteNotice, $this ] ) ) {
+ if ( is_object( $this->getUser() ) && $this->getUser()->isLoggedIn() ) {
+ $siteNotice = $this->getCachedNotice( 'sitenotice' );
+ } else {
+ $anonNotice = $this->getCachedNotice( 'anonnotice' );
+ if ( $anonNotice === false ) {
+ $siteNotice = $this->getCachedNotice( 'sitenotice' );
+ } else {
+ $siteNotice = $anonNotice;
+ }
+ }
+ if ( $siteNotice === false ) {
+ $siteNotice = $this->getCachedNotice( 'default' );
+ }
+ }
+
+ Hooks::run( 'SiteNoticeAfter', [ &$siteNotice, $this ] );
+ return $siteNotice;
+ }
+
+ /**
+ * Create a section edit link. This supersedes editSectionLink() and
+ * editSectionLinkForOther().
+ *
+ * @param Title $nt The title being linked to (may not be the same as
+ * the current page, if the section is included from a template)
+ * @param string $section The designation of the section being pointed to,
+ * to be included in the link, like "&section=$section"
+ * @param string $tooltip The tooltip to use for the link: will be escaped
+ * and wrapped in the 'editsectionhint' message
+ * @param string $lang Language code
+ * @return string HTML to use for edit link
+ */
+ public function doEditSectionLink( Title $nt, $section, $tooltip = null, $lang = false ) {
+ // HTML generated here should probably have userlangattributes
+ // added to it for LTR text on RTL pages
+
+ $lang = wfGetLangObj( $lang );
+
+ $attribs = [];
+ if ( !is_null( $tooltip ) ) {
+ $attribs['title'] = wfMessage( 'editsectionhint' )->rawParams( $tooltip )
+ ->inLanguage( $lang )->text();
+ }
+
+ $links = [
+ 'editsection' => [
+ 'text' => wfMessage( 'editsection' )->inLanguage( $lang )->escaped(),
+ 'targetTitle' => $nt,
+ 'attribs' => $attribs,
+ 'query' => [ 'action' => 'edit', 'section' => $section ],
+ 'options' => [ 'noclasses', 'known' ]
+ ]
+ ];
+
+ Hooks::run( 'SkinEditSectionLinks', [ $this, $nt, $section, $tooltip, &$links, $lang ] );
+
+ $result = '<span class="mw-editsection"><span class="mw-editsection-bracket">[</span>';
+
+ $linksHtml = [];
+ foreach ( $links as $k => $linkDetails ) {
+ $linksHtml[] = Linker::link(
+ $linkDetails['targetTitle'],
+ $linkDetails['text'],
+ $linkDetails['attribs'],
+ $linkDetails['query'],
+ $linkDetails['options']
+ );
+ }
+
+ $result .= implode(
+ '<span class="mw-editsection-divider">'
+ . wfMessage( 'pipe-separator' )->inLanguage( $lang )->escaped()
+ . '</span>',
+ $linksHtml
+ );
+
+ $result .= '<span class="mw-editsection-bracket">]</span></span>';
+ // Deprecated, use SkinEditSectionLinks hook instead
+ Hooks::run(
+ 'DoEditSectionLink',
+ [ $this, $nt, $section, $tooltip, &$result, $lang ],
+ '1.25'
+ );
+ return $result;
+ }
+
+}
diff --git a/www/wiki/includes/skins/SkinApi.php b/www/wiki/includes/skins/SkinApi.php
new file mode 100644
index 00000000..6679098f
--- /dev/null
+++ b/www/wiki/includes/skins/SkinApi.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Extremely basic "skin" for API output, which needs to output a page without
+ * the usual skin elements but still using CSS, JS, and such via OutputPage and
+ * ResourceLoader.
+ *
+ * Created on Sep 08, 2014
+ *
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * SkinTemplate class for API output
+ * @since 1.25
+ */
+class SkinApi extends SkinTemplate {
+ public $skinname = 'apioutput';
+ public $template = 'SkinApiTemplate';
+
+ public function setupSkinUserCss( OutputPage $out ) {
+ parent::setupSkinUserCss( $out );
+ $out->addModuleStyles( 'mediawiki.skinning.interface' );
+ }
+
+ // Skip work and hooks for stuff we don't use
+
+ function buildSidebar() {
+ return [];
+ }
+
+ function getNewtalks() {
+ return '';
+ }
+
+ function getSiteNotice() {
+ return '';
+ }
+
+ public function getLanguages() {
+ return [];
+ }
+
+ protected function buildPersonalUrls() {
+ return [];
+ }
+
+ protected function buildContentNavigationUrls() {
+ return [];
+ }
+
+ protected function buildNavUrls() {
+ return [];
+ }
+}
diff --git a/www/wiki/includes/skins/SkinApiTemplate.php b/www/wiki/includes/skins/SkinApiTemplate.php
new file mode 100644
index 00000000..d3e453a6
--- /dev/null
+++ b/www/wiki/includes/skins/SkinApiTemplate.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * Extremely basic "skin" for API output, which needs to output a page without
+ * the usual skin elements but still using CSS, JS, and such via OutputPage and
+ * ResourceLoader.
+ *
+ * Created on Sep 08, 2014
+ *
+ * Copyright © 2014 Wikimedia Foundation and contributors
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * BaseTemplate class for the 'apioutput' skin
+ * @since 1.25
+ */
+class SkinApiTemplate extends BaseTemplate {
+
+ public function execute() {
+ $this->html( 'headelement' ) ?>
+
+ <div class="mw-body" role="main">
+ <h1 class="firstHeading"><?php $this->html( 'title' ) ?></h1>
+ <div class="mw-body-content">
+ <?php $this->html( 'bodytext' ) ?>
+ </div>
+ </div>
+
+ <?php $this->printTrail() ?>
+ </body></html>
+<?php
+ }
+
+ // Skip work and hooks for stuff we don't use
+
+ function getToolbox() {
+ return [];
+ }
+
+ function getPersonalTools() {
+ return [];
+ }
+
+ function getSidebar( $options = [] ) {
+ return [];
+ }
+}
diff --git a/www/wiki/includes/skins/SkinException.php b/www/wiki/includes/skins/SkinException.php
new file mode 100644
index 00000000..31ff1437
--- /dev/null
+++ b/www/wiki/includes/skins/SkinException.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Exceptions for skin-related failures
+ *
+ * @since 1.24
+ */
+class SkinException extends MWException {
+}
diff --git a/www/wiki/includes/skins/SkinFactory.php b/www/wiki/includes/skins/SkinFactory.php
new file mode 100644
index 00000000..cc993aaf
--- /dev/null
+++ b/www/wiki/includes/skins/SkinFactory.php
@@ -0,0 +1,103 @@
+<?php
+
+/**
+ * Copyright 2014
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Factory class to create Skin objects
+ *
+ * @since 1.24
+ */
+class SkinFactory {
+
+ /**
+ * Map of name => callback
+ * @var array
+ */
+ private $factoryFunctions = [];
+ /**
+ * Map of name => fallback human-readable name, used when the 'skinname-<skin>' message is not
+ * available
+ *
+ * @var array
+ */
+ private $displayNames = [];
+
+ /**
+ * @deprecated in 1.27
+ * @return SkinFactory
+ */
+ public static function getDefaultInstance() {
+ return MediaWikiServices::getInstance()->getSkinFactory();
+ }
+
+ /**
+ * Register a new Skin factory function.
+ *
+ * Will override if it's already registered.
+ *
+ * @param string $name Internal skin name. Should be all-lowercase (technically doesn't have
+ * to be, but doing so would change the case of i18n message keys).
+ * @param string $displayName For backwards-compatibility with old skin loading system. This is
+ * the text used as skin's human-readable name when the 'skinname-<skin>' message is not
+ * available. It should be the same as the skin name provided in $wgExtensionCredits.
+ * @param callable $callback Callback that takes the skin name as an argument
+ * @throws InvalidArgumentException If an invalid callback is provided
+ */
+ public function register( $name, $displayName, $callback ) {
+ if ( !is_callable( $callback ) ) {
+ throw new InvalidArgumentException( 'Invalid callback provided' );
+ }
+ $this->factoryFunctions[$name] = $callback;
+ $this->displayNames[$name] = $displayName;
+ }
+
+ /**
+ * Returns an associative array of:
+ * skin name => human readable name
+ *
+ * @return array
+ */
+ public function getSkinNames() {
+ return $this->displayNames;
+ }
+
+ /**
+ * Create a given Skin using the registered callback for $name.
+ * @param string $name Name of the skin you want
+ * @throws SkinException If a factory function isn't registered for $name
+ * @throws UnexpectedValueException If the factory function returns a non-Skin object
+ * @return Skin
+ */
+ public function makeSkin( $name ) {
+ if ( !isset( $this->factoryFunctions[$name] ) ) {
+ throw new SkinException( "No registered builder available for $name." );
+ }
+ $skin = call_user_func( $this->factoryFunctions[$name], $name );
+ if ( $skin instanceof Skin ) {
+ return $skin;
+ } else {
+ throw new UnexpectedValueException( "The builder for $name returned a non-Skin object." );
+ }
+ }
+}
diff --git a/www/wiki/includes/skins/SkinFallback.php b/www/wiki/includes/skins/SkinFallback.php
new file mode 100644
index 00000000..96ff2285
--- /dev/null
+++ b/www/wiki/includes/skins/SkinFallback.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Skin file for the fallback skin.
+ *
+ * The structure is copied from the example skin (mediawiki/skins/Example).
+ *
+ * @since 1.24
+ * @file
+ */
+
+/**
+ * SkinTemplate class for the fallback skin
+ */
+class SkinFallback extends SkinTemplate {
+
+ public $skinname = 'fallback';
+ public $template = 'SkinFallbackTemplate';
+
+ /**
+ * Add CSS via ResourceLoader
+ *
+ * @param OutputPage $out
+ */
+ public function setupSkinUserCss( OutputPage $out ) {
+ parent::setupSkinUserCss( $out );
+ $out->addModuleStyles( 'mediawiki.skinning.interface' );
+ }
+
+ /**
+ * @param OutputPage $out
+ */
+ public function initPage( OutputPage $out ) {
+ parent::initPage( $out );
+ $out->enableClientCache( false );
+ }
+}
diff --git a/www/wiki/includes/skins/SkinFallbackTemplate.php b/www/wiki/includes/skins/SkinFallbackTemplate.php
new file mode 100644
index 00000000..ee8d8417
--- /dev/null
+++ b/www/wiki/includes/skins/SkinFallbackTemplate.php
@@ -0,0 +1,126 @@
+<?php
+
+/**
+ * Skin template for the fallback skin.
+ *
+ * The structure is copied from the example skin (mediawiki/skins/Example).
+ *
+ * @since 1.24
+ * @file
+ */
+
+/**
+ * BaseTemplate class for the fallback skin
+ */
+class SkinFallbackTemplate extends BaseTemplate {
+ /**
+ * @return array
+ */
+ private function findInstalledSkins() {
+ $styleDirectory = $this->config->get( 'StyleDirectory' );
+ // Get all subdirectories which might contains skins
+ $possibleSkins = scandir( $styleDirectory );
+ $possibleSkins = array_filter( $possibleSkins, function ( $maybeDir ) use ( $styleDirectory ) {
+ return $maybeDir !== '.' && $maybeDir !== '..' && is_dir( "$styleDirectory/$maybeDir" );
+ } );
+
+ // Filter out skins that aren't installed
+ $possibleSkins = array_filter( $possibleSkins, function ( $skinDir ) use ( $styleDirectory ) {
+ return
+ is_file( "$styleDirectory/$skinDir/skin.json" )
+ || is_file( "$styleDirectory/$skinDir/$skinDir.php" );
+ } );
+
+ return $possibleSkins;
+ }
+
+ /**
+ * Inform the user why they are seeing this skin.
+ *
+ * @return string
+ */
+ private function buildHelpfulInformationMessage() {
+ $defaultSkin = $this->config->get( 'DefaultSkin' );
+ $installedSkins = $this->findInstalledSkins();
+ $enabledSkins = SkinFactory::getDefaultInstance()->getSkinNames();
+ $enabledSkins = array_change_key_case( $enabledSkins, CASE_LOWER );
+
+ if ( $installedSkins ) {
+ $skinsInstalledText = [];
+ $skinsInstalledSnippet = [];
+
+ foreach ( $installedSkins as $skin ) {
+ $normalizedKey = strtolower( $skin );
+ $isEnabled = array_key_exists( $normalizedKey, $enabledSkins );
+ if ( $isEnabled ) {
+ $skinsInstalledText[] = $this->getMsg( 'default-skin-not-found-row-enabled' )
+ ->params( $normalizedKey, $skin )->plain();
+ } else {
+ $skinsInstalledText[] = $this->getMsg( 'default-skin-not-found-row-disabled' )
+ ->params( $normalizedKey, $skin )->plain();
+ $skinsInstalledSnippet[] = $this->getSnippetForSkin( $skin );
+ }
+ }
+
+ return $this->getMsg( 'default-skin-not-found' )->params(
+ $defaultSkin,
+ implode( "\n", $skinsInstalledText ),
+ implode( "\n", $skinsInstalledSnippet ) )->numParams(
+ count( $skinsInstalledText ),
+ count( $skinsInstalledSnippet )
+ )->parseAsBlock();
+ } else {
+ return $this->getMsg( 'default-skin-not-found-no-skins' )->params(
+ $defaultSkin
+ )->parseAsBlock();
+ }
+ }
+
+ /**
+ * Get the appropriate LocalSettings.php snippet to enable the given skin
+ *
+ * @param string $skin
+ * @return string
+ */
+ private function getSnippetForSkin( $skin ) {
+ global $IP;
+ if ( file_exists( "$IP/skins/$skin/skin.json" ) ) {
+ return "wfLoadSkin( '$skin' );";
+ } else {
+ return "require_once \"\$IP/skins/$skin/$skin.php\";";
+ }
+ }
+
+ /**
+ * Outputs the entire contents of the page. No navigation (other than search box), just the big
+ * warning message and page content.
+ */
+ public function execute() {
+ $this->html( 'headelement' ) ?>
+
+ <div class="warningbox">
+ <?php echo $this->buildHelpfulInformationMessage() ?>
+ </div>
+
+ <form action="<?php $this->text( 'wgScript' ) ?>">
+ <input type="hidden" name="title" value="<?php $this->text( 'searchtitle' ) ?>" />
+ <h3><label for="searchInput"><?php $this->msg( 'search' ) ?></label></h3>
+ <?php echo $this->makeSearchInput( [ "id" => "searchInput" ] ) ?>
+ <?php echo $this->makeSearchButton( 'go' ) ?>
+ </form>
+
+ <div class="mw-body" role="main">
+ <h1 class="firstHeading"><?php $this->html( 'title' ) ?></h1>
+
+ <div class="mw-body-content">
+ <?php $this->html( 'bodytext' ) ?>
+ <?php $this->html( 'catlinks' ) ?>
+ </div>
+ </div>
+
+ <?php $this->printTrail() ?>
+ </body></html>
+
+ <?php
+ }
+}
diff --git a/www/wiki/includes/skins/SkinTemplate.php b/www/wiki/includes/skins/SkinTemplate.php
new file mode 100644
index 00000000..4fcc8657
--- /dev/null
+++ b/www/wiki/includes/skins/SkinTemplate.php
@@ -0,0 +1,1359 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Base class for template-based skins.
+ *
+ * Template-filler skin base class
+ * Formerly generic PHPTal (http://phptal.sourceforge.net/) skin
+ * Based on Brion's smarty skin
+ * @copyright Copyright © Gabriel Wicke -- http://www.aulinx.de/
+ *
+ * @todo Needs some serious refactoring into functions that correspond
+ * to the computations individual esi snippets need. Most importantly no body
+ * parsing for most of those of course.
+ *
+ * @ingroup Skins
+ */
+class SkinTemplate extends Skin {
+ /**
+ * @var string Name of our skin, it probably needs to be all lower case.
+ * Child classes should override the default.
+ */
+ public $skinname = 'monobook';
+
+ /**
+ * @var string For QuickTemplate, the name of the subclass which will
+ * actually fill the template. Child classes should override the default.
+ */
+ public $template = 'QuickTemplate';
+
+ public $thispage;
+ public $titletxt;
+ public $userpage;
+ public $thisquery;
+ public $loggedin;
+ public $username;
+ public $userpageUrlDetails;
+
+ /**
+ * Add specific styles for this skin
+ *
+ * @param OutputPage $out
+ */
+ public function setupSkinUserCss( OutputPage $out ) {
+ $moduleStyles = [
+ 'mediawiki.legacy.shared',
+ 'mediawiki.legacy.commonPrint',
+ 'mediawiki.sectionAnchor'
+ ];
+ if ( $out->isSyndicated() ) {
+ $moduleStyles[] = 'mediawiki.feedlink';
+ }
+
+ // Deprecated since 1.26: Unconditional loading of mediawiki.ui.button
+ // on every page is deprecated. Express a dependency instead.
+ if ( strpos( $out->getHTML(), 'mw-ui-button' ) !== false ) {
+ $moduleStyles[] = 'mediawiki.ui.button';
+ }
+
+ $out->addModuleStyles( $moduleStyles );
+ }
+
+ /**
+ * Create the template engine object; we feed it a bunch of data
+ * and eventually it spits out some HTML. Should have interface
+ * roughly equivalent to PHPTAL 0.7.
+ *
+ * @param string $classname
+ * @param bool|string $repository Subdirectory where we keep template files
+ * @param bool|string $cache_dir
+ * @return QuickTemplate
+ * @private
+ */
+ function setupTemplate( $classname, $repository = false, $cache_dir = false ) {
+ return new $classname( $this->getConfig() );
+ }
+
+ /**
+ * Generates array of language links for the current page
+ *
+ * @return array
+ */
+ public function getLanguages() {
+ global $wgHideInterlanguageLinks;
+ if ( $wgHideInterlanguageLinks ) {
+ return [];
+ }
+
+ $userLang = $this->getLanguage();
+ $languageLinks = [];
+
+ foreach ( $this->getOutput()->getLanguageLinks() as $languageLinkText ) {
+ $class = 'interlanguage-link interwiki-' . explode( ':', $languageLinkText, 2 )[0];
+
+ $languageLinkTitle = Title::newFromText( $languageLinkText );
+ if ( $languageLinkTitle ) {
+ $ilInterwikiCode = $languageLinkTitle->getInterwiki();
+ $ilLangName = Language::fetchLanguageName( $ilInterwikiCode );
+
+ if ( strval( $ilLangName ) === '' ) {
+ $ilDisplayTextMsg = wfMessage( "interlanguage-link-$ilInterwikiCode" );
+ if ( !$ilDisplayTextMsg->isDisabled() ) {
+ // Use custom MW message for the display text
+ $ilLangName = $ilDisplayTextMsg->text();
+ } else {
+ // Last resort: fallback to the language link target
+ $ilLangName = $languageLinkText;
+ }
+ } else {
+ // Use the language autonym as display text
+ $ilLangName = $this->formatLanguageName( $ilLangName );
+ }
+
+ // CLDR extension or similar is required to localize the language name;
+ // otherwise we'll end up with the autonym again.
+ $ilLangLocalName = Language::fetchLanguageName(
+ $ilInterwikiCode,
+ $userLang->getCode()
+ );
+
+ $languageLinkTitleText = $languageLinkTitle->getText();
+ if ( $ilLangLocalName === '' ) {
+ $ilFriendlySiteName = wfMessage( "interlanguage-link-sitename-$ilInterwikiCode" );
+ if ( !$ilFriendlySiteName->isDisabled() ) {
+ if ( $languageLinkTitleText === '' ) {
+ $ilTitle = wfMessage(
+ 'interlanguage-link-title-nonlangonly',
+ $ilFriendlySiteName->text()
+ )->text();
+ } else {
+ $ilTitle = wfMessage(
+ 'interlanguage-link-title-nonlang',
+ $languageLinkTitleText,
+ $ilFriendlySiteName->text()
+ )->text();
+ }
+ } else {
+ // we have nothing friendly to put in the title, so fall back to
+ // displaying the interlanguage link itself in the title text
+ // (similar to what is done in page content)
+ $ilTitle = $languageLinkTitle->getInterwiki() .
+ ":$languageLinkTitleText";
+ }
+ } elseif ( $languageLinkTitleText === '' ) {
+ $ilTitle = wfMessage(
+ 'interlanguage-link-title-langonly',
+ $ilLangLocalName
+ )->text();
+ } else {
+ $ilTitle = wfMessage(
+ 'interlanguage-link-title',
+ $languageLinkTitleText,
+ $ilLangLocalName
+ )->text();
+ }
+
+ $ilInterwikiCodeBCP47 = wfBCP47( $ilInterwikiCode );
+ $languageLink = [
+ 'href' => $languageLinkTitle->getFullURL(),
+ 'text' => $ilLangName,
+ 'title' => $ilTitle,
+ 'class' => $class,
+ 'link-class' => 'interlanguage-link-target',
+ 'lang' => $ilInterwikiCodeBCP47,
+ 'hreflang' => $ilInterwikiCodeBCP47,
+ ];
+ Hooks::run(
+ 'SkinTemplateGetLanguageLink',
+ [ &$languageLink, $languageLinkTitle, $this->getTitle(), $this->getOutput() ]
+ );
+ $languageLinks[] = $languageLink;
+ }
+ }
+
+ return $languageLinks;
+ }
+
+ protected function setupTemplateForOutput() {
+ $request = $this->getRequest();
+ $user = $this->getUser();
+ $title = $this->getTitle();
+
+ $tpl = $this->setupTemplate( $this->template, 'skins' );
+
+ $this->thispage = $title->getPrefixedDBkey();
+ $this->titletxt = $title->getPrefixedText();
+ $this->userpage = $user->getUserPage()->getPrefixedText();
+ $query = [];
+ if ( !$request->wasPosted() ) {
+ $query = $request->getValues();
+ unset( $query['title'] );
+ unset( $query['returnto'] );
+ unset( $query['returntoquery'] );
+ }
+ $this->thisquery = wfArrayToCgi( $query );
+ $this->loggedin = $user->isLoggedIn();
+ $this->username = $user->getName();
+
+ if ( $this->loggedin ) {
+ $this->userpageUrlDetails = self::makeUrlDetails( $this->userpage );
+ } else {
+ # This won't be used in the standard skins, but we define it to preserve the interface
+ # To save time, we check for existence
+ $this->userpageUrlDetails = self::makeKnownUrlDetails( $this->userpage );
+ }
+
+ return $tpl;
+ }
+
+ /**
+ * initialize various variables and generate the template
+ *
+ * @param OutputPage $out
+ */
+ function outputPage( OutputPage $out = null ) {
+ Profiler::instance()->setTemplated( true );
+
+ $oldContext = null;
+ if ( $out !== null ) {
+ // Deprecated since 1.20, note added in 1.25
+ wfDeprecated( __METHOD__, '1.25' );
+ $oldContext = $this->getContext();
+ $this->setContext( $out->getContext() );
+ }
+
+ $out = $this->getOutput();
+
+ $this->initPage( $out );
+ $tpl = $this->prepareQuickTemplate();
+ // execute template
+ $res = $tpl->execute();
+
+ // result may be an error
+ $this->printOrError( $res );
+
+ if ( $oldContext ) {
+ $this->setContext( $oldContext );
+ }
+ }
+
+ /**
+ * Wrap the body text with language information and identifiable element
+ *
+ * @param Title $title
+ * @param string $html body text
+ * @return string html
+ */
+ protected function wrapHTML( $title, $html ) {
+ # An ID that includes the actual body text; without categories, contentSub, ...
+ $realBodyAttribs = [ 'id' => 'mw-content-text' ];
+
+ # Add a mw-content-ltr/rtl class to be able to style based on text
+ # direction when the content is different from the UI language (only
+ # when viewing)
+ # Most information on special pages and file pages is in user language,
+ # rather than content language, so those will not get this
+ if ( Action::getActionName( $this ) === 'view' &&
+ ( !$title->inNamespaces( NS_SPECIAL, NS_FILE ) || $title->isRedirect() ) ) {
+ $pageLang = $title->getPageViewLanguage();
+ $realBodyAttribs['lang'] = $pageLang->getHtmlCode();
+ $realBodyAttribs['dir'] = $pageLang->getDir();
+ $realBodyAttribs['class'] = 'mw-content-' . $pageLang->getDir();
+ }
+
+ return Html::rawElement( 'div', $realBodyAttribs, $html );
+ }
+
+ /**
+ * initialize various variables and generate the template
+ *
+ * @since 1.23
+ * @return QuickTemplate The template to be executed by outputPage
+ */
+ protected function prepareQuickTemplate() {
+ global $wgContLang, $wgScript, $wgStylePath, $wgMimeType, $wgJsMimeType,
+ $wgSitename, $wgLogo, $wgMaxCredits,
+ $wgShowCreditsIfMax, $wgArticlePath,
+ $wgScriptPath, $wgServer;
+
+ $title = $this->getTitle();
+ $request = $this->getRequest();
+ $out = $this->getOutput();
+ $tpl = $this->setupTemplateForOutput();
+
+ $tpl->set( 'title', $out->getPageTitle() );
+ $tpl->set( 'pagetitle', $out->getHTMLTitle() );
+ $tpl->set( 'displaytitle', $out->mPageLinkTitle );
+
+ $tpl->set( 'thispage', $this->thispage );
+ $tpl->set( 'titleprefixeddbkey', $this->thispage );
+ $tpl->set( 'titletext', $title->getText() );
+ $tpl->set( 'articleid', $title->getArticleID() );
+
+ $tpl->set( 'isarticle', $out->isArticle() );
+
+ $subpagestr = $this->subPageSubtitle();
+ if ( $subpagestr !== '' ) {
+ $subpagestr = '<span class="subpages">' . $subpagestr . '</span>';
+ }
+ $tpl->set( 'subtitle', $subpagestr . $out->getSubtitle() );
+
+ $undelete = $this->getUndeleteLink();
+ if ( $undelete === '' ) {
+ $tpl->set( 'undelete', '' );
+ } else {
+ $tpl->set( 'undelete', '<span class="subpages">' . $undelete . '</span>' );
+ }
+
+ $tpl->set( 'catlinks', $this->getCategories() );
+ if ( $out->isSyndicated() ) {
+ $feeds = [];
+ foreach ( $out->getSyndicationLinks() as $format => $link ) {
+ $feeds[$format] = [
+ // Messages: feed-atom, feed-rss
+ 'text' => $this->msg( "feed-$format" )->text(),
+ 'href' => $link
+ ];
+ }
+ $tpl->set( 'feeds', $feeds );
+ } else {
+ $tpl->set( 'feeds', false );
+ }
+
+ $tpl->set( 'mimetype', $wgMimeType );
+ $tpl->set( 'jsmimetype', $wgJsMimeType );
+ $tpl->set( 'charset', 'UTF-8' );
+ $tpl->set( 'wgScript', $wgScript );
+ $tpl->set( 'skinname', $this->skinname );
+ $tpl->set( 'skinclass', static::class );
+ $tpl->set( 'skin', $this );
+ $tpl->set( 'stylename', $this->stylename );
+ $tpl->set( 'printable', $out->isPrintable() );
+ $tpl->set( 'handheld', $request->getBool( 'handheld' ) );
+ $tpl->set( 'loggedin', $this->loggedin );
+ $tpl->set( 'notspecialpage', !$title->isSpecialPage() );
+ $tpl->set( 'searchaction', $this->escapeSearchLink() );
+ $tpl->set( 'searchtitle', SpecialPage::getTitleFor( 'Search' )->getPrefixedDBkey() );
+ $tpl->set( 'search', trim( $request->getVal( 'search' ) ) );
+ $tpl->set( 'stylepath', $wgStylePath );
+ $tpl->set( 'articlepath', $wgArticlePath );
+ $tpl->set( 'scriptpath', $wgScriptPath );
+ $tpl->set( 'serverurl', $wgServer );
+ $tpl->set( 'logopath', $wgLogo );
+ $tpl->set( 'sitename', $wgSitename );
+
+ $userLang = $this->getLanguage();
+ $userLangCode = $userLang->getHtmlCode();
+ $userLangDir = $userLang->getDir();
+
+ $tpl->set( 'lang', $userLangCode );
+ $tpl->set( 'dir', $userLangDir );
+ $tpl->set( 'rtl', $userLang->isRTL() );
+
+ $tpl->set( 'capitalizeallnouns', $userLang->capitalizeAllNouns() ? ' capitalize-all-nouns' : '' );
+ $tpl->set( 'showjumplinks', true ); // showjumplinks preference has been removed
+ $tpl->set( 'username', $this->loggedin ? $this->username : null );
+ $tpl->set( 'userpage', $this->userpage );
+ $tpl->set( 'userpageurl', $this->userpageUrlDetails['href'] );
+ $tpl->set( 'userlang', $userLangCode );
+
+ // Users can have their language set differently than the
+ // content of the wiki. For these users, tell the web browser
+ // that interface elements are in a different language.
+ $tpl->set( 'userlangattributes', '' );
+ $tpl->set( 'specialpageattributes', '' ); # obsolete
+ // Used by VectorBeta to insert HTML before content but after the
+ // heading for the page title. Defaults to empty string.
+ $tpl->set( 'prebodyhtml', '' );
+
+ if ( $userLangCode !== $wgContLang->getHtmlCode() || $userLangDir !== $wgContLang->getDir() ) {
+ $escUserlang = htmlspecialchars( $userLangCode );
+ $escUserdir = htmlspecialchars( $userLangDir );
+ // Attributes must be in double quotes because htmlspecialchars() doesn't
+ // escape single quotes
+ $attrs = " lang=\"$escUserlang\" dir=\"$escUserdir\"";
+ $tpl->set( 'userlangattributes', $attrs );
+ }
+
+ $tpl->set( 'newtalk', $this->getNewtalks() );
+ $tpl->set( 'logo', $this->logoText() );
+
+ $tpl->set( 'copyright', false );
+ // No longer used
+ $tpl->set( 'viewcount', false );
+ $tpl->set( 'lastmod', false );
+ $tpl->set( 'credits', false );
+ $tpl->set( 'numberofwatchingusers', false );
+ if ( $out->isArticle() && $title->exists() ) {
+ if ( $this->isRevisionCurrent() ) {
+ if ( $wgMaxCredits != 0 ) {
+ $tpl->set( 'credits', Action::factory( 'credits', $this->getWikiPage(),
+ $this->getContext() )->getCredits( $wgMaxCredits, $wgShowCreditsIfMax ) );
+ } else {
+ $tpl->set( 'lastmod', $this->lastModified() );
+ }
+ }
+ $tpl->set( 'copyright', $this->getCopyright() );
+ }
+
+ $tpl->set( 'copyrightico', $this->getCopyrightIcon() );
+ $tpl->set( 'poweredbyico', $this->getPoweredBy() );
+ $tpl->set( 'disclaimer', $this->disclaimerLink() );
+ $tpl->set( 'privacy', $this->privacyLink() );
+ $tpl->set( 'about', $this->aboutLink() );
+
+ $tpl->set( 'footerlinks', [
+ 'info' => [
+ 'lastmod',
+ 'numberofwatchingusers',
+ 'credits',
+ 'copyright',
+ ],
+ 'places' => [
+ 'privacy',
+ 'about',
+ 'disclaimer',
+ ],
+ ] );
+
+ global $wgFooterIcons;
+ $tpl->set( 'footericons', $wgFooterIcons );
+ foreach ( $tpl->data['footericons'] as $footerIconsKey => &$footerIconsBlock ) {
+ if ( count( $footerIconsBlock ) > 0 ) {
+ foreach ( $footerIconsBlock as &$footerIcon ) {
+ if ( isset( $footerIcon['src'] ) ) {
+ if ( !isset( $footerIcon['width'] ) ) {
+ $footerIcon['width'] = 88;
+ }
+ if ( !isset( $footerIcon['height'] ) ) {
+ $footerIcon['height'] = 31;
+ }
+ }
+ }
+ } else {
+ unset( $tpl->data['footericons'][$footerIconsKey] );
+ }
+ }
+
+ $tpl->set( 'indicators', $out->getIndicators() );
+
+ $tpl->set( 'sitenotice', $this->getSiteNotice() );
+ $tpl->set( 'printfooter', $this->printSource() );
+ // Wrap the bodyText with #mw-content-text element
+ $out->mBodytext = $this->wrapHTML( $title, $out->mBodytext );
+ $tpl->set( 'bodytext', $out->mBodytext );
+
+ $language_urls = $this->getLanguages();
+ if ( count( $language_urls ) ) {
+ $tpl->set( 'language_urls', $language_urls );
+ } else {
+ $tpl->set( 'language_urls', false );
+ }
+
+ # Personal toolbar
+ $tpl->set( 'personal_urls', $this->buildPersonalUrls() );
+ $content_navigation = $this->buildContentNavigationUrls();
+ $content_actions = $this->buildContentActionUrls( $content_navigation );
+ $tpl->set( 'content_navigation', $content_navigation );
+ $tpl->set( 'content_actions', $content_actions );
+
+ $tpl->set( 'sidebar', $this->buildSidebar() );
+ $tpl->set( 'nav_urls', $this->buildNavUrls() );
+
+ // Do this last in case hooks above add bottom scripts
+ $tpl->set( 'bottomscripts', $this->bottomScripts() );
+
+ // Set the head scripts near the end, in case the above actions resulted in added scripts
+ $tpl->set( 'headelement', $out->headElement( $this ) );
+
+ $tpl->set( 'debug', '' );
+ $tpl->set( 'debughtml', $this->generateDebugHTML() );
+ $tpl->set( 'reporttime', wfReportTime() );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $skinTemplate = $this;
+ // original version by hansm
+ if ( !Hooks::run( 'SkinTemplateOutputPageBeforeExec', [ &$skinTemplate, &$tpl ] ) ) {
+ wfDebug( __METHOD__ . ": Hook SkinTemplateOutputPageBeforeExec broke outputPage execution!\n" );
+ }
+
+ // Set the bodytext to another key so that skins can just output it on its own
+ // and output printfooter and debughtml separately
+ $tpl->set( 'bodycontent', $tpl->data['bodytext'] );
+
+ // Append printfooter and debughtml onto bodytext so that skins that
+ // were already using bodytext before they were split out don't suddenly
+ // start not outputting information.
+ $tpl->data['bodytext'] .= Html::rawElement(
+ 'div',
+ [ 'class' => 'printfooter' ],
+ "\n{$tpl->data['printfooter']}"
+ ) . "\n";
+ $tpl->data['bodytext'] .= $tpl->data['debughtml'];
+
+ // allow extensions adding stuff after the page content.
+ // See Skin::afterContentHook() for further documentation.
+ $tpl->set( 'dataAfterContent', $this->afterContentHook() );
+
+ return $tpl;
+ }
+
+ /**
+ * Get the HTML for the p-personal list
+ * @return string
+ */
+ public function getPersonalToolsList() {
+ $tpl = $this->setupTemplateForOutput();
+ $tpl->set( 'personal_urls', $this->buildPersonalUrls() );
+ $html = '';
+ foreach ( $tpl->getPersonalTools() as $key => $item ) {
+ $html .= $tpl->makeListItem( $key, $item );
+ }
+ return $html;
+ }
+
+ /**
+ * Format language name for use in sidebar interlanguage links list.
+ * By default it is capitalized.
+ *
+ * @param string $name Language name, e.g. "English" or "español"
+ * @return string
+ * @private
+ */
+ function formatLanguageName( $name ) {
+ return $this->getLanguage()->ucfirst( $name );
+ }
+
+ /**
+ * Output the string, or print error message if it's
+ * an error object of the appropriate type.
+ * For the base class, assume strings all around.
+ *
+ * @param string $str
+ * @private
+ */
+ function printOrError( $str ) {
+ echo $str;
+ }
+
+ /**
+ * Output a boolean indicating if buildPersonalUrls should output separate
+ * login and create account links or output a combined link
+ * By default we simply return a global config setting that affects most skins
+ * This is setup as a method so that like with $wgLogo and getLogo() a skin
+ * can override this setting and always output one or the other if it has
+ * a reason it can't output one of the two modes.
+ * @return bool
+ */
+ function useCombinedLoginLink() {
+ global $wgUseCombinedLoginLink;
+ return $wgUseCombinedLoginLink;
+ }
+
+ /**
+ * build array of urls for personal toolbar
+ * @return array
+ */
+ protected function buildPersonalUrls() {
+ $title = $this->getTitle();
+ $request = $this->getRequest();
+ $pageurl = $title->getLocalURL();
+ $authManager = AuthManager::singleton();
+
+ /* set up the default links for the personal toolbar */
+ $personal_urls = [];
+
+ # Due to T34276, if a user does not have read permissions,
+ # $this->getTitle() will just give Special:Badtitle, which is
+ # not especially useful as a returnto parameter. Use the title
+ # from the request instead, if there was one.
+ if ( $this->getUser()->isAllowed( 'read' ) ) {
+ $page = $this->getTitle();
+ } else {
+ $page = Title::newFromText( $request->getVal( 'title', '' ) );
+ }
+ $page = $request->getVal( 'returnto', $page );
+ $a = [];
+ if ( strval( $page ) !== '' ) {
+ $a['returnto'] = $page;
+ $query = $request->getVal( 'returntoquery', $this->thisquery );
+ if ( $query != '' ) {
+ $a['returntoquery'] = $query;
+ }
+ }
+
+ $returnto = wfArrayToCgi( $a );
+ if ( $this->loggedin ) {
+ $personal_urls['userpage'] = [
+ 'text' => $this->username,
+ 'href' => &$this->userpageUrlDetails['href'],
+ 'class' => $this->userpageUrlDetails['exists'] ? false : 'new',
+ 'active' => ( $this->userpageUrlDetails['href'] == $pageurl ),
+ 'dir' => 'auto'
+ ];
+ $usertalkUrlDetails = $this->makeTalkUrlDetails( $this->userpage );
+ $personal_urls['mytalk'] = [
+ 'text' => $this->msg( 'mytalk' )->text(),
+ 'href' => &$usertalkUrlDetails['href'],
+ 'class' => $usertalkUrlDetails['exists'] ? false : 'new',
+ 'active' => ( $usertalkUrlDetails['href'] == $pageurl )
+ ];
+ $href = self::makeSpecialUrl( 'Preferences' );
+ $personal_urls['preferences'] = [
+ 'text' => $this->msg( 'mypreferences' )->text(),
+ 'href' => $href,
+ 'active' => ( $href == $pageurl )
+ ];
+
+ if ( $this->getUser()->isAllowed( 'viewmywatchlist' ) ) {
+ $href = self::makeSpecialUrl( 'Watchlist' );
+ $personal_urls['watchlist'] = [
+ 'text' => $this->msg( 'mywatchlist' )->text(),
+ 'href' => $href,
+ 'active' => ( $href == $pageurl )
+ ];
+ }
+
+ # We need to do an explicit check for Special:Contributions, as we
+ # have to match both the title, and the target, which could come
+ # from request values (Special:Contributions?target=Jimbo_Wales)
+ # or be specified in "sub page" form
+ # (Special:Contributions/Jimbo_Wales). The plot
+ # thickens, because the Title object is altered for special pages,
+ # so it doesn't contain the original alias-with-subpage.
+ $origTitle = Title::newFromText( $request->getText( 'title' ) );
+ if ( $origTitle instanceof Title && $origTitle->isSpecialPage() ) {
+ list( $spName, $spPar ) = SpecialPageFactory::resolveAlias( $origTitle->getText() );
+ $active = $spName == 'Contributions'
+ && ( ( $spPar && $spPar == $this->username )
+ || $request->getText( 'target' ) == $this->username );
+ } else {
+ $active = false;
+ }
+
+ $href = self::makeSpecialUrlSubpage( 'Contributions', $this->username );
+ $personal_urls['mycontris'] = [
+ 'text' => $this->msg( 'mycontris' )->text(),
+ 'href' => $href,
+ 'active' => $active
+ ];
+
+ // if we can't set the user, we can't unset it either
+ if ( $request->getSession()->canSetUser() ) {
+ $personal_urls['logout'] = [
+ 'text' => $this->msg( 'pt-userlogout' )->text(),
+ 'href' => self::makeSpecialUrl( 'Userlogout',
+ // userlogout link must always contain an & character, otherwise we might not be able
+ // to detect a buggy precaching proxy (T19790)
+ $title->isSpecial( 'Preferences' ) ? 'noreturnto' : $returnto ),
+ 'active' => false
+ ];
+ }
+ } else {
+ $useCombinedLoginLink = $this->useCombinedLoginLink();
+ if ( !$authManager->canCreateAccounts() || !$authManager->canAuthenticateNow() ) {
+ // don't show combined login/signup link if one of those is actually not available
+ $useCombinedLoginLink = false;
+ }
+
+ $loginlink = $this->getUser()->isAllowed( 'createaccount' ) && $useCombinedLoginLink
+ ? 'nav-login-createaccount'
+ : 'pt-login';
+
+ $login_url = [
+ 'text' => $this->msg( $loginlink )->text(),
+ 'href' => self::makeSpecialUrl( 'Userlogin', $returnto ),
+ 'active' => $title->isSpecial( 'Userlogin' )
+ || $title->isSpecial( 'CreateAccount' ) && $useCombinedLoginLink,
+ ];
+ $createaccount_url = [
+ 'text' => $this->msg( 'pt-createaccount' )->text(),
+ 'href' => self::makeSpecialUrl( 'CreateAccount', $returnto ),
+ 'active' => $title->isSpecial( 'CreateAccount' ),
+ ];
+
+ // No need to show Talk and Contributions to anons if they can't contribute!
+ if ( User::groupHasPermission( '*', 'edit' ) ) {
+ // Because of caching, we can't link directly to the IP talk and
+ // contributions pages. Instead we use the special page shortcuts
+ // (which work correctly regardless of caching). This means we can't
+ // determine whether these links are active or not, but since major
+ // skins (MonoBook, Vector) don't use this information, it's not a
+ // huge loss.
+ $personal_urls['anontalk'] = [
+ 'text' => $this->msg( 'anontalk' )->text(),
+ 'href' => self::makeSpecialUrlSubpage( 'Mytalk', false ),
+ 'active' => false
+ ];
+ $personal_urls['anoncontribs'] = [
+ 'text' => $this->msg( 'anoncontribs' )->text(),
+ 'href' => self::makeSpecialUrlSubpage( 'Mycontributions', false ),
+ 'active' => false
+ ];
+ }
+
+ if (
+ $authManager->canCreateAccounts()
+ && $this->getUser()->isAllowed( 'createaccount' )
+ && !$useCombinedLoginLink
+ ) {
+ $personal_urls['createaccount'] = $createaccount_url;
+ }
+
+ if ( $authManager->canAuthenticateNow() ) {
+ $key = User::groupHasPermission( '*', 'read' )
+ ? 'login'
+ : 'login-private';
+ $personal_urls[$key] = $login_url;
+ }
+ }
+
+ Hooks::run( 'PersonalUrls', [ &$personal_urls, &$title, $this ] );
+ return $personal_urls;
+ }
+
+ /**
+ * Builds an array with tab definition
+ *
+ * @param Title $title Page Where the tab links to
+ * @param string|array $message Message key or an array of message keys (will fall back)
+ * @param bool $selected Display the tab as selected
+ * @param string $query Query string attached to tab URL
+ * @param bool $checkEdit Check if $title exists and mark with .new if one doesn't
+ *
+ * @return array
+ */
+ function tabAction( $title, $message, $selected, $query = '', $checkEdit = false ) {
+ $classes = [];
+ if ( $selected ) {
+ $classes[] = 'selected';
+ }
+ if ( $checkEdit && !$title->isKnown() ) {
+ $classes[] = 'new';
+ if ( $query !== '' ) {
+ $query = 'action=edit&redlink=1&' . $query;
+ } else {
+ $query = 'action=edit&redlink=1';
+ }
+ }
+
+ $linkClass = MediaWikiServices::getInstance()->getLinkRenderer()->getLinkClasses( $title );
+
+ // wfMessageFallback will nicely accept $message as an array of fallbacks
+ // or just a single key
+ $msg = wfMessageFallback( $message )->setContext( $this->getContext() );
+ if ( is_array( $message ) ) {
+ // for hook compatibility just keep the last message name
+ $message = end( $message );
+ }
+ if ( $msg->exists() ) {
+ $text = $msg->text();
+ } else {
+ global $wgContLang;
+ $text = $wgContLang->getConverter()->convertNamespace(
+ MWNamespace::getSubject( $title->getNamespace() ) );
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $skinTemplate = $this;
+ $result = [];
+ if ( !Hooks::run( 'SkinTemplateTabAction', [ &$skinTemplate,
+ $title, $message, $selected, $checkEdit,
+ &$classes, &$query, &$text, &$result ] ) ) {
+ return $result;
+ }
+
+ $result = [
+ 'class' => implode( ' ', $classes ),
+ 'text' => $text,
+ 'href' => $title->getLocalURL( $query ),
+ 'primary' => true ];
+ if ( $linkClass !== '' ) {
+ $result['link-class'] = $linkClass;
+ }
+
+ return $result;
+ }
+
+ function makeTalkUrlDetails( $name, $urlaction = '' ) {
+ $title = Title::newFromText( $name );
+ if ( !is_object( $title ) ) {
+ throw new MWException( __METHOD__ . " given invalid pagename $name" );
+ }
+ $title = $title->getTalkPage();
+ self::checkTitle( $title, $name );
+ return [
+ 'href' => $title->getLocalURL( $urlaction ),
+ 'exists' => $title->isKnown(),
+ ];
+ }
+
+ /**
+ * @todo is this even used?
+ * @param string $name
+ * @param string $urlaction
+ * @return array
+ */
+ function makeArticleUrlDetails( $name, $urlaction = '' ) {
+ $title = Title::newFromText( $name );
+ $title = $title->getSubjectPage();
+ self::checkTitle( $title, $name );
+ return [
+ 'href' => $title->getLocalURL( $urlaction ),
+ 'exists' => $title->exists(),
+ ];
+ }
+
+ /**
+ * a structured array of links usually used for the tabs in a skin
+ *
+ * There are 4 standard sections
+ * namespaces: Used for namespace tabs like special, page, and talk namespaces
+ * views: Used for primary page views like read, edit, history
+ * actions: Used for most extra page actions like deletion, protection, etc...
+ * variants: Used to list the language variants for the page
+ *
+ * Each section's value is a key/value array of links for that section.
+ * The links themselves have these common keys:
+ * - class: The css classes to apply to the tab
+ * - text: The text to display on the tab
+ * - href: The href for the tab to point to
+ * - rel: An optional rel= for the tab's link
+ * - redundant: If true the tab will be dropped in skins using content_actions
+ * this is useful for tabs like "Read" which only have meaning in skins that
+ * take special meaning from the grouped structure of content_navigation
+ *
+ * Views also have an extra key which can be used:
+ * - primary: If this is not true skins like vector may try to hide the tab
+ * when the user has limited space in their browser window
+ *
+ * content_navigation using code also expects these ids to be present on the
+ * links, however these are usually automatically generated by SkinTemplate
+ * itself and are not necessary when using a hook. The only things these may
+ * matter to are people modifying content_navigation after it's initial creation:
+ * - id: A "preferred" id, most skins are best off outputting this preferred
+ * id for best compatibility.
+ * - tooltiponly: This is set to true for some tabs in cases where the system
+ * believes that the accesskey should not be added to the tab.
+ *
+ * @return array
+ */
+ protected function buildContentNavigationUrls() {
+ global $wgDisableLangConversion;
+
+ // Display tabs for the relevant title rather than always the title itself
+ $title = $this->getRelevantTitle();
+ $onPage = $title->equals( $this->getTitle() );
+
+ $out = $this->getOutput();
+ $request = $this->getRequest();
+ $user = $this->getUser();
+
+ $content_navigation = [
+ 'namespaces' => [],
+ 'views' => [],
+ 'actions' => [],
+ 'variants' => []
+ ];
+
+ // parameters
+ $action = $request->getVal( 'action', 'view' );
+
+ $userCanRead = $title->quickUserCan( 'read', $user );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $skinTemplate = $this;
+ $preventActiveTabs = false;
+ Hooks::run( 'SkinTemplatePreventOtherActiveTabs', [ &$skinTemplate, &$preventActiveTabs ] );
+
+ // Checks if page is some kind of content
+ if ( $title->canExist() ) {
+ // Gets page objects for the related namespaces
+ $subjectPage = $title->getSubjectPage();
+ $talkPage = $title->getTalkPage();
+
+ // Determines if this is a talk page
+ $isTalk = $title->isTalkPage();
+
+ // Generates XML IDs from namespace names
+ $subjectId = $title->getNamespaceKey( '' );
+
+ if ( $subjectId == 'main' ) {
+ $talkId = 'talk';
+ } else {
+ $talkId = "{$subjectId}_talk";
+ }
+
+ $skname = $this->skinname;
+
+ // Adds namespace links
+ $subjectMsg = [ "nstab-$subjectId" ];
+ if ( $subjectPage->isMainPage() ) {
+ array_unshift( $subjectMsg, 'mainpage-nstab' );
+ }
+ $content_navigation['namespaces'][$subjectId] = $this->tabAction(
+ $subjectPage, $subjectMsg, !$isTalk && !$preventActiveTabs, '', $userCanRead
+ );
+ $content_navigation['namespaces'][$subjectId]['context'] = 'subject';
+ $content_navigation['namespaces'][$talkId] = $this->tabAction(
+ $talkPage, [ "nstab-$talkId", 'talk' ], $isTalk && !$preventActiveTabs, '', $userCanRead
+ );
+ $content_navigation['namespaces'][$talkId]['context'] = 'talk';
+
+ if ( $userCanRead ) {
+ // Adds "view" view link
+ if ( $title->isKnown() ) {
+ $content_navigation['views']['view'] = $this->tabAction(
+ $isTalk ? $talkPage : $subjectPage,
+ [ "$skname-view-view", 'view' ],
+ ( $onPage && ( $action == 'view' || $action == 'purge' ) ), '', true
+ );
+ // signal to hide this from simple content_actions
+ $content_navigation['views']['view']['redundant'] = true;
+ }
+
+ $page = $this->canUseWikiPage() ? $this->getWikiPage() : false;
+ $isRemoteContent = $page && !$page->isLocal();
+
+ // If it is a non-local file, show a link to the file in its own repository
+ // @todo abstract this for remote content that isn't a file
+ if ( $isRemoteContent ) {
+ $content_navigation['views']['view-foreign'] = [
+ 'class' => '',
+ 'text' => wfMessageFallback( "$skname-view-foreign", 'view-foreign' )->
+ setContext( $this->getContext() )->
+ params( $page->getWikiDisplayName() )->text(),
+ 'href' => $page->getSourceURL(),
+ 'primary' => false,
+ ];
+ }
+
+ // Checks if user can edit the current page if it exists or create it otherwise
+ if ( $title->quickUserCan( 'edit', $user )
+ && ( $title->exists() || $title->quickUserCan( 'create', $user ) )
+ ) {
+ // Builds CSS class for talk page links
+ $isTalkClass = $isTalk ? ' istalk' : '';
+ // Whether the user is editing the page
+ $isEditing = $onPage && ( $action == 'edit' || $action == 'submit' );
+ // Whether to show the "Add a new section" tab
+ // Checks if this is a current rev of talk page and is not forced to be hidden
+ $showNewSection = !$out->forceHideNewSectionLink()
+ && ( ( $isTalk && $this->isRevisionCurrent() ) || $out->showNewSectionLink() );
+ $section = $request->getVal( 'section' );
+
+ if ( $title->exists()
+ || ( $title->getNamespace() == NS_MEDIAWIKI
+ && $title->getDefaultMessageText() !== false
+ )
+ ) {
+ $msgKey = $isRemoteContent ? 'edit-local' : 'edit';
+ } else {
+ $msgKey = $isRemoteContent ? 'create-local' : 'create';
+ }
+ $content_navigation['views']['edit'] = [
+ 'class' => ( $isEditing && ( $section !== 'new' || !$showNewSection )
+ ? 'selected'
+ : ''
+ ) . $isTalkClass,
+ 'text' => wfMessageFallback( "$skname-view-$msgKey", $msgKey )
+ ->setContext( $this->getContext() )->text(),
+ 'href' => $title->getLocalURL( $this->editUrlOptions() ),
+ 'primary' => !$isRemoteContent, // don't collapse this in vector
+ ];
+
+ // section link
+ if ( $showNewSection ) {
+ // Adds new section link
+ // $content_navigation['actions']['addsection']
+ $content_navigation['views']['addsection'] = [
+ 'class' => ( $isEditing && $section == 'new' ) ? 'selected' : false,
+ 'text' => wfMessageFallback( "$skname-action-addsection", 'addsection' )
+ ->setContext( $this->getContext() )->text(),
+ 'href' => $title->getLocalURL( 'action=edit&section=new' )
+ ];
+ }
+ // Checks if the page has some kind of viewable source content
+ } elseif ( $title->hasSourceText() ) {
+ // Adds view source view link
+ $content_navigation['views']['viewsource'] = [
+ 'class' => ( $onPage && $action == 'edit' ) ? 'selected' : false,
+ 'text' => wfMessageFallback( "$skname-action-viewsource", 'viewsource' )
+ ->setContext( $this->getContext() )->text(),
+ 'href' => $title->getLocalURL( $this->editUrlOptions() ),
+ 'primary' => true, // don't collapse this in vector
+ ];
+ }
+
+ // Checks if the page exists
+ if ( $title->exists() ) {
+ // Adds history view link
+ $content_navigation['views']['history'] = [
+ 'class' => ( $onPage && $action == 'history' ) ? 'selected' : false,
+ 'text' => wfMessageFallback( "$skname-view-history", 'history_short' )
+ ->setContext( $this->getContext() )->text(),
+ 'href' => $title->getLocalURL( 'action=history' ),
+ ];
+
+ if ( $title->quickUserCan( 'delete', $user ) ) {
+ $content_navigation['actions']['delete'] = [
+ 'class' => ( $onPage && $action == 'delete' ) ? 'selected' : false,
+ 'text' => wfMessageFallback( "$skname-action-delete", 'delete' )
+ ->setContext( $this->getContext() )->text(),
+ 'href' => $title->getLocalURL( 'action=delete' )
+ ];
+ }
+
+ if ( $title->quickUserCan( 'move', $user ) ) {
+ $moveTitle = SpecialPage::getTitleFor( 'Movepage', $title->getPrefixedDBkey() );
+ $content_navigation['actions']['move'] = [
+ 'class' => $this->getTitle()->isSpecial( 'Movepage' ) ? 'selected' : false,
+ 'text' => wfMessageFallback( "$skname-action-move", 'move' )
+ ->setContext( $this->getContext() )->text(),
+ 'href' => $moveTitle->getLocalURL()
+ ];
+ }
+ } else {
+ // article doesn't exist or is deleted
+ if ( $user->isAllowed( 'deletedhistory' ) ) {
+ $n = $title->isDeleted();
+ if ( $n ) {
+ $undelTitle = SpecialPage::getTitleFor( 'Undelete', $title->getPrefixedDBkey() );
+ // If the user can't undelete but can view deleted
+ // history show them a "View .. deleted" tab instead.
+ $msgKey = $user->isAllowed( 'undelete' ) ? 'undelete' : 'viewdeleted';
+ $content_navigation['actions']['undelete'] = [
+ 'class' => $this->getTitle()->isSpecial( 'Undelete' ) ? 'selected' : false,
+ 'text' => wfMessageFallback( "$skname-action-$msgKey", "{$msgKey}_short" )
+ ->setContext( $this->getContext() )->numParams( $n )->text(),
+ 'href' => $undelTitle->getLocalURL()
+ ];
+ }
+ }
+ }
+
+ if ( $title->quickUserCan( 'protect', $user ) && $title->getRestrictionTypes() &&
+ MWNamespace::getRestrictionLevels( $title->getNamespace(), $user ) !== [ '' ]
+ ) {
+ $mode = $title->isProtected() ? 'unprotect' : 'protect';
+ $content_navigation['actions'][$mode] = [
+ 'class' => ( $onPage && $action == $mode ) ? 'selected' : false,
+ 'text' => wfMessageFallback( "$skname-action-$mode", $mode )
+ ->setContext( $this->getContext() )->text(),
+ 'href' => $title->getLocalURL( "action=$mode" )
+ ];
+ }
+
+ // Checks if the user is logged in
+ if ( $this->loggedin && $user->isAllowedAll( 'viewmywatchlist', 'editmywatchlist' ) ) {
+ /**
+ * The following actions use messages which, if made particular to
+ * the any specific skins, would break the Ajax code which makes this
+ * action happen entirely inline. OutputPage::getJSVars
+ * defines a set of messages in a javascript object - and these
+ * messages are assumed to be global for all skins. Without making
+ * a change to that procedure these messages will have to remain as
+ * the global versions.
+ */
+ $mode = $user->isWatched( $title ) ? 'unwatch' : 'watch';
+ $content_navigation['actions'][$mode] = [
+ 'class' => 'mw-watchlink ' . (
+ $onPage && ( $action == 'watch' || $action == 'unwatch' ) ? 'selected' : ''
+ ),
+ // uses 'watch' or 'unwatch' message
+ 'text' => $this->msg( $mode )->text(),
+ 'href' => $title->getLocalURL( [ 'action' => $mode ] ),
+ // Set a data-mw=interface attribute, which the mediawiki.page.ajax
+ // module will look for to make sure it's a trusted link
+ 'data' => [
+ 'mw' => 'interface',
+ ],
+ ];
+ }
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $skinTemplate = $this;
+ Hooks::run( 'SkinTemplateNavigation', [ &$skinTemplate, &$content_navigation ] );
+
+ if ( $userCanRead && !$wgDisableLangConversion ) {
+ $pageLang = $title->getPageLanguage();
+ // Gets list of language variants
+ $variants = $pageLang->getVariants();
+ // Checks that language conversion is enabled and variants exist
+ // And if it is not in the special namespace
+ if ( count( $variants ) > 1 ) {
+ // Gets preferred variant (note that user preference is
+ // only possible for wiki content language variant)
+ $preferred = $pageLang->getPreferredVariant();
+ if ( Action::getActionName( $this ) === 'view' ) {
+ $params = $request->getQueryValues();
+ unset( $params['title'] );
+ } else {
+ $params = [];
+ }
+ // Loops over each variant
+ foreach ( $variants as $code ) {
+ // Gets variant name from language code
+ $varname = $pageLang->getVariantname( $code );
+ // Appends variant link
+ $content_navigation['variants'][] = [
+ 'class' => ( $code == $preferred ) ? 'selected' : false,
+ 'text' => $varname,
+ 'href' => $title->getLocalURL( [ 'variant' => $code ] + $params ),
+ 'lang' => wfBCP47( $code ),
+ 'hreflang' => wfBCP47( $code ),
+ ];
+ }
+ }
+ }
+ } else {
+ // If it's not content, it's got to be a special page
+ $content_navigation['namespaces']['special'] = [
+ 'class' => 'selected',
+ 'text' => $this->msg( 'nstab-special' )->text(),
+ 'href' => $request->getRequestURL(), // @see: T4457, T4510
+ 'context' => 'subject'
+ ];
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $skinTemplate = $this;
+ Hooks::run( 'SkinTemplateNavigation::SpecialPage',
+ [ &$skinTemplate, &$content_navigation ] );
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $skinTemplate = $this;
+ // Equiv to SkinTemplateContentActions
+ Hooks::run( 'SkinTemplateNavigation::Universal', [ &$skinTemplate, &$content_navigation ] );
+
+ // Setup xml ids and tooltip info
+ foreach ( $content_navigation as $section => &$links ) {
+ foreach ( $links as $key => &$link ) {
+ $xmlID = $key;
+ if ( isset( $link['context'] ) && $link['context'] == 'subject' ) {
+ $xmlID = 'ca-nstab-' . $xmlID;
+ } elseif ( isset( $link['context'] ) && $link['context'] == 'talk' ) {
+ $xmlID = 'ca-talk';
+ $link['rel'] = 'discussion';
+ } elseif ( $section == 'variants' ) {
+ $xmlID = 'ca-varlang-' . $xmlID;
+ } else {
+ $xmlID = 'ca-' . $xmlID;
+ }
+ $link['id'] = $xmlID;
+ }
+ }
+
+ # We don't want to give the watch tab an accesskey if the
+ # page is being edited, because that conflicts with the
+ # accesskey on the watch checkbox. We also don't want to
+ # give the edit tab an accesskey, because that's fairly
+ # superfluous and conflicts with an accesskey (Ctrl-E) often
+ # used for editing in Safari.
+ if ( in_array( $action, [ 'edit', 'submit' ] ) ) {
+ if ( isset( $content_navigation['views']['edit'] ) ) {
+ $content_navigation['views']['edit']['tooltiponly'] = true;
+ }
+ if ( isset( $content_navigation['actions']['watch'] ) ) {
+ $content_navigation['actions']['watch']['tooltiponly'] = true;
+ }
+ if ( isset( $content_navigation['actions']['unwatch'] ) ) {
+ $content_navigation['actions']['unwatch']['tooltiponly'] = true;
+ }
+ }
+
+ return $content_navigation;
+ }
+
+ /**
+ * an array of edit links by default used for the tabs
+ * @param array $content_navigation
+ * @return array
+ */
+ private function buildContentActionUrls( $content_navigation ) {
+ // content_actions has been replaced with content_navigation for backwards
+ // compatibility and also for skins that just want simple tabs content_actions
+ // is now built by flattening the content_navigation arrays into one
+
+ $content_actions = [];
+
+ foreach ( $content_navigation as $links ) {
+ foreach ( $links as $key => $value ) {
+ if ( isset( $value['redundant'] ) && $value['redundant'] ) {
+ // Redundant tabs are dropped from content_actions
+ continue;
+ }
+
+ // content_actions used to have ids built using the "ca-$key" pattern
+ // so the xmlID based id is much closer to the actual $key that we want
+ // for that reason we'll just strip out the ca- if present and use
+ // the latter potion of the "id" as the $key
+ if ( isset( $value['id'] ) && substr( $value['id'], 0, 3 ) == 'ca-' ) {
+ $key = substr( $value['id'], 3 );
+ }
+
+ if ( isset( $content_actions[$key] ) ) {
+ wfDebug( __METHOD__ . ": Found a duplicate key for $key while flattening " .
+ "content_navigation into content_actions.\n" );
+ continue;
+ }
+
+ $content_actions[$key] = $value;
+ }
+ }
+
+ return $content_actions;
+ }
+
+ /**
+ * build array of common navigation links
+ * @return array
+ */
+ protected function buildNavUrls() {
+ global $wgUploadNavigationUrl;
+
+ $out = $this->getOutput();
+ $request = $this->getRequest();
+
+ $nav_urls = [];
+ $nav_urls['mainpage'] = [ 'href' => self::makeMainPageUrl() ];
+ if ( $wgUploadNavigationUrl ) {
+ $nav_urls['upload'] = [ 'href' => $wgUploadNavigationUrl ];
+ } elseif ( UploadBase::isEnabled() && UploadBase::isAllowed( $this->getUser() ) === true ) {
+ $nav_urls['upload'] = [ 'href' => self::makeSpecialUrl( 'Upload' ) ];
+ } else {
+ $nav_urls['upload'] = false;
+ }
+ $nav_urls['specialpages'] = [ 'href' => self::makeSpecialUrl( 'Specialpages' ) ];
+
+ $nav_urls['print'] = false;
+ $nav_urls['permalink'] = false;
+ $nav_urls['info'] = false;
+ $nav_urls['whatlinkshere'] = false;
+ $nav_urls['recentchangeslinked'] = false;
+ $nav_urls['contributions'] = false;
+ $nav_urls['log'] = false;
+ $nav_urls['blockip'] = false;
+ $nav_urls['emailuser'] = false;
+ $nav_urls['userrights'] = false;
+
+ // A print stylesheet is attached to all pages, but nobody ever
+ // figures that out. :) Add a link...
+ if ( !$out->isPrintable() && ( $out->isArticle() || $this->getTitle()->isSpecialPage() ) ) {
+ $nav_urls['print'] = [
+ 'text' => $this->msg( 'printableversion' )->text(),
+ 'href' => $this->getTitle()->getLocalURL(
+ $request->appendQueryValue( 'printable', 'yes' ) )
+ ];
+ }
+
+ if ( $out->isArticle() ) {
+ // Also add a "permalink" while we're at it
+ $revid = $this->getRevisionId();
+ if ( $revid ) {
+ $nav_urls['permalink'] = [
+ 'text' => $this->msg( 'permalink' )->text(),
+ 'href' => $this->getTitle()->getLocalURL( "oldid=$revid" )
+ ];
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $skinTemplate = $this;
+ // Use the copy of revision ID in case this undocumented, shady hook tries to mess with internals
+ Hooks::run( 'SkinTemplateBuildNavUrlsNav_urlsAfterPermalink',
+ [ &$skinTemplate, &$nav_urls, &$revid, &$revid ] );
+ }
+
+ if ( $out->isArticleRelated() ) {
+ $nav_urls['whatlinkshere'] = [
+ 'href' => SpecialPage::getTitleFor( 'Whatlinkshere', $this->thispage )->getLocalURL()
+ ];
+
+ $nav_urls['info'] = [
+ 'text' => $this->msg( 'pageinfo-toolboxlink' )->text(),
+ 'href' => $this->getTitle()->getLocalURL( "action=info" )
+ ];
+
+ if ( $this->getTitle()->exists() || $this->getTitle()->inNamespace( NS_CATEGORY ) ) {
+ $nav_urls['recentchangeslinked'] = [
+ 'href' => SpecialPage::getTitleFor( 'Recentchangeslinked', $this->thispage )->getLocalURL()
+ ];
+ }
+ }
+
+ $user = $this->getRelevantUser();
+ if ( $user ) {
+ $rootUser = $user->getName();
+
+ $nav_urls['contributions'] = [
+ 'text' => $this->msg( 'contributions', $rootUser )->text(),
+ 'href' => self::makeSpecialUrlSubpage( 'Contributions', $rootUser ),
+ 'tooltip-params' => [ $rootUser ],
+ ];
+
+ $nav_urls['log'] = [
+ 'href' => self::makeSpecialUrlSubpage( 'Log', $rootUser )
+ ];
+
+ if ( $this->getUser()->isAllowed( 'block' ) ) {
+ $nav_urls['blockip'] = [
+ 'text' => $this->msg( 'blockip', $rootUser )->text(),
+ 'href' => self::makeSpecialUrlSubpage( 'Block', $rootUser )
+ ];
+ }
+
+ if ( $this->showEmailUser( $user ) ) {
+ $nav_urls['emailuser'] = [
+ 'text' => $this->msg( 'tool-link-emailuser', $rootUser )->text(),
+ 'href' => self::makeSpecialUrlSubpage( 'Emailuser', $rootUser ),
+ 'tooltip-params' => [ $rootUser ],
+ ];
+ }
+
+ if ( !$user->isAnon() ) {
+ $sur = new UserrightsPage;
+ $sur->setContext( $this->getContext() );
+ $canChange = $sur->userCanChangeRights( $user );
+ $nav_urls['userrights'] = [
+ 'text' => $this->msg(
+ $canChange ? 'tool-link-userrights' : 'tool-link-userrights-readonly',
+ $rootUser
+ )->text(),
+ 'href' => self::makeSpecialUrlSubpage( 'Userrights', $rootUser )
+ ];
+ }
+ }
+
+ return $nav_urls;
+ }
+
+ /**
+ * Generate strings used for xml 'id' names
+ * @return string
+ */
+ protected function getNameSpaceKey() {
+ return $this->getTitle()->getNamespaceKey();
+ }
+}
diff --git a/www/wiki/includes/specialpage/AuthManagerSpecialPage.php b/www/wiki/includes/specialpage/AuthManagerSpecialPage.php
new file mode 100644
index 00000000..95729f3c
--- /dev/null
+++ b/www/wiki/includes/specialpage/AuthManagerSpecialPage.php
@@ -0,0 +1,766 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\Token;
+
+/**
+ * A special page subclass for authentication-related special pages. It generates a form from
+ * a set of AuthenticationRequest objects, submits the result to AuthManager and
+ * partially handles the response.
+ */
+abstract class AuthManagerSpecialPage extends SpecialPage {
+ /** @var string[] The list of actions this special page deals with. Subclasses should override
+ * this. */
+ protected static $allowedActions = [
+ AuthManager::ACTION_LOGIN, AuthManager::ACTION_LOGIN_CONTINUE,
+ AuthManager::ACTION_CREATE, AuthManager::ACTION_CREATE_CONTINUE,
+ AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE,
+ AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK,
+ ];
+
+ /** @var array Customized messages */
+ protected static $messages = [];
+
+ /** @var string one of the AuthManager::ACTION_* constants. */
+ protected $authAction;
+
+ /** @var AuthenticationRequest[] */
+ protected $authRequests;
+
+ /** @var string Subpage of the special page. */
+ protected $subPage;
+
+ /** @var bool True if the current request is a result of returning from a redirect flow. */
+ protected $isReturn;
+
+ /** @var WebRequest|null If set, will be used instead of the real request. Used for redirection. */
+ protected $savedRequest;
+
+ /**
+ * Change the form descriptor that determines how a field will look in the authentication form.
+ * Called from fieldInfoToFormDescriptor().
+ * @param AuthenticationRequest[] $requests
+ * @param array $fieldInfo Field information array (union of all
+ * AuthenticationRequest::getFieldInfo() responses).
+ * @param array &$formDescriptor HTMLForm descriptor. The special key 'weight' can be set to
+ * change the order of the fields.
+ * @param string $action Authentication type (one of the AuthManager::ACTION_* constants)
+ * @return bool
+ */
+ public function onAuthChangeFormFields(
+ array $requests, array $fieldInfo, array &$formDescriptor, $action
+ ) {
+ return true;
+ }
+
+ protected function getLoginSecurityLevel() {
+ return $this->getName();
+ }
+
+ public function getRequest() {
+ return $this->savedRequest ?: $this->getContext()->getRequest();
+ }
+
+ /**
+ * Override the POST data, GET data from the real request is preserved.
+ *
+ * Used to preserve POST data over a HTTP redirect.
+ *
+ * @param array $data
+ * @param bool $wasPosted
+ */
+ protected function setRequest( array $data, $wasPosted = null ) {
+ $request = $this->getContext()->getRequest();
+ if ( $wasPosted === null ) {
+ $wasPosted = $request->wasPosted();
+ }
+ $this->savedRequest = new DerivativeRequest( $request, $data + $request->getQueryValues(),
+ $wasPosted );
+ }
+
+ protected function beforeExecute( $subPage ) {
+ $this->getOutput()->disallowUserJs();
+
+ return $this->handleReturnBeforeExecute( $subPage )
+ && $this->handleReauthBeforeExecute( $subPage );
+ }
+
+ /**
+ * Handle redirection from the /return subpage.
+ *
+ * This is used in the redirect flow where we need
+ * to be able to process data that was sent via a GET request. We set the /return subpage as
+ * the reentry point so we know we need to treat GET as POST, but we don't want to handle all
+ * future GETs as POSTs so we need to normalize the URL. (Also we don't want to show any
+ * received parameters around in the URL; they are ugly and might be sensitive.)
+ *
+ * Thus when on the /return subpage, we stash the request data in the session, redirect, then
+ * use the session to detect that we have been redirected, recover the data and replace the
+ * real WebRequest with a fake one that contains the saved data.
+ *
+ * @param string $subPage
+ * @return bool False if execution should be stopped.
+ */
+ protected function handleReturnBeforeExecute( $subPage ) {
+ $authManager = AuthManager::singleton();
+ $key = 'AuthManagerSpecialPage:return:' . $this->getName();
+
+ if ( $subPage === 'return' ) {
+ $this->loadAuth( $subPage );
+ $preservedParams = $this->getPreservedParams( false );
+
+ // FIXME save POST values only from request
+ $authData = array_diff_key( $this->getRequest()->getValues(),
+ $preservedParams, [ 'title' => 1 ] );
+ $authManager->setAuthenticationSessionData( $key, $authData );
+
+ $url = $this->getPageTitle()->getFullURL( $preservedParams, false, PROTO_HTTPS );
+ $this->getOutput()->redirect( $url );
+ return false;
+ }
+
+ $authData = $authManager->getAuthenticationSessionData( $key );
+ if ( $authData ) {
+ $authManager->removeAuthenticationSessionData( $key );
+ $this->isReturn = true;
+ $this->setRequest( $authData, true );
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle redirection when the user needs to (re)authenticate.
+ *
+ * Send the user to the login form if needed; in case the request was a POST, stash in the
+ * session and simulate it once the user gets back.
+ *
+ * @param string $subPage
+ * @return bool False if execution should be stopped.
+ * @throws ErrorPageError When the user is not allowed to use this page.
+ */
+ protected function handleReauthBeforeExecute( $subPage ) {
+ $authManager = AuthManager::singleton();
+ $request = $this->getRequest();
+ $key = 'AuthManagerSpecialPage:reauth:' . $this->getName();
+
+ $securityLevel = $this->getLoginSecurityLevel();
+ if ( $securityLevel ) {
+ $securityStatus = AuthManager::singleton()
+ ->securitySensitiveOperationStatus( $securityLevel );
+ if ( $securityStatus === AuthManager::SEC_REAUTH ) {
+ $queryParams = array_diff_key( $request->getQueryValues(), [ 'title' => true ] );
+
+ if ( $request->wasPosted() ) {
+ // unique ID in case the same special page is open in multiple browser tabs
+ $uniqueId = MWCryptRand::generateHex( 6 );
+ $key = $key . ':' . $uniqueId;
+
+ $queryParams = [ 'authUniqueId' => $uniqueId ] + $queryParams;
+ $authData = array_diff_key( $request->getValues(),
+ $this->getPreservedParams( false ), [ 'title' => 1 ] );
+ $authManager->setAuthenticationSessionData( $key, $authData );
+ }
+
+ $title = SpecialPage::getTitleFor( 'Userlogin' );
+ $url = $title->getFullURL( [
+ 'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
+ 'returntoquery' => wfArrayToCgi( $queryParams ),
+ 'force' => $securityLevel,
+ ], false, PROTO_HTTPS );
+
+ $this->getOutput()->redirect( $url );
+ return false;
+ } elseif ( $securityStatus !== AuthManager::SEC_OK ) {
+ throw new ErrorPageError( 'cannotauth-not-allowed-title', 'cannotauth-not-allowed' );
+ }
+ }
+
+ $uniqueId = $request->getVal( 'authUniqueId' );
+ if ( $uniqueId ) {
+ $key = $key . ':' . $uniqueId;
+ $authData = $authManager->getAuthenticationSessionData( $key );
+ if ( $authData ) {
+ $authManager->removeAuthenticationSessionData( $key );
+ $this->setRequest( $authData, true );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the default action for this special page, if none is given via URL/POST data.
+ * Subclasses should override this (or override loadAuth() so this is never called).
+ * @param string $subPage Subpage of the special page.
+ * @return string an AuthManager::ACTION_* constant.
+ */
+ abstract protected function getDefaultAction( $subPage );
+
+ /**
+ * Return custom message key.
+ * Allows subclasses to customize messages.
+ * @param string $defaultKey
+ * @return string
+ */
+ protected function messageKey( $defaultKey ) {
+ return array_key_exists( $defaultKey, static::$messages )
+ ? static::$messages[$defaultKey] : $defaultKey;
+ }
+
+ /**
+ * Allows blacklisting certain request types.
+ * @return array A list of AuthenticationRequest subclass names
+ */
+ protected function getRequestBlacklist() {
+ return [];
+ }
+
+ /**
+ * Load or initialize $authAction, $authRequests and $subPage.
+ * Subclasses should call this from execute() or otherwise ensure the variables are initialized.
+ * @param string $subPage Subpage of the special page.
+ * @param string $authAction Override auth action specified in request (this is useful
+ * when the form needs to be changed from <action> to <action>_CONTINUE after a successful
+ * authentication step)
+ * @param bool $reset Regenerate the requests even if a cached version is available
+ */
+ protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
+ // Do not load if already loaded, to cut down on the number of getAuthenticationRequests
+ // calls. This is important for requests which have hidden information so any
+ // getAuthenticationRequests call would mean putting data into some cache.
+ if (
+ !$reset && $this->subPage === $subPage && $this->authAction
+ && ( !$authAction || $authAction === $this->authAction )
+ ) {
+ return;
+ }
+
+ $request = $this->getRequest();
+ $this->subPage = $subPage;
+ $this->authAction = $authAction ?: $request->getText( 'authAction' );
+ if ( !in_array( $this->authAction, static::$allowedActions, true ) ) {
+ $this->authAction = $this->getDefaultAction( $subPage );
+ if ( $request->wasPosted() ) {
+ $continueAction = $this->getContinueAction( $this->authAction );
+ if ( in_array( $continueAction, static::$allowedActions, true ) ) {
+ $this->authAction = $continueAction;
+ }
+ }
+ }
+
+ $allReqs = AuthManager::singleton()->getAuthenticationRequests(
+ $this->authAction, $this->getUser() );
+ $this->authRequests = array_filter( $allReqs, function ( $req ) use ( $subPage ) {
+ return !in_array( get_class( $req ), $this->getRequestBlacklist(), true );
+ } );
+ }
+
+ /**
+ * Returns true if this is not the first step of the authentication.
+ * @return bool
+ */
+ protected function isContinued() {
+ return in_array( $this->authAction, [
+ AuthManager::ACTION_LOGIN_CONTINUE,
+ AuthManager::ACTION_CREATE_CONTINUE,
+ AuthManager::ACTION_LINK_CONTINUE,
+ ], true );
+ }
+
+ /**
+ * Gets the _CONTINUE version of an action.
+ * @param string $action An AuthManager::ACTION_* constant.
+ * @return string An AuthManager::ACTION_*_CONTINUE constant.
+ */
+ protected function getContinueAction( $action ) {
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ $action = AuthManager::ACTION_LOGIN_CONTINUE;
+ break;
+ case AuthManager::ACTION_CREATE:
+ $action = AuthManager::ACTION_CREATE_CONTINUE;
+ break;
+ case AuthManager::ACTION_LINK:
+ $action = AuthManager::ACTION_LINK_CONTINUE;
+ break;
+ }
+ return $action;
+ }
+
+ /**
+ * Checks whether AuthManager is ready to perform the action.
+ * ACTION_CHANGE needs special verification (AuthManager::allowsAuthenticationData*) which is
+ * the caller's responsibility.
+ * @param string $action One of the AuthManager::ACTION_* constants in static::$allowedActions
+ * @return bool
+ * @throws LogicException if $action is invalid
+ */
+ protected function isActionAllowed( $action ) {
+ $authManager = AuthManager::singleton();
+ if ( !in_array( $action, static::$allowedActions, true ) ) {
+ throw new InvalidArgumentException( 'invalid action: ' . $action );
+ }
+
+ // calling getAuthenticationRequests can be expensive, avoid if possible
+ $requests = ( $action === $this->authAction ) ? $this->authRequests
+ : $authManager->getAuthenticationRequests( $action );
+ if ( !$requests ) {
+ // no provider supports this action in the current state
+ return false;
+ }
+
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ case AuthManager::ACTION_LOGIN_CONTINUE:
+ return $authManager->canAuthenticateNow();
+ case AuthManager::ACTION_CREATE:
+ case AuthManager::ACTION_CREATE_CONTINUE:
+ return $authManager->canCreateAccounts();
+ case AuthManager::ACTION_LINK:
+ case AuthManager::ACTION_LINK_CONTINUE:
+ return $authManager->canLinkAccounts();
+ case AuthManager::ACTION_CHANGE:
+ case AuthManager::ACTION_REMOVE:
+ case AuthManager::ACTION_UNLINK:
+ return true;
+ default:
+ // should never reach here but makes static code analyzers happy
+ throw new InvalidArgumentException( 'invalid action: ' . $action );
+ }
+ }
+
+ /**
+ * @param string $action One of the AuthManager::ACTION_* constants
+ * @param AuthenticationRequest[] $requests
+ * @return AuthenticationResponse
+ * @throws LogicException if $action is invalid
+ */
+ protected function performAuthenticationStep( $action, array $requests ) {
+ if ( !in_array( $action, static::$allowedActions, true ) ) {
+ throw new InvalidArgumentException( 'invalid action: ' . $action );
+ }
+
+ $authManager = AuthManager::singleton();
+ $returnToUrl = $this->getPageTitle( 'return' )
+ ->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
+
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ return $authManager->beginAuthentication( $requests, $returnToUrl );
+ case AuthManager::ACTION_LOGIN_CONTINUE:
+ return $authManager->continueAuthentication( $requests );
+ case AuthManager::ACTION_CREATE:
+ return $authManager->beginAccountCreation( $this->getUser(), $requests,
+ $returnToUrl );
+ case AuthManager::ACTION_CREATE_CONTINUE:
+ return $authManager->continueAccountCreation( $requests );
+ case AuthManager::ACTION_LINK:
+ return $authManager->beginAccountLink( $this->getUser(), $requests, $returnToUrl );
+ case AuthManager::ACTION_LINK_CONTINUE:
+ return $authManager->continueAccountLink( $requests );
+ case AuthManager::ACTION_CHANGE:
+ case AuthManager::ACTION_REMOVE:
+ case AuthManager::ACTION_UNLINK:
+ if ( count( $requests ) > 1 ) {
+ throw new InvalidArgumentException( 'only one auth request can be changed at a time' );
+ } elseif ( !$requests ) {
+ throw new InvalidArgumentException( 'no auth request' );
+ }
+ $req = reset( $requests );
+ $status = $authManager->allowsAuthenticationDataChange( $req );
+ Hooks::run( 'ChangeAuthenticationDataAudit', [ $req, $status ] );
+ if ( !$status->isGood() ) {
+ return AuthenticationResponse::newFail( $status->getMessage() );
+ }
+ $authManager->changeAuthenticationData( $req );
+ return AuthenticationResponse::newPass();
+ default:
+ // should never reach here but makes static code analyzers happy
+ throw new InvalidArgumentException( 'invalid action: ' . $action );
+ }
+ }
+
+ /**
+ * Attempts to do an authentication step with the submitted data.
+ * Subclasses should probably call this from execute().
+ * @return false|Status
+ * - false if there was no submit at all
+ * - a good Status wrapping an AuthenticationResponse if the form submit was successful.
+ * This does not necessarily mean that the authentication itself was successful; see the
+ * response for that.
+ * - a bad Status for form errors.
+ */
+ protected function trySubmit() {
+ $status = false;
+
+ $form = $this->getAuthForm( $this->authRequests, $this->authAction );
+ $form->setSubmitCallback( [ $this, 'handleFormSubmit' ] );
+
+ if ( $this->getRequest()->wasPosted() ) {
+ // handle tokens manually; $form->tryAuthorizedSubmit only works for logged-in users
+ $requestTokenValue = $this->getRequest()->getVal( $this->getTokenName() );
+ $sessionToken = $this->getToken();
+ if ( $sessionToken->wasNew() ) {
+ return Status::newFatal( $this->messageKey( 'authform-newtoken' ) );
+ } elseif ( !$requestTokenValue ) {
+ return Status::newFatal( $this->messageKey( 'authform-notoken' ) );
+ } elseif ( !$sessionToken->match( $requestTokenValue ) ) {
+ return Status::newFatal( $this->messageKey( 'authform-wrongtoken' ) );
+ }
+
+ $form->prepareForm();
+ $status = $form->trySubmit();
+
+ // HTMLForm submit return values are a mess; let's ensure it is false or a Status
+ // FIXME this probably should be in HTMLForm
+ if ( $status === true ) {
+ // not supposed to happen since our submit handler should always return a Status
+ throw new UnexpectedValueException( 'HTMLForm::trySubmit() returned true' );
+ } elseif ( $status === false ) {
+ // form was not submitted; nothing to do
+ } elseif ( $status instanceof Status ) {
+ // already handled by the form; nothing to do
+ } elseif ( $status instanceof StatusValue ) {
+ // in theory not an allowed return type but nothing stops the submit handler from
+ // accidentally returning it so best check and fix
+ $status = Status::wrap( $status );
+ } elseif ( is_string( $status ) ) {
+ $status = Status::newFatal( new RawMessage( '$1', $status ) );
+ } elseif ( is_array( $status ) ) {
+ if ( is_string( reset( $status ) ) ) {
+ $status = call_user_func_array( 'Status::newFatal', $status );
+ } elseif ( is_array( reset( $status ) ) ) {
+ $status = Status::newGood();
+ foreach ( $status as $message ) {
+ call_user_func_array( [ $status, 'fatal' ], $message );
+ }
+ } else {
+ throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return value: '
+ . 'first element of array is ' . gettype( reset( $status ) ) );
+ }
+ } else {
+ // not supposed to happen but HTMLForm does not actually verify the return type
+ // from the submit callback; better safe then sorry
+ throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return type: '
+ . gettype( $status ) );
+ }
+
+ if ( ( !$status || !$status->isOK() ) && $this->isReturn ) {
+ // This is awkward. There was a form validation error, which means the data was not
+ // passed to AuthManager. Normally we would display the form with an error message,
+ // but for the data we received via the redirect flow that would not be helpful at all.
+ // Let's just submit the data to AuthManager directly instead.
+ LoggerFactory::getInstance( 'authentication' )
+ ->warning( 'Validation error on return', [ 'data' => $form->mFieldData,
+ 'status' => $status->getWikiText() ] );
+ $status = $this->handleFormSubmit( $form->mFieldData );
+ }
+ }
+
+ $changeActions = [
+ AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK
+ ];
+ if ( in_array( $this->authAction, $changeActions, true ) && $status && !$status->isOK() ) {
+ Hooks::run( 'ChangeAuthenticationDataAudit', [ reset( $this->authRequests ), $status ] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Submit handler callback for HTMLForm
+ * @private
+ * @param array $data Submitted data
+ * @return Status
+ */
+ public function handleFormSubmit( $data ) {
+ $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
+ $response = $this->performAuthenticationStep( $this->authAction, $requests );
+
+ // we can't handle FAIL or similar as failure here since it might require changing the form
+ return Status::newGood( $response );
+ }
+
+ /**
+ * Returns URL query parameters which can be used to reload the page (or leave and return) while
+ * preserving all information that is necessary for authentication to continue. These parameters
+ * will be preserved in the action URL of the form and in the return URL for redirect flow.
+ * @param bool $withToken Include CSRF token
+ * @return array
+ */
+ protected function getPreservedParams( $withToken = false ) {
+ $params = [];
+ if ( $this->authAction !== $this->getDefaultAction( $this->subPage ) ) {
+ $params['authAction'] = $this->getContinueAction( $this->authAction );
+ }
+ if ( $withToken ) {
+ $params[$this->getTokenName()] = $this->getToken()->toString();
+ }
+ return $params;
+ }
+
+ /**
+ * Generates a HTMLForm descriptor array from a set of authentication requests.
+ * @param AuthenticationRequest[] $requests
+ * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
+ * @return array
+ */
+ protected function getAuthFormDescriptor( $requests, $action ) {
+ $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
+ $formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action );
+
+ $this->addTabIndex( $formDescriptor );
+
+ return $formDescriptor;
+ }
+
+ /**
+ * @param AuthenticationRequest[] $requests
+ * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
+ * @return HTMLForm
+ */
+ protected function getAuthForm( array $requests, $action ) {
+ $formDescriptor = $this->getAuthFormDescriptor( $requests, $action );
+ $context = $this->getContext();
+ if ( $context->getRequest() !== $this->getRequest() ) {
+ // We have overridden the request, need to make sure the form uses that too.
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setRequest( $this->getRequest() );
+ }
+ $form = HTMLForm::factory( 'ooui', $formDescriptor, $context );
+ $form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
+ $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
+ $form->addHiddenField( 'authAction', $this->authAction );
+ $form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) );
+
+ return $form;
+ }
+
+ /**
+ * Display the form.
+ * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit()
+ */
+ protected function displayForm( $status ) {
+ if ( $status instanceof StatusValue ) {
+ $status = Status::wrap( $status );
+ }
+ $form = $this->getAuthForm( $this->authRequests, $this->authAction );
+ $form->prepareForm()->displayForm( $status );
+ }
+
+ /**
+ * Returns true if the form built from the given AuthenticationRequests needs a submit button.
+ * Providers using redirect flow (e.g. Google login) need their own submit buttons; if using
+ * one of those custom buttons is the only way to proceed, there is no point in displaying the
+ * default button which won't do anything useful.
+ *
+ * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the
+ * form will be built
+ * @return bool
+ */
+ protected function needsSubmitButton( array $requests ) {
+ $customSubmitButtonPresent = false;
+
+ // Secondary and preauth providers always need their data; they will not care what button
+ // is used, so they can be ignored. So can OPTIONAL buttons createdby primary providers;
+ // that's the point in being optional. Se we need to check whether all primary providers
+ // have their own buttons and whether there is at least one button present.
+ foreach ( $requests as $req ) {
+ if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED ) {
+ if ( $this->hasOwnSubmitButton( $req ) ) {
+ $customSubmitButtonPresent = true;
+ } else {
+ return true;
+ }
+ }
+ }
+ return !$customSubmitButtonPresent;
+ }
+
+ /**
+ * Checks whether the given AuthenticationRequest has its own submit button.
+ * @param AuthenticationRequest $req
+ * @return bool
+ */
+ protected function hasOwnSubmitButton( AuthenticationRequest $req ) {
+ foreach ( $req->getFieldInfo() as $field => $info ) {
+ if ( $info['type'] === 'button' ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Adds a sequential tabindex starting from 1 to all form elements. This way the user can
+ * use the tab key to traverse the form without having to step through all links and such.
+ * @param array &$formDescriptor
+ */
+ protected function addTabIndex( &$formDescriptor ) {
+ $i = 1;
+ foreach ( $formDescriptor as $field => &$definition ) {
+ $class = false;
+ if ( array_key_exists( 'class', $definition ) ) {
+ $class = $definition['class'];
+ } elseif ( array_key_exists( 'type', $definition ) ) {
+ $class = HTMLForm::$typeMappings[$definition['type']];
+ }
+ if ( $class !== 'HTMLInfoField' ) {
+ $definition['tabindex'] = $i;
+ $i++;
+ }
+ }
+ }
+
+ /**
+ * Returns the CSRF token.
+ * @return Token
+ */
+ protected function getToken() {
+ return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:'
+ . $this->getName() );
+ }
+
+ /**
+ * Returns the name of the CSRF token (under which it should be found in the POST or GET data).
+ * @return string
+ */
+ protected function getTokenName() {
+ return 'wpAuthToken';
+ }
+
+ /**
+ * Turns a field info array into a form descriptor. Behavior can be modified by the
+ * AuthChangeFormFields hook.
+ * @param AuthenticationRequest[] $requests
+ * @param array $fieldInfo Field information, in the format used by
+ * AuthenticationRequest::getFieldInfo()
+ * @param string $action One of the AuthManager::ACTION_* constants
+ * @return array A form descriptor that can be passed to HTMLForm
+ */
+ protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) {
+ $formDescriptor = [];
+ foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) {
+ $formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName );
+ }
+
+ $requestSnapshot = serialize( $requests );
+ $this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
+ \Hooks::run( 'AuthChangeFormFields', [ $requests, $fieldInfo, &$formDescriptor, $action ] );
+ if ( $requestSnapshot !== serialize( $requests ) ) {
+ LoggerFactory::getInstance( 'authentication' )->warning(
+ 'AuthChangeFormFields hook changed auth requests' );
+ }
+
+ // Process the special 'weight' property, which is a way for AuthChangeFormFields hook
+ // subscribers (who only see one field at a time) to influence ordering.
+ self::sortFormDescriptorFields( $formDescriptor );
+
+ return $formDescriptor;
+ }
+
+ /**
+ * Maps an authentication field configuration for a single field (as returned by
+ * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor.
+ * @param array $singleFieldInfo
+ * @param string $fieldName
+ * @return array
+ */
+ protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) {
+ $type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] );
+ $descriptor = [
+ 'type' => $type,
+ // Do not prefix input name with 'wp'. This is important for the redirect flow.
+ 'name' => $fieldName,
+ ];
+
+ if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) {
+ $descriptor['default'] = $singleFieldInfo['label']->plain();
+ } elseif ( $type !== 'submit' ) {
+ $descriptor += array_filter( [
+ // help-message is omitted as it is usually not really useful for a web interface
+ 'label-message' => self::getField( $singleFieldInfo, 'label' ),
+ ] );
+
+ if ( isset( $singleFieldInfo['options'] ) ) {
+ $descriptor['options'] = array_flip( array_map( function ( $message ) {
+ /** @var Message $message */
+ return $message->parse();
+ }, $singleFieldInfo['options'] ) );
+ }
+
+ if ( isset( $singleFieldInfo['value'] ) ) {
+ $descriptor['default'] = $singleFieldInfo['value'];
+ }
+
+ if ( empty( $singleFieldInfo['optional'] ) ) {
+ $descriptor['required'] = true;
+ }
+ }
+
+ return $descriptor;
+ }
+
+ /**
+ * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight
+ * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.)
+ * Keep order if weights are equal.
+ * @param array &$formDescriptor
+ * @return array
+ */
+ protected static function sortFormDescriptorFields( array &$formDescriptor ) {
+ $i = 0;
+ foreach ( $formDescriptor as &$field ) {
+ $field['__index'] = $i++;
+ }
+ uasort( $formDescriptor, function ( $first, $second ) {
+ return self::getField( $first, 'weight', 0 ) - self::getField( $second, 'weight', 0 )
+ ?: $first['__index'] - $second['__index'];
+ } );
+ foreach ( $formDescriptor as &$field ) {
+ unset( $field['__index'] );
+ }
+ }
+
+ /**
+ * Get an array value, or a default if it does not exist.
+ * @param array $array
+ * @param string $fieldName
+ * @param mixed $default
+ * @return mixed
+ */
+ protected static function getField( array $array, $fieldName, $default = null ) {
+ if ( array_key_exists( $fieldName, $array ) ) {
+ return $array[$fieldName];
+ } else {
+ return $default;
+ }
+ }
+
+ /**
+ * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types
+ * @param string $type
+ * @return string
+ * @throws \LogicException
+ */
+ protected static function mapFieldInfoTypeToFormDescriptorType( $type ) {
+ $map = [
+ 'string' => 'text',
+ 'password' => 'password',
+ 'select' => 'select',
+ 'checkbox' => 'check',
+ 'multiselect' => 'multiselect',
+ 'button' => 'submit',
+ 'hidden' => 'hidden',
+ 'null' => 'info',
+ ];
+ if ( !array_key_exists( $type, $map ) ) {
+ throw new \LogicException( 'invalid field type: ' . $type );
+ }
+ return $map[$type];
+ }
+}
diff --git a/www/wiki/includes/specialpage/ChangesListSpecialPage.php b/www/wiki/includes/specialpage/ChangesListSpecialPage.php
new file mode 100644
index 00000000..dd0dd92a
--- /dev/null
+++ b/www/wiki/includes/specialpage/ChangesListSpecialPage.php
@@ -0,0 +1,1613 @@
+<?php
+/**
+ * Special page which uses a ChangesList to show query results.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\Logger\LoggerFactory;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Special page which uses a ChangesList to show query results.
+ * @todo Way too many public functions, most of them should be protected
+ *
+ * @ingroup SpecialPage
+ */
+abstract class ChangesListSpecialPage extends SpecialPage {
+ /**
+ * Preference name for saved queries. Subclasses that use saved queries should override this.
+ * @var string
+ */
+ protected static $savedQueriesPreferenceName;
+
+ /** @var string */
+ protected $rcSubpage;
+
+ /** @var FormOptions */
+ protected $rcOptions;
+
+ /** @var array */
+ protected $customFilters;
+
+ // Order of both groups and filters is significant; first is top-most priority,
+ // descending from there.
+ // 'showHideSuffix' is a shortcut to and avoid spelling out
+ // details specific to subclasses here.
+ /**
+ * Definition information for the filters and their groups
+ *
+ * The value is $groupDefinition, a parameter to the ChangesListFilterGroup constructor.
+ * However, priority is dynamically added for the core groups, to ease maintenance.
+ *
+ * Groups are displayed to the user in the structured UI. However, if necessary,
+ * all of the filters in a group can be configured to only display on the
+ * unstuctured UI, in which case you don't need a group title. This is done in
+ * getFilterGroupDefinitionFromLegacyCustomFilters, for example.
+ *
+ * @var array $filterGroupDefinitions
+ */
+ private $filterGroupDefinitions;
+
+ // Same format as filterGroupDefinitions, but for a single group (reviewStatus)
+ // that is registered conditionally.
+ private $reviewStatusFilterGroupDefinition;
+
+ // Single filter registered conditionally
+ private $hideCategorizationFilterDefinition;
+
+ /**
+ * Filter groups, and their contained filters
+ * This is an associative array (with group name as key) of ChangesListFilterGroup objects.
+ *
+ * @var array $filterGroups
+ */
+ protected $filterGroups = [];
+
+ public function __construct( $name, $restriction ) {
+ parent::__construct( $name, $restriction );
+
+ $this->filterGroupDefinitions = [
+ [
+ 'name' => 'registration',
+ 'title' => 'rcfilters-filtergroup-registration',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hideliu',
+ // rcshowhideliu-show, rcshowhideliu-hide,
+ // wlshowhideliu
+ 'showHideSuffix' => 'showhideliu',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_user = 0';
+ },
+ 'isReplacedInStructuredUi' => true,
+
+ ],
+ [
+ 'name' => 'hideanons',
+ // rcshowhideanons-show, rcshowhideanons-hide,
+ // wlshowhideanons
+ 'showHideSuffix' => 'showhideanons',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_user != 0';
+ },
+ 'isReplacedInStructuredUi' => true,
+ ]
+ ],
+ ],
+
+ [
+ 'name' => 'userExpLevel',
+ 'title' => 'rcfilters-filtergroup-userExpLevel',
+ 'class' => ChangesListStringOptionsFilterGroup::class,
+ 'isFullCoverage' => true,
+ 'filters' => [
+ [
+ 'name' => 'unregistered',
+ 'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
+ 'description' => 'rcfilters-filter-user-experience-level-unregistered-description',
+ 'cssClassSuffix' => 'user-unregistered',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$rc->getAttribute( 'rc_user' );
+ }
+ ],
+ [
+ 'name' => 'registered',
+ 'label' => 'rcfilters-filter-user-experience-level-registered-label',
+ 'description' => 'rcfilters-filter-user-experience-level-registered-description',
+ 'cssClassSuffix' => 'user-registered',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_user' );
+ }
+ ],
+ [
+ 'name' => 'newcomer',
+ 'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
+ 'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
+ 'cssClassSuffix' => 'user-newcomer',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ $performer = $rc->getPerformer();
+ return $performer && $performer->isLoggedIn() &&
+ $performer->getExperienceLevel() === 'newcomer';
+ }
+ ],
+ [
+ 'name' => 'learner',
+ 'label' => 'rcfilters-filter-user-experience-level-learner-label',
+ 'description' => 'rcfilters-filter-user-experience-level-learner-description',
+ 'cssClassSuffix' => 'user-learner',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ $performer = $rc->getPerformer();
+ return $performer && $performer->isLoggedIn() &&
+ $performer->getExperienceLevel() === 'learner';
+ },
+ ],
+ [
+ 'name' => 'experienced',
+ 'label' => 'rcfilters-filter-user-experience-level-experienced-label',
+ 'description' => 'rcfilters-filter-user-experience-level-experienced-description',
+ 'cssClassSuffix' => 'user-experienced',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ $performer = $rc->getPerformer();
+ return $performer && $performer->isLoggedIn() &&
+ $performer->getExperienceLevel() === 'experienced';
+ },
+ ]
+ ],
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ 'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
+ ],
+
+ [
+ 'name' => 'authorship',
+ 'title' => 'rcfilters-filtergroup-authorship',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hidemyself',
+ 'label' => 'rcfilters-filter-editsbyself-label',
+ 'description' => 'rcfilters-filter-editsbyself-description',
+ // rcshowhidemine-show, rcshowhidemine-hide,
+ // wlshowhidemine
+ 'showHideSuffix' => 'showhidemine',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $user = $ctx->getUser();
+ $conds[] = 'rc_user_text != ' . $dbr->addQuotes( $user->getName() );
+ },
+ 'cssClassSuffix' => 'self',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $ctx->getUser()->equals( $rc->getPerformer() );
+ },
+ ],
+ [
+ 'name' => 'hidebyothers',
+ 'label' => 'rcfilters-filter-editsbyother-label',
+ 'description' => 'rcfilters-filter-editsbyother-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $user = $ctx->getUser();
+ $conds[] = 'rc_user_text = ' . $dbr->addQuotes( $user->getName() );
+ },
+ 'cssClassSuffix' => 'others',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$ctx->getUser()->equals( $rc->getPerformer() );
+ },
+ ]
+ ]
+ ],
+
+ [
+ 'name' => 'automated',
+ 'title' => 'rcfilters-filtergroup-automated',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hidebots',
+ 'label' => 'rcfilters-filter-bots-label',
+ 'description' => 'rcfilters-filter-bots-description',
+ // rcshowhidebots-show, rcshowhidebots-hide,
+ // wlshowhidebots
+ 'showHideSuffix' => 'showhidebots',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_bot = 0';
+ },
+ 'cssClassSuffix' => 'bot',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_bot' );
+ },
+ ],
+ [
+ 'name' => 'hidehumans',
+ 'label' => 'rcfilters-filter-humans-label',
+ 'description' => 'rcfilters-filter-humans-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_bot = 1';
+ },
+ 'cssClassSuffix' => 'human',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$rc->getAttribute( 'rc_bot' );
+ },
+ ]
+ ]
+ ],
+
+ // reviewStatus (conditional)
+
+ [
+ 'name' => 'significance',
+ 'title' => 'rcfilters-filtergroup-significance',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -6,
+ 'filters' => [
+ [
+ 'name' => 'hideminor',
+ 'label' => 'rcfilters-filter-minor-label',
+ 'description' => 'rcfilters-filter-minor-description',
+ // rcshowhideminor-show, rcshowhideminor-hide,
+ // wlshowhideminor
+ 'showHideSuffix' => 'showhideminor',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_minor = 0';
+ },
+ 'cssClassSuffix' => 'minor',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_minor' );
+ }
+ ],
+ [
+ 'name' => 'hidemajor',
+ 'label' => 'rcfilters-filter-major-label',
+ 'description' => 'rcfilters-filter-major-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_minor = 1';
+ },
+ 'cssClassSuffix' => 'major',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$rc->getAttribute( 'rc_minor' );
+ }
+ ]
+ ]
+ ],
+
+ [
+ 'name' => 'lastRevision',
+ 'title' => 'rcfilters-filtergroup-lastRevision',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -7,
+ 'filters' => [
+ [
+ 'name' => 'hidelastrevision',
+ 'label' => 'rcfilters-filter-lastrevision-label',
+ 'description' => 'rcfilters-filter-lastrevision-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds ) {
+ $conds[] = 'rc_this_oldid <> page_latest';
+ },
+ 'cssClassSuffix' => 'last',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
+ }
+ ],
+ [
+ 'name' => 'hidepreviousrevisions',
+ 'label' => 'rcfilters-filter-previousrevision-label',
+ 'description' => 'rcfilters-filter-previousrevision-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds ) {
+ $conds[] = 'rc_this_oldid = page_latest';
+ },
+ 'cssClassSuffix' => 'previous',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
+ }
+ ]
+ ]
+ ],
+
+ // With extensions, there can be change types that will not be hidden by any of these.
+ [
+ 'name' => 'changeType',
+ 'title' => 'rcfilters-filtergroup-changetype',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -8,
+ 'filters' => [
+ [
+ 'name' => 'hidepageedits',
+ 'label' => 'rcfilters-filter-pageedits-label',
+ 'description' => 'rcfilters-filter-pageedits-description',
+ 'default' => false,
+ 'priority' => -2,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
+ },
+ 'cssClassSuffix' => 'src-mw-edit',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
+ },
+ ],
+ [
+ 'name' => 'hidenewpages',
+ 'label' => 'rcfilters-filter-newpages-label',
+ 'description' => 'rcfilters-filter-newpages-description',
+ 'default' => false,
+ 'priority' => -3,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
+ },
+ 'cssClassSuffix' => 'src-mw-new',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
+ },
+ ],
+
+ // hidecategorization
+
+ [
+ 'name' => 'hidelog',
+ 'label' => 'rcfilters-filter-logactions-label',
+ 'description' => 'rcfilters-filter-logactions-description',
+ 'default' => false,
+ 'priority' => -5,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
+ },
+ 'cssClassSuffix' => 'src-mw-log',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
+ }
+ ],
+ ],
+ ],
+
+ ];
+
+ $this->reviewStatusFilterGroupDefinition = [
+ [
+ 'name' => 'reviewStatus',
+ 'title' => 'rcfilters-filtergroup-reviewstatus',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -5,
+ 'filters' => [
+ [
+ 'name' => 'hidepatrolled',
+ 'label' => 'rcfilters-filter-patrolled-label',
+ 'description' => 'rcfilters-filter-patrolled-description',
+ // rcshowhidepatr-show, rcshowhidepatr-hide
+ // wlshowhidepatr
+ 'showHideSuffix' => 'showhidepatr',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_patrolled = 0';
+ },
+ 'cssClassSuffix' => 'patrolled',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_patrolled' );
+ },
+ ],
+ [
+ 'name' => 'hideunpatrolled',
+ 'label' => 'rcfilters-filter-unpatrolled-label',
+ 'description' => 'rcfilters-filter-unpatrolled-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_patrolled = 1';
+ },
+ 'cssClassSuffix' => 'unpatrolled',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$rc->getAttribute( 'rc_patrolled' );
+ },
+ ],
+ ],
+ ]
+ ];
+
+ $this->hideCategorizationFilterDefinition = [
+ 'name' => 'hidecategorization',
+ 'label' => 'rcfilters-filter-categorization-label',
+ 'description' => 'rcfilters-filter-categorization-description',
+ // rcshowhidecategorization-show, rcshowhidecategorization-hide.
+ // wlshowhidecategorization
+ 'showHideSuffix' => 'showhidecategorization',
+ 'default' => false,
+ 'priority' => -4,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
+ },
+ 'cssClassSuffix' => 'src-mw-categorize',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
+ },
+ ];
+ }
+
+ /**
+ * Check if filters are in conflict and guaranteed to return no results.
+ *
+ * @return bool
+ */
+ protected function areFiltersInConflict() {
+ $opts = $this->getOptions();
+ /** @var ChangesListFilterGroup $group */
+ foreach ( $this->getFilterGroups() as $group ) {
+ if ( $group->getConflictingGroups() ) {
+ wfLogWarning(
+ $group->getName() .
+ " specifies conflicts with other groups but these are not supported yet."
+ );
+ }
+
+ /** @var ChangesListFilter $conflictingFilter */
+ foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
+ if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
+ return true;
+ }
+ }
+
+ /** @var ChangesListFilter $filter */
+ foreach ( $group->getFilters() as $filter ) {
+ /** @var ChangesListFilter $conflictingFilter */
+ foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
+ if (
+ $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
+ $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
+ ) {
+ return true;
+ }
+ }
+
+ }
+
+ }
+
+ return false;
+ }
+
+ /**
+ * Main execution point
+ *
+ * @param string $subpage
+ */
+ public function execute( $subpage ) {
+ $this->rcSubpage = $subpage;
+
+ $rows = $this->getRows();
+ $opts = $this->getOptions();
+ if ( $rows === false ) {
+ $rows = new FakeResultWrapper( [] );
+ }
+
+ // Used by Structured UI app to get results without MW chrome
+ if ( $this->getRequest()->getVal( 'action' ) === 'render' ) {
+ $this->getOutput()->setArticleBodyOnly( true );
+ }
+
+ // Used by "live update" and "view newest" to check
+ // if there's new changes with minimal data transfer
+ if ( $this->getRequest()->getBool( 'peek' ) ) {
+ $code = $rows->numRows() > 0 ? 200 : 204;
+ $this->getOutput()->setStatusCode( $code );
+ return;
+ }
+
+ $batch = new LinkBatch;
+ foreach ( $rows as $row ) {
+ $batch->add( NS_USER, $row->rc_user_text );
+ $batch->add( NS_USER_TALK, $row->rc_user_text );
+ $batch->add( $row->rc_namespace, $row->rc_title );
+ if ( $row->rc_source === RecentChange::SRC_LOG ) {
+ $formatter = LogFormatter::newFromRow( $row );
+ foreach ( $formatter->getPreloadTitles() as $title ) {
+ $batch->addObj( $title );
+ }
+ }
+ }
+ $batch->execute();
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->addModules();
+ $this->webOutput( $rows, $opts );
+
+ $rows->free();
+
+ if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) {
+ // Clean up any bad page entries for titles showing up in RC
+ DeferredUpdates::addUpdate( new WANCacheReapUpdate(
+ $this->getDB(),
+ LoggerFactory::getInstance( 'objectcache' )
+ ) );
+ }
+
+ $this->includeRcFiltersApp();
+ }
+
+ /**
+ * Include the modules and configuration for the RCFilters app.
+ * Conditional on the user having the feature enabled.
+ *
+ * If it is disabled, add a <body> class marking that
+ */
+ protected function includeRcFiltersApp() {
+ $out = $this->getOutput();
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $jsData = $this->getStructuredFilterJsData();
+
+ $messages = [];
+ foreach ( $jsData['messageKeys'] as $key ) {
+ $messages[$key] = $this->msg( $key )->plain();
+ }
+
+ $out->addBodyClasses( 'mw-rcfilters-enabled' );
+
+ $out->addHTML(
+ ResourceLoader::makeInlineScript(
+ ResourceLoader::makeMessageSetScript( $messages )
+ )
+ );
+
+ $experimentalStructuredChangeFilters =
+ $this->getConfig()->get( 'StructuredChangeFiltersEnableExperimentalViews' );
+
+ $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
+ $out->addJsConfigVars(
+ 'wgStructuredChangeFiltersEnableExperimentalViews',
+ $experimentalStructuredChangeFilters
+ );
+
+ $out->addJsConfigVars(
+ 'wgRCFiltersChangeTags',
+ $this->buildChangeTagList()
+ );
+ $out->addJsConfigVars(
+ 'StructuredChangeFiltersDisplayConfig',
+ [
+ 'maxDays' => (int)$this->getConfig()->get( 'RCMaxAge' ) / ( 24 * 3600 ), // Translate to days
+ 'limitArray' => $this->getConfig()->get( 'RCLinkLimits' ),
+ 'limitDefault' => $this->getDefaultLimit(),
+ 'daysArray' => $this->getConfig()->get( 'RCLinkDays' ),
+ 'daysDefault' => $this->getDefaultDays(),
+ ]
+ );
+
+ if ( static::$savedQueriesPreferenceName ) {
+ $savedQueries = FormatJson::decode(
+ $this->getUser()->getOption( static::$savedQueriesPreferenceName )
+ );
+ if ( $savedQueries && isset( $savedQueries->default ) ) {
+ // If there is a default saved query, show a loading spinner,
+ // since the frontend is going to reload the results
+ $out->addBodyClasses( 'mw-rcfilters-ui-loading' );
+ }
+ $out->addJsConfigVars(
+ 'wgStructuredChangeFiltersSavedQueriesPreferenceName',
+ static::$savedQueriesPreferenceName
+ );
+ }
+ } else {
+ $out->addBodyClasses( 'mw-rcfilters-disabled' );
+ }
+ }
+
+ /**
+ * Fetch the change tags list for the front end
+ *
+ * @return Array Tag data
+ */
+ protected function buildChangeTagList() {
+ $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
+ $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 );
+
+ // Hit counts disabled for perf reasons, see T169997
+ /*
+ $tagStats = ChangeTags::tagUsageStatistics();
+ $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats );
+
+ // Sort by hits
+ arsort( $tagHitCounts );
+ */
+ $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags );
+
+ // Build the list and data
+ $result = [];
+ foreach ( $tagHitCounts as $tagName => $hits ) {
+ if (
+ // Only get active tags
+ isset( $explicitlyDefinedTags[ $tagName ] ) ||
+ isset( $softwareActivatedTags[ $tagName ] )
+ ) {
+ // Parse description
+ $desc = ChangeTags::tagLongDescriptionMessage( $tagName, $this->getContext() );
+
+ $result[] = [
+ 'name' => $tagName,
+ 'label' => Sanitizer::stripAllTags(
+ ChangeTags::tagDescription( $tagName, $this->getContext() )
+ ),
+ 'description' => $desc ? Sanitizer::stripAllTags( $desc->parse() ) : '',
+ 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
+ 'hits' => $hits,
+ ];
+ }
+ }
+
+ // Instead of sorting by hit count (disabled, see above), sort by display name
+ usort( $result, function ( $a, $b ) {
+ return strcasecmp( $a['label'], $b['label'] );
+ } );
+
+ return $result;
+ }
+
+ /**
+ * Add the "no results" message to the output
+ */
+ protected function outputNoResults() {
+ $this->getOutput()->addHTML(
+ '<div class="mw-changeslist-empty">' .
+ $this->msg( 'recentchanges-noresult' )->parse() .
+ '</div>'
+ );
+ }
+
+ /**
+ * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
+ *
+ * @return bool|ResultWrapper Result or false
+ */
+ public function getRows() {
+ $opts = $this->getOptions();
+
+ $tables = [];
+ $fields = [];
+ $conds = [];
+ $query_options = [];
+ $join_conds = [];
+ $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
+
+ return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
+ }
+
+ /**
+ * Get the current FormOptions for this request
+ *
+ * @return FormOptions
+ */
+ public function getOptions() {
+ if ( $this->rcOptions === null ) {
+ $this->rcOptions = $this->setup( $this->rcSubpage );
+ }
+
+ return $this->rcOptions;
+ }
+
+ /**
+ * Register all filters and their groups (including those from hooks), plus handle
+ * conflicts and defaults.
+ *
+ * You might want to customize these in the same method, in subclasses. You can
+ * call getFilterGroup to access a group, and (on the group) getFilter to access a
+ * filter, then make necessary modfications to the filter or group (e.g. with
+ * setDefault).
+ */
+ protected function registerFilters() {
+ $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
+
+ // Make sure this is not being transcluded (we don't want to show this
+ // information to all users just because the user that saves the edit can
+ // patrol or is logged in)
+ if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
+ $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
+ }
+
+ $changeTypeGroup = $this->getFilterGroup( 'changeType' );
+
+ if ( $this->getConfig()->get( 'RCWatchCategoryMembership' ) ) {
+ $transformedHideCategorizationDef = $this->transformFilterDefinition(
+ $this->hideCategorizationFilterDefinition
+ );
+
+ $transformedHideCategorizationDef['group'] = $changeTypeGroup;
+
+ $hideCategorization = new ChangesListBooleanFilter(
+ $transformedHideCategorizationDef
+ );
+ }
+
+ Hooks::run( 'ChangesListSpecialPageStructuredFilters', [ $this ] );
+
+ $unstructuredGroupDefinition =
+ $this->getFilterGroupDefinitionFromLegacyCustomFilters(
+ $this->getCustomFilters()
+ );
+ $this->registerFiltersFromDefinitions( [ $unstructuredGroupDefinition ] );
+
+ $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
+ $registered = $userExperienceLevel->getFilter( 'registered' );
+ $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
+ $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
+ $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
+
+ $categoryFilter = $changeTypeGroup->getFilter( 'hidecategorization' );
+ $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
+ $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
+
+ $significanceTypeGroup = $this->getFilterGroup( 'significance' );
+ $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
+
+ // categoryFilter is conditional; see registerFilters
+ if ( $categoryFilter !== null ) {
+ $hideMinorFilter->conflictsWith(
+ $categoryFilter,
+ 'rcfilters-hideminor-conflicts-typeofchange-global',
+ 'rcfilters-hideminor-conflicts-typeofchange',
+ 'rcfilters-typeofchange-conflicts-hideminor'
+ );
+ }
+ $hideMinorFilter->conflictsWith(
+ $logactionsFilter,
+ 'rcfilters-hideminor-conflicts-typeofchange-global',
+ 'rcfilters-hideminor-conflicts-typeofchange',
+ 'rcfilters-typeofchange-conflicts-hideminor'
+ );
+ $hideMinorFilter->conflictsWith(
+ $pagecreationFilter,
+ 'rcfilters-hideminor-conflicts-typeofchange-global',
+ 'rcfilters-hideminor-conflicts-typeofchange',
+ 'rcfilters-typeofchange-conflicts-hideminor'
+ );
+ }
+
+ /**
+ * Transforms filter definition to prepare it for constructor.
+ *
+ * See overrides of this method as well.
+ *
+ * @param array $filterDefinition Original filter definition
+ *
+ * @return array Transformed definition
+ */
+ protected function transformFilterDefinition( array $filterDefinition ) {
+ return $filterDefinition;
+ }
+
+ /**
+ * Register filters from a definition object
+ *
+ * Array specifying groups and their filters; see Filter and
+ * ChangesListFilterGroup constructors.
+ *
+ * There is light processing to simplify core maintenance.
+ * @param array $definition
+ */
+ protected function registerFiltersFromDefinitions( array $definition ) {
+ $autoFillPriority = -1;
+ foreach ( $definition as $groupDefinition ) {
+ if ( !isset( $groupDefinition['priority'] ) ) {
+ $groupDefinition['priority'] = $autoFillPriority;
+ } else {
+ // If it's explicitly specified, start over the auto-fill
+ $autoFillPriority = $groupDefinition['priority'];
+ }
+
+ $autoFillPriority--;
+
+ $className = $groupDefinition['class'];
+ unset( $groupDefinition['class'] );
+
+ foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
+ $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
+ }
+
+ $this->registerFilterGroup( new $className( $groupDefinition ) );
+ }
+ }
+
+ /**
+ * Get filter group definition from legacy custom filters
+ *
+ * @param array $customFilters Custom filters from legacy hooks
+ * @return array Group definition
+ */
+ protected function getFilterGroupDefinitionFromLegacyCustomFilters( array $customFilters ) {
+ // Special internal unstructured group
+ $unstructuredGroupDefinition = [
+ 'name' => 'unstructured',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -1, // Won't display in structured
+ 'filters' => [],
+ ];
+
+ foreach ( $customFilters as $name => $params ) {
+ $unstructuredGroupDefinition['filters'][] = [
+ 'name' => $name,
+ 'showHide' => $params['msg'],
+ 'default' => $params['default'],
+ ];
+ }
+
+ return $unstructuredGroupDefinition;
+ }
+
+ /**
+ * Register all the filters, including legacy hook-driven ones.
+ * Then create a FormOptions object with options as specified by the user
+ *
+ * @param array $parameters
+ *
+ * @return FormOptions
+ */
+ public function setup( $parameters ) {
+ $this->registerFilters();
+
+ $opts = $this->getDefaultOptions();
+
+ $opts = $this->fetchOptionsFromRequest( $opts );
+
+ // Give precedence to subpage syntax
+ if ( $parameters !== null ) {
+ $this->parseParameters( $parameters, $opts );
+ }
+
+ $this->validateOptions( $opts );
+
+ return $opts;
+ }
+
+ /**
+ * Get a FormOptions object containing the default options. By default, returns
+ * some basic options. The filters listed explicitly here are overriden in this
+ * method, in subclasses, but most filters (e.g. hideminor, userExpLevel filters,
+ * and more) are structured. Structured filters are overriden in registerFilters.
+ * not here.
+ *
+ * @return FormOptions
+ */
+ public function getDefaultOptions() {
+ $opts = new FormOptions();
+ $structuredUI = $this->isStructuredFilterUiEnabled();
+ // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
+ $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
+
+ // Add all filters
+ /** @var ChangesListFilterGroup $filterGroup */
+ foreach ( $this->filterGroups as $filterGroup ) {
+ // URL parameters can be per-group, like 'userExpLevel',
+ // or per-filter, like 'hideminor'.
+ if ( $filterGroup->isPerGroupRequestParameter() ) {
+ $opts->add( $filterGroup->getName(), $useDefaults ? $filterGroup->getDefault() : '' );
+ } else {
+ /** @var ChangesListBooleanFilter $filter */
+ foreach ( $filterGroup->getFilters() as $filter ) {
+ $opts->add( $filter->getName(), $useDefaults ? $filter->getDefault( $structuredUI ) : false );
+ }
+ }
+ }
+
+ $opts->add( 'namespace', '', FormOptions::STRING );
+ $opts->add( 'invert', false );
+ $opts->add( 'associated', false );
+ $opts->add( 'urlversion', 1 );
+ $opts->add( 'tagfilter', '' );
+
+ return $opts;
+ }
+
+ /**
+ * Register a structured changes list filter group
+ *
+ * @param ChangesListFilterGroup $group
+ */
+ public function registerFilterGroup( ChangesListFilterGroup $group ) {
+ $groupName = $group->getName();
+
+ $this->filterGroups[$groupName] = $group;
+ }
+
+ /**
+ * Gets the currently registered filters groups
+ *
+ * @return array Associative array of ChangesListFilterGroup objects, with group name as key
+ */
+ protected function getFilterGroups() {
+ return $this->filterGroups;
+ }
+
+ /**
+ * Gets a specified ChangesListFilterGroup by name
+ *
+ * @param string $groupName Name of group
+ *
+ * @return ChangesListFilterGroup|null Group, or null if not registered
+ */
+ public function getFilterGroup( $groupName ) {
+ return isset( $this->filterGroups[$groupName] ) ?
+ $this->filterGroups[$groupName] :
+ null;
+ }
+
+ // Currently, this intentionally only includes filters that display
+ // in the structured UI. This can be changed easily, though, if we want
+ // to include data on filters that use the unstructured UI. messageKeys is a
+ // special top-level value, with the value being an array of the message keys to
+ // send to the client.
+ /**
+ * Gets structured filter information needed by JS
+ *
+ * @return array Associative array
+ * * array $return['groups'] Group data
+ * * array $return['messageKeys'] Array of message keys
+ */
+ public function getStructuredFilterJsData() {
+ $output = [
+ 'groups' => [],
+ 'messageKeys' => [],
+ ];
+
+ usort( $this->filterGroups, function ( $a, $b ) {
+ return $b->getPriority() - $a->getPriority();
+ } );
+
+ foreach ( $this->filterGroups as $groupName => $group ) {
+ $groupOutput = $group->getJsData( $this );
+ if ( $groupOutput !== null ) {
+ $output['messageKeys'] = array_merge(
+ $output['messageKeys'],
+ $groupOutput['messageKeys']
+ );
+
+ unset( $groupOutput['messageKeys'] );
+ $output['groups'][] = $groupOutput;
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Get custom show/hide filters using deprecated ChangesListSpecialPageFilters
+ * hook.
+ *
+ * @return array Map of filter URL param names to properties (msg/default)
+ */
+ protected function getCustomFilters() {
+ if ( $this->customFilters === null ) {
+ $this->customFilters = [];
+ Hooks::run( 'ChangesListSpecialPageFilters', [ $this, &$this->customFilters ], '1.29' );
+ }
+
+ return $this->customFilters;
+ }
+
+ /**
+ * Fetch values for a FormOptions object from the WebRequest associated with this instance.
+ *
+ * Intended for subclassing, e.g. to add a backwards-compatibility layer.
+ *
+ * @param FormOptions $opts
+ * @return FormOptions
+ */
+ protected function fetchOptionsFromRequest( $opts ) {
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+
+ return $opts;
+ }
+
+ /**
+ * Process $par and put options found in $opts. Used when including the page.
+ *
+ * @param string $par
+ * @param FormOptions $opts
+ */
+ public function parseParameters( $par, FormOptions $opts ) {
+ $stringParameterNameSet = [];
+ $hideParameterNameSet = [];
+
+ // URL parameters can be per-group, like 'userExpLevel',
+ // or per-filter, like 'hideminor'.
+
+ foreach ( $this->filterGroups as $filterGroup ) {
+ if ( $filterGroup->isPerGroupRequestParameter() ) {
+ $stringParameterNameSet[$filterGroup->getName()] = true;
+ } elseif ( $filterGroup->getType() === ChangesListBooleanFilterGroup::TYPE ) {
+ foreach ( $filterGroup->getFilters() as $filter ) {
+ $hideParameterNameSet[$filter->getName()] = true;
+ }
+ }
+ }
+
+ $bits = preg_split( '/\s*,\s*/', trim( $par ) );
+ foreach ( $bits as $bit ) {
+ $m = [];
+ if ( isset( $hideParameterNameSet[$bit] ) ) {
+ // hidefoo => hidefoo=true
+ $opts[$bit] = true;
+ } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
+ // foo => hidefoo=false
+ $opts["hide$bit"] = false;
+ } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
+ if ( isset( $stringParameterNameSet[$m[1]] ) ) {
+ $opts[$m[1]] = $m[2];
+ }
+ }
+ }
+ }
+
+ /**
+ * Validate a FormOptions object generated by getDefaultOptions() with values already populated.
+ *
+ * @param FormOptions $opts
+ */
+ public function validateOptions( FormOptions $opts ) {
+ if ( $this->fixContradictoryOptions( $opts ) ) {
+ $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
+ $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
+ }
+ }
+
+ /**
+ * Fix invalid options by resetting pairs that should never appear together.
+ *
+ * @param FormOptions $opts
+ * @return bool True if any option was reset
+ */
+ private function fixContradictoryOptions( FormOptions $opts ) {
+ $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
+
+ foreach ( $this->filterGroups as $filterGroup ) {
+ if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
+ $filters = $filterGroup->getFilters();
+
+ if ( count( $filters ) === 1 ) {
+ // legacy boolean filters should not be considered
+ continue;
+ }
+
+ $allInGroupEnabled = array_reduce(
+ $filters,
+ function ( $carry, $filter ) use ( $opts ) {
+ return $carry && $opts[ $filter->getName() ];
+ },
+ /* initialValue */ count( $filters ) > 0
+ );
+
+ if ( $allInGroupEnabled ) {
+ foreach ( $filters as $filter ) {
+ $opts[ $filter->getName() ] = false;
+ }
+
+ $fixed = true;
+ }
+ }
+ }
+
+ return $fixed;
+ }
+
+ /**
+ * Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards
+ * compatibility.
+ *
+ * This is deprecated and may be removed.
+ *
+ * @param FormOptions $opts
+ * @return bool True if this change was mode
+ */
+ private function fixBackwardsCompatibilityOptions( FormOptions $opts ) {
+ if ( $opts['hideanons'] && $opts['hideliu'] ) {
+ $opts->reset( 'hideanons' );
+ if ( !$opts['hidebots'] ) {
+ $opts->reset( 'hideliu' );
+ $opts['hidehumans'] = 1;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Convert parameters values from true/false to 1/0
+ * so they are not omitted by wfArrayToCgi()
+ * Bug 36524
+ *
+ * @param array $params
+ * @return array
+ */
+ protected function convertParamsForLink( $params ) {
+ foreach ( $params as &$value ) {
+ if ( $value === false ) {
+ $value = '0';
+ }
+ }
+ unset( $value );
+ return $params;
+ }
+
+ /**
+ * Sets appropriate tables, fields, conditions, etc. depending on which filters
+ * the user requested.
+ *
+ * @param array &$tables Array of tables; see IDatabase::select $table
+ * @param array &$fields Array of fields; see IDatabase::select $vars
+ * @param array &$conds Array of conditions; see IDatabase::select $conds
+ * @param array &$query_options Array of query options; see IDatabase::select $options
+ * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+ * @param FormOptions $opts
+ */
+ protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
+ &$join_conds, FormOptions $opts
+ ) {
+ $dbr = $this->getDB();
+ $isStructuredUI = $this->isStructuredFilterUiEnabled();
+
+ foreach ( $this->filterGroups as $filterGroup ) {
+ // URL parameters can be per-group, like 'userExpLevel',
+ // or per-filter, like 'hideminor'.
+ if ( $filterGroup->isPerGroupRequestParameter() ) {
+ $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
+ $query_options, $join_conds, $opts[$filterGroup->getName()] );
+ } else {
+ foreach ( $filterGroup->getFilters() as $filter ) {
+ if ( $filter->isActive( $opts, $isStructuredUI ) ) {
+ $filter->modifyQuery( $dbr, $this, $tables, $fields, $conds,
+ $query_options, $join_conds );
+ }
+ }
+ }
+ }
+
+ // Namespace filtering
+ if ( $opts[ 'namespace' ] !== '' ) {
+ $namespaces = explode( ';', $opts[ 'namespace' ] );
+
+ if ( $opts[ 'associated' ] ) {
+ $associatedNamespaces = array_map(
+ function ( $ns ) {
+ return MWNamespace::getAssociated( $ns );
+ },
+ $namespaces
+ );
+ $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
+ }
+
+ if ( count( $namespaces ) === 1 ) {
+ $operator = $opts[ 'invert' ] ? '!=' : '=';
+ $value = $dbr->addQuotes( reset( $namespaces ) );
+ } else {
+ $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN';
+ sort( $namespaces );
+ $value = '(' . $dbr->makeList( $namespaces ) . ')';
+ }
+ $conds[] = "rc_namespace $operator $value";
+ }
+ }
+
+ /**
+ * Process the query
+ *
+ * @param array $tables Array of tables; see IDatabase::select $table
+ * @param array $fields Array of fields; see IDatabase::select $vars
+ * @param array $conds Array of conditions; see IDatabase::select $conds
+ * @param array $query_options Array of query options; see IDatabase::select $options
+ * @param array $join_conds Array of join conditions; see IDatabase::select $join_conds
+ * @param FormOptions $opts
+ * @return bool|ResultWrapper Result or false
+ */
+ protected function doMainQuery( $tables, $fields, $conds,
+ $query_options, $join_conds, FormOptions $opts
+ ) {
+ $tables[] = 'recentchanges';
+ $fields = array_merge( RecentChange::selectFields(), $fields );
+
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $fields,
+ $conds,
+ $join_conds,
+ $query_options,
+ ''
+ );
+
+ if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
+ $opts )
+ ) {
+ return false;
+ }
+
+ $dbr = $this->getDB();
+
+ return $dbr->select(
+ $tables,
+ $fields,
+ $conds,
+ __METHOD__,
+ $query_options,
+ $join_conds
+ );
+ }
+
+ protected function runMainQueryHook( &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds, $opts
+ ) {
+ return Hooks::run(
+ 'ChangesListSpecialPageQuery',
+ [ $this->getName(), &$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts ]
+ );
+ }
+
+ /**
+ * Return a IDatabase object for reading
+ *
+ * @return IDatabase
+ */
+ protected function getDB() {
+ return wfGetDB( DB_REPLICA );
+ }
+
+ /**
+ * Send output to the OutputPage object, only called if not used feeds
+ *
+ * @param ResultWrapper $rows Database rows
+ * @param FormOptions $opts
+ */
+ public function webOutput( $rows, $opts ) {
+ if ( !$this->including() ) {
+ $this->outputFeedLinks();
+ $this->doHeader( $opts, $rows->numRows() );
+ }
+
+ $this->outputChangesList( $rows, $opts );
+ }
+
+ /**
+ * Output feed links.
+ */
+ public function outputFeedLinks() {
+ // nothing by default
+ }
+
+ /**
+ * Build and output the actual changes list.
+ *
+ * @param ResultWrapper $rows Database rows
+ * @param FormOptions $opts
+ */
+ abstract public function outputChangesList( $rows, $opts );
+
+ /**
+ * Set the text to be displayed above the changes
+ *
+ * @param FormOptions $opts
+ * @param int $numRows Number of rows in the result to show after this header
+ */
+ public function doHeader( $opts, $numRows ) {
+ $this->setTopText( $opts );
+
+ // @todo Lots of stuff should be done here.
+
+ $this->setBottomText( $opts );
+ }
+
+ /**
+ * Send the text to be displayed before the options. Should use $this->getOutput()->addWikiText()
+ * or similar methods to print the text.
+ *
+ * @param FormOptions $opts
+ */
+ public function setTopText( FormOptions $opts ) {
+ // nothing by default
+ }
+
+ /**
+ * Send the text to be displayed after the options. Should use $this->getOutput()->addWikiText()
+ * or similar methods to print the text.
+ *
+ * @param FormOptions $opts
+ */
+ public function setBottomText( FormOptions $opts ) {
+ // nothing by default
+ }
+
+ /**
+ * Get options to be displayed in a form
+ * @todo This should handle options returned by getDefaultOptions().
+ * @todo Not called by anything in this class (but is in subclasses), should be
+ * called by something… doHeader() maybe?
+ *
+ * @param FormOptions $opts
+ * @return array
+ */
+ public function getExtraOptions( $opts ) {
+ return [];
+ }
+
+ /**
+ * Return the legend displayed within the fieldset
+ *
+ * @return string
+ */
+ public function makeLegend() {
+ $context = $this->getContext();
+ $user = $context->getUser();
+ # The legend showing what the letters and stuff mean
+ $legend = Html::openElement( 'dl' ) . "\n";
+ # Iterates through them and gets the messages for both letter and tooltip
+ $legendItems = $context->getConfig()->get( 'RecentChangesFlags' );
+ if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
+ unset( $legendItems['unpatrolled'] );
+ }
+ foreach ( $legendItems as $key => $item ) { # generate items of the legend
+ $label = isset( $item['legend'] ) ? $item['legend'] : $item['title'];
+ $letter = $item['letter'];
+ $cssClass = isset( $item['class'] ) ? $item['class'] : $key;
+
+ $legend .= Html::element( 'dt',
+ [ 'class' => $cssClass ], $context->msg( $letter )->text()
+ ) . "\n" .
+ Html::rawElement( 'dd',
+ [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
+ $context->msg( $label )->parse()
+ ) . "\n";
+ }
+ # (+-123)
+ $legend .= Html::rawElement( 'dt',
+ [ 'class' => 'mw-plusminus-pos' ],
+ $context->msg( 'recentchanges-legend-plusminus' )->parse()
+ ) . "\n";
+ $legend .= Html::element(
+ 'dd',
+ [ 'class' => 'mw-changeslist-legend-plusminus' ],
+ $context->msg( 'recentchanges-label-plusminus' )->text()
+ ) . "\n";
+ $legend .= Html::closeElement( 'dl' ) . "\n";
+
+ $legendHeading = $this->isStructuredFilterUiEnabled() ?
+ $context->msg( 'rcfilters-legend-heading' )->parse() :
+ $context->msg( 'recentchanges-legend-heading' )->parse();
+
+ # Collapsible
+ $legend =
+ '<div class="mw-changeslist-legend">' .
+ $legendHeading .
+ '<div class="mw-collapsible-content">' . $legend . '</div>' .
+ '</div>';
+
+ return $legend;
+ }
+
+ /**
+ * Add page-specific modules.
+ */
+ protected function addModules() {
+ $out = $this->getOutput();
+ // Styles and behavior for the legend box (see makeLegend())
+ $out->addModuleStyles( [
+ 'mediawiki.special.changeslist.legend',
+ 'mediawiki.special.changeslist',
+ ] );
+ $out->addModules( 'mediawiki.special.changeslist.legend.js' );
+
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $out->addModules( 'mediawiki.rcfilters.filters.ui' );
+ $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
+ }
+ }
+
+ protected function getGroupName() {
+ return 'changes';
+ }
+
+ /**
+ * Filter on users' experience levels; this will not be called if nothing is
+ * selected.
+ *
+ * @param string $specialPageClassName Class name of current special page
+ * @param IContextSource $context Context, for e.g. user
+ * @param IDatabase $dbr Database, for addQuotes, makeList, and similar
+ * @param array &$tables Array of tables; see IDatabase::select $table
+ * @param array &$fields Array of fields; see IDatabase::select $vars
+ * @param array &$conds Array of conditions; see IDatabase::select $conds
+ * @param array &$query_options Array of query options; see IDatabase::select $options
+ * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+ * @param array $selectedExpLevels The allowed active values, sorted
+ * @param int $now Number of seconds since the UNIX epoch, or 0 if not given
+ * (optional)
+ */
+ public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
+ ) {
+ global $wgLearnerEdits,
+ $wgExperiencedUserEdits,
+ $wgLearnerMemberSince,
+ $wgExperiencedUserMemberSince;
+
+ $LEVEL_COUNT = 5;
+
+ // If all levels are selected, don't filter
+ if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
+ return;
+ }
+
+ // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered'
+ if (
+ in_array( 'registered', $selectedExpLevels ) &&
+ in_array( 'unregistered', $selectedExpLevels )
+ ) {
+ return;
+ }
+
+ // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered'
+ if (
+ in_array( 'registered', $selectedExpLevels ) &&
+ !in_array( 'unregistered', $selectedExpLevels )
+ ) {
+ $conds[] = 'rc_user != 0';
+ return;
+ }
+
+ if ( $selectedExpLevels === [ 'unregistered' ] ) {
+ $conds[] = 'rc_user = 0';
+ return;
+ }
+
+ $tables[] = 'user';
+ $join_conds['user'] = [ 'LEFT JOIN', 'rc_user = user_id' ];
+
+ if ( $now === 0 ) {
+ $now = time();
+ }
+ $secondsPerDay = 86400;
+ $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay;
+ $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay;
+
+ $aboveNewcomer = $dbr->makeList(
+ [
+ 'user_editcount >= ' . intval( $wgLearnerEdits ),
+ 'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ),
+ ],
+ IDatabase::LIST_AND
+ );
+
+ $aboveLearner = $dbr->makeList(
+ [
+ 'user_editcount >= ' . intval( $wgExperiencedUserEdits ),
+ 'user_registration <= ' .
+ $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ),
+ ],
+ IDatabase::LIST_AND
+ );
+
+ $conditions = [];
+
+ if ( in_array( 'unregistered', $selectedExpLevels ) ) {
+ $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] );
+ $conditions[] = 'rc_user = 0';
+ }
+
+ if ( $selectedExpLevels === [ 'newcomer' ] ) {
+ $conditions[] = "NOT ( $aboveNewcomer )";
+ } elseif ( $selectedExpLevels === [ 'learner' ] ) {
+ $conditions[] = $dbr->makeList(
+ [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
+ IDatabase::LIST_AND
+ );
+ } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
+ $conditions[] = $aboveLearner;
+ } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
+ $conditions[] = "NOT ( $aboveLearner )";
+ } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
+ $conditions[] = $dbr->makeList(
+ [ "NOT ( $aboveNewcomer )", $aboveLearner ],
+ IDatabase::LIST_OR
+ );
+ } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
+ $conditions[] = $aboveNewcomer;
+ } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) {
+ $conditions[] = 'rc_user != 0';
+ }
+
+ if ( count( $conditions ) > 1 ) {
+ $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR );
+ } elseif ( count( $conditions ) === 1 ) {
+ $conds[] = reset( $conditions );
+ }
+ }
+
+ /**
+ * Check whether the structured filter UI is enabled
+ *
+ * @return bool
+ */
+ public function isStructuredFilterUiEnabled() {
+ if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
+ return true;
+ }
+
+ if ( $this->getConfig()->get( 'StructuredChangeFiltersShowPreference' ) ) {
+ return !$this->getUser()->getOption( 'rcenhancedfilters-disable' );
+ } else {
+ return $this->getUser()->getOption( 'rcenhancedfilters' );
+ }
+ }
+
+ /**
+ * Check whether the structured filter UI is enabled by default (regardless of
+ * this particular user's setting)
+ *
+ * @return bool
+ */
+ public function isStructuredFilterUiEnabledByDefault() {
+ if ( $this->getConfig()->get( 'StructuredChangeFiltersShowPreference' ) ) {
+ return !$this->getUser()->getDefaultOption( 'rcenhancedfilters-disable' );
+ } else {
+ return $this->getUser()->getDefaultOption( 'rcenhancedfilters' );
+ }
+ }
+
+ abstract function getDefaultLimit();
+
+ /**
+ * Get the default value of the number of days to display when loading
+ * the result set.
+ * Supports fractional values, and should be cast to a float.
+ *
+ * @return float
+ */
+ abstract function getDefaultDays();
+}
diff --git a/www/wiki/includes/specialpage/FormSpecialPage.php b/www/wiki/includes/specialpage/FormSpecialPage.php
new file mode 100644
index 00000000..66c7d47e
--- /dev/null
+++ b/www/wiki/includes/specialpage/FormSpecialPage.php
@@ -0,0 +1,202 @@
+<?php
+/**
+ * Special page which uses an HTMLForm to handle processing.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page which uses an HTMLForm to handle processing. This is mostly a
+ * clone of FormAction. More special pages should be built this way; maybe this could be
+ * a new structure for SpecialPages.
+ *
+ * @ingroup SpecialPage
+ */
+abstract class FormSpecialPage extends SpecialPage {
+ /**
+ * The sub-page of the special page.
+ * @var string
+ */
+ protected $par = null;
+
+ /**
+ * Get an HTMLForm descriptor array
+ * @return array
+ */
+ abstract protected function getFormFields();
+
+ /**
+ * Add pre-text to the form
+ * @return string HTML which will be sent to $form->addPreText()
+ */
+ protected function preText() {
+ return '';
+ }
+
+ /**
+ * Add post-text to the form
+ * @return string HTML which will be sent to $form->addPostText()
+ */
+ protected function postText() {
+ return '';
+ }
+
+ /**
+ * Play with the HTMLForm if you need to more substantially
+ * @param HTMLForm $form
+ */
+ protected function alterForm( HTMLForm $form ) {
+ }
+
+ /**
+ * Get message prefix for HTMLForm
+ *
+ * @since 1.21
+ * @return string
+ */
+ protected function getMessagePrefix() {
+ return strtolower( $this->getName() );
+ }
+
+ /**
+ * Get display format for the form. See HTMLForm documentation for available values.
+ *
+ * @since 1.25
+ * @return string
+ */
+ protected function getDisplayFormat() {
+ return 'table';
+ }
+
+ /**
+ * Get the HTMLForm to control behavior
+ * @return HTMLForm|null
+ */
+ protected function getForm() {
+ $form = HTMLForm::factory(
+ $this->getDisplayFormat(),
+ $this->getFormFields(),
+ $this->getContext(),
+ $this->getMessagePrefix()
+ );
+ $form->setSubmitCallback( [ $this, 'onSubmit' ] );
+ if ( $this->getDisplayFormat() !== 'ooui' ) {
+ // No legend and wrapper by default in OOUI forms, but can be set manually
+ // from alterForm()
+ $form->setWrapperLegendMsg( $this->getMessagePrefix() . '-legend' );
+ }
+
+ $headerMsg = $this->msg( $this->getMessagePrefix() . '-text' );
+ if ( !$headerMsg->isDisabled() ) {
+ $form->addHeaderText( $headerMsg->parseAsBlock() );
+ }
+
+ $form->addPreText( $this->preText() );
+ $form->addPostText( $this->postText() );
+ $this->alterForm( $form );
+ if ( $form->getMethod() == 'post' ) {
+ // Retain query parameters (uselang etc) on POST requests
+ $params = array_diff_key(
+ $this->getRequest()->getQueryValues(), [ 'title' => null ] );
+ $form->addHiddenField( 'redirectparams', wfArrayToCgi( $params ) );
+ }
+
+ // Give hooks a chance to alter the form, adding extra fields or text etc
+ Hooks::run( 'SpecialPageBeforeFormDisplay', [ $this->getName(), &$form ] );
+
+ return $form;
+ }
+
+ /**
+ * Process the form on POST submission.
+ * @param array $data
+ * @param HTMLForm $form
+ * @return bool|string|array|Status As documented for HTMLForm::trySubmit.
+ */
+ abstract public function onSubmit( array $data /* $form = null */ );
+
+ /**
+ * Do something exciting on successful processing of the form, most likely to show a
+ * confirmation message
+ * @since 1.22 Default is to do nothing
+ */
+ public function onSuccess() {
+ }
+
+ /**
+ * Basic SpecialPage workflow: get a form, send it to the user; get some data back,
+ *
+ * @param string $par Subpage string if one was specified
+ */
+ public function execute( $par ) {
+ $this->setParameter( $par );
+ $this->setHeaders();
+
+ // This will throw exceptions if there's a problem
+ $this->checkExecutePermissions( $this->getUser() );
+
+ $form = $this->getForm();
+ if ( $form->show() ) {
+ $this->onSuccess();
+ }
+ }
+
+ /**
+ * Maybe do something interesting with the subpage parameter
+ * @param string $par
+ */
+ protected function setParameter( $par ) {
+ $this->par = $par;
+ }
+
+ /**
+ * Called from execute() to check if the given user can perform this action.
+ * Failures here must throw subclasses of ErrorPageError.
+ * @param User $user
+ * @throws UserBlockedError
+ */
+ protected function checkExecutePermissions( User $user ) {
+ $this->checkPermissions();
+
+ if ( $this->requiresUnblock() && $user->isBlocked() ) {
+ $block = $user->getBlock();
+ throw new UserBlockedError( $block );
+ }
+
+ if ( $this->requiresWrite() ) {
+ $this->checkReadOnly();
+ }
+ }
+
+ /**
+ * Whether this action requires the wiki not to be locked
+ * @return bool
+ */
+ public function requiresWrite() {
+ return true;
+ }
+
+ /**
+ * Whether this action cannot be executed by a blocked user
+ * @return bool
+ */
+ public function requiresUnblock() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/specialpage/ImageQueryPage.php b/www/wiki/includes/specialpage/ImageQueryPage.php
new file mode 100644
index 00000000..59abefd8
--- /dev/null
+++ b/www/wiki/includes/specialpage/ImageQueryPage.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * Variant of QueryPage which uses a gallery to output results.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Variant of QueryPage which uses a gallery to output results, thus
+ * suited for reports generating images
+ *
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>
+ */
+abstract class ImageQueryPage extends QueryPage {
+ /**
+ * Format and output report results using the given information plus
+ * OutputPage
+ *
+ * @param OutputPage $out OutputPage to print to
+ * @param Skin $skin User skin to use [unused]
+ * @param IDatabase $dbr (read) connection to use
+ * @param ResultWrapper $res Result pointer
+ * @param int $num Number of available result rows
+ * @param int $offset Paging offset
+ */
+ protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
+ if ( $num > 0 ) {
+ $gallery = ImageGalleryBase::factory( false, $this->getContext() );
+
+ # $res might contain the whole 1,000 rows, so we read up to
+ # $num [should update this to use a Pager]
+ $i = 0;
+ foreach ( $res as $row ) {
+ $i++;
+ $namespace = isset( $row->namespace ) ? $row->namespace : NS_FILE;
+ $title = Title::makeTitleSafe( $namespace, $row->title );
+ if ( $title instanceof Title && $title->getNamespace() == NS_FILE ) {
+ $gallery->add( $title, $this->getCellHtml( $row ) );
+ }
+ if ( $i === $num ) {
+ break;
+ }
+ }
+
+ $out->addHTML( $gallery->toHTML() );
+ }
+ }
+
+ // Gotta override this since it's abstract
+ function formatResult( $skin, $result ) {
+ }
+
+ /**
+ * Get additional HTML to be shown in a results' cell
+ *
+ * @param object $row Result row
+ * @return string
+ */
+ protected function getCellHtml( $row ) {
+ return '';
+ }
+}
diff --git a/www/wiki/includes/specialpage/IncludableSpecialPage.php b/www/wiki/includes/specialpage/IncludableSpecialPage.php
new file mode 100644
index 00000000..2f7f69ce
--- /dev/null
+++ b/www/wiki/includes/specialpage/IncludableSpecialPage.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Shortcut to construct an includable special page.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Shortcut to construct an includable special page.
+ *
+ * @ingroup SpecialPage
+ */
+class IncludableSpecialPage extends SpecialPage {
+ function __construct(
+ $name, $restriction = '', $listed = true, $function = false, $file = 'default'
+ ) {
+ parent::__construct( $name, $restriction, $listed, $function, $file, true );
+ }
+
+ public function isIncludable() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/specialpage/LoginSignupSpecialPage.php b/www/wiki/includes/specialpage/LoginSignupSpecialPage.php
new file mode 100644
index 00000000..04d391b3
--- /dev/null
+++ b/www/wiki/includes/specialpage/LoginSignupSpecialPage.php
@@ -0,0 +1,1599 @@
+<?php
+/**
+ * Holds shared logic for login and account creation pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\Throttler;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Session\SessionManager;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Holds shared logic for login and account creation pages.
+ *
+ * @ingroup SpecialPage
+ */
+abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
+ protected $mReturnTo;
+ protected $mPosted;
+ protected $mAction;
+ protected $mLanguage;
+ protected $mReturnToQuery;
+ protected $mToken;
+ protected $mStickHTTPS;
+ protected $mFromHTTP;
+ protected $mEntryError = '';
+ protected $mEntryErrorType = 'error';
+
+ protected $mLoaded = false;
+ protected $mLoadedRequest = false;
+ protected $mSecureLoginUrl;
+
+ /** @var string */
+ protected $securityLevel;
+
+ /** @var bool True if the user if creating an account for someone else. Flag used for internal
+ * communication, only set at the very end. */
+ protected $proxyAccountCreation;
+ /** @var User FIXME another flag for passing data. */
+ protected $targetUser;
+
+ /** @var HTMLForm */
+ protected $authForm;
+
+ /** @var FakeAuthTemplate */
+ protected $fakeTemplate;
+
+ abstract protected function isSignup();
+
+ /**
+ * @param bool $direct True if the action was successful just now; false if that happened
+ * pre-redirection (so this handler was called already)
+ * @param StatusValue|null $extraMessages
+ * @return void
+ */
+ abstract protected function successfulAction( $direct = false, $extraMessages = null );
+
+ /**
+ * Logs to the authmanager-stats channel.
+ * @param bool $success
+ * @param string|null $status Error message key
+ */
+ abstract protected function logAuthResult( $success, $status = null );
+
+ public function __construct( $name ) {
+ global $wgUseMediaWikiUIEverywhere;
+ parent::__construct( $name );
+
+ // Override UseMediaWikiEverywhere to true, to force login and create form to use mw ui
+ $wgUseMediaWikiUIEverywhere = true;
+ }
+
+ protected function setRequest( array $data, $wasPosted = null ) {
+ parent::setRequest( $data, $wasPosted );
+ $this->mLoadedRequest = false;
+ }
+
+ /**
+ * Load basic request parameters for this Special page.
+ * @param string $subPage
+ */
+ private function loadRequestParameters( $subPage ) {
+ if ( $this->mLoadedRequest ) {
+ return;
+ }
+ $this->mLoadedRequest = true;
+ $request = $this->getRequest();
+
+ $this->mPosted = $request->wasPosted();
+ $this->mIsReturn = $subPage === 'return';
+ $this->mAction = $request->getVal( 'action' );
+ $this->mFromHTTP = $request->getBool( 'fromhttp', false )
+ || $request->getBool( 'wpFromhttp', false );
+ $this->mStickHTTPS = ( !$this->mFromHTTP && $request->getProtocol() === 'https' )
+ || $request->getBool( 'wpForceHttps', false );
+ $this->mLanguage = $request->getText( 'uselang' );
+ $this->mReturnTo = $request->getVal( 'returnto', '' );
+ $this->mReturnToQuery = $request->getVal( 'returntoquery', '' );
+ }
+
+ /**
+ * Load data from request.
+ * @private
+ * @param string $subPage Subpage of Special:Userlogin
+ */
+ protected function load( $subPage ) {
+ global $wgSecureLogin;
+
+ $this->loadRequestParameters( $subPage );
+ if ( $this->mLoaded ) {
+ return;
+ }
+ $this->mLoaded = true;
+ $request = $this->getRequest();
+
+ $securityLevel = $this->getRequest()->getText( 'force' );
+ if (
+ $securityLevel && AuthManager::singleton()->securitySensitiveOperationStatus(
+ $securityLevel ) === AuthManager::SEC_REAUTH
+ ) {
+ $this->securityLevel = $securityLevel;
+ }
+
+ $this->loadAuth( $subPage );
+
+ $this->mToken = $request->getVal( $this->getTokenName() );
+
+ // Show an error or warning passed on from a previous page
+ $entryError = $this->msg( $request->getVal( 'error', '' ) );
+ $entryWarning = $this->msg( $request->getVal( 'warning', '' ) );
+ // bc: provide login link as a parameter for messages where the translation
+ // was not updated
+ $loginreqlink = $this->getLinkRenderer()->makeKnownLink(
+ $this->getPageTitle(),
+ $this->msg( 'loginreqlink' )->text(),
+ [],
+ [
+ 'returnto' => $this->mReturnTo,
+ 'returntoquery' => $this->mReturnToQuery,
+ 'uselang' => $this->mLanguage ?: null,
+ 'fromhttp' => $wgSecureLogin && $this->mFromHTTP ? '1' : null,
+ ]
+ );
+
+ // Only show valid error or warning messages.
+ if ( $entryError->exists()
+ && in_array( $entryError->getKey(), LoginHelper::getValidErrorMessages(), true )
+ ) {
+ $this->mEntryErrorType = 'error';
+ $this->mEntryError = $entryError->rawParams( $loginreqlink )->parse();
+
+ } elseif ( $entryWarning->exists()
+ && in_array( $entryWarning->getKey(), LoginHelper::getValidErrorMessages(), true )
+ ) {
+ $this->mEntryErrorType = 'warning';
+ $this->mEntryError = $entryWarning->rawParams( $loginreqlink )->parse();
+ }
+
+ # 1. When switching accounts, it sucks to get automatically logged out
+ # 2. Do not return to PasswordReset after a successful password change
+ # but goto Wiki start page (Main_Page) instead ( T35997 )
+ $returnToTitle = Title::newFromText( $this->mReturnTo );
+ if ( is_object( $returnToTitle )
+ && ( $returnToTitle->isSpecial( 'Userlogout' )
+ || $returnToTitle->isSpecial( 'PasswordReset' ) )
+ ) {
+ $this->mReturnTo = '';
+ $this->mReturnToQuery = '';
+ }
+ }
+
+ protected function getPreservedParams( $withToken = false ) {
+ global $wgSecureLogin;
+
+ $params = parent::getPreservedParams( $withToken );
+ $params += [
+ 'returnto' => $this->mReturnTo ?: null,
+ 'returntoquery' => $this->mReturnToQuery ?: null,
+ ];
+ if ( $wgSecureLogin && !$this->isSignup() ) {
+ $params['fromhttp'] = $this->mFromHTTP ? '1' : null;
+ }
+ return $params;
+ }
+
+ protected function beforeExecute( $subPage ) {
+ // finish initializing the class before processing the request - T135924
+ $this->loadRequestParameters( $subPage );
+ return parent::beforeExecute( $subPage );
+ }
+
+ /**
+ * @param string|null $subPage
+ */
+ public function execute( $subPage ) {
+ if ( $this->mPosted ) {
+ $time = microtime( true );
+ $profilingScope = new ScopedCallback( function () use ( $time ) {
+ $time = microtime( true ) - $time;
+ $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $statsd->timing( "timing.login.ui.{$this->authAction}", $time * 1000 );
+ } );
+ }
+
+ $authManager = AuthManager::singleton();
+ $session = SessionManager::getGlobalSession();
+
+ // Session data is used for various things in the authentication process, so we must make
+ // sure a session cookie or some equivalent mechanism is set.
+ $session->persist();
+
+ $this->load( $subPage );
+ $this->setHeaders();
+ $this->checkPermissions();
+
+ // Make sure the system configuration allows log in / sign up
+ if ( !$this->isSignup() && !$authManager->canAuthenticateNow() ) {
+ if ( !$session->canSetUser() ) {
+ throw new ErrorPageError( 'cannotloginnow-title', 'cannotloginnow-text', [
+ $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+ ] );
+ }
+ throw new ErrorPageError( 'cannotlogin-title', 'cannotlogin-text' );
+ } elseif ( $this->isSignup() && !$authManager->canCreateAccounts() ) {
+ throw new ErrorPageError( 'cannotcreateaccount-title', 'cannotcreateaccount-text' );
+ }
+
+ /*
+ * In the case where the user is already logged in, and was redirected to
+ * the login form from a page that requires login, do not show the login
+ * page. The use case scenario for this is when a user opens a large number
+ * of tabs, is redirected to the login page on all of them, and then logs
+ * in on one, expecting all the others to work properly.
+ *
+ * However, do show the form if it was visited intentionally (no 'returnto'
+ * is present). People who often switch between several accounts have grown
+ * accustomed to this behavior.
+ *
+ * Also make an exception when force=<level> is set in the URL, which means the user must
+ * reauthenticate for security reasons.
+ */
+ if ( !$this->isSignup() && !$this->mPosted && !$this->securityLevel &&
+ ( $this->mReturnTo !== '' || $this->mReturnToQuery !== '' ) &&
+ $this->getUser()->isLoggedIn()
+ ) {
+ $this->successfulAction();
+ }
+
+ // If logging in and not on HTTPS, either redirect to it or offer a link.
+ global $wgSecureLogin;
+ if ( $this->getRequest()->getProtocol() !== 'https' ) {
+ $title = $this->getFullTitle();
+ $query = $this->getPreservedParams( false ) + [
+ 'title' => null,
+ ( $this->mEntryErrorType === 'error' ? 'error'
+ : 'warning' ) => $this->mEntryError,
+ ] + $this->getRequest()->getQueryValues();
+ $url = $title->getFullURL( $query, false, PROTO_HTTPS );
+ if ( $wgSecureLogin && !$this->mFromHTTP &&
+ wfCanIPUseHTTPS( $this->getRequest()->getIP() )
+ ) {
+ // Avoid infinite redirect
+ $url = wfAppendQuery( $url, 'fromhttp=1' );
+ $this->getOutput()->redirect( $url );
+ // Since we only do this redir to change proto, always vary
+ $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' );
+
+ return;
+ } else {
+ // A wiki without HTTPS login support should set $wgServer to
+ // http://somehost, in which case the secure URL generated
+ // above won't actually start with https://
+ if ( substr( $url, 0, 8 ) === 'https://' ) {
+ $this->mSecureLoginUrl = $url;
+ }
+ }
+ }
+
+ if ( !$this->isActionAllowed( $this->authAction ) ) {
+ // FIXME how do we explain this to the user? can we handle session loss better?
+ // messages used: authpage-cannot-login, authpage-cannot-login-continue,
+ // authpage-cannot-create, authpage-cannot-create-continue
+ $this->mainLoginForm( [], 'authpage-cannot-' . $this->authAction );
+ return;
+ }
+
+ if ( $this->canBypassForm( $button_name ) ) {
+ $this->setRequest( [], true );
+ $this->getRequest()->setVal( $this->getTokenName(), $this->getToken() );
+ if ( $button_name ) {
+ $this->getRequest()->setVal( $button_name, true );
+ }
+ }
+
+ $status = $this->trySubmit();
+
+ if ( !$status || !$status->isGood() ) {
+ $this->mainLoginForm( $this->authRequests, $status ? $status->getMessage() : '', 'error' );
+ return;
+ }
+
+ /** @var AuthenticationResponse $response */
+ $response = $status->getValue();
+
+ $returnToUrl = $this->getPageTitle( 'return' )
+ ->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
+ switch ( $response->status ) {
+ case AuthenticationResponse::PASS:
+ $this->logAuthResult( true );
+ $this->proxyAccountCreation = $this->isSignup() && !$this->getUser()->isAnon();
+ $this->targetUser = User::newFromName( $response->username );
+
+ if (
+ !$this->proxyAccountCreation
+ && $response->loginRequest
+ && $authManager->canAuthenticateNow()
+ ) {
+ // successful registration; log the user in instantly
+ $response2 = $authManager->beginAuthentication( [ $response->loginRequest ],
+ $returnToUrl );
+ if ( $response2->status !== AuthenticationResponse::PASS ) {
+ LoggerFactory::getInstance( 'login' )
+ ->error( 'Could not log in after account creation' );
+ $this->successfulAction( true, Status::newFatal( 'createacct-loginerror' ) );
+ break;
+ }
+ }
+
+ if ( !$this->proxyAccountCreation ) {
+ // Ensure that the context user is the same as the session user.
+ $this->setSessionUserForCurrentRequest();
+ }
+
+ $this->successfulAction( true );
+ break;
+ case AuthenticationResponse::FAIL:
+ // fall through
+ case AuthenticationResponse::RESTART:
+ unset( $this->authForm );
+ if ( $response->status === AuthenticationResponse::FAIL ) {
+ $action = $this->getDefaultAction( $subPage );
+ $messageType = 'error';
+ } else {
+ $action = $this->getContinueAction( $this->authAction );
+ $messageType = 'warning';
+ }
+ $this->logAuthResult( false, $response->message ? $response->message->getKey() : '-' );
+ $this->loadAuth( $subPage, $action, true );
+ $this->mainLoginForm( $this->authRequests, $response->message, $messageType );
+ break;
+ case AuthenticationResponse::REDIRECT:
+ unset( $this->authForm );
+ $this->getOutput()->redirect( $response->redirectTarget );
+ break;
+ case AuthenticationResponse::UI:
+ unset( $this->authForm );
+ $this->authAction = $this->isSignup() ? AuthManager::ACTION_CREATE_CONTINUE
+ : AuthManager::ACTION_LOGIN_CONTINUE;
+ $this->authRequests = $response->neededRequests;
+ $this->mainLoginForm( $response->neededRequests, $response->message, $response->messageType );
+ break;
+ default:
+ throw new LogicException( 'invalid AuthenticationResponse' );
+ }
+ }
+
+ /**
+ * Determine if the login form can be bypassed. This will be the case when no more than one
+ * button is present and no other user input fields that are not marked as 'skippable' are
+ * present. If the login form were not bypassed, the user would be presented with a
+ * superfluous page on which they must press the single button to proceed with login.
+ * Not only does this cause an additional mouse click and page load, it confuses users,
+ * especially since there are a help link and forgotten password link that are
+ * provided on the login page that do not apply to this situation.
+ *
+ * @param string|null &$button_name if the form has a single button, returns
+ * the name of the button; otherwise, returns null
+ * @return bool
+ */
+ private function canBypassForm( &$button_name ) {
+ $button_name = null;
+ if ( $this->isContinued() ) {
+ return false;
+ }
+ $fields = AuthenticationRequest::mergeFieldInfo( $this->authRequests );
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !isset( $field['type'] ) ) {
+ return false;
+ }
+ if ( !empty( $field['skippable'] ) ) {
+ continue;
+ }
+ if ( $field['type'] === 'button' ) {
+ if ( $button_name !== null ) {
+ $button_name = null;
+ return false;
+ } else {
+ $button_name = $fieldname;
+ }
+ } elseif ( $field['type'] !== 'null' ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Show the success page.
+ *
+ * @param string $type Condition of return to; see `executeReturnTo`
+ * @param string|Message $title Page's title
+ * @param string $msgname
+ * @param string $injected_html
+ * @param StatusValue|null $extraMessages
+ */
+ protected function showSuccessPage(
+ $type, $title, $msgname, $injected_html, $extraMessages
+ ) {
+ $out = $this->getOutput();
+ $out->setPageTitle( $title );
+ if ( $msgname ) {
+ $out->addWikiMsg( $msgname, wfEscapeWikiText( $this->getUser()->getName() ) );
+ }
+ if ( $extraMessages ) {
+ $extraMessages = Status::wrap( $extraMessages );
+ $out->addWikiText( $extraMessages->getWikiText() );
+ }
+
+ $out->addHTML( $injected_html );
+
+ $helper = new LoginHelper( $this->getContext() );
+ $helper->showReturnToPage( $type, $this->mReturnTo, $this->mReturnToQuery, $this->mStickHTTPS );
+ }
+
+ /**
+ * Add a "return to" link or redirect to it.
+ * Extensions can use this to reuse the "return to" logic after
+ * inject steps (such as redirection) into the login process.
+ *
+ * @param string $type One of the following:
+ * - error: display a return to link ignoring $wgRedirectOnLogin
+ * - signup: display a return to link using $wgRedirectOnLogin if needed
+ * - success: display a return to link using $wgRedirectOnLogin if needed
+ * - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
+ * @param string $returnTo
+ * @param array|string $returnToQuery
+ * @param bool $stickHTTPS Keep redirect link on HTTPS
+ * @since 1.22
+ */
+ public function showReturnToPage(
+ $type, $returnTo = '', $returnToQuery = '', $stickHTTPS = false
+ ) {
+ $helper = new LoginHelper( $this->getContext() );
+ $helper->showReturnToPage( $type, $returnTo, $returnToQuery, $stickHTTPS );
+ }
+
+ /**
+ * Replace some globals to make sure the fact that the user has just been logged in is
+ * reflected in the current request.
+ * @param User $user
+ */
+ protected function setSessionUserForCurrentRequest() {
+ global $wgUser, $wgLang;
+
+ $context = RequestContext::getMain();
+ $localContext = $this->getContext();
+ if ( $context !== $localContext ) {
+ // remove AuthManagerSpecialPage context hack
+ $this->setContext( $context );
+ }
+
+ $user = $context->getRequest()->getSession()->getUser();
+
+ $wgUser = $user;
+ $context->setUser( $user );
+
+ $code = $this->getRequest()->getVal( 'uselang', $user->getOption( 'language' ) );
+ $userLang = Language::factory( $code );
+ $wgLang = $userLang;
+ $context->setLanguage( $userLang );
+ }
+
+ /**
+ * @param AuthenticationRequest[] $requests A list of AuthorizationRequest objects,
+ * used to generate the form fields. An empty array means a fatal error
+ * (authentication cannot continue).
+ * @param string|Message $msg
+ * @param string $msgtype
+ * @throws ErrorPageError
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ * @throws PermissionsError
+ * @throws ReadOnlyError
+ * @private
+ */
+ protected function mainLoginForm( array $requests, $msg = '', $msgtype = 'error' ) {
+ $titleObj = $this->getPageTitle();
+ $user = $this->getUser();
+ $out = $this->getOutput();
+
+ // FIXME how to handle empty $requests - restart, or no form, just an error message?
+ // no form would be better for no session type errors, restart is better when can* fails.
+ if ( !$requests ) {
+ $this->authAction = $this->getDefaultAction( $this->subPage );
+ $this->authForm = null;
+ $requests = AuthManager::singleton()->getAuthenticationRequests( $this->authAction, $user );
+ }
+
+ // Generic styles and scripts for both login and signup form
+ $out->addModuleStyles( [
+ 'mediawiki.ui',
+ 'mediawiki.ui.button',
+ 'mediawiki.ui.checkbox',
+ 'mediawiki.ui.input',
+ 'mediawiki.special.userlogin.common.styles'
+ ] );
+ if ( $this->isSignup() ) {
+ // XXX hack pending RL or JS parse() support for complex content messages T27349
+ $out->addJsConfigVars( 'wgCreateacctImgcaptchaHelp',
+ $this->msg( 'createacct-imgcaptcha-help' )->parse() );
+
+ // Additional styles and scripts for signup form
+ $out->addModules( [
+ 'mediawiki.special.userlogin.signup.js'
+ ] );
+ $out->addModuleStyles( [
+ 'mediawiki.special.userlogin.signup.styles'
+ ] );
+ } else {
+ // Additional styles for login form
+ $out->addModuleStyles( [
+ 'mediawiki.special.userlogin.login.styles'
+ ] );
+ }
+ $out->disallowUserJs(); // just in case...
+
+ $form = $this->getAuthForm( $requests, $this->authAction, $msg, $msgtype );
+ $form->prepareForm();
+
+ $submitStatus = Status::newGood();
+ if ( $msg && $msgtype === 'warning' ) {
+ $submitStatus->warning( $msg );
+ } elseif ( $msg && $msgtype === 'error' ) {
+ $submitStatus->fatal( $msg );
+ }
+
+ // warning header for non-standard workflows (e.g. security reauthentication)
+ if (
+ !$this->isSignup() &&
+ $this->getUser()->isLoggedIn() &&
+ $this->authAction !== AuthManager::ACTION_LOGIN_CONTINUE
+ ) {
+ $reauthMessage = $this->securityLevel ? 'userlogin-reauth' : 'userlogin-loggedin';
+ $submitStatus->warning( $reauthMessage, $this->getUser()->getName() );
+ }
+
+ $formHtml = $form->getHTML( $submitStatus );
+
+ $out->addHTML( $this->getPageHtml( $formHtml ) );
+ }
+
+ /**
+ * Add page elements which are outside the form.
+ * FIXME this should probably be a template, but use a sane language (handlebars?)
+ * @param string $formHtml
+ * @return string
+ */
+ protected function getPageHtml( $formHtml ) {
+ global $wgLoginLanguageSelector;
+
+ $loginPrompt = $this->isSignup() ? '' : Html::rawElement( 'div',
+ [ 'id' => 'userloginprompt' ], $this->msg( 'loginprompt' )->parseAsBlock() );
+ $languageLinks = $wgLoginLanguageSelector ? $this->makeLanguageSelector() : '';
+ $signupStartMsg = $this->msg( 'signupstart' );
+ $signupStart = ( $this->isSignup() && !$signupStartMsg->isDisabled() )
+ ? Html::rawElement( 'div', [ 'id' => 'signupstart' ], $signupStartMsg->parseAsBlock() ) : '';
+ if ( $languageLinks ) {
+ $languageLinks = Html::rawElement( 'div', [ 'id' => 'languagelinks' ],
+ Html::rawElement( 'p', [], $languageLinks )
+ );
+ }
+
+ $benefitsContainer = '';
+ if ( $this->isSignup() && $this->showExtraInformation() ) {
+ // messages used:
+ // createacct-benefit-icon1 createacct-benefit-head1 createacct-benefit-body1
+ // createacct-benefit-icon2 createacct-benefit-head2 createacct-benefit-body2
+ // createacct-benefit-icon3 createacct-benefit-head3 createacct-benefit-body3
+ $benefitCount = 3;
+ $benefitList = '';
+ for ( $benefitIdx = 1; $benefitIdx <= $benefitCount; $benefitIdx++ ) {
+ $headUnescaped = $this->msg( "createacct-benefit-head$benefitIdx" )->text();
+ $iconClass = $this->msg( "createacct-benefit-icon$benefitIdx" )->escaped();
+ $benefitList .= Html::rawElement( 'div', [ 'class' => "mw-number-text $iconClass" ],
+ Html::rawElement( 'h3', [],
+ $this->msg( "createacct-benefit-head$benefitIdx" )->escaped()
+ )
+ . Html::rawElement( 'p', [],
+ $this->msg( "createacct-benefit-body$benefitIdx" )->params( $headUnescaped )->escaped()
+ )
+ );
+ }
+ $benefitsContainer = Html::rawElement( 'div', [ 'class' => 'mw-createacct-benefits-container' ],
+ Html::rawElement( 'h2', [], $this->msg( 'createacct-benefit-heading' )->escaped() )
+ . Html::rawElement( 'div', [ 'class' => 'mw-createacct-benefits-list' ],
+ $benefitList
+ )
+ );
+ }
+
+ $html = Html::rawElement( 'div', [ 'class' => 'mw-ui-container' ],
+ $loginPrompt
+ . $languageLinks
+ . $signupStart
+ . Html::rawElement( 'div', [ 'id' => 'userloginForm' ],
+ $formHtml
+ )
+ . $benefitsContainer
+ );
+
+ return $html;
+ }
+
+ /**
+ * Generates a form from the given request.
+ * @param AuthenticationRequest[] $requests
+ * @param string $action AuthManager action name
+ * @param string|Message $msg
+ * @param string $msgType
+ * @return HTMLForm
+ */
+ protected function getAuthForm( array $requests, $action, $msg = '', $msgType = 'error' ) {
+ global $wgSecureLogin;
+ // FIXME merge this with parent
+
+ if ( isset( $this->authForm ) ) {
+ return $this->authForm;
+ }
+
+ $usingHTTPS = $this->getRequest()->getProtocol() === 'https';
+
+ // get basic form description from the auth logic
+ $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
+ $fakeTemplate = $this->getFakeTemplate( $msg, $msgType );
+ $this->fakeTemplate = $fakeTemplate; // FIXME there should be a saner way to pass this to the hook
+ // this will call onAuthChangeFormFields()
+ $formDescriptor = static::fieldInfoToFormDescriptor( $requests, $fieldInfo, $this->authAction );
+ $this->postProcessFormDescriptor( $formDescriptor, $requests );
+
+ $context = $this->getContext();
+ if ( $context->getRequest() !== $this->getRequest() ) {
+ // We have overridden the request, need to make sure the form uses that too.
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setRequest( $this->getRequest() );
+ }
+ $form = HTMLForm::factory( 'vform', $formDescriptor, $context );
+
+ $form->addHiddenField( 'authAction', $this->authAction );
+ if ( $this->mLanguage ) {
+ $form->addHiddenField( 'uselang', $this->mLanguage );
+ }
+ $form->addHiddenField( 'force', $this->securityLevel );
+ $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
+ if ( $wgSecureLogin ) {
+ // If using HTTPS coming from HTTP, then the 'fromhttp' parameter must be preserved
+ if ( !$this->isSignup() ) {
+ $form->addHiddenField( 'wpForceHttps', (int)$this->mStickHTTPS );
+ $form->addHiddenField( 'wpFromhttp', $usingHTTPS );
+ }
+ }
+
+ // set properties of the form itself
+ $form->setAction( $this->getPageTitle()->getLocalURL( $this->getReturnToQueryStringFragment() ) );
+ $form->setName( 'userlogin' . ( $this->isSignup() ? '2' : '' ) );
+ if ( $this->isSignup() ) {
+ $form->setId( 'userlogin2' );
+ }
+
+ $form->suppressDefaultSubmit();
+
+ $this->authForm = $form;
+
+ return $form;
+ }
+
+ /**
+ * Temporary B/C method to handle extensions using the UserLoginForm/UserCreateForm hooks.
+ * @param string|Message $msg
+ * @param string $msgType
+ * @return FakeAuthTemplate
+ */
+ protected function getFakeTemplate( $msg, $msgType ) {
+ global $wgAuth, $wgEnableEmail, $wgHiddenPrefs, $wgEmailConfirmToEdit, $wgEnableUserEmail,
+ $wgSecureLogin, $wgPasswordResetRoutes;
+
+ // make a best effort to get the value of fields which used to be fixed in the old login
+ // template but now might or might not exist depending on what providers are used
+ $request = $this->getRequest();
+ $data = (object)[
+ 'mUsername' => $request->getText( 'wpName' ),
+ 'mPassword' => $request->getText( 'wpPassword' ),
+ 'mRetype' => $request->getText( 'wpRetype' ),
+ 'mEmail' => $request->getText( 'wpEmail' ),
+ 'mRealName' => $request->getText( 'wpRealName' ),
+ 'mDomain' => $request->getText( 'wpDomain' ),
+ 'mReason' => $request->getText( 'wpReason' ),
+ 'mRemember' => $request->getCheck( 'wpRemember' ),
+ ];
+
+ // Preserves a bunch of logic from the old code that was rewritten in getAuthForm().
+ // There is no code reuse to make this easier to remove .
+ // If an extension tries to change any of these values, they are out of luck - we only
+ // actually use the domain/usedomain/domainnames, extraInput and extrafields keys.
+
+ $titleObj = $this->getPageTitle();
+ $user = $this->getUser();
+ $template = new FakeAuthTemplate();
+
+ // Pre-fill username (if not creating an account, T46775).
+ if ( $data->mUsername == '' && $this->isSignup() ) {
+ if ( $user->isLoggedIn() ) {
+ $data->mUsername = $user->getName();
+ } else {
+ $data->mUsername = $this->getRequest()->getSession()->suggestLoginUsername();
+ }
+ }
+
+ if ( $this->isSignup() ) {
+ // Must match number of benefits defined in messages
+ $template->set( 'benefitCount', 3 );
+
+ $q = 'action=submitlogin&type=signup';
+ $linkq = 'type=login';
+ } else {
+ $q = 'action=submitlogin&type=login';
+ $linkq = 'type=signup';
+ }
+
+ if ( $this->mReturnTo !== '' ) {
+ $returnto = '&returnto=' . wfUrlencode( $this->mReturnTo );
+ if ( $this->mReturnToQuery !== '' ) {
+ $returnto .= '&returntoquery=' .
+ wfUrlencode( $this->mReturnToQuery );
+ }
+ $q .= $returnto;
+ $linkq .= $returnto;
+ }
+
+ # Don't show a "create account" link if the user can't.
+ if ( $this->showCreateAccountLink() ) {
+ # Pass any language selection on to the mode switch link
+ if ( $this->mLanguage ) {
+ $linkq .= '&uselang=' . urlencode( $this->mLanguage );
+ }
+ // Supply URL, login template creates the button.
+ $template->set( 'createOrLoginHref', $titleObj->getLocalURL( $linkq ) );
+ } else {
+ $template->set( 'link', '' );
+ }
+
+ $resetLink = $this->isSignup()
+ ? null
+ : is_array( $wgPasswordResetRoutes )
+ && in_array( true, array_values( $wgPasswordResetRoutes ), true );
+
+ $template->set( 'header', '' );
+ $template->set( 'formheader', '' );
+ $template->set( 'skin', $this->getSkin() );
+
+ $template->set( 'name', $data->mUsername );
+ $template->set( 'password', $data->mPassword );
+ $template->set( 'retype', $data->mRetype );
+ $template->set( 'createemailset', false ); // no easy way to get that from AuthManager
+ $template->set( 'email', $data->mEmail );
+ $template->set( 'realname', $data->mRealName );
+ $template->set( 'domain', $data->mDomain );
+ $template->set( 'reason', $data->mReason );
+ $template->set( 'remember', $data->mRemember );
+
+ $template->set( 'action', $titleObj->getLocalURL( $q ) );
+ $template->set( 'message', $msg );
+ $template->set( 'messagetype', $msgType );
+ $template->set( 'createemail', $wgEnableEmail && $user->isLoggedIn() );
+ $template->set( 'userealname', !in_array( 'realname', $wgHiddenPrefs, true ) );
+ $template->set( 'useemail', $wgEnableEmail );
+ $template->set( 'emailrequired', $wgEmailConfirmToEdit );
+ $template->set( 'emailothers', $wgEnableUserEmail );
+ $template->set( 'canreset', $wgAuth->allowPasswordChange() );
+ $template->set( 'resetlink', $resetLink );
+ $template->set( 'canremember', $request->getSession()->getProvider()
+ ->getRememberUserDuration() !== null );
+ $template->set( 'usereason', $user->isLoggedIn() );
+ $template->set( 'cansecurelogin', ( $wgSecureLogin ) );
+ $template->set( 'stickhttps', (int)$this->mStickHTTPS );
+ $template->set( 'loggedin', $user->isLoggedIn() );
+ $template->set( 'loggedinuser', $user->getName() );
+ $template->set( 'token', $this->getToken()->toString() );
+
+ $action = $this->isSignup() ? 'signup' : 'login';
+ $wgAuth->modifyUITemplate( $template, $action );
+
+ $oldTemplate = $template;
+
+ // Both Hooks::run are explicit here to make findHooks.php happy
+ if ( $this->isSignup() ) {
+ Hooks::run( 'UserCreateForm', [ &$template ] );
+ if ( $oldTemplate !== $template ) {
+ wfDeprecated( "reference in UserCreateForm hook", '1.27' );
+ }
+ } else {
+ Hooks::run( 'UserLoginForm', [ &$template ] );
+ if ( $oldTemplate !== $template ) {
+ wfDeprecated( "reference in UserLoginForm hook", '1.27' );
+ }
+ }
+
+ return $template;
+ }
+
+ public function onAuthChangeFormFields(
+ array $requests, array $fieldInfo, array &$formDescriptor, $action
+ ) {
+ $coreFieldDescriptors = $this->getFieldDefinitions( $this->fakeTemplate );
+ $specialFields = array_merge( [ 'extraInput' ],
+ array_keys( $this->fakeTemplate->getExtraInputDefinitions() ) );
+
+ // keep the ordering from getCoreFieldDescriptors() where there is no explicit weight
+ foreach ( $coreFieldDescriptors as $fieldName => $coreField ) {
+ $requestField = isset( $formDescriptor[$fieldName] ) ?
+ $formDescriptor[$fieldName] : [];
+
+ // remove everything that is not in the fieldinfo, is not marked as a supplemental field
+ // to something in the fieldinfo, is not B/C for the pre-AuthManager templates,
+ // and is not an info field or a submit button
+ if (
+ !isset( $fieldInfo[$fieldName] )
+ && (
+ !isset( $coreField['baseField'] )
+ || !isset( $fieldInfo[$coreField['baseField']] )
+ )
+ && !in_array( $fieldName, $specialFields, true )
+ && (
+ !isset( $coreField['type'] )
+ || !in_array( $coreField['type'], [ 'submit', 'info' ], true )
+ )
+ ) {
+ $coreFieldDescriptors[$fieldName] = null;
+ continue;
+ }
+
+ // core message labels should always take priority
+ if (
+ isset( $coreField['label'] )
+ || isset( $coreField['label-message'] )
+ || isset( $coreField['label-raw'] )
+ ) {
+ unset( $requestField['label'], $requestField['label-message'], $coreField['label-raw'] );
+ }
+
+ $coreFieldDescriptors[$fieldName] += $requestField;
+ }
+
+ $formDescriptor = array_filter( $coreFieldDescriptors + $formDescriptor );
+ return true;
+ }
+
+ /**
+ * Show extra information such as password recovery information, link from login to signup,
+ * CTA etc? Such information should only be shown on the "landing page", ie. when the user
+ * is at the first step of the authentication process.
+ * @return bool
+ */
+ protected function showExtraInformation() {
+ return $this->authAction !== $this->getContinueAction( $this->authAction )
+ && !$this->securityLevel;
+ }
+
+ /**
+ * Create a HTMLForm descriptor for the core login fields.
+ * @param FakeAuthTemplate $template B/C data (not used but needed by getBCFieldDefinitions)
+ * @return array
+ */
+ protected function getFieldDefinitions( $template ) {
+ global $wgEmailConfirmToEdit;
+
+ $isLoggedIn = $this->getUser()->isLoggedIn();
+ $continuePart = $this->isContinued() ? 'continue-' : '';
+ $anotherPart = $isLoggedIn ? 'another-' : '';
+ $expiration = $this->getRequest()->getSession()->getProvider()->getRememberUserDuration();
+ $expirationDays = ceil( $expiration / ( 3600 * 24 ) );
+ $secureLoginLink = '';
+ if ( $this->mSecureLoginUrl ) {
+ $secureLoginLink = Html::element( 'a', [
+ 'href' => $this->mSecureLoginUrl,
+ 'class' => 'mw-ui-flush-right mw-secure',
+ ], $this->msg( 'userlogin-signwithsecure' )->text() );
+ }
+ $usernameHelpLink = '';
+ if ( !$this->msg( 'createacct-helpusername' )->isDisabled() ) {
+ $usernameHelpLink = Html::rawElement( 'span', [
+ 'class' => 'mw-ui-flush-right',
+ ], $this->msg( 'createacct-helpusername' )->parse() );
+ }
+
+ if ( $this->isSignup() ) {
+ $fieldDefinitions = [
+ 'statusarea' => [
+ // used by the mediawiki.special.userlogin.signup.js module for error display
+ // FIXME merge this with HTMLForm's normal status (error) area
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => Html::element( 'div', [ 'id' => 'mw-createacct-status-area' ] ),
+ 'weight' => -105,
+ ],
+ 'username' => [
+ 'label-raw' => $this->msg( 'userlogin-yourname' )->escaped() . $usernameHelpLink,
+ 'id' => 'wpName2',
+ 'placeholder-message' => $isLoggedIn ? 'createacct-another-username-ph'
+ : 'userlogin-yourname-ph',
+ ],
+ 'mailpassword' => [
+ // create account without providing password, a temporary one will be mailed
+ 'type' => 'check',
+ 'label-message' => 'createaccountmail',
+ 'name' => 'wpCreateaccountMail',
+ 'id' => 'wpCreateaccountMail',
+ ],
+ 'password' => [
+ 'id' => 'wpPassword2',
+ 'placeholder-message' => 'createacct-yourpassword-ph',
+ 'hide-if' => [ '===', 'wpCreateaccountMail', '1' ],
+ ],
+ 'domain' => [],
+ 'retype' => [
+ 'baseField' => 'password',
+ 'type' => 'password',
+ 'label-message' => 'createacct-yourpasswordagain',
+ 'id' => 'wpRetype',
+ 'cssclass' => 'loginPassword',
+ 'size' => 20,
+ 'validation-callback' => function ( $value, $alldata ) {
+ if ( empty( $alldata['mailpassword'] ) && !empty( $alldata['password'] ) ) {
+ if ( !$value ) {
+ return $this->msg( 'htmlform-required' );
+ } elseif ( $value !== $alldata['password'] ) {
+ return $this->msg( 'badretype' );
+ }
+ }
+ return true;
+ },
+ 'hide-if' => [ '===', 'wpCreateaccountMail', '1' ],
+ 'placeholder-message' => 'createacct-yourpasswordagain-ph',
+ ],
+ 'email' => [
+ 'type' => 'email',
+ 'label-message' => $wgEmailConfirmToEdit ? 'createacct-emailrequired'
+ : 'createacct-emailoptional',
+ 'id' => 'wpEmail',
+ 'cssclass' => 'loginText',
+ 'size' => '20',
+ // FIXME will break non-standard providers
+ 'required' => $wgEmailConfirmToEdit,
+ 'validation-callback' => function ( $value, $alldata ) {
+ global $wgEmailConfirmToEdit;
+
+ // AuthManager will check most of these, but that will make the auth
+ // session fail and this won't, so nicer to do it this way
+ if ( !$value && $wgEmailConfirmToEdit ) {
+ // no point in allowing registration without email when email is
+ // required to edit
+ return $this->msg( 'noemailtitle' );
+ } elseif ( !$value && !empty( $alldata['mailpassword'] ) ) {
+ // cannot send password via email when there is no email address
+ return $this->msg( 'noemailcreate' );
+ } elseif ( $value && !Sanitizer::validateEmail( $value ) ) {
+ return $this->msg( 'invalidemailaddress' );
+ }
+ return true;
+ },
+ 'placeholder-message' => 'createacct-' . $anotherPart . 'email-ph',
+ ],
+ 'realname' => [
+ 'type' => 'text',
+ 'help-message' => $isLoggedIn ? 'createacct-another-realname-tip'
+ : 'prefs-help-realname',
+ 'label-message' => 'createacct-realname',
+ 'cssclass' => 'loginText',
+ 'size' => 20,
+ 'id' => 'wpRealName',
+ ],
+ 'reason' => [
+ // comment for the user creation log
+ 'type' => 'text',
+ 'label-message' => 'createacct-reason',
+ 'cssclass' => 'loginText',
+ 'id' => 'wpReason',
+ 'size' => '20',
+ 'placeholder-message' => 'createacct-reason-ph',
+ ],
+ 'extrainput' => [], // placeholder for fields coming from the template
+ 'createaccount' => [
+ // submit button
+ 'type' => 'submit',
+ 'default' => $this->msg( 'createacct-' . $anotherPart . $continuePart .
+ 'submit' )->text(),
+ 'name' => 'wpCreateaccount',
+ 'id' => 'wpCreateaccount',
+ 'weight' => 100,
+ ],
+ ];
+ } else {
+ $fieldDefinitions = [
+ 'username' => [
+ 'label-raw' => $this->msg( 'userlogin-yourname' )->escaped() . $secureLoginLink,
+ 'id' => 'wpName1',
+ 'placeholder-message' => 'userlogin-yourname-ph',
+ ],
+ 'password' => [
+ 'id' => 'wpPassword1',
+ 'placeholder-message' => 'userlogin-yourpassword-ph',
+ ],
+ 'domain' => [],
+ 'extrainput' => [],
+ 'rememberMe' => [
+ // option for saving the user token to a cookie
+ 'type' => 'check',
+ 'name' => 'wpRemember',
+ 'label-message' => $this->msg( 'userlogin-remembermypassword' )
+ ->numParams( $expirationDays ),
+ 'id' => 'wpRemember',
+ ],
+ 'loginattempt' => [
+ // submit button
+ 'type' => 'submit',
+ 'default' => $this->msg( 'pt-login-' . $continuePart . 'button' )->text(),
+ 'id' => 'wpLoginAttempt',
+ 'weight' => 100,
+ ],
+ 'linkcontainer' => [
+ // help link
+ 'type' => 'info',
+ 'cssclass' => 'mw-form-related-link-container mw-userlogin-help',
+ // 'id' => 'mw-userlogin-help', // FIXME HTMLInfoField ignores this
+ 'raw' => true,
+ 'default' => Html::element( 'a', [
+ 'href' => Skin::makeInternalOrExternalUrl( wfMessage( 'helplogin-url' )
+ ->inContentLanguage()
+ ->text() ),
+ ], $this->msg( 'userlogin-helplink2' )->text() ),
+ 'weight' => 200,
+ ],
+ // button for ResetPasswordSecondaryAuthenticationProvider
+ 'skipReset' => [
+ 'weight' => 110,
+ 'flags' => [],
+ ],
+ ];
+ }
+
+ $fieldDefinitions['username'] += [
+ 'type' => 'text',
+ 'name' => 'wpName',
+ 'cssclass' => 'loginText',
+ 'size' => 20,
+ // 'required' => true,
+ ];
+ $fieldDefinitions['password'] += [
+ 'type' => 'password',
+ // 'label-message' => 'userlogin-yourpassword', // would override the changepassword label
+ 'name' => 'wpPassword',
+ 'cssclass' => 'loginPassword',
+ 'size' => 20,
+ // 'required' => true,
+ ];
+
+ if ( $template->get( 'header' ) || $template->get( 'formheader' ) ) {
+ // B/C for old extensions that haven't been converted to AuthManager (or have been
+ // but somebody is using the old version) and still use templates via the
+ // UserCreateForm/UserLoginForm hook.
+ // 'header' used by ConfirmEdit, ConfirmAccount, Persona, WikimediaIncubator, SemanticSignup
+ // 'formheader' used by MobileFrontend
+ $fieldDefinitions['header'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $template->get( 'header' ) ?: $template->get( 'formheader' ),
+ 'weight' => -110,
+ ];
+ }
+ if ( $this->mEntryError ) {
+ $fieldDefinitions['entryError'] = [
+ 'type' => 'info',
+ 'default' => Html::rawElement( 'div', [ 'class' => $this->mEntryErrorType . 'box', ],
+ $this->mEntryError ),
+ 'raw' => true,
+ 'rawrow' => true,
+ 'weight' => -100,
+ ];
+ }
+ if ( !$this->showExtraInformation() ) {
+ unset( $fieldDefinitions['linkcontainer'], $fieldDefinitions['signupend'] );
+ }
+ if ( $this->isSignup() && $this->showExtraInformation() ) {
+ // blank signup footer for site customization
+ // uses signupend-https for HTTPS requests if it's not blank, signupend otherwise
+ $signupendMsg = $this->msg( 'signupend' );
+ $signupendHttpsMsg = $this->msg( 'signupend-https' );
+ if ( !$signupendMsg->isDisabled() ) {
+ $usingHTTPS = $this->getRequest()->getProtocol() === 'https';
+ $signupendText = ( $usingHTTPS && !$signupendHttpsMsg->isBlank() )
+ ? $signupendHttpsMsg ->parse() : $signupendMsg->parse();
+ $fieldDefinitions['signupend'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => Html::rawElement( 'div', [ 'id' => 'signupend' ], $signupendText ),
+ 'weight' => 225,
+ ];
+ }
+ }
+ if ( !$this->isSignup() && $this->showExtraInformation() ) {
+ $passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
+ if ( $passwordReset->isAllowed( $this->getUser() )->isGood() ) {
+ $fieldDefinitions['passwordReset'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'cssclass' => 'mw-form-related-link-container',
+ 'default' => $this->getLinkRenderer()->makeLink(
+ SpecialPage::getTitleFor( 'PasswordReset' ),
+ $this->msg( 'userlogin-resetpassword-link' )->text()
+ ),
+ 'weight' => 230,
+ ];
+ }
+
+ // Don't show a "create account" link if the user can't.
+ if ( $this->showCreateAccountLink() ) {
+ // link to the other action
+ $linkTitle = $this->getTitleFor( $this->isSignup() ? 'Userlogin' : 'CreateAccount' );
+ $linkq = $this->getReturnToQueryStringFragment();
+ // Pass any language selection on to the mode switch link
+ if ( $this->mLanguage ) {
+ $linkq .= '&uselang=' . urlencode( $this->mLanguage );
+ }
+ $loggedIn = $this->getUser()->isLoggedIn();
+
+ $fieldDefinitions['createOrLogin'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'linkQuery' => $linkq,
+ 'default' => function ( $params ) use ( $loggedIn, $linkTitle ) {
+ return Html::rawElement( 'div',
+ [ 'id' => 'mw-createaccount' . ( !$loggedIn ? '-cta' : '' ),
+ 'class' => ( $loggedIn ? 'mw-form-related-link-container' : 'mw-ui-vform-field' ) ],
+ ( $loggedIn ? '' : $this->msg( 'userlogin-noaccount' )->escaped() )
+ . Html::element( 'a',
+ [
+ 'id' => 'mw-createaccount-join' . ( $loggedIn ? '-loggedin' : '' ),
+ 'href' => $linkTitle->getLocalURL( $params['linkQuery'] ),
+ 'class' => ( $loggedIn ? '' : 'mw-ui-button' ),
+ 'tabindex' => 100,
+ ],
+ $this->msg(
+ $loggedIn ? 'userlogin-createanother' : 'userlogin-joinproject'
+ )->escaped()
+ )
+ );
+ },
+ 'weight' => 235,
+ ];
+ }
+ }
+
+ $fieldDefinitions = $this->getBCFieldDefinitions( $fieldDefinitions, $template );
+ $fieldDefinitions = array_filter( $fieldDefinitions );
+
+ return $fieldDefinitions;
+ }
+
+ /**
+ * Adds fields provided via the deprecated UserLoginForm / UserCreateForm hooks
+ * @param array $fieldDefinitions
+ * @param FakeAuthTemplate $template
+ * @return array
+ */
+ protected function getBCFieldDefinitions( $fieldDefinitions, $template ) {
+ if ( $template->get( 'usedomain', false ) ) {
+ // TODO probably should be translated to the new domain notation in AuthManager
+ $fieldDefinitions['domain'] = [
+ 'type' => 'select',
+ 'label-message' => 'yourdomainname',
+ 'options' => array_combine( $template->get( 'domainnames', [] ),
+ $template->get( 'domainnames', [] ) ),
+ 'default' => $template->get( 'domain', '' ),
+ 'name' => 'wpDomain',
+ // FIXME id => 'mw-user-domain-section' on the parent div
+ ];
+ }
+
+ // poor man's associative array_splice
+ $extraInputPos = array_search( 'extrainput', array_keys( $fieldDefinitions ), true );
+ $fieldDefinitions = array_slice( $fieldDefinitions, 0, $extraInputPos, true )
+ + $template->getExtraInputDefinitions()
+ + array_slice( $fieldDefinitions, $extraInputPos + 1, null, true );
+
+ return $fieldDefinitions;
+ }
+
+ /**
+ * Check if a session cookie is present.
+ *
+ * This will not pick up a cookie set during _this_ request, but is meant
+ * to ensure that the client is returning the cookie which was set on a
+ * previous pass through the system.
+ *
+ * @return bool
+ */
+ protected function hasSessionCookie() {
+ global $wgDisableCookieCheck, $wgInitialSessionId;
+
+ return $wgDisableCookieCheck || (
+ $wgInitialSessionId &&
+ $this->getRequest()->getSession()->getId() === (string)$wgInitialSessionId
+ );
+ }
+
+ /**
+ * Returns a string that can be appended to the URL (without encoding) to preserve the
+ * return target. Does not include leading '?'/'&'.
+ * @return string
+ */
+ protected function getReturnToQueryStringFragment() {
+ $returnto = '';
+ if ( $this->mReturnTo !== '' ) {
+ $returnto = 'returnto=' . wfUrlencode( $this->mReturnTo );
+ if ( $this->mReturnToQuery !== '' ) {
+ $returnto .= '&returntoquery=' . wfUrlencode( $this->mReturnToQuery );
+ }
+ }
+ return $returnto;
+ }
+
+ /**
+ * Whether the login/create account form should display a link to the
+ * other form (in addition to whatever the skin provides).
+ * @return bool
+ */
+ private function showCreateAccountLink() {
+ if ( $this->isSignup() ) {
+ return true;
+ } elseif ( $this->getUser()->isAllowed( 'createaccount' ) ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ protected function getTokenName() {
+ return $this->isSignup() ? 'wpCreateaccountToken' : 'wpLoginToken';
+ }
+
+ /**
+ * Produce a bar of links which allow the user to select another language
+ * during login/registration but retain "returnto"
+ *
+ * @return string
+ */
+ protected function makeLanguageSelector() {
+ $msg = $this->msg( 'loginlanguagelinks' )->inContentLanguage();
+ if ( $msg->isBlank() ) {
+ return '';
+ }
+ $langs = explode( "\n", $msg->text() );
+ $links = [];
+ foreach ( $langs as $lang ) {
+ $lang = trim( $lang, '* ' );
+ $parts = explode( '|', $lang );
+ if ( count( $parts ) >= 2 ) {
+ $links[] = $this->makeLanguageSelectorLink( $parts[0], trim( $parts[1] ) );
+ }
+ }
+
+ return count( $links ) > 0 ? $this->msg( 'loginlanguagelabel' )->rawParams(
+ $this->getLanguage()->pipeList( $links ) )->escaped() : '';
+ }
+
+ /**
+ * Create a language selector link for a particular language
+ * Links back to this page preserving type and returnto
+ *
+ * @param string $text Link text
+ * @param string $lang Language code
+ * @return string
+ */
+ protected function makeLanguageSelectorLink( $text, $lang ) {
+ if ( $this->getLanguage()->getCode() == $lang ) {
+ // no link for currently used language
+ return htmlspecialchars( $text );
+ }
+ $query = [ 'uselang' => $lang ];
+ if ( $this->mReturnTo !== '' ) {
+ $query['returnto'] = $this->mReturnTo;
+ $query['returntoquery'] = $this->mReturnToQuery;
+ }
+
+ $attr = [];
+ $targetLanguage = Language::factory( $lang );
+ $attr['lang'] = $attr['hreflang'] = $targetLanguage->getHtmlCode();
+
+ return $this->getLinkRenderer()->makeKnownLink(
+ $this->getPageTitle(),
+ $text,
+ $attr,
+ $query
+ );
+ }
+
+ protected function getGroupName() {
+ return 'login';
+ }
+
+ /**
+ * @param array &$formDescriptor
+ * @param array $requests
+ */
+ protected function postProcessFormDescriptor( &$formDescriptor, $requests ) {
+ // Pre-fill username (if not creating an account, T46775).
+ if (
+ isset( $formDescriptor['username'] ) &&
+ !isset( $formDescriptor['username']['default'] ) &&
+ !$this->isSignup()
+ ) {
+ $user = $this->getUser();
+ if ( $user->isLoggedIn() ) {
+ $formDescriptor['username']['default'] = $user->getName();
+ } else {
+ $formDescriptor['username']['default'] =
+ $this->getRequest()->getSession()->suggestLoginUsername();
+ }
+ }
+
+ // don't show a submit button if there is nothing to submit (i.e. the only form content
+ // is other submit buttons, for redirect flows)
+ if ( !$this->needsSubmitButton( $requests ) ) {
+ unset( $formDescriptor['createaccount'], $formDescriptor['loginattempt'] );
+ }
+
+ if ( !$this->isSignup() ) {
+ // FIXME HACK don't focus on non-empty field
+ // maybe there should be an autofocus-if similar to hide-if?
+ if (
+ isset( $formDescriptor['username'] )
+ && empty( $formDescriptor['username']['default'] )
+ && !$this->getRequest()->getCheck( 'wpName' )
+ ) {
+ $formDescriptor['username']['autofocus'] = true;
+ } elseif ( isset( $formDescriptor['password'] ) ) {
+ $formDescriptor['password']['autofocus'] = true;
+ }
+ }
+
+ $this->addTabIndex( $formDescriptor );
+ }
+}
+
+/**
+ * B/C class to try handling login/signup template modifications even though login/signup does not
+ * actually happen through a template anymore. Just collects extra field definitions and allows
+ * some other class to do decide what to do with threm..
+ * TODO find the right place for adding extra fields and kill this
+ */
+class FakeAuthTemplate extends BaseTemplate {
+ public function execute() {
+ throw new LogicException( 'not used' );
+ }
+
+ /**
+ * Extensions (AntiSpoof and TitleBlacklist) call this in response to
+ * UserCreateForm hook to add checkboxes to the create account form.
+ * @param string $name
+ * @param string $value
+ * @param string $type
+ * @param string $msg
+ * @param string|bool $helptext
+ */
+ public function addInputItem( $name, $value, $type, $msg, $helptext = false ) {
+ // use the same indexes as UserCreateForm just in case someone adds an item manually
+ $this->data['extrainput'][] = [
+ 'name' => $name,
+ 'value' => $value,
+ 'type' => $type,
+ 'msg' => $msg,
+ 'helptext' => $helptext,
+ ];
+ }
+
+ /**
+ * Turns addInputItem-style field definitions into HTMLForm field definitions.
+ * @return array
+ */
+ public function getExtraInputDefinitions() {
+ $definitions = [];
+
+ foreach ( $this->get( 'extrainput', [] ) as $field ) {
+ $definition = [
+ 'type' => $field['type'] === 'checkbox' ? 'check' : $field['type'],
+ 'name' => $field['name'],
+ 'value' => $field['value'],
+ 'id' => $field['name'],
+ ];
+ if ( $field['msg'] ) {
+ $definition['label-message'] = $this->getMsg( $field['msg'] );
+ }
+ if ( $field['helptext'] ) {
+ $definition['help'] = $this->msgWiki( $field['helptext'] );
+ }
+
+ // the array key doesn't matter much when name is defined explicitly but
+ // let's try and follow HTMLForm conventions
+ $name = preg_replace( '/^wp(?=[A-Z])/', '', $field['name'] );
+ $definitions[$name] = $definition;
+ }
+
+ if ( $this->haveData( 'extrafields' ) ) {
+ $definitions['extrafields'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $this->get( 'extrafields' ),
+ ];
+ }
+
+ return $definitions;
+ }
+}
+
+/**
+ * LoginForm as a special page has been replaced by SpecialUserLogin and SpecialCreateAccount,
+ * but some extensions called its public methods directly, so the class is retained as a
+ * B/C wrapper. Anything that used it before should use AuthManager instead.
+ */
+class LoginForm extends SpecialPage {
+ const SUCCESS = 0;
+ const NO_NAME = 1;
+ const ILLEGAL = 2;
+ const WRONG_PLUGIN_PASS = 3;
+ const NOT_EXISTS = 4;
+ const WRONG_PASS = 5;
+ const EMPTY_PASS = 6;
+ const RESET_PASS = 7;
+ const ABORTED = 8;
+ const CREATE_BLOCKED = 9;
+ const THROTTLED = 10;
+ const USER_BLOCKED = 11;
+ const NEED_TOKEN = 12;
+ const WRONG_TOKEN = 13;
+ const USER_MIGRATED = 14;
+
+ public static $statusCodes = [
+ self::SUCCESS => 'success',
+ self::NO_NAME => 'no_name',
+ self::ILLEGAL => 'illegal',
+ self::WRONG_PLUGIN_PASS => 'wrong_plugin_pass',
+ self::NOT_EXISTS => 'not_exists',
+ self::WRONG_PASS => 'wrong_pass',
+ self::EMPTY_PASS => 'empty_pass',
+ self::RESET_PASS => 'reset_pass',
+ self::ABORTED => 'aborted',
+ self::CREATE_BLOCKED => 'create_blocked',
+ self::THROTTLED => 'throttled',
+ self::USER_BLOCKED => 'user_blocked',
+ self::NEED_TOKEN => 'need_token',
+ self::WRONG_TOKEN => 'wrong_token',
+ self::USER_MIGRATED => 'user_migrated',
+ ];
+
+ /**
+ * @param WebRequest $request
+ */
+ public function __construct( $request = null ) {
+ wfDeprecated( 'LoginForm', '1.27' );
+ parent::__construct();
+ }
+
+ /**
+ * @deprecated since 1.27 - call LoginHelper::getValidErrorMessages instead.
+ * @return array
+ */
+ public static function getValidErrorMessages() {
+ return LoginHelper::getValidErrorMessages();
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ * @param string $username
+ * @return array|false
+ */
+ public static function incrementLoginThrottle( $username ) {
+ wfDeprecated( __METHOD__, "1.27" );
+ global $wgRequest;
+ $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
+ $throttler = new Throttler();
+ return $throttler->increase( $username, $wgRequest->getIP(), __METHOD__ );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ * @param string $username
+ * @return bool|int
+ */
+ public static function incLoginThrottle( $username ) {
+ wfDeprecated( __METHOD__, "1.27" );
+ $res = self::incrementLoginThrottle( $username );
+ return is_array( $res ) ? true : 0;
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ * @param string $username
+ * @return void
+ */
+ public static function clearLoginThrottle( $username ) {
+ wfDeprecated( __METHOD__, "1.27" );
+ global $wgRequest;
+ $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
+ $throttler = new Throttler();
+ return $throttler->clear( $username, $wgRequest->getIP() );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function getLoginToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ global $wgRequest;
+ return $wgRequest->getSession()->getToken( '', 'login' )->toString();
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function setLoginToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function clearLoginToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ global $wgRequest;
+ $wgRequest->getSession()->resetToken( 'login' );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ * @return string
+ */
+ public static function getCreateaccountToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ global $wgRequest;
+ return $wgRequest->getSession()->getToken( '', 'createaccount' )->toString();
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function setCreateaccountToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function clearCreateaccountToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ global $wgRequest;
+ $wgRequest->getSession()->resetToken( 'createaccount' );
+ }
+}
diff --git a/www/wiki/includes/specialpage/PageQueryPage.php b/www/wiki/includes/specialpage/PageQueryPage.php
new file mode 100644
index 00000000..f7f04993
--- /dev/null
+++ b/www/wiki/includes/specialpage/PageQueryPage.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Variant of QueryPage which formats the result as a simple link to the page.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Variant of QueryPage which formats the result as a simple link to the page
+ *
+ * @ingroup SpecialPage
+ */
+abstract class PageQueryPage extends QueryPage {
+ /**
+ * Run a LinkBatch to pre-cache LinkCache information,
+ * like page existence and information for stub color and redirect hints.
+ * This should be done for live data and cached data.
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ public function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * Format the result as a simple link to the page
+ *
+ * @param Skin $skin
+ * @param object $row Result row
+ * @return string
+ */
+ public function formatResult( $skin, $row ) {
+ global $wgContLang;
+
+ $title = Title::makeTitleSafe( $row->namespace, $row->title );
+
+ if ( $title instanceof Title ) {
+ $text = $wgContLang->convert( $title->getPrefixedText() );
+ return $this->getLinkRenderer()->makeLink( $title, $text );
+ } else {
+ return Html::element( 'span', [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription( $this->getContext(), $row->namespace, $row->title ) );
+ }
+ }
+}
diff --git a/www/wiki/includes/specialpage/QueryPage.php b/www/wiki/includes/specialpage/QueryPage.php
new file mode 100644
index 00000000..73b81289
--- /dev/null
+++ b/www/wiki/includes/specialpage/QueryPage.php
@@ -0,0 +1,875 @@
+<?php
+/**
+ * Base code for "query" special pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * This is a class for doing query pages; since they're almost all the same,
+ * we factor out some of the functionality into a superclass, and let
+ * subclasses derive from it.
+ * @ingroup SpecialPage
+ */
+abstract class QueryPage extends SpecialPage {
+ /** @var bool Whether or not we want plain listoutput rather than an ordered list */
+ protected $listoutput = false;
+
+ /** @var int The offset and limit in use, as passed to the query() function */
+ protected $offset = 0;
+
+ /** @var int */
+ protected $limit = 0;
+
+ /**
+ * The number of rows returned by the query. Reading this variable
+ * only makes sense in functions that are run after the query has been
+ * done, such as preprocessResults() and formatRow().
+ */
+ protected $numRows;
+
+ protected $cachedTimestamp = null;
+
+ /**
+ * Whether to show prev/next links
+ */
+ protected $shownavigation = true;
+
+ /**
+ * Get a list of query page classes and their associated special pages,
+ * for periodic updates.
+ *
+ * DO NOT CHANGE THIS LIST without testing that
+ * maintenance/updateSpecialPages.php still works.
+ * @return array
+ */
+ public static function getPages() {
+ static $qp = null;
+
+ if ( $qp === null ) {
+ // QueryPage subclass, Special page name
+ $qp = [
+ [ 'AncientPagesPage', 'Ancientpages' ],
+ [ 'BrokenRedirectsPage', 'BrokenRedirects' ],
+ [ 'DeadendPagesPage', 'Deadendpages' ],
+ [ 'DoubleRedirectsPage', 'DoubleRedirects' ],
+ [ 'FileDuplicateSearchPage', 'FileDuplicateSearch' ],
+ [ 'ListDuplicatedFilesPage', 'ListDuplicatedFiles' ],
+ [ 'LinkSearchPage', 'LinkSearch' ],
+ [ 'ListredirectsPage', 'Listredirects' ],
+ [ 'LonelyPagesPage', 'Lonelypages' ],
+ [ 'LongPagesPage', 'Longpages' ],
+ [ 'MediaStatisticsPage', 'MediaStatistics' ],
+ [ 'MIMEsearchPage', 'MIMEsearch' ],
+ [ 'MostcategoriesPage', 'Mostcategories' ],
+ [ 'MostimagesPage', 'Mostimages' ],
+ [ 'MostinterwikisPage', 'Mostinterwikis' ],
+ [ 'MostlinkedCategoriesPage', 'Mostlinkedcategories' ],
+ [ 'MostlinkedTemplatesPage', 'Mostlinkedtemplates' ],
+ [ 'MostlinkedPage', 'Mostlinked' ],
+ [ 'MostrevisionsPage', 'Mostrevisions' ],
+ [ 'FewestrevisionsPage', 'Fewestrevisions' ],
+ [ 'ShortPagesPage', 'Shortpages' ],
+ [ 'UncategorizedCategoriesPage', 'Uncategorizedcategories' ],
+ [ 'UncategorizedPagesPage', 'Uncategorizedpages' ],
+ [ 'UncategorizedImagesPage', 'Uncategorizedimages' ],
+ [ 'UncategorizedTemplatesPage', 'Uncategorizedtemplates' ],
+ [ 'UnusedCategoriesPage', 'Unusedcategories' ],
+ [ 'UnusedimagesPage', 'Unusedimages' ],
+ [ 'WantedCategoriesPage', 'Wantedcategories' ],
+ [ 'WantedFilesPage', 'Wantedfiles' ],
+ [ 'WantedPagesPage', 'Wantedpages' ],
+ [ 'WantedTemplatesPage', 'Wantedtemplates' ],
+ [ 'UnwatchedpagesPage', 'Unwatchedpages' ],
+ [ 'UnusedtemplatesPage', 'Unusedtemplates' ],
+ [ 'WithoutInterwikiPage', 'Withoutinterwiki' ],
+ ];
+ Hooks::run( 'wgQueryPages', [ &$qp ] );
+ }
+
+ return $qp;
+ }
+
+ /**
+ * A mutator for $this->listoutput;
+ *
+ * @param bool $bool
+ */
+ function setListoutput( $bool ) {
+ $this->listoutput = $bool;
+ }
+
+ /**
+ * Subclasses return an SQL query here, formatted as an array with the
+ * following keys:
+ * tables => Table(s) for passing to Database::select()
+ * fields => Field(s) for passing to Database::select(), may be *
+ * conds => WHERE conditions
+ * options => options
+ * join_conds => JOIN conditions
+ *
+ * Note that the query itself should return the following three columns:
+ * 'namespace', 'title', and 'value'. 'value' is used for sorting.
+ *
+ * These may be stored in the querycache table for expensive queries,
+ * and that cached data will be returned sometimes, so the presence of
+ * extra fields can't be relied upon. The cached 'value' column will be
+ * an integer; non-numeric values are useful only for sorting the
+ * initial query (except if they're timestamps, see usesTimestamps()).
+ *
+ * Don't include an ORDER or LIMIT clause, they will be added.
+ *
+ * If this function is not overridden or returns something other than
+ * an array, getSQL() will be used instead. This is for backwards
+ * compatibility only and is strongly deprecated.
+ * @return array
+ * @since 1.18
+ */
+ public function getQueryInfo() {
+ return null;
+ }
+
+ /**
+ * For back-compat, subclasses may return a raw SQL query here, as a string.
+ * This is strongly deprecated; getQueryInfo() should be overridden instead.
+ * @throws MWException
+ * @return string
+ */
+ function getSQL() {
+ /* Implement getQueryInfo() instead */
+ throw new MWException( "Bug in a QueryPage: doesn't implement getQueryInfo() nor "
+ . "getQuery() properly" );
+ }
+
+ /**
+ * Subclasses return an array of fields to order by here. Don't append
+ * DESC to the field names, that'll be done automatically if
+ * sortDescending() returns true.
+ * @return array
+ * @since 1.18
+ */
+ function getOrderFields() {
+ return [ 'value' ];
+ }
+
+ /**
+ * Does this query return timestamps rather than integers in its
+ * 'value' field? If true, this class will convert 'value' to a
+ * UNIX timestamp for caching.
+ * NOTE: formatRow() may get timestamps in TS_MW (mysql), TS_DB (pgsql)
+ * or TS_UNIX (querycache) format, so be sure to always run them
+ * through wfTimestamp()
+ * @return bool
+ * @since 1.18
+ */
+ public function usesTimestamps() {
+ return false;
+ }
+
+ /**
+ * Override to sort by increasing values
+ *
+ * @return bool
+ */
+ function sortDescending() {
+ return true;
+ }
+
+ /**
+ * Is this query expensive (for some definition of expensive)? Then we
+ * don't let it run in miser mode. $wgDisableQueryPages causes all query
+ * pages to be declared expensive. Some query pages are always expensive.
+ *
+ * @return bool
+ */
+ public function isExpensive() {
+ return $this->getConfig()->get( 'DisableQueryPages' );
+ }
+
+ /**
+ * Is the output of this query cacheable? Non-cacheable expensive pages
+ * will be disabled in miser mode and will not have their results written
+ * to the querycache table.
+ * @return bool
+ * @since 1.18
+ */
+ public function isCacheable() {
+ return true;
+ }
+
+ /**
+ * Whether or not the output of the page in question is retrieved from
+ * the database cache.
+ *
+ * @return bool
+ */
+ public function isCached() {
+ return $this->isExpensive() && $this->getConfig()->get( 'MiserMode' );
+ }
+
+ /**
+ * Sometime we don't want to build rss / atom feeds.
+ *
+ * @return bool
+ */
+ function isSyndicated() {
+ return true;
+ }
+
+ /**
+ * Formats the results of the query for display. The skin is the current
+ * skin; you can use it for making links. The result is a single row of
+ * result data. You should be able to grab SQL results off of it.
+ * If the function returns false, the line output will be skipped.
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string|bool String or false to skip
+ */
+ abstract function formatResult( $skin, $result );
+
+ /**
+ * The content returned by this function will be output before any result
+ *
+ * @return string
+ */
+ function getPageHeader() {
+ return '';
+ }
+
+ /**
+ * Outputs some kind of an informative message (via OutputPage) to let the
+ * user know that the query returned nothing and thus there's nothing to
+ * show.
+ *
+ * @since 1.26
+ */
+ protected function showEmptyText() {
+ $this->getOutput()->addWikiMsg( 'specialpage-empty' );
+ }
+
+ /**
+ * If using extra form wheely-dealies, return a set of parameters here
+ * as an associative array. They will be encoded and added to the paging
+ * links (prev/next/lengths).
+ *
+ * @return array
+ */
+ function linkParameters() {
+ return [];
+ }
+
+ /**
+ * Some special pages (for example SpecialListusers used to) might not return the
+ * current object formatted, but return the previous one instead.
+ * Setting this to return true will ensure formatResult() is called
+ * one more time to make sure that the very last result is formatted
+ * as well.
+ *
+ * @deprecated since 1.27
+ *
+ * @return bool
+ */
+ function tryLastResult() {
+ return false;
+ }
+
+ /**
+ * Clear the cache and save new results
+ *
+ * @param int|bool $limit Limit for SQL statement
+ * @param bool $ignoreErrors Whether to ignore database errors
+ * @throws DBError|Exception
+ * @return bool|int
+ */
+ public function recache( $limit, $ignoreErrors = true ) {
+ if ( !$this->isCacheable() ) {
+ return 0;
+ }
+
+ $fname = static::class . '::recache';
+ $dbw = wfGetDB( DB_MASTER );
+ if ( !$dbw ) {
+ return false;
+ }
+
+ try {
+ # Do query
+ $res = $this->reallyDoQuery( $limit, false );
+ $num = false;
+ if ( $res ) {
+ $num = $res->numRows();
+ # Fetch results
+ $vals = [];
+ foreach ( $res as $row ) {
+ if ( isset( $row->value ) ) {
+ if ( $this->usesTimestamps() ) {
+ $value = wfTimestamp( TS_UNIX,
+ $row->value );
+ } else {
+ $value = intval( $row->value ); // T16414
+ }
+ } else {
+ $value = 0;
+ }
+
+ $vals[] = [
+ 'qc_type' => $this->getName(),
+ 'qc_namespace' => $row->namespace,
+ 'qc_title' => $row->title,
+ 'qc_value' => $value
+ ];
+ }
+
+ $dbw->doAtomicSection(
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) use ( $vals ) {
+ # Clear out any old cached data
+ $dbw->delete( 'querycache',
+ [ 'qc_type' => $this->getName() ],
+ $fname
+ );
+ # Save results into the querycache table on the master
+ if ( count( $vals ) ) {
+ $dbw->insert( 'querycache', $vals, $fname );
+ }
+ # Update the querycache_info record for the page
+ $dbw->delete( 'querycache_info',
+ [ 'qci_type' => $this->getName() ],
+ $fname
+ );
+ $dbw->insert( 'querycache_info',
+ [ 'qci_type' => $this->getName(),
+ 'qci_timestamp' => $dbw->timestamp() ],
+ $fname
+ );
+ }
+ );
+ }
+ } catch ( DBError $e ) {
+ if ( !$ignoreErrors ) {
+ throw $e; // report query error
+ }
+ $num = false; // set result to false to indicate error
+ }
+
+ return $num;
+ }
+
+ /**
+ * Get a DB connection to be used for slow recache queries
+ * @return IDatabase
+ */
+ function getRecacheDB() {
+ return wfGetDB( DB_REPLICA, [ $this->getName(), 'QueryPage::recache', 'vslow' ] );
+ }
+
+ /**
+ * Run the query and return the result
+ * @param int|bool $limit Numerical limit or false for no limit
+ * @param int|bool $offset Numerical offset or false for no offset
+ * @return ResultWrapper
+ * @since 1.18
+ */
+ public function reallyDoQuery( $limit, $offset = false ) {
+ $fname = static::class . '::reallyDoQuery';
+ $dbr = $this->getRecacheDB();
+ $query = $this->getQueryInfo();
+ $order = $this->getOrderFields();
+
+ if ( $this->sortDescending() ) {
+ foreach ( $order as &$field ) {
+ $field .= ' DESC';
+ }
+ }
+
+ if ( is_array( $query ) ) {
+ $tables = isset( $query['tables'] ) ? (array)$query['tables'] : [];
+ $fields = isset( $query['fields'] ) ? (array)$query['fields'] : [];
+ $conds = isset( $query['conds'] ) ? (array)$query['conds'] : [];
+ $options = isset( $query['options'] ) ? (array)$query['options'] : [];
+ $join_conds = isset( $query['join_conds'] ) ? (array)$query['join_conds'] : [];
+
+ if ( $order ) {
+ $options['ORDER BY'] = $order;
+ }
+
+ if ( $limit !== false ) {
+ $options['LIMIT'] = intval( $limit );
+ }
+
+ if ( $offset !== false ) {
+ $options['OFFSET'] = intval( $offset );
+ }
+
+ $res = $dbr->select( $tables, $fields, $conds, $fname,
+ $options, $join_conds
+ );
+ } else {
+ // Old-fashioned raw SQL style, deprecated
+ $sql = $this->getSQL();
+ $sql .= ' ORDER BY ' . implode( ', ', $order );
+ $sql = $dbr->limitResult( $sql, $limit, $offset );
+ $res = $dbr->query( $sql, $fname );
+ }
+
+ return $res;
+ }
+
+ /**
+ * Somewhat deprecated, you probably want to be using execute()
+ * @param int|bool $offset
+ * @param int|bool $limit
+ * @return ResultWrapper
+ */
+ public function doQuery( $offset = false, $limit = false ) {
+ if ( $this->isCached() && $this->isCacheable() ) {
+ return $this->fetchFromCache( $limit, $offset );
+ } else {
+ return $this->reallyDoQuery( $limit, $offset );
+ }
+ }
+
+ /**
+ * Fetch the query results from the query cache
+ * @param int|bool $limit Numerical limit or false for no limit
+ * @param int|bool $offset Numerical offset or false for no offset
+ * @return ResultWrapper
+ * @since 1.18
+ */
+ public function fetchFromCache( $limit, $offset = false ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $options = [];
+
+ if ( $limit !== false ) {
+ $options['LIMIT'] = intval( $limit );
+ }
+
+ if ( $offset !== false ) {
+ $options['OFFSET'] = intval( $offset );
+ }
+
+ $order = $this->getCacheOrderFields();
+ if ( $this->sortDescending() ) {
+ foreach ( $order as &$field ) {
+ $field .= " DESC";
+ }
+ }
+ if ( $order ) {
+ $options['ORDER BY'] = $order;
+ }
+
+ return $dbr->select( 'querycache',
+ [ 'qc_type',
+ 'namespace' => 'qc_namespace',
+ 'title' => 'qc_title',
+ 'value' => 'qc_value' ],
+ [ 'qc_type' => $this->getName() ],
+ __METHOD__,
+ $options
+ );
+ }
+
+ /**
+ * Return the order fields for fetchFromCache. Default is to always use
+ * "ORDER BY value" which was the default prior to this function.
+ * @return array
+ * @since 1.29
+ */
+ function getCacheOrderFields() {
+ return [ 'value' ];
+ }
+
+ public function getCachedTimestamp() {
+ if ( is_null( $this->cachedTimestamp ) ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $fname = static::class . '::getCachedTimestamp';
+ $this->cachedTimestamp = $dbr->selectField( 'querycache_info', 'qci_timestamp',
+ [ 'qci_type' => $this->getName() ], $fname );
+ }
+ return $this->cachedTimestamp;
+ }
+
+ /**
+ * Returns limit and offset, as returned by $this->getRequest()->getLimitOffset().
+ * Subclasses may override this to further restrict or modify limit and offset.
+ *
+ * @note Restricts the offset parameter, as most query pages have inefficient paging
+ *
+ * Its generally expected that the returned limit will not be 0, and the returned
+ * offset will be less than the max results.
+ *
+ * @since 1.26
+ * @return int[] list( $limit, $offset )
+ */
+ protected function getLimitOffset() {
+ list( $limit, $offset ) = $this->getRequest()->getLimitOffset();
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $maxResults = $this->getMaxResults();
+ // Can't display more than max results on a page
+ $limit = min( $limit, $maxResults );
+ // Can't skip over more than the end of $maxResults
+ $offset = min( $offset, $maxResults + 1 );
+ }
+ return [ $limit, $offset ];
+ }
+
+ /**
+ * What is limit to fetch from DB
+ *
+ * Used to make it appear the DB stores less results then it actually does
+ * @param int $uiLimit Limit from UI
+ * @param int $uiOffset Offset from UI
+ * @return int Limit to use for DB (not including extra row to see if at end)
+ */
+ protected function getDBLimit( $uiLimit, $uiOffset ) {
+ $maxResults = $this->getMaxResults();
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $limit = min( $uiLimit + 1, $maxResults - $uiOffset );
+ return max( $limit, 0 );
+ } else {
+ return $uiLimit + 1;
+ }
+ }
+
+ /**
+ * Get max number of results we can return in miser mode.
+ *
+ * Most QueryPage subclasses use inefficient paging, so limit the max amount we return
+ * This matters for uncached query pages that might otherwise accept an offset of 3 million
+ *
+ * @since 1.27
+ * @return int
+ */
+ protected function getMaxResults() {
+ // Max of 10000, unless we store more than 10000 in query cache.
+ return max( $this->getConfig()->get( 'QueryCacheLimit' ), 10000 );
+ }
+
+ /**
+ * This is the actual workhorse. It does everything needed to make a
+ * real, honest-to-gosh query page.
+ * @param string $par
+ */
+ public function execute( $par ) {
+ $user = $this->getUser();
+ if ( !$this->userCanExecute( $user ) ) {
+ $this->displayRestrictionError();
+ return;
+ }
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+
+ if ( $this->isCached() && !$this->isCacheable() ) {
+ $out->addWikiMsg( 'querypage-disabled' );
+ return;
+ }
+
+ $out->setSyndicated( $this->isSyndicated() );
+
+ if ( $this->limit == 0 && $this->offset == 0 ) {
+ list( $this->limit, $this->offset ) = $this->getLimitOffset();
+ }
+ $dbLimit = $this->getDBLimit( $this->limit, $this->offset );
+ // @todo Use doQuery()
+ if ( !$this->isCached() ) {
+ # select one extra row for navigation
+ $res = $this->reallyDoQuery( $dbLimit, $this->offset );
+ } else {
+ # Get the cached result, select one extra row for navigation
+ $res = $this->fetchFromCache( $dbLimit, $this->offset );
+ if ( !$this->listoutput ) {
+ # Fetch the timestamp of this update
+ $ts = $this->getCachedTimestamp();
+ $lang = $this->getLanguage();
+ $maxResults = $lang->formatNum( $this->getConfig()->get( 'QueryCacheLimit' ) );
+
+ if ( $ts ) {
+ $updated = $lang->userTimeAndDate( $ts, $user );
+ $updateddate = $lang->userDate( $ts, $user );
+ $updatedtime = $lang->userTime( $ts, $user );
+ $out->addMeta( 'Data-Cache-Time', $ts );
+ $out->addJsConfigVars( 'dataCacheTime', $ts );
+ $out->addWikiMsg( 'perfcachedts', $updated, $updateddate, $updatedtime, $maxResults );
+ } else {
+ $out->addWikiMsg( 'perfcached', $maxResults );
+ }
+
+ # If updates on this page have been disabled, let the user know
+ # that the data set won't be refreshed for now
+ if ( is_array( $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
+ && in_array( $this->getName(), $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
+ ) {
+ $out->wrapWikiMsg(
+ "<div class=\"mw-querypage-no-updates\">\n$1\n</div>",
+ 'querypage-no-updates'
+ );
+ }
+ }
+ }
+
+ $this->numRows = $res->numRows();
+
+ $dbr = $this->getRecacheDB();
+ $this->preprocessResults( $dbr, $res );
+
+ $out->addHTML( Xml::openElement( 'div', [ 'class' => 'mw-spcontent' ] ) );
+
+ # Top header and navigation
+ if ( $this->shownavigation ) {
+ $out->addHTML( $this->getPageHeader() );
+ if ( $this->numRows > 0 ) {
+ $out->addHTML( $this->msg( 'showingresultsinrange' )->numParams(
+ min( $this->numRows, $this->limit ), # do not show the one extra row, if exist
+ $this->offset + 1, ( min( $this->numRows, $this->limit ) + $this->offset ) )->parseAsBlock() );
+ # Disable the "next" link when we reach the end
+ $miserMaxResults = $this->getConfig()->get( 'MiserMode' )
+ && ( $this->offset + $this->limit >= $this->getMaxResults() );
+ $atEnd = ( $this->numRows <= $this->limit ) || $miserMaxResults;
+ $paging = $this->getLanguage()->viewPrevNext( $this->getPageTitle( $par ), $this->offset,
+ $this->limit, $this->linkParameters(), $atEnd );
+ $out->addHTML( '<p>' . $paging . '</p>' );
+ } else {
+ # No results to show, so don't bother with "showing X of Y" etc.
+ # -- just let the user know and give up now
+ $this->showEmptyText();
+ $out->addHTML( Xml::closeElement( 'div' ) );
+ return;
+ }
+ }
+
+ # The actual results; specialist subclasses will want to handle this
+ # with more than a straight list, so we hand them the info, plus
+ # an OutputPage, and let them get on with it
+ $this->outputResults( $out,
+ $this->getSkin(),
+ $dbr, # Should use a ResultWrapper for this
+ $res,
+ min( $this->numRows, $this->limit ), # do not format the one extra row, if exist
+ $this->offset );
+
+ # Repeat the paging links at the bottom
+ if ( $this->shownavigation ) {
+ $out->addHTML( '<p>' . $paging . '</p>' );
+ }
+
+ $out->addHTML( Xml::closeElement( 'div' ) );
+ }
+
+ /**
+ * Format and output report results using the given information plus
+ * OutputPage
+ *
+ * @param OutputPage $out OutputPage to print to
+ * @param Skin $skin User skin to use
+ * @param IDatabase $dbr Database (read) connection to use
+ * @param ResultWrapper $res Result pointer
+ * @param int $num Number of available result rows
+ * @param int $offset Paging offset
+ */
+ protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
+ global $wgContLang;
+
+ if ( $num > 0 ) {
+ $html = [];
+ if ( !$this->listoutput ) {
+ $html[] = $this->openList( $offset );
+ }
+
+ # $res might contain the whole 1,000 rows, so we read up to
+ # $num [should update this to use a Pager]
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $i = 0; $i < $num && $row = $res->fetchObject(); $i++ ) {
+ // @codingStandardsIgnoreEnd
+ $line = $this->formatResult( $skin, $row );
+ if ( $line ) {
+ $html[] = $this->listoutput
+ ? $line
+ : "<li>{$line}</li>\n";
+ }
+ }
+
+ # Flush the final result
+ if ( $this->tryLastResult() ) {
+ $row = null;
+ $line = $this->formatResult( $skin, $row );
+ if ( $line ) {
+ $html[] = $this->listoutput
+ ? $line
+ : "<li>{$line}</li>\n";
+ }
+ }
+
+ if ( !$this->listoutput ) {
+ $html[] = $this->closeList();
+ }
+
+ $html = $this->listoutput
+ ? $wgContLang->listToText( $html )
+ : implode( '', $html );
+
+ $out->addHTML( $html );
+ }
+ }
+
+ /**
+ * @param int $offset
+ * @return string
+ */
+ function openList( $offset ) {
+ return "\n<ol start='" . ( $offset + 1 ) . "' class='special'>\n";
+ }
+
+ /**
+ * @return string
+ */
+ function closeList() {
+ return "</ol>\n";
+ }
+
+ /**
+ * Do any necessary preprocessing of the result object.
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ }
+
+ /**
+ * Similar to above, but packaging in a syndicated feed instead of a web page
+ * @param string $class
+ * @param int $limit
+ * @return bool
+ */
+ function doFeed( $class = '', $limit = 50 ) {
+ if ( !$this->getConfig()->get( 'Feed' ) ) {
+ $this->getOutput()->addWikiMsg( 'feed-unavailable' );
+ return false;
+ }
+
+ $limit = min( $limit, $this->getConfig()->get( 'FeedLimit' ) );
+
+ $feedClasses = $this->getConfig()->get( 'FeedClasses' );
+ if ( isset( $feedClasses[$class] ) ) {
+ /** @var RSSFeed|AtomFeed $feed */
+ $feed = new $feedClasses[$class](
+ $this->feedTitle(),
+ $this->feedDesc(),
+ $this->feedUrl() );
+ $feed->outHeader();
+
+ $res = $this->reallyDoQuery( $limit, 0 );
+ foreach ( $res as $obj ) {
+ $item = $this->feedResult( $obj );
+ if ( $item ) {
+ $feed->outItem( $item );
+ }
+ }
+
+ $feed->outFooter();
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Override for custom handling. If the titles/links are ok, just do
+ * feedItemDesc()
+ * @param object $row
+ * @return FeedItem|null
+ */
+ function feedResult( $row ) {
+ if ( !isset( $row->title ) ) {
+ return null;
+ }
+ $title = Title::makeTitle( intval( $row->namespace ), $row->title );
+ if ( $title ) {
+ $date = isset( $row->timestamp ) ? $row->timestamp : '';
+ $comments = '';
+ if ( $title ) {
+ $talkpage = $title->getTalkPage();
+ $comments = $talkpage->getFullURL();
+ }
+
+ return new FeedItem(
+ $title->getPrefixedText(),
+ $this->feedItemDesc( $row ),
+ $title->getFullURL(),
+ $date,
+ $this->feedItemAuthor( $row ),
+ $comments );
+ } else {
+ return null;
+ }
+ }
+
+ function feedItemDesc( $row ) {
+ return isset( $row->comment ) ? htmlspecialchars( $row->comment ) : '';
+ }
+
+ function feedItemAuthor( $row ) {
+ return isset( $row->user_text ) ? $row->user_text : '';
+ }
+
+ function feedTitle() {
+ $desc = $this->getDescription();
+ $code = $this->getConfig()->get( 'LanguageCode' );
+ $sitename = $this->getConfig()->get( 'Sitename' );
+ return "$sitename - $desc [$code]";
+ }
+
+ function feedDesc() {
+ return $this->msg( 'tagline' )->text();
+ }
+
+ function feedUrl() {
+ return $this->getPageTitle()->getFullURL();
+ }
+
+ /**
+ * Creates a new LinkBatch object, adds all pages from the passed ResultWrapper (MUST include
+ * title and optional the namespace field) and executes the batch. This operation will pre-cache
+ * LinkCache information like page existence and information for stub color and redirect hints.
+ *
+ * @param ResultWrapper $res The ResultWrapper object to process. Needs to include the title
+ * field and namespace field, if the $ns parameter isn't set.
+ * @param null $ns Use this namespace for the given titles in the ResultWrapper object,
+ * instead of the namespace value of $res.
+ */
+ protected function executeLBFromResultWrapper( ResultWrapper $res, $ns = null ) {
+ if ( !$res->numRows() ) {
+ return;
+ }
+
+ $batch = new LinkBatch;
+ foreach ( $res as $row ) {
+ $batch->add( $ns !== null ? $ns : $row->namespace, $row->title );
+ }
+ $batch->execute();
+
+ $res->seek( 0 );
+ }
+}
diff --git a/www/wiki/includes/specialpage/RedirectSpecialPage.php b/www/wiki/includes/specialpage/RedirectSpecialPage.php
new file mode 100644
index 00000000..8d39c996
--- /dev/null
+++ b/www/wiki/includes/specialpage/RedirectSpecialPage.php
@@ -0,0 +1,235 @@
+<?php
+/**
+ * Shortcuts to construct a special page alias.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Shortcut to construct a special page alias.
+ *
+ * @ingroup SpecialPage
+ */
+abstract class RedirectSpecialPage extends UnlistedSpecialPage {
+ // Query parameters that can be passed through redirects
+ protected $mAllowedRedirectParams = [];
+
+ // Query parameters added by redirects
+ protected $mAddedRedirectParams = [];
+
+ /**
+ * @param string|null $subpage
+ * @return Title|bool
+ */
+ public function execute( $subpage ) {
+ $redirect = $this->getRedirect( $subpage );
+ $query = $this->getRedirectQuery();
+ // Redirect to a page title with possible query parameters
+ if ( $redirect instanceof Title ) {
+ $url = $redirect->getFullUrlForRedirect( $query );
+ $this->getOutput()->redirect( $url );
+
+ return $redirect;
+ } elseif ( $redirect === true ) {
+ // Redirect to index.php with query parameters
+ $url = wfAppendQuery( wfScript( 'index' ), $query );
+ $this->getOutput()->redirect( $url );
+
+ return $redirect;
+ } else {
+ $this->showNoRedirectPage();
+ }
+ }
+
+ /**
+ * If the special page is a redirect, then get the Title object it redirects to.
+ * False otherwise.
+ *
+ * @param string|null $subpage
+ * @return Title|bool
+ */
+ abstract public function getRedirect( $subpage );
+
+ /**
+ * Return part of the request string for a special redirect page
+ * This allows passing, e.g. action=history to Special:Mypage, etc.
+ *
+ * @return array|bool
+ */
+ public function getRedirectQuery() {
+ $params = [];
+ $request = $this->getRequest();
+
+ foreach ( array_merge( $this->mAllowedRedirectParams,
+ [ 'uselang', 'useskin', 'debug' ] // parameters which can be passed to all pages
+ ) as $arg ) {
+ if ( $request->getVal( $arg, null ) !== null ) {
+ $params[$arg] = $request->getVal( $arg );
+ } elseif ( $request->getArray( $arg, null ) !== null ) {
+ $params[$arg] = $request->getArray( $arg );
+ }
+ }
+
+ foreach ( $this->mAddedRedirectParams as $arg => $val ) {
+ $params[$arg] = $val;
+ }
+
+ return count( $params )
+ ? $params
+ : false;
+ }
+
+ /**
+ * Indicate if the target of this redirect can be used to identify
+ * a particular user of this wiki (e.g., if the redirect is to the
+ * user page of a User). See T109724.
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function personallyIdentifiableTarget() {
+ return false;
+ }
+
+ protected function showNoRedirectPage() {
+ $class = static::class;
+ throw new MWException( "RedirectSpecialPage $class doesn't redirect!" );
+ }
+}
+
+/**
+ * @ingroup SpecialPage
+ */
+abstract class SpecialRedirectToSpecial extends RedirectSpecialPage {
+ /** @var string Name of redirect target */
+ protected $redirName;
+
+ /** @var string Name of subpage of redirect target */
+ protected $redirSubpage;
+
+ function __construct(
+ $name, $redirName, $redirSubpage = false,
+ $allowedRedirectParams = [], $addedRedirectParams = []
+ ) {
+ parent::__construct( $name );
+ $this->redirName = $redirName;
+ $this->redirSubpage = $redirSubpage;
+ $this->mAllowedRedirectParams = $allowedRedirectParams;
+ $this->mAddedRedirectParams = $addedRedirectParams;
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title|bool
+ */
+ public function getRedirect( $subpage ) {
+ if ( $this->redirSubpage === false ) {
+ return SpecialPage::getTitleFor( $this->redirName, $subpage );
+ }
+
+ return SpecialPage::getTitleFor( $this->redirName, $this->redirSubpage );
+ }
+}
+
+/**
+ * Superclass for any RedirectSpecialPage which redirects the user
+ * to a particular article (as opposed to user contributions, logs, etc.).
+ *
+ * For security reasons these special pages are restricted to pass on
+ * the following subset of GET parameters to the target page while
+ * removing all others:
+ *
+ * - useskin, uselang, printable: to alter the appearance of the resulting page
+ *
+ * - redirect: allows viewing one's user page or talk page even if it is a
+ * redirect.
+ *
+ * - rdfrom: allows redirecting to one's user page or talk page from an
+ * external wiki with the "Redirect from..." notice.
+ *
+ * - limit, offset: Useful for linking to history of one's own user page or
+ * user talk page. For example, this would be a link to "the last edit to your
+ * user talk page in the year 2010":
+ * https://en.wikipedia.org/wiki/Special:MyPage?offset=20110000000000&limit=1&action=history
+ *
+ * - feed: would allow linking to the current user's RSS feed for their user
+ * talk page:
+ * https://en.wikipedia.org/w/index.php?title=Special:MyTalk&action=history&feed=rss
+ *
+ * - preloadtitle: Can be used to provide a default section title for a
+ * preloaded new comment on one's own talk page.
+ *
+ * - summary : Can be used to provide a default edit summary for a preloaded
+ * edit to one's own user page or talk page.
+ *
+ * - preview: Allows showing/hiding preview on first edit regardless of user
+ * preference, useful for preloaded edits where you know preview wouldn't be
+ * useful.
+ *
+ * - redlink: Affects the message the user sees if their talk page/user talk
+ * page does not currently exist. Avoids confusion for newbies with no user
+ * pages over why they got a "permission error" following this link:
+ * https://en.wikipedia.org/w/index.php?title=Special:MyPage&redlink=1
+ *
+ * - debug: determines whether the debug parameter is passed to load.php,
+ * which disables reformatting and allows scripts to be debugged. Useful
+ * when debugging scripts that manipulate one's own user page or talk page.
+ *
+ * @par Hook extension:
+ * Extensions can add to the redirect parameters list by using the hook
+ * RedirectSpecialArticleRedirectParams
+ *
+ * This hook allows extensions which add GET parameters like FlaggedRevs to
+ * retain those parameters when redirecting using special pages.
+ *
+ * @par Hook extension example:
+ * @code
+ * $wgHooks['RedirectSpecialArticleRedirectParams'][] =
+ * 'MyExtensionHooks::onRedirectSpecialArticleRedirectParams';
+ * public static function onRedirectSpecialArticleRedirectParams( &$redirectParams ) {
+ * $redirectParams[] = 'stable';
+ * return true;
+ * }
+ * @endcode
+ *
+ * @ingroup SpecialPage
+ */
+abstract class RedirectSpecialArticle extends RedirectSpecialPage {
+ function __construct( $name ) {
+ parent::__construct( $name );
+ $redirectParams = [
+ 'action',
+ 'redirect', 'rdfrom',
+ # Options for preloaded edits
+ 'preload', 'preloadparams', 'editintro', 'preloadtitle', 'summary', 'nosummary',
+ # Options for overriding user settings
+ 'preview', 'minor', 'watchthis',
+ # Options for history/diffs
+ 'section', 'oldid', 'diff', 'dir',
+ 'limit', 'offset', 'feed',
+ # Misc options
+ 'redlink',
+ # Options for action=raw; missing ctype can break JS or CSS in some browsers
+ 'ctype', 'maxage', 'smaxage',
+ ];
+
+ Hooks::run( "RedirectSpecialArticleRedirectParams", [ &$redirectParams ] );
+ $this->mAllowedRedirectParams = $redirectParams;
+ }
+}
diff --git a/www/wiki/includes/specialpage/SpecialPage.php b/www/wiki/includes/specialpage/SpecialPage.php
new file mode 100644
index 00000000..4c3ca54b
--- /dev/null
+++ b/www/wiki/includes/specialpage/SpecialPage.php
@@ -0,0 +1,875 @@
+<?php
+/**
+ * Parent class for all special pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Parent class for all special pages.
+ *
+ * Includes some static functions for handling the special page list deprecated
+ * in favor of SpecialPageFactory.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialPage implements MessageLocalizer {
+ // The canonical name of this special page
+ // Also used for the default <h1> heading, @see getDescription()
+ protected $mName;
+
+ // The local name of this special page
+ private $mLocalName;
+
+ // Minimum user level required to access this page, or "" for anyone.
+ // Also used to categorise the pages in Special:Specialpages
+ protected $mRestriction;
+
+ // Listed in Special:Specialpages?
+ private $mListed;
+
+ // Whether or not this special page is being included from an article
+ protected $mIncluding;
+
+ // Whether the special page can be included in an article
+ protected $mIncludable;
+
+ /**
+ * Current request context
+ * @var IContextSource
+ */
+ protected $mContext;
+
+ /**
+ * @var \MediaWiki\Linker\LinkRenderer|null
+ */
+ private $linkRenderer;
+
+ /**
+ * Get a localised Title object for a specified special page name
+ * If you don't need a full Title object, consider using TitleValue through
+ * getTitleValueFor() below.
+ *
+ * @since 1.9
+ * @since 1.21 $fragment parameter added
+ *
+ * @param string $name
+ * @param string|bool $subpage Subpage string, or false to not use a subpage
+ * @param string $fragment The link fragment (after the "#")
+ * @return Title
+ * @throws MWException
+ */
+ public static function getTitleFor( $name, $subpage = false, $fragment = '' ) {
+ return Title::newFromTitleValue(
+ self::getTitleValueFor( $name, $subpage, $fragment )
+ );
+ }
+
+ /**
+ * Get a localised TitleValue object for a specified special page name
+ *
+ * @since 1.28
+ * @param string $name
+ * @param string|bool $subpage Subpage string, or false to not use a subpage
+ * @param string $fragment The link fragment (after the "#")
+ * @return TitleValue
+ */
+ public static function getTitleValueFor( $name, $subpage = false, $fragment = '' ) {
+ $name = SpecialPageFactory::getLocalNameFor( $name, $subpage );
+
+ return new TitleValue( NS_SPECIAL, $name, $fragment );
+ }
+
+ /**
+ * Get a localised Title object for a page name with a possibly unvalidated subpage
+ *
+ * @param string $name
+ * @param string|bool $subpage Subpage string, or false to not use a subpage
+ * @return Title|null Title object or null if the page doesn't exist
+ */
+ public static function getSafeTitleFor( $name, $subpage = false ) {
+ $name = SpecialPageFactory::getLocalNameFor( $name, $subpage );
+ if ( $name ) {
+ return Title::makeTitleSafe( NS_SPECIAL, $name );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Default constructor for special pages
+ * Derivative classes should call this from their constructor
+ * Note that if the user does not have the required level, an error message will
+ * be displayed by the default execute() method, without the global function ever
+ * being called.
+ *
+ * If you override execute(), you can recover the default behavior with userCanExecute()
+ * and displayRestrictionError()
+ *
+ * @param string $name Name of the special page, as seen in links and URLs
+ * @param string $restriction User right required, e.g. "block" or "delete"
+ * @param bool $listed Whether the page is listed in Special:Specialpages
+ * @param callable|bool $function Unused
+ * @param string $file Unused
+ * @param bool $includable Whether the page can be included in normal pages
+ */
+ public function __construct(
+ $name = '', $restriction = '', $listed = true,
+ $function = false, $file = '', $includable = false
+ ) {
+ $this->mName = $name;
+ $this->mRestriction = $restriction;
+ $this->mListed = $listed;
+ $this->mIncludable = $includable;
+ }
+
+ /**
+ * Get the name of this Special Page.
+ * @return string
+ */
+ function getName() {
+ return $this->mName;
+ }
+
+ /**
+ * Get the permission that a user must have to execute this page
+ * @return string
+ */
+ function getRestriction() {
+ return $this->mRestriction;
+ }
+
+ // @todo FIXME: Decide which syntax to use for this, and stick to it
+ /**
+ * Whether this special page is listed in Special:SpecialPages
+ * @since 1.3 (r3583)
+ * @return bool
+ */
+ function isListed() {
+ return $this->mListed;
+ }
+
+ /**
+ * Set whether this page is listed in Special:Specialpages, at run-time
+ * @since 1.3
+ * @param bool $listed
+ * @return bool
+ */
+ function setListed( $listed ) {
+ return wfSetVar( $this->mListed, $listed );
+ }
+
+ /**
+ * Get or set whether this special page is listed in Special:SpecialPages
+ * @since 1.6
+ * @param bool $x
+ * @return bool
+ */
+ function listed( $x = null ) {
+ return wfSetVar( $this->mListed, $x );
+ }
+
+ /**
+ * Whether it's allowed to transclude the special page via {{Special:Foo/params}}
+ * @return bool
+ */
+ public function isIncludable() {
+ return $this->mIncludable;
+ }
+
+ /**
+ * How long to cache page when it is being included.
+ *
+ * @note If cache time is not 0, then the current user becomes an anon
+ * if you want to do any per-user customizations, than this method
+ * must be overriden to return 0.
+ * @since 1.26
+ * @return int Time in seconds, 0 to disable caching altogether,
+ * false to use the parent page's cache settings
+ */
+ public function maxIncludeCacheTime() {
+ return $this->getConfig()->get( 'MiserMode' ) ? $this->getCacheTTL() : 0;
+ }
+
+ /**
+ * @return int Seconds that this page can be cached
+ */
+ protected function getCacheTTL() {
+ return 60 * 60;
+ }
+
+ /**
+ * Whether the special page is being evaluated via transclusion
+ * @param bool $x
+ * @return bool
+ */
+ function including( $x = null ) {
+ return wfSetVar( $this->mIncluding, $x );
+ }
+
+ /**
+ * Get the localised name of the special page
+ * @return string
+ */
+ function getLocalName() {
+ if ( !isset( $this->mLocalName ) ) {
+ $this->mLocalName = SpecialPageFactory::getLocalNameFor( $this->mName );
+ }
+
+ return $this->mLocalName;
+ }
+
+ /**
+ * Is this page expensive (for some definition of expensive)?
+ * Expensive pages are disabled or cached in miser mode. Originally used
+ * (and still overridden) by QueryPage and subclasses, moved here so that
+ * Special:SpecialPages can safely call it for all special pages.
+ *
+ * @return bool
+ */
+ public function isExpensive() {
+ return false;
+ }
+
+ /**
+ * Is this page cached?
+ * Expensive pages are cached or disabled in miser mode.
+ * Used by QueryPage and subclasses, moved here so that
+ * Special:SpecialPages can safely call it for all special pages.
+ *
+ * @return bool
+ * @since 1.21
+ */
+ public function isCached() {
+ return false;
+ }
+
+ /**
+ * Can be overridden by subclasses with more complicated permissions
+ * schemes.
+ *
+ * @return bool Should the page be displayed with the restricted-access
+ * pages?
+ */
+ public function isRestricted() {
+ // DWIM: If anons can do something, then it is not restricted
+ return $this->mRestriction != '' && !User::groupHasPermission( '*', $this->mRestriction );
+ }
+
+ /**
+ * Checks if the given user (identified by an object) can execute this
+ * special page (as defined by $mRestriction). Can be overridden by sub-
+ * classes with more complicated permissions schemes.
+ *
+ * @param User $user The user to check
+ * @return bool Does the user have permission to view the page?
+ */
+ public function userCanExecute( User $user ) {
+ return $user->isAllowed( $this->mRestriction );
+ }
+
+ /**
+ * Output an error message telling the user what access level they have to have
+ * @throws PermissionsError
+ */
+ function displayRestrictionError() {
+ throw new PermissionsError( $this->mRestriction );
+ }
+
+ /**
+ * Checks if userCanExecute, and if not throws a PermissionsError
+ *
+ * @since 1.19
+ * @return void
+ * @throws PermissionsError
+ */
+ public function checkPermissions() {
+ if ( !$this->userCanExecute( $this->getUser() ) ) {
+ $this->displayRestrictionError();
+ }
+ }
+
+ /**
+ * If the wiki is currently in readonly mode, throws a ReadOnlyError
+ *
+ * @since 1.19
+ * @return void
+ * @throws ReadOnlyError
+ */
+ public function checkReadOnly() {
+ if ( wfReadOnly() ) {
+ throw new ReadOnlyError;
+ }
+ }
+
+ /**
+ * If the user is not logged in, throws UserNotLoggedIn error
+ *
+ * The user will be redirected to Special:Userlogin with the given message as an error on
+ * the form.
+ *
+ * @since 1.23
+ * @param string $reasonMsg [optional] Message key to be displayed on login page
+ * @param string $titleMsg [optional] Passed on to UserNotLoggedIn constructor
+ * @throws UserNotLoggedIn
+ */
+ public function requireLogin(
+ $reasonMsg = 'exception-nologin-text', $titleMsg = 'exception-nologin'
+ ) {
+ if ( $this->getUser()->isAnon() ) {
+ throw new UserNotLoggedIn( $reasonMsg, $titleMsg );
+ }
+ }
+
+ /**
+ * Tells if the special page does something security-sensitive and needs extra defense against
+ * a stolen account (e.g. a reauthentication). What exactly that will mean is decided by the
+ * authentication framework.
+ * @return bool|string False or the argument for AuthManager::securitySensitiveOperationStatus().
+ * Typically a special page needing elevated security would return its name here.
+ */
+ protected function getLoginSecurityLevel() {
+ return false;
+ }
+
+ /**
+ * Verifies that the user meets the security level, possibly reauthenticating them in the process.
+ *
+ * This should be used when the page does something security-sensitive and needs extra defense
+ * against a stolen account (e.g. a reauthentication). The authentication framework will make
+ * an extra effort to make sure the user account is not compromised. What that exactly means
+ * will depend on the system and user settings; e.g. the user might be required to log in again
+ * unless their last login happened recently, or they might be given a second-factor challenge.
+ *
+ * Calling this method will result in one if these actions:
+ * - return true: all good.
+ * - return false and set a redirect: caller should abort; the redirect will take the user
+ * to the login page for reauthentication, and back.
+ * - throw an exception if there is no way for the user to meet the requirements without using
+ * a different access method (e.g. this functionality is only available from a specific IP).
+ *
+ * Note that this does not in any way check that the user is authorized to use this special page
+ * (use checkPermissions() for that).
+ *
+ * @param string $level A security level. Can be an arbitrary string, defaults to the page name.
+ * @return bool False means a redirect to the reauthentication page has been set and processing
+ * of the special page should be aborted.
+ * @throws ErrorPageError If the security level cannot be met, even with reauthentication.
+ */
+ protected function checkLoginSecurityLevel( $level = null ) {
+ $level = $level ?: $this->getName();
+ $securityStatus = AuthManager::singleton()->securitySensitiveOperationStatus( $level );
+ if ( $securityStatus === AuthManager::SEC_OK ) {
+ return true;
+ } elseif ( $securityStatus === AuthManager::SEC_REAUTH ) {
+ $request = $this->getRequest();
+ $title = self::getTitleFor( 'Userlogin' );
+ $query = [
+ 'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
+ 'returntoquery' => wfArrayToCgi( array_diff_key( $request->getQueryValues(),
+ [ 'title' => true ] ) ),
+ 'force' => $level,
+ ];
+ $url = $title->getFullURL( $query, false, PROTO_HTTPS );
+
+ $this->getOutput()->redirect( $url );
+ return false;
+ }
+
+ $titleMessage = wfMessage( 'specialpage-securitylevel-not-allowed-title' );
+ $errorMessage = wfMessage( 'specialpage-securitylevel-not-allowed' );
+ throw new ErrorPageError( $titleMessage, $errorMessage );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * For example, if a page supports subpages "foo", "bar" and "baz" (as in Special:PageName/foo,
+ * etc.):
+ *
+ * - `prefixSearchSubpages( "ba" )` should return `array( "bar", "baz" )`
+ * - `prefixSearchSubpages( "f" )` should return `array( "foo" )`
+ * - `prefixSearchSubpages( "z" )` should return `array()`
+ * - `prefixSearchSubpages( "" )` should return `array( foo", "bar", "baz" )`
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $subpages = $this->getSubpagesForPrefixSearch();
+ if ( !$subpages ) {
+ return [];
+ }
+
+ return self::prefixSearchArray( $search, $limit, $subpages, $offset );
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept for prefix
+ * searches. If this method requires a query you might instead want to implement
+ * prefixSearchSubpages() directly so you can support $limit and $offset. This
+ * method is better for static-ish lists of things.
+ *
+ * @return string[] subpages to search from
+ */
+ protected function getSubpagesForPrefixSearch() {
+ return [];
+ }
+
+ /**
+ * Perform a regular substring search for prefixSearchSubpages
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ protected function prefixSearchString( $search, $limit, $offset ) {
+ $title = Title::newFromText( $search );
+ if ( !$title || !$title->canExist() ) {
+ // No prefix suggestion in special and media namespace
+ return [];
+ }
+
+ $searchEngine = MediaWikiServices::getInstance()->newSearchEngine();
+ $searchEngine->setLimitOffset( $limit, $offset );
+ $searchEngine->setNamespaces( [] );
+ $result = $searchEngine->defaultPrefixSearch( $search );
+ return array_map( function ( Title $t ) {
+ return $t->getPrefixedText();
+ }, $result );
+ }
+
+ /**
+ * Helper function for implementations of prefixSearchSubpages() that
+ * filter the values in memory (as opposed to making a query).
+ *
+ * @since 1.24
+ * @param string $search
+ * @param int $limit
+ * @param array $subpages
+ * @param int $offset
+ * @return string[]
+ */
+ protected static function prefixSearchArray( $search, $limit, array $subpages, $offset ) {
+ $escaped = preg_quote( $search, '/' );
+ return array_slice( preg_grep( "/^$escaped/i",
+ array_slice( $subpages, $offset ) ), 0, $limit );
+ }
+
+ /**
+ * Sets headers - this should be called from the execute() method of all derived classes!
+ */
+ function setHeaders() {
+ $out = $this->getOutput();
+ $out->setArticleRelated( false );
+ $out->setRobotPolicy( $this->getRobotPolicy() );
+ $out->setPageTitle( $this->getDescription() );
+ if ( $this->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $out->addModuleStyles( [
+ 'mediawiki.ui.input',
+ 'mediawiki.ui.radio',
+ 'mediawiki.ui.checkbox',
+ ] );
+ }
+ }
+
+ /**
+ * Entry point.
+ *
+ * @since 1.20
+ *
+ * @param string|null $subPage
+ */
+ final public function run( $subPage ) {
+ /**
+ * Gets called before @see SpecialPage::execute.
+ * Return false to prevent calling execute() (since 1.27+).
+ *
+ * @since 1.20
+ *
+ * @param SpecialPage $this
+ * @param string|null $subPage
+ */
+ if ( !Hooks::run( 'SpecialPageBeforeExecute', [ $this, $subPage ] ) ) {
+ return;
+ }
+
+ if ( $this->beforeExecute( $subPage ) === false ) {
+ return;
+ }
+ $this->execute( $subPage );
+ $this->afterExecute( $subPage );
+
+ /**
+ * Gets called after @see SpecialPage::execute.
+ *
+ * @since 1.20
+ *
+ * @param SpecialPage $this
+ * @param string|null $subPage
+ */
+ Hooks::run( 'SpecialPageAfterExecute', [ $this, $subPage ] );
+ }
+
+ /**
+ * Gets called before @see SpecialPage::execute.
+ * Return false to prevent calling execute() (since 1.27+).
+ *
+ * @since 1.20
+ *
+ * @param string|null $subPage
+ * @return bool|void
+ */
+ protected function beforeExecute( $subPage ) {
+ // No-op
+ }
+
+ /**
+ * Gets called after @see SpecialPage::execute.
+ *
+ * @since 1.20
+ *
+ * @param string|null $subPage
+ */
+ protected function afterExecute( $subPage ) {
+ // No-op
+ }
+
+ /**
+ * Default execute method
+ * Checks user permissions
+ *
+ * This must be overridden by subclasses; it will be made abstract in a future version
+ *
+ * @param string|null $subPage
+ */
+ public function execute( $subPage ) {
+ $this->setHeaders();
+ $this->checkPermissions();
+ $this->checkLoginSecurityLevel( $this->getLoginSecurityLevel() );
+ $this->outputHeader();
+ }
+
+ /**
+ * Outputs a summary message on top of special pages
+ * Per default the message key is the canonical name of the special page
+ * May be overridden, i.e. by extensions to stick with the naming conventions
+ * for message keys: 'extensionname-xxx'
+ *
+ * @param string $summaryMessageKey Message key of the summary
+ */
+ function outputHeader( $summaryMessageKey = '' ) {
+ global $wgContLang;
+
+ if ( $summaryMessageKey == '' ) {
+ $msg = $wgContLang->lc( $this->getName() ) . '-summary';
+ } else {
+ $msg = $summaryMessageKey;
+ }
+ if ( !$this->msg( $msg )->isDisabled() && !$this->including() ) {
+ $this->getOutput()->wrapWikiMsg(
+ "<div class='mw-specialpage-summary'>\n$1\n</div>", $msg );
+ }
+ }
+
+ /**
+ * Returns the name that goes in the \<h1\> in the special page itself, and
+ * also the name that will be listed in Special:Specialpages
+ *
+ * Derived classes can override this, but usually it is easier to keep the
+ * default behavior.
+ *
+ * @return string
+ */
+ function getDescription() {
+ return $this->msg( strtolower( $this->mName ) )->text();
+ }
+
+ /**
+ * Get a self-referential title object
+ *
+ * @param string|bool $subpage
+ * @return Title
+ * @deprecated since 1.23, use SpecialPage::getPageTitle
+ */
+ function getTitle( $subpage = false ) {
+ return $this->getPageTitle( $subpage );
+ }
+
+ /**
+ * Get a self-referential title object
+ *
+ * @param string|bool $subpage
+ * @return Title
+ * @since 1.23
+ */
+ function getPageTitle( $subpage = false ) {
+ return self::getTitleFor( $this->mName, $subpage );
+ }
+
+ /**
+ * Sets the context this SpecialPage is executed in
+ *
+ * @param IContextSource $context
+ * @since 1.18
+ */
+ public function setContext( $context ) {
+ $this->mContext = $context;
+ }
+
+ /**
+ * Gets the context this SpecialPage is executed in
+ *
+ * @return IContextSource|RequestContext
+ * @since 1.18
+ */
+ public function getContext() {
+ if ( $this->mContext instanceof IContextSource ) {
+ return $this->mContext;
+ } else {
+ wfDebug( __METHOD__ . " called and \$mContext is null. " .
+ "Return RequestContext::getMain(); for sanity\n" );
+
+ return RequestContext::getMain();
+ }
+ }
+
+ /**
+ * Get the WebRequest being used for this instance
+ *
+ * @return WebRequest
+ * @since 1.18
+ */
+ public function getRequest() {
+ return $this->getContext()->getRequest();
+ }
+
+ /**
+ * Get the OutputPage being used for this instance
+ *
+ * @return OutputPage
+ * @since 1.18
+ */
+ public function getOutput() {
+ return $this->getContext()->getOutput();
+ }
+
+ /**
+ * Shortcut to get the User executing this instance
+ *
+ * @return User
+ * @since 1.18
+ */
+ public function getUser() {
+ return $this->getContext()->getUser();
+ }
+
+ /**
+ * Shortcut to get the skin being used for this instance
+ *
+ * @return Skin
+ * @since 1.18
+ */
+ public function getSkin() {
+ return $this->getContext()->getSkin();
+ }
+
+ /**
+ * Shortcut to get user's language
+ *
+ * @return Language
+ * @since 1.19
+ */
+ public function getLanguage() {
+ return $this->getContext()->getLanguage();
+ }
+
+ /**
+ * Shortcut to get main config object
+ * @return Config
+ * @since 1.24
+ */
+ public function getConfig() {
+ return $this->getContext()->getConfig();
+ }
+
+ /**
+ * Return the full title, including $par
+ *
+ * @return Title
+ * @since 1.18
+ */
+ public function getFullTitle() {
+ return $this->getContext()->getTitle();
+ }
+
+ /**
+ * Return the robot policy. Derived classes that override this can change
+ * the robot policy set by setHeaders() from the default 'noindex,nofollow'.
+ *
+ * @return string
+ * @since 1.23
+ */
+ protected function getRobotPolicy() {
+ return 'noindex,nofollow';
+ }
+
+ /**
+ * Wrapper around wfMessage that sets the current context.
+ *
+ * @since 1.16
+ * @return Message
+ * @see wfMessage
+ */
+ public function msg( $key /* $args */ ) {
+ $message = call_user_func_array(
+ [ $this->getContext(), 'msg' ],
+ func_get_args()
+ );
+ // RequestContext passes context to wfMessage, and the language is set from
+ // the context, but setting the language for Message class removes the
+ // interface message status, which breaks for example usernameless gender
+ // invocations. Restore the flag when not including special page in content.
+ if ( $this->including() ) {
+ $message->setInterfaceMessageFlag( false );
+ }
+
+ return $message;
+ }
+
+ /**
+ * Adds RSS/atom links
+ *
+ * @param array $params
+ */
+ protected function addFeedLinks( $params ) {
+ $feedTemplate = wfScript( 'api' );
+
+ foreach ( $this->getConfig()->get( 'FeedClasses' ) as $format => $class ) {
+ $theseParams = $params + [ 'feedformat' => $format ];
+ $url = wfAppendQuery( $feedTemplate, $theseParams );
+ $this->getOutput()->addFeedLink( $format, $url );
+ }
+ }
+
+ /**
+ * Adds help link with an icon via page indicators.
+ * Link target can be overridden by a local message containing a wikilink:
+ * the message key is: lowercase special page name + '-helppage'.
+ * @param string $to Target MediaWiki.org page title or encoded URL.
+ * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o.
+ * @since 1.25
+ */
+ public function addHelpLink( $to, $overrideBaseUrl = false ) {
+ if ( $this->including() ) {
+ return;
+ }
+
+ global $wgContLang;
+ $msg = $this->msg( $wgContLang->lc( $this->getName() ) . '-helppage' );
+
+ if ( !$msg->isDisabled() ) {
+ $helpUrl = Skin::makeUrl( $msg->plain() );
+ $this->getOutput()->addHelpLink( $helpUrl, true );
+ } else {
+ $this->getOutput()->addHelpLink( $to, $overrideBaseUrl );
+ }
+ }
+
+ /**
+ * Get the group that the special page belongs in on Special:SpecialPage
+ * Use this method, instead of getGroupName to allow customization
+ * of the group name from the wiki side
+ *
+ * @return string Group of this special page
+ * @since 1.21
+ */
+ public function getFinalGroupName() {
+ $name = $this->getName();
+
+ // Allow overbidding the group from the wiki side
+ $msg = $this->msg( 'specialpages-specialpagegroup-' . strtolower( $name ) )->inContentLanguage();
+ if ( !$msg->isBlank() ) {
+ $group = $msg->text();
+ } else {
+ // Than use the group from this object
+ $group = $this->getGroupName();
+ }
+
+ return $group;
+ }
+
+ /**
+ * Indicates whether this special page may perform database writes
+ *
+ * @return bool
+ * @since 1.27
+ */
+ public function doesWrites() {
+ return false;
+ }
+
+ /**
+ * Under which header this special page is listed in Special:SpecialPages
+ * See messages 'specialpages-group-*' for valid names
+ * This method defaults to group 'other'
+ *
+ * @return string
+ * @since 1.21
+ */
+ protected function getGroupName() {
+ return 'other';
+ }
+
+ /**
+ * Call wfTransactionalTimeLimit() if this request was POSTed
+ * @since 1.26
+ */
+ protected function useTransactionalTimeLimit() {
+ if ( $this->getRequest()->wasPosted() ) {
+ wfTransactionalTimeLimit();
+ }
+ }
+
+ /**
+ * @since 1.28
+ * @return \MediaWiki\Linker\LinkRenderer
+ */
+ public function getLinkRenderer() {
+ if ( $this->linkRenderer ) {
+ return $this->linkRenderer;
+ } else {
+ return MediaWikiServices::getInstance()->getLinkRenderer();
+ }
+ }
+
+ /**
+ * @since 1.28
+ * @param \MediaWiki\Linker\LinkRenderer $linkRenderer
+ */
+ public function setLinkRenderer( LinkRenderer $linkRenderer ) {
+ $this->linkRenderer = $linkRenderer;
+ }
+}
diff --git a/www/wiki/includes/specialpage/SpecialPageFactory.php b/www/wiki/includes/specialpage/SpecialPageFactory.php
new file mode 100644
index 00000000..4433ddb7
--- /dev/null
+++ b/www/wiki/includes/specialpage/SpecialPageFactory.php
@@ -0,0 +1,719 @@
+<?php
+/**
+ * Factory for handling the special page list and generating SpecialPage objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @defgroup SpecialPage SpecialPage
+ */
+use MediaWiki\Linker\LinkRenderer;
+
+/**
+ * Factory for handling the special page list and generating SpecialPage objects.
+ *
+ * To add a special page in an extension, add to $wgSpecialPages either
+ * an object instance or an array containing the name and constructor
+ * parameters. The latter is preferred for performance reasons.
+ *
+ * The object instantiated must be either an instance of SpecialPage or a
+ * sub-class thereof. It must have an execute() method, which sends the HTML
+ * for the special page to $wgOut. The parent class has an execute() method
+ * which distributes the call to the historical global functions. Additionally,
+ * execute() also checks if the user has the necessary access privileges
+ * and bails out if not.
+ *
+ * To add a core special page, use the similar static list in
+ * SpecialPageFactory::$list. To remove a core static special page at runtime, use
+ * a SpecialPage_initList hook.
+ *
+ * @ingroup SpecialPage
+ * @since 1.17
+ */
+class SpecialPageFactory {
+ /**
+ * List of special page names to the subclass of SpecialPage which handles them.
+ */
+ private static $coreList = [
+ // Maintenance Reports
+ 'BrokenRedirects' => 'BrokenRedirectsPage',
+ 'Deadendpages' => 'DeadendPagesPage',
+ 'DoubleRedirects' => 'DoubleRedirectsPage',
+ 'Longpages' => 'LongPagesPage',
+ 'Ancientpages' => 'AncientPagesPage',
+ 'Lonelypages' => 'LonelyPagesPage',
+ 'Fewestrevisions' => 'FewestrevisionsPage',
+ 'Withoutinterwiki' => 'WithoutInterwikiPage',
+ 'Protectedpages' => 'SpecialProtectedpages',
+ 'Protectedtitles' => 'SpecialProtectedtitles',
+ 'Shortpages' => 'ShortPagesPage',
+ 'Uncategorizedcategories' => 'UncategorizedCategoriesPage',
+ 'Uncategorizedimages' => 'UncategorizedImagesPage',
+ 'Uncategorizedpages' => 'UncategorizedPagesPage',
+ 'Uncategorizedtemplates' => 'UncategorizedTemplatesPage',
+ 'Unusedcategories' => 'UnusedCategoriesPage',
+ 'Unusedimages' => 'UnusedimagesPage',
+ 'Unusedtemplates' => 'UnusedtemplatesPage',
+ 'Unwatchedpages' => 'UnwatchedpagesPage',
+ 'Wantedcategories' => 'WantedCategoriesPage',
+ 'Wantedfiles' => 'WantedFilesPage',
+ 'Wantedpages' => 'WantedPagesPage',
+ 'Wantedtemplates' => 'WantedTemplatesPage',
+
+ // List of pages
+ 'Allpages' => 'SpecialAllPages',
+ 'Prefixindex' => 'SpecialPrefixindex',
+ 'Categories' => 'SpecialCategories',
+ 'Listredirects' => 'ListredirectsPage',
+ 'PagesWithProp' => 'SpecialPagesWithProp',
+ 'TrackingCategories' => 'SpecialTrackingCategories',
+
+ // Authentication
+ 'Userlogin' => 'SpecialUserLogin',
+ 'Userlogout' => 'SpecialUserLogout',
+ 'CreateAccount' => 'SpecialCreateAccount',
+ 'LinkAccounts' => 'SpecialLinkAccounts',
+ 'UnlinkAccounts' => 'SpecialUnlinkAccounts',
+ 'ChangeCredentials' => 'SpecialChangeCredentials',
+ 'RemoveCredentials' => 'SpecialRemoveCredentials',
+
+ // Users and rights
+ 'Activeusers' => 'SpecialActiveUsers',
+ 'Block' => 'SpecialBlock',
+ 'Unblock' => 'SpecialUnblock',
+ 'BlockList' => 'SpecialBlockList',
+ 'AutoblockList' => 'SpecialAutoblockList',
+ 'ChangePassword' => 'SpecialChangePassword',
+ 'BotPasswords' => 'SpecialBotPasswords',
+ 'PasswordReset' => 'SpecialPasswordReset',
+ 'DeletedContributions' => 'DeletedContributionsPage',
+ 'Preferences' => 'SpecialPreferences',
+ 'ResetTokens' => 'SpecialResetTokens',
+ 'Contributions' => 'SpecialContributions',
+ 'Listgrouprights' => 'SpecialListGroupRights',
+ 'Listgrants' => 'SpecialListGrants',
+ 'Listusers' => 'SpecialListUsers',
+ 'Listadmins' => 'SpecialListAdmins',
+ 'Listbots' => 'SpecialListBots',
+ 'Userrights' => 'UserrightsPage',
+ 'EditWatchlist' => 'SpecialEditWatchlist',
+
+ // Recent changes and logs
+ 'Newimages' => 'SpecialNewFiles',
+ 'Log' => 'SpecialLog',
+ 'Watchlist' => 'SpecialWatchlist',
+ 'Newpages' => 'SpecialNewpages',
+ 'Recentchanges' => 'SpecialRecentChanges',
+ 'Recentchangeslinked' => 'SpecialRecentChangesLinked',
+ 'Tags' => 'SpecialTags',
+
+ // Media reports and uploads
+ 'Listfiles' => 'SpecialListFiles',
+ 'Filepath' => 'SpecialFilepath',
+ 'MediaStatistics' => 'MediaStatisticsPage',
+ 'MIMEsearch' => 'MIMEsearchPage',
+ 'FileDuplicateSearch' => 'FileDuplicateSearchPage',
+ 'Upload' => 'SpecialUpload',
+ 'UploadStash' => 'SpecialUploadStash',
+ 'ListDuplicatedFiles' => 'ListDuplicatedFilesPage',
+
+ // Data and tools
+ 'ApiSandbox' => 'SpecialApiSandbox',
+ 'Statistics' => 'SpecialStatistics',
+ 'Allmessages' => 'SpecialAllMessages',
+ 'Version' => 'SpecialVersion',
+ 'Lockdb' => 'SpecialLockdb',
+ 'Unlockdb' => 'SpecialUnlockdb',
+
+ // Redirecting special pages
+ 'LinkSearch' => 'LinkSearchPage',
+ 'Randompage' => 'RandomPage',
+ 'RandomInCategory' => 'SpecialRandomInCategory',
+ 'Randomredirect' => 'SpecialRandomredirect',
+ 'Randomrootpage' => 'SpecialRandomrootpage',
+ 'GoToInterwiki' => 'SpecialGoToInterwiki',
+
+ // High use pages
+ 'Mostlinkedcategories' => 'MostlinkedCategoriesPage',
+ 'Mostimages' => 'MostimagesPage',
+ 'Mostinterwikis' => 'MostinterwikisPage',
+ 'Mostlinked' => 'MostlinkedPage',
+ 'Mostlinkedtemplates' => 'MostlinkedTemplatesPage',
+ 'Mostcategories' => 'MostcategoriesPage',
+ 'Mostrevisions' => 'MostrevisionsPage',
+
+ // Page tools
+ 'ComparePages' => 'SpecialComparePages',
+ 'Export' => 'SpecialExport',
+ 'Import' => 'SpecialImport',
+ 'Undelete' => 'SpecialUndelete',
+ 'Whatlinkshere' => 'SpecialWhatLinksHere',
+ 'MergeHistory' => 'SpecialMergeHistory',
+ 'ExpandTemplates' => 'SpecialExpandTemplates',
+
+ // Other
+ 'Booksources' => 'SpecialBookSources',
+
+ // Unlisted / redirects
+ 'ApiHelp' => 'SpecialApiHelp',
+ 'Blankpage' => 'SpecialBlankpage',
+ 'Diff' => 'SpecialDiff',
+ 'EditTags' => 'SpecialEditTags',
+ 'Emailuser' => 'SpecialEmailUser',
+ 'Movepage' => 'MovePageForm',
+ 'Mycontributions' => 'SpecialMycontributions',
+ 'MyLanguage' => 'SpecialMyLanguage',
+ 'Mypage' => 'SpecialMypage',
+ 'Mytalk' => 'SpecialMytalk',
+ 'Myuploads' => 'SpecialMyuploads',
+ 'AllMyUploads' => 'SpecialAllMyUploads',
+ 'PermanentLink' => 'SpecialPermanentLink',
+ 'Redirect' => 'SpecialRedirect',
+ 'Revisiondelete' => 'SpecialRevisionDelete',
+ 'RunJobs' => 'SpecialRunJobs',
+ 'Specialpages' => 'SpecialSpecialpages',
+ 'PageData' => 'SpecialPageData'
+ ];
+
+ private static $list;
+ private static $aliases;
+
+ /**
+ * Reset the internal list of special pages. Useful when changing $wgSpecialPages after
+ * the internal list has already been initialized, e.g. during testing.
+ */
+ public static function resetList() {
+ self::$list = null;
+ self::$aliases = null;
+ }
+
+ /**
+ * Returns a list of canonical special page names.
+ * May be used to iterate over all registered special pages.
+ *
+ * @return string[]
+ */
+ public static function getNames() {
+ return array_keys( self::getPageList() );
+ }
+
+ /**
+ * Get the special page list as an array
+ *
+ * @deprecated since 1.24, use getNames() instead.
+ * @return array
+ */
+ public static function getList() {
+ wfDeprecated( __FUNCTION__, '1.24' );
+ return self::getPageList();
+ }
+
+ /**
+ * Get the special page list as an array
+ *
+ * @return array
+ */
+ private static function getPageList() {
+ global $wgSpecialPages;
+ global $wgDisableInternalSearch, $wgEmailAuthentication;
+ global $wgEnableEmail, $wgEnableJavaScriptTest;
+ global $wgPageLanguageUseDB, $wgContentHandlerUseDB;
+
+ if ( !is_array( self::$list ) ) {
+ self::$list = self::$coreList;
+
+ if ( !$wgDisableInternalSearch ) {
+ self::$list['Search'] = 'SpecialSearch';
+ }
+
+ if ( $wgEmailAuthentication ) {
+ self::$list['Confirmemail'] = 'EmailConfirmation';
+ self::$list['Invalidateemail'] = 'EmailInvalidation';
+ }
+
+ if ( $wgEnableEmail ) {
+ self::$list['ChangeEmail'] = 'SpecialChangeEmail';
+ }
+
+ if ( $wgEnableJavaScriptTest ) {
+ self::$list['JavaScriptTest'] = 'SpecialJavaScriptTest';
+ }
+
+ if ( $wgPageLanguageUseDB ) {
+ self::$list['PageLanguage'] = 'SpecialPageLanguage';
+ }
+ if ( $wgContentHandlerUseDB ) {
+ self::$list['ChangeContentModel'] = 'SpecialChangeContentModel';
+ }
+
+ // Add extension special pages
+ self::$list = array_merge( self::$list, $wgSpecialPages );
+
+ // This hook can be used to disable unwanted core special pages
+ // or conditionally register special pages.
+ Hooks::run( 'SpecialPage_initList', [ &self::$list ] );
+
+ }
+
+ return self::$list;
+ }
+
+ /**
+ * Initialise and return the list of special page aliases. Returns an array where
+ * the key is an alias, and the value is the canonical name of the special page.
+ * All registered special pages are guaranteed to map to themselves.
+ * @return array
+ */
+ private static function getAliasList() {
+ if ( is_null( self::$aliases ) ) {
+ global $wgContLang;
+ $aliases = $wgContLang->getSpecialPageAliases();
+ $pageList = self::getPageList();
+
+ self::$aliases = [];
+ $keepAlias = [];
+
+ // Force every canonical name to be an alias for itself.
+ foreach ( $pageList as $name => $stuff ) {
+ $caseFoldedAlias = $wgContLang->caseFold( $name );
+ self::$aliases[$caseFoldedAlias] = $name;
+ $keepAlias[$caseFoldedAlias] = 'canonical';
+ }
+
+ // Check for $aliases being an array since Language::getSpecialPageAliases can return null
+ if ( is_array( $aliases ) ) {
+ foreach ( $aliases as $realName => $aliasList ) {
+ $aliasList = array_values( $aliasList );
+ foreach ( $aliasList as $i => $alias ) {
+ $caseFoldedAlias = $wgContLang->caseFold( $alias );
+
+ if ( isset( self::$aliases[$caseFoldedAlias] ) &&
+ $realName === self::$aliases[$caseFoldedAlias]
+ ) {
+ // Ignore same-realName conflicts
+ continue;
+ }
+
+ if ( !isset( $keepAlias[$caseFoldedAlias] ) ) {
+ self::$aliases[$caseFoldedAlias] = $realName;
+ if ( !$i ) {
+ $keepAlias[$caseFoldedAlias] = 'first';
+ }
+ } elseif ( !$i ) {
+ wfWarn( "First alias '$alias' for $realName conflicts with " .
+ "{$keepAlias[$caseFoldedAlias]} alias for " .
+ self::$aliases[$caseFoldedAlias]
+ );
+ }
+ }
+ }
+ }
+ }
+
+ return self::$aliases;
+ }
+
+ /**
+ * Given a special page name with a possible subpage, return an array
+ * where the first element is the special page name and the second is the
+ * subpage.
+ *
+ * @param string $alias
+ * @return array Array( String, String|null ), or array( null, null ) if the page is invalid
+ */
+ public static function resolveAlias( $alias ) {
+ global $wgContLang;
+ $bits = explode( '/', $alias, 2 );
+
+ $caseFoldedAlias = $wgContLang->caseFold( $bits[0] );
+ $caseFoldedAlias = str_replace( ' ', '_', $caseFoldedAlias );
+ $aliases = self::getAliasList();
+ if ( isset( $aliases[$caseFoldedAlias] ) ) {
+ $name = $aliases[$caseFoldedAlias];
+ } else {
+ return [ null, null ];
+ }
+
+ if ( !isset( $bits[1] ) ) { // T4087
+ $par = null;
+ } else {
+ $par = $bits[1];
+ }
+
+ return [ $name, $par ];
+ }
+
+ /**
+ * Check if a given name exist as a special page or as a special page alias
+ *
+ * @param string $name Name of a special page
+ * @return bool True if a special page exists with this name
+ */
+ public static function exists( $name ) {
+ list( $title, /*...*/ ) = self::resolveAlias( $name );
+
+ $specialPageList = self::getPageList();
+ return isset( $specialPageList[$title] );
+ }
+
+ /**
+ * Find the object with a given name and return it (or NULL)
+ *
+ * @param string $name Special page name, may be localised and/or an alias
+ * @return SpecialPage|null SpecialPage object or null if the page doesn't exist
+ */
+ public static function getPage( $name ) {
+ list( $realName, /*...*/ ) = self::resolveAlias( $name );
+
+ $specialPageList = self::getPageList();
+
+ if ( isset( $specialPageList[$realName] ) ) {
+ $rec = $specialPageList[$realName];
+
+ if ( is_callable( $rec ) ) {
+ // Use callback to instantiate the special page
+ $page = call_user_func( $rec );
+ } elseif ( is_string( $rec ) ) {
+ $className = $rec;
+ $page = new $className;
+ } elseif ( is_array( $rec ) ) {
+ $className = array_shift( $rec );
+ // @deprecated, officially since 1.18, unofficially since forever
+ wfDeprecated( "Array syntax for \$wgSpecialPages is deprecated ($className), " .
+ "define a subclass of SpecialPage instead.", '1.18' );
+ $page = ObjectFactory::getObjectFromSpec( [
+ 'class' => $className,
+ 'args' => $rec,
+ 'closure_expansion' => false,
+ ] );
+ } elseif ( $rec instanceof SpecialPage ) {
+ $page = $rec; // XXX: we should deep clone here
+ } else {
+ $page = null;
+ }
+
+ if ( $page instanceof SpecialPage ) {
+ return $page;
+ } else {
+ // It's not a classname, nor a callback, nor a legacy constructor array,
+ // nor a special page object. Give up.
+ wfLogWarning( "Cannot instantiate special page $realName: bad spec!" );
+ return null;
+ }
+
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Return categorised listable special pages which are available
+ * for the current user, and everyone.
+ *
+ * @param User $user User object to check permissions, $wgUser will be used
+ * if not provided
+ * @return array ( string => Specialpage )
+ */
+ public static function getUsablePages( User $user = null ) {
+ $pages = [];
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+ foreach ( self::getPageList() as $name => $rec ) {
+ $page = self::getPage( $name );
+ if ( $page ) { // not null
+ $page->setContext( RequestContext::getMain() );
+ if ( $page->isListed()
+ && ( !$page->isRestricted() || $page->userCanExecute( $user ) )
+ ) {
+ $pages[$name] = $page;
+ }
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Return categorised listable special pages for all users
+ *
+ * @return array ( string => Specialpage )
+ */
+ public static function getRegularPages() {
+ $pages = [];
+ foreach ( self::getPageList() as $name => $rec ) {
+ $page = self::getPage( $name );
+ if ( $page && $page->isListed() && !$page->isRestricted() ) {
+ $pages[$name] = $page;
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Return categorised listable special pages which are available
+ * for the current user, but not for everyone
+ *
+ * @param User|null $user User object to use or null for $wgUser
+ * @return array ( string => Specialpage )
+ */
+ public static function getRestrictedPages( User $user = null ) {
+ $pages = [];
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+ foreach ( self::getPageList() as $name => $rec ) {
+ $page = self::getPage( $name );
+ if ( $page
+ && $page->isListed()
+ && $page->isRestricted()
+ && $page->userCanExecute( $user )
+ ) {
+ $pages[$name] = $page;
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Execute a special page path.
+ * The path may contain parameters, e.g. Special:Name/Params
+ * Extracts the special page name and call the execute method, passing the parameters
+ *
+ * Returns a title object if the page is redirected, false if there was no such special
+ * page, and true if it was successful.
+ *
+ * @param Title &$title
+ * @param IContextSource &$context
+ * @param bool $including Bool output is being captured for use in {{special:whatever}}
+ * @param LinkRenderer|null $linkRenderer (since 1.28)
+ *
+ * @return bool|Title
+ */
+ public static function executePath( Title &$title, IContextSource &$context, $including = false,
+ LinkRenderer $linkRenderer = null
+ ) {
+ // @todo FIXME: Redirects broken due to this call
+ $bits = explode( '/', $title->getDBkey(), 2 );
+ $name = $bits[0];
+ if ( !isset( $bits[1] ) ) { // T4087
+ $par = null;
+ } else {
+ $par = $bits[1];
+ }
+
+ $page = self::getPage( $name );
+ if ( !$page ) {
+ $context->getOutput()->setArticleRelated( false );
+ $context->getOutput()->setRobotPolicy( 'noindex,nofollow' );
+
+ global $wgSend404Code;
+ if ( $wgSend404Code ) {
+ $context->getOutput()->setStatusCode( 404 );
+ }
+
+ $context->getOutput()->showErrorPage( 'nosuchspecialpage', 'nospecialpagetext' );
+
+ return false;
+ }
+
+ if ( !$including ) {
+ // Narrow DB query expectations for this HTTP request
+ $trxLimits = $context->getConfig()->get( 'TrxProfilerLimits' );
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+ if ( $context->getRequest()->wasPosted() && !$page->doesWrites() ) {
+ $trxProfiler->setExpectations( $trxLimits['POST-nonwrite'], __METHOD__ );
+ $context->getRequest()->markAsSafeRequest();
+ }
+ }
+
+ // Page exists, set the context
+ $page->setContext( $context );
+
+ if ( !$including ) {
+ // Redirect to canonical alias for GET commands
+ // Not for POST, we'd lose the post data, so it's best to just distribute
+ // the request. Such POST requests are possible for old extensions that
+ // generate self-links without being aware that their default name has
+ // changed.
+ if ( $name != $page->getLocalName() && !$context->getRequest()->wasPosted() ) {
+ $query = $context->getRequest()->getQueryValues();
+ unset( $query['title'] );
+ $title = $page->getPageTitle( $par );
+ $url = $title->getFullURL( $query );
+ $context->getOutput()->redirect( $url );
+
+ return $title;
+ } else {
+ $context->setTitle( $page->getPageTitle( $par ) );
+ }
+ } elseif ( !$page->isIncludable() ) {
+ return false;
+ }
+
+ $page->including( $including );
+ if ( $linkRenderer ) {
+ $page->setLinkRenderer( $linkRenderer );
+ }
+
+ // Execute special page
+ $page->run( $par );
+
+ return true;
+ }
+
+ /**
+ * Just like executePath() but will override global variables and execute
+ * the page in "inclusion" mode. Returns true if the execution was
+ * successful or false if there was no such special page, or a title object
+ * if it was a redirect.
+ *
+ * Also saves the current $wgTitle, $wgOut, $wgRequest, $wgUser and $wgLang
+ * variables so that the special page will get the context it'd expect on a
+ * normal request, and then restores them to their previous values after.
+ *
+ * @param Title $title
+ * @param IContextSource $context
+ * @param LinkRenderer|null $linkRenderer (since 1.28)
+ * @return string HTML fragment
+ */
+ public static function capturePath(
+ Title $title, IContextSource $context, LinkRenderer $linkRenderer = null
+ ) {
+ global $wgTitle, $wgOut, $wgRequest, $wgUser, $wgLang;
+ $main = RequestContext::getMain();
+
+ // Save current globals and main context
+ $glob = [
+ 'title' => $wgTitle,
+ 'output' => $wgOut,
+ 'request' => $wgRequest,
+ 'user' => $wgUser,
+ 'language' => $wgLang,
+ ];
+ $ctx = [
+ 'title' => $main->getTitle(),
+ 'output' => $main->getOutput(),
+ 'request' => $main->getRequest(),
+ 'user' => $main->getUser(),
+ 'language' => $main->getLanguage(),
+ ];
+
+ // Override
+ $wgTitle = $title;
+ $wgOut = $context->getOutput();
+ $wgRequest = $context->getRequest();
+ $wgUser = $context->getUser();
+ $wgLang = $context->getLanguage();
+ $main->setTitle( $title );
+ $main->setOutput( $context->getOutput() );
+ $main->setRequest( $context->getRequest() );
+ $main->setUser( $context->getUser() );
+ $main->setLanguage( $context->getLanguage() );
+
+ // The useful part
+ $ret = self::executePath( $title, $context, true, $linkRenderer );
+
+ // Restore old globals and context
+ $wgTitle = $glob['title'];
+ $wgOut = $glob['output'];
+ $wgRequest = $glob['request'];
+ $wgUser = $glob['user'];
+ $wgLang = $glob['language'];
+ $main->setTitle( $ctx['title'] );
+ $main->setOutput( $ctx['output'] );
+ $main->setRequest( $ctx['request'] );
+ $main->setUser( $ctx['user'] );
+ $main->setLanguage( $ctx['language'] );
+
+ return $ret;
+ }
+
+ /**
+ * Get the local name for a specified canonical name
+ *
+ * @param string $name
+ * @param string|bool $subpage
+ * @return string
+ */
+ public static function getLocalNameFor( $name, $subpage = false ) {
+ global $wgContLang;
+ $aliases = $wgContLang->getSpecialPageAliases();
+ $aliasList = self::getAliasList();
+
+ // Find the first alias that maps back to $name
+ if ( isset( $aliases[$name] ) ) {
+ $found = false;
+ foreach ( $aliases[$name] as $alias ) {
+ $caseFoldedAlias = $wgContLang->caseFold( $alias );
+ $caseFoldedAlias = str_replace( ' ', '_', $caseFoldedAlias );
+ if ( isset( $aliasList[$caseFoldedAlias] ) &&
+ $aliasList[$caseFoldedAlias] === $name
+ ) {
+ $name = $alias;
+ $found = true;
+ break;
+ }
+ }
+ if ( !$found ) {
+ wfWarn( "Did not find a usable alias for special page '$name'. " .
+ "It seems all defined aliases conflict?" );
+ }
+ } else {
+ // Check if someone misspelled the correct casing
+ if ( is_array( $aliases ) ) {
+ foreach ( $aliases as $n => $values ) {
+ if ( strcasecmp( $name, $n ) === 0 ) {
+ wfWarn( "Found alias defined for $n when searching for " .
+ "special page aliases for $name. Case mismatch?" );
+ return self::getLocalNameFor( $n, $subpage );
+ }
+ }
+ }
+
+ wfWarn( "Did not find alias for special page '$name'. " .
+ "Perhaps no aliases are defined for it?" );
+ }
+
+ if ( $subpage !== false && !is_null( $subpage ) ) {
+ // Make sure it's in dbkey form
+ $subpage = str_replace( ' ', '_', $subpage );
+ $name = "$name/$subpage";
+ }
+
+ return $wgContLang->ucfirst( $name );
+ }
+
+ /**
+ * Get a title for a given alias
+ *
+ * @param string $alias
+ * @return Title|null Title or null if there is no such alias
+ */
+ public static function getTitleForAlias( $alias ) {
+ list( $name, $subpage ) = self::resolveAlias( $alias );
+ if ( $name != null ) {
+ return SpecialPage::getTitleFor( $name, $subpage );
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/www/wiki/includes/specialpage/UnlistedSpecialPage.php b/www/wiki/includes/specialpage/UnlistedSpecialPage.php
new file mode 100644
index 00000000..f5e2ccf7
--- /dev/null
+++ b/www/wiki/includes/specialpage/UnlistedSpecialPage.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Shortcut to construct a special page which is unlisted by default.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Shortcut to construct a special page which is unlisted by default.
+ *
+ * @ingroup SpecialPage
+ */
+class UnlistedSpecialPage extends SpecialPage {
+ function __construct( $name, $restriction = '', $function = false, $file = 'default' ) {
+ parent::__construct( $name, $restriction, false, $function, $file );
+ }
+
+ public function isListed() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/specialpage/WantedQueryPage.php b/www/wiki/includes/specialpage/WantedQueryPage.php
new file mode 100644
index 00000000..8b60387e
--- /dev/null
+++ b/www/wiki/includes/specialpage/WantedQueryPage.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * Class definition for a wanted query page.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Class definition for a wanted query page like
+ * WantedPages, WantedTemplates, etc
+ * @ingroup SpecialPage
+ */
+abstract class WantedQueryPage extends QueryPage {
+ function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ /**
+ * Cache page existence for performance
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * Should formatResult() always check page existence, even if
+ * the results are fresh? This is a (hopefully temporary)
+ * kluge for Special:WantedFiles, which may contain false
+ * positives for files that exist e.g. in a shared repo (bug
+ * 6220).
+ * @return bool
+ */
+ function forceExistenceCheck() {
+ return false;
+ }
+
+ /**
+ * Format an individual result
+ *
+ * @param Skin $skin Skin to use for UI elements
+ * @param object $result Result row
+ * @return string
+ */
+ public function formatResult( $skin, $result ) {
+ $linkRenderer = $this->getLinkRenderer();
+ $title = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( $title instanceof Title ) {
+ if ( $this->isCached() || $this->forceExistenceCheck() ) {
+ $pageLink = $this->existenceCheck( $title )
+ ? '<del>' . $linkRenderer->makeLink( $title ) . '</del>'
+ : $linkRenderer->makeLink( $title );
+ } else {
+ $pageLink = $linkRenderer->makeLink(
+ $title,
+ null,
+ [],
+ [],
+ [ 'broken' ]
+ );
+ }
+ return $this->getLanguage()->specialList( $pageLink, $this->makeWlhLink( $title, $result ) );
+ } else {
+ return $this->msg( 'wantedpages-badtitle', $result->title )->escaped();
+ }
+ }
+
+ /**
+ * Does the Title currently exists
+ *
+ * This method allows a subclass to override this check
+ * (For example, wantedfiles, would want to check if the file exists
+ * not just that a page in the file namespace exists).
+ *
+ * This will only control if the link is crossed out. Whether or not the link
+ * is blue vs red is controlled by if the title exists.
+ *
+ * @note This will only be run if the page is cached (ie $wgMiserMode = true)
+ * unless forceExistenceCheck() is true.
+ * @since 1.24
+ * @param Title $title
+ * @return bool
+ */
+ protected function existenceCheck( Title $title ) {
+ return $title->isKnown();
+ }
+
+ /**
+ * Make a "what links here" link for a given title
+ *
+ * @param Title $title Title to make the link for
+ * @param object $result Result row
+ * @return string
+ */
+ private function makeWlhLink( $title, $result ) {
+ $wlh = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() );
+ $label = $this->msg( 'nlinks' )->numParams( $result->value )->text();
+ return $this->getLinkRenderer()->makeLink( $wlh, $label );
+ }
+
+ /**
+ * Order by title for pages with the same number of links to them
+ *
+ * @return array
+ * @since 1.29
+ */
+ function getOrderFields() {
+ return [ 'value DESC', 'namespace', 'title' ];
+ }
+
+ /**
+ * Do not order descending for all order fields. We will use DESC only on one field, see
+ * getOrderFields above. This overwrites sortDescending from QueryPage::getOrderFields().
+ * Do NOT change this to true unless you remove the phrase DESC in getOrderFiels above.
+ * If you do a database error will be thrown due to double adding DESC to query!
+ *
+ * @return bool
+ * @since 1.29
+ */
+ function sortDescending() {
+ return false;
+ }
+
+ /**
+ * Also use the order fields returned by getOrderFields when fetching from the cache.
+ * @return array
+ * @since 1.29
+ */
+ function getCacheOrderFields() {
+ return $this->getOrderFields();
+ }
+
+}
diff --git a/www/wiki/includes/specials/SpecialActiveusers.php b/www/wiki/includes/specials/SpecialActiveusers.php
new file mode 100644
index 00000000..90287878
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialActiveusers.php
@@ -0,0 +1,172 @@
+<?php
+/**
+ * Implements Special:Activeusers
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:Activeusers
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialActiveUsers extends SpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'Activeusers' );
+ }
+
+ /**
+ * Show the special page
+ *
+ * @param string $par Parameter passed to the page or null
+ */
+ public function execute( $par ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $opts = new FormOptions();
+
+ $opts->add( 'username', '' );
+ $opts->add( 'groups', [] );
+ $opts->add( 'excludegroups', [] );
+ // Backwards-compatibility with old URLs
+ $opts->add( 'hidebots', false, FormOptions::BOOL );
+ $opts->add( 'hidesysops', false, FormOptions::BOOL );
+
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+
+ if ( $par !== null ) {
+ $opts->setValue( 'username', $par );
+ }
+
+ $pager = new ActiveUsersPager( $this->getContext(), $opts );
+ $usersBody = $pager->getBody();
+
+ $this->buildForm();
+
+ if ( $usersBody ) {
+ $out->addHTML(
+ $pager->getNavigationBar() .
+ Html::rawElement( 'ul', [], $usersBody ) .
+ $pager->getNavigationBar()
+ );
+ } else {
+ $out->addWikiMsg( 'activeusers-noresult' );
+ }
+ }
+
+ /**
+ * Generate and output the form
+ */
+ protected function buildForm() {
+ $groups = User::getAllGroups();
+
+ foreach ( $groups as $group ) {
+ $msg = htmlspecialchars( UserGroupMembership::getGroupName( $group ) );
+ $options[$msg] = $group;
+ }
+
+ // Backwards-compatibility with old URLs
+ $req = $this->getRequest();
+ $excludeDefault = [];
+ if ( $req->getCheck( 'hidebots' ) ) {
+ $excludeDefault[] = 'bot';
+ }
+ if ( $req->getCheck( 'hidesysops' ) ) {
+ $excludeDefault[] = 'sysop';
+ }
+
+ $formDescriptor = [
+ 'username' => [
+ 'type' => 'user',
+ 'name' => 'username',
+ 'label-message' => 'activeusers-from',
+ ],
+ 'groups' => [
+ 'type' => 'multiselect',
+ 'dropdown' => true,
+ 'flatlist' => true,
+ 'name' => 'groups',
+ 'label-message' => 'activeusers-groups',
+ 'options' => $options,
+ ],
+ 'excludegroups' => [
+ 'type' => 'multiselect',
+ 'dropdown' => true,
+ 'flatlist' => true,
+ 'name' => 'excludegroups',
+ 'label-message' => 'activeusers-excludegroups',
+ 'options' => $options,
+ 'default' => $excludeDefault,
+ ],
+ ];
+
+ HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
+ // For the 'multiselect' field values to be preserved on submit
+ ->setFormIdentifier( 'specialactiveusers' )
+ ->setIntro( $this->getIntroText() )
+ ->setWrapperLegendMsg( 'activeusers' )
+ ->setSubmitTextMsg( 'activeusers-submit' )
+ // prevent setting subpage and 'username' parameter at the same time
+ ->setAction( $this->getPageTitle()->getLocalURL() )
+ ->setMethod( 'get' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ /**
+ * Return introductory message.
+ * @return string
+ */
+ protected function getIntroText() {
+ $days = $this->getConfig()->get( 'ActiveUserDays' );
+
+ $intro = $this->msg( 'activeusers-intro' )->numParams( $days )->parse();
+
+ // Mention the level of cache staleness...
+ $dbr = wfGetDB( DB_REPLICA, 'recentchanges' );
+ $rcMax = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ );
+ if ( $rcMax ) {
+ $cTime = $dbr->selectField( 'querycache_info',
+ 'qci_timestamp',
+ [ 'qci_type' => 'activeusers' ],
+ __METHOD__
+ );
+ if ( $cTime ) {
+ $secondsOld = wfTimestamp( TS_UNIX, $rcMax ) - wfTimestamp( TS_UNIX, $cTime );
+ } else {
+ $rcMin = $dbr->selectField( 'recentchanges', 'MIN(rc_timestamp)' );
+ $secondsOld = time() - wfTimestamp( TS_UNIX, $rcMin );
+ }
+ if ( $secondsOld > 0 ) {
+ $intro .= $this->msg( 'cachedspecial-viewing-cached-ttl' )
+ ->durationParams( $secondsOld )->parseAsBlock();
+ }
+ }
+
+ return $intro;
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialAllMessages.php b/www/wiki/includes/specials/SpecialAllMessages.php
new file mode 100644
index 00000000..9e66447f
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialAllMessages.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Implements Special:Allmessages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Use this special page to get a list of the MediaWiki system messages.
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+class SpecialAllMessages extends SpecialPage {
+ /**
+ * @var AllMessagesTablePager
+ */
+ protected $table;
+
+ public function __construct() {
+ parent::__construct( 'Allmessages' );
+ }
+
+ /**
+ * Show the special page
+ *
+ * @param string $par Parameter passed to the page or null
+ */
+ public function execute( $par ) {
+ $request = $this->getRequest();
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+
+ if ( !$this->getConfig()->get( 'UseDatabaseMessages' ) ) {
+ $out->addWikiMsg( 'allmessagesnotsupportedDB' );
+
+ return;
+ }
+
+ $this->outputHeader( 'allmessagestext' );
+ $out->addModuleStyles( 'mediawiki.special' );
+ $this->addHelpLink( 'Help:System message' );
+
+ $this->table = new AllMessagesTablePager(
+ $this,
+ [],
+ wfGetLangObj( $request->getVal( 'lang', $par ) )
+ );
+
+ $out->addHTML( $this->table->buildForm() );
+ $out->addParserOutputContent( $this->table->getFullOutput() );
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialAllPages.php b/www/wiki/includes/specials/SpecialAllPages.php
new file mode 100644
index 00000000..f9c917d3
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialAllPages.php
@@ -0,0 +1,384 @@
+<?php
+/**
+ * Implements Special:Allpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:Allpages
+ *
+ * @ingroup SpecialPage
+ * @todo Rewrite using IndexPager
+ */
+class SpecialAllPages extends IncludableSpecialPage {
+
+ /**
+ * Maximum number of pages to show on single subpage.
+ *
+ * @var int $maxPerPage
+ */
+ protected $maxPerPage = 345;
+
+ /**
+ * Determines, which message describes the input field 'nsfrom'.
+ *
+ * @var string $nsfromMsg
+ */
+ protected $nsfromMsg = 'allpagesfrom';
+
+ /**
+ * @param string $name Name of the special page, as seen in links and URLs (default: 'Allpages')
+ */
+ function __construct( $name = 'Allpages' ) {
+ parent::__construct( $name );
+ }
+
+ /**
+ * Entry point : initialise variables and call subfunctions.
+ *
+ * @param string $par Becomes "FOO" when called like Special:Allpages/FOO (default null)
+ */
+ function execute( $par ) {
+ $request = $this->getRequest();
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $out->allowClickjacking();
+
+ # GET values
+ $from = $request->getVal( 'from', null );
+ $to = $request->getVal( 'to', null );
+ $namespace = $request->getInt( 'namespace' );
+
+ $miserMode = (bool)$this->getConfig()->get( 'MiserMode' );
+
+ // Redirects filter is disabled in MiserMode
+ $hideredirects = $request->getBool( 'hideredirects', false ) && !$miserMode;
+
+ $namespaces = $this->getLanguage()->getNamespaces();
+
+ $out->setPageTitle(
+ ( $namespace > 0 && array_key_exists( $namespace, $namespaces ) ) ?
+ $this->msg( 'allinnamespace', str_replace( '_', ' ', $namespaces[$namespace] ) ) :
+ $this->msg( 'allarticles' )
+ );
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ if ( $par !== null ) {
+ $this->showChunk( $namespace, $par, $to, $hideredirects );
+ } elseif ( $from !== null && $to === null ) {
+ $this->showChunk( $namespace, $from, $to, $hideredirects );
+ } else {
+ $this->showToplevel( $namespace, $from, $to, $hideredirects );
+ }
+ }
+
+ /**
+ * Outputs the HTMLForm used on this page
+ *
+ * @param int $namespace A namespace constant (default NS_MAIN).
+ * @param string $from DbKey we are starting listing at.
+ * @param string $to DbKey we are ending listing at.
+ * @param bool $hideRedirects Dont show redirects (default false)
+ */
+ protected function outputHTMLForm( $namespace = NS_MAIN,
+ $from = '', $to = '', $hideRedirects = false
+ ) {
+ $miserMode = (bool)$this->getConfig()->get( 'MiserMode' );
+ $fields = [
+ 'from' => [
+ 'type' => 'text',
+ 'name' => 'from',
+ 'id' => 'nsfrom',
+ 'size' => 30,
+ 'label-message' => 'allpagesfrom',
+ 'default' => str_replace( '_', ' ', $from ),
+ ],
+ 'to' => [
+ 'type' => 'text',
+ 'name' => 'to',
+ 'id' => 'nsto',
+ 'size' => 30,
+ 'label-message' => 'allpagesto',
+ 'default' => str_replace( '_', ' ', $to ),
+ ],
+ 'namespace' => [
+ 'type' => 'namespaceselect',
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'label-message' => 'namespace',
+ 'all' => null,
+ 'value' => $namespace,
+ ],
+ 'hideredirects' => [
+ 'type' => 'check',
+ 'name' => 'hideredirects',
+ 'id' => 'hidredirects',
+ 'label-message' => 'allpages-hide-redirects',
+ 'value' => $hideRedirects,
+ ],
+ ];
+
+ if ( $miserMode ) {
+ unset( $fields['hideredirects'] );
+ }
+
+ $form = HTMLForm::factory( 'table', $fields, $this->getContext() );
+ $form->setMethod( 'get' )
+ ->setWrapperLegendMsg( 'allpages' )
+ ->setSubmitTextMsg( 'allpagessubmit' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ /**
+ * @param int $namespace (default NS_MAIN)
+ * @param string $from List all pages from this name
+ * @param string $to List all pages to this name
+ * @param bool $hideredirects Dont show redirects (default false)
+ */
+ function showToplevel( $namespace = NS_MAIN, $from = '', $to = '', $hideredirects = false ) {
+ $from = Title::makeTitleSafe( $namespace, $from );
+ $to = Title::makeTitleSafe( $namespace, $to );
+ $from = ( $from && $from->isLocal() ) ? $from->getDBkey() : null;
+ $to = ( $to && $to->isLocal() ) ? $to->getDBkey() : null;
+
+ $this->showChunk( $namespace, $from, $to, $hideredirects );
+ }
+
+ /**
+ * @param int $namespace Namespace (Default NS_MAIN)
+ * @param string $from List all pages from this name (default false)
+ * @param string $to List all pages to this name (default false)
+ * @param bool $hideredirects Dont show redirects (default false)
+ */
+ function showChunk( $namespace = NS_MAIN, $from = false, $to = false, $hideredirects = false ) {
+ $output = $this->getOutput();
+
+ $fromList = $this->getNamespaceKeyAndText( $namespace, $from );
+ $toList = $this->getNamespaceKeyAndText( $namespace, $to );
+ $namespaces = $this->getContext()->getLanguage()->getNamespaces();
+ $n = 0;
+ $prevTitle = null;
+
+ if ( !$fromList || !$toList ) {
+ $out = $this->msg( 'allpagesbadtitle' )->parseAsBlock();
+ } elseif ( !array_key_exists( $namespace, $namespaces ) ) {
+ // Show errormessage and reset to NS_MAIN
+ $out = $this->msg( 'allpages-bad-ns', $namespace )->parse();
+ $namespace = NS_MAIN;
+ } else {
+ list( $namespace, $fromKey, $from ) = $fromList;
+ list( , $toKey, $to ) = $toList;
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $filterConds = [ 'page_namespace' => $namespace ];
+ if ( $hideredirects ) {
+ $filterConds['page_is_redirect'] = 0;
+ }
+
+ $conds = $filterConds;
+ $conds[] = 'page_title >= ' . $dbr->addQuotes( $fromKey );
+ if ( $toKey !== "" ) {
+ $conds[] = 'page_title <= ' . $dbr->addQuotes( $toKey );
+ }
+
+ $res = $dbr->select( 'page',
+ [ 'page_namespace', 'page_title', 'page_is_redirect', 'page_id' ],
+ $conds,
+ __METHOD__,
+ [
+ 'ORDER BY' => 'page_title',
+ 'LIMIT' => $this->maxPerPage + 1,
+ 'USE INDEX' => 'name_title',
+ ]
+ );
+
+ $linkRenderer = $this->getLinkRenderer();
+ if ( $res->numRows() > 0 ) {
+ $out = Html::openElement( 'ul', [ 'class' => 'mw-allpages-chunk' ] );
+
+ while ( ( $n < $this->maxPerPage ) && ( $s = $res->fetchObject() ) ) {
+ $t = Title::newFromRow( $s );
+ if ( $t ) {
+ $out .= '<li' .
+ ( $s->page_is_redirect ? ' class="allpagesredirect"' : '' ) .
+ '>' .
+ $linkRenderer->makeLink( $t ) .
+ "</li>\n";
+ } else {
+ $out .= '<li>[[' . htmlspecialchars( $s->page_title ) . "]]</li>\n";
+ }
+ $n++;
+ }
+ $out .= Html::closeElement( 'ul' );
+
+ if ( $res->numRows() > 2 ) {
+ // Only apply CSS column styles if there's more than 2 entries.
+ // Otherwise, rendering is broken as "mw-allpages-body"'s CSS column count is 3.
+ $out = Html::rawElement( 'div', [ 'class' => 'mw-allpages-body' ], $out );
+ }
+ } else {
+ $out = '';
+ }
+
+ if ( $fromKey !== '' && !$this->including() ) {
+ # Get the first title from previous chunk
+ $prevConds = $filterConds;
+ $prevConds[] = 'page_title < ' . $dbr->addQuotes( $fromKey );
+ $prevKey = $dbr->selectField(
+ 'page',
+ 'page_title',
+ $prevConds,
+ __METHOD__,
+ [ 'ORDER BY' => 'page_title DESC', 'OFFSET' => $this->maxPerPage - 1 ]
+ );
+
+ if ( $prevKey === false ) {
+ # The previous chunk is not complete, need to link to the very first title
+ # available in the database
+ $prevKey = $dbr->selectField(
+ 'page',
+ 'page_title',
+ $prevConds,
+ __METHOD__,
+ [ 'ORDER BY' => 'page_title' ]
+ );
+ }
+
+ if ( $prevKey !== false ) {
+ $prevTitle = Title::makeTitle( $namespace, $prevKey );
+ }
+ }
+ }
+
+ if ( $this->including() ) {
+ $output->addHTML( $out );
+ return;
+ }
+
+ $navLinks = [];
+ $self = $this->getPageTitle();
+
+ $linkRenderer = $this->getLinkRenderer();
+ // Generate a "previous page" link if needed
+ if ( $prevTitle ) {
+ $query = [ 'from' => $prevTitle->getText() ];
+
+ if ( $namespace ) {
+ $query['namespace'] = $namespace;
+ }
+
+ if ( $hideredirects ) {
+ $query['hideredirects'] = $hideredirects;
+ }
+
+ $navLinks[] = $linkRenderer->makeKnownLink(
+ $self,
+ $this->msg( 'prevpage', $prevTitle->getText() )->text(),
+ [],
+ $query
+ );
+
+ }
+
+ // Generate a "next page" link if needed
+ if ( $n == $this->maxPerPage && $s = $res->fetchObject() ) {
+ # $s is the first link of the next chunk
+ $t = Title::makeTitle( $namespace, $s->page_title );
+ $query = [ 'from' => $t->getText() ];
+
+ if ( $namespace ) {
+ $query['namespace'] = $namespace;
+ }
+
+ if ( $hideredirects ) {
+ $query['hideredirects'] = $hideredirects;
+ }
+
+ $navLinks[] = $linkRenderer->makeKnownLink(
+ $self,
+ $this->msg( 'nextpage', $t->getText() )->text(),
+ [],
+ $query
+ );
+ }
+
+ $this->outputHTMLForm( $namespace, $from, $to, $hideredirects );
+
+ if ( count( $navLinks ) ) {
+ // Add pagination links
+ $pagination = Html::rawElement( 'div',
+ [ 'class' => 'mw-allpages-nav' ],
+ $this->getLanguage()->pipeList( $navLinks )
+ );
+
+ $output->addHTML( $pagination );
+ $out .= Html::element( 'hr' ) . $pagination; // Footer
+ }
+
+ $output->addHTML( $out );
+ }
+
+ /**
+ * @param int $ns The namespace of the article
+ * @param string $text The name of the article
+ * @return array|null [ int namespace, string dbkey, string pagename ] or null on error
+ */
+ protected function getNamespaceKeyAndText( $ns, $text ) {
+ if ( $text == '' ) {
+ # shortcut for common case
+ return [ $ns, '', '' ];
+ }
+
+ $t = Title::makeTitleSafe( $ns, $text );
+ if ( $t && $t->isLocal() ) {
+ return [ $t->getNamespace(), $t->getDBkey(), $t->getText() ];
+ } elseif ( $t ) {
+ return null;
+ }
+
+ # try again, in case the problem was an empty pagename
+ $text = preg_replace( '/(#|$)/', 'X$1', $text );
+ $t = Title::makeTitleSafe( $ns, $text );
+ if ( $t && $t->isLocal() ) {
+ return [ $t->getNamespace(), '', '' ];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'pages';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialAncientpages.php b/www/wiki/includes/specials/SpecialAncientpages.php
new file mode 100644
index 00000000..ecc030e6
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialAncientpages.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Implements Special:Ancientpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:Ancientpages
+ *
+ * @ingroup SpecialPage
+ */
+class AncientPagesPage extends QueryPage {
+
+ function __construct( $name = 'Ancientpages' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'page', 'revision' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'rev_timestamp'
+ ],
+ 'conds' => [
+ 'page_namespace' => MWNamespace::getContentNamespaces(),
+ 'page_is_redirect' => 0,
+ 'page_latest=rev_id'
+ ]
+ ];
+ }
+
+ public function usesTimestamps() {
+ return true;
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ public function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ global $wgContLang;
+
+ $d = $this->getLanguage()->userTimeAndDate( $result->value, $this->getUser() );
+ $title = Title::makeTitle( $result->namespace, $result->title );
+ $linkRenderer = $this->getLinkRenderer();
+ $link = $linkRenderer->makeKnownLink(
+ $title,
+ $wgContLang->convert( $title->getPrefixedText() )
+ );
+
+ return $this->getLanguage()->specialList( $link, htmlspecialchars( $d ) );
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialApiHelp.php b/www/wiki/includes/specials/SpecialApiHelp.php
new file mode 100644
index 00000000..54480132
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialApiHelp.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Implements Special:ApiHelp
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page to redirect to API help pages, for situations where linking to
+ * the api.php endpoint is not wanted.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialApiHelp extends UnlistedSpecialPage {
+ public function __construct() {
+ parent::__construct( 'ApiHelp' );
+ }
+
+ public function execute( $par ) {
+ if ( empty( $par ) ) {
+ $par = 'main';
+ }
+
+ // These come from transclusions
+ $request = $this->getRequest();
+ $options = [
+ 'action' => 'help',
+ 'nolead' => true,
+ 'submodules' => $request->getCheck( 'submodules' ),
+ 'recursivesubmodules' => $request->getCheck( 'recursivesubmodules' ),
+ 'title' => $request->getVal( 'title', $this->getPageTitle( '$1' )->getPrefixedText() ),
+ ];
+
+ // These are for linking from wikitext, since url parameters are a pain
+ // to do.
+ while ( true ) {
+ if ( substr( $par, 0, 4 ) === 'sub/' ) {
+ $par = substr( $par, 4 );
+ $options['submodules'] = 1;
+ continue;
+ }
+
+ if ( substr( $par, 0, 5 ) === 'rsub/' ) {
+ $par = substr( $par, 5 );
+ $options['recursivesubmodules'] = 1;
+ continue;
+ }
+
+ $moduleName = $par;
+ break;
+ }
+
+ if ( !$this->including() ) {
+ unset( $options['nolead'], $options['title'] );
+ $options['modules'] = $moduleName;
+ $link = wfAppendQuery( wfExpandUrl( wfScript( 'api' ), PROTO_CURRENT ), $options );
+ $this->getOutput()->redirect( $link );
+ return;
+ }
+
+ $main = new ApiMain( $this->getContext(), false );
+ try {
+ $module = $main->getModuleFromPath( $moduleName );
+ } catch ( ApiUsageException $ex ) {
+ $this->getOutput()->addHTML( Html::rawElement( 'span', [ 'class' => 'error' ],
+ $this->msg( 'apihelp-no-such-module', $moduleName )->inContentLanguage()->parse()
+ ) );
+ return;
+ } catch ( UsageException $ex ) {
+ $this->getOutput()->addHTML( Html::rawElement( 'span', [ 'class' => 'error' ],
+ $this->msg( 'apihelp-no-such-module', $moduleName )->inContentLanguage()->parse()
+ ) );
+ return;
+ }
+
+ ApiHelp::getHelp( $this->getContext(), $module, $options );
+ }
+
+ public function isIncludable() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialApiSandbox.php b/www/wiki/includes/specials/SpecialApiSandbox.php
new file mode 100644
index 00000000..e9943477
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialApiSandbox.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Implements Special:ApiSandbox
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * @ingroup SpecialPage
+ * @since 1.27
+ */
+class SpecialApiSandbox extends SpecialPage {
+ public function __construct() {
+ parent::__construct( 'ApiSandbox' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $out = $this->getOutput();
+
+ if ( !$this->getConfig()->get( 'EnableAPI' ) ) {
+ $out->showErrorPage( 'error', 'apisandbox-api-disabled' );
+ }
+
+ $out->addJsConfigVars( 'apihighlimits', $this->getUser()->isAllowed( 'apihighlimits' ) );
+ $out->addModuleStyles( [
+ 'mediawiki.special.apisandbox.styles',
+ ] );
+ $out->addModules( [
+ 'mediawiki.special.apisandbox',
+ 'mediawiki.apipretty',
+ ] );
+ $out->wrapWikiMsg(
+ "<div id='mw-apisandbox'><div class='mw-apisandbox-nojs error'>\n$1\n</div></div>",
+ 'apisandbox-jsonly'
+ );
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialAutoblockList.php b/www/wiki/includes/specials/SpecialAutoblockList.php
new file mode 100644
index 00000000..bf138656
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialAutoblockList.php
@@ -0,0 +1,167 @@
+<?php
+/**
+ * Implements Special:AutoblockList
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists autoblocks
+ *
+ * @since 1.29
+ * @ingroup SpecialPage
+ */
+class SpecialAutoblockList extends SpecialPage {
+
+ function __construct() {
+ parent::__construct( 'AutoblockList' );
+ }
+
+ /**
+ * Main execution point
+ *
+ * @param string $par Title fragment
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $out = $this->getOutput();
+ $lang = $this->getLanguage();
+ $out->setPageTitle( $this->msg( 'autoblocklist' ) );
+ $this->addHelpLink( 'Autoblock' );
+ $out->addModuleStyles( [ 'mediawiki.special' ] );
+
+ # setup BlockListPager here to get the actual default Limit
+ $pager = $this->getBlockListPager();
+
+ # Just show the block list
+ $fields = [
+ 'Limit' => [
+ 'type' => 'limitselect',
+ 'label-message' => 'table_pager_limit_label',
+ 'options' => [
+ $lang->formatNum( 20 ) => 20,
+ $lang->formatNum( 50 ) => 50,
+ $lang->formatNum( 100 ) => 100,
+ $lang->formatNum( 250 ) => 250,
+ $lang->formatNum( 500 ) => 500,
+ ],
+ 'name' => 'limit',
+ 'default' => $pager->getLimit(),
+ ]
+ ];
+
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() ); // Remove subpage
+ $form = HTMLForm::factory( 'ooui', $fields, $context );
+ $form->setMethod( 'get' )
+ ->setFormIdentifier( 'blocklist' )
+ ->setWrapperLegendMsg( 'autoblocklist-legend' )
+ ->setSubmitTextMsg( 'autoblocklist-submit' )
+ ->setSubmitProgressive()
+ ->prepareForm()
+ ->displayForm( false );
+
+ $this->showTotal( $pager );
+ $this->showList( $pager );
+ }
+
+ /**
+ * Setup a new BlockListPager instance.
+ * @return BlockListPager
+ */
+ protected function getBlockListPager() {
+ $conds = [
+ 'ipb_parent_block_id IS NOT NULL'
+ ];
+ # Is the user allowed to see hidden blocks?
+ if ( !$this->getUser()->isAllowed( 'hideuser' ) ) {
+ $conds['ipb_deleted'] = 0;
+ }
+
+ return new BlockListPager( $this, $conds );
+ }
+
+ /**
+ * Show total number of autoblocks on top of the table
+ *
+ * @param BlockListPager $pager The BlockListPager instance for this page
+ */
+ protected function showTotal( BlockListPager $pager ) {
+ $out = $this->getOutput();
+ $out->addHTML(
+ Html::element( 'div', [ 'style' => 'font-weight: bold;' ],
+ $this->msg( 'autoblocklist-total-autoblocks', $pager->getTotalAutoblocks() )->parse() )
+ . "\n"
+ );
+ }
+
+ /**
+ * Show the list of blocked accounts matching the actual filter.
+ * @param BlockListPager $pager The BlockListPager instance for this page
+ */
+ protected function showList( BlockListPager $pager ) {
+ $out = $this->getOutput();
+
+ # Check for other blocks, i.e. global/tor blocks
+ $otherAutoblockLink = [];
+ Hooks::run( 'OtherAutoblockLogLink', [ &$otherAutoblockLink ] );
+
+ # Show additional header for the local block only when other blocks exists.
+ # Not necessary in a standard installation without such extensions enabled
+ if ( count( $otherAutoblockLink ) ) {
+ $out->addHTML(
+ Html::element( 'h2', [], $this->msg( 'autoblocklist-localblocks',
+ $pager->getNumRows() )->parse() )
+ . "\n"
+ );
+ }
+
+ if ( $pager->getNumRows() ) {
+ $out->addParserOutputContent( $pager->getFullOutput() );
+ } else {
+ $out->addWikiMsg( 'autoblocklist-empty' );
+ }
+
+ if ( count( $otherAutoblockLink ) ) {
+ $out->addHTML(
+ Html::rawElement(
+ 'h2',
+ [],
+ $this->msg( 'autoblocklist-otherblocks', count( $otherAutoblockLink ) )->parse()
+ ) . "\n"
+ );
+ $list = '';
+ foreach ( $otherAutoblockLink as $link ) {
+ $list .= Html::rawElement( 'li', [], $link ) . "\n";
+ }
+ $out->addHTML(
+ Html::rawElement(
+ 'ul',
+ [ 'class' => 'mw-autoblocklist-otherblocks' ],
+ $list
+ ) . "\n"
+ );
+ }
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialBlankpage.php b/www/wiki/includes/specials/SpecialBlankpage.php
new file mode 100644
index 00000000..e61f12b9
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialBlankpage.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Implements Special:Blankpage
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page designed for basic benchmarking of
+ * MediaWiki since it doesn't really do much.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialBlankpage extends UnlistedSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Blankpage' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->getOutput()->addWikiMsg( 'intentionallyblankpage' );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialBlock.php b/www/wiki/includes/specials/SpecialBlock.php
new file mode 100644
index 00000000..cd3c0289
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialBlock.php
@@ -0,0 +1,1026 @@
+<?php
+/**
+ * Implements Special:Block
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that allows users with 'block' right to block users from
+ * editing pages and other actions
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialBlock extends FormSpecialPage {
+ /** @var User|string|null User to be blocked, as passed either by parameter (url?wpTarget=Foo)
+ * or as subpage (Special:Block/Foo) */
+ protected $target;
+
+ /** @var int Block::TYPE_ constant */
+ protected $type;
+
+ /** @var User|string The previous block target */
+ protected $previousTarget;
+
+ /** @var bool Whether the previous submission of the form asked for HideUser */
+ protected $requestedHideUser;
+
+ /** @var bool */
+ protected $alreadyBlocked;
+
+ /** @var array */
+ protected $preErrors = [];
+
+ public function __construct() {
+ parent::__construct( 'Block', 'block' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * Checks that the user can unblock themselves if they are trying to do so
+ *
+ * @param User $user
+ * @throws ErrorPageError
+ */
+ protected function checkExecutePermissions( User $user ) {
+ parent::checkExecutePermissions( $user );
+
+ # T17810: blocked admins should have limited access here
+ $status = self::checkUnblockSelf( $this->target, $user );
+ if ( $status !== true ) {
+ throw new ErrorPageError( 'badaccess', $status );
+ }
+ }
+
+ /**
+ * Handle some magic here
+ *
+ * @param string $par
+ */
+ protected function setParameter( $par ) {
+ # Extract variables from the request. Try not to get into a situation where we
+ # need to extract *every* variable from the form just for processing here, but
+ # there are legitimate uses for some variables
+ $request = $this->getRequest();
+ list( $this->target, $this->type ) = self::getTargetAndType( $par, $request );
+ if ( $this->target instanceof User ) {
+ # Set the 'relevant user' in the skin, so it displays links like Contributions,
+ # User logs, UserRights, etc.
+ $this->getSkin()->setRelevantUser( $this->target );
+ }
+
+ list( $this->previousTarget, /*...*/ ) =
+ Block::parseTarget( $request->getVal( 'wpPreviousTarget' ) );
+ $this->requestedHideUser = $request->getBool( 'wpHideUser' );
+ }
+
+ /**
+ * Customizes the HTMLForm a bit
+ *
+ * @param HTMLForm $form
+ */
+ protected function alterForm( HTMLForm $form ) {
+ $form->setWrapperLegendMsg( 'blockip-legend' );
+ $form->setHeaderText( '' );
+ $form->setSubmitDestructive();
+
+ $msg = $this->alreadyBlocked ? 'ipb-change-block' : 'ipbsubmit';
+ $form->setSubmitTextMsg( $msg );
+
+ $this->addHelpLink( 'Help:Blocking users' );
+
+ # Don't need to do anything if the form has been posted
+ if ( !$this->getRequest()->wasPosted() && $this->preErrors ) {
+ $s = $form->formatErrors( $this->preErrors );
+ if ( $s ) {
+ $form->addHeaderText( Html::rawElement(
+ 'div',
+ [ 'class' => 'error' ],
+ $s
+ ) );
+ }
+ }
+ }
+
+ /**
+ * Get the HTMLForm descriptor array for the block form
+ * @return array
+ */
+ protected function getFormFields() {
+ global $wgBlockAllowsUTEdit;
+
+ $user = $this->getUser();
+
+ $suggestedDurations = self::getSuggestedDurations();
+
+ $a = [
+ 'Target' => [
+ 'type' => 'text',
+ 'label-message' => 'ipaddressorusername',
+ 'id' => 'mw-bi-target',
+ 'size' => '45',
+ 'autofocus' => true,
+ 'required' => true,
+ 'validation-callback' => [ __CLASS__, 'validateTargetField' ],
+ 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
+ ],
+ 'Expiry' => [
+ 'type' => !count( $suggestedDurations ) ? 'text' : 'selectorother',
+ 'label-message' => 'ipbexpiry',
+ 'required' => true,
+ 'options' => $suggestedDurations,
+ 'other' => $this->msg( 'ipbother' )->text(),
+ 'default' => $this->msg( 'ipb-default-expiry' )->inContentLanguage()->text(),
+ ],
+ 'Reason' => [
+ 'type' => 'selectandother',
+ 'maxlength' => 255,
+ 'label-message' => 'ipbreason',
+ 'options-message' => 'ipbreason-dropdown',
+ ],
+ 'CreateAccount' => [
+ 'type' => 'check',
+ 'label-message' => 'ipbcreateaccount',
+ 'default' => true,
+ ],
+ ];
+
+ if ( self::canBlockEmail( $user ) ) {
+ $a['DisableEmail'] = [
+ 'type' => 'check',
+ 'label-message' => 'ipbemailban',
+ ];
+ }
+
+ if ( $wgBlockAllowsUTEdit ) {
+ $a['DisableUTEdit'] = [
+ 'type' => 'check',
+ 'label-message' => 'ipb-disableusertalk',
+ 'default' => false,
+ ];
+ }
+
+ $a['AutoBlock'] = [
+ 'type' => 'check',
+ 'label-message' => 'ipbenableautoblock',
+ 'default' => true,
+ ];
+
+ # Allow some users to hide name from block log, blocklist and listusers
+ if ( $user->isAllowed( 'hideuser' ) ) {
+ $a['HideUser'] = [
+ 'type' => 'check',
+ 'label-message' => 'ipbhidename',
+ 'cssclass' => 'mw-block-hideuser',
+ ];
+ }
+
+ # Watchlist their user page? (Only if user is logged in)
+ if ( $user->isLoggedIn() ) {
+ $a['Watch'] = [
+ 'type' => 'check',
+ 'label-message' => 'ipbwatchuser',
+ ];
+ }
+
+ $a['HardBlock'] = [
+ 'type' => 'check',
+ 'label-message' => 'ipb-hardblock',
+ 'default' => false,
+ ];
+
+ # This is basically a copy of the Target field, but the user can't change it, so we
+ # can see if the warnings we maybe showed to the user before still apply
+ $a['PreviousTarget'] = [
+ 'type' => 'hidden',
+ 'default' => false,
+ ];
+
+ # We'll turn this into a checkbox if we need to
+ $a['Confirm'] = [
+ 'type' => 'hidden',
+ 'default' => '',
+ 'label-message' => 'ipb-confirm',
+ ];
+
+ $this->maybeAlterFormDefaults( $a );
+
+ // Allow extensions to add more fields
+ Hooks::run( 'SpecialBlockModifyFormFields', [ $this, &$a ] );
+
+ return $a;
+ }
+
+ /**
+ * If the user has already been blocked with similar settings, load that block
+ * and change the defaults for the form fields to match the existing settings.
+ * @param array &$fields HTMLForm descriptor array
+ * @return bool Whether fields were altered (that is, whether the target is
+ * already blocked)
+ */
+ protected function maybeAlterFormDefaults( &$fields ) {
+ # This will be overwritten by request data
+ $fields['Target']['default'] = (string)$this->target;
+
+ if ( $this->target ) {
+ $status = self::validateTarget( $this->target, $this->getUser() );
+ if ( !$status->isOK() ) {
+ $errors = $status->getErrorsArray();
+ $this->preErrors = array_merge( $this->preErrors, $errors );
+ }
+ }
+
+ # This won't be
+ $fields['PreviousTarget']['default'] = (string)$this->target;
+
+ $block = Block::newFromTarget( $this->target );
+
+ if ( $block instanceof Block && !$block->mAuto # The block exists and isn't an autoblock
+ && ( $this->type != Block::TYPE_RANGE # The block isn't a rangeblock
+ || $block->getTarget() == $this->target ) # or if it is, the range is what we're about to block
+ ) {
+ $fields['HardBlock']['default'] = $block->isHardblock();
+ $fields['CreateAccount']['default'] = $block->prevents( 'createaccount' );
+ $fields['AutoBlock']['default'] = $block->isAutoblocking();
+
+ if ( isset( $fields['DisableEmail'] ) ) {
+ $fields['DisableEmail']['default'] = $block->prevents( 'sendemail' );
+ }
+
+ if ( isset( $fields['HideUser'] ) ) {
+ $fields['HideUser']['default'] = $block->mHideName;
+ }
+
+ if ( isset( $fields['DisableUTEdit'] ) ) {
+ $fields['DisableUTEdit']['default'] = $block->prevents( 'editownusertalk' );
+ }
+
+ // If the username was hidden (ipb_deleted == 1), don't show the reason
+ // unless this user also has rights to hideuser: T37839
+ if ( !$block->mHideName || $this->getUser()->isAllowed( 'hideuser' ) ) {
+ $fields['Reason']['default'] = $block->mReason;
+ } else {
+ $fields['Reason']['default'] = '';
+ }
+
+ if ( $this->getRequest()->wasPosted() ) {
+ # Ok, so we got a POST submission asking us to reblock a user. So show the
+ # confirm checkbox; the user will only see it if they haven't previously
+ $fields['Confirm']['type'] = 'check';
+ } else {
+ # We got a target, but it wasn't a POST request, so the user must have gone
+ # to a link like [[Special:Block/User]]. We don't need to show the checkbox
+ # as long as they go ahead and block *that* user
+ $fields['Confirm']['default'] = 1;
+ }
+
+ if ( $block->mExpiry == 'infinity' ) {
+ $fields['Expiry']['default'] = 'infinite';
+ } else {
+ $fields['Expiry']['default'] = wfTimestamp( TS_RFC2822, $block->mExpiry );
+ }
+
+ $this->alreadyBlocked = true;
+ $this->preErrors[] = [ 'ipb-needreblock', wfEscapeWikiText( (string)$block->getTarget() ) ];
+ }
+
+ # We always need confirmation to do HideUser
+ if ( $this->requestedHideUser ) {
+ $fields['Confirm']['type'] = 'check';
+ unset( $fields['Confirm']['default'] );
+ $this->preErrors[] = [ 'ipb-confirmhideuser', 'ipb-confirmaction' ];
+ }
+
+ # Or if the user is trying to block themselves
+ if ( (string)$this->target === $this->getUser()->getName() ) {
+ $fields['Confirm']['type'] = 'check';
+ unset( $fields['Confirm']['default'] );
+ $this->preErrors[] = [ 'ipb-blockingself', 'ipb-confirmaction' ];
+ }
+ }
+
+ /**
+ * Add header elements like block log entries, etc.
+ * @return string
+ */
+ protected function preText() {
+ $this->getOutput()->addModules( [ 'mediawiki.special.block', 'mediawiki.userSuggest' ] );
+
+ $blockCIDRLimit = $this->getConfig()->get( 'BlockCIDRLimit' );
+ $text = $this->msg( 'blockiptext', $blockCIDRLimit['IPv4'], $blockCIDRLimit['IPv6'] )->parse();
+
+ $otherBlockMessages = [];
+ if ( $this->target !== null ) {
+ $targetName = $this->target;
+ if ( $this->target instanceof User ) {
+ $targetName = $this->target->getName();
+ }
+ # Get other blocks, i.e. from GlobalBlocking or TorBlock extension
+ Hooks::run( 'OtherBlockLogLink', [ &$otherBlockMessages, $targetName ] );
+
+ if ( count( $otherBlockMessages ) ) {
+ $s = Html::rawElement(
+ 'h2',
+ [],
+ $this->msg( 'ipb-otherblocks-header', count( $otherBlockMessages ) )->parse()
+ ) . "\n";
+
+ $list = '';
+
+ foreach ( $otherBlockMessages as $link ) {
+ $list .= Html::rawElement( 'li', [], $link ) . "\n";
+ }
+
+ $s .= Html::rawElement(
+ 'ul',
+ [ 'class' => 'mw-blockip-alreadyblocked' ],
+ $list
+ ) . "\n";
+
+ $text .= $s;
+ }
+ }
+
+ return $text;
+ }
+
+ /**
+ * Add footer elements to the form
+ * @return string
+ */
+ protected function postText() {
+ $links = [];
+
+ $this->getOutput()->addModuleStyles( 'mediawiki.special' );
+
+ $linkRenderer = $this->getLinkRenderer();
+ # Link to the user's contributions, if applicable
+ if ( $this->target instanceof User ) {
+ $contribsPage = SpecialPage::getTitleFor( 'Contributions', $this->target->getName() );
+ $links[] = $linkRenderer->makeLink(
+ $contribsPage,
+ $this->msg( 'ipb-blocklist-contribs', $this->target->getName() )->text()
+ );
+ }
+
+ # Link to unblock the specified user, or to a blank unblock form
+ if ( $this->target instanceof User ) {
+ $message = $this->msg(
+ 'ipb-unblock-addr',
+ wfEscapeWikiText( $this->target->getName() )
+ )->parse();
+ $list = SpecialPage::getTitleFor( 'Unblock', $this->target->getName() );
+ } else {
+ $message = $this->msg( 'ipb-unblock' )->parse();
+ $list = SpecialPage::getTitleFor( 'Unblock' );
+ }
+ $links[] = $linkRenderer->makeKnownLink(
+ $list,
+ new HtmlArmor( $message )
+ );
+
+ # Link to the block list
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'BlockList' ),
+ $this->msg( 'ipb-blocklist' )->text()
+ );
+
+ $user = $this->getUser();
+
+ # Link to edit the block dropdown reasons, if applicable
+ if ( $user->isAllowed( 'editinterface' ) ) {
+ $links[] = $linkRenderer->makeKnownLink(
+ $this->msg( 'ipbreason-dropdown' )->inContentLanguage()->getTitle(),
+ $this->msg( 'ipb-edit-dropdown' )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ }
+
+ $text = Html::rawElement(
+ 'p',
+ [ 'class' => 'mw-ipb-conveniencelinks' ],
+ $this->getLanguage()->pipeList( $links )
+ );
+
+ $userTitle = self::getTargetUserTitle( $this->target );
+ if ( $userTitle ) {
+ # Get relevant extracts from the block and suppression logs, if possible
+ $out = '';
+
+ LogEventsList::showLogExtract(
+ $out,
+ 'block',
+ $userTitle,
+ '',
+ [
+ 'lim' => 10,
+ 'msgKey' => [ 'blocklog-showlog', $userTitle->getText() ],
+ 'showIfEmpty' => false
+ ]
+ );
+ $text .= $out;
+
+ # Add suppression block entries if allowed
+ if ( $user->isAllowed( 'suppressionlog' ) ) {
+ LogEventsList::showLogExtract(
+ $out,
+ 'suppress',
+ $userTitle,
+ '',
+ [
+ 'lim' => 10,
+ 'conds' => [ 'log_action' => [ 'block', 'reblock', 'unblock' ] ],
+ 'msgKey' => [ 'blocklog-showsuppresslog', $userTitle->getText() ],
+ 'showIfEmpty' => false
+ ]
+ );
+
+ $text .= $out;
+ }
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get a user page target for things like logs.
+ * This handles account and IP range targets.
+ * @param User|string $target
+ * @return Title|null
+ */
+ protected static function getTargetUserTitle( $target ) {
+ if ( $target instanceof User ) {
+ return $target->getUserPage();
+ } elseif ( IP::isIPAddress( $target ) ) {
+ return Title::makeTitleSafe( NS_USER, $target );
+ }
+
+ return null;
+ }
+
+ /**
+ * Determine the target of the block, and the type of target
+ * @todo Should be in Block.php?
+ * @param string $par Subpage parameter passed to setup, or data value from
+ * the HTMLForm
+ * @param WebRequest $request Optionally try and get data from a request too
+ * @return array [ User|string|null, Block::TYPE_ constant|null ]
+ */
+ public static function getTargetAndType( $par, WebRequest $request = null ) {
+ $i = 0;
+ $target = null;
+
+ while ( true ) {
+ switch ( $i++ ) {
+ case 0:
+ # The HTMLForm will check wpTarget first and only if it doesn't get
+ # a value use the default, which will be generated from the options
+ # below; so this has to have a higher precedence here than $par, or
+ # we could end up with different values in $this->target and the HTMLForm!
+ if ( $request instanceof WebRequest ) {
+ $target = $request->getText( 'wpTarget', null );
+ }
+ break;
+ case 1:
+ $target = $par;
+ break;
+ case 2:
+ if ( $request instanceof WebRequest ) {
+ $target = $request->getText( 'ip', null );
+ }
+ break;
+ case 3:
+ # B/C @since 1.18
+ if ( $request instanceof WebRequest ) {
+ $target = $request->getText( 'wpBlockAddress', null );
+ }
+ break;
+ case 4:
+ break 2;
+ }
+
+ list( $target, $type ) = Block::parseTarget( $target );
+
+ if ( $type !== null ) {
+ return [ $target, $type ];
+ }
+ }
+
+ return [ null, null ];
+ }
+
+ /**
+ * HTMLForm field validation-callback for Target field.
+ * @since 1.18
+ * @param string $value
+ * @param array $alldata
+ * @param HTMLForm $form
+ * @return Message
+ */
+ public static function validateTargetField( $value, $alldata, $form ) {
+ $status = self::validateTarget( $value, $form->getUser() );
+ if ( !$status->isOK() ) {
+ $errors = $status->getErrorsArray();
+
+ return call_user_func_array( [ $form, 'msg' ], $errors[0] );
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Validate a block target.
+ *
+ * @since 1.21
+ * @param string $value Block target to check
+ * @param User $user Performer of the block
+ * @return Status
+ */
+ public static function validateTarget( $value, User $user ) {
+ global $wgBlockCIDRLimit;
+
+ /** @var User $target */
+ list( $target, $type ) = self::getTargetAndType( $value );
+ $status = Status::newGood( $target );
+
+ if ( $type == Block::TYPE_USER ) {
+ if ( $target->isAnon() ) {
+ $status->fatal(
+ 'nosuchusershort',
+ wfEscapeWikiText( $target->getName() )
+ );
+ }
+
+ $unblockStatus = self::checkUnblockSelf( $target, $user );
+ if ( $unblockStatus !== true ) {
+ $status->fatal( 'badaccess', $unblockStatus );
+ }
+ } elseif ( $type == Block::TYPE_RANGE ) {
+ list( $ip, $range ) = explode( '/', $target, 2 );
+
+ if (
+ ( IP::isIPv4( $ip ) && $wgBlockCIDRLimit['IPv4'] == 32 ) ||
+ ( IP::isIPv6( $ip ) && $wgBlockCIDRLimit['IPv6'] == 128 )
+ ) {
+ // Range block effectively disabled
+ $status->fatal( 'range_block_disabled' );
+ }
+
+ if (
+ ( IP::isIPv4( $ip ) && $range > 32 ) ||
+ ( IP::isIPv6( $ip ) && $range > 128 )
+ ) {
+ // Dodgy range
+ $status->fatal( 'ip_range_invalid' );
+ }
+
+ if ( IP::isIPv4( $ip ) && $range < $wgBlockCIDRLimit['IPv4'] ) {
+ $status->fatal( 'ip_range_toolarge', $wgBlockCIDRLimit['IPv4'] );
+ }
+
+ if ( IP::isIPv6( $ip ) && $range < $wgBlockCIDRLimit['IPv6'] ) {
+ $status->fatal( 'ip_range_toolarge', $wgBlockCIDRLimit['IPv6'] );
+ }
+ } elseif ( $type == Block::TYPE_IP ) {
+ # All is well
+ } else {
+ $status->fatal( 'badipaddress' );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Given the form data, actually implement a block. This is also called from ApiBlock.
+ *
+ * @param array $data
+ * @param IContextSource $context
+ * @return bool|string
+ */
+ public static function processForm( array $data, IContextSource $context ) {
+ global $wgBlockAllowsUTEdit, $wgHideUserContribLimit;
+
+ $performer = $context->getUser();
+
+ // Handled by field validator callback
+ // self::validateTargetField( $data['Target'] );
+
+ # This might have been a hidden field or a checkbox, so interesting data
+ # can come from it
+ $data['Confirm'] = !in_array( $data['Confirm'], [ '', '0', null, false ], true );
+
+ /** @var User $target */
+ list( $target, $type ) = self::getTargetAndType( $data['Target'] );
+ if ( $type == Block::TYPE_USER ) {
+ $user = $target;
+ $target = $user->getName();
+ $userId = $user->getId();
+
+ # Give admins a heads-up before they go and block themselves. Much messier
+ # to do this for IPs, but it's pretty unlikely they'd ever get the 'block'
+ # permission anyway, although the code does allow for it.
+ # Note: Important to use $target instead of $data['Target']
+ # since both $data['PreviousTarget'] and $target are normalized
+ # but $data['target'] gets overridden by (non-normalized) request variable
+ # from previous request.
+ if ( $target === $performer->getName() &&
+ ( $data['PreviousTarget'] !== $target || !$data['Confirm'] )
+ ) {
+ return [ 'ipb-blockingself', 'ipb-confirmaction' ];
+ }
+ } elseif ( $type == Block::TYPE_RANGE ) {
+ $user = null;
+ $userId = 0;
+ } elseif ( $type == Block::TYPE_IP ) {
+ $user = null;
+ $target = $target->getName();
+ $userId = 0;
+ } else {
+ # This should have been caught in the form field validation
+ return [ 'badipaddress' ];
+ }
+
+ $expiryTime = self::parseExpiryInput( $data['Expiry'] );
+
+ if (
+ // an expiry time is needed
+ ( strlen( $data['Expiry'] ) == 0 ) ||
+ // can't be a larger string as 50 (it should be a time format in any way)
+ ( strlen( $data['Expiry'] ) > 50 ) ||
+ // check, if the time could be parsed
+ !$expiryTime
+ ) {
+ return [ 'ipb_expiry_invalid' ];
+ }
+
+ // an expiry time should be in the future, not in the
+ // past (wouldn't make any sense) - bug T123069
+ if ( $expiryTime < wfTimestampNow() ) {
+ return [ 'ipb_expiry_old' ];
+ }
+
+ if ( !isset( $data['DisableEmail'] ) ) {
+ $data['DisableEmail'] = false;
+ }
+
+ # If the user has done the form 'properly', they won't even have been given the
+ # option to suppress-block unless they have the 'hideuser' permission
+ if ( !isset( $data['HideUser'] ) ) {
+ $data['HideUser'] = false;
+ }
+
+ if ( $data['HideUser'] ) {
+ if ( !$performer->isAllowed( 'hideuser' ) ) {
+ # this codepath is unreachable except by a malicious user spoofing forms,
+ # or by race conditions (user has hideuser and block rights, loads block form,
+ # and loses hideuser rights before submission); so need to fail completely
+ # rather than just silently disable hiding
+ return [ 'badaccess-group0' ];
+ }
+
+ # Recheck params here...
+ if ( $type != Block::TYPE_USER ) {
+ $data['HideUser'] = false; # IP users should not be hidden
+ } elseif ( !wfIsInfinity( $data['Expiry'] ) ) {
+ # Bad expiry.
+ return [ 'ipb_expiry_temp' ];
+ } elseif ( $wgHideUserContribLimit !== false
+ && $user->getEditCount() > $wgHideUserContribLimit
+ ) {
+ # Typically, the user should have a handful of edits.
+ # Disallow hiding users with many edits for performance.
+ return [ [ 'ipb_hide_invalid',
+ Message::numParam( $wgHideUserContribLimit ) ] ];
+ } elseif ( !$data['Confirm'] ) {
+ return [ 'ipb-confirmhideuser', 'ipb-confirmaction' ];
+ }
+ }
+
+ # Create block object.
+ $block = new Block();
+ $block->setTarget( $target );
+ $block->setBlocker( $performer );
+ $block->mReason = $data['Reason'][0];
+ $block->mExpiry = $expiryTime;
+ $block->prevents( 'createaccount', $data['CreateAccount'] );
+ $block->prevents( 'editownusertalk', ( !$wgBlockAllowsUTEdit || $data['DisableUTEdit'] ) );
+ $block->prevents( 'sendemail', $data['DisableEmail'] );
+ $block->isHardblock( $data['HardBlock'] );
+ $block->isAutoblocking( $data['AutoBlock'] );
+ $block->mHideName = $data['HideUser'];
+
+ $reason = [ 'hookaborted' ];
+ if ( !Hooks::run( 'BlockIp', [ &$block, &$performer, &$reason ] ) ) {
+ return $reason;
+ }
+
+ $priorBlock = null;
+ # Try to insert block. Is there a conflicting block?
+ $status = $block->insert();
+ if ( !$status ) {
+ # Indicates whether the user is confirming the block and is aware of
+ # the conflict (did not change the block target in the meantime)
+ $blockNotConfirmed = !$data['Confirm'] || ( array_key_exists( 'PreviousTarget', $data )
+ && $data['PreviousTarget'] !== $target );
+
+ # Special case for API - T34434
+ $reblockNotAllowed = ( array_key_exists( 'Reblock', $data ) && !$data['Reblock'] );
+
+ # Show form unless the user is already aware of this...
+ if ( $blockNotConfirmed || $reblockNotAllowed ) {
+ return [ [ 'ipb_already_blocked', $block->getTarget() ] ];
+ # Otherwise, try to update the block...
+ } else {
+ # This returns direct blocks before autoblocks/rangeblocks, since we should
+ # be sure the user is blocked by now it should work for our purposes
+ $currentBlock = Block::newFromTarget( $target );
+ if ( $block->equals( $currentBlock ) ) {
+ return [ [ 'ipb_already_blocked', $block->getTarget() ] ];
+ }
+ # If the name was hidden and the blocking user cannot hide
+ # names, then don't allow any block changes...
+ if ( $currentBlock->mHideName && !$performer->isAllowed( 'hideuser' ) ) {
+ return [ 'cant-see-hidden-user' ];
+ }
+
+ $priorBlock = clone $currentBlock;
+ $currentBlock->isHardblock( $block->isHardblock() );
+ $currentBlock->prevents( 'createaccount', $block->prevents( 'createaccount' ) );
+ $currentBlock->mExpiry = $block->mExpiry;
+ $currentBlock->isAutoblocking( $block->isAutoblocking() );
+ $currentBlock->mHideName = $block->mHideName;
+ $currentBlock->prevents( 'sendemail', $block->prevents( 'sendemail' ) );
+ $currentBlock->prevents( 'editownusertalk', $block->prevents( 'editownusertalk' ) );
+ $currentBlock->mReason = $block->mReason;
+
+ $status = $currentBlock->update();
+
+ $logaction = 'reblock';
+
+ # Unset _deleted fields if requested
+ if ( $currentBlock->mHideName && !$data['HideUser'] ) {
+ RevisionDeleteUser::unsuppressUserName( $target, $userId );
+ }
+
+ # If hiding/unhiding a name, this should go in the private logs
+ if ( (bool)$currentBlock->mHideName ) {
+ $data['HideUser'] = true;
+ }
+ }
+ } else {
+ $logaction = 'block';
+ }
+
+ Hooks::run( 'BlockIpComplete', [ $block, $performer, $priorBlock ] );
+
+ # Set *_deleted fields if requested
+ if ( $data['HideUser'] ) {
+ RevisionDeleteUser::suppressUserName( $target, $userId );
+ }
+
+ # Can't watch a rangeblock
+ if ( $type != Block::TYPE_RANGE && $data['Watch'] ) {
+ WatchAction::doWatch(
+ Title::makeTitle( NS_USER, $target ),
+ $performer,
+ User::IGNORE_USER_RIGHTS
+ );
+ }
+
+ # Block constructor sanitizes certain block options on insert
+ $data['BlockEmail'] = $block->prevents( 'sendemail' );
+ $data['AutoBlock'] = $block->isAutoblocking();
+
+ # Prepare log parameters
+ $logParams = [];
+ $logParams['5::duration'] = $data['Expiry'];
+ $logParams['6::flags'] = self::blockLogFlags( $data, $type );
+
+ # Make log entry, if the name is hidden, put it in the suppression log
+ $log_type = $data['HideUser'] ? 'suppress' : 'block';
+ $logEntry = new ManualLogEntry( $log_type, $logaction );
+ $logEntry->setTarget( Title::makeTitle( NS_USER, $target ) );
+ $logEntry->setComment( $data['Reason'][0] );
+ $logEntry->setPerformer( $performer );
+ $logEntry->setParameters( $logParams );
+ # Relate log ID to block IDs (T27763)
+ $blockIds = array_merge( [ $status['id'] ], $status['autoIds'] );
+ $logEntry->setRelations( [ 'ipb_id' => $blockIds ] );
+ $logId = $logEntry->insert();
+
+ if ( !empty( $data['Tags'] ) ) {
+ $logEntry->setTags( $data['Tags'] );
+ }
+
+ $logEntry->publish( $logId );
+
+ return true;
+ }
+
+ /**
+ * Get an array of suggested block durations from MediaWiki:Ipboptions
+ * @todo FIXME: This uses a rather odd syntax for the options, should it be converted
+ * to the standard "**<duration>|<displayname>" format?
+ * @param Language|null $lang The language to get the durations in, or null to use
+ * the wiki's content language
+ * @return array
+ */
+ public static function getSuggestedDurations( $lang = null ) {
+ $a = [];
+ $msg = $lang === null
+ ? wfMessage( 'ipboptions' )->inContentLanguage()->text()
+ : wfMessage( 'ipboptions' )->inLanguage( $lang )->text();
+
+ if ( $msg == '-' ) {
+ return [];
+ }
+
+ foreach ( explode( ',', $msg ) as $option ) {
+ if ( strpos( $option, ':' ) === false ) {
+ $option = "$option:$option";
+ }
+
+ list( $show, $value ) = explode( ':', $option );
+ $a[$show] = $value;
+ }
+
+ return $a;
+ }
+
+ /**
+ * Convert a submitted expiry time, which may be relative ("2 weeks", etc) or absolute
+ * ("24 May 2034", etc), into an absolute timestamp we can put into the database.
+ * @param string $expiry Whatever was typed into the form
+ * @return string Timestamp or 'infinity'
+ */
+ public static function parseExpiryInput( $expiry ) {
+ if ( wfIsInfinity( $expiry ) ) {
+ $expiry = 'infinity';
+ } else {
+ $expiry = strtotime( $expiry );
+
+ if ( $expiry < 0 || $expiry === false ) {
+ return false;
+ }
+
+ $expiry = wfTimestamp( TS_MW, $expiry );
+ }
+
+ return $expiry;
+ }
+
+ /**
+ * Can we do an email block?
+ * @param User $user The sysop wanting to make a block
+ * @return bool
+ */
+ public static function canBlockEmail( $user ) {
+ global $wgEnableUserEmail, $wgSysopEmailBans;
+
+ return ( $wgEnableUserEmail && $wgSysopEmailBans && $user->isAllowed( 'blockemail' ) );
+ }
+
+ /**
+ * T17810: blocked admins should not be able to block/unblock
+ * others, and probably shouldn't be able to unblock themselves
+ * either.
+ * @param User|int|string $user
+ * @param User $performer User doing the request
+ * @return bool|string True or error message key
+ */
+ public static function checkUnblockSelf( $user, User $performer ) {
+ if ( is_int( $user ) ) {
+ $user = User::newFromId( $user );
+ } elseif ( is_string( $user ) ) {
+ $user = User::newFromName( $user );
+ }
+
+ if ( $performer->isBlocked() ) {
+ if ( $user instanceof User && $user->getId() == $performer->getId() ) {
+ # User is trying to unblock themselves
+ if ( $performer->isAllowed( 'unblockself' ) ) {
+ return true;
+ # User blocked themselves and is now trying to reverse it
+ } elseif ( $performer->blockedBy() === $performer->getName() ) {
+ return true;
+ } else {
+ return 'ipbnounblockself';
+ }
+ } else {
+ # User is trying to block/unblock someone else
+ return 'ipbblocked';
+ }
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Return a comma-delimited list of "flags" to be passed to the log
+ * reader for this block, to provide more information in the logs
+ * @param array $data From HTMLForm data
+ * @param int $type Block::TYPE_ constant (USER, RANGE, or IP)
+ * @return string
+ */
+ protected static function blockLogFlags( array $data, $type ) {
+ global $wgBlockAllowsUTEdit;
+ $flags = [];
+
+ # when blocking a user the option 'anononly' is not available/has no effect
+ # -> do not write this into log
+ if ( !$data['HardBlock'] && $type != Block::TYPE_USER ) {
+ // For grepping: message block-log-flags-anononly
+ $flags[] = 'anononly';
+ }
+
+ if ( $data['CreateAccount'] ) {
+ // For grepping: message block-log-flags-nocreate
+ $flags[] = 'nocreate';
+ }
+
+ # Same as anononly, this is not displayed when blocking an IP address
+ if ( !$data['AutoBlock'] && $type == Block::TYPE_USER ) {
+ // For grepping: message block-log-flags-noautoblock
+ $flags[] = 'noautoblock';
+ }
+
+ if ( $data['DisableEmail'] ) {
+ // For grepping: message block-log-flags-noemail
+ $flags[] = 'noemail';
+ }
+
+ if ( $wgBlockAllowsUTEdit && $data['DisableUTEdit'] ) {
+ // For grepping: message block-log-flags-nousertalk
+ $flags[] = 'nousertalk';
+ }
+
+ if ( $data['HideUser'] ) {
+ // For grepping: message block-log-flags-hiddenname
+ $flags[] = 'hiddenname';
+ }
+
+ return implode( ',', $flags );
+ }
+
+ /**
+ * Process the form on POST submission.
+ * @param array $data
+ * @param HTMLForm $form
+ * @return bool|array True for success, false for didn't-try, array of errors on failure
+ */
+ public function onSubmit( array $data, HTMLForm $form = null ) {
+ return self::processForm( $data, $form->getContext() );
+ }
+
+ /**
+ * Do something exciting on successful processing of the form, most likely to show a
+ * confirmation message
+ */
+ public function onSuccess() {
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'blockipsuccesssub' ) );
+ $out->addWikiMsg( 'blockipsuccesstext', wfEscapeWikiText( $this->target ) );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialBlockList.php b/www/wiki/includes/specials/SpecialBlockList.php
new file mode 100644
index 00000000..0899d580
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialBlockList.php
@@ -0,0 +1,225 @@
+<?php
+/**
+ * Implements Special:BlockList
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists existing blocks
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialBlockList extends SpecialPage {
+ protected $target;
+
+ protected $options;
+
+ function __construct() {
+ parent::__construct( 'BlockList' );
+ }
+
+ /**
+ * Main execution point
+ *
+ * @param string $par Title fragment
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $out = $this->getOutput();
+ $lang = $this->getLanguage();
+ $out->setPageTitle( $this->msg( 'ipblocklist' ) );
+ $out->addModuleStyles( [ 'mediawiki.special' ] );
+
+ $request = $this->getRequest();
+ $par = $request->getVal( 'ip', $par );
+ $this->target = trim( $request->getVal( 'wpTarget', $par ) );
+
+ $this->options = $request->getArray( 'wpOptions', [] );
+
+ $action = $request->getText( 'action' );
+
+ if ( $action == 'unblock' || $action == 'submit' && $request->wasPosted() ) {
+ # B/C @since 1.18: Unblock interface is now at Special:Unblock
+ $title = SpecialPage::getTitleFor( 'Unblock', $this->target );
+ $out->redirect( $title->getFullURL() );
+
+ return;
+ }
+
+ # setup BlockListPager here to get the actual default Limit
+ $pager = $this->getBlockListPager();
+
+ # Just show the block list
+ $fields = [
+ 'Target' => [
+ 'type' => 'user',
+ 'label-message' => 'ipaddressorusername',
+ 'tabindex' => '1',
+ 'size' => '45',
+ 'default' => $this->target,
+ ],
+ 'Options' => [
+ 'type' => 'multiselect',
+ 'options-messages' => [
+ 'blocklist-userblocks' => 'userblocks',
+ 'blocklist-tempblocks' => 'tempblocks',
+ 'blocklist-addressblocks' => 'addressblocks',
+ 'blocklist-rangeblocks' => 'rangeblocks',
+ ],
+ 'flatlist' => true,
+ ],
+ 'Limit' => [
+ 'type' => 'limitselect',
+ 'label-message' => 'table_pager_limit_label',
+ 'options' => [
+ $lang->formatNum( 20 ) => 20,
+ $lang->formatNum( 50 ) => 50,
+ $lang->formatNum( 100 ) => 100,
+ $lang->formatNum( 250 ) => 250,
+ $lang->formatNum( 500 ) => 500,
+ ],
+ 'name' => 'limit',
+ 'default' => $pager->getLimit(),
+ ],
+ ];
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() ); // Remove subpage
+ $form = HTMLForm::factory( 'ooui', $fields, $context );
+ $form
+ ->setMethod( 'get' )
+ ->setFormIdentifier( 'blocklist' )
+ ->setWrapperLegendMsg( 'ipblocklist-legend' )
+ ->setSubmitTextMsg( 'ipblocklist-submit' )
+ ->setSubmitProgressive()
+ ->prepareForm()
+ ->displayForm( false );
+
+ $this->showList( $pager );
+ }
+
+ /**
+ * Setup a new BlockListPager instance.
+ * @return BlockListPager
+ */
+ protected function getBlockListPager() {
+ $conds = [];
+ # Is the user allowed to see hidden blocks?
+ if ( !$this->getUser()->isAllowed( 'hideuser' ) ) {
+ $conds['ipb_deleted'] = 0;
+ }
+
+ if ( $this->target !== '' ) {
+ list( $target, $type ) = Block::parseTarget( $this->target );
+
+ switch ( $type ) {
+ case Block::TYPE_ID:
+ case Block::TYPE_AUTO:
+ $conds['ipb_id'] = $target;
+ break;
+
+ case Block::TYPE_IP:
+ case Block::TYPE_RANGE:
+ list( $start, $end ) = IP::parseRange( $target );
+ $conds[] = wfGetDB( DB_REPLICA )->makeList(
+ [
+ 'ipb_address' => $target,
+ Block::getRangeCond( $start, $end )
+ ],
+ LIST_OR
+ );
+ $conds['ipb_auto'] = 0;
+ break;
+
+ case Block::TYPE_USER:
+ $conds['ipb_address'] = $target->getName();
+ $conds['ipb_auto'] = 0;
+ break;
+ }
+ }
+
+ # Apply filters
+ if ( in_array( 'userblocks', $this->options ) ) {
+ $conds['ipb_user'] = 0;
+ }
+ if ( in_array( 'tempblocks', $this->options ) ) {
+ $conds['ipb_expiry'] = 'infinity';
+ }
+ if ( in_array( 'addressblocks', $this->options ) ) {
+ $conds[] = "ipb_user != 0 OR ipb_range_end > ipb_range_start";
+ }
+ if ( in_array( 'rangeblocks', $this->options ) ) {
+ $conds[] = "ipb_range_end = ipb_range_start";
+ }
+
+ return new BlockListPager( $this, $conds );
+ }
+
+ /**
+ * Show the list of blocked accounts matching the actual filter.
+ * @param BlockListPager $pager The BlockListPager instance for this page
+ */
+ protected function showList( BlockListPager $pager ) {
+ $out = $this->getOutput();
+
+ # Check for other blocks, i.e. global/tor blocks
+ $otherBlockLink = [];
+ Hooks::run( 'OtherBlockLogLink', [ &$otherBlockLink, $this->target ] );
+
+ # Show additional header for the local block only when other blocks exists.
+ # Not necessary in a standard installation without such extensions enabled
+ if ( count( $otherBlockLink ) ) {
+ $out->addHTML(
+ Html::element( 'h2', [], $this->msg( 'ipblocklist-localblock' )->text() ) . "\n"
+ );
+ }
+
+ if ( $pager->getNumRows() ) {
+ $out->addParserOutputContent( $pager->getFullOutput() );
+ } elseif ( $this->target ) {
+ $out->addWikiMsg( 'ipblocklist-no-results' );
+ } else {
+ $out->addWikiMsg( 'ipblocklist-empty' );
+ }
+
+ if ( count( $otherBlockLink ) ) {
+ $out->addHTML(
+ Html::rawElement(
+ 'h2',
+ [],
+ $this->msg( 'ipblocklist-otherblocks', count( $otherBlockLink ) )->parse()
+ ) . "\n"
+ );
+ $list = '';
+ foreach ( $otherBlockLink as $link ) {
+ $list .= Html::rawElement( 'li', [], $link ) . "\n";
+ }
+ $out->addHTML( Html::rawElement(
+ 'ul',
+ [ 'class' => 'mw-ipblocklist-otherblocks' ],
+ $list
+ ) . "\n" );
+ }
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialBooksources.php b/www/wiki/includes/specials/SpecialBooksources.php
new file mode 100644
index 00000000..72e0b888
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialBooksources.php
@@ -0,0 +1,214 @@
+<?php
+/**
+ * Implements Special:Booksources
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page outputs information on sourcing a book with a particular ISBN
+ * The parser creates links to this page when dealing with ISBNs in wikitext
+ *
+ * @author Rob Church <robchur@gmail.com>
+ * @ingroup SpecialPage
+ */
+class SpecialBookSources extends SpecialPage {
+ public function __construct() {
+ parent::__construct( 'Booksources' );
+ }
+
+ /**
+ * Show the special page
+ *
+ * @param string $isbn ISBN passed as a subpage parameter
+ */
+ public function execute( $isbn ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ // User provided ISBN
+ $isbn = $isbn ?: $this->getRequest()->getText( 'isbn' );
+ $isbn = trim( $isbn );
+
+ $this->buildForm( $isbn );
+
+ if ( $isbn !== '' ) {
+ if ( !self::isValidISBN( $isbn ) ) {
+ $out->wrapWikiMsg(
+ "<div class=\"error\">\n$1\n</div>",
+ 'booksources-invalid-isbn'
+ );
+ }
+
+ $this->showList( $isbn );
+ }
+ }
+
+ /**
+ * Return whether a given ISBN (10 or 13) is valid.
+ *
+ * @param string $isbn ISBN passed for check
+ * @return bool
+ */
+ public static function isValidISBN( $isbn ) {
+ $isbn = self::cleanIsbn( $isbn );
+ $sum = 0;
+ if ( strlen( $isbn ) == 13 ) {
+ for ( $i = 0; $i < 12; $i++ ) {
+ if ( $isbn[$i] === 'X' ) {
+ return false;
+ } elseif ( $i % 2 == 0 ) {
+ $sum += $isbn[$i];
+ } else {
+ $sum += 3 * $isbn[$i];
+ }
+ }
+
+ $check = ( 10 - ( $sum % 10 ) ) % 10;
+ if ( (string)$check === $isbn[12] ) {
+ return true;
+ }
+ } elseif ( strlen( $isbn ) == 10 ) {
+ for ( $i = 0; $i < 9; $i++ ) {
+ if ( $isbn[$i] === 'X' ) {
+ return false;
+ }
+ $sum += $isbn[$i] * ( $i + 1 );
+ }
+
+ $check = $sum % 11;
+ if ( $check == 10 ) {
+ $check = "X";
+ }
+ if ( (string)$check === $isbn[9] ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Trim ISBN and remove characters which aren't required
+ *
+ * @param string $isbn Unclean ISBN
+ * @return string
+ */
+ private static function cleanIsbn( $isbn ) {
+ return trim( preg_replace( '![^0-9X]!', '', $isbn ) );
+ }
+
+ /**
+ * Generate a form to allow users to enter an ISBN
+ *
+ * @param string $isbn
+ */
+ private function buildForm( $isbn ) {
+ $formDescriptor = [
+ 'isbn' => [
+ 'type' => 'text',
+ 'name' => 'isbn',
+ 'label-message' => 'booksources-isbn',
+ 'default' => $isbn,
+ 'autofocus' => true,
+ 'required' => true,
+ ],
+ ];
+
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() );
+ HTMLForm::factory( 'ooui', $formDescriptor, $context )
+ ->setWrapperLegendMsg( 'booksources-search-legend' )
+ ->setSubmitTextMsg( 'booksources-search' )
+ ->setMethod( 'get' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ /**
+ * Determine where to get the list of book sources from,
+ * format and output them
+ *
+ * @param string $isbn
+ * @throws MWException
+ * @return bool
+ */
+ private function showList( $isbn ) {
+ $out = $this->getOutput();
+
+ global $wgContLang;
+
+ $isbn = self::cleanIsbn( $isbn );
+ # Hook to allow extensions to insert additional HTML,
+ # e.g. for API-interacting plugins and so on
+ Hooks::run( 'BookInformation', [ $isbn, $out ] );
+
+ # Check for a local page such as Project:Book_sources and use that if available
+ $page = $this->msg( 'booksources' )->inContentLanguage()->text();
+ $title = Title::makeTitleSafe( NS_PROJECT, $page ); # Show list in content language
+ if ( is_object( $title ) && $title->exists() ) {
+ $rev = Revision::newFromTitle( $title, false, Revision::READ_NORMAL );
+ $content = $rev->getContent();
+
+ if ( $content instanceof TextContent ) {
+ // XXX: in the future, this could be stored as structured data, defining a list of book sources
+
+ $text = $content->getNativeData();
+ $out->addWikiText( str_replace( 'MAGICNUMBER', $isbn, $text ) );
+
+ return true;
+ } else {
+ throw new MWException( "Unexpected content type for book sources: " . $content->getModel() );
+ }
+ }
+
+ # Fall back to the defaults given in the language file
+ $out->addWikiMsg( 'booksources-text' );
+ $out->addHTML( '<ul>' );
+ $items = $wgContLang->getBookstoreList();
+ foreach ( $items as $label => $url ) {
+ $out->addHTML( $this->makeListItem( $isbn, $label, $url ) );
+ }
+ $out->addHTML( '</ul>' );
+
+ return true;
+ }
+
+ /**
+ * Format a book source list item
+ *
+ * @param string $isbn
+ * @param string $label Book source label
+ * @param string $url Book source URL
+ * @return string
+ */
+ private function makeListItem( $isbn, $label, $url ) {
+ $url = str_replace( '$1', $isbn, $url );
+
+ return Html::rawElement( 'li', [],
+ Html::element( 'a', [ 'href' => $url, 'class' => 'external' ], $label )
+ );
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialBotPasswords.php b/www/wiki/includes/specials/SpecialBotPasswords.php
new file mode 100644
index 00000000..056ce657
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialBotPasswords.php
@@ -0,0 +1,367 @@
+<?php
+/**
+ * Implements Special:BotPasswords
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Let users manage bot passwords
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialBotPasswords extends FormSpecialPage {
+
+ /** @var int Central user ID */
+ private $userId = 0;
+
+ /** @var BotPassword|null Bot password being edited, if any */
+ private $botPassword = null;
+
+ /** @var string Operation being performed: create, update, delete */
+ private $operation = null;
+
+ /** @var string New password set, for communication between onSubmit() and onSuccess() */
+ private $password = null;
+
+ public function __construct() {
+ parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
+ }
+
+ /**
+ * @return bool
+ */
+ public function isListed() {
+ return $this->getConfig()->get( 'EnableBotPasswords' );
+ }
+
+ protected function getLoginSecurityLevel() {
+ return $this->getName();
+ }
+
+ /**
+ * Main execution point
+ * @param string|null $par
+ */
+ function execute( $par ) {
+ $this->getOutput()->disallowUserJs();
+ $this->requireLogin();
+
+ $par = trim( $par );
+ if ( strlen( $par ) === 0 ) {
+ $par = null;
+ } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
+ throw new ErrorPageError( 'botpasswords', 'botpasswords-bad-appid',
+ [ htmlspecialchars( $par ) ] );
+ }
+
+ parent::execute( $par );
+ }
+
+ protected function checkExecutePermissions( User $user ) {
+ parent::checkExecutePermissions( $user );
+
+ if ( !$this->getConfig()->get( 'EnableBotPasswords' ) ) {
+ throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
+ }
+
+ $this->userId = CentralIdLookup::factory()->centralIdFromLocalUser( $this->getUser() );
+ if ( !$this->userId ) {
+ throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
+ }
+ }
+
+ protected function getFormFields() {
+ $fields = [];
+
+ if ( $this->par !== null ) {
+ $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
+ if ( !$this->botPassword ) {
+ $this->botPassword = BotPassword::newUnsaved( [
+ 'centralId' => $this->userId,
+ 'appId' => $this->par,
+ ] );
+ }
+
+ $sep = BotPassword::getSeparator();
+ $fields[] = [
+ 'type' => 'info',
+ 'label-message' => 'username',
+ 'default' => $this->getUser()->getName() . $sep . $this->par
+ ];
+
+ if ( $this->botPassword->isSaved() ) {
+ $fields['resetPassword'] = [
+ 'type' => 'check',
+ 'label-message' => 'botpasswords-label-resetpassword',
+ ];
+ if ( $this->botPassword->isInvalid() ) {
+ $fields['resetPassword']['default'] = true;
+ }
+ }
+
+ $lang = $this->getLanguage();
+ $showGrants = MWGrants::getValidGrants();
+ $fields['grants'] = [
+ 'type' => 'checkmatrix',
+ 'label-message' => 'botpasswords-label-grants',
+ 'help-message' => 'botpasswords-help-grants',
+ 'columns' => [
+ $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
+ ],
+ 'rows' => array_combine(
+ array_map( 'MWGrants::getGrantsLink', $showGrants ),
+ $showGrants
+ ),
+ 'default' => array_map(
+ function ( $g ) {
+ return "grant-$g";
+ },
+ $this->botPassword->getGrants()
+ ),
+ 'tooltips' => array_combine(
+ array_map( 'MWGrants::getGrantsLink', $showGrants ),
+ array_map(
+ function ( $rights ) use ( $lang ) {
+ return $lang->semicolonList( array_map( 'User::getRightDescription', $rights ) );
+ },
+ array_intersect_key( MWGrants::getRightsByGrant(), array_flip( $showGrants ) )
+ )
+ ),
+ 'force-options-on' => array_map(
+ function ( $g ) {
+ return "grant-$g";
+ },
+ MWGrants::getHiddenGrants()
+ ),
+ ];
+
+ $fields['restrictions'] = [
+ 'class' => 'HTMLRestrictionsField',
+ 'required' => true,
+ 'default' => $this->botPassword->getRestrictions(),
+ ];
+
+ } else {
+ $linkRenderer = $this->getLinkRenderer();
+ $passwordFactory = new PasswordFactory();
+ $passwordFactory->init( $this->getConfig() );
+
+ $dbr = BotPassword::getDB( DB_REPLICA );
+ $res = $dbr->select(
+ 'bot_passwords',
+ [ 'bp_app_id', 'bp_password' ],
+ [ 'bp_user' => $this->userId ],
+ __METHOD__
+ );
+ foreach ( $res as $row ) {
+ try {
+ $password = $passwordFactory->newFromCiphertext( $row->bp_password );
+ $passwordInvalid = $password instanceof InvalidPassword;
+ unset( $password );
+ } catch ( PasswordError $ex ) {
+ $passwordInvalid = true;
+ }
+
+ $text = $linkRenderer->makeKnownLink(
+ $this->getPageTitle( $row->bp_app_id ),
+ $row->bp_app_id
+ );
+ if ( $passwordInvalid ) {
+ $text .= $this->msg( 'word-separator' )->escaped()
+ . $this->msg( 'botpasswords-label-needsreset' )->parse();
+ }
+
+ $fields[] = [
+ 'section' => 'existing',
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $text,
+ ];
+ }
+
+ $fields['appId'] = [
+ 'section' => 'createnew',
+ 'type' => 'textwithbutton',
+ 'label-message' => 'botpasswords-label-appid',
+ 'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
+ 'buttonflags' => [ 'progressive', 'primary' ],
+ 'required' => true,
+ 'size' => BotPassword::APPID_MAXLENGTH,
+ 'maxlength' => BotPassword::APPID_MAXLENGTH,
+ 'validation-callback' => function ( $v ) {
+ $v = trim( $v );
+ return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
+ },
+ ];
+
+ $fields[] = [
+ 'type' => 'hidden',
+ 'default' => 'new',
+ 'name' => 'op',
+ ];
+ }
+
+ return $fields;
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setId( 'mw-botpasswords-form' );
+ $form->setTableId( 'mw-botpasswords-table' );
+ $form->addPreText( $this->msg( 'botpasswords-summary' )->parseAsBlock() );
+ $form->suppressDefaultSubmit();
+
+ if ( $this->par !== null ) {
+ if ( $this->botPassword->isSaved() ) {
+ $form->setWrapperLegendMsg( 'botpasswords-editexisting' );
+ $form->addButton( [
+ 'name' => 'op',
+ 'value' => 'update',
+ 'label-message' => 'botpasswords-label-update',
+ 'flags' => [ 'primary', 'progressive' ],
+ ] );
+ $form->addButton( [
+ 'name' => 'op',
+ 'value' => 'delete',
+ 'label-message' => 'botpasswords-label-delete',
+ 'flags' => [ 'destructive' ],
+ ] );
+ } else {
+ $form->setWrapperLegendMsg( 'botpasswords-createnew' );
+ $form->addButton( [
+ 'name' => 'op',
+ 'value' => 'create',
+ 'label-message' => 'botpasswords-label-create',
+ 'flags' => [ 'primary', 'progressive' ],
+ ] );
+ }
+
+ $form->addButton( [
+ 'name' => 'op',
+ 'value' => 'cancel',
+ 'label-message' => 'botpasswords-label-cancel'
+ ] );
+ }
+ }
+
+ public function onSubmit( array $data ) {
+ $op = $this->getRequest()->getVal( 'op', '' );
+
+ switch ( $op ) {
+ case 'new':
+ $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
+ return false;
+
+ case 'create':
+ $this->operation = 'insert';
+ return $this->save( $data );
+
+ case 'update':
+ $this->operation = 'update';
+ return $this->save( $data );
+
+ case 'delete':
+ $this->operation = 'delete';
+ $bp = BotPassword::newFromCentralId( $this->userId, $this->par );
+ if ( $bp ) {
+ $bp->delete();
+ }
+ return Status::newGood();
+
+ case 'cancel':
+ $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
+ return false;
+ }
+
+ return false;
+ }
+
+ private function save( array $data ) {
+ $bp = BotPassword::newUnsaved( [
+ 'centralId' => $this->userId,
+ 'appId' => $this->par,
+ 'restrictions' => $data['restrictions'],
+ 'grants' => array_merge(
+ MWGrants::getHiddenGrants(),
+ preg_replace( '/^grant-/', '', $data['grants'] )
+ )
+ ] );
+
+ if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
+ $this->password = BotPassword::generatePassword( $this->getConfig() );
+ $passwordFactory = new PasswordFactory();
+ $passwordFactory->init( RequestContext::getMain()->getConfig() );
+ $password = $passwordFactory->newFromPlaintext( $this->password );
+ } else {
+ $password = null;
+ }
+
+ if ( $bp->save( $this->operation, $password ) ) {
+ return Status::newGood();
+ } else {
+ // Messages: botpasswords-insert-failed, botpasswords-update-failed
+ return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
+ }
+ }
+
+ public function onSuccess() {
+ $out = $this->getOutput();
+
+ $username = $this->getUser()->getName();
+ switch ( $this->operation ) {
+ case 'insert':
+ $out->setPageTitle( $this->msg( 'botpasswords-created-title' )->text() );
+ $out->addWikiMsg( 'botpasswords-created-body', $this->par, $username );
+ break;
+
+ case 'update':
+ $out->setPageTitle( $this->msg( 'botpasswords-updated-title' )->text() );
+ $out->addWikiMsg( 'botpasswords-updated-body', $this->par, $username );
+ break;
+
+ case 'delete':
+ $out->setPageTitle( $this->msg( 'botpasswords-deleted-title' )->text() );
+ $out->addWikiMsg( 'botpasswords-deleted-body', $this->par, $username );
+ $this->password = null;
+ break;
+ }
+
+ if ( $this->password !== null ) {
+ $sep = BotPassword::getSeparator();
+ $out->addWikiMsg(
+ 'botpasswords-newpassword',
+ htmlspecialchars( $username . $sep . $this->par ),
+ htmlspecialchars( $this->password ),
+ htmlspecialchars( $username ),
+ htmlspecialchars( $this->par . $sep . $this->password )
+ );
+ $this->password = null;
+ }
+
+ $out->addReturnTo( $this->getPageTitle() );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialBrokenRedirects.php b/www/wiki/includes/specials/SpecialBrokenRedirects.php
new file mode 100644
index 00000000..cf9ae071
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialBrokenRedirects.php
@@ -0,0 +1,179 @@
+<?php
+/**
+ * Implements Special:Brokenredirects
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A special page listing redirects to non existent page. Those should be
+ * fixed to point to an existing page.
+ *
+ * @ingroup SpecialPage
+ */
+class BrokenRedirectsPage extends QueryPage {
+ function __construct( $name = 'BrokenRedirects' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ function getPageHeader() {
+ return $this->msg( 'brokenredirectstext' )->parseAsBlock();
+ }
+
+ public function getQueryInfo() {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ return [
+ 'tables' => [
+ 'redirect',
+ 'p1' => 'page',
+ 'p2' => 'page',
+ ],
+ 'fields' => [
+ 'namespace' => 'p1.page_namespace',
+ 'title' => 'p1.page_title',
+ 'value' => 'p1.page_title',
+ 'rd_namespace',
+ 'rd_title',
+ 'rd_fragment',
+ ],
+ 'conds' => [
+ // Exclude pages that don't exist locally as wiki pages,
+ // but aren't "broken" either.
+ // Special pages and interwiki links
+ 'rd_namespace >= 0',
+ 'rd_interwiki IS NULL OR rd_interwiki = ' . $dbr->addQuotes( '' ),
+ 'p2.page_namespace IS NULL',
+ ],
+ 'join_conds' => [
+ 'p1' => [ 'JOIN', [
+ 'rd_from=p1.page_id',
+ ] ],
+ 'p2' => [ 'LEFT JOIN', [
+ 'rd_namespace=p2.page_namespace',
+ 'rd_title=p2.page_title'
+ ] ],
+ ],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ function getOrderFields() {
+ return [ 'rd_namespace', 'rd_title', 'rd_from' ];
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $fromObj = Title::makeTitle( $result->namespace, $result->title );
+ if ( isset( $result->rd_title ) ) {
+ $toObj = Title::makeTitle( $result->rd_namespace, $result->rd_title, $result->rd_fragment );
+ } else {
+ $blinks = $fromObj->getBrokenLinksFrom(); # TODO: check for redirect, not for links
+ if ( $blinks ) {
+ $toObj = $blinks[0];
+ } else {
+ $toObj = false;
+ }
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+ // $toObj may very easily be false if the $result list is cached
+ if ( !is_object( $toObj ) ) {
+ return '<del>' . $linkRenderer->makeLink( $fromObj ) . '</del>';
+ }
+
+ $from = $linkRenderer->makeKnownLink(
+ $fromObj,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+ $links = [];
+ // if the page is editable, add an edit link
+ if (
+ // check user permissions
+ $this->getUser()->isAllowed( 'edit' ) &&
+ // check, if the content model is editable through action=edit
+ ContentHandler::getForTitle( $fromObj )->supportsDirectEditing()
+ ) {
+ $links[] = $linkRenderer->makeKnownLink(
+ $fromObj,
+ $this->msg( 'brokenredirects-edit' )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ }
+ $to = $linkRenderer->makeBrokenLink( $toObj, $toObj->getFullText() );
+ $arr = $this->getLanguage()->getArrow();
+
+ $out = $from . $this->msg( 'word-separator' )->escaped();
+
+ if ( $this->getUser()->isAllowed( 'delete' ) ) {
+ $links[] = $linkRenderer->makeKnownLink(
+ $fromObj,
+ $this->msg( 'brokenredirects-delete' )->text(),
+ [],
+ [ 'action' => 'delete' ]
+ );
+ }
+
+ if ( $links ) {
+ $out .= $this->msg( 'parentheses' )->rawParams( $this->getLanguage()
+ ->pipeList( $links ) )->escaped();
+ }
+ $out .= " {$arr} {$to}";
+
+ return $out;
+ }
+
+ /**
+ * Cache page content model for performance
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialCachedPage.php b/www/wiki/includes/specials/SpecialCachedPage.php
new file mode 100644
index 00000000..14c84e9d
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialCachedPage.php
@@ -0,0 +1,201 @@
+<?php
+
+/**
+ * Abstract special page class with scaffolding for caching HTML and other values
+ * in a single blob.
+ *
+ * Before using any of the caching functionality, call startCache.
+ * After the last call to either getCachedValue or addCachedHTML, call saveCache.
+ *
+ * To get a cached value or compute it, use getCachedValue like this:
+ * $this->getCachedValue( $callback );
+ *
+ * To add HTML that should be cached, use addCachedHTML like this:
+ * $this->addCachedHTML( $callback );
+ *
+ * The callback function is only called when needed, so do all your expensive
+ * computations here. This function should returns the HTML to be cached.
+ * It should not add anything to the PageOutput object!
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Jeroen De Dauw < jeroendedauw@gmail.com >
+ * @since 1.20
+ */
+abstract class SpecialCachedPage extends SpecialPage implements ICacheHelper {
+ /**
+ * CacheHelper object to which we forward the non-SpecialPage specific caching work.
+ * Initialized in startCache.
+ *
+ * @since 1.20
+ * @var CacheHelper
+ */
+ protected $cacheHelper;
+
+ /**
+ * If the cache is enabled or not.
+ *
+ * @since 1.20
+ * @var bool
+ */
+ protected $cacheEnabled = true;
+
+ /**
+ * Gets called after @see SpecialPage::execute.
+ *
+ * @since 1.20
+ *
+ * @param string|null $subPage
+ */
+ protected function afterExecute( $subPage ) {
+ $this->saveCache();
+
+ parent::afterExecute( $subPage );
+ }
+
+ /**
+ * Sets if the cache should be enabled or not.
+ *
+ * @since 1.20
+ * @param bool $cacheEnabled
+ */
+ public function setCacheEnabled( $cacheEnabled ) {
+ $this->cacheHelper->setCacheEnabled( $cacheEnabled );
+ }
+
+ /**
+ * Initializes the caching.
+ * Should be called before the first time anything is added via addCachedHTML.
+ *
+ * @since 1.20
+ *
+ * @param int|null $cacheExpiry Sets the cache expiry, either ttl in seconds or unix timestamp.
+ * @param bool|null $cacheEnabled Sets if the cache should be enabled or not.
+ */
+ public function startCache( $cacheExpiry = null, $cacheEnabled = null ) {
+ if ( !isset( $this->cacheHelper ) ) {
+ $this->cacheHelper = new CacheHelper();
+
+ $this->cacheHelper->setCacheEnabled( $this->cacheEnabled );
+ $this->cacheHelper->setOnInitializedHandler( [ $this, 'onCacheInitialized' ] );
+
+ $keyArgs = $this->getCacheKey();
+
+ if ( array_key_exists( 'action', $keyArgs ) && $keyArgs['action'] === 'purge' ) {
+ unset( $keyArgs['action'] );
+ }
+
+ $this->cacheHelper->setCacheKey( $keyArgs );
+
+ if ( $this->getRequest()->getText( 'action' ) === 'purge' ) {
+ $this->cacheHelper->rebuildOnDemand();
+ }
+ }
+
+ $this->cacheHelper->startCache( $cacheExpiry, $cacheEnabled );
+ }
+
+ /**
+ * Get a cached value if available or compute it if not and then cache it if possible.
+ * The provided $computeFunction is only called when the computation needs to happen
+ * and should return a result value. $args are arguments that will be passed to the
+ * compute function when called.
+ *
+ * @since 1.20
+ *
+ * @param callable $computeFunction
+ * @param array|mixed $args
+ * @param string|null $key
+ *
+ * @return mixed
+ */
+ public function getCachedValue( $computeFunction, $args = [], $key = null ) {
+ return $this->cacheHelper->getCachedValue( $computeFunction, $args, $key );
+ }
+
+ /**
+ * Add some HTML to be cached.
+ * This is done by providing a callback function that should
+ * return the HTML to be added. It will only be called if the
+ * item is not in the cache yet or when the cache has been invalidated.
+ *
+ * @since 1.20
+ *
+ * @param callable $computeFunction
+ * @param array $args
+ * @param string|null $key
+ */
+ public function addCachedHTML( $computeFunction, $args = [], $key = null ) {
+ $this->getOutput()->addHTML( $this->cacheHelper->getCachedValue(
+ $computeFunction,
+ $args,
+ $key
+ ) );
+ }
+
+ /**
+ * Saves the HTML to the cache in case it got recomputed.
+ * Should be called after the last time anything is added via addCachedHTML.
+ *
+ * @since 1.20
+ */
+ public function saveCache() {
+ if ( isset( $this->cacheHelper ) ) {
+ $this->cacheHelper->saveCache();
+ }
+ }
+
+ /**
+ * Sets the time to live for the cache, in seconds or a unix timestamp
+ * indicating the point of expiry.
+ *
+ * @since 1.20
+ *
+ * @param int $cacheExpiry
+ */
+ public function setExpiry( $cacheExpiry ) {
+ $this->cacheHelper->setExpiry( $cacheExpiry );
+ }
+
+ /**
+ * Returns the variables used to constructed the cache key in an array.
+ *
+ * @since 1.20
+ *
+ * @return array
+ */
+ protected function getCacheKey() {
+ return [
+ $this->mName,
+ $this->getLanguage()->getCode()
+ ];
+ }
+
+ /**
+ * Gets called after the cache got initialized.
+ *
+ * @since 1.20
+ *
+ * @param bool $hasCached
+ */
+ public function onCacheInitialized( $hasCached ) {
+ if ( $hasCached ) {
+ $this->getOutput()->setSubtitle( $this->cacheHelper->getCachedNotice( $this->getContext() ) );
+ }
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialCategories.php b/www/wiki/includes/specials/SpecialCategories.php
new file mode 100644
index 00000000..84d1f7c7
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialCategories.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Implements Special:Categories
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * @ingroup SpecialPage
+ */
+class SpecialCategories extends SpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'Categories' );
+
+ // Since we don't control the constructor parameters, we can't inject services that way.
+ // Instead, we initialize services in the execute() method, and allow them to be overridden
+ // using the initServices() method.
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->getOutput()->allowClickjacking();
+
+ $from = $this->getRequest()->getText( 'from', $par );
+
+ $cap = new CategoryPager(
+ $this->getContext(),
+ $from,
+ $this->getLinkRenderer()
+ );
+ $cap->doQuery();
+
+ $this->getOutput()->addHTML(
+ Html::openElement( 'div', [ 'class' => 'mw-spcontent' ] ) .
+ $this->msg( 'categoriespagetext', $cap->getNumRows() )->parseAsBlock() .
+ $cap->getStartForm( $from ) .
+ $cap->getNavigationBar() .
+ '<ul>' . $cap->getBody() . '</ul>' .
+ $cap->getNavigationBar() .
+ Html::closeElement( 'div' )
+ );
+ }
+
+ protected function getGroupName() {
+ return 'pages';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialChangeContentModel.php b/www/wiki/includes/specials/SpecialChangeContentModel.php
new file mode 100644
index 00000000..87c899f4
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialChangeContentModel.php
@@ -0,0 +1,296 @@
+<?php
+
+class SpecialChangeContentModel extends FormSpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'ChangeContentModel', 'editcontentmodel' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * @var Title|null
+ */
+ private $title;
+
+ /**
+ * @var Revision|bool|null
+ *
+ * A Revision object, false if no revision exists, null if not loaded yet
+ */
+ private $oldRevision;
+
+ protected function setParameter( $par ) {
+ $par = $this->getRequest()->getVal( 'pagetitle', $par );
+ $title = Title::newFromText( $par );
+ if ( $title ) {
+ $this->title = $title;
+ $this->par = $title->getPrefixedText();
+ } else {
+ $this->par = '';
+ }
+ }
+
+ protected function postText() {
+ $text = '';
+ if ( $this->title ) {
+ $contentModelLogPage = new LogPage( 'contentmodel' );
+ $text = Xml::element( 'h2', null, $contentModelLogPage->getName()->text() );
+ $out = '';
+ LogEventsList::showLogExtract( $out, 'contentmodel', $this->title );
+ $text .= $out;
+ }
+ return $text;
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ if ( !$this->title ) {
+ $form->setMethod( 'GET' );
+ }
+
+ $this->addHelpLink( 'Help:ChangeContentModel' );
+
+ // T120576
+ $form->setSubmitTextMsg( 'changecontentmodel-submit' );
+ }
+
+ public function validateTitle( $title ) {
+ if ( !$title ) {
+ // No form input yet
+ return true;
+ }
+
+ // Already validated by HTMLForm, but if not, throw
+ // and exception instead of a fatal
+ $titleObj = Title::newFromTextThrow( $title );
+
+ $this->oldRevision = Revision::newFromTitle( $titleObj ) ?: false;
+
+ if ( $this->oldRevision ) {
+ $oldContent = $this->oldRevision->getContent();
+ if ( !$oldContent->getContentHandler()->supportsDirectEditing() ) {
+ return $this->msg( 'changecontentmodel-nodirectediting' )
+ ->params( ContentHandler::getLocalizedName( $oldContent->getModel() ) )
+ ->escaped();
+ }
+ }
+
+ return true;
+ }
+
+ protected function getFormFields() {
+ $fields = [
+ 'pagetitle' => [
+ 'type' => 'title',
+ 'creatable' => true,
+ 'name' => 'pagetitle',
+ 'default' => $this->par,
+ 'label-message' => 'changecontentmodel-title-label',
+ 'validation-callback' => [ $this, 'validateTitle' ],
+ ],
+ ];
+ if ( $this->title ) {
+ $options = $this->getOptionsForTitle( $this->title );
+ if ( empty( $options ) ) {
+ throw new ErrorPageError(
+ 'changecontentmodel-emptymodels-title',
+ 'changecontentmodel-emptymodels-text',
+ $this->title->getPrefixedText()
+ );
+ }
+ $fields['pagetitle']['readonly'] = true;
+ $fields += [
+ 'model' => [
+ 'type' => 'select',
+ 'name' => 'model',
+ 'options' => $options,
+ 'label-message' => 'changecontentmodel-model-label'
+ ],
+ 'reason' => [
+ 'type' => 'text',
+ 'name' => 'reason',
+ 'validation-callback' => function ( $reason ) {
+ $match = EditPage::matchSummarySpamRegex( $reason );
+ if ( $match ) {
+ return $this->msg( 'spamprotectionmatch', $match )->parse();
+ }
+
+ return true;
+ },
+ 'label-message' => 'changecontentmodel-reason-label',
+ ],
+ ];
+ }
+
+ return $fields;
+ }
+
+ private function getOptionsForTitle( Title $title = null ) {
+ $models = ContentHandler::getContentModels();
+ $options = [];
+ foreach ( $models as $model ) {
+ $handler = ContentHandler::getForModelID( $model );
+ if ( !$handler->supportsDirectEditing() ) {
+ continue;
+ }
+ if ( $title ) {
+ if ( $title->getContentModel() === $model ) {
+ continue;
+ }
+ if ( !$handler->canBeUsedOn( $title ) ) {
+ continue;
+ }
+ }
+ $options[ContentHandler::getLocalizedName( $model )] = $model;
+ }
+
+ return $options;
+ }
+
+ public function onSubmit( array $data ) {
+ if ( $data['pagetitle'] === '' ) {
+ // Initial form view of special page, pass
+ return false;
+ }
+
+ // At this point, it has to be a POST request. This is enforced by HTMLForm,
+ // but lets be safe verify that.
+ if ( !$this->getRequest()->wasPosted() ) {
+ throw new RuntimeException( "Form submission was not POSTed" );
+ }
+
+ $this->title = Title::newFromText( $data['pagetitle'] );
+ $titleWithNewContentModel = clone $this->title;
+ $titleWithNewContentModel->setContentModel( $data['model'] );
+ $user = $this->getUser();
+ // Check permissions and make sure the user has permission to:
+ $errors = wfMergeErrorArrays(
+ // edit the contentmodel of the page
+ $this->title->getUserPermissionsErrors( 'editcontentmodel', $user ),
+ // edit the page under the old content model
+ $this->title->getUserPermissionsErrors( 'edit', $user ),
+ // edit the contentmodel under the new content model
+ $titleWithNewContentModel->getUserPermissionsErrors( 'editcontentmodel', $user ),
+ // edit the page under the new content model
+ $titleWithNewContentModel->getUserPermissionsErrors( 'edit', $user )
+ );
+ if ( $errors ) {
+ $out = $this->getOutput();
+ $wikitext = $out->formatPermissionsErrorMessage( $errors );
+ // Hack to get our wikitext parsed
+ return Status::newFatal( new RawMessage( '$1', [ $wikitext ] ) );
+ }
+
+ $page = WikiPage::factory( $this->title );
+ if ( $this->oldRevision === null ) {
+ $this->oldRevision = $page->getRevision() ?: false;
+ }
+ $oldModel = $this->title->getContentModel();
+ if ( $this->oldRevision ) {
+ $oldContent = $this->oldRevision->getContent();
+ try {
+ $newContent = ContentHandler::makeContent(
+ $oldContent->serialize(), $this->title, $data['model']
+ );
+ } catch ( MWException $e ) {
+ return Status::newFatal(
+ $this->msg( 'changecontentmodel-cannot-convert' )
+ ->params(
+ $this->title->getPrefixedText(),
+ ContentHandler::getLocalizedName( $data['model'] )
+ )
+ );
+ }
+ } else {
+ // Page doesn't exist, create an empty content object
+ $newContent = ContentHandler::getForModelID( $data['model'] )->makeEmptyContent();
+ }
+
+ // All other checks have passed, let's check rate limits
+ if ( $user->pingLimiter( 'editcontentmodel' ) ) {
+ throw new ThrottledError();
+ }
+
+ $flags = $this->oldRevision ? EDIT_UPDATE : EDIT_NEW;
+ $flags |= EDIT_INTERNAL;
+ if ( $user->isAllowed( 'bot' ) ) {
+ $flags |= EDIT_FORCE_BOT;
+ }
+
+ $log = new ManualLogEntry( 'contentmodel', $this->oldRevision ? 'change' : 'new' );
+ $log->setPerformer( $user );
+ $log->setTarget( $this->title );
+ $log->setComment( $data['reason'] );
+ $log->setParameters( [
+ '4::oldmodel' => $oldModel,
+ '5::newmodel' => $data['model']
+ ] );
+
+ $formatter = LogFormatter::newFromEntry( $log );
+ $formatter->setContext( RequestContext::newExtraneousContext( $this->title ) );
+ $reason = $formatter->getPlainActionText();
+ if ( $data['reason'] !== '' ) {
+ $reason .= $this->msg( 'colon-separator' )->inContentLanguage()->text() . $data['reason'];
+ }
+
+ // Run edit filters
+ $derivativeContext = new DerivativeContext( $this->getContext() );
+ $derivativeContext->setTitle( $this->title );
+ $derivativeContext->setWikiPage( $page );
+ $status = new Status();
+ if ( !Hooks::run( 'EditFilterMergedContent',
+ [ $derivativeContext, $newContent, $status, $reason,
+ $user, false ] )
+ ) {
+ if ( $status->isGood() ) {
+ // TODO: extensions should really specify an error message
+ $status->fatal( 'hookaborted' );
+ }
+ return $status;
+ }
+
+ $status = $page->doEditContent(
+ $newContent,
+ $reason,
+ $flags,
+ $this->oldRevision ? $this->oldRevision->getId() : false,
+ $user
+ );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $logid = $log->insert();
+ $log->publish( $logid );
+
+ return $status;
+ }
+
+ public function onSuccess() {
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'changecontentmodel-success-title' ) );
+ $out->addWikiMsg( 'changecontentmodel-success-text', $this->title );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialChangeCredentials.php b/www/wiki/includes/specials/SpecialChangeCredentials.php
new file mode 100644
index 00000000..970a2e29
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialChangeCredentials.php
@@ -0,0 +1,267 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Session\SessionManager;
+
+/**
+ * Special change to change credentials (such as the password).
+ *
+ * Also does most of the work for SpecialRemoveCredentials.
+ */
+class SpecialChangeCredentials extends AuthManagerSpecialPage {
+ protected static $allowedActions = [ AuthManager::ACTION_CHANGE ];
+
+ protected static $messagePrefix = 'changecredentials';
+
+ /** Change action needs user data; remove action does not */
+ protected static $loadUserData = true;
+
+ public function __construct( $name = 'ChangeCredentials' ) {
+ parent::__construct( $name, 'editmyprivateinfo' );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+
+ public function isListed() {
+ $this->loadAuth( '' );
+ return (bool)$this->authRequests;
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ protected function getDefaultAction( $subPage ) {
+ return AuthManager::ACTION_CHANGE;
+ }
+
+ protected function getPreservedParams( $withToken = false ) {
+ $request = $this->getRequest();
+ $params = parent::getPreservedParams( $withToken );
+ $params += [
+ 'returnto' => $request->getVal( 'returnto' ),
+ 'returntoquery' => $request->getVal( 'returntoquery' ),
+ ];
+ return $params;
+ }
+
+ public function onAuthChangeFormFields(
+ array $requests, array $fieldInfo, array &$formDescriptor, $action
+ ) {
+ // This method is never called for remove actions.
+
+ $extraFields = [];
+ Hooks::run( 'ChangePasswordForm', [ &$extraFields ], '1.27' );
+ foreach ( $extraFields as $extra ) {
+ list( $name, $label, $type, $default ) = $extra;
+ $formDescriptor[$name] = [
+ 'type' => $type,
+ 'name' => $name,
+ 'label-message' => $label,
+ 'default' => $default,
+ ];
+
+ }
+
+ return parent::onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
+ }
+
+ public function execute( $subPage ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $this->loadAuth( $subPage );
+
+ if ( !$subPage ) {
+ $this->showSubpageList();
+ return;
+ }
+
+ if ( !$this->authRequests ) {
+ // messages used: changecredentials-invalidsubpage, removecredentials-invalidsubpage
+ $this->showSubpageList( $this->msg( static::$messagePrefix . '-invalidsubpage', $subPage ) );
+ return;
+ }
+
+ $this->getOutput()->addBacklinkSubtitle( $this->getPageTitle() );
+
+ $status = $this->trySubmit();
+
+ if ( $status === false || !$status->isOK() ) {
+ $this->displayForm( $status );
+ return;
+ }
+
+ $response = $status->getValue();
+
+ switch ( $response->status ) {
+ case AuthenticationResponse::PASS:
+ $this->success();
+ break;
+ case AuthenticationResponse::FAIL:
+ $this->displayForm( Status::newFatal( $response->message ) );
+ break;
+ default:
+ throw new LogicException( 'invalid AuthenticationResponse' );
+ }
+ }
+
+ protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
+ parent::loadAuth( $subPage, $authAction );
+ if ( $subPage ) {
+ $this->authRequests = array_filter( $this->authRequests, function ( $req ) use ( $subPage ) {
+ return $req->getUniqueId() === $subPage;
+ } );
+ if ( count( $this->authRequests ) > 1 ) {
+ throw new LogicException( 'Multiple AuthenticationRequest objects with same ID!' );
+ }
+ }
+ }
+
+ protected function getAuthFormDescriptor( $requests, $action ) {
+ if ( !static::$loadUserData ) {
+ return [];
+ } else {
+ $descriptor = parent::getAuthFormDescriptor( $requests, $action );
+
+ $any = false;
+ foreach ( $descriptor as &$field ) {
+ if ( $field['type'] === 'password' && $field['name'] !== 'retype' ) {
+ $any = true;
+ if ( isset( $field['cssclass'] ) ) {
+ $field['cssclass'] .= ' mw-changecredentials-validate-password';
+ } else {
+ $field['cssclass'] = 'mw-changecredentials-validate-password';
+ }
+ }
+ }
+
+ if ( $any ) {
+ $this->getOutput()->addModules( [
+ 'mediawiki.special.changecredentials.js'
+ ] );
+ }
+
+ return $descriptor;
+ }
+ }
+
+ protected function getAuthForm( array $requests, $action ) {
+ $form = parent::getAuthForm( $requests, $action );
+ $req = reset( $requests );
+ $info = $req->describeCredentials();
+
+ $form->addPreText(
+ Html::openElement( 'dl' )
+ . Html::element( 'dt', [], wfMessage( 'credentialsform-provider' )->text() )
+ . Html::element( 'dd', [], $info['provider'] )
+ . Html::element( 'dt', [], wfMessage( 'credentialsform-account' )->text() )
+ . Html::element( 'dd', [], $info['account'] )
+ . Html::closeElement( 'dl' )
+ );
+
+ // messages used: changecredentials-submit removecredentials-submit
+ $form->setSubmitTextMsg( static::$messagePrefix . '-submit' );
+ $form->showCancel()->setCancelTarget( $this->getReturnUrl() ?: Title::newMainPage() );
+
+ return $form;
+ }
+
+ protected function needsSubmitButton( array $requests ) {
+ // Change/remove forms show are built from a single AuthenticationRequest and do not allow
+ // for redirect flow; they always need a submit button.
+ return true;
+ }
+
+ public function handleFormSubmit( $data ) {
+ // remove requests do not accept user input
+ $requests = $this->authRequests;
+ if ( static::$loadUserData ) {
+ $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
+ }
+
+ $response = $this->performAuthenticationStep( $this->authAction, $requests );
+
+ // we can't handle FAIL or similar as failure here since it might require changing the form
+ return Status::newGood( $response );
+ }
+
+ /**
+ * @param Message|null $error
+ */
+ protected function showSubpageList( $error = null ) {
+ $out = $this->getOutput();
+
+ if ( $error ) {
+ $out->addHTML( $error->parse() );
+ }
+
+ $groupedRequests = [];
+ foreach ( $this->authRequests as $req ) {
+ $info = $req->describeCredentials();
+ $groupedRequests[(string)$info['provider']][] = $req;
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+ $out->addHTML( Html::openElement( 'dl' ) );
+ foreach ( $groupedRequests as $group => $members ) {
+ $out->addHTML( Html::element( 'dt', [], $group ) );
+ foreach ( $members as $req ) {
+ /** @var AuthenticationRequest $req */
+ $info = $req->describeCredentials();
+ $out->addHTML( Html::rawElement( 'dd', [],
+ $linkRenderer->makeLink(
+ $this->getPageTitle( $req->getUniqueId() ),
+ $info['account']
+ )
+ ) );
+ }
+ }
+ $out->addHTML( Html::closeElement( 'dl' ) );
+ }
+
+ protected function success() {
+ $session = $this->getRequest()->getSession();
+ $user = $this->getUser();
+ $out = $this->getOutput();
+ $returnUrl = $this->getReturnUrl();
+
+ // change user token and update the session
+ SessionManager::singleton()->invalidateSessionsForUser( $user );
+ $session->setUser( $user );
+ $session->resetId();
+
+ if ( $returnUrl ) {
+ $out->redirect( $returnUrl );
+ } else {
+ // messages used: changecredentials-success removecredentials-success
+ $out->wrapWikiMsg( "<div class=\"successbox\">\n$1\n</div>", static::$messagePrefix
+ . '-success' );
+ $out->returnToMain();
+ }
+ }
+
+ /**
+ * @return string|null
+ */
+ protected function getReturnUrl() {
+ $request = $this->getRequest();
+ $returnTo = $request->getText( 'returnto' );
+ $returnToQuery = $request->getText( 'returntoquery', '' );
+
+ if ( !$returnTo ) {
+ return null;
+ }
+
+ $title = Title::newFromText( $returnTo );
+ return $title->getFullUrlForRedirect( $returnToQuery );
+ }
+
+ protected function getRequestBlacklist() {
+ return $this->getConfig()->get( 'ChangeCredentialsBlacklist' );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialChangeEmail.php b/www/wiki/includes/specials/SpecialChangeEmail.php
new file mode 100644
index 00000000..3d24832b
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialChangeEmail.php
@@ -0,0 +1,194 @@
+<?php
+/**
+ * Implements Special:ChangeEmail
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Let users change their email address.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialChangeEmail extends FormSpecialPage {
+ /**
+ * @var Status
+ */
+ private $status;
+
+ public function __construct() {
+ parent::__construct( 'ChangeEmail', 'editmyprivateinfo' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isListed() {
+ return AuthManager::singleton()->allowsPropertyChange( 'emailaddress' );
+ }
+
+ /**
+ * Main execution point
+ * @param string $par
+ */
+ function execute( $par ) {
+ $this->checkLoginSecurityLevel();
+
+ $out = $this->getOutput();
+ $out->disallowUserJs();
+
+ parent::execute( $par );
+ }
+
+ protected function checkExecutePermissions( User $user ) {
+ if ( !AuthManager::singleton()->allowsPropertyChange( 'emailaddress' ) ) {
+ throw new ErrorPageError( 'changeemail', 'cannotchangeemail' );
+ }
+
+ $this->requireLogin( 'changeemail-no-info' );
+
+ // This could also let someone check the current email address, so
+ // require both permissions.
+ if ( !$this->getUser()->isAllowed( 'viewmyprivateinfo' ) ) {
+ throw new PermissionsError( 'viewmyprivateinfo' );
+ }
+
+ parent::checkExecutePermissions( $user );
+ }
+
+ protected function getFormFields() {
+ $user = $this->getUser();
+
+ $fields = [
+ 'Name' => [
+ 'type' => 'info',
+ 'label-message' => 'username',
+ 'default' => $user->getName(),
+ ],
+ 'OldEmail' => [
+ 'type' => 'info',
+ 'label-message' => 'changeemail-oldemail',
+ 'default' => $user->getEmail() ?: $this->msg( 'changeemail-none' )->text(),
+ ],
+ 'NewEmail' => [
+ 'type' => 'email',
+ 'label-message' => 'changeemail-newemail',
+ 'autofocus' => true,
+ 'help-message' => 'changeemail-newemail-help',
+ ],
+ ];
+
+ return $fields;
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setId( 'mw-changeemail-form' );
+ $form->setTableId( 'mw-changeemail-table' );
+ $form->setSubmitTextMsg( 'changeemail-submit' );
+ $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
+
+ $form->addHeaderText( $this->msg( 'changeemail-header' )->parseAsBlock() );
+ }
+
+ public function onSubmit( array $data ) {
+ $status = $this->attemptChange( $this->getUser(), $data['NewEmail'] );
+
+ $this->status = $status;
+
+ return $status;
+ }
+
+ public function onSuccess() {
+ $request = $this->getRequest();
+
+ $returnto = $request->getVal( 'returnto' );
+ $titleObj = $returnto !== null ? Title::newFromText( $returnto ) : null;
+ if ( !$titleObj instanceof Title ) {
+ $titleObj = Title::newMainPage();
+ }
+ $query = $request->getVal( 'returntoquery' );
+
+ if ( $this->status->value === true ) {
+ $this->getOutput()->redirect( $titleObj->getFullUrlForRedirect( $query ) );
+ } elseif ( $this->status->value === 'eauth' ) {
+ # Notify user that a confirmation email has been sent...
+ $this->getOutput()->wrapWikiMsg( "<div class='error' style='clear: both;'>\n$1\n</div>",
+ 'eauthentsent', $this->getUser()->getName() );
+ // just show the link to go back
+ $this->getOutput()->addReturnTo( $titleObj, wfCgiToArray( $query ) );
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param string $newaddr
+ * @return Status
+ */
+ private function attemptChange( User $user, $newaddr ) {
+ $authManager = AuthManager::singleton();
+
+ if ( $newaddr != '' && !Sanitizer::validateEmail( $newaddr ) ) {
+ return Status::newFatal( 'invalidemailaddress' );
+ }
+
+ if ( $newaddr === $user->getEmail() ) {
+ return Status::newFatal( 'changeemail-nochange' );
+ }
+
+ $oldaddr = $user->getEmail();
+ $status = $user->setEmailWithConfirmation( $newaddr );
+ if ( !$status->isGood() ) {
+ return $status;
+ }
+
+ LoggerFactory::getInstance( 'authentication' )->info(
+ 'Changing email address for {user} from {oldemail} to {newemail}', [
+ 'user' => $user->getName(),
+ 'oldemail' => $oldaddr,
+ 'newemail' => $newaddr,
+ ]
+ );
+
+ Hooks::run( 'PrefsEmailAudit', [ $user, $oldaddr, $newaddr ] );
+
+ $user->saveSettings();
+ MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'updateExternalDB', [ $user ] );
+
+ return $status;
+ }
+
+ public function requiresUnblock() {
+ return false;
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialChangePassword.php b/www/wiki/includes/specials/SpecialChangePassword.php
new file mode 100644
index 00000000..ce769bfd
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialChangePassword.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Implements Special:ChangePassword
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\PasswordAuthenticationRequest;
+
+/**
+ * Let users recover their password.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialChangePassword extends SpecialRedirectToSpecial {
+ public function __construct() {
+ parent::__construct( 'ChangePassword', 'ChangeCredentials',
+ PasswordAuthenticationRequest::class, [ 'returnto', 'returntoquery' ] );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialComparePages.php b/www/wiki/includes/specials/SpecialComparePages.php
new file mode 100644
index 00000000..35cc6b84
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialComparePages.php
@@ -0,0 +1,174 @@
+<?php
+/**
+ * Implements Special:ComparePages
+ *
+ * Copyright © 2010 Derk-Jan Hartman <hartman@videolan.org>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:ComparePages
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialComparePages extends SpecialPage {
+
+ // Stored objects
+ protected $opts, $skin;
+
+ // Some internal settings
+ protected $showNavigation = false;
+
+ public function __construct() {
+ parent::__construct( 'ComparePages' );
+ }
+
+ /**
+ * Show a form for filtering namespace and username
+ *
+ * @param string $par
+ * @return string
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->getOutput()->addModuleStyles( 'mediawiki.special.comparepages.styles' );
+
+ $form = HTMLForm::factory( 'ooui', [
+ 'Page1' => [
+ 'type' => 'title',
+ 'name' => 'page1',
+ 'label-message' => 'compare-page1',
+ 'size' => '40',
+ 'section' => 'page1',
+ 'validation-callback' => [ $this, 'checkExistingTitle' ],
+ ],
+ 'Revision1' => [
+ 'type' => 'int',
+ 'name' => 'rev1',
+ 'label-message' => 'compare-rev1',
+ 'size' => '8',
+ 'section' => 'page1',
+ 'validation-callback' => [ $this, 'checkExistingRevision' ],
+ ],
+ 'Page2' => [
+ 'type' => 'title',
+ 'name' => 'page2',
+ 'label-message' => 'compare-page2',
+ 'size' => '40',
+ 'section' => 'page2',
+ 'validation-callback' => [ $this, 'checkExistingTitle' ],
+ ],
+ 'Revision2' => [
+ 'type' => 'int',
+ 'name' => 'rev2',
+ 'label-message' => 'compare-rev2',
+ 'size' => '8',
+ 'section' => 'page2',
+ 'validation-callback' => [ $this, 'checkExistingRevision' ],
+ ],
+ 'Action' => [
+ 'type' => 'hidden',
+ 'name' => 'action',
+ ],
+ 'Diffonly' => [
+ 'type' => 'hidden',
+ 'name' => 'diffonly',
+ ],
+ 'Unhide' => [
+ 'type' => 'hidden',
+ 'name' => 'unhide',
+ ],
+ ], $this->getContext(), 'compare' );
+ $form->setSubmitTextMsg( 'compare-submit' );
+ $form->suppressReset();
+ $form->setMethod( 'get' );
+ $form->setSubmitCallback( [ __CLASS__, 'showDiff' ] );
+
+ $form->loadData();
+ $form->displayForm( '' );
+ $form->trySubmit();
+ }
+
+ public static function showDiff( $data, HTMLForm $form ) {
+ $rev1 = self::revOrTitle( $data['Revision1'], $data['Page1'] );
+ $rev2 = self::revOrTitle( $data['Revision2'], $data['Page2'] );
+
+ if ( $rev1 && $rev2 ) {
+ $revision = Revision::newFromId( $rev1 );
+
+ if ( $revision ) { // NOTE: $rev1 was already checked, should exist.
+ $contentHandler = $revision->getContentHandler();
+ $de = $contentHandler->createDifferenceEngine( $form->getContext(),
+ $rev1,
+ $rev2,
+ null, // rcid
+ ( $data['Action'] == 'purge' ),
+ ( $data['Unhide'] == '1' )
+ );
+ $de->showDiffPage( true );
+ }
+ }
+ }
+
+ public static function revOrTitle( $revision, $title ) {
+ if ( $revision ) {
+ return $revision;
+ } elseif ( $title ) {
+ $title = Title::newFromText( $title );
+ if ( $title instanceof Title ) {
+ return $title->getLatestRevID();
+ }
+ }
+
+ return null;
+ }
+
+ public function checkExistingTitle( $value, $alldata ) {
+ if ( $value === '' || $value === null ) {
+ return true;
+ }
+ $title = Title::newFromText( $value );
+ if ( !$title instanceof Title ) {
+ return $this->msg( 'compare-invalid-title' )->parseAsBlock();
+ }
+ if ( !$title->exists() ) {
+ return $this->msg( 'compare-title-not-exists' )->parseAsBlock();
+ }
+
+ return true;
+ }
+
+ public function checkExistingRevision( $value, $alldata ) {
+ if ( $value === '' || $value === null ) {
+ return true;
+ }
+ $revision = Revision::newFromId( $value );
+ if ( $revision === null ) {
+ return $this->msg( 'compare-revision-not-exists' )->parseAsBlock();
+ }
+
+ return true;
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialConfirmemail.php b/www/wiki/includes/specials/SpecialConfirmemail.php
new file mode 100644
index 00000000..f494b9d6
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialConfirmemail.php
@@ -0,0 +1,168 @@
+<?php
+/**
+ * Implements Special:Confirmemail
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page allows users to request email confirmation message, and handles
+ * processing of the confirmation code when the link in the email is followed
+ *
+ * @ingroup SpecialPage
+ * @author Brion Vibber
+ * @author Rob Church <robchur@gmail.com>
+ */
+class EmailConfirmation extends UnlistedSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Confirmemail', 'editmyprivateinfo' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * Main execution point
+ *
+ * @param null|string $code Confirmation code passed to the page
+ * @throws PermissionsError
+ * @throws ReadOnlyError
+ * @throws UserNotLoggedIn
+ */
+ function execute( $code ) {
+ // Ignore things like master queries/connections on GET requests.
+ // It's very convenient to just allow formless link usage.
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+
+ $this->setHeaders();
+ $this->checkReadOnly();
+ $this->checkPermissions();
+
+ // This could also let someone check the current email address, so
+ // require both permissions.
+ if ( !$this->getUser()->isAllowed( 'viewmyprivateinfo' ) ) {
+ throw new PermissionsError( 'viewmyprivateinfo' );
+ }
+
+ if ( $code === null || $code === '' ) {
+ $this->requireLogin( 'confirmemail_needlogin' );
+ if ( Sanitizer::validateEmail( $this->getUser()->getEmail() ) ) {
+ $this->showRequestForm();
+ } else {
+ $this->getOutput()->addWikiMsg( 'confirmemail_noemail' );
+ }
+ } else {
+ $old = $trxProfiler->setSilenced( true );
+ $this->attemptConfirm( $code );
+ $trxProfiler->setSilenced( $old );
+ }
+ }
+
+ /**
+ * Show a nice form for the user to request a confirmation mail
+ */
+ function showRequestForm() {
+ $user = $this->getUser();
+ $out = $this->getOutput();
+
+ if ( !$user->isEmailConfirmed() ) {
+ $descriptor = [];
+ if ( $user->isEmailConfirmationPending() ) {
+ $descriptor += [
+ 'pending' => [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => "<div class=\"error mw-confirmemail-pending\">\n" .
+ $this->msg( 'confirmemail_pending' )->escaped() .
+ "\n</div>",
+ ],
+ ];
+ }
+
+ $out->addWikiMsg( 'confirmemail_text' );
+ $form = HTMLForm::factory( 'ooui', $descriptor, $this->getContext() );
+ $form
+ ->setMethod( 'post' )
+ ->setAction( $this->getPageTitle()->getLocalURL() )
+ ->setSubmitTextMsg( 'confirmemail_send' )
+ ->setSubmitCallback( [ $this, 'submitSend' ] );
+
+ $retval = $form->show();
+
+ if ( $retval === true ) {
+ // should never happen, but if so, don't let the user without any message
+ $out->addWikiMsg( 'confirmemail_sent' );
+ } elseif ( $retval instanceof Status && $retval->isGood() ) {
+ $out->addWikiText( $retval->getValue() );
+ }
+ } else {
+ // date and time are separate parameters to facilitate localisation.
+ // $time is kept for backward compat reasons.
+ // 'emailauthenticated' is also used in SpecialPreferences.php
+ $lang = $this->getLanguage();
+ $emailAuthenticated = $user->getEmailAuthenticationTimestamp();
+ $time = $lang->userTimeAndDate( $emailAuthenticated, $user );
+ $d = $lang->userDate( $emailAuthenticated, $user );
+ $t = $lang->userTime( $emailAuthenticated, $user );
+ $out->addWikiMsg( 'emailauthenticated', $time, $d, $t );
+ }
+ }
+
+ /**
+ * Callback for HTMLForm send confirmation mail.
+ *
+ * @return Status Status object with the result
+ */
+ public function submitSend() {
+ $status = $this->getUser()->sendConfirmationMail();
+ if ( $status->isGood() ) {
+ return Status::newGood( $this->msg( 'confirmemail_sent' )->text() );
+ } else {
+ return Status::newFatal( new RawMessage(
+ $status->getWikiText( 'confirmemail_sendfailed' )
+ ) );
+ }
+ }
+
+ /**
+ * Attempt to confirm the user's email address and show success or failure
+ * as needed; if successful, take the user to log in
+ *
+ * @param string $code Confirmation code
+ */
+ private function attemptConfirm( $code ) {
+ $user = User::newFromConfirmationCode( $code, User::READ_LATEST );
+ if ( !is_object( $user ) ) {
+ $this->getOutput()->addWikiMsg( 'confirmemail_invalid' );
+
+ return;
+ }
+
+ $user->confirmEmail();
+ $user->saveSettings();
+ $message = $this->getUser()->isLoggedIn() ? 'confirmemail_loggedin' : 'confirmemail_success';
+ $this->getOutput()->addWikiMsg( $message );
+
+ if ( !$this->getUser()->isLoggedIn() ) {
+ $title = SpecialPage::getTitleFor( 'Userlogin' );
+ $this->getOutput()->returnToMain( true, $title );
+ }
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialContributions.php b/www/wiki/includes/specials/SpecialContributions.php
new file mode 100644
index 00000000..5a5f005b
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialContributions.php
@@ -0,0 +1,762 @@
+<?php
+/**
+ * Implements Special:Contributions
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Widget\DateInputWidget;
+
+/**
+ * Special:Contributions, show user contributions in a paged list
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialContributions extends IncludableSpecialPage {
+ protected $opts;
+
+ public function __construct() {
+ parent::__construct( 'Contributions' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $out = $this->getOutput();
+ $out->addModuleStyles( [
+ 'mediawiki.special',
+ 'mediawiki.special.changeslist',
+ 'mediawiki.widgets.DateInputWidget.styles',
+ ] );
+ $out->addModules( 'mediawiki.special.contributions' );
+ $this->addHelpLink( 'Help:User contributions' );
+ $out->enableOOUI();
+
+ $this->opts = [];
+ $request = $this->getRequest();
+
+ if ( $par !== null ) {
+ $target = $par;
+ } else {
+ $target = $request->getVal( 'target' );
+ }
+
+ if ( $request->getVal( 'contribs' ) == 'newbie' || $par === 'newbies' ) {
+ $target = 'newbies';
+ $this->opts['contribs'] = 'newbie';
+ } else {
+ $this->opts['contribs'] = 'user';
+ }
+
+ $this->opts['deletedOnly'] = $request->getBool( 'deletedOnly' );
+
+ if ( !strlen( $target ) ) {
+ if ( !$this->including() ) {
+ $out->addHTML( $this->getForm() );
+ }
+
+ return;
+ }
+
+ $user = $this->getUser();
+
+ $this->opts['limit'] = $request->getInt( 'limit', $user->getOption( 'rclimit' ) );
+ $this->opts['target'] = $target;
+ $this->opts['topOnly'] = $request->getBool( 'topOnly' );
+ $this->opts['newOnly'] = $request->getBool( 'newOnly' );
+ $this->opts['hideMinor'] = $request->getBool( 'hideMinor' );
+
+ $nt = Title::makeTitleSafe( NS_USER, $target );
+ if ( !$nt ) {
+ $out->addHTML( $this->getForm() );
+
+ return;
+ }
+ $userObj = User::newFromName( $nt->getText(), false );
+ if ( !$userObj ) {
+ $out->addHTML( $this->getForm() );
+
+ return;
+ }
+ $id = $userObj->getId();
+
+ if ( $this->opts['contribs'] != 'newbie' ) {
+ $target = $nt->getText();
+ $out->addSubtitle( $this->contributionsSub( $userObj ) );
+ $out->setHTMLTitle( $this->msg(
+ 'pagetitle',
+ $this->msg( 'contributions-title', $target )->plain()
+ )->inContentLanguage() );
+
+ # For IP ranges, we want the contributionsSub, but not the skin-dependent
+ # links under 'Tools', which may include irrelevant links like 'Logs'.
+ if ( !IP::isValidRange( $target ) ) {
+ $this->getSkin()->setRelevantUser( $userObj );
+ }
+ } else {
+ $out->addSubtitle( $this->msg( 'sp-contributions-newbies-sub' ) );
+ $out->setHTMLTitle( $this->msg(
+ 'pagetitle',
+ $this->msg( 'sp-contributions-newbies-title' )->plain()
+ )->inContentLanguage() );
+ }
+
+ $ns = $request->getVal( 'namespace', null );
+ if ( $ns !== null && $ns !== '' ) {
+ $this->opts['namespace'] = intval( $ns );
+ } else {
+ $this->opts['namespace'] = '';
+ }
+
+ $this->opts['associated'] = $request->getBool( 'associated' );
+ $this->opts['nsInvert'] = (bool)$request->getVal( 'nsInvert' );
+ $this->opts['tagfilter'] = (string)$request->getVal( 'tagfilter' );
+
+ // Allows reverts to have the bot flag in recent changes. It is just here to
+ // be passed in the form at the top of the page
+ if ( $user->isAllowed( 'markbotedits' ) && $request->getBool( 'bot' ) ) {
+ $this->opts['bot'] = '1';
+ }
+
+ $skip = $request->getText( 'offset' ) || $request->getText( 'dir' ) == 'prev';
+ # Offset overrides year/month selection
+ if ( !$skip ) {
+ $this->opts['year'] = $request->getVal( 'year' );
+ $this->opts['month'] = $request->getVal( 'month' );
+
+ $this->opts['start'] = $request->getVal( 'start' );
+ $this->opts['end'] = $request->getVal( 'end' );
+ }
+ $this->opts = ContribsPager::processDateFilter( $this->opts );
+
+ $feedType = $request->getVal( 'feed' );
+
+ $feedParams = [
+ 'action' => 'feedcontributions',
+ 'user' => $target,
+ ];
+ if ( $this->opts['topOnly'] ) {
+ $feedParams['toponly'] = true;
+ }
+ if ( $this->opts['newOnly'] ) {
+ $feedParams['newonly'] = true;
+ }
+ if ( $this->opts['hideMinor'] ) {
+ $feedParams['hideminor'] = true;
+ }
+ if ( $this->opts['deletedOnly'] ) {
+ $feedParams['deletedonly'] = true;
+ }
+ if ( $this->opts['tagfilter'] !== '' ) {
+ $feedParams['tagfilter'] = $this->opts['tagfilter'];
+ }
+ if ( $this->opts['namespace'] !== '' ) {
+ $feedParams['namespace'] = $this->opts['namespace'];
+ }
+ // Don't use year and month for the feed URL, but pass them on if
+ // we redirect to API (if $feedType is specified)
+ if ( $feedType && $this->opts['year'] !== null ) {
+ $feedParams['year'] = $this->opts['year'];
+ }
+ if ( $feedType && $this->opts['month'] !== null ) {
+ $feedParams['month'] = $this->opts['month'];
+ }
+
+ if ( $feedType ) {
+ // Maintain some level of backwards compatibility
+ // If people request feeds using the old parameters, redirect to API
+ $feedParams['feedformat'] = $feedType;
+ $url = wfAppendQuery( wfScript( 'api' ), $feedParams );
+
+ $out->redirect( $url, '301' );
+
+ return;
+ }
+
+ // Add RSS/atom links
+ $this->addFeedLinks( $feedParams );
+
+ if ( Hooks::run( 'SpecialContributionsBeforeMainOutput', [ $id, $userObj, $this ] ) ) {
+ if ( !$this->including() ) {
+ $out->addHTML( $this->getForm() );
+ }
+ $pager = new ContribsPager( $this->getContext(), [
+ 'target' => $target,
+ 'contribs' => $this->opts['contribs'],
+ 'namespace' => $this->opts['namespace'],
+ 'tagfilter' => $this->opts['tagfilter'],
+ 'start' => $this->opts['start'],
+ 'end' => $this->opts['end'],
+ 'deletedOnly' => $this->opts['deletedOnly'],
+ 'topOnly' => $this->opts['topOnly'],
+ 'newOnly' => $this->opts['newOnly'],
+ 'hideMinor' => $this->opts['hideMinor'],
+ 'nsInvert' => $this->opts['nsInvert'],
+ 'associated' => $this->opts['associated'],
+ ] );
+
+ if ( IP::isValidRange( $target ) && !$pager->isQueryableRange( $target ) ) {
+ // Valid range, but outside CIDR limit.
+ $limits = $this->getConfig()->get( 'RangeContributionsCIDRLimit' );
+ $limit = $limits[ IP::isIPv4( $target ) ? 'IPv4' : 'IPv6' ];
+ $out->addWikiMsg( 'sp-contributions-outofrange', $limit );
+ } elseif ( !$pager->getNumRows() ) {
+ $out->addWikiMsg( 'nocontribs', $target );
+ } else {
+ # Show a message about replica DB lag, if applicable
+ $lag = wfGetLB()->safeGetLag( $pager->getDatabase() );
+ if ( $lag > 0 ) {
+ $out->showLagWarning( $lag );
+ }
+
+ $output = $pager->getBody();
+ if ( !$this->including() ) {
+ $output = '<p>' . $pager->getNavigationBar() . '</p>' .
+ $output .
+ '<p>' . $pager->getNavigationBar() . '</p>';
+ }
+ $out->addHTML( $output );
+ }
+
+ $out->preventClickjacking( $pager->getPreventClickjacking() );
+
+ # Show the appropriate "footer" message - WHOIS tools, etc.
+ if ( $this->opts['contribs'] == 'newbie' ) {
+ $message = 'sp-contributions-footer-newbies';
+ } elseif ( IP::isValidRange( $target ) ) {
+ $message = 'sp-contributions-footer-anon-range';
+ } elseif ( IP::isIPAddress( $target ) ) {
+ $message = 'sp-contributions-footer-anon';
+ } elseif ( $userObj->isAnon() ) {
+ // No message for non-existing users
+ $message = '';
+ } else {
+ $message = 'sp-contributions-footer';
+ }
+
+ if ( $message ) {
+ if ( !$this->including() ) {
+ if ( !$this->msg( $message, $target )->isDisabled() ) {
+ $out->wrapWikiMsg(
+ "<div class='mw-contributions-footer'>\n$1\n</div>",
+ [ $message, $target ] );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates the subheading with links
+ * @param User $userObj User object for the target
+ * @return string Appropriately-escaped HTML to be output literally
+ * @todo FIXME: Almost the same as getSubTitle in SpecialDeletedContributions.php.
+ * Could be combined.
+ */
+ protected function contributionsSub( $userObj ) {
+ if ( $userObj->isAnon() ) {
+ // Show a warning message that the user being searched for doesn't exists.
+ // User::isIP returns true for IP address and usemod IPs like '123.123.123.xxx',
+ // but returns false for IP ranges. We don't want to suggest either of these are
+ // valid usernames which we would with the 'contributions-userdoesnotexist' message.
+ if ( !User::isIP( $userObj->getName() ) && !$userObj->isIPRange() ) {
+ $this->getOutput()->wrapWikiMsg(
+ "<div class=\"mw-userpage-userdoesnotexist error\">\n\$1\n</div>",
+ [
+ 'contributions-userdoesnotexist',
+ wfEscapeWikiText( $userObj->getName() ),
+ ]
+ );
+ if ( !$this->including() ) {
+ $this->getOutput()->setStatusCode( 404 );
+ }
+ }
+ $user = htmlspecialchars( $userObj->getName() );
+ } else {
+ $user = $this->getLinkRenderer()->makeLink( $userObj->getUserPage(), $userObj->getName() );
+ }
+ $nt = $userObj->getUserPage();
+ $talk = $userObj->getTalkPage();
+ $links = '';
+ if ( $talk ) {
+ $tools = self::getUserLinks( $this, $userObj );
+ $links = $this->getLanguage()->pipeList( $tools );
+
+ // Show a note if the user is blocked and display the last block log entry.
+ // Do not expose the autoblocks, since that may lead to a leak of accounts' IPs,
+ // and also this will display a totally irrelevant log entry as a current block.
+ if ( !$this->including() ) {
+ // For IP ranges you must give Block::newFromTarget the CIDR string and not a user object.
+ if ( $userObj->isIPRange() ) {
+ $block = Block::newFromTarget( $userObj->getName(), $userObj->getName() );
+ } else {
+ $block = Block::newFromTarget( $userObj, $userObj );
+ }
+
+ if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) {
+ if ( $block->getType() == Block::TYPE_RANGE ) {
+ $nt = MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget();
+ }
+
+ $out = $this->getOutput(); // showLogExtract() wants first parameter by reference
+ LogEventsList::showLogExtract(
+ $out,
+ 'block',
+ $nt,
+ '',
+ [
+ 'lim' => 1,
+ 'showIfEmpty' => false,
+ 'msgKey' => [
+ $userObj->isAnon() ?
+ 'sp-contributions-blocked-notice-anon' :
+ 'sp-contributions-blocked-notice',
+ $userObj->getName() # Support GENDER in 'sp-contributions-blocked-notice'
+ ],
+ 'offset' => '' # don't use WebRequest parameter offset
+ ]
+ );
+ }
+ }
+ }
+
+ return $this->msg( 'contribsub2' )->rawParams( $user, $links )->params( $userObj->getName() );
+ }
+
+ /**
+ * Links to different places.
+ *
+ * @note This function is also called in DeletedContributionsPage
+ * @param SpecialPage $sp SpecialPage instance, for context
+ * @param User $target Target user object
+ * @return array
+ */
+ public static function getUserLinks( SpecialPage $sp, User $target ) {
+ $id = $target->getId();
+ $username = $target->getName();
+ $userpage = $target->getUserPage();
+ $talkpage = $target->getTalkPage();
+
+ $linkRenderer = $sp->getLinkRenderer();
+
+ # No talk pages for IP ranges.
+ if ( !IP::isValidRange( $username ) ) {
+ $tools['user-talk'] = $linkRenderer->makeLink(
+ $talkpage,
+ $sp->msg( 'sp-contributions-talk' )->text()
+ );
+ }
+
+ if ( ( $id !== null ) || ( $id === null && IP::isIPAddress( $username ) ) ) {
+ if ( $sp->getUser()->isAllowed( 'block' ) ) { # Block / Change block / Unblock links
+ if ( $target->isBlocked() && $target->getBlock()->getType() != Block::TYPE_AUTO ) {
+ $tools['block'] = $linkRenderer->makeKnownLink( # Change block link
+ SpecialPage::getTitleFor( 'Block', $username ),
+ $sp->msg( 'change-blocklink' )->text()
+ );
+ $tools['unblock'] = $linkRenderer->makeKnownLink( # Unblock link
+ SpecialPage::getTitleFor( 'Unblock', $username ),
+ $sp->msg( 'unblocklink' )->text()
+ );
+ } else { # User is not blocked
+ $tools['block'] = $linkRenderer->makeKnownLink( # Block link
+ SpecialPage::getTitleFor( 'Block', $username ),
+ $sp->msg( 'blocklink' )->text()
+ );
+ }
+ }
+
+ # Block log link
+ $tools['log-block'] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Log', 'block' ),
+ $sp->msg( 'sp-contributions-blocklog' )->text(),
+ [],
+ [ 'page' => $userpage->getPrefixedText() ]
+ );
+
+ # Suppression log link (T61120)
+ if ( $sp->getUser()->isAllowed( 'suppressionlog' ) ) {
+ $tools['log-suppression'] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Log', 'suppress' ),
+ $sp->msg( 'sp-contributions-suppresslog', $username )->text(),
+ [],
+ [ 'offender' => $username ]
+ );
+ }
+ }
+
+ # Don't show some links for IP ranges
+ if ( !IP::isValidRange( $username ) ) {
+ # Uploads
+ $tools['uploads'] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Listfiles', $username ),
+ $sp->msg( 'sp-contributions-uploads' )->text()
+ );
+
+ # Other logs link
+ $tools['logs'] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Log', $username ),
+ $sp->msg( 'sp-contributions-logs' )->text()
+ );
+
+ # Add link to deleted user contributions for priviledged users
+ if ( $sp->getUser()->isAllowed( 'deletedhistory' ) ) {
+ $tools['deletedcontribs'] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'DeletedContributions', $username ),
+ $sp->msg( 'sp-contributions-deleted', $username )->text()
+ );
+ }
+ }
+
+ # Add a link to change user rights for privileged users
+ $userrightsPage = new UserrightsPage();
+ $userrightsPage->setContext( $sp->getContext() );
+ if ( $userrightsPage->userCanChangeRights( $target ) ) {
+ $tools['userrights'] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Userrights', $username ),
+ $sp->msg( 'sp-contributions-userrights', $username )->text()
+ );
+ }
+
+ Hooks::run( 'ContributionsToolLinks', [ $id, $userpage, &$tools, $sp ] );
+
+ return $tools;
+ }
+
+ /**
+ * Generates the namespace selector form with hidden attributes.
+ * @return string HTML fragment
+ */
+ protected function getForm() {
+ $this->opts['title'] = $this->getPageTitle()->getPrefixedText();
+ if ( !isset( $this->opts['target'] ) ) {
+ $this->opts['target'] = '';
+ } else {
+ $this->opts['target'] = str_replace( '_', ' ', $this->opts['target'] );
+ }
+
+ if ( !isset( $this->opts['namespace'] ) ) {
+ $this->opts['namespace'] = '';
+ }
+
+ if ( !isset( $this->opts['nsInvert'] ) ) {
+ $this->opts['nsInvert'] = '';
+ }
+
+ if ( !isset( $this->opts['associated'] ) ) {
+ $this->opts['associated'] = false;
+ }
+
+ if ( !isset( $this->opts['contribs'] ) ) {
+ $this->opts['contribs'] = 'user';
+ }
+
+ if ( !isset( $this->opts['start'] ) ) {
+ $this->opts['start'] = '';
+ }
+
+ if ( !isset( $this->opts['end'] ) ) {
+ $this->opts['end'] = '';
+ }
+
+ if ( $this->opts['contribs'] == 'newbie' ) {
+ $this->opts['target'] = '';
+ }
+
+ if ( !isset( $this->opts['tagfilter'] ) ) {
+ $this->opts['tagfilter'] = '';
+ }
+
+ if ( !isset( $this->opts['topOnly'] ) ) {
+ $this->opts['topOnly'] = false;
+ }
+
+ if ( !isset( $this->opts['newOnly'] ) ) {
+ $this->opts['newOnly'] = false;
+ }
+
+ if ( !isset( $this->opts['hideMinor'] ) ) {
+ $this->opts['hideMinor'] = false;
+ }
+
+ $form = Html::openElement(
+ 'form',
+ [
+ 'method' => 'get',
+ 'action' => wfScript(),
+ 'class' => 'mw-contributions-form'
+ ]
+ );
+
+ # Add hidden params for tracking except for parameters in $skipParameters
+ $skipParameters = [
+ 'namespace',
+ 'nsInvert',
+ 'deletedOnly',
+ 'target',
+ 'contribs',
+ 'year',
+ 'month',
+ 'start',
+ 'end',
+ 'topOnly',
+ 'newOnly',
+ 'hideMinor',
+ 'associated',
+ 'tagfilter'
+ ];
+
+ foreach ( $this->opts as $name => $value ) {
+ if ( in_array( $name, $skipParameters ) ) {
+ continue;
+ }
+ $form .= "\t" . Html::hidden( $name, $value ) . "\n";
+ }
+
+ $tagFilter = ChangeTags::buildTagFilterSelector(
+ $this->opts['tagfilter'], false, $this->getContext() );
+
+ if ( $tagFilter ) {
+ $filterSelection = Html::rawElement(
+ 'div',
+ [],
+ implode( '&#160;', $tagFilter )
+ );
+ } else {
+ $filterSelection = Html::rawElement( 'div', [], '' );
+ }
+
+ $this->getOutput()->addModules( 'mediawiki.userSuggest' );
+
+ $labelNewbies = Xml::radioLabel(
+ $this->msg( 'sp-contributions-newbies' )->text(),
+ 'contribs',
+ 'newbie',
+ 'newbie',
+ $this->opts['contribs'] == 'newbie',
+ [ 'class' => 'mw-input' ]
+ );
+ $labelUsername = Xml::radioLabel(
+ $this->msg( 'sp-contributions-username' )->text(),
+ 'contribs',
+ 'user',
+ 'user',
+ $this->opts['contribs'] == 'user',
+ [ 'class' => 'mw-input' ]
+ );
+ $input = Html::input(
+ 'target',
+ $this->opts['target'],
+ 'text',
+ [
+ 'size' => '40',
+ 'class' => [
+ 'mw-input',
+ 'mw-ui-input-inline',
+ 'mw-autocomplete-user', // used by mediawiki.userSuggest
+ ],
+ ] + (
+ // Only autofocus if target hasn't been specified or in non-newbies mode
+ ( $this->opts['contribs'] === 'newbie' || $this->opts['target'] )
+ ? [] : [ 'autofocus' => true ]
+ )
+ );
+
+ $targetSelection = Html::rawElement(
+ 'div',
+ [],
+ $labelNewbies . '<br>' . $labelUsername . ' ' . $input . ' '
+ );
+
+ $namespaceSelection = Xml::tags(
+ 'div',
+ [],
+ Xml::label(
+ $this->msg( 'namespace' )->text(),
+ 'namespace',
+ ''
+ ) . '&#160;' .
+ Html::namespaceSelector(
+ [ 'selected' => $this->opts['namespace'], 'all' => '' ],
+ [
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'class' => 'namespaceselector',
+ ]
+ ) . '&#160;' .
+ Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-input-with-label' ],
+ Xml::checkLabel(
+ $this->msg( 'invert' )->text(),
+ 'nsInvert',
+ 'nsInvert',
+ $this->opts['nsInvert'],
+ [
+ 'title' => $this->msg( 'tooltip-invert' )->text(),
+ 'class' => 'mw-input'
+ ]
+ ) . '&#160;'
+ ) .
+ Html::rawElement( 'span', [ 'class' => 'mw-input-with-label' ],
+ Xml::checkLabel(
+ $this->msg( 'namespace_association' )->text(),
+ 'associated',
+ 'associated',
+ $this->opts['associated'],
+ [
+ 'title' => $this->msg( 'tooltip-namespace_association' )->text(),
+ 'class' => 'mw-input'
+ ]
+ ) . '&#160;'
+ )
+ );
+
+ $filters = [];
+
+ if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
+ $filters[] = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-input-with-label' ],
+ Xml::checkLabel(
+ $this->msg( 'history-show-deleted' )->text(),
+ 'deletedOnly',
+ 'mw-show-deleted-only',
+ $this->opts['deletedOnly'],
+ [ 'class' => 'mw-input' ]
+ )
+ );
+ }
+
+ $filters[] = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-input-with-label' ],
+ Xml::checkLabel(
+ $this->msg( 'sp-contributions-toponly' )->text(),
+ 'topOnly',
+ 'mw-show-top-only',
+ $this->opts['topOnly'],
+ [ 'class' => 'mw-input' ]
+ )
+ );
+ $filters[] = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-input-with-label' ],
+ Xml::checkLabel(
+ $this->msg( 'sp-contributions-newonly' )->text(),
+ 'newOnly',
+ 'mw-show-new-only',
+ $this->opts['newOnly'],
+ [ 'class' => 'mw-input' ]
+ )
+ );
+ $filters[] = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-input-with-label' ],
+ Xml::checkLabel(
+ $this->msg( 'sp-contributions-hideminor' )->text(),
+ 'hideMinor',
+ 'mw-hide-minor-edits',
+ $this->opts['hideMinor'],
+ [ 'class' => 'mw-input' ]
+ )
+ );
+
+ Hooks::run(
+ 'SpecialContributions::getForm::filters',
+ [ $this, &$filters ]
+ );
+
+ $extraOptions = Html::rawElement(
+ 'div',
+ [],
+ implode( '', $filters )
+ );
+
+ $dateRangeSelection = Html::rawElement(
+ 'div',
+ [],
+ Xml::label( wfMessage( 'date-range-from' )->text(), 'mw-date-start' ) . ' ' .
+ new DateInputWidget( [
+ 'infusable' => true,
+ 'id' => 'mw-date-start',
+ 'name' => 'start',
+ 'value' => $this->opts['start'],
+ 'longDisplayFormat' => true,
+ ] ) . '<br>' .
+ Xml::label( wfMessage( 'date-range-to' )->text(), 'mw-date-end' ) . ' ' .
+ new DateInputWidget( [
+ 'infusable' => true,
+ 'id' => 'mw-date-end',
+ 'name' => 'end',
+ 'value' => $this->opts['end'],
+ 'longDisplayFormat' => true,
+ ] )
+ );
+
+ $submit = Xml::tags( 'div', [],
+ Html::submitButton(
+ $this->msg( 'sp-contributions-submit' )->text(),
+ [ 'class' => 'mw-submit' ], [ 'mw-ui-progressive' ]
+ )
+ );
+
+ $form .= Xml::fieldset(
+ $this->msg( 'sp-contributions-search' )->text(),
+ $targetSelection .
+ $namespaceSelection .
+ $filterSelection .
+ $extraOptions .
+ $dateRangeSelection .
+ $submit,
+ [ 'class' => 'mw-contributions-table' ]
+ );
+
+ $explain = $this->msg( 'sp-contributions-explain' );
+ if ( !$explain->isBlank() ) {
+ $form .= "<p id='mw-sp-contributions-explain'>{$explain->parse()}</p>";
+ }
+
+ $form .= Xml::closeElement( 'form' );
+
+ return $form;
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialCreateAccount.php b/www/wiki/includes/specials/SpecialCreateAccount.php
new file mode 100644
index 00000000..73beafce
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialCreateAccount.php
@@ -0,0 +1,173 @@
+<?php
+/**
+ * Implements Special:CreateAccount
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Implements Special:CreateAccount
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialCreateAccount extends LoginSignupSpecialPage {
+ protected static $allowedActions = [
+ AuthManager::ACTION_CREATE,
+ AuthManager::ACTION_CREATE_CONTINUE
+ ];
+
+ protected static $messages = [
+ 'authform-newtoken' => 'nocookiesfornew',
+ 'authform-notoken' => 'sessionfailure',
+ 'authform-wrongtoken' => 'sessionfailure',
+ ];
+
+ public function __construct() {
+ parent::__construct( 'CreateAccount' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function isRestricted() {
+ return !User::groupHasPermission( '*', 'createaccount' );
+ }
+
+ public function userCanExecute( User $user ) {
+ return $user->isAllowed( 'createaccount' );
+ }
+
+ public function checkPermissions() {
+ parent::checkPermissions();
+
+ $user = $this->getUser();
+ $status = AuthManager::singleton()->checkAccountCreatePermissions( $user );
+ if ( !$status->isGood() ) {
+ throw new ErrorPageError( 'createacct-error', $status->getMessage() );
+ }
+ }
+
+ protected function getLoginSecurityLevel() {
+ return false;
+ }
+
+ protected function getDefaultAction( $subPage ) {
+ return AuthManager::ACTION_CREATE;
+ }
+
+ public function getDescription() {
+ return $this->msg( 'createaccount' )->text();
+ }
+
+ protected function isSignup() {
+ return true;
+ }
+
+ /**
+ * Run any hooks registered for logins, then display a message welcoming
+ * the user.
+ * @param bool $direct True if the action was successful just now; false if that happened
+ * pre-redirection (so this handler was called already)
+ * @param StatusValue|null $extraMessages
+ */
+ protected function successfulAction( $direct = false, $extraMessages = null ) {
+ $session = $this->getRequest()->getSession();
+ $user = $this->targetUser ?: $this->getUser();
+
+ if ( $direct ) {
+ # Only save preferences if the user is not creating an account for someone else.
+ if ( !$this->proxyAccountCreation ) {
+ Hooks::run( 'AddNewAccount', [ $user, false ] );
+
+ // If the user does not have a session cookie at this point, they probably need to
+ // do something to their browser.
+ if ( !$this->hasSessionCookie() ) {
+ $this->mainLoginForm( [ /*?*/ ], $session->getProvider()->whyNoSession() );
+ // TODO something more specific? This used to use nocookiesnew
+ // FIXME should redirect to login page instead?
+ return;
+ }
+ } else {
+ $byEmail = false; // FIXME no way to set this
+
+ Hooks::run( 'AddNewAccount', [ $user, $byEmail ] );
+
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( $byEmail ? 'accmailtitle' : 'accountcreated' ) );
+ if ( $byEmail ) {
+ $out->addWikiMsg( 'accmailtext', $user->getName(), $user->getEmail() );
+ } else {
+ $out->addWikiMsg( 'accountcreatedtext', $user->getName() );
+ }
+
+ $rt = Title::newFromText( $this->mReturnTo );
+ $out->addReturnTo(
+ ( $rt && !$rt->isExternal() ) ? $rt : $this->getPageTitle(),
+ wfCgiToArray( $this->mReturnToQuery )
+ );
+ return;
+ }
+ }
+
+ $this->clearToken();
+
+ # Run any hooks; display injected HTML
+ $injected_html = '';
+ $welcome_creation_msg = 'welcomecreation-msg';
+ Hooks::run( 'UserLoginComplete', [ &$user, &$injected_html, $direct ] );
+
+ /**
+ * Let any extensions change what message is shown.
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeWelcomeCreation
+ * @since 1.18
+ */
+ Hooks::run( 'BeforeWelcomeCreation', [ &$welcome_creation_msg, &$injected_html ] );
+
+ $this->showSuccessPage( 'signup', $this->msg( 'welcomeuser', $this->getUser()->getName() ),
+ $welcome_creation_msg, $injected_html, $extraMessages );
+ }
+
+ protected function getToken() {
+ return $this->getRequest()->getSession()->getToken( '', 'createaccount' );
+ }
+
+ protected function clearToken() {
+ return $this->getRequest()->getSession()->resetToken( 'createaccount' );
+ }
+
+ protected function getTokenName() {
+ return 'wpCreateaccountToken';
+ }
+
+ protected function getGroupName() {
+ return 'login';
+ }
+
+ protected function logAuthResult( $success, $status = null ) {
+ LoggerFactory::getInstance( 'authevents' )->info( 'Account creation attempt', [
+ 'event' => 'accountcreation',
+ 'successful' => $success,
+ 'status' => $status,
+ ] );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialDeadendpages.php b/www/wiki/includes/specials/SpecialDeadendpages.php
new file mode 100644
index 00000000..f13f231d
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialDeadendpages.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Implements Special:Deadenpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that list pages that contain no link to other pages
+ *
+ * @ingroup SpecialPage
+ */
+class DeadendPagesPage extends PageQueryPage {
+
+ function __construct( $name = 'Deadendpages' ) {
+ parent::__construct( $name );
+ }
+
+ function getPageHeader() {
+ return $this->msg( 'deadendpagestext' )->parseAsBlock();
+ }
+
+ /**
+ * LEFT JOIN is expensive
+ *
+ * @return bool
+ */
+ function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ /**
+ * @return bool
+ */
+ function sortDescending() {
+ return false;
+ }
+
+ function getQueryInfo() {
+ return [
+ 'tables' => [ 'page', 'pagelinks' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_title'
+ ],
+ 'conds' => [
+ 'pl_from IS NULL',
+ 'page_namespace' => MWNamespace::getContentNamespaces(),
+ 'page_is_redirect' => 0
+ ],
+ 'join_conds' => [
+ 'pagelinks' => [
+ 'LEFT JOIN',
+ [ 'page_id=pl_from' ]
+ ]
+ ]
+ ];
+ }
+
+ function getOrderFields() {
+ // For some crazy reason ordering by a constant
+ // causes a filesort
+ if ( count( MWNamespace::getContentNamespaces() ) > 1 ) {
+ return [ 'page_namespace', 'page_title' ];
+ } else {
+ return [ 'page_title' ];
+ }
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialDeletedContributions.php b/www/wiki/includes/specials/SpecialDeletedContributions.php
new file mode 100644
index 00000000..5c8b3a62
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialDeletedContributions.php
@@ -0,0 +1,240 @@
+<?php
+/**
+ * Implements Special:DeletedContributions
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:DeletedContributions to display archived revisions
+ * @ingroup SpecialPage
+ */
+class DeletedContributionsPage extends SpecialPage {
+ /** @var FormOptions */
+ protected $mOpts;
+
+ function __construct() {
+ parent::__construct( 'DeletedContributions', 'deletedhistory' );
+ }
+
+ /**
+ * Special page "deleted user contributions".
+ * Shows a list of the deleted contributions of a user.
+ *
+ * @param string $par (optional) user name of the user for which to show the contributions
+ */
+ function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->checkPermissions();
+
+ $user = $this->getUser();
+
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'deletedcontributions-title' ) );
+
+ $opts = new FormOptions();
+
+ $opts->add( 'target', '' );
+ $opts->add( 'namespace', '' );
+ $opts->add( 'limit', 20 );
+
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+ $opts->validateIntBounds( 'limit', 0, $this->getConfig()->get( 'QueryPageDefaultLimit' ) );
+
+ if ( $par !== null ) {
+ $opts->setValue( 'target', $par );
+ }
+
+ $ns = $opts->getValue( 'namespace' );
+ if ( $ns !== null && $ns !== '' ) {
+ $opts->setValue( 'namespace', intval( $ns ) );
+ }
+
+ $this->mOpts = $opts;
+
+ $target = $opts->getValue( 'target' );
+ if ( !strlen( $target ) ) {
+ $this->getForm();
+
+ return;
+ }
+
+ $userObj = User::newFromName( $target, false );
+ if ( !$userObj ) {
+ $this->getForm();
+
+ return;
+ }
+ $this->getSkin()->setRelevantUser( $userObj );
+
+ $target = $userObj->getName();
+ $out->addSubtitle( $this->getSubTitle( $userObj ) );
+
+ $this->getForm();
+
+ $pager = new DeletedContribsPager( $this->getContext(), $target, $opts->getValue( 'namespace' ) );
+ if ( !$pager->getNumRows() ) {
+ $out->addWikiMsg( 'nocontribs' );
+
+ return;
+ }
+
+ # Show a message about replica DB lag, if applicable
+ $lag = wfGetLB()->safeGetLag( $pager->getDatabase() );
+ if ( $lag > 0 ) {
+ $out->showLagWarning( $lag );
+ }
+
+ $out->addHTML(
+ '<p>' . $pager->getNavigationBar() . '</p>' .
+ $pager->getBody() .
+ '<p>' . $pager->getNavigationBar() . '</p>' );
+
+ # If there were contributions, and it was a valid user or IP, show
+ # the appropriate "footer" message - WHOIS tools, etc.
+ if ( $target != 'newbies' ) {
+ $message = IP::isIPAddress( $target ) ?
+ 'sp-contributions-footer-anon' :
+ 'sp-contributions-footer';
+
+ if ( !$this->msg( $message )->isDisabled() ) {
+ $out->wrapWikiMsg(
+ "<div class='mw-contributions-footer'>\n$1\n</div>",
+ [ $message, $target ]
+ );
+ }
+ }
+ }
+
+ /**
+ * Generates the subheading with links
+ * @param User $userObj User object for the target
+ * @return string Appropriately-escaped HTML to be output literally
+ */
+ function getSubTitle( $userObj ) {
+ $linkRenderer = $this->getLinkRenderer();
+ if ( $userObj->isAnon() ) {
+ $user = htmlspecialchars( $userObj->getName() );
+ } else {
+ $user = $linkRenderer->makeLink( $userObj->getUserPage(), $userObj->getName() );
+ }
+ $links = '';
+ $nt = $userObj->getUserPage();
+ $talk = $nt->getTalkPage();
+ if ( $talk ) {
+ $tools = SpecialContributions::getUserLinks( $this, $userObj );
+
+ # Link to contributions
+ $insert['contribs'] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Contributions', $nt->getDBkey() ),
+ $this->msg( 'sp-deletedcontributions-contribs' )->text()
+ );
+
+ // Swap out the deletedcontribs link for our contribs one
+ $tools = wfArrayInsertAfter( $tools, $insert, 'deletedcontribs' );
+ unset( $tools['deletedcontribs'] );
+
+ $links = $this->getLanguage()->pipeList( $tools );
+
+ // Show a note if the user is blocked and display the last block log entry.
+ $block = Block::newFromTarget( $userObj, $userObj );
+ if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) {
+ if ( $block->getType() == Block::TYPE_RANGE ) {
+ $nt = MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget();
+ }
+
+ // LogEventsList::showLogExtract() wants the first parameter by ref
+ $out = $this->getOutput();
+ LogEventsList::showLogExtract(
+ $out,
+ 'block',
+ $nt,
+ '',
+ [
+ 'lim' => 1,
+ 'showIfEmpty' => false,
+ 'msgKey' => [
+ 'sp-contributions-blocked-notice',
+ $userObj->getName() # Support GENDER in 'sp-contributions-blocked-notice'
+ ],
+ 'offset' => '' # don't use $this->getRequest() parameter offset
+ ]
+ );
+ }
+ }
+
+ return $this->msg( 'contribsub2' )->rawParams( $user, $links )->params( $userObj->getName() );
+ }
+
+ /**
+ * Generates the namespace selector form with hidden attributes.
+ */
+ function getForm() {
+ $opts = $this->mOpts;
+
+ $formDescriptor = [
+ 'target' => [
+ 'type' => 'user',
+ 'name' => 'target',
+ 'label-message' => 'sp-contributions-username',
+ 'default' => $opts->getValue( 'target' ),
+ 'ipallowed' => true,
+ ],
+
+ 'namespace' => [
+ 'type' => 'namespaceselect',
+ 'name' => 'namespace',
+ 'label-message' => 'namespace',
+ 'all' => '',
+ ],
+ ];
+
+ HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
+ ->setWrapperLegendMsg( 'sp-contributions-search' )
+ ->setSubmitTextMsg( 'sp-contributions-submit' )
+ // prevent setting subpage and 'target' parameter at the same time
+ ->setAction( $this->getPageTitle()->getLocalURL() )
+ ->setMethod( 'get' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialDiff.php b/www/wiki/includes/specials/SpecialDiff.php
new file mode 100644
index 00000000..28cd0d19
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialDiff.php
@@ -0,0 +1,119 @@
+<?php
+/**
+ * Redirect from Special:Diff/### to index.php?diff=### and
+ * from Special:Diff/###/### to index.php?oldid=###&diff=###.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Redirect from Special:Diff/### to index.php?diff=### and
+ * from Special:Diff/###/### to index.php?oldid=###&diff=###.
+ *
+ * All of the following are valid usages:
+ * - [[Special:Diff/12345]] (diff of a revision with the previous one)
+ * - [[Special:Diff/12345/prev]] (diff of a revision with the previous one as well)
+ * - [[Special:Diff/12345/next]] (diff of a revision with the next one)
+ * - [[Special:Diff/12345/cur]] (diff of a revision with the latest one of that page)
+ * - [[Special:Diff/12345/98765]] (diff between arbitrary two revisions)
+ *
+ * @ingroup SpecialPage
+ * @since 1.23
+ */
+class SpecialDiff extends RedirectSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Diff' );
+ $this->mAllowedRedirectParams = [];
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title|bool
+ */
+ public function getRedirect( $subpage ) {
+ $parts = explode( '/', $subpage );
+
+ // Try to parse the values given, generating somewhat pretty URLs if possible
+ if ( count( $parts ) === 1 && $parts[0] !== '' ) {
+ $this->mAddedRedirectParams['diff'] = $parts[0];
+ } elseif ( count( $parts ) === 2 ) {
+ $this->mAddedRedirectParams['oldid'] = $parts[0];
+ $this->mAddedRedirectParams['diff'] = $parts[1];
+ } else {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected function showNoRedirectPage() {
+ $this->addHelpLink( 'Help:Diff' );
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->showForm();
+ }
+
+ private function showForm() {
+ $form = HTMLForm::factory( 'ooui', [
+ 'oldid' => [
+ 'name' => 'oldid',
+ 'type' => 'int',
+ 'label-message' => 'diff-form-oldid',
+ ],
+ 'diff' => [
+ 'name' => 'diff',
+ 'class' => 'HTMLTextField',
+ 'label-message' => 'diff-form-revid',
+ ],
+ ], $this->getContext(), 'diff-form' );
+ $form->setSubmitTextMsg( 'diff-form-submit' );
+ $form->setSubmitCallback( [ $this, 'onFormSubmit' ] );
+ $form->show();
+ }
+
+ public function onFormSubmit( $formData ) {
+ $params = [];
+ if ( $formData['oldid'] ) {
+ $params[] = $formData['oldid'];
+ }
+ if ( $formData['diff'] ) {
+ $params[] = $formData['diff'];
+ }
+ $title = $this->getPageTitle( $params ? implode( '/', $params ) : null );
+ $url = $title->getFullUrlForRedirect();
+ $this->getOutput()->redirect( $url );
+ }
+
+ public function getDescription() {
+ // 'diff' message is in lowercase, using own message
+ return $this->msg( 'diff-form' )->text();
+ }
+
+ public function getName() {
+ return 'diff-form';
+ }
+
+ public function isListed() {
+ return true;
+ }
+
+ protected function getGroupName() {
+ return 'redirects';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialDoubleRedirects.php b/www/wiki/includes/specials/SpecialDoubleRedirects.php
new file mode 100644
index 00000000..d73ac198
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialDoubleRedirects.php
@@ -0,0 +1,233 @@
+<?php
+/**
+ * Implements Special:DoubleRedirects
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A special page listing redirects to redirecting page.
+ * The software will automatically not follow double redirects, to prevent loops.
+ *
+ * @ingroup SpecialPage
+ */
+class DoubleRedirectsPage extends QueryPage {
+ function __construct( $name = 'DoubleRedirects' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ function getPageHeader() {
+ return $this->msg( 'doubleredirectstext' )->parseAsBlock();
+ }
+
+ function reallyGetQueryInfo( $namespace = null, $title = null ) {
+ $limitToTitle = !( $namespace === null && $title === null );
+ $dbr = wfGetDB( DB_REPLICA );
+ $retval = [
+ 'tables' => [
+ 'ra' => 'redirect',
+ 'rb' => 'redirect',
+ 'pa' => 'page',
+ 'pb' => 'page'
+ ],
+ 'fields' => [
+ 'namespace' => 'pa.page_namespace',
+ 'title' => 'pa.page_title',
+ 'value' => 'pa.page_title',
+
+ 'b_namespace' => 'pb.page_namespace',
+ 'b_title' => 'pb.page_title',
+
+ // Select fields from redirect instead of page. Because there may
+ // not actually be a page table row for this target (e.g. for interwiki redirects)
+ 'c_namespace' => 'rb.rd_namespace',
+ 'c_title' => 'rb.rd_title',
+ 'c_fragment' => 'rb.rd_fragment',
+ 'c_interwiki' => 'rb.rd_interwiki',
+ ],
+ 'conds' => [
+ 'ra.rd_from = pa.page_id',
+
+ // Filter out redirects where the target goes interwiki (T42353).
+ // This isn't an optimization, it is required for correct results,
+ // otherwise a non-double redirect like Bar -> w:Foo will show up
+ // like "Bar -> Foo -> w:Foo".
+
+ // Need to check both NULL and "" for some reason,
+ // apparently either can be stored for non-iw entries.
+ 'ra.rd_interwiki IS NULL OR ra.rd_interwiki = ' . $dbr->addQuotes( '' ),
+
+ 'pb.page_namespace = ra.rd_namespace',
+ 'pb.page_title = ra.rd_title',
+
+ 'rb.rd_from = pb.page_id',
+ ]
+ ];
+
+ if ( $limitToTitle ) {
+ $retval['conds']['pa.page_namespace'] = $namespace;
+ $retval['conds']['pa.page_title'] = $title;
+ }
+
+ return $retval;
+ }
+
+ public function getQueryInfo() {
+ return $this->reallyGetQueryInfo();
+ }
+
+ function getOrderFields() {
+ return [ 'ra.rd_namespace', 'ra.rd_title' ];
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ // If no Title B or C is in the query, it means this came from
+ // querycache (which only saves the 3 columns for title A).
+ // That does save the bulk of the query cost, but now we need to
+ // get a little more detail about each individual entry quickly
+ // using the filter of reallyGetQueryInfo.
+ $deep = false;
+ if ( $result ) {
+ if ( isset( $result->b_namespace ) ) {
+ $deep = $result;
+ } else {
+ $dbr = wfGetDB( DB_REPLICA );
+ $qi = $this->reallyGetQueryInfo(
+ $result->namespace,
+ $result->title
+ );
+ $res = $dbr->select(
+ $qi['tables'],
+ $qi['fields'],
+ $qi['conds'],
+ __METHOD__
+ );
+
+ if ( $res ) {
+ $deep = $dbr->fetchObject( $res ) ?: false;
+ }
+ }
+ }
+
+ $titleA = Title::makeTitle( $result->namespace, $result->title );
+
+ $linkRenderer = $this->getLinkRenderer();
+ if ( !$deep ) {
+ return '<del>' . $linkRenderer->makeLink( $titleA, null, [], [ 'redirect' => 'no' ] ) . '</del>';
+ }
+
+ // if the page is editable, add an edit link
+ if (
+ // check user permissions
+ $this->getUser()->isAllowed( 'edit' ) &&
+ // check, if the content model is editable through action=edit
+ ContentHandler::getForTitle( $titleA )->supportsDirectEditing()
+ ) {
+ $edit = $linkRenderer->makeKnownLink(
+ $titleA,
+ $this->msg( 'parentheses', $this->msg( 'editlink' )->text() )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ } else {
+ $edit = '';
+ }
+
+ $linkA = $linkRenderer->makeKnownLink(
+ $titleA,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+
+ $titleB = Title::makeTitle( $deep->b_namespace, $deep->b_title );
+ $linkB = $linkRenderer->makeKnownLink(
+ $titleB,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+
+ $titleC = Title::makeTitle(
+ $deep->c_namespace,
+ $deep->c_title,
+ $deep->c_fragment,
+ $deep->c_interwiki
+ );
+ $linkC = $linkRenderer->makeKnownLink( $titleC, $titleC->getFullText() );
+
+ $lang = $this->getLanguage();
+ $arr = $lang->getArrow() . $lang->getDirMark();
+
+ return ( "{$linkA} {$edit} {$arr} {$linkB} {$arr} {$linkC}" );
+ }
+
+ /**
+ * Cache page content model and gender distinction for performance
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ if ( !$res->numRows() ) {
+ return;
+ }
+
+ $batch = new LinkBatch;
+ foreach ( $res as $row ) {
+ $batch->add( $row->namespace, $row->title );
+ if ( isset( $row->b_namespace ) ) {
+ // lazy loaded when using cached results
+ $batch->add( $row->b_namespace, $row->b_title );
+ }
+ if ( isset( $row->c_interwiki ) && !$row->c_interwiki ) {
+ // lazy loaded when using cached result, not added when interwiki link
+ $batch->add( $row->c_namespace, $row->c_title );
+ }
+ }
+ $batch->execute();
+
+ // Back to start for display
+ $res->seek( 0 );
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialEditTags.php b/www/wiki/includes/specials/SpecialEditTags.php
new file mode 100644
index 00000000..476c452a
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialEditTags.php
@@ -0,0 +1,468 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page for adding and removing change tags to individual revisions.
+ * A lot of this is copied out of SpecialRevisiondelete.
+ *
+ * @ingroup SpecialPage
+ * @since 1.25
+ */
+class SpecialEditTags extends UnlistedSpecialPage {
+ /** @var bool Was the DB modified in this request */
+ protected $wasSaved = false;
+
+ /** @var bool True if the submit button was clicked, and the form was posted */
+ private $submitClicked;
+
+ /** @var array Target ID list */
+ private $ids;
+
+ /** @var Title Title object for target parameter */
+ private $targetObj;
+
+ /** @var string Deletion type, may be revision or logentry */
+ private $typeName;
+
+ /** @var ChangeTagsList Storing the list of items to be tagged */
+ private $revList;
+
+ /** @var bool Whether user is allowed to perform the action */
+ private $isAllowed;
+
+ /** @var string */
+ private $reason;
+
+ public function __construct() {
+ parent::__construct( 'EditTags', 'changetags' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function execute( $par ) {
+ $this->checkPermissions();
+ $this->checkReadOnly();
+
+ $output = $this->getOutput();
+ $user = $this->getUser();
+ $request = $this->getRequest();
+
+ // Check blocks
+ if ( $user->isBlocked() ) {
+ throw new UserBlockedError( $user->getBlock() );
+ }
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $this->getOutput()->addModules( [ 'mediawiki.special.edittags',
+ 'mediawiki.special.edittags.styles' ] );
+
+ $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' );
+
+ // Handle our many different possible input types
+ $ids = $request->getVal( 'ids' );
+ if ( !is_null( $ids ) ) {
+ // Allow CSV from the form hidden field, or a single ID for show/hide links
+ $this->ids = explode( ',', $ids );
+ } else {
+ // Array input
+ $this->ids = array_keys( $request->getArray( 'ids', [] ) );
+ }
+ $this->ids = array_unique( array_filter( $this->ids ) );
+
+ // No targets?
+ if ( count( $this->ids ) == 0 ) {
+ throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
+ }
+
+ $this->typeName = $request->getVal( 'type' );
+ $this->targetObj = Title::newFromText( $request->getText( 'target' ) );
+
+ // sanity check of parameter
+ switch ( $this->typeName ) {
+ case 'logentry':
+ case 'logging':
+ $this->typeName = 'logentry';
+ break;
+ default:
+ $this->typeName = 'revision';
+ break;
+ }
+
+ // Allow the list type to adjust the passed target
+ // Yuck! Copied straight out of SpecialRevisiondelete, but it does exactly
+ // what we want
+ $this->targetObj = RevisionDeleter::suggestTarget(
+ $this->typeName === 'revision' ? 'revision' : 'logging',
+ $this->targetObj,
+ $this->ids
+ );
+
+ $this->isAllowed = $user->isAllowed( 'changetags' );
+
+ $this->reason = $request->getVal( 'wpReason' );
+ // We need a target page!
+ if ( is_null( $this->targetObj ) ) {
+ $output->addWikiMsg( 'undelete-header' );
+ return;
+ }
+ // Give a link to the logs/hist for this page
+ $this->showConvenienceLinks();
+
+ // Either submit or create our form
+ if ( $this->isAllowed && $this->submitClicked ) {
+ $this->submit();
+ } else {
+ $this->showForm();
+ }
+
+ // Show relevant lines from the tag log
+ $tagLogPage = new LogPage( 'tag' );
+ $output->addHTML( "<h2>" . $tagLogPage->getName()->escaped() . "</h2>\n" );
+ LogEventsList::showLogExtract(
+ $output,
+ 'tag',
+ $this->targetObj,
+ '', /* user */
+ [ 'lim' => 25, 'conds' => [], 'useMaster' => $this->wasSaved ]
+ );
+ }
+
+ /**
+ * Show some useful links in the subtitle
+ */
+ protected function showConvenienceLinks() {
+ // Give a link to the logs/hist for this page
+ if ( $this->targetObj ) {
+ // Also set header tabs to be for the target.
+ $this->getSkin()->setRelevantTitle( $this->targetObj );
+
+ $linkRenderer = $this->getLinkRenderer();
+ $links = [];
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Log' ),
+ $this->msg( 'viewpagelogs' )->text(),
+ [],
+ [
+ 'page' => $this->targetObj->getPrefixedText(),
+ 'hide_tag_log' => '0',
+ ]
+ );
+ if ( !$this->targetObj->isSpecialPage() ) {
+ // Give a link to the page history
+ $links[] = $linkRenderer->makeKnownLink(
+ $this->targetObj,
+ $this->msg( 'pagehist' )->text(),
+ [],
+ [ 'action' => 'history' ]
+ );
+ }
+ // Link to Special:Tags
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Tags' ),
+ $this->msg( 'tags-edit-manage-link' )->text()
+ );
+ // Logs themselves don't have histories or archived revisions
+ $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) );
+ }
+ }
+
+ /**
+ * Get the list object for this request
+ * @return ChangeTagsList
+ */
+ protected function getList() {
+ if ( is_null( $this->revList ) ) {
+ $this->revList = ChangeTagsList::factory( $this->typeName, $this->getContext(),
+ $this->targetObj, $this->ids );
+ }
+
+ return $this->revList;
+ }
+
+ /**
+ * Show a list of items that we will operate on, and show a form which allows
+ * the user to modify the tags applied to those items.
+ */
+ protected function showForm() {
+ $userAllowed = true;
+
+ $out = $this->getOutput();
+ // Messages: tags-edit-revision-selected, tags-edit-logentry-selected
+ $out->wrapWikiMsg( "<strong>$1</strong>", [
+ "tags-edit-{$this->typeName}-selected",
+ $this->getLanguage()->formatNum( count( $this->ids ) ),
+ $this->targetObj->getPrefixedText()
+ ] );
+
+ $this->addHelpLink( 'Help:Tags' );
+ $out->addHTML( "<ul>" );
+
+ $numRevisions = 0;
+ // Live revisions...
+ $list = $this->getList();
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $list->reset(); $list->current(); $list->next() ) {
+ // @codingStandardsIgnoreEnd
+ $item = $list->current();
+ $numRevisions++;
+ $out->addHTML( $item->getHTML() );
+ }
+
+ if ( !$numRevisions ) {
+ throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' );
+ }
+
+ $out->addHTML( "</ul>" );
+ // Explanation text
+ $out->wrapWikiMsg( '<p>$1</p>', "tags-edit-{$this->typeName}-explanation" );
+
+ // Show form if the user can submit
+ if ( $this->isAllowed ) {
+ $form = Xml::openElement( 'form', [ 'method' => 'post',
+ 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ),
+ 'id' => 'mw-revdel-form-revisions' ] ) .
+ Xml::fieldset( $this->msg( "tags-edit-{$this->typeName}-legend",
+ count( $this->ids ) )->text() ) .
+ $this->buildCheckBoxes() .
+ Xml::openElement( 'table' ) .
+ "<tr>\n" .
+ '<td class="mw-label">' .
+ Xml::label( $this->msg( 'tags-edit-reason' )->text(), 'wpReason' ) .
+ '</td>' .
+ '<td class="mw-input">' .
+ Xml::input(
+ 'wpReason',
+ 60,
+ $this->reason,
+ [ 'id' => 'wpReason', 'maxlength' => 100 ]
+ ) .
+ '</td>' .
+ "</tr><tr>\n" .
+ '<td></td>' .
+ '<td class="mw-submit">' .
+ Xml::submitButton( $this->msg( "tags-edit-{$this->typeName}-submit",
+ $numRevisions )->text(), [ 'name' => 'wpSubmit' ] ) .
+ '</td>' .
+ "</tr>\n" .
+ Xml::closeElement( 'table' ) .
+ Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
+ Html::hidden( 'target', $this->targetObj->getPrefixedText() ) .
+ Html::hidden( 'type', $this->typeName ) .
+ Html::hidden( 'ids', implode( ',', $this->ids ) ) .
+ Xml::closeElement( 'fieldset' ) . "\n" .
+ Xml::closeElement( 'form' ) . "\n";
+ } else {
+ $form = '';
+ }
+ $out->addHTML( $form );
+ }
+
+ /**
+ * @return string HTML
+ */
+ protected function buildCheckBoxes() {
+ // If there is just one item, provide the user with a multi-select field
+ $list = $this->getList();
+ $tags = [];
+ if ( $list->length() == 1 ) {
+ $list->reset();
+ $tags = $list->current()->getTags();
+ if ( $tags ) {
+ $tags = explode( ',', $tags );
+ } else {
+ $tags = [];
+ }
+
+ $html = '<table id="mw-edittags-tags-selector">';
+ $html .= '<tr><td>' . $this->msg( 'tags-edit-existing-tags' )->escaped() .
+ '</td><td>';
+ if ( $tags ) {
+ $html .= $this->getLanguage()->commaList( array_map( 'htmlspecialchars', $tags ) );
+ } else {
+ $html .= $this->msg( 'tags-edit-existing-tags-none' )->parse();
+ }
+ $html .= '</td></tr>';
+ $tagSelect = $this->getTagSelect( $tags, $this->msg( 'tags-edit-new-tags' )->plain() );
+ $html .= '<tr><td>' . $tagSelect[0] . '</td><td>' . $tagSelect[1];
+ } else {
+ // Otherwise, use a multi-select field for adding tags, and a list of
+ // checkboxes for removing them
+
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $list->reset(); $list->current(); $list->next() ) {
+ // @codingStandardsIgnoreEnd
+ $currentTags = $list->current()->getTags();
+ if ( $currentTags ) {
+ $tags = array_merge( $tags, explode( ',', $currentTags ) );
+ }
+ }
+ $tags = array_unique( $tags );
+
+ $html = '<table id="mw-edittags-tags-selector-multi"><tr><td>';
+ $tagSelect = $this->getTagSelect( [], $this->msg( 'tags-edit-add' )->plain() );
+ $html .= '<p>' . $tagSelect[0] . '</p>' . $tagSelect[1] . '</td><td>';
+ $html .= Xml::element( 'p', null, $this->msg( 'tags-edit-remove' )->plain() );
+ $html .= Xml::checkLabel( $this->msg( 'tags-edit-remove-all-tags' )->plain(),
+ 'wpRemoveAllTags', 'mw-edittags-remove-all' );
+ $i = 0; // used for generating checkbox IDs only
+ foreach ( $tags as $tag ) {
+ $html .= Xml::element( 'br' ) . "\n" . Xml::checkLabel( $tag,
+ 'wpTagsToRemove[]', 'mw-edittags-remove-' . $i++, false, [
+ 'value' => $tag,
+ 'class' => 'mw-edittags-remove-checkbox',
+ ] );
+ }
+ }
+
+ // also output the tags currently applied as a hidden form field, so we
+ // know what to remove from the revision/log entry when the form is submitted
+ $html .= Html::hidden( 'wpExistingTags', implode( ',', $tags ) );
+ $html .= '</td></tr></table>';
+
+ return $html;
+ }
+
+ /**
+ * Returns a <select multiple> element with a list of change tags that can be
+ * applied by users.
+ *
+ * @param array $selectedTags The tags that should be preselected in the
+ * list. Any tags in this list, but not in the list returned by
+ * ChangeTags::listExplicitlyDefinedTags, will be appended to the <select>
+ * element.
+ * @param string $label The text of a <label> to precede the <select>
+ * @return array HTML <label> element at index 0, HTML <select> element at
+ * index 1
+ */
+ protected function getTagSelect( $selectedTags, $label ) {
+ $result = [];
+ $result[0] = Xml::label( $label, 'mw-edittags-tag-list' );
+
+ $select = new XmlSelect( 'wpTagList[]', 'mw-edittags-tag-list', $selectedTags );
+ $select->setAttribute( 'multiple', 'multiple' );
+ $select->setAttribute( 'size', '8' );
+
+ $tags = ChangeTags::listExplicitlyDefinedTags();
+ $tags = array_unique( array_merge( $tags, $selectedTags ) );
+
+ // Values of $tags are also used as <option> labels
+ $select->addOptions( array_combine( $tags, $tags ) );
+
+ $result[1] = $select->getHTML();
+ return $result;
+ }
+
+ /**
+ * UI entry point for form submission.
+ * @throws PermissionsError
+ * @return bool
+ */
+ protected function submit() {
+ // Check edit token on submission
+ $request = $this->getRequest();
+ $token = $request->getVal( 'wpEditToken' );
+ if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) {
+ $this->getOutput()->addWikiMsg( 'sessionfailure' );
+ return false;
+ }
+
+ // Evaluate incoming request data
+ $tagList = $request->getArray( 'wpTagList' );
+ if ( is_null( $tagList ) ) {
+ $tagList = [];
+ }
+ $existingTags = $request->getVal( 'wpExistingTags' );
+ if ( is_null( $existingTags ) || $existingTags === '' ) {
+ $existingTags = [];
+ } else {
+ $existingTags = explode( ',', $existingTags );
+ }
+
+ if ( count( $this->ids ) > 1 ) {
+ // multiple revisions selected
+ $tagsToAdd = $tagList;
+ if ( $request->getBool( 'wpRemoveAllTags' ) ) {
+ $tagsToRemove = $existingTags;
+ } else {
+ $tagsToRemove = $request->getArray( 'wpTagsToRemove' );
+ }
+ } else {
+ // single revision selected
+ // The user tells us which tags they want associated to the revision.
+ // We have to figure out which ones to add, and which to remove.
+ $tagsToAdd = array_diff( $tagList, $existingTags );
+ $tagsToRemove = array_diff( $existingTags, $tagList );
+ }
+
+ if ( !$tagsToAdd && !$tagsToRemove ) {
+ $status = Status::newFatal( 'tags-edit-none-selected' );
+ } else {
+ $status = $this->getList()->updateChangeTagsOnAll( $tagsToAdd,
+ $tagsToRemove, null, $this->reason, $this->getUser() );
+ }
+
+ if ( $status->isGood() ) {
+ $this->success();
+ return true;
+ } else {
+ $this->failure( $status );
+ return false;
+ }
+ }
+
+ /**
+ * Report that the submit operation succeeded
+ */
+ protected function success() {
+ $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) );
+ $this->getOutput()->wrapWikiMsg( "<div class=\"successbox\">\n$1\n</div>",
+ 'tags-edit-success' );
+ $this->wasSaved = true;
+ $this->revList->reloadFromMaster();
+ $this->reason = ''; // no need to spew the reason back at the user
+ $this->showForm();
+ }
+
+ /**
+ * Report that the submit operation failed
+ * @param Status $status
+ */
+ protected function failure( $status ) {
+ $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) );
+ $this->getOutput()->addWikiText( '<div class="errorbox">' .
+ $status->getWikiText( 'tags-edit-failure' ) .
+ '</div>'
+ );
+ $this->showForm();
+ }
+
+ public function getDescription() {
+ return $this->msg( 'tags-edit-title' )->text();
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialEditWatchlist.php b/www/wiki/includes/specials/SpecialEditWatchlist.php
new file mode 100644
index 00000000..d2940e4a
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialEditWatchlist.php
@@ -0,0 +1,772 @@
+<?php
+/**
+ * @defgroup Watchlist Users watchlist handling
+ */
+
+/**
+ * Implements Special:EditWatchlist
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @ingroup Watchlist
+ */
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Provides the UI through which users can perform editing
+ * operations on their watchlist
+ *
+ * @ingroup SpecialPage
+ * @ingroup Watchlist
+ * @author Rob Church <robchur@gmail.com>
+ */
+class SpecialEditWatchlist extends UnlistedSpecialPage {
+ /**
+ * Editing modes. EDIT_CLEAR is no longer used; the "Clear" link scared people
+ * too much. Now it's passed on to the raw editor, from which it's very easy to clear.
+ */
+ const EDIT_CLEAR = 1;
+ const EDIT_RAW = 2;
+ const EDIT_NORMAL = 3;
+
+ protected $successMessage;
+
+ protected $toc;
+
+ private $badItems = [];
+
+ /**
+ * @var TitleParser
+ */
+ private $titleParser;
+
+ public function __construct() {
+ parent::__construct( 'EditWatchlist', 'editmywatchlist' );
+ }
+
+ /**
+ * Initialize any services we'll need (unless it has already been provided via a setter).
+ * This allows for dependency injection even though we don't control object creation.
+ */
+ private function initServices() {
+ if ( !$this->titleParser ) {
+ $this->titleParser = MediaWikiServices::getInstance()->getTitleParser();
+ }
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * Main execution point
+ *
+ * @param int $mode
+ */
+ public function execute( $mode ) {
+ $this->initServices();
+ $this->setHeaders();
+
+ # Anons don't get a watchlist
+ $this->requireLogin( 'watchlistanontext' );
+
+ $out = $this->getOutput();
+
+ $this->checkPermissions();
+ $this->checkReadOnly();
+
+ $this->outputHeader();
+ $this->outputSubtitle();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ # B/C: $mode used to be waaay down the parameter list, and the first parameter
+ # was $wgUser
+ if ( $mode instanceof User ) {
+ $args = func_get_args();
+ if ( count( $args ) >= 4 ) {
+ $mode = $args[3];
+ }
+ }
+ $mode = self::getMode( $this->getRequest(), $mode );
+
+ switch ( $mode ) {
+ case self::EDIT_RAW:
+ $out->setPageTitle( $this->msg( 'watchlistedit-raw-title' ) );
+ $form = $this->getRawForm();
+ if ( $form->show() ) {
+ $out->addHTML( $this->successMessage );
+ $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) );
+ }
+ break;
+ case self::EDIT_CLEAR:
+ $out->setPageTitle( $this->msg( 'watchlistedit-clear-title' ) );
+ $form = $this->getClearForm();
+ if ( $form->show() ) {
+ $out->addHTML( $this->successMessage );
+ $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) );
+ }
+ break;
+
+ case self::EDIT_NORMAL:
+ default:
+ $this->executeViewEditWatchlist();
+ break;
+ }
+ }
+
+ /**
+ * Renders a subheader on the watchlist page.
+ */
+ protected function outputSubtitle() {
+ $out = $this->getOutput();
+ $out->addSubtitle( $this->msg( 'watchlistfor2', $this->getUser()->getName() )
+ ->rawParams(
+ self::buildTools(
+ $this->getLanguage(),
+ $this->getLinkRenderer()
+ )
+ )
+ );
+ }
+
+ /**
+ * Executes an edit mode for the watchlist view, from which you can manage your watchlist
+ */
+ protected function executeViewEditWatchlist() {
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'watchlistedit-normal-title' ) );
+ $form = $this->getNormalForm();
+ if ( $form->show() ) {
+ $out->addHTML( $this->successMessage );
+ $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) );
+ } elseif ( $this->toc !== false ) {
+ $out->prependHTML( $this->toc );
+ $out->addModules( 'mediawiki.toc' );
+ }
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept.
+ *
+ * @see also SpecialWatchlist::getSubpagesForPrefixSearch
+ * @return string[] subpages
+ */
+ public function getSubpagesForPrefixSearch() {
+ // SpecialWatchlist uses SpecialEditWatchlist::getMode, so new types should be added
+ // here and there - no 'edit' here, because that the default for this page
+ return [
+ 'clear',
+ 'raw',
+ ];
+ }
+
+ /**
+ * Extract a list of titles from a blob of text, returning
+ * (prefixed) strings; unwatchable titles are ignored
+ *
+ * @param string $list
+ * @return array
+ */
+ private function extractTitles( $list ) {
+ $list = explode( "\n", trim( $list ) );
+ if ( !is_array( $list ) ) {
+ return [];
+ }
+
+ $titles = [];
+
+ foreach ( $list as $text ) {
+ $text = trim( $text );
+ if ( strlen( $text ) > 0 ) {
+ $title = Title::newFromText( $text );
+ if ( $title instanceof Title && $title->isWatchable() ) {
+ $titles[] = $title;
+ }
+ }
+ }
+
+ MediaWikiServices::getInstance()->getGenderCache()->doTitlesArray( $titles );
+
+ $list = [];
+ /** @var Title $title */
+ foreach ( $titles as $title ) {
+ $list[] = $title->getPrefixedText();
+ }
+
+ return array_unique( $list );
+ }
+
+ public function submitRaw( $data ) {
+ $wanted = $this->extractTitles( $data['Titles'] );
+ $current = $this->getWatchlist();
+
+ if ( count( $wanted ) > 0 ) {
+ $toWatch = array_diff( $wanted, $current );
+ $toUnwatch = array_diff( $current, $wanted );
+ $this->watchTitles( $toWatch );
+ $this->unwatchTitles( $toUnwatch );
+ $this->getUser()->invalidateCache();
+
+ if ( count( $toWatch ) > 0 || count( $toUnwatch ) > 0 ) {
+ $this->successMessage = $this->msg( 'watchlistedit-raw-done' )->parse();
+ } else {
+ return false;
+ }
+
+ if ( count( $toWatch ) > 0 ) {
+ $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-added' )
+ ->numParams( count( $toWatch ) )->parse();
+ $this->showTitles( $toWatch, $this->successMessage );
+ }
+
+ if ( count( $toUnwatch ) > 0 ) {
+ $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-removed' )
+ ->numParams( count( $toUnwatch ) )->parse();
+ $this->showTitles( $toUnwatch, $this->successMessage );
+ }
+ } else {
+ $this->clearWatchlist();
+ $this->getUser()->invalidateCache();
+
+ if ( count( $current ) > 0 ) {
+ $this->successMessage = $this->msg( 'watchlistedit-raw-done' )->parse();
+ } else {
+ return false;
+ }
+
+ $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-removed' )
+ ->numParams( count( $current ) )->parse();
+ $this->showTitles( $current, $this->successMessage );
+ }
+
+ return true;
+ }
+
+ public function submitClear( $data ) {
+ $current = $this->getWatchlist();
+ $this->clearWatchlist();
+ $this->getUser()->invalidateCache();
+ $this->successMessage = $this->msg( 'watchlistedit-clear-done' )->parse();
+ $this->successMessage .= ' ' . $this->msg( 'watchlistedit-clear-removed' )
+ ->numParams( count( $current ) )->parse();
+ $this->showTitles( $current, $this->successMessage );
+
+ return true;
+ }
+
+ /**
+ * Print out a list of linked titles
+ *
+ * $titles can be an array of strings or Title objects; the former
+ * is preferred, since Titles are very memory-heavy
+ *
+ * @param array $titles Array of strings, or Title objects
+ * @param string $output
+ */
+ private function showTitles( $titles, &$output ) {
+ $talk = $this->msg( 'talkpagelinktext' )->text();
+ // Do a batch existence check
+ $batch = new LinkBatch();
+ if ( count( $titles ) >= 100 ) {
+ $output = $this->msg( 'watchlistedit-too-many' )->parse();
+ return;
+ }
+ foreach ( $titles as $title ) {
+ if ( !$title instanceof Title ) {
+ $title = Title::newFromText( $title );
+ }
+
+ if ( $title instanceof Title ) {
+ $batch->addObj( $title );
+ $batch->addObj( $title->getTalkPage() );
+ }
+ }
+
+ $batch->execute();
+
+ // Print out the list
+ $output .= "<ul>\n";
+
+ $linkRenderer = $this->getLinkRenderer();
+ foreach ( $titles as $title ) {
+ if ( !$title instanceof Title ) {
+ $title = Title::newFromText( $title );
+ }
+
+ if ( $title instanceof Title ) {
+ $output .= '<li>' .
+ $linkRenderer->makeLink( $title ) . ' ' .
+ $this->msg( 'parentheses' )->rawParams(
+ $linkRenderer->makeLink( $title->getTalkPage(), $talk )
+ )->escaped() .
+ "</li>\n";
+ }
+ }
+
+ $output .= "</ul>\n";
+ }
+
+ /**
+ * Prepare a list of titles on a user's watchlist (excluding talk pages)
+ * and return an array of (prefixed) strings
+ *
+ * @return array
+ */
+ private function getWatchlist() {
+ $list = [];
+
+ $watchedItems = MediaWikiServices::getInstance()->getWatchedItemStore()->getWatchedItemsForUser(
+ $this->getUser(),
+ [ 'forWrite' => $this->getRequest()->wasPosted() ]
+ );
+
+ if ( $watchedItems ) {
+ /** @var Title[] $titles */
+ $titles = [];
+ foreach ( $watchedItems as $watchedItem ) {
+ $namespace = $watchedItem->getLinkTarget()->getNamespace();
+ $dbKey = $watchedItem->getLinkTarget()->getDBkey();
+ $title = Title::makeTitleSafe( $namespace, $dbKey );
+
+ if ( $this->checkTitle( $title, $namespace, $dbKey )
+ && !$title->isTalkPage()
+ ) {
+ $titles[] = $title;
+ }
+ }
+
+ MediaWikiServices::getInstance()->getGenderCache()->doTitlesArray( $titles );
+
+ foreach ( $titles as $title ) {
+ $list[] = $title->getPrefixedText();
+ }
+ }
+
+ $this->cleanupWatchlist();
+
+ return $list;
+ }
+
+ /**
+ * Get a list of titles on a user's watchlist, excluding talk pages,
+ * and return as a two-dimensional array with namespace and title.
+ *
+ * @return array
+ */
+ protected function getWatchlistInfo() {
+ $titles = [];
+
+ $watchedItems = MediaWikiServices::getInstance()->getWatchedItemStore()
+ ->getWatchedItemsForUser( $this->getUser(), [ 'sort' => WatchedItemStore::SORT_ASC ] );
+
+ $lb = new LinkBatch();
+
+ foreach ( $watchedItems as $watchedItem ) {
+ $namespace = $watchedItem->getLinkTarget()->getNamespace();
+ $dbKey = $watchedItem->getLinkTarget()->getDBkey();
+ $lb->add( $namespace, $dbKey );
+ if ( !MWNamespace::isTalk( $namespace ) ) {
+ $titles[$namespace][$dbKey] = 1;
+ }
+ }
+
+ $lb->execute();
+
+ return $titles;
+ }
+
+ /**
+ * Validates watchlist entry
+ *
+ * @param Title $title
+ * @param int $namespace
+ * @param string $dbKey
+ * @return bool Whether this item is valid
+ */
+ private function checkTitle( $title, $namespace, $dbKey ) {
+ if ( $title
+ && ( $title->isExternal()
+ || $title->getNamespace() < 0
+ )
+ ) {
+ $title = false; // unrecoverable
+ }
+
+ if ( !$title
+ || $title->getNamespace() != $namespace
+ || $title->getDBkey() != $dbKey
+ ) {
+ $this->badItems[] = [ $title, $namespace, $dbKey ];
+ }
+
+ return (bool)$title;
+ }
+
+ /**
+ * Attempts to clean up broken items
+ */
+ private function cleanupWatchlist() {
+ if ( !count( $this->badItems ) ) {
+ return; // nothing to do
+ }
+
+ $user = $this->getUser();
+ $badItems = $this->badItems;
+ DeferredUpdates::addCallableUpdate( function () use ( $user, $badItems ) {
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ foreach ( $badItems as $row ) {
+ list( $title, $namespace, $dbKey ) = $row;
+ $action = $title ? 'cleaning up' : 'deleting';
+ wfDebug( "User {$user->getName()} has broken watchlist item " .
+ "ns($namespace):$dbKey, $action.\n" );
+
+ $store->removeWatch( $user, new TitleValue( (int)$namespace, $dbKey ) );
+ // Can't just do an UPDATE instead of DELETE/INSERT due to unique index
+ if ( $title ) {
+ $user->addWatch( $title );
+ }
+ }
+ } );
+ }
+
+ /**
+ * Remove all titles from a user's watchlist
+ */
+ private function clearWatchlist() {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete(
+ 'watchlist',
+ [ 'wl_user' => $this->getUser()->getId() ],
+ __METHOD__
+ );
+ }
+
+ /**
+ * Add a list of targets to a user's watchlist
+ *
+ * @param string[]|LinkTarget[] $targets
+ */
+ private function watchTitles( $targets ) {
+ $expandedTargets = [];
+ foreach ( $targets as $target ) {
+ if ( !$target instanceof LinkTarget ) {
+ try {
+ $target = $this->titleParser->parseTitle( $target, NS_MAIN );
+ }
+ catch ( MalformedTitleException $e ) {
+ continue;
+ }
+ }
+
+ $ns = $target->getNamespace();
+ $dbKey = $target->getDBkey();
+ $expandedTargets[] = new TitleValue( MWNamespace::getSubject( $ns ), $dbKey );
+ $expandedTargets[] = new TitleValue( MWNamespace::getTalk( $ns ), $dbKey );
+ }
+
+ MediaWikiServices::getInstance()->getWatchedItemStore()->addWatchBatchForUser(
+ $this->getUser(),
+ $expandedTargets
+ );
+ }
+
+ /**
+ * Remove a list of titles from a user's watchlist
+ *
+ * $titles can be an array of strings or Title objects; the former
+ * is preferred, since Titles are very memory-heavy
+ *
+ * @param array $titles Array of strings, or Title objects
+ */
+ private function unwatchTitles( $titles ) {
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+
+ foreach ( $titles as $title ) {
+ if ( !$title instanceof Title ) {
+ $title = Title::newFromText( $title );
+ }
+
+ if ( $title instanceof Title ) {
+ $store->removeWatch( $this->getUser(), $title->getSubjectPage() );
+ $store->removeWatch( $this->getUser(), $title->getTalkPage() );
+
+ $page = WikiPage::factory( $title );
+ Hooks::run( 'UnwatchArticleComplete', [ $this->getUser(), &$page ] );
+ }
+ }
+ }
+
+ public function submitNormal( $data ) {
+ $removed = [];
+
+ foreach ( $data as $titles ) {
+ $this->unwatchTitles( $titles );
+ $removed = array_merge( $removed, $titles );
+ }
+
+ if ( count( $removed ) > 0 ) {
+ $this->successMessage = $this->msg( 'watchlistedit-normal-done'
+ )->numParams( count( $removed ) )->parse();
+ $this->showTitles( $removed, $this->successMessage );
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Get the standard watchlist editing form
+ *
+ * @return HTMLForm
+ */
+ protected function getNormalForm() {
+ global $wgContLang;
+
+ $fields = [];
+ $count = 0;
+
+ // Allow subscribers to manipulate the list of watched pages (or use it
+ // to preload lots of details at once)
+ $watchlistInfo = $this->getWatchlistInfo();
+ Hooks::run(
+ 'WatchlistEditorBeforeFormRender',
+ [ &$watchlistInfo ]
+ );
+
+ foreach ( $watchlistInfo as $namespace => $pages ) {
+ $options = [];
+
+ foreach ( array_keys( $pages ) as $dbkey ) {
+ $title = Title::makeTitleSafe( $namespace, $dbkey );
+
+ if ( $this->checkTitle( $title, $namespace, $dbkey ) ) {
+ $text = $this->buildRemoveLine( $title );
+ $options[$text] = $title->getPrefixedText();
+ $count++;
+ }
+ }
+
+ // checkTitle can filter some options out, avoid empty sections
+ if ( count( $options ) > 0 ) {
+ $fields['TitlesNs' . $namespace] = [
+ 'class' => 'EditWatchlistCheckboxSeriesField',
+ 'options' => $options,
+ 'section' => "ns$namespace",
+ ];
+ }
+ }
+ $this->cleanupWatchlist();
+
+ if ( count( $fields ) > 1 && $count > 30 ) {
+ $this->toc = Linker::tocIndent();
+ $tocLength = 0;
+
+ foreach ( $fields as $data ) {
+ # strip out the 'ns' prefix from the section name:
+ $ns = substr( $data['section'], 2 );
+
+ $nsText = ( $ns == NS_MAIN )
+ ? $this->msg( 'blanknamespace' )->escaped()
+ : htmlspecialchars( $wgContLang->getFormattedNsText( $ns ) );
+ $this->toc .= Linker::tocLine( "editwatchlist-{$data['section']}", $nsText,
+ $this->getLanguage()->formatNum( ++$tocLength ), 1 ) . Linker::tocLineEnd();
+ }
+
+ $this->toc = Linker::tocList( $this->toc );
+ } else {
+ $this->toc = false;
+ }
+
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() ); // Remove subpage
+ $form = new EditWatchlistNormalHTMLForm( $fields, $context );
+ $form->setSubmitTextMsg( 'watchlistedit-normal-submit' );
+ $form->setSubmitDestructive();
+ # Used message keys:
+ # 'accesskey-watchlistedit-normal-submit', 'tooltip-watchlistedit-normal-submit'
+ $form->setSubmitTooltip( 'watchlistedit-normal-submit' );
+ $form->setWrapperLegendMsg( 'watchlistedit-normal-legend' );
+ $form->addHeaderText( $this->msg( 'watchlistedit-normal-explain' )->parse() );
+ $form->setSubmitCallback( [ $this, 'submitNormal' ] );
+
+ return $form;
+ }
+
+ /**
+ * Build the label for a checkbox, with a link to the title, and various additional bits
+ *
+ * @param Title $title
+ * @return string
+ */
+ private function buildRemoveLine( $title ) {
+ $linkRenderer = $this->getLinkRenderer();
+ $link = $linkRenderer->makeLink( $title );
+
+ $tools['talk'] = $linkRenderer->makeLink(
+ $title->getTalkPage(),
+ $this->msg( 'talkpagelinktext' )->text()
+ );
+
+ if ( $title->exists() ) {
+ $tools['history'] = $linkRenderer->makeKnownLink(
+ $title,
+ $this->msg( 'history_small' )->text(),
+ [],
+ [ 'action' => 'history' ]
+ );
+ }
+
+ if ( $title->getNamespace() == NS_USER && !$title->isSubpage() ) {
+ $tools['contributions'] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Contributions', $title->getText() ),
+ $this->msg( 'contributions' )->text()
+ );
+ }
+
+ Hooks::run(
+ 'WatchlistEditorBuildRemoveLine',
+ [ &$tools, $title, $title->isRedirect(), $this->getSkin(), &$link ]
+ );
+
+ if ( $title->isRedirect() ) {
+ // Linker already makes class mw-redirect, so this is redundant
+ $link = '<span class="watchlistredir">' . $link . '</span>';
+ }
+
+ return $link . ' ' .
+ $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList( $tools ) )->escaped();
+ }
+
+ /**
+ * Get a form for editing the watchlist in "raw" mode
+ *
+ * @return HTMLForm
+ */
+ protected function getRawForm() {
+ $titles = implode( $this->getWatchlist(), "\n" );
+ $fields = [
+ 'Titles' => [
+ 'type' => 'textarea',
+ 'label-message' => 'watchlistedit-raw-titles',
+ 'default' => $titles,
+ ],
+ ];
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle( 'raw' ) ); // Reset subpage
+ $form = new HTMLForm( $fields, $context );
+ $form->setSubmitTextMsg( 'watchlistedit-raw-submit' );
+ # Used message keys: 'accesskey-watchlistedit-raw-submit', 'tooltip-watchlistedit-raw-submit'
+ $form->setSubmitTooltip( 'watchlistedit-raw-submit' );
+ $form->setWrapperLegendMsg( 'watchlistedit-raw-legend' );
+ $form->addHeaderText( $this->msg( 'watchlistedit-raw-explain' )->parse() );
+ $form->setSubmitCallback( [ $this, 'submitRaw' ] );
+
+ return $form;
+ }
+
+ /**
+ * Get a form for clearing the watchlist
+ *
+ * @return HTMLForm
+ */
+ protected function getClearForm() {
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle( 'clear' ) ); // Reset subpage
+ $form = new HTMLForm( [], $context );
+ $form->setSubmitTextMsg( 'watchlistedit-clear-submit' );
+ # Used message keys: 'accesskey-watchlistedit-clear-submit', 'tooltip-watchlistedit-clear-submit'
+ $form->setSubmitTooltip( 'watchlistedit-clear-submit' );
+ $form->setWrapperLegendMsg( 'watchlistedit-clear-legend' );
+ $form->addHeaderText( $this->msg( 'watchlistedit-clear-explain' )->parse() );
+ $form->setSubmitCallback( [ $this, 'submitClear' ] );
+ $form->setSubmitDestructive();
+
+ return $form;
+ }
+
+ /**
+ * Determine whether we are editing the watchlist, and if so, what
+ * kind of editing operation
+ *
+ * @param WebRequest $request
+ * @param string $par
+ * @return int
+ */
+ public static function getMode( $request, $par ) {
+ $mode = strtolower( $request->getVal( 'action', $par ) );
+
+ switch ( $mode ) {
+ case 'clear':
+ case self::EDIT_CLEAR:
+ return self::EDIT_CLEAR;
+ case 'raw':
+ case self::EDIT_RAW:
+ return self::EDIT_RAW;
+ case 'edit':
+ case self::EDIT_NORMAL:
+ return self::EDIT_NORMAL;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Build a set of links for convenient navigation
+ * between watchlist viewing and editing modes
+ *
+ * @param Language $lang
+ * @param LinkRenderer|null $linkRenderer
+ * @return string
+ */
+ public static function buildTools( $lang, LinkRenderer $linkRenderer = null ) {
+ if ( !$lang instanceof Language ) {
+ // back-compat where the first parameter was $unused
+ global $wgLang;
+ $lang = $wgLang;
+ }
+ if ( !$linkRenderer ) {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ }
+
+ $tools = [];
+ $modes = [
+ 'view' => [ 'Watchlist', false ],
+ 'edit' => [ 'EditWatchlist', false ],
+ 'raw' => [ 'EditWatchlist', 'raw' ],
+ 'clear' => [ 'EditWatchlist', 'clear' ],
+ ];
+
+ foreach ( $modes as $mode => $arr ) {
+ // can use messages 'watchlisttools-view', 'watchlisttools-edit', 'watchlisttools-raw'
+ $tools[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( $arr[0], $arr[1] ),
+ wfMessage( "watchlisttools-{$mode}" )->text()
+ );
+ }
+
+ return Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-watchlist-toollinks' ],
+ wfMessage( 'parentheses' )->rawParams( $lang->pipeList( $tools ) )->escaped()
+ );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialEmailInvalidate.php b/www/wiki/includes/specials/SpecialEmailInvalidate.php
new file mode 100644
index 00000000..c54abadd
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialEmailInvalidate.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * Implements Special:EmailInvalidation
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page allows users to cancel an email confirmation using the e-mail
+ * confirmation code
+ *
+ * @ingroup SpecialPage
+ */
+class EmailInvalidation extends UnlistedSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Invalidateemail', 'editmyprivateinfo' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ function execute( $code ) {
+ // Ignore things like master queries/connections on GET requests.
+ // It's very convenient to just allow formless link usage.
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+
+ $this->setHeaders();
+ $this->checkReadOnly();
+ $this->checkPermissions();
+
+ $old = $trxProfiler->setSilenced( true );
+ $this->attemptInvalidate( $code );
+ $trxProfiler->setSilenced( $old );
+ }
+
+ /**
+ * Attempt to invalidate the user's email address and show success or failure
+ * as needed; if successful, link to main page
+ *
+ * @param string $code Confirmation code
+ */
+ private function attemptInvalidate( $code ) {
+ $user = User::newFromConfirmationCode( $code, User::READ_LATEST );
+ if ( !is_object( $user ) ) {
+ $this->getOutput()->addWikiMsg( 'confirmemail_invalid' );
+
+ return;
+ }
+
+ $user->invalidateEmail();
+ $user->saveSettings();
+ $this->getOutput()->addWikiMsg( 'confirmemail_invalidated' );
+
+ if ( !$this->getUser()->isLoggedIn() ) {
+ $this->getOutput()->returnToMain();
+ }
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialEmailuser.php b/www/wiki/includes/specials/SpecialEmailuser.php
new file mode 100644
index 00000000..249be7f1
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialEmailuser.php
@@ -0,0 +1,504 @@
+<?php
+/**
+ * Implements Special:Emailuser
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * A special page that allows users to send e-mails to other users
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialEmailUser extends UnlistedSpecialPage {
+ protected $mTarget;
+
+ /**
+ * @var User|string $mTargetObj
+ */
+ protected $mTargetObj;
+
+ public function __construct() {
+ parent::__construct( 'Emailuser' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function getDescription() {
+ $target = self::getTarget( $this->mTarget, $this->getUser() );
+ if ( !$target instanceof User ) {
+ return $this->msg( 'emailuser-title-notarget' )->text();
+ }
+
+ return $this->msg( 'emailuser-title-target', $target->getName() )->text();
+ }
+
+ protected function getFormFields() {
+ $linkRenderer = $this->getLinkRenderer();
+ return [
+ 'From' => [
+ 'type' => 'info',
+ 'raw' => 1,
+ 'default' => $linkRenderer->makeLink(
+ $this->getUser()->getUserPage(),
+ $this->getUser()->getName()
+ ),
+ 'label-message' => 'emailfrom',
+ 'id' => 'mw-emailuser-sender',
+ ],
+ 'To' => [
+ 'type' => 'info',
+ 'raw' => 1,
+ 'default' => $linkRenderer->makeLink(
+ $this->mTargetObj->getUserPage(),
+ $this->mTargetObj->getName()
+ ),
+ 'label-message' => 'emailto',
+ 'id' => 'mw-emailuser-recipient',
+ ],
+ 'Target' => [
+ 'type' => 'hidden',
+ 'default' => $this->mTargetObj->getName(),
+ ],
+ 'Subject' => [
+ 'type' => 'text',
+ 'default' => $this->msg( 'defemailsubject',
+ $this->getUser()->getName() )->inContentLanguage()->text(),
+ 'label-message' => 'emailsubject',
+ 'maxlength' => 200,
+ 'size' => 60,
+ 'required' => true,
+ ],
+ 'Text' => [
+ 'type' => 'textarea',
+ 'rows' => 20,
+ 'cols' => 80,
+ 'label-message' => 'emailmessage',
+ 'required' => true,
+ ],
+ 'CCMe' => [
+ 'type' => 'check',
+ 'label-message' => 'emailccme',
+ 'default' => $this->getUser()->getBoolOption( 'ccmeonemails' ),
+ ],
+ ];
+ }
+
+ public function execute( $par ) {
+ $out = $this->getOutput();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ $this->mTarget = is_null( $par )
+ ? $this->getRequest()->getVal( 'wpTarget', $this->getRequest()->getVal( 'target', '' ) )
+ : $par;
+
+ // This needs to be below assignment of $this->mTarget because
+ // getDescription() needs it to determine the correct page title.
+ $this->setHeaders();
+ $this->outputHeader();
+
+ // error out if sending user cannot do this
+ $error = self::getPermissionsError(
+ $this->getUser(),
+ $this->getRequest()->getVal( 'wpEditToken' ),
+ $this->getConfig()
+ );
+
+ switch ( $error ) {
+ case null:
+ # Wahey!
+ break;
+ case 'badaccess':
+ throw new PermissionsError( 'sendemail' );
+ case 'blockedemailuser':
+ throw new UserBlockedError( $this->getUser()->mBlock );
+ case 'actionthrottledtext':
+ throw new ThrottledError;
+ case 'mailnologin':
+ case 'usermaildisabled':
+ throw new ErrorPageError( $error, "{$error}text" );
+ default:
+ # It's a hook error
+ list( $title, $msg, $params ) = $error;
+ throw new ErrorPageError( $title, $msg, $params );
+ }
+ // Got a valid target user name? Else ask for one.
+ $ret = self::getTarget( $this->mTarget, $this->getUser() );
+ if ( !$ret instanceof User ) {
+ if ( $this->mTarget != '' ) {
+ // Messages used here: notargettext, noemailtext, nowikiemailtext
+ $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' );
+ $out->wrapWikiMsg( "<p class='error'>$1</p>", $ret );
+ }
+ $out->addHTML( $this->userForm( $this->mTarget ) );
+
+ return;
+ }
+
+ $this->mTargetObj = $ret;
+
+ // Set the 'relevant user' in the skin, so it displays links like Contributions,
+ // User logs, UserRights, etc.
+ $this->getSkin()->setRelevantUser( $this->mTargetObj );
+
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() ); // Remove subpage
+ $form = new HTMLForm( $this->getFormFields(), $context );
+ // By now we are supposed to be sure that $this->mTarget is a user name
+ $form->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() );
+ $form->setSubmitTextMsg( 'emailsend' );
+ $form->setSubmitCallback( [ __CLASS__, 'uiSubmit' ] );
+ $form->setWrapperLegendMsg( 'email-legend' );
+ $form->loadData();
+
+ if ( !Hooks::run( 'EmailUserForm', [ &$form ] ) ) {
+ return;
+ }
+
+ $result = $form->show();
+
+ if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
+ $out->setPageTitle( $this->msg( 'emailsent' ) );
+ $out->addWikiMsg( 'emailsenttext', $this->mTarget );
+ $out->returnToMain( false, $this->mTargetObj->getUserPage() );
+ }
+ }
+
+ /**
+ * Validate target User
+ *
+ * @param string $target Target user name
+ * @param User|null $sender User sending the email
+ * @return User|string User object on success or a string on error
+ */
+ public static function getTarget( $target, User $sender = null ) {
+ if ( $sender === null ) {
+ wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' );
+ }
+
+ if ( $target == '' ) {
+ wfDebug( "Target is empty.\n" );
+
+ return 'notarget';
+ }
+
+ $nu = User::newFromName( $target );
+ $error = self::validateTarget( $nu, $sender );
+
+ return $error ? $error : $nu;
+ }
+
+ /**
+ * Validate target User
+ *
+ * @param User $target Target user
+ * @param User|null $sender User sending the email
+ * @return string Error message or empty string if valid.
+ * @since 1.30
+ */
+ public static function validateTarget( $target, User $sender = null ) {
+ if ( $sender === null ) {
+ wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' );
+ }
+
+ if ( !$target instanceof User || !$target->getId() ) {
+ wfDebug( "Target is invalid user.\n" );
+
+ return 'notarget';
+ } elseif ( !$target->isEmailConfirmed() ) {
+ wfDebug( "User has no valid email.\n" );
+
+ return 'noemail';
+ } elseif ( !$target->canReceiveEmail() ) {
+ wfDebug( "User does not allow user emails.\n" );
+
+ return 'nowikiemail';
+ } elseif ( $sender !== null ) {
+ $blacklist = $target->getOption( 'email-blacklist', [] );
+ if ( $blacklist ) {
+ $lookup = CentralIdLookup::factory();
+ $senderId = $lookup->centralIdFromLocalUser( $sender );
+ if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) {
+ wfDebug( "User does not allow user emails from this user.\n" );
+
+ return 'nowikiemail';
+ }
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Check whether a user is allowed to send email
+ *
+ * @param User $user
+ * @param string $editToken Edit token
+ * @param Config $config optional for backwards compatibility
+ * @return string|null Null on success or string on error
+ */
+ public static function getPermissionsError( $user, $editToken, Config $config = null ) {
+ if ( $config === null ) {
+ wfDebug( __METHOD__ . ' called without a Config instance passed to it' );
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+ if ( !$config->get( 'EnableEmail' ) || !$config->get( 'EnableUserEmail' ) ) {
+ return 'usermaildisabled';
+ }
+
+ // Run this before $user->isAllowed, to show appropriate message to anons (T160309)
+ if ( !$user->isEmailConfirmed() ) {
+ return 'mailnologin';
+ }
+
+ if ( !$user->isAllowed( 'sendemail' ) ) {
+ return 'badaccess';
+ }
+
+ if ( $user->isBlockedFromEmailuser() ) {
+ wfDebug( "User is blocked from sending e-mail.\n" );
+
+ return "blockedemailuser";
+ }
+
+ if ( $user->pingLimiter( 'emailuser' ) ) {
+ wfDebug( "Ping limiter triggered.\n" );
+
+ return 'actionthrottledtext';
+ }
+
+ $hookErr = false;
+
+ Hooks::run( 'UserCanSendEmail', [ &$user, &$hookErr ] );
+ Hooks::run( 'EmailUserPermissionsErrors', [ $user, $editToken, &$hookErr ] );
+
+ if ( $hookErr ) {
+ return $hookErr;
+ }
+
+ return null;
+ }
+
+ /**
+ * Form to ask for target user name.
+ *
+ * @param string $name User name submitted.
+ * @return string Form asking for user name.
+ */
+ protected function userForm( $name ) {
+ $this->getOutput()->addModules( 'mediawiki.userSuggest' );
+ $string = Html::openElement(
+ 'form',
+ [ 'method' => 'get', 'action' => wfScript(), 'id' => 'askusername' ]
+ ) .
+ Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
+ Html::openElement( 'fieldset' ) .
+ Html::rawElement( 'legend', null, $this->msg( 'emailtarget' )->parse() ) .
+ Html::label(
+ $this->msg( 'emailusername' )->text(),
+ 'emailusertarget'
+ ) . '&#160;' .
+ Html::input(
+ 'target',
+ $name,
+ 'text',
+ [
+ 'id' => 'emailusertarget',
+ 'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
+ 'autofocus' => true,
+ 'size' => 30,
+ ]
+ ) .
+ ' ' .
+ Html::submitButton( $this->msg( 'emailusernamesubmit' )->text(), [] ) .
+ Html::closeElement( 'fieldset' ) .
+ Html::closeElement( 'form' ) . "\n";
+
+ return $string;
+ }
+
+ /**
+ * Submit callback for an HTMLForm object, will simply call submit().
+ *
+ * @since 1.20
+ * @param array $data
+ * @param HTMLForm $form
+ * @return Status|bool
+ */
+ public static function uiSubmit( array $data, HTMLForm $form ) {
+ return self::submit( $data, $form->getContext() );
+ }
+
+ /**
+ * Really send a mail. Permissions should have been checked using
+ * getPermissionsError(). It is probably also a good
+ * idea to check the edit token and ping limiter in advance.
+ *
+ * @param array $data
+ * @param IContextSource $context
+ * @return Status|bool
+ */
+ public static function submit( array $data, IContextSource $context ) {
+ $config = $context->getConfig();
+
+ $target = self::getTarget( $data['Target'], $context->getUser() );
+ if ( !$target instanceof User ) {
+ // Messages used here: notargettext, noemailtext, nowikiemailtext
+ return Status::newFatal( $target . 'text' );
+ }
+
+ $to = MailAddress::newFromUser( $target );
+ $from = MailAddress::newFromUser( $context->getUser() );
+ $subject = $data['Subject'];
+ $text = $data['Text'];
+
+ // Add a standard footer and trim up trailing newlines
+ $text = rtrim( $text ) . "\n\n-- \n";
+ $text .= $context->msg( 'emailuserfooter',
+ $from->name, $to->name )->inContentLanguage()->text();
+
+ $error = false;
+ if ( !Hooks::run( 'EmailUser', [ &$to, &$from, &$subject, &$text, &$error ] ) ) {
+ if ( $error instanceof Status ) {
+ return $error;
+ } elseif ( $error === false || $error === '' || $error === [] ) {
+ // Possibly to tell HTMLForm to pretend there was no submission?
+ return false;
+ } elseif ( $error === true ) {
+ // Hook sent the mail itself and indicates success?
+ return Status::newGood();
+ } elseif ( is_array( $error ) ) {
+ $status = Status::newGood();
+ foreach ( $error as $e ) {
+ $status->fatal( $e );
+ }
+ return $status;
+ } elseif ( $error instanceof MessageSpecifier ) {
+ return Status::newFatal( $error );
+ } else {
+ // Ugh. Either a raw HTML string, or something that's supposed
+ // to be treated like one.
+ $type = is_object( $error ) ? get_class( $error ) : gettype( $error );
+ wfDeprecated( "EmailUser hook returning a $type as \$error", '1.29' );
+ return Status::newFatal( new ApiRawMessage(
+ [ '$1', Message::rawParam( (string)$error ) ], 'hookaborted'
+ ) );
+ }
+ }
+
+ if ( $config->get( 'UserEmailUseReplyTo' ) ) {
+ /**
+ * Put the generic wiki autogenerated address in the From:
+ * header and reserve the user for Reply-To.
+ *
+ * This is a bit ugly, but will serve to differentiate
+ * wiki-borne mails from direct mails and protects against
+ * SPF and bounce problems with some mailers (see below).
+ */
+ $mailFrom = new MailAddress( $config->get( 'PasswordSender' ),
+ wfMessage( 'emailsender' )->inContentLanguage()->text() );
+ $replyTo = $from;
+ } else {
+ /**
+ * Put the sending user's e-mail address in the From: header.
+ *
+ * This is clean-looking and convenient, but has issues.
+ * One is that it doesn't as clearly differentiate the wiki mail
+ * from "directly" sent mails.
+ *
+ * Another is that some mailers (like sSMTP) will use the From
+ * address as the envelope sender as well. For open sites this
+ * can cause mails to be flunked for SPF violations (since the
+ * wiki server isn't an authorized sender for various users'
+ * domains) as well as creating a privacy issue as bounces
+ * containing the recipient's e-mail address may get sent to
+ * the sending user.
+ */
+ $mailFrom = $from;
+ $replyTo = null;
+ }
+
+ $status = UserMailer::send( $to, $mailFrom, $subject, $text, [
+ 'replyTo' => $replyTo,
+ ] );
+
+ if ( !$status->isGood() ) {
+ return $status;
+ } else {
+ // if the user requested a copy of this mail, do this now,
+ // unless they are emailing themselves, in which case one
+ // copy of the message is sufficient.
+ if ( $data['CCMe'] && $to != $from ) {
+ $ccTo = $from;
+ $ccFrom = $from;
+ $ccSubject = $context->msg( 'emailccsubject' )->rawParams(
+ $target->getName(), $subject )->text();
+ $ccText = $text;
+
+ Hooks::run( 'EmailUserCC', [ &$ccTo, &$ccFrom, &$ccSubject, &$ccText ] );
+
+ if ( $config->get( 'UserEmailUseReplyTo' ) ) {
+ $mailFrom = new MailAddress(
+ $config->get( 'PasswordSender' ),
+ wfMessage( 'emailsender' )->inContentLanguage()->text()
+ );
+ $replyTo = $ccFrom;
+ } else {
+ $mailFrom = $ccFrom;
+ $replyTo = null;
+ }
+
+ $ccStatus = UserMailer::send(
+ $ccTo, $mailFrom, $ccSubject, $ccText, [
+ 'replyTo' => $replyTo,
+ ] );
+ $status->merge( $ccStatus );
+ }
+
+ Hooks::run( 'EmailUserComplete', [ $to, $from, $subject, $text ] );
+
+ return $status;
+ }
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialExpandTemplates.php b/www/wiki/includes/specials/SpecialExpandTemplates.php
new file mode 100644
index 00000000..560d75a6
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialExpandTemplates.php
@@ -0,0 +1,296 @@
+<?php
+/**
+ * Implements Special:ExpandTemplates
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that expands submitted templates, parser functions,
+ * and variables, allowing easier debugging of these.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialExpandTemplates extends SpecialPage {
+
+ /** @var bool Whether or not to show the XML parse tree */
+ protected $generateXML;
+
+ /** @var bool Whether or not to show the raw HTML code */
+ protected $generateRawHtml;
+
+ /** @var bool Whether or not to remove comments in the expanded wikitext */
+ protected $removeComments;
+
+ /** @var bool Whether or not to remove <nowiki> tags in the expanded wikitext */
+ protected $removeNowiki;
+
+ /** @var int Maximum size in bytes to include. 50MB allows fixing those huge pages */
+ const MAX_INCLUDE_SIZE = 50000000;
+
+ function __construct() {
+ parent::__construct( 'ExpandTemplates' );
+ }
+
+ /**
+ * Show the special page
+ * @param string|null $subpage
+ */
+ function execute( $subpage ) {
+ global $wgParser;
+
+ $this->setHeaders();
+
+ $request = $this->getRequest();
+ $titleStr = $request->getText( 'wpContextTitle' );
+ $title = Title::newFromText( $titleStr );
+
+ if ( !$title ) {
+ $title = $this->getPageTitle();
+ }
+ $input = $request->getText( 'wpInput' );
+ $this->generateXML = $request->getBool( 'wpGenerateXml' );
+ $this->generateRawHtml = $request->getBool( 'wpGenerateRawHtml' );
+
+ if ( strlen( $input ) ) {
+ $this->removeComments = $request->getBool( 'wpRemoveComments', false );
+ $this->removeNowiki = $request->getBool( 'wpRemoveNowiki', false );
+ $options = ParserOptions::newFromContext( $this->getContext() );
+ $options->setRemoveComments( $this->removeComments );
+ $options->setTidy( true );
+ $options->setMaxIncludeSize( self::MAX_INCLUDE_SIZE );
+
+ if ( $this->generateXML ) {
+ $wgParser->startExternalParse( $title, $options, Parser::OT_PREPROCESS );
+ $dom = $wgParser->preprocessToDom( $input );
+
+ if ( method_exists( $dom, 'saveXML' ) ) {
+ $xml = $dom->saveXML();
+ } else {
+ $xml = $dom->__toString();
+ }
+ }
+
+ $output = $wgParser->preprocess( $input, $title, $options );
+ } else {
+ $this->removeComments = $request->getBool( 'wpRemoveComments', true );
+ $this->removeNowiki = $request->getBool( 'wpRemoveNowiki', false );
+ $output = false;
+ }
+
+ $out = $this->getOutput();
+
+ $this->makeForm( $titleStr, $input );
+
+ if ( $output !== false ) {
+ if ( $this->generateXML && strlen( $output ) > 0 ) {
+ $out->addHTML( $this->makeOutput( $xml, 'expand_templates_xml_output' ) );
+ }
+
+ $tmp = $this->makeOutput( $output );
+
+ if ( $this->removeNowiki ) {
+ $tmp = preg_replace(
+ [ '_&lt;nowiki&gt;_', '_&lt;/nowiki&gt;_', '_&lt;nowiki */&gt;_' ],
+ '',
+ $tmp
+ );
+ }
+
+ $config = $this->getConfig();
+ if ( $config->get( 'UseTidy' ) && $options->getTidy() ) {
+ $tmp = MWTidy::tidy( $tmp );
+ }
+
+ $out->addHTML( $tmp );
+
+ $pout = $this->generateHtml( $title, $output );
+ $rawhtml = $pout->getText();
+ if ( $this->generateRawHtml && strlen( $rawhtml ) > 0 ) {
+ $out->addHTML( $this->makeOutput( $rawhtml, 'expand_templates_html_output' ) );
+ }
+
+ $this->showHtmlPreview( $title, $pout, $out );
+ }
+ }
+
+ /**
+ * Callback for the HTMLForm used in self::makeForm.
+ * Checks, if the input was given, and if not, returns a fatal Status
+ * object with an error message.
+ *
+ * @param array $values The values submitted to the HTMLForm
+ * @return Status
+ */
+ public function onSubmitInput( array $values ) {
+ $status = Status::newGood();
+ if ( !strlen( $values['input'] ) ) {
+ $status = Status::newFatal( 'expand_templates_input_missing' );
+ }
+ return $status;
+ }
+
+ /**
+ * Generate a form allowing users to enter information
+ *
+ * @param string $title Value for context title field
+ * @param string $input Value for input textbox
+ * @return string
+ */
+ private function makeForm( $title, $input ) {
+ $fields = [
+ 'contexttitle' => [
+ 'type' => 'text',
+ 'label' => $this->msg( 'expand_templates_title' )->plain(),
+ 'name' => 'wpContextTitle',
+ 'id' => 'contexttitle',
+ 'size' => 60,
+ 'default' => $title,
+ 'autofocus' => true,
+ 'cssclass' => 'mw-ui-input-inline',
+ ],
+ 'input' => [
+ 'type' => 'textarea',
+ 'name' => 'wpInput',
+ 'label' => $this->msg( 'expand_templates_input' )->text(),
+ 'rows' => 10,
+ 'default' => $input,
+ 'id' => 'input',
+ ],
+ 'removecomments' => [
+ 'type' => 'check',
+ 'label' => $this->msg( 'expand_templates_remove_comments' )->text(),
+ 'name' => 'wpRemoveComments',
+ 'id' => 'removecomments',
+ 'default' => $this->removeComments,
+ ],
+ 'removenowiki' => [
+ 'type' => 'check',
+ 'label' => $this->msg( 'expand_templates_remove_nowiki' )->text(),
+ 'name' => 'wpRemoveNowiki',
+ 'id' => 'removenowiki',
+ 'default' => $this->removeNowiki,
+ ],
+ 'generate_xml' => [
+ 'type' => 'check',
+ 'label' => $this->msg( 'expand_templates_generate_xml' )->text(),
+ 'name' => 'wpGenerateXml',
+ 'id' => 'generate_xml',
+ 'default' => $this->generateXML,
+ ],
+ 'generate_rawhtml' => [
+ 'type' => 'check',
+ 'label' => $this->msg( 'expand_templates_generate_rawhtml' )->text(),
+ 'name' => 'wpGenerateRawHtml',
+ 'id' => 'generate_rawhtml',
+ 'default' => $this->generateRawHtml,
+ ],
+ ];
+
+ $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
+ $form
+ ->setSubmitTextMsg( 'expand_templates_ok' )
+ ->setWrapperLegendMsg( 'expandtemplates' )
+ ->setHeaderText( $this->msg( 'expand_templates_intro' )->parse() )
+ ->setSubmitCallback( [ $this, 'onSubmitInput' ] )
+ ->showAlways();
+ }
+
+ /**
+ * Generate a nice little box with a heading for output
+ *
+ * @param string $output Wiki text output
+ * @param string $heading
+ * @return string
+ */
+ private function makeOutput( $output, $heading = 'expand_templates_output' ) {
+ $out = "<h2>" . $this->msg( $heading )->escaped() . "</h2>\n";
+ $out .= Xml::textarea(
+ 'output',
+ $output,
+ 10,
+ 10,
+ [ 'id' => 'output', 'readonly' => 'readonly' ]
+ );
+
+ return $out;
+ }
+
+ /**
+ * Renders the supplied wikitext as html
+ *
+ * @param Title $title
+ * @param string $text
+ * @return ParserOutput
+ */
+ private function generateHtml( Title $title, $text ) {
+ global $wgParser;
+
+ $popts = ParserOptions::newFromContext( $this->getContext() );
+ $popts->setTargetLanguage( $title->getPageLanguage() );
+ return $wgParser->parse( $text, $title, $popts );
+ }
+
+ /**
+ * Wraps the provided html code in a div and outputs it to the page
+ *
+ * @param Title $title
+ * @param ParserOutput $pout
+ * @param OutputPage $out
+ */
+ private function showHtmlPreview( Title $title, ParserOutput $pout, OutputPage $out ) {
+ $lang = $title->getPageViewLanguage();
+ $out->addHTML( "<h2>" . $this->msg( 'expand_templates_preview' )->escaped() . "</h2>\n" );
+
+ if ( $this->getConfig()->get( 'RawHtml' ) ) {
+ $request = $this->getRequest();
+ $user = $this->getUser();
+
+ // To prevent cross-site scripting attacks, don't show the preview if raw HTML is
+ // allowed and a valid edit token is not provided (T73111). However, MediaWiki
+ // does not currently provide logged-out users with CSRF protection; in that case,
+ // do not show the preview unless anonymous editing is allowed.
+ if ( $user->isAnon() && !$user->isAllowed( 'edit' ) ) {
+ $error = [ 'expand_templates_preview_fail_html_anon' ];
+ } elseif ( !$user->matchEditToken( $request->getVal( 'wpEditToken' ), '', $request ) ) {
+ $error = [ 'expand_templates_preview_fail_html' ];
+ } else {
+ $error = false;
+ }
+
+ if ( $error ) {
+ $out->wrapWikiMsg( "<div class='previewnote'>\n$1\n</div>", $error );
+ return;
+ }
+ }
+
+ $out->addHTML( Html::openElement( 'div', [
+ 'class' => 'mw-content-' . $lang->getDir(),
+ 'dir' => $lang->getDir(),
+ 'lang' => $lang->getHtmlCode(),
+ ] ) );
+ $out->addParserOutputContent( $pout );
+ $out->addHTML( Html::closeElement( 'div' ) );
+ $out->setCategoryLinks( $pout->getCategories() );
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialExport.php b/www/wiki/includes/specials/SpecialExport.php
new file mode 100644
index 00000000..8e6c4462
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialExport.php
@@ -0,0 +1,595 @@
+<?php
+/**
+ * Implements Special:Export
+ *
+ * Copyright © 2003-2008 Brion Vibber <brion@pobox.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Mediawiki\MediaWikiServices;
+
+/**
+ * A special page that allows users to export pages in a XML file
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialExport extends SpecialPage {
+ private $curonly, $doExport, $pageLinkDepth, $templates;
+
+ public function __construct() {
+ parent::__construct( 'Export' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $config = $this->getConfig();
+
+ // Set some variables
+ $this->curonly = true;
+ $this->doExport = false;
+ $request = $this->getRequest();
+ $this->templates = $request->getCheck( 'templates' );
+ $this->pageLinkDepth = $this->validateLinkDepth(
+ $request->getIntOrNull( 'pagelink-depth' )
+ );
+ $nsindex = '';
+ $exportall = false;
+
+ if ( $request->getCheck( 'addcat' ) ) {
+ $page = $request->getText( 'pages' );
+ $catname = $request->getText( 'catname' );
+
+ if ( $catname !== '' && $catname !== null && $catname !== false ) {
+ $t = Title::makeTitleSafe( NS_MAIN, $catname );
+ if ( $t ) {
+ /**
+ * @todo FIXME: This can lead to hitting memory limit for very large
+ * categories. Ideally we would do the lookup synchronously
+ * during the export in a single query.
+ */
+ $catpages = $this->getPagesFromCategory( $t );
+ if ( $catpages ) {
+ if ( $page !== '' ) {
+ $page .= "\n";
+ }
+ $page .= implode( "\n", $catpages );
+ }
+ }
+ }
+ } elseif ( $request->getCheck( 'addns' ) && $config->get( 'ExportFromNamespaces' ) ) {
+ $page = $request->getText( 'pages' );
+ $nsindex = $request->getText( 'nsindex', '' );
+
+ if ( strval( $nsindex ) !== '' ) {
+ /**
+ * Same implementation as above, so same @todo
+ */
+ $nspages = $this->getPagesFromNamespace( $nsindex );
+ if ( $nspages ) {
+ $page .= "\n" . implode( "\n", $nspages );
+ }
+ }
+ } elseif ( $request->getCheck( 'exportall' ) && $config->get( 'ExportAllowAll' ) ) {
+ $this->doExport = true;
+ $exportall = true;
+
+ /* Although $page and $history are not used later on, we
+ nevertheless set them to avoid that PHP notices about using
+ undefined variables foul up our XML output (see call to
+ doExport(...) further down) */
+ $page = '';
+ $history = '';
+ } elseif ( $request->wasPosted() && $par == '' ) {
+ $page = $request->getText( 'pages' );
+ $this->curonly = $request->getCheck( 'curonly' );
+ $rawOffset = $request->getVal( 'offset' );
+
+ if ( $rawOffset ) {
+ $offset = wfTimestamp( TS_MW, $rawOffset );
+ } else {
+ $offset = null;
+ }
+
+ $maxHistory = $config->get( 'ExportMaxHistory' );
+ $limit = $request->getInt( 'limit' );
+ $dir = $request->getVal( 'dir' );
+ $history = [
+ 'dir' => 'asc',
+ 'offset' => false,
+ 'limit' => $maxHistory,
+ ];
+ $historyCheck = $request->getCheck( 'history' );
+
+ if ( $this->curonly ) {
+ $history = WikiExporter::CURRENT;
+ } elseif ( !$historyCheck ) {
+ if ( $limit > 0 && ( $maxHistory == 0 || $limit < $maxHistory ) ) {
+ $history['limit'] = $limit;
+ }
+
+ if ( !is_null( $offset ) ) {
+ $history['offset'] = $offset;
+ }
+
+ if ( strtolower( $dir ) == 'desc' ) {
+ $history['dir'] = 'desc';
+ }
+ }
+
+ if ( $page != '' ) {
+ $this->doExport = true;
+ }
+ } else {
+ // Default to current-only for GET requests.
+ $page = $request->getText( 'pages', $par );
+ $historyCheck = $request->getCheck( 'history' );
+
+ if ( $historyCheck ) {
+ $history = WikiExporter::FULL;
+ } else {
+ $history = WikiExporter::CURRENT;
+ }
+
+ if ( $page != '' ) {
+ $this->doExport = true;
+ }
+ }
+
+ if ( !$config->get( 'ExportAllowHistory' ) ) {
+ // Override
+ $history = WikiExporter::CURRENT;
+ }
+
+ $list_authors = $request->getCheck( 'listauthors' );
+ if ( !$this->curonly || !$config->get( 'ExportAllowListContributors' ) ) {
+ $list_authors = false;
+ }
+
+ if ( $this->doExport ) {
+ $this->getOutput()->disable();
+
+ // Cancel output buffering and gzipping if set
+ // This should provide safer streaming for pages with history
+ wfResetOutputBuffers();
+ $request->response()->header( "Content-type: application/xml; charset=utf-8" );
+ $request->response()->header( "X-Robots-Tag: noindex,nofollow" );
+
+ if ( $request->getCheck( 'wpDownload' ) ) {
+ // Provide a sane filename suggestion
+ $filename = urlencode( $config->get( 'Sitename' ) . '-' . wfTimestampNow() . '.xml' );
+ $request->response()->header( "Content-disposition: attachment;filename={$filename}" );
+ }
+
+ $this->doExport( $page, $history, $list_authors, $exportall );
+
+ return;
+ }
+
+ $out = $this->getOutput();
+ $out->addWikiMsg( 'exporttext' );
+
+ if ( $page == '' ) {
+ $categoryName = $request->getText( 'catname' );
+ } else {
+ $categoryName = '';
+ }
+
+ $formDescriptor = [
+ 'catname' => [
+ 'type' => 'textwithbutton',
+ 'name' => 'catname',
+ 'horizontal-label' => true,
+ 'label-message' => 'export-addcattext',
+ 'default' => $categoryName,
+ 'size' => 40,
+ 'buttontype' => 'submit',
+ 'buttonname' => 'addcat',
+ 'buttondefault' => $this->msg( 'export-addcat' )->text(),
+ 'hide-if' => [ '===', 'exportall', '1' ],
+ ],
+ ];
+ if ( $config->get( 'ExportFromNamespaces' ) ) {
+ $formDescriptor += [
+ 'nsindex' => [
+ 'type' => 'namespaceselectwithbutton',
+ 'default' => $nsindex,
+ 'label-message' => 'export-addnstext',
+ 'horizontal-label' => true,
+ 'name' => 'nsindex',
+ 'id' => 'namespace',
+ 'cssclass' => 'namespaceselector',
+ 'buttontype' => 'submit',
+ 'buttonname' => 'addns',
+ 'buttondefault' => $this->msg( 'export-addns' )->text(),
+ 'hide-if' => [ '===', 'exportall', '1' ],
+ ],
+ ];
+ }
+
+ if ( $config->get( 'ExportAllowAll' ) ) {
+ $formDescriptor += [
+ 'exportall' => [
+ 'type' => 'check',
+ 'label-message' => 'exportall',
+ 'name' => 'exportall',
+ 'id' => 'exportall',
+ 'default' => $request->wasPosted() ? $request->getCheck( 'exportall' ) : false,
+ ],
+ ];
+ }
+
+ $formDescriptor += [
+ 'textarea' => [
+ 'class' => 'HTMLTextAreaField',
+ 'name' => 'pages',
+ 'label-message' => 'export-manual',
+ 'nodata' => true,
+ 'rows' => 10,
+ 'default' => $page,
+ 'hide-if' => [ '===', 'exportall', '1' ],
+ ],
+ ];
+
+ if ( $config->get( 'ExportAllowHistory' ) ) {
+ $formDescriptor += [
+ 'curonly' => [
+ 'type' => 'check',
+ 'label-message' => 'exportcuronly',
+ 'name' => 'curonly',
+ 'id' => 'curonly',
+ 'default' => $request->wasPosted() ? $request->getCheck( 'curonly' ) : true,
+ ],
+ ];
+ } else {
+ $out->addWikiMsg( 'exportnohistory' );
+ }
+
+ $formDescriptor += [
+ 'templates' => [
+ 'type' => 'check',
+ 'label-message' => 'export-templates',
+ 'name' => 'templates',
+ 'id' => 'wpExportTemplates',
+ 'default' => $request->wasPosted() ? $request->getCheck( 'templates' ) : false,
+ ],
+ ];
+
+ if ( $config->get( 'ExportMaxLinkDepth' ) || $this->userCanOverrideExportDepth() ) {
+ $formDescriptor += [
+ 'pagelink-depth' => [
+ 'type' => 'text',
+ 'name' => 'pagelink-depth',
+ 'id' => 'pagelink-depth',
+ 'label-message' => 'export-pagelinks',
+ 'default' => '0',
+ 'size' => 20,
+ ],
+ ];
+ }
+
+ $formDescriptor += [
+ 'wpDownload' => [
+ 'type' => 'check',
+ 'name' => 'wpDownload',
+ 'id' => 'wpDownload',
+ 'default' => $request->wasPosted() ? $request->getCheck( 'wpDownload' ) : true,
+ 'label-message' => 'export-download',
+ ],
+ ];
+
+ if ( $config->get( 'ExportAllowListContributors' ) ) {
+ $formDescriptor += [
+ 'listauthors' => [
+ 'type' => 'check',
+ 'label-message' => 'exportlistauthors',
+ 'default' => $request->wasPosted() ? $request->getCheck( 'listauthors' ) : false,
+ 'name' => 'listauthors',
+ 'id' => 'listauthors',
+ ],
+ ];
+ }
+
+ $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
+ $htmlForm->setSubmitTextMsg( 'export-submit' );
+ $htmlForm->prepareForm()->displayForm( false );
+ $this->addHelpLink( 'Help:Export' );
+ }
+
+ /**
+ * @return bool
+ */
+ private function userCanOverrideExportDepth() {
+ return $this->getUser()->isAllowed( 'override-export-depth' );
+ }
+
+ /**
+ * Do the actual page exporting
+ *
+ * @param string $page User input on what page(s) to export
+ * @param int $history One of the WikiExporter history export constants
+ * @param bool $list_authors Whether to add distinct author list (when
+ * not returning full history)
+ * @param bool $exportall Whether to export everything
+ */
+ private function doExport( $page, $history, $list_authors, $exportall ) {
+ // If we are grabbing everything, enable full history and ignore the rest
+ if ( $exportall ) {
+ $history = WikiExporter::FULL;
+ } else {
+ $pageSet = []; // Inverted index of all pages to look up
+
+ // Split up and normalize input
+ foreach ( explode( "\n", $page ) as $pageName ) {
+ $pageName = trim( $pageName );
+ $title = Title::newFromText( $pageName );
+ if ( $title && !$title->isExternal() && $title->getText() !== '' ) {
+ // Only record each page once!
+ $pageSet[$title->getPrefixedText()] = true;
+ }
+ }
+
+ // Set of original pages to pass on to further manipulation...
+ $inputPages = array_keys( $pageSet );
+
+ // Look up any linked pages if asked...
+ if ( $this->templates ) {
+ $pageSet = $this->getTemplates( $inputPages, $pageSet );
+ }
+ $linkDepth = $this->pageLinkDepth;
+ if ( $linkDepth ) {
+ $pageSet = $this->getPageLinks( $inputPages, $pageSet, $linkDepth );
+ }
+
+ $pages = array_keys( $pageSet );
+
+ // Normalize titles to the same format and remove dupes, see T19374
+ foreach ( $pages as $k => $v ) {
+ $pages[$k] = str_replace( " ", "_", $v );
+ }
+
+ $pages = array_unique( $pages );
+ }
+
+ /* Ok, let's get to it... */
+ if ( $history == WikiExporter::CURRENT ) {
+ $lb = false;
+ $db = wfGetDB( DB_REPLICA );
+ $buffer = WikiExporter::BUFFER;
+ } else {
+ // Use an unbuffered query; histories may be very long!
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->newMainLB();
+ $db = $lb->getConnection( DB_REPLICA );
+ $buffer = WikiExporter::STREAM;
+
+ // This might take a while... :D
+ MediaWiki\suppressWarnings();
+ set_time_limit( 0 );
+ MediaWiki\restoreWarnings();
+ }
+
+ $exporter = new WikiExporter( $db, $history, $buffer );
+ $exporter->list_authors = $list_authors;
+ $exporter->openStream();
+
+ if ( $exportall ) {
+ $exporter->allPages();
+ } else {
+ foreach ( $pages as $page ) {
+ # T10824: Only export pages the user can read
+ $title = Title::newFromText( $page );
+ if ( is_null( $title ) ) {
+ // @todo Perhaps output an <error> tag or something.
+ continue;
+ }
+
+ if ( !$title->userCan( 'read', $this->getUser() ) ) {
+ // @todo Perhaps output an <error> tag or something.
+ continue;
+ }
+
+ $exporter->pageByTitle( $title );
+ }
+ }
+
+ $exporter->closeStream();
+
+ if ( $lb ) {
+ $lb->closeAll();
+ }
+ }
+
+ /**
+ * @param Title $title
+ * @return array
+ */
+ private function getPagesFromCategory( $title ) {
+ global $wgContLang;
+
+ $maxPages = $this->getConfig()->get( 'ExportPagelistLimit' );
+
+ $name = $title->getDBkey();
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select(
+ [ 'page', 'categorylinks' ],
+ [ 'page_namespace', 'page_title' ],
+ [ 'cl_from=page_id', 'cl_to' => $name ],
+ __METHOD__,
+ [ 'LIMIT' => $maxPages ]
+ );
+
+ $pages = [];
+
+ foreach ( $res as $row ) {
+ $n = $row->page_title;
+ if ( $row->page_namespace ) {
+ $ns = $wgContLang->getNsText( $row->page_namespace );
+ $n = $ns . ':' . $n;
+ }
+
+ $pages[] = $n;
+ }
+
+ return $pages;
+ }
+
+ /**
+ * @param int $nsindex
+ * @return array
+ */
+ private function getPagesFromNamespace( $nsindex ) {
+ global $wgContLang;
+
+ $maxPages = $this->getConfig()->get( 'ExportPagelistLimit' );
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select(
+ 'page',
+ [ 'page_namespace', 'page_title' ],
+ [ 'page_namespace' => $nsindex ],
+ __METHOD__,
+ [ 'LIMIT' => $maxPages ]
+ );
+
+ $pages = [];
+
+ foreach ( $res as $row ) {
+ $n = $row->page_title;
+
+ if ( $row->page_namespace ) {
+ $ns = $wgContLang->getNsText( $row->page_namespace );
+ $n = $ns . ':' . $n;
+ }
+
+ $pages[] = $n;
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Expand a list of pages to include templates used in those pages.
+ * @param array $inputPages List of titles to look up
+ * @param array $pageSet Associative array indexed by titles for output
+ * @return array Associative array index by titles
+ */
+ private function getTemplates( $inputPages, $pageSet ) {
+ return $this->getLinks( $inputPages, $pageSet,
+ 'templatelinks',
+ [ 'namespace' => 'tl_namespace', 'title' => 'tl_title' ],
+ [ 'page_id=tl_from' ]
+ );
+ }
+
+ /**
+ * Validate link depth setting, if available.
+ * @param int $depth
+ * @return int
+ */
+ private function validateLinkDepth( $depth ) {
+ if ( $depth < 0 ) {
+ return 0;
+ }
+
+ if ( !$this->userCanOverrideExportDepth() ) {
+ $maxLinkDepth = $this->getConfig()->get( 'ExportMaxLinkDepth' );
+ if ( $depth > $maxLinkDepth ) {
+ return $maxLinkDepth;
+ }
+ }
+
+ /*
+ * There's a HARD CODED limit of 5 levels of recursion here to prevent a
+ * crazy-big export from being done by someone setting the depth
+ * number too high. In other words, last resort safety net.
+ */
+
+ return intval( min( $depth, 5 ) );
+ }
+
+ /**
+ * Expand a list of pages to include pages linked to from that page.
+ * @param array $inputPages
+ * @param array $pageSet
+ * @param int $depth
+ * @return array
+ */
+ private function getPageLinks( $inputPages, $pageSet, $depth ) {
+ // @codingStandardsIgnoreStart Squiz.WhiteSpace.SemicolonSpacing.Incorrect
+ for ( ; $depth > 0; --$depth ) {
+ // @codingStandardsIgnoreEnd
+ $pageSet = $this->getLinks(
+ $inputPages, $pageSet, 'pagelinks',
+ [ 'namespace' => 'pl_namespace', 'title' => 'pl_title' ],
+ [ 'page_id=pl_from' ]
+ );
+ $inputPages = array_keys( $pageSet );
+ }
+
+ return $pageSet;
+ }
+
+ /**
+ * Expand a list of pages to include items used in those pages.
+ * @param array $inputPages Array of page titles
+ * @param array $pageSet
+ * @param string $table
+ * @param array $fields Array of field names
+ * @param array $join
+ * @return array
+ */
+ private function getLinks( $inputPages, $pageSet, $table, $fields, $join ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ foreach ( $inputPages as $page ) {
+ $title = Title::newFromText( $page );
+
+ if ( $title ) {
+ $pageSet[$title->getPrefixedText()] = true;
+ /// @todo FIXME: May or may not be more efficient to batch these
+ /// by namespace when given multiple input pages.
+ $result = $dbr->select(
+ [ 'page', $table ],
+ $fields,
+ array_merge(
+ $join,
+ [
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey()
+ ]
+ ),
+ __METHOD__
+ );
+
+ foreach ( $result as $row ) {
+ $template = Title::makeTitle( $row->namespace, $row->title );
+ $pageSet[$template->getPrefixedText()] = true;
+ }
+ }
+ }
+
+ return $pageSet;
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialFewestrevisions.php b/www/wiki/includes/specials/SpecialFewestrevisions.php
new file mode 100644
index 00000000..f20829fd
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialFewestrevisions.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Implements Special:Fewestrevisions
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page for listing the articles with the fewest revisions.
+ *
+ * @ingroup SpecialPage
+ * @author Martin Drashkov
+ */
+class FewestrevisionsPage extends QueryPage {
+ function __construct( $name = 'Fewestrevisions' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'revision', 'page' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'COUNT(*)',
+ 'redirect' => 'page_is_redirect'
+ ],
+ 'conds' => [
+ 'page_namespace' => MWNamespace::getContentNamespaces(),
+ 'page_id = rev_page' ],
+ 'options' => [
+ 'GROUP BY' => [ 'page_namespace', 'page_title', 'page_is_redirect' ]
+ ]
+ ];
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Database row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ global $wgContLang;
+
+ $nt = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( !$nt ) {
+ return Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ $result->namespace,
+ $result->title
+ )
+ );
+ }
+ $linkRenderer = $this->getLinkRenderer();
+ $text = $wgContLang->convert( $nt->getPrefixedText() );
+ $plink = $linkRenderer->makeLink( $nt, $text );
+
+ $nl = $this->msg( 'nrevisions' )->numParams( $result->value )->text();
+ $redirect = isset( $result->redirect ) && $result->redirect ?
+ ' - ' . $this->msg( 'isredirect' )->escaped() : '';
+ $nlink = $linkRenderer->makeKnownLink(
+ $nt,
+ $nl,
+ [],
+ [ 'action' => 'history' ]
+ ) . $redirect;
+
+ return $this->getLanguage()->specialList( $plink, $nlink );
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialFileDuplicateSearch.php b/www/wiki/includes/specials/SpecialFileDuplicateSearch.php
new file mode 100644
index 00000000..8021bc2c
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialFileDuplicateSearch.php
@@ -0,0 +1,265 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Implements Special:FileDuplicateSearch
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Raimond Spekking, based on Special:MIMESearch by Ævar Arnfjörð Bjarmason
+ */
+
+/**
+ * Searches the database for files of the requested hash, comparing this with the
+ * 'img_sha1' field in the image table.
+ *
+ * @ingroup SpecialPage
+ */
+class FileDuplicateSearchPage extends QueryPage {
+ protected $hash = '', $filename = '';
+
+ /**
+ * @var File $file selected reference file, if present
+ */
+ protected $file = null;
+
+ function __construct( $name = 'FileDuplicateSearch' ) {
+ parent::__construct( $name );
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function isCacheable() {
+ return false;
+ }
+
+ public function isCached() {
+ return false;
+ }
+
+ function linkParameters() {
+ return [ 'filename' => $this->filename ];
+ }
+
+ /**
+ * Fetch dupes from all connected file repositories.
+ *
+ * @return array Array of File objects
+ */
+ function getDupes() {
+ return RepoGroup::singleton()->findBySha1( $this->hash );
+ }
+
+ /**
+ *
+ * @param array $dupes Array of File objects
+ */
+ function showList( $dupes ) {
+ $html = [];
+ $html[] = $this->openList( 0 );
+
+ foreach ( $dupes as $dupe ) {
+ $line = $this->formatResult( null, $dupe );
+ $html[] = "<li>" . $line . "</li>";
+ }
+ $html[] = $this->closeList();
+
+ $this->getOutput()->addHTML( implode( "\n", $html ) );
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'image' ],
+ 'fields' => [
+ 'title' => 'img_name',
+ 'value' => 'img_sha1',
+ 'img_user_text',
+ 'img_timestamp'
+ ],
+ 'conds' => [ 'img_sha1' => $this->hash ]
+ ];
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $this->filename = $par !== null ? $par : $this->getRequest()->getText( 'filename' );
+ $this->file = null;
+ $this->hash = '';
+ $title = Title::newFromText( $this->filename, NS_FILE );
+ if ( $title && $title->getText() != '' ) {
+ $this->file = wfFindFile( $title );
+ }
+
+ $out = $this->getOutput();
+
+ # Create the input form
+ $formFields = [
+ 'filename' => [
+ 'type' => 'text',
+ 'name' => 'filename',
+ 'label-message' => 'fileduplicatesearch-filename',
+ 'id' => 'filename',
+ 'size' => 50,
+ 'value' => $this->filename,
+ ],
+ ];
+ $hiddenFields = [
+ 'title' => $this->getPageTitle()->getPrefixedDBkey(),
+ ];
+ $htmlForm = HTMLForm::factory( 'ooui', $formFields, $this->getContext() );
+ $htmlForm->addHiddenFields( $hiddenFields );
+ $htmlForm->setAction( wfScript() );
+ $htmlForm->setMethod( 'get' );
+ $htmlForm->setSubmitProgressive();
+ $htmlForm->setSubmitTextMsg( $this->msg( 'fileduplicatesearch-submit' ) );
+
+ // The form should be visible always, even if it was submitted (e.g. to perform another action).
+ // To bypass the callback validation of HTMLForm, use prepareForm() and displayForm().
+ $htmlForm->prepareForm()->displayForm( false );
+
+ if ( $this->file ) {
+ $this->hash = $this->file->getSha1();
+ } elseif ( $this->filename !== '' ) {
+ $out->wrapWikiMsg(
+ "<p class='mw-fileduplicatesearch-noresults'>\n$1\n</p>",
+ [ 'fileduplicatesearch-noresults', wfEscapeWikiText( $this->filename ) ]
+ );
+ }
+
+ if ( $this->hash != '' ) {
+ # Show a thumbnail of the file
+ $img = $this->file;
+ if ( $img ) {
+ $thumb = $img->transform( [ 'width' => 120, 'height' => 120 ] );
+ if ( $thumb ) {
+ $out->addModuleStyles( 'mediawiki.special' );
+ $out->addHTML( '<div id="mw-fileduplicatesearch-icon">' .
+ $thumb->toHtml( [ 'desc-link' => false ] ) . '<br />' .
+ $this->msg( 'fileduplicatesearch-info' )->numParams(
+ $img->getWidth(), $img->getHeight() )->params(
+ $this->getLanguage()->formatSize( $img->getSize() ),
+ $img->getMimeType() )->parseAsBlock() .
+ '</div>' );
+ }
+ }
+
+ $dupes = $this->getDupes();
+ $numRows = count( $dupes );
+
+ # Show a short summary
+ if ( $numRows == 1 ) {
+ $out->wrapWikiMsg(
+ "<p class='mw-fileduplicatesearch-result-1'>\n$1\n</p>",
+ [ 'fileduplicatesearch-result-1', wfEscapeWikiText( $this->filename ) ]
+ );
+ } elseif ( $numRows ) {
+ $out->wrapWikiMsg(
+ "<p class='mw-fileduplicatesearch-result-n'>\n$1\n</p>",
+ [ 'fileduplicatesearch-result-n', wfEscapeWikiText( $this->filename ),
+ $this->getLanguage()->formatNum( $numRows - 1 ) ]
+ );
+ }
+
+ $this->doBatchLookups( $dupes );
+ $this->showList( $dupes );
+ }
+ }
+
+ function doBatchLookups( $list ) {
+ $batch = new LinkBatch();
+ /** @var File $file */
+ foreach ( $list as $file ) {
+ $batch->addObj( $file->getTitle() );
+ if ( $file->isLocal() ) {
+ $userName = $file->getUser( 'text' );
+ $batch->add( NS_USER, $userName );
+ $batch->add( NS_USER_TALK, $userName );
+ }
+ }
+
+ $batch->execute();
+ }
+
+ /**
+ *
+ * @param Skin $skin
+ * @param File $result
+ * @return string HTML
+ */
+ function formatResult( $skin, $result ) {
+ global $wgContLang;
+
+ $linkRenderer = $this->getLinkRenderer();
+ $nt = $result->getTitle();
+ $text = $wgContLang->convert( $nt->getText() );
+ $plink = $linkRenderer->makeLink(
+ $nt,
+ $text
+ );
+
+ $userText = $result->getUser( 'text' );
+ if ( $result->isLocal() ) {
+ $userId = $result->getUser( 'id' );
+ $user = Linker::userLink( $userId, $userText );
+ $user .= '<span style="white-space: nowrap;">';
+ $user .= Linker::userToolLinks( $userId, $userText );
+ $user .= '</span>';
+ } else {
+ $user = htmlspecialchars( $userText );
+ }
+
+ $time = htmlspecialchars( $this->getLanguage()->userTimeAndDate(
+ $result->getTimestamp(), $this->getUser() ) );
+
+ return "$plink . . $user . . $time";
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $title = Title::newFromText( $search, NS_FILE );
+ if ( !$title || $title->getNamespace() !== NS_FILE ) {
+ // No prefix suggestion outside of file namespace
+ return [];
+ }
+ $searchEngine = MediaWikiServices::getInstance()->newSearchEngine();
+ $searchEngine->setLimitOffset( $limit, $offset );
+ // Autocomplete subpage the same as a normal search, but just for files
+ $searchEngine->setNamespaces( [ NS_FILE ] );
+ $result = $searchEngine->defaultPrefixSearch( $search );
+
+ return array_map( function ( Title $t ) {
+ // Remove namespace in search suggestion
+ return $t->getText();
+ }, $result );
+ }
+
+ protected function getGroupName() {
+ return 'media';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialFilepath.php b/www/wiki/includes/specials/SpecialFilepath.php
new file mode 100644
index 00000000..c18faa12
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialFilepath.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * Implements Special:Filepath
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that redirects to the URL of a given file
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialFilepath extends RedirectSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Filepath' );
+ $this->mAllowedRedirectParams = [ 'width', 'height' ];
+ }
+
+ /**
+ * Implement by redirecting through Special:Redirect/file.
+ *
+ * @param string|null $par
+ * @return Title
+ */
+ public function getRedirect( $par ) {
+ $file = $par ?: $this->getRequest()->getText( 'file' );
+
+ if ( $file ) {
+ $argument = "file/$file";
+ } else {
+ $argument = 'file';
+ }
+ return SpecialPage::getSafeTitleFor( 'Redirect', $argument );
+ }
+
+ protected function getGroupName() {
+ return 'media';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialGoToInterwiki.php b/www/wiki/includes/specials/SpecialGoToInterwiki.php
new file mode 100644
index 00000000..809a14aa
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialGoToInterwiki.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * Implements Special:GoToInterwiki
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Landing page for non-local interwiki links.
+ *
+ * Meant to warn people that the site they're visiting
+ * is not the local wiki (In case of phishing tricks).
+ * Only meant to be used for things that directly
+ * redirect from url (e.g. Special:Search/google:foo )
+ * Not meant for general interwiki linking (e.g.
+ * [[google:foo]] should still directly link)
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialGoToInterwiki extends UnlistedSpecialPage {
+ public function __construct( $name = 'GoToInterwiki' ) {
+ parent::__construct( $name );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $target = Title::newFromText( $par );
+ // Disallow special pages as a precaution against
+ // possible redirect loops.
+ if ( !$target || $target->isSpecialPage() ) {
+ $this->getOutput()->setStatusCode( 404 );
+ $this->getOutput()->addWikiMsg( 'gotointerwiki-invalid' );
+ return;
+ }
+
+ $url = $target->getFullURL();
+ if ( !$target->isExternal() || $target->isLocal() ) {
+ // Either a normal page, or a local interwiki.
+ // just redirect.
+ $this->getOutput()->redirect( $url, '301' );
+ } else {
+ $this->getOutput()->addWikiMsg(
+ 'gotointerwiki-external',
+ $url,
+ $target->getFullText()
+ );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function requiresWrite() {
+ return false;
+ }
+
+ /**
+ * @return String
+ */
+ protected function getGroupName() {
+ return 'redirects';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialImport.php b/www/wiki/includes/specials/SpecialImport.php
new file mode 100644
index 00000000..9ce52ef0
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialImport.php
@@ -0,0 +1,524 @@
+<?php
+/**
+ * Implements Special:Import
+ *
+ * Copyright © 2003,2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * MediaWiki page data importer
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialImport extends SpecialPage {
+ private $sourceName = false;
+ private $interwiki = false;
+ private $subproject;
+ private $fullInterwikiPrefix;
+ private $mapping = 'default';
+ private $namespace;
+ private $rootpage = '';
+ private $frompage = '';
+ private $logcomment = false;
+ private $history = true;
+ private $includeTemplates = false;
+ private $pageLinkDepth;
+ private $importSources;
+
+ public function __construct() {
+ parent::__construct( 'Import', 'import' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * Execute
+ * @param string|null $par
+ * @throws PermissionsError
+ * @throws ReadOnlyError
+ */
+ function execute( $par ) {
+ $this->useTransactionalTimeLimit();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $this->namespace = $this->getConfig()->get( 'ImportTargetNamespace' );
+
+ $this->getOutput()->addModules( 'mediawiki.special.import' );
+
+ $this->importSources = $this->getConfig()->get( 'ImportSources' );
+ Hooks::run( 'ImportSources', [ &$this->importSources ] );
+
+ $user = $this->getUser();
+ if ( !$user->isAllowedAny( 'import', 'importupload' ) ) {
+ throw new PermissionsError( 'import' );
+ }
+
+ # @todo Allow Title::getUserPermissionsErrors() to take an array
+ # @todo FIXME: Title::checkSpecialsAndNSPermissions() has a very wierd expectation of what
+ # getUserPermissionsErrors() might actually be used for, hence the 'ns-specialprotected'
+ $errors = wfMergeErrorArrays(
+ $this->getPageTitle()->getUserPermissionsErrors(
+ 'import', $user, true,
+ [ 'ns-specialprotected', 'badaccess-group0', 'badaccess-groups' ]
+ ),
+ $this->getPageTitle()->getUserPermissionsErrors(
+ 'importupload', $user, true,
+ [ 'ns-specialprotected', 'badaccess-group0', 'badaccess-groups' ]
+ )
+ );
+
+ if ( $errors ) {
+ throw new PermissionsError( 'import', $errors );
+ }
+
+ $this->checkReadOnly();
+
+ $request = $this->getRequest();
+ if ( $request->wasPosted() && $request->getVal( 'action' ) == 'submit' ) {
+ $this->doImport();
+ }
+ $this->showForm();
+ }
+
+ /**
+ * Do the actual import
+ */
+ private function doImport() {
+ $isUpload = false;
+ $request = $this->getRequest();
+ $this->sourceName = $request->getVal( "source" );
+
+ $this->logcomment = $request->getText( 'log-comment' );
+ $this->pageLinkDepth = $this->getConfig()->get( 'ExportMaxLinkDepth' ) == 0
+ ? 0
+ : $request->getIntOrNull( 'pagelink-depth' );
+
+ $this->mapping = $request->getVal( 'mapping' );
+ if ( $this->mapping === 'namespace' ) {
+ $this->namespace = $request->getIntOrNull( 'namespace' );
+ } elseif ( $this->mapping === 'subpage' ) {
+ $this->rootpage = $request->getText( 'rootpage' );
+ } else {
+ $this->mapping = 'default';
+ }
+
+ $user = $this->getUser();
+ if ( !$user->matchEditToken( $request->getVal( 'editToken' ) ) ) {
+ $source = Status::newFatal( 'import-token-mismatch' );
+ } elseif ( $this->sourceName === 'upload' ) {
+ $isUpload = true;
+ if ( $user->isAllowed( 'importupload' ) ) {
+ $source = ImportStreamSource::newFromUpload( "xmlimport" );
+ } else {
+ throw new PermissionsError( 'importupload' );
+ }
+ } elseif ( $this->sourceName === 'interwiki' ) {
+ if ( !$user->isAllowed( 'import' ) ) {
+ throw new PermissionsError( 'import' );
+ }
+ $this->interwiki = $this->fullInterwikiPrefix = $request->getVal( 'interwiki' );
+ // does this interwiki have subprojects?
+ $hasSubprojects = array_key_exists( $this->interwiki, $this->importSources );
+ if ( !$hasSubprojects && !in_array( $this->interwiki, $this->importSources ) ) {
+ $source = Status::newFatal( "import-invalid-interwiki" );
+ } else {
+ if ( $hasSubprojects ) {
+ $this->subproject = $request->getVal( 'subproject' );
+ $this->fullInterwikiPrefix .= ':' . $request->getVal( 'subproject' );
+ }
+ if ( $hasSubprojects &&
+ !in_array( $this->subproject, $this->importSources[$this->interwiki] )
+ ) {
+ $source = Status::newFatal( "import-invalid-interwiki" );
+ } else {
+ $this->history = $request->getCheck( 'interwikiHistory' );
+ $this->frompage = $request->getText( "frompage" );
+ $this->includeTemplates = $request->getCheck( 'interwikiTemplates' );
+ $source = ImportStreamSource::newFromInterwiki(
+ $this->fullInterwikiPrefix,
+ $this->frompage,
+ $this->history,
+ $this->includeTemplates,
+ $this->pageLinkDepth );
+ }
+ }
+ } else {
+ $source = Status::newFatal( "importunknownsource" );
+ }
+
+ $out = $this->getOutput();
+ if ( !$source->isGood() ) {
+ $out->addWikiText( "<p class=\"error\">\n" .
+ $this->msg( 'importfailed', $source->getWikiText() )->parse() . "\n</p>" );
+ } else {
+ $importer = new WikiImporter( $source->value, $this->getConfig() );
+ if ( !is_null( $this->namespace ) ) {
+ $importer->setTargetNamespace( $this->namespace );
+ } elseif ( !is_null( $this->rootpage ) ) {
+ $statusRootPage = $importer->setTargetRootPage( $this->rootpage );
+ if ( !$statusRootPage->isGood() ) {
+ $out->wrapWikiMsg(
+ "<p class=\"error\">\n$1\n</p>",
+ [
+ 'import-options-wrong',
+ $statusRootPage->getWikiText(),
+ count( $statusRootPage->getErrorsArray() )
+ ]
+ );
+
+ return;
+ }
+ }
+
+ $out->addWikiMsg( "importstart" );
+
+ $reporter = new ImportReporter(
+ $importer,
+ $isUpload,
+ $this->fullInterwikiPrefix,
+ $this->logcomment
+ );
+ $reporter->setContext( $this->getContext() );
+ $exception = false;
+
+ $reporter->open();
+ try {
+ $importer->doImport();
+ } catch ( Exception $e ) {
+ $exception = $e;
+ }
+ $result = $reporter->close();
+
+ if ( $exception ) {
+ # No source or XML parse error
+ $out->wrapWikiMsg(
+ "<p class=\"error\">\n$1\n</p>",
+ [ 'importfailed', $exception->getMessage() ]
+ );
+ } elseif ( !$result->isGood() ) {
+ # Zero revisions
+ $out->wrapWikiMsg(
+ "<p class=\"error\">\n$1\n</p>",
+ [ 'importfailed', $result->getWikiText() ]
+ );
+ } else {
+ # Success!
+ $out->addWikiMsg( 'importsuccess' );
+ }
+ $out->addHTML( '<hr />' );
+ }
+ }
+
+ private function getMappingFormPart( $sourceName ) {
+ $isSameSourceAsBefore = ( $this->sourceName === $sourceName );
+ $defaultNamespace = $this->getConfig()->get( 'ImportTargetNamespace' );
+ return "<tr>
+ <td>
+ </td>
+ <td class='mw-input'>" .
+ Xml::radioLabel(
+ $this->msg( 'import-mapping-default' )->text(),
+ 'mapping',
+ 'default',
+ // mw-import-mapping-interwiki-default, mw-import-mapping-upload-default
+ "mw-import-mapping-$sourceName-default",
+ ( $isSameSourceAsBefore ?
+ ( $this->mapping === 'default' ) :
+ is_null( $defaultNamespace ) )
+ ) .
+ "</td>
+ </tr>
+ <tr>
+ <td>
+ </td>
+ <td class='mw-input'>" .
+ Xml::radioLabel(
+ $this->msg( 'import-mapping-namespace' )->text(),
+ 'mapping',
+ 'namespace',
+ // mw-import-mapping-interwiki-namespace, mw-import-mapping-upload-namespace
+ "mw-import-mapping-$sourceName-namespace",
+ ( $isSameSourceAsBefore ?
+ ( $this->mapping === 'namespace' ) :
+ !is_null( $defaultNamespace ) )
+ ) . ' ' .
+ Html::namespaceSelector(
+ [
+ 'selected' => ( $isSameSourceAsBefore ?
+ $this->namespace :
+ ( $defaultNamespace || '' ) ),
+ ], [
+ 'name' => "namespace",
+ // mw-import-namespace-interwiki, mw-import-namespace-upload
+ 'id' => "mw-import-namespace-$sourceName",
+ 'class' => 'namespaceselector',
+ ]
+ ) .
+ "</td>
+ </tr>
+ <tr>
+ <td>
+ </td>
+ <td class='mw-input'>" .
+ Xml::radioLabel(
+ $this->msg( 'import-mapping-subpage' )->text(),
+ 'mapping',
+ 'subpage',
+ // mw-import-mapping-interwiki-subpage, mw-import-mapping-upload-subpage
+ "mw-import-mapping-$sourceName-subpage",
+ ( $isSameSourceAsBefore ? ( $this->mapping === 'subpage' ) : '' )
+ ) . ' ' .
+ Xml::input( 'rootpage', 50,
+ ( $isSameSourceAsBefore ? $this->rootpage : '' ),
+ [
+ // Should be "mw-import-rootpage-...", but we keep this inaccurate
+ // ID for legacy reasons
+ // mw-interwiki-rootpage-interwiki, mw-interwiki-rootpage-upload
+ 'id' => "mw-interwiki-rootpage-$sourceName",
+ 'type' => 'text'
+ ]
+ ) . ' ' .
+ "</td>
+ </tr>";
+ }
+
+ private function showForm() {
+ $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] );
+ $user = $this->getUser();
+ $out = $this->getOutput();
+ $this->addHelpLink( '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Import', true );
+
+ if ( $user->isAllowed( 'importupload' ) ) {
+ $mappingSelection = $this->getMappingFormPart( 'upload' );
+ $out->addHTML(
+ Xml::fieldset( $this->msg( 'import-upload' )->text() ) .
+ Xml::openElement(
+ 'form',
+ [
+ 'enctype' => 'multipart/form-data',
+ 'method' => 'post',
+ 'action' => $action,
+ 'id' => 'mw-import-upload-form'
+ ]
+ ) .
+ $this->msg( 'importtext' )->parseAsBlock() .
+ Html::hidden( 'action', 'submit' ) .
+ Html::hidden( 'source', 'upload' ) .
+ Xml::openElement( 'table', [ 'id' => 'mw-import-table-upload' ] ) .
+ "<tr>
+ <td class='mw-label'>" .
+ Xml::label( $this->msg( 'import-upload-filename' )->text(), 'xmlimport' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Html::input( 'xmlimport', '', 'file', [ 'id' => 'xmlimport' ] ) . ' ' .
+ "</td>
+ </tr>
+ <tr>
+ <td class='mw-label'>" .
+ Xml::label( $this->msg( 'import-comment' )->text(), 'mw-import-comment' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::input( 'log-comment', 50,
+ ( $this->sourceName === 'upload' ? $this->logcomment : '' ),
+ [ 'id' => 'mw-import-comment', 'type' => 'text' ] ) . ' ' .
+ "</td>
+ </tr>
+ $mappingSelection
+ <tr>
+ <td></td>
+ <td class='mw-submit'>" .
+ Xml::submitButton( $this->msg( 'uploadbtn' )->text() ) .
+ "</td>
+ </tr>" .
+ Xml::closeElement( 'table' ) .
+ Html::hidden( 'editToken', $user->getEditToken() ) .
+ Xml::closeElement( 'form' ) .
+ Xml::closeElement( 'fieldset' )
+ );
+ } else {
+ if ( empty( $this->importSources ) ) {
+ $out->addWikiMsg( 'importnosources' );
+ }
+ }
+
+ if ( $user->isAllowed( 'import' ) && !empty( $this->importSources ) ) {
+ # Show input field for import depth only if $wgExportMaxLinkDepth > 0
+ $importDepth = '';
+ if ( $this->getConfig()->get( 'ExportMaxLinkDepth' ) > 0 ) {
+ $importDepth = "<tr>
+ <td class='mw-label'>" .
+ $this->msg( 'export-pagelinks' )->parse() .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::input( 'pagelink-depth', 3, 0 ) .
+ "</td>
+ </tr>";
+ }
+ $mappingSelection = $this->getMappingFormPart( 'interwiki' );
+
+ $out->addHTML(
+ Xml::fieldset( $this->msg( 'importinterwiki' )->text() ) .
+ Xml::openElement(
+ 'form',
+ [
+ 'method' => 'post',
+ 'action' => $action,
+ 'id' => 'mw-import-interwiki-form'
+ ]
+ ) .
+ $this->msg( 'import-interwiki-text' )->parseAsBlock() .
+ Html::hidden( 'action', 'submit' ) .
+ Html::hidden( 'source', 'interwiki' ) .
+ Html::hidden( 'editToken', $user->getEditToken() ) .
+ Xml::openElement( 'table', [ 'id' => 'mw-import-table-interwiki' ] ) .
+ "<tr>
+ <td class='mw-label'>" .
+ Xml::label( $this->msg( 'import-interwiki-sourcewiki' )->text(), 'interwiki' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::openElement(
+ 'select',
+ [ 'name' => 'interwiki', 'id' => 'interwiki' ]
+ )
+ );
+
+ $needSubprojectField = false;
+ foreach ( $this->importSources as $key => $value ) {
+ if ( is_int( $key ) ) {
+ $key = $value;
+ } elseif ( $value !== $key ) {
+ $needSubprojectField = true;
+ }
+
+ $attribs = [
+ 'value' => $key,
+ ];
+ if ( is_array( $value ) ) {
+ $attribs['data-subprojects'] = implode( ' ', $value );
+ }
+ if ( $this->interwiki === $key ) {
+ $attribs['selected'] = 'selected';
+ }
+ $out->addHTML( Html::element( 'option', $attribs, $key ) );
+ }
+
+ $out->addHTML(
+ Xml::closeElement( 'select' )
+ );
+
+ if ( $needSubprojectField ) {
+ $out->addHTML(
+ Xml::openElement(
+ 'select',
+ [ 'name' => 'subproject', 'id' => 'subproject' ]
+ )
+ );
+
+ $subprojectsToAdd = [];
+ foreach ( $this->importSources as $key => $value ) {
+ if ( is_array( $value ) ) {
+ $subprojectsToAdd = array_merge( $subprojectsToAdd, $value );
+ }
+ }
+ $subprojectsToAdd = array_unique( $subprojectsToAdd );
+ sort( $subprojectsToAdd );
+ foreach ( $subprojectsToAdd as $subproject ) {
+ $out->addHTML( Xml::option( $subproject, $subproject, $this->subproject === $subproject ) );
+ }
+
+ $out->addHTML(
+ Xml::closeElement( 'select' )
+ );
+ }
+
+ $out->addHTML(
+ "</td>
+ </tr>
+ <tr>
+ <td class='mw-label'>" .
+ Xml::label( $this->msg( 'import-interwiki-sourcepage' )->text(), 'frompage' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::input( 'frompage', 50, $this->frompage, [ 'id' => 'frompage' ] ) .
+ "</td>
+ </tr>
+ <tr>
+ <td>
+ </td>
+ <td class='mw-input'>" .
+ Xml::checkLabel(
+ $this->msg( 'import-interwiki-history' )->text(),
+ 'interwikiHistory',
+ 'interwikiHistory',
+ $this->history
+ ) .
+ "</td>
+ </tr>
+ <tr>
+ <td>
+ </td>
+ <td class='mw-input'>" .
+ Xml::checkLabel(
+ $this->msg( 'import-interwiki-templates' )->text(),
+ 'interwikiTemplates',
+ 'interwikiTemplates',
+ $this->includeTemplates
+ ) .
+ "</td>
+ </tr>
+ $importDepth
+ <tr>
+ <td class='mw-label'>" .
+ Xml::label( $this->msg( 'import-comment' )->text(), 'mw-interwiki-comment' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::input( 'log-comment', 50,
+ ( $this->sourceName === 'interwiki' ? $this->logcomment : '' ),
+ [ 'id' => 'mw-interwiki-comment', 'type' => 'text' ] ) . ' ' .
+ "</td>
+ </tr>
+ $mappingSelection
+ <tr>
+ <td>
+ </td>
+ <td class='mw-submit'>" .
+ Xml::submitButton(
+ $this->msg( 'import-interwiki-submit' )->text(),
+ Linker::tooltipAndAccesskeyAttribs( 'import' )
+ ) .
+ "</td>
+ </tr>" .
+ Xml::closeElement( 'table' ) .
+ Xml::closeElement( 'form' ) .
+ Xml::closeElement( 'fieldset' )
+ );
+ }
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialJavaScriptTest.php b/www/wiki/includes/specials/SpecialJavaScriptTest.php
new file mode 100644
index 00000000..17c64c8e
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialJavaScriptTest.php
@@ -0,0 +1,214 @@
+<?php
+/**
+ * Implements Special:JavaScriptTest
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * @ingroup SpecialPage
+ */
+class SpecialJavaScriptTest extends SpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'JavaScriptTest' );
+ }
+
+ public function execute( $par ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $out->disallowUserJs();
+
+ // This special page is disabled by default ($wgEnableJavaScriptTest), and contains
+ // no sensitive data. In order to allow TestSwarm to embed it into a test client window,
+ // we need to allow iframing of this page.
+ $out->allowClickjacking();
+
+ // Sub resource: Internal JavaScript export bundle for QUnit
+ if ( $par === 'qunit/export' ) {
+ $this->exportQUnit();
+ return;
+ }
+
+ // Regular view: QUnit test runner
+ // (Support "/qunit" and "/qunit/plain" for backwards compatibility)
+ if ( $par === null || $par === '' || $par === 'qunit' || $par === 'qunit/plain' ) {
+ $this->plainQUnit();
+ return;
+ }
+
+ // Unknown action
+ $out->setStatusCode( 404 );
+ $out->setPageTitle( $this->msg( 'javascripttest' ) );
+ $out->addHTML(
+ '<div class="error">'
+ . $this->msg( 'javascripttest-pagetext-unknownaction' )
+ ->plaintextParams( $par )->parseAsBlock()
+ . '</div>'
+ );
+ }
+
+ /**
+ * Get summary text wrapped in a container
+ *
+ * @return string HTML
+ */
+ private function getSummaryHtml() {
+ $summary = $this->msg( 'javascripttest-qunit-intro' )
+ ->params( 'https://www.mediawiki.org/wiki/Manual:JavaScript_unit_testing' )
+ ->parseAsBlock();
+ return "<div id=\"mw-javascripttest-summary\">$summary</div>";
+ }
+
+ /**
+ * Generate self-sufficient JavaScript payload to run the tests elsewhere.
+ *
+ * Includes startup module to request modules from ResourceLoader.
+ *
+ * Note: This modifies the registry to replace 'jquery.qunit' with an
+ * empty module to allow external environment to preload QUnit with any
+ * neccecary framework adapters (e.g. Karma). Loading it again would
+ * re-define QUnit and dereference event handlers from Karma.
+ */
+ private function exportQUnit() {
+ $out = $this->getOutput();
+ $out->disable();
+
+ $rl = $out->getResourceLoader();
+
+ $query = [
+ 'lang' => $this->getLanguage()->getCode(),
+ 'skin' => $this->getSkin()->getSkinName(),
+ 'debug' => ResourceLoader::inDebugMode() ? 'true' : 'false',
+ 'target' => 'test',
+ ];
+ $embedContext = new ResourceLoaderContext( $rl, new FauxRequest( $query ) );
+ $query['only'] = 'scripts';
+ $startupContext = new ResourceLoaderContext( $rl, new FauxRequest( $query ) );
+
+ $query['raw'] = true;
+
+ $modules = $rl->getTestModuleNames( 'qunit' );
+
+ // Disable autostart because we load modules asynchronously. By default, QUnit would start
+ // at domready when there are no tests loaded and also fire 'QUnit.done' which then instructs
+ // Karma to end the run before the tests even started.
+ $qunitConfig = 'QUnit.config.autostart = false;'
+ . 'if (window.__karma__) {'
+ // karma-qunit's use of autostart=false and QUnit.start conflicts with ours.
+ // Hack around this by replacing 'karma.loaded' with a no-op and call it ourselves later.
+ // See <https://github.com/karma-runner/karma-qunit/issues/27>.
+ . 'window.__karma__.loaded = function () {};'
+ . '}';
+
+ // The below is essentially a pure-javascript version of OutputPage::headElement().
+ $startup = $rl->makeModuleResponse( $startupContext, [
+ 'startup' => $rl->getModule( 'startup' ),
+ ] );
+ // Embed page-specific mw.config variables.
+ // The current Special page shouldn't be relevant to tests, but various modules (which
+ // are loaded before the test suites), reference mw.config while initialising.
+ $code = ResourceLoader::makeConfigSetScript( $out->getJSVars() );
+ // Embed private modules as they're not allowed to be loaded dynamically
+ $code .= $rl->makeModuleResponse( $embedContext, [
+ 'user.options' => $rl->getModule( 'user.options' ),
+ 'user.tokens' => $rl->getModule( 'user.tokens' ),
+ ] );
+ // Catch exceptions (such as "dependency missing" or "unknown module") so that we
+ // always start QUnit. Re-throw so that they are caught and reported as global exceptions
+ // by QUnit and Karma.
+ $modules = Xml::encodeJsVar( $modules );
+ $code .= <<<CODE
+(function () {
+ var start = window.__karma__ ? window.__karma__.start : QUnit.start;
+ try {
+ mw.loader.using( $modules )
+ .always( function () {
+ start();
+ } )
+ .fail( function ( e ) {
+ setTimeout( function () {
+ throw e;
+ } );
+ } );
+ } catch ( e ) {
+ start();
+ throw e;
+ }
+}());
+CODE;
+
+ header( 'Content-Type: text/javascript; charset=utf-8' );
+ header( 'Cache-Control: private, no-cache, must-revalidate' );
+ header( 'Pragma: no-cache' );
+ echo $qunitConfig;
+ echo $startup;
+ // The following has to be deferred via RLQ because the startup module is asynchronous.
+ echo ResourceLoader::makeLoaderConditionalScript( $code );
+ }
+
+ private function plainQUnit() {
+ $out = $this->getOutput();
+ $out->disable();
+
+ $styles = $out->makeResourceLoaderLink( 'jquery.qunit',
+ ResourceLoaderModule::TYPE_STYLES
+ );
+
+ // Use 'raw' because QUnit loads before ResourceLoader initialises (omit mw.loader.state call)
+ // Use 'test' to ensure OutputPage doesn't use the "async" attribute because QUnit must
+ // load before qunit/export.
+ $scripts = $out->makeResourceLoaderLink( 'jquery.qunit',
+ ResourceLoaderModule::TYPE_SCRIPTS,
+ [ 'raw' => true, 'sync' => true ]
+ );
+
+ $head = implode( "\n", [ $styles, $scripts ] );
+ $summary = $this->getSummaryHtml();
+ $html = <<<HTML
+<!DOCTYPE html>
+<title>QUnit</title>
+$head
+$summary
+<div id="qunit"></div>
+HTML;
+
+ $url = $this->getPageTitle( 'qunit/export' )->getFullURL( [
+ 'debug' => ResourceLoader::inDebugMode() ? 'true' : 'false',
+ ] );
+ $html .= "\n" . Html::linkedScript( $url );
+
+ header( 'Content-Type: text/html; charset=utf-8' );
+ echo $html;
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept.
+ *
+ * @return string[] subpages
+ */
+ public function getSubpagesForPrefixSearch() {
+ return self::$frameworks;
+ }
+
+ protected function getGroupName() {
+ return 'other';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialLinkAccounts.php b/www/wiki/includes/specials/SpecialLinkAccounts.php
new file mode 100644
index 00000000..da10b90b
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialLinkAccounts.php
@@ -0,0 +1,111 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Links/unlinks external accounts to the current user.
+ *
+ * To interact with this page, account providers need to register themselves with AuthManager.
+ */
+class SpecialLinkAccounts extends AuthManagerSpecialPage {
+ protected static $allowedActions = [
+ AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE,
+ ];
+
+ public function __construct() {
+ parent::__construct( 'LinkAccounts' );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+
+ public function isListed() {
+ return AuthManager::singleton()->canLinkAccounts();
+ }
+
+ protected function getRequestBlacklist() {
+ return $this->getConfig()->get( 'ChangeCredentialsBlacklist' );
+ }
+
+ /**
+ * @param null|string $subPage
+ * @throws MWException
+ * @throws PermissionsError
+ */
+ public function execute( $subPage ) {
+ $this->setHeaders();
+ $this->loadAuth( $subPage );
+
+ if ( !$this->isActionAllowed( $this->authAction ) ) {
+ if ( $this->authAction === AuthManager::ACTION_LINK ) {
+ // looks like no linking provider is installed or willing to take this user
+ $titleMessage = wfMessage( 'cannotlink-no-provider-title' );
+ $errorMessage = wfMessage( 'cannotlink-no-provider' );
+ throw new ErrorPageError( $titleMessage, $errorMessage );
+ } else {
+ // user probably back-button-navigated into an auth session that no longer exists
+ // FIXME would be nice to show a message
+ $this->getOutput()->redirect( $this->getPageTitle()->getFullURL( '', false,
+ PROTO_HTTPS ) );
+ return;
+ }
+ }
+
+ $this->outputHeader();
+
+ $status = $this->trySubmit();
+
+ if ( $status === false || !$status->isOK() ) {
+ $this->displayForm( $status );
+ return;
+ }
+
+ $response = $status->getValue();
+
+ switch ( $response->status ) {
+ case AuthenticationResponse::PASS:
+ $this->success();
+ break;
+ case AuthenticationResponse::FAIL:
+ $this->loadAuth( '', AuthManager::ACTION_LINK, true );
+ $this->displayForm( StatusValue::newFatal( $response->message ) );
+ break;
+ case AuthenticationResponse::REDIRECT:
+ $this->getOutput()->redirect( $response->redirectTarget );
+ break;
+ case AuthenticationResponse::UI:
+ $this->authAction = AuthManager::ACTION_LINK_CONTINUE;
+ $this->authRequests = $response->neededRequests;
+ $this->displayForm( StatusValue::newFatal( $response->message ) );
+ break;
+ default:
+ throw new LogicException( 'invalid AuthenticationResponse' );
+ }
+ }
+
+ protected function getDefaultAction( $subPage ) {
+ return AuthManager::ACTION_LINK;
+ }
+
+ /**
+ * @param AuthenticationRequest[] $requests
+ * @param string $action AuthManager action name, should be ACTION_LINK or ACTION_LINK_CONTINUE
+ * @return HTMLForm
+ */
+ protected function getAuthForm( array $requests, $action ) {
+ $form = parent::getAuthForm( $requests, $action );
+ $form->setSubmitTextMsg( 'linkaccounts-submit' );
+ return $form;
+ }
+
+ /**
+ * Show a success message.
+ */
+ protected function success() {
+ $this->loadAuth( '', AuthManager::ACTION_LINK, true );
+ $this->displayForm( StatusValue::newFatal( $this->msg( 'linkaccounts-success-text' ) ) );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialLinkSearch.php b/www/wiki/includes/specials/SpecialLinkSearch.php
new file mode 100644
index 00000000..cda0854d
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialLinkSearch.php
@@ -0,0 +1,274 @@
+<?php
+/**
+ * Implements Special:LinkSearch
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Brion Vibber
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Special:LinkSearch to search the external-links table.
+ * @ingroup SpecialPage
+ */
+class LinkSearchPage extends QueryPage {
+ /** @var array|bool */
+ private $mungedQuery = false;
+
+ function setParams( $params ) {
+ $this->mQuery = $params['query'];
+ $this->mNs = $params['namespace'];
+ $this->mProt = $params['protocol'];
+ }
+
+ function __construct( $name = 'LinkSearch' ) {
+ parent::__construct( $name );
+
+ // Since we don't control the constructor parameters, we can't inject services that way.
+ // Instead, we initialize services in the execute() method, and allow them to be overridden
+ // using the setServices() method.
+ }
+
+ function isCacheable() {
+ return false;
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+ $out->allowClickjacking();
+
+ $request = $this->getRequest();
+ $target = $request->getVal( 'target', $par );
+ $namespace = $request->getIntOrNull( 'namespace' );
+
+ $protocols_list = [];
+ foreach ( $this->getConfig()->get( 'UrlProtocols' ) as $prot ) {
+ if ( $prot !== '//' ) {
+ $protocols_list[] = $prot;
+ }
+ }
+
+ $target2 = $target;
+ // Get protocol, default is http://
+ $protocol = 'http://';
+ $bits = wfParseUrl( $target );
+ if ( isset( $bits['scheme'] ) && isset( $bits['delimiter'] ) ) {
+ $protocol = $bits['scheme'] . $bits['delimiter'];
+ // Make sure wfParseUrl() didn't make some well-intended correction in the
+ // protocol
+ if ( strcasecmp( $protocol, substr( $target, 0, strlen( $protocol ) ) ) === 0 ) {
+ $target2 = substr( $target, strlen( $protocol ) );
+ } else {
+ // If it did, let LinkFilter::makeLikeArray() handle this
+ $protocol = '';
+ }
+ }
+
+ $out->addWikiMsg(
+ 'linksearch-text',
+ '<nowiki>' . $this->getLanguage()->commaList( $protocols_list ) . '</nowiki>',
+ count( $protocols_list )
+ );
+ $fields = [
+ 'target' => [
+ 'type' => 'text',
+ 'name' => 'target',
+ 'id' => 'target',
+ 'size' => 50,
+ 'label-message' => 'linksearch-pat',
+ 'default' => $target,
+ 'dir' => 'ltr',
+ ]
+ ];
+ if ( !$this->getConfig()->get( 'MiserMode' ) ) {
+ $fields += [
+ 'namespace' => [
+ 'type' => 'namespaceselect',
+ 'name' => 'namespace',
+ 'label-message' => 'linksearch-ns',
+ 'default' => $namespace,
+ 'id' => 'namespace',
+ 'all' => '',
+ 'cssclass' => 'namespaceselector',
+ ],
+ ];
+ }
+ $hiddenFields = [
+ 'title' => $this->getPageTitle()->getPrefixedDBkey(),
+ ];
+ $htmlForm = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
+ $htmlForm->addHiddenFields( $hiddenFields );
+ $htmlForm->setSubmitTextMsg( 'linksearch-ok' );
+ $htmlForm->setWrapperLegendMsg( 'linksearch' );
+ $htmlForm->setAction( wfScript() );
+ $htmlForm->setMethod( 'get' );
+ $htmlForm->prepareForm()->displayForm( false );
+ $this->addHelpLink( 'Help:Linksearch' );
+
+ if ( $target != '' ) {
+ $this->setParams( [
+ 'query' => Parser::normalizeLinkUrl( $target2 ),
+ 'namespace' => $namespace,
+ 'protocol' => $protocol ] );
+ parent::execute( $par );
+ if ( $this->mungedQuery === false ) {
+ $out->addWikiMsg( 'linksearch-error' );
+ }
+ }
+ }
+
+ /**
+ * Disable RSS/Atom feeds
+ * @return bool
+ */
+ function isSyndicated() {
+ return false;
+ }
+
+ /**
+ * Return an appropriately formatted LIKE query and the clause
+ *
+ * @param string $query Search pattern to search for
+ * @param string $prot Protocol, e.g. 'http://'
+ *
+ * @return array
+ */
+ static function mungeQuery( $query, $prot ) {
+ $field = 'el_index';
+ $dbr = wfGetDB( DB_REPLICA );
+
+ if ( $query === '*' && $prot !== '' ) {
+ // Allow queries like 'ftp://*' to find all ftp links
+ $rv = [ $prot, $dbr->anyString() ];
+ } else {
+ $rv = LinkFilter::makeLikeArray( $query, $prot );
+ }
+
+ if ( $rv === false ) {
+ // LinkFilter doesn't handle wildcard in IP, so we'll have to munge here.
+ $pattern = '/^(:?[0-9]{1,3}\.)+\*\s*$|^(:?[0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]*\*\s*$/';
+ if ( preg_match( $pattern, $query ) ) {
+ $rv = [ $prot . rtrim( $query, " \t*" ), $dbr->anyString() ];
+ $field = 'el_to';
+ }
+ }
+
+ return [ $rv, $field ];
+ }
+
+ function linkParameters() {
+ $params = [];
+ $params['target'] = $this->mProt . $this->mQuery;
+ if ( $this->mNs !== null && !$this->getConfig()->get( 'MiserMode' ) ) {
+ $params['namespace'] = $this->mNs;
+ }
+
+ return $params;
+ }
+
+ public function getQueryInfo() {
+ $dbr = wfGetDB( DB_REPLICA );
+ // strip everything past first wildcard, so that
+ // index-based-only lookup would be done
+ list( $this->mungedQuery, $clause ) = self::mungeQuery( $this->mQuery, $this->mProt );
+ if ( $this->mungedQuery === false ) {
+ // Invalid query; return no results
+ return [ 'tables' => 'page', 'fields' => 'page_id', 'conds' => '0=1' ];
+ }
+
+ $stripped = LinkFilter::keepOneWildcard( $this->mungedQuery );
+ $like = $dbr->buildLike( $stripped );
+ $retval = [
+ 'tables' => [ 'page', 'externallinks' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'el_index',
+ 'url' => 'el_to'
+ ],
+ 'conds' => [
+ 'page_id = el_from',
+ "$clause $like"
+ ],
+ 'options' => [ 'USE INDEX' => $clause ]
+ ];
+
+ if ( $this->mNs !== null && !$this->getConfig()->get( 'MiserMode' ) ) {
+ $retval['conds']['page_namespace'] = $this->mNs;
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Pre-fill the link cache
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $title = new TitleValue( (int)$result->namespace, $result->title );
+ $pageLink = $this->getLinkRenderer()->makeLink( $title );
+
+ $url = $result->url;
+ $urlLink = Linker::makeExternalLink( $url, $url );
+
+ return $this->msg( 'linksearch-line' )->rawParams( $urlLink, $pageLink )->escaped();
+ }
+
+ /**
+ * Override to squash the ORDER BY.
+ * We do a truncated index search, so the optimizer won't trust
+ * it as good enough for optimizing sort. The implicit ordering
+ * from the scan will usually do well enough for our needs.
+ * @return array
+ */
+ function getOrderFields() {
+ return [];
+ }
+
+ protected function getGroupName() {
+ return 'redirects';
+ }
+
+ /**
+ * enwiki complained about low limits on this special page
+ *
+ * @see T130058
+ * @todo FIXME This special page should not use LIMIT for paging
+ * @return int
+ */
+ protected function getMaxResults() {
+ return max( parent::getMaxResults(), 60000 );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialListDuplicatedFiles.php b/www/wiki/includes/specials/SpecialListDuplicatedFiles.php
new file mode 100644
index 00000000..d5fb0018
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialListDuplicatedFiles.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * Implements Special:ListDuplicatedFiles
+ *
+ * Copyright © 2013 Brian Wolff
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Brian Wolff
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Special:ListDuplicatedFiles Lists all files where the current version is
+ * a duplicate of the current version of some other file.
+ * @ingroup SpecialPage
+ */
+class ListDuplicatedFilesPage extends QueryPage {
+ function __construct( $name = 'ListDuplicatedFiles' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ /**
+ * Get all the duplicates by grouping on sha1s.
+ *
+ * A cheaper (but less useful) version of this
+ * query would be to not care how many duplicates a
+ * particular file has, and do a self-join on image table.
+ * However this version should be no more expensive then
+ * Special:MostLinked, which seems to get handled fine
+ * with however we are doing cached special pages.
+ * @return array
+ */
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'image' ],
+ 'fields' => [
+ 'namespace' => NS_FILE,
+ 'title' => 'MIN(img_name)',
+ 'value' => 'count(*)'
+ ],
+ 'options' => [
+ 'GROUP BY' => 'img_sha1',
+ 'HAVING' => 'count(*) > 1',
+ ],
+ ];
+ }
+
+ /**
+ * Pre-fill the link cache
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ // Future version might include a list of the first 5 duplicates
+ // perhaps separated by an "↔".
+ $image1 = Title::makeTitle( $result->namespace, $result->title );
+ $dupeSearch = SpecialPage::getTitleFor( 'FileDuplicateSearch', $image1->getDBkey() );
+
+ $msg = $this->msg( 'listduplicatedfiles-entry' )
+ ->params( $image1->getText() )
+ ->numParams( $result->value - 1 )
+ ->params( $dupeSearch->getPrefixedDBkey() );
+
+ return $msg->parse();
+ }
+
+ protected function getGroupName() {
+ return 'media';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialListfiles.php b/www/wiki/includes/specials/SpecialListfiles.php
new file mode 100644
index 00000000..e6e1048c
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialListfiles.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Implements Special:Listfiles
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+class SpecialListFiles extends IncludableSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Listfiles' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ if ( $this->including() ) {
+ $userName = $par;
+ $search = '';
+ $showAll = false;
+ } else {
+ $userName = $this->getRequest()->getText( 'user', $par );
+ $search = $this->getRequest()->getText( 'ilsearch', '' );
+ $showAll = $this->getRequest()->getBool( 'ilshowall', false );
+ }
+
+ $pager = new ImageListPager(
+ $this->getContext(),
+ $userName,
+ $search,
+ $this->including(),
+ $showAll
+ );
+
+ $out = $this->getOutput();
+ if ( $this->including() ) {
+ $out->addParserOutputContent( $pager->getBodyOutput() );
+ } else {
+ $user = $pager->getRelevantUser();
+ $this->getSkin()->setRelevantUser( $user );
+ $pager->getForm();
+ $out->addParserOutputContent( $pager->getFullOutput() );
+ }
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'media';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialListgrants.php b/www/wiki/includes/specials/SpecialListgrants.php
new file mode 100644
index 00000000..1a04eec4
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialListgrants.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * Implements Special:Listgrants
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * This special page lists all defined rights grants and the associated rights.
+ * See also @ref $wgGrantPermissions and @ref $wgGrantPermissionGroups.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialListGrants extends SpecialPage {
+ function __construct() {
+ parent::__construct( 'Listgrants' );
+ }
+
+ /**
+ * Show the special page
+ * @param string|null $par
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ $out->addHTML(
+ \Html::openElement( 'table',
+ [ 'class' => 'wikitable mw-listgrouprights-table' ] ) .
+ '<tr>' .
+ \Html::element( 'th', null, $this->msg( 'listgrants-grant' )->text() ) .
+ \Html::element( 'th', null, $this->msg( 'listgrants-rights' )->text() ) .
+ '</tr>'
+ );
+
+ foreach ( $this->getConfig()->get( 'GrantPermissions' ) as $grant => $rights ) {
+ $descs = [];
+ $rights = array_filter( $rights ); // remove ones with 'false'
+ foreach ( $rights as $permission => $granted ) {
+ $descs[] = $this->msg(
+ 'listgrouprights-right-display',
+ \User::getRightDescription( $permission ),
+ '<span class="mw-listgrants-right-name">' . $permission . '</span>'
+ )->parse();
+ }
+ if ( !count( $descs ) ) {
+ $grantCellHtml = '';
+ } else {
+ sort( $descs );
+ $grantCellHtml = '<ul><li>' . implode( "</li>\n<li>", $descs ) . '</li></ul>';
+ }
+
+ $id = Sanitizer::escapeIdForAttribute( $grant );
+ $out->addHTML( \Html::rawElement( 'tr', [ 'id' => $id ],
+ "<td>" .
+ $this->msg(
+ "listgrants-grant-display",
+ \User::getGrantName( $grant ),
+ "<span class='mw-listgrants-grant-name'>" . $id . "</span>"
+ )->parse() .
+ "</td>" .
+ "<td>" . $grantCellHtml . "</td>"
+ ) );
+ }
+
+ $out->addHTML( \Html::closeElement( 'table' ) );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialListgrouprights.php b/www/wiki/includes/specials/SpecialListgrouprights.php
new file mode 100644
index 00000000..2315887a
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialListgrouprights.php
@@ -0,0 +1,298 @@
+<?php
+/**
+ * Implements Special:Listgrouprights
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * This special page lists all defined user groups and the associated rights.
+ * See also @ref $wgGroupPermissions.
+ *
+ * @ingroup SpecialPage
+ * @author Petr Kadlec <mormegil@centrum.cz>
+ */
+class SpecialListGroupRights extends SpecialPage {
+ function __construct() {
+ parent::__construct( 'Listgrouprights' );
+ }
+
+ /**
+ * Show the special page
+ * @param string|null $par
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ $out->wrapWikiMsg( "<div class=\"mw-listgrouprights-key\">\n$1\n</div>", 'listgrouprights-key' );
+
+ $out->addHTML(
+ Xml::openElement( 'table', [ 'class' => 'wikitable mw-listgrouprights-table' ] ) .
+ '<tr>' .
+ Xml::element( 'th', null, $this->msg( 'listgrouprights-group' )->text() ) .
+ Xml::element( 'th', null, $this->msg( 'listgrouprights-rights' )->text() ) .
+ '</tr>'
+ );
+
+ $config = $this->getConfig();
+ $groupPermissions = $config->get( 'GroupPermissions' );
+ $revokePermissions = $config->get( 'RevokePermissions' );
+ $addGroups = $config->get( 'AddGroups' );
+ $removeGroups = $config->get( 'RemoveGroups' );
+ $groupsAddToSelf = $config->get( 'GroupsAddToSelf' );
+ $groupsRemoveFromSelf = $config->get( 'GroupsRemoveFromSelf' );
+ $allGroups = array_unique( array_merge(
+ array_keys( $groupPermissions ),
+ array_keys( $revokePermissions ),
+ array_keys( $addGroups ),
+ array_keys( $removeGroups ),
+ array_keys( $groupsAddToSelf ),
+ array_keys( $groupsRemoveFromSelf )
+ ) );
+ asort( $allGroups );
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ foreach ( $allGroups as $group ) {
+ $permissions = isset( $groupPermissions[$group] )
+ ? $groupPermissions[$group]
+ : [];
+ $groupname = ( $group == '*' ) // Replace * with a more descriptive groupname
+ ? 'all'
+ : $group;
+
+ $msg = $this->msg( 'group-' . $groupname );
+ $groupnameLocalized = !$msg->isBlank() ? $msg->text() : $groupname;
+
+ $msg = $this->msg( 'grouppage-' . $groupname )->inContentLanguage();
+ $grouppageLocalized = !$msg->isBlank() ?
+ $msg->text() :
+ MWNamespace::getCanonicalName( NS_PROJECT ) . ':' . $groupname;
+ $grouppageLocalizedTitle = Title::newFromText( $grouppageLocalized );
+
+ if ( $group == '*' || !$grouppageLocalizedTitle ) {
+ // Do not make a link for the generic * group or group with invalid group page
+ $grouppage = htmlspecialchars( $groupnameLocalized );
+ } else {
+ $grouppage = $linkRenderer->makeLink(
+ $grouppageLocalizedTitle,
+ $groupnameLocalized
+ );
+ }
+
+ if ( $group === 'user' ) {
+ // Link to Special:listusers for implicit group 'user'
+ $grouplink = '<br />' . $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Listusers' ),
+ $this->msg( 'listgrouprights-members' )->text()
+ );
+ } elseif ( !in_array( $group, $config->get( 'ImplicitGroups' ) ) ) {
+ $grouplink = '<br />' . $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Listusers' ),
+ $this->msg( 'listgrouprights-members' )->text(),
+ [],
+ [ 'group' => $group ]
+ );
+ } else {
+ // No link to Special:listusers for other implicit groups as they are unlistable
+ $grouplink = '';
+ }
+
+ $revoke = isset( $revokePermissions[$group] ) ? $revokePermissions[$group] : [];
+ $addgroups = isset( $addGroups[$group] ) ? $addGroups[$group] : [];
+ $removegroups = isset( $removeGroups[$group] ) ? $removeGroups[$group] : [];
+ $addgroupsSelf = isset( $groupsAddToSelf[$group] ) ? $groupsAddToSelf[$group] : [];
+ $removegroupsSelf = isset( $groupsRemoveFromSelf[$group] )
+ ? $groupsRemoveFromSelf[$group]
+ : [];
+
+ $id = $group == '*' ? false : Sanitizer::escapeIdForAttribute( $group );
+ $out->addHTML( Html::rawElement( 'tr', [ 'id' => $id ], "
+ <td>$grouppage$grouplink</td>
+ <td>" .
+ $this->formatPermissions( $permissions, $revoke, $addgroups, $removegroups,
+ $addgroupsSelf, $removegroupsSelf ) .
+ '</td>
+ '
+ ) );
+ }
+ $out->addHTML( Xml::closeElement( 'table' ) );
+ $this->outputNamespaceProtectionInfo();
+ }
+
+ private function outputNamespaceProtectionInfo() {
+ global $wgParser, $wgContLang;
+ $out = $this->getOutput();
+ $namespaceProtection = $this->getConfig()->get( 'NamespaceProtection' );
+
+ if ( count( $namespaceProtection ) == 0 ) {
+ return;
+ }
+
+ $header = $this->msg( 'listgrouprights-namespaceprotection-header' )->parse();
+ $out->addHTML(
+ Html::rawElement( 'h2', [], Html::element( 'span', [
+ 'class' => 'mw-headline',
+ 'id' => $wgParser->guessSectionNameFromWikiText( $header )
+ ], $header ) ) .
+ Xml::openElement( 'table', [ 'class' => 'wikitable' ] ) .
+ Html::element(
+ 'th',
+ [],
+ $this->msg( 'listgrouprights-namespaceprotection-namespace' )->text()
+ ) .
+ Html::element(
+ 'th',
+ [],
+ $this->msg( 'listgrouprights-namespaceprotection-restrictedto' )->text()
+ )
+ );
+ $linkRenderer = $this->getLinkRenderer();
+ ksort( $namespaceProtection );
+ foreach ( $namespaceProtection as $namespace => $rights ) {
+ if ( !in_array( $namespace, MWNamespace::getValidNamespaces() ) ) {
+ continue;
+ }
+
+ if ( $namespace == NS_MAIN ) {
+ $namespaceText = $this->msg( 'blanknamespace' )->text();
+ } else {
+ $namespaceText = $wgContLang->convertNamespace( $namespace );
+ }
+
+ $out->addHTML(
+ Xml::openElement( 'tr' ) .
+ Html::rawElement(
+ 'td',
+ [],
+ $linkRenderer->makeLink(
+ SpecialPage::getTitleFor( 'Allpages' ),
+ $namespaceText,
+ [],
+ [ 'namespace' => $namespace ]
+ )
+ ) .
+ Xml::openElement( 'td' ) . Xml::openElement( 'ul' )
+ );
+
+ if ( !is_array( $rights ) ) {
+ $rights = [ $rights ];
+ }
+
+ foreach ( $rights as $right ) {
+ $out->addHTML(
+ Html::rawElement( 'li', [], $this->msg(
+ 'listgrouprights-right-display',
+ User::getRightDescription( $right ),
+ Html::element(
+ 'span',
+ [ 'class' => 'mw-listgrouprights-right-name' ],
+ $right
+ )
+ )->parse() )
+ );
+ }
+
+ $out->addHTML(
+ Xml::closeElement( 'ul' ) .
+ Xml::closeElement( 'td' ) .
+ Xml::closeElement( 'tr' )
+ );
+ }
+ $out->addHTML( Xml::closeElement( 'table' ) );
+ }
+
+ /**
+ * Create a user-readable list of permissions from the given array.
+ *
+ * @param array $permissions Array of permission => bool (from $wgGroupPermissions items)
+ * @param array $revoke Array of permission => bool (from $wgRevokePermissions items)
+ * @param array $add Array of groups this group is allowed to add or true
+ * @param array $remove Array of groups this group is allowed to remove or true
+ * @param array $addSelf Array of groups this group is allowed to add to self or true
+ * @param array $removeSelf Array of group this group is allowed to remove from self or true
+ * @return string List of all granted permissions, separated by comma separator
+ */
+ private function formatPermissions( $permissions, $revoke, $add, $remove, $addSelf, $removeSelf ) {
+ $r = [];
+ foreach ( $permissions as $permission => $granted ) {
+ // show as granted only if it isn't revoked to prevent duplicate display of permissions
+ if ( $granted && ( !isset( $revoke[$permission] ) || !$revoke[$permission] ) ) {
+ $r[] = $this->msg( 'listgrouprights-right-display',
+ User::getRightDescription( $permission ),
+ '<span class="mw-listgrouprights-right-name">' . $permission . '</span>'
+ )->parse();
+ }
+ }
+ foreach ( $revoke as $permission => $revoked ) {
+ if ( $revoked ) {
+ $r[] = $this->msg( 'listgrouprights-right-revoked',
+ User::getRightDescription( $permission ),
+ '<span class="mw-listgrouprights-right-name">' . $permission . '</span>'
+ )->parse();
+ }
+ }
+
+ sort( $r );
+
+ $lang = $this->getLanguage();
+ $allGroups = User::getAllGroups();
+
+ $changeGroups = [
+ 'addgroup' => $add,
+ 'removegroup' => $remove,
+ 'addgroup-self' => $addSelf,
+ 'removegroup-self' => $removeSelf
+ ];
+
+ foreach ( $changeGroups as $messageKey => $changeGroup ) {
+ if ( $changeGroup === true ) {
+ // For grep: listgrouprights-addgroup-all, listgrouprights-removegroup-all,
+ // listgrouprights-addgroup-self-all, listgrouprights-removegroup-self-all
+ $r[] = $this->msg( 'listgrouprights-' . $messageKey . '-all' )->escaped();
+ } elseif ( is_array( $changeGroup ) ) {
+ $changeGroup = array_intersect( array_values( array_unique( $changeGroup ) ), $allGroups );
+ if ( count( $changeGroup ) ) {
+ $groupLinks = [];
+ foreach ( $changeGroup as $group ) {
+ $groupLinks[] = UserGroupMembership::getLink( $group, $this->getContext(), 'wiki' );
+ }
+ // For grep: listgrouprights-addgroup, listgrouprights-removegroup,
+ // listgrouprights-addgroup-self, listgrouprights-removegroup-self
+ $r[] = $this->msg( 'listgrouprights-' . $messageKey,
+ $lang->listToText( $groupLinks ), count( $changeGroup ) )->parse();
+ }
+ }
+ }
+
+ if ( empty( $r ) ) {
+ return '';
+ } else {
+ return '<ul><li>' . implode( "</li>\n<li>", $r ) . '</li></ul>';
+ }
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialListredirects.php b/www/wiki/includes/specials/SpecialListredirects.php
new file mode 100644
index 00000000..f81c03c7
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialListredirects.php
@@ -0,0 +1,151 @@
+<?php
+/**
+ * Implements Special:Listredirects
+ *
+ * Copyright © 2006 Rob Church
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Special:Listredirects - Lists all the redirects on the wiki.
+ * @ingroup SpecialPage
+ */
+class ListredirectsPage extends QueryPage {
+ function __construct( $name = 'Listredirects' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'p1' => 'page', 'redirect', 'p2' => 'page' ],
+ 'fields' => [ 'namespace' => 'p1.page_namespace',
+ 'title' => 'p1.page_title',
+ 'value' => 'p1.page_title',
+ 'rd_namespace',
+ 'rd_title',
+ 'rd_fragment',
+ 'rd_interwiki',
+ 'redirid' => 'p2.page_id' ],
+ 'conds' => [ 'p1.page_is_redirect' => 1 ],
+ 'join_conds' => [ 'redirect' => [
+ 'LEFT JOIN', 'rd_from=p1.page_id' ],
+ 'p2' => [ 'LEFT JOIN', [
+ 'p2.page_namespace=rd_namespace',
+ 'p2.page_title=rd_title' ] ] ]
+ ];
+ }
+
+ function getOrderFields() {
+ return [ 'p1.page_namespace', 'p1.page_title' ];
+ }
+
+ /**
+ * Cache page existence for performance
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ if ( !$res->numRows() ) {
+ return;
+ }
+
+ $batch = new LinkBatch;
+ foreach ( $res as $row ) {
+ $batch->add( $row->namespace, $row->title );
+ $redirTarget = $this->getRedirectTarget( $row );
+ if ( $redirTarget ) {
+ $batch->addObj( $redirTarget );
+ }
+ }
+ $batch->execute();
+
+ // Back to start for display
+ $res->seek( 0 );
+ }
+
+ /**
+ * @param stdClass $row
+ * @return Title|null
+ */
+ protected function getRedirectTarget( $row ) {
+ if ( isset( $row->rd_title ) ) {
+ return Title::makeTitle( $row->rd_namespace,
+ $row->rd_title, $row->rd_fragment,
+ $row->rd_interwiki
+ );
+ } else {
+ $title = Title::makeTitle( $row->namespace, $row->title );
+ $article = WikiPage::factory( $title );
+
+ return $article->getRedirectTarget();
+ }
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $linkRenderer = $this->getLinkRenderer();
+ # Make a link to the redirect itself
+ $rd_title = Title::makeTitle( $result->namespace, $result->title );
+ $rd_link = $linkRenderer->makeLink(
+ $rd_title,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+
+ # Find out where the redirect leads
+ $target = $this->getRedirectTarget( $result );
+ if ( $target ) {
+ # Make a link to the destination page
+ $lang = $this->getLanguage();
+ $arr = $lang->getArrow() . $lang->getDirMark();
+ $targetLink = $linkRenderer->makeLink( $target, $target->getFullText() );
+
+ return "$rd_link $arr $targetLink";
+ } else {
+ return "<del>$rd_link</del>";
+ }
+ }
+
+ protected function getGroupName() {
+ return 'pages';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialListusers.php b/www/wiki/includes/specials/SpecialListusers.php
new file mode 100644
index 00000000..dee2968d
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialListusers.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * Implements Special:Listusers
+ *
+ * Copyright © 2004 Brion Vibber, lcrocker, Tim Starling,
+ * Domas Mituzas, Antoine Musso, Jens Frank, Zhengzhu,
+ * 2006 Rob Church <robchur@gmail.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * @ingroup SpecialPage
+ */
+class SpecialListUsers extends IncludableSpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'Listusers' );
+ }
+
+ /**
+ * Show the special page
+ *
+ * @param string $par (optional) A group to list users from
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $up = new UsersPager( $this->getContext(), $par, $this->including() );
+
+ # getBody() first to check, if empty
+ $usersbody = $up->getBody();
+
+ $s = '';
+ if ( !$this->including() ) {
+ $s = $up->getPageHeader();
+ }
+
+ if ( $usersbody ) {
+ $s .= $up->getNavigationBar();
+ $s .= Html::rawElement( 'ul', [], $usersbody );
+ $s .= $up->getNavigationBar();
+ } else {
+ $s .= $this->msg( 'listusers-noresult' )->parseAsBlock();
+ }
+
+ $this->getOutput()->addHTML( $s );
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept.
+ *
+ * @return string[] subpages
+ */
+ public function getSubpagesForPrefixSearch() {
+ return User::getAllGroups();
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
+
+/**
+ * Redirect page: Special:ListAdmins --> Special:ListUsers/sysop.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialListAdmins extends SpecialRedirectToSpecial {
+ function __construct() {
+ parent::__construct( 'Listadmins', 'Listusers', 'sysop' );
+ }
+}
+
+/**
+ * Redirect page: Special:ListBots --> Special:ListUsers/bot.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialListBots extends SpecialRedirectToSpecial {
+ function __construct() {
+ parent::__construct( 'Listbots', 'Listusers', 'bot' );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialLockdb.php b/www/wiki/includes/specials/SpecialLockdb.php
new file mode 100644
index 00000000..2d087ca4
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialLockdb.php
@@ -0,0 +1,118 @@
+<?php
+/**
+ * Implements Special:Lockdb
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A form to make the database readonly (eg for maintenance purposes).
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialLockdb extends FormSpecialPage {
+ protected $reason = '';
+
+ public function __construct() {
+ parent::__construct( 'Lockdb', 'siteadmin' );
+ }
+
+ public function doesWrites() {
+ return false;
+ }
+
+ public function requiresWrite() {
+ return false;
+ }
+
+ public function checkExecutePermissions( User $user ) {
+ parent::checkExecutePermissions( $user );
+ # If the lock file isn't writable, we can do sweet bugger all
+ if ( !is_writable( dirname( $this->getConfig()->get( 'ReadOnlyFile' ) ) ) ) {
+ throw new ErrorPageError( 'lockdb', 'lockfilenotwritable' );
+ }
+ if ( file_exists( $this->getConfig()->get( 'ReadOnlyFile' ) ) ) {
+ throw new ErrorPageError( 'lockdb', 'databaselocked' );
+ }
+ }
+
+ protected function getFormFields() {
+ return [
+ 'Reason' => [
+ 'type' => 'textarea',
+ 'rows' => 4,
+ 'vertical-label' => true,
+ 'label-message' => 'enterlockreason',
+ ],
+ 'Confirm' => [
+ 'type' => 'toggle',
+ 'label-message' => 'lockconfirm',
+ ],
+ ];
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setWrapperLegend( false )
+ ->setHeaderText( $this->msg( 'lockdbtext' )->parseAsBlock() )
+ ->setSubmitTextMsg( 'lockbtn' );
+ }
+
+ public function onSubmit( array $data ) {
+ global $wgContLang;
+
+ if ( !$data['Confirm'] ) {
+ return Status::newFatal( 'locknoconfirm' );
+ }
+
+ MediaWiki\suppressWarnings();
+ $fp = fopen( $this->getConfig()->get( 'ReadOnlyFile' ), 'w' );
+ MediaWiki\restoreWarnings();
+
+ if ( false === $fp ) {
+ # This used to show a file not found error, but the likeliest reason for fopen()
+ # to fail at this point is insufficient permission to write to the file...good old
+ # is_writable() is plain wrong in some cases, it seems...
+ return Status::newFatal( 'lockfilenotwritable' );
+ }
+ fwrite( $fp, $data['Reason'] );
+ $timestamp = wfTimestampNow();
+ fwrite( $fp, "\n<p>" . $this->msg( 'lockedbyandtime',
+ $this->getUser()->getName(),
+ $wgContLang->date( $timestamp, false, false ),
+ $wgContLang->time( $timestamp, false, false )
+ )->inContentLanguage()->text() . "</p>\n" );
+ fclose( $fp );
+
+ return Status::newGood();
+ }
+
+ public function onSuccess() {
+ $out = $this->getOutput();
+ $out->addSubtitle( $this->msg( 'lockdbsuccesssub' ) );
+ $out->addWikiMsg( 'lockdbsuccesstext' );
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialLog.php b/www/wiki/includes/specials/SpecialLog.php
new file mode 100644
index 00000000..c82d001d
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialLog.php
@@ -0,0 +1,302 @@
+<?php
+/**
+ * Implements Special:Log
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists log entries
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialLog extends SpecialPage {
+ public function __construct() {
+ parent::__construct( 'Log' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->getOutput()->addModules( 'mediawiki.userSuggest' );
+ $this->addHelpLink( 'Help:Log' );
+
+ $opts = new FormOptions;
+ $opts->add( 'type', '' );
+ $opts->add( 'user', '' );
+ $opts->add( 'page', '' );
+ $opts->add( 'pattern', false );
+ $opts->add( 'year', null, FormOptions::INTNULL );
+ $opts->add( 'month', null, FormOptions::INTNULL );
+ $opts->add( 'tagfilter', '' );
+ $opts->add( 'offset', '' );
+ $opts->add( 'dir', '' );
+ $opts->add( 'offender', '' );
+ $opts->add( 'subtype', '' );
+ $opts->add( 'logid', '' );
+
+ // Set values
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+ if ( $par !== null ) {
+ $this->parseParams( $opts, (string)$par );
+ }
+
+ # Don't let the user get stuck with a certain date
+ if ( $opts->getValue( 'offset' ) || $opts->getValue( 'dir' ) == 'prev' ) {
+ $opts->setValue( 'year', '' );
+ $opts->setValue( 'month', '' );
+ }
+
+ // If the user doesn't have the right permission to view the specific
+ // log type, throw a PermissionsError
+ // If the log type is invalid, just show all public logs
+ $logRestrictions = $this->getConfig()->get( 'LogRestrictions' );
+ $type = $opts->getValue( 'type' );
+ if ( !LogPage::isLogType( $type ) ) {
+ $opts->setValue( 'type', '' );
+ } elseif ( isset( $logRestrictions[$type] )
+ && !$this->getUser()->isAllowed( $logRestrictions[$type] )
+ ) {
+ throw new PermissionsError( $logRestrictions[$type] );
+ }
+
+ # Handle type-specific inputs
+ $qc = [];
+ if ( $opts->getValue( 'type' ) == 'suppress' ) {
+ $offender = User::newFromName( $opts->getValue( 'offender' ), false );
+ if ( $offender && $offender->getId() > 0 ) {
+ $qc = [ 'ls_field' => 'target_author_id', 'ls_value' => $offender->getId() ];
+ } elseif ( $offender && IP::isIPAddress( $offender->getName() ) ) {
+ $qc = [ 'ls_field' => 'target_author_ip', 'ls_value' => $offender->getName() ];
+ }
+ } else {
+ // Allow extensions to add relations to their search types
+ Hooks::run(
+ 'SpecialLogAddLogSearchRelations',
+ [ $opts->getValue( 'type' ), $this->getRequest(), &$qc ]
+ );
+ }
+
+ # Some log types are only for a 'User:' title but we might have been given
+ # only the username instead of the full title 'User:username'. This part try
+ # to lookup for a user by that name and eventually fix user input. See T3697.
+ if ( in_array( $opts->getValue( 'type' ), self::getLogTypesOnUser() ) ) {
+ # ok we have a type of log which expect a user title.
+ $target = Title::newFromText( $opts->getValue( 'page' ) );
+ if ( $target && $target->getNamespace() === NS_MAIN ) {
+ # User forgot to add 'User:', we are adding it for him
+ $opts->setValue( 'page',
+ Title::makeTitleSafe( NS_USER, $opts->getValue( 'page' ) )
+ );
+ }
+ }
+
+ $this->show( $opts, $qc );
+ }
+
+ /**
+ * List log type for which the target is a user
+ * Thus if the given target is in NS_MAIN we can alter it to be an NS_USER
+ * Title user instead.
+ *
+ * @since 1.25
+ * @return array
+ */
+ public static function getLogTypesOnUser() {
+ static $types = null;
+ if ( $types !== null ) {
+ return $types;
+ }
+ $types = [
+ 'block',
+ 'newusers',
+ 'rights',
+ ];
+
+ Hooks::run( 'GetLogTypesOnUser', [ &$types ] );
+ return $types;
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept.
+ *
+ * @return string[] subpages
+ */
+ public function getSubpagesForPrefixSearch() {
+ $subpages = $this->getConfig()->get( 'LogTypes' );
+ $subpages[] = 'all';
+ sort( $subpages );
+ return $subpages;
+ }
+
+ /**
+ * Set options based on the subpage title parts:
+ * - One part that is a valid log type: Special:Log/logtype
+ * - Two parts: Special:Log/logtype/username
+ * - Otherwise, assume the whole subpage is a username.
+ *
+ * @param FormOptions $opts
+ * @param $par
+ * @throws ConfigException
+ */
+ private function parseParams( FormOptions $opts, $par ) {
+ # Get parameters
+ $par = $par !== null ? $par : '';
+ $parms = explode( '/', $par );
+ $symsForAll = [ '*', 'all' ];
+ if ( $parms[0] != '' &&
+ ( in_array( $par, $this->getConfig()->get( 'LogTypes' ) ) || in_array( $par, $symsForAll ) )
+ ) {
+ $opts->setValue( 'type', $par );
+ } elseif ( count( $parms ) == 2 ) {
+ $opts->setValue( 'type', $parms[0] );
+ $opts->setValue( 'user', $parms[1] );
+ } elseif ( $par != '' ) {
+ $opts->setValue( 'user', $par );
+ }
+ }
+
+ private function show( FormOptions $opts, array $extraConds ) {
+ # Create a LogPager item to get the results and a LogEventsList item to format them...
+ $loglist = new LogEventsList(
+ $this->getContext(),
+ $this->getLinkRenderer(),
+ LogEventsList::USE_CHECKBOXES
+ );
+
+ $pager = new LogPager(
+ $loglist,
+ $opts->getValue( 'type' ),
+ $opts->getValue( 'user' ),
+ $opts->getValue( 'page' ),
+ $opts->getValue( 'pattern' ),
+ $extraConds,
+ $opts->getValue( 'year' ),
+ $opts->getValue( 'month' ),
+ $opts->getValue( 'tagfilter' ),
+ $opts->getValue( 'subtype' ),
+ $opts->getValue( 'logid' )
+ );
+
+ $this->addHeader( $opts->getValue( 'type' ) );
+
+ # Set relevant user
+ if ( $pager->getPerformer() ) {
+ $performerUser = User::newFromName( $pager->getPerformer(), false );
+ $this->getSkin()->setRelevantUser( $performerUser );
+ }
+
+ # Show form options
+ $loglist->showOptions(
+ $pager->getType(),
+ $pager->getPerformer(),
+ $pager->getPage(),
+ $pager->getPattern(),
+ $pager->getYear(),
+ $pager->getMonth(),
+ $pager->getFilterParams(),
+ $pager->getTagFilter(),
+ $pager->getAction()
+ );
+
+ # Insert list
+ $logBody = $pager->getBody();
+ if ( $logBody ) {
+ $this->getOutput()->addHTML(
+ $pager->getNavigationBar() .
+ $this->getActionButtons(
+ $loglist->beginLogEventsList() .
+ $logBody .
+ $loglist->endLogEventsList()
+ ) .
+ $pager->getNavigationBar()
+ );
+ } else {
+ $this->getOutput()->addWikiMsg( 'logempty' );
+ }
+ }
+
+ private function getActionButtons( $formcontents ) {
+ $user = $this->getUser();
+ $canRevDelete = $user->isAllowedAll( 'deletedhistory', 'deletelogentry' );
+ $showTagEditUI = ChangeTags::showTagEditingUI( $user );
+ # If the user doesn't have the ability to delete log entries nor edit tags,
+ # don't bother showing them the button(s).
+ if ( !$canRevDelete && !$showTagEditUI ) {
+ return $formcontents;
+ }
+
+ # Show button to hide log entries and/or edit change tags
+ $s = Html::openElement(
+ 'form',
+ [ 'action' => wfScript(), 'id' => 'mw-log-deleterevision-submit' ]
+ ) . "\n";
+ $s .= Html::hidden( 'action', 'historysubmit' ) . "\n";
+ $s .= Html::hidden( 'type', 'logging' ) . "\n";
+
+ $buttons = '';
+ if ( $canRevDelete ) {
+ $buttons .= Html::element(
+ 'button',
+ [
+ 'type' => 'submit',
+ 'name' => 'revisiondelete',
+ 'value' => '1',
+ 'class' => "deleterevision-log-submit mw-log-deleterevision-button"
+ ],
+ $this->msg( 'showhideselectedlogentries' )->text()
+ ) . "\n";
+ }
+ if ( $showTagEditUI ) {
+ $buttons .= Html::element(
+ 'button',
+ [
+ 'type' => 'submit',
+ 'name' => 'editchangetags',
+ 'value' => '1',
+ 'class' => "editchangetags-log-submit mw-log-editchangetags-button"
+ ],
+ $this->msg( 'log-edit-tags' )->text()
+ ) . "\n";
+ }
+
+ $buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
+
+ $s .= $buttons . $formcontents . $buttons;
+ $s .= Html::closeElement( 'form' );
+
+ return $s;
+ }
+
+ /**
+ * Set page title and show header for this log type
+ * @param string $type
+ * @since 1.19
+ */
+ protected function addHeader( $type ) {
+ $page = new LogPage( $type );
+ $this->getOutput()->setPageTitle( $page->getName() );
+ $this->getOutput()->addHTML( $page->getDescription()
+ ->setContext( $this->getContext() )->parseAsBlock() );
+ }
+
+ protected function getGroupName() {
+ return 'changes';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialLonelypages.php b/www/wiki/includes/specials/SpecialLonelypages.php
new file mode 100644
index 00000000..ff76a4b4
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialLonelypages.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * Implements Special:Lonelypaages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page looking for articles with no article linking to them,
+ * thus being lonely.
+ *
+ * @ingroup SpecialPage
+ */
+class LonelyPagesPage extends PageQueryPage {
+ function __construct( $name = 'Lonelypages' ) {
+ parent::__construct( $name );
+ }
+
+ function getPageHeader() {
+ return $this->msg( 'lonelypagestext' )->parseAsBlock();
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function getQueryInfo() {
+ $tables = [ 'page', 'pagelinks', 'templatelinks' ];
+ $conds = [
+ 'pl_namespace IS NULL',
+ 'page_namespace' => MWNamespace::getContentNamespaces(),
+ 'page_is_redirect' => 0,
+ 'tl_namespace IS NULL'
+ ];
+ $joinConds = [
+ 'pagelinks' => [
+ 'LEFT JOIN', [
+ 'pl_namespace = page_namespace',
+ 'pl_title = page_title'
+ ]
+ ],
+ 'templatelinks' => [
+ 'LEFT JOIN', [
+ 'tl_namespace = page_namespace',
+ 'tl_title = page_title'
+ ]
+ ]
+ ];
+
+ // Allow extensions to modify the query
+ Hooks::run( 'LonelyPagesQuery', [ &$tables, &$conds, &$joinConds ] );
+
+ return [
+ 'tables' => $tables,
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_title'
+ ],
+ 'conds' => $conds,
+ 'join_conds' => $joinConds
+ ];
+ }
+
+ function getOrderFields() {
+ // For some crazy reason ordering by a constant
+ // causes a filesort in MySQL 5
+ if ( count( MWNamespace::getContentNamespaces() ) > 1 ) {
+ return [ 'page_namespace', 'page_title' ];
+ } else {
+ return [ 'page_title' ];
+ }
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialLongpages.php b/www/wiki/includes/specials/SpecialLongpages.php
new file mode 100644
index 00000000..d90d2718
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialLongpages.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Implements Special:Longpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ *
+ * @ingroup SpecialPage
+ */
+class LongPagesPage extends ShortPagesPage {
+ function __construct( $name = 'Longpages' ) {
+ parent::__construct( $name );
+ }
+
+ function sortDescending() {
+ return true;
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMIMEsearch.php b/www/wiki/includes/specials/SpecialMIMEsearch.php
new file mode 100644
index 00000000..3290abd5
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMIMEsearch.php
@@ -0,0 +1,239 @@
+<?php
+/**
+ * Implements Special:MIMESearch
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ */
+
+/**
+ * Searches the database for files of the requested MIME type, comparing this with the
+ * 'img_major_mime' and 'img_minor_mime' fields in the image table.
+ * @ingroup SpecialPage
+ */
+class MIMEsearchPage extends QueryPage {
+ protected $major, $minor, $mime;
+
+ function __construct( $name = 'MIMEsearch' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return false;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function isCacheable() {
+ return false;
+ }
+
+ function linkParameters() {
+ return [ 'mime' => "{$this->major}/{$this->minor}" ];
+ }
+
+ public function getQueryInfo() {
+ $minorType = [];
+ if ( $this->minor !== '*' ) {
+ // Allow wildcard searching
+ $minorType['img_minor_mime'] = $this->minor;
+ }
+ $qi = [
+ 'tables' => [ 'image' ],
+ 'fields' => [
+ 'namespace' => NS_FILE,
+ 'title' => 'img_name',
+ // Still have a value field just in case,
+ // but it isn't actually used for sorting.
+ 'value' => 'img_name',
+ 'img_size',
+ 'img_width',
+ 'img_height',
+ 'img_user_text',
+ 'img_timestamp'
+ ],
+ 'conds' => [
+ 'img_major_mime' => $this->major,
+ // This is in order to trigger using
+ // the img_media_mime index in "range" mode.
+ // @todo how is order defined? use MimeAnalyzer::getMediaTypes?
+ 'img_media_type' => [
+ MEDIATYPE_BITMAP,
+ MEDIATYPE_DRAWING,
+ MEDIATYPE_AUDIO,
+ MEDIATYPE_VIDEO,
+ MEDIATYPE_MULTIMEDIA,
+ MEDIATYPE_UNKNOWN,
+ MEDIATYPE_OFFICE,
+ MEDIATYPE_TEXT,
+ MEDIATYPE_EXECUTABLE,
+ MEDIATYPE_ARCHIVE,
+ MEDIATYPE_3D,
+ ],
+ ] + $minorType,
+ ];
+
+ return $qi;
+ }
+
+ /**
+ * The index is on (img_media_type, img_major_mime, img_minor_mime)
+ * which unfortunately doesn't have img_name at the end for sorting.
+ * So tell db to sort it however it wishes (Its not super important
+ * that this report gives results in a logical order). As an aditional
+ * note, mysql seems to by default order things by img_name ASC, which
+ * is what we ideally want, so everything works out fine anyhow.
+ * @return array
+ */
+ function getOrderFields() {
+ return [];
+ }
+
+ /**
+ * Generate and output the form
+ */
+ function getPageHeader() {
+ $formDescriptor = [
+ 'mime' => [
+ 'type' => 'combobox',
+ 'options' => $this->getSuggestionsForTypes(),
+ 'name' => 'mime',
+ 'label-message' => 'mimetype',
+ 'required' => true,
+ 'default' => $this->mime,
+ ],
+ ];
+
+ HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
+ ->setSubmitTextMsg( 'ilsubmit' )
+ ->setAction( $this->getPageTitle()->getLocalURL() )
+ ->setMethod( 'get' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ protected function getSuggestionsForTypes() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $lastMajor = null;
+ $suggestions = [];
+ $result = $dbr->select(
+ [ 'image' ],
+ // We ignore img_media_type, but using it in the query is needed for MySQL to choose a
+ // sensible execution plan
+ [ 'img_media_type', 'img_major_mime', 'img_minor_mime' ],
+ [],
+ __METHOD__,
+ [ 'GROUP BY' => [ 'img_media_type', 'img_major_mime', 'img_minor_mime' ] ]
+ );
+ foreach ( $result as $row ) {
+ $major = $row->img_major_mime;
+ $minor = $row->img_minor_mime;
+ $suggestions[ "$major/$minor" ] = "$major/$minor";
+ if ( $lastMajor === $major ) {
+ // If there are at least two with the same major mime type, also include the wildcard
+ $suggestions[ "$major/*" ] = "$major/*";
+ }
+ $lastMajor = $major;
+ }
+ ksort( $suggestions );
+ return $suggestions;
+ }
+
+ public function execute( $par ) {
+ $this->mime = $par ? $par : $this->getRequest()->getText( 'mime' );
+ $this->mime = trim( $this->mime );
+ list( $this->major, $this->minor ) = File::splitMime( $this->mime );
+
+ if ( $this->major == '' || $this->minor == '' || $this->minor == 'unknown' ||
+ !self::isValidType( $this->major )
+ ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->getPageHeader();
+ return;
+ }
+
+ parent::execute( $par );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ global $wgContLang;
+
+ $linkRenderer = $this->getLinkRenderer();
+ $nt = Title::makeTitle( $result->namespace, $result->title );
+ $text = $wgContLang->convert( $nt->getText() );
+ $plink = $linkRenderer->makeLink(
+ Title::newFromText( $nt->getPrefixedText() ),
+ $text
+ );
+
+ $download = Linker::makeMediaLinkObj( $nt, $this->msg( 'download' )->escaped() );
+ $download = $this->msg( 'parentheses' )->rawParams( $download )->escaped();
+ $lang = $this->getLanguage();
+ $bytes = htmlspecialchars( $lang->formatSize( $result->img_size ) );
+ $dimensions = $this->msg( 'widthheight' )->numParams( $result->img_width,
+ $result->img_height )->escaped();
+ $user = $linkRenderer->makeLink(
+ Title::makeTitle( NS_USER, $result->img_user_text ),
+ $result->img_user_text
+ );
+
+ $time = $lang->userTimeAndDate( $result->img_timestamp, $this->getUser() );
+ $time = htmlspecialchars( $time );
+
+ return "$download $plink . . $dimensions . . $bytes . . $user . . $time";
+ }
+
+ /**
+ * @param string $type
+ * @return bool
+ */
+ protected static function isValidType( $type ) {
+ // From maintenance/tables.sql => img_major_mime
+ $types = [
+ 'unknown',
+ 'application',
+ 'audio',
+ 'image',
+ 'text',
+ 'video',
+ 'message',
+ 'model',
+ 'multipart',
+ 'chemical'
+ ];
+
+ return in_array( $type, $types );
+ }
+
+ public function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ protected function getGroupName() {
+ return 'media';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMediaStatistics.php b/www/wiki/includes/specials/SpecialMediaStatistics.php
new file mode 100644
index 00000000..a6d4a3e9
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMediaStatistics.php
@@ -0,0 +1,370 @@
+<?php
+/**
+ * Implements Special:MediaStatistics
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Brian Wolff
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * @ingroup SpecialPage
+ */
+class MediaStatisticsPage extends QueryPage {
+ protected $totalCount = 0, $totalBytes = 0;
+
+ /**
+ * @var int $totalPerType Combined file size of all files in a section
+ */
+ protected $totalPerType = 0;
+
+ /**
+ * @var int $totalSize Combined file size of all files
+ */
+ protected $totalSize = 0;
+
+ function __construct( $name = 'MediaStatistics' ) {
+ parent::__construct( $name );
+ // Generally speaking there is only a small number of file types,
+ // so just show all of them.
+ $this->limit = 5000;
+ $this->shownavigation = false;
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ /**
+ * Query to do.
+ *
+ * This abuses the query cache table by storing mime types as "titles".
+ *
+ * This will store entries like [[Media:BITMAP;image/jpeg;200;20000]]
+ * where the form is Media type;mime type;count;bytes.
+ *
+ * This relies on the behaviour that when value is tied, the order things
+ * come out of querycache table is the order they went in. Which is hacky.
+ * However, other special pages like Special:Deadendpages and
+ * Special:BrokenRedirects also rely on this.
+ * @return array
+ */
+ public function getQueryInfo() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $fakeTitle = $dbr->buildConcat( [
+ 'img_media_type',
+ $dbr->addQuotes( ';' ),
+ 'img_major_mime',
+ $dbr->addQuotes( '/' ),
+ 'img_minor_mime',
+ $dbr->addQuotes( ';' ),
+ 'COUNT(*)',
+ $dbr->addQuotes( ';' ),
+ 'SUM( img_size )'
+ ] );
+ return [
+ 'tables' => [ 'image' ],
+ 'fields' => [
+ 'title' => $fakeTitle,
+ 'namespace' => NS_MEDIA, /* needs to be something */
+ 'value' => '1'
+ ],
+ 'options' => [
+ 'GROUP BY' => [
+ 'img_media_type',
+ 'img_major_mime',
+ 'img_minor_mime',
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * How to sort the results
+ *
+ * It's important that img_media_type come first, otherwise the
+ * tables will be fragmented.
+ * @return Array Fields to sort by
+ */
+ function getOrderFields() {
+ return [ 'img_media_type', 'count(*)', 'img_major_mime', 'img_minor_mime' ];
+ }
+
+ /**
+ * Output the results of the query.
+ *
+ * @param OutputPage $out
+ * @param Skin $skin (deprecated presumably)
+ * @param IDatabase $dbr
+ * @param ResultWrapper $res Results from query
+ * @param int $num Number of results
+ * @param int $offset Paging offset (Should always be 0 in our case)
+ */
+ protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
+ $prevMediaType = null;
+ foreach ( $res as $row ) {
+ $mediaStats = $this->splitFakeTitle( $row->title );
+ if ( count( $mediaStats ) < 4 ) {
+ continue;
+ }
+ list( $mediaType, $mime, $totalCount, $totalBytes ) = $mediaStats;
+ if ( $prevMediaType !== $mediaType ) {
+ if ( $prevMediaType !== null ) {
+ // We're not at beginning, so we have to
+ // close the previous table.
+ $this->outputTableEnd();
+ }
+ $this->outputMediaType( $mediaType );
+ $this->totalPerType = 0;
+ $this->outputTableStart( $mediaType );
+ $prevMediaType = $mediaType;
+ }
+ $this->outputTableRow( $mime, intval( $totalCount ), intval( $totalBytes ) );
+ }
+ if ( $prevMediaType !== null ) {
+ $this->outputTableEnd();
+ // add total size of all files
+ $this->outputMediaType( 'total' );
+ $this->getOutput()->addWikiText(
+ $this->msg( 'mediastatistics-allbytes' )
+ ->numParams( $this->totalSize )
+ ->sizeParams( $this->totalSize )
+ ->text()
+ );
+ }
+ }
+
+ /**
+ * Output closing </table>
+ */
+ protected function outputTableEnd() {
+ $this->getOutput()->addHTML( Html::closeElement( 'table' ) );
+ $this->getOutput()->addWikiText(
+ $this->msg( 'mediastatistics-bytespertype' )
+ ->numParams( $this->totalPerType )
+ ->sizeParams( $this->totalPerType )
+ ->numParams( $this->makePercentPretty( $this->totalPerType / $this->totalBytes ) )
+ ->text()
+ );
+ $this->totalSize += $this->totalPerType;
+ }
+
+ /**
+ * Output a row of the stats table
+ *
+ * @param string $mime mime type (e.g. image/jpeg)
+ * @param int $count Number of images of this type
+ * @param int $bytes Total space for images of this type
+ */
+ protected function outputTableRow( $mime, $count, $bytes ) {
+ $mimeSearch = SpecialPage::getTitleFor( 'MIMEsearch', $mime );
+ $linkRenderer = $this->getLinkRenderer();
+ $row = Html::rawElement(
+ 'td',
+ [],
+ $linkRenderer->makeLink( $mimeSearch, $mime )
+ );
+ $row .= Html::element(
+ 'td',
+ [],
+ $this->getExtensionList( $mime )
+ );
+ $row .= Html::rawElement(
+ 'td',
+ // Make sure js sorts it in numeric order
+ [ 'data-sort-value' => $count ],
+ $this->msg( 'mediastatistics-nfiles' )
+ ->numParams( $count )
+ /** @todo Check to be sure this really should have number formatting */
+ ->numParams( $this->makePercentPretty( $count / $this->totalCount ) )
+ ->parse()
+ );
+ $row .= Html::rawElement(
+ 'td',
+ // Make sure js sorts it in numeric order
+ [ 'data-sort-value' => $bytes ],
+ $this->msg( 'mediastatistics-nbytes' )
+ ->numParams( $bytes )
+ ->sizeParams( $bytes )
+ /** @todo Check to be sure this really should have number formatting */
+ ->numParams( $this->makePercentPretty( $bytes / $this->totalBytes ) )
+ ->parse()
+ );
+ $this->totalPerType += $bytes;
+ $this->getOutput()->addHTML( Html::rawElement( 'tr', [], $row ) );
+ }
+
+ /**
+ * @param float $decimal A decimal percentage (ie for 12.3%, this would be 0.123)
+ * @return String The percentage formatted so that 3 significant digits are shown.
+ */
+ protected function makePercentPretty( $decimal ) {
+ $decimal *= 100;
+ // Always show three useful digits
+ if ( $decimal == 0 ) {
+ return '0';
+ }
+ if ( $decimal >= 100 ) {
+ return '100';
+ }
+ $percent = sprintf( "%." . max( 0, 2 - floor( log10( $decimal ) ) ) . "f", $decimal );
+ // Then remove any trailing 0's
+ return preg_replace( '/\.?0*$/', '', $percent );
+ }
+
+ /**
+ * Given a mime type, return a comma separated list of allowed extensions.
+ *
+ * @param string $mime mime type
+ * @return string Comma separated list of allowed extensions (e.g. ".ogg, .oga")
+ */
+ private function getExtensionList( $mime ) {
+ $exts = MimeMagic::singleton()->getExtensionsForType( $mime );
+ if ( $exts === null ) {
+ return '';
+ }
+ $extArray = explode( ' ', $exts );
+ $extArray = array_unique( $extArray );
+ foreach ( $extArray as &$ext ) {
+ $ext = '.' . $ext;
+ }
+
+ return $this->getLanguage()->commaList( $extArray );
+ }
+
+ /**
+ * Output the start of the table
+ *
+ * Including opening <table>, and first <tr> with column headers.
+ * @param string $mediaType
+ */
+ protected function outputTableStart( $mediaType ) {
+ $this->getOutput()->addHTML(
+ Html::openElement(
+ 'table',
+ [ 'class' => [
+ 'mw-mediastats-table',
+ 'mw-mediastats-table-' . strtolower( $mediaType ),
+ 'sortable',
+ 'wikitable'
+ ] ]
+ )
+ );
+ $this->getOutput()->addHTML( $this->getTableHeaderRow() );
+ }
+
+ /**
+ * Get (not output) the header row for the table
+ *
+ * @return String the header row of the able
+ */
+ protected function getTableHeaderRow() {
+ $headers = [ 'mimetype', 'extensions', 'count', 'totalbytes' ];
+ $ths = '';
+ foreach ( $headers as $header ) {
+ $ths .= Html::rawElement(
+ 'th',
+ [],
+ // for grep:
+ // mediastatistics-table-mimetype, mediastatistics-table-extensions
+ // tatistics-table-count, mediastatistics-table-totalbytes
+ $this->msg( 'mediastatistics-table-' . $header )->parse()
+ );
+ }
+ return Html::rawElement( 'tr', [], $ths );
+ }
+
+ /**
+ * Output a header for a new media type section
+ *
+ * @param string $mediaType A media type (e.g. from the MEDIATYPE_xxx constants)
+ */
+ protected function outputMediaType( $mediaType ) {
+ $this->getOutput()->addHTML(
+ Html::element(
+ 'h2',
+ [ 'class' => [
+ 'mw-mediastats-mediatype',
+ 'mw-mediastats-mediatype-' . strtolower( $mediaType )
+ ] ],
+ // for grep
+ // mediastatistics-header-unknown, mediastatistics-header-bitmap,
+ // mediastatistics-header-drawing, mediastatistics-header-audio,
+ // mediastatistics-header-video, mediastatistics-header-multimedia,
+ // mediastatistics-header-office, mediastatistics-header-text,
+ // mediastatistics-header-executable, mediastatistics-header-archive,
+ // mediastatistics-header-3d,
+ $this->msg( 'mediastatistics-header-' . strtolower( $mediaType ) )->text()
+ )
+ );
+ /** @todo Possibly could add a message here explaining what the different types are.
+ * not sure if it is needed though.
+ */
+ }
+
+ /**
+ * parse the fake title format that this special page abuses querycache with.
+ *
+ * @param string $fakeTitle A string formatted as <media type>;<mime type>;<count>;<bytes>
+ * @return array The constituant parts of $fakeTitle
+ */
+ private function splitFakeTitle( $fakeTitle ) {
+ return explode( ';', $fakeTitle, 4 );
+ }
+
+ /**
+ * What group to put the page in
+ * @return string
+ */
+ protected function getGroupName() {
+ return 'media';
+ }
+
+ /**
+ * This method isn't used, since we override outputResults, but
+ * we need to implement since abstract in parent class.
+ *
+ * @param Skin $skin
+ * @param stdClass $result Result row
+ * @return bool|string|void
+ * @throws MWException
+ */
+ public function formatResult( $skin, $result ) {
+ throw new MWException( "unimplemented" );
+ }
+
+ /**
+ * Initialize total values so we can figure out percentages later.
+ *
+ * @param IDatabase $dbr
+ * @param ResultWrapper $res
+ */
+ public function preprocessResults( $dbr, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ $this->totalCount = $this->totalBytes = 0;
+ foreach ( $res as $row ) {
+ $mediaStats = $this->splitFakeTitle( $row->title );
+ $this->totalCount += isset( $mediaStats[2] ) ? $mediaStats[2] : 0;
+ $this->totalBytes += isset( $mediaStats[3] ) ? $mediaStats[3] : 0;
+ }
+ $res->seek( 0 );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMergeHistory.php b/www/wiki/includes/specials/SpecialMergeHistory.php
new file mode 100644
index 00000000..f122db8a
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMergeHistory.php
@@ -0,0 +1,385 @@
+<?php
+/**
+ * Implements Special:MergeHistory
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page allowing users with the appropriate permissions to
+ * merge article histories, with some restrictions
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialMergeHistory extends SpecialPage {
+ /** @var string */
+ protected $mAction;
+
+ /** @var string */
+ protected $mTarget;
+
+ /** @var string */
+ protected $mDest;
+
+ /** @var string */
+ protected $mTimestamp;
+
+ /** @var int */
+ protected $mTargetID;
+
+ /** @var int */
+ protected $mDestID;
+
+ /** @var string */
+ protected $mComment;
+
+ /** @var bool Was posted? */
+ protected $mMerge;
+
+ /** @var bool Was submitted? */
+ protected $mSubmitted;
+
+ /** @var Title */
+ protected $mTargetObj;
+
+ /** @var Title */
+ protected $mDestObj;
+
+ /** @var int[] */
+ public $prevId;
+
+ public function __construct() {
+ parent::__construct( 'MergeHistory', 'mergehistory' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * @return void
+ */
+ private function loadRequestParams() {
+ $request = $this->getRequest();
+ $this->mAction = $request->getVal( 'action' );
+ $this->mTarget = $request->getVal( 'target' );
+ $this->mDest = $request->getVal( 'dest' );
+ $this->mSubmitted = $request->getBool( 'submitted' );
+
+ $this->mTargetID = intval( $request->getVal( 'targetID' ) );
+ $this->mDestID = intval( $request->getVal( 'destID' ) );
+ $this->mTimestamp = $request->getVal( 'mergepoint' );
+ if ( !preg_match( '/[0-9]{14}/', $this->mTimestamp ) ) {
+ $this->mTimestamp = '';
+ }
+ $this->mComment = $request->getText( 'wpComment' );
+
+ $this->mMerge = $request->wasPosted()
+ && $this->getUser()->matchEditToken( $request->getVal( 'wpEditToken' ) );
+
+ // target page
+ if ( $this->mSubmitted ) {
+ $this->mTargetObj = Title::newFromText( $this->mTarget );
+ $this->mDestObj = Title::newFromText( $this->mDest );
+ } else {
+ $this->mTargetObj = null;
+ $this->mDestObj = null;
+ }
+ }
+
+ public function execute( $par ) {
+ $this->useTransactionalTimeLimit();
+
+ $this->checkPermissions();
+ $this->checkReadOnly();
+
+ $this->loadRequestParams();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ if ( $this->mTargetID && $this->mDestID && $this->mAction == 'submit' && $this->mMerge ) {
+ $this->merge();
+
+ return;
+ }
+
+ if ( !$this->mSubmitted ) {
+ $this->showMergeForm();
+
+ return;
+ }
+
+ $errors = [];
+ if ( !$this->mTargetObj instanceof Title ) {
+ $errors[] = $this->msg( 'mergehistory-invalid-source' )->parseAsBlock();
+ } elseif ( !$this->mTargetObj->exists() ) {
+ $errors[] = $this->msg( 'mergehistory-no-source',
+ wfEscapeWikiText( $this->mTargetObj->getPrefixedText() )
+ )->parseAsBlock();
+ }
+
+ if ( !$this->mDestObj instanceof Title ) {
+ $errors[] = $this->msg( 'mergehistory-invalid-destination' )->parseAsBlock();
+ } elseif ( !$this->mDestObj->exists() ) {
+ $errors[] = $this->msg( 'mergehistory-no-destination',
+ wfEscapeWikiText( $this->mDestObj->getPrefixedText() )
+ )->parseAsBlock();
+ }
+
+ if ( $this->mTargetObj && $this->mDestObj && $this->mTargetObj->equals( $this->mDestObj ) ) {
+ $errors[] = $this->msg( 'mergehistory-same-destination' )->parseAsBlock();
+ }
+
+ if ( count( $errors ) ) {
+ $this->showMergeForm();
+ $this->getOutput()->addHTML( implode( "\n", $errors ) );
+ } else {
+ $this->showHistory();
+ }
+ }
+
+ function showMergeForm() {
+ $out = $this->getOutput();
+ $out->addWikiMsg( 'mergehistory-header' );
+
+ $out->addHTML(
+ Xml::openElement( 'form', [
+ 'method' => 'get',
+ 'action' => wfScript() ] ) .
+ '<fieldset>' .
+ Xml::element( 'legend', [],
+ $this->msg( 'mergehistory-box' )->text() ) .
+ Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
+ Html::hidden( 'submitted', '1' ) .
+ Html::hidden( 'mergepoint', $this->mTimestamp ) .
+ Xml::openElement( 'table' ) .
+ '<tr>
+ <td>' . Xml::label( $this->msg( 'mergehistory-from' )->text(), 'target' ) . '</td>
+ <td>' . Xml::input( 'target', 30, $this->mTarget, [ 'id' => 'target' ] ) . '</td>
+ </tr><tr>
+ <td>' . Xml::label( $this->msg( 'mergehistory-into' )->text(), 'dest' ) . '</td>
+ <td>' . Xml::input( 'dest', 30, $this->mDest, [ 'id' => 'dest' ] ) . '</td>
+ </tr><tr><td>' .
+ Xml::submitButton( $this->msg( 'mergehistory-go' )->text() ) .
+ '</td></tr>' .
+ Xml::closeElement( 'table' ) .
+ '</fieldset>' .
+ '</form>'
+ );
+
+ $this->addHelpLink( 'Help:Merge history' );
+ }
+
+ private function showHistory() {
+ $this->showMergeForm();
+
+ # List all stored revisions
+ $revisions = new MergeHistoryPager(
+ $this, [], $this->mTargetObj, $this->mDestObj
+ );
+ $haveRevisions = $revisions && $revisions->getNumRows() > 0;
+
+ $out = $this->getOutput();
+ $titleObj = $this->getPageTitle();
+ $action = $titleObj->getLocalURL( [ 'action' => 'submit' ] );
+ # Start the form here
+ $top = Xml::openElement(
+ 'form',
+ [
+ 'method' => 'post',
+ 'action' => $action,
+ 'id' => 'merge'
+ ]
+ );
+ $out->addHTML( $top );
+
+ if ( $haveRevisions ) {
+ # Format the user-visible controls (comment field, submission button)
+ # in a nice little table
+ $table =
+ Xml::openElement( 'fieldset' ) .
+ $this->msg( 'mergehistory-merge', $this->mTargetObj->getPrefixedText(),
+ $this->mDestObj->getPrefixedText() )->parse() .
+ Xml::openElement( 'table', [ 'id' => 'mw-mergehistory-table' ] ) .
+ '<tr>
+ <td class="mw-label">' .
+ Xml::label( $this->msg( 'mergehistory-reason' )->text(), 'wpComment' ) .
+ '</td>
+ <td class="mw-input">' .
+ Xml::input( 'wpComment', 50, $this->mComment, [ 'id' => 'wpComment' ] ) .
+ '</td>
+ </tr>
+ <tr>
+ <td>&#160;</td>
+ <td class="mw-submit">' .
+ Xml::submitButton(
+ $this->msg( 'mergehistory-submit' )->text(),
+ [ 'name' => 'merge', 'id' => 'mw-merge-submit' ]
+ ) .
+ '</td>
+ </tr>' .
+ Xml::closeElement( 'table' ) .
+ Xml::closeElement( 'fieldset' );
+
+ $out->addHTML( $table );
+ }
+
+ $out->addHTML(
+ '<h2 id="mw-mergehistory">' .
+ $this->msg( 'mergehistory-list' )->escaped() . "</h2>\n"
+ );
+
+ if ( $haveRevisions ) {
+ $out->addHTML( $revisions->getNavigationBar() );
+ $out->addHTML( '<ul>' );
+ $out->addHTML( $revisions->getBody() );
+ $out->addHTML( '</ul>' );
+ $out->addHTML( $revisions->getNavigationBar() );
+ } else {
+ $out->addWikiMsg( 'mergehistory-empty' );
+ }
+
+ # Show relevant lines from the merge log:
+ $mergeLogPage = new LogPage( 'merge' );
+ $out->addHTML( '<h2>' . $mergeLogPage->getName()->escaped() . "</h2>\n" );
+ LogEventsList::showLogExtract( $out, 'merge', $this->mTargetObj );
+
+ # When we submit, go by page ID to avoid some nasty but unlikely collisions.
+ # Such would happen if a page was renamed after the form loaded, but before submit
+ $misc = Html::hidden( 'targetID', $this->mTargetObj->getArticleID() );
+ $misc .= Html::hidden( 'destID', $this->mDestObj->getArticleID() );
+ $misc .= Html::hidden( 'target', $this->mTarget );
+ $misc .= Html::hidden( 'dest', $this->mDest );
+ $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
+ $misc .= Xml::closeElement( 'form' );
+ $out->addHTML( $misc );
+
+ return true;
+ }
+
+ function formatRevisionRow( $row ) {
+ $rev = new Revision( $row );
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ $stxt = '';
+ $last = $this->msg( 'last' )->escaped();
+
+ $ts = wfTimestamp( TS_MW, $row->rev_timestamp );
+ $checkBox = Xml::radio( 'mergepoint', $ts, ( $this->mTimestamp === $ts ) );
+
+ $user = $this->getUser();
+
+ $pageLink = $linkRenderer->makeKnownLink(
+ $rev->getTitle(),
+ $this->getLanguage()->userTimeAndDate( $ts, $user ),
+ [],
+ [ 'oldid' => $rev->getId() ]
+ );
+ if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $pageLink = '<span class="history-deleted">' . $pageLink . '</span>';
+ }
+
+ # Last link
+ if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) {
+ $last = $this->msg( 'last' )->escaped();
+ } elseif ( isset( $this->prevId[$row->rev_id] ) ) {
+ $last = $linkRenderer->makeKnownLink(
+ $rev->getTitle(),
+ $this->msg( 'last' )->text(),
+ [],
+ [
+ 'diff' => $row->rev_id,
+ 'oldid' => $this->prevId[$row->rev_id]
+ ]
+ );
+ }
+
+ $userLink = Linker::revUserTools( $rev );
+
+ $size = $row->rev_len;
+ if ( !is_null( $size ) ) {
+ $stxt = Linker::formatRevisionSize( $size );
+ }
+ $comment = Linker::revComment( $rev );
+
+ return Html::rawElement( 'li', [],
+ $this->msg( 'mergehistory-revisionrow' )
+ ->rawParams( $checkBox, $last, $pageLink, $userLink, $stxt, $comment )->escaped() );
+ }
+
+ /**
+ * Actually attempt the history move
+ *
+ * @todo if all versions of page A are moved to B and then a user
+ * tries to do a reverse-merge via the "unmerge" log link, then page
+ * A will still be a redirect (as it was after the original merge),
+ * though it will have the old revisions back from before (as expected).
+ * The user may have to "undo" the redirect manually to finish the "unmerge".
+ * Maybe this should delete redirects at the target page of merges?
+ *
+ * @return bool Success
+ */
+ function merge() {
+ # Get the titles directly from the IDs, in case the target page params
+ # were spoofed. The queries are done based on the IDs, so it's best to
+ # keep it consistent...
+ $targetTitle = Title::newFromID( $this->mTargetID );
+ $destTitle = Title::newFromID( $this->mDestID );
+ if ( is_null( $targetTitle ) || is_null( $destTitle ) ) {
+ return false; // validate these
+ }
+ if ( $targetTitle->getArticleID() == $destTitle->getArticleID() ) {
+ return false;
+ }
+
+ // MergeHistory object
+ $mh = new MergeHistory( $targetTitle, $destTitle, $this->mTimestamp );
+
+ // Merge!
+ $mergeStatus = $mh->merge( $this->getUser(), $this->mComment );
+ if ( !$mergeStatus->isOK() ) {
+ // Failed merge
+ $this->getOutput()->addWikiMsg( $mergeStatus->getMessage() );
+ return false;
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ $targetLink = $linkRenderer->makeLink(
+ $targetTitle,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+
+ $this->getOutput()->addWikiMsg( $this->msg( 'mergehistory-done' )
+ ->rawParams( $targetLink )
+ ->params( $destTitle->getPrefixedText() )
+ ->numParams( $mh->getMergedRevisionCount() )
+ );
+
+ return true;
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMostcategories.php b/www/wiki/includes/specials/SpecialMostcategories.php
new file mode 100644
index 00000000..bebed12e
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMostcategories.php
@@ -0,0 +1,112 @@
+<?php
+/**
+ * Implements Special:Mostcategories
+ *
+ * Copyright © 2005 Ævar Arnfjörð Bjarmason
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A special page that list pages that have highest category count
+ *
+ * @ingroup SpecialPage
+ */
+class MostcategoriesPage extends QueryPage {
+ function __construct( $name = 'Mostcategories' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'categorylinks', 'page' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'COUNT(*)'
+ ],
+ 'conds' => [ 'page_namespace' => MWNamespace::getContentNamespaces() ],
+ 'options' => [
+ 'HAVING' => 'COUNT(*) > 1',
+ 'GROUP BY' => [ 'page_namespace', 'page_title' ]
+ ],
+ 'join_conds' => [
+ 'page' => [
+ 'LEFT JOIN',
+ 'page_id = cl_from'
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $title = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( !$title ) {
+ return Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ $result->namespace,
+ $result->title
+ )
+ );
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+ if ( $this->isCached() ) {
+ $link = $linkRenderer->makeLink( $title );
+ } else {
+ $link = $linkRenderer->makeKnownLink( $title );
+ }
+
+ $count = $this->msg( 'ncategories' )->numParams( $result->value )->escaped();
+
+ return $this->getLanguage()->specialList( $link, $count );
+ }
+
+ protected function getGroupName() {
+ return 'highuse';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMostimages.php b/www/wiki/includes/specials/SpecialMostimages.php
new file mode 100644
index 00000000..1339f4bc
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMostimages.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Implements Special:Mostimages
+ *
+ * Copyright © 2005 Ævar Arnfjörð Bjarmason
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ */
+
+/**
+ * A special page that lists most used images
+ *
+ * @ingroup SpecialPage
+ */
+class MostimagesPage extends ImageQueryPage {
+ function __construct( $name = 'Mostimages' ) {
+ parent::__construct( $name );
+ }
+
+ function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function getQueryInfo() {
+ return [
+ 'tables' => [ 'imagelinks' ],
+ 'fields' => [
+ 'namespace' => NS_FILE,
+ 'title' => 'il_to',
+ 'value' => 'COUNT(*)'
+ ],
+ 'options' => [
+ 'GROUP BY' => 'il_to',
+ 'HAVING' => 'COUNT(*) > 1'
+ ]
+ ];
+ }
+
+ function getCellHtml( $row ) {
+ return $this->msg( 'nimagelinks' )->numParams( $row->value )->escaped() . '<br />';
+ }
+
+ protected function getGroupName() {
+ return 'highuse';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMostinterwikis.php b/www/wiki/includes/specials/SpecialMostinterwikis.php
new file mode 100644
index 00000000..5e56694f
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMostinterwikis.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Implements Special:Mostinterwikis
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A special page that listed pages that have highest interwiki count
+ *
+ * @ingroup SpecialPage
+ */
+class MostinterwikisPage extends QueryPage {
+ function __construct( $name = 'Mostinterwikis' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [
+ 'langlinks',
+ 'page'
+ ], 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'COUNT(*)'
+ ], 'conds' => [
+ 'page_namespace' => MWNamespace::getContentNamespaces()
+ ], 'options' => [
+ 'HAVING' => 'COUNT(*) > 1',
+ 'GROUP BY' => [
+ 'page_namespace',
+ 'page_title'
+ ]
+ ], 'join_conds' => [
+ 'page' => [
+ 'LEFT JOIN',
+ 'page_id = ll_from'
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * Pre-fill the link cache
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $title = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( !$title ) {
+ return Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ $result->namespace,
+ $result->title
+ )
+ );
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+ if ( $this->isCached() ) {
+ $link = $linkRenderer->makeLink( $title );
+ } else {
+ $link = $linkRenderer->makeKnownLink( $title );
+ }
+
+ $count = $this->msg( 'ninterwikis' )->numParams( $result->value )->escaped();
+
+ return $this->getLanguage()->specialList( $link, $count );
+ }
+
+ protected function getGroupName() {
+ return 'highuse';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMostlinked.php b/www/wiki/includes/specials/SpecialMostlinked.php
new file mode 100644
index 00000000..fbfaa738
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMostlinked.php
@@ -0,0 +1,135 @@
+<?php
+/**
+ * Implements Special:Mostlinked
+ *
+ * Copyright © 2005 Ævar Arnfjörð Bjarmason, 2006 Rob Church
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ * @author Rob Church <robchur@gmail.com>
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A special page to show pages ordered by the number of pages linking to them.
+ *
+ * @ingroup SpecialPage
+ */
+class MostlinkedPage extends QueryPage {
+ function __construct( $name = 'Mostlinked' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'pagelinks', 'page' ],
+ 'fields' => [
+ 'namespace' => 'pl_namespace',
+ 'title' => 'pl_title',
+ 'value' => 'COUNT(*)',
+ 'page_namespace'
+ ],
+ 'options' => [
+ 'HAVING' => 'COUNT(*) > 1',
+ 'GROUP BY' => [
+ 'pl_namespace', 'pl_title',
+ 'page_namespace'
+ ]
+ ],
+ 'join_conds' => [
+ 'page' => [
+ 'LEFT JOIN',
+ [
+ 'page_namespace = pl_namespace',
+ 'page_title = pl_title'
+ ]
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * Pre-fill the link cache
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * Make a link to "what links here" for the specified title
+ *
+ * @param Title $title Title being queried
+ * @param string $caption Text to display on the link
+ * @return string
+ */
+ function makeWlhLink( $title, $caption ) {
+ $wlh = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedDBkey() );
+
+ $linkRenderer = $this->getLinkRenderer();
+ return $linkRenderer->makeKnownLink( $wlh, $caption );
+ }
+
+ /**
+ * Make links to the page corresponding to the item,
+ * and the "what links here" page for it
+ *
+ * @param Skin $skin Skin to be used
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $title = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( !$title ) {
+ return Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ $result->namespace,
+ $result->title )
+ );
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+ $link = $linkRenderer->makeLink( $title );
+ $wlh = $this->makeWlhLink(
+ $title,
+ $this->msg( 'nlinks' )->numParams( $result->value )->text()
+ );
+
+ return $this->getLanguage()->specialList( $link, $wlh );
+ }
+
+ protected function getGroupName() {
+ return 'highuse';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMostlinkedcategories.php b/www/wiki/includes/specials/SpecialMostlinkedcategories.php
new file mode 100644
index 00000000..956207f8
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMostlinkedcategories.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Implements Special:Mostlinkedcategories
+ *
+ * Copyright © 2005, Ævar Arnfjörð Bjarmason
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A querypage to show categories ordered in descending order by the pages in them
+ *
+ * @ingroup SpecialPage
+ */
+class MostlinkedCategoriesPage extends QueryPage {
+ function __construct( $name = 'Mostlinkedcategories' ) {
+ parent::__construct( $name );
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'category' ],
+ 'fields' => [ 'title' => 'cat_title',
+ 'namespace' => NS_CATEGORY,
+ 'value' => 'cat_pages' ],
+ 'conds' => [ 'cat_pages > 0' ],
+ ];
+ }
+
+ function sortDescending() {
+ return true;
+ }
+
+ /**
+ * Fetch user page links and cache their existence
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ global $wgContLang;
+
+ $nt = Title::makeTitleSafe( NS_CATEGORY, $result->title );
+ if ( !$nt ) {
+ return Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ NS_CATEGORY,
+ $result->title )
+ );
+ }
+
+ $text = $wgContLang->convert( $nt->getText() );
+ $plink = $this->getLinkRenderer()->makeLink( $nt, $text );
+ $nlinks = $this->msg( 'nmembers' )->numParams( $result->value )->escaped();
+
+ return $this->getLanguage()->specialList( $plink, $nlinks );
+ }
+
+ protected function getGroupName() {
+ return 'highuse';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMostlinkedtemplates.php b/www/wiki/includes/specials/SpecialMostlinkedtemplates.php
new file mode 100644
index 00000000..dee1c8ec
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMostlinkedtemplates.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * Implements Special:Mostlinkedtemplates
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Special page lists templates with a large number of
+ * transclusion links, i.e. "most used" templates
+ *
+ * @ingroup SpecialPage
+ */
+class MostlinkedTemplatesPage extends QueryPage {
+ function __construct( $name = 'Mostlinkedtemplates' ) {
+ parent::__construct( $name );
+ }
+
+ /**
+ * Is this report expensive, i.e should it be cached?
+ *
+ * @return bool
+ */
+ public function isExpensive() {
+ return true;
+ }
+
+ /**
+ * Is there a feed available?
+ *
+ * @return bool
+ */
+ public function isSyndicated() {
+ return false;
+ }
+
+ /**
+ * Sort the results in descending order?
+ *
+ * @return bool
+ */
+ public function sortDescending() {
+ return true;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'templatelinks' ],
+ 'fields' => [
+ 'namespace' => 'tl_namespace',
+ 'title' => 'tl_title',
+ 'value' => 'COUNT(*)'
+ ],
+ 'options' => [ 'GROUP BY' => [ 'tl_namespace', 'tl_title' ] ]
+ ];
+ }
+
+ /**
+ * Pre-cache page existence to speed up link generation
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ public function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * Format a result row
+ *
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ public function formatResult( $skin, $result ) {
+ $title = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( !$title ) {
+ return Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ $result->namespace,
+ $result->title
+ )
+ );
+ }
+
+ return $this->getLanguage()->specialList(
+ $this->getLinkRenderer()->makeLink( $title ),
+ $this->makeWlhLink( $title, $result )
+ );
+ }
+
+ /**
+ * Make a "what links here" link for a given title
+ *
+ * @param Title $title Title to make the link for
+ * @param object $result Result row
+ * @return string
+ */
+ private function makeWlhLink( $title, $result ) {
+ $wlh = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() );
+ $label = $this->msg( 'ntransclusions' )->numParams( $result->value )->text();
+
+ return $this->getLinkRenderer()->makeLink( $wlh, $label );
+ }
+
+ protected function getGroupName() {
+ return 'highuse';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMostrevisions.php b/www/wiki/includes/specials/SpecialMostrevisions.php
new file mode 100644
index 00000000..0471cafe
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMostrevisions.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Implements Special:Mostrevisions
+ *
+ * Copyright © 2005 Ævar Arnfjörð Bjarmason
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ */
+
+class MostrevisionsPage extends FewestrevisionsPage {
+ function __construct( $name = 'Mostrevisions' ) {
+ parent::__construct( $name );
+ }
+
+ function sortDescending() {
+ return true;
+ }
+
+ protected function getGroupName() {
+ return 'highuse';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMovepage.php b/www/wiki/includes/specials/SpecialMovepage.php
new file mode 100644
index 00000000..46d7cf7a
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMovepage.php
@@ -0,0 +1,867 @@
+<?php
+/**
+ * Implements Special:Movepage
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that allows users to change page titles
+ *
+ * @ingroup SpecialPage
+ */
+class MovePageForm extends UnlistedSpecialPage {
+ /** @var Title */
+ protected $oldTitle = null;
+
+ /** @var Title */
+ protected $newTitle;
+
+ /** @var string Text input */
+ protected $reason;
+
+ // Checks
+
+ /** @var bool */
+ protected $moveTalk;
+
+ /** @var bool */
+ protected $deleteAndMove;
+
+ /** @var bool */
+ protected $moveSubpages;
+
+ /** @var bool */
+ protected $fixRedirects;
+
+ /** @var bool */
+ protected $leaveRedirect;
+
+ /** @var bool */
+ protected $moveOverShared;
+
+ private $watch = false;
+
+ public function __construct() {
+ parent::__construct( 'Movepage' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function execute( $par ) {
+ $this->useTransactionalTimeLimit();
+
+ $this->checkReadOnly();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $request = $this->getRequest();
+ $target = !is_null( $par ) ? $par : $request->getVal( 'target' );
+
+ // Yes, the use of getVal() and getText() is wanted, see T22365
+
+ $oldTitleText = $request->getVal( 'wpOldTitle', $target );
+ $this->oldTitle = Title::newFromText( $oldTitleText );
+
+ if ( !$this->oldTitle ) {
+ // Either oldTitle wasn't passed, or newFromText returned null
+ throw new ErrorPageError( 'notargettitle', 'notargettext' );
+ }
+ if ( !$this->oldTitle->exists() ) {
+ throw new ErrorPageError( 'nopagetitle', 'nopagetext' );
+ }
+
+ $newTitleTextMain = $request->getText( 'wpNewTitleMain' );
+ $newTitleTextNs = $request->getInt( 'wpNewTitleNs', $this->oldTitle->getNamespace() );
+ // Backwards compatibility for forms submitting here from other sources
+ // which is more common than it should be..
+ $newTitleText_bc = $request->getText( 'wpNewTitle' );
+ $this->newTitle = strlen( $newTitleText_bc ) > 0
+ ? Title::newFromText( $newTitleText_bc )
+ : Title::makeTitleSafe( $newTitleTextNs, $newTitleTextMain );
+
+ $user = $this->getUser();
+
+ # Check rights
+ $permErrors = $this->oldTitle->getUserPermissionsErrors( 'move', $user );
+ if ( count( $permErrors ) ) {
+ // Auto-block user's IP if the account was "hard" blocked
+ DeferredUpdates::addCallableUpdate( function () use ( $user ) {
+ $user->spreadAnyEditBlock();
+ } );
+ throw new PermissionsError( 'move', $permErrors );
+ }
+
+ $def = !$request->wasPosted();
+
+ $this->reason = $request->getText( 'wpReason' );
+ $this->moveTalk = $request->getBool( 'wpMovetalk', $def );
+ $this->fixRedirects = $request->getBool( 'wpFixRedirects', $def );
+ $this->leaveRedirect = $request->getBool( 'wpLeaveRedirect', $def );
+ $this->moveSubpages = $request->getBool( 'wpMovesubpages' );
+ $this->deleteAndMove = $request->getBool( 'wpDeleteAndMove' );
+ $this->moveOverShared = $request->getBool( 'wpMoveOverSharedFile' );
+ $this->watch = $request->getCheck( 'wpWatch' ) && $user->isLoggedIn();
+
+ if ( 'submit' == $request->getVal( 'action' ) && $request->wasPosted()
+ && $user->matchEditToken( $request->getVal( 'wpEditToken' ) )
+ ) {
+ $this->doSubmit();
+ } else {
+ $this->showForm( [] );
+ }
+ }
+
+ /**
+ * Show the form
+ *
+ * @param array $err Error messages. Each item is an error message.
+ * It may either be a string message name or array message name and
+ * parameters, like the second argument to OutputPage::wrapWikiMsg().
+ */
+ function showForm( $err ) {
+ $this->getSkin()->setRelevantTitle( $this->oldTitle );
+
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'move-page', $this->oldTitle->getPrefixedText() ) );
+ $out->addModules( 'mediawiki.special.movePage' );
+ $out->addModuleStyles( 'mediawiki.special.movePage.styles' );
+ $this->addHelpLink( 'Help:Moving a page' );
+
+ $out->addWikiMsg( $this->getConfig()->get( 'FixDoubleRedirects' ) ?
+ 'movepagetext' :
+ 'movepagetext-noredirectfixer'
+ );
+
+ if ( $this->oldTitle->getNamespace() == NS_USER && !$this->oldTitle->isSubpage() ) {
+ $out->wrapWikiMsg(
+ "<div class=\"warningbox mw-moveuserpage-warning\">\n$1\n</div>",
+ 'moveuserpage-warning'
+ );
+ } elseif ( $this->oldTitle->getNamespace() == NS_CATEGORY ) {
+ $out->wrapWikiMsg(
+ "<div class=\"warningbox mw-movecategorypage-warning\">\n$1\n</div>",
+ 'movecategorypage-warning'
+ );
+ }
+
+ $deleteAndMove = false;
+ $moveOverShared = false;
+
+ $newTitle = $this->newTitle;
+
+ if ( !$newTitle ) {
+ # Show the current title as a default
+ # when the form is first opened.
+ $newTitle = $this->oldTitle;
+ } elseif ( !count( $err ) ) {
+ # If a title was supplied, probably from the move log revert
+ # link, check for validity. We can then show some diagnostic
+ # information and save a click.
+ $newerr = $this->oldTitle->isValidMoveOperation( $newTitle );
+ if ( is_array( $newerr ) ) {
+ $err = $newerr;
+ }
+ }
+
+ $user = $this->getUser();
+
+ if ( count( $err ) == 1 && isset( $err[0][0] ) && $err[0][0] == 'articleexists'
+ && $newTitle->quickUserCan( 'delete', $user )
+ ) {
+ $out->wrapWikiMsg(
+ "<div class='warningbox'>\n$1\n</div>\n",
+ [ 'delete_and_move_text', $newTitle->getPrefixedText() ]
+ );
+ $deleteAndMove = true;
+ $err = [];
+ }
+
+ if ( count( $err ) == 1 && isset( $err[0][0] ) && $err[0][0] == 'file-exists-sharedrepo'
+ && $user->isAllowed( 'reupload-shared' )
+ ) {
+ $out->wrapWikiMsg(
+ "<div class='warningbox'>\n$1\n</div>\n",
+ [
+ 'move-over-sharedrepo',
+ $newTitle->getPrefixedText()
+ ]
+ );
+ $moveOverShared = true;
+ $err = [];
+ }
+
+ $oldTalk = $this->oldTitle->getTalkPage();
+ $oldTitleSubpages = $this->oldTitle->hasSubpages();
+ $oldTitleTalkSubpages = $this->oldTitle->getTalkPage()->hasSubpages();
+
+ $canMoveSubpage = ( $oldTitleSubpages || $oldTitleTalkSubpages ) &&
+ !count( $this->oldTitle->getUserPermissionsErrors( 'move-subpages', $user ) );
+
+ # We also want to be able to move assoc. subpage talk-pages even if base page
+ # has no associated talk page, so || with $oldTitleTalkSubpages.
+ $considerTalk = !$this->oldTitle->isTalkPage() &&
+ ( $oldTalk->exists()
+ || ( $oldTitleTalkSubpages && $canMoveSubpage ) );
+
+ $dbr = wfGetDB( DB_REPLICA );
+ if ( $this->getConfig()->get( 'FixDoubleRedirects' ) ) {
+ $hasRedirects = $dbr->selectField( 'redirect', '1',
+ [
+ 'rd_namespace' => $this->oldTitle->getNamespace(),
+ 'rd_title' => $this->oldTitle->getDBkey(),
+ ], __METHOD__ );
+ } else {
+ $hasRedirects = false;
+ }
+
+ if ( count( $err ) ) {
+ $out->addHTML( "<div class='errorbox'>\n" );
+ $action_desc = $this->msg( 'action-move' )->plain();
+ $out->addWikiMsg( 'permissionserrorstext-withaction', count( $err ), $action_desc );
+
+ if ( count( $err ) == 1 ) {
+ $errMsg = $err[0];
+ $errMsgName = array_shift( $errMsg );
+
+ if ( $errMsgName == 'hookaborted' ) {
+ $out->addHTML( "<p>{$errMsg[0]}</p>\n" );
+ } else {
+ $out->addWikiMsgArray( $errMsgName, $errMsg );
+ }
+ } else {
+ $errStr = [];
+
+ foreach ( $err as $errMsg ) {
+ if ( $errMsg[0] == 'hookaborted' ) {
+ $errStr[] = $errMsg[1];
+ } else {
+ $errMsgName = array_shift( $errMsg );
+ $errStr[] = $this->msg( $errMsgName, $errMsg )->parse();
+ }
+ }
+
+ $out->addHTML( '<ul><li>' . implode( "</li>\n<li>", $errStr ) . "</li></ul>\n" );
+ }
+ $out->addHTML( "</div>\n" );
+ }
+
+ if ( $this->oldTitle->isProtected( 'move' ) ) {
+ # Is the title semi-protected?
+ if ( $this->oldTitle->isSemiProtected( 'move' ) ) {
+ $noticeMsg = 'semiprotectedpagemovewarning';
+ $classes[] = 'mw-textarea-sprotected';
+ } else {
+ # Then it must be protected based on static groups (regular)
+ $noticeMsg = 'protectedpagemovewarning';
+ $classes[] = 'mw-textarea-protected';
+ }
+ $out->addHTML( "<div class='mw-warning-with-logexcerpt'>\n" );
+ $out->addWikiMsg( $noticeMsg );
+ LogEventsList::showLogExtract(
+ $out,
+ 'protect',
+ $this->oldTitle,
+ '',
+ [ 'lim' => 1 ]
+ );
+ $out->addHTML( "</div>\n" );
+ }
+
+ // Byte limit (not string length limit) for wpReason and wpNewTitleMain
+ // is enforced in the mediawiki.special.movePage module
+
+ $immovableNamespaces = [];
+ foreach ( array_keys( $this->getLanguage()->getNamespaces() ) as $nsId ) {
+ if ( !MWNamespace::isMovable( $nsId ) ) {
+ $immovableNamespaces[] = $nsId;
+ }
+ }
+
+ $handler = ContentHandler::getForTitle( $this->oldTitle );
+
+ $out->enableOOUI();
+ $fields = [];
+
+ $fields[] = new OOUI\FieldLayout(
+ new MediaWiki\Widget\ComplexTitleInputWidget( [
+ 'id' => 'wpNewTitle',
+ 'namespace' => [
+ 'id' => 'wpNewTitleNs',
+ 'name' => 'wpNewTitleNs',
+ 'value' => $newTitle->getNamespace(),
+ 'exclude' => $immovableNamespaces,
+ ],
+ 'title' => [
+ 'id' => 'wpNewTitleMain',
+ 'name' => 'wpNewTitleMain',
+ 'value' => $newTitle->getText(),
+ // Inappropriate, since we're expecting the user to input a non-existent page's title
+ 'suggestions' => false,
+ ],
+ 'infusable' => true,
+ ] ),
+ [
+ 'label' => $this->msg( 'newtitle' )->text(),
+ 'align' => 'top',
+ ]
+ );
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\TextInputWidget( [
+ 'name' => 'wpReason',
+ 'id' => 'wpReason',
+ 'maxLength' => 200,
+ 'infusable' => true,
+ 'value' => $this->reason,
+ ] ),
+ [
+ 'label' => $this->msg( 'movereason' )->text(),
+ 'align' => 'top',
+ ]
+ );
+
+ if ( $considerTalk ) {
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpMovetalk',
+ 'id' => 'wpMovetalk',
+ 'value' => '1',
+ 'selected' => $this->moveTalk,
+ ] ),
+ [
+ 'label' => $this->msg( 'movetalk' )->text(),
+ 'help' => new OOUI\HtmlSnippet( $this->msg( 'movepagetalktext' )->parseAsBlock() ),
+ 'align' => 'inline',
+ 'infusable' => true,
+ 'id' => 'wpMovetalk-field',
+ ]
+ );
+ }
+
+ if ( $user->isAllowed( 'suppressredirect' ) ) {
+ if ( $handler->supportsRedirects() ) {
+ $isChecked = $this->leaveRedirect;
+ $isDisabled = false;
+ } else {
+ $isChecked = false;
+ $isDisabled = true;
+ }
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpLeaveRedirect',
+ 'id' => 'wpLeaveRedirect',
+ 'value' => '1',
+ 'selected' => $isChecked,
+ 'disabled' => $isDisabled,
+ ] ),
+ [
+ 'label' => $this->msg( 'move-leave-redirect' )->text(),
+ 'align' => 'inline',
+ ]
+ );
+ }
+
+ if ( $hasRedirects ) {
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpFixRedirects',
+ 'id' => 'wpFixRedirects',
+ 'value' => '1',
+ 'selected' => $this->fixRedirects,
+ ] ),
+ [
+ 'label' => $this->msg( 'fix-double-redirects' )->text(),
+ 'align' => 'inline',
+ ]
+ );
+ }
+
+ if ( $canMoveSubpage ) {
+ $maximumMovedPages = $this->getConfig()->get( 'MaximumMovedPages' );
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpMovesubpages',
+ 'id' => 'wpMovesubpages',
+ 'value' => '1',
+ # Don't check the box if we only have talk subpages to
+ # move and we aren't moving the talk page.
+ 'selected' => $this->moveSubpages && ( $this->oldTitle->hasSubpages() || $this->moveTalk ),
+ ] ),
+ [
+ 'label' => new OOUI\HtmlSnippet(
+ $this->msg(
+ ( $this->oldTitle->hasSubpages()
+ ? 'move-subpages'
+ : 'move-talk-subpages' )
+ )->numParams( $maximumMovedPages )->params( $maximumMovedPages )->parse()
+ ),
+ 'align' => 'inline',
+ ]
+ );
+ }
+
+ # Don't allow watching if user is not logged in
+ if ( $user->isLoggedIn() ) {
+ $watchChecked = $user->isLoggedIn() && ( $this->watch || $user->getBoolOption( 'watchmoves' )
+ || $user->isWatched( $this->oldTitle ) );
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpWatch',
+ 'id' => 'watch', # ew
+ 'value' => '1',
+ 'selected' => $watchChecked,
+ ] ),
+ [
+ 'label' => $this->msg( 'move-watch' )->text(),
+ 'align' => 'inline',
+ ]
+ );
+ }
+
+ $hiddenFields = '';
+ if ( $moveOverShared ) {
+ $hiddenFields .= Html::hidden( 'wpMoveOverSharedFile', '1' );
+ }
+
+ if ( $deleteAndMove ) {
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpDeleteAndMove',
+ 'id' => 'wpDeleteAndMove',
+ 'value' => '1',
+ ] ),
+ [
+ 'label' => $this->msg( 'delete_and_move_confirm' )->text(),
+ 'align' => 'inline',
+ ]
+ );
+ }
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\ButtonInputWidget( [
+ 'name' => 'wpMove',
+ 'value' => $this->msg( 'movepagebtn' )->text(),
+ 'label' => $this->msg( 'movepagebtn' )->text(),
+ 'flags' => [ 'primary', 'progressive' ],
+ 'type' => 'submit',
+ ] ),
+ [
+ 'align' => 'top',
+ ]
+ );
+
+ $fieldset = new OOUI\FieldsetLayout( [
+ 'label' => $this->msg( 'move-page-legend' )->text(),
+ 'id' => 'mw-movepage-table',
+ 'items' => $fields,
+ ] );
+
+ $form = new OOUI\FormLayout( [
+ 'method' => 'post',
+ 'action' => $this->getPageTitle()->getLocalURL( 'action=submit' ),
+ 'id' => 'movepage',
+ ] );
+ $form->appendContent(
+ $fieldset,
+ new OOUI\HtmlSnippet(
+ $hiddenFields .
+ Html::hidden( 'wpOldTitle', $this->oldTitle->getPrefixedText() ) .
+ Html::hidden( 'wpEditToken', $user->getEditToken() )
+ )
+ );
+
+ $out->addHTML(
+ new OOUI\PanelLayout( [
+ 'classes' => [ 'movepage-wrapper' ],
+ 'expanded' => false,
+ 'padded' => true,
+ 'framed' => true,
+ 'content' => $form,
+ ] )
+ );
+
+ $this->showLogFragment( $this->oldTitle );
+ $this->showSubpages( $this->oldTitle );
+ }
+
+ function doSubmit() {
+ $user = $this->getUser();
+
+ if ( $user->pingLimiter( 'move' ) ) {
+ throw new ThrottledError;
+ }
+
+ $ot = $this->oldTitle;
+ $nt = $this->newTitle;
+
+ # don't allow moving to pages with # in
+ if ( !$nt || $nt->hasFragment() ) {
+ $this->showForm( [ [ 'badtitletext' ] ] );
+
+ return;
+ }
+
+ # Show a warning if the target file exists on a shared repo
+ if ( $nt->getNamespace() == NS_FILE
+ && !( $this->moveOverShared && $user->isAllowed( 'reupload-shared' ) )
+ && !RepoGroup::singleton()->getLocalRepo()->findFile( $nt )
+ && wfFindFile( $nt )
+ ) {
+ $this->showForm( [ [ 'file-exists-sharedrepo' ] ] );
+
+ return;
+ }
+
+ # Delete to make way if requested
+ if ( $this->deleteAndMove ) {
+ $permErrors = $nt->getUserPermissionsErrors( 'delete', $user );
+ if ( count( $permErrors ) ) {
+ # Only show the first error
+ $this->showForm( $permErrors );
+
+ return;
+ }
+
+ $reason = $this->msg( 'delete_and_move_reason', $ot )->inContentLanguage()->text();
+
+ // Delete an associated image if there is
+ if ( $nt->getNamespace() == NS_FILE ) {
+ $file = wfLocalFile( $nt );
+ $file->load( File::READ_LATEST );
+ if ( $file->exists() ) {
+ $file->delete( $reason, false, $user );
+ }
+ }
+
+ $error = ''; // passed by ref
+ $page = WikiPage::factory( $nt );
+ $deleteStatus = $page->doDeleteArticleReal( $reason, false, 0, true, $error, $user );
+ if ( !$deleteStatus->isGood() ) {
+ $this->showForm( $deleteStatus->getErrorsArray() );
+
+ return;
+ }
+ }
+
+ $handler = ContentHandler::getForTitle( $ot );
+
+ if ( !$handler->supportsRedirects() ) {
+ $createRedirect = false;
+ } elseif ( $user->isAllowed( 'suppressredirect' ) ) {
+ $createRedirect = $this->leaveRedirect;
+ } else {
+ $createRedirect = true;
+ }
+
+ # Do the actual move.
+ $mp = new MovePage( $ot, $nt );
+ $valid = $mp->isValidMove();
+ if ( !$valid->isOK() ) {
+ $this->showForm( $valid->getErrorsArray() );
+ return;
+ }
+
+ $permStatus = $mp->checkPermissions( $user, $this->reason );
+ if ( !$permStatus->isOK() ) {
+ $this->showForm( $permStatus->getErrorsArray() );
+ return;
+ }
+
+ $status = $mp->move( $user, $this->reason, $createRedirect );
+ if ( !$status->isOK() ) {
+ $this->showForm( $status->getErrorsArray() );
+ return;
+ }
+
+ if ( $this->getConfig()->get( 'FixDoubleRedirects' ) && $this->fixRedirects ) {
+ DoubleRedirectJob::fixRedirects( 'move', $ot, $nt );
+ }
+
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'pagemovedsub' ) );
+
+ $linkRenderer = $this->getLinkRenderer();
+ $oldLink = $linkRenderer->makeLink(
+ $ot,
+ null,
+ [ 'id' => 'movepage-oldlink' ],
+ [ 'redirect' => 'no' ]
+ );
+ $newLink = $linkRenderer->makeKnownLink(
+ $nt,
+ null,
+ [ 'id' => 'movepage-newlink' ]
+ );
+ $oldText = $ot->getPrefixedText();
+ $newText = $nt->getPrefixedText();
+
+ if ( $ot->exists() ) {
+ // NOTE: we assume that if the old title exists, it's because it was re-created as
+ // a redirect to the new title. This is not safe, but what we did before was
+ // even worse: we just determined whether a redirect should have been created,
+ // and reported that it was created if it should have, without any checks.
+ // Also note that isRedirect() is unreliable because of T39209.
+ $msgName = 'movepage-moved-redirect';
+ } else {
+ $msgName = 'movepage-moved-noredirect';
+ }
+
+ $out->addHTML( $this->msg( 'movepage-moved' )->rawParams( $oldLink,
+ $newLink )->params( $oldText, $newText )->parseAsBlock() );
+ $out->addWikiMsg( $msgName );
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $movePage = $this;
+ Hooks::run( 'SpecialMovepageAfterMove', [ &$movePage, &$ot, &$nt ] );
+
+ # Now we move extra pages we've been asked to move: subpages and talk
+ # pages. First, if the old page or the new page is a talk page, we
+ # can't move any talk pages: cancel that.
+ if ( $ot->isTalkPage() || $nt->isTalkPage() ) {
+ $this->moveTalk = false;
+ }
+
+ if ( count( $ot->getUserPermissionsErrors( 'move-subpages', $user ) ) ) {
+ $this->moveSubpages = false;
+ }
+
+ /**
+ * Next make a list of id's. This might be marginally less efficient
+ * than a more direct method, but this is not a highly performance-cri-
+ * tical code path and readable code is more important here.
+ *
+ * If the target namespace doesn't allow subpages, moving with subpages
+ * would mean that you couldn't move them back in one operation, which
+ * is bad.
+ * @todo FIXME: A specific error message should be given in this case.
+ */
+
+ // @todo FIXME: Use Title::moveSubpages() here
+ $dbr = wfGetDB( DB_MASTER );
+ if ( $this->moveSubpages && (
+ MWNamespace::hasSubpages( $nt->getNamespace() ) || (
+ $this->moveTalk
+ && MWNamespace::hasSubpages( $nt->getTalkPage()->getNamespace() )
+ )
+ ) ) {
+ $conds = [
+ 'page_title' . $dbr->buildLike( $ot->getDBkey() . '/', $dbr->anyString() )
+ . ' OR page_title = ' . $dbr->addQuotes( $ot->getDBkey() )
+ ];
+ $conds['page_namespace'] = [];
+ if ( MWNamespace::hasSubpages( $nt->getNamespace() ) ) {
+ $conds['page_namespace'][] = $ot->getNamespace();
+ }
+ if ( $this->moveTalk &&
+ MWNamespace::hasSubpages( $nt->getTalkPage()->getNamespace() )
+ ) {
+ $conds['page_namespace'][] = $ot->getTalkPage()->getNamespace();
+ }
+ } elseif ( $this->moveTalk ) {
+ $conds = [
+ 'page_namespace' => $ot->getTalkPage()->getNamespace(),
+ 'page_title' => $ot->getDBkey()
+ ];
+ } else {
+ # Skip the query
+ $conds = null;
+ }
+
+ $extraPages = [];
+ if ( !is_null( $conds ) ) {
+ $extraPages = TitleArray::newFromResult(
+ $dbr->select( 'page',
+ [ 'page_id', 'page_namespace', 'page_title' ],
+ $conds,
+ __METHOD__
+ )
+ );
+ }
+
+ $extraOutput = [];
+ $count = 1;
+ foreach ( $extraPages as $oldSubpage ) {
+ if ( $ot->equals( $oldSubpage ) || $nt->equals( $oldSubpage ) ) {
+ # Already did this one.
+ continue;
+ }
+
+ $newPageName = preg_replace(
+ '#^' . preg_quote( $ot->getDBkey(), '#' ) . '#',
+ StringUtils::escapeRegexReplacement( $nt->getDBkey() ), # T23234
+ $oldSubpage->getDBkey()
+ );
+
+ if ( $oldSubpage->isSubpage() && ( $ot->isTalkPage() xor $nt->isTalkPage() ) ) {
+ // Moving a subpage from a subject namespace to a talk namespace or vice-versa
+ $newNs = $nt->getNamespace();
+ } elseif ( $oldSubpage->isTalkPage() ) {
+ $newNs = $nt->getTalkPage()->getNamespace();
+ } else {
+ $newNs = $nt->getSubjectPage()->getNamespace();
+ }
+
+ # T16385: we need makeTitleSafe because the new page names may
+ # be longer than 255 characters.
+ $newSubpage = Title::makeTitleSafe( $newNs, $newPageName );
+ if ( !$newSubpage ) {
+ $oldLink = $linkRenderer->makeKnownLink( $oldSubpage );
+ $extraOutput[] = $this->msg( 'movepage-page-unmoved' )->rawParams( $oldLink )
+ ->params( Title::makeName( $newNs, $newPageName ) )->escaped();
+ continue;
+ }
+
+ # This was copy-pasted from Renameuser, bleh.
+ if ( $newSubpage->exists() && !$oldSubpage->isValidMoveTarget( $newSubpage ) ) {
+ $link = $linkRenderer->makeKnownLink( $newSubpage );
+ $extraOutput[] = $this->msg( 'movepage-page-exists' )->rawParams( $link )->escaped();
+ } else {
+ $success = $oldSubpage->moveTo( $newSubpage, true, $this->reason, $createRedirect );
+
+ if ( $success === true ) {
+ if ( $this->fixRedirects ) {
+ DoubleRedirectJob::fixRedirects( 'move', $oldSubpage, $newSubpage );
+ }
+ $oldLink = $linkRenderer->makeLink(
+ $oldSubpage,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+
+ $newLink = $linkRenderer->makeKnownLink( $newSubpage );
+ $extraOutput[] = $this->msg( 'movepage-page-moved' )
+ ->rawParams( $oldLink, $newLink )->escaped();
+ ++$count;
+
+ $maximumMovedPages = $this->getConfig()->get( 'MaximumMovedPages' );
+ if ( $count >= $maximumMovedPages ) {
+ $extraOutput[] = $this->msg( 'movepage-max-pages' )
+ ->numParams( $maximumMovedPages )->escaped();
+ break;
+ }
+ } else {
+ $oldLink = $linkRenderer->makeKnownLink( $oldSubpage );
+ $newLink = $linkRenderer->makeLink( $newSubpage );
+ $extraOutput[] = $this->msg( 'movepage-page-unmoved' )
+ ->rawParams( $oldLink, $newLink )->escaped();
+ }
+ }
+ }
+
+ if ( $extraOutput !== [] ) {
+ $out->addHTML( "<ul>\n<li>" . implode( "</li>\n<li>", $extraOutput ) . "</li>\n</ul>" );
+ }
+
+ # Deal with watches (we don't watch subpages)
+ WatchAction::doWatchOrUnwatch( $this->watch, $ot, $user );
+ WatchAction::doWatchOrUnwatch( $this->watch, $nt, $user );
+
+ /**
+ * T163966
+ * Increment user_editcount during page moves
+ */
+ $user->incEditCount();
+ }
+
+ function showLogFragment( $title ) {
+ $moveLogPage = new LogPage( 'move' );
+ $out = $this->getOutput();
+ $out->addHTML( Xml::element( 'h2', null, $moveLogPage->getName()->text() ) );
+ LogEventsList::showLogExtract( $out, 'move', $title );
+ }
+
+ /**
+ * Show subpages of the page being moved. Section is not shown if both current
+ * namespace does not support subpages and no talk subpages were found.
+ *
+ * @param Title $title Page being moved.
+ */
+ function showSubpages( $title ) {
+ $nsHasSubpages = MWNamespace::hasSubpages( $title->getNamespace() );
+ $subpages = $title->getSubpages();
+ $count = $subpages instanceof TitleArray ? $subpages->count() : 0;
+
+ $titleIsTalk = $title->isTalkPage();
+ $subpagesTalk = $title->getTalkPage()->getSubpages();
+ $countTalk = $subpagesTalk instanceof TitleArray ? $subpagesTalk->count() : 0;
+ $totalCount = $count + $countTalk;
+
+ if ( !$nsHasSubpages && $countTalk == 0 ) {
+ return;
+ }
+
+ $this->getOutput()->wrapWikiMsg(
+ '== $1 ==',
+ [ 'movesubpage', ( $titleIsTalk ? $count : $totalCount ) ]
+ );
+
+ if ( $nsHasSubpages ) {
+ $this->showSubpagesList( $subpages, $count, 'movesubpagetext', true );
+ }
+
+ if ( !$titleIsTalk && $countTalk > 0 ) {
+ $this->showSubpagesList( $subpagesTalk, $countTalk, 'movesubpagetalktext' );
+ }
+ }
+
+ function showSubpagesList( $subpages, $pagecount, $wikiMsg, $noSubpageMsg = false ) {
+ $out = $this->getOutput();
+
+ # No subpages.
+ if ( $pagecount == 0 && $noSubpageMsg ) {
+ $out->addWikiMsg( 'movenosubpage' );
+ return;
+ }
+
+ $out->addWikiMsg( $wikiMsg, $this->getLanguage()->formatNum( $pagecount ) );
+ $out->addHTML( "<ul>\n" );
+
+ $linkBatch = new LinkBatch( $subpages );
+ $linkBatch->setCaller( __METHOD__ );
+ $linkBatch->execute();
+ $linkRenderer = $this->getLinkRenderer();
+
+ foreach ( $subpages as $subpage ) {
+ $link = $linkRenderer->makeLink( $subpage );
+ $out->addHTML( "<li>$link</li>\n" );
+ }
+ $out->addHTML( "</ul>\n" );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMyLanguage.php b/www/wiki/includes/specials/SpecialMyLanguage.php
new file mode 100644
index 00000000..9cb6d4b5
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMyLanguage.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * Implements Special:MyLanguage
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Niklas Laxström
+ * @author Siebrand Mazeland
+ * @copyright Copyright © 2010-2013 Niklas Laxström, Siebrand Mazeland
+ */
+
+/**
+ * Unlisted special page just to redirect the user to the translated version of
+ * a page, if it exists.
+ *
+ * Usage: [[Special:MyLanguage/Page name|link text]]
+ *
+ * @since 1.24
+ * @ingroup SpecialPage
+ */
+class SpecialMyLanguage extends RedirectSpecialArticle {
+ public function __construct() {
+ parent::__construct( 'MyLanguage' );
+ }
+
+ /**
+ * If the special page is a redirect, then get the Title object it redirects to.
+ * False otherwise.
+ *
+ * @param string|null $subpage
+ * @return Title
+ */
+ public function getRedirect( $subpage ) {
+ $title = $this->findTitle( $subpage );
+ // Go to the main page if given invalid title.
+ if ( !$title ) {
+ $title = Title::newMainPage();
+ }
+ return $title;
+ }
+
+ /**
+ * Assuming the user's interface language is fi. Given input Page, it
+ * returns Page/fi if it exists, otherwise Page. Given input Page/de,
+ * it returns Page/fi if it exists, otherwise Page/de if it exists,
+ * otherwise Page.
+ *
+ * @param string|null $subpage
+ * @return Title|null
+ */
+ public function findTitle( $subpage ) {
+ // base = title without language code suffix
+ // provided = the title as it was given
+ $base = $provided = null;
+ if ( $subpage !== null ) {
+ $provided = Title::newFromText( $subpage );
+ $base = $provided;
+ }
+
+ if ( $provided && strpos( $subpage, '/' ) !== false ) {
+ $pos = strrpos( $subpage, '/' );
+ $basepage = substr( $subpage, 0, $pos );
+ $code = substr( $subpage, $pos + 1 );
+ if ( strlen( $code ) && Language::isKnownLanguageTag( $code ) ) {
+ $base = Title::newFromText( $basepage );
+ }
+ }
+
+ if ( !$base ) {
+ return null;
+ }
+
+ if ( $base->isRedirect() ) {
+ $page = new WikiPage( $base );
+ $base = $page->getRedirectTarget();
+ }
+
+ $uiCode = $this->getLanguage()->getCode();
+ $proposed = $base->getSubpage( $uiCode );
+ if ( $proposed && $proposed->exists() && $uiCode !== $base->getPageLanguage()->getCode() ) {
+ return $proposed;
+ } elseif ( $provided && $provided->exists() ) {
+ return $provided;
+ } else {
+ return $base;
+ }
+ }
+
+ /**
+ * Target can identify a specific user's language preference.
+ *
+ * @see T109724
+ * @since 1.27
+ * @return bool
+ */
+ public function personallyIdentifiableTarget() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialMyRedirectPages.php b/www/wiki/includes/specials/SpecialMyRedirectPages.php
new file mode 100644
index 00000000..4521a53f
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialMyRedirectPages.php
@@ -0,0 +1,185 @@
+<?php
+/**
+ * Special pages that are used to get user independent links pointing to
+ * current user's pages (user page, talk page, contributions, etc.).
+ * This can let us cache a single copy of some generated content for all
+ * users or be linked in wikitext help pages.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page pointing to current user's user page.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialMypage extends RedirectSpecialArticle {
+ public function __construct() {
+ parent::__construct( 'Mypage' );
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title
+ */
+ public function getRedirect( $subpage ) {
+ if ( $subpage === null || $subpage === '' ) {
+ return Title::makeTitle( NS_USER, $this->getUser()->getName() );
+ }
+
+ return Title::makeTitle( NS_USER, $this->getUser()->getName() . '/' . $subpage );
+ }
+
+ /**
+ * Target identifies a specific User. See T109724.
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function personallyIdentifiableTarget() {
+ return true;
+ }
+}
+
+/**
+ * Special page pointing to current user's talk page.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialMytalk extends RedirectSpecialArticle {
+ public function __construct() {
+ parent::__construct( 'Mytalk' );
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title
+ */
+ public function getRedirect( $subpage ) {
+ if ( $subpage === null || $subpage === '' ) {
+ return Title::makeTitle( NS_USER_TALK, $this->getUser()->getName() );
+ }
+
+ return Title::makeTitle( NS_USER_TALK, $this->getUser()->getName() . '/' . $subpage );
+ }
+
+ /**
+ * Target identifies a specific User. See T109724.
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function personallyIdentifiableTarget() {
+ return true;
+ }
+}
+
+/**
+ * Special page pointing to current user's contributions.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialMycontributions extends RedirectSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Mycontributions' );
+ $this->mAllowedRedirectParams = [ 'limit', 'namespace', 'tagfilter',
+ 'offset', 'dir', 'year', 'month', 'feed', 'deletedOnly',
+ 'nsInvert', 'associated', 'newOnly', 'topOnly' ];
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title
+ */
+ public function getRedirect( $subpage ) {
+ return SpecialPage::getTitleFor( 'Contributions', $this->getUser()->getName() );
+ }
+
+ /**
+ * Target identifies a specific User. See T109724.
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function personallyIdentifiableTarget() {
+ return true;
+ }
+}
+
+/**
+ * Special page pointing to current user's uploaded files.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialMyuploads extends RedirectSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Myuploads' );
+ $this->mAllowedRedirectParams = [ 'limit', 'ilshowall', 'ilsearch' ];
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title
+ */
+ public function getRedirect( $subpage ) {
+ return SpecialPage::getTitleFor( 'Listfiles', $this->getUser()->getName() );
+ }
+
+ /**
+ * Target identifies a specific User. See T109724.
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function personallyIdentifiableTarget() {
+ return true;
+ }
+}
+
+/**
+ * Special page pointing to current user's uploaded files (including old versions).
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialAllMyUploads extends RedirectSpecialPage {
+ public function __construct() {
+ parent::__construct( 'AllMyUploads' );
+ $this->mAllowedRedirectParams = [ 'limit', 'ilsearch' ];
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title
+ */
+ public function getRedirect( $subpage ) {
+ $this->mAddedRedirectParams['ilshowall'] = 1;
+
+ return SpecialPage::getTitleFor( 'Listfiles', $this->getUser()->getName() );
+ }
+
+ /**
+ * Target identifies a specific User. See T109724.
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function personallyIdentifiableTarget() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialNewimages.php b/www/wiki/includes/specials/SpecialNewimages.php
new file mode 100644
index 00000000..693b8aa9
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialNewimages.php
@@ -0,0 +1,230 @@
+<?php
+/**
+ * Implements Special:Newimages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+class SpecialNewFiles extends IncludableSpecialPage {
+ /** @var FormOptions */
+ protected $opts;
+
+ /** @var string[] */
+ protected $mediaTypes;
+
+ public function __construct() {
+ parent::__construct( 'Newimages' );
+ }
+
+ public function execute( $par ) {
+ $context = new DerivativeContext( $this->getContext() );
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $mimeAnalyzer = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer();
+ $this->mediaTypes = $mimeAnalyzer->getMediaTypes();
+
+ $out = $this->getOutput();
+ $this->addHelpLink( 'Help:New images' );
+
+ $opts = new FormOptions();
+
+ $opts->add( 'like', '' );
+ $opts->add( 'user', '' );
+ $opts->add( 'showbots', false );
+ $opts->add( 'newbies', false );
+ $opts->add( 'hidepatrolled', false );
+ $opts->add( 'mediatype', $this->mediaTypes );
+ $opts->add( 'limit', 50 );
+ $opts->add( 'offset', '' );
+ $opts->add( 'start', '' );
+ $opts->add( 'end', '' );
+
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+
+ if ( $par !== null ) {
+ $opts->setValue( is_numeric( $par ) ? 'limit' : 'like', $par );
+ }
+
+ // If start date comes after end date chronologically, swap them.
+ // They are swapped in the interface by JS.
+ $start = $opts->getValue( 'start' );
+ $end = $opts->getValue( 'end' );
+ if ( $start !== '' && $end !== '' && $start > $end ) {
+ $temp = $end;
+ $end = $start;
+ $start = $temp;
+
+ $opts->setValue( 'start', $start, true );
+ $opts->setValue( 'end', $end, true );
+
+ // also swap values in request object, which is used by HTMLForm
+ // to pre-populate the fields with the previous input
+ $request = $context->getRequest();
+ $context->setRequest( new DerivativeRequest(
+ $request,
+ [ 'start' => $start, 'end' => $end ] + $request->getValues(),
+ $request->wasPosted()
+ ) );
+ }
+
+ // if all media types have been selected, wipe out the array to prevent
+ // the pointless IN(...) query condition (which would have no effect
+ // because every possible type has been selected)
+ $missingMediaTypes = array_diff( $this->mediaTypes, $opts->getValue( 'mediatype' ) );
+ if ( empty( $missingMediaTypes ) ) {
+ $opts->setValue( 'mediatype', [] );
+ }
+
+ $opts->validateIntBounds( 'limit', 0, 500 );
+
+ $this->opts = $opts;
+
+ if ( !$this->including() ) {
+ $this->setTopText();
+ $this->buildForm( $context );
+ }
+
+ $pager = new NewFilesPager( $context, $opts );
+
+ $out->addHTML( $pager->getBody() );
+ if ( !$this->including() ) {
+ $out->addHTML( $pager->getNavigationBar() );
+ }
+ }
+
+ protected function buildForm( IContextSource $context ) {
+ $mediaTypesText = array_map( function ( $type ) {
+ // mediastatistics-header-unknown, mediastatistics-header-bitmap,
+ // mediastatistics-header-drawing, mediastatistics-header-audio,
+ // mediastatistics-header-video, mediastatistics-header-multimedia,
+ // mediastatistics-header-office, mediastatistics-header-text,
+ // mediastatistics-header-executable, mediastatistics-header-archive,
+ // mediastatistics-header-3d,
+ return $this->msg( 'mediastatistics-header-' . strtolower( $type ) )->text();
+ }, $this->mediaTypes );
+ $mediaTypesOptions = array_combine( $mediaTypesText, $this->mediaTypes );
+ ksort( $mediaTypesOptions );
+
+ $formDescriptor = [
+ 'like' => [
+ 'type' => 'text',
+ 'label-message' => 'newimages-label',
+ 'name' => 'like',
+ ],
+
+ 'user' => [
+ 'type' => 'text',
+ 'label-message' => 'newimages-user',
+ 'name' => 'user',
+ ],
+
+ 'newbies' => [
+ 'type' => 'check',
+ 'label-message' => 'newimages-newbies',
+ 'name' => 'newbies',
+ ],
+
+ 'showbots' => [
+ 'type' => 'check',
+ 'label-message' => 'newimages-showbots',
+ 'name' => 'showbots',
+ ],
+
+ 'hidepatrolled' => [
+ 'type' => 'check',
+ 'label-message' => 'newimages-hidepatrolled',
+ 'name' => 'hidepatrolled',
+ ],
+
+ 'mediatype' => [
+ 'type' => 'multiselect',
+ 'flatlist' => true,
+ 'name' => 'mediatype',
+ 'label-message' => 'newimages-mediatype',
+ 'options' => $mediaTypesOptions,
+ 'default' => $this->mediaTypes,
+ ],
+
+ 'limit' => [
+ 'type' => 'hidden',
+ 'default' => $this->opts->getValue( 'limit' ),
+ 'name' => 'limit',
+ ],
+
+ 'offset' => [
+ 'type' => 'hidden',
+ 'default' => $this->opts->getValue( 'offset' ),
+ 'name' => 'offset',
+ ],
+
+ 'start' => [
+ 'type' => 'date',
+ 'label-message' => 'date-range-from',
+ 'name' => 'start',
+ ],
+
+ 'end' => [
+ 'type' => 'date',
+ 'label-message' => 'date-range-to',
+ 'name' => 'end',
+ ],
+ ];
+
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ unset( $formDescriptor['like'] );
+ }
+
+ if ( !$this->getUser()->useFilePatrol() ) {
+ unset( $formDescriptor['hidepatrolled'] );
+ }
+
+ HTMLForm::factory( 'ooui', $formDescriptor, $context )
+ // For the 'multiselect' field values to be preserved on submit
+ ->setFormIdentifier( 'specialnewimages' )
+ ->setWrapperLegendMsg( 'newimages-legend' )
+ ->setSubmitTextMsg( 'ilsubmit' )
+ ->setMethod( 'get' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ protected function getGroupName() {
+ return 'changes';
+ }
+
+ /**
+ * Send the text to be displayed above the options
+ */
+ function setTopText() {
+ global $wgContLang;
+
+ $message = $this->msg( 'newimagestext' )->inContentLanguage();
+ if ( !$message->isDisabled() ) {
+ $this->getOutput()->addWikiText(
+ Html::rawElement( 'p',
+ [ 'lang' => $wgContLang->getHtmlCode(), 'dir' => $wgContLang->getDir() ],
+ "\n" . $message->plain() . "\n"
+ ),
+ /* $lineStart */ false,
+ /* $interface */ false
+ );
+ }
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialNewpages.php b/www/wiki/includes/specials/SpecialNewpages.php
new file mode 100644
index 00000000..671ab6fb
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialNewpages.php
@@ -0,0 +1,517 @@
+<?php
+/**
+ * Implements Special:Newpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that list newly created pages
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialNewpages extends IncludableSpecialPage {
+ /**
+ * @var FormOptions
+ */
+ protected $opts;
+ protected $customFilters;
+
+ protected $showNavigation = false;
+
+ public function __construct() {
+ parent::__construct( 'Newpages' );
+ }
+
+ protected function setup( $par ) {
+ // Options
+ $opts = new FormOptions();
+ $this->opts = $opts; // bind
+ $opts->add( 'hideliu', false );
+ $opts->add( 'hidepatrolled', $this->getUser()->getBoolOption( 'newpageshidepatrolled' ) );
+ $opts->add( 'hidebots', false );
+ $opts->add( 'hideredirs', true );
+ $opts->add( 'limit', $this->getUser()->getIntOption( 'rclimit' ) );
+ $opts->add( 'offset', '' );
+ $opts->add( 'namespace', '0' );
+ $opts->add( 'username', '' );
+ $opts->add( 'feed', '' );
+ $opts->add( 'tagfilter', '' );
+ $opts->add( 'invert', false );
+ $opts->add( 'size-mode', 'max' );
+ $opts->add( 'size', 0 );
+
+ $this->customFilters = [];
+ Hooks::run( 'SpecialNewPagesFilters', [ $this, &$this->customFilters ] );
+ foreach ( $this->customFilters as $key => $params ) {
+ $opts->add( $key, $params['default'] );
+ }
+
+ // Set values
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+ if ( $par ) {
+ $this->parseParams( $par );
+ }
+
+ // Validate
+ $opts->validateIntBounds( 'limit', 0, 5000 );
+ }
+
+ protected function parseParams( $par ) {
+ $bits = preg_split( '/\s*,\s*/', trim( $par ) );
+ foreach ( $bits as $bit ) {
+ if ( 'shownav' == $bit ) {
+ $this->showNavigation = true;
+ }
+ if ( 'hideliu' === $bit ) {
+ $this->opts->setValue( 'hideliu', true );
+ }
+ if ( 'hidepatrolled' == $bit ) {
+ $this->opts->setValue( 'hidepatrolled', true );
+ }
+ if ( 'hidebots' == $bit ) {
+ $this->opts->setValue( 'hidebots', true );
+ }
+ if ( 'showredirs' == $bit ) {
+ $this->opts->setValue( 'hideredirs', false );
+ }
+ if ( is_numeric( $bit ) ) {
+ $this->opts->setValue( 'limit', intval( $bit ) );
+ }
+
+ $m = [];
+ if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
+ $this->opts->setValue( 'limit', intval( $m[1] ) );
+ }
+ // PG offsets not just digits!
+ if ( preg_match( '/^offset=([^=]+)$/', $bit, $m ) ) {
+ $this->opts->setValue( 'offset', intval( $m[1] ) );
+ }
+ if ( preg_match( '/^username=(.*)$/', $bit, $m ) ) {
+ $this->opts->setValue( 'username', $m[1] );
+ }
+ if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
+ $ns = $this->getLanguage()->getNsIndex( $m[1] );
+ if ( $ns !== false ) {
+ $this->opts->setValue( 'namespace', $ns );
+ }
+ }
+ }
+ }
+
+ /**
+ * Show a form for filtering namespace and username
+ *
+ * @param string $par
+ */
+ public function execute( $par ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $this->showNavigation = !$this->including(); // Maybe changed in setup
+ $this->setup( $par );
+
+ $this->addHelpLink( 'Help:New pages' );
+
+ if ( !$this->including() ) {
+ // Settings
+ $this->form();
+
+ $feedType = $this->opts->getValue( 'feed' );
+ if ( $feedType ) {
+ $this->feed( $feedType );
+
+ return;
+ }
+
+ $allValues = $this->opts->getAllValues();
+ unset( $allValues['feed'] );
+ $out->setFeedAppendQuery( wfArrayToCgi( $allValues ) );
+ }
+
+ $pager = new NewPagesPager( $this, $this->opts );
+ $pager->mLimit = $this->opts->getValue( 'limit' );
+ $pager->mOffset = $this->opts->getValue( 'offset' );
+
+ if ( $pager->getNumRows() ) {
+ $navigation = '';
+ if ( $this->showNavigation ) {
+ $navigation = $pager->getNavigationBar();
+ }
+ $out->addHTML( $navigation . $pager->getBody() . $navigation );
+ } else {
+ $out->addWikiMsg( 'specialpage-empty' );
+ }
+ }
+
+ protected function filterLinks() {
+ // show/hide links
+ $showhide = [ $this->msg( 'show' )->escaped(), $this->msg( 'hide' )->escaped() ];
+
+ // Option value -> message mapping
+ $filters = [
+ 'hideliu' => 'rcshowhideliu',
+ 'hidepatrolled' => 'rcshowhidepatr',
+ 'hidebots' => 'rcshowhidebots',
+ 'hideredirs' => 'whatlinkshere-hideredirs'
+ ];
+ foreach ( $this->customFilters as $key => $params ) {
+ $filters[$key] = $params['msg'];
+ }
+
+ // Disable some if needed
+ if ( !User::groupHasPermission( '*', 'createpage' ) ) {
+ unset( $filters['hideliu'] );
+ }
+ if ( !$this->getUser()->useNPPatrol() ) {
+ unset( $filters['hidepatrolled'] );
+ }
+
+ $links = [];
+ $changed = $this->opts->getChangedValues();
+ unset( $changed['offset'] ); // Reset offset if query type changes
+
+ $self = $this->getPageTitle();
+ $linkRenderer = $this->getLinkRenderer();
+ foreach ( $filters as $key => $msg ) {
+ $onoff = 1 - $this->opts->getValue( $key );
+ $link = $linkRenderer->makeLink(
+ $self,
+ new HtmlArmor( $showhide[$onoff] ),
+ [],
+ [ $key => $onoff ] + $changed
+ );
+ $links[$key] = $this->msg( $msg )->rawParams( $link )->escaped();
+ }
+
+ return $this->getLanguage()->pipeList( $links );
+ }
+
+ protected function form() {
+ $out = $this->getOutput();
+ $out->addModules( 'mediawiki.userSuggest' );
+
+ // Consume values
+ $this->opts->consumeValue( 'offset' ); // don't carry offset, DWIW
+ $namespace = $this->opts->consumeValue( 'namespace' );
+ $username = $this->opts->consumeValue( 'username' );
+ $tagFilterVal = $this->opts->consumeValue( 'tagfilter' );
+ $nsinvert = $this->opts->consumeValue( 'invert' );
+
+ $size = $this->opts->consumeValue( 'size' );
+ $max = $this->opts->consumeValue( 'size-mode' ) === 'max';
+
+ // Check username input validity
+ $ut = Title::makeTitleSafe( NS_USER, $username );
+ $userText = $ut ? $ut->getText() : '';
+
+ // Store query values in hidden fields so that form submission doesn't lose them
+ $hidden = [];
+ foreach ( $this->opts->getUnconsumedValues() as $key => $value ) {
+ $hidden[] = Html::hidden( $key, $value );
+ }
+ $hidden = implode( "\n", $hidden );
+
+ $form = [
+ 'namespace' => [
+ 'type' => 'namespaceselect',
+ 'name' => 'namespace',
+ 'label-message' => 'namespace',
+ 'default' => $namespace,
+ ],
+ 'nsinvert' => [
+ 'type' => 'check',
+ 'name' => 'invert',
+ 'label-message' => 'invert',
+ 'default' => $nsinvert,
+ 'tooltip' => 'invert',
+ ],
+ 'tagFilter' => [
+ 'type' => 'tagfilter',
+ 'name' => 'tagfilter',
+ 'label-raw' => $this->msg( 'tag-filter' )->parse(),
+ 'default' => $tagFilterVal,
+ ],
+ 'username' => [
+ 'type' => 'text',
+ 'name' => 'username',
+ 'label-message' => 'newpages-username',
+ 'default' => $userText,
+ 'id' => 'mw-np-username',
+ 'size' => 30,
+ 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
+ ],
+ 'size' => [
+ 'type' => 'sizefilter',
+ 'name' => 'size',
+ 'default' => -$max * $size,
+ ],
+ ];
+
+ $htmlForm = new HTMLForm( $form, $this->getContext() );
+
+ $htmlForm->setSubmitText( $this->msg( 'newpages-submit' )->text() );
+ $htmlForm->setSubmitProgressive();
+ // The form should be visible on each request (inclusive requests with submitted forms), so
+ // return always false here.
+ $htmlForm->setSubmitCallback(
+ function () {
+ return false;
+ }
+ );
+ $htmlForm->setMethod( 'get' );
+ $htmlForm->setWrapperLegend( true );
+ $htmlForm->setWrapperLegendMsg( 'newpages' );
+ $htmlForm->addFooterText( Html::rawElement(
+ 'div',
+ null,
+ $this->filterLinks()
+ ) );
+ $htmlForm->show();
+ }
+
+ /**
+ * @param stdClass $result Result row from recent changes
+ * @return Revision|bool
+ */
+ protected function revisionFromRcResult( stdClass $result ) {
+ return new Revision( [
+ 'comment' => CommentStore::newKey( 'rc_comment' )->getComment( $result )->text,
+ 'deleted' => $result->rc_deleted,
+ 'user_text' => $result->rc_user_text,
+ 'user' => $result->rc_user,
+ ] );
+ }
+
+ /**
+ * Format a row, providing the timestamp, links to the page/history,
+ * size, user links, and a comment
+ *
+ * @param object $result Result row
+ * @return string
+ */
+ public function formatRow( $result ) {
+ $title = Title::newFromRow( $result );
+
+ // Revision deletion works on revisions,
+ // so cast our recent change row to a revision row.
+ $rev = $this->revisionFromRcResult( $result );
+ $rev->setTitle( $title );
+
+ $classes = [];
+ $attribs = [ 'data-mw-revid' => $result->rev_id ];
+
+ $lang = $this->getLanguage();
+ $dm = $lang->getDirMark();
+
+ $spanTime = Html::element( 'span', [ 'class' => 'mw-newpages-time' ],
+ $lang->userTimeAndDate( $result->rc_timestamp, $this->getUser() )
+ );
+ $linkRenderer = $this->getLinkRenderer();
+ $time = $linkRenderer->makeKnownLink(
+ $title,
+ new HtmlArmor( $spanTime ),
+ [],
+ [ 'oldid' => $result->rc_this_oldid ]
+ );
+
+ $query = $title->isRedirect() ? [ 'redirect' => 'no' ] : [];
+
+ $plink = $linkRenderer->makeKnownLink(
+ $title,
+ null,
+ [ 'class' => 'mw-newpages-pagename' ],
+ $query
+ );
+ $histLink = $linkRenderer->makeKnownLink(
+ $title,
+ $this->msg( 'hist' )->text(),
+ [],
+ [ 'action' => 'history' ]
+ );
+ $hist = Html::rawElement( 'span', [ 'class' => 'mw-newpages-history' ],
+ $this->msg( 'parentheses' )->rawParams( $histLink )->escaped() );
+
+ $length = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-newpages-length' ],
+ $this->msg( 'brackets' )->rawParams(
+ $this->msg( 'nbytes' )->numParams( $result->length )->escaped()
+ )->escaped()
+ );
+
+ $ulink = Linker::revUserTools( $rev );
+ $comment = Linker::revComment( $rev );
+
+ if ( $this->patrollable( $result ) ) {
+ $classes[] = 'not-patrolled';
+ }
+
+ # Add a class for zero byte pages
+ if ( $result->length == 0 ) {
+ $classes[] = 'mw-newpages-zero-byte-page';
+ }
+
+ # Tags, if any.
+ if ( isset( $result->ts_tags ) ) {
+ list( $tagDisplay, $newClasses ) = ChangeTags::formatSummaryRow(
+ $result->ts_tags,
+ 'newpages',
+ $this->getContext()
+ );
+ $classes = array_merge( $classes, $newClasses );
+ } else {
+ $tagDisplay = '';
+ }
+
+ # Display the old title if the namespace/title has been changed
+ $oldTitleText = '';
+ $oldTitle = Title::makeTitle( $result->rc_namespace, $result->rc_title );
+
+ if ( !$title->equals( $oldTitle ) ) {
+ $oldTitleText = $oldTitle->getPrefixedText();
+ $oldTitleText = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-newpages-oldtitle' ],
+ $this->msg( 'rc-old-title' )->params( $oldTitleText )->escaped()
+ );
+ }
+
+ $ret = "{$time} {$dm}{$plink} {$hist} {$dm}{$length} {$dm}{$ulink} {$comment} "
+ . "{$tagDisplay} {$oldTitleText}";
+
+ // Let extensions add data
+ Hooks::run( 'NewPagesLineEnding', [ $this, &$ret, $result, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+
+ if ( count( $classes ) ) {
+ $attribs['class'] = implode( ' ', $classes );
+ }
+
+ return Html::rawElement( 'li', $attribs, $ret ) . "\n";
+ }
+
+ /**
+ * Should a specific result row provide "patrollable" links?
+ *
+ * @param object $result Result row
+ * @return bool
+ */
+ protected function patrollable( $result ) {
+ return ( $this->getUser()->useNPPatrol() && !$result->rc_patrolled );
+ }
+
+ /**
+ * Output a subscription feed listing recent edits to this page.
+ *
+ * @param string $type
+ */
+ protected function feed( $type ) {
+ if ( !$this->getConfig()->get( 'Feed' ) ) {
+ $this->getOutput()->addWikiMsg( 'feed-unavailable' );
+
+ return;
+ }
+
+ $feedClasses = $this->getConfig()->get( 'FeedClasses' );
+ if ( !isset( $feedClasses[$type] ) ) {
+ $this->getOutput()->addWikiMsg( 'feed-invalid' );
+
+ return;
+ }
+
+ $feed = new $feedClasses[$type](
+ $this->feedTitle(),
+ $this->msg( 'tagline' )->text(),
+ $this->getPageTitle()->getFullURL()
+ );
+
+ $pager = new NewPagesPager( $this, $this->opts );
+ $limit = $this->opts->getValue( 'limit' );
+ $pager->mLimit = min( $limit, $this->getConfig()->get( 'FeedLimit' ) );
+
+ $feed->outHeader();
+ if ( $pager->getNumRows() > 0 ) {
+ foreach ( $pager->mResult as $row ) {
+ $feed->outItem( $this->feedItem( $row ) );
+ }
+ }
+ $feed->outFooter();
+ }
+
+ protected function feedTitle() {
+ $desc = $this->getDescription();
+ $code = $this->getConfig()->get( 'LanguageCode' );
+ $sitename = $this->getConfig()->get( 'Sitename' );
+
+ return "$sitename - $desc [$code]";
+ }
+
+ protected function feedItem( $row ) {
+ $title = Title::makeTitle( intval( $row->rc_namespace ), $row->rc_title );
+ if ( $title ) {
+ $date = $row->rc_timestamp;
+ $comments = $title->getTalkPage()->getFullURL();
+
+ return new FeedItem(
+ $title->getPrefixedText(),
+ $this->feedItemDesc( $row ),
+ $title->getFullURL(),
+ $date,
+ $this->feedItemAuthor( $row ),
+ $comments
+ );
+ } else {
+ return null;
+ }
+ }
+
+ protected function feedItemAuthor( $row ) {
+ return isset( $row->rc_user_text ) ? $row->rc_user_text : '';
+ }
+
+ protected function feedItemDesc( $row ) {
+ $revision = Revision::newFromId( $row->rev_id );
+ if ( !$revision ) {
+ return '';
+ }
+
+ $content = $revision->getContent();
+ if ( $content === null ) {
+ return '';
+ }
+
+ // XXX: include content model/type in feed item?
+ return '<p>' . htmlspecialchars( $revision->getUserText() ) .
+ $this->msg( 'colon-separator' )->inContentLanguage()->escaped() .
+ htmlspecialchars( FeedItem::stripComment( $revision->getComment() ) ) .
+ "</p>\n<hr />\n<div>" .
+ nl2br( htmlspecialchars( $content->serialize() ) ) . "</div>";
+ }
+
+ protected function getGroupName() {
+ return 'changes';
+ }
+
+ protected function getCacheTTL() {
+ return 60 * 5;
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialPageData.php b/www/wiki/includes/specials/SpecialPageData.php
new file mode 100644
index 00000000..c52c426e
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialPageData.php
@@ -0,0 +1,86 @@
+<?php
+
+/**
+ * Special page to act as an endpoint for accessing raw page data.
+ * The web server should generally be configured to make this accessible via a canonical URL/URI,
+ * such as <http://my.domain.org/data/main/Foo>.
+ *
+ * @license GPL-2.0+
+ */
+class SpecialPageData extends SpecialPage {
+
+ /**
+ * @var PageDataRequestHandler|null
+ */
+ private $requestHandler = null;
+
+ public function __construct() {
+ parent::__construct( 'PageData' );
+ }
+
+ /**
+ * Sets the request handler to be used by the special page.
+ * May be used when a particular instance of PageDataRequestHandler is already
+ * known, e.g. during testing.
+ *
+ * If no request handler is set using this method, a default handler is created
+ * on demand by initDependencies().
+ *
+ * @param PageDataRequestHandler $requestHandler
+ */
+ public function setRequestHandler( PageDataRequestHandler $requestHandler ) {
+ $this->requestHandler = $requestHandler;
+ }
+
+ /**
+ * Initialize any un-initialized members from global context.
+ * In particular, this initializes $this->requestHandler
+ */
+ protected function initDependencies() {
+ if ( $this->requestHandler === null ) {
+ $this->requestHandler = $this->newDefaultRequestHandler();
+ }
+ }
+
+ /**
+ * Creates a PageDataRequestHandler based on global defaults.
+ *
+ * @return PageDataRequestHandler
+ */
+ private function newDefaultRequestHandler() {
+ return new PageDataRequestHandler();
+ }
+
+ /**
+ * @see SpecialWikibasePage::execute
+ *
+ * @param string|null $subPage
+ *
+ * @throws HttpError
+ */
+ public function execute( $subPage ) {
+ $this->initDependencies();
+
+ // If there is no title, show an HTML form
+ // TODO: Don't do this if HTML is not acceptable according to HTTP headers.
+ if ( !$this->requestHandler->canHandleRequest( $subPage, $this->getRequest() ) ) {
+ $this->showForm();
+ return;
+ }
+
+ $this->requestHandler->handleRequest( $subPage, $this->getRequest(), $this->getOutput() );
+ }
+
+ /**
+ * Shows an informative page to the user; Called when there is no page to output.
+ */
+ public function showForm() {
+ $this->getOutput()->showErrorPage( 'pagedata-title', 'pagedata-text' );
+ }
+
+ public function isListed() {
+ // Do not list this page in Special:SpecialPages
+ return false;
+ }
+
+}
diff --git a/www/wiki/includes/specials/SpecialPageLanguage.php b/www/wiki/includes/specials/SpecialPageLanguage.php
new file mode 100644
index 00000000..a68f08fd
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialPageLanguage.php
@@ -0,0 +1,299 @@
+<?php
+/**
+ * Implements Special:PageLanguage
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Kunal Grover
+ * @since 1.24
+ */
+
+/**
+ * Special page for changing the content language of a page
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialPageLanguage extends FormSpecialPage {
+ /**
+ * @var string URL to go to if language change successful
+ */
+ private $goToUrl;
+
+ public function __construct() {
+ parent::__construct( 'PageLanguage', 'pagelang' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ protected function preText() {
+ $this->getOutput()->addModules( 'mediawiki.special.pageLanguage' );
+ }
+
+ protected function getFormFields() {
+ // Get default from the subpage of Special page
+ $defaultName = $this->par;
+ $title = $defaultName ? Title::newFromText( $defaultName ) : null;
+ if ( $title ) {
+ $defaultPageLanguage =
+ ContentHandler::getForTitle( $title )->getPageLanguage( $title );
+ $hasCustomLanguageSet = !$defaultPageLanguage->equals( $title->getPageLanguage() );
+ } else {
+ $hasCustomLanguageSet = false;
+ }
+
+ $page = [];
+ $page['pagename'] = [
+ 'type' => 'title',
+ 'label-message' => 'pagelang-name',
+ 'default' => $title ? $title->getPrefixedText() : $defaultName,
+ 'autofocus' => $defaultName === null,
+ 'exists' => true,
+ ];
+
+ // Options for whether to use the default language or select language
+ $selectoptions = [
+ (string)$this->msg( 'pagelang-use-default' )->escaped() => 1,
+ (string)$this->msg( 'pagelang-select-lang' )->escaped() => 2,
+ ];
+ $page['selectoptions'] = [
+ 'id' => 'mw-pl-options',
+ 'type' => 'radio',
+ 'options' => $selectoptions,
+ 'default' => $hasCustomLanguageSet ? 2 : 1
+ ];
+
+ // Building a language selector
+ $userLang = $this->getLanguage()->getCode();
+ $languages = Language::fetchLanguageNames( $userLang, 'mwfile' );
+ ksort( $languages );
+ $options = [];
+ foreach ( $languages as $code => $name ) {
+ $options["$code - $name"] = $code;
+ }
+
+ $page['language'] = [
+ 'id' => 'mw-pl-languageselector',
+ 'cssclass' => 'mw-languageselector',
+ 'type' => 'select',
+ 'options' => $options,
+ 'label-message' => 'pagelang-language',
+ 'default' => $title ?
+ $title->getPageLanguage()->getCode() :
+ $this->getConfig()->get( 'LanguageCode' ),
+ ];
+
+ // Allow user to enter a comment explaining the change
+ $page['reason'] = [
+ 'type' => 'text',
+ 'label-message' => 'pagelang-reason'
+ ];
+
+ return $page;
+ }
+
+ protected function postText() {
+ if ( $this->par ) {
+ return $this->showLogFragment( $this->par );
+ }
+ return '';
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ public function alterForm( HTMLForm $form ) {
+ Hooks::run( 'LanguageSelector', [ $this->getOutput(), 'mw-languageselector' ] );
+ $form->setSubmitTextMsg( 'pagelang-submit' );
+ }
+
+ /**
+ *
+ * @param array $data
+ * @return Status
+ */
+ public function onSubmit( array $data ) {
+ $pageName = $data['pagename'];
+
+ // Check if user wants to use default language
+ if ( $data['selectoptions'] == 1 ) {
+ $newLanguage = 'default';
+ } else {
+ $newLanguage = $data['language'];
+ }
+
+ try {
+ $title = Title::newFromTextThrow( $pageName );
+ } catch ( MalformedTitleException $ex ) {
+ return Status::newFatal( $ex->getMessageObject() );
+ }
+
+ // Check permissions and make sure the user has permission to edit the page
+ $errors = $title->getUserPermissionsErrors( 'edit', $this->getUser() );
+
+ if ( $errors ) {
+ $out = $this->getOutput();
+ $wikitext = $out->formatPermissionsErrorMessage( $errors );
+ // Hack to get our wikitext parsed
+ return Status::newFatal( new RawMessage( '$1', [ $wikitext ] ) );
+ }
+
+ // Url to redirect to after the operation
+ $this->goToUrl = $title->getFullUrlForRedirect(
+ $title->isRedirect() ? [ 'redirect' => 'no' ] : []
+ );
+
+ return self::changePageLanguage(
+ $this->getContext(),
+ $title,
+ $newLanguage,
+ $data['reason'] === null ? '' : $data['reason']
+ );
+ }
+
+ /**
+ * @param IContextSource $context
+ * @param Title $title
+ * @param string $newLanguage Language code
+ * @param string $reason Reason for the change
+ * @param array $tags Change tags to apply to the log entry
+ * @return Status
+ */
+ public static function changePageLanguage( IContextSource $context, Title $title,
+ $newLanguage, $reason, array $tags = [] ) {
+ // Get the default language for the wiki
+ $defLang = $context->getConfig()->get( 'LanguageCode' );
+
+ $pageId = $title->getArticleID();
+
+ // Check if article exists
+ if ( !$pageId ) {
+ return Status::newFatal(
+ 'pagelang-nonexistent-page',
+ wfEscapeWikiText( $title->getPrefixedText() )
+ );
+ }
+
+ // Load the page language from DB
+ $dbw = wfGetDB( DB_MASTER );
+ $oldLanguage = $dbw->selectField(
+ 'page',
+ 'page_lang',
+ [ 'page_id' => $pageId ],
+ __METHOD__
+ );
+
+ // Check if user wants to use the default language
+ if ( $newLanguage === 'default' ) {
+ $newLanguage = null;
+ }
+
+ // No change in language
+ if ( $newLanguage === $oldLanguage ) {
+ // Check if old language does not exist
+ if ( !$oldLanguage ) {
+ return Status::newFatal( ApiMessage::create(
+ [
+ 'pagelang-unchanged-language-default',
+ wfEscapeWikiText( $title->getPrefixedText() )
+ ],
+ 'pagelang-unchanged-language'
+ ) );
+ }
+ return Status::newFatal(
+ 'pagelang-unchanged-language',
+ wfEscapeWikiText( $title->getPrefixedText() ),
+ $oldLanguage
+ );
+ }
+
+ // Hardcoded [def] if the language is set to null
+ $logOld = $oldLanguage ? $oldLanguage : $defLang . '[def]';
+ $logNew = $newLanguage ? $newLanguage : $defLang . '[def]';
+
+ // Writing new page language to database
+ $dbw->update(
+ 'page',
+ [ 'page_lang' => $newLanguage ],
+ [
+ 'page_id' => $pageId,
+ 'page_lang' => $oldLanguage
+ ],
+ __METHOD__
+ );
+
+ if ( !$dbw->affectedRows() ) {
+ return Status::newFatal( 'pagelang-db-failed' );
+ }
+
+ // Logging change of language
+ $logParams = [
+ '4::oldlanguage' => $logOld,
+ '5::newlanguage' => $logNew
+ ];
+ $entry = new ManualLogEntry( 'pagelang', 'pagelang' );
+ $entry->setPerformer( $context->getUser() );
+ $entry->setTarget( $title );
+ $entry->setParameters( $logParams );
+ $entry->setComment( $reason );
+ $entry->setTags( $tags );
+
+ $logid = $entry->insert();
+ $entry->publish( $logid );
+
+ // Force re-render so that language-based content (parser functions etc.) gets updated
+ $title->invalidateCache();
+
+ return Status::newGood( (object)[
+ 'oldLanguage' => $logOld,
+ 'newLanguage' => $logNew,
+ 'logId' => $logid,
+ ] );
+ }
+
+ public function onSuccess() {
+ // Success causes a redirect
+ $this->getOutput()->redirect( $this->goToUrl );
+ }
+
+ function showLogFragment( $title ) {
+ $moveLogPage = new LogPage( 'pagelang' );
+ $out1 = Xml::element( 'h2', null, $moveLogPage->getName()->text() );
+ $out2 = '';
+ LogEventsList::showLogExtract( $out2, 'pagelang', $title );
+ return $out1 . $out2;
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialPagesWithProp.php b/www/wiki/includes/specials/SpecialPagesWithProp.php
new file mode 100644
index 00000000..34fcc78c
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialPagesWithProp.php
@@ -0,0 +1,240 @@
+<?php
+/**
+ * Implements Special:PagesWithProp
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.21
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special:PagesWithProp to search the page_props table
+ * @ingroup SpecialPage
+ * @since 1.21
+ */
+class SpecialPagesWithProp extends QueryPage {
+
+ /**
+ * @var string|null
+ */
+ private $propName = null;
+
+ /**
+ * @var string[]|null
+ */
+ private $existingPropNames = null;
+
+ /**
+ * @var bool
+ */
+ private $reverse = false;
+
+ /**
+ * @var bool
+ */
+ private $sortByValue = false;
+
+ function __construct( $name = 'PagesWithProp' ) {
+ parent::__construct( $name );
+ }
+
+ function isCacheable() {
+ return false;
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->getOutput()->addModuleStyles( 'mediawiki.special.pagesWithProp' );
+
+ $request = $this->getRequest();
+ $propname = $request->getVal( 'propname', $par );
+ $this->reverse = $request->getBool( 'reverse' );
+ $this->sortByValue = $request->getBool( 'sortbyvalue' );
+
+ $propnames = $this->getExistingPropNames();
+
+ $form = HTMLForm::factory( 'ooui', [
+ 'propname' => [
+ 'type' => 'combobox',
+ 'name' => 'propname',
+ 'options' => $propnames,
+ 'default' => $propname,
+ 'label-message' => 'pageswithprop-prop',
+ 'required' => true,
+ ],
+ 'reverse' => [
+ 'type' => 'check',
+ 'name' => 'reverse',
+ 'default' => $this->reverse,
+ 'label-message' => 'pageswithprop-reverse',
+ 'required' => false,
+ ],
+ 'sortbyvalue' => [
+ 'type' => 'check',
+ 'name' => 'sortbyvalue',
+ 'default' => $this->sortByValue,
+ 'label-message' => 'pageswithprop-sortbyvalue',
+ 'required' => false,
+ ]
+ ], $this->getContext() );
+ $form->setMethod( 'get' );
+ $form->setSubmitCallback( [ $this, 'onSubmit' ] );
+ $form->setWrapperLegendMsg( 'pageswithprop-legend' );
+ $form->addHeaderText( $this->msg( 'pageswithprop-text' )->parseAsBlock() );
+ $form->setSubmitTextMsg( 'pageswithprop-submit' );
+
+ $form->prepareForm();
+ $form->displayForm( false );
+ if ( $propname !== '' && $propname !== null ) {
+ $form->trySubmit();
+ }
+ }
+
+ public function onSubmit( $data, $form ) {
+ $this->propName = $data['propname'];
+ parent::execute( $data['propname'] );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return
+ * @param int $offset Number of pages to skip
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $subpages = array_keys( $this->queryExistingProps( $limit, $offset ) );
+ // We've already limited and offsetted, set to N and 0 respectively.
+ return self::prefixSearchArray( $search, count( $subpages ), $subpages, 0 );
+ }
+
+ /**
+ * Disable RSS/Atom feeds
+ * @return bool
+ */
+ function isSyndicated() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'page_props', 'page' ],
+ 'fields' => [
+ 'page_id' => 'pp_page',
+ 'page_namespace',
+ 'page_title',
+ 'page_len',
+ 'page_is_redirect',
+ 'page_latest',
+ 'pp_value',
+ ],
+ 'conds' => [
+ 'pp_propname' => $this->propName,
+ ],
+ 'join_conds' => [
+ 'page' => [ 'INNER JOIN', 'page_id = pp_page' ]
+ ],
+ 'options' => []
+ ];
+ }
+
+ function getOrderFields() {
+ $sort = [ 'page_id' ];
+ if ( $this->sortByValue ) {
+ array_unshift( $sort, 'pp_sortkey' );
+ }
+ return $sort;
+ }
+
+ /**
+ * @return bool
+ */
+ public function sortDescending() {
+ return !$this->reverse;
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $title = Title::newFromRow( $result );
+ $ret = $this->getLinkRenderer()->makeKnownLink( $title );
+ if ( $result->pp_value !== '' ) {
+ // Do not show very long or binary values on the special page
+ $valueLength = strlen( $result->pp_value );
+ $isBinary = strpos( $result->pp_value, "\0" ) !== false;
+ $isTooLong = $valueLength > 1024;
+
+ if ( $isBinary || $isTooLong ) {
+ $message = $this
+ ->msg( $isBinary ? 'pageswithprop-prophidden-binary' : 'pageswithprop-prophidden-long' )
+ ->params( $this->getLanguage()->formatSize( $valueLength ) );
+
+ $propValue = Html::element( 'span', [ 'class' => 'prop-value-hidden' ], $message->text() );
+ } else {
+ $propValue = Html::element( 'span', [ 'class' => 'prop-value' ], $result->pp_value );
+ }
+
+ $ret .= $this->msg( 'colon-separator' )->escaped() . $propValue;
+ }
+
+ return $ret;
+ }
+
+ public function getExistingPropNames() {
+ if ( $this->existingPropNames === null ) {
+ $this->existingPropNames = $this->queryExistingProps();
+ }
+ return $this->existingPropNames;
+ }
+
+ protected function queryExistingProps( $limit = null, $offset = 0 ) {
+ $opts = [
+ 'DISTINCT', 'ORDER BY' => 'pp_propname'
+ ];
+ if ( $limit ) {
+ $opts['LIMIT'] = $limit;
+ }
+ if ( $offset ) {
+ $opts['OFFSET'] = $offset;
+ }
+
+ $res = wfGetDB( DB_REPLICA )->select(
+ 'page_props',
+ 'pp_propname',
+ '',
+ __METHOD__,
+ $opts
+ );
+
+ $propnames = [];
+ foreach ( $res as $row ) {
+ $propnames[$row->pp_propname] = $row->pp_propname;
+ }
+
+ return $propnames;
+ }
+
+ protected function getGroupName() {
+ return 'pages';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialPasswordReset.php b/www/wiki/includes/specials/SpecialPasswordReset.php
new file mode 100644
index 00000000..a4f16bd7
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialPasswordReset.php
@@ -0,0 +1,178 @@
+<?php
+/**
+ * Implements Special:PasswordReset
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Special page for requesting a password reset email.
+ *
+ * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the
+ * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent
+ * functionality) to be enabled.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialPasswordReset extends FormSpecialPage {
+ /** @var PasswordReset */
+ private $passwordReset = null;
+
+ /**
+ * @var string[] Temporary storage for the passwords which have been sent out, keyed by username.
+ */
+ private $passwords = [];
+
+ /**
+ * @var Status
+ */
+ private $result;
+
+ /**
+ * @var string $method Identifies which password reset field was specified by the user.
+ */
+ private $method;
+
+ public function __construct() {
+ parent::__construct( 'PasswordReset', 'editmyprivateinfo' );
+ }
+
+ private function getPasswordReset() {
+ if ( $this->passwordReset === null ) {
+ $this->passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
+ }
+ return $this->passwordReset;
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function userCanExecute( User $user ) {
+ return $this->getPasswordReset()->isAllowed( $user )->isGood();
+ }
+
+ public function checkExecutePermissions( User $user ) {
+ $status = Status::wrap( $this->getPasswordReset()->isAllowed( $user ) );
+ if ( !$status->isGood() ) {
+ throw new ErrorPageError( 'internalerror', $status->getMessage() );
+ }
+
+ parent::checkExecutePermissions( $user );
+ }
+
+ protected function getFormFields() {
+ $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+ $a = [];
+ if ( isset( $resetRoutes['username'] ) && $resetRoutes['username'] ) {
+ $a['Username'] = [
+ 'type' => 'text',
+ 'label-message' => 'passwordreset-username',
+ ];
+
+ if ( $this->getUser()->isLoggedIn() ) {
+ $a['Username']['default'] = $this->getUser()->getName();
+ }
+ }
+
+ if ( isset( $resetRoutes['email'] ) && $resetRoutes['email'] ) {
+ $a['Email'] = [
+ 'type' => 'email',
+ 'label-message' => 'passwordreset-email',
+ ];
+ }
+
+ return $a;
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ public function alterForm( HTMLForm $form ) {
+ $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+
+ $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
+
+ $i = 0;
+ if ( isset( $resetRoutes['username'] ) && $resetRoutes['username'] ) {
+ $i++;
+ }
+ if ( isset( $resetRoutes['email'] ) && $resetRoutes['email'] ) {
+ $i++;
+ }
+
+ $message = ( $i > 1 ) ? 'passwordreset-text-many' : 'passwordreset-text-one';
+
+ $form->setHeaderText( $this->msg( $message, $i )->parseAsBlock() );
+ $form->setSubmitTextMsg( 'mailmypassword' );
+ }
+
+ /**
+ * Process the form. At this point we know that the user passes all the criteria in
+ * userCanExecute(), and if the data array contains 'Username', etc, then Username
+ * resets are allowed.
+ * @param array $data
+ * @throws MWException
+ * @throws ThrottledError|PermissionsError
+ * @return Status
+ */
+ public function onSubmit( array $data ) {
+ $username = isset( $data['Username'] ) ? $data['Username'] : null;
+ $email = isset( $data['Email'] ) ? $data['Email'] : null;
+
+ $this->method = $username ? 'username' : 'email';
+ $this->result = Status::wrap(
+ $this->getPasswordReset()->execute( $this->getUser(), $username, $email ) );
+
+ if ( $this->result->hasMessage( 'actionthrottledtext' ) ) {
+ throw new ThrottledError;
+ }
+
+ return $this->result;
+ }
+
+ public function onSuccess() {
+ if ( $this->method === 'email' ) {
+ $this->getOutput()->addWikiMsg( 'passwordreset-emailsentemail' );
+ } else {
+ $this->getOutput()->addWikiMsg( 'passwordreset-emailsentusername' );
+ }
+
+ $this->getOutput()->returnToMain();
+ }
+
+ /**
+ * Hide the password reset page if resets are disabled.
+ * @return bool
+ */
+ public function isListed() {
+ if ( $this->getPasswordReset()->isAllowed( $this->getUser() )->isGood() ) {
+ return parent::isListed();
+ }
+
+ return false;
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialPermanentLink.php b/www/wiki/includes/specials/SpecialPermanentLink.php
new file mode 100644
index 00000000..b1772b78
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialPermanentLink.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * Redirect from Special:PermanentLink/### to index.php?oldid=###.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Redirect from Special:PermanentLink/### to index.php?oldid=###.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialPermanentLink extends RedirectSpecialPage {
+ public function __construct() {
+ parent::__construct( 'PermanentLink' );
+ $this->mAllowedRedirectParams = [];
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title|bool
+ */
+ public function getRedirect( $subpage ) {
+ $subpage = intval( $subpage );
+ if ( $subpage === 0 ) {
+ return false;
+ }
+ $this->mAddedRedirectParams['oldid'] = $subpage;
+
+ return true;
+ }
+
+ protected function showNoRedirectPage() {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->showForm();
+ }
+
+ private function showForm() {
+ $form = HTMLForm::factory( 'ooui', [
+ 'revid' => [
+ 'type' => 'int',
+ 'name' => 'revid',
+ 'label-message' => 'permanentlink-revid',
+ ],
+ ], $this->getContext(), 'permanentlink' );
+ $form->setSubmitTextMsg( 'permanentlink-submit' );
+ $form->setSubmitCallback( [ $this, 'onFormSubmit' ] );
+ $form->show();
+ }
+
+ public function onFormSubmit( $formData ) {
+ $revid = $formData['revid'];
+ $title = $this->getPageTitle( $revid ?: null );
+ $url = $title->getFullUrlForRedirect();
+ $this->getOutput()->redirect( $url );
+ }
+
+ public function isListed() {
+ return true;
+ }
+
+ protected function getGroupName() {
+ return 'redirects';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialPreferences.php b/www/wiki/includes/specials/SpecialPreferences.php
new file mode 100644
index 00000000..ba5a57ea
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialPreferences.php
@@ -0,0 +1,170 @@
+<?php
+/**
+ * Implements Special:Preferences
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that allows users to change their preferences
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialPreferences extends SpecialPage {
+ function __construct() {
+ parent::__construct( 'Preferences' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $out = $this->getOutput();
+ $out->disallowUserJs(); # Prevent hijacked user scripts from sniffing passwords etc.
+
+ $this->requireLogin( 'prefsnologintext2' );
+ $this->checkReadOnly();
+
+ if ( $par == 'reset' ) {
+ $this->showResetForm();
+
+ return;
+ }
+
+ $out->addModules( 'mediawiki.special.preferences' );
+ $out->addModuleStyles( 'mediawiki.special.preferences.styles' );
+
+ $session = $this->getRequest()->getSession();
+ if ( $session->get( 'specialPreferencesSaveSuccess' ) ) {
+ // Remove session data for the success message
+ $session->remove( 'specialPreferencesSaveSuccess' );
+ $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
+
+ $out->addHTML(
+ Html::rawElement(
+ 'div',
+ [
+ 'class' => 'mw-preferences-messagebox mw-notify-success successbox',
+ 'id' => 'mw-preferences-success',
+ 'data-mw-autohide' => 'false',
+ ],
+ Html::element( 'p', [], $this->msg( 'savedprefs' )->text() )
+ )
+ );
+ }
+
+ $this->addHelpLink( 'Help:Preferences' );
+
+ // Load the user from the master to reduce CAS errors on double post (T95839)
+ if ( $this->getRequest()->wasPosted() ) {
+ $user = $this->getUser()->getInstanceForUpdate() ?: $this->getUser();
+ } else {
+ $user = $this->getUser();
+ }
+
+ $htmlForm = $this->getFormObject( $user, $this->getContext() );
+ $htmlForm->setSubmitCallback( [ 'Preferences', 'tryUISubmit' ] );
+ $sectionTitles = $htmlForm->getPreferenceSections();
+
+ $prefTabs = '';
+ foreach ( $sectionTitles as $key ) {
+ $prefTabs .= Html::rawElement( 'li',
+ [
+ 'role' => 'presentation',
+ 'class' => ( $key === 'personal' ) ? 'selected' : null
+ ],
+ Html::rawElement( 'a',
+ [
+ 'id' => 'preftab-' . $key,
+ 'role' => 'tab',
+ 'href' => '#mw-prefsection-' . $key,
+ 'aria-controls' => 'mw-prefsection-' . $key,
+ 'aria-selected' => ( $key === 'personal' ) ? 'true' : 'false',
+ 'tabIndex' => ( $key === 'personal' ) ? 0 : -1,
+ ],
+ $htmlForm->getLegend( $key )
+ )
+ );
+ }
+
+ $out->addHTML(
+ Html::rawElement( 'ul',
+ [
+ 'id' => 'preftoc',
+ 'role' => 'tablist'
+ ],
+ $prefTabs )
+ );
+ $htmlForm->show();
+ }
+
+ /**
+ * Get the preferences form to use.
+ * @param User $user The user.
+ * @param IContextSource $context The context.
+ * @return PreferencesForm|HtmlForm
+ */
+ protected function getFormObject( $user, IContextSource $context ) {
+ return Preferences::getFormObject( $user, $context );
+ }
+
+ private function showResetForm() {
+ if ( !$this->getUser()->isAllowed( 'editmyoptions' ) ) {
+ throw new PermissionsError( 'editmyoptions' );
+ }
+
+ $this->getOutput()->addWikiMsg( 'prefs-reset-intro' );
+
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle( 'reset' ) ); // Reset subpage
+ $htmlForm = new HTMLForm( [], $context, 'prefs-restore' );
+
+ $htmlForm->setSubmitTextMsg( 'restoreprefs' );
+ $htmlForm->setSubmitDestructive();
+ $htmlForm->setSubmitCallback( [ $this, 'submitReset' ] );
+ $htmlForm->suppressReset();
+
+ $htmlForm->show();
+ }
+
+ public function submitReset( $formData ) {
+ if ( !$this->getUser()->isAllowed( 'editmyoptions' ) ) {
+ throw new PermissionsError( 'editmyoptions' );
+ }
+
+ $user = $this->getUser()->getInstanceForUpdate();
+ $user->resetOptions( 'all', $this->getContext() );
+ $user->saveSettings();
+
+ // Set session data for the success message
+ $this->getRequest()->getSession()->set( 'specialPreferencesSaveSuccess', 1 );
+
+ $url = $this->getPageTitle()->getFullUrlForRedirect();
+ $this->getOutput()->redirect( $url );
+
+ return true;
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialPrefixindex.php b/www/wiki/includes/specials/SpecialPrefixindex.php
new file mode 100644
index 00000000..34ffa073
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialPrefixindex.php
@@ -0,0 +1,319 @@
+<?php
+/**
+ * Implements Special:Prefixindex
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Implements Special:Prefixindex
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialPrefixindex extends SpecialAllPages {
+
+ /**
+ * Whether to remove the searched prefix from the displayed link. Useful
+ * for inclusion of a set of sub pages in a root page.
+ */
+ protected $stripPrefix = false;
+
+ protected $hideRedirects = false;
+
+ // Inherit $maxPerPage
+
+ function __construct() {
+ parent::__construct( 'Prefixindex' );
+ }
+
+ /**
+ * Entry point : initialise variables and call subfunctions.
+ * @param string $par Becomes "FOO" when called like Special:Prefixindex/FOO (default null)
+ */
+ function execute( $par ) {
+ global $wgContLang;
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ # GET values
+ $request = $this->getRequest();
+ $from = $request->getVal( 'from', '' );
+ $prefix = $request->getVal( 'prefix', '' );
+ $ns = $request->getIntOrNull( 'namespace' );
+ $namespace = (int)$ns; // if no namespace given, use 0 (NS_MAIN).
+ $this->hideRedirects = $request->getBool( 'hideredirects', $this->hideRedirects );
+ $this->stripPrefix = $request->getBool( 'stripprefix', $this->stripPrefix );
+
+ $namespaces = $wgContLang->getNamespaces();
+ $out->setPageTitle(
+ ( $namespace > 0 && array_key_exists( $namespace, $namespaces ) )
+ ? $this->msg( 'prefixindex-namespace', str_replace( '_', ' ', $namespaces[$namespace] ) )
+ : $this->msg( 'prefixindex' )
+ );
+
+ $showme = '';
+ if ( $par !== null ) {
+ $showme = $par;
+ } elseif ( $prefix != '' ) {
+ $showme = $prefix;
+ } elseif ( $from != '' && $ns === null ) {
+ // For back-compat with Special:Allpages
+ // Don't do this if namespace is passed, so paging works when doing NS views.
+ $showme = $from;
+ }
+
+ // T29864: if transcluded, show all pages instead of the form.
+ if ( $this->including() || $showme != '' || $ns !== null ) {
+ $this->showPrefixChunk( $namespace, $showme, $from );
+ } else {
+ $out->addHTML( $this->namespacePrefixForm( $namespace, null ) );
+ }
+ }
+
+ /**
+ * HTML for the top form
+ * @param int $namespace A namespace constant (default NS_MAIN).
+ * @param string $from DbKey we are starting listing at.
+ * @return string
+ */
+ protected function namespacePrefixForm( $namespace = NS_MAIN, $from = '' ) {
+ $out = Xml::openElement( 'div', [ 'class' => 'namespaceoptions' ] );
+ $out .= Xml::openElement(
+ 'form',
+ [ 'method' => 'get', 'action' => $this->getConfig()->get( 'Script' ) ]
+ );
+ $out .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
+ $out .= Xml::openElement( 'fieldset' );
+ $out .= Xml::element( 'legend', null, $this->msg( 'allpages' )->text() );
+ $out .= Xml::openElement( 'table', [ 'id' => 'nsselect', 'class' => 'allpages' ] );
+ $out .= "<tr>
+ <td class='mw-label'>" .
+ Xml::label( $this->msg( 'allpagesprefix' )->text(), 'nsfrom' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::input( 'prefix', 30, str_replace( '_', ' ', $from ), [ 'id' => 'nsfrom' ] ) .
+ "</td>
+ </tr>
+ <tr>
+ <td class='mw-label'>" .
+ Xml::label( $this->msg( 'namespace' )->text(), 'namespace' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Html::namespaceSelector( [
+ 'selected' => $namespace,
+ ], [
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'class' => 'namespaceselector',
+ ] ) .
+ Xml::checkLabel(
+ $this->msg( 'allpages-hide-redirects' )->text(),
+ 'hideredirects',
+ 'hideredirects',
+ $this->hideRedirects
+ ) . ' ' .
+ Xml::checkLabel(
+ $this->msg( 'prefixindex-strip' )->text(),
+ 'stripprefix',
+ 'stripprefix',
+ $this->stripPrefix
+ ) . ' ' .
+ Xml::submitButton( $this->msg( 'prefixindex-submit' )->text() ) .
+ "</td>
+ </tr>";
+ $out .= Xml::closeElement( 'table' );
+ $out .= Xml::closeElement( 'fieldset' );
+ $out .= Xml::closeElement( 'form' );
+ $out .= Xml::closeElement( 'div' );
+
+ return $out;
+ }
+
+ /**
+ * @param int $namespace Default NS_MAIN
+ * @param string $prefix
+ * @param string $from List all pages from this name (default false)
+ */
+ protected function showPrefixChunk( $namespace = NS_MAIN, $prefix, $from = null ) {
+ global $wgContLang;
+
+ if ( $from === null ) {
+ $from = $prefix;
+ }
+
+ $fromList = $this->getNamespaceKeyAndText( $namespace, $from );
+ $prefixList = $this->getNamespaceKeyAndText( $namespace, $prefix );
+ $namespaces = $wgContLang->getNamespaces();
+ $res = null;
+ $n = 0;
+ $nextRow = null;
+
+ if ( !$prefixList || !$fromList ) {
+ $out = $this->msg( 'allpagesbadtitle' )->parseAsBlock();
+ } elseif ( !array_key_exists( $namespace, $namespaces ) ) {
+ // Show errormessage and reset to NS_MAIN
+ $out = $this->msg( 'allpages-bad-ns', $namespace )->parse();
+ $namespace = NS_MAIN;
+ } else {
+ list( $namespace, $prefixKey, $prefix ) = $prefixList;
+ list( /* $fromNS */, $fromKey, ) = $fromList;
+
+ # ## @todo FIXME: Should complain if $fromNs != $namespace
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $conds = [
+ 'page_namespace' => $namespace,
+ 'page_title' . $dbr->buildLike( $prefixKey, $dbr->anyString() ),
+ 'page_title >= ' . $dbr->addQuotes( $fromKey ),
+ ];
+
+ if ( $this->hideRedirects ) {
+ $conds['page_is_redirect'] = 0;
+ }
+
+ $res = $dbr->select( 'page',
+ array_merge(
+ [ 'page_namespace', 'page_title' ],
+ LinkCache::getSelectFields()
+ ),
+ $conds,
+ __METHOD__,
+ [
+ 'ORDER BY' => 'page_title',
+ 'LIMIT' => $this->maxPerPage + 1,
+ 'USE INDEX' => 'name_title',
+ ]
+ );
+
+ // @todo FIXME: Side link to previous
+
+ if ( $res->numRows() > 0 ) {
+ $out = Html::openElement( 'ul', [ 'class' => 'mw-prefixindex-list' ] );
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+
+ $prefixLength = strlen( $prefix );
+ foreach ( $res as $row ) {
+ if ( $n >= $this->maxPerPage ) {
+ $nextRow = $row;
+ break;
+ }
+ $title = Title::newFromRow( $row );
+ // Make sure it gets into LinkCache
+ $linkCache->addGoodLinkObjFromRow( $title, $row );
+ $displayed = $title->getText();
+ // Try not to generate unclickable links
+ if ( $this->stripPrefix && $prefixLength !== strlen( $displayed ) ) {
+ $displayed = substr( $displayed, $prefixLength );
+ }
+ $link = ( $title->isRedirect() ? '<div class="allpagesredirect">' : '' ) .
+ $this->getLinkRenderer()->makeKnownLink(
+ $title,
+ $displayed
+ ) .
+ ( $title->isRedirect() ? '</div>' : '' );
+
+ $out .= "<li>$link</li>\n";
+ $n++;
+
+ }
+ $out .= Html::closeElement( 'ul' );
+
+ if ( $res->numRows() > 2 ) {
+ // Only apply CSS column styles if there's more than 2 entries.
+ // Otherwise rendering is broken as "mw-prefixindex-body"'s CSS column count is 3.
+ $out = Html::rawElement( 'div', [ 'class' => 'mw-prefixindex-body' ], $out );
+ }
+ } else {
+ $out = '';
+ }
+ }
+
+ $output = $this->getOutput();
+
+ if ( $this->including() ) {
+ // We don't show the nav-links and the form when included into other
+ // pages so let's just finish here.
+ $output->addHTML( $out );
+ return;
+ }
+
+ $topOut = $this->namespacePrefixForm( $namespace, $prefix );
+
+ if ( $res && ( $n == $this->maxPerPage ) && $nextRow ) {
+ $query = [
+ 'from' => $nextRow->page_title,
+ 'prefix' => $prefix,
+ 'hideredirects' => $this->hideRedirects,
+ 'stripprefix' => $this->stripPrefix,
+ ];
+
+ if ( $namespace || $prefix == '' ) {
+ // Keep the namespace even if it's 0 for empty prefixes.
+ // This tells us we're not just a holdover from old links.
+ $query['namespace'] = $namespace;
+ }
+
+ $nextLink = $this->getLinkRenderer()->makeKnownLink(
+ $this->getPageTitle(),
+ $this->msg( 'nextpage', str_replace( '_', ' ', $nextRow->page_title ) )->text(),
+ [],
+ $query
+ );
+
+ // Link shown at the top of the page below the form
+ $topOut .= Html::rawElement( 'div',
+ [ 'class' => 'mw-prefixindex-nav' ],
+ $nextLink
+ );
+
+ // Link shown at the footer
+ $out .= "\n" . Html::element( 'hr' ) .
+ Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-prefixindex-nav' ],
+ $nextLink
+ );
+
+ }
+
+ $output->addHTML( $topOut . $out );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'pages';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialProtectedpages.php b/www/wiki/includes/specials/SpecialProtectedpages.php
new file mode 100644
index 00000000..8e20d883
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialProtectedpages.php
@@ -0,0 +1,273 @@
+<?php
+/**
+ * Implements Special:Protectedpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists protected pages
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialProtectedpages extends SpecialPage {
+ protected $IdLevel = 'level';
+ protected $IdType = 'type';
+
+ public function __construct() {
+ parent::__construct( 'Protectedpages' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->getOutput()->addModuleStyles( 'mediawiki.special' );
+
+ $request = $this->getRequest();
+ $type = $request->getVal( $this->IdType );
+ $level = $request->getVal( $this->IdLevel );
+ $sizetype = $request->getVal( 'sizetype' );
+ $size = $request->getIntOrNull( 'size' );
+ $ns = $request->getIntOrNull( 'namespace' );
+ $indefOnly = $request->getBool( 'indefonly' ) ? 1 : 0;
+ $cascadeOnly = $request->getBool( 'cascadeonly' ) ? 1 : 0;
+ $noRedirect = $request->getBool( 'noredirect' ) ? 1 : 0;
+
+ $pager = new ProtectedPagesPager(
+ $this,
+ [],
+ $type,
+ $level,
+ $ns,
+ $sizetype,
+ $size,
+ $indefOnly,
+ $cascadeOnly,
+ $noRedirect,
+ $this->getLinkRenderer()
+ );
+
+ $this->getOutput()->addHTML( $this->showOptions(
+ $ns,
+ $type,
+ $level,
+ $sizetype,
+ $size,
+ $indefOnly,
+ $cascadeOnly,
+ $noRedirect
+ ) );
+
+ if ( $pager->getNumRows() ) {
+ $this->getOutput()->addParserOutputContent( $pager->getFullOutput() );
+ } else {
+ $this->getOutput()->addWikiMsg( 'protectedpagesempty' );
+ }
+ }
+
+ /**
+ * @param int $namespace
+ * @param string $type Restriction type
+ * @param string $level Restriction level
+ * @param string $sizetype "min" or "max"
+ * @param int $size
+ * @param bool $indefOnly Only indefinite protection
+ * @param bool $cascadeOnly Only cascading protection
+ * @param bool $noRedirect Don't show redirects
+ * @return string Input form
+ */
+ protected function showOptions( $namespace, $type = 'edit', $level, $sizetype,
+ $size, $indefOnly, $cascadeOnly, $noRedirect
+ ) {
+ $title = $this->getPageTitle();
+
+ return Xml::openElement( 'form', [ 'method' => 'get', 'action' => wfScript() ] ) .
+ Xml::openElement( 'fieldset' ) .
+ Xml::element( 'legend', [], $this->msg( 'protectedpages' )->text() ) .
+ Html::hidden( 'title', $title->getPrefixedDBkey() ) . "\n" .
+ $this->getNamespaceMenu( $namespace ) . "\n" .
+ $this->getTypeMenu( $type ) . "\n" .
+ $this->getLevelMenu( $level ) . "\n" .
+ "<br />\n" .
+ $this->getExpiryCheck( $indefOnly ) . "\n" .
+ $this->getCascadeCheck( $cascadeOnly ) . "\n" .
+ $this->getRedirectCheck( $noRedirect ) . "\n" .
+ "<br />\n" .
+ $this->getSizeLimit( $sizetype, $size ) . "\n" .
+ Xml::submitButton( $this->msg( 'protectedpages-submit' )->text() ) . "\n" .
+ Xml::closeElement( 'fieldset' ) .
+ Xml::closeElement( 'form' );
+ }
+
+ /**
+ * Prepare the namespace filter drop-down; standard namespace
+ * selector, sans the MediaWiki namespace
+ *
+ * @param string|null $namespace Pre-select namespace
+ * @return string
+ */
+ protected function getNamespaceMenu( $namespace = null ) {
+ return Html::rawElement( 'span', [ 'class' => 'mw-input-with-label' ],
+ Html::namespaceSelector(
+ [
+ 'selected' => $namespace,
+ 'all' => '',
+ 'label' => $this->msg( 'namespace' )->text()
+ ], [
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'class' => 'namespaceselector',
+ ]
+ )
+ );
+ }
+
+ /**
+ * @param bool $indefOnly
+ * @return string Formatted HTML
+ */
+ protected function getExpiryCheck( $indefOnly ) {
+ return '<span class="mw-input-with-label">' . Xml::checkLabel(
+ $this->msg( 'protectedpages-indef' )->text(),
+ 'indefonly',
+ 'indefonly',
+ $indefOnly
+ ) . "</span>\n";
+ }
+
+ /**
+ * @param bool $cascadeOnly
+ * @return string Formatted HTML
+ */
+ protected function getCascadeCheck( $cascadeOnly ) {
+ return '<span class="mw-input-with-label">' . Xml::checkLabel(
+ $this->msg( 'protectedpages-cascade' )->text(),
+ 'cascadeonly',
+ 'cascadeonly',
+ $cascadeOnly
+ ) . "</span>\n";
+ }
+
+ /**
+ * @param bool $noRedirect
+ * @return string Formatted HTML
+ */
+ protected function getRedirectCheck( $noRedirect ) {
+ return '<span class="mw-input-with-label">' . Xml::checkLabel(
+ $this->msg( 'protectedpages-noredirect' )->text(),
+ 'noredirect',
+ 'noredirect',
+ $noRedirect
+ ) . "</span>\n";
+ }
+
+ /**
+ * @param string $sizetype "min" or "max"
+ * @param mixed $size
+ * @return string Formatted HTML
+ */
+ protected function getSizeLimit( $sizetype, $size ) {
+ $max = $sizetype === 'max';
+
+ return '<span class="mw-input-with-label">' . Xml::radioLabel(
+ $this->msg( 'minimum-size' )->text(),
+ 'sizetype',
+ 'min',
+ 'wpmin',
+ !$max
+ ) .
+ ' ' .
+ Xml::radioLabel(
+ $this->msg( 'maximum-size' )->text(),
+ 'sizetype',
+ 'max',
+ 'wpmax',
+ $max
+ ) .
+ ' ' .
+ Xml::input( 'size', 9, $size, [ 'id' => 'wpsize' ] ) .
+ ' ' .
+ Xml::label( $this->msg( 'pagesize' )->text(), 'wpsize' ) . "</span>\n";
+ }
+
+ /**
+ * Creates the input label of the restriction type
+ * @param string $pr_type Protection type
+ * @return string Formatted HTML
+ */
+ protected function getTypeMenu( $pr_type ) {
+ $m = []; // Temporary array
+ $options = [];
+
+ // First pass to load the log names
+ foreach ( Title::getFilteredRestrictionTypes( true ) as $type ) {
+ // Messages: restriction-edit, restriction-move, restriction-create, restriction-upload
+ $text = $this->msg( "restriction-$type" )->text();
+ $m[$text] = $type;
+ }
+
+ // Third pass generates sorted XHTML content
+ foreach ( $m as $text => $type ) {
+ $selected = ( $type == $pr_type );
+ $options[] = Xml::option( $text, $type, $selected ) . "\n";
+ }
+
+ return '<span class="mw-input-with-label">' .
+ Xml::label( $this->msg( 'restriction-type' )->text(), $this->IdType ) . ' ' .
+ Xml::tags( 'select',
+ [ 'id' => $this->IdType, 'name' => $this->IdType ],
+ implode( "\n", $options ) ) . "</span>";
+ }
+
+ /**
+ * Creates the input label of the restriction level
+ * @param string $pr_level Protection level
+ * @return string Formatted HTML
+ */
+ protected function getLevelMenu( $pr_level ) {
+ // Temporary array
+ $m = [ $this->msg( 'restriction-level-all' )->text() => 0 ];
+ $options = [];
+
+ // First pass to load the log names
+ foreach ( $this->getConfig()->get( 'RestrictionLevels' ) as $type ) {
+ // Messages used can be 'restriction-level-sysop' and 'restriction-level-autoconfirmed'
+ if ( $type != '' && $type != '*' ) {
+ $text = $this->msg( "restriction-level-$type" )->text();
+ $m[$text] = $type;
+ }
+ }
+
+ // Third pass generates sorted XHTML content
+ foreach ( $m as $text => $type ) {
+ $selected = ( $type == $pr_level );
+ $options[] = Xml::option( $text, $type, $selected );
+ }
+
+ return '<span class="mw-input-with-label">' .
+ Xml::label( $this->msg( 'restriction-level' )->text(), $this->IdLevel ) . ' ' .
+ Xml::tags( 'select',
+ [ 'id' => $this->IdLevel, 'name' => $this->IdLevel ],
+ implode( "\n", $options ) ) . "</span>";
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialProtectedtitles.php b/www/wiki/includes/specials/SpecialProtectedtitles.php
new file mode 100644
index 00000000..fa9033cb
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialProtectedtitles.php
@@ -0,0 +1,192 @@
+<?php
+/**
+ * Implements Special:Protectedtitles
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that list protected titles from creation
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialProtectedtitles extends SpecialPage {
+ protected $IdLevel = 'level';
+ protected $IdType = 'type';
+
+ public function __construct() {
+ parent::__construct( 'Protectedtitles' );
+ }
+
+ function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $request = $this->getRequest();
+ $type = $request->getVal( $this->IdType );
+ $level = $request->getVal( $this->IdLevel );
+ $sizetype = $request->getVal( 'sizetype' );
+ $size = $request->getIntOrNull( 'size' );
+ $NS = $request->getIntOrNull( 'namespace' );
+
+ $pager = new ProtectedTitlesPager( $this, [], $type, $level, $NS, $sizetype, $size );
+
+ $this->getOutput()->addHTML( $this->showOptions( $NS, $type, $level ) );
+
+ if ( $pager->getNumRows() ) {
+ $this->getOutput()->addHTML(
+ $pager->getNavigationBar() .
+ '<ul>' . $pager->getBody() . '</ul>' .
+ $pager->getNavigationBar()
+ );
+ } else {
+ $this->getOutput()->addWikiMsg( 'protectedtitlesempty' );
+ }
+ }
+
+ /**
+ * Callback function to output a restriction
+ *
+ * @param object $row Database row
+ * @return string
+ */
+ function formatRow( $row ) {
+ $title = Title::makeTitleSafe( $row->pt_namespace, $row->pt_title );
+ if ( !$title ) {
+ return Html::rawElement(
+ 'li',
+ [],
+ Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ $row->pt_namespace,
+ $row->pt_title
+ )
+ )
+ ) . "\n";
+ }
+
+ $link = $this->getLinkRenderer()->makeLink( $title );
+ $description_items = [];
+ // Messages: restriction-level-sysop, restriction-level-autoconfirmed
+ $protType = $this->msg( 'restriction-level-' . $row->pt_create_perm )->escaped();
+ $description_items[] = $protType;
+ $lang = $this->getLanguage();
+ $expiry = strlen( $row->pt_expiry ) ?
+ $lang->formatExpiry( $row->pt_expiry, TS_MW ) :
+ 'infinity';
+
+ if ( $expiry !== 'infinity' ) {
+ $user = $this->getUser();
+ $description_items[] = $this->msg(
+ 'protect-expiring-local',
+ $lang->userTimeAndDate( $expiry, $user ),
+ $lang->userDate( $expiry, $user ),
+ $lang->userTime( $expiry, $user )
+ )->escaped();
+ }
+
+ // @todo i18n: This should use a comma separator instead of a hard coded comma, right?
+ return '<li>' . $lang->specialList( $link, implode( $description_items, ', ' ) ) . "</li>\n";
+ }
+
+ /**
+ * @param int $namespace
+ * @param string $type
+ * @param string $level
+ * @return string
+ * @private
+ */
+ function showOptions( $namespace, $type = 'edit', $level ) {
+ $action = htmlspecialchars( wfScript() );
+ $title = $this->getPageTitle();
+ $special = htmlspecialchars( $title->getPrefixedDBkey() );
+
+ return "<form action=\"$action\" method=\"get\">\n" .
+ '<fieldset>' .
+ Xml::element( 'legend', [], $this->msg( 'protectedtitles' )->text() ) .
+ Html::hidden( 'title', $special ) . "&#160;\n" .
+ $this->getNamespaceMenu( $namespace ) . "&#160;\n" .
+ $this->getLevelMenu( $level ) . "&#160;\n" .
+ "&#160;" . Xml::submitButton( $this->msg( 'protectedtitles-submit' )->text() ) . "\n" .
+ "</fieldset></form>";
+ }
+
+ /**
+ * Prepare the namespace filter drop-down; standard namespace
+ * selector, sans the MediaWiki namespace
+ *
+ * @param string|null $namespace Pre-select namespace
+ * @return string
+ */
+ function getNamespaceMenu( $namespace = null ) {
+ return Html::namespaceSelector(
+ [
+ 'selected' => $namespace,
+ 'all' => '',
+ 'label' => $this->msg( 'namespace' )->text()
+ ], [
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'class' => 'namespaceselector',
+ ]
+ );
+ }
+
+ /**
+ * @param string $pr_level Determines which option is selected as default
+ * @return string Formatted HTML
+ * @private
+ */
+ function getLevelMenu( $pr_level ) {
+ // Temporary array
+ $m = [ $this->msg( 'restriction-level-all' )->text() => 0 ];
+ $options = [];
+
+ // First pass to load the log names
+ foreach ( $this->getConfig()->get( 'RestrictionLevels' ) as $type ) {
+ if ( $type != '' && $type != '*' ) {
+ // Messages: restriction-level-sysop, restriction-level-autoconfirmed
+ $text = $this->msg( "restriction-level-$type" )->text();
+ $m[$text] = $type;
+ }
+ }
+
+ // Is there only one level (aside from "all")?
+ if ( count( $m ) <= 2 ) {
+ return '';
+ }
+ // Third pass generates sorted XHTML content
+ foreach ( $m as $text => $type ) {
+ $selected = ( $type == $pr_level );
+ $options[] = Xml::option( $text, $type, $selected );
+ }
+
+ return Xml::label( $this->msg( 'restriction-level' )->text(), $this->IdLevel ) . '&#160;' .
+ Xml::tags( 'select',
+ [ 'id' => $this->IdLevel, 'name' => $this->IdLevel ],
+ implode( "\n", $options ) );
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRandomInCategory.php b/www/wiki/includes/specials/SpecialRandomInCategory.php
new file mode 100644
index 00000000..adf12d40
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRandomInCategory.php
@@ -0,0 +1,315 @@
+<?php
+/**
+ * Implements Special:RandomInCategory
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Brian Wolff
+ */
+
+/**
+ * Special page to direct the user to a random page
+ *
+ * @note The method used here is rather biased. It is assumed that
+ * the use of this page will be people wanting to get a random page
+ * out of a maintenance category, to fix it up. The method used by
+ * this page should return different pages in an unpredictable fashion
+ * which is hoped to be sufficient, even if some pages are selected
+ * more often than others.
+ *
+ * A more unbiased method could be achieved by adding a cl_random field
+ * to the categorylinks table.
+ *
+ * The method used here is as follows:
+ * * Find the smallest and largest timestamp in the category
+ * * Pick a random timestamp in between
+ * * Pick an offset between 0 and 30
+ * * Get the offset'ed page that is newer than the timestamp selected
+ * The offset is meant to counter the fact the timestamps aren't usually
+ * uniformly distributed, so if things are very non-uniform at least we
+ * won't have the same page selected 99% of the time.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRandomInCategory extends FormSpecialPage {
+ /** @var string[] */
+ protected $extra = []; // Extra SQL statements
+ /** @var Title|false */
+ protected $category = false; // Title object of category
+ /** @var int */
+ protected $maxOffset = 30; // Max amount to fudge randomness by.
+ /** @var int|null */
+ private $maxTimestamp = null;
+ /** @var int|null */
+ private $minTimestamp = null;
+
+ public function __construct( $name = 'RandomInCategory' ) {
+ parent::__construct( $name );
+ }
+
+ /**
+ * Set which category to use.
+ * @param Title $cat
+ */
+ public function setCategory( Title $cat ) {
+ $this->category = $cat;
+ $this->maxTimestamp = null;
+ $this->minTimestamp = null;
+ }
+
+ protected function getFormFields() {
+ $this->addHelpLink( 'Help:RandomInCategory' );
+
+ return [
+ 'category' => [
+ 'type' => 'title',
+ 'namespace' => NS_CATEGORY,
+ 'relative' => true,
+ 'label-message' => 'randomincategory-category',
+ 'required' => true,
+ ]
+ ];
+ }
+
+ public function requiresWrite() {
+ return false;
+ }
+
+ public function requiresUnblock() {
+ return false;
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setSubmitTextMsg( 'randomincategory-submit' );
+ }
+
+ protected function setParameter( $par ) {
+ // if subpage present, fake form submission
+ $this->onSubmit( [ 'category' => $par ] );
+ }
+
+ public function onSubmit( array $data ) {
+ $cat = false;
+
+ $categoryStr = $data['category'];
+
+ if ( $categoryStr ) {
+ $cat = Title::newFromText( $categoryStr, NS_CATEGORY );
+ }
+
+ if ( $cat && $cat->getNamespace() !== NS_CATEGORY ) {
+ // Someone searching for something like "Wikipedia:Foo"
+ $cat = Title::makeTitleSafe( NS_CATEGORY, $categoryStr );
+ }
+
+ if ( $cat ) {
+ $this->setCategory( $cat );
+ }
+
+ if ( !$this->category && $categoryStr ) {
+ $msg = $this->msg( 'randomincategory-invalidcategory',
+ wfEscapeWikiText( $categoryStr ) );
+
+ return Status::newFatal( $msg );
+
+ } elseif ( !$this->category ) {
+ return false; // no data sent
+ }
+
+ $title = $this->getRandomTitle();
+
+ if ( is_null( $title ) ) {
+ $msg = $this->msg( 'randomincategory-nopages',
+ $this->category->getText() );
+
+ return Status::newFatal( $msg );
+ }
+
+ $this->getOutput()->redirect( $title->getFullURL() );
+ }
+
+ /**
+ * Choose a random title.
+ * @return Title|null Title object (or null if nothing to choose from)
+ */
+ public function getRandomTitle() {
+ // Convert to float, since we do math with the random number.
+ $rand = (float)wfRandom();
+ $title = null;
+
+ // Given that timestamps are rather unevenly distributed, we also
+ // use an offset between 0 and 30 to make any biases less noticeable.
+ $offset = mt_rand( 0, $this->maxOffset );
+
+ if ( mt_rand( 0, 1 ) ) {
+ $up = true;
+ } else {
+ $up = false;
+ }
+
+ $row = $this->selectRandomPageFromDB( $rand, $offset, $up );
+
+ // Try again without the timestamp offset (wrap around the end)
+ if ( !$row ) {
+ $row = $this->selectRandomPageFromDB( false, $offset, $up );
+ }
+
+ // Maybe the category is really small and offset too high
+ if ( !$row ) {
+ $row = $this->selectRandomPageFromDB( $rand, 0, $up );
+ }
+
+ // Just get the first entry.
+ if ( !$row ) {
+ $row = $this->selectRandomPageFromDB( false, 0, true );
+ }
+
+ if ( $row ) {
+ return Title::makeTitle( $row->page_namespace, $row->page_title );
+ }
+
+ return null;
+ }
+
+ /**
+ * @param float $rand Random number between 0 and 1
+ * @param int $offset Extra offset to fudge randomness
+ * @param bool $up True to get the result above the random number, false for below
+ * @return array Query information.
+ * @throws MWException
+ * @note The $up parameter is supposed to counteract what would happen if there
+ * was a large gap in the distribution of cl_timestamp values. This way instead
+ * of things to the right of the gap being favoured, both sides of the gap
+ * are favoured.
+ */
+ protected function getQueryInfo( $rand, $offset, $up ) {
+ $op = $up ? '>=' : '<=';
+ $dir = $up ? 'ASC' : 'DESC';
+ if ( !$this->category instanceof Title ) {
+ throw new MWException( 'No category set' );
+ }
+ $qi = [
+ 'tables' => [ 'categorylinks', 'page' ],
+ 'fields' => [ 'page_title', 'page_namespace' ],
+ 'conds' => array_merge( [
+ 'cl_to' => $this->category->getDBkey(),
+ ], $this->extra ),
+ 'options' => [
+ 'ORDER BY' => 'cl_timestamp ' . $dir,
+ 'LIMIT' => 1,
+ 'OFFSET' => $offset
+ ],
+ 'join_conds' => [
+ 'page' => [ 'INNER JOIN', 'cl_from = page_id' ]
+ ]
+ ];
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $minClTime = $this->getTimestampOffset( $rand );
+ if ( $minClTime ) {
+ $qi['conds'][] = 'cl_timestamp ' . $op . ' ' .
+ $dbr->addQuotes( $dbr->timestamp( $minClTime ) );
+ }
+
+ return $qi;
+ }
+
+ /**
+ * @param float $rand Random number between 0 and 1
+ *
+ * @return int|bool A random (unix) timestamp from the range of the category or false on failure
+ */
+ protected function getTimestampOffset( $rand ) {
+ if ( $rand === false ) {
+ return false;
+ }
+ if ( !$this->minTimestamp || !$this->maxTimestamp ) {
+ try {
+ list( $this->minTimestamp, $this->maxTimestamp ) = $this->getMinAndMaxForCat( $this->category );
+ } catch ( Exception $e ) {
+ // Possibly no entries in category.
+ return false;
+ }
+ }
+
+ $ts = ( $this->maxTimestamp - $this->minTimestamp ) * $rand + $this->minTimestamp;
+
+ return intval( $ts );
+ }
+
+ /**
+ * Get the lowest and highest timestamp for a category.
+ *
+ * @param Title $category
+ * @return array The lowest and highest timestamp
+ * @throws MWException If category has no entries.
+ */
+ protected function getMinAndMaxForCat( Title $category ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->selectRow(
+ 'categorylinks',
+ [
+ 'low' => 'MIN( cl_timestamp )',
+ 'high' => 'MAX( cl_timestamp )'
+ ],
+ [
+ 'cl_to' => $this->category->getDBkey(),
+ ],
+ __METHOD__,
+ [
+ 'LIMIT' => 1
+ ]
+ );
+ if ( !$res ) {
+ throw new MWException( 'No entries in category' );
+ }
+
+ return [ wfTimestamp( TS_UNIX, $res->low ), wfTimestamp( TS_UNIX, $res->high ) ];
+ }
+
+ /**
+ * @param float $rand A random number that is converted to a random timestamp
+ * @param int $offset A small offset to make the result seem more "random"
+ * @param bool $up Get the result above the random value
+ * @param string $fname The name of the calling method
+ * @return array Info for the title selected.
+ */
+ private function selectRandomPageFromDB( $rand, $offset, $up, $fname = __METHOD__ ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $query = $this->getQueryInfo( $rand, $offset, $up );
+ $res = $dbr->select(
+ $query['tables'],
+ $query['fields'],
+ $query['conds'],
+ $fname,
+ $query['options'],
+ $query['join_conds']
+ );
+
+ return $res->fetchObject();
+ }
+
+ protected function getGroupName() {
+ return 'redirects';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRandompage.php b/www/wiki/includes/specials/SpecialRandompage.php
new file mode 100644
index 00000000..e3b567d7
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRandompage.php
@@ -0,0 +1,180 @@
+<?php
+/**
+ * Implements Special:Randompage
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>, Ilmari Karonen
+ */
+
+/**
+ * Special page to direct the user to a random page
+ *
+ * @ingroup SpecialPage
+ */
+class RandomPage extends SpecialPage {
+ private $namespaces; // namespaces to select pages from
+ protected $isRedir = false; // should the result be a redirect?
+ protected $extra = []; // Extra SQL statements
+
+ public function __construct( $name = 'Randompage' ) {
+ $this->namespaces = MWNamespace::getContentNamespaces();
+ parent::__construct( $name );
+ }
+
+ public function getNamespaces() {
+ return $this->namespaces;
+ }
+
+ public function setNamespace( $ns ) {
+ if ( !$ns || $ns < NS_MAIN ) {
+ $ns = NS_MAIN;
+ }
+ $this->namespaces = [ $ns ];
+ }
+
+ // select redirects instead of normal pages?
+ public function isRedirect() {
+ return $this->isRedir;
+ }
+
+ public function execute( $par ) {
+ global $wgContLang;
+
+ if ( is_string( $par ) ) {
+ // Testing for stringiness since we want to catch
+ // the empty string to mean main namespace only.
+ $this->setNamespace( $wgContLang->getNsIndex( $par ) );
+ }
+
+ $title = $this->getRandomTitle();
+
+ if ( is_null( $title ) ) {
+ $this->setHeaders();
+ // Message: randompage-nopages, randomredirect-nopages
+ $this->getOutput()->addWikiMsg( strtolower( $this->getName() ) . '-nopages',
+ $this->getNsList(), count( $this->namespaces ) );
+
+ return;
+ }
+
+ $redirectParam = $this->isRedirect() ? [ 'redirect' => 'no' ] : [];
+ $query = array_merge( $this->getRequest()->getValues(), $redirectParam );
+ unset( $query['title'] );
+ $this->getOutput()->redirect( $title->getFullURL( $query ) );
+ }
+
+ /**
+ * Get a comma-delimited list of namespaces we don't have
+ * any pages in
+ * @return string
+ */
+ private function getNsList() {
+ global $wgContLang;
+ $nsNames = [];
+ foreach ( $this->namespaces as $n ) {
+ if ( $n === NS_MAIN ) {
+ $nsNames[] = $this->msg( 'blanknamespace' )->plain();
+ } else {
+ $nsNames[] = $wgContLang->getNsText( $n );
+ }
+ }
+
+ return $wgContLang->commaList( $nsNames );
+ }
+
+ /**
+ * Choose a random title.
+ * @return Title|null Title object (or null if nothing to choose from)
+ */
+ public function getRandomTitle() {
+ $randstr = wfRandom();
+ $title = null;
+
+ if ( !Hooks::run(
+ 'SpecialRandomGetRandomTitle',
+ [ &$randstr, &$this->isRedir, &$this->namespaces, &$this->extra, &$title ]
+ ) ) {
+ return $title;
+ }
+
+ $row = $this->selectRandomPageFromDB( $randstr );
+
+ /* If we picked a value that was higher than any in
+ * the DB, wrap around and select the page with the
+ * lowest value instead! One might think this would
+ * skew the distribution, but in fact it won't cause
+ * any more bias than what the page_random scheme
+ * causes anyway. Trust me, I'm a mathematician. :)
+ */
+ if ( !$row ) {
+ $row = $this->selectRandomPageFromDB( "0" );
+ }
+
+ if ( $row ) {
+ return Title::makeTitleSafe( $row->page_namespace, $row->page_title );
+ }
+
+ return null;
+ }
+
+ protected function getQueryInfo( $randstr ) {
+ $redirect = $this->isRedirect() ? 1 : 0;
+ $tables = [ 'page' ];
+ $conds = array_merge( [
+ 'page_namespace' => $this->namespaces,
+ 'page_is_redirect' => $redirect,
+ 'page_random >= ' . $randstr
+ ], $this->extra );
+ $joinConds = [];
+
+ // Allow extensions to modify the query
+ Hooks::run( 'RandomPageQuery', [ &$tables, &$conds, &$joinConds ] );
+
+ return [
+ 'tables' => $tables,
+ 'fields' => [ 'page_title', 'page_namespace' ],
+ 'conds' => $conds,
+ 'options' => [
+ 'ORDER BY' => 'page_random',
+ 'LIMIT' => 1,
+ ],
+ 'join_conds' => $joinConds
+ ];
+ }
+
+ private function selectRandomPageFromDB( $randstr, $fname = __METHOD__ ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $query = $this->getQueryInfo( $randstr );
+ $res = $dbr->select(
+ $query['tables'],
+ $query['fields'],
+ $query['conds'],
+ $fname,
+ $query['options'],
+ $query['join_conds']
+ );
+
+ return $dbr->fetchObject( $res );
+ }
+
+ protected function getGroupName() {
+ return 'redirects';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRandomredirect.php b/www/wiki/includes/specials/SpecialRandomredirect.php
new file mode 100644
index 00000000..7c36a28a
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRandomredirect.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Implements Special:Randomredirect
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>, Ilmari Karonen
+ */
+
+/**
+ * Special page to direct the user to a random redirect page (minus the second redirect)
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRandomredirect extends RandomPage {
+ function __construct() {
+ parent::__construct( 'Randomredirect' );
+ $this->isRedir = true;
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRandomrootpage.php b/www/wiki/includes/specials/SpecialRandomrootpage.php
new file mode 100644
index 00000000..0df8423f
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRandomrootpage.php
@@ -0,0 +1,39 @@
+<?php
+
+/**
+ * Implements Special:Randomrootpage
+ *
+ * Copyright © 2008 Hojjat (aka Huji)
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+class SpecialRandomrootpage extends RandomPage {
+
+ public function __construct() {
+ parent::__construct( 'Randomrootpage' );
+ $dbr = wfGetDB( DB_REPLICA );
+ $this->extra[] = 'page_title NOT ' . $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() );
+ }
+
+ // Don't select redirects
+ public function isRedirect() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRecentchanges.php b/www/wiki/includes/specials/SpecialRecentchanges.php
new file mode 100644
index 00000000..40834cb5
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRecentchanges.php
@@ -0,0 +1,1012 @@
+<?php
+/**
+ * Implements Special:Recentchanges
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+/**
+ * A special page that lists last changes made to the wiki
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRecentChanges extends ChangesListSpecialPage {
+
+ protected static $savedQueriesPreferenceName = 'rcfilters-saved-queries';
+
+ private $watchlistFilterGroupDefinition;
+
+ // @codingStandardsIgnoreStart Needed "useless" override to change parameters.
+ public function __construct( $name = 'Recentchanges', $restriction = '' ) {
+ parent::__construct( $name, $restriction );
+
+ $this->watchlistFilterGroupDefinition = [
+ 'name' => 'watchlist',
+ 'title' => 'rcfilters-filtergroup-watchlist',
+ 'class' => ChangesListStringOptionsFilterGroup::class,
+ 'priority' => -9,
+ 'isFullCoverage' => true,
+ 'filters' => [
+ [
+ 'name' => 'watched',
+ 'label' => 'rcfilters-filter-watchlist-watched-label',
+ 'description' => 'rcfilters-filter-watchlist-watched-description',
+ 'cssClassSuffix' => 'watched',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'wl_user' );
+ }
+ ],
+ [
+ 'name' => 'watchednew',
+ 'label' => 'rcfilters-filter-watchlist-watchednew-label',
+ 'description' => 'rcfilters-filter-watchlist-watchednew-description',
+ 'cssClassSuffix' => 'watchednew',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'wl_user' ) &&
+ $rc->getAttribute( 'rc_timestamp' ) &&
+ $rc->getAttribute( 'wl_notificationtimestamp' ) &&
+ $rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' );
+ },
+ ],
+ [
+ 'name' => 'notwatched',
+ 'label' => 'rcfilters-filter-watchlist-notwatched-label',
+ 'description' => 'rcfilters-filter-watchlist-notwatched-description',
+ 'cssClassSuffix' => 'notwatched',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'wl_user' ) === null;
+ },
+ ]
+ ],
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ 'queryCallable' => function ( $specialPageClassName, $context, $dbr,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
+ sort( $selectedValues );
+ $notwatchedCond = 'wl_user IS NULL';
+ $watchedCond = 'wl_user IS NOT NULL';
+ $newCond = 'rc_timestamp >= wl_notificationtimestamp';
+
+ if ( $selectedValues === [ 'notwatched' ] ) {
+ $conds[] = $notwatchedCond;
+ return;
+ }
+
+ if ( $selectedValues === [ 'watched' ] ) {
+ $conds[] = $watchedCond;
+ return;
+ }
+
+ if ( $selectedValues === [ 'watchednew' ] ) {
+ $conds[] = $dbr->makeList( [
+ $watchedCond,
+ $newCond
+ ], LIST_AND );
+ return;
+ }
+
+ if ( $selectedValues === [ 'notwatched', 'watched' ] ) {
+ // no filters
+ return;
+ }
+
+ if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) {
+ $conds[] = $dbr->makeList( [
+ $notwatchedCond,
+ $dbr->makeList( [
+ $watchedCond,
+ $newCond
+ ], LIST_AND )
+ ], LIST_OR );
+ return;
+ }
+
+ if ( $selectedValues === [ 'watched', 'watchednew' ] ) {
+ $conds[] = $watchedCond;
+ return;
+ }
+
+ if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) {
+ // no filters
+ return;
+ }
+ }
+ ];
+ }
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * Main execution point
+ *
+ * @param string $subpage
+ */
+ public function execute( $subpage ) {
+ // Backwards-compatibility: redirect to new feed URLs
+ $feedFormat = $this->getRequest()->getVal( 'feed' );
+ if ( !$this->including() && $feedFormat ) {
+ $query = $this->getFeedQuery();
+ $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss';
+ $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) );
+
+ return;
+ }
+
+ // 10 seconds server-side caching max
+ $out = $this->getOutput();
+ $out->setCdnMaxage( 10 );
+ // Check if the client has a cached version
+ $lastmod = $this->checkLastModified();
+ if ( $lastmod === false ) {
+ return;
+ }
+
+ $this->addHelpLink(
+ '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes',
+ true
+ );
+ parent::execute( $subpage );
+
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $out->addJsConfigVars( 'wgStructuredChangeFiltersLiveUpdateSupported', true );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function transformFilterDefinition( array $filterDefinition ) {
+ if ( isset( $filterDefinition['showHideSuffix'] ) ) {
+ $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix'];
+ }
+
+ return $filterDefinition;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function registerFilters() {
+ parent::registerFilters();
+
+ if (
+ !$this->including() &&
+ $this->getUser()->isLoggedIn() &&
+ $this->getUser()->isAllowed( 'viewmywatchlist' )
+ ) {
+ $this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] );
+ $watchlistGroup = $this->getFilterGroup( 'watchlist' );
+ $watchlistGroup->getFilter( 'watched' )->setAsSupersetOf(
+ $watchlistGroup->getFilter( 'watchednew' )
+ );
+ }
+
+ $user = $this->getUser();
+
+ $significance = $this->getFilterGroup( 'significance' );
+ $hideMinor = $significance->getFilter( 'hideminor' );
+ $hideMinor->setDefault( $user->getBoolOption( 'hideminor' ) );
+
+ $automated = $this->getFilterGroup( 'automated' );
+ $hideBots = $automated->getFilter( 'hidebots' );
+ $hideBots->setDefault( true );
+
+ $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
+ if ( $reviewStatus !== null ) {
+ // Conditional on feature being available and rights
+ $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' );
+ $hidePatrolled->setDefault( $user->getBoolOption( 'hidepatrolled' ) );
+ }
+
+ $changeType = $this->getFilterGroup( 'changeType' );
+ $hideCategorization = $changeType->getFilter( 'hidecategorization' );
+ if ( $hideCategorization !== null ) {
+ // Conditional on feature being available
+ $hideCategorization->setDefault( $user->getBoolOption( 'hidecategorization' ) );
+ }
+ }
+
+ /**
+ * Get a FormOptions object containing the default options
+ *
+ * @return FormOptions
+ */
+ public function getDefaultOptions() {
+ $opts = parent::getDefaultOptions();
+
+ $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
+ $opts->add( 'limit', $this->getDefaultLimit() );
+ $opts->add( 'from', '' );
+
+ $opts->add( 'categories', '' );
+ $opts->add( 'categories_any', false );
+
+ return $opts;
+ }
+
+ /**
+ * Get all custom filters
+ *
+ * @return array Map of filter URL param names to properties (msg/default)
+ */
+ protected function getCustomFilters() {
+ if ( $this->customFilters === null ) {
+ $this->customFilters = parent::getCustomFilters();
+ Hooks::run( 'SpecialRecentChangesFilters', [ $this, &$this->customFilters ], '1.23' );
+ }
+
+ return $this->customFilters;
+ }
+
+ /**
+ * Process $par and put options found in $opts. Used when including the page.
+ *
+ * @param string $par
+ * @param FormOptions $opts
+ */
+ public function parseParameters( $par, FormOptions $opts ) {
+ parent::parseParameters( $par, $opts );
+
+ $bits = preg_split( '/\s*,\s*/', trim( $par ) );
+ foreach ( $bits as $bit ) {
+ if ( is_numeric( $bit ) ) {
+ $opts['limit'] = $bit;
+ }
+
+ $m = [];
+ if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
+ $opts['limit'] = $m[1];
+ }
+ if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) {
+ $opts['days'] = $m[1];
+ }
+ if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
+ $opts['namespace'] = $m[1];
+ }
+ if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) {
+ $opts['tagfilter'] = $m[1];
+ }
+ }
+ }
+
+ public function validateOptions( FormOptions $opts ) {
+ $opts->validateIntBounds( 'limit', 0, 5000 );
+ $opts->validateBounds( 'days', 0, $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 ) );
+ parent::validateOptions( $opts );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function buildQuery( &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds, FormOptions $opts
+ ) {
+ $dbr = $this->getDB();
+ parent::buildQuery( $tables, $fields, $conds,
+ $query_options, $join_conds, $opts );
+
+ // Calculate cutoff
+ $cutoff_unixtime = time() - $opts['days'] * 3600 * 24;
+ $cutoff = $dbr->timestamp( $cutoff_unixtime );
+
+ $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
+ if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
+ $cutoff = $dbr->timestamp( $opts['from'] );
+ } else {
+ $opts->reset( 'from' );
+ }
+
+ $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function doMainQuery( $tables, $fields, $conds, $query_options,
+ $join_conds, FormOptions $opts
+ ) {
+ $dbr = $this->getDB();
+ $user = $this->getUser();
+
+ $tables[] = 'recentchanges';
+ $fields = array_merge( RecentChange::selectFields(), $fields );
+
+ // JOIN on watchlist for users
+ if ( $user->isLoggedIn() && $user->isAllowed( 'viewmywatchlist' ) ) {
+ $tables[] = 'watchlist';
+ $fields[] = 'wl_user';
+ $fields[] = 'wl_notificationtimestamp';
+ $join_conds['watchlist'] = [ 'LEFT JOIN', [
+ 'wl_user' => $user->getId(),
+ 'wl_title=rc_title',
+ 'wl_namespace=rc_namespace'
+ ] ];
+ }
+
+ // JOIN on page, used for 'last revision' filter highlight
+ $tables[] = 'page';
+ $fields[] = 'page_latest';
+ $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
+
+ $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $fields,
+ $conds,
+ $join_conds,
+ $query_options,
+ $tagFilter
+ );
+
+ if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
+ $opts )
+ ) {
+ return false;
+ }
+
+ if ( $this->areFiltersInConflict() ) {
+ return false;
+ }
+
+ $orderByAndLimit = [
+ 'ORDER BY' => 'rc_timestamp DESC',
+ 'LIMIT' => $opts['limit']
+ ];
+ if ( in_array( 'DISTINCT', $query_options ) ) {
+ // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
+ // In order to prevent DISTINCT from causing query performance problems,
+ // we have to GROUP BY the primary key. This in turn requires us to add
+ // the primary key to the end of the ORDER BY, and the old ORDER BY to the
+ // start of the GROUP BY
+ $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
+ $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
+ }
+ // array_merge() is used intentionally here so that hooks can, should
+ // they so desire, override the ORDER BY / LIMIT condition(s); prior to
+ // MediaWiki 1.26 this used to use the plus operator instead, which meant
+ // that extensions weren't able to change these conditions
+ $query_options = array_merge( $orderByAndLimit, $query_options );
+ $rows = $dbr->select(
+ $tables,
+ $fields,
+ // rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough
+ // knowledge to use an index merge if it wants (it may use some other index though).
+ $conds + [ 'rc_new' => [ 0, 1 ] ],
+ __METHOD__,
+ $query_options,
+ $join_conds
+ );
+
+ // Build the final data
+ if ( $this->getConfig()->get( 'AllowCategorizedRecentChanges' ) ) {
+ $this->filterByCategories( $rows, $opts );
+ }
+
+ return $rows;
+ }
+
+ protected function runMainQueryHook( &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds, $opts
+ ) {
+ return parent::runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts )
+ && Hooks::run(
+ 'SpecialRecentChangesQuery',
+ [ &$conds, &$tables, &$join_conds, $opts, &$query_options, &$fields ],
+ '1.23'
+ );
+ }
+
+ protected function getDB() {
+ return wfGetDB( DB_REPLICA, 'recentchanges' );
+ }
+
+ public function outputFeedLinks() {
+ $this->addFeedLinks( $this->getFeedQuery() );
+ }
+
+ /**
+ * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view.
+ *
+ * @return array
+ */
+ protected function getFeedQuery() {
+ $query = array_filter( $this->getOptions()->getAllValues(), function ( $value ) {
+ // API handles empty parameters in a different way
+ return $value !== '';
+ } );
+ $query['action'] = 'feedrecentchanges';
+ $feedLimit = $this->getConfig()->get( 'FeedLimit' );
+ if ( $query['limit'] > $feedLimit ) {
+ $query['limit'] = $feedLimit;
+ }
+
+ return $query;
+ }
+
+ /**
+ * Build and output the actual changes list.
+ *
+ * @param ResultWrapper $rows Database rows
+ * @param FormOptions $opts
+ */
+ public function outputChangesList( $rows, $opts ) {
+ $limit = $opts['limit'];
+
+ $showWatcherCount = $this->getConfig()->get( 'RCShowWatchingUsers' )
+ && $this->getUser()->getOption( 'shownumberswatching' );
+ $watcherCache = [];
+
+ $counter = 1;
+ $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
+ $list->initChangesListRows( $rows );
+
+ $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' );
+ $rclistOutput = $list->beginRecentChangesList();
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $rclistOutput .= $this->makeLegend();
+ }
+
+ foreach ( $rows as $obj ) {
+ if ( $limit == 0 ) {
+ break;
+ }
+ $rc = RecentChange::newFromRow( $obj );
+
+ # Skip CatWatch entries for hidden cats based on user preference
+ if (
+ $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
+ !$userShowHiddenCats &&
+ $rc->getParam( 'hidden-cat' )
+ ) {
+ continue;
+ }
+
+ $rc->counter = $counter++;
+ # Check if the page has been updated since the last visit
+ if ( $this->getConfig()->get( 'ShowUpdatedMarker' )
+ && !empty( $obj->wl_notificationtimestamp )
+ ) {
+ $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp );
+ } else {
+ $rc->notificationtimestamp = false; // Default
+ }
+ # Check the number of users watching the page
+ $rc->numberofWatchingusers = 0; // Default
+ if ( $showWatcherCount && $obj->rc_namespace >= 0 ) {
+ if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) {
+ $watcherCache[$obj->rc_namespace][$obj->rc_title] =
+ MediaWikiServices::getInstance()->getWatchedItemStore()->countWatchers(
+ new TitleValue( (int)$obj->rc_namespace, $obj->rc_title )
+ );
+ }
+ $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title];
+ }
+
+ $changeLine = $list->recentChangesLine( $rc, !empty( $obj->wl_user ), $counter );
+ if ( $changeLine !== false ) {
+ $rclistOutput .= $changeLine;
+ --$limit;
+ }
+ }
+ $rclistOutput .= $list->endRecentChangesList();
+
+ if ( $rows->numRows() === 0 ) {
+ $this->outputNoResults();
+ if ( !$this->including() ) {
+ $this->getOutput()->setStatusCode( 404 );
+ }
+ } else {
+ $this->getOutput()->addHTML( $rclistOutput );
+ }
+ }
+
+ /**
+ * Set the text to be displayed above the changes
+ *
+ * @param FormOptions $opts
+ * @param int $numRows Number of rows in the result to show after this header
+ */
+ public function doHeader( $opts, $numRows ) {
+ $this->setTopText( $opts );
+
+ $defaults = $opts->getAllValues();
+ $nondefaults = $opts->getChangedValues();
+
+ $panel = [];
+ if ( !$this->isStructuredFilterUiEnabled() ) {
+ $panel[] = $this->makeLegend();
+ }
+ $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows );
+ $panel[] = '<hr />';
+
+ $extraOpts = $this->getExtraOptions( $opts );
+ $extraOptsCount = count( $extraOpts );
+ $count = 0;
+ $submit = ' ' . Xml::submitButton( $this->msg( 'recentchanges-submit' )->text() );
+
+ $out = Xml::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] );
+ foreach ( $extraOpts as $name => $optionRow ) {
+ # Add submit button to the last row only
+ ++$count;
+ $addSubmit = ( $count === $extraOptsCount ) ? $submit : '';
+
+ $out .= Xml::openElement( 'tr', [ 'class' => $name . 'Form' ] );
+ if ( is_array( $optionRow ) ) {
+ $out .= Xml::tags(
+ 'td',
+ [ 'class' => 'mw-label mw-' . $name . '-label' ],
+ $optionRow[0]
+ );
+ $out .= Xml::tags(
+ 'td',
+ [ 'class' => 'mw-input' ],
+ $optionRow[1] . $addSubmit
+ );
+ } else {
+ $out .= Xml::tags(
+ 'td',
+ [ 'class' => 'mw-input', 'colspan' => 2 ],
+ $optionRow . $addSubmit
+ );
+ }
+ $out .= Xml::closeElement( 'tr' );
+ }
+ $out .= Xml::closeElement( 'table' );
+
+ $unconsumed = $opts->getUnconsumedValues();
+ foreach ( $unconsumed as $key => $value ) {
+ $out .= Html::hidden( $key, $value );
+ }
+
+ $t = $this->getPageTitle();
+ $out .= Html::hidden( 'title', $t->getPrefixedText() );
+ $form = Xml::tags( 'form', [ 'action' => wfScript() ], $out );
+ $panel[] = $form;
+ $panelString = implode( "\n", $panel );
+
+ $rcoptions = Xml::fieldset(
+ $this->msg( 'recentchanges-legend' )->text(),
+ $panelString,
+ [ 'class' => 'rcoptions cloptions' ]
+ );
+
+ // Insert a placeholder for RCFilters
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $rcfilterContainer = Html::element(
+ 'div',
+ [ 'class' => 'rcfilters-container' ]
+ );
+
+ $loadingContainer = Html::rawElement(
+ 'div',
+ [ 'class' => 'rcfilters-spinner' ],
+ Html::element(
+ 'div',
+ [ 'class' => 'rcfilters-spinner-bounce' ]
+ )
+ );
+
+ // Wrap both with rcfilters-head
+ $this->getOutput()->addHTML(
+ Html::rawElement(
+ 'div',
+ [ 'class' => 'rcfilters-head' ],
+ $rcfilterContainer . $rcoptions
+ )
+ );
+
+ // Add spinner
+ $this->getOutput()->addHTML( $loadingContainer );
+ } else {
+ $this->getOutput()->addHTML( $rcoptions );
+ }
+
+ $this->setBottomText( $opts );
+ }
+
+ /**
+ * Send the text to be displayed above the options
+ *
+ * @param FormOptions $opts Unused
+ */
+ function setTopText( FormOptions $opts ) {
+ global $wgContLang;
+
+ $message = $this->msg( 'recentchangestext' )->inContentLanguage();
+ if ( !$message->isDisabled() ) {
+ // Parse the message in this weird ugly way to preserve the ability to include interlanguage
+ // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use
+ // $message->parse() instead. This code is copied from Message::parseText().
+ $parserOutput = MessageCache::singleton()->parse(
+ $message->plain(),
+ $this->getPageTitle(),
+ /*linestart*/true,
+ // Message class sets the interface flag to false when parsing in a language different than
+ // user language, and this is wiki content language
+ /*interface*/false,
+ $wgContLang
+ );
+ $content = $parserOutput->getText();
+ // Add only metadata here (including the language links), text is added below
+ $this->getOutput()->addParserOutputMetadata( $parserOutput );
+
+ $langAttributes = [
+ 'lang' => $wgContLang->getHtmlCode(),
+ 'dir' => $wgContLang->getDir(),
+ ];
+
+ $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ];
+
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $contentTitle = Html::rawElement( 'div',
+ [ 'class' => 'mw-recentchanges-toplinks-title' ],
+ $this->msg( 'rcfilters-other-review-tools' )->parse()
+ );
+ $contentWrapper = Html::rawElement( 'div',
+ array_merge( [ 'class' => 'mw-collapsible-content' ], $langAttributes ),
+ $content
+ );
+ $content = $contentTitle . $contentWrapper;
+ } else {
+ // Language direction should be on the top div only
+ // if the title is not there. If it is there, it's
+ // interface direction, and the language/dir attributes
+ // should be on the content itself
+ $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes );
+ }
+
+ $this->getOutput()->addHTML(
+ Html::rawElement( 'div', $topLinksAttributes, $content )
+ );
+ }
+ }
+
+ /**
+ * Get options to be displayed in a form
+ *
+ * @param FormOptions $opts
+ * @return array
+ */
+ function getExtraOptions( $opts ) {
+ $opts->consumeValues( [
+ 'namespace', 'invert', 'associated', 'tagfilter', 'categories', 'categories_any'
+ ] );
+
+ $extraOpts = [];
+ $extraOpts['namespace'] = $this->namespaceFilterForm( $opts );
+
+ if ( $this->getConfig()->get( 'AllowCategorizedRecentChanges' ) ) {
+ $extraOpts['category'] = $this->categoryFilterForm( $opts );
+ }
+
+ $tagFilter = ChangeTags::buildTagFilterSelector(
+ $opts['tagfilter'], false, $this->getContext() );
+ if ( count( $tagFilter ) ) {
+ $extraOpts['tagfilter'] = $tagFilter;
+ }
+
+ // Don't fire the hook for subclasses. (Or should we?)
+ if ( $this->getName() === 'Recentchanges' ) {
+ Hooks::run( 'SpecialRecentChangesPanel', [ &$extraOpts, $opts ] );
+ }
+
+ return $extraOpts;
+ }
+
+ /**
+ * Add page-specific modules.
+ */
+ protected function addModules() {
+ parent::addModules();
+ $out = $this->getOutput();
+ $out->addModules( 'mediawiki.special.recentchanges' );
+ }
+
+ /**
+ * Get last modified date, for client caching
+ * Don't use this if we are using the patrol feature, patrol changes don't
+ * update the timestamp
+ *
+ * @return string|bool
+ */
+ public function checkLastModified() {
+ $dbr = $this->getDB();
+ $lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', false, __METHOD__ );
+
+ return $lastmod;
+ }
+
+ /**
+ * Creates the choose namespace selection
+ *
+ * @param FormOptions $opts
+ * @return string
+ */
+ protected function namespaceFilterForm( FormOptions $opts ) {
+ $nsSelect = Html::namespaceSelector(
+ [ 'selected' => $opts['namespace'], 'all' => '' ],
+ [ 'name' => 'namespace', 'id' => 'namespace' ]
+ );
+ $nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' );
+ $invert = Xml::checkLabel(
+ $this->msg( 'invert' )->text(), 'invert', 'nsinvert',
+ $opts['invert'],
+ [ 'title' => $this->msg( 'tooltip-invert' )->text() ]
+ );
+ $associated = Xml::checkLabel(
+ $this->msg( 'namespace_association' )->text(), 'associated', 'nsassociated',
+ $opts['associated'],
+ [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ]
+ );
+
+ return [ $nsLabel, "$nsSelect $invert $associated" ];
+ }
+
+ /**
+ * Create an input to filter changes by categories
+ *
+ * @param FormOptions $opts
+ * @return array
+ */
+ protected function categoryFilterForm( FormOptions $opts ) {
+ list( $label, $input ) = Xml::inputLabelSep( $this->msg( 'rc_categories' )->text(),
+ 'categories', 'mw-categories', false, $opts['categories'] );
+
+ $input .= ' ' . Xml::checkLabel( $this->msg( 'rc_categories_any' )->text(),
+ 'categories_any', 'mw-categories_any', $opts['categories_any'] );
+
+ return [ $label, $input ];
+ }
+
+ /**
+ * Filter $rows by categories set in $opts
+ *
+ * @param ResultWrapper &$rows Database rows
+ * @param FormOptions $opts
+ */
+ function filterByCategories( &$rows, FormOptions $opts ) {
+ $categories = array_map( 'trim', explode( '|', $opts['categories'] ) );
+
+ if ( !count( $categories ) ) {
+ return;
+ }
+
+ # Filter categories
+ $cats = [];
+ foreach ( $categories as $cat ) {
+ $cat = trim( $cat );
+ if ( $cat == '' ) {
+ continue;
+ }
+ $cats[] = $cat;
+ }
+
+ # Filter articles
+ $articles = [];
+ $a2r = [];
+ $rowsarr = [];
+ foreach ( $rows as $k => $r ) {
+ $nt = Title::makeTitle( $r->rc_namespace, $r->rc_title );
+ $id = $nt->getArticleID();
+ if ( $id == 0 ) {
+ continue; # Page might have been deleted...
+ }
+ if ( !in_array( $id, $articles ) ) {
+ $articles[] = $id;
+ }
+ if ( !isset( $a2r[$id] ) ) {
+ $a2r[$id] = [];
+ }
+ $a2r[$id][] = $k;
+ $rowsarr[$k] = $r;
+ }
+
+ # Shortcut?
+ if ( !count( $articles ) || !count( $cats ) ) {
+ return;
+ }
+
+ # Look up
+ $catFind = new CategoryFinder;
+ $catFind->seed( $articles, $cats, $opts['categories_any'] ? 'OR' : 'AND' );
+ $match = $catFind->run();
+
+ # Filter
+ $newrows = [];
+ foreach ( $match as $id ) {
+ foreach ( $a2r[$id] as $rev ) {
+ $k = $rev;
+ $newrows[$k] = $rowsarr[$k];
+ }
+ }
+ $rows = new FakeResultWrapper( array_values( $newrows ) );
+ }
+
+ /**
+ * Makes change an option link which carries all the other options
+ *
+ * @param string $title Title
+ * @param array $override Options to override
+ * @param array $options Current options
+ * @param bool $active Whether to show the link in bold
+ * @return string
+ */
+ function makeOptionsLink( $title, $override, $options, $active = false ) {
+ $params = $this->convertParamsForLink( $override + $options );
+
+ if ( $active ) {
+ $title = new HtmlArmor( '<strong>' . htmlspecialchars( $title ) . '</strong>' );
+ }
+
+ return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [
+ 'data-params' => json_encode( $override ),
+ 'data-keys' => implode( ',', array_keys( $override ) ),
+ ], $params );
+ }
+
+ /**
+ * Creates the options panel.
+ *
+ * @param array $defaults
+ * @param array $nondefaults
+ * @param int $numRows Number of rows in the result to show after this header
+ * @return string
+ */
+ function optionsPanel( $defaults, $nondefaults, $numRows ) {
+ $options = $nondefaults + $defaults;
+
+ $note = '';
+ $msg = $this->msg( 'rclegend' );
+ if ( !$msg->isDisabled() ) {
+ $note .= '<div class="mw-rclegend">' . $msg->parse() . "</div>\n";
+ }
+
+ $lang = $this->getLanguage();
+ $user = $this->getUser();
+ $config = $this->getConfig();
+ if ( $options['from'] ) {
+ $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' ),
+ [ 'from' => '' ], $nondefaults );
+
+ $noteFromMsg = $this->msg( 'rcnotefrom' )
+ ->numParams( $options['limit'] )
+ ->params(
+ $lang->userTimeAndDate( $options['from'], $user ),
+ $lang->userDate( $options['from'], $user ),
+ $lang->userTime( $options['from'], $user )
+ )
+ ->numParams( $numRows );
+ $note .= Html::rawElement(
+ 'span',
+ [ 'class' => 'rcnotefrom' ],
+ $noteFromMsg->parse()
+ ) .
+ ' ' .
+ Html::rawElement(
+ 'span',
+ [ 'class' => 'rcoptions-listfromreset' ],
+ $this->msg( 'parentheses' )->rawParams( $resetLink )->parse()
+ ) .
+ '<br />';
+ }
+
+ # Sort data for display and make sure it's unique after we've added user data.
+ $linkLimits = $config->get( 'RCLinkLimits' );
+ $linkLimits[] = $options['limit'];
+ sort( $linkLimits );
+ $linkLimits = array_unique( $linkLimits );
+
+ $linkDays = $config->get( 'RCLinkDays' );
+ $linkDays[] = $options['days'];
+ sort( $linkDays );
+ $linkDays = array_unique( $linkDays );
+
+ // limit links
+ $cl = [];
+ foreach ( $linkLimits as $value ) {
+ $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
+ [ 'limit' => $value ], $nondefaults, $value == $options['limit'] );
+ }
+ $cl = $lang->pipeList( $cl );
+
+ // day links, reset 'from' to none
+ $dl = [];
+ foreach ( $linkDays as $value ) {
+ $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
+ [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] );
+ }
+ $dl = $lang->pipeList( $dl );
+
+ $showhide = [ 'show', 'hide' ];
+
+ $links = [];
+
+ $filterGroups = $this->getFilterGroups();
+
+ foreach ( $filterGroups as $groupName => $group ) {
+ if ( !$group->isPerGroupRequestParameter() ) {
+ foreach ( $group->getFilters() as $key => $filter ) {
+ if ( $filter->displaysOnUnstructuredUi( $this ) ) {
+ $msg = $filter->getShowHide();
+ $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
+ // Extensions can define additional filters, but don't need to define the corresponding
+ // messages. If they don't exist, just fall back to 'show' and 'hide'.
+ if ( !$linkMessage->exists() ) {
+ $linkMessage = $this->msg( $showhide[1 - $options[$key]] );
+ }
+
+ $link = $this->makeOptionsLink( $linkMessage->text(),
+ [ $key => 1 - $options[$key] ], $nondefaults );
+
+ $attribs = [
+ 'class' => "$msg rcshowhideoption clshowhideoption",
+ 'data-filter-name' => $filter->getName(),
+ ];
+
+ if ( $filter->isFeatureAvailableOnStructuredUi( $this ) ) {
+ $attribs['data-feature-in-structured-ui'] = true;
+ }
+
+ $links[] = Html::rawElement(
+ 'span',
+ $attribs,
+ $this->msg( $msg )->rawParams( $link )->escaped()
+ );
+ }
+ }
+ }
+ }
+
+ // show from this onward link
+ $timestamp = wfTimestampNow();
+ $now = $lang->userTimeAndDate( $timestamp, $user );
+ $timenow = $lang->userTime( $timestamp, $user );
+ $datenow = $lang->userDate( $timestamp, $user );
+ $pipedLinks = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>';
+
+ $rclinks = '<span class="rclinks">' . $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )
+ ->parse() . '</span>';
+
+ $rclistfrom = '<span class="rclistfrom">' . $this->makeOptionsLink(
+ $this->msg( 'rclistfrom' )->rawParams( $now, $timenow, $datenow )->parse(),
+ [ 'from' => $timestamp ],
+ $nondefaults
+ ) . '</span>';
+
+ return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom";
+ }
+
+ public function isIncludable() {
+ return true;
+ }
+
+ protected function getCacheTTL() {
+ return 60 * 5;
+ }
+
+ function getDefaultLimit() {
+ return $this->getUser()->getIntOption( 'rclimit' );
+ }
+
+ function getDefaultDays() {
+ return floatval( $this->getUser()->getOption( 'rcdays' ) );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRecentchangeslinked.php b/www/wiki/includes/specials/SpecialRecentchangeslinked.php
new file mode 100644
index 00000000..a13af55d
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRecentchangeslinked.php
@@ -0,0 +1,293 @@
+<?php
+/**
+ * Implements Special:Recentchangeslinked
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * This is to display changes made to all articles linked in an article.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRecentChangesLinked extends SpecialRecentChanges {
+ /** @var bool|Title */
+ protected $rclTargetTitle;
+
+ function __construct() {
+ parent::__construct( 'Recentchangeslinked' );
+ }
+
+ public function getDefaultOptions() {
+ $opts = parent::getDefaultOptions();
+ $opts->add( 'target', '' );
+ $opts->add( 'showlinkedto', false );
+
+ return $opts;
+ }
+
+ public function parseParameters( $par, FormOptions $opts ) {
+ $opts['target'] = $par;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function doMainQuery( $tables, $select, $conds, $query_options,
+ $join_conds, FormOptions $opts
+ ) {
+ $target = $opts['target'];
+ $showlinkedto = $opts['showlinkedto'];
+ $limit = $opts['limit'];
+
+ if ( $target === '' ) {
+ return false;
+ }
+ $outputPage = $this->getOutput();
+ $title = Title::newFromText( $target );
+ if ( !$title || $title->isExternal() ) {
+ $outputPage->addHTML( '<div class="errorbox">' . $this->msg( 'allpagesbadtitle' )
+ ->parse() . '</div>' );
+
+ return false;
+ }
+
+ $outputPage->setPageTitle( $this->msg( 'recentchangeslinked-title', $title->getPrefixedText() ) );
+
+ /*
+ * Ordinary links are in the pagelinks table, while transclusions are
+ * in the templatelinks table, categorizations in categorylinks and
+ * image use in imagelinks. We need to somehow combine all these.
+ * Special:Whatlinkshere does this by firing multiple queries and
+ * merging the results, but the code we inherit from our parent class
+ * expects only one result set so we use UNION instead.
+ */
+
+ $dbr = wfGetDB( DB_REPLICA, 'recentchangeslinked' );
+ $id = $title->getArticleID();
+ $ns = $title->getNamespace();
+ $dbkey = $title->getDBkey();
+
+ $tables[] = 'recentchanges';
+ $select = array_merge( RecentChange::selectFields(), $select );
+
+ // left join with watchlist table to highlight watched rows
+ $uid = $this->getUser()->getId();
+ if ( $uid && $this->getUser()->isAllowed( 'viewmywatchlist' ) ) {
+ $tables[] = 'watchlist';
+ $select[] = 'wl_user';
+ $join_conds['watchlist'] = [ 'LEFT JOIN', [
+ 'wl_user' => $uid,
+ 'wl_title=rc_title',
+ 'wl_namespace=rc_namespace'
+ ] ];
+ }
+
+ // JOIN on page, used for 'last revision' filter highlight
+ $tables[] = 'page';
+ $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
+ $select[] = 'page_latest';
+
+ $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $select,
+ $conds,
+ $join_conds,
+ $query_options,
+ $tagFilter
+ );
+
+ if ( $dbr->unionSupportsOrderAndLimit() ) {
+ if ( count( $tagFilter ) > 1 ) {
+ // ChangeTags::modifyDisplayQuery() will have added DISTINCT.
+ // To prevent this from causing query performance problems, we need to add
+ // a GROUP BY, and add rc_id to the ORDER BY.
+ $order = [
+ 'GROUP BY' => 'rc_timestamp, rc_id',
+ 'ORDER BY' => 'rc_timestamp DESC, rc_id DESC'
+ ];
+ } else {
+ $order = [ 'ORDER BY' => 'rc_timestamp DESC' ];
+ }
+ } else {
+ $order = [];
+ }
+
+ if ( !$this->runMainQueryHook( $tables, $select, $conds, $query_options, $join_conds,
+ $opts )
+ ) {
+ return false;
+ }
+
+ if ( $ns == NS_CATEGORY && !$showlinkedto ) {
+ // special handling for categories
+ // XXX: should try to make this less kludgy
+ $link_tables = [ 'categorylinks' ];
+ $showlinkedto = true;
+ } else {
+ // for now, always join on these tables; really should be configurable as in whatlinkshere
+ $link_tables = [ 'pagelinks', 'templatelinks' ];
+ // imagelinks only contains links to pages in NS_FILE
+ if ( $ns == NS_FILE || !$showlinkedto ) {
+ $link_tables[] = 'imagelinks';
+ }
+ }
+
+ if ( $id == 0 && !$showlinkedto ) {
+ return false; // nonexistent pages can't link to any pages
+ }
+
+ // field name prefixes for all the various tables we might want to join with
+ $prefix = [
+ 'pagelinks' => 'pl',
+ 'templatelinks' => 'tl',
+ 'categorylinks' => 'cl',
+ 'imagelinks' => 'il'
+ ];
+
+ $subsql = []; // SELECT statements to combine with UNION
+
+ foreach ( $link_tables as $link_table ) {
+ $pfx = $prefix[$link_table];
+
+ // imagelinks and categorylinks tables have no xx_namespace field,
+ // and have xx_to instead of xx_title
+ if ( $link_table == 'imagelinks' ) {
+ $link_ns = NS_FILE;
+ } elseif ( $link_table == 'categorylinks' ) {
+ $link_ns = NS_CATEGORY;
+ } else {
+ $link_ns = 0;
+ }
+
+ if ( $showlinkedto ) {
+ // find changes to pages linking to this page
+ if ( $link_ns ) {
+ if ( $ns != $link_ns ) {
+ continue;
+ } // should never happen, but check anyway
+ $subconds = [ "{$pfx}_to" => $dbkey ];
+ } else {
+ $subconds = [ "{$pfx}_namespace" => $ns, "{$pfx}_title" => $dbkey ];
+ }
+ $subjoin = "rc_cur_id = {$pfx}_from";
+ } else {
+ // find changes to pages linked from this page
+ $subconds = [ "{$pfx}_from" => $id ];
+ if ( $link_table == 'imagelinks' || $link_table == 'categorylinks' ) {
+ $subconds["rc_namespace"] = $link_ns;
+ $subjoin = "rc_title = {$pfx}_to";
+ } else {
+ $subjoin = [ "rc_namespace = {$pfx}_namespace", "rc_title = {$pfx}_title" ];
+ }
+ }
+
+ $query = $dbr->selectSQLText(
+ array_merge( $tables, [ $link_table ] ),
+ $select,
+ $conds + $subconds,
+ __METHOD__,
+ $order + $query_options,
+ $join_conds + [ $link_table => [ 'INNER JOIN', $subjoin ] ]
+ );
+
+ if ( $dbr->unionSupportsOrderAndLimit() ) {
+ $query = $dbr->limitResult( $query, $limit );
+ }
+
+ $subsql[] = $query;
+ }
+
+ if ( count( $subsql ) == 0 ) {
+ return false; // should never happen
+ }
+ if ( count( $subsql ) == 1 && $dbr->unionSupportsOrderAndLimit() ) {
+ $sql = $subsql[0];
+ } else {
+ // need to resort and relimit after union
+ $sql = $dbr->unionQueries( $subsql, false ) . ' ORDER BY rc_timestamp DESC';
+ $sql = $dbr->limitResult( $sql, $limit, false );
+ }
+
+ $res = $dbr->query( $sql, __METHOD__ );
+
+ if ( $res->numRows() == 0 ) {
+ $this->mResultEmpty = true;
+ }
+
+ return $res;
+ }
+
+ function setTopText( FormOptions $opts ) {
+ $target = $this->getTargetTitle();
+ if ( $target ) {
+ $this->getOutput()->addBacklinkSubtitle( $target );
+ $this->getSkin()->setRelevantTitle( $target );
+ }
+ }
+
+ /**
+ * Get options to be displayed in a form
+ *
+ * @param FormOptions $opts
+ * @return array
+ */
+ function getExtraOptions( $opts ) {
+ $extraOpts = parent::getExtraOptions( $opts );
+
+ $opts->consumeValues( [ 'showlinkedto', 'target' ] );
+
+ $extraOpts['target'] = [ $this->msg( 'recentchangeslinked-page' )->escaped(),
+ Xml::input( 'target', 40, str_replace( '_', ' ', $opts['target'] ) ) .
+ Xml::check( 'showlinkedto', $opts['showlinkedto'], [ 'id' => 'showlinkedto' ] ) . ' ' .
+ Xml::label( $this->msg( 'recentchangeslinked-to' )->text(), 'showlinkedto' ) ];
+
+ $this->addHelpLink( 'Help:Related changes' );
+ return $extraOpts;
+ }
+
+ /**
+ * @return Title
+ */
+ function getTargetTitle() {
+ if ( $this->rclTargetTitle === null ) {
+ $opts = $this->getOptions();
+ if ( isset( $opts['target'] ) && $opts['target'] !== '' ) {
+ $this->rclTargetTitle = Title::newFromText( $opts['target'] );
+ } else {
+ $this->rclTargetTitle = false;
+ }
+ }
+
+ return $this->rclTargetTitle;
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRedirect.php b/www/wiki/includes/specials/SpecialRedirect.php
new file mode 100644
index 00000000..a3635ebe
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRedirect.php
@@ -0,0 +1,327 @@
+<?php
+/**
+ * Implements Special:Redirect
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that redirects to: the user for a numeric user id,
+ * the file for a given filename, or the page for a given revision id.
+ *
+ * @ingroup SpecialPage
+ * @since 1.22
+ */
+class SpecialRedirect extends FormSpecialPage {
+
+ /**
+ * The type of the redirect (user/file/revision)
+ *
+ * Example value: `'user'`
+ *
+ * @var string $mType
+ */
+ protected $mType;
+
+ /**
+ * The identifier/value for the redirect (which id, which file)
+ *
+ * Example value: `'42'`
+ *
+ * @var string $mValue
+ */
+ protected $mValue;
+
+ function __construct() {
+ parent::__construct( 'Redirect' );
+ $this->mType = null;
+ $this->mValue = null;
+ }
+
+ /**
+ * Set $mType and $mValue based on parsed value of $subpage.
+ * @param string $subpage
+ */
+ function setParameter( $subpage ) {
+ // parse $subpage to pull out the parts
+ $parts = explode( '/', $subpage, 2 );
+ $this->mType = count( $parts ) > 0 ? $parts[0] : null;
+ $this->mValue = count( $parts ) > 1 ? $parts[1] : null;
+ }
+
+ /**
+ * Handle Special:Redirect/user/xxxx (by redirecting to User:YYYY)
+ *
+ * @return string|null Url to redirect to, or null if $mValue is invalid.
+ */
+ function dispatchUser() {
+ if ( !ctype_digit( $this->mValue ) ) {
+ return null;
+ }
+ $user = User::newFromId( (int)$this->mValue );
+ $username = $user->getName(); // load User as side-effect
+ if ( $user->isAnon() ) {
+ return null;
+ }
+ $userpage = Title::makeTitle( NS_USER, $username );
+
+ return $userpage->getFullURL( '', false, PROTO_CURRENT );
+ }
+
+ /**
+ * Handle Special:Redirect/file/xxxx
+ *
+ * @return string|null Url to redirect to, or null if $mValue is not found.
+ */
+ function dispatchFile() {
+ $title = Title::makeTitleSafe( NS_FILE, $this->mValue );
+
+ if ( !$title instanceof Title ) {
+ return null;
+ }
+ $file = wfFindFile( $title );
+
+ if ( !$file || !$file->exists() ) {
+ return null;
+ }
+ // Default behavior: Use the direct link to the file.
+ $url = $file->getUrl();
+ $request = $this->getRequest();
+ $width = $request->getInt( 'width', -1 );
+ $height = $request->getInt( 'height', -1 );
+
+ // If a width is requested...
+ if ( $width != -1 ) {
+ $mto = $file->transform( [ 'width' => $width, 'height' => $height ] );
+ // ... and we can
+ if ( $mto && !$mto->isError() ) {
+ // ... change the URL to point to a thumbnail.
+ $url = $mto->getUrl();
+ }
+ }
+
+ return $url;
+ }
+
+ /**
+ * Handle Special:Redirect/revision/xxx
+ * (by redirecting to index.php?oldid=xxx)
+ *
+ * @return string|null Url to redirect to, or null if $mValue is invalid.
+ */
+ function dispatchRevision() {
+ $oldid = $this->mValue;
+ if ( !ctype_digit( $oldid ) ) {
+ return null;
+ }
+ $oldid = (int)$oldid;
+ if ( $oldid === 0 ) {
+ return null;
+ }
+
+ return wfAppendQuery( wfScript( 'index' ), [
+ 'oldid' => $oldid
+ ] );
+ }
+
+ /**
+ * Handle Special:Redirect/page/xxx (by redirecting to index.php?curid=xxx)
+ *
+ * @return string|null Url to redirect to, or null if $mValue is invalid.
+ */
+ function dispatchPage() {
+ $curid = $this->mValue;
+ if ( !ctype_digit( $curid ) ) {
+ return null;
+ }
+ $curid = (int)$curid;
+ if ( $curid === 0 ) {
+ return null;
+ }
+
+ return wfAppendQuery( wfScript( 'index' ), [
+ 'curid' => $curid
+ ] );
+ }
+
+ /**
+ * Handle Special:Redirect/logid/xxx
+ * (by redirecting to index.php?title=Special:Log&logid=xxx)
+ *
+ * @since 1.27
+ * @return string|null Url to redirect to, or null if $mValue is invalid.
+ */
+ function dispatchLog() {
+ $logid = $this->mValue;
+ if ( !ctype_digit( $logid ) ) {
+ return null;
+ }
+ $logid = (int)$logid;
+ if ( $logid === 0 ) {
+ return null;
+ }
+
+ $query = [ 'title' => 'Special:Log', 'logid' => $logid ];
+ return wfAppendQuery( wfScript( 'index' ), $query );
+ }
+
+ /**
+ * Use appropriate dispatch* method to obtain a redirection URL,
+ * and either: redirect, set a 404 error code and error message,
+ * or do nothing (if $mValue wasn't set) allowing the form to be
+ * displayed.
+ *
+ * @return bool True if a redirect was successfully handled.
+ */
+ function dispatch() {
+ // the various namespaces supported by Special:Redirect
+ switch ( $this->mType ) {
+ case 'user':
+ $url = $this->dispatchUser();
+ break;
+ case 'file':
+ $url = $this->dispatchFile();
+ break;
+ case 'revision':
+ $url = $this->dispatchRevision();
+ break;
+ case 'page':
+ $url = $this->dispatchPage();
+ break;
+ case 'logid':
+ $url = $this->dispatchLog();
+ break;
+ default:
+ $url = null;
+ break;
+ }
+ if ( $url ) {
+ $this->getOutput()->redirect( $url );
+
+ return true;
+ }
+ if ( !is_null( $this->mValue ) ) {
+ $this->getOutput()->setStatusCode( 404 );
+ // Message: redirect-not-exists
+ $msg = $this->getMessagePrefix() . '-not-exists';
+
+ return Status::newFatal( $msg );
+ }
+
+ return false;
+ }
+
+ protected function getFormFields() {
+ $mp = $this->getMessagePrefix();
+ $ns = [
+ // subpage => message
+ // Messages: redirect-user, redirect-page, redirect-revision,
+ // redirect-file, redirect-logid
+ 'user' => $mp . '-user',
+ 'page' => $mp . '-page',
+ 'revision' => $mp . '-revision',
+ 'file' => $mp . '-file',
+ 'logid' => $mp . '-logid',
+ ];
+ $a = [];
+ $a['type'] = [
+ 'type' => 'select',
+ 'label-message' => $mp . '-lookup', // Message: redirect-lookup
+ 'options' => [],
+ 'default' => current( array_keys( $ns ) ),
+ ];
+ foreach ( $ns as $n => $m ) {
+ $m = $this->msg( $m )->text();
+ $a['type']['options'][$m] = $n;
+ }
+ $a['value'] = [
+ 'type' => 'text',
+ 'label-message' => $mp . '-value' // Message: redirect-value
+ ];
+ // set the defaults according to the parsed subpage path
+ if ( !empty( $this->mType ) ) {
+ $a['type']['default'] = $this->mType;
+ }
+ if ( !empty( $this->mValue ) ) {
+ $a['value']['default'] = $this->mValue;
+ }
+
+ return $a;
+ }
+
+ public function onSubmit( array $data ) {
+ if ( !empty( $data['type'] ) && !empty( $data['value'] ) ) {
+ $this->setParameter( $data['type'] . '/' . $data['value'] );
+ }
+
+ /* if this returns false, will show the form */
+ return $this->dispatch();
+ }
+
+ public function onSuccess() {
+ /* do nothing, we redirect in $this->dispatch if successful. */
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ /* display summary at top of page */
+ $this->outputHeader();
+ // tweak label on submit button
+ // Message: redirect-submit
+ $form->setSubmitTextMsg( $this->getMessagePrefix() . '-submit' );
+ /* submit form every time */
+ $form->setMethod( 'get' );
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept.
+ *
+ * @return string[] subpages
+ */
+ protected function getSubpagesForPrefixSearch() {
+ return [
+ 'file',
+ 'page',
+ 'revision',
+ 'user',
+ 'logid',
+ ];
+ }
+
+ /**
+ * @return bool
+ */
+ public function requiresWrite() {
+ return false;
+ }
+
+ /**
+ * @return bool
+ */
+ public function requiresUnblock() {
+ return false;
+ }
+
+ protected function getGroupName() {
+ return 'redirects';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRemoveCredentials.php b/www/wiki/includes/specials/SpecialRemoveCredentials.php
new file mode 100644
index 00000000..4efec035
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRemoveCredentials.php
@@ -0,0 +1,26 @@
+<?php
+
+use MediaWiki\Auth\AuthManager;
+
+/**
+ * Special change to remove credentials (such as a two-factor token).
+ */
+class SpecialRemoveCredentials extends SpecialChangeCredentials {
+ protected static $allowedActions = [ AuthManager::ACTION_REMOVE ];
+
+ protected static $messagePrefix = 'removecredentials';
+
+ protected static $loadUserData = false;
+
+ public function __construct() {
+ parent::__construct( 'RemoveCredentials' );
+ }
+
+ protected function getDefaultAction( $subPage ) {
+ return AuthManager::ACTION_REMOVE;
+ }
+
+ protected function getRequestBlacklist() {
+ return $this->getConfig()->get( 'RemoveCredentialsBlacklist' );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialResetTokens.php b/www/wiki/includes/specials/SpecialResetTokens.php
new file mode 100644
index 00000000..3e896863
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialResetTokens.php
@@ -0,0 +1,156 @@
+<?php
+/**
+ * Implements Special:ResetTokens
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Let users reset tokens like the watchlist token.
+ *
+ * @ingroup SpecialPage
+ * @deprecated since 1.26
+ */
+class SpecialResetTokens extends FormSpecialPage {
+ private $tokensList;
+
+ public function __construct() {
+ parent::__construct( 'ResetTokens' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * Returns the token information list for this page after running
+ * the hook and filtering out disabled preferences.
+ *
+ * @return array
+ */
+ protected function getTokensList() {
+ if ( !isset( $this->tokensList ) ) {
+ $tokens = [
+ [ 'preference' => 'watchlisttoken', 'label-message' => 'resettokens-watchlist-token' ],
+ ];
+ Hooks::run( 'SpecialResetTokensTokens', [ &$tokens ] );
+
+ $hiddenPrefs = $this->getConfig()->get( 'HiddenPrefs' );
+ $tokens = array_filter( $tokens, function ( $tok ) use ( $hiddenPrefs ) {
+ return !in_array( $tok['preference'], $hiddenPrefs );
+ } );
+
+ $this->tokensList = $tokens;
+ }
+
+ return $this->tokensList;
+ }
+
+ public function execute( $par ) {
+ // This is a preferences page, so no user JS for y'all.
+ $this->getOutput()->disallowUserJs();
+ $this->requireLogin();
+
+ parent::execute( $par );
+
+ $this->getOutput()->addReturnTo( SpecialPage::getTitleFor( 'Preferences' ) );
+ }
+
+ public function onSuccess() {
+ $this->getOutput()->wrapWikiMsg(
+ "<div class='successbox'>\n$1\n</div>",
+ 'resettokens-done'
+ );
+ }
+
+ /**
+ * Display appropriate message if there's nothing to do.
+ * The submit button is also suppressed in this case (see alterForm()).
+ * @return array
+ */
+ protected function getFormFields() {
+ $user = $this->getUser();
+ $tokens = $this->getTokensList();
+
+ if ( $tokens ) {
+ $tokensForForm = [];
+ foreach ( $tokens as $tok ) {
+ $label = $this->msg( 'resettokens-token-label' )
+ ->rawParams( $this->msg( $tok['label-message'] )->parse() )
+ ->params( $user->getTokenFromOption( $tok['preference'] ) )
+ ->escaped();
+ $tokensForForm[$label] = $tok['preference'];
+ }
+
+ $desc = [
+ 'label-message' => 'resettokens-tokens',
+ 'type' => 'multiselect',
+ 'options' => $tokensForForm,
+ ];
+ } else {
+ $desc = [
+ 'label-message' => 'resettokens-no-tokens',
+ 'type' => 'info',
+ ];
+ }
+
+ return [
+ 'tokens' => $desc,
+ ];
+ }
+
+ /**
+ * Suppress the submit button if there's nothing to do;
+ * provide additional message on it otherwise.
+ * @param HTMLForm $form
+ */
+ protected function alterForm( HTMLForm $form ) {
+ if ( $this->getTokensList() ) {
+ $form->setSubmitTextMsg( 'resettokens-resetbutton' );
+ } else {
+ $form->suppressDefaultSubmit();
+ }
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ public function onSubmit( array $formData ) {
+ if ( $formData['tokens'] ) {
+ $user = $this->getUser();
+ foreach ( $formData['tokens'] as $tokenPref ) {
+ $user->resetTokenFromOption( $tokenPref );
+ }
+ $user->saveSettings();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+
+ public function isListed() {
+ return (bool)$this->getTokensList();
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRevisiondelete.php b/www/wiki/includes/specials/SpecialRevisiondelete.php
new file mode 100644
index 00000000..e1d4dd1b
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRevisiondelete.php
@@ -0,0 +1,683 @@
+<?php
+/**
+ * Implements Special:Revisiondelete
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page allowing users with the appropriate permissions to view
+ * and hide revisions. Log items can also be hidden.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRevisionDelete extends UnlistedSpecialPage {
+ /** @var bool Was the DB modified in this request */
+ protected $wasSaved = false;
+
+ /** @var bool True if the submit button was clicked, and the form was posted */
+ private $submitClicked;
+
+ /** @var array Target ID list */
+ private $ids;
+
+ /** @var string Archive name, for reviewing deleted files */
+ private $archiveName;
+
+ /** @var string Edit token for securing image views against XSS */
+ private $token;
+
+ /** @var Title Title object for target parameter */
+ private $targetObj;
+
+ /** @var string Deletion type, may be revision, archive, oldimage, filearchive, logging. */
+ private $typeName;
+
+ /** @var array Array of checkbox specs (message, name, deletion bits) */
+ private $checks;
+
+ /** @var array UI Labels about the current type */
+ private $typeLabels;
+
+ /** @var RevDelList RevDelList object, storing the list of items to be deleted/undeleted */
+ private $revDelList;
+
+ /** @var bool Whether user is allowed to perform the action */
+ private $mIsAllowed;
+
+ /** @var string */
+ private $otherReason;
+
+ /**
+ * UI labels for each type.
+ */
+ private static $UILabels = [
+ 'revision' => [
+ 'check-label' => 'revdelete-hide-text',
+ 'success' => 'revdelete-success',
+ 'failure' => 'revdelete-failure',
+ 'text' => 'revdelete-text-text',
+ 'selected' => 'revdelete-selected-text',
+ ],
+ 'archive' => [
+ 'check-label' => 'revdelete-hide-text',
+ 'success' => 'revdelete-success',
+ 'failure' => 'revdelete-failure',
+ 'text' => 'revdelete-text-text',
+ 'selected' => 'revdelete-selected-text',
+ ],
+ 'oldimage' => [
+ 'check-label' => 'revdelete-hide-image',
+ 'success' => 'revdelete-success',
+ 'failure' => 'revdelete-failure',
+ 'text' => 'revdelete-text-file',
+ 'selected' => 'revdelete-selected-file',
+ ],
+ 'filearchive' => [
+ 'check-label' => 'revdelete-hide-image',
+ 'success' => 'revdelete-success',
+ 'failure' => 'revdelete-failure',
+ 'text' => 'revdelete-text-file',
+ 'selected' => 'revdelete-selected-file',
+ ],
+ 'logging' => [
+ 'check-label' => 'revdelete-hide-name',
+ 'success' => 'logdelete-success',
+ 'failure' => 'logdelete-failure',
+ 'text' => 'logdelete-text',
+ 'selected' => 'logdelete-selected',
+ ],
+ ];
+
+ public function __construct() {
+ parent::__construct( 'Revisiondelete', 'deleterevision' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function execute( $par ) {
+ $this->useTransactionalTimeLimit();
+
+ $this->checkPermissions();
+ $this->checkReadOnly();
+
+ $output = $this->getOutput();
+ $user = $this->getUser();
+
+ // Check blocks
+ if ( $user->isBlocked() ) {
+ throw new UserBlockedError( $user->getBlock() );
+ }
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $request = $this->getRequest();
+ $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' );
+ # Handle our many different possible input types.
+ $ids = $request->getVal( 'ids' );
+ if ( !is_null( $ids ) ) {
+ # Allow CSV, for backwards compatibility, or a single ID for show/hide links
+ $this->ids = explode( ',', $ids );
+ } else {
+ # Array input
+ $this->ids = array_keys( $request->getArray( 'ids', [] ) );
+ }
+ // $this->ids = array_map( 'intval', $this->ids );
+ $this->ids = array_unique( array_filter( $this->ids ) );
+
+ $this->typeName = $request->getVal( 'type' );
+ $this->targetObj = Title::newFromText( $request->getText( 'target' ) );
+
+ # For reviewing deleted files...
+ $this->archiveName = $request->getVal( 'file' );
+ $this->token = $request->getVal( 'token' );
+ if ( $this->archiveName && $this->targetObj ) {
+ $this->tryShowFile( $this->archiveName );
+
+ return;
+ }
+
+ $this->typeName = RevisionDeleter::getCanonicalTypeName( $this->typeName );
+
+ # No targets?
+ if ( !$this->typeName || count( $this->ids ) == 0 ) {
+ throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' );
+ }
+
+ # Allow the list type to adjust the passed target
+ $this->targetObj = RevisionDeleter::suggestTarget(
+ $this->typeName,
+ $this->targetObj,
+ $this->ids
+ );
+
+ # We need a target page!
+ if ( $this->targetObj === null ) {
+ $output->addWikiMsg( 'undelete-header' );
+
+ return;
+ }
+
+ $this->typeLabels = self::$UILabels[$this->typeName];
+ $list = $this->getList();
+ $list->reset();
+ $this->mIsAllowed = $user->isAllowed( RevisionDeleter::getRestriction( $this->typeName ) );
+ $canViewSuppressedOnly = $this->getUser()->isAllowed( 'viewsuppressed' ) &&
+ !$this->getUser()->isAllowed( 'suppressrevision' );
+ $pageIsSuppressed = $list->areAnySuppressed();
+ $this->mIsAllowed = $this->mIsAllowed && !( $canViewSuppressedOnly && $pageIsSuppressed );
+
+ $this->otherReason = $request->getVal( 'wpReason' );
+ # Give a link to the logs/hist for this page
+ $this->showConvenienceLinks();
+
+ # Initialise checkboxes
+ $this->checks = [
+ # Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name
+ [ $this->typeLabels['check-label'], 'wpHidePrimary',
+ RevisionDeleter::getRevdelConstant( $this->typeName )
+ ],
+ [ 'revdelete-hide-comment', 'wpHideComment', Revision::DELETED_COMMENT ],
+ [ 'revdelete-hide-user', 'wpHideUser', Revision::DELETED_USER ]
+ ];
+ if ( $user->isAllowed( 'suppressrevision' ) ) {
+ $this->checks[] = [ 'revdelete-hide-restricted',
+ 'wpHideRestricted', Revision::DELETED_RESTRICTED ];
+ }
+
+ # Either submit or create our form
+ if ( $this->mIsAllowed && $this->submitClicked ) {
+ $this->submit( $request );
+ } else {
+ $this->showForm();
+ }
+
+ if ( $user->isAllowed( 'deletedhistory' ) ) {
+ $qc = $this->getLogQueryCond();
+ # Show relevant lines from the deletion log
+ $deleteLogPage = new LogPage( 'delete' );
+ $output->addHTML( "<h2>" . $deleteLogPage->getName()->escaped() . "</h2>\n" );
+ LogEventsList::showLogExtract(
+ $output,
+ 'delete',
+ $this->targetObj,
+ '', /* user */
+ [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ]
+ );
+ }
+ # Show relevant lines from the suppression log
+ if ( $user->isAllowed( 'suppressionlog' ) ) {
+ $suppressLogPage = new LogPage( 'suppress' );
+ $output->addHTML( "<h2>" . $suppressLogPage->getName()->escaped() . "</h2>\n" );
+ LogEventsList::showLogExtract(
+ $output,
+ 'suppress',
+ $this->targetObj,
+ '',
+ [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ]
+ );
+ }
+ }
+
+ /**
+ * Show some useful links in the subtitle
+ */
+ protected function showConvenienceLinks() {
+ $linkRenderer = $this->getLinkRenderer();
+ # Give a link to the logs/hist for this page
+ if ( $this->targetObj ) {
+ // Also set header tabs to be for the target.
+ $this->getSkin()->setRelevantTitle( $this->targetObj );
+
+ $links = [];
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Log' ),
+ $this->msg( 'viewpagelogs' )->text(),
+ [],
+ [ 'page' => $this->targetObj->getPrefixedText() ]
+ );
+ if ( !$this->targetObj->isSpecialPage() ) {
+ # Give a link to the page history
+ $links[] = $linkRenderer->makeKnownLink(
+ $this->targetObj,
+ $this->msg( 'pagehist' )->text(),
+ [],
+ [ 'action' => 'history' ]
+ );
+ # Link to deleted edits
+ if ( $this->getUser()->isAllowed( 'undelete' ) ) {
+ $undelete = SpecialPage::getTitleFor( 'Undelete' );
+ $links[] = $linkRenderer->makeKnownLink(
+ $undelete,
+ $this->msg( 'deletedhist' )->text(),
+ [],
+ [ 'target' => $this->targetObj->getPrefixedDBkey() ]
+ );
+ }
+ }
+ # Logs themselves don't have histories or archived revisions
+ $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) );
+ }
+ }
+
+ /**
+ * Get the condition used for fetching log snippets
+ * @return array
+ */
+ protected function getLogQueryCond() {
+ $conds = [];
+ // Revision delete logs for these item
+ $conds['log_type'] = [ 'delete', 'suppress' ];
+ $conds['log_action'] = $this->getList()->getLogAction();
+ $conds['ls_field'] = RevisionDeleter::getRelationType( $this->typeName );
+ $conds['ls_value'] = $this->ids;
+
+ return $conds;
+ }
+
+ /**
+ * Show a deleted file version requested by the visitor.
+ * @todo Mostly copied from Special:Undelete. Refactor.
+ * @param string $archiveName
+ * @throws MWException
+ * @throws PermissionsError
+ */
+ protected function tryShowFile( $archiveName ) {
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ $oimage = $repo->newFromArchiveName( $this->targetObj, $archiveName );
+ $oimage->load();
+ // Check if user is allowed to see this file
+ if ( !$oimage->exists() ) {
+ $this->getOutput()->addWikiMsg( 'revdelete-no-file' );
+
+ return;
+ }
+ $user = $this->getUser();
+ if ( !$oimage->userCan( File::DELETED_FILE, $user ) ) {
+ if ( $oimage->isDeleted( File::DELETED_RESTRICTED ) ) {
+ throw new PermissionsError( 'suppressrevision' );
+ } else {
+ throw new PermissionsError( 'deletedtext' );
+ }
+ }
+ if ( !$user->matchEditToken( $this->token, $archiveName ) ) {
+ $lang = $this->getLanguage();
+ $this->getOutput()->addWikiMsg( 'revdelete-show-file-confirm',
+ $this->targetObj->getText(),
+ $lang->userDate( $oimage->getTimestamp(), $user ),
+ $lang->userTime( $oimage->getTimestamp(), $user ) );
+ $this->getOutput()->addHTML(
+ Xml::openElement( 'form', [
+ 'method' => 'POST',
+ 'action' => $this->getPageTitle()->getLocalURL( [
+ 'target' => $this->targetObj->getPrefixedDBkey(),
+ 'file' => $archiveName,
+ 'token' => $user->getEditToken( $archiveName ),
+ ] )
+ ]
+ ) .
+ Xml::submitButton( $this->msg( 'revdelete-show-file-submit' )->text() ) .
+ '</form>'
+ );
+
+ return;
+ }
+ $this->getOutput()->disable();
+ # We mustn't allow the output to be CDN cached, otherwise
+ # if an admin previews a deleted image, and it's cached, then
+ # a user without appropriate permissions can toddle off and
+ # nab the image, and CDN will serve it
+ $this->getRequest()->response()->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
+ $this->getRequest()->response()->header(
+ 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate'
+ );
+ $this->getRequest()->response()->header( 'Pragma: no-cache' );
+
+ $key = $oimage->getStorageKey();
+ $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key;
+ $repo->streamFile( $path );
+ }
+
+ /**
+ * Get the list object for this request
+ * @return RevDelList
+ */
+ protected function getList() {
+ if ( is_null( $this->revDelList ) ) {
+ $this->revDelList = RevisionDeleter::createList(
+ $this->typeName, $this->getContext(), $this->targetObj, $this->ids
+ );
+ }
+
+ return $this->revDelList;
+ }
+
+ /**
+ * Show a list of items that we will operate on, and show a form with checkboxes
+ * which will allow the user to choose new visibility settings.
+ */
+ protected function showForm() {
+ $userAllowed = true;
+
+ // Messages: revdelete-selected-text, revdelete-selected-file, logdelete-selected
+ $out = $this->getOutput();
+ $out->wrapWikiMsg( "<strong>$1</strong>", [ $this->typeLabels['selected'],
+ $this->getLanguage()->formatNum( count( $this->ids ) ), $this->targetObj->getPrefixedText() ] );
+
+ $this->addHelpLink( 'Help:RevisionDelete' );
+ $out->addHTML( "<ul>" );
+
+ $numRevisions = 0;
+ // Live revisions...
+ $list = $this->getList();
+ // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed
+ for ( $list->reset(); $list->current(); $list->next() ) {
+ // @codingStandardsIgnoreEnd
+ $item = $list->current();
+
+ if ( !$item->canView() ) {
+ if ( !$this->submitClicked ) {
+ throw new PermissionsError( 'suppressrevision' );
+ }
+ $userAllowed = false;
+ }
+
+ $numRevisions++;
+ $out->addHTML( $item->getHTML() );
+ }
+
+ if ( !$numRevisions ) {
+ throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' );
+ }
+
+ $out->addHTML( "</ul>" );
+ // Explanation text
+ $this->addUsageText();
+
+ // Normal sysops can always see what they did, but can't always change it
+ if ( !$userAllowed ) {
+ return;
+ }
+
+ // Show form if the user can submit
+ if ( $this->mIsAllowed ) {
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ $form = Xml::openElement( 'form', [ 'method' => 'post',
+ 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ),
+ 'id' => 'mw-revdel-form-revisions' ] ) .
+ Xml::fieldset( $this->msg( 'revdelete-legend' )->text() ) .
+ $this->buildCheckBoxes() .
+ Xml::openElement( 'table' ) .
+ "<tr>\n" .
+ '<td class="mw-label">' .
+ Xml::label( $this->msg( 'revdelete-log' )->text(), 'wpRevDeleteReasonList' ) .
+ '</td>' .
+ '<td class="mw-input">' .
+ Xml::listDropDown( 'wpRevDeleteReasonList',
+ $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text(),
+ $this->msg( 'revdelete-reasonotherlist' )->inContentLanguage()->text(),
+ $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' ), 'wpReasonDropDown'
+ ) .
+ '</td>' .
+ "</tr><tr>\n" .
+ '<td class="mw-label">' .
+ Xml::label( $this->msg( 'revdelete-otherreason' )->text(), 'wpReason' ) .
+ '</td>' .
+ '<td class="mw-input">' .
+ Xml::input(
+ 'wpReason',
+ 60,
+ $this->otherReason,
+ [ 'id' => 'wpReason', 'maxlength' => 100 ]
+ ) .
+ '</td>' .
+ "</tr><tr>\n" .
+ '<td></td>' .
+ '<td class="mw-submit">' .
+ Xml::submitButton( $this->msg( 'revdelete-submit', $numRevisions )->text(),
+ [ 'name' => 'wpSubmit' ] ) .
+ '</td>' .
+ "</tr>\n" .
+ Xml::closeElement( 'table' ) .
+ Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
+ Html::hidden( 'target', $this->targetObj->getPrefixedText() ) .
+ Html::hidden( 'type', $this->typeName ) .
+ Html::hidden( 'ids', implode( ',', $this->ids ) ) .
+ Xml::closeElement( 'fieldset' ) . "\n" .
+ Xml::closeElement( 'form' ) . "\n";
+ // Show link to edit the dropdown reasons
+ if ( $this->getUser()->isAllowed( 'editinterface' ) ) {
+ $link = $this->getLinkRenderer()->makeKnownLink(
+ $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->getTitle(),
+ $this->msg( 'revdelete-edit-reasonlist' )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ $form .= Xml::tags( 'p', [ 'class' => 'mw-revdel-editreasons' ], $link ) . "\n";
+ }
+ } else {
+ $form = '';
+ }
+ $out->addHTML( $form );
+ }
+
+ /**
+ * Show some introductory text
+ * @todo FIXME: Wikimedia-specific policy text
+ */
+ protected function addUsageText() {
+ // Messages: revdelete-text-text, revdelete-text-file, logdelete-text
+ $this->getOutput()->wrapWikiMsg(
+ "<strong>$1</strong>\n$2", $this->typeLabels['text'],
+ 'revdelete-text-others'
+ );
+
+ if ( $this->getUser()->isAllowed( 'suppressrevision' ) ) {
+ $this->getOutput()->addWikiMsg( 'revdelete-suppress-text' );
+ }
+
+ if ( $this->mIsAllowed ) {
+ $this->getOutput()->addWikiMsg( 'revdelete-confirm' );
+ }
+ }
+
+ /**
+ * @return string HTML
+ */
+ protected function buildCheckBoxes() {
+ $html = '<table>';
+ // If there is just one item, use checkboxes
+ $list = $this->getList();
+ if ( $list->length() == 1 ) {
+ $list->reset();
+ $bitfield = $list->current()->getBits(); // existing field
+
+ if ( $this->submitClicked ) {
+ $bitfield = RevisionDeleter::extractBitfield( $this->extractBitParams(), $bitfield );
+ }
+
+ foreach ( $this->checks as $item ) {
+ // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name,
+ // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted
+ list( $message, $name, $field ) = $item;
+ $innerHTML = Xml::checkLabel(
+ $this->msg( $message )->text(),
+ $name,
+ $name,
+ $bitfield & $field
+ );
+
+ if ( $field == Revision::DELETED_RESTRICTED ) {
+ $innerHTML = "<b>$innerHTML</b>";
+ }
+
+ $line = Xml::tags( 'td', [ 'class' => 'mw-input' ], $innerHTML );
+ $html .= "<tr>$line</tr>\n";
+ }
+ } else {
+ // Otherwise, use tri-state radios
+ $html .= '<tr>';
+ $html .= '<th class="mw-revdel-checkbox">'
+ . $this->msg( 'revdelete-radio-same' )->escaped() . '</th>';
+ $html .= '<th class="mw-revdel-checkbox">'
+ . $this->msg( 'revdelete-radio-unset' )->escaped() . '</th>';
+ $html .= '<th class="mw-revdel-checkbox">'
+ . $this->msg( 'revdelete-radio-set' )->escaped() . '</th>';
+ $html .= "<th></th></tr>\n";
+ foreach ( $this->checks as $item ) {
+ // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name,
+ // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted
+ list( $message, $name, $field ) = $item;
+ // If there are several items, use third state by default...
+ if ( $this->submitClicked ) {
+ $selected = $this->getRequest()->getInt( $name, 0 /* unchecked */ );
+ } else {
+ $selected = -1; // use existing field
+ }
+ $line = '<td class="mw-revdel-checkbox">' . Xml::radio( $name, -1, $selected == -1 ) . '</td>';
+ $line .= '<td class="mw-revdel-checkbox">' . Xml::radio( $name, 0, $selected == 0 ) . '</td>';
+ $line .= '<td class="mw-revdel-checkbox">' . Xml::radio( $name, 1, $selected == 1 ) . '</td>';
+ $label = $this->msg( $message )->escaped();
+ if ( $field == Revision::DELETED_RESTRICTED ) {
+ $label = "<b>$label</b>";
+ }
+ $line .= "<td>$label</td>";
+ $html .= "<tr>$line</tr>\n";
+ }
+ }
+
+ $html .= '</table>';
+
+ return $html;
+ }
+
+ /**
+ * UI entry point for form submission.
+ * @throws PermissionsError
+ * @return bool
+ */
+ protected function submit() {
+ # Check edit token on submission
+ $token = $this->getRequest()->getVal( 'wpEditToken' );
+ if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) {
+ $this->getOutput()->addWikiMsg( 'sessionfailure' );
+
+ return false;
+ }
+ $bitParams = $this->extractBitParams();
+ // from dropdown
+ $listReason = $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' );
+ $comment = $listReason;
+ if ( $comment === 'other' ) {
+ $comment = $this->otherReason;
+ } elseif ( $this->otherReason !== '' ) {
+ // Entry from drop down menu + additional comment
+ $comment .= $this->msg( 'colon-separator' )->inContentLanguage()->text()
+ . $this->otherReason;
+ }
+ # Can the user set this field?
+ if ( $bitParams[Revision::DELETED_RESTRICTED] == 1
+ && !$this->getUser()->isAllowed( 'suppressrevision' )
+ ) {
+ throw new PermissionsError( 'suppressrevision' );
+ }
+ # If the save went through, go to success message...
+ $status = $this->save( $bitParams, $comment );
+ if ( $status->isGood() ) {
+ $this->success();
+
+ return true;
+ } else {
+ # ...otherwise, bounce back to form...
+ $this->failure( $status );
+ }
+
+ return false;
+ }
+
+ /**
+ * Report that the submit operation succeeded
+ */
+ protected function success() {
+ // Messages: revdelete-success, logdelete-success
+ $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) );
+ $this->getOutput()->wrapWikiMsg(
+ "<div class=\"successbox\">\n$1\n</div>",
+ $this->typeLabels['success']
+ );
+ $this->wasSaved = true;
+ $this->revDelList->reloadFromMaster();
+ $this->showForm();
+ }
+
+ /**
+ * Report that the submit operation failed
+ * @param Status $status
+ */
+ protected function failure( $status ) {
+ // Messages: revdelete-failure, logdelete-failure
+ $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) );
+ $this->getOutput()->addWikiText( '<div class="errorbox">' .
+ $status->getWikiText( $this->typeLabels['failure'] ) .
+ '</div>'
+ );
+ $this->showForm();
+ }
+
+ /**
+ * Put together an array that contains -1, 0, or the *_deleted const for each bit
+ *
+ * @return array
+ */
+ protected function extractBitParams() {
+ $bitfield = [];
+ foreach ( $this->checks as $item ) {
+ list( /* message */, $name, $field ) = $item;
+ $val = $this->getRequest()->getInt( $name, 0 /* unchecked */ );
+ if ( $val < -1 || $val > 1 ) {
+ $val = -1; // -1 for existing value
+ }
+ $bitfield[$field] = $val;
+ }
+ if ( !isset( $bitfield[Revision::DELETED_RESTRICTED] ) ) {
+ $bitfield[Revision::DELETED_RESTRICTED] = 0;
+ }
+
+ return $bitfield;
+ }
+
+ /**
+ * Do the write operations. Simple wrapper for RevDel*List::setVisibility().
+ * @param array $bitPars ExtractBitParams() bitfield array
+ * @param string $reason
+ * @return Status
+ */
+ protected function save( array $bitPars, $reason ) {
+ return $this->getList()->setVisibility(
+ [ 'value' => $bitPars, 'comment' => $reason ]
+ );
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialRunJobs.php b/www/wiki/includes/specials/SpecialRunJobs.php
new file mode 100644
index 00000000..cb1e892e
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialRunJobs.php
@@ -0,0 +1,122 @@
+<?php
+/**
+ * Implements Special:RunJobs
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Special page designed for running background tasks (internal use only)
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRunJobs extends UnlistedSpecialPage {
+ public function __construct() {
+ parent::__construct( 'RunJobs' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function execute( $par = '' ) {
+ $this->getOutput()->disable();
+ if ( wfReadOnly() ) {
+ wfHttpError( 423, 'Locked', 'Wiki is in read-only mode.' );
+ return;
+ } elseif ( !$this->getRequest()->wasPosted() ) {
+ wfHttpError( 400, 'Bad Request', 'Request must be POSTed.' );
+ return;
+ }
+
+ $optional = [ 'maxjobs' => 0, 'maxtime' => 30, 'type' => false, 'async' => true ];
+ $required = array_flip( [ 'title', 'tasks', 'signature', 'sigexpiry' ] );
+
+ $params = array_intersect_key( $this->getRequest()->getValues(), $required + $optional );
+ $missing = array_diff_key( $required, $params );
+ if ( count( $missing ) ) {
+ wfHttpError( 400, 'Bad Request',
+ 'Missing parameters: ' . implode( ', ', array_keys( $missing ) )
+ );
+ return;
+ }
+
+ $squery = $params;
+ unset( $squery['signature'] );
+ $correctSignature = self::getQuerySignature( $squery, $this->getConfig()->get( 'SecretKey' ) );
+ $providedSignature = $params['signature'];
+
+ $verified = is_string( $providedSignature )
+ && hash_equals( $correctSignature, $providedSignature );
+ if ( !$verified || $params['sigexpiry'] < time() ) {
+ wfHttpError( 400, 'Bad Request', 'Invalid or stale signature provided.' );
+ return;
+ }
+
+ // Apply any default parameter values
+ $params += $optional;
+
+ if ( $params['async'] ) {
+ // Client will usually disconnect before checking the response,
+ // but it needs to know when it is safe to disconnect. Until this
+ // reaches ignore_user_abort(), it is not safe as the jobs won't run.
+ ignore_user_abort( true ); // jobs may take a bit of time
+ // HTTP 202 Accepted
+ HttpStatus::header( 202 );
+ ob_flush();
+ flush();
+ // Once the client receives this response, it can disconnect
+ set_error_handler( function ( $errno, $errstr ) {
+ if ( strpos( $errstr, 'Cannot modify header information' ) !== false ) {
+ return true; // bug T115413
+ }
+ // Delegate unhandled errors to the default MediaWiki handler
+ // so that fatal errors get proper logging (T89169)
+ return call_user_func_array(
+ 'MWExceptionHandler::handleError', func_get_args()
+ );
+ } );
+ }
+
+ // Do all of the specified tasks...
+ if ( in_array( 'jobs', explode( '|', $params['tasks'] ) ) ) {
+ $runner = new JobRunner( LoggerFactory::getInstance( 'runJobs' ) );
+ $response = $runner->run( [
+ 'type' => $params['type'],
+ 'maxJobs' => $params['maxjobs'] ? $params['maxjobs'] : 1,
+ 'maxTime' => $params['maxtime'] ? $params['maxjobs'] : 30
+ ] );
+ if ( !$params['async'] ) {
+ print FormatJson::encode( $response, true );
+ }
+ }
+ }
+
+ /**
+ * @param array $query
+ * @param string $secretKey
+ * @return string
+ */
+ public static function getQuerySignature( array $query, $secretKey ) {
+ ksort( $query ); // stable order
+ return hash_hmac( 'sha1', wfArrayToCgi( $query ), $secretKey );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialSearch.php b/www/wiki/includes/specials/SpecialSearch.php
new file mode 100644
index 00000000..85b4572b
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialSearch.php
@@ -0,0 +1,721 @@
+<?php
+/**
+ * Implements Special:Search
+ *
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Widget\Search\BasicSearchResultSetWidget;
+use MediaWiki\Widget\Search\FullSearchResultWidget;
+use MediaWiki\Widget\Search\InterwikiSearchResultWidget;
+use MediaWiki\Widget\Search\InterwikiSearchResultSetWidget;
+use MediaWiki\Widget\Search\SimpleSearchResultWidget;
+use MediaWiki\Widget\Search\SimpleSearchResultSetWidget;
+
+/**
+ * implements Special:Search - Run text & title search and display the output
+ * @ingroup SpecialPage
+ */
+class SpecialSearch extends SpecialPage {
+ /**
+ * Current search profile. Search profile is just a name that identifies
+ * the active search tab on the search page (content, discussions...)
+ * For users tt replaces the set of enabled namespaces from the query
+ * string when applicable. Extensions can add new profiles with hooks
+ * with custom search options just for that profile.
+ * @var null|string
+ */
+ protected $profile;
+
+ /** @var SearchEngine Search engine */
+ protected $searchEngine;
+
+ /** @var string Search engine type, if not default */
+ protected $searchEngineType;
+
+ /** @var array For links */
+ protected $extraParams = [];
+
+ /**
+ * @var string The prefix url parameter. Set on the searcher and the
+ * is expected to treat it as prefix filter on titles.
+ */
+ protected $mPrefix;
+
+ /**
+ * @var int
+ */
+ protected $limit, $offset;
+
+ /**
+ * @var array
+ */
+ protected $namespaces;
+
+ /**
+ * @var string
+ */
+ protected $fulltext;
+
+ /**
+ * @var bool
+ */
+ protected $runSuggestion = true;
+
+ /**
+ * Search engine configurations.
+ * @var SearchEngineConfig
+ */
+ protected $searchConfig;
+
+ const NAMESPACES_CURRENT = 'sense';
+
+ public function __construct() {
+ parent::__construct( 'Search' );
+ $this->searchConfig = MediaWikiServices::getInstance()->getSearchEngineConfig();
+ }
+
+ /**
+ * Entry point
+ *
+ * @param string $par
+ */
+ public function execute( $par ) {
+ $request = $this->getRequest();
+ $out = $this->getOutput();
+
+ // Fetch the search term
+ $term = str_replace( "\n", " ", $request->getText( 'search' ) );
+
+ // Historically search terms have been accepted not only in the search query
+ // parameter, but also as part of the primary url. This can have PII implications
+ // in releasing page view data. As such issue a 301 redirect to the correct
+ // URL.
+ if ( strlen( $par ) && !strlen( $term ) ) {
+ $query = $request->getValues();
+ unset( $query['title'] );
+ // Strip underscores from title parameter; most of the time we'll want
+ // text form here. But don't strip underscores from actual text params!
+ $query['search'] = str_replace( '_', ' ', $par );
+ $out->redirect( $this->getPageTitle()->getFullURL( $query ), 301 );
+ return;
+ }
+
+ // Need to load selected namespaces before handling nsRemember
+ $this->load();
+ // TODO: This performs database actions on GET request, which is going to
+ // be a problem for our multi-datacenter work.
+ if ( !is_null( $request->getVal( 'nsRemember' ) ) ) {
+ $this->saveNamespaces();
+ // Remove the token from the URL to prevent the user from inadvertently
+ // exposing it (e.g. by pasting it into a public wiki page) or undoing
+ // later settings changes (e.g. by reloading the page).
+ $query = $request->getValues();
+ unset( $query['title'], $query['nsRemember'] );
+ $out->redirect( $this->getPageTitle()->getFullURL( $query ) );
+ return;
+ }
+
+ $this->searchEngineType = $request->getVal( 'srbackend' );
+ if (
+ !$request->getVal( 'fulltext' ) &&
+ $request->getVal( 'offset' ) === null
+ ) {
+ $url = $this->goResult( $term );
+ if ( $url !== null ) {
+ // successful 'go'
+ $out->redirect( $url );
+ return;
+ }
+ // No match. If it could plausibly be a title
+ // run the No go match hook.
+ $title = Title::newFromText( $term );
+ if ( !is_null( $title ) ) {
+ Hooks::run( 'SpecialSearchNogomatch', [ &$title ] );
+ }
+ }
+
+ $this->setupPage( $term );
+
+ if ( $this->getConfig()->get( 'DisableTextSearch' ) ) {
+ $searchForwardUrl = $this->getConfig()->get( 'SearchForwardUrl' );
+ if ( $searchForwardUrl ) {
+ $url = str_replace( '$1', urlencode( $term ), $searchForwardUrl );
+ $out->redirect( $url );
+ } else {
+ $out->addHTML(
+ "<fieldset>" .
+ "<legend>" .
+ $this->msg( 'search-external' )->escaped() .
+ "</legend>" .
+ "<p class='mw-searchdisabled'>" .
+ $this->msg( 'searchdisabled' )->escaped() .
+ "</p>" .
+ $this->msg( 'googlesearch' )->rawParams(
+ htmlspecialchars( $term ),
+ 'UTF-8',
+ $this->msg( 'searchbutton' )->escaped()
+ )->text() .
+ "</fieldset>"
+ );
+ }
+
+ return;
+ }
+
+ $this->showResults( $term );
+ }
+
+ /**
+ * Set up basic search parameters from the request and user settings.
+ *
+ * @see tests/phpunit/includes/specials/SpecialSearchTest.php
+ */
+ public function load() {
+ $request = $this->getRequest();
+ list( $this->limit, $this->offset ) = $request->getLimitOffset( 20, '' );
+ $this->mPrefix = $request->getVal( 'prefix', '' );
+
+ $user = $this->getUser();
+
+ # Extract manually requested namespaces
+ $nslist = $this->powerSearch( $request );
+ if ( !count( $nslist ) ) {
+ # Fallback to user preference
+ $nslist = $this->searchConfig->userNamespaces( $user );
+ }
+
+ $profile = null;
+ if ( !count( $nslist ) ) {
+ $profile = 'default';
+ }
+
+ $profile = $request->getVal( 'profile', $profile );
+ $profiles = $this->getSearchProfiles();
+ if ( $profile === null ) {
+ // BC with old request format
+ $profile = 'advanced';
+ foreach ( $profiles as $key => $data ) {
+ if ( $nslist === $data['namespaces'] && $key !== 'advanced' ) {
+ $profile = $key;
+ }
+ }
+ $this->namespaces = $nslist;
+ } elseif ( $profile === 'advanced' ) {
+ $this->namespaces = $nslist;
+ } else {
+ if ( isset( $profiles[$profile]['namespaces'] ) ) {
+ $this->namespaces = $profiles[$profile]['namespaces'];
+ } else {
+ // Unknown profile requested
+ $profile = 'default';
+ $this->namespaces = $profiles['default']['namespaces'];
+ }
+ }
+
+ $this->fulltext = $request->getVal( 'fulltext' );
+ $this->runSuggestion = (bool)$request->getVal( 'runsuggestion', true );
+ $this->profile = $profile;
+ }
+
+ /**
+ * If an exact title match can be found, jump straight ahead to it.
+ *
+ * @param string $term
+ * @return string|null The url to redirect to, or null if no redirect.
+ */
+ public function goResult( $term ) {
+ # If the string cannot be used to create a title
+ if ( is_null( Title::newFromText( $term ) ) ) {
+ return null;
+ }
+ # If there's an exact or very near match, jump right there.
+ $title = $this->getSearchEngine()
+ ->getNearMatcher( $this->getConfig() )->getNearMatch( $term );
+ if ( is_null( $title ) ) {
+ return null;
+ }
+ $url = null;
+ if ( !Hooks::run( 'SpecialSearchGoResult', [ $term, $title, &$url ] ) ) {
+ return null;
+ }
+
+ return $url === null ? $title->getFullUrlForRedirect() : $url;
+ }
+
+ /**
+ * @param string $term
+ */
+ public function showResults( $term ) {
+ global $wgContLang;
+
+ if ( $this->searchEngineType !== null ) {
+ $this->setExtraParam( 'srbackend', $this->searchEngineType );
+ }
+
+ $out = $this->getOutput();
+ $formWidget = new MediaWiki\Widget\Search\SearchFormWidget(
+ $this,
+ $this->searchConfig,
+ $this->getSearchProfiles()
+ );
+ $filePrefix = $wgContLang->getFormattedNsText( NS_FILE ) . ':';
+ if ( trim( $term ) === '' || $filePrefix === trim( $term ) ) {
+ // Empty query -- straight view of search form
+ if ( !Hooks::run( 'SpecialSearchResultsPrepend', [ $this, $out, $term ] ) ) {
+ # Hook requested termination
+ return;
+ }
+ $out->enableOOUI();
+ // The form also contains the 'Showing results 0 - 20 of 1234' so we can
+ // only do the form render here for the empty $term case. Rendering
+ // the form when a search is provided is repeated below.
+ $out->addHTML( $formWidget->render(
+ $this->profile, $term, 0, 0, $this->offset, $this->isPowerSearch()
+ ) );
+ return;
+ }
+
+ $search = $this->getSearchEngine();
+ $search->setFeatureData( 'rewrite', $this->runSuggestion );
+ $search->setLimitOffset( $this->limit, $this->offset );
+ $search->setNamespaces( $this->namespaces );
+ $search->prefix = $this->mPrefix;
+ $term = $search->transformSearchTerm( $term );
+
+ Hooks::run( 'SpecialSearchSetupEngine', [ $this, $this->profile, $search ] );
+ if ( !Hooks::run( 'SpecialSearchResultsPrepend', [ $this, $out, $term ] ) ) {
+ # Hook requested termination
+ return;
+ }
+
+ $title = Title::newFromText( $term );
+ $showSuggestion = $title === null || !$title->isKnown();
+ $search->setShowSuggestion( $showSuggestion );
+
+ // fetch search results
+ $rewritten = $search->replacePrefixes( $term );
+
+ $titleMatches = $search->searchTitle( $rewritten );
+ $textMatches = $search->searchText( $rewritten );
+
+ $textStatus = null;
+ if ( $textMatches instanceof Status ) {
+ $textStatus = $textMatches;
+ $textMatches = $textStatus->getValue();
+ }
+
+ // Get number of results
+ $titleMatchesNum = $textMatchesNum = $numTitleMatches = $numTextMatches = 0;
+ if ( $titleMatches ) {
+ $titleMatchesNum = $titleMatches->numRows();
+ $numTitleMatches = $titleMatches->getTotalHits();
+ }
+ if ( $textMatches ) {
+ $textMatchesNum = $textMatches->numRows();
+ $numTextMatches = $textMatches->getTotalHits();
+ if ( $textMatchesNum > 0 ) {
+ $search->augmentSearchResults( $textMatches );
+ }
+ }
+ $num = $titleMatchesNum + $textMatchesNum;
+ $totalRes = $numTitleMatches + $numTextMatches;
+
+ // start rendering the page
+ $out->enableOOUI();
+ $out->addHTML( $formWidget->render(
+ $this->profile, $term, $num, $totalRes, $this->offset, $this->isPowerSearch()
+ ) );
+
+ // did you mean... suggestions
+ if ( $textMatches ) {
+ $dymWidget = new MediaWiki\Widget\Search\DidYouMeanWidget( $this );
+ $out->addHTML( $dymWidget->render( $term, $textMatches ) );
+ }
+
+ $hasErrors = $textStatus && $textStatus->getErrors() !== [];
+ $hasOtherResults = $textMatches &&
+ $textMatches->hasInterwikiResults( SearchResultSet::INLINE_RESULTS );
+
+ if ( $textMatches && $textMatches->hasInterwikiResults( SearchResultSet::SECONDARY_RESULTS ) ) {
+ $out->addHTML( '<div class="searchresults mw-searchresults-has-iw">' );
+ } else {
+ $out->addHTML( '<div class="searchresults">' );
+ }
+
+ if ( $hasErrors ) {
+ list( $error, $warning ) = $textStatus->splitByErrorType();
+ if ( $error->getErrors() ) {
+ $out->addHTML( Html::rawElement(
+ 'div',
+ [ 'class' => 'errorbox' ],
+ $error->getHTML( 'search-error' )
+ ) );
+ }
+ if ( $warning->getErrors() ) {
+ $out->addHTML( Html::rawElement(
+ 'div',
+ [ 'class' => 'warningbox' ],
+ $warning->getHTML( 'search-warning' )
+ ) );
+ }
+ }
+
+ // Show the create link ahead
+ $this->showCreateLink( $title, $num, $titleMatches, $textMatches );
+
+ Hooks::run( 'SpecialSearchResults', [ $term, &$titleMatches, &$textMatches ] );
+
+ // If we have no results and have not already displayed an error message
+ if ( $num === 0 && !$hasErrors ) {
+ $out->wrapWikiMsg( "<p class=\"mw-search-nonefound\">\n$1</p>", [
+ $hasOtherResults ? 'search-nonefound-thiswiki' : 'search-nonefound',
+ wfEscapeWikiText( $term )
+ ] );
+ }
+
+ // Although $num might be 0 there can still be secondary or inline
+ // results to display.
+ $linkRenderer = $this->getLinkRenderer();
+ $mainResultWidget = new FullSearchResultWidget( $this, $linkRenderer );
+
+ if ( $search->getFeatureData( 'enable-new-crossproject-page' ) ) {
+ $sidebarResultWidget = new InterwikiSearchResultWidget( $this, $linkRenderer );
+ $sidebarResultsWidget = new InterwikiSearchResultSetWidget(
+ $this,
+ $sidebarResultWidget,
+ $linkRenderer,
+ MediaWikiServices::getInstance()->getInterwikiLookup(),
+ $search->getFeatureData( 'show-multimedia-search-results' )
+ );
+ } else {
+ $sidebarResultWidget = new SimpleSearchResultWidget( $this, $linkRenderer );
+ $sidebarResultsWidget = new SimpleSearchResultSetWidget(
+ $this,
+ $sidebarResultWidget,
+ $linkRenderer,
+ MediaWikiServices::getInstance()->getInterwikiLookup()
+ );
+ }
+
+ $widget = new BasicSearchResultSetWidget( $this, $mainResultWidget, $sidebarResultsWidget );
+
+ $out->addHTML( $widget->render(
+ $term, $this->offset, $titleMatches, $textMatches
+ ) );
+
+ if ( $titleMatches ) {
+ $titleMatches->free();
+ }
+
+ if ( $textMatches ) {
+ $textMatches->free();
+ }
+
+ $out->addHTML( '<div class="mw-search-visualclear"></div>' );
+
+ // prev/next links
+ if ( $totalRes > $this->limit || $this->offset ) {
+ // Allow matches to define the correct offset, as interleaved
+ // AB testing may require a different next page offset.
+ if ( $textMatches && $textMatches->getOffset() !== null ) {
+ $offset = $textMatches->getOffset();
+ } else {
+ $offset = $this->offset;
+ }
+
+ $prevnext = $this->getLanguage()->viewPrevNext(
+ $this->getPageTitle(),
+ $offset,
+ $this->limit,
+ $this->powerSearchOptions() + [ 'search' => $term ],
+ $this->limit + $this->offset >= $totalRes
+ );
+ $out->addHTML( "<p class='mw-search-pager-bottom'>{$prevnext}</p>\n" );
+ }
+
+ // Close <div class='searchresults'>
+ $out->addHTML( "</div>" );
+
+ Hooks::run( 'SpecialSearchResultsAppend', [ $this, $out, $term ] );
+ }
+
+ /**
+ * @param Title $title
+ * @param int $num The number of search results found
+ * @param null|SearchResultSet $titleMatches Results from title search
+ * @param null|SearchResultSet $textMatches Results from text search
+ */
+ protected function showCreateLink( $title, $num, $titleMatches, $textMatches ) {
+ // show direct page/create link if applicable
+
+ // Check DBkey !== '' in case of fragment link only.
+ if ( is_null( $title ) || $title->getDBkey() === ''
+ || ( $titleMatches !== null && $titleMatches->searchContainedSyntax() )
+ || ( $textMatches !== null && $textMatches->searchContainedSyntax() )
+ ) {
+ // invalid title
+ // preserve the paragraph for margins etc...
+ $this->getOutput()->addHTML( '<p></p>' );
+
+ return;
+ }
+
+ $messageName = 'searchmenu-new-nocreate';
+ $linkClass = 'mw-search-createlink';
+
+ if ( !$title->isExternal() ) {
+ if ( $title->isKnown() ) {
+ $messageName = 'searchmenu-exists';
+ $linkClass = 'mw-search-exists';
+ } elseif ( ContentHandler::getForTitle( $title )->supportsDirectEditing()
+ && $title->quickUserCan( 'create', $this->getUser() )
+ ) {
+ $messageName = 'searchmenu-new';
+ }
+ }
+
+ $params = [
+ $messageName,
+ wfEscapeWikiText( $title->getPrefixedText() ),
+ Message::numParam( $num )
+ ];
+ Hooks::run( 'SpecialSearchCreateLink', [ $title, &$params ] );
+
+ // Extensions using the hook might still return an empty $messageName
+ if ( $messageName ) {
+ $this->getOutput()->wrapWikiMsg( "<p class=\"$linkClass\">\n$1</p>", $params );
+ } else {
+ // preserve the paragraph for margins etc...
+ $this->getOutput()->addHTML( '<p></p>' );
+ }
+ }
+
+ /**
+ * Sets up everything for the HTML output page including styles, javascript,
+ * page title, etc.
+ *
+ * @param string $term
+ */
+ protected function setupPage( $term ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+ // TODO: Is this true? The namespace remember uses a user token
+ // on save.
+ $out->allowClickjacking();
+ $this->addHelpLink( 'Help:Searching' );
+
+ if ( strval( $term ) !== '' ) {
+ $out->setPageTitle( $this->msg( 'searchresults' ) );
+ $out->setHTMLTitle( $this->msg( 'pagetitle' )
+ ->rawParams( $this->msg( 'searchresults-title' )->rawParams( $term )->text() )
+ ->inContentLanguage()->text()
+ );
+ }
+
+ $out->addJsConfigVars( [ 'searchTerm' => $term ] );
+ $out->addModules( 'mediawiki.special.search' );
+ $out->addModuleStyles( [
+ 'mediawiki.special', 'mediawiki.special.search.styles', 'mediawiki.ui', 'mediawiki.ui.button',
+ 'mediawiki.ui.input', 'mediawiki.widgets.SearchInputWidget.styles',
+ ] );
+ }
+
+ /**
+ * Return true if current search is a power (advanced) search
+ *
+ * @return bool
+ */
+ protected function isPowerSearch() {
+ return $this->profile === 'advanced';
+ }
+
+ /**
+ * Extract "power search" namespace settings from the request object,
+ * returning a list of index numbers to search.
+ *
+ * @param WebRequest &$request
+ * @return array
+ */
+ protected function powerSearch( &$request ) {
+ $arr = [];
+ foreach ( $this->searchConfig->searchableNamespaces() as $ns => $name ) {
+ if ( $request->getCheck( 'ns' . $ns ) ) {
+ $arr[] = $ns;
+ }
+ }
+
+ return $arr;
+ }
+
+ /**
+ * Reconstruct the 'power search' options for links
+ * TODO: Instead of exposing this publicly, could we instead expose
+ * a function for creating search links?
+ *
+ * @return array
+ */
+ public function powerSearchOptions() {
+ $opt = [];
+ if ( $this->isPowerSearch() ) {
+ foreach ( $this->namespaces as $n ) {
+ $opt['ns' . $n] = 1;
+ }
+ } else {
+ $opt['profile'] = $this->profile;
+ }
+
+ return $opt + $this->extraParams;
+ }
+
+ /**
+ * Save namespace preferences when we're supposed to
+ *
+ * @return bool Whether we wrote something
+ */
+ protected function saveNamespaces() {
+ $user = $this->getUser();
+ $request = $this->getRequest();
+
+ if ( $user->isLoggedIn() &&
+ $user->matchEditToken(
+ $request->getVal( 'nsRemember' ),
+ 'searchnamespace',
+ $request
+ ) && !wfReadOnly()
+ ) {
+ // Reset namespace preferences: namespaces are not searched
+ // when they're not mentioned in the URL parameters.
+ foreach ( MWNamespace::getValidNamespaces() as $n ) {
+ $user->setOption( 'searchNs' . $n, false );
+ }
+ // The request parameters include all the namespaces to be searched.
+ // Even if they're the same as an existing profile, they're not eaten.
+ foreach ( $this->namespaces as $n ) {
+ $user->setOption( 'searchNs' . $n, true );
+ }
+
+ DeferredUpdates::addCallableUpdate( function () use ( $user ) {
+ $user->saveSettings();
+ } );
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getSearchProfiles() {
+ // Builds list of Search Types (profiles)
+ $nsAllSet = array_keys( $this->searchConfig->searchableNamespaces() );
+ $defaultNs = $this->searchConfig->defaultNamespaces();
+ $profiles = [
+ 'default' => [
+ 'message' => 'searchprofile-articles',
+ 'tooltip' => 'searchprofile-articles-tooltip',
+ 'namespaces' => $defaultNs,
+ 'namespace-messages' => $this->searchConfig->namespacesAsText(
+ $defaultNs
+ ),
+ ],
+ 'images' => [
+ 'message' => 'searchprofile-images',
+ 'tooltip' => 'searchprofile-images-tooltip',
+ 'namespaces' => [ NS_FILE ],
+ ],
+ 'all' => [
+ 'message' => 'searchprofile-everything',
+ 'tooltip' => 'searchprofile-everything-tooltip',
+ 'namespaces' => $nsAllSet,
+ ],
+ 'advanced' => [
+ 'message' => 'searchprofile-advanced',
+ 'tooltip' => 'searchprofile-advanced-tooltip',
+ 'namespaces' => self::NAMESPACES_CURRENT,
+ ]
+ ];
+
+ Hooks::run( 'SpecialSearchProfiles', [ &$profiles ] );
+
+ foreach ( $profiles as &$data ) {
+ if ( !is_array( $data['namespaces'] ) ) {
+ continue;
+ }
+ sort( $data['namespaces'] );
+ }
+
+ return $profiles;
+ }
+
+ /**
+ * @since 1.18
+ *
+ * @return SearchEngine
+ */
+ public function getSearchEngine() {
+ if ( $this->searchEngine === null ) {
+ $this->searchEngine = $this->searchEngineType ?
+ MediaWikiServices::getInstance()->getSearchEngineFactory()->create( $this->searchEngineType ) :
+ MediaWikiServices::getInstance()->newSearchEngine();
+ }
+
+ return $this->searchEngine;
+ }
+
+ /**
+ * Current search profile.
+ * @return null|string
+ */
+ function getProfile() {
+ return $this->profile;
+ }
+
+ /**
+ * Current namespaces.
+ * @return array
+ */
+ function getNamespaces() {
+ return $this->namespaces;
+ }
+
+ /**
+ * Users of hook SpecialSearchSetupEngine can use this to
+ * add more params to links to not lose selection when
+ * user navigates search results.
+ * @since 1.18
+ *
+ * @param string $key
+ * @param mixed $value
+ */
+ public function setExtraParam( $key, $value ) {
+ $this->extraParams[$key] = $value;
+ }
+
+ protected function getGroupName() {
+ return 'pages';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialShortpages.php b/www/wiki/includes/specials/SpecialShortpages.php
new file mode 100644
index 00000000..e9c15e7b
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialShortpages.php
@@ -0,0 +1,178 @@
+<?php
+/**
+ * Implements Special:Shortpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * SpecialShortpages extends QueryPage. It is used to return the shortest
+ * pages in the database.
+ *
+ * @ingroup SpecialPage
+ */
+class ShortPagesPage extends QueryPage {
+
+ function __construct( $name = 'Shortpages' ) {
+ parent::__construct( $name );
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ $config = $this->getConfig();
+ $blacklist = $config->get( 'ShortPagesNamespaceBlacklist' );
+ $tables = [ 'page' ];
+ $conds = [
+ 'page_namespace' => array_diff( MWNamespace::getContentNamespaces(), $blacklist ),
+ 'page_is_redirect' => 0
+ ];
+ $joinConds = [];
+ $options = [ 'USE INDEX' => [ 'page' => 'page_redirect_namespace_len' ] ];
+
+ // Allow extensions to modify the query
+ Hooks::run( 'ShortPagesQuery', [ &$tables, &$conds, &$joinConds, &$options ] );
+
+ return [
+ 'tables' => $tables,
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_len'
+ ],
+ 'conds' => $conds,
+ 'join_conds' => $joinConds,
+ 'options' => $options
+ ];
+ }
+
+ public function reallyDoQuery( $limit, $offset = false ) {
+ $fname = static::class . '::reallyDoQuery';
+ $dbr = $this->getRecacheDB();
+ $query = $this->getQueryInfo();
+ $order = $this->getOrderFields();
+
+ if ( $this->sortDescending() ) {
+ foreach ( $order as &$field ) {
+ $field .= ' DESC';
+ }
+ }
+
+ $tables = isset( $query['tables'] ) ? (array)$query['tables'] : [];
+ $fields = isset( $query['fields'] ) ? (array)$query['fields'] : [];
+ $conds = isset( $query['conds'] ) ? (array)$query['conds'] : [];
+ $options = isset( $query['options'] ) ? (array)$query['options'] : [];
+ $join_conds = isset( $query['join_conds'] ) ? (array)$query['join_conds'] : [];
+
+ if ( $limit !== false ) {
+ $options['LIMIT'] = intval( $limit );
+ }
+
+ if ( $offset !== false ) {
+ $options['OFFSET'] = intval( $offset );
+ }
+
+ $namespaces = $conds['page_namespace'];
+ if ( count( $namespaces ) === 1 ) {
+ $options['ORDER BY'] = $order;
+ $res = $dbr->select( $tables, $fields, $conds, $fname,
+ $options, $join_conds
+ );
+ } else {
+ unset( $conds['page_namespace'] );
+ $options['INNER ORDER BY'] = $order;
+ $options['ORDER BY'] = [ 'value' . ( $this->sortDescending() ? ' DESC' : '' ) ];
+ $sql = $dbr->unionConditionPermutations(
+ $tables,
+ $fields,
+ [ 'page_namespace' => $namespaces ],
+ $conds,
+ $fname,
+ $options,
+ $join_conds
+ );
+ $res = $dbr->query( $sql, $fname );
+ }
+
+ return $res;
+ }
+
+ function getOrderFields() {
+ return [ 'page_len' ];
+ }
+
+ /**
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $dm = $this->getLanguage()->getDirMark();
+
+ $title = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( !$title ) {
+ return Html::element( 'span', [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription( $this->getContext(), $result->namespace, $result->title ) );
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+ $hlink = $linkRenderer->makeKnownLink(
+ $title,
+ $this->msg( 'hist' )->text(),
+ [],
+ [ 'action' => 'history' ]
+ );
+ $hlinkInParentheses = $this->msg( 'parentheses' )->rawParams( $hlink )->escaped();
+
+ if ( $this->isCached() ) {
+ $plink = $linkRenderer->makeLink( $title );
+ $exists = $title->exists();
+ } else {
+ $plink = $linkRenderer->makeKnownLink( $title );
+ $exists = true;
+ }
+
+ $size = $this->msg( 'nbytes' )->numParams( $result->value )->escaped();
+
+ return $exists
+ ? "${hlinkInParentheses} {$dm}{$plink} {$dm}[{$size}]"
+ : "<del>${hlinkInParentheses} {$dm}{$plink} {$dm}[{$size}]</del>";
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialSpecialpages.php b/www/wiki/includes/specials/SpecialSpecialpages.php
new file mode 100644
index 00000000..4f290822
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialSpecialpages.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Implements Special:Specialpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists special pages
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialSpecialpages extends UnlistedSpecialPage {
+
+ function __construct() {
+ parent::__construct( 'Specialpages' );
+ }
+
+ function execute( $par ) {
+ $out = $this->getOutput();
+ $this->setHeaders();
+ $this->outputHeader();
+ $out->allowClickjacking();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ $groups = $this->getPageGroups();
+
+ if ( $groups === false ) {
+ return;
+ }
+
+ $this->addHelpLink( 'Help:Special pages' );
+ $this->outputPageList( $groups );
+ }
+
+ private function getPageGroups() {
+ $pages = SpecialPageFactory::getUsablePages( $this->getUser() );
+
+ if ( !count( $pages ) ) {
+ # Yeah, that was pointless. Thanks for coming.
+ return false;
+ }
+
+ /** Put them into a sortable array */
+ $groups = [];
+ /** @var SpecialPage $page */
+ foreach ( $pages as $page ) {
+ if ( $page->isListed() ) {
+ $group = $page->getFinalGroupName();
+ if ( !isset( $groups[$group] ) ) {
+ $groups[$group] = [];
+ }
+ $groups[$group][$page->getDescription()] = [
+ $page->getPageTitle(),
+ $page->isRestricted(),
+ $page->isCached()
+ ];
+ }
+ }
+
+ /** Sort */
+ foreach ( $groups as $group => $sortedPages ) {
+ ksort( $groups[$group] );
+ }
+
+ /** Always move "other" to end */
+ if ( array_key_exists( 'other', $groups ) ) {
+ $other = $groups['other'];
+ unset( $groups['other'] );
+ $groups['other'] = $other;
+ }
+
+ return $groups;
+ }
+
+ private function outputPageList( $groups ) {
+ $out = $this->getOutput();
+
+ $includesRestrictedPages = false;
+ $includesCachedPages = false;
+
+ foreach ( $groups as $group => $sortedPages ) {
+ $out->wrapWikiMsg(
+ "<h2 class=\"mw-specialpagesgroup\" id=\"mw-specialpagesgroup-$group\">$1</h2>\n",
+ "specialpages-group-$group"
+ );
+ $out->addHTML(
+ Html::openElement( 'div', [ 'class' => 'mw-specialpages-list' ] )
+ . '<ul>'
+ );
+ foreach ( $sortedPages as $desc => $specialpage ) {
+ list( $title, $restricted, $cached ) = $specialpage;
+
+ $pageClasses = [];
+ if ( $cached ) {
+ $includesCachedPages = true;
+ $pageClasses[] = 'mw-specialpagecached';
+ }
+ if ( $restricted ) {
+ $includesRestrictedPages = true;
+ $pageClasses[] = 'mw-specialpagerestricted';
+ }
+
+ $link = $this->getLinkRenderer()->makeKnownLink( $title, $desc );
+ $out->addHTML( Html::rawElement(
+ 'li',
+ [ 'class' => implode( ' ', $pageClasses ) ],
+ $link
+ ) . "\n" );
+ }
+ $out->addHTML(
+ Html::closeElement( 'ul' ) .
+ Html::closeElement( 'div' )
+ );
+ }
+
+ // add legend
+ $notes = [];
+ if ( $includesRestrictedPages ) {
+ $restricedMsg = $this->msg( 'specialpages-note-restricted' );
+ if ( !$restricedMsg->isDisabled() ) {
+ $notes[] = $restricedMsg->plain();
+ }
+ }
+ if ( $includesCachedPages ) {
+ $cachedMsg = $this->msg( 'specialpages-note-cached' );
+ if ( !$cachedMsg->isDisabled() ) {
+ $notes[] = $cachedMsg->plain();
+ }
+ }
+ if ( $notes !== [] ) {
+ $out->wrapWikiMsg(
+ "<h2 class=\"mw-specialpages-note-top\">$1</h2>", 'specialpages-note-top'
+ );
+ $out->addWikiText(
+ "<div class=\"mw-specialpages-notes\">\n" .
+ implode( "\n", $notes ) .
+ "\n</div>"
+ );
+ }
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialStatistics.php b/www/wiki/includes/specials/SpecialStatistics.php
new file mode 100644
index 00000000..a60549bf
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialStatistics.php
@@ -0,0 +1,307 @@
+<?php
+/**
+ * Implements Special:Statistics
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page lists various statistics, including the contents of
+ * `site_stats`, plus page view details if enabled
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialStatistics extends SpecialPage {
+ private $edits, $good, $images, $total, $users,
+ $activeUsers = 0;
+
+ public function __construct() {
+ parent::__construct( 'Statistics' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->getOutput()->addModuleStyles( 'mediawiki.special' );
+
+ $this->edits = SiteStats::edits();
+ $this->good = SiteStats::articles();
+ $this->images = SiteStats::images();
+ $this->total = SiteStats::pages();
+ $this->users = SiteStats::users();
+ $this->activeUsers = SiteStats::activeUsers();
+ $this->hook = '';
+
+ $text = Xml::openElement( 'table', [ 'class' => 'wikitable mw-statistics-table' ] );
+
+ # Statistic - pages
+ $text .= $this->getPageStats();
+
+ # Statistic - edits
+ $text .= $this->getEditStats();
+
+ # Statistic - users
+ $text .= $this->getUserStats();
+
+ # Statistic - usergroups
+ $text .= $this->getGroupStats();
+
+ # Statistic - other
+ $extraStats = [];
+ if ( Hooks::run( 'SpecialStatsAddExtra', [ &$extraStats, $this->getContext() ] ) ) {
+ $text .= $this->getOtherStats( $extraStats );
+ }
+
+ $text .= Xml::closeElement( 'table' );
+
+ # Customizable footer
+ $footer = $this->msg( 'statistics-footer' );
+ if ( !$footer->isBlank() ) {
+ $text .= "\n" . $footer->parse();
+ }
+
+ $this->getOutput()->addHTML( $text );
+ }
+
+ /**
+ * Format a row
+ * @param string $text Description of the row
+ * @param float $number A statistical number
+ * @param array $trExtraParams Params to table row, see Html::elememt
+ * @param string $descMsg Message key
+ * @param array|string $descMsgParam Message parameters
+ * @return string Table row in HTML format
+ */
+ private function formatRow( $text, $number, $trExtraParams = [],
+ $descMsg = '', $descMsgParam = ''
+ ) {
+ if ( $descMsg ) {
+ $msg = $this->msg( $descMsg, $descMsgParam );
+ if ( !$msg->isDisabled() ) {
+ $descriptionHtml = $this->msg( 'parentheses' )->rawParams( $msg->parse() )
+ ->escaped();
+ $text .= "<br />" . Html::rawElement(
+ 'small',
+ [ 'class' => 'mw-statistic-desc' ],
+ " $descriptionHtml"
+ );
+ }
+ }
+
+ return Html::rawElement( 'tr', $trExtraParams,
+ Html::rawElement( 'td', [], $text ) .
+ Html::rawElement( 'td', [ 'class' => 'mw-statistics-numbers' ], $number )
+ );
+ }
+
+ /**
+ * Each of these methods is pretty self-explanatory, get a particular
+ * row for the table of statistics
+ * @return string
+ */
+ private function getPageStats() {
+ $linkRenderer = $this->getLinkRenderer();
+
+ $specialAllPagesTitle = SpecialPage::getTitleFor( 'Allpages' );
+ $pageStatsHtml = Xml::openElement( 'tr' ) .
+ Xml::tags( 'th', [ 'colspan' => '2' ], $this->msg( 'statistics-header-pages' )
+ ->parse() ) .
+ Xml::closeElement( 'tr' ) .
+ $this->formatRow( $linkRenderer->makeKnownLink(
+ $specialAllPagesTitle,
+ $this->msg( 'statistics-articles' )->text(),
+ [], [ 'hideredirects' => 1 ] ),
+ $this->getLanguage()->formatNum( $this->good ),
+ [ 'class' => 'mw-statistics-articles' ],
+ 'statistics-articles-desc' ) .
+ $this->formatRow( $linkRenderer->makeKnownLink( $specialAllPagesTitle,
+ $this->msg( 'statistics-pages' )->text() ),
+ $this->getLanguage()->formatNum( $this->total ),
+ [ 'class' => 'mw-statistics-pages' ],
+ 'statistics-pages-desc' );
+
+ // Show the image row only, when there are files or upload is possible
+ if ( $this->images !== 0 || $this->getConfig()->get( 'EnableUploads' ) ) {
+ $pageStatsHtml .= $this->formatRow(
+ $linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'MediaStatistics' ),
+ $this->msg( 'statistics-files' )->text() ),
+ $this->getLanguage()->formatNum( $this->images ),
+ [ 'class' => 'mw-statistics-files' ] );
+ }
+
+ return $pageStatsHtml;
+ }
+
+ private function getEditStats() {
+ return Xml::openElement( 'tr' ) .
+ Xml::tags( 'th', [ 'colspan' => '2' ],
+ $this->msg( 'statistics-header-edits' )->parse() ) .
+ Xml::closeElement( 'tr' ) .
+ $this->formatRow( $this->msg( 'statistics-edits' )->parse(),
+ $this->getLanguage()->formatNum( $this->edits ),
+ [ 'class' => 'mw-statistics-edits' ]
+ ) .
+ $this->formatRow( $this->msg( 'statistics-edits-average' )->parse(),
+ $this->getLanguage()->formatNum(
+ sprintf( '%.2f', $this->total ? $this->edits / $this->total : 0 )
+ ), [ 'class' => 'mw-statistics-edits-average' ]
+ );
+ }
+
+ private function getUserStats() {
+ return Xml::openElement( 'tr' ) .
+ Xml::tags( 'th', [ 'colspan' => '2' ],
+ $this->msg( 'statistics-header-users' )->parse() ) .
+ Xml::closeElement( 'tr' ) .
+ $this->formatRow( $this->msg( 'statistics-users' )->parse(),
+ $this->getLanguage()->formatNum( $this->users ),
+ [ 'class' => 'mw-statistics-users' ]
+ ) .
+ $this->formatRow( $this->msg( 'statistics-users-active' )->parse() . ' ' .
+ $this->getLinkRenderer()->makeKnownLink(
+ SpecialPage::getTitleFor( 'Activeusers' ),
+ $this->msg( 'listgrouprights-members' )->text()
+ ),
+ $this->getLanguage()->formatNum( $this->activeUsers ),
+ [ 'class' => 'mw-statistics-users-active' ],
+ 'statistics-users-active-desc',
+ $this->getLanguage()->formatNum(
+ $this->getConfig()->get( 'ActiveUserDays' ) )
+ );
+ }
+
+ private function getGroupStats() {
+ $linkRenderer = $this->getLinkRenderer();
+ $text = '';
+ foreach ( $this->getConfig()->get( 'GroupPermissions' ) as $group => $permissions ) {
+ # Skip generic * and implicit groups
+ if ( in_array( $group, $this->getConfig()->get( 'ImplicitGroups' ) )
+ || $group == '*' ) {
+ continue;
+ }
+ $groupname = htmlspecialchars( $group );
+ $msg = $this->msg( 'group-' . $groupname );
+ if ( $msg->isBlank() ) {
+ $groupnameLocalized = $groupname;
+ } else {
+ $groupnameLocalized = $msg->text();
+ }
+ $msg = $this->msg( 'grouppage-' . $groupname )->inContentLanguage();
+ if ( $msg->isBlank() ) {
+ $grouppageLocalized = MWNamespace::getCanonicalName( NS_PROJECT ) .
+ ':' . $groupname;
+ } else {
+ $grouppageLocalized = $msg->text();
+ }
+ $linkTarget = Title::newFromText( $grouppageLocalized );
+
+ if ( $linkTarget ) {
+ $grouppage = $linkRenderer->makeLink(
+ $linkTarget,
+ $groupnameLocalized
+ );
+ } else {
+ $grouppage = htmlspecialchars( $groupnameLocalized );
+ }
+
+ $grouplink = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Listusers' ),
+ $this->msg( 'listgrouprights-members' )->text(),
+ [],
+ [ 'group' => $group ]
+ );
+ # Add a class when a usergroup contains no members to allow hiding these rows
+ $classZero = '';
+ $countUsers = SiteStats::numberingroup( $groupname );
+ if ( $countUsers == 0 ) {
+ $classZero = ' statistics-group-zero';
+ }
+ $text .= $this->formatRow( $grouppage . ' ' . $grouplink,
+ $this->getLanguage()->formatNum( $countUsers ),
+ [ 'class' => 'statistics-group-' . Sanitizer::escapeClass( $group ) .
+ $classZero ] );
+ }
+
+ return $text;
+ }
+
+ /**
+ * Conversion of external statistics into an internal representation
+ * Following a ([<header-message>][<item-message>] = number) pattern
+ *
+ * @param array $stats
+ * @return string
+ */
+ private function getOtherStats( array $stats ) {
+ $return = '';
+
+ foreach ( $stats as $header => $items ) {
+ // Identify the structure used
+ if ( is_array( $items ) ) {
+ // Ignore headers that are recursively set as legacy header
+ if ( $header !== 'statistics-header-hooks' ) {
+ $return .= $this->formatRowHeader( $header );
+ }
+
+ // Collect all items that belong to the same header
+ foreach ( $items as $key => $value ) {
+ if ( is_array( $value ) ) {
+ $name = $value['name'];
+ $number = $value['number'];
+ } else {
+ $name = $this->msg( $key )->parse();
+ $number = $value;
+ }
+
+ $return .= $this->formatRow(
+ $name,
+ $this->getLanguage()->formatNum( htmlspecialchars( $number ) ),
+ [ 'class' => 'mw-statistics-hook', 'id' => 'mw-' . $key ]
+ );
+ }
+ } else {
+ // Create the legacy header only once
+ if ( $return === '' ) {
+ $return .= $this->formatRowHeader( 'statistics-header-hooks' );
+ }
+
+ // Recursively remap the legacy structure
+ $return .= $this->getOtherStats( [ 'statistics-header-hooks' =>
+ [ $header => $items ] ] );
+ }
+ }
+
+ return $return;
+ }
+
+ /**
+ * Format row header
+ *
+ * @param string $header
+ * @return string
+ */
+ private function formatRowHeader( $header ) {
+ return Xml::openElement( 'tr' ) .
+ Xml::tags( 'th', [ 'colspan' => '2' ], $this->msg( $header )->parse() ) .
+ Xml::closeElement( 'tr' );
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialTags.php b/www/wiki/includes/specials/SpecialTags.php
new file mode 100644
index 00000000..605ee008
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialTags.php
@@ -0,0 +1,482 @@
+<?php
+/**
+ * Implements Special:Tags
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists tags for edits
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialTags extends SpecialPage {
+
+ /**
+ * @var array List of explicitly defined tags
+ */
+ protected $explicitlyDefinedTags;
+
+ /**
+ * @var array List of software defined tags
+ */
+ protected $softwareDefinedTags;
+
+ /**
+ * @var array List of software activated tags
+ */
+ protected $softwareActivatedTags;
+
+ function __construct() {
+ parent::__construct( 'Tags' );
+ }
+
+ function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $request = $this->getRequest();
+ switch ( $par ) {
+ case 'delete':
+ $this->showDeleteTagForm( $request->getVal( 'tag' ) );
+ break;
+ case 'activate':
+ $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
+ break;
+ case 'deactivate':
+ $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
+ break;
+ case 'create':
+ // fall through, thanks to HTMLForm's logic
+ default:
+ $this->showTagList();
+ break;
+ }
+ }
+
+ function showTagList() {
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'tags-title' ) );
+ $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' );
+
+ $user = $this->getUser();
+ $userCanManage = $user->isAllowed( 'managechangetags' );
+ $userCanDelete = $user->isAllowed( 'deletechangetags' );
+ $userCanEditInterface = $user->isAllowed( 'editinterface' );
+
+ // Show form to create a tag
+ if ( $userCanManage ) {
+ $fields = [
+ 'Tag' => [
+ 'type' => 'text',
+ 'label' => $this->msg( 'tags-create-tag-name' )->plain(),
+ 'required' => true,
+ ],
+ 'Reason' => [
+ 'type' => 'text',
+ 'label' => $this->msg( 'tags-create-reason' )->plain(),
+ 'size' => 50,
+ ],
+ 'IgnoreWarnings' => [
+ 'type' => 'hidden',
+ ],
+ ];
+
+ $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
+ $form->setAction( $this->getPageTitle( 'create' )->getLocalURL() );
+ $form->setWrapperLegendMsg( 'tags-create-heading' );
+ $form->setHeaderText( $this->msg( 'tags-create-explanation' )->parseAsBlock() );
+ $form->setSubmitCallback( [ $this, 'processCreateTagForm' ] );
+ $form->setSubmitTextMsg( 'tags-create-submit' );
+ $form->show();
+
+ // If processCreateTagForm generated a redirect, there's no point
+ // continuing with this, as the user is just going to end up getting sent
+ // somewhere else. Additionally, if we keep going here, we end up
+ // populating the memcache of tag data (see ChangeTags::listDefinedTags)
+ // with out-of-date data from the replica DB, because the replica DB hasn't caught
+ // up to the fact that a new tag has been created as part of an implicit,
+ // as yet uncommitted transaction on master.
+ if ( $out->getRedirect() !== '' ) {
+ return;
+ }
+ }
+
+ // Used to get hitcounts for #doTagRow()
+ $tagStats = ChangeTags::tagUsageStatistics();
+
+ // Used in #doTagRow()
+ $this->explicitlyDefinedTags = array_fill_keys(
+ ChangeTags::listExplicitlyDefinedTags(), true );
+ $this->softwareDefinedTags = array_fill_keys(
+ ChangeTags::listSoftwareDefinedTags(), true );
+
+ // List all defined tags, even if they were never applied
+ $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
+
+ // Show header only if there exists atleast one tag
+ if ( !$tagStats && !$definedTags ) {
+ return;
+ }
+
+ // Write the headers
+ $html = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) .
+ Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) .
+ Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) .
+ Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) .
+ Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) .
+ Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) .
+ ( ( $userCanManage || $userCanDelete ) ?
+ Xml::tags( 'th', [ 'class' => 'unsortable' ],
+ $this->msg( 'tags-actions-header' )->parse() ) :
+ '' )
+ );
+
+ // Used in #doTagRow()
+ $this->softwareActivatedTags = array_fill_keys(
+ ChangeTags::listSoftwareActivatedTags(), true );
+
+ // Insert tags that have been applied at least once
+ foreach ( $tagStats as $tag => $hitcount ) {
+ $html .= $this->doTagRow( $tag, $hitcount, $userCanManage,
+ $userCanDelete, $userCanEditInterface );
+ }
+ // Insert tags defined somewhere but never applied
+ foreach ( $definedTags as $tag ) {
+ if ( !isset( $tagStats[$tag] ) ) {
+ $html .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
+ }
+ }
+
+ $out->addHTML( Xml::tags(
+ 'table',
+ [ 'class' => 'mw-datatable sortable mw-tags-table' ],
+ $html
+ ) );
+ }
+
+ function doTagRow( $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks ) {
+ $newRow = '';
+ $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
+
+ $linkRenderer = $this->getLinkRenderer();
+ $disp = ChangeTags::tagDescription( $tag, $this->getContext() );
+ if ( $showEditLinks ) {
+ $disp .= ' ';
+ $editLink = $linkRenderer->makeLink(
+ $this->msg( "tag-$tag" )->inContentLanguage()->getTitle(),
+ $this->msg( 'tags-edit' )->text()
+ );
+ $disp .= $this->msg( 'parentheses' )->rawParams( $editLink )->escaped();
+ }
+ $newRow .= Xml::tags( 'td', null, $disp );
+
+ $msg = $this->msg( "tag-$tag-description" );
+ $desc = !$msg->exists() ? '' : $msg->parse();
+ if ( $showEditLinks ) {
+ $desc .= ' ';
+ $editDescLink = $linkRenderer->makeLink(
+ $this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(),
+ $this->msg( 'tags-edit' )->text()
+ );
+ $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
+ }
+ $newRow .= Xml::tags( 'td', null, $desc );
+
+ $sourceMsgs = [];
+ $isSoftware = isset( $this->softwareDefinedTags[$tag] );
+ $isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
+ if ( $isSoftware ) {
+ // TODO: Rename this message
+ $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
+ }
+ if ( $isExplicit ) {
+ $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
+ }
+ if ( !$sourceMsgs ) {
+ $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
+ }
+ $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
+
+ $isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] );
+ $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' );
+ $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() );
+
+ $hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount );
+ if ( $this->getConfig()->get( 'UseTagFilter' ) ) {
+ $hitcountLabel = $linkRenderer->makeLink(
+ SpecialPage::getTitleFor( 'Recentchanges' ),
+ $hitcountLabelMsg->text(),
+ [],
+ [ 'tagfilter' => $tag ]
+ );
+ } else {
+ $hitcountLabel = $hitcountLabelMsg->escaped();
+ }
+
+ // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
+ $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
+
+ // actions
+ $actionLinks = [];
+
+ // delete
+ if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
+ $actionLinks[] = $linkRenderer->makeKnownLink(
+ $this->getPageTitle( 'delete' ),
+ $this->msg( 'tags-delete' )->text(),
+ [],
+ [ 'tag' => $tag ] );
+ }
+
+ if ( $showManageActions ) { // we've already checked that the user had the requisite userright
+ // activate
+ if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
+ $actionLinks[] = $linkRenderer->makeKnownLink(
+ $this->getPageTitle( 'activate' ),
+ $this->msg( 'tags-activate' )->text(),
+ [],
+ [ 'tag' => $tag ] );
+ }
+
+ // deactivate
+ if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
+ $actionLinks[] = $linkRenderer->makeKnownLink(
+ $this->getPageTitle( 'deactivate' ),
+ $this->msg( 'tags-deactivate' )->text(),
+ [],
+ [ 'tag' => $tag ] );
+ }
+ }
+
+ if ( $showDeleteActions || $showManageActions ) {
+ $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
+ }
+
+ return Xml::tags( 'tr', null, $newRow ) . "\n";
+ }
+
+ public function processCreateTagForm( array $data, HTMLForm $form ) {
+ $context = $form->getContext();
+ $out = $context->getOutput();
+
+ $tag = trim( strval( $data['Tag'] ) );
+ $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1';
+ $status = ChangeTags::createTagWithChecks( $tag, $data['Reason'],
+ $context->getUser(), $ignoreWarnings );
+
+ if ( $status->isGood() ) {
+ $out->redirect( $this->getPageTitle()->getLocalURL() );
+ return true;
+ } elseif ( $status->isOK() ) {
+ // we have some warnings, so we show a confirmation form
+ $fields = [
+ 'Tag' => [
+ 'type' => 'hidden',
+ 'default' => $data['Tag'],
+ ],
+ 'Reason' => [
+ 'type' => 'hidden',
+ 'default' => $data['Reason'],
+ ],
+ 'IgnoreWarnings' => [
+ 'type' => 'hidden',
+ 'default' => '1',
+ ],
+ ];
+
+ // fool HTMLForm into thinking the form hasn't been submitted yet. Otherwise
+ // we get into an infinite loop!
+ $context->getRequest()->unsetVal( 'wpEditToken' );
+
+ $headerText = $this->msg( 'tags-create-warnings-above', $tag,
+ count( $status->getWarningsArray() ) )->parseAsBlock() .
+ $out->parse( $status->getWikiText() ) .
+ $this->msg( 'tags-create-warnings-below' )->parseAsBlock();
+
+ $subform = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
+ $subform->setAction( $this->getPageTitle( 'create' )->getLocalURL() );
+ $subform->setWrapperLegendMsg( 'tags-create-heading' );
+ $subform->setHeaderText( $headerText );
+ $subform->setSubmitCallback( [ $this, 'processCreateTagForm' ] );
+ $subform->setSubmitTextMsg( 'htmlform-yes' );
+ $subform->show();
+
+ $out->addBacklinkSubtitle( $this->getPageTitle() );
+ return true;
+ } else {
+ $out->addWikiText( "<div class=\"error\">\n" . $status->getWikiText() .
+ "\n</div>" );
+ return false;
+ }
+ }
+
+ protected function showDeleteTagForm( $tag ) {
+ $user = $this->getUser();
+ if ( !$user->isAllowed( 'deletechangetags' ) ) {
+ throw new PermissionsError( 'deletechangetags' );
+ }
+
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'tags-delete-title' ) );
+ $out->addBacklinkSubtitle( $this->getPageTitle() );
+
+ // is the tag actually able to be deleted?
+ $canDeleteResult = ChangeTags::canDeleteTag( $tag, $user );
+ if ( !$canDeleteResult->isGood() ) {
+ $out->addWikiText( "<div class=\"error\">\n" . $canDeleteResult->getWikiText() .
+ "\n</div>" );
+ if ( !$canDeleteResult->isOK() ) {
+ return;
+ }
+ }
+
+ $preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock();
+ $tagUsage = ChangeTags::tagUsageStatistics();
+ if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) {
+ $preText .= $this->msg( 'tags-delete-explanation-in-use', $tag,
+ $tagUsage[$tag] )->parseAsBlock();
+ }
+ $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
+
+ // see if the tag is in use
+ $this->softwareActivatedTags = array_fill_keys(
+ ChangeTags::listSoftwareActivatedTags(), true );
+ if ( isset( $this->softwareActivatedTags[$tag] ) ) {
+ $preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock();
+ }
+
+ $fields = [];
+ $fields['Reason'] = [
+ 'type' => 'text',
+ 'label' => $this->msg( 'tags-delete-reason' )->plain(),
+ 'size' => 50,
+ ];
+ $fields['HiddenTag'] = [
+ 'type' => 'hidden',
+ 'name' => 'tag',
+ 'default' => $tag,
+ 'required' => true,
+ ];
+
+ $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
+ $form->setAction( $this->getPageTitle( 'delete' )->getLocalURL() );
+ $form->tagAction = 'delete'; // custom property on HTMLForm object
+ $form->setSubmitCallback( [ $this, 'processTagForm' ] );
+ $form->setSubmitTextMsg( 'tags-delete-submit' );
+ $form->setSubmitDestructive(); // nasty!
+ $form->addPreText( $preText );
+ $form->show();
+ }
+
+ protected function showActivateDeactivateForm( $tag, $activate ) {
+ $actionStr = $activate ? 'activate' : 'deactivate';
+
+ $user = $this->getUser();
+ if ( !$user->isAllowed( 'managechangetags' ) ) {
+ throw new PermissionsError( 'managechangetags' );
+ }
+
+ $out = $this->getOutput();
+ // tags-activate-title, tags-deactivate-title
+ $out->setPageTitle( $this->msg( "tags-$actionStr-title" ) );
+ $out->addBacklinkSubtitle( $this->getPageTitle() );
+
+ // is it possible to do this?
+ $func = $activate ? 'canActivateTag' : 'canDeactivateTag';
+ $result = ChangeTags::$func( $tag, $user );
+ if ( !$result->isGood() ) {
+ $out->addWikiText( "<div class=\"error\">\n" . $result->getWikiText() .
+ "\n</div>" );
+ if ( !$result->isOK() ) {
+ return;
+ }
+ }
+
+ // tags-activate-question, tags-deactivate-question
+ $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
+
+ $fields = [];
+ // tags-activate-reason, tags-deactivate-reason
+ $fields['Reason'] = [
+ 'type' => 'text',
+ 'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
+ 'size' => 50,
+ ];
+ $fields['HiddenTag'] = [
+ 'type' => 'hidden',
+ 'name' => 'tag',
+ 'default' => $tag,
+ 'required' => true,
+ ];
+
+ $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
+ $form->setAction( $this->getPageTitle( $actionStr )->getLocalURL() );
+ $form->tagAction = $actionStr;
+ $form->setSubmitCallback( [ $this, 'processTagForm' ] );
+ // tags-activate-submit, tags-deactivate-submit
+ $form->setSubmitTextMsg( "tags-$actionStr-submit" );
+ $form->addPreText( $preText );
+ $form->show();
+ }
+
+ public function processTagForm( array $data, HTMLForm $form ) {
+ $context = $form->getContext();
+ $out = $context->getOutput();
+
+ $tag = $data['HiddenTag'];
+ $status = call_user_func( [ 'ChangeTags', "{$form->tagAction}TagWithChecks" ],
+ $tag, $data['Reason'], $context->getUser(), true );
+
+ if ( $status->isGood() ) {
+ $out->redirect( $this->getPageTitle()->getLocalURL() );
+ return true;
+ } elseif ( $status->isOK() && $form->tagAction === 'delete' ) {
+ // deletion succeeded, but hooks raised a warning
+ $out->addWikiText( $this->msg( 'tags-delete-warnings-after-delete', $tag,
+ count( $status->getWarningsArray() ) )->text() . "\n" .
+ $status->getWikitext() );
+ $out->addReturnTo( $this->getPageTitle() );
+ return true;
+ } else {
+ $out->addWikiText( "<div class=\"error\">\n" . $status->getWikitext() .
+ "\n</div>" );
+ return false;
+ }
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept.
+ *
+ * @return string[] subpages
+ */
+ public function getSubpagesForPrefixSearch() {
+ // The subpages does not have an own form, so not listing it at the moment
+ return [
+ // 'delete',
+ // 'activate',
+ // 'deactivate',
+ // 'create',
+ ];
+ }
+
+ protected function getGroupName() {
+ return 'changes';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialTrackingCategories.php b/www/wiki/includes/specials/SpecialTrackingCategories.php
new file mode 100644
index 00000000..e503d92b
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialTrackingCategories.php
@@ -0,0 +1,130 @@
+<?php
+/**
+ * Implements Special:TrackingCategories
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that displays list of tracking categories
+ * Tracking categories allow pages with certain characteristics to be tracked.
+ * It works by adding any such page to a category automatically.
+ * Category is specified by the tracking category's system message.
+ *
+ * @ingroup SpecialPage
+ * @since 1.23
+ */
+
+class SpecialTrackingCategories extends SpecialPage {
+ function __construct() {
+ parent::__construct( 'TrackingCategories' );
+ }
+
+ function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->getOutput()->allowClickjacking();
+ $this->getOutput()->addHTML(
+ Html::openElement( 'table', [ 'class' => 'mw-datatable',
+ 'id' => 'mw-trackingcategories-table' ] ) . "\n" .
+ "<thead><tr>
+ <th>" .
+ $this->msg( 'trackingcategories-msg' )->escaped() . "
+ </th>
+ <th>" .
+ $this->msg( 'trackingcategories-name' )->escaped() .
+ "</th>
+ <th>" .
+ $this->msg( 'trackingcategories-desc' )->escaped() . "
+ </th>
+ </tr></thead>"
+ );
+
+ $trackingCategories = new TrackingCategories( $this->getConfig() );
+ $categoryList = $trackingCategories->getTrackingCategories();
+
+ $batch = new LinkBatch();
+ foreach ( $categoryList as $catMsg => $data ) {
+ $batch->addObj( $data['msg'] );
+ foreach ( $data['cats'] as $catTitle ) {
+ $batch->addObj( $catTitle );
+ }
+ }
+ $batch->execute();
+
+ Hooks::run( 'SpecialTrackingCategories::preprocess', [ $this, $categoryList ] );
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ foreach ( $categoryList as $catMsg => $data ) {
+ $allMsgs = [];
+ $catDesc = $catMsg . '-desc';
+
+ $catMsgTitleText = $linkRenderer->makeLink(
+ $data['msg'],
+ $catMsg
+ );
+
+ foreach ( $data['cats'] as $catTitle ) {
+ $html = $linkRenderer->makeLink(
+ $catTitle,
+ $catTitle->getText()
+ );
+
+ Hooks::run( 'SpecialTrackingCategories::generateCatLink',
+ [ $this, $catTitle, &$html ] );
+
+ $allMsgs[] = $html;
+ }
+
+ # Extra message, when no category was found
+ if ( !count( $allMsgs ) ) {
+ $allMsgs[] = $this->msg( 'trackingcategories-disabled' )->parse();
+ }
+
+ /*
+ * Show category description if it exists as a system message
+ * as category-name-desc
+ */
+ $descMsg = $this->msg( $catDesc );
+ if ( $descMsg->isBlank() ) {
+ $descMsg = $this->msg( 'trackingcategories-nodesc' );
+ }
+
+ $this->getOutput()->addHTML(
+ Html::openElement( 'tr' ) .
+ Html::openElement( 'td', [ 'class' => 'mw-trackingcategories-name' ] ) .
+ $this->getLanguage()->commaList( array_unique( $allMsgs ) ) .
+ Html::closeElement( 'td' ) .
+ Html::openElement( 'td', [ 'class' => 'mw-trackingcategories-msg' ] ) .
+ $catMsgTitleText .
+ Html::closeElement( 'td' ) .
+ Html::openElement( 'td', [ 'class' => 'mw-trackingcategories-desc' ] ) .
+ $descMsg->parse() .
+ Html::closeElement( 'td' ) .
+ Html::closeElement( 'tr' )
+ );
+ }
+ $this->getOutput()->addHTML( Html::closeElement( 'table' ) );
+ }
+
+ protected function getGroupName() {
+ return 'pages';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUnblock.php b/www/wiki/includes/specials/SpecialUnblock.php
new file mode 100644
index 00000000..01125fcf
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUnblock.php
@@ -0,0 +1,278 @@
+<?php
+/**
+ * Implements Special:Unblock
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page for unblocking users
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialUnblock extends SpecialPage {
+
+ protected $target;
+ protected $type;
+ protected $block;
+
+ public function __construct() {
+ parent::__construct( 'Unblock', 'block' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function execute( $par ) {
+ $this->checkPermissions();
+ $this->checkReadOnly();
+
+ list( $this->target, $this->type ) = SpecialBlock::getTargetAndType( $par, $this->getRequest() );
+ $this->block = Block::newFromTarget( $this->target );
+ if ( $this->target instanceof User ) {
+ # Set the 'relevant user' in the skin, so it displays links like Contributions,
+ # User logs, UserRights, etc.
+ $this->getSkin()->setRelevantUser( $this->target );
+ }
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'unblockip' ) );
+ $out->addModules( [ 'mediawiki.special', 'mediawiki.userSuggest' ] );
+
+ $form = new HTMLForm( $this->getFields(), $this->getContext() );
+ $form->setWrapperLegendMsg( 'unblockip' );
+ $form->setSubmitCallback( [ __CLASS__, 'processUIUnblock' ] );
+ $form->setSubmitTextMsg( 'ipusubmit' );
+ $form->addPreText( $this->msg( 'unblockiptext' )->parseAsBlock() );
+
+ if ( $form->show() ) {
+ switch ( $this->type ) {
+ case Block::TYPE_IP:
+ $out->addWikiMsg( 'unblocked-ip', wfEscapeWikiText( $this->target ) );
+ break;
+ case Block::TYPE_USER:
+ $out->addWikiMsg( 'unblocked', wfEscapeWikiText( $this->target ) );
+ break;
+ case Block::TYPE_RANGE:
+ $out->addWikiMsg( 'unblocked-range', wfEscapeWikiText( $this->target ) );
+ break;
+ case Block::TYPE_ID:
+ case Block::TYPE_AUTO:
+ $out->addWikiMsg( 'unblocked-id', wfEscapeWikiText( $this->target ) );
+ break;
+ }
+ }
+ }
+
+ protected function getFields() {
+ $fields = [
+ 'Target' => [
+ 'type' => 'text',
+ 'label-message' => 'ipaddressorusername',
+ 'autofocus' => true,
+ 'size' => '45',
+ 'required' => true,
+ 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
+ ],
+ 'Name' => [
+ 'type' => 'info',
+ 'label-message' => 'ipaddressorusername',
+ ],
+ 'Reason' => [
+ 'type' => 'text',
+ 'label-message' => 'ipbreason',
+ ]
+ ];
+
+ if ( $this->block instanceof Block ) {
+ list( $target, $type ) = $this->block->getTargetAndType();
+
+ # Autoblocks are logged as "autoblock #123 because the IP was recently used by
+ # User:Foo, and we've just got any block, auto or not, that applies to a target
+ # the user has specified. Someone could be fishing to connect IPs to autoblocks,
+ # so don't show any distinction between unblocked IPs and autoblocked IPs
+ if ( $type == Block::TYPE_AUTO && $this->type == Block::TYPE_IP ) {
+ $fields['Target']['default'] = $this->target;
+ unset( $fields['Name'] );
+ } else {
+ $fields['Target']['default'] = $target;
+ $fields['Target']['type'] = 'hidden';
+ switch ( $type ) {
+ case Block::TYPE_IP:
+ $fields['Name']['default'] = $this->getLinkRenderer()->makeKnownLink(
+ SpecialPage::getTitleFor( 'Contributions', $target->getName() ),
+ $target->getName()
+ );
+ $fields['Name']['raw'] = true;
+ break;
+ case Block::TYPE_USER:
+ $fields['Name']['default'] = $this->getLinkRenderer()->makeLink(
+ $target->getUserPage(),
+ $target->getName()
+ );
+ $fields['Name']['raw'] = true;
+ break;
+
+ case Block::TYPE_RANGE:
+ $fields['Name']['default'] = $target;
+ break;
+
+ case Block::TYPE_AUTO:
+ $fields['Name']['default'] = $this->block->getRedactedName();
+ $fields['Name']['raw'] = true;
+ # Don't expose the real target of the autoblock
+ $fields['Target']['default'] = "#{$this->target}";
+ break;
+ }
+ // target is hidden, so the reason is the first element
+ $fields['Target']['autofocus'] = false;
+ $fields['Reason']['autofocus'] = true;
+ }
+ } else {
+ $fields['Target']['default'] = $this->target;
+ unset( $fields['Name'] );
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Submit callback for an HTMLForm object
+ * @param array $data
+ * @param HTMLForm $form
+ * @return array|bool Array(message key, parameters)
+ */
+ public static function processUIUnblock( array $data, HTMLForm $form ) {
+ return self::processUnblock( $data, $form->getContext() );
+ }
+
+ /**
+ * Process the form
+ *
+ * Change tags can be provided via $data['Tags'], but the calling function
+ * must check if the tags can be added by the user prior to this function.
+ *
+ * @param array $data
+ * @param IContextSource $context
+ * @throws ErrorPageError
+ * @return array|bool Array( Array( message key, parameters ) ) on failure, True on success
+ */
+ public static function processUnblock( array $data, IContextSource $context ) {
+ $performer = $context->getUser();
+ $target = $data['Target'];
+ $block = Block::newFromTarget( $data['Target'] );
+
+ if ( !$block instanceof Block ) {
+ return [ [ 'ipb_cant_unblock', $target ] ];
+ }
+
+ # T17810: blocked admins should have limited access here. This
+ # won't allow sysops to remove autoblocks on themselves, but they
+ # should have ipblock-exempt anyway
+ $status = SpecialBlock::checkUnblockSelf( $target, $performer );
+ if ( $status !== true ) {
+ throw new ErrorPageError( 'badaccess', $status );
+ }
+
+ # If the specified IP is a single address, and the block is a range block, don't
+ # unblock the whole range.
+ list( $target, $type ) = SpecialBlock::getTargetAndType( $target );
+ if ( $block->getType() == Block::TYPE_RANGE && $type == Block::TYPE_IP ) {
+ $range = $block->getTarget();
+
+ return [ [ 'ipb_blocked_as_range', $target, $range ] ];
+ }
+
+ # If the name was hidden and the blocking user cannot hide
+ # names, then don't allow any block removals...
+ if ( !$performer->isAllowed( 'hideuser' ) && $block->mHideName ) {
+ return [ 'unblock-hideuser' ];
+ }
+
+ $reason = [ 'hookaborted' ];
+ if ( !Hooks::run( 'UnblockUser', [ &$block, &$performer, &$reason ] ) ) {
+ return $reason;
+ }
+
+ # Delete block
+ if ( !$block->delete() ) {
+ return [ [ 'ipb_cant_unblock', htmlspecialchars( $block->getTarget() ) ] ];
+ }
+
+ Hooks::run( 'UnblockUserComplete', [ $block, $performer ] );
+
+ # Unset _deleted fields as needed
+ if ( $block->mHideName ) {
+ # Something is deeply FUBAR if this is not a User object, but who knows?
+ $id = $block->getTarget() instanceof User
+ ? $block->getTarget()->getId()
+ : User::idFromName( $block->getTarget() );
+
+ RevisionDeleteUser::unsuppressUserName( $block->getTarget(), $id );
+ }
+
+ # Redact the name (IP address) for autoblocks
+ if ( $block->getType() == Block::TYPE_AUTO ) {
+ $page = Title::makeTitle( NS_USER, '#' . $block->getId() );
+ } else {
+ $page = $block->getTarget() instanceof User
+ ? $block->getTarget()->getUserPage()
+ : Title::makeTitle( NS_USER, $block->getTarget() );
+ }
+
+ # Make log entry
+ $logEntry = new ManualLogEntry( 'block', 'unblock' );
+ $logEntry->setTarget( $page );
+ $logEntry->setComment( $data['Reason'] );
+ $logEntry->setPerformer( $performer );
+ if ( isset( $data['Tags'] ) ) {
+ $logEntry->setTags( $data['Tags'] );
+ }
+ $logId = $logEntry->insert();
+ $logEntry->publish( $logId );
+
+ return true;
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUncategorizedcategories.php b/www/wiki/includes/specials/SpecialUncategorizedcategories.php
new file mode 100644
index 00000000..5ff9e04e
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUncategorizedcategories.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Implements Special:Uncategorizedcategories
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists uncategorized categories
+ *
+ * @ingroup SpecialPage
+ */
+class UncategorizedCategoriesPage extends UncategorizedPagesPage {
+ /**
+ * Holds a list of categories, which shouldn't be listed on this special page,
+ * even if it is uncategorized.
+ * @var array
+ */
+ private $exceptionList = null;
+
+ function __construct( $name = 'Uncategorizedcategories' ) {
+ parent::__construct( $name );
+ $this->requestedNamespace = NS_CATEGORY;
+ }
+
+ /**
+ * Returns an array of category titles (usually without the namespace), which
+ * shouldn't be listed on this page, even if they're uncategorized.
+ *
+ * @return array
+ */
+ private function getExceptionList() {
+ if ( $this->exceptionList === null ) {
+ $exList = $this->msg( 'uncategorized-categories-exceptionlist' )
+ ->inContentLanguage()->plain();
+ $proposedTitles = explode( "\n", $exList );
+ foreach ( $proposedTitles as $count => $titleStr ) {
+ if ( strpos( $titleStr, '*' ) !== 0 ) {
+ continue;
+ }
+ $titleStr = preg_replace( "/^\\*\\s*/", '', $titleStr );
+ $title = Title::newFromText( $titleStr, NS_CATEGORY );
+ if ( $title && $title->getNamespace() !== NS_CATEGORY ) {
+ $title = Title::makeTitleSafe( NS_CATEGORY, $titleStr );
+ }
+ if ( $title ) {
+ $this->exceptionList[] = $title->getDBKey();
+ }
+ }
+ }
+ return $this->exceptionList;
+ }
+
+ public function getQueryInfo() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $query = parent::getQueryInfo();
+ $exceptionList = $this->getExceptionList();
+ if ( $exceptionList ) {
+ $query['conds'][] = 'page_title not in ( ' . $dbr->makeList( $exceptionList ) . ' )';
+ }
+
+ return $query;
+ }
+
+ /**
+ * Formats the result
+ * @param Skin $skin The current skin
+ * @param object $result The query result
+ * @return string The category link
+ */
+ function formatResult( $skin, $result ) {
+ $title = Title::makeTitle( NS_CATEGORY, $result->title );
+ $text = $title->getText();
+
+ return $this->getLinkRenderer()->makeKnownLink( $title, $text );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUncategorizedimages.php b/www/wiki/includes/specials/SpecialUncategorizedimages.php
new file mode 100644
index 00000000..1cb27a3f
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUncategorizedimages.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Implements Special:Uncategorizedimages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>
+ */
+
+/**
+ * Special page lists images which haven't been categorised
+ *
+ * @ingroup SpecialPage
+ * @todo FIXME: Use an instance of UncategorizedPagesPage or something
+ */
+class UncategorizedImagesPage extends ImageQueryPage {
+ function __construct( $name = 'Uncategorizedimages' ) {
+ parent::__construct( $name );
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function getQueryInfo() {
+ return [
+ 'tables' => [ 'page', 'categorylinks' ],
+ 'fields' => [ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_title' ],
+ 'conds' => [ 'cl_from IS NULL',
+ 'page_namespace' => NS_FILE,
+ 'page_is_redirect' => 0 ],
+ 'join_conds' => [ 'categorylinks' => [
+ 'LEFT JOIN', 'cl_from=page_id' ] ]
+ ];
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUncategorizedpages.php b/www/wiki/includes/specials/SpecialUncategorizedpages.php
new file mode 100644
index 00000000..30b33cc6
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUncategorizedpages.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Implements Special:Uncategorizedpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page looking for page without any category.
+ *
+ * @ingroup SpecialPage
+ * @todo FIXME: Make $requestedNamespace selectable, unify all subclasses into one
+ */
+class UncategorizedPagesPage extends PageQueryPage {
+ protected $requestedNamespace = false;
+
+ function __construct( $name = 'Uncategorizedpages' ) {
+ parent::__construct( $name );
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function getQueryInfo() {
+ return [
+ 'tables' => [ 'page', 'categorylinks' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_title'
+ ],
+ // default for page_namespace is all content namespaces (if requestedNamespace is false)
+ // otherwise, page_namespace is requestedNamespace
+ 'conds' => [
+ 'cl_from IS NULL',
+ 'page_namespace' => $this->requestedNamespace !== false
+ ? $this->requestedNamespace
+ : MWNamespace::getContentNamespaces(),
+ 'page_is_redirect' => 0
+ ],
+ 'join_conds' => [
+ 'categorylinks' => [ 'LEFT JOIN', 'cl_from = page_id' ]
+ ]
+ ];
+ }
+
+ function getOrderFields() {
+ // For some crazy reason ordering by a constant
+ // causes a filesort
+ if ( $this->requestedNamespace === false && count( MWNamespace::getContentNamespaces() ) > 1 ) {
+ return [ 'page_namespace', 'page_title' ];
+ }
+
+ return [ 'page_title' ];
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUncategorizedtemplates.php b/www/wiki/includes/specials/SpecialUncategorizedtemplates.php
new file mode 100644
index 00000000..af038fa8
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUncategorizedtemplates.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Implements Special:Uncategorizedtemplates
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>
+ */
+
+/**
+ * Special page lists all uncategorised pages in the
+ * template namespace
+ *
+ * @ingroup SpecialPage
+ */
+class UncategorizedTemplatesPage extends UncategorizedPagesPage {
+ public function __construct( $name = 'Uncategorizedtemplates' ) {
+ parent::__construct( $name );
+ $this->requestedNamespace = NS_TEMPLATE;
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUndelete.php b/www/wiki/includes/specials/SpecialUndelete.php
new file mode 100644
index 00000000..740207d6
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUndelete.php
@@ -0,0 +1,1188 @@
+<?php
+/**
+ * Implements Special:Undelete
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * Special page allowing users with the appropriate permissions to view
+ * and restore deleted content.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialUndelete extends SpecialPage {
+ private $mAction;
+ private $mTarget;
+ private $mTimestamp;
+ private $mRestore;
+ private $mRevdel;
+ private $mInvert;
+ private $mFilename;
+ private $mTargetTimestamp;
+ private $mAllowed;
+ private $mCanView;
+ private $mComment;
+ private $mToken;
+
+ /** @var Title */
+ private $mTargetObj;
+ /**
+ * @var string Search prefix
+ */
+ private $mSearchPrefix;
+
+ function __construct() {
+ parent::__construct( 'Undelete', 'deletedhistory' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ function loadRequest( $par ) {
+ $request = $this->getRequest();
+ $user = $this->getUser();
+
+ $this->mAction = $request->getVal( 'action' );
+ if ( $par !== null && $par !== '' ) {
+ $this->mTarget = $par;
+ } else {
+ $this->mTarget = $request->getVal( 'target' );
+ }
+
+ $this->mTargetObj = null;
+
+ if ( $this->mTarget !== null && $this->mTarget !== '' ) {
+ $this->mTargetObj = Title::newFromText( $this->mTarget );
+ }
+
+ $this->mSearchPrefix = $request->getText( 'prefix' );
+ $time = $request->getVal( 'timestamp' );
+ $this->mTimestamp = $time ? wfTimestamp( TS_MW, $time ) : '';
+ $this->mFilename = $request->getVal( 'file' );
+
+ $posted = $request->wasPosted() &&
+ $user->matchEditToken( $request->getVal( 'wpEditToken' ) );
+ $this->mRestore = $request->getCheck( 'restore' ) && $posted;
+ $this->mRevdel = $request->getCheck( 'revdel' ) && $posted;
+ $this->mInvert = $request->getCheck( 'invert' ) && $posted;
+ $this->mPreview = $request->getCheck( 'preview' ) && $posted;
+ $this->mDiff = $request->getCheck( 'diff' );
+ $this->mDiffOnly = $request->getBool( 'diffonly', $this->getUser()->getOption( 'diffonly' ) );
+ $this->mComment = $request->getText( 'wpComment' );
+ $this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) && $user->isAllowed( 'suppressrevision' );
+ $this->mToken = $request->getVal( 'token' );
+
+ if ( $this->isAllowed( 'undelete' ) && !$user->isBlocked() ) {
+ $this->mAllowed = true; // user can restore
+ $this->mCanView = true; // user can view content
+ } elseif ( $this->isAllowed( 'deletedtext' ) ) {
+ $this->mAllowed = false; // user cannot restore
+ $this->mCanView = true; // user can view content
+ $this->mRestore = false;
+ } else { // user can only view the list of revisions
+ $this->mAllowed = false;
+ $this->mCanView = false;
+ $this->mTimestamp = '';
+ $this->mRestore = false;
+ }
+
+ if ( $this->mRestore || $this->mInvert ) {
+ $timestamps = [];
+ $this->mFileVersions = [];
+ foreach ( $request->getValues() as $key => $val ) {
+ $matches = [];
+ if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) {
+ array_push( $timestamps, $matches[1] );
+ }
+
+ if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) {
+ $this->mFileVersions[] = intval( $matches[1] );
+ }
+ }
+ rsort( $timestamps );
+ $this->mTargetTimestamp = $timestamps;
+ }
+ }
+
+ /**
+ * Checks whether a user is allowed the permission for the
+ * specific title if one is set.
+ *
+ * @param string $permission
+ * @param User $user
+ * @return bool
+ */
+ protected function isAllowed( $permission, User $user = null ) {
+ $user = $user ?: $this->getUser();
+ if ( $this->mTargetObj !== null ) {
+ return $this->mTargetObj->userCan( $permission, $user );
+ } else {
+ return $user->isAllowed( $permission );
+ }
+ }
+
+ function userCanExecute( User $user ) {
+ return $this->isAllowed( $this->mRestriction, $user );
+ }
+
+ function execute( $par ) {
+ $this->useTransactionalTimeLimit();
+
+ $user = $this->getUser();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $this->loadRequest( $par );
+ $this->checkPermissions(); // Needs to be after mTargetObj is set
+
+ $out = $this->getOutput();
+
+ if ( is_null( $this->mTargetObj ) ) {
+ $out->addWikiMsg( 'undelete-header' );
+
+ # Not all users can just browse every deleted page from the list
+ if ( $user->isAllowed( 'browsearchive' ) ) {
+ $this->showSearchForm();
+ }
+
+ return;
+ }
+
+ $this->addHelpLink( 'Help:Undelete' );
+ if ( $this->mAllowed ) {
+ $out->setPageTitle( $this->msg( 'undeletepage' ) );
+ } else {
+ $out->setPageTitle( $this->msg( 'viewdeletedpage' ) );
+ }
+
+ $this->getSkin()->setRelevantTitle( $this->mTargetObj );
+
+ if ( $this->mTimestamp !== '' ) {
+ $this->showRevision( $this->mTimestamp );
+ } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) {
+ $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename );
+ // Check if user is allowed to see this file
+ if ( !$file->exists() ) {
+ $out->addWikiMsg( 'filedelete-nofile', $this->mFilename );
+ } elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) {
+ if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) {
+ throw new PermissionsError( 'suppressrevision' );
+ } else {
+ throw new PermissionsError( 'deletedtext' );
+ }
+ } elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) {
+ $this->showFileConfirmationForm( $this->mFilename );
+ } else {
+ $this->showFile( $this->mFilename );
+ }
+ } elseif ( $this->mAction === "submit" ) {
+ if ( $this->mRestore ) {
+ $this->undelete();
+ } elseif ( $this->mRevdel ) {
+ $this->redirectToRevDel();
+ }
+
+ } else {
+ $this->showHistory();
+ }
+ }
+
+ /**
+ * Convert submitted form data to format expected by RevisionDelete and
+ * redirect the request
+ */
+ private function redirectToRevDel() {
+ $archive = new PageArchive( $this->mTargetObj );
+
+ $revisions = [];
+
+ foreach ( $this->getRequest()->getValues() as $key => $val ) {
+ $matches = [];
+ if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) {
+ $revisions[ $archive->getRevision( $matches[1] )->getId() ] = 1;
+ }
+ }
+ $query = [
+ "type" => "revision",
+ "ids" => $revisions,
+ "target" => $this->mTargetObj->getPrefixedText()
+ ];
+ $url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query );
+ $this->getOutput()->redirect( $url );
+ }
+
+ function showSearchForm() {
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'undelete-search-title' ) );
+ $fuzzySearch = $this->getRequest()->getVal( 'fuzzy', true );
+
+ $out->enableOOUI();
+
+ $fields[] = new OOUI\ActionFieldLayout(
+ new OOUI\TextInputWidget( [
+ 'name' => 'prefix',
+ 'inputId' => 'prefix',
+ 'infusable' => true,
+ 'value' => $this->mSearchPrefix,
+ 'autofocus' => true,
+ ] ),
+ new OOUI\ButtonInputWidget( [
+ 'label' => $this->msg( 'undelete-search-submit' )->text(),
+ 'flags' => [ 'primary', 'progressive' ],
+ 'inputId' => 'searchUndelete',
+ 'type' => 'submit',
+ ] ),
+ [
+ 'label' => new OOUI\HtmlSnippet(
+ $this->msg(
+ $fuzzySearch ? 'undelete-search-full' : 'undelete-search-prefix'
+ )->parse()
+ ),
+ 'align' => 'left',
+ ]
+ );
+
+ $fieldset = new OOUI\FieldsetLayout( [
+ 'label' => $this->msg( 'undelete-search-box' )->text(),
+ 'items' => $fields,
+ ] );
+
+ $form = new OOUI\FormLayout( [
+ 'method' => 'get',
+ 'action' => wfScript(),
+ ] );
+
+ $form->appendContent(
+ $fieldset,
+ new OOUI\HtmlSnippet(
+ Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
+ Html::hidden( 'fuzzy', $fuzzySearch )
+ )
+ );
+
+ $out->addHTML(
+ new OOUI\PanelLayout( [
+ 'expanded' => false,
+ 'padded' => true,
+ 'framed' => true,
+ 'content' => $form,
+ ] )
+ );
+
+ # List undeletable articles
+ if ( $this->mSearchPrefix ) {
+ // For now, we enable search engine match only when specifically asked to
+ // by using fuzzy=1 parameter.
+ if ( $fuzzySearch ) {
+ $result = PageArchive::listPagesBySearch( $this->mSearchPrefix );
+ } else {
+ $result = PageArchive::listPagesByPrefix( $this->mSearchPrefix );
+ }
+ $this->showList( $result );
+ }
+ }
+
+ /**
+ * Generic list of deleted pages
+ *
+ * @param ResultWrapper $result
+ * @return bool
+ */
+ private function showList( $result ) {
+ $out = $this->getOutput();
+
+ if ( $result->numRows() == 0 ) {
+ $out->addWikiMsg( 'undelete-no-results' );
+
+ return false;
+ }
+
+ $out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) );
+
+ $linkRenderer = $this->getLinkRenderer();
+ $undelete = $this->getPageTitle();
+ $out->addHTML( "<ul id='undeleteResultsList'>\n" );
+ foreach ( $result as $row ) {
+ $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
+ if ( $title !== null ) {
+ $item = $linkRenderer->makeKnownLink(
+ $undelete,
+ $title->getPrefixedText(),
+ [],
+ [ 'target' => $title->getPrefixedText() ]
+ );
+ } else {
+ // The title is no longer valid, show as text
+ $item = Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ $row->ar_namespace,
+ $row->ar_title
+ )
+ );
+ }
+ $revs = $this->msg( 'undeleterevisions' )->numParams( $row->count )->parse();
+ $out->addHTML( "<li class='undeleteResult'>{$item} ({$revs})</li>\n" );
+ }
+ $result->free();
+ $out->addHTML( "</ul>\n" );
+
+ return true;
+ }
+
+ private function showRevision( $timestamp ) {
+ if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) {
+ return;
+ }
+
+ $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
+ if ( !Hooks::run( 'UndeleteForm::showRevision', [ &$archive, $this->mTargetObj ] ) ) {
+ return;
+ }
+ $rev = $archive->getRevision( $timestamp );
+
+ $out = $this->getOutput();
+ $user = $this->getUser();
+
+ if ( !$rev ) {
+ $out->addWikiMsg( 'undeleterevision-missing' );
+
+ return;
+ }
+
+ if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+ if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) {
+ $out->wrapWikiMsg(
+ "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ $rev->isDeleted( Revision::DELETED_RESTRICTED ) ?
+ 'rev-suppressed-text-permission' : 'rev-deleted-text-permission'
+ );
+
+ return;
+ }
+
+ $out->wrapWikiMsg(
+ "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ $rev->isDeleted( Revision::DELETED_RESTRICTED ) ?
+ 'rev-suppressed-text-view' : 'rev-deleted-text-view'
+ );
+ $out->addHTML( '<br />' );
+ // and we are allowed to see...
+ }
+
+ if ( $this->mDiff ) {
+ $previousRev = $archive->getPreviousRevision( $timestamp );
+ if ( $previousRev ) {
+ $this->showDiff( $previousRev, $rev );
+ if ( $this->mDiffOnly ) {
+ return;
+ }
+
+ $out->addHTML( '<hr />' );
+ } else {
+ $out->addWikiMsg( 'undelete-nodiff' );
+ }
+ }
+
+ $link = $this->getLinkRenderer()->makeKnownLink(
+ $this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ),
+ $this->mTargetObj->getPrefixedText()
+ );
+
+ $lang = $this->getLanguage();
+
+ // date and time are separate parameters to facilitate localisation.
+ // $time is kept for backward compat reasons.
+ $time = $lang->userTimeAndDate( $timestamp, $user );
+ $d = $lang->userDate( $timestamp, $user );
+ $t = $lang->userTime( $timestamp, $user );
+ $userLink = Linker::revUserTools( $rev );
+
+ $content = $rev->getContent( Revision::FOR_THIS_USER, $user );
+
+ $isText = ( $content instanceof TextContent );
+
+ if ( $this->mPreview || $isText ) {
+ $openDiv = '<div id="mw-undelete-revision" class="mw-warning">';
+ } else {
+ $openDiv = '<div id="mw-undelete-revision">';
+ }
+ $out->addHTML( $openDiv );
+
+ // Revision delete links
+ if ( !$this->mDiff ) {
+ $revdel = Linker::getRevDeleteLink( $user, $rev, $this->mTargetObj );
+ if ( $revdel ) {
+ $out->addHTML( "$revdel " );
+ }
+ }
+
+ $out->addHTML( $this->msg( 'undelete-revision' )->rawParams( $link )->params(
+ $time )->rawParams( $userLink )->params( $d, $t )->parse() . '</div>' );
+
+ if ( !Hooks::run( 'UndeleteShowRevision', [ $this->mTargetObj, $rev ] ) ) {
+ return;
+ }
+
+ if ( ( $this->mPreview || !$isText ) && $content ) {
+ // NOTE: non-text content has no source view, so always use rendered preview
+
+ // Hide [edit]s
+ $popts = $out->parserOptions();
+ $popts->setEditSection( false );
+
+ $pout = $content->getParserOutput( $this->mTargetObj, $rev->getId(), $popts, true );
+ $out->addParserOutput( $pout );
+ }
+
+ if ( $isText ) {
+ // source view for textual content
+ $sourceView = Xml::element(
+ 'textarea',
+ [
+ 'readonly' => 'readonly',
+ 'cols' => 80,
+ 'rows' => 25
+ ],
+ $content->getNativeData() . "\n"
+ );
+
+ $previewButton = Xml::element( 'input', [
+ 'type' => 'submit',
+ 'name' => 'preview',
+ 'value' => $this->msg( 'showpreview' )->text()
+ ] );
+ } else {
+ $sourceView = '';
+ $previewButton = '';
+ }
+
+ $diffButton = Xml::element( 'input', [
+ 'name' => 'diff',
+ 'type' => 'submit',
+ 'value' => $this->msg( 'showdiff' )->text() ] );
+
+ $out->addHTML(
+ $sourceView .
+ Xml::openElement( 'div', [
+ 'style' => 'clear: both' ] ) .
+ Xml::openElement( 'form', [
+ 'method' => 'post',
+ 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) .
+ Xml::element( 'input', [
+ 'type' => 'hidden',
+ 'name' => 'target',
+ 'value' => $this->mTargetObj->getPrefixedDBkey() ] ) .
+ Xml::element( 'input', [
+ 'type' => 'hidden',
+ 'name' => 'timestamp',
+ 'value' => $timestamp ] ) .
+ Xml::element( 'input', [
+ 'type' => 'hidden',
+ 'name' => 'wpEditToken',
+ 'value' => $user->getEditToken() ] ) .
+ $previewButton .
+ $diffButton .
+ Xml::closeElement( 'form' ) .
+ Xml::closeElement( 'div' )
+ );
+ }
+
+ /**
+ * Build a diff display between this and the previous either deleted
+ * or non-deleted edit.
+ *
+ * @param Revision $previousRev
+ * @param Revision $currentRev
+ * @return string HTML
+ */
+ function showDiff( $previousRev, $currentRev ) {
+ $diffContext = clone $this->getContext();
+ $diffContext->setTitle( $currentRev->getTitle() );
+ $diffContext->setWikiPage( WikiPage::factory( $currentRev->getTitle() ) );
+
+ $diffEngine = $currentRev->getContentHandler()->createDifferenceEngine( $diffContext );
+ $diffEngine->showDiffStyle();
+
+ $formattedDiff = $diffEngine->generateContentDiffBody(
+ $previousRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ),
+ $currentRev->getContent( Revision::FOR_THIS_USER, $this->getUser() )
+ );
+
+ $formattedDiff = $diffEngine->addHeader(
+ $formattedDiff,
+ $this->diffHeader( $previousRev, 'o' ),
+ $this->diffHeader( $currentRev, 'n' )
+ );
+
+ $this->getOutput()->addHTML( "<div>$formattedDiff</div>\n" );
+ }
+
+ /**
+ * @param Revision $rev
+ * @param string $prefix
+ * @return string
+ */
+ private function diffHeader( $rev, $prefix ) {
+ $isDeleted = !( $rev->getId() && $rev->getTitle() );
+ if ( $isDeleted ) {
+ /// @todo FIXME: $rev->getTitle() is null for deleted revs...?
+ $targetPage = $this->getPageTitle();
+ $targetQuery = [
+ 'target' => $this->mTargetObj->getPrefixedText(),
+ 'timestamp' => wfTimestamp( TS_MW, $rev->getTimestamp() )
+ ];
+ } else {
+ /// @todo FIXME: getId() may return non-zero for deleted revs...
+ $targetPage = $rev->getTitle();
+ $targetQuery = [ 'oldid' => $rev->getId() ];
+ }
+
+ // Add show/hide deletion links if available
+ $user = $this->getUser();
+ $lang = $this->getLanguage();
+ $rdel = Linker::getRevDeleteLink( $user, $rev, $this->mTargetObj );
+
+ if ( $rdel ) {
+ $rdel = " $rdel";
+ }
+
+ $minor = $rev->isMinor() ? ChangesList::flag( 'minor' ) : '';
+
+ $tags = wfGetDB( DB_REPLICA )->selectField(
+ 'tag_summary',
+ 'ts_tags',
+ [ 'ts_rev_id' => $rev->getId() ],
+ __METHOD__
+ );
+ $tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() );
+
+ // FIXME This is reimplementing DifferenceEngine#getRevisionHeader
+ // and partially #showDiffPage, but worse
+ return '<div id="mw-diff-' . $prefix . 'title1"><strong>' .
+ $this->getLinkRenderer()->makeLink(
+ $targetPage,
+ $this->msg(
+ 'revisionasof',
+ $lang->userTimeAndDate( $rev->getTimestamp(), $user ),
+ $lang->userDate( $rev->getTimestamp(), $user ),
+ $lang->userTime( $rev->getTimestamp(), $user )
+ )->text(),
+ [],
+ $targetQuery
+ ) .
+ '</strong></div>' .
+ '<div id="mw-diff-' . $prefix . 'title2">' .
+ Linker::revUserTools( $rev ) . '<br />' .
+ '</div>' .
+ '<div id="mw-diff-' . $prefix . 'title3">' .
+ $minor . Linker::revComment( $rev ) . $rdel . '<br />' .
+ '</div>' .
+ '<div id="mw-diff-' . $prefix . 'title5">' .
+ $tagSummary[0] . '<br />' .
+ '</div>';
+ }
+
+ /**
+ * Show a form confirming whether a tokenless user really wants to see a file
+ * @param string $key
+ */
+ private function showFileConfirmationForm( $key ) {
+ $out = $this->getOutput();
+ $lang = $this->getLanguage();
+ $user = $this->getUser();
+ $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename );
+ $out->addWikiMsg( 'undelete-show-file-confirm',
+ $this->mTargetObj->getText(),
+ $lang->userDate( $file->getTimestamp(), $user ),
+ $lang->userTime( $file->getTimestamp(), $user ) );
+ $out->addHTML(
+ Xml::openElement( 'form', [
+ 'method' => 'POST',
+ 'action' => $this->getPageTitle()->getLocalURL( [
+ 'target' => $this->mTarget,
+ 'file' => $key,
+ 'token' => $user->getEditToken( $key ),
+ ] ),
+ ]
+ ) .
+ Xml::submitButton( $this->msg( 'undelete-show-file-submit' )->text() ) .
+ '</form>'
+ );
+ }
+
+ /**
+ * Show a deleted file version requested by the visitor.
+ * @param string $key
+ */
+ private function showFile( $key ) {
+ $this->getOutput()->disable();
+
+ # We mustn't allow the output to be CDN cached, otherwise
+ # if an admin previews a deleted image, and it's cached, then
+ # a user without appropriate permissions can toddle off and
+ # nab the image, and CDN will serve it
+ $response = $this->getRequest()->response();
+ $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
+ $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
+ $response->header( 'Pragma: no-cache' );
+
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key;
+ $repo->streamFile( $path );
+ }
+
+ protected function showHistory() {
+ $this->checkReadOnly();
+
+ $out = $this->getOutput();
+ if ( $this->mAllowed ) {
+ $out->addModules( 'mediawiki.special.undelete' );
+ }
+ $out->wrapWikiMsg(
+ "<div class='mw-undelete-pagetitle'>\n$1\n</div>\n",
+ [ 'undeletepagetitle', wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) ]
+ );
+
+ $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
+ Hooks::run( 'UndeleteForm::showHistory', [ &$archive, $this->mTargetObj ] );
+
+ $out->addHTML( '<div class="mw-undelete-history">' );
+ if ( $this->mAllowed ) {
+ $out->addWikiMsg( 'undeletehistory' );
+ $out->addWikiMsg( 'undeleterevdel' );
+ } else {
+ $out->addWikiMsg( 'undeletehistorynoadmin' );
+ }
+ $out->addHTML( '</div>' );
+
+ # List all stored revisions
+ $revisions = $archive->listRevisions();
+ $files = $archive->listFiles();
+
+ $haveRevisions = $revisions && $revisions->numRows() > 0;
+ $haveFiles = $files && $files->numRows() > 0;
+
+ # Batch existence check on user and talk pages
+ if ( $haveRevisions ) {
+ $batch = new LinkBatch();
+ foreach ( $revisions as $row ) {
+ $batch->addObj( Title::makeTitleSafe( NS_USER, $row->ar_user_text ) );
+ $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->ar_user_text ) );
+ }
+ $batch->execute();
+ $revisions->seek( 0 );
+ }
+ if ( $haveFiles ) {
+ $batch = new LinkBatch();
+ foreach ( $files as $row ) {
+ $batch->addObj( Title::makeTitleSafe( NS_USER, $row->fa_user_text ) );
+ $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->fa_user_text ) );
+ }
+ $batch->execute();
+ $files->seek( 0 );
+ }
+
+ if ( $this->mAllowed ) {
+ $out->enableOOUI();
+
+ $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] );
+ # Start the form here
+ $form = new OOUI\FormLayout( [
+ 'method' => 'post',
+ 'action' => $action,
+ 'id' => 'undelete',
+ ] );
+ }
+
+ # Show relevant lines from the deletion log:
+ $deleteLogPage = new LogPage( 'delete' );
+ $out->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) . "\n" );
+ LogEventsList::showLogExtract( $out, 'delete', $this->mTargetObj );
+ # Show relevant lines from the suppression log:
+ $suppressLogPage = new LogPage( 'suppress' );
+ if ( $this->getUser()->isAllowed( 'suppressionlog' ) ) {
+ $out->addHTML( Xml::element( 'h2', null, $suppressLogPage->getName()->text() ) . "\n" );
+ LogEventsList::showLogExtract( $out, 'suppress', $this->mTargetObj );
+ }
+
+ if ( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) {
+ $fields[] = new OOUI\Layout( [
+ 'content' => new OOUI\HtmlSnippet( $this->msg( 'undeleteextrahelp' )->parseAsBlock() )
+ ] );
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\TextInputWidget( [
+ 'name' => 'wpComment',
+ 'inputId' => 'wpComment',
+ 'infusable' => true,
+ 'value' => $this->mComment,
+ 'autofocus' => true,
+ ] ),
+ [
+ 'label' => $this->msg( 'undeletecomment' )->text(),
+ 'align' => 'top',
+ ]
+ );
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\Widget( [
+ 'content' => new OOUI\HorizontalLayout( [
+ 'items' => [
+ new OOUI\ButtonInputWidget( [
+ 'name' => 'restore',
+ 'inputId' => 'mw-undelete-submit',
+ 'value' => '1',
+ 'label' => $this->msg( 'undeletebtn' )->text(),
+ 'flags' => [ 'primary', 'progressive' ],
+ 'type' => 'submit',
+ ] ),
+ new OOUI\ButtonInputWidget( [
+ 'name' => 'invert',
+ 'inputId' => 'mw-undelete-invert',
+ 'value' => '1',
+ 'label' => $this->msg( 'undeleteinvert' )->text()
+ ] ),
+ ]
+ ] )
+ ] )
+ );
+
+ if ( $this->getUser()->isAllowed( 'suppressrevision' ) ) {
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpUnsuppress',
+ 'inputId' => 'mw-undelete-unsuppress',
+ 'value' => '1',
+ ] ),
+ [
+ 'label' => $this->msg( 'revdelete-unsuppress' )->text(),
+ 'align' => 'inline',
+ ]
+ );
+ }
+
+ $fieldset = new OOUI\FieldsetLayout( [
+ 'label' => $this->msg( 'undelete-fieldset-title' )->text(),
+ 'id' => 'mw-undelete-table',
+ 'items' => $fields,
+ ] );
+
+ $form->appendContent(
+ new OOUI\PanelLayout( [
+ 'expanded' => false,
+ 'padded' => true,
+ 'framed' => true,
+ 'content' => $fieldset,
+ ] ),
+ new OOUI\HtmlSnippet(
+ Html::hidden( 'target', $this->mTarget ) .
+ Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() )
+ )
+ );
+ }
+
+ $history = '';
+ $history .= Xml::element( 'h2', null, $this->msg( 'history' )->text() ) . "\n";
+
+ if ( $haveRevisions ) {
+ # Show the page's stored (deleted) history
+
+ if ( $this->getUser()->isAllowed( 'deleterevision' ) ) {
+ $history .= Html::element(
+ 'button',
+ [
+ 'name' => 'revdel',
+ 'type' => 'submit',
+ 'class' => 'deleterevision-log-submit mw-log-deleterevision-button'
+ ],
+ $this->msg( 'showhideselectedversions' )->text()
+ ) . "\n";
+ }
+
+ $history .= '<ul class="mw-undelete-revlist">';
+ $remaining = $revisions->numRows();
+ $earliestLiveTime = $this->mTargetObj->getEarliestRevTime();
+
+ foreach ( $revisions as $row ) {
+ $remaining--;
+ $history .= $this->formatRevisionRow( $row, $earliestLiveTime, $remaining );
+ }
+ $revisions->free();
+ $history .= '</ul>';
+ } else {
+ $out->addWikiMsg( 'nohistory' );
+ }
+
+ if ( $haveFiles ) {
+ $history .= Xml::element( 'h2', null, $this->msg( 'filehist' )->text() ) . "\n";
+ $history .= '<ul class="mw-undelete-revlist">';
+ foreach ( $files as $row ) {
+ $history .= $this->formatFileRow( $row );
+ }
+ $files->free();
+ $history .= '</ul>';
+ }
+
+ if ( $this->mAllowed ) {
+ # Slip in the hidden controls here
+ $misc = Html::hidden( 'target', $this->mTarget );
+ $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
+ $history .= $misc;
+
+ $form->appendContent( new OOUI\HtmlSnippet( $history ) );
+ $out->addHTML( $form );
+ } else {
+ $out->addHTML( $history );
+ }
+
+ return true;
+ }
+
+ protected function formatRevisionRow( $row, $earliestLiveTime, $remaining ) {
+ $rev = Revision::newFromArchiveRow( $row,
+ [
+ 'title' => $this->mTargetObj
+ ] );
+
+ $revTextSize = '';
+ $ts = wfTimestamp( TS_MW, $row->ar_timestamp );
+ // Build checkboxen...
+ if ( $this->mAllowed ) {
+ if ( $this->mInvert ) {
+ if ( in_array( $ts, $this->mTargetTimestamp ) ) {
+ $checkBox = Xml::check( "ts$ts" );
+ } else {
+ $checkBox = Xml::check( "ts$ts", true );
+ }
+ } else {
+ $checkBox = Xml::check( "ts$ts" );
+ }
+ } else {
+ $checkBox = '';
+ }
+
+ // Build page & diff links...
+ $user = $this->getUser();
+ if ( $this->mCanView ) {
+ $titleObj = $this->getPageTitle();
+ # Last link
+ if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
+ $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
+ $last = $this->msg( 'diff' )->escaped();
+ } elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) {
+ $pageLink = $this->getPageLink( $rev, $titleObj, $ts );
+ $last = $this->getLinkRenderer()->makeKnownLink(
+ $titleObj,
+ $this->msg( 'diff' )->text(),
+ [],
+ [
+ 'target' => $this->mTargetObj->getPrefixedText(),
+ 'timestamp' => $ts,
+ 'diff' => 'prev'
+ ]
+ );
+ } else {
+ $pageLink = $this->getPageLink( $rev, $titleObj, $ts );
+ $last = $this->msg( 'diff' )->escaped();
+ }
+ } else {
+ $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
+ $last = $this->msg( 'diff' )->escaped();
+ }
+
+ // User links
+ $userLink = Linker::revUserTools( $rev );
+
+ // Minor edit
+ $minor = $rev->isMinor() ? ChangesList::flag( 'minor' ) : '';
+
+ // Revision text size
+ $size = $row->ar_len;
+ if ( !is_null( $size ) ) {
+ $revTextSize = Linker::formatRevisionSize( $size );
+ }
+
+ // Edit summary
+ $comment = Linker::revComment( $rev );
+
+ // Tags
+ $attribs = [];
+ list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow(
+ $row->ts_tags,
+ 'deletedhistory',
+ $this->getContext()
+ );
+ if ( $classes ) {
+ $attribs['class'] = implode( ' ', $classes );
+ }
+
+ $revisionRow = $this->msg( 'undelete-revision-row2' )
+ ->rawParams(
+ $checkBox,
+ $last,
+ $pageLink,
+ $userLink,
+ $minor,
+ $revTextSize,
+ $comment,
+ $tagSummary
+ )
+ ->escaped();
+
+ return Xml::tags( 'li', $attribs, $revisionRow ) . "\n";
+ }
+
+ private function formatFileRow( $row ) {
+ $file = ArchivedFile::newFromRow( $row );
+ $ts = wfTimestamp( TS_MW, $row->fa_timestamp );
+ $user = $this->getUser();
+
+ $checkBox = '';
+ if ( $this->mCanView && $row->fa_storage_key ) {
+ if ( $this->mAllowed ) {
+ $checkBox = Xml::check( 'fileid' . $row->fa_id );
+ }
+ $key = urlencode( $row->fa_storage_key );
+ $pageLink = $this->getFileLink( $file, $this->getPageTitle(), $ts, $key );
+ } else {
+ $pageLink = $this->getLanguage()->userTimeAndDate( $ts, $user );
+ }
+ $userLink = $this->getFileUser( $file );
+ $data = $this->msg( 'widthheight' )->numParams( $row->fa_width, $row->fa_height )->text();
+ $bytes = $this->msg( 'parentheses' )
+ ->rawParams( $this->msg( 'nbytes' )->numParams( $row->fa_size )->text() )
+ ->plain();
+ $data = htmlspecialchars( $data . ' ' . $bytes );
+ $comment = $this->getFileComment( $file );
+
+ // Add show/hide deletion links if available
+ $canHide = $this->isAllowed( 'deleterevision' );
+ if ( $canHide || ( $file->getVisibility() && $this->isAllowed( 'deletedhistory' ) ) ) {
+ if ( !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
+ // Revision was hidden from sysops
+ $revdlink = Linker::revDeleteLinkDisabled( $canHide );
+ } else {
+ $query = [
+ 'type' => 'filearchive',
+ 'target' => $this->mTargetObj->getPrefixedDBkey(),
+ 'ids' => $row->fa_id
+ ];
+ $revdlink = Linker::revDeleteLink( $query,
+ $file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
+ }
+ } else {
+ $revdlink = '';
+ }
+
+ return "<li>$checkBox $revdlink $pageLink . . $userLink $data $comment</li>\n";
+ }
+
+ /**
+ * Fetch revision text link if it's available to all users
+ *
+ * @param Revision $rev
+ * @param Title $titleObj
+ * @param string $ts Timestamp
+ * @return string
+ */
+ function getPageLink( $rev, $titleObj, $ts ) {
+ $user = $this->getUser();
+ $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
+
+ if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) {
+ return '<span class="history-deleted">' . $time . '</span>';
+ }
+
+ $link = $this->getLinkRenderer()->makeKnownLink(
+ $titleObj,
+ $time,
+ [],
+ [
+ 'target' => $this->mTargetObj->getPrefixedText(),
+ 'timestamp' => $ts
+ ]
+ );
+
+ if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $link = '<span class="history-deleted">' . $link . '</span>';
+ }
+
+ return $link;
+ }
+
+ /**
+ * Fetch image view link if it's available to all users
+ *
+ * @param File|ArchivedFile $file
+ * @param Title $titleObj
+ * @param string $ts A timestamp
+ * @param string $key A storage key
+ *
+ * @return string HTML fragment
+ */
+ function getFileLink( $file, $titleObj, $ts, $key ) {
+ $user = $this->getUser();
+ $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
+
+ if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
+ return '<span class="history-deleted">' . $time . '</span>';
+ }
+
+ $link = $this->getLinkRenderer()->makeKnownLink(
+ $titleObj,
+ $time,
+ [],
+ [
+ 'target' => $this->mTargetObj->getPrefixedText(),
+ 'file' => $key,
+ 'token' => $user->getEditToken( $key )
+ ]
+ );
+
+ if ( $file->isDeleted( File::DELETED_FILE ) ) {
+ $link = '<span class="history-deleted">' . $link . '</span>';
+ }
+
+ return $link;
+ }
+
+ /**
+ * Fetch file's user id if it's available to this user
+ *
+ * @param File|ArchivedFile $file
+ * @return string HTML fragment
+ */
+ function getFileUser( $file ) {
+ if ( !$file->userCan( File::DELETED_USER, $this->getUser() ) ) {
+ return '<span class="history-deleted">' .
+ $this->msg( 'rev-deleted-user' )->escaped() .
+ '</span>';
+ }
+
+ $link = Linker::userLink( $file->getRawUser(), $file->getRawUserText() ) .
+ Linker::userToolLinks( $file->getRawUser(), $file->getRawUserText() );
+
+ if ( $file->isDeleted( File::DELETED_USER ) ) {
+ $link = '<span class="history-deleted">' . $link . '</span>';
+ }
+
+ return $link;
+ }
+
+ /**
+ * Fetch file upload comment if it's available to this user
+ *
+ * @param File|ArchivedFile $file
+ * @return string HTML fragment
+ */
+ function getFileComment( $file ) {
+ if ( !$file->userCan( File::DELETED_COMMENT, $this->getUser() ) ) {
+ return '<span class="history-deleted"><span class="comment">' .
+ $this->msg( 'rev-deleted-comment' )->escaped() . '</span></span>';
+ }
+
+ $link = Linker::commentBlock( $file->getRawDescription() );
+
+ if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
+ $link = '<span class="history-deleted">' . $link . '</span>';
+ }
+
+ return $link;
+ }
+
+ function undelete() {
+ if ( $this->getConfig()->get( 'UploadMaintenance' )
+ && $this->mTargetObj->getNamespace() == NS_FILE
+ ) {
+ throw new ErrorPageError( 'undelete-error', 'filedelete-maintenance' );
+ }
+
+ $this->checkReadOnly();
+
+ $out = $this->getOutput();
+ $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
+ Hooks::run( 'UndeleteForm::undelete', [ &$archive, $this->mTargetObj ] );
+ $ok = $archive->undelete(
+ $this->mTargetTimestamp,
+ $this->mComment,
+ $this->mFileVersions,
+ $this->mUnsuppress,
+ $this->getUser()
+ );
+
+ if ( is_array( $ok ) ) {
+ if ( $ok[1] ) { // Undeleted file count
+ Hooks::run( 'FileUndeleteComplete', [
+ $this->mTargetObj, $this->mFileVersions,
+ $this->getUser(), $this->mComment ] );
+ }
+
+ $link = $this->getLinkRenderer()->makeKnownLink( $this->mTargetObj );
+ $out->addHTML( $this->msg( 'undeletedpage' )->rawParams( $link )->parse() );
+ } else {
+ $out->setPageTitle( $this->msg( 'undelete-error' ) );
+ }
+
+ // Show revision undeletion warnings and errors
+ $status = $archive->getRevisionStatus();
+ if ( $status && !$status->isGood() ) {
+ $out->addWikiText( '<div class="error" id="mw-error-cannotundelete">' .
+ $status->getWikiText(
+ 'cannotundelete',
+ 'cannotundelete'
+ ) . '</div>'
+ );
+ }
+
+ // Show file undeletion warnings and errors
+ $status = $archive->getFileStatus();
+ if ( $status && !$status->isGood() ) {
+ $out->addWikiText( '<div class="error">' .
+ $status->getWikiText(
+ 'undelete-error-short',
+ 'undelete-error-long'
+ ) . '</div>'
+ );
+ }
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUnlinkAccounts.php b/www/wiki/includes/specials/SpecialUnlinkAccounts.php
new file mode 100644
index 00000000..b159fff1
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUnlinkAccounts.php
@@ -0,0 +1,79 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Session\SessionManager;
+
+class SpecialUnlinkAccounts extends AuthManagerSpecialPage {
+ protected static $allowedActions = [ AuthManager::ACTION_UNLINK ];
+
+ public function __construct() {
+ parent::__construct( 'UnlinkAccounts' );
+ }
+
+ protected function getLoginSecurityLevel() {
+ return 'UnlinkAccount';
+ }
+
+ protected function getDefaultAction( $subPage ) {
+ return AuthManager::ACTION_UNLINK;
+ }
+
+ /**
+ * Under which header this special page is listed in Special:SpecialPages.
+ * @return string
+ */
+ protected function getGroupName() {
+ return 'users';
+ }
+
+ public function isListed() {
+ return AuthManager::singleton()->canLinkAccounts();
+ }
+
+ protected function getRequestBlacklist() {
+ return $this->getConfig()->get( 'RemoveCredentialsBlacklist' );
+ }
+
+ public function execute( $subPage ) {
+ $this->setHeaders();
+ $this->loadAuth( $subPage );
+ $this->outputHeader();
+
+ $status = $this->trySubmit();
+
+ if ( $status === false || !$status->isOK() ) {
+ $this->displayForm( $status );
+ return;
+ }
+
+ /** @var AuthenticationResponse $response */
+ $response = $status->getValue();
+
+ if ( $response->status === AuthenticationResponse::FAIL ) {
+ $this->displayForm( StatusValue::newFatal( $response->message ) );
+ return;
+ }
+
+ $status = StatusValue::newGood();
+ $status->warning( wfMessage( 'unlinkaccounts-success' ) );
+ $this->loadAuth( $subPage, null, true ); // update requests so the unlinked one doesn't show up
+
+ // Reset sessions - if the user unlinked an account because it was compromised,
+ // log attackers out from sessions obtained via that account.
+ $session = $this->getRequest()->getSession();
+ $user = $this->getUser();
+ SessionManager::singleton()->invalidateSessionsForUser( $user );
+ $session->setUser( $user );
+ $session->resetId();
+
+ $this->displayForm( $status );
+ }
+
+ public function handleFormSubmit( $data ) {
+ // unlink requests do not accept user input so repeat parent code but skip call to
+ // AuthenticationRequest::loadRequestsFromSubmission
+ $response = $this->performAuthenticationStep( $this->authAction, $this->authRequests );
+ return Status::newGood( $response );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUnlockdb.php b/www/wiki/includes/specials/SpecialUnlockdb.php
new file mode 100644
index 00000000..8cd86ce6
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUnlockdb.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * Implements Special:Unlockdb
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:Unlockdb
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialUnlockdb extends FormSpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'Unlockdb', 'siteadmin' );
+ }
+
+ public function doesWrites() {
+ return false;
+ }
+
+ public function requiresWrite() {
+ return false;
+ }
+
+ public function checkExecutePermissions( User $user ) {
+ parent::checkExecutePermissions( $user );
+ # If the lock file isn't writable, we can do sweet bugger all
+ if ( !file_exists( $this->getConfig()->get( 'ReadOnlyFile' ) ) ) {
+ throw new ErrorPageError( 'lockdb', 'databasenotlocked' );
+ }
+ }
+
+ protected function getFormFields() {
+ return [
+ 'Confirm' => [
+ 'type' => 'toggle',
+ 'label-message' => 'unlockconfirm',
+ ],
+ ];
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setWrapperLegend( false )
+ ->setHeaderText( $this->msg( 'unlockdbtext' )->parseAsBlock() )
+ ->setSubmitTextMsg( 'unlockbtn' );
+ }
+
+ public function onSubmit( array $data ) {
+ if ( !$data['Confirm'] ) {
+ return Status::newFatal( 'locknoconfirm' );
+ }
+
+ $readOnlyFile = $this->getConfig()->get( 'ReadOnlyFile' );
+ MediaWiki\suppressWarnings();
+ $res = unlink( $readOnlyFile );
+ MediaWiki\restoreWarnings();
+
+ if ( $res ) {
+ return Status::newGood();
+ } else {
+ return Status::newFatal( 'filedeleteerror', $readOnlyFile );
+ }
+ }
+
+ public function onSuccess() {
+ $out = $this->getOutput();
+ $out->addSubtitle( $this->msg( 'unlockdbsuccesssub' ) );
+ $out->addWikiMsg( 'unlockdbsuccesstext' );
+ }
+
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUnusedcategories.php b/www/wiki/includes/specials/SpecialUnusedcategories.php
new file mode 100644
index 00000000..1469742a
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUnusedcategories.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Implements Special:Unusedcategories
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * @ingroup SpecialPage
+ */
+class UnusedCategoriesPage extends QueryPage {
+ function __construct( $name = 'Unusedcategories' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function getPageHeader() {
+ return $this->msg( 'unusedcategoriestext' )->parseAsBlock();
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'page', 'categorylinks' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_title'
+ ],
+ 'conds' => [
+ 'cl_from IS NULL',
+ 'page_namespace' => NS_CATEGORY,
+ 'page_is_redirect' => 0
+ ],
+ 'join_conds' => [ 'categorylinks' => [ 'LEFT JOIN', 'cl_to = page_title' ] ]
+ ];
+ }
+
+ /**
+ * A should come before Z (T32907)
+ * @return bool
+ */
+ function sortDescending() {
+ return false;
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $title = Title::makeTitle( NS_CATEGORY, $result->title );
+
+ return $this->getLinkRenderer()->makeLink( $title, $title->getText() );
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+
+ public function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUnusedimages.php b/www/wiki/includes/specials/SpecialUnusedimages.php
new file mode 100644
index 00000000..9fcbf15f
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUnusedimages.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Implements Special:Unusedimages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists unused images
+ *
+ * @ingroup SpecialPage
+ */
+class UnusedimagesPage extends ImageQueryPage {
+ function __construct( $name = 'Unusedimages' ) {
+ parent::__construct( $name );
+ }
+
+ function isExpensive() {
+ return true;
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function getQueryInfo() {
+ $retval = [
+ 'tables' => [ 'image', 'imagelinks' ],
+ 'fields' => [
+ 'namespace' => NS_FILE,
+ 'title' => 'img_name',
+ 'value' => 'img_timestamp',
+ ],
+ 'conds' => [ 'il_to IS NULL' ],
+ 'join_conds' => [ 'imagelinks' => [ 'LEFT JOIN', 'il_to = img_name' ] ]
+ ];
+
+ if ( $this->getConfig()->get( 'CountCategorizedImagesAsUsed' ) ) {
+ // Order is significant
+ $retval['tables'] = [ 'image', 'page', 'categorylinks',
+ 'imagelinks' ];
+ $retval['conds']['page_namespace'] = NS_FILE;
+ $retval['conds'][] = 'cl_from IS NULL';
+ $retval['conds'][] = 'img_name = page_title';
+ $retval['join_conds']['categorylinks'] = [
+ 'LEFT JOIN', 'cl_from = page_id' ];
+ $retval['join_conds']['imagelinks'] = [
+ 'LEFT JOIN', 'il_to = page_title' ];
+ }
+
+ return $retval;
+ }
+
+ function usesTimestamps() {
+ return true;
+ }
+
+ function getPageHeader() {
+ return $this->msg( 'unusedimagestext' )->parseAsBlock();
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUnusedtemplates.php b/www/wiki/includes/specials/SpecialUnusedtemplates.php
new file mode 100644
index 00000000..f73be438
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUnusedtemplates.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * Implements Special:Unusedtemplates
+ *
+ * Copyright © 2006 Rob Church
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>
+ */
+
+/**
+ * A special page that lists unused templates
+ *
+ * @ingroup SpecialPage
+ */
+class UnusedtemplatesPage extends QueryPage {
+ function __construct( $name = 'Unusedtemplates' ) {
+ parent::__construct( $name );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ public function getQueryInfo() {
+ return [
+ 'tables' => [ 'page', 'templatelinks' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_title'
+ ],
+ 'conds' => [
+ 'page_namespace' => NS_TEMPLATE,
+ 'tl_from IS NULL',
+ 'page_is_redirect' => 0
+ ],
+ 'join_conds' => [ 'templatelinks' => [
+ 'LEFT JOIN', [ 'tl_title = page_title',
+ 'tl_namespace = page_namespace' ] ] ]
+ ];
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ $linkRenderer = $this->getLinkRenderer();
+ $title = Title::makeTitle( NS_TEMPLATE, $result->title );
+ $pageLink = $linkRenderer->makeKnownLink(
+ $title,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+ $wlhLink = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() ),
+ $this->msg( 'unusedtemplateswlh' )->text()
+ );
+
+ return $this->getLanguage()->specialList( $pageLink, $wlhLink );
+ }
+
+ function getPageHeader() {
+ return $this->msg( 'unusedtemplatestext' )->parseAsBlock();
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUnwatchedpages.php b/www/wiki/includes/specials/SpecialUnwatchedpages.php
new file mode 100644
index 00000000..fea7e216
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUnwatchedpages.php
@@ -0,0 +1,138 @@
+<?php
+/**
+ * Implements Special:Unwatchedpages
+ *
+ * Copyright © 2005 Ævar Arnfjörð Bjarmason
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A special page that displays a list of pages that are not on anyones watchlist.
+ *
+ * @ingroup SpecialPage
+ */
+class UnwatchedpagesPage extends QueryPage {
+
+ function __construct( $name = 'Unwatchedpages' ) {
+ parent::__construct( $name, 'unwatchedpages' );
+ }
+
+ public function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ /**
+ * Pre-cache page existence to speed up link generation
+ *
+ * @param IDatabase $db
+ * @param ResultWrapper $res
+ */
+ public function preprocessResults( $db, $res ) {
+ if ( !$res->numRows() ) {
+ return;
+ }
+
+ $batch = new LinkBatch();
+ foreach ( $res as $row ) {
+ $batch->add( $row->namespace, $row->title );
+ }
+ $batch->execute();
+
+ $res->seek( 0 );
+ }
+
+ public function getQueryInfo() {
+ $dbr = wfGetDB( DB_REPLICA );
+ return [
+ 'tables' => [ 'page', 'watchlist' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_namespace'
+ ],
+ 'conds' => [
+ 'wl_title IS NULL',
+ 'page_is_redirect' => 0,
+ 'page_namespace != ' . $dbr->addQuotes( NS_MEDIAWIKI ),
+ ],
+ 'join_conds' => [ 'watchlist' => [
+ 'LEFT JOIN', [ 'wl_title = page_title',
+ 'wl_namespace = page_namespace' ] ] ]
+ ];
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ function getOrderFields() {
+ return [ 'page_namespace', 'page_title' ];
+ }
+
+ /**
+ * Add the JS
+ * @param string|null $par
+ */
+ public function execute( $par ) {
+ parent::execute( $par );
+ $this->getOutput()->addModules( 'mediawiki.special.unwatchedPages' );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ global $wgContLang;
+
+ $nt = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( !$nt ) {
+ return Html::element( 'span', [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription( $this->getContext(), $result->namespace, $result->title ) );
+ }
+
+ $text = $wgContLang->convert( $nt->getPrefixedText() );
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ $plink = $linkRenderer->makeKnownLink( $nt, $text );
+ $wlink = $linkRenderer->makeKnownLink(
+ $nt,
+ $this->msg( 'watch' )->text(),
+ [ 'class' => 'mw-watch-link' ],
+ [ 'action' => 'watch' ]
+ );
+
+ return $this->getLanguage()->specialList( $plink, $wlink );
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUpload.php b/www/wiki/includes/specials/SpecialUpload.php
new file mode 100644
index 00000000..024034a6
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUpload.php
@@ -0,0 +1,849 @@
+<?php
+/**
+ * Implements Special:Upload
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @ingroup Upload
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Form for handling uploads and special page.
+ *
+ * @ingroup SpecialPage
+ * @ingroup Upload
+ */
+class SpecialUpload extends SpecialPage {
+ /**
+ * Get data POSTed through the form and assign them to the object
+ * @param WebRequest $request Data posted.
+ */
+ public function __construct( $request = null ) {
+ parent::__construct( 'Upload', 'upload' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /** Misc variables **/
+
+ /** @var WebRequest|FauxRequest The request this form is supposed to handle */
+ public $mRequest;
+ public $mSourceType;
+
+ /** @var UploadBase */
+ public $mUpload;
+
+ /** @var LocalFile */
+ public $mLocalFile;
+ public $mUploadClicked;
+
+ /** User input variables from the "description" section **/
+
+ /** @var string The requested target file name */
+ public $mDesiredDestName;
+ public $mComment;
+ public $mLicense;
+
+ /** User input variables from the root section **/
+
+ public $mIgnoreWarning;
+ public $mWatchthis;
+ public $mCopyrightStatus;
+ public $mCopyrightSource;
+
+ /** Hidden variables **/
+
+ public $mDestWarningAck;
+
+ /** @var bool The user followed an "overwrite this file" link */
+ public $mForReUpload;
+
+ /** @var bool The user clicked "Cancel and return to upload form" button */
+ public $mCancelUpload;
+ public $mTokenOk;
+
+ /** @var bool Subclasses can use this to determine whether a file was uploaded */
+ public $mUploadSuccessful = false;
+
+ /** Text injection points for hooks not using HTMLForm **/
+ public $uploadFormTextTop;
+ public $uploadFormTextAfterSummary;
+
+ /**
+ * Initialize instance variables from request and create an Upload handler
+ */
+ protected function loadRequest() {
+ $this->mRequest = $request = $this->getRequest();
+ $this->mSourceType = $request->getVal( 'wpSourceType', 'file' );
+ $this->mUpload = UploadBase::createFromRequest( $request );
+ $this->mUploadClicked = $request->wasPosted()
+ && ( $request->getCheck( 'wpUpload' )
+ || $request->getCheck( 'wpUploadIgnoreWarning' ) );
+
+ // Guess the desired name from the filename if not provided
+ $this->mDesiredDestName = $request->getText( 'wpDestFile' );
+ if ( !$this->mDesiredDestName && $request->getFileName( 'wpUploadFile' ) !== null ) {
+ $this->mDesiredDestName = $request->getFileName( 'wpUploadFile' );
+ }
+ $this->mLicense = $request->getText( 'wpLicense' );
+
+ $this->mDestWarningAck = $request->getText( 'wpDestFileWarningAck' );
+ $this->mIgnoreWarning = $request->getCheck( 'wpIgnoreWarning' )
+ || $request->getCheck( 'wpUploadIgnoreWarning' );
+ $this->mWatchthis = $request->getBool( 'wpWatchthis' ) && $this->getUser()->isLoggedIn();
+ $this->mCopyrightStatus = $request->getText( 'wpUploadCopyStatus' );
+ $this->mCopyrightSource = $request->getText( 'wpUploadSource' );
+
+ $this->mForReUpload = $request->getBool( 'wpForReUpload' ); // updating a file
+
+ $commentDefault = '';
+ $commentMsg = wfMessage( 'upload-default-description' )->inContentLanguage();
+ if ( !$this->mForReUpload && !$commentMsg->isDisabled() ) {
+ $commentDefault = $commentMsg->plain();
+ }
+ $this->mComment = $request->getText( 'wpUploadDescription', $commentDefault );
+
+ $this->mCancelUpload = $request->getCheck( 'wpCancelUpload' )
+ || $request->getCheck( 'wpReUpload' ); // b/w compat
+
+ // If it was posted check for the token (no remote POST'ing with user credentials)
+ $token = $request->getVal( 'wpEditToken' );
+ $this->mTokenOk = $this->getUser()->matchEditToken( $token );
+
+ $this->uploadFormTextTop = '';
+ $this->uploadFormTextAfterSummary = '';
+ }
+
+ /**
+ * This page can be shown if uploading is enabled.
+ * Handle permission checking elsewhere in order to be able to show
+ * custom error messages.
+ *
+ * @param User $user
+ * @return bool
+ */
+ public function userCanExecute( User $user ) {
+ return UploadBase::isEnabled() && parent::userCanExecute( $user );
+ }
+
+ /**
+ * Special page entry point
+ * @param string $par
+ * @throws ErrorPageError
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ * @throws PermissionsError
+ * @throws ReadOnlyError
+ * @throws UserBlockedError
+ */
+ public function execute( $par ) {
+ $this->useTransactionalTimeLimit();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ # Check uploading enabled
+ if ( !UploadBase::isEnabled() ) {
+ throw new ErrorPageError( 'uploaddisabled', 'uploaddisabledtext' );
+ }
+
+ $this->addHelpLink( 'Help:Managing files' );
+
+ # Check permissions
+ $user = $this->getUser();
+ $permissionRequired = UploadBase::isAllowed( $user );
+ if ( $permissionRequired !== true ) {
+ throw new PermissionsError( $permissionRequired );
+ }
+
+ # Check blocks
+ if ( $user->isBlocked() ) {
+ throw new UserBlockedError( $user->getBlock() );
+ }
+
+ // Global blocks
+ if ( $user->isBlockedGlobally() ) {
+ throw new UserBlockedError( $user->getGlobalBlock() );
+ }
+
+ # Check whether we actually want to allow changing stuff
+ $this->checkReadOnly();
+
+ $this->loadRequest();
+
+ # Unsave the temporary file in case this was a cancelled upload
+ if ( $this->mCancelUpload ) {
+ if ( !$this->unsaveUploadedFile() ) {
+ # Something went wrong, so unsaveUploadedFile showed a warning
+ return;
+ }
+ }
+
+ # Process upload or show a form
+ if (
+ $this->mTokenOk && !$this->mCancelUpload &&
+ ( $this->mUpload && $this->mUploadClicked )
+ ) {
+ $this->processUpload();
+ } else {
+ # Backwards compatibility hook
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $upload = $this;
+ if ( !Hooks::run( 'UploadForm:initial', [ &$upload ] ) ) {
+ wfDebug( "Hook 'UploadForm:initial' broke output of the upload form\n" );
+
+ return;
+ }
+ $this->showUploadForm( $this->getUploadForm() );
+ }
+
+ # Cleanup
+ if ( $this->mUpload ) {
+ $this->mUpload->cleanupTempFile();
+ }
+ }
+
+ /**
+ * Show the main upload form
+ *
+ * @param HTMLForm|string $form An HTMLForm instance or HTML string to show
+ */
+ protected function showUploadForm( $form ) {
+ # Add links if file was previously deleted
+ if ( $this->mDesiredDestName ) {
+ $this->showViewDeletedLinks();
+ }
+
+ if ( $form instanceof HTMLForm ) {
+ $form->show();
+ } else {
+ $this->getOutput()->addHTML( $form );
+ }
+ }
+
+ /**
+ * Get an UploadForm instance with title and text properly set.
+ *
+ * @param string $message HTML string to add to the form
+ * @param string $sessionKey Session key in case this is a stashed upload
+ * @param bool $hideIgnoreWarning Whether to hide "ignore warning" check box
+ * @return UploadForm
+ */
+ protected function getUploadForm( $message = '', $sessionKey = '', $hideIgnoreWarning = false ) {
+ # Initialize form
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() ); // Remove subpage
+ $form = new UploadForm( [
+ 'watch' => $this->getWatchCheck(),
+ 'forreupload' => $this->mForReUpload,
+ 'sessionkey' => $sessionKey,
+ 'hideignorewarning' => $hideIgnoreWarning,
+ 'destwarningack' => (bool)$this->mDestWarningAck,
+
+ 'description' => $this->mComment,
+ 'texttop' => $this->uploadFormTextTop,
+ 'textaftersummary' => $this->uploadFormTextAfterSummary,
+ 'destfile' => $this->mDesiredDestName,
+ ], $context, $this->getLinkRenderer() );
+
+ # Check the token, but only if necessary
+ if (
+ !$this->mTokenOk && !$this->mCancelUpload &&
+ ( $this->mUpload && $this->mUploadClicked )
+ ) {
+ $form->addPreText( $this->msg( 'session_fail_preview' )->parse() );
+ }
+
+ # Give a notice if the user is uploading a file that has been deleted or moved
+ # Note that this is independent from the message 'filewasdeleted'
+ $desiredTitleObj = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName );
+ $delNotice = ''; // empty by default
+ if ( $desiredTitleObj instanceof Title && !$desiredTitleObj->exists() ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ LogEventsList::showLogExtract( $delNotice, [ 'delete', 'move' ],
+ $desiredTitleObj,
+ '', [ 'lim' => 10,
+ 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ],
+ 'showIfEmpty' => false,
+ 'msgKey' => [ 'upload-recreate-warning' ] ]
+ );
+ }
+ $form->addPreText( $delNotice );
+
+ # Add text to form
+ $form->addPreText( '<div id="uploadtext">' .
+ $this->msg( 'uploadtext', [ $this->mDesiredDestName ] )->parseAsBlock() .
+ '</div>' );
+ # Add upload error message
+ $form->addPreText( $message );
+
+ # Add footer to form
+ $uploadFooter = $this->msg( 'uploadfooter' );
+ if ( !$uploadFooter->isDisabled() ) {
+ $form->addPostText( '<div id="mw-upload-footer-message">'
+ . $uploadFooter->parseAsBlock() . "</div>\n" );
+ }
+
+ return $form;
+ }
+
+ /**
+ * Shows the "view X deleted revivions link""
+ */
+ protected function showViewDeletedLinks() {
+ $title = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName );
+ $user = $this->getUser();
+ // Show a subtitle link to deleted revisions (to sysops et al only)
+ if ( $title instanceof Title ) {
+ $count = $title->isDeleted();
+ if ( $count > 0 && $user->isAllowed( 'deletedhistory' ) ) {
+ $restorelink = $this->getLinkRenderer()->makeKnownLink(
+ SpecialPage::getTitleFor( 'Undelete', $title->getPrefixedText() ),
+ $this->msg( 'restorelink' )->numParams( $count )->text()
+ );
+ $link = $this->msg( $user->isAllowed( 'delete' ) ? 'thisisdeleted' : 'viewdeleted' )
+ ->rawParams( $restorelink )->parseAsBlock();
+ $this->getOutput()->addHTML( "<div id=\"contentSub2\">{$link}</div>" );
+ }
+ }
+ }
+
+ /**
+ * Stashes the upload and shows the main upload form.
+ *
+ * Note: only errors that can be handled by changing the name or
+ * description should be redirected here. It should be assumed that the
+ * file itself is sane and has passed UploadBase::verifyFile. This
+ * essentially means that UploadBase::VERIFICATION_ERROR and
+ * UploadBase::EMPTY_FILE should not be passed here.
+ *
+ * @param string $message HTML message to be passed to mainUploadForm
+ */
+ protected function showRecoverableUploadError( $message ) {
+ $stashStatus = $this->mUpload->tryStashFile( $this->getUser() );
+ if ( $stashStatus->isGood() ) {
+ $sessionKey = $stashStatus->getValue()->getFileKey();
+ $uploadWarning = 'upload-tryagain';
+ } else {
+ $sessionKey = null;
+ $uploadWarning = 'upload-tryagain-nostash';
+ }
+ $message = '<h2>' . $this->msg( 'uploaderror' )->escaped() . "</h2>\n" .
+ '<div class="error">' . $message . "</div>\n";
+
+ $form = $this->getUploadForm( $message, $sessionKey );
+ $form->setSubmitText( $this->msg( $uploadWarning )->escaped() );
+ $this->showUploadForm( $form );
+ }
+
+ /**
+ * Stashes the upload, shows the main form, but adds a "continue anyway button".
+ * Also checks whether there are actually warnings to display.
+ *
+ * @param array $warnings
+ * @return bool True if warnings were displayed, false if there are no
+ * warnings and it should continue processing
+ */
+ protected function showUploadWarning( $warnings ) {
+ # If there are no warnings, or warnings we can ignore, return early.
+ # mDestWarningAck is set when some javascript has shown the warning
+ # to the user. mForReUpload is set when the user clicks the "upload a
+ # new version" link.
+ if ( !$warnings || ( count( $warnings ) == 1
+ && isset( $warnings['exists'] )
+ && ( $this->mDestWarningAck || $this->mForReUpload ) )
+ ) {
+ return false;
+ }
+
+ $stashStatus = $this->mUpload->tryStashFile( $this->getUser() );
+ if ( $stashStatus->isGood() ) {
+ $sessionKey = $stashStatus->getValue()->getFileKey();
+ $uploadWarning = 'uploadwarning-text';
+ } else {
+ $sessionKey = null;
+ $uploadWarning = 'uploadwarning-text-nostash';
+ }
+
+ // Add styles for the warning, reused from the live preview
+ $this->getOutput()->addModuleStyles( 'mediawiki.special.upload.styles' );
+
+ $linkRenderer = $this->getLinkRenderer();
+ $warningHtml = '<h2>' . $this->msg( 'uploadwarning' )->escaped() . "</h2>\n"
+ . '<div class="mw-destfile-warning"><ul>';
+ foreach ( $warnings as $warning => $args ) {
+ if ( $warning == 'badfilename' ) {
+ $this->mDesiredDestName = Title::makeTitle( NS_FILE, $args )->getText();
+ }
+ if ( $warning == 'exists' ) {
+ $msg = "\t<li>" . self::getExistsWarning( $args ) . "</li>\n";
+ } elseif ( $warning == 'no-change' ) {
+ $file = $args;
+ $filename = $file->getTitle()->getPrefixedText();
+ $msg = "\t<li>" . wfMessage( 'fileexists-no-change', $filename )->parse() . "</li>\n";
+ } elseif ( $warning == 'duplicate-version' ) {
+ $file = $args[0];
+ $count = count( $args );
+ $filename = $file->getTitle()->getPrefixedText();
+ $message = wfMessage( 'fileexists-duplicate-version' )
+ ->params( $filename )
+ ->numParams( $count );
+ $msg = "\t<li>" . $message->parse() . "</li>\n";
+ } elseif ( $warning == 'was-deleted' ) {
+ # If the file existed before and was deleted, warn the user of this
+ $ltitle = SpecialPage::getTitleFor( 'Log' );
+ $llink = $linkRenderer->makeKnownLink(
+ $ltitle,
+ wfMessage( 'deletionlog' )->text(),
+ [],
+ [
+ 'type' => 'delete',
+ 'page' => Title::makeTitle( NS_FILE, $args )->getPrefixedText(),
+ ]
+ );
+ $msg = "\t<li>" . wfMessage( 'filewasdeleted' )->rawParams( $llink )->parse() . "</li>\n";
+ } elseif ( $warning == 'duplicate' ) {
+ $msg = $this->getDupeWarning( $args );
+ } elseif ( $warning == 'duplicate-archive' ) {
+ if ( $args === '' ) {
+ $msg = "\t<li>" . $this->msg( 'file-deleted-duplicate-notitle' )->parse()
+ . "</li>\n";
+ } else {
+ $msg = "\t<li>" . $this->msg( 'file-deleted-duplicate',
+ Title::makeTitle( NS_FILE, $args )->getPrefixedText() )->parse()
+ . "</li>\n";
+ }
+ } else {
+ if ( $args === true ) {
+ $args = [];
+ } elseif ( !is_array( $args ) ) {
+ $args = [ $args ];
+ }
+ $msg = "\t<li>" . $this->msg( $warning, $args )->parse() . "</li>\n";
+ }
+ $warningHtml .= $msg;
+ }
+ $warningHtml .= "</ul></div>\n";
+ $warningHtml .= $this->msg( $uploadWarning )->parseAsBlock();
+
+ $form = $this->getUploadForm( $warningHtml, $sessionKey, /* $hideIgnoreWarning */ true );
+ $form->setSubmitText( $this->msg( 'upload-tryagain' )->text() );
+ $form->addButton( [
+ 'name' => 'wpUploadIgnoreWarning',
+ 'value' => $this->msg( 'ignorewarning' )->text()
+ ] );
+ $form->addButton( [
+ 'name' => 'wpCancelUpload',
+ 'value' => $this->msg( 'reuploaddesc' )->text()
+ ] );
+
+ $this->showUploadForm( $form );
+
+ # Indicate that we showed a form
+ return true;
+ }
+
+ /**
+ * Show the upload form with error message, but do not stash the file.
+ *
+ * @param string $message HTML string
+ */
+ protected function showUploadError( $message ) {
+ $message = '<h2>' . $this->msg( 'uploadwarning' )->escaped() . "</h2>\n" .
+ '<div class="error">' . $message . "</div>\n";
+ $this->showUploadForm( $this->getUploadForm( $message ) );
+ }
+
+ /**
+ * Do the upload.
+ * Checks are made in SpecialUpload::execute()
+ */
+ protected function processUpload() {
+ // Fetch the file if required
+ $status = $this->mUpload->fetchFile();
+ if ( !$status->isOK() ) {
+ $this->showUploadError( $this->getOutput()->parse( $status->getWikiText() ) );
+
+ return;
+ }
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $upload = $this;
+ if ( !Hooks::run( 'UploadForm:BeforeProcessing', [ &$upload ] ) ) {
+ wfDebug( "Hook 'UploadForm:BeforeProcessing' broke processing the file.\n" );
+ // This code path is deprecated. If you want to break upload processing
+ // do so by hooking into the appropriate hooks in UploadBase::verifyUpload
+ // and UploadBase::verifyFile.
+ // If you use this hook to break uploading, the user will be returned
+ // an empty form with no error message whatsoever.
+ return;
+ }
+
+ // Upload verification
+ $details = $this->mUpload->verifyUpload();
+ if ( $details['status'] != UploadBase::OK ) {
+ $this->processVerificationError( $details );
+
+ return;
+ }
+
+ // Verify permissions for this title
+ $permErrors = $this->mUpload->verifyTitlePermissions( $this->getUser() );
+ if ( $permErrors !== true ) {
+ $code = array_shift( $permErrors[0] );
+ $this->showRecoverableUploadError( $this->msg( $code, $permErrors[0] )->parse() );
+
+ return;
+ }
+
+ $this->mLocalFile = $this->mUpload->getLocalFile();
+
+ // Check warnings if necessary
+ if ( !$this->mIgnoreWarning ) {
+ $warnings = $this->mUpload->checkWarnings();
+ if ( $this->showUploadWarning( $warnings ) ) {
+ return;
+ }
+ }
+
+ // This is as late as we can throttle, after expected issues have been handled
+ if ( UploadBase::isThrottled( $this->getUser() ) ) {
+ $this->showRecoverableUploadError(
+ $this->msg( 'actionthrottledtext' )->escaped()
+ );
+ return;
+ }
+
+ // Get the page text if this is not a reupload
+ if ( !$this->mForReUpload ) {
+ $pageText = self::getInitialPageText( $this->mComment, $this->mLicense,
+ $this->mCopyrightStatus, $this->mCopyrightSource, $this->getConfig() );
+ } else {
+ $pageText = false;
+ }
+
+ $changeTags = $this->getRequest()->getVal( 'wpChangeTags' );
+ if ( is_null( $changeTags ) || $changeTags === '' ) {
+ $changeTags = [];
+ } else {
+ $changeTags = array_filter( array_map( 'trim', explode( ',', $changeTags ) ) );
+ }
+
+ if ( $changeTags ) {
+ $changeTagsStatus = ChangeTags::canAddTagsAccompanyingChange(
+ $changeTags, $this->getUser() );
+ if ( !$changeTagsStatus->isOK() ) {
+ $this->showUploadError( $this->getOutput()->parse( $changeTagsStatus->getWikiText() ) );
+
+ return;
+ }
+ }
+
+ $status = $this->mUpload->performUpload(
+ $this->mComment,
+ $pageText,
+ $this->mWatchthis,
+ $this->getUser(),
+ $changeTags
+ );
+
+ if ( !$status->isGood() ) {
+ $this->showRecoverableUploadError( $this->getOutput()->parse( $status->getWikiText() ) );
+
+ return;
+ }
+
+ // Success, redirect to description page
+ $this->mUploadSuccessful = true;
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $upload = $this;
+ Hooks::run( 'SpecialUploadComplete', [ &$upload ] );
+ $this->getOutput()->redirect( $this->mLocalFile->getTitle()->getFullURL() );
+ }
+
+ /**
+ * Get the initial image page text based on a comment and optional file status information
+ * @param string $comment
+ * @param string $license
+ * @param string $copyStatus
+ * @param string $source
+ * @param Config $config Configuration object to load data from
+ * @return string
+ */
+ public static function getInitialPageText( $comment = '', $license = '',
+ $copyStatus = '', $source = '', Config $config = null
+ ) {
+ if ( $config === null ) {
+ wfDebug( __METHOD__ . ' called without a Config instance passed to it' );
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+
+ $msg = [];
+ $forceUIMsgAsContentMsg = (array)$config->get( 'ForceUIMsgAsContentMsg' );
+ /* These messages are transcluded into the actual text of the description page.
+ * Thus, forcing them as content messages makes the upload to produce an int: template
+ * instead of hardcoding it there in the uploader language.
+ */
+ foreach ( [ 'license-header', 'filedesc', 'filestatus', 'filesource' ] as $msgName ) {
+ if ( in_array( $msgName, $forceUIMsgAsContentMsg ) ) {
+ $msg[$msgName] = "{{int:$msgName}}";
+ } else {
+ $msg[$msgName] = wfMessage( $msgName )->inContentLanguage()->text();
+ }
+ }
+
+ if ( $config->get( 'UseCopyrightUpload' ) ) {
+ $licensetxt = '';
+ if ( $license != '' ) {
+ $licensetxt = '== ' . $msg['license-header'] . " ==\n" . '{{' . $license . '}}' . "\n";
+ }
+ $pageText = '== ' . $msg['filedesc'] . " ==\n" . $comment . "\n" .
+ '== ' . $msg['filestatus'] . " ==\n" . $copyStatus . "\n" .
+ "$licensetxt" .
+ '== ' . $msg['filesource'] . " ==\n" . $source;
+ } else {
+ if ( $license != '' ) {
+ $filedesc = $comment == '' ? '' : '== ' . $msg['filedesc'] . " ==\n" . $comment . "\n";
+ $pageText = $filedesc .
+ '== ' . $msg['license-header'] . " ==\n" . '{{' . $license . '}}' . "\n";
+ } else {
+ $pageText = $comment;
+ }
+ }
+
+ return $pageText;
+ }
+
+ /**
+ * See if we should check the 'watch this page' checkbox on the form
+ * based on the user's preferences and whether we're being asked
+ * to create a new file or update an existing one.
+ *
+ * In the case where 'watch edits' is off but 'watch creations' is on,
+ * we'll leave the box unchecked.
+ *
+ * Note that the page target can be changed *on the form*, so our check
+ * state can get out of sync.
+ * @return bool|string
+ */
+ protected function getWatchCheck() {
+ if ( $this->getUser()->getOption( 'watchdefault' ) ) {
+ // Watch all edits!
+ return true;
+ }
+
+ $desiredTitleObj = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName );
+ if ( $desiredTitleObj instanceof Title && $this->getUser()->isWatched( $desiredTitleObj ) ) {
+ // Already watched, don't change that
+ return true;
+ }
+
+ $local = wfLocalFile( $this->mDesiredDestName );
+ if ( $local && $local->exists() ) {
+ // We're uploading a new version of an existing file.
+ // No creation, so don't watch it if we're not already.
+ return false;
+ } else {
+ // New page should get watched if that's our option.
+ return $this->getUser()->getOption( 'watchcreations' ) ||
+ $this->getUser()->getOption( 'watchuploads' );
+ }
+ }
+
+ /**
+ * Provides output to the user for a result of UploadBase::verifyUpload
+ *
+ * @param array $details Result of UploadBase::verifyUpload
+ * @throws MWException
+ */
+ protected function processVerificationError( $details ) {
+ switch ( $details['status'] ) {
+ /** Statuses that only require name changing **/
+ case UploadBase::MIN_LENGTH_PARTNAME:
+ $this->showRecoverableUploadError( $this->msg( 'minlength1' )->escaped() );
+ break;
+ case UploadBase::ILLEGAL_FILENAME:
+ $this->showRecoverableUploadError( $this->msg( 'illegalfilename',
+ $details['filtered'] )->parse() );
+ break;
+ case UploadBase::FILENAME_TOO_LONG:
+ $this->showRecoverableUploadError( $this->msg( 'filename-toolong' )->escaped() );
+ break;
+ case UploadBase::FILETYPE_MISSING:
+ $this->showRecoverableUploadError( $this->msg( 'filetype-missing' )->parse() );
+ break;
+ case UploadBase::WINDOWS_NONASCII_FILENAME:
+ $this->showRecoverableUploadError( $this->msg( 'windows-nonascii-filename' )->parse() );
+ break;
+
+ /** Statuses that require reuploading **/
+ case UploadBase::EMPTY_FILE:
+ $this->showUploadError( $this->msg( 'emptyfile' )->escaped() );
+ break;
+ case UploadBase::FILE_TOO_LARGE:
+ $this->showUploadError( $this->msg( 'largefileserver' )->escaped() );
+ break;
+ case UploadBase::FILETYPE_BADTYPE:
+ $msg = $this->msg( 'filetype-banned-type' );
+ if ( isset( $details['blacklistedExt'] ) ) {
+ $msg->params( $this->getLanguage()->commaList( $details['blacklistedExt'] ) );
+ } else {
+ $msg->params( $details['finalExt'] );
+ }
+ $extensions = array_unique( $this->getConfig()->get( 'FileExtensions' ) );
+ $msg->params( $this->getLanguage()->commaList( $extensions ),
+ count( $extensions ) );
+
+ // Add PLURAL support for the first parameter. This results
+ // in a bit unlogical parameter sequence, but does not break
+ // old translations
+ if ( isset( $details['blacklistedExt'] ) ) {
+ $msg->params( count( $details['blacklistedExt'] ) );
+ } else {
+ $msg->params( 1 );
+ }
+
+ $this->showUploadError( $msg->parse() );
+ break;
+ case UploadBase::VERIFICATION_ERROR:
+ unset( $details['status'] );
+ $code = array_shift( $details['details'] );
+ $this->showUploadError( $this->msg( $code, $details['details'] )->parse() );
+ break;
+ case UploadBase::HOOK_ABORTED:
+ if ( is_array( $details['error'] ) ) { # allow hooks to return error details in an array
+ $args = $details['error'];
+ $error = array_shift( $args );
+ } else {
+ $error = $details['error'];
+ $args = null;
+ }
+
+ $this->showUploadError( $this->msg( $error, $args )->parse() );
+ break;
+ default:
+ throw new MWException( __METHOD__ . ": Unknown value `{$details['status']}`" );
+ }
+ }
+
+ /**
+ * Remove a temporarily kept file stashed by saveTempUploadedFile().
+ *
+ * @return bool Success
+ */
+ protected function unsaveUploadedFile() {
+ if ( !( $this->mUpload instanceof UploadFromStash ) ) {
+ return true;
+ }
+ $success = $this->mUpload->unsaveUploadedFile();
+ if ( !$success ) {
+ $this->getOutput()->showFileDeleteError( $this->mUpload->getTempPath() );
+
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ /*** Functions for formatting warnings ***/
+
+ /**
+ * Formats a result of UploadBase::getExistsWarning as HTML
+ * This check is static and can be done pre-upload via AJAX
+ *
+ * @param array $exists The result of UploadBase::getExistsWarning
+ * @return string Empty string if there is no warning or an HTML fragment
+ */
+ public static function getExistsWarning( $exists ) {
+ if ( !$exists ) {
+ return '';
+ }
+
+ $file = $exists['file'];
+ $filename = $file->getTitle()->getPrefixedText();
+ $warnMsg = null;
+
+ if ( $exists['warning'] == 'exists' ) {
+ // Exact match
+ $warnMsg = wfMessage( 'fileexists', $filename );
+ } elseif ( $exists['warning'] == 'page-exists' ) {
+ // Page exists but file does not
+ $warnMsg = wfMessage( 'filepageexists', $filename );
+ } elseif ( $exists['warning'] == 'exists-normalized' ) {
+ $warnMsg = wfMessage( 'fileexists-extension', $filename,
+ $exists['normalizedFile']->getTitle()->getPrefixedText() );
+ } elseif ( $exists['warning'] == 'thumb' ) {
+ // Swapped argument order compared with other messages for backwards compatibility
+ $warnMsg = wfMessage( 'fileexists-thumbnail-yes',
+ $exists['thumbFile']->getTitle()->getPrefixedText(), $filename );
+ } elseif ( $exists['warning'] == 'thumb-name' ) {
+ // Image w/o '180px-' does not exists, but we do not like these filenames
+ $name = $file->getName();
+ $badPart = substr( $name, 0, strpos( $name, '-' ) + 1 );
+ $warnMsg = wfMessage( 'file-thumbnail-no', $badPart );
+ } elseif ( $exists['warning'] == 'bad-prefix' ) {
+ $warnMsg = wfMessage( 'filename-bad-prefix', $exists['prefix'] );
+ }
+
+ return $warnMsg ? $warnMsg->title( $file->getTitle() )->parse() : '';
+ }
+
+ /**
+ * Construct a warning and a gallery from an array of duplicate files.
+ * @param array $dupes
+ * @return string
+ */
+ public function getDupeWarning( $dupes ) {
+ if ( !$dupes ) {
+ return '';
+ }
+
+ $gallery = ImageGalleryBase::factory( false, $this->getContext() );
+ $gallery->setShowBytes( false );
+ $gallery->setShowDimensions( false );
+ foreach ( $dupes as $file ) {
+ $gallery->add( $file->getTitle() );
+ }
+
+ return '<li>' .
+ $this->msg( 'file-exists-duplicate' )->numParams( count( $dupes ) )->parse() .
+ $gallery->toHTML() . "</li>\n";
+ }
+
+ protected function getGroupName() {
+ return 'media';
+ }
+
+ /**
+ * Should we rotate images in the preview on Special:Upload.
+ *
+ * This controls js: mw.config.get( 'wgFileCanRotate' )
+ *
+ * @todo What about non-BitmapHandler handled files?
+ * @return bool
+ */
+ public static function rotationEnabled() {
+ $bitmapHandler = new BitmapHandler();
+ return $bitmapHandler->autoRotateEnabled();
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUploadStash.php b/www/wiki/includes/specials/SpecialUploadStash.php
new file mode 100644
index 00000000..b0bb595e
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUploadStash.php
@@ -0,0 +1,431 @@
+<?php
+/**
+ * Implements Special:UploadStash.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @ingroup Upload
+ */
+
+/**
+ * Web access for files temporarily stored by UploadStash.
+ *
+ * For example -- files that were uploaded with the UploadWizard extension are stored temporarily
+ * before committing them to the db. But we want to see their thumbnails and get other information
+ * about them.
+ *
+ * Since this is based on the user's session, in effect this creates a private temporary file area.
+ * However, the URLs for the files cannot be shared.
+ */
+class SpecialUploadStash extends UnlistedSpecialPage {
+ // UploadStash
+ private $stash;
+
+ /**
+ * Since we are directly writing the file to STDOUT,
+ * we should not be reading in really big files and serving them out.
+ *
+ * We also don't want people using this as a file drop, even if they
+ * share credentials.
+ *
+ * This service is really for thumbnails and other such previews while
+ * uploading.
+ */
+ const MAX_SERVE_BYTES = 1048576; // 1MB
+
+ public function __construct() {
+ parent::__construct( 'UploadStash', 'upload' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * Execute page -- can output a file directly or show a listing of them.
+ *
+ * @param string $subPage Subpage, e.g. in
+ * https://example.com/wiki/Special:UploadStash/foo.jpg, the "foo.jpg" part
+ * @return bool Success
+ */
+ public function execute( $subPage ) {
+ $this->useTransactionalTimeLimit();
+
+ $this->stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $this->getUser() );
+ $this->checkPermissions();
+
+ if ( $subPage === null || $subPage === '' ) {
+ return $this->showUploads();
+ }
+
+ return $this->showUpload( $subPage );
+ }
+
+ /**
+ * If file available in stash, cats it out to the client as a simple HTTP response.
+ * n.b. Most sanity checking done in UploadStashLocalFile, so this is straightforward.
+ *
+ * @param string $key The key of a particular requested file
+ * @throws HttpError
+ * @return bool
+ */
+ public function showUpload( $key ) {
+ // prevent callers from doing standard HTML output -- we'll take it from here
+ $this->getOutput()->disable();
+
+ try {
+ $params = $this->parseKey( $key );
+ if ( $params['type'] === 'thumb' ) {
+ return $this->outputThumbFromStash( $params['file'], $params['params'] );
+ } else {
+ return $this->outputLocalFile( $params['file'] );
+ }
+ } catch ( UploadStashFileNotFoundException $e ) {
+ $code = 404;
+ $message = $e->getMessage();
+ } catch ( UploadStashZeroLengthFileException $e ) {
+ $code = 500;
+ $message = $e->getMessage();
+ } catch ( UploadStashBadPathException $e ) {
+ $code = 500;
+ $message = $e->getMessage();
+ } catch ( SpecialUploadStashTooLargeException $e ) {
+ $code = 500;
+ $message = 'Cannot serve a file larger than ' . self::MAX_SERVE_BYTES .
+ ' bytes. ' . $e->getMessage();
+ } catch ( Exception $e ) {
+ $code = 500;
+ $message = $e->getMessage();
+ }
+
+ throw new HttpError( $code, $message );
+ }
+
+ /**
+ * Parse the key passed to the SpecialPage. Returns an array containing
+ * the associated file object, the type ('file' or 'thumb') and if
+ * application the transform parameters
+ *
+ * @param string $key
+ * @throws UploadStashBadPathException
+ * @return array
+ */
+ private function parseKey( $key ) {
+ $type = strtok( $key, '/' );
+
+ if ( $type !== 'file' && $type !== 'thumb' ) {
+ throw new UploadStashBadPathException( "Unknown type '$type'" );
+ }
+ $fileName = strtok( '/' );
+ $thumbPart = strtok( '/' );
+ $file = $this->stash->getFile( $fileName );
+ if ( $type === 'thumb' ) {
+ $srcNamePos = strrpos( $thumbPart, $fileName );
+ if ( $srcNamePos === false || $srcNamePos < 1 ) {
+ throw new UploadStashBadPathException( 'Unrecognized thumb name' );
+ }
+ $paramString = substr( $thumbPart, 0, $srcNamePos - 1 );
+
+ $handler = $file->getHandler();
+ if ( $handler ) {
+ $params = $handler->parseParamString( $paramString );
+
+ return [ 'file' => $file, 'type' => $type, 'params' => $params ];
+ } else {
+ throw new UploadStashBadPathException( 'No handler found for ' .
+ "mime {$file->getMimeType()} of file {$file->getPath()}" );
+ }
+ }
+
+ return [ 'file' => $file, 'type' => $type ];
+ }
+
+ /**
+ * Get a thumbnail for file, either generated locally or remotely, and stream it out
+ *
+ * @param File $file
+ * @param array $params
+ *
+ * @return bool Success
+ */
+ private function outputThumbFromStash( $file, $params ) {
+ $flags = 0;
+ // this config option, if it exists, points to a "scaler", as you might find in
+ // the Wikimedia Foundation cluster. See outputRemoteScaledThumb(). This
+ // is part of our horrible NFS-based system, we create a file on a mount
+ // point here, but fetch the scaled file from somewhere else that
+ // happens to share it over NFS.
+ if ( $this->getConfig()->get( 'UploadStashScalerBaseUrl' ) ) {
+ $this->outputRemoteScaledThumb( $file, $params, $flags );
+ } else {
+ $this->outputLocallyScaledThumb( $file, $params, $flags );
+ }
+ }
+
+ /**
+ * Scale a file (probably with a locally installed imagemagick, or similar)
+ * and output it to STDOUT.
+ * @param File $file
+ * @param array $params Scaling parameters ( e.g. [ width => '50' ] );
+ * @param int $flags Scaling flags ( see File:: constants )
+ * @throws MWException|UploadStashFileNotFoundException
+ * @return bool Success
+ */
+ private function outputLocallyScaledThumb( $file, $params, $flags ) {
+ // n.b. this is stupid, we insist on re-transforming the file every time we are invoked. We rely
+ // on HTTP caching to ensure this doesn't happen.
+
+ $flags |= File::RENDER_NOW;
+
+ $thumbnailImage = $file->transform( $params, $flags );
+ if ( !$thumbnailImage ) {
+ throw new MWException( 'Could not obtain thumbnail' );
+ }
+
+ // we should have just generated it locally
+ if ( !$thumbnailImage->getStoragePath() ) {
+ throw new UploadStashFileNotFoundException( "no local path for scaled item" );
+ }
+
+ // now we should construct a File, so we can get MIME and other such info in a standard way
+ // n.b. MIME type may be different from original (ogx original -> jpeg thumb)
+ $thumbFile = new UnregisteredLocalFile( false,
+ $this->stash->repo, $thumbnailImage->getStoragePath(), false );
+ if ( !$thumbFile ) {
+ throw new UploadStashFileNotFoundException( "couldn't create local file object for thumbnail" );
+ }
+
+ return $this->outputLocalFile( $thumbFile );
+ }
+
+ /**
+ * Scale a file with a remote "scaler", as exists on the Wikimedia Foundation
+ * cluster, and output it to STDOUT.
+ * Note: Unlike the usual thumbnail process, the web client never sees the
+ * cluster URL; we do the whole HTTP transaction to the scaler ourselves
+ * and cat the results out.
+ * Note: We rely on NFS to have propagated the file contents to the scaler.
+ * However, we do not rely on the thumbnail being created in NFS and then
+ * propagated back to our filesystem. Instead we take the results of the
+ * HTTP request instead.
+ * Note: No caching is being done here, although we are instructing the
+ * client to cache it forever.
+ *
+ * @param File $file
+ * @param array $params Scaling parameters ( e.g. [ width => '50' ] );
+ * @param int $flags Scaling flags ( see File:: constants )
+ * @throws MWException
+ * @return bool Success
+ */
+ private function outputRemoteScaledThumb( $file, $params, $flags ) {
+ // This option probably looks something like
+ // '//upload.wikimedia.org/wikipedia/test/thumb/temp'. Do not use
+ // trailing slash.
+ $scalerBaseUrl = $this->getConfig()->get( 'UploadStashScalerBaseUrl' );
+
+ if ( preg_match( '/^\/\//', $scalerBaseUrl ) ) {
+ // this is apparently a protocol-relative URL, which makes no sense in this context,
+ // since this is used for communication that's internal to the application.
+ // default to http.
+ $scalerBaseUrl = wfExpandUrl( $scalerBaseUrl, PROTO_CANONICAL );
+ }
+
+ // We need to use generateThumbName() instead of thumbName(), because
+ // the suffix needs to match the file name for the remote thumbnailer
+ // to work
+ $scalerThumbName = $file->generateThumbName( $file->getName(), $params );
+ $scalerThumbUrl = $scalerBaseUrl . '/' . $file->getUrlRel() .
+ '/' . rawurlencode( $scalerThumbName );
+
+ // make a curl call to the scaler to create a thumbnail
+ $httpOptions = [
+ 'method' => 'GET',
+ 'timeout' => 5 // T90599 attempt to time out cleanly
+ ];
+ $req = MWHttpRequest::factory( $scalerThumbUrl, $httpOptions, __METHOD__ );
+ $status = $req->execute();
+ if ( !$status->isOK() ) {
+ $errors = $status->getErrorsArray();
+ $errorStr = "Fetching thumbnail failed: " . print_r( $errors, 1 );
+ $errorStr .= "\nurl = $scalerThumbUrl\n";
+ throw new MWException( $errorStr );
+ }
+ $contentType = $req->getResponseHeader( "content-type" );
+ if ( !$contentType ) {
+ throw new MWException( "Missing content-type header" );
+ }
+
+ return $this->outputContents( $req->getContent(), $contentType );
+ }
+
+ /**
+ * Output HTTP response for file
+ * Side effect: writes HTTP response to STDOUT.
+ *
+ * @param File $file File object with a local path (e.g. UnregisteredLocalFile,
+ * LocalFile. Oddly these don't share an ancestor!)
+ * @throws SpecialUploadStashTooLargeException
+ * @return bool
+ */
+ private function outputLocalFile( File $file ) {
+ if ( $file->getSize() > self::MAX_SERVE_BYTES ) {
+ throw new SpecialUploadStashTooLargeException();
+ }
+
+ return $file->getRepo()->streamFile( $file->getPath(),
+ [ 'Content-Transfer-Encoding: binary',
+ 'Expires: Sun, 17-Jan-2038 19:14:07 GMT' ]
+ );
+ }
+
+ /**
+ * Output HTTP response of raw content
+ * Side effect: writes HTTP response to STDOUT.
+ * @param string $content Content
+ * @param string $contentType MIME type
+ * @throws SpecialUploadStashTooLargeException
+ * @return bool
+ */
+ private function outputContents( $content, $contentType ) {
+ $size = strlen( $content );
+ if ( $size > self::MAX_SERVE_BYTES ) {
+ throw new SpecialUploadStashTooLargeException();
+ }
+ // Cancel output buffering and gzipping if set
+ wfResetOutputBuffers();
+ self::outputFileHeaders( $contentType, $size );
+ print $content;
+
+ return true;
+ }
+
+ /**
+ * Output headers for streaming
+ * @todo Unsure about encoding as binary; if we received from HTTP perhaps
+ * we should use that encoding, concatenated with semicolon to `$contentType` as it
+ * usually is.
+ * Side effect: preps PHP to write headers to STDOUT.
+ * @param string $contentType String suitable for content-type header
+ * @param string $size Length in bytes
+ */
+ private static function outputFileHeaders( $contentType, $size ) {
+ header( "Content-Type: $contentType", true );
+ header( 'Content-Transfer-Encoding: binary', true );
+ header( 'Expires: Sun, 17-Jan-2038 19:14:07 GMT', true );
+ // T55032 - It shouldn't be a problem here, but let's be safe and not cache
+ header( 'Cache-Control: private' );
+ header( "Content-Length: $size", true );
+ }
+
+ /**
+ * Static callback for the HTMLForm in showUploads, to process
+ * Note the stash has to be recreated since this is being called in a static context.
+ * This works, because there really is only one stash per logged-in user, despite appearances.
+ *
+ * @param array $formData
+ * @param HTMLForm $form
+ * @return Status
+ */
+ public static function tryClearStashedUploads( $formData, $form ) {
+ if ( isset( $formData['Clear'] ) ) {
+ $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $form->getUser() );
+ wfDebug( 'stash has: ' . print_r( $stash->listFiles(), true ) . "\n" );
+
+ if ( !$stash->clear() ) {
+ return Status::newFatal( 'uploadstash-errclear' );
+ }
+ }
+
+ return Status::newGood();
+ }
+
+ /**
+ * Default action when we don't have a subpage -- just show links to the uploads we have,
+ * Also show a button to clear stashed files
+ * @return bool
+ */
+ private function showUploads() {
+ // sets the title, etc.
+ $this->setHeaders();
+ $this->outputHeader();
+
+ // create the form, which will also be used to execute a callback to process incoming form data
+ // this design is extremely dubious, but supposedly HTMLForm is our standard now?
+
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() ); // Remove subpage
+ $form = HTMLForm::factory( 'ooui', [
+ 'Clear' => [
+ 'type' => 'hidden',
+ 'default' => true,
+ 'name' => 'clear',
+ ]
+ ], $context, 'clearStashedUploads' );
+ $form->setSubmitDestructive();
+ $form->setSubmitCallback( [ __CLASS__, 'tryClearStashedUploads' ] );
+ $form->setSubmitTextMsg( 'uploadstash-clear' );
+
+ $form->prepareForm();
+ $formResult = $form->tryAuthorizedSubmit();
+
+ // show the files + form, if there are any, or just say there are none
+ $refreshHtml = Html::element( 'a',
+ [ 'href' => $this->getPageTitle()->getLocalURL() ],
+ $this->msg( 'uploadstash-refresh' )->text() );
+ $files = $this->stash->listFiles();
+ if ( $files && count( $files ) ) {
+ sort( $files );
+ $fileListItemsHtml = '';
+ $linkRenderer = $this->getLinkRenderer();
+ foreach ( $files as $file ) {
+ $itemHtml = $linkRenderer->makeKnownLink(
+ $this->getPageTitle( "file/$file" ),
+ $file
+ );
+ try {
+ $fileObj = $this->stash->getFile( $file );
+ $thumb = $fileObj->generateThumbName( $file, [ 'width' => 220 ] );
+ $itemHtml .=
+ $this->msg( 'word-separator' )->escaped() .
+ $this->msg( 'parentheses' )->rawParams(
+ $linkRenderer->makeKnownLink(
+ $this->getPageTitle( "thumb/$file/$thumb" ),
+ $this->msg( 'uploadstash-thumbnail' )->text()
+ )
+ )->escaped();
+ } catch ( Exception $e ) {
+ }
+ $fileListItemsHtml .= Html::rawElement( 'li', [], $itemHtml );
+ }
+ $this->getOutput()->addHTML( Html::rawElement( 'ul', [], $fileListItemsHtml ) );
+ $form->displayForm( $formResult );
+ $this->getOutput()->addHTML( Html::rawElement( 'p', [], $refreshHtml ) );
+ } else {
+ $this->getOutput()->addHTML( Html::rawElement( 'p', [],
+ Html::element( 'span', [], $this->msg( 'uploadstash-nofiles' )->text() )
+ . ' '
+ . $refreshHtml
+ ) );
+ }
+
+ return true;
+ }
+}
+
+class SpecialUploadStashTooLargeException extends MWException {
+}
diff --git a/www/wiki/includes/specials/SpecialUserLogin.php b/www/wiki/includes/specials/SpecialUserLogin.php
new file mode 100644
index 00000000..253cd507
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUserLogin.php
@@ -0,0 +1,162 @@
+<?php
+/**
+ * Implements Special:UserLogin
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Implements Special:UserLogin
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialUserLogin extends LoginSignupSpecialPage {
+ protected static $allowedActions = [
+ AuthManager::ACTION_LOGIN,
+ AuthManager::ACTION_LOGIN_CONTINUE
+ ];
+
+ protected static $messages = [
+ 'authform-newtoken' => 'nocookiesforlogin',
+ 'authform-notoken' => 'sessionfailure',
+ 'authform-wrongtoken' => 'sessionfailure',
+ ];
+
+ public function __construct() {
+ parent::__construct( 'Userlogin' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ protected function getLoginSecurityLevel() {
+ return false;
+ }
+
+ protected function getDefaultAction( $subPage ) {
+ return AuthManager::ACTION_LOGIN;
+ }
+
+ public function getDescription() {
+ return $this->msg( 'login' )->text();
+ }
+
+ public function setHeaders() {
+ // override the page title if we are doing a forced reauthentication
+ parent::setHeaders();
+ if ( $this->securityLevel && $this->getUser()->isLoggedIn() ) {
+ $this->getOutput()->setPageTitle( $this->msg( 'login-security' ) );
+ }
+ }
+
+ protected function isSignup() {
+ return false;
+ }
+
+ protected function beforeExecute( $subPage ) {
+ if ( $subPage === 'signup' || $this->getRequest()->getText( 'type' ) === 'signup' ) {
+ // B/C for old account creation URLs
+ $title = SpecialPage::getTitleFor( 'CreateAccount' );
+ $query = array_diff_key( $this->getRequest()->getValues(),
+ array_fill_keys( [ 'type', 'title' ], true ) );
+ $url = $title->getFullURL( $query, false, PROTO_CURRENT );
+ $this->getOutput()->redirect( $url );
+ return false;
+ }
+ return parent::beforeExecute( $subPage );
+ }
+
+ /**
+ * Run any hooks registered for logins, then HTTP redirect to
+ * $this->mReturnTo (or Main Page if that's undefined). Formerly we had a
+ * nice message here, but that's really not as useful as just being sent to
+ * wherever you logged in from. It should be clear that the action was
+ * successful, given the lack of error messages plus the appearance of your
+ * name in the upper right.
+ * @param bool $direct True if the action was successful just now; false if that happened
+ * pre-redirection (so this handler was called already)
+ * @param StatusValue|null $extraMessages
+ */
+ protected function successfulAction( $direct = false, $extraMessages = null ) {
+ global $wgSecureLogin;
+
+ $user = $this->targetUser ?: $this->getUser();
+ $session = $this->getRequest()->getSession();
+
+ if ( $direct ) {
+ $user->touch();
+
+ $this->clearToken();
+
+ if ( $user->requiresHTTPS() ) {
+ $this->mStickHTTPS = true;
+ }
+ $session->setForceHTTPS( $wgSecureLogin && $this->mStickHTTPS );
+
+ // If the user does not have a session cookie at this point, they probably need to
+ // do something to their browser.
+ if ( !$this->hasSessionCookie() ) {
+ $this->mainLoginForm( [ /*?*/ ], $session->getProvider()->whyNoSession() );
+ // TODO something more specific? This used to use nocookieslogin
+ return;
+ }
+ }
+
+ # Run any hooks; display injected HTML if any, else redirect
+ $injected_html = '';
+ Hooks::run( 'UserLoginComplete', [ &$user, &$injected_html, $direct ] );
+
+ if ( $injected_html !== '' || $extraMessages ) {
+ $this->showSuccessPage( 'success', $this->msg( 'loginsuccesstitle' ),
+ 'loginsuccess', $injected_html, $extraMessages );
+ } else {
+ $helper = new LoginHelper( $this->getContext() );
+ $helper->showReturnToPage( 'successredirect', $this->mReturnTo, $this->mReturnToQuery,
+ $this->mStickHTTPS );
+ }
+ }
+
+ protected function getToken() {
+ return $this->getRequest()->getSession()->getToken( '', 'login' );
+ }
+
+ protected function clearToken() {
+ return $this->getRequest()->getSession()->resetToken( 'login' );
+ }
+
+ protected function getTokenName() {
+ return 'wpLoginToken';
+ }
+
+ protected function getGroupName() {
+ return 'login';
+ }
+
+ protected function logAuthResult( $success, $status = null ) {
+ LoggerFactory::getInstance( 'authevents' )->info( 'Login attempt', [
+ 'event' => 'login',
+ 'successful' => $success,
+ 'status' => $status,
+ ] );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUserLogout.php b/www/wiki/includes/specials/SpecialUserLogout.php
new file mode 100644
index 00000000..a9b732ef
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUserLogout.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Implements Special:Userlogout
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:Userlogout
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialUserLogout extends UnlistedSpecialPage {
+ function __construct() {
+ parent::__construct( 'Userlogout' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ function execute( $par ) {
+ /**
+ * Some satellite ISPs use broken precaching schemes that log people out straight after
+ * they're logged in (T19790). Luckily, there's a way to detect such requests.
+ */
+ if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( $_SERVER['REQUEST_URI'], '&amp;' ) !== false ) {
+ wfDebug( "Special:UserLogout request {$_SERVER['REQUEST_URI']} looks suspicious, denying.\n" );
+ throw new HttpError( 400, $this->msg( 'suspicious-userlogout' ), $this->msg( 'loginerror' ) );
+ }
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ // Make sure it's possible to log out
+ $session = MediaWiki\Session\SessionManager::getGlobalSession();
+ if ( !$session->canSetUser() ) {
+ throw new ErrorPageError(
+ 'cannotlogoutnow-title',
+ 'cannotlogoutnow-text',
+ [
+ $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+ ]
+ );
+ }
+
+ $user = $this->getUser();
+ $oldName = $user->getName();
+
+ $user->logout();
+
+ $loginURL = SpecialPage::getTitleFor( 'Userlogin' )->getFullURL(
+ $this->getRequest()->getValues( 'returnto', 'returntoquery' ) );
+
+ $out = $this->getOutput();
+ $out->addWikiMsg( 'logouttext', $loginURL );
+
+ // Hook.
+ $injected_html = '';
+ Hooks::run( 'UserLogoutComplete', [ &$user, &$injected_html, $oldName ] );
+ $out->addHTML( $injected_html );
+
+ $out->returnToMain();
+ }
+
+ protected function getGroupName() {
+ return 'login';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialUserrights.php b/www/wiki/includes/specials/SpecialUserrights.php
new file mode 100644
index 00000000..0a712eff
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialUserrights.php
@@ -0,0 +1,1025 @@
+<?php
+/**
+ * Implements Special:Userrights
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page to allow managing user group membership
+ *
+ * @ingroup SpecialPage
+ */
+class UserrightsPage extends SpecialPage {
+ /**
+ * The target of the local right-adjuster's interest. Can be gotten from
+ * either a GET parameter or a subpage-style parameter, so have a member
+ * variable for it.
+ * @var null|string $mTarget
+ */
+ protected $mTarget;
+ /*
+ * @var null|User $mFetchedUser The user object of the target username or null.
+ */
+ protected $mFetchedUser = null;
+ protected $isself = false;
+
+ public function __construct() {
+ parent::__construct( 'Userrights' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * Check whether the current user (from context) can change the target user's rights.
+ *
+ * @param User $targetUser User whose rights are being changed
+ * @param bool $checkIfSelf If false, assume that the current user can add/remove groups defined
+ * in $wgGroupsAddToSelf / $wgGroupsRemoveFromSelf, without checking if it's the same as target
+ * user
+ * @return bool
+ */
+ public function userCanChangeRights( $targetUser, $checkIfSelf = true ) {
+ $isself = $this->getUser()->equals( $targetUser );
+
+ $available = $this->changeableGroups();
+ if ( $targetUser->getId() == 0 ) {
+ return false;
+ }
+
+ return !empty( $available['add'] )
+ || !empty( $available['remove'] )
+ || ( ( $isself || !$checkIfSelf ) &&
+ ( !empty( $available['add-self'] )
+ || !empty( $available['remove-self'] ) ) );
+ }
+
+ /**
+ * Manage forms to be shown according to posted data.
+ * Depending on the submit button used, call a form or a save function.
+ *
+ * @param string|null $par String if any subpage provided, else null
+ * @throws UserBlockedError|PermissionsError
+ */
+ public function execute( $par ) {
+ $user = $this->getUser();
+ $request = $this->getRequest();
+ $session = $request->getSession();
+ $out = $this->getOutput();
+
+ $out->addModules( [ 'mediawiki.special.userrights' ] );
+
+ if ( $par !== null ) {
+ $this->mTarget = $par;
+ } else {
+ $this->mTarget = $request->getVal( 'user' );
+ }
+
+ if ( is_string( $this->mTarget ) ) {
+ $this->mTarget = trim( $this->mTarget );
+ }
+
+ if ( $this->mTarget !== null && User::getCanonicalName( $this->mTarget ) === $user->getName() ) {
+ $this->isself = true;
+ }
+
+ $fetchedStatus = $this->fetchUser( $this->mTarget, true );
+ if ( $fetchedStatus->isOK() ) {
+ $this->mFetchedUser = $fetchedStatus->value;
+ if ( $this->mFetchedUser instanceof User ) {
+ // Set the 'relevant user' in the skin, so it displays links like Contributions,
+ // User logs, UserRights, etc.
+ $this->getSkin()->setRelevantUser( $this->mFetchedUser );
+ }
+ }
+
+ // show a successbox, if the user rights was saved successfully
+ if (
+ $session->get( 'specialUserrightsSaveSuccess' ) &&
+ $this->mFetchedUser !== null
+ ) {
+ // Remove session data for the success message
+ $session->remove( 'specialUserrightsSaveSuccess' );
+
+ $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
+ $out->addHTML(
+ Html::rawElement(
+ 'div',
+ [
+ 'class' => 'mw-notify-success successbox',
+ 'id' => 'mw-preferences-success',
+ 'data-mw-autohide' => 'false',
+ ],
+ Html::element(
+ 'p',
+ [],
+ $this->msg( 'savedrights', $this->mFetchedUser->getName() )->text()
+ )
+ )
+ );
+ }
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out->addModuleStyles( 'mediawiki.special' );
+ $this->addHelpLink( 'Help:Assigning permissions' );
+
+ $this->switchForm();
+
+ if (
+ $request->wasPosted() &&
+ $request->getCheck( 'saveusergroups' ) &&
+ $this->mTarget !== null &&
+ $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
+ ) {
+ /*
+ * If the user is blocked and they only have "partial" access
+ * (e.g. they don't have the userrights permission), then don't
+ * allow them to change any user rights.
+ */
+ if ( $user->isBlocked() && !$user->isAllowed( 'userrights' ) ) {
+ throw new UserBlockedError( $user->getBlock() );
+ }
+
+ $this->checkReadOnly();
+
+ // save settings
+ if ( !$fetchedStatus->isOK() ) {
+ $this->getOutput()->addWikiText( $fetchedStatus->getWikiText() );
+
+ return;
+ }
+
+ $targetUser = $this->mFetchedUser;
+ if ( $targetUser instanceof User ) { // UserRightsProxy doesn't have this method (T63252)
+ $targetUser->clearInstanceCache(); // T40989
+ }
+
+ if ( $request->getVal( 'conflictcheck-originalgroups' )
+ !== implode( ',', $targetUser->getGroups() )
+ ) {
+ $out->addWikiMsg( 'userrights-conflict' );
+ } else {
+ $status = $this->saveUserGroups(
+ $this->mTarget,
+ $request->getVal( 'user-reason' ),
+ $targetUser
+ );
+
+ if ( $status->isOK() ) {
+ // Set session data for the success message
+ $session->set( 'specialUserrightsSaveSuccess', 1 );
+
+ $out->redirect( $this->getSuccessURL() );
+ return;
+ } else {
+ // Print an error message and redisplay the form
+ $out->addWikiText( '<div class="error">' . $status->getWikiText() . '</div>' );
+ }
+ }
+ }
+
+ // show some more forms
+ if ( $this->mTarget !== null ) {
+ $this->editUserGroupsForm( $this->mTarget );
+ }
+ }
+
+ function getSuccessURL() {
+ return $this->getPageTitle( $this->mTarget )->getFullURL();
+ }
+
+ /**
+ * Returns true if this user rights form can set and change user group expiries.
+ * Subclasses may wish to override this to return false.
+ *
+ * @return bool
+ */
+ public function canProcessExpiries() {
+ return true;
+ }
+
+ /**
+ * Converts a user group membership expiry string into a timestamp. Words like
+ * 'existing' or 'other' should have been filtered out before calling this
+ * function.
+ *
+ * @param string $expiry
+ * @return string|null|false A string containing a valid timestamp, or null
+ * if the expiry is infinite, or false if the timestamp is not valid
+ */
+ public static function expiryToTimestamp( $expiry ) {
+ if ( wfIsInfinity( $expiry ) ) {
+ return null;
+ }
+
+ $unix = strtotime( $expiry );
+
+ if ( !$unix || $unix === -1 ) {
+ return false;
+ }
+
+ // @todo FIXME: Non-qualified absolute times are not in users specified timezone
+ // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
+ return wfTimestamp( TS_MW, $unix );
+ }
+
+ /**
+ * Save user groups changes in the database.
+ * Data comes from the editUserGroupsForm() form function
+ *
+ * @param string $username Username to apply changes to.
+ * @param string $reason Reason for group change
+ * @param User|UserRightsProxy $user Target user object.
+ * @return Status
+ */
+ protected function saveUserGroups( $username, $reason, $user ) {
+ $allgroups = $this->getAllGroups();
+ $addgroup = [];
+ $groupExpiries = []; // associative array of (group name => expiry)
+ $removegroup = [];
+ $existingUGMs = $user->getGroupMemberships();
+
+ // This could possibly create a highly unlikely race condition if permissions are changed between
+ // when the form is loaded and when the form is saved. Ignoring it for the moment.
+ foreach ( $allgroups as $group ) {
+ // We'll tell it to remove all unchecked groups, and add all checked groups.
+ // Later on, this gets filtered for what can actually be removed
+ if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
+ $addgroup[] = $group;
+
+ if ( $this->canProcessExpiries() ) {
+ // read the expiry information from the request
+ $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
+ if ( $expiryDropdown === 'existing' ) {
+ continue;
+ }
+
+ if ( $expiryDropdown === 'other' ) {
+ $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
+ } else {
+ $expiryValue = $expiryDropdown;
+ }
+
+ // validate the expiry
+ $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
+
+ if ( $groupExpiries[$group] === false ) {
+ return Status::newFatal( 'userrights-invalid-expiry', $group );
+ }
+
+ // not allowed to have things expiring in the past
+ if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
+ return Status::newFatal( 'userrights-expiry-in-past', $group );
+ }
+
+ // if the user can only add this group (not remove it), the expiry time
+ // cannot be brought forward (T156784)
+ if ( !$this->canRemove( $group ) &&
+ isset( $existingUGMs[$group] ) &&
+ ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
+ ( $groupExpiries[$group] ?: 'infinity' )
+ ) {
+ return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
+ }
+ }
+ } else {
+ $removegroup[] = $group;
+ }
+ }
+
+ $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
+
+ return Status::newGood();
+ }
+
+ /**
+ * Save user groups changes in the database. This function does not throw errors;
+ * instead, it ignores groups that the performer does not have permission to set.
+ *
+ * @param User|UserRightsProxy $user
+ * @param array $add Array of groups to add
+ * @param array $remove Array of groups to remove
+ * @param string $reason Reason for group change
+ * @param array $tags Array of change tags to add to the log entry
+ * @param array $groupExpiries Associative array of (group name => expiry),
+ * containing only those groups that are to have new expiry values set
+ * @return array Tuple of added, then removed groups
+ */
+ function doSaveUserGroups( $user, $add, $remove, $reason = '', $tags = [],
+ $groupExpiries = []
+ ) {
+ // Validate input set...
+ $isself = $user->getName() == $this->getUser()->getName();
+ $groups = $user->getGroups();
+ $ugms = $user->getGroupMemberships();
+ $changeable = $this->changeableGroups();
+ $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
+ $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
+
+ $remove = array_unique(
+ array_intersect( (array)$remove, $removable, $groups ) );
+ $add = array_intersect( (array)$add, $addable );
+
+ // add only groups that are not already present or that need their expiry updated,
+ // UNLESS the user can only add this group (not remove it) and the expiry time
+ // is being brought forward (T156784)
+ $add = array_filter( $add,
+ function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
+ if ( isset( $groupExpiries[$group] ) &&
+ !in_array( $group, $removable ) &&
+ isset( $ugms[$group] ) &&
+ ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
+ ( $groupExpiries[$group] ?: 'infinity' )
+ ) {
+ return false;
+ }
+ return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
+ } );
+
+ Hooks::run( 'ChangeUserGroups', [ $this->getUser(), $user, &$add, &$remove ] );
+
+ $oldGroups = $groups;
+ $oldUGMs = $user->getGroupMemberships();
+ $newGroups = $oldGroups;
+
+ // Remove groups, then add new ones/update expiries of existing ones
+ if ( $remove ) {
+ foreach ( $remove as $index => $group ) {
+ if ( !$user->removeGroup( $group ) ) {
+ unset( $remove[$index] );
+ }
+ }
+ $newGroups = array_diff( $newGroups, $remove );
+ }
+ if ( $add ) {
+ foreach ( $add as $index => $group ) {
+ $expiry = isset( $groupExpiries[$group] ) ? $groupExpiries[$group] : null;
+ if ( !$user->addGroup( $group, $expiry ) ) {
+ unset( $add[$index] );
+ }
+ }
+ $newGroups = array_merge( $newGroups, $add );
+ }
+ $newGroups = array_unique( $newGroups );
+ $newUGMs = $user->getGroupMemberships();
+
+ // Ensure that caches are cleared
+ $user->invalidateCache();
+
+ // update groups in external authentication database
+ Hooks::run( 'UserGroupsChanged', [ $user, $add, $remove, $this->getUser(),
+ $reason, $oldUGMs, $newUGMs ] );
+ MediaWiki\Auth\AuthManager::callLegacyAuthPlugin(
+ 'updateExternalDBGroups', [ $user, $add, $remove ]
+ );
+
+ wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) . "\n" );
+ wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" );
+ wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) . "\n" );
+ wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) . "\n" );
+ // Deprecated in favor of UserGroupsChanged hook
+ Hooks::run( 'UserRights', [ &$user, $add, $remove ], '1.26' );
+
+ // Only add a log entry if something actually changed
+ if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
+ $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
+ }
+
+ return [ $add, $remove ];
+ }
+
+ /**
+ * Serialise a UserGroupMembership object for storage in the log_params section
+ * of the logging table. Only keeps essential data, removing redundant fields.
+ *
+ * @param UserGroupMembership|null $ugm May be null if things get borked
+ * @return array
+ */
+ protected static function serialiseUgmForLog( $ugm ) {
+ if ( !$ugm instanceof UserGroupMembership ) {
+ return null;
+ }
+ return [ 'expiry' => $ugm->getExpiry() ];
+ }
+
+ /**
+ * Add a rights log entry for an action.
+ * @param User|UserRightsProxy $user
+ * @param array $oldGroups
+ * @param array $newGroups
+ * @param array $reason
+ * @param array $tags Change tags for the log entry
+ * @param array $oldUGMs Associative array of (group name => UserGroupMembership)
+ * @param array $newUGMs Associative array of (group name => UserGroupMembership)
+ */
+ protected function addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags,
+ $oldUGMs, $newUGMs
+ ) {
+ // make sure $oldUGMs and $newUGMs are in the same order, and serialise
+ // each UGM object to a simplified array
+ $oldUGMs = array_map( function ( $group ) use ( $oldUGMs ) {
+ return isset( $oldUGMs[$group] ) ?
+ self::serialiseUgmForLog( $oldUGMs[$group] ) :
+ null;
+ }, $oldGroups );
+ $newUGMs = array_map( function ( $group ) use ( $newUGMs ) {
+ return isset( $newUGMs[$group] ) ?
+ self::serialiseUgmForLog( $newUGMs[$group] ) :
+ null;
+ }, $newGroups );
+
+ $logEntry = new ManualLogEntry( 'rights', 'rights' );
+ $logEntry->setPerformer( $this->getUser() );
+ $logEntry->setTarget( $user->getUserPage() );
+ $logEntry->setComment( $reason );
+ $logEntry->setParameters( [
+ '4::oldgroups' => $oldGroups,
+ '5::newgroups' => $newGroups,
+ 'oldmetadata' => $oldUGMs,
+ 'newmetadata' => $newUGMs,
+ ] );
+ $logid = $logEntry->insert();
+ if ( count( $tags ) ) {
+ $logEntry->setTags( $tags );
+ }
+ $logEntry->publish( $logid );
+ }
+
+ /**
+ * Edit user groups membership
+ * @param string $username Name of the user.
+ */
+ function editUserGroupsForm( $username ) {
+ $status = $this->fetchUser( $username, true );
+ if ( !$status->isOK() ) {
+ $this->getOutput()->addWikiText( $status->getWikiText() );
+
+ return;
+ } else {
+ $user = $status->value;
+ }
+
+ $groups = $user->getGroups();
+ $groupMemberships = $user->getGroupMemberships();
+ $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
+
+ // This isn't really ideal logging behavior, but let's not hide the
+ // interwiki logs if we're using them as is.
+ $this->showLogFragment( $user, $this->getOutput() );
+ }
+
+ /**
+ * Normalize the input username, which may be local or remote, and
+ * return a user (or proxy) object for manipulating it.
+ *
+ * Side effects: error output for invalid access
+ * @param string $username
+ * @param bool $writing
+ * @return Status
+ */
+ public function fetchUser( $username, $writing = true ) {
+ $parts = explode( $this->getConfig()->get( 'UserrightsInterwikiDelimiter' ), $username );
+ if ( count( $parts ) < 2 ) {
+ $name = trim( $username );
+ $database = '';
+ } else {
+ list( $name, $database ) = array_map( 'trim', $parts );
+
+ if ( $database == wfWikiID() ) {
+ $database = '';
+ } else {
+ if ( $writing && !$this->getUser()->isAllowed( 'userrights-interwiki' ) ) {
+ return Status::newFatal( 'userrights-no-interwiki' );
+ }
+ if ( !UserRightsProxy::validDatabase( $database ) ) {
+ return Status::newFatal( 'userrights-nodatabase', $database );
+ }
+ }
+ }
+
+ if ( $name === '' ) {
+ return Status::newFatal( 'nouserspecified' );
+ }
+
+ if ( $name[0] == '#' ) {
+ // Numeric ID can be specified...
+ // We'll do a lookup for the name internally.
+ $id = intval( substr( $name, 1 ) );
+
+ if ( $database == '' ) {
+ $name = User::whoIs( $id );
+ } else {
+ $name = UserRightsProxy::whoIs( $database, $id );
+ }
+
+ if ( !$name ) {
+ return Status::newFatal( 'noname' );
+ }
+ } else {
+ $name = User::getCanonicalName( $name );
+ if ( $name === false ) {
+ // invalid name
+ return Status::newFatal( 'nosuchusershort', $username );
+ }
+ }
+
+ if ( $database == '' ) {
+ $user = User::newFromName( $name );
+ } else {
+ $user = UserRightsProxy::newFromName( $database, $name );
+ }
+
+ if ( !$user || $user->isAnon() ) {
+ return Status::newFatal( 'nosuchusershort', $username );
+ }
+
+ return Status::newGood( $user );
+ }
+
+ /**
+ * @since 1.15
+ *
+ * @param array $ids
+ *
+ * @return string
+ */
+ public function makeGroupNameList( $ids ) {
+ if ( empty( $ids ) ) {
+ return $this->msg( 'rightsnone' )->inContentLanguage()->text();
+ } else {
+ return implode( ', ', $ids );
+ }
+ }
+
+ /**
+ * Output a form to allow searching for a user
+ */
+ function switchForm() {
+ $this->getOutput()->addModules( 'mediawiki.userSuggest' );
+
+ $this->getOutput()->addHTML(
+ Html::openElement(
+ 'form',
+ [
+ 'method' => 'get',
+ 'action' => wfScript(),
+ 'name' => 'uluser',
+ 'id' => 'mw-userrights-form1'
+ ]
+ ) .
+ Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
+ Xml::fieldset( $this->msg( 'userrights-lookup-user' )->text() ) .
+ Xml::inputLabel(
+ $this->msg( 'userrights-user-editname' )->text(),
+ 'user',
+ 'username',
+ 30,
+ str_replace( '_', ' ', $this->mTarget ),
+ [
+ 'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
+ ] + (
+ // Set autofocus on blank input and error input
+ $this->mFetchedUser === null ? [ 'autofocus' => '' ] : []
+ )
+ ) . ' ' .
+ Xml::submitButton(
+ $this->msg( 'editusergroup' )->text()
+ ) .
+ Html::closeElement( 'fieldset' ) .
+ Html::closeElement( 'form' ) . "\n"
+ );
+ }
+
+ /**
+ * Show the form to edit group memberships.
+ *
+ * @param User|UserRightsProxy $user User or UserRightsProxy you're editing
+ * @param array $groups Array of groups the user is in. Not used by this implementation
+ * anymore, but kept for backward compatibility with subclasses
+ * @param array $groupMemberships Associative array of (group name => UserGroupMembership
+ * object) containing the groups the user is in
+ */
+ protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
+ $list = $membersList = $tempList = $tempMembersList = [];
+ foreach ( $groupMemberships as $ugm ) {
+ $linkG = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html' );
+ $linkM = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html',
+ $user->getName() );
+ if ( $ugm->getExpiry() ) {
+ $tempList[] = $linkG;
+ $tempMembersList[] = $linkM;
+ } else {
+ $list[] = $linkG;
+ $membersList[] = $linkM;
+
+ }
+ }
+
+ $autoList = [];
+ $autoMembersList = [];
+ if ( $user instanceof User ) {
+ foreach ( Autopromote::getAutopromoteGroups( $user ) as $group ) {
+ $autoList[] = UserGroupMembership::getLink( $group, $this->getContext(), 'html' );
+ $autoMembersList[] = UserGroupMembership::getLink( $group, $this->getContext(),
+ 'html', $user->getName() );
+ }
+ }
+
+ $language = $this->getLanguage();
+ $displayedList = $this->msg( 'userrights-groupsmember-type' )
+ ->rawParams(
+ $language->commaList( array_merge( $tempList, $list ) ),
+ $language->commaList( array_merge( $tempMembersList, $membersList ) )
+ )->escaped();
+ $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
+ ->rawParams(
+ $language->commaList( $autoList ),
+ $language->commaList( $autoMembersList )
+ )->escaped();
+
+ $grouplist = '';
+ $count = count( $list );
+ if ( $count > 0 ) {
+ $grouplist = $this->msg( 'userrights-groupsmember' )
+ ->numParams( $count )
+ ->params( $user->getName() )
+ ->parse();
+ $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
+ }
+
+ $count = count( $autoList );
+ if ( $count > 0 ) {
+ $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' )
+ ->numParams( $count )
+ ->params( $user->getName() )
+ ->parse();
+ $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n";
+ }
+
+ $userToolLinks = Linker::userToolLinks(
+ $user->getId(),
+ $user->getName(),
+ false, /* default for redContribsWhenNoEdits */
+ Linker::TOOL_LINKS_EMAIL /* Add "send e-mail" link */
+ );
+
+ list( $groupCheckboxes, $canChangeAny ) =
+ $this->groupCheckboxes( $groupMemberships, $user );
+ $this->getOutput()->addHTML(
+ Xml::openElement(
+ 'form',
+ [
+ 'method' => 'post',
+ 'action' => $this->getPageTitle()->getLocalURL(),
+ 'name' => 'editGroup',
+ 'id' => 'mw-userrights-form2'
+ ]
+ ) .
+ Html::hidden( 'user', $this->mTarget ) .
+ Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
+ Html::hidden(
+ 'conflictcheck-originalgroups',
+ implode( ',', $user->getGroups() )
+ ) . // Conflict detection
+ Xml::openElement( 'fieldset' ) .
+ Xml::element(
+ 'legend',
+ [],
+ $this->msg(
+ $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
+ $user->getName()
+ )->text()
+ ) .
+ $this->msg(
+ $canChangeAny ? 'editinguser' : 'viewinguserrights'
+ )->params( wfEscapeWikiText( $user->getName() ) )
+ ->rawParams( $userToolLinks )->parse()
+ );
+ if ( $canChangeAny ) {
+ $this->getOutput()->addHTML(
+ $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
+ $grouplist .
+ $groupCheckboxes .
+ Xml::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
+ "<tr>
+ <td class='mw-label'>" .
+ Xml::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
+ "</td>
+ <td class='mw-input'>" .
+ Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason', false ),
+ [ 'id' => 'wpReason', 'maxlength' => 255 ] ) .
+ "</td>
+ </tr>
+ <tr>
+ <td></td>
+ <td class='mw-submit'>" .
+ Xml::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
+ [ 'name' => 'saveusergroups' ] +
+ Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
+ ) .
+ "</td>
+ </tr>" .
+ Xml::closeElement( 'table' ) . "\n"
+ );
+ } else {
+ $this->getOutput()->addHTML( $grouplist );
+ }
+ $this->getOutput()->addHTML(
+ Xml::closeElement( 'fieldset' ) .
+ Xml::closeElement( 'form' ) . "\n"
+ );
+ }
+
+ /**
+ * Returns an array of all groups that may be edited
+ * @return array Array of groups that may be edited.
+ */
+ protected static function getAllGroups() {
+ return User::getAllGroups();
+ }
+
+ /**
+ * Adds a table with checkboxes where you can select what groups to add/remove
+ *
+ * @param array $usergroups Associative array of (group name as string =>
+ * UserGroupMembership object) for groups the user belongs to
+ * @param User $user
+ * @return Array with 2 elements: the XHTML table element with checkxboes, and
+ * whether any groups are changeable
+ */
+ private function groupCheckboxes( $usergroups, $user ) {
+ $allgroups = $this->getAllGroups();
+ $ret = '';
+
+ // Get the list of preset expiry times from the system message
+ $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
+ $expiryOptions = $expiryOptionsMsg->isDisabled() ?
+ [] :
+ explode( ',', $expiryOptionsMsg->text() );
+
+ // Put all column info into an associative array so that extensions can
+ // more easily manage it.
+ $columns = [ 'unchangeable' => [], 'changeable' => [] ];
+
+ foreach ( $allgroups as $group ) {
+ $set = isset( $usergroups[$group] );
+ // Users who can add the group, but not remove it, can only lengthen
+ // expiries, not shorten them. So they should only see the expiry
+ // dropdown if the group currently has a finite expiry
+ $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
+ !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
+ // Should the checkbox be disabled?
+ $disabledCheckbox = !(
+ ( $set && $this->canRemove( $group ) ) ||
+ ( !$set && $this->canAdd( $group ) ) );
+ // Should the expiry elements be disabled?
+ $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
+ // Do we need to point out that this action is irreversible?
+ $irreversible = !$disabledCheckbox && (
+ ( $set && !$this->canAdd( $group ) ) ||
+ ( !$set && !$this->canRemove( $group ) ) );
+
+ $checkbox = [
+ 'set' => $set,
+ 'disabled' => $disabledCheckbox,
+ 'disabled-expiry' => $disabledExpiry,
+ 'irreversible' => $irreversible
+ ];
+
+ if ( $disabledCheckbox && $disabledExpiry ) {
+ $columns['unchangeable'][$group] = $checkbox;
+ } else {
+ $columns['changeable'][$group] = $checkbox;
+ }
+ }
+
+ // Build the HTML table
+ $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
+ "<tr>\n";
+ foreach ( $columns as $name => $column ) {
+ if ( $column === [] ) {
+ continue;
+ }
+ // Messages: userrights-changeable-col, userrights-unchangeable-col
+ $ret .= Xml::element(
+ 'th',
+ null,
+ $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
+ );
+ }
+
+ $ret .= "</tr>\n<tr>\n";
+ foreach ( $columns as $column ) {
+ if ( $column === [] ) {
+ continue;
+ }
+ $ret .= "\t<td style='vertical-align:top;'>\n";
+ foreach ( $column as $group => $checkbox ) {
+ $attr = $checkbox['disabled'] ? [ 'disabled' => 'disabled' ] : [];
+
+ $member = UserGroupMembership::getGroupMemberName( $group, $user->getName() );
+ if ( $checkbox['irreversible'] ) {
+ $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
+ } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
+ $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
+ } else {
+ $text = $member;
+ }
+ $checkboxHtml = Xml::checkLabel( $text, "wpGroup-" . $group,
+ "wpGroup-" . $group, $checkbox['set'], $attr );
+ $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
+ ? Xml::tags( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
+ : Xml::tags( 'div', [], $checkboxHtml )
+ ) . "\n";
+
+ if ( $this->canProcessExpiries() ) {
+ $uiUser = $this->getUser();
+ $uiLanguage = $this->getLanguage();
+
+ $currentExpiry = isset( $usergroups[$group] ) ?
+ $usergroups[$group]->getExpiry() :
+ null;
+
+ // If the user can't modify the expiry, print the current expiry below
+ // it in plain text. Otherwise provide UI to set/change the expiry
+ if ( $checkbox['set'] &&
+ ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
+ ) {
+ if ( $currentExpiry ) {
+ $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
+ $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
+ $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
+ $expiryHtml = $this->msg( 'userrights-expiry-current' )->params(
+ $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text();
+ } else {
+ $expiryHtml = $this->msg( 'userrights-expiry-none' )->text();
+ }
+ $expiryHtml .= "<br />\n";
+ } else {
+ $expiryHtml = Xml::element( 'span', null,
+ $this->msg( 'userrights-expiry' )->text() );
+ $expiryHtml .= Xml::openElement( 'span' );
+
+ // add a form element to set the expiry date
+ $expiryFormOptions = new XmlSelect(
+ "wpExpiry-$group",
+ "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
+ $currentExpiry ? 'existing' : 'infinite'
+ );
+ if ( $checkbox['disabled-expiry'] ) {
+ $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
+ }
+
+ if ( $currentExpiry ) {
+ $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
+ $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
+ $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
+ $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
+ $timestamp, $d, $t );
+ $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
+ }
+
+ $expiryFormOptions->addOption(
+ $this->msg( 'userrights-expiry-none' )->text(),
+ 'infinite'
+ );
+ $expiryFormOptions->addOption(
+ $this->msg( 'userrights-expiry-othertime' )->text(),
+ 'other'
+ );
+ foreach ( $expiryOptions as $option ) {
+ if ( strpos( $option, ":" ) === false ) {
+ $displayText = $value = $option;
+ } else {
+ list( $displayText, $value ) = explode( ":", $option );
+ }
+ $expiryFormOptions->addOption( $displayText, htmlspecialchars( $value ) );
+ }
+
+ // Add expiry dropdown
+ $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
+
+ // Add custom expiry field
+ $attribs = [ 'id' => "mw-input-wpExpiry-$group-other" ];
+ if ( $checkbox['disabled-expiry'] ) {
+ $attribs['disabled'] = 'disabled';
+ }
+ $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs );
+
+ // If the user group is set but the checkbox is disabled, mimic a
+ // checked checkbox in the form submission
+ if ( $checkbox['set'] && $checkbox['disabled'] ) {
+ $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
+ }
+
+ $expiryHtml .= Xml::closeElement( 'span' );
+ }
+
+ $divAttribs = [
+ 'id' => "mw-userrights-nested-wpGroup-$group",
+ 'class' => 'mw-userrights-nested',
+ ];
+ $ret .= "\t\t\t" . Xml::tags( 'div', $divAttribs, $expiryHtml ) . "\n";
+ }
+ }
+ $ret .= "\t</td>\n";
+ }
+ $ret .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'table' );
+
+ return [ $ret, (bool)$columns['changeable'] ];
+ }
+
+ /**
+ * @param string $group The name of the group to check
+ * @return bool Can we remove the group?
+ */
+ private function canRemove( $group ) {
+ $groups = $this->changeableGroups();
+
+ return in_array(
+ $group,
+ $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
+ );
+ }
+
+ /**
+ * @param string $group The name of the group to check
+ * @return bool Can we add the group?
+ */
+ private function canAdd( $group ) {
+ $groups = $this->changeableGroups();
+
+ return in_array(
+ $group,
+ $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
+ );
+ }
+
+ /**
+ * Returns $this->getUser()->changeableGroups()
+ *
+ * @return array Array(
+ * 'add' => array( addablegroups ),
+ * 'remove' => array( removablegroups ),
+ * 'add-self' => array( addablegroups to self ),
+ * 'remove-self' => array( removable groups from self )
+ * )
+ */
+ function changeableGroups() {
+ return $this->getUser()->changeableGroups();
+ }
+
+ /**
+ * Show a rights log fragment for the specified user
+ *
+ * @param User $user User to show log for
+ * @param OutputPage $output OutputPage to use
+ */
+ protected function showLogFragment( $user, $output ) {
+ $rightsLogPage = new LogPage( 'rights' );
+ $output->addHTML( Xml::element( 'h2', null, $rightsLogPage->getName()->text() ) );
+ LogEventsList::showLogExtract( $output, 'rights', $user->getUserPage() );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialVersion.php b/www/wiki/includes/specials/SpecialVersion.php
new file mode 100644
index 00000000..f176b407
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialVersion.php
@@ -0,0 +1,1198 @@
+<?php
+/**
+ * Implements Special:Version
+ *
+ * Copyright © 2005 Ævar Arnfjörð Bjarmason
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Give information about the version of MediaWiki, PHP, the DB and extensions
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialVersion extends SpecialPage {
+ protected $firstExtOpened = false;
+
+ /**
+ * Stores the current rev id/SHA hash of MediaWiki core
+ */
+ protected $coreId = '';
+
+ protected static $extensionTypes = false;
+
+ public function __construct() {
+ parent::__construct( 'Version' );
+ }
+
+ /**
+ * main()
+ * @param string|null $par
+ */
+ public function execute( $par ) {
+ global $IP, $wgExtensionCredits;
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $out = $this->getOutput();
+ $out->allowClickjacking();
+
+ // Explode the sub page information into useful bits
+ $parts = explode( '/', (string)$par );
+ $extNode = null;
+ if ( isset( $parts[1] ) ) {
+ $extName = str_replace( '_', ' ', $parts[1] );
+ // Find it!
+ foreach ( $wgExtensionCredits as $group => $extensions ) {
+ foreach ( $extensions as $ext ) {
+ if ( isset( $ext['name'] ) && ( $ext['name'] === $extName ) ) {
+ $extNode = &$ext;
+ break 2;
+ }
+ }
+ }
+ if ( !$extNode ) {
+ $out->setStatusCode( 404 );
+ }
+ } else {
+ $extName = 'MediaWiki';
+ }
+
+ // Now figure out what to do
+ switch ( strtolower( $parts[0] ) ) {
+ case 'credits':
+ $out->addModuleStyles( 'mediawiki.special.version' );
+
+ $wikiText = '{{int:version-credits-not-found}}';
+ if ( $extName === 'MediaWiki' ) {
+ $wikiText = file_get_contents( $IP . '/CREDITS' );
+ // Put the contributor list into columns
+ $wikiText = str_replace(
+ [ '<!-- BEGIN CONTRIBUTOR LIST -->', '<!-- END CONTRIBUTOR LIST -->' ],
+ [ '<div class="mw-version-credits">', '</div>' ],
+ $wikiText );
+ } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
+ $file = $this->getExtAuthorsFileName( dirname( $extNode['path'] ) );
+ if ( $file ) {
+ $wikiText = file_get_contents( $file );
+ if ( substr( $file, -4 ) === '.txt' ) {
+ $wikiText = Html::element(
+ 'pre',
+ [
+ 'lang' => 'en',
+ 'dir' => 'ltr',
+ ],
+ $wikiText
+ );
+ }
+ }
+ }
+
+ $out->setPageTitle( $this->msg( 'version-credits-title', $extName ) );
+ $out->addWikiText( $wikiText );
+ break;
+
+ case 'license':
+ $wikiText = '{{int:version-license-not-found}}';
+ if ( $extName === 'MediaWiki' ) {
+ $wikiText = file_get_contents( $IP . '/COPYING' );
+ } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
+ $file = $this->getExtLicenseFileName( dirname( $extNode['path'] ) );
+ if ( $file ) {
+ $wikiText = file_get_contents( $file );
+ $wikiText = Html::element(
+ 'pre',
+ [
+ 'lang' => 'en',
+ 'dir' => 'ltr',
+ ],
+ $wikiText
+ );
+ }
+ }
+
+ $out->setPageTitle( $this->msg( 'version-license-title', $extName ) );
+ $out->addWikiText( $wikiText );
+ break;
+
+ default:
+ $out->addModuleStyles( 'mediawiki.special.version' );
+ $out->addWikiText(
+ $this->getMediaWikiCredits() .
+ $this->softwareInformation() .
+ $this->getEntryPointInfo()
+ );
+ $out->addHTML(
+ $this->getSkinCredits() .
+ $this->getExtensionCredits() .
+ $this->getExternalLibraries() .
+ $this->getParserTags() .
+ $this->getParserFunctionHooks()
+ );
+ $out->addWikiText( $this->getWgHooks() );
+ $out->addHTML( $this->IPInfo() );
+
+ break;
+ }
+ }
+
+ /**
+ * Returns wiki text showing the license information.
+ *
+ * @return string
+ */
+ private static function getMediaWikiCredits() {
+ $ret = Xml::element(
+ 'h2',
+ [ 'id' => 'mw-version-license' ],
+ wfMessage( 'version-license' )->text()
+ );
+
+ // This text is always left-to-right.
+ $ret .= '<div class="plainlinks">';
+ $ret .= "__NOTOC__
+ " . self::getCopyrightAndAuthorList() . "\n
+ " . wfMessage( 'version-license-info' )->text();
+ $ret .= '</div>';
+
+ return str_replace( "\t\t", '', $ret ) . "\n";
+ }
+
+ /**
+ * Get the "MediaWiki is copyright 2001-20xx by lots of cool guys" text
+ *
+ * @return string
+ */
+ public static function getCopyrightAndAuthorList() {
+ global $wgLang;
+
+ if ( defined( 'MEDIAWIKI_INSTALL' ) ) {
+ $othersLink = '[https://www.mediawiki.org/wiki/Special:Version/Credits ' .
+ wfMessage( 'version-poweredby-others' )->text() . ']';
+ } else {
+ $othersLink = '[[Special:Version/Credits|' .
+ wfMessage( 'version-poweredby-others' )->text() . ']]';
+ }
+
+ $translatorsLink = '[https://translatewiki.net/wiki/Translating:MediaWiki/Credits ' .
+ wfMessage( 'version-poweredby-translators' )->text() . ']';
+
+ $authorList = [
+ 'Magnus Manske', 'Brion Vibber', 'Lee Daniel Crocker',
+ 'Tim Starling', 'Erik Möller', 'Gabriel Wicke', 'Ævar Arnfjörð Bjarmason',
+ 'Niklas Laxström', 'Domas Mituzas', 'Rob Church', 'Yuri Astrakhan',
+ 'Aryeh Gregor', 'Aaron Schulz', 'Andrew Garrett', 'Raimond Spekking',
+ 'Alexandre Emsenhuber', 'Siebrand Mazeland', 'Chad Horohoe',
+ 'Roan Kattouw', 'Trevor Parscal', 'Bryan Tong Minh', 'Sam Reed',
+ 'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso',
+ 'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch',
+ 'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender',
+ $othersLink, $translatorsLink
+ ];
+
+ return wfMessage( 'version-poweredby-credits', MWTimestamp::getLocalInstance()->format( 'Y' ),
+ $wgLang->listToText( $authorList ) )->text();
+ }
+
+ /**
+ * Returns wiki text showing the third party software versions (apache, php, mysql).
+ *
+ * @return string
+ */
+ public static function softwareInformation() {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ // Put the software in an array of form 'name' => 'version'. All messages should
+ // be loaded here, so feel free to use wfMessage in the 'name'. Raw HTML or
+ // wikimarkup can be used.
+ $software = [];
+ $software['[https://www.mediawiki.org/ MediaWiki]'] = self::getVersionLinked();
+ if ( wfIsHHVM() ) {
+ $software['[http://hhvm.com/ HHVM]'] = HHVM_VERSION . " (" . PHP_SAPI . ")";
+ } else {
+ $software['[https://php.net/ PHP]'] = PHP_VERSION . " (" . PHP_SAPI . ")";
+ }
+ $software[$dbr->getSoftwareLink()] = $dbr->getServerInfo();
+
+ if ( IcuCollation::getICUVersion() ) {
+ $software['[http://site.icu-project.org/ ICU]'] = IcuCollation::getICUVersion();
+ }
+
+ // Allow a hook to add/remove items.
+ Hooks::run( 'SoftwareInfo', [ &$software ] );
+
+ $out = Xml::element(
+ 'h2',
+ [ 'id' => 'mw-version-software' ],
+ wfMessage( 'version-software' )->text()
+ ) .
+ Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] ) .
+ "<tr>
+ <th>" . wfMessage( 'version-software-product' )->text() . "</th>
+ <th>" . wfMessage( 'version-software-version' )->text() . "</th>
+ </tr>\n";
+
+ foreach ( $software as $name => $version ) {
+ $out .= "<tr>
+ <td>" . $name . "</td>
+ <td dir=\"ltr\">" . $version . "</td>
+ </tr>\n";
+ }
+
+ return $out . Xml::closeElement( 'table' );
+ }
+
+ /**
+ * Return a string of the MediaWiki version with Git revision if available.
+ *
+ * @param string $flags
+ * @param Language|string|null $lang
+ * @return mixed
+ */
+ public static function getVersion( $flags = '', $lang = null ) {
+ global $wgVersion, $IP;
+
+ $gitInfo = self::getGitHeadSha1( $IP );
+ if ( !$gitInfo ) {
+ $version = $wgVersion;
+ } elseif ( $flags === 'nodb' ) {
+ $shortSha1 = substr( $gitInfo, 0, 7 );
+ $version = "$wgVersion ($shortSha1)";
+ } else {
+ $shortSha1 = substr( $gitInfo, 0, 7 );
+ $msg = wfMessage( 'parentheses' );
+ if ( $lang !== null ) {
+ $msg->inLanguage( $lang );
+ }
+ $shortSha1 = $msg->params( $shortSha1 )->escaped();
+ $version = "$wgVersion $shortSha1";
+ }
+
+ return $version;
+ }
+
+ /**
+ * Return a wikitext-formatted string of the MediaWiki version with a link to
+ * the Git SHA1 of head if available.
+ * The fallback is just $wgVersion
+ *
+ * @return mixed
+ */
+ public static function getVersionLinked() {
+ global $wgVersion;
+
+ $gitVersion = self::getVersionLinkedGit();
+ if ( $gitVersion ) {
+ $v = $gitVersion;
+ } else {
+ $v = $wgVersion; // fallback
+ }
+
+ return $v;
+ }
+
+ /**
+ * @return string
+ */
+ private static function getwgVersionLinked() {
+ global $wgVersion;
+ $versionUrl = "";
+ if ( Hooks::run( 'SpecialVersionVersionUrl', [ $wgVersion, &$versionUrl ] ) ) {
+ $versionParts = [];
+ preg_match( "/^(\d+\.\d+)/", $wgVersion, $versionParts );
+ $versionUrl = "https://www.mediawiki.org/wiki/MediaWiki_{$versionParts[1]}";
+ }
+
+ return "[$versionUrl $wgVersion]";
+ }
+
+ /**
+ * @since 1.22 Returns the HEAD date in addition to the sha1 and link
+ * @return bool|string Global wgVersion + HEAD sha1 stripped to the first 7 chars
+ * with link and date, or false on failure
+ */
+ private static function getVersionLinkedGit() {
+ global $IP, $wgLang;
+
+ $gitInfo = new GitInfo( $IP );
+ $headSHA1 = $gitInfo->getHeadSHA1();
+ if ( !$headSHA1 ) {
+ return false;
+ }
+
+ $shortSHA1 = '(' . substr( $headSHA1, 0, 7 ) . ')';
+
+ $gitHeadUrl = $gitInfo->getHeadViewUrl();
+ if ( $gitHeadUrl !== false ) {
+ $shortSHA1 = "[$gitHeadUrl $shortSHA1]";
+ }
+
+ $gitHeadCommitDate = $gitInfo->getHeadCommitDate();
+ if ( $gitHeadCommitDate ) {
+ $shortSHA1 .= Html::element( 'br' ) . $wgLang->timeanddate( $gitHeadCommitDate, true );
+ }
+
+ return self::getwgVersionLinked() . " $shortSHA1";
+ }
+
+ /**
+ * Returns an array with the base extension types.
+ * Type is stored as array key, the message as array value.
+ *
+ * TODO: ideally this would return all extension types.
+ *
+ * @since 1.17
+ *
+ * @return array
+ */
+ public static function getExtensionTypes() {
+ if ( self::$extensionTypes === false ) {
+ self::$extensionTypes = [
+ 'specialpage' => wfMessage( 'version-specialpages' )->text(),
+ 'parserhook' => wfMessage( 'version-parserhooks' )->text(),
+ 'variable' => wfMessage( 'version-variables' )->text(),
+ 'media' => wfMessage( 'version-mediahandlers' )->text(),
+ 'antispam' => wfMessage( 'version-antispam' )->text(),
+ 'skin' => wfMessage( 'version-skins' )->text(),
+ 'api' => wfMessage( 'version-api' )->text(),
+ 'other' => wfMessage( 'version-other' )->text(),
+ ];
+
+ Hooks::run( 'ExtensionTypes', [ &self::$extensionTypes ] );
+ }
+
+ return self::$extensionTypes;
+ }
+
+ /**
+ * Returns the internationalized name for an extension type.
+ *
+ * @since 1.17
+ *
+ * @param string $type
+ *
+ * @return string
+ */
+ public static function getExtensionTypeName( $type ) {
+ $types = self::getExtensionTypes();
+
+ return isset( $types[$type] ) ? $types[$type] : $types['other'];
+ }
+
+ /**
+ * Generate wikitext showing the name, URL, author and description of each extension.
+ *
+ * @return string Wikitext
+ */
+ public function getExtensionCredits() {
+ global $wgExtensionCredits;
+
+ if (
+ count( $wgExtensionCredits ) === 0 ||
+ // Skins are displayed separately, see getSkinCredits()
+ ( count( $wgExtensionCredits ) === 1 && isset( $wgExtensionCredits['skin'] ) )
+ ) {
+ return '';
+ }
+
+ $extensionTypes = self::getExtensionTypes();
+
+ $out = Xml::element(
+ 'h2',
+ [ 'id' => 'mw-version-ext' ],
+ $this->msg( 'version-extensions' )->text()
+ ) .
+ Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-ext' ] );
+
+ // Make sure the 'other' type is set to an array.
+ if ( !array_key_exists( 'other', $wgExtensionCredits ) ) {
+ $wgExtensionCredits['other'] = [];
+ }
+
+ // Find all extensions that do not have a valid type and give them the type 'other'.
+ foreach ( $wgExtensionCredits as $type => $extensions ) {
+ if ( !array_key_exists( $type, $extensionTypes ) ) {
+ $wgExtensionCredits['other'] = array_merge( $wgExtensionCredits['other'], $extensions );
+ }
+ }
+
+ $this->firstExtOpened = false;
+ // Loop through the extension categories to display their extensions in the list.
+ foreach ( $extensionTypes as $type => $message ) {
+ // Skins have a separate section
+ if ( $type !== 'other' && $type !== 'skin' ) {
+ $out .= $this->getExtensionCategory( $type, $message );
+ }
+ }
+
+ // We want the 'other' type to be last in the list.
+ $out .= $this->getExtensionCategory( 'other', $extensionTypes['other'] );
+
+ $out .= Xml::closeElement( 'table' );
+
+ return $out;
+ }
+
+ /**
+ * Generate wikitext showing the name, URL, author and description of each skin.
+ *
+ * @return string Wikitext
+ */
+ public function getSkinCredits() {
+ global $wgExtensionCredits;
+ if ( !isset( $wgExtensionCredits['skin'] ) || count( $wgExtensionCredits['skin'] ) === 0 ) {
+ return '';
+ }
+
+ $out = Xml::element(
+ 'h2',
+ [ 'id' => 'mw-version-skin' ],
+ $this->msg( 'version-skins' )->text()
+ ) .
+ Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-skin' ] );
+
+ $this->firstExtOpened = false;
+ $out .= $this->getExtensionCategory( 'skin', null );
+
+ $out .= Xml::closeElement( 'table' );
+
+ return $out;
+ }
+
+ /**
+ * Generate an HTML table for external libraries that are installed
+ *
+ * @return string
+ */
+ protected function getExternalLibraries() {
+ global $IP;
+ $path = "$IP/vendor/composer/installed.json";
+ if ( !file_exists( $path ) ) {
+ return '';
+ }
+
+ $installed = new ComposerInstalled( $path );
+ $out = Html::element(
+ 'h2',
+ [ 'id' => 'mw-version-libraries' ],
+ $this->msg( 'version-libraries' )->text()
+ );
+ $out .= Html::openElement(
+ 'table',
+ [ 'class' => 'wikitable plainlinks', 'id' => 'sv-libraries' ]
+ );
+ $out .= Html::openElement( 'tr' )
+ . Html::element( 'th', [], $this->msg( 'version-libraries-library' )->text() )
+ . Html::element( 'th', [], $this->msg( 'version-libraries-version' )->text() )
+ . Html::element( 'th', [], $this->msg( 'version-libraries-license' )->text() )
+ . Html::element( 'th', [], $this->msg( 'version-libraries-description' )->text() )
+ . Html::element( 'th', [], $this->msg( 'version-libraries-authors' )->text() )
+ . Html::closeElement( 'tr' );
+
+ foreach ( $installed->getInstalledDependencies() as $name => $info ) {
+ if ( strpos( $info['type'], 'mediawiki-' ) === 0 ) {
+ // Skip any extensions or skins since they'll be listed
+ // in their proper section
+ continue;
+ }
+ $authors = array_map( function ( $arr ) {
+ // If a homepage is set, link to it
+ if ( isset( $arr['homepage'] ) ) {
+ return "[{$arr['homepage']} {$arr['name']}]";
+ }
+ return $arr['name'];
+ }, $info['authors'] );
+ $authors = $this->listAuthors( $authors, false, "$IP/vendor/$name" );
+
+ // We can safely assume that the libraries' names and descriptions
+ // are written in English and aren't going to be translated,
+ // so set appropriate lang and dir attributes
+ $out .= Html::openElement( 'tr' )
+ . Html::rawElement(
+ 'td',
+ [],
+ Linker::makeExternalLink(
+ "https://packagist.org/packages/$name", $name,
+ true, '',
+ [ 'class' => 'mw-version-library-name' ]
+ )
+ )
+ . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
+ . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['licenses'] ) )
+ . Html::element( 'td', [ 'lang' => 'en', 'dir' => 'ltr' ], $info['description'] )
+ . Html::rawElement( 'td', [], $authors )
+ . Html::closeElement( 'tr' );
+ }
+ $out .= Html::closeElement( 'table' );
+
+ return $out;
+ }
+
+ /**
+ * Obtains a list of installed parser tags and the associated H2 header
+ *
+ * @return string HTML output
+ */
+ protected function getParserTags() {
+ global $wgParser;
+
+ $tags = $wgParser->getTags();
+
+ if ( count( $tags ) ) {
+ $out = Html::rawElement(
+ 'h2',
+ [
+ 'class' => 'mw-headline plainlinks',
+ 'id' => 'mw-version-parser-extensiontags',
+ ],
+ Linker::makeExternalLink(
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Tag_extensions',
+ $this->msg( 'version-parser-extensiontags' )->parse(),
+ false /* msg()->parse() already escapes */
+ )
+ );
+
+ array_walk( $tags, function ( &$value ) {
+ // Bidirectional isolation improves readability in RTL wikis
+ $value = Html::element(
+ 'bdi',
+ // Prevent < and > from slipping to another line
+ [
+ 'style' => 'white-space: nowrap;',
+ ],
+ "<$value>"
+ );
+ } );
+
+ $out .= $this->listToText( $tags );
+ } else {
+ $out = '';
+ }
+
+ return $out;
+ }
+
+ /**
+ * Obtains a list of installed parser function hooks and the associated H2 header
+ *
+ * @return string HTML output
+ */
+ protected function getParserFunctionHooks() {
+ global $wgParser;
+
+ $fhooks = $wgParser->getFunctionHooks();
+ if ( count( $fhooks ) ) {
+ $out = Html::rawElement(
+ 'h2',
+ [
+ 'class' => 'mw-headline plainlinks',
+ 'id' => 'mw-version-parser-function-hooks',
+ ],
+ Linker::makeExternalLink(
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Parser_functions',
+ $this->msg( 'version-parser-function-hooks' )->parse(),
+ false /* msg()->parse() already escapes */
+ )
+ );
+
+ $out .= $this->listToText( $fhooks );
+ } else {
+ $out = '';
+ }
+
+ return $out;
+ }
+
+ /**
+ * Creates and returns the HTML for a single extension category.
+ *
+ * @since 1.17
+ *
+ * @param string $type
+ * @param string $message
+ *
+ * @return string
+ */
+ protected function getExtensionCategory( $type, $message ) {
+ global $wgExtensionCredits;
+
+ $out = '';
+
+ if ( array_key_exists( $type, $wgExtensionCredits ) && count( $wgExtensionCredits[$type] ) > 0 ) {
+ $out .= $this->openExtType( $message, 'credits-' . $type );
+
+ usort( $wgExtensionCredits[$type], [ $this, 'compare' ] );
+
+ foreach ( $wgExtensionCredits[$type] as $extension ) {
+ $out .= $this->getCreditsForExtension( $type, $extension );
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Callback to sort extensions by type.
+ * @param array $a
+ * @param array $b
+ * @return int
+ */
+ public function compare( $a, $b ) {
+ if ( $a['name'] === $b['name'] ) {
+ return 0;
+ } else {
+ return $this->getLanguage()->lc( $a['name'] ) > $this->getLanguage()->lc( $b['name'] )
+ ? 1
+ : -1;
+ }
+ }
+
+ /**
+ * Creates and formats a version line for a single extension.
+ *
+ * Information for five columns will be created. Parameters required in the
+ * $extension array for part rendering are indicated in ()
+ * - The name of (name), and URL link to (url), the extension
+ * - Official version number (version) and if available version control system
+ * revision (path), link, and date
+ * - If available the short name of the license (license-name) and a link
+ * to ((LICENSE)|(COPYING))(\.txt)? if it exists.
+ * - Description of extension (descriptionmsg or description)
+ * - List of authors (author) and link to a ((AUTHORS)|(CREDITS))(\.txt)? file if it exists
+ *
+ * @param string $type Category name of the extension
+ * @param array $extension
+ *
+ * @return string Raw HTML
+ */
+ public function getCreditsForExtension( $type, array $extension ) {
+ $out = $this->getOutput();
+
+ // We must obtain the information for all the bits and pieces!
+ // ... such as extension names and links
+ if ( isset( $extension['namemsg'] ) ) {
+ // Localized name of extension
+ $extensionName = $this->msg( $extension['namemsg'] )->text();
+ } elseif ( isset( $extension['name'] ) ) {
+ // Non localized version
+ $extensionName = $extension['name'];
+ } else {
+ $extensionName = $this->msg( 'version-no-ext-name' )->text();
+ }
+
+ if ( isset( $extension['url'] ) ) {
+ $extensionNameLink = Linker::makeExternalLink(
+ $extension['url'],
+ $extensionName,
+ true,
+ '',
+ [ 'class' => 'mw-version-ext-name' ]
+ );
+ } else {
+ $extensionNameLink = $extensionName;
+ }
+
+ // ... and the version information
+ // If the extension path is set we will check that directory for GIT
+ // metadata in an attempt to extract date and vcs commit metadata.
+ $canonicalVersion = '&ndash;';
+ $extensionPath = null;
+ $vcsVersion = null;
+ $vcsLink = null;
+ $vcsDate = null;
+
+ if ( isset( $extension['version'] ) ) {
+ $canonicalVersion = $out->parseInline( $extension['version'] );
+ }
+
+ if ( isset( $extension['path'] ) ) {
+ global $IP;
+ $extensionPath = dirname( $extension['path'] );
+ if ( $this->coreId == '' ) {
+ wfDebug( 'Looking up core head id' );
+ $coreHeadSHA1 = self::getGitHeadSha1( $IP );
+ if ( $coreHeadSHA1 ) {
+ $this->coreId = $coreHeadSHA1;
+ }
+ }
+ $cache = wfGetCache( CACHE_ANYTHING );
+ $memcKey = $cache->makeKey(
+ 'specialversion-ext-version-text', $extension['path'], $this->coreId
+ );
+ list( $vcsVersion, $vcsLink, $vcsDate ) = $cache->get( $memcKey );
+
+ if ( !$vcsVersion ) {
+ wfDebug( "Getting VCS info for extension {$extension['name']}" );
+ $gitInfo = new GitInfo( $extensionPath );
+ $vcsVersion = $gitInfo->getHeadSHA1();
+ if ( $vcsVersion !== false ) {
+ $vcsVersion = substr( $vcsVersion, 0, 7 );
+ $vcsLink = $gitInfo->getHeadViewUrl();
+ $vcsDate = $gitInfo->getHeadCommitDate();
+ }
+ $cache->set( $memcKey, [ $vcsVersion, $vcsLink, $vcsDate ], 60 * 60 * 24 );
+ } else {
+ wfDebug( "Pulled VCS info for extension {$extension['name']} from cache" );
+ }
+ }
+
+ $versionString = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-version-ext-version' ],
+ $canonicalVersion
+ );
+
+ if ( $vcsVersion ) {
+ if ( $vcsLink ) {
+ $vcsVerString = Linker::makeExternalLink(
+ $vcsLink,
+ $this->msg( 'version-version', $vcsVersion ),
+ true,
+ '',
+ [ 'class' => 'mw-version-ext-vcs-version' ]
+ );
+ } else {
+ $vcsVerString = Html::element( 'span',
+ [ 'class' => 'mw-version-ext-vcs-version' ],
+ "({$vcsVersion})"
+ );
+ }
+ $versionString .= " {$vcsVerString}";
+
+ if ( $vcsDate ) {
+ $vcsTimeString = Html::element( 'span',
+ [ 'class' => 'mw-version-ext-vcs-timestamp' ],
+ $this->getLanguage()->timeanddate( $vcsDate, true )
+ );
+ $versionString .= " {$vcsTimeString}";
+ }
+ $versionString = Html::rawElement( 'span',
+ [ 'class' => 'mw-version-ext-meta-version' ],
+ $versionString
+ );
+ }
+
+ // ... and license information; if a license file exists we
+ // will link to it
+ $licenseLink = '';
+ if ( isset( $extension['name'] ) ) {
+ $licenseName = null;
+ if ( isset( $extension['license-name'] ) ) {
+ $licenseName = new HtmlArmor( $out->parseInline( $extension['license-name'] ) );
+ } elseif ( $this->getExtLicenseFileName( $extensionPath ) ) {
+ $licenseName = $this->msg( 'version-ext-license' )->text();
+ }
+ if ( $licenseName !== null ) {
+ $licenseLink = $this->getLinkRenderer()->makeLink(
+ $this->getPageTitle( 'License/' . $extension['name'] ),
+ $licenseName,
+ [
+ 'class' => 'mw-version-ext-license',
+ 'dir' => 'auto',
+ ]
+ );
+ }
+ }
+
+ // ... and generate the description; which can be a parameterized l10n message
+ // in the form array( <msgname>, <parameter>, <parameter>... ) or just a straight
+ // up string
+ if ( isset( $extension['descriptionmsg'] ) ) {
+ // Localized description of extension
+ $descriptionMsg = $extension['descriptionmsg'];
+
+ if ( is_array( $descriptionMsg ) ) {
+ $descriptionMsgKey = $descriptionMsg[0]; // Get the message key
+ array_shift( $descriptionMsg ); // Shift out the message key to get the parameters only
+ array_map( "htmlspecialchars", $descriptionMsg ); // For sanity
+ $description = $this->msg( $descriptionMsgKey, $descriptionMsg )->text();
+ } else {
+ $description = $this->msg( $descriptionMsg )->text();
+ }
+ } elseif ( isset( $extension['description'] ) ) {
+ // Non localized version
+ $description = $extension['description'];
+ } else {
+ $description = '';
+ }
+ $description = $out->parseInline( $description );
+
+ // ... now get the authors for this extension
+ $authors = isset( $extension['author'] ) ? $extension['author'] : [];
+ $authors = $this->listAuthors( $authors, $extension['name'], $extensionPath );
+
+ // Finally! Create the table
+ $html = Html::openElement( 'tr', [
+ 'class' => 'mw-version-ext',
+ 'id' => Sanitizer::escapeIdForAttribute( 'mw-version-ext-' . $type . '-' . $extension['name'] )
+ ]
+ );
+
+ $html .= Html::rawElement( 'td', [], $extensionNameLink );
+ $html .= Html::rawElement( 'td', [], $versionString );
+ $html .= Html::rawElement( 'td', [], $licenseLink );
+ $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-description' ], $description );
+ $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-authors' ], $authors );
+
+ $html .= Html::closeElement( 'tr' );
+
+ return $html;
+ }
+
+ /**
+ * Generate wikitext showing hooks in $wgHooks.
+ *
+ * @return string Wikitext
+ */
+ private function getWgHooks() {
+ global $wgSpecialVersionShowHooks, $wgHooks;
+
+ if ( $wgSpecialVersionShowHooks && count( $wgHooks ) ) {
+ $myWgHooks = $wgHooks;
+ ksort( $myWgHooks );
+
+ $ret = [];
+ $ret[] = '== {{int:version-hooks}} ==';
+ $ret[] = Html::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] );
+ $ret[] = Html::openElement( 'tr' );
+ $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-name' )->text() );
+ $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() );
+ $ret[] = Html::closeElement( 'tr' );
+
+ foreach ( $myWgHooks as $hook => $hooks ) {
+ $ret[] = Html::openElement( 'tr' );
+ $ret[] = Html::element( 'td', [], $hook );
+ $ret[] = Html::element( 'td', [], $this->listToText( $hooks ) );
+ $ret[] = Html::closeElement( 'tr' );
+ }
+
+ $ret[] = Html::closeElement( 'table' );
+
+ return implode( "\n", $ret );
+ } else {
+ return '';
+ }
+ }
+
+ private function openExtType( $text = null, $name = null ) {
+ $out = '';
+
+ $opt = [ 'colspan' => 5 ];
+ if ( $this->firstExtOpened ) {
+ // Insert a spacing line
+ $out .= Html::rawElement( 'tr', [ 'class' => 'sv-space' ],
+ Html::element( 'td', $opt )
+ );
+ }
+ $this->firstExtOpened = true;
+
+ if ( $name ) {
+ $opt['id'] = "sv-$name";
+ }
+
+ if ( $text !== null ) {
+ $out .= Html::rawElement( 'tr', [],
+ Html::element( 'th', $opt, $text )
+ );
+ }
+
+ $firstHeadingMsg = ( $name === 'credits-skin' )
+ ? 'version-skin-colheader-name'
+ : 'version-ext-colheader-name';
+ $out .= Html::openElement( 'tr' );
+ $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
+ $this->msg( $firstHeadingMsg )->text() );
+ $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
+ $this->msg( 'version-ext-colheader-version' )->text() );
+ $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
+ $this->msg( 'version-ext-colheader-license' )->text() );
+ $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
+ $this->msg( 'version-ext-colheader-description' )->text() );
+ $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ],
+ $this->msg( 'version-ext-colheader-credits' )->text() );
+ $out .= Html::closeElement( 'tr' );
+
+ return $out;
+ }
+
+ /**
+ * Get information about client's IP address.
+ *
+ * @return string HTML fragment
+ */
+ private function IPInfo() {
+ $ip = str_replace( '--', ' - ', htmlspecialchars( $this->getRequest()->getIP() ) );
+
+ return "<!-- visited from $ip -->\n<span style='display:none'>visited from $ip</span>";
+ }
+
+ /**
+ * Return a formatted unsorted list of authors
+ *
+ * 'And Others'
+ * If an item in the $authors array is '...' it is assumed to indicate an
+ * 'and others' string which will then be linked to an ((AUTHORS)|(CREDITS))(\.txt)?
+ * file if it exists in $dir.
+ *
+ * Similarly an entry ending with ' ...]' is assumed to be a link to an
+ * 'and others' page.
+ *
+ * If no '...' string variant is found, but an authors file is found an
+ * 'and others' will be added to the end of the credits.
+ *
+ * @param string|array $authors
+ * @param string|bool $extName Name of the extension for link creation,
+ * false if no links should be created
+ * @param string $extDir Path to the extension root directory
+ *
+ * @return string HTML fragment
+ */
+ public function listAuthors( $authors, $extName, $extDir ) {
+ $hasOthers = false;
+ $linkRenderer = $this->getLinkRenderer();
+
+ $list = [];
+ foreach ( (array)$authors as $item ) {
+ if ( $item == '...' ) {
+ $hasOthers = true;
+
+ if ( $extName && $this->getExtAuthorsFileName( $extDir ) ) {
+ $text = $linkRenderer->makeLink(
+ $this->getPageTitle( "Credits/$extName" ),
+ $this->msg( 'version-poweredby-others' )->text()
+ );
+ } else {
+ $text = $this->msg( 'version-poweredby-others' )->escaped();
+ }
+ $list[] = $text;
+ } elseif ( substr( $item, -5 ) == ' ...]' ) {
+ $hasOthers = true;
+ $list[] = $this->getOutput()->parseInline(
+ substr( $item, 0, -4 ) . $this->msg( 'version-poweredby-others' )->text() . "]"
+ );
+ } else {
+ $list[] = $this->getOutput()->parseInline( $item );
+ }
+ }
+
+ if ( $extName && !$hasOthers && $this->getExtAuthorsFileName( $extDir ) ) {
+ $list[] = $text = $linkRenderer->makeLink(
+ $this->getPageTitle( "Credits/$extName" ),
+ $this->msg( 'version-poweredby-others' )->text()
+ );
+ }
+
+ return $this->listToText( $list, false );
+ }
+
+ /**
+ * Obtains the full path of an extensions authors or credits file if
+ * one exists.
+ *
+ * @param string $extDir Path to the extensions root directory
+ *
+ * @since 1.23
+ *
+ * @return bool|string False if no such file exists, otherwise returns
+ * a path to it.
+ */
+ public static function getExtAuthorsFileName( $extDir ) {
+ if ( !$extDir ) {
+ return false;
+ }
+
+ foreach ( scandir( $extDir ) as $file ) {
+ $fullPath = $extDir . DIRECTORY_SEPARATOR . $file;
+ if ( preg_match( '/^((AUTHORS)|(CREDITS))(\.txt|\.wiki|\.mediawiki)?$/', $file ) &&
+ is_readable( $fullPath ) &&
+ is_file( $fullPath )
+ ) {
+ return $fullPath;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Obtains the full path of an extensions copying or license file if
+ * one exists.
+ *
+ * @param string $extDir Path to the extensions root directory
+ *
+ * @since 1.23
+ *
+ * @return bool|string False if no such file exists, otherwise returns
+ * a path to it.
+ */
+ public static function getExtLicenseFileName( $extDir ) {
+ if ( !$extDir ) {
+ return false;
+ }
+
+ foreach ( scandir( $extDir ) as $file ) {
+ $fullPath = $extDir . DIRECTORY_SEPARATOR . $file;
+ if ( preg_match( '/^((COPYING)|(LICENSE))(\.txt)?$/', $file ) &&
+ is_readable( $fullPath ) &&
+ is_file( $fullPath )
+ ) {
+ return $fullPath;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Convert an array of items into a list for display.
+ *
+ * @param array $list List of elements to display
+ * @param bool $sort Whether to sort the items in $list
+ *
+ * @return string
+ */
+ public function listToText( $list, $sort = true ) {
+ if ( !count( $list ) ) {
+ return '';
+ }
+ if ( $sort ) {
+ sort( $list );
+ }
+
+ return $this->getLanguage()
+ ->listToText( array_map( [ __CLASS__, 'arrayToString' ], $list ) );
+ }
+
+ /**
+ * Convert an array or object to a string for display.
+ *
+ * @param mixed $list Will convert an array to string if given and return
+ * the parameter unaltered otherwise
+ *
+ * @return mixed
+ */
+ public static function arrayToString( $list ) {
+ if ( is_array( $list ) && count( $list ) == 1 ) {
+ $list = $list[0];
+ }
+ if ( $list instanceof Closure ) {
+ // Don't output stuff like "Closure$;1028376090#8$48499d94fe0147f7c633b365be39952b$"
+ return 'Closure';
+ } elseif ( is_object( $list ) ) {
+ $class = wfMessage( 'parentheses' )->params( get_class( $list ) )->escaped();
+
+ return $class;
+ } elseif ( !is_array( $list ) ) {
+ return $list;
+ } else {
+ if ( is_object( $list[0] ) ) {
+ $class = get_class( $list[0] );
+ } else {
+ $class = $list[0];
+ }
+
+ return wfMessage( 'parentheses' )->params( "$class, {$list[1]}" )->escaped();
+ }
+ }
+
+ /**
+ * @param string $dir Directory of the git checkout
+ * @return bool|string Sha1 of commit HEAD points to
+ */
+ public static function getGitHeadSha1( $dir ) {
+ $repo = new GitInfo( $dir );
+
+ return $repo->getHeadSHA1();
+ }
+
+ /**
+ * @param string $dir Directory of the git checkout
+ * @return bool|string Branch currently checked out
+ */
+ public static function getGitCurrentBranch( $dir ) {
+ $repo = new GitInfo( $dir );
+ return $repo->getCurrentBranch();
+ }
+
+ /**
+ * Get the list of entry points and their URLs
+ * @return string Wikitext
+ */
+ public function getEntryPointInfo() {
+ global $wgArticlePath, $wgScriptPath;
+ $scriptPath = $wgScriptPath ? $wgScriptPath : "/";
+ $entryPoints = [
+ 'version-entrypoints-articlepath' => $wgArticlePath,
+ 'version-entrypoints-scriptpath' => $scriptPath,
+ 'version-entrypoints-index-php' => wfScript( 'index' ),
+ 'version-entrypoints-api-php' => wfScript( 'api' ),
+ 'version-entrypoints-load-php' => wfScript( 'load' ),
+ ];
+
+ $language = $this->getLanguage();
+ $thAttribures = [
+ 'dir' => $language->getDir(),
+ 'lang' => $language->getHtmlCode()
+ ];
+ $out = Html::element(
+ 'h2',
+ [ 'id' => 'mw-version-entrypoints' ],
+ $this->msg( 'version-entrypoints' )->text()
+ ) .
+ Html::openElement( 'table',
+ [
+ 'class' => 'wikitable plainlinks',
+ 'id' => 'mw-version-entrypoints-table',
+ 'dir' => 'ltr',
+ 'lang' => 'en'
+ ]
+ ) .
+ Html::openElement( 'tr' ) .
+ Html::element(
+ 'th',
+ $thAttribures,
+ $this->msg( 'version-entrypoints-header-entrypoint' )->text()
+ ) .
+ Html::element(
+ 'th',
+ $thAttribures,
+ $this->msg( 'version-entrypoints-header-url' )->text()
+ ) .
+ Html::closeElement( 'tr' );
+
+ foreach ( $entryPoints as $message => $value ) {
+ $url = wfExpandUrl( $value, PROTO_RELATIVE );
+ $out .= Html::openElement( 'tr' ) .
+ // ->text() looks like it should be ->parse(), but this function
+ // returns wikitext, not HTML, boo
+ Html::rawElement( 'td', [], $this->msg( $message )->text() ) .
+ Html::rawElement( 'td', [], Html::rawElement( 'code', [], "[$url $value]" ) ) .
+ Html::closeElement( 'tr' );
+ }
+
+ $out .= Html::closeElement( 'table' );
+
+ return $out;
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialWantedcategories.php b/www/wiki/includes/specials/SpecialWantedcategories.php
new file mode 100644
index 00000000..fc0c3123
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialWantedcategories.php
@@ -0,0 +1,131 @@
+<?php
+/**
+ * Implements Special:Wantedcategories
+ *
+ * Copyright © 2005 Ævar Arnfjörð Bjarmason
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A querypage to list the most wanted categories - implements Special:Wantedcategories
+ *
+ * @ingroup SpecialPage
+ */
+class WantedCategoriesPage extends WantedQueryPage {
+ private $currentCategoryCounts;
+
+ function __construct( $name = 'Wantedcategories' ) {
+ parent::__construct( $name );
+ }
+
+ function getQueryInfo() {
+ return [
+ 'tables' => [ 'categorylinks', 'page' ],
+ 'fields' => [
+ 'namespace' => NS_CATEGORY,
+ 'title' => 'cl_to',
+ 'value' => 'COUNT(*)'
+ ],
+ 'conds' => [ 'page_title IS NULL' ],
+ 'options' => [ 'GROUP BY' => 'cl_to' ],
+ 'join_conds' => [ 'page' => [ 'LEFT JOIN',
+ [ 'page_title = cl_to',
+ 'page_namespace' => NS_CATEGORY ] ] ]
+ ];
+ }
+
+ function preprocessResults( $db, $res ) {
+ parent::preprocessResults( $db, $res );
+
+ $this->currentCategoryCounts = [];
+
+ if ( !$res->numRows() || !$this->isCached() ) {
+ return;
+ }
+
+ // Fetch (hopefully) up-to-date numbers of pages in each category.
+ // This should be fast enough as we limit the list to a reasonable length.
+
+ $allCategories = [];
+ foreach ( $res as $row ) {
+ $allCategories[] = $row->title;
+ }
+
+ $categoryRes = $db->select(
+ 'category',
+ [ 'cat_title', 'cat_pages' ],
+ [ 'cat_title' => $allCategories ],
+ __METHOD__
+ );
+ foreach ( $categoryRes as $row ) {
+ $this->currentCategoryCounts[$row->cat_title] = intval( $row->cat_pages );
+ }
+
+ // Back to start for display
+ $res->seek( 0 );
+ }
+
+ /**
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string
+ */
+ function formatResult( $skin, $result ) {
+ global $wgContLang;
+
+ $nt = Title::makeTitle( $result->namespace, $result->title );
+ $text = $wgContLang->convert( $nt->getText() );
+
+ if ( !$this->isCached() ) {
+ // We can assume the freshest data
+ $plink = $this->getLinkRenderer()->makeBrokenLink(
+ $nt,
+ $text
+ );
+ $nlinks = $this->msg( 'nmembers' )->numParams( $result->value )->escaped();
+ } else {
+ $plink = $this->getLinkRenderer()->makeLink( $nt, $text );
+
+ $currentValue = isset( $this->currentCategoryCounts[$result->title] )
+ ? $this->currentCategoryCounts[$result->title]
+ : 0;
+ $cachedValue = intval( $result->value ); // T76910
+
+ // If the category has been created or emptied since the list was refreshed, strike it
+ if ( $nt->isKnown() || $currentValue === 0 ) {
+ $plink = "<del>$plink</del>";
+ }
+
+ // Show the current number of category entries if it changed
+ if ( $currentValue !== $cachedValue ) {
+ $nlinks = $this->msg( 'nmemberschanged' )
+ ->numParams( $cachedValue, $currentValue )->escaped();
+ } else {
+ $nlinks = $this->msg( 'nmembers' )->numParams( $cachedValue )->escaped();
+ }
+ }
+
+ return $this->getLanguage()->specialList( $plink, $nlinks );
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialWantedfiles.php b/www/wiki/includes/specials/SpecialWantedfiles.php
new file mode 100644
index 00000000..2ebbc2d8
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialWantedfiles.php
@@ -0,0 +1,153 @@
+<?php
+/**
+ * Implements Special:Wantedfiles
+ *
+ * Copyright © 2008 Soxred93
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Soxred93 <soxred93@gmail.com>
+ */
+
+/**
+ * Querypage that lists the most wanted files
+ *
+ * @ingroup SpecialPage
+ */
+class WantedFilesPage extends WantedQueryPage {
+
+ function __construct( $name = 'Wantedfiles' ) {
+ parent::__construct( $name );
+ }
+
+ function getPageHeader() {
+ # Specifically setting to use "Wanted Files" (NS_MAIN) as title, so as to get what
+ # category would be used on main namespace pages, for those tricky wikipedia
+ # admins who like to do {{#ifeq:{{NAMESPACE}}|foo|bar|....}}.
+ $catMessage = $this->msg( 'broken-file-category' )
+ ->title( Title::newFromText( "Wanted Files", NS_MAIN ) )
+ ->inContentLanguage();
+
+ if ( !$catMessage->isDisabled() ) {
+ $category = Title::makeTitleSafe( NS_CATEGORY, $catMessage->text() );
+ } else {
+ $category = false;
+ }
+
+ $noForeign = '';
+ if ( !$this->likelyToHaveFalsePositives() ) {
+ // Additional messages for grep:
+ // wantedfiletext-cat-noforeign, wantedfiletext-nocat-noforeign
+ $noForeign = '-noforeign';
+ }
+
+ if ( $category ) {
+ return $this
+ ->msg( 'wantedfiletext-cat' . $noForeign )
+ ->params( $category->getFullText() )
+ ->parseAsBlock();
+ } else {
+ return $this
+ ->msg( 'wantedfiletext-nocat' . $noForeign )
+ ->parseAsBlock();
+ }
+ }
+
+ /**
+ * Whether foreign repos are likely to cause false positives
+ *
+ * In its own function to allow subclasses to override.
+ * @see SpecialWantedFilesGUOverride in GlobalUsage extension.
+ * @since 1.24
+ * @return bool
+ */
+ protected function likelyToHaveFalsePositives() {
+ return RepoGroup::singleton()->hasForeignRepos();
+ }
+
+ /**
+ * KLUGE: The results may contain false positives for files
+ * that exist e.g. in a shared repo. Setting this at least
+ * keeps them from showing up as redlinks in the output, even
+ * if it doesn't fix the real problem (T8220).
+ *
+ * @note could also have existing links here from broken file
+ * redirects.
+ * @return bool
+ */
+ function forceExistenceCheck() {
+ return true;
+ }
+
+ /**
+ * Does the file exist?
+ *
+ * Use wfFindFile so we still think file namespace pages without
+ * files are missing, but valid file redirects and foreign files are ok.
+ *
+ * @param Title $title
+ * @return bool
+ */
+ protected function existenceCheck( Title $title ) {
+ return (bool)wfFindFile( $title );
+ }
+
+ function getQueryInfo() {
+ return [
+ 'tables' => [
+ 'imagelinks',
+ 'page',
+ 'redirect',
+ 'img1' => 'image',
+ 'img2' => 'image',
+ ],
+ 'fields' => [
+ 'namespace' => NS_FILE,
+ 'title' => 'il_to',
+ 'value' => 'COUNT(*)'
+ ],
+ 'conds' => [
+ 'img1.img_name' => null,
+ // We also need to exclude file redirects
+ 'img2.img_name' => null,
+ ],
+ 'options' => [ 'GROUP BY' => 'il_to' ],
+ 'join_conds' => [
+ 'img1' => [ 'LEFT JOIN',
+ 'il_to = img1.img_name'
+ ],
+ 'page' => [ 'LEFT JOIN', [
+ 'il_to = page_title',
+ 'page_namespace' => NS_FILE,
+ ] ],
+ 'redirect' => [ 'LEFT JOIN', [
+ 'page_id = rd_from',
+ 'rd_namespace' => NS_FILE,
+ 'rd_interwiki' => ''
+ ] ],
+ 'img2' => [ 'LEFT JOIN',
+ 'rd_title = img2.img_name'
+ ]
+ ]
+ ];
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialWantedpages.php b/www/wiki/includes/specials/SpecialWantedpages.php
new file mode 100644
index 00000000..8cea6ccb
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialWantedpages.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Implements Special:Wantedpages
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * A special page that lists most linked pages that does not exist
+ *
+ * @ingroup SpecialPage
+ */
+class WantedPagesPage extends WantedQueryPage {
+
+ function __construct( $name = 'Wantedpages' ) {
+ parent::__construct( $name );
+ }
+
+ function isIncludable() {
+ return true;
+ }
+
+ function execute( $par ) {
+ $inc = $this->including();
+
+ if ( $inc ) {
+ $this->limit = (int)$par;
+ $this->offset = 0;
+ }
+ $this->setListoutput( $inc );
+ $this->shownavigation = !$inc;
+ parent::execute( $par );
+ }
+
+ function getQueryInfo() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $count = $this->getConfig()->get( 'WantedPagesThreshold' ) - 1;
+ $query = [
+ 'tables' => [
+ 'pagelinks',
+ 'pg1' => 'page',
+ 'pg2' => 'page'
+ ],
+ 'fields' => [
+ 'namespace' => 'pl_namespace',
+ 'title' => 'pl_title',
+ 'value' => 'COUNT(*)'
+ ],
+ 'conds' => [
+ 'pg1.page_namespace IS NULL',
+ 'pl_namespace NOT IN (' . $dbr->makeList( [ NS_USER, NS_USER_TALK ] ) . ')',
+ 'pg2.page_namespace != ' . $dbr->addQuotes( NS_MEDIAWIKI ),
+ ],
+ 'options' => [
+ 'HAVING' => [
+ 'COUNT(*) > ' . $dbr->addQuotes( $count ),
+ 'COUNT(*) > SUM(pg2.page_is_redirect)'
+ ],
+ 'GROUP BY' => [ 'pl_namespace', 'pl_title' ]
+ ],
+ 'join_conds' => [
+ 'pg1' => [
+ 'LEFT JOIN', [
+ 'pg1.page_namespace = pl_namespace',
+ 'pg1.page_title = pl_title'
+ ]
+ ],
+ 'pg2' => [ 'LEFT JOIN', 'pg2.page_id = pl_from' ]
+ ]
+ ];
+ // Replacement for the WantedPages::getSQL hook
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $wantedPages = $this;
+ Hooks::run( 'WantedPages::getQueryInfo', [ &$wantedPages, &$query ] );
+
+ return $query;
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialWantedtemplates.php b/www/wiki/includes/specials/SpecialWantedtemplates.php
new file mode 100644
index 00000000..66e68142
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialWantedtemplates.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Implements Special:Wantedtemplates
+ *
+ * Copyright © 2008, Danny B.
+ * Based on SpecialWantedcategories.php by Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ * makeWlhLink() taken from SpecialMostlinkedtemplates by Rob Church <robchur@gmail.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Danny B.
+ */
+
+/**
+ * A querypage to list the most wanted templates
+ *
+ * @ingroup SpecialPage
+ */
+class WantedTemplatesPage extends WantedQueryPage {
+ function __construct( $name = 'Wantedtemplates' ) {
+ parent::__construct( $name );
+ }
+
+ function getQueryInfo() {
+ return [
+ 'tables' => [ 'templatelinks', 'page' ],
+ 'fields' => [
+ 'namespace' => 'tl_namespace',
+ 'title' => 'tl_title',
+ 'value' => 'COUNT(*)'
+ ],
+ 'conds' => [
+ 'page_title IS NULL',
+ 'tl_namespace' => NS_TEMPLATE
+ ],
+ 'options' => [ 'GROUP BY' => [ 'tl_namespace', 'tl_title' ] ],
+ 'join_conds' => [ 'page' => [ 'LEFT JOIN',
+ [ 'page_namespace = tl_namespace',
+ 'page_title = tl_title' ] ] ]
+ ];
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialWatchlist.php b/www/wiki/includes/specials/SpecialWatchlist.php
new file mode 100644
index 00000000..4f4570e3
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialWatchlist.php
@@ -0,0 +1,922 @@
+<?php
+/**
+ * Implements Special:Watchlist
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * A special page that lists last changes made to the wiki,
+ * limited to user-defined list of titles.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialWatchlist extends ChangesListSpecialPage {
+ protected static $savedQueriesPreferenceName = 'rcfilters-wl-saved-queries';
+
+ private $maxDays;
+
+ public function __construct( $page = 'Watchlist', $restriction = 'viewmywatchlist' ) {
+ parent::__construct( $page, $restriction );
+
+ $this->maxDays = $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * Main execution point
+ *
+ * @param string $subpage
+ */
+ function execute( $subpage ) {
+ // Anons don't get a watchlist
+ $this->requireLogin( 'watchlistanontext' );
+
+ $output = $this->getOutput();
+ $request = $this->getRequest();
+ $this->addHelpLink( 'Help:Watching pages' );
+ $output->addModules( [
+ 'mediawiki.special.changeslist.visitedstatus',
+ 'mediawiki.special.watchlist',
+ ] );
+ $output->addModuleStyles( [ 'mediawiki.special.watchlist.styles' ] );
+
+ $mode = SpecialEditWatchlist::getMode( $request, $subpage );
+ if ( $mode !== false ) {
+ if ( $mode === SpecialEditWatchlist::EDIT_RAW ) {
+ $title = SpecialPage::getTitleFor( 'EditWatchlist', 'raw' );
+ } elseif ( $mode === SpecialEditWatchlist::EDIT_CLEAR ) {
+ $title = SpecialPage::getTitleFor( 'EditWatchlist', 'clear' );
+ } else {
+ $title = SpecialPage::getTitleFor( 'EditWatchlist' );
+ }
+
+ $output->redirect( $title->getLocalURL() );
+
+ return;
+ }
+
+ $this->checkPermissions();
+
+ $user = $this->getUser();
+ $opts = $this->getOptions();
+
+ $config = $this->getConfig();
+ if ( ( $config->get( 'EnotifWatchlist' ) || $config->get( 'ShowUpdatedMarker' ) )
+ && $request->getVal( 'reset' )
+ && $request->wasPosted()
+ && $user->matchEditToken( $request->getVal( 'token' ) )
+ ) {
+ $user->clearAllNotifications();
+ $output->redirect( $this->getPageTitle()->getFullURL( $opts->getChangedValues() ) );
+
+ return;
+ }
+
+ parent::execute( $subpage );
+
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $output->addModuleStyles( [ 'mediawiki.rcfilters.highlightCircles.seenunseen.styles' ] );
+
+ $output->addJsConfigVars( 'wgStructuredChangeFiltersLiveUpdateSupported', false );
+ $output->addJsConfigVars(
+ 'wgStructuredChangeFiltersEditWatchlistUrl',
+ SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
+ );
+ }
+ }
+
+ public function isStructuredFilterUiEnabled() {
+ return $this->getRequest()->getBool( 'rcfilters' ) || (
+ $this->getConfig()->get( 'StructuredChangeFiltersOnWatchlist' ) &&
+ $this->getUser()->getOption( 'rcenhancedfilters' )
+ );
+ }
+
+ public function isStructuredFilterUiEnabledByDefault() {
+ return $this->getConfig()->get( 'StructuredChangeFiltersOnWatchlist' ) &&
+ $this->getUser()->getDefaultOption( 'rcenhancedfilters' );
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept.
+ *
+ * @see also SpecialEditWatchlist::getSubpagesForPrefixSearch
+ * @return string[] subpages
+ */
+ public function getSubpagesForPrefixSearch() {
+ return [
+ 'clear',
+ 'edit',
+ 'raw',
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function transformFilterDefinition( array $filterDefinition ) {
+ if ( isset( $filterDefinition['showHideSuffix'] ) ) {
+ $filterDefinition['showHide'] = 'wl' . $filterDefinition['showHideSuffix'];
+ }
+
+ return $filterDefinition;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function registerFilters() {
+ parent::registerFilters();
+
+ // legacy 'extended' filter
+ $this->registerFilterGroup( new ChangesListBooleanFilterGroup( [
+ 'name' => 'extended-group',
+ 'filters' => [
+ [
+ 'name' => 'extended',
+ 'isReplacedInStructuredUi' => true,
+ 'activeValue' => false,
+ 'default' => $this->getUser()->getBoolOption( 'extendwatchlist' ),
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables,
+ &$fields, &$conds, &$query_options, &$join_conds ) {
+ $nonRevisionTypes = [ RC_LOG ];
+ Hooks::run( 'SpecialWatchlistGetNonRevisionTypes', [ &$nonRevisionTypes ] );
+ if ( $nonRevisionTypes ) {
+ $conds[] = $dbr->makeList(
+ [
+ 'rc_this_oldid=page_latest',
+ 'rc_type' => $nonRevisionTypes,
+ ],
+ LIST_OR
+ );
+ }
+ },
+ ]
+ ],
+
+ ] ) );
+
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $this->getFilterGroup( 'lastRevision' )
+ ->getFilter( 'hidepreviousrevisions' )
+ ->setDefault( !$this->getUser()->getBoolOption( 'extendwatchlist' ) );
+ }
+
+ $this->registerFilterGroup( new ChangesListStringOptionsFilterGroup( [
+ 'name' => 'watchlistactivity',
+ 'title' => 'rcfilters-filtergroup-watchlistactivity',
+ 'class' => ChangesListStringOptionsFilterGroup::class,
+ 'priority' => 3,
+ 'isFullCoverage' => true,
+ 'filters' => [
+ [
+ 'name' => 'unseen',
+ 'label' => 'rcfilters-filter-watchlistactivity-unseen-label',
+ 'description' => 'rcfilters-filter-watchlistactivity-unseen-description',
+ 'cssClassSuffix' => 'watchedunseen',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ $changeTs = $rc->getAttribute( 'rc_timestamp' );
+ $lastVisitTs = $rc->getAttribute( 'wl_notificationtimestamp' );
+ return $lastVisitTs !== null && $changeTs >= $lastVisitTs;
+ },
+ ],
+ [
+ 'name' => 'seen',
+ 'label' => 'rcfilters-filter-watchlistactivity-seen-label',
+ 'description' => 'rcfilters-filter-watchlistactivity-seen-description',
+ 'cssClassSuffix' => 'watchedseen',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ $changeTs = $rc->getAttribute( 'rc_timestamp' );
+ $lastVisitTs = $rc->getAttribute( 'wl_notificationtimestamp' );
+ return $lastVisitTs === null || $changeTs < $lastVisitTs;
+ }
+ ],
+ ],
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ 'queryCallable' => function ( $specialPageClassName, $context, $dbr,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
+ if ( $selectedValues === [ 'seen' ] ) {
+ $conds[] = $dbr->makeList( [
+ 'wl_notificationtimestamp IS NULL',
+ 'rc_timestamp < wl_notificationtimestamp'
+ ], LIST_OR );
+ } elseif ( $selectedValues === [ 'unseen' ] ) {
+ $conds[] = $dbr->makeList( [
+ 'wl_notificationtimestamp IS NOT NULL',
+ 'rc_timestamp >= wl_notificationtimestamp'
+ ], LIST_AND );
+ }
+ }
+ ] ) );
+
+ $user = $this->getUser();
+
+ $significance = $this->getFilterGroup( 'significance' );
+ $hideMinor = $significance->getFilter( 'hideminor' );
+ $hideMinor->setDefault( $user->getBoolOption( 'watchlisthideminor' ) );
+
+ $automated = $this->getFilterGroup( 'automated' );
+ $hideBots = $automated->getFilter( 'hidebots' );
+ $hideBots->setDefault( $user->getBoolOption( 'watchlisthidebots' ) );
+
+ $registration = $this->getFilterGroup( 'registration' );
+ $hideAnons = $registration->getFilter( 'hideanons' );
+ $hideAnons->setDefault( $user->getBoolOption( 'watchlisthideanons' ) );
+ $hideLiu = $registration->getFilter( 'hideliu' );
+ $hideLiu->setDefault( $user->getBoolOption( 'watchlisthideliu' ) );
+
+ $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
+ if ( $reviewStatus !== null ) {
+ // Conditional on feature being available and rights
+ $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' );
+ $hidePatrolled->setDefault( $user->getBoolOption( 'watchlisthidepatrolled' ) );
+ }
+
+ $authorship = $this->getFilterGroup( 'authorship' );
+ $hideMyself = $authorship->getFilter( 'hidemyself' );
+ $hideMyself->setDefault( $user->getBoolOption( 'watchlisthideown' ) );
+
+ $changeType = $this->getFilterGroup( 'changeType' );
+ $hideCategorization = $changeType->getFilter( 'hidecategorization' );
+ if ( $hideCategorization !== null ) {
+ // Conditional on feature being available
+ $hideCategorization->setDefault( $user->getBoolOption( 'watchlisthidecategorization' ) );
+ }
+ }
+
+ /**
+ * Get a FormOptions object containing the default options
+ *
+ * @return FormOptions
+ */
+ public function getDefaultOptions() {
+ $opts = parent::getDefaultOptions();
+
+ $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
+ $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT );
+
+ return $opts;
+ }
+
+ public function validateOptions( FormOptions $opts ) {
+ $opts->validateBounds( 'days', 0, $this->maxDays );
+ $opts->validateIntBounds( 'limit', 0, 5000 );
+ parent::validateOptions( $opts );
+ }
+
+ /**
+ * Get all custom filters
+ *
+ * @return array Map of filter URL param names to properties (msg/default)
+ */
+ protected function getCustomFilters() {
+ if ( $this->customFilters === null ) {
+ $this->customFilters = parent::getCustomFilters();
+ Hooks::run( 'SpecialWatchlistFilters', [ $this, &$this->customFilters ], '1.23' );
+ }
+
+ return $this->customFilters;
+ }
+
+ /**
+ * Fetch values for a FormOptions object from the WebRequest associated with this instance.
+ *
+ * Maps old pre-1.23 request parameters Watchlist used to use (different from Recentchanges' ones)
+ * to the current ones.
+ *
+ * @param FormOptions $opts
+ * @return FormOptions
+ */
+ protected function fetchOptionsFromRequest( $opts ) {
+ static $compatibilityMap = [
+ 'hideMinor' => 'hideminor',
+ 'hideBots' => 'hidebots',
+ 'hideAnons' => 'hideanons',
+ 'hideLiu' => 'hideliu',
+ 'hidePatrolled' => 'hidepatrolled',
+ 'hideOwn' => 'hidemyself',
+ ];
+
+ $params = $this->getRequest()->getValues();
+ foreach ( $compatibilityMap as $from => $to ) {
+ if ( isset( $params[$from] ) ) {
+ $params[$to] = $params[$from];
+ unset( $params[$from] );
+ }
+ }
+
+ if ( $this->getRequest()->getVal( 'action' ) == 'submit' ) {
+ $allBooleansFalse = [];
+
+ // If the user submitted the form, start with a baseline of "all
+ // booleans are false", then change the ones they checked. This
+ // means we ignore the defaults.
+
+ // This is how we handle the fact that HTML forms don't submit
+ // unchecked boxes.
+ foreach ( $this->filterGroups as $filterGroup ) {
+ if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
+ /** @var ChangesListBooleanFilter $filter */
+ foreach ( $filterGroup->getFilters() as $filter ) {
+ if ( $filter->displaysOnUnstructuredUi() ) {
+ $allBooleansFalse[$filter->getName()] = false;
+ }
+ }
+ }
+ }
+
+ $params = $params + $allBooleansFalse;
+ }
+
+ // Not the prettiest way to achieve this… FormOptions internally depends on data sanitization
+ // methods defined on WebRequest and removing this dependency would cause some code duplication.
+ $request = new DerivativeRequest( $this->getRequest(), $params );
+ $opts->fetchValuesFromRequest( $request );
+
+ return $opts;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
+ &$join_conds, FormOptions $opts
+ ) {
+ $dbr = $this->getDB();
+ parent::buildQuery( $tables, $fields, $conds, $query_options, $join_conds,
+ $opts );
+
+ // Calculate cutoff
+ if ( $opts['days'] > 0 ) {
+ $conds[] = 'rc_timestamp > ' .
+ $dbr->addQuotes( $dbr->timestamp( time() - $opts['days'] * 3600 * 24 ) );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function doMainQuery( $tables, $fields, $conds, $query_options,
+ $join_conds, FormOptions $opts
+ ) {
+ $dbr = $this->getDB();
+ $user = $this->getUser();
+
+ $tables = array_merge( [ 'recentchanges', 'watchlist' ], $tables );
+ $fields = array_merge( RecentChange::selectFields(), $fields );
+
+ $join_conds = array_merge(
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_user' => $user->getId(),
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ],
+ ],
+ ],
+ $join_conds
+ );
+
+ $tables[] = 'page';
+ $fields[] = 'page_latest';
+ $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
+
+ $fields[] = 'wl_notificationtimestamp';
+
+ // Log entries with DELETED_ACTION must not show up unless the user has
+ // the necessary rights.
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $bitmask = LogPage::DELETED_ACTION;
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
+ } else {
+ $bitmask = 0;
+ }
+ if ( $bitmask ) {
+ $conds[] = $dbr->makeList( [
+ 'rc_type != ' . RC_LOG,
+ $dbr->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
+ ], LIST_OR );
+ }
+
+ $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $fields,
+ $conds,
+ $join_conds,
+ $query_options,
+ $tagFilter
+ );
+
+ $this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts );
+
+ if ( $this->areFiltersInConflict() ) {
+ return false;
+ }
+
+ $orderByAndLimit = [
+ 'ORDER BY' => 'rc_timestamp DESC',
+ 'LIMIT' => $opts['limit']
+ ];
+ if ( in_array( 'DISTINCT', $query_options ) ) {
+ // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
+ // In order to prevent DISTINCT from causing query performance problems,
+ // we have to GROUP BY the primary key. This in turn requires us to add
+ // the primary key to the end of the ORDER BY, and the old ORDER BY to the
+ // start of the GROUP BY
+ $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
+ $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
+ }
+ // array_merge() is used intentionally here so that hooks can, should
+ // they so desire, override the ORDER BY / LIMIT condition(s)
+ $query_options = array_merge( $orderByAndLimit, $query_options );
+
+ return $dbr->select(
+ $tables,
+ $fields,
+ $conds,
+ __METHOD__,
+ $query_options,
+ $join_conds
+ );
+ }
+
+ protected function runMainQueryHook( &$tables, &$fields, &$conds, &$query_options,
+ &$join_conds, $opts
+ ) {
+ return parent::runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts )
+ && Hooks::run(
+ 'SpecialWatchlistQuery',
+ [ &$conds, &$tables, &$join_conds, &$fields, $opts ],
+ '1.23'
+ );
+ }
+
+ /**
+ * Return a IDatabase object for reading
+ *
+ * @return IDatabase
+ */
+ protected function getDB() {
+ return wfGetDB( DB_REPLICA, 'watchlist' );
+ }
+
+ /**
+ * Output feed links.
+ */
+ public function outputFeedLinks() {
+ $user = $this->getUser();
+ $wlToken = $user->getTokenFromOption( 'watchlisttoken' );
+ if ( $wlToken ) {
+ $this->addFeedLinks( [
+ 'action' => 'feedwatchlist',
+ 'allrev' => 1,
+ 'wlowner' => $user->getName(),
+ 'wltoken' => $wlToken,
+ ] );
+ }
+ }
+
+ /**
+ * Build and output the actual changes list.
+ *
+ * @param ResultWrapper $rows Database rows
+ * @param FormOptions $opts
+ */
+ public function outputChangesList( $rows, $opts ) {
+ $dbr = $this->getDB();
+ $user = $this->getUser();
+ $output = $this->getOutput();
+
+ # Show a message about replica DB lag, if applicable
+ $lag = wfGetLB()->safeGetLag( $dbr );
+ if ( $lag > 0 ) {
+ $output->showLagWarning( $lag );
+ }
+
+ # If no rows to display, show message before try to render the list
+ if ( $rows->numRows() == 0 ) {
+ $output->wrapWikiMsg(
+ "<div class='mw-changeslist-empty'>\n$1\n</div>", 'recentchanges-noresult'
+ );
+ return;
+ }
+
+ $dbr->dataSeek( $rows, 0 );
+
+ $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
+ $list->setWatchlistDivs();
+ $list->initChangesListRows( $rows );
+ if ( $user->getOption( 'watchlistunwatchlinks' ) ) {
+ $list->setChangeLinePrefixer( function ( RecentChange $rc, ChangesList $cl, $grouped ) {
+ // Don't show unwatch link if the line is a grouped log entry using EnhancedChangesList,
+ // since EnhancedChangesList groups log entries by performer rather than by target article
+ if ( $rc->mAttribs['rc_type'] == RC_LOG && $cl instanceof EnhancedChangesList &&
+ $grouped ) {
+ return '';
+ } else {
+ return $this->getLinkRenderer()
+ ->makeKnownLink( $rc->getTitle(),
+ $this->msg( 'watchlist-unwatch' )->text(), [
+ 'class' => 'mw-unwatch-link',
+ 'title' => $this->msg( 'tooltip-ca-unwatch' )->text()
+ ], [ 'action' => 'unwatch' ] ) . '&#160;';
+ }
+ } );
+ }
+ $dbr->dataSeek( $rows, 0 );
+
+ if ( $this->getConfig()->get( 'RCShowWatchingUsers' )
+ && $user->getOption( 'shownumberswatching' )
+ ) {
+ $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore();
+ }
+
+ $s = $list->beginRecentChangesList();
+
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $s .= $this->makeLegend();
+ }
+
+ $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' );
+ $counter = 1;
+ foreach ( $rows as $obj ) {
+ # Make RC entry
+ $rc = RecentChange::newFromRow( $obj );
+
+ # Skip CatWatch entries for hidden cats based on user preference
+ if (
+ $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
+ !$userShowHiddenCats &&
+ $rc->getParam( 'hidden-cat' )
+ ) {
+ continue;
+ }
+
+ $rc->counter = $counter++;
+
+ if ( $this->getConfig()->get( 'ShowUpdatedMarker' ) ) {
+ $updated = $obj->wl_notificationtimestamp;
+ } else {
+ $updated = false;
+ }
+
+ if ( isset( $watchedItemStore ) ) {
+ $rcTitleValue = new TitleValue( (int)$obj->rc_namespace, $obj->rc_title );
+ $rc->numberofWatchingusers = $watchedItemStore->countWatchers( $rcTitleValue );
+ } else {
+ $rc->numberofWatchingusers = 0;
+ }
+
+ $changeLine = $list->recentChangesLine( $rc, $updated, $counter );
+ if ( $changeLine !== false ) {
+ $s .= $changeLine;
+ }
+ }
+ $s .= $list->endRecentChangesList();
+
+ $output->addHTML( $s );
+ }
+
+ /**
+ * Set the text to be displayed above the changes
+ *
+ * @param FormOptions $opts
+ * @param int $numRows Number of rows in the result to show after this header
+ */
+ public function doHeader( $opts, $numRows ) {
+ $user = $this->getUser();
+ $out = $this->getOutput();
+
+ $out->addSubtitle(
+ $this->msg( 'watchlistfor2', $user->getName() )
+ ->rawParams( SpecialEditWatchlist::buildTools(
+ $this->getLanguage(),
+ $this->getLinkRenderer()
+ ) )
+ );
+
+ $this->setTopText( $opts );
+
+ $form = '';
+
+ $form .= Xml::openElement( 'form', [
+ 'method' => 'get',
+ 'action' => wfScript(),
+ 'id' => 'mw-watchlist-form'
+ ] );
+ $form .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
+ $form .= Xml::openElement(
+ 'fieldset',
+ [ 'id' => 'mw-watchlist-options', 'class' => 'cloptions' ]
+ );
+ $form .= Xml::element(
+ 'legend', null, $this->msg( 'watchlist-options' )->text()
+ );
+
+ if ( !$this->isStructuredFilterUiEnabled() ) {
+ $form .= $this->makeLegend();
+ }
+
+ $lang = $this->getLanguage();
+ if ( $opts['days'] > 0 ) {
+ $days = $opts['days'];
+ } else {
+ $days = $this->maxDays;
+ }
+ $timestamp = wfTimestampNow();
+ $wlInfo = Html::rawElement(
+ 'span',
+ [ 'class' => 'wlinfo' ],
+ $this->msg( 'wlnote' )->numParams( $numRows, round( $days * 24 ) )->params(
+ $lang->userDate( $timestamp, $user ), $lang->userTime( $timestamp, $user )
+ )->parse()
+ ) . "<br />\n";
+
+ $nondefaults = $opts->getChangedValues();
+ $cutofflinks = Html::rawElement(
+ 'span',
+ [ 'class' => 'cldays cloption' ],
+ $this->msg( 'wlshowtime' ) . ' ' . $this->cutoffselector( $opts )
+ );
+
+ # Spit out some control panel links
+ $links = [];
+ $namesOfDisplayedFilters = [];
+ foreach ( $this->getFilterGroups() as $groupName => $group ) {
+ if ( !$group->isPerGroupRequestParameter() ) {
+ foreach ( $group->getFilters() as $filterName => $filter ) {
+ if ( $filter->displaysOnUnstructuredUi( $this ) ) {
+ $namesOfDisplayedFilters[] = $filterName;
+ $links[] = $this->showHideCheck(
+ $nondefaults,
+ $filter->getShowHide(),
+ $filterName,
+ $opts[$filterName],
+ $filter->isFeatureAvailableOnStructuredUi( $this )
+ );
+ }
+ }
+ }
+ }
+
+ $hiddenFields = $nondefaults;
+ $hiddenFields['action'] = 'submit';
+ unset( $hiddenFields['namespace'] );
+ unset( $hiddenFields['invert'] );
+ unset( $hiddenFields['associated'] );
+ unset( $hiddenFields['days'] );
+ foreach ( $namesOfDisplayedFilters as $filterName ) {
+ unset( $hiddenFields[$filterName] );
+ }
+
+ # Namespace filter and put the whole form together.
+ $form .= $wlInfo;
+ $form .= $cutofflinks;
+ $form .= Html::rawElement(
+ 'span',
+ [ 'class' => 'clshowhide' ],
+ $this->msg( 'watchlist-hide' ) .
+ $this->msg( 'colon-separator' )->escaped() .
+ implode( ' ', $links )
+ );
+ $form .= "\n<br />\n";
+
+ $namespaceForm = Html::namespaceSelector(
+ [
+ 'selected' => $opts['namespace'],
+ 'all' => '',
+ 'label' => $this->msg( 'namespace' )->text()
+ ], [
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'class' => 'namespaceselector',
+ ]
+ ) . "\n";
+ $namespaceForm .= '<span class="mw-input-with-label">' . Xml::checkLabel(
+ $this->msg( 'invert' )->text(),
+ 'invert',
+ 'nsinvert',
+ $opts['invert'],
+ [ 'title' => $this->msg( 'tooltip-invert' )->text() ]
+ ) . "</span>\n";
+ $namespaceForm .= '<span class="mw-input-with-label">' . Xml::checkLabel(
+ $this->msg( 'namespace_association' )->text(),
+ 'associated',
+ 'nsassociated',
+ $opts['associated'],
+ [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ]
+ ) . "</span>\n";
+ $form .= Html::rawElement(
+ 'span',
+ [ 'class' => 'namespaceForm cloption' ],
+ $namespaceForm
+ );
+
+ $form .= Xml::submitButton(
+ $this->msg( 'watchlist-submit' )->text(),
+ [ 'class' => 'cloption-submit' ]
+ ) . "\n";
+ foreach ( $hiddenFields as $key => $value ) {
+ $form .= Html::hidden( $key, $value ) . "\n";
+ }
+ $form .= Xml::closeElement( 'fieldset' ) . "\n";
+ $form .= Xml::closeElement( 'form' ) . "\n";
+
+ // Insert a placeholder for RCFilters
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $rcfilterContainer = Html::element(
+ 'div',
+ [ 'class' => 'rcfilters-container' ]
+ );
+
+ $loadingContainer = Html::rawElement(
+ 'div',
+ [ 'class' => 'rcfilters-spinner' ],
+ Html::element(
+ 'div',
+ [ 'class' => 'rcfilters-spinner-bounce' ]
+ )
+ );
+
+ // Wrap both with rcfilters-head
+ $this->getOutput()->addHTML(
+ Html::rawElement(
+ 'div',
+ [ 'class' => 'rcfilters-head' ],
+ $rcfilterContainer . $form
+ )
+ );
+
+ // Add spinner
+ $this->getOutput()->addHTML( $loadingContainer );
+ } else {
+ $this->getOutput()->addHTML( $form );
+ }
+
+ $this->setBottomText( $opts );
+ }
+
+ function cutoffselector( $options ) {
+ // Cast everything to strings immediately, so that we know all of the values have the same
+ // precision, and can be compared with '==='. 2/24 has a few more decimal places than its
+ // default string representation, for example, and would confuse comparisons.
+
+ // Misleadingly, the 'days' option supports hours too.
+ $days = array_map( 'strval', [ 1 / 24, 2 / 24, 6 / 24, 12 / 24, 1, 3, 7 ] );
+
+ $userWatchlistOption = (string)$this->getUser()->getOption( 'watchlistdays' );
+ // add the user preference, if it isn't available already
+ if ( !in_array( $userWatchlistOption, $days ) && $userWatchlistOption !== '0' ) {
+ $days[] = $userWatchlistOption;
+ }
+
+ $maxDays = (string)$this->maxDays;
+ // add the maximum possible value, if it isn't available already
+ if ( !in_array( $maxDays, $days ) ) {
+ $days[] = $maxDays;
+ }
+
+ $selected = (string)$options['days'];
+ if ( $selected <= 0 ) {
+ $selected = $maxDays;
+ }
+
+ // add the currently selected value, if it isn't available already
+ if ( !in_array( $selected, $days ) ) {
+ $days[] = $selected;
+ }
+
+ $select = new XmlSelect( 'days', 'days', $selected );
+
+ asort( $days );
+ foreach ( $days as $value ) {
+ if ( $value < 1 ) {
+ $name = $this->msg( 'hours' )->numParams( $value * 24 )->text();
+ } else {
+ $name = $this->msg( 'days' )->numParams( $value )->text();
+ }
+ $select->addOption( $name, $value );
+ }
+
+ return $select->getHTML() . "\n<br />\n";
+ }
+
+ function setTopText( FormOptions $opts ) {
+ $nondefaults = $opts->getChangedValues();
+ $form = '';
+ $user = $this->getUser();
+
+ $numItems = $this->countItems();
+ $showUpdatedMarker = $this->getConfig()->get( 'ShowUpdatedMarker' );
+
+ // Show watchlist header
+ $watchlistHeader = '';
+ if ( $numItems == 0 ) {
+ $watchlistHeader = $this->msg( 'nowatchlist' )->parse();
+ } else {
+ $watchlistHeader .= $this->msg( 'watchlist-details' )->numParams( $numItems )->parse() . "\n";
+ if ( $this->getConfig()->get( 'EnotifWatchlist' )
+ && $user->getOption( 'enotifwatchlistpages' )
+ ) {
+ $watchlistHeader .= $this->msg( 'wlheader-enotif' )->parse() . "\n";
+ }
+ if ( $showUpdatedMarker ) {
+ $watchlistHeader .= $this->msg(
+ $this->isStructuredFilterUiEnabled() ?
+ 'rcfilters-watchlist-showupdated' :
+ 'wlheader-showupdated'
+ )->parse() . "\n";
+ }
+ }
+ $form .= Html::rawElement(
+ 'div',
+ [ 'class' => 'watchlistDetails' ],
+ $watchlistHeader
+ );
+
+ if ( $numItems > 0 && $showUpdatedMarker ) {
+ $form .= Xml::openElement( 'form', [ 'method' => 'post',
+ 'action' => $this->getPageTitle()->getLocalURL(),
+ 'id' => 'mw-watchlist-resetbutton' ] ) . "\n" .
+ Xml::submitButton( $this->msg( 'enotif_reset' )->text(),
+ [ 'name' => 'mw-watchlist-reset-submit' ] ) . "\n" .
+ Html::hidden( 'token', $user->getEditToken() ) . "\n" .
+ Html::hidden( 'reset', 'all' ) . "\n";
+ foreach ( $nondefaults as $key => $value ) {
+ $form .= Html::hidden( $key, $value ) . "\n";
+ }
+ $form .= Xml::closeElement( 'form' ) . "\n";
+ }
+
+ $this->getOutput()->addHTML( $form );
+ }
+
+ protected function showHideCheck( $options, $message, $name, $value, $inStructuredUi ) {
+ $options[$name] = 1 - (int)$value;
+
+ $attribs = [ 'class' => 'mw-input-with-label clshowhideoption cloption' ];
+ if ( $inStructuredUi ) {
+ $attribs[ 'data-feature-in-structured-ui' ] = true;
+ }
+
+ return Html::rawElement(
+ 'span',
+ $attribs,
+ Xml::checkLabel(
+ $this->msg( $message, '' )->text(),
+ $name,
+ $name,
+ (int)$value
+ )
+ );
+ }
+
+ /**
+ * Count the number of paired items on a user's watchlist.
+ * The assumption made here is that when a subject page is watched a talk page is also watched.
+ * Hence the number of individual items is halved.
+ *
+ * @return int
+ */
+ protected function countItems() {
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $count = $store->countWatchedItems( $this->getUser() );
+ return floor( $count / 2 );
+ }
+
+ function getDefaultLimit() {
+ return $this->getUser()->getIntOption( 'wllimit' );
+ }
+
+ function getDefaultDays() {
+ return floatval( $this->getUser()->getOption( 'watchlistdays' ) );
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialWhatlinkshere.php b/www/wiki/includes/specials/SpecialWhatlinkshere.php
new file mode 100644
index 00000000..6f91c46f
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialWhatlinkshere.php
@@ -0,0 +1,573 @@
+<?php
+/**
+ * Implements Special:Whatlinkshere
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @todo Use some variant of Pager or something; the pagination here is lousy.
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Implements Special:Whatlinkshere
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialWhatLinksHere extends IncludableSpecialPage {
+ /** @var FormOptions */
+ protected $opts;
+
+ protected $selfTitle;
+
+ /** @var Title */
+ protected $target;
+
+ protected $limits = [ 20, 50, 100, 250, 500 ];
+
+ public function __construct() {
+ parent::__construct( 'Whatlinkshere' );
+ }
+
+ function execute( $par ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->addHelpLink( 'Help:What links here' );
+
+ $opts = new FormOptions();
+
+ $opts->add( 'target', '' );
+ $opts->add( 'namespace', '', FormOptions::INTNULL );
+ $opts->add( 'limit', $this->getConfig()->get( 'QueryPageDefaultLimit' ) );
+ $opts->add( 'from', 0 );
+ $opts->add( 'back', 0 );
+ $opts->add( 'hideredirs', false );
+ $opts->add( 'hidetrans', false );
+ $opts->add( 'hidelinks', false );
+ $opts->add( 'hideimages', false );
+ $opts->add( 'invert', false );
+
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+ $opts->validateIntBounds( 'limit', 0, 5000 );
+
+ // Give precedence to subpage syntax
+ if ( $par !== null ) {
+ $opts->setValue( 'target', $par );
+ }
+
+ // Bind to member variable
+ $this->opts = $opts;
+
+ $this->target = Title::newFromText( $opts->getValue( 'target' ) );
+ if ( !$this->target ) {
+ if ( !$this->including() ) {
+ $out->addHTML( $this->whatlinkshereForm() );
+ }
+
+ return;
+ }
+
+ $this->getSkin()->setRelevantTitle( $this->target );
+
+ $this->selfTitle = $this->getPageTitle( $this->target->getPrefixedDBkey() );
+
+ $out->setPageTitle( $this->msg( 'whatlinkshere-title', $this->target->getPrefixedText() ) );
+ $out->addBacklinkSubtitle( $this->target );
+ $this->showIndirectLinks(
+ 0,
+ $this->target,
+ $opts->getValue( 'limit' ),
+ $opts->getValue( 'from' ),
+ $opts->getValue( 'back' )
+ );
+ }
+
+ /**
+ * @param int $level Recursion level
+ * @param Title $target Target title
+ * @param int $limit Number of entries to display
+ * @param int $from Display from this article ID (default: 0)
+ * @param int $back Display from this article ID at backwards scrolling (default: 0)
+ */
+ function showIndirectLinks( $level, $target, $limit, $from = 0, $back = 0 ) {
+ $out = $this->getOutput();
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $hidelinks = $this->opts->getValue( 'hidelinks' );
+ $hideredirs = $this->opts->getValue( 'hideredirs' );
+ $hidetrans = $this->opts->getValue( 'hidetrans' );
+ $hideimages = $target->getNamespace() != NS_FILE || $this->opts->getValue( 'hideimages' );
+
+ $fetchlinks = ( !$hidelinks || !$hideredirs );
+
+ // Build query conds in concert for all three tables...
+ $conds['pagelinks'] = [
+ 'pl_namespace' => $target->getNamespace(),
+ 'pl_title' => $target->getDBkey(),
+ ];
+ $conds['templatelinks'] = [
+ 'tl_namespace' => $target->getNamespace(),
+ 'tl_title' => $target->getDBkey(),
+ ];
+ $conds['imagelinks'] = [
+ 'il_to' => $target->getDBkey(),
+ ];
+
+ $namespace = $this->opts->getValue( 'namespace' );
+ $invert = $this->opts->getValue( 'invert' );
+ $nsComparison = ( $invert ? '!= ' : '= ' ) . $dbr->addQuotes( $namespace );
+ if ( is_int( $namespace ) ) {
+ $conds['pagelinks'][] = "pl_from_namespace $nsComparison";
+ $conds['templatelinks'][] = "tl_from_namespace $nsComparison";
+ $conds['imagelinks'][] = "il_from_namespace $nsComparison";
+ }
+
+ if ( $from ) {
+ $conds['templatelinks'][] = "tl_from >= $from";
+ $conds['pagelinks'][] = "pl_from >= $from";
+ $conds['imagelinks'][] = "il_from >= $from";
+ }
+
+ if ( $hideredirs ) {
+ $conds['pagelinks']['rd_from'] = null;
+ } elseif ( $hidelinks ) {
+ $conds['pagelinks'][] = 'rd_from is NOT NULL';
+ }
+
+ $queryFunc = function ( IDatabase $dbr, $table, $fromCol ) use (
+ $conds, $target, $limit
+ ) {
+ // Read an extra row as an at-end check
+ $queryLimit = $limit + 1;
+ $on = [
+ "rd_from = $fromCol",
+ 'rd_title' => $target->getDBkey(),
+ 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL'
+ ];
+ $on['rd_namespace'] = $target->getNamespace();
+ // Inner LIMIT is 2X in case of stale backlinks with wrong namespaces
+ $subQuery = $dbr->selectSQLText(
+ [ $table, 'redirect', 'page' ],
+ [ $fromCol, 'rd_from' ],
+ $conds[$table],
+ __CLASS__ . '::showIndirectLinks',
+ // Force JOIN order per T106682 to avoid large filesorts
+ [ 'ORDER BY' => $fromCol, 'LIMIT' => 2 * $queryLimit, 'STRAIGHT_JOIN' ],
+ [
+ 'page' => [ 'INNER JOIN', "$fromCol = page_id" ],
+ 'redirect' => [ 'LEFT JOIN', $on ]
+ ]
+ );
+ return $dbr->select(
+ [ 'page', 'temp_backlink_range' => "($subQuery)" ],
+ [ 'page_id', 'page_namespace', 'page_title', 'rd_from', 'page_is_redirect' ],
+ [],
+ __CLASS__ . '::showIndirectLinks',
+ [ 'ORDER BY' => 'page_id', 'LIMIT' => $queryLimit ],
+ [ 'page' => [ 'INNER JOIN', "$fromCol = page_id" ] ]
+ );
+ };
+
+ if ( $fetchlinks ) {
+ $plRes = $queryFunc( $dbr, 'pagelinks', 'pl_from' );
+ }
+
+ if ( !$hidetrans ) {
+ $tlRes = $queryFunc( $dbr, 'templatelinks', 'tl_from' );
+ }
+
+ if ( !$hideimages ) {
+ $ilRes = $queryFunc( $dbr, 'imagelinks', 'il_from' );
+ }
+
+ if ( ( !$fetchlinks || !$plRes->numRows() )
+ && ( $hidetrans || !$tlRes->numRows() )
+ && ( $hideimages || !$ilRes->numRows() )
+ ) {
+ if ( 0 == $level ) {
+ if ( !$this->including() ) {
+ $out->addHTML( $this->whatlinkshereForm() );
+
+ // Show filters only if there are links
+ if ( $hidelinks || $hidetrans || $hideredirs || $hideimages ) {
+ $out->addHTML( $this->getFilterPanel() );
+ }
+ $errMsg = is_int( $namespace ) ? 'nolinkshere-ns' : 'nolinkshere';
+ $out->addWikiMsg( $errMsg, $this->target->getPrefixedText() );
+ $out->setStatusCode( 404 );
+ }
+ }
+
+ return;
+ }
+
+ // Read the rows into an array and remove duplicates
+ // templatelinks comes second so that the templatelinks row overwrites the
+ // pagelinks row, so we get (inclusion) rather than nothing
+ if ( $fetchlinks ) {
+ foreach ( $plRes as $row ) {
+ $row->is_template = 0;
+ $row->is_image = 0;
+ $rows[$row->page_id] = $row;
+ }
+ }
+ if ( !$hidetrans ) {
+ foreach ( $tlRes as $row ) {
+ $row->is_template = 1;
+ $row->is_image = 0;
+ $rows[$row->page_id] = $row;
+ }
+ }
+ if ( !$hideimages ) {
+ foreach ( $ilRes as $row ) {
+ $row->is_template = 0;
+ $row->is_image = 1;
+ $rows[$row->page_id] = $row;
+ }
+ }
+
+ // Sort by key and then change the keys to 0-based indices
+ ksort( $rows );
+ $rows = array_values( $rows );
+
+ $numRows = count( $rows );
+
+ // Work out the start and end IDs, for prev/next links
+ if ( $numRows > $limit ) {
+ // More rows available after these ones
+ // Get the ID from the last row in the result set
+ $nextId = $rows[$limit]->page_id;
+ // Remove undisplayed rows
+ $rows = array_slice( $rows, 0, $limit );
+ } else {
+ // No more rows after
+ $nextId = false;
+ }
+ $prevId = $from;
+
+ // use LinkBatch to make sure, that all required data (associated with Titles)
+ // is loaded in one query
+ $lb = new LinkBatch();
+ foreach ( $rows as $row ) {
+ $lb->add( $row->page_namespace, $row->page_title );
+ }
+ $lb->execute();
+
+ if ( $level == 0 ) {
+ if ( !$this->including() ) {
+ $out->addHTML( $this->whatlinkshereForm() );
+ $out->addHTML( $this->getFilterPanel() );
+ $out->addWikiMsg( 'linkshere', $this->target->getPrefixedText() );
+
+ $prevnext = $this->getPrevNext( $prevId, $nextId );
+ $out->addHTML( $prevnext );
+ }
+ }
+ $out->addHTML( $this->listStart( $level ) );
+ foreach ( $rows as $row ) {
+ $nt = Title::makeTitle( $row->page_namespace, $row->page_title );
+
+ if ( $row->rd_from && $level < 2 ) {
+ $out->addHTML( $this->listItem( $row, $nt, $target, true ) );
+ $this->showIndirectLinks(
+ $level + 1,
+ $nt,
+ $this->getConfig()->get( 'MaxRedirectLinksRetrieved' )
+ );
+ $out->addHTML( Xml::closeElement( 'li' ) );
+ } else {
+ $out->addHTML( $this->listItem( $row, $nt, $target ) );
+ }
+ }
+
+ $out->addHTML( $this->listEnd() );
+
+ if ( $level == 0 ) {
+ if ( !$this->including() ) {
+ $out->addHTML( $prevnext );
+ }
+ }
+ }
+
+ protected function listStart( $level ) {
+ return Xml::openElement( 'ul', ( $level ? [] : [ 'id' => 'mw-whatlinkshere-list' ] ) );
+ }
+
+ protected function listItem( $row, $nt, $target, $notClose = false ) {
+ $dirmark = $this->getLanguage()->getDirMark();
+
+ # local message cache
+ static $msgcache = null;
+ if ( $msgcache === null ) {
+ static $msgs = [ 'isredirect', 'istemplate', 'semicolon-separator',
+ 'whatlinkshere-links', 'isimage', 'editlink' ];
+ $msgcache = [];
+ foreach ( $msgs as $msg ) {
+ $msgcache[$msg] = $this->msg( $msg )->escaped();
+ }
+ }
+
+ if ( $row->rd_from ) {
+ $query = [ 'redirect' => 'no' ];
+ } else {
+ $query = [];
+ }
+
+ $link = $this->getLinkRenderer()->makeKnownLink(
+ $nt,
+ null,
+ $row->page_is_redirect ? [ 'class' => 'mw-redirect' ] : [],
+ $query
+ );
+
+ // Display properties (redirect or template)
+ $propsText = '';
+ $props = [];
+ if ( $row->rd_from ) {
+ $props[] = $msgcache['isredirect'];
+ }
+ if ( $row->is_template ) {
+ $props[] = $msgcache['istemplate'];
+ }
+ if ( $row->is_image ) {
+ $props[] = $msgcache['isimage'];
+ }
+
+ Hooks::run( 'WhatLinksHereProps', [ $row, $nt, $target, &$props ] );
+
+ if ( count( $props ) ) {
+ $propsText = $this->msg( 'parentheses' )
+ ->rawParams( implode( $msgcache['semicolon-separator'], $props ) )->escaped();
+ }
+
+ # Space for utilities links, with a what-links-here link provided
+ $wlhLink = $this->wlhLink( $nt, $msgcache['whatlinkshere-links'], $msgcache['editlink'] );
+ $wlh = Xml::wrapClass(
+ $this->msg( 'parentheses' )->rawParams( $wlhLink )->escaped(),
+ 'mw-whatlinkshere-tools'
+ );
+
+ return $notClose ?
+ Xml::openElement( 'li' ) . "$link $propsText $dirmark $wlh\n" :
+ Xml::tags( 'li', null, "$link $propsText $dirmark $wlh" ) . "\n";
+ }
+
+ protected function listEnd() {
+ return Xml::closeElement( 'ul' );
+ }
+
+ protected function wlhLink( Title $target, $text, $editText ) {
+ static $title = null;
+ if ( $title === null ) {
+ $title = $this->getPageTitle();
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ if ( $text !== null ) {
+ $text = new HtmlArmor( $text );
+ }
+
+ // always show a "<- Links" link
+ $links = [
+ 'links' => $linkRenderer->makeKnownLink(
+ $title,
+ $text,
+ [],
+ [ 'target' => $target->getPrefixedText() ]
+ ),
+ ];
+
+ // if the page is editable, add an edit link
+ if (
+ // check user permissions
+ $this->getUser()->isAllowed( 'edit' ) &&
+ // check, if the content model is editable through action=edit
+ ContentHandler::getForTitle( $target )->supportsDirectEditing()
+ ) {
+ if ( $editText !== null ) {
+ $editText = new HtmlArmor( $editText );
+ }
+
+ $links['edit'] = $linkRenderer->makeKnownLink(
+ $target,
+ $editText,
+ [],
+ [ 'action' => 'edit' ]
+ );
+ }
+
+ // build the links html
+ return $this->getLanguage()->pipeList( $links );
+ }
+
+ function makeSelfLink( $text, $query ) {
+ if ( $text !== null ) {
+ $text = new HtmlArmor( $text );
+ }
+
+ return $this->getLinkRenderer()->makeKnownLink(
+ $this->selfTitle,
+ $text,
+ [],
+ $query
+ );
+ }
+
+ function getPrevNext( $prevId, $nextId ) {
+ $currentLimit = $this->opts->getValue( 'limit' );
+ $prev = $this->msg( 'whatlinkshere-prev' )->numParams( $currentLimit )->escaped();
+ $next = $this->msg( 'whatlinkshere-next' )->numParams( $currentLimit )->escaped();
+
+ $changed = $this->opts->getChangedValues();
+ unset( $changed['target'] ); // Already in the request title
+
+ if ( 0 != $prevId ) {
+ $overrides = [ 'from' => $this->opts->getValue( 'back' ) ];
+ $prev = $this->makeSelfLink( $prev, array_merge( $changed, $overrides ) );
+ }
+ if ( 0 != $nextId ) {
+ $overrides = [ 'from' => $nextId, 'back' => $prevId ];
+ $next = $this->makeSelfLink( $next, array_merge( $changed, $overrides ) );
+ }
+
+ $limitLinks = [];
+ $lang = $this->getLanguage();
+ foreach ( $this->limits as $limit ) {
+ $prettyLimit = htmlspecialchars( $lang->formatNum( $limit ) );
+ $overrides = [ 'limit' => $limit ];
+ $limitLinks[] = $this->makeSelfLink( $prettyLimit, array_merge( $changed, $overrides ) );
+ }
+
+ $nums = $lang->pipeList( $limitLinks );
+
+ return $this->msg( 'viewprevnext' )->rawParams( $prev, $next, $nums )->escaped();
+ }
+
+ function whatlinkshereForm() {
+ // We get nicer value from the title object
+ $this->opts->consumeValue( 'target' );
+ // Reset these for new requests
+ $this->opts->consumeValues( [ 'back', 'from' ] );
+
+ $target = $this->target ? $this->target->getPrefixedText() : '';
+ $namespace = $this->opts->consumeValue( 'namespace' );
+ $nsinvert = $this->opts->consumeValue( 'invert' );
+
+ # Build up the form
+ $f = Xml::openElement( 'form', [ 'action' => wfScript() ] );
+
+ # Values that should not be forgotten
+ $f .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
+ foreach ( $this->opts->getUnconsumedValues() as $name => $value ) {
+ $f .= Html::hidden( $name, $value );
+ }
+
+ $f .= Xml::fieldset( $this->msg( 'whatlinkshere' )->text() );
+
+ # Target input (.mw-searchInput enables suggestions)
+ $f .= Xml::inputLabel( $this->msg( 'whatlinkshere-page' )->text(), 'target',
+ 'mw-whatlinkshere-target', 40, $target, [ 'class' => 'mw-searchInput' ] );
+
+ $f .= ' ';
+
+ # Namespace selector
+ $f .= Html::namespaceSelector(
+ [
+ 'selected' => $namespace,
+ 'all' => '',
+ 'label' => $this->msg( 'namespace' )->text()
+ ], [
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'class' => 'namespaceselector',
+ ]
+ );
+
+ $f .= '&#160;' .
+ Xml::checkLabel(
+ $this->msg( 'invert' )->text(),
+ 'invert',
+ 'nsinvert',
+ $nsinvert,
+ [ 'title' => $this->msg( 'tooltip-whatlinkshere-invert' )->text() ]
+ );
+
+ $f .= ' ';
+
+ # Submit
+ $f .= Xml::submitButton( $this->msg( 'whatlinkshere-submit' )->text() );
+
+ # Close
+ $f .= Xml::closeElement( 'fieldset' ) . Xml::closeElement( 'form' ) . "\n";
+
+ return $f;
+ }
+
+ /**
+ * Create filter panel
+ *
+ * @return string HTML fieldset and filter panel with the show/hide links
+ */
+ function getFilterPanel() {
+ $show = $this->msg( 'show' )->escaped();
+ $hide = $this->msg( 'hide' )->escaped();
+
+ $changed = $this->opts->getChangedValues();
+ unset( $changed['target'] ); // Already in the request title
+
+ $links = [];
+ $types = [ 'hidetrans', 'hidelinks', 'hideredirs' ];
+ if ( $this->target->getNamespace() == NS_FILE ) {
+ $types[] = 'hideimages';
+ }
+
+ // Combined message keys: 'whatlinkshere-hideredirs', 'whatlinkshere-hidetrans',
+ // 'whatlinkshere-hidelinks', 'whatlinkshere-hideimages'
+ // To be sure they will be found by grep
+ foreach ( $types as $type ) {
+ $chosen = $this->opts->getValue( $type );
+ $msg = $chosen ? $show : $hide;
+ $overrides = [ $type => !$chosen ];
+ $links[] = $this->msg( "whatlinkshere-{$type}" )->rawParams(
+ $this->makeSelfLink( $msg, array_merge( $changed, $overrides ) ) )->escaped();
+ }
+
+ return Xml::fieldset(
+ $this->msg( 'whatlinkshere-filters' )->text(),
+ $this->getLanguage()->pipeList( $links )
+ );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
diff --git a/www/wiki/includes/specials/SpecialWithoutinterwiki.php b/www/wiki/includes/specials/SpecialWithoutinterwiki.php
new file mode 100644
index 00000000..a1e51563
--- /dev/null
+++ b/www/wiki/includes/specials/SpecialWithoutinterwiki.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Implements Special:Withoutinterwiki
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>
+ */
+
+/**
+ * Special page lists pages without language links
+ *
+ * @ingroup SpecialPage
+ */
+class WithoutInterwikiPage extends PageQueryPage {
+ private $prefix = '';
+
+ function __construct( $name = 'Withoutinterwiki' ) {
+ parent::__construct( $name );
+ }
+
+ function execute( $par ) {
+ $this->prefix = Title::capitalize(
+ $this->getRequest()->getVal( 'prefix', $par ), NS_MAIN );
+ parent::execute( $par );
+ }
+
+ function getPageHeader() {
+ # Do not show useless input form if special page is cached
+ if ( $this->isCached() ) {
+ return '';
+ }
+
+ $formDescriptor = [
+ 'prefix' => [
+ 'label-message' => 'allpagesprefix',
+ 'name' => 'prefix',
+ 'id' => 'wiprefix',
+ 'type' => 'text',
+ 'size' => 20,
+ 'default' => $this->prefix
+ ]
+ ];
+
+ $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
+ $htmlForm->setWrapperLegend( '' )
+ ->setSubmitTextMsg( 'withoutinterwiki-submit' )
+ ->setMethod( 'get' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ function sortDescending() {
+ return false;
+ }
+
+ function getOrderFields() {
+ return [ 'page_namespace', 'page_title' ];
+ }
+
+ function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ function getQueryInfo() {
+ $query = [
+ 'tables' => [ 'page', 'langlinks' ],
+ 'fields' => [
+ 'namespace' => 'page_namespace',
+ 'title' => 'page_title',
+ 'value' => 'page_title'
+ ],
+ 'conds' => [
+ 'll_title IS NULL',
+ 'page_namespace' => MWNamespace::getContentNamespaces(),
+ 'page_is_redirect' => 0
+ ],
+ 'join_conds' => [ 'langlinks' => [ 'LEFT JOIN', 'll_from = page_id' ] ]
+ ];
+ if ( $this->prefix ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $query['conds'][] = 'page_title ' . $dbr->buildLike( $this->prefix, $dbr->anyString() );
+ }
+
+ return $query;
+ }
+
+ protected function getGroupName() {
+ return 'maintenance';
+ }
+}
diff --git a/www/wiki/includes/specials/formfields/EditWatchlistCheckboxSeriesField.php b/www/wiki/includes/specials/formfields/EditWatchlistCheckboxSeriesField.php
new file mode 100644
index 00000000..cb93bb2c
--- /dev/null
+++ b/www/wiki/includes/specials/formfields/EditWatchlistCheckboxSeriesField.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class EditWatchlistCheckboxSeriesField extends HTMLMultiSelectField {
+ /**
+ * HTMLMultiSelectField throws validation errors if we get input data
+ * that doesn't match the data set in the form setup. This causes
+ * problems if something gets removed from the watchlist while the
+ * form is open (T34126), but we know that invalid items will
+ * be harmless so we can override it here.
+ *
+ * @param string $value The value the field was submitted with
+ * @param array $alldata The data collected from the form
+ * @return bool|string Bool true on success, or String error to display.
+ */
+ function validate( $value, $alldata ) {
+ // Need to call into grandparent to be a good citizen. :)
+ return HTMLFormField::validate( $value, $alldata );
+ }
+}
diff --git a/www/wiki/includes/specials/formfields/Licenses.php b/www/wiki/includes/specials/formfields/Licenses.php
new file mode 100644
index 00000000..f499cc16
--- /dev/null
+++ b/www/wiki/includes/specials/formfields/Licenses.php
@@ -0,0 +1,189 @@
+<?php
+/**
+ * License selector for use on Special:Upload.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * A License class for use on Special:Upload
+ */
+class Licenses extends HTMLFormField {
+ /** @var string */
+ protected $msg;
+
+ /** @var array */
+ protected $licenses = [];
+
+ /** @var string */
+ protected $html;
+ /**#@-*/
+
+ /**
+ * @param array $params
+ */
+ public function __construct( $params ) {
+ parent::__construct( $params );
+
+ $this->msg = empty( $params['licenses'] )
+ ? wfMessage( 'licenses' )->inContentLanguage()->plain()
+ : $params['licenses'];
+ $this->selected = null;
+
+ $this->makeLicenses();
+ }
+
+ /**
+ * @private
+ */
+ protected function makeLicenses() {
+ $levels = [];
+ $lines = explode( "\n", $this->msg );
+
+ foreach ( $lines as $line ) {
+ if ( strpos( $line, '*' ) !== 0 ) {
+ continue;
+ } else {
+ list( $level, $line ) = $this->trimStars( $line );
+
+ if ( strpos( $line, '|' ) !== false ) {
+ $obj = new License( $line );
+ $this->stackItem( $this->licenses, $levels, $obj );
+ } else {
+ if ( $level < count( $levels ) ) {
+ $levels = array_slice( $levels, 0, $level );
+ }
+ if ( $level == count( $levels ) ) {
+ $levels[$level - 1] = $line;
+ } elseif ( $level > count( $levels ) ) {
+ $levels[] = $line;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @param string $str
+ * @return array
+ */
+ protected function trimStars( $str ) {
+ $numStars = strspn( $str, '*' );
+ return [ $numStars, ltrim( substr( $str, $numStars ), ' ' ) ];
+ }
+
+ /**
+ * @param array &$list
+ * @param array $path
+ * @param mixed $item
+ */
+ protected function stackItem( &$list, $path, $item ) {
+ $position =& $list;
+ if ( $path ) {
+ foreach ( $path as $key ) {
+ $position =& $position[$key];
+ }
+ }
+ $position[] = $item;
+ }
+
+ /**
+ * @param array $tagset
+ * @param int $depth
+ */
+ protected function makeHtml( $tagset, $depth = 0 ) {
+ foreach ( $tagset as $key => $val ) {
+ if ( is_array( $val ) ) {
+ $this->html .= $this->outputOption(
+ $key, '',
+ [
+ 'disabled' => 'disabled',
+ 'style' => 'color: GrayText', // for MSIE
+ ],
+ $depth
+ );
+ $this->makeHtml( $val, $depth + 1 );
+ } else {
+ $this->html .= $this->outputOption(
+ $val->text, $val->template,
+ [ 'title' => '{{' . $val->template . '}}' ],
+ $depth
+ );
+ }
+ }
+ }
+
+ /**
+ * @param string $message
+ * @param string $value
+ * @param null|array $attribs
+ * @param int $depth
+ * @return string
+ */
+ protected function outputOption( $message, $value, $attribs = null, $depth = 0 ) {
+ $msgObj = $this->msg( $message );
+ $text = $msgObj->exists() ? $msgObj->text() : $message;
+ $attribs['value'] = $value;
+ if ( $value === $this->selected ) {
+ $attribs['selected'] = 'selected';
+ }
+
+ $val = str_repeat( /* &nbsp */ "\xc2\xa0", $depth * 2 ) . $text;
+ return str_repeat( "\t", $depth ) . Xml::element( 'option', $attribs, $val ) . "\n";
+ }
+
+ /**#@-*/
+
+ /**
+ * Accessor for $this->licenses
+ *
+ * @return array
+ */
+ public function getLicenses() {
+ return $this->licenses;
+ }
+
+ /**
+ * Accessor for $this->html
+ *
+ * @param bool $value
+ *
+ * @return string
+ */
+ public function getInputHTML( $value ) {
+ $this->selected = $value;
+
+ $this->html = $this->outputOption( wfMessage( 'nolicense' )->text(), '',
+ (bool)$this->selected ? null : [ 'selected' => 'selected' ] );
+ $this->makeHtml( $this->getLicenses() );
+
+ $attribs = [
+ 'name' => $this->mName,
+ 'id' => $this->mID
+ ];
+ if ( !empty( $this->mParams['disabled'] ) ) {
+ $attibs['disabled'] = 'disabled';
+ }
+
+ return Html::rawElement( 'select', $attribs, $this->html );
+ }
+}
diff --git a/www/wiki/includes/specials/formfields/UploadSourceField.php b/www/wiki/includes/specials/formfields/UploadSourceField.php
new file mode 100644
index 00000000..251a2866
--- /dev/null
+++ b/www/wiki/includes/specials/formfields/UploadSourceField.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A form field that contains a radio box in the label
+ */
+class UploadSourceField extends HTMLTextField {
+
+ /**
+ * @param array $cellAttributes
+ * @return string
+ */
+ function getLabelHtml( $cellAttributes = [] ) {
+ $id = $this->mParams['id'];
+ $label = Html::rawElement( 'label', [ 'for' => $id ], $this->mLabel );
+
+ if ( !empty( $this->mParams['radio'] ) ) {
+ if ( isset( $this->mParams['radio-id'] ) ) {
+ $radioId = $this->mParams['radio-id'];
+ } else {
+ // Old way. For the benefit of extensions that do not define
+ // the 'radio-id' key.
+ $radioId = 'wpSourceType' . $this->mParams['upload-type'];
+ }
+
+ $attribs = [
+ 'name' => 'wpSourceType',
+ 'type' => 'radio',
+ 'id' => $radioId,
+ 'value' => $this->mParams['upload-type'],
+ ];
+
+ if ( !empty( $this->mParams['checked'] ) ) {
+ $attribs['checked'] = 'checked';
+ }
+
+ $label .= Html::element( 'input', $attribs );
+ }
+
+ return Html::rawElement( 'td', [ 'class' => 'mw-label' ] + $cellAttributes, $label );
+ }
+
+ /**
+ * @return int
+ */
+ function getSize() {
+ return isset( $this->mParams['size'] )
+ ? $this->mParams['size']
+ : 60;
+ }
+}
diff --git a/www/wiki/includes/specials/forms/EditWatchlistNormalHTMLForm.php b/www/wiki/includes/specials/forms/EditWatchlistNormalHTMLForm.php
new file mode 100644
index 00000000..723093a7
--- /dev/null
+++ b/www/wiki/includes/specials/forms/EditWatchlistNormalHTMLForm.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Extend HTMLForm purely so we can have a more sane way of getting the section headers
+ */
+class EditWatchlistNormalHTMLForm extends HTMLForm {
+ public function getLegend( $namespace ) {
+ $namespace = substr( $namespace, 2 );
+
+ return $namespace == NS_MAIN
+ ? $this->msg( 'blanknamespace' )->escaped()
+ : htmlspecialchars( $this->getContext()->getLanguage()->getFormattedNsText( $namespace ) );
+ }
+
+ public function getBody() {
+ return $this->displaySection( $this->mFieldTree, '', 'editwatchlist-' );
+ }
+}
diff --git a/www/wiki/includes/specials/forms/PreferencesForm.php b/www/wiki/includes/specials/forms/PreferencesForm.php
new file mode 100644
index 00000000..d4e5ef4f
--- /dev/null
+++ b/www/wiki/includes/specials/forms/PreferencesForm.php
@@ -0,0 +1,143 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Form to edit user preferences.
+ */
+class PreferencesForm extends HTMLForm {
+ // Override default value from HTMLForm
+ protected $mSubSectionBeforeFields = false;
+
+ private $modifiedUser;
+
+ /**
+ * @param User $user
+ */
+ public function setModifiedUser( $user ) {
+ $this->modifiedUser = $user;
+ }
+
+ /**
+ * @return User
+ */
+ public function getModifiedUser() {
+ if ( $this->modifiedUser === null ) {
+ return $this->getUser();
+ } else {
+ return $this->modifiedUser;
+ }
+ }
+
+ /**
+ * Get extra parameters for the query string when redirecting after
+ * successful save.
+ *
+ * @return array
+ */
+ public function getExtraSuccessRedirectParameters() {
+ return [];
+ }
+
+ /**
+ * @param string $html
+ * @return string
+ */
+ function wrapForm( $html ) {
+ $html = Xml::tags( 'div', [ 'id' => 'preferences' ], $html );
+
+ return parent::wrapForm( $html );
+ }
+
+ /**
+ * @return string
+ */
+ function getButtons() {
+ $attrs = [ 'id' => 'mw-prefs-restoreprefs' ];
+
+ if ( !$this->getModifiedUser()->isAllowedAny( 'editmyprivateinfo', 'editmyoptions' ) ) {
+ return '';
+ }
+
+ $html = parent::getButtons();
+
+ if ( $this->getModifiedUser()->isAllowed( 'editmyoptions' ) ) {
+ $t = $this->getTitle()->getSubpage( 'reset' );
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ $html .= "\n" . $linkRenderer->makeLink( $t, $this->msg( 'restoreprefs' )->text(),
+ Html::buttonAttributes( $attrs, [ 'mw-ui-quiet' ] ) );
+
+ $html = Xml::tags( 'div', [ 'class' => 'mw-prefs-buttons' ], $html );
+ }
+
+ return $html;
+ }
+
+ /**
+ * Separate multi-option preferences into multiple preferences, since we
+ * have to store them separately
+ * @param array $data
+ * @return array
+ */
+ function filterDataForSubmit( $data ) {
+ foreach ( $this->mFlatFields as $fieldname => $field ) {
+ if ( $field instanceof HTMLNestedFilterable ) {
+ $info = $field->mParams;
+ $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $fieldname;
+ foreach ( $field->filterDataForSubmit( $data[$fieldname] ) as $key => $value ) {
+ $data["$prefix$key"] = $value;
+ }
+ unset( $data[$fieldname] );
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the whole body of the form.
+ * @return string
+ */
+ function getBody() {
+ return $this->displaySection( $this->mFieldTree, '', 'mw-prefsection-' );
+ }
+
+ /**
+ * Get the "<legend>" for a given section key. Normally this is the
+ * prefs-$key message but we'll allow extensions to override it.
+ * @param string $key
+ * @return string
+ */
+ function getLegend( $key ) {
+ $legend = parent::getLegend( $key );
+ Hooks::run( 'PreferencesGetLegend', [ $this, $key, &$legend ] );
+ return $legend;
+ }
+
+ /**
+ * Get the keys of each top level preference section.
+ * @return array of section keys
+ */
+ function getPreferenceSections() {
+ return array_keys( array_filter( $this->mFieldTree, 'is_array' ) );
+ }
+}
diff --git a/www/wiki/includes/specials/forms/UploadForm.php b/www/wiki/includes/specials/forms/UploadForm.php
new file mode 100644
index 00000000..44d91a8a
--- /dev/null
+++ b/www/wiki/includes/specials/forms/UploadForm.php
@@ -0,0 +1,446 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Sub class of HTMLForm that provides the form section of SpecialUpload
+ */
+class UploadForm extends HTMLForm {
+ protected $mWatch;
+ protected $mForReUpload;
+ protected $mSessionKey;
+ protected $mHideIgnoreWarning;
+ protected $mDestWarningAck;
+ protected $mDestFile;
+
+ protected $mComment;
+ protected $mTextTop;
+ protected $mTextAfterSummary;
+
+ protected $mSourceIds;
+
+ protected $mMaxFileSize = [];
+
+ protected $mMaxUploadSize = [];
+
+ public function __construct( array $options = [], IContextSource $context = null,
+ LinkRenderer $linkRenderer = null
+ ) {
+ if ( $context instanceof IContextSource ) {
+ $this->setContext( $context );
+ }
+
+ if ( !$linkRenderer ) {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ }
+
+ $this->mWatch = !empty( $options['watch'] );
+ $this->mForReUpload = !empty( $options['forreupload'] );
+ $this->mSessionKey = isset( $options['sessionkey'] ) ? $options['sessionkey'] : '';
+ $this->mHideIgnoreWarning = !empty( $options['hideignorewarning'] );
+ $this->mDestWarningAck = !empty( $options['destwarningack'] );
+ $this->mDestFile = isset( $options['destfile'] ) ? $options['destfile'] : '';
+
+ $this->mComment = isset( $options['description'] ) ?
+ $options['description'] : '';
+
+ $this->mTextTop = isset( $options['texttop'] )
+ ? $options['texttop'] : '';
+
+ $this->mTextAfterSummary = isset( $options['textaftersummary'] )
+ ? $options['textaftersummary'] : '';
+
+ $sourceDescriptor = $this->getSourceSection();
+ $descriptor = $sourceDescriptor
+ + $this->getDescriptionSection()
+ + $this->getOptionsSection();
+
+ Hooks::run( 'UploadFormInitDescriptor', [ &$descriptor ] );
+ parent::__construct( $descriptor, $context, 'upload' );
+
+ # Add a link to edit MediaWiki:Licenses
+ if ( $this->getUser()->isAllowed( 'editinterface' ) ) {
+ $this->getOutput()->addModuleStyles( 'mediawiki.special.upload.styles' );
+ $licensesLink = $linkRenderer->makeKnownLink(
+ $this->msg( 'licenses' )->inContentLanguage()->getTitle(),
+ $this->msg( 'licenses-edit' )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ $editLicenses = '<p class="mw-upload-editlicenses">' . $licensesLink . '</p>';
+ $this->addFooterText( $editLicenses, 'description' );
+ }
+
+ # Set some form properties
+ $this->setSubmitText( $this->msg( 'uploadbtn' )->text() );
+ $this->setSubmitName( 'wpUpload' );
+ # Used message keys: 'accesskey-upload', 'tooltip-upload'
+ $this->setSubmitTooltip( 'upload' );
+ $this->setId( 'mw-upload-form' );
+
+ # Build a list of IDs for javascript insertion
+ $this->mSourceIds = [];
+ foreach ( $sourceDescriptor as $field ) {
+ if ( !empty( $field['id'] ) ) {
+ $this->mSourceIds[] = $field['id'];
+ }
+ }
+ }
+
+ /**
+ * Get the descriptor of the fieldset that contains the file source
+ * selection. The section is 'source'
+ *
+ * @return array Descriptor array
+ */
+ protected function getSourceSection() {
+ if ( $this->mSessionKey ) {
+ return [
+ 'SessionKey' => [
+ 'type' => 'hidden',
+ 'default' => $this->mSessionKey,
+ ],
+ 'SourceType' => [
+ 'type' => 'hidden',
+ 'default' => 'Stash',
+ ],
+ ];
+ }
+
+ $canUploadByUrl = UploadFromUrl::isEnabled()
+ && ( UploadFromUrl::isAllowed( $this->getUser() ) === true )
+ && $this->getConfig()->get( 'CopyUploadsFromSpecialUpload' );
+ $radio = $canUploadByUrl;
+ $selectedSourceType = strtolower( $this->getRequest()->getText( 'wpSourceType', 'File' ) );
+
+ $descriptor = [];
+ if ( $this->mTextTop ) {
+ $descriptor['UploadFormTextTop'] = [
+ 'type' => 'info',
+ 'section' => 'source',
+ 'default' => $this->mTextTop,
+ 'raw' => true,
+ ];
+ }
+
+ $this->mMaxUploadSize['file'] = min(
+ UploadBase::getMaxUploadSize( 'file' ),
+ UploadBase::getMaxPhpUploadSize()
+ );
+
+ $help = $this->msg( 'upload-maxfilesize',
+ $this->getContext()->getLanguage()->formatSize( $this->mMaxUploadSize['file'] )
+ )->parse();
+
+ // If the user can also upload by URL, there are 2 different file size limits.
+ // This extra message helps stress which limit corresponds to what.
+ if ( $canUploadByUrl ) {
+ $help .= $this->msg( 'word-separator' )->escaped();
+ $help .= $this->msg( 'upload_source_file' )->parse();
+ }
+
+ $descriptor['UploadFile'] = [
+ 'class' => 'UploadSourceField',
+ 'section' => 'source',
+ 'type' => 'file',
+ 'id' => 'wpUploadFile',
+ 'radio-id' => 'wpSourceTypeFile',
+ 'label-message' => 'sourcefilename',
+ 'upload-type' => 'File',
+ 'radio' => &$radio,
+ 'help' => $help,
+ 'checked' => $selectedSourceType == 'file',
+ ];
+
+ if ( $canUploadByUrl ) {
+ $this->mMaxUploadSize['url'] = UploadBase::getMaxUploadSize( 'url' );
+ $descriptor['UploadFileURL'] = [
+ 'class' => 'UploadSourceField',
+ 'section' => 'source',
+ 'id' => 'wpUploadFileURL',
+ 'radio-id' => 'wpSourceTypeurl',
+ 'label-message' => 'sourceurl',
+ 'upload-type' => 'url',
+ 'radio' => &$radio,
+ 'help' => $this->msg( 'upload-maxfilesize',
+ $this->getContext()->getLanguage()->formatSize( $this->mMaxUploadSize['url'] )
+ )->parse() .
+ $this->msg( 'word-separator' )->escaped() .
+ $this->msg( 'upload_source_url' )->parse(),
+ 'checked' => $selectedSourceType == 'url',
+ ];
+ }
+ Hooks::run( 'UploadFormSourceDescriptors', [ &$descriptor, &$radio, $selectedSourceType ] );
+
+ $descriptor['Extensions'] = [
+ 'type' => 'info',
+ 'section' => 'source',
+ 'default' => $this->getExtensionsMessage(),
+ 'raw' => true,
+ ];
+
+ return $descriptor;
+ }
+
+ /**
+ * Get the messages indicating which extensions are preferred and prohibitted.
+ *
+ * @return string HTML string containing the message
+ */
+ protected function getExtensionsMessage() {
+ # Print a list of allowed file extensions, if so configured. We ignore
+ # MIME type here, it's incomprehensible to most people and too long.
+ $config = $this->getConfig();
+
+ if ( $config->get( 'CheckFileExtensions' ) ) {
+ $fileExtensions = array_unique( $config->get( 'FileExtensions' ) );
+ if ( $config->get( 'StrictFileExtensions' ) ) {
+ # Everything not permitted is banned
+ $extensionsList =
+ '<div id="mw-upload-permitted">' .
+ $this->msg( 'upload-permitted' )
+ ->params( $this->getLanguage()->commaList( $fileExtensions ) )
+ ->numParams( count( $fileExtensions ) )
+ ->parseAsBlock() .
+ "</div>\n";
+ } else {
+ # We have to list both preferred and prohibited
+ $fileBlacklist = array_unique( $config->get( 'FileBlacklist' ) );
+ $extensionsList =
+ '<div id="mw-upload-preferred">' .
+ $this->msg( 'upload-preferred' )
+ ->params( $this->getLanguage()->commaList( $fileExtensions ) )
+ ->numParams( count( $fileExtensions ) )
+ ->parseAsBlock() .
+ "</div>\n" .
+ '<div id="mw-upload-prohibited">' .
+ $this->msg( 'upload-prohibited' )
+ ->params( $this->getLanguage()->commaList( $fileBlacklist ) )
+ ->numParams( count( $fileBlacklist ) )
+ ->parseAsBlock() .
+ "</div>\n";
+ }
+ } else {
+ # Everything is permitted.
+ $extensionsList = '';
+ }
+
+ return $extensionsList;
+ }
+
+ /**
+ * Get the descriptor of the fieldset that contains the file description
+ * input. The section is 'description'
+ *
+ * @return array Descriptor array
+ */
+ protected function getDescriptionSection() {
+ $config = $this->getConfig();
+ if ( $this->mSessionKey ) {
+ $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $this->getUser() );
+ try {
+ $file = $stash->getFile( $this->mSessionKey );
+ } catch ( Exception $e ) {
+ $file = null;
+ }
+ if ( $file ) {
+ global $wgContLang;
+
+ $mto = $file->transform( [ 'width' => 120 ] );
+ if ( $mto ) {
+ $this->addHeaderText(
+ '<div class="thumb t' . $wgContLang->alignEnd() . '">' .
+ Html::element( 'img', [
+ 'src' => $mto->getUrl(),
+ 'class' => 'thumbimage',
+ ] ) . '</div>', 'description' );
+ }
+ }
+ }
+
+ $descriptor = [
+ 'DestFile' => [
+ 'type' => 'text',
+ 'section' => 'description',
+ 'id' => 'wpDestFile',
+ 'label-message' => 'destfilename',
+ 'size' => 60,
+ 'default' => $this->mDestFile,
+ # @todo FIXME: Hack to work around poor handling of the 'default' option in HTMLForm
+ 'nodata' => strval( $this->mDestFile ) !== '',
+ ],
+ 'UploadDescription' => [
+ 'type' => 'textarea',
+ 'section' => 'description',
+ 'id' => 'wpUploadDescription',
+ 'label-message' => $this->mForReUpload
+ ? 'filereuploadsummary'
+ : 'fileuploadsummary',
+ 'default' => $this->mComment,
+ 'cols' => 80,
+ 'rows' => 8,
+ ]
+ ];
+ if ( $this->mTextAfterSummary ) {
+ $descriptor['UploadFormTextAfterSummary'] = [
+ 'type' => 'info',
+ 'section' => 'description',
+ 'default' => $this->mTextAfterSummary,
+ 'raw' => true,
+ ];
+ }
+
+ $descriptor += [
+ 'EditTools' => [
+ 'type' => 'edittools',
+ 'section' => 'description',
+ 'message' => 'edittools-upload',
+ ]
+ ];
+
+ if ( $this->mForReUpload ) {
+ $descriptor['DestFile']['readonly'] = true;
+ } else {
+ $descriptor['License'] = [
+ 'type' => 'select',
+ 'class' => 'Licenses',
+ 'section' => 'description',
+ 'id' => 'wpLicense',
+ 'label-message' => 'license',
+ ];
+ }
+
+ if ( $config->get( 'UseCopyrightUpload' ) ) {
+ $descriptor['UploadCopyStatus'] = [
+ 'type' => 'text',
+ 'section' => 'description',
+ 'id' => 'wpUploadCopyStatus',
+ 'label-message' => 'filestatus',
+ ];
+ $descriptor['UploadSource'] = [
+ 'type' => 'text',
+ 'section' => 'description',
+ 'id' => 'wpUploadSource',
+ 'label-message' => 'filesource',
+ ];
+ }
+
+ return $descriptor;
+ }
+
+ /**
+ * Get the descriptor of the fieldset that contains the upload options,
+ * such as "watch this file". The section is 'options'
+ *
+ * @return array Descriptor array
+ */
+ protected function getOptionsSection() {
+ $user = $this->getUser();
+ if ( $user->isLoggedIn() ) {
+ $descriptor = [
+ 'Watchthis' => [
+ 'type' => 'check',
+ 'id' => 'wpWatchthis',
+ 'label-message' => 'watchthisupload',
+ 'section' => 'options',
+ 'default' => $this->mWatch,
+ ]
+ ];
+ }
+ if ( !$this->mHideIgnoreWarning ) {
+ $descriptor['IgnoreWarning'] = [
+ 'type' => 'check',
+ 'id' => 'wpIgnoreWarning',
+ 'label-message' => 'ignorewarnings',
+ 'section' => 'options',
+ ];
+ }
+
+ $descriptor['DestFileWarningAck'] = [
+ 'type' => 'hidden',
+ 'id' => 'wpDestFileWarningAck',
+ 'default' => $this->mDestWarningAck ? '1' : '',
+ ];
+
+ if ( $this->mForReUpload ) {
+ $descriptor['ForReUpload'] = [
+ 'type' => 'hidden',
+ 'id' => 'wpForReUpload',
+ 'default' => '1',
+ ];
+ }
+
+ return $descriptor;
+ }
+
+ /**
+ * Add the upload JS and show the form.
+ */
+ public function show() {
+ $this->addUploadJS();
+ parent::show();
+ }
+
+ /**
+ * Add upload JS to the OutputPage
+ */
+ protected function addUploadJS() {
+ $config = $this->getConfig();
+
+ $useAjaxDestCheck = $config->get( 'UseAjax' ) && $config->get( 'AjaxUploadDestCheck' );
+ $useAjaxLicensePreview = $config->get( 'UseAjax' ) &&
+ $config->get( 'AjaxLicensePreview' ) && $config->get( 'EnableAPI' );
+ $this->mMaxUploadSize['*'] = UploadBase::getMaxUploadSize();
+
+ $scriptVars = [
+ 'wgAjaxUploadDestCheck' => $useAjaxDestCheck,
+ 'wgAjaxLicensePreview' => $useAjaxLicensePreview,
+ 'wgUploadAutoFill' => !$this->mForReUpload &&
+ // If we received mDestFile from the request, don't autofill
+ // the wpDestFile textbox
+ $this->mDestFile === '',
+ 'wgUploadSourceIds' => $this->mSourceIds,
+ 'wgCheckFileExtensions' => $config->get( 'CheckFileExtensions' ),
+ 'wgStrictFileExtensions' => $config->get( 'StrictFileExtensions' ),
+ 'wgFileExtensions' => array_values( array_unique( $config->get( 'FileExtensions' ) ) ),
+ 'wgCapitalizeUploads' => MWNamespace::isCapitalized( NS_FILE ),
+ 'wgMaxUploadSize' => $this->mMaxUploadSize,
+ 'wgFileCanRotate' => SpecialUpload::rotationEnabled(),
+ ];
+
+ $out = $this->getOutput();
+ $out->addJsConfigVars( $scriptVars );
+
+ $out->addModules( [
+ 'mediawiki.special.upload', // Extras for thumbnail and license preview.
+ ] );
+ }
+
+ /**
+ * Empty function; submission is handled elsewhere.
+ *
+ * @return bool False
+ */
+ function trySubmit() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/specials/helpers/ImportReporter.php b/www/wiki/includes/specials/helpers/ImportReporter.php
new file mode 100644
index 00000000..63addb87
--- /dev/null
+++ b/www/wiki/includes/specials/helpers/ImportReporter.php
@@ -0,0 +1,190 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Reporting callback
+ * @ingroup SpecialPage
+ */
+class ImportReporter extends ContextSource {
+ private $reason = false;
+ private $logTags = [];
+ private $mOriginalLogCallback = null;
+ private $mOriginalPageOutCallback = null;
+ private $mLogItemCount = 0;
+
+ /**
+ * @param WikiImporter $importer
+ * @param bool $upload
+ * @param string $interwiki
+ * @param string|bool $reason
+ */
+ function __construct( $importer, $upload, $interwiki, $reason = false ) {
+ $this->mOriginalPageOutCallback =
+ $importer->setPageOutCallback( [ $this, 'reportPage' ] );
+ $this->mOriginalLogCallback =
+ $importer->setLogItemCallback( [ $this, 'reportLogItem' ] );
+ $importer->setNoticeCallback( [ $this, 'reportNotice' ] );
+ $this->mPageCount = 0;
+ $this->mIsUpload = $upload;
+ $this->mInterwiki = $interwiki;
+ $this->reason = $reason;
+ }
+
+ /**
+ * Sets change tags to apply to the import log entry and null revision.
+ *
+ * @param array $tags
+ * @since 1.29
+ */
+ public function setChangeTags( array $tags ) {
+ $this->logTags = $tags;
+ }
+
+ function open() {
+ $this->getOutput()->addHTML( "<ul>\n" );
+ }
+
+ function reportNotice( $msg, array $params ) {
+ $this->getOutput()->addHTML(
+ Html::element( 'li', [], $this->msg( $msg, $params )->text() )
+ );
+ }
+
+ function reportLogItem( /* ... */ ) {
+ $this->mLogItemCount++;
+ if ( is_callable( $this->mOriginalLogCallback ) ) {
+ call_user_func_array( $this->mOriginalLogCallback, func_get_args() );
+ }
+ }
+
+ /**
+ * @param Title $title
+ * @param ForeignTitle $foreignTitle
+ * @param int $revisionCount
+ * @param int $successCount
+ * @param array $pageInfo
+ * @return void
+ */
+ public function reportPage( $title, $foreignTitle, $revisionCount,
+ $successCount, $pageInfo ) {
+ $args = func_get_args();
+ call_user_func_array( $this->mOriginalPageOutCallback, $args );
+
+ if ( $title === null ) {
+ # Invalid or non-importable title; a notice is already displayed
+ return;
+ }
+
+ $this->mPageCount++;
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ if ( $successCount > 0 ) {
+ // <bdi> prevents jumbling of the versions count
+ // in RTL wikis in case the page title is LTR
+ $this->getOutput()->addHTML(
+ "<li>" . $linkRenderer->makeLink( $title ) . " " .
+ "<bdi>" .
+ $this->msg( 'import-revision-count' )->numParams( $successCount )->escaped() .
+ "</bdi>" .
+ "</li>\n"
+ );
+
+ $logParams = [ '4:number:count' => $successCount ];
+ if ( $this->mIsUpload ) {
+ $detail = $this->msg( 'import-logentry-upload-detail' )->numParams(
+ $successCount )->inContentLanguage()->text();
+ $action = 'upload';
+ } else {
+ $pageTitle = $foreignTitle->getFullText();
+ $fullInterwikiPrefix = $this->mInterwiki;
+ Hooks::run( 'ImportLogInterwikiLink', [ &$fullInterwikiPrefix, &$pageTitle ] );
+
+ $interwikiTitleStr = $fullInterwikiPrefix . ':' . $pageTitle;
+ $interwiki = '[[:' . $interwikiTitleStr . ']]';
+ $detail = $this->msg( 'import-logentry-interwiki-detail' )->numParams(
+ $successCount )->params( $interwiki )->inContentLanguage()->text();
+ $action = 'interwiki';
+ $logParams['5:title-link:interwiki'] = $interwikiTitleStr;
+ }
+ if ( $this->reason ) {
+ $detail .= $this->msg( 'colon-separator' )->inContentLanguage()->text()
+ . $this->reason;
+ }
+
+ $comment = $detail; // quick
+ $dbw = wfGetDB( DB_MASTER );
+ $latest = $title->getLatestRevID();
+ $nullRevision = Revision::newNullRevision(
+ $dbw,
+ $title->getArticleID(),
+ $comment,
+ true,
+ $this->getUser()
+ );
+
+ $nullRevId = null;
+ if ( !is_null( $nullRevision ) ) {
+ $nullRevId = $nullRevision->insertOn( $dbw );
+ $page = WikiPage::factory( $title );
+ # Update page record
+ $page->updateRevisionOn( $dbw, $nullRevision );
+ Hooks::run(
+ 'NewRevisionFromEditComplete',
+ [ $page, $nullRevision, $latest, $this->getUser() ]
+ );
+ }
+
+ // Create the import log entry
+ $logEntry = new ManualLogEntry( 'import', $action );
+ $logEntry->setTarget( $title );
+ $logEntry->setComment( $this->reason );
+ $logEntry->setPerformer( $this->getUser() );
+ $logEntry->setParameters( $logParams );
+ $logid = $logEntry->insert();
+ if ( count( $this->logTags ) ) {
+ $logEntry->setTags( $this->logTags );
+ }
+ // Make sure the null revision will be tagged as well
+ $logEntry->setAssociatedRevId( $nullRevId );
+
+ $logEntry->publish( $logid );
+
+ } else {
+ $this->getOutput()->addHTML( "<li>" . $linkRenderer->makeKnownLink( $title ) . " " .
+ $this->msg( 'import-nonewrevisions' )->escaped() . "</li>\n" );
+ }
+ }
+
+ function close() {
+ $out = $this->getOutput();
+ if ( $this->mLogItemCount > 0 ) {
+ $msg = $this->msg( 'imported-log-entries' )->numParams( $this->mLogItemCount )->parse();
+ $out->addHTML( Xml::tags( 'li', null, $msg ) );
+ } elseif ( $this->mPageCount == 0 && $this->mLogItemCount == 0 ) {
+ $out->addHTML( "</ul>\n" );
+
+ return Status::newFatal( 'importnopages' );
+ }
+ $out->addHTML( "</ul>\n" );
+
+ return Status::newGood( $this->mPageCount );
+ }
+}
diff --git a/www/wiki/includes/specials/helpers/License.php b/www/wiki/includes/specials/helpers/License.php
new file mode 100644
index 00000000..4f94b4d2
--- /dev/null
+++ b/www/wiki/includes/specials/helpers/License.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * License selector for use on Special:Upload.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
+ * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
+ */
+
+/**
+ * A License class for use on Special:Upload (represents a single type of license).
+ */
+class License {
+ /** @var string */
+ public $template;
+
+ /** @var string */
+ public $text;
+
+ /**
+ * @param string $str License name??
+ */
+ function __construct( $str ) {
+ list( $text, $template ) = explode( '|', strrev( $str ), 2 );
+
+ $this->template = strrev( $template );
+ $this->text = strrev( $text );
+ }
+}
diff --git a/www/wiki/includes/specials/helpers/LoginHelper.php b/www/wiki/includes/specials/helpers/LoginHelper.php
new file mode 100644
index 00000000..a35a420e
--- /dev/null
+++ b/www/wiki/includes/specials/helpers/LoginHelper.php
@@ -0,0 +1,98 @@
+<?php
+
+/**
+ * Helper functions for the login form that need to be shared with other special pages
+ * (such as CentralAuth's SpecialCentralLogin).
+ * @since 1.27
+ */
+class LoginHelper extends ContextSource {
+ /**
+ * Valid error and warning messages
+ *
+ * Special:Userlogin can show an error or warning message on the form when
+ * coming from another page. This is done via the ?error= or ?warning= GET
+ * parameters.
+ *
+ * This array is the list of valid message keys. Further keys can be added by the
+ * LoginFormValidErrorMessages hook. All other values will be ignored.
+ *
+ * @var string[]
+ */
+ public static $validErrorMessages = [
+ 'exception-nologin-text',
+ 'watchlistanontext',
+ 'changeemail-no-info',
+ 'resetpass-no-info',
+ 'confirmemail_needlogin',
+ 'prefsnologintext2',
+ ];
+
+ /**
+ * Returns an array of all valid error messages.
+ *
+ * @return array
+ * @see LoginHelper::$validErrorMessages
+ */
+ public static function getValidErrorMessages() {
+ static $messages = null;
+ if ( !$messages ) {
+ $messages = self::$validErrorMessages;
+ Hooks::run( 'LoginFormValidErrorMessages', [ &$messages ] );
+ }
+
+ return $messages;
+ }
+
+ public function __construct( IContextSource $context ) {
+ $this->setContext( $context );
+ }
+
+ /**
+ * Show a return link or redirect to it.
+ * Extensions can change where the link should point or inject content into the page
+ * (which will change it from redirect to link mode).
+ *
+ * @param string $type One of the following:
+ * - error: display a return to link ignoring $wgRedirectOnLogin
+ * - success: display a return to link using $wgRedirectOnLogin if needed
+ * - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
+ * @param string $returnTo
+ * @param array|string $returnToQuery
+ * @param bool $stickHTTPS Keep redirect link on HTTPS
+ */
+ public function showReturnToPage(
+ $type, $returnTo = '', $returnToQuery = '', $stickHTTPS = false
+ ) {
+ global $wgRedirectOnLogin, $wgSecureLogin;
+
+ if ( $type !== 'error' && $wgRedirectOnLogin !== null ) {
+ $returnTo = $wgRedirectOnLogin;
+ $returnToQuery = [];
+ } elseif ( is_string( $returnToQuery ) ) {
+ $returnToQuery = wfCgiToArray( $returnToQuery );
+ }
+
+ // Allow modification of redirect behavior
+ Hooks::run( 'PostLoginRedirect', [ &$returnTo, &$returnToQuery, &$type ] );
+
+ $returnToTitle = Title::newFromText( $returnTo ) ?: Title::newMainPage();
+
+ if ( $wgSecureLogin && !$stickHTTPS ) {
+ $options = [ 'http' ];
+ $proto = PROTO_HTTP;
+ } elseif ( $wgSecureLogin ) {
+ $options = [ 'https' ];
+ $proto = PROTO_HTTPS;
+ } else {
+ $options = [];
+ $proto = PROTO_RELATIVE;
+ }
+
+ if ( $type === 'successredirect' ) {
+ $redirectUrl = $returnToTitle->getFullUrlForRedirect( $returnToQuery, $proto );
+ $this->getOutput()->redirect( $redirectUrl );
+ } else {
+ $this->getOutput()->addReturnTo( $returnToTitle, $returnToQuery, null, $options );
+ }
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/ActiveUsersPager.php b/www/wiki/includes/specials/pagers/ActiveUsersPager.php
new file mode 100644
index 00000000..64af71a1
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/ActiveUsersPager.php
@@ -0,0 +1,190 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * This class is used to get a list of active users. The ones with specials
+ * rights (sysop, bureaucrat, developer) will have them displayed
+ * next to their names.
+ *
+ * @ingroup Pager
+ */
+class ActiveUsersPager extends UsersPager {
+
+ /**
+ * @var FormOptions
+ */
+ protected $opts;
+
+ /**
+ * @var string[]
+ */
+ protected $groups;
+
+ /**
+ * @var array
+ */
+ private $blockStatusByUid;
+
+ /**
+ * @param IContextSource $context
+ * @param FormOptions $opts
+ */
+ function __construct( IContextSource $context = null, FormOptions $opts ) {
+ parent::__construct( $context );
+
+ $this->RCMaxAge = $this->getConfig()->get( 'ActiveUserDays' );
+ $this->requestedUser = '';
+
+ $un = $opts->getValue( 'username' );
+ if ( $un != '' ) {
+ $username = Title::makeTitleSafe( NS_USER, $un );
+ if ( !is_null( $username ) ) {
+ $this->requestedUser = $username->getText();
+ }
+ }
+
+ $this->groups = $opts->getValue( 'groups' );
+ $this->excludegroups = $opts->getValue( 'excludegroups' );
+ // Backwards-compatibility with old URLs
+ if ( $opts->getValue( 'hidebots' ) ) {
+ $this->excludegroups[] = 'bot';
+ }
+ if ( $opts->getValue( 'hidesysops' ) ) {
+ $this->excludegroups[] = 'sysop';
+ }
+ }
+
+ function getIndexField() {
+ return 'qcc_title';
+ }
+
+ function getQueryInfo() {
+ $dbr = $this->getDatabase();
+
+ $activeUserSeconds = $this->getConfig()->get( 'ActiveUserDays' ) * 86400;
+ $timestamp = $dbr->timestamp( wfTimestamp( TS_UNIX ) - $activeUserSeconds );
+ $tables = [ 'querycachetwo', 'user', 'recentchanges' ];
+ $conds = [
+ 'qcc_type' => 'activeusers',
+ 'qcc_namespace' => NS_USER,
+ 'user_name = qcc_title',
+ 'rc_user_text = qcc_title',
+ 'rc_type != ' . $dbr->addQuotes( RC_EXTERNAL ), // Don't count wikidata.
+ 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE ), // Don't count categorization changes.
+ 'rc_log_type IS NULL OR rc_log_type != ' . $dbr->addQuotes( 'newusers' ),
+ 'rc_timestamp >= ' . $dbr->addQuotes( $timestamp ),
+ ];
+ if ( $this->requestedUser != '' ) {
+ $conds[] = 'qcc_title >= ' . $dbr->addQuotes( $this->requestedUser );
+ }
+ if ( $this->groups !== [] ) {
+ $tables[] = 'user_groups';
+ $conds[] = 'ug_user = user_id';
+ $conds['ug_group'] = $this->groups;
+ $conds[] = 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() );
+ }
+ if ( $this->excludegroups !== [] ) {
+ foreach ( $this->excludegroups as $group ) {
+ $conds[] = 'NOT EXISTS (' . $dbr->selectSQLText(
+ 'user_groups', '1', [
+ 'ug_user = user_id',
+ 'ug_group' => $group,
+ 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
+ ]
+ ) . ')';
+ }
+ }
+ if ( !$this->getUser()->isAllowed( 'hideuser' ) ) {
+ $conds[] = 'NOT EXISTS (' . $dbr->selectSQLText(
+ 'ipblocks', '1', [ 'ipb_user=user_id', 'ipb_deleted' => 1 ]
+ ) . ')';
+ }
+
+ return [
+ 'tables' => $tables,
+ 'fields' => [
+ 'qcc_title',
+ 'user_name' => 'qcc_title',
+ 'user_id' => 'MAX(user_id)',
+ 'recentedits' => 'COUNT(*)'
+ ],
+ 'options' => [ 'GROUP BY' => [ 'qcc_title' ] ],
+ 'conds' => $conds
+ ];
+ }
+
+ function doBatchLookups() {
+ parent::doBatchLookups();
+
+ $uids = [];
+ foreach ( $this->mResult as $row ) {
+ $uids[] = $row->user_id;
+ }
+ // Fetch the block status of the user for showing "(blocked)" text and for
+ // striking out names of suppressed users when privileged user views the list.
+ // Although the first query already hits the block table for un-privileged, this
+ // is done in two queries to avoid huge quicksorts and to make COUNT(*) correct.
+ $dbr = $this->getDatabase();
+ $res = $dbr->select( 'ipblocks',
+ [ 'ipb_user', 'MAX(ipb_deleted) AS block_status' ],
+ [ 'ipb_user' => $uids ],
+ __METHOD__,
+ [ 'GROUP BY' => [ 'ipb_user' ] ]
+ );
+ $this->blockStatusByUid = [];
+ foreach ( $res as $row ) {
+ $this->blockStatusByUid[$row->ipb_user] = $row->block_status; // 0 or 1
+ }
+ $this->mResult->seek( 0 );
+ }
+
+ function formatRow( $row ) {
+ $userName = $row->user_name;
+
+ $ulinks = Linker::userLink( $row->user_id, $userName );
+ $ulinks .= Linker::userToolLinks( $row->user_id, $userName );
+
+ $lang = $this->getLanguage();
+
+ $list = [];
+ $user = User::newFromId( $row->user_id );
+
+ $ugms = self::getGroupMemberships( intval( $row->user_id ), $this->userGroupCache );
+ foreach ( $ugms as $ugm ) {
+ $list[] = $this->buildGroupLink( $ugm, $userName );
+ }
+
+ $groups = $lang->commaList( $list );
+
+ $item = $lang->specialList( $ulinks, $groups );
+
+ $isBlocked = isset( $this->blockStatusByUid[$row->user_id] );
+ if ( $isBlocked && $this->blockStatusByUid[$row->user_id] == 1 ) {
+ $item = "<span class=\"deleted\">$item</span>";
+ }
+ $count = $this->msg( 'activeusers-count' )->numParams( $row->recentedits )
+ ->params( $userName )->numParams( $this->RCMaxAge )->escaped();
+ $blocked = $isBlocked ? ' ' . $this->msg( 'listusers-blocked', $userName )->escaped() : '';
+
+ return Html::rawElement( 'li', [], "{$item} [{$count}]{$blocked}" );
+ }
+
+}
diff --git a/www/wiki/includes/specials/pagers/AllMessagesTablePager.php b/www/wiki/includes/specials/pagers/AllMessagesTablePager.php
new file mode 100644
index 00000000..e6a0f0be
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/AllMessagesTablePager.php
@@ -0,0 +1,424 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+/**
+ * Use TablePager for prettified output. We have to pretend that we're
+ * getting data from a table when in fact not all of it comes from the database.
+ *
+ * @ingroup Pager
+ */
+use MediaWiki\MediaWikiServices;
+
+class AllMessagesTablePager extends TablePager {
+
+ protected $filter, $prefix, $langcode, $displayPrefix;
+
+ public $mLimitsShown;
+
+ /**
+ * @var Language
+ */
+ public $lang;
+
+ /**
+ * @var null|bool
+ */
+ public $custom;
+
+ function __construct( $page, $conds, $langObj = null ) {
+ parent::__construct( $page->getContext() );
+ $this->mIndexField = 'am_title';
+ $this->mPage = $page;
+ $this->mConds = $conds;
+ // FIXME: Why does this need to be set to DIR_DESCENDING to produce ascending ordering?
+ $this->mDefaultDirection = IndexPager::DIR_DESCENDING;
+ $this->mLimitsShown = [ 20, 50, 100, 250, 500, 5000 ];
+
+ global $wgContLang;
+
+ $this->talk = $this->msg( 'talkpagelinktext' )->escaped();
+
+ $this->lang = ( $langObj ? $langObj : $wgContLang );
+ $this->langcode = $this->lang->getCode();
+ $this->foreign = !$this->lang->equals( $wgContLang );
+
+ $request = $this->getRequest();
+
+ $this->filter = $request->getVal( 'filter', 'all' );
+ if ( $this->filter === 'all' ) {
+ $this->custom = null; // So won't match in either case
+ } else {
+ $this->custom = ( $this->filter === 'unmodified' );
+ }
+
+ $prefix = $this->getLanguage()->ucfirst( $request->getVal( 'prefix', '' ) );
+ $prefix = $prefix !== '' ?
+ Title::makeTitleSafe( NS_MEDIAWIKI, $request->getVal( 'prefix', null ) ) :
+ null;
+
+ if ( $prefix !== null ) {
+ $this->displayPrefix = $prefix->getDBkey();
+ $this->prefix = '/^' . preg_quote( $this->displayPrefix, '/' ) . '/i';
+ } else {
+ $this->displayPrefix = false;
+ $this->prefix = false;
+ }
+
+ // The suffix that may be needed for message names if we're in a
+ // different language (eg [[MediaWiki:Foo/fr]]: $suffix = '/fr'
+ if ( $this->foreign ) {
+ $this->suffix = '/' . $this->langcode;
+ } else {
+ $this->suffix = '';
+ }
+ }
+
+ function buildForm() {
+ $attrs = [ 'id' => 'mw-allmessages-form-lang', 'name' => 'lang' ];
+ $msg = wfMessage( 'allmessages-language' );
+ $langSelect = Xml::languageSelector( $this->langcode, false, null, $attrs, $msg );
+
+ $out = Xml::openElement( 'form', [
+ 'method' => 'get',
+ 'action' => $this->getConfig()->get( 'Script' ),
+ 'id' => 'mw-allmessages-form'
+ ] ) .
+ Xml::fieldset( $this->msg( 'allmessages-filter-legend' )->text() ) .
+ Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) .
+ Xml::openElement( 'table', [ 'class' => 'mw-allmessages-table' ] ) . "\n" .
+ '<tr>
+ <td class="mw-label">' .
+ Xml::label( $this->msg( 'allmessages-prefix' )->text(), 'mw-allmessages-form-prefix' ) .
+ "</td>\n
+ <td class=\"mw-input\">" .
+ Xml::input(
+ 'prefix',
+ 20,
+ str_replace( '_', ' ', $this->displayPrefix ),
+ [ 'id' => 'mw-allmessages-form-prefix' ]
+ ) .
+ "</td>\n
+ </tr>
+ <tr>\n
+ <td class='mw-label'>" .
+ $this->msg( 'allmessages-filter' )->escaped() .
+ "</td>\n
+ <td class='mw-input'>" .
+ Xml::radioLabel( $this->msg( 'allmessages-filter-unmodified' )->text(),
+ 'filter',
+ 'unmodified',
+ 'mw-allmessages-form-filter-unmodified',
+ ( $this->filter === 'unmodified' )
+ ) .
+ Xml::radioLabel( $this->msg( 'allmessages-filter-all' )->text(),
+ 'filter',
+ 'all',
+ 'mw-allmessages-form-filter-all',
+ ( $this->filter === 'all' )
+ ) .
+ Xml::radioLabel( $this->msg( 'allmessages-filter-modified' )->text(),
+ 'filter',
+ 'modified',
+ 'mw-allmessages-form-filter-modified',
+ ( $this->filter === 'modified' )
+ ) .
+ "</td>\n
+ </tr>
+ <tr>\n
+ <td class=\"mw-label\">" . $langSelect[0] . "</td>\n
+ <td class=\"mw-input\">" . $langSelect[1] . "</td>\n
+ </tr>" .
+
+ '<tr>
+ <td class="mw-label">' .
+ Xml::label( $this->msg( 'table_pager_limit_label' )->text(), 'mw-table_pager_limit_label' ) .
+ '</td>
+ <td class="mw-input">' .
+ $this->getLimitSelect( [ 'id' => 'mw-table_pager_limit_label' ] ) .
+ '</td>
+ <tr>
+ <td></td>
+ <td>' .
+ Xml::submitButton( $this->msg( 'allmessages-filter-submit' )->text() ) .
+ "</td>\n
+ </tr>" .
+
+ Xml::closeElement( 'table' ) .
+ $this->getHiddenFields( [ 'title', 'prefix', 'filter', 'lang', 'limit' ] ) .
+ Xml::closeElement( 'fieldset' ) .
+ Xml::closeElement( 'form' );
+
+ return $out;
+ }
+
+ function getAllMessages( $descending ) {
+ $messageNames = Language::getLocalisationCache()->getSubitemList( 'en', 'messages' );
+
+ // Normalise message names so they look like page titles and sort correctly - T86139
+ $messageNames = array_map( [ $this->lang, 'ucfirst' ], $messageNames );
+
+ if ( $descending ) {
+ rsort( $messageNames );
+ } else {
+ asort( $messageNames );
+ }
+
+ return $messageNames;
+ }
+
+ /**
+ * Determine which of the MediaWiki and MediaWiki_talk namespace pages exist.
+ * Returns [ 'pages' => ..., 'talks' => ... ], where the subarrays have
+ * an entry for each existing page, with the key being the message name and
+ * value arbitrary.
+ *
+ * @param array $messageNames
+ * @param string $langcode What language code
+ * @param bool $foreign Whether the $langcode is not the content language
+ * @return array A 'pages' and 'talks' array with the keys of existing pages
+ */
+ public static function getCustomisedStatuses( $messageNames, $langcode = 'en', $foreign = false ) {
+ // FIXME: This function should be moved to Language:: or something.
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'page',
+ [ 'page_namespace', 'page_title' ],
+ [ 'page_namespace' => [ NS_MEDIAWIKI, NS_MEDIAWIKI_TALK ] ],
+ __METHOD__,
+ [ 'USE INDEX' => 'name_title' ]
+ );
+ $xNames = array_flip( $messageNames );
+
+ $pageFlags = $talkFlags = [];
+
+ foreach ( $res as $s ) {
+ $exists = false;
+
+ if ( $foreign ) {
+ $titleParts = explode( '/', $s->page_title );
+ if ( count( $titleParts ) === 2 &&
+ $langcode === $titleParts[1] &&
+ isset( $xNames[$titleParts[0]] )
+ ) {
+ $exists = $titleParts[0];
+ }
+ } elseif ( isset( $xNames[$s->page_title] ) ) {
+ $exists = $s->page_title;
+ }
+
+ $title = Title::newFromRow( $s );
+ if ( $exists && $title->inNamespace( NS_MEDIAWIKI ) ) {
+ $pageFlags[$exists] = true;
+ } elseif ( $exists && $title->inNamespace( NS_MEDIAWIKI_TALK ) ) {
+ $talkFlags[$exists] = true;
+ }
+ }
+
+ return [ 'pages' => $pageFlags, 'talks' => $talkFlags ];
+ }
+
+ /**
+ * This function normally does a database query to get the results; we need
+ * to make a pretend result using a FakeResultWrapper.
+ * @param string $offset
+ * @param int $limit
+ * @param bool $descending
+ * @return FakeResultWrapper
+ */
+ function reallyDoQuery( $offset, $limit, $descending ) {
+ $result = new FakeResultWrapper( [] );
+
+ $messageNames = $this->getAllMessages( $descending );
+ $statuses = self::getCustomisedStatuses( $messageNames, $this->langcode, $this->foreign );
+
+ $count = 0;
+ foreach ( $messageNames as $key ) {
+ $customised = isset( $statuses['pages'][$key] );
+ if ( $customised !== $this->custom &&
+ ( $descending && ( $key < $offset || !$offset ) || !$descending && $key > $offset ) &&
+ ( ( $this->prefix && preg_match( $this->prefix, $key ) ) || $this->prefix === false )
+ ) {
+ $actual = wfMessage( $key )->inLanguage( $this->langcode )->plain();
+ $default = wfMessage( $key )->inLanguage( $this->langcode )->useDatabase( false )->plain();
+ $result->result[] = [
+ 'am_title' => $key,
+ 'am_actual' => $actual,
+ 'am_default' => $default,
+ 'am_customised' => $customised,
+ 'am_talk_exists' => isset( $statuses['talks'][$key] )
+ ];
+ $count++;
+ }
+
+ if ( $count === $limit ) {
+ break;
+ }
+ }
+
+ return $result;
+ }
+
+ function getStartBody() {
+ $tableClass = $this->getTableClass();
+ return Xml::openElement( 'table', [
+ 'class' => "mw-datatable $tableClass",
+ 'id' => 'mw-allmessagestable'
+ ] ) .
+ "\n" .
+ "<thead><tr>
+ <th rowspan=\"2\">" .
+ $this->msg( 'allmessagesname' )->escaped() . "
+ </th>
+ <th>" .
+ $this->msg( 'allmessagesdefault' )->escaped() .
+ "</th>
+ </tr>\n
+ <tr>
+ <th>" .
+ $this->msg( 'allmessagescurrent' )->escaped() .
+ "</th>
+ </tr></thead><tbody>\n";
+ }
+
+ function formatValue( $field, $value ) {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ switch ( $field ) {
+ case 'am_title' :
+ $title = Title::makeTitle( NS_MEDIAWIKI, $value . $this->suffix );
+ $talk = Title::makeTitle( NS_MEDIAWIKI_TALK, $value . $this->suffix );
+ $translation = Linker::makeExternalLink(
+ 'https://translatewiki.net/w/i.php?' . wfArrayToCgi( [
+ 'title' => 'Special:SearchTranslations',
+ 'group' => 'mediawiki',
+ 'grouppath' => 'mediawiki',
+ 'language' => $this->getLanguage()->getCode(),
+ 'query' => $value . ' ' . $this->msg( $value )->plain()
+ ] ),
+ $this->msg( 'allmessages-filter-translate' )->text()
+ );
+
+ if ( $this->mCurrentRow->am_customised ) {
+ $title = $linkRenderer->makeKnownLink( $title, $this->getLanguage()->lcfirst( $value ) );
+ } else {
+ $title = $linkRenderer->makeBrokenLink(
+ $title,
+ $this->getLanguage()->lcfirst( $value )
+ );
+ }
+ if ( $this->mCurrentRow->am_talk_exists ) {
+ $talk = $linkRenderer->makeKnownLink( $talk, $this->talk );
+ } else {
+ $talk = $linkRenderer->makeBrokenLink(
+ $talk,
+ $this->talk
+ );
+ }
+
+ return $title . ' ' .
+ $this->msg( 'parentheses' )->rawParams( $talk )->escaped() .
+ ' ' .
+ $this->msg( 'parentheses' )->rawParams( $translation )->escaped();
+
+ case 'am_default' :
+ case 'am_actual' :
+ return Sanitizer::escapeHtmlAllowEntities( $value );
+ }
+
+ return '';
+ }
+
+ function formatRow( $row ) {
+ // Do all the normal stuff
+ $s = parent::formatRow( $row );
+
+ // But if there's a customised message, add that too.
+ if ( $row->am_customised ) {
+ $s .= Xml::openElement( 'tr', $this->getRowAttrs( $row, true ) );
+ $formatted = strval( $this->formatValue( 'am_actual', $row->am_actual ) );
+
+ if ( $formatted === '' ) {
+ $formatted = '&#160;';
+ }
+
+ $s .= Xml::tags( 'td', $this->getCellAttrs( 'am_actual', $row->am_actual ), $formatted )
+ . "</tr>\n";
+ }
+
+ return $s;
+ }
+
+ function getRowAttrs( $row, $isSecond = false ) {
+ $arr = [];
+
+ if ( $row->am_customised ) {
+ $arr['class'] = 'allmessages-customised';
+ }
+
+ if ( !$isSecond ) {
+ $arr['id'] = Sanitizer::escapeIdForAttribute(
+ 'msg_' . $this->getLanguage()->lcfirst( $row->am_title )
+ );
+ }
+
+ return $arr;
+ }
+
+ function getCellAttrs( $field, $value ) {
+ if ( $this->mCurrentRow->am_customised && $field === 'am_title' ) {
+ return [ 'rowspan' => '2', 'class' => $field ];
+ } elseif ( $field === 'am_title' ) {
+ return [ 'class' => $field ];
+ } else {
+ return [
+ 'lang' => $this->lang->getHtmlCode(),
+ 'dir' => $this->lang->getDir(),
+ 'class' => $field
+ ];
+ }
+ }
+
+ // This is not actually used, as getStartBody is overridden above
+ function getFieldNames() {
+ return [
+ 'am_title' => $this->msg( 'allmessagesname' )->text(),
+ 'am_default' => $this->msg( 'allmessagesdefault' )->text()
+ ];
+ }
+
+ function getTitle() {
+ return SpecialPage::getTitleFor( 'Allmessages', false );
+ }
+
+ function isFieldSortable( $x ) {
+ return false;
+ }
+
+ function getDefaultSort() {
+ return '';
+ }
+
+ function getQueryInfo() {
+ return '';
+ }
+
+}
diff --git a/www/wiki/includes/specials/pagers/BlockListPager.php b/www/wiki/includes/specials/pagers/BlockListPager.php
new file mode 100644
index 00000000..924fd06c
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/BlockListPager.php
@@ -0,0 +1,309 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * @ingroup Pager
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+
+class BlockListPager extends TablePager {
+
+ protected $conds;
+ protected $page;
+
+ /**
+ * @param SpecialPage $page
+ * @param array $conds
+ */
+ function __construct( $page, $conds ) {
+ $this->page = $page;
+ $this->conds = $conds;
+ $this->mDefaultDirection = IndexPager::DIR_DESCENDING;
+ parent::__construct( $page->getContext() );
+ }
+
+ function getFieldNames() {
+ static $headers = null;
+
+ if ( $headers === null ) {
+ $headers = [
+ 'ipb_timestamp' => 'blocklist-timestamp',
+ 'ipb_target' => 'blocklist-target',
+ 'ipb_expiry' => 'blocklist-expiry',
+ 'ipb_by' => 'blocklist-by',
+ 'ipb_params' => 'blocklist-params',
+ 'ipb_reason' => 'blocklist-reason',
+ ];
+ foreach ( $headers as $key => $val ) {
+ $headers[$key] = $this->msg( $val )->text();
+ }
+ }
+
+ return $headers;
+ }
+
+ function formatValue( $name, $value ) {
+ static $msg = null;
+ if ( $msg === null ) {
+ $keys = [
+ 'anononlyblock',
+ 'createaccountblock',
+ 'noautoblockblock',
+ 'emailblock',
+ 'blocklist-nousertalk',
+ 'unblocklink',
+ 'change-blocklink',
+ ];
+
+ foreach ( $keys as $key ) {
+ $msg[$key] = $this->msg( $key )->text();
+ }
+ }
+
+ /** @var object $row */
+ $row = $this->mCurrentRow;
+
+ $language = $this->getLanguage();
+
+ $formatted = '';
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ switch ( $name ) {
+ case 'ipb_timestamp':
+ $formatted = htmlspecialchars( $language->userTimeAndDate( $value, $this->getUser() ) );
+ break;
+
+ case 'ipb_target':
+ if ( $row->ipb_auto ) {
+ $formatted = $this->msg( 'autoblockid', $row->ipb_id )->parse();
+ } else {
+ list( $target, $type ) = Block::parseTarget( $row->ipb_address );
+ switch ( $type ) {
+ case Block::TYPE_USER:
+ case Block::TYPE_IP:
+ $formatted = Linker::userLink( $target->getId(), $target );
+ $formatted .= Linker::userToolLinks(
+ $target->getId(),
+ $target,
+ false,
+ Linker::TOOL_LINKS_NOBLOCK
+ );
+ break;
+ case Block::TYPE_RANGE:
+ $formatted = htmlspecialchars( $target );
+ }
+ }
+ break;
+
+ case 'ipb_expiry':
+ $formatted = htmlspecialchars( $language->formatExpiry(
+ $value,
+ /* User preference timezone */true
+ ) );
+ if ( $this->getUser()->isAllowed( 'block' ) ) {
+ if ( $row->ipb_auto ) {
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Unblock' ),
+ $msg['unblocklink'],
+ [],
+ [ 'wpTarget' => "#{$row->ipb_id}" ]
+ );
+ } else {
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Unblock', $row->ipb_address ),
+ $msg['unblocklink']
+ );
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Block', $row->ipb_address ),
+ $msg['change-blocklink']
+ );
+ }
+ $formatted .= ' ' . Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-blocklist-actions' ],
+ $this->msg( 'parentheses' )->rawParams(
+ $language->pipeList( $links ) )->escaped()
+ );
+ }
+ if ( $value !== 'infinity' ) {
+ $timestamp = new MWTimestamp( $value );
+ $formatted .= '<br />' . $this->msg(
+ 'ipb-blocklist-duration-left',
+ $language->formatDuration(
+ $timestamp->getTimestamp() - time(),
+ // reasonable output
+ [
+ 'minutes',
+ 'hours',
+ 'days',
+ 'years',
+ ]
+ )
+ )->escaped();
+ }
+ break;
+
+ case 'ipb_by':
+ if ( isset( $row->by_user_name ) ) {
+ $formatted = Linker::userLink( $value, $row->by_user_name );
+ $formatted .= Linker::userToolLinks( $value, $row->by_user_name );
+ } else {
+ $formatted = htmlspecialchars( $row->ipb_by_text ); // foreign user?
+ }
+ break;
+
+ case 'ipb_reason':
+ $value = CommentStore::newKey( 'ipb_reason' )->getComment( $row )->text;
+ $formatted = Linker::formatComment( $value );
+ break;
+
+ case 'ipb_params':
+ $properties = [];
+ if ( $row->ipb_anon_only ) {
+ $properties[] = htmlspecialchars( $msg['anononlyblock'] );
+ }
+ if ( $row->ipb_create_account ) {
+ $properties[] = htmlspecialchars( $msg['createaccountblock'] );
+ }
+ if ( $row->ipb_user && !$row->ipb_enable_autoblock ) {
+ $properties[] = htmlspecialchars( $msg['noautoblockblock'] );
+ }
+
+ if ( $row->ipb_block_email ) {
+ $properties[] = htmlspecialchars( $msg['emailblock'] );
+ }
+
+ if ( !$row->ipb_allow_usertalk ) {
+ $properties[] = htmlspecialchars( $msg['blocklist-nousertalk'] );
+ }
+
+ $formatted = $language->commaList( $properties );
+ break;
+
+ default:
+ $formatted = "Unable to format $name";
+ break;
+ }
+
+ return $formatted;
+ }
+
+ function getQueryInfo() {
+ $commentQuery = CommentStore::newKey( 'ipb_reason' )->getJoin();
+
+ $info = [
+ 'tables' => [ 'ipblocks', 'user' ] + $commentQuery['tables'],
+ 'fields' => [
+ 'ipb_id',
+ 'ipb_address',
+ 'ipb_user',
+ 'ipb_by',
+ 'ipb_by_text',
+ 'by_user_name' => 'user_name',
+ 'ipb_timestamp',
+ 'ipb_auto',
+ 'ipb_anon_only',
+ 'ipb_create_account',
+ 'ipb_enable_autoblock',
+ 'ipb_expiry',
+ 'ipb_range_start',
+ 'ipb_range_end',
+ 'ipb_deleted',
+ 'ipb_block_email',
+ 'ipb_allow_usertalk',
+ ] + $commentQuery['fields'],
+ 'conds' => $this->conds,
+ 'join_conds' => [ 'user' => [ 'LEFT JOIN', 'user_id = ipb_by' ] ] + $commentQuery['joins']
+ ];
+
+ # Filter out any expired blocks
+ $db = $this->getDatabase();
+ $info['conds'][] = 'ipb_expiry > ' . $db->addQuotes( $db->timestamp() );
+
+ # Is the user allowed to see hidden blocks?
+ if ( !$this->getUser()->isAllowed( 'hideuser' ) ) {
+ $info['conds']['ipb_deleted'] = 0;
+ }
+
+ return $info;
+ }
+
+ /**
+ * Get total number of autoblocks at any given time
+ *
+ * @return int Total number of unexpired active autoblocks
+ */
+ function getTotalAutoblocks() {
+ $dbr = $this->getDatabase();
+ $res = $dbr->selectField( 'ipblocks',
+ [ 'COUNT(*) AS totalautoblocks' ],
+ [
+ 'ipb_auto' => '1',
+ 'ipb_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ),
+ ]
+ );
+ if ( $res ) {
+ return $res;
+ }
+ return 0; // We found nothing
+ }
+
+ protected function getTableClass() {
+ return parent::getTableClass() . ' mw-blocklist';
+ }
+
+ function getIndexField() {
+ return 'ipb_timestamp';
+ }
+
+ function getDefaultSort() {
+ return 'ipb_timestamp';
+ }
+
+ function isFieldSortable( $name ) {
+ return false;
+ }
+
+ /**
+ * Do a LinkBatch query to minimise database load when generating all these links
+ * @param ResultWrapper $result
+ */
+ function preprocessResults( $result ) {
+ # Do a link batch query
+ $lb = new LinkBatch;
+ $lb->setCaller( __METHOD__ );
+
+ foreach ( $result as $row ) {
+ $lb->add( NS_USER, $row->ipb_address );
+ $lb->add( NS_USER_TALK, $row->ipb_address );
+
+ if ( isset( $row->by_user_name ) ) {
+ $lb->add( NS_USER, $row->by_user_name );
+ $lb->add( NS_USER_TALK, $row->by_user_name );
+ }
+ }
+
+ $lb->execute();
+ }
+
+}
diff --git a/www/wiki/includes/specials/pagers/CategoryPager.php b/www/wiki/includes/specials/pagers/CategoryPager.php
new file mode 100644
index 00000000..7db90c17
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/CategoryPager.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+use MediaWiki\Linker\LinkRenderer;
+
+/**
+ * @ingroup Pager
+ */
+class CategoryPager extends AlphabeticPager {
+
+ /**
+ * @var LinkRenderer
+ */
+ protected $linkRenderer;
+
+ /**
+ * @param IContextSource $context
+ * @param string $from
+ * @param LinkRenderer $linkRenderer
+ */
+ public function __construct( IContextSource $context, $from, LinkRenderer $linkRenderer
+ ) {
+ parent::__construct( $context );
+ $from = str_replace( ' ', '_', $from );
+ if ( $from !== '' ) {
+ $from = Title::capitalize( $from, NS_CATEGORY );
+ $this->setOffset( $from );
+ $this->setIncludeOffset( true );
+ }
+
+ $this->linkRenderer = $linkRenderer;
+ }
+
+ function getQueryInfo() {
+ return [
+ 'tables' => [ 'category' ],
+ 'fields' => [ 'cat_title', 'cat_pages' ],
+ 'options' => [ 'USE INDEX' => 'cat_title' ],
+ ];
+ }
+
+ function getIndexField() {
+ return 'cat_title';
+ }
+
+ function getDefaultQuery() {
+ parent::getDefaultQuery();
+ unset( $this->mDefaultQuery['from'] );
+
+ return $this->mDefaultQuery;
+ }
+
+ /* Override getBody to apply LinksBatch on resultset before actually outputting anything. */
+ public function getBody() {
+ $batch = new LinkBatch;
+
+ $this->mResult->rewind();
+
+ foreach ( $this->mResult as $row ) {
+ $batch->addObj( new TitleValue( NS_CATEGORY, $row->cat_title ) );
+ }
+ $batch->execute();
+ $this->mResult->rewind();
+
+ return parent::getBody();
+ }
+
+ function formatRow( $result ) {
+ $title = new TitleValue( NS_CATEGORY, $result->cat_title );
+ $text = $title->getText();
+ $link = $this->linkRenderer->makeLink( $title, $text );
+
+ $count = $this->msg( 'nmembers' )->numParams( $result->cat_pages )->escaped();
+ return Html::rawElement( 'li', null, $this->getLanguage()->specialList( $link, $count ) ) . "\n";
+ }
+
+ public function getStartForm( $from ) {
+ $formDescriptor = [
+ 'from' => [
+ 'type' => 'title',
+ 'namespace' => NS_CATEGORY,
+ 'relative' => true,
+ 'label-message' => 'categoriesfrom',
+ 'name' => 'from',
+ 'id' => 'from',
+ 'size' => 20,
+ 'default' => $from,
+ ],
+ ];
+
+ $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
+ ->setSubmitTextMsg( 'categories-submit' )
+ ->setWrapperLegendMsg( 'categories' )
+ ->setMethod( 'get' );
+ return $htmlForm->prepareForm()->getHTML( false );
+ }
+
+}
diff --git a/www/wiki/includes/specials/pagers/ContribsPager.php b/www/wiki/includes/specials/pagers/ContribsPager.php
new file mode 100644
index 00000000..979460cf
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/ContribsPager.php
@@ -0,0 +1,693 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * Pager for Special:Contributions
+ * @ingroup Pager
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+class ContribsPager extends RangeChronologicalPager {
+
+ public $mDefaultDirection = IndexPager::DIR_DESCENDING;
+ public $messages;
+ public $target;
+ public $namespace = '';
+ public $mDb;
+ public $preventClickjacking = false;
+
+ /** @var IDatabase */
+ public $mDbSecondary;
+
+ /**
+ * @var array
+ */
+ protected $mParentLens;
+
+ /**
+ * @var TemplateParser
+ */
+ protected $templateParser;
+
+ function __construct( IContextSource $context, array $options ) {
+ parent::__construct( $context );
+
+ $msgs = [
+ 'diff',
+ 'hist',
+ 'pipe-separator',
+ 'uctop'
+ ];
+
+ foreach ( $msgs as $msg ) {
+ $this->messages[$msg] = $this->msg( $msg )->escaped();
+ }
+
+ $this->target = isset( $options['target'] ) ? $options['target'] : '';
+ $this->contribs = isset( $options['contribs'] ) ? $options['contribs'] : 'users';
+ $this->namespace = isset( $options['namespace'] ) ? $options['namespace'] : '';
+ $this->tagFilter = isset( $options['tagfilter'] ) ? $options['tagfilter'] : false;
+ $this->nsInvert = isset( $options['nsInvert'] ) ? $options['nsInvert'] : false;
+ $this->associated = isset( $options['associated'] ) ? $options['associated'] : false;
+
+ $this->deletedOnly = !empty( $options['deletedOnly'] );
+ $this->topOnly = !empty( $options['topOnly'] );
+ $this->newOnly = !empty( $options['newOnly'] );
+ $this->hideMinor = !empty( $options['hideMinor'] );
+
+ // Date filtering: use timestamp if available
+ $startTimestamp = '';
+ $endTimestamp = '';
+ if ( $options['start'] ) {
+ $startTimestamp = $options['start'] . ' 00:00:00';
+ }
+ if ( $options['end'] ) {
+ $endTimestamp = $options['end'] . ' 23:59:59';
+ }
+ $this->getDateRangeCond( $startTimestamp, $endTimestamp );
+
+ // This property on IndexPager is set by $this->getIndexField() in parent::__construct().
+ // We need to reassign it here so that it is used when the actual query is ran.
+ $this->mIndexField = $this->getIndexField();
+
+ // Most of this code will use the 'contributions' group DB, which can map to replica DBs
+ // with extra user based indexes or partioning by user. The additional metadata
+ // queries should use a regular replica DB since the lookup pattern is not all by user.
+ $this->mDbSecondary = wfGetDB( DB_REPLICA ); // any random replica DB
+ $this->mDb = wfGetDB( DB_REPLICA, 'contributions' );
+ $this->templateParser = new TemplateParser();
+ }
+
+ function getDefaultQuery() {
+ $query = parent::getDefaultQuery();
+ $query['target'] = $this->target;
+
+ return $query;
+ }
+
+ /**
+ * This method basically executes the exact same code as the parent class, though with
+ * a hook added, to allow extensions to add additional queries.
+ *
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return ResultWrapper
+ */
+ function reallyDoQuery( $offset, $limit, $descending ) {
+ list( $tables, $fields, $conds, $fname, $options, $join_conds ) = $this->buildQueryInfo(
+ $offset,
+ $limit,
+ $descending
+ );
+
+ /*
+ * This hook will allow extensions to add in additional queries, so they can get their data
+ * in My Contributions as well. Extensions should append their results to the $data array.
+ *
+ * Extension queries have to implement the navbar requirement as well. They should
+ * - have a column aliased as $pager->getIndexField()
+ * - have LIMIT set
+ * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset
+ * - have the ORDER BY specified based upon the details provided by the navbar
+ *
+ * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY
+ *
+ * &$data: an array of results of all contribs queries
+ * $pager: the ContribsPager object hooked into
+ * $offset: see phpdoc above
+ * $limit: see phpdoc above
+ * $descending: see phpdoc above
+ */
+ $data = [ $this->mDb->select(
+ $tables, $fields, $conds, $fname, $options, $join_conds
+ ) ];
+ Hooks::run(
+ 'ContribsPager::reallyDoQuery',
+ [ &$data, $this, $offset, $limit, $descending ]
+ );
+
+ $result = [];
+
+ // loop all results and collect them in an array
+ foreach ( $data as $query ) {
+ foreach ( $query as $i => $row ) {
+ // use index column as key, allowing us to easily sort in PHP
+ $result[$row->{$this->getIndexField()} . "-$i"] = $row;
+ }
+ }
+
+ // sort results
+ if ( $descending ) {
+ ksort( $result );
+ } else {
+ krsort( $result );
+ }
+
+ // enforce limit
+ $result = array_slice( $result, 0, $limit );
+
+ // get rid of array keys
+ $result = array_values( $result );
+
+ return new FakeResultWrapper( $result );
+ }
+
+ function getQueryInfo() {
+ list( $tables, $index, $userCond, $join_cond ) = $this->getUserCond();
+
+ $user = $this->getUser();
+ $conds = array_merge( $userCond, $this->getNamespaceCond() );
+
+ // Paranoia: avoid brute force searches (T19342)
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $conds[] = $this->mDb->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0';
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $conds[] = $this->mDb->bitAnd( 'rev_deleted', Revision::SUPPRESSED_USER ) .
+ ' != ' . Revision::SUPPRESSED_USER;
+ }
+
+ # Don't include orphaned revisions
+ $join_cond['page'] = Revision::pageJoinCond();
+ # Get the current user name for accounts
+ $join_cond['user'] = Revision::userJoinCond();
+
+ $options = [];
+ if ( $index ) {
+ $options['USE INDEX'] = [ 'revision' => $index ];
+ }
+
+ $queryInfo = [
+ 'tables' => $tables,
+ 'fields' => array_merge(
+ Revision::selectFields(),
+ Revision::selectUserFields(),
+ [ 'page_namespace', 'page_title', 'page_is_new',
+ 'page_latest', 'page_is_redirect', 'page_len' ]
+ ),
+ 'conds' => $conds,
+ 'options' => $options,
+ 'join_conds' => $join_cond
+ ];
+
+ // For IPv6, we use ipc_rev_timestamp on ip_changes as the index field,
+ // which will be referenced when parsing the results of a query.
+ if ( self::isQueryableRange( $this->target ) ) {
+ $queryInfo['fields'][] = 'ipc_rev_timestamp';
+ }
+
+ ChangeTags::modifyDisplayQuery(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ $queryInfo['join_conds'],
+ $queryInfo['options'],
+ $this->tagFilter
+ );
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $pager = $this;
+ Hooks::run( 'ContribsPager::getQueryInfo', [ &$pager, &$queryInfo ] );
+
+ return $queryInfo;
+ }
+
+ function getUserCond() {
+ $condition = [];
+ $join_conds = [];
+ $tables = [ 'revision', 'page', 'user' ];
+ $index = false;
+ if ( $this->contribs == 'newbie' ) {
+ $max = $this->mDb->selectField( 'user', 'max(user_id)', false, __METHOD__ );
+ $condition[] = 'rev_user >' . (int)( $max - $max / 100 );
+ # ignore local groups with the bot right
+ # @todo FIXME: Global groups may have 'bot' rights
+ $groupsWithBotPermission = User::getGroupsWithPermission( 'bot' );
+ if ( count( $groupsWithBotPermission ) ) {
+ $tables[] = 'user_groups';
+ $condition[] = 'ug_group IS NULL';
+ $join_conds['user_groups'] = [
+ 'LEFT JOIN', [
+ 'ug_user = rev_user',
+ 'ug_group' => $groupsWithBotPermission,
+ 'ug_expiry IS NULL OR ug_expiry >= ' .
+ $this->mDb->addQuotes( $this->mDb->timestamp() )
+ ]
+ ];
+ }
+ // (T140537) Disallow looking too far in the past for 'newbies' queries. If the user requested
+ // a timestamp offset far in the past such that there are no edits by users with user_ids in
+ // the range, we would end up scanning all revisions from that offset until start of time.
+ $condition[] = 'rev_timestamp > ' .
+ $this->mDb->addQuotes( $this->mDb->timestamp( wfTimestamp() - 30 * 24 * 60 * 60 ) );
+ } else {
+ $uid = User::idFromName( $this->target );
+ if ( $uid ) {
+ $condition['rev_user'] = $uid;
+ $index = 'user_timestamp';
+ } else {
+ $ipRangeConds = $this->getIpRangeConds( $this->mDb, $this->target );
+
+ if ( $ipRangeConds ) {
+ $tables[] = 'ip_changes';
+ $join_conds['ip_changes'] = [
+ 'LEFT JOIN', [ 'ipc_rev_id = rev_id' ]
+ ];
+ $condition[] = $ipRangeConds;
+ } else {
+ $condition['rev_user_text'] = $this->target;
+ $index = 'usertext_timestamp';
+ }
+ }
+ }
+
+ if ( $this->deletedOnly ) {
+ $condition[] = 'rev_deleted != 0';
+ }
+
+ if ( $this->topOnly ) {
+ $condition[] = 'rev_id = page_latest';
+ }
+
+ if ( $this->newOnly ) {
+ $condition[] = 'rev_parent_id = 0';
+ }
+
+ if ( $this->hideMinor ) {
+ $condition[] = 'rev_minor_edit = 0';
+ }
+
+ return [ $tables, $index, $condition, $join_conds ];
+ }
+
+ function getNamespaceCond() {
+ if ( $this->namespace !== '' ) {
+ $selectedNS = $this->mDb->addQuotes( $this->namespace );
+ $eq_op = $this->nsInvert ? '!=' : '=';
+ $bool_op = $this->nsInvert ? 'AND' : 'OR';
+
+ if ( !$this->associated ) {
+ return [ "page_namespace $eq_op $selectedNS" ];
+ }
+
+ $associatedNS = $this->mDb->addQuotes(
+ MWNamespace::getAssociated( $this->namespace )
+ );
+
+ return [
+ "page_namespace $eq_op $selectedNS " .
+ $bool_op .
+ " page_namespace $eq_op $associatedNS"
+ ];
+ }
+
+ return [];
+ }
+
+ /**
+ * Get SQL conditions for an IP range, if applicable
+ * @param IDatabase $db
+ * @param string $ip The IP address or CIDR
+ * @return string|false SQL for valid IP ranges, false if invalid
+ */
+ private function getIpRangeConds( $db, $ip ) {
+ // First make sure it is a valid range and they are not outside the CIDR limit
+ if ( !$this->isQueryableRange( $ip ) ) {
+ return false;
+ }
+
+ list( $start, $end ) = IP::parseRange( $ip );
+
+ return 'ipc_hex BETWEEN ' . $db->addQuotes( $start ) . ' AND ' . $db->addQuotes( $end );
+ }
+
+ /**
+ * Is the given IP a range and within the CIDR limit?
+ *
+ * @param string $ipRange
+ * @return bool True if it is valid
+ * @since 1.30
+ */
+ public function isQueryableRange( $ipRange ) {
+ $limits = $this->getConfig()->get( 'RangeContributionsCIDRLimit' );
+
+ $bits = IP::parseCIDR( $ipRange )[1];
+ if (
+ ( $bits === false ) ||
+ ( IP::isIPv4( $ipRange ) && $bits < $limits['IPv4'] ) ||
+ ( IP::isIPv6( $ipRange ) && $bits < $limits['IPv6'] )
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Override of getIndexField() in IndexPager.
+ * For IP ranges, it's faster to use the replicated ipc_rev_timestamp
+ * on the `ip_changes` table than the rev_timestamp on the `revision` table.
+ * @return string Name of field
+ */
+ public function getIndexField() {
+ if ( $this->isQueryableRange( $this->target ) ) {
+ return 'ipc_rev_timestamp';
+ } else {
+ return 'rev_timestamp';
+ }
+ }
+
+ function doBatchLookups() {
+ # Do a link batch query
+ $this->mResult->seek( 0 );
+ $parentRevIds = [];
+ $this->mParentLens = [];
+ $batch = new LinkBatch();
+ $isIpRange = $this->isQueryableRange( $this->target );
+ # Give some pointers to make (last) links
+ foreach ( $this->mResult as $row ) {
+ if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) {
+ $parentRevIds[] = $row->rev_parent_id;
+ }
+ if ( isset( $row->rev_id ) ) {
+ $this->mParentLens[$row->rev_id] = $row->rev_len;
+ if ( $this->contribs === 'newbie' ) { // multiple users
+ $batch->add( NS_USER, $row->user_name );
+ $batch->add( NS_USER_TALK, $row->user_name );
+ } elseif ( $isIpRange ) {
+ // If this is an IP range, batch the IP's talk page
+ $batch->add( NS_USER_TALK, $row->rev_user_text );
+ }
+ $batch->add( $row->page_namespace, $row->page_title );
+ }
+ }
+ # Fetch rev_len for revisions not already scanned above
+ $this->mParentLens += Revision::getParentLengths(
+ $this->mDbSecondary,
+ array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
+ );
+ $batch->execute();
+ $this->mResult->seek( 0 );
+ }
+
+ /**
+ * @return string
+ */
+ function getStartBody() {
+ return "<ul class=\"mw-contributions-list\">\n";
+ }
+
+ /**
+ * @return string
+ */
+ function getEndBody() {
+ return "</ul>\n";
+ }
+
+ /**
+ * Generates each row in the contributions list.
+ *
+ * Contributions which are marked "top" are currently on top of the history.
+ * For these contributions, a [rollback] link is shown for users with roll-
+ * back privileges. The rollback link restores the most recent version that
+ * was not written by the target user.
+ *
+ * @todo This would probably look a lot nicer in a table.
+ * @param object $row
+ * @return string
+ */
+ function formatRow( $row ) {
+ $ret = '';
+ $classes = [];
+ $attribs = [];
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ /*
+ * There may be more than just revision rows. To make sure that we'll only be processing
+ * revisions here, let's _try_ to build a revision out of our row (without displaying
+ * notices though) and then trying to grab data from the built object. If we succeed,
+ * we're definitely dealing with revision data and we may proceed, if not, we'll leave it
+ * to extensions to subscribe to the hook to parse the row.
+ */
+ MediaWiki\suppressWarnings();
+ try {
+ $rev = new Revision( $row );
+ $validRevision = (bool)$rev->getId();
+ } catch ( Exception $e ) {
+ $validRevision = false;
+ }
+ MediaWiki\restoreWarnings();
+
+ if ( $validRevision ) {
+ $attribs['data-mw-revid'] = $rev->getId();
+
+ $page = Title::newFromRow( $row );
+ $link = $linkRenderer->makeLink(
+ $page,
+ $page->getPrefixedText(),
+ [ 'class' => 'mw-contributions-title' ],
+ $page->isRedirect() ? [ 'redirect' => 'no' ] : []
+ );
+ # Mark current revisions
+ $topmarktext = '';
+ $user = $this->getUser();
+
+ if ( $row->rev_id === $row->page_latest ) {
+ $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
+ $classes[] = 'mw-contributions-current';
+ # Add rollback link
+ if ( !$row->page_is_new && $page->quickUserCan( 'rollback', $user )
+ && $page->quickUserCan( 'edit', $user )
+ ) {
+ $this->preventClickjacking();
+ $topmarktext .= ' ' . Linker::generateRollback( $rev, $this->getContext() );
+ }
+ }
+ # Is there a visible previous revision?
+ if ( $rev->userCan( Revision::DELETED_TEXT, $user ) && $rev->getParentId() !== 0 ) {
+ $difftext = $linkRenderer->makeKnownLink(
+ $page,
+ new HtmlArmor( $this->messages['diff'] ),
+ [ 'class' => 'mw-changeslist-diff' ],
+ [
+ 'diff' => 'prev',
+ 'oldid' => $row->rev_id
+ ]
+ );
+ } else {
+ $difftext = $this->messages['diff'];
+ }
+ $histlink = $linkRenderer->makeKnownLink(
+ $page,
+ new HtmlArmor( $this->messages['hist'] ),
+ [ 'class' => 'mw-changeslist-history' ],
+ [ 'action' => 'history' ]
+ );
+
+ if ( $row->rev_parent_id === null ) {
+ // For some reason rev_parent_id isn't populated for this row.
+ // Its rumoured this is true on wikipedia for some revisions (T36922).
+ // Next best thing is to have the total number of bytes.
+ $chardiff = ' <span class="mw-changeslist-separator">. .</span> ';
+ $chardiff .= Linker::formatRevisionSize( $row->rev_len );
+ $chardiff .= ' <span class="mw-changeslist-separator">. .</span> ';
+ } else {
+ $parentLen = 0;
+ if ( isset( $this->mParentLens[$row->rev_parent_id] ) ) {
+ $parentLen = $this->mParentLens[$row->rev_parent_id];
+ }
+
+ $chardiff = ' <span class="mw-changeslist-separator">. .</span> ';
+ $chardiff .= ChangesList::showCharacterDifference(
+ $parentLen,
+ $row->rev_len,
+ $this->getContext()
+ );
+ $chardiff .= ' <span class="mw-changeslist-separator">. .</span> ';
+ }
+
+ $lang = $this->getLanguage();
+ $comment = $lang->getDirMark() . Linker::revComment( $rev, false, true );
+ $date = $lang->userTimeAndDate( $row->rev_timestamp, $user );
+ if ( $rev->userCan( Revision::DELETED_TEXT, $user ) ) {
+ $d = $linkRenderer->makeKnownLink(
+ $page,
+ $date,
+ [ 'class' => 'mw-changeslist-date' ],
+ [ 'oldid' => intval( $row->rev_id ) ]
+ );
+ } else {
+ $d = htmlspecialchars( $date );
+ }
+ if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $d = '<span class="history-deleted">' . $d . '</span>';
+ }
+
+ # Show user names for /newbies as there may be different users.
+ # Note that only unprivileged users have rows with hidden user names excluded.
+ # When querying for an IP range, we want to always show user and user talk links.
+ $userlink = '';
+ if ( ( $this->contribs == 'newbie' && !$rev->isDeleted( Revision::DELETED_USER ) )
+ || $this->isQueryableRange( $this->target ) ) {
+ $userlink = ' . . ' . $lang->getDirMark()
+ . Linker::userLink( $rev->getUser(), $rev->getUserText() );
+ $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
+ Linker::userTalkLink( $rev->getUser(), $rev->getUserText() ) )->escaped() . ' ';
+ }
+
+ $flags = [];
+ if ( $rev->getParentId() === 0 ) {
+ $flags[] = ChangesList::flag( 'newpage' );
+ }
+
+ if ( $rev->isMinor() ) {
+ $flags[] = ChangesList::flag( 'minor' );
+ }
+
+ $del = Linker::getRevDeleteLink( $user, $rev, $page );
+ if ( $del !== '' ) {
+ $del .= ' ';
+ }
+
+ $diffHistLinks = $this->msg( 'parentheses' )
+ ->rawParams( $difftext . $this->messages['pipe-separator'] . $histlink )
+ ->escaped();
+
+ # Tags, if any.
+ list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow(
+ $row->ts_tags,
+ 'contributions',
+ $this->getContext()
+ );
+ $classes = array_merge( $classes, $newClasses );
+
+ Hooks::run( 'SpecialContributions::formatRow::flags', [ $this->getContext(), $row, &$flags ] );
+
+ $templateParams = [
+ 'del' => $del,
+ 'timestamp' => $d,
+ 'diffHistLinks' => $diffHistLinks,
+ 'charDifference' => $chardiff,
+ 'flags' => $flags,
+ 'articleLink' => $link,
+ 'userlink' => $userlink,
+ 'logText' => $comment,
+ 'topmarktext' => $topmarktext,
+ 'tagSummary' => $tagSummary,
+ ];
+
+ # Denote if username is redacted for this edit
+ if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
+ $templateParams['rev-deleted-user-contribs'] =
+ $this->msg( 'rev-deleted-user-contribs' )->escaped();
+ }
+
+ $ret = $this->templateParser->processTemplate(
+ 'SpecialContributionsLine',
+ $templateParams
+ );
+ }
+
+ // Let extensions add data
+ Hooks::run( 'ContributionsLineEnding', [ $this, &$ret, $row, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+
+ // TODO: Handle exceptions in the catch block above. Do any extensions rely on
+ // receiving empty rows?
+
+ if ( $classes === [] && $attribs === [] && $ret === '' ) {
+ wfDebug( "Dropping Special:Contribution row that could not be formatted\n" );
+ return "<!-- Could not format Special:Contribution row. -->\n";
+ }
+ $attribs['class'] = $classes;
+
+ // FIXME: The signature of the ContributionsLineEnding hook makes it
+ // very awkward to move this LI wrapper into the template.
+ return Html::rawElement( 'li', $attribs, $ret ) . "\n";
+ }
+
+ /**
+ * Overwrite Pager function and return a helpful comment
+ * @return string
+ */
+ function getSqlComment() {
+ if ( $this->namespace || $this->deletedOnly ) {
+ // potentially slow, see CR r58153
+ return 'contributions page filtered for namespace or RevisionDeleted edits';
+ } else {
+ return 'contributions page unfiltered';
+ }
+ }
+
+ protected function preventClickjacking() {
+ $this->preventClickjacking = true;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getPreventClickjacking() {
+ return $this->preventClickjacking;
+ }
+
+ /**
+ * Set up date filter options, given request data.
+ *
+ * @param array $opts Options array
+ * @return array Options array with processed start and end date filter options
+ */
+ public static function processDateFilter( $opts ) {
+ $start = isset( $opts['start'] ) ? $opts['start'] : '';
+ $end = isset( $opts['end'] ) ? $opts['end'] : '';
+ $year = isset( $opts['year'] ) ? $opts['year'] : '';
+ $month = isset( $opts['month'] ) ? $opts['month'] : '';
+
+ if ( $start !== '' && $end !== '' && $start > $end ) {
+ $temp = $start;
+ $start = $end;
+ $end = $temp;
+ }
+
+ // If year/month legacy filtering options are set, convert them to display the new stamp
+ if ( $year !== '' || $month !== '' ) {
+ // Reuse getDateCond logic, but subtract a day because
+ // the endpoints of our date range appear inclusive
+ // but the internal end offsets are always exclusive
+ $legacyTimestamp = ReverseChronologicalPager::getOffsetDate( $year, $month );
+ $legacyDateTime = new DateTime( $legacyTimestamp->getTimestamp( TS_ISO_8601 ) );
+ $legacyDateTime = $legacyDateTime->modify( '-1 day' );
+
+ // Clear the new timestamp range options if used and
+ // replace with the converted legacy timestamp
+ $start = '';
+ $end = $legacyDateTime->format( 'Y-m-d' );
+ }
+
+ $opts['start'] = $start;
+ $opts['end'] = $end;
+
+ return $opts;
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/DeletedContribsPager.php b/www/wiki/includes/specials/pagers/DeletedContribsPager.php
new file mode 100644
index 00000000..38a332e6
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/DeletedContribsPager.php
@@ -0,0 +1,367 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * @ingroup Pager
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+class DeletedContribsPager extends IndexPager {
+
+ public $mDefaultDirection = IndexPager::DIR_DESCENDING;
+ public $messages;
+ public $target;
+ public $namespace = '';
+ public $mDb;
+
+ /**
+ * @var string Navigation bar with paging links.
+ */
+ protected $mNavigationBar;
+
+ function __construct( IContextSource $context, $target, $namespace = false ) {
+ parent::__construct( $context );
+ $msgs = [ 'deletionlog', 'undeleteviewlink', 'diff' ];
+ foreach ( $msgs as $msg ) {
+ $this->messages[$msg] = $this->msg( $msg )->text();
+ }
+ $this->target = $target;
+ $this->namespace = $namespace;
+ $this->mDb = wfGetDB( DB_REPLICA, 'contributions' );
+ }
+
+ function getDefaultQuery() {
+ $query = parent::getDefaultQuery();
+ $query['target'] = $this->target;
+
+ return $query;
+ }
+
+ function getQueryInfo() {
+ list( $index, $userCond ) = $this->getUserCond();
+ $conds = array_merge( $userCond, $this->getNamespaceCond() );
+ $user = $this->getUser();
+ // Paranoia: avoid brute force searches (T19792)
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $conds[] = $this->mDb->bitAnd( 'ar_deleted', Revision::DELETED_USER ) . ' = 0';
+ } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $conds[] = $this->mDb->bitAnd( 'ar_deleted', Revision::SUPPRESSED_USER ) .
+ ' != ' . Revision::SUPPRESSED_USER;
+ }
+
+ $commentQuery = CommentStore::newKey( 'ar_comment' )->getJoin();
+
+ return [
+ 'tables' => [ 'archive' ] + $commentQuery['tables'],
+ 'fields' => [
+ 'ar_rev_id', 'ar_namespace', 'ar_title', 'ar_timestamp',
+ 'ar_minor_edit', 'ar_user', 'ar_user_text', 'ar_deleted'
+ ] + $commentQuery['fields'],
+ 'conds' => $conds,
+ 'options' => [ 'USE INDEX' => [ 'archive' => $index ] ],
+ 'join_conds' => $commentQuery['joins'],
+ ];
+ }
+
+ /**
+ * This method basically executes the exact same code as the parent class, though with
+ * a hook added, to allow extensions to add additional queries.
+ *
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $descending Query direction, false for ascending, true for descending
+ * @return ResultWrapper
+ */
+ function reallyDoQuery( $offset, $limit, $descending ) {
+ $data = [ parent::reallyDoQuery( $offset, $limit, $descending ) ];
+
+ // This hook will allow extensions to add in additional queries, nearly
+ // identical to ContribsPager::reallyDoQuery.
+ Hooks::run(
+ 'DeletedContribsPager::reallyDoQuery',
+ [ &$data, $this, $offset, $limit, $descending ]
+ );
+
+ $result = [];
+
+ // loop all results and collect them in an array
+ foreach ( $data as $query ) {
+ foreach ( $query as $i => $row ) {
+ // use index column as key, allowing us to easily sort in PHP
+ $result[$row->{$this->getIndexField()} . "-$i"] = $row;
+ }
+ }
+
+ // sort results
+ if ( $descending ) {
+ ksort( $result );
+ } else {
+ krsort( $result );
+ }
+
+ // enforce limit
+ $result = array_slice( $result, 0, $limit );
+
+ // get rid of array keys
+ $result = array_values( $result );
+
+ return new FakeResultWrapper( $result );
+ }
+
+ function getUserCond() {
+ $condition = [];
+
+ $condition['ar_user_text'] = $this->target;
+ $index = 'ar_usertext_timestamp';
+
+ return [ $index, $condition ];
+ }
+
+ function getIndexField() {
+ return 'ar_timestamp';
+ }
+
+ function getStartBody() {
+ return "<ul>\n";
+ }
+
+ function getEndBody() {
+ return "</ul>\n";
+ }
+
+ function getNavigationBar() {
+ if ( isset( $this->mNavigationBar ) ) {
+ return $this->mNavigationBar;
+ }
+
+ $linkTexts = [
+ 'prev' => $this->msg( 'pager-newer-n' )->numParams( $this->mLimit )->escaped(),
+ 'next' => $this->msg( 'pager-older-n' )->numParams( $this->mLimit )->escaped(),
+ 'first' => $this->msg( 'histlast' )->escaped(),
+ 'last' => $this->msg( 'histfirst' )->escaped()
+ ];
+
+ $pagingLinks = $this->getPagingLinks( $linkTexts );
+ $limitLinks = $this->getLimitLinks();
+ $lang = $this->getLanguage();
+ $limits = $lang->pipeList( $limitLinks );
+
+ $firstLast = $lang->pipeList( [ $pagingLinks['first'], $pagingLinks['last'] ] );
+ $firstLast = $this->msg( 'parentheses' )->rawParams( $firstLast )->escaped();
+ $prevNext = $this->msg( 'viewprevnext' )
+ ->rawParams(
+ $pagingLinks['prev'],
+ $pagingLinks['next'],
+ $limits
+ )->escaped();
+ $separator = $this->msg( 'word-separator' )->escaped();
+ $this->mNavigationBar = $firstLast . $separator . $prevNext;
+
+ return $this->mNavigationBar;
+ }
+
+ function getNamespaceCond() {
+ if ( $this->namespace !== '' ) {
+ return [ 'ar_namespace' => (int)$this->namespace ];
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * Generates each row in the contributions list.
+ *
+ * @todo This would probably look a lot nicer in a table.
+ * @param stdClass $row
+ * @return string
+ */
+ function formatRow( $row ) {
+ $ret = '';
+ $classes = [];
+ $attribs = [];
+
+ /*
+ * There may be more than just revision rows. To make sure that we'll only be processing
+ * revisions here, let's _try_ to build a revision out of our row (without displaying
+ * notices though) and then trying to grab data from the built object. If we succeed,
+ * we're definitely dealing with revision data and we may proceed, if not, we'll leave it
+ * to extensions to subscribe to the hook to parse the row.
+ */
+ MediaWiki\suppressWarnings();
+ try {
+ $rev = Revision::newFromArchiveRow( $row );
+ $validRevision = (bool)$rev->getId();
+ } catch ( Exception $e ) {
+ $validRevision = false;
+ }
+ MediaWiki\restoreWarnings();
+
+ if ( $validRevision ) {
+ $attribs['data-mw-revid'] = $rev->getId();
+ $ret = $this->formatRevisionRow( $row );
+ }
+
+ // Let extensions add data
+ Hooks::run( 'DeletedContributionsLineEnding', [ $this, &$ret, $row, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+
+ if ( $classes === [] && $attribs === [] && $ret === '' ) {
+ wfDebug( "Dropping Special:DeletedContribution row that could not be formatted\n" );
+ $ret = "<!-- Could not format Special:DeletedContribution row. -->\n";
+ } else {
+ $attribs['class'] = $classes;
+ $ret = Html::rawElement( 'li', $attribs, $ret ) . "\n";
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Generates each row in the contributions list for archive entries.
+ *
+ * Contributions which are marked "top" are currently on top of the history.
+ * For these contributions, a [rollback] link is shown for users with sysop
+ * privileges. The rollback link restores the most recent version that was not
+ * written by the target user.
+ *
+ * @todo This would probably look a lot nicer in a table.
+ * @param stdClass $row
+ * @return string
+ */
+ function formatRevisionRow( $row ) {
+ $page = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+
+ $rev = new Revision( [
+ 'title' => $page,
+ 'id' => $row->ar_rev_id,
+ 'comment' => CommentStore::newKey( 'ar_comment' )->getComment( $row )->text,
+ 'user' => $row->ar_user,
+ 'user_text' => $row->ar_user_text,
+ 'timestamp' => $row->ar_timestamp,
+ 'minor_edit' => $row->ar_minor_edit,
+ 'deleted' => $row->ar_deleted,
+ ] );
+
+ $undelete = SpecialPage::getTitleFor( 'Undelete' );
+
+ $logs = SpecialPage::getTitleFor( 'Log' );
+ $dellog = $linkRenderer->makeKnownLink(
+ $logs,
+ $this->messages['deletionlog'],
+ [],
+ [
+ 'type' => 'delete',
+ 'page' => $page->getPrefixedText()
+ ]
+ );
+
+ $reviewlink = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Undelete', $page->getPrefixedDBkey() ),
+ $this->messages['undeleteviewlink']
+ );
+
+ $user = $this->getUser();
+
+ if ( $user->isAllowed( 'deletedtext' ) ) {
+ $last = $linkRenderer->makeKnownLink(
+ $undelete,
+ $this->messages['diff'],
+ [],
+ [
+ 'target' => $page->getPrefixedText(),
+ 'timestamp' => $rev->getTimestamp(),
+ 'diff' => 'prev'
+ ]
+ );
+ } else {
+ $last = htmlspecialchars( $this->messages['diff'] );
+ }
+
+ $comment = Linker::revComment( $rev );
+ $date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $user );
+
+ if ( !$user->isAllowed( 'undelete' ) || !$rev->userCan( Revision::DELETED_TEXT, $user ) ) {
+ $link = htmlspecialchars( $date ); // unusable link
+ } else {
+ $link = $linkRenderer->makeKnownLink(
+ $undelete,
+ $date,
+ [ 'class' => 'mw-changeslist-date' ],
+ [
+ 'target' => $page->getPrefixedText(),
+ 'timestamp' => $rev->getTimestamp()
+ ]
+ );
+ }
+ // Style deleted items
+ if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+ $link = '<span class="history-deleted">' . $link . '</span>';
+ }
+
+ $pagelink = $linkRenderer->makeLink(
+ $page,
+ null,
+ [ 'class' => 'mw-changeslist-title' ]
+ );
+
+ if ( $rev->isMinor() ) {
+ $mflag = ChangesList::flag( 'minor' );
+ } else {
+ $mflag = '';
+ }
+
+ // Revision delete link
+ $del = Linker::getRevDeleteLink( $user, $rev, $page );
+ if ( $del ) {
+ $del .= ' ';
+ }
+
+ $tools = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-deletedcontribs-tools' ],
+ $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
+ [ $last, $dellog, $reviewlink ] ) )->escaped()
+ );
+
+ $separator = '<span class="mw-changeslist-separator">. .</span>';
+ $ret = "{$del}{$link} {$tools} {$separator} {$mflag} {$pagelink} {$comment}";
+
+ # Denote if username is redacted for this edit
+ if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
+ $ret .= " <strong>" . $this->msg( 'rev-deleted-user-contribs' )->escaped() . "</strong>";
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Get the Database object in use
+ *
+ * @return IDatabase
+ */
+ public function getDatabase() {
+ return $this->mDb;
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/ImageListPager.php b/www/wiki/includes/specials/pagers/ImageListPager.php
new file mode 100644
index 00000000..008573be
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/ImageListPager.php
@@ -0,0 +1,617 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * @ingroup Pager
+ */
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+class ImageListPager extends TablePager {
+
+ protected $mFieldNames = null;
+
+ // Subclasses should override buildQueryConds instead of using $mQueryConds variable.
+ protected $mQueryConds = [];
+
+ protected $mUserName = null;
+
+ /**
+ * The relevant user
+ *
+ * @var User|null
+ */
+ protected $mUser = null;
+
+ protected $mSearch = '';
+
+ protected $mIncluding = false;
+
+ protected $mShowAll = false;
+
+ protected $mTableName = 'image';
+
+ function __construct( IContextSource $context, $userName = null, $search = '',
+ $including = false, $showAll = false
+ ) {
+ $this->setContext( $context );
+ $this->mIncluding = $including;
+ $this->mShowAll = $showAll;
+
+ if ( $userName !== null && $userName !== '' ) {
+ $nt = Title::makeTitleSafe( NS_USER, $userName );
+ if ( is_null( $nt ) ) {
+ $this->outputUserDoesNotExist( $userName );
+ } else {
+ $this->mUserName = $nt->getText();
+ $user = User::newFromName( $this->mUserName, false );
+ if ( $user ) {
+ $this->mUser = $user;
+ }
+ if ( !$user || ( $user->isAnon() && !User::isIP( $user->getName() ) ) ) {
+ $this->outputUserDoesNotExist( $userName );
+ }
+ }
+ }
+
+ if ( $search !== '' && !$this->getConfig()->get( 'MiserMode' ) ) {
+ $this->mSearch = $search;
+ $nt = Title::newFromText( $this->mSearch );
+
+ if ( $nt ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $this->mQueryConds[] = 'LOWER(img_name)' .
+ $dbr->buildLike( $dbr->anyString(),
+ strtolower( $nt->getDBkey() ), $dbr->anyString() );
+ }
+ }
+
+ if ( !$including ) {
+ if ( $this->getRequest()->getText( 'sort', 'img_date' ) == 'img_date' ) {
+ $this->mDefaultDirection = IndexPager::DIR_DESCENDING;
+ } else {
+ $this->mDefaultDirection = IndexPager::DIR_ASCENDING;
+ }
+ } else {
+ $this->mDefaultDirection = IndexPager::DIR_DESCENDING;
+ }
+
+ parent::__construct( $context );
+ }
+
+ /**
+ * Get the user relevant to the ImageList
+ *
+ * @return User|null
+ */
+ function getRelevantUser() {
+ return $this->mUser;
+ }
+
+ /**
+ * Add a message to the output stating that the user doesn't exist
+ *
+ * @param string $userName Unescaped user name
+ */
+ protected function outputUserDoesNotExist( $userName ) {
+ $this->getOutput()->wrapWikiMsg(
+ "<div class=\"mw-userpage-userdoesnotexist error\">\n$1\n</div>",
+ [
+ 'listfiles-userdoesnotexist',
+ wfEscapeWikiText( $userName ),
+ ]
+ );
+ }
+
+ /**
+ * Build the where clause of the query.
+ *
+ * Replaces the older mQueryConds member variable.
+ * @param string $table Either "image" or "oldimage"
+ * @return array The query conditions.
+ */
+ protected function buildQueryConds( $table ) {
+ $prefix = $table === 'image' ? 'img' : 'oi';
+ $conds = [];
+
+ if ( !is_null( $this->mUserName ) ) {
+ $conds[$prefix . '_user_text'] = $this->mUserName;
+ }
+
+ if ( $this->mSearch !== '' ) {
+ $nt = Title::newFromText( $this->mSearch );
+ if ( $nt ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $conds[] = 'LOWER(' . $prefix . '_name)' .
+ $dbr->buildLike( $dbr->anyString(),
+ strtolower( $nt->getDBkey() ), $dbr->anyString() );
+ }
+ }
+
+ if ( $table === 'oldimage' ) {
+ // Don't want to deal with revdel.
+ // Future fixme: Show partial information as appropriate.
+ // Would have to be careful about filtering by username when username is deleted.
+ $conds['oi_deleted'] = 0;
+ }
+
+ // Add mQueryConds in case anyone was subclassing and using the old variable.
+ return $conds + $this->mQueryConds;
+ }
+
+ /**
+ * @return array
+ */
+ function getFieldNames() {
+ if ( !$this->mFieldNames ) {
+ $this->mFieldNames = [
+ 'img_timestamp' => $this->msg( 'listfiles_date' )->text(),
+ 'img_name' => $this->msg( 'listfiles_name' )->text(),
+ 'thumb' => $this->msg( 'listfiles_thumb' )->text(),
+ 'img_size' => $this->msg( 'listfiles_size' )->text(),
+ ];
+ if ( is_null( $this->mUserName ) ) {
+ // Do not show username if filtering by username
+ $this->mFieldNames['img_user_text'] = $this->msg( 'listfiles_user' )->text();
+ }
+ // img_description down here, in order so that its still after the username field.
+ $this->mFieldNames['img_description'] = $this->msg( 'listfiles_description' )->text();
+
+ if ( !$this->getConfig()->get( 'MiserMode' ) && !$this->mShowAll ) {
+ $this->mFieldNames['count'] = $this->msg( 'listfiles_count' )->text();
+ }
+ if ( $this->mShowAll ) {
+ $this->mFieldNames['top'] = $this->msg( 'listfiles-latestversion' )->text();
+ }
+ }
+
+ return $this->mFieldNames;
+ }
+
+ function isFieldSortable( $field ) {
+ if ( $this->mIncluding ) {
+ return false;
+ }
+ $sortable = [ 'img_timestamp', 'img_name', 'img_size' ];
+ /* For reference, the indicies we can use for sorting are:
+ * On the image table: img_user_timestamp, img_usertext_timestamp,
+ * img_size, img_timestamp
+ * On oldimage: oi_usertext_timestamp, oi_name_timestamp
+ *
+ * In particular that means we cannot sort by timestamp when not filtering
+ * by user and including old images in the results. Which is sad.
+ */
+ if ( $this->getConfig()->get( 'MiserMode' ) && !is_null( $this->mUserName ) ) {
+ // If we're sorting by user, the index only supports sorting by time.
+ if ( $field === 'img_timestamp' ) {
+ return true;
+ } else {
+ return false;
+ }
+ } elseif ( $this->getConfig()->get( 'MiserMode' )
+ && $this->mShowAll /* && mUserName === null */
+ ) {
+ // no oi_timestamp index, so only alphabetical sorting in this case.
+ if ( $field === 'img_name' ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ return in_array( $field, $sortable );
+ }
+
+ function getQueryInfo() {
+ // Hacky Hacky Hacky - I want to get query info
+ // for two different tables, without reimplementing
+ // the pager class.
+ $qi = $this->getQueryInfoReal( $this->mTableName );
+
+ return $qi;
+ }
+
+ /**
+ * Actually get the query info.
+ *
+ * This is to allow displaying both stuff from image and oldimage table.
+ *
+ * This is a bit hacky.
+ *
+ * @param string $table Either 'image' or 'oldimage'
+ * @return array Query info
+ */
+ protected function getQueryInfoReal( $table ) {
+ $prefix = $table === 'oldimage' ? 'oi' : 'img';
+
+ $tables = [ $table ];
+ $fields = $this->getFieldNames();
+ unset( $fields['img_description'] );
+ $fields = array_keys( $fields );
+
+ if ( $table === 'oldimage' ) {
+ foreach ( $fields as $id => &$field ) {
+ if ( substr( $field, 0, 4 ) !== 'img_' ) {
+ continue;
+ }
+ $field = $prefix . substr( $field, 3 ) . ' AS ' . $field;
+ }
+ $fields[array_search( 'top', $fields )] = "'no' AS top";
+ } else {
+ if ( $this->mShowAll ) {
+ $fields[array_search( 'top', $fields )] = "'yes' AS top";
+ }
+ }
+ $fields[] = $prefix . '_user AS img_user';
+ $fields[array_search( 'thumb', $fields )] = $prefix . '_name AS thumb';
+
+ $options = $join_conds = [];
+
+ # Description field
+ $commentQuery = CommentStore::newKey( $prefix . '_description' )->getJoin();
+ $tables += $commentQuery['tables'];
+ $fields += $commentQuery['fields'];
+ $join_conds += $commentQuery['joins'];
+ $fields['description_field'] = "'{$prefix}_description'";
+
+ # Depends on $wgMiserMode
+ # Will also not happen if mShowAll is true.
+ if ( isset( $this->mFieldNames['count'] ) ) {
+ $tables[] = 'oldimage';
+
+ # Need to rewrite this one
+ foreach ( $fields as &$field ) {
+ if ( $field == 'count' ) {
+ $field = 'COUNT(oi_archive_name) AS count';
+ }
+ }
+ unset( $field );
+
+ $columnlist = preg_grep( '/^img/', array_keys( $this->getFieldNames() ) );
+ $options = [ 'GROUP BY' => array_merge( [ 'img_user' ], $columnlist ) ];
+ $join_conds['oldimage'] = [ 'LEFT JOIN', 'oi_name = img_name' ];
+ }
+
+ return [
+ 'tables' => $tables,
+ 'fields' => $fields,
+ 'conds' => $this->buildQueryConds( $table ),
+ 'options' => $options,
+ 'join_conds' => $join_conds
+ ];
+ }
+
+ /**
+ * Override reallyDoQuery to mix together two queries.
+ *
+ * @note $asc is named $descending in IndexPager base class. However
+ * it is true when the order is ascending, and false when the order
+ * is descending, so I renamed it to $asc here.
+ * @param int $offset
+ * @param int $limit
+ * @param bool $asc
+ * @return array
+ * @throws MWException
+ */
+ function reallyDoQuery( $offset, $limit, $asc ) {
+ $prevTableName = $this->mTableName;
+ $this->mTableName = 'image';
+ list( $tables, $fields, $conds, $fname, $options, $join_conds ) =
+ $this->buildQueryInfo( $offset, $limit, $asc );
+ $imageRes = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
+ $this->mTableName = $prevTableName;
+
+ if ( !$this->mShowAll ) {
+ return $imageRes;
+ }
+
+ $this->mTableName = 'oldimage';
+
+ # Hacky...
+ $oldIndex = $this->mIndexField;
+ if ( substr( $this->mIndexField, 0, 4 ) !== 'img_' ) {
+ throw new MWException( "Expected to be sorting on an image table field" );
+ }
+ $this->mIndexField = 'oi_' . substr( $this->mIndexField, 4 );
+
+ list( $tables, $fields, $conds, $fname, $options, $join_conds ) =
+ $this->buildQueryInfo( $offset, $limit, $asc );
+ $oldimageRes = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
+
+ $this->mTableName = $prevTableName;
+ $this->mIndexField = $oldIndex;
+
+ return $this->combineResult( $imageRes, $oldimageRes, $limit, $asc );
+ }
+
+ /**
+ * Combine results from 2 tables.
+ *
+ * Note: This will throw away some results
+ *
+ * @param ResultWrapper $res1
+ * @param ResultWrapper $res2
+ * @param int $limit
+ * @param bool $ascending See note about $asc in $this->reallyDoQuery
+ * @return FakeResultWrapper $res1 and $res2 combined
+ */
+ protected function combineResult( $res1, $res2, $limit, $ascending ) {
+ $res1->rewind();
+ $res2->rewind();
+ $topRes1 = $res1->next();
+ $topRes2 = $res2->next();
+ $resultArray = [];
+ for ( $i = 0; $i < $limit && $topRes1 && $topRes2; $i++ ) {
+ if ( strcmp( $topRes1->{$this->mIndexField}, $topRes2->{$this->mIndexField} ) > 0 ) {
+ if ( !$ascending ) {
+ $resultArray[] = $topRes1;
+ $topRes1 = $res1->next();
+ } else {
+ $resultArray[] = $topRes2;
+ $topRes2 = $res2->next();
+ }
+ } else {
+ if ( !$ascending ) {
+ $resultArray[] = $topRes2;
+ $topRes2 = $res2->next();
+ } else {
+ $resultArray[] = $topRes1;
+ $topRes1 = $res1->next();
+ }
+ }
+ }
+
+ // @codingStandardsIgnoreStart Squiz.WhiteSpace.SemicolonSpacing.Incorrect
+ for ( ; $i < $limit && $topRes1; $i++ ) {
+ // @codingStandardsIgnoreEnd
+ $resultArray[] = $topRes1;
+ $topRes1 = $res1->next();
+ }
+
+ // @codingStandardsIgnoreStart Squiz.WhiteSpace.SemicolonSpacing.Incorrect
+ for ( ; $i < $limit && $topRes2; $i++ ) {
+ // @codingStandardsIgnoreEnd
+ $resultArray[] = $topRes2;
+ $topRes2 = $res2->next();
+ }
+
+ return new FakeResultWrapper( $resultArray );
+ }
+
+ function getDefaultSort() {
+ if ( $this->mShowAll && $this->getConfig()->get( 'MiserMode' ) && is_null( $this->mUserName ) ) {
+ // Unfortunately no index on oi_timestamp.
+ return 'img_name';
+ } else {
+ return 'img_timestamp';
+ }
+ }
+
+ function doBatchLookups() {
+ $userIds = [];
+ $this->mResult->seek( 0 );
+ foreach ( $this->mResult as $row ) {
+ $userIds[] = $row->img_user;
+ }
+ # Do a link batch query for names and userpages
+ UserCache::singleton()->doQuery( $userIds, [ 'userpage' ], __METHOD__ );
+ }
+
+ /**
+ * @param string $field
+ * @param string $value
+ * @return Message|string|int The return type depends on the value of $field:
+ * - thumb: string
+ * - img_timestamp: string
+ * - img_name: string
+ * - img_user_text: string
+ * - img_size: string
+ * - img_description: string
+ * - count: int
+ * - top: Message
+ * @throws MWException
+ */
+ function formatValue( $field, $value ) {
+ $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ switch ( $field ) {
+ case 'thumb':
+ $opt = [ 'time' => wfTimestamp( TS_MW, $this->mCurrentRow->img_timestamp ) ];
+ $file = RepoGroup::singleton()->getLocalRepo()->findFile( $value, $opt );
+ // If statement for paranoia
+ if ( $file ) {
+ $thumb = $file->transform( [ 'width' => 180, 'height' => 360 ] );
+ if ( $thumb ) {
+ return $thumb->toHtml( [ 'desc-link' => true ] );
+ } else {
+ return wfMessage( 'thumbnail_error', '' )->escaped();
+ }
+ } else {
+ return htmlspecialchars( $value );
+ }
+ case 'img_timestamp':
+ // We may want to make this a link to the "old" version when displaying old files
+ return htmlspecialchars( $this->getLanguage()->userTimeAndDate( $value, $this->getUser() ) );
+ case 'img_name':
+ static $imgfile = null;
+ if ( $imgfile === null ) {
+ $imgfile = $this->msg( 'imgfile' )->text();
+ }
+
+ // Weird files can maybe exist? T24227
+ $filePage = Title::makeTitleSafe( NS_FILE, $value );
+ if ( $filePage ) {
+ $link = $linkRenderer->makeKnownLink(
+ $filePage,
+ $filePage->getText()
+ );
+ $download = Xml::element( 'a',
+ [ 'href' => wfLocalFile( $filePage )->getUrl() ],
+ $imgfile
+ );
+ $download = $this->msg( 'parentheses' )->rawParams( $download )->escaped();
+
+ // Add delete links if allowed
+ // From https://github.com/Wikia/app/pull/3859
+ if ( $filePage->userCan( 'delete', $this->getUser() ) ) {
+ $deleteMsg = $this->msg( 'listfiles-delete' )->text();
+
+ $delete = $linkRenderer->makeKnownLink(
+ $filePage, $deleteMsg, [], [ 'action' => 'delete' ]
+ );
+ $delete = $this->msg( 'parentheses' )->rawParams( $delete )->escaped();
+
+ return "$link $download $delete";
+ }
+
+ return "$link $download";
+ } else {
+ return htmlspecialchars( $value );
+ }
+ case 'img_user_text':
+ if ( $this->mCurrentRow->img_user ) {
+ $name = User::whoIs( $this->mCurrentRow->img_user );
+ $link = $linkRenderer->makeLink(
+ Title::makeTitle( NS_USER, $name ),
+ $name
+ );
+ } else {
+ $link = htmlspecialchars( $value );
+ }
+
+ return $link;
+ case 'img_size':
+ return htmlspecialchars( $this->getLanguage()->formatSize( $value ) );
+ case 'img_description':
+ $field = $this->mCurrentRow->description_field;
+ $value = CommentStore::newKey( $field )->getComment( $this->mCurrentRow )->text;
+ return Linker::formatComment( $value );
+ case 'count':
+ return $this->getLanguage()->formatNum( intval( $value ) + 1 );
+ case 'top':
+ // Messages: listfiles-latestversion-yes, listfiles-latestversion-no
+ return $this->msg( 'listfiles-latestversion-' . $value );
+ default:
+ throw new MWException( "Unknown field '$field'" );
+ }
+ }
+
+ function getForm() {
+ $fields = [];
+ $fields['limit'] = [
+ 'type' => 'select',
+ 'name' => 'limit',
+ 'label-message' => 'table_pager_limit_label',
+ 'options' => $this->getLimitSelectList(),
+ 'default' => $this->mLimit,
+ ];
+
+ if ( !$this->getConfig()->get( 'MiserMode' ) ) {
+ $fields['ilsearch'] = [
+ 'type' => 'text',
+ 'name' => 'ilsearch',
+ 'id' => 'mw-ilsearch',
+ 'label-message' => 'listfiles_search_for',
+ 'default' => $this->mSearch,
+ 'size' => '40',
+ 'maxlength' => '255',
+ ];
+ }
+
+ $this->getOutput()->addModules( 'mediawiki.userSuggest' );
+ $fields['user'] = [
+ 'type' => 'text',
+ 'name' => 'user',
+ 'id' => 'mw-listfiles-user',
+ 'label-message' => 'username',
+ 'default' => $this->mUserName,
+ 'size' => '40',
+ 'maxlength' => '255',
+ 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
+ ];
+
+ $fields['ilshowall'] = [
+ 'type' => 'check',
+ 'name' => 'ilshowall',
+ 'id' => 'mw-listfiles-show-all',
+ 'label-message' => 'listfiles-show-all',
+ 'default' => $this->mShowAll,
+ ];
+
+ $query = $this->getRequest()->getQueryValues();
+ unset( $query['title'] );
+ unset( $query['limit'] );
+ unset( $query['ilsearch'] );
+ unset( $query['ilshowall'] );
+ unset( $query['user'] );
+
+ $form = new HTMLForm( $fields, $this->getContext() );
+
+ $form->setMethod( 'get' );
+ $form->setTitle( $this->getTitle() );
+ $form->setId( 'mw-listfiles-form' );
+ $form->setWrapperLegendMsg( 'listfiles' );
+ $form->setSubmitTextMsg( 'table_pager_limit_submit' );
+ $form->addHiddenFields( $query );
+
+ $form->prepareForm();
+ $form->displayForm( '' );
+ }
+
+ protected function getTableClass() {
+ return parent::getTableClass() . ' listfiles';
+ }
+
+ protected function getNavClass() {
+ return parent::getNavClass() . ' listfiles_nav';
+ }
+
+ protected function getSortHeaderClass() {
+ return parent::getSortHeaderClass() . ' listfiles_sort';
+ }
+
+ function getPagingQueries() {
+ $queries = parent::getPagingQueries();
+ if ( !is_null( $this->mUserName ) ) {
+ # Append the username to the query string
+ foreach ( $queries as &$query ) {
+ if ( $query !== false ) {
+ $query['user'] = $this->mUserName;
+ }
+ }
+ }
+
+ return $queries;
+ }
+
+ function getDefaultQuery() {
+ $queries = parent::getDefaultQuery();
+ if ( !isset( $queries['user'] ) && !is_null( $this->mUserName ) ) {
+ $queries['user'] = $this->mUserName;
+ }
+
+ return $queries;
+ }
+
+ function getTitle() {
+ return SpecialPage::getTitleFor( 'Listfiles' );
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/MergeHistoryPager.php b/www/wiki/includes/specials/pagers/MergeHistoryPager.php
new file mode 100644
index 00000000..bbf97e13
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/MergeHistoryPager.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * @ingroup Pager
+ */
+class MergeHistoryPager extends ReverseChronologicalPager {
+
+ /** @var SpecialMergeHistory */
+ public $mForm;
+
+ /** @var array */
+ public $mConds;
+
+ function __construct( SpecialMergeHistory $form, $conds, Title $source, Title $dest ) {
+ $this->mForm = $form;
+ $this->mConds = $conds;
+ $this->title = $source;
+ $this->articleID = $source->getArticleID();
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $maxtimestamp = $dbr->selectField(
+ 'revision',
+ 'MIN(rev_timestamp)',
+ [ 'rev_page' => $dest->getArticleID() ],
+ __METHOD__
+ );
+ $this->maxTimestamp = $maxtimestamp;
+
+ parent::__construct( $form->getContext() );
+ }
+
+ function getStartBody() {
+ # Do a link batch query
+ $this->mResult->seek( 0 );
+ $batch = new LinkBatch();
+ # Give some pointers to make (last) links
+ $this->mForm->prevId = [];
+ $rev_id = null;
+ foreach ( $this->mResult as $row ) {
+ $batch->addObj( Title::makeTitleSafe( NS_USER, $row->user_name ) );
+ $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->user_name ) );
+
+ if ( isset( $rev_id ) ) {
+ if ( $rev_id > $row->rev_id ) {
+ $this->mForm->prevId[$rev_id] = $row->rev_id;
+ } elseif ( $rev_id < $row->rev_id ) {
+ $this->mForm->prevId[$row->rev_id] = $rev_id;
+ }
+ }
+
+ $rev_id = $row->rev_id;
+ }
+
+ $batch->execute();
+ $this->mResult->seek( 0 );
+
+ return '';
+ }
+
+ function formatRow( $row ) {
+ return $this->mForm->formatRevisionRow( $row );
+ }
+
+ function getQueryInfo() {
+ $conds = $this->mConds;
+ $conds['rev_page'] = $this->articleID;
+ $conds[] = "rev_timestamp < " . $this->mDb->addQuotes( $this->maxTimestamp );
+
+ return [
+ 'tables' => [ 'revision', 'page', 'user' ],
+ 'fields' => array_merge( Revision::selectFields(), Revision::selectUserFields() ),
+ 'conds' => $conds,
+ 'join_conds' => [
+ 'page' => Revision::pageJoinCond(),
+ 'user' => Revision::userJoinCond() ]
+ ];
+ }
+
+ function getIndexField() {
+ return 'rev_timestamp';
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/NewFilesPager.php b/www/wiki/includes/specials/pagers/NewFilesPager.php
new file mode 100644
index 00000000..001c296d
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/NewFilesPager.php
@@ -0,0 +1,199 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * @ingroup Pager
+ */
+use MediaWiki\MediaWikiServices;
+
+class NewFilesPager extends RangeChronologicalPager {
+
+ /**
+ * @var ImageGalleryBase
+ */
+ protected $gallery;
+
+ /**
+ * @var FormOptions
+ */
+ protected $opts;
+
+ /**
+ * @param IContextSource $context
+ * @param FormOptions $opts
+ */
+ function __construct( IContextSource $context, FormOptions $opts ) {
+ parent::__construct( $context );
+
+ $this->opts = $opts;
+ $this->setLimit( $opts->getValue( 'limit' ) );
+
+ $startTimestamp = '';
+ $endTimestamp = '';
+ if ( $opts->getValue( 'start' ) ) {
+ $startTimestamp = $opts->getValue( 'start' ) . ' 00:00:00';
+ }
+ if ( $opts->getValue( 'end' ) ) {
+ $endTimestamp = $opts->getValue( 'end' ) . ' 23:59:59';
+ }
+ $this->getDateRangeCond( $startTimestamp, $endTimestamp );
+ }
+
+ function getQueryInfo() {
+ $opts = $this->opts;
+ $conds = $jconds = [];
+ $tables = [ 'image' ];
+ $fields = [ 'img_name', 'img_user', 'img_timestamp' ];
+ $options = [];
+
+ $user = $opts->getValue( 'user' );
+ if ( $user !== '' ) {
+ $userId = User::idFromName( $user );
+ if ( $userId ) {
+ $conds['img_user'] = $userId;
+ } else {
+ $conds['img_user_text'] = $user;
+ }
+ }
+
+ if ( $opts->getValue( 'newbies' ) ) {
+ // newbie = most recent 1% of users
+ $dbr = wfGetDB( DB_REPLICA );
+ $max = $dbr->selectField( 'user', 'max(user_id)', false, __METHOD__ );
+ $conds[] = 'img_user >' . (int)( $max - $max / 100 );
+
+ // there's no point in looking for new user activity in a far past;
+ // beyond a certain point, we'd just end up scanning the rest of the
+ // table even though the users we're looking for didn't yet exist...
+ // see T140537, (for ContribsPages, but similar to this)
+ $conds[] = 'img_timestamp > ' .
+ $dbr->addQuotes( $dbr->timestamp( wfTimestamp() - 30 * 24 * 60 * 60 ) );
+ }
+
+ if ( !$opts->getValue( 'showbots' ) ) {
+ $groupsWithBotPermission = User::getGroupsWithPermission( 'bot' );
+
+ if ( count( $groupsWithBotPermission ) ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $tables[] = 'user_groups';
+ $conds[] = 'ug_group IS NULL';
+ $jconds['user_groups'] = [
+ 'LEFT JOIN',
+ [
+ 'ug_group' => $groupsWithBotPermission,
+ 'ug_user = img_user',
+ 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
+ ]
+ ];
+ }
+ }
+
+ if ( $opts->getValue( 'hidepatrolled' ) ) {
+ $tables[] = 'recentchanges';
+ $conds['rc_type'] = RC_LOG;
+ $conds['rc_log_type'] = 'upload';
+ $conds['rc_patrolled'] = 0;
+ $conds['rc_namespace'] = NS_FILE;
+ $jconds['recentchanges'] = [
+ 'INNER JOIN',
+ [
+ 'rc_title = img_name',
+ 'rc_user = img_user',
+ 'rc_timestamp = img_timestamp'
+ ]
+ ];
+ // We're ordering by img_timestamp, so we have to make sure MariaDB queries `image` first.
+ // It sometimes decides to query `recentchanges` first and filesort the result set later
+ // to get the right ordering. T124205 / https://mariadb.atlassian.net/browse/MDEV-8880
+ $options[] = 'STRAIGHT_JOIN';
+ }
+
+ if ( $opts->getValue( 'mediatype' ) ) {
+ $conds['img_media_type'] = $opts->getValue( 'mediatype' );
+ }
+
+ $likeVal = $opts->getValue( 'like' );
+ if ( !$this->getConfig()->get( 'MiserMode' ) && $likeVal !== '' ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $likeObj = Title::newFromText( $likeVal );
+ if ( $likeObj instanceof Title ) {
+ $like = $dbr->buildLike(
+ $dbr->anyString(),
+ strtolower( $likeObj->getDBkey() ),
+ $dbr->anyString()
+ );
+ $conds[] = "LOWER(img_name) $like";
+ }
+ }
+
+ $query = [
+ 'tables' => $tables,
+ 'fields' => $fields,
+ 'join_conds' => $jconds,
+ 'conds' => $conds,
+ 'options' => $options,
+ ];
+
+ return $query;
+ }
+
+ function getIndexField() {
+ return 'img_timestamp';
+ }
+
+ function getStartBody() {
+ if ( !$this->gallery ) {
+ // Note that null for mode is taken to mean use default.
+ $mode = $this->getRequest()->getVal( 'gallerymode', null );
+ try {
+ $this->gallery = ImageGalleryBase::factory( $mode, $this->getContext() );
+ } catch ( Exception $e ) {
+ // User specified something invalid, fallback to default.
+ $this->gallery = ImageGalleryBase::factory( false, $this->getContext() );
+ }
+ }
+
+ return '';
+ }
+
+ function getEndBody() {
+ return $this->gallery->toHTML();
+ }
+
+ function formatRow( $row ) {
+ $name = $row->img_name;
+ $user = User::newFromId( $row->img_user );
+
+ $title = Title::makeTitle( NS_FILE, $name );
+ $ul = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
+ $user->getUserPage(),
+ $user->getName()
+ );
+ $time = $this->getLanguage()->userTimeAndDate( $row->img_timestamp, $this->getUser() );
+
+ $this->gallery->add(
+ $title,
+ "$ul<br />\n<i>"
+ . htmlspecialchars( $time )
+ . "</i><br />\n"
+ );
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/NewPagesPager.php b/www/wiki/includes/specials/pagers/NewPagesPager.php
new file mode 100644
index 00000000..53362d9c
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/NewPagesPager.php
@@ -0,0 +1,161 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * @ingroup Pager
+ */
+class NewPagesPager extends ReverseChronologicalPager {
+
+ // Stored opts
+ protected $opts;
+
+ /**
+ * @var HtmlForm
+ */
+ protected $mForm;
+
+ function __construct( $form, FormOptions $opts ) {
+ parent::__construct( $form->getContext() );
+ $this->mForm = $form;
+ $this->opts = $opts;
+ }
+
+ function getQueryInfo() {
+ $conds = [];
+ $conds['rc_new'] = 1;
+
+ $namespace = $this->opts->getValue( 'namespace' );
+ $namespace = ( $namespace === 'all' ) ? false : intval( $namespace );
+
+ $username = $this->opts->getValue( 'username' );
+ $user = Title::makeTitleSafe( NS_USER, $username );
+
+ $size = abs( intval( $this->opts->getValue( 'size' ) ) );
+ if ( $size > 0 ) {
+ if ( $this->opts->getValue( 'size-mode' ) === 'max' ) {
+ $conds[] = 'page_len <= ' . $size;
+ } else {
+ $conds[] = 'page_len >= ' . $size;
+ }
+ }
+
+ $rcIndexes = [];
+
+ if ( $namespace !== false ) {
+ if ( $this->opts->getValue( 'invert' ) ) {
+ $conds[] = 'rc_namespace != ' . $this->mDb->addQuotes( $namespace );
+ } else {
+ $conds['rc_namespace'] = $namespace;
+ }
+ }
+
+ if ( $user ) {
+ $conds['rc_user_text'] = $user->getText();
+ $rcIndexes = 'rc_user_text';
+ } elseif ( User::groupHasPermission( '*', 'createpage' ) &&
+ $this->opts->getValue( 'hideliu' )
+ ) {
+ # If anons cannot make new pages, don't "exclude logged in users"!
+ $conds['rc_user'] = 0;
+ }
+
+ # If this user cannot see patrolled edits or they are off, don't do dumb queries!
+ if ( $this->opts->getValue( 'hidepatrolled' ) && $this->getUser()->useNPPatrol() ) {
+ $conds['rc_patrolled'] = 0;
+ }
+
+ if ( $this->opts->getValue( 'hidebots' ) ) {
+ $conds['rc_bot'] = 0;
+ }
+
+ if ( $this->opts->getValue( 'hideredirs' ) ) {
+ $conds['page_is_redirect'] = 0;
+ }
+
+ $commentQuery = CommentStore::newKey( 'rc_comment' )->getJoin();
+
+ // Allow changes to the New Pages query
+ $tables = [ 'recentchanges', 'page' ] + $commentQuery['tables'];
+ $fields = [
+ 'rc_namespace', 'rc_title', 'rc_cur_id', 'rc_user', 'rc_user_text',
+ 'rc_timestamp', 'rc_patrolled', 'rc_id', 'rc_deleted',
+ 'length' => 'page_len', 'rev_id' => 'page_latest', 'rc_this_oldid',
+ 'page_namespace', 'page_title'
+ ] + $commentQuery['fields'];
+ $join_conds = [ 'page' => [ 'INNER JOIN', 'page_id=rc_cur_id' ] ] + $commentQuery['joins'];
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $pager = $this;
+ Hooks::run( 'SpecialNewpagesConditions',
+ [ &$pager, $this->opts, &$conds, &$tables, &$fields, &$join_conds ] );
+
+ $options = [];
+
+ if ( $rcIndexes ) {
+ $options = [ 'USE INDEX' => [ 'recentchanges' => $rcIndexes ] ];
+ }
+
+ $info = [
+ 'tables' => $tables,
+ 'fields' => $fields,
+ 'conds' => $conds,
+ 'options' => $options,
+ 'join_conds' => $join_conds
+ ];
+
+ // Modify query for tags
+ ChangeTags::modifyDisplayQuery(
+ $info['tables'],
+ $info['fields'],
+ $info['conds'],
+ $info['join_conds'],
+ $info['options'],
+ $this->opts['tagfilter']
+ );
+
+ return $info;
+ }
+
+ function getIndexField() {
+ return 'rc_timestamp';
+ }
+
+ function formatRow( $row ) {
+ return $this->mForm->formatRow( $row );
+ }
+
+ function getStartBody() {
+ # Do a batch existence check on pages
+ $linkBatch = new LinkBatch();
+ foreach ( $this->mResult as $row ) {
+ $linkBatch->add( NS_USER, $row->rc_user_text );
+ $linkBatch->add( NS_USER_TALK, $row->rc_user_text );
+ $linkBatch->add( $row->page_namespace, $row->page_title );
+ }
+ $linkBatch->execute();
+
+ return '<ul>';
+ }
+
+ function getEndBody() {
+ return '</ul>';
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/ProtectedPagesPager.php b/www/wiki/includes/specials/pagers/ProtectedPagesPager.php
new file mode 100644
index 00000000..1587abc0
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/ProtectedPagesPager.php
@@ -0,0 +1,337 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+use \MediaWiki\Linker\LinkRenderer;
+
+/**
+ * @todo document
+ */
+class ProtectedPagesPager extends TablePager {
+ public $mForm, $mConds;
+ private $type, $level, $namespace, $sizetype, $size, $indefonly, $cascadeonly, $noredirect;
+
+ /**
+ * @var LinkRenderer
+ */
+ private $linkRenderer;
+
+ /**
+ * @param SpecialProtectedpages $form
+ * @param array $conds
+ * @param string $type
+ * @param string $level
+ * @param int $namespace
+ * @param string $sizetype
+ * @param int $size
+ * @param bool $indefonly
+ * @param bool $cascadeonly
+ * @param bool $noredirect
+ * @param LinkRenderer $linkRenderer
+ */
+ function __construct( $form, $conds = [], $type, $level, $namespace,
+ $sizetype = '', $size = 0, $indefonly = false, $cascadeonly = false, $noredirect = false,
+ LinkRenderer $linkRenderer
+ ) {
+ $this->mForm = $form;
+ $this->mConds = $conds;
+ $this->type = ( $type ) ? $type : 'edit';
+ $this->level = $level;
+ $this->namespace = $namespace;
+ $this->sizetype = $sizetype;
+ $this->size = intval( $size );
+ $this->indefonly = (bool)$indefonly;
+ $this->cascadeonly = (bool)$cascadeonly;
+ $this->noredirect = (bool)$noredirect;
+ $this->linkRenderer = $linkRenderer;
+ parent::__construct( $form->getContext() );
+ }
+
+ function preprocessResults( $result ) {
+ # Do a link batch query
+ $lb = new LinkBatch;
+ $userids = [];
+
+ foreach ( $result as $row ) {
+ $lb->add( $row->page_namespace, $row->page_title );
+ // field is nullable, maybe null on old protections
+ if ( $row->log_user !== null ) {
+ $userids[] = $row->log_user;
+ }
+ }
+
+ // fill LinkBatch with user page and user talk
+ if ( count( $userids ) ) {
+ $userCache = UserCache::singleton();
+ $userCache->doQuery( $userids, [], __METHOD__ );
+ foreach ( $userids as $userid ) {
+ $name = $userCache->getProp( $userid, 'name' );
+ if ( $name !== false ) {
+ $lb->add( NS_USER, $name );
+ $lb->add( NS_USER_TALK, $name );
+ }
+ }
+ }
+
+ $lb->execute();
+ }
+
+ function getFieldNames() {
+ static $headers = null;
+
+ if ( $headers == [] ) {
+ $headers = [
+ 'log_timestamp' => 'protectedpages-timestamp',
+ 'pr_page' => 'protectedpages-page',
+ 'pr_expiry' => 'protectedpages-expiry',
+ 'log_user' => 'protectedpages-performer',
+ 'pr_params' => 'protectedpages-params',
+ 'log_comment' => 'protectedpages-reason',
+ ];
+ foreach ( $headers as $key => $val ) {
+ $headers[$key] = $this->msg( $val )->text();
+ }
+ }
+
+ return $headers;
+ }
+
+ /**
+ * @param string $field
+ * @param string $value
+ * @return string HTML
+ * @throws MWException
+ */
+ function formatValue( $field, $value ) {
+ /** @var object $row */
+ $row = $this->mCurrentRow;
+
+ switch ( $field ) {
+ case 'log_timestamp':
+ // when timestamp is null, this is a old protection row
+ if ( $value === null ) {
+ $formatted = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-protectedpages-unknown' ],
+ $this->msg( 'protectedpages-unknown-timestamp' )->escaped()
+ );
+ } else {
+ $formatted = htmlspecialchars( $this->getLanguage()->userTimeAndDate(
+ $value, $this->getUser() ) );
+ }
+ break;
+
+ case 'pr_page':
+ $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
+ if ( !$title ) {
+ $formatted = Html::element(
+ 'span',
+ [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription(
+ $this->getContext(),
+ $row->page_namespace,
+ $row->page_title
+ )
+ );
+ } else {
+ $formatted = $this->linkRenderer->makeLink( $title );
+ }
+ if ( !is_null( $row->page_len ) ) {
+ $formatted .= $this->getLanguage()->getDirMark() .
+ ' ' . Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-protectedpages-length' ],
+ Linker::formatRevisionSize( $row->page_len )
+ );
+ }
+ break;
+
+ case 'pr_expiry':
+ $formatted = htmlspecialchars( $this->getLanguage()->formatExpiry(
+ $value, /* User preference timezone */true ) );
+ $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
+ if ( $this->getUser()->isAllowed( 'protect' ) && $title ) {
+ $changeProtection = $this->linkRenderer->makeKnownLink(
+ $title,
+ $this->msg( 'protect_change' )->text(),
+ [],
+ [ 'action' => 'unprotect' ]
+ );
+ $formatted .= ' ' . Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-protectedpages-actions' ],
+ $this->msg( 'parentheses' )->rawParams( $changeProtection )->escaped()
+ );
+ }
+ break;
+
+ case 'log_user':
+ // when timestamp is null, this is a old protection row
+ if ( $row->log_timestamp === null ) {
+ $formatted = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-protectedpages-unknown' ],
+ $this->msg( 'protectedpages-unknown-performer' )->escaped()
+ );
+ } else {
+ $username = UserCache::singleton()->getProp( $value, 'name' );
+ if ( LogEventsList::userCanBitfield(
+ $row->log_deleted,
+ LogPage::DELETED_USER,
+ $this->getUser()
+ ) ) {
+ if ( $username === false ) {
+ $formatted = htmlspecialchars( $value );
+ } else {
+ $formatted = Linker::userLink( $value, $username )
+ . Linker::userToolLinks( $value, $username );
+ }
+ } else {
+ $formatted = $this->msg( 'rev-deleted-user' )->escaped();
+ }
+ if ( LogEventsList::isDeleted( $row, LogPage::DELETED_USER ) ) {
+ $formatted = '<span class="history-deleted">' . $formatted . '</span>';
+ }
+ }
+ break;
+
+ case 'pr_params':
+ $params = [];
+ // Messages: restriction-level-sysop, restriction-level-autoconfirmed
+ $params[] = $this->msg( 'restriction-level-' . $row->pr_level )->escaped();
+ if ( $row->pr_cascade ) {
+ $params[] = $this->msg( 'protect-summary-cascade' )->escaped();
+ }
+ $formatted = $this->getLanguage()->commaList( $params );
+ break;
+
+ case 'log_comment':
+ // when timestamp is null, this is an old protection row
+ if ( $row->log_timestamp === null ) {
+ $formatted = Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-protectedpages-unknown' ],
+ $this->msg( 'protectedpages-unknown-reason' )->escaped()
+ );
+ } else {
+ if ( LogEventsList::userCanBitfield(
+ $row->log_deleted,
+ LogPage::DELETED_COMMENT,
+ $this->getUser()
+ ) ) {
+ $value = CommentStore::newKey( 'log_comment' )->getComment( $row )->text;
+ $formatted = Linker::formatComment( $value !== null ? $value : '' );
+ } else {
+ $formatted = $this->msg( 'rev-deleted-comment' )->escaped();
+ }
+ if ( LogEventsList::isDeleted( $row, LogPage::DELETED_COMMENT ) ) {
+ $formatted = '<span class="history-deleted">' . $formatted . '</span>';
+ }
+ }
+ break;
+
+ default:
+ throw new MWException( "Unknown field '$field'" );
+ }
+
+ return $formatted;
+ }
+
+ function getQueryInfo() {
+ $conds = $this->mConds;
+ $conds[] = 'pr_expiry > ' . $this->mDb->addQuotes( $this->mDb->timestamp() ) .
+ ' OR pr_expiry IS NULL';
+ $conds[] = 'page_id=pr_page';
+ $conds[] = 'pr_type=' . $this->mDb->addQuotes( $this->type );
+
+ if ( $this->sizetype == 'min' ) {
+ $conds[] = 'page_len>=' . $this->size;
+ } elseif ( $this->sizetype == 'max' ) {
+ $conds[] = 'page_len<=' . $this->size;
+ }
+
+ if ( $this->indefonly ) {
+ $infinity = $this->mDb->addQuotes( $this->mDb->getInfinity() );
+ $conds[] = "pr_expiry = $infinity OR pr_expiry IS NULL";
+ }
+ if ( $this->cascadeonly ) {
+ $conds[] = 'pr_cascade = 1';
+ }
+ if ( $this->noredirect ) {
+ $conds[] = 'page_is_redirect = 0';
+ }
+
+ if ( $this->level ) {
+ $conds[] = 'pr_level=' . $this->mDb->addQuotes( $this->level );
+ }
+ if ( !is_null( $this->namespace ) ) {
+ $conds[] = 'page_namespace=' . $this->mDb->addQuotes( $this->namespace );
+ }
+
+ $commentQuery = CommentStore::newKey( 'log_comment' )->getJoin();
+
+ return [
+ 'tables' => [ 'page', 'page_restrictions', 'log_search', 'logging' ] + $commentQuery['tables'],
+ 'fields' => [
+ 'pr_id',
+ 'page_namespace',
+ 'page_title',
+ 'page_len',
+ 'pr_type',
+ 'pr_level',
+ 'pr_expiry',
+ 'pr_cascade',
+ 'log_timestamp',
+ 'log_user',
+ 'log_deleted',
+ ] + $commentQuery['fields'],
+ 'conds' => $conds,
+ 'join_conds' => [
+ 'log_search' => [
+ 'LEFT JOIN', [
+ 'ls_field' => 'pr_id', 'ls_value = ' . $this->mDb->buildStringCast( 'pr_id' )
+ ]
+ ],
+ 'logging' => [
+ 'LEFT JOIN', [
+ 'ls_log_id = log_id'
+ ]
+ ]
+ ] + $commentQuery['joins']
+ ];
+ }
+
+ protected function getTableClass() {
+ return parent::getTableClass() . ' mw-protectedpages';
+ }
+
+ function getIndexField() {
+ return 'pr_id';
+ }
+
+ function getDefaultSort() {
+ return 'pr_id';
+ }
+
+ function isFieldSortable( $field ) {
+ // no index for sorting exists
+ return false;
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/ProtectedTitlesPager.php b/www/wiki/includes/specials/pagers/ProtectedTitlesPager.php
new file mode 100644
index 00000000..8f172f8b
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/ProtectedTitlesPager.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * @ingroup Pager
+ */
+class ProtectedTitlesPager extends AlphabeticPager {
+
+ public $mForm, $mConds;
+
+ function __construct( $form, $conds = [], $type, $level, $namespace,
+ $sizetype = '', $size = 0
+ ) {
+ $this->mForm = $form;
+ $this->mConds = $conds;
+ $this->level = $level;
+ $this->namespace = $namespace;
+ $this->size = intval( $size );
+ parent::__construct( $form->getContext() );
+ }
+
+ function getStartBody() {
+ # Do a link batch query
+ $this->mResult->seek( 0 );
+ $lb = new LinkBatch;
+
+ foreach ( $this->mResult as $row ) {
+ $lb->add( $row->pt_namespace, $row->pt_title );
+ }
+
+ $lb->execute();
+
+ return '';
+ }
+
+ /**
+ * @return Title
+ */
+ function getTitle() {
+ return $this->mForm->getTitle();
+ }
+
+ function formatRow( $row ) {
+ return $this->mForm->formatRow( $row );
+ }
+
+ /**
+ * @return array
+ */
+ function getQueryInfo() {
+ $conds = $this->mConds;
+ $conds[] = 'pt_expiry > ' . $this->mDb->addQuotes( $this->mDb->timestamp() ) .
+ ' OR pt_expiry IS NULL';
+ if ( $this->level ) {
+ $conds['pt_create_perm'] = $this->level;
+ }
+
+ if ( !is_null( $this->namespace ) ) {
+ $conds[] = 'pt_namespace=' . $this->mDb->addQuotes( $this->namespace );
+ }
+
+ return [
+ 'tables' => 'protected_titles',
+ 'fields' => [ 'pt_namespace', 'pt_title', 'pt_create_perm',
+ 'pt_expiry', 'pt_timestamp' ],
+ 'conds' => $conds
+ ];
+ }
+
+ function getIndexField() {
+ return 'pt_timestamp';
+ }
+}
diff --git a/www/wiki/includes/specials/pagers/UsersPager.php b/www/wiki/includes/specials/pagers/UsersPager.php
new file mode 100644
index 00000000..fdd1b353
--- /dev/null
+++ b/www/wiki/includes/specials/pagers/UsersPager.php
@@ -0,0 +1,416 @@
+<?php
+/**
+ * Copyright © 2004 Brion Vibber, lcrocker, Tim Starling,
+ * Domas Mituzas, Antoine Musso, Jens Frank, Zhengzhu,
+ * 2006 Rob Church <robchur@gmail.com>
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+/**
+ * This class is used to get a list of user. The ones with specials
+ * rights (sysop, bureaucrat, developer) will have them displayed
+ * next to their names.
+ *
+ * @ingroup Pager
+ */
+class UsersPager extends AlphabeticPager {
+
+ /**
+ * @var array A array with user ids as key and a array of groups as value
+ */
+ protected $userGroupCache;
+
+ /**
+ * @param IContextSource $context
+ * @param array $par (Default null)
+ * @param bool $including Whether this page is being transcluded in
+ * another page
+ */
+ function __construct( IContextSource $context = null, $par = null, $including = null ) {
+ if ( $context ) {
+ $this->setContext( $context );
+ }
+
+ $request = $this->getRequest();
+ $par = ( $par !== null ) ? $par : '';
+ $parms = explode( '/', $par );
+ $symsForAll = [ '*', 'user' ];
+
+ if ( $parms[0] != '' &&
+ ( in_array( $par, User::getAllGroups() ) || in_array( $par, $symsForAll ) )
+ ) {
+ $this->requestedGroup = $par;
+ $un = $request->getText( 'username' );
+ } elseif ( count( $parms ) == 2 ) {
+ $this->requestedGroup = $parms[0];
+ $un = $parms[1];
+ } else {
+ $this->requestedGroup = $request->getVal( 'group' );
+ $un = ( $par != '' ) ? $par : $request->getText( 'username' );
+ }
+
+ if ( in_array( $this->requestedGroup, $symsForAll ) ) {
+ $this->requestedGroup = '';
+ }
+ $this->editsOnly = $request->getBool( 'editsOnly' );
+ $this->creationSort = $request->getBool( 'creationSort' );
+ $this->including = $including;
+ $this->mDefaultDirection = $request->getBool( 'desc' )
+ ? IndexPager::DIR_DESCENDING
+ : IndexPager::DIR_ASCENDING;
+
+ $this->requestedUser = '';
+
+ if ( $un != '' ) {
+ $username = Title::makeTitleSafe( NS_USER, $un );
+
+ if ( !is_null( $username ) ) {
+ $this->requestedUser = $username->getText();
+ }
+ }
+
+ parent::__construct();
+ }
+
+ /**
+ * @return string
+ */
+ function getIndexField() {
+ return $this->creationSort ? 'user_id' : 'user_name';
+ }
+
+ /**
+ * @return array
+ */
+ function getQueryInfo() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $conds = [];
+
+ // Don't show hidden names
+ if ( !$this->getUser()->isAllowed( 'hideuser' ) ) {
+ $conds[] = 'ipb_deleted IS NULL OR ipb_deleted = 0';
+ }
+
+ $options = [];
+
+ if ( $this->requestedGroup != '' ) {
+ $conds['ug_group'] = $this->requestedGroup;
+ $conds[] = 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() );
+ }
+
+ if ( $this->requestedUser != '' ) {
+ # Sorted either by account creation or name
+ if ( $this->creationSort ) {
+ $conds[] = 'user_id >= ' . intval( User::idFromName( $this->requestedUser ) );
+ } else {
+ $conds[] = 'user_name >= ' . $dbr->addQuotes( $this->requestedUser );
+ }
+ }
+
+ if ( $this->editsOnly ) {
+ $conds[] = 'user_editcount > 0';
+ }
+
+ $options['GROUP BY'] = $this->creationSort ? 'user_id' : 'user_name';
+
+ $query = [
+ 'tables' => [ 'user', 'user_groups', 'ipblocks' ],
+ 'fields' => [
+ 'user_name' => $this->creationSort ? 'MAX(user_name)' : 'user_name',
+ 'user_id' => $this->creationSort ? 'user_id' : 'MAX(user_id)',
+ 'edits' => 'MAX(user_editcount)',
+ 'creation' => 'MIN(user_registration)',
+ 'ipb_deleted' => 'MAX(ipb_deleted)' // block/hide status
+ ],
+ 'options' => $options,
+ 'join_conds' => [
+ 'user_groups' => [ 'LEFT JOIN', 'user_id=ug_user' ],
+ 'ipblocks' => [
+ 'LEFT JOIN', [
+ 'user_id=ipb_user',
+ 'ipb_auto' => 0
+ ]
+ ],
+ ],
+ 'conds' => $conds
+ ];
+
+ Hooks::run( 'SpecialListusersQueryInfo', [ $this, &$query ] );
+
+ return $query;
+ }
+
+ /**
+ * @param stdClass $row
+ * @return string
+ */
+ function formatRow( $row ) {
+ if ( $row->user_id == 0 ) { # T18487
+ return '';
+ }
+
+ $userName = $row->user_name;
+
+ $ulinks = Linker::userLink( $row->user_id, $userName );
+ $ulinks .= Linker::userToolLinksRedContribs(
+ $row->user_id,
+ $userName,
+ (int)$row->edits
+ );
+
+ $lang = $this->getLanguage();
+
+ $groups = '';
+ $ugms = self::getGroupMemberships( intval( $row->user_id ), $this->userGroupCache );
+
+ if ( !$this->including && count( $ugms ) > 0 ) {
+ $list = [];
+ foreach ( $ugms as $ugm ) {
+ $list[] = $this->buildGroupLink( $ugm, $userName );
+ }
+ $groups = $lang->commaList( $list );
+ }
+
+ $item = $lang->specialList( $ulinks, $groups );
+
+ if ( $row->ipb_deleted ) {
+ $item = "<span class=\"deleted\">$item</span>";
+ }
+
+ $edits = '';
+ if ( !$this->including && $this->getConfig()->get( 'Edititis' ) ) {
+ $count = $this->msg( 'usereditcount' )->numParams( $row->edits )->escaped();
+ $edits = $this->msg( 'word-separator' )->escaped() . $this->msg( 'brackets', $count )->escaped();
+ }
+
+ $created = '';
+ # Some rows may be null
+ if ( !$this->including && $row->creation ) {
+ $user = $this->getUser();
+ $d = $lang->userDate( $row->creation, $user );
+ $t = $lang->userTime( $row->creation, $user );
+ $created = $this->msg( 'usercreated', $d, $t, $row->user_name )->escaped();
+ $created = ' ' . $this->msg( 'parentheses' )->rawParams( $created )->escaped();
+ }
+ $blocked = !is_null( $row->ipb_deleted ) ?
+ ' ' . $this->msg( 'listusers-blocked', $userName )->escaped() :
+ '';
+
+ Hooks::run( 'SpecialListusersFormatRow', [ &$item, $row ] );
+
+ return Html::rawElement( 'li', [], "{$item}{$edits}{$created}{$blocked}" );
+ }
+
+ function doBatchLookups() {
+ $batch = new LinkBatch();
+ $userIds = [];
+ # Give some pointers to make user links
+ foreach ( $this->mResult as $row ) {
+ $batch->add( NS_USER, $row->user_name );
+ $batch->add( NS_USER_TALK, $row->user_name );
+ $userIds[] = $row->user_id;
+ }
+
+ // Lookup groups for all the users
+ $dbr = wfGetDB( DB_REPLICA );
+ $groupRes = $dbr->select(
+ 'user_groups',
+ UserGroupMembership::selectFields(),
+ [ 'ug_user' => $userIds ],
+ __METHOD__
+ );
+ $cache = [];
+ $groups = [];
+ foreach ( $groupRes as $row ) {
+ $ugm = UserGroupMembership::newFromRow( $row );
+ if ( !$ugm->isExpired() ) {
+ $cache[$row->ug_user][$row->ug_group] = $ugm;
+ $groups[$row->ug_group] = true;
+ }
+ }
+
+ // Give extensions a chance to add things like global user group data
+ // into the cache array to ensure proper output later on
+ Hooks::run( 'UsersPagerDoBatchLookups', [ $dbr, $userIds, &$cache, &$groups ] );
+
+ $this->userGroupCache = $cache;
+
+ // Add page of groups to link batch
+ foreach ( $groups as $group => $unused ) {
+ $groupPage = UserGroupMembership::getGroupPage( $group );
+ if ( $groupPage ) {
+ $batch->addObj( $groupPage );
+ }
+ }
+
+ $batch->execute();
+ $this->mResult->rewind();
+ }
+
+ /**
+ * @return string
+ */
+ function getPageHeader() {
+ list( $self ) = explode( '/', $this->getTitle()->getPrefixedDBkey() );
+
+ $groupOptions = [ $this->msg( 'group-all' )->text() => '' ];
+ foreach ( $this->getAllGroups() as $group => $groupText ) {
+ $groupOptions[ $groupText ] = $group;
+ }
+
+ $formDescriptor = [
+ 'user' => [
+ 'class' => 'HTMLUserTextField',
+ 'label' => $this->msg( 'listusersfrom' )->text(),
+ 'name' => 'username',
+ 'default' => $this->requestedUser,
+ ],
+ 'dropdown' => [
+ 'label' => $this->msg( 'group' )->text(),
+ 'name' => 'group',
+ 'default' => $this->requestedGroup,
+ 'class' => 'HTMLSelectField',
+ 'options' => $groupOptions,
+ ],
+ 'editsOnly' => [
+ 'type' => 'check',
+ 'label' => $this->msg( 'listusers-editsonly' )->text(),
+ 'name' => 'editsOnly',
+ 'id' => 'editsOnly',
+ 'default' => $this->editsOnly
+ ],
+ 'creationSort' => [
+ 'type' => 'check',
+ 'label' => $this->msg( 'listusers-creationsort' )->text(),
+ 'name' => 'creationSort',
+ 'id' => 'creationSort',
+ 'default' => $this->creationSort
+ ],
+ 'desc' => [
+ 'type' => 'check',
+ 'label' => $this->msg( 'listusers-desc' )->text(),
+ 'name' => 'desc',
+ 'id' => 'desc',
+ 'default' => $this->mDefaultDirection
+ ],
+ 'limithiddenfield' => [
+ 'class' => 'HTMLHiddenField',
+ 'name' => 'limit',
+ 'default' => $this->mLimit
+ ]
+ ];
+
+ $beforeSubmitButtonHookOut = '';
+ Hooks::run( 'SpecialListusersHeaderForm', [ $this, &$beforeSubmitButtonHookOut ] );
+
+ if ( $beforeSubmitButtonHookOut !== '' ) {
+ $formDescriptor[ 'beforeSubmitButtonHookOut' ] = [
+ 'class' => 'HTMLInfoField',
+ 'raw' => true,
+ 'default' => $beforeSubmitButtonHookOut
+ ];
+ }
+
+ $formDescriptor[ 'submit' ] = [
+ 'class' => 'HTMLSubmitField',
+ 'buttonlabel-message' => 'listusers-submit',
+ ];
+
+ $beforeClosingFieldsetHookOut = '';
+ Hooks::run( 'SpecialListusersHeader', [ $this, &$beforeClosingFieldsetHookOut ] );
+
+ if ( $beforeClosingFieldsetHookOut !== '' ) {
+ $formDescriptor[ 'beforeClosingFieldsetHookOut' ] = [
+ 'class' => 'HTMLInfoField',
+ 'raw' => true,
+ 'default' => $beforeClosingFieldsetHookOut
+ ];
+ }
+
+ $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
+ $htmlForm
+ ->setMethod( 'get' )
+ ->setAction( Title::newFromText( $self )->getLocalURL() )
+ ->setId( 'mw-listusers-form' )
+ ->setFormIdentifier( 'mw-listusers-form' )
+ ->suppressDefaultSubmit()
+ ->setWrapperLegendMsg( 'listusers' );
+ return $htmlForm->prepareForm()->getHTML( true );
+ }
+
+ /**
+ * Get a list of all explicit groups
+ * @return array
+ */
+ function getAllGroups() {
+ $result = [];
+ foreach ( User::getAllGroups() as $group ) {
+ $result[$group] = UserGroupMembership::getGroupName( $group );
+ }
+ asort( $result );
+
+ return $result;
+ }
+
+ /**
+ * Preserve group and username offset parameters when paging
+ * @return array
+ */
+ function getDefaultQuery() {
+ $query = parent::getDefaultQuery();
+ if ( $this->requestedGroup != '' ) {
+ $query['group'] = $this->requestedGroup;
+ }
+ if ( $this->requestedUser != '' ) {
+ $query['username'] = $this->requestedUser;
+ }
+ Hooks::run( 'SpecialListusersDefaultQuery', [ $this, &$query ] );
+
+ return $query;
+ }
+
+ /**
+ * Get an associative array containing groups the specified user belongs to,
+ * and the relevant UserGroupMembership objects
+ *
+ * @param int $uid User id
+ * @param array|null $cache
+ * @return array (group name => UserGroupMembership object)
+ */
+ protected static function getGroupMemberships( $uid, $cache = null ) {
+ if ( $cache === null ) {
+ $user = User::newFromId( $uid );
+ return $user->getGroupMemberships();
+ } else {
+ return isset( $cache[$uid] ) ? $cache[$uid] : [];
+ }
+ }
+
+ /**
+ * Format a link to a group description page
+ *
+ * @param string|UserGroupMembership $group Group name or UserGroupMembership object
+ * @param string $username Username
+ * @return string
+ */
+ protected function buildGroupLink( $group, $username ) {
+ return UserGroupMembership::getLink( $group, $this->getContext(), 'html', $username );
+ }
+}
diff --git a/www/wiki/includes/templates/EnhancedChangesListGroup.mustache b/www/wiki/includes/templates/EnhancedChangesListGroup.mustache
new file mode 100644
index 00000000..6493df88
--- /dev/null
+++ b/www/wiki/includes/templates/EnhancedChangesListGroup.mustache
@@ -0,0 +1,30 @@
+<table class="{{# tableClasses }}{{ . }} {{/ tableClasses }}" data-mw-ts="{{{ fullTimestamp }}}">
+ <tr>
+ <td>
+ <span class="mw-collapsible-toggle mw-collapsible-arrow mw-enhancedchanges-arrow mw-enhancedchanges-arrow-space"></span>
+ </td>
+ <td class="mw-changeslist-line-prefix">{{{ prefix }}}</td>
+ <td class="mw-enhanced-rc">{{{ collectedRcFlags }}}&#160;{{ timestamp }}&#160;</td>
+ <td class="mw-changeslist-line-inner">
+ {{# rev-deleted-event }}<span class="history-deleted">{{{ . }}}</span>{{/ rev-deleted-event }}
+ {{{ articleLink }}}{{{ languageDirMark }}}{{{ logText }}}
+ <span class="mw-changeslist-separator">. .</span>
+ {{# charDifference }}{{{ . }}} <span class="mw-changeslist-separator">. .</span>{{/ charDifference }}
+ <span class="changedby">{{{ users }}}</span>
+ {{ numberofWatchingusers }}
+ </td>
+ </tr>
+ {{# lines }}
+ <tr class="{{# classes }}{{ . }} {{/ classes }}"{{{ attribs }}}>
+ <td></td>
+ <td></td>
+ <td class="mw-enhanced-rc">{{{ recentChangesFlags }}}&#160;</td>
+ <td class="mw-enhanced-rc-nested" data-target-page="{{ targetTitle }}">
+ {{# timestampLink }}
+ <span class="mw-enhanced-rc-time">{{{ . }}}</span>
+ {{/ timestampLink }}
+ {{# data }}{{{ . }}}{{/ data }}
+ </td>
+ </tr>
+ {{/ lines }}
+</table>
diff --git a/www/wiki/includes/templates/NoLocalSettings.mustache b/www/wiki/includes/templates/NoLocalSettings.mustache
new file mode 100644
index 00000000..54579491
--- /dev/null
+++ b/www/wiki/includes/templates/NoLocalSettings.mustache
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html lang="en" dir="ltr">
+ <head>
+ <meta charset="UTF-8" />
+ <title>MediaWiki {{wgVersion}}</title>
+ <style media="screen">
+ html, body {
+ color: #000;
+ background-color: #fff;
+ font-family: sans-serif;
+ text-align: center;
+ }
+
+ h1 {
+ font-size: 150%;
+ }
+ </style>
+ </head>
+ <body>
+ <img src="{{path}}resources/assets/mediawiki.png" alt="The MediaWiki logo" />
+
+ <h1>MediaWiki {{wgVersion}}</h1>
+ <div class="error">
+ {{#localSettingsExists}}
+ <p>LocalSettings.php not readable.</p>
+ <p>Please correct file permissions and try again.</p>
+ {{/localSettingsExists}}
+ {{^localSettingsExists}}
+ <p>LocalSettings.php not found.</p>
+ {{#installerStarted}}
+ <p>Please <a href="{{path}}mw-config/index.{{ext}}">complete the installation</a> and download LocalSettings.php.</p>
+ {{/installerStarted}}
+ {{^installerStarted}}
+ <p>Please <a href="{{path}}mw-config/index.{{ext}}">set up the wiki</a> first.</p>
+ {{/installerStarted}}
+ {{/localSettingsExists}}
+ </div>
+ </body>
+</html> \ No newline at end of file
diff --git a/www/wiki/includes/templates/SpecialContributionsLine.mustache b/www/wiki/includes/templates/SpecialContributionsLine.mustache
new file mode 100644
index 00000000..7a334014
--- /dev/null
+++ b/www/wiki/includes/templates/SpecialContributionsLine.mustache
@@ -0,0 +1,6 @@
+{{{ del }}}{{{ timestamp }}}
+{{{ diffHistLinks }}}{{{ charDifference }}}{{# flags }}{{{ . }}}{{/ flags }}
+{{{ articleLink }}}{{{ userlink }}}
+{{{ logText }}}
+{{{ topmarktext }}}{{# rev-deleted-user-contribs }} <strong>{{{ . }}}</strong>{{/ rev-deleted-user-contribs }}
+{{{ tagSummary }}}
diff --git a/www/wiki/includes/tidy/Balancer.php b/www/wiki/includes/tidy/Balancer.php
new file mode 100644
index 00000000..fbe92702
--- /dev/null
+++ b/www/wiki/includes/tidy/Balancer.php
@@ -0,0 +1,3582 @@
+<?php
+/**
+ * An implementation of the tree building portion of the HTML5 parsing
+ * spec.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Parser
+ * @since 1.27
+ * @author C. Scott Ananian, 2016
+ */
+namespace MediaWiki\Tidy;
+
+use Wikimedia\Assert\Assert;
+use Wikimedia\Assert\ParameterAssertionException;
+use \ExplodeIterator;
+use \IteratorAggregate;
+use \ReverseArrayIterator;
+use \Sanitizer;
+
+// A note for future librarization[1] -- this file is a good candidate
+// for splitting into an independent library, except that it is currently
+// highly optimized for MediaWiki use. It only implements the portions
+// of the HTML5 tree builder used by tags supported by MediaWiki, and
+// does not contain a true tokenizer pass, instead relying on
+// comment stripping, attribute normalization, and escaping done by
+// the MediaWiki Sanitizer. It also deliberately avoids building
+// a true DOM in memory, instead serializing elements to an output string
+// as soon as possible (usually as soon as the tag is closed) to reduce
+// its memory footprint.
+
+// We've been gradually lifting some of these restrictions to handle
+// non-sanitized output generated by extensions, but we shortcut the tokenizer
+// for speed (primarily by splitting on `<`) and so rely on syntactic
+// well-formedness.
+
+// On the other hand, I've been pretty careful to note with comments in the
+// code the places where this implementation omits features of the spec or
+// depends on the MediaWiki Sanitizer. Perhaps in the future we'll want to
+// implement the missing pieces and make this a standalone PHP HTML5 parser.
+// In order to do so, some sort of MediaWiki-specific API will need
+// to be added to (a) allow the Balancer to bypass the tokenizer,
+// and (b) support on-the-fly flattening instead of DOM node creation.
+
+// [1]: https://www.mediawiki.org/wiki/Library_infrastructure_for_MediaWiki
+
+/**
+ * Utility constants and sets for the HTML5 tree building algorithm.
+ * Sets are associative arrays indexed first by namespace and then by
+ * lower-cased tag name.
+ *
+ * @ingroup Parser
+ * @since 1.27
+ */
+class BalanceSets {
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
+ const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+
+ public static $unsupportedSet = [
+ self::HTML_NAMESPACE => [
+ 'html' => true, 'head' => true, 'body' => true, 'frameset' => true,
+ 'frame' => true,
+ 'plaintext' => true,
+ 'xmp' => true, 'iframe' => true, 'noembed' => true,
+ 'noscript' => true, 'script' => true,
+ 'title' => true
+ ]
+ ];
+
+ public static $emptyElementSet = [
+ self::HTML_NAMESPACE => [
+ 'area' => true, 'base' => true, 'basefont' => true,
+ 'bgsound' => true, 'br' => true, 'col' => true, 'command' => true,
+ 'embed' => true, 'frame' => true, 'hr' => true, 'img' => true,
+ 'input' => true, 'keygen' => true, 'link' => true, 'meta' => true,
+ 'param' => true, 'source' => true, 'track' => true, 'wbr' => true
+ ]
+ ];
+
+ public static $extraLinefeedSet = [
+ self::HTML_NAMESPACE => [
+ 'pre' => true, 'textarea' => true, 'listing' => true,
+ ]
+ ];
+
+ public static $headingSet = [
+ self::HTML_NAMESPACE => [
+ 'h1' => true, 'h2' => true, 'h3' => true,
+ 'h4' => true, 'h5' => true, 'h6' => true
+ ]
+ ];
+
+ public static $specialSet = [
+ self::HTML_NAMESPACE => [
+ 'address' => true, 'applet' => true, 'area' => true,
+ 'article' => true, 'aside' => true, 'base' => true,
+ 'basefont' => true, 'bgsound' => true, 'blockquote' => true,
+ 'body' => true, 'br' => true, 'button' => true, 'caption' => true,
+ 'center' => true, 'col' => true, 'colgroup' => true, 'dd' => true,
+ 'details' => true, 'dir' => true, 'div' => true, 'dl' => true,
+ 'dt' => true, 'embed' => true, 'fieldset' => true,
+ 'figcaption' => true, 'figure' => true, 'footer' => true,
+ 'form' => true, 'frame' => true, 'frameset' => true, 'h1' => true,
+ 'h2' => true, 'h3' => true, 'h4' => true, 'h5' => true,
+ 'h6' => true, 'head' => true, 'header' => true, 'hgroup' => true,
+ 'hr' => true, 'html' => true, 'iframe' => true, 'img' => true,
+ 'input' => true, 'li' => true, 'link' => true,
+ 'listing' => true, 'main' => true, 'marquee' => true,
+ 'menu' => true, 'meta' => true, 'nav' => true,
+ 'noembed' => true, 'noframes' => true, 'noscript' => true,
+ 'object' => true, 'ol' => true, 'p' => true, 'param' => true,
+ 'plaintext' => true, 'pre' => true, 'script' => true,
+ 'section' => true, 'select' => true, 'source' => true,
+ 'style' => true, 'summary' => true, 'table' => true,
+ 'tbody' => true, 'td' => true, 'template' => true,
+ 'textarea' => true, 'tfoot' => true, 'th' => true, 'thead' => true,
+ 'title' => true, 'tr' => true, 'track' => true, 'ul' => true,
+ 'wbr' => true, 'xmp' => true
+ ],
+ self::SVG_NAMESPACE => [
+ 'foreignobject' => true, 'desc' => true, 'title' => true
+ ],
+ self::MATHML_NAMESPACE => [
+ 'mi' => true, 'mo' => true, 'mn' => true, 'ms' => true,
+ 'mtext' => true, 'annotation-xml' => true
+ ]
+ ];
+
+ public static $addressDivPSet = [
+ self::HTML_NAMESPACE => [
+ 'address' => true, 'div' => true, 'p' => true
+ ]
+ ];
+
+ public static $tableSectionRowSet = [
+ self::HTML_NAMESPACE => [
+ 'table' => true, 'thead' => true, 'tbody' => true,
+ 'tfoot' => true, 'tr' => true
+ ]
+ ];
+
+ public static $impliedEndTagsSet = [
+ self::HTML_NAMESPACE => [
+ 'dd' => true, 'dt' => true, 'li' => true,
+ 'menuitem' => true, 'optgroup' => true,
+ 'option' => true, 'p' => true, 'rb' => true, 'rp' => true,
+ 'rt' => true, 'rtc' => true
+ ]
+ ];
+
+ public static $thoroughImpliedEndTagsSet = [
+ self::HTML_NAMESPACE => [
+ 'caption' => true, 'colgroup' => true, 'dd' => true, 'dt' => true,
+ 'li' => true, 'optgroup' => true, 'option' => true, 'p' => true,
+ 'rb' => true, 'rp' => true, 'rt' => true, 'rtc' => true,
+ 'tbody' => true, 'td' => true, 'tfoot' => true, 'th' => true,
+ 'thead' => true, 'tr' => true
+ ]
+ ];
+
+ public static $tableCellSet = [
+ self::HTML_NAMESPACE => [
+ 'td' => true, 'th' => true
+ ]
+ ];
+ public static $tableContextSet = [
+ self::HTML_NAMESPACE => [
+ 'table' => true, 'template' => true, 'html' => true
+ ]
+ ];
+
+ public static $tableBodyContextSet = [
+ self::HTML_NAMESPACE => [
+ 'tbody' => true, 'tfoot' => true, 'thead' => true,
+ 'template' => true, 'html' => true
+ ]
+ ];
+
+ public static $tableRowContextSet = [
+ self::HTML_NAMESPACE => [
+ 'tr' => true, 'template' => true, 'html' => true
+ ]
+ ];
+
+ // See https://html.spec.whatwg.org/multipage/forms.html#form-associated-element
+ public static $formAssociatedSet = [
+ self::HTML_NAMESPACE => [
+ 'button' => true, 'fieldset' => true, 'input' => true,
+ 'keygen' => true, 'object' => true, 'output' => true,
+ 'select' => true, 'textarea' => true, 'img' => true
+ ]
+ ];
+
+ public static $inScopeSet = [
+ self::HTML_NAMESPACE => [
+ 'applet' => true, 'caption' => true, 'html' => true,
+ 'marquee' => true, 'object' => true,
+ 'table' => true, 'td' => true, 'template' => true,
+ 'th' => true
+ ],
+ self::SVG_NAMESPACE => [
+ 'foreignobject' => true, 'desc' => true, 'title' => true
+ ],
+ self::MATHML_NAMESPACE => [
+ 'mi' => true, 'mo' => true, 'mn' => true, 'ms' => true,
+ 'mtext' => true, 'annotation-xml' => true
+ ]
+ ];
+
+ private static $inListItemScopeSet = null;
+ public static function inListItemScopeSet() {
+ if ( self::$inListItemScopeSet === null ) {
+ self::$inListItemScopeSet = self::$inScopeSet;
+ self::$inListItemScopeSet[self::HTML_NAMESPACE]['ol'] = true;
+ self::$inListItemScopeSet[self::HTML_NAMESPACE]['ul'] = true;
+ }
+ return self::$inListItemScopeSet;
+ }
+
+ private static $inButtonScopeSet = null;
+ public static function inButtonScopeSet() {
+ if ( self::$inButtonScopeSet === null ) {
+ self::$inButtonScopeSet = self::$inScopeSet;
+ self::$inButtonScopeSet[self::HTML_NAMESPACE]['button'] = true;
+ }
+ return self::$inButtonScopeSet;
+ }
+
+ public static $inTableScopeSet = [
+ self::HTML_NAMESPACE => [
+ 'html' => true, 'table' => true, 'template' => true
+ ]
+ ];
+
+ public static $inInvertedSelectScopeSet = [
+ self::HTML_NAMESPACE => [
+ 'option' => true, 'optgroup' => true
+ ]
+ ];
+
+ public static $mathmlTextIntegrationPointSet = [
+ self::MATHML_NAMESPACE => [
+ 'mi' => true, 'mo' => true, 'mn' => true, 'ms' => true,
+ 'mtext' => true
+ ]
+ ];
+
+ public static $htmlIntegrationPointSet = [
+ self::SVG_NAMESPACE => [
+ 'foreignobject' => true,
+ 'desc' => true,
+ 'title' => true
+ ]
+ ];
+
+ // For tidy compatibility.
+ public static $tidyPWrapSet = [
+ self::HTML_NAMESPACE => [
+ 'body' => true, 'blockquote' => true,
+ // We parse with <body> as the fragment context, but the top-level
+ // element on the stack is actually <html>. We could use the
+ // "adjusted current node" everywhere to work around this, but it's
+ // easier just to add <html> to the p-wrap set.
+ 'html' => true,
+ ],
+ ];
+ public static $tidyInlineSet = [
+ self::HTML_NAMESPACE => [
+ 'a' => true, 'abbr' => true, 'acronym' => true, 'applet' => true,
+ 'b' => true, 'basefont' => true, 'bdo' => true, 'big' => true,
+ 'br' => true, 'button' => true, 'cite' => true, 'code' => true,
+ 'dfn' => true, 'em' => true, 'font' => true, 'i' => true,
+ 'iframe' => true, 'img' => true, 'input' => true, 'kbd' => true,
+ 'label' => true, 'legend' => true, 'map' => true, 'object' => true,
+ 'param' => true, 'q' => true, 'rb' => true, 'rbc' => true,
+ 'rp' => true, 'rt' => true, 'rtc' => true, 'ruby' => true,
+ 's' => true, 'samp' => true, 'select' => true, 'small' => true,
+ 'span' => true, 'strike' => true, 'strong' => true, 'sub' => true,
+ 'sup' => true, 'textarea' => true, 'tt' => true, 'u' => true,
+ 'var' => true,
+ ],
+ ];
+}
+
+/**
+ * A BalanceElement is a simplified version of a DOM Node. The main
+ * difference is that we only keep BalanceElements around for nodes
+ * currently on the BalanceStack of open elements. As soon as an
+ * element is closed, with some minor exceptions relating to the
+ * tree builder "adoption agency algorithm", the element and all its
+ * children are serialized to a string using the flatten() method.
+ * This keeps our memory usage low.
+ *
+ * @ingroup Parser
+ * @since 1.27
+ */
+class BalanceElement {
+ /**
+ * The namespace of the element.
+ * @var string $namespaceURI
+ */
+ public $namespaceURI;
+ /**
+ * The lower-cased name of the element.
+ * @var string $localName
+ */
+ public $localName;
+ /**
+ * Attributes for the element, in array form
+ * @var array $attribs
+ */
+ public $attribs;
+
+ /**
+ * Parent of this element, or the string "flat" if this element has
+ * already been flattened into its parent.
+ * @var BalanceElement|string|null $parent
+ */
+ public $parent;
+
+ /**
+ * An array of children of this element. Typically only the last
+ * child will be an actual BalanceElement object; the rest will
+ * be strings, representing either text nodes or flattened
+ * BalanceElement objects.
+ * @var BalanceElement[]|string[] $children
+ */
+ public $children;
+
+ /**
+ * A unique string identifier for Noah's Ark purposes, lazy initialized
+ */
+ private $noahKey;
+
+ /**
+ * The next active formatting element in the list, or null if this is the
+ * end of the AFE list or if the element is not in the AFE list.
+ */
+ public $nextAFE;
+
+ /**
+ * The previous active formatting element in the list, or null if this is
+ * the start of the list or if the element is not in the AFE list.
+ */
+ public $prevAFE;
+
+ /**
+ * The next element in the Noah's Ark species bucket.
+ */
+ public $nextNoah;
+
+ /**
+ * Make a new BalanceElement corresponding to the HTML DOM Element
+ * with the given localname, namespace, and attributes.
+ *
+ * @param string $namespaceURI The namespace of the element.
+ * @param string $localName The lowercased name of the tag.
+ * @param array $attribs Attributes of the element
+ */
+ public function __construct( $namespaceURI, $localName, array $attribs ) {
+ $this->localName = $localName;
+ $this->namespaceURI = $namespaceURI;
+ $this->attribs = $attribs;
+ $this->contents = '';
+ $this->parent = null;
+ $this->children = [];
+ }
+
+ /**
+ * Remove the given child from this element.
+ * @param BalanceElement $elt
+ */
+ private function removeChild( BalanceElement $elt ) {
+ Assert::precondition(
+ $this->parent !== 'flat', "Can't removeChild after flattening $this"
+ );
+ Assert::parameter(
+ $elt->parent === $this, 'elt', 'must have $this as a parent'
+ );
+ $idx = array_search( $elt, $this->children, true );
+ Assert::parameter( $idx !== false, '$elt', 'must be a child of $this' );
+ $elt->parent = null;
+ array_splice( $this->children, $idx, 1 );
+ }
+
+ /**
+ * Find $a in the list of children and insert $b before it.
+ * @param BalanceElement $a
+ * @param BalanceElement|string $b
+ */
+ public function insertBefore( BalanceElement $a, $b ) {
+ Assert::precondition(
+ $this->parent !== 'flat', "Can't insertBefore after flattening."
+ );
+ $idx = array_search( $a, $this->children, true );
+ Assert::parameter( $idx !== false, '$a', 'must be a child of $this' );
+ if ( is_string( $b ) ) {
+ array_splice( $this->children, $idx, 0, [ $b ] );
+ } else {
+ Assert::parameter( $b->parent !== 'flat', '$b', "Can't be flat" );
+ if ( $b->parent !== null ) {
+ $b->parent->removeChild( $b );
+ }
+ array_splice( $this->children, $idx, 0, [ $b ] );
+ $b->parent = $this;
+ }
+ }
+
+ /**
+ * Append $elt to the end of the list of children.
+ * @param BalanceElement|string $elt
+ */
+ public function appendChild( $elt ) {
+ Assert::precondition(
+ $this->parent !== 'flat', "Can't appendChild after flattening."
+ );
+ if ( is_string( $elt ) ) {
+ array_push( $this->children, $elt );
+ return;
+ }
+ // Remove $elt from parent, if it had one.
+ if ( $elt->parent !== null ) {
+ $elt->parent->removeChild( $elt );
+ }
+ array_push( $this->children, $elt );
+ $elt->parent = $this;
+ }
+
+ /**
+ * Transfer all of the children of $elt to $this.
+ * @param BalanceElement $elt
+ */
+ public function adoptChildren( BalanceElement $elt ) {
+ Assert::precondition(
+ $elt->parent !== 'flat', "Can't adoptChildren after flattening."
+ );
+ foreach ( $elt->children as $child ) {
+ if ( !is_string( $child ) ) {
+ // This is an optimization which avoids an O(n^2) set of
+ // array_splice operations.
+ $child->parent = null;
+ }
+ $this->appendChild( $child );
+ }
+ $elt->children = [];
+ }
+
+ /**
+ * Flatten this node and all of its children into a string, as specified
+ * by the HTML serialization specification, and replace this node
+ * in its parent by that string.
+ *
+ * @param array $config Balancer configuration; see Balancer::__construct().
+ * @return string
+ *
+ * @see __toString()
+ */
+ public function flatten( array $config ) {
+ Assert::parameter( $this->parent !== null, '$this', 'must be a child' );
+ Assert::parameter( $this->parent !== 'flat', '$this', 'already flat' );
+ $idx = array_search( $this, $this->parent->children, true );
+ Assert::parameter(
+ $idx !== false, '$this', 'must be a child of its parent'
+ );
+ $tidyCompat = $config['tidyCompat'];
+ if ( $tidyCompat ) {
+ $blank = true;
+ foreach ( $this->children as $elt ) {
+ if ( !is_string( $elt ) ) {
+ $elt = $elt->flatten( $config );
+ }
+ if ( $blank && preg_match( '/[^\t\n\f\r ]/', $elt ) ) {
+ $blank = false;
+ }
+ }
+ if ( $this->isHtmlNamed( 'mw:p-wrap' ) ) {
+ $this->localName = 'p';
+ } elseif ( $blank ) {
+ // Add 'mw-empty-elt' class so elements can be hidden via CSS
+ // for compatibility with legacy tidy.
+ if ( !count( $this->attribs ) &&
+ ( $this->localName === 'tr' || $this->localName === 'li' )
+ ) {
+ $this->attribs = [ 'class' => "mw-empty-elt" ];
+ }
+ $blank = false;
+ } elseif (
+ $this->isA( BalanceSets::$extraLinefeedSet ) &&
+ count( $this->children ) > 0 &&
+ substr( $this->children[0], 0, 1 ) == "\n"
+ ) {
+ // Double the linefeed after pre/listing/textarea
+ // according to the (old) HTML5 fragment serialization
+ // algorithm (see https://github.com/whatwg/html/issues/944)
+ // to ensure this will round-trip.
+ array_unshift( $this->children, "\n" );
+ }
+ $flat = $blank ? '' : "{$this}";
+ } else {
+ $flat = "{$this}";
+ }
+ $this->parent->children[$idx] = $flat;
+ $this->parent = 'flat'; // for assertion checking
+ return $flat;
+ }
+
+ /**
+ * Serialize this node and all of its children to a string, as specified
+ * by the HTML serialization specification.
+ *
+ * @return string The serialization of the BalanceElement
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#serialising-html-fragments
+ */
+ public function __toString() {
+ $encAttribs = '';
+ foreach ( $this->attribs as $name => $value ) {
+ $encValue = Sanitizer::encodeAttribute( $value );
+ $encAttribs .= " $name=\"$encValue\"";
+ }
+ if ( !$this->isA( BalanceSets::$emptyElementSet ) ) {
+ $out = "<{$this->localName}{$encAttribs}>";
+ $len = strlen( $out );
+ // flatten children
+ foreach ( $this->children as $elt ) {
+ $out .= "{$elt}";
+ }
+ $out .= "</{$this->localName}>";
+ } else {
+ $out = "<{$this->localName}{$encAttribs} />";
+ Assert::invariant(
+ count( $this->children ) === 0,
+ "Empty elements shouldn't have children."
+ );
+ }
+ return $out;
+ }
+
+ // Utility functions on BalanceElements.
+
+ /**
+ * Determine if $this represents a specific HTML tag, is a member of
+ * a tag set, or is equal to another BalanceElement.
+ *
+ * @param BalanceElement|array|string $set The target BalanceElement,
+ * set (from the BalanceSets class), or string (HTML tag name).
+ * @return bool
+ */
+ public function isA( $set ) {
+ if ( $set instanceof BalanceElement ) {
+ return $this === $set;
+ } elseif ( is_array( $set ) ) {
+ return isset( $set[$this->namespaceURI] ) &&
+ isset( $set[$this->namespaceURI][$this->localName] );
+ } else {
+ // assume this is an HTML element name.
+ return $this->isHtml() && $this->localName === $set;
+ }
+ }
+
+ /**
+ * Determine if this element is an HTML element with the specified name
+ * @param string $tagName
+ * @return bool
+ */
+ public function isHtmlNamed( $tagName ) {
+ return $this->namespaceURI === BalanceSets::HTML_NAMESPACE
+ && $this->localName === $tagName;
+ }
+
+ /**
+ * Determine if $this represents an element in the HTML namespace.
+ *
+ * @return bool
+ */
+ public function isHtml() {
+ return $this->namespaceURI === BalanceSets::HTML_NAMESPACE;
+ }
+
+ /**
+ * Determine if $this represents a MathML text integration point,
+ * as defined in the HTML5 specification.
+ *
+ * @return bool
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#mathml-text-integration-point
+ */
+ public function isMathmlTextIntegrationPoint() {
+ return $this->isA( BalanceSets::$mathmlTextIntegrationPointSet );
+ }
+
+ /**
+ * Determine if $this represents an HTML integration point,
+ * as defined in the HTML5 specification.
+ *
+ * @return bool
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#html-integration-point
+ */
+ public function isHtmlIntegrationPoint() {
+ if ( $this->isA( BalanceSets::$htmlIntegrationPointSet ) ) {
+ return true;
+ }
+ if (
+ $this->namespaceURI === BalanceSets::MATHML_NAMESPACE &&
+ $this->localName === 'annotation-xml' &&
+ isset( $this->attribs['encoding'] ) &&
+ ( strcasecmp( $this->attribs['encoding'], 'text/html' ) == 0 ||
+ strcasecmp( $this->attribs['encoding'], 'application/xhtml+xml' ) == 0 )
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Get a string key for the Noah's Ark algorithm
+ * @return string
+ */
+ public function getNoahKey() {
+ if ( $this->noahKey === null ) {
+ $attribs = $this->attribs;
+ ksort( $attribs );
+ $this->noahKey = serialize( [ $this->namespaceURI, $this->localName, $attribs ] );
+ }
+ return $this->noahKey;
+ }
+}
+
+/**
+ * The "stack of open elements" as defined in the HTML5 tree builder
+ * spec. This contains methods to ensure that content (start tags, text)
+ * are inserted at the correct place in the output string, and to
+ * flatten BalanceElements are they are closed to avoid holding onto
+ * a complete DOM tree for the document in memory.
+ *
+ * The stack defines a PHP iterator to traverse it in "reverse order",
+ * that is, the most-recently-added element is visited first in a
+ * foreach loop.
+ *
+ * @ingroup Parser
+ * @since 1.27
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#the-stack-of-open-elements
+ */
+class BalanceStack implements IteratorAggregate {
+ /**
+ * Backing storage for the stack.
+ * @var BalanceElement[] $elements
+ */
+ private $elements = [];
+ /**
+ * Foster parent mode determines how nodes are inserted into the
+ * stack.
+ * @var bool $fosterParentMode
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#foster-parent
+ */
+ public $fosterParentMode = false;
+ /**
+ * Configuration options governing flattening.
+ * @var array $config
+ * @see Balancer::__construct()
+ */
+ private $config;
+ /**
+ * Reference to the current element
+ */
+ public $currentNode;
+
+ /**
+ * Create a new BalanceStack with a single BalanceElement on it,
+ * representing the root &lt;html&gt; node.
+ * @param array $config Balancer configuration; see Balancer::_construct().
+ */
+ public function __construct( array $config ) {
+ // always a root <html> element on the stack
+ array_push(
+ $this->elements,
+ new BalanceElement( BalanceSets::HTML_NAMESPACE, 'html', [] )
+ );
+ $this->currentNode = $this->elements[0];
+ $this->config = $config;
+ }
+
+ /**
+ * Return a string representing the output of the tree builder:
+ * all the children of the root &lt;html&gt; node.
+ * @return string
+ */
+ public function getOutput() {
+ // Don't include the outer '<html>....</html>'
+ $out = '';
+ foreach ( $this->elements[0]->children as $elt ) {
+ $out .= is_string( $elt ) ? $elt :
+ $elt->flatten( $this->config );
+ }
+ return $out;
+ }
+
+ /**
+ * Insert a comment at the appropriate place for inserting a node.
+ * @param string $value Content of the comment.
+ * @return string
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#insert-a-comment
+ */
+ public function insertComment( $value ) {
+ // Just another type of text node, except for tidy p-wrapping.
+ return $this->insertText( '<!--' . $value . '-->', true );
+ }
+
+ /**
+ * Insert text at the appropriate place for inserting a node.
+ * @param string $value
+ * @param bool $isComment
+ * @return string
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#appropriate-place-for-inserting-a-node
+ */
+ public function insertText( $value, $isComment = false ) {
+ if (
+ $this->fosterParentMode &&
+ $this->currentNode->isA( BalanceSets::$tableSectionRowSet )
+ ) {
+ $this->fosterParent( $value );
+ } elseif (
+ $this->config['tidyCompat'] && !$isComment &&
+ $this->currentNode->isA( BalanceSets::$tidyPWrapSet )
+ ) {
+ $this->insertHTMLElement( 'mw:p-wrap', [] );
+ return $this->insertText( $value );
+ } else {
+ $this->currentNode->appendChild( $value );
+ }
+ }
+
+ /**
+ * Insert a BalanceElement at the appropriate place, pushing it
+ * on to the open elements stack.
+ * @param string $namespaceURI The element namespace
+ * @param string $tag The tag name
+ * @param string $attribs Normalized attributes, as a string.
+ * @return BalanceElement
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#insert-a-foreign-element
+ */
+ public function insertForeignElement( $namespaceURI, $tag, $attribs ) {
+ return $this->insertElement(
+ new BalanceElement( $namespaceURI, $tag, $attribs )
+ );
+ }
+
+ /**
+ * Insert an HTML element at the appropriate place, pushing it on to
+ * the open elements stack.
+ * @param string $tag The tag name
+ * @param string $attribs Normalized attributes, as a string.
+ * @return BalanceElement
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#insert-an-html-element
+ */
+ public function insertHTMLElement( $tag, $attribs ) {
+ return $this->insertForeignElement(
+ BalanceSets::HTML_NAMESPACE, $tag, $attribs
+ );
+ }
+
+ /**
+ * Insert an element at the appropriate place and push it on to the
+ * open elements stack.
+ * @param BalanceElement $elt
+ * @return BalanceElement
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#appropriate-place-for-inserting-a-node
+ */
+ public function insertElement( BalanceElement $elt ) {
+ if (
+ $this->currentNode->isHtmlNamed( 'mw:p-wrap' ) &&
+ !$elt->isA( BalanceSets::$tidyInlineSet )
+ ) {
+ // Tidy compatibility.
+ $this->pop();
+ }
+ if (
+ $this->fosterParentMode &&
+ $this->currentNode->isA( BalanceSets::$tableSectionRowSet )
+ ) {
+ $elt = $this->fosterParent( $elt );
+ } else {
+ $this->currentNode->appendChild( $elt );
+ }
+ Assert::invariant( $elt->parent !== null, "$elt must be in tree" );
+ Assert::invariant( $elt->parent !== 'flat', "$elt must not have been previous flattened" );
+ array_push( $this->elements, $elt );
+ $this->currentNode = $elt;
+ return $elt;
+ }
+
+ /**
+ * Determine if the stack has $tag in scope.
+ * @param BalanceElement|array|string $tag
+ * @return bool
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope
+ */
+ public function inScope( $tag ) {
+ return $this->inSpecificScope( $tag, BalanceSets::$inScopeSet );
+ }
+
+ /**
+ * Determine if the stack has $tag in button scope.
+ * @param BalanceElement|array|string $tag
+ * @return bool
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-button-scope
+ */
+ public function inButtonScope( $tag ) {
+ return $this->inSpecificScope( $tag, BalanceSets::inButtonScopeSet() );
+ }
+
+ /**
+ * Determine if the stack has $tag in list item scope.
+ * @param BalanceElement|array|string $tag
+ * @return bool
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-list-item-scope
+ */
+ public function inListItemScope( $tag ) {
+ return $this->inSpecificScope( $tag, BalanceSets::inListItemScopeSet() );
+ }
+
+ /**
+ * Determine if the stack has $tag in table scope.
+ * @param BalanceElement|array|string $tag
+ * @return bool
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-table-scope
+ */
+ public function inTableScope( $tag ) {
+ return $this->inSpecificScope( $tag, BalanceSets::$inTableScopeSet );
+ }
+
+ /**
+ * Determine if the stack has $tag in select scope.
+ * @param BalanceElement|array|string $tag
+ * @return bool
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-select-scope
+ */
+ public function inSelectScope( $tag ) {
+ // Can't use inSpecificScope to implement this, since it involves
+ // *inverting* a set of tags. Implement manually.
+ foreach ( $this as $elt ) {
+ if ( $elt->isA( $tag ) ) {
+ return true;
+ }
+ if ( !$elt->isA( BalanceSets::$inInvertedSelectScopeSet ) ) {
+ return false;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Determine if the stack has $tag in a specific scope, $set.
+ * @param BalanceElement|array|string $tag
+ * @param BalanceElement|array|string $set
+ * @return bool
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-the-specific-scope
+ */
+ public function inSpecificScope( $tag, $set ) {
+ foreach ( $this as $elt ) {
+ if ( $elt->isA( $tag ) ) {
+ return true;
+ }
+ if ( $elt->isA( $set ) ) {
+ return false;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Generate implied end tags.
+ * @param string $butnot
+ * @param bool $thorough True if we should generate end tags thoroughly.
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags
+ */
+ public function generateImpliedEndTags( $butnot = null, $thorough = false ) {
+ $endTagSet = $thorough ?
+ BalanceSets::$thoroughImpliedEndTagsSet :
+ BalanceSets::$impliedEndTagsSet;
+ while ( $this->currentNode ) {
+ if ( $butnot !== null && $this->currentNode->isHtmlNamed( $butnot ) ) {
+ break;
+ }
+ if ( !$this->currentNode->isA( $endTagSet ) ) {
+ break;
+ }
+ $this->pop();
+ }
+ }
+
+ /**
+ * Return the adjusted current node.
+ * @param string $fragmentContext
+ * @return string
+ */
+ public function adjustedCurrentNode( $fragmentContext ) {
+ return ( $fragmentContext && count( $this->elements ) === 1 ) ?
+ $fragmentContext : $this->currentNode;
+ }
+
+ /**
+ * Return an iterator over this stack which visits the current node
+ * first, and the root node last.
+ * @return \Iterator
+ */
+ public function getIterator() {
+ return new ReverseArrayIterator( $this->elements );
+ }
+
+ /**
+ * Return the BalanceElement at the given position $idx, where
+ * position 0 represents the root element.
+ * @param int $idx
+ * @return BalanceElement
+ */
+ public function node( $idx ) {
+ return $this->elements[ $idx ];
+ }
+
+ /**
+ * Replace the element at position $idx in the BalanceStack with $elt.
+ * @param int $idx
+ * @param BalanceElement $elt
+ */
+ public function replaceAt( $idx, BalanceElement $elt ) {
+ Assert::precondition(
+ $this->elements[$idx]->parent !== 'flat',
+ 'Replaced element should not have already been flattened.'
+ );
+ Assert::precondition(
+ $elt->parent !== 'flat',
+ 'New element should not have already been flattened.'
+ );
+ $this->elements[$idx] = $elt;
+ if ( $idx === count( $this->elements ) - 1 ) {
+ $this->currentNode = $elt;
+ }
+ }
+
+ /**
+ * Return the position of the given BalanceElement, set, or
+ * HTML tag name string in the BalanceStack.
+ * @param BalanceElement|array|string $tag
+ * @return int
+ */
+ public function indexOf( $tag ) {
+ for ( $i = count( $this->elements ) - 1; $i >= 0; $i-- ) {
+ if ( $this->elements[$i]->isA( $tag ) ) {
+ return $i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Return the number of elements currently in the BalanceStack.
+ * @return int
+ */
+ public function length() {
+ return count( $this->elements );
+ }
+
+ /**
+ * Remove the current node from the BalanceStack, flattening it
+ * in the process.
+ */
+ public function pop() {
+ $elt = array_pop( $this->elements );
+ if ( count( $this->elements ) ) {
+ $this->currentNode = $this->elements[ count( $this->elements ) - 1 ];
+ } else {
+ $this->currentNode = null;
+ }
+ if ( !$elt->isHtmlNamed( 'mw:p-wrap' ) ) {
+ $elt->flatten( $this->config );
+ }
+ }
+
+ /**
+ * Remove all nodes up to and including position $idx from the
+ * BalanceStack, flattening them in the process.
+ * @param int $idx
+ */
+ public function popTo( $idx ) {
+ for ( $length = count( $this->elements ); $length > $idx; $length-- ) {
+ $this->pop();
+ }
+ }
+
+ /**
+ * Pop elements off the stack up to and including the first
+ * element with the specified HTML tagname (or matching the given
+ * set).
+ * @param BalanceElement|array|string $tag
+ */
+ public function popTag( $tag ) {
+ while ( $this->currentNode ) {
+ if ( $this->currentNode->isA( $tag ) ) {
+ $this->pop();
+ break;
+ }
+ $this->pop();
+ }
+ }
+
+ /**
+ * Pop elements off the stack *not including* the first element
+ * in the specified set.
+ * @param BalanceElement|array|string $set
+ */
+ public function clearToContext( $set ) {
+ // Note that we don't loop to 0. Never pop the <html> elt off.
+ for ( $length = count( $this->elements ); $length > 1; $length-- ) {
+ if ( $this->currentNode->isA( $set ) ) {
+ break;
+ }
+ $this->pop();
+ }
+ }
+
+ /**
+ * Remove the given $elt from the BalanceStack, optionally
+ * flattening it in the process.
+ * @param BalanceElement $elt The element to remove.
+ * @param bool $flatten Whether to flatten the removed element.
+ */
+ public function removeElement( BalanceElement $elt, $flatten = true ) {
+ Assert::parameter(
+ $elt->parent !== 'flat',
+ '$elt',
+ '$elt should not already have been flattened.'
+ );
+ Assert::parameter(
+ $elt->parent->parent !== 'flat',
+ '$elt',
+ 'The parent of $elt should not already have been flattened.'
+ );
+ $idx = array_search( $elt, $this->elements, true );
+ Assert::parameter( $idx !== false, '$elt', 'must be in stack' );
+ array_splice( $this->elements, $idx, 1 );
+ if ( $idx === count( $this->elements ) ) {
+ $this->currentNode = $this->elements[$idx - 1];
+ }
+ if ( $flatten ) {
+ // serialize $elt into its parent
+ // otherwise, it will eventually serialize when the parent
+ // is serialized, we just hold onto the memory for its
+ // tree of objects a little longer.
+ $elt->flatten( $this->config );
+ }
+ Assert::postcondition(
+ array_search( $elt, $this->elements, true ) === false,
+ '$elt should no longer be in open elements stack'
+ );
+ }
+
+ /**
+ * Find $a in the BalanceStack and insert $b after it.
+ * @param BalanceElement $a
+ * @param BalanceElement $b
+ */
+ public function insertAfter( BalanceElement $a, BalanceElement $b ) {
+ $idx = $this->indexOf( $a );
+ Assert::parameter( $idx !== false, '$a', 'must be in stack' );
+ if ( $idx === count( $this->elements ) - 1 ) {
+ array_push( $this->elements, $b );
+ $this->currentNode = $b;
+ } else {
+ array_splice( $this->elements, $idx + 1, 0, [ $b ] );
+ }
+ }
+
+ // Fostering and adoption.
+
+ /**
+ * Foster parent the given $elt in the stack of open elements.
+ * @param BalanceElement|string $elt
+ * @return BalanceElement|string
+ *
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#foster-parent
+ */
+ private function fosterParent( $elt ) {
+ $lastTable = $this->indexOf( 'table' );
+ $lastTemplate = $this->indexOf( 'template' );
+ $parent = null;
+ $before = null;
+
+ if ( $lastTemplate >= 0 && ( $lastTable < 0 || $lastTemplate > $lastTable ) ) {
+ $parent = $this->elements[$lastTemplate];
+ } elseif ( $lastTable >= 0 ) {
+ $parent = $this->elements[$lastTable]->parent;
+ // Assume all tables have parents, since we're not running scripts!
+ Assert::invariant(
+ $parent !== null, "All tables should have parents"
+ );
+ $before = $this->elements[$lastTable];
+ } else {
+ $parent = $this->elements[0]; // the `html` element.
+ }
+
+ if ( $this->config['tidyCompat'] ) {
+ if ( is_string( $elt ) ) {
+ // We're fostering text: do we need a p-wrapper?
+ if ( $parent->isA( BalanceSets::$tidyPWrapSet ) ) {
+ $this->insertHTMLElement( 'mw:p-wrap', [] );
+ $this->insertText( $elt );
+ return $elt;
+ }
+ } else {
+ // We're fostering an element; do we need to merge p-wrappers?
+ if ( $elt->isHtmlNamed( 'mw:p-wrap' ) ) {
+ $idx = $before ?
+ array_search( $before, $parent->children, true ) :
+ count( $parent->children );
+ $after = $idx > 0 ? $parent->children[$idx - 1] : '';
+ if (
+ $after instanceof BalanceElement &&
+ $after->isHtmlNamed( 'mw:p-wrap' )
+ ) {
+ return $after; // Re-use existing p-wrapper.
+ }
+ }
+ }
+ }
+
+ if ( $before ) {
+ $parent->insertBefore( $before, $elt );
+ } else {
+ $parent->appendChild( $elt );
+ }
+ return $elt;
+ }
+
+ /**
+ * Run the "adoption agency algoritm" (AAA) for the given subject
+ * tag name.
+ * @param string $tag The subject tag name.
+ * @param BalanceActiveFormattingElements $afe The current
+ * active formatting elements list.
+ * @return true if the adoption agency algorithm "did something", false
+ * if more processing is required by the caller.
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#adoption-agency-algorithm
+ */
+ public function adoptionAgency( $tag, $afe ) {
+ // If the current node is an HTML element whose tag name is subject,
+ // and the current node is not in the list of active formatting
+ // elements, then pop the current node off the stack of open
+ // elements and abort these steps.
+ if (
+ $this->currentNode->isHtmlNamed( $tag ) &&
+ !$afe->isInList( $this->currentNode )
+ ) {
+ $this->pop();
+ return true; // no more handling required
+ }
+
+ // Outer loop: If outer loop counter is greater than or
+ // equal to eight, then abort these steps.
+ for ( $outer = 0; $outer < 8; $outer++ ) {
+ // Let the formatting element be the last element in the list
+ // of active formatting elements that: is between the end of
+ // the list and the last scope marker in the list, if any, or
+ // the start of the list otherwise, and has the same tag name
+ // as the token.
+ $fmtElt = $afe->findElementByTag( $tag );
+
+ // If there is no such node, then abort these steps and instead
+ // act as described in the "any other end tag" entry below.
+ if ( !$fmtElt ) {
+ return false; // false means handle by the default case
+ }
+
+ // Otherwise, if there is such a node, but that node is not in
+ // the stack of open elements, then this is a parse error;
+ // remove the element from the list, and abort these steps.
+ $index = $this->indexOf( $fmtElt );
+ if ( $index < 0 ) {
+ $afe->remove( $fmtElt );
+ return true; // true means no more handling required
+ }
+
+ // Otherwise, if there is such a node, and that node is also in
+ // the stack of open elements, but the element is not in scope,
+ // then this is a parse error; ignore the token, and abort
+ // these steps.
+ if ( !$this->inScope( $fmtElt ) ) {
+ return true;
+ }
+
+ // Let the furthest block be the topmost node in the stack of
+ // open elements that is lower in the stack than the formatting
+ // element, and is an element in the special category. There
+ // might not be one.
+ $furthestBlock = null;
+ $furthestBlockIndex = -1;
+ $stackLength = $this->length();
+ for ( $i = $index + 1; $i < $stackLength; $i++ ) {
+ if ( $this->node( $i )->isA( BalanceSets::$specialSet ) ) {
+ $furthestBlock = $this->node( $i );
+ $furthestBlockIndex = $i;
+ break;
+ }
+ }
+
+ // If there is no furthest block, then the UA must skip the
+ // subsequent steps and instead just pop all the nodes from the
+ // bottom of the stack of open elements, from the current node
+ // up to and including the formatting element, and remove the
+ // formatting element from the list of active formatting
+ // elements.
+ if ( !$furthestBlock ) {
+ $this->popTag( $fmtElt );
+ $afe->remove( $fmtElt );
+ return true;
+ }
+
+ // Let the common ancestor be the element immediately above
+ // the formatting element in the stack of open elements.
+ $ancestor = $this->node( $index - 1 );
+
+ // Let a bookmark note the position of the formatting
+ // element in the list of active formatting elements
+ // relative to the elements on either side of it in the
+ // list.
+ $BOOKMARK = new BalanceElement( '[bookmark]', '[bookmark]', [] );
+ $afe->insertAfter( $fmtElt, $BOOKMARK );
+
+ // Let node and last node be the furthest block.
+ $node = $furthestBlock;
+ $lastNode = $furthestBlock;
+ $nodeIndex = $furthestBlockIndex;
+ $isAFE = false;
+
+ // Inner loop
+ for ( $inner = 1; true; $inner++ ) {
+ // Let node be the element immediately above node in
+ // the stack of open elements, or if node is no longer
+ // in the stack of open elements (e.g. because it got
+ // removed by this algorithm), the element that was
+ // immediately above node in the stack of open elements
+ // before node was removed.
+ $node = $this->node( --$nodeIndex );
+
+ // If node is the formatting element, then go
+ // to the next step in the overall algorithm.
+ if ( $node === $fmtElt ) break;
+
+ // If the inner loop counter is greater than three and node
+ // is in the list of active formatting elements, then remove
+ // node from the list of active formatting elements.
+ $isAFE = $afe->isInList( $node );
+ if ( $inner > 3 && $isAFE ) {
+ $afe->remove( $node );
+ $isAFE = false;
+ }
+
+ // If node is not in the list of active formatting
+ // elements, then remove node from the stack of open
+ // elements and then go back to the step labeled inner
+ // loop.
+ if ( !$isAFE ) {
+ // Don't flatten here, since we're about to relocate
+ // parts of this $node.
+ $this->removeElement( $node, false );
+ continue;
+ }
+
+ // Create an element for the token for which the
+ // element node was created with common ancestor as
+ // the intended parent, replace the entry for node
+ // in the list of active formatting elements with an
+ // entry for the new element, replace the entry for
+ // node in the stack of open elements with an entry for
+ // the new element, and let node be the new element.
+ $newElt = new BalanceElement(
+ $node->namespaceURI, $node->localName, $node->attribs );
+ $afe->replace( $node, $newElt );
+ $this->replaceAt( $nodeIndex, $newElt );
+ $node = $newElt;
+
+ // If last node is the furthest block, then move the
+ // aforementioned bookmark to be immediately after the
+ // new node in the list of active formatting elements.
+ if ( $lastNode === $furthestBlock ) {
+ $afe->remove( $BOOKMARK );
+ $afe->insertAfter( $newElt, $BOOKMARK );
+ }
+
+ // Insert last node into node, first removing it from
+ // its previous parent node if any.
+ $node->appendChild( $lastNode );
+
+ // Let last node be node.
+ $lastNode = $node;
+ }
+
+ // If the common ancestor node is a table, tbody, tfoot,
+ // thead, or tr element, then, foster parent whatever last
+ // node ended up being in the previous step, first removing
+ // it from its previous parent node if any.
+ if (
+ $this->fosterParentMode &&
+ $ancestor->isA( BalanceSets::$tableSectionRowSet )
+ ) {
+ $this->fosterParent( $lastNode );
+ } else {
+ // Otherwise, append whatever last node ended up being in
+ // the previous step to the common ancestor node, first
+ // removing it from its previous parent node if any.
+ $ancestor->appendChild( $lastNode );
+ }
+
+ // Create an element for the token for which the
+ // formatting element was created, with furthest block
+ // as the intended parent.
+ $newElt2 = new BalanceElement(
+ $fmtElt->namespaceURI, $fmtElt->localName, $fmtElt->attribs );
+
+ // Take all of the child nodes of the furthest block and
+ // append them to the element created in the last step.
+ $newElt2->adoptChildren( $furthestBlock );
+
+ // Append that new element to the furthest block.
+ $furthestBlock->appendChild( $newElt2 );
+
+ // Remove the formatting element from the list of active
+ // formatting elements, and insert the new element into the
+ // list of active formatting elements at the position of
+ // the aforementioned bookmark.
+ $afe->remove( $fmtElt );
+ $afe->replace( $BOOKMARK, $newElt2 );
+
+ // Remove the formatting element from the stack of open
+ // elements, and insert the new element into the stack of
+ // open elements immediately below the position of the
+ // furthest block in that stack.
+ $this->removeElement( $fmtElt );
+ $this->insertAfter( $furthestBlock, $newElt2 );
+ }
+
+ return true;
+ }
+
+ /**
+ * Return the contents of the open elements stack as a string for
+ * debugging.
+ * @return string
+ */
+ public function __toString() {
+ $r = [];
+ foreach ( $this->elements as $elt ) {
+ array_push( $r, $elt->localName );
+ }
+ return implode( $r, ' ' );
+ }
+}
+
+/**
+ * A pseudo-element used as a marker in the list of active formatting elements
+ *
+ * @ingroup Parser
+ * @since 1.27
+ */
+class BalanceMarker {
+ public $nextAFE;
+ public $prevAFE;
+}
+
+/**
+ * The list of active formatting elements, which is used to handle
+ * mis-nested formatting element tags in the HTML5 tree builder
+ * specification.
+ *
+ * @ingroup Parser
+ * @since 1.27
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#list-of-active-formatting-elements
+ */
+class BalanceActiveFormattingElements {
+ /** The last (most recent) element in the list */
+ private $tail;
+
+ /** The first (least recent) element in the list */
+ private $head;
+
+ /**
+ * An array of arrays representing the population of elements in each bucket
+ * according to the Noah's Ark clause. The outer array is stack-like, with each
+ * integer-indexed element representing a segment of the list, bounded by
+ * markers. The first element represents the segment of the list before the
+ * first marker.
+ *
+ * The inner arrays are indexed by "Noah key", which is a string which uniquely
+ * identifies each bucket according to the rules in the spec. The value in
+ * the inner array is the first (least recently inserted) element in the bucket,
+ * and subsequent members of the bucket can be found by iterating through the
+ * singly-linked list via $node->nextNoah.
+ *
+ * This is optimised for the most common case of inserting into a bucket
+ * with zero members, and deleting a bucket containing one member. In the
+ * worst case, iteration through the list is still O(1) in the document
+ * size, since each bucket can have at most 3 members.
+ */
+ private $noahTableStack = [ [] ];
+
+ public function __destruct() {
+ $next = null;
+ for ( $node = $this->head; $node; $node = $next ) {
+ $next = $node->nextAFE;
+ $node->prevAFE = $node->nextAFE = $node->nextNoah = null;
+ }
+ $this->head = $this->tail = $this->noahTableStack = null;
+ }
+
+ public function insertMarker() {
+ $elt = new BalanceMarker;
+ if ( $this->tail ) {
+ $this->tail->nextAFE = $elt;
+ $elt->prevAFE = $this->tail;
+ } else {
+ $this->head = $elt;
+ }
+ $this->tail = $elt;
+ $this->noahTableStack[] = [];
+ }
+
+ /**
+ * Follow the steps required when the spec requires us to "push onto the
+ * list of active formatting elements".
+ * @param BalanceElement $elt
+ */
+ public function push( BalanceElement $elt ) {
+ // Must not be in the list already
+ if ( $elt->prevAFE !== null || $this->head === $elt ) {
+ throw new ParameterAssertionException( '$elt',
+ 'Cannot insert a node into the AFE list twice' );
+ }
+
+ // "Noah's Ark clause" -- if there are already three copies of
+ // this element before we encounter a marker, then drop the last
+ // one.
+ $noahKey = $elt->getNoahKey();
+ $table =& $this->noahTableStack[ count( $this->noahTableStack ) - 1 ];
+ if ( !isset( $table[$noahKey] ) ) {
+ $table[$noahKey] = $elt;
+ } else {
+ $count = 1;
+ $head = $tail = $table[$noahKey];
+ while ( $tail->nextNoah ) {
+ $tail = $tail->nextNoah;
+ $count++;
+ }
+ if ( $count >= 3 ) {
+ $this->remove( $head );
+ }
+ $tail->nextNoah = $elt;
+ }
+ // Add to the main AFE list
+ if ( $this->tail ) {
+ $this->tail->nextAFE = $elt;
+ $elt->prevAFE = $this->tail;
+ } else {
+ $this->head = $elt;
+ }
+ $this->tail = $elt;
+ }
+
+ /**
+ * Follow the steps required when the spec asks us to "clear the list of
+ * active formatting elements up to the last marker".
+ */
+ public function clearToMarker() {
+ // Iterate back through the list starting from the tail
+ $tail = $this->tail;
+ while ( $tail && !( $tail instanceof BalanceMarker ) ) {
+ // Unlink the element
+ $prev = $tail->prevAFE;
+ $tail->prevAFE = null;
+ if ( $prev ) {
+ $prev->nextAFE = null;
+ }
+ $tail->nextNoah = null;
+ $tail = $prev;
+ }
+ // If we finished on a marker, unlink it and pop it off the Noah table stack
+ if ( $tail ) {
+ $prev = $tail->prevAFE;
+ if ( $prev ) {
+ $prev->nextAFE = null;
+ }
+ $tail = $prev;
+ array_pop( $this->noahTableStack );
+ } else {
+ // No marker: wipe the top-level Noah table (which is the only one)
+ $this->noahTableStack[0] = [];
+ }
+ // If we removed all the elements, clear the head pointer
+ if ( !$tail ) {
+ $this->head = null;
+ }
+ $this->tail = $tail;
+ }
+
+ /**
+ * Find and return the last element with the specified tag between the
+ * end of the list and the last marker on the list.
+ * Used when parsing &lt;a&gt; "in body mode".
+ * @param string $tag
+ * @return null|Node
+ */
+ public function findElementByTag( $tag ) {
+ $elt = $this->tail;
+ while ( $elt && !( $elt instanceof BalanceMarker ) ) {
+ if ( $elt->localName === $tag ) {
+ return $elt;
+ }
+ $elt = $elt->prevAFE;
+ }
+ return null;
+ }
+
+ /**
+ * Determine whether an element is in the list of formatting elements.
+ * @param BalanceElement $elt
+ * @return bool
+ */
+ public function isInList( BalanceElement $elt ) {
+ return $this->head === $elt || $elt->prevAFE;
+ }
+
+ /**
+ * Find the element $elt in the list and remove it.
+ * Used when parsing &lt;a&gt; in body mode.
+ *
+ * @param BalanceElement $elt
+ */
+ public function remove( BalanceElement $elt ) {
+ if ( $this->head !== $elt && !$elt->prevAFE ) {
+ throw new ParameterAssertionException( '$elt',
+ "Attempted to remove an element which is not in the AFE list" );
+ }
+ // Update head and tail pointers
+ if ( $this->head === $elt ) {
+ $this->head = $elt->nextAFE;
+ }
+ if ( $this->tail === $elt ) {
+ $this->tail = $elt->prevAFE;
+ }
+ // Update previous element
+ if ( $elt->prevAFE ) {
+ $elt->prevAFE->nextAFE = $elt->nextAFE;
+ }
+ // Update next element
+ if ( $elt->nextAFE ) {
+ $elt->nextAFE->prevAFE = $elt->prevAFE;
+ }
+ // Clear pointers so that isInList() etc. will work
+ $elt->prevAFE = $elt->nextAFE = null;
+ // Update Noah list
+ $this->removeFromNoahList( $elt );
+ }
+
+ private function addToNoahList( BalanceElement $elt ) {
+ $noahKey = $elt->getNoahKey();
+ $table =& $this->noahTableStack[ count( $this->noahTableStack ) - 1 ];
+ if ( !isset( $table[$noahKey] ) ) {
+ $table[$noahKey] = $elt;
+ } else {
+ $tail = $table[$noahKey];
+ while ( $tail->nextNoah ) {
+ $tail = $tail->nextNoah;
+ }
+ $tail->nextNoah = $elt;
+ }
+ }
+
+ private function removeFromNoahList( BalanceElement $elt ) {
+ $table =& $this->noahTableStack[ count( $this->noahTableStack ) - 1 ];
+ $key = $elt->getNoahKey();
+ $noahElt = $table[$key];
+ if ( $noahElt === $elt ) {
+ if ( $noahElt->nextNoah ) {
+ $table[$key] = $noahElt->nextNoah;
+ $noahElt->nextNoah = null;
+ } else {
+ unset( $table[$key] );
+ }
+ } else {
+ do {
+ $prevNoahElt = $noahElt;
+ $noahElt = $prevNoahElt->nextNoah;
+ if ( $noahElt === $elt ) {
+ // Found it, unlink
+ $prevNoahElt->nextNoah = $elt->nextNoah;
+ $elt->nextNoah = null;
+ break;
+ }
+ } while ( $noahElt );
+ }
+ }
+
+ /**
+ * Find element $a in the list and replace it with element $b
+ *
+ * @param BalanceElement $a
+ * @param BalanceElement $b
+ */
+ public function replace( BalanceElement $a, BalanceElement $b ) {
+ if ( $this->head !== $a && !$a->prevAFE ) {
+ throw new ParameterAssertionException( '$a',
+ "Attempted to replace an element which is not in the AFE list" );
+ }
+ // Update head and tail pointers
+ if ( $this->head === $a ) {
+ $this->head = $b;
+ }
+ if ( $this->tail === $a ) {
+ $this->tail = $b;
+ }
+ // Update previous element
+ if ( $a->prevAFE ) {
+ $a->prevAFE->nextAFE = $b;
+ }
+ // Update next element
+ if ( $a->nextAFE ) {
+ $a->nextAFE->prevAFE = $b;
+ }
+ $b->prevAFE = $a->prevAFE;
+ $b->nextAFE = $a->nextAFE;
+ $a->nextAFE = $a->prevAFE = null;
+ // Update Noah list
+ $this->removeFromNoahList( $a );
+ $this->addToNoahList( $b );
+ }
+
+ /**
+ * Find $a in the list and insert $b after it.
+
+ * @param BalanceElement $a
+ * @param BalanceElement $b
+ */
+ public function insertAfter( BalanceElement $a, BalanceElement $b ) {
+ if ( $this->head !== $a && !$a->prevAFE ) {
+ throw new ParameterAssertionException( '$a',
+ "Attempted to insert after an element which is not in the AFE list" );
+ }
+ if ( $this->tail === $a ) {
+ $this->tail = $b;
+ }
+ if ( $a->nextAFE ) {
+ $a->nextAFE->prevAFE = $b;
+ }
+ $b->nextAFE = $a->nextAFE;
+ $b->prevAFE = $a;
+ $a->nextAFE = $b;
+ $this->addToNoahList( $b );
+ }
+
+ // @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong
+ /**
+ * Reconstruct the active formatting elements.
+ * @param BalanceStack $stack The open elements stack
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#reconstruct-the-active-formatting-elements
+ */
+ // @codingStandardsIgnoreEnd
+ public function reconstruct( $stack ) {
+ $entry = $this->tail;
+ // If there are no entries in the list of active formatting elements,
+ // then there is nothing to reconstruct
+ if ( !$entry ) {
+ return;
+ }
+ // If the last is a marker, do nothing.
+ if ( $entry instanceof BalanceMarker ) {
+ return;
+ }
+ // Or if it is an open element, do nothing.
+ if ( $stack->indexOf( $entry ) >= 0 ) {
+ return;
+ }
+
+ // Loop backward through the list until we find a marker or an
+ // open element
+ $foundIt = false;
+ while ( $entry->prevAFE ) {
+ $entry = $entry->prevAFE;
+ if ( $entry instanceof BalanceMarker || $stack->indexOf( $entry ) >= 0 ) {
+ $foundIt = true;
+ break;
+ }
+ }
+
+ // Now loop forward, starting from the element after the current one (or
+ // the first element if we didn't find a marker or open element),
+ // recreating formatting elements and pushing them back onto the list
+ // of open elements.
+ if ( $foundIt ) {
+ $entry = $entry->nextAFE;
+ }
+ do {
+ $newElement = $stack->insertHTMLElement(
+ $entry->localName,
+ $entry->attribs );
+ $this->replace( $entry, $newElement );
+ $entry = $newElement->nextAFE;
+ } while ( $entry );
+ }
+
+ /**
+ * Get a string representation of the AFE list, for debugging
+ */
+ public function __toString() {
+ $prev = null;
+ $s = '';
+ for ( $node = $this->head; $node; $prev = $node, $node = $node->nextAFE ) {
+ if ( $node instanceof BalanceMarker ) {
+ $s .= "MARKER\n";
+ continue;
+ }
+ $s .= $node->localName . '#' . substr( md5( spl_object_hash( $node ) ), 0, 8 );
+ if ( $node->nextNoah ) {
+ $s .= " (noah sibling: {$node->nextNoah->localName}#" .
+ substr( md5( spl_object_hash( $node->nextNoah ) ), 0, 8 ) .
+ ')';
+ }
+ if ( $node->nextAFE && $node->nextAFE->prevAFE !== $node ) {
+ $s .= " (reverse link is wrong!)";
+ }
+ $s .= "\n";
+ }
+ if ( $prev !== $this->tail ) {
+ $s .= "(tail pointer is wrong!)\n";
+ }
+ return $s;
+ }
+}
+
+/**
+ * An implementation of the tree building portion of the HTML5 parsing
+ * spec.
+ *
+ * This is used to balance and tidy output so that the result can
+ * always be cleanly serialized/deserialized by an HTML5 parser. It
+ * does *not* guarantee "conforming" output -- the HTML5 spec contains
+ * a number of constraints which are not enforced by the HTML5 parsing
+ * process. But the result will be free of gross errors: misnested or
+ * unclosed tags, for example, and will be unchanged by spec-complient
+ * parsing followed by serialization.
+ *
+ * The tree building stage is structured as a state machine.
+ * When comparing the implementation to
+ * https://www.w3.org/TR/html5/syntax.html#tree-construction
+ * note that each state is implemented as a function with a
+ * name ending in `Mode` (because the HTML spec refers to them
+ * as insertion modes). The current insertion mode is held by
+ * the $parseMode property.
+ *
+ * The following simplifications have been made:
+ * - We handle body content only (ie, we start `in body`.)
+ * - The document is never in "quirks mode".
+ * - All occurrences of < and > have been entity escaped, so we
+ * can parse tags by simply splitting on those two characters.
+ * (This also simplifies the handling of < inside <textarea>.)
+ * The character < must not appear inside comments.
+ * Similarly, all attributes have been "cleaned" and are double-quoted
+ * and escaped.
+ * - All null characters are assumed to have been removed.
+ * - The following elements are disallowed: <html>, <head>, <body>, <frameset>,
+ * <frame>, <plaintext>, <xmp>, <iframe>,
+ * <noembed>, <noscript>, <script>, <title>. As a result,
+ * further simplifications can be made:
+ * - `frameset-ok` is not tracked.
+ * - `head element pointer` is not tracked (but presumed non-null)
+ * - Tokenizer has only a single mode. (<textarea> wants RCDATA and
+ * <style>/<noframes> want RAWTEXT modes which we only loosely emulate.)
+ *
+ * We generally mark places where we omit cases from the spec due to
+ * disallowed elements with a comment: `// OMITTED: <element-name>`.
+ *
+ * The HTML spec keeps a flag during the parsing process to track
+ * whether or not a "parse error" has been encountered. We don't
+ * bother to track that flag, we just implement the error-handling
+ * process as specified.
+ *
+ * @ingroup Parser
+ * @since 1.27
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#tree-construction
+ */
+class Balancer {
+ private $parseMode;
+ /** @var \Iterator */
+ private $bitsIterator;
+ private $allowedHtmlElements;
+ /** @var BalanceActiveFormattingElements */
+ private $afe;
+ /** @var BalanceStack */
+ private $stack;
+ private $strict;
+ private $allowComments;
+ private $config;
+
+ private $textIntegrationMode;
+ private $pendingTableText;
+ private $originalInsertionMode;
+ private $fragmentContext;
+ private $formElementPointer;
+ private $ignoreLinefeed;
+ private $inRCDATA;
+ private $inRAWTEXT;
+
+ /** @var callable|null */
+ private $processingCallback;
+ /** @var array */
+ private $processingArgs;
+
+ /**
+ * Valid HTML5 comments.
+ * Regex borrowed from Tim Starling's "remex-html" project.
+ */
+ const VALID_COMMENT_REGEX = "~ !--
+ ( # 1. Comment match detector
+ > | -> | # Invalid short close
+ ( # 2. Comment contents
+ (?:
+ (?! --> )
+ (?! --!> )
+ (?! --! \z )
+ (?! -- \z )
+ (?! - \z )
+ .
+ )*+
+ )
+ ( # 3. Comment close
+ --> | # Normal close
+ --!> | # Comment end bang
+ ( # 4. Indicate matches requiring EOF
+ --! | # EOF in comment end bang state
+ -- | # EOF in comment end state
+ - | # EOF in comment end dash state
+ (?#nothing) # EOF in comment state
+ )
+ )
+ )
+ ([^<]*) \z # 5. Non-tag text after the comment
+ ~xs";
+
+ /**
+ * Create a new Balancer.
+ * @param array $config Balancer configuration. Includes:
+ * 'strict' : boolean, defaults to false.
+ * When true, enforces syntactic constraints on input:
+ * all non-tag '<' must be escaped, all attributes must be
+ * separated by a single space and double-quoted. This is
+ * consistent with the output of the Sanitizer.
+ * 'allowedHtmlElements' : array, defaults to null.
+ * When present, the keys of this associative array give
+ * the acceptable HTML tag names. When not present, no
+ * tag sanitization is done.
+ * 'tidyCompat' : boolean, defaults to false.
+ * When true, the serialization algorithm is tweaked to
+ * provide historical compatibility with the old "tidy"
+ * program: <p>-wrapping is done to the children of
+ * <body> and <blockquote> elements, and empty elements
+ * are removed. The <pre>/<listing>/<textarea> serialization
+ * is also tweaked to allow lossless round trips.
+ * (See: https://github.com/whatwg/html/issues/944)
+ * 'allowComments': boolean, defaults to true.
+ * When true, allows HTML comments in the input.
+ * The Sanitizer generally strips all comments, so if you
+ * are running on sanitized output you can set this to
+ * false to get a bit more performance.
+ */
+ public function __construct( array $config = [] ) {
+ $this->config = $config = $config + [
+ 'strict' => false,
+ 'allowedHtmlElements' => null,
+ 'tidyCompat' => false,
+ 'allowComments' => true,
+ ];
+ $this->allowedHtmlElements = $config['allowedHtmlElements'];
+ $this->strict = $config['strict'];
+ $this->allowComments = $config['allowComments'];
+ if ( $this->allowedHtmlElements !== null ) {
+ // Sanity check!
+ $bad = array_uintersect_assoc(
+ $this->allowedHtmlElements,
+ BalanceSets::$unsupportedSet[BalanceSets::HTML_NAMESPACE],
+ function ( $a, $b ) {
+ // Ignore the values (just intersect the keys) by saying
+ // all values are equal to each other.
+ return 0;
+ }
+ );
+ if ( count( $bad ) > 0 ) {
+ $badstr = implode( array_keys( $bad ), ',' );
+ throw new ParameterAssertionException(
+ '$config',
+ 'Balance attempted with sanitization including ' .
+ "unsupported elements: {$badstr}"
+ );
+ }
+ }
+ }
+
+ /**
+ * Return a balanced HTML string for the HTML fragment given by $text,
+ * subject to the caveats listed in the class description. The result
+ * will typically be idempotent -- that is, rebalancing the output
+ * would result in no change.
+ *
+ * @param string $text The markup to be balanced
+ * @param callable $processingCallback Callback to do any variable or
+ * parameter replacements in HTML attributes values
+ * @param array|bool $processingArgs Arguments for the processing callback
+ * @return string The balanced markup
+ */
+ public function balance( $text, $processingCallback = null, $processingArgs = [] ) {
+ $this->parseMode = 'inBodyMode';
+ $this->bitsIterator = new ExplodeIterator( '<', $text );
+ $this->afe = new BalanceActiveFormattingElements();
+ $this->stack = new BalanceStack( $this->config );
+ $this->processingCallback = $processingCallback;
+ $this->processingArgs = $processingArgs;
+
+ $this->textIntegrationMode =
+ $this->ignoreLinefeed =
+ $this->inRCDATA =
+ $this->inRAWTEXT = false;
+
+ // The stack is constructed with an <html> element already on it.
+ // Set this up as a fragment parsed with <body> as the context.
+ $this->fragmentContext =
+ new BalanceElement( BalanceSets::HTML_NAMESPACE, 'body', [] );
+ $this->resetInsertionMode();
+ $this->formElementPointer = null;
+ for ( $e = $this->fragmentContext; $e != null; $e = $e->parent ) {
+ if ( $e->isHtmlNamed( 'form' ) ) {
+ $this->formElementPointer = $e;
+ break;
+ }
+ }
+
+ // First element is text not tag
+ $x = $this->bitsIterator->current();
+ $this->bitsIterator->next();
+ $this->insertToken( 'text', str_replace( '>', '&gt;', $x ) );
+ // Now process each tag.
+ while ( $this->bitsIterator->valid() ) {
+ $this->advance();
+ }
+ $this->insertToken( 'eof', null );
+ $result = $this->stack->getOutput();
+ // Free memory before returning.
+ $this->bitsIterator = null;
+ $this->afe = null;
+ $this->stack = null;
+ $this->fragmentContext = null;
+ $this->formElementPointer = null;
+ return $result;
+ }
+
+ /**
+ * Pass a token to the tree builder. The $token will be one of the
+ * strings "tag", "endtag", or "text".
+ */
+ private function insertToken( $token, $value, $attribs = null, $selfClose = false ) {
+ // validate tags against $unsupportedSet
+ if ( $token === 'tag' || $token === 'endtag' ) {
+ if ( isset( BalanceSets::$unsupportedSet[BalanceSets::HTML_NAMESPACE][$value] ) ) {
+ // As described in "simplifications" above, these tags are
+ // not supported in the balancer.
+ Assert::invariant(
+ !$this->strict,
+ "Unsupported $token <$value> found."
+ );
+ return false;
+ }
+ } elseif ( $token === 'text' && $value === '' ) {
+ // Don't actually inject the empty string as a text token.
+ return true;
+ }
+ // Support pre/listing/textarea by suppressing initial linefeed
+ if ( $this->ignoreLinefeed ) {
+ $this->ignoreLinefeed = false;
+ if ( $token === 'text' ) {
+ if ( $value[0] === "\n" ) {
+ if ( $value === "\n" ) {
+ // Nothing would be left, don't inject the empty string.
+ return true;
+ }
+ $value = substr( $value, 1 );
+ }
+ }
+ }
+ // Some hoops we have to jump through
+ $adjusted = $this->stack->adjustedCurrentNode( $this->fragmentContext );
+
+ // The spec calls this the "tree construction dispatcher".
+ $isForeign = true;
+ if (
+ $this->stack->length() === 0 ||
+ $adjusted->isHtml() ||
+ $token === 'eof'
+ ) {
+ $isForeign = false;
+ } elseif ( $adjusted->isMathmlTextIntegrationPoint() ) {
+ if ( $token === 'text' ) {
+ $isForeign = false;
+ } elseif (
+ $token === 'tag' &&
+ $value !== 'mglyph' && $value !== 'malignmark'
+ ) {
+ $isForeign = false;
+ }
+ } elseif (
+ $adjusted->namespaceURI === BalanceSets::MATHML_NAMESPACE &&
+ $adjusted->localName === 'annotation-xml' &&
+ $token === 'tag' && $value === 'svg'
+ ) {
+ $isForeign = false;
+ } elseif (
+ $adjusted->isHtmlIntegrationPoint() &&
+ ( $token === 'tag' || $token === 'text' )
+ ) {
+ $isForeign = false;
+ }
+ if ( $isForeign ) {
+ return $this->insertForeignToken( $token, $value, $attribs, $selfClose );
+ } else {
+ $func = $this->parseMode;
+ return $this->$func( $token, $value, $attribs, $selfClose );
+ }
+ }
+
+ private function insertForeignToken( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' ) {
+ $this->stack->insertText( $value );
+ return true;
+ } elseif ( $token === 'comment' ) {
+ $this->stack->insertComment( $value );
+ return true;
+ } elseif ( $token === 'tag' ) {
+ switch ( $value ) {
+ case 'font':
+ if ( isset( $attribs['color'] )
+ || isset( $attribs['face'] )
+ || isset( $attribs['size'] )
+ ) {
+ break;
+ }
+ // otherwise, fall through
+ case 'b':
+ case 'big':
+ case 'blockquote':
+ case 'body':
+ case 'br':
+ case 'center':
+ case 'code':
+ case 'dd':
+ case 'div':
+ case 'dl':
+ case 'dt':
+ case 'em':
+ case 'embed':
+ case 'h1':
+ case 'h2':
+ case 'h3':
+ case 'h4':
+ case 'h5':
+ case 'h6':
+ case 'head':
+ case 'hr':
+ case 'i':
+ case 'img':
+ case 'li':
+ case 'listing':
+ case 'menu':
+ case 'meta':
+ case 'nobr':
+ case 'ol':
+ case 'p':
+ case 'pre':
+ case 'ruby':
+ case 's':
+ case 'small':
+ case 'span':
+ case 'strong':
+ case 'strike':
+ case 'sub':
+ case 'sup':
+ case 'table':
+ case 'tt':
+ case 'u':
+ case 'ul':
+ case 'var':
+ if ( $this->fragmentContext ) {
+ break;
+ }
+ while ( true ) {
+ $this->stack->pop();
+ $node = $this->stack->currentNode;
+ if (
+ $node->isMathmlTextIntegrationPoint() ||
+ $node->isHtmlIntegrationPoint() ||
+ $node->isHtml()
+ ) {
+ break;
+ }
+ }
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ // "Any other start tag"
+ $adjusted = ( $this->fragmentContext && $this->stack->length() === 1 ) ?
+ $this->fragmentContext : $this->stack->currentNode;
+ $this->stack->insertForeignElement(
+ $adjusted->namespaceURI, $value, $attribs
+ );
+ if ( $selfClose ) {
+ $this->stack->pop();
+ }
+ return true;
+ } elseif ( $token === 'endtag' ) {
+ $first = true;
+ foreach ( $this->stack as $i => $node ) {
+ if ( $node->isHtml() && !$first ) {
+ // process the end tag as HTML
+ $func = $this->parseMode;
+ return $this->$func( $token, $value, $attribs, $selfClose );
+ } elseif ( $i === 0 ) {
+ return true;
+ } elseif ( $node->localName === $value ) {
+ $this->stack->popTag( $node );
+ return true;
+ }
+ $first = false;
+ }
+ }
+ }
+
+ /**
+ * Grab the next "token" from $bitsIterator. This is either a open/close
+ * tag or text or a comment, depending on whether the Sanitizer approves.
+ */
+ private function advance() {
+ $x = $this->bitsIterator->current();
+ $this->bitsIterator->next();
+ $regs = [];
+ // Handle comments. These won't be generated by mediawiki (they
+ // are stripped in the Sanitizer) but may be generated by extensions.
+ if (
+ $this->allowComments &&
+ !( $this->inRCDATA || $this->inRAWTEXT ) &&
+ preg_match( self::VALID_COMMENT_REGEX, $x, $regs, PREG_OFFSET_CAPTURE ) &&
+ // verify EOF condition where necessary
+ ( $regs[4][1] < 0 || !$this->bitsIterator->valid() )
+ ) {
+ $contents = $regs[2][0];
+ $rest = $regs[5][0];
+ $this->insertToken( 'comment', $contents );
+ $this->insertToken( 'text', str_replace( '>', '&gt;', $rest ) );
+ return;
+ }
+ // $slash: Does the current element start with a '/'?
+ // $t: Current element name
+ // $attribStr: String between element name and >
+ // $brace: Ending '>' or '/>'
+ // $rest: Everything until the next element from the $bitsIterator
+ if ( preg_match( Sanitizer::ELEMENT_BITS_REGEX, $x, $regs ) ) {
+ list( /* $qbar */, $slash, $t, $attribStr, $brace, $rest ) = $regs;
+ $t = strtolower( $t );
+ if ( $this->strict ) {
+ // Verify that attributes are all properly double-quoted
+ Assert::invariant(
+ preg_match(
+ '/^( [:_A-Z0-9][-.:_A-Z0-9]*="[^"]*")*[ ]*$/i', $attribStr
+ ),
+ "Bad attribute string found"
+ );
+ }
+ } else {
+ Assert::invariant(
+ !$this->strict, "< found which does not start a valid tag"
+ );
+ $slash = $t = $attribStr = $brace = $rest = null;
+ }
+ $goodTag = $t;
+ if ( $this->inRCDATA ) {
+ if ( $slash && $t === $this->inRCDATA ) {
+ $this->inRCDATA = false;
+ } else {
+ // No tags allowed; this emulates the "rcdata" tokenizer mode.
+ $goodTag = false;
+ }
+ }
+ if ( $this->inRAWTEXT ) {
+ if ( $slash && $t === $this->inRAWTEXT ) {
+ $this->inRAWTEXT = false;
+ } else {
+ // No tags allowed, no entity-escaping done.
+ $goodTag = false;
+ }
+ }
+ $sanitize = $this->allowedHtmlElements !== null;
+ if ( $sanitize ) {
+ $goodTag = $t && isset( $this->allowedHtmlElements[$t] );
+ }
+ if ( $goodTag ) {
+ if ( is_callable( $this->processingCallback ) ) {
+ call_user_func_array( $this->processingCallback, [ &$attribStr, $this->processingArgs ] );
+ }
+ if ( $sanitize ) {
+ $goodTag = Sanitizer::validateTag( $attribStr, $t );
+ }
+ }
+ if ( $goodTag ) {
+ if ( $sanitize ) {
+ $attribs = Sanitizer::decodeTagAttributes( $attribStr );
+ $attribs = Sanitizer::validateTagAttributes( $attribs, $t );
+ } else {
+ $attribs = Sanitizer::decodeTagAttributes( $attribStr );
+ }
+ $goodTag = $this->insertToken(
+ $slash ? 'endtag' : 'tag', $t, $attribs, $brace === '/>'
+ );
+ }
+ if ( $goodTag ) {
+ $rest = str_replace( '>', '&gt;', $rest );
+ $this->insertToken( 'text', str_replace( '>', '&gt;', $rest ) );
+ } elseif ( $this->inRAWTEXT ) {
+ $this->insertToken( 'text', "<$x" );
+ } else {
+ // bad tag; serialize entire thing as text.
+ $this->insertToken( 'text', '&lt;' . str_replace( '>', '&gt;', $x ) );
+ }
+ }
+
+ private function switchMode( $mode ) {
+ Assert::parameter(
+ substr( $mode, -4 ) === 'Mode', '$mode', 'should end in Mode'
+ );
+ $oldMode = $this->parseMode;
+ $this->parseMode = $mode;
+ return $oldMode;
+ }
+
+ private function switchModeAndReprocess( $mode, $token, $value, $attribs, $selfClose ) {
+ $this->switchMode( $mode );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+
+ private function resetInsertionMode() {
+ $last = false;
+ foreach ( $this->stack as $i => $node ) {
+ if ( $i === 0 ) {
+ $last = true;
+ if ( $this->fragmentContext ) {
+ $node = $this->fragmentContext;
+ }
+ }
+ if ( $node->isHtml() ) {
+ switch ( $node->localName ) {
+ case 'select':
+ $stackLength = $this->stack->length();
+ for ( $j = $i + 1; $j < $stackLength - 1; $j++ ) {
+ $ancestor = $this->stack->node( $stackLength - $j - 1 );
+ if ( $ancestor->isHtmlNamed( 'template' ) ) {
+ break;
+ }
+ if ( $ancestor->isHtmlNamed( 'table' ) ) {
+ $this->switchMode( 'inSelectInTableMode' );
+ return;
+ }
+ }
+ $this->switchMode( 'inSelectMode' );
+ return;
+ case 'tr':
+ $this->switchMode( 'inRowMode' );
+ return;
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ $this->switchMode( 'inTableBodyMode' );
+ return;
+ case 'caption':
+ $this->switchMode( 'inCaptionMode' );
+ return;
+ case 'colgroup':
+ $this->switchMode( 'inColumnGroupMode' );
+ return;
+ case 'table':
+ $this->switchMode( 'inTableMode' );
+ return;
+ case 'template':
+ $this->switchMode(
+ array_slice( $this->templateInsertionModes, -1 )[0]
+ );
+ return;
+ case 'body':
+ $this->switchMode( 'inBodyMode' );
+ return;
+ // OMITTED: <frameset>
+ // OMITTED: <html>
+ // OMITTED: <head>
+ default:
+ if ( !$last ) {
+ // OMITTED: <head>
+ if ( $node->isA( BalanceSets::$tableCellSet ) ) {
+ $this->switchMode( 'inCellMode' );
+ return;
+ }
+ }
+ }
+ }
+ if ( $last ) {
+ $this->switchMode( 'inBodyMode' );
+ return;
+ }
+ }
+ }
+
+ private function stopParsing() {
+ // Most of the spec methods are inapplicable, other than step 2:
+ // "pop all the nodes off the stack of open elements".
+ // We're going to keep the top-most <html> element on the stack, though.
+
+ // Clear the AFE list first, otherwise the element objects will stay live
+ // during serialization, potentially using O(N^2) memory. Note that
+ // popping the stack will never result in reconstructing the active
+ // formatting elements.
+ $this->afe = null;
+ $this->stack->popTo( 1 );
+ }
+
+ private function parseRawText( $value, $attribs = null ) {
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->inRAWTEXT = $value;
+ $this->originalInsertionMode = $this->switchMode( 'inTextMode' );
+ return true;
+ }
+
+ private function inTextMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' ) {
+ $this->stack->insertText( $value );
+ return true;
+ } elseif ( $token === 'eof' ) {
+ $this->stack->pop();
+ return $this->switchModeAndReprocess(
+ $this->originalInsertionMode, $token, $value, $attribs, $selfClose
+ );
+ } elseif ( $token === 'endtag' ) {
+ $this->stack->pop();
+ $this->switchMode( $this->originalInsertionMode );
+ return true;
+ }
+ return true;
+ }
+
+ private function inHeadMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' ) {
+ if ( preg_match( '/^[\x09\x0A\x0C\x0D\x20]+/', $value, $matches ) ) {
+ $this->stack->insertText( $matches[0] );
+ $value = substr( $value, strlen( $matches[0] ) );
+ }
+ if ( strlen( $value ) === 0 ) {
+ return true; // All text handled.
+ }
+ // Fall through to handle non-whitespace below.
+ } elseif ( $token === 'tag' ) {
+ switch ( $value ) {
+ case 'meta':
+ // OMITTED: in a full HTML parser, this might change the encoding.
+ // falls through
+ // OMITTED: <html>
+ case 'base':
+ case 'basefont':
+ case 'bgsound':
+ case 'link':
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->stack->pop();
+ return true;
+ // OMITTED: <title>
+ // OMITTED: <noscript>
+ case 'noframes':
+ case 'style':
+ return $this->parseRawText( $value, $attribs );
+ // OMITTED: <script>
+ case 'template':
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->afe->insertMarker();
+ // OMITTED: frameset_ok
+ $this->switchMode( 'inTemplateMode' );
+ $this->templateInsertionModes[] = $this->parseMode;
+ return true;
+ // OMITTED: <head>
+ }
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ // OMITTED: <head>
+ // OMITTED: <body>
+ // OMITTED: <html>
+ case 'br':
+ break; // handle at the bottom of the function
+ case 'template':
+ if ( $this->stack->indexOf( $value ) < 0 ) {
+ return true; // Ignore the token.
+ }
+ $this->stack->generateImpliedEndTags( null, true /* thorough */ );
+ $this->stack->popTag( $value );
+ $this->afe->clearToMarker();
+ array_pop( $this->templateInsertionModes );
+ $this->resetInsertionMode();
+ return true;
+ default:
+ // ignore any other end tag
+ return true;
+ }
+ } elseif ( $token === 'comment' ) {
+ $this->stack->insertComment( $value );
+ return true;
+ }
+
+ // If not handled above
+ $this->inHeadMode( 'endtag', 'head' ); // synthetic </head>
+ // Then redo this one
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+
+ private function inBodyMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' ) {
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertText( $value );
+ return true;
+ } elseif ( $token === 'eof' ) {
+ if ( !empty( $this->templateInsertionModes ) ) {
+ return $this->inTemplateMode( $token, $value, $attribs, $selfClose );
+ }
+ $this->stopParsing();
+ return true;
+ } elseif ( $token === 'tag' ) {
+ switch ( $value ) {
+ // OMITTED: <html>
+ case 'base':
+ case 'basefont':
+ case 'bgsound':
+ case 'link':
+ case 'meta':
+ case 'noframes':
+ // OMITTED: <script>
+ case 'style':
+ case 'template':
+ // OMITTED: <title>
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+ // OMITTED: <body>
+ // OMITTED: <frameset>
+
+ case 'address':
+ case 'article':
+ case 'aside':
+ case 'blockquote':
+ case 'center':
+ case 'details':
+ case 'dialog':
+ case 'dir':
+ case 'div':
+ case 'dl':
+ case 'fieldset':
+ case 'figcaption':
+ case 'figure':
+ case 'footer':
+ case 'header':
+ case 'hgroup':
+ case 'main':
+ case 'nav':
+ case 'ol':
+ case 'p':
+ case 'section':
+ case 'summary':
+ case 'ul':
+ if ( $this->stack->inButtonScope( 'p' ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'menu':
+ if ( $this->stack->inButtonScope( "p" ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ if ( $this->stack->currentNode->isHtmlNamed( 'menuitem' ) ) {
+ $this->stack->pop();
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'h1':
+ case 'h2':
+ case 'h3':
+ case 'h4':
+ case 'h5':
+ case 'h6':
+ if ( $this->stack->inButtonScope( 'p' ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ if ( $this->stack->currentNode->isA( BalanceSets::$headingSet ) ) {
+ $this->stack->pop();
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'pre':
+ case 'listing':
+ if ( $this->stack->inButtonScope( 'p' ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->ignoreLinefeed = true;
+ // OMITTED: frameset_ok
+ return true;
+
+ case 'form':
+ if (
+ $this->formElementPointer &&
+ $this->stack->indexOf( 'template' ) < 0
+ ) {
+ return true; // in a form, not in a template.
+ }
+ if ( $this->stack->inButtonScope( "p" ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ $elt = $this->stack->insertHTMLElement( $value, $attribs );
+ if ( $this->stack->indexOf( 'template' ) < 0 ) {
+ $this->formElementPointer = $elt;
+ }
+ return true;
+
+ case 'li':
+ // OMITTED: frameset_ok
+ foreach ( $this->stack as $node ) {
+ if ( $node->isHtmlNamed( 'li' ) ) {
+ $this->inBodyMode( 'endtag', 'li' );
+ break;
+ }
+ if (
+ $node->isA( BalanceSets::$specialSet ) &&
+ !$node->isA( BalanceSets::$addressDivPSet )
+ ) {
+ break;
+ }
+ }
+ if ( $this->stack->inButtonScope( 'p' ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'dd':
+ case 'dt':
+ // OMITTED: frameset_ok
+ foreach ( $this->stack as $node ) {
+ if ( $node->isHtmlNamed( 'dd' ) ) {
+ $this->inBodyMode( 'endtag', 'dd' );
+ break;
+ }
+ if ( $node->isHtmlNamed( 'dt' ) ) {
+ $this->inBodyMode( 'endtag', 'dt' );
+ break;
+ }
+ if (
+ $node->isA( BalanceSets::$specialSet ) &&
+ !$node->isA( BalanceSets::$addressDivPSet )
+ ) {
+ break;
+ }
+ }
+ if ( $this->stack->inButtonScope( 'p' ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ // OMITTED: <plaintext>
+
+ case 'button':
+ if ( $this->stack->inScope( 'button' ) ) {
+ $this->inBodyMode( 'endtag', 'button' );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'a':
+ $activeElement = $this->afe->findElementByTag( 'a' );
+ if ( $activeElement ) {
+ $this->inBodyMode( 'endtag', 'a' );
+ if ( $this->afe->isInList( $activeElement ) ) {
+ $this->afe->remove( $activeElement );
+ // Don't flatten here, since when we fall
+ // through below we might foster parent
+ // the new <a> tag inside this one.
+ $this->stack->removeElement( $activeElement, false );
+ }
+ }
+ // Falls through
+ case 'b':
+ case 'big':
+ case 'code':
+ case 'em':
+ case 'font':
+ case 'i':
+ case 's':
+ case 'small':
+ case 'strike':
+ case 'strong':
+ case 'tt':
+ case 'u':
+ $this->afe->reconstruct( $this->stack );
+ $this->afe->push( $this->stack->insertHTMLElement( $value, $attribs ) );
+ return true;
+
+ case 'nobr':
+ $this->afe->reconstruct( $this->stack );
+ if ( $this->stack->inScope( 'nobr' ) ) {
+ $this->inBodyMode( 'endtag', 'nobr' );
+ $this->afe->reconstruct( $this->stack );
+ }
+ $this->afe->push( $this->stack->insertHTMLElement( $value, $attribs ) );
+ return true;
+
+ case 'applet':
+ case 'marquee':
+ case 'object':
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->afe->insertMarker();
+ // OMITTED: frameset_ok
+ return true;
+
+ case 'table':
+ // The document is never in "quirks mode"; see simplifications
+ // above.
+ if ( $this->stack->inButtonScope( 'p' ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ // OMITTED: frameset_ok
+ $this->switchMode( 'inTableMode' );
+ return true;
+
+ case 'area':
+ case 'br':
+ case 'embed':
+ case 'img':
+ case 'keygen':
+ case 'wbr':
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->stack->pop();
+ // OMITTED: frameset_ok
+ return true;
+
+ case 'input':
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->stack->pop();
+ // OMITTED: frameset_ok
+ // (hence we don't need to examine the tag's "type" attribute)
+ return true;
+
+ case 'param':
+ case 'source':
+ case 'track':
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->stack->pop();
+ return true;
+
+ case 'hr':
+ if ( $this->stack->inButtonScope( 'p' ) ) {
+ $this->inBodyMode( 'endtag', 'p' );
+ }
+ if ( $this->stack->currentNode->isHtmlNamed( 'menuitem' ) ) {
+ $this->stack->pop();
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->stack->pop();
+ return true;
+
+ case 'image':
+ // warts!
+ return $this->inBodyMode( $token, 'img', $attribs, $selfClose );
+
+ case 'textarea':
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->ignoreLinefeed = true;
+ $this->inRCDATA = $value; // emulate rcdata tokenizer mode
+ // OMITTED: frameset_ok
+ return true;
+
+ // OMITTED: <xmp>
+ // OMITTED: <iframe>
+ // OMITTED: <noembed>
+ // OMITTED: <noscript>
+
+ case 'select':
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ switch ( $this->parseMode ) {
+ case 'inTableMode':
+ case 'inCaptionMode':
+ case 'inTableBodyMode':
+ case 'inRowMode':
+ case 'inCellMode':
+ $this->switchMode( 'inSelectInTableMode' );
+ return true;
+ default:
+ $this->switchMode( 'inSelectMode' );
+ return true;
+ }
+
+ case 'optgroup':
+ case 'option':
+ if ( $this->stack->currentNode->isHtmlNamed( 'option' ) ) {
+ $this->inBodyMode( 'endtag', 'option' );
+ }
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'menuitem':
+ if ( $this->stack->currentNode->isHtmlNamed( 'menuitem' ) ) {
+ $this->stack->pop();
+ }
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'rb':
+ case 'rtc':
+ if ( $this->stack->inScope( 'ruby' ) ) {
+ $this->stack->generateImpliedEndTags();
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'rp':
+ case 'rt':
+ if ( $this->stack->inScope( 'ruby' ) ) {
+ $this->stack->generateImpliedEndTags( 'rtc' );
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+
+ case 'math':
+ $this->afe->reconstruct( $this->stack );
+ // We skip the spec's "adjust MathML attributes" and
+ // "adjust foreign attributes" steps, since the browser will
+ // do this later when it parses the output and it doesn't affect
+ // balancing.
+ $this->stack->insertForeignElement(
+ BalanceSets::MATHML_NAMESPACE, $value, $attribs
+ );
+ if ( $selfClose ) {
+ // emit explicit </math> tag.
+ $this->stack->pop();
+ }
+ return true;
+
+ case 'svg':
+ $this->afe->reconstruct( $this->stack );
+ // We skip the spec's "adjust SVG attributes" and
+ // "adjust foreign attributes" steps, since the browser will
+ // do this later when it parses the output and it doesn't affect
+ // balancing.
+ $this->stack->insertForeignElement(
+ BalanceSets::SVG_NAMESPACE, $value, $attribs
+ );
+ if ( $selfClose ) {
+ // emit explicit </svg> tag.
+ $this->stack->pop();
+ }
+ return true;
+
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ // OMITTED: <frame>
+ case 'head':
+ case 'tbody':
+ case 'td':
+ case 'tfoot':
+ case 'th':
+ case 'thead':
+ case 'tr':
+ // Ignore table tags if we're not inTableMode
+ return true;
+ }
+
+ // Handle any other start tag here
+ $this->afe->reconstruct( $this->stack );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ // </body>,</html> are unsupported.
+
+ case 'template':
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+
+ case 'address':
+ case 'article':
+ case 'aside':
+ case 'blockquote':
+ case 'button':
+ case 'center':
+ case 'details':
+ case 'dialog':
+ case 'dir':
+ case 'div':
+ case 'dl':
+ case 'fieldset':
+ case 'figcaption':
+ case 'figure':
+ case 'footer':
+ case 'header':
+ case 'hgroup':
+ case 'listing':
+ case 'main':
+ case 'menu':
+ case 'nav':
+ case 'ol':
+ case 'pre':
+ case 'section':
+ case 'summary':
+ case 'ul':
+ // Ignore if there is not a matching open tag
+ if ( !$this->stack->inScope( $value ) ) {
+ return true;
+ }
+ $this->stack->generateImpliedEndTags();
+ $this->stack->popTag( $value );
+ return true;
+
+ case 'form':
+ if ( $this->stack->indexOf( 'template' ) < 0 ) {
+ $openform = $this->formElementPointer;
+ $this->formElementPointer = null;
+ if ( !$openform || !$this->stack->inScope( $openform ) ) {
+ return true;
+ }
+ $this->stack->generateImpliedEndTags();
+ // Don't flatten yet if we're removing a <form> element
+ // out-of-order. (eg. `<form><div></form>`)
+ $flatten = ( $this->stack->currentNode === $openform );
+ $this->stack->removeElement( $openform, $flatten );
+ } else {
+ if ( !$this->stack->inScope( 'form' ) ) {
+ return true;
+ }
+ $this->stack->generateImpliedEndTags();
+ $this->stack->popTag( 'form' );
+ }
+ return true;
+
+ case 'p':
+ if ( !$this->stack->inButtonScope( 'p' ) ) {
+ $this->inBodyMode( 'tag', 'p', [] );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ $this->stack->generateImpliedEndTags( $value );
+ $this->stack->popTag( $value );
+ return true;
+
+ case 'li':
+ if ( !$this->stack->inListItemScope( $value ) ) {
+ return true; // ignore
+ }
+ $this->stack->generateImpliedEndTags( $value );
+ $this->stack->popTag( $value );
+ return true;
+
+ case 'dd':
+ case 'dt':
+ if ( !$this->stack->inScope( $value ) ) {
+ return true; // ignore
+ }
+ $this->stack->generateImpliedEndTags( $value );
+ $this->stack->popTag( $value );
+ return true;
+
+ case 'h1':
+ case 'h2':
+ case 'h3':
+ case 'h4':
+ case 'h5':
+ case 'h6':
+ if ( !$this->stack->inScope( BalanceSets::$headingSet ) ) {
+ return true; // ignore
+ }
+ $this->stack->generateImpliedEndTags();
+ $this->stack->popTag( BalanceSets::$headingSet );
+ return true;
+
+ case 'sarcasm':
+ // Take a deep breath, then:
+ break;
+
+ case 'a':
+ case 'b':
+ case 'big':
+ case 'code':
+ case 'em':
+ case 'font':
+ case 'i':
+ case 'nobr':
+ case 's':
+ case 'small':
+ case 'strike':
+ case 'strong':
+ case 'tt':
+ case 'u':
+ if ( $this->stack->adoptionAgency( $value, $this->afe ) ) {
+ return true; // If we did something, we're done.
+ }
+ break; // Go to the "any other end tag" case.
+
+ case 'applet':
+ case 'marquee':
+ case 'object':
+ if ( !$this->stack->inScope( $value ) ) {
+ return true; // ignore
+ }
+ $this->stack->generateImpliedEndTags();
+ $this->stack->popTag( $value );
+ $this->afe->clearToMarker();
+ return true;
+
+ case 'br':
+ // Turn </br> into <br>
+ return $this->inBodyMode( 'tag', $value, [] );
+ }
+
+ // Any other end tag goes here
+ foreach ( $this->stack as $i => $node ) {
+ if ( $node->isHtmlNamed( $value ) ) {
+ $this->stack->generateImpliedEndTags( $value );
+ $this->stack->popTo( $i ); // including $i
+ break;
+ } elseif ( $node->isA( BalanceSets::$specialSet ) ) {
+ return true; // ignore this close token.
+ }
+ }
+ return true;
+ } elseif ( $token === 'comment' ) {
+ $this->stack->insertComment( $value );
+ return true;
+ } else {
+ Assert::invariant( false, "Bad token type: $token" );
+ }
+ }
+
+ private function inTableMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' ) {
+ if ( $this->textIntegrationMode ) {
+ return $this->inBodyMode( $token, $value, $attribs, $selfClose );
+ } elseif ( $this->stack->currentNode->isA( BalanceSets::$tableSectionRowSet ) ) {
+ $this->pendingTableText = '';
+ $this->originalInsertionMode = $this->parseMode;
+ return $this->switchModeAndReprocess( 'inTableTextMode',
+ $token, $value, $attribs, $selfClose );
+ }
+ // fall through to default case.
+ } elseif ( $token === 'eof' ) {
+ $this->stopParsing();
+ return true;
+ } elseif ( $token === 'tag' ) {
+ switch ( $value ) {
+ case 'caption':
+ $this->afe->insertMarker();
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->switchMode( 'inCaptionMode' );
+ return true;
+ case 'colgroup':
+ $this->stack->clearToContext( BalanceSets::$tableContextSet );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->switchMode( 'inColumnGroupMode' );
+ return true;
+ case 'col':
+ $this->inTableMode( 'tag', 'colgroup', [] );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ $this->stack->clearToContext( BalanceSets::$tableContextSet );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->switchMode( 'inTableBodyMode' );
+ return true;
+ case 'td':
+ case 'th':
+ case 'tr':
+ $this->inTableMode( 'tag', 'tbody', [] );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ case 'table':
+ if ( !$this->stack->inTableScope( $value ) ) {
+ return true; // Ignore this tag.
+ }
+ $this->inTableMode( 'endtag', $value );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+
+ case 'style':
+ // OMITTED: <script>
+ case 'template':
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+
+ case 'input':
+ if ( !isset( $attribs['type'] ) || strcasecmp( $attribs['type'], 'hidden' ) !== 0 ) {
+ break; // Handle this as "everything else"
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->stack->pop();
+ return true;
+
+ case 'form':
+ if (
+ $this->formElementPointer ||
+ $this->stack->indexOf( 'template' ) >= 0
+ ) {
+ return true; // ignore this token
+ }
+ $this->formElementPointer =
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->stack->popTag( $this->formElementPointer );
+ return true;
+ }
+ // Fall through for "anything else" clause.
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ case 'table':
+ if ( !$this->stack->inTableScope( $value ) ) {
+ return true; // Ignore.
+ }
+ $this->stack->popTag( $value );
+ $this->resetInsertionMode();
+ return true;
+ // OMITTED: <body>
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ // OMITTED: <html>
+ case 'tbody':
+ case 'td':
+ case 'tfoot':
+ case 'th':
+ case 'thead':
+ case 'tr':
+ return true; // Ignore the token.
+ case 'template':
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+ }
+ // Fall through for "anything else" clause.
+ } elseif ( $token === 'comment' ) {
+ $this->stack->insertComment( $value );
+ return true;
+ }
+ // This is the "anything else" case:
+ $this->stack->fosterParentMode = true;
+ $this->inBodyMode( $token, $value, $attribs, $selfClose );
+ $this->stack->fosterParentMode = false;
+ return true;
+ }
+
+ private function inTableTextMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' ) {
+ $this->pendingTableText .= $value;
+ return true;
+ }
+ // Non-text token:
+ $text = $this->pendingTableText;
+ $this->pendingTableText = '';
+ if ( preg_match( '/[^\x09\x0A\x0C\x0D\x20]/', $text ) ) {
+ // This should match the "anything else" case inTableMode
+ $this->stack->fosterParentMode = true;
+ $this->inBodyMode( 'text', $text );
+ $this->stack->fosterParentMode = false;
+ } else {
+ // Pending text is just whitespace.
+ $this->stack->insertText( $text );
+ }
+ return $this->switchModeAndReprocess(
+ $this->originalInsertionMode, $token, $value, $attribs, $selfClose
+ );
+ }
+
+ // helper for inCaptionMode
+ private function endCaption() {
+ if ( !$this->stack->inTableScope( 'caption' ) ) {
+ return false;
+ }
+ $this->stack->generateImpliedEndTags();
+ $this->stack->popTag( 'caption' );
+ $this->afe->clearToMarker();
+ $this->switchMode( 'inTableMode' );
+ return true;
+ }
+
+ private function inCaptionMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'tag' ) {
+ switch ( $value ) {
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ case 'tbody':
+ case 'td':
+ case 'tfoot':
+ case 'th':
+ case 'thead':
+ case 'tr':
+ if ( $this->endCaption() ) {
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ }
+ // Fall through to "anything else" case.
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ case 'caption':
+ $this->endCaption();
+ return true;
+ case 'table':
+ if ( $this->endCaption() ) {
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ case 'body':
+ case 'col':
+ case 'colgroup':
+ // OMITTED: <html>
+ case 'tbody':
+ case 'td':
+ case 'tfoot':
+ case 'th':
+ case 'thead':
+ case 'tr':
+ // Ignore the token
+ return true;
+ }
+ // Fall through to "anything else" case.
+ }
+ // The Anything Else case
+ return $this->inBodyMode( $token, $value, $attribs, $selfClose );
+ }
+
+ private function inColumnGroupMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' ) {
+ if ( preg_match( '/^[\x09\x0A\x0C\x0D\x20]+/', $value, $matches ) ) {
+ $this->stack->insertText( $matches[0] );
+ $value = substr( $value, strlen( $matches[0] ) );
+ }
+ if ( strlen( $value ) === 0 ) {
+ return true; // All text handled.
+ }
+ // Fall through to handle non-whitespace below.
+ } elseif ( $token === 'tag' ) {
+ switch ( $value ) {
+ // OMITTED: <html>
+ case 'col':
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->stack->pop();
+ return true;
+ case 'template':
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+ }
+ // Fall through for "anything else".
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ case 'colgroup':
+ if ( !$this->stack->currentNode->isHtmlNamed( 'colgroup' ) ) {
+ return true; // Ignore the token.
+ }
+ $this->stack->pop();
+ $this->switchMode( 'inTableMode' );
+ return true;
+ case 'col':
+ return true; // Ignore the token.
+ case 'template':
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+ }
+ // Fall through for "anything else".
+ } elseif ( $token === 'eof' ) {
+ return $this->inBodyMode( $token, $value, $attribs, $selfClose );
+ } elseif ( $token === 'comment' ) {
+ $this->stack->insertComment( $value );
+ return true;
+ }
+
+ // Anything else
+ if ( !$this->stack->currentNode->isHtmlNamed( 'colgroup' ) ) {
+ return true; // Ignore the token.
+ }
+ $this->inColumnGroupMode( 'endtag', 'colgroup' );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+
+ // Helper function for inTableBodyMode
+ private function endSection() {
+ if ( !(
+ $this->stack->inTableScope( 'tbody' ) ||
+ $this->stack->inTableScope( 'thead' ) ||
+ $this->stack->inTableScope( 'tfoot' )
+ ) ) {
+ return false;
+ }
+ $this->stack->clearToContext( BalanceSets::$tableBodyContextSet );
+ $this->stack->pop();
+ $this->switchMode( 'inTableMode' );
+ return true;
+ }
+ private function inTableBodyMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'tag' ) {
+ switch ( $value ) {
+ case 'tr':
+ $this->stack->clearToContext( BalanceSets::$tableBodyContextSet );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->switchMode( 'inRowMode' );
+ return true;
+ case 'th':
+ case 'td':
+ $this->inTableBodyMode( 'tag', 'tr', [] );
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ return true;
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ if ( $this->endSection() ) {
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ }
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ case 'table':
+ if ( $this->endSection() ) {
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ if ( $this->stack->inTableScope( $value ) ) {
+ $this->endSection();
+ }
+ return true;
+ // OMITTED: <body>
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ // OMITTED: <html>
+ case 'td':
+ case 'th':
+ case 'tr':
+ return true; // Ignore the token.
+ }
+ }
+ // Anything else:
+ return $this->inTableMode( $token, $value, $attribs, $selfClose );
+ }
+
+ // Helper function for inRowMode
+ private function endRow() {
+ if ( !$this->stack->inTableScope( 'tr' ) ) {
+ return false;
+ }
+ $this->stack->clearToContext( BalanceSets::$tableRowContextSet );
+ $this->stack->pop();
+ $this->switchMode( 'inTableBodyMode' );
+ return true;
+ }
+ private function inRowMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'tag' ) {
+ switch ( $value ) {
+ case 'th':
+ case 'td':
+ $this->stack->clearToContext( BalanceSets::$tableRowContextSet );
+ $this->stack->insertHTMLElement( $value, $attribs );
+ $this->switchMode( 'inCellMode' );
+ $this->afe->insertMarker();
+ return true;
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ case 'tr':
+ if ( $this->endRow() ) {
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ }
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ case 'tr':
+ $this->endRow();
+ return true;
+ case 'table':
+ if ( $this->endRow() ) {
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ if (
+ $this->stack->inTableScope( $value ) &&
+ $this->endRow()
+ ) {
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ // OMITTED: <body>
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ // OMITTED: <html>
+ case 'td':
+ case 'th':
+ return true; // Ignore the token.
+ }
+ }
+ // Anything else:
+ return $this->inTableMode( $token, $value, $attribs, $selfClose );
+ }
+
+ // Helper for inCellMode
+ private function endCell() {
+ if ( $this->stack->inTableScope( 'td' ) ) {
+ $this->inCellMode( 'endtag', 'td' );
+ return true;
+ } elseif ( $this->stack->inTableScope( 'th' ) ) {
+ $this->inCellMode( 'endtag', 'th' );
+ return true;
+ } else {
+ return false;
+ }
+ }
+ private function inCellMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'tag' ) {
+ switch ( $value ) {
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ case 'tbody':
+ case 'td':
+ case 'tfoot':
+ case 'th':
+ case 'thead':
+ case 'tr':
+ if ( $this->endCell() ) {
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ }
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ case 'td':
+ case 'th':
+ if ( $this->stack->inTableScope( $value ) ) {
+ $this->stack->generateImpliedEndTags();
+ $this->stack->popTag( $value );
+ $this->afe->clearToMarker();
+ $this->switchMode( 'inRowMode' );
+ }
+ return true;
+ // OMITTED: <body>
+ case 'caption':
+ case 'col':
+ case 'colgroup':
+ // OMITTED: <html>
+ return true;
+
+ case 'table':
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ case 'tr':
+ if ( $this->stack->inTableScope( $value ) ) {
+ $this->stack->generateImpliedEndTags();
+ $this->stack->popTag( BalanceSets::$tableCellSet );
+ $this->afe->clearToMarker();
+ $this->switchMode( 'inRowMode' );
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ }
+ }
+ // Anything else:
+ return $this->inBodyMode( $token, $value, $attribs, $selfClose );
+ }
+
+ private function inSelectMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' ) {
+ $this->stack->insertText( $value );
+ return true;
+ } elseif ( $token === 'eof' ) {
+ return $this->inBodyMode( $token, $value, $attribs, $selfClose );
+ } elseif ( $token === 'tag' ) {
+ switch ( $value ) {
+ // OMITTED: <html>
+ case 'option':
+ if ( $this->stack->currentNode->isHtmlNamed( 'option' ) ) {
+ $this->stack->pop();
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+ case 'optgroup':
+ if ( $this->stack->currentNode->isHtmlNamed( 'option' ) ) {
+ $this->stack->pop();
+ }
+ if ( $this->stack->currentNode->isHtmlNamed( 'optgroup' ) ) {
+ $this->stack->pop();
+ }
+ $this->stack->insertHTMLElement( $value, $attribs );
+ return true;
+ case 'select':
+ $this->inSelectMode( 'endtag', $value ); // treat it like endtag
+ return true;
+ case 'input':
+ case 'keygen':
+ case 'textarea':
+ if ( !$this->stack->inSelectScope( 'select' ) ) {
+ return true; // ignore token (fragment case)
+ }
+ $this->inSelectMode( 'endtag', 'select' );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ case 'script':
+ case 'template':
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+ }
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ case 'optgroup':
+ if (
+ $this->stack->currentNode->isHtmlNamed( 'option' ) &&
+ $this->stack->length() >= 2 &&
+ $this->stack->node( $this->stack->length() - 2 )->isHtmlNamed( 'optgroup' )
+ ) {
+ $this->stack->pop();
+ }
+ if ( $this->stack->currentNode->isHtmlNamed( 'optgroup' ) ) {
+ $this->stack->pop();
+ }
+ return true;
+ case 'option':
+ if ( $this->stack->currentNode->isHtmlNamed( 'option' ) ) {
+ $this->stack->pop();
+ }
+ return true;
+ case 'select':
+ if ( !$this->stack->inSelectScope( $value ) ) {
+ return true; // fragment case
+ }
+ $this->stack->popTag( $value );
+ $this->resetInsertionMode();
+ return true;
+ case 'template':
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+ }
+ } elseif ( $token === 'comment' ) {
+ $this->stack->insertComment( $value );
+ return true;
+ }
+ // anything else: just ignore the token
+ return true;
+ }
+
+ private function inSelectInTableMode( $token, $value, $attribs = null, $selfClose = false ) {
+ switch ( $value ) {
+ case 'caption':
+ case 'table':
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ case 'tr':
+ case 'td':
+ case 'th':
+ if ( $token === 'tag' ) {
+ $this->inSelectInTableMode( 'endtag', 'select' );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ } elseif ( $token === 'endtag' ) {
+ if ( $this->stack->inTableScope( $value ) ) {
+ $this->inSelectInTableMode( 'endtag', 'select' );
+ return $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ }
+ }
+ // anything else
+ return $this->inSelectMode( $token, $value, $attribs, $selfClose );
+ }
+
+ private function inTemplateMode( $token, $value, $attribs = null, $selfClose = false ) {
+ if ( $token === 'text' || $token === 'comment' ) {
+ return $this->inBodyMode( $token, $value, $attribs, $selfClose );
+ } elseif ( $token === 'eof' ) {
+ if ( $this->stack->indexOf( 'template' ) < 0 ) {
+ $this->stopParsing();
+ } else {
+ $this->stack->popTag( 'template' );
+ $this->afe->clearToMarker();
+ array_pop( $this->templateInsertionModes );
+ $this->resetInsertionMode();
+ $this->insertToken( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ } elseif ( $token === 'tag' ) {
+ switch ( $value ) {
+ case 'base':
+ case 'basefont':
+ case 'bgsound':
+ case 'link':
+ case 'meta':
+ case 'noframes':
+ // OMITTED: <script>
+ case 'style':
+ case 'template':
+ // OMITTED: <title>
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+
+ case 'caption':
+ case 'colgroup':
+ case 'tbody':
+ case 'tfoot':
+ case 'thead':
+ return $this->switchModeAndReprocess(
+ 'inTableMode', $token, $value, $attribs, $selfClose
+ );
+
+ case 'col':
+ return $this->switchModeAndReprocess(
+ 'inColumnGroupMode', $token, $value, $attribs, $selfClose
+ );
+
+ case 'tr':
+ return $this->switchModeAndReprocess(
+ 'inTableBodyMode', $token, $value, $attribs, $selfClose
+ );
+
+ case 'td':
+ case 'th':
+ return $this->switchModeAndReprocess(
+ 'inRowMode', $token, $value, $attribs, $selfClose
+ );
+ }
+ return $this->switchModeAndReprocess(
+ 'inBodyMode', $token, $value, $attribs, $selfClose
+ );
+ } elseif ( $token === 'endtag' ) {
+ switch ( $value ) {
+ case 'template':
+ return $this->inHeadMode( $token, $value, $attribs, $selfClose );
+ }
+ return true;
+ } else {
+ Assert::invariant( false, "Bad token type: $token" );
+ }
+ }
+}
diff --git a/www/wiki/includes/tidy/Html5Depurate.php b/www/wiki/includes/tidy/Html5Depurate.php
new file mode 100644
index 00000000..c6acd661
--- /dev/null
+++ b/www/wiki/includes/tidy/Html5Depurate.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+use MWHttpRequest;
+use Exception;
+
+class Html5Depurate extends TidyDriverBase {
+ public function __construct( array $config ) {
+ parent::__construct( $config + [
+ 'url' => 'http://localhost:4339/document',
+ 'timeout' => 10,
+ 'connectTimeout' => 0.5,
+ ] );
+ }
+
+ public function tidy( $text ) {
+ $wrappedtext = '<!DOCTYPE html><html>' .
+ '<body>' . $text . '</body></html>';
+
+ $req = MWHttpRequest::factory( $this->config['url'],
+ [
+ 'method' => 'POST',
+ 'timeout' => $this->config['timeout'],
+ 'connectTimeout' => $this->config['connectTimeout'],
+ 'postData' => [
+ 'text' => $wrappedtext
+ ]
+ ] );
+ $status = $req->execute();
+ if ( !$status->isOK() ) {
+ throw new Exception( "Error contacting depurate service: "
+ . $status->getWikiText( false, false, 'en' ) );
+ } elseif ( $req->getStatus() !== 200 ) {
+ throw new Exception( "Depurate returned error: " . $status->getWikiText( false, false, 'en' ) );
+ }
+ $result = $req->getContent();
+ $startBody = strpos( $result, "<body>" );
+ $endBody = strrpos( $result, "</body>" );
+ if ( $startBody !== false && $endBody !== false && $endBody > $startBody ) {
+ $startBody += strlen( "<body>" );
+ return substr( $result, $startBody, $endBody - $startBody );
+ } else {
+ return $text . "\n<!-- Html5Depurate returned an invalid result -->";
+ }
+ }
+}
diff --git a/www/wiki/includes/tidy/Html5Internal.php b/www/wiki/includes/tidy/Html5Internal.php
new file mode 100644
index 00000000..4ad82002
--- /dev/null
+++ b/www/wiki/includes/tidy/Html5Internal.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+class Html5Internal extends TidyDriverBase {
+ private $balancer;
+ public function __construct( array $config ) {
+ parent::__construct( $config + [
+ 'strict' => true,
+ 'tidyCompat' => true,
+ ] );
+ $this->balancer = new Balancer( $this->config );
+ }
+
+ public function tidy( $text ) {
+ return $this->balancer->balance( $text );
+ }
+}
diff --git a/www/wiki/includes/tidy/RaggettBase.php b/www/wiki/includes/tidy/RaggettBase.php
new file mode 100644
index 00000000..a3717b2b
--- /dev/null
+++ b/www/wiki/includes/tidy/RaggettBase.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+abstract class RaggettBase extends TidyDriverBase {
+ /**
+ * Generic interface for wrapping and unwrapping HTML for Dave Raggett's tidy.
+ *
+ * @param string $text Hideous HTML input
+ * @return string Corrected HTML output
+ */
+ public function tidy( $text ) {
+ $wrapper = new RaggettWrapper;
+ $wrappedtext = $wrapper->getWrapped( $text );
+
+ $retVal = null;
+ $correctedtext = $this->cleanWrapped( $wrappedtext, false, $retVal );
+
+ if ( $retVal < 0 ) {
+ wfDebug( "Possible tidy configuration error!\n" );
+ return $text . "\n<!-- Tidy was unable to run -->\n";
+ } elseif ( is_null( $correctedtext ) ) {
+ wfDebug( "Tidy error detected!\n" );
+ return $text . "\n<!-- Tidy found serious XHTML errors -->\n";
+ }
+
+ $correctedtext = $wrapper->postprocess( $correctedtext ); // restore any hidden tokens
+
+ return $correctedtext;
+ }
+
+ public function validate( $text, &$errorStr ) {
+ $retval = 0;
+ $errorStr = $this->cleanWrapped( $text, true, $retval );
+ return ( $retval < 0 && $errorStr == '' ) || $retval == 0;
+ }
+
+ /**
+ * Perform a clean/repair operation
+ * @param string $text HTML to check
+ * @param bool $stderr Whether to read result from STDERR rather than STDOUT
+ * @param int &$retval Exit code (-1 on internal error)
+ * @return null|string
+ * @throws MWException
+ */
+ abstract protected function cleanWrapped( $text, $stderr = false, &$retval = null );
+}
diff --git a/www/wiki/includes/tidy/RaggettExternal.php b/www/wiki/includes/tidy/RaggettExternal.php
new file mode 100644
index 00000000..b59423ab
--- /dev/null
+++ b/www/wiki/includes/tidy/RaggettExternal.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+class RaggettExternal extends RaggettBase {
+ /**
+ * Spawn an external HTML tidy process and get corrected markup back from it.
+ * Also called in OutputHandler.php for full page validation
+ *
+ * @param string $text HTML to check
+ * @param bool $stderr Whether to read result from STDERR rather than STDOUT
+ * @param int &$retval Exit code (-1 on internal error)
+ * @return string|null
+ */
+ protected function cleanWrapped( $text, $stderr = false, &$retval = null ) {
+ $cleansource = '';
+ $opts = ' -utf8';
+
+ if ( $stderr ) {
+ $descriptorspec = [
+ 0 => [ 'pipe', 'r' ],
+ 1 => [ 'file', wfGetNull(), 'a' ],
+ 2 => [ 'pipe', 'w' ]
+ ];
+ } else {
+ $descriptorspec = [
+ 0 => [ 'pipe', 'r' ],
+ 1 => [ 'pipe', 'w' ],
+ 2 => [ 'file', wfGetNull(), 'a' ]
+ ];
+ }
+
+ $readpipe = $stderr ? 2 : 1;
+ $pipes = [];
+
+ $process = proc_open(
+ "{$this->config['tidyBin']} -config {$this->config['tidyConfigFile']} " .
+ $this->config['tidyCommandLine'] . $opts, $descriptorspec, $pipes );
+
+ // NOTE: At least on linux, the process will be created even if tidy is not installed.
+ // This means that missing tidy will be treated as a validation failure.
+
+ if ( is_resource( $process ) ) {
+ // Theoretically, this style of communication could cause a deadlock
+ // here. If the stdout buffer fills up, then writes to stdin could
+ // block. This doesn't appear to happen with tidy, because tidy only
+ // writes to stdout after it's finished reading from stdin. Search
+ // for tidyParseStdin and tidySaveStdout in console/tidy.c
+ fwrite( $pipes[0], $text );
+ fclose( $pipes[0] );
+ while ( !feof( $pipes[$readpipe] ) ) {
+ $cleansource .= fgets( $pipes[$readpipe], 1024 );
+ }
+ fclose( $pipes[$readpipe] );
+ $retval = proc_close( $process );
+ } else {
+ wfWarn( "Unable to start external tidy process" );
+ $retval = -1;
+ }
+
+ if ( !$stderr && $cleansource == '' && $text != '' ) {
+ // Some kind of error happened, so we couldn't get the corrected text.
+ // Just give up; we'll use the source text and append a warning.
+ $cleansource = null;
+ }
+
+ return $cleansource;
+ }
+
+ public function supportsValidate() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/tidy/RaggettInternalHHVM.php b/www/wiki/includes/tidy/RaggettInternalHHVM.php
new file mode 100644
index 00000000..bb83d6a3
--- /dev/null
+++ b/www/wiki/includes/tidy/RaggettInternalHHVM.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+class RaggettInternalHHVM extends RaggettBase {
+ /**
+ * Use the HTML tidy extension to use the tidy library in-process,
+ * saving the overhead of spawning a new process.
+ *
+ * @param string $text HTML to check
+ * @param bool $stderr Whether to read result from error status instead of output
+ * @param int &$retval Exit code (-1 on internal error)
+ * @return string|null
+ */
+ protected function cleanWrapped( $text, $stderr = false, &$retval = null ) {
+ if ( $stderr ) {
+ throw new \Exception( "\$stderr cannot be used with RaggettInternalHHVM" );
+ }
+ $cleansource = tidy_repair_string( $text, $this->config['tidyConfigFile'], 'utf8' );
+ if ( $cleansource === false ) {
+ $cleansource = null;
+ $retval = -1;
+ } else {
+ $retval = 0;
+ }
+
+ return $cleansource;
+ }
+}
diff --git a/www/wiki/includes/tidy/RaggettInternalPHP.php b/www/wiki/includes/tidy/RaggettInternalPHP.php
new file mode 100644
index 00000000..e5642d9e
--- /dev/null
+++ b/www/wiki/includes/tidy/RaggettInternalPHP.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+class RaggettInternalPHP extends RaggettBase {
+ /**
+ * Use the HTML tidy extension to use the tidy library in-process,
+ * saving the overhead of spawning a new process.
+ *
+ * @param string $text HTML to check
+ * @param bool $stderr Whether to read result from error status instead of output
+ * @param int &$retval Exit code (-1 on internal error)
+ * @return string|null
+ */
+ protected function cleanWrapped( $text, $stderr = false, &$retval = null ) {
+ if ( !class_exists( 'tidy' ) ) {
+ wfWarn( "Unable to load internal tidy class." );
+ $retval = -1;
+
+ return null;
+ }
+
+ $tidy = new \tidy;
+ $tidy->parseString( $text, $this->config['tidyConfigFile'], 'utf8' );
+
+ if ( $stderr ) {
+ $retval = $tidy->getStatus();
+ return $tidy->errorBuffer;
+ }
+
+ $tidy->cleanRepair();
+ $retval = $tidy->getStatus();
+ if ( $retval == 2 ) {
+ // 2 is magic number for fatal error
+ // https://secure.php.net/manual/en/tidy.getstatus.php
+ $cleansource = null;
+ } else {
+ $cleansource = tidy_get_output( $tidy );
+ if ( !empty( $this->config['debugComment'] ) && $retval > 0 ) {
+ $cleansource .= "<!--\nTidy reports:\n" .
+ str_replace( '-->', '--&gt;', $tidy->errorBuffer ) .
+ "\n-->";
+ }
+ }
+
+ return $cleansource;
+ }
+
+ public function supportsValidate() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/tidy/RaggettWrapper.php b/www/wiki/includes/tidy/RaggettWrapper.php
new file mode 100644
index 00000000..b793a58a
--- /dev/null
+++ b/www/wiki/includes/tidy/RaggettWrapper.php
@@ -0,0 +1,100 @@
+<?php
+namespace MediaWiki\Tidy;
+
+use ParserOutput;
+use Parser;
+
+/**
+ * Class used to hide mw:editsection tokens from Tidy so that it doesn't break them
+ * or break on them. This is a bit of a hack for now, but hopefully in the future
+ * we may create a real postprocessor or something that will replace this.
+ * It's called wrapper because for now it basically takes over MWTidy::tidy's task
+ * of wrapping the text in a xhtml block
+ *
+ * This re-uses some of the parser's UNIQ tricks, though some of it is private so it's
+ * duplicated. Perhaps we should create an abstract marker hiding class.
+ *
+ * @ingroup Parser
+ */
+class RaggettWrapper {
+
+ /**
+ * @var array
+ */
+ protected $mTokens;
+
+ /**
+ * @var int
+ */
+ protected $mMarkerIndex;
+
+ /**
+ * @param string $text
+ * @return string
+ */
+ public function getWrapped( $text ) {
+ $this->mTokens = [];
+ $this->mMarkerIndex = 0;
+
+ // Replace <mw:editsection> elements with placeholders
+ $wrappedtext = preg_replace_callback( ParserOutput::EDITSECTION_REGEX,
+ [ $this, 'replaceCallback' ], $text );
+ // ...and <mw:toc> markers
+ $wrappedtext = preg_replace_callback( '/\<\\/?mw:toc\>/',
+ [ $this, 'replaceCallback' ], $wrappedtext );
+ // ... and <math> tags
+ $wrappedtext = preg_replace_callback( '/\<math(.*?)\<\\/math\>/s',
+ [ $this, 'replaceCallback' ], $wrappedtext );
+ // Modify inline Microdata <link> and <meta> elements so they say <html-link> and <html-meta> so
+ // we can trick Tidy into not stripping them out by including them in tidy's new-empty-tags config
+ $wrappedtext = preg_replace( '!<(link|meta)([^>]*?)(/{0,1}>)!', '<html-$1$2$3', $wrappedtext );
+ // Similar for inline <style> tags, but those aren't empty.
+ $wrappedtext = preg_replace_callback( '!<style([^>]*)>(.*?)</style>!s', function ( $m ) {
+ return '<html-style' . $m[1] . '>'
+ . $this->replaceCallback( [ $m[2] ] )
+ . '</html-style>';
+ }, $wrappedtext );
+
+ // Preserve empty li elements (T49673) by abusing Tidy's datafld hack
+ // The whitespace class is as in TY_(InitMap)
+ $wrappedtext = preg_replace( "!<li>([ \r\n\t\f]*)</li>!",
+ '<li datafld="" class="mw-empty-elt">\1</li>', $wrappedtext );
+
+ // Wrap the whole thing in a doctype and body for Tidy.
+ $wrappedtext = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"' .
+ ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html>' .
+ '<head><title>test</title></head><body>' . $wrappedtext . '</body></html>';
+
+ return $wrappedtext;
+ }
+
+ /**
+ * @param array $m
+ * @return string
+ */
+ private function replaceCallback( array $m ) {
+ $marker = Parser::MARKER_PREFIX . "-item-{$this->mMarkerIndex}" . Parser::MARKER_SUFFIX;
+ $this->mMarkerIndex++;
+ $this->mTokens[$marker] = $m[0];
+ return $marker;
+ }
+
+ /**
+ * @param string $text
+ * @return string
+ */
+ public function postprocess( $text ) {
+ // Revert <html-{link,meta,style}> back to <{link,meta,style}>
+ $text = preg_replace( '!<html-(link|meta)([^>]*?)(/{0,1}>)!', '<$1$2$3', $text );
+ $text = preg_replace( '!<(/?)html-(style)([^>]*)>!', '<$1$2$3>', $text );
+
+ // Remove datafld
+ $text = str_replace( '<li datafld=""', '<li', $text );
+
+ // Restore the contents of placeholder tokens
+ $text = strtr( $text, $this->mTokens );
+
+ return $text;
+ }
+
+}
diff --git a/www/wiki/includes/tidy/RemexCompatFormatter.php b/www/wiki/includes/tidy/RemexCompatFormatter.php
new file mode 100644
index 00000000..c8a715b1
--- /dev/null
+++ b/www/wiki/includes/tidy/RemexCompatFormatter.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+use RemexHtml\HTMLData;
+use RemexHtml\Serializer\HtmlFormatter;
+use RemexHtml\Serializer\SerializerNode;
+
+/**
+ * @internal
+ */
+class RemexCompatFormatter extends HtmlFormatter {
+ private static $markedEmptyElements = [
+ 'li' => true,
+ 'p' => true,
+ 'tr' => true,
+ ];
+
+ public function __construct( $options = [] ) {
+ parent::__construct( $options );
+ $this->attributeEscapes["\xc2\xa0"] = '&#160;';
+ unset( $this->attributeEscapes["&"] );
+ $this->textEscapes["\xc2\xa0"] = '&#160;';
+ unset( $this->textEscapes["&"] );
+ }
+
+ public function startDocument( $fragmentNamespace, $fragmentName ) {
+ return '';
+ }
+
+ public function element( SerializerNode $parent, SerializerNode $node, $contents ) {
+ $data = $node->snData;
+ if ( $data && $data->isPWrapper ) {
+ if ( $data->nonblankNodeCount ) {
+ return "<p>$contents</p>";
+ } else {
+ return $contents;
+ }
+ }
+
+ $name = $node->name;
+ $attrs = $node->attrs;
+ if ( isset( self::$markedEmptyElements[$name] ) && $attrs->count() === 0 ) {
+ if ( strspn( $contents, "\t\n\f\r " ) === strlen( $contents ) ) {
+ return "<{$name} class=\"mw-empty-elt\">$contents</{$name}>";
+ }
+ }
+
+ $s = "<$name";
+ foreach ( $attrs->getValues() as $attrName => $attrValue ) {
+ $encValue = strtr( $attrValue, $this->attributeEscapes );
+ $s .= " $attrName=\"$encValue\"";
+ }
+ if ( $node->namespace === HTMLData::NS_HTML && isset( $this->voidElements[$name] ) ) {
+ $s .= ' />';
+ return $s;
+ }
+
+ $s .= '>';
+ if ( $node->namespace === HTMLData::NS_HTML
+ && isset( $contents[0] ) && $contents[0] === "\n"
+ && isset( $this->prefixLfElements[$name] )
+ ) {
+ $s .= "\n$contents</$name>";
+ } else {
+ $s .= "$contents</$name>";
+ }
+ return $s;
+ }
+}
diff --git a/www/wiki/includes/tidy/RemexCompatMunger.php b/www/wiki/includes/tidy/RemexCompatMunger.php
new file mode 100644
index 00000000..73bc5f84
--- /dev/null
+++ b/www/wiki/includes/tidy/RemexCompatMunger.php
@@ -0,0 +1,472 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+use RemexHtml\HTMLData;
+use RemexHtml\Serializer\Serializer;
+use RemexHtml\Serializer\SerializerNode;
+use RemexHtml\Tokenizer\Attributes;
+use RemexHtml\Tokenizer\PlainAttributes;
+use RemexHtml\TreeBuilder\TreeBuilder;
+use RemexHtml\TreeBuilder\TreeHandler;
+use RemexHtml\TreeBuilder\Element;
+
+/**
+ * @internal
+ */
+class RemexCompatMunger implements TreeHandler {
+ private static $onlyInlineElements = [
+ "a" => true,
+ "abbr" => true,
+ "acronym" => true,
+ "applet" => true,
+ "b" => true,
+ "basefont" => true,
+ "bdo" => true,
+ "big" => true,
+ "br" => true,
+ "button" => true,
+ "cite" => true,
+ "code" => true,
+ "dfn" => true,
+ "em" => true,
+ "font" => true,
+ "i" => true,
+ "iframe" => true,
+ "img" => true,
+ "input" => true,
+ "kbd" => true,
+ "label" => true,
+ "legend" => true,
+ "map" => true,
+ "object" => true,
+ "param" => true,
+ "q" => true,
+ "rb" => true,
+ "rbc" => true,
+ "rp" => true,
+ "rt" => true,
+ "rtc" => true,
+ "ruby" => true,
+ "s" => true,
+ "samp" => true,
+ "select" => true,
+ "small" => true,
+ "span" => true,
+ "strike" => true,
+ "strong" => true,
+ "sub" => true,
+ "sup" => true,
+ "textarea" => true,
+ "tt" => true,
+ "u" => true,
+ "var" => true,
+ ];
+
+ private static $formattingElements = [
+ 'a' => true,
+ 'b' => true,
+ 'big' => true,
+ 'code' => true,
+ 'em' => true,
+ 'font' => true,
+ 'i' => true,
+ 'nobr' => true,
+ 's' => true,
+ 'small' => true,
+ 'strike' => true,
+ 'strong' => true,
+ 'tt' => true,
+ 'u' => true,
+ ];
+
+ /**
+ * @param Serializer $serializer
+ */
+ public function __construct( Serializer $serializer ) {
+ $this->serializer = $serializer;
+ }
+
+ public function startDocument( $fragmentNamespace, $fragmentName ) {
+ $this->serializer->startDocument( $fragmentNamespace, $fragmentName );
+ $root = $this->serializer->getRootNode();
+ $root->snData = new RemexMungerData;
+ $root->snData->needsPWrapping = true;
+ }
+
+ public function endDocument( $pos ) {
+ $this->serializer->endDocument( $pos );
+ }
+
+ private function getParentForInsert( $preposition, $refElement ) {
+ if ( $preposition === TreeBuilder::ROOT ) {
+ return [ $this->serializer->getRootNode(), null ];
+ } elseif ( $preposition === TreeBuilder::BEFORE ) {
+ $refNode = $refElement->userData;
+ return [ $this->serializer->getParentNode( $refNode ), $refNode ];
+ } else {
+ $refNode = $refElement->userData;
+ $refData = $refNode->snData;
+ if ( $refData->currentCloneElement ) {
+ // Follow a chain of clone links if necessary
+ $origRefData = $refData;
+ while ( $refData->currentCloneElement ) {
+ $refElement = $refData->currentCloneElement;
+ $refNode = $refElement->userData;
+ $refData = $refNode->snData;
+ }
+ // Cache the end of the chain in the requested element
+ $origRefData->currentCloneElement = $refElement;
+ } elseif ( $refData->childPElement ) {
+ $refElement = $refData->childPElement;
+ $refNode = $refElement->userData;
+ }
+ return [ $refNode, $refNode ];
+ }
+ }
+
+ /**
+ * Insert a p-wrapper
+ *
+ * @param SerializerNode $parent
+ * @param int $sourceStart
+ * @return SerializerNode
+ */
+ private function insertPWrapper( SerializerNode $parent, $sourceStart ) {
+ $pWrap = new Element( HTMLData::NS_HTML, 'mw:p-wrap', new PlainAttributes );
+ $this->serializer->insertElement( TreeBuilder::UNDER, $parent, $pWrap, false,
+ $sourceStart, 0 );
+ $data = new RemexMungerData;
+ $data->isPWrapper = true;
+ $data->wrapBaseNode = $parent;
+ $pWrap->userData->snData = $data;
+ $parent->snData->childPElement = $pWrap;
+ return $pWrap->userData;
+ }
+
+ public function characters( $preposition, $refElement, $text, $start, $length,
+ $sourceStart, $sourceLength
+ ) {
+ $isBlank = strspn( $text, "\t\n\f\r ", $start, $length ) === $length;
+
+ list( $parent, $refNode ) = $this->getParentForInsert( $preposition, $refElement );
+ $parentData = $parent->snData;
+
+ if ( $preposition === TreeBuilder::UNDER ) {
+ if ( $parentData->needsPWrapping && !$isBlank ) {
+ // Add a p-wrapper for bare text under body/blockquote
+ $refNode = $this->insertPWrapper( $refNode, $sourceStart );
+ $parent = $refNode;
+ $parentData = $parent->snData;
+ } elseif ( $parentData->isSplittable && !$parentData->ancestorPNode ) {
+ // The parent is splittable and in block mode, so split the tag stack
+ $refNode = $this->splitTagStack( $refNode, true, $sourceStart );
+ $parent = $refNode;
+ $parentData = $parent->snData;
+ }
+ }
+
+ if ( !$isBlank ) {
+ // Non-whitespace characters detected
+ $parentData->nonblankNodeCount++;
+ }
+ $this->serializer->characters( $preposition, $refNode, $text, $start,
+ $length, $sourceStart, $sourceLength );
+ }
+
+ /**
+ * Insert or reparent an element. Create p-wrappers or split the tag stack
+ * as necessary.
+ *
+ * Consider the following insertion locations. The parent may be:
+ *
+ * - A: A body or blockquote (!!needsPWrapping)
+ * - B: A p-wrapper (!!isPWrapper)
+ * - C: A descendant of a p-wrapper (!!ancestorPNode)
+ * - CS: With splittable formatting elements in the stack region up to
+ * the p-wrapper
+ * - CU: With one or more unsplittable elements in the stack region up
+ * to the p-wrapper
+ * - D: Not a descendant of a p-wrapper (!ancestorNode)
+ * - DS: With splittable formatting elements in the stack region up to
+ * the body or blockquote
+ * - DU: With one or more unsplittable elements in the stack region up
+ * to the body or blockquote
+ *
+ * And consider that we may insert two types of element:
+ * - b: block
+ * - i: inline
+ *
+ * We handle the insertion as follows:
+ *
+ * - A/i: Create a p-wrapper, insert under it
+ * - A/b: Insert as normal
+ * - B/i: Insert as normal
+ * - B/b: Close the p-wrapper, insert under the body/blockquote (wrap
+ * base) instead)
+ * - C/i: Insert as normal
+ * - CS/b: Split the tag stack, insert the block under cloned formatting
+ * elements which have the wrap base (the parent of the p-wrap) as
+ * their ultimate parent.
+ * - CU/b: Disable the p-wrap, by reparenting the currently open child
+ * of the p-wrap under the p-wrap's parent. Then insert the block as
+ * normal.
+ * - D/b: Insert as normal
+ * - DS/i: Split the tag stack, creating a new p-wrapper as the ultimate
+ * parent of the formatting elements thus cloned. The parent of the
+ * p-wrapper is the body or blockquote.
+ * - DU/i: Insert as normal
+ *
+ * FIXME: fostering ($preposition == BEFORE) is mostly done by inserting as
+ * normal, the full algorithm is not followed.
+ *
+ * @param int $preposition
+ * @param Element|SerializerNode|null $refElement
+ * @param Element $element
+ * @param bool $void
+ * @param int $sourceStart
+ * @param int $sourceLength
+ */
+ public function insertElement( $preposition, $refElement, Element $element, $void,
+ $sourceStart, $sourceLength
+ ) {
+ list( $parent, $newRef ) = $this->getParentForInsert( $preposition, $refElement );
+ $parentData = $parent->snData;
+ $parentNs = $parent->namespace;
+ $parentName = $parent->name;
+ $elementName = $element->htmlName;
+
+ $inline = isset( self::$onlyInlineElements[$elementName] );
+ $under = $preposition === TreeBuilder::UNDER;
+
+ if ( $under && $parentData->isPWrapper && !$inline ) {
+ // [B/b] The element is non-inline and the parent is a p-wrapper,
+ // close the parent and insert into its parent instead
+ $newParent = $this->serializer->getParentNode( $parent );
+ $parent = $newParent;
+ $parentData = $parent->snData;
+ $pElement = $parentData->childPElement;
+ $parentData->childPElement = null;
+ $newRef = $refElement->userData;
+ $this->endTag( $pElement, $sourceStart, 0 );
+ } elseif ( $under && $parentData->isSplittable
+ && (bool)$parentData->ancestorPNode !== $inline
+ ) {
+ // [CS/b, DS/i] The parent is splittable and the current element is
+ // inline in block context, or if the current element is a block
+ // under a p-wrapper, split the tag stack.
+ $newRef = $this->splitTagStack( $newRef, $inline, $sourceStart );
+ $parent = $newRef;
+ $parentData = $parent->snData;
+ } elseif ( $under && $parentData->needsPWrapping && $inline ) {
+ // [A/i] If the element is inline and we are in body/blockquote,
+ // we need to create a p-wrapper
+ $newRef = $this->insertPWrapper( $newRef, $sourceStart );
+ $parent = $newRef;
+ $parentData = $parent->snData;
+ } elseif ( $parentData->ancestorPNode && !$inline ) {
+ // [CU/b] If the element is non-inline and (despite attempting to
+ // split above) there is still an ancestor p-wrap, disable that
+ // p-wrap
+ $this->disablePWrapper( $parent, $sourceStart );
+ }
+ // else [A/b, B/i, C/i, D/b, DU/i] insert as normal
+
+ // An element with element children is a non-blank element
+ $parentData->nonblankNodeCount++;
+
+ // Insert the element downstream and so initialise its userData
+ $this->serializer->insertElement( $preposition, $newRef,
+ $element, $void, $sourceStart, $sourceLength );
+
+ // Initialise snData
+ if ( !$element->userData->snData ) {
+ $elementData = $element->userData->snData = new RemexMungerData;
+ } else {
+ $elementData = $element->userData->snData;
+ }
+ if ( ( $parentData->isPWrapper || $parentData->isSplittable )
+ && isset( self::$formattingElements[$elementName] )
+ ) {
+ $elementData->isSplittable = true;
+ }
+ if ( $parentData->isPWrapper ) {
+ $elementData->ancestorPNode = $parent;
+ } elseif ( $parentData->ancestorPNode ) {
+ $elementData->ancestorPNode = $parentData->ancestorPNode;
+ }
+ if ( $parentData->wrapBaseNode ) {
+ $elementData->wrapBaseNode = $parentData->wrapBaseNode;
+ } elseif ( $parentData->needsPWrapping ) {
+ $elementData->wrapBaseNode = $parent;
+ }
+ if ( $elementName === 'body'
+ || $elementName === 'blockquote'
+ || $elementName === 'html'
+ ) {
+ $elementData->needsPWrapping = true;
+ }
+ }
+
+ /**
+ * Clone nodes in a stack range and return the new parent
+ *
+ * @param SerializerNode $parentNode
+ * @param bool $inline
+ * @param int $pos The source position
+ * @return SerializerNode
+ */
+ private function splitTagStack( SerializerNode $parentNode, $inline, $pos ) {
+ $parentData = $parentNode->snData;
+ $wrapBase = $parentData->wrapBaseNode;
+ $pWrap = $parentData->ancestorPNode;
+ if ( !$pWrap ) {
+ $cloneEnd = $wrapBase;
+ } else {
+ $cloneEnd = $parentData->ancestorPNode;
+ }
+
+ $serializer = $this->serializer;
+ $node = $parentNode;
+ $root = $serializer->getRootNode();
+ $nodes = [];
+ $removableNodes = [];
+ $haveContent = false;
+ while ( $node !== $cloneEnd ) {
+ $nextParent = $serializer->getParentNode( $node );
+ if ( $nextParent === $root ) {
+ throw new \Exception( 'Did not find end of clone range' );
+ }
+ $nodes[] = $node;
+ if ( $node->snData->nonblankNodeCount === 0 ) {
+ $removableNodes[] = $node;
+ $nextParent->snData->nonblankNodeCount--;
+ }
+ $node = $nextParent;
+ }
+
+ if ( $inline ) {
+ $pWrap = $this->insertPWrapper( $wrapBase, $pos );
+ $node = $pWrap;
+ } else {
+ if ( $pWrap ) {
+ // End the p-wrap which was open, cancel the diversion
+ $wrapBase->snData->childPElement = null;
+ }
+ $pWrap = null;
+ $node = $wrapBase;
+ }
+
+ for ( $i = count( $nodes ) - 1; $i >= 0; $i-- ) {
+ $oldNode = $nodes[$i];
+ $oldData = $oldNode->snData;
+ $nodeParent = $node;
+ $element = new Element( $oldNode->namespace, $oldNode->name, $oldNode->attrs );
+ $this->serializer->insertElement( TreeBuilder::UNDER, $nodeParent,
+ $element, false, $pos, 0 );
+ $oldData->currentCloneElement = $element;
+
+ $newNode = $element->userData;
+ $newData = $newNode->snData = new RemexMungerData;
+ if ( $pWrap ) {
+ $newData->ancestorPNode = $pWrap;
+ }
+ $newData->isSplittable = true;
+ $newData->wrapBaseNode = $wrapBase;
+ $newData->isPWrapper = $oldData->isPWrapper;
+
+ $nodeParent->snData->nonblankNodeCount++;
+
+ $node = $newNode;
+ }
+ foreach ( $removableNodes as $rNode ) {
+ $fakeElement = new Element( $rNode->namespace, $rNode->name, $rNode->attrs );
+ $fakeElement->userData = $rNode;
+ $this->serializer->removeNode( $fakeElement, $pos );
+ }
+ return $node;
+ }
+
+ /**
+ * Find the ancestor of $node which is a child of a p-wrapper, and
+ * reparent that node so that it is placed after the end of the p-wrapper
+ */
+ private function disablePWrapper( SerializerNode $node, $sourceStart ) {
+ $nodeData = $node->snData;
+ $pWrapNode = $nodeData->ancestorPNode;
+ $newParent = $this->serializer->getParentNode( $pWrapNode );
+ if ( $pWrapNode !== $this->serializer->getLastChild( $newParent ) ) {
+ // Fostering or something? Abort!
+ return;
+ }
+
+ $nextParent = $node;
+ do {
+ $victim = $nextParent;
+ $victim->snData->ancestorPNode = null;
+ $nextParent = $this->serializer->getParentNode( $victim );
+ } while ( $nextParent !== $pWrapNode );
+
+ // Make a fake Element to use in a reparenting operation
+ $victimElement = new Element( $victim->namespace, $victim->name, $victim->attrs );
+ $victimElement->userData = $victim;
+
+ // Reparent
+ $this->serializer->insertElement( TreeBuilder::UNDER, $newParent, $victimElement,
+ false, $sourceStart, 0 );
+
+ // Decrement nonblank node count
+ $pWrapNode->snData->nonblankNodeCount--;
+
+ // Cancel the diversion so that no more elements are inserted under this p-wrap
+ $newParent->snData->childPElement = null;
+ }
+
+ public function endTag( Element $element, $sourceStart, $sourceLength ) {
+ $data = $element->userData->snData;
+ if ( $data->childPElement ) {
+ $this->endTag( $data->childPElement, $sourceStart, 0 );
+ }
+ $this->serializer->endTag( $element, $sourceStart, $sourceLength );
+ $element->userData->snData = null;
+ $element->userData = null;
+ }
+
+ public function doctype( $name, $public, $system, $quirks, $sourceStart, $sourceLength ) {
+ $this->serializer->doctype( $name, $public, $system, $quirks,
+ $sourceStart, $sourceLength );
+ }
+
+ public function comment( $preposition, $refElement, $text, $sourceStart, $sourceLength ) {
+ list( $parent, $refNode ) = $this->getParentForInsert( $preposition, $refElement );
+ $this->serializer->comment( $preposition, $refNode, $text,
+ $sourceStart, $sourceLength );
+ }
+
+ public function error( $text, $pos ) {
+ $this->serializer->error( $text, $pos );
+ }
+
+ public function mergeAttributes( Element $element, Attributes $attrs, $sourceStart ) {
+ $this->serializer->mergeAttributes( $element, $attrs, $sourceStart );
+ }
+
+ public function removeNode( Element $element, $sourceStart ) {
+ $this->serializer->removeNode( $element, $sourceStart );
+ }
+
+ public function reparentChildren( Element $element, Element $newParent, $sourceStart ) {
+ $self = $element->userData;
+ $children = $self->children;
+ $self->children = [];
+ $this->insertElement( TreeBuilder::UNDER, $element, $newParent, false, $sourceStart, 0 );
+ $newParentNode = $newParent->userData;
+ $newParentId = $newParentNode->id;
+ foreach ( $children as $child ) {
+ if ( is_object( $child ) ) {
+ $child->parentId = $newParentId;
+ }
+ }
+ $newParentNode->children = $children;
+ }
+}
diff --git a/www/wiki/includes/tidy/RemexDriver.php b/www/wiki/includes/tidy/RemexDriver.php
new file mode 100644
index 00000000..e02af88f
--- /dev/null
+++ b/www/wiki/includes/tidy/RemexDriver.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+use RemexHtml\Serializer\Serializer;
+use RemexHtml\Tokenizer\Tokenizer;
+use RemexHtml\TreeBuilder\Dispatcher;
+use RemexHtml\TreeBuilder\TreeBuilder;
+use RemexHtml\TreeBuilder\TreeMutationTracer;
+
+class RemexDriver extends TidyDriverBase {
+ private $trace;
+ private $pwrap;
+
+ public function __construct( array $config ) {
+ $config += [
+ 'treeMutationTrace' => false,
+ 'pwrap' => true
+ ];
+ $this->trace = $config['treeMutationTrace'];
+ $this->pwrap = $config['pwrap'];
+ parent::__construct( $config );
+ }
+
+ public function tidy( $text ) {
+ $formatter = new RemexCompatFormatter;
+ $serializer = new Serializer( $formatter );
+ if ( $this->pwrap ) {
+ $munger = new RemexCompatMunger( $serializer );
+ } else {
+ $munger = $serializer;
+ }
+ if ( $this->trace ) {
+ $tracer = new TreeMutationTracer( $munger, function ( $msg ) {
+ wfDebug( "RemexHtml: $msg" );
+ } );
+ } else {
+ $tracer = $munger;
+ }
+ $treeBuilder = new TreeBuilder( $tracer, [
+ 'ignoreErrors' => true,
+ 'ignoreNulls' => true,
+ ] );
+ $dispatcher = new Dispatcher( $treeBuilder );
+ $tokenizer = new Tokenizer( $dispatcher, $text, [
+ 'ignoreErrors' => true,
+ 'ignoreCharRefs' => true,
+ 'ignoreNulls' => true,
+ 'skipPreprocess' => true,
+ ] );
+ $tokenizer->execute( [
+ 'fragmentNamespace' => \RemexHtml\HTMLData::NS_HTML,
+ 'fragmentName' => 'body'
+ ] );
+ return $serializer->getResult();
+ }
+}
diff --git a/www/wiki/includes/tidy/RemexMungerData.php b/www/wiki/includes/tidy/RemexMungerData.php
new file mode 100644
index 00000000..d614a381
--- /dev/null
+++ b/www/wiki/includes/tidy/RemexMungerData.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+/**
+ * @internal
+ */
+class RemexMungerData {
+ /**
+ * The Element for the mw:p-wrap which is a child of the current node. If
+ * this is set, inline insertions into this node will be diverted so that
+ * they insert into the p-wrap.
+ *
+ * @var \RemexHtml\TreeBuilder\Element|null
+ */
+ public $childPElement;
+
+ /**
+ * This tracks the mw:p-wrap node in the Serializer stack which is an
+ * ancestor of this node. If there is no mw:p-wrap ancestor, it is null.
+ *
+ * @var \RemexHtml\Serializer\SerializerNode|null
+ */
+ public $ancestorPNode;
+
+ /**
+ * The wrap base node is the body or blockquote node which is the parent
+ * of active p-wrappers. This is set if there is an ancestor p-wrapper,
+ * or if a p-wrapper was closed due to a block element being encountered
+ * inside it.
+ *
+ * @var \RemexHtml\Serializer\SerializerNode|null
+ */
+ public $wrapBaseNode;
+
+ /**
+ * Stack splitting (essentially our idea of AFE reconstruction) can clone
+ * formatting elements which are split over multiple paragraphs.
+ * TreeBuilder is not aware of the cloning, and continues to insert into
+ * the original element. This is set to the newer clone if this node was
+ * cloned, i.e. if there is an active diversion of the insertion location.
+ *
+ * @var \RemexHtml\TreeBuilder\Element|null
+ */
+ public $currentCloneElement;
+
+ /**
+ * Is the node a p-wrapper, with name mw:p-wrap?
+ *
+ * @var bool
+ */
+ public $isPWrapper = false;
+
+ /**
+ * Is the node splittable, i.e. a formatting element or a node with a
+ * formatting element ancestor which is under an active or deactivated
+ * p-wrapper.
+ *
+ * @var bool
+ */
+ public $isSplittable = false;
+
+ /**
+ * This is true if the node is a body or blockquote, which activates
+ * p-wrapping of child nodes.
+ */
+ public $needsPWrapping = false;
+
+ /**
+ * The number of child nodes, not counting whitespace-only text nodes or
+ * comments.
+ */
+ public $nonblankNodeCount = 0;
+
+ public function __set( $name, $value ) {
+ throw new \Exception( "Cannot set property \"$name\"" );
+ }
+}
diff --git a/www/wiki/includes/tidy/TidyDriverBase.php b/www/wiki/includes/tidy/TidyDriverBase.php
new file mode 100644
index 00000000..f88b6734
--- /dev/null
+++ b/www/wiki/includes/tidy/TidyDriverBase.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace MediaWiki\Tidy;
+
+/**
+ * Base class for HTML cleanup utilities
+ */
+abstract class TidyDriverBase {
+ protected $config;
+
+ function __construct( $config ) {
+ $this->config = $config;
+ }
+
+ /**
+ * Return true if validate() can be used
+ * @return bool
+ */
+ public function supportsValidate() {
+ return false;
+ }
+
+ /**
+ * Check HTML for errors, used if $wgValidateAllHtml = true.
+ *
+ * @param string $text
+ * @param string &$errorStr Return the error string
+ * @throws \MWException
+ * @return bool Whether the HTML is valid
+ */
+ public function validate( $text, &$errorStr ) {
+ throw new \MWException( static::class . ' does not support validate()' );
+ }
+
+ /**
+ * Clean up HTML
+ *
+ * @param string $text HTML document fragment to clean up
+ * @return string The corrected HTML output
+ */
+ abstract public function tidy( $text );
+}
diff --git a/www/wiki/includes/tidy/tidy.conf b/www/wiki/includes/tidy/tidy.conf
new file mode 100644
index 00000000..d4a31993
--- /dev/null
+++ b/www/wiki/includes/tidy/tidy.conf
@@ -0,0 +1,24 @@
+# html tidy (http://tidy.sf.net) configuration
+# tidy - validate, correct, and pretty-print HTML files
+# see: man 1 tidy, http://tidy.sourceforge.net/docs/quickref.html
+
+show-body-only: yes
+force-output: yes
+tidy-mark: no
+wrap: 0
+wrap-attributes: no
+literal-attributes: yes
+output-xhtml: yes
+numeric-entities: yes
+enclose-text: yes
+enclose-block-text: yes
+quiet: yes
+quote-nbsp: yes
+fix-backslash: no
+fix-uri: no
+# Don't strip html5 elements we support
+# html-{meta,link} is a hack we use to prevent Tidy from stripping <meta> and <link> used in the body for Microdata
+new-empty-tags: html-meta, html-link, wbr, source, track
+new-inline-tags: video, audio, bdi, data, time, mark
+# html-style is a hack we use to prevent pre-HTML5 versions of Tidy from stripping <style> used in the body for TemplateStyles
+new-blocklevel-tags: html-style
diff --git a/www/wiki/includes/title/ForeignTitle.php b/www/wiki/includes/title/ForeignTitle.php
new file mode 100644
index 00000000..6e6f2ead
--- /dev/null
+++ b/www/wiki/includes/title/ForeignTitle.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * A structure to hold the title of a page on a foreign MediaWiki installation
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author This, that and the other
+ */
+
+/**
+ * A simple, immutable structure to hold the title of a page on a foreign
+ * MediaWiki installation.
+ */
+class ForeignTitle {
+ /**
+ * @var int|null
+ * Null if we don't know the namespace ID (e.g. interwiki links)
+ */
+ private $namespaceId;
+ /** @var string */
+ private $namespaceName;
+ /** @var string */
+ private $pageName;
+
+ /**
+ * Creates a new ForeignTitle object.
+ *
+ * @param int|null $namespaceId Null if the namespace ID is unknown (e.g.
+ * interwiki links)
+ * @param string $namespaceName
+ * @param string $pageName
+ */
+ public function __construct( $namespaceId, $namespaceName, $pageName ) {
+ if ( is_null( $namespaceId ) ) {
+ $this->namespaceId = null;
+ } else {
+ $this->namespaceId = intval( $namespaceId );
+ }
+ $this->namespaceName = str_replace( ' ', '_', $namespaceName );
+ $this->pageName = str_replace( ' ', '_', $pageName );
+ }
+
+ /**
+ * Do we know the namespace ID of the page on the foreign wiki?
+ * @return bool
+ */
+ public function isNamespaceIdKnown() {
+ return !is_null( $this->namespaceId );
+ }
+
+ /**
+ * @return int
+ * @throws MWException If isNamespaceIdKnown() is false, it does not make
+ * sense to call this function.
+ */
+ public function getNamespaceId() {
+ if ( is_null( $this->namespaceId ) ) {
+ throw new MWException(
+ "Attempted to call getNamespaceId when the namespace ID is not known" );
+ }
+ return $this->namespaceId;
+ }
+
+ /** @return string */
+ public function getNamespaceName() {
+ return $this->namespaceName;
+ }
+
+ /** @return string */
+ public function getText() {
+ return $this->pageName;
+ }
+
+ /** @return string */
+ public function getFullText() {
+ $result = '';
+ if ( $this->namespaceName ) {
+ $result .= $this->namespaceName . ':';
+ }
+ $result .= $this->pageName;
+ return $result;
+ }
+
+ /**
+ * Returns a string representation of the title, for logging. This is purely
+ * informative and must not be used programmatically. Use the appropriate
+ * ImportTitleFactory to generate the correct string representation for a
+ * given use.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $name = '';
+ if ( $this->isNamespaceIdKnown() ) {
+ $name .= '{ns' . $this->namespaceId . '}';
+ } else {
+ $name .= '{ns??}';
+ }
+ $name .= $this->namespaceName . ':' . $this->pageName;
+
+ return $name;
+ }
+}
diff --git a/www/wiki/includes/title/ForeignTitleFactory.php b/www/wiki/includes/title/ForeignTitleFactory.php
new file mode 100644
index 00000000..427afdf3
--- /dev/null
+++ b/www/wiki/includes/title/ForeignTitleFactory.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ */
+
+/**
+ * A parser that translates page titles into ForeignTitle objects.
+ */
+interface ForeignTitleFactory {
+ /**
+ * Creates a ForeignTitle object based on the page title, and optionally the
+ * namespace ID, of a page on a foreign wiki. These values could be, for
+ * example, the <title> and <ns> attributes found in an XML dump.
+ *
+ * @param string $title The page title
+ * @param int|null $ns The namespace ID, or null if this data is not available
+ * @return ForeignTitle
+ */
+ public function createForeignTitle( $title, $ns = null );
+}
diff --git a/www/wiki/includes/title/ImportTitleFactory.php b/www/wiki/includes/title/ImportTitleFactory.php
new file mode 100644
index 00000000..629616d8
--- /dev/null
+++ b/www/wiki/includes/title/ImportTitleFactory.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ */
+
+/**
+ * Represents an object that can convert page titles on a foreign wiki
+ * (ForeignTitle objects) into page titles on the local wiki (Title objects).
+ */
+interface ImportTitleFactory {
+ /**
+ * Determines which local title best corresponds to the given foreign title.
+ * If such a title can't be found or would be locally invalid, null is
+ * returned.
+ *
+ * @param ForeignTitle $foreignTitle The ForeignTitle to convert
+ * @return Title|null
+ */
+ public function createTitleFromForeignTitle( ForeignTitle $foreignTitle );
+}
diff --git a/www/wiki/includes/title/MalformedTitleException.php b/www/wiki/includes/title/MalformedTitleException.php
new file mode 100644
index 00000000..213343f9
--- /dev/null
+++ b/www/wiki/includes/title/MalformedTitleException.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * MalformedTitleException is thrown when a TitleParser is unable to parse a title string.
+ * @since 1.23
+ */
+class MalformedTitleException extends Exception implements ILocalizedException {
+ private $titleText = null;
+ private $errorMessage = null;
+ private $errorMessageParameters = [];
+
+ /**
+ * @param string $errorMessage Localisation message describing the error (since MW 1.26)
+ * @param string $titleText The invalid title text (since MW 1.26)
+ * @param string[] $errorMessageParameters Additional parameters for the error message.
+ * $titleText will be appended if it's not null. (since MW 1.26)
+ */
+ public function __construct(
+ $errorMessage, $titleText = null, $errorMessageParameters = []
+ ) {
+ $this->errorMessage = $errorMessage;
+ $this->titleText = $titleText;
+ if ( $titleText !== null ) {
+ $errorMessageParameters[] = $titleText;
+ }
+ $this->errorMessageParameters = $errorMessageParameters;
+
+ // Supply something useful for Exception::getMessage() to return.
+ $enMsg = wfMessage( $errorMessage, $errorMessageParameters );
+ $enMsg->inLanguage( 'en' )->useDatabase( false );
+ parent::__construct( $enMsg->text() );
+ }
+
+ /**
+ * @since 1.26
+ * @return string|null
+ */
+ public function getTitleText() {
+ return $this->titleText;
+ }
+
+ /**
+ * @since 1.26
+ * @return string
+ */
+ public function getErrorMessage() {
+ return $this->errorMessage;
+ }
+
+ /**
+ * @since 1.26
+ * @return string[]
+ */
+ public function getErrorMessageParameters() {
+ return $this->errorMessageParameters;
+ }
+
+ /**
+ * @since 1.29
+ * @return Message
+ */
+ public function getMessageObject() {
+ return wfMessage( $this->getErrorMessage(), $this->getErrorMessageParameters() );
+ }
+}
diff --git a/www/wiki/includes/title/MediaWikiPageLinkRenderer.php b/www/wiki/includes/title/MediaWikiPageLinkRenderer.php
new file mode 100644
index 00000000..a5652710
--- /dev/null
+++ b/www/wiki/includes/title/MediaWikiPageLinkRenderer.php
@@ -0,0 +1,134 @@
+<?php
+/**
+ * A service for generating links from page titles
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Daniel Kinzler
+ */
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * A service for generating links from page titles.
+ *
+ * @see https://www.mediawiki.org/wiki/Requests_for_comment/TitleValue
+ * @since 1.23
+ */
+class MediaWikiPageLinkRenderer implements PageLinkRenderer {
+ /**
+ * @var TitleFormatter
+ */
+ protected $formatter;
+
+ /**
+ * @var string
+ */
+ protected $baseUrl;
+
+ /**
+ * @note $formatter and $baseUrl are currently not used for generating links,
+ * since we still rely on the Linker class to generate the actual HTML.
+ * Once this is reversed so that Linker becomes a legacy interface to
+ * HtmlPageLinkRenderer, we will be using them, so it seems prudent to
+ * already declare the dependency and inject them.
+ *
+ * @param TitleFormatter $formatter Formatter for generating the target title string
+ * @param string $baseUrl (currently unused, pending refactoring of Linker).
+ * Defaults to $wgArticlePath.
+ */
+ public function __construct( TitleFormatter $formatter, $baseUrl = null ) {
+ if ( $baseUrl === null ) {
+ $baseUrl = $GLOBALS['wgArticlePath'];
+ }
+
+ $this->formatter = $formatter;
+ $this->baseUrl = $baseUrl;
+ }
+
+ /**
+ * Returns the (partial) URL for the given page (including any section identifier).
+ *
+ * @param LinkTarget $page The link's target
+ * @param array $params Any additional URL parameters.
+ *
+ * @return string
+ */
+ public function getPageUrl( LinkTarget $page, $params = [] ) {
+ // TODO: move the code from Linker::linkUrl here!
+ // The below is just a rough estimation!
+
+ $name = $this->formatter->getPrefixedText( $page );
+ $name = str_replace( ' ', '_', $name );
+ $name = wfUrlencode( $name );
+
+ $url = $this->baseUrl . $name;
+
+ if ( $params ) {
+ $separator = ( strpos( $url, '?' ) ) ? '&' : '?';
+ $url .= $separator . wfArrayToCgi( $params );
+ }
+
+ $fragment = $page->getFragment();
+ if ( $fragment !== '' ) {
+ $url = $url . '#' . wfUrlencode( $fragment );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Returns an HTML link to the given page, using the given surface text.
+ *
+ * @param LinkTarget $linkTarget The link's target
+ * @param string $text The link's surface text (will be derived from $page if not given).
+ *
+ * @return string
+ */
+ public function renderHtmlLink( LinkTarget $linkTarget, $text = null ) {
+ if ( $text === null ) {
+ $text = $this->formatter->getFullText( $linkTarget );
+ }
+
+ // TODO: move the logic implemented by Linker here,
+ // using $this->formatter and $this->baseUrl, and
+ // re-implement Linker to use a HtmlPageLinkRenderer.
+
+ $title = Title::newFromLinkTarget( $linkTarget );
+ $link = Linker::link( $title, htmlspecialchars( $text ) );
+
+ return $link;
+ }
+
+ /**
+ * Returns a wikitext link to the given page, using the given surface text.
+ *
+ * @param LinkTarget $page The link's target
+ * @param string $text The link's surface text (will be derived from $page if not given).
+ *
+ * @return string
+ */
+ public function renderWikitextLink( LinkTarget $page, $text = null ) {
+ if ( $text === null ) {
+ $text = $this->formatter->getFullText( $page );
+ }
+
+ $name = $this->formatter->getFullText( $page );
+
+ return '[[:' . $name . '|' . wfEscapeWikiText( $text ) . ']]';
+ }
+}
diff --git a/www/wiki/includes/title/MediaWikiTitleCodec.php b/www/wiki/includes/title/MediaWikiTitleCodec.php
new file mode 100644
index 00000000..efc0fd4a
--- /dev/null
+++ b/www/wiki/includes/title/MediaWikiTitleCodec.php
@@ -0,0 +1,493 @@
+<?php
+/**
+ * A codec for %MediaWiki page titles.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Daniel Kinzler
+ */
+use MediaWiki\Interwiki\InterwikiLookup;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * A codec for %MediaWiki page titles.
+ *
+ * @note Normalization and validation is applied while parsing, not when formatting.
+ * It's possible to construct a TitleValue with an invalid title, and use MediaWikiTitleCodec
+ * to generate an (invalid) title string from it. TitleValues should be constructed only
+ * via parseTitle() or from a (semi)trusted source, such as the database.
+ *
+ * @see https://www.mediawiki.org/wiki/Requests_for_comment/TitleValue
+ * @since 1.23
+ */
+class MediaWikiTitleCodec implements TitleFormatter, TitleParser {
+ /**
+ * @var Language
+ */
+ protected $language;
+
+ /**
+ * @var GenderCache
+ */
+ protected $genderCache;
+
+ /**
+ * @var string[]
+ */
+ protected $localInterwikis;
+
+ /**
+ * @var InterwikiLookup
+ */
+ protected $interwikiLookup;
+
+ /**
+ * @param Language $language The language object to use for localizing namespace names.
+ * @param GenderCache $genderCache The gender cache for generating gendered namespace names
+ * @param string[]|string $localInterwikis
+ * @param InterwikiLookup|null $interwikiLookup
+ */
+ public function __construct( Language $language, GenderCache $genderCache,
+ $localInterwikis = [], $interwikiLookup = null
+ ) {
+ $this->language = $language;
+ $this->genderCache = $genderCache;
+ $this->localInterwikis = (array)$localInterwikis;
+ $this->interwikiLookup = $interwikiLookup ?:
+ MediaWikiServices::getInstance()->getInterwikiLookup();
+ }
+
+ /**
+ * @see TitleFormatter::getNamespaceName()
+ *
+ * @param int $namespace
+ * @param string $text
+ *
+ * @throws InvalidArgumentException If the namespace is invalid
+ * @return string
+ */
+ public function getNamespaceName( $namespace, $text ) {
+ if ( $this->language->needsGenderDistinction() &&
+ MWNamespace::hasGenderDistinction( $namespace )
+ ) {
+ // NOTE: we are assuming here that the title text is a user name!
+ $gender = $this->genderCache->getGenderOf( $text, __METHOD__ );
+ $name = $this->language->getGenderNsText( $namespace, $gender );
+ } else {
+ $name = $this->language->getNsText( $namespace );
+ }
+
+ if ( $name === false ) {
+ throw new InvalidArgumentException( 'Unknown namespace ID: ' . $namespace );
+ }
+
+ return $name;
+ }
+
+ /**
+ * @see TitleFormatter::formatTitle()
+ *
+ * @param int|bool $namespace The namespace ID (or false, if the namespace should be ignored)
+ * @param string $text The page title. Should be valid. Only minimal normalization is applied.
+ * Underscores will be replaced.
+ * @param string $fragment The fragment name (may be empty).
+ * @param string $interwiki The interwiki name (may be empty).
+ *
+ * @throws InvalidArgumentException If the namespace is invalid
+ * @return string
+ */
+ public function formatTitle( $namespace, $text, $fragment = '', $interwiki = '' ) {
+ if ( $namespace !== false ) {
+ // Try to get a namespace name, but fallback
+ // to empty string if it doesn't exist
+ try {
+ $nsName = $this->getNamespaceName( $namespace, $text );
+ } catch ( InvalidArgumentException $e ) {
+ $nsName = '';
+ }
+
+ if ( $namespace !== 0 ) {
+ $text = $nsName . ':' . $text;
+ }
+ }
+
+ if ( $fragment !== '' ) {
+ $text = $text . '#' . $fragment;
+ }
+
+ if ( $interwiki !== '' ) {
+ $text = $interwiki . ':' . $text;
+ }
+
+ $text = str_replace( '_', ' ', $text );
+
+ return $text;
+ }
+
+ /**
+ * Parses the given text and constructs a TitleValue. Normalization
+ * is applied according to the rules appropriate for the form specified by $form.
+ *
+ * @param string $text The text to parse
+ * @param int $defaultNamespace Namespace to assume per default (usually NS_MAIN)
+ *
+ * @throws MalformedTitleException
+ * @return TitleValue
+ */
+ public function parseTitle( $text, $defaultNamespace ) {
+ // NOTE: this is an ugly cludge that allows this class to share the
+ // code for parsing with the old Title class. The parser code should
+ // be refactored to avoid this.
+ $parts = $this->splitTitleString( $text, $defaultNamespace );
+
+ // Relative fragment links are not supported by TitleValue
+ if ( $parts['dbkey'] === '' ) {
+ throw new MalformedTitleException( 'title-invalid-empty', $text );
+ }
+
+ return new TitleValue(
+ $parts['namespace'],
+ $parts['dbkey'],
+ $parts['fragment'],
+ $parts['interwiki']
+ );
+ }
+
+ /**
+ * @see TitleFormatter::getText()
+ *
+ * @param LinkTarget $title
+ *
+ * @return string $title->getText()
+ */
+ public function getText( LinkTarget $title ) {
+ return $this->formatTitle( false, $title->getText(), '' );
+ }
+
+ /**
+ * @see TitleFormatter::getText()
+ *
+ * @param LinkTarget $title
+ *
+ * @return string
+ */
+ public function getPrefixedText( LinkTarget $title ) {
+ return $this->formatTitle(
+ $title->getNamespace(),
+ $title->getText(),
+ '',
+ $title->getInterwiki()
+ );
+ }
+
+ /**
+ * @since 1.27
+ * @see TitleFormatter::getPrefixedDBkey()
+ * @param LinkTarget $target
+ * @return string
+ */
+ public function getPrefixedDBkey( LinkTarget $target ) {
+ $key = '';
+ if ( $target->isExternal() ) {
+ $key .= $target->getInterwiki() . ':';
+ }
+ // Try to get a namespace name, but fallback
+ // to empty string if it doesn't exist
+ try {
+ $nsName = $this->getNamespaceName(
+ $target->getNamespace(),
+ $target->getText()
+ );
+ } catch ( InvalidArgumentException $e ) {
+ $nsName = '';
+ }
+
+ if ( $target->getNamespace() !== 0 ) {
+ $key .= $nsName . ':';
+ }
+
+ $key .= $target->getText();
+
+ return strtr( $key, ' ', '_' );
+ }
+
+ /**
+ * @see TitleFormatter::getText()
+ *
+ * @param LinkTarget $title
+ *
+ * @return string
+ */
+ public function getFullText( LinkTarget $title ) {
+ return $this->formatTitle(
+ $title->getNamespace(),
+ $title->getText(),
+ $title->getFragment(),
+ $title->getInterwiki()
+ );
+ }
+
+ /**
+ * Normalizes and splits a title string.
+ *
+ * This function removes illegal characters, splits off the interwiki and
+ * namespace prefixes, sets the other forms, and canonicalizes
+ * everything.
+ *
+ * @todo this method is only exposed as a temporary measure to ease refactoring.
+ * It was copied with minimal changes from Title::secureAndSplit().
+ *
+ * @todo This method should be split up and an appropriate interface
+ * defined for use by the Title class.
+ *
+ * @param string $text
+ * @param int $defaultNamespace
+ *
+ * @throws MalformedTitleException If $text is not a valid title string.
+ * @return array A map with the fields 'interwiki', 'fragment', 'namespace',
+ * 'user_case_dbkey', and 'dbkey'.
+ */
+ public function splitTitleString( $text, $defaultNamespace = NS_MAIN ) {
+ $dbkey = str_replace( ' ', '_', $text );
+
+ # Initialisation
+ $parts = [
+ 'interwiki' => '',
+ 'local_interwiki' => false,
+ 'fragment' => '',
+ 'namespace' => $defaultNamespace,
+ 'dbkey' => $dbkey,
+ 'user_case_dbkey' => $dbkey,
+ ];
+
+ # Strip Unicode bidi override characters.
+ # Sometimes they slip into cut-n-pasted page titles, where the
+ # override chars get included in list displays.
+ $dbkey = preg_replace( '/\xE2\x80[\x8E\x8F\xAA-\xAE]/S', '', $dbkey );
+
+ # Clean up whitespace
+ # Note: use of the /u option on preg_replace here will cause
+ # input with invalid UTF-8 sequences to be nullified out in PHP 5.2.x,
+ # conveniently disabling them.
+ $dbkey = preg_replace(
+ '/[ _\xA0\x{1680}\x{180E}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}]+/u',
+ '_',
+ $dbkey
+ );
+ $dbkey = trim( $dbkey, '_' );
+
+ if ( strpos( $dbkey, UtfNormal\Constants::UTF8_REPLACEMENT ) !== false ) {
+ # Contained illegal UTF-8 sequences or forbidden Unicode chars.
+ throw new MalformedTitleException( 'title-invalid-utf8', $text );
+ }
+
+ $parts['dbkey'] = $dbkey;
+
+ # Initial colon indicates main namespace rather than specified default
+ # but should not create invalid {ns,title} pairs such as {0,Project:Foo}
+ if ( $dbkey !== '' && $dbkey[0] == ':' ) {
+ $parts['namespace'] = NS_MAIN;
+ $dbkey = substr( $dbkey, 1 ); # remove the colon but continue processing
+ $dbkey = trim( $dbkey, '_' ); # remove any subsequent whitespace
+ }
+
+ if ( $dbkey == '' ) {
+ throw new MalformedTitleException( 'title-invalid-empty', $text );
+ }
+
+ # Namespace or interwiki prefix
+ $prefixRegexp = "/^(.+?)_*:_*(.*)$/S";
+ do {
+ $m = [];
+ if ( preg_match( $prefixRegexp, $dbkey, $m ) ) {
+ $p = $m[1];
+ $ns = $this->language->getNsIndex( $p );
+ if ( $ns !== false ) {
+ # Ordinary namespace
+ $dbkey = $m[2];
+ $parts['namespace'] = $ns;
+ # For Talk:X pages, check if X has a "namespace" prefix
+ if ( $ns == NS_TALK && preg_match( $prefixRegexp, $dbkey, $x ) ) {
+ if ( $this->language->getNsIndex( $x[1] ) ) {
+ # Disallow Talk:File:x type titles...
+ throw new MalformedTitleException( 'title-invalid-talk-namespace', $text );
+ } elseif ( $this->interwikiLookup->isValidInterwiki( $x[1] ) ) {
+ # Disallow Talk:Interwiki:x type titles...
+ throw new MalformedTitleException( 'title-invalid-talk-namespace', $text );
+ }
+ }
+ } elseif ( $this->interwikiLookup->isValidInterwiki( $p ) ) {
+ # Interwiki link
+ $dbkey = $m[2];
+ $parts['interwiki'] = $this->language->lc( $p );
+
+ # Redundant interwiki prefix to the local wiki
+ foreach ( $this->localInterwikis as $localIW ) {
+ if ( 0 == strcasecmp( $parts['interwiki'], $localIW ) ) {
+ if ( $dbkey == '' ) {
+ # Empty self-links should point to the Main Page, to ensure
+ # compatibility with cross-wiki transclusions and the like.
+ $mainPage = Title::newMainPage();
+ return [
+ 'interwiki' => $mainPage->getInterwiki(),
+ 'local_interwiki' => true,
+ 'fragment' => $mainPage->getFragment(),
+ 'namespace' => $mainPage->getNamespace(),
+ 'dbkey' => $mainPage->getDBkey(),
+ 'user_case_dbkey' => $mainPage->getUserCaseDBKey()
+ ];
+ }
+ $parts['interwiki'] = '';
+ # local interwikis should behave like initial-colon links
+ $parts['local_interwiki'] = true;
+
+ # Do another namespace split...
+ continue 2;
+ }
+ }
+
+ # If there's an initial colon after the interwiki, that also
+ # resets the default namespace
+ if ( $dbkey !== '' && $dbkey[0] == ':' ) {
+ $parts['namespace'] = NS_MAIN;
+ $dbkey = substr( $dbkey, 1 );
+ $dbkey = trim( $dbkey, '_' );
+ }
+ }
+ # If there's no recognized interwiki or namespace,
+ # then let the colon expression be part of the title.
+ }
+ break;
+ } while ( true );
+
+ $fragment = strstr( $dbkey, '#' );
+ if ( false !== $fragment ) {
+ $parts['fragment'] = str_replace( '_', ' ', substr( $fragment, 1 ) );
+ $dbkey = substr( $dbkey, 0, strlen( $dbkey ) - strlen( $fragment ) );
+ # remove whitespace again: prevents "Foo_bar_#"
+ # becoming "Foo_bar_"
+ $dbkey = preg_replace( '/_*$/', '', $dbkey );
+ }
+
+ # Reject illegal characters.
+ $rxTc = self::getTitleInvalidRegex();
+ $matches = [];
+ if ( preg_match( $rxTc, $dbkey, $matches ) ) {
+ throw new MalformedTitleException( 'title-invalid-characters', $text, [ $matches[0] ] );
+ }
+
+ # Pages with "/./" or "/../" appearing in the URLs will often be un-
+ # reachable due to the way web browsers deal with 'relative' URLs.
+ # Also, they conflict with subpage syntax. Forbid them explicitly.
+ if (
+ strpos( $dbkey, '.' ) !== false &&
+ (
+ $dbkey === '.' || $dbkey === '..' ||
+ strpos( $dbkey, './' ) === 0 ||
+ strpos( $dbkey, '../' ) === 0 ||
+ strpos( $dbkey, '/./' ) !== false ||
+ strpos( $dbkey, '/../' ) !== false ||
+ substr( $dbkey, -2 ) == '/.' ||
+ substr( $dbkey, -3 ) == '/..'
+ )
+ ) {
+ throw new MalformedTitleException( 'title-invalid-relative', $text );
+ }
+
+ # Magic tilde sequences? Nu-uh!
+ if ( strpos( $dbkey, '~~~' ) !== false ) {
+ throw new MalformedTitleException( 'title-invalid-magic-tilde', $text );
+ }
+
+ # Limit the size of titles to 255 bytes. This is typically the size of the
+ # underlying database field. We make an exception for special pages, which
+ # don't need to be stored in the database, and may edge over 255 bytes due
+ # to subpage syntax for long titles, e.g. [[Special:Block/Long name]]
+ $maxLength = ( $parts['namespace'] != NS_SPECIAL ) ? 255 : 512;
+ if ( strlen( $dbkey ) > $maxLength ) {
+ throw new MalformedTitleException( 'title-invalid-too-long', $text,
+ [ Message::numParam( $maxLength ) ] );
+ }
+
+ # Normally, all wiki links are forced to have an initial capital letter so [[foo]]
+ # and [[Foo]] point to the same place. Don't force it for interwikis, since the
+ # other site might be case-sensitive.
+ $parts['user_case_dbkey'] = $dbkey;
+ if ( $parts['interwiki'] === '' ) {
+ $dbkey = Title::capitalize( $dbkey, $parts['namespace'] );
+ }
+
+ # Can't make a link to a namespace alone... "empty" local links can only be
+ # self-links with a fragment identifier.
+ if ( $dbkey == '' && $parts['interwiki'] === '' ) {
+ if ( $parts['namespace'] != NS_MAIN ) {
+ throw new MalformedTitleException( 'title-invalid-empty', $text );
+ }
+ }
+
+ // Allow IPv6 usernames to start with '::' by canonicalizing IPv6 titles.
+ // IP names are not allowed for accounts, and can only be referring to
+ // edits from the IP. Given '::' abbreviations and caps/lowercaps,
+ // there are numerous ways to present the same IP. Having sp:contribs scan
+ // them all is silly and having some show the edits and others not is
+ // inconsistent. Same for talk/userpages. Keep them normalized instead.
+ if ( $parts['namespace'] == NS_USER || $parts['namespace'] == NS_USER_TALK ) {
+ $dbkey = IP::sanitizeIP( $dbkey );
+ }
+
+ // Any remaining initial :s are illegal.
+ if ( $dbkey !== '' && ':' == $dbkey[0] ) {
+ throw new MalformedTitleException( 'title-invalid-leading-colon', $text );
+ }
+
+ # Fill fields
+ $parts['dbkey'] = $dbkey;
+
+ return $parts;
+ }
+
+ /**
+ * Returns a simple regex that will match on characters and sequences invalid in titles.
+ * Note that this doesn't pick up many things that could be wrong with titles, but that
+ * replacing this regex with something valid will make many titles valid.
+ * Previously Title::getTitleInvalidRegex()
+ *
+ * @return string Regex string
+ * @since 1.25
+ */
+ public static function getTitleInvalidRegex() {
+ static $rxTc = false;
+ if ( !$rxTc ) {
+ # Matching titles will be held as illegal.
+ $rxTc = '/' .
+ # Any character not allowed is forbidden...
+ '[^' . Title::legalChars() . ']' .
+ # 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\x80-\xff]+;' .
+ '|&#[0-9]+;' .
+ '|&#x[0-9A-Fa-f]+;' .
+ '/S';
+ }
+
+ return $rxTc;
+ }
+}
diff --git a/www/wiki/includes/title/NaiveForeignTitleFactory.php b/www/wiki/includes/title/NaiveForeignTitleFactory.php
new file mode 100644
index 00000000..2c2f94b2
--- /dev/null
+++ b/www/wiki/includes/title/NaiveForeignTitleFactory.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ */
+
+/**
+ * A parser that translates page titles on a foreign wiki into ForeignTitle
+ * objects, with no knowledge of the namespace setup on the foreign site.
+ */
+class NaiveForeignTitleFactory implements ForeignTitleFactory {
+ /**
+ * Creates a ForeignTitle object based on the page title, and optionally the
+ * namespace ID, of a page on a foreign wiki. These values could be, for
+ * example, the <title> and <ns> attributes found in an XML dump.
+ *
+ * Although exported XML dumps have contained a map of namespace IDs to names
+ * since MW 1.5, the importer used to completely ignore the <siteinfo> tag
+ * before MW 1.25. It is therefore possible that custom XML dumps (i.e. not
+ * generated by Special:Export) have been created without this metadata.
+ * As a result, this code falls back to using namespace data for the local
+ * wiki (similar to buggy pre-1.25 behaviour) if $ns is not supplied.
+ *
+ * @param string $title The page title
+ * @param int|null $ns The namespace ID, or null if this data is not available
+ * @return ForeignTitle
+ */
+ public function createForeignTitle( $title, $ns = null ) {
+ $pieces = explode( ':', $title, 2 );
+
+ global $wgContLang;
+
+ /**
+ * Can we assume that the part of the page title before the colon is a
+ * namespace name?
+ *
+ * XML export schema version 0.5 and earlier (MW 1.18 and earlier) does not
+ * contain a <ns> tag, so we need to be able to handle that case.
+ *
+ * If we know the namespace ID, we assume a non-zero namespace ID means
+ * the ':' sets off a valid namespace name. If we don't know the namespace
+ * ID, we fall back to using the local wiki's namespace names to resolve
+ * this -- better than nothing, and mimics the old crappy behavior
+ */
+ $isNamespacePartValid = is_null( $ns ) ?
+ ( $wgContLang->getNsIndex( $pieces[0] ) !== false ) :
+ $ns != 0;
+
+ if ( count( $pieces ) === 2 && $isNamespacePartValid ) {
+ list( $namespaceName, $pageName ) = $pieces;
+ } else {
+ $namespaceName = '';
+ $pageName = $title;
+ }
+
+ return new ForeignTitle( $ns, $namespaceName, $pageName );
+ }
+}
diff --git a/www/wiki/includes/title/NaiveImportTitleFactory.php b/www/wiki/includes/title/NaiveImportTitleFactory.php
new file mode 100644
index 00000000..43c662e7
--- /dev/null
+++ b/www/wiki/includes/title/NaiveImportTitleFactory.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ */
+
+/**
+ * A class to convert page titles on a foreign wiki (ForeignTitle objects) into
+ * page titles on the local wiki (Title objects), using a default namespace
+ * mapping.
+ *
+ * For built-in namespaces (0 <= ID < 100), we try to find a local namespace
+ * with the same namespace ID as the foreign page. If no such namespace exists,
+ * or the namespace ID is unknown or > 100, we look for a local namespace with
+ * a matching namespace name. If that can't be found, we dump the page in the
+ * main namespace as a last resort.
+ */
+class NaiveImportTitleFactory implements ImportTitleFactory {
+ /**
+ * Determines which local title best corresponds to the given foreign title.
+ * If such a title can't be found or would be locally invalid, null is
+ * returned.
+ *
+ * @param ForeignTitle $foreignTitle The ForeignTitle to convert
+ * @return Title|null
+ */
+ public function createTitleFromForeignTitle( ForeignTitle $foreignTitle ) {
+ global $wgContLang;
+
+ if ( $foreignTitle->isNamespaceIdKnown() ) {
+ $foreignNs = $foreignTitle->getNamespaceId();
+
+ // For built-in namespaces (0 <= ID < 100), we try to find a local NS with
+ // the same namespace ID
+ if ( $foreignNs < 100 && MWNamespace::exists( $foreignNs ) ) {
+ return Title::makeTitleSafe( $foreignNs, $foreignTitle->getText() );
+ }
+ }
+
+ // Do we have a local namespace by the same name as the foreign
+ // namespace?
+ $targetNs = $wgContLang->getNsIndex( $foreignTitle->getNamespaceName() );
+ if ( $targetNs !== false ) {
+ return Title::makeTitleSafe( $targetNs, $foreignTitle->getText() );
+ }
+
+ // Otherwise, just fall back to main namespace
+ return Title::makeTitleSafe( 0, $foreignTitle->getFullText() );
+ }
+}
diff --git a/www/wiki/includes/title/NamespaceAwareForeignTitleFactory.php b/www/wiki/includes/title/NamespaceAwareForeignTitleFactory.php
new file mode 100644
index 00000000..4d24cb85
--- /dev/null
+++ b/www/wiki/includes/title/NamespaceAwareForeignTitleFactory.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ */
+
+/**
+ * A parser that translates page titles on a foreign wiki into ForeignTitle
+ * objects, using information about the namespace setup on the foreign site.
+ */
+class NamespaceAwareForeignTitleFactory implements ForeignTitleFactory {
+ /**
+ * @var array
+ */
+ protected $foreignNamespaces;
+ /**
+ * @var array
+ */
+ private $foreignNamespacesFlipped;
+
+ /**
+ * Normalizes an array name for $foreignNamespacesFlipped.
+ * @param string $name
+ * @return string
+ */
+ private function normalizeNamespaceName( $name ) {
+ return strtolower( str_replace( ' ', '_', $name ) );
+ }
+
+ /**
+ * @param array|null $foreignNamespaces An array 'id' => 'name' which contains
+ * the complete namespace setup of the foreign wiki. Such data could be
+ * obtained from siteinfo/namespaces in an XML dump file, or by an action API
+ * query such as api.php?action=query&meta=siteinfo&siprop=namespaces. If
+ * this data is unavailable, use NaiveForeignTitleFactory instead.
+ */
+ public function __construct( $foreignNamespaces ) {
+ $this->foreignNamespaces = $foreignNamespaces;
+ if ( !is_null( $foreignNamespaces ) ) {
+ $this->foreignNamespacesFlipped = [];
+ foreach ( $foreignNamespaces as $id => $name ) {
+ $newKey = self::normalizeNamespaceName( $name );
+ $this->foreignNamespacesFlipped[$newKey] = $id;
+ }
+ }
+ }
+
+ /**
+ * Creates a ForeignTitle object based on the page title, and optionally the
+ * namespace ID, of a page on a foreign wiki. These values could be, for
+ * example, the <title> and <ns> attributes found in an XML dump.
+ *
+ * @param string $title The page title
+ * @param int|null $ns The namespace ID, or null if this data is not available
+ * @return ForeignTitle
+ */
+ public function createForeignTitle( $title, $ns = null ) {
+ // Export schema version 0.5 and earlier (MW 1.18 and earlier) does not
+ // contain a <ns> tag, so we need to be able to handle that case.
+ if ( is_null( $ns ) ) {
+ return self::parseTitleNoNs( $title );
+ } else {
+ return self::parseTitleWithNs( $title, $ns );
+ }
+ }
+
+ /**
+ * Helper function to parse the title when the namespace ID is not specified.
+ *
+ * @param string $title
+ * @return ForeignTitle
+ */
+ protected function parseTitleNoNs( $title ) {
+ $pieces = explode( ':', $title, 2 );
+ $key = self::normalizeNamespaceName( $pieces[0] );
+
+ // Does the part before the colon match a known namespace? Check the
+ // foreign namespaces
+ $isNamespacePartValid = isset( $this->foreignNamespacesFlipped[$key] );
+
+ if ( count( $pieces ) === 2 && $isNamespacePartValid ) {
+ list( $namespaceName, $pageName ) = $pieces;
+ $ns = $this->foreignNamespacesFlipped[$key];
+ } else {
+ $namespaceName = '';
+ $pageName = $title;
+ $ns = 0;
+ }
+
+ return new ForeignTitle( $ns, $namespaceName, $pageName );
+ }
+
+ /**
+ * Helper function to parse the title when the namespace value is known.
+ *
+ * @param string $title
+ * @param int $ns
+ * @return ForeignTitle
+ */
+ protected function parseTitleWithNs( $title, $ns ) {
+ $pieces = explode( ':', $title, 2 );
+
+ // Is $title of the form Namespace:Title (true), or just Title (false)?
+ $titleIncludesNamespace = ( $ns != '0' && count( $pieces ) === 2 );
+
+ if ( isset( $this->foreignNamespaces[$ns] ) ) {
+ $namespaceName = $this->foreignNamespaces[$ns];
+ } else {
+ // If the foreign wiki is misconfigured, XML dumps can contain a page with
+ // a non-zero namespace ID, but whose title doesn't contain a colon
+ // (T114115). In those cases, output a made-up namespace name to avoid
+ // collisions. The ImportTitleFactory might replace this with something
+ // more appropriate.
+ $namespaceName = $titleIncludesNamespace ? $pieces[0] : "Ns$ns";
+ }
+
+ // We assume that the portion of the page title before the colon is the
+ // namespace name, except in the case of namespace 0.
+ if ( $titleIncludesNamespace ) {
+ $pageName = $pieces[1];
+ } else {
+ $pageName = $title;
+ }
+
+ return new ForeignTitle( $ns, $namespaceName, $pageName );
+ }
+}
diff --git a/www/wiki/includes/title/NamespaceImportTitleFactory.php b/www/wiki/includes/title/NamespaceImportTitleFactory.php
new file mode 100644
index 00000000..0c1d0c40
--- /dev/null
+++ b/www/wiki/includes/title/NamespaceImportTitleFactory.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ */
+
+/**
+ * A class to convert page titles on a foreign wiki (ForeignTitle objects) into
+ * page titles on the local wiki (Title objects), placing all pages in a fixed
+ * local namespace.
+ */
+class NamespaceImportTitleFactory implements ImportTitleFactory {
+ /** @var int */
+ protected $ns;
+
+ /**
+ * @param int $ns The namespace to use for all pages
+ */
+ public function __construct( $ns ) {
+ if ( !MWNamespace::exists( $ns ) ) {
+ throw new MWException( "Namespace $ns doesn't exist on this wiki" );
+ }
+ $this->ns = $ns;
+ }
+
+ /**
+ * Determines which local title best corresponds to the given foreign title.
+ * If such a title can't be found or would be locally invalid, null is
+ * returned.
+ *
+ * @param ForeignTitle $foreignTitle The ForeignTitle to convert
+ * @return Title|null
+ */
+ public function createTitleFromForeignTitle( ForeignTitle $foreignTitle ) {
+ return Title::makeTitleSafe( $this->ns, $foreignTitle->getText() );
+ }
+}
diff --git a/www/wiki/includes/title/PageLinkRenderer.php b/www/wiki/includes/title/PageLinkRenderer.php
new file mode 100644
index 00000000..e26fe1a2
--- /dev/null
+++ b/www/wiki/includes/title/PageLinkRenderer.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Represents a link rendering service for %MediaWiki.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Daniel Kinzler
+ */
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * Represents a link rendering service for %MediaWiki.
+ *
+ * This is designed to encapsulate the knowledge about how page titles map to
+ * URLs, and how links are encoded in a given output format.
+ *
+ * @see https://www.mediawiki.org/wiki/Requests_for_comment/TitleValue
+ * @since 1.23
+ */
+interface PageLinkRenderer {
+ /**
+ * Returns the URL for the given page.
+ *
+ * @todo expand this to cover the functionality of Linker::linkUrl
+ *
+ * @param LinkTarget $page The link's target
+ * @param array $params Any additional URL parameters.
+ *
+ * @return string
+ */
+ public function getPageUrl( LinkTarget $page, $params = [] );
+
+ /**
+ * Returns an HTML link to the given page, using the given surface text.
+ *
+ * @todo expand this to cover the functionality of Linker::link
+ *
+ * @param LinkTarget $page The link's target
+ * @param string $text The link's surface text (will be derived from $page if not given).
+ *
+ * @return string
+ */
+ public function renderHtmlLink( LinkTarget $page, $text = null );
+
+ /**
+ * Returns a wikitext link to the given page, using the given surface text.
+ *
+ * @param LinkTarget $page The link's target
+ * @param string $text The link's surface text (will be derived from $page if not given).
+ *
+ * @return string
+ */
+ public function renderWikitextLink( LinkTarget $page, $text = null );
+}
diff --git a/www/wiki/includes/title/SubpageImportTitleFactory.php b/www/wiki/includes/title/SubpageImportTitleFactory.php
new file mode 100644
index 00000000..b0be7afa
--- /dev/null
+++ b/www/wiki/includes/title/SubpageImportTitleFactory.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ */
+
+/**
+ * A class to convert page titles on a foreign wiki (ForeignTitle objects) into
+ * page titles on the local wiki (Title objects), placing all pages as subpages
+ * of a given root page.
+ */
+class SubpageImportTitleFactory implements ImportTitleFactory {
+ /** @var Title */
+ protected $rootPage;
+
+ /**
+ * @param Title $rootPage The root page under which all pages should be
+ * created
+ */
+ public function __construct( Title $rootPage ) {
+ if ( !MWNamespace::hasSubpages( $rootPage->getNamespace() ) ) {
+ throw new MWException( "The root page you specified, $rootPage, is in a " .
+ "namespace where subpages are not allowed" );
+ }
+ $this->rootPage = $rootPage;
+ }
+
+ /**
+ * Determines which local title best corresponds to the given foreign title.
+ * If such a title can't be found or would be locally invalid, null is
+ * returned.
+ *
+ * @param ForeignTitle $foreignTitle The ForeignTitle to convert
+ * @return Title|null
+ */
+ public function createTitleFromForeignTitle( ForeignTitle $foreignTitle ) {
+ return Title::newFromText( $this->rootPage->getPrefixedDBkey() . '/' .
+ $foreignTitle->getFullText() );
+ }
+}
diff --git a/www/wiki/includes/title/TitleFormatter.php b/www/wiki/includes/title/TitleFormatter.php
new file mode 100644
index 00000000..5177606f
--- /dev/null
+++ b/www/wiki/includes/title/TitleFormatter.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * A title formatter service for %MediaWiki.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Daniel Kinzler
+ */
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * A title formatter service for MediaWiki.
+ *
+ * This is designed to encapsulate knowledge about conventions for the title
+ * forms to be used in the database, in urls, in wikitext, etc.
+ *
+ * @see https://www.mediawiki.org/wiki/Requests_for_comment/TitleValue
+ * @since 1.23
+ */
+interface TitleFormatter {
+ /**
+ * Returns the title formatted for display.
+ * Per default, this includes the namespace but not the fragment.
+ *
+ * @note Normalization is applied if $title is not in TitleValue::TITLE_FORM.
+ *
+ * @param int|bool $namespace The namespace ID (or false, if the namespace should be ignored)
+ * @param string $text The page title
+ * @param string $fragment The fragment name (may be empty).
+ * @param string $interwiki The interwiki prefix (may be empty).
+ *
+ * @return string
+ */
+ public function formatTitle( $namespace, $text, $fragment = '', $interwiki = '' );
+
+ /**
+ * Returns the title text formatted for display, without namespace of fragment.
+ *
+ * @note Only minimal normalization is applied. Consider using TitleValue::getText() directly.
+ *
+ * @param LinkTarget $title The title to format
+ *
+ * @return string
+ */
+ public function getText( LinkTarget $title );
+
+ /**
+ * Returns the title formatted for display, including the namespace name.
+ *
+ * @param LinkTarget $title The title to format
+ *
+ * @return string
+ */
+ public function getPrefixedText( LinkTarget $title );
+
+ /**
+ * Return the title in prefixed database key form, with interwiki
+ * and namespace.
+ *
+ * @since 1.27
+ *
+ * @param LinkTarget $target
+ *
+ * @return string
+ */
+ public function getPrefixedDBkey( LinkTarget $target );
+
+ /**
+ * Returns the title formatted for display, with namespace and fragment.
+ *
+ * @param LinkTarget $title The title to format
+ *
+ * @return string
+ */
+ public function getFullText( LinkTarget $title );
+
+ /**
+ * Returns the name of the namespace for the given title.
+ *
+ * @note This must take into account gender sensitive namespace names.
+ * @todo Move this to a separate interface
+ *
+ * @param int $namespace
+ * @param string $text
+ *
+ * @throws InvalidArgumentException
+ * @return string
+ */
+ public function getNamespaceName( $namespace, $text );
+}
diff --git a/www/wiki/includes/title/TitleParser.php b/www/wiki/includes/title/TitleParser.php
new file mode 100644
index 00000000..381b1d09
--- /dev/null
+++ b/www/wiki/includes/title/TitleParser.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * A title parser service for %MediaWiki.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Daniel Kinzler
+ */
+
+/**
+ * A title parser service for %MediaWiki.
+ *
+ * This is designed to encapsulate knowledge about conventions for the title
+ * forms to be used in the database, in urls, in wikitext, etc.
+ *
+ * @see https://www.mediawiki.org/wiki/Requests_for_comment/TitleValue
+ * @since 1.23
+ */
+interface TitleParser {
+ /**
+ * Parses the given text and constructs a TitleValue. Normalization
+ * is applied according to the rules appropriate for the form specified by $form.
+ *
+ * @note this only parses local page links, interwiki-prefixes etc. are not considered!
+ *
+ * @param string $text The text to parse
+ * @param int $defaultNamespace Namespace to assume per default (usually NS_MAIN)
+ *
+ * @throws MalformedTitleException If the text is not a valid representation of a page title.
+ * @return TitleValue
+ */
+ public function parseTitle( $text, $defaultNamespace );
+}
diff --git a/www/wiki/includes/title/TitleValue.php b/www/wiki/includes/title/TitleValue.php
new file mode 100644
index 00000000..7c370f1a
--- /dev/null
+++ b/www/wiki/includes/title/TitleValue.php
@@ -0,0 +1,204 @@
+<?php
+/**
+ * Representation of a page title within %MediaWiki.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL 2+
+ * @author Daniel Kinzler
+ */
+use MediaWiki\Linker\LinkTarget;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Represents a page (or page fragment) title within %MediaWiki.
+ *
+ * @note In contrast to Title, this is designed to be a plain value object. That is,
+ * it is immutable, does not use global state, and causes no side effects.
+ *
+ * @see https://www.mediawiki.org/wiki/Requests_for_comment/TitleValue
+ * @since 1.23
+ */
+class TitleValue implements LinkTarget {
+ /**
+ * @var int
+ */
+ protected $namespace;
+
+ /**
+ * @var string
+ */
+ protected $dbkey;
+
+ /**
+ * @var string
+ */
+ protected $fragment;
+
+ /**
+ * @var string
+ */
+ protected $interwiki;
+
+ /**
+ * Constructs a TitleValue.
+ *
+ * @note TitleValue expects a valid DB key; typically, a TitleValue is constructed either
+ * from a database entry, or by a TitleParser. We could apply "some" normalization here,
+ * such as substituting spaces by underscores, but that would encourage the use of
+ * un-normalized text when constructing TitleValues. For constructing a TitleValue from
+ * user input or external sources, use a TitleParser.
+ *
+ * @param int $namespace The namespace ID. This is not validated.
+ * @param string $dbkey The page title in valid DBkey form. No normalization is applied.
+ * @param string $fragment The fragment title. Use '' to represent the whole page.
+ * No validation or normalization is applied.
+ * @param string $interwiki The interwiki component
+ *
+ * @throws InvalidArgumentException
+ */
+ public function __construct( $namespace, $dbkey, $fragment = '', $interwiki = '' ) {
+ Assert::parameterType( 'integer', $namespace, '$namespace' );
+ Assert::parameterType( 'string', $dbkey, '$dbkey' );
+ Assert::parameterType( 'string', $fragment, '$fragment' );
+ Assert::parameterType( 'string', $interwiki, '$interwiki' );
+
+ // Sanity check, no full validation or normalization applied here!
+ Assert::parameter( !preg_match( '/^_|[ \r\n\t]|_$/', $dbkey ), '$dbkey',
+ "invalid DB key '$dbkey'" );
+ Assert::parameter( $dbkey !== '', '$dbkey', 'should not be empty' );
+
+ $this->namespace = $namespace;
+ $this->dbkey = $dbkey;
+ $this->fragment = $fragment;
+ $this->interwiki = $interwiki;
+ }
+
+ /**
+ * @return int
+ */
+ public function getNamespace() {
+ return $this->namespace;
+ }
+
+ /**
+ * @since 1.27
+ * @param int $ns
+ * @return bool
+ */
+ public function inNamespace( $ns ) {
+ return $this->namespace == $ns;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFragment() {
+ return $this->fragment;
+ }
+
+ /**
+ * @since 1.27
+ * @return bool
+ */
+ public function hasFragment() {
+ return $this->fragment !== '';
+ }
+
+ /**
+ * Returns the title's DB key, as supplied to the constructor,
+ * without namespace prefix or fragment.
+ *
+ * @return string
+ */
+ public function getDBkey() {
+ return $this->dbkey;
+ }
+
+ /**
+ * Returns the title in text form,
+ * without namespace prefix or fragment.
+ *
+ * This is computed from the DB key by replacing any underscores with spaces.
+ *
+ * @note To get a title string that includes the namespace and/or fragment,
+ * use a TitleFormatter.
+ *
+ * @return string
+ */
+ public function getText() {
+ return str_replace( '_', ' ', $this->getDBkey() );
+ }
+
+ /**
+ * Creates a new TitleValue for a different fragment of the same page.
+ *
+ * @since 1.27
+ * @param string $fragment The fragment name, or "" for the entire page.
+ *
+ * @return TitleValue
+ */
+ public function createFragmentTarget( $fragment ) {
+ return new TitleValue(
+ $this->namespace,
+ $this->dbkey,
+ $fragment,
+ $this->interwiki
+ );
+ }
+
+ /**
+ * Whether it has an interwiki part
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function isExternal() {
+ return $this->interwiki !== '';
+ }
+
+ /**
+ * Returns the interwiki part
+ *
+ * @since 1.27
+ * @return string
+ */
+ public function getInterwiki() {
+ return $this->interwiki;
+ }
+
+ /**
+ * Returns a string representation of the title, for logging. This is purely informative
+ * and must not be used programmatically. Use the appropriate TitleFormatter to generate
+ * the correct string representation for a given use.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $name = $this->namespace . ':' . $this->dbkey;
+
+ if ( $this->fragment !== '' ) {
+ $name .= '#' . $this->fragment;
+ }
+
+ if ( $this->interwiki !== '' ) {
+ $name = $this->interwiki . ':' . $name;
+ }
+
+ return $name;
+ }
+}
diff --git a/www/wiki/includes/upload/UploadBase.php b/www/wiki/includes/upload/UploadBase.php
new file mode 100644
index 00000000..da3f9f82
--- /dev/null
+++ b/www/wiki/includes/upload/UploadBase.php
@@ -0,0 +1,2237 @@
+<?php
+/**
+ * Base class for the backend of file upload.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Upload
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @defgroup Upload Upload related
+ */
+
+/**
+ * @ingroup Upload
+ *
+ * UploadBase and subclasses are the backend of MediaWiki's file uploads.
+ * The frontends are formed by ApiUpload and SpecialUpload.
+ *
+ * @author Brion Vibber
+ * @author Bryan Tong Minh
+ * @author Michael Dale
+ */
+abstract class UploadBase {
+ /** @var string Local file system path to the file to upload (or a local copy) */
+ protected $mTempPath;
+ /** @var TempFSFile|null Wrapper to handle deleting the temp file */
+ protected $tempFileObj;
+
+ protected $mDesiredDestName, $mDestName, $mRemoveTempFile, $mSourceType;
+ protected $mTitle = false, $mTitleError = 0;
+ protected $mFilteredName, $mFinalExtension;
+ protected $mLocalFile, $mStashFile, $mFileSize, $mFileProps;
+ protected $mBlackListedExtensions;
+ protected $mJavaDetected, $mSVGNSError;
+
+ protected static $safeXmlEncodings = [
+ 'UTF-8',
+ 'ISO-8859-1',
+ 'ISO-8859-2',
+ 'UTF-16',
+ 'UTF-32',
+ 'WINDOWS-1250',
+ 'WINDOWS-1251',
+ 'WINDOWS-1252',
+ 'WINDOWS-1253',
+ 'WINDOWS-1254',
+ 'WINDOWS-1255',
+ 'WINDOWS-1256',
+ 'WINDOWS-1257',
+ 'WINDOWS-1258',
+ ];
+
+ const SUCCESS = 0;
+ const OK = 0;
+ const EMPTY_FILE = 3;
+ const MIN_LENGTH_PARTNAME = 4;
+ const ILLEGAL_FILENAME = 5;
+ const OVERWRITE_EXISTING_FILE = 7; # Not used anymore; handled by verifyTitlePermissions()
+ const FILETYPE_MISSING = 8;
+ const FILETYPE_BADTYPE = 9;
+ const VERIFICATION_ERROR = 10;
+ const HOOK_ABORTED = 11;
+ const FILE_TOO_LARGE = 12;
+ const WINDOWS_NONASCII_FILENAME = 13;
+ const FILENAME_TOO_LONG = 14;
+
+ /**
+ * @param int $error
+ * @return string
+ */
+ public function getVerificationErrorCode( $error ) {
+ $code_to_status = [
+ self::EMPTY_FILE => 'empty-file',
+ self::FILE_TOO_LARGE => 'file-too-large',
+ self::FILETYPE_MISSING => 'filetype-missing',
+ self::FILETYPE_BADTYPE => 'filetype-banned',
+ self::MIN_LENGTH_PARTNAME => 'filename-tooshort',
+ self::ILLEGAL_FILENAME => 'illegal-filename',
+ self::OVERWRITE_EXISTING_FILE => 'overwrite',
+ self::VERIFICATION_ERROR => 'verification-error',
+ self::HOOK_ABORTED => 'hookaborted',
+ self::WINDOWS_NONASCII_FILENAME => 'windows-nonascii-filename',
+ self::FILENAME_TOO_LONG => 'filename-toolong',
+ ];
+ if ( isset( $code_to_status[$error] ) ) {
+ return $code_to_status[$error];
+ }
+
+ return 'unknown-error';
+ }
+
+ /**
+ * Returns true if uploads are enabled.
+ * Can be override by subclasses.
+ * @return bool
+ */
+ public static function isEnabled() {
+ global $wgEnableUploads;
+
+ if ( !$wgEnableUploads ) {
+ return false;
+ }
+
+ # Check php's file_uploads setting
+ return wfIsHHVM() || wfIniGetBool( 'file_uploads' );
+ }
+
+ /**
+ * Returns true if the user can use this upload module or else a string
+ * identifying the missing permission.
+ * Can be overridden by subclasses.
+ *
+ * @param User $user
+ * @return bool|string
+ */
+ public static function isAllowed( $user ) {
+ foreach ( [ 'upload', 'edit' ] as $permission ) {
+ if ( !$user->isAllowed( $permission ) ) {
+ return $permission;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns true if the user has surpassed the upload rate limit, false otherwise.
+ *
+ * @param User $user
+ * @return bool
+ */
+ public static function isThrottled( $user ) {
+ return $user->pingLimiter( 'upload' );
+ }
+
+ // Upload handlers. Should probably just be a global.
+ private static $uploadHandlers = [ 'Stash', 'File', 'Url' ];
+
+ /**
+ * Create a form of UploadBase depending on wpSourceType and initializes it
+ *
+ * @param WebRequest &$request
+ * @param string|null $type
+ * @return null|UploadBase
+ */
+ public static function createFromRequest( &$request, $type = null ) {
+ $type = $type ? $type : $request->getVal( 'wpSourceType', 'File' );
+
+ if ( !$type ) {
+ return null;
+ }
+
+ // Get the upload class
+ $type = ucfirst( $type );
+
+ // Give hooks the chance to handle this request
+ $className = null;
+ Hooks::run( 'UploadCreateFromRequest', [ $type, &$className ] );
+ if ( is_null( $className ) ) {
+ $className = 'UploadFrom' . $type;
+ wfDebug( __METHOD__ . ": class name: $className\n" );
+ if ( !in_array( $type, self::$uploadHandlers ) ) {
+ return null;
+ }
+ }
+
+ // Check whether this upload class is enabled
+ if ( !call_user_func( [ $className, 'isEnabled' ] ) ) {
+ return null;
+ }
+
+ // Check whether the request is valid
+ if ( !call_user_func( [ $className, 'isValidRequest' ], $request ) ) {
+ return null;
+ }
+
+ /** @var UploadBase $handler */
+ $handler = new $className;
+
+ $handler->initializeFromRequest( $request );
+
+ return $handler;
+ }
+
+ /**
+ * Check whether a request if valid for this handler
+ * @param WebRequest $request
+ * @return bool
+ */
+ public static function isValidRequest( $request ) {
+ return false;
+ }
+
+ public function __construct() {
+ }
+
+ /**
+ * Returns the upload type. Should be overridden by child classes
+ *
+ * @since 1.18
+ * @return string
+ */
+ public function getSourceType() {
+ return null;
+ }
+
+ /**
+ * Initialize the path information
+ * @param string $name The desired destination name
+ * @param string $tempPath The temporary path
+ * @param int $fileSize The file size
+ * @param bool $removeTempFile (false) remove the temporary file?
+ * @throws MWException
+ */
+ public function initializePathInfo( $name, $tempPath, $fileSize, $removeTempFile = false ) {
+ $this->mDesiredDestName = $name;
+ if ( FileBackend::isStoragePath( $tempPath ) ) {
+ throw new MWException( __METHOD__ . " given storage path `$tempPath`." );
+ }
+
+ $this->setTempFile( $tempPath, $fileSize );
+ $this->mRemoveTempFile = $removeTempFile;
+ }
+
+ /**
+ * Initialize from a WebRequest. Override this in a subclass.
+ *
+ * @param WebRequest &$request
+ */
+ abstract public function initializeFromRequest( &$request );
+
+ /**
+ * @param string $tempPath File system path to temporary file containing the upload
+ * @param int $fileSize
+ */
+ protected function setTempFile( $tempPath, $fileSize = null ) {
+ $this->mTempPath = $tempPath;
+ $this->mFileSize = $fileSize ?: null;
+ if ( strlen( $this->mTempPath ) && file_exists( $this->mTempPath ) ) {
+ $this->tempFileObj = new TempFSFile( $this->mTempPath );
+ if ( !$fileSize ) {
+ $this->mFileSize = filesize( $this->mTempPath );
+ }
+ } else {
+ $this->tempFileObj = null;
+ }
+ }
+
+ /**
+ * Fetch the file. Usually a no-op
+ * @return Status
+ */
+ public function fetchFile() {
+ return Status::newGood();
+ }
+
+ /**
+ * Return true if the file is empty
+ * @return bool
+ */
+ public function isEmptyFile() {
+ return empty( $this->mFileSize );
+ }
+
+ /**
+ * Return the file size
+ * @return int
+ */
+ public function getFileSize() {
+ return $this->mFileSize;
+ }
+
+ /**
+ * Get the base 36 SHA1 of the file
+ * @return string
+ */
+ public function getTempFileSha1Base36() {
+ return FSFile::getSha1Base36FromPath( $this->mTempPath );
+ }
+
+ /**
+ * @param string $srcPath The source path
+ * @return string|bool The real path if it was a virtual URL Returns false on failure
+ */
+ public function getRealPath( $srcPath ) {
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ if ( $repo->isVirtualUrl( $srcPath ) ) {
+ /** @todo Just make uploads work with storage paths UploadFromStash
+ * loads files via virtual URLs.
+ */
+ $tmpFile = $repo->getLocalCopy( $srcPath );
+ if ( $tmpFile ) {
+ $tmpFile->bind( $this ); // keep alive with $this
+ }
+ $path = $tmpFile ? $tmpFile->getPath() : false;
+ } else {
+ $path = $srcPath;
+ }
+
+ return $path;
+ }
+
+ /**
+ * Verify whether the upload is sane.
+ * @return mixed Const self::OK or else an array with error information
+ */
+ public function verifyUpload() {
+ /**
+ * If there was no filename or a zero size given, give up quick.
+ */
+ if ( $this->isEmptyFile() ) {
+ return [ 'status' => self::EMPTY_FILE ];
+ }
+
+ /**
+ * Honor $wgMaxUploadSize
+ */
+ $maxSize = self::getMaxUploadSize( $this->getSourceType() );
+ if ( $this->mFileSize > $maxSize ) {
+ return [
+ 'status' => self::FILE_TOO_LARGE,
+ 'max' => $maxSize,
+ ];
+ }
+
+ /**
+ * Look at the contents of the file; if we can recognize the
+ * type but it's corrupt or data of the wrong type, we should
+ * probably not accept it.
+ */
+ $verification = $this->verifyFile();
+ if ( $verification !== true ) {
+ return [
+ 'status' => self::VERIFICATION_ERROR,
+ 'details' => $verification
+ ];
+ }
+
+ /**
+ * Make sure this file can be created
+ */
+ $result = $this->validateName();
+ if ( $result !== true ) {
+ return $result;
+ }
+
+ $error = '';
+ if ( !Hooks::run( 'UploadVerification',
+ [ $this->mDestName, $this->mTempPath, &$error ], '1.28' )
+ ) {
+ return [ 'status' => self::HOOK_ABORTED, 'error' => $error ];
+ }
+
+ return [ 'status' => self::OK ];
+ }
+
+ /**
+ * Verify that the name is valid and, if necessary, that we can overwrite
+ *
+ * @return mixed True if valid, otherwise and array with 'status'
+ * and other keys
+ */
+ public function validateName() {
+ $nt = $this->getTitle();
+ if ( is_null( $nt ) ) {
+ $result = [ 'status' => $this->mTitleError ];
+ if ( $this->mTitleError == self::ILLEGAL_FILENAME ) {
+ $result['filtered'] = $this->mFilteredName;
+ }
+ if ( $this->mTitleError == self::FILETYPE_BADTYPE ) {
+ $result['finalExt'] = $this->mFinalExtension;
+ if ( count( $this->mBlackListedExtensions ) ) {
+ $result['blacklistedExt'] = $this->mBlackListedExtensions;
+ }
+ }
+
+ return $result;
+ }
+ $this->mDestName = $this->getLocalFile()->getName();
+
+ return true;
+ }
+
+ /**
+ * Verify the MIME type.
+ *
+ * @note Only checks that it is not an evil MIME. The "does it have
+ * correct extension given its MIME type?" check is in verifyFile.
+ * in `verifyFile()` that MIME type and file extension correlate.
+ * @param string $mime Representing the MIME
+ * @return mixed True if the file is verified, an array otherwise
+ */
+ protected function verifyMimeType( $mime ) {
+ global $wgVerifyMimeType;
+ if ( $wgVerifyMimeType ) {
+ wfDebug( "mime: <$mime> extension: <{$this->mFinalExtension}>\n" );
+ global $wgMimeTypeBlacklist;
+ if ( $this->checkFileExtension( $mime, $wgMimeTypeBlacklist ) ) {
+ return [ 'filetype-badmime', $mime ];
+ }
+
+ # Check what Internet Explorer would detect
+ $fp = fopen( $this->mTempPath, 'rb' );
+ $chunk = fread( $fp, 256 );
+ fclose( $fp );
+
+ $magic = MimeMagic::singleton();
+ $extMime = $magic->guessTypesForExtension( $this->mFinalExtension );
+ $ieTypes = $magic->getIEMimeTypes( $this->mTempPath, $chunk, $extMime );
+ foreach ( $ieTypes as $ieType ) {
+ if ( $this->checkFileExtension( $ieType, $wgMimeTypeBlacklist ) ) {
+ return [ 'filetype-bad-ie-mime', $ieType ];
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Verifies that it's ok to include the uploaded file
+ *
+ * @return mixed True of the file is verified, array otherwise.
+ */
+ protected function verifyFile() {
+ global $wgVerifyMimeType, $wgDisableUploadScriptChecks;
+
+ $status = $this->verifyPartialFile();
+ if ( $status !== true ) {
+ return $status;
+ }
+
+ $mwProps = new MWFileProps( MimeMagic::singleton() );
+ $this->mFileProps = $mwProps->getPropsFromPath( $this->mTempPath, $this->mFinalExtension );
+ $mime = $this->mFileProps['mime'];
+
+ if ( $wgVerifyMimeType ) {
+ # XXX: Missing extension will be caught by validateName() via getTitle()
+ if ( $this->mFinalExtension != '' && !$this->verifyExtension( $mime, $this->mFinalExtension ) ) {
+ return [ 'filetype-mime-mismatch', $this->mFinalExtension, $mime ];
+ }
+ }
+
+ # check for htmlish code and javascript
+ if ( !$wgDisableUploadScriptChecks ) {
+ if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) {
+ $svgStatus = $this->detectScriptInSvg( $this->mTempPath, false );
+ if ( $svgStatus !== false ) {
+ return $svgStatus;
+ }
+ }
+ }
+
+ $handler = MediaHandler::getHandler( $mime );
+ if ( $handler ) {
+ $handlerStatus = $handler->verifyUpload( $this->mTempPath );
+ if ( !$handlerStatus->isOK() ) {
+ $errors = $handlerStatus->getErrorsArray();
+
+ return reset( $errors );
+ }
+ }
+
+ $error = true;
+ Hooks::run( 'UploadVerifyFile', [ $this, $mime, &$error ] );
+ if ( $error !== true ) {
+ if ( !is_array( $error ) ) {
+ $error = [ $error ];
+ }
+ return $error;
+ }
+
+ wfDebug( __METHOD__ . ": all clear; passing.\n" );
+
+ return true;
+ }
+
+ /**
+ * A verification routine suitable for partial files
+ *
+ * Runs the blacklist checks, but not any checks that may
+ * assume the entire file is present.
+ *
+ * @return mixed True for valid or array with error message key.
+ */
+ protected function verifyPartialFile() {
+ global $wgAllowJavaUploads, $wgDisableUploadScriptChecks;
+
+ # getTitle() sets some internal parameters like $this->mFinalExtension
+ $this->getTitle();
+
+ $mwProps = new MWFileProps( MimeMagic::singleton() );
+ $this->mFileProps = $mwProps->getPropsFromPath( $this->mTempPath, $this->mFinalExtension );
+
+ # check MIME type, if desired
+ $mime = $this->mFileProps['file-mime'];
+ $status = $this->verifyMimeType( $mime );
+ if ( $status !== true ) {
+ return $status;
+ }
+
+ # check for htmlish code and javascript
+ if ( !$wgDisableUploadScriptChecks ) {
+ if ( self::detectScript( $this->mTempPath, $mime, $this->mFinalExtension ) ) {
+ return [ 'uploadscripted' ];
+ }
+ if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) {
+ $svgStatus = $this->detectScriptInSvg( $this->mTempPath, true );
+ if ( $svgStatus !== false ) {
+ return $svgStatus;
+ }
+ }
+ }
+
+ # Check for Java applets, which if uploaded can bypass cross-site
+ # restrictions.
+ if ( !$wgAllowJavaUploads ) {
+ $this->mJavaDetected = false;
+ $zipStatus = ZipDirectoryReader::read( $this->mTempPath,
+ [ $this, 'zipEntryCallback' ] );
+ if ( !$zipStatus->isOK() ) {
+ $errors = $zipStatus->getErrorsArray();
+ $error = reset( $errors );
+ if ( $error[0] !== 'zip-wrong-format' ) {
+ return $error;
+ }
+ }
+ if ( $this->mJavaDetected ) {
+ return [ 'uploadjava' ];
+ }
+ }
+
+ # Scan the uploaded file for viruses
+ $virus = $this->detectVirus( $this->mTempPath );
+ if ( $virus ) {
+ return [ 'uploadvirus', $virus ];
+ }
+
+ return true;
+ }
+
+ /**
+ * Callback for ZipDirectoryReader to detect Java class files.
+ *
+ * @param array $entry
+ */
+ public function zipEntryCallback( $entry ) {
+ $names = [ $entry['name'] ];
+
+ // If there is a null character, cut off the name at it, because JDK's
+ // ZIP_GetEntry() uses strcmp() if the name hashes match. If a file name
+ // were constructed which had ".class\0" followed by a string chosen to
+ // make the hash collide with the truncated name, that file could be
+ // returned in response to a request for the .class file.
+ $nullPos = strpos( $entry['name'], "\000" );
+ if ( $nullPos !== false ) {
+ $names[] = substr( $entry['name'], 0, $nullPos );
+ }
+
+ // If there is a trailing slash in the file name, we have to strip it,
+ // because that's what ZIP_GetEntry() does.
+ if ( preg_grep( '!\.class/?$!', $names ) ) {
+ $this->mJavaDetected = true;
+ }
+ }
+
+ /**
+ * Alias for verifyTitlePermissions. The function was originally
+ * 'verifyPermissions', but that suggests it's checking the user, when it's
+ * really checking the title + user combination.
+ *
+ * @param User $user User object to verify the permissions against
+ * @return mixed An array as returned by getUserPermissionsErrors or true
+ * in case the user has proper permissions.
+ */
+ public function verifyPermissions( $user ) {
+ return $this->verifyTitlePermissions( $user );
+ }
+
+ /**
+ * Check whether the user can edit, upload and create the image. This
+ * checks only against the current title; if it returns errors, it may
+ * very well be that another title will not give errors. Therefore
+ * isAllowed() should be called as well for generic is-user-blocked or
+ * can-user-upload checking.
+ *
+ * @param User $user User object to verify the permissions against
+ * @return mixed An array as returned by getUserPermissionsErrors or true
+ * in case the user has proper permissions.
+ */
+ public function verifyTitlePermissions( $user ) {
+ /**
+ * If the image is protected, non-sysop users won't be able
+ * to modify it by uploading a new revision.
+ */
+ $nt = $this->getTitle();
+ if ( is_null( $nt ) ) {
+ return true;
+ }
+ $permErrors = $nt->getUserPermissionsErrors( 'edit', $user );
+ $permErrorsUpload = $nt->getUserPermissionsErrors( 'upload', $user );
+ if ( !$nt->exists() ) {
+ $permErrorsCreate = $nt->getUserPermissionsErrors( 'create', $user );
+ } else {
+ $permErrorsCreate = [];
+ }
+ if ( $permErrors || $permErrorsUpload || $permErrorsCreate ) {
+ $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsUpload, $permErrors ) );
+ $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsCreate, $permErrors ) );
+
+ return $permErrors;
+ }
+
+ $overwriteError = $this->checkOverwrite( $user );
+ if ( $overwriteError !== true ) {
+ return [ $overwriteError ];
+ }
+
+ return true;
+ }
+
+ /**
+ * Check for non fatal problems with the file.
+ *
+ * This should not assume that mTempPath is set.
+ *
+ * @return mixed[] Array of warnings
+ */
+ public function checkWarnings() {
+ $warnings = [];
+
+ $localFile = $this->getLocalFile();
+ $localFile->load( File::READ_LATEST );
+ $filename = $localFile->getName();
+ $hash = $this->getTempFileSha1Base36();
+
+ $badFileName = $this->checkBadFileName( $filename, $this->mDesiredDestName );
+ if ( $badFileName !== null ) {
+ $warnings['badfilename'] = $badFileName;
+ }
+
+ $unwantedFileExtensionDetails = $this->checkUnwantedFileExtensions( $this->mFinalExtension );
+ if ( $unwantedFileExtensionDetails !== null ) {
+ $warnings['filetype-unwanted-type'] = $unwantedFileExtensionDetails;
+ }
+
+ $fileSizeWarnings = $this->checkFileSize( $this->mFileSize );
+ if ( $fileSizeWarnings ) {
+ $warnings = array_merge( $warnings, $fileSizeWarnings );
+ }
+
+ $localFileExistsWarnings = $this->checkLocalFileExists( $localFile, $hash );
+ if ( $localFileExistsWarnings ) {
+ $warnings = array_merge( $warnings, $localFileExistsWarnings );
+ }
+
+ if ( $this->checkLocalFileWasDeleted( $localFile ) ) {
+ $warnings['was-deleted'] = $filename;
+ }
+
+ $dupes = $this->checkAgainstExistingDupes( $hash );
+ if ( $dupes ) {
+ $warnings['duplicate'] = $dupes;
+ }
+
+ $archivedDupes = $this->checkAgainstArchiveDupes( $hash );
+ if ( $archivedDupes !== null ) {
+ $warnings['duplicate-archive'] = $archivedDupes;
+ }
+
+ return $warnings;
+ }
+
+ /**
+ * Check whether the resulting filename is different from the desired one,
+ * but ignore things like ucfirst() and spaces/underscore things
+ *
+ * @param string $filename
+ * @param string $desiredFileName
+ *
+ * @return string|null String that was determined to be bad or null if the filename is okay
+ */
+ private function checkBadFileName( $filename, $desiredFileName ) {
+ $comparableName = str_replace( ' ', '_', $desiredFileName );
+ $comparableName = Title::capitalize( $comparableName, NS_FILE );
+
+ if ( $desiredFileName != $filename && $comparableName != $filename ) {
+ return $filename;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param string $fileExtension The file extension to check
+ *
+ * @return array|null array with the following keys:
+ * 0 => string The final extension being used
+ * 1 => string[] The extensions that are allowed
+ * 2 => int The number of extensions that are allowed.
+ */
+ private function checkUnwantedFileExtensions( $fileExtension ) {
+ global $wgCheckFileExtensions, $wgFileExtensions, $wgLang;
+
+ if ( $wgCheckFileExtensions ) {
+ $extensions = array_unique( $wgFileExtensions );
+ if ( !$this->checkFileExtension( $fileExtension, $extensions ) ) {
+ return [
+ $fileExtension,
+ $wgLang->commaList( $extensions ),
+ count( $extensions )
+ ];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param int $fileSize
+ *
+ * @return array warnings
+ */
+ private function checkFileSize( $fileSize ) {
+ global $wgUploadSizeWarning;
+
+ $warnings = [];
+
+ if ( $wgUploadSizeWarning && ( $fileSize > $wgUploadSizeWarning ) ) {
+ $warnings['large-file'] = [ $wgUploadSizeWarning, $fileSize ];
+ }
+
+ if ( $fileSize == 0 ) {
+ $warnings['empty-file'] = true;
+ }
+
+ return $warnings;
+ }
+
+ /**
+ * @param LocalFile $localFile
+ * @param string $hash sha1 hash of the file to check
+ *
+ * @return array warnings
+ */
+ private function checkLocalFileExists( LocalFile $localFile, $hash ) {
+ $warnings = [];
+
+ $exists = self::getExistsWarning( $localFile );
+ if ( $exists !== false ) {
+ $warnings['exists'] = $exists;
+
+ // check if file is an exact duplicate of current file version
+ if ( $hash === $localFile->getSha1() ) {
+ $warnings['no-change'] = $localFile;
+ }
+
+ // check if file is an exact duplicate of older versions of this file
+ $history = $localFile->getHistory();
+ foreach ( $history as $oldFile ) {
+ if ( $hash === $oldFile->getSha1() ) {
+ $warnings['duplicate-version'][] = $oldFile;
+ }
+ }
+ }
+
+ return $warnings;
+ }
+
+ private function checkLocalFileWasDeleted( LocalFile $localFile ) {
+ return $localFile->wasDeleted() && !$localFile->exists();
+ }
+
+ /**
+ * @param string $hash sha1 hash of the file to check
+ *
+ * @return File[] Duplicate files, if found.
+ */
+ private function checkAgainstExistingDupes( $hash ) {
+ $dupes = RepoGroup::singleton()->findBySha1( $hash );
+ $title = $this->getTitle();
+ // Remove all matches against self
+ foreach ( $dupes as $key => $dupe ) {
+ if ( $title->equals( $dupe->getTitle() ) ) {
+ unset( $dupes[$key] );
+ }
+ }
+
+ return $dupes;
+ }
+
+ /**
+ * @param string $hash sha1 hash of the file to check
+ *
+ * @return string|null Name of the dupe or empty string if discovered (depending on visibility)
+ * null if the check discovered no dupes.
+ */
+ private function checkAgainstArchiveDupes( $hash ) {
+ $archivedFile = new ArchivedFile( null, 0, '', $hash );
+ if ( $archivedFile->getID() > 0 ) {
+ if ( $archivedFile->userCan( File::DELETED_FILE ) ) {
+ return $archivedFile->getName();
+ } else {
+ return '';
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Really perform the upload. Stores the file in the local repo, watches
+ * if necessary and runs the UploadComplete hook.
+ *
+ * @param string $comment
+ * @param string $pageText
+ * @param bool $watch Whether the file page should be added to user's watchlist.
+ * (This doesn't check $user's permissions.)
+ * @param User $user
+ * @param string[] $tags Change tags to add to the log entry and page revision.
+ * (This doesn't check $user's permissions.)
+ * @return Status Indicating the whether the upload succeeded.
+ */
+ public function performUpload( $comment, $pageText, $watch, $user, $tags = [] ) {
+ $this->getLocalFile()->load( File::READ_LATEST );
+ $props = $this->mFileProps;
+
+ $error = null;
+ Hooks::run( 'UploadVerifyUpload', [ $this, $user, $props, $comment, $pageText, &$error ] );
+ if ( $error ) {
+ if ( !is_array( $error ) ) {
+ $error = [ $error ];
+ }
+ return call_user_func_array( 'Status::newFatal', $error );
+ }
+
+ $status = $this->getLocalFile()->upload(
+ $this->mTempPath,
+ $comment,
+ $pageText,
+ File::DELETE_SOURCE,
+ $props,
+ false,
+ $user,
+ $tags
+ );
+
+ if ( $status->isGood() ) {
+ if ( $watch ) {
+ WatchAction::doWatch(
+ $this->getLocalFile()->getTitle(),
+ $user,
+ User::IGNORE_USER_RIGHTS
+ );
+ }
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $uploadBase = $this;
+ Hooks::run( 'UploadComplete', [ &$uploadBase ] );
+
+ $this->postProcessUpload();
+ }
+
+ return $status;
+ }
+
+ /**
+ * Perform extra steps after a successful upload.
+ *
+ * @since 1.25
+ */
+ public function postProcessUpload() {
+ }
+
+ /**
+ * Returns the title of the file to be uploaded. Sets mTitleError in case
+ * the name was illegal.
+ *
+ * @return Title|null The title of the file or null in case the name was illegal
+ */
+ public function getTitle() {
+ if ( $this->mTitle !== false ) {
+ return $this->mTitle;
+ }
+ if ( !is_string( $this->mDesiredDestName ) ) {
+ $this->mTitleError = self::ILLEGAL_FILENAME;
+ $this->mTitle = null;
+
+ return $this->mTitle;
+ }
+ /* Assume that if a user specified File:Something.jpg, this is an error
+ * and that the namespace prefix needs to be stripped of.
+ */
+ $title = Title::newFromText( $this->mDesiredDestName );
+ if ( $title && $title->getNamespace() == NS_FILE ) {
+ $this->mFilteredName = $title->getDBkey();
+ } else {
+ $this->mFilteredName = $this->mDesiredDestName;
+ }
+
+ # oi_archive_name is max 255 bytes, which include a timestamp and an
+ # exclamation mark, so restrict file name to 240 bytes.
+ if ( strlen( $this->mFilteredName ) > 240 ) {
+ $this->mTitleError = self::FILENAME_TOO_LONG;
+ $this->mTitle = null;
+
+ return $this->mTitle;
+ }
+
+ /**
+ * Chop off any directories in the given filename. Then
+ * filter out illegal characters, and try to make a legible name
+ * out of it. We'll strip some silently that Title would die on.
+ */
+ $this->mFilteredName = wfStripIllegalFilenameChars( $this->mFilteredName );
+ /* Normalize to title form before we do any further processing */
+ $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
+ if ( is_null( $nt ) ) {
+ $this->mTitleError = self::ILLEGAL_FILENAME;
+ $this->mTitle = null;
+
+ return $this->mTitle;
+ }
+ $this->mFilteredName = $nt->getDBkey();
+
+ /**
+ * We'll want to blacklist against *any* 'extension', and use
+ * only the final one for the whitelist.
+ */
+ list( $partname, $ext ) = $this->splitExtensions( $this->mFilteredName );
+
+ if ( count( $ext ) ) {
+ $this->mFinalExtension = trim( $ext[count( $ext ) - 1] );
+ } else {
+ $this->mFinalExtension = '';
+
+ # No extension, try guessing one
+ $magic = MimeMagic::singleton();
+ $mime = $magic->guessMimeType( $this->mTempPath );
+ if ( $mime !== 'unknown/unknown' ) {
+ # Get a space separated list of extensions
+ $extList = $magic->getExtensionsForType( $mime );
+ if ( $extList ) {
+ # Set the extension to the canonical extension
+ $this->mFinalExtension = strtok( $extList, ' ' );
+
+ # Fix up the other variables
+ $this->mFilteredName .= ".{$this->mFinalExtension}";
+ $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
+ $ext = [ $this->mFinalExtension ];
+ }
+ }
+ }
+
+ /* Don't allow users to override the blacklist (check file extension) */
+ global $wgCheckFileExtensions, $wgStrictFileExtensions;
+ global $wgFileExtensions, $wgFileBlacklist;
+
+ $blackListedExtensions = $this->checkFileExtensionList( $ext, $wgFileBlacklist );
+
+ if ( $this->mFinalExtension == '' ) {
+ $this->mTitleError = self::FILETYPE_MISSING;
+ $this->mTitle = null;
+
+ return $this->mTitle;
+ } elseif ( $blackListedExtensions ||
+ ( $wgCheckFileExtensions && $wgStrictFileExtensions &&
+ !$this->checkFileExtension( $this->mFinalExtension, $wgFileExtensions ) )
+ ) {
+ $this->mBlackListedExtensions = $blackListedExtensions;
+ $this->mTitleError = self::FILETYPE_BADTYPE;
+ $this->mTitle = null;
+
+ return $this->mTitle;
+ }
+
+ // Windows may be broken with special characters, see T3780
+ if ( !preg_match( '/^[\x0-\x7f]*$/', $nt->getText() )
+ && !RepoGroup::singleton()->getLocalRepo()->backendSupportsUnicodePaths()
+ ) {
+ $this->mTitleError = self::WINDOWS_NONASCII_FILENAME;
+ $this->mTitle = null;
+
+ return $this->mTitle;
+ }
+
+ # If there was more than one "extension", reassemble the base
+ # filename to prevent bogus complaints about length
+ if ( count( $ext ) > 1 ) {
+ $iterations = count( $ext ) - 1;
+ for ( $i = 0; $i < $iterations; $i++ ) {
+ $partname .= '.' . $ext[$i];
+ }
+ }
+
+ if ( strlen( $partname ) < 1 ) {
+ $this->mTitleError = self::MIN_LENGTH_PARTNAME;
+ $this->mTitle = null;
+
+ return $this->mTitle;
+ }
+
+ $this->mTitle = $nt;
+
+ return $this->mTitle;
+ }
+
+ /**
+ * Return the local file and initializes if necessary.
+ *
+ * @return LocalFile|null
+ */
+ public function getLocalFile() {
+ if ( is_null( $this->mLocalFile ) ) {
+ $nt = $this->getTitle();
+ $this->mLocalFile = is_null( $nt ) ? null : wfLocalFile( $nt );
+ }
+
+ return $this->mLocalFile;
+ }
+
+ /**
+ * @return UploadStashFile|null
+ */
+ public function getStashFile() {
+ return $this->mStashFile;
+ }
+
+ /**
+ * Like stashFile(), but respects extensions' wishes to prevent the stashing. verifyUpload() must
+ * be called before calling this method (unless $isPartial is true).
+ *
+ * Upload stash exceptions are also caught and converted to an error status.
+ *
+ * @since 1.28
+ * @param User $user
+ * @param bool $isPartial Pass `true` if this is a part of a chunked upload (not a complete file).
+ * @return Status If successful, value is an UploadStashFile instance
+ */
+ public function tryStashFile( User $user, $isPartial = false ) {
+ if ( !$isPartial ) {
+ $error = $this->runUploadStashFileHook( $user );
+ if ( $error ) {
+ return call_user_func_array( 'Status::newFatal', $error );
+ }
+ }
+ try {
+ $file = $this->doStashFile( $user );
+ return Status::newGood( $file );
+ } catch ( UploadStashException $e ) {
+ return Status::newFatal( 'uploadstash-exception', get_class( $e ), $e->getMessage() );
+ }
+ }
+
+ /**
+ * @param User $user
+ * @return array|null Error message and parameters, null if there's no error
+ */
+ protected function runUploadStashFileHook( User $user ) {
+ $props = $this->mFileProps;
+ $error = null;
+ Hooks::run( 'UploadStashFile', [ $this, $user, $props, &$error ] );
+ if ( $error ) {
+ if ( !is_array( $error ) ) {
+ $error = [ $error ];
+ }
+ }
+ return $error;
+ }
+
+ /**
+ * If the user does not supply all necessary information in the first upload
+ * form submission (either by accident or by design) then we may want to
+ * stash the file temporarily, get more information, and publish the file
+ * later.
+ *
+ * This method will stash a file in a temporary directory for later
+ * processing, and save the necessary descriptive info into the database.
+ * This method returns the file object, which also has a 'fileKey' property
+ * which can be passed through a form or API request to find this stashed
+ * file again.
+ *
+ * @deprecated since 1.28 Use tryStashFile() instead
+ * @param User $user
+ * @return UploadStashFile Stashed file
+ * @throws UploadStashBadPathException
+ * @throws UploadStashFileException
+ * @throws UploadStashNotLoggedInException
+ */
+ public function stashFile( User $user = null ) {
+ return $this->doStashFile( $user );
+ }
+
+ /**
+ * Implementation for stashFile() and tryStashFile().
+ *
+ * @param User $user
+ * @return UploadStashFile Stashed file
+ */
+ protected function doStashFile( User $user = null ) {
+ $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $user );
+ $file = $stash->stashFile( $this->mTempPath, $this->getSourceType() );
+ $this->mStashFile = $file;
+
+ return $file;
+ }
+
+ /**
+ * Stash a file in a temporary directory, returning a key which can be used
+ * to find the file again. See stashFile().
+ *
+ * @deprecated since 1.28
+ * @return string File key
+ */
+ public function stashFileGetKey() {
+ wfDeprecated( __METHOD__, '1.28' );
+ return $this->doStashFile()->getFileKey();
+ }
+
+ /**
+ * alias for stashFileGetKey, for backwards compatibility
+ *
+ * @deprecated since 1.28
+ * @return string File key
+ */
+ public function stashSession() {
+ wfDeprecated( __METHOD__, '1.28' );
+ return $this->doStashFile()->getFileKey();
+ }
+
+ /**
+ * If we've modified the upload file we need to manually remove it
+ * on exit to clean up.
+ */
+ public function cleanupTempFile() {
+ if ( $this->mRemoveTempFile && $this->tempFileObj ) {
+ // Delete when all relevant TempFSFile handles go out of scope
+ wfDebug( __METHOD__ . ": Marked temporary file '{$this->mTempPath}' for removal\n" );
+ $this->tempFileObj->autocollect();
+ }
+ }
+
+ public function getTempPath() {
+ return $this->mTempPath;
+ }
+
+ /**
+ * Split a file into a base name and all dot-delimited 'extensions'
+ * on the end. Some web server configurations will fall back to
+ * earlier pseudo-'extensions' to determine type and execute
+ * scripts, so the blacklist needs to check them all.
+ *
+ * @param string $filename
+ * @return array
+ */
+ public static function splitExtensions( $filename ) {
+ $bits = explode( '.', $filename );
+ $basename = array_shift( $bits );
+
+ return [ $basename, $bits ];
+ }
+
+ /**
+ * Perform case-insensitive match against a list of file extensions.
+ * Returns true if the extension is in the list.
+ *
+ * @param string $ext
+ * @param array $list
+ * @return bool
+ */
+ public static function checkFileExtension( $ext, $list ) {
+ return in_array( strtolower( $ext ), $list );
+ }
+
+ /**
+ * Perform case-insensitive match against a list of file extensions.
+ * Returns an array of matching extensions.
+ *
+ * @param array $ext
+ * @param array $list
+ * @return bool
+ */
+ public static function checkFileExtensionList( $ext, $list ) {
+ return array_intersect( array_map( 'strtolower', $ext ), $list );
+ }
+
+ /**
+ * Checks if the MIME type of the uploaded file matches the file extension.
+ *
+ * @param string $mime The MIME type of the uploaded file
+ * @param string $extension The filename extension that the file is to be served with
+ * @return bool
+ */
+ public static function verifyExtension( $mime, $extension ) {
+ $magic = MimeMagic::singleton();
+
+ if ( !$mime || $mime == 'unknown' || $mime == 'unknown/unknown' ) {
+ if ( !$magic->isRecognizableExtension( $extension ) ) {
+ wfDebug( __METHOD__ . ": passing file with unknown detected mime type; " .
+ "unrecognized extension '$extension', can't verify\n" );
+
+ return true;
+ } else {
+ wfDebug( __METHOD__ . ": rejecting file with unknown detected mime type; " .
+ "recognized extension '$extension', so probably invalid file\n" );
+
+ return false;
+ }
+ }
+
+ $match = $magic->isMatchingExtension( $extension, $mime );
+
+ if ( $match === null ) {
+ if ( $magic->getTypesForExtension( $extension ) !== null ) {
+ wfDebug( __METHOD__ . ": No extension known for $mime, but we know a mime for $extension\n" );
+
+ return false;
+ } else {
+ wfDebug( __METHOD__ . ": no file extension known for mime type $mime, passing file\n" );
+
+ return true;
+ }
+ } elseif ( $match === true ) {
+ wfDebug( __METHOD__ . ": mime type $mime matches extension $extension, passing file\n" );
+
+ /** @todo If it's a bitmap, make sure PHP or ImageMagick resp. can handle it! */
+ return true;
+ } else {
+ wfDebug( __METHOD__
+ . ": mime type $mime mismatches file extension $extension, rejecting file\n" );
+
+ return false;
+ }
+ }
+
+ /**
+ * Heuristic for detecting files that *could* contain JavaScript instructions or
+ * things that may look like HTML to a browser and are thus
+ * potentially harmful. The present implementation will produce false
+ * positives in some situations.
+ *
+ * @param string $file Pathname to the temporary upload file
+ * @param string $mime The MIME type of the file
+ * @param string $extension The extension of the file
+ * @return bool True if the file contains something looking like embedded scripts
+ */
+ public static function detectScript( $file, $mime, $extension ) {
+ global $wgAllowTitlesInSVG;
+
+ # ugly hack: for text files, always look at the entire file.
+ # For binary field, just check the first K.
+
+ if ( strpos( $mime, 'text/' ) === 0 ) {
+ $chunk = file_get_contents( $file );
+ } else {
+ $fp = fopen( $file, 'rb' );
+ $chunk = fread( $fp, 1024 );
+ fclose( $fp );
+ }
+
+ $chunk = strtolower( $chunk );
+
+ if ( !$chunk ) {
+ return false;
+ }
+
+ # decode from UTF-16 if needed (could be used for obfuscation).
+ if ( substr( $chunk, 0, 2 ) == "\xfe\xff" ) {
+ $enc = 'UTF-16BE';
+ } elseif ( substr( $chunk, 0, 2 ) == "\xff\xfe" ) {
+ $enc = 'UTF-16LE';
+ } else {
+ $enc = null;
+ }
+
+ if ( $enc ) {
+ $chunk = iconv( $enc, "ASCII//IGNORE", $chunk );
+ }
+
+ $chunk = trim( $chunk );
+
+ /** @todo FIXME: Convert from UTF-16 if necessary! */
+ wfDebug( __METHOD__ . ": checking for embedded scripts and HTML stuff\n" );
+
+ # check for HTML doctype
+ if ( preg_match( "/<!DOCTYPE *X?HTML/i", $chunk ) ) {
+ return true;
+ }
+
+ // Some browsers will interpret obscure xml encodings as UTF-8, while
+ // PHP/expat will interpret the given encoding in the xml declaration (T49304)
+ if ( $extension == 'svg' || strpos( $mime, 'image/svg' ) === 0 ) {
+ if ( self::checkXMLEncodingMissmatch( $file ) ) {
+ return true;
+ }
+ }
+
+ /**
+ * Internet Explorer for Windows performs some really stupid file type
+ * autodetection which can cause it to interpret valid image files as HTML
+ * and potentially execute JavaScript, creating a cross-site scripting
+ * attack vectors.
+ *
+ * Apple's Safari browser also performs some unsafe file type autodetection
+ * which can cause legitimate files to be interpreted as HTML if the
+ * web server is not correctly configured to send the right content-type
+ * (or if you're really uploading plain text and octet streams!)
+ *
+ * Returns true if IE is likely to mistake the given file for HTML.
+ * Also returns true if Safari would mistake the given file for HTML
+ * when served with a generic content-type.
+ */
+ $tags = [
+ '<a href',
+ '<body',
+ '<head',
+ '<html', # also in safari
+ '<img',
+ '<pre',
+ '<script', # also in safari
+ '<table'
+ ];
+
+ if ( !$wgAllowTitlesInSVG && $extension !== 'svg' && $mime !== 'image/svg' ) {
+ $tags[] = '<title';
+ }
+
+ foreach ( $tags as $tag ) {
+ if ( false !== strpos( $chunk, $tag ) ) {
+ wfDebug( __METHOD__ . ": found something that may make it be mistaken for html: $tag\n" );
+
+ return true;
+ }
+ }
+
+ /*
+ * look for JavaScript
+ */
+
+ # resolve entity-refs to look at attributes. may be harsh on big files... cache result?
+ $chunk = Sanitizer::decodeCharReferences( $chunk );
+
+ # look for script-types
+ if ( preg_match( '!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!sim', $chunk ) ) {
+ wfDebug( __METHOD__ . ": found script types\n" );
+
+ return true;
+ }
+
+ # look for html-style script-urls
+ if ( preg_match( '!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) {
+ wfDebug( __METHOD__ . ": found html-style script urls\n" );
+
+ return true;
+ }
+
+ # look for css-style script-urls
+ if ( preg_match( '!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) {
+ wfDebug( __METHOD__ . ": found css-style script urls\n" );
+
+ return true;
+ }
+
+ wfDebug( __METHOD__ . ": no scripts found\n" );
+
+ return false;
+ }
+
+ /**
+ * Check a whitelist of xml encodings that are known not to be interpreted differently
+ * by the server's xml parser (expat) and some common browsers.
+ *
+ * @param string $file Pathname to the temporary upload file
+ * @return bool True if the file contains an encoding that could be misinterpreted
+ */
+ public static function checkXMLEncodingMissmatch( $file ) {
+ global $wgSVGMetadataCutoff;
+ $contents = file_get_contents( $file, false, null, -1, $wgSVGMetadataCutoff );
+ $encodingRegex = '!encoding[ \t\n\r]*=[ \t\n\r]*[\'"](.*?)[\'"]!si';
+
+ if ( preg_match( "!<\?xml\b(.*?)\?>!si", $contents, $matches ) ) {
+ if ( preg_match( $encodingRegex, $matches[1], $encMatch )
+ && !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings )
+ ) {
+ wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" );
+
+ return true;
+ }
+ } elseif ( preg_match( "!<\?xml\b!si", $contents ) ) {
+ // Start of XML declaration without an end in the first $wgSVGMetadataCutoff
+ // bytes. There shouldn't be a legitimate reason for this to happen.
+ wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" );
+
+ return true;
+ } elseif ( substr( $contents, 0, 4 ) == "\x4C\x6F\xA7\x94" ) {
+ // EBCDIC encoded XML
+ wfDebug( __METHOD__ . ": EBCDIC Encoded XML\n" );
+
+ return true;
+ }
+
+ // It's possible the file is encoded with multi-byte encoding, so re-encode attempt to
+ // detect the encoding in case is specifies an encoding not whitelisted in self::$safeXmlEncodings
+ $attemptEncodings = [ 'UTF-16', 'UTF-16BE', 'UTF-32', 'UTF-32BE' ];
+ foreach ( $attemptEncodings as $encoding ) {
+ MediaWiki\suppressWarnings();
+ $str = iconv( $encoding, 'UTF-8', $contents );
+ MediaWiki\restoreWarnings();
+ if ( $str != '' && preg_match( "!<\?xml\b(.*?)\?>!si", $str, $matches ) ) {
+ if ( preg_match( $encodingRegex, $matches[1], $encMatch )
+ && !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings )
+ ) {
+ wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" );
+
+ return true;
+ }
+ } elseif ( $str != '' && preg_match( "!<\?xml\b!si", $str ) ) {
+ // Start of XML declaration without an end in the first $wgSVGMetadataCutoff
+ // bytes. There shouldn't be a legitimate reason for this to happen.
+ wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" );
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $filename
+ * @param bool $partial
+ * @return mixed False of the file is verified (does not contain scripts), array otherwise.
+ */
+ protected function detectScriptInSvg( $filename, $partial ) {
+ $this->mSVGNSError = false;
+ $check = new XmlTypeCheck(
+ $filename,
+ [ $this, 'checkSvgScriptCallback' ],
+ true,
+ [
+ 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback',
+ 'external_dtd_handler' => 'UploadBase::checkSvgExternalDTD',
+ ]
+ );
+ if ( $check->wellFormed !== true ) {
+ // Invalid xml (T60553)
+ // But only when non-partial (T67724)
+ return $partial ? false : [ 'uploadinvalidxml' ];
+ } elseif ( $check->filterMatch ) {
+ if ( $this->mSVGNSError ) {
+ return [ 'uploadscriptednamespace', $this->mSVGNSError ];
+ }
+
+ return $check->filterMatchType;
+ }
+
+ return false;
+ }
+
+ /**
+ * Callback to filter SVG Processing Instructions.
+ * @param string $target Processing instruction name
+ * @param string $data Processing instruction attribute and value
+ * @return bool (true if the filter identified something bad)
+ */
+ public static function checkSvgPICallback( $target, $data ) {
+ // Don't allow external stylesheets (T59550)
+ if ( preg_match( '/xml-stylesheet/i', $target ) ) {
+ return [ 'upload-scripted-pi-callback' ];
+ }
+
+ return false;
+ }
+
+ /**
+ * Verify that DTD urls referenced are only the standard dtds
+ *
+ * Browsers seem to ignore external dtds. However just to be on the
+ * safe side, only allow dtds from the svg standard.
+ *
+ * @param string $type PUBLIC or SYSTEM
+ * @param string $publicId The well-known public identifier for the dtd
+ * @param string $systemId The url for the external dtd
+ * @return bool|array
+ */
+ public static function checkSvgExternalDTD( $type, $publicId, $systemId ) {
+ // This doesn't include the XHTML+MathML+SVG doctype since we don't
+ // allow XHTML anyways.
+ $allowedDTDs = [
+ 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd',
+ 'http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd',
+ 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd',
+ 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd',
+ // https://phabricator.wikimedia.org/T168856
+ 'http://www.w3.org/TR/2001/PR-SVG-20010719/DTD/svg10.dtd',
+ ];
+ if ( $type !== 'PUBLIC'
+ || !in_array( $systemId, $allowedDTDs )
+ || strpos( $publicId, "-//W3C//" ) !== 0
+ ) {
+ return [ 'upload-scripted-dtd' ];
+ }
+ return false;
+ }
+
+ /**
+ * @todo Replace this with a whitelist filter!
+ * @param string $element
+ * @param array $attribs
+ * @param array $data
+ * @return bool
+ */
+ public function checkSvgScriptCallback( $element, $attribs, $data = null ) {
+ list( $namespace, $strippedElement ) = $this->splitXmlNamespace( $element );
+
+ // We specifically don't include:
+ // http://www.w3.org/1999/xhtml (T62771)
+ static $validNamespaces = [
+ '',
+ 'adobe:ns:meta/',
+ 'http://creativecommons.org/ns#',
+ 'http://inkscape.sourceforge.net/dtd/sodipodi-0.dtd',
+ 'http://ns.adobe.com/adobeillustrator/10.0/',
+ 'http://ns.adobe.com/adobesvgviewerextensions/3.0/',
+ 'http://ns.adobe.com/extensibility/1.0/',
+ 'http://ns.adobe.com/flows/1.0/',
+ 'http://ns.adobe.com/illustrator/1.0/',
+ 'http://ns.adobe.com/imagereplacement/1.0/',
+ 'http://ns.adobe.com/pdf/1.3/',
+ 'http://ns.adobe.com/photoshop/1.0/',
+ 'http://ns.adobe.com/saveforweb/1.0/',
+ 'http://ns.adobe.com/variables/1.0/',
+ 'http://ns.adobe.com/xap/1.0/',
+ 'http://ns.adobe.com/xap/1.0/g/',
+ 'http://ns.adobe.com/xap/1.0/g/img/',
+ 'http://ns.adobe.com/xap/1.0/mm/',
+ 'http://ns.adobe.com/xap/1.0/rights/',
+ 'http://ns.adobe.com/xap/1.0/stype/dimensions#',
+ 'http://ns.adobe.com/xap/1.0/stype/font#',
+ 'http://ns.adobe.com/xap/1.0/stype/manifestitem#',
+ 'http://ns.adobe.com/xap/1.0/stype/resourceevent#',
+ 'http://ns.adobe.com/xap/1.0/stype/resourceref#',
+ 'http://ns.adobe.com/xap/1.0/t/pg/',
+ 'http://purl.org/dc/elements/1.1/',
+ 'http://purl.org/dc/elements/1.1',
+ 'http://schemas.microsoft.com/visio/2003/svgextensions/',
+ 'http://sodipodi.sourceforge.net/dtd/sodipodi-0.dtd',
+ 'http://taptrix.com/inkpad/svg_extensions',
+ 'http://web.resource.org/cc/',
+ 'http://www.freesoftware.fsf.org/bkchem/cdml',
+ 'http://www.inkscape.org/namespaces/inkscape',
+ 'http://www.opengis.net/gml',
+ 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
+ 'http://www.w3.org/2000/svg',
+ 'http://www.w3.org/tr/rec-rdf-syntax/',
+ 'http://www.w3.org/2000/01/rdf-schema#',
+ ];
+
+ // Inkscape mangles namespace definitions created by Adobe Illustrator.
+ // This is nasty but harmless. (T144827)
+ $isBuggyInkscape = preg_match( '/^&(#38;)*ns_[a-z_]+;$/', $namespace );
+
+ if ( !( $isBuggyInkscape || in_array( $namespace, $validNamespaces ) ) ) {
+ wfDebug( __METHOD__ . ": Non-svg namespace '$namespace' in uploaded file.\n" );
+ /** @todo Return a status object to a closure in XmlTypeCheck, for MW1.21+ */
+ $this->mSVGNSError = $namespace;
+
+ return true;
+ }
+
+ /*
+ * check for elements that can contain javascript
+ */
+ if ( $strippedElement == 'script' ) {
+ wfDebug( __METHOD__ . ": Found script element '$element' in uploaded file.\n" );
+
+ return [ 'uploaded-script-svg', $strippedElement ];
+ }
+
+ # e.g., <svg xmlns="http://www.w3.org/2000/svg">
+ # <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>
+ if ( $strippedElement == 'handler' ) {
+ wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" );
+
+ return [ 'uploaded-script-svg', $strippedElement ];
+ }
+
+ # SVG reported in Feb '12 that used xml:stylesheet to generate javascript block
+ if ( $strippedElement == 'stylesheet' ) {
+ wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" );
+
+ return [ 'uploaded-script-svg', $strippedElement ];
+ }
+
+ # Block iframes, in case they pass the namespace check
+ if ( $strippedElement == 'iframe' ) {
+ wfDebug( __METHOD__ . ": iframe in uploaded file.\n" );
+
+ return [ 'uploaded-script-svg', $strippedElement ];
+ }
+
+ # Check <style> css
+ if ( $strippedElement == 'style'
+ && self::checkCssFragment( Sanitizer::normalizeCss( $data ) )
+ ) {
+ wfDebug( __METHOD__ . ": hostile css in style element.\n" );
+ return [ 'uploaded-hostile-svg' ];
+ }
+
+ foreach ( $attribs as $attrib => $value ) {
+ $stripped = $this->stripXmlNamespace( $attrib );
+ $value = strtolower( $value );
+
+ if ( substr( $stripped, 0, 2 ) == 'on' ) {
+ wfDebug( __METHOD__
+ . ": Found event-handler attribute '$attrib'='$value' in uploaded file.\n" );
+
+ return [ 'uploaded-event-handler-on-svg', $attrib, $value ];
+ }
+
+ # Do not allow relative links, or unsafe url schemas.
+ # For <a> tags, only data:, http: and https: and same-document
+ # fragment links are allowed. For all other tags, only data:
+ # and fragment are allowed.
+ if ( $stripped == 'href'
+ && $value !== ''
+ && strpos( $value, 'data:' ) !== 0
+ && strpos( $value, '#' ) !== 0
+ ) {
+ if ( !( $strippedElement === 'a'
+ && preg_match( '!^https?://!i', $value ) )
+ ) {
+ wfDebug( __METHOD__ . ": Found href attribute <$strippedElement "
+ . "'$attrib'='$value' in uploaded file.\n" );
+
+ return [ 'uploaded-href-attribute-svg', $strippedElement, $attrib, $value ];
+ }
+ }
+
+ # only allow data: targets that should be safe. This prevents vectors like,
+ # image/svg, text/xml, application/xml, and text/html, which can contain scripts
+ if ( $stripped == 'href' && strncasecmp( 'data:', $value, 5 ) === 0 ) {
+ // rfc2397 parameters. This is only slightly slower than (;[\w;]+)*.
+ // @codingStandardsIgnoreStart Generic.Files.LineLength
+ $parameters = '(?>;[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+=(?>[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+|"(?>[\0-\x0c\x0e-\x21\x23-\x5b\x5d-\x7f]+|\\\\[\0-\x7f])*"))*(?:;base64)?';
+ // @codingStandardsIgnoreEnd
+
+ if ( !preg_match( "!^data:\s*image/(gif|jpeg|jpg|png)$parameters,!i", $value ) ) {
+ wfDebug( __METHOD__ . ": Found href to unwhitelisted data: uri "
+ . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" );
+ return [ 'uploaded-href-unsafe-target-svg', $strippedElement, $attrib, $value ];
+ }
+ }
+
+ # Change href with animate from (http://html5sec.org/#137).
+ if ( $stripped === 'attributename'
+ && $strippedElement === 'animate'
+ && $this->stripXmlNamespace( $value ) == 'href'
+ ) {
+ wfDebug( __METHOD__ . ": Found animate that might be changing href using from "
+ . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" );
+
+ return [ 'uploaded-animate-svg', $strippedElement, $attrib, $value ];
+ }
+
+ # use set/animate to add event-handler attribute to parent
+ if ( ( $strippedElement == 'set' || $strippedElement == 'animate' )
+ && $stripped == 'attributename'
+ && substr( $value, 0, 2 ) == 'on'
+ ) {
+ wfDebug( __METHOD__ . ": Found svg setting event-handler attribute with "
+ . "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" );
+
+ return [ 'uploaded-setting-event-handler-svg', $strippedElement, $stripped, $value ];
+ }
+
+ # use set to add href attribute to parent element
+ if ( $strippedElement == 'set'
+ && $stripped == 'attributename'
+ && strpos( $value, 'href' ) !== false
+ ) {
+ wfDebug( __METHOD__ . ": Found svg setting href attribute '$value' in uploaded file.\n" );
+
+ return [ 'uploaded-setting-href-svg' ];
+ }
+
+ # use set to add a remote / data / script target to an element
+ if ( $strippedElement == 'set'
+ && $stripped == 'to'
+ && preg_match( '!(http|https|data|script):!sim', $value )
+ ) {
+ wfDebug( __METHOD__ . ": Found svg setting attribute to '$value' in uploaded file.\n" );
+
+ return [ 'uploaded-wrong-setting-svg', $value ];
+ }
+
+ # use handler attribute with remote / data / script
+ if ( $stripped == 'handler' && preg_match( '!(http|https|data|script):!sim', $value ) ) {
+ wfDebug( __METHOD__ . ": Found svg setting handler with remote/data/script "
+ . "'$attrib'='$value' in uploaded file.\n" );
+
+ return [ 'uploaded-setting-handler-svg', $attrib, $value ];
+ }
+
+ # use CSS styles to bring in remote code
+ if ( $stripped == 'style'
+ && self::checkCssFragment( Sanitizer::normalizeCss( $value ) )
+ ) {
+ wfDebug( __METHOD__ . ": Found svg setting a style with "
+ . "remote url '$attrib'='$value' in uploaded file.\n" );
+ return [ 'uploaded-remote-url-svg', $attrib, $value ];
+ }
+
+ # Several attributes can include css, css character escaping isn't allowed
+ $cssAttrs = [ 'font', 'clip-path', 'fill', 'filter', 'marker',
+ 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke' ];
+ if ( in_array( $stripped, $cssAttrs )
+ && self::checkCssFragment( $value )
+ ) {
+ wfDebug( __METHOD__ . ": Found svg setting a style with "
+ . "remote url '$attrib'='$value' in uploaded file.\n" );
+ return [ 'uploaded-remote-url-svg', $attrib, $value ];
+ }
+
+ # image filters can pull in url, which could be svg that executes scripts
+ if ( $strippedElement == 'image'
+ && $stripped == 'filter'
+ && preg_match( '!url\s*\(!sim', $value )
+ ) {
+ wfDebug( __METHOD__ . ": Found image filter with url: "
+ . "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" );
+
+ return [ 'uploaded-image-filter-svg', $strippedElement, $stripped, $value ];
+ }
+ }
+
+ return false; // No scripts detected
+ }
+
+ /**
+ * Check a block of CSS or CSS fragment for anything that looks like
+ * it is bringing in remote code.
+ * @param string $value a string of CSS
+ * @param bool $propOnly only check css properties (start regex with :)
+ * @return bool true if the CSS contains an illegal string, false if otherwise
+ */
+ private static function checkCssFragment( $value ) {
+ # Forbid external stylesheets, for both reliability and to protect viewer's privacy
+ if ( stripos( $value, '@import' ) !== false ) {
+ return true;
+ }
+
+ # We allow @font-face to embed fonts with data: urls, so we snip the string
+ # 'url' out so this case won't match when we check for urls below
+ $pattern = '!(@font-face\s*{[^}]*src:)url(\("data:;base64,)!im';
+ $value = preg_replace( $pattern, '$1$2', $value );
+
+ # Check for remote and executable CSS. Unlike in Sanitizer::checkCss, the CSS
+ # properties filter and accelerator don't seem to be useful for xss in SVG files.
+ # Expression and -o-link don't seem to work either, but filtering them here in case.
+ # Additionally, we catch remote urls like url("http:..., url('http:..., url(http:...,
+ # but not local ones such as url("#..., url('#..., url(#....
+ if ( preg_match( '!expression
+ | -o-link\s*:
+ | -o-link-source\s*:
+ | -o-replace\s*:!imx', $value ) ) {
+ return true;
+ }
+
+ if ( preg_match_all(
+ "!(\s*(url|image|image-set)\s*\(\s*[\"']?\s*[^#]+.*?\))!sim",
+ $value,
+ $matches
+ ) !== 0
+ ) {
+ # TODO: redo this in one regex. Until then, url("#whatever") matches the first
+ foreach ( $matches[1] as $match ) {
+ if ( !preg_match( "!\s*(url|image|image-set)\s*\(\s*(#|'#|\"#)!im", $match ) ) {
+ return true;
+ }
+ }
+ }
+
+ if ( preg_match( '/[\000-\010\013\016-\037\177]/', $value ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Divide the element name passed by the xml parser to the callback into URI and prifix.
+ * @param string $element
+ * @return array Containing the namespace URI and prefix
+ */
+ private static function splitXmlNamespace( $element ) {
+ // 'http://www.w3.org/2000/svg:script' -> [ 'http://www.w3.org/2000/svg', 'script' ]
+ $parts = explode( ':', strtolower( $element ) );
+ $name = array_pop( $parts );
+ $ns = implode( ':', $parts );
+
+ return [ $ns, $name ];
+ }
+
+ /**
+ * @param string $name
+ * @return string
+ */
+ private function stripXmlNamespace( $name ) {
+ // 'http://www.w3.org/2000/svg:script' -> 'script'
+ $parts = explode( ':', strtolower( $name ) );
+
+ return array_pop( $parts );
+ }
+
+ /**
+ * Generic wrapper function for a virus scanner program.
+ * This relies on the $wgAntivirus and $wgAntivirusSetup variables.
+ * $wgAntivirusRequired may be used to deny upload if the scan fails.
+ *
+ * @param string $file Pathname to the temporary upload file
+ * @return mixed False if not virus is found, null if the scan fails or is disabled,
+ * or a string containing feedback from the virus scanner if a virus was found.
+ * If textual feedback is missing but a virus was found, this function returns true.
+ */
+ public static function detectVirus( $file ) {
+ global $wgAntivirus, $wgAntivirusSetup, $wgAntivirusRequired, $wgOut;
+
+ if ( !$wgAntivirus ) {
+ wfDebug( __METHOD__ . ": virus scanner disabled\n" );
+
+ return null;
+ }
+
+ if ( !$wgAntivirusSetup[$wgAntivirus] ) {
+ wfDebug( __METHOD__ . ": unknown virus scanner: $wgAntivirus\n" );
+ $wgOut->wrapWikiMsg( "<div class=\"error\">\n$1\n</div>",
+ [ 'virus-badscanner', $wgAntivirus ] );
+
+ return wfMessage( 'virus-unknownscanner' )->text() . " $wgAntivirus";
+ }
+
+ # look up scanner configuration
+ $command = $wgAntivirusSetup[$wgAntivirus]['command'];
+ $exitCodeMap = $wgAntivirusSetup[$wgAntivirus]['codemap'];
+ $msgPattern = isset( $wgAntivirusSetup[$wgAntivirus]['messagepattern'] ) ?
+ $wgAntivirusSetup[$wgAntivirus]['messagepattern'] : null;
+
+ if ( strpos( $command, "%f" ) === false ) {
+ # simple pattern: append file to scan
+ $command .= " " . wfEscapeShellArg( $file );
+ } else {
+ # complex pattern: replace "%f" with file to scan
+ $command = str_replace( "%f", wfEscapeShellArg( $file ), $command );
+ }
+
+ wfDebug( __METHOD__ . ": running virus scan: $command \n" );
+
+ # execute virus scanner
+ $exitCode = false;
+
+ # NOTE: there's a 50 line workaround to make stderr redirection work on windows, too.
+ # that does not seem to be worth the pain.
+ # Ask me (Duesentrieb) about it if it's ever needed.
+ $output = wfShellExecWithStderr( $command, $exitCode );
+
+ # map exit code to AV_xxx constants.
+ $mappedCode = $exitCode;
+ if ( $exitCodeMap ) {
+ if ( isset( $exitCodeMap[$exitCode] ) ) {
+ $mappedCode = $exitCodeMap[$exitCode];
+ } elseif ( isset( $exitCodeMap["*"] ) ) {
+ $mappedCode = $exitCodeMap["*"];
+ }
+ }
+
+ /* NB: AV_NO_VIRUS is 0 but AV_SCAN_FAILED is false,
+ * so we need the strict equalities === and thus can't use a switch here
+ */
+ if ( $mappedCode === AV_SCAN_FAILED ) {
+ # scan failed (code was mapped to false by $exitCodeMap)
+ wfDebug( __METHOD__ . ": failed to scan $file (code $exitCode).\n" );
+
+ $output = $wgAntivirusRequired
+ ? wfMessage( 'virus-scanfailed', [ $exitCode ] )->text()
+ : null;
+ } elseif ( $mappedCode === AV_SCAN_ABORTED ) {
+ # scan failed because filetype is unknown (probably imune)
+ wfDebug( __METHOD__ . ": unsupported file type $file (code $exitCode).\n" );
+ $output = null;
+ } elseif ( $mappedCode === AV_NO_VIRUS ) {
+ # no virus found
+ wfDebug( __METHOD__ . ": file passed virus scan.\n" );
+ $output = false;
+ } else {
+ $output = trim( $output );
+
+ if ( !$output ) {
+ $output = true; # if there's no output, return true
+ } elseif ( $msgPattern ) {
+ $groups = [];
+ if ( preg_match( $msgPattern, $output, $groups ) ) {
+ if ( $groups[1] ) {
+ $output = $groups[1];
+ }
+ }
+ }
+
+ wfDebug( __METHOD__ . ": FOUND VIRUS! scanner feedback: $output \n" );
+ }
+
+ return $output;
+ }
+
+ /**
+ * Check if there's an overwrite conflict and, if so, if restrictions
+ * forbid this user from performing the upload.
+ *
+ * @param User $user
+ *
+ * @return mixed True on success, array on failure
+ */
+ private function checkOverwrite( $user ) {
+ // First check whether the local file can be overwritten
+ $file = $this->getLocalFile();
+ $file->load( File::READ_LATEST );
+ if ( $file->exists() ) {
+ if ( !self::userCanReUpload( $user, $file ) ) {
+ return [ 'fileexists-forbidden', $file->getName() ];
+ } else {
+ return true;
+ }
+ }
+
+ /* Check shared conflicts: if the local file does not exist, but
+ * wfFindFile finds a file, it exists in a shared repository.
+ */
+ $file = wfFindFile( $this->getTitle(), [ 'latest' => true ] );
+ if ( $file && !$user->isAllowed( 'reupload-shared' ) ) {
+ return [ 'fileexists-shared-forbidden', $file->getName() ];
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if a user is the last uploader
+ *
+ * @param User $user
+ * @param File $img
+ * @return bool
+ */
+ public static function userCanReUpload( User $user, File $img ) {
+ if ( $user->isAllowed( 'reupload' ) ) {
+ return true; // non-conditional
+ } elseif ( !$user->isAllowed( 'reupload-own' ) ) {
+ return false;
+ }
+
+ if ( !( $img instanceof LocalFile ) ) {
+ return false;
+ }
+
+ $img->load();
+
+ return $user->getId() == $img->getUser( 'id' );
+ }
+
+ /**
+ * Helper function that does various existence checks for a file.
+ * The following checks are performed:
+ * - The file exists
+ * - Article with the same name as the file exists
+ * - File exists with normalized extension
+ * - The file looks like a thumbnail and the original exists
+ *
+ * @param File $file The File object to check
+ * @return mixed False if the file does not exists, else an array
+ */
+ public static function getExistsWarning( $file ) {
+ if ( $file->exists() ) {
+ return [ 'warning' => 'exists', 'file' => $file ];
+ }
+
+ if ( $file->getTitle()->getArticleID() ) {
+ return [ 'warning' => 'page-exists', 'file' => $file ];
+ }
+
+ if ( strpos( $file->getName(), '.' ) == false ) {
+ $partname = $file->getName();
+ $extension = '';
+ } else {
+ $n = strrpos( $file->getName(), '.' );
+ $extension = substr( $file->getName(), $n + 1 );
+ $partname = substr( $file->getName(), 0, $n );
+ }
+ $normalizedExtension = File::normalizeExtension( $extension );
+
+ if ( $normalizedExtension != $extension ) {
+ // We're not using the normalized form of the extension.
+ // Normal form is lowercase, using most common of alternate
+ // extensions (eg 'jpg' rather than 'JPEG').
+
+ // Check for another file using the normalized form...
+ $nt_lc = Title::makeTitle( NS_FILE, "{$partname}.{$normalizedExtension}" );
+ $file_lc = wfLocalFile( $nt_lc );
+
+ if ( $file_lc->exists() ) {
+ return [
+ 'warning' => 'exists-normalized',
+ 'file' => $file,
+ 'normalizedFile' => $file_lc
+ ];
+ }
+ }
+
+ // Check for files with the same name but a different extension
+ $similarFiles = RepoGroup::singleton()->getLocalRepo()->findFilesByPrefix(
+ "{$partname}.", 1 );
+ if ( count( $similarFiles ) ) {
+ return [
+ 'warning' => 'exists-normalized',
+ 'file' => $file,
+ 'normalizedFile' => $similarFiles[0],
+ ];
+ }
+
+ if ( self::isThumbName( $file->getName() ) ) {
+ # Check for filenames like 50px- or 180px-, these are mostly thumbnails
+ $nt_thb = Title::newFromText(
+ substr( $partname, strpos( $partname, '-' ) + 1 ) . '.' . $extension,
+ NS_FILE
+ );
+ $file_thb = wfLocalFile( $nt_thb );
+ if ( $file_thb->exists() ) {
+ return [
+ 'warning' => 'thumb',
+ 'file' => $file,
+ 'thumbFile' => $file_thb
+ ];
+ } else {
+ // File does not exist, but we just don't like the name
+ return [
+ 'warning' => 'thumb-name',
+ 'file' => $file,
+ 'thumbFile' => $file_thb
+ ];
+ }
+ }
+
+ foreach ( self::getFilenamePrefixBlacklist() as $prefix ) {
+ if ( substr( $partname, 0, strlen( $prefix ) ) == $prefix ) {
+ return [
+ 'warning' => 'bad-prefix',
+ 'file' => $file,
+ 'prefix' => $prefix
+ ];
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Helper function that checks whether the filename looks like a thumbnail
+ * @param string $filename
+ * @return bool
+ */
+ public static function isThumbName( $filename ) {
+ $n = strrpos( $filename, '.' );
+ $partname = $n ? substr( $filename, 0, $n ) : $filename;
+
+ return (
+ substr( $partname, 3, 3 ) == 'px-' ||
+ substr( $partname, 2, 3 ) == 'px-'
+ ) &&
+ preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) );
+ }
+
+ /**
+ * Get a list of blacklisted filename prefixes from [[MediaWiki:Filename-prefix-blacklist]]
+ *
+ * @return array List of prefixes
+ */
+ public static function getFilenamePrefixBlacklist() {
+ $blacklist = [];
+ $message = wfMessage( 'filename-prefix-blacklist' )->inContentLanguage();
+ if ( !$message->isDisabled() ) {
+ $lines = explode( "\n", $message->plain() );
+ foreach ( $lines as $line ) {
+ // Remove comment lines
+ $comment = substr( trim( $line ), 0, 1 );
+ if ( $comment == '#' || $comment == '' ) {
+ continue;
+ }
+ // Remove additional comments after a prefix
+ $comment = strpos( $line, '#' );
+ if ( $comment > 0 ) {
+ $line = substr( $line, 0, $comment - 1 );
+ }
+ $blacklist[] = trim( $line );
+ }
+ }
+
+ return $blacklist;
+ }
+
+ /**
+ * Gets image info about the file just uploaded.
+ *
+ * Also has the effect of setting metadata to be an 'indexed tag name' in
+ * returned API result if 'metadata' was requested. Oddly, we have to pass
+ * the "result" object down just so it can do that with the appropriate
+ * format, presumably.
+ *
+ * @param ApiResult $result
+ * @return array Image info
+ */
+ public function getImageInfo( $result ) {
+ $localFile = $this->getLocalFile();
+ $stashFile = $this->getStashFile();
+ // Calling a different API module depending on whether the file was stashed is less than optimal.
+ // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
+ if ( $stashFile ) {
+ $imParam = ApiQueryStashImageInfo::getPropertyNames();
+ $info = ApiQueryStashImageInfo::getInfo( $stashFile, array_flip( $imParam ), $result );
+ } else {
+ $imParam = ApiQueryImageInfo::getPropertyNames();
+ $info = ApiQueryImageInfo::getInfo( $localFile, array_flip( $imParam ), $result );
+ }
+
+ return $info;
+ }
+
+ /**
+ * @param array $error
+ * @return Status
+ */
+ public function convertVerifyErrorToStatus( $error ) {
+ $code = $error['status'];
+ unset( $code['status'] );
+
+ return Status::newFatal( $this->getVerificationErrorCode( $code ), $error );
+ }
+
+ /**
+ * Get the MediaWiki maximum uploaded file size for given type of upload, based on
+ * $wgMaxUploadSize.
+ *
+ * @param null|string $forType
+ * @return int
+ */
+ public static function getMaxUploadSize( $forType = null ) {
+ global $wgMaxUploadSize;
+
+ if ( is_array( $wgMaxUploadSize ) ) {
+ if ( !is_null( $forType ) && isset( $wgMaxUploadSize[$forType] ) ) {
+ return $wgMaxUploadSize[$forType];
+ } else {
+ return $wgMaxUploadSize['*'];
+ }
+ } else {
+ return intval( $wgMaxUploadSize );
+ }
+ }
+
+ /**
+ * Get the PHP maximum uploaded file size, based on ini settings. If there is no limit or the
+ * limit can't be guessed, returns a very large number (PHP_INT_MAX).
+ *
+ * @since 1.27
+ * @return int
+ */
+ public static function getMaxPhpUploadSize() {
+ $phpMaxFileSize = wfShorthandToInteger(
+ ini_get( 'upload_max_filesize' ) ?: ini_get( 'hhvm.server.upload.upload_max_file_size' ),
+ PHP_INT_MAX
+ );
+ $phpMaxPostSize = wfShorthandToInteger(
+ ini_get( 'post_max_size' ) ?: ini_get( 'hhvm.server.max_post_size' ),
+ PHP_INT_MAX
+ ) ?: PHP_INT_MAX;
+ return min( $phpMaxFileSize, $phpMaxPostSize );
+ }
+
+ /**
+ * Get the current status of a chunked upload (used for polling)
+ *
+ * The value will be read from cache.
+ *
+ * @param User $user
+ * @param string $statusKey
+ * @return Status[]|bool
+ */
+ public static function getSessionStatus( User $user, $statusKey ) {
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+ $key = $cache->makeKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey );
+
+ return $cache->get( $key );
+ }
+
+ /**
+ * Set the current status of a chunked upload (used for polling)
+ *
+ * The value will be set in cache for 1 day
+ *
+ * @param User $user
+ * @param string $statusKey
+ * @param array|bool $value
+ * @return void
+ */
+ public static function setSessionStatus( User $user, $statusKey, $value ) {
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+ $key = $cache->makeKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey );
+
+ if ( $value === false ) {
+ $cache->delete( $key );
+ } else {
+ $cache->set( $key, $value, $cache::TTL_DAY );
+ }
+ }
+}
diff --git a/www/wiki/includes/upload/UploadFromChunks.php b/www/wiki/includes/upload/UploadFromChunks.php
new file mode 100644
index 00000000..68bcb9d9
--- /dev/null
+++ b/www/wiki/includes/upload/UploadFromChunks.php
@@ -0,0 +1,430 @@
+<?php
+/**
+ * Backend for uploading files from chunks.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Upload
+ */
+
+/**
+ * Implements uploading from chunks
+ *
+ * @ingroup Upload
+ * @author Michael Dale
+ */
+class UploadFromChunks extends UploadFromFile {
+ protected $mOffset;
+ protected $mChunkIndex;
+ protected $mFileKey;
+ protected $mVirtualTempPath;
+ /** @var LocalRepo */
+ private $repo;
+
+ /**
+ * Setup local pointers to stash, repo and user (similar to UploadFromStash)
+ *
+ * @param User $user
+ * @param UploadStash|bool $stash Default: false
+ * @param FileRepo|bool $repo Default: false
+ */
+ public function __construct( User $user, $stash = false, $repo = false ) {
+ $this->user = $user;
+
+ if ( $repo ) {
+ $this->repo = $repo;
+ } else {
+ $this->repo = RepoGroup::singleton()->getLocalRepo();
+ }
+
+ if ( $stash ) {
+ $this->stash = $stash;
+ } else {
+ if ( $user ) {
+ wfDebug( __METHOD__ . " creating new UploadFromChunks instance for " . $user->getId() . "\n" );
+ } else {
+ wfDebug( __METHOD__ . " creating new UploadFromChunks instance with no user\n" );
+ }
+ $this->stash = new UploadStash( $this->repo, $this->user );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function tryStashFile( User $user, $isPartial = false ) {
+ try {
+ $this->verifyChunk();
+ } catch ( UploadChunkVerificationException $e ) {
+ return Status::newFatal( $e->msg );
+ }
+
+ return parent::tryStashFile( $user, $isPartial );
+ }
+
+ /**
+ * @inheritDoc
+ * @throws UploadChunkVerificationException
+ * @deprecated since 1.28 Use tryStashFile() instead
+ */
+ public function stashFile( User $user = null ) {
+ wfDeprecated( __METHOD__, '1.28' );
+ $this->verifyChunk();
+ return parent::stashFile( $user );
+ }
+
+ /**
+ * @inheritDoc
+ * @throws UploadChunkVerificationException
+ * @deprecated since 1.28
+ */
+ public function stashFileGetKey() {
+ wfDeprecated( __METHOD__, '1.28' );
+ $this->verifyChunk();
+ return parent::stashFileGetKey();
+ }
+
+ /**
+ * @inheritDoc
+ * @throws UploadChunkVerificationException
+ * @deprecated since 1.28
+ */
+ public function stashSession() {
+ wfDeprecated( __METHOD__, '1.28' );
+ $this->verifyChunk();
+ return parent::stashSession();
+ }
+
+ /**
+ * Calls the parent doStashFile and updates the uploadsession table to handle "chunks"
+ *
+ * @param User|null $user
+ * @return UploadStashFile Stashed file
+ */
+ protected function doStashFile( User $user = null ) {
+ // Stash file is the called on creating a new chunk session:
+ $this->mChunkIndex = 0;
+ $this->mOffset = 0;
+
+ // Create a local stash target
+ $this->mStashFile = parent::doStashFile( $user );
+ // Update the initial file offset (based on file size)
+ $this->mOffset = $this->mStashFile->getSize();
+ $this->mFileKey = $this->mStashFile->getFileKey();
+
+ // Output a copy of this first to chunk 0 location:
+ $this->outputChunk( $this->mStashFile->getPath() );
+
+ // Update db table to reflect initial "chunk" state
+ $this->updateChunkStatus();
+
+ return $this->mStashFile;
+ }
+
+ /**
+ * Continue chunk uploading
+ *
+ * @param string $name
+ * @param string $key
+ * @param WebRequestUpload $webRequestUpload
+ */
+ public function continueChunks( $name, $key, $webRequestUpload ) {
+ $this->mFileKey = $key;
+ $this->mUpload = $webRequestUpload;
+ // Get the chunk status form the db:
+ $this->getChunkStatus();
+
+ $metadata = $this->stash->getMetadata( $key );
+ $this->initializePathInfo( $name,
+ $this->getRealPath( $metadata['us_path'] ),
+ $metadata['us_size'],
+ false
+ );
+ }
+
+ /**
+ * Append the final chunk and ready file for parent::performUpload()
+ * @return Status
+ */
+ public function concatenateChunks() {
+ $chunkIndex = $this->getChunkIndex();
+ wfDebug( __METHOD__ . " concatenate {$this->mChunkIndex} chunks:" .
+ $this->getOffset() . ' inx:' . $chunkIndex . "\n" );
+
+ // Concatenate all the chunks to mVirtualTempPath
+ $fileList = [];
+ // The first chunk is stored at the mVirtualTempPath path so we start on "chunk 1"
+ for ( $i = 0; $i <= $chunkIndex; $i++ ) {
+ $fileList[] = $this->getVirtualChunkLocation( $i );
+ }
+
+ // Get the file extension from the last chunk
+ $ext = FileBackend::extensionFromPath( $this->mVirtualTempPath );
+ // Get a 0-byte temp file to perform the concatenation at
+ $tmpFile = TempFSFile::factory( 'chunkedupload_', $ext, wfTempDir() );
+ $tmpPath = false; // fail in concatenate()
+ if ( $tmpFile ) {
+ // keep alive with $this
+ $tmpPath = $tmpFile->bind( $this )->getPath();
+ }
+
+ // Concatenate the chunks at the temp file
+ $tStart = microtime( true );
+ $status = $this->repo->concatenate( $fileList, $tmpPath, FileRepo::DELETE_SOURCE );
+ $tAmount = microtime( true ) - $tStart;
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ wfDebugLog( 'fileconcatenate', "Combined $i chunks in $tAmount seconds." );
+
+ // File system path of the actual full temp file
+ $this->setTempFile( $tmpPath );
+
+ $ret = $this->verifyUpload();
+ if ( $ret['status'] !== UploadBase::OK ) {
+ wfDebugLog( 'fileconcatenate', "Verification failed for chunked upload" );
+ $status->fatal( $this->getVerificationErrorCode( $ret['status'] ) );
+
+ return $status;
+ }
+
+ // Update the mTempPath and mStashFile
+ // (for FileUpload or normal Stash to take over)
+ $tStart = microtime( true );
+ // This is a re-implementation of UploadBase::tryStashFile(), we can't call it because we
+ // override doStashFile() with completely different functionality in this class...
+ $error = $this->runUploadStashFileHook( $this->user );
+ if ( $error ) {
+ call_user_func_array( [ $status, 'fatal' ], $error );
+ return $status;
+ }
+ try {
+ $this->mStashFile = parent::doStashFile( $this->user );
+ } catch ( UploadStashException $e ) {
+ $status->fatal( 'uploadstash-exception', get_class( $e ), $e->getMessage() );
+ return $status;
+ }
+
+ $tAmount = microtime( true ) - $tStart;
+ $this->mStashFile->setLocalReference( $tmpFile ); // reuse (e.g. for getImageInfo())
+ wfDebugLog( 'fileconcatenate', "Stashed combined file ($i chunks) in $tAmount seconds." );
+
+ return $status;
+ }
+
+ /**
+ * Returns the virtual chunk location:
+ * @param int $index
+ * @return string
+ */
+ function getVirtualChunkLocation( $index ) {
+ return $this->repo->getVirtualUrl( 'temp' ) .
+ '/' .
+ $this->repo->getHashPath(
+ $this->getChunkFileKey( $index )
+ ) .
+ $this->getChunkFileKey( $index );
+ }
+
+ /**
+ * Add a chunk to the temporary directory
+ *
+ * @param string $chunkPath Path to temporary chunk file
+ * @param int $chunkSize Size of the current chunk
+ * @param int $offset Offset of current chunk ( mutch match database chunk offset )
+ * @return Status
+ */
+ public function addChunk( $chunkPath, $chunkSize, $offset ) {
+ // Get the offset before we add the chunk to the file system
+ $preAppendOffset = $this->getOffset();
+
+ if ( $preAppendOffset + $chunkSize > $this->getMaxUploadSize() ) {
+ $status = Status::newFatal( 'file-too-large' );
+ } else {
+ // Make sure the client is uploading the correct chunk with a matching offset.
+ if ( $preAppendOffset == $offset ) {
+ // Update local chunk index for the current chunk
+ $this->mChunkIndex++;
+ try {
+ # For some reason mTempPath is set to first part
+ $oldTemp = $this->mTempPath;
+ $this->mTempPath = $chunkPath;
+ $this->verifyChunk();
+ $this->mTempPath = $oldTemp;
+ } catch ( UploadChunkVerificationException $e ) {
+ return Status::newFatal( $e->msg );
+ }
+ $status = $this->outputChunk( $chunkPath );
+ if ( $status->isGood() ) {
+ // Update local offset:
+ $this->mOffset = $preAppendOffset + $chunkSize;
+ // Update chunk table status db
+ $this->updateChunkStatus();
+ }
+ } else {
+ $status = Status::newFatal( 'invalid-chunk-offset' );
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Update the chunk db table with the current status:
+ */
+ private function updateChunkStatus() {
+ wfDebug( __METHOD__ . " update chunk status for {$this->mFileKey} offset:" .
+ $this->getOffset() . ' inx:' . $this->getChunkIndex() . "\n" );
+
+ $dbw = $this->repo->getMasterDB();
+ $dbw->update(
+ 'uploadstash',
+ [
+ 'us_status' => 'chunks',
+ 'us_chunk_inx' => $this->getChunkIndex(),
+ 'us_size' => $this->getOffset()
+ ],
+ [ 'us_key' => $this->mFileKey ],
+ __METHOD__
+ );
+ }
+
+ /**
+ * Get the chunk db state and populate update relevant local values
+ */
+ private function getChunkStatus() {
+ // get Master db to avoid race conditions.
+ // Otherwise, if chunk upload time < replag there will be spurious errors
+ $dbw = $this->repo->getMasterDB();
+ $row = $dbw->selectRow(
+ 'uploadstash',
+ [
+ 'us_chunk_inx',
+ 'us_size',
+ 'us_path',
+ ],
+ [ 'us_key' => $this->mFileKey ],
+ __METHOD__
+ );
+ // Handle result:
+ if ( $row ) {
+ $this->mChunkIndex = $row->us_chunk_inx;
+ $this->mOffset = $row->us_size;
+ $this->mVirtualTempPath = $row->us_path;
+ }
+ }
+
+ /**
+ * Get the current Chunk index
+ * @return int Index of the current chunk
+ */
+ private function getChunkIndex() {
+ if ( $this->mChunkIndex !== null ) {
+ return $this->mChunkIndex;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Get the offset at which the next uploaded chunk will be appended to
+ * @return int Current byte offset of the chunk file set
+ */
+ public function getOffset() {
+ if ( $this->mOffset !== null ) {
+ return $this->mOffset;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Output the chunk to disk
+ *
+ * @param string $chunkPath
+ * @throws UploadChunkFileException
+ * @return Status
+ */
+ private function outputChunk( $chunkPath ) {
+ // Key is fileKey + chunk index
+ $fileKey = $this->getChunkFileKey();
+
+ // Store the chunk per its indexed fileKey:
+ $hashPath = $this->repo->getHashPath( $fileKey );
+ $storeStatus = $this->repo->quickImport( $chunkPath,
+ $this->repo->getZonePath( 'temp' ) . "/{$hashPath}{$fileKey}" );
+
+ // Check for error in stashing the chunk:
+ if ( !$storeStatus->isOK() ) {
+ $error = $storeStatus->getErrorsArray();
+ $error = reset( $error );
+ if ( !count( $error ) ) {
+ $error = $storeStatus->getWarningsArray();
+ $error = reset( $error );
+ if ( !count( $error ) ) {
+ $error = [ 'unknown', 'no error recorded' ];
+ }
+ }
+ throw new UploadChunkFileException( "Error storing file in '$chunkPath': " .
+ implode( '; ', $error ) );
+ }
+
+ return $storeStatus;
+ }
+
+ private function getChunkFileKey( $index = null ) {
+ if ( $index === null ) {
+ $index = $this->getChunkIndex();
+ }
+
+ return $this->mFileKey . '.' . $index;
+ }
+
+ /**
+ * Verify that the chunk isn't really an evil html file
+ *
+ * @throws UploadChunkVerificationException
+ */
+ private function verifyChunk() {
+ // Rest mDesiredDestName here so we verify the name as if it were mFileKey
+ $oldDesiredDestName = $this->mDesiredDestName;
+ $this->mDesiredDestName = $this->mFileKey;
+ $this->mTitle = false;
+ $res = $this->verifyPartialFile();
+ $this->mDesiredDestName = $oldDesiredDestName;
+ $this->mTitle = false;
+ if ( is_array( $res ) ) {
+ throw new UploadChunkVerificationException( $res );
+ }
+ }
+}
+
+class UploadChunkZeroLengthFileException extends MWException {
+}
+
+class UploadChunkFileException extends MWException {
+}
+
+class UploadChunkVerificationException extends MWException {
+ public $msg;
+ public function __construct( $res ) {
+ $this->msg = call_user_func_array( 'wfMessage', $res );
+ parent::__construct( call_user_func_array( 'wfMessage', $res )
+ ->inLanguage( 'en' )->useDatabase( false )->text() );
+ }
+}
diff --git a/www/wiki/includes/upload/UploadFromFile.php b/www/wiki/includes/upload/UploadFromFile.php
new file mode 100644
index 00000000..675bb8d4
--- /dev/null
+++ b/www/wiki/includes/upload/UploadFromFile.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * Backend for regular file upload.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Upload
+ */
+
+/**
+ * Implements regular file uploads
+ *
+ * @ingroup Upload
+ * @author Bryan Tong Minh
+ */
+class UploadFromFile extends UploadBase {
+ /**
+ * @var WebRequestUpload
+ */
+ protected $mUpload = null;
+
+ /**
+ * @param WebRequest &$request
+ */
+ function initializeFromRequest( &$request ) {
+ $upload = $request->getUpload( 'wpUploadFile' );
+ $desiredDestName = $request->getText( 'wpDestFile' );
+ if ( !$desiredDestName ) {
+ $desiredDestName = $upload->getName();
+ }
+
+ $this->initialize( $desiredDestName, $upload );
+ }
+
+ /**
+ * Initialize from a filename and a WebRequestUpload
+ * @param string $name
+ * @param WebRequestUpload $webRequestUpload
+ */
+ function initialize( $name, $webRequestUpload ) {
+ $this->mUpload = $webRequestUpload;
+ $this->initializePathInfo( $name,
+ $this->mUpload->getTempName(), $this->mUpload->getSize() );
+ }
+
+ /**
+ * @param WebRequest $request
+ * @return bool
+ */
+ static function isValidRequest( $request ) {
+ # Allow all requests, even if no file is present, so that an error
+ # because a post_max_size or upload_max_filesize overflow
+ return true;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSourceType() {
+ return 'file';
+ }
+
+ /**
+ * @return array
+ */
+ public function verifyUpload() {
+ # Check for a post_max_size or upload_max_size overflow, so that a
+ # proper error can be shown to the user
+ if ( is_null( $this->mTempPath ) || $this->isEmptyFile() ) {
+ if ( $this->mUpload->isIniSizeOverflow() ) {
+ return [
+ 'status' => UploadBase::FILE_TOO_LARGE,
+ 'max' => min(
+ self::getMaxUploadSize( $this->getSourceType() ),
+ self::getMaxPhpUploadSize()
+ ),
+ ];
+ }
+ }
+
+ return parent::verifyUpload();
+ }
+}
diff --git a/www/wiki/includes/upload/UploadFromStash.php b/www/wiki/includes/upload/UploadFromStash.php
new file mode 100644
index 00000000..a9f399b7
--- /dev/null
+++ b/www/wiki/includes/upload/UploadFromStash.php
@@ -0,0 +1,161 @@
+<?php
+/**
+ * Backend for uploading files from previously stored file.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Upload
+ */
+
+/**
+ * Implements uploading from previously stored file.
+ *
+ * @ingroup Upload
+ * @author Bryan Tong Minh
+ */
+class UploadFromStash extends UploadBase {
+ protected $mFileKey;
+ protected $mVirtualTempPath;
+ protected $mFileProps;
+ protected $mSourceType;
+
+ // an instance of UploadStash
+ private $stash;
+
+ // LocalFile repo
+ private $repo;
+
+ /**
+ * @param User|bool $user Default: false
+ * @param UploadStash|bool $stash Default: false
+ * @param FileRepo|bool $repo Default: false
+ */
+ public function __construct( $user = false, $stash = false, $repo = false ) {
+ // user object. sometimes this won't exist, as when running from cron.
+ $this->user = $user;
+
+ if ( $repo ) {
+ $this->repo = $repo;
+ } else {
+ $this->repo = RepoGroup::singleton()->getLocalRepo();
+ }
+
+ if ( $stash ) {
+ $this->stash = $stash;
+ } else {
+ if ( $user ) {
+ wfDebug( __METHOD__ . " creating new UploadStash instance for " . $user->getId() . "\n" );
+ } else {
+ wfDebug( __METHOD__ . " creating new UploadStash instance with no user\n" );
+ }
+
+ $this->stash = new UploadStash( $this->repo, $this->user );
+ }
+ }
+
+ /**
+ * @param string $key
+ * @return bool
+ */
+ public static function isValidKey( $key ) {
+ // this is checked in more detail in UploadStash
+ return (bool)preg_match( UploadStash::KEY_FORMAT_REGEX, $key );
+ }
+
+ /**
+ * @param WebRequest $request
+ * @return bool
+ */
+ public static function isValidRequest( $request ) {
+ // this passes wpSessionKey to getText() as a default when wpFileKey isn't set.
+ // wpSessionKey has no default which guarantees failure if both are missing
+ // (though that should have been caught earlier)
+ return self::isValidKey( $request->getText( 'wpFileKey', $request->getText( 'wpSessionKey' ) ) );
+ }
+
+ /**
+ * @param string $key
+ * @param string $name
+ * @param bool $initTempFile
+ */
+ public function initialize( $key, $name = 'upload_file', $initTempFile = true ) {
+ /**
+ * Confirming a temporarily stashed upload.
+ * We don't want path names to be forged, so we keep
+ * them in the session on the server and just give
+ * an opaque key to the user agent.
+ */
+ $metadata = $this->stash->getMetadata( $key );
+ $this->initializePathInfo( $name,
+ $initTempFile ? $this->getRealPath( $metadata['us_path'] ) : false,
+ $metadata['us_size'],
+ false
+ );
+
+ $this->mFileKey = $key;
+ $this->mVirtualTempPath = $metadata['us_path'];
+ $this->mFileProps = $this->stash->getFileProps( $key );
+ $this->mSourceType = $metadata['us_source_type'];
+ }
+
+ /**
+ * @param WebRequest &$request
+ */
+ public function initializeFromRequest( &$request ) {
+ // sends wpSessionKey as a default when wpFileKey is missing
+ $fileKey = $request->getText( 'wpFileKey', $request->getText( 'wpSessionKey' ) );
+
+ // chooses one of wpDestFile, wpUploadFile, filename in that order.
+ $desiredDestName = $request->getText(
+ 'wpDestFile',
+ $request->getText( 'wpUploadFile', $request->getText( 'filename' ) )
+ );
+
+ $this->initialize( $fileKey, $desiredDestName );
+ }
+
+ /**
+ * @return string
+ */
+ public function getSourceType() {
+ return $this->mSourceType;
+ }
+
+ /**
+ * Get the base 36 SHA1 of the file
+ * @return string
+ */
+ public function getTempFileSha1Base36() {
+ return $this->mFileProps['sha1'];
+ }
+
+ /**
+ * Remove a temporarily kept file stashed by saveTempUploadedFile().
+ * @return bool Success
+ */
+ public function unsaveUploadedFile() {
+ return $this->stash->removeFile( $this->mFileKey );
+ }
+
+ /**
+ * Remove the database record after a successful upload.
+ */
+ public function postProcessUpload() {
+ parent::postProcessUpload();
+ $this->unsaveUploadedFile();
+ }
+}
diff --git a/www/wiki/includes/upload/UploadFromUrl.php b/www/wiki/includes/upload/UploadFromUrl.php
new file mode 100644
index 00000000..f5367bb6
--- /dev/null
+++ b/www/wiki/includes/upload/UploadFromUrl.php
@@ -0,0 +1,300 @@
+<?php
+/**
+ * Backend for uploading files from a HTTP resource.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Upload
+ */
+
+/**
+ * Implements uploading from a HTTP resource.
+ *
+ * @ingroup Upload
+ * @author Bryan Tong Minh
+ * @author Michael Dale
+ */
+class UploadFromUrl extends UploadBase {
+ protected $mUrl;
+
+ protected $mTempPath, $mTmpHandle;
+
+ protected static $allowedUrls = [];
+
+ /**
+ * Checks if the user is allowed to use the upload-by-URL feature. If the
+ * user is not allowed, return the name of the user right as a string. If
+ * the user is allowed, have the parent do further permissions checking.
+ *
+ * @param User $user
+ *
+ * @return bool|string
+ */
+ public static function isAllowed( $user ) {
+ if ( !$user->isAllowed( 'upload_by_url' ) ) {
+ return 'upload_by_url';
+ }
+
+ return parent::isAllowed( $user );
+ }
+
+ /**
+ * Checks if the upload from URL feature is enabled
+ * @return bool
+ */
+ public static function isEnabled() {
+ global $wgAllowCopyUploads;
+
+ return $wgAllowCopyUploads && parent::isEnabled();
+ }
+
+ /**
+ * Checks whether the URL is for an allowed host
+ * The domains in the whitelist can include wildcard characters (*) in place
+ * of any of the domain levels, e.g. '*.flickr.com' or 'upload.*.gov.uk'.
+ *
+ * @param string $url
+ * @return bool
+ */
+ public static function isAllowedHost( $url ) {
+ global $wgCopyUploadsDomains;
+ if ( !count( $wgCopyUploadsDomains ) ) {
+ return true;
+ }
+ $parsedUrl = wfParseUrl( $url );
+ if ( !$parsedUrl ) {
+ return false;
+ }
+ $valid = false;
+ foreach ( $wgCopyUploadsDomains as $domain ) {
+ // See if the domain for the upload matches this whitelisted domain
+ $whitelistedDomainPieces = explode( '.', $domain );
+ $uploadDomainPieces = explode( '.', $parsedUrl['host'] );
+ if ( count( $whitelistedDomainPieces ) === count( $uploadDomainPieces ) ) {
+ $valid = true;
+ // See if all the pieces match or not (excluding wildcards)
+ foreach ( $whitelistedDomainPieces as $index => $piece ) {
+ if ( $piece !== '*' && $piece !== $uploadDomainPieces[$index] ) {
+ $valid = false;
+ }
+ }
+ if ( $valid ) {
+ // We found a match, so quit comparing against the list
+ break;
+ }
+ }
+ /* Non-wildcard test
+ if ( $parsedUrl['host'] === $domain ) {
+ $valid = true;
+ break;
+ }
+ */
+ }
+
+ return $valid;
+ }
+
+ /**
+ * Checks whether the URL is not allowed.
+ *
+ * @param string $url
+ * @return bool
+ */
+ public static function isAllowedUrl( $url ) {
+ if ( !isset( self::$allowedUrls[$url] ) ) {
+ $allowed = true;
+ Hooks::run( 'IsUploadAllowedFromUrl', [ $url, &$allowed ] );
+ self::$allowedUrls[$url] = $allowed;
+ }
+
+ return self::$allowedUrls[$url];
+ }
+
+ /**
+ * Entry point for API upload
+ *
+ * @param string $name
+ * @param string $url
+ * @throws MWException
+ */
+ public function initialize( $name, $url ) {
+ $this->mUrl = $url;
+
+ $tempPath = $this->makeTemporaryFile();
+ # File size and removeTempFile will be filled in later
+ $this->initializePathInfo( $name, $tempPath, 0, false );
+ }
+
+ /**
+ * Entry point for SpecialUpload
+ * @param WebRequest &$request
+ */
+ public function initializeFromRequest( &$request ) {
+ $desiredDestName = $request->getText( 'wpDestFile' );
+ if ( !$desiredDestName ) {
+ $desiredDestName = $request->getText( 'wpUploadFileURL' );
+ }
+ $this->initialize(
+ $desiredDestName,
+ trim( $request->getVal( 'wpUploadFileURL' ) ),
+ false
+ );
+ }
+
+ /**
+ * @param WebRequest $request
+ * @return bool
+ */
+ public static function isValidRequest( $request ) {
+ global $wgUser;
+
+ $url = $request->getVal( 'wpUploadFileURL' );
+
+ return !empty( $url )
+ && $wgUser->isAllowed( 'upload_by_url' );
+ }
+
+ /**
+ * @return string
+ */
+ public function getSourceType() {
+ return 'url';
+ }
+
+ /**
+ * Download the file
+ *
+ * @param array $httpOptions Array of options for MWHttpRequest.
+ * This could be used to override the timeout on the http request.
+ * @return Status
+ */
+ public function fetchFile( $httpOptions = [] ) {
+ if ( !Http::isValidURI( $this->mUrl ) ) {
+ return Status::newFatal( 'http-invalid-url', $this->mUrl );
+ }
+
+ if ( !self::isAllowedHost( $this->mUrl ) ) {
+ return Status::newFatal( 'upload-copy-upload-invalid-domain' );
+ }
+ if ( !self::isAllowedUrl( $this->mUrl ) ) {
+ return Status::newFatal( 'upload-copy-upload-invalid-url' );
+ }
+ return $this->reallyFetchFile( $httpOptions );
+ }
+
+ /**
+ * Create a new temporary file in the URL subdirectory of wfTempDir().
+ *
+ * @return string Path to the file
+ */
+ protected function makeTemporaryFile() {
+ $tmpFile = TempFSFile::factory( 'URL', 'urlupload_', wfTempDir() );
+ $tmpFile->bind( $this );
+
+ return $tmpFile->getPath();
+ }
+
+ /**
+ * Callback: save a chunk of the result of a HTTP request to the temporary file
+ *
+ * @param mixed $req
+ * @param string $buffer
+ * @return int Number of bytes handled
+ */
+ public function saveTempFileChunk( $req, $buffer ) {
+ wfDebugLog( 'fileupload', 'Received chunk of ' . strlen( $buffer ) . ' bytes' );
+ $nbytes = fwrite( $this->mTmpHandle, $buffer );
+
+ if ( $nbytes == strlen( $buffer ) ) {
+ $this->mFileSize += $nbytes;
+ } else {
+ // Well... that's not good!
+ wfDebugLog(
+ 'fileupload',
+ 'Short write ' . $nbytes . '/' . strlen( $buffer ) .
+ ' bytes, aborting with ' . $this->mFileSize . ' uploaded so far'
+ );
+ fclose( $this->mTmpHandle );
+ $this->mTmpHandle = false;
+ }
+
+ return $nbytes;
+ }
+
+ /**
+ * Download the file, save it to the temporary file and update the file
+ * size and set $mRemoveTempFile to true.
+ *
+ * @param array $httpOptions Array of options for MWHttpRequest
+ * @return Status
+ */
+ protected function reallyFetchFile( $httpOptions = [] ) {
+ global $wgCopyUploadProxy, $wgCopyUploadTimeout;
+ if ( $this->mTempPath === false ) {
+ return Status::newFatal( 'tmp-create-error' );
+ }
+
+ // Note the temporary file should already be created by makeTemporaryFile()
+ $this->mTmpHandle = fopen( $this->mTempPath, 'wb' );
+ if ( !$this->mTmpHandle ) {
+ return Status::newFatal( 'tmp-create-error' );
+ }
+ wfDebugLog( 'fileupload', 'Temporary file created "' . $this->mTempPath . '"' );
+
+ $this->mRemoveTempFile = true;
+ $this->mFileSize = 0;
+
+ $options = $httpOptions + [ 'followRedirects' => true ];
+
+ if ( $wgCopyUploadProxy !== false ) {
+ $options['proxy'] = $wgCopyUploadProxy;
+ }
+
+ if ( $wgCopyUploadTimeout && !isset( $options['timeout'] ) ) {
+ $options['timeout'] = $wgCopyUploadTimeout;
+ }
+ wfDebugLog(
+ 'fileupload',
+ 'Starting download from "' . $this->mUrl . '" ' .
+ '<' . implode( ',', array_keys( array_filter( $options ) ) ) . '>'
+ );
+ $req = MWHttpRequest::factory( $this->mUrl, $options, __METHOD__ );
+ $req->setCallback( [ $this, 'saveTempFileChunk' ] );
+ $status = $req->execute();
+
+ if ( $this->mTmpHandle ) {
+ // File got written ok...
+ fclose( $this->mTmpHandle );
+ $this->mTmpHandle = null;
+ } else {
+ // We encountered a write error during the download...
+ return Status::newFatal( 'tmp-write-error' );
+ }
+
+ wfDebugLog( 'fileupload', $status );
+ if ( $status->isOK() ) {
+ wfDebugLog( 'fileupload', 'Download by URL completed successfully.' );
+ } else {
+ wfDebugLog(
+ 'fileupload',
+ 'Download by URL completed with HTTP status ' . $req->getStatus()
+ );
+ }
+
+ return $status;
+ }
+}
diff --git a/www/wiki/includes/upload/UploadStash.php b/www/wiki/includes/upload/UploadStash.php
new file mode 100644
index 00000000..755f9fdf
--- /dev/null
+++ b/www/wiki/includes/upload/UploadStash.php
@@ -0,0 +1,759 @@
+<?php
+/**
+ * Temporary storage for uploaded files.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Upload
+ */
+
+/**
+ * UploadStash is intended to accomplish a few things:
+ * - Enable applications to temporarily stash files without publishing them to
+ * the wiki.
+ * - Several parts of MediaWiki do this in similar ways: UploadBase,
+ * UploadWizard, and FirefoggChunkedExtension.
+ * And there are several that reimplement stashing from scratch, in
+ * idiosyncratic ways. The idea is to unify them all here.
+ * Mostly all of them are the same except for storing some custom fields,
+ * which we subsume into the data array.
+ * - Enable applications to find said files later, as long as the db table or
+ * temp files haven't been purged.
+ * - Enable the uploading user (and *ONLY* the uploading user) to access said
+ * files, and thumbnails of said files, via a URL. We accomplish this using
+ * a database table, with ownership checking as you might expect. See
+ * SpecialUploadStash, which implements a web interface to some files stored
+ * this way.
+ *
+ * UploadStash right now is *mostly* intended to show you one user's slice of
+ * the entire stash. The user parameter is only optional because there are few
+ * cases where we clean out the stash from an automated script. In the future we
+ * might refactor this.
+ *
+ * UploadStash represents the entire stash of temporary files.
+ * UploadStashFile is a filestore for the actual physical disk files.
+ * UploadFromStash extends UploadBase, and represents a single stashed file as
+ * it is moved from the stash to the regular file repository
+ *
+ * @ingroup Upload
+ */
+class UploadStash {
+ // Format of the key for files -- has to be suitable as a filename itself (e.g. ab12cd34ef.jpg)
+ const KEY_FORMAT_REGEX = '/^[\w-\.]+\.\w*$/';
+ const MAX_US_PROPS_SIZE = 65535;
+
+ /**
+ * repository that this uses to store temp files
+ * public because we sometimes need to get a LocalFile within the same repo.
+ *
+ * @var LocalRepo
+ */
+ public $repo;
+
+ // array of initialized repo objects
+ protected $files = [];
+
+ // cache of the file metadata that's stored in the database
+ protected $fileMetadata = [];
+
+ // fileprops cache
+ protected $fileProps = [];
+
+ // current user
+ protected $user, $userId, $isLoggedIn;
+
+ /**
+ * Represents a temporary filestore, with metadata in the database.
+ * Designed to be compatible with the session stashing code in UploadBase
+ * (should replace it eventually).
+ *
+ * @param FileRepo $repo
+ * @param User $user (default null)
+ */
+ public function __construct( FileRepo $repo, $user = null ) {
+ // this might change based on wiki's configuration.
+ $this->repo = $repo;
+
+ // if a user was passed, use it. otherwise, attempt to use the global.
+ // this keeps FileRepo from breaking when it creates an UploadStash object
+ if ( $user ) {
+ $this->user = $user;
+ } else {
+ global $wgUser;
+ $this->user = $wgUser;
+ }
+
+ if ( is_object( $this->user ) ) {
+ $this->userId = $this->user->getId();
+ $this->isLoggedIn = $this->user->isLoggedIn();
+ }
+ }
+
+ /**
+ * Get a file and its metadata from the stash.
+ * The noAuth param is a bit janky but is required for automated scripts
+ * which clean out the stash.
+ *
+ * @param string $key Key under which file information is stored
+ * @param bool $noAuth (optional) Don't check authentication. Used by maintenance scripts.
+ * @throws UploadStashFileNotFoundException
+ * @throws UploadStashNotLoggedInException
+ * @throws UploadStashWrongOwnerException
+ * @throws UploadStashBadPathException
+ * @return UploadStashFile
+ */
+ public function getFile( $key, $noAuth = false ) {
+ if ( !preg_match( self::KEY_FORMAT_REGEX, $key ) ) {
+ throw new UploadStashBadPathException( "key '$key' is not in a proper format" );
+ }
+
+ if ( !$noAuth && !$this->isLoggedIn ) {
+ throw new UploadStashNotLoggedInException( __METHOD__ .
+ ' No user is logged in, files must belong to users' );
+ }
+
+ if ( !isset( $this->fileMetadata[$key] ) ) {
+ if ( !$this->fetchFileMetadata( $key ) ) {
+ // If nothing was received, it's likely due to replication lag.
+ // Check the master to see if the record is there.
+ $this->fetchFileMetadata( $key, DB_MASTER );
+ }
+
+ if ( !isset( $this->fileMetadata[$key] ) ) {
+ throw new UploadStashFileNotFoundException( "key '$key' not found in stash" );
+ }
+
+ // create $this->files[$key]
+ $this->initFile( $key );
+
+ // fetch fileprops
+ if ( strlen( $this->fileMetadata[$key]['us_props'] ) ) {
+ $this->fileProps[$key] = unserialize( $this->fileMetadata[$key]['us_props'] );
+ } else { // b/c for rows with no us_props
+ wfDebug( __METHOD__ . " fetched props for $key from file\n" );
+ $path = $this->fileMetadata[$key]['us_path'];
+ $this->fileProps[$key] = $this->repo->getFileProps( $path );
+ }
+ }
+
+ if ( !$this->files[$key]->exists() ) {
+ wfDebug( __METHOD__ . " tried to get file at $key, but it doesn't exist\n" );
+ // @todo Is this not an UploadStashFileNotFoundException case?
+ throw new UploadStashBadPathException( "path doesn't exist" );
+ }
+
+ if ( !$noAuth ) {
+ if ( $this->fileMetadata[$key]['us_user'] != $this->userId ) {
+ throw new UploadStashWrongOwnerException( "This file ($key) doesn't "
+ . "belong to the current user." );
+ }
+ }
+
+ return $this->files[$key];
+ }
+
+ /**
+ * Getter for file metadata.
+ *
+ * @param string $key Key under which file information is stored
+ * @return array
+ */
+ public function getMetadata( $key ) {
+ $this->getFile( $key );
+
+ return $this->fileMetadata[$key];
+ }
+
+ /**
+ * Getter for fileProps
+ *
+ * @param string $key Key under which file information is stored
+ * @return array
+ */
+ public function getFileProps( $key ) {
+ $this->getFile( $key );
+
+ return $this->fileProps[$key];
+ }
+
+ /**
+ * Stash a file in a temp directory and record that we did this in the
+ * database, along with other metadata.
+ *
+ * @param string $path Path to file you want stashed
+ * @param string $sourceType The type of upload that generated this file
+ * (currently, I believe, 'file' or null)
+ * @throws UploadStashBadPathException
+ * @throws UploadStashFileException
+ * @throws UploadStashNotLoggedInException
+ * @return UploadStashFile|null File, or null on failure
+ */
+ public function stashFile( $path, $sourceType = null ) {
+ if ( !is_file( $path ) ) {
+ wfDebug( __METHOD__ . " tried to stash file at '$path', but it doesn't exist\n" );
+ throw new UploadStashBadPathException( "path doesn't exist" );
+ }
+
+ $mwProps = new MWFileProps( MimeMagic::singleton() );
+ $fileProps = $mwProps->getPropsFromPath( $path, true );
+ wfDebug( __METHOD__ . " stashing file at '$path'\n" );
+
+ // we will be initializing from some tmpnam files that don't have extensions.
+ // most of MediaWiki assumes all uploaded files have good extensions. So, we fix this.
+ $extension = self::getExtensionForPath( $path );
+ if ( !preg_match( "/\\.\\Q$extension\\E$/", $path ) ) {
+ $pathWithGoodExtension = "$path.$extension";
+ } else {
+ $pathWithGoodExtension = $path;
+ }
+
+ // If no key was supplied, make one. a mysql insertid would be totally
+ // reasonable here, except that for historical reasons, the key is this
+ // random thing instead. At least it's not guessable.
+ // Some things that when combined will make a suitably unique key.
+ // see: http://www.jwz.org/doc/mid.html
+ list( $usec, $sec ) = explode( ' ', microtime() );
+ $usec = substr( $usec, 2 );
+ $key = Wikimedia\base_convert( $sec . $usec, 10, 36 ) . '.' .
+ Wikimedia\base_convert( mt_rand(), 10, 36 ) . '.' .
+ $this->userId . '.' .
+ $extension;
+
+ $this->fileProps[$key] = $fileProps;
+
+ if ( !preg_match( self::KEY_FORMAT_REGEX, $key ) ) {
+ throw new UploadStashBadPathException( "key '$key' is not in a proper format" );
+ }
+
+ wfDebug( __METHOD__ . " key for '$path': $key\n" );
+
+ // if not already in a temporary area, put it there
+ $storeStatus = $this->repo->storeTemp( basename( $pathWithGoodExtension ), $path );
+
+ if ( !$storeStatus->isOK() ) {
+ // It is a convention in MediaWiki to only return one error per API
+ // exception, even if multiple errors are available. We use reset()
+ // to pick the "first" thing that was wrong, preferring errors to
+ // warnings. This is a bit lame, as we may have more info in the
+ // $storeStatus and we're throwing it away, but to fix it means
+ // redesigning API errors significantly.
+ // $storeStatus->value just contains the virtual URL (if anything)
+ // which is probably useless to the caller.
+ $error = $storeStatus->getErrorsArray();
+ $error = reset( $error );
+ if ( !count( $error ) ) {
+ $error = $storeStatus->getWarningsArray();
+ $error = reset( $error );
+ if ( !count( $error ) ) {
+ $error = [ 'unknown', 'no error recorded' ];
+ }
+ }
+ // At this point, $error should contain the single "most important"
+ // error, plus any parameters.
+ $errorMsg = array_shift( $error );
+ throw new UploadStashFileException( "Error storing file in '$path': "
+ . wfMessage( $errorMsg, $error )->text() );
+ }
+ $stashPath = $storeStatus->value;
+
+ // fetch the current user ID
+ if ( !$this->isLoggedIn ) {
+ throw new UploadStashNotLoggedInException( __METHOD__
+ . ' No user is logged in, files must belong to users' );
+ }
+
+ // insert the file metadata into the db.
+ wfDebug( __METHOD__ . " inserting $stashPath under $key\n" );
+ $dbw = $this->repo->getMasterDB();
+
+ $serializedFileProps = serialize( $fileProps );
+ if ( strlen( $serializedFileProps ) > self::MAX_US_PROPS_SIZE ) {
+ // Database is going to truncate this and make the field invalid.
+ // Prioritize important metadata over file handler metadata.
+ // File handler should be prepared to regenerate invalid metadata if needed.
+ $fileProps['metadata'] = false;
+ $serializedFileProps = serialize( $fileProps );
+ }
+
+ $this->fileMetadata[$key] = [
+ 'us_user' => $this->userId,
+ 'us_key' => $key,
+ 'us_orig_path' => $path,
+ 'us_path' => $stashPath, // virtual URL
+ 'us_props' => $dbw->encodeBlob( $serializedFileProps ),
+ 'us_size' => $fileProps['size'],
+ 'us_sha1' => $fileProps['sha1'],
+ 'us_mime' => $fileProps['mime'],
+ 'us_media_type' => $fileProps['media_type'],
+ 'us_image_width' => $fileProps['width'],
+ 'us_image_height' => $fileProps['height'],
+ 'us_image_bits' => $fileProps['bits'],
+ 'us_source_type' => $sourceType,
+ 'us_timestamp' => $dbw->timestamp(),
+ 'us_status' => 'finished'
+ ];
+
+ $dbw->insert(
+ 'uploadstash',
+ $this->fileMetadata[$key],
+ __METHOD__
+ );
+
+ // store the insertid in the class variable so immediate retrieval
+ // (possibly laggy) isn't necesary.
+ $this->fileMetadata[$key]['us_id'] = $dbw->insertId();
+
+ # create the UploadStashFile object for this file.
+ $this->initFile( $key );
+
+ return $this->getFile( $key );
+ }
+
+ /**
+ * Remove all files from the stash.
+ * Does not clean up files in the repo, just the record of them.
+ *
+ * @throws UploadStashNotLoggedInException
+ * @return bool Success
+ */
+ public function clear() {
+ if ( !$this->isLoggedIn ) {
+ throw new UploadStashNotLoggedInException( __METHOD__
+ . ' No user is logged in, files must belong to users' );
+ }
+
+ wfDebug( __METHOD__ . ' clearing all rows for user ' . $this->userId . "\n" );
+ $dbw = $this->repo->getMasterDB();
+ $dbw->delete(
+ 'uploadstash',
+ [ 'us_user' => $this->userId ],
+ __METHOD__
+ );
+
+ # destroy objects.
+ $this->files = [];
+ $this->fileMetadata = [];
+
+ return true;
+ }
+
+ /**
+ * Remove a particular file from the stash. Also removes it from the repo.
+ *
+ * @param string $key
+ * @throws UploadStashNoSuchKeyException|UploadStashNotLoggedInException
+ * @throws UploadStashWrongOwnerException
+ * @return bool Success
+ */
+ public function removeFile( $key ) {
+ if ( !$this->isLoggedIn ) {
+ throw new UploadStashNotLoggedInException( __METHOD__
+ . ' No user is logged in, files must belong to users' );
+ }
+
+ $dbw = $this->repo->getMasterDB();
+
+ // this is a cheap query. it runs on the master so that this function
+ // still works when there's lag. It won't be called all that often.
+ $row = $dbw->selectRow(
+ 'uploadstash',
+ 'us_user',
+ [ 'us_key' => $key ],
+ __METHOD__
+ );
+
+ if ( !$row ) {
+ throw new UploadStashNoSuchKeyException( "No such key ($key), cannot remove" );
+ }
+
+ if ( $row->us_user != $this->userId ) {
+ throw new UploadStashWrongOwnerException( "Can't delete: "
+ . "the file ($key) doesn't belong to this user." );
+ }
+
+ return $this->removeFileNoAuth( $key );
+ }
+
+ /**
+ * Remove a file (see removeFile), but doesn't check ownership first.
+ *
+ * @param string $key
+ * @return bool Success
+ */
+ public function removeFileNoAuth( $key ) {
+ wfDebug( __METHOD__ . " clearing row $key\n" );
+
+ // Ensure we have the UploadStashFile loaded for this key
+ $this->getFile( $key, true );
+
+ $dbw = $this->repo->getMasterDB();
+
+ $dbw->delete(
+ 'uploadstash',
+ [ 'us_key' => $key ],
+ __METHOD__
+ );
+
+ /** @todo Look into UnregisteredLocalFile and find out why the rv here is
+ * sometimes wrong (false when file was removed). For now, ignore.
+ */
+ $this->files[$key]->remove();
+
+ unset( $this->files[$key] );
+ unset( $this->fileMetadata[$key] );
+
+ return true;
+ }
+
+ /**
+ * List all files in the stash.
+ *
+ * @throws UploadStashNotLoggedInException
+ * @return array
+ */
+ public function listFiles() {
+ if ( !$this->isLoggedIn ) {
+ throw new UploadStashNotLoggedInException( __METHOD__
+ . ' No user is logged in, files must belong to users' );
+ }
+
+ $dbr = $this->repo->getReplicaDB();
+ $res = $dbr->select(
+ 'uploadstash',
+ 'us_key',
+ [ 'us_user' => $this->userId ],
+ __METHOD__
+ );
+
+ if ( !is_object( $res ) || $res->numRows() == 0 ) {
+ // nothing to do.
+ return false;
+ }
+
+ // finish the read before starting writes.
+ $keys = [];
+ foreach ( $res as $row ) {
+ array_push( $keys, $row->us_key );
+ }
+
+ return $keys;
+ }
+
+ /**
+ * Find or guess extension -- ensuring that our extension matches our MIME type.
+ * Since these files are constructed from php tempnames they may not start off
+ * with an extension.
+ * XXX this is somewhat redundant with the checks that ApiUpload.php does with incoming
+ * uploads versus the desired filename. Maybe we can get that passed to us...
+ * @param string $path
+ * @throws UploadStashFileException
+ * @return string
+ */
+ public static function getExtensionForPath( $path ) {
+ global $wgFileBlacklist;
+ // Does this have an extension?
+ $n = strrpos( $path, '.' );
+ $extension = null;
+ if ( $n !== false ) {
+ $extension = $n ? substr( $path, $n + 1 ) : '';
+ } else {
+ // If not, assume that it should be related to the MIME type of the original file.
+ $magic = MimeMagic::singleton();
+ $mimeType = $magic->guessMimeType( $path );
+ $extensions = explode( ' ', MimeMagic::singleton()->getExtensionsForType( $mimeType ) );
+ if ( count( $extensions ) ) {
+ $extension = $extensions[0];
+ }
+ }
+
+ if ( is_null( $extension ) ) {
+ throw new UploadStashFileException( "extension is null" );
+ }
+
+ $extension = File::normalizeExtension( $extension );
+ if ( in_array( $extension, $wgFileBlacklist ) ) {
+ // The file should already be checked for being evil.
+ // However, if somehow we got here, we definitely
+ // don't want to give it an extension of .php and
+ // put it in a web accesible directory.
+ return '';
+ }
+
+ return $extension;
+ }
+
+ /**
+ * Helper function: do the actual database query to fetch file metadata.
+ *
+ * @param string $key
+ * @param int $readFromDB Constant (default: DB_REPLICA)
+ * @return bool
+ */
+ protected function fetchFileMetadata( $key, $readFromDB = DB_REPLICA ) {
+ // populate $fileMetadata[$key]
+ $dbr = null;
+ if ( $readFromDB === DB_MASTER ) {
+ // sometimes reading from the master is necessary, if there's replication lag.
+ $dbr = $this->repo->getMasterDB();
+ } else {
+ $dbr = $this->repo->getReplicaDB();
+ }
+
+ $row = $dbr->selectRow(
+ 'uploadstash',
+ '*',
+ [ 'us_key' => $key ],
+ __METHOD__
+ );
+
+ if ( !is_object( $row ) ) {
+ // key wasn't present in the database. this will happen sometimes.
+ return false;
+ }
+
+ $this->fileMetadata[$key] = (array)$row;
+ $this->fileMetadata[$key]['us_props'] = $dbr->decodeBlob( $row->us_props );
+
+ return true;
+ }
+
+ /**
+ * Helper function: Initialize the UploadStashFile for a given file.
+ *
+ * @param string $key Key under which to store the object
+ * @throws UploadStashZeroLengthFileException
+ * @return bool
+ */
+ protected function initFile( $key ) {
+ $file = new UploadStashFile( $this->repo, $this->fileMetadata[$key]['us_path'], $key );
+ if ( $file->getSize() === 0 ) {
+ throw new UploadStashZeroLengthFileException( "File is zero length" );
+ }
+ $this->files[$key] = $file;
+
+ return true;
+ }
+}
+
+class UploadStashFile extends UnregisteredLocalFile {
+ private $fileKey;
+ private $urlName;
+ protected $url;
+
+ /**
+ * A LocalFile wrapper around a file that has been temporarily stashed,
+ * so we can do things like create thumbnails for it. Arguably
+ * UnregisteredLocalFile should be handling its own file repo but that
+ * class is a bit retarded currently.
+ *
+ * @param FileRepo $repo Repository where we should find the path
+ * @param string $path Path to file
+ * @param string $key Key to store the path and any stashed data under
+ * @throws UploadStashBadPathException
+ * @throws UploadStashFileNotFoundException
+ */
+ public function __construct( $repo, $path, $key ) {
+ $this->fileKey = $key;
+
+ // resolve mwrepo:// urls
+ if ( $repo->isVirtualUrl( $path ) ) {
+ $path = $repo->resolveVirtualUrl( $path );
+ } else {
+ // check if path appears to be sane, no parent traversals,
+ // and is in this repo's temp zone.
+ $repoTempPath = $repo->getZonePath( 'temp' );
+ if ( ( !$repo->validateFilename( $path ) ) ||
+ ( strpos( $path, $repoTempPath ) !== 0 )
+ ) {
+ wfDebug( "UploadStash: tried to construct an UploadStashFile "
+ . "from a file that should already exist at '$path', but path is not valid\n" );
+ throw new UploadStashBadPathException( 'path is not valid' );
+ }
+
+ // check if path exists! and is a plain file.
+ if ( !$repo->fileExists( $path ) ) {
+ wfDebug( "UploadStash: tried to construct an UploadStashFile from "
+ . "a file that should already exist at '$path', but path is not found\n" );
+ throw new UploadStashFileNotFoundException( 'cannot find path, or not a plain file' );
+ }
+ }
+
+ parent::__construct( false, $repo, $path, false );
+
+ $this->name = basename( $this->path );
+ }
+
+ /**
+ * A method needed by the file transforming and scaling routines in File.php
+ * We do not necessarily care about doing the description at this point
+ * However, we also can't return the empty string, as the rest of MediaWiki
+ * demands this (and calls to imagemagick convert require it to be there)
+ *
+ * @return string Dummy value
+ */
+ public function getDescriptionUrl() {
+ return $this->getUrl();
+ }
+
+ /**
+ * Get the path for the thumbnail (actually any transformation of this file)
+ * The actual argument is the result of thumbName although we seem to have
+ * buggy code elsewhere that expects a boolean 'suffix'
+ *
+ * @param string $thumbName Name of thumbnail (e.g. "120px-123456.jpg" ),
+ * or false to just get the path
+ * @return string Path thumbnail should take on filesystem, or containing
+ * directory if thumbname is false
+ */
+ public function getThumbPath( $thumbName = false ) {
+ $path = dirname( $this->path );
+ if ( $thumbName !== false ) {
+ $path .= "/$thumbName";
+ }
+
+ return $path;
+ }
+
+ /**
+ * Return the file/url base name of a thumbnail with the specified parameters.
+ * We override this because we want to use the pretty url name instead of the
+ * ugly file name.
+ *
+ * @param array $params Handler-specific parameters
+ * @param int $flags Bitfield that supports THUMB_* constants
+ * @return string|null Base name for URL, like '120px-12345.jpg', or null if there is no handler
+ */
+ function thumbName( $params, $flags = 0 ) {
+ return $this->generateThumbName( $this->getUrlName(), $params );
+ }
+
+ /**
+ * Helper function -- given a 'subpage', return the local URL,
+ * e.g. /wiki/Special:UploadStash/subpage
+ * @param string $subPage
+ * @return string Local URL for this subpage in the Special:UploadStash space.
+ */
+ private function getSpecialUrl( $subPage ) {
+ return SpecialPage::getTitleFor( 'UploadStash', $subPage )->getLocalURL();
+ }
+
+ /**
+ * Get a URL to access the thumbnail
+ * This is required because the model of how files work requires that
+ * the thumbnail urls be predictable. However, in our model the URL is
+ * not based on the filename (that's hidden in the db)
+ *
+ * @param string $thumbName Basename of thumbnail file -- however, we don't
+ * want to use the file exactly
+ * @return string URL to access thumbnail, or URL with partial path
+ */
+ public function getThumbUrl( $thumbName = false ) {
+ wfDebug( __METHOD__ . " getting for $thumbName \n" );
+
+ return $this->getSpecialUrl( 'thumb/' . $this->getUrlName() . '/' . $thumbName );
+ }
+
+ /**
+ * The basename for the URL, which we want to not be related to the filename.
+ * Will also be used as the lookup key for a thumbnail file.
+ *
+ * @return string Base url name, like '120px-123456.jpg'
+ */
+ public function getUrlName() {
+ if ( !$this->urlName ) {
+ $this->urlName = $this->fileKey;
+ }
+
+ return $this->urlName;
+ }
+
+ /**
+ * Return the URL of the file, if for some reason we wanted to download it
+ * We tend not to do this for the original file, but we do want thumb icons
+ *
+ * @return string Url
+ */
+ public function getUrl() {
+ if ( !isset( $this->url ) ) {
+ $this->url = $this->getSpecialUrl( 'file/' . $this->getUrlName() );
+ }
+
+ return $this->url;
+ }
+
+ /**
+ * Parent classes use this method, for no obvious reason, to return the path
+ * (relative to wiki root, I assume). But with this class, the URL is
+ * unrelated to the path.
+ *
+ * @return string Url
+ */
+ public function getFullUrl() {
+ return $this->getUrl();
+ }
+
+ /**
+ * Getter for file key (the unique id by which this file's location &
+ * metadata is stored in the db)
+ *
+ * @return string File key
+ */
+ public function getFileKey() {
+ return $this->fileKey;
+ }
+
+ /**
+ * Remove the associated temporary file
+ * @return status Success
+ */
+ public function remove() {
+ if ( !$this->repo->fileExists( $this->path ) ) {
+ // Maybe the file's already been removed? This could totally happen in UploadBase.
+ return true;
+ }
+
+ return $this->repo->freeTemp( $this->path );
+ }
+
+ public function exists() {
+ return $this->repo->fileExists( $this->path );
+ }
+}
+
+class UploadStashException extends MWException {
+}
+
+class UploadStashFileNotFoundException extends UploadStashException {
+}
+
+class UploadStashBadPathException extends UploadStashException {
+}
+
+class UploadStashFileException extends UploadStashException {
+}
+
+class UploadStashZeroLengthFileException extends UploadStashException {
+}
+
+class UploadStashNotLoggedInException extends UploadStashException {
+}
+
+class UploadStashWrongOwnerException extends UploadStashException {
+}
+
+class UploadStashNoSuchKeyException extends UploadStashException {
+}
diff --git a/www/wiki/includes/user/BotPassword.php b/www/wiki/includes/user/BotPassword.php
new file mode 100644
index 00000000..0a8b3609
--- /dev/null
+++ b/www/wiki/includes/user/BotPassword.php
@@ -0,0 +1,519 @@
+<?php
+/**
+ * Utility class for bot passwords
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+use MediaWiki\Session\BotPasswordSessionProvider;
+use Wikimedia\Rdbms\IMaintainableDatabase;
+
+/**
+ * Utility class for bot passwords
+ * @since 1.27
+ */
+class BotPassword implements IDBAccessObject {
+
+ const APPID_MAXLENGTH = 32;
+
+ /** @var bool */
+ private $isSaved;
+
+ /** @var int */
+ private $centralId;
+
+ /** @var string */
+ private $appId;
+
+ /** @var string */
+ private $token;
+
+ /** @var MWRestrictions */
+ private $restrictions;
+
+ /** @var string[] */
+ private $grants;
+
+ /** @var int */
+ private $flags = self::READ_NORMAL;
+
+ /**
+ * @param object $row bot_passwords database row
+ * @param bool $isSaved Whether the bot password was read from the database
+ * @param int $flags IDBAccessObject read flags
+ */
+ protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
+ $this->isSaved = $isSaved;
+ $this->flags = $flags;
+
+ $this->centralId = (int)$row->bp_user;
+ $this->appId = $row->bp_app_id;
+ $this->token = $row->bp_token;
+ $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
+ $this->grants = FormatJson::decode( $row->bp_grants );
+ }
+
+ /**
+ * Get a database connection for the bot passwords database
+ * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_REPLICA.
+ * @return IMaintainableDatabase
+ */
+ public static function getDB( $db ) {
+ global $wgBotPasswordsCluster, $wgBotPasswordsDatabase;
+
+ $lb = $wgBotPasswordsCluster
+ ? wfGetLBFactory()->getExternalLB( $wgBotPasswordsCluster )
+ : wfGetLB( $wgBotPasswordsDatabase );
+ return $lb->getConnectionRef( $db, [], $wgBotPasswordsDatabase );
+ }
+
+ /**
+ * Load a BotPassword from the database
+ * @param User $user
+ * @param string $appId
+ * @param int $flags IDBAccessObject read flags
+ * @return BotPassword|null
+ */
+ public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
+ $centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
+ $user, CentralIdLookup::AUDIENCE_RAW, $flags
+ );
+ return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
+ }
+
+ /**
+ * Load a BotPassword from the database
+ * @param int $centralId from CentralIdLookup
+ * @param string $appId
+ * @param int $flags IDBAccessObject read flags
+ * @return BotPassword|null
+ */
+ public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
+ global $wgEnableBotPasswords;
+
+ if ( !$wgEnableBotPasswords ) {
+ return null;
+ }
+
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
+ $db = self::getDB( $index );
+ $row = $db->selectRow(
+ 'bot_passwords',
+ [ 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ],
+ [ 'bp_user' => $centralId, 'bp_app_id' => $appId ],
+ __METHOD__,
+ $options
+ );
+ return $row ? new self( $row, true, $flags ) : null;
+ }
+
+ /**
+ * Create an unsaved BotPassword
+ * @param array $data Data to use to create the bot password. Keys are:
+ * - user: (User) User object to create the password for. Overrides username and centralId.
+ * - username: (string) Username to create the password for. Overrides centralId.
+ * - centralId: (int) User central ID to create the password for.
+ * - appId: (string) App ID for the password.
+ * - restrictions: (MWRestrictions, optional) Restrictions.
+ * - grants: (string[], optional) Grants.
+ * @param int $flags IDBAccessObject read flags
+ * @return BotPassword|null
+ */
+ public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
+ $row = (object)[
+ 'bp_user' => 0,
+ 'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
+ 'bp_token' => '**unsaved**',
+ 'bp_restrictions' => isset( $data['restrictions'] )
+ ? $data['restrictions']
+ : MWRestrictions::newDefault(),
+ 'bp_grants' => isset( $data['grants'] ) ? $data['grants'] : [],
+ ];
+
+ if (
+ $row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
+ !$row->bp_restrictions instanceof MWRestrictions ||
+ !is_array( $row->bp_grants )
+ ) {
+ return null;
+ }
+
+ $row->bp_restrictions = $row->bp_restrictions->toJson();
+ $row->bp_grants = FormatJson::encode( $row->bp_grants );
+
+ if ( isset( $data['user'] ) ) {
+ if ( !$data['user'] instanceof User ) {
+ return null;
+ }
+ $row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
+ $data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
+ );
+ } elseif ( isset( $data['username'] ) ) {
+ $row->bp_user = CentralIdLookup::factory()->centralIdFromName(
+ $data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
+ );
+ } elseif ( isset( $data['centralId'] ) ) {
+ $row->bp_user = $data['centralId'];
+ }
+ if ( !$row->bp_user ) {
+ return null;
+ }
+
+ return new self( $row, false, $flags );
+ }
+
+ /**
+ * Indicate whether this is known to be saved
+ * @return bool
+ */
+ public function isSaved() {
+ return $this->isSaved;
+ }
+
+ /**
+ * Get the central user ID
+ * @return int
+ */
+ public function getUserCentralId() {
+ return $this->centralId;
+ }
+
+ /**
+ * Get the app ID
+ * @return string
+ */
+ public function getAppId() {
+ return $this->appId;
+ }
+
+ /**
+ * Get the token
+ * @return string
+ */
+ public function getToken() {
+ return $this->token;
+ }
+
+ /**
+ * Get the restrictions
+ * @return MWRestrictions
+ */
+ public function getRestrictions() {
+ return $this->restrictions;
+ }
+
+ /**
+ * Get the grants
+ * @return string[]
+ */
+ public function getGrants() {
+ return $this->grants;
+ }
+
+ /**
+ * Get the separator for combined user name + app ID
+ * @return string
+ */
+ public static function getSeparator() {
+ global $wgUserrightsInterwikiDelimiter;
+ return $wgUserrightsInterwikiDelimiter;
+ }
+
+ /**
+ * Get the password
+ * @return Password
+ */
+ protected function getPassword() {
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
+ $db = self::getDB( $index );
+ $password = $db->selectField(
+ 'bot_passwords',
+ 'bp_password',
+ [ 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ],
+ __METHOD__,
+ $options
+ );
+ if ( $password === false ) {
+ return PasswordFactory::newInvalidPassword();
+ }
+
+ $passwordFactory = new \PasswordFactory();
+ $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+ try {
+ return $passwordFactory->newFromCiphertext( $password );
+ } catch ( PasswordError $ex ) {
+ return PasswordFactory::newInvalidPassword();
+ }
+ }
+
+ /**
+ * Whether the password is currently invalid
+ * @since 1.32
+ * @return bool
+ */
+ public function isInvalid() {
+ return $this->getPassword() instanceof InvalidPassword;
+ }
+
+ /**
+ * Save the BotPassword to the database
+ * @param string $operation 'update' or 'insert'
+ * @param Password|null $password Password to set.
+ * @return bool Success
+ */
+ public function save( $operation, Password $password = null ) {
+ $conds = [
+ 'bp_user' => $this->centralId,
+ 'bp_app_id' => $this->appId,
+ ];
+ $fields = [
+ 'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
+ 'bp_restrictions' => $this->restrictions->toJson(),
+ 'bp_grants' => FormatJson::encode( $this->grants ),
+ ];
+
+ if ( $password !== null ) {
+ $fields['bp_password'] = $password->toString();
+ } elseif ( $operation === 'insert' ) {
+ $fields['bp_password'] = PasswordFactory::newInvalidPassword()->toString();
+ }
+
+ $dbw = self::getDB( DB_MASTER );
+ switch ( $operation ) {
+ case 'insert':
+ $dbw->insert( 'bot_passwords', $fields + $conds, __METHOD__, [ 'IGNORE' ] );
+ break;
+
+ case 'update':
+ $dbw->update( 'bot_passwords', $fields, $conds, __METHOD__ );
+ break;
+
+ default:
+ return false;
+ }
+ $ok = (bool)$dbw->affectedRows();
+ if ( $ok ) {
+ $this->token = $dbw->selectField( 'bot_passwords', 'bp_token', $conds, __METHOD__ );
+ $this->isSaved = true;
+ }
+ return $ok;
+ }
+
+ /**
+ * Delete the BotPassword from the database
+ * @return bool Success
+ */
+ public function delete() {
+ $conds = [
+ 'bp_user' => $this->centralId,
+ 'bp_app_id' => $this->appId,
+ ];
+ $dbw = self::getDB( DB_MASTER );
+ $dbw->delete( 'bot_passwords', $conds, __METHOD__ );
+ $ok = (bool)$dbw->affectedRows();
+ if ( $ok ) {
+ $this->token = '**unsaved**';
+ $this->isSaved = false;
+ }
+ return $ok;
+ }
+
+ /**
+ * Invalidate all passwords for a user, by name
+ * @param string $username User name
+ * @return bool Whether any passwords were invalidated
+ */
+ public static function invalidateAllPasswordsForUser( $username ) {
+ $centralId = CentralIdLookup::factory()->centralIdFromName(
+ $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+ );
+ return $centralId && self::invalidateAllPasswordsForCentralId( $centralId );
+ }
+
+ /**
+ * Invalidate all passwords for a user, by central ID
+ * @param int $centralId
+ * @return bool Whether any passwords were invalidated
+ */
+ public static function invalidateAllPasswordsForCentralId( $centralId ) {
+ global $wgEnableBotPasswords;
+
+ if ( !$wgEnableBotPasswords ) {
+ return false;
+ }
+
+ $dbw = self::getDB( DB_MASTER );
+ $dbw->update(
+ 'bot_passwords',
+ [ 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ],
+ [ 'bp_user' => $centralId ],
+ __METHOD__
+ );
+ return (bool)$dbw->affectedRows();
+ }
+
+ /**
+ * Remove all passwords for a user, by name
+ * @param string $username User name
+ * @return bool Whether any passwords were removed
+ */
+ public static function removeAllPasswordsForUser( $username ) {
+ $centralId = CentralIdLookup::factory()->centralIdFromName(
+ $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+ );
+ return $centralId && self::removeAllPasswordsForCentralId( $centralId );
+ }
+
+ /**
+ * Remove all passwords for a user, by central ID
+ * @param int $centralId
+ * @return bool Whether any passwords were removed
+ */
+ public static function removeAllPasswordsForCentralId( $centralId ) {
+ global $wgEnableBotPasswords;
+
+ if ( !$wgEnableBotPasswords ) {
+ return false;
+ }
+
+ $dbw = self::getDB( DB_MASTER );
+ $dbw->delete(
+ 'bot_passwords',
+ [ 'bp_user' => $centralId ],
+ __METHOD__
+ );
+ return (bool)$dbw->affectedRows();
+ }
+
+ /**
+ * Returns a (raw, unhashed) random password string.
+ * @param Config $config
+ * @return string
+ */
+ public static function generatePassword( $config ) {
+ return PasswordFactory::generateRandomPasswordString(
+ max( 32, $config->get( 'MinimalPasswordLength' ) ) );
+ }
+
+ /**
+ * There are two ways to login with a bot password: "username@appId", "password" and
+ * "username", "appId@password". Transform it so it is always in the first form.
+ * Returns [bot username, bot password, could be normal password?] where the last one is a flag
+ * meaning this could either be a bot password or a normal password, it cannot be decided for
+ * certain (although in such cases it almost always will be a bot password).
+ * If this cannot be a bot password login just return false.
+ * @param string $username
+ * @param string $password
+ * @return array|false
+ */
+ public static function canonicalizeLoginData( $username, $password ) {
+ $sep = self::getSeparator();
+ // the strlen check helps minimize the password information obtainable from timing
+ if ( strlen( $password ) >= 32 && strpos( $username, $sep ) !== false ) {
+ // the separator is not valid in new usernames but might appear in legacy ones
+ if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
+ return [ $username, $password, true ];
+ }
+ } elseif ( strlen( $password ) > 32 && strpos( $password, $sep ) !== false ) {
+ $segments = explode( $sep, $password );
+ $password = array_pop( $segments );
+ $appId = implode( $sep, $segments );
+ if ( preg_match( '/^[0-9a-w]{32,}$/', $password ) ) {
+ return [ $username . $sep . $appId, $password, true ];
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Try to log the user in
+ * @param string $username Combined user name and app ID
+ * @param string $password Supplied password
+ * @param WebRequest $request
+ * @return Status On success, the good status's value is the new Session object
+ */
+ public static function login( $username, $password, WebRequest $request ) {
+ global $wgEnableBotPasswords, $wgPasswordAttemptThrottle;
+
+ if ( !$wgEnableBotPasswords ) {
+ return Status::newFatal( 'botpasswords-disabled' );
+ }
+
+ $manager = MediaWiki\Session\SessionManager::singleton();
+ $provider = $manager->getProvider( BotPasswordSessionProvider::class );
+ if ( !$provider ) {
+ return Status::newFatal( 'botpasswords-no-provider' );
+ }
+
+ // Split name into name+appId
+ $sep = self::getSeparator();
+ if ( strpos( $username, $sep ) === false ) {
+ return Status::newFatal( 'botpasswords-invalid-name', $sep );
+ }
+ list( $name, $appId ) = explode( $sep, $username, 2 );
+
+ // Find the named user
+ $user = User::newFromName( $name );
+ if ( !$user || $user->isAnon() ) {
+ return Status::newFatal( 'nosuchuser', $name );
+ }
+
+ if ( $user->isLocked() ) {
+ return Status::newFatal( 'botpasswords-locked' );
+ }
+
+ // Throttle
+ $throttle = null;
+ if ( !empty( $wgPasswordAttemptThrottle ) ) {
+ $throttle = new MediaWiki\Auth\Throttler( $wgPasswordAttemptThrottle, [
+ 'type' => 'botpassword',
+ 'cache' => ObjectCache::getLocalClusterInstance(),
+ ] );
+ $result = $throttle->increase( $user->getName(), $request->getIP(), __METHOD__ );
+ if ( $result ) {
+ $msg = wfMessage( 'login-throttled' )->durationParams( $result['wait'] );
+ return Status::newFatal( $msg );
+ }
+ }
+
+ // Get the bot password
+ $bp = self::newFromUser( $user, $appId );
+ if ( !$bp ) {
+ return Status::newFatal( 'botpasswords-not-exist', $name, $appId );
+ }
+
+ // Check restrictions
+ $status = $bp->getRestrictions()->check( $request );
+ if ( !$status->isOK() ) {
+ return Status::newFatal( 'botpasswords-restriction-failed' );
+ }
+
+ // Check the password
+ $passwordObj = $bp->getPassword();
+ if ( $passwordObj instanceof InvalidPassword ) {
+ return Status::newFatal( 'botpasswords-needs-reset', $name, $appId );
+ }
+ if ( !$passwordObj->equals( $password ) ) {
+ return Status::newFatal( 'wrongpassword' );
+ }
+
+ // Ok! Create the session.
+ if ( $throttle ) {
+ $throttle->clear( $user->getName(), $request->getIP() );
+ }
+ return Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) );
+ }
+}
diff --git a/www/wiki/includes/user/CentralIdLookup.php b/www/wiki/includes/user/CentralIdLookup.php
new file mode 100644
index 00000000..618b7f07
--- /dev/null
+++ b/www/wiki/includes/user/CentralIdLookup.php
@@ -0,0 +1,262 @@
+<?php
+/**
+ * A central user id lookup service
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * The CentralIdLookup service allows for connecting local users with
+ * cluster-wide IDs.
+ *
+ * @since 1.27
+ */
+abstract class CentralIdLookup implements IDBAccessObject {
+ // Audience options for accessors
+ const AUDIENCE_PUBLIC = 1;
+ const AUDIENCE_RAW = 2;
+
+ /** @var CentralIdLookup[] */
+ private static $instances = [];
+
+ /** @var string */
+ private $providerId;
+
+ /**
+ * Fetch a CentralIdLookup
+ * @param string|null $providerId Provider ID from $wgCentralIdLookupProviders
+ * @return CentralIdLookup|null
+ */
+ public static function factory( $providerId = null ) {
+ global $wgCentralIdLookupProviders, $wgCentralIdLookupProvider;
+
+ if ( $providerId === null ) {
+ $providerId = $wgCentralIdLookupProvider;
+ }
+
+ if ( !array_key_exists( $providerId, self::$instances ) ) {
+ self::$instances[$providerId] = null;
+
+ if ( isset( $wgCentralIdLookupProviders[$providerId] ) ) {
+ $provider = ObjectFactory::getObjectFromSpec( $wgCentralIdLookupProviders[$providerId] );
+ if ( $provider instanceof CentralIdLookup ) {
+ $provider->providerId = $providerId;
+ self::$instances[$providerId] = $provider;
+ }
+ }
+ }
+
+ return self::$instances[$providerId];
+ }
+
+ /**
+ * Reset internal cache for unit testing
+ */
+ public static function resetCache() {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
+ }
+ self::$instances = [];
+ }
+
+ final public function getProviderId() {
+ return $this->providerId;
+ }
+
+ /**
+ * Check that the "audience" parameter is valid
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @return User|null User to check against, or null if no checks are needed
+ * @throws InvalidArgumentException
+ */
+ protected function checkAudience( $audience ) {
+ if ( $audience instanceof User ) {
+ return $audience;
+ }
+ if ( $audience === self::AUDIENCE_PUBLIC ) {
+ return new User;
+ }
+ if ( $audience === self::AUDIENCE_RAW ) {
+ return null;
+ }
+ throw new InvalidArgumentException( 'Invalid audience' );
+ }
+
+ /**
+ * Check that a User is attached on the specified wiki.
+ *
+ * If unattached local accounts don't exist in your extension, this comes
+ * down to a check whether the central account exists at all and that
+ * $wikiId is using the same central database.
+ *
+ * @param User $user
+ * @param string|null $wikiId Wiki to check attachment status. If null, check the current wiki.
+ * @return bool
+ */
+ abstract public function isAttached( User $user, $wikiId = null );
+
+ /**
+ * Given central user IDs, return the (local) user names
+ * @note There's no requirement that the user names actually exist locally,
+ * or if they do that they're actually attached to the central account.
+ * @param array $idToName Array with keys being central user IDs
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @param int $flags IDBAccessObject read flags
+ * @return array Copy of $idToName with values set to user names (or
+ * empty-string if the user exists but $audience lacks the rights needed
+ * to see it). IDs not corresponding to a user are unchanged.
+ */
+ abstract public function lookupCentralIds(
+ array $idToName, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ );
+
+ /**
+ * Given (local) user names, return the central IDs
+ * @note There's no requirement that the user names actually exist locally,
+ * or if they do that they're actually attached to the central account.
+ * @param array $nameToId Array with keys being canonicalized user names
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @param int $flags IDBAccessObject read flags
+ * @return array Copy of $nameToId with values set to central IDs.
+ * Names not corresponding to a user (or $audience lacks the rights needed
+ * to see it) are unchanged.
+ */
+ abstract public function lookupUserNames(
+ array $nameToId, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ );
+
+ /**
+ * Given a central user ID, return the (local) user name
+ * @note There's no requirement that the user name actually exists locally,
+ * or if it does that it's actually attached to the central account.
+ * @param int $id Central user ID
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @param int $flags IDBAccessObject read flags
+ * @return string|null User name, or empty string if $audience lacks the
+ * rights needed to see it, or null if $id doesn't correspond to a user
+ */
+ public function nameFromCentralId(
+ $id, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ ) {
+ $idToName = $this->lookupCentralIds( [ $id => null ], $audience, $flags );
+ return $idToName[$id];
+ }
+
+ /**
+ * Given a an array of central user IDs, return the (local) user names.
+ * @param int[] $ids Central user IDs
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @param int $flags IDBAccessObject read flags
+ * @return string[] User names
+ * @since 1.30
+ */
+ public function namesFromCentralIds(
+ array $ids, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ ) {
+ $idToName = array_fill_keys( $ids, false );
+ $names = $this->lookupCentralIds( $idToName, $audience, $flags );
+ $names = array_unique( $names );
+ $names = array_filter( $names, function ( $name ) {
+ return $name !== false && $name !== '';
+ } );
+
+ return array_values( $names );
+ }
+
+ /**
+ * Given a (local) user name, return the central ID
+ * @note There's no requirement that the user name actually exists locally,
+ * or if it does that it's actually attached to the central account.
+ * @param string $name Canonicalized user name
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @param int $flags IDBAccessObject read flags
+ * @return int User ID; 0 if the name does not correspond to a user or
+ * $audience lacks the rights needed to see it.
+ */
+ public function centralIdFromName(
+ $name, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ ) {
+ $nameToId = $this->lookupUserNames( [ $name => 0 ], $audience, $flags );
+ return $nameToId[$name];
+ }
+
+ /**
+ * Given an array of (local) user names, return the central IDs.
+ * @param string[] $names Canonicalized user names
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @param int $flags IDBAccessObject read flags
+ * @return int[] User IDs
+ * @since 1.30
+ */
+ public function centralIdsFromNames(
+ array $names, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ ) {
+ $nameToId = array_fill_keys( $names, false );
+ $ids = $this->lookupUserNames( $nameToId, $audience, $flags );
+ $ids = array_unique( $ids );
+ $ids = array_filter( $ids, function ( $id ) {
+ return $id !== false;
+ } );
+
+ return array_values( $ids );
+ }
+
+ /**
+ * Given a central user ID, return a local User object
+ * @note Unlike nameFromCentralId(), this does guarantee that the local
+ * user exists and is attached to the central account.
+ * @param int $id Central user ID
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @param int $flags IDBAccessObject read flags
+ * @return User|null Local user, or null if: $id doesn't correspond to a
+ * user, $audience lacks the rights needed to see the user, the user
+ * doesn't exist locally, or the user isn't locally attached.
+ */
+ public function localUserFromCentralId(
+ $id, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ ) {
+ $name = $this->nameFromCentralId( $id, $audience, $flags );
+ if ( $name !== null && $name !== '' ) {
+ $user = User::newFromName( $name );
+ if ( $user && $user->getId() && $this->isAttached( $user ) ) {
+ return $user;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Given a local User object, return the central ID
+ * @note Unlike centralIdFromName(), this does guarantee that the local
+ * user is attached to the central account.
+ * @param User $user Local user
+ * @param int|User $audience One of the audience constants, or a specific user
+ * @param int $flags IDBAccessObject read flags
+ * @return int User ID; 0 if the local user does not correspond to a
+ * central user, $audience lacks the rights needed to see it, or the
+ * central user isn't locally attached.
+ */
+ public function centralIdFromLocalUser(
+ User $user, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ ) {
+ return $this->isAttached( $user )
+ ? $this->centralIdFromName( $user->getName(), $audience, $flags )
+ : 0;
+ }
+
+}
diff --git a/www/wiki/includes/user/LocalIdLookup.php b/www/wiki/includes/user/LocalIdLookup.php
new file mode 100644
index 00000000..0a345540
--- /dev/null
+++ b/www/wiki/includes/user/LocalIdLookup.php
@@ -0,0 +1,116 @@
+<?php
+/**
+ * A central user id lookup service implementation
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A CentralIdLookup provider that just uses local IDs. Useful if the wiki
+ * isn't part of a cluster or you're using shared user tables.
+ *
+ * @note Shared user table support expects that all wikis involved have
+ * $wgSharedDB and $wgSharedTables set, and that all wikis involved in the
+ * sharing are listed in $wgLocalDatabases, and that no wikis not involved in
+ * the sharing are listed in $wgLocalDatabases.
+ * @since 1.27
+ */
+class LocalIdLookup extends CentralIdLookup {
+
+ public function isAttached( User $user, $wikiId = null ) {
+ global $wgSharedDB, $wgSharedTables, $wgLocalDatabases;
+
+ // If the user has no ID, it can't be attached
+ if ( !$user->getId() ) {
+ return false;
+ }
+
+ // Easy case, we're checking locally
+ if ( $wikiId === null || $wikiId === wfWikiID() ) {
+ return true;
+ }
+
+ // Assume that shared user tables are set up as described above, if
+ // they're being used at all.
+ return $wgSharedDB !== null &&
+ in_array( 'user', $wgSharedTables, true ) &&
+ in_array( $wikiId, $wgLocalDatabases, true );
+ }
+
+ public function lookupCentralIds(
+ array $idToName, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ ) {
+ if ( !$idToName ) {
+ return [];
+ }
+
+ $audience = $this->checkAudience( $audience );
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
+ $db = wfGetDB( $index );
+
+ $tables = [ 'user' ];
+ $fields = [ 'user_id', 'user_name' ];
+ $where = [
+ 'user_id' => array_map( 'intval', array_keys( $idToName ) ),
+ ];
+ $join = [];
+ if ( $audience && !$audience->isAllowed( 'hideuser' ) ) {
+ $tables[] = 'ipblocks';
+ $join['ipblocks'] = [ 'LEFT JOIN', 'ipb_user=user_id' ];
+ $fields[] = 'ipb_deleted';
+ }
+
+ $res = $db->select( $tables, $fields, $where, __METHOD__, $options, $join );
+ foreach ( $res as $row ) {
+ $idToName[$row->user_id] = empty( $row->ipb_deleted ) ? $row->user_name : '';
+ }
+
+ return $idToName;
+ }
+
+ public function lookupUserNames(
+ array $nameToId, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL
+ ) {
+ if ( !$nameToId ) {
+ return [];
+ }
+
+ $audience = $this->checkAudience( $audience );
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
+ $db = wfGetDB( $index );
+
+ $tables = [ 'user' ];
+ $fields = [ 'user_id', 'user_name' ];
+ $where = [
+ 'user_name' => array_map( 'strval', array_keys( $nameToId ) ),
+ ];
+ $join = [];
+ if ( $audience && !$audience->isAllowed( 'hideuser' ) ) {
+ $tables[] = 'ipblocks';
+ $join['ipblocks'] = [ 'LEFT JOIN', 'ipb_user=user_id' ];
+ $where[] = 'ipb_deleted = 0 OR ipb_deleted IS NULL';
+ }
+
+ $res = $db->select( $tables, $fields, $where, __METHOD__, $options, $join );
+ foreach ( $res as $row ) {
+ $nameToId[$row->user_name] = (int)$row->user_id;
+ }
+
+ return $nameToId;
+ }
+}
diff --git a/www/wiki/includes/user/LoggedOutEditToken.php b/www/wiki/includes/user/LoggedOutEditToken.php
new file mode 100644
index 00000000..5444a51f
--- /dev/null
+++ b/www/wiki/includes/user/LoggedOutEditToken.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * MediaWiki edit token
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+use MediaWiki\Session\Token;
+
+/**
+ * Value object representing a logged-out user's edit token
+ *
+ * This exists so that code generically dealing with MediaWiki\Session\Token
+ * (i.e. the API) doesn't have to have so many special cases for anon edit
+ * tokens.
+ *
+ * @since 1.27
+ */
+class LoggedOutEditToken extends Token {
+ public function __construct() {
+ parent::__construct( '', '', false );
+ }
+
+ protected function toStringAtTimestamp( $timestamp ) {
+ return self::SUFFIX;
+ }
+
+ public function match( $userToken, $maxAge = null ) {
+ return $userToken === self::SUFFIX;
+ }
+}
diff --git a/www/wiki/includes/user/PasswordReset.php b/www/wiki/includes/user/PasswordReset.php
new file mode 100644
index 00000000..dd16fb78
--- /dev/null
+++ b/www/wiki/includes/user/PasswordReset.php
@@ -0,0 +1,309 @@
+<?php
+/**
+ * User password reset helper for MediaWiki.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Helper class for the password reset functionality shared by the web UI and the API.
+ *
+ * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the
+ * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent
+ * functionality) to be enabled.
+ */
+class PasswordReset implements LoggerAwareInterface {
+ /** @var Config */
+ protected $config;
+
+ /** @var AuthManager */
+ protected $authManager;
+
+ /** @var LoggerInterface */
+ protected $logger;
+
+ /**
+ * In-process cache for isAllowed lookups, by username.
+ * Contains a StatusValue object
+ * @var HashBagOStuff
+ */
+ private $permissionCache;
+
+ public function __construct( Config $config, AuthManager $authManager ) {
+ $this->config = $config;
+ $this->authManager = $authManager;
+ $this->permissionCache = new HashBagOStuff( [ 'maxKeys' => 1 ] );
+ $this->logger = LoggerFactory::getInstance( 'authentication' );
+ }
+
+ /**
+ * Set the logger instance to use.
+ *
+ * @param LoggerInterface $logger
+ * @since 1.29
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Check if a given user has permission to use this functionality.
+ * @param User $user
+ * @param bool $displayPassword If set, also check whether the user is allowed to reset the
+ * password of another user and see the temporary password.
+ * @since 1.29 Second argument for displayPassword removed.
+ * @return StatusValue
+ */
+ public function isAllowed( User $user ) {
+ $status = $this->permissionCache->get( $user->getName() );
+ if ( !$status ) {
+ $resetRoutes = $this->config->get( 'PasswordResetRoutes' );
+ $status = StatusValue::newGood();
+
+ if ( !is_array( $resetRoutes ) ||
+ !in_array( true, array_values( $resetRoutes ), true )
+ ) {
+ // Maybe password resets are disabled, or there are no allowable routes
+ $status = StatusValue::newFatal( 'passwordreset-disabled' );
+ } elseif (
+ ( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
+ new TemporaryPasswordAuthenticationRequest(), false ) )
+ && !$providerStatus->isGood()
+ ) {
+ // Maybe the external auth plugin won't allow local password changes
+ $status = StatusValue::newFatal( 'resetpass_forbidden-reason',
+ $providerStatus->getMessage() );
+ } elseif ( !$this->config->get( 'EnableEmail' ) ) {
+ // Maybe email features have been disabled
+ $status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
+ } elseif ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
+ // Maybe not all users have permission to change private data
+ $status = StatusValue::newFatal( 'badaccess' );
+ } elseif ( $this->isBlocked( $user ) ) {
+ // Maybe the user is blocked (check this here rather than relying on the parent
+ // method as we have a more specific error message to use here and we want to
+ // ignore some types of blocks)
+ $status = StatusValue::newFatal( 'blocked-mailpassword' );
+ }
+
+ $this->permissionCache->set( $user->getName(), $status );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Do a password reset. Authorization is the caller's responsibility.
+ *
+ * Process the form. At this point we know that the user passes all the criteria in
+ * userCanExecute(), and if the data array contains 'Username', etc, then Username
+ * resets are allowed.
+ *
+ * @since 1.29 Fourth argument for displayPassword removed.
+ * @param User $performingUser The user that does the password reset
+ * @param string $username The user whose password is reset
+ * @param string $email Alternative way to specify the user
+ * @return StatusValue Will contain the passwords as a username => password array if the
+ * $displayPassword flag was set
+ * @throws LogicException When the user is not allowed to perform the action
+ * @throws MWException On unexpected DB errors
+ */
+ public function execute(
+ User $performingUser, $username = null, $email = null
+ ) {
+ if ( !$this->isAllowed( $performingUser )->isGood() ) {
+ throw new LogicException( 'User ' . $performingUser->getName()
+ . ' is not allowed to reset passwords' );
+ }
+
+ $resetRoutes = $this->config->get( 'PasswordResetRoutes' )
+ + [ 'username' => false, 'email' => false ];
+ if ( $resetRoutes['username'] && $username ) {
+ $method = 'username';
+ $users = [ User::newFromName( $username ) ];
+ $email = null;
+ } elseif ( $resetRoutes['email'] && $email ) {
+ if ( !Sanitizer::validateEmail( $email ) ) {
+ return StatusValue::newFatal( 'passwordreset-invalidemail' );
+ }
+ $method = 'email';
+ $users = $this->getUsersByEmail( $email );
+ $username = null;
+ } else {
+ // The user didn't supply any data
+ return StatusValue::newFatal( 'passwordreset-nodata' );
+ }
+
+ // Check for hooks (captcha etc), and allow them to modify the users list
+ $error = [];
+ $data = [
+ 'Username' => $username,
+ 'Email' => $email,
+ ];
+ if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', [ &$users, $data, &$error ] ) ) {
+ return StatusValue::newFatal( Message::newFromSpecifier( $error ) );
+ }
+
+ if ( !$users ) {
+ if ( $method === 'email' ) {
+ // Don't reveal whether or not an email address is in use
+ return StatusValue::newGood( [] );
+ } else {
+ return StatusValue::newFatal( 'noname' );
+ }
+ }
+
+ $firstUser = $users[0];
+
+ if ( !$firstUser instanceof User || !$firstUser->getId() ) {
+ // Don't parse username as wikitext (T67501)
+ return StatusValue::newFatal( wfMessage( 'nosuchuser', wfEscapeWikiText( $username ) ) );
+ }
+
+ // Check against the rate limiter
+ if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
+ return StatusValue::newFatal( 'actionthrottledtext' );
+ }
+
+ // All the users will have the same email address
+ if ( !$firstUser->getEmail() ) {
+ // This won't be reachable from the email route, so safe to expose the username
+ return StatusValue::newFatal( wfMessage( 'noemail',
+ wfEscapeWikiText( $firstUser->getName() ) ) );
+ }
+
+ // We need to have a valid IP address for the hook, but per T20347, we should
+ // send the user's name if they're logged in.
+ $ip = $performingUser->getRequest()->getIP();
+ if ( !$ip ) {
+ return StatusValue::newFatal( 'badipaddress' );
+ }
+
+ Hooks::run( 'User::mailPasswordInternal', [ &$performingUser, &$ip, &$firstUser ] );
+
+ $result = StatusValue::newGood();
+ $reqs = [];
+ foreach ( $users as $user ) {
+ $req = TemporaryPasswordAuthenticationRequest::newRandom();
+ $req->username = $user->getName();
+ $req->mailpassword = true;
+ $req->caller = $performingUser->getName();
+ $status = $this->authManager->allowsAuthenticationDataChange( $req, true );
+ if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
+ $reqs[] = $req;
+ } elseif ( $result->isGood() ) {
+ // only record the first error, to avoid exposing the number of users having the
+ // same email address
+ if ( $status->getValue() === 'ignored' ) {
+ $status = StatusValue::newFatal( 'passwordreset-ignored' );
+ }
+ $result->merge( $status );
+ }
+ }
+
+ $logContext = [
+ 'requestingIp' => $ip,
+ 'requestingUser' => $performingUser->getName(),
+ 'targetUsername' => $username,
+ 'targetEmail' => $email,
+ 'actualUser' => $firstUser->getName(),
+ ];
+
+ if ( !$result->isGood() ) {
+ $this->logger->info(
+ "{requestingUser} attempted password reset of {actualUser} but failed",
+ $logContext + [ 'errors' => $result->getErrors() ]
+ );
+ return $result;
+ }
+
+ $passwords = [];
+ foreach ( $reqs as $req ) {
+ $this->authManager->changeAuthenticationData( $req );
+ }
+
+ $this->logger->info(
+ "{requestingUser} did password reset of {actualUser}",
+ $logContext
+ );
+
+ return StatusValue::newGood( $passwords );
+ }
+
+ /**
+ * Check whether the user is blocked.
+ * Ignores certain types of system blocks that are only meant to force users to log in.
+ * @param User $user
+ * @return bool
+ * @since 1.30
+ */
+ protected function isBlocked( User $user ) {
+ $block = $user->getBlock() ?: $user->getGlobalBlock();
+ if ( !$block ) {
+ return false;
+ }
+ $type = $block->getSystemBlockType();
+ if ( in_array( $type, [ null, 'global-block' ], true ) ) {
+ // Normal block. Maybe it was meant for someone else and the user just needs to log in;
+ // or maybe it was issued specifically to prevent some IP from messing with password
+ // reset? Go out on a limb and use the registration allowed flag to decide.
+ return $block->prevents( 'createaccount' );
+ } elseif ( $type === 'proxy' ) {
+ // we disallow actions through proxy even if the user is logged in
+ // so it makes sense to disallow password resets as well
+ return true;
+ } elseif ( in_array( $type, [ 'dnsbl', 'wgSoftBlockRanges' ], true ) ) {
+ // these are just meant to force login so let's not prevent that
+ return false;
+ } else {
+ // some extension - we'll have to guess
+ return true;
+ }
+ }
+
+ /**
+ * @param string $email
+ * @return User[]
+ * @throws MWException On unexpected database errors
+ */
+ protected function getUsersByEmail( $email ) {
+ $res = wfGetDB( DB_REPLICA )->select(
+ 'user',
+ User::selectFields(),
+ [ 'user_email' => $email ],
+ __METHOD__
+ );
+
+ if ( !$res ) {
+ // Some sort of database error, probably unreachable
+ throw new MWException( 'Unknown database error in ' . __METHOD__ );
+ }
+
+ $users = [];
+ foreach ( $res as $row ) {
+ $users[] = User::newFromRow( $row );
+ }
+ return $users;
+ }
+}
diff --git a/www/wiki/includes/user/User.php b/www/wiki/includes/user/User.php
new file mode 100644
index 00000000..9de30979
--- /dev/null
+++ b/www/wiki/includes/user/User.php
@@ -0,0 +1,5571 @@
+<?php
+/**
+ * Implements the User class for the %MediaWiki software.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use IPSet\IPSet;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Session\SessionManager;
+use MediaWiki\Session\Token;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthenticationRequest;
+use Wikimedia\ScopedCallback;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DBExpectedError;
+
+/**
+ * String Some punctuation to prevent editing from broken text-mangling proxies.
+ * @deprecated since 1.27, use \MediaWiki\Session\Token::SUFFIX
+ * @ingroup Constants
+ */
+define( 'EDIT_TOKEN_SUFFIX', Token::SUFFIX );
+
+/**
+ * The User object encapsulates all of the user-specific settings (user_id,
+ * name, rights, email address, options, last login time). Client
+ * classes use the getXXX() functions to access these fields. These functions
+ * do all the work of determining whether the user is logged in,
+ * whether the requested option can be satisfied from cookies or
+ * whether a database query is needed. Most of the settings needed
+ * for rendering normal pages are set in the cookie to minimize use
+ * of the database.
+ */
+class User implements IDBAccessObject {
+ /**
+ * @const int Number of characters in user_token field.
+ */
+ const TOKEN_LENGTH = 32;
+
+ /**
+ * @const string An invalid value for user_token
+ */
+ const INVALID_TOKEN = '*** INVALID ***';
+
+ /**
+ * Global constant made accessible as class constants so that autoloader
+ * magic can be used.
+ * @deprecated since 1.27, use \MediaWiki\Session\Token::SUFFIX
+ */
+ const EDIT_TOKEN_SUFFIX = EDIT_TOKEN_SUFFIX;
+
+ /**
+ * @const int Serialized record version.
+ */
+ const VERSION = 11;
+
+ /**
+ * Exclude user options that are set to their default value.
+ * @since 1.25
+ */
+ const GETOPTIONS_EXCLUDE_DEFAULTS = 1;
+
+ /**
+ * @since 1.27
+ */
+ const CHECK_USER_RIGHTS = true;
+
+ /**
+ * @since 1.27
+ */
+ const IGNORE_USER_RIGHTS = false;
+
+ /**
+ * Array of Strings List of member variables which are saved to the
+ * shared cache (memcached). Any operation which changes the
+ * corresponding database fields must call a cache-clearing function.
+ * @showinitializer
+ */
+ protected static $mCacheVars = [
+ // user table
+ 'mId',
+ 'mName',
+ 'mRealName',
+ 'mEmail',
+ 'mTouched',
+ 'mToken',
+ 'mEmailAuthenticated',
+ 'mEmailToken',
+ 'mEmailTokenExpires',
+ 'mRegistration',
+ 'mEditCount',
+ // user_groups table
+ 'mGroupMemberships',
+ // user_properties table
+ 'mOptionOverrides',
+ ];
+
+ /**
+ * Array of Strings Core rights.
+ * Each of these should have a corresponding message of the form
+ * "right-$right".
+ * @showinitializer
+ */
+ protected static $mCoreRights = [
+ 'apihighlimits',
+ 'applychangetags',
+ 'autoconfirmed',
+ 'autocreateaccount',
+ 'autopatrol',
+ 'bigdelete',
+ 'block',
+ 'blockemail',
+ 'bot',
+ 'browsearchive',
+ 'changetags',
+ 'createaccount',
+ 'createpage',
+ 'createtalk',
+ 'delete',
+ 'deletechangetags',
+ 'deletedhistory',
+ 'deletedtext',
+ 'deletelogentry',
+ 'deleterevision',
+ 'edit',
+ 'editcontentmodel',
+ 'editinterface',
+ 'editprotected',
+ 'editmyoptions',
+ 'editmyprivateinfo',
+ 'editmyusercss',
+ 'editmyuserjs',
+ 'editmywatchlist',
+ 'editsemiprotected',
+ 'editusercss',
+ 'edituserjs',
+ 'hideuser',
+ 'import',
+ 'importupload',
+ 'ipblock-exempt',
+ 'managechangetags',
+ 'markbotedits',
+ 'mergehistory',
+ 'minoredit',
+ 'move',
+ 'movefile',
+ 'move-categorypages',
+ 'move-rootuserpages',
+ 'move-subpages',
+ 'nominornewtalk',
+ 'noratelimit',
+ 'override-export-depth',
+ 'pagelang',
+ 'patrol',
+ 'patrolmarks',
+ 'protect',
+ 'purge',
+ 'read',
+ 'reupload',
+ 'reupload-own',
+ 'reupload-shared',
+ 'rollback',
+ 'sendemail',
+ 'siteadmin',
+ 'suppressionlog',
+ 'suppressredirect',
+ 'suppressrevision',
+ 'unblockself',
+ 'undelete',
+ 'unwatchedpages',
+ 'upload',
+ 'upload_by_url',
+ 'userrights',
+ 'userrights-interwiki',
+ 'viewmyprivateinfo',
+ 'viewmywatchlist',
+ 'viewsuppressed',
+ 'writeapi',
+ ];
+
+ /**
+ * String Cached results of getAllRights()
+ */
+ protected static $mAllRights = false;
+
+ /** Cache variables */
+ // @{
+ /** @var int */
+ public $mId;
+ /** @var string */
+ public $mName;
+ /** @var string */
+ public $mRealName;
+
+ /** @var string */
+ public $mEmail;
+ /** @var string TS_MW timestamp from the DB */
+ public $mTouched;
+ /** @var string TS_MW timestamp from cache */
+ protected $mQuickTouched;
+ /** @var string */
+ protected $mToken;
+ /** @var string */
+ public $mEmailAuthenticated;
+ /** @var string */
+ protected $mEmailToken;
+ /** @var string */
+ protected $mEmailTokenExpires;
+ /** @var string */
+ protected $mRegistration;
+ /** @var int */
+ protected $mEditCount;
+ /**
+ * @var array No longer used since 1.29; use User::getGroups() instead
+ * @deprecated since 1.29
+ */
+ private $mGroups;
+ /** @var array Associative array of (group name => UserGroupMembership object) */
+ protected $mGroupMemberships;
+ /** @var array */
+ protected $mOptionOverrides;
+ // @}
+
+ /**
+ * Bool Whether the cache variables have been loaded.
+ */
+ // @{
+ public $mOptionsLoaded;
+
+ /**
+ * Array with already loaded items or true if all items have been loaded.
+ */
+ protected $mLoadedItems = [];
+ // @}
+
+ /**
+ * String Initialization data source if mLoadedItems!==true. May be one of:
+ * - 'defaults' anonymous user initialised from class defaults
+ * - 'name' initialise from mName
+ * - 'id' initialise from mId
+ * - 'session' log in from session if possible
+ *
+ * Use the User::newFrom*() family of functions to set this.
+ */
+ public $mFrom;
+
+ /**
+ * Lazy-initialized variables, invalidated with clearInstanceCache
+ */
+ protected $mNewtalk;
+ /** @var string */
+ protected $mDatePreference;
+ /** @var string */
+ public $mBlockedby;
+ /** @var string */
+ protected $mHash;
+ /** @var array */
+ public $mRights;
+ /** @var string */
+ protected $mBlockreason;
+ /** @var array */
+ protected $mEffectiveGroups;
+ /** @var array */
+ protected $mImplicitGroups;
+ /** @var array */
+ protected $mFormerGroups;
+ /** @var Block */
+ protected $mGlobalBlock;
+ /** @var bool */
+ protected $mLocked;
+ /** @var bool */
+ public $mHideName;
+ /** @var array */
+ public $mOptions;
+
+ /** @var WebRequest */
+ private $mRequest;
+
+ /** @var Block */
+ public $mBlock;
+
+ /** @var bool */
+ protected $mAllowUsertalk;
+
+ /** @var Block */
+ private $mBlockedFromCreateAccount = false;
+
+ /** @var int User::READ_* constant bitfield used to load data */
+ protected $queryFlagsUsed = self::READ_NORMAL;
+
+ public static $idCacheByName = [];
+
+ /**
+ * Lightweight constructor for an anonymous user.
+ * Use the User::newFrom* factory functions for other kinds of users.
+ *
+ * @see newFromName()
+ * @see newFromId()
+ * @see newFromConfirmationCode()
+ * @see newFromSession()
+ * @see newFromRow()
+ */
+ public function __construct() {
+ $this->clearInstanceCache( 'defaults' );
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString() {
+ return (string)$this->getName();
+ }
+
+ /**
+ * Test if it's safe to load this User object.
+ *
+ * You should typically check this before using $wgUser or
+ * RequestContext::getUser in a method that might be called before the
+ * system has been fully initialized. If the object is unsafe, you should
+ * use an anonymous user:
+ * \code
+ * $user = $wgUser->isSafeToLoad() ? $wgUser : new User;
+ * \endcode
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function isSafeToLoad() {
+ global $wgFullyInitialised;
+
+ // The user is safe to load if:
+ // * MW_NO_SESSION is undefined AND $wgFullyInitialised is true (safe to use session data)
+ // * mLoadedItems === true (already loaded)
+ // * mFrom !== 'session' (sessions not involved at all)
+
+ return ( !defined( 'MW_NO_SESSION' ) && $wgFullyInitialised ) ||
+ $this->mLoadedItems === true || $this->mFrom !== 'session';
+ }
+
+ /**
+ * Load the user table data for this object from the source given by mFrom.
+ *
+ * @param int $flags User::READ_* constant bitfield
+ */
+ public function load( $flags = self::READ_NORMAL ) {
+ global $wgFullyInitialised;
+
+ if ( $this->mLoadedItems === true ) {
+ return;
+ }
+
+ // Set it now to avoid infinite recursion in accessors
+ $oldLoadedItems = $this->mLoadedItems;
+ $this->mLoadedItems = true;
+ $this->queryFlagsUsed = $flags;
+
+ // If this is called too early, things are likely to break.
+ if ( !$wgFullyInitialised && $this->mFrom === 'session' ) {
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+ ->warning( 'User::loadFromSession called before the end of Setup.php', [
+ 'exception' => new Exception( 'User::loadFromSession called before the end of Setup.php' ),
+ ] );
+ $this->loadDefaults();
+ $this->mLoadedItems = $oldLoadedItems;
+ return;
+ }
+
+ switch ( $this->mFrom ) {
+ case 'defaults':
+ $this->loadDefaults();
+ break;
+ case 'name':
+ // Make sure this thread sees its own changes
+ if ( wfGetLB()->hasOrMadeRecentMasterChanges() ) {
+ $flags |= self::READ_LATEST;
+ $this->queryFlagsUsed = $flags;
+ }
+
+ $this->mId = self::idFromName( $this->mName, $flags );
+ if ( !$this->mId ) {
+ // Nonexistent user placeholder object
+ $this->loadDefaults( $this->mName );
+ } else {
+ $this->loadFromId( $flags );
+ }
+ break;
+ case 'id':
+ $this->loadFromId( $flags );
+ break;
+ case 'session':
+ if ( !$this->loadFromSession() ) {
+ // Loading from session failed. Load defaults.
+ $this->loadDefaults();
+ }
+ Hooks::run( 'UserLoadAfterLoadFromSession', [ $this ] );
+ break;
+ default:
+ throw new UnexpectedValueException(
+ "Unrecognised value for User->mFrom: \"{$this->mFrom}\"" );
+ }
+ }
+
+ /**
+ * Load user table data, given mId has already been set.
+ * @param int $flags User::READ_* constant bitfield
+ * @return bool False if the ID does not exist, true otherwise
+ */
+ public function loadFromId( $flags = self::READ_NORMAL ) {
+ if ( $this->mId == 0 ) {
+ // Anonymous users are not in the database (don't need cache)
+ $this->loadDefaults();
+ return false;
+ }
+
+ // Try cache (unless this needs data from the master DB).
+ // NOTE: if this thread called saveSettings(), the cache was cleared.
+ $latest = DBAccessObjectUtils::hasFlags( $flags, self::READ_LATEST );
+ if ( $latest ) {
+ if ( !$this->loadFromDatabase( $flags ) ) {
+ // Can't load from ID
+ return false;
+ }
+ } else {
+ $this->loadFromCache();
+ }
+
+ $this->mLoadedItems = true;
+ $this->queryFlagsUsed = $flags;
+
+ return true;
+ }
+
+ /**
+ * @since 1.27
+ * @param string $wikiId
+ * @param int $userId
+ */
+ public static function purge( $wikiId, $userId ) {
+ $cache = ObjectCache::getMainWANInstance();
+ $key = $cache->makeGlobalKey( 'user', 'id', $wikiId, $userId );
+ $cache->delete( $key );
+ }
+
+ /**
+ * @since 1.27
+ * @param WANObjectCache $cache
+ * @return string
+ */
+ protected function getCacheKey( WANObjectCache $cache ) {
+ return $cache->makeGlobalKey( 'user', 'id', wfWikiID(), $this->mId );
+ }
+
+ /**
+ * @param WANObjectCache $cache
+ * @return string[]
+ * @since 1.28
+ */
+ public function getMutableCacheKeys( WANObjectCache $cache ) {
+ $id = $this->getId();
+
+ return $id ? [ $this->getCacheKey( $cache ) ] : [];
+ }
+
+ /**
+ * Load user data from shared cache, given mId has already been set.
+ *
+ * @return bool True
+ * @since 1.25
+ */
+ protected function loadFromCache() {
+ $cache = ObjectCache::getMainWANInstance();
+ $data = $cache->getWithSetCallback(
+ $this->getCacheKey( $cache ),
+ $cache::TTL_HOUR,
+ function ( $oldValue, &$ttl, array &$setOpts ) use ( $cache ) {
+ $setOpts += Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) );
+ wfDebug( "User: cache miss for user {$this->mId}\n" );
+
+ $this->loadFromDatabase( self::READ_NORMAL );
+ $this->loadGroups();
+ $this->loadOptions();
+
+ $data = [];
+ foreach ( self::$mCacheVars as $name ) {
+ $data[$name] = $this->$name;
+ }
+
+ $ttl = $cache->adaptiveTTL( wfTimestamp( TS_UNIX, $this->mTouched ), $ttl );
+
+ // if a user group membership is about to expire, the cache needs to
+ // expire at that time (T163691)
+ foreach ( $this->mGroupMemberships as $ugm ) {
+ if ( $ugm->getExpiry() ) {
+ $secondsUntilExpiry = wfTimestamp( TS_UNIX, $ugm->getExpiry() ) - time();
+ if ( $secondsUntilExpiry > 0 && $secondsUntilExpiry < $ttl ) {
+ $ttl = $secondsUntilExpiry;
+ }
+ }
+ }
+
+ return $data;
+ },
+ [ 'pcTTL' => $cache::TTL_PROC_LONG, 'version' => self::VERSION ]
+ );
+
+ // Restore from cache
+ foreach ( self::$mCacheVars as $name ) {
+ $this->$name = $data[$name];
+ }
+
+ return true;
+ }
+
+ /** @name newFrom*() static factory methods */
+ // @{
+
+ /**
+ * Static factory method for creation from username.
+ *
+ * This is slightly less efficient than newFromId(), so use newFromId() if
+ * you have both an ID and a name handy.
+ *
+ * @param string $name Username, validated by Title::newFromText()
+ * @param string|bool $validate Validate username. Takes the same parameters as
+ * User::getCanonicalName(), except that true is accepted as an alias
+ * for 'valid', for BC.
+ *
+ * @return User|bool User object, or false if the username is invalid
+ * (e.g. if it contains illegal characters or is an IP address). If the
+ * username is not present in the database, the result will be a user object
+ * with a name, zero user ID and default settings.
+ */
+ public static function newFromName( $name, $validate = 'valid' ) {
+ if ( $validate === true ) {
+ $validate = 'valid';
+ }
+ $name = self::getCanonicalName( $name, $validate );
+ if ( $name === false ) {
+ return false;
+ } else {
+ // Create unloaded user object
+ $u = new User;
+ $u->mName = $name;
+ $u->mFrom = 'name';
+ $u->setItemLoaded( 'name' );
+ return $u;
+ }
+ }
+
+ /**
+ * Static factory method for creation from a given user ID.
+ *
+ * @param int $id Valid user ID
+ * @return User The corresponding User object
+ */
+ public static function newFromId( $id ) {
+ $u = new User;
+ $u->mId = $id;
+ $u->mFrom = 'id';
+ $u->setItemLoaded( 'id' );
+ return $u;
+ }
+
+ /**
+ * Factory method to fetch whichever user has a given email confirmation code.
+ * This code is generated when an account is created or its e-mail address
+ * has changed.
+ *
+ * If the code is invalid or has expired, returns NULL.
+ *
+ * @param string $code Confirmation code
+ * @param int $flags User::READ_* bitfield
+ * @return User|null
+ */
+ public static function newFromConfirmationCode( $code, $flags = 0 ) {
+ $db = ( $flags & self::READ_LATEST ) == self::READ_LATEST
+ ? wfGetDB( DB_MASTER )
+ : wfGetDB( DB_REPLICA );
+
+ $id = $db->selectField(
+ 'user',
+ 'user_id',
+ [
+ 'user_email_token' => md5( $code ),
+ 'user_email_token_expires > ' . $db->addQuotes( $db->timestamp() ),
+ ]
+ );
+
+ return $id ? self::newFromId( $id ) : null;
+ }
+
+ /**
+ * Create a new user object using data from session. If the login
+ * credentials are invalid, the result is an anonymous user.
+ *
+ * @param WebRequest|null $request Object to use; $wgRequest will be used if omitted.
+ * @return User
+ */
+ public static function newFromSession( WebRequest $request = null ) {
+ $user = new User;
+ $user->mFrom = 'session';
+ $user->mRequest = $request;
+ return $user;
+ }
+
+ /**
+ * Create a new user object from a user row.
+ * The row should have the following fields from the user table in it:
+ * - either user_name or user_id to load further data if needed (or both)
+ * - user_real_name
+ * - all other fields (email, etc.)
+ * It is useless to provide the remaining fields if either user_id,
+ * user_name and user_real_name are not provided because the whole row
+ * will be loaded once more from the database when accessing them.
+ *
+ * @param stdClass $row A row from the user table
+ * @param array $data Further data to load into the object (see User::loadFromRow for valid keys)
+ * @return User
+ */
+ public static function newFromRow( $row, $data = null ) {
+ $user = new User;
+ $user->loadFromRow( $row, $data );
+ return $user;
+ }
+
+ /**
+ * Static factory method for creation of a "system" user from username.
+ *
+ * A "system" user is an account that's used to attribute logged actions
+ * taken by MediaWiki itself, as opposed to a bot or human user. Examples
+ * might include the 'Maintenance script' or 'Conversion script' accounts
+ * used by various scripts in the maintenance/ directory or accounts such
+ * as 'MediaWiki message delivery' used by the MassMessage extension.
+ *
+ * This can optionally create the user if it doesn't exist, and "steal" the
+ * account if it does exist.
+ *
+ * "Stealing" an existing user is intended to make it impossible for normal
+ * authentication processes to use the account, effectively disabling the
+ * account for normal use:
+ * - Email is invalidated, to prevent account recovery by emailing a
+ * temporary password and to disassociate the account from the existing
+ * human.
+ * - The token is set to a magic invalid value, to kill existing sessions
+ * and to prevent $this->setToken() calls from resetting the token to a
+ * valid value.
+ * - SessionManager is instructed to prevent new sessions for the user, to
+ * do things like deauthorizing OAuth consumers.
+ * - AuthManager is instructed to revoke access, to invalidate or remove
+ * passwords and other credentials.
+ *
+ * @param string $name Username
+ * @param array $options Options are:
+ * - validate: As for User::getCanonicalName(), default 'valid'
+ * - create: Whether to create the user if it doesn't already exist, default true
+ * - steal: Whether to "disable" the account for normal use if it already
+ * exists, default false
+ * @return User|null
+ * @since 1.27
+ */
+ public static function newSystemUser( $name, $options = [] ) {
+ $options += [
+ 'validate' => 'valid',
+ 'create' => true,
+ 'steal' => false,
+ ];
+
+ $name = self::getCanonicalName( $name, $options['validate'] );
+ if ( $name === false ) {
+ return null;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ 'user',
+ self::selectFields(),
+ [ 'user_name' => $name ],
+ __METHOD__
+ );
+ if ( !$row ) {
+ // Try the master database...
+ $dbw = wfGetDB( DB_MASTER );
+ $row = $dbw->selectRow(
+ 'user',
+ self::selectFields(),
+ [ 'user_name' => $name ],
+ __METHOD__
+ );
+ }
+
+ if ( !$row ) {
+ // No user. Create it?
+ return $options['create']
+ ? self::createNew( $name, [ 'token' => self::INVALID_TOKEN ] )
+ : null;
+ }
+
+ $user = self::newFromRow( $row );
+
+ // A user is considered to exist as a non-system user if it can
+ // authenticate, or has an email set, or has a non-invalid token.
+ if ( $user->mEmail || $user->mToken !== self::INVALID_TOKEN ||
+ AuthManager::singleton()->userCanAuthenticate( $name )
+ ) {
+ // User exists. Steal it?
+ if ( !$options['steal'] ) {
+ return null;
+ }
+
+ AuthManager::singleton()->revokeAccessForUser( $name );
+
+ $user->invalidateEmail();
+ $user->mToken = self::INVALID_TOKEN;
+ $user->saveSettings();
+ SessionManager::singleton()->preventSessionsForUser( $user->getName() );
+ }
+
+ return $user;
+ }
+
+ // @}
+
+ /**
+ * Get the username corresponding to a given user ID
+ * @param int $id User ID
+ * @return string|bool The corresponding username
+ */
+ public static function whoIs( $id ) {
+ return UserCache::singleton()->getProp( $id, 'name' );
+ }
+
+ /**
+ * Get the real name of a user given their user ID
+ *
+ * @param int $id User ID
+ * @return string|bool The corresponding user's real name
+ */
+ public static function whoIsReal( $id ) {
+ return UserCache::singleton()->getProp( $id, 'real_name' );
+ }
+
+ /**
+ * Get database id given a user name
+ * @param string $name Username
+ * @param int $flags User::READ_* constant bitfield
+ * @return int|null The corresponding user's ID, or null if user is nonexistent
+ */
+ public static function idFromName( $name, $flags = self::READ_NORMAL ) {
+ $nt = Title::makeTitleSafe( NS_USER, $name );
+ if ( is_null( $nt ) ) {
+ // Illegal name
+ return null;
+ }
+
+ if ( !( $flags & self::READ_LATEST ) && isset( self::$idCacheByName[$name] ) ) {
+ return self::$idCacheByName[$name];
+ }
+
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
+ $db = wfGetDB( $index );
+
+ $s = $db->selectRow(
+ 'user',
+ [ 'user_id' ],
+ [ 'user_name' => $nt->getText() ],
+ __METHOD__,
+ $options
+ );
+
+ if ( $s === false ) {
+ $result = null;
+ } else {
+ $result = $s->user_id;
+ }
+
+ self::$idCacheByName[$name] = $result;
+
+ if ( count( self::$idCacheByName ) > 1000 ) {
+ self::$idCacheByName = [];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Reset the cache used in idFromName(). For use in tests.
+ */
+ public static function resetIdByNameCache() {
+ self::$idCacheByName = [];
+ }
+
+ /**
+ * Does the string match an anonymous IP address?
+ *
+ * This function exists for username validation, in order to reject
+ * usernames which are similar in form to IP addresses. Strings such
+ * as 300.300.300.300 will return true because it looks like an IP
+ * address, despite not being strictly valid.
+ *
+ * We match "\d{1,3}\.\d{1,3}\.\d{1,3}\.xxx" as an anonymous IP
+ * address because the usemod software would "cloak" anonymous IP
+ * addresses like this, if we allowed accounts like this to be created
+ * new users could get the old edits of these anonymous users.
+ *
+ * @param string $name Name to match
+ * @return bool
+ */
+ public static function isIP( $name ) {
+ return preg_match( '/^\d{1,3}\.\d{1,3}\.\d{1,3}\.(?:xxx|\d{1,3})$/', $name )
+ || IP::isIPv6( $name );
+ }
+
+ /**
+ * Is the user an IP range?
+ *
+ * @since 1.30
+ * @return bool
+ */
+ public function isIPRange() {
+ return IP::isValidRange( $this->mName );
+ }
+
+ /**
+ * Is the input a valid username?
+ *
+ * Checks if the input is a valid username, we don't want an empty string,
+ * an IP address, anything that contains slashes (would mess up subpages),
+ * is longer than the maximum allowed username size or doesn't begin with
+ * a capital letter.
+ *
+ * @param string $name Name to match
+ * @return bool
+ */
+ public static function isValidUserName( $name ) {
+ global $wgContLang, $wgMaxNameChars;
+
+ if ( $name == ''
+ || self::isIP( $name )
+ || strpos( $name, '/' ) !== false
+ || strlen( $name ) > $wgMaxNameChars
+ || $name != $wgContLang->ucfirst( $name )
+ ) {
+ return false;
+ }
+
+ // Ensure that the name can't be misresolved as a different title,
+ // such as with extra namespace keys at the start.
+ $parsed = Title::newFromText( $name );
+ if ( is_null( $parsed )
+ || $parsed->getNamespace()
+ || strcmp( $name, $parsed->getPrefixedText() ) ) {
+ return false;
+ }
+
+ // Check an additional blacklist of troublemaker characters.
+ // Should these be merged into the title char list?
+ $unicodeBlacklist = '/[' .
+ '\x{0080}-\x{009f}' . # iso-8859-1 control chars
+ '\x{00a0}' . # non-breaking space
+ '\x{2000}-\x{200f}' . # various whitespace
+ '\x{2028}-\x{202f}' . # breaks and control chars
+ '\x{3000}' . # ideographic space
+ '\x{e000}-\x{f8ff}' . # private use
+ ']/u';
+ if ( preg_match( $unicodeBlacklist, $name ) ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Usernames which fail to pass this function will be blocked
+ * from user login and new account registrations, but may be used
+ * internally by batch processes.
+ *
+ * If an account already exists in this form, login will be blocked
+ * by a failure to pass this function.
+ *
+ * @param string $name Name to match
+ * @return bool
+ */
+ public static function isUsableName( $name ) {
+ global $wgReservedUsernames;
+ // Must be a valid username, obviously ;)
+ if ( !self::isValidUserName( $name ) ) {
+ return false;
+ }
+
+ static $reservedUsernames = false;
+ if ( !$reservedUsernames ) {
+ $reservedUsernames = $wgReservedUsernames;
+ Hooks::run( 'UserGetReservedNames', [ &$reservedUsernames ] );
+ }
+
+ // Certain names may be reserved for batch processes.
+ foreach ( $reservedUsernames as $reserved ) {
+ if ( substr( $reserved, 0, 4 ) == 'msg:' ) {
+ $reserved = wfMessage( substr( $reserved, 4 ) )->inContentLanguage()->text();
+ }
+ if ( $reserved == $name ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Return the users who are members of the given group(s). In case of multiple groups,
+ * users who are members of at least one of them are returned.
+ *
+ * @param string|array $groups A single group name or an array of group names
+ * @param int $limit Max number of users to return. The actual limit will never exceed 5000
+ * records; larger values are ignored.
+ * @param int $after ID the user to start after
+ * @return UserArrayFromResult
+ */
+ public static function findUsersByGroup( $groups, $limit = 5000, $after = null ) {
+ if ( $groups === [] ) {
+ return UserArrayFromResult::newFromIDs( [] );
+ }
+
+ $groups = array_unique( (array)$groups );
+ $limit = min( 5000, $limit );
+
+ $conds = [ 'ug_group' => $groups ];
+ if ( $after !== null ) {
+ $conds[] = 'ug_user > ' . (int)$after;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $ids = $dbr->selectFieldValues(
+ 'user_groups',
+ 'ug_user',
+ $conds,
+ __METHOD__,
+ [
+ 'DISTINCT' => true,
+ 'ORDER BY' => 'ug_user',
+ 'LIMIT' => $limit,
+ ]
+ ) ?: [];
+ return UserArray::newFromIDs( $ids );
+ }
+
+ /**
+ * Usernames which fail to pass this function will be blocked
+ * from new account registrations, but may be used internally
+ * either by batch processes or by user accounts which have
+ * already been created.
+ *
+ * Additional blacklisting may be added here rather than in
+ * isValidUserName() to avoid disrupting existing accounts.
+ *
+ * @param string $name String to match
+ * @return bool
+ */
+ public static function isCreatableName( $name ) {
+ global $wgInvalidUsernameCharacters;
+
+ // Ensure that the username isn't longer than 235 bytes, so that
+ // (at least for the builtin skins) user javascript and css files
+ // will work. (T25080)
+ if ( strlen( $name ) > 235 ) {
+ wfDebugLog( 'username', __METHOD__ .
+ ": '$name' invalid due to length" );
+ return false;
+ }
+
+ // Preg yells if you try to give it an empty string
+ if ( $wgInvalidUsernameCharacters !== '' ) {
+ if ( preg_match( '/[' . preg_quote( $wgInvalidUsernameCharacters, '/' ) . ']/', $name ) ) {
+ wfDebugLog( 'username', __METHOD__ .
+ ": '$name' invalid due to wgInvalidUsernameCharacters" );
+ return false;
+ }
+ }
+
+ return self::isUsableName( $name );
+ }
+
+ /**
+ * Is the input a valid password for this user?
+ *
+ * @param string $password Desired password
+ * @return bool
+ */
+ public function isValidPassword( $password ) {
+ // simple boolean wrapper for getPasswordValidity
+ return $this->getPasswordValidity( $password ) === true;
+ }
+
+ /**
+ * Given unvalidated password input, return error message on failure.
+ *
+ * @param string $password Desired password
+ * @return bool|string|array True on success, string or array of error message on failure
+ */
+ public function getPasswordValidity( $password ) {
+ $result = $this->checkPasswordValidity( $password );
+ if ( $result->isGood() ) {
+ return true;
+ } else {
+ $messages = [];
+ foreach ( $result->getErrorsByType( 'error' ) as $error ) {
+ $messages[] = $error['message'];
+ }
+ foreach ( $result->getErrorsByType( 'warning' ) as $warning ) {
+ $messages[] = $warning['message'];
+ }
+ if ( count( $messages ) === 1 ) {
+ return $messages[0];
+ }
+ return $messages;
+ }
+ }
+
+ /**
+ * Check if this is a valid password for this user
+ *
+ * Create a Status object based on the password's validity.
+ * The Status should be set to fatal if the user should not
+ * be allowed to log in, and should have any errors that
+ * would block changing the password.
+ *
+ * If the return value of this is not OK, the password
+ * should not be checked. If the return value is not Good,
+ * the password can be checked, but the user should not be
+ * able to set their password to this.
+ *
+ * @param string $password Desired password
+ * @return Status
+ * @since 1.23
+ */
+ public function checkPasswordValidity( $password ) {
+ global $wgPasswordPolicy;
+
+ $upp = new UserPasswordPolicy(
+ $wgPasswordPolicy['policies'],
+ $wgPasswordPolicy['checks']
+ );
+
+ $status = Status::newGood();
+ $result = false; // init $result to false for the internal checks
+
+ if ( !Hooks::run( 'isValidPassword', [ $password, &$result, $this ] ) ) {
+ $status->error( $result );
+ return $status;
+ }
+
+ if ( $result === false ) {
+ $status->merge( $upp->checkUserPassword( $this, $password ) );
+ return $status;
+ } elseif ( $result === true ) {
+ return $status;
+ } else {
+ $status->error( $result );
+ return $status; // the isValidPassword hook set a string $result and returned true
+ }
+ }
+
+ /**
+ * Given unvalidated user input, return a canonical username, or false if
+ * the username is invalid.
+ * @param string $name User input
+ * @param string|bool $validate Type of validation to use:
+ * - false No validation
+ * - 'valid' Valid for batch processes
+ * - 'usable' Valid for batch processes and login
+ * - 'creatable' Valid for batch processes, login and account creation
+ *
+ * @throws InvalidArgumentException
+ * @return bool|string
+ */
+ public static function getCanonicalName( $name, $validate = 'valid' ) {
+ // Force usernames to capital
+ global $wgContLang;
+ $name = $wgContLang->ucfirst( $name );
+
+ # Reject names containing '#'; these will be cleaned up
+ # with title normalisation, but then it's too late to
+ # check elsewhere
+ if ( strpos( $name, '#' ) !== false ) {
+ return false;
+ }
+
+ // Clean up name according to title rules,
+ // but only when validation is requested (T14654)
+ $t = ( $validate !== false ) ?
+ Title::newFromText( $name, NS_USER ) : Title::makeTitle( NS_USER, $name );
+ // Check for invalid titles
+ if ( is_null( $t ) || $t->getNamespace() !== NS_USER || $t->isExternal() ) {
+ return false;
+ }
+
+ // Reject various classes of invalid names
+ $name = AuthManager::callLegacyAuthPlugin(
+ 'getCanonicalName', [ $t->getText() ], $t->getText()
+ );
+
+ switch ( $validate ) {
+ case false:
+ break;
+ case 'valid':
+ if ( !self::isValidUserName( $name ) ) {
+ $name = false;
+ }
+ break;
+ case 'usable':
+ if ( !self::isUsableName( $name ) ) {
+ $name = false;
+ }
+ break;
+ case 'creatable':
+ if ( !self::isCreatableName( $name ) ) {
+ $name = false;
+ }
+ break;
+ default:
+ throw new InvalidArgumentException(
+ 'Invalid parameter value for $validate in ' . __METHOD__ );
+ }
+ return $name;
+ }
+
+ /**
+ * Return a random password.
+ *
+ * @deprecated since 1.27, use PasswordFactory::generateRandomPasswordString()
+ * @return string New random password
+ */
+ public static function randomPassword() {
+ global $wgMinimalPasswordLength;
+ return PasswordFactory::generateRandomPasswordString( $wgMinimalPasswordLength );
+ }
+
+ /**
+ * Set cached properties to default.
+ *
+ * @note This no longer clears uncached lazy-initialised properties;
+ * the constructor does that instead.
+ *
+ * @param string|bool $name
+ */
+ public function loadDefaults( $name = false ) {
+ $this->mId = 0;
+ $this->mName = $name;
+ $this->mRealName = '';
+ $this->mEmail = '';
+ $this->mOptionOverrides = null;
+ $this->mOptionsLoaded = false;
+
+ $loggedOut = $this->mRequest && !defined( 'MW_NO_SESSION' )
+ ? $this->mRequest->getSession()->getLoggedOutTimestamp() : 0;
+ if ( $loggedOut !== 0 ) {
+ $this->mTouched = wfTimestamp( TS_MW, $loggedOut );
+ } else {
+ $this->mTouched = '1'; # Allow any pages to be cached
+ }
+
+ $this->mToken = null; // Don't run cryptographic functions till we need a token
+ $this->mEmailAuthenticated = null;
+ $this->mEmailToken = '';
+ $this->mEmailTokenExpires = null;
+ $this->mRegistration = wfTimestamp( TS_MW );
+ $this->mGroupMemberships = [];
+
+ Hooks::run( 'UserLoadDefaults', [ $this, $name ] );
+ }
+
+ /**
+ * Return whether an item has been loaded.
+ *
+ * @param string $item Item to check. Current possibilities:
+ * - id
+ * - name
+ * - realname
+ * @param string $all 'all' to check if the whole object has been loaded
+ * or any other string to check if only the item is available (e.g.
+ * for optimisation)
+ * @return bool
+ */
+ public function isItemLoaded( $item, $all = 'all' ) {
+ return ( $this->mLoadedItems === true && $all === 'all' ) ||
+ ( isset( $this->mLoadedItems[$item] ) && $this->mLoadedItems[$item] === true );
+ }
+
+ /**
+ * Set that an item has been loaded
+ *
+ * @param string $item
+ */
+ protected function setItemLoaded( $item ) {
+ if ( is_array( $this->mLoadedItems ) ) {
+ $this->mLoadedItems[$item] = true;
+ }
+ }
+
+ /**
+ * Load user data from the session.
+ *
+ * @return bool True if the user is logged in, false otherwise.
+ */
+ private function loadFromSession() {
+ // Deprecated hook
+ $result = null;
+ Hooks::run( 'UserLoadFromSession', [ $this, &$result ], '1.27' );
+ if ( $result !== null ) {
+ return $result;
+ }
+
+ // MediaWiki\Session\Session already did the necessary authentication of the user
+ // returned here, so just use it if applicable.
+ $session = $this->getRequest()->getSession();
+ $user = $session->getUser();
+ if ( $user->isLoggedIn() ) {
+ $this->loadFromUserObject( $user );
+
+ // If this user is autoblocked, set a cookie to track the Block. This has to be done on
+ // every session load, because an autoblocked editor might not edit again from the same
+ // IP address after being blocked.
+ $config = RequestContext::getMain()->getConfig();
+ if ( $config->get( 'CookieSetOnAutoblock' ) === true ) {
+ $block = $this->getBlock();
+ $shouldSetCookie = $this->getRequest()->getCookie( 'BlockID' ) === null
+ && $block
+ && $block->getType() === Block::TYPE_USER
+ && $block->isAutoblocking();
+ if ( $shouldSetCookie ) {
+ wfDebug( __METHOD__ . ': User is autoblocked, setting cookie to track' );
+ $block->setCookie( $this->getRequest()->response() );
+ }
+ }
+
+ // Other code expects these to be set in the session, so set them.
+ $session->set( 'wsUserID', $this->getId() );
+ $session->set( 'wsUserName', $this->getName() );
+ $session->set( 'wsToken', $this->getToken() );
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Load user and user_group data from the database.
+ * $this->mId must be set, this is how the user is identified.
+ *
+ * @param int $flags User::READ_* constant bitfield
+ * @return bool True if the user exists, false if the user is anonymous
+ */
+ public function loadFromDatabase( $flags = self::READ_LATEST ) {
+ // Paranoia
+ $this->mId = intval( $this->mId );
+
+ if ( !$this->mId ) {
+ // Anonymous users are not in the database
+ $this->loadDefaults();
+ return false;
+ }
+
+ list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
+ $db = wfGetDB( $index );
+
+ $s = $db->selectRow(
+ 'user',
+ self::selectFields(),
+ [ 'user_id' => $this->mId ],
+ __METHOD__,
+ $options
+ );
+
+ $this->queryFlagsUsed = $flags;
+ Hooks::run( 'UserLoadFromDatabase', [ $this, &$s ] );
+
+ if ( $s !== false ) {
+ // Initialise user table data
+ $this->loadFromRow( $s );
+ $this->mGroupMemberships = null; // deferred
+ $this->getEditCount(); // revalidation for nulls
+ return true;
+ } else {
+ // Invalid user_id
+ $this->mId = 0;
+ $this->loadDefaults();
+ return false;
+ }
+ }
+
+ /**
+ * Initialize this object from a row from the user table.
+ *
+ * @param stdClass $row Row from the user table to load.
+ * @param array $data Further user data to load into the object
+ *
+ * user_groups Array of arrays or stdClass result rows out of the user_groups
+ * table. Previously you were supposed to pass an array of strings
+ * here, but we also need expiry info nowadays, so an array of
+ * strings is ignored.
+ * user_properties Array with properties out of the user_properties table
+ */
+ protected function loadFromRow( $row, $data = null ) {
+ $all = true;
+
+ $this->mGroupMemberships = null; // deferred
+
+ if ( isset( $row->user_name ) ) {
+ $this->mName = $row->user_name;
+ $this->mFrom = 'name';
+ $this->setItemLoaded( 'name' );
+ } else {
+ $all = false;
+ }
+
+ if ( isset( $row->user_real_name ) ) {
+ $this->mRealName = $row->user_real_name;
+ $this->setItemLoaded( 'realname' );
+ } else {
+ $all = false;
+ }
+
+ if ( isset( $row->user_id ) ) {
+ $this->mId = intval( $row->user_id );
+ $this->mFrom = 'id';
+ $this->setItemLoaded( 'id' );
+ } else {
+ $all = false;
+ }
+
+ if ( isset( $row->user_id ) && isset( $row->user_name ) ) {
+ self::$idCacheByName[$row->user_name] = $row->user_id;
+ }
+
+ if ( isset( $row->user_editcount ) ) {
+ $this->mEditCount = $row->user_editcount;
+ } else {
+ $all = false;
+ }
+
+ if ( isset( $row->user_touched ) ) {
+ $this->mTouched = wfTimestamp( TS_MW, $row->user_touched );
+ } else {
+ $all = false;
+ }
+
+ if ( isset( $row->user_token ) ) {
+ // The definition for the column is binary(32), so trim the NULs
+ // that appends. The previous definition was char(32), so trim
+ // spaces too.
+ $this->mToken = rtrim( $row->user_token, " \0" );
+ if ( $this->mToken === '' ) {
+ $this->mToken = null;
+ }
+ } else {
+ $all = false;
+ }
+
+ if ( isset( $row->user_email ) ) {
+ $this->mEmail = $row->user_email;
+ $this->mEmailAuthenticated = wfTimestampOrNull( TS_MW, $row->user_email_authenticated );
+ $this->mEmailToken = $row->user_email_token;
+ $this->mEmailTokenExpires = wfTimestampOrNull( TS_MW, $row->user_email_token_expires );
+ $this->mRegistration = wfTimestampOrNull( TS_MW, $row->user_registration );
+ } else {
+ $all = false;
+ }
+
+ if ( $all ) {
+ $this->mLoadedItems = true;
+ }
+
+ if ( is_array( $data ) ) {
+ if ( isset( $data['user_groups'] ) && is_array( $data['user_groups'] ) ) {
+ if ( !count( $data['user_groups'] ) ) {
+ $this->mGroupMemberships = [];
+ } else {
+ $firstGroup = reset( $data['user_groups'] );
+ if ( is_array( $firstGroup ) || is_object( $firstGroup ) ) {
+ $this->mGroupMemberships = [];
+ foreach ( $data['user_groups'] as $row ) {
+ $ugm = UserGroupMembership::newFromRow( (object)$row );
+ $this->mGroupMemberships[$ugm->getGroup()] = $ugm;
+ }
+ }
+ }
+ }
+ if ( isset( $data['user_properties'] ) && is_array( $data['user_properties'] ) ) {
+ $this->loadOptions( $data['user_properties'] );
+ }
+ }
+ }
+
+ /**
+ * Load the data for this user object from another user object.
+ *
+ * @param User $user
+ */
+ protected function loadFromUserObject( $user ) {
+ $user->load();
+ foreach ( self::$mCacheVars as $var ) {
+ $this->$var = $user->$var;
+ }
+ }
+
+ /**
+ * Load the groups from the database if they aren't already loaded.
+ */
+ private function loadGroups() {
+ if ( is_null( $this->mGroupMemberships ) ) {
+ $db = ( $this->queryFlagsUsed & self::READ_LATEST )
+ ? wfGetDB( DB_MASTER )
+ : wfGetDB( DB_REPLICA );
+ $this->mGroupMemberships = UserGroupMembership::getMembershipsForUser(
+ $this->mId, $db );
+ }
+ }
+
+ /**
+ * Add the user to the group if he/she meets given criteria.
+ *
+ * Contrary to autopromotion by \ref $wgAutopromote, the group will be
+ * possible to remove manually via Special:UserRights. In such case it
+ * will not be re-added automatically. The user will also not lose the
+ * group if they no longer meet the criteria.
+ *
+ * @param string $event Key in $wgAutopromoteOnce (each one has groups/criteria)
+ *
+ * @return array Array of groups the user has been promoted to.
+ *
+ * @see $wgAutopromoteOnce
+ */
+ public function addAutopromoteOnceGroups( $event ) {
+ global $wgAutopromoteOnceLogInRC;
+
+ if ( wfReadOnly() || !$this->getId() ) {
+ return [];
+ }
+
+ $toPromote = Autopromote::getAutopromoteOnceGroups( $this, $event );
+ if ( !count( $toPromote ) ) {
+ return [];
+ }
+
+ if ( !$this->checkAndSetTouched() ) {
+ return []; // raced out (bug T48834)
+ }
+
+ $oldGroups = $this->getGroups(); // previous groups
+ foreach ( $toPromote as $group ) {
+ $this->addGroup( $group );
+ }
+ // update groups in external authentication database
+ Hooks::run( 'UserGroupsChanged', [ $this, $toPromote, [], false, false ] );
+ AuthManager::callLegacyAuthPlugin( 'updateExternalDBGroups', [ $this, $toPromote ] );
+
+ $newGroups = array_merge( $oldGroups, $toPromote ); // all groups
+
+ $logEntry = new ManualLogEntry( 'rights', 'autopromote' );
+ $logEntry->setPerformer( $this );
+ $logEntry->setTarget( $this->getUserPage() );
+ $logEntry->setParameters( [
+ '4::oldgroups' => $oldGroups,
+ '5::newgroups' => $newGroups,
+ ] );
+ $logid = $logEntry->insert();
+ if ( $wgAutopromoteOnceLogInRC ) {
+ $logEntry->publish( $logid );
+ }
+
+ return $toPromote;
+ }
+
+ /**
+ * Builds update conditions. Additional conditions may be added to $conditions to
+ * protected against race conditions using a compare-and-set (CAS) mechanism
+ * based on comparing $this->mTouched with the user_touched field.
+ *
+ * @param Database $db
+ * @param array $conditions WHERE conditions for use with Database::update
+ * @return array WHERE conditions for use with Database::update
+ */
+ protected function makeUpdateConditions( Database $db, array $conditions ) {
+ if ( $this->mTouched ) {
+ // CAS check: only update if the row wasn't changed sicne it was loaded.
+ $conditions['user_touched'] = $db->timestamp( $this->mTouched );
+ }
+
+ return $conditions;
+ }
+
+ /**
+ * Bump user_touched if it didn't change since this object was loaded
+ *
+ * On success, the mTouched field is updated.
+ * The user serialization cache is always cleared.
+ *
+ * @return bool Whether user_touched was actually updated
+ * @since 1.26
+ */
+ protected function checkAndSetTouched() {
+ $this->load();
+
+ if ( !$this->mId ) {
+ return false; // anon
+ }
+
+ // Get a new user_touched that is higher than the old one
+ $newTouched = $this->newTouchedTimestamp();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update( 'user',
+ [ 'user_touched' => $dbw->timestamp( $newTouched ) ],
+ $this->makeUpdateConditions( $dbw, [
+ 'user_id' => $this->mId,
+ ] ),
+ __METHOD__
+ );
+ $success = ( $dbw->affectedRows() > 0 );
+
+ if ( $success ) {
+ $this->mTouched = $newTouched;
+ $this->clearSharedCache();
+ } else {
+ // Clears on failure too since that is desired if the cache is stale
+ $this->clearSharedCache( 'refresh' );
+ }
+
+ return $success;
+ }
+
+ /**
+ * Clear various cached data stored in this object. The cache of the user table
+ * data (i.e. self::$mCacheVars) is not cleared unless $reloadFrom is given.
+ *
+ * @param bool|string $reloadFrom Reload user and user_groups table data from a
+ * given source. May be "name", "id", "defaults", "session", or false for no reload.
+ */
+ public function clearInstanceCache( $reloadFrom = false ) {
+ $this->mNewtalk = -1;
+ $this->mDatePreference = null;
+ $this->mBlockedby = -1; # Unset
+ $this->mHash = false;
+ $this->mRights = null;
+ $this->mEffectiveGroups = null;
+ $this->mImplicitGroups = null;
+ $this->mGroupMemberships = null;
+ $this->mOptions = null;
+ $this->mOptionsLoaded = false;
+ $this->mEditCount = null;
+
+ if ( $reloadFrom ) {
+ $this->mLoadedItems = [];
+ $this->mFrom = $reloadFrom;
+ }
+ }
+
+ /**
+ * Combine the language default options with any site-specific options
+ * and add the default language variants.
+ *
+ * @return array Array of String options
+ */
+ public static function getDefaultOptions() {
+ global $wgNamespacesToBeSearchedDefault, $wgDefaultUserOptions, $wgContLang, $wgDefaultSkin;
+
+ static $defOpt = null;
+ static $defOptLang = null;
+
+ if ( $defOpt !== null && $defOptLang === $wgContLang->getCode() ) {
+ // $wgContLang does not change (and should not change) mid-request,
+ // but the unit tests change it anyway, and expect this method to
+ // return values relevant to the current $wgContLang.
+ return $defOpt;
+ }
+
+ $defOpt = $wgDefaultUserOptions;
+ // Default language setting
+ $defOptLang = $wgContLang->getCode();
+ $defOpt['language'] = $defOptLang;
+ foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
+ $defOpt[$langCode == $wgContLang->getCode() ? 'variant' : "variant-$langCode"] = $langCode;
+ }
+
+ // NOTE: don't use SearchEngineConfig::getSearchableNamespaces here,
+ // since extensions may change the set of searchable namespaces depending
+ // on user groups/permissions.
+ foreach ( $wgNamespacesToBeSearchedDefault as $nsnum => $val ) {
+ $defOpt['searchNs' . $nsnum] = (bool)$val;
+ }
+ $defOpt['skin'] = Skin::normalizeKey( $wgDefaultSkin );
+
+ Hooks::run( 'UserGetDefaultOptions', [ &$defOpt ] );
+
+ return $defOpt;
+ }
+
+ /**
+ * Get a given default option value.
+ *
+ * @param string $opt Name of option to retrieve
+ * @return string Default option value
+ */
+ public static function getDefaultOption( $opt ) {
+ $defOpts = self::getDefaultOptions();
+ if ( isset( $defOpts[$opt] ) ) {
+ return $defOpts[$opt];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Get blocking information
+ * @param bool $bFromSlave Whether to check the replica DB first.
+ * To improve performance, non-critical checks are done against replica DBs.
+ * Check when actually saving should be done against master.
+ */
+ private function getBlockedStatus( $bFromSlave = true ) {
+ global $wgProxyWhitelist, $wgUser, $wgApplyIpBlocksToXff, $wgSoftBlockRanges;
+
+ if ( -1 != $this->mBlockedby ) {
+ return;
+ }
+
+ wfDebug( __METHOD__ . ": checking...\n" );
+
+ // Initialize data...
+ // Otherwise something ends up stomping on $this->mBlockedby when
+ // things get lazy-loaded later, causing false positive block hits
+ // due to -1 !== 0. Probably session-related... Nothing should be
+ // overwriting mBlockedby, surely?
+ $this->load();
+
+ # We only need to worry about passing the IP address to the Block generator if the
+ # user is not immune to autoblocks/hardblocks, and they are the current user so we
+ # know which IP address they're actually coming from
+ $ip = null;
+ if ( !$this->isAllowed( 'ipblock-exempt' ) ) {
+ // $wgUser->getName() only works after the end of Setup.php. Until
+ // then, assume it's a logged-out user.
+ $globalUserName = $wgUser->isSafeToLoad()
+ ? $wgUser->getName()
+ : IP::sanitizeIP( $wgUser->getRequest()->getIP() );
+ if ( $this->getName() === $globalUserName ) {
+ $ip = $this->getRequest()->getIP();
+ }
+ }
+
+ // User/IP blocking
+ $block = Block::newFromTarget( $this, $ip, !$bFromSlave );
+
+ // Cookie blocking
+ if ( !$block instanceof Block ) {
+ $block = $this->getBlockFromCookieValue( $this->getRequest()->getCookie( 'BlockID' ) );
+ }
+
+ // Proxy blocking
+ if ( !$block instanceof Block && $ip !== null && !in_array( $ip, $wgProxyWhitelist ) ) {
+ // Local list
+ if ( self::isLocallyBlockedProxy( $ip ) ) {
+ $block = new Block( [
+ 'byText' => wfMessage( 'proxyblocker' )->text(),
+ 'reason' => wfMessage( 'proxyblockreason' )->text(),
+ 'address' => $ip,
+ 'systemBlock' => 'proxy',
+ ] );
+ } elseif ( $this->isAnon() && $this->isDnsBlacklisted( $ip ) ) {
+ $block = new Block( [
+ 'byText' => wfMessage( 'sorbs' )->text(),
+ 'reason' => wfMessage( 'sorbsreason' )->text(),
+ 'address' => $ip,
+ 'systemBlock' => 'dnsbl',
+ ] );
+ }
+ }
+
+ // (T25343) Apply IP blocks to the contents of XFF headers, if enabled
+ if ( !$block instanceof Block
+ && $wgApplyIpBlocksToXff
+ && $ip !== null
+ && !in_array( $ip, $wgProxyWhitelist )
+ ) {
+ $xff = $this->getRequest()->getHeader( 'X-Forwarded-For' );
+ $xff = array_map( 'trim', explode( ',', $xff ) );
+ $xff = array_diff( $xff, [ $ip ] );
+ $xffblocks = Block::getBlocksForIPList( $xff, $this->isAnon(), !$bFromSlave );
+ $block = Block::chooseBlock( $xffblocks, $xff );
+ if ( $block instanceof Block ) {
+ # Mangle the reason to alert the user that the block
+ # originated from matching the X-Forwarded-For header.
+ $block->mReason = wfMessage( 'xffblockreason', $block->mReason )->text();
+ }
+ }
+
+ if ( !$block instanceof Block
+ && $ip !== null
+ && $this->isAnon()
+ && IP::isInRanges( $ip, $wgSoftBlockRanges )
+ ) {
+ $block = new Block( [
+ 'address' => $ip,
+ 'byText' => 'MediaWiki default',
+ 'reason' => wfMessage( 'softblockrangesreason', $ip )->text(),
+ 'anonOnly' => true,
+ 'systemBlock' => 'wgSoftBlockRanges',
+ ] );
+ }
+
+ if ( $block instanceof Block ) {
+ wfDebug( __METHOD__ . ": Found block.\n" );
+ $this->mBlock = $block;
+ $this->mBlockedby = $block->getByName();
+ $this->mBlockreason = $block->mReason;
+ $this->mHideName = $block->mHideName;
+ $this->mAllowUsertalk = !$block->prevents( 'editownusertalk' );
+ } else {
+ $this->mBlockedby = '';
+ $this->mHideName = 0;
+ $this->mAllowUsertalk = false;
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ // Extensions
+ Hooks::run( 'GetBlockedStatus', [ &$user ] );
+ }
+
+ /**
+ * Try to load a Block from an ID given in a cookie value.
+ * @param string|null $blockCookieVal The cookie value to check.
+ * @return Block|bool The Block object, or false if none could be loaded.
+ */
+ protected function getBlockFromCookieValue( $blockCookieVal ) {
+ // Make sure there's something to check. The cookie value must start with a number.
+ if ( strlen( $blockCookieVal ) < 1 || !is_numeric( substr( $blockCookieVal, 0, 1 ) ) ) {
+ return false;
+ }
+ // Load the Block from the ID in the cookie.
+ $blockCookieId = Block::getIdFromCookieValue( $blockCookieVal );
+ if ( $blockCookieId !== null ) {
+ // An ID was found in the cookie.
+ $tmpBlock = Block::newFromID( $blockCookieId );
+ if ( $tmpBlock instanceof Block ) {
+ // Check the validity of the block.
+ $blockIsValid = $tmpBlock->getType() == Block::TYPE_USER
+ && !$tmpBlock->isExpired()
+ && $tmpBlock->isAutoblocking();
+ $config = RequestContext::getMain()->getConfig();
+ $useBlockCookie = ( $config->get( 'CookieSetOnAutoblock' ) === true );
+ if ( $blockIsValid && $useBlockCookie ) {
+ // Use the block.
+ return $tmpBlock;
+ } else {
+ // If the block is not valid, remove the cookie.
+ Block::clearCookie( $this->getRequest()->response() );
+ }
+ } else {
+ // If the block doesn't exist, remove the cookie.
+ Block::clearCookie( $this->getRequest()->response() );
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Whether the given IP is in a DNS blacklist.
+ *
+ * @param string $ip IP to check
+ * @param bool $checkWhitelist Whether to check the whitelist first
+ * @return bool True if blacklisted.
+ */
+ public function isDnsBlacklisted( $ip, $checkWhitelist = false ) {
+ global $wgEnableDnsBlacklist, $wgDnsBlacklistUrls, $wgProxyWhitelist;
+
+ if ( !$wgEnableDnsBlacklist ) {
+ return false;
+ }
+
+ if ( $checkWhitelist && in_array( $ip, $wgProxyWhitelist ) ) {
+ return false;
+ }
+
+ return $this->inDnsBlacklist( $ip, $wgDnsBlacklistUrls );
+ }
+
+ /**
+ * Whether the given IP is in a given DNS blacklist.
+ *
+ * @param string $ip IP to check
+ * @param string|array $bases Array of Strings: URL of the DNS blacklist
+ * @return bool True if blacklisted.
+ */
+ public function inDnsBlacklist( $ip, $bases ) {
+ $found = false;
+ // @todo FIXME: IPv6 ??? (https://bugs.php.net/bug.php?id=33170)
+ if ( IP::isIPv4( $ip ) ) {
+ // Reverse IP, T23255
+ $ipReversed = implode( '.', array_reverse( explode( '.', $ip ) ) );
+
+ foreach ( (array)$bases as $base ) {
+ // Make hostname
+ // If we have an access key, use that too (ProjectHoneypot, etc.)
+ $basename = $base;
+ if ( is_array( $base ) ) {
+ if ( count( $base ) >= 2 ) {
+ // Access key is 1, base URL is 0
+ $host = "{$base[1]}.$ipReversed.{$base[0]}";
+ } else {
+ $host = "$ipReversed.{$base[0]}";
+ }
+ $basename = $base[0];
+ } else {
+ $host = "$ipReversed.$base";
+ }
+
+ // Send query
+ $ipList = gethostbynamel( $host );
+
+ if ( $ipList ) {
+ wfDebugLog( 'dnsblacklist', "Hostname $host is {$ipList[0]}, it's a proxy says $basename!" );
+ $found = true;
+ break;
+ } else {
+ wfDebugLog( 'dnsblacklist', "Requested $host, not found in $basename." );
+ }
+ }
+ }
+
+ return $found;
+ }
+
+ /**
+ * Check if an IP address is in the local proxy list
+ *
+ * @param string $ip
+ *
+ * @return bool
+ */
+ public static function isLocallyBlockedProxy( $ip ) {
+ global $wgProxyList;
+
+ if ( !$wgProxyList ) {
+ return false;
+ }
+
+ if ( !is_array( $wgProxyList ) ) {
+ // Load values from the specified file
+ $wgProxyList = array_map( 'trim', file( $wgProxyList ) );
+ }
+
+ $resultProxyList = [];
+ $deprecatedIPEntries = [];
+
+ // backward compatibility: move all ip addresses in keys to values
+ foreach ( $wgProxyList as $key => $value ) {
+ $keyIsIP = IP::isIPAddress( $key );
+ $valueIsIP = IP::isIPAddress( $value );
+ if ( $keyIsIP && !$valueIsIP ) {
+ $deprecatedIPEntries[] = $key;
+ $resultProxyList[] = $key;
+ } elseif ( $keyIsIP && $valueIsIP ) {
+ $deprecatedIPEntries[] = $key;
+ $resultProxyList[] = $key;
+ $resultProxyList[] = $value;
+ } else {
+ $resultProxyList[] = $value;
+ }
+ }
+
+ if ( $deprecatedIPEntries ) {
+ wfDeprecated(
+ 'IP addresses in the keys of $wgProxyList (found the following IP addresses in keys: ' .
+ implode( ', ', $deprecatedIPEntries ) . ', please move them to values)', '1.30' );
+ }
+
+ $proxyListIPSet = new IPSet( $resultProxyList );
+ return $proxyListIPSet->match( $ip );
+ }
+
+ /**
+ * Is this user subject to rate limiting?
+ *
+ * @return bool True if rate limited
+ */
+ public function isPingLimitable() {
+ global $wgRateLimitsExcludedIPs;
+ if ( IP::isInRanges( $this->getRequest()->getIP(), $wgRateLimitsExcludedIPs ) ) {
+ // No other good way currently to disable rate limits
+ // for specific IPs. :P
+ // But this is a crappy hack and should die.
+ return false;
+ }
+ return !$this->isAllowed( 'noratelimit' );
+ }
+
+ /**
+ * Primitive rate limits: enforce maximum actions per time period
+ * to put a brake on flooding.
+ *
+ * The method generates both a generic profiling point and a per action one
+ * (suffix being "-$action".
+ *
+ * @note When using a shared cache like memcached, IP-address
+ * last-hit counters will be shared across wikis.
+ *
+ * @param string $action Action to enforce; 'edit' if unspecified
+ * @param int $incrBy Positive amount to increment counter by [defaults to 1]
+ * @return bool True if a rate limiter was tripped
+ */
+ public function pingLimiter( $action = 'edit', $incrBy = 1 ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ // Call the 'PingLimiter' hook
+ $result = false;
+ if ( !Hooks::run( 'PingLimiter', [ &$user, $action, &$result, $incrBy ] ) ) {
+ return $result;
+ }
+
+ global $wgRateLimits;
+ if ( !isset( $wgRateLimits[$action] ) ) {
+ return false;
+ }
+
+ $limits = array_merge(
+ [ '&can-bypass' => true ],
+ $wgRateLimits[$action]
+ );
+
+ // Some groups shouldn't trigger the ping limiter, ever
+ if ( $limits['&can-bypass'] && !$this->isPingLimitable() ) {
+ return false;
+ }
+
+ $keys = [];
+ $id = $this->getId();
+ $userLimit = false;
+ $isNewbie = $this->isNewbie();
+ $cache = ObjectCache::getLocalClusterInstance();
+
+ if ( $id == 0 ) {
+ // limits for anons
+ if ( isset( $limits['anon'] ) ) {
+ $keys[$cache->makeKey( 'limiter', $action, 'anon' )] = $limits['anon'];
+ }
+ } else {
+ // limits for logged-in users
+ if ( isset( $limits['user'] ) ) {
+ $userLimit = $limits['user'];
+ }
+ }
+
+ // limits for anons and for newbie logged-in users
+ if ( $isNewbie ) {
+ // ip-based limits
+ if ( isset( $limits['ip'] ) ) {
+ $ip = $this->getRequest()->getIP();
+ $keys["mediawiki:limiter:$action:ip:$ip"] = $limits['ip'];
+ }
+ // subnet-based limits
+ if ( isset( $limits['subnet'] ) ) {
+ $ip = $this->getRequest()->getIP();
+ $subnet = IP::getSubnet( $ip );
+ if ( $subnet !== false ) {
+ $keys["mediawiki:limiter:$action:subnet:$subnet"] = $limits['subnet'];
+ }
+ }
+ }
+
+ // Check for group-specific permissions
+ // If more than one group applies, use the group with the highest limit ratio (max/period)
+ foreach ( $this->getGroups() as $group ) {
+ if ( isset( $limits[$group] ) ) {
+ if ( $userLimit === false
+ || $limits[$group][0] / $limits[$group][1] > $userLimit[0] / $userLimit[1]
+ ) {
+ $userLimit = $limits[$group];
+ }
+ }
+ }
+
+ // limits for newbie logged-in users (override all the normal user limits)
+ if ( $id !== 0 && $isNewbie && isset( $limits['newbie'] ) ) {
+ $userLimit = $limits['newbie'];
+ }
+
+ // Set the user limit key
+ if ( $userLimit !== false ) {
+ list( $max, $period ) = $userLimit;
+ wfDebug( __METHOD__ . ": effective user limit: $max in {$period}s\n" );
+ $keys[$cache->makeKey( 'limiter', $action, 'user', $id )] = $userLimit;
+ }
+
+ // ip-based limits for all ping-limitable users
+ if ( isset( $limits['ip-all'] ) ) {
+ $ip = $this->getRequest()->getIP();
+ // ignore if user limit is more permissive
+ if ( $isNewbie || $userLimit === false
+ || $limits['ip-all'][0] / $limits['ip-all'][1] > $userLimit[0] / $userLimit[1] ) {
+ $keys["mediawiki:limiter:$action:ip-all:$ip"] = $limits['ip-all'];
+ }
+ }
+
+ // subnet-based limits for all ping-limitable users
+ if ( isset( $limits['subnet-all'] ) ) {
+ $ip = $this->getRequest()->getIP();
+ $subnet = IP::getSubnet( $ip );
+ if ( $subnet !== false ) {
+ // ignore if user limit is more permissive
+ if ( $isNewbie || $userLimit === false
+ || $limits['ip-all'][0] / $limits['ip-all'][1]
+ > $userLimit[0] / $userLimit[1] ) {
+ $keys["mediawiki:limiter:$action:subnet-all:$subnet"] = $limits['subnet-all'];
+ }
+ }
+ }
+
+ $triggered = false;
+ foreach ( $keys as $key => $limit ) {
+ list( $max, $period ) = $limit;
+ $summary = "(limit $max in {$period}s)";
+ $count = $cache->get( $key );
+ // Already pinged?
+ if ( $count ) {
+ if ( $count >= $max ) {
+ wfDebugLog( 'ratelimit', "User '{$this->getName()}' " .
+ "(IP {$this->getRequest()->getIP()}) tripped $key at $count $summary" );
+ $triggered = true;
+ } else {
+ wfDebug( __METHOD__ . ": ok. $key at $count $summary\n" );
+ }
+ } else {
+ wfDebug( __METHOD__ . ": adding record for $key $summary\n" );
+ if ( $incrBy > 0 ) {
+ $cache->add( $key, 0, intval( $period ) ); // first ping
+ }
+ }
+ if ( $incrBy > 0 ) {
+ $cache->incr( $key, $incrBy );
+ }
+ }
+
+ return $triggered;
+ }
+
+ /**
+ * Check if user is blocked
+ *
+ * @param bool $bFromSlave Whether to check the replica DB instead of
+ * the master. Hacked from false due to horrible probs on site.
+ * @return bool True if blocked, false otherwise
+ */
+ public function isBlocked( $bFromSlave = true ) {
+ return $this->getBlock( $bFromSlave ) instanceof Block && $this->getBlock()->prevents( 'edit' );
+ }
+
+ /**
+ * Get the block affecting the user, or null if the user is not blocked
+ *
+ * @param bool $bFromSlave Whether to check the replica DB instead of the master
+ * @return Block|null
+ */
+ public function getBlock( $bFromSlave = true ) {
+ $this->getBlockedStatus( $bFromSlave );
+ return $this->mBlock instanceof Block ? $this->mBlock : null;
+ }
+
+ /**
+ * Check if user is blocked from editing a particular article
+ *
+ * @param Title $title Title to check
+ * @param bool $bFromSlave Whether to check the replica DB instead of the master
+ * @return bool
+ */
+ public function isBlockedFrom( $title, $bFromSlave = false ) {
+ global $wgBlockAllowsUTEdit;
+
+ $blocked = $this->isBlocked( $bFromSlave );
+ $allowUsertalk = ( $wgBlockAllowsUTEdit ? $this->mAllowUsertalk : false );
+ // If a user's name is suppressed, they cannot make edits anywhere
+ if ( !$this->mHideName && $allowUsertalk && $title->getText() === $this->getName()
+ && $title->getNamespace() == NS_USER_TALK ) {
+ $blocked = false;
+ wfDebug( __METHOD__ . ": self-talk page, ignoring any blocks\n" );
+ }
+
+ Hooks::run( 'UserIsBlockedFrom', [ $this, $title, &$blocked, &$allowUsertalk ] );
+
+ return $blocked;
+ }
+
+ /**
+ * If user is blocked, return the name of the user who placed the block
+ * @return string Name of blocker
+ */
+ public function blockedBy() {
+ $this->getBlockedStatus();
+ return $this->mBlockedby;
+ }
+
+ /**
+ * If user is blocked, return the specified reason for the block
+ * @return string Blocking reason
+ */
+ public function blockedFor() {
+ $this->getBlockedStatus();
+ return $this->mBlockreason;
+ }
+
+ /**
+ * If user is blocked, return the ID for the block
+ * @return int Block ID
+ */
+ public function getBlockId() {
+ $this->getBlockedStatus();
+ return ( $this->mBlock ? $this->mBlock->getId() : false );
+ }
+
+ /**
+ * Check if user is blocked on all wikis.
+ * Do not use for actual edit permission checks!
+ * This is intended for quick UI checks.
+ *
+ * @param string $ip IP address, uses current client if none given
+ * @return bool True if blocked, false otherwise
+ */
+ public function isBlockedGlobally( $ip = '' ) {
+ return $this->getGlobalBlock( $ip ) instanceof Block;
+ }
+
+ /**
+ * Check if user is blocked on all wikis.
+ * Do not use for actual edit permission checks!
+ * This is intended for quick UI checks.
+ *
+ * @param string $ip IP address, uses current client if none given
+ * @return Block|null Block object if blocked, null otherwise
+ * @throws FatalError
+ * @throws MWException
+ */
+ public function getGlobalBlock( $ip = '' ) {
+ if ( $this->mGlobalBlock !== null ) {
+ return $this->mGlobalBlock ?: null;
+ }
+ // User is already an IP?
+ if ( IP::isIPAddress( $this->getName() ) ) {
+ $ip = $this->getName();
+ } elseif ( !$ip ) {
+ $ip = $this->getRequest()->getIP();
+ }
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ $blocked = false;
+ $block = null;
+ Hooks::run( 'UserIsBlockedGlobally', [ &$user, $ip, &$blocked, &$block ] );
+
+ if ( $blocked && $block === null ) {
+ // back-compat: UserIsBlockedGlobally didn't have $block param first
+ $block = new Block( [
+ 'address' => $ip,
+ 'systemBlock' => 'global-block'
+ ] );
+ }
+
+ $this->mGlobalBlock = $blocked ? $block : false;
+ return $this->mGlobalBlock ?: null;
+ }
+
+ /**
+ * Check if user account is locked
+ *
+ * @return bool True if locked, false otherwise
+ */
+ public function isLocked() {
+ if ( $this->mLocked !== null ) {
+ return $this->mLocked;
+ }
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ $authUser = AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$user ], null );
+ $this->mLocked = $authUser && $authUser->isLocked();
+ Hooks::run( 'UserIsLocked', [ $this, &$this->mLocked ] );
+ return $this->mLocked;
+ }
+
+ /**
+ * Check if user account is hidden
+ *
+ * @return bool True if hidden, false otherwise
+ */
+ public function isHidden() {
+ if ( $this->mHideName !== null ) {
+ return $this->mHideName;
+ }
+ $this->getBlockedStatus();
+ if ( !$this->mHideName ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ $authUser = AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$user ], null );
+ $this->mHideName = $authUser && $authUser->isHidden();
+ Hooks::run( 'UserIsHidden', [ $this, &$this->mHideName ] );
+ }
+ return $this->mHideName;
+ }
+
+ /**
+ * Get the user's ID.
+ * @return int The user's ID; 0 if the user is anonymous or nonexistent
+ */
+ public function getId() {
+ if ( $this->mId === null && $this->mName !== null && self::isIP( $this->mName ) ) {
+ // Special case, we know the user is anonymous
+ return 0;
+ } elseif ( !$this->isItemLoaded( 'id' ) ) {
+ // Don't load if this was initialized from an ID
+ $this->load();
+ }
+
+ return (int)$this->mId;
+ }
+
+ /**
+ * Set the user and reload all fields according to a given ID
+ * @param int $v User ID to reload
+ */
+ public function setId( $v ) {
+ $this->mId = $v;
+ $this->clearInstanceCache( 'id' );
+ }
+
+ /**
+ * Get the user name, or the IP of an anonymous user
+ * @return string User's name or IP address
+ */
+ public function getName() {
+ if ( $this->isItemLoaded( 'name', 'only' ) ) {
+ // Special case optimisation
+ return $this->mName;
+ } else {
+ $this->load();
+ if ( $this->mName === false ) {
+ // Clean up IPs
+ $this->mName = IP::sanitizeIP( $this->getRequest()->getIP() );
+ }
+ return $this->mName;
+ }
+ }
+
+ /**
+ * Set the user name.
+ *
+ * This does not reload fields from the database according to the given
+ * name. Rather, it is used to create a temporary "nonexistent user" for
+ * later addition to the database. It can also be used to set the IP
+ * address for an anonymous user to something other than the current
+ * remote IP.
+ *
+ * @note User::newFromName() has roughly the same function, when the named user
+ * does not exist.
+ * @param string $str New user name to set
+ */
+ public function setName( $str ) {
+ $this->load();
+ $this->mName = $str;
+ }
+
+ /**
+ * Get the user's name escaped by underscores.
+ * @return string Username escaped by underscores.
+ */
+ public function getTitleKey() {
+ return str_replace( ' ', '_', $this->getName() );
+ }
+
+ /**
+ * Check if the user has new messages.
+ * @return bool True if the user has new messages
+ */
+ public function getNewtalk() {
+ $this->load();
+
+ // Load the newtalk status if it is unloaded (mNewtalk=-1)
+ if ( $this->mNewtalk === -1 ) {
+ $this->mNewtalk = false; # reset talk page status
+
+ // Check memcached separately for anons, who have no
+ // entire User object stored in there.
+ if ( !$this->mId ) {
+ global $wgDisableAnonTalk;
+ if ( $wgDisableAnonTalk ) {
+ // Anon newtalk disabled by configuration.
+ $this->mNewtalk = false;
+ } else {
+ $this->mNewtalk = $this->checkNewtalk( 'user_ip', $this->getName() );
+ }
+ } else {
+ $this->mNewtalk = $this->checkNewtalk( 'user_id', $this->mId );
+ }
+ }
+
+ return (bool)$this->mNewtalk;
+ }
+
+ /**
+ * Return the data needed to construct links for new talk page message
+ * alerts. If there are new messages, this will return an associative array
+ * with the following data:
+ * wiki: The database name of the wiki
+ * link: Root-relative link to the user's talk page
+ * rev: The last talk page revision that the user has seen or null. This
+ * is useful for building diff links.
+ * If there are no new messages, it returns an empty array.
+ * @note This function was designed to accomodate multiple talk pages, but
+ * currently only returns a single link and revision.
+ * @return array
+ */
+ public function getNewMessageLinks() {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ $talks = [];
+ if ( !Hooks::run( 'UserRetrieveNewTalks', [ &$user, &$talks ] ) ) {
+ return $talks;
+ } elseif ( !$this->getNewtalk() ) {
+ return [];
+ }
+ $utp = $this->getTalkPage();
+ $dbr = wfGetDB( DB_REPLICA );
+ // Get the "last viewed rev" timestamp from the oldest message notification
+ $timestamp = $dbr->selectField( 'user_newtalk',
+ 'MIN(user_last_timestamp)',
+ $this->isAnon() ? [ 'user_ip' => $this->getName() ] : [ 'user_id' => $this->getId() ],
+ __METHOD__ );
+ $rev = $timestamp ? Revision::loadFromTimestamp( $dbr, $utp, $timestamp ) : null;
+ return [ [ 'wiki' => wfWikiID(), 'link' => $utp->getLocalURL(), 'rev' => $rev ] ];
+ }
+
+ /**
+ * Get the revision ID for the last talk page revision viewed by the talk
+ * page owner.
+ * @return int|null Revision ID or null
+ */
+ public function getNewMessageRevisionId() {
+ $newMessageRevisionId = null;
+ $newMessageLinks = $this->getNewMessageLinks();
+ if ( $newMessageLinks ) {
+ // Note: getNewMessageLinks() never returns more than a single link
+ // and it is always for the same wiki, but we double-check here in
+ // case that changes some time in the future.
+ if ( count( $newMessageLinks ) === 1
+ && $newMessageLinks[0]['wiki'] === wfWikiID()
+ && $newMessageLinks[0]['rev']
+ ) {
+ /** @var Revision $newMessageRevision */
+ $newMessageRevision = $newMessageLinks[0]['rev'];
+ $newMessageRevisionId = $newMessageRevision->getId();
+ }
+ }
+ return $newMessageRevisionId;
+ }
+
+ /**
+ * Internal uncached check for new messages
+ *
+ * @see getNewtalk()
+ * @param string $field 'user_ip' for anonymous users, 'user_id' otherwise
+ * @param string|int $id User's IP address for anonymous users, User ID otherwise
+ * @return bool True if the user has new messages
+ */
+ protected function checkNewtalk( $field, $id ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $ok = $dbr->selectField( 'user_newtalk', $field, [ $field => $id ], __METHOD__ );
+
+ return $ok !== false;
+ }
+
+ /**
+ * Add or update the new messages flag
+ * @param string $field 'user_ip' for anonymous users, 'user_id' otherwise
+ * @param string|int $id User's IP address for anonymous users, User ID otherwise
+ * @param Revision|null $curRev New, as yet unseen revision of the user talk page. Ignored if null.
+ * @return bool True if successful, false otherwise
+ */
+ protected function updateNewtalk( $field, $id, $curRev = null ) {
+ // Get timestamp of the talk page revision prior to the current one
+ $prevRev = $curRev ? $curRev->getPrevious() : false;
+ $ts = $prevRev ? $prevRev->getTimestamp() : null;
+ // Mark the user as having new messages since this revision
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert( 'user_newtalk',
+ [ $field => $id, 'user_last_timestamp' => $dbw->timestampOrNull( $ts ) ],
+ __METHOD__,
+ 'IGNORE' );
+ if ( $dbw->affectedRows() ) {
+ wfDebug( __METHOD__ . ": set on ($field, $id)\n" );
+ return true;
+ } else {
+ wfDebug( __METHOD__ . " already set ($field, $id)\n" );
+ return false;
+ }
+ }
+
+ /**
+ * Clear the new messages flag for the given user
+ * @param string $field 'user_ip' for anonymous users, 'user_id' otherwise
+ * @param string|int $id User's IP address for anonymous users, User ID otherwise
+ * @return bool True if successful, false otherwise
+ */
+ protected function deleteNewtalk( $field, $id ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete( 'user_newtalk',
+ [ $field => $id ],
+ __METHOD__ );
+ if ( $dbw->affectedRows() ) {
+ wfDebug( __METHOD__ . ": killed on ($field, $id)\n" );
+ return true;
+ } else {
+ wfDebug( __METHOD__ . ": already gone ($field, $id)\n" );
+ return false;
+ }
+ }
+
+ /**
+ * Update the 'You have new messages!' status.
+ * @param bool $val Whether the user has new messages
+ * @param Revision $curRev New, as yet unseen revision of the user talk
+ * page. Ignored if null or !$val.
+ */
+ public function setNewtalk( $val, $curRev = null ) {
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ $this->load();
+ $this->mNewtalk = $val;
+
+ if ( $this->isAnon() ) {
+ $field = 'user_ip';
+ $id = $this->getName();
+ } else {
+ $field = 'user_id';
+ $id = $this->getId();
+ }
+
+ if ( $val ) {
+ $changed = $this->updateNewtalk( $field, $id, $curRev );
+ } else {
+ $changed = $this->deleteNewtalk( $field, $id );
+ }
+
+ if ( $changed ) {
+ $this->invalidateCache();
+ }
+ }
+
+ /**
+ * Generate a current or new-future timestamp to be stored in the
+ * user_touched field when we update things.
+ * @return string Timestamp in TS_MW format
+ */
+ private function newTouchedTimestamp() {
+ global $wgClockSkewFudge;
+
+ $time = wfTimestamp( TS_MW, time() + $wgClockSkewFudge );
+ if ( $this->mTouched && $time <= $this->mTouched ) {
+ $time = wfTimestamp( TS_MW, wfTimestamp( TS_UNIX, $this->mTouched ) + 1 );
+ }
+
+ return $time;
+ }
+
+ /**
+ * Clear user data from memcached
+ *
+ * Use after applying updates to the database; caller's
+ * responsibility to update user_touched if appropriate.
+ *
+ * Called implicitly from invalidateCache() and saveSettings().
+ *
+ * @param string $mode Use 'refresh' to clear now; otherwise before DB commit
+ */
+ public function clearSharedCache( $mode = 'changed' ) {
+ if ( !$this->getId() ) {
+ return;
+ }
+
+ $cache = ObjectCache::getMainWANInstance();
+ $key = $this->getCacheKey( $cache );
+ if ( $mode === 'refresh' ) {
+ $cache->delete( $key, 1 );
+ } else {
+ wfGetDB( DB_MASTER )->onTransactionPreCommitOrIdle(
+ function () use ( $cache, $key ) {
+ $cache->delete( $key );
+ },
+ __METHOD__
+ );
+ }
+ }
+
+ /**
+ * Immediately touch the user data cache for this account
+ *
+ * Calls touch() and removes account data from memcached
+ */
+ public function invalidateCache() {
+ $this->touch();
+ $this->clearSharedCache();
+ }
+
+ /**
+ * Update the "touched" timestamp for the user
+ *
+ * This is useful on various login/logout events when making sure that
+ * a browser or proxy that has multiple tenants does not suffer cache
+ * pollution where the new user sees the old users content. The value
+ * of getTouched() is checked when determining 304 vs 200 responses.
+ * Unlike invalidateCache(), this preserves the User object cache and
+ * avoids database writes.
+ *
+ * @since 1.25
+ */
+ public function touch() {
+ $id = $this->getId();
+ if ( $id ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $key = $cache->makeKey( 'user-quicktouched', 'id', $id );
+ $cache->touchCheckKey( $key );
+ $this->mQuickTouched = null;
+ }
+ }
+
+ /**
+ * Validate the cache for this account.
+ * @param string $timestamp A timestamp in TS_MW format
+ * @return bool
+ */
+ public function validateCache( $timestamp ) {
+ return ( $timestamp >= $this->getTouched() );
+ }
+
+ /**
+ * Get the user touched timestamp
+ *
+ * Use this value only to validate caches via inequalities
+ * such as in the case of HTTP If-Modified-Since response logic
+ *
+ * @return string TS_MW Timestamp
+ */
+ public function getTouched() {
+ $this->load();
+
+ if ( $this->mId ) {
+ if ( $this->mQuickTouched === null ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $key = $cache->makeKey( 'user-quicktouched', 'id', $this->mId );
+
+ $this->mQuickTouched = wfTimestamp( TS_MW, $cache->getCheckKeyTime( $key ) );
+ }
+
+ return max( $this->mTouched, $this->mQuickTouched );
+ }
+
+ return $this->mTouched;
+ }
+
+ /**
+ * Get the user_touched timestamp field (time of last DB updates)
+ * @return string TS_MW Timestamp
+ * @since 1.26
+ */
+ public function getDBTouched() {
+ $this->load();
+
+ return $this->mTouched;
+ }
+
+ /**
+ * Set the password and reset the random token.
+ * Calls through to authentication plugin if necessary;
+ * will have no effect if the auth plugin refuses to
+ * pass the change through or if the legal password
+ * checks fail.
+ *
+ * As a special case, setting the password to null
+ * wipes it, so the account cannot be logged in until
+ * a new password is set, for instance via e-mail.
+ *
+ * @deprecated since 1.27, use AuthManager instead
+ * @param string $str New password to set
+ * @throws PasswordError On failure
+ * @return bool
+ */
+ public function setPassword( $str ) {
+ return $this->setPasswordInternal( $str );
+ }
+
+ /**
+ * Set the password and reset the random token unconditionally.
+ *
+ * @deprecated since 1.27, use AuthManager instead
+ * @param string|null $str New password to set or null to set an invalid
+ * password hash meaning that the user will not be able to log in
+ * through the web interface.
+ */
+ public function setInternalPassword( $str ) {
+ $this->setPasswordInternal( $str );
+ }
+
+ /**
+ * Actually set the password and such
+ * @since 1.27 cannot set a password for a user not in the database
+ * @param string|null $str New password to set or null to set an invalid
+ * password hash meaning that the user will not be able to log in
+ * through the web interface.
+ * @return bool Success
+ */
+ private function setPasswordInternal( $str ) {
+ $manager = AuthManager::singleton();
+
+ // If the user doesn't exist yet, fail
+ if ( !$manager->userExists( $this->getName() ) ) {
+ throw new LogicException( 'Cannot set a password for a user that is not in the database.' );
+ }
+
+ $status = $this->changeAuthenticationData( [
+ 'username' => $this->getName(),
+ 'password' => $str,
+ 'retype' => $str,
+ ] );
+ if ( !$status->isGood() ) {
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' )
+ ->info( __METHOD__ . ': Password change rejected: '
+ . $status->getWikiText( null, null, 'en' ) );
+ return false;
+ }
+
+ $this->setOption( 'watchlisttoken', false );
+ SessionManager::singleton()->invalidateSessionsForUser( $this );
+
+ return true;
+ }
+
+ /**
+ * Changes credentials of the user.
+ *
+ * This is a convenience wrapper around AuthManager::changeAuthenticationData.
+ * Note that this can return a status that isOK() but not isGood() on certain types of failures,
+ * e.g. when no provider handled the change.
+ *
+ * @param array $data A set of authentication data in fieldname => value format. This is the
+ * same data you would pass the changeauthenticationdata API - 'username', 'password' etc.
+ * @return Status
+ * @since 1.27
+ */
+ public function changeAuthenticationData( array $data ) {
+ $manager = AuthManager::singleton();
+ $reqs = $manager->getAuthenticationRequests( AuthManager::ACTION_CHANGE, $this );
+ $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
+
+ $status = Status::newGood( 'ignored' );
+ foreach ( $reqs as $req ) {
+ $status->merge( $manager->allowsAuthenticationDataChange( $req ), true );
+ }
+ if ( $status->getValue() === 'ignored' ) {
+ $status->warning( 'authenticationdatachange-ignored' );
+ }
+
+ if ( $status->isGood() ) {
+ foreach ( $reqs as $req ) {
+ $manager->changeAuthenticationData( $req );
+ }
+ }
+ return $status;
+ }
+
+ /**
+ * Get the user's current token.
+ * @param bool $forceCreation Force the generation of a new token if the
+ * user doesn't have one (default=true for backwards compatibility).
+ * @return string|null Token
+ */
+ public function getToken( $forceCreation = true ) {
+ global $wgAuthenticationTokenVersion;
+
+ $this->load();
+ if ( !$this->mToken && $forceCreation ) {
+ $this->setToken();
+ }
+
+ if ( !$this->mToken ) {
+ // The user doesn't have a token, return null to indicate that.
+ return null;
+ } elseif ( $this->mToken === self::INVALID_TOKEN ) {
+ // We return a random value here so existing token checks are very
+ // likely to fail.
+ return MWCryptRand::generateHex( self::TOKEN_LENGTH );
+ } elseif ( $wgAuthenticationTokenVersion === null ) {
+ // $wgAuthenticationTokenVersion not in use, so return the raw secret
+ return $this->mToken;
+ } else {
+ // $wgAuthenticationTokenVersion in use, so hmac it.
+ $ret = MWCryptHash::hmac( $wgAuthenticationTokenVersion, $this->mToken, false );
+
+ // The raw hash can be overly long. Shorten it up.
+ $len = max( 32, self::TOKEN_LENGTH );
+ if ( strlen( $ret ) < $len ) {
+ // Should never happen, even md5 is 128 bits
+ throw new \UnexpectedValueException( 'Hmac returned less than 128 bits' );
+ }
+ return substr( $ret, -$len );
+ }
+ }
+
+ /**
+ * Set the random token (used for persistent authentication)
+ * Called from loadDefaults() among other places.
+ *
+ * @param string|bool $token If specified, set the token to this value
+ */
+ public function setToken( $token = false ) {
+ $this->load();
+ if ( $this->mToken === self::INVALID_TOKEN ) {
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+ ->debug( __METHOD__ . ": Ignoring attempt to set token for system user \"$this\"" );
+ } elseif ( !$token ) {
+ $this->mToken = MWCryptRand::generateHex( self::TOKEN_LENGTH );
+ } else {
+ $this->mToken = $token;
+ }
+ }
+
+ /**
+ * Set the password for a password reminder or new account email
+ *
+ * @deprecated Removed in 1.27. Use PasswordReset instead.
+ * @param string $str New password to set or null to set an invalid
+ * password hash meaning that the user will not be able to use it
+ * @param bool $throttle If true, reset the throttle timestamp to the present
+ */
+ public function setNewpassword( $str, $throttle = true ) {
+ throw new BadMethodCallException( __METHOD__ . ' has been removed in 1.27' );
+ }
+
+ /**
+ * Get the user's e-mail address
+ * @return string User's email address
+ */
+ public function getEmail() {
+ $this->load();
+ Hooks::run( 'UserGetEmail', [ $this, &$this->mEmail ] );
+ return $this->mEmail;
+ }
+
+ /**
+ * Get the timestamp of the user's e-mail authentication
+ * @return string TS_MW timestamp
+ */
+ public function getEmailAuthenticationTimestamp() {
+ $this->load();
+ Hooks::run( 'UserGetEmailAuthenticationTimestamp', [ $this, &$this->mEmailAuthenticated ] );
+ return $this->mEmailAuthenticated;
+ }
+
+ /**
+ * Set the user's e-mail address
+ * @param string $str New e-mail address
+ */
+ public function setEmail( $str ) {
+ $this->load();
+ if ( $str == $this->mEmail ) {
+ return;
+ }
+ $this->invalidateEmail();
+ $this->mEmail = $str;
+ Hooks::run( 'UserSetEmail', [ $this, &$this->mEmail ] );
+ }
+
+ /**
+ * Set the user's e-mail address and a confirmation mail if needed.
+ *
+ * @since 1.20
+ * @param string $str New e-mail address
+ * @return Status
+ */
+ public function setEmailWithConfirmation( $str ) {
+ global $wgEnableEmail, $wgEmailAuthentication;
+
+ if ( !$wgEnableEmail ) {
+ return Status::newFatal( 'emaildisabled' );
+ }
+
+ $oldaddr = $this->getEmail();
+ if ( $str === $oldaddr ) {
+ return Status::newGood( true );
+ }
+
+ $type = $oldaddr != '' ? 'changed' : 'set';
+ $notificationResult = null;
+
+ if ( $wgEmailAuthentication ) {
+ // Send the user an email notifying the user of the change in registered
+ // email address on their previous email address
+ if ( $type == 'changed' ) {
+ $change = $str != '' ? 'changed' : 'removed';
+ $notificationResult = $this->sendMail(
+ wfMessage( 'notificationemail_subject_' . $change )->text(),
+ wfMessage( 'notificationemail_body_' . $change,
+ $this->getRequest()->getIP(),
+ $this->getName(),
+ $str )->text()
+ );
+ }
+ }
+
+ $this->setEmail( $str );
+
+ if ( $str !== '' && $wgEmailAuthentication ) {
+ // Send a confirmation request to the new address if needed
+ $result = $this->sendConfirmationMail( $type );
+
+ if ( $notificationResult !== null ) {
+ $result->merge( $notificationResult );
+ }
+
+ if ( $result->isGood() ) {
+ // Say to the caller that a confirmation and notification mail has been sent
+ $result->value = 'eauth';
+ }
+ } else {
+ $result = Status::newGood( true );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the user's real name
+ * @return string User's real name
+ */
+ public function getRealName() {
+ if ( !$this->isItemLoaded( 'realname' ) ) {
+ $this->load();
+ }
+
+ return $this->mRealName;
+ }
+
+ /**
+ * Set the user's real name
+ * @param string $str New real name
+ */
+ public function setRealName( $str ) {
+ $this->load();
+ $this->mRealName = $str;
+ }
+
+ /**
+ * Get the user's current setting for a given option.
+ *
+ * @param string $oname The option to check
+ * @param string $defaultOverride A default value returned if the option does not exist
+ * @param bool $ignoreHidden Whether to ignore the effects of $wgHiddenPrefs
+ * @return string|null User's current value for the option
+ * @see getBoolOption()
+ * @see getIntOption()
+ */
+ public function getOption( $oname, $defaultOverride = null, $ignoreHidden = false ) {
+ global $wgHiddenPrefs;
+ $this->loadOptions();
+
+ # We want 'disabled' preferences to always behave as the default value for
+ # users, even if they have set the option explicitly in their settings (ie they
+ # set it, and then it was disabled removing their ability to change it). But
+ # we don't want to erase the preferences in the database in case the preference
+ # is re-enabled again. So don't touch $mOptions, just override the returned value
+ if ( !$ignoreHidden && in_array( $oname, $wgHiddenPrefs ) ) {
+ return self::getDefaultOption( $oname );
+ }
+
+ if ( array_key_exists( $oname, $this->mOptions ) ) {
+ return $this->mOptions[$oname];
+ } else {
+ return $defaultOverride;
+ }
+ }
+
+ /**
+ * Get all user's options
+ *
+ * @param int $flags Bitwise combination of:
+ * User::GETOPTIONS_EXCLUDE_DEFAULTS Exclude user options that are set
+ * to the default value. (Since 1.25)
+ * @return array
+ */
+ public function getOptions( $flags = 0 ) {
+ global $wgHiddenPrefs;
+ $this->loadOptions();
+ $options = $this->mOptions;
+
+ # We want 'disabled' preferences to always behave as the default value for
+ # users, even if they have set the option explicitly in their settings (ie they
+ # set it, and then it was disabled removing their ability to change it). But
+ # we don't want to erase the preferences in the database in case the preference
+ # is re-enabled again. So don't touch $mOptions, just override the returned value
+ foreach ( $wgHiddenPrefs as $pref ) {
+ $default = self::getDefaultOption( $pref );
+ if ( $default !== null ) {
+ $options[$pref] = $default;
+ }
+ }
+
+ if ( $flags & self::GETOPTIONS_EXCLUDE_DEFAULTS ) {
+ $options = array_diff_assoc( $options, self::getDefaultOptions() );
+ }
+
+ return $options;
+ }
+
+ /**
+ * Get the user's current setting for a given option, as a boolean value.
+ *
+ * @param string $oname The option to check
+ * @return bool User's current value for the option
+ * @see getOption()
+ */
+ public function getBoolOption( $oname ) {
+ return (bool)$this->getOption( $oname );
+ }
+
+ /**
+ * Get the user's current setting for a given option, as an integer value.
+ *
+ * @param string $oname The option to check
+ * @param int $defaultOverride A default value returned if the option does not exist
+ * @return int User's current value for the option
+ * @see getOption()
+ */
+ public function getIntOption( $oname, $defaultOverride = 0 ) {
+ $val = $this->getOption( $oname );
+ if ( $val == '' ) {
+ $val = $defaultOverride;
+ }
+ return intval( $val );
+ }
+
+ /**
+ * Set the given option for a user.
+ *
+ * You need to call saveSettings() to actually write to the database.
+ *
+ * @param string $oname The option to set
+ * @param mixed $val New value to set
+ */
+ public function setOption( $oname, $val ) {
+ $this->loadOptions();
+
+ // Explicitly NULL values should refer to defaults
+ if ( is_null( $val ) ) {
+ $val = self::getDefaultOption( $oname );
+ }
+
+ $this->mOptions[$oname] = $val;
+ }
+
+ /**
+ * Get a token stored in the preferences (like the watchlist one),
+ * resetting it if it's empty (and saving changes).
+ *
+ * @param string $oname The option name to retrieve the token from
+ * @return string|bool User's current value for the option, or false if this option is disabled.
+ * @see resetTokenFromOption()
+ * @see getOption()
+ * @deprecated since 1.26 Applications should use the OAuth extension
+ */
+ public function getTokenFromOption( $oname ) {
+ global $wgHiddenPrefs;
+
+ $id = $this->getId();
+ if ( !$id || in_array( $oname, $wgHiddenPrefs ) ) {
+ return false;
+ }
+
+ $token = $this->getOption( $oname );
+ if ( !$token ) {
+ // Default to a value based on the user token to avoid space
+ // wasted on storing tokens for all users. When this option
+ // is set manually by the user, only then is it stored.
+ $token = hash_hmac( 'sha1', "$oname:$id", $this->getToken() );
+ }
+
+ return $token;
+ }
+
+ /**
+ * Reset a token stored in the preferences (like the watchlist one).
+ * *Does not* save user's preferences (similarly to setOption()).
+ *
+ * @param string $oname The option name to reset the token in
+ * @return string|bool New token value, or false if this option is disabled.
+ * @see getTokenFromOption()
+ * @see setOption()
+ */
+ public function resetTokenFromOption( $oname ) {
+ global $wgHiddenPrefs;
+ if ( in_array( $oname, $wgHiddenPrefs ) ) {
+ return false;
+ }
+
+ $token = MWCryptRand::generateHex( 40 );
+ $this->setOption( $oname, $token );
+ return $token;
+ }
+
+ /**
+ * Return a list of the types of user options currently returned by
+ * User::getOptionKinds().
+ *
+ * Currently, the option kinds are:
+ * - 'registered' - preferences which are registered in core MediaWiki or
+ * by extensions using the UserGetDefaultOptions hook.
+ * - 'registered-multiselect' - as above, using the 'multiselect' type.
+ * - 'registered-checkmatrix' - as above, using the 'checkmatrix' type.
+ * - 'userjs' - preferences with names starting with 'userjs-', intended to
+ * be used by user scripts.
+ * - 'special' - "preferences" that are not accessible via User::getOptions
+ * or User::setOptions.
+ * - 'unused' - preferences about which MediaWiki doesn't know anything.
+ * These are usually legacy options, removed in newer versions.
+ *
+ * The API (and possibly others) use this function to determine the possible
+ * option types for validation purposes, so make sure to update this when a
+ * new option kind is added.
+ *
+ * @see User::getOptionKinds
+ * @return array Option kinds
+ */
+ public static function listOptionKinds() {
+ return [
+ 'registered',
+ 'registered-multiselect',
+ 'registered-checkmatrix',
+ 'userjs',
+ 'special',
+ 'unused'
+ ];
+ }
+
+ /**
+ * Return an associative array mapping preferences keys to the kind of a preference they're
+ * used for. Different kinds are handled differently when setting or reading preferences.
+ *
+ * See User::listOptionKinds for the list of valid option types that can be provided.
+ *
+ * @see User::listOptionKinds
+ * @param IContextSource $context
+ * @param array $options Assoc. array with options keys to check as keys.
+ * Defaults to $this->mOptions.
+ * @return array The key => kind mapping data
+ */
+ public function getOptionKinds( IContextSource $context, $options = null ) {
+ $this->loadOptions();
+ if ( $options === null ) {
+ $options = $this->mOptions;
+ }
+
+ $prefs = Preferences::getPreferences( $this, $context );
+ $mapping = [];
+
+ // Pull out the "special" options, so they don't get converted as
+ // multiselect or checkmatrix.
+ $specialOptions = array_fill_keys( Preferences::getSaveBlacklist(), true );
+ foreach ( $specialOptions as $name => $value ) {
+ unset( $prefs[$name] );
+ }
+
+ // Multiselect and checkmatrix options are stored in the database with
+ // one key per option, each having a boolean value. Extract those keys.
+ $multiselectOptions = [];
+ foreach ( $prefs as $name => $info ) {
+ if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
+ ( isset( $info['class'] ) && $info['class'] == 'HTMLMultiSelectField' ) ) {
+ $opts = HTMLFormField::flattenOptions( $info['options'] );
+ $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $name;
+
+ foreach ( $opts as $value ) {
+ $multiselectOptions["$prefix$value"] = true;
+ }
+
+ unset( $prefs[$name] );
+ }
+ }
+ $checkmatrixOptions = [];
+ foreach ( $prefs as $name => $info ) {
+ if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
+ ( isset( $info['class'] ) && $info['class'] == 'HTMLCheckMatrix' ) ) {
+ $columns = HTMLFormField::flattenOptions( $info['columns'] );
+ $rows = HTMLFormField::flattenOptions( $info['rows'] );
+ $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $name;
+
+ foreach ( $columns as $column ) {
+ foreach ( $rows as $row ) {
+ $checkmatrixOptions["$prefix$column-$row"] = true;
+ }
+ }
+
+ unset( $prefs[$name] );
+ }
+ }
+
+ // $value is ignored
+ foreach ( $options as $key => $value ) {
+ if ( isset( $prefs[$key] ) ) {
+ $mapping[$key] = 'registered';
+ } elseif ( isset( $multiselectOptions[$key] ) ) {
+ $mapping[$key] = 'registered-multiselect';
+ } elseif ( isset( $checkmatrixOptions[$key] ) ) {
+ $mapping[$key] = 'registered-checkmatrix';
+ } elseif ( isset( $specialOptions[$key] ) ) {
+ $mapping[$key] = 'special';
+ } elseif ( substr( $key, 0, 7 ) === 'userjs-' ) {
+ $mapping[$key] = 'userjs';
+ } else {
+ $mapping[$key] = 'unused';
+ }
+ }
+
+ return $mapping;
+ }
+
+ /**
+ * Reset certain (or all) options to the site defaults
+ *
+ * The optional parameter determines which kinds of preferences will be reset.
+ * Supported values are everything that can be reported by getOptionKinds()
+ * and 'all', which forces a reset of *all* preferences and overrides everything else.
+ *
+ * @param array|string $resetKinds Which kinds of preferences to reset. Defaults to
+ * array( 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' )
+ * for backwards-compatibility.
+ * @param IContextSource|null $context Context source used when $resetKinds
+ * does not contain 'all', passed to getOptionKinds().
+ * Defaults to RequestContext::getMain() when null.
+ */
+ public function resetOptions(
+ $resetKinds = [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ],
+ IContextSource $context = null
+ ) {
+ $this->load();
+ $defaultOptions = self::getDefaultOptions();
+
+ if ( !is_array( $resetKinds ) ) {
+ $resetKinds = [ $resetKinds ];
+ }
+
+ if ( in_array( 'all', $resetKinds ) ) {
+ $newOptions = $defaultOptions;
+ } else {
+ if ( $context === null ) {
+ $context = RequestContext::getMain();
+ }
+
+ $optionKinds = $this->getOptionKinds( $context );
+ $resetKinds = array_intersect( $resetKinds, self::listOptionKinds() );
+ $newOptions = [];
+
+ // Use default values for the options that should be deleted, and
+ // copy old values for the ones that shouldn't.
+ foreach ( $this->mOptions as $key => $value ) {
+ if ( in_array( $optionKinds[$key], $resetKinds ) ) {
+ if ( array_key_exists( $key, $defaultOptions ) ) {
+ $newOptions[$key] = $defaultOptions[$key];
+ }
+ } else {
+ $newOptions[$key] = $value;
+ }
+ }
+ }
+
+ Hooks::run( 'UserResetAllOptions', [ $this, &$newOptions, $this->mOptions, $resetKinds ] );
+
+ $this->mOptions = $newOptions;
+ $this->mOptionsLoaded = true;
+ }
+
+ /**
+ * Get the user's preferred date format.
+ * @return string User's preferred date format
+ */
+ public function getDatePreference() {
+ // Important migration for old data rows
+ if ( is_null( $this->mDatePreference ) ) {
+ global $wgLang;
+ $value = $this->getOption( 'date' );
+ $map = $wgLang->getDatePreferenceMigrationMap();
+ if ( isset( $map[$value] ) ) {
+ $value = $map[$value];
+ }
+ $this->mDatePreference = $value;
+ }
+ return $this->mDatePreference;
+ }
+
+ /**
+ * Determine based on the wiki configuration and the user's options,
+ * whether this user must be over HTTPS no matter what.
+ *
+ * @return bool
+ */
+ public function requiresHTTPS() {
+ global $wgSecureLogin;
+ if ( !$wgSecureLogin ) {
+ return false;
+ } else {
+ $https = $this->getBoolOption( 'prefershttps' );
+ Hooks::run( 'UserRequiresHTTPS', [ $this, &$https ] );
+ if ( $https ) {
+ $https = wfCanIPUseHTTPS( $this->getRequest()->getIP() );
+ }
+ return $https;
+ }
+ }
+
+ /**
+ * Get the user preferred stub threshold
+ *
+ * @return int
+ */
+ public function getStubThreshold() {
+ global $wgMaxArticleSize; # Maximum article size, in Kb
+ $threshold = $this->getIntOption( 'stubthreshold' );
+ if ( $threshold > $wgMaxArticleSize * 1024 ) {
+ // If they have set an impossible value, disable the preference
+ // so we can use the parser cache again.
+ $threshold = 0;
+ }
+ return $threshold;
+ }
+
+ /**
+ * Get the permissions this user has.
+ * @return string[] permission names
+ */
+ public function getRights() {
+ if ( is_null( $this->mRights ) ) {
+ $this->mRights = self::getGroupPermissions( $this->getEffectiveGroups() );
+ Hooks::run( 'UserGetRights', [ $this, &$this->mRights ] );
+
+ // Deny any rights denied by the user's session, unless this
+ // endpoint has no sessions.
+ if ( !defined( 'MW_NO_SESSION' ) ) {
+ $allowedRights = $this->getRequest()->getSession()->getAllowedUserRights();
+ if ( $allowedRights !== null ) {
+ $this->mRights = array_intersect( $this->mRights, $allowedRights );
+ }
+ }
+
+ // Force reindexation of rights when a hook has unset one of them
+ $this->mRights = array_values( array_unique( $this->mRights ) );
+
+ // If block disables login, we should also remove any
+ // extra rights blocked users might have, in case the
+ // blocked user has a pre-existing session (T129738).
+ // This is checked here for cases where people only call
+ // $user->isAllowed(). It is also checked in Title::checkUserBlock()
+ // to give a better error message in the common case.
+ $config = RequestContext::getMain()->getConfig();
+ if (
+ $this->isLoggedIn() &&
+ $config->get( 'BlockDisablesLogin' ) &&
+ $this->isBlocked()
+ ) {
+ $anon = new User;
+ $this->mRights = array_intersect( $this->mRights, $anon->getRights() );
+ }
+ }
+ return $this->mRights;
+ }
+
+ /**
+ * Get the list of explicit group memberships this user has.
+ * The implicit * and user groups are not included.
+ * @return array Array of String internal group names
+ */
+ public function getGroups() {
+ $this->load();
+ $this->loadGroups();
+ return array_keys( $this->mGroupMemberships );
+ }
+
+ /**
+ * Get the list of explicit group memberships this user has, stored as
+ * UserGroupMembership objects. Implicit groups are not included.
+ *
+ * @return array Associative array of (group name as string => UserGroupMembership object)
+ * @since 1.29
+ */
+ public function getGroupMemberships() {
+ $this->load();
+ $this->loadGroups();
+ return $this->mGroupMemberships;
+ }
+
+ /**
+ * Get the list of implicit group memberships this user has.
+ * This includes all explicit groups, plus 'user' if logged in,
+ * '*' for all accounts, and autopromoted groups
+ * @param bool $recache Whether to avoid the cache
+ * @return array Array of String internal group names
+ */
+ public function getEffectiveGroups( $recache = false ) {
+ if ( $recache || is_null( $this->mEffectiveGroups ) ) {
+ $this->mEffectiveGroups = array_unique( array_merge(
+ $this->getGroups(), // explicit groups
+ $this->getAutomaticGroups( $recache ) // implicit groups
+ ) );
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ // Hook for additional groups
+ Hooks::run( 'UserEffectiveGroups', [ &$user, &$this->mEffectiveGroups ] );
+ // Force reindexation of groups when a hook has unset one of them
+ $this->mEffectiveGroups = array_values( array_unique( $this->mEffectiveGroups ) );
+ }
+ return $this->mEffectiveGroups;
+ }
+
+ /**
+ * Get the list of implicit group memberships this user has.
+ * This includes 'user' if logged in, '*' for all accounts,
+ * and autopromoted groups
+ * @param bool $recache Whether to avoid the cache
+ * @return array Array of String internal group names
+ */
+ public function getAutomaticGroups( $recache = false ) {
+ if ( $recache || is_null( $this->mImplicitGroups ) ) {
+ $this->mImplicitGroups = [ '*' ];
+ if ( $this->getId() ) {
+ $this->mImplicitGroups[] = 'user';
+
+ $this->mImplicitGroups = array_unique( array_merge(
+ $this->mImplicitGroups,
+ Autopromote::getAutopromoteGroups( $this )
+ ) );
+ }
+ if ( $recache ) {
+ // Assure data consistency with rights/groups,
+ // as getEffectiveGroups() depends on this function
+ $this->mEffectiveGroups = null;
+ }
+ }
+ return $this->mImplicitGroups;
+ }
+
+ /**
+ * Returns the groups the user has belonged to.
+ *
+ * The user may still belong to the returned groups. Compare with getGroups().
+ *
+ * The function will not return groups the user had belonged to before MW 1.17
+ *
+ * @return array Names of the groups the user has belonged to.
+ */
+ public function getFormerGroups() {
+ $this->load();
+
+ if ( is_null( $this->mFormerGroups ) ) {
+ $db = ( $this->queryFlagsUsed & self::READ_LATEST )
+ ? wfGetDB( DB_MASTER )
+ : wfGetDB( DB_REPLICA );
+ $res = $db->select( 'user_former_groups',
+ [ 'ufg_group' ],
+ [ 'ufg_user' => $this->mId ],
+ __METHOD__ );
+ $this->mFormerGroups = [];
+ foreach ( $res as $row ) {
+ $this->mFormerGroups[] = $row->ufg_group;
+ }
+ }
+
+ return $this->mFormerGroups;
+ }
+
+ /**
+ * Get the user's edit count.
+ * @return int|null Null for anonymous users
+ */
+ public function getEditCount() {
+ if ( !$this->getId() ) {
+ return null;
+ }
+
+ if ( $this->mEditCount === null ) {
+ /* Populate the count, if it has not been populated yet */
+ $dbr = wfGetDB( DB_REPLICA );
+ // check if the user_editcount field has been initialized
+ $count = $dbr->selectField(
+ 'user', 'user_editcount',
+ [ 'user_id' => $this->mId ],
+ __METHOD__
+ );
+
+ if ( $count === null ) {
+ // it has not been initialized. do so.
+ $count = $this->initEditCount();
+ }
+ $this->mEditCount = $count;
+ }
+ return (int)$this->mEditCount;
+ }
+
+ /**
+ * Add the user to the given group. This takes immediate effect.
+ * If the user is already in the group, the expiry time will be updated to the new
+ * expiry time. (If $expiry is omitted or null, the membership will be altered to
+ * never expire.)
+ *
+ * @param string $group Name of the group to add
+ * @param string $expiry Optional expiry timestamp in any format acceptable to
+ * wfTimestamp(), or null if the group assignment should not expire
+ * @return bool
+ */
+ public function addGroup( $group, $expiry = null ) {
+ $this->load();
+ $this->loadGroups();
+
+ if ( $expiry ) {
+ $expiry = wfTimestamp( TS_MW, $expiry );
+ }
+
+ if ( !Hooks::run( 'UserAddGroup', [ $this, &$group, &$expiry ] ) ) {
+ return false;
+ }
+
+ // create the new UserGroupMembership and put it in the DB
+ $ugm = new UserGroupMembership( $this->mId, $group, $expiry );
+ if ( !$ugm->insert( true ) ) {
+ return false;
+ }
+
+ $this->mGroupMemberships[$group] = $ugm;
+
+ // Refresh the groups caches, and clear the rights cache so it will be
+ // refreshed on the next call to $this->getRights().
+ $this->getEffectiveGroups( true );
+ $this->mRights = null;
+
+ $this->invalidateCache();
+
+ return true;
+ }
+
+ /**
+ * Remove the user from the given group.
+ * This takes immediate effect.
+ * @param string $group Name of the group to remove
+ * @return bool
+ */
+ public function removeGroup( $group ) {
+ $this->load();
+
+ if ( !Hooks::run( 'UserRemoveGroup', [ $this, &$group ] ) ) {
+ return false;
+ }
+
+ $ugm = UserGroupMembership::getMembership( $this->mId, $group );
+ // delete the membership entry
+ if ( !$ugm || !$ugm->delete() ) {
+ return false;
+ }
+
+ $this->loadGroups();
+ unset( $this->mGroupMemberships[$group] );
+
+ // Refresh the groups caches, and clear the rights cache so it will be
+ // refreshed on the next call to $this->getRights().
+ $this->getEffectiveGroups( true );
+ $this->mRights = null;
+
+ $this->invalidateCache();
+
+ return true;
+ }
+
+ /**
+ * Get whether the user is logged in
+ * @return bool
+ */
+ public function isLoggedIn() {
+ return $this->getId() != 0;
+ }
+
+ /**
+ * Get whether the user is anonymous
+ * @return bool
+ */
+ public function isAnon() {
+ return !$this->isLoggedIn();
+ }
+
+ /**
+ * @return bool Whether this user is flagged as being a bot role account
+ * @since 1.28
+ */
+ public function isBot() {
+ if ( in_array( 'bot', $this->getGroups() ) && $this->isAllowed( 'bot' ) ) {
+ return true;
+ }
+
+ $isBot = false;
+ Hooks::run( "UserIsBot", [ $this, &$isBot ] );
+
+ return $isBot;
+ }
+
+ /**
+ * Check if user is allowed to access a feature / make an action
+ *
+ * @param string $permissions,... Permissions to test
+ * @return bool True if user is allowed to perform *any* of the given actions
+ */
+ public function isAllowedAny() {
+ $permissions = func_get_args();
+ foreach ( $permissions as $permission ) {
+ if ( $this->isAllowed( $permission ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ *
+ * @param string $permissions,... Permissions to test
+ * @return bool True if the user is allowed to perform *all* of the given actions
+ */
+ public function isAllowedAll() {
+ $permissions = func_get_args();
+ foreach ( $permissions as $permission ) {
+ if ( !$this->isAllowed( $permission ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Internal mechanics of testing a permission
+ * @param string $action
+ * @return bool
+ */
+ public function isAllowed( $action = '' ) {
+ if ( $action === '' ) {
+ return true; // In the spirit of DWIM
+ }
+ // Use strict parameter to avoid matching numeric 0 accidentally inserted
+ // by misconfiguration: 0 == 'foo'
+ return in_array( $action, $this->getRights(), true );
+ }
+
+ /**
+ * Check whether to enable recent changes patrol features for this user
+ * @return bool True or false
+ */
+ public function useRCPatrol() {
+ global $wgUseRCPatrol;
+ return $wgUseRCPatrol && $this->isAllowedAny( 'patrol', 'patrolmarks' );
+ }
+
+ /**
+ * Check whether to enable new pages patrol features for this user
+ * @return bool True or false
+ */
+ public function useNPPatrol() {
+ global $wgUseRCPatrol, $wgUseNPPatrol;
+ return (
+ ( $wgUseRCPatrol || $wgUseNPPatrol )
+ && ( $this->isAllowedAny( 'patrol', 'patrolmarks' ) )
+ );
+ }
+
+ /**
+ * Check whether to enable new files patrol features for this user
+ * @return bool True or false
+ */
+ public function useFilePatrol() {
+ global $wgUseRCPatrol, $wgUseFilePatrol;
+ return (
+ ( $wgUseRCPatrol || $wgUseFilePatrol )
+ && ( $this->isAllowedAny( 'patrol', 'patrolmarks' ) )
+ );
+ }
+
+ /**
+ * Get the WebRequest object to use with this object
+ *
+ * @return WebRequest
+ */
+ public function getRequest() {
+ if ( $this->mRequest ) {
+ return $this->mRequest;
+ } else {
+ global $wgRequest;
+ return $wgRequest;
+ }
+ }
+
+ /**
+ * Check the watched status of an article.
+ * @since 1.22 $checkRights parameter added
+ * @param Title $title Title of the article to look at
+ * @param bool $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
+ * Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS.
+ * @return bool
+ */
+ public function isWatched( $title, $checkRights = self::CHECK_USER_RIGHTS ) {
+ if ( $title->isWatchable() && ( !$checkRights || $this->isAllowed( 'viewmywatchlist' ) ) ) {
+ return MediaWikiServices::getInstance()->getWatchedItemStore()->isWatched( $this, $title );
+ }
+ return false;
+ }
+
+ /**
+ * Watch an article.
+ * @since 1.22 $checkRights parameter added
+ * @param Title $title Title of the article to look at
+ * @param bool $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
+ * Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS.
+ */
+ public function addWatch( $title, $checkRights = self::CHECK_USER_RIGHTS ) {
+ if ( !$checkRights || $this->isAllowed( 'editmywatchlist' ) ) {
+ MediaWikiServices::getInstance()->getWatchedItemStore()->addWatchBatchForUser(
+ $this,
+ [ $title->getSubjectPage(), $title->getTalkPage() ]
+ );
+ }
+ $this->invalidateCache();
+ }
+
+ /**
+ * Stop watching an article.
+ * @since 1.22 $checkRights parameter added
+ * @param Title $title Title of the article to look at
+ * @param bool $checkRights Whether to check 'viewmywatchlist'/'editmywatchlist' rights.
+ * Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS.
+ */
+ public function removeWatch( $title, $checkRights = self::CHECK_USER_RIGHTS ) {
+ if ( !$checkRights || $this->isAllowed( 'editmywatchlist' ) ) {
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->removeWatch( $this, $title->getSubjectPage() );
+ $store->removeWatch( $this, $title->getTalkPage() );
+ }
+ $this->invalidateCache();
+ }
+
+ /**
+ * Clear the user's notification timestamp for the given title.
+ * If e-notif e-mails are on, they will receive notification mails on
+ * the next change of the page if it's watched etc.
+ * @note If the user doesn't have 'editmywatchlist', this will do nothing.
+ * @param Title &$title Title of the article to look at
+ * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
+ */
+ public function clearNotification( &$title, $oldid = 0 ) {
+ global $wgUseEnotif, $wgShowUpdatedMarker;
+
+ // Do nothing if the database is locked to writes
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ // Do nothing if not allowed to edit the watchlist
+ if ( !$this->isAllowed( 'editmywatchlist' ) ) {
+ return;
+ }
+
+ // If we're working on user's talk page, we should update the talk page message indicator
+ if ( $title->getNamespace() == NS_USER_TALK && $title->getText() == $this->getName() ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ if ( !Hooks::run( 'UserClearNewTalkNotification', [ &$user, $oldid ] ) ) {
+ return;
+ }
+
+ // Try to update the DB post-send and only if needed...
+ DeferredUpdates::addCallableUpdate( function () use ( $title, $oldid ) {
+ if ( !$this->getNewtalk() ) {
+ return; // no notifications to clear
+ }
+
+ // Delete the last notifications (they stack up)
+ $this->setNewtalk( false );
+
+ // If there is a new, unseen, revision, use its timestamp
+ $nextid = $oldid
+ ? $title->getNextRevisionID( $oldid, Title::GAID_FOR_UPDATE )
+ : null;
+ if ( $nextid ) {
+ $this->setNewtalk( true, Revision::newFromId( $nextid ) );
+ }
+ } );
+ }
+
+ if ( !$wgUseEnotif && !$wgShowUpdatedMarker ) {
+ return;
+ }
+
+ if ( $this->isAnon() ) {
+ // Nothing else to do...
+ return;
+ }
+
+ // Only update the timestamp if the page is being watched.
+ // The query to find out if it is watched is cached both in memcached and per-invocation,
+ // and when it does have to be executed, it can be on a replica DB
+ // If this is the user's newtalk page, we always update the timestamp
+ $force = '';
+ if ( $title->getNamespace() == NS_USER_TALK && $title->getText() == $this->getName() ) {
+ $force = 'force';
+ }
+
+ MediaWikiServices::getInstance()->getWatchedItemStore()
+ ->resetNotificationTimestamp( $this, $title, $force, $oldid );
+ }
+
+ /**
+ * Resets all of the given user's page-change notification timestamps.
+ * If e-notif e-mails are on, they will receive notification mails on
+ * the next change of any watched page.
+ * @note If the user doesn't have 'editmywatchlist', this will do nothing.
+ */
+ public function clearAllNotifications() {
+ global $wgUseEnotif, $wgShowUpdatedMarker;
+ // Do nothing if not allowed to edit the watchlist
+ if ( wfReadOnly() || !$this->isAllowed( 'editmywatchlist' ) ) {
+ return;
+ }
+
+ if ( !$wgUseEnotif && !$wgShowUpdatedMarker ) {
+ $this->setNewtalk( false );
+ return;
+ }
+
+ $id = $this->getId();
+ if ( !$id ) {
+ return;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $asOfTimes = array_unique( $dbw->selectFieldValues(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [ 'wl_user' => $id, 'wl_notificationtimestamp IS NOT NULL' ],
+ __METHOD__,
+ [ 'ORDER BY' => 'wl_notificationtimestamp DESC', 'LIMIT' => 500 ]
+ ) );
+ if ( !$asOfTimes ) {
+ return;
+ }
+ // Immediately update the most recent touched rows, which hopefully covers what
+ // the user sees on the watchlist page before pressing "mark all pages visited"....
+ $dbw->update(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [ 'wl_user' => $id, 'wl_notificationtimestamp' => $asOfTimes ],
+ __METHOD__
+ );
+ // ...and finish the older ones in a post-send update with lag checks...
+ DeferredUpdates::addUpdate( new AutoCommitUpdate(
+ $dbw,
+ __METHOD__,
+ function () use ( $dbw, $id ) {
+ global $wgUpdateRowsPerQuery;
+
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+ $asOfTimes = array_unique( $dbw->selectFieldValues(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [ 'wl_user' => $id, 'wl_notificationtimestamp IS NOT NULL' ],
+ __METHOD__
+ ) );
+ foreach ( array_chunk( $asOfTimes, $wgUpdateRowsPerQuery ) as $asOfTimeBatch ) {
+ $dbw->update(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [ 'wl_user' => $id, 'wl_notificationtimestamp' => $asOfTimeBatch ],
+ __METHOD__
+ );
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
+ }
+ }
+ ) );
+ // We also need to clear here the "you have new message" notification for the own
+ // user_talk page; it's cleared one page view later in WikiPage::doViewUpdates().
+ }
+
+ /**
+ * Compute experienced level based on edit count and registration date.
+ *
+ * @return string 'newcomer', 'learner', or 'experienced'
+ */
+ public function getExperienceLevel() {
+ global $wgLearnerEdits,
+ $wgExperiencedUserEdits,
+ $wgLearnerMemberSince,
+ $wgExperiencedUserMemberSince;
+
+ if ( $this->isAnon() ) {
+ return false;
+ }
+
+ $editCount = $this->getEditCount();
+ $registration = $this->getRegistration();
+ $now = time();
+ $learnerRegistration = wfTimestamp( TS_MW, $now - $wgLearnerMemberSince * 86400 );
+ $experiencedRegistration = wfTimestamp( TS_MW, $now - $wgExperiencedUserMemberSince * 86400 );
+
+ if (
+ $editCount < $wgLearnerEdits ||
+ $registration > $learnerRegistration
+ ) {
+ return 'newcomer';
+ } elseif (
+ $editCount > $wgExperiencedUserEdits &&
+ $registration <= $experiencedRegistration
+ ) {
+ return 'experienced';
+ } else {
+ return 'learner';
+ }
+ }
+
+ /**
+ * Set a cookie on the user's client. Wrapper for
+ * WebResponse::setCookie
+ * @deprecated since 1.27
+ * @param string $name Name of the cookie to set
+ * @param string $value Value to set
+ * @param int $exp Expiration time, as a UNIX time value;
+ * if 0 or not specified, use the default $wgCookieExpiration
+ * @param bool $secure
+ * true: Force setting the secure attribute when setting the cookie
+ * false: Force NOT setting the secure attribute when setting the cookie
+ * null (default): Use the default ($wgCookieSecure) to set the secure attribute
+ * @param array $params Array of options sent passed to WebResponse::setcookie()
+ * @param WebRequest|null $request WebRequest object to use; $wgRequest will be used if null
+ * is passed.
+ */
+ protected function setCookie(
+ $name, $value, $exp = 0, $secure = null, $params = [], $request = null
+ ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ if ( $request === null ) {
+ $request = $this->getRequest();
+ }
+ $params['secure'] = $secure;
+ $request->response()->setCookie( $name, $value, $exp, $params );
+ }
+
+ /**
+ * Clear a cookie on the user's client
+ * @deprecated since 1.27
+ * @param string $name Name of the cookie to clear
+ * @param bool $secure
+ * true: Force setting the secure attribute when setting the cookie
+ * false: Force NOT setting the secure attribute when setting the cookie
+ * null (default): Use the default ($wgCookieSecure) to set the secure attribute
+ * @param array $params Array of options sent passed to WebResponse::setcookie()
+ */
+ protected function clearCookie( $name, $secure = null, $params = [] ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ $this->setCookie( $name, '', time() - 86400, $secure, $params );
+ }
+
+ /**
+ * Set an extended login cookie on the user's client. The expiry of the cookie
+ * is controlled by the $wgExtendedLoginCookieExpiration configuration
+ * variable.
+ *
+ * @see User::setCookie
+ *
+ * @deprecated since 1.27
+ * @param string $name Name of the cookie to set
+ * @param string $value Value to set
+ * @param bool $secure
+ * true: Force setting the secure attribute when setting the cookie
+ * false: Force NOT setting the secure attribute when setting the cookie
+ * null (default): Use the default ($wgCookieSecure) to set the secure attribute
+ */
+ protected function setExtendedLoginCookie( $name, $value, $secure ) {
+ global $wgExtendedLoginCookieExpiration, $wgCookieExpiration;
+
+ wfDeprecated( __METHOD__, '1.27' );
+
+ $exp = time();
+ $exp += $wgExtendedLoginCookieExpiration !== null
+ ? $wgExtendedLoginCookieExpiration
+ : $wgCookieExpiration;
+
+ $this->setCookie( $name, $value, $exp, $secure );
+ }
+
+ /**
+ * Persist this user's session (e.g. set cookies)
+ *
+ * @param WebRequest|null $request WebRequest object to use; $wgRequest will be used if null
+ * is passed.
+ * @param bool $secure Whether to force secure/insecure cookies or use default
+ * @param bool $rememberMe Whether to add a Token cookie for elongated sessions
+ */
+ public function setCookies( $request = null, $secure = null, $rememberMe = false ) {
+ $this->load();
+ if ( 0 == $this->mId ) {
+ return;
+ }
+
+ $session = $this->getRequest()->getSession();
+ if ( $request && $session->getRequest() !== $request ) {
+ $session = $session->sessionWithRequest( $request );
+ }
+ $delay = $session->delaySave();
+
+ if ( !$session->getUser()->equals( $this ) ) {
+ if ( !$session->canSetUser() ) {
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+ ->warning( __METHOD__ .
+ ": Cannot save user \"$this\" to a user \"{$session->getUser()}\"'s immutable session"
+ );
+ return;
+ }
+ $session->setUser( $this );
+ }
+
+ $session->setRememberUser( $rememberMe );
+ if ( $secure !== null ) {
+ $session->setForceHTTPS( $secure );
+ }
+
+ $session->persist();
+
+ ScopedCallback::consume( $delay );
+ }
+
+ /**
+ * Log this user out.
+ */
+ public function logout() {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ if ( Hooks::run( 'UserLogout', [ &$user ] ) ) {
+ $this->doLogout();
+ }
+ }
+
+ /**
+ * Clear the user's session, and reset the instance cache.
+ * @see logout()
+ */
+ public function doLogout() {
+ $session = $this->getRequest()->getSession();
+ if ( !$session->canSetUser() ) {
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+ ->warning( __METHOD__ . ": Cannot log out of an immutable session" );
+ $error = 'immutable';
+ } elseif ( !$session->getUser()->equals( $this ) ) {
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+ ->warning( __METHOD__ .
+ ": Cannot log user \"$this\" out of a user \"{$session->getUser()}\"'s session"
+ );
+ // But we still may as well make this user object anon
+ $this->clearInstanceCache( 'defaults' );
+ $error = 'wronguser';
+ } else {
+ $this->clearInstanceCache( 'defaults' );
+ $delay = $session->delaySave();
+ $session->unpersist(); // Clear cookies (T127436)
+ $session->setLoggedOutTimestamp( time() );
+ $session->setUser( new User );
+ $session->set( 'wsUserID', 0 ); // Other code expects this
+ $session->resetAllTokens();
+ ScopedCallback::consume( $delay );
+ $error = false;
+ }
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'authevents' )->info( 'Logout', [
+ 'event' => 'logout',
+ 'successful' => $error === false,
+ 'status' => $error ?: 'success',
+ ] );
+ }
+
+ /**
+ * Save this user's settings into the database.
+ * @todo Only rarely do all these fields need to be set!
+ */
+ public function saveSettings() {
+ if ( wfReadOnly() ) {
+ // @TODO: caller should deal with this instead!
+ // This should really just be an exception.
+ MWExceptionHandler::logException( new DBExpectedError(
+ null,
+ "Could not update user with ID '{$this->mId}'; DB is read-only."
+ ) );
+ return;
+ }
+
+ $this->load();
+ if ( 0 == $this->mId ) {
+ return; // anon
+ }
+
+ // Get a new user_touched that is higher than the old one.
+ // This will be used for a CAS check as a last-resort safety
+ // check against race conditions and replica DB lag.
+ $newTouched = $this->newTouchedTimestamp();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update( 'user',
+ [ /* SET */
+ 'user_name' => $this->mName,
+ 'user_real_name' => $this->mRealName,
+ 'user_email' => $this->mEmail,
+ 'user_email_authenticated' => $dbw->timestampOrNull( $this->mEmailAuthenticated ),
+ 'user_touched' => $dbw->timestamp( $newTouched ),
+ 'user_token' => strval( $this->mToken ),
+ 'user_email_token' => $this->mEmailToken,
+ 'user_email_token_expires' => $dbw->timestampOrNull( $this->mEmailTokenExpires ),
+ ], $this->makeUpdateConditions( $dbw, [ /* WHERE */
+ 'user_id' => $this->mId,
+ ] ), __METHOD__
+ );
+
+ if ( !$dbw->affectedRows() ) {
+ // Maybe the problem was a missed cache update; clear it to be safe
+ $this->clearSharedCache( 'refresh' );
+ // User was changed in the meantime or loaded with stale data
+ $from = ( $this->queryFlagsUsed & self::READ_LATEST ) ? 'master' : 'replica';
+ throw new MWException(
+ "CAS update failed on user_touched for user ID '{$this->mId}' (read from $from);" .
+ " the version of the user to be saved is older than the current version."
+ );
+ }
+
+ $this->mTouched = $newTouched;
+ $this->saveOptions();
+
+ Hooks::run( 'UserSaveSettings', [ $this ] );
+ $this->clearSharedCache();
+ $this->getUserPage()->invalidateCache();
+ }
+
+ /**
+ * If only this user's username is known, and it exists, return the user ID.
+ *
+ * @param int $flags Bitfield of User:READ_* constants; useful for existence checks
+ * @return int
+ */
+ public function idForName( $flags = 0 ) {
+ $s = trim( $this->getName() );
+ if ( $s === '' ) {
+ return 0;
+ }
+
+ $db = ( ( $flags & self::READ_LATEST ) == self::READ_LATEST )
+ ? wfGetDB( DB_MASTER )
+ : wfGetDB( DB_REPLICA );
+
+ $options = ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING )
+ ? [ 'LOCK IN SHARE MODE' ]
+ : [];
+
+ $id = $db->selectField( 'user',
+ 'user_id', [ 'user_name' => $s ], __METHOD__, $options );
+
+ return (int)$id;
+ }
+
+ /**
+ * Add a user to the database, return the user object
+ *
+ * @param string $name Username to add
+ * @param array $params Array of Strings Non-default parameters to save to
+ * the database as user_* fields:
+ * - email: The user's email address.
+ * - email_authenticated: The email authentication timestamp.
+ * - real_name: The user's real name.
+ * - options: An associative array of non-default options.
+ * - token: Random authentication token. Do not set.
+ * - registration: Registration timestamp. Do not set.
+ *
+ * @return User|null User object, or null if the username already exists.
+ */
+ public static function createNew( $name, $params = [] ) {
+ foreach ( [ 'password', 'newpassword', 'newpass_time', 'password_expires' ] as $field ) {
+ if ( isset( $params[$field] ) ) {
+ wfDeprecated( __METHOD__ . " with param '$field'", '1.27' );
+ unset( $params[$field] );
+ }
+ }
+
+ $user = new User;
+ $user->load();
+ $user->setToken(); // init token
+ if ( isset( $params['options'] ) ) {
+ $user->mOptions = $params['options'] + (array)$user->mOptions;
+ unset( $params['options'] );
+ }
+ $dbw = wfGetDB( DB_MASTER );
+
+ $noPass = PasswordFactory::newInvalidPassword()->toString();
+
+ $fields = [
+ 'user_name' => $name,
+ 'user_password' => $noPass,
+ 'user_newpassword' => $noPass,
+ 'user_email' => $user->mEmail,
+ 'user_email_authenticated' => $dbw->timestampOrNull( $user->mEmailAuthenticated ),
+ 'user_real_name' => $user->mRealName,
+ 'user_token' => strval( $user->mToken ),
+ 'user_registration' => $dbw->timestamp( $user->mRegistration ),
+ 'user_editcount' => 0,
+ 'user_touched' => $dbw->timestamp( $user->newTouchedTimestamp() ),
+ ];
+ foreach ( $params as $name => $value ) {
+ $fields["user_$name"] = $value;
+ }
+ $dbw->insert( 'user', $fields, __METHOD__, [ 'IGNORE' ] );
+ if ( $dbw->affectedRows() ) {
+ $newUser = self::newFromId( $dbw->insertId() );
+ } else {
+ $newUser = null;
+ }
+ return $newUser;
+ }
+
+ /**
+ * Add this existing user object to the database. If the user already
+ * exists, a fatal status object is returned, and the user object is
+ * initialised with the data from the database.
+ *
+ * Previously, this function generated a DB error due to a key conflict
+ * if the user already existed. Many extension callers use this function
+ * in code along the lines of:
+ *
+ * $user = User::newFromName( $name );
+ * if ( !$user->isLoggedIn() ) {
+ * $user->addToDatabase();
+ * }
+ * // do something with $user...
+ *
+ * However, this was vulnerable to a race condition (T18020). By
+ * initialising the user object if the user exists, we aim to support this
+ * calling sequence as far as possible.
+ *
+ * Note that if the user exists, this function will acquire a write lock,
+ * so it is still advisable to make the call conditional on isLoggedIn(),
+ * and to commit the transaction after calling.
+ *
+ * @throws MWException
+ * @return Status
+ */
+ public function addToDatabase() {
+ $this->load();
+ if ( !$this->mToken ) {
+ $this->setToken(); // init token
+ }
+
+ if ( !is_string( $this->mName ) ) {
+ throw new RuntimeException( "User name field is not set." );
+ }
+
+ $this->mTouched = $this->newTouchedTimestamp();
+
+ $noPass = PasswordFactory::newInvalidPassword()->toString();
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert( 'user',
+ [
+ 'user_name' => $this->mName,
+ 'user_password' => $noPass,
+ 'user_newpassword' => $noPass,
+ 'user_email' => $this->mEmail,
+ 'user_email_authenticated' => $dbw->timestampOrNull( $this->mEmailAuthenticated ),
+ 'user_real_name' => $this->mRealName,
+ 'user_token' => strval( $this->mToken ),
+ 'user_registration' => $dbw->timestamp( $this->mRegistration ),
+ 'user_editcount' => 0,
+ 'user_touched' => $dbw->timestamp( $this->mTouched ),
+ ], __METHOD__,
+ [ 'IGNORE' ]
+ );
+ if ( !$dbw->affectedRows() ) {
+ // Use locking reads to bypass any REPEATABLE-READ snapshot.
+ $this->mId = $dbw->selectField(
+ 'user',
+ 'user_id',
+ [ 'user_name' => $this->mName ],
+ __METHOD__,
+ [ 'LOCK IN SHARE MODE' ]
+ );
+ $loaded = false;
+ if ( $this->mId ) {
+ if ( $this->loadFromDatabase( self::READ_LOCKING ) ) {
+ $loaded = true;
+ }
+ }
+ if ( !$loaded ) {
+ throw new MWException( __METHOD__ . ": hit a key conflict attempting " .
+ "to insert user '{$this->mName}' row, but it was not present in select!" );
+ }
+ return Status::newFatal( 'userexists' );
+ }
+ $this->mId = $dbw->insertId();
+ self::$idCacheByName[$this->mName] = $this->mId;
+
+ // Clear instance cache other than user table data, which is already accurate
+ $this->clearInstanceCache();
+
+ $this->saveOptions();
+ return Status::newGood();
+ }
+
+ /**
+ * If this user is logged-in and blocked,
+ * block any IP address they've successfully logged in from.
+ * @return bool A block was spread
+ */
+ public function spreadAnyEditBlock() {
+ if ( $this->isLoggedIn() && $this->isBlocked() ) {
+ return $this->spreadBlock();
+ }
+
+ return false;
+ }
+
+ /**
+ * If this (non-anonymous) user is blocked,
+ * block the IP address they've successfully logged in from.
+ * @return bool A block was spread
+ */
+ protected function spreadBlock() {
+ wfDebug( __METHOD__ . "()\n" );
+ $this->load();
+ if ( $this->mId == 0 ) {
+ return false;
+ }
+
+ $userblock = Block::newFromTarget( $this->getName() );
+ if ( !$userblock ) {
+ return false;
+ }
+
+ return (bool)$userblock->doAutoblock( $this->getRequest()->getIP() );
+ }
+
+ /**
+ * Get whether the user is explicitly blocked from account creation.
+ * @return bool|Block
+ */
+ public function isBlockedFromCreateAccount() {
+ $this->getBlockedStatus();
+ if ( $this->mBlock && $this->mBlock->prevents( 'createaccount' ) ) {
+ return $this->mBlock;
+ }
+
+ # T15611: if the IP address the user is trying to create an account from is
+ # blocked with createaccount disabled, prevent new account creation there even
+ # when the user is logged in
+ if ( $this->mBlockedFromCreateAccount === false && !$this->isAllowed( 'ipblock-exempt' ) ) {
+ $this->mBlockedFromCreateAccount = Block::newFromTarget( null, $this->getRequest()->getIP() );
+ }
+ return $this->mBlockedFromCreateAccount instanceof Block
+ && $this->mBlockedFromCreateAccount->prevents( 'createaccount' )
+ ? $this->mBlockedFromCreateAccount
+ : false;
+ }
+
+ /**
+ * Get whether the user is blocked from using Special:Emailuser.
+ * @return bool
+ */
+ public function isBlockedFromEmailuser() {
+ $this->getBlockedStatus();
+ return $this->mBlock && $this->mBlock->prevents( 'sendemail' );
+ }
+
+ /**
+ * Get whether the user is allowed to create an account.
+ * @return bool
+ */
+ public function isAllowedToCreateAccount() {
+ return $this->isAllowed( 'createaccount' ) && !$this->isBlockedFromCreateAccount();
+ }
+
+ /**
+ * Get this user's personal page title.
+ *
+ * @return Title User's personal page title
+ */
+ public function getUserPage() {
+ return Title::makeTitle( NS_USER, $this->getName() );
+ }
+
+ /**
+ * Get this user's talk page title.
+ *
+ * @return Title User's talk page title
+ */
+ public function getTalkPage() {
+ $title = $this->getUserPage();
+ return $title->getTalkPage();
+ }
+
+ /**
+ * Determine whether the user is a newbie. Newbies are either
+ * anonymous IPs, or the most recently created accounts.
+ * @return bool
+ */
+ public function isNewbie() {
+ return !$this->isAllowed( 'autoconfirmed' );
+ }
+
+ /**
+ * Check to see if the given clear-text password is one of the accepted passwords
+ * @deprecated since 1.27, use AuthManager instead
+ * @param string $password User password
+ * @return bool True if the given password is correct, otherwise False
+ */
+ public function checkPassword( $password ) {
+ $manager = AuthManager::singleton();
+ $reqs = AuthenticationRequest::loadRequestsFromSubmission(
+ $manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN ),
+ [
+ 'username' => $this->getName(),
+ 'password' => $password,
+ ]
+ );
+ $res = AuthManager::singleton()->beginAuthentication( $reqs, 'null:' );
+ switch ( $res->status ) {
+ case AuthenticationResponse::PASS:
+ return true;
+ case AuthenticationResponse::FAIL:
+ // Hope it's not a PreAuthenticationProvider that failed...
+ \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' )
+ ->info( __METHOD__ . ': Authentication failed: ' . $res->message->plain() );
+ return false;
+ default:
+ throw new BadMethodCallException(
+ 'AuthManager returned a response unsupported by ' . __METHOD__
+ );
+ }
+ }
+
+ /**
+ * Check if the given clear-text password matches the temporary password
+ * sent by e-mail for password reset operations.
+ *
+ * @deprecated since 1.27, use AuthManager instead
+ * @param string $plaintext
+ * @return bool True if matches, false otherwise
+ */
+ public function checkTemporaryPassword( $plaintext ) {
+ // Can't check the temporary password individually.
+ return $this->checkPassword( $plaintext );
+ }
+
+ /**
+ * Initialize (if necessary) and return a session token value
+ * which can be used in edit forms to show that the user's
+ * login credentials aren't being hijacked with a foreign form
+ * submission.
+ *
+ * @since 1.27
+ * @param string|array $salt Array of Strings Optional function-specific data for hashing
+ * @param WebRequest|null $request WebRequest object to use or null to use $wgRequest
+ * @return MediaWiki\Session\Token The new edit token
+ */
+ public function getEditTokenObject( $salt = '', $request = null ) {
+ if ( $this->isAnon() ) {
+ return new LoggedOutEditToken();
+ }
+
+ if ( !$request ) {
+ $request = $this->getRequest();
+ }
+ return $request->getSession()->getToken( $salt );
+ }
+
+ /**
+ * Initialize (if necessary) and return a session token value
+ * which can be used in edit forms to show that the user's
+ * login credentials aren't being hijacked with a foreign form
+ * submission.
+ *
+ * The $salt for 'edit' and 'csrf' tokens is the default (empty string).
+ *
+ * @since 1.19
+ * @param string|array $salt Array of Strings Optional function-specific data for hashing
+ * @param WebRequest|null $request WebRequest object to use or null to use $wgRequest
+ * @return string The new edit token
+ */
+ public function getEditToken( $salt = '', $request = null ) {
+ return $this->getEditTokenObject( $salt, $request )->toString();
+ }
+
+ /**
+ * Get the embedded timestamp from a token.
+ * @deprecated since 1.27, use \MediaWiki\Session\Token::getTimestamp instead.
+ * @param string $val Input token
+ * @return int|null
+ */
+ public static function getEditTokenTimestamp( $val ) {
+ wfDeprecated( __METHOD__, '1.27' );
+ return MediaWiki\Session\Token::getTimestamp( $val );
+ }
+
+ /**
+ * Check given value against the token value stored in the session.
+ * A match should confirm that the form was submitted from the
+ * user's own login session, not a form submission from a third-party
+ * site.
+ *
+ * @param string $val Input value to compare
+ * @param string $salt Optional function-specific data for hashing
+ * @param WebRequest|null $request Object to use or null to use $wgRequest
+ * @param int $maxage Fail tokens older than this, in seconds
+ * @return bool Whether the token matches
+ */
+ public function matchEditToken( $val, $salt = '', $request = null, $maxage = null ) {
+ return $this->getEditTokenObject( $salt, $request )->match( $val, $maxage );
+ }
+
+ /**
+ * Check given value against the token value stored in the session,
+ * ignoring the suffix.
+ *
+ * @param string $val Input value to compare
+ * @param string $salt Optional function-specific data for hashing
+ * @param WebRequest|null $request Object to use or null to use $wgRequest
+ * @param int $maxage Fail tokens older than this, in seconds
+ * @return bool Whether the token matches
+ */
+ public function matchEditTokenNoSuffix( $val, $salt = '', $request = null, $maxage = null ) {
+ $val = substr( $val, 0, strspn( $val, '0123456789abcdef' ) ) . Token::SUFFIX;
+ return $this->matchEditToken( $val, $salt, $request, $maxage );
+ }
+
+ /**
+ * Generate a new e-mail confirmation token and send a confirmation/invalidation
+ * mail to the user's given address.
+ *
+ * @param string $type Message to send, either "created", "changed" or "set"
+ * @return Status
+ */
+ public function sendConfirmationMail( $type = 'created' ) {
+ global $wgLang;
+ $expiration = null; // gets passed-by-ref and defined in next line.
+ $token = $this->confirmationToken( $expiration );
+ $url = $this->confirmationTokenUrl( $token );
+ $invalidateURL = $this->invalidationTokenUrl( $token );
+ $this->saveSettings();
+
+ if ( $type == 'created' || $type === false ) {
+ $message = 'confirmemail_body';
+ } elseif ( $type === true ) {
+ $message = 'confirmemail_body_changed';
+ } else {
+ // Messages: confirmemail_body_changed, confirmemail_body_set
+ $message = 'confirmemail_body_' . $type;
+ }
+
+ return $this->sendMail( wfMessage( 'confirmemail_subject' )->text(),
+ wfMessage( $message,
+ $this->getRequest()->getIP(),
+ $this->getName(),
+ $url,
+ $wgLang->userTimeAndDate( $expiration, $this ),
+ $invalidateURL,
+ $wgLang->userDate( $expiration, $this ),
+ $wgLang->userTime( $expiration, $this ) )->text() );
+ }
+
+ /**
+ * Send an e-mail to this user's account. Does not check for
+ * confirmed status or validity.
+ *
+ * @param string $subject Message subject
+ * @param string $body Message body
+ * @param User|null $from Optional sending user; if unspecified, default
+ * $wgPasswordSender will be used.
+ * @param string $replyto Reply-To address
+ * @return Status
+ */
+ public function sendMail( $subject, $body, $from = null, $replyto = null ) {
+ global $wgPasswordSender;
+
+ if ( $from instanceof User ) {
+ $sender = MailAddress::newFromUser( $from );
+ } else {
+ $sender = new MailAddress( $wgPasswordSender,
+ wfMessage( 'emailsender' )->inContentLanguage()->text() );
+ }
+ $to = MailAddress::newFromUser( $this );
+
+ return UserMailer::send( $to, $sender, $subject, $body, [
+ 'replyTo' => $replyto,
+ ] );
+ }
+
+ /**
+ * Generate, store, and return a new e-mail confirmation code.
+ * A hash (unsalted, since it's used as a key) is stored.
+ *
+ * @note Call saveSettings() after calling this function to commit
+ * this change to the database.
+ *
+ * @param string &$expiration Accepts the expiration time
+ * @return string New token
+ */
+ protected function confirmationToken( &$expiration ) {
+ global $wgUserEmailConfirmationTokenExpiry;
+ $now = time();
+ $expires = $now + $wgUserEmailConfirmationTokenExpiry;
+ $expiration = wfTimestamp( TS_MW, $expires );
+ $this->load();
+ $token = MWCryptRand::generateHex( 32 );
+ $hash = md5( $token );
+ $this->mEmailToken = $hash;
+ $this->mEmailTokenExpires = $expiration;
+ return $token;
+ }
+
+ /**
+ * Return a URL the user can use to confirm their email address.
+ * @param string $token Accepts the email confirmation token
+ * @return string New token URL
+ */
+ protected function confirmationTokenUrl( $token ) {
+ return $this->getTokenUrl( 'ConfirmEmail', $token );
+ }
+
+ /**
+ * Return a URL the user can use to invalidate their email address.
+ * @param string $token Accepts the email confirmation token
+ * @return string New token URL
+ */
+ protected function invalidationTokenUrl( $token ) {
+ return $this->getTokenUrl( 'InvalidateEmail', $token );
+ }
+
+ /**
+ * Internal function to format the e-mail validation/invalidation URLs.
+ * This uses a quickie hack to use the
+ * hardcoded English names of the Special: pages, for ASCII safety.
+ *
+ * @note Since these URLs get dropped directly into emails, using the
+ * short English names avoids insanely long URL-encoded links, which
+ * also sometimes can get corrupted in some browsers/mailers
+ * (T8957 with Gmail and Internet Explorer).
+ *
+ * @param string $page Special page
+ * @param string $token Token
+ * @return string Formatted URL
+ */
+ protected function getTokenUrl( $page, $token ) {
+ // Hack to bypass localization of 'Special:'
+ $title = Title::makeTitle( NS_MAIN, "Special:$page/$token" );
+ return $title->getCanonicalURL();
+ }
+
+ /**
+ * Mark the e-mail address confirmed.
+ *
+ * @note Call saveSettings() after calling this function to commit the change.
+ *
+ * @return bool
+ */
+ public function confirmEmail() {
+ // Check if it's already confirmed, so we don't touch the database
+ // and fire the ConfirmEmailComplete hook on redundant confirmations.
+ if ( !$this->isEmailConfirmed() ) {
+ $this->setEmailAuthenticationTimestamp( wfTimestampNow() );
+ Hooks::run( 'ConfirmEmailComplete', [ $this ] );
+ }
+ return true;
+ }
+
+ /**
+ * Invalidate the user's e-mail confirmation, and unauthenticate the e-mail
+ * address if it was already confirmed.
+ *
+ * @note Call saveSettings() after calling this function to commit the change.
+ * @return bool Returns true
+ */
+ public function invalidateEmail() {
+ $this->load();
+ $this->mEmailToken = null;
+ $this->mEmailTokenExpires = null;
+ $this->setEmailAuthenticationTimestamp( null );
+ $this->mEmail = '';
+ Hooks::run( 'InvalidateEmailComplete', [ $this ] );
+ return true;
+ }
+
+ /**
+ * Set the e-mail authentication timestamp.
+ * @param string $timestamp TS_MW timestamp
+ */
+ public function setEmailAuthenticationTimestamp( $timestamp ) {
+ $this->load();
+ $this->mEmailAuthenticated = $timestamp;
+ Hooks::run( 'UserSetEmailAuthenticationTimestamp', [ $this, &$this->mEmailAuthenticated ] );
+ }
+
+ /**
+ * Is this user allowed to send e-mails within limits of current
+ * site configuration?
+ * @return bool
+ */
+ public function canSendEmail() {
+ global $wgEnableEmail, $wgEnableUserEmail;
+ if ( !$wgEnableEmail || !$wgEnableUserEmail || !$this->isAllowed( 'sendemail' ) ) {
+ return false;
+ }
+ $canSend = $this->isEmailConfirmed();
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ Hooks::run( 'UserCanSendEmail', [ &$user, &$canSend ] );
+ return $canSend;
+ }
+
+ /**
+ * Is this user allowed to receive e-mails within limits of current
+ * site configuration?
+ * @return bool
+ */
+ public function canReceiveEmail() {
+ return $this->isEmailConfirmed() && !$this->getOption( 'disablemail' );
+ }
+
+ /**
+ * Is this user's e-mail address valid-looking and confirmed within
+ * limits of the current site configuration?
+ *
+ * @note If $wgEmailAuthentication is on, this may require the user to have
+ * confirmed their address by returning a code or using a password
+ * sent to the address from the wiki.
+ *
+ * @return bool
+ */
+ public function isEmailConfirmed() {
+ global $wgEmailAuthentication;
+ $this->load();
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $user = $this;
+ $confirmed = true;
+ if ( Hooks::run( 'EmailConfirmed', [ &$user, &$confirmed ] ) ) {
+ if ( $this->isAnon() ) {
+ return false;
+ }
+ if ( !Sanitizer::validateEmail( $this->mEmail ) ) {
+ return false;
+ }
+ if ( $wgEmailAuthentication && !$this->getEmailAuthenticationTimestamp() ) {
+ return false;
+ }
+ return true;
+ } else {
+ return $confirmed;
+ }
+ }
+
+ /**
+ * Check whether there is an outstanding request for e-mail confirmation.
+ * @return bool
+ */
+ public function isEmailConfirmationPending() {
+ global $wgEmailAuthentication;
+ return $wgEmailAuthentication &&
+ !$this->isEmailConfirmed() &&
+ $this->mEmailToken &&
+ $this->mEmailTokenExpires > wfTimestamp();
+ }
+
+ /**
+ * Get the timestamp of account creation.
+ *
+ * @return string|bool|null Timestamp of account creation, false for
+ * non-existent/anonymous user accounts, or null if existing account
+ * but information is not in database.
+ */
+ public function getRegistration() {
+ if ( $this->isAnon() ) {
+ return false;
+ }
+ $this->load();
+ return $this->mRegistration;
+ }
+
+ /**
+ * Get the timestamp of the first edit
+ *
+ * @return string|bool Timestamp of first edit, or false for
+ * non-existent/anonymous user accounts.
+ */
+ public function getFirstEditTimestamp() {
+ if ( $this->getId() == 0 ) {
+ return false; // anons
+ }
+ $dbr = wfGetDB( DB_REPLICA );
+ $time = $dbr->selectField( 'revision', 'rev_timestamp',
+ [ 'rev_user' => $this->getId() ],
+ __METHOD__,
+ [ 'ORDER BY' => 'rev_timestamp ASC' ]
+ );
+ if ( !$time ) {
+ return false; // no edits
+ }
+ return wfTimestamp( TS_MW, $time );
+ }
+
+ /**
+ * Get the permissions associated with a given list of groups
+ *
+ * @param array $groups Array of Strings List of internal group names
+ * @return array Array of Strings List of permission key names for given groups combined
+ */
+ public static function getGroupPermissions( $groups ) {
+ global $wgGroupPermissions, $wgRevokePermissions;
+ $rights = [];
+ // grant every granted permission first
+ foreach ( $groups as $group ) {
+ if ( isset( $wgGroupPermissions[$group] ) ) {
+ $rights = array_merge( $rights,
+ // array_filter removes empty items
+ array_keys( array_filter( $wgGroupPermissions[$group] ) ) );
+ }
+ }
+ // now revoke the revoked permissions
+ foreach ( $groups as $group ) {
+ if ( isset( $wgRevokePermissions[$group] ) ) {
+ $rights = array_diff( $rights,
+ array_keys( array_filter( $wgRevokePermissions[$group] ) ) );
+ }
+ }
+ return array_unique( $rights );
+ }
+
+ /**
+ * Get all the groups who have a given permission
+ *
+ * @param string $role Role to check
+ * @return array Array of Strings List of internal group names with the given permission
+ */
+ public static function getGroupsWithPermission( $role ) {
+ global $wgGroupPermissions;
+ $allowedGroups = [];
+ foreach ( array_keys( $wgGroupPermissions ) as $group ) {
+ if ( self::groupHasPermission( $group, $role ) ) {
+ $allowedGroups[] = $group;
+ }
+ }
+ return $allowedGroups;
+ }
+
+ /**
+ * Check, if the given group has the given permission
+ *
+ * If you're wanting to check whether all users have a permission, use
+ * User::isEveryoneAllowed() instead. That properly checks if it's revoked
+ * from anyone.
+ *
+ * @since 1.21
+ * @param string $group Group to check
+ * @param string $role Role to check
+ * @return bool
+ */
+ public static function groupHasPermission( $group, $role ) {
+ global $wgGroupPermissions, $wgRevokePermissions;
+ return isset( $wgGroupPermissions[$group][$role] ) && $wgGroupPermissions[$group][$role]
+ && !( isset( $wgRevokePermissions[$group][$role] ) && $wgRevokePermissions[$group][$role] );
+ }
+
+ /**
+ * Check if all users may be assumed to have the given permission
+ *
+ * We generally assume so if the right is granted to '*' and isn't revoked
+ * on any group. It doesn't attempt to take grants or other extension
+ * limitations on rights into account in the general case, though, as that
+ * would require it to always return false and defeat the purpose.
+ * Specifically, session-based rights restrictions (such as OAuth or bot
+ * passwords) are applied based on the current session.
+ *
+ * @since 1.22
+ * @param string $right Right to check
+ * @return bool
+ */
+ public static function isEveryoneAllowed( $right ) {
+ global $wgGroupPermissions, $wgRevokePermissions;
+ static $cache = [];
+
+ // Use the cached results, except in unit tests which rely on
+ // being able change the permission mid-request
+ if ( isset( $cache[$right] ) && !defined( 'MW_PHPUNIT_TEST' ) ) {
+ return $cache[$right];
+ }
+
+ if ( !isset( $wgGroupPermissions['*'][$right] ) || !$wgGroupPermissions['*'][$right] ) {
+ $cache[$right] = false;
+ return false;
+ }
+
+ // If it's revoked anywhere, then everyone doesn't have it
+ foreach ( $wgRevokePermissions as $rights ) {
+ if ( isset( $rights[$right] ) && $rights[$right] ) {
+ $cache[$right] = false;
+ return false;
+ }
+ }
+
+ // Remove any rights that aren't allowed to the global-session user,
+ // unless there are no sessions for this endpoint.
+ if ( !defined( 'MW_NO_SESSION' ) ) {
+ $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights();
+ if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
+ $cache[$right] = false;
+ return false;
+ }
+ }
+
+ // Allow extensions to say false
+ if ( !Hooks::run( 'UserIsEveryoneAllowed', [ $right ] ) ) {
+ $cache[$right] = false;
+ return false;
+ }
+
+ $cache[$right] = true;
+ return true;
+ }
+
+ /**
+ * Get the localized descriptive name for a group, if it exists
+ * @deprecated since 1.29 Use UserGroupMembership::getGroupName instead
+ *
+ * @param string $group Internal group name
+ * @return string Localized descriptive group name
+ */
+ public static function getGroupName( $group ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ return UserGroupMembership::getGroupName( $group );
+ }
+
+ /**
+ * Get the localized descriptive name for a member of a group, if it exists
+ * @deprecated since 1.29 Use UserGroupMembership::getGroupMemberName instead
+ *
+ * @param string $group Internal group name
+ * @param string $username Username for gender (since 1.19)
+ * @return string Localized name for group member
+ */
+ public static function getGroupMember( $group, $username = '#' ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ return UserGroupMembership::getGroupMemberName( $group, $username );
+ }
+
+ /**
+ * Return the set of defined explicit groups.
+ * The implicit groups (by default *, 'user' and 'autoconfirmed')
+ * are not included, as they are defined automatically, not in the database.
+ * @return array Array of internal group names
+ */
+ public static function getAllGroups() {
+ global $wgGroupPermissions, $wgRevokePermissions;
+ return array_diff(
+ array_merge( array_keys( $wgGroupPermissions ), array_keys( $wgRevokePermissions ) ),
+ self::getImplicitGroups()
+ );
+ }
+
+ /**
+ * Get a list of all available permissions.
+ * @return string[] Array of permission names
+ */
+ public static function getAllRights() {
+ if ( self::$mAllRights === false ) {
+ global $wgAvailableRights;
+ if ( count( $wgAvailableRights ) ) {
+ self::$mAllRights = array_unique( array_merge( self::$mCoreRights, $wgAvailableRights ) );
+ } else {
+ self::$mAllRights = self::$mCoreRights;
+ }
+ Hooks::run( 'UserGetAllRights', [ &self::$mAllRights ] );
+ }
+ return self::$mAllRights;
+ }
+
+ /**
+ * Get a list of implicit groups
+ * @return array Array of Strings Array of internal group names
+ */
+ public static function getImplicitGroups() {
+ global $wgImplicitGroups;
+
+ $groups = $wgImplicitGroups;
+ # Deprecated, use $wgImplicitGroups instead
+ Hooks::run( 'UserGetImplicitGroups', [ &$groups ], '1.25' );
+
+ return $groups;
+ }
+
+ /**
+ * Get the title of a page describing a particular group
+ * @deprecated since 1.29 Use UserGroupMembership::getGroupPage instead
+ *
+ * @param string $group Internal group name
+ * @return Title|bool Title of the page if it exists, false otherwise
+ */
+ public static function getGroupPage( $group ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ return UserGroupMembership::getGroupPage( $group );
+ }
+
+ /**
+ * Create a link to the group in HTML, if available;
+ * else return the group name.
+ * @deprecated since 1.29 Use UserGroupMembership::getLink instead, or
+ * make the link yourself if you need custom text
+ *
+ * @param string $group Internal name of the group
+ * @param string $text The text of the link
+ * @return string HTML link to the group
+ */
+ public static function makeGroupLinkHTML( $group, $text = '' ) {
+ wfDeprecated( __METHOD__, '1.29' );
+
+ if ( $text == '' ) {
+ $text = UserGroupMembership::getGroupName( $group );
+ }
+ $title = UserGroupMembership::getGroupPage( $group );
+ if ( $title ) {
+ return MediaWikiServices::getInstance()
+ ->getLinkRenderer()->makeLink( $title, $text );
+ } else {
+ return htmlspecialchars( $text );
+ }
+ }
+
+ /**
+ * Create a link to the group in Wikitext, if available;
+ * else return the group name.
+ * @deprecated since 1.29 Use UserGroupMembership::getLink instead, or
+ * make the link yourself if you need custom text
+ *
+ * @param string $group Internal name of the group
+ * @param string $text The text of the link
+ * @return string Wikilink to the group
+ */
+ public static function makeGroupLinkWiki( $group, $text = '' ) {
+ wfDeprecated( __METHOD__, '1.29' );
+
+ if ( $text == '' ) {
+ $text = UserGroupMembership::getGroupName( $group );
+ }
+ $title = UserGroupMembership::getGroupPage( $group );
+ if ( $title ) {
+ $page = $title->getFullText();
+ return "[[$page|$text]]";
+ } else {
+ return $text;
+ }
+ }
+
+ /**
+ * Returns an array of the groups that a particular group can add/remove.
+ *
+ * @param string $group The group to check for whether it can add/remove
+ * @return array Array( 'add' => array( addablegroups ),
+ * 'remove' => array( removablegroups ),
+ * 'add-self' => array( addablegroups to self),
+ * 'remove-self' => array( removable groups from self) )
+ */
+ public static function changeableByGroup( $group ) {
+ global $wgAddGroups, $wgRemoveGroups, $wgGroupsAddToSelf, $wgGroupsRemoveFromSelf;
+
+ $groups = [
+ 'add' => [],
+ 'remove' => [],
+ 'add-self' => [],
+ 'remove-self' => []
+ ];
+
+ if ( empty( $wgAddGroups[$group] ) ) {
+ // Don't add anything to $groups
+ } elseif ( $wgAddGroups[$group] === true ) {
+ // You get everything
+ $groups['add'] = self::getAllGroups();
+ } elseif ( is_array( $wgAddGroups[$group] ) ) {
+ $groups['add'] = $wgAddGroups[$group];
+ }
+
+ // Same thing for remove
+ if ( empty( $wgRemoveGroups[$group] ) ) {
+ // Do nothing
+ } elseif ( $wgRemoveGroups[$group] === true ) {
+ $groups['remove'] = self::getAllGroups();
+ } elseif ( is_array( $wgRemoveGroups[$group] ) ) {
+ $groups['remove'] = $wgRemoveGroups[$group];
+ }
+
+ // Re-map numeric keys of AddToSelf/RemoveFromSelf to the 'user' key for backwards compatibility
+ if ( empty( $wgGroupsAddToSelf['user'] ) || $wgGroupsAddToSelf['user'] !== true ) {
+ foreach ( $wgGroupsAddToSelf as $key => $value ) {
+ if ( is_int( $key ) ) {
+ $wgGroupsAddToSelf['user'][] = $value;
+ }
+ }
+ }
+
+ if ( empty( $wgGroupsRemoveFromSelf['user'] ) || $wgGroupsRemoveFromSelf['user'] !== true ) {
+ foreach ( $wgGroupsRemoveFromSelf as $key => $value ) {
+ if ( is_int( $key ) ) {
+ $wgGroupsRemoveFromSelf['user'][] = $value;
+ }
+ }
+ }
+
+ // Now figure out what groups the user can add to him/herself
+ if ( empty( $wgGroupsAddToSelf[$group] ) ) {
+ // Do nothing
+ } elseif ( $wgGroupsAddToSelf[$group] === true ) {
+ // No idea WHY this would be used, but it's there
+ $groups['add-self'] = self::getAllGroups();
+ } elseif ( is_array( $wgGroupsAddToSelf[$group] ) ) {
+ $groups['add-self'] = $wgGroupsAddToSelf[$group];
+ }
+
+ if ( empty( $wgGroupsRemoveFromSelf[$group] ) ) {
+ // Do nothing
+ } elseif ( $wgGroupsRemoveFromSelf[$group] === true ) {
+ $groups['remove-self'] = self::getAllGroups();
+ } elseif ( is_array( $wgGroupsRemoveFromSelf[$group] ) ) {
+ $groups['remove-self'] = $wgGroupsRemoveFromSelf[$group];
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Returns an array of groups that this user can add and remove
+ * @return array Array( 'add' => array( addablegroups ),
+ * 'remove' => array( removablegroups ),
+ * 'add-self' => array( addablegroups to self),
+ * 'remove-self' => array( removable groups from self) )
+ */
+ public function changeableGroups() {
+ if ( $this->isAllowed( 'userrights' ) ) {
+ // This group gives the right to modify everything (reverse-
+ // compatibility with old "userrights lets you change
+ // everything")
+ // Using array_merge to make the groups reindexed
+ $all = array_merge( self::getAllGroups() );
+ return [
+ 'add' => $all,
+ 'remove' => $all,
+ 'add-self' => [],
+ 'remove-self' => []
+ ];
+ }
+
+ // Okay, it's not so simple, we will have to go through the arrays
+ $groups = [
+ 'add' => [],
+ 'remove' => [],
+ 'add-self' => [],
+ 'remove-self' => []
+ ];
+ $addergroups = $this->getEffectiveGroups();
+
+ foreach ( $addergroups as $addergroup ) {
+ $groups = array_merge_recursive(
+ $groups, $this->changeableByGroup( $addergroup )
+ );
+ $groups['add'] = array_unique( $groups['add'] );
+ $groups['remove'] = array_unique( $groups['remove'] );
+ $groups['add-self'] = array_unique( $groups['add-self'] );
+ $groups['remove-self'] = array_unique( $groups['remove-self'] );
+ }
+ return $groups;
+ }
+
+ /**
+ * Deferred version of incEditCountImmediate()
+ *
+ * This function, rather than incEditCountImmediate(), should be used for
+ * most cases as it avoids potential deadlocks caused by concurrent editing.
+ */
+ public function incEditCount() {
+ wfGetDB( DB_MASTER )->onTransactionPreCommitOrIdle(
+ function () {
+ $this->incEditCountImmediate();
+ },
+ __METHOD__
+ );
+ }
+
+ /**
+ * Increment the user's edit-count field.
+ * Will have no effect for anonymous users.
+ * @since 1.26
+ */
+ public function incEditCountImmediate() {
+ if ( $this->isAnon() ) {
+ return;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ // No rows will be "affected" if user_editcount is NULL
+ $dbw->update(
+ 'user',
+ [ 'user_editcount=user_editcount+1' ],
+ [ 'user_id' => $this->getId(), 'user_editcount IS NOT NULL' ],
+ __METHOD__
+ );
+ // Lazy initialization check...
+ if ( $dbw->affectedRows() == 0 ) {
+ // Now here's a goddamn hack...
+ $dbr = wfGetDB( DB_REPLICA );
+ if ( $dbr !== $dbw ) {
+ // If we actually have a replica DB server, the count is
+ // at least one behind because the current transaction
+ // has not been committed and replicated.
+ $this->mEditCount = $this->initEditCount( 1 );
+ } else {
+ // But if DB_REPLICA is selecting the master, then the
+ // count we just read includes the revision that was
+ // just added in the working transaction.
+ $this->mEditCount = $this->initEditCount();
+ }
+ } else {
+ if ( $this->mEditCount === null ) {
+ $this->getEditCount();
+ $dbr = wfGetDB( DB_REPLICA );
+ $this->mEditCount += ( $dbr !== $dbw ) ? 1 : 0;
+ } else {
+ $this->mEditCount++;
+ }
+ }
+ // Edit count in user cache too
+ $this->invalidateCache();
+ }
+
+ /**
+ * Initialize user_editcount from data out of the revision table
+ *
+ * @param int $add Edits to add to the count from the revision table
+ * @return int Number of edits
+ */
+ protected function initEditCount( $add = 0 ) {
+ // Pull from a replica DB to be less cruel to servers
+ // Accuracy isn't the point anyway here
+ $dbr = wfGetDB( DB_REPLICA );
+ $count = (int)$dbr->selectField(
+ 'revision',
+ 'COUNT(rev_user)',
+ [ 'rev_user' => $this->getId() ],
+ __METHOD__
+ );
+ $count = $count + $add;
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'user',
+ [ 'user_editcount' => $count ],
+ [ 'user_id' => $this->getId() ],
+ __METHOD__
+ );
+
+ return $count;
+ }
+
+ /**
+ * Get the description of a given right
+ *
+ * @since 1.29
+ * @param string $right Right to query
+ * @return string Localized description of the right
+ */
+ public static function getRightDescription( $right ) {
+ $key = "right-$right";
+ $msg = wfMessage( $key );
+ return $msg->isDisabled() ? $right : $msg->text();
+ }
+
+ /**
+ * Get the name of a given grant
+ *
+ * @since 1.29
+ * @param string $grant Grant to query
+ * @return string Localized name of the grant
+ */
+ public static function getGrantName( $grant ) {
+ $key = "grant-$grant";
+ $msg = wfMessage( $key );
+ return $msg->isDisabled() ? $grant : $msg->text();
+ }
+
+ /**
+ * Add a newuser log entry for this user.
+ * Before 1.19 the return value was always true.
+ *
+ * @deprecated since 1.27, AuthManager handles logging
+ * @param string|bool $action Account creation type.
+ * - String, one of the following values:
+ * - 'create' for an anonymous user creating an account for himself.
+ * This will force the action's performer to be the created user itself,
+ * no matter the value of $wgUser
+ * - 'create2' for a logged in user creating an account for someone else
+ * - 'byemail' when the created user will receive its password by e-mail
+ * - 'autocreate' when the user is automatically created (such as by CentralAuth).
+ * - Boolean means whether the account was created by e-mail (deprecated):
+ * - true will be converted to 'byemail'
+ * - false will be converted to 'create' if this object is the same as
+ * $wgUser and to 'create2' otherwise
+ * @param string $reason User supplied reason
+ * @return bool true
+ */
+ public function addNewUserLogEntry( $action = false, $reason = '' ) {
+ return true; // disabled
+ }
+
+ /**
+ * Add an autocreate newuser log entry for this user
+ * Used by things like CentralAuth and perhaps other authplugins.
+ * Consider calling addNewUserLogEntry() directly instead.
+ *
+ * @deprecated since 1.27, AuthManager handles logging
+ * @return bool
+ */
+ public function addNewUserLogEntryAutoCreate() {
+ $this->addNewUserLogEntry( 'autocreate' );
+
+ return true;
+ }
+
+ /**
+ * Load the user options either from cache, the database or an array
+ *
+ * @param array $data Rows for the current user out of the user_properties table
+ */
+ protected function loadOptions( $data = null ) {
+ global $wgContLang;
+
+ $this->load();
+
+ if ( $this->mOptionsLoaded ) {
+ return;
+ }
+
+ $this->mOptions = self::getDefaultOptions();
+
+ if ( !$this->getId() ) {
+ // For unlogged-in users, load language/variant options from request.
+ // There's no need to do it for logged-in users: they can set preferences,
+ // and handling of page content is done by $pageLang->getPreferredVariant() and such,
+ // so don't override user's choice (especially when the user chooses site default).
+ $variant = $wgContLang->getDefaultVariant();
+ $this->mOptions['variant'] = $variant;
+ $this->mOptions['language'] = $variant;
+ $this->mOptionsLoaded = true;
+ return;
+ }
+
+ // Maybe load from the object
+ if ( !is_null( $this->mOptionOverrides ) ) {
+ wfDebug( "User: loading options for user " . $this->getId() . " from override cache.\n" );
+ foreach ( $this->mOptionOverrides as $key => $value ) {
+ $this->mOptions[$key] = $value;
+ }
+ } else {
+ if ( !is_array( $data ) ) {
+ wfDebug( "User: loading options for user " . $this->getId() . " from database.\n" );
+ // Load from database
+ $dbr = ( $this->queryFlagsUsed & self::READ_LATEST )
+ ? wfGetDB( DB_MASTER )
+ : wfGetDB( DB_REPLICA );
+
+ $res = $dbr->select(
+ 'user_properties',
+ [ 'up_property', 'up_value' ],
+ [ 'up_user' => $this->getId() ],
+ __METHOD__
+ );
+
+ $this->mOptionOverrides = [];
+ $data = [];
+ foreach ( $res as $row ) {
+ // Convert '0' to 0. PHP's boolean conversion considers them both
+ // false, but e.g. JavaScript considers the former as true.
+ // @todo: T54542 Somehow determine the desired type (string/int/bool)
+ // and convert all values here.
+ if ( $row->up_value === '0' ) {
+ $row->up_value = 0;
+ }
+ $data[$row->up_property] = $row->up_value;
+ }
+ }
+
+ // Convert the email blacklist from a new line delimited string
+ // to an array of ids.
+ if ( isset( $data['email-blacklist'] ) && $data['email-blacklist'] ) {
+ $data['email-blacklist'] = array_map( 'intval', explode( "\n", $data['email-blacklist'] ) );
+ }
+
+ foreach ( $data as $property => $value ) {
+ $this->mOptionOverrides[$property] = $value;
+ $this->mOptions[$property] = $value;
+ }
+ }
+
+ $this->mOptionsLoaded = true;
+
+ Hooks::run( 'UserLoadOptions', [ $this, &$this->mOptions ] );
+ }
+
+ /**
+ * Saves the non-default options for this user, as previously set e.g. via
+ * setOption(), in the database's "user_properties" (preferences) table.
+ * Usually used via saveSettings().
+ */
+ protected function saveOptions() {
+ $this->loadOptions();
+
+ // Not using getOptions(), to keep hidden preferences in database
+ $saveOptions = $this->mOptions;
+
+ // Convert usernames to ids.
+ if ( isset( $this->mOptions['email-blacklist'] ) ) {
+ if ( $this->mOptions['email-blacklist'] ) {
+ $value = $this->mOptions['email-blacklist'];
+ // Email Blacklist may be an array of ids or a string of new line
+ // delimnated user names.
+ if ( is_array( $value ) ) {
+ $ids = array_filter( $value, 'is_numeric' );
+ } else {
+ $lookup = CentralIdLookup::factory();
+ $ids = $lookup->centralIdsFromNames( explode( "\n", $value ), $this );
+ }
+ $this->mOptions['email-blacklist'] = $ids;
+ $saveOptions['email-blacklist'] = implode( "\n", $this->mOptions['email-blacklist'] );
+ } else {
+ // If the blacklist is empty, set it to null rather than an empty string.
+ $this->mOptions['email-blacklist'] = null;
+ }
+ }
+
+ // Allow hooks to abort, for instance to save to a global profile.
+ // Reset options to default state before saving.
+ if ( !Hooks::run( 'UserSaveOptions', [ $this, &$saveOptions ] ) ) {
+ return;
+ }
+
+ $userId = $this->getId();
+
+ $insert_rows = []; // all the new preference rows
+ foreach ( $saveOptions as $key => $value ) {
+ // Don't bother storing default values
+ $defaultOption = self::getDefaultOption( $key );
+ if ( ( $defaultOption === null && $value !== false && $value !== null )
+ || $value != $defaultOption
+ ) {
+ $insert_rows[] = [
+ 'up_user' => $userId,
+ 'up_property' => $key,
+ 'up_value' => $value,
+ ];
+ }
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ $res = $dbw->select( 'user_properties',
+ [ 'up_property', 'up_value' ], [ 'up_user' => $userId ], __METHOD__ );
+
+ // Find prior rows that need to be removed or updated. These rows will
+ // all be deleted (the latter so that INSERT IGNORE applies the new values).
+ $keysDelete = [];
+ foreach ( $res as $row ) {
+ if ( !isset( $saveOptions[$row->up_property] )
+ || strcmp( $saveOptions[$row->up_property], $row->up_value ) != 0
+ ) {
+ $keysDelete[] = $row->up_property;
+ }
+ }
+
+ if ( count( $keysDelete ) ) {
+ // Do the DELETE by PRIMARY KEY for prior rows.
+ // In the past a very large portion of calls to this function are for setting
+ // 'rememberpassword' for new accounts (a preference that has since been removed).
+ // Doing a blanket per-user DELETE for new accounts with no rows in the table
+ // caused gap locks on [max user ID,+infinity) which caused high contention since
+ // updates would pile up on each other as they are for higher (newer) user IDs.
+ // It might not be necessary these days, but it shouldn't hurt either.
+ $dbw->delete( 'user_properties',
+ [ 'up_user' => $userId, 'up_property' => $keysDelete ], __METHOD__ );
+ }
+ // Insert the new preference rows
+ $dbw->insert( 'user_properties', $insert_rows, __METHOD__, [ 'IGNORE' ] );
+ }
+
+ /**
+ * Lazily instantiate and return a factory object for making passwords
+ *
+ * @deprecated since 1.27, create a PasswordFactory directly instead
+ * @return PasswordFactory
+ */
+ public static function getPasswordFactory() {
+ wfDeprecated( __METHOD__, '1.27' );
+ $ret = new PasswordFactory();
+ $ret->init( RequestContext::getMain()->getConfig() );
+ return $ret;
+ }
+
+ /**
+ * Provide an array of HTML5 attributes to put on an input element
+ * intended for the user to enter a new password. This may include
+ * required, title, and/or pattern, depending on $wgMinimalPasswordLength.
+ *
+ * Do *not* use this when asking the user to enter his current password!
+ * Regardless of configuration, users may have invalid passwords for whatever
+ * reason (e.g., they were set before requirements were tightened up).
+ * Only use it when asking for a new password, like on account creation or
+ * ResetPass.
+ *
+ * Obviously, you still need to do server-side checking.
+ *
+ * NOTE: A combination of bugs in various browsers means that this function
+ * actually just returns array() unconditionally at the moment. May as
+ * well keep it around for when the browser bugs get fixed, though.
+ *
+ * @todo FIXME: This does not belong here; put it in Html or Linker or somewhere
+ *
+ * @deprecated since 1.27
+ * @return array Array of HTML attributes suitable for feeding to
+ * Html::element(), directly or indirectly. (Don't feed to Xml::*()!
+ * That will get confused by the boolean attribute syntax used.)
+ */
+ public static function passwordChangeInputAttribs() {
+ global $wgMinimalPasswordLength;
+
+ if ( $wgMinimalPasswordLength == 0 ) {
+ return [];
+ }
+
+ # Note that the pattern requirement will always be satisfied if the
+ # input is empty, so we need required in all cases.
+
+ # @todo FIXME: T25769: This needs to not claim the password is required
+ # if e-mail confirmation is being used. Since HTML5 input validation
+ # is b0rked anyway in some browsers, just return nothing. When it's
+ # re-enabled, fix this code to not output required for e-mail
+ # registration.
+ # $ret = array( 'required' );
+ $ret = [];
+
+ # We can't actually do this right now, because Opera 9.6 will print out
+ # the entered password visibly in its error message! When other
+ # browsers add support for this attribute, or Opera fixes its support,
+ # we can add support with a version check to avoid doing this on Opera
+ # versions where it will be a problem. Reported to Opera as
+ # DSK-262266, but they don't have a public bug tracker for us to follow.
+ /*
+ if ( $wgMinimalPasswordLength > 1 ) {
+ $ret['pattern'] = '.{' . intval( $wgMinimalPasswordLength ) . ',}';
+ $ret['title'] = wfMessage( 'passwordtooshort' )
+ ->numParams( $wgMinimalPasswordLength )->text();
+ }
+ */
+
+ return $ret;
+ }
+
+ /**
+ * Return the list of user fields that should be selected to create
+ * a new user object.
+ * @return array
+ */
+ public static function selectFields() {
+ return [
+ 'user_id',
+ 'user_name',
+ 'user_real_name',
+ 'user_email',
+ 'user_touched',
+ 'user_token',
+ 'user_email_authenticated',
+ 'user_email_token',
+ 'user_email_token_expires',
+ 'user_registration',
+ 'user_editcount',
+ ];
+ }
+
+ /**
+ * Factory function for fatal permission-denied errors
+ *
+ * @since 1.22
+ * @param string $permission User right required
+ * @return Status
+ */
+ static function newFatalPermissionDeniedStatus( $permission ) {
+ global $wgLang;
+
+ $groups = [];
+ foreach ( self::getGroupsWithPermission( $permission ) as $group ) {
+ $groups[] = UserGroupMembership::getLink( $group, RequestContext::getMain(), 'wiki' );
+ }
+
+ if ( $groups ) {
+ return Status::newFatal( 'badaccess-groups', $wgLang->commaList( $groups ), count( $groups ) );
+ } else {
+ return Status::newFatal( 'badaccess-group0' );
+ }
+ }
+
+ /**
+ * Get a new instance of this user that was loaded from the master via a locking read
+ *
+ * Use this instead of the main context User when updating that user. This avoids races
+ * where that user was loaded from a replica DB or even the master but without proper locks.
+ *
+ * @return User|null Returns null if the user was not found in the DB
+ * @since 1.27
+ */
+ public function getInstanceForUpdate() {
+ if ( !$this->getId() ) {
+ return null; // anon
+ }
+
+ $user = self::newFromId( $this->getId() );
+ if ( !$user->loadFromId( self::READ_EXCLUSIVE ) ) {
+ return null;
+ }
+
+ return $user;
+ }
+
+ /**
+ * Checks if two user objects point to the same user.
+ *
+ * @since 1.25
+ * @param User $user
+ * @return bool
+ */
+ public function equals( User $user ) {
+ return $this->getName() === $user->getName();
+ }
+}
diff --git a/www/wiki/includes/user/UserArray.php b/www/wiki/includes/user/UserArray.php
new file mode 100644
index 00000000..ab6683b2
--- /dev/null
+++ b/www/wiki/includes/user/UserArray.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * Class to walk into a list of User objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+abstract class UserArray implements Iterator {
+ /**
+ * @param ResultWrapper $res
+ * @return UserArrayFromResult
+ */
+ static function newFromResult( $res ) {
+ $userArray = null;
+ if ( !Hooks::run( 'UserArrayFromResult', [ &$userArray, $res ] ) ) {
+ return null;
+ }
+ if ( $userArray === null ) {
+ $userArray = self::newFromResult_internal( $res );
+ }
+ return $userArray;
+ }
+
+ /**
+ * @param array $ids
+ * @return UserArrayFromResult|ArrayIterator
+ */
+ static function newFromIDs( $ids ) {
+ $ids = array_map( 'intval', (array)$ids ); // paranoia
+ if ( !$ids ) {
+ // Database::select() doesn't like empty arrays
+ return new ArrayIterator( [] );
+ }
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select(
+ 'user',
+ User::selectFields(),
+ [ 'user_id' => array_unique( $ids ) ],
+ __METHOD__
+ );
+ return self::newFromResult( $res );
+ }
+
+ /**
+ * @since 1.25
+ * @param array $names
+ * @return UserArrayFromResult|ArrayIterator
+ */
+ static function newFromNames( $names ) {
+ $names = array_map( 'strval', (array)$names ); // paranoia
+ if ( !$names ) {
+ // Database::select() doesn't like empty arrays
+ return new ArrayIterator( [] );
+ }
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select(
+ 'user',
+ User::selectFields(),
+ [ 'user_name' => array_unique( $names ) ],
+ __METHOD__
+ );
+ return self::newFromResult( $res );
+ }
+
+ /**
+ * @param ResultWrapper $res
+ * @return UserArrayFromResult
+ */
+ protected static function newFromResult_internal( $res ) {
+ return new UserArrayFromResult( $res );
+ }
+}
diff --git a/www/wiki/includes/user/UserArrayFromResult.php b/www/wiki/includes/user/UserArrayFromResult.php
new file mode 100644
index 00000000..527df7fa
--- /dev/null
+++ b/www/wiki/includes/user/UserArrayFromResult.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * Class to walk into a list of User objects.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+class UserArrayFromResult extends UserArray implements Countable {
+ /** @var ResultWrapper */
+ public $res;
+
+ /** @var int */
+ public $key;
+
+ /** @var bool|User */
+ public $current;
+
+ /**
+ * @param ResultWrapper $res
+ */
+ function __construct( $res ) {
+ $this->res = $res;
+ $this->key = 0;
+ $this->setCurrent( $this->res->current() );
+ }
+
+ /**
+ * @param bool|stdClass $row
+ * @return void
+ */
+ protected function setCurrent( $row ) {
+ if ( $row === false ) {
+ $this->current = false;
+ } else {
+ $this->current = User::newFromRow( $row );
+ }
+ }
+
+ /**
+ * @return int
+ */
+ public function count() {
+ return $this->res->numRows();
+ }
+
+ /**
+ * @return User
+ */
+ function current() {
+ return $this->current;
+ }
+
+ function key() {
+ return $this->key;
+ }
+
+ function next() {
+ $row = $this->res->next();
+ $this->setCurrent( $row );
+ $this->key++;
+ }
+
+ function rewind() {
+ $this->res->rewind();
+ $this->key = 0;
+ $this->setCurrent( $this->res->current() );
+ }
+
+ /**
+ * @return bool
+ */
+ function valid() {
+ return $this->current !== false;
+ }
+}
diff --git a/www/wiki/includes/user/UserGroupMembership.php b/www/wiki/includes/user/UserGroupMembership.php
new file mode 100644
index 00000000..a06be834
--- /dev/null
+++ b/www/wiki/includes/user/UserGroupMembership.php
@@ -0,0 +1,440 @@
+<?php
+/**
+ * Represents the membership of a user to a user group.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Represents a "user group membership" -- a specific instance of a user belonging
+ * to a group. For example, the fact that user Mary belongs to the sysop group is a
+ * user group membership.
+ *
+ * The class encapsulates rows in the user_groups table. The logic is low-level and
+ * doesn't run any hooks. Often, you will want to call User::addGroup() or
+ * User::removeGroup() instead.
+ *
+ * @since 1.29
+ */
+class UserGroupMembership {
+ /** @var int The ID of the user who belongs to the group */
+ private $userId;
+
+ /** @var string */
+ private $group;
+
+ /** @var string|null Timestamp of expiry in TS_MW format, or null if no expiry */
+ private $expiry;
+
+ /**
+ * @param int $userId The ID of the user who belongs to the group
+ * @param string $group The internal group name
+ * @param string|null $expiry Timestamp of expiry in TS_MW format, or null if no expiry
+ */
+ public function __construct( $userId = 0, $group = null, $expiry = null ) {
+ $this->userId = (int)$userId;
+ $this->group = $group; // TODO throw on invalid group?
+ $this->expiry = $expiry ?: null;
+ }
+
+ /**
+ * @return int
+ */
+ public function getUserId() {
+ return $this->userId;
+ }
+
+ /**
+ * @return string
+ */
+ public function getGroup() {
+ return $this->group;
+ }
+
+ /**
+ * @return string|null Timestamp of expiry in TS_MW format, or null if no expiry
+ */
+ public function getExpiry() {
+ return $this->expiry;
+ }
+
+ protected function initFromRow( $row ) {
+ $this->userId = (int)$row->ug_user;
+ $this->group = $row->ug_group;
+ $this->expiry = $row->ug_expiry === null ?
+ null :
+ wfTimestamp( TS_MW, $row->ug_expiry );
+ }
+
+ /**
+ * Creates a new UserGroupMembership object from a database row.
+ *
+ * @param stdClass $row The row from the user_groups table
+ * @return UserGroupMembership
+ */
+ public static function newFromRow( $row ) {
+ $ugm = new self;
+ $ugm->initFromRow( $row );
+ return $ugm;
+ }
+
+ /**
+ * Returns the list of user_groups fields that should be selected to create
+ * a new user group membership.
+ * @return array
+ */
+ public static function selectFields() {
+ return [
+ 'ug_user',
+ 'ug_group',
+ 'ug_expiry',
+ ];
+ }
+
+ /**
+ * Delete the row from the user_groups table.
+ *
+ * @throws MWException
+ * @param IDatabase|null $dbw Optional master database connection to use
+ * @return bool Whether or not anything was deleted
+ */
+ public function delete( IDatabase $dbw = null ) {
+ if ( wfReadOnly() ) {
+ return false;
+ }
+
+ if ( $dbw === null ) {
+ $dbw = wfGetDB( DB_MASTER );
+ }
+
+ $dbw->delete(
+ 'user_groups',
+ [ 'ug_user' => $this->userId, 'ug_group' => $this->group ],
+ __METHOD__ );
+ if ( !$dbw->affectedRows() ) {
+ return false;
+ }
+
+ // Remember that the user was in this group
+ $dbw->insert(
+ 'user_former_groups',
+ [ 'ufg_user' => $this->userId, 'ufg_group' => $this->group ],
+ __METHOD__,
+ [ 'IGNORE' ] );
+
+ return true;
+ }
+
+ /**
+ * Insert a user right membership into the database. When $allowUpdate is false,
+ * the function fails if there is a conflicting membership entry (same user and
+ * group) already in the table.
+ *
+ * @throws MWException
+ * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
+ * @param IDatabase|null $dbw If you have one available
+ * @return bool Whether or not anything was inserted
+ */
+ public function insert( $allowUpdate = false, IDatabase $dbw = null ) {
+ if ( $dbw === null ) {
+ $dbw = wfGetDB( DB_MASTER );
+ }
+
+ // Purge old, expired memberships from the DB
+ self::purgeExpired( $dbw );
+
+ // Check that the values make sense
+ if ( $this->group === null ) {
+ throw new UnexpectedValueException(
+ 'Don\'t try inserting an uninitialized UserGroupMembership object' );
+ } elseif ( $this->userId <= 0 ) {
+ throw new UnexpectedValueException(
+ 'UserGroupMembership::insert() needs a positive user ID. ' .
+ 'Did you forget to add your User object to the database before calling addGroup()?' );
+ }
+
+ $row = $this->getDatabaseArray( $dbw );
+ $dbw->insert( 'user_groups', $row, __METHOD__, [ 'IGNORE' ] );
+ $affected = $dbw->affectedRows();
+
+ // Don't collide with expired user group memberships
+ // Do this after trying to insert, in order to avoid locking
+ if ( !$affected ) {
+ $conds = [
+ 'ug_user' => $row['ug_user'],
+ 'ug_group' => $row['ug_group'],
+ ];
+ // if we're unconditionally updating, check that the expiry is not already the
+ // same as what we are trying to update it to; otherwise, only update if
+ // the expiry date is in the past
+ if ( $allowUpdate ) {
+ if ( $this->expiry ) {
+ $conds[] = 'ug_expiry IS NULL OR ug_expiry != ' .
+ $dbw->addQuotes( $dbw->timestamp( $this->expiry ) );
+ } else {
+ $conds[] = 'ug_expiry IS NOT NULL';
+ }
+ } else {
+ $conds[] = 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() );
+ }
+
+ $row = $dbw->selectRow( 'user_groups', $this::selectFields(), $conds, __METHOD__ );
+ if ( $row ) {
+ $dbw->update(
+ 'user_groups',
+ [ 'ug_expiry' => $this->expiry ? $dbw->timestamp( $this->expiry ) : null ],
+ [ 'ug_user' => $row->ug_user, 'ug_group' => $row->ug_group ],
+ __METHOD__ );
+ $affected = $dbw->affectedRows();
+ }
+ }
+
+ return $affected > 0;
+ }
+
+ /**
+ * Get an array suitable for passing to $dbw->insert() or $dbw->update()
+ * @param IDatabase $db
+ * @return array
+ */
+ protected function getDatabaseArray( IDatabase $db ) {
+ return [
+ 'ug_user' => $this->userId,
+ 'ug_group' => $this->group,
+ 'ug_expiry' => $this->expiry ? $db->timestamp( $this->expiry ) : null,
+ ];
+ }
+
+ /**
+ * Has the membership expired?
+ * @return bool
+ */
+ public function isExpired() {
+ if ( !$this->expiry ) {
+ return false;
+ } else {
+ return wfTimestampNow() > $this->expiry;
+ }
+ }
+
+ /**
+ * Purge expired memberships from the user_groups table
+ *
+ * @param IDatabase|null $dbw
+ */
+ public static function purgeExpired( IDatabase $dbw = null ) {
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ if ( $dbw === null ) {
+ $dbw = wfGetDB( DB_MASTER );
+ }
+
+ DeferredUpdates::addUpdate( new AtomicSectionUpdate(
+ $dbw,
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) {
+ $expiryCond = [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ];
+ $res = $dbw->select( 'user_groups', self::selectFields(), $expiryCond, $fname );
+
+ // save an array of users/groups to insert to user_former_groups
+ $usersAndGroups = [];
+ foreach ( $res as $row ) {
+ $usersAndGroups[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
+ }
+
+ // delete 'em all
+ $dbw->delete( 'user_groups', $expiryCond, $fname );
+
+ // and push the groups to user_former_groups
+ $dbw->insert( 'user_former_groups', $usersAndGroups, __METHOD__, [ 'IGNORE' ] );
+ }
+ ) );
+ }
+
+ /**
+ * Returns UserGroupMembership objects for all the groups a user currently
+ * belongs to.
+ *
+ * @param int $userId ID of the user to search for
+ * @param IDatabase|null $db Optional database connection
+ * @return array Associative array of (group name => UserGroupMembership object)
+ */
+ public static function getMembershipsForUser( $userId, IDatabase $db = null ) {
+ if ( !$db ) {
+ $db = wfGetDB( DB_REPLICA );
+ }
+
+ $res = $db->select( 'user_groups',
+ self::selectFields(),
+ [ 'ug_user' => $userId ],
+ __METHOD__ );
+
+ $ugms = [];
+ foreach ( $res as $row ) {
+ $ugm = self::newFromRow( $row );
+ if ( !$ugm->isExpired() ) {
+ $ugms[$ugm->group] = $ugm;
+ }
+ }
+
+ return $ugms;
+ }
+
+ /**
+ * Returns a UserGroupMembership object that pertains to the given user and group,
+ * or false if the user does not belong to that group (or the assignment has
+ * expired).
+ *
+ * @param int $userId ID of the user to search for
+ * @param string $group User group name
+ * @param IDatabase|null $db Optional database connection
+ * @return UserGroupMembership|false
+ */
+ public static function getMembership( $userId, $group, IDatabase $db = null ) {
+ if ( !$db ) {
+ $db = wfGetDB( DB_REPLICA );
+ }
+
+ $row = $db->selectRow( 'user_groups',
+ self::selectFields(),
+ [ 'ug_user' => $userId, 'ug_group' => $group ],
+ __METHOD__ );
+ if ( !$row ) {
+ return false;
+ }
+
+ $ugm = self::newFromRow( $row );
+ if ( !$ugm->isExpired() ) {
+ return $ugm;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Gets a link for a user group, possibly including the expiry date if relevant.
+ *
+ * @param string|UserGroupMembership $ugm Either a group name as a string, or
+ * a UserGroupMembership object
+ * @param IContextSource $context
+ * @param string $format Either 'wiki' or 'html'
+ * @param string|null $userName If you want to use the group member message
+ * ("administrator"), pass the name of the user who belongs to the group; it
+ * is used for GENDER of the group member message. If you instead want the
+ * group name message ("Administrators"), omit this parameter.
+ * @return string
+ */
+ public static function getLink( $ugm, IContextSource $context, $format,
+ $userName = null
+ ) {
+ if ( $format !== 'wiki' && $format !== 'html' ) {
+ throw new MWException( 'UserGroupMembership::getLink() $format parameter should be ' .
+ "'wiki' or 'html'" );
+ }
+
+ if ( $ugm instanceof UserGroupMembership ) {
+ $expiry = $ugm->getExpiry();
+ $group = $ugm->getGroup();
+ } else {
+ $expiry = null;
+ $group = $ugm;
+ }
+
+ if ( $userName !== null ) {
+ $groupName = self::getGroupMemberName( $group, $userName );
+ } else {
+ $groupName = self::getGroupName( $group );
+ }
+
+ // link to the group description page, if it exists
+ $linkTitle = self::getGroupPage( $group );
+ if ( $linkTitle ) {
+ if ( $format === 'wiki' ) {
+ $linkPage = $linkTitle->getFullText();
+ $groupLink = "[[$linkPage|$groupName]]";
+ } else {
+ $groupLink = Linker::link( $linkTitle, htmlspecialchars( $groupName ) );
+ }
+ } else {
+ $groupLink = htmlspecialchars( $groupName );
+ }
+
+ if ( $expiry ) {
+ // format the expiry to a nice string
+ $uiLanguage = $context->getLanguage();
+ $uiUser = $context->getUser();
+ $expiryDT = $uiLanguage->userTimeAndDate( $expiry, $uiUser );
+ $expiryD = $uiLanguage->userDate( $expiry, $uiUser );
+ $expiryT = $uiLanguage->userTime( $expiry, $uiUser );
+ if ( $format === 'html' ) {
+ $groupLink = Message::rawParam( $groupLink );
+ }
+ return $context->msg( 'group-membership-link-with-expiry' )
+ ->params( $groupLink, $expiryDT, $expiryD, $expiryT )->text();
+ } else {
+ return $groupLink;
+ }
+ }
+
+ /**
+ * Gets the localized friendly name for a group, if it exists. For example,
+ * "Administrators" or "Bureaucrats"
+ *
+ * @param string $group Internal group name
+ * @return string Localized friendly group name
+ */
+ public static function getGroupName( $group ) {
+ $msg = wfMessage( "group-$group" );
+ return $msg->isBlank() ? $group : $msg->text();
+ }
+
+ /**
+ * Gets the localized name for a member of a group, if it exists. For example,
+ * "administrator" or "bureaucrat"
+ *
+ * @param string $group Internal group name
+ * @param string $username Username for gender
+ * @return string Localized name for group member
+ */
+ public static function getGroupMemberName( $group, $username ) {
+ $msg = wfMessage( "group-$group-member", $username );
+ return $msg->isBlank() ? $group : $msg->text();
+ }
+
+ /**
+ * Gets the title of a page describing a particular user group. When the name
+ * of the group appears in the UI, it can link to this page.
+ *
+ * @param string $group Internal group name
+ * @return Title|bool Title of the page if it exists, false otherwise
+ */
+ public static function getGroupPage( $group ) {
+ $msg = wfMessage( "grouppage-$group" )->inContentLanguage();
+ if ( $msg->exists() ) {
+ $title = Title::newFromText( $msg->text() );
+ if ( is_object( $title ) ) {
+ return $title;
+ }
+ }
+ return false;
+ }
+}
diff --git a/www/wiki/includes/user/UserNamePrefixSearch.php b/www/wiki/includes/user/UserNamePrefixSearch.php
new file mode 100644
index 00000000..b7d50582
--- /dev/null
+++ b/www/wiki/includes/user/UserNamePrefixSearch.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Prefix search of user names.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Handles searching prefixes of user names
+ *
+ * @since 1.27
+ */
+class UserNamePrefixSearch {
+
+ /**
+ * Do a prefix search of user names and return a list of matching user names.
+ *
+ * @param string|User $audience The string 'public' or a user object to show the search for
+ * @param string $search
+ * @param int $limit
+ * @param int $offset How many results to offset from the beginning
+ * @return array Array of strings
+ */
+ public static function search( $audience, $search, $limit, $offset = 0 ) {
+ $user = User::newFromName( $search );
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $prefix = $user ? $user->getName() : '';
+ $tables = [ 'user' ];
+ $cond = [ 'user_name ' . $dbr->buildLike( $prefix, $dbr->anyString() ) ];
+ $joinConds = [];
+
+ // Filter out hidden user names
+ if ( $audience === 'public' || !$audience->isAllowed( 'hideuser' ) ) {
+ $tables[] = 'ipblocks';
+ $cond['ipb_deleted'] = [ 0, null ];
+ $joinConds['ipblocks'] = [ 'LEFT JOIN', 'user_id=ipb_user' ];
+ }
+
+ $res = $dbr->selectFieldValues(
+ $tables,
+ 'user_name',
+ $cond,
+ __METHOD__,
+ [
+ 'LIMIT' => $limit,
+ 'ORDER BY' => 'user_name',
+ 'OFFSET' => $offset
+ ],
+ $joinConds
+ );
+
+ return $res === false ? [] : $res;
+ }
+}
diff --git a/www/wiki/includes/user/UserRightsProxy.php b/www/wiki/includes/user/UserRightsProxy.php
new file mode 100644
index 00000000..3c2731a7
--- /dev/null
+++ b/www/wiki/includes/user/UserRightsProxy.php
@@ -0,0 +1,289 @@
+<?php
+/**
+ * Representation of an user on a other locally-hosted wiki.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Cut-down copy of User interface for local-interwiki-database
+ * user rights manipulation.
+ */
+class UserRightsProxy {
+
+ /**
+ * @see newFromId()
+ * @see newFromName()
+ * @param IDatabase $db Db connection
+ * @param string $database Database name
+ * @param string $name User name
+ * @param int $id User ID
+ */
+ private function __construct( $db, $database, $name, $id ) {
+ $this->db = $db;
+ $this->database = $database;
+ $this->name = $name;
+ $this->id = intval( $id );
+ $this->newOptions = [];
+ }
+
+ /**
+ * Accessor for $this->database
+ *
+ * @return string Database name
+ */
+ public function getDBName() {
+ return $this->database;
+ }
+
+ /**
+ * Confirm the selected database name is a valid local interwiki database name.
+ *
+ * @param string $database Database name
+ * @return bool
+ */
+ public static function validDatabase( $database ) {
+ global $wgLocalDatabases;
+ return in_array( $database, $wgLocalDatabases );
+ }
+
+ /**
+ * Same as User::whoIs()
+ *
+ * @param string $database Database name
+ * @param int $id User ID
+ * @param bool $ignoreInvalidDB If true, don't check if $database is in $wgLocalDatabases
+ * @return string User name or false if the user doesn't exist
+ */
+ public static function whoIs( $database, $id, $ignoreInvalidDB = false ) {
+ $user = self::newFromId( $database, $id, $ignoreInvalidDB );
+ if ( $user ) {
+ return $user->name;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Factory function; get a remote user entry by ID number.
+ *
+ * @param string $database Database name
+ * @param int $id User ID
+ * @param bool $ignoreInvalidDB If true, don't check if $database is in $wgLocalDatabases
+ * @return UserRightsProxy|null If doesn't exist
+ */
+ public static function newFromId( $database, $id, $ignoreInvalidDB = false ) {
+ return self::newFromLookup( $database, 'user_id', intval( $id ), $ignoreInvalidDB );
+ }
+
+ /**
+ * Factory function; get a remote user entry by name.
+ *
+ * @param string $database Database name
+ * @param string $name User name
+ * @param bool $ignoreInvalidDB If true, don't check if $database is in $wgLocalDatabases
+ * @return UserRightsProxy|null If doesn't exist
+ */
+ public static function newFromName( $database, $name, $ignoreInvalidDB = false ) {
+ return self::newFromLookup( $database, 'user_name', $name, $ignoreInvalidDB );
+ }
+
+ /**
+ * @param string $database
+ * @param string $field
+ * @param string $value
+ * @param bool $ignoreInvalidDB
+ * @return null|UserRightsProxy
+ */
+ private static function newFromLookup( $database, $field, $value, $ignoreInvalidDB = false ) {
+ global $wgSharedDB, $wgSharedTables;
+ // If the user table is shared, perform the user query on it,
+ // but don't pass it to the UserRightsProxy,
+ // as user rights are normally not shared.
+ if ( $wgSharedDB && in_array( 'user', $wgSharedTables ) ) {
+ $userdb = self::getDB( $wgSharedDB, $ignoreInvalidDB );
+ } else {
+ $userdb = self::getDB( $database, $ignoreInvalidDB );
+ }
+
+ $db = self::getDB( $database, $ignoreInvalidDB );
+
+ if ( $db && $userdb ) {
+ $row = $userdb->selectRow( 'user',
+ [ 'user_id', 'user_name' ],
+ [ $field => $value ],
+ __METHOD__ );
+
+ if ( $row !== false ) {
+ return new UserRightsProxy( $db, $database,
+ $row->user_name,
+ intval( $row->user_id ) );
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Open a database connection to work on for the requested user.
+ * This may be a new connection to another database for remote users.
+ *
+ * @param string $database
+ * @param bool $ignoreInvalidDB If true, don't check if $database is in $wgLocalDatabases
+ * @return IDatabase|null If invalid selection
+ */
+ public static function getDB( $database, $ignoreInvalidDB = false ) {
+ global $wgDBname;
+ if ( $ignoreInvalidDB || self::validDatabase( $database ) ) {
+ if ( $database == $wgDBname ) {
+ // Hmm... this shouldn't happen though. :)
+ return wfGetDB( DB_MASTER );
+ } else {
+ return wfGetDB( DB_MASTER, [], $database );
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @return int
+ */
+ public function getId() {
+ return $this->id;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isAnon() {
+ return $this->getId() == 0;
+ }
+
+ /**
+ * Same as User::getName()
+ *
+ * @return string
+ */
+ public function getName() {
+ return $this->name . '@' . $this->database;
+ }
+
+ /**
+ * Same as User::getUserPage()
+ *
+ * @return Title
+ */
+ public function getUserPage() {
+ return Title::makeTitle( NS_USER, $this->getName() );
+ }
+
+ /**
+ * Replaces User::getUserGroups()
+ * @return array
+ */
+ function getGroups() {
+ return array_keys( self::getGroupMemberships() );
+ }
+
+ /**
+ * Replaces User::getGroupMemberships()
+ *
+ * @return array
+ * @since 1.29
+ */
+ function getGroupMemberships() {
+ return UserGroupMembership::getMembershipsForUser( $this->id, $this->db );
+ }
+
+ /**
+ * Replaces User::addGroup()
+ *
+ * @param string $group
+ * @param string|null $expiry
+ * @return bool
+ */
+ function addGroup( $group, $expiry = null ) {
+ if ( $expiry ) {
+ $expiry = wfTimestamp( TS_MW, $expiry );
+ }
+
+ $ugm = new UserGroupMembership( $this->id, $group, $expiry );
+ return $ugm->insert( true, $this->db );
+ }
+
+ /**
+ * Replaces User::removeGroup()
+ *
+ * @param string $group
+ * @return bool
+ */
+ function removeGroup( $group ) {
+ $ugm = UserGroupMembership::getMembership( $this->id, $group, $this->db );
+ if ( !$ugm ) {
+ return false;
+ }
+ return $ugm->delete( $this->db );
+ }
+
+ /**
+ * Replaces User::setOption()
+ * @param string $option
+ * @param mixed $value
+ */
+ public function setOption( $option, $value ) {
+ $this->newOptions[$option] = $value;
+ }
+
+ public function saveSettings() {
+ $rows = [];
+ foreach ( $this->newOptions as $option => $value ) {
+ $rows[] = [
+ 'up_user' => $this->id,
+ 'up_property' => $option,
+ 'up_value' => $value,
+ ];
+ }
+ $this->db->replace( 'user_properties',
+ [ [ 'up_user', 'up_property' ] ],
+ $rows, __METHOD__
+ );
+ $this->invalidateCache();
+ }
+
+ /**
+ * Replaces User::touchUser()
+ */
+ function invalidateCache() {
+ $this->db->update(
+ 'user',
+ [ 'user_touched' => $this->db->timestamp() ],
+ [ 'user_id' => $this->id ],
+ __METHOD__
+ );
+
+ $domainId = $this->db->getDomainID();
+ $userId = $this->id;
+ $this->db->onTransactionPreCommitOrIdle(
+ function () use ( $domainId, $userId ) {
+ User::purge( $domainId, $userId );
+ },
+ __METHOD__
+ );
+ }
+}
diff --git a/www/wiki/includes/utils/AutoloadGenerator.php b/www/wiki/includes/utils/AutoloadGenerator.php
new file mode 100644
index 00000000..4f639c13
--- /dev/null
+++ b/www/wiki/includes/utils/AutoloadGenerator.php
@@ -0,0 +1,471 @@
+<?php
+
+/**
+ * Accepts a list of files and directories to search for
+ * php files and generates $wgAutoloadLocalClasses or $wgAutoloadClasses
+ * lines for all detected classes. These lines are written out
+ * to an autoload.php file in the projects provided basedir.
+ *
+ * Usage:
+ *
+ * $gen = new AutoloadGenerator( __DIR__ );
+ * $gen->readDir( __DIR__ . '/includes' );
+ * $gen->readFile( __DIR__ . '/foo.php' )
+ * $gen->getAutoload();
+ */
+class AutoloadGenerator {
+ const FILETYPE_JSON = 'json';
+ const FILETYPE_PHP = 'php';
+
+ /**
+ * @var string Root path of the project being scanned for classes
+ */
+ protected $basepath;
+
+ /**
+ * @var ClassCollector Helper class extracts class names from php files
+ */
+ protected $collector;
+
+ /**
+ * @var array Map of file shortpath to list of FQCN detected within file
+ */
+ protected $classes = [];
+
+ /**
+ * @var string The global variable to write output to
+ */
+ protected $variableName = 'wgAutoloadClasses';
+
+ /**
+ * @var array Map of FQCN to relative path(from self::$basepath)
+ */
+ protected $overrides = [];
+
+ /**
+ * @param string $basepath Root path of the project being scanned for classes
+ * @param array|string $flags
+ *
+ * local - If this flag is set $wgAutoloadLocalClasses will be build instead
+ * of $wgAutoloadClasses
+ */
+ public function __construct( $basepath, $flags = [] ) {
+ if ( !is_array( $flags ) ) {
+ $flags = [ $flags ];
+ }
+ $this->basepath = self::normalizePathSeparator( realpath( $basepath ) );
+ $this->collector = new ClassCollector;
+ if ( in_array( 'local', $flags ) ) {
+ $this->variableName = 'wgAutoloadLocalClasses';
+ }
+ }
+
+ /**
+ * Force a class to be autoloaded from a specific path, regardless of where
+ * or if it was detected.
+ *
+ * @param string $fqcn FQCN to force the location of
+ * @param string $inputPath Full path to the file containing the class
+ * @throws Exception
+ */
+ public function forceClassPath( $fqcn, $inputPath ) {
+ $path = self::normalizePathSeparator( realpath( $inputPath ) );
+ if ( !$path ) {
+ throw new \Exception( "Invalid path: $inputPath" );
+ }
+ $len = strlen( $this->basepath );
+ if ( substr( $path, 0, $len ) !== $this->basepath ) {
+ throw new \Exception( "Path is not within basepath: $inputPath" );
+ }
+ $shortpath = substr( $path, $len );
+ $this->overrides[$fqcn] = $shortpath;
+ }
+
+ /**
+ * @param string $inputPath Path to a php file to find classes within
+ * @throws Exception
+ */
+ public function readFile( $inputPath ) {
+ // NOTE: do NOT expand $inputPath using realpath(). It is perfectly
+ // reasonable for LocalSettings.php and similiar files to be symlinks
+ // to files that are outside of $this->basepath.
+ $inputPath = self::normalizePathSeparator( $inputPath );
+ $len = strlen( $this->basepath );
+ if ( substr( $inputPath, 0, $len ) !== $this->basepath ) {
+ throw new \Exception( "Path is not within basepath: $inputPath" );
+ }
+ $result = $this->collector->getClasses(
+ file_get_contents( $inputPath )
+ );
+ if ( $result ) {
+ $shortpath = substr( $inputPath, $len );
+ $this->classes[$shortpath] = $result;
+ }
+ }
+
+ /**
+ * @param string $dir Path to a directory to recursively search
+ * for php files with either .php or .inc extensions
+ */
+ public function readDir( $dir ) {
+ $it = new RecursiveDirectoryIterator(
+ self::normalizePathSeparator( realpath( $dir ) ) );
+ $it = new RecursiveIteratorIterator( $it );
+
+ foreach ( $it as $path => $file ) {
+ $ext = pathinfo( $path, PATHINFO_EXTENSION );
+ // some older files in mw use .inc
+ if ( $ext === 'php' || $ext === 'inc' ) {
+ $this->readFile( $path );
+ }
+ }
+ }
+
+ /**
+ * Updates the AutoloadClasses field at the given
+ * filename.
+ *
+ * @param string $filename Filename of JSON
+ * extension/skin registration file
+ * @return string Updated Json of the file given as the $filename parameter
+ */
+ protected function generateJsonAutoload( $filename ) {
+ $key = 'AutoloadClasses';
+ $json = FormatJson::decode( file_get_contents( $filename ), true );
+ unset( $json[$key] );
+ // Inverting the key-value pairs so that they become of the
+ // format class-name : path when they get converted into json.
+ foreach ( $this->classes as $path => $contained ) {
+ foreach ( $contained as $fqcn ) {
+ // Using substr to remove the leading '/'
+ $json[$key][$fqcn] = substr( $path, 1 );
+ }
+ }
+ foreach ( $this->overrides as $path => $fqcn ) {
+ // Using substr to remove the leading '/'
+ $json[$key][$fqcn] = substr( $path, 1 );
+ }
+
+ // Sorting the list of autoload classes.
+ ksort( $json[$key] );
+
+ // Return the whole JSON file
+ return FormatJson::encode( $json, "\t", FormatJson::ALL_OK ) . "\n";
+ }
+
+ /**
+ * Generates a PHP file setting up autoload information.
+ *
+ * @param {string} $commandName Command name to include in comment
+ * @param {string} $filename of PHP file to put autoload information in.
+ * @return string
+ */
+ protected function generatePHPAutoload( $commandName, $filename ) {
+ // No existing JSON file found; update/generate PHP file
+ $content = [];
+
+ // We need to generate a line each rather than exporting the
+ // full array so __DIR__ can be prepended to all the paths
+ $format = "%s => __DIR__ . %s,";
+ foreach ( $this->classes as $path => $contained ) {
+ $exportedPath = var_export( $path, true );
+ foreach ( $contained as $fqcn ) {
+ $content[$fqcn] = sprintf(
+ $format,
+ var_export( $fqcn, true ),
+ $exportedPath
+ );
+ }
+ }
+
+ foreach ( $this->overrides as $fqcn => $path ) {
+ $content[$fqcn] = sprintf(
+ $format,
+ var_export( $fqcn, true ),
+ var_export( $path, true )
+ );
+ }
+
+ // sort for stable output
+ ksort( $content );
+
+ // extensions using this generator are appending to the existing
+ // autoload.
+ if ( $this->variableName === 'wgAutoloadClasses' ) {
+ $op = '+=';
+ } else {
+ $op = '=';
+ }
+
+ $output = implode( "\n\t", $content );
+ return
+ <<<EOD
+<?php
+// This file is generated by $commandName, do not adjust manually
+// @codingStandardsIgnoreFile
+global \${$this->variableName};
+
+\${$this->variableName} {$op} [
+ {$output}
+];
+
+EOD;
+ }
+
+ /**
+ * Returns all known classes as a string, which can be used to put into a target
+ * file (e.g. extension.json, skin.json or autoload.php)
+ *
+ * @param string $commandName Value used in file comment to direct
+ * developers towards the appropriate way to update the autoload.
+ * @return string
+ */
+ public function getAutoload( $commandName = 'AutoloadGenerator' ) {
+ // We need to check whether an extenson.json or skin.json exists or not, and
+ // incase it doesn't, update the autoload.php file.
+
+ $fileinfo = $this->getTargetFileinfo();
+
+ if ( $fileinfo['type'] === self::FILETYPE_JSON ) {
+ return $this->generateJsonAutoload( $fileinfo['filename'] );
+ } else {
+ return $this->generatePHPAutoload( $commandName, $fileinfo['filename'] );
+ }
+ }
+
+ /**
+ * Returns the filename of the extension.json of skin.json, if there's any, or
+ * otherwise the path to the autoload.php file in an array as the "filename"
+ * key and with the type (AutoloadGenerator::FILETYPE_JSON or AutoloadGenerator::FILETYPE_PHP)
+ * of the file as the "type" key.
+ *
+ * @return array
+ */
+ public function getTargetFileinfo() {
+ $fileinfo = [
+ 'filename' => $this->basepath . '/autoload.php',
+ 'type' => self::FILETYPE_PHP
+ ];
+ if ( file_exists( $this->basepath . '/extension.json' ) ) {
+ $fileinfo = [
+ 'filename' => $this->basepath . '/extension.json',
+ 'type' => self::FILETYPE_JSON
+ ];
+ } elseif ( file_exists( $this->basepath . '/skin.json' ) ) {
+ $fileinfo = [
+ 'filename' => $this->basepath . '/skin.json',
+ 'type' => self::FILETYPE_JSON
+ ];
+ }
+
+ return $fileinfo;
+ }
+
+ /**
+ * Ensure that Unix-style path separators ("/") are used in the path.
+ *
+ * @param string $path
+ * @return string
+ */
+ protected static function normalizePathSeparator( $path ) {
+ return str_replace( '\\', '/', $path );
+ }
+
+ /**
+ * Initialize the source files and directories which are used for the MediaWiki default
+ * autoloader in {mw-base-dir}/autoload.php including:
+ * * includes/
+ * * languages/
+ * * maintenance/
+ * * mw-config/
+ * * /*.php
+ */
+ public function initMediaWikiDefault() {
+ foreach ( [ 'includes', 'languages', 'maintenance', 'mw-config' ] as $dir ) {
+ $this->readDir( $this->basepath . '/' . $dir );
+ }
+ foreach ( glob( $this->basepath . '/*.php' ) as $file ) {
+ $this->readFile( $file );
+ }
+ }
+}
+
+/**
+ * Reads PHP code and returns the FQCN of every class defined within it.
+ */
+class ClassCollector {
+
+ /**
+ * @var string Current namespace
+ */
+ protected $namespace = '';
+
+ /**
+ * @var array List of FQCN detected in this pass
+ */
+ protected $classes;
+
+ /**
+ * @var array Token from token_get_all() that started an expect sequence
+ */
+ protected $startToken;
+
+ /**
+ * @var array List of tokens that are members of the current expect sequence
+ */
+ protected $tokens;
+
+ /**
+ * @var array Class alias with target/name fields
+ */
+ protected $alias;
+
+ /**
+ * @param string $code PHP code (including <?php) to detect class names from
+ * @return array List of FQCN detected within the tokens
+ */
+ public function getClasses( $code ) {
+ $this->namespace = '';
+ $this->classes = [];
+ $this->startToken = null;
+ $this->alias = null;
+ $this->tokens = [];
+
+ foreach ( token_get_all( $code ) as $token ) {
+ if ( $this->startToken === null ) {
+ $this->tryBeginExpect( $token );
+ } else {
+ $this->tryEndExpect( $token );
+ }
+ }
+
+ return $this->classes;
+ }
+
+ /**
+ * Determine if $token begins the next expect sequence.
+ *
+ * @param array $token
+ */
+ protected function tryBeginExpect( $token ) {
+ if ( is_string( $token ) ) {
+ return;
+ }
+ // Note: When changing class name discovery logic,
+ // AutoLoaderTest.php may also need to be updated.
+ switch ( $token[0] ) {
+ case T_NAMESPACE:
+ case T_CLASS:
+ case T_INTERFACE:
+ case T_TRAIT:
+ case T_DOUBLE_COLON:
+ $this->startToken = $token;
+ break;
+ case T_STRING:
+ if ( $token[1] === 'class_alias' ) {
+ $this->startToken = $token;
+ $this->alias = [];
+ }
+ }
+ }
+
+ /**
+ * Accepts the next token in an expect sequence
+ *
+ * @param array $token
+ */
+ protected function tryEndExpect( $token ) {
+ switch ( $this->startToken[0] ) {
+ case T_DOUBLE_COLON:
+ // Skip over T_CLASS after T_DOUBLE_COLON because this is something like
+ // "self::static" which accesses the class name. It doens't define a new class.
+ $this->startToken = null;
+ break;
+ case T_NAMESPACE:
+ if ( $token === ';' || $token === '{' ) {
+ $this->namespace = $this->implodeTokens() . '\\';
+ } else {
+ $this->tokens[] = $token;
+ }
+ break;
+
+ case T_STRING:
+ if ( $this->alias !== null ) {
+ // Flow 1 - Two string literals:
+ // - T_STRING class_alias
+ // - '('
+ // - T_CONSTANT_ENCAPSED_STRING 'TargetClass'
+ // - ','
+ // - T_WHITESPACE
+ // - T_CONSTANT_ENCAPSED_STRING 'AliasName'
+ // - ')'
+ // Flow 2 - Use of ::class syntax for first parameter
+ // - T_STRING class_alias
+ // - '('
+ // - T_STRING TargetClass
+ // - T_DOUBLE_COLON ::
+ // - T_CLASS class
+ // - ','
+ // - T_WHITESPACE
+ // - T_CONSTANT_ENCAPSED_STRING 'AliasName'
+ // - ')'
+ if ( $token === '(' ) {
+ // Start of a function call to class_alias()
+ $this->alias = [ 'target' => false, 'name' => false ];
+ } elseif ( $token === ',' ) {
+ // Record that we're past the first parameter
+ if ( $this->alias['target'] === false ) {
+ $this->alias['target'] = true;
+ }
+ } elseif ( is_array( $token ) && $token[0] === T_CONSTANT_ENCAPSED_STRING ) {
+ if ( $this->alias['target'] === true ) {
+ // We already saw a first argument, this must be the second.
+ // Strip quotes from the string literal.
+ $this->alias['name'] = substr( $token[1], 1, -1 );
+ }
+ } elseif ( $token === ')' ) {
+ // End of function call
+ $this->classes[] = $this->alias['name'];
+ $this->alias = null;
+ $this->startToken = null;
+ } elseif ( !is_array( $token ) || (
+ $token[0] !== T_STRING &&
+ $token[0] !== T_DOUBLE_COLON &&
+ $token[0] !== T_CLASS &&
+ $token[0] !== T_WHITESPACE
+ ) ) {
+ // Ignore this call to class_alias() - compat/Timestamp.php
+ $this->alias = null;
+ $this->startToken = null;
+ }
+ }
+ break;
+
+ case T_CLASS:
+ case T_INTERFACE:
+ case T_TRAIT:
+ $this->tokens[] = $token;
+ if ( is_array( $token ) && $token[0] === T_STRING ) {
+ $this->classes[] = $this->namespace . $this->implodeTokens();
+ }
+ }
+ }
+
+ /**
+ * Returns the string representation of the tokens within the
+ * current expect sequence and resets the sequence.
+ *
+ * @return string
+ */
+ protected function implodeTokens() {
+ $content = [];
+ foreach ( $this->tokens as $token ) {
+ $content[] = is_string( $token ) ? $token : $token[1];
+ }
+
+ $this->tokens = [];
+ $this->startToken = null;
+
+ return trim( implode( '', $content ), " \n\t" );
+ }
+}
diff --git a/www/wiki/includes/utils/AvroValidator.php b/www/wiki/includes/utils/AvroValidator.php
new file mode 100644
index 00000000..554dda9d
--- /dev/null
+++ b/www/wiki/includes/utils/AvroValidator.php
@@ -0,0 +1,181 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Generate error strings for data that doesn't match the specified
+ * Avro schema. This is very similar to AvroSchema::is_valid_datum(),
+ * but returns error messages instead of a boolean.
+ *
+ * @since 1.26
+ * @author Erik Bernhardson <ebernhardson@wikimedia.org>
+ * @copyright © 2015 Erik Bernhardson and Wikimedia Foundation.
+ */
+class AvroValidator {
+ /**
+ * @param AvroSchema $schema The rules to conform to.
+ * @param mixed $datum The value to validate against $schema.
+ * @return string|string[] An error or list of errors in the
+ * provided $datum. When no errors exist the empty array is
+ * returned.
+ */
+ public static function getErrors( AvroSchema $schema, $datum ) {
+ switch ( $schema->type ) {
+ case AvroSchema::NULL_TYPE:
+ if ( !is_null( $datum ) ) {
+ return self::wrongType( 'null', $datum );
+ }
+ return [];
+ case AvroSchema::BOOLEAN_TYPE:
+ if ( !is_bool( $datum ) ) {
+ return self::wrongType( 'boolean', $datum );
+ }
+ return [];
+ case AvroSchema::STRING_TYPE:
+ case AvroSchema::BYTES_TYPE:
+ if ( !is_string( $datum ) ) {
+ return self::wrongType( 'string', $datum );
+ }
+ return [];
+ case AvroSchema::INT_TYPE:
+ if ( !is_int( $datum ) ) {
+ return self::wrongType( 'integer', $datum );
+ }
+ if ( AvroSchema::INT_MIN_VALUE > $datum
+ || $datum > AvroSchema::INT_MAX_VALUE
+ ) {
+ return self::outOfRange(
+ AvroSchema::INT_MIN_VALUE,
+ AvroSchema::INT_MAX_VALUE,
+ $datum
+ );
+ }
+ return [];
+ case AvroSchema::LONG_TYPE:
+ if ( !is_int( $datum ) ) {
+ return self::wrongType( 'integer', $datum );
+ }
+ if ( AvroSchema::LONG_MIN_VALUE > $datum
+ || $datum > AvroSchema::LONG_MAX_VALUE
+ ) {
+ return self::outOfRange(
+ AvroSchema::LONG_MIN_VALUE,
+ AvroSchema::LONG_MAX_VALUE,
+ $datum
+ );
+ }
+ return [];
+ case AvroSchema::FLOAT_TYPE:
+ case AvroSchema::DOUBLE_TYPE:
+ if ( !is_float( $datum ) && !is_int( $datum ) ) {
+ return self::wrongType( 'float or integer', $datum );
+ }
+ return [];
+ case AvroSchema::ARRAY_SCHEMA:
+ if ( !is_array( $datum ) ) {
+ return self::wrongType( 'array', $datum );
+ }
+ $errors = [];
+ foreach ( $datum as $d ) {
+ $result = self::getErrors( $schema->items(), $d );
+ if ( $result ) {
+ $errors[] = $result;
+ }
+ }
+ return $errors;
+ case AvroSchema::MAP_SCHEMA:
+ if ( !is_array( $datum ) ) {
+ return self::wrongType( 'array', $datum );
+ }
+ $errors = [];
+ foreach ( $datum as $k => $v ) {
+ if ( !is_string( $k ) ) {
+ $errors[] = self::wrongType( 'string key', $k );
+ }
+ $result = self::getErrors( $schema->values(), $v );
+ if ( $result ) {
+ $errors[$k] = $result;
+ }
+ }
+ return $errors;
+ case AvroSchema::UNION_SCHEMA:
+ $errors = [];
+ foreach ( $schema->schemas() as $schema ) {
+ $result = self::getErrors( $schema, $datum );
+ if ( !$result ) {
+ return [];
+ }
+ $errors[] = $result;
+ }
+ if ( $errors ) {
+ return [ "Expected any one of these to be true", $errors ];
+ }
+ return "No schemas provided to union";
+ case AvroSchema::ENUM_SCHEMA:
+ if ( !in_array( $datum, $schema->symbols() ) ) {
+ $symbols = implode( ', ', $schema->symbols );
+ return "Expected one of $symbols but recieved $datum";
+ }
+ return [];
+ case AvroSchema::FIXED_SCHEMA:
+ if ( !is_string( $datum ) ) {
+ return self::wrongType( 'string', $datum );
+ }
+ $len = strlen( $datum );
+ if ( $len !== $schema->size() ) {
+ return "Expected string of length {$schema->size()}, "
+ . "but recieved one of length $len";
+ }
+ return [];
+ case AvroSchema::RECORD_SCHEMA:
+ case AvroSchema::ERROR_SCHEMA:
+ case AvroSchema::REQUEST_SCHEMA:
+ if ( !is_array( $datum ) ) {
+ return self::wrongType( 'array', $datum );
+ }
+ $errors = [];
+ foreach ( $schema->fields() as $field ) {
+ $name = $field->name();
+ if ( !array_key_exists( $name, $datum ) ) {
+ $errors[$name] = 'Missing expected field';
+ continue;
+ }
+ $result = self::getErrors( $field->type(), $datum[$name] );
+ if ( $result ) {
+ $errors[$name] = $result;
+ }
+ }
+ return $errors;
+ default:
+ return "Unknown avro schema type: {$schema->type}";
+ }
+ }
+
+ public static function typeOf( $datum ) {
+ return is_object( $datum ) ? get_class( $datum ) : gettype( $datum );
+ }
+
+ public static function wrongType( $expected, $datum ) {
+ return "Expected $expected, but recieved " . self::typeOf( $datum );
+ }
+
+ public static function outOfRange( $min, $max, $datum ) {
+ return "Expected value between $min and $max, but recieved $datum";
+ }
+}
diff --git a/www/wiki/includes/utils/BatchRowIterator.php b/www/wiki/includes/utils/BatchRowIterator.php
new file mode 100644
index 00000000..60720c87
--- /dev/null
+++ b/www/wiki/includes/utils/BatchRowIterator.php
@@ -0,0 +1,296 @@
+<?php
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Allows iterating a large number of rows in batches transparently.
+ * By default when iterated over returns the full query result as an
+ * array of rows. Can be wrapped in RecursiveIteratorIterator to
+ * collapse those arrays into a single stream of rows queried in batches.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+class BatchRowIterator implements RecursiveIterator {
+
+ /**
+ * @var IDatabase $db The database to read from
+ */
+ protected $db;
+
+ /**
+ * @var string|array $table The name or names of the table to read from
+ */
+ protected $table;
+
+ /**
+ * @var array $primaryKey The name of the primary key(s)
+ */
+ protected $primaryKey;
+
+ /**
+ * @var int $batchSize The number of rows to fetch per iteration
+ */
+ protected $batchSize;
+
+ /**
+ * @var array $conditions Array of strings containing SQL conditions
+ * to add to the query
+ */
+ protected $conditions = [];
+
+ /**
+ * @var array $joinConditions
+ */
+ protected $joinConditions = [];
+
+ /**
+ * @var array $fetchColumns List of column names to select from the
+ * table suitable for use with IDatabase::select()
+ */
+ protected $fetchColumns;
+
+ /**
+ * @var string $orderBy SQL Order by condition generated from $this->primaryKey
+ */
+ protected $orderBy;
+
+ /**
+ * @var array $current The current iterator value
+ */
+ private $current = [];
+
+ /**
+ * @var int key 0-indexed number of pages fetched since self::reset()
+ */
+ private $key;
+
+ /**
+ * @var array Additional query options
+ */
+ protected $options = [];
+
+ /**
+ * @param IDatabase $db The database to read from
+ * @param string|array $table The name or names of the table to read from
+ * @param string|array $primaryKey The name or names of the primary key columns
+ * @param int $batchSize The number of rows to fetch per iteration
+ * @throws InvalidArgumentException
+ */
+ public function __construct( IDatabase $db, $table, $primaryKey, $batchSize ) {
+ if ( $batchSize < 1 ) {
+ throw new InvalidArgumentException( 'Batch size must be at least 1 row.' );
+ }
+ $this->db = $db;
+ $this->table = $table;
+ $this->primaryKey = (array)$primaryKey;
+ $this->fetchColumns = $this->primaryKey;
+ $this->orderBy = implode( ' ASC,', $this->primaryKey ) . ' ASC';
+ $this->batchSize = $batchSize;
+ }
+
+ /**
+ * @param array $conditions Query conditions suitable for use with
+ * IDatabase::select
+ */
+ public function addConditions( array $conditions ) {
+ $this->conditions = array_merge( $this->conditions, $conditions );
+ }
+
+ /**
+ * @param array $options Query options suitable for use with
+ * IDatabase::select
+ */
+ public function addOptions( array $options ) {
+ $this->options = array_merge( $this->options, $options );
+ }
+
+ /**
+ * @param array $conditions Query join conditions suitable for use
+ * with IDatabase::select
+ */
+ public function addJoinConditions( array $conditions ) {
+ $this->joinConditions = array_merge( $this->joinConditions, $conditions );
+ }
+
+ /**
+ * @param array $columns List of column names to select from the
+ * table suitable for use with IDatabase::select()
+ */
+ public function setFetchColumns( array $columns ) {
+ // If it's not the all column selector merge in the primary keys we need
+ if ( count( $columns ) === 1 && reset( $columns ) === '*' ) {
+ $this->fetchColumns = $columns;
+ } else {
+ $this->fetchColumns = array_unique( array_merge(
+ $this->primaryKey,
+ $columns
+ ) );
+ }
+ }
+
+ /**
+ * Extracts the primary key(s) from a database row.
+ *
+ * @param stdClass $row An individual database row from this iterator
+ * @return array Map of primary key column to value within the row
+ */
+ public function extractPrimaryKeys( $row ) {
+ $pk = [];
+ foreach ( $this->primaryKey as $alias => $column ) {
+ $name = is_numeric( $alias ) ? $column : $alias;
+ $pk[$name] = $row->{$name};
+ }
+ return $pk;
+ }
+
+ /**
+ * @return array The most recently fetched set of rows from the database
+ */
+ public function current() {
+ return $this->current;
+ }
+
+ /**
+ * @return int 0-indexed count of the page number fetched
+ */
+ public function key() {
+ return $this->key;
+ }
+
+ /**
+ * Reset the iterator to the begining of the table.
+ */
+ public function rewind() {
+ $this->key = -1; // self::next() will turn this into 0
+ $this->current = [];
+ $this->next();
+ }
+
+ /**
+ * @return bool True when the iterator is in a valid state
+ */
+ public function valid() {
+ return (bool)$this->current;
+ }
+
+ /**
+ * @return bool True when this result set has rows
+ */
+ public function hasChildren() {
+ return $this->current && count( $this->current );
+ }
+
+ /**
+ * @return RecursiveIterator
+ */
+ public function getChildren() {
+ return new NotRecursiveIterator( new ArrayIterator( $this->current ) );
+ }
+
+ /**
+ * Fetch the next set of rows from the database.
+ */
+ public function next() {
+ $res = $this->db->select(
+ $this->table,
+ $this->fetchColumns,
+ $this->buildConditions(),
+ __METHOD__,
+ [
+ 'LIMIT' => $this->batchSize,
+ 'ORDER BY' => $this->orderBy,
+ ] + $this->options,
+ $this->joinConditions
+ );
+
+ // The iterator is converted to an array because in addition to
+ // returning it in self::current() we need to use the end value
+ // in self::buildConditions()
+ $this->current = iterator_to_array( $res );
+ $this->key++;
+ }
+
+ /**
+ * Uses the primary key list and the maximal result row from the
+ * previous iteration to build an SQL condition sufficient for
+ * selecting the next page of results. All except the final key use
+ * `=` conditions while the final key uses a `>` condition
+ *
+ * Example output:
+ * [ '( foo = 42 AND bar > 7 ) OR ( foo > 42 )' ]
+ *
+ * @return array The SQL conditions necessary to select the next set
+ * of rows in the batched query
+ */
+ protected function buildConditions() {
+ if ( !$this->current ) {
+ return $this->conditions;
+ }
+
+ $maxRow = end( $this->current );
+ $maximumValues = [];
+ foreach ( $this->primaryKey as $alias => $column ) {
+ $name = is_numeric( $alias ) ? $column : $alias;
+ $maximumValues[$column] = $this->db->addQuotes( $maxRow->{$name} );
+ }
+
+ $pkConditions = [];
+ // For example: If we have 3 primary keys
+ // first run through will generate
+ // col1 = 4 AND col2 = 7 AND col3 > 1
+ // second run through will generate
+ // col1 = 4 AND col2 > 7
+ // and the final run through will generate
+ // col1 > 4
+ while ( $maximumValues ) {
+ $pkConditions[] = $this->buildGreaterThanCondition( $maximumValues );
+ array_pop( $maximumValues );
+ }
+
+ $conditions = $this->conditions;
+ $conditions[] = sprintf( '( %s )', implode( ' ) OR ( ', $pkConditions ) );
+
+ return $conditions;
+ }
+
+ /**
+ * Given an array of column names and their maximum value generate
+ * an SQL condition where all keys except the last match $quotedMaximumValues
+ * exactly and the last column is greater than the matching value in
+ * $quotedMaximumValues
+ *
+ * @param array $quotedMaximumValues The maximum values quoted with
+ * $this->db->addQuotes()
+ * @return string An SQL condition that will select rows where all
+ * columns match the maximum value exactly except the last column
+ * which must be greater than the provided maximum value
+ */
+ protected function buildGreaterThanCondition( array $quotedMaximumValues ) {
+ $keys = array_keys( $quotedMaximumValues );
+ $lastColumn = end( $keys );
+ $lastValue = array_pop( $quotedMaximumValues );
+ $conditions = [];
+ foreach ( $quotedMaximumValues as $column => $value ) {
+ $conditions[] = "$column = $value";
+ }
+ $conditions[] = "$lastColumn > $lastValue";
+
+ return implode( ' AND ', $conditions );
+ }
+}
diff --git a/www/wiki/includes/utils/BatchRowUpdate.php b/www/wiki/includes/utils/BatchRowUpdate.php
new file mode 100644
index 00000000..f42b5a07
--- /dev/null
+++ b/www/wiki/includes/utils/BatchRowUpdate.php
@@ -0,0 +1,128 @@
+<?php
+/*
+ * Ties together the batch update components to provide a composable
+ * method of batch updating rows in a database. To use create a class
+ * implementing the RowUpdateGenerator interface and configure the
+ * BatchRowIterator and BatchRowWriter for access to the correct table.
+ * The components will handle reading, writing, and waiting for replica DBs
+ * while the generator implementation handles generating update arrays
+ * for singular rows.
+ *
+ * Instantiate:
+ * $updater = new BatchRowUpdate(
+ * new BatchRowIterator( $dbr, 'some_table', 'primary_key_column', 500 ),
+ * new BatchRowWriter( $dbw, 'some_table', 'clusterName' ),
+ * new MyImplementationOfRowUpdateGenerator
+ * );
+ *
+ * Run:
+ * $updater->execute();
+ *
+ * An example maintenance script utilizing the BatchRowUpdate can be
+ * located in the Echo extension file maintenance/updateSchema.php
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+class BatchRowUpdate {
+ /**
+ * @var BatchRowIterator $reader Iterator that returns an array of
+ * database rows
+ */
+ protected $reader;
+
+ /**
+ * @var BatchRowWriter $writer Writer capable of pushing row updates
+ * to the database
+ */
+ protected $writer;
+
+ /**
+ * @var RowUpdateGenerator $generator Generates single row updates
+ * based on the rows content
+ */
+ protected $generator;
+
+ /**
+ * @var callable $output Output callback
+ */
+ protected $output;
+
+ /**
+ * @param BatchRowIterator $reader Iterator that returns an
+ * array of database rows
+ * @param BatchRowWriter $writer Writer capable of pushing
+ * row updates to the database
+ * @param RowUpdateGenerator $generator Generates single row updates
+ * based on the rows content
+ */
+ public function __construct(
+ BatchRowIterator $reader, BatchRowWriter $writer, RowUpdateGenerator $generator
+ ) {
+ $this->reader = $reader;
+ $this->writer = $writer;
+ $this->generator = $generator;
+ $this->output = function () {
+ }; // nop
+ }
+
+ /**
+ * Runs the batch update process
+ */
+ public function execute() {
+ foreach ( $this->reader as $rows ) {
+ $updates = [];
+ foreach ( $rows as $row ) {
+ $update = $this->generator->update( $row );
+ if ( $update ) {
+ $updates[] = [
+ 'primaryKey' => $this->reader->extractPrimaryKeys( $row ),
+ 'changes' => $update,
+ ];
+ }
+ }
+
+ if ( $updates ) {
+ $this->output( "Processing " . count( $updates ) . " rows\n" );
+ $this->writer->write( $updates );
+ }
+ }
+
+ $this->output( "Completed\n" );
+ }
+
+ /**
+ * Accepts a callable which will receive a single parameter
+ * containing string status updates
+ *
+ * @param callable $output A callback taking a single string
+ * parameter to output
+ */
+ public function setOutput( callable $output ) {
+ $this->output = $output;
+ }
+
+ /**
+ * Write out a status update
+ *
+ * @param string $text The value to print
+ */
+ protected function output( $text ) {
+ call_user_func( $this->output, $text );
+ }
+}
diff --git a/www/wiki/includes/utils/BatchRowWriter.php b/www/wiki/includes/utils/BatchRowWriter.php
new file mode 100644
index 00000000..59dcbd63
--- /dev/null
+++ b/www/wiki/includes/utils/BatchRowWriter.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Updates database rows by primary key in batches.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+use Wikimedia\Rdbms\IDatabase;
+use \MediaWiki\MediaWikiServices;
+
+class BatchRowWriter {
+ /**
+ * @var IDatabase $db The database to write to
+ */
+ protected $db;
+
+ /**
+ * @var string $table The name of the table to update
+ */
+ protected $table;
+
+ /**
+ * @var string $clusterName A cluster name valid for use with LBFactory
+ */
+ protected $clusterName;
+
+ /**
+ * @param IDatabase $db The database to write to
+ * @param string $table The name of the table to update
+ * @param string|bool $clusterName A cluster name valid for use with LBFactory
+ */
+ public function __construct( IDatabase $db, $table, $clusterName = false ) {
+ $this->db = $db;
+ $this->table = $table;
+ $this->clusterName = $clusterName;
+ }
+
+ /**
+ * @param array $updates Array of arrays each containing two keys, 'primaryKey'
+ * and 'changes'. primaryKey must contain a map of column names to values
+ * sufficient to uniquely identify the row changes must contain a map of column
+ * names to update values to apply to the row.
+ */
+ public function write( array $updates ) {
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+
+ foreach ( $updates as $update ) {
+ $this->db->update(
+ $this->table,
+ $update['changes'],
+ $update['primaryKey'],
+ __METHOD__
+ );
+ }
+
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
+ }
+}
diff --git a/www/wiki/includes/utils/FileContentsHasher.php b/www/wiki/includes/utils/FileContentsHasher.php
new file mode 100644
index 00000000..afe9c0a0
--- /dev/null
+++ b/www/wiki/includes/utils/FileContentsHasher.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Generate hash digests of file contents to help with cache invalidation.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+class FileContentsHasher {
+
+ /** @var BagOStuff */
+ protected $cache;
+
+ /** @var FileContentsHasher */
+ private static $instance;
+
+ public function __construct() {
+ $this->cache = ObjectCache::getLocalServerInstance( 'hash' );
+ }
+
+ /**
+ * Get the singleton instance of this class.
+ *
+ * @return FileContentsHasher
+ */
+ public static function singleton() {
+ if ( !self::$instance ) {
+ self::$instance = new self;
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get a hash of a file's contents, either by retrieving a previously-
+ * computed hash from the cache, or by computing a hash from the file.
+ *
+ * @private
+ * @param string $filePath Full path to the file.
+ * @param string $algo Name of selected hashing algorithm.
+ * @return string|bool Hash of file contents, or false if the file could not be read.
+ */
+ public function getFileContentsHashInternal( $filePath, $algo = 'md4' ) {
+ $mtime = filemtime( $filePath );
+ if ( $mtime === false ) {
+ return false;
+ }
+
+ $cacheKey = $this->cache->makeGlobalKey( __CLASS__, $filePath, $mtime, $algo );
+ $hash = $this->cache->get( $cacheKey );
+
+ if ( $hash ) {
+ return $hash;
+ }
+
+ $contents = file_get_contents( $filePath );
+ if ( $contents === false ) {
+ return false;
+ }
+
+ $hash = hash( $algo, $contents );
+ $this->cache->set( $cacheKey, $hash, 60 * 60 * 24 ); // 24h
+
+ return $hash;
+ }
+
+ /**
+ * Get a hash of the combined contents of one or more files, either by
+ * retrieving a previously-computed hash from the cache, or by computing
+ * a hash from the files.
+ *
+ * @param string|string[] $filePaths One or more file paths.
+ * @param string $algo Name of selected hashing algorithm.
+ * @return string|bool Hash of files' contents, or false if no file could not be read.
+ */
+ public static function getFileContentsHash( $filePaths, $algo = 'md4' ) {
+ $instance = self::singleton();
+
+ if ( !is_array( $filePaths ) ) {
+ $filePaths = (array)$filePaths;
+ }
+
+ MediaWiki\suppressWarnings();
+
+ if ( count( $filePaths ) === 1 ) {
+ $hash = $instance->getFileContentsHashInternal( $filePaths[0], $algo );
+ MediaWiki\restoreWarnings();
+ return $hash;
+ }
+
+ sort( $filePaths );
+ $hashes = array_map( function ( $filePath ) use ( $instance, $algo ) {
+ return $instance->getFileContentsHashInternal( $filePath, $algo ) ?: '';
+ }, $filePaths );
+
+ MediaWiki\restoreWarnings();
+
+ $hashes = implode( '', $hashes );
+ return $hashes ? hash( $algo, $hashes ) : false;
+ }
+}
diff --git a/www/wiki/includes/utils/IP.php b/www/wiki/includes/utils/IP.php
new file mode 100644
index 00000000..4a2205ee
--- /dev/null
+++ b/www/wiki/includes/utils/IP.php
@@ -0,0 +1,791 @@
+<?php
+/**
+ * Functions and constants to play with IP addresses and ranges
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Antoine Musso "<hashar at free dot fr>", Aaron Schulz
+ */
+
+use IPSet\IPSet;
+
+// Some regex definition to "play" with IP address and IP address blocks
+
+// An IPv4 address is made of 4 bytes from x00 to xFF which is d0 to d255
+define( 'RE_IP_BYTE', '(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])' );
+define( 'RE_IP_ADD', RE_IP_BYTE . '\.' . RE_IP_BYTE . '\.' . RE_IP_BYTE . '\.' . RE_IP_BYTE );
+// An IPv4 block is an IP address and a prefix (d1 to d32)
+define( 'RE_IP_PREFIX', '(3[0-2]|[12]?\d)' );
+define( 'RE_IP_BLOCK', RE_IP_ADD . '\/' . RE_IP_PREFIX );
+
+// An IPv6 address is made up of 8 words (each x0000 to xFFFF).
+// However, the "::" abbreviation can be used on consecutive x0000 words.
+define( 'RE_IPV6_WORD', '([0-9A-Fa-f]{1,4})' );
+define( 'RE_IPV6_PREFIX', '(12[0-8]|1[01][0-9]|[1-9]?\d)' );
+define( 'RE_IPV6_ADD',
+ '(?:' . // starts with "::" (including "::")
+ ':(?::|(?::' . RE_IPV6_WORD . '){1,7})' .
+ '|' . // ends with "::" (except "::")
+ RE_IPV6_WORD . '(?::' . RE_IPV6_WORD . '){0,6}::' .
+ '|' . // contains one "::" in the middle (the ^ makes the test fail if none found)
+ RE_IPV6_WORD . '(?::((?(-1)|:))?' . RE_IPV6_WORD . '){1,6}(?(-2)|^)' .
+ '|' . // contains no "::"
+ RE_IPV6_WORD . '(?::' . RE_IPV6_WORD . '){7}' .
+ ')'
+);
+// An IPv6 block is an IP address and a prefix (d1 to d128)
+define( 'RE_IPV6_BLOCK', RE_IPV6_ADD . '\/' . RE_IPV6_PREFIX );
+// For IPv6 canonicalization (NOT for strict validation; these are quite lax!)
+define( 'RE_IPV6_GAP', ':(?:0+:)*(?::(?:0+:)*)?' );
+define( 'RE_IPV6_V4_PREFIX', '0*' . RE_IPV6_GAP . '(?:ffff:)?' );
+
+// This might be useful for regexps used elsewhere, matches any IPv6 or IPv6 address or network
+define( 'IP_ADDRESS_STRING',
+ '(?:' .
+ RE_IP_ADD . '(?:\/' . RE_IP_PREFIX . ')?' . // IPv4
+ '|' .
+ RE_IPV6_ADD . '(?:\/' . RE_IPV6_PREFIX . ')?' . // IPv6
+ ')'
+);
+
+/**
+ * A collection of public static functions to play with IP address
+ * and IP blocks.
+ */
+class IP {
+ /** @var IPSet */
+ private static $proxyIpSet = null;
+
+ /**
+ * Determine if a string is as valid IP address or network (CIDR prefix).
+ * SIIT IPv4-translated addresses are rejected.
+ * @note canonicalize() tries to convert translated addresses to IPv4.
+ *
+ * @param string $ip Possible IP address
+ * @return bool
+ */
+ public static function isIPAddress( $ip ) {
+ return (bool)preg_match( '/^' . IP_ADDRESS_STRING . '$/', $ip );
+ }
+
+ /**
+ * Given a string, determine if it as valid IP in IPv6 only.
+ * @note Unlike isValid(), this looks for networks too.
+ *
+ * @param string $ip Possible IP address
+ * @return bool
+ */
+ public static function isIPv6( $ip ) {
+ return (bool)preg_match( '/^' . RE_IPV6_ADD . '(?:\/' . RE_IPV6_PREFIX . ')?$/', $ip );
+ }
+
+ /**
+ * Given a string, determine if it as valid IP in IPv4 only.
+ * @note Unlike isValid(), this looks for networks too.
+ *
+ * @param string $ip Possible IP address
+ * @return bool
+ */
+ public static function isIPv4( $ip ) {
+ return (bool)preg_match( '/^' . RE_IP_ADD . '(?:\/' . RE_IP_PREFIX . ')?$/', $ip );
+ }
+
+ /**
+ * Validate an IP address. Ranges are NOT considered valid.
+ * SIIT IPv4-translated addresses are rejected.
+ * @note canonicalize() tries to convert translated addresses to IPv4.
+ *
+ * @param string $ip
+ * @return bool True if it is valid
+ */
+ public static function isValid( $ip ) {
+ return ( preg_match( '/^' . RE_IP_ADD . '$/', $ip )
+ || preg_match( '/^' . RE_IPV6_ADD . '$/', $ip ) );
+ }
+
+ /**
+ * Validate an IP Block (valid address WITH a valid prefix).
+ * SIIT IPv4-translated addresses are rejected.
+ * @note canonicalize() tries to convert translated addresses to IPv4.
+ *
+ * @param string $ipblock
+ * @return bool True if it is valid
+ */
+ public static function isValidBlock( $ipblock ) {
+ return ( preg_match( '/^' . RE_IPV6_BLOCK . '$/', $ipblock )
+ || preg_match( '/^' . RE_IP_BLOCK . '$/', $ipblock ) );
+ }
+
+ /**
+ * Convert an IP into a verbose, uppercase, normalized form.
+ * Both IPv4 and IPv6 addresses are trimmed. Additionally,
+ * IPv6 addresses in octet notation are expanded to 8 words;
+ * IPv4 addresses have leading zeros, in each octet, removed.
+ *
+ * @param string $ip IP address in quad or octet form (CIDR or not).
+ * @return string
+ */
+ public static function sanitizeIP( $ip ) {
+ $ip = trim( $ip );
+ if ( $ip === '' ) {
+ return null;
+ }
+ /* If not an IP, just return trimmed value, since sanitizeIP() is called
+ * in a number of contexts where usernames are supplied as input.
+ */
+ if ( !self::isIPAddress( $ip ) ) {
+ return $ip;
+ }
+ if ( self::isIPv4( $ip ) ) {
+ // Remove leading 0's from octet representation of IPv4 address
+ $ip = preg_replace( '/(?:^|(?<=\.))0+(?=[1-9]|0\.|0$)/', '', $ip );
+ return $ip;
+ }
+ // Remove any whitespaces, convert to upper case
+ $ip = strtoupper( $ip );
+ // Expand zero abbreviations
+ $abbrevPos = strpos( $ip, '::' );
+ if ( $abbrevPos !== false ) {
+ // We know this is valid IPv6. Find the last index of the
+ // address before any CIDR number (e.g. "a:b:c::/24").
+ $CIDRStart = strpos( $ip, "/" );
+ $addressEnd = ( $CIDRStart !== false )
+ ? $CIDRStart - 1
+ : strlen( $ip ) - 1;
+ // If the '::' is at the beginning...
+ if ( $abbrevPos == 0 ) {
+ $repeat = '0:';
+ $extra = ( $ip == '::' ) ? '0' : ''; // for the address '::'
+ $pad = 9; // 7+2 (due to '::')
+ // If the '::' is at the end...
+ } elseif ( $abbrevPos == ( $addressEnd - 1 ) ) {
+ $repeat = ':0';
+ $extra = '';
+ $pad = 9; // 7+2 (due to '::')
+ // If the '::' is in the middle...
+ } else {
+ $repeat = ':0';
+ $extra = ':';
+ $pad = 8; // 6+2 (due to '::')
+ }
+ $ip = str_replace( '::',
+ str_repeat( $repeat, $pad - substr_count( $ip, ':' ) ) . $extra,
+ $ip
+ );
+ }
+ // Remove leading zeros from each bloc as needed
+ $ip = preg_replace( '/(^|:)0+(' . RE_IPV6_WORD . ')/', '$1$2', $ip );
+
+ return $ip;
+ }
+
+ /**
+ * Prettify an IP for display to end users.
+ * This will make it more compact and lower-case.
+ *
+ * @param string $ip
+ * @return string
+ */
+ public static function prettifyIP( $ip ) {
+ $ip = self::sanitizeIP( $ip ); // normalize (removes '::')
+ if ( self::isIPv6( $ip ) ) {
+ // Split IP into an address and a CIDR
+ if ( strpos( $ip, '/' ) !== false ) {
+ list( $ip, $cidr ) = explode( '/', $ip, 2 );
+ } else {
+ list( $ip, $cidr ) = [ $ip, '' ];
+ }
+ // Get the largest slice of words with multiple zeros
+ $offset = 0;
+ $longest = $longestPos = false;
+ while ( preg_match(
+ '!(?:^|:)0(?::0)+(?:$|:)!', $ip, $m, PREG_OFFSET_CAPTURE, $offset
+ ) ) {
+ list( $match, $pos ) = $m[0]; // full match
+ if ( strlen( $match ) > strlen( $longest ) ) {
+ $longest = $match;
+ $longestPos = $pos;
+ }
+ $offset = ( $pos + strlen( $match ) ); // advance
+ }
+ if ( $longest !== false ) {
+ // Replace this portion of the string with the '::' abbreviation
+ $ip = substr_replace( $ip, '::', $longestPos, strlen( $longest ) );
+ }
+ // Add any CIDR back on
+ if ( $cidr !== '' ) {
+ $ip = "{$ip}/{$cidr}";
+ }
+ // Convert to lower case to make it more readable
+ $ip = strtolower( $ip );
+ }
+
+ return $ip;
+ }
+
+ /**
+ * Given a host/port string, like one might find in the host part of a URL
+ * per RFC 2732, split the hostname part and the port part and return an
+ * array with an element for each. If there is no port part, the array will
+ * have false in place of the port. If the string was invalid in some way,
+ * false is returned.
+ *
+ * This was easy with IPv4 and was generally done in an ad-hoc way, but
+ * with IPv6 it's somewhat more complicated due to the need to parse the
+ * square brackets and colons.
+ *
+ * A bare IPv6 address is accepted despite the lack of square brackets.
+ *
+ * @param string $both The string with the host and port
+ * @return array|false Array normally, false on certain failures
+ */
+ public static function splitHostAndPort( $both ) {
+ if ( substr( $both, 0, 1 ) === '[' ) {
+ if ( preg_match( '/^\[(' . RE_IPV6_ADD . ')\](?::(?P<port>\d+))?$/', $both, $m ) ) {
+ if ( isset( $m['port'] ) ) {
+ return [ $m[1], intval( $m['port'] ) ];
+ } else {
+ return [ $m[1], false ];
+ }
+ } else {
+ // Square bracket found but no IPv6
+ return false;
+ }
+ }
+ $numColons = substr_count( $both, ':' );
+ if ( $numColons >= 2 ) {
+ // Is it a bare IPv6 address?
+ if ( preg_match( '/^' . RE_IPV6_ADD . '$/', $both ) ) {
+ return [ $both, false ];
+ } else {
+ // Not valid IPv6, but too many colons for anything else
+ return false;
+ }
+ }
+ if ( $numColons >= 1 ) {
+ // Host:port?
+ $bits = explode( ':', $both );
+ if ( preg_match( '/^\d+/', $bits[1] ) ) {
+ return [ $bits[0], intval( $bits[1] ) ];
+ } else {
+ // Not a valid port
+ return false;
+ }
+ }
+
+ // Plain hostname
+ return [ $both, false ];
+ }
+
+ /**
+ * Given a host name and a port, combine them into host/port string like
+ * you might find in a URL. If the host contains a colon, wrap it in square
+ * brackets like in RFC 2732. If the port matches the default port, omit
+ * the port specification
+ *
+ * @param string $host
+ * @param int $port
+ * @param bool|int $defaultPort
+ * @return string
+ */
+ public static function combineHostAndPort( $host, $port, $defaultPort = false ) {
+ if ( strpos( $host, ':' ) !== false ) {
+ $host = "[$host]";
+ }
+ if ( $defaultPort !== false && $port == $defaultPort ) {
+ return $host;
+ } else {
+ return "$host:$port";
+ }
+ }
+
+ /**
+ * Convert an IPv4 or IPv6 hexadecimal representation back to readable format
+ *
+ * @param string $hex Number, with "v6-" prefix if it is IPv6
+ * @return string Quad-dotted (IPv4) or octet notation (IPv6)
+ */
+ public static function formatHex( $hex ) {
+ if ( substr( $hex, 0, 3 ) == 'v6-' ) { // IPv6
+ return self::hexToOctet( substr( $hex, 3 ) );
+ } else { // IPv4
+ return self::hexToQuad( $hex );
+ }
+ }
+
+ /**
+ * Converts a hexadecimal number to an IPv6 address in octet notation
+ *
+ * @param string $ip_hex Pure hex (no v6- prefix)
+ * @return string (of format a:b:c:d:e:f:g:h)
+ */
+ public static function hexToOctet( $ip_hex ) {
+ // Pad hex to 32 chars (128 bits)
+ $ip_hex = str_pad( strtoupper( $ip_hex ), 32, '0', STR_PAD_LEFT );
+ // Separate into 8 words
+ $ip_oct = substr( $ip_hex, 0, 4 );
+ for ( $n = 1; $n < 8; $n++ ) {
+ $ip_oct .= ':' . substr( $ip_hex, 4 * $n, 4 );
+ }
+ // NO leading zeroes
+ $ip_oct = preg_replace( '/(^|:)0+(' . RE_IPV6_WORD . ')/', '$1$2', $ip_oct );
+
+ return $ip_oct;
+ }
+
+ /**
+ * Converts a hexadecimal number to an IPv4 address in quad-dotted notation
+ *
+ * @param string $ip_hex Pure hex
+ * @return string (of format a.b.c.d)
+ */
+ public static function hexToQuad( $ip_hex ) {
+ // Pad hex to 8 chars (32 bits)
+ $ip_hex = str_pad( strtoupper( $ip_hex ), 8, '0', STR_PAD_LEFT );
+ // Separate into four quads
+ $s = '';
+ for ( $i = 0; $i < 4; $i++ ) {
+ if ( $s !== '' ) {
+ $s .= '.';
+ }
+ $s .= base_convert( substr( $ip_hex, $i * 2, 2 ), 16, 10 );
+ }
+
+ return $s;
+ }
+
+ /**
+ * Determine if an IP address really is an IP address, and if it is public,
+ * i.e. not RFC 1918 or similar
+ *
+ * @param string $ip
+ * @return bool
+ */
+ public static function isPublic( $ip ) {
+ static $privateSet = null;
+ if ( !$privateSet ) {
+ $privateSet = new IPSet( [
+ '10.0.0.0/8', # RFC 1918 (private)
+ '172.16.0.0/12', # RFC 1918 (private)
+ '192.168.0.0/16', # RFC 1918 (private)
+ '0.0.0.0/8', # this network
+ '127.0.0.0/8', # loopback
+ 'fc00::/7', # RFC 4193 (local)
+ '0:0:0:0:0:0:0:1', # loopback
+ '169.254.0.0/16', # link-local
+ 'fe80::/10', # link-local
+ ] );
+ }
+ return !$privateSet->match( $ip );
+ }
+
+ /**
+ * Return a zero-padded upper case hexadecimal representation of an IP address.
+ *
+ * Hexadecimal addresses are used because they can easily be extended to
+ * IPv6 support. To separate the ranges, the return value from this
+ * function for an IPv6 address will be prefixed with "v6-", a non-
+ * hexadecimal string which sorts after the IPv4 addresses.
+ *
+ * @param string $ip Quad dotted/octet IP address.
+ * @return string|bool False on failure
+ */
+ public static function toHex( $ip ) {
+ if ( self::isIPv6( $ip ) ) {
+ $n = 'v6-' . self::IPv6ToRawHex( $ip );
+ } elseif ( self::isIPv4( $ip ) ) {
+ // T62035/T97897: An IP with leading 0's fails in ip2long sometimes (e.g. *.08),
+ // also double/triple 0 needs to be changed to just a single 0 for ip2long.
+ $ip = self::sanitizeIP( $ip );
+ $n = ip2long( $ip );
+ if ( $n < 0 ) {
+ $n += pow( 2, 32 );
+ # On 32-bit platforms (and on Windows), 2^32 does not fit into an int,
+ # so $n becomes a float. We convert it to string instead.
+ if ( is_float( $n ) ) {
+ $n = (string)$n;
+ }
+ }
+ if ( $n !== false ) {
+ # Floating points can handle the conversion; faster than Wikimedia\base_convert()
+ $n = strtoupper( str_pad( base_convert( $n, 10, 16 ), 8, '0', STR_PAD_LEFT ) );
+ }
+ } else {
+ $n = false;
+ }
+
+ return $n;
+ }
+
+ /**
+ * Given an IPv6 address in octet notation, returns a pure hex string.
+ *
+ * @param string $ip Octet ipv6 IP address.
+ * @return string|bool Pure hex (uppercase); false on failure
+ */
+ private static function IPv6ToRawHex( $ip ) {
+ $ip = self::sanitizeIP( $ip );
+ if ( !$ip ) {
+ return false;
+ }
+ $r_ip = '';
+ foreach ( explode( ':', $ip ) as $v ) {
+ $r_ip .= str_pad( $v, 4, 0, STR_PAD_LEFT );
+ }
+
+ return $r_ip;
+ }
+
+ /**
+ * Convert a network specification in CIDR notation
+ * to an integer network and a number of bits
+ *
+ * @param string $range IP with CIDR prefix
+ * @return array(int or string, int)
+ */
+ public static function parseCIDR( $range ) {
+ if ( self::isIPv6( $range ) ) {
+ return self::parseCIDR6( $range );
+ }
+ $parts = explode( '/', $range, 2 );
+ if ( count( $parts ) != 2 ) {
+ return [ false, false ];
+ }
+ list( $network, $bits ) = $parts;
+ $network = ip2long( $network );
+ if ( $network !== false && is_numeric( $bits ) && $bits >= 0 && $bits <= 32 ) {
+ if ( $bits == 0 ) {
+ $network = 0;
+ } else {
+ $network &= ~( ( 1 << ( 32 - $bits ) ) - 1 );
+ }
+ # Convert to unsigned
+ if ( $network < 0 ) {
+ $network += pow( 2, 32 );
+ }
+ } else {
+ $network = false;
+ $bits = false;
+ }
+
+ return [ $network, $bits ];
+ }
+
+ /**
+ * Given a string range in a number of formats,
+ * return the start and end of the range in hexadecimal.
+ *
+ * Formats are:
+ * 1.2.3.4/24 CIDR
+ * 1.2.3.4 - 1.2.3.5 Explicit range
+ * 1.2.3.4 Single IP
+ *
+ * 2001:0db8:85a3::7344/96 CIDR
+ * 2001:0db8:85a3::7344 - 2001:0db8:85a3::7344 Explicit range
+ * 2001:0db8:85a3::7344 Single IP
+ * @param string $range IP range
+ * @return array(string, string)
+ */
+ public static function parseRange( $range ) {
+ // CIDR notation
+ if ( strpos( $range, '/' ) !== false ) {
+ if ( self::isIPv6( $range ) ) {
+ return self::parseRange6( $range );
+ }
+ list( $network, $bits ) = self::parseCIDR( $range );
+ if ( $network === false ) {
+ $start = $end = false;
+ } else {
+ $start = sprintf( '%08X', $network );
+ $end = sprintf( '%08X', $network + pow( 2, ( 32 - $bits ) ) - 1 );
+ }
+ // Explicit range
+ } elseif ( strpos( $range, '-' ) !== false ) {
+ list( $start, $end ) = array_map( 'trim', explode( '-', $range, 2 ) );
+ if ( self::isIPv6( $start ) && self::isIPv6( $end ) ) {
+ return self::parseRange6( $range );
+ }
+ if ( self::isIPv4( $start ) && self::isIPv4( $end ) ) {
+ $start = self::toHex( $start );
+ $end = self::toHex( $end );
+ if ( $start > $end ) {
+ $start = $end = false;
+ }
+ } else {
+ $start = $end = false;
+ }
+ } else {
+ # Single IP
+ $start = $end = self::toHex( $range );
+ }
+ if ( $start === false || $end === false ) {
+ return [ false, false ];
+ } else {
+ return [ $start, $end ];
+ }
+ }
+
+ /**
+ * Convert a network specification in IPv6 CIDR notation to an
+ * integer network and a number of bits
+ *
+ * @param string $range
+ *
+ * @return array(string, int)
+ */
+ private static function parseCIDR6( $range ) {
+ # Explode into <expanded IP,range>
+ $parts = explode( '/', IP::sanitizeIP( $range ), 2 );
+ if ( count( $parts ) != 2 ) {
+ return [ false, false ];
+ }
+ list( $network, $bits ) = $parts;
+ $network = self::IPv6ToRawHex( $network );
+ if ( $network !== false && is_numeric( $bits ) && $bits >= 0 && $bits <= 128 ) {
+ if ( $bits == 0 ) {
+ $network = "0";
+ } else {
+ # Native 32 bit functions WONT work here!!!
+ # Convert to a padded binary number
+ $network = Wikimedia\base_convert( $network, 16, 2, 128 );
+ # Truncate the last (128-$bits) bits and replace them with zeros
+ $network = str_pad( substr( $network, 0, $bits ), 128, 0, STR_PAD_RIGHT );
+ # Convert back to an integer
+ $network = Wikimedia\base_convert( $network, 2, 10 );
+ }
+ } else {
+ $network = false;
+ $bits = false;
+ }
+
+ return [ $network, (int)$bits ];
+ }
+
+ /**
+ * Given a string range in a number of formats, return the
+ * start and end of the range in hexadecimal. For IPv6.
+ *
+ * Formats are:
+ * 2001:0db8:85a3::7344/96 CIDR
+ * 2001:0db8:85a3::7344 - 2001:0db8:85a3::7344 Explicit range
+ * 2001:0db8:85a3::7344/96 Single IP
+ *
+ * @param string $range
+ *
+ * @return array(string, string)
+ */
+ private static function parseRange6( $range ) {
+ # Expand any IPv6 IP
+ $range = IP::sanitizeIP( $range );
+ // CIDR notation...
+ if ( strpos( $range, '/' ) !== false ) {
+ list( $network, $bits ) = self::parseCIDR6( $range );
+ if ( $network === false ) {
+ $start = $end = false;
+ } else {
+ $start = Wikimedia\base_convert( $network, 10, 16, 32, false );
+ # Turn network to binary (again)
+ $end = Wikimedia\base_convert( $network, 10, 2, 128 );
+ # Truncate the last (128-$bits) bits and replace them with ones
+ $end = str_pad( substr( $end, 0, $bits ), 128, 1, STR_PAD_RIGHT );
+ # Convert to hex
+ $end = Wikimedia\base_convert( $end, 2, 16, 32, false );
+ # see toHex() comment
+ $start = "v6-$start";
+ $end = "v6-$end";
+ }
+ // Explicit range notation...
+ } elseif ( strpos( $range, '-' ) !== false ) {
+ list( $start, $end ) = array_map( 'trim', explode( '-', $range, 2 ) );
+ $start = self::toHex( $start );
+ $end = self::toHex( $end );
+ if ( $start > $end ) {
+ $start = $end = false;
+ }
+ } else {
+ # Single IP
+ $start = $end = self::toHex( $range );
+ }
+ if ( $start === false || $end === false ) {
+ return [ false, false ];
+ } else {
+ return [ $start, $end ];
+ }
+ }
+
+ /**
+ * Determine if a given IPv4/IPv6 address is in a given CIDR network
+ *
+ * @param string $addr The address to check against the given range.
+ * @param string $range The range to check the given address against.
+ * @return bool Whether or not the given address is in the given range.
+ *
+ * @note This can return unexpected results for invalid arguments!
+ * Make sure you pass a valid IP address and IP range.
+ */
+ public static function isInRange( $addr, $range ) {
+ $hexIP = self::toHex( $addr );
+ list( $start, $end ) = self::parseRange( $range );
+
+ return ( strcmp( $hexIP, $start ) >= 0 &&
+ strcmp( $hexIP, $end ) <= 0 );
+ }
+
+ /**
+ * Determines if an IP address is a list of CIDR a.b.c.d/n ranges.
+ *
+ * @since 1.25
+ *
+ * @param string $ip the IP to check
+ * @param array $ranges the IP ranges, each element a range
+ *
+ * @return bool true if the specified adress belongs to the specified range; otherwise, false.
+ */
+ public static function isInRanges( $ip, $ranges ) {
+ foreach ( $ranges as $range ) {
+ if ( self::isInRange( $ip, $range ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Convert some unusual representations of IPv4 addresses to their
+ * canonical dotted quad representation.
+ *
+ * This currently only checks a few IPV4-to-IPv6 related cases. More
+ * unusual representations may be added later.
+ *
+ * @param string $addr Something that might be an IP address
+ * @return string|null Valid dotted quad IPv4 address or null
+ */
+ public static function canonicalize( $addr ) {
+ // remove zone info (bug 35738)
+ $addr = preg_replace( '/\%.*/', '', $addr );
+
+ if ( self::isValid( $addr ) ) {
+ return $addr;
+ }
+ // Turn mapped addresses from ::ce:ffff:1.2.3.4 to 1.2.3.4
+ if ( strpos( $addr, ':' ) !== false && strpos( $addr, '.' ) !== false ) {
+ $addr = substr( $addr, strrpos( $addr, ':' ) + 1 );
+ if ( self::isIPv4( $addr ) ) {
+ return $addr;
+ }
+ }
+ // IPv6 loopback address
+ $m = [];
+ if ( preg_match( '/^0*' . RE_IPV6_GAP . '1$/', $addr, $m ) ) {
+ return '127.0.0.1';
+ }
+ // IPv4-mapped and IPv4-compatible IPv6 addresses
+ if ( preg_match( '/^' . RE_IPV6_V4_PREFIX . '(' . RE_IP_ADD . ')$/i', $addr, $m ) ) {
+ return $m[1];
+ }
+ if ( preg_match( '/^' . RE_IPV6_V4_PREFIX . RE_IPV6_WORD .
+ ':' . RE_IPV6_WORD . '$/i', $addr, $m )
+ ) {
+ return long2ip( ( hexdec( $m[1] ) << 16 ) + hexdec( $m[2] ) );
+ }
+
+ return null; // give up
+ }
+
+ /**
+ * Gets rid of unneeded numbers in quad-dotted/octet IP strings
+ * For example, 127.111.113.151/24 -> 127.111.113.0/24
+ * @param string $range IP address to normalize
+ * @return string
+ */
+ public static function sanitizeRange( $range ) {
+ list( /*...*/, $bits ) = self::parseCIDR( $range );
+ list( $start, /*...*/ ) = self::parseRange( $range );
+ $start = self::formatHex( $start );
+ if ( $bits === false ) {
+ return $start; // wasn't actually a range
+ }
+
+ return "$start/$bits";
+ }
+
+ /**
+ * Checks if an IP is a trusted proxy provider.
+ * Useful to tell if X-Forwarded-For data is possibly bogus.
+ * CDN cache servers for the site are whitelisted.
+ * @since 1.24
+ *
+ * @param string $ip
+ * @return bool
+ */
+ public static function isTrustedProxy( $ip ) {
+ $trusted = self::isConfiguredProxy( $ip );
+ Hooks::run( 'IsTrustedProxy', [ &$ip, &$trusted ] );
+ return $trusted;
+ }
+
+ /**
+ * Checks if an IP matches a proxy we've configured
+ * @since 1.24
+ *
+ * @param string $ip
+ * @return bool
+ */
+ public static function isConfiguredProxy( $ip ) {
+ global $wgSquidServers, $wgSquidServersNoPurge;
+
+ // Quick check of known singular proxy servers
+ $trusted = in_array( $ip, $wgSquidServers );
+
+ // Check against addresses and CIDR nets in the NoPurge list
+ if ( !$trusted ) {
+ if ( !self::$proxyIpSet ) {
+ self::$proxyIpSet = new IPSet( $wgSquidServersNoPurge );
+ }
+ $trusted = self::$proxyIpSet->match( $ip );
+ }
+
+ return $trusted;
+ }
+
+ /**
+ * Clears precomputed data used for proxy support.
+ * Use this only for unit tests.
+ */
+ public static function clearCaches() {
+ self::$proxyIpSet = null;
+ }
+
+ /**
+ * Returns the subnet of a given IP
+ *
+ * @param string $ip
+ * @return string|false
+ */
+ public static function getSubnet( $ip ) {
+ $matches = [];
+ $subnet = false;
+ if ( IP::isIPv6( $ip ) ) {
+ $parts = IP::parseRange( "$ip/64" );
+ $subnet = $parts[0];
+ } elseif ( preg_match( '/^(\d+\.\d+\.\d+)\.\d+$/', $ip, $matches ) ) {
+ // IPv4
+ $subnet = $matches[1];
+ }
+ return $subnet;
+ }
+}
diff --git a/www/wiki/includes/utils/MWCryptHKDF.php b/www/wiki/includes/utils/MWCryptHKDF.php
new file mode 100644
index 00000000..1c8d4861
--- /dev/null
+++ b/www/wiki/includes/utils/MWCryptHKDF.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * Extract-and-Expand Key Derivation Function (HKDF). A cryptographicly
+ * secure key expansion function based on RFC 5869.
+ *
+ * This relies on the secrecy of $wgSecretKey (by default), or $wgHKDFSecret.
+ * By default, sha256 is used as the underlying hashing algorithm, but any other
+ * algorithm can be used. Finding the secret key from the output would require
+ * an attacker to discover the input key (the PRK) to the hmac that generated
+ * the output, and discover the particular data, hmac'ed with an evolving key
+ * (salt), to produce the PRK. Even with md5, no publicly known attacks make
+ * this currently feasible.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @author Chris Steipp
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+class MWCryptHKDF {
+
+ /**
+ * Return a singleton instance, based on the global configs.
+ * @return CryptHKDF
+ */
+ protected static function singleton() {
+ return MediaWikiServices::getInstance()->getCryptHKDF();
+ }
+
+ /**
+ * RFC5869 defines HKDF in 2 steps, extraction and expansion.
+ * From http://eprint.iacr.org/2010/264.pdf:
+ *
+ * The scheme HKDF is specifed as:
+ * HKDF(XTS, SKM, CTXinfo, L) = K(1) || K(2) || ... || K(t)
+ * where the values K(i) are defined as follows:
+ * PRK = HMAC(XTS, SKM)
+ * K(1) = HMAC(PRK, CTXinfo || 0);
+ * K(i+1) = HMAC(PRK, K(i) || CTXinfo || i), 1 <= i < t;
+ * where t = [L/k] and the value K(t) is truncated to its first d = L mod k bits;
+ * the counter i is non-wrapping and of a given fixed size, e.g., a single byte.
+ * Note that the length of the HMAC output is the same as its key length and therefore
+ * the scheme is well defined.
+ *
+ * XTS is the "extractor salt"
+ * SKM is the "secret keying material"
+ *
+ * N.B. http://eprint.iacr.org/2010/264.pdf seems to differ from RFC 5869 in that the test
+ * vectors from RFC 5869 only work if K(0) = '' and K(1) = HMAC(PRK, K(0) || CTXinfo || 1)
+ *
+ * @param string $hash The hashing function to use (e.g., sha256)
+ * @param string $ikm The input keying material
+ * @param string $salt The salt to add to the ikm, to get the prk
+ * @param string $info Optional context (change the output without affecting
+ * the randomness properties of the output)
+ * @param int $L Number of bytes to return
+ * @return string Cryptographically secure pseudorandom binary string
+ */
+ public static function HKDF( $hash, $ikm, $salt, $info, $L ) {
+ return CryptHKDF::HKDF( $hash, $ikm, $salt, $info, $L );
+ }
+
+ /**
+ * Generate cryptographically random data and return it in raw binary form.
+ *
+ * @param int $bytes The number of bytes of random data to generate
+ * @param string $context String to mix into HMAC context
+ * @return string Binary string of length $bytes
+ */
+ public static function generate( $bytes, $context ) {
+ return self::singleton()->generate( $bytes, $context );
+ }
+
+ /**
+ * Generate cryptographically random data and return it in hexadecimal string format.
+ * See MWCryptRand::realGenerateHex for details of the char-to-byte conversion logic.
+ *
+ * @param int $chars The number of hex chars of random data to generate
+ * @param string $context String to mix into HMAC context
+ * @return string Random hex characters, $chars long
+ */
+ public static function generateHex( $chars, $context = '' ) {
+ $bytes = ceil( $chars / 2 );
+ $hex = bin2hex( self::singleton()->generate( $bytes, $context ) );
+ return substr( $hex, 0, $chars );
+ }
+
+}
diff --git a/www/wiki/includes/utils/MWCryptHash.php b/www/wiki/includes/utils/MWCryptHash.php
new file mode 100644
index 00000000..11173573
--- /dev/null
+++ b/www/wiki/includes/utils/MWCryptHash.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Utility functions for generating hashes
+ *
+ * This is based in part on Drupal code as well as what we used in our own code
+ * prior to introduction of this class, by way of MWCryptRand.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class MWCryptHash {
+ /**
+ * The hash algorithm being used
+ */
+ protected static $algo = null;
+
+ /**
+ * The number of bytes outputted by the hash algorithm
+ */
+ protected static $hashLength = [
+ true => null,
+ false => null,
+ ];
+
+ /**
+ * Decide on the best acceptable hash algorithm we have available for hash()
+ * @return string A hash algorithm
+ */
+ public static function hashAlgo() {
+ if ( !is_null( self::$algo ) ) {
+ return self::$algo;
+ }
+
+ $algos = hash_algos();
+ $preference = [ 'whirlpool', 'sha256', 'sha1', 'md5' ];
+
+ foreach ( $preference as $algorithm ) {
+ if ( in_array( $algorithm, $algos ) ) {
+ self::$algo = $algorithm;
+ wfDebug( __METHOD__ . ': Using the ' . self::$algo . " hash algorithm.\n" );
+
+ return self::$algo;
+ }
+ }
+
+ // We only reach here if no acceptable hash is found in the list, this should
+ // be a technical impossibility since most of php's hash list is fixed and
+ // some of the ones we list are available as their own native functions
+ // But since we already require at least 5.2 and hash() was default in
+ // 5.1.2 we don't bother falling back to methods like sha1 and md5.
+ throw new DomainException( "Could not find an acceptable hashing function in hash_algos()" );
+ }
+
+ /**
+ * Return the byte-length output of the hash algorithm we are
+ * using in self::hash and self::hmac.
+ *
+ * @param bool $raw True to return the length for binary data, false to
+ * return for hex-encoded
+ * @return int Number of bytes the hash outputs
+ */
+ public static function hashLength( $raw = true ) {
+ $raw = (bool)$raw;
+ if ( is_null( self::$hashLength[$raw] ) ) {
+ self::$hashLength[$raw] = strlen( self::hash( '', $raw ) );
+ }
+
+ return self::$hashLength[$raw];
+ }
+
+ /**
+ * Generate an acceptably unstable one-way-hash of some text
+ * making use of the best hash algorithm that we have available.
+ *
+ * @param string $data
+ * @param bool $raw True to return binary data, false to return it hex-encoded
+ * @return string A hash of the data
+ */
+ public static function hash( $data, $raw = true ) {
+ return hash( self::hashAlgo(), $data, $raw );
+ }
+
+ /**
+ * Generate an acceptably unstable one-way-hmac of some text
+ * making use of the best hash algorithm that we have available.
+ *
+ * @param string $data
+ * @param string $key
+ * @param bool $raw True to return binary data, false to return it hex-encoded
+ * @return string An hmac hash of the data + key
+ */
+ public static function hmac( $data, $key, $raw = true ) {
+ if ( !is_string( $key ) ) {
+ // a fatal error in HHVM; an exception will at least give us a stack trace
+ throw new InvalidArgumentException( 'Invalid key type: ' . gettype( $key ) );
+ }
+ return hash_hmac( self::hashAlgo(), $data, $key, $raw );
+ }
+
+}
diff --git a/www/wiki/includes/utils/MWCryptRand.php b/www/wiki/includes/utils/MWCryptRand.php
new file mode 100644
index 00000000..58189580
--- /dev/null
+++ b/www/wiki/includes/utils/MWCryptRand.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * A cryptographic random generator class used for generating secret keys
+ *
+ * This is based in part on Drupal code as well as what we used in our own code
+ * prior to introduction of this class.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @author Daniel Friesen
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+class MWCryptRand {
+ /**
+ * @return CryptRand
+ */
+ protected static function singleton() {
+ return MediaWikiServices::getInstance()->getCryptRand();
+ }
+
+ /**
+ * Return a boolean indicating whether or not the source used for cryptographic
+ * random bytes generation in the previously run generate* call
+ * was cryptographically strong.
+ *
+ * @return bool Returns true if the source was strong, false if not.
+ */
+ public static function wasStrong() {
+ return self::singleton()->wasStrong();
+ }
+
+ /**
+ * Generate a run of (ideally) cryptographically random data and return
+ * it in raw binary form.
+ * You can use MWCryptRand::wasStrong() if you wish to know if the source used
+ * was cryptographically strong.
+ *
+ * @param int $bytes The number of bytes of random data to generate
+ * @param bool $forceStrong Pass true if you want generate to prefer cryptographically
+ * strong sources of entropy even if reading from them may steal
+ * more entropy from the system than optimal.
+ * @return string Raw binary random data
+ */
+ public static function generate( $bytes, $forceStrong = false ) {
+ return self::singleton()->generate( $bytes, $forceStrong );
+ }
+
+ /**
+ * Generate a run of (ideally) cryptographically random data and return
+ * it in hexadecimal string format.
+ * You can use MWCryptRand::wasStrong() if you wish to know if the source used
+ * was cryptographically strong.
+ *
+ * @param int $chars The number of hex chars of random data to generate
+ * @param bool $forceStrong Pass true if you want generate to prefer cryptographically
+ * strong sources of entropy even if reading from them may steal
+ * more entropy from the system than optimal.
+ * @return string Hexadecimal random data
+ */
+ public static function generateHex( $chars, $forceStrong = false ) {
+ return self::singleton()->generateHex( $chars, $forceStrong );
+ }
+}
diff --git a/www/wiki/includes/utils/MWFileProps.php b/www/wiki/includes/utils/MWFileProps.php
new file mode 100644
index 00000000..e60b9ab7
--- /dev/null
+++ b/www/wiki/includes/utils/MWFileProps.php
@@ -0,0 +1,145 @@
+<?php
+/**
+ * MimeMagic helper functions for detecting and dealing with MIME types.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * MimeMagic helper wrapper
+ *
+ * @since 1.28
+ */
+class MWFileProps {
+ /** @var MimeMagic */
+ private $magic;
+
+ /**
+ * @param MimeMagic $magic
+ */
+ public function __construct( MimeMagic $magic ) {
+ $this->magic = $magic;
+ }
+
+ /**
+ * Get an associative array containing information about
+ * a file with the given storage path.
+ *
+ * Resulting array fields include:
+ * - fileExists
+ * - size (filesize in bytes)
+ * - mime (as major/minor)
+ * - media_type (value to be used with the MEDIATYPE_xxx constants)
+ * - metadata (handler specific)
+ * - sha1 (in base 36)
+ * - width
+ * - height
+ * - bits (bitrate)
+ * - file-mime
+ * - major_mime
+ * - minor_mime
+ *
+ * @param string $path Filesystem path to a file
+ * @param string|bool $ext The file extension, or true to extract it from the filename.
+ * Set it to false to ignore the extension.
+ * @return array
+ * @since 1.28
+ */
+ public function getPropsFromPath( $path, $ext ) {
+ $fsFile = new FSFile( $path );
+
+ $info = $this->newPlaceholderProps();
+ $info['fileExists'] = $fsFile->exists();
+ if ( $info['fileExists'] ) {
+ $info['size'] = $fsFile->getSize(); // bytes
+ $info['sha1'] = $fsFile->getSha1Base36();
+
+ # MIME type according to file contents
+ $info['file-mime'] = $this->magic->guessMimeType( $path, false );
+ # Logical MIME type
+ $ext = ( $ext === true ) ? FileBackend::extensionFromPath( $path ) : $ext;
+ $info['mime'] = $this->magic->improveTypeFromExtension( $info['file-mime'], $ext );
+
+ list( $info['major_mime'], $info['minor_mime'] ) = File::splitMime( $info['mime'] );
+ $info['media_type'] = $this->magic->getMediaType( $path, $info['mime'] );
+
+ # Height, width and metadata
+ $handler = MediaHandler::getHandler( $info['mime'] );
+ if ( $handler ) {
+ $info['metadata'] = $handler->getMetadata( $fsFile, $path );
+ /** @noinspection PhpMethodParametersCountMismatchInspection */
+ $gis = $handler->getImageSize( $fsFile, $path, $info['metadata'] );
+ if ( is_array( $gis ) ) {
+ $info = $this->extractImageSizeInfo( $gis ) + $info;
+ }
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * Exract image size information
+ *
+ * @param array $gis
+ * @return array
+ */
+ private function extractImageSizeInfo( array $gis ) {
+ $info = [];
+ # NOTE: $gis[2] contains a code for the image type. This is no longer used.
+ $info['width'] = $gis[0];
+ $info['height'] = $gis[1];
+ if ( isset( $gis['bits'] ) ) {
+ $info['bits'] = $gis['bits'];
+ } else {
+ $info['bits'] = 0;
+ }
+
+ return $info;
+ }
+
+ /**
+ * Empty place holder props for non-existing files
+ *
+ * Resulting array fields include:
+ * - fileExists
+ * - size (filesize in bytes)
+ * - mime (as major/minor)
+ * - media_type (value to be used with the MEDIATYPE_xxx constants)
+ * - metadata (handler specific)
+ * - sha1 (in base 36)
+ * - width
+ * - height
+ * - bits (bitrate)
+ * - file-mime
+ * - major_mime
+ * - minor_mime
+ *
+ * @return array
+ * @since 1.28
+ */
+ public function newPlaceholderProps() {
+ return FSFile::placeholderProps() + [
+ 'metadata' => '',
+ 'width' => 0,
+ 'height' => 0,
+ 'bits' => 0,
+ 'media_type' => MEDIATYPE_UNKNOWN
+ ];
+ }
+}
diff --git a/www/wiki/includes/utils/MWGrants.php b/www/wiki/includes/utils/MWGrants.php
new file mode 100644
index 00000000..58efdc72
--- /dev/null
+++ b/www/wiki/includes/utils/MWGrants.php
@@ -0,0 +1,214 @@
+<?php
+/**
+ * Functions and constants to deal with grants
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * A collection of public static functions to deal with grants.
+ */
+class MWGrants {
+
+ /**
+ * List all known grants.
+ * @return array
+ */
+ public static function getValidGrants() {
+ global $wgGrantPermissions;
+
+ return array_keys( $wgGrantPermissions );
+ }
+
+ /**
+ * Map all grants to corresponding user rights.
+ * @return array grant => array of rights
+ */
+ public static function getRightsByGrant() {
+ global $wgGrantPermissions;
+
+ $res = [];
+ foreach ( $wgGrantPermissions as $grant => $rights ) {
+ $res[$grant] = array_keys( array_filter( $rights ) );
+ }
+ return $res;
+ }
+
+ /**
+ * Fetch the display name of the grant
+ * @param string $grant
+ * @param Language|string|null $lang
+ * @return string Grant description
+ */
+ public static function grantName( $grant, $lang = null ) {
+ // Give grep a chance to find the usages:
+ // grant-blockusers, grant-createeditmovepage, grant-delete,
+ // grant-editinterface, grant-editmycssjs, grant-editmywatchlist,
+ // grant-editpage, grant-editprotected, grant-highvolume,
+ // grant-oversight, grant-patrol, grant-protect, grant-rollback,
+ // grant-sendemail, grant-uploadeditmovefile, grant-uploadfile,
+ // grant-basic, grant-viewdeleted, grant-viewmywatchlist,
+ // grant-createaccount
+ $msg = wfMessage( "grant-$grant" );
+ if ( $lang !== null ) {
+ if ( is_string( $lang ) ) {
+ $lang = Language::factory( $lang );
+ }
+ $msg->inLanguage( $lang );
+ }
+ if ( !$msg->exists() ) {
+ $msg = wfMessage( 'grant-generic', $grant );
+ if ( $lang ) {
+ $msg->inLanguage( $lang );
+ }
+ }
+ return $msg->text();
+ }
+
+ /**
+ * Fetch the display names for the grants.
+ * @param string[] $grants
+ * @param Language|string|null $lang
+ * @return string[] Corresponding grant descriptions
+ */
+ public static function grantNames( array $grants, $lang = null ) {
+ if ( $lang !== null ) {
+ if ( is_string( $lang ) ) {
+ $lang = Language::factory( $lang );
+ }
+ }
+
+ $ret = [];
+ foreach ( $grants as $grant ) {
+ $ret[] = self::grantName( $grant, $lang );
+ }
+ return $ret;
+ }
+
+ /**
+ * Fetch the rights allowed by a set of grants.
+ * @param string[]|string $grants
+ * @return string[]
+ */
+ public static function getGrantRights( $grants ) {
+ global $wgGrantPermissions;
+
+ $rights = [];
+ foreach ( (array)$grants as $grant ) {
+ if ( isset( $wgGrantPermissions[$grant] ) ) {
+ $rights = array_merge( $rights, array_keys( array_filter( $wgGrantPermissions[$grant] ) ) );
+ }
+ }
+ return array_unique( $rights );
+ }
+
+ /**
+ * Test that all grants in the list are known.
+ * @param string[] $grants
+ * @return bool
+ */
+ public static function grantsAreValid( array $grants ) {
+ return array_diff( $grants, self::getValidGrants() ) === [];
+ }
+
+ /**
+ * Divide the grants into groups.
+ * @param string[]|null $grantsFilter
+ * @return array Map of (group => (grant list))
+ */
+ public static function getGrantGroups( $grantsFilter = null ) {
+ global $wgGrantPermissions, $wgGrantPermissionGroups;
+
+ if ( is_array( $grantsFilter ) ) {
+ $grantsFilter = array_flip( $grantsFilter );
+ }
+
+ $groups = [];
+ foreach ( $wgGrantPermissions as $grant => $rights ) {
+ if ( $grantsFilter !== null && !isset( $grantsFilter[$grant] ) ) {
+ continue;
+ }
+ if ( isset( $wgGrantPermissionGroups[$grant] ) ) {
+ $groups[$wgGrantPermissionGroups[$grant]][] = $grant;
+ } else {
+ $groups['other'][] = $grant;
+ }
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Get the list of grants that are hidden and should always be granted
+ * @return string[]
+ */
+ public static function getHiddenGrants() {
+ global $wgGrantPermissionGroups;
+
+ $grants = [];
+ foreach ( $wgGrantPermissionGroups as $grant => $group ) {
+ if ( $group === 'hidden' ) {
+ $grants[] = $grant;
+ }
+ }
+ return $grants;
+ }
+
+ /**
+ * Generate a link to Special:ListGrants for a particular grant name.
+ *
+ * This should be used to link end users to a full description of what
+ * rights they are giving when they authorize a grant.
+ *
+ * @param string $grant the grant name
+ * @param Language|string|null $lang
+ * @return string (proto-relative) HTML link
+ */
+ public static function getGrantsLink( $grant, $lang = null ) {
+ return \Linker::linkKnown(
+ \SpecialPage::getTitleFor( 'Listgrants', false, $grant ),
+ htmlspecialchars( self::grantName( $grant, $lang ) )
+ );
+ }
+
+ /**
+ * Generate wikitext to display a list of grants
+ * @param string[]|null $grantsFilter If non-null, only display these grants.
+ * @param Language|string|null $lang
+ * @return string Wikitext
+ */
+ public static function getGrantsWikiText( $grantsFilter, $lang = null ) {
+ global $wgContLang;
+
+ if ( is_string( $lang ) ) {
+ $lang = Language::factory( $lang );
+ } elseif ( $lang === null ) {
+ $lang = $wgContLang;
+ }
+
+ $s = '';
+ foreach ( self::getGrantGroups( $grantsFilter ) as $group => $grants ) {
+ if ( $group === 'hidden' ) {
+ continue; // implicitly granted
+ }
+ $s .= "*<span class=\"mw-grantgroup\">" .
+ wfMessage( "grant-group-$group" )->inLanguage( $lang )->text() . "</span>\n";
+ $s .= ":" . $lang->semicolonList( self::grantNames( $grants, $lang ) ) . "\n";
+ }
+ return "$s\n";
+ }
+
+}
diff --git a/www/wiki/includes/utils/MWRestrictions.php b/www/wiki/includes/utils/MWRestrictions.php
new file mode 100644
index 00000000..caf88a15
--- /dev/null
+++ b/www/wiki/includes/utils/MWRestrictions.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * A class to check request restrictions expressed as a JSON object
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * A class to check request restrictions expressed as a JSON object
+ */
+class MWRestrictions {
+
+ private $ipAddresses = [ '0.0.0.0/0', '::/0' ];
+
+ /**
+ * @param array $restrictions
+ * @throws InvalidArgumentException
+ */
+ protected function __construct( array $restrictions = null ) {
+ if ( $restrictions !== null ) {
+ $this->loadFromArray( $restrictions );
+ }
+ }
+
+ /**
+ * @return MWRestrictions
+ */
+ public static function newDefault() {
+ return new self();
+ }
+
+ /**
+ * @param array $restrictions
+ * @return MWRestrictions
+ * @throws InvalidArgumentException
+ */
+ public static function newFromArray( array $restrictions ) {
+ return new self( $restrictions );
+ }
+
+ /**
+ * @param string $json JSON representation of the restrictions
+ * @return MWRestrictions
+ * @throws InvalidArgumentException
+ */
+ public static function newFromJson( $json ) {
+ $restrictions = FormatJson::decode( $json, true );
+ if ( !is_array( $restrictions ) ) {
+ throw new InvalidArgumentException( 'Invalid restrictions JSON' );
+ }
+ return new self( $restrictions );
+ }
+
+ private function loadFromArray( array $restrictions ) {
+ static $validKeys = [ 'IPAddresses' ];
+ static $neededKeys = [ 'IPAddresses' ];
+
+ $keys = array_keys( $restrictions );
+ $invalidKeys = array_diff( $keys, $validKeys );
+ if ( $invalidKeys ) {
+ throw new InvalidArgumentException(
+ 'Array contains invalid keys: ' . implode( ', ', $invalidKeys )
+ );
+ }
+ $missingKeys = array_diff( $neededKeys, $keys );
+ if ( $missingKeys ) {
+ throw new InvalidArgumentException(
+ 'Array is missing required keys: ' . implode( ', ', $missingKeys )
+ );
+ }
+
+ if ( !is_array( $restrictions['IPAddresses'] ) ) {
+ throw new InvalidArgumentException( 'IPAddresses is not an array' );
+ }
+ foreach ( $restrictions['IPAddresses'] as $ip ) {
+ if ( !\IP::isIPAddress( $ip ) ) {
+ throw new InvalidArgumentException( "Invalid IP address: $ip" );
+ }
+ }
+ $this->ipAddresses = $restrictions['IPAddresses'];
+ }
+
+ /**
+ * Return the restrictions as an array
+ * @return array
+ */
+ public function toArray() {
+ return [
+ 'IPAddresses' => $this->ipAddresses,
+ ];
+ }
+
+ /**
+ * Return the restrictions as a JSON string
+ * @param bool|string $pretty Pretty-print the JSON output, see FormatJson::encode
+ * @return string
+ */
+ public function toJson( $pretty = false ) {
+ return FormatJson::encode( $this->toArray(), $pretty, FormatJson::ALL_OK );
+ }
+
+ public function __toString() {
+ return $this->toJson();
+ }
+
+ /**
+ * Test against the passed WebRequest
+ * @param WebRequest $request
+ * @return Status
+ */
+ public function check( WebRequest $request ) {
+ $ok = [
+ 'ip' => $this->checkIP( $request->getIP() ),
+ ];
+ $status = Status::newGood();
+ $status->setResult( $ok === array_filter( $ok ), $ok );
+ return $status;
+ }
+
+ /**
+ * Test an IP address
+ * @param string $ip
+ * @return bool
+ */
+ public function checkIP( $ip ) {
+ foreach ( $this->ipAddresses as $range ) {
+ if ( \IP::isInRange( $ip, $range ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/www/wiki/includes/utils/README b/www/wiki/includes/utils/README
new file mode 100644
index 00000000..b5b8ec88
--- /dev/null
+++ b/www/wiki/includes/utils/README
@@ -0,0 +1,9 @@
+The classes in this directory are general utilities for use by any part of
+MediaWiki. They do not favour any particular user interface and are not
+constrained to serve any particular feature. This is similar to includes/libs,
+except that some dependency on the MediaWiki framework (such as the use of
+MWException, Status or wfDebug()) disqualifies them from use outside of
+MediaWiki without modification.
+
+Utilities should not use global configuration variables, rather they should rely
+on the caller to configure their behaviour.
diff --git a/www/wiki/includes/utils/RowUpdateGenerator.php b/www/wiki/includes/utils/RowUpdateGenerator.php
new file mode 100644
index 00000000..342dffd6
--- /dev/null
+++ b/www/wiki/includes/utils/RowUpdateGenerator.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Interface for generating updates to single rows in the database.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+interface RowUpdateGenerator {
+ /**
+ * Given a database row, generates an array mapping column names to
+ * updated value within the database row.
+ *
+ * Sample Response:
+ * return [
+ * 'some_col' => 'new value',
+ * 'other_col' => 99,
+ * ];
+ *
+ * @param stdClass $row A row from the database
+ * @return array Map of column names to updated value within the
+ * database row. When no update is required returns an empty array.
+ */
+ public function update( $row );
+}
diff --git a/www/wiki/includes/utils/UIDGenerator.php b/www/wiki/includes/utils/UIDGenerator.php
new file mode 100644
index 00000000..736109b4
--- /dev/null
+++ b/www/wiki/includes/utils/UIDGenerator.php
@@ -0,0 +1,629 @@
+<?php
+/**
+ * This file deals with UID generation.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+use Wikimedia\Assert\Assert;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Class for getting statistically unique IDs
+ *
+ * @since 1.21
+ */
+class UIDGenerator {
+ /** @var UIDGenerator */
+ protected static $instance = null;
+
+ protected $nodeIdFile; // string; local file path
+ protected $nodeId32; // string; node ID in binary (32 bits)
+ protected $nodeId48; // string; node ID in binary (48 bits)
+
+ protected $lockFile88; // string; local file path
+ protected $lockFile128; // string; local file path
+ protected $lockFileUUID; // string; local file path
+
+ /** @var array */
+ protected $fileHandles = []; // cache file handles
+
+ const QUICK_RAND = 1; // get randomness from fast and insecure sources
+ const QUICK_VOLATILE = 2; // use an APC like in-memory counter if available
+
+ protected function __construct() {
+ $this->nodeIdFile = wfTempDir() . '/mw-' . __CLASS__ . '-UID-nodeid';
+ $nodeId = '';
+ if ( is_file( $this->nodeIdFile ) ) {
+ $nodeId = file_get_contents( $this->nodeIdFile );
+ }
+ // Try to get some ID that uniquely identifies this machine (RFC 4122)...
+ if ( !preg_match( '/^[0-9a-f]{12}$/i', $nodeId ) ) {
+ MediaWiki\suppressWarnings();
+ if ( wfIsWindows() ) {
+ // https://technet.microsoft.com/en-us/library/bb490913.aspx
+ $csv = trim( wfShellExec( 'getmac /NH /FO CSV' ) );
+ $line = substr( $csv, 0, strcspn( $csv, "\n" ) );
+ $info = str_getcsv( $line );
+ $nodeId = isset( $info[0] ) ? str_replace( '-', '', $info[0] ) : '';
+ } elseif ( is_executable( '/sbin/ifconfig' ) ) { // Linux/BSD/Solaris/OS X
+ // See https://linux.die.net/man/8/ifconfig
+ $m = [];
+ preg_match( '/\s([0-9a-f]{2}(:[0-9a-f]{2}){5})\s/',
+ wfShellExec( '/sbin/ifconfig -a' ), $m );
+ $nodeId = isset( $m[1] ) ? str_replace( ':', '', $m[1] ) : '';
+ }
+ MediaWiki\restoreWarnings();
+ if ( !preg_match( '/^[0-9a-f]{12}$/i', $nodeId ) ) {
+ $nodeId = MWCryptRand::generateHex( 12, true );
+ $nodeId[1] = dechex( hexdec( $nodeId[1] ) | 0x1 ); // set multicast bit
+ }
+ file_put_contents( $this->nodeIdFile, $nodeId ); // cache
+ }
+ $this->nodeId32 = Wikimedia\base_convert( substr( sha1( $nodeId ), 0, 8 ), 16, 2, 32 );
+ $this->nodeId48 = Wikimedia\base_convert( $nodeId, 16, 2, 48 );
+ // If different processes run as different users, they may have different temp dirs.
+ // This is dealt with by initializing the clock sequence number and counters randomly.
+ $this->lockFile88 = wfTempDir() . '/mw-' . __CLASS__ . '-UID-88';
+ $this->lockFile128 = wfTempDir() . '/mw-' . __CLASS__ . '-UID-128';
+ $this->lockFileUUID = wfTempDir() . '/mw-' . __CLASS__ . '-UUID-128';
+ }
+
+ /**
+ * @todo: move to MW-specific factory class and inject temp dir
+ * @return UIDGenerator
+ */
+ protected static function singleton() {
+ if ( self::$instance === null ) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get a statistically unique 88-bit unsigned integer ID string.
+ * The bits of the UID are prefixed with the time (down to the millisecond).
+ *
+ * These IDs are suitable as values for the shard key of distributed data.
+ * If a column uses these as values, it should be declared UNIQUE to handle collisions.
+ * New rows almost always have higher UIDs, which makes B-TREE updates on INSERT fast.
+ * They can also be stored "DECIMAL(27) UNSIGNED" or BINARY(11) in MySQL.
+ *
+ * UID generation is serialized on each server (as the node ID is for the whole machine).
+ *
+ * @param int $base Specifies a base other than 10
+ * @return string Number
+ * @throws RuntimeException
+ */
+ public static function newTimestampedUID88( $base = 10 ) {
+ Assert::parameterType( 'integer', $base, '$base' );
+ Assert::parameter( $base <= 36, '$base', 'must be <= 36' );
+ Assert::parameter( $base >= 2, '$base', 'must be >= 2' );
+
+ $gen = self::singleton();
+ $info = $gen->getTimeAndDelay( 'lockFile88', 1, 1024, 1024 );
+ $info['offsetCounter'] = $info['offsetCounter'] % 1024;
+ return Wikimedia\base_convert( $gen->getTimestampedID88( $info ), 2, $base );
+ }
+
+ /**
+ * @param array $info The result of UIDGenerator::getTimeAndDelay() or
+ * a plain (UIDGenerator::millitime(), counter, clock sequence) array.
+ * @return string 88 bits
+ * @throws RuntimeException
+ */
+ protected function getTimestampedID88( array $info ) {
+ if ( isset( $info['time'] ) ) {
+ $time = $info['time'];
+ $counter = $info['offsetCounter'];
+ } else {
+ $time = $info[0];
+ $counter = $info[1];
+ }
+ // Take the 46 LSBs of "milliseconds since epoch"
+ $id_bin = $this->millisecondsSinceEpochBinary( $time );
+ // Add a 10 bit counter resulting in 56 bits total
+ $id_bin .= str_pad( decbin( $counter ), 10, '0', STR_PAD_LEFT );
+ // Add the 32 bit node ID resulting in 88 bits total
+ $id_bin .= $this->nodeId32;
+ // Convert to a 1-27 digit integer string
+ if ( strlen( $id_bin ) !== 88 ) {
+ throw new RuntimeException( "Detected overflow for millisecond timestamp." );
+ }
+
+ return $id_bin;
+ }
+
+ /**
+ * Get a statistically unique 128-bit unsigned integer ID string.
+ * The bits of the UID are prefixed with the time (down to the millisecond).
+ *
+ * These IDs are suitable as globally unique IDs, without any enforced uniqueness.
+ * New rows almost always have higher UIDs, which makes B-TREE updates on INSERT fast.
+ * They can also be stored as "DECIMAL(39) UNSIGNED" or BINARY(16) in MySQL.
+ *
+ * UID generation is serialized on each server (as the node ID is for the whole machine).
+ *
+ * @param int $base Specifies a base other than 10
+ * @return string Number
+ * @throws RuntimeException
+ */
+ public static function newTimestampedUID128( $base = 10 ) {
+ Assert::parameterType( 'integer', $base, '$base' );
+ Assert::parameter( $base <= 36, '$base', 'must be <= 36' );
+ Assert::parameter( $base >= 2, '$base', 'must be >= 2' );
+
+ $gen = self::singleton();
+ $info = $gen->getTimeAndDelay( 'lockFile128', 16384, 1048576, 1048576 );
+ $info['offsetCounter'] = $info['offsetCounter'] % 1048576;
+
+ return Wikimedia\base_convert( $gen->getTimestampedID128( $info ), 2, $base );
+ }
+
+ /**
+ * @param array $info The result of UIDGenerator::getTimeAndDelay() or
+ * a plain (UIDGenerator::millitime(), counter, clock sequence) array.
+ * @return string 128 bits
+ * @throws RuntimeException
+ */
+ protected function getTimestampedID128( array $info ) {
+ if ( isset( $info['time'] ) ) {
+ $time = $info['time'];
+ $counter = $info['offsetCounter'];
+ $clkSeq = $info['clkSeq'];
+ } else {
+ $time = $info[0];
+ $counter = $info[1];
+ $clkSeq = $info[2];
+ }
+ // Take the 46 LSBs of "milliseconds since epoch"
+ $id_bin = $this->millisecondsSinceEpochBinary( $time );
+ // Add a 20 bit counter resulting in 66 bits total
+ $id_bin .= str_pad( decbin( $counter ), 20, '0', STR_PAD_LEFT );
+ // Add a 14 bit clock sequence number resulting in 80 bits total
+ $id_bin .= str_pad( decbin( $clkSeq ), 14, '0', STR_PAD_LEFT );
+ // Add the 48 bit node ID resulting in 128 bits total
+ $id_bin .= $this->nodeId48;
+ // Convert to a 1-39 digit integer string
+ if ( strlen( $id_bin ) !== 128 ) {
+ throw new RuntimeException( "Detected overflow for millisecond timestamp." );
+ }
+
+ return $id_bin;
+ }
+
+ /**
+ * Return an RFC4122 compliant v1 UUID
+ *
+ * @return string
+ * @throws RuntimeException
+ * @since 1.27
+ */
+ public static function newUUIDv1() {
+ $gen = self::singleton();
+ // There can be up to 10000 intervals for the same millisecond timestamp.
+ // [0,4999] counter + [0,5000] offset is in [0,9999] for the offset counter.
+ // Add this onto the timestamp to allow making up to 5000 IDs per second.
+ return $gen->getUUIDv1( $gen->getTimeAndDelay( 'lockFileUUID', 16384, 5000, 5001 ) );
+ }
+
+ /**
+ * Return an RFC4122 compliant v1 UUID
+ *
+ * @return string 32 hex characters with no hyphens
+ * @throws RuntimeException
+ * @since 1.27
+ */
+ public static function newRawUUIDv1() {
+ return str_replace( '-', '', self::newUUIDv1() );
+ }
+
+ /**
+ * @param array $info Result of UIDGenerator::getTimeAndDelay()
+ * @return string 128 bits
+ */
+ protected function getUUIDv1( array $info ) {
+ $clkSeq_bin = Wikimedia\base_convert( $info['clkSeq'], 10, 2, 14 );
+ $time_bin = $this->intervalsSinceGregorianBinary( $info['time'], $info['offsetCounter'] );
+ // Take the 32 bits of "time low"
+ $id_bin = substr( $time_bin, 28, 32 );
+ // Add 16 bits of "time mid" resulting in 48 bits total
+ $id_bin .= substr( $time_bin, 12, 16 );
+ // Add 4 bit version resulting in 52 bits total
+ $id_bin .= '0001';
+ // Add 12 bits of "time high" resulting in 64 bits total
+ $id_bin .= substr( $time_bin, 0, 12 );
+ // Add 2 bits of "variant" resulting in 66 bits total
+ $id_bin .= '10';
+ // Add 6 bits of "clock seq high" resulting in 72 bits total
+ $id_bin .= substr( $clkSeq_bin, 0, 6 );
+ // Add 8 bits of "clock seq low" resulting in 80 bits total
+ $id_bin .= substr( $clkSeq_bin, 6, 8 );
+ // Add the 48 bit node ID resulting in 128 bits total
+ $id_bin .= $this->nodeId48;
+ // Convert to a 32 char hex string with dashes
+ if ( strlen( $id_bin ) !== 128 ) {
+ throw new RuntimeException( "Detected overflow for millisecond timestamp." );
+ }
+ $hex = Wikimedia\base_convert( $id_bin, 2, 16, 32 );
+ return sprintf( '%s-%s-%s-%s-%s',
+ // "time_low" (32 bits)
+ substr( $hex, 0, 8 ),
+ // "time_mid" (16 bits)
+ substr( $hex, 8, 4 ),
+ // "time_hi_and_version" (16 bits)
+ substr( $hex, 12, 4 ),
+ // "clk_seq_hi_res" (8 bits) and "clk_seq_low" (8 bits)
+ substr( $hex, 16, 4 ),
+ // "node" (48 bits)
+ substr( $hex, 20, 12 )
+ );
+ }
+
+ /**
+ * Return an RFC4122 compliant v4 UUID
+ *
+ * @param int $flags Bitfield (supports UIDGenerator::QUICK_RAND)
+ * @return string
+ * @throws RuntimeException
+ */
+ public static function newUUIDv4( $flags = 0 ) {
+ $hex = ( $flags & self::QUICK_RAND )
+ ? wfRandomString( 31 )
+ : MWCryptRand::generateHex( 31 );
+
+ return sprintf( '%s-%s-%s-%s-%s',
+ // "time_low" (32 bits)
+ substr( $hex, 0, 8 ),
+ // "time_mid" (16 bits)
+ substr( $hex, 8, 4 ),
+ // "time_hi_and_version" (16 bits)
+ '4' . substr( $hex, 12, 3 ),
+ // "clk_seq_hi_res" (8 bits, variant is binary 10x) and "clk_seq_low" (8 bits)
+ dechex( 0x8 | ( hexdec( $hex[15] ) & 0x3 ) ) . $hex[16] . substr( $hex, 17, 2 ),
+ // "node" (48 bits)
+ substr( $hex, 19, 12 )
+ );
+ }
+
+ /**
+ * Return an RFC4122 compliant v4 UUID
+ *
+ * @param int $flags Bitfield (supports UIDGenerator::QUICK_RAND)
+ * @return string 32 hex characters with no hyphens
+ * @throws RuntimeException
+ */
+ public static function newRawUUIDv4( $flags = 0 ) {
+ return str_replace( '-', '', self::newUUIDv4( $flags ) );
+ }
+
+ /**
+ * Return an ID that is sequential *only* for this node and bucket
+ *
+ * These IDs are suitable for per-host sequence numbers, e.g. for some packet protocols.
+ * If UIDGenerator::QUICK_VOLATILE is used the counter might reset on server restart.
+ *
+ * @param string $bucket Arbitrary bucket name (should be ASCII)
+ * @param int $bits Bit size (<=48) of resulting numbers before wrap-around
+ * @param int $flags (supports UIDGenerator::QUICK_VOLATILE)
+ * @return float Integer value as float
+ * @since 1.23
+ */
+ public static function newSequentialPerNodeID( $bucket, $bits = 48, $flags = 0 ) {
+ return current( self::newSequentialPerNodeIDs( $bucket, $bits, 1, $flags ) );
+ }
+
+ /**
+ * Return IDs that are sequential *only* for this node and bucket
+ *
+ * @see UIDGenerator::newSequentialPerNodeID()
+ * @param string $bucket Arbitrary bucket name (should be ASCII)
+ * @param int $bits Bit size (16 to 48) of resulting numbers before wrap-around
+ * @param int $count Number of IDs to return
+ * @param int $flags (supports UIDGenerator::QUICK_VOLATILE)
+ * @return array Ordered list of float integer values
+ * @since 1.23
+ */
+ public static function newSequentialPerNodeIDs( $bucket, $bits, $count, $flags = 0 ) {
+ $gen = self::singleton();
+ return $gen->getSequentialPerNodeIDs( $bucket, $bits, $count, $flags );
+ }
+
+ /**
+ * Return IDs that are sequential *only* for this node and bucket
+ *
+ * @see UIDGenerator::newSequentialPerNodeID()
+ * @param string $bucket Arbitrary bucket name (should be ASCII)
+ * @param int $bits Bit size (16 to 48) of resulting numbers before wrap-around
+ * @param int $count Number of IDs to return
+ * @param int $flags (supports UIDGenerator::QUICK_VOLATILE)
+ * @return array Ordered list of float integer values
+ * @throws RuntimeException
+ */
+ protected function getSequentialPerNodeIDs( $bucket, $bits, $count, $flags ) {
+ if ( $count <= 0 ) {
+ return []; // nothing to do
+ } elseif ( $bits < 16 || $bits > 48 ) {
+ throw new RuntimeException( "Requested bit size ($bits) is out of range." );
+ }
+
+ $counter = null; // post-increment persistent counter value
+
+ // Use APC/eAccelerator/xcache if requested, available, and not in CLI mode;
+ // Counter values would not survive accross script instances in CLI mode.
+ $cache = null;
+ if ( ( $flags & self::QUICK_VOLATILE ) && PHP_SAPI !== 'cli' ) {
+ $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
+ }
+ if ( $cache ) {
+ $counter = $cache->incrWithInit( $bucket, $cache::TTL_INDEFINITE, $count, $count );
+ if ( $counter === false ) {
+ throw new RuntimeException( 'Unable to set value to ' . get_class( $cache ) );
+ }
+ }
+
+ // Note: use of fmod() avoids "division by zero" on 32 bit machines
+ if ( $counter === null ) {
+ $path = wfTempDir() . '/mw-' . __CLASS__ . '-' . rawurlencode( $bucket ) . '-48';
+ // Get the UID lock file handle
+ if ( isset( $this->fileHandles[$path] ) ) {
+ $handle = $this->fileHandles[$path];
+ } else {
+ $handle = fopen( $path, 'cb+' );
+ $this->fileHandles[$path] = $handle ?: null; // cache
+ }
+ // Acquire the UID lock file
+ if ( $handle === false ) {
+ throw new RuntimeException( "Could not open '{$path}'." );
+ } elseif ( !flock( $handle, LOCK_EX ) ) {
+ fclose( $handle );
+ throw new RuntimeException( "Could not acquire '{$path}'." );
+ }
+ // Fetch the counter value and increment it...
+ rewind( $handle );
+ $counter = floor( trim( fgets( $handle ) ) ) + $count; // fetch as float
+ // Write back the new counter value
+ ftruncate( $handle, 0 );
+ rewind( $handle );
+ fwrite( $handle, fmod( $counter, pow( 2, 48 ) ) ); // warp-around as needed
+ fflush( $handle );
+ // Release the UID lock file
+ flock( $handle, LOCK_UN );
+ }
+
+ $ids = [];
+ $divisor = pow( 2, $bits );
+ $currentId = floor( $counter - $count ); // pre-increment counter value
+ for ( $i = 0; $i < $count; ++$i ) {
+ $ids[] = fmod( ++$currentId, $divisor );
+ }
+
+ return $ids;
+ }
+
+ /**
+ * Get a (time,counter,clock sequence) where (time,counter) is higher
+ * than any previous (time,counter) value for the given clock sequence.
+ * This is useful for making UIDs sequential on a per-node bases.
+ *
+ * @param string $lockFile Name of a local lock file
+ * @param int $clockSeqSize The number of possible clock sequence values
+ * @param int $counterSize The number of possible counter values
+ * @param int $offsetSize The number of possible offset values
+ * @return array (result of UIDGenerator::millitime(), counter, clock sequence)
+ * @throws RuntimeException
+ */
+ protected function getTimeAndDelay( $lockFile, $clockSeqSize, $counterSize, $offsetSize ) {
+ // Get the UID lock file handle
+ if ( isset( $this->fileHandles[$lockFile] ) ) {
+ $handle = $this->fileHandles[$lockFile];
+ } else {
+ $handle = fopen( $this->$lockFile, 'cb+' );
+ $this->fileHandles[$lockFile] = $handle ?: null; // cache
+ }
+ // Acquire the UID lock file
+ if ( $handle === false ) {
+ throw new RuntimeException( "Could not open '{$this->$lockFile}'." );
+ } elseif ( !flock( $handle, LOCK_EX ) ) {
+ fclose( $handle );
+ throw new RuntimeException( "Could not acquire '{$this->$lockFile}'." );
+ }
+ // Get the current timestamp, clock sequence number, last time, and counter
+ rewind( $handle );
+ $data = explode( ' ', fgets( $handle ) ); // "<clk seq> <sec> <msec> <counter> <offset>"
+ $clockChanged = false; // clock set back significantly?
+ if ( count( $data ) == 5 ) { // last UID info already initialized
+ $clkSeq = (int)$data[0] % $clockSeqSize;
+ $prevTime = [ (int)$data[1], (int)$data[2] ];
+ $offset = (int)$data[4] % $counterSize; // random counter offset
+ $counter = 0; // counter for UIDs with the same timestamp
+ // Delay until the clock reaches the time of the last ID.
+ // This detects any microtime() drift among processes.
+ $time = $this->timeWaitUntil( $prevTime );
+ if ( !$time ) { // too long to delay?
+ $clockChanged = true; // bump clock sequence number
+ $time = self::millitime();
+ } elseif ( $time == $prevTime ) {
+ // Bump the counter if there are timestamp collisions
+ $counter = (int)$data[3] % $counterSize;
+ if ( ++$counter >= $counterSize ) { // sanity (starts at 0)
+ flock( $handle, LOCK_UN ); // abort
+ throw new RuntimeException( "Counter overflow for timestamp value." );
+ }
+ }
+ } else { // last UID info not initialized
+ $clkSeq = mt_rand( 0, $clockSeqSize - 1 );
+ $counter = 0;
+ $offset = mt_rand( 0, $offsetSize - 1 );
+ $time = self::millitime();
+ }
+ // microtime() and gettimeofday() can drift from time() at least on Windows.
+ // The drift is immediate for processes running while the system clock changes.
+ // time() does not have this problem. See https://bugs.php.net/bug.php?id=42659.
+ if ( abs( time() - $time[0] ) >= 2 ) {
+ // We don't want processes using too high or low timestamps to avoid duplicate
+ // UIDs and clock sequence number churn. This process should just be restarted.
+ flock( $handle, LOCK_UN ); // abort
+ throw new RuntimeException( "Process clock is outdated or drifted." );
+ }
+ // If microtime() is synced and a clock change was detected, then the clock went back
+ if ( $clockChanged ) {
+ // Bump the clock sequence number and also randomize the counter offset,
+ // which is useful for UIDs that do not include the clock sequence number.
+ $clkSeq = ( $clkSeq + 1 ) % $clockSeqSize;
+ $offset = mt_rand( 0, $offsetSize - 1 );
+ trigger_error( "Clock was set back; sequence number incremented." );
+ }
+ // Update the (clock sequence number, timestamp, counter)
+ ftruncate( $handle, 0 );
+ rewind( $handle );
+ fwrite( $handle, "{$clkSeq} {$time[0]} {$time[1]} {$counter} {$offset}" );
+ fflush( $handle );
+ // Release the UID lock file
+ flock( $handle, LOCK_UN );
+
+ return [
+ 'time' => $time,
+ 'counter' => $counter,
+ 'clkSeq' => $clkSeq,
+ 'offset' => $offset,
+ 'offsetCounter' => $counter + $offset
+ ];
+ }
+
+ /**
+ * Wait till the current timestamp reaches $time and return the current
+ * timestamp. This returns false if it would have to wait more than 10ms.
+ *
+ * @param array $time Result of UIDGenerator::millitime()
+ * @return array|bool UIDGenerator::millitime() result or false
+ */
+ protected function timeWaitUntil( array $time ) {
+ do {
+ $ct = self::millitime();
+ if ( $ct >= $time ) { // https://secure.php.net/manual/en/language.operators.comparison.php
+ return $ct; // current timestamp is higher than $time
+ }
+ } while ( ( ( $time[0] - $ct[0] ) * 1000 + ( $time[1] - $ct[1] ) ) <= 10 );
+
+ return false;
+ }
+
+ /**
+ * @param array $time Result of UIDGenerator::millitime()
+ * @return string 46 LSBs of "milliseconds since epoch" in binary (rolls over in 4201)
+ * @throws RuntimeException
+ */
+ protected function millisecondsSinceEpochBinary( array $time ) {
+ list( $sec, $msec ) = $time;
+ $ts = 1000 * $sec + $msec;
+ if ( $ts > pow( 2, 52 ) ) {
+ throw new RuntimeException( __METHOD__ .
+ ': sorry, this function doesn\'t work after the year 144680' );
+ }
+
+ return substr( Wikimedia\base_convert( $ts, 10, 2, 46 ), -46 );
+ }
+
+ /**
+ * @param array $time Result of UIDGenerator::millitime()
+ * @param int $delta Number of intervals to add on to the timestamp
+ * @return string 60 bits of "100ns intervals since 15 October 1582" (rolls over in 3400)
+ * @throws RuntimeException
+ */
+ protected function intervalsSinceGregorianBinary( array $time, $delta = 0 ) {
+ list( $sec, $msec ) = $time;
+ $offset = '122192928000000000';
+ if ( PHP_INT_SIZE >= 8 ) { // 64 bit integers
+ $ts = ( 1000 * $sec + $msec ) * 10000 + (int)$offset + $delta;
+ $id_bin = str_pad( decbin( $ts % pow( 2, 60 ) ), 60, '0', STR_PAD_LEFT );
+ } elseif ( extension_loaded( 'gmp' ) ) {
+ $ts = gmp_add( gmp_mul( (string)$sec, '1000' ), (string)$msec ); // ms
+ $ts = gmp_add( gmp_mul( $ts, '10000' ), $offset ); // 100ns intervals
+ $ts = gmp_add( $ts, (string)$delta );
+ $ts = gmp_mod( $ts, gmp_pow( '2', '60' ) ); // wrap around
+ $id_bin = str_pad( gmp_strval( $ts, 2 ), 60, '0', STR_PAD_LEFT );
+ } elseif ( extension_loaded( 'bcmath' ) ) {
+ $ts = bcadd( bcmul( $sec, 1000 ), $msec ); // ms
+ $ts = bcadd( bcmul( $ts, 10000 ), $offset ); // 100ns intervals
+ $ts = bcadd( $ts, $delta );
+ $ts = bcmod( $ts, bcpow( 2, 60 ) ); // wrap around
+ $id_bin = Wikimedia\base_convert( $ts, 10, 2, 60 );
+ } else {
+ throw new RuntimeException( 'bcmath or gmp extension required for 32 bit machines.' );
+ }
+ return $id_bin;
+ }
+
+ /**
+ * @return array (current time in seconds, milliseconds since then)
+ */
+ protected static function millitime() {
+ list( $msec, $sec ) = explode( ' ', microtime() );
+
+ return [ (int)$sec, (int)( $msec * 1000 ) ];
+ }
+
+ /**
+ * Delete all cache files that have been created.
+ *
+ * This is a cleanup method primarily meant to be used from unit tests to
+ * avoid poluting the local filesystem. If used outside of a unit test
+ * environment it should be used with caution as it may destroy state saved
+ * in the files.
+ *
+ * @see unitTestTearDown
+ * @since 1.23
+ */
+ protected function deleteCacheFiles() {
+ // Bug: 44850
+ foreach ( $this->fileHandles as $path => $handle ) {
+ if ( $handle !== null ) {
+ fclose( $handle );
+ }
+ if ( is_file( $path ) ) {
+ unlink( $path );
+ }
+ unset( $this->fileHandles[$path] );
+ }
+ if ( is_file( $this->nodeIdFile ) ) {
+ unlink( $this->nodeIdFile );
+ }
+ }
+
+ /**
+ * Cleanup resources when tearing down after a unit test.
+ *
+ * This is a cleanup method primarily meant to be used from unit tests to
+ * avoid poluting the local filesystem. If used outside of a unit test
+ * environment it should be used with caution as it may destroy state saved
+ * in the files.
+ *
+ * @see deleteCacheFiles
+ * @since 1.23
+ */
+ public static function unitTestTearDown() {
+ // Bug: 44850
+ $gen = self::singleton();
+ $gen->deleteCacheFiles();
+ }
+
+ function __destruct() {
+ array_map( 'fclose', array_filter( $this->fileHandles ) );
+ }
+}
diff --git a/www/wiki/includes/utils/ZipDirectoryReader.php b/www/wiki/includes/utils/ZipDirectoryReader.php
new file mode 100644
index 00000000..f0ace2cc
--- /dev/null
+++ b/www/wiki/includes/utils/ZipDirectoryReader.php
@@ -0,0 +1,717 @@
+<?php
+/**
+ * ZIP file directories reader, for the purposes of upload verification.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A class for reading ZIP file directories, for the purposes of upload
+ * verification.
+ *
+ * Only a functional interface is provided: ZipFileReader::read(). No access is
+ * given to object instances.
+ */
+class ZipDirectoryReader {
+ /**
+ * Read a ZIP file and call a function for each file discovered in it.
+ *
+ * Because this class is aimed at verification, an error is raised on
+ * suspicious or ambiguous input, instead of emulating some standard
+ * behavior.
+ *
+ * @param string $fileName The archive file name
+ * @param array $callback The callback function. It will be called for each file
+ * with a single associative array each time, with members:
+ *
+ * - name: The file name. Directories conventionally have a trailing
+ * slash.
+ *
+ * - mtime: The file modification time, in MediaWiki 14-char format
+ *
+ * - size: The uncompressed file size
+ *
+ * @param array $options An associative array of read options, with the option
+ * name in the key. This may currently contain:
+ *
+ * - zip64: If this is set to true, then we will emulate a
+ * library with ZIP64 support, like OpenJDK 7. If it is set to
+ * false, then we will emulate a library with no knowledge of
+ * ZIP64.
+ *
+ * NOTE: The ZIP64 code is untested and probably doesn't work. It
+ * turned out to be easier to just reject ZIP64 archive uploads,
+ * since they are likely to be very rare. Confirming safety of a
+ * ZIP64 file is fairly complex. What do you do with a file that is
+ * ambiguous and broken when read with a non-ZIP64 reader, but valid
+ * when read with a ZIP64 reader? This situation is normal for a
+ * valid ZIP64 file, and working out what non-ZIP64 readers will make
+ * of such a file is not trivial.
+ *
+ * @return Status A Status object. The following fatal errors are defined:
+ *
+ * - zip-file-open-error: The file could not be opened.
+ *
+ * - zip-wrong-format: The file does not appear to be a ZIP file.
+ *
+ * - zip-bad: There was something wrong or ambiguous about the file
+ * data.
+ *
+ * - zip-unsupported: The ZIP file uses features which
+ * ZipDirectoryReader does not support.
+ *
+ * The default messages for those fatal errors are written in a way that
+ * makes sense for upload verification.
+ *
+ * If a fatal error is returned, more information about the error will be
+ * available in the debug log.
+ *
+ * Note that the callback function may be called any number of times before
+ * a fatal error is returned. If this occurs, the data sent to the callback
+ * function should be discarded.
+ */
+ public static function read( $fileName, $callback, $options = [] ) {
+ $zdr = new self( $fileName, $callback, $options );
+
+ return $zdr->execute();
+ }
+
+ /** The file name */
+ protected $fileName;
+
+ /** The opened file resource */
+ protected $file;
+
+ /** The cached length of the file, or null if it has not been loaded yet. */
+ protected $fileLength;
+
+ /** A segmented cache of the file contents */
+ protected $buffer;
+
+ /** The file data callback */
+ protected $callback;
+
+ /** The ZIP64 mode */
+ protected $zip64 = false;
+
+ /** Stored headers */
+ protected $eocdr, $eocdr64, $eocdr64Locator;
+
+ protected $data;
+
+ /** The "extra field" ID for ZIP64 central directory entries */
+ const ZIP64_EXTRA_HEADER = 0x0001;
+
+ /** The segment size for the file contents cache */
+ const SEGSIZE = 16384;
+
+ /** The index of the "general field" bit for UTF-8 file names */
+ const GENERAL_UTF8 = 11;
+
+ /** The index of the "general field" bit for central directory encryption */
+ const GENERAL_CD_ENCRYPTED = 13;
+
+ /**
+ * Private constructor
+ * @param string $fileName
+ * @param callable $callback
+ * @param array $options
+ */
+ protected function __construct( $fileName, $callback, $options ) {
+ $this->fileName = $fileName;
+ $this->callback = $callback;
+
+ if ( isset( $options['zip64'] ) ) {
+ $this->zip64 = $options['zip64'];
+ }
+ }
+
+ /**
+ * Read the directory according to settings in $this.
+ *
+ * @return Status
+ */
+ function execute() {
+ $this->file = fopen( $this->fileName, 'r' );
+ $this->data = [];
+ if ( !$this->file ) {
+ return Status::newFatal( 'zip-file-open-error' );
+ }
+
+ $status = Status::newGood();
+ try {
+ $this->readEndOfCentralDirectoryRecord();
+ if ( $this->zip64 ) {
+ list( $offset, $size ) = $this->findZip64CentralDirectory();
+ $this->readCentralDirectory( $offset, $size );
+ } else {
+ if ( $this->eocdr['CD size'] == 0xffffffff
+ || $this->eocdr['CD offset'] == 0xffffffff
+ || $this->eocdr['CD entries total'] == 0xffff
+ ) {
+ $this->error( 'zip-unsupported', 'Central directory header indicates ZIP64, ' .
+ 'but we are in legacy mode. Rejecting this upload is necessary to avoid ' .
+ 'opening vulnerabilities on clients using OpenJDK 7 or later.' );
+ }
+
+ list( $offset, $size ) = $this->findOldCentralDirectory();
+ $this->readCentralDirectory( $offset, $size );
+ }
+ } catch ( ZipDirectoryReaderError $e ) {
+ $status->fatal( $e->getErrorCode() );
+ }
+
+ fclose( $this->file );
+
+ return $status;
+ }
+
+ /**
+ * Throw an error, and log a debug message
+ * @param mixed $code
+ * @param string $debugMessage
+ * @throws ZipDirectoryReaderError
+ */
+ function error( $code, $debugMessage ) {
+ wfDebug( __CLASS__ . ": Fatal error: $debugMessage\n" );
+ throw new ZipDirectoryReaderError( $code );
+ }
+
+ /**
+ * Read the header which is at the end of the central directory,
+ * unimaginatively called the "end of central directory record" by the ZIP
+ * spec.
+ */
+ function readEndOfCentralDirectoryRecord() {
+ $info = [
+ 'signature' => 4,
+ 'disk' => 2,
+ 'CD start disk' => 2,
+ 'CD entries this disk' => 2,
+ 'CD entries total' => 2,
+ 'CD size' => 4,
+ 'CD offset' => 4,
+ 'file comment length' => 2,
+ ];
+ $structSize = $this->getStructSize( $info );
+ $startPos = $this->getFileLength() - 65536 - $structSize;
+ if ( $startPos < 0 ) {
+ $startPos = 0;
+ }
+
+ if ( $this->getFileLength() === 0 ) {
+ $this->error( 'zip-wrong-format', "The file is empty." );
+ }
+
+ $block = $this->getBlock( $startPos );
+ $sigPos = strrpos( $block, "PK\x05\x06" );
+ if ( $sigPos === false ) {
+ $this->error( 'zip-wrong-format',
+ "zip file lacks EOCDR signature. It probably isn't a zip file." );
+ }
+
+ $this->eocdr = $this->unpack( substr( $block, $sigPos ), $info );
+ $this->eocdr['EOCDR size'] = $structSize + $this->eocdr['file comment length'];
+
+ if ( $structSize + $this->eocdr['file comment length'] != strlen( $block ) - $sigPos ) {
+ $this->error( 'zip-bad', 'trailing bytes after the end of the file comment' );
+ }
+ if ( $this->eocdr['disk'] !== 0
+ || $this->eocdr['CD start disk'] !== 0
+ ) {
+ $this->error( 'zip-unsupported', 'more than one disk (in EOCDR)' );
+ }
+ $this->eocdr += $this->unpack(
+ $block,
+ [ 'file comment' => [ 'string', $this->eocdr['file comment length'] ] ],
+ $sigPos + $structSize );
+ $this->eocdr['position'] = $startPos + $sigPos;
+ }
+
+ /**
+ * Read the header called the "ZIP64 end of central directory locator". An
+ * error will be raised if it does not exist.
+ */
+ function readZip64EndOfCentralDirectoryLocator() {
+ $info = [
+ 'signature' => [ 'string', 4 ],
+ 'eocdr64 start disk' => 4,
+ 'eocdr64 offset' => 8,
+ 'number of disks' => 4,
+ ];
+ $structSize = $this->getStructSize( $info );
+
+ $start = $this->getFileLength() - $this->eocdr['EOCDR size'] - $structSize;
+ $block = $this->getBlock( $start, $structSize );
+ $this->eocdr64Locator = $data = $this->unpack( $block, $info );
+
+ if ( $data['signature'] !== "PK\x06\x07" ) {
+ // Note: Java will allow this and continue to read the
+ // EOCDR64, so we have to reject the upload, we can't
+ // just use the EOCDR header instead.
+ $this->error( 'zip-bad', 'wrong signature on Zip64 end of central directory locator' );
+ }
+ }
+
+ /**
+ * Read the header called the "ZIP64 end of central directory record". It
+ * may replace the regular "end of central directory record" in ZIP64 files.
+ */
+ function readZip64EndOfCentralDirectoryRecord() {
+ if ( $this->eocdr64Locator['eocdr64 start disk'] != 0
+ || $this->eocdr64Locator['number of disks'] != 0
+ ) {
+ $this->error( 'zip-unsupported', 'more than one disk (in EOCDR64 locator)' );
+ }
+
+ $info = [
+ 'signature' => [ 'string', 4 ],
+ 'EOCDR64 size' => 8,
+ 'version made by' => 2,
+ 'version needed' => 2,
+ 'disk' => 4,
+ 'CD start disk' => 4,
+ 'CD entries this disk' => 8,
+ 'CD entries total' => 8,
+ 'CD size' => 8,
+ 'CD offset' => 8
+ ];
+ $structSize = $this->getStructSize( $info );
+ $block = $this->getBlock( $this->eocdr64Locator['eocdr64 offset'], $structSize );
+ $this->eocdr64 = $data = $this->unpack( $block, $info );
+ if ( $data['signature'] !== "PK\x06\x06" ) {
+ $this->error( 'zip-bad', 'wrong signature on Zip64 end of central directory record' );
+ }
+ if ( $data['disk'] !== 0
+ || $data['CD start disk'] !== 0
+ ) {
+ $this->error( 'zip-unsupported', 'more than one disk (in EOCDR64)' );
+ }
+ }
+
+ /**
+ * Find the location of the central directory, as would be seen by a
+ * non-ZIP64 reader.
+ *
+ * @return array List containing offset, size and end position.
+ */
+ function findOldCentralDirectory() {
+ $size = $this->eocdr['CD size'];
+ $offset = $this->eocdr['CD offset'];
+ $endPos = $this->eocdr['position'];
+
+ // Some readers use the EOCDR position instead of the offset field
+ // to find the directory, so to be safe, we check if they both agree.
+ if ( $offset + $size != $endPos ) {
+ $this->error( 'zip-bad', 'the central directory does not immediately precede the end ' .
+ 'of central directory record' );
+ }
+
+ return [ $offset, $size ];
+ }
+
+ /**
+ * Find the location of the central directory, as would be seen by a
+ * ZIP64-compliant reader.
+ *
+ * @return array List containing offset, size and end position.
+ */
+ function findZip64CentralDirectory() {
+ // The spec is ambiguous about the exact rules of precedence between the
+ // ZIP64 headers and the original headers. Here we follow zip_util.c
+ // from OpenJDK 7.
+ $size = $this->eocdr['CD size'];
+ $offset = $this->eocdr['CD offset'];
+ $numEntries = $this->eocdr['CD entries total'];
+ $endPos = $this->eocdr['position'];
+ if ( $size == 0xffffffff
+ || $offset == 0xffffffff
+ || $numEntries == 0xffff
+ ) {
+ $this->readZip64EndOfCentralDirectoryLocator();
+
+ if ( isset( $this->eocdr64Locator['eocdr64 offset'] ) ) {
+ $this->readZip64EndOfCentralDirectoryRecord();
+ if ( isset( $this->eocdr64['CD offset'] ) ) {
+ $size = $this->eocdr64['CD size'];
+ $offset = $this->eocdr64['CD offset'];
+ $endPos = $this->eocdr64Locator['eocdr64 offset'];
+ }
+ }
+ }
+ // Some readers use the EOCDR position instead of the offset field
+ // to find the directory, so to be safe, we check if they both agree.
+ if ( $offset + $size != $endPos ) {
+ $this->error( 'zip-bad', 'the central directory does not immediately precede the end ' .
+ 'of central directory record' );
+ }
+
+ return [ $offset, $size ];
+ }
+
+ /**
+ * Read the central directory at the given location
+ * @param int $offset
+ * @param int $size
+ */
+ function readCentralDirectory( $offset, $size ) {
+ $block = $this->getBlock( $offset, $size );
+
+ $fixedInfo = [
+ 'signature' => [ 'string', 4 ],
+ 'version made by' => 2,
+ 'version needed' => 2,
+ 'general bits' => 2,
+ 'compression method' => 2,
+ 'mod time' => 2,
+ 'mod date' => 2,
+ 'crc-32' => 4,
+ 'compressed size' => 4,
+ 'uncompressed size' => 4,
+ 'name length' => 2,
+ 'extra field length' => 2,
+ 'comment length' => 2,
+ 'disk number start' => 2,
+ 'internal attrs' => 2,
+ 'external attrs' => 4,
+ 'local header offset' => 4,
+ ];
+ $fixedSize = $this->getStructSize( $fixedInfo );
+
+ $pos = 0;
+ while ( $pos < $size ) {
+ $data = $this->unpack( $block, $fixedInfo, $pos );
+ $pos += $fixedSize;
+
+ if ( $data['signature'] !== "PK\x01\x02" ) {
+ $this->error( 'zip-bad', 'Invalid signature found in directory entry' );
+ }
+
+ $variableInfo = [
+ 'name' => [ 'string', $data['name length'] ],
+ 'extra field' => [ 'string', $data['extra field length'] ],
+ 'comment' => [ 'string', $data['comment length'] ],
+ ];
+ $data += $this->unpack( $block, $variableInfo, $pos );
+ $pos += $this->getStructSize( $variableInfo );
+
+ if ( $this->zip64 && (
+ $data['compressed size'] == 0xffffffff
+ || $data['uncompressed size'] == 0xffffffff
+ || $data['local header offset'] == 0xffffffff )
+ ) {
+ $zip64Data = $this->unpackZip64Extra( $data['extra field'] );
+ if ( $zip64Data ) {
+ $data = $zip64Data + $data;
+ }
+ }
+
+ if ( $this->testBit( $data['general bits'], self::GENERAL_CD_ENCRYPTED ) ) {
+ $this->error( 'zip-unsupported', 'central directory encryption is not supported' );
+ }
+
+ // Convert the timestamp into MediaWiki format
+ // For the format, please see the MS-DOS 2.0 Programmer's Reference,
+ // pages 3-5 and 3-6.
+ $time = $data['mod time'];
+ $date = $data['mod date'];
+
+ $year = 1980 + ( $date >> 9 );
+ $month = ( $date >> 5 ) & 15;
+ $day = $date & 31;
+ $hour = ( $time >> 11 ) & 31;
+ $minute = ( $time >> 5 ) & 63;
+ $second = ( $time & 31 ) * 2;
+ $timestamp = sprintf( "%04d%02d%02d%02d%02d%02d",
+ $year, $month, $day, $hour, $minute, $second );
+
+ // Convert the character set in the file name
+ if ( $this->testBit( $data['general bits'], self::GENERAL_UTF8 ) ) {
+ $name = $data['name'];
+ } else {
+ $name = iconv( 'CP437', 'UTF-8', $data['name'] );
+ }
+
+ // Compile a data array for the user, with a sensible format
+ $userData = [
+ 'name' => $name,
+ 'mtime' => $timestamp,
+ 'size' => $data['uncompressed size'],
+ ];
+ call_user_func( $this->callback, $userData );
+ }
+ }
+
+ /**
+ * Interpret ZIP64 "extra field" data and return an associative array.
+ * @param string $extraField
+ * @return array|bool
+ */
+ function unpackZip64Extra( $extraField ) {
+ $extraHeaderInfo = [
+ 'id' => 2,
+ 'size' => 2,
+ ];
+ $extraHeaderSize = $this->getStructSize( $extraHeaderInfo );
+
+ $zip64ExtraInfo = [
+ 'uncompressed size' => 8,
+ 'compressed size' => 8,
+ 'local header offset' => 8,
+ 'disk number start' => 4,
+ ];
+
+ $extraPos = 0;
+ while ( $extraPos < strlen( $extraField ) ) {
+ $extra = $this->unpack( $extraField, $extraHeaderInfo, $extraPos );
+ $extraPos += $extraHeaderSize;
+ $extra += $this->unpack( $extraField,
+ [ 'data' => [ 'string', $extra['size'] ] ],
+ $extraPos );
+ $extraPos += $extra['size'];
+
+ if ( $extra['id'] == self::ZIP64_EXTRA_HEADER ) {
+ return $this->unpack( $extra['data'], $zip64ExtraInfo );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the length of the file.
+ * @return int
+ */
+ function getFileLength() {
+ if ( $this->fileLength === null ) {
+ $stat = fstat( $this->file );
+ $this->fileLength = $stat['size'];
+ }
+
+ return $this->fileLength;
+ }
+
+ /**
+ * Get the file contents from a given offset. If there are not enough bytes
+ * in the file to satisfy the request, an exception will be thrown.
+ *
+ * @param int $start The byte offset of the start of the block.
+ * @param int $length The number of bytes to return. If omitted, the remainder
+ * of the file will be returned.
+ *
+ * @return string
+ */
+ function getBlock( $start, $length = null ) {
+ $fileLength = $this->getFileLength();
+ if ( $start >= $fileLength ) {
+ $this->error( 'zip-bad', "getBlock() requested position $start, " .
+ "file length is $fileLength" );
+ }
+ if ( $length === null ) {
+ $length = $fileLength - $start;
+ }
+ $end = $start + $length;
+ if ( $end > $fileLength ) {
+ $this->error( 'zip-bad', "getBlock() requested end position $end, " .
+ "file length is $fileLength" );
+ }
+ $startSeg = floor( $start / self::SEGSIZE );
+ $endSeg = ceil( $end / self::SEGSIZE );
+
+ $block = '';
+ for ( $segIndex = $startSeg; $segIndex <= $endSeg; $segIndex++ ) {
+ $block .= $this->getSegment( $segIndex );
+ }
+
+ $block = substr( $block,
+ $start - $startSeg * self::SEGSIZE,
+ $length );
+
+ if ( strlen( $block ) < $length ) {
+ $this->error( 'zip-bad', 'getBlock() returned an unexpectedly small amount of data' );
+ }
+
+ return $block;
+ }
+
+ /**
+ * Get a section of the file starting at position $segIndex * self::SEGSIZE,
+ * of length self::SEGSIZE. The result is cached. This is a helper function
+ * for getBlock().
+ *
+ * If there are not enough bytes in the file to satisfy the request, the
+ * return value will be truncated. If a request is made for a segment beyond
+ * the end of the file, an empty string will be returned.
+ *
+ * @param int $segIndex
+ *
+ * @return string
+ */
+ function getSegment( $segIndex ) {
+ if ( !isset( $this->buffer[$segIndex] ) ) {
+ $bytePos = $segIndex * self::SEGSIZE;
+ if ( $bytePos >= $this->getFileLength() ) {
+ $this->buffer[$segIndex] = '';
+
+ return '';
+ }
+ if ( fseek( $this->file, $bytePos ) ) {
+ $this->error( 'zip-bad', "seek to $bytePos failed" );
+ }
+ $seg = fread( $this->file, self::SEGSIZE );
+ if ( $seg === false ) {
+ $this->error( 'zip-bad', "read from $bytePos failed" );
+ }
+ $this->buffer[$segIndex] = $seg;
+ }
+
+ return $this->buffer[$segIndex];
+ }
+
+ /**
+ * Get the size of a structure in bytes. See unpack() for the format of $struct.
+ * @param array $struct
+ * @return int
+ */
+ function getStructSize( $struct ) {
+ $size = 0;
+ foreach ( $struct as $type ) {
+ if ( is_array( $type ) ) {
+ list( , $fieldSize ) = $type;
+ $size += $fieldSize;
+ } else {
+ $size += $type;
+ }
+ }
+
+ return $size;
+ }
+
+ /**
+ * Unpack a binary structure. This is like the built-in unpack() function
+ * except nicer.
+ *
+ * @param string $string The binary data input
+ *
+ * @param array $struct An associative array giving structure members and their
+ * types. In the key is the field name. The value may be either an
+ * integer, in which case the field is a little-endian unsigned integer
+ * encoded in the given number of bytes, or an array, in which case the
+ * first element of the array is the type name, and the subsequent
+ * elements are type-dependent parameters. Only one such type is defined:
+ * - "string": The second array element gives the length of string.
+ * Not null terminated.
+ *
+ * @param int $offset The offset into the string at which to start unpacking.
+ *
+ * @throws MWException
+ * @return array Unpacked associative array. Note that large integers in the input
+ * may be represented as floating point numbers in the return value, so
+ * the use of weak comparison is advised.
+ */
+ function unpack( $string, $struct, $offset = 0 ) {
+ $size = $this->getStructSize( $struct );
+ if ( $offset + $size > strlen( $string ) ) {
+ $this->error( 'zip-bad', 'unpack() would run past the end of the supplied string' );
+ }
+
+ $data = [];
+ $pos = $offset;
+ foreach ( $struct as $key => $type ) {
+ if ( is_array( $type ) ) {
+ list( $typeName, $fieldSize ) = $type;
+ switch ( $typeName ) {
+ case 'string':
+ $data[$key] = substr( $string, $pos, $fieldSize );
+ $pos += $fieldSize;
+ break;
+ default:
+ throw new MWException( __METHOD__ . ": invalid type \"$typeName\"" );
+ }
+ } else {
+ // Unsigned little-endian integer
+ $length = intval( $type );
+
+ // Calculate the value. Use an algorithm which automatically
+ // upgrades the value to floating point if necessary.
+ $value = 0;
+ for ( $i = $length - 1; $i >= 0; $i-- ) {
+ $value *= 256;
+ $value += ord( $string[$pos + $i] );
+ }
+
+ // Throw an exception if there was loss of precision
+ if ( $value > pow( 2, 52 ) ) {
+ $this->error( 'zip-unsupported', 'number too large to be stored in a double. ' .
+ 'This could happen if we tried to unpack a 64-bit structure ' .
+ 'at an invalid location.' );
+ }
+ $data[$key] = $value;
+ $pos += $length;
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Returns a bit from a given position in an integer value, converted to
+ * boolean.
+ *
+ * @param int $value
+ * @param int $bitIndex The index of the bit, where 0 is the LSB.
+ * @return bool
+ */
+ function testBit( $value, $bitIndex ) {
+ return (bool)( ( $value >> $bitIndex ) & 1 );
+ }
+
+ /**
+ * Debugging helper function which dumps a string in hexdump -C format.
+ * @param string $s
+ */
+ function hexDump( $s ) {
+ $n = strlen( $s );
+ for ( $i = 0; $i < $n; $i += 16 ) {
+ printf( "%08X ", $i );
+ for ( $j = 0; $j < 16; $j++ ) {
+ print " ";
+ if ( $j == 8 ) {
+ print " ";
+ }
+ if ( $i + $j >= $n ) {
+ print " ";
+ } else {
+ printf( "%02X", ord( $s[$i + $j] ) );
+ }
+ }
+
+ print " |";
+ for ( $j = 0; $j < 16; $j++ ) {
+ if ( $i + $j >= $n ) {
+ print " ";
+ } elseif ( ctype_print( $s[$i + $j] ) ) {
+ print $s[$i + $j];
+ } else {
+ print '.';
+ }
+ }
+ print "|\n";
+ }
+ }
+}
diff --git a/www/wiki/includes/utils/ZipDirectoryReaderError.php b/www/wiki/includes/utils/ZipDirectoryReaderError.php
new file mode 100644
index 00000000..592036e3
--- /dev/null
+++ b/www/wiki/includes/utils/ZipDirectoryReaderError.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Internal exception class. Will be caught by private code.
+ */
+class ZipDirectoryReaderError extends Exception {
+ protected $errorCode;
+
+ function __construct( $code ) {
+ $this->errorCode = $code;
+ parent::__construct( "ZipDirectoryReader error: $code" );
+ }
+
+ /**
+ * @return mixed
+ */
+ function getErrorCode() {
+ return $this->errorCode;
+ }
+}
diff --git a/www/wiki/includes/utils/iterators/IteratorDecorator.php b/www/wiki/includes/utils/iterators/IteratorDecorator.php
new file mode 100644
index 00000000..c1b50207
--- /dev/null
+++ b/www/wiki/includes/utils/iterators/IteratorDecorator.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * Allows extending classes to decorate an Iterator with
+ * reduced boilerplate.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+abstract class IteratorDecorator implements Iterator {
+ protected $iterator;
+
+ public function __construct( Iterator $iterator ) {
+ $this->iterator = $iterator;
+ }
+
+ public function current() {
+ return $this->iterator->current();
+ }
+
+ public function key() {
+ return $this->iterator->key();
+ }
+
+ public function next() {
+ $this->iterator->next();
+ }
+
+ public function rewind() {
+ $this->iterator->rewind();
+ }
+
+ public function valid() {
+ return $this->iterator->valid();
+ }
+}
diff --git a/www/wiki/includes/utils/iterators/NotRecursiveIterator.php b/www/wiki/includes/utils/iterators/NotRecursiveIterator.php
new file mode 100644
index 00000000..52ca61b4
--- /dev/null
+++ b/www/wiki/includes/utils/iterators/NotRecursiveIterator.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Wraps a non-recursive iterator with methods to be recursive
+ * without children.
+ *
+ * Alternatively wraps a recursive iterator to prevent recursing deeper
+ * than the wrapped iterator.
+ *
+ * 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.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+class NotRecursiveIterator extends IteratorDecorator implements RecursiveIterator {
+ public function hasChildren() {
+ return false;
+ }
+
+ public function getChildren() {
+ return null;
+ }
+}
diff --git a/www/wiki/includes/widget/AUTHORS.txt b/www/wiki/includes/widget/AUTHORS.txt
new file mode 100644
index 00000000..2490b9d8
--- /dev/null
+++ b/www/wiki/includes/widget/AUTHORS.txt
@@ -0,0 +1,13 @@
+Authors (alphabetically)
+
+Alex Monk <krenair@wikimedia.org>
+Bartosz Dziewoński <bdziewonski@wikimedia.org>
+Brad Jorsch <bjorsch@wikimedia.org>
+Ed Sanders <esanders@wikimedia.org>
+Florian Schmidt <florian.schmidt.welzow@t-online.de>
+Geoffrey Mon <geofbot@gmail.com>
+James D. Forrester <jforrester@wikimedia.org>
+Roan Kattouw <roan@wikimedia.org>
+Sucheta Ghoshal <sghoshal@wikimedia.org>
+Timo Tijhof <krinklemail@gmail.com>
+Trevor Parscal <trevor@wikimedia.org>
diff --git a/www/wiki/includes/widget/ComplexNamespaceInputWidget.php b/www/wiki/includes/widget/ComplexNamespaceInputWidget.php
new file mode 100644
index 00000000..d3ada03b
--- /dev/null
+++ b/www/wiki/includes/widget/ComplexNamespaceInputWidget.php
@@ -0,0 +1,119 @@
+<?php
+/**
+ * MediaWiki Widgets – ComplexNamespaceInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+/**
+ * Namespace input widget. Displays a dropdown box with the choice of available namespaces, plus two
+ * checkboxes to include associated namespace or to invert selection.
+ */
+class ComplexNamespaceInputWidget extends \OOUI\Widget {
+
+ protected $config;
+ protected $namespace;
+ protected $associated = null;
+ protected $associatedLabel = null;
+ protected $invert = null;
+ protected $invertLabel = null;
+
+ /**
+ * @param array $config Configuration options
+ * @param array $config['namespace'] Configuration for the NamespaceInputWidget
+ * dropdown with list of namespaces
+ * @param string $config['namespace']['includeAllValue'] If specified,
+ * add an "all namespaces" option to the dropdown, and use this as the input value for it
+ * @param array|null $config['invert'] Configuration for the "invert selection"
+ * CheckboxInputWidget. If null, the checkbox will not be generated.
+ * @param array|null $config['associated'] Configuration for the "include associated namespace"
+ * CheckboxInputWidget. If null, the checkbox will not be generated.
+ * @param array $config['invertLabel'] Configuration for the FieldLayout with label
+ * wrapping the "invert selection" checkbox
+ * @param string $config['invertLabel']['label'] Label text for the label
+ * @param array $config['associatedLabel'] Configuration for the FieldLayout with label
+ * wrapping the "include associated namespace" checkbox
+ * @param string $config['associatedLabel']['label'] Label text for the label
+ */
+ public function __construct( array $config = [] ) {
+ // Configuration initialization
+ $config = array_merge(
+ [
+ // Config options for nested widgets
+ 'namespace' => [],
+ 'invert' => [],
+ 'invertLabel' => [],
+ 'associated' => [],
+ 'associatedLabel' => [],
+ ],
+ $config
+ );
+
+ // Parent constructor
+ parent::__construct( $config );
+
+ // Properties
+ $this->config = $config;
+
+ $this->namespace = new NamespaceInputWidget( $config['namespace'] );
+ if ( $config['associated'] !== null ) {
+ $this->associated = new \OOUI\CheckboxInputWidget( array_merge(
+ [ 'value' => '1' ],
+ $config['associated']
+ ) );
+ // TODO Should use a LabelWidget? But they don't work like HTML <label>s yet
+ $this->associatedLabel = new \OOUI\FieldLayout(
+ $this->associated,
+ array_merge(
+ [ 'align' => 'inline' ],
+ $config['associatedLabel']
+ )
+ );
+ }
+ if ( $config['invert'] !== null ) {
+ $this->invert = new \OOUI\CheckboxInputWidget( array_merge(
+ [ 'value' => '1' ],
+ $config['invert']
+ ) );
+ // TODO Should use a LabelWidget? But they don't work like HTML <label>s yet
+ $this->invertLabel = new \OOUI\FieldLayout(
+ $this->invert,
+ array_merge(
+ [ 'align' => 'inline' ],
+ $config['invertLabel']
+ )
+ );
+ }
+
+ // Initialization
+ $this
+ ->addClasses( [ 'mw-widget-complexNamespaceInputWidget' ] )
+ ->appendContent( $this->namespace, $this->associatedLabel, $this->invertLabel );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.ComplexNamespaceInputWidget';
+ }
+
+ public function getConfig( &$config ) {
+ $config = array_merge(
+ $config,
+ array_intersect_key(
+ $this->config,
+ array_fill_keys(
+ [
+ 'namespace',
+ 'invert',
+ 'invertLabel',
+ 'associated',
+ 'associatedLabel'
+ ],
+ true
+ )
+ )
+ );
+ return parent::getConfig( $config );
+ }
+}
diff --git a/www/wiki/includes/widget/ComplexTitleInputWidget.php b/www/wiki/includes/widget/ComplexTitleInputWidget.php
new file mode 100644
index 00000000..a9e80425
--- /dev/null
+++ b/www/wiki/includes/widget/ComplexTitleInputWidget.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * MediaWiki Widgets – ComplexTitleInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+/**
+ * Complex title input widget.
+ */
+class ComplexTitleInputWidget extends \OOUI\Widget {
+
+ protected $namespace = null;
+ protected $title = null;
+
+ /**
+ * Like TitleInputWidget, but the namespace has to be input through a separate dropdown field.
+ *
+ * @param array $config Configuration options
+ * @param array $config['namespace'] Configuration for the NamespaceInputWidget dropdown
+ * with list of namespaces
+ * @param array $config['title'] Configuration for the TitleInputWidget text field
+ */
+ public function __construct( array $config = [] ) {
+ // Configuration initialization
+ $config = array_merge(
+ [
+ 'namespace' => [],
+ 'title' => [],
+ ],
+ $config
+ );
+
+ // Parent constructor
+ parent::__construct( $config );
+
+ // Properties
+ $this->config = $config;
+ $this->namespace = new NamespaceInputWidget( $config['namespace'] );
+ $this->title = new TitleInputWidget( array_merge(
+ $config['title'],
+ [
+ 'relative' => true,
+ 'namespace' => isset( $config['namespace']['value'] ) ?
+ $config['namespace']['value'] :
+ null,
+ ]
+ ) );
+
+ // Initialization
+ $this
+ ->addClasses( [ 'mw-widget-complexTitleInputWidget' ] )
+ ->appendContent( $this->namespace, $this->title );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.ComplexTitleInputWidget';
+ }
+
+ public function getConfig( &$config ) {
+ $config['namespace'] = $this->config['namespace'];
+ $config['title'] = $this->config['title'];
+ return parent::getConfig( $config );
+ }
+}
diff --git a/www/wiki/includes/widget/DateInputWidget.php b/www/wiki/includes/widget/DateInputWidget.php
new file mode 100644
index 00000000..507dab6f
--- /dev/null
+++ b/www/wiki/includes/widget/DateInputWidget.php
@@ -0,0 +1,176 @@
+<?php
+/**
+ * MediaWiki Widgets – DateInputWidget class.
+ *
+ * @copyright 2016 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+namespace MediaWiki\Widget;
+
+use DateTime;
+
+/**
+ * Date input widget.
+ *
+ * @since 1.29
+ */
+class DateInputWidget extends \OOUI\TextInputWidget {
+
+ protected $inputFormat = null;
+ protected $displayFormat = null;
+ protected $longDisplayFormat = null;
+ protected $placeholderLabel = null;
+ protected $placeholderDateFormat = null;
+ protected $precision = null;
+ protected $mustBeAfter = null;
+ protected $mustBeBefore = null;
+ protected $overlay = null;
+
+ /**
+ * @param array $config Configuration options
+ * @param string $config['inputFormat'] Date format string to use for the textual input field.
+ * Displayed while the widget is active, and the user can type in a date in this format.
+ * Should be short and easy to type. (default: 'YYYY-MM-DD' or 'YYYY-MM', depending on
+ * `precision`)
+ * @param string $config['displayFormat'] Date format string to use for the clickable label.
+ * while the widget is inactive. Should be as unambiguous as possible (for example, prefer
+ * to spell out the month, rather than rely on the order), even if that makes it longer.
+ * Applicable only if the widget is infused. (default: language-specific)
+ * @param string $config['longDisplayFormat'] If a custom displayFormat is not specified, use
+ * unabbreviated day of the week and month names in the default language-specific
+ * displayFormat. (default: false)
+ * @param string $config['placeholderLabel'] Placeholder text shown when the widget is not
+ * selected. Applicable only if the widget is infused. (default: taken from message
+ * `mw-widgets-dateinput-no-date`)
+ * @param string $config['placeholderDateFormat'] User-visible date format string displayed
+ * in the textual input field when it's empty. Should be the same as `inputFormat`, but
+ * translated to the user's language. (default: 'YYYY-MM-DD' or 'YYYY-MM', depending on
+ * `precision`)
+ * @param string $config['precision'] Date precision to use, 'day' or 'month' (default: 'day')
+ * @param string $config['mustBeAfter'] Validates the date to be after this.
+ * In the 'YYYY-MM-DD' or 'YYYY-MM' format, depending on `precision`.
+ * @param string $config['mustBeBefore'] Validates the date to be before this.
+ * In the 'YYYY-MM-DD' or 'YYYY-MM' format, depending on `precision`.
+ * @param string $config['overlay'] The jQuery selector for the overlay layer on which to render
+ * the calendar. This configuration is useful in cases where the expanded calendar is larger
+ * than its container. The specified overlay layer is usually on top of the container and has
+ * a larger area. Applicable only if the widget is infused. By default, the calendar uses
+ * relative positioning.
+ */
+ public function __construct( array $config = [] ) {
+ $config = array_merge( [
+ // Default config values
+ 'precision' => 'day',
+ 'longDisplayFormat' => false,
+ ], $config );
+
+ // Properties
+ if ( isset( $config['inputFormat'] ) ) {
+ $this->inputFormat = $config['inputFormat'];
+ }
+ if ( isset( $config['placeholderDateFormat'] ) ) {
+ $this->placeholderDateFormat = $config['placeholderDateFormat'];
+ }
+ $this->precision = $config['precision'];
+ if ( isset( $config['mustBeAfter'] ) ) {
+ $this->mustBeAfter = $config['mustBeAfter'];
+ }
+ if ( isset( $config['mustBeBefore'] ) ) {
+ $this->mustBeBefore = $config['mustBeBefore'];
+ }
+
+ // Properties stored for the infused JS widget
+ if ( isset( $config['displayFormat'] ) ) {
+ $this->displayFormat = $config['displayFormat'];
+ }
+ if ( isset( $config['longDisplayFormat'] ) ) {
+ $this->longDisplayFormat = $config['longDisplayFormat'];
+ }
+ if ( isset( $config['placeholderLabel'] ) ) {
+ $this->placeholderLabel = $config['placeholderLabel'];
+ }
+ if ( isset( $config['overlay'] ) ) {
+ $this->overlay = $config['overlay'];
+ }
+
+ // Set up placeholder text visible if the browser doesn't override it (logic taken from JS)
+ if ( $this->placeholderDateFormat !== null ) {
+ $placeholder = $this->placeholderDateFormat;
+ } elseif ( $this->inputFormat !== null ) {
+ // We have no way to display a translated placeholder for custom formats
+ $placeholder = '';
+ } else {
+ $placeholder = wfMessage( "mw-widgets-dateinput-placeholder-$this->precision" )->text();
+ }
+
+ $config = array_merge( [
+ // Processed config values
+ 'placeholder' => $placeholder,
+ ], $config );
+
+ // Parent constructor
+ parent::__construct( $config );
+
+ // Calculate min/max attributes (which are skipped by TextInputWidget) and add to <input>
+ // min/max attributes are inclusive, but mustBeAfter/Before are exclusive
+ if ( $this->mustBeAfter !== null ) {
+ $min = new DateTime( $this->mustBeAfter );
+ $min = $min->modify( '+1 day' );
+ $min = $min->format( 'Y-m-d' );
+ $this->input->setAttributes( [ 'min' => $min ] );
+ }
+ if ( $this->mustBeBefore !== null ) {
+ $max = new DateTime( $this->mustBeBefore );
+ $max = $max->modify( '-1 day' );
+ $max = $max->format( 'Y-m-d' );
+ $this->input->setAttributes( [ 'max' => $max ] );
+ }
+
+ // Initialization
+ $this->addClasses( [ 'mw-widget-dateInputWidget' ] );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.DateInputWidget';
+ }
+
+ public function getConfig( &$config ) {
+ if ( $this->inputFormat !== null ) {
+ $config['inputFormat'] = $this->inputFormat;
+ }
+ if ( $this->displayFormat !== null ) {
+ $config['displayFormat'] = $this->displayFormat;
+ }
+ if ( $this->longDisplayFormat !== null ) {
+ $config['longDisplayFormat'] = $this->longDisplayFormat;
+ }
+ if ( $this->placeholderLabel !== null ) {
+ $config['placeholderLabel'] = $this->placeholderLabel;
+ }
+ if ( $this->placeholderDateFormat !== null ) {
+ $config['placeholderDateFormat'] = $this->placeholderDateFormat;
+ }
+ if ( $this->precision !== null ) {
+ $config['precision'] = $this->precision;
+ }
+ if ( $this->mustBeAfter !== null ) {
+ $config['mustBeAfter'] = $this->mustBeAfter;
+ }
+ if ( $this->mustBeBefore !== null ) {
+ $config['mustBeBefore'] = $this->mustBeBefore;
+ }
+ if ( $this->overlay !== null ) {
+ $config['overlay'] = $this->overlay;
+ }
+ return parent::getConfig( $config );
+ }
+
+ public function getInputElement( $config ) {
+ // Inserts date/month type attribute
+ return parent::getInputElement( $config )
+ ->setAttributes( [
+ 'type' => ( $config['precision'] === 'month' ) ? 'month' : 'date'
+ ] );
+ }
+}
diff --git a/www/wiki/includes/widget/DateTimeInputWidget.php b/www/wiki/includes/widget/DateTimeInputWidget.php
new file mode 100644
index 00000000..f0d5cdb6
--- /dev/null
+++ b/www/wiki/includes/widget/DateTimeInputWidget.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * MediaWiki Widgets – DateTimeInputWidget class.
+ *
+ * @copyright 2016 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+use OOUI\Tag;
+
+/**
+ * Date-time input widget.
+ */
+class DateTimeInputWidget extends \OOUI\InputWidget {
+
+ protected $type = null;
+ protected $min = null;
+ protected $max = null;
+ protected $clearable = null;
+
+ /**
+ * @param array $config Configuration options
+ * @param string $config['type'] 'date', 'time', or 'datetime'
+ * @param string $config['min'] Minimum date, time, or datetime
+ * @param string $config['max'] Maximum date, time, or datetime
+ * @param bool $config['clearable'] Whether to provide for blanking the value.
+ */
+ public function __construct( array $config = [] ) {
+ // We need $this->type set before calling the parent constructor
+ if ( isset( $config['type'] ) ) {
+ $this->type = $config['type'];
+ } else {
+ throw new \InvalidArgumentException( '$config[\'type\'] must be specified' );
+ }
+
+ // Parent constructor
+ parent::__construct( $config );
+
+ // Properties, which are ignored in PHP and just shipped back to JS
+ if ( isset( $config['min'] ) ) {
+ $this->min = $config['min'];
+ }
+ if ( isset( $config['max'] ) ) {
+ $this->max = $config['max'];
+ }
+ if ( isset( $config['clearable'] ) ) {
+ $this->clearable = $config['clearable'];
+ }
+
+ // Initialization
+ $this->addClasses( [ 'mw-widgets-datetime-dateTimeInputWidget' ] );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.datetime.DateTimeInputWidget';
+ }
+
+ public function getConfig( &$config ) {
+ $config['type'] = $this->type;
+ if ( $this->min !== null ) {
+ $config['min'] = $this->min;
+ }
+ if ( $this->max !== null ) {
+ $config['max'] = $this->max;
+ }
+ if ( $this->clearable !== null ) {
+ $config['clearable'] = $this->clearable;
+ }
+ return parent::getConfig( $config );
+ }
+
+ protected function getInputElement( $config ) {
+ return ( new Tag( 'input' ) )->setAttributes( [ 'type' => $this->type ] );
+ }
+}
diff --git a/www/wiki/includes/widget/LICENSE.txt b/www/wiki/includes/widget/LICENSE.txt
new file mode 100644
index 00000000..b03ca801
--- /dev/null
+++ b/www/wiki/includes/widget/LICENSE.txt
@@ -0,0 +1,25 @@
+Copyright (c) 2011-2015 MediaWiki Widgets Team and others under the
+terms of The MIT License (MIT), as follows:
+
+This software consists of voluntary contributions made by many
+individuals (AUTHORS.txt) For exact contribution history, see the
+revision history and logs, available at https://gerrit.wikimedia.org
+
+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.
diff --git a/www/wiki/includes/widget/NamespaceInputWidget.php b/www/wiki/includes/widget/NamespaceInputWidget.php
new file mode 100644
index 00000000..3e86738b
--- /dev/null
+++ b/www/wiki/includes/widget/NamespaceInputWidget.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * MediaWiki Widgets – NamespaceInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+/**
+ * Namespace input widget. Displays a dropdown box with the choice of available namespaces.
+ */
+class NamespaceInputWidget extends \OOUI\DropdownInputWidget {
+
+ protected $includeAllValue = null;
+
+ /**
+ * @param array $config Configuration options
+ * @param string $config['includeAllValue'] If specified, add a "all namespaces" option to the
+ * namespace dropdown, and use this as the input value for it
+ * @param number[] $config['exclude'] List of namespace numbers to exclude from the selector
+ */
+ public function __construct( array $config = [] ) {
+ // Configuration initialization
+ $config['options'] = $this->getNamespaceDropdownOptions( $config );
+
+ // Parent constructor
+ parent::__construct( $config );
+
+ // Properties
+ $this->includeAllValue = isset( $config['includeAllValue'] ) ? $config['includeAllValue'] : null;
+ $this->exclude = isset( $config['exclude'] ) ? $config['exclude'] : [];
+
+ // Initialization
+ $this->addClasses( [ 'mw-widget-namespaceInputWidget' ] );
+ }
+
+ protected function getNamespaceDropdownOptions( array $config ) {
+ $namespaceOptionsParams = [
+ 'all' => isset( $config['includeAllValue'] ) ? $config['includeAllValue'] : null,
+ 'exclude' => isset( $config['exclude'] ) ? $config['exclude'] : null
+ ];
+ $namespaceOptions = \Html::namespaceSelectorOptions( $namespaceOptionsParams );
+
+ $options = [];
+ foreach ( $namespaceOptions as $id => $name ) {
+ $options[] = [
+ 'data' => (string)$id,
+ 'label' => $name,
+ ];
+ }
+
+ return $options;
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.NamespaceInputWidget';
+ }
+
+ public function getConfig( &$config ) {
+ $config['includeAllValue'] = $this->includeAllValue;
+ $config['exclude'] = $this->exclude;
+ // Skip DropdownInputWidget's getConfig(), we don't need 'options' config
+ return \OOUI\InputWidget::getConfig( $config );
+ }
+}
diff --git a/www/wiki/includes/widget/SearchInputWidget.php b/www/wiki/includes/widget/SearchInputWidget.php
new file mode 100644
index 00000000..773c291d
--- /dev/null
+++ b/www/wiki/includes/widget/SearchInputWidget.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * MediaWiki Widgets – SearchInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+/**
+ * Search input widget.
+ */
+class SearchInputWidget extends TitleInputWidget {
+
+ protected $pushPending = false;
+ protected $performSearchOnClick = true;
+ protected $validateTitle = false;
+ protected $highlightFirst = false;
+ protected $dataLocation = 'header';
+
+ /**
+ * @param array $config Configuration options
+ * @param int|null $config['pushPending'] Whether the input should be visually marked as
+ * "pending", while requesting suggestions (default: false)
+ * @param bool|null $config['performSearchOnClick'] If true, the script will start a search
+ * whenever a user hits a suggestion. If false, the text of the suggestion is inserted into the
+ * text field only (default: true)
+ * @param string $config['dataLocation'] Where the search input field will be
+ * used (header or content, default: header)
+ */
+ public function __construct( array $config = [] ) {
+ $config = array_merge( [
+ 'maxLength' => null,
+ 'icon' => 'search',
+ ], $config );
+
+ // Parent constructor
+ parent::__construct( $config );
+
+ // Properties, which are ignored in PHP and just shipped back to JS
+ if ( isset( $config['pushPending'] ) ) {
+ $this->pushPending = $config['pushPending'];
+ }
+
+ if ( isset( $config['performSearchOnClick'] ) ) {
+ $this->performSearchOnClick = $config['performSearchOnClick'];
+ }
+
+ if ( isset( $config['dataLocation'] ) ) {
+ // identifies the location of the search bar for tracking purposes
+ $this->dataLocation = $config['dataLocation'];
+ }
+
+ // Initialization
+ $this->addClasses( [ 'mw-widget-searchInputWidget' ] );
+ }
+
+ protected function getInputElement( $config ) {
+ return ( new \OOUI\Tag( 'input' ) )->setAttributes( [ 'type' => 'search' ] );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.SearchInputWidget';
+ }
+
+ public function getConfig( &$config ) {
+ $config['pushPending'] = $this->pushPending;
+ $config['performSearchOnClick'] = $this->performSearchOnClick;
+ if ( $this->dataLocation ) {
+ $config['dataLocation'] = $this->dataLocation;
+ }
+ return parent::getConfig( $config );
+ }
+}
diff --git a/www/wiki/includes/widget/SelectWithInputWidget.php b/www/wiki/includes/widget/SelectWithInputWidget.php
new file mode 100644
index 00000000..d2dda75e
--- /dev/null
+++ b/www/wiki/includes/widget/SelectWithInputWidget.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * MediaWiki Widgets – SelectWithInputWidget class.
+ *
+ * @copyright 2011-2017 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+use \OOUI\TextInputWidget;
+use \OOUI\DropdownInputWidget;
+
+/**
+ * Select and input widget.
+ */
+class SelectWithInputWidget extends \OOUI\Widget {
+
+ protected $textinput = null;
+ protected $dropdowninput = null;
+
+ /**
+ * A version of the SelectWithInputWidget, with `or` set to true.
+ *
+ * @param array $config Configuration options
+ * @param array $config['textinput'] Configuration for the TextInputWidget
+ * @param array $config['dropdowninput'] Configuration for the DropdownInputWidget
+ * @param bool $config['or'] Configuration for whether the widget is dropdown AND input
+ * or dropdown OR input
+ */
+ public function __construct( array $config = [] ) {
+ // Configuration initialization
+ $config = array_merge(
+ [
+ 'textinput' => [],
+ 'dropdowninput' => [],
+ 'or' => false
+ ],
+ $config
+ );
+
+ // Parent constructor
+ parent::__construct( $config );
+
+ // Properties
+ $this->config = $config;
+ $this->textinput = new TextInputWidget( $config['textinput'] );
+ $this->dropdowninput = new DropdownInputWidget( $config['dropdowninput'] );
+
+ // Initialization
+ $this
+ ->addClasses( [ 'mw-widget-selectWithInputWidget' ] )
+ ->appendContent( $this->dropdowninput, $this->textinput );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.SelectWithInputWidget';
+ }
+
+ public function getConfig( &$config ) {
+ $config['textinput'] = $this->config['textinput'];
+ $config['dropdowninput'] = $this->config['dropdowninput'];
+ $config['or'] = $this->config['or'];
+ return parent::getConfig( $config );
+ }
+}
diff --git a/www/wiki/includes/widget/TitleInputWidget.php b/www/wiki/includes/widget/TitleInputWidget.php
new file mode 100644
index 00000000..da2e94bb
--- /dev/null
+++ b/www/wiki/includes/widget/TitleInputWidget.php
@@ -0,0 +1,81 @@
+<?php
+/**
+ * MediaWiki Widgets – TitleInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+/**
+ * Title input widget.
+ */
+class TitleInputWidget extends \OOUI\TextInputWidget {
+
+ protected $namespace = null;
+ protected $relative = null;
+ protected $suggestions = null;
+ protected $highlightFirst = null;
+ protected $validateTitle = null;
+
+ /**
+ * @param array $config Configuration options
+ * @param int|null $config['namespace'] Namespace to prepend to queries
+ * @param bool|null $config['relative'] If a namespace is set,
+ * return a title relative to it (default: true)
+ * @param bool|null $config['suggestions'] Display search suggestions (default: true)
+ * @param bool|null $config['highlightFirst'] Automatically highlight
+ * the first result (default: true)
+ * @param bool|null $config['validateTitle'] Whether the input must
+ * be a valid title (default: true)
+ */
+ public function __construct( array $config = [] ) {
+ // Parent constructor
+ parent::__construct(
+ array_merge( [ 'maxLength' => 255 ], $config )
+ );
+
+ // Properties, which are ignored in PHP and just shipped back to JS
+ if ( isset( $config['namespace'] ) ) {
+ $this->namespace = $config['namespace'];
+ }
+ if ( isset( $config['relative'] ) ) {
+ $this->relative = $config['relative'];
+ }
+ if ( isset( $config['suggestions'] ) ) {
+ $this->suggestions = $config['suggestions'];
+ }
+ if ( isset( $config['highlightFirst'] ) ) {
+ $this->highlightFirst = $config['highlightFirst'];
+ }
+ if ( isset( $config['validateTitle'] ) ) {
+ $this->validateTitle = $config['validateTitle'];
+ }
+
+ // Initialization
+ $this->addClasses( [ 'mw-widget-titleInputWidget' ] );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.TitleInputWidget';
+ }
+
+ public function getConfig( &$config ) {
+ if ( $this->namespace !== null ) {
+ $config['namespace'] = $this->namespace;
+ }
+ if ( $this->relative !== null ) {
+ $config['relative'] = $this->relative;
+ }
+ if ( $this->suggestions !== null ) {
+ $config['suggestions'] = $this->suggestions;
+ }
+ if ( $this->highlightFirst !== null ) {
+ $config['highlightFirst'] = $this->highlightFirst;
+ }
+ if ( $this->validateTitle !== null ) {
+ $config['validateTitle'] = $this->validateTitle;
+ }
+ return parent::getConfig( $config );
+ }
+}
diff --git a/www/wiki/includes/widget/UserInputWidget.php b/www/wiki/includes/widget/UserInputWidget.php
new file mode 100644
index 00000000..d591ad13
--- /dev/null
+++ b/www/wiki/includes/widget/UserInputWidget.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * MediaWiki Widgets – UserInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+/**
+ * User input widget.
+ */
+class UserInputWidget extends \OOUI\TextInputWidget {
+
+ /**
+ * @param array $config Configuration options
+ */
+ public function __construct( array $config = [] ) {
+ // Parent constructor
+ parent::__construct( $config );
+
+ // Initialization
+ $this->addClasses( [ 'mw-widget-userInputWidget' ] );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.UserInputWidget';
+ }
+}
diff --git a/www/wiki/includes/widget/UsersMultiselectWidget.php b/www/wiki/includes/widget/UsersMultiselectWidget.php
new file mode 100644
index 00000000..999cb6ab
--- /dev/null
+++ b/www/wiki/includes/widget/UsersMultiselectWidget.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * MediaWiki Widgets – UsersMultiselectWidget class.
+ *
+ * @copyright 2017 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+use \OOUI\TextInputWidget;
+
+/**
+ * Widget to select multiple users.
+ */
+class UsersMultiselectWidget extends \OOUI\Widget {
+
+ protected $usersArray = [];
+ protected $inputName = null;
+ protected $inputPlaceholder = null;
+
+ /**
+ * @param array $config Configuration options
+ * @param array $config['users'] Array of usernames to use as preset data
+ * @param array $config['placeholder'] Placeholder message for input
+ * @param array $config['name'] Name attribute (used in forms)
+ */
+ public function __construct( array $config = [] ) {
+ parent::__construct( $config );
+
+ // Properties
+ if ( isset( $config['default'] ) ) {
+ $this->usersArray = $config['default'];
+ }
+ if ( isset( $config['name'] ) ) {
+ $this->inputName = $config['name'];
+ }
+ if ( isset( $config['placeholder'] ) ) {
+ $this->inputPlaceholder = $config['placeholder'];
+ }
+
+ $textarea = new TextInputWidget( [
+ 'name' => $this->inputName,
+ 'multiline' => true,
+ 'value' => implode( "\n", $this->usersArray ),
+ 'rows' => 25,
+ ] );
+ $this->prependContent( $textarea );
+ }
+
+ protected function getJavaScriptClassName() {
+ return 'mw.widgets.UsersMultiselectWidget';
+ }
+
+ public function getConfig( &$config ) {
+ if ( $this->usersArray !== null ) {
+ $config['selected'] = $this->usersArray;
+ }
+ if ( $this->inputName !== null ) {
+ $config['name'] = $this->inputName;
+ }
+ if ( $this->inputPlaceholder !== null ) {
+ $config['placeholder'] = $this->inputPlaceholder;
+ }
+
+ return parent::getConfig( $config );
+ }
+
+}
diff --git a/www/wiki/includes/widget/search/BasicSearchResultSetWidget.php b/www/wiki/includes/widget/search/BasicSearchResultSetWidget.php
new file mode 100644
index 00000000..bf59fe9e
--- /dev/null
+++ b/www/wiki/includes/widget/search/BasicSearchResultSetWidget.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use Message;
+use SearchResultSet;
+use SpecialSearch;
+use Status;
+
+/**
+ * Renders the search result area. Handles Title and Full-Text search results,
+ * along with inline and sidebar secondary (interwiki) results.
+ */
+class BasicSearchResultSetWidget {
+ /** @var SpecialSearch */
+ protected $specialPage;
+ /** @var SearchResultWidget */
+ protected $resultWidget;
+ /** @var InterwikiSearchResultSetWidget */
+ protected $sidebarWidget;
+
+ public function __construct(
+ SpecialSearch $specialPage,
+ SearchResultWidget $resultWidget,
+ SearchResultSetWidget $sidebarWidget
+ ) {
+ $this->specialPage = $specialPage;
+ $this->resultWidget = $resultWidget;
+ $this->sidebarWidget = $sidebarWidget;
+ }
+
+ /**
+ * @param string $term The search term to highlight
+ * @param int $offset The offset of the first result in the result set
+ * @param SearchResultSet|null $titleResultSet Results of searching only page titles
+ * @param SearchResultSet|null $textResultSet Results of general full text search.
+ * @return string HTML
+ */
+ public function render(
+ $term,
+ $offset,
+ SearchResultSet $titleResultSet = null,
+ SearchResultSet $textResultSet = null
+ ) {
+ global $wgContLang;
+
+ $hasTitle = $titleResultSet ? $titleResultSet->numRows() > 0 : false;
+ $hasText = $textResultSet ? $textResultSet->numRows() > 0 : false;
+ $hasSecondary = $textResultSet
+ ? $textResultSet->hasInterwikiResults( SearchResultSet::SECONDARY_RESULTS )
+ : false;
+ $hasSecondaryInline = $textResultSet
+ ? $textResultSet->hasInterwikiResults( SearchResultSet::INLINE_RESULTS )
+ : false;
+
+ if ( !$hasTitle && !$hasText && !$hasSecondary && !$hasSecondaryInline ) {
+ return '';
+ }
+
+ $out = '';
+ if ( $hasTitle ) {
+ $out .= $this->header( $this->specialPage->msg( 'titlematches' ) )
+ . $this->renderResultSet( $titleResultSet, $offset );
+ }
+
+ if ( $hasText ) {
+ if ( $hasTitle ) {
+ $out .= "<div class='mw-search-visualclear'></div>" .
+ $this->header( $this->specialPage->msg( 'textmatches' ) );
+ }
+ $out .= $this->renderResultSet( $textResultSet, $offset );
+ }
+
+ if ( $hasSecondaryInline ) {
+ $iwResults = $textResultSet->getInterwikiResults( SearchResultSet::INLINE_RESULTS );
+ foreach ( $iwResults as $interwiki => $results ) {
+ if ( $results instanceof Status || $results->numRows() === 0 ) {
+ // ignore bad interwikis for now
+ continue;
+ }
+ $out .=
+ "<h2 class='mw-search-interwiki-header mw-search-visualclear'>" .
+ $this->specialPage->msg( "search-interwiki-results-{$interwiki}" )->parse() .
+ "</h2>";
+ $out .= $this->renderResultSet( $results, $offset );
+ }
+ }
+
+ if ( $hasSecondary ) {
+ $out .= $this->sidebarWidget->render(
+ $term,
+ $textResultSet->getInterwikiResults( SearchResultSet::SECONDARY_RESULTS )
+ );
+ }
+
+ // Convert the whole thing to desired language variant
+ // TODO: Move this up to Special:Search?
+ return $wgContLang->convert( $out );
+ }
+
+ /**
+ * Generate a headline for a section of the search results. In prior
+ * implementations this was rendering wikitext of '==$1==', but seems
+ * a waste to call the full parser to generate this tiny bit of html
+ *
+ * @param Message $msg i18n message to use as header
+ * @return string HTML
+ */
+ protected function header( Message $msg ) {
+ return
+ "<h2>" .
+ "<span class='mw-headline'>" . $msg->escaped() . "</span>" .
+ "</h2>";
+ }
+
+ /**
+ * @param SearchResultSet $resultSet The search results to render
+ * @param int $offset Offset of the first result in $resultSet
+ * @return string HTML
+ */
+ protected function renderResultSet( SearchResultSet $resultSet, $offset ) {
+ global $wgContLang;
+
+ $terms = $wgContLang->convertForSearchResult( $resultSet->termMatches() );
+
+ $hits = [];
+ $result = $resultSet->next();
+ while ( $result ) {
+ $hits[] .= $this->resultWidget->render( $result, $terms, $offset++ );
+ $result = $resultSet->next();
+ }
+
+ return "<ul class='mw-search-results'>" . implode( '', $hits ) . "</ul>";
+ }
+}
diff --git a/www/wiki/includes/widget/search/DidYouMeanWidget.php b/www/wiki/includes/widget/search/DidYouMeanWidget.php
new file mode 100644
index 00000000..4e5b76b6
--- /dev/null
+++ b/www/wiki/includes/widget/search/DidYouMeanWidget.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use HtmlArmor;
+use SearchResultSet;
+use SpecialSearch;
+
+/**
+ * Renders a suggested search for the user, or tells the user
+ * a suggested search was run instead of the one provided.
+ */
+class DidYouMeanWidget {
+ /** @var SpecialSearch */
+ protected $specialSearch;
+
+ public function __construct( SpecialSearch $specialSearch ) {
+ $this->specialSearch = $specialSearch;
+ }
+
+ /**
+ * @param string $term The user provided search term
+ * @param SearchResultSet $resultSet
+ * @return string HTML
+ */
+ public function render( $term, SearchResultSet $resultSet ) {
+ if ( $resultSet->hasRewrittenQuery() ) {
+ $html = $this->rewrittenHtml( $term, $resultSet );
+ } elseif ( $resultSet->hasSuggestion() ) {
+ $html = $this->suggestionHtml( $resultSet );
+ } else {
+ return '';
+ }
+
+ return "<div class='searchdidyoumean'>$html</div>";
+ }
+
+ /**
+ * Generates HTML shown to user when their query has been internally
+ * rewritten, and the results of the rewritten query are being returned.
+ *
+ * @param string $term The users search input
+ * @param SearchResultSet $resultSet The response to the search request
+ * @return string HTML Links the user to their original $term query, and the
+ * one suggested by $resultSet
+ */
+ protected function rewrittenHtml( $term, SearchResultSet $resultSet ) {
+ $params = [
+ 'search' => $resultSet->getQueryAfterRewrite(),
+ // Don't magic this link into a 'go' link, it should always
+ // show search results.
+ 'fultext' => 1,
+ ];
+ $stParams = array_merge( $params, $this->specialSearch->powerSearchOptions() );
+
+ $linkRenderer = $this->specialSearch->getLinkRenderer();
+ $snippet = $resultSet->getQueryAfterRewriteSnippet();
+ $rewritten = $linkRenderer->makeKnownLink(
+ $this->specialSearch->getPageTitle(),
+ $snippet ? new HtmlArmor( $snippet ) : null,
+ [ 'id' => 'mw-search-DYM-rewritten' ],
+ $stParams
+ );
+
+ $stParams['search'] = $term;
+ $stParams['runsuggestion'] = 0;
+ $original = $linkRenderer->makeKnownLink(
+ $this->specialSearch->getPageTitle(),
+ $term,
+ [ 'id' => 'mwsearch-DYM-original' ],
+ $stParams
+ );
+
+ return $this->specialSearch->msg( 'search-rewritten' )
+ ->rawParams( $rewritten, $original )
+ ->escaped();
+ }
+
+ /**
+ * Generates HTML shown to the user when we have a suggestion about
+ * a query that might give more/better results than their current
+ * query.
+ *
+ * @param SearchResultSet $resultSet
+ * @return string HTML
+ */
+ protected function suggestionHtml( SearchResultSet $resultSet ) {
+ $params = [
+ 'search' => $resultSet->getSuggestionQuery(),
+ 'fulltext' => 1,
+ ];
+ $stParams = array_merge( $params, $this->specialSearch->powerSearchOptions() );
+
+ $snippet = $resultSet->getSuggestionSnippet();
+ $suggest = $this->specialSearch->getLinkRenderer()->makeKnownLink(
+ $this->specialSearch->getPageTitle(),
+ $snippet ? new HtmlArmor( $snippet ) : null,
+ [ 'id' => 'mw-search-DYM-suggestion' ],
+ $stParams
+ );
+
+ return $this->specialSearch->msg( 'search-suggest' )
+ ->rawParams( $suggest )->parse();
+ }
+}
diff --git a/www/wiki/includes/widget/search/FullSearchResultWidget.php b/www/wiki/includes/widget/search/FullSearchResultWidget.php
new file mode 100644
index 00000000..0d0fa124
--- /dev/null
+++ b/www/wiki/includes/widget/search/FullSearchResultWidget.php
@@ -0,0 +1,284 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use Category;
+use Hooks;
+use HtmlArmor;
+use MediaWiki\Linker\LinkRenderer;
+use SearchResult;
+use SpecialSearch;
+use Title;
+
+/**
+ * Renders a 'full' multi-line search result with metadata.
+ *
+ * The Title
+ * some *highlighted* *text* about the search result
+ * 5KB (651 words) - 12:40, 6 Aug 2016
+ */
+class FullSearchResultWidget implements SearchResultWidget {
+ /** @var SpecialSearch */
+ protected $specialPage;
+ /** @var LinkRenderer */
+ protected $linkRenderer;
+
+ public function __construct( SpecialSearch $specialPage, LinkRenderer $linkRenderer ) {
+ $this->specialPage = $specialPage;
+ $this->linkRenderer = $linkRenderer;
+ }
+
+ /**
+ * @param SearchResult $result The result to render
+ * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet)
+ * @param int $position The result position, including offset
+ * @return string HTML
+ */
+ public function render( SearchResult $result, $terms, $position ) {
+ // If the page doesn't *exist*... our search index is out of date.
+ // The least confusing at this point is to drop the result.
+ // You may get less results, but... on well. :P
+ if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
+ return '';
+ }
+
+ $link = $this->generateMainLinkHtml( $result, $terms, $position );
+ // If page content is not readable, just return ths title.
+ // This is not quite safe, but better than showing excerpts from
+ // non-readable pages. Note that hiding the entry entirely would
+ // screw up paging (really?).
+ if ( !$result->getTitle()->userCan( 'read', $this->specialPage->getUser() ) ) {
+ return "<li>{$link}</li>";
+ }
+
+ $redirect = $this->generateRedirectHtml( $result );
+ $section = $this->generateSectionHtml( $result );
+ $category = $this->generateCategoryHtml( $result );
+ $date = $this->specialPage->getLanguage()->userTimeAndDate(
+ $result->getTimestamp(),
+ $this->specialPage->getUser()
+ );
+ list( $file, $desc, $thumb ) = $this->generateFileHtml( $result );
+ $snippet = $result->getTextSnippet( $terms );
+ if ( $snippet ) {
+ $extract = "<div class='searchresult'>$snippet</div>";
+ } else {
+ $extract = '';
+ }
+
+ if ( $thumb === null ) {
+ // If no thumb, then the description is about size
+ $desc = $this->generateSizeHtml( $result );
+
+ // Let hooks do their own final construction if desired.
+ // FIXME: Not sure why this is only for results without thumbnails,
+ // but keeping it as-is for now to prevent breaking hook consumers.
+ $html = null;
+ $score = '';
+ $related = '';
+ if ( !Hooks::run( 'ShowSearchHit', [
+ $this->specialPage, $result, $terms,
+ &$link, &$redirect, &$section, &$extract,
+ &$score, &$size, &$date, &$related, &$html
+ ] ) ) {
+ return $html;
+ }
+ }
+
+ // All the pieces have been collected. Now generate the final HTML
+ $joined = "{$link} {$redirect} {$category} {$section} {$file}";
+ $meta = $this->buildMeta( $desc, $date );
+
+ if ( $thumb === null ) {
+ $html =
+ "<div class='mw-search-result-heading'>{$joined}</div>" .
+ "{$extract} {$meta}";
+ } else {
+ $html =
+ "<table class='searchResultImage'>" .
+ "<tr>" .
+ "<td style='width: 120px; text-align: center; vertical-align: top'>" .
+ $thumb .
+ "</td>" .
+ "<td style='vertical-align: top'>" .
+ "{$joined} {$extract} {$meta}" .
+ "</td>" .
+ "</tr>" .
+ "</table>";
+ }
+
+ return "<li>{$html}</li>";
+ }
+
+ /**
+ * Generates HTML for the primary call to action. It is
+ * typically the article title, but the search engine can
+ * return an exact snippet to use (typically the article
+ * title with highlighted words).
+ *
+ * @param SearchResult $result
+ * @param string $terms
+ * @param int $position
+ * @return string HTML
+ */
+ protected function generateMainLinkHtml( SearchResult $result, $terms, $position ) {
+ $snippet = $result->getTitleSnippet();
+ if ( $snippet === '' ) {
+ $snippet = null;
+ } else {
+ $snippet = new HtmlArmor( $snippet );
+ }
+
+ // clone to prevent hook from changing the title stored inside $result
+ $title = clone $result->getTitle();
+ $query = [];
+
+ Hooks::run( 'ShowSearchHitTitle',
+ [ &$title, &$snippet, $result, $terms, $this->specialPage, &$query ] );
+
+ $link = $this->linkRenderer->makeLink(
+ $title,
+ $snippet,
+ [ 'data-serp-pos' => $position ],
+ $query
+ );
+
+ return $link;
+ }
+
+ /**
+ * Generates an alternate title link, such as (redirect from <a>Foo</a>).
+ *
+ * @param string $msgKey i18n message used to wrap title
+ * @param Title|null $title The title to link to, or null to generate
+ * the message without a link. In that case $text must be non-null.
+ * @param string|null $text The text snippet to display, or null
+ * to use the title
+ * @return string HTML
+ */
+ protected function generateAltTitleHtml( $msgKey, Title $title = null, $text ) {
+ $inner = $title === null
+ ? $text
+ : $this->linkRenderer->makeLink( $title, $text ? new HtmlArmor( $text ) : null );
+
+ return "<span class='searchalttitle'>" .
+ $this->specialPage->msg( $msgKey )->rawParams( $inner )->text()
+ . "</span>";
+ }
+
+ /**
+ * @param SearchResult $result
+ * @return string HTML
+ */
+ protected function generateRedirectHtml( SearchResult $result ) {
+ $title = $result->getRedirectTitle();
+ return $title === null
+ ? ''
+ : $this->generateAltTitleHtml( 'search-redirect', $title, $result->getRedirectSnippet() );
+ }
+
+ /**
+ * @param SearchResult $result
+ * @return string HTML
+ */
+ protected function generateSectionHtml( SearchResult $result ) {
+ $title = $result->getSectionTitle();
+ return $title === null
+ ? ''
+ : $this->generateAltTitleHtml( 'search-section', $title, $result->getSectionSnippet() );
+ }
+
+ /**
+ * @param SearchResult $result
+ * @return string HTML
+ */
+ protected function generateCategoryHtml( SearchResult $result ) {
+ $snippet = $result->getCategorySnippet();
+ return $snippet
+ ? $this->generateAltTitleHtml( 'search-category', null, $snippet )
+ : '';
+ }
+
+ /**
+ * @param SearchResult $result
+ * @return string HTML
+ */
+ protected function generateSizeHtml( SearchResult $result ) {
+ $title = $result->getTitle();
+ if ( $title->getNamespace() === NS_CATEGORY ) {
+ $cat = Category::newFromTitle( $title );
+ return $this->specialPage->msg( 'search-result-category-size' )
+ ->numParams( $cat->getPageCount(), $cat->getSubcatCount(), $cat->getFileCount() )
+ ->escaped();
+ // TODO: This is a bit odd...but requires changing the i18n message to fix
+ } elseif ( $result->getByteSize() !== null || $result->getWordCount() > 0 ) {
+ $lang = $this->specialPage->getLanguage();
+ $bytes = $lang->formatSize( $result->getByteSize() );
+ $words = $result->getWordCount();
+
+ return $this->specialPage->msg( 'search-result-size', $bytes )
+ ->numParams( $words )
+ ->escaped();
+ }
+
+ return '';
+ }
+
+ /**
+ * @param SearchResult $result
+ * @return array Three element array containing the main file html,
+ * a text description of the file, and finally the thumbnail html.
+ * If no thumbnail is available the second and third will be null.
+ */
+ protected function generateFileHtml( SearchResult $result ) {
+ $title = $result->getTitle();
+ if ( $title->getNamespace() !== NS_FILE ) {
+ return [ '', null, null ];
+ }
+
+ if ( $result->isFileMatch() ) {
+ $html = "<span class='searchalttitle'>" .
+ $this->specialPage->msg( 'search-file-match' )->escaped() .
+ "</span>";
+ } else {
+ $html = '';
+ }
+
+ $descHtml = null;
+ $thumbHtml = null;
+
+ $img = $result->getFile() ?: wfFindFile( $title );
+ if ( $img ) {
+ $thumb = $img->transform( [ 'width' => 120, 'height' => 120 ] );
+ if ( $thumb ) {
+ $descHtml = $this->specialPage->msg( 'parentheses' )
+ ->rawParams( $img->getShortDesc() )
+ ->escaped();
+ $thumbHtml = $thumb->toHtml( [ 'desc-link' => true ] );
+ }
+ }
+
+ return [ $html, $descHtml, $thumbHtml ];
+ }
+
+ /**
+ * @param string $desc HTML description of result, ex: size in bytes, or empty string
+ * @param string $date HTML representation of last edit date, or empty string
+ * @return string HTML A div combining $desc and $date with a separator in a <div>.
+ * If either is missing only one will be represented. If both are missing an empty
+ * string will be returned.
+ */
+ protected function buildMeta( $desc, $date ) {
+ if ( $desc && $date ) {
+ $meta = "{$desc} - {$date}";
+ } elseif ( $desc ) {
+ $meta = $desc;
+ } elseif ( $date ) {
+ $meta = $date;
+ } else {
+ return '';
+ }
+
+ return "<div class='mw-search-result-data'>{$meta}</div>";
+ }
+}
diff --git a/www/wiki/includes/widget/search/InterwikiSearchResultSetWidget.php b/www/wiki/includes/widget/search/InterwikiSearchResultSetWidget.php
new file mode 100644
index 00000000..81a1a431
--- /dev/null
+++ b/www/wiki/includes/widget/search/InterwikiSearchResultSetWidget.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use MediaWiki\Interwiki\InterwikiLookup;
+use MediaWiki\Linker\LinkRenderer;
+use SearchResultSet;
+use SpecialSearch;
+use Title;
+use Html;
+use OOUI;
+
+/**
+ * Renders one or more SearchResultSets into a sidebar grouped by
+ * interwiki prefix. Includes a per-wiki header indicating where
+ * the results are from.
+ */
+class InterwikiSearchResultSetWidget implements SearchResultSetWidget {
+ /** @var SpecialSearch */
+ protected $specialSearch;
+ /** @var SearchResultWidget */
+ protected $resultWidget;
+ /** @var string[]|null */
+ protected $customCaptions;
+ /** @var LinkRenderer */
+ protected $linkRenderer;
+ /** @var InterwikiLookup */
+ protected $iwLookup;
+ /** @var $output */
+ protected $output;
+ /** @var bool $showMultimedia */
+ protected $showMultimedia;
+
+ public function __construct(
+ SpecialSearch $specialSearch,
+ SearchResultWidget $resultWidget,
+ LinkRenderer $linkRenderer,
+ InterwikiLookup $iwLookup,
+ $showMultimedia = false
+ ) {
+ $this->specialSearch = $specialSearch;
+ $this->resultWidget = $resultWidget;
+ $this->linkRenderer = $linkRenderer;
+ $this->iwLookup = $iwLookup;
+ $this->output = $specialSearch->getOutput();
+ $this->showMultimedia = $showMultimedia;
+ }
+ /**
+ * @param string $term User provided search term
+ * @param SearchResultSet|SearchResultSet[] $resultSets List of interwiki
+ * results to render.
+ * @return string HTML
+ */
+ public function render( $term, $resultSets ) {
+ if ( !is_array( $resultSets ) ) {
+ $resultSets = [ $resultSets ];
+ }
+
+ $this->loadCustomCaptions();
+
+ if ( $this->showMultimedia ) {
+ $this->output->addModules( 'mediawiki.special.search.commonsInterwikiWidget' );
+ }
+ $this->output->addModuleStyles( 'mediawiki.special.search.interwikiwidget.styles' );
+
+ $iwResults = [];
+ foreach ( $resultSets as $resultSet ) {
+ $result = $resultSet->next();
+ while ( $result ) {
+ if ( !$result->isBrokenTitle() ) {
+ $iwResults[$result->getTitle()->getInterwiki()][] = $result;
+ }
+ $result = $resultSet->next();
+ }
+ }
+
+ $iwResultSetPos = 1;
+ $iwResultListOutput = '';
+
+ foreach ( $iwResults as $iwPrefix => $results ) {
+ // TODO: Assumes interwiki results are never paginated
+ $position = 0;
+ $iwResultItemOutput = '';
+
+ foreach ( $results as $result ) {
+ $iwResultItemOutput .= $this->resultWidget->render( $result, $term, $position++ );
+ }
+
+ $footerHtml = $this->footerHtml( $term, $iwPrefix );
+ $iwResultListOutput .= Html::rawElement( 'li',
+ [
+ 'class' => 'iw-resultset',
+ 'data-iw-resultset-pos' => $iwResultSetPos,
+ 'data-iw-resultset-source' => $iwPrefix
+ ],
+
+ $iwResultItemOutput .
+ $footerHtml
+ );
+
+ $iwResultSetPos++;
+ }
+
+ return Html::rawElement(
+ 'div',
+ [ 'id' => 'mw-interwiki-results' ],
+ Html::rawElement(
+ 'p',
+ [ 'class' => 'iw-headline' ],
+ $this->specialSearch->msg( 'search-interwiki-caption' )->parse()
+ ) .
+ Html::rawElement(
+ 'ul', [ 'class' => 'iw-results', ], $iwResultListOutput
+ )
+ );
+ }
+
+ /**
+ * Generates an HTML footer for the given interwiki prefix
+ *
+ * @param string $term User provided search term
+ * @param string $iwPrefix Interwiki prefix of wiki to show footer for
+ * @return string HTML
+ */
+ protected function footerHtml( $term, $iwPrefix ) {
+ $href = Title::makeTitle( NS_SPECIAL, 'Search', null, $iwPrefix )->getLocalURL(
+ [ 'search' => $term, 'fulltext' => 1 ]
+ );
+
+ $interwiki = $this->iwLookup->fetch( $iwPrefix );
+ $parsed = wfParseUrl( wfExpandUrl( $interwiki ? $interwiki->getURL() : '/' ) );
+
+ if ( isset( $this->customCaptions[$iwPrefix] ) ) {
+ $caption = $this->customCaptions[$iwPrefix];
+ } else {
+ $caption = $this->specialSearch->msg( 'search-interwiki-default', $parsed['host'] )->escaped();
+ }
+
+ $searchLink = Html::rawElement( 'em', null,
+ Html::rawElement( 'a', [ 'href' => $href, 'target' => '_blank' ], $caption )
+ );
+
+ return Html::rawElement( 'div',
+ [ 'class' => 'iw-result__footer' ],
+ $this->iwIcon( $iwPrefix ) . $searchLink );
+ }
+
+ protected function loadCustomCaptions() {
+ if ( $this->customCaptions !== null ) {
+ return;
+ }
+
+ $this->customCaptions = [];
+ $customLines = explode( "\n", $this->specialSearch->msg( 'search-interwiki-custom' )->escaped() );
+ foreach ( $customLines as $line ) {
+ $parts = explode( ':', $line, 2 );
+ if ( count( $parts ) === 2 ) {
+ $this->customCaptions[$parts[0]] = $parts[1];
+ }
+ }
+ }
+
+ /**
+ * Generates a custom OOUI icon element with a favicon as the image.
+ * The favicon image URL is generated by parsing the interwiki URL
+ * and returning the default location of the favicon for that domain,
+ * which is assumed to be '/favicon.ico'.
+ *
+ * @param string $iwPrefix Interwiki prefix
+ * @return OOUI\IconWidget
+ **/
+ protected function iwIcon( $iwPrefix ) {
+ $interwiki = $this->iwLookup->fetch( $iwPrefix );
+ $parsed = wfParseUrl( wfExpandUrl( $interwiki ? $interwiki->getURL() : '/' ) );
+
+ $iwIconUrl = $parsed['scheme'] .
+ $parsed['delimiter'] .
+ $parsed['host'] .
+ ( isset( $parsed['port'] ) ? ':' . $parsed['port'] : '' ) .
+ '/favicon.ico';
+
+ $iwIcon = new OOUI\IconWidget( [
+ 'icon' => 'favicon'
+ ] );
+
+ $iwIcon->setAttributes( [ 'style' => "background-image:url($iwIconUrl);" ] );
+
+ return $iwIcon;
+ }
+}
diff --git a/www/wiki/includes/widget/search/InterwikiSearchResultWidget.php b/www/wiki/includes/widget/search/InterwikiSearchResultWidget.php
new file mode 100644
index 00000000..4eead5e7
--- /dev/null
+++ b/www/wiki/includes/widget/search/InterwikiSearchResultWidget.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use HtmlArmor;
+use MediaWiki\Linker\LinkRenderer;
+use SearchResult;
+use SpecialSearch;
+use Html;
+
+/**
+ * Renders an enhanced interwiki result
+ */
+class InterwikiSearchResultWidget implements SearchResultWidget {
+ /** @var SpecialSearch */
+ protected $specialSearch;
+ /** @var LinkRenderer */
+ protected $linkRenderer;
+
+ public function __construct( SpecialSearch $specialSearch, LinkRenderer $linkRenderer ) {
+ $this->specialSearch = $specialSearch;
+ $this->linkRenderer = $linkRenderer;
+ }
+
+ /**
+ * @param SearchResult $result The result to render
+ * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet)
+ * @param int $position The result position, including offset
+ * @return string HTML
+ */
+ public function render( SearchResult $result, $terms, $position ) {
+ $title = $result->getTitle();
+ $iwPrefix = $result->getTitle()->getInterwiki();
+ $titleSnippet = $result->getTitleSnippet();
+ $snippet = $result->getTextSnippet( $terms );
+
+ if ( $titleSnippet ) {
+ $titleSnippet = new HtmlArmor( $titleSnippet );
+ } else {
+ $titleSnippet = null;
+ }
+
+ $link = $this->linkRenderer->makeLink( $title, $titleSnippet );
+
+ $redirectTitle = $result->getRedirectTitle();
+ $redirect = '';
+ if ( $redirectTitle !== null ) {
+ $redirectText = $result->getRedirectSnippet();
+
+ if ( $redirectText ) {
+ $redirectText = new HtmlArmor( $redirectText );
+ } else {
+ $redirectText = null;
+ }
+
+ $redirect = Html::rawElement( 'span', [ 'class' => 'iw-result__redirect' ],
+ $this->specialSearch->msg( 'search-redirect' )->rawParams(
+ $this->linkRenderer->makeLink( $redirectTitle, $redirectText )
+ )->escaped()
+ );
+ }
+
+ return Html::rawElement( 'div', [ 'class' => 'iw-result__title' ], $link . ' ' . $redirect ) .
+ Html::rawElement( 'div', [ 'class' => 'iw-result__content' ], $snippet );
+ }
+}
diff --git a/www/wiki/includes/widget/search/SearchFormWidget.php b/www/wiki/includes/widget/search/SearchFormWidget.php
new file mode 100644
index 00000000..008ed19b
--- /dev/null
+++ b/www/wiki/includes/widget/search/SearchFormWidget.php
@@ -0,0 +1,316 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use Hooks;
+use Html;
+use MediaWiki\Widget\SearchInputWidget;
+use MWNamespace;
+use SearchEngineConfig;
+use SpecialSearch;
+use Xml;
+
+class SearchFormWidget {
+ /** @var SpecialSearch */
+ protected $specialSearch;
+ /** @var SearchEngineConfig */
+ protected $searchConfig;
+ /** @var array */
+ protected $profiles;
+
+ /**
+ * @param SpecialSearch $specialSearch
+ * @param SearchEngineConfig $searchConfig
+ * @param array $profiles
+ */
+ public function __construct(
+ SpecialSearch $specialSearch,
+ SearchEngineConfig $searchConfig,
+ array $profiles
+ ) {
+ $this->specialSearch = $specialSearch;
+ $this->searchConfig = $searchConfig;
+ $this->profiles = $profiles;
+ }
+
+ /**
+ * @param string $profile The current search profile
+ * @param string $term The current search term
+ * @param int $numResults The number of results shown
+ * @param int $totalResults The total estimated results found
+ * @param int $offset Current offset in search results
+ * @param bool $isPowerSearch Is the 'advanced' section open?
+ * @return string HTML
+ */
+ public function render(
+ $profile,
+ $term,
+ $numResults,
+ $totalResults,
+ $offset,
+ $isPowerSearch
+ ) {
+ return Xml::openElement(
+ 'form',
+ [
+ 'id' => $isPowerSearch ? 'powersearch' : 'search',
+ 'method' => 'get',
+ 'action' => wfScript(),
+ ]
+ ) .
+ '<div id="mw-search-top-table">' .
+ $this->shortDialogHtml( $profile, $term, $numResults, $totalResults, $offset ) .
+ '</div>' .
+ "<div class='mw-search-visualclear'></div>" .
+ "<div class='mw-search-profile-tabs'>" .
+ $this->profileTabsHtml( $profile, $term ) .
+ "<div style='clear:both'></div>" .
+ "</div>" .
+ $this->optionsHtml( $term, $isPowerSearch, $profile ) .
+ '</form>';
+ }
+
+ /**
+ * @param string $profile The current search profile
+ * @param string $term The current search term
+ * @param int $numResults The number of results shown
+ * @param int $totalResults The total estimated results found
+ * @param int $offset Current offset in search results
+ * @return string HTML
+ */
+ protected function shortDialogHtml( $profile, $term, $numResults, $totalResults, $offset ) {
+ $html = '';
+
+ $searchWidget = new SearchInputWidget( [
+ 'id' => 'searchText',
+ 'name' => 'search',
+ 'autofocus' => trim( $term ) === '',
+ 'value' => $term,
+ 'dataLocation' => 'content',
+ 'infusable' => true,
+ ] );
+
+ $layout = new \OOUI\ActionFieldLayout( $searchWidget, new \OOUI\ButtonInputWidget( [
+ 'type' => 'submit',
+ 'label' => $this->specialSearch->msg( 'searchbutton' )->text(),
+ 'flags' => [ 'progressive', 'primary' ],
+ ] ), [
+ 'align' => 'top',
+ ] );
+
+ $html .= $layout;
+
+ if ( $totalResults > 0 && $offset < $totalResults ) {
+ $html .= Xml::tags(
+ 'div',
+ [
+ 'class' => 'results-info',
+ 'data-mw-num-results-offset' => $offset,
+ 'data-mw-num-results-total' => $totalResults
+ ],
+ $this->specialSearch->msg( 'search-showingresults' )
+ ->numParams( $offset + 1, $offset + $numResults, $totalResults )
+ ->numParams( $numResults )
+ ->parse()
+ );
+ }
+
+ $html .=
+ Html::hidden( 'title', $this->specialSearch->getPageTitle()->getPrefixedText() ) .
+ Html::hidden( 'profile', $profile ) .
+ Html::hidden( 'fulltext', '1' );
+
+ return $html;
+ }
+
+ /**
+ * Generates HTML for the list of available search profiles.
+ *
+ * @param string $profile The currently selected profile
+ * @param string $term The user provided search terms
+ * @return string HTML
+ */
+ protected function profileTabsHtml( $profile, $term ) {
+ $bareterm = $this->startsWithImage( $term )
+ ? substr( $term, strpos( $term, ':' ) + 1 )
+ : $term;
+ $lang = $this->specialSearch->getLanguage();
+ $items = [];
+ foreach ( $this->profiles as $id => $profileConfig ) {
+ $profileConfig['parameters']['profile'] = $id;
+ $tooltipParam = isset( $profileConfig['namespace-messages'] )
+ ? $lang->commaList( $profileConfig['namespace-messages'] )
+ : null;
+ $items[] = Xml::tags(
+ 'li',
+ [ 'class' => $profile === $id ? 'current' : 'normal' ],
+ $this->makeSearchLink(
+ $bareterm,
+ $this->specialSearch->msg( $profileConfig['message'] )->text(),
+ $this->specialSearch->msg( $profileConfig['tooltip'], $tooltipParam )->text(),
+ $profileConfig['parameters']
+ )
+ );
+ }
+
+ return
+ "<div class='search-types'>" .
+ "<ul>" . implode( '', $items ) . "</ul>" .
+ "</div>";
+ }
+
+ /**
+ * Check if query starts with image: prefix
+ *
+ * @param string $term The string to check
+ * @return bool
+ */
+ protected function startsWithImage( $term ) {
+ global $wgContLang;
+
+ $parts = explode( ':', $term );
+ return count( $parts ) > 1
+ ? $wgContLang->getNsIndex( $parts[0] ) === NS_FILE
+ : false;
+ }
+
+ /**
+ * Make a search link with some target namespaces
+ *
+ * @param string $term The term to search for
+ * @param string $label Link's text
+ * @param string $tooltip Link's tooltip
+ * @param array $params Query string parameters
+ * @return string HTML fragment
+ */
+ protected function makeSearchLink( $term, $label, $tooltip, array $params = [] ) {
+ $params += [
+ 'search' => $term,
+ 'fulltext' => 1,
+ ];
+
+ return Xml::element(
+ 'a',
+ [
+ 'href' => $this->specialSearch->getPageTitle()->getLocalURL( $params ),
+ 'title' => $tooltip,
+ ],
+ $label
+ );
+ }
+
+ /**
+ * Generates HTML for advanced options available with the currently
+ * selected search profile.
+ *
+ * @param string $term User provided search term
+ * @param bool $isPowerSearch Is the advanced search profile enabled?
+ * @param string $profile The current search profile
+ * @return string HTML
+ */
+ protected function optionsHtml( $term, $isPowerSearch, $profile ) {
+ $html = '';
+
+ if ( $isPowerSearch ) {
+ $html .= $this->powerSearchBox( $term, [] );
+ } else {
+ $form = '';
+ Hooks::run( 'SpecialSearchProfileForm', [
+ $this->specialSearch, &$form, $profile, $term, []
+ ] );
+ $html .= $form;
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param string $term The current search term
+ * @param array $opts Additional key/value pairs that will be submitted
+ * with the generated form.
+ * @return string HTML
+ */
+ protected function powerSearchBox( $term, array $opts ) {
+ global $wgContLang;
+
+ $rows = [];
+ $activeNamespaces = $this->specialSearch->getNamespaces();
+ foreach ( $this->searchConfig->searchableNamespaces() as $namespace => $name ) {
+ $subject = MWNamespace::getSubject( $namespace );
+ if ( !isset( $rows[$subject] ) ) {
+ $rows[$subject] = "";
+ }
+
+ $name = $wgContLang->getConverter()->convertNamespace( $namespace );
+ if ( $name === '' ) {
+ $name = $this->specialSearch->msg( 'blanknamespace' )->text();
+ }
+
+ $rows[$subject] .=
+ '<td>' .
+ Xml::checkLabel(
+ $name,
+ "ns{$namespace}",
+ "mw-search-ns{$namespace}",
+ in_array( $namespace, $activeNamespaces )
+ ) .
+ '</td>';
+ }
+
+ // Lays out namespaces in multiple floating two-column tables so they'll
+ // be arranged nicely while still accomodating diferent screen widths
+ $tableRows = [];
+ foreach ( $rows as $row ) {
+ $tableRows[] = "<tr>{$row}</tr>";
+ }
+ $namespaceTables = [];
+ foreach ( array_chunk( $tableRows, 4 ) as $chunk ) {
+ $namespaceTables[] = implode( '', $chunk );
+ }
+
+ $showSections = [
+ 'namespaceTables' => "<table>" . implode( '</table><table>', $namespaceTables ) . '</table>',
+ ];
+ Hooks::run( 'SpecialSearchPowerBox', [ &$showSections, $term, $opts ] );
+
+ $hidden = '';
+ foreach ( $opts as $key => $value ) {
+ $hidden .= Html::hidden( $key, $value );
+ }
+
+ $divider = "<div class='divider'></div>";
+
+ // Stuff to feed SpecialSearch::saveNamespaces()
+ $user = $this->specialSearch->getUser();
+ $remember = '';
+ if ( $user->isLoggedIn() ) {
+ $remember = $divider . Xml::checkLabel(
+ $this->specialSearch->msg( 'powersearch-remember' )->text(),
+ 'nsRemember',
+ 'mw-search-powersearch-remember',
+ false,
+ // The token goes here rather than in a hidden field so it
+ // is only sent when necessary (not every form submission)
+ [ 'value' => $user->getEditToken(
+ 'searchnamespace',
+ $this->specialSearch->getRequest()
+ ) ]
+ );
+ }
+
+ return
+ "<fieldset id='mw-searchoptions'>" .
+ "<legend>" . $this->specialSearch->msg( 'powersearch-legend' )->escaped() . '</legend>' .
+ "<h4>" . $this->specialSearch->msg( 'powersearch-ns' )->parse() . '</h4>' .
+ // populated by js if available
+ "<div id='mw-search-togglebox'></div>" .
+ $divider .
+ implode(
+ $divider,
+ $showSections
+ ) .
+ $hidden .
+ $remember .
+ "</fieldset>";
+ }
+}
diff --git a/www/wiki/includes/widget/search/SearchResultSetWidget.php b/www/wiki/includes/widget/search/SearchResultSetWidget.php
new file mode 100644
index 00000000..6df6e65c
--- /dev/null
+++ b/www/wiki/includes/widget/search/SearchResultSetWidget.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use SearchResultSet;
+
+/**
+ * Renders a set of search results to HTML
+ */
+interface SearchResultSetWidget {
+ /**
+ * @param string $term User provided search term
+ * @param SearchResultSet|SearchResultSet[] $resultSets List of interwiki
+ * results to render.
+ * @return string HTML
+ */
+ public function render( $term, $resultSets );
+}
diff --git a/www/wiki/includes/widget/search/SearchResultWidget.php b/www/wiki/includes/widget/search/SearchResultWidget.php
new file mode 100644
index 00000000..3fbdbef2
--- /dev/null
+++ b/www/wiki/includes/widget/search/SearchResultWidget.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use SearchResult;
+
+/**
+ * Renders a single search result to HTML
+ */
+interface SearchResultWidget {
+ /**
+ * @param SearchResult $result The result to render
+ * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet)
+ * @param int $position The zero indexed result position, including offset
+ * @return string HTML
+ */
+ public function render( SearchResult $result, $terms, $position );
+}
diff --git a/www/wiki/includes/widget/search/SimpleSearchResultSetWidget.php b/www/wiki/includes/widget/search/SimpleSearchResultSetWidget.php
new file mode 100644
index 00000000..4df2eb54
--- /dev/null
+++ b/www/wiki/includes/widget/search/SimpleSearchResultSetWidget.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use MediaWiki\Interwiki\InterwikiLookup;
+use MediaWiki\Linker\LinkRenderer;
+use SearchResultSet;
+use SpecialSearch;
+use Title;
+use Html;
+
+/**
+ * Renders one or more SearchResultSets into a sidebar grouped by
+ * interwiki prefix. Includes a per-wiki header indicating where
+ * the results are from.
+ */
+class SimpleSearchResultSetWidget implements SearchResultSetWidget {
+ /** @var SpecialSearch */
+ protected $specialSearch;
+ /** @var SearchResultWidget */
+ protected $resultWidget;
+ /** @var string[]|null */
+ protected $customCaptions;
+ /** @var LinkRenderer */
+ protected $linkRenderer;
+ /** @var InterwikiLookup */
+ protected $iwLookup;
+
+ public function __construct(
+ SpecialSearch $specialSearch,
+ SearchResultWidget $resultWidget,
+ LinkRenderer $linkRenderer,
+ InterwikiLookup $iwLookup
+ ) {
+ $this->specialSearch = $specialSearch;
+ $this->resultWidget = $resultWidget;
+ $this->linkRenderer = $linkRenderer;
+ $this->iwLookup = $iwLookup;
+ }
+
+ /**
+ * @param string $term User provided search term
+ * @param SearchResultSet|SearchResultSet[] $resultSets List of interwiki
+ * results to render.
+ * @return string HTML
+ */
+ public function render( $term, $resultSets ) {
+ if ( !is_array( $resultSets ) ) {
+ $resultSets = [ $resultSets ];
+ }
+
+ $this->loadCustomCaptions();
+
+ $iwResults = [];
+ foreach ( $resultSets as $resultSet ) {
+ $result = $resultSet->next();
+ while ( $result ) {
+ if ( !$result->isBrokenTitle() ) {
+ $iwResults[$result->getTitle()->getInterwiki()][] = $result;
+ }
+ $result = $resultSet->next();
+ }
+ }
+
+ $out = '';
+ foreach ( $iwResults as $iwPrefix => $results ) {
+ $out .= $this->headerHtml( $iwPrefix, $term );
+ $out .= "<ul class='mw-search-iwresults'>";
+ // TODO: Assumes interwiki results are never paginated
+ $position = 0;
+ foreach ( $results as $result ) {
+ $out .= $this->resultWidget->render( $result, $term, $position++ );
+ }
+ $out .= "</ul>";
+ }
+
+ return
+ "<div id='mw-search-interwiki'>" .
+ "<div id='mw-search-interwiki-caption'>" .
+ $this->specialSearch->msg( 'search-interwiki-caption' )->parse() .
+ '</div>' .
+ $out .
+ "</div>";
+ }
+
+ /**
+ * Generates an appropriate HTML header for the given interwiki prefix
+ *
+ * @param string $iwPrefix Interwiki prefix of wiki to show header for
+ * @param string $term User provided search term
+ * @return string HTML
+ */
+ protected function headerHtml( $iwPrefix, $term ) {
+ if ( isset( $this->customCaptions[$iwPrefix] ) ) {
+ $caption = $this->customCaptions[$iwPrefix];
+ } else {
+ $interwiki = $this->iwLookup->fetch( $iwPrefix );
+ $parsed = wfParseUrl( wfExpandUrl( $interwiki ? $interwiki->getURL() : '/' ) );
+ $caption = $this->specialSearch->msg( 'search-interwiki-default', $parsed['host'] )->escaped();
+ }
+
+ $href = Title::makeTitle( NS_SPECIAL, 'Search', null, $iwPrefix )->getLocalURL(
+ [ 'search' => $term, 'fulltext' => 1 ]
+ );
+ $searchLink = Html::rawElement(
+ 'a',
+ [ 'href' => $href ],
+ $this->specialSearch->msg( 'search-interwiki-more' )->escaped()
+ );
+
+ return
+ "<div class='mw-search-interwiki-project'>" .
+ "<span class='mw-search-interwiki-more'>{$searchLink}</span>" .
+ $caption .
+ "</div>";
+ }
+
+ protected function loadCustomCaptions() {
+ if ( $this->customCaptions !== null ) {
+ return;
+ }
+
+ $this->customCaptions = [];
+ $customLines = explode( "\n", $this->specialSearch->msg( 'search-interwiki-custom' )->escaped() );
+ foreach ( $customLines as $line ) {
+ $parts = explode( ':', $line, 2 );
+ if ( count( $parts ) === 2 ) {
+ $this->customCaptions[$parts[0]] = $parts[1];
+ }
+ }
+ }
+}
diff --git a/www/wiki/includes/widget/search/SimpleSearchResultWidget.php b/www/wiki/includes/widget/search/SimpleSearchResultWidget.php
new file mode 100644
index 00000000..8190442a
--- /dev/null
+++ b/www/wiki/includes/widget/search/SimpleSearchResultWidget.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace MediaWiki\Widget\Search;
+
+use HtmlArmor;
+use MediaWiki\Linker\LinkRenderer;
+use SearchResult;
+use SpecialSearch;
+
+/**
+ * Renders a simple one-line result
+ */
+class SimpleSearchResultWidget implements SearchResultWidget {
+ /** @var SpecialSearch */
+ protected $specialSearch;
+ /** @var LinkRenderer */
+ protected $linkRenderer;
+
+ public function __construct( SpecialSearch $specialSearch, LinkRenderer $linkRenderer ) {
+ $this->specialSearch = $specialSearch;
+ $this->linkRenderer = $linkRenderer;
+ }
+
+ /**
+ * @param SearchResult $result The result to render
+ * @param string $terms Terms to be highlighted (@see SearchResult::getTextSnippet)
+ * @param int $position The result position, including offset
+ * @return string HTML
+ */
+ public function render( SearchResult $result, $terms, $position ) {
+ $title = $result->getTitle();
+ $titleSnippet = $result->getTitleSnippet();
+ if ( $titleSnippet ) {
+ $titleSnippet = new HtmlArmor( $titleSnippet );
+ } else {
+ $titleSnippet = null;
+ }
+
+ $link = $this->linkRenderer->makeLink( $title, $titleSnippet );
+
+ $redirectTitle = $result->getRedirectTitle();
+ $redirect = '';
+ if ( $redirectTitle !== null ) {
+ $redirectText = $result->getRedirectSnippet();
+ if ( $redirectText ) {
+ $redirectText = new HtmlArmor( $redirectText );
+ } else {
+ $redirectText = null;
+ }
+ $redirect =
+ "<span class='searchalttitle'>" .
+ $this->specialSearch->msg( 'search-redirect' )->rawParams(
+ $this->linkRenderer->makeLink( $redirectTitle, $redirectText )
+ )->text() .
+ "</span>";
+ }
+
+ return "<li>{$link} {$redirect}</li>";
+ }
+}